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