Skip to main content

microsandbox_image/archive/
docker.rs

1//! Container image archive import/export.
2
3use std::collections::{BTreeMap, HashMap, HashSet};
4use std::fs::{File, OpenOptions};
5use std::io::{self, BufWriter, Read, Write};
6use std::path::{Path, PathBuf};
7use std::sync::{
8    Arc,
9    atomic::{AtomicU64, Ordering},
10};
11
12use serde::{Deserialize, Serialize};
13use sha2::{Digest as Sha2Digest, Sha256};
14
15use crate::{
16    CachedImageMetadata, CachedLayerMetadata, Digest, GlobalCache, ImageConfig, ImageError,
17    ImageResult, Platform, Reference, Registry,
18    erofs::{ErofsEntryKind, ErofsReader},
19    path_bytes::{os_str_bytes, os_string_from_vec, path_bytes},
20    tar::Compression,
21};
22
23use super::tar_ext::TarBuilderExt;
24
25//--------------------------------------------------------------------------------------------------
26// Constants
27//--------------------------------------------------------------------------------------------------
28
29const OCI_CONFIG_MEDIA_TYPE: &str = "application/vnd.oci.image.config.v1+json";
30const OCI_MANIFEST_MEDIA_TYPE: &str = "application/vnd.oci.image.manifest.v1+json";
31const OCI_INDEX_MEDIA_TYPE: &str = "application/vnd.oci.image.index.v1+json";
32const OCI_LAYER_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar";
33const OCI_LAYER_GZIP_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar+gzip";
34const OCI_LAYER_ZSTD_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar+zstd";
35const OCI_REF_NAME_ANNOTATION: &str = "org.opencontainers.image.ref.name";
36const ARCHIVE_METADATA_MAX_BYTES: u64 = 16 * 1024 * 1024;
37const ARCHIVE_LAYER_MAX_BYTES: u64 = 10 * 1024 * 1024 * 1024;
38const ARCHIVE_MAX_ENTRY_COUNT: u64 = 1_000_000;
39const OCI_INDEX_MAX_DEPTH: usize = 32;
40const OCI_INDEX_MAX_COUNT: usize = 1_024;
41static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
42
43//--------------------------------------------------------------------------------------------------
44// Types
45//--------------------------------------------------------------------------------------------------
46
47/// Options for importing image archives.
48#[derive(Debug, Clone, Default)]
49pub struct ImageLoadOptions {
50    /// Extra tags to apply to the first image in the archive.
51    pub tags: Vec<String>,
52    /// Optional sink for materialization progress events. `None` stays silent.
53    pub progress: Option<crate::progress::PullProgressSender>,
54}
55
56/// Archive format to use when saving images.
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58pub enum ImageArchiveFormat {
59    /// Docker `docker save` compatible archive.
60    #[default]
61    Docker,
62    /// OCI Image Layout archive.
63    Oci,
64}
65
66/// One loaded image reference and its cached metadata.
67#[derive(Debug, Clone)]
68pub struct LoadedImage {
69    /// Image reference imported into the local cache.
70    pub reference: String,
71    /// Cached image metadata to persist in the database.
72    pub metadata: CachedImageMetadata,
73}
74
75/// Image data needed to export a Docker archive.
76#[derive(Debug, Clone)]
77pub struct ImageSaveRequest {
78    /// Image reference to write as a Docker `RepoTags` entry.
79    pub reference: String,
80    /// Image config fields.
81    pub config: ImageSaveConfig,
82    /// Raw image config JSON to preserve non-runtime metadata on export.
83    pub raw_config_json: String,
84    /// Ordered layer list, bottom-to-top.
85    pub layers: Vec<ImageSaveLayer>,
86}
87
88/// Config fields used when synthesizing an exported Docker image config.
89#[derive(Debug, Clone, Default)]
90pub struct ImageSaveConfig {
91    /// Target architecture.
92    pub architecture: Option<String>,
93    /// Target OS.
94    pub os: Option<String>,
95    /// Environment variables.
96    pub env: Vec<String>,
97    /// Entrypoint.
98    pub entrypoint: Option<Vec<String>>,
99    /// Command.
100    pub cmd: Option<Vec<String>>,
101    /// Working directory.
102    pub working_dir: Option<String>,
103    /// User.
104    pub user: Option<String>,
105    /// Labels.
106    pub labels: BTreeMap<String, String>,
107}
108
109/// Layer data used when exporting an image.
110#[derive(Debug, Clone)]
111pub struct ImageSaveLayer {
112    /// Original cached layer diff ID.
113    pub diff_id: String,
114}
115
116#[derive(Debug)]
117struct PreparedLoadedImage {
118    reference: String,
119    metadata: CachedImageMetadata,
120}
121
122#[derive(Debug)]
123struct PreparedArchiveLoad {
124    images: Vec<PreparedLoadedImage>,
125    staged_layers: Arc<StagedLayerGuard>,
126}
127
128#[derive(Debug)]
129struct OciManifestCandidate {
130    descriptor: oci_spec::image::Descriptor,
131    reference: Option<String>,
132    /// Position of this descriptor within the index tree, one entry per level:
133    /// the index into `index.json` followed by the index into each nested image
134    /// index traversed to reach it. Sorting candidates by `tree_path` restores the
135    /// original depth-first order regardless of the level they were resolved at.
136    tree_path: Vec<usize>,
137}
138
139#[derive(Debug)]
140pub(crate) struct StagedLayerGuard {
141    paths: HashMap<String, PathBuf>,
142    cleanup_on_drop: bool,
143}
144
145#[derive(Debug)]
146struct LayerBlobInfo {
147    digest: String,
148    media_type: String,
149    size_bytes: u64,
150    path: PathBuf,
151}
152
153#[derive(Debug, Deserialize)]
154struct DockerManifestEntry {
155    #[serde(rename = "Config")]
156    config: String,
157    #[serde(rename = "RepoTags")]
158    repo_tags: Option<Vec<String>>,
159    #[serde(rename = "Layers")]
160    layers: Vec<String>,
161}
162
163#[derive(Debug, Serialize)]
164struct DockerManifestOut {
165    #[serde(rename = "Config")]
166    config: String,
167    #[serde(rename = "RepoTags")]
168    repo_tags: Vec<String>,
169    #[serde(rename = "Layers")]
170    layers: Vec<String>,
171}
172
173#[derive(Debug)]
174struct GeneratedLayer {
175    diff_id: String,
176    hex: String,
177    path: PathBuf,
178    size: u64,
179}
180
181struct DigestingWriter<W> {
182    inner: W,
183    hasher: Sha256,
184    written: u64,
185}
186
187//--------------------------------------------------------------------------------------------------
188// Methods
189//--------------------------------------------------------------------------------------------------
190
191impl<W> DigestingWriter<W> {
192    fn new(inner: W) -> Self {
193        Self {
194            inner,
195            hasher: Sha256::new(),
196            written: 0,
197        }
198    }
199
200    fn finish(self) -> (W, String, u64) {
201        (
202            self.inner,
203            hex::encode(self.hasher.finalize()),
204            self.written,
205        )
206    }
207}
208
209impl ImageSaveRequest {
210    /// Build a save request for `reference` from its cached metadata.
211    ///
212    /// Extracts `architecture`/`os` from the raw config JSON and carries the
213    /// runtime config fields and ordered layer diff IDs over verbatim.
214    pub fn from_cached(reference: impl Into<String>, metadata: CachedImageMetadata) -> Self {
215        let (architecture, os) = raw_config_platform(&metadata.raw_config_json);
216
217        let layers = metadata
218            .layers
219            .iter()
220            .map(|layer| ImageSaveLayer {
221                diff_id: layer.diff_id.clone(),
222            })
223            .collect();
224
225        let config = metadata.config;
226        Self {
227            reference: reference.into(),
228            config: ImageSaveConfig {
229                architecture,
230                os,
231                env: config.env,
232                entrypoint: config.entrypoint,
233                cmd: config.cmd,
234                working_dir: config.working_dir,
235                user: config.user,
236                labels: config.labels.into_iter().collect(),
237            },
238            raw_config_json: metadata.raw_config_json,
239            layers,
240        }
241    }
242}
243
244impl StagedLayerGuard {
245    fn new() -> Self {
246        Self {
247            paths: HashMap::new(),
248            cleanup_on_drop: true,
249        }
250    }
251
252    fn track(&mut self, digest: String, path: PathBuf) -> PathBuf {
253        if let Some(existing_path) = self.paths.get(&digest) {
254            let _ = std::fs::remove_file(&path);
255            return existing_path.clone();
256        }
257
258        self.paths.insert(digest, path.clone());
259        path
260    }
261}
262
263//--------------------------------------------------------------------------------------------------
264// Trait Implementations
265//--------------------------------------------------------------------------------------------------
266
267impl<W: Write> Write for DigestingWriter<W> {
268    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
269        let written = self.inner.write(buf)?;
270        self.hasher.update(&buf[..written]);
271        self.written += written as u64;
272        Ok(written)
273    }
274
275    fn flush(&mut self) -> io::Result<()> {
276        self.inner.flush()
277    }
278}
279
280impl std::ops::Deref for StagedLayerGuard {
281    type Target = HashMap<String, PathBuf>;
282    fn deref(&self) -> &Self::Target {
283        &self.paths
284    }
285}
286
287impl Drop for StagedLayerGuard {
288    fn drop(&mut self) {
289        if !self.cleanup_on_drop {
290            return;
291        }
292
293        for path in self.paths.values() {
294            let _ = std::fs::remove_file(path);
295        }
296    }
297}
298
299//--------------------------------------------------------------------------------------------------
300// Functions
301//--------------------------------------------------------------------------------------------------
302
303/// Extract `architecture` and `os` from a raw OCI config JSON document.
304fn raw_config_platform(raw_config_json: &str) -> (Option<String>, Option<String>) {
305    let Ok(value) = serde_json::from_str::<serde_json::Value>(raw_config_json) else {
306        return (None, None);
307    };
308
309    let architecture = value
310        .get("architecture")
311        .and_then(serde_json::Value::as_str)
312        .map(ToOwned::to_owned);
313    let os = value
314        .get("os")
315        .and_then(serde_json::Value::as_str)
316        .map(ToOwned::to_owned);
317
318    (architecture, os)
319}
320
321/// Load a Docker image archive into the microsandbox image cache.
322pub async fn load_archive(
323    cache_dir: &Path,
324    input: &Path,
325    options: ImageLoadOptions,
326) -> ImageResult<Vec<LoadedImage>> {
327    let cache = GlobalCache::new_async(cache_dir).await?;
328    load_archive_into(&cache, input, options).await
329}
330
331/// Import using a caller-owned operation scope, retained through catalog publication.
332pub async fn load_archive_into(
333    cache: &GlobalCache,
334    input: &Path,
335    options: ImageLoadOptions,
336) -> ImageResult<Vec<LoadedImage>> {
337    let operation = cache.operation_or_new();
338    load_archive_with(&operation, input, options, |_| async { Ok(()) }).await
339}
340
341/// Import one reference at a time and await durable publication before releasing its pins.
342/// Completed references remain installed if a later reference fails. A caller-supplied
343/// operation scope still retains all pins when a larger ownership handoff requires it.
344pub async fn load_archive_with<F, Fut>(
345    cache: &GlobalCache,
346    input: &Path,
347    options: ImageLoadOptions,
348    mut publish: F,
349) -> ImageResult<Vec<LoadedImage>>
350where
351    F: FnMut(LoadedImage) -> Fut,
352    Fut: std::future::Future<Output = ImageResult<()>>,
353{
354    let cache_dir = cache
355        .layers_dir()
356        .parent()
357        .expect("cache layers have a parent")
358        .to_path_buf();
359    let input = input.to_path_buf();
360    let progress = options.progress.clone();
361    let worker_dir = cache_dir.clone();
362    let worker_input = input.clone();
363    let worker_options = options.clone();
364    let prepared = tokio::task::spawn_blocking(move || {
365        load_archive_blocking(&worker_dir, &worker_input, worker_options)
366    })
367    .await
368    .map_err(std::io::Error::other)??;
369    let PreparedArchiveLoad {
370        images,
371        mut staged_layers,
372    } = prepared;
373    let mut refreshed = HashMap::new();
374    let mut loaded = Vec::with_capacity(images.len());
375    for mut image in images {
376        let operation = cache.operation_or_new();
377        if let Some(metadata) = refreshed.remove(&image.reference) {
378            image.metadata = metadata;
379        }
380        let reference: Reference = image.reference.parse().map_err(|error| {
381            ImageError::ManifestParse(format!("invalid image reference: {error}"))
382        })?;
383        let mut paths = operation.metadata_paths(&image.metadata)?;
384        paths.push(operation.image_metadata_path(&reference));
385        operation.lease_paths_async(paths).await?;
386        // Warm preparation is optimistic. Once admitted, a cache miss must restage the
387        // supplied archive, never turn an offline import into a registry download.
388        let missing = image.metadata.layers.iter().any(|layer| {
389            !staged_layers.contains_key(&layer.digest)
390                && layer
391                    .diff_id
392                    .parse()
393                    .is_ok_and(|id| !operation.is_layer_materialized(&id))
394        });
395        if missing {
396            let directory = cache_dir.clone();
397            let source = input.clone();
398            let retry_options = options.clone();
399            let prepared = tokio::task::spawn_blocking(move || {
400                prepare_archive(&directory, &source, retry_options, false)
401            })
402            .await
403            .map_err(std::io::Error::other)??;
404            staged_layers = prepared.staged_layers;
405            refreshed = prepared
406                .images
407                .into_iter()
408                .map(|image| (image.reference, image.metadata))
409                .collect();
410            image.metadata = refreshed
411                .remove(&image.reference)
412                .ok_or_else(|| ImageError::ManifestParse("archive changed during import".into()))?;
413            operation
414                .lease_paths_async(operation.metadata_paths(&image.metadata)?)
415                .await?;
416        }
417        let registry = Registry::new(Platform::host_linux(), operation.clone())?;
418        registry
419            .materialize_cached_layers_from_paths(
420                &reference,
421                &image.metadata,
422                false,
423                staged_layers.clone(),
424                progress.clone(),
425            )
426            .await?;
427        operation
428            .write_image_metadata_async(&reference, &image.metadata)
429            .await?;
430        let image = LoadedImage {
431            reference: image.reference,
432            metadata: image.metadata,
433        };
434        publish(image.clone()).await?;
435        loaded.push(image);
436    }
437    Ok(loaded)
438}
439
440/// Save images as a Docker-compatible image archive.
441pub fn save_docker_archive(
442    cache: &GlobalCache,
443    output: &Path,
444    images: &[ImageSaveRequest],
445) -> ImageResult<()> {
446    save_archive(cache, output, images, ImageArchiveFormat::Docker)
447}
448
449/// Save images as a container image archive.
450pub fn save_archive(
451    cache: &GlobalCache,
452    output: &Path,
453    images: &[ImageSaveRequest],
454    format: ImageArchiveFormat,
455) -> ImageResult<()> {
456    let mut paths = Vec::new();
457    for image in images {
458        paths.push(
459            cache.image_metadata_path(
460                &image
461                    .reference
462                    .parse::<Reference>()
463                    .map_err(|error| ImageError::ManifestParse(error.to_string()))?,
464            ),
465        );
466        for layer in &image.layers {
467            paths.push(cache.layer_erofs_path(&layer.diff_id.parse()?));
468        }
469    }
470    let _leases = cache.lease_paths(paths)?;
471    match format {
472        ImageArchiveFormat::Docker => save_docker_archive_inner(cache, output, images),
473        ImageArchiveFormat::Oci => save_oci_archive_inner(cache, output, images),
474    }
475}
476
477fn save_docker_archive_inner(
478    cache: &GlobalCache,
479    output: &Path,
480    images: &[ImageSaveRequest],
481) -> ImageResult<()> {
482    if images.is_empty() {
483        return Err(ImageError::ManifestParse(
484            "at least one image reference is required".into(),
485        ));
486    }
487
488    let output_file = File::create(output).map_err(|e| ImageError::Cache {
489        path: output.to_path_buf(),
490        source: e,
491    })?;
492    let mut archive = tar::Builder::new(BufWriter::new(output_file));
493    let mut generated_layers: HashMap<String, GeneratedLayer> = HashMap::new();
494    let mut appended_layers: HashSet<String> = HashSet::new();
495    let mut manifest_entries = Vec::with_capacity(images.len());
496    let mut config_entries = Vec::with_capacity(images.len());
497
498    for image in images {
499        let mut layer_paths = Vec::with_capacity(image.layers.len());
500        let mut regenerated_diff_ids = Vec::with_capacity(image.layers.len());
501
502        for layer in &image.layers {
503            let generated = match generated_layers.get(&layer.diff_id) {
504                Some(generated) => generated,
505                None => {
506                    let generated = generate_layer_tar(cache, layer)?;
507                    generated_layers.insert(layer.diff_id.clone(), generated);
508                    generated_layers.get(&layer.diff_id).unwrap()
509                }
510            };
511
512            regenerated_diff_ids.push(generated.diff_id.clone());
513            layer_paths.push(format!("{}/layer.tar", generated.hex));
514        }
515
516        let config_bytes =
517            docker_config_json(&image.config, &image.raw_config_json, &regenerated_diff_ids)?;
518        let config_hex = sha256_hex(&config_bytes);
519        let config_name = format!("{config_hex}.json");
520
521        config_entries.push((config_name.clone(), config_bytes));
522
523        manifest_entries.push(DockerManifestOut {
524            config: config_name,
525            repo_tags: vec![image.reference.clone()],
526            layers: layer_paths,
527        });
528    }
529
530    let manifest_bytes = serde_json::to_vec_pretty(&manifest_entries)
531        .map_err(|e| ImageError::ConfigParse(format!("serialize docker manifest: {e}")))?;
532    append_bytes(&mut archive, "manifest.json", &manifest_bytes)?;
533
534    for (config_name, config_bytes) in config_entries {
535        append_bytes(&mut archive, &config_name, &config_bytes)?;
536    }
537
538    for image in images {
539        for layer in &image.layers {
540            let generated = generated_layers.get(&layer.diff_id).ok_or_else(|| {
541                ImageError::ManifestParse(format!("missing generated layer {}", layer.diff_id))
542            })?;
543            if appended_layers.insert(generated.hex.clone()) {
544                append_layer_entries(&mut archive, generated)?;
545            }
546        }
547    }
548
549    archive.finish().map_err(ImageError::Io)?;
550
551    for layer in generated_layers.values() {
552        let _ = std::fs::remove_file(&layer.path);
553    }
554
555    Ok(())
556}
557
558fn save_oci_archive_inner(
559    cache: &GlobalCache,
560    output: &Path,
561    images: &[ImageSaveRequest],
562) -> ImageResult<()> {
563    if images.is_empty() {
564        return Err(ImageError::ManifestParse(
565            "at least one image reference is required".into(),
566        ));
567    }
568
569    let output_file = File::create(output).map_err(|e| ImageError::Cache {
570        path: output.to_path_buf(),
571        source: e,
572    })?;
573    let mut archive = tar::Builder::new(BufWriter::new(output_file));
574    let mut generated_layers: HashMap<String, GeneratedLayer> = HashMap::new();
575    let mut appended_metadata_blobs: HashSet<String> = HashSet::new();
576    let mut appended_layer_blobs: HashSet<String> = HashSet::new();
577    let mut layer_blob_order = Vec::new();
578    let mut metadata_blobs = Vec::new();
579    let mut index_manifests = Vec::with_capacity(images.len());
580
581    for image in images {
582        let mut layer_descriptors = Vec::with_capacity(image.layers.len());
583        let mut regenerated_diff_ids = Vec::with_capacity(image.layers.len());
584
585        for layer in &image.layers {
586            let generated = match generated_layers.get(&layer.diff_id) {
587                Some(generated) => generated,
588                None => {
589                    let generated = generate_layer_tar(cache, layer)?;
590                    generated_layers.insert(layer.diff_id.clone(), generated);
591                    generated_layers.get(&layer.diff_id).unwrap()
592                }
593            };
594
595            regenerated_diff_ids.push(generated.diff_id.clone());
596            if appended_layer_blobs.insert(generated.hex.clone()) {
597                layer_blob_order.push(layer.diff_id.clone());
598            }
599            layer_descriptors.push(serde_json::json!({
600                "mediaType": OCI_LAYER_MEDIA_TYPE,
601                "digest": generated.diff_id,
602                "size": generated.size,
603            }));
604        }
605
606        let config_bytes =
607            docker_config_json(&image.config, &image.raw_config_json, &regenerated_diff_ids)?;
608        let config_hex = sha256_hex(&config_bytes);
609        if appended_metadata_blobs.insert(config_hex.clone()) {
610            metadata_blobs.push((config_hex.clone(), config_bytes.clone()));
611        }
612
613        let manifest_bytes = serde_json::to_vec(&serde_json::json!({
614            "schemaVersion": 2,
615            "mediaType": OCI_MANIFEST_MEDIA_TYPE,
616            "config": {
617                "mediaType": OCI_CONFIG_MEDIA_TYPE,
618                "digest": format!("sha256:{config_hex}"),
619                "size": config_bytes.len(),
620            },
621            "layers": layer_descriptors,
622        }))
623        .map_err(|e| ImageError::ManifestParse(format!("serialize OCI manifest: {e}")))?;
624        let manifest_hex = sha256_hex(&manifest_bytes);
625        if appended_metadata_blobs.insert(manifest_hex.clone()) {
626            metadata_blobs.push((manifest_hex.clone(), manifest_bytes.clone()));
627        }
628
629        index_manifests.push(serde_json::json!({
630            "mediaType": OCI_MANIFEST_MEDIA_TYPE,
631            "digest": format!("sha256:{manifest_hex}"),
632            "size": manifest_bytes.len(),
633            "platform": {
634                "architecture": image.config.architecture.as_deref().unwrap_or("amd64"),
635                "os": image.config.os.as_deref().unwrap_or("linux"),
636            },
637            "annotations": {
638                (OCI_REF_NAME_ANNOTATION): image.reference.clone(),
639            },
640        }));
641    }
642
643    let index_bytes = serde_json::to_vec_pretty(&serde_json::json!({
644        "schemaVersion": 2,
645        "mediaType": OCI_INDEX_MEDIA_TYPE,
646        "manifests": index_manifests,
647    }))
648    .map_err(|e| ImageError::ManifestParse(format!("serialize OCI index: {e}")))?;
649
650    append_bytes(
651        &mut archive,
652        "oci-layout",
653        br#"{"imageLayoutVersion":"1.0.0"}"#,
654    )?;
655    append_bytes(&mut archive, "index.json", &index_bytes)?;
656    append_directory(&mut archive, "blobs")?;
657    append_directory(&mut archive, "blobs/sha256")?;
658
659    for (hex, bytes) in metadata_blobs {
660        append_blob_bytes(&mut archive, &hex, &bytes)?;
661    }
662
663    for diff_id in layer_blob_order {
664        let generated = generated_layers.get(&diff_id).ok_or_else(|| {
665            ImageError::ManifestParse(format!("missing generated layer {diff_id}"))
666        })?;
667        append_blob_file(
668            &mut archive,
669            &generated.hex,
670            &generated.path,
671            generated.size,
672        )?;
673    }
674
675    archive.finish().map_err(ImageError::Io)?;
676
677    for layer in generated_layers.values() {
678        let _ = std::fs::remove_file(&layer.path);
679    }
680
681    Ok(())
682}
683
684fn load_archive_blocking(
685    cache_dir: &Path,
686    input: &Path,
687    options: ImageLoadOptions,
688) -> ImageResult<PreparedArchiveLoad> {
689    prepare_archive(cache_dir, input, options, true)
690}
691
692fn prepare_archive(
693    cache_dir: &Path,
694    input: &Path,
695    options: ImageLoadOptions,
696    use_cache: bool,
697) -> ImageResult<PreparedArchiveLoad> {
698    // A `docker save` archive carries a `manifest.json` (and often an `oci-layout` compat shim). Prefer the Docker path when `manifest.json` is present: it derives the image
699    // name from `RepoTags` and handles the layer layout that `docker save` actually writes. The OCI path is for archives that ship only an OCI layout.
700    if let Some(manifest_json) = read_archive_entry(input, "manifest.json")? {
701        let manifest: Vec<DockerManifestEntry> = serde_json::from_slice(&manifest_json)
702            .map_err(|e| ImageError::ManifestParse(format!("docker manifest.json: {e}")))?;
703        return prepare_docker_archive(cache_dir, input, options, manifest, use_cache);
704    }
705
706    if read_archive_entry(input, "oci-layout")?.is_some() {
707        return prepare_oci_archive(cache_dir, input, options, use_cache);
708    }
709
710    Err(ImageError::ManifestParse(
711        "archive missing manifest.json or oci-layout".into(),
712    ))
713}
714
715fn prepare_docker_archive(
716    cache_dir: &Path,
717    input: &Path,
718    options: ImageLoadOptions,
719    manifest: Vec<DockerManifestEntry>,
720    use_cache: bool,
721) -> ImageResult<PreparedArchiveLoad> {
722    let cache = GlobalCache::new(cache_dir)?;
723    if manifest.is_empty() {
724        return Err(ImageError::ManifestParse(
725            "docker archive manifest is empty".into(),
726        ));
727    }
728
729    let required_configs = manifest
730        .iter()
731        .map(|image| image.config.clone())
732        .collect::<HashSet<_>>();
733    let required_layers = manifest
734        .iter()
735        .flat_map(|image| image.layers.iter().cloned())
736        .collect::<HashSet<_>>();
737
738    // Early cache gate: when this exact image is already materialized, skip staging (and re-hashing) every layer blob. `docker save` names images via RepoTags, so we look up
739    // the cached metadata by reference, confirm the archive's content still matches it (diff_ids -- guards against a rebuilt tag reusing the name), and verify the EROFS/VMDK
740    // artifacts survive. On a hit the cached metadata is reused verbatim, which also keys fsmeta/VMDK by the manifest digest recorded at materialization time -- so a `pull`
741    // followed by a `load` of the same image still hits. Only the small config blob is read (seekably, via `read_archive_entries`); layer bytes are never touched.
742    'early_gate: {
743        if !use_cache {
744            break 'early_gate;
745        }
746        let config_blobs = read_archive_entries(input, &required_configs)?;
747        let mut early_images = Vec::new();
748        for (image_index, image) in manifest.iter().enumerate() {
749            let Some(config_bytes) = config_blobs.get(&image.config) else {
750                break 'early_gate;
751            };
752            let (_, diff_ids) = ImageConfig::parse(config_bytes)?;
753            if diff_ids.len() != image.layers.len() {
754                break 'early_gate;
755            }
756            let config_digest = format!("sha256:{}", sha256_hex(config_bytes));
757
758            let mut refs = image
759                .repo_tags
760                .clone()
761                .unwrap_or_default()
762                .into_iter()
763                .filter(|tag| tag != "<none>:<none>")
764                .collect::<Vec<_>>();
765            if image_index == 0 {
766                refs.extend(options.tags.iter().cloned());
767            }
768            refs.sort();
769            refs.dedup();
770            if refs.is_empty() {
771                break 'early_gate;
772            }
773
774            // Cached metadata under any of this image's refs whose recorded content (diff_ids) still equals the archive's.
775            let mut cached = None;
776            for reference in &refs {
777                let Ok(parsed) = reference.parse::<Reference>() else {
778                    break 'early_gate;
779                };
780                if let Some(metadata) = cache.read_image_metadata(&parsed)? {
781                    let cached_diff_ids = metadata
782                        .layers
783                        .iter()
784                        .map(|layer| layer.diff_id.clone())
785                        .collect::<Vec<_>>();
786                    if metadata.config_digest == config_digest && cached_diff_ids == diff_ids {
787                        cached = Some(metadata);
788                        break;
789                    }
790                }
791            }
792            let Some(metadata) = cached else {
793                break 'early_gate;
794            };
795
796            let Ok(manifest_digest) = metadata.manifest_digest.parse::<crate::Digest>() else {
797                break 'early_gate;
798            };
799            if !crate::cache::is_valid_erofs_artifact(&cache.fsmeta_erofs_path(&manifest_digest))
800                || !cache.vmdk_path(&manifest_digest).exists()
801            {
802                break 'early_gate;
803            }
804            let mut layers_present = true;
805            for diff_id_str in &diff_ids {
806                let Ok(diff_id) = diff_id_str.parse::<crate::Digest>() else {
807                    layers_present = false;
808                    break;
809                };
810                if !crate::cache::is_valid_erofs_artifact(&cache.layer_erofs_path(&diff_id)) {
811                    layers_present = false;
812                    break;
813                }
814            }
815            if !layers_present {
816                break 'early_gate;
817            }
818
819            for reference in refs {
820                early_images.push(PreparedLoadedImage {
821                    reference,
822                    metadata: metadata.clone(),
823                });
824            }
825        }
826        if !archive_contains_entries(input, &required_layers)? {
827            break 'early_gate;
828        }
829        return Ok(PreparedArchiveLoad {
830            images: early_images,
831            staged_layers: Arc::new(StagedLayerGuard::new()),
832        });
833    }
834
835    let file = File::open(input).map_err(|e| ImageError::Cache {
836        path: input.to_path_buf(),
837        source: e,
838    })?;
839    let mut archive = tar::Archive::new(file);
840    let mut configs: HashMap<String, Vec<u8>> = HashMap::new();
841    let mut layers: HashMap<String, LayerBlobInfo> = HashMap::new();
842    let mut staged_layers = StagedLayerGuard::new();
843    let mut temp_counter = 0u64;
844    let mut entry_count = 0u64;
845
846    for entry in archive.entries().map_err(ImageError::Io)? {
847        let mut entry = entry.map_err(ImageError::Io)?;
848        entry_count += 1;
849        enforce_archive_entry_count(entry_count)?;
850        let path = normalized_archive_path(&entry)?;
851
852        if required_configs.contains(&path) {
853            let data = read_entry_to_vec(&mut entry, &path, ARCHIVE_METADATA_MAX_BYTES)?;
854            configs.insert(path, data);
855            continue;
856        }
857
858        if required_layers.contains(&path) {
859            let mut info = extract_layer_blob(&cache, &path, &mut entry, temp_counter)?;
860            temp_counter += 1;
861            info.path = staged_layers.track(info.digest.clone(), info.path);
862            verify_docker_layer_path_digest(&path, &info.digest)?;
863            layers.insert(path, info);
864            continue;
865        }
866    }
867
868    let mut loaded = Vec::new();
869    for (image_index, image) in manifest.into_iter().enumerate() {
870        let config_bytes = configs.get(&image.config).ok_or_else(|| {
871            ImageError::ConfigParse(format!("docker archive missing config {}", image.config))
872        })?;
873        let (config, diff_ids) = ImageConfig::parse(config_bytes)?;
874
875        if diff_ids.len() != image.layers.len() {
876            return Err(ImageError::ManifestParse(format!(
877                "layer count mismatch: config has {} diff_ids but archive manifest has {} layers",
878                diff_ids.len(),
879                image.layers.len()
880            )));
881        }
882
883        let config_digest = format!("sha256:{}", sha256_hex(config_bytes));
884        let mut layer_metadata = Vec::with_capacity(image.layers.len());
885        let mut manifest_layers = Vec::with_capacity(image.layers.len());
886
887        for (position, layer_path) in image.layers.iter().enumerate() {
888            let layer = layers.get(layer_path).ok_or_else(|| {
889                ImageError::ManifestParse(format!("docker archive missing layer {layer_path}"))
890            })?;
891            let diff_id = diff_ids[position].clone();
892            layer_metadata.push(CachedLayerMetadata {
893                digest: layer.digest.clone(),
894                media_type: Some(layer.media_type.clone()),
895                size_bytes: Some(layer.size_bytes),
896                diff_id,
897            });
898            manifest_layers.push(serde_json::json!({
899                "mediaType": layer.media_type,
900                "digest": layer.digest,
901                "size": layer.size_bytes,
902            }));
903        }
904
905        let manifest_bytes = serde_json::to_vec(&serde_json::json!({
906            "schemaVersion": 2,
907            "mediaType": OCI_MANIFEST_MEDIA_TYPE,
908            "config": {
909                "mediaType": OCI_CONFIG_MEDIA_TYPE,
910                "digest": config_digest,
911                "size": config_bytes.len(),
912            },
913            "layers": manifest_layers,
914        }))
915        .map_err(|e| ImageError::ManifestParse(format!("serialize manifest: {e}")))?;
916        let manifest_digest = format!("sha256:{}", sha256_hex(&manifest_bytes));
917
918        let metadata = CachedImageMetadata {
919            manifest_digest,
920            config_digest,
921            raw_manifest_json: json_bytes_to_string(&manifest_bytes, "docker manifest")?,
922            raw_config_json: json_bytes_to_string(config_bytes, "docker config")?,
923            config,
924            layers: layer_metadata,
925        };
926
927        let mut refs = image
928            .repo_tags
929            .unwrap_or_default()
930            .into_iter()
931            .filter(|tag| tag != "<none>:<none>")
932            .collect::<Vec<_>>();
933
934        if image_index == 0 {
935            refs.extend(options.tags.iter().cloned());
936        }
937
938        refs.sort();
939        refs.dedup();
940
941        if refs.is_empty() {
942            return Err(ImageError::ManifestParse(
943                "docker archive image has no tags; pass --tag to name it".into(),
944            ));
945        }
946
947        for reference in refs {
948            let _: Reference = reference.parse().map_err(|e| {
949                ImageError::ManifestParse(format!("invalid image reference {reference}: {e}"))
950            })?;
951            loaded.push(PreparedLoadedImage {
952                reference,
953                metadata: metadata.clone(),
954            });
955        }
956    }
957
958    Ok(PreparedArchiveLoad {
959        images: loaded,
960        staged_layers: Arc::new(staged_layers),
961    })
962}
963
964#[cfg(test)]
965fn load_oci_archive_blocking(
966    cache_dir: &Path,
967    input: &Path,
968    options: ImageLoadOptions,
969) -> ImageResult<PreparedArchiveLoad> {
970    prepare_oci_archive(cache_dir, input, options, true)
971}
972
973fn prepare_oci_archive(
974    cache_dir: &Path,
975    input: &Path,
976    options: ImageLoadOptions,
977    use_cache: bool,
978) -> ImageResult<PreparedArchiveLoad> {
979    let cache = GlobalCache::new(cache_dir)?;
980    let layout_json = read_archive_entry(input, "oci-layout")?
981        .ok_or_else(|| ImageError::ManifestParse("OCI layout missing oci-layout".into()))?;
982    serde_json::from_slice::<oci_spec::image::OciLayout>(&layout_json)
983        .map_err(|e| ImageError::ManifestParse(format!("oci-layout: {e}")))?;
984
985    let index_json = read_archive_entry(input, "index.json")?
986        .ok_or_else(|| ImageError::ManifestParse("OCI layout missing index.json".into()))?;
987    let index: oci_spec::image::ImageIndex = serde_json::from_slice(&index_json)
988        .map_err(|e| ImageError::ManifestParse(format!("OCI index.json: {e}")))?;
989    let manifest_candidates = resolve_oci_manifest_candidates(input, index.manifests())?;
990    if manifest_candidates.is_empty() {
991        return Err(ImageError::ManifestParse(
992            "OCI layout contains no image manifests for the host platform".into(),
993        ));
994    }
995
996    let manifest_paths = manifest_candidates
997        .iter()
998        .map(|candidate| blob_path_from_digest(candidate.descriptor.digest().as_ref()))
999        .collect::<ImageResult<HashSet<_>>>()?;
1000    let manifest_blobs = read_archive_entries(input, &manifest_paths)?;
1001    let mut manifests = Vec::with_capacity(manifest_candidates.len());
1002    let mut required_configs = HashSet::new();
1003    let mut required_layers = HashSet::new();
1004
1005    for candidate in manifest_candidates {
1006        let descriptor = &candidate.descriptor;
1007        let manifest_path = blob_path_from_digest(descriptor.digest().as_ref())?;
1008        let manifest_bytes = manifest_blobs.get(&manifest_path).ok_or_else(|| {
1009            ImageError::ManifestParse(format!("OCI layout missing manifest blob {manifest_path}"))
1010        })?;
1011        verify_descriptor_blob(descriptor, manifest_bytes)?;
1012        let manifest: oci_spec::image::ImageManifest = serde_json::from_slice(manifest_bytes)
1013            .map_err(|e| ImageError::ManifestParse(format!("OCI image manifest: {e}")))?;
1014
1015        required_configs.insert(blob_path_from_digest(manifest.config().digest().as_ref())?);
1016        for layer in manifest.layers() {
1017            required_layers.insert(blob_path_from_digest(layer.digest().as_ref())?);
1018        }
1019        manifests.push((candidate, manifest, manifest_bytes.clone()));
1020    }
1021
1022    // Fast path: skip re-importing an image that is already fully materialized.
1023    //
1024    // A `CachedImageMetadata` is fully determined by the small manifest and config blobs — `manifest_digest` is `sha256(manifest_bytes)`; each layer's digest, media type, and
1025    // size come from the manifest descriptors; the diff_ids come from the config. So the whole record can be built without opening a single layer blob. When the fsmeta, VMDK,
1026    // and every layer EROFS are already cached, return here and never stage (and re-SHA-256) the layer blobs — the work that otherwise dominates a warm-cache load. Any miss
1027    // breaks out to the full staging path below.
1028    //
1029    // Only the small config/manifest blobs are read, seekably (`read_archive_entries` uses `entries_with_seek`), so a hit skips both the ~16 s of layer hashing and the
1030    // streaming of layer bytes -- it is truly sub-second.
1031    'early_gate: {
1032        if !use_cache {
1033            break 'early_gate;
1034        }
1035        let config_blobs = read_archive_entries(input, &required_configs)?;
1036        let mut early_images = Vec::new();
1037        for (image_index, (candidate, manifest, manifest_bytes)) in manifests.iter().enumerate() {
1038            let config_path = blob_path_from_digest(manifest.config().digest().as_ref())?;
1039            let Some(config_bytes) = config_blobs.get(&config_path) else {
1040                break 'early_gate;
1041            };
1042            verify_descriptor_blob(manifest.config(), config_bytes)?;
1043            let (config, diff_ids) = ImageConfig::parse(config_bytes)?;
1044            if diff_ids.len() != manifest.layers().len() {
1045                break 'early_gate;
1046            }
1047            let manifest_digest_str = format!("sha256:{}", sha256_hex(manifest_bytes));
1048            let Ok(manifest_digest) = manifest_digest_str.parse::<crate::Digest>() else {
1049                break 'early_gate;
1050            };
1051            if !crate::cache::is_valid_erofs_artifact(&cache.fsmeta_erofs_path(&manifest_digest))
1052                || !cache.vmdk_path(&manifest_digest).exists()
1053            {
1054                break 'early_gate;
1055            }
1056
1057            let mut layer_metadata = Vec::with_capacity(manifest.layers().len());
1058            for (position, layer_descriptor) in manifest.layers().iter().enumerate() {
1059                let diff_id_str = diff_ids[position].clone();
1060                let Ok(diff_id) = diff_id_str.parse::<crate::Digest>() else {
1061                    break 'early_gate;
1062                };
1063                if !crate::cache::is_valid_erofs_artifact(&cache.layer_erofs_path(&diff_id)) {
1064                    break 'early_gate;
1065                }
1066                layer_metadata.push(CachedLayerMetadata {
1067                    digest: layer_descriptor.digest().to_string(),
1068                    media_type: Some(layer_descriptor.media_type().to_string()),
1069                    size_bytes: Some(layer_descriptor.size()),
1070                    diff_id: diff_id_str,
1071                });
1072            }
1073
1074            let metadata = CachedImageMetadata {
1075                manifest_digest: manifest_digest_str,
1076                config_digest: manifest.config().digest().to_string(),
1077                raw_manifest_json: json_bytes_to_string(manifest_bytes, "OCI manifest")?,
1078                raw_config_json: json_bytes_to_string(config_bytes, "OCI config")?,
1079                config,
1080                layers: layer_metadata,
1081            };
1082
1083            let mut refs = candidate.reference.clone().into_iter().collect::<Vec<_>>();
1084            if image_index == 0 {
1085                refs.extend(options.tags.iter().cloned());
1086            }
1087            refs.sort();
1088            refs.dedup();
1089            if refs.is_empty() {
1090                break 'early_gate;
1091            }
1092            for reference in refs {
1093                if reference.parse::<Reference>().is_err() {
1094                    break 'early_gate;
1095                }
1096                early_images.push(PreparedLoadedImage {
1097                    reference,
1098                    metadata: metadata.clone(),
1099                });
1100            }
1101        }
1102        if !archive_contains_entries(input, &required_layers)? {
1103            break 'early_gate;
1104        }
1105        return Ok(PreparedArchiveLoad {
1106            images: early_images,
1107            staged_layers: Arc::new(StagedLayerGuard::new()),
1108        });
1109    }
1110
1111    // Full path: some layer (or fsmeta/VMDK) is missing — stage every layer blob so
1112    // the materialize step can build what isn't cached.
1113    let file = File::open(input).map_err(|e| ImageError::Cache {
1114        path: input.to_path_buf(),
1115        source: e,
1116    })?;
1117    let mut archive = tar::Archive::new(file);
1118    let mut configs: HashMap<String, Vec<u8>> = HashMap::new();
1119    let mut layers: HashMap<String, LayerBlobInfo> = HashMap::new();
1120    let mut staged_layers = StagedLayerGuard::new();
1121    let mut temp_counter = 0u64;
1122    let mut entry_count = 0u64;
1123
1124    for entry in archive.entries().map_err(ImageError::Io)? {
1125        let mut entry = entry.map_err(ImageError::Io)?;
1126        entry_count += 1;
1127        enforce_archive_entry_count(entry_count)?;
1128        let path = normalized_archive_path(&entry)?;
1129
1130        if required_configs.contains(&path) {
1131            let data = read_entry_to_vec(&mut entry, &path, ARCHIVE_METADATA_MAX_BYTES)?;
1132            configs.insert(path, data);
1133            continue;
1134        }
1135
1136        if required_layers.contains(&path) {
1137            let mut info = extract_layer_blob(&cache, &path, &mut entry, temp_counter)?;
1138            temp_counter += 1;
1139            info.path = staged_layers.track(info.digest.clone(), info.path);
1140            layers.insert(path, info);
1141            continue;
1142        }
1143    }
1144
1145    let mut loaded = Vec::new();
1146    for (image_index, (candidate, manifest, manifest_bytes)) in manifests.into_iter().enumerate() {
1147        let config_path = blob_path_from_digest(manifest.config().digest().as_ref())?;
1148        let config_bytes = configs.get(&config_path).ok_or_else(|| {
1149            ImageError::ConfigParse(format!("OCI layout missing config blob {config_path}"))
1150        })?;
1151        verify_descriptor_blob(manifest.config(), config_bytes)?;
1152        let (config, diff_ids) = ImageConfig::parse(config_bytes)?;
1153
1154        if diff_ids.len() != manifest.layers().len() {
1155            return Err(ImageError::ManifestParse(format!(
1156                "layer count mismatch: config has {} diff_ids but OCI manifest has {} layers",
1157                diff_ids.len(),
1158                manifest.layers().len()
1159            )));
1160        }
1161
1162        let mut layer_metadata = Vec::with_capacity(manifest.layers().len());
1163        for (position, layer_descriptor) in manifest.layers().iter().enumerate() {
1164            let layer_path = blob_path_from_digest(layer_descriptor.digest().as_ref())?;
1165            let layer = layers.get(&layer_path).ok_or_else(|| {
1166                ImageError::ManifestParse(format!("OCI layout missing layer blob {layer_path}"))
1167            })?;
1168            verify_layer_descriptor(layer_descriptor, layer)?;
1169            layer_metadata.push(CachedLayerMetadata {
1170                digest: layer.digest.clone(),
1171                media_type: Some(layer.media_type.clone()),
1172                size_bytes: Some(layer.size_bytes),
1173                diff_id: diff_ids[position].clone(),
1174            });
1175        }
1176
1177        let metadata = CachedImageMetadata {
1178            manifest_digest: format!("sha256:{}", sha256_hex(&manifest_bytes)),
1179            config_digest: manifest.config().digest().to_string(),
1180            raw_manifest_json: json_bytes_to_string(&manifest_bytes, "OCI manifest")?,
1181            raw_config_json: json_bytes_to_string(config_bytes, "OCI config")?,
1182            config,
1183            layers: layer_metadata,
1184        };
1185
1186        let mut refs = candidate.reference.into_iter().collect::<Vec<_>>();
1187
1188        if image_index == 0 {
1189            refs.extend(options.tags.iter().cloned());
1190        }
1191
1192        refs.sort();
1193        refs.dedup();
1194
1195        if refs.is_empty() {
1196            return Err(ImageError::ManifestParse(
1197                "OCI layout image has no ref.name annotation; pass --tag to name it".into(),
1198            ));
1199        }
1200
1201        for reference in refs {
1202            let _: Reference = reference.parse().map_err(|e| {
1203                ImageError::ManifestParse(format!("invalid image reference {reference}: {e}"))
1204            })?;
1205            loaded.push(PreparedLoadedImage {
1206                reference,
1207                metadata: metadata.clone(),
1208            });
1209        }
1210    }
1211
1212    Ok(PreparedArchiveLoad {
1213        images: loaded,
1214        staged_layers: Arc::new(staged_layers),
1215    })
1216}
1217
1218fn read_archive_entry(input: &Path, wanted_path: &str) -> ImageResult<Option<Vec<u8>>> {
1219    let file = File::open(input).map_err(|e| ImageError::Cache {
1220        path: input.to_path_buf(),
1221        source: e,
1222    })?;
1223    let mut archive = tar::Archive::new(file);
1224    let mut entry_count = 0u64;
1225
1226    // `entries_with_seek` skips over each entry's data via the header size field instead of reading through it. In an OCI `docker save` tar the metadata sits at the tail
1227    // (after the blobs), so this reaches it in O(headers) rather than a full read of every layer blob.
1228    for entry in archive.entries_with_seek().map_err(ImageError::Io)? {
1229        let mut entry = entry.map_err(ImageError::Io)?;
1230        entry_count += 1;
1231        enforce_archive_entry_count(entry_count)?;
1232        let path = normalized_archive_path(&entry)?;
1233        if path != wanted_path {
1234            continue;
1235        }
1236
1237        let data = read_entry_to_vec(&mut entry, &path, ARCHIVE_METADATA_MAX_BYTES)?;
1238        return Ok(Some(data));
1239    }
1240
1241    Ok(None)
1242}
1243
1244fn read_archive_entries(
1245    input: &Path,
1246    wanted_paths: &HashSet<String>,
1247) -> ImageResult<HashMap<String, Vec<u8>>> {
1248    let file = File::open(input).map_err(|e| ImageError::Cache {
1249        path: input.to_path_buf(),
1250        source: e,
1251    })?;
1252    let mut archive = tar::Archive::new(file);
1253    let mut entries = HashMap::new();
1254    let mut entry_count = 0u64;
1255
1256    // Seek past unwanted entry data (see `read_archive_entry`).
1257    for entry in archive.entries_with_seek().map_err(ImageError::Io)? {
1258        let mut entry = entry.map_err(ImageError::Io)?;
1259        entry_count += 1;
1260        enforce_archive_entry_count(entry_count)?;
1261        let path = normalized_archive_path(&entry)?;
1262        if !wanted_paths.contains(&path) {
1263            continue;
1264        }
1265
1266        let data = read_entry_to_vec(&mut entry, &path, ARCHIVE_METADATA_MAX_BYTES)?;
1267        entries.insert(path, data);
1268        if entries.len() == wanted_paths.len() {
1269            break;
1270        }
1271    }
1272
1273    Ok(entries)
1274}
1275
1276fn archive_contains_entries(input: &Path, wanted_paths: &HashSet<String>) -> ImageResult<bool> {
1277    if wanted_paths.is_empty() {
1278        return Ok(true);
1279    }
1280
1281    let file = File::open(input).map_err(|e| ImageError::Cache {
1282        path: input.to_path_buf(),
1283        source: e,
1284    })?;
1285    let mut archive = tar::Archive::new(file);
1286    let mut entries = HashSet::new();
1287    let mut entry_count = 0u64;
1288
1289    // The warm gate trusts cached layer EROFS contents, but the archive still needs
1290    // to contain the layer members it advertises. Header-only scanning preserves the
1291    // warm path's main win: layer bytes are seeked over, not read or re-hashed.
1292    for entry in archive.entries_with_seek().map_err(ImageError::Io)? {
1293        let entry = entry.map_err(ImageError::Io)?;
1294        entry_count += 1;
1295        enforce_archive_entry_count(entry_count)?;
1296        let path = normalized_archive_path(&entry)?;
1297        if wanted_paths.contains(&path) {
1298            entries.insert(path);
1299            if entries.len() == wanted_paths.len() {
1300                return Ok(true);
1301            }
1302        }
1303    }
1304
1305    Ok(false)
1306}
1307
1308fn resolve_oci_manifest_candidates(
1309    input: &Path,
1310    descriptors: &[oci_spec::image::Descriptor],
1311) -> ImageResult<Vec<OciManifestCandidate>> {
1312    let host = Platform::host_linux();
1313    let mut pending = descriptors
1314        .iter()
1315        .cloned()
1316        .enumerate()
1317        .map(|(index, descriptor)| OciManifestCandidate {
1318            reference: descriptor
1319                .annotations()
1320                .as_ref()
1321                .and_then(|annotations| annotations.get(OCI_REF_NAME_ANNOTATION))
1322                .cloned(),
1323            descriptor,
1324            tree_path: vec![index],
1325        })
1326        .collect::<Vec<_>>();
1327
1328    // Every candidate in a given pass sits at the same nesting depth, so a single
1329    // counter tracks the level being expanded (top-level descriptors are depth 0).
1330    let mut depth = 0usize;
1331    let mut index_count = 0usize;
1332    let mut resolved = Vec::new();
1333
1334    while !pending.is_empty() {
1335        // Classify every pending candidate once for this level: drop
1336        // platform-incompatible and unknown media types, keep image manifests as
1337        // resolved, and collect image indexes to expand into the next level.
1338        let mut indexes = Vec::new();
1339        for candidate in pending {
1340            if !descriptor_matches_platform(&candidate.descriptor, &host) {
1341                continue;
1342            }
1343
1344            if is_oci_image_manifest_descriptor(&candidate.descriptor) {
1345                resolved.push(candidate);
1346            } else if is_oci_image_index_descriptor(&candidate.descriptor) {
1347                indexes.push(candidate);
1348            }
1349        }
1350
1351        if indexes.is_empty() {
1352            break;
1353        }
1354
1355        // Enforce both nesting and total-index limits before reading any blobs.
1356        if depth >= OCI_INDEX_MAX_DEPTH {
1357            return Err(ImageError::ManifestParse(format!(
1358                "OCI image index nesting exceeds {OCI_INDEX_MAX_DEPTH} levels"
1359            )));
1360        }
1361
1362        index_count = index_count.saturating_add(indexes.len());
1363        if index_count > OCI_INDEX_MAX_COUNT {
1364            return Err(ImageError::ManifestParse(format!(
1365                "OCI layout contains more than {OCI_INDEX_MAX_COUNT} nested image indexes"
1366            )));
1367        }
1368
1369        // Batch-read every image index blob for this level in a single archive pass.
1370        let index_paths = indexes
1371            .iter()
1372            .map(|candidate| blob_path_from_digest(candidate.descriptor.digest().as_ref()))
1373            .collect::<ImageResult<HashSet<_>>>()?;
1374        let index_blobs = read_archive_entries(input, &index_paths)?;
1375        let mut next = Vec::new();
1376
1377        for candidate in indexes {
1378            let index_path = blob_path_from_digest(candidate.descriptor.digest().as_ref())?;
1379            let index_bytes = index_blobs.get(&index_path).ok_or_else(|| {
1380                ImageError::ManifestParse(format!(
1381                    "OCI layout missing image index blob {index_path}"
1382                ))
1383            })?;
1384            verify_descriptor_blob(&candidate.descriptor, index_bytes)?;
1385            let index: oci_spec::image::ImageIndex = serde_json::from_slice(index_bytes)
1386                .map_err(|e| ImageError::ManifestParse(format!("OCI image index: {e}")))?;
1387
1388            for (child_index, descriptor) in index.manifests().iter().cloned().enumerate() {
1389                let mut tree_path = candidate.tree_path.clone();
1390                tree_path.push(child_index);
1391                next.push(OciManifestCandidate {
1392                    descriptor,
1393                    reference: candidate.reference.clone(),
1394                    tree_path,
1395                });
1396            }
1397        }
1398
1399        pending = next;
1400        depth += 1;
1401    }
1402
1403    resolved.sort_by(|left, right| left.tree_path.cmp(&right.tree_path));
1404
1405    Ok(resolved)
1406}
1407
1408fn is_oci_image_manifest_descriptor(descriptor: &oci_spec::image::Descriptor) -> bool {
1409    matches!(
1410        descriptor.media_type(),
1411        oci_spec::image::MediaType::ImageManifest
1412    ) || descriptor.media_type().to_string()
1413        == "application/vnd.docker.distribution.manifest.v2+json"
1414}
1415
1416fn is_oci_image_index_descriptor(descriptor: &oci_spec::image::Descriptor) -> bool {
1417    matches!(
1418        descriptor.media_type(),
1419        oci_spec::image::MediaType::ImageIndex
1420    ) || descriptor.media_type().to_string()
1421        == "application/vnd.docker.distribution.manifest.list.v2+json"
1422}
1423
1424fn descriptor_matches_platform(descriptor: &oci_spec::image::Descriptor, host: &Platform) -> bool {
1425    let Some(platform) = descriptor.platform() else {
1426        return true;
1427    };
1428
1429    if *platform.os() != host.os || *platform.architecture() != host.arch {
1430        return false;
1431    }
1432
1433    match (&host.variant, platform.variant()) {
1434        (Some(host_variant), Some(descriptor_variant)) => host_variant == descriptor_variant,
1435        (Some(_), None) => false,
1436        (None, _) => true,
1437    }
1438}
1439
1440fn blob_path_from_digest(digest: &str) -> ImageResult<String> {
1441    let digest: Digest = digest.parse()?;
1442    Ok(format!("blobs/{}/{}", digest.algorithm(), digest.hex()))
1443}
1444
1445fn verify_descriptor_blob(
1446    descriptor: &oci_spec::image::Descriptor,
1447    bytes: &[u8],
1448) -> ImageResult<()> {
1449    if descriptor.size() != bytes.len() as u64 {
1450        return Err(ImageError::ManifestParse(format!(
1451            "OCI blob {} size mismatch: descriptor has {}, archive has {}",
1452            descriptor.digest(),
1453            descriptor.size(),
1454            bytes.len()
1455        )));
1456    }
1457
1458    verify_digest_bytes(descriptor.digest().as_ref(), bytes)
1459}
1460
1461fn verify_layer_descriptor(
1462    descriptor: &oci_spec::image::Descriptor,
1463    layer: &LayerBlobInfo,
1464) -> ImageResult<()> {
1465    if descriptor.size() != layer.size_bytes {
1466        return Err(ImageError::ManifestParse(format!(
1467            "OCI layer {} size mismatch: descriptor has {}, archive has {}",
1468            descriptor.digest(),
1469            descriptor.size(),
1470            layer.size_bytes
1471        )));
1472    }
1473
1474    if descriptor.digest().to_string() != layer.digest {
1475        return Err(ImageError::ManifestParse(format!(
1476            "OCI layer digest mismatch: descriptor has {}, archive has {}",
1477            descriptor.digest(),
1478            layer.digest
1479        )));
1480    }
1481
1482    Ok(())
1483}
1484
1485fn verify_digest_bytes(digest: &str, bytes: &[u8]) -> ImageResult<()> {
1486    let digest: Digest = digest.parse()?;
1487    if digest.algorithm() != "sha256" {
1488        return Err(ImageError::ManifestParse(format!(
1489            "unsupported OCI digest algorithm: {}",
1490            digest.algorithm()
1491        )));
1492    }
1493
1494    let actual = sha256_hex(bytes);
1495    if actual != digest.hex() {
1496        return Err(ImageError::ManifestParse(format!(
1497            "OCI blob digest mismatch: expected {}, got sha256:{actual}",
1498            digest
1499        )));
1500    }
1501
1502    Ok(())
1503}
1504
1505fn verify_docker_layer_path_digest(path: &str, digest: &str) -> ImageResult<()> {
1506    let Some(hex) = path.strip_prefix("blobs/sha256/") else {
1507        return Ok(());
1508    };
1509    if hex.contains('/') {
1510        return Ok(());
1511    }
1512
1513    let expected = format!("sha256:{hex}");
1514    if expected != digest {
1515        return Err(ImageError::ManifestParse(format!(
1516            "docker archive layer path {path} digest mismatch: expected {expected}, got {digest}"
1517        )));
1518    }
1519
1520    Ok(())
1521}
1522
1523fn create_unique_temp_file(dir: &Path, prefix: &str, suffix: &str) -> ImageResult<(File, PathBuf)> {
1524    for _ in 0..128 {
1525        let id = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
1526        let path = dir.join(format!("{prefix}-{}-{id}{suffix}", std::process::id()));
1527        match OpenOptions::new().write(true).create_new(true).open(&path) {
1528            Ok(file) => return Ok((file, path)),
1529            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
1530            Err(e) => {
1531                return Err(ImageError::Cache { path, source: e });
1532            }
1533        }
1534    }
1535
1536    Err(ImageError::Cache {
1537        path: dir.to_path_buf(),
1538        source: io::Error::new(
1539            io::ErrorKind::AlreadyExists,
1540            "could not allocate a unique temporary image archive file",
1541        ),
1542    })
1543}
1544
1545fn extract_layer_blob(
1546    cache: &GlobalCache,
1547    path: &str,
1548    entry: &mut tar::Entry<'_, File>,
1549    counter: u64,
1550) -> ImageResult<LayerBlobInfo> {
1551    let declared_size = entry.header().size().map_err(ImageError::Io)?;
1552    if declared_size > ARCHIVE_LAYER_MAX_BYTES {
1553        return Err(ImageError::ManifestParse(format!(
1554            "archive layer {path} is {declared_size} bytes; max is {ARCHIVE_LAYER_MAX_BYTES}"
1555        )));
1556    }
1557
1558    let (mut temp, temp_path) =
1559        create_unique_temp_file(cache.tmp_dir(), &format!("load-{counter}"), ".blob")?;
1560    let result = (|| {
1561        let mut hasher = Sha256::new();
1562        let mut size = 0u64;
1563        let mut magic = Vec::with_capacity(4);
1564        let mut buf = [0u8; 64 * 1024];
1565
1566        loop {
1567            let read = entry.read(&mut buf).map_err(ImageError::Io)?;
1568            if read == 0 {
1569                break;
1570            }
1571            if magic.len() < 4 {
1572                let take = (4 - magic.len()).min(read);
1573                magic.extend_from_slice(&buf[..take]);
1574            }
1575            hasher.update(&buf[..read]);
1576            temp.write_all(&buf[..read])
1577                .map_err(|e| ImageError::Cache {
1578                    path: temp_path.clone(),
1579                    source: e,
1580                })?;
1581            size += read as u64;
1582            if size > ARCHIVE_LAYER_MAX_BYTES {
1583                return Err(ImageError::ManifestParse(format!(
1584                    "archive layer {path} exceeds {ARCHIVE_LAYER_MAX_BYTES} bytes"
1585                )));
1586            }
1587        }
1588        temp.flush().map_err(|e| ImageError::Cache {
1589            path: temp_path.clone(),
1590            source: e,
1591        })?;
1592        drop(temp);
1593
1594        let digest = Digest::new("sha256", hex::encode(hasher.finalize()));
1595        let staged_path = temp_path.clone();
1596
1597        let media_type = match Compression::detect(&magic) {
1598            Compression::None => OCI_LAYER_MEDIA_TYPE,
1599            Compression::Gzip => OCI_LAYER_GZIP_MEDIA_TYPE,
1600            Compression::Zstd => OCI_LAYER_ZSTD_MEDIA_TYPE,
1601        };
1602
1603        tracing::debug!(path, digest = %digest, size, "loaded layer blob from docker archive");
1604
1605        Ok(LayerBlobInfo {
1606            digest: digest.to_string(),
1607            media_type: media_type.to_string(),
1608            size_bytes: size,
1609            path: staged_path,
1610        })
1611    })();
1612
1613    if result.is_err() {
1614        let _ = std::fs::remove_file(&temp_path);
1615    }
1616
1617    result
1618}
1619
1620fn generate_layer_tar(cache: &GlobalCache, layer: &ImageSaveLayer) -> ImageResult<GeneratedLayer> {
1621    let diff_id: Digest = layer.diff_id.parse()?;
1622    let erofs_path = cache.layer_erofs_path(&diff_id);
1623    let file = File::open(&erofs_path).map_err(|e| ImageError::Cache {
1624        path: erofs_path.clone(),
1625        source: e,
1626    })?;
1627    let mut reader = ErofsReader::new(file).map_err(ImageError::Io)?;
1628    let (temp_file, temp_path) = create_unique_temp_file(cache.tmp_dir(), "save", ".layer.tar")?;
1629    let result = (|| {
1630        let digesting = DigestingWriter::new(BufWriter::new(temp_file));
1631        let mut builder = tar::Builder::new(digesting);
1632        let mut hardlinks: HashMap<u32, PathBuf> = HashMap::new();
1633
1634        reader.walk_entries::<ImageError, _>(|reader, entry| {
1635            if entry.path.as_os_str().is_empty() {
1636                return Ok(());
1637            }
1638
1639            if entry.kind == ErofsEntryKind::CharDevice && entry.rdev == Some((0, 0)) {
1640                append_whiteout(&mut builder, &entry)?;
1641                return Ok(());
1642            }
1643
1644            append_erofs_entry(&mut builder, reader, &entry, &mut hardlinks)?;
1645
1646            if entry.kind == ErofsEntryKind::Directory && entry.is_opaque() {
1647                append_opaque_marker(&mut builder, &entry)?;
1648            }
1649            Ok(())
1650        })?;
1651
1652        let digesting = builder.into_inner().map_err(ImageError::Io)?;
1653        let (mut file, hex, size) = digesting.finish();
1654        file.flush().map_err(|e| ImageError::Cache {
1655            path: temp_path.clone(),
1656            source: e,
1657        })?;
1658
1659        Ok(GeneratedLayer {
1660            diff_id: format!("sha256:{hex}"),
1661            hex,
1662            path: temp_path.clone(),
1663            size,
1664        })
1665    })();
1666
1667    if result.is_err() {
1668        let _ = std::fs::remove_file(&temp_path);
1669    }
1670
1671    result
1672}
1673
1674fn append_erofs_entry<W: Write>(
1675    builder: &mut tar::Builder<DigestingWriter<W>>,
1676    reader: &mut ErofsReader,
1677    entry: &crate::erofs::ErofsTreeEntry,
1678    hardlinks: &mut HashMap<u32, PathBuf>,
1679) -> ImageResult<()> {
1680    let mut header = tar::Header::new_gnu();
1681    apply_header_metadata(&mut header, entry);
1682
1683    match entry.kind {
1684        ErofsEntryKind::RegularFile => {
1685            if let Some(first_path) = hardlinks.get(&entry.nid) {
1686                header.set_entry_type(tar::EntryType::Link);
1687                header.set_size(0);
1688                builder
1689                    .append_link(&mut header, &entry.path, first_path)
1690                    .map_err(ImageError::Io)?;
1691                return Ok(());
1692            }
1693
1694            hardlinks.insert(entry.nid, entry.path.clone());
1695            header.set_entry_type(tar::EntryType::Regular);
1696            header.set_size(entry.size);
1697            header.set_cksum();
1698            let mut data = reader.file_data_reader(entry.nid).map_err(ImageError::Io)?;
1699            builder
1700                .append_data(&mut header, &entry.path, &mut data)
1701                .map_err(ImageError::Io)?;
1702        }
1703        ErofsEntryKind::Directory => {
1704            header.set_entry_type(tar::EntryType::Directory);
1705            header.set_size(0);
1706            header.set_cksum();
1707            builder
1708                .append_data(&mut header, &entry.path, io::empty())
1709                .map_err(ImageError::Io)?;
1710        }
1711        ErofsEntryKind::Symlink => {
1712            header.set_entry_type(tar::EntryType::Symlink);
1713            header.set_size(0);
1714            let target = reader.read_link_by_nid(entry.nid).map_err(ImageError::Io)?;
1715            builder.append_link_literal(&mut header, &entry.path, &target)?;
1716        }
1717        ErofsEntryKind::CharDevice | ErofsEntryKind::BlockDevice => {
1718            header.set_entry_type(if entry.kind == ErofsEntryKind::CharDevice {
1719                tar::EntryType::Char
1720            } else {
1721                tar::EntryType::Block
1722            });
1723            header.set_size(0);
1724            if let Some((major, minor)) = entry.rdev {
1725                header.set_device_major(major).map_err(ImageError::Io)?;
1726                header.set_device_minor(minor).map_err(ImageError::Io)?;
1727            }
1728            header.set_cksum();
1729            builder
1730                .append_data(&mut header, &entry.path, io::empty())
1731                .map_err(ImageError::Io)?;
1732        }
1733        ErofsEntryKind::Fifo => {
1734            header.set_entry_type(tar::EntryType::Fifo);
1735            header.set_size(0);
1736            header.set_cksum();
1737            builder
1738                .append_data(&mut header, &entry.path, io::empty())
1739                .map_err(ImageError::Io)?;
1740        }
1741        ErofsEntryKind::Socket => {
1742            header.set_entry_type(tar::EntryType::new(0o140));
1743            header.set_size(0);
1744            header.set_cksum();
1745            builder
1746                .append_data(&mut header, &entry.path, io::empty())
1747                .map_err(ImageError::Io)?;
1748        }
1749    }
1750
1751    Ok(())
1752}
1753
1754fn append_whiteout<W: Write>(
1755    builder: &mut tar::Builder<DigestingWriter<W>>,
1756    entry: &crate::erofs::ErofsTreeEntry,
1757) -> ImageResult<()> {
1758    let Some(file_name) = entry.path.file_name() else {
1759        return Ok(());
1760    };
1761    let mut path = entry.path.clone();
1762    let mut whiteout_name = b".wh.".to_vec();
1763    whiteout_name.extend_from_slice(os_str_bytes(file_name));
1764    path.set_file_name(os_string_from_vec(whiteout_name).map_err(ImageError::Io)?);
1765    append_empty_file(builder, &path, entry)
1766}
1767
1768fn append_opaque_marker<W: Write>(
1769    builder: &mut tar::Builder<DigestingWriter<W>>,
1770    entry: &crate::erofs::ErofsTreeEntry,
1771) -> ImageResult<()> {
1772    let path = entry.path.join(".wh..wh..opq");
1773    append_empty_file(builder, &path, entry)
1774}
1775
1776fn append_empty_file<W: Write>(
1777    builder: &mut tar::Builder<DigestingWriter<W>>,
1778    path: &Path,
1779    entry: &crate::erofs::ErofsTreeEntry,
1780) -> ImageResult<()> {
1781    let mut header = tar::Header::new_gnu();
1782    apply_header_metadata(&mut header, entry);
1783    header.set_mode(0o000);
1784    header.set_entry_type(tar::EntryType::Regular);
1785    header.set_size(0);
1786    header.set_cksum();
1787    builder
1788        .append_data(&mut header, path, io::empty())
1789        .map_err(ImageError::Io)
1790}
1791
1792fn append_layer_entries<W: Write>(
1793    archive: &mut tar::Builder<W>,
1794    layer: &GeneratedLayer,
1795) -> ImageResult<()> {
1796    append_bytes(archive, &format!("{}/VERSION", layer.hex), b"1.0\n")?;
1797    append_bytes(archive, &format!("{}/json", layer.hex), b"{}")?;
1798
1799    let mut file = File::open(&layer.path).map_err(|e| ImageError::Cache {
1800        path: layer.path.clone(),
1801        source: e,
1802    })?;
1803    let mut header = tar::Header::new_gnu();
1804    header.set_entry_type(tar::EntryType::Regular);
1805    header.set_mode(0o644);
1806    header.set_uid(0);
1807    header.set_gid(0);
1808    header.set_mtime(0);
1809    header.set_size(layer.size);
1810    header.set_cksum();
1811    archive
1812        .append_data(&mut header, format!("{}/layer.tar", layer.hex), &mut file)
1813        .map_err(ImageError::Io)
1814}
1815
1816fn append_blob_file<W: Write>(
1817    archive: &mut tar::Builder<W>,
1818    hex: &str,
1819    path: &Path,
1820    size: u64,
1821) -> ImageResult<()> {
1822    let mut file = File::open(path).map_err(|e| ImageError::Cache {
1823        path: path.to_path_buf(),
1824        source: e,
1825    })?;
1826    let mut header = tar::Header::new_gnu();
1827    header.set_entry_type(tar::EntryType::Regular);
1828    header.set_mode(0o644);
1829    header.set_uid(0);
1830    header.set_gid(0);
1831    header.set_mtime(0);
1832    header.set_size(size);
1833    header.set_cksum();
1834    archive
1835        .append_data(&mut header, format!("blobs/sha256/{hex}"), &mut file)
1836        .map_err(ImageError::Io)
1837}
1838
1839fn append_blob_bytes<W: Write>(
1840    archive: &mut tar::Builder<W>,
1841    hex: &str,
1842    bytes: &[u8],
1843) -> ImageResult<()> {
1844    append_bytes(archive, &format!("blobs/sha256/{hex}"), bytes)
1845}
1846
1847fn append_directory<W: Write>(archive: &mut tar::Builder<W>, path: &str) -> ImageResult<()> {
1848    let mut header = tar::Header::new_gnu();
1849    header.set_entry_type(tar::EntryType::Directory);
1850    header.set_mode(0o755);
1851    header.set_uid(0);
1852    header.set_gid(0);
1853    header.set_mtime(0);
1854    header.set_size(0);
1855    header.set_cksum();
1856    archive
1857        .append_data(&mut header, path, io::empty())
1858        .map_err(ImageError::Io)
1859}
1860
1861fn append_bytes<W: Write>(
1862    archive: &mut tar::Builder<W>,
1863    path: &str,
1864    bytes: &[u8],
1865) -> ImageResult<()> {
1866    let mut header = tar::Header::new_gnu();
1867    header.set_entry_type(tar::EntryType::Regular);
1868    header.set_mode(0o644);
1869    header.set_uid(0);
1870    header.set_gid(0);
1871    header.set_mtime(0);
1872    header.set_size(bytes.len() as u64);
1873    header.set_cksum();
1874    archive
1875        .append_data(&mut header, path, bytes)
1876        .map_err(ImageError::Io)
1877}
1878
1879fn enforce_archive_entry_count(count: u64) -> ImageResult<()> {
1880    if count > ARCHIVE_MAX_ENTRY_COUNT {
1881        return Err(ImageError::ManifestParse(format!(
1882            "archive has more than {ARCHIVE_MAX_ENTRY_COUNT} entries"
1883        )));
1884    }
1885
1886    Ok(())
1887}
1888
1889fn read_entry_to_vec(
1890    entry: &mut tar::Entry<'_, File>,
1891    path: &str,
1892    max_bytes: u64,
1893) -> ImageResult<Vec<u8>> {
1894    let declared_size = entry.header().size().map_err(ImageError::Io)?;
1895    if declared_size > max_bytes {
1896        return Err(ImageError::ManifestParse(format!(
1897            "archive metadata entry {path} is {declared_size} bytes; max is {max_bytes}"
1898        )));
1899    }
1900
1901    let mut data = Vec::with_capacity(declared_size as usize);
1902    entry.read_to_end(&mut data).map_err(ImageError::Io)?;
1903    Ok(data)
1904}
1905
1906fn json_bytes_to_string(bytes: &[u8], context: &str) -> ImageResult<String> {
1907    std::str::from_utf8(bytes)
1908        .map(str::to_owned)
1909        .map_err(|e| ImageError::ConfigParse(format!("{context} is not UTF-8 JSON: {e}")))
1910}
1911
1912fn docker_config_json(
1913    config: &ImageSaveConfig,
1914    raw_config_json: &str,
1915    diff_ids: &[String],
1916) -> ImageResult<Vec<u8>> {
1917    if !raw_config_json.is_empty() {
1918        let mut config_json: serde_json::Value = serde_json::from_str(raw_config_json)
1919            .map_err(|e| ImageError::ConfigParse(format!("parse raw image config: {e}")))?;
1920        let Some(object) = config_json.as_object_mut() else {
1921            return Err(ImageError::ConfigParse(
1922                "raw image config JSON is not an object".into(),
1923            ));
1924        };
1925        object.insert(
1926            "rootfs".into(),
1927            serde_json::json!({
1928                "type": "layers",
1929                "diff_ids": diff_ids,
1930            }),
1931        );
1932        object.entry("architecture").or_insert_with(|| {
1933            serde_json::json!(config.architecture.as_deref().unwrap_or("amd64"))
1934        });
1935        object
1936            .entry("os")
1937            .or_insert_with(|| serde_json::json!(config.os.as_deref().unwrap_or("linux")));
1938        return serde_json::to_vec(&config_json)
1939            .map_err(|e| ImageError::ConfigParse(format!("serialize image config: {e}")));
1940    }
1941
1942    let config_json = serde_json::json!({
1943        "architecture": config.architecture.as_deref().unwrap_or("amd64"),
1944        "os": config.os.as_deref().unwrap_or("linux"),
1945        "config": {
1946            "Env": config.env,
1947            "Entrypoint": config.entrypoint,
1948            "Cmd": config.cmd,
1949            "WorkingDir": config.working_dir,
1950            "User": config.user,
1951            "Labels": if config.labels.is_empty() {
1952                serde_json::Value::Null
1953            } else {
1954                serde_json::to_value(&config.labels)
1955                    .map_err(|e| ImageError::ConfigParse(format!("serialize labels: {e}")))?
1956            },
1957        },
1958        "rootfs": {
1959            "type": "layers",
1960            "diff_ids": diff_ids,
1961        },
1962        "history": diff_ids
1963            .iter()
1964            .map(|_| serde_json::json!({"created_by": "microsandbox image save"}))
1965            .collect::<Vec<_>>(),
1966    });
1967
1968    serde_json::to_vec(&config_json)
1969        .map_err(|e| ImageError::ConfigParse(format!("serialize image config: {e}")))
1970}
1971
1972fn apply_header_metadata(header: &mut tar::Header, entry: &crate::erofs::ErofsTreeEntry) {
1973    header.set_mode((entry.metadata.mode & 0o7777) as u32);
1974    header.set_uid(entry.metadata.uid as u64);
1975    header.set_gid(entry.metadata.gid as u64);
1976    header.set_mtime(entry.metadata.mtime);
1977}
1978
1979fn normalized_archive_path(entry: &tar::Entry<'_, File>) -> ImageResult<String> {
1980    let path = entry.path().map_err(ImageError::Io)?;
1981    let bytes = path_bytes(path.as_ref());
1982    let normalized = if let Some(stripped) = bytes.strip_prefix(b"./") {
1983        stripped
1984    } else {
1985        bytes
1986    };
1987    String::from_utf8(normalized.to_vec())
1988        .map_err(|_| ImageError::ManifestParse("archive path is not valid UTF-8".into()))
1989}
1990
1991fn sha256_hex(bytes: &[u8]) -> String {
1992    hex::encode(Sha256::digest(bytes))
1993}
1994
1995//--------------------------------------------------------------------------------------------------
1996// Tests
1997//--------------------------------------------------------------------------------------------------
1998
1999#[cfg(test)]
2000mod tests {
2001    use std::collections::BTreeMap;
2002    use std::io::Cursor;
2003
2004    use tempfile::tempdir;
2005
2006    use super::*;
2007
2008    #[tokio::test]
2009    async fn warm_import_restages_evicted_layers_from_the_archive() {
2010        let temp = tempdir().unwrap();
2011        let cache = GlobalCache::new(&temp.path().join("cache")).unwrap();
2012        let first = temp.path().join("first.tar");
2013        let second = temp.path().join("second.tar");
2014        write_test_docker_archive_from_layer(
2015            &first,
2016            "example.invalid/first:latest",
2017            simple_layer_tar(),
2018        );
2019        write_test_docker_archive_from_layer(
2020            &second,
2021            "example.invalid/second:latest",
2022            complex_layer_tar(),
2023        );
2024        let mut images = load_archive_into(&cache, &first, ImageLoadOptions::default())
2025            .await
2026            .unwrap();
2027        images.extend(
2028            load_archive_into(&cache, &second, ImageLoadOptions::default())
2029                .await
2030                .unwrap(),
2031        );
2032        let archive = temp.path().join("both.tar");
2033        let requests = images
2034            .iter()
2035            .map(save_request_from_loaded)
2036            .collect::<Vec<_>>();
2037        save_docker_archive(&cache, &archive, &requests).unwrap();
2038        let warmed = load_archive_into(&cache, &archive, ImageLoadOptions::default())
2039            .await
2040            .unwrap();
2041        let evicted = cache.metadata_paths(&warmed[1].metadata).unwrap();
2042        assert!(
2043            load_archive_blocking(
2044                cache.layers_dir().parent().unwrap(),
2045                &archive,
2046                ImageLoadOptions::default()
2047            )
2048            .unwrap()
2049            .staged_layers
2050            .is_empty()
2051        );
2052        // The callback runs after archive preparation and the first image's admission,
2053        // exactly where prune used to invalidate an unprotected warm hit for image two.
2054        let imported = load_archive_with(&cache, &archive, ImageLoadOptions::default(), |image| {
2055            let evicted = evicted.clone();
2056            async move {
2057                if image.reference == "example.invalid/first:latest" {
2058                    for path in evicted {
2059                        std::fs::remove_file(path)?;
2060                    }
2061                }
2062                Ok(())
2063            }
2064        })
2065        .await
2066        .unwrap();
2067        assert_eq!(imported.len(), 2);
2068        for layer in &imported[1].metadata.layers {
2069            assert!(cache.is_layer_materialized(&layer.diff_id.parse().unwrap()));
2070        }
2071    }
2072
2073    #[test]
2074    fn docker_archive_load_save_load_roundtrip() {
2075        let runtime = tokio::runtime::Builder::new_current_thread()
2076            .enable_all()
2077            .build()
2078            .unwrap();
2079        let temp = tempdir().unwrap();
2080        let input = temp.path().join("image.tar");
2081        write_test_docker_archive(&input, "tiny:latest");
2082
2083        let first_cache = temp.path().join("cache-1");
2084        let loaded = runtime
2085            .block_on(load_archive(
2086                &first_cache,
2087                &input,
2088                ImageLoadOptions::default(),
2089            ))
2090            .unwrap();
2091
2092        assert_eq!(loaded.len(), 1);
2093        assert_eq!(loaded[0].reference, "tiny:latest");
2094
2095        let saved = temp.path().join("saved.tar");
2096        let request = save_request_from_loaded(&loaded[0]);
2097        let cache = GlobalCache::new(&first_cache).unwrap();
2098        save_docker_archive(&cache, &saved, &[request]).unwrap();
2099
2100        let second_cache = temp.path().join("cache-2");
2101        let reloaded = runtime
2102            .block_on(load_archive(
2103                &second_cache,
2104                &saved,
2105                ImageLoadOptions::default(),
2106            ))
2107            .unwrap();
2108
2109        assert_eq!(reloaded.len(), 1);
2110        assert_eq!(reloaded[0].reference, "tiny:latest");
2111        assert_eq!(
2112            reloaded[0].metadata.config.cmd,
2113            Some(vec!["cat".into(), "/hello.txt".into()])
2114        );
2115    }
2116
2117    #[test]
2118    fn docker_archive_loads_manifest_blob_paths() {
2119        let runtime = tokio::runtime::Builder::new_current_thread()
2120            .enable_all()
2121            .build()
2122            .unwrap();
2123        let temp = tempdir().unwrap();
2124        let input = temp.path().join("blob-paths.tar");
2125        write_test_docker_blob_archive_from_layer(&input, "blob-paths:latest", simple_layer_tar());
2126
2127        let loaded = runtime
2128            .block_on(load_archive(
2129                &temp.path().join("cache"),
2130                &input,
2131                ImageLoadOptions::default(),
2132            ))
2133            .unwrap();
2134
2135        assert_eq!(loaded.len(), 1);
2136        assert_eq!(loaded[0].reference, "blob-paths:latest");
2137        assert_eq!(
2138            loaded[0].metadata.config.cmd,
2139            Some(vec!["cat".into(), "/hello.txt".into()])
2140        );
2141    }
2142
2143    #[test]
2144    fn docker_archive_rejects_mismatched_blob_layer_path() {
2145        let runtime = tokio::runtime::Builder::new_current_thread()
2146            .enable_all()
2147            .build()
2148            .unwrap();
2149        let temp = tempdir().unwrap();
2150        let input = temp.path().join("bad-blob-path.tar");
2151        let layer_bytes = simple_layer_tar();
2152        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
2153        let config_bytes = test_config_bytes(&diff_id);
2154        let config_name = format!("blobs/sha256/{}", sha256_hex(&config_bytes));
2155        let layer_name = format!("blobs/sha256/{:064x}", 1u8);
2156
2157        write_test_docker_archive_entries(
2158            &input,
2159            "bad-blob-path:latest",
2160            config_name,
2161            layer_name,
2162            config_bytes,
2163            layer_bytes,
2164        );
2165
2166        let err = runtime
2167            .block_on(load_archive(
2168                &temp.path().join("cache"),
2169                &input,
2170                ImageLoadOptions::default(),
2171            ))
2172            .unwrap_err();
2173
2174        assert!(err.to_string().contains("digest mismatch"));
2175    }
2176
2177    #[test]
2178    fn oci_layout_archive_load_save_load_roundtrip() {
2179        let runtime = tokio::runtime::Builder::new_current_thread()
2180            .enable_all()
2181            .build()
2182            .unwrap();
2183        let temp = tempdir().unwrap();
2184        let input = temp.path().join("oci-layout.tar");
2185        write_test_oci_archive_from_layer(&input, "oci-layout:latest", simple_layer_tar());
2186
2187        let first_cache = temp.path().join("cache-1");
2188        let loaded = runtime
2189            .block_on(load_archive(
2190                &first_cache,
2191                &input,
2192                ImageLoadOptions::default(),
2193            ))
2194            .unwrap();
2195
2196        assert_eq!(loaded.len(), 1);
2197        assert_eq!(loaded[0].reference, "oci-layout:latest");
2198
2199        let saved = temp.path().join("saved-oci-layout.tar");
2200        let request = save_request_from_loaded(&loaded[0]);
2201        let cache = GlobalCache::new(&first_cache).unwrap();
2202        save_archive(&cache, &saved, &[request], ImageArchiveFormat::Oci).unwrap();
2203
2204        let index_bytes = read_archive_entry(&saved, "index.json").unwrap().unwrap();
2205        let index: oci_spec::image::ImageIndex = serde_json::from_slice(&index_bytes).unwrap();
2206        assert_eq!(index.manifests().len(), 1);
2207        assert_eq!(
2208            index.manifests()[0]
2209                .annotations()
2210                .as_ref()
2211                .unwrap()
2212                .get(OCI_REF_NAME_ANNOTATION),
2213            Some(&"oci-layout:latest".to_string())
2214        );
2215
2216        let second_cache = temp.path().join("cache-2");
2217        let reloaded = runtime
2218            .block_on(load_archive(
2219                &second_cache,
2220                &saved,
2221                ImageLoadOptions::default(),
2222            ))
2223            .unwrap();
2224
2225        assert_eq!(reloaded.len(), 1);
2226        assert_eq!(reloaded[0].reference, "oci-layout:latest");
2227    }
2228
2229    #[test]
2230    fn oci_layout_archive_loads_nested_indexes() {
2231        let runtime = tokio::runtime::Builder::new_current_thread()
2232            .enable_all()
2233            .build()
2234            .unwrap();
2235        let temp = tempdir().unwrap();
2236        let input = temp.path().join("nested-oci-layout.tar");
2237        write_test_nested_oci_archive(&input, Some("nested:latest"), simple_layer_tar(), 2);
2238
2239        let loaded = runtime
2240            .block_on(load_archive(
2241                &temp.path().join("cache"),
2242                &input,
2243                ImageLoadOptions::default(),
2244            ))
2245            .unwrap();
2246
2247        assert_eq!(loaded.len(), 1);
2248        assert_eq!(loaded[0].reference, "nested:latest");
2249        assert_eq!(
2250            loaded[0].metadata.config.cmd,
2251            Some(vec!["cat".into(), "/hello.txt".into()])
2252        );
2253    }
2254
2255    #[test]
2256    fn oci_layout_nested_index_without_reference_uses_explicit_tag() {
2257        let runtime = tokio::runtime::Builder::new_current_thread()
2258            .enable_all()
2259            .build()
2260            .unwrap();
2261        let temp = tempdir().unwrap();
2262        let input = temp.path().join("untagged-nested-oci-layout.tar");
2263        write_test_nested_oci_archive(&input, None, simple_layer_tar(), 1);
2264
2265        let loaded = runtime
2266            .block_on(load_archive(
2267                &temp.path().join("cache"),
2268                &input,
2269                ImageLoadOptions {
2270                    tags: vec!["explicit:latest".into()],
2271                    ..ImageLoadOptions::default()
2272                },
2273            ))
2274            .unwrap();
2275
2276        assert_eq!(loaded.len(), 1);
2277        assert_eq!(loaded[0].reference, "explicit:latest");
2278    }
2279
2280    #[test]
2281    fn oci_layout_nested_index_skips_incompatible_and_unknown_siblings() {
2282        let runtime = tokio::runtime::Builder::new_current_thread()
2283            .enable_all()
2284            .build()
2285            .unwrap();
2286        let temp = tempdir().unwrap();
2287        let input = temp.path().join("mixed-nested-oci-layout.tar");
2288        // The nested index level holds three siblings: a host-compatible image
2289        // index that resolves to a valid manifest, an incompatible-platform image
2290        // index, and an unknown media type. The latter two point at blobs left out
2291        // of the archive, so a successful load proves neither branch was traversed.
2292        write_test_mixed_nested_oci_archive(&input, "mixed-nested:latest", simple_layer_tar());
2293
2294        let loaded = runtime
2295            .block_on(load_archive(
2296                &temp.path().join("cache"),
2297                &input,
2298                ImageLoadOptions::default(),
2299            ))
2300            .unwrap();
2301
2302        assert_eq!(loaded.len(), 1);
2303        assert_eq!(loaded[0].reference, "mixed-nested:latest");
2304        assert_eq!(
2305            loaded[0].metadata.config.cmd,
2306            Some(vec!["cat".into(), "/hello.txt".into()])
2307        );
2308    }
2309
2310    #[test]
2311    fn docker_archive_save_preserves_layer_semantics() {
2312        let runtime = tokio::runtime::Builder::new_current_thread()
2313            .enable_all()
2314            .build()
2315            .unwrap();
2316        let temp = tempdir().unwrap();
2317        let input = temp.path().join("complex.tar");
2318        let layer_bytes = complex_layer_tar();
2319        write_test_docker_archive_from_layer(&input, "complex:latest", layer_bytes);
2320
2321        let first_cache = temp.path().join("cache-1");
2322        let loaded = runtime
2323            .block_on(load_archive(
2324                &first_cache,
2325                &input,
2326                ImageLoadOptions::default(),
2327            ))
2328            .unwrap();
2329
2330        let saved = temp.path().join("saved-complex.tar");
2331        let request = save_request_from_loaded(&loaded[0]);
2332        let cache = GlobalCache::new(&first_cache).unwrap();
2333        save_docker_archive(&cache, &saved, &[request]).unwrap();
2334
2335        let entries = saved_layer_entries(&saved);
2336        let config_entry = entries.get("etc/config.txt").unwrap();
2337        let config_link_entry = entries.get("etc/config.link").unwrap();
2338        let regular_config_paths = [
2339            ("etc/config.txt", config_entry),
2340            ("etc/config.link", config_link_entry),
2341        ]
2342        .into_iter()
2343        .filter(|(_, entry)| entry.entry_type == tar::EntryType::Regular)
2344        .collect::<Vec<_>>();
2345        let hardlink_config_paths = [
2346            ("etc/config.txt", config_entry),
2347            ("etc/config.link", config_link_entry),
2348        ]
2349        .into_iter()
2350        .filter(|(_, entry)| entry.entry_type == tar::EntryType::Link)
2351        .collect::<Vec<_>>();
2352
2353        assert_eq!(regular_config_paths.len(), 1);
2354        assert_eq!(hardlink_config_paths.len(), 1);
2355        assert_eq!(regular_config_paths[0].1.data, b"shared config\n");
2356        assert_eq!(
2357            hardlink_config_paths[0].1.link_name.as_deref(),
2358            Some(regular_config_paths[0].0)
2359        );
2360        assert_eq!(regular_config_paths[0].1.mode, 0o640);
2361        assert_eq!(regular_config_paths[0].1.uid, 1000);
2362        assert_eq!(regular_config_paths[0].1.gid, 1001);
2363        assert_eq!(regular_config_paths[0].1.mtime, 42);
2364
2365        let symlink_entry = entries.get("bin/config").unwrap();
2366        assert_eq!(symlink_entry.entry_type, tar::EntryType::Symlink);
2367        assert_eq!(
2368            symlink_entry.link_name.as_deref(),
2369            Some("../etc/config.txt")
2370        );
2371
2372        let whiteout_entry = entries.get("var/.wh.deleted").unwrap();
2373        assert_eq!(whiteout_entry.entry_type, tar::EntryType::Regular);
2374        assert!(whiteout_entry.data.is_empty());
2375
2376        let opaque_entry = entries.get("cache/.wh..wh..opq").unwrap();
2377        assert_eq!(opaque_entry.entry_type, tar::EntryType::Regular);
2378        assert!(opaque_entry.data.is_empty());
2379
2380        let second_cache = temp.path().join("cache-2");
2381        let reloaded = runtime
2382            .block_on(load_archive(
2383                &second_cache,
2384                &saved,
2385                ImageLoadOptions::default(),
2386            ))
2387            .unwrap();
2388
2389        assert_eq!(reloaded.len(), 1);
2390        assert_eq!(reloaded[0].reference, "complex:latest");
2391    }
2392
2393    #[test]
2394    fn docker_archive_save_preserves_long_link_targets() {
2395        let runtime = tokio::runtime::Builder::new_current_thread()
2396            .enable_all()
2397            .build()
2398            .unwrap();
2399        let temp = tempdir().unwrap();
2400        let input = temp.path().join("long-links.tar");
2401        let long_target = format!("deep/{}config.txt", "component/".repeat(12));
2402        let long_symlink_path = format!("links/{}link", "component/".repeat(12));
2403        let relative_symlink_target = "../../etc/passwd";
2404        let short_symlink_target = r"..\..\etc\passwd";
2405        let long_symlink_target = format!(r"..\{}..\etc\passwd", "component\\".repeat(12));
2406        let mut layer_bytes = Vec::new();
2407        {
2408            let mut layer = tar::Builder::new(&mut layer_bytes);
2409            append_test_file(&mut layer, &long_target, b"shared config\n", 0o644, 0, 0, 1);
2410            append_test_hardlink(&mut layer, "zz-hardlink", &long_target);
2411            append_test_symlink(&mut layer, "zz-relative-symlink", relative_symlink_target);
2412            append_test_symlink(&mut layer, "zz-short-symlink", short_symlink_target);
2413            append_test_symlink(&mut layer, &long_symlink_path, &long_symlink_target);
2414            layer.finish().unwrap();
2415        }
2416        write_test_docker_archive_from_layer(&input, "long-links:latest", layer_bytes);
2417
2418        let first_cache = temp.path().join("cache-1");
2419        let loaded = runtime
2420            .block_on(load_archive(
2421                &first_cache,
2422                &input,
2423                ImageLoadOptions::default(),
2424            ))
2425            .unwrap();
2426
2427        let saved = temp.path().join("saved-long-links.tar");
2428        let request = save_request_from_loaded(&loaded[0]);
2429        let cache = GlobalCache::new(&first_cache).unwrap();
2430        save_docker_archive(&cache, &saved, &[request]).unwrap();
2431
2432        let entries = saved_layer_entries(&saved);
2433        assert_eq!(
2434            entries.get("zz-hardlink").unwrap().link_name.as_deref(),
2435            Some(long_target.as_str())
2436        );
2437        assert_eq!(
2438            entries
2439                .get("zz-short-symlink")
2440                .unwrap()
2441                .link_name
2442                .as_deref(),
2443            Some(short_symlink_target)
2444        );
2445        assert_eq!(
2446            entries
2447                .get("zz-relative-symlink")
2448                .unwrap()
2449                .link_name
2450                .as_deref(),
2451            Some(relative_symlink_target)
2452        );
2453        assert_eq!(
2454            entries
2455                .get(&long_symlink_path)
2456                .unwrap()
2457                .link_name
2458                .as_deref(),
2459            Some(long_symlink_target.as_str())
2460        );
2461
2462        let second_cache = temp.path().join("cache-2");
2463        let reloaded = runtime
2464            .block_on(load_archive(
2465                &second_cache,
2466                &saved,
2467                ImageLoadOptions::default(),
2468            ))
2469            .unwrap();
2470        assert_eq!(reloaded[0].reference, "long-links:latest");
2471    }
2472
2473    #[test]
2474    fn docker_archive_save_preserves_raw_config_fields() {
2475        let runtime = tokio::runtime::Builder::new_current_thread()
2476            .enable_all()
2477            .build()
2478            .unwrap();
2479        let temp = tempdir().unwrap();
2480        let input = temp.path().join("config-fidelity.tar");
2481        let layer_bytes = simple_layer_tar();
2482        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
2483        let config_bytes = serde_json::to_vec(&serde_json::json!({
2484            "architecture": "arm64",
2485            "os": "linux",
2486            "author": "microsandbox-test",
2487            "config": {
2488                "Env": ["PATH=/usr/bin"],
2489                "Cmd": ["cat", "/hello.txt"],
2490            },
2491            "rootfs": {
2492                "type": "layers",
2493                "diff_ids": [diff_id],
2494            },
2495            "history": [{
2496                "created_by": "fixture",
2497                "comment": "keep me",
2498            }],
2499        }))
2500        .unwrap();
2501        let config_name = format!("{}.json", sha256_hex(&config_bytes));
2502
2503        write_test_docker_archive_entries(
2504            &input,
2505            "config-fidelity:latest",
2506            config_name,
2507            "layer/layer.tar".into(),
2508            config_bytes,
2509            layer_bytes,
2510        );
2511
2512        let first_cache = temp.path().join("cache-1");
2513        let loaded = runtime
2514            .block_on(load_archive(
2515                &first_cache,
2516                &input,
2517                ImageLoadOptions::default(),
2518            ))
2519            .unwrap();
2520        let saved = temp.path().join("saved-config-fidelity.tar");
2521        let request = save_request_from_loaded(&loaded[0]);
2522        let cache = GlobalCache::new(&first_cache).unwrap();
2523        save_docker_archive(&cache, &saved, &[request]).unwrap();
2524
2525        let manifest_bytes = read_archive_entry(&saved, "manifest.json")
2526            .unwrap()
2527            .unwrap();
2528        let manifest: Vec<DockerManifestEntry> = serde_json::from_slice(&manifest_bytes).unwrap();
2529        let saved_config = read_archive_entry(&saved, &manifest[0].config)
2530            .unwrap()
2531            .unwrap();
2532        let saved_config: serde_json::Value = serde_json::from_slice(&saved_config).unwrap();
2533
2534        assert_eq!(saved_config["author"], "microsandbox-test");
2535        assert_eq!(saved_config["history"][0]["comment"], "keep me");
2536    }
2537
2538    #[test]
2539    fn oci_load_hits_early_cache_gate_when_materialized() {
2540        let runtime = tokio::runtime::Builder::new_current_thread()
2541            .enable_all()
2542            .build()
2543            .unwrap();
2544        let temp = tempdir().unwrap();
2545        let input = temp.path().join("gate.tar");
2546        write_test_oci_archive_from_layer(&input, "gate:latest", simple_layer_tar());
2547        let cache = temp.path().join("cache");
2548
2549        // First load materializes the image (layer EROFS + fsmeta + VMDK).
2550        let loaded = runtime
2551            .block_on(load_archive(&cache, &input, ImageLoadOptions::default()))
2552            .unwrap();
2553        assert_eq!(loaded.len(), 1);
2554
2555        // A second load must take the early cache gate: the metadata is rebuilt from the manifest + config blobs alone, and no layer blob is staged.
2556        let prepared =
2557            load_oci_archive_blocking(&cache, &input, ImageLoadOptions::default()).unwrap();
2558        assert!(
2559            prepared.staged_layers.is_empty(),
2560            "early cache gate should skip staging layer blobs on a warm cache"
2561        );
2562        assert_eq!(prepared.images.len(), 1);
2563        assert_eq!(prepared.images[0].reference, "gate:latest");
2564        assert_eq!(
2565            prepared.images[0].metadata.manifest_digest,
2566            loaded[0].metadata.manifest_digest,
2567        );
2568    }
2569
2570    #[test]
2571    fn docker_load_hits_early_cache_gate_when_materialized() {
2572        let runtime = tokio::runtime::Builder::new_current_thread()
2573            .enable_all()
2574            .build()
2575            .unwrap();
2576        let temp = tempdir().unwrap();
2577        let input = temp.path().join("gate.tar");
2578        write_test_docker_archive(&input, "gate-docker:latest");
2579        let cache = temp.path().join("cache");
2580
2581        // First load materializes the image and records its metadata by reference.
2582        let loaded = runtime
2583            .block_on(load_archive(&cache, &input, ImageLoadOptions::default()))
2584            .unwrap();
2585        assert_eq!(loaded.len(), 1);
2586
2587        // A second load must take the early cache gate: the cached metadata is found by reference (RepoTags) and no layer blob is staged.
2588        let prepared = load_archive_blocking(&cache, &input, ImageLoadOptions::default()).unwrap();
2589        assert!(
2590            prepared.staged_layers.is_empty(),
2591            "early cache gate should skip staging layer blobs on a warm cache"
2592        );
2593        assert_eq!(prepared.images.len(), 1);
2594        assert_eq!(prepared.images[0].reference, "gate-docker:latest");
2595        assert_eq!(
2596            prepared.images[0].metadata.manifest_digest,
2597            loaded[0].metadata.manifest_digest,
2598        );
2599    }
2600
2601    #[test]
2602    fn docker_load_misses_gate_after_content_change() {
2603        let runtime = tokio::runtime::Builder::new_current_thread()
2604            .enable_all()
2605            .build()
2606            .unwrap();
2607        let temp = tempdir().unwrap();
2608        let cache = temp.path().join("cache");
2609
2610        // Materialize `app:latest` from one layer, recording metadata under that ref.
2611        let first = temp.path().join("first.tar");
2612        write_test_docker_archive_from_layer(&first, "app:latest", simple_layer_tar());
2613        runtime
2614            .block_on(load_archive(&cache, &first, ImageLoadOptions::default()))
2615            .unwrap();
2616
2617        // Rebuild `app:latest` with different content (a new layer) and reload. The diff_ids no longer match the cached metadata, so the gate must fall through to
2618        // staging rather than reuse the stale image.
2619        let second = temp.path().join("second.tar");
2620        write_test_docker_archive_from_layer(&second, "app:latest", complex_layer_tar());
2621        let prepared = load_archive_blocking(&cache, &second, ImageLoadOptions::default()).unwrap();
2622        assert!(
2623            !prepared.staged_layers.is_empty(),
2624            "a rebuilt tag with new content must not hit the gate"
2625        );
2626    }
2627
2628    #[test]
2629    fn docker_load_misses_gate_after_config_change() {
2630        let runtime = tokio::runtime::Builder::new_current_thread()
2631            .enable_all()
2632            .build()
2633            .unwrap();
2634        let temp = tempdir().unwrap();
2635        let cache = temp.path().join("cache");
2636        let layer_bytes = simple_layer_tar();
2637        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
2638
2639        // Materialize the first image under `app:latest`.
2640        let first = temp.path().join("first.tar");
2641        write_test_docker_archive_from_layer(&first, "app:latest", layer_bytes.clone());
2642        runtime
2643            .block_on(load_archive(&cache, &first, ImageLoadOptions::default()))
2644            .unwrap();
2645
2646        // Rebuild the same filesystem layer with a different image config. Diff IDs
2647        // alone would hit the old gate, but the config digest must force a miss.
2648        let second = temp.path().join("second.tar");
2649        let config_bytes = test_config_bytes_with_cmd(&diff_id, &["sh", "-c", "echo changed"]);
2650        write_test_docker_archive_entries(
2651            &second,
2652            "app:latest",
2653            format!("{}.json", sha256_hex(&config_bytes)),
2654            "layer/layer.tar".into(),
2655            config_bytes,
2656            layer_bytes,
2657        );
2658
2659        let prepared = load_archive_blocking(&cache, &second, ImageLoadOptions::default()).unwrap();
2660        assert!(
2661            !prepared.staged_layers.is_empty(),
2662            "a config-only rebuild must not reuse stale cached metadata"
2663        );
2664        let expected_cmd = vec!["sh".into(), "-c".into(), "echo changed".into()];
2665        assert_eq!(
2666            prepared.images[0].metadata.config.cmd.as_ref(),
2667            Some(&expected_cmd)
2668        );
2669    }
2670
2671    #[test]
2672    fn docker_load_rejects_warm_archive_missing_layer_entry() {
2673        let runtime = tokio::runtime::Builder::new_current_thread()
2674            .enable_all()
2675            .build()
2676            .unwrap();
2677        let temp = tempdir().unwrap();
2678        let cache = temp.path().join("cache");
2679        let layer_bytes = simple_layer_tar();
2680
2681        let valid = temp.path().join("valid.tar");
2682        write_test_docker_archive_from_layer(&valid, "app:latest", layer_bytes.clone());
2683        runtime
2684            .block_on(load_archive(&cache, &valid, ImageLoadOptions::default()))
2685            .unwrap();
2686
2687        let missing_layer = temp.path().join("missing-layer.tar");
2688        write_test_docker_archive_without_layer(&missing_layer, "app:latest", layer_bytes);
2689        let err =
2690            load_archive_blocking(&cache, &missing_layer, ImageLoadOptions::default()).unwrap_err();
2691        match err {
2692            ImageError::ManifestParse(message) => {
2693                assert!(message.contains("docker archive missing layer layer/layer.tar"));
2694            }
2695            other => panic!("expected missing layer error, got {other:?}"),
2696        }
2697    }
2698
2699    #[test]
2700    fn oci_load_rejects_warm_archive_missing_layer_entry() {
2701        let runtime = tokio::runtime::Builder::new_current_thread()
2702            .enable_all()
2703            .build()
2704            .unwrap();
2705        let temp = tempdir().unwrap();
2706        let cache = temp.path().join("cache");
2707        let layer_bytes = simple_layer_tar();
2708
2709        let valid = temp.path().join("valid.tar");
2710        write_test_oci_archive_from_layer(&valid, "app:latest", layer_bytes.clone());
2711        runtime
2712            .block_on(load_archive(&cache, &valid, ImageLoadOptions::default()))
2713            .unwrap();
2714
2715        let missing_layer = temp.path().join("missing-layer.tar");
2716        write_test_oci_archive_without_layer(&missing_layer, "app:latest", layer_bytes);
2717        let err = load_oci_archive_blocking(&cache, &missing_layer, ImageLoadOptions::default())
2718            .unwrap_err();
2719        match err {
2720            ImageError::ManifestParse(message) => {
2721                assert!(message.contains("OCI layout missing layer blob"));
2722            }
2723            other => panic!("expected missing layer error, got {other:?}"),
2724        }
2725    }
2726
2727    #[test]
2728    fn oci_load_rejects_warm_archive_with_mismatched_config_blob() {
2729        let runtime = tokio::runtime::Builder::new_current_thread()
2730            .enable_all()
2731            .build()
2732            .unwrap();
2733        let temp = tempdir().unwrap();
2734        let cache = temp.path().join("cache");
2735        let layer_bytes = simple_layer_tar();
2736        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
2737
2738        let valid = temp.path().join("valid.tar");
2739        write_test_oci_archive_from_layer(&valid, "app:latest", layer_bytes.clone());
2740        runtime
2741            .block_on(load_archive(&cache, &valid, ImageLoadOptions::default()))
2742            .unwrap();
2743
2744        let bad_config = test_config_bytes_with_cmd(&diff_id, &["sh", "-c", "echo changed"]);
2745        let corrupt = temp.path().join("corrupt-config.tar");
2746        write_test_oci_archive_with_config_blob(&corrupt, "app:latest", layer_bytes, bad_config);
2747        let err =
2748            load_oci_archive_blocking(&cache, &corrupt, ImageLoadOptions::default()).unwrap_err();
2749        match err {
2750            ImageError::ManifestParse(message) => {
2751                assert!(message.contains("OCI blob sha256:"));
2752            }
2753            other => panic!("expected config descriptor error, got {other:?}"),
2754        }
2755    }
2756
2757    fn write_test_docker_archive(path: &Path, reference: &str) {
2758        write_test_docker_archive_from_layer(path, reference, simple_layer_tar());
2759    }
2760
2761    fn write_test_docker_archive_from_layer(path: &Path, reference: &str, layer_bytes: Vec<u8>) {
2762        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
2763        let config_bytes = test_config_bytes(&diff_id);
2764        let config_name = format!("{}.json", sha256_hex(&config_bytes));
2765
2766        write_test_docker_archive_entries(
2767            path,
2768            reference,
2769            config_name,
2770            "layer/layer.tar".into(),
2771            config_bytes,
2772            layer_bytes,
2773        );
2774    }
2775
2776    fn write_test_docker_archive_without_layer(path: &Path, reference: &str, layer_bytes: Vec<u8>) {
2777        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
2778        let config_bytes = test_config_bytes(&diff_id);
2779        let config_name = format!("{}.json", sha256_hex(&config_bytes));
2780        let layer_name = "layer/layer.tar".to_string();
2781        let manifest_bytes = serde_json::to_vec(&vec![DockerManifestOut {
2782            config: config_name.clone(),
2783            repo_tags: vec![reference.into()],
2784            layers: vec![layer_name],
2785        }])
2786        .unwrap();
2787
2788        let file = File::create(path).unwrap();
2789        let mut archive = tar::Builder::new(file);
2790        append_bytes(&mut archive, &config_name, &config_bytes).unwrap();
2791        append_bytes(&mut archive, "manifest.json", &manifest_bytes).unwrap();
2792        archive.finish().unwrap();
2793    }
2794
2795    fn write_test_docker_blob_archive_from_layer(
2796        path: &Path,
2797        reference: &str,
2798        layer_bytes: Vec<u8>,
2799    ) {
2800        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
2801        let config_bytes = test_config_bytes(&diff_id);
2802        let config_name = format!("blobs/sha256/{}", sha256_hex(&config_bytes));
2803        let layer_name = format!("blobs/sha256/{}", sha256_hex(&layer_bytes));
2804
2805        write_test_docker_archive_entries(
2806            path,
2807            reference,
2808            config_name,
2809            layer_name,
2810            config_bytes,
2811            layer_bytes,
2812        );
2813    }
2814
2815    fn write_test_nested_oci_archive(
2816        path: &Path,
2817        reference: Option<&str>,
2818        layer_bytes: Vec<u8>,
2819        index_depth: usize,
2820    ) {
2821        assert!(index_depth > 0);
2822
2823        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
2824        let config_bytes = test_config_bytes(&diff_id);
2825        let config_hex = sha256_hex(&config_bytes);
2826        let layer_hex = sha256_hex(&layer_bytes);
2827        let manifest_bytes = serde_json::to_vec(&serde_json::json!({
2828            "schemaVersion": 2,
2829            "mediaType": OCI_MANIFEST_MEDIA_TYPE,
2830            "config": {
2831                "mediaType": OCI_CONFIG_MEDIA_TYPE,
2832                "digest": format!("sha256:{config_hex}"),
2833                "size": config_bytes.len(),
2834            },
2835            "layers": [{
2836                "mediaType": OCI_LAYER_MEDIA_TYPE,
2837                "digest": format!("sha256:{layer_hex}"),
2838                "size": layer_bytes.len(),
2839            }],
2840        }))
2841        .unwrap();
2842        let manifest_hex = sha256_hex(&manifest_bytes);
2843        let host = Platform::host_linux();
2844        let mut target_descriptor = serde_json::json!({
2845            "mediaType": OCI_MANIFEST_MEDIA_TYPE,
2846            "digest": format!("sha256:{manifest_hex}"),
2847            "size": manifest_bytes.len(),
2848            "platform": {
2849                "architecture": host.arch.to_string(),
2850                "os": host.os.to_string(),
2851            },
2852        });
2853        let mut metadata_blobs = vec![(manifest_hex, manifest_bytes)];
2854
2855        for _ in 0..index_depth {
2856            let index_bytes = serde_json::to_vec(&serde_json::json!({
2857                "schemaVersion": 2,
2858                "mediaType": OCI_INDEX_MEDIA_TYPE,
2859                "manifests": [target_descriptor],
2860            }))
2861            .unwrap();
2862            let index_hex = sha256_hex(&index_bytes);
2863            target_descriptor = serde_json::json!({
2864                "mediaType": OCI_INDEX_MEDIA_TYPE,
2865                "digest": format!("sha256:{index_hex}"),
2866                "size": index_bytes.len(),
2867            });
2868            metadata_blobs.push((index_hex, index_bytes));
2869        }
2870
2871        if let Some(reference) = reference {
2872            target_descriptor["annotations"] = serde_json::json!({
2873                (OCI_REF_NAME_ANNOTATION): reference,
2874            });
2875        }
2876        let root_index_bytes = serde_json::to_vec(&serde_json::json!({
2877            "schemaVersion": 2,
2878            "mediaType": OCI_INDEX_MEDIA_TYPE,
2879            "manifests": [target_descriptor],
2880        }))
2881        .unwrap();
2882
2883        let file = File::create(path).unwrap();
2884        let mut archive = tar::Builder::new(file);
2885        append_bytes(
2886            &mut archive,
2887            "oci-layout",
2888            br#"{"imageLayoutVersion":"1.0.0"}"#,
2889        )
2890        .unwrap();
2891        append_bytes(&mut archive, "index.json", &root_index_bytes).unwrap();
2892        append_bytes(
2893            &mut archive,
2894            &format!("blobs/sha256/{config_hex}"),
2895            &config_bytes,
2896        )
2897        .unwrap();
2898        for (hex, bytes) in metadata_blobs {
2899            append_bytes(&mut archive, &format!("blobs/sha256/{hex}"), &bytes).unwrap();
2900        }
2901        append_bytes(
2902            &mut archive,
2903            &format!("blobs/sha256/{layer_hex}"),
2904            &layer_bytes,
2905        )
2906        .unwrap();
2907        archive.finish().unwrap();
2908    }
2909
2910    /// Builds an OCI layout whose nested index level mixes a resolvable
2911    /// host-compatible image index with two branches that must never be read:
2912    /// an incompatible-platform image index and an unknown media type. Both of
2913    /// those descriptors reference blobs that are intentionally omitted from the
2914    /// archive, so the load only succeeds if their branches are skipped.
2915    fn write_test_mixed_nested_oci_archive(path: &Path, reference: &str, layer_bytes: Vec<u8>) {
2916        let host = Platform::host_linux();
2917        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
2918        let config_bytes = test_config_bytes(&diff_id);
2919        let config_hex = sha256_hex(&config_bytes);
2920        let layer_hex = sha256_hex(&layer_bytes);
2921
2922        let manifest_bytes = serde_json::to_vec(&serde_json::json!({
2923            "schemaVersion": 2,
2924            "mediaType": OCI_MANIFEST_MEDIA_TYPE,
2925            "config": {
2926                "mediaType": OCI_CONFIG_MEDIA_TYPE,
2927                "digest": format!("sha256:{config_hex}"),
2928                "size": config_bytes.len(),
2929            },
2930            "layers": [{
2931                "mediaType": OCI_LAYER_MEDIA_TYPE,
2932                "digest": format!("sha256:{layer_hex}"),
2933                "size": layer_bytes.len(),
2934            }],
2935        }))
2936        .unwrap();
2937        let manifest_hex = sha256_hex(&manifest_bytes);
2938
2939        // Host-compatible branch: an image index that resolves to the manifest.
2940        let inner_index_bytes = serde_json::to_vec(&serde_json::json!({
2941            "schemaVersion": 2,
2942            "mediaType": OCI_INDEX_MEDIA_TYPE,
2943            "manifests": [{
2944                "mediaType": OCI_MANIFEST_MEDIA_TYPE,
2945                "digest": format!("sha256:{manifest_hex}"),
2946                "size": manifest_bytes.len(),
2947                "platform": {
2948                    "architecture": host.arch.to_string(),
2949                    "os": host.os.to_string(),
2950                },
2951            }],
2952        }))
2953        .unwrap();
2954        let inner_index_hex = sha256_hex(&inner_index_bytes);
2955
2956        // Digests for descriptors whose blobs are deliberately absent from the
2957        // archive: reaching either branch would fail the load.
2958        let absent_incompatible_hex = sha256_hex(b"absent-incompatible-platform-index");
2959        let absent_unknown_hex = sha256_hex(b"absent-unknown-media-type");
2960
2961        // Nested index level holding the three siblings under test.
2962        let mixed_index_bytes = serde_json::to_vec(&serde_json::json!({
2963            "schemaVersion": 2,
2964            "mediaType": OCI_INDEX_MEDIA_TYPE,
2965            "manifests": [
2966                {
2967                    "mediaType": OCI_INDEX_MEDIA_TYPE,
2968                    "digest": format!("sha256:{inner_index_hex}"),
2969                    "size": inner_index_bytes.len(),
2970                },
2971                {
2972                    "mediaType": OCI_INDEX_MEDIA_TYPE,
2973                    "digest": format!("sha256:{absent_incompatible_hex}"),
2974                    "size": 0,
2975                    "platform": {
2976                        "architecture": "sparc64",
2977                        "os": "solaris",
2978                    },
2979                },
2980                {
2981                    "mediaType": "application/vnd.example.unknown.v1+json",
2982                    "digest": format!("sha256:{absent_unknown_hex}"),
2983                    "size": 0,
2984                },
2985            ],
2986        }))
2987        .unwrap();
2988        let mixed_index_hex = sha256_hex(&mixed_index_bytes);
2989
2990        // Root index nests the mixed level one level below index.json and carries
2991        // the reference annotation so it propagates to the resolved manifest.
2992        let root_index_bytes = serde_json::to_vec(&serde_json::json!({
2993            "schemaVersion": 2,
2994            "mediaType": OCI_INDEX_MEDIA_TYPE,
2995            "manifests": [{
2996                "mediaType": OCI_INDEX_MEDIA_TYPE,
2997                "digest": format!("sha256:{mixed_index_hex}"),
2998                "size": mixed_index_bytes.len(),
2999                "annotations": {
3000                    (OCI_REF_NAME_ANNOTATION): reference,
3001                },
3002            }],
3003        }))
3004        .unwrap();
3005
3006        let file = File::create(path).unwrap();
3007        let mut archive = tar::Builder::new(file);
3008        append_bytes(
3009            &mut archive,
3010            "oci-layout",
3011            br#"{"imageLayoutVersion":"1.0.0"}"#,
3012        )
3013        .unwrap();
3014        append_bytes(&mut archive, "index.json", &root_index_bytes).unwrap();
3015        for (hex, bytes) in [
3016            (config_hex, config_bytes),
3017            (manifest_hex, manifest_bytes),
3018            (inner_index_hex, inner_index_bytes),
3019            (mixed_index_hex, mixed_index_bytes),
3020        ] {
3021            append_bytes(&mut archive, &format!("blobs/sha256/{hex}"), &bytes).unwrap();
3022        }
3023        append_bytes(
3024            &mut archive,
3025            &format!("blobs/sha256/{layer_hex}"),
3026            &layer_bytes,
3027        )
3028        .unwrap();
3029        archive.finish().unwrap();
3030    }
3031
3032    fn write_test_oci_archive_from_layer(path: &Path, reference: &str, layer_bytes: Vec<u8>) {
3033        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
3034        let config_bytes = test_config_bytes(&diff_id);
3035        let config_hex = sha256_hex(&config_bytes);
3036        let layer_hex = sha256_hex(&layer_bytes);
3037        let manifest_bytes = serde_json::to_vec(&serde_json::json!({
3038            "schemaVersion": 2,
3039            "mediaType": OCI_MANIFEST_MEDIA_TYPE,
3040            "config": {
3041                "mediaType": OCI_CONFIG_MEDIA_TYPE,
3042                "digest": format!("sha256:{config_hex}"),
3043                "size": config_bytes.len(),
3044            },
3045            "layers": [{
3046                "mediaType": OCI_LAYER_MEDIA_TYPE,
3047                "digest": format!("sha256:{layer_hex}"),
3048                "size": layer_bytes.len(),
3049            }],
3050        }))
3051        .unwrap();
3052        let manifest_hex = sha256_hex(&manifest_bytes);
3053        let host = Platform::host_linux();
3054        let index_bytes = serde_json::to_vec(&serde_json::json!({
3055            "schemaVersion": 2,
3056            "mediaType": OCI_INDEX_MEDIA_TYPE,
3057            "manifests": [{
3058                "mediaType": OCI_MANIFEST_MEDIA_TYPE,
3059                "digest": format!("sha256:{manifest_hex}"),
3060                "size": manifest_bytes.len(),
3061                "platform": {
3062                    "architecture": host.arch.to_string(),
3063                    "os": host.os.to_string(),
3064                },
3065                "annotations": {
3066                    (OCI_REF_NAME_ANNOTATION): reference,
3067                },
3068            }],
3069        }))
3070        .unwrap();
3071
3072        let file = File::create(path).unwrap();
3073        let mut archive = tar::Builder::new(file);
3074        append_bytes(
3075            &mut archive,
3076            "oci-layout",
3077            br#"{"imageLayoutVersion":"1.0.0"}"#,
3078        )
3079        .unwrap();
3080        append_bytes(&mut archive, "index.json", &index_bytes).unwrap();
3081        append_bytes(
3082            &mut archive,
3083            &format!("blobs/sha256/{config_hex}"),
3084            &config_bytes,
3085        )
3086        .unwrap();
3087        append_bytes(
3088            &mut archive,
3089            &format!("blobs/sha256/{manifest_hex}"),
3090            &manifest_bytes,
3091        )
3092        .unwrap();
3093        append_bytes(
3094            &mut archive,
3095            &format!("blobs/sha256/{layer_hex}"),
3096            &layer_bytes,
3097        )
3098        .unwrap();
3099        archive.finish().unwrap();
3100    }
3101
3102    fn write_test_oci_archive_without_layer(path: &Path, reference: &str, layer_bytes: Vec<u8>) {
3103        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
3104        let config_bytes = test_config_bytes(&diff_id);
3105        let config_hex = sha256_hex(&config_bytes);
3106        let layer_hex = sha256_hex(&layer_bytes);
3107        let manifest_bytes = serde_json::to_vec(&serde_json::json!({
3108            "schemaVersion": 2,
3109            "mediaType": OCI_MANIFEST_MEDIA_TYPE,
3110            "config": {
3111                "mediaType": OCI_CONFIG_MEDIA_TYPE,
3112                "digest": format!("sha256:{config_hex}"),
3113                "size": config_bytes.len(),
3114            },
3115            "layers": [{
3116                "mediaType": OCI_LAYER_MEDIA_TYPE,
3117                "digest": format!("sha256:{layer_hex}"),
3118                "size": layer_bytes.len(),
3119            }],
3120        }))
3121        .unwrap();
3122        let manifest_hex = sha256_hex(&manifest_bytes);
3123        let index_bytes = test_oci_index_bytes(reference, &manifest_hex, manifest_bytes.len());
3124
3125        let file = File::create(path).unwrap();
3126        let mut archive = tar::Builder::new(file);
3127        append_bytes(
3128            &mut archive,
3129            "oci-layout",
3130            br#"{"imageLayoutVersion":"1.0.0"}"#,
3131        )
3132        .unwrap();
3133        append_bytes(&mut archive, "index.json", &index_bytes).unwrap();
3134        append_bytes(
3135            &mut archive,
3136            &format!("blobs/sha256/{config_hex}"),
3137            &config_bytes,
3138        )
3139        .unwrap();
3140        append_bytes(
3141            &mut archive,
3142            &format!("blobs/sha256/{manifest_hex}"),
3143            &manifest_bytes,
3144        )
3145        .unwrap();
3146        archive.finish().unwrap();
3147    }
3148
3149    fn write_test_oci_archive_with_config_blob(
3150        path: &Path,
3151        reference: &str,
3152        layer_bytes: Vec<u8>,
3153        stored_config_bytes: Vec<u8>,
3154    ) {
3155        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
3156        let manifest_config_bytes = test_config_bytes(&diff_id);
3157        let config_hex = sha256_hex(&manifest_config_bytes);
3158        let layer_hex = sha256_hex(&layer_bytes);
3159        let manifest_bytes = serde_json::to_vec(&serde_json::json!({
3160            "schemaVersion": 2,
3161            "mediaType": OCI_MANIFEST_MEDIA_TYPE,
3162            "config": {
3163                "mediaType": OCI_CONFIG_MEDIA_TYPE,
3164                "digest": format!("sha256:{config_hex}"),
3165                "size": manifest_config_bytes.len(),
3166            },
3167            "layers": [{
3168                "mediaType": OCI_LAYER_MEDIA_TYPE,
3169                "digest": format!("sha256:{layer_hex}"),
3170                "size": layer_bytes.len(),
3171            }],
3172        }))
3173        .unwrap();
3174        let manifest_hex = sha256_hex(&manifest_bytes);
3175        let index_bytes = test_oci_index_bytes(reference, &manifest_hex, manifest_bytes.len());
3176
3177        let file = File::create(path).unwrap();
3178        let mut archive = tar::Builder::new(file);
3179        append_bytes(
3180            &mut archive,
3181            "oci-layout",
3182            br#"{"imageLayoutVersion":"1.0.0"}"#,
3183        )
3184        .unwrap();
3185        append_bytes(&mut archive, "index.json", &index_bytes).unwrap();
3186        append_bytes(
3187            &mut archive,
3188            &format!("blobs/sha256/{config_hex}"),
3189            &stored_config_bytes,
3190        )
3191        .unwrap();
3192        append_bytes(
3193            &mut archive,
3194            &format!("blobs/sha256/{manifest_hex}"),
3195            &manifest_bytes,
3196        )
3197        .unwrap();
3198        append_bytes(
3199            &mut archive,
3200            &format!("blobs/sha256/{layer_hex}"),
3201            &layer_bytes,
3202        )
3203        .unwrap();
3204        archive.finish().unwrap();
3205    }
3206
3207    fn simple_layer_tar() -> Vec<u8> {
3208        let mut layer_bytes = Vec::new();
3209        {
3210            let mut layer = tar::Builder::new(&mut layer_bytes);
3211            let data = b"hello from archive\n";
3212            let mut header = tar::Header::new_gnu();
3213            header.set_entry_type(tar::EntryType::Regular);
3214            header.set_mode(0o644);
3215            header.set_uid(0);
3216            header.set_gid(0);
3217            header.set_mtime(0);
3218            header.set_size(data.len() as u64);
3219            header.set_cksum();
3220            layer
3221                .append_data(&mut header, "hello.txt", Cursor::new(data))
3222                .unwrap();
3223            layer.finish().unwrap();
3224        }
3225
3226        layer_bytes
3227    }
3228
3229    fn test_config_bytes(diff_id: &str) -> Vec<u8> {
3230        test_config_bytes_with_cmd(diff_id, &["cat", "/hello.txt"])
3231    }
3232
3233    fn test_config_bytes_with_cmd(diff_id: &str, cmd: &[&str]) -> Vec<u8> {
3234        serde_json::to_vec(&serde_json::json!({
3235            "architecture": "arm64",
3236            "os": "linux",
3237            "config": {
3238                "Env": ["PATH=/usr/bin"],
3239                "Cmd": cmd,
3240            },
3241            "rootfs": {
3242                "type": "layers",
3243                "diff_ids": [diff_id],
3244            },
3245        }))
3246        .unwrap()
3247    }
3248
3249    fn test_oci_index_bytes(reference: &str, manifest_hex: &str, manifest_len: usize) -> Vec<u8> {
3250        let host = Platform::host_linux();
3251        serde_json::to_vec(&serde_json::json!({
3252            "schemaVersion": 2,
3253            "mediaType": OCI_INDEX_MEDIA_TYPE,
3254            "manifests": [{
3255                "mediaType": OCI_MANIFEST_MEDIA_TYPE,
3256                "digest": format!("sha256:{manifest_hex}"),
3257                "size": manifest_len,
3258                "platform": {
3259                    "architecture": host.arch.to_string(),
3260                    "os": host.os.to_string(),
3261                },
3262                "annotations": {
3263                    (OCI_REF_NAME_ANNOTATION): reference,
3264                },
3265            }],
3266        }))
3267        .unwrap()
3268    }
3269
3270    fn write_test_docker_archive_entries(
3271        path: &Path,
3272        reference: &str,
3273        config_name: String,
3274        layer_name: String,
3275        config_bytes: Vec<u8>,
3276        layer_bytes: Vec<u8>,
3277    ) {
3278        let manifest_bytes = serde_json::to_vec(&vec![DockerManifestOut {
3279            config: config_name.clone(),
3280            repo_tags: vec![reference.into()],
3281            layers: vec![layer_name.clone()],
3282        }])
3283        .unwrap();
3284
3285        let file = File::create(path).unwrap();
3286        let mut archive = tar::Builder::new(file);
3287        append_bytes(&mut archive, &config_name, &config_bytes).unwrap();
3288        append_bytes(&mut archive, "manifest.json", &manifest_bytes).unwrap();
3289
3290        let mut header = tar::Header::new_gnu();
3291        header.set_entry_type(tar::EntryType::Regular);
3292        header.set_mode(0o644);
3293        header.set_uid(0);
3294        header.set_gid(0);
3295        header.set_mtime(0);
3296        header.set_size(layer_bytes.len() as u64);
3297        header.set_cksum();
3298        archive
3299            .append_data(&mut header, layer_name, Cursor::new(layer_bytes))
3300            .unwrap();
3301        archive.finish().unwrap();
3302    }
3303
3304    fn complex_layer_tar() -> Vec<u8> {
3305        let mut layer_bytes = Vec::new();
3306        {
3307            let mut layer = tar::Builder::new(&mut layer_bytes);
3308            append_test_dir(&mut layer, "bin", 0o755, 0, 0, 1);
3309            append_test_dir(&mut layer, "cache", 0o755, 0, 0, 1);
3310            append_test_dir(&mut layer, "etc", 0o755, 0, 0, 1);
3311            append_test_dir(&mut layer, "var", 0o755, 0, 0, 1);
3312            append_test_file(
3313                &mut layer,
3314                "etc/config.txt",
3315                b"shared config\n",
3316                0o640,
3317                1000,
3318                1001,
3319                42,
3320            );
3321            append_test_hardlink(&mut layer, "etc/config.link", "etc/config.txt");
3322            append_test_symlink(&mut layer, "bin/config", "../etc/config.txt");
3323            append_test_file(&mut layer, "var/.wh.deleted", b"", 0o000, 0, 0, 1);
3324            append_test_file(&mut layer, "cache/.wh..wh..opq", b"", 0o000, 0, 0, 1);
3325            layer.finish().unwrap();
3326        }
3327        layer_bytes
3328    }
3329
3330    fn append_test_dir(
3331        layer: &mut tar::Builder<&mut Vec<u8>>,
3332        path: &str,
3333        mode: u32,
3334        uid: u64,
3335        gid: u64,
3336        mtime: u64,
3337    ) {
3338        let mut header = tar::Header::new_gnu();
3339        header.set_entry_type(tar::EntryType::Directory);
3340        header.set_mode(mode);
3341        header.set_uid(uid);
3342        header.set_gid(gid);
3343        header.set_mtime(mtime);
3344        header.set_size(0);
3345        header.set_cksum();
3346        layer.append_data(&mut header, path, io::empty()).unwrap();
3347    }
3348
3349    fn append_test_file(
3350        layer: &mut tar::Builder<&mut Vec<u8>>,
3351        path: &str,
3352        data: &[u8],
3353        mode: u32,
3354        uid: u64,
3355        gid: u64,
3356        mtime: u64,
3357    ) {
3358        let mut header = tar::Header::new_gnu();
3359        header.set_entry_type(tar::EntryType::Regular);
3360        header.set_mode(mode);
3361        header.set_uid(uid);
3362        header.set_gid(gid);
3363        header.set_mtime(mtime);
3364        header.set_size(data.len() as u64);
3365        header.set_cksum();
3366        layer
3367            .append_data(&mut header, path, Cursor::new(data))
3368            .unwrap();
3369    }
3370
3371    fn append_test_hardlink(layer: &mut tar::Builder<&mut Vec<u8>>, path: &str, target: &str) {
3372        let mut header = tar::Header::new_gnu();
3373        header.set_entry_type(tar::EntryType::Link);
3374        header.set_size(0);
3375        layer.append_link(&mut header, path, target).unwrap();
3376    }
3377
3378    fn append_test_symlink(layer: &mut tar::Builder<&mut Vec<u8>>, path: &str, target: &str) {
3379        let mut header = tar::Header::new_gnu();
3380        header.set_entry_type(tar::EntryType::Symlink);
3381        header.set_mode(0o777);
3382        header.set_size(0);
3383        layer
3384            .append_link_literal(&mut header, Path::new(path), target.as_bytes())
3385            .unwrap();
3386    }
3387
3388    #[derive(Debug)]
3389    struct SavedLayerEntry {
3390        entry_type: tar::EntryType,
3391        link_name: Option<String>,
3392        mode: u32,
3393        uid: u64,
3394        gid: u64,
3395        mtime: u64,
3396        data: Vec<u8>,
3397    }
3398
3399    fn saved_layer_entries(path: &Path) -> BTreeMap<String, SavedLayerEntry> {
3400        let file = File::open(path).unwrap();
3401        let mut archive = tar::Archive::new(file);
3402        let mut layer_bytes = None;
3403
3404        for entry in archive.entries().unwrap() {
3405            let mut entry = entry.unwrap();
3406            let entry_path = entry.path().unwrap().to_string_lossy().into_owned();
3407            if entry_path.ends_with("/layer.tar") {
3408                assert!(layer_bytes.is_none());
3409                let mut data = Vec::new();
3410                entry.read_to_end(&mut data).unwrap();
3411                layer_bytes = Some(data);
3412            }
3413        }
3414
3415        let layer_bytes = layer_bytes.unwrap();
3416        let mut layer = tar::Archive::new(Cursor::new(layer_bytes));
3417        let mut entries = BTreeMap::new();
3418
3419        for entry in layer.entries().unwrap() {
3420            let mut entry = entry.unwrap();
3421            let path = entry.path().unwrap().to_string_lossy().into_owned();
3422            let header = entry.header();
3423            let entry_type = header.entry_type();
3424            let mode = header.mode().unwrap();
3425            let uid = header.uid().unwrap();
3426            let gid = header.gid().unwrap();
3427            let mtime = header.mtime().unwrap();
3428            let link_name = if matches!(entry_type, tar::EntryType::Link | tar::EntryType::Symlink)
3429            {
3430                Some(String::from_utf8_lossy(entry.link_name_bytes().unwrap().as_ref()).into())
3431            } else {
3432                None
3433            };
3434            let mut data = Vec::new();
3435            entry.read_to_end(&mut data).unwrap();
3436
3437            entries.insert(
3438                path,
3439                SavedLayerEntry {
3440                    entry_type,
3441                    link_name,
3442                    mode,
3443                    uid,
3444                    gid,
3445                    mtime,
3446                    data,
3447                },
3448            );
3449        }
3450
3451        entries
3452    }
3453
3454    fn save_request_from_loaded(image: &LoadedImage) -> ImageSaveRequest {
3455        let host = Platform::host_linux();
3456        ImageSaveRequest {
3457            reference: image.reference.clone(),
3458            config: ImageSaveConfig {
3459                architecture: Some(host.arch.to_string()),
3460                os: Some(host.os.to_string()),
3461                env: image.metadata.config.env.clone(),
3462                entrypoint: image.metadata.config.entrypoint.clone(),
3463                cmd: image.metadata.config.cmd.clone(),
3464                working_dir: image.metadata.config.working_dir.clone(),
3465                user: image.metadata.config.user.clone(),
3466                labels: image
3467                    .metadata
3468                    .config
3469                    .labels
3470                    .iter()
3471                    .map(|(key, value)| (key.clone(), value.clone()))
3472                    .collect(),
3473            },
3474            raw_config_json: image.metadata.raw_config_json.clone(),
3475            layers: image
3476                .metadata
3477                .layers
3478                .iter()
3479                .map(|layer| ImageSaveLayer {
3480                    diff_id: layer.diff_id.clone(),
3481                })
3482                .collect(),
3483        }
3484    }
3485}