Skip to content

Commit 9d44a6f

Browse files
pofallonclaude
andauthored
fix(oci): write manifest digest to lockfiles (#268)
fetch_feature stored the OCI layer/blob digest into DownloadedFeature.digest, which both lockfile writers (up's feature-build path and `upgrade`) used to populate LockfileFeature.resolved/integrity. The reference @devcontainers/cli expects the manifest digest there, so `devcontainer features resolve-dependencies` rejected Deacon-generated lockfiles (#264). Add a manifest_digest field to DownloadedFeature, computed once from the raw manifest body already fetched in fetch_feature (shared via a new fetch_manifest_bytes helper so get_manifest/get_manifest_with_digest/ fetch_feature only hit the network once), and populate it on both the cache-hit and cache-miss return paths. Point both lockfile writers at the new field; the existing digest field keeps its role as the layer cache key and blob-integrity check. Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 5756906 commit 9d44a6f

6 files changed

Lines changed: 169 additions & 74 deletions

File tree

crates/core/src/oci/fetcher.rs

Lines changed: 74 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -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

crates/core/src/oci/types.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,15 @@ pub struct DownloadedFeature {
9494
pub path: PathBuf,
9595
/// Feature metadata
9696
pub metadata: FeatureMetadata,
97-
/// Feature digest for caching
97+
/// Layer (blob) digest, used as the on-disk cache key and for blob
98+
/// integrity verification. This is NOT the digest the reference CLI
99+
/// expects in lockfile `resolved`/`integrity` fields — use
100+
/// `manifest_digest` for that.
98101
pub digest: String,
102+
/// `sha256:`-prefixed digest of the OCI manifest body. This is the value
103+
/// the reference `@devcontainers/cli` records in lockfile `resolved`
104+
/// (`{registry}/{repository}@{manifest_digest}`) and `integrity` fields.
105+
pub manifest_digest: String,
99106
}
100107

101108
/// Downloaded and extracted template data

crates/core/tests/integration_oci.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,16 @@ async fn test_oci_feature_fetch_with_mock_client() {
108108
let manifest_url = "https://ghcr.io/v2/test/feature/manifests/1.0.0";
109109
let blob_url = format!("https://ghcr.io/v2/test/feature/blobs/{}", layer_digest);
110110

111+
// Expected manifest digest: sha256 of the exact manifest body served over
112+
// the wire — this is what the lockfile's `resolved`/`integrity` fields
113+
// must carry (#264), distinct from the layer/blob digest above.
114+
let expected_manifest_digest = {
115+
use sha2::{Digest, Sha256};
116+
let mut hasher = Sha256::new();
117+
hasher.update(manifest_json.as_bytes());
118+
format!("sha256:{:x}", hasher.finalize())
119+
};
120+
111121
fetcher
112122
.client()
113123
.add_response(manifest_url.to_string(), Bytes::from(manifest_json))
@@ -139,10 +149,22 @@ async fn test_oci_feature_fetch_with_mock_client() {
139149
);
140150
assert!(downloaded_feature.path.join("install.sh").exists());
141151

152+
// #264 guard: manifest_digest is the manifest body digest, NOT the layer
153+
// digest, and never silently regresses to it.
154+
assert_eq!(downloaded_feature.manifest_digest, expected_manifest_digest);
155+
assert_ne!(
156+
downloaded_feature.manifest_digest,
157+
downloaded_feature.digest
158+
);
159+
assert_ne!(downloaded_feature.manifest_digest, layer_digest);
160+
142161
// Test caching - fetch the same feature again
143162
let cached_feature = fetcher.fetch_feature(&feature_ref).await.unwrap();
144163
assert_eq!(cached_feature.metadata.id, "test-feature");
145164
assert_eq!(cached_feature.path, downloaded_feature.path);
165+
// The cache-hit branch must populate manifest_digest identically to the
166+
// cache-miss branch (both construct DownloadedFeature independently).
167+
assert_eq!(cached_feature.manifest_digest, expected_manifest_digest);
146168
}
147169

148170
/// Security regression: a registry that serves blob bytes which do not match
@@ -250,6 +272,7 @@ echo "Installation completed"
250272
path: feature_dir,
251273
metadata,
252274
digest: "test-digest".to_string(),
275+
manifest_digest: "sha256:test-manifest-digest".to_string(),
253276
};
254277

255278
// Create fetcher and test installation
@@ -289,6 +312,7 @@ async fn test_oci_feature_install_no_script() {
289312
path: feature_dir,
290313
metadata,
291314
digest: "test-digest".to_string(),
315+
manifest_digest: "sha256:test-manifest-digest".to_string(),
292316
};
293317

294318
// Create fetcher and test installation

crates/core/tests/integration_redaction.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1159,6 +1159,7 @@ async fn test_oci_install_env_secret_not_in_logs() {
11591159
path: feature_dir,
11601160
metadata,
11611161
digest: "test-digest".to_string(),
1162+
manifest_digest: "sha256:test-manifest-digest".to_string(),
11621163
};
11631164

11641165
let client = ReqwestClient::new().unwrap();

crates/deacon/src/commands/up/features_build.rs

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,8 @@ async fn resolve_and_stage_features(
545545
DownloadedFeature {
546546
path: canonical_path.clone(),
547547
metadata,
548-
digest,
548+
digest: digest.clone(),
549+
manifest_digest: digest,
549550
},
550551
);
551552

@@ -657,7 +658,8 @@ async fn resolve_and_stage_features(
657658
DownloadedFeature {
658659
path: canonical_path.clone(),
659660
metadata,
660-
digest,
661+
digest: digest.clone(),
662+
manifest_digest: digest,
661663
},
662664
);
663665
let dep_ref = FeatureRef::new(
@@ -944,7 +946,7 @@ fn build_lockfile_from_features(
944946
let entry = LockfileFeature::from_resolved(
945947
&feature_ref.registry,
946948
&feature_ref.repository(),
947-
&downloaded.digest,
949+
&downloaded.manifest_digest,
948950
version,
949951
depends_on,
950952
);
@@ -1033,6 +1035,7 @@ mod lockfile_assembly_tests {
10331035
..FeatureMetadata::default()
10341036
},
10351037
digest: digest.to_string(),
1038+
manifest_digest: digest.to_string(),
10361039
}
10371040
}
10381041

@@ -1050,6 +1053,7 @@ mod lockfile_assembly_tests {
10501053
..FeatureMetadata::default()
10511054
},
10521055
digest: digest.to_string(),
1056+
manifest_digest: digest.to_string(),
10531057
}
10541058
}
10551059

@@ -1101,6 +1105,59 @@ mod lockfile_assembly_tests {
11011105
assert!(entry.depends_on.is_none());
11021106
}
11031107

1108+
/// #264 guard: the lockfile writer must record the OCI *manifest* digest,
1109+
/// not the layer/blob digest used for on-disk caching — even when they
1110+
/// differ, as they always do in practice.
1111+
#[test]
1112+
fn build_lockfile_uses_manifest_digest_not_layer_digest() {
1113+
let feature_ref = FeatureRef::new(
1114+
"ghcr.io".to_string(),
1115+
"devcontainers".to_string(),
1116+
"python".to_string(),
1117+
Some("1".to_string()),
1118+
);
1119+
let canonical = "ghcr.io/devcontainers/python".to_string();
1120+
let user_id = "ghcr.io/devcontainers/python:1".to_string();
1121+
1122+
let layer_digest =
1123+
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string();
1124+
let manifest_digest =
1125+
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string();
1126+
assert_ne!(layer_digest, manifest_digest);
1127+
1128+
let downloaded = DownloadedFeature {
1129+
path: PathBuf::from("/tmp/unused"),
1130+
metadata: FeatureMetadata {
1131+
id: "python".to_string(),
1132+
version: Some("1.2.3".to_string()),
1133+
..FeatureMetadata::default()
1134+
},
1135+
digest: layer_digest,
1136+
manifest_digest: manifest_digest.clone(),
1137+
};
1138+
1139+
let mut downloaded_features = HashMap::new();
1140+
downloaded_features.insert(canonical.clone(), downloaded);
1141+
let mut user_id_by_canonical = HashMap::new();
1142+
user_id_by_canonical.insert(canonical.clone(), user_id.clone());
1143+
1144+
let lockfile = build_lockfile_from_features(
1145+
&[(canonical, feature_ref)],
1146+
&downloaded_features,
1147+
&user_id_by_canonical,
1148+
);
1149+
1150+
let entry = lockfile
1151+
.features
1152+
.get(&user_id)
1153+
.expect("lockfile must contain the feature entry");
1154+
assert_eq!(
1155+
entry.resolved,
1156+
format!("ghcr.io/devcontainers/python@{}", manifest_digest)
1157+
);
1158+
assert_eq!(entry.integrity, manifest_digest);
1159+
}
1160+
11041161
#[test]
11051162
fn build_lockfile_falls_back_to_tag_when_version_missing() {
11061163
// Some features ship without a `version` in their metadata; rather

0 commit comments

Comments
 (0)