@@ -272,6 +272,131 @@ void shouldPushConfigWithReference() throws IOException {
272272 assertEquals ("hello" , new String (content .readAllBytes (), StandardCharsets .UTF_8 ));
273273 }
274274
275+ @ Test
276+ void shouldRejectTamperedBlobOnRead () throws IOException {
277+ Path path = layoutPath .resolve ("tamperedBlob" );
278+ LayoutRef layoutRef = LayoutRef .parse ("%s" .formatted (path .toString ()));
279+ OCILayout ociLayout = OCILayout .Builder .builder ().defaults (path ).build ();
280+
281+ // Push a known blob into the layout
282+ Path blobFile = blobDir .resolve ("blob.txt" );
283+ Files .writeString (blobFile , "hello" );
284+ String digest = SupportedAlgorithm .getDefault ().digest (blobFile );
285+ ociLayout .pushBlob (layoutRef .withDigest (digest ), blobFile );
286+
287+ // Untampered blob reads back correctly
288+ assertEquals ("hello" , new String (ociLayout .getBlob (layoutRef .withDigest (digest )), StandardCharsets .UTF_8 ));
289+
290+ // Tamper the blob on disk at its digest-derived path (simulating a co-tenant / shared FS)
291+ String hex = digest .substring (digest .indexOf (':' ) + 1 );
292+ Path onDisk = path .resolve (Const .OCI_LAYOUT_BLOBS ).resolve ("sha256" ).resolve (hex );
293+ Files .writeString (onDisk , "evil" );
294+
295+ // The tampered content must be rejected rather than served as authentic
296+ LayoutRef tamperedRef = layoutRef .withDigest (digest );
297+ OrasException viaGetBlob = assertThrows (OrasException .class , () -> ociLayout .getBlob (tamperedRef ));
298+ assertTrue (
299+ viaGetBlob .getMessage ().contains ("integrity check failed" ),
300+ "Unexpected message: " + viaGetBlob .getMessage ());
301+
302+ // Every blob read path (getBlob, fetchBlob(ref), fetchBlob(ref, path)) is protected
303+ assertThrows (OrasException .class , () -> ociLayout .fetchBlob (tamperedRef ));
304+ Path out = extractDir .resolve ("out.bin" );
305+ assertThrows (OrasException .class , () -> ociLayout .fetchBlob (tamperedRef , out ));
306+ }
307+
308+ @ Test
309+ void shouldReadUntamperedBlobAfterIntegrityCheck () throws IOException {
310+ Path path = layoutPath .resolve ("untamperedBlob" );
311+ LayoutRef layoutRef = LayoutRef .parse ("%s" .formatted (path .toString ()));
312+ OCILayout ociLayout = OCILayout .Builder .builder ().defaults (path ).build ();
313+
314+ Path blobFile = blobDir .resolve ("intact.txt" );
315+ Files .writeString (blobFile , "intact-content" );
316+ String digest = SupportedAlgorithm .getDefault ().digest (blobFile );
317+ ociLayout .pushBlob (layoutRef .withDigest (digest ), blobFile );
318+
319+ // The integrity check must not break a legitimate, untouched blob on any read path
320+ LayoutRef ref = layoutRef .withDigest (digest );
321+ assertEquals ("intact-content" , new String (ociLayout .getBlob (ref ), StandardCharsets .UTF_8 ));
322+ try (InputStream is = ociLayout .fetchBlob (ref )) {
323+ assertEquals ("intact-content" , new String (is .readAllBytes (), StandardCharsets .UTF_8 ));
324+ }
325+ Path out = extractDir .resolve ("intact-out.txt" );
326+ ociLayout .fetchBlob (ref , out );
327+ assertEquals ("intact-content" , Files .readString (out ));
328+ }
329+
330+ @ Test
331+ void shouldRejectTamperedLayerOnPullArtifact () throws IOException {
332+ Path ociLayoutPath = layoutPath .resolve ("tamperedLayerPull" );
333+ Path artifactPath = blobDir .resolve ("artifact.txt" );
334+ Files .writeString (artifactPath , "artifact-content" );
335+
336+ LayoutRef layoutRef = LayoutRef .parse ("%s:latest" .formatted (ociLayoutPath .toString ()));
337+ OCILayout ociLayout =
338+ OCILayout .Builder .builder ().defaults (ociLayoutPath ).build ();
339+ Annotations annotations = Annotations .ofManifest (Map .of (Const .ANNOTATION_CREATED , Const .currentTimestamp ()));
340+ ociLayout .pushArtifact (
341+ layoutRef , ArtifactType .from ("foo/bar" ), annotations , LocalPath .of (artifactPath , "text/plain" ));
342+
343+ // Tamper the layer blob on disk at its digest-derived path
344+ String digest = SupportedAlgorithm .SHA256 .digest (artifactPath );
345+ String hex = digest .substring (digest .indexOf (':' ) + 1 );
346+ Path onDisk =
347+ ociLayoutPath .resolve (Const .OCI_LAYOUT_BLOBS ).resolve ("sha256" ).resolve (hex );
348+ Files .writeString (onDisk , "tampered-artifact" );
349+
350+ Path target = extractDir .resolve ("pull-target" );
351+ Files .createDirectories (target );
352+ OrasException ex = assertThrows (OrasException .class , () -> ociLayout .pullArtifact (layoutRef , target , true ));
353+ assertTrue (ex .getMessage ().contains ("integrity check failed" ), "Unexpected message: " + ex .getMessage ());
354+ }
355+
356+ @ Test
357+ void verifyBlobDigestRejectsNonContentAddressedPath () throws IOException {
358+ Path ociLayoutPath = layoutPath .resolve ("nonCasPath" );
359+ OCILayout ociLayout =
360+ OCILayout .Builder .builder ().defaults (ociLayoutPath ).build ();
361+
362+ // An existing file whose "<parentDir>:<fileName>" is not a valid digest ("notanalgo:somefile")
363+ Path notContentAddressed = blobDir .resolve ("notanalgo" ).resolve ("somefile" );
364+ Files .createDirectories (notContentAddressed .getParent ());
365+ Files .writeString (notContentAddressed , "data" );
366+
367+ OrasException ex = assertThrows (OrasException .class , () -> ociLayout .verifyBlobDigest (notContentAddressed ));
368+ assertTrue (
369+ ex .getMessage ().contains ("not stored at a content-addressed path" ),
370+ "Unexpected message: " + ex .getMessage ());
371+ }
372+
373+ @ Test
374+ void verifyBlobDigestRejectsPathWithoutResolvableDigest () {
375+ Path ociLayoutPath = layoutPath .resolve ("noDigestPath" );
376+ OCILayout ociLayout =
377+ OCILayout .Builder .builder ().defaults (ociLayoutPath ).build ();
378+
379+ // The filesystem root exists but has no file name (and no parent), so no digest can be derived
380+ Path root = layoutPath .getRoot ();
381+ assertNotNull (root );
382+ assertNull (root .getFileName ());
383+ OrasException ex = assertThrows (OrasException .class , () -> ociLayout .verifyBlobDigest (root ));
384+ assertTrue (
385+ ex .getMessage ().contains ("Cannot resolve expected digest" ), "Unexpected message: " + ex .getMessage ());
386+ }
387+
388+ @ Test
389+ void verifyBlobDigestIgnoresMissingBlob () {
390+ Path ociLayoutPath = layoutPath .resolve ("missingBlob" );
391+ OCILayout ociLayout =
392+ OCILayout .Builder .builder ().defaults (ociLayoutPath ).build ();
393+
394+ // A missing blob is not the integrity check's concern: it returns without throwing and lets
395+ // the caller (Files.newInputStream / Files.copy) surface the absence.
396+ Path missing = blobDir .resolve ("does-not-exist" );
397+ assertDoesNotThrow (() -> ociLayout .verifyBlobDigest (missing ));
398+ }
399+
275400 @ Test
276401 void shouldPushIndexWithTag () {
277402 Path path = layoutPath .resolve ("shouldPushIndexWithTag" );
0 commit comments