@@ -107,13 +107,30 @@ impl<C: HttpClient> FeatureFetcher<C> {
107107 info ! ( "Fetching feature: {}" , feature_ref. reference( ) ) ;
108108
109109 let result = async {
110- // Get the manifest
111- let manifest = self . get_manifest ( feature_ref) . await . map_err ( |e| match e {
112- crate :: errors:: DeaconError :: Feature ( f) => f,
113- _ => FeatureError :: Oci {
114- message : format ! ( "Get manifest error: {}" , e) ,
115- } ,
116- } ) ?;
110+ // Get the raw manifest body once, then derive both the typed
111+ // `Manifest` and its `sha256:`-prefixed digest from it (the
112+ // digest the reference CLI records in lockfile `resolved`/
113+ // `integrity` fields — see #264).
114+ let manifest_data =
115+ self . fetch_manifest_bytes ( feature_ref)
116+ . await
117+ . map_err ( |e| match e {
118+ crate :: errors:: DeaconError :: Feature ( f) => f,
119+ _ => FeatureError :: Oci {
120+ message : format ! ( "Get manifest error: {}" , e) ,
121+ } ,
122+ } ) ?;
123+ let manifest_digest =
124+ Self :: manifest_digest ( feature_ref, & manifest_data) . map_err ( |e| match e {
125+ crate :: errors:: DeaconError :: Feature ( f) => f,
126+ _ => FeatureError :: Oci {
127+ message : format ! ( "Manifest digest error: {}" , e) ,
128+ } ,
129+ } ) ?;
130+ let manifest: Manifest =
131+ serde_json:: from_slice ( & manifest_data) . map_err ( |e| FeatureError :: Parsing {
132+ message : format ! ( "Failed to parse manifest: {}" , e) ,
133+ } ) ?;
117134 debug ! ( "Got manifest with {} layers" , manifest. layers. len( ) ) ;
118135
119136 // For now, assume single tar layer (as per requirements)
@@ -134,7 +151,7 @@ impl<C: HttpClient> FeatureFetcher<C> {
134151 if is_cached {
135152 info ! ( "Found cached feature at: {}" , cached_dir. display( ) ) ;
136153 let feature = self
137- . load_cached_feature ( cached_dir, layer. digest . clone ( ) )
154+ . load_cached_feature ( cached_dir, layer. digest . clone ( ) , manifest_digest . clone ( ) )
138155 . await
139156 . map_err ( |e| match e {
140157 crate :: errors:: DeaconError :: Feature ( f) => f,
@@ -190,6 +207,7 @@ impl<C: HttpClient> FeatureFetcher<C> {
190207 path : extracted_dir,
191208 metadata,
192209 digest : layer. digest . clone ( ) ,
210+ manifest_digest,
193211 } ,
194212 is_cached,
195213 ) )
@@ -244,8 +262,11 @@ impl<C: HttpClient> FeatureFetcher<C> {
244262 Ok ( ( ) )
245263 }
246264
247- /// Get the OCI manifest for a feature
248- pub async fn get_manifest ( & self , feature_ref : & FeatureRef ) -> Result < Manifest > {
265+ /// Fetch the raw manifest body for a feature, retrying on transient errors.
266+ ///
267+ /// Shared by [`Self::get_manifest`], [`Self::get_manifest_with_digest`], and
268+ /// [`Self::fetch_feature`] so the manifest is only downloaded once per call site.
269+ async fn fetch_manifest_bytes ( & self , feature_ref : & FeatureRef ) -> Result < Bytes > {
249270 let manifest_url = format ! (
250271 "https://{}/v2/{}/manifests/{}" ,
251272 feature_ref. registry,
@@ -287,6 +308,39 @@ impl<C: HttpClient> FeatureFetcher<C> {
287308 )
288309 . await ?;
289310
311+ Ok ( manifest_data)
312+ }
313+
314+ /// Compute the `sha256:`-prefixed digest of a raw manifest body, verifying
315+ /// it against a digest-pinned reference (e.g. `feature@sha256:...`) if one
316+ /// was requested.
317+ fn manifest_digest ( feature_ref : & FeatureRef , manifest_data : & [ u8 ] ) -> Result < String > {
318+ let mut hasher = Sha256 :: new ( ) ;
319+ hasher. update ( manifest_data) ;
320+ let digest_hex = format ! ( "{:x}" , hasher. finalize( ) ) ;
321+
322+ // If the reference is digest-pinned (e.g. `feature@sha256:...`, surfaced
323+ // here as the tag), the returned manifest MUST hash to that digest.
324+ // Otherwise the registry could serve a different manifest than the one
325+ // the caller pinned.
326+ if let Some ( expected_hex) = feature_ref. tag ( ) . strip_prefix ( "sha256:" ) {
327+ if !digest_hex. eq_ignore_ascii_case ( expected_hex) {
328+ return Err ( FeatureError :: IntegrityMismatch {
329+ context : format ! ( "manifest for {}" , feature_ref. reference( ) ) ,
330+ expected : format ! ( "sha256:{}" , expected_hex) ,
331+ actual : format ! ( "sha256:{}" , digest_hex) ,
332+ }
333+ . into ( ) ) ;
334+ }
335+ }
336+
337+ Ok ( format ! ( "sha256:{}" , digest_hex) )
338+ }
339+
340+ /// Get the OCI manifest for a feature
341+ pub async fn get_manifest ( & self , feature_ref : & FeatureRef ) -> Result < Manifest > {
342+ let manifest_data = self . fetch_manifest_bytes ( feature_ref) . await ?;
343+
290344 let manifest: Manifest =
291345 serde_json:: from_slice ( & manifest_data) . map_err ( |e| FeatureError :: Parsing {
292346 message : format ! ( "Failed to parse manifest: {}" , e) ,
@@ -303,66 +357,15 @@ impl<C: HttpClient> FeatureFetcher<C> {
303357 & self ,
304358 feature_ref : & FeatureRef ,
305359 ) -> Result < ( serde_json:: Value , String ) > {
306- let manifest_url = format ! (
307- "https://{}/v2/{}/manifests/{}" ,
308- feature_ref. registry,
309- feature_ref. repository( ) ,
310- feature_ref. tag( )
311- ) ;
360+ let manifest_data = self . fetch_manifest_bytes ( feature_ref) . await ?;
312361
313- debug ! ( "Fetching manifest with digest from: {}" , manifest_url) ;
314-
315- let mut headers = HashMap :: new ( ) ;
316- headers. insert (
317- "Accept" . to_string ( ) ,
318- "application/vnd.oci.image.manifest.v1+json" . to_string ( ) ,
319- ) ;
320-
321- // Retry the manifest download with exponential backoff
322- let manifest_data = retry_async (
323- & self . retry_config ,
324- || {
325- let client = & self . client ;
326- let url = & manifest_url;
327- let headers = headers. clone ( ) ;
328- async move {
329- client. get_with_headers ( url, headers) . await . map_err ( |e| {
330- let error_msg = e. to_string ( ) ;
331- if error_msg. contains ( "Authentication failed" ) {
332- FeatureError :: Authentication {
333- message : format ! ( "Failed to authenticate for manifest: {}" , e) ,
334- }
335- } else {
336- FeatureError :: Download {
337- message : format ! ( "Failed to download manifest: {}" , e) ,
338- }
339- }
340- } )
341- }
342- } ,
343- classify_network_error,
344- )
345- . await ?;
346-
347- // Compute SHA256 digest of the raw manifest body
348- let mut hasher = Sha256 :: new ( ) ;
349- hasher. update ( & manifest_data) ;
350- let digest = format ! ( "{:x}" , hasher. finalize( ) ) ;
351-
352- // If the reference is digest-pinned (e.g. `feature@sha256:...`, surfaced
353- // here as the tag), the returned manifest MUST hash to that digest.
354- // Otherwise the registry could serve a different manifest than the one
355- // the caller pinned.
356- if let Some ( expected_hex) = feature_ref. tag ( ) . strip_prefix ( "sha256:" ) {
357- if !digest. eq_ignore_ascii_case ( expected_hex) {
358- return Err ( FeatureError :: IntegrityMismatch {
359- context : format ! ( "manifest for {}" , feature_ref. reference( ) ) ,
360- expected : format ! ( "sha256:{}" , expected_hex) ,
361- actual : format ! ( "sha256:{}" , digest) ,
362- }
363- . into ( ) ) ;
364- }
365- }
362+ // Historically this method returned the bare hex digest (no `sha256:`
363+ // prefix); preserve that for existing callers computing canonical IDs.
364+ let digest_with_prefix = Self :: manifest_digest ( feature_ref, & manifest_data) ?;
365+ let digest = digest_with_prefix
366+ . strip_prefix ( "sha256:" )
367+ . unwrap_or ( & digest_with_prefix)
368+ . to_string ( ) ;
366369
367370 // Parse the manifest JSON
368371 let manifest: serde_json:: Value =
@@ -619,6 +622,7 @@ impl<C: HttpClient> FeatureFetcher<C> {
619622 & self ,
620623 cached_dir : PathBuf ,
621624 digest : String ,
625+ manifest_digest : String ,
622626 ) -> Result < DownloadedFeature > {
623627 let metadata_path = cached_dir. join ( "devcontainer-feature.json" ) ;
624628 // parse_feature_metadata is sync and does file IO; offload to spawn_blocking
@@ -637,6 +641,7 @@ impl<C: HttpClient> FeatureFetcher<C> {
637641 path : cached_dir,
638642 metadata,
639643 digest,
644+ manifest_digest,
640645 } )
641646 }
642647
0 commit comments