2222
2323import io .micrometer .core .instrument .MeterRegistry ;
2424import io .micrometer .core .instrument .simple .SimpleMeterRegistry ;
25+ import java .io .FilterInputStream ;
2526import java .io .IOException ;
2627import java .io .InputStream ;
2728import java .net .URI ;
3132import java .nio .file .Path ;
3233import java .nio .file .StandardCopyOption ;
3334import java .security .MessageDigest ;
35+ import java .security .NoSuchAlgorithmException ;
3436import java .util .ArrayList ;
3537import java .util .HashMap ;
38+ import java .util .HexFormat ;
3639import java .util .List ;
3740import java .util .Map ;
3841import java .util .Objects ;
@@ -1160,24 +1163,11 @@ public byte[] getBlob(ContainerRef containerRef) {
11601163 }
11611164
11621165 private byte [] getBlobDirect (ContainerRef containerRef ) {
1163- ContainerRef ref = containerRef .forRegistry (this ).checkBlocked (this );
1164- if (ref .isInsecure (this ) && !this .isInsecure ()) {
1165- return copyForNewTransport (ref .getRegistry (), true ).getBlobDirect (ref );
1166- }
1167- if (!ref .isInsecure (this ) && this .isInsecure ()) {
1168- return copyForNewTransport (ref .getRegistry (), false ).getBlobDirect (ref );
1166+ try (InputStream is = fetchBlobDirect (containerRef )) {
1167+ return is .readAllBytes ();
1168+ } catch (IOException e ) {
1169+ throw new OrasException ("Failed to get blob" , e );
11691170 }
1170- URI uri = URI .create ("%s://%s" .formatted (getScheme (), ref .getBlobsPath (this )));
1171- HttpClient .ResponseWrapper <String > response = client .get (
1172- uri ,
1173- Map .of (Const .ACCEPT_HEADER , Const .APPLICATION_OCTET_STREAM_HEADER_VALUE ),
1174- Scopes .of (ref ),
1175- authProvider );
1176- logResponse (response );
1177- handleError (response );
1178- byte [] data = response .response ().getBytes (StandardCharsets .UTF_8 );
1179- validateDockerContentDigest (response , data );
1180- return data ;
11811171 }
11821172
11831173 @ Override
@@ -1207,7 +1197,7 @@ private void fetchBlobDirect(ContainerRef containerRef, Path path) {
12071197 authProvider );
12081198 logResponse (response );
12091199 handleError (response );
1210- validateDockerContentDigest ( response , path );
1200+ verifyBlobDigest ( ref , path , response . headers () );
12111201 }
12121202
12131203 @ Override
@@ -1231,8 +1221,30 @@ private InputStream fetchBlobDirect(ContainerRef containerRef) {
12311221 authProvider );
12321222 logResponse (response );
12331223 handleError (response );
1234- validateDockerContentDigest (response );
1235- return response .response ();
1224+ // Verify the content digest incrementally
1225+ List <String > expected = expectedBlobDigests (ref , response .headers ());
1226+ if (expected .isEmpty ()) {
1227+ return response .response ();
1228+ }
1229+ return new DigestVerifyingInputStream (response .response (), expected );
1230+ }
1231+
1232+ /**
1233+ * Collect the digests a downloaded blob must match: the caller-pinned digest (when it is a supported
1234+ * digest and not a plain tag) and the server-advertised {@code Docker-Content-Digest} header (when
1235+ * present). Duplicates are collapsed so the content is hashed once per distinct algorithm.
1236+ */
1237+ private static List <String > expectedBlobDigests (ContainerRef ref , Map <String , String > headers ) {
1238+ List <String > expected = new ArrayList <>(2 );
1239+ String pinned = ref .getDigest ();
1240+ if (pinned != null && SupportedAlgorithm .isSupported (pinned )) {
1241+ expected .add (pinned );
1242+ }
1243+ String header = headers .get (Const .DOCKER_CONTENT_DIGEST_HEADER .toLowerCase ());
1244+ if (header != null && !expected .contains (header )) {
1245+ expected .add (header );
1246+ }
1247+ return expected ;
12361248 }
12371249
12381250 @ Override
@@ -1304,16 +1316,16 @@ public Descriptor getDescriptor(ContainerRef containerRef) {
13041316 HttpClient .ResponseWrapper <String > response = getManifestResponse (containerRef );
13051317 logResponse (response );
13061318 handleError (response );
1319+ String json = response .response ();
1320+ // When the reference is pinned to a digest, verify the returned manifest/index bytes against it
1321+ verifyPinnedDigest (containerRef , json .getBytes (StandardCharsets .UTF_8 ));
13071322 String size = response .headers ().get (Const .CONTENT_LENGTH_HEADER .toLowerCase ());
13081323 String contentType = response .headers ().get (Const .CONTENT_TYPE_HEADER .toLowerCase ());
13091324 return Descriptor .of (
13101325 validateDockerContentDigest (response ),
1311- Long .parseLong (
1312- size == null
1313- ? String .valueOf (response .response ().length ())
1314- : size ),
1326+ Long .parseLong (size == null ? String .valueOf (json .length ()) : size ),
13151327 contentType )
1316- .withJson (response . response () );
1328+ .withJson (json );
13171329 }
13181330
13191331 @ Override
@@ -1407,30 +1419,37 @@ private HttpClient.ResponseWrapper<String> getManifestResponseDirect(ContainerRe
14071419 return client .get (uri , Map .of ("Accept" , Const .MANIFEST_ACCEPT_TYPE ), Scopes .of (ref ), authProvider );
14081420 }
14091421
1410- private void validateDockerContentDigest (HttpClient .ResponseWrapper <String > response , byte [] data ) {
1411- String digest = response .headers ().get (Const .DOCKER_CONTENT_DIGEST_HEADER .toLowerCase ());
1412- // This might happen when blob are hosted other storage.
1413- // We need a way to propagate the headers like scoped.
1414- // For now just skip validation
1415- if (digest == null ) {
1416- LOG .debug ("Docker-Content-Digest header not found in response. Skipping validation." );
1422+ /**
1423+ * Verify in-memory content against the digest pinned in the reference. No-op when the reference is
1424+ * not digest-pinned (e.g. pulled by tag), since there is then no trusted value to check against.
1425+ * @param ref The reference the content was requested for
1426+ * @param content The bytes returned by the registry
1427+ */
1428+ private void verifyPinnedDigest (ContainerRef ref , byte [] content ) {
1429+ String digest = ref .getDigest ();
1430+ if (digest == null || !SupportedAlgorithm .isSupported (digest )) {
14171431 return ;
14181432 }
1419- String computedDigest = SupportedAlgorithm .fromDigest (digest ).digest (data );
1420- ensureDigest (digest , computedDigest );
1433+ ensureDigest (digest , SupportedAlgorithm .fromDigest (digest ).digest (content ));
14211434 }
14221435
1423- private void validateDockerContentDigest (HttpClient .ResponseWrapper <Path > response , Path path ) {
1424- String digest = response .headers ().get (Const .DOCKER_CONTENT_DIGEST_HEADER .toLowerCase ());
1425- // This might happen when blob are hosted other storage.
1426- // We need a way to propagate the headers like scoped.
1427- // For now just skip validation
1428- if (digest == null ) {
1429- LOG .debug ("Docker-Content-Digest header not found in response. Skipping validation." );
1430- return ;
1436+ /**
1437+ * Verify a downloaded blob against every digest that applies (pinned or from header)
1438+ * @param ref The reference the blob was requested for
1439+ * @param content The path the registry response was written to
1440+ * @param headers The response headers
1441+ */
1442+ private void verifyBlobDigest (ContainerRef ref , Path content , Map <String , String > headers ) {
1443+ String pinned = ref .getDigest ();
1444+ String verified = null ;
1445+ if (pinned != null && SupportedAlgorithm .isSupported (pinned )) {
1446+ ensureDigest (pinned , SupportedAlgorithm .fromDigest (pinned ).digest (content ));
1447+ verified = pinned ;
1448+ }
1449+ String header = headers .get (Const .DOCKER_CONTENT_DIGEST_HEADER .toLowerCase ());
1450+ if (header != null && !header .equals (verified )) {
1451+ ensureDigest (header , SupportedAlgorithm .fromDigest (header ).digest (content ));
14311452 }
1432- String computedDigest = SupportedAlgorithm .fromDigest (digest ).digest (path );
1433- ensureDigest (digest , computedDigest );
14341453 }
14351454
14361455 private @ Nullable String validateDockerContentDigest (HttpClient .ResponseWrapper <?> response ) {
@@ -1439,9 +1458,7 @@ private void validateDockerContentDigest(HttpClient.ResponseWrapper<Path> respon
14391458
14401459 private @ Nullable String validateDockerContentDigest (Map <String , String > headers ) {
14411460 String digest = headers .get (Const .DOCKER_CONTENT_DIGEST_HEADER .toLowerCase ());
1442- // This might happen when blob are hosted other storage.
1443- // We need a way to propagate the headers like scoped.
1444- // For now just skip validation
1461+ // Not mandatory, but require validation if present
14451462 if (digest == null ) {
14461463 LOG .debug ("Docker-Content-Digest header not found in response. Skipping validation." );
14471464 return null ;
@@ -2001,4 +2018,61 @@ public Registry build() {
20012018 return registry .build ();
20022019 }
20032020 }
2021+
2022+ /**
2023+ * A stream that verifies the content digest incrementally as it is read, without buffering to a
2024+ * temporary file. It updates one {@link MessageDigest} per distinct algorithm as bytes pass through
2025+ * and, at end-of-stream
2026+ */
2027+ private static final class DigestVerifyingInputStream extends FilterInputStream {
2028+
2029+ private final List <String > expectedDigests ;
2030+ private final Map <String , MessageDigest > digestsByPrefix = new HashMap <>();
2031+ private boolean verified ;
2032+
2033+ private DigestVerifyingInputStream (InputStream in , List <String > expectedDigests ) {
2034+ super (in );
2035+ this .expectedDigests = expectedDigests ;
2036+ for (String expected : expectedDigests ) {
2037+ SupportedAlgorithm algorithm = SupportedAlgorithm .fromDigest (expected );
2038+ digestsByPrefix .computeIfAbsent (algorithm .getPrefix (), p -> {
2039+ try {
2040+ return MessageDigest .getInstance (algorithm .getAlgorithmName ());
2041+ } catch (NoSuchAlgorithmException e ) {
2042+ throw new OrasException ("Unsupported digest algorithm: " + algorithm .getAlgorithmName (), e );
2043+ }
2044+ });
2045+ }
2046+ }
2047+
2048+ @ Override
2049+ public int read (byte [] buffer , int off , int len ) throws IOException {
2050+ int read = super .read (buffer , off , len );
2051+ if (read > 0 ) {
2052+ for (MessageDigest digest : digestsByPrefix .values ()) {
2053+ digest .update (buffer , off , read );
2054+ }
2055+ } else if (read < 0 ) {
2056+ verify ();
2057+ }
2058+ return read ;
2059+ }
2060+
2061+ private void verify () {
2062+ if (verified ) {
2063+ return ;
2064+ }
2065+ verified = true ;
2066+ Map <String , String > actualByPrefix = new HashMap <>();
2067+ digestsByPrefix .forEach ((prefix , digest ) ->
2068+ actualByPrefix .put (prefix , prefix + ":" + HexFormat .of ().formatHex (digest .digest ())));
2069+ for (String expected : expectedDigests ) {
2070+ String prefix = SupportedAlgorithm .fromDigest (expected ).getPrefix ();
2071+ String actual = actualByPrefix .get (prefix );
2072+ if (!expected .equals (actual )) {
2073+ throw new OrasException ("Digest mismatch: %s != %s" .formatted (expected , actual ));
2074+ }
2075+ }
2076+ }
2077+ }
20042078}
0 commit comments