1use std::collections::{HashMap, HashSet};
41use std::sync::Arc;
42
43use anyhow::{Context, Result, ensure};
44use containers_image_proxy::oci_spec::image::{
45 Descriptor, Digest as OciDigest, ImageConfiguration, ImageManifest, MediaType,
46};
47use rustix::fs::{AtFlags, Dir, Mode, OFlags, openat, readlinkat, unlinkat};
48use rustix::io::Errno;
49use serde::Serialize;
50
51use composefs::{
52 erofs::format::{FormatEpoch, FormatVersion},
53 fsverity::FsVerityHashValue,
54 generic_tree::XattrFiltering,
55 repository::Repository,
56};
57
58use crate::ContentAndVerity;
59use crate::layer::is_tar_media_type;
60use crate::skopeo::{OCI_BLOB_CONTENT_TYPE, OCI_CONFIG_CONTENT_TYPE, OCI_MANIFEST_CONTENT_TYPE};
61
62#[derive(Debug, thiserror::Error)]
64#[error("OCI reference not found: {name}")]
65pub struct OciRefNotFound {
66 pub name: String,
68}
69
70#[derive(Debug, thiserror::Error)]
72#[error("OCI image not found: {digest}")]
73pub struct OciImageNotFound {
74 pub digest: String,
76}
77
78type ExternalData<ObjectID> = (Vec<u8>, HashMap<Box<str>, ObjectID>);
80
81pub(crate) fn read_external_splitstream<ObjectID: FsVerityHashValue>(
89 repo: &Repository<ObjectID>,
90 content_id: &str,
91 verity: Option<&ObjectID>,
92 expected_content_type: Option<u64>,
93) -> Result<ExternalData<ObjectID>> {
94 let mut stream = repo.open_stream(content_id, verity, expected_content_type)?;
95
96 let mut object_refs = Vec::new();
97 stream.get_object_refs(|id| object_refs.push(id.clone()))?;
98 ensure!(
99 object_refs.len() == 1,
100 "Expected exactly 1 external object in splitstream, got {}",
101 object_refs.len()
102 );
103
104 let data = repo.read_object(&object_refs[0])?;
105 let named_refs = stream.into_named_refs();
106 Ok((data, named_refs))
107}
108
109pub const OCI_REF_PREFIX: &str = "oci/";
111
112#[derive(Debug)]
118pub struct OciImage<ObjectID: FsVerityHashValue> {
119 manifest_digest: OciDigest,
121 manifest: ImageManifest,
123 config_digest: OciDigest,
125 config_verity: ObjectID,
127 config: Option<ImageConfiguration>,
129 layer_refs: HashMap<Box<str>, ObjectID>,
131 image_ref: Option<ObjectID>,
133 image_ref_v1: Option<ObjectID>,
135 boot_image_refs: HashMap<Box<str>, ObjectID>,
139 manifest_verity: ObjectID,
141}
142
143impl<ObjectID: FsVerityHashValue> OciImage<ObjectID> {
144 pub fn open(
149 repo: &Repository<ObjectID>,
150 manifest_digest: &OciDigest,
151 verity: Option<&ObjectID>,
152 ) -> Result<Self> {
153 let manifest_id = manifest_identifier(manifest_digest);
154 let (data, named_refs) =
155 read_external_splitstream(repo, &manifest_id, verity, Some(OCI_MANIFEST_CONTENT_TYPE))?;
156
157 if verity.is_none() {
159 let computed = hash_sha256(&data);
160 ensure!(
161 *manifest_digest == computed,
162 "Manifest integrity failed: expected {manifest_digest}, got {computed}"
163 );
164 }
165
166 let manifest = ImageManifest::from_reader(&data[..])?;
167
168 let config_digest = manifest.config().digest().clone();
169 let config_key = format!("config:{config_digest}");
170 let config_verity = named_refs
171 .get(config_key.as_str())
172 .context("Manifest missing config reference")?
173 .clone();
174
175 let config_id = crate::config_identifier(&config_digest);
176 let (config_data, config_named_refs) = read_external_splitstream(
177 repo,
178 &config_id,
179 Some(&config_verity),
180 Some(OCI_CONFIG_CONTENT_TYPE),
181 )?;
182
183 let (config, mut layer_refs) = match manifest.config().media_type() {
185 MediaType::ImageConfig => {
186 let config = ImageConfiguration::from_reader(&config_data[..])?;
187 (Some(config), config_named_refs)
188 }
189 _ => {
190 let layer_digests: HashSet<&str> = manifest
195 .layers()
196 .iter()
197 .map(|d| d.digest().as_ref())
198 .collect();
199 let refs = named_refs
200 .into_iter()
201 .filter(|(k, _)| layer_digests.contains(k.as_ref()))
202 .collect();
203 (None, refs)
204 }
205 };
206
207 let image_ref = layer_refs.remove(crate::IMAGE_REF_KEY);
209 let image_ref_v1 = layer_refs.remove(crate::IMAGE_REF_KEY_V1);
210 let (boot_image_refs, layer_refs) = crate::take_boot_image_refs(layer_refs);
211
212 let manifest_verity = if let Some(v) = verity {
213 v.clone()
214 } else {
215 match repo.has_stream(&manifest_id)? {
216 Some(v) => v,
217 None => {
218 return Err(anyhow::Error::new(OciImageNotFound {
219 digest: manifest_digest.to_string(),
220 }));
221 }
222 }
223 };
224
225 Ok(Self {
226 manifest_digest: manifest_digest.clone(),
227 manifest,
228 config_digest,
229 config_verity,
230 config,
231 layer_refs,
232 image_ref,
233 image_ref_v1,
234 boot_image_refs,
235 manifest_verity,
236 })
237 }
238
239 pub fn open_ref(repo: &Repository<ObjectID>, name: &str) -> Result<Self> {
241 let (manifest_digest, verity) = resolve_ref(repo, name)?;
242 Self::open(repo, &manifest_digest, Some(&verity))
243 }
244
245 pub fn is_container_image(&self) -> bool {
247 matches!(self.manifest.config().media_type(), MediaType::ImageConfig)
248 }
249
250 pub fn manifest_digest(&self) -> &OciDigest {
252 &self.manifest_digest
253 }
254
255 pub fn manifest_verity(&self) -> &ObjectID {
257 &self.manifest_verity
258 }
259
260 pub fn manifest(&self) -> &ImageManifest {
262 &self.manifest
263 }
264
265 pub fn config_digest(&self) -> &OciDigest {
267 &self.config_digest
268 }
269
270 pub fn config_verity(&self) -> &ObjectID {
272 &self.config_verity
273 }
274
275 pub fn config(&self) -> Option<&ImageConfiguration> {
277 self.config.as_ref()
278 }
279
280 pub fn layer_refs(&self) -> &HashMap<Box<str>, ObjectID> {
282 &self.layer_refs
283 }
284
285 pub fn image_ref(&self, version: FormatVersion) -> Option<&ObjectID> {
291 match version.epoch() {
292 FormatEpoch::Epoch1 => self.image_ref_v1.as_ref(),
293 FormatEpoch::Epoch2 => self.image_ref.as_ref(),
294 }
295 }
296
297 pub fn image_ref_v2(&self) -> Option<&ObjectID> {
299 self.image_ref.as_ref()
300 }
301
302 pub fn image_ref_v1(&self) -> Option<&ObjectID> {
304 self.image_ref_v1.as_ref()
305 }
306
307 pub fn boot_image_ref(&self, version: FormatVersion) -> Option<&ObjectID> {
314 self.boot_image_ref_for_mode(version, XattrFiltering::AllowlistOnly)
315 }
316
317 pub fn boot_image_ref_for_mode(
320 &self,
321 version: FormatVersion,
322 mode: XattrFiltering,
323 ) -> Option<&ObjectID> {
324 self.boot_image_refs
325 .get(&*crate::boot_image_ref_key(version, mode))
326 }
327
328 pub fn boot_image_ref_v2(&self) -> Option<&ObjectID> {
331 self.boot_image_ref(FormatVersion::V2)
332 }
333
334 pub fn boot_image_ref_v1(&self) -> Option<&ObjectID> {
337 self.boot_image_ref(FormatVersion::V1)
338 }
339
340 pub(crate) fn boot_image_refs(&self) -> &HashMap<Box<str>, ObjectID> {
344 &self.boot_image_refs
345 }
346
347 pub fn architecture(&self) -> String {
349 self.config
350 .as_ref()
351 .map(|c| c.architecture().to_string())
352 .unwrap_or_default()
353 }
354
355 pub fn os(&self) -> String {
357 self.config
358 .as_ref()
359 .map(|c| c.os().to_string())
360 .unwrap_or_default()
361 }
362
363 pub fn created(&self) -> Option<&str> {
365 self.config.as_ref().and_then(|c| c.created().as_deref())
366 }
367
368 pub fn open_layer_fd(
374 &self,
375 repo: &Repository<ObjectID>,
376 index: usize,
377 ) -> Result<rustix::fd::OwnedFd> {
378 let descriptor = self
379 .manifest
380 .layers()
381 .get(index)
382 .with_context(|| format!("Layer index {index} out of range"))?;
383
384 ensure!(
385 !is_tar_media_type(descriptor.media_type()),
386 "open_layer_fd does not support tar layers (media type: {}); \
387 use the splitstream API instead",
388 descriptor.media_type()
389 );
390
391 let diff_id = descriptor.digest();
392 let layer_verity = self
393 .layer_verity(diff_id.as_ref())
394 .with_context(|| format!("No verity for layer {diff_id}"))?;
395
396 let content_id = crate::layer_identifier(diff_id);
397 let mut stream = repo.open_stream(&content_id, Some(layer_verity), None)?;
398
399 let mut object_refs = vec![];
402 stream.get_object_refs(|id| object_refs.push(id.clone()))?;
403 ensure!(
404 object_refs.len() == 1,
405 "Expected exactly 1 external ref for artifact layer, got {}",
406 object_refs.len()
407 );
408 repo.open_object(&object_refs[0])
409 }
410
411 pub fn layer_diff_ids(&self) -> Vec<&str> {
413 self.config
414 .as_ref()
415 .map(|c| c.rootfs().diff_ids().iter().map(|s| s.as_str()).collect())
416 .unwrap_or_default()
417 }
418
419 pub fn layer_verity(&self, diff_id: &str) -> Option<&ObjectID> {
421 self.layer_refs.get(diff_id)
422 }
423
424 pub fn layer_descriptors(&self) -> &[Descriptor] {
426 self.manifest.layers()
427 }
428
429 pub fn label(&self, key: &str) -> Option<&str> {
431 self.config.as_ref().and_then(|c| {
432 c.config()
433 .as_ref()
434 .and_then(|cfg| cfg.labels().as_ref())
435 .and_then(|labels| labels.get(key).map(|s| s.as_str()))
436 })
437 }
438
439 pub fn labels(&self) -> Option<&HashMap<String, String>> {
441 self.config
442 .as_ref()
443 .and_then(|c| c.config().as_ref())
444 .and_then(|cfg| cfg.labels().as_ref())
445 }
446
447 pub fn read_manifest_json(&self, repo: &Repository<ObjectID>) -> Result<Vec<u8>> {
452 let manifest_id = manifest_identifier(&self.manifest_digest);
453 let (data, _) = read_external_splitstream(
454 repo,
455 &manifest_id,
456 Some(&self.manifest_verity),
457 Some(OCI_MANIFEST_CONTENT_TYPE),
458 )?;
459 Ok(data)
460 }
461
462 pub fn read_config_json(&self, repo: &Repository<ObjectID>) -> Result<Vec<u8>> {
467 let config_id = crate::config_identifier(&self.config_digest);
468
469 let (data, _) = read_external_splitstream(
470 repo,
471 &config_id,
472 Some(&self.config_verity),
473 Some(OCI_CONFIG_CONTENT_TYPE),
474 )?;
475 Ok(data)
476 }
477
478 pub fn inspect_json(&self, repo: &Repository<ObjectID>) -> Result<serde_json::Value> {
483 let manifest_json = self.read_manifest_json(repo)?;
484 let config_json = self.read_config_json(repo)?;
485 let referrers = list_referrers(repo, &self.manifest_digest)?;
486
487 let manifest_value: serde_json::Value = serde_json::from_slice(&manifest_json)?;
488 let config_value: serde_json::Value = serde_json::from_slice(&config_json)?;
489
490 let referrers_value: Vec<serde_json::Value> = referrers
491 .iter()
492 .map(|(digest, _verity)| serde_json::json!({ "digest": digest }))
493 .collect();
494
495 let mut result = serde_json::json!({
496 "manifest": manifest_value,
497 "config": config_value,
498 "referrers": referrers_value,
499 });
500
501 if let Some(erofs_id) = self.image_ref(repo.erofs_version()) {
502 result["composefs_erofs"] = serde_json::json!(erofs_id.to_hex());
503 }
504
505 if let Some(boot_id) = self.boot_image_ref(repo.erofs_version()) {
506 result["composefs_boot_erofs"] = serde_json::json!(boot_id.to_hex());
507 }
508
509 Ok(result)
510 }
511}
512
513fn validate_ref_name(name: &str) -> Result<()> {
520 ensure!(
521 !name.starts_with('@'),
522 "Invalid ref name {name:?}: leading '@' is reserved for digest references"
523 );
524 Ok(())
525}
526
527pub fn tag_image<ObjectID: FsVerityHashValue>(
532 repo: &Repository<ObjectID>,
533 manifest_digest: &OciDigest,
534 name: &str,
535) -> Result<()> {
536 validate_ref_name(name)?;
537 let manifest_id = manifest_identifier(manifest_digest);
538 let ref_name = oci_ref_path(name);
539 repo.name_stream(&manifest_id, &ref_name)
540}
541
542pub fn untag_image<ObjectID: FsVerityHashValue>(
547 repo: &Repository<ObjectID>,
548 name: &str,
549) -> Result<()> {
550 let ref_path = format!("streams/refs/{}", oci_ref_path(name));
551 unlinkat(repo.repo_fd(), &ref_path, AtFlags::empty())
552 .with_context(|| format!("Failed to remove tag {name}"))?;
553 Ok(())
554}
555
556pub fn resolve_ref<ObjectID: FsVerityHashValue>(
558 repo: &Repository<ObjectID>,
559 name: &str,
560) -> Result<(OciDigest, ObjectID)> {
561 let ref_path = format!("streams/refs/{}", oci_ref_path(name));
562
563 let target = match readlinkat(repo.repo_fd(), &ref_path, vec![]) {
565 Ok(t) => t,
566 Err(Errno::NOENT) => {
567 return Err(anyhow::Error::new(OciRefNotFound {
568 name: name.to_string(),
569 }));
570 }
571 Err(e) => {
572 return Err(e).with_context(|| format!("Reference {name} not found"));
573 }
574 };
575
576 let target_str = target
577 .to_str()
578 .context("Invalid UTF-8 in reference target")?;
579
580 let manifest_part = target_str
582 .rsplit('/')
583 .next()
584 .context("Invalid reference target")?;
585
586 let digest_str = manifest_part
587 .strip_prefix("oci-manifest-")
588 .with_context(|| format!("Invalid manifest reference: {manifest_part}"))?;
589
590 let digest: OciDigest = digest_str
591 .parse()
592 .with_context(|| format!("Invalid OCI digest in reference: {digest_str}"))?;
593
594 let verity = repo
596 .has_stream(&manifest_identifier(&digest))?
597 .with_context(|| format!("Manifest {digest} not found"))?;
598
599 Ok((digest, verity))
600}
601
602pub fn list_refs<ObjectID: FsVerityHashValue>(
606 repo: &Repository<ObjectID>,
607) -> Result<Vec<(String, OciDigest)>> {
608 let mut refs = Vec::new();
609
610 for (name, target) in repo.list_stream_refs("oci")? {
612 let manifest_part = target.rsplit('/').next().unwrap_or(&target);
614 if let Some(digest_str) = manifest_part.strip_prefix("oci-manifest-")
615 && let Ok(digest) = digest_str.parse()
616 {
617 refs.push((decode_tag(&name), digest));
619 }
620 }
621
622 Ok(refs)
623}
624
625#[derive(Debug, Clone, Serialize)]
630#[serde(rename_all = "camelCase")]
631pub struct ImageInfo {
632 pub name: String,
634 pub manifest_digest: OciDigest,
636 pub is_container: bool,
638 pub architecture: String,
640 pub os: String,
642 pub created: Option<String>,
644 pub layer_count: usize,
646 pub referrer_count: usize,
648}
649
650pub fn list_images<ObjectID: FsVerityHashValue>(
652 repo: &Repository<ObjectID>,
653) -> Result<Vec<ImageInfo>> {
654 let mut images = Vec::new();
655
656 for (name, digest) in list_refs(repo)? {
657 match OciImage::open(repo, &digest, None) {
658 Ok(img) => {
659 let referrer_count = list_referrers(repo, &digest).map(|r| r.len()).unwrap_or(0);
660 images.push(ImageInfo {
661 name,
662 manifest_digest: digest,
663 is_container: img.is_container_image(),
664 architecture: img.architecture(),
665 os: img.os(),
666 created: img.created().map(String::from),
667 layer_count: img.layer_descriptors().len(),
668 referrer_count,
669 });
670 }
671 Err(e) => {
672 tracing::warn!("skipping image {name}: {e:#}");
673 continue;
674 }
675 }
676 }
677
678 Ok(images)
679}
680
681pub fn write_manifest<ObjectID: FsVerityHashValue, S: AsRef<str>>(
695 repo: &Arc<Repository<ObjectID>>,
696 manifest: &ImageManifest,
697 manifest_digest: &OciDigest,
698 config_verity: &ObjectID,
699 layer_verities: &[(S, ObjectID)],
700 reference: Option<&str>,
701) -> Result<ContentAndVerity<ObjectID>> {
702 if let Some(name) = reference {
703 validate_ref_name(name)?;
704 }
705
706 let content_id = manifest_identifier(manifest_digest);
707
708 if let Some(verity) = repo.has_stream(&content_id)? {
709 if let Some(name) = reference {
711 tag_image(repo, manifest_digest, name)?;
712 }
713 return Ok((manifest_digest.clone(), verity));
714 }
715
716 let json = manifest.to_string()?;
717 let json_bytes = json.as_bytes();
718
719 let computed = hash_sha256(json_bytes);
720 ensure!(
721 *manifest_digest == computed,
722 "Manifest digest mismatch: expected {manifest_digest}, got {computed}"
723 );
724
725 let mut stream = repo.create_stream(OCI_MANIFEST_CONTENT_TYPE)?;
726
727 let config_key = format!("config:{}", manifest.config().digest());
728 stream.add_named_stream_ref(&config_key, config_verity);
729
730 for (diff_id, verity) in layer_verities {
731 stream.add_named_stream_ref(diff_id.as_ref(), verity);
732 }
733
734 stream.write_external(json_bytes)?;
735
736 let oci_ref = reference.map(oci_ref_path);
737 let id = repo.write_stream(stream, &content_id, oci_ref.as_deref())?;
738
739 Ok((computed, id))
740}
741
742pub(crate) fn rewrite_manifest<ObjectID: FsVerityHashValue, S: AsRef<str>>(
751 repo: &Arc<Repository<ObjectID>>,
752 manifest_json: &[u8],
753 manifest_digest: &OciDigest,
754 config_verity: &ObjectID,
755 layer_verities: &[(S, ObjectID)],
756 reference: Option<&str>,
757) -> Result<(OciDigest, ObjectID)> {
758 let content_id = manifest_identifier(manifest_digest);
759
760 let config_digest = {
761 let manifest = ImageManifest::from_reader(manifest_json)?;
762 manifest.config().digest().to_string()
763 };
764
765 let mut stream = repo.create_stream(OCI_MANIFEST_CONTENT_TYPE)?;
766
767 let config_key = format!("config:{config_digest}");
768 stream.add_named_stream_ref(&config_key, config_verity);
769
770 for (diff_id, verity) in layer_verities {
771 stream.add_named_stream_ref(diff_id.as_ref(), verity);
772 }
773
774 stream.write_external(manifest_json)?;
775
776 let oci_ref = reference.map(oci_ref_path);
777 let id = repo.write_stream(stream, &content_id, oci_ref.as_deref())?;
778
779 Ok((manifest_digest.clone(), id))
780}
781
782pub fn has_manifest<ObjectID: FsVerityHashValue>(
784 repo: &Repository<ObjectID>,
785 manifest_digest: &OciDigest,
786) -> Result<Option<ObjectID>> {
787 repo.has_stream(&manifest_identifier(manifest_digest))
788}
789
790pub fn manifest_identifier(digest: &OciDigest) -> String {
792 format!("oci-manifest-{digest}")
793}
794
795fn oci_ref_path(name: &str) -> String {
797 format!("{OCI_REF_PREFIX}{}", encode_tag(name))
798}
799
800fn encode_tag(name: &str) -> String {
806 name.replace('%', "%25").replace('/', "%2F")
807}
808
809fn decode_tag(encoded: &str) -> String {
814 let mut result = String::with_capacity(encoded.len());
815 let mut chars = encoded.chars().peekable();
816 while let Some(c) = chars.next() {
817 if c == '%' {
818 let hex: String = chars.by_ref().take(2).collect();
819 match hex.as_str() {
820 "2F" => result.push('/'),
821 "25" => result.push('%'),
822 _ => {
823 result.push('%');
824 result.push_str(&hex);
825 }
826 }
827 } else {
828 result.push(c);
829 }
830 }
831 result
832}
833
834fn hash_sha256(bytes: &[u8]) -> OciDigest {
836 crate::sha256_content_digest(bytes)
837}
838
839pub fn blob_identifier(digest: &OciDigest) -> String {
845 format!("oci-blob-{digest}")
846}
847
848pub fn write_blob<ObjectID: FsVerityHashValue>(
856 repo: &Arc<Repository<ObjectID>>,
857 data: &[u8],
858) -> Result<(OciDigest, ObjectID)> {
859 let digest = hash_sha256(data);
860 let content_id = blob_identifier(&digest);
861
862 if let Some(verity) = repo.has_stream(&content_id)? {
863 return Ok((digest, verity));
864 }
865
866 let mut stream = repo.create_stream(OCI_BLOB_CONTENT_TYPE)?;
867 stream.write_external(data)?;
868 let verity = repo.write_stream(stream, &content_id, None)?;
869
870 Ok((digest, verity))
871}
872
873pub fn open_blob<ObjectID: FsVerityHashValue>(
878 repo: &Repository<ObjectID>,
879 digest: &OciDigest,
880 verity: Option<&ObjectID>,
881) -> Result<Vec<u8>> {
882 let content_id = blob_identifier(digest);
883 let (data, _named_refs) =
884 read_external_splitstream(repo, &content_id, verity, Some(OCI_BLOB_CONTENT_TYPE))?;
885
886 if verity.is_none() {
887 let computed = hash_sha256(&data);
888 ensure!(
889 *digest == computed,
890 "Blob integrity failed: expected {digest}, got {computed}"
891 );
892 }
893
894 Ok(data)
895}
896
897const REFERRER_REF_PREFIX: &str = "oci-referrers/";
903
904pub fn add_referrer<ObjectID: FsVerityHashValue>(
912 repo: &Repository<ObjectID>,
913 subject_digest: &OciDigest,
914 artifact_manifest_digest: &OciDigest,
915) -> Result<()> {
916 let subject_str: &str = subject_digest.as_ref();
917 let artifact_str: &str = artifact_manifest_digest.as_ref();
918 let ref_name = format!(
919 "{REFERRER_REF_PREFIX}{}/{}",
920 encode_tag(subject_str),
921 encode_tag(artifact_str)
922 );
923 let manifest_id = manifest_identifier(artifact_manifest_digest);
924 repo.name_stream(&manifest_id, &ref_name)
925}
926
927pub fn list_referrers<ObjectID: FsVerityHashValue>(
933 repo: &Repository<ObjectID>,
934 subject_digest: &OciDigest,
935) -> Result<Vec<(OciDigest, ObjectID)>> {
936 let subject_str: &str = subject_digest.as_ref();
937 let prefix = format!("{REFERRER_REF_PREFIX}{}", encode_tag(subject_str));
938
939 let mut referrers = Vec::new();
940
941 for (name, target) in repo.list_stream_refs(&prefix)? {
942 let artifact_digest_str = decode_tag(&name);
944
945 let manifest_part = target.rsplit('/').next().unwrap_or(&target);
948 if let Some(digest) = manifest_part.strip_prefix("oci-manifest-") {
949 if digest != artifact_digest_str {
951 continue;
952 }
953 }
954
955 let artifact_digest: OciDigest = artifact_digest_str
957 .parse()
958 .with_context(|| format!("Parsing referrer digest '{artifact_digest_str}'"))?;
959 match repo.has_stream(&manifest_identifier(&artifact_digest))? {
960 Some(verity) => referrers.push((artifact_digest, verity)),
961 None => {
962 continue;
963 }
964 }
965 }
966
967 Ok(referrers)
968}
969
970pub fn remove_referrer<ObjectID: FsVerityHashValue>(
974 repo: &Repository<ObjectID>,
975 subject_digest: &OciDigest,
976 artifact_digest: &OciDigest,
977) -> Result<()> {
978 let subject_str: &str = subject_digest.as_ref();
979 let artifact_str: &str = artifact_digest.as_ref();
980 let ref_path = format!(
981 "streams/refs/{REFERRER_REF_PREFIX}{}/{}",
982 encode_tag(subject_str),
983 encode_tag(artifact_str)
984 );
985 match unlinkat(repo.repo_fd(), &ref_path, AtFlags::empty()) {
986 Ok(()) => Ok(()),
987 Err(Errno::NOENT) => Ok(()),
988 Err(e) => Err(e).with_context(|| format!("Failed to remove referrer {artifact_digest}")),
989 }
990}
991
992pub fn remove_referrers_for_subject<ObjectID: FsVerityHashValue>(
997 repo: &Repository<ObjectID>,
998 subject_digest: &OciDigest,
999) -> Result<()> {
1000 let referrers = list_referrers(repo, subject_digest)?;
1001 for (artifact_digest, _verity) in &referrers {
1002 remove_referrer(repo, subject_digest, artifact_digest)?;
1003 }
1004 let subject_str: &str = subject_digest.as_ref();
1006 let subject_dir = format!(
1007 "streams/refs/{REFERRER_REF_PREFIX}{}",
1008 encode_tag(subject_str)
1009 );
1010 let _ = unlinkat(repo.repo_fd(), &subject_dir, AtFlags::REMOVEDIR);
1011 Ok(())
1012}
1013
1014pub fn cleanup_dangling_referrers<ObjectID: FsVerityHashValue>(
1031 repo: &Repository<ObjectID>,
1032) -> Result<u64> {
1033 let referrers_path = format!("streams/refs/{REFERRER_REF_PREFIX}");
1034
1035 let referrers_dir = match openat(
1037 repo.repo_fd(),
1038 &*referrers_path,
1039 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1040 Mode::empty(),
1041 ) {
1042 Ok(fd) => fd,
1043 Err(Errno::NOENT) => return Ok(0),
1044 Err(e) => return Err(e).context("Opening oci-referrers directory")?,
1045 };
1046
1047 let mut removed = 0u64;
1048
1049 let mut subject_dirs = Vec::new();
1051 for item in Dir::read_from(&referrers_dir).context("Reading oci-referrers directory")? {
1052 let entry = item.context("Reading oci-referrers entry")?;
1053 let name = entry.file_name();
1054 if name == c"." || name == c".." {
1055 continue;
1056 }
1057 if let Ok(s) = std::str::from_utf8(name.to_bytes()) {
1058 subject_dirs.push(s.to_string());
1059 }
1060 }
1061
1062 for encoded_subject in &subject_dirs {
1063 let subject_digest_str = decode_tag(encoded_subject);
1064 let subject_digest: OciDigest = subject_digest_str
1065 .parse()
1066 .with_context(|| format!("Parsing subject digest '{subject_digest_str}'"))?;
1067
1068 if has_manifest(repo, &subject_digest)?.is_some() {
1070 continue;
1071 }
1072
1073 let subject_dir_fd = match openat(
1075 &referrers_dir,
1076 encoded_subject.as_str(),
1077 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
1078 Mode::empty(),
1079 ) {
1080 Ok(fd) => fd,
1081 Err(Errno::NOENT) => continue,
1082 Err(e) => {
1083 return Err(e)
1084 .context(format!("Opening referrer subject dir {encoded_subject}"))?;
1085 }
1086 };
1087
1088 for item in Dir::read_from(&subject_dir_fd).context("Reading referrer subject directory")? {
1089 let entry = item.context("Reading referrer entry")?;
1090 let name = entry.file_name();
1091 if name == c"." || name == c".." {
1092 continue;
1093 }
1094 unlinkat(&subject_dir_fd, name, AtFlags::empty())
1095 .with_context(|| format!("Removing referrer entry {name:?}"))?;
1096 removed += 1;
1097 }
1098
1099 unlinkat(&referrers_dir, encoded_subject.as_str(), AtFlags::REMOVEDIR)
1101 .with_context(|| format!("Removing empty referrer subject dir {encoded_subject}"))?;
1102 }
1103
1104 Ok(removed)
1105}
1106
1107#[derive(Debug, Clone, serde::Serialize, thiserror::Error)]
1117#[serde(tag = "type", rename_all = "kebab-case")]
1118#[non_exhaustive]
1119#[allow(missing_docs)]
1120pub enum OciFsckError {
1121 #[error("fsck: manifest-read-failed: {name}: {detail}")]
1122 ManifestReadFailed { name: String, detail: String },
1123
1124 #[error("fsck: manifest-digest-mismatch: {name}: expected {expected}, got {actual}")]
1125 ManifestDigestMismatch {
1126 name: String,
1127 expected: String,
1128 actual: String,
1129 },
1130
1131 #[error("fsck: manifest-parse-failed: {name}: {detail}")]
1132 ManifestParseFailed { name: String, detail: String },
1133
1134 #[error("fsck: config-ref-missing: {name}: {digest}")]
1135 ConfigRefMissing { name: String, digest: String },
1136
1137 #[error("fsck: config-read-failed: {name}: {detail}")]
1138 ConfigReadFailed { name: String, detail: String },
1139
1140 #[error("fsck: config-digest-mismatch: {name}: expected {expected}, got {actual}")]
1141 ConfigDigestMismatch {
1142 name: String,
1143 expected: String,
1144 actual: String,
1145 },
1146
1147 #[error("fsck: config-parse-failed: {name}: {detail}")]
1148 ConfigParseFailed { name: String, detail: String },
1149
1150 #[error("fsck: layer-ref-missing: {name}: {diff_id}")]
1151 #[serde(rename_all = "camelCase")]
1152 LayerRefMissing { name: String, diff_id: String },
1153
1154 #[error("fsck: layer-stream-missing: {name}: {diff_id}")]
1155 #[serde(rename_all = "camelCase")]
1156 LayerStreamMissing { name: String, diff_id: String },
1157
1158 #[error("fsck: layer-check-failed: {name}: {diff_id}: {detail}")]
1159 #[serde(rename_all = "camelCase")]
1160 LayerCheckFailed {
1161 name: String,
1162 diff_id: String,
1163 detail: String,
1164 },
1165
1166 #[error("fsck: layer-object-missing: {name}: {diff_id}: {detail}")]
1167 #[serde(rename_all = "camelCase")]
1168 LayerObjectMissing {
1169 name: String,
1170 diff_id: String,
1171 detail: String,
1172 },
1173
1174 #[error("fsck: seal-image-missing: {name}: {digest}: {detail}")]
1175 SealImageMissing {
1176 name: String,
1177 digest: String,
1178 detail: String,
1179 },
1180
1181 #[error("fsck: artifact-layer-ref-missing: {name}: {digest}")]
1182 ArtifactLayerRefMissing { name: String, digest: String },
1183
1184 #[error("fsck: artifact-layer-object-missing: {name}: {digest}: {detail}")]
1185 ArtifactLayerObjectMissing {
1186 name: String,
1187 digest: String,
1188 detail: String,
1189 },
1190
1191 #[error("fsck: ref-resolve-failed: {name}: {detail}")]
1192 RefResolveFailed { name: String, detail: String },
1193
1194 #[error("fsck: invalid-ref-name: {name}: leading '@' is reserved for digest references")]
1195 InvalidRefName { name: String },
1196}
1197
1198#[derive(Debug, Clone, Default, Serialize)]
1205#[serde(rename_all = "camelCase")]
1206pub struct OciFsckResult {
1207 pub(crate) repo_result: composefs::repository::FsckResult,
1208 pub(crate) images_checked: u64,
1209 pub(crate) images_corrupted: u64,
1210 pub(crate) errors: Vec<OciFsckError>,
1211}
1212
1213impl OciFsckResult {
1214 pub fn is_ok(&self) -> bool {
1216 debug_assert!(
1217 self.images_corrupted == 0 || !self.errors.is_empty(),
1218 "images_corrupted is non-zero but no OCI error messages recorded"
1219 );
1220 self.repo_result.is_ok() && self.errors.is_empty()
1221 }
1222
1223 pub fn repo_result(&self) -> &composefs::repository::FsckResult {
1225 &self.repo_result
1226 }
1227
1228 pub fn images_checked(&self) -> u64 {
1230 self.images_checked
1231 }
1232
1233 pub fn images_corrupted(&self) -> u64 {
1235 self.images_corrupted
1236 }
1237
1238 pub fn errors(&self) -> &[OciFsckError] {
1240 &self.errors
1241 }
1242}
1243
1244impl std::fmt::Display for OciFsckResult {
1245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1246 write!(f, "{}", self.repo_result)?;
1247 writeln!(
1248 f,
1249 "oci images: {}/{} ok",
1250 self.images_checked.saturating_sub(self.images_corrupted),
1251 self.images_checked
1252 )?;
1253 if !self.errors.is_empty() {
1254 writeln!(f, "oci errors: {}", self.errors.len())?;
1255 for err in &self.errors {
1256 writeln!(f, " - {err}")?;
1257 }
1258 }
1259 Ok(())
1260 }
1261}
1262
1263pub async fn oci_fsck<ObjectID: FsVerityHashValue>(
1270 repo: &Repository<ObjectID>,
1271) -> Result<OciFsckResult> {
1272 let repo_result = repo.fsck().await?;
1273 let mut result = OciFsckResult {
1274 repo_result,
1275 ..Default::default()
1276 };
1277
1278 let refs = list_refs(repo).context("listing OCI refs")?;
1280 for (name, manifest_digest) in refs {
1281 if name.starts_with('@') {
1282 result.images_checked += 1;
1283 result.images_corrupted += 1;
1284 result
1285 .errors
1286 .push(OciFsckError::InvalidRefName { name: name.clone() });
1287 continue;
1288 }
1289 fsck_single_image(repo, &name, &manifest_digest, &mut result);
1290 }
1291
1292 Ok(result)
1293}
1294
1295pub async fn oci_fsck_image<ObjectID: FsVerityHashValue>(
1299 repo: &Repository<ObjectID>,
1300 name: &str,
1301) -> Result<OciFsckResult> {
1302 let repo_result = repo.fsck().await?;
1303 let mut result = OciFsckResult {
1304 repo_result,
1305 ..Default::default()
1306 };
1307
1308 let (manifest_digest, _verity) = match resolve_ref(repo, name) {
1309 Ok(v) => v,
1310 Err(e) => {
1311 result.images_corrupted += 1;
1312 result.images_checked += 1;
1313 result.errors.push(OciFsckError::RefResolveFailed {
1314 name: name.to_string(),
1315 detail: e.to_string(),
1316 });
1317 return Ok(result);
1318 }
1319 };
1320
1321 fsck_single_image(repo, name, &manifest_digest, &mut result);
1322 Ok(result)
1323}
1324
1325fn fsck_single_image<ObjectID: FsVerityHashValue>(
1327 repo: &Repository<ObjectID>,
1328 name: &str,
1329 manifest_digest: &OciDigest,
1330 result: &mut OciFsckResult,
1331) {
1332 result.images_checked += 1;
1333 let error_count_before = result.errors.len();
1334
1335 let manifest_id = manifest_identifier(manifest_digest);
1337 let (manifest_data, manifest_named_refs) = match read_external_splitstream(
1338 repo,
1339 &manifest_id,
1340 None,
1341 Some(OCI_MANIFEST_CONTENT_TYPE),
1342 ) {
1343 Ok(v) => v,
1344 Err(e) => {
1345 result.images_corrupted += 1;
1346 result.errors.push(OciFsckError::ManifestReadFailed {
1347 name: name.to_string(),
1348 detail: e.to_string(),
1349 });
1350 return;
1351 }
1352 };
1353
1354 let computed_digest = hash_sha256(&manifest_data);
1355 if *manifest_digest != computed_digest {
1356 result.images_corrupted += 1;
1357 result.errors.push(OciFsckError::ManifestDigestMismatch {
1358 name: name.to_string(),
1359 expected: manifest_digest.to_string(),
1360 actual: computed_digest.to_string(),
1361 });
1362 return;
1363 }
1364
1365 let manifest = match ImageManifest::from_reader(&manifest_data[..]) {
1367 Ok(m) => m,
1368 Err(e) => {
1369 result.images_corrupted += 1;
1370 result.errors.push(OciFsckError::ManifestParseFailed {
1371 name: name.to_string(),
1372 detail: e.to_string(),
1373 });
1374 return;
1375 }
1376 };
1377
1378 let config_digest = manifest.config().digest().clone();
1380 let config_key = format!("config:{config_digest}");
1381 let config_verity = match manifest_named_refs.get(config_key.as_str()) {
1382 Some(v) => v.clone(),
1383 None => {
1384 result.images_corrupted += 1;
1385 result.errors.push(OciFsckError::ConfigRefMissing {
1386 name: name.to_string(),
1387 digest: config_digest.to_string(),
1388 });
1389 return;
1390 }
1391 };
1392
1393 let config_id = crate::config_identifier(&config_digest);
1395 let (config_data, config_named_refs) = match read_external_splitstream(
1396 repo,
1397 &config_id,
1398 Some(&config_verity),
1399 Some(OCI_CONFIG_CONTENT_TYPE),
1400 ) {
1401 Ok(v) => v,
1402 Err(e) => {
1403 result.images_corrupted += 1;
1404 result.errors.push(OciFsckError::ConfigReadFailed {
1405 name: name.to_string(),
1406 detail: e.to_string(),
1407 });
1408 return;
1409 }
1410 };
1411
1412 let computed_config = hash_sha256(&config_data);
1413 if config_digest != computed_config {
1414 result.images_corrupted += 1;
1415 result.errors.push(OciFsckError::ConfigDigestMismatch {
1416 name: name.to_string(),
1417 expected: config_digest.to_string(),
1418 actual: computed_config.to_string(),
1419 });
1420 return;
1421 }
1422
1423 let is_container = matches!(manifest.config().media_type(), MediaType::ImageConfig);
1425
1426 if is_container {
1427 let config = match ImageConfiguration::from_reader(&config_data[..]) {
1428 Ok(c) => c,
1429 Err(e) => {
1430 result.images_corrupted += 1;
1431 result.errors.push(OciFsckError::ConfigParseFailed {
1432 name: name.to_string(),
1433 detail: e.to_string(),
1434 });
1435 return;
1436 }
1437 };
1438
1439 for diff_id_str in config.rootfs().diff_ids() {
1441 let layer_verity = match config_named_refs.get(diff_id_str.as_str()) {
1442 Some(v) => v,
1443 None => {
1444 result.errors.push(OciFsckError::LayerRefMissing {
1445 name: name.to_string(),
1446 diff_id: diff_id_str.to_string(),
1447 });
1448 continue;
1449 }
1450 };
1451
1452 let diff_id: OciDigest = match diff_id_str.parse() {
1453 Ok(d) => d,
1454 Err(e) => {
1455 result.errors.push(OciFsckError::LayerCheckFailed {
1456 name: name.to_string(),
1457 diff_id: diff_id_str.to_string(),
1458 detail: format!("Invalid diff_id: {e}"),
1459 });
1460 continue;
1461 }
1462 };
1463
1464 let layer_id = crate::layer_identifier(&diff_id);
1466 match repo.has_stream(&layer_id) {
1467 Ok(Some(_)) => {}
1468 Ok(None) => {
1469 result.errors.push(OciFsckError::LayerStreamMissing {
1470 name: name.to_string(),
1471 diff_id: diff_id.to_string(),
1472 });
1473 }
1474 Err(e) => {
1475 result.errors.push(OciFsckError::LayerCheckFailed {
1476 name: name.to_string(),
1477 diff_id: diff_id.to_string(),
1478 detail: e.to_string(),
1479 });
1480 }
1481 }
1482
1483 match repo.open_object(layer_verity) {
1485 Ok(_) => {}
1486 Err(e) => {
1487 result.errors.push(OciFsckError::LayerObjectMissing {
1488 name: name.to_string(),
1489 diff_id: diff_id.to_string(),
1490 detail: e.to_string(),
1491 });
1492 }
1493 }
1494 }
1495
1496 if let Some(seal_digest) = config.get_config_annotation("containers.composefs.fsverity") {
1498 match repo.open_image(seal_digest) {
1499 Ok(_) => {}
1500 Err(e) => {
1501 result.errors.push(OciFsckError::SealImageMissing {
1502 name: name.to_string(),
1503 digest: seal_digest.to_string(),
1504 detail: e.to_string(),
1505 });
1506 }
1507 }
1508 }
1509 } else {
1510 for layer_desc in manifest.layers() {
1512 let layer_digest = layer_desc.digest().to_string();
1513 match manifest_named_refs.get(layer_digest.as_str()) {
1514 Some(verity) => {
1515 match repo.open_object(verity) {
1517 Ok(_) => {}
1518 Err(e) => {
1519 result
1520 .errors
1521 .push(OciFsckError::ArtifactLayerObjectMissing {
1522 name: name.to_string(),
1523 digest: layer_digest,
1524 detail: e.to_string(),
1525 });
1526 }
1527 }
1528 }
1529 None => {
1530 result.errors.push(OciFsckError::ArtifactLayerRefMissing {
1531 name: name.to_string(),
1532 digest: layer_digest,
1533 });
1534 }
1535 }
1536 }
1537 }
1538
1539 if result.errors.len() > error_count_before {
1541 result.images_corrupted += 1;
1542 }
1543}
1544
1545#[derive(Debug, Clone, Serialize)]
1551#[serde(rename_all = "camelCase")]
1552pub struct LayerInfo {
1553 pub diff_id: String,
1555 pub verity: String,
1557 pub size: u64,
1559 pub entry_count: usize,
1561 pub splitstream: SplitstreamInfo,
1563}
1564
1565#[derive(Debug, Clone, Serialize)]
1567#[serde(rename_all = "camelCase")]
1568pub struct SplitstreamInfo {
1569 pub external_objects: usize,
1571 pub external_size: u64,
1573 pub inline_size: u64,
1575}
1576
1577pub fn layer_info<ObjectID: FsVerityHashValue>(
1581 repo: &Repository<ObjectID>,
1582 diff_id: &OciDigest,
1583) -> Result<LayerInfo> {
1584 let content_id = crate::layer_identifier(diff_id);
1585 let verity = repo
1586 .has_stream(&content_id)?
1587 .with_context(|| format!("Layer {diff_id} not found"))?;
1588
1589 let mut stream = repo.open_stream(
1590 &content_id,
1591 Some(&verity),
1592 Some(crate::skopeo::TAR_LAYER_CONTENT_TYPE),
1593 )?;
1594
1595 let size = stream.total_size;
1597
1598 let mut external_objects = 0usize;
1600 stream.get_object_refs(|_| external_objects += 1)?;
1601
1602 let mut entry_count = 0usize;
1604 let mut external_size = 0u64;
1605
1606 while let Some(entry) = crate::tar::get_entry(&mut stream)? {
1607 entry_count += 1;
1608 if let crate::tar::TarItem::Leaf(composefs::tree::LeafContent::Regular(
1609 composefs::tree::RegularFile::External(_, file_size),
1610 )) = entry.item
1611 {
1612 external_size += file_size;
1613 }
1614 }
1615
1616 let inline_size = size.saturating_sub(external_size);
1618
1619 Ok(LayerInfo {
1620 diff_id: diff_id.to_string(),
1621 verity: verity.to_hex(),
1622 size,
1623 entry_count,
1624 splitstream: SplitstreamInfo {
1625 external_objects,
1626 external_size,
1627 inline_size,
1628 },
1629 })
1630}
1631
1632pub fn layer_dumpfile<ObjectID: FsVerityHashValue>(
1637 repo: &Repository<ObjectID>,
1638 diff_id: &OciDigest,
1639 output: &mut impl std::io::Write,
1640) -> Result<()> {
1641 let content_id = crate::layer_identifier(diff_id);
1642 let verity = repo
1643 .has_stream(&content_id)?
1644 .with_context(|| format!("Layer {diff_id} not found"))?;
1645
1646 let mut stream = repo.open_stream(
1647 &content_id,
1648 Some(&verity),
1649 Some(crate::skopeo::TAR_LAYER_CONTENT_TYPE),
1650 )?;
1651
1652 while let Some(entry) = crate::tar::get_entry(&mut stream)? {
1653 writeln!(output, "{entry}")?;
1654 }
1655
1656 Ok(())
1657}
1658
1659pub fn layer_tar<ObjectID: FsVerityHashValue>(
1664 repo: &Repository<ObjectID>,
1665 diff_id: &OciDigest,
1666 output: &mut impl std::io::Write,
1667) -> Result<()> {
1668 let content_id = crate::layer_identifier(diff_id);
1669 let verity = repo
1670 .has_stream(&content_id)?
1671 .with_context(|| format!("Layer {diff_id} not found"))?;
1672
1673 repo.merge_splitstream(
1674 &content_id,
1675 Some(&verity),
1676 Some(crate::skopeo::TAR_LAYER_CONTENT_TYPE),
1677 output,
1678 )
1679}
1680
1681#[cfg(test)]
1682mod test {
1683 use super::*;
1684 use composefs::fsverity::Sha256HashValue;
1685 use composefs::test::TestRepo;
1686 use containers_image_proxy::oci_spec::image::{
1687 ConfigBuilder, DescriptorBuilder, ImageConfigurationBuilder, ImageManifestBuilder,
1688 RootFsBuilder,
1689 };
1690 use std::fs::File;
1691 use std::io::Read;
1692
1693 fn create_test_image(
1702 repo: &Arc<Repository<Sha256HashValue>>,
1703 tag: Option<&str>,
1704 arch: &str,
1705 ) -> (OciDigest, Sha256HashValue, OciDigest) {
1706 let layer_data = format!("fake-layer-{arch}").into_bytes();
1709 let layer_digest = hash_sha256(&layer_data);
1710
1711 let mut layer_stream = repo
1712 .create_stream(crate::skopeo::TAR_LAYER_CONTENT_TYPE)
1713 .unwrap();
1714 layer_stream.write_external(&layer_data).unwrap();
1715 let layer_verity = repo
1716 .write_stream(layer_stream, &crate::layer_identifier(&layer_digest), None)
1717 .unwrap();
1718
1719 let rootfs = RootFsBuilder::default()
1720 .typ("layers")
1721 .diff_ids(vec![layer_digest.to_string()])
1722 .build()
1723 .unwrap();
1724
1725 let cfg = ConfigBuilder::default().build().unwrap();
1726
1727 let config = ImageConfigurationBuilder::default()
1728 .architecture(arch)
1729 .os("linux")
1730 .rootfs(rootfs)
1731 .config(cfg)
1732 .build()
1733 .unwrap();
1734
1735 let config_json = config.to_string().unwrap();
1736 let config_digest = hash_sha256(config_json.as_bytes());
1737
1738 let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
1739 config_stream.add_named_stream_ref(layer_digest.as_ref(), &layer_verity);
1740 config_stream
1741 .write_external(config_json.as_bytes())
1742 .unwrap();
1743 let config_verity = repo
1744 .write_stream(
1745 config_stream,
1746 &crate::config_identifier(&config_digest),
1747 None,
1748 )
1749 .unwrap();
1750
1751 let config_descriptor = DescriptorBuilder::default()
1752 .media_type(MediaType::ImageConfig)
1753 .digest(config_digest.clone())
1754 .size(config_json.len() as u64)
1755 .build()
1756 .unwrap();
1757
1758 let layer_descriptor = DescriptorBuilder::default()
1759 .media_type(MediaType::ImageLayerGzip)
1760 .digest(layer_digest.clone())
1761 .size(layer_data.len() as u64)
1762 .build()
1763 .unwrap();
1764
1765 let manifest = ImageManifestBuilder::default()
1766 .schema_version(2u32)
1767 .media_type(MediaType::ImageManifest)
1768 .config(config_descriptor)
1769 .layers(vec![layer_descriptor])
1770 .build()
1771 .unwrap();
1772
1773 let layer_verities = [(layer_digest, layer_verity)];
1774
1775 let manifest_json = manifest.to_string().unwrap();
1776 let manifest_digest = hash_sha256(manifest_json.as_bytes());
1777
1778 let (_stored_digest, manifest_verity) = write_manifest(
1779 repo,
1780 &manifest,
1781 &manifest_digest,
1782 &config_verity,
1783 &layer_verities,
1784 tag,
1785 )
1786 .unwrap();
1787
1788 (manifest_digest, manifest_verity, config_digest)
1789 }
1790
1791 #[test]
1792 fn test_manifest_identifier() {
1793 let digest: OciDigest =
1794 "sha256:abc1230000000000000000000000000000000000000000000000000000000000"
1795 .parse()
1796 .unwrap();
1797 assert_eq!(
1798 manifest_identifier(&digest),
1799 "oci-manifest-sha256:abc1230000000000000000000000000000000000000000000000000000000000"
1800 );
1801 }
1802
1803 #[test]
1804 fn test_oci_ref_path() {
1805 assert_eq!(oci_ref_path("myimage:latest"), "oci/myimage:latest");
1806 assert_eq!(oci_ref_path("library/nginx"), "oci/library%2Fnginx");
1808 assert_eq!(oci_ref_path("docker://busybox"), "oci/docker:%2F%2Fbusybox");
1809 }
1810
1811 #[test]
1812 fn test_encode_decode_tag() {
1813 assert_eq!(encode_tag("myimage:latest"), "myimage:latest");
1815 assert_eq!(decode_tag("myimage:latest"), "myimage:latest");
1816
1817 assert_eq!(encode_tag("library/nginx"), "library%2Fnginx");
1819 assert_eq!(decode_tag("library%2Fnginx"), "library/nginx");
1820
1821 assert_eq!(encode_tag("docker://busybox"), "docker:%2F%2Fbusybox");
1823 assert_eq!(decode_tag("docker:%2F%2Fbusybox"), "docker://busybox");
1824
1825 assert_eq!(encode_tag("test%2F"), "test%252F");
1827 assert_eq!(decode_tag("test%252F"), "test%2F");
1828
1829 let names = [
1832 "simple",
1833 "with:tag",
1834 "registry.io/image:v1",
1835 "docker://busybox:latest",
1836 "containers-storage:myimage",
1837 "weird%name/with/slashes",
1838 "%2F",
1839 "a/b%c",
1840 "100%",
1841 "normal:tag",
1842 "%25already-encoded",
1843 "double%%percent",
1844 ];
1845 for name in names {
1846 assert_eq!(
1847 decode_tag(&encode_tag(name)),
1848 name,
1849 "round-trip failed for {name}"
1850 );
1851 }
1852 }
1853
1854 #[test]
1855 fn test_hash_sha256() {
1856 assert_eq!(
1857 hash_sha256(b"hello world").as_ref(),
1858 "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
1859 );
1860 }
1861
1862 #[test]
1863 fn test_blob_identifier() {
1864 let digest: OciDigest =
1865 "sha256:abc1230000000000000000000000000000000000000000000000000000000000"
1866 .parse()
1867 .unwrap();
1868 assert_eq!(
1869 blob_identifier(&digest),
1870 "oci-blob-sha256:abc1230000000000000000000000000000000000000000000000000000000000"
1871 );
1872 }
1873
1874 #[test]
1875 fn test_write_and_read_blob() {
1876 let test_repo = TestRepo::<Sha256HashValue>::new();
1877 let repo = &test_repo.repo;
1878
1879 let data = b"This is some arbitrary blob data for an OCI artifact.";
1880 let (digest, verity) = write_blob(repo, data).unwrap();
1881
1882 assert!(digest.as_ref().starts_with("sha256:"));
1883
1884 let read_data = open_blob(&repo, &digest, Some(&verity)).unwrap();
1886 assert_eq!(read_data, data);
1887
1888 let read_data2 = open_blob(&repo, &digest, None).unwrap();
1890 assert_eq!(read_data2, data);
1891 }
1892
1893 #[test]
1894 fn test_write_blob_deduplication() {
1895 let test_repo = TestRepo::<Sha256HashValue>::new();
1896 let repo = &test_repo.repo;
1897
1898 let data = b"duplicate blob content";
1899
1900 let (digest1, verity1) = write_blob(repo, data).unwrap();
1901 let (digest2, verity2) = write_blob(repo, data).unwrap();
1902
1903 assert_eq!(digest1, digest2);
1904 assert_eq!(verity1, verity2);
1905 }
1906
1907 #[test]
1908 fn test_open_blob_bad_digest() {
1909 let test_repo = TestRepo::<Sha256HashValue>::new();
1910 let repo = &test_repo.repo;
1911
1912 let data = b"some blob data";
1913 let (_digest, _verity) = write_blob(repo, data).unwrap();
1914
1915 let bad_digest: OciDigest =
1916 "sha256:0000000000000000000000000000000000000000000000000000000000000000"
1917 .parse()
1918 .unwrap();
1919 let result = open_blob::<Sha256HashValue>(&repo, &bad_digest, None);
1920 assert!(result.is_err());
1921 }
1922
1923 #[test]
1930 fn test_manifest_stored_as_external_object() {
1931 let test_repo = TestRepo::<Sha256HashValue>::new();
1932 let repo = &test_repo.repo;
1933
1934 let (manifest_digest, manifest_verity, _) =
1935 create_test_image(repo, Some("ext-test"), "amd64");
1936
1937 let manifest_id = manifest_identifier(&manifest_digest);
1938 let mut stream = repo
1939 .open_stream(&manifest_id, Some(&manifest_verity), None)
1940 .unwrap();
1941
1942 let mut object_refs = Vec::new();
1943 stream
1944 .get_object_refs(|id| object_refs.push(id.clone()))
1945 .unwrap();
1946
1947 assert!(
1949 !object_refs.is_empty(),
1950 "Manifest splitstream should contain external object references"
1951 );
1952
1953 let img = OciImage::open(&repo, &manifest_digest, Some(&manifest_verity)).unwrap();
1954 let manifest_json = img.manifest().to_string().unwrap();
1955 let expected_verity: Sha256HashValue =
1956 composefs::fsverity::compute_verity(manifest_json.as_bytes());
1957
1958 assert!(
1959 object_refs.contains(&expected_verity),
1960 "Manifest JSON fsverity digest should appear in splitstream object refs"
1961 );
1962 }
1963
1964 #[test]
1966 fn test_blob_stored_as_external_object() {
1967 let test_repo = TestRepo::<Sha256HashValue>::new();
1968 let repo = &test_repo.repo;
1969
1970 let data = b"artifact blob content for external storage test";
1971 let (digest, verity) = write_blob(repo, data).unwrap();
1972
1973 let content_id = blob_identifier(&digest);
1974 let mut stream = repo.open_stream(&content_id, Some(&verity), None).unwrap();
1975
1976 let mut object_refs = Vec::new();
1977 stream
1978 .get_object_refs(|id| object_refs.push(id.clone()))
1979 .unwrap();
1980
1981 assert_eq!(
1982 object_refs.len(),
1983 1,
1984 "Blob should be stored as exactly one external object"
1985 );
1986
1987 let expected_verity: Sha256HashValue = composefs::fsverity::compute_verity(data);
1988 assert_eq!(
1989 object_refs[0], expected_verity,
1990 "External object verity should match independently computed verity of blob data"
1991 );
1992 }
1993
1994 #[test]
1999 fn test_oci_artifact_roundtrip() {
2000 use containers_image_proxy::oci_spec::image::{DescriptorBuilder, ImageManifestBuilder};
2001
2002 let test_repo = TestRepo::<Sha256HashValue>::new();
2003 let repo = &test_repo.repo;
2004
2005 let wasm_bytes = b"\x00asm\x01\x00\x00\x00"; let (blob_digest, blob_verity) = write_blob(repo, wasm_bytes).unwrap();
2008
2009 let empty_config = b"{}";
2011 let config_digest = hash_sha256(empty_config);
2012
2013 let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
2014 config_stream.write_external(empty_config).unwrap();
2015 let config_verity = repo
2016 .write_stream(
2017 config_stream,
2018 &crate::config_identifier(&config_digest),
2019 None,
2020 )
2021 .unwrap();
2022
2023 let config_descriptor = DescriptorBuilder::default()
2024 .media_type(MediaType::Other(
2025 "application/vnd.wasm.config.v1+json".to_string(),
2026 ))
2027 .digest(config_digest.clone())
2028 .size(empty_config.len() as u64)
2029 .build()
2030 .unwrap();
2031
2032 let blob_descriptor = DescriptorBuilder::default()
2033 .media_type(MediaType::Other("application/wasm".to_string()))
2034 .digest(blob_digest.clone())
2035 .size(wasm_bytes.len() as u64)
2036 .build()
2037 .unwrap();
2038
2039 let manifest = ImageManifestBuilder::default()
2040 .schema_version(2u32)
2041 .media_type(MediaType::ImageManifest)
2042 .config(config_descriptor)
2043 .layers(vec![blob_descriptor])
2044 .build()
2045 .unwrap();
2046
2047 let layer_verities = [(blob_digest.clone(), blob_verity.clone())];
2049
2050 let manifest_json = manifest.to_string().unwrap();
2051 let manifest_digest = hash_sha256(manifest_json.as_bytes());
2052
2053 let (stored_digest, manifest_verity) = write_manifest(
2054 &repo,
2055 &manifest,
2056 &manifest_digest,
2057 &config_verity,
2058 &layer_verities,
2059 Some("my-wasm-artifact:v1"),
2060 )
2061 .unwrap();
2062
2063 assert_eq!(stored_digest, manifest_digest);
2064
2065 let opened = OciImage::open(&repo, &manifest_digest, Some(&manifest_verity)).unwrap();
2066
2067 assert!(!opened.is_container_image()); assert_eq!(opened.manifest_digest(), &manifest_digest);
2069 assert_eq!(opened.config_digest(), &config_digest);
2070 assert_eq!(opened.layer_descriptors().len(), 1);
2071 assert_eq!(
2072 opened.layer_descriptors()[0].media_type(),
2073 &MediaType::Other("application/wasm".to_string())
2074 );
2075
2076 let by_tag = OciImage::open_ref(&repo, "my-wasm-artifact:v1").unwrap();
2077 assert_eq!(by_tag.manifest_digest(), &manifest_digest);
2078
2079 let images = list_images(&repo).unwrap();
2080 assert_eq!(images.len(), 1);
2081 assert_eq!(images[0].name, "my-wasm-artifact:v1");
2082 assert!(!images[0].is_container);
2083
2084 let read_wasm = open_blob(&repo, &blob_digest, Some(&blob_verity)).unwrap();
2085 assert_eq!(read_wasm, wasm_bytes);
2086 }
2087
2088 #[test]
2093 fn test_oci_artifact_empty_config() {
2094 let test_repo = TestRepo::<Sha256HashValue>::new();
2095 let repo = &test_repo.repo;
2096
2097 let sbom_data = br#"{"spdxVersion":"SPDX-2.3","name":"example"}"#;
2098 let layer_digest = hash_sha256(sbom_data);
2099
2100 let blob_object_id = repo.ensure_object(sbom_data).unwrap();
2102 let layer_content_id = crate::layer_identifier(&layer_digest);
2103 let mut layer_stream = repo
2104 .create_stream(crate::skopeo::OCI_BLOB_CONTENT_TYPE)
2105 .unwrap();
2106 layer_stream.add_external_size(sbom_data.len() as u64);
2107 layer_stream
2108 .write_reference(blob_object_id.clone())
2109 .unwrap();
2110 let layer_verity = repo
2111 .write_stream(layer_stream, &layer_content_id, None)
2112 .unwrap();
2113
2114 let empty_config = b"{}";
2116 let config_digest = hash_sha256(empty_config);
2117 assert_eq!(
2118 config_digest.as_ref(),
2119 "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
2120 );
2121
2122 let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
2126 config_stream.write_external(empty_config).unwrap();
2127 let config_verity = repo
2128 .write_stream(
2129 config_stream,
2130 &crate::config_identifier(&config_digest),
2131 None,
2132 )
2133 .unwrap();
2134
2135 let config_descriptor = DescriptorBuilder::default()
2136 .media_type(MediaType::EmptyJSON)
2137 .digest(config_digest.clone())
2138 .size(empty_config.len() as u64)
2139 .build()
2140 .unwrap();
2141
2142 let layer_descriptor = DescriptorBuilder::default()
2143 .media_type(MediaType::Other("text/spdx+json".to_string()))
2144 .digest(layer_digest.clone())
2145 .size(sbom_data.len() as u64)
2146 .build()
2147 .unwrap();
2148
2149 let manifest = ImageManifestBuilder::default()
2150 .schema_version(2u32)
2151 .media_type(MediaType::ImageManifest)
2152 .config(config_descriptor.clone())
2153 .layers(vec![layer_descriptor])
2154 .build()
2155 .unwrap();
2156
2157 assert_ne!(*config_descriptor.media_type(), MediaType::ImageConfig);
2158
2159 let layer_verities = [(layer_digest.clone(), layer_verity.clone())];
2162
2163 let manifest_json = manifest.to_string().unwrap();
2164 let manifest_digest = hash_sha256(manifest_json.as_bytes());
2165
2166 let (_stored_digest, manifest_verity) = write_manifest(
2167 &repo,
2168 &manifest,
2169 &manifest_digest,
2170 &config_verity,
2171 &layer_verities,
2172 Some("my-sbom:v1"),
2173 )
2174 .unwrap();
2175
2176 let opened = OciImage::open(&repo, &manifest_digest, Some(&manifest_verity)).unwrap();
2177 assert!(!opened.is_container_image());
2178 assert_eq!(opened.layer_descriptors().len(), 1);
2179 assert_eq!(
2180 opened.layer_descriptors()[0].media_type(),
2181 &MediaType::Other("text/spdx+json".to_string())
2182 );
2183
2184 let fd = opened.open_layer_fd(&repo, 0).unwrap();
2185 let mut recovered = vec![];
2186 File::from(fd).read_to_end(&mut recovered).unwrap();
2187 assert_eq!(recovered, sbom_data);
2188
2189 assert!(opened.open_layer_fd(&repo, 1).is_err());
2190
2191 let gc = repo.gc(&[]).unwrap();
2192 assert_eq!(gc.objects_removed, 0);
2193
2194 untag_image(&repo, "my-sbom:v1").unwrap();
2195 let gc = repo.gc(&[]).unwrap();
2196 assert!(gc.objects_removed > 0);
2197 }
2198
2199 #[test]
2201 fn test_open_layer_fd_rejects_tar() {
2202 let test_repo = TestRepo::<Sha256HashValue>::new();
2203 let repo = &test_repo.repo;
2204
2205 let (digest, verity, _) = create_test_image(repo, Some("myimage:v1"), "amd64");
2206 let img = OciImage::open(&repo, &digest, Some(&verity)).unwrap();
2207 assert!(img.is_container_image());
2208
2209 let err = img.open_layer_fd(&repo, 0).unwrap_err();
2211 let msg = format!("{err}");
2212 assert!(msg.contains("does not support tar layers"), "got: {msg}");
2213 }
2214
2215 #[test]
2220 fn test_non_tar_layer_storage() {
2221 let test_repo = TestRepo::<Sha256HashValue>::new();
2222 let repo = &test_repo.repo;
2223
2224 let sbom_data = br#"{"spdxVersion":"SPDX-2.3","name":"example"}"#;
2225 let diff_id = hash_sha256(sbom_data);
2226
2227 let object_id = repo.ensure_object(sbom_data).unwrap();
2228
2229 let content_id = crate::layer_identifier(&diff_id);
2230 let mut stream = repo
2231 .create_stream(crate::skopeo::OCI_BLOB_CONTENT_TYPE)
2232 .unwrap();
2233 stream.add_external_size(sbom_data.len() as u64);
2234 stream.write_reference(object_id.clone()).unwrap();
2235 let stream_verity = repo.write_stream(stream, &content_id, None).unwrap();
2236
2237 let found = repo.has_stream(&content_id).unwrap();
2238 assert!(found.is_some());
2239 assert_eq!(found.unwrap(), stream_verity);
2240
2241 let mut reader = repo
2242 .open_stream(
2243 &content_id,
2244 Some(&stream_verity),
2245 Some(crate::skopeo::OCI_BLOB_CONTENT_TYPE),
2246 )
2247 .unwrap();
2248 let mut refs = vec![];
2249 reader.get_object_refs(|id| refs.push(id.clone())).unwrap();
2250 assert_eq!(refs.len(), 1);
2251 assert_eq!(refs[0], object_id);
2252
2253 let mut recovered = vec![];
2254 File::from(repo.open_object(&object_id).unwrap())
2255 .read_to_end(&mut recovered)
2256 .unwrap();
2257 assert_eq!(recovered, sbom_data);
2258 }
2259
2260 #[test]
2263 fn test_non_tar_artifact_gc() {
2264 use containers_image_proxy::oci_spec::image::{DescriptorBuilder, ImageManifestBuilder};
2265
2266 let test_repo = TestRepo::<Sha256HashValue>::new();
2267 let repo = &test_repo.repo;
2268
2269 let sbom_data = br#"{"spdxVersion":"SPDX-2.3","name":"example"}"#;
2270 let diff_id = hash_sha256(sbom_data);
2271 let blob_object_id = repo.ensure_object(sbom_data).unwrap();
2272
2273 let layer_content_id = crate::layer_identifier(&diff_id);
2274 let mut layer_stream = repo
2275 .create_stream(crate::skopeo::OCI_BLOB_CONTENT_TYPE)
2276 .unwrap();
2277 layer_stream.add_external_size(sbom_data.len() as u64);
2278 layer_stream
2279 .write_reference(blob_object_id.clone())
2280 .unwrap();
2281 let layer_verity = repo
2282 .write_stream(layer_stream, &layer_content_id, None)
2283 .unwrap();
2284
2285 let config_bytes = b"{}";
2286 let config_digest = hash_sha256(config_bytes);
2287 let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
2288 config_stream.write_external(config_bytes).unwrap();
2289 let config_verity = repo
2290 .write_stream(
2291 config_stream,
2292 &crate::config_identifier(&config_digest),
2293 None,
2294 )
2295 .unwrap();
2296
2297 let config_descriptor = DescriptorBuilder::default()
2298 .media_type(MediaType::ImageConfig)
2299 .digest(config_digest.clone())
2300 .size(config_bytes.len() as u64)
2301 .build()
2302 .unwrap();
2303 let layer_descriptor = DescriptorBuilder::default()
2304 .media_type(MediaType::Other("text/spdx+json".to_string()))
2305 .digest(diff_id.clone())
2306 .size(sbom_data.len() as u64)
2307 .build()
2308 .unwrap();
2309 let manifest = ImageManifestBuilder::default()
2310 .schema_version(2u32)
2311 .media_type(MediaType::ImageManifest)
2312 .config(config_descriptor)
2313 .layers(vec![layer_descriptor])
2314 .build()
2315 .unwrap();
2316
2317 let layer_verities = [(diff_id.clone(), layer_verity)];
2318
2319 let manifest_json = manifest.to_string().unwrap();
2320 let manifest_digest = hash_sha256(manifest_json.as_bytes());
2321
2322 let (_stored_digest, _manifest_verity) = write_manifest(
2323 &repo,
2324 &manifest,
2325 &manifest_digest,
2326 &config_verity,
2327 &layer_verities,
2328 Some("my-sbom:v1"),
2329 )
2330 .unwrap();
2331
2332 let gc = repo.gc(&[]).unwrap();
2335 assert_eq!(gc.objects_removed, 0, "tagged artifact should be preserved");
2336
2337 let mut recovered = vec![];
2338 File::from(repo.open_object(&blob_object_id).unwrap())
2339 .read_to_end(&mut recovered)
2340 .unwrap();
2341 assert_eq!(recovered, sbom_data);
2342 }
2343
2344 #[test]
2346 fn test_multiple_images() {
2347 let test_repo = TestRepo::<Sha256HashValue>::new();
2348 let repo = &test_repo.repo;
2349
2350 let (digest1, verity1, _) = create_test_image(repo, Some("app:v1"), "amd64");
2351 let (digest2, verity2, _) = create_test_image(repo, Some("app:v2"), "amd64");
2352 let (digest3, verity3, _) = create_test_image(repo, Some("other:latest"), "arm64");
2353
2354 let images = list_images(repo).unwrap();
2355 assert_eq!(images.len(), 3);
2356
2357 let names: Vec<_> = images.iter().map(|i| i.name.as_str()).collect();
2358 assert!(names.contains(&"app:v1"));
2359 assert!(names.contains(&"app:v2"));
2360 assert!(names.contains(&"other:latest"));
2361
2362 for img in &images {
2363 if img.name == "other:latest" {
2364 assert_eq!(img.architecture, "arm64");
2365 } else {
2366 assert_eq!(img.architecture, "amd64");
2367 }
2368 assert!(img.is_container);
2369 }
2370
2371 let img1 = OciImage::open_ref(repo, "app:v1").unwrap();
2372 assert_eq!(img1.manifest_digest(), &digest1);
2373 assert_eq!(img1.manifest_verity(), &verity1);
2374
2375 let img2 = OciImage::open_ref(repo, "app:v2").unwrap();
2376 assert_eq!(img2.manifest_digest(), &digest2);
2377 assert_eq!(img2.manifest_verity(), &verity2);
2378
2379 let img3 = OciImage::open_ref(repo, "other:latest").unwrap();
2380 assert_eq!(img3.manifest_digest(), &digest3);
2381 assert_eq!(img3.manifest_verity(), &verity3);
2382 }
2383
2384 #[test]
2386 fn test_untag_image() {
2387 let test_repo = TestRepo::<Sha256HashValue>::new();
2388 let repo = &test_repo.repo;
2389
2390 let (digest1, verity1, _) = create_test_image(repo, Some("myapp:v1"), "amd64");
2391 let (digest2, _verity2, _) = create_test_image(repo, Some("myapp:v2"), "amd64");
2392
2393 let images = list_images(repo).unwrap();
2394 assert_eq!(images.len(), 2);
2395
2396 untag_image(repo, "myapp:v1").unwrap();
2397
2398 let images = list_images(repo).unwrap();
2399 assert_eq!(images.len(), 1);
2400 assert_eq!(images[0].name, "myapp:v2");
2401 assert_eq!(images[0].manifest_digest, digest2);
2402
2403 let img = OciImage::open(repo, &digest1, Some(&verity1)).unwrap();
2404 assert_eq!(img.manifest_digest(), &digest1);
2405
2406 let result = OciImage::open_ref(repo, "myapp:v1");
2407 assert!(result.is_err());
2408 }
2409
2410 #[test]
2412 fn test_refs() {
2413 let test_repo = TestRepo::<Sha256HashValue>::new();
2414 let repo = &test_repo.repo;
2415
2416 let (digest, verity, _) = create_test_image(repo, Some("test:latest"), "amd64");
2417
2418 let refs = list_refs(repo).unwrap();
2419 assert_eq!(refs.len(), 1);
2420 assert_eq!(refs[0].0, "test:latest");
2421 assert_eq!(refs[0].1, digest);
2422
2423 let (resolved_digest, resolved_verity) = resolve_ref(repo, "test:latest").unwrap();
2424 assert_eq!(resolved_digest, digest);
2425 assert_eq!(resolved_verity, verity);
2426
2427 let result = resolve_ref::<Sha256HashValue>(repo, "nonexistent:tag");
2428 assert!(result.is_err());
2429 }
2430
2431 #[test]
2433 fn test_tag_rejects_leading_at_sign() {
2434 let test_repo = TestRepo::<Sha256HashValue>::new();
2435 let repo = &test_repo.repo;
2436
2437 let (digest, _, _) = create_test_image(repo, Some("valid:v1"), "amd64");
2438
2439 let result = tag_image(repo, &digest, "@sha256:bad");
2441 assert!(result.is_err());
2442 let err = result.unwrap_err().to_string();
2443 assert!(err.contains("'@' is reserved"), "unexpected error: {err}");
2444
2445 let result = tag_image(repo, &digest, "name@digest");
2447 assert!(result.is_ok());
2448 }
2449
2450 #[tokio::test]
2452 async fn test_oci_fsck_detects_invalid_ref_name() {
2453 let test_repo = TestRepo::<Sha256HashValue>::new();
2454 let repo = &test_repo.repo;
2455
2456 let (digest, _, _) = create_test_image(repo, Some("good:v1"), "amd64");
2457
2458 let bad_name = "@badref";
2460 let ref_path = format!("streams/refs/{}", oci_ref_path(bad_name));
2461 let manifest_id = manifest_identifier(&digest);
2462 let target = format!("../../{manifest_id}");
2463 repo.symlink(&ref_path, &target)
2464 .expect("create bad ref symlink");
2465
2466 let result = oci_fsck(repo).await.unwrap();
2467 assert!(
2468 result.images_corrupted > 0,
2469 "fsck should report corruption for @ in ref name"
2470 );
2471 assert!(
2472 result
2473 .errors
2474 .iter()
2475 .any(|e| matches!(e, OciFsckError::InvalidRefName { name } if name == bad_name)),
2476 "fsck should report InvalidRefName error"
2477 );
2478 let invalid_count = result
2480 .errors
2481 .iter()
2482 .filter(|e| matches!(e, OciFsckError::InvalidRefName { .. }))
2483 .count();
2484 assert_eq!(invalid_count, 1, "should report exactly one InvalidRefName");
2485 }
2486
2487 #[test]
2489 fn test_tag_existing_manifest() {
2490 let test_repo = TestRepo::<Sha256HashValue>::new();
2491 let repo = &test_repo.repo;
2492
2493 let (digest, verity, _) = create_test_image(repo, Some("original:v1"), "amd64");
2494
2495 tag_image(repo, &digest, "alias:latest").unwrap();
2496
2497 let (d1, v1) = resolve_ref(repo, "original:v1").unwrap();
2498 let (d2, v2) = resolve_ref(repo, "alias:latest").unwrap();
2499 assert_eq!(d1, d2);
2500 assert_eq!(v1, v2);
2501 assert_eq!(d1, digest);
2502 assert_eq!(v1, verity);
2503
2504 let images = list_images(repo).unwrap();
2505 assert_eq!(images.len(), 2);
2506
2507 untag_image(repo, "original:v1").unwrap();
2508 let (d3, _) = resolve_ref(repo, "alias:latest").unwrap();
2509 assert_eq!(d3, digest);
2510
2511 let images = list_images(repo).unwrap();
2512 assert_eq!(images.len(), 1);
2513 assert_eq!(images[0].name, "alias:latest");
2514 }
2515
2516 #[test]
2518 fn test_open_by_digest() {
2519 let test_repo = TestRepo::<Sha256HashValue>::new();
2520 let repo = &test_repo.repo;
2521
2522 let (digest, verity, config_digest) = create_test_image(repo, None, "amd64");
2523
2524 let images = list_images(repo).unwrap();
2525 assert!(images.is_empty());
2526
2527 let img = OciImage::open(repo, &digest, Some(&verity)).unwrap();
2528 assert_eq!(img.manifest_digest(), &digest);
2529 assert_eq!(img.config_digest(), &config_digest);
2530 assert!(img.is_container_image());
2531 assert_eq!(img.architecture(), "amd64");
2532
2533 let img2 = OciImage::open(repo, &digest, None).unwrap();
2534 assert_eq!(img2.manifest_digest(), &digest);
2535 }
2536
2537 #[test]
2539 fn test_fetch_manifest_config() {
2540 let test_repo = TestRepo::<Sha256HashValue>::new();
2541 let repo = &test_repo.repo;
2542
2543 let (digest, verity, config_digest) =
2544 create_test_image(repo, Some("fetchtest:v1"), "amd64");
2545
2546 let img = OciImage::open_ref(repo, "fetchtest:v1").unwrap();
2547
2548 assert_eq!(img.manifest_digest(), &digest);
2549 assert_eq!(img.manifest_verity(), &verity);
2550 let manifest = img.manifest();
2551 assert_eq!(manifest.schema_version(), 2u32);
2552 assert_eq!(manifest.layers().len(), 1);
2553
2554 assert_eq!(img.config_digest(), &config_digest);
2555 let config = img.config().expect("should have config");
2556 assert_eq!(config.architecture().to_string(), "amd64");
2557 assert_eq!(config.os().to_string(), "linux");
2558 assert_eq!(config.rootfs().diff_ids().len(), 1);
2559
2560 let diff_ids = img.layer_diff_ids();
2561 assert_eq!(diff_ids.len(), 1);
2562 let layer_verity = img.layer_verity(diff_ids[0]);
2563 assert!(layer_verity.is_some());
2564 }
2565
2566 #[test]
2568 fn test_has_manifest() {
2569 let test_repo = TestRepo::<Sha256HashValue>::new();
2570 let repo = &test_repo.repo;
2571
2572 let nonexistent: OciDigest =
2573 "sha256:0000000000000000000000000000000000000000000000000000000000000000"
2574 .parse()
2575 .unwrap();
2576 assert!(has_manifest(repo, &nonexistent).unwrap().is_none());
2577
2578 let (digest, verity, _) = create_test_image(repo, None, "amd64");
2579
2580 let found = has_manifest(repo, &digest).unwrap();
2581 assert!(found.is_some());
2582 assert_eq!(found.unwrap(), verity);
2583
2584 assert!(has_manifest(repo, &nonexistent).unwrap().is_none());
2585 }
2586
2587 #[test]
2589 fn test_empty_repo() {
2590 let test_repo = TestRepo::<Sha256HashValue>::new();
2591 let repo = &test_repo.repo;
2592
2593 let images = list_images(repo).unwrap();
2595 assert!(images.is_empty());
2596
2597 let refs = list_refs(repo).unwrap();
2598 assert!(refs.is_empty());
2599 }
2600
2601 #[test]
2603 fn test_untag_nonexistent() {
2604 let test_repo = TestRepo::<Sha256HashValue>::new();
2605 let repo = &test_repo.repo;
2606
2607 let result = untag_image(repo, "nonexistent:tag");
2608 assert!(result.is_err());
2609 }
2610
2611 #[test]
2620 fn test_gc_preserves_tagged_oci_image() {
2621 let test_repo = TestRepo::<Sha256HashValue>::new();
2622 let repo = &test_repo.repo;
2623
2624 let (manifest_digest, manifest_verity, config_digest) =
2625 create_test_image(repo, Some("myapp:v1"), "amd64");
2626
2627 let gc_result = repo.gc(&[]).unwrap();
2628
2629 assert_eq!(gc_result.objects_removed, 0);
2630 assert_eq!(gc_result.streams_pruned, 0);
2631
2632 let img = OciImage::open_ref(repo, "myapp:v1").unwrap();
2633 assert_eq!(img.manifest_digest(), &manifest_digest);
2634 assert_eq!(img.manifest_verity(), &manifest_verity);
2635 assert_eq!(img.config_digest(), &config_digest);
2636
2637 let diff_ids = img.layer_diff_ids();
2638 assert_eq!(diff_ids.len(), 1);
2639 assert!(img.layer_verity(diff_ids[0]).is_some());
2640 }
2641
2642 #[test]
2644 fn test_gc_removes_untagged_oci_image() {
2645 let test_repo = TestRepo::<Sha256HashValue>::new();
2646 let repo = &test_repo.repo;
2647
2648 let (manifest_digest, manifest_verity, _config_digest) =
2649 create_test_image(repo, None, "amd64");
2650
2651 let img = OciImage::open(repo, &manifest_digest, Some(&manifest_verity)).unwrap();
2652 let diff_ids = img.layer_diff_ids();
2653 assert_eq!(diff_ids.len(), 1);
2654 drop(img);
2655
2656 let gc_result = repo.gc(&[]).unwrap();
2657
2658 assert!(gc_result.objects_removed > 0);
2659
2660 let result = has_manifest(repo, &manifest_digest);
2661 assert!(
2662 result.unwrap().is_none(),
2663 "manifest should be gone after GC"
2664 );
2665 }
2666
2667 #[test]
2669 fn test_gc_after_untag_removes_image() {
2670 let test_repo = TestRepo::<Sha256HashValue>::new();
2671 let repo = &test_repo.repo;
2672
2673 let (manifest_digest, manifest_verity, _) =
2674 create_test_image(repo, Some("temporary:v1"), "amd64");
2675
2676 let gc_result = repo.gc(&[]).unwrap();
2677 assert_eq!(gc_result.objects_removed, 0);
2678
2679 untag_image(repo, "temporary:v1").unwrap();
2680
2681 assert!(OciImage::open_ref(repo, "temporary:v1").is_err());
2682
2683 assert!(OciImage::open(repo, &manifest_digest, Some(&manifest_verity)).is_ok());
2684
2685 let gc_result = repo.gc(&[]).unwrap();
2686 assert!(gc_result.objects_removed > 0);
2687
2688 assert!(has_manifest(repo, &manifest_digest).unwrap().is_none());
2689 }
2690
2691 #[test]
2693 fn test_gc_with_shared_layers() {
2694 let test_repo = TestRepo::<Sha256HashValue>::new();
2695 let repo = &test_repo.repo;
2696
2697 let shared_layer_data = b"shared-base-layer-content";
2698 let shared_layer_digest = hash_sha256(shared_layer_data);
2699
2700 let mut shared_layer_stream = repo
2701 .create_stream(crate::skopeo::TAR_LAYER_CONTENT_TYPE)
2702 .unwrap();
2703 shared_layer_stream
2704 .write_external(shared_layer_data)
2705 .unwrap();
2706 let shared_layer_verity = repo
2707 .write_stream(
2708 shared_layer_stream,
2709 &crate::layer_identifier(&shared_layer_digest),
2710 None,
2711 )
2712 .unwrap();
2713
2714 let create_image_with_shared_layer = |repo: &Arc<Repository<Sha256HashValue>>,
2716 tag: Option<&str>,
2717 extra_data: &[u8]|
2718 -> (OciDigest, Sha256HashValue) {
2719 let rootfs = RootFsBuilder::default()
2720 .typ("layers")
2721 .diff_ids(vec![shared_layer_digest.to_string()])
2722 .build()
2723 .unwrap();
2724
2725 let cfg = ConfigBuilder::default().build().unwrap();
2726
2727 let config = ImageConfigurationBuilder::default()
2729 .architecture("amd64")
2730 .os("linux")
2731 .rootfs(rootfs)
2732 .config(cfg)
2733 .created(String::from_utf8_lossy(extra_data).to_string())
2734 .build()
2735 .unwrap();
2736
2737 let config_json = config.to_string().unwrap();
2738 let config_digest = hash_sha256(config_json.as_bytes());
2739
2740 let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
2741 config_stream.add_named_stream_ref(shared_layer_digest.as_ref(), &shared_layer_verity);
2742 config_stream
2743 .write_external(config_json.as_bytes())
2744 .unwrap();
2745 let config_verity = repo
2746 .write_stream(
2747 config_stream,
2748 &crate::config_identifier(&config_digest),
2749 None,
2750 )
2751 .unwrap();
2752
2753 let config_descriptor = DescriptorBuilder::default()
2754 .media_type(MediaType::ImageConfig)
2755 .digest(config_digest.clone())
2756 .size(config_json.len() as u64)
2757 .build()
2758 .unwrap();
2759
2760 let layer_descriptor = DescriptorBuilder::default()
2761 .media_type(MediaType::ImageLayerGzip)
2762 .digest(shared_layer_digest.clone())
2763 .size(shared_layer_data.len() as u64)
2764 .build()
2765 .unwrap();
2766
2767 let manifest = ImageManifestBuilder::default()
2768 .schema_version(2u32)
2769 .media_type(MediaType::ImageManifest)
2770 .config(config_descriptor)
2771 .layers(vec![layer_descriptor])
2772 .build()
2773 .unwrap();
2774
2775 let layer_verities = [(shared_layer_digest.clone(), shared_layer_verity.clone())];
2776
2777 let manifest_json = manifest.to_string().unwrap();
2778 let manifest_digest = hash_sha256(manifest_json.as_bytes());
2779
2780 let (_stored_digest, manifest_verity) = write_manifest(
2781 repo,
2782 &manifest,
2783 &manifest_digest,
2784 &config_verity,
2785 &layer_verities,
2786 tag,
2787 )
2788 .unwrap();
2789
2790 (manifest_digest, manifest_verity)
2791 };
2792
2793 let (digest1, verity1) = create_image_with_shared_layer(repo, Some("tagged:v1"), b"image1");
2794 let (digest2, _verity2) = create_image_with_shared_layer(repo, None, b"image2");
2795
2796 assert!(has_manifest(repo, &digest1).unwrap().is_some());
2797 assert!(has_manifest(repo, &digest2).unwrap().is_some());
2798
2799 let gc_result = repo.gc(&[]).unwrap();
2800
2801 assert!(gc_result.objects_removed > 0);
2802
2803 let img1 = OciImage::open(repo, &digest1, Some(&verity1)).unwrap();
2804 assert_eq!(img1.layer_diff_ids().len(), 1);
2805 assert!(img1.layer_verity(shared_layer_digest.as_ref()).is_some());
2806
2807 assert!(has_manifest(repo, &digest2).unwrap().is_none());
2808
2809 assert!(
2811 repo.has_stream(&crate::layer_identifier(&shared_layer_digest))
2812 .unwrap()
2813 .is_some()
2814 );
2815 }
2816
2817 #[test]
2819 fn test_gc_with_multiple_tags_same_manifest() {
2820 let test_repo = TestRepo::<Sha256HashValue>::new();
2821 let repo = &test_repo.repo;
2822
2823 let (manifest_digest, manifest_verity, _) =
2825 create_test_image(repo, Some("original:v1"), "amd64");
2826
2827 tag_image(repo, &manifest_digest, "alias:latest").unwrap();
2828
2829 assert_eq!(list_images(repo).unwrap().len(), 2);
2830
2831 untag_image(repo, "original:v1").unwrap();
2832
2833 let gc_result = repo.gc(&[]).unwrap();
2834
2835 assert_eq!(gc_result.objects_removed, 0);
2836
2837 let img = OciImage::open_ref(repo, "alias:latest").unwrap();
2838 assert_eq!(img.manifest_digest(), &manifest_digest);
2839 assert_eq!(img.manifest_verity(), &manifest_verity);
2840
2841 let diff_ids = img.layer_diff_ids();
2842 assert!(img.layer_verity(diff_ids[0]).is_some());
2843
2844 untag_image(repo, "alias:latest").unwrap();
2845
2846 let gc_result = repo.gc(&[]).unwrap();
2847
2848 assert!(gc_result.objects_removed > 0);
2849 assert!(has_manifest(repo, &manifest_digest).unwrap().is_none());
2850 }
2851
2852 #[test]
2854 fn test_gc_dry_run_oci_image() {
2855 let test_repo = TestRepo::<Sha256HashValue>::new();
2856 let repo = &test_repo.repo;
2857
2858 let (tagged_digest, tagged_verity, _) = create_test_image(repo, Some("keep:v1"), "amd64");
2861 let (untagged_digest, _untagged_verity, _) = create_test_image(repo, None, "arm64");
2862
2863 assert!(has_manifest(repo, &tagged_digest).unwrap().is_some());
2864 assert!(has_manifest(repo, &untagged_digest).unwrap().is_some());
2865
2866 let dry_run_result = repo.gc_dry_run(&[]).unwrap();
2867 assert!(
2868 dry_run_result.objects_removed > 0,
2869 "dry-run should report objects to remove, got {:?}",
2870 dry_run_result
2871 );
2872
2873 assert!(has_manifest(repo, &tagged_digest).unwrap().is_some());
2875 assert!(has_manifest(repo, &untagged_digest).unwrap().is_some());
2876
2877 let img = OciImage::open(repo, &tagged_digest, Some(&tagged_verity)).unwrap();
2878 assert!(img.layer_verity(img.layer_diff_ids()[0]).is_some());
2879
2880 let real_result = repo.gc(&[]).unwrap();
2881
2882 assert_eq!(real_result.objects_removed, dry_run_result.objects_removed);
2883
2884 assert!(has_manifest(repo, &untagged_digest).unwrap().is_none());
2885 assert!(has_manifest(repo, &tagged_digest).unwrap().is_some());
2886 }
2887
2888 #[test]
2891 fn test_referrer_index_roundtrip() {
2892 let test_repo = TestRepo::<Sha256HashValue>::new();
2893 let repo = &test_repo.repo;
2894
2895 let (subject_digest, _, _) = create_test_image(repo, Some("subject:v1"), "amd64");
2896
2897 let empty_config = b"{}";
2898 let config_digest = hash_sha256(empty_config);
2899 let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
2900 config_stream.write_external(empty_config).unwrap();
2901 let config_verity = repo
2902 .write_stream(
2903 config_stream,
2904 &crate::config_identifier(&config_digest),
2905 None,
2906 )
2907 .unwrap();
2908
2909 let mut artifact_digests = Vec::new();
2910 for i in 0..2u8 {
2911 let blob_data = format!("artifact-blob-{i}").into_bytes();
2912 let (blob_digest, blob_verity) = write_blob(repo, &blob_data).unwrap();
2913
2914 let config_descriptor = DescriptorBuilder::default()
2915 .media_type(MediaType::EmptyJSON)
2916 .digest(config_digest.clone())
2917 .size(empty_config.len() as u64)
2918 .build()
2919 .unwrap();
2920
2921 let layer_descriptor = DescriptorBuilder::default()
2922 .media_type(MediaType::Other("application/octet-stream".to_string()))
2923 .digest(blob_digest.clone())
2924 .size(blob_data.len() as u64)
2925 .build()
2926 .unwrap();
2927
2928 let manifest = ImageManifestBuilder::default()
2929 .schema_version(2u32)
2930 .media_type(MediaType::ImageManifest)
2931 .config(config_descriptor)
2932 .layers(vec![layer_descriptor])
2933 .build()
2934 .unwrap();
2935
2936 let layer_verities = [(blob_digest, blob_verity)];
2937
2938 let manifest_json = manifest.to_string().unwrap();
2939 let manifest_digest = hash_sha256(manifest_json.as_bytes());
2940
2941 write_manifest(
2942 repo,
2943 &manifest,
2944 &manifest_digest,
2945 &config_verity,
2946 &layer_verities,
2947 None,
2948 )
2949 .unwrap();
2950
2951 add_referrer(repo, &subject_digest, &manifest_digest).unwrap();
2952 artifact_digests.push(manifest_digest);
2953 }
2954
2955 let referrers = list_referrers(repo, &subject_digest).unwrap();
2956 assert_eq!(referrers.len(), 2);
2957
2958 let found_digests: Vec<&OciDigest> = referrers.iter().map(|(d, _)| d).collect();
2959 for expected in &artifact_digests {
2960 assert!(
2961 found_digests.contains(&expected),
2962 "Missing artifact {expected} in referrers"
2963 );
2964 }
2965 }
2966
2967 fn create_test_artifact(
2971 repo: &Arc<Repository<Sha256HashValue>>,
2972 blob_data: &[u8],
2973 ) -> (OciDigest, Sha256HashValue) {
2974 let (blob_digest, blob_verity) = write_blob(repo, blob_data).unwrap();
2975
2976 let empty_config = b"{}";
2977 let config_digest = hash_sha256(empty_config);
2978
2979 let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
2980 config_stream.write_external(empty_config).unwrap();
2981 let config_verity = repo
2982 .write_stream(
2983 config_stream,
2984 &crate::config_identifier(&config_digest),
2985 None,
2986 )
2987 .unwrap();
2988
2989 let config_descriptor = DescriptorBuilder::default()
2990 .media_type(MediaType::EmptyJSON)
2991 .digest(config_digest.clone())
2992 .size(empty_config.len() as u64)
2993 .build()
2994 .unwrap();
2995
2996 let layer_descriptor = DescriptorBuilder::default()
2997 .media_type(MediaType::Other("application/octet-stream".to_string()))
2998 .digest(blob_digest.clone())
2999 .size(blob_data.len() as u64)
3000 .build()
3001 .unwrap();
3002
3003 let manifest = ImageManifestBuilder::default()
3004 .schema_version(2u32)
3005 .media_type(MediaType::ImageManifest)
3006 .config(config_descriptor)
3007 .layers(vec![layer_descriptor])
3008 .build()
3009 .unwrap();
3010
3011 let layer_verities = [(blob_digest, blob_verity)];
3012
3013 let manifest_json = manifest.to_string().unwrap();
3014 let manifest_digest = hash_sha256(manifest_json.as_bytes());
3015
3016 let (_stored_digest, manifest_verity) = write_manifest(
3017 repo,
3018 &manifest,
3019 &manifest_digest,
3020 &config_verity,
3021 &layer_verities,
3022 None,
3023 )
3024 .unwrap();
3025
3026 (manifest_digest, manifest_verity)
3027 }
3028
3029 #[test]
3035 fn test_gc_cleans_referrer_artifacts() {
3036 let test_repo = TestRepo::<Sha256HashValue>::new();
3037 let repo = &test_repo.repo;
3038
3039 let (subject_digest, _subject_verity, _) =
3041 create_test_image(repo, Some("subject:v1"), "amd64");
3042
3043 let (artifact_digest, _artifact_verity) =
3045 create_test_artifact(repo, b"fake-signature-data");
3046
3047 add_referrer(repo, &subject_digest, &artifact_digest).unwrap();
3049
3050 let referrers = list_referrers(repo, &subject_digest).unwrap();
3052 assert_eq!(referrers.len(), 1);
3053 assert_eq!(referrers[0].0, artifact_digest);
3054
3055 let gc = repo.gc(&[]).unwrap();
3057 assert_eq!(gc.objects_removed, 0, "nothing should be collected yet");
3058
3059 assert!(
3061 has_manifest(repo, &artifact_digest).unwrap().is_some(),
3062 "artifact manifest should exist"
3063 );
3064
3065 untag_image(repo, "subject:v1").unwrap();
3067
3068 let gc1 = repo.gc(&[]).unwrap();
3072 assert!(gc1.objects_removed > 0, "should collect subject objects");
3073 assert!(
3074 has_manifest(repo, &subject_digest).unwrap().is_none(),
3075 "subject manifest should be gone after first GC"
3076 );
3077 assert!(
3079 has_manifest(repo, &artifact_digest).unwrap().is_some(),
3080 "artifact should survive first GC (referrer symlink roots it)"
3081 );
3082
3083 let cleaned = cleanup_dangling_referrers(repo).unwrap();
3085 assert_eq!(cleaned, 1, "should remove 1 dangling referrer entry");
3086
3087 let gc2 = repo.gc(&[]).unwrap();
3089 assert!(gc2.objects_removed > 0, "should collect artifact objects");
3090
3091 assert!(
3093 has_manifest(repo, &artifact_digest).unwrap().is_none(),
3094 "artifact manifest should be collected"
3095 );
3096
3097 let referrers = list_referrers(repo, &subject_digest).unwrap();
3099 assert!(referrers.is_empty(), "no referrers should remain after GC");
3100
3101 assert!(
3103 has_manifest(repo, &subject_digest).unwrap().is_none(),
3104 "subject manifest should be collected"
3105 );
3106 }
3107
3108 #[test]
3110 fn test_cleanup_referrers_preserves_tagged_subjects() {
3111 let test_repo = TestRepo::<Sha256HashValue>::new();
3112 let repo = &test_repo.repo;
3113
3114 let (subject_digest, _, _) = create_test_image(repo, Some("subject:v1"), "amd64");
3116
3117 let (artifact_digest, _) = create_test_artifact(repo, b"sig-data");
3119 add_referrer(repo, &subject_digest, &artifact_digest).unwrap();
3120
3121 let cleaned = cleanup_dangling_referrers(repo).unwrap();
3123 assert_eq!(cleaned, 0, "should not remove referrers for tagged subject");
3124
3125 let referrers = list_referrers(repo, &subject_digest).unwrap();
3127 assert_eq!(referrers.len(), 1);
3128 }
3129
3130 #[test]
3132 fn test_cleanup_referrers_mixed_subjects() {
3133 let test_repo = TestRepo::<Sha256HashValue>::new();
3134 let repo = &test_repo.repo;
3135
3136 let (subject1_digest, _, _) = create_test_image(repo, Some("subject1:v1"), "amd64");
3138 let (subject2_digest, _, _) = create_test_image(repo, Some("subject2:v1"), "arm64");
3139
3140 let (artifact1_digest, _) = create_test_artifact(repo, b"sig-for-subject1");
3142 let (artifact2_digest, _) = create_test_artifact(repo, b"sig-for-subject2");
3143
3144 add_referrer(repo, &subject1_digest, &artifact1_digest).unwrap();
3145 add_referrer(repo, &subject2_digest, &artifact2_digest).unwrap();
3146
3147 untag_image(repo, "subject1:v1").unwrap();
3149
3150 repo.gc(&[]).unwrap();
3154
3155 let cleaned = cleanup_dangling_referrers(repo).unwrap();
3157 assert_eq!(cleaned, 1, "should remove 1 referrer for untagged subject");
3158
3159 let gc = repo.gc(&[]).unwrap();
3161 assert!(gc.objects_removed > 0);
3162
3163 let referrers2 = list_referrers(repo, &subject2_digest).unwrap();
3165 assert_eq!(referrers2.len(), 1);
3166 assert_eq!(referrers2[0].0, artifact2_digest);
3167
3168 assert!(has_manifest(repo, &artifact1_digest).unwrap().is_none());
3170 assert!(has_manifest(repo, &artifact2_digest).unwrap().is_some());
3172 }
3173
3174 #[test]
3176 fn test_cleanup_referrers_empty_repo() {
3177 let test_repo = TestRepo::<Sha256HashValue>::new();
3178 let repo = &test_repo.repo;
3179
3180 let cleaned = cleanup_dangling_referrers(repo).unwrap();
3181 assert_eq!(cleaned, 0);
3182 }
3183
3184 #[test]
3187 fn test_remove_referrer() {
3188 let test_repo = TestRepo::<Sha256HashValue>::new();
3189 let repo = &test_repo.repo;
3190
3191 let (subject_digest, _, _) = create_test_image(repo, Some("subject:v1"), "amd64");
3192 let (artifact_digest, _) = create_test_artifact(repo, b"sig-remove-test");
3193
3194 add_referrer(repo, &subject_digest, &artifact_digest).unwrap();
3195 assert_eq!(list_referrers(repo, &subject_digest).unwrap().len(), 1);
3196
3197 remove_referrer(repo, &subject_digest, &artifact_digest).unwrap();
3199 assert!(list_referrers(repo, &subject_digest).unwrap().is_empty());
3200
3201 remove_referrer(repo, &subject_digest, &artifact_digest).unwrap();
3203 }
3204
3205 mod proptests {
3208 use super::*;
3209 use proptest::prelude::*;
3210
3211 proptest! {
3212 #[test]
3213 fn encode_decode_tag_roundtrip(s in "\\PC*") {
3214 prop_assert_eq!(decode_tag(&encode_tag(&s)), s);
3215 }
3216
3217 #[test]
3218 fn encode_tag_no_slashes(s in "\\PC*") {
3219 prop_assert!(!encode_tag(&s).contains('/'));
3220 }
3221
3222 #[test]
3223 fn hash_deterministic_and_prefixed(data in proptest::collection::vec(any::<u8>(), 0..4096)) {
3224 let h1 = hash_sha256(&data);
3225 let h2 = hash_sha256(&data);
3226 prop_assert_eq!(&h1, &h2);
3227 prop_assert!(AsRef::<str>::as_ref(&h1).starts_with("sha256:"));
3228 }
3229
3230 #[test]
3231 fn manifest_identifier_format(hex in "[0-9a-f]{64}") {
3232 let digest_str = format!("sha256:{hex}");
3233 let digest: OciDigest = digest_str.parse().unwrap();
3234 let id = manifest_identifier(&digest);
3235 prop_assert!(id.starts_with("oci-manifest-"));
3236 prop_assert!(id.ends_with(&digest_str));
3237 }
3238
3239 #[test]
3240 fn blob_identifier_format(hex in "[0-9a-f]{64}") {
3241 let digest_str = format!("sha256:{hex}");
3242 let digest: OciDigest = digest_str.parse().unwrap();
3243 let id = blob_identifier(&digest);
3244 prop_assert!(id.starts_with("oci-blob-"));
3245 prop_assert!(id.ends_with(&digest_str));
3246 }
3247
3248 #[test]
3249 fn write_read_blob_roundtrip(data in proptest::collection::vec(any::<u8>(), 1..4096)) {
3250 let test_repo = TestRepo::<Sha256HashValue>::new();
3251 let repo = &test_repo.repo;
3252
3253 let (digest, verity) = write_blob(repo, &data).unwrap();
3254 let read_back = open_blob(repo, &digest, Some(&verity)).unwrap();
3255 prop_assert_eq!(read_back, data);
3256 }
3257 }
3258 }
3259
3260 #[test]
3262 fn test_remove_referrers_for_subject() {
3263 let test_repo = TestRepo::<Sha256HashValue>::new();
3264 let repo = &test_repo.repo;
3265
3266 let (subject_digest, _, _) = create_test_image(repo, Some("subject:v1"), "amd64");
3267 let (artifact1_digest, _) = create_test_artifact(repo, b"sig-bulk-1");
3268 let (artifact2_digest, _) = create_test_artifact(repo, b"sig-bulk-2");
3269
3270 add_referrer(repo, &subject_digest, &artifact1_digest).unwrap();
3271 add_referrer(repo, &subject_digest, &artifact2_digest).unwrap();
3272 assert_eq!(list_referrers(repo, &subject_digest).unwrap().len(), 2);
3273
3274 remove_referrers_for_subject(repo, &subject_digest).unwrap();
3276 assert!(list_referrers(repo, &subject_digest).unwrap().is_empty());
3277
3278 remove_referrers_for_subject(repo, &subject_digest).unwrap();
3280 }
3281
3282 #[tokio::test]
3285 async fn test_oci_fsck_healthy_image() {
3286 let test_repo = TestRepo::<Sha256HashValue>::new();
3287 let repo = &test_repo.repo;
3288
3289 create_test_image(repo, Some("healthy:v1"), "amd64");
3290
3291 let result = oci_fsck(repo).await.unwrap();
3292
3293 assert!(
3294 result.is_ok(),
3295 "oci_fsck should pass on healthy repo: {result}"
3296 );
3297 assert_eq!(result.images_checked, 1);
3298 assert_eq!(result.images_corrupted, 0);
3299 assert!(result.repo_result.is_ok());
3300 assert!(result.errors.is_empty());
3301 }
3302
3303 #[tokio::test]
3304 async fn test_oci_fsck_detects_corrupt_manifest() {
3305 let test_repo = TestRepo::<Sha256HashValue>::new();
3306 let repo = &test_repo.repo;
3307
3308 let (manifest_digest, manifest_verity, _) =
3309 create_test_image(repo, Some("corrupt:v1"), "amd64");
3310
3311 let manifest_id = manifest_identifier(&manifest_digest);
3314 let mut stream = repo
3315 .open_stream(&manifest_id, Some(&manifest_verity), None)
3316 .unwrap();
3317
3318 let mut object_refs: Vec<Sha256HashValue> = Vec::new();
3319 stream
3320 .get_object_refs(|id| object_refs.push(id.clone()))
3321 .unwrap();
3322 assert!(
3323 !object_refs.is_empty(),
3324 "manifest should have an external object ref"
3325 );
3326
3327 let obj = &object_refs[0];
3330 let hex = obj.to_hex();
3331 let (dir, file) = hex.split_at(2);
3332 let obj_path = test_repo.path().join(format!("objects/{dir}/{file}"));
3333 std::fs::remove_file(&obj_path).unwrap();
3334 std::fs::write(&obj_path, b"not valid manifest json").unwrap();
3335
3336 let result = oci_fsck(repo).await.unwrap();
3337
3338 assert!(
3340 !result.is_ok(),
3341 "oci_fsck should fail with corrupted manifest object: {result}"
3342 );
3343 assert!(
3344 result.repo_result().objects_corrupted() > 0,
3345 "repo fsck should detect corrupted object"
3346 );
3347 }
3348
3349 #[tokio::test]
3350 async fn test_oci_fsck_detects_missing_layer() {
3351 let test_repo = TestRepo::<Sha256HashValue>::new();
3352 let repo = &test_repo.repo;
3353
3354 let (manifest_digest, manifest_verity, _) =
3355 create_test_image(repo, Some("missing-layer:v1"), "amd64");
3356
3357 let img = OciImage::open(repo, &manifest_digest, Some(&manifest_verity)).unwrap();
3359 let diff_ids = img.layer_diff_ids();
3360 assert_eq!(diff_ids.len(), 1);
3361
3362 let diff_id_parsed: OciDigest = diff_ids[0].parse().unwrap();
3365 let layer_id = crate::layer_identifier(&diff_id_parsed);
3366 let stream_symlink = test_repo.path().join(format!("streams/{layer_id}"));
3367 std::fs::remove_file(&stream_symlink).unwrap();
3368
3369 let result = oci_fsck(repo).await.unwrap();
3370
3371 assert!(
3372 !result.is_ok(),
3373 "oci_fsck should detect missing layer: {result}"
3374 );
3375 assert!(
3376 result.images_corrupted > 0,
3377 "should report corrupted OCI image"
3378 );
3379 assert!(
3380 result
3381 .errors
3382 .iter()
3383 .any(|e| e.to_string().contains("layer-stream-missing")),
3384 "errors should mention missing layer stream: {:?}",
3385 result.errors
3386 );
3387 }
3388
3389 #[tokio::test]
3392 async fn test_oci_fsck_detects_config_digest_mismatch() {
3393 let test_repo = TestRepo::<Sha256HashValue>::new();
3397 let repo = &test_repo.repo;
3398
3399 let (manifest_digest, manifest_verity, config_digest) =
3400 create_test_image(repo, Some("config-corrupt:v1"), "amd64");
3401
3402 let img = OciImage::open(repo, &manifest_digest, Some(&manifest_verity)).unwrap();
3404 let config_verity = img.config_verity.clone();
3405 drop(img);
3406
3407 let config_id = crate::config_identifier(&config_digest);
3408 let mut stream = repo
3409 .open_stream(&config_id, Some(&config_verity), None)
3410 .unwrap();
3411 let mut config_obj_refs: Vec<Sha256HashValue> = Vec::new();
3412 stream
3413 .get_object_refs(|id| config_obj_refs.push(id.clone()))
3414 .unwrap();
3415 assert!(!config_obj_refs.is_empty());
3416
3417 let obj = &config_obj_refs[0];
3420 let hex = obj.to_hex();
3421 let (prefix, rest) = hex.split_at(2);
3422 let dir =
3423 cap_std::fs::Dir::open_ambient_dir(test_repo.path(), cap_std::ambient_authority())
3424 .unwrap();
3425 let obj_rel = format!("objects/{prefix}/{rest}");
3426 dir.remove_file(&obj_rel).unwrap();
3427 dir.write(
3429 &obj_rel,
3430 br#"{"architecture":"arm64","os":"linux","rootfs":{"type":"layers","diff_ids":[]}}"#,
3431 )
3432 .unwrap();
3433
3434 let result = oci_fsck(repo).await.unwrap();
3435
3436 assert!(
3439 !result.is_ok(),
3440 "oci_fsck should detect config corruption: {result}"
3441 );
3442 }
3443
3444 #[tokio::test]
3445 async fn test_oci_fsck_detects_missing_config_named_ref() {
3446 let test_repo = TestRepo::<Sha256HashValue>::new();
3454 let repo = &test_repo.repo;
3455
3456 let layer_data = b"fake-layer-data";
3458 let layer_digest = hash_sha256(layer_data);
3459
3460 let mut layer_stream = repo
3461 .create_stream(crate::skopeo::TAR_LAYER_CONTENT_TYPE)
3462 .unwrap();
3463 layer_stream.write_external(layer_data).unwrap();
3464 let layer_verity = repo
3465 .write_stream(layer_stream, &crate::layer_identifier(&layer_digest), None)
3466 .unwrap();
3467
3468 let rootfs = RootFsBuilder::default()
3469 .typ("layers")
3470 .diff_ids(vec![layer_digest.to_string()])
3471 .build()
3472 .unwrap();
3473 let cfg = ConfigBuilder::default().build().unwrap();
3474 let config = ImageConfigurationBuilder::default()
3475 .architecture("amd64")
3476 .os("linux")
3477 .rootfs(rootfs)
3478 .config(cfg)
3479 .build()
3480 .unwrap();
3481 let config_json = config.to_string().unwrap();
3482 let config_digest = hash_sha256(config_json.as_bytes());
3483
3484 let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
3486 config_stream.add_named_stream_ref(layer_digest.as_ref(), &layer_verity);
3487 config_stream
3488 .write_external(config_json.as_bytes())
3489 .unwrap();
3490 let _config_verity = repo
3491 .write_stream(
3492 config_stream,
3493 &crate::config_identifier(&config_digest),
3494 None,
3495 )
3496 .unwrap();
3497
3498 let config_descriptor = DescriptorBuilder::default()
3499 .media_type(MediaType::ImageConfig)
3500 .digest(config_digest.clone())
3501 .size(config_json.len() as u64)
3502 .build()
3503 .unwrap();
3504 let layer_descriptor = DescriptorBuilder::default()
3505 .media_type(MediaType::ImageLayerGzip)
3506 .digest(layer_digest.clone())
3507 .size(layer_data.len() as u64)
3508 .build()
3509 .unwrap();
3510 let manifest = ImageManifestBuilder::default()
3511 .schema_version(2u32)
3512 .media_type(MediaType::ImageManifest)
3513 .config(config_descriptor)
3514 .layers(vec![layer_descriptor])
3515 .build()
3516 .unwrap();
3517
3518 let manifest_json = manifest.to_string().unwrap();
3519 let manifest_digest = hash_sha256(manifest_json.as_bytes());
3520
3521 let manifest_id = manifest_identifier(&manifest_digest);
3523 let mut manifest_stream = repo.create_stream(OCI_MANIFEST_CONTENT_TYPE).unwrap();
3524 manifest_stream
3526 .write_external(manifest_json.as_bytes())
3527 .unwrap();
3528 let _manifest_verity = repo
3529 .write_stream(manifest_stream, &manifest_id, None)
3530 .unwrap();
3531
3532 let ref_path = oci_ref_path("no-config-ref:v1");
3534 let stream_path = format!("streams/{manifest_id}");
3535 repo.symlink(&format!("streams/refs/{ref_path}"), &stream_path)
3536 .unwrap();
3537
3538 let result = oci_fsck_image(repo, "no-config-ref:v1").await.unwrap();
3539
3540 assert!(
3541 !result.is_ok(),
3542 "oci_fsck should detect missing config ref: {result}"
3543 );
3544 assert!(
3545 result
3546 .errors
3547 .iter()
3548 .any(|e| e.to_string().contains("config-ref-missing")),
3549 "errors should mention missing config reference: {:?}",
3550 result.errors
3551 );
3552 }
3553
3554 #[tokio::test]
3555 async fn test_oci_fsck_healthy_artifact() {
3556 let test_repo = TestRepo::<Sha256HashValue>::new();
3559 let repo = &test_repo.repo;
3560
3561 let blob_data = b"artifact-content-for-fsck-test";
3563 let (blob_digest, blob_verity) = write_blob(repo, blob_data).unwrap();
3564
3565 let empty_config = b"{}";
3566 let config_digest = hash_sha256(empty_config);
3567 let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
3568 config_stream.write_external(empty_config).unwrap();
3569 let config_verity = repo
3570 .write_stream(
3571 config_stream,
3572 &crate::config_identifier(&config_digest),
3573 None,
3574 )
3575 .unwrap();
3576
3577 let config_descriptor = DescriptorBuilder::default()
3578 .media_type(MediaType::EmptyJSON) .digest(config_digest.clone())
3580 .size(empty_config.len() as u64)
3581 .build()
3582 .unwrap();
3583 let layer_descriptor = DescriptorBuilder::default()
3584 .media_type(MediaType::Other("application/octet-stream".to_string()))
3585 .digest(blob_digest.clone())
3586 .size(blob_data.len() as u64)
3587 .build()
3588 .unwrap();
3589 let manifest = ImageManifestBuilder::default()
3590 .schema_version(2u32)
3591 .media_type(MediaType::ImageManifest)
3592 .config(config_descriptor)
3593 .layers(vec![layer_descriptor])
3594 .build()
3595 .unwrap();
3596
3597 let layer_verities = [(blob_digest.to_string(), blob_verity)];
3598
3599 let manifest_json = manifest.to_string().unwrap();
3600 let manifest_digest = hash_sha256(manifest_json.as_bytes());
3601
3602 write_manifest(
3603 repo,
3604 &manifest,
3605 &manifest_digest,
3606 &config_verity,
3607 &layer_verities,
3608 Some("artifact-fsck:v1"),
3609 )
3610 .unwrap();
3611
3612 let result = oci_fsck(repo).await.unwrap();
3613 assert!(
3614 result.is_ok(),
3615 "oci_fsck should pass for healthy artifact: {result}"
3616 );
3617 assert_eq!(result.images_checked, 1);
3618 assert_eq!(result.images_corrupted, 0);
3619 }
3620
3621 #[tokio::test]
3622 async fn test_oci_fsck_detects_missing_artifact_layer_ref() {
3623 let test_repo = TestRepo::<Sha256HashValue>::new();
3627 let repo = &test_repo.repo;
3628
3629 let blob_data = b"artifact-blob-missing-ref";
3630 let (blob_digest, _blob_verity) = write_blob(repo, blob_data).unwrap();
3631
3632 let empty_config = b"{}";
3633 let config_digest = hash_sha256(empty_config);
3634 let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
3635 config_stream.write_external(empty_config).unwrap();
3636 let config_verity = repo
3637 .write_stream(
3638 config_stream,
3639 &crate::config_identifier(&config_digest),
3640 None,
3641 )
3642 .unwrap();
3643
3644 let config_descriptor = DescriptorBuilder::default()
3645 .media_type(MediaType::EmptyJSON)
3646 .digest(config_digest.clone())
3647 .size(empty_config.len() as u64)
3648 .build()
3649 .unwrap();
3650 let layer_descriptor = DescriptorBuilder::default()
3651 .media_type(MediaType::Other("application/wasm".to_string()))
3652 .digest(blob_digest.clone())
3653 .size(blob_data.len() as u64)
3654 .build()
3655 .unwrap();
3656 let manifest = ImageManifestBuilder::default()
3657 .schema_version(2u32)
3658 .media_type(MediaType::ImageManifest)
3659 .config(config_descriptor)
3660 .layers(vec![layer_descriptor])
3661 .build()
3662 .unwrap();
3663
3664 let layer_verities: Vec<(String, Sha256HashValue)> = Vec::new();
3666
3667 let manifest_json = manifest.to_string().unwrap();
3668 let manifest_digest = hash_sha256(manifest_json.as_bytes());
3669
3670 write_manifest(
3671 repo,
3672 &manifest,
3673 &manifest_digest,
3674 &config_verity,
3675 &layer_verities,
3676 Some("artifact-no-layer-ref:v1"),
3677 )
3678 .unwrap();
3679
3680 let result = oci_fsck(repo).await.unwrap();
3681
3682 assert!(
3683 !result.is_ok(),
3684 "oci_fsck should detect missing artifact layer ref: {result}"
3685 );
3686 assert!(
3687 result
3688 .errors
3689 .iter()
3690 .any(|e| e.to_string().contains("artifact-layer-ref-missing")),
3691 "errors should mention missing layer reference: {:?}",
3692 result.errors
3693 );
3694 }
3695
3696 #[tokio::test]
3697 async fn test_oci_fsck_image_unresolvable_ref() {
3698 let test_repo = TestRepo::<Sha256HashValue>::new();
3700 let repo = &test_repo.repo;
3701
3702 let result = oci_fsck_image(repo, "nonexistent:tag").await.unwrap();
3703
3704 assert!(!result.is_ok(), "should fail for nonexistent ref");
3705 assert_eq!(result.images_checked, 1);
3706 assert_eq!(result.images_corrupted, 1);
3707 assert!(
3708 result
3709 .errors
3710 .iter()
3711 .any(|e| e.to_string().contains("ref-resolve-failed")),
3712 "errors should mention cannot resolve ref: {:?}",
3713 result.errors
3714 );
3715 }
3716
3717 #[tokio::test]
3718 async fn test_oci_fsck_multiple_images_partial_corruption() {
3719 let test_repo = TestRepo::<Sha256HashValue>::new();
3722 let repo = &test_repo.repo;
3723
3724 create_test_image(repo, Some("healthy1:v1"), "amd64");
3726 let (manifest_digest2, manifest_verity2, _) =
3727 create_test_image(repo, Some("corrupt1:v1"), "arm64");
3728
3729 let img = OciImage::open(repo, &manifest_digest2, Some(&manifest_verity2)).unwrap();
3731 let diff_ids = img.layer_diff_ids();
3732 let diff_id_parsed: OciDigest = diff_ids[0].parse().unwrap();
3733 let layer_id = crate::layer_identifier(&diff_id_parsed);
3734 let dir =
3735 cap_std::fs::Dir::open_ambient_dir(test_repo.path(), cap_std::ambient_authority())
3736 .unwrap();
3737 dir.remove_file(format!("streams/{layer_id}")).unwrap();
3738
3739 let result = oci_fsck(repo).await.unwrap();
3740
3741 assert!(!result.is_ok(), "should detect corruption: {result}");
3742 assert_eq!(result.images_checked, 2);
3743 assert_eq!(
3744 result.images_corrupted, 1,
3745 "only one image should be corrupt"
3746 );
3747 }
3748
3749 #[tokio::test]
3750 async fn test_oci_fsck_detects_missing_layer_named_ref_in_config() {
3751 let test_repo = TestRepo::<Sha256HashValue>::new();
3755 let repo = &test_repo.repo;
3756
3757 let layer_data = b"layer-for-missing-ref-test";
3758 let layer_digest = hash_sha256(layer_data);
3759
3760 let mut layer_stream = repo
3761 .create_stream(crate::skopeo::TAR_LAYER_CONTENT_TYPE)
3762 .unwrap();
3763 layer_stream.write_external(layer_data).unwrap();
3764 let layer_verity = repo
3765 .write_stream(layer_stream, &crate::layer_identifier(&layer_digest), None)
3766 .unwrap();
3767
3768 let rootfs = RootFsBuilder::default()
3769 .typ("layers")
3770 .diff_ids(vec![layer_digest.to_string()])
3771 .build()
3772 .unwrap();
3773 let cfg = ConfigBuilder::default().build().unwrap();
3774 let config = ImageConfigurationBuilder::default()
3775 .architecture("amd64")
3776 .os("linux")
3777 .rootfs(rootfs)
3778 .config(cfg)
3779 .build()
3780 .unwrap();
3781 let config_json = config.to_string().unwrap();
3782 let config_digest = hash_sha256(config_json.as_bytes());
3783
3784 let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE).unwrap();
3786 config_stream
3788 .write_external(config_json.as_bytes())
3789 .unwrap();
3790 let config_verity = repo
3791 .write_stream(
3792 config_stream,
3793 &crate::config_identifier(&config_digest),
3794 None,
3795 )
3796 .unwrap();
3797
3798 let config_descriptor = DescriptorBuilder::default()
3799 .media_type(MediaType::ImageConfig)
3800 .digest(config_digest.clone())
3801 .size(config_json.len() as u64)
3802 .build()
3803 .unwrap();
3804 let layer_descriptor = DescriptorBuilder::default()
3805 .media_type(MediaType::ImageLayerGzip)
3806 .digest(layer_digest.clone())
3807 .size(layer_data.len() as u64)
3808 .build()
3809 .unwrap();
3810 let manifest = ImageManifestBuilder::default()
3811 .schema_version(2u32)
3812 .media_type(MediaType::ImageManifest)
3813 .config(config_descriptor)
3814 .layers(vec![layer_descriptor])
3815 .build()
3816 .unwrap();
3817
3818 let layer_verities = [(layer_digest.to_string(), layer_verity)];
3819 let manifest_json = manifest.to_string().unwrap();
3820 let manifest_digest = hash_sha256(manifest_json.as_bytes());
3821
3822 write_manifest(
3823 repo,
3824 &manifest,
3825 &manifest_digest,
3826 &config_verity,
3827 &layer_verities,
3828 Some("missing-layer-ref:v1"),
3829 )
3830 .unwrap();
3831
3832 let result = oci_fsck(repo).await.unwrap();
3833
3834 assert!(
3835 !result.is_ok(),
3836 "oci_fsck should detect missing layer ref in config: {result}"
3837 );
3838 assert!(
3839 result
3840 .errors
3841 .iter()
3842 .any(|e| e.to_string().contains("layer-ref-missing")),
3843 "errors should mention config missing layer reference: {:?}",
3844 result.errors
3845 );
3846 }
3847}