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