Skip to main content

a3s_box_runtime/oci/
pull.rs

1//! High-level OCI image pull orchestrator.
2//!
3//! Combines the registry puller and image store to provide a cache-first
4//! pull workflow. Images are checked in the local store first; if not found,
5//! they are pulled from the registry and stored locally.
6
7use std::sync::Arc;
8
9use a3s_box_core::error::{BoxError, Result};
10use a3s_box_core::StoredImage;
11
12use super::image::OciImage;
13use super::reference::ImageReference;
14use super::registry::{RegistryAuth, RegistryPuller};
15use super::store::ImageStore;
16
17/// Callback type for layer pull progress: `(current, total, digest, size_bytes)`.
18type PullProgressFn = Arc<dyn Fn(usize, usize, &str, i64) + Send + Sync>;
19
20/// High-level image puller with caching.
21pub struct ImagePuller {
22    store: Arc<ImageStore>,
23    puller: RegistryPuller,
24    metrics: Option<crate::prom::RuntimeMetrics>,
25    /// Registry mirror map (original host → mirror host). Pulls fetch layers and
26    /// manifests from the mirror while the image keeps its canonical reference
27    /// for storage and identity. Parsed from `A3S_REGISTRY_MIRRORS`
28    /// (`host=mirror,host=mirror`) — lets a3s-box pull in registry-restricted
29    /// environments, like containerd's registry mirrors.
30    mirrors: std::collections::HashMap<String, String>,
31}
32
33/// Parse `A3S_REGISTRY_MIRRORS=host=mirror,host=mirror` into a map.
34fn parse_registry_mirrors() -> std::collections::HashMap<String, String> {
35    std::env::var("A3S_REGISTRY_MIRRORS")
36        .ok()
37        .map(|raw| {
38            raw.split(',')
39                .filter_map(|pair| pair.split_once('='))
40                .map(|(host, mirror)| (host.trim().to_string(), mirror.trim().to_string()))
41                .filter(|(host, mirror)| !host.is_empty() && !mirror.is_empty())
42                .collect()
43        })
44        .unwrap_or_default()
45}
46
47impl ImagePuller {
48    /// Create a new image puller.
49    pub fn new(store: Arc<ImageStore>, auth: RegistryAuth) -> Self {
50        Self::with_platform(store, auth, None)
51    }
52
53    /// Create an image puller that resolves multi-arch images to an explicit
54    /// platform (e.g. "linux/arm64") instead of the host architecture. `None`
55    /// keeps the host-architecture default.
56    pub fn with_platform(
57        store: Arc<ImageStore>,
58        auth: RegistryAuth,
59        platform: Option<String>,
60    ) -> Self {
61        Self {
62            store,
63            puller: RegistryPuller::with_auth_and_platform(auth, platform),
64            metrics: None,
65            mirrors: parse_registry_mirrors(),
66        }
67    }
68
69    /// Resolve a reference to its fetch target, applying a configured registry
70    /// mirror. The returned reference is used only for the registry HTTP
71    /// fetch — storage and identity always use the original canonical reference.
72    fn mirror_reference(&self, reference: &ImageReference) -> ImageReference {
73        match self.mirrors.get(&reference.registry) {
74            Some(mirror) if !mirror.is_empty() => {
75                tracing::info!(
76                    registry = %reference.registry,
77                    mirror = %mirror,
78                    "Pulling via configured registry mirror"
79                );
80                let mut mirrored = reference.clone();
81                mirror.clone_into(&mut mirrored.registry);
82                mirrored
83            }
84            _ => reference.clone(),
85        }
86    }
87
88    /// Attach Prometheus metrics to this puller.
89    pub fn set_metrics(mut self, metrics: crate::prom::RuntimeMetrics) -> Self {
90        self.metrics = Some(metrics);
91        self
92    }
93
94    /// Set the signature verification policy for image pulls.
95    pub fn with_signature_policy(mut self, policy: super::signing::SignaturePolicy) -> Self {
96        self.puller = self.puller.with_signature_policy(policy);
97        self
98    }
99
100    /// Set a layer progress callback: `(current, total, digest, size_bytes)`.
101    pub fn with_progress_fn(mut self, f: PullProgressFn) -> Self {
102        self.puller = self.puller.with_progress_fn(f);
103        self
104    }
105
106    /// Pull an image, using the local cache if available.
107    ///
108    /// Returns the loaded OCI image from the store.
109    pub async fn pull(&self, reference: &str) -> Result<OciImage> {
110        self.pull_resolved(reference).await.map(|(image, _)| image)
111    }
112
113    async fn pull_resolved(&self, reference: &str) -> Result<(OciImage, String)> {
114        let reference = reference.trim();
115        if is_digest_reference(reference) {
116            let Some((matched_reference, stored)) = self.cached_digest_image(reference).await?
117            else {
118                return Err(BoxError::OciImageError(format!(
119                    "Image digest not found in local cache: {reference}"
120                )));
121            };
122            tracing::info!(
123                requested_reference = %reference,
124                matched_reference = %matched_reference,
125                digest = %stored.digest,
126                "Using cached image by digest"
127            );
128            return Ok((OciImage::from_path(&stored.path)?, matched_reference));
129        }
130
131        let parsed = ImageReference::parse(reference)?;
132
133        if let Some((matched_reference, stored)) = self.cached_image(reference, &parsed).await? {
134            tracing::info!(
135                requested_reference = %reference,
136                matched_reference = %matched_reference,
137                digest = %stored.digest,
138                "Using cached image"
139            );
140            return Ok((OciImage::from_path(&stored.path)?, matched_reference));
141        }
142
143        Ok((self.pull_and_store(&parsed).await?, parsed.full_reference()))
144    }
145
146    /// Pull an image, bypassing the local cache.
147    pub async fn force_pull(&self, reference: &str) -> Result<OciImage> {
148        let reference = reference.trim();
149        if is_digest_reference(reference) {
150            return Err(BoxError::OciImageError(format!(
151                "Cannot force-pull digest-only reference {reference}; use a tagged registry reference"
152            )));
153        }
154
155        let parsed = ImageReference::parse(reference)?;
156
157        for candidate in cache_reference_candidates(reference, &parsed) {
158            if self.store.get(&candidate).await.is_some() {
159                let _ = self.store.remove(&candidate).await;
160            }
161        }
162
163        self.pull_and_store(&parsed).await
164    }
165
166    /// Check if an image is already cached.
167    pub async fn is_cached(&self, reference: &str) -> bool {
168        let reference = reference.trim();
169        if is_digest_reference(reference) {
170            return matches!(self.cached_digest_image(reference).await, Ok(Some(_)));
171        }
172
173        let parsed = match ImageReference::parse(reference) {
174            Ok(p) => p,
175            Err(_) => return false,
176        };
177        matches!(self.cached_image(reference, &parsed).await, Ok(Some(_)))
178    }
179
180    /// Remove a cached image by reference.
181    pub async fn remove_cached(&self, reference: &str) -> Result<bool> {
182        let reference = reference.trim();
183        if is_digest_reference(reference) {
184            if let Some((matched_reference, _)) = self.cached_digest_image(reference).await? {
185                self.store.remove(&matched_reference).await?;
186                return Ok(true);
187            }
188            return Ok(false);
189        }
190
191        let parsed = ImageReference::parse(reference)?;
192        if let Some((matched_reference, _)) = self.cached_image(reference, &parsed).await? {
193            self.store.remove(&matched_reference).await?;
194            Ok(true)
195        } else {
196            Ok(false)
197        }
198    }
199
200    /// List all cached image references.
201    pub async fn list_cached(&self) -> Result<Vec<String>> {
202        Ok(self
203            .store
204            .list()
205            .await
206            .into_iter()
207            .map(|img| img.reference)
208            .collect())
209    }
210
211    /// Pull from registry and store locally.
212    async fn pull_and_store(&self, reference: &ImageReference) -> Result<OciImage> {
213        let full_ref = reference.full_reference();
214
215        // Fetch from a configured registry mirror when one applies; the image
216        // keeps its canonical reference (full_ref) for storage and identity.
217        let fetch = self.mirror_reference(reference);
218
219        // Get the manifest digest for storage key. The registry returns it
220        // verbatim (the Docker-Content-Digest header), so validate it before it is
221        // used to build any on-disk path below (the temp dir that gets
222        // remove_dir_all'd, the store key) — a `sha256:../../x` value would
223        // otherwise be a path-traversal arbitrary-dir-delete / store-escape.
224        let digest = self.puller.pull_manifest_digest(&fetch).await?;
225        super::registry::validated_digest_hex(&digest)?;
226
227        // Check if we already have this digest (different tag, same content)
228        if let Some(stored) = self.store.get_by_digest(&digest).await {
229            tracing::info!(
230                reference = %full_ref,
231                digest = %digest,
232                "Image content already cached under different reference"
233            );
234            // Store under the new reference too
235            self.store.put(&full_ref, &digest, &stored.path).await?;
236            return OciImage::from_path(&stored.path);
237        }
238
239        // Pull to a temporary directory first.
240        // Strip the "sha256:" prefix so the directory name is pure hex,
241        // which is valid on all platforms (Windows forbids ':' in filenames).
242        let digest_hex = digest.strip_prefix("sha256:").unwrap_or(&digest);
243        let tmp_dir = self.store.store_dir().join("tmp").join(digest_hex);
244        if tmp_dir.exists() {
245            std::fs::remove_dir_all(&tmp_dir).map_err(|e| {
246                BoxError::OciImageError(format!(
247                    "Failed to clean temp directory {}: {}",
248                    tmp_dir.display(),
249                    e
250                ))
251            })?;
252        }
253
254        let pull_start = std::time::Instant::now();
255        // Remove the partially-written temp directory on ANY failure so aborted
256        // pulls (network error, signature failure, disk error) don't accumulate
257        // under store_dir/tmp — each can be hundreds of MB and is never counted
258        // toward the cache size or evicted by the LRU.
259        if let Err(e) = self.puller.pull(&fetch, &tmp_dir).await {
260            let _ = std::fs::remove_dir_all(&tmp_dir);
261            return Err(e);
262        }
263        if let Some(ref m) = self.metrics {
264            m.image_pull_total.inc();
265            m.image_pull_duration
266                .observe(pull_start.elapsed().as_secs_f64());
267        }
268
269        // Store in the image store
270        let stored = match self.store.put(&full_ref, &digest, &tmp_dir).await {
271            Ok(stored) => stored,
272            Err(e) => {
273                let _ = std::fs::remove_dir_all(&tmp_dir);
274                return Err(e);
275            }
276        };
277
278        // Clean up temp directory
279        if let Err(e) = std::fs::remove_dir_all(&tmp_dir) {
280            tracing::warn!(path = %tmp_dir.display(), error = %e, "Failed to remove temp dir after pull");
281        }
282
283        // Evict old images if over capacity
284        let evicted = self.store.evict().await?;
285        if !evicted.is_empty() {
286            tracing::info!(
287                count = evicted.len(),
288                references = ?evicted,
289                "Evicted images from cache"
290            );
291        }
292
293        OciImage::from_path(&stored.path)
294    }
295
296    async fn cached_image(
297        &self,
298        reference: &str,
299        parsed: &ImageReference,
300    ) -> Result<Option<(String, StoredImage)>> {
301        for candidate in cache_reference_candidates(reference, parsed) {
302            if let Some(stored) = self.store.get(&candidate).await {
303                return Ok(Some((candidate, stored)));
304            }
305        }
306        if let Some(digest) = parsed.digest.as_deref() {
307            return self.cached_digest_image(digest).await;
308        }
309        Ok(None)
310    }
311
312    async fn cached_digest_image(&self, digest: &str) -> Result<Option<(String, StoredImage)>> {
313        let images = self.store.list().await;
314        let mut matches = Vec::new();
315
316        for image in images {
317            let already_matched = matches
318                .iter()
319                .any(|matched: &StoredImage| matched.reference == image.reference);
320            if (digest_matches(&image.digest, digest) || image.reference == digest)
321                && !already_matched
322            {
323                matches.push(image);
324            }
325        }
326
327        match matches.len() {
328            0 => Ok(None),
329            1 => {
330                let stored = matches.pop().expect("checked one match");
331                let stored = self.store.get(&stored.reference).await.unwrap_or(stored);
332                Ok(Some((stored.reference.clone(), stored)))
333            }
334            _ => Err(BoxError::OciImageError(ambiguous_digest_error(
335                digest, &matches,
336            ))),
337        }
338    }
339}
340
341fn ambiguous_digest_error(query: &str, matches: &[StoredImage]) -> String {
342    let mut references: Vec<_> = matches
343        .iter()
344        .map(|image| image.reference.as_str())
345        .collect();
346    references.sort_unstable();
347    format!(
348        "Image digest '{query}' is ambiguous; it matches: {}",
349        references.join(", ")
350    )
351}
352
353fn is_digest_reference(reference: &str) -> bool {
354    reference.starts_with("sha256:")
355}
356
357fn digest_matches(stored_digest: &str, query: &str) -> bool {
358    if stored_digest == query {
359        return true;
360    }
361    let Some(query_hex) = query.strip_prefix("sha256:") else {
362        return false;
363    };
364    !query_hex.is_empty() && stored_digest.starts_with(query)
365}
366
367fn cache_reference_candidates(reference: &str, parsed: &ImageReference) -> Vec<String> {
368    let mut candidates = Vec::new();
369    push_unique(&mut candidates, reference.trim().to_string());
370    push_unique(&mut candidates, parsed.full_reference());
371
372    if parsed.registry == "docker.io" {
373        let repository = parsed
374            .repository
375            .strip_prefix("library/")
376            .unwrap_or(&parsed.repository);
377        push_unique(
378            &mut candidates,
379            reference_from_repository(repository, parsed.tag.as_deref(), parsed.digest.as_deref()),
380        );
381
382        if parsed.digest.is_none() && parsed.tag.as_deref() == Some("latest") {
383            push_unique(&mut candidates, repository.to_string());
384        }
385    }
386
387    if let Some(digest) = &parsed.digest {
388        push_unique(&mut candidates, digest.clone());
389    }
390
391    candidates
392}
393
394fn reference_from_repository(repository: &str, tag: Option<&str>, digest: Option<&str>) -> String {
395    let mut reference = repository.to_string();
396    if let Some(tag) = tag {
397        reference.push(':');
398        reference.push_str(tag);
399    }
400    if let Some(digest) = digest {
401        reference.push('@');
402        reference.push_str(digest);
403    }
404    reference
405}
406
407fn push_unique(values: &mut Vec<String>, value: String) {
408    if !value.is_empty() && !values.iter().any(|existing| existing == &value) {
409        values.push(value);
410    }
411}
412
413#[async_trait::async_trait]
414impl a3s_box_core::traits::ImageRegistry for ImagePuller {
415    async fn pull(&self, reference: &str) -> Result<a3s_box_core::traits::PulledImage> {
416        let (image, resolved_reference) = self.pull_resolved(reference).await?;
417        Ok(a3s_box_core::traits::PulledImage {
418            path: image.root_dir().to_path_buf(),
419            digest: image.manifest_digest().to_string(),
420            reference: resolved_reference,
421        })
422    }
423
424    async fn force_pull(&self, reference: &str) -> Result<a3s_box_core::traits::PulledImage> {
425        let image = self.force_pull(reference).await?;
426        let parsed = ImageReference::parse(reference)?;
427        Ok(a3s_box_core::traits::PulledImage {
428            path: image.root_dir().to_path_buf(),
429            digest: image.manifest_digest().to_string(),
430            reference: parsed.full_reference(),
431        })
432    }
433
434    async fn is_cached(&self, reference: &str) -> bool {
435        self.is_cached(reference).await
436    }
437
438    async fn remove(&self, reference: &str) -> Result<bool> {
439        self.remove_cached(reference).await
440    }
441
442    async fn list_cached(&self) -> Result<Vec<String>> {
443        self.list_cached().await
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::oci::store::ImageStore;
451    use std::path::Path;
452    use tempfile::TempDir;
453
454    #[test]
455    fn test_image_puller_creation() {
456        let tmp = TempDir::new().unwrap();
457        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
458        let _puller = ImagePuller::new(store, RegistryAuth::anonymous());
459    }
460
461    #[tokio::test]
462    async fn test_is_cached_empty_store() {
463        let tmp = TempDir::new().unwrap();
464        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
465        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
466        assert!(!puller.is_cached("nginx:latest").await);
467    }
468
469    #[tokio::test]
470    async fn test_is_cached_invalid_reference() {
471        let tmp = TempDir::new().unwrap();
472        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
473        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
474        assert!(!puller.is_cached("").await);
475    }
476
477    #[tokio::test]
478    async fn test_is_cached_matches_docker_hub_aliases() {
479        let tmp = TempDir::new().unwrap();
480        let source = TempDir::new().unwrap();
481        create_complete_oci_image(source.path());
482        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
483        store
484            .put("alpine:latest", "sha256:manifestxyz789", source.path())
485            .await
486            .unwrap();
487
488        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
489
490        assert!(puller.is_cached("alpine:latest").await);
491        assert!(puller.is_cached("docker.io/library/alpine:latest").await);
492        assert!(puller.is_cached("alpine").await);
493    }
494
495    #[tokio::test]
496    async fn test_pull_uses_cached_short_alias_for_full_reference() {
497        let tmp = TempDir::new().unwrap();
498        let source = TempDir::new().unwrap();
499        create_complete_oci_image(source.path());
500        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
501        store
502            .put("alpine:latest", "sha256:manifestxyz789", source.path())
503            .await
504            .unwrap();
505        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
506
507        let image = puller
508            .pull("docker.io/library/alpine:latest")
509            .await
510            .unwrap();
511
512        assert_eq!(image.manifest_digest(), "sha256:manifestxyz789");
513    }
514
515    #[tokio::test]
516    async fn test_pull_uses_cached_digest_reference_without_registry_parse() {
517        let tmp = TempDir::new().unwrap();
518        let source = TempDir::new().unwrap();
519        create_complete_oci_image(source.path());
520        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
521        store
522            .put("alpine:latest", "sha256:manifestxyz789", source.path())
523            .await
524            .unwrap();
525        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
526
527        let image = puller.pull("sha256:manifestxyz789").await.unwrap();
528
529        assert_eq!(image.manifest_digest(), "sha256:manifestxyz789");
530    }
531
532    #[tokio::test]
533    async fn test_is_cached_matches_digest_prefix() {
534        let tmp = TempDir::new().unwrap();
535        let source = TempDir::new().unwrap();
536        create_complete_oci_image(source.path());
537        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
538        store
539            .put("alpine:latest", "sha256:manifestxyz789", source.path())
540            .await
541            .unwrap();
542        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
543
544        assert!(puller.is_cached("sha256:manifest").await);
545    }
546
547    #[tokio::test]
548    async fn test_pull_reports_missing_digest_reference_as_local_cache_miss() {
549        let tmp = TempDir::new().unwrap();
550        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
551        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
552
553        let error = puller.pull("sha256:notfound").await.unwrap_err();
554
555        assert!(error.to_string().contains("not found in local cache"));
556    }
557
558    #[tokio::test]
559    async fn test_pull_reports_ambiguous_digest_prefix() {
560        let tmp = TempDir::new().unwrap();
561        let source = TempDir::new().unwrap();
562        create_complete_oci_image(source.path());
563        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
564        store
565            .put("alpine:latest", "sha256:manifestaaa", source.path())
566            .await
567            .unwrap();
568        store
569            .put("busybox:latest", "sha256:manifestbbb", source.path())
570            .await
571            .unwrap();
572        let puller = ImagePuller::new(store, RegistryAuth::anonymous());
573
574        let error = puller.pull("sha256:manifest").await.unwrap_err();
575
576        assert!(error.to_string().contains("ambiguous"));
577        assert!(error.to_string().contains("alpine:latest"));
578        assert!(error.to_string().contains("busybox:latest"));
579    }
580
581    #[tokio::test]
582    async fn test_remove_cached_matches_docker_hub_alias() {
583        let tmp = TempDir::new().unwrap();
584        let source = TempDir::new().unwrap();
585        create_complete_oci_image(source.path());
586        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
587        store
588            .put("alpine:latest", "sha256:manifestxyz789", source.path())
589            .await
590            .unwrap();
591        let puller = ImagePuller::new(store.clone(), RegistryAuth::anonymous());
592
593        assert!(puller
594            .remove_cached("docker.io/library/alpine:latest")
595            .await
596            .unwrap());
597        assert!(store.get("alpine:latest").await.is_none());
598    }
599
600    #[test]
601    fn test_set_metrics_attaches_to_puller() {
602        let tmp = TempDir::new().unwrap();
603        let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
604        let metrics = crate::prom::RuntimeMetrics::new();
605        // Verify set_metrics() returns Self (builder pattern) and metrics start at zero
606        let puller =
607            ImagePuller::new(store, RegistryAuth::anonymous()).set_metrics(metrics.clone());
608        assert!(puller.metrics.is_some());
609        assert_eq!(metrics.image_pull_total.get(), 0);
610        assert_eq!(metrics.image_pull_duration.get_sample_count(), 0);
611    }
612
613    #[test]
614    fn test_cache_reference_candidates_include_short_docker_hub_aliases() {
615        let parsed = ImageReference::parse("docker.io/library/alpine:latest").unwrap();
616        let candidates = cache_reference_candidates("docker.io/library/alpine:latest", &parsed);
617
618        assert_eq!(
619            candidates,
620            vec![
621                "docker.io/library/alpine:latest".to_string(),
622                "alpine:latest".to_string(),
623                "alpine".to_string(),
624            ]
625        );
626    }
627
628    #[test]
629    fn test_digest_matches_exact_and_prefix_queries() {
630        assert!(digest_matches("sha256:abcdef123456", "sha256:abcdef123456"));
631        assert!(digest_matches("sha256:abcdef123456", "sha256:abcdef"));
632        assert!(!digest_matches("sha256:abcdef123456", "sha256:"));
633        assert!(!digest_matches("sha256:abcdef123456", "abcdef"));
634    }
635
636    fn create_complete_oci_image(path: &Path) {
637        std::fs::create_dir_all(path.join("blobs/sha256")).unwrap();
638        std::fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
639
640        let config_content = r#"{
641            "architecture": "amd64",
642            "os": "linux",
643            "config": {
644                "Entrypoint": ["/bin/sh"],
645                "Cmd": ["-c", "true"],
646                "Env": ["PATH=/usr/bin:/bin"],
647                "WorkingDir": "/"
648            },
649            "rootfs": {
650                "type": "layers",
651                "diff_ids": ["sha256:layerdiff"]
652            },
653            "history": []
654        }"#;
655        let config_hash = "configabc123";
656        std::fs::write(path.join("blobs/sha256").join(config_hash), config_content).unwrap();
657
658        let layer_hash = "layerdef456";
659        std::fs::write(path.join("blobs/sha256").join(layer_hash), b"layer").unwrap();
660
661        let manifest_content = format!(
662            r#"{{
663            "schemaVersion": 2,
664            "mediaType": "application/vnd.oci.image.manifest.v1+json",
665            "config": {{
666                "mediaType": "application/vnd.oci.image.config.v1+json",
667                "digest": "sha256:{}",
668                "size": {}
669            }},
670            "layers": [
671                {{
672                    "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
673                    "digest": "sha256:{}",
674                    "size": 5
675                }}
676            ]
677        }}"#,
678            config_hash,
679            config_content.len(),
680            layer_hash
681        );
682        let manifest_hash = "manifestxyz789";
683        std::fs::write(
684            path.join("blobs/sha256").join(manifest_hash),
685            &manifest_content,
686        )
687        .unwrap();
688
689        let index_content = format!(
690            r#"{{
691            "schemaVersion": 2,
692            "mediaType": "application/vnd.oci.image.index.v1+json",
693            "manifests": [
694                {{
695                    "mediaType": "application/vnd.oci.image.manifest.v1+json",
696                    "digest": "sha256:{}",
697                    "size": {}
698                }}
699            ]
700        }}"#,
701            manifest_hash,
702            manifest_content.len()
703        );
704        std::fs::write(path.join("index.json"), index_content).unwrap();
705    }
706}