Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,16 @@ void processAfter(CdsReadEventContext context, List<CdsData> data) {
InputStream content = attachment.getContent();
if (nonNull(attachment.getContentId())) {
verifyStatus(path, attachment);
CdsEntity attachmentEntity = path.target().entity();
Supplier<InputStream> supplier =
nonNull(content)
? () -> content
: () -> attachmentService.readAttachment(attachment.getContentId());
return new LazyProxyInputStream(supplier, statusValidator, attachment.getStatus());
// Enforce the status/freshness policy before bytes are served for every read shape,
// including keyed reads that stream content (content-only reads are additionally
// guarded eagerly above via verifyStatus).
return new LazyProxyInputStream(
supplier, () -> enforceReadPolicy(attachmentEntity, attachment));
} else {
return value;
}
Expand All @@ -133,23 +138,32 @@ void processAfter(CdsReadEventContext context, List<CdsData> data) {

private void verifyStatus(Path path, Attachments attachment) {
if (areKeysEmpty(path.target().keys())) {
String currentStatus = attachment.getStatus();
enforceReadPolicy(path.target().entity(), attachment);
}
}

/**
* Enforces the malware scan status and freshness policy for the given attachment: triggers a
* rescan when the attachment is unscanned, scanning, or a stale clean, and validates that the
* (possibly updated) status permits serving the content.
*/
private void enforceReadPolicy(CdsEntity entity, Attachments attachment) {
String currentStatus = attachment.getStatus();
logger.debug(
"In verify status for content id {} and status {}",
attachment.getContentId(),
currentStatus);
if (scannerAvailable && needsScan(currentStatus, attachment.getScannedAt())) {
if (StatusCode.CLEAN.equals(currentStatus)) {
transitionToScanning(entity, attachment);
}
logger.debug(
"In verify status for content id {} and status {}",
"Scanning content with ID {} for malware, has current status {}",
attachment.getContentId(),
currentStatus);
if (scannerAvailable && needsScan(currentStatus, attachment.getScannedAt())) {
if (StatusCode.CLEAN.equals(currentStatus)) {
transitionToScanning(path.target().entity(), attachment);
}
logger.debug(
"Scanning content with ID {} for malware, has current status {}",
attachment.getContentId(),
currentStatus);
scanExecutor.scanAsync(path.target().entity(), attachment.getContentId());
}
statusValidator.verifyStatus(attachment.getStatus());
scanExecutor.scanAsync(entity, attachment.getContentId());
}
statusValidator.verifyStatus(attachment.getStatus());
}

private boolean needsScan(String status, Instant scannedAt) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.handler.applicationservice.readhelper;

/**
* Guard invoked by {@link LazyProxyInputStream} right before attachment content bytes are served.
*
* <p>It enforces the malware scan status and freshness policy (rescan-on-download) for the content
* that is about to be read, regardless of the read shape (content-only {@code $value} reads as well
* as keyed reads that stream the content). Implementations throw an {@link
* AttachmentStatusException} to block the download when the content is not clean or must be
* rescanned first.
*/
@FunctionalInterface
public interface ContentReadGuard {

/**
* Enforces the status and freshness policy before content is served.
*
* @throws AttachmentStatusException if the content must not be served (e.g. not clean or a rescan
* has been triggered)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Best Practice: The Javadoc @throws tag references AttachmentStatusException without importing or qualifying it, so the cross-reference is broken in generated Javadoc. Use a fully-qualified name or add the import to make the link resolvable.

Suggested change
* has been triggered)
* @throws com.sap.cds.feature.attachments.handler.applicationservice.readhelper.AttachmentStatusException if the content must not be served (e.g. not clean or a rescan
* has been triggered)

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

*/
void verify();
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,20 @@
/**
* The class {@link LazyProxyInputStream} is a lazy proxy for an {@link InputStream}. The class is
* used to create a proxy for an {@link InputStream} that is not yet available. Before the {@link
* InputStream} is accessed, the status of the attachment is validated.
* InputStream} is accessed, the {@link ContentReadGuard} enforces the attachment status and
* freshness policy.
*/
public class LazyProxyInputStream extends InputStream {
private static final Logger logger = LoggerFactory.getLogger(LazyProxyInputStream.class);

private final Supplier<InputStream> inputStreamSupplier;
private final AttachmentStatusValidator attachmentStatusValidator;
private final String status;
private final ContentReadGuard contentReadGuard;
private InputStream delegate;

public LazyProxyInputStream(
Supplier<InputStream> inputStreamSupplier,
AttachmentStatusValidator attachmentStatusValidator,
String status) {
Supplier<InputStream> inputStreamSupplier, ContentReadGuard contentReadGuard) {
this.inputStreamSupplier = inputStreamSupplier;
this.attachmentStatusValidator = attachmentStatusValidator;
this.status = status;
this.contentReadGuard = contentReadGuard;
}

@Override
Expand Down Expand Up @@ -58,7 +55,7 @@ public void close() throws IOException {
}

private InputStream getDelegate() {
attachmentStatusValidator.verifyStatus(status);
contentReadGuard.verify();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: contentReadGuard.verify() is invoked on every read call, not just the first.

getDelegate() calls contentReadGuard.verify() unconditionally each time, while the delegate-initialization guard (delegate == null) is right below it. For a single content download this means enforceReadPolicy runs once per read()/read(byte[]) call, re-executing the staleness check, potentially calling transitionToScanning and scanAsync again on each chunk. The guard should only fire once — before the delegate is initialized.

Suggested change
contentReadGuard.verify();
private InputStream getDelegate() {
if (delegate == null) {
contentReadGuard.verify();
logger.debug("Creating delegate input stream");
delegate = inputStreamSupplier.get();
}
return delegate;
}

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful


if (delegate == null) {
logger.debug("Creating delegate input stream");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,55 @@ void statusNotVerifiedIfNotOnlyContentIsRequested() {
verifyNoInteractions(attachmentStatusValidator);
}

@Test
void keyedStaleCleanAttachmentIsRescannedWhenContentIsRead() throws IOException {
mockEventContext(Attachment_.CDS_NAME, mock(CqnSelect.class));
var attachment = Attachments.create();
attachment.setId(UUID.randomUUID().toString());
attachment.setContentId("some ID");
attachment.setContent(mock(InputStream.class));
attachment.setStatus(StatusCode.CLEAN);
attachment.setScannedAt(Instant.now().minus(4, ChronoUnit.DAYS));
doThrow(AttachmentStatusException.class)
.when(attachmentStatusValidator)
.verifyStatus(StatusCode.SCANNING);

cut.processAfter(readEventContext, List.of(attachment));

// keyed read: nothing is scanned or validated eagerly
verifyNoInteractions(asyncMalwareScanExecutor);
verifyNoInteractions(persistenceService);

// reading the content enforces the freshness policy: rescan is triggered and download blocked
var content = attachment.getContent();
assertThat(content).isInstanceOf(LazyProxyInputStream.class);
assertThrows(AttachmentStatusException.class, content::readAllBytes);
verify(persistenceService).run(any(com.sap.cds.ql.cqn.CqnUpdate.class));
verify(asyncMalwareScanExecutor)
.scanAsync(readEventContext.getTarget(), attachment.getContentId());
assertThat(attachment.getStatus()).isEqualTo(StatusCode.SCANNING);
}

@Test
void keyedInfectedAttachmentIsBlockedWhenContentIsRead() {
mockEventContext(Attachment_.CDS_NAME, mock(CqnSelect.class));
var attachment = Attachments.create();
attachment.setId(UUID.randomUUID().toString());
attachment.setContentId("some ID");
attachment.setContent(mock(InputStream.class));
attachment.setStatus(StatusCode.INFECTED);
doThrow(AttachmentStatusException.class)
.when(attachmentStatusValidator)
.verifyStatus(StatusCode.INFECTED);

cut.processAfter(readEventContext, List.of(attachment));

var content = attachment.getContent();
assertThat(content).isInstanceOf(LazyProxyInputStream.class);
verifyNoInteractions(attachmentService);
assertThrows(AttachmentStatusException.class, content::readAllBytes);
}

@Test
void emptyContentIdAndEmptyContentReturnNullContent() {
mockEventContext(Attachment_.CDS_NAME, mock(CqnSelect.class));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,40 +14,34 @@
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode;
import com.sap.cds.feature.attachments.service.AttachmentService;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

class LazyProxyInputStreamTest {

private LazyProxyInputStream cut;
private InputStream inputStream;
private AttachmentService attachmentService;
private AttachmentStatusValidator attachmentStatusValidator;
private ContentReadGuard contentReadGuard;

@BeforeEach
void setup() {
inputStream = mock(InputStream.class);
attachmentService = mock(AttachmentService.class);
attachmentStatusValidator = mock(AttachmentStatusValidator.class);
contentReadGuard = mock(ContentReadGuard.class);
when(attachmentService.readAttachment(any())).thenReturn(inputStream);
cut =
new LazyProxyInputStream(
() -> attachmentService.readAttachment(any()),
attachmentStatusValidator,
StatusCode.CLEAN);
cut = new LazyProxyInputStream(() -> attachmentService.readAttachment(any()), contentReadGuard);
}

@Test
void noMethodCallNoStreamAccess() {
verifyNoInteractions(attachmentService);
verifyNoInteractions(contentReadGuard);
}

@Test
Expand Down Expand Up @@ -115,26 +109,24 @@ void closeCallsInputStream() throws IOException {
verify(attachmentService).readAttachment(any());
}

@ParameterizedTest
@ValueSource(strings = {StatusCode.UNSCANNED, StatusCode.INFECTED})
void exceptionIfWrongStatus(String status) {
doThrow(AttachmentStatusException.class).when(attachmentStatusValidator).verifyStatus(status);
@Test
void guardIsVerifiedBeforeContentIsServed() throws IOException {
cut.read();

verify(contentReadGuard).verify();
verify(attachmentService).readAttachment(any());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: supplierOnlyCalledOnce does not verify that the guard is also invoked only once across two reads. With the current implementation contentReadGuard.verify() fires on every read() call (once per chunk), masking the repeated-guard-invocation bug. A verify(contentReadGuard, times(1)).verify() assertion should be added here.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

}

cut =
new LazyProxyInputStream(
() -> attachmentService.readAttachment(any()), attachmentStatusValidator, status);
@Test
void exceptionIfGuardBlocksRead() {
doThrow(AttachmentStatusException.class).when(contentReadGuard).verify();

assertThrows(AttachmentStatusException.class, () -> cut.read());
verifyNoInteractions(attachmentService);
}

@Test
void noExceptionIfCorrectStatus() {
cut =
new LazyProxyInputStream(
() -> attachmentService.readAttachment(any()),
attachmentStatusValidator,
StatusCode.CLEAN);

void noExceptionIfGuardPasses() {
assertDoesNotThrow(() -> cut.read());
}
}
Loading