Skip to content

Commit ac95135

Browse files
committed
fix(gc): keep tag manifests' digest aliases, reap manifest meta sidecars
The tag-rooted mark walk (#938) marked a tag manifest's children (arch manifests, config, layer blobs) but not the digest-named file of the tag manifest itself. The sweep then deleted manifests/sha256:<digest>.json while manifests/<tag>.json — the same bytes — survived, so pull-by-digest 404'd while pull-by-tag worked. The walk now hashes each tag manifest's bytes and marks the sha256:<digest>.json alias as a root, per the OCI distribution spec requirement that content pullable by tag stays pullable by digest. Also reap the .meta.json sidecar together with an orphaned digest manifest; it was invisible to detection on its own and leaked forever. Fixes #949 Signed-off-by: Joe Grund <grundjoseph@gmail.com>
1 parent 74ede2f commit ac95135

2 files changed

Lines changed: 81 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# Changelog
22
## [Unreleased]
33

4+
### Fixed
5+
- **GC: tag-rooted mark walk kept a tag manifest's children but swept the digest-named copy of the manifest itself**, so pull-by-digest of a tagged image 404'd after the first GC run while pull-by-tag kept working. The walk now marks `manifests/sha256:<sha256(bytes)>.json` for every tag manifest — the digest alias the OCI distribution spec requires to stay pullable. Orphaned digest manifests now also take their `.meta.json` sidecar with them instead of leaking it. (#949)
6+
47
## [1.2.0] - 2026-08-23
58

69
### Added

nora-registry/src/gc.rs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,9 +423,18 @@ async fn detect_docker_orphans(storage: &Storage) -> DetectionResult {
423423
// For manifest lists, also collect sub-manifest digests to resolve in step 3.
424424
let mut referenced = HashSet::new();
425425
let mut sub_manifest_digests: HashSet<String> = HashSet::new();
426+
// The digest-named aliases of the tag manifests themselves: the OCI
427+
// distribution spec keeps content pullable by digest whenever it is
428+
// pullable by tag, so these files are roots too (#949).
429+
let mut tag_manifest_digests: HashSet<String> = HashSet::new();
426430

427431
for key in &tag_manifests {
428432
if let Ok(data) = storage.get(key).await {
433+
use sha2::Digest;
434+
tag_manifest_digests.insert(format!(
435+
"sha256:{}",
436+
hex::encode(sha2::Sha256::digest(&data))
437+
));
429438
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&data) {
430439
collect_manifest_refs(&json, &mut referenced, &mut sub_manifest_digests);
431440
}
@@ -450,12 +459,27 @@ async fn detect_docker_orphans(storage: &Storage) -> DetectionResult {
450459
// Step 4: Detect orphaned digest manifests — digest-keyed manifests not
451460
// reachable from any tag (neither directly tagged nor a sub-manifest of a
452461
// tagged manifest list).
462+
let meta_sidecars: HashSet<&String> = keys
463+
.iter()
464+
.filter(|k| k.contains("/manifests/") && ends_with_ci(k, ".meta.json"))
465+
.collect();
453466
let mut orphan_digest_manifests: Vec<String> = Vec::new();
454467
for key in &all_manifest_keys {
455468
let filename = key.rsplit('/').next().unwrap_or("");
456469
let ref_name = filename.strip_suffix(".json").unwrap_or(filename);
457-
if is_digest_ref(ref_name) && !sub_manifest_digests.contains(ref_name) {
470+
if is_digest_ref(ref_name)
471+
&& !sub_manifest_digests.contains(ref_name)
472+
&& !tag_manifest_digests.contains(ref_name)
473+
{
458474
orphan_digest_manifests.push(key.clone());
475+
// Reap the .meta.json sidecar with its manifest (#949): it is
476+
// invisible to detection on its own, so it would leak forever.
477+
if let Some(stem) = key.strip_suffix(".json") {
478+
let sidecar = format!("{stem}.meta.json");
479+
if meta_sidecars.contains(&sidecar) {
480+
orphan_digest_manifests.push(sidecar);
481+
}
482+
}
459483
}
460484
}
461485

@@ -1547,6 +1571,59 @@ mod tests {
15471571
.is_ok());
15481572
}
15491573

1574+
/// The digest-named alias of a tag manifest is a root: pull-by-digest must
1575+
/// keep working after GC (#949). Orphaned digest manifests take their
1576+
/// .meta.json sidecar with them instead of leaking it.
1577+
#[tokio::test]
1578+
async fn test_gc_keeps_tag_manifest_digest_alias_and_reaps_sidecars() {
1579+
use sha2::Digest;
1580+
let dir = tempfile::tempdir().unwrap();
1581+
let storage = Storage::new_local(dir.path().join("data").to_str().unwrap());
1582+
1583+
let manifest = serde_json::json!({
1584+
"config": {"digest": "sha256:cfg"},
1585+
"layers": []
1586+
})
1587+
.to_string();
1588+
let digest = format!("sha256:{}", hex::encode(sha2::Sha256::digest(manifest.as_bytes())));
1589+
let alias_key = format!("docker/test/manifests/{digest}.json");
1590+
let alias_meta_key = format!("docker/test/manifests/{digest}.meta.json");
1591+
1592+
storage
1593+
.put("docker/test/manifests/latest.json", manifest.as_bytes())
1594+
.await
1595+
.unwrap();
1596+
storage.put(&alias_key, manifest.as_bytes()).await.unwrap();
1597+
storage.put(&alias_meta_key, b"{}").await.unwrap();
1598+
storage
1599+
.put("docker/test/blobs/sha256:cfg", b"cfg")
1600+
.await
1601+
.unwrap();
1602+
1603+
// Untagged digest manifest: orphan, and its sidecar must go with it.
1604+
storage
1605+
.put("docker/test/manifests/sha256:dead.json", b"{}")
1606+
.await
1607+
.unwrap();
1608+
storage
1609+
.put("docker/test/manifests/sha256:dead.meta.json", b"{}")
1610+
.await
1611+
.unwrap();
1612+
1613+
let result = run_gc(&storage, &test_publish_locks(), false, 0, false, 0).await;
1614+
assert_eq!(result.deleted, 2);
1615+
assert!(storage.get(&alias_key).await.is_ok());
1616+
assert!(storage.get(&alias_meta_key).await.is_ok());
1617+
assert!(storage
1618+
.get("docker/test/manifests/sha256:dead.json")
1619+
.await
1620+
.is_err());
1621+
assert!(storage
1622+
.get("docker/test/manifests/sha256:dead.meta.json")
1623+
.await
1624+
.is_err());
1625+
}
1626+
15501627
/// Manifest list (image index) tag transitively protects sub-manifest blobs.
15511628
#[tokio::test]
15521629
async fn test_gc_manifest_list_references() {

0 commit comments

Comments
 (0)