Skip to main content

microsandbox_image/archive/
docker.rs

1//! Container image archive import/export.
2
3use std::collections::{BTreeMap, HashMap, HashSet};
4use std::ffi::OsString;
5use std::fs::{File, OpenOptions};
6use std::io::{self, BufWriter, Read, Write};
7use std::os::unix::ffi::{OsStrExt, OsStringExt};
8use std::path::{Path, PathBuf};
9use std::sync::{
10    Arc,
11    atomic::{AtomicU64, Ordering},
12};
13
14use serde::{Deserialize, Serialize};
15use sha2::{Digest as Sha2Digest, Sha256};
16
17use crate::{
18    CachedImageMetadata, CachedLayerMetadata, Digest, GlobalCache, ImageConfig, ImageError,
19    ImageResult, Platform, Reference, Registry,
20    erofs::{ErofsEntryKind, ErofsReader},
21    tar::Compression,
22};
23
24//--------------------------------------------------------------------------------------------------
25// Constants
26//--------------------------------------------------------------------------------------------------
27
28const OCI_CONFIG_MEDIA_TYPE: &str = "application/vnd.oci.image.config.v1+json";
29const OCI_MANIFEST_MEDIA_TYPE: &str = "application/vnd.oci.image.manifest.v1+json";
30const OCI_INDEX_MEDIA_TYPE: &str = "application/vnd.oci.image.index.v1+json";
31const OCI_LAYER_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar";
32const OCI_LAYER_GZIP_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar+gzip";
33const OCI_LAYER_ZSTD_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar+zstd";
34const OCI_REF_NAME_ANNOTATION: &str = "org.opencontainers.image.ref.name";
35const ARCHIVE_METADATA_MAX_BYTES: u64 = 16 * 1024 * 1024;
36const ARCHIVE_LAYER_MAX_BYTES: u64 = 10 * 1024 * 1024 * 1024;
37const ARCHIVE_MAX_ENTRY_COUNT: u64 = 1_000_000;
38static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
39
40//--------------------------------------------------------------------------------------------------
41// Types
42//--------------------------------------------------------------------------------------------------
43
44/// Options for importing image archives.
45#[derive(Debug, Clone, Default)]
46pub struct ImageLoadOptions {
47    /// Extra tags to apply to the first image in the archive.
48    pub tags: Vec<String>,
49}
50
51/// Archive format to use when saving images.
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
53pub enum ImageArchiveFormat {
54    /// Docker `docker save` compatible archive.
55    #[default]
56    Docker,
57    /// OCI Image Layout archive.
58    Oci,
59}
60
61/// One loaded image reference and its cached metadata.
62#[derive(Debug, Clone)]
63pub struct LoadedImage {
64    /// Image reference imported into the local cache.
65    pub reference: String,
66    /// Cached image metadata to persist in the database.
67    pub metadata: CachedImageMetadata,
68}
69
70/// Image data needed to export a Docker archive.
71#[derive(Debug, Clone)]
72pub struct ImageSaveRequest {
73    /// Image reference to write as a Docker `RepoTags` entry.
74    pub reference: String,
75    /// Image config fields.
76    pub config: ImageSaveConfig,
77    /// Raw image config JSON to preserve non-runtime metadata on export.
78    pub raw_config_json: String,
79    /// Ordered layer list, bottom-to-top.
80    pub layers: Vec<ImageSaveLayer>,
81}
82
83/// Config fields used when synthesizing an exported Docker image config.
84#[derive(Debug, Clone, Default)]
85pub struct ImageSaveConfig {
86    /// Target architecture.
87    pub architecture: Option<String>,
88    /// Target OS.
89    pub os: Option<String>,
90    /// Environment variables.
91    pub env: Vec<String>,
92    /// Entrypoint.
93    pub entrypoint: Option<Vec<String>>,
94    /// Command.
95    pub cmd: Option<Vec<String>>,
96    /// Working directory.
97    pub working_dir: Option<String>,
98    /// User.
99    pub user: Option<String>,
100    /// Labels.
101    pub labels: BTreeMap<String, String>,
102}
103
104/// Layer data used when exporting an image.
105#[derive(Debug, Clone)]
106pub struct ImageSaveLayer {
107    /// Original cached layer diff ID.
108    pub diff_id: String,
109}
110
111#[derive(Debug)]
112struct PreparedLoadedImage {
113    reference: String,
114    metadata: CachedImageMetadata,
115}
116
117#[derive(Debug)]
118struct PreparedArchiveLoad {
119    images: Vec<PreparedLoadedImage>,
120    staged_layers: HashMap<String, PathBuf>,
121}
122
123#[derive(Debug)]
124struct StagedLayerGuard {
125    paths: HashMap<String, PathBuf>,
126    cleanup_on_drop: bool,
127}
128
129#[derive(Debug)]
130struct LayerBlobInfo {
131    digest: String,
132    media_type: String,
133    size_bytes: u64,
134    path: PathBuf,
135}
136
137#[derive(Debug, Deserialize)]
138struct DockerManifestEntry {
139    #[serde(rename = "Config")]
140    config: String,
141    #[serde(rename = "RepoTags")]
142    repo_tags: Option<Vec<String>>,
143    #[serde(rename = "Layers")]
144    layers: Vec<String>,
145}
146
147#[derive(Debug, Serialize)]
148struct DockerManifestOut {
149    #[serde(rename = "Config")]
150    config: String,
151    #[serde(rename = "RepoTags")]
152    repo_tags: Vec<String>,
153    #[serde(rename = "Layers")]
154    layers: Vec<String>,
155}
156
157#[derive(Debug)]
158struct GeneratedLayer {
159    diff_id: String,
160    hex: String,
161    path: PathBuf,
162    size: u64,
163}
164
165struct DigestingWriter<W> {
166    inner: W,
167    hasher: Sha256,
168    written: u64,
169}
170
171//--------------------------------------------------------------------------------------------------
172// Methods
173//--------------------------------------------------------------------------------------------------
174
175impl<W> DigestingWriter<W> {
176    fn new(inner: W) -> Self {
177        Self {
178            inner,
179            hasher: Sha256::new(),
180            written: 0,
181        }
182    }
183
184    fn finish(self) -> (W, String, u64) {
185        (
186            self.inner,
187            hex::encode(self.hasher.finalize()),
188            self.written,
189        )
190    }
191}
192
193impl StagedLayerGuard {
194    fn new() -> Self {
195        Self {
196            paths: HashMap::new(),
197            cleanup_on_drop: true,
198        }
199    }
200
201    fn track(&mut self, digest: String, path: PathBuf) -> PathBuf {
202        if let Some(existing_path) = self.paths.get(&digest) {
203            let _ = std::fs::remove_file(&path);
204            return existing_path.clone();
205        }
206
207        self.paths.insert(digest, path.clone());
208        path
209    }
210
211    fn into_inner(mut self) -> HashMap<String, PathBuf> {
212        self.cleanup_on_drop = false;
213        std::mem::take(&mut self.paths)
214    }
215}
216
217//--------------------------------------------------------------------------------------------------
218// Trait Implementations
219//--------------------------------------------------------------------------------------------------
220
221impl<W: Write> Write for DigestingWriter<W> {
222    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
223        let written = self.inner.write(buf)?;
224        self.hasher.update(&buf[..written]);
225        self.written += written as u64;
226        Ok(written)
227    }
228
229    fn flush(&mut self) -> io::Result<()> {
230        self.inner.flush()
231    }
232}
233
234impl Drop for StagedLayerGuard {
235    fn drop(&mut self) {
236        if !self.cleanup_on_drop {
237            return;
238        }
239
240        for path in self.paths.values() {
241            let _ = std::fs::remove_file(path);
242        }
243    }
244}
245
246//--------------------------------------------------------------------------------------------------
247// Functions
248//--------------------------------------------------------------------------------------------------
249
250/// Load a Docker image archive into the microsandbox image cache.
251pub async fn load_archive(
252    cache_dir: &Path,
253    input: &Path,
254    options: ImageLoadOptions,
255) -> ImageResult<Vec<LoadedImage>> {
256    let cache_dir_for_blocking = cache_dir.to_path_buf();
257    let input = input.to_path_buf();
258    let prepared = tokio::task::spawn_blocking(move || {
259        load_archive_blocking(&cache_dir_for_blocking, &input, options)
260    })
261    .await
262    .map_err(|e| ImageError::Io(io::Error::other(e)))??;
263
264    let cache = GlobalCache::new_async(cache_dir).await?;
265    let registry = Registry::new(Platform::host_linux(), cache)?;
266    let PreparedArchiveLoad {
267        images,
268        staged_layers,
269    } = prepared;
270    let cleanup_paths = staged_layers.values().cloned().collect::<Vec<_>>();
271    let staged_layers = Arc::new(staged_layers);
272    let cache = GlobalCache::new_async(cache_dir).await?;
273    let mut loaded = Vec::with_capacity(images.len());
274
275    let result = async {
276        for image in images {
277            let reference: Reference = image
278                .reference
279                .parse()
280                .map_err(|e| ImageError::ManifestParse(format!("invalid image reference: {e}")))?;
281
282            registry
283                .materialize_cached_layers_from_paths(
284                    &reference,
285                    &image.metadata,
286                    false,
287                    Arc::clone(&staged_layers),
288                )
289                .await?;
290
291            cache
292                .write_image_metadata_async(&reference, &image.metadata)
293                .await?;
294
295            loaded.push(LoadedImage {
296                reference: image.reference,
297                metadata: image.metadata,
298            });
299        }
300
301        Ok(loaded)
302    }
303    .await;
304
305    for path in cleanup_paths {
306        let _ = tokio::fs::remove_file(path).await;
307    }
308
309    result
310}
311
312/// Save images as a Docker-compatible image archive.
313pub fn save_docker_archive(
314    cache: &GlobalCache,
315    output: &Path,
316    images: &[ImageSaveRequest],
317) -> ImageResult<()> {
318    save_archive(cache, output, images, ImageArchiveFormat::Docker)
319}
320
321/// Save images as a container image archive.
322pub fn save_archive(
323    cache: &GlobalCache,
324    output: &Path,
325    images: &[ImageSaveRequest],
326    format: ImageArchiveFormat,
327) -> ImageResult<()> {
328    match format {
329        ImageArchiveFormat::Docker => save_docker_archive_inner(cache, output, images),
330        ImageArchiveFormat::Oci => save_oci_archive_inner(cache, output, images),
331    }
332}
333
334fn save_docker_archive_inner(
335    cache: &GlobalCache,
336    output: &Path,
337    images: &[ImageSaveRequest],
338) -> ImageResult<()> {
339    if images.is_empty() {
340        return Err(ImageError::ManifestParse(
341            "at least one image reference is required".into(),
342        ));
343    }
344
345    let output_file = File::create(output).map_err(|e| ImageError::Cache {
346        path: output.to_path_buf(),
347        source: e,
348    })?;
349    let mut archive = tar::Builder::new(BufWriter::new(output_file));
350    let mut generated_layers: HashMap<String, GeneratedLayer> = HashMap::new();
351    let mut appended_layers: HashSet<String> = HashSet::new();
352    let mut manifest_entries = Vec::with_capacity(images.len());
353    let mut config_entries = Vec::with_capacity(images.len());
354
355    for image in images {
356        let mut layer_paths = Vec::with_capacity(image.layers.len());
357        let mut regenerated_diff_ids = Vec::with_capacity(image.layers.len());
358
359        for layer in &image.layers {
360            let generated = match generated_layers.get(&layer.diff_id) {
361                Some(generated) => generated,
362                None => {
363                    let generated = generate_layer_tar(cache, layer)?;
364                    generated_layers.insert(layer.diff_id.clone(), generated);
365                    generated_layers.get(&layer.diff_id).unwrap()
366                }
367            };
368
369            regenerated_diff_ids.push(generated.diff_id.clone());
370            layer_paths.push(format!("{}/layer.tar", generated.hex));
371        }
372
373        let config_bytes =
374            docker_config_json(&image.config, &image.raw_config_json, &regenerated_diff_ids)?;
375        let config_hex = sha256_hex(&config_bytes);
376        let config_name = format!("{config_hex}.json");
377
378        config_entries.push((config_name.clone(), config_bytes));
379
380        manifest_entries.push(DockerManifestOut {
381            config: config_name,
382            repo_tags: vec![image.reference.clone()],
383            layers: layer_paths,
384        });
385    }
386
387    let manifest_bytes = serde_json::to_vec_pretty(&manifest_entries)
388        .map_err(|e| ImageError::ConfigParse(format!("serialize docker manifest: {e}")))?;
389    append_bytes(&mut archive, "manifest.json", &manifest_bytes)?;
390
391    for (config_name, config_bytes) in config_entries {
392        append_bytes(&mut archive, &config_name, &config_bytes)?;
393    }
394
395    for image in images {
396        for layer in &image.layers {
397            let generated = generated_layers.get(&layer.diff_id).ok_or_else(|| {
398                ImageError::ManifestParse(format!("missing generated layer {}", layer.diff_id))
399            })?;
400            if appended_layers.insert(generated.hex.clone()) {
401                append_layer_entries(&mut archive, generated)?;
402            }
403        }
404    }
405
406    archive.finish().map_err(ImageError::Io)?;
407
408    for layer in generated_layers.values() {
409        let _ = std::fs::remove_file(&layer.path);
410    }
411
412    Ok(())
413}
414
415fn save_oci_archive_inner(
416    cache: &GlobalCache,
417    output: &Path,
418    images: &[ImageSaveRequest],
419) -> ImageResult<()> {
420    if images.is_empty() {
421        return Err(ImageError::ManifestParse(
422            "at least one image reference is required".into(),
423        ));
424    }
425
426    let output_file = File::create(output).map_err(|e| ImageError::Cache {
427        path: output.to_path_buf(),
428        source: e,
429    })?;
430    let mut archive = tar::Builder::new(BufWriter::new(output_file));
431    let mut generated_layers: HashMap<String, GeneratedLayer> = HashMap::new();
432    let mut appended_metadata_blobs: HashSet<String> = HashSet::new();
433    let mut appended_layer_blobs: HashSet<String> = HashSet::new();
434    let mut layer_blob_order = Vec::new();
435    let mut metadata_blobs = Vec::new();
436    let mut index_manifests = Vec::with_capacity(images.len());
437
438    for image in images {
439        let mut layer_descriptors = Vec::with_capacity(image.layers.len());
440        let mut regenerated_diff_ids = Vec::with_capacity(image.layers.len());
441
442        for layer in &image.layers {
443            let generated = match generated_layers.get(&layer.diff_id) {
444                Some(generated) => generated,
445                None => {
446                    let generated = generate_layer_tar(cache, layer)?;
447                    generated_layers.insert(layer.diff_id.clone(), generated);
448                    generated_layers.get(&layer.diff_id).unwrap()
449                }
450            };
451
452            regenerated_diff_ids.push(generated.diff_id.clone());
453            if appended_layer_blobs.insert(generated.hex.clone()) {
454                layer_blob_order.push(layer.diff_id.clone());
455            }
456            layer_descriptors.push(serde_json::json!({
457                "mediaType": OCI_LAYER_MEDIA_TYPE,
458                "digest": generated.diff_id,
459                "size": generated.size,
460            }));
461        }
462
463        let config_bytes =
464            docker_config_json(&image.config, &image.raw_config_json, &regenerated_diff_ids)?;
465        let config_hex = sha256_hex(&config_bytes);
466        if appended_metadata_blobs.insert(config_hex.clone()) {
467            metadata_blobs.push((config_hex.clone(), config_bytes.clone()));
468        }
469
470        let manifest_bytes = serde_json::to_vec(&serde_json::json!({
471            "schemaVersion": 2,
472            "mediaType": OCI_MANIFEST_MEDIA_TYPE,
473            "config": {
474                "mediaType": OCI_CONFIG_MEDIA_TYPE,
475                "digest": format!("sha256:{config_hex}"),
476                "size": config_bytes.len(),
477            },
478            "layers": layer_descriptors,
479        }))
480        .map_err(|e| ImageError::ManifestParse(format!("serialize OCI manifest: {e}")))?;
481        let manifest_hex = sha256_hex(&manifest_bytes);
482        if appended_metadata_blobs.insert(manifest_hex.clone()) {
483            metadata_blobs.push((manifest_hex.clone(), manifest_bytes.clone()));
484        }
485
486        index_manifests.push(serde_json::json!({
487            "mediaType": OCI_MANIFEST_MEDIA_TYPE,
488            "digest": format!("sha256:{manifest_hex}"),
489            "size": manifest_bytes.len(),
490            "platform": {
491                "architecture": image.config.architecture.as_deref().unwrap_or("amd64"),
492                "os": image.config.os.as_deref().unwrap_or("linux"),
493            },
494            "annotations": {
495                (OCI_REF_NAME_ANNOTATION): image.reference.clone(),
496            },
497        }));
498    }
499
500    let index_bytes = serde_json::to_vec_pretty(&serde_json::json!({
501        "schemaVersion": 2,
502        "mediaType": OCI_INDEX_MEDIA_TYPE,
503        "manifests": index_manifests,
504    }))
505    .map_err(|e| ImageError::ManifestParse(format!("serialize OCI index: {e}")))?;
506
507    append_bytes(
508        &mut archive,
509        "oci-layout",
510        br#"{"imageLayoutVersion":"1.0.0"}"#,
511    )?;
512    append_bytes(&mut archive, "index.json", &index_bytes)?;
513    append_directory(&mut archive, "blobs")?;
514    append_directory(&mut archive, "blobs/sha256")?;
515
516    for (hex, bytes) in metadata_blobs {
517        append_blob_bytes(&mut archive, &hex, &bytes)?;
518    }
519
520    for diff_id in layer_blob_order {
521        let generated = generated_layers.get(&diff_id).ok_or_else(|| {
522            ImageError::ManifestParse(format!("missing generated layer {diff_id}"))
523        })?;
524        append_blob_file(
525            &mut archive,
526            &generated.hex,
527            &generated.path,
528            generated.size,
529        )?;
530    }
531
532    archive.finish().map_err(ImageError::Io)?;
533
534    for layer in generated_layers.values() {
535        let _ = std::fs::remove_file(&layer.path);
536    }
537
538    Ok(())
539}
540
541fn load_archive_blocking(
542    cache_dir: &Path,
543    input: &Path,
544    options: ImageLoadOptions,
545) -> ImageResult<PreparedArchiveLoad> {
546    if let Some(manifest_json) = read_archive_entry(input, "manifest.json")? {
547        let manifest: Vec<DockerManifestEntry> = serde_json::from_slice(&manifest_json)
548            .map_err(|e| ImageError::ManifestParse(format!("docker manifest.json: {e}")))?;
549        return load_docker_archive_blocking(cache_dir, input, options, manifest);
550    }
551
552    if read_archive_entry(input, "oci-layout")?.is_some() {
553        return load_oci_archive_blocking(cache_dir, input, options);
554    }
555
556    Err(ImageError::ManifestParse(
557        "archive missing manifest.json or oci-layout".into(),
558    ))
559}
560
561fn load_docker_archive_blocking(
562    cache_dir: &Path,
563    input: &Path,
564    options: ImageLoadOptions,
565    manifest: Vec<DockerManifestEntry>,
566) -> ImageResult<PreparedArchiveLoad> {
567    let cache = GlobalCache::new(cache_dir)?;
568    if manifest.is_empty() {
569        return Err(ImageError::ManifestParse(
570            "docker archive manifest is empty".into(),
571        ));
572    }
573
574    let required_configs = manifest
575        .iter()
576        .map(|image| image.config.clone())
577        .collect::<HashSet<_>>();
578    let required_layers = manifest
579        .iter()
580        .flat_map(|image| image.layers.iter().cloned())
581        .collect::<HashSet<_>>();
582    let file = File::open(input).map_err(|e| ImageError::Cache {
583        path: input.to_path_buf(),
584        source: e,
585    })?;
586    let mut archive = tar::Archive::new(file);
587    let mut configs: HashMap<String, Vec<u8>> = HashMap::new();
588    let mut layers: HashMap<String, LayerBlobInfo> = HashMap::new();
589    let mut staged_layers = StagedLayerGuard::new();
590    let mut temp_counter = 0u64;
591    let mut entry_count = 0u64;
592
593    for entry in archive.entries().map_err(ImageError::Io)? {
594        let mut entry = entry.map_err(ImageError::Io)?;
595        entry_count += 1;
596        enforce_archive_entry_count(entry_count)?;
597        let path = normalized_archive_path(&entry)?;
598
599        if required_configs.contains(&path) {
600            let data = read_entry_to_vec(&mut entry, &path, ARCHIVE_METADATA_MAX_BYTES)?;
601            configs.insert(path, data);
602            continue;
603        }
604
605        if required_layers.contains(&path) {
606            let mut info = extract_layer_blob(&cache, &path, &mut entry, temp_counter)?;
607            temp_counter += 1;
608            info.path = staged_layers.track(info.digest.clone(), info.path);
609            verify_docker_layer_path_digest(&path, &info.digest)?;
610            layers.insert(path, info);
611            continue;
612        }
613    }
614
615    let mut loaded = Vec::new();
616    for (image_index, image) in manifest.into_iter().enumerate() {
617        let config_bytes = configs.get(&image.config).ok_or_else(|| {
618            ImageError::ConfigParse(format!("docker archive missing config {}", image.config))
619        })?;
620        let (config, diff_ids) = ImageConfig::parse(config_bytes)?;
621
622        if diff_ids.len() != image.layers.len() {
623            return Err(ImageError::ManifestParse(format!(
624                "layer count mismatch: config has {} diff_ids but archive manifest has {} layers",
625                diff_ids.len(),
626                image.layers.len()
627            )));
628        }
629
630        let config_digest = format!("sha256:{}", sha256_hex(config_bytes));
631        let mut layer_metadata = Vec::with_capacity(image.layers.len());
632        let mut manifest_layers = Vec::with_capacity(image.layers.len());
633
634        for (position, layer_path) in image.layers.iter().enumerate() {
635            let layer = layers.get(layer_path).ok_or_else(|| {
636                ImageError::ManifestParse(format!("docker archive missing layer {layer_path}"))
637            })?;
638            let diff_id = diff_ids[position].clone();
639            layer_metadata.push(CachedLayerMetadata {
640                digest: layer.digest.clone(),
641                media_type: Some(layer.media_type.clone()),
642                size_bytes: Some(layer.size_bytes),
643                diff_id,
644            });
645            manifest_layers.push(serde_json::json!({
646                "mediaType": layer.media_type,
647                "digest": layer.digest,
648                "size": layer.size_bytes,
649            }));
650        }
651
652        let manifest_bytes = serde_json::to_vec(&serde_json::json!({
653            "schemaVersion": 2,
654            "mediaType": OCI_MANIFEST_MEDIA_TYPE,
655            "config": {
656                "mediaType": OCI_CONFIG_MEDIA_TYPE,
657                "digest": config_digest,
658                "size": config_bytes.len(),
659            },
660            "layers": manifest_layers,
661        }))
662        .map_err(|e| ImageError::ManifestParse(format!("serialize manifest: {e}")))?;
663        let manifest_digest = format!("sha256:{}", sha256_hex(&manifest_bytes));
664
665        let metadata = CachedImageMetadata {
666            manifest_digest,
667            config_digest,
668            raw_manifest_json: json_bytes_to_string(&manifest_bytes, "docker manifest")?,
669            raw_config_json: json_bytes_to_string(config_bytes, "docker config")?,
670            config,
671            layers: layer_metadata,
672        };
673
674        let mut refs = image
675            .repo_tags
676            .unwrap_or_default()
677            .into_iter()
678            .filter(|tag| tag != "<none>:<none>")
679            .collect::<Vec<_>>();
680
681        if image_index == 0 {
682            refs.extend(options.tags.iter().cloned());
683        }
684
685        refs.sort();
686        refs.dedup();
687
688        if refs.is_empty() {
689            return Err(ImageError::ManifestParse(
690                "docker archive image has no tags; pass --tag to name it".into(),
691            ));
692        }
693
694        for reference in refs {
695            let _: Reference = reference.parse().map_err(|e| {
696                ImageError::ManifestParse(format!("invalid image reference {reference}: {e}"))
697            })?;
698            loaded.push(PreparedLoadedImage {
699                reference,
700                metadata: metadata.clone(),
701            });
702        }
703    }
704
705    Ok(PreparedArchiveLoad {
706        images: loaded,
707        staged_layers: staged_layers.into_inner(),
708    })
709}
710
711fn load_oci_archive_blocking(
712    cache_dir: &Path,
713    input: &Path,
714    options: ImageLoadOptions,
715) -> ImageResult<PreparedArchiveLoad> {
716    let cache = GlobalCache::new(cache_dir)?;
717    let layout_json = read_archive_entry(input, "oci-layout")?
718        .ok_or_else(|| ImageError::ManifestParse("OCI layout missing oci-layout".into()))?;
719    serde_json::from_slice::<oci_spec::image::OciLayout>(&layout_json)
720        .map_err(|e| ImageError::ManifestParse(format!("oci-layout: {e}")))?;
721
722    let index_json = read_archive_entry(input, "index.json")?
723        .ok_or_else(|| ImageError::ManifestParse("OCI layout missing index.json".into()))?;
724    let index: oci_spec::image::ImageIndex = serde_json::from_slice(&index_json)
725        .map_err(|e| ImageError::ManifestParse(format!("OCI index.json: {e}")))?;
726    let manifest_descriptors = selectable_oci_manifests(index.manifests())?;
727    if manifest_descriptors.is_empty() {
728        return Err(ImageError::ManifestParse(
729            "OCI layout contains no image manifests for the host platform".into(),
730        ));
731    }
732
733    let manifest_paths = manifest_descriptors
734        .iter()
735        .map(|descriptor| blob_path_from_digest(descriptor.digest().as_ref()))
736        .collect::<ImageResult<HashSet<_>>>()?;
737    let manifest_blobs = read_archive_entries(input, &manifest_paths)?;
738    let mut manifests = Vec::with_capacity(manifest_descriptors.len());
739    let mut required_configs = HashSet::new();
740    let mut required_layers = HashSet::new();
741
742    for descriptor in &manifest_descriptors {
743        let manifest_path = blob_path_from_digest(descriptor.digest().as_ref())?;
744        let manifest_bytes = manifest_blobs.get(&manifest_path).ok_or_else(|| {
745            ImageError::ManifestParse(format!("OCI layout missing manifest blob {manifest_path}"))
746        })?;
747        verify_descriptor_blob(descriptor, manifest_bytes)?;
748        let manifest: oci_spec::image::ImageManifest = serde_json::from_slice(manifest_bytes)
749            .map_err(|e| ImageError::ManifestParse(format!("OCI image manifest: {e}")))?;
750
751        required_configs.insert(blob_path_from_digest(manifest.config().digest().as_ref())?);
752        for layer in manifest.layers() {
753            required_layers.insert(blob_path_from_digest(layer.digest().as_ref())?);
754        }
755        manifests.push((descriptor.clone(), manifest, manifest_bytes.clone()));
756    }
757
758    let file = File::open(input).map_err(|e| ImageError::Cache {
759        path: input.to_path_buf(),
760        source: e,
761    })?;
762    let mut archive = tar::Archive::new(file);
763    let mut configs: HashMap<String, Vec<u8>> = HashMap::new();
764    let mut layers: HashMap<String, LayerBlobInfo> = HashMap::new();
765    let mut staged_layers = StagedLayerGuard::new();
766    let mut temp_counter = 0u64;
767    let mut entry_count = 0u64;
768
769    for entry in archive.entries().map_err(ImageError::Io)? {
770        let mut entry = entry.map_err(ImageError::Io)?;
771        entry_count += 1;
772        enforce_archive_entry_count(entry_count)?;
773        let path = normalized_archive_path(&entry)?;
774
775        if required_configs.contains(&path) {
776            let data = read_entry_to_vec(&mut entry, &path, ARCHIVE_METADATA_MAX_BYTES)?;
777            configs.insert(path, data);
778            continue;
779        }
780
781        if required_layers.contains(&path) {
782            let mut info = extract_layer_blob(&cache, &path, &mut entry, temp_counter)?;
783            temp_counter += 1;
784            info.path = staged_layers.track(info.digest.clone(), info.path);
785            layers.insert(path, info);
786            continue;
787        }
788    }
789
790    let mut loaded = Vec::new();
791    for (image_index, (descriptor, manifest, manifest_bytes)) in manifests.into_iter().enumerate() {
792        let config_path = blob_path_from_digest(manifest.config().digest().as_ref())?;
793        let config_bytes = configs.get(&config_path).ok_or_else(|| {
794            ImageError::ConfigParse(format!("OCI layout missing config blob {config_path}"))
795        })?;
796        verify_descriptor_blob(manifest.config(), config_bytes)?;
797        let (config, diff_ids) = ImageConfig::parse(config_bytes)?;
798
799        if diff_ids.len() != manifest.layers().len() {
800            return Err(ImageError::ManifestParse(format!(
801                "layer count mismatch: config has {} diff_ids but OCI manifest has {} layers",
802                diff_ids.len(),
803                manifest.layers().len()
804            )));
805        }
806
807        let mut layer_metadata = Vec::with_capacity(manifest.layers().len());
808        for (position, layer_descriptor) in manifest.layers().iter().enumerate() {
809            let layer_path = blob_path_from_digest(layer_descriptor.digest().as_ref())?;
810            let layer = layers.get(&layer_path).ok_or_else(|| {
811                ImageError::ManifestParse(format!("OCI layout missing layer blob {layer_path}"))
812            })?;
813            verify_layer_descriptor(layer_descriptor, layer)?;
814            layer_metadata.push(CachedLayerMetadata {
815                digest: layer.digest.clone(),
816                media_type: Some(layer.media_type.clone()),
817                size_bytes: Some(layer.size_bytes),
818                diff_id: diff_ids[position].clone(),
819            });
820        }
821
822        let metadata = CachedImageMetadata {
823            manifest_digest: format!("sha256:{}", sha256_hex(&manifest_bytes)),
824            config_digest: manifest.config().digest().to_string(),
825            raw_manifest_json: json_bytes_to_string(&manifest_bytes, "OCI manifest")?,
826            raw_config_json: json_bytes_to_string(config_bytes, "OCI config")?,
827            config,
828            layers: layer_metadata,
829        };
830
831        let mut refs = descriptor
832            .annotations()
833            .as_ref()
834            .and_then(|annotations| annotations.get(OCI_REF_NAME_ANNOTATION))
835            .cloned()
836            .into_iter()
837            .collect::<Vec<_>>();
838
839        if image_index == 0 {
840            refs.extend(options.tags.iter().cloned());
841        }
842
843        refs.sort();
844        refs.dedup();
845
846        if refs.is_empty() {
847            return Err(ImageError::ManifestParse(
848                "OCI layout image has no ref.name annotation; pass --tag to name it".into(),
849            ));
850        }
851
852        for reference in refs {
853            let _: Reference = reference.parse().map_err(|e| {
854                ImageError::ManifestParse(format!("invalid image reference {reference}: {e}"))
855            })?;
856            loaded.push(PreparedLoadedImage {
857                reference,
858                metadata: metadata.clone(),
859            });
860        }
861    }
862
863    Ok(PreparedArchiveLoad {
864        images: loaded,
865        staged_layers: staged_layers.into_inner(),
866    })
867}
868
869fn read_archive_entry(input: &Path, wanted_path: &str) -> ImageResult<Option<Vec<u8>>> {
870    let file = File::open(input).map_err(|e| ImageError::Cache {
871        path: input.to_path_buf(),
872        source: e,
873    })?;
874    let mut archive = tar::Archive::new(file);
875    let mut entry_count = 0u64;
876
877    for entry in archive.entries().map_err(ImageError::Io)? {
878        let mut entry = entry.map_err(ImageError::Io)?;
879        entry_count += 1;
880        enforce_archive_entry_count(entry_count)?;
881        let path = normalized_archive_path(&entry)?;
882        if path != wanted_path {
883            continue;
884        }
885
886        let data = read_entry_to_vec(&mut entry, &path, ARCHIVE_METADATA_MAX_BYTES)?;
887        return Ok(Some(data));
888    }
889
890    Ok(None)
891}
892
893fn read_archive_entries(
894    input: &Path,
895    wanted_paths: &HashSet<String>,
896) -> ImageResult<HashMap<String, Vec<u8>>> {
897    let file = File::open(input).map_err(|e| ImageError::Cache {
898        path: input.to_path_buf(),
899        source: e,
900    })?;
901    let mut archive = tar::Archive::new(file);
902    let mut entries = HashMap::new();
903    let mut entry_count = 0u64;
904
905    for entry in archive.entries().map_err(ImageError::Io)? {
906        let mut entry = entry.map_err(ImageError::Io)?;
907        entry_count += 1;
908        enforce_archive_entry_count(entry_count)?;
909        let path = normalized_archive_path(&entry)?;
910        if !wanted_paths.contains(&path) {
911            continue;
912        }
913
914        let data = read_entry_to_vec(&mut entry, &path, ARCHIVE_METADATA_MAX_BYTES)?;
915        entries.insert(path, data);
916        if entries.len() == wanted_paths.len() {
917            break;
918        }
919    }
920
921    Ok(entries)
922}
923
924fn selectable_oci_manifests(
925    descriptors: &[oci_spec::image::Descriptor],
926) -> ImageResult<Vec<oci_spec::image::Descriptor>> {
927    let host = Platform::host_linux();
928    let selected = descriptors
929        .iter()
930        .filter(|descriptor| is_oci_image_manifest_descriptor(descriptor))
931        .filter(|descriptor| descriptor_matches_platform(descriptor, &host))
932        .cloned()
933        .collect();
934
935    Ok(selected)
936}
937
938fn is_oci_image_manifest_descriptor(descriptor: &oci_spec::image::Descriptor) -> bool {
939    matches!(
940        descriptor.media_type(),
941        oci_spec::image::MediaType::ImageManifest
942    ) || descriptor.media_type().to_string()
943        == "application/vnd.docker.distribution.manifest.v2+json"
944}
945
946fn descriptor_matches_platform(descriptor: &oci_spec::image::Descriptor, host: &Platform) -> bool {
947    let Some(platform) = descriptor.platform() else {
948        return true;
949    };
950
951    if *platform.os() != host.os || *platform.architecture() != host.arch {
952        return false;
953    }
954
955    match (&host.variant, platform.variant()) {
956        (Some(host_variant), Some(descriptor_variant)) => host_variant == descriptor_variant,
957        (Some(_), None) => false,
958        (None, _) => true,
959    }
960}
961
962fn blob_path_from_digest(digest: &str) -> ImageResult<String> {
963    let digest: Digest = digest.parse()?;
964    Ok(format!("blobs/{}/{}", digest.algorithm(), digest.hex()))
965}
966
967fn verify_descriptor_blob(
968    descriptor: &oci_spec::image::Descriptor,
969    bytes: &[u8],
970) -> ImageResult<()> {
971    if descriptor.size() != bytes.len() as u64 {
972        return Err(ImageError::ManifestParse(format!(
973            "OCI blob {} size mismatch: descriptor has {}, archive has {}",
974            descriptor.digest(),
975            descriptor.size(),
976            bytes.len()
977        )));
978    }
979
980    verify_digest_bytes(descriptor.digest().as_ref(), bytes)
981}
982
983fn verify_layer_descriptor(
984    descriptor: &oci_spec::image::Descriptor,
985    layer: &LayerBlobInfo,
986) -> ImageResult<()> {
987    if descriptor.size() != layer.size_bytes {
988        return Err(ImageError::ManifestParse(format!(
989            "OCI layer {} size mismatch: descriptor has {}, archive has {}",
990            descriptor.digest(),
991            descriptor.size(),
992            layer.size_bytes
993        )));
994    }
995
996    if descriptor.digest().to_string() != layer.digest {
997        return Err(ImageError::ManifestParse(format!(
998            "OCI layer digest mismatch: descriptor has {}, archive has {}",
999            descriptor.digest(),
1000            layer.digest
1001        )));
1002    }
1003
1004    Ok(())
1005}
1006
1007fn verify_digest_bytes(digest: &str, bytes: &[u8]) -> ImageResult<()> {
1008    let digest: Digest = digest.parse()?;
1009    if digest.algorithm() != "sha256" {
1010        return Err(ImageError::ManifestParse(format!(
1011            "unsupported OCI digest algorithm: {}",
1012            digest.algorithm()
1013        )));
1014    }
1015
1016    let actual = sha256_hex(bytes);
1017    if actual != digest.hex() {
1018        return Err(ImageError::ManifestParse(format!(
1019            "OCI blob digest mismatch: expected {}, got sha256:{actual}",
1020            digest
1021        )));
1022    }
1023
1024    Ok(())
1025}
1026
1027fn verify_docker_layer_path_digest(path: &str, digest: &str) -> ImageResult<()> {
1028    let Some(hex) = path.strip_prefix("blobs/sha256/") else {
1029        return Ok(());
1030    };
1031    if hex.contains('/') {
1032        return Ok(());
1033    }
1034
1035    let expected = format!("sha256:{hex}");
1036    if expected != digest {
1037        return Err(ImageError::ManifestParse(format!(
1038            "docker archive layer path {path} digest mismatch: expected {expected}, got {digest}"
1039        )));
1040    }
1041
1042    Ok(())
1043}
1044
1045fn create_unique_temp_file(dir: &Path, prefix: &str, suffix: &str) -> ImageResult<(File, PathBuf)> {
1046    for _ in 0..128 {
1047        let id = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
1048        let path = dir.join(format!("{prefix}-{}-{id}{suffix}", std::process::id()));
1049        match OpenOptions::new().write(true).create_new(true).open(&path) {
1050            Ok(file) => return Ok((file, path)),
1051            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
1052            Err(e) => {
1053                return Err(ImageError::Cache { path, source: e });
1054            }
1055        }
1056    }
1057
1058    Err(ImageError::Cache {
1059        path: dir.to_path_buf(),
1060        source: io::Error::new(
1061            io::ErrorKind::AlreadyExists,
1062            "could not allocate a unique temporary image archive file",
1063        ),
1064    })
1065}
1066
1067fn extract_layer_blob(
1068    cache: &GlobalCache,
1069    path: &str,
1070    entry: &mut tar::Entry<'_, File>,
1071    counter: u64,
1072) -> ImageResult<LayerBlobInfo> {
1073    let declared_size = entry.header().size().map_err(ImageError::Io)?;
1074    if declared_size > ARCHIVE_LAYER_MAX_BYTES {
1075        return Err(ImageError::ManifestParse(format!(
1076            "archive layer {path} is {declared_size} bytes; max is {ARCHIVE_LAYER_MAX_BYTES}"
1077        )));
1078    }
1079
1080    let (mut temp, temp_path) =
1081        create_unique_temp_file(cache.tmp_dir(), &format!("load-{counter}"), ".blob")?;
1082    let result = (|| {
1083        let mut hasher = Sha256::new();
1084        let mut size = 0u64;
1085        let mut magic = Vec::with_capacity(4);
1086        let mut buf = [0u8; 64 * 1024];
1087
1088        loop {
1089            let read = entry.read(&mut buf).map_err(ImageError::Io)?;
1090            if read == 0 {
1091                break;
1092            }
1093            if magic.len() < 4 {
1094                let take = (4 - magic.len()).min(read);
1095                magic.extend_from_slice(&buf[..take]);
1096            }
1097            hasher.update(&buf[..read]);
1098            temp.write_all(&buf[..read])
1099                .map_err(|e| ImageError::Cache {
1100                    path: temp_path.clone(),
1101                    source: e,
1102                })?;
1103            size += read as u64;
1104            if size > ARCHIVE_LAYER_MAX_BYTES {
1105                return Err(ImageError::ManifestParse(format!(
1106                    "archive layer {path} exceeds {ARCHIVE_LAYER_MAX_BYTES} bytes"
1107                )));
1108            }
1109        }
1110        temp.flush().map_err(|e| ImageError::Cache {
1111            path: temp_path.clone(),
1112            source: e,
1113        })?;
1114        drop(temp);
1115
1116        let digest = Digest::new("sha256", hex::encode(hasher.finalize()));
1117        let staged_path = temp_path.clone();
1118
1119        let media_type = match Compression::detect(&magic) {
1120            Compression::None => OCI_LAYER_MEDIA_TYPE,
1121            Compression::Gzip => OCI_LAYER_GZIP_MEDIA_TYPE,
1122            Compression::Zstd => OCI_LAYER_ZSTD_MEDIA_TYPE,
1123        };
1124
1125        tracing::debug!(path, digest = %digest, size, "loaded layer blob from docker archive");
1126
1127        Ok(LayerBlobInfo {
1128            digest: digest.to_string(),
1129            media_type: media_type.to_string(),
1130            size_bytes: size,
1131            path: staged_path,
1132        })
1133    })();
1134
1135    if result.is_err() {
1136        let _ = std::fs::remove_file(&temp_path);
1137    }
1138
1139    result
1140}
1141
1142fn generate_layer_tar(cache: &GlobalCache, layer: &ImageSaveLayer) -> ImageResult<GeneratedLayer> {
1143    let diff_id: Digest = layer.diff_id.parse()?;
1144    let erofs_path = cache.layer_erofs_path(&diff_id);
1145    let file = File::open(&erofs_path).map_err(|e| ImageError::Cache {
1146        path: erofs_path.clone(),
1147        source: e,
1148    })?;
1149    let mut reader = ErofsReader::new(file).map_err(ImageError::Io)?;
1150    let (temp_file, temp_path) = create_unique_temp_file(cache.tmp_dir(), "save", ".layer.tar")?;
1151    let result = (|| {
1152        let digesting = DigestingWriter::new(BufWriter::new(temp_file));
1153        let mut builder = tar::Builder::new(digesting);
1154        let mut hardlinks: HashMap<u32, PathBuf> = HashMap::new();
1155
1156        reader.walk_entries::<ImageError, _>(|reader, entry| {
1157            if entry.path.as_os_str().is_empty() {
1158                return Ok(());
1159            }
1160
1161            if entry.kind == ErofsEntryKind::CharDevice && entry.rdev == Some((0, 0)) {
1162                append_whiteout(&mut builder, &entry)?;
1163                return Ok(());
1164            }
1165
1166            append_erofs_entry(&mut builder, reader, &entry, &mut hardlinks)?;
1167
1168            if entry.kind == ErofsEntryKind::Directory && entry.is_opaque() {
1169                append_opaque_marker(&mut builder, &entry)?;
1170            }
1171            Ok(())
1172        })?;
1173
1174        let digesting = builder.into_inner().map_err(ImageError::Io)?;
1175        let (mut file, hex, size) = digesting.finish();
1176        file.flush().map_err(|e| ImageError::Cache {
1177            path: temp_path.clone(),
1178            source: e,
1179        })?;
1180
1181        Ok(GeneratedLayer {
1182            diff_id: format!("sha256:{hex}"),
1183            hex,
1184            path: temp_path.clone(),
1185            size,
1186        })
1187    })();
1188
1189    if result.is_err() {
1190        let _ = std::fs::remove_file(&temp_path);
1191    }
1192
1193    result
1194}
1195
1196fn append_erofs_entry<W: Write>(
1197    builder: &mut tar::Builder<DigestingWriter<W>>,
1198    reader: &mut ErofsReader,
1199    entry: &crate::erofs::ErofsTreeEntry,
1200    hardlinks: &mut HashMap<u32, PathBuf>,
1201) -> ImageResult<()> {
1202    let mut header = tar::Header::new_gnu();
1203    apply_header_metadata(&mut header, entry);
1204
1205    match entry.kind {
1206        ErofsEntryKind::RegularFile => {
1207            if let Some(first_path) = hardlinks.get(&entry.nid) {
1208                header.set_entry_type(tar::EntryType::Link);
1209                header.set_size(0);
1210                header.set_link_name(first_path).map_err(ImageError::Io)?;
1211                header.set_cksum();
1212                builder
1213                    .append_data(&mut header, &entry.path, io::empty())
1214                    .map_err(ImageError::Io)?;
1215                return Ok(());
1216            }
1217
1218            hardlinks.insert(entry.nid, entry.path.clone());
1219            header.set_entry_type(tar::EntryType::Regular);
1220            header.set_size(entry.size);
1221            header.set_cksum();
1222            let mut data = reader.file_data_reader(entry.nid).map_err(ImageError::Io)?;
1223            builder
1224                .append_data(&mut header, &entry.path, &mut data)
1225                .map_err(ImageError::Io)?;
1226        }
1227        ErofsEntryKind::Directory => {
1228            header.set_entry_type(tar::EntryType::Directory);
1229            header.set_size(0);
1230            header.set_cksum();
1231            builder
1232                .append_data(&mut header, &entry.path, io::empty())
1233                .map_err(ImageError::Io)?;
1234        }
1235        ErofsEntryKind::Symlink => {
1236            header.set_entry_type(tar::EntryType::Symlink);
1237            header.set_size(0);
1238            let target = reader.read_link_by_nid(entry.nid).map_err(ImageError::Io)?;
1239            header
1240                .set_link_name_literal(target)
1241                .map_err(ImageError::Io)?;
1242            header.set_cksum();
1243            builder
1244                .append_data(&mut header, &entry.path, io::empty())
1245                .map_err(ImageError::Io)?;
1246        }
1247        ErofsEntryKind::CharDevice | ErofsEntryKind::BlockDevice => {
1248            header.set_entry_type(if entry.kind == ErofsEntryKind::CharDevice {
1249                tar::EntryType::Char
1250            } else {
1251                tar::EntryType::Block
1252            });
1253            header.set_size(0);
1254            if let Some((major, minor)) = entry.rdev {
1255                header.set_device_major(major).map_err(ImageError::Io)?;
1256                header.set_device_minor(minor).map_err(ImageError::Io)?;
1257            }
1258            header.set_cksum();
1259            builder
1260                .append_data(&mut header, &entry.path, io::empty())
1261                .map_err(ImageError::Io)?;
1262        }
1263        ErofsEntryKind::Fifo => {
1264            header.set_entry_type(tar::EntryType::Fifo);
1265            header.set_size(0);
1266            header.set_cksum();
1267            builder
1268                .append_data(&mut header, &entry.path, io::empty())
1269                .map_err(ImageError::Io)?;
1270        }
1271        ErofsEntryKind::Socket => {
1272            header.set_entry_type(tar::EntryType::new(0o140));
1273            header.set_size(0);
1274            header.set_cksum();
1275            builder
1276                .append_data(&mut header, &entry.path, io::empty())
1277                .map_err(ImageError::Io)?;
1278        }
1279    }
1280
1281    Ok(())
1282}
1283
1284fn append_whiteout<W: Write>(
1285    builder: &mut tar::Builder<DigestingWriter<W>>,
1286    entry: &crate::erofs::ErofsTreeEntry,
1287) -> ImageResult<()> {
1288    let Some(file_name) = entry.path.file_name() else {
1289        return Ok(());
1290    };
1291    let mut path = entry.path.clone();
1292    let mut whiteout_name = b".wh.".to_vec();
1293    whiteout_name.extend_from_slice(file_name.as_bytes());
1294    path.set_file_name(OsString::from_vec(whiteout_name));
1295    append_empty_file(builder, &path, entry)
1296}
1297
1298fn append_opaque_marker<W: Write>(
1299    builder: &mut tar::Builder<DigestingWriter<W>>,
1300    entry: &crate::erofs::ErofsTreeEntry,
1301) -> ImageResult<()> {
1302    let path = entry.path.join(".wh..wh..opq");
1303    append_empty_file(builder, &path, entry)
1304}
1305
1306fn append_empty_file<W: Write>(
1307    builder: &mut tar::Builder<DigestingWriter<W>>,
1308    path: &Path,
1309    entry: &crate::erofs::ErofsTreeEntry,
1310) -> ImageResult<()> {
1311    let mut header = tar::Header::new_gnu();
1312    apply_header_metadata(&mut header, entry);
1313    header.set_mode(0o000);
1314    header.set_entry_type(tar::EntryType::Regular);
1315    header.set_size(0);
1316    header.set_cksum();
1317    builder
1318        .append_data(&mut header, path, io::empty())
1319        .map_err(ImageError::Io)
1320}
1321
1322fn append_layer_entries<W: Write>(
1323    archive: &mut tar::Builder<W>,
1324    layer: &GeneratedLayer,
1325) -> ImageResult<()> {
1326    append_bytes(archive, &format!("{}/VERSION", layer.hex), b"1.0\n")?;
1327    append_bytes(archive, &format!("{}/json", layer.hex), b"{}")?;
1328
1329    let mut file = File::open(&layer.path).map_err(|e| ImageError::Cache {
1330        path: layer.path.clone(),
1331        source: e,
1332    })?;
1333    let mut header = tar::Header::new_gnu();
1334    header.set_entry_type(tar::EntryType::Regular);
1335    header.set_mode(0o644);
1336    header.set_uid(0);
1337    header.set_gid(0);
1338    header.set_mtime(0);
1339    header.set_size(layer.size);
1340    header.set_cksum();
1341    archive
1342        .append_data(&mut header, format!("{}/layer.tar", layer.hex), &mut file)
1343        .map_err(ImageError::Io)
1344}
1345
1346fn append_blob_file<W: Write>(
1347    archive: &mut tar::Builder<W>,
1348    hex: &str,
1349    path: &Path,
1350    size: u64,
1351) -> ImageResult<()> {
1352    let mut file = File::open(path).map_err(|e| ImageError::Cache {
1353        path: path.to_path_buf(),
1354        source: e,
1355    })?;
1356    let mut header = tar::Header::new_gnu();
1357    header.set_entry_type(tar::EntryType::Regular);
1358    header.set_mode(0o644);
1359    header.set_uid(0);
1360    header.set_gid(0);
1361    header.set_mtime(0);
1362    header.set_size(size);
1363    header.set_cksum();
1364    archive
1365        .append_data(&mut header, format!("blobs/sha256/{hex}"), &mut file)
1366        .map_err(ImageError::Io)
1367}
1368
1369fn append_blob_bytes<W: Write>(
1370    archive: &mut tar::Builder<W>,
1371    hex: &str,
1372    bytes: &[u8],
1373) -> ImageResult<()> {
1374    append_bytes(archive, &format!("blobs/sha256/{hex}"), bytes)
1375}
1376
1377fn append_directory<W: Write>(archive: &mut tar::Builder<W>, path: &str) -> ImageResult<()> {
1378    let mut header = tar::Header::new_gnu();
1379    header.set_entry_type(tar::EntryType::Directory);
1380    header.set_mode(0o755);
1381    header.set_uid(0);
1382    header.set_gid(0);
1383    header.set_mtime(0);
1384    header.set_size(0);
1385    header.set_cksum();
1386    archive
1387        .append_data(&mut header, path, io::empty())
1388        .map_err(ImageError::Io)
1389}
1390
1391fn append_bytes<W: Write>(
1392    archive: &mut tar::Builder<W>,
1393    path: &str,
1394    bytes: &[u8],
1395) -> ImageResult<()> {
1396    let mut header = tar::Header::new_gnu();
1397    header.set_entry_type(tar::EntryType::Regular);
1398    header.set_mode(0o644);
1399    header.set_uid(0);
1400    header.set_gid(0);
1401    header.set_mtime(0);
1402    header.set_size(bytes.len() as u64);
1403    header.set_cksum();
1404    archive
1405        .append_data(&mut header, path, bytes)
1406        .map_err(ImageError::Io)
1407}
1408
1409fn enforce_archive_entry_count(count: u64) -> ImageResult<()> {
1410    if count > ARCHIVE_MAX_ENTRY_COUNT {
1411        return Err(ImageError::ManifestParse(format!(
1412            "archive has more than {ARCHIVE_MAX_ENTRY_COUNT} entries"
1413        )));
1414    }
1415
1416    Ok(())
1417}
1418
1419fn read_entry_to_vec(
1420    entry: &mut tar::Entry<'_, File>,
1421    path: &str,
1422    max_bytes: u64,
1423) -> ImageResult<Vec<u8>> {
1424    let declared_size = entry.header().size().map_err(ImageError::Io)?;
1425    if declared_size > max_bytes {
1426        return Err(ImageError::ManifestParse(format!(
1427            "archive metadata entry {path} is {declared_size} bytes; max is {max_bytes}"
1428        )));
1429    }
1430
1431    let mut data = Vec::with_capacity(declared_size as usize);
1432    entry.read_to_end(&mut data).map_err(ImageError::Io)?;
1433    Ok(data)
1434}
1435
1436fn json_bytes_to_string(bytes: &[u8], context: &str) -> ImageResult<String> {
1437    std::str::from_utf8(bytes)
1438        .map(str::to_owned)
1439        .map_err(|e| ImageError::ConfigParse(format!("{context} is not UTF-8 JSON: {e}")))
1440}
1441
1442fn docker_config_json(
1443    config: &ImageSaveConfig,
1444    raw_config_json: &str,
1445    diff_ids: &[String],
1446) -> ImageResult<Vec<u8>> {
1447    if !raw_config_json.is_empty() {
1448        let mut config_json: serde_json::Value = serde_json::from_str(raw_config_json)
1449            .map_err(|e| ImageError::ConfigParse(format!("parse raw image config: {e}")))?;
1450        let Some(object) = config_json.as_object_mut() else {
1451            return Err(ImageError::ConfigParse(
1452                "raw image config JSON is not an object".into(),
1453            ));
1454        };
1455        object.insert(
1456            "rootfs".into(),
1457            serde_json::json!({
1458                "type": "layers",
1459                "diff_ids": diff_ids,
1460            }),
1461        );
1462        object.entry("architecture").or_insert_with(|| {
1463            serde_json::json!(config.architecture.as_deref().unwrap_or("amd64"))
1464        });
1465        object
1466            .entry("os")
1467            .or_insert_with(|| serde_json::json!(config.os.as_deref().unwrap_or("linux")));
1468        return serde_json::to_vec(&config_json)
1469            .map_err(|e| ImageError::ConfigParse(format!("serialize image config: {e}")));
1470    }
1471
1472    let config_json = serde_json::json!({
1473        "architecture": config.architecture.as_deref().unwrap_or("amd64"),
1474        "os": config.os.as_deref().unwrap_or("linux"),
1475        "config": {
1476            "Env": config.env,
1477            "Entrypoint": config.entrypoint,
1478            "Cmd": config.cmd,
1479            "WorkingDir": config.working_dir,
1480            "User": config.user,
1481            "Labels": if config.labels.is_empty() {
1482                serde_json::Value::Null
1483            } else {
1484                serde_json::to_value(&config.labels)
1485                    .map_err(|e| ImageError::ConfigParse(format!("serialize labels: {e}")))?
1486            },
1487        },
1488        "rootfs": {
1489            "type": "layers",
1490            "diff_ids": diff_ids,
1491        },
1492        "history": diff_ids
1493            .iter()
1494            .map(|_| serde_json::json!({"created_by": "microsandbox image save"}))
1495            .collect::<Vec<_>>(),
1496    });
1497
1498    serde_json::to_vec(&config_json)
1499        .map_err(|e| ImageError::ConfigParse(format!("serialize image config: {e}")))
1500}
1501
1502fn apply_header_metadata(header: &mut tar::Header, entry: &crate::erofs::ErofsTreeEntry) {
1503    header.set_mode((entry.metadata.mode & 0o7777) as u32);
1504    header.set_uid(entry.metadata.uid as u64);
1505    header.set_gid(entry.metadata.gid as u64);
1506    header.set_mtime(entry.metadata.mtime);
1507}
1508
1509fn normalized_archive_path(entry: &tar::Entry<'_, File>) -> ImageResult<String> {
1510    let path = entry.path().map_err(ImageError::Io)?;
1511    let bytes = path.as_os_str().as_bytes();
1512    let normalized = if let Some(stripped) = bytes.strip_prefix(b"./") {
1513        stripped
1514    } else {
1515        bytes
1516    };
1517    String::from_utf8(normalized.to_vec())
1518        .map_err(|_| ImageError::ManifestParse("archive path is not valid UTF-8".into()))
1519}
1520
1521fn sha256_hex(bytes: &[u8]) -> String {
1522    hex::encode(Sha256::digest(bytes))
1523}
1524
1525//--------------------------------------------------------------------------------------------------
1526// Tests
1527//--------------------------------------------------------------------------------------------------
1528
1529#[cfg(test)]
1530mod tests {
1531    use std::collections::BTreeMap;
1532    use std::io::Cursor;
1533
1534    use tempfile::tempdir;
1535
1536    use super::*;
1537
1538    #[test]
1539    fn docker_archive_load_save_load_roundtrip() {
1540        let runtime = tokio::runtime::Builder::new_current_thread()
1541            .enable_all()
1542            .build()
1543            .unwrap();
1544        let temp = tempdir().unwrap();
1545        let input = temp.path().join("image.tar");
1546        write_test_docker_archive(&input, "tiny:latest");
1547
1548        let first_cache = temp.path().join("cache-1");
1549        let loaded = runtime
1550            .block_on(load_archive(
1551                &first_cache,
1552                &input,
1553                ImageLoadOptions::default(),
1554            ))
1555            .unwrap();
1556
1557        assert_eq!(loaded.len(), 1);
1558        assert_eq!(loaded[0].reference, "tiny:latest");
1559
1560        let saved = temp.path().join("saved.tar");
1561        let request = save_request_from_loaded(&loaded[0]);
1562        let cache = GlobalCache::new(&first_cache).unwrap();
1563        save_docker_archive(&cache, &saved, &[request]).unwrap();
1564
1565        let second_cache = temp.path().join("cache-2");
1566        let reloaded = runtime
1567            .block_on(load_archive(
1568                &second_cache,
1569                &saved,
1570                ImageLoadOptions::default(),
1571            ))
1572            .unwrap();
1573
1574        assert_eq!(reloaded.len(), 1);
1575        assert_eq!(reloaded[0].reference, "tiny:latest");
1576        assert_eq!(
1577            reloaded[0].metadata.config.cmd,
1578            Some(vec!["cat".into(), "/hello.txt".into()])
1579        );
1580    }
1581
1582    #[test]
1583    fn docker_archive_loads_manifest_blob_paths() {
1584        let runtime = tokio::runtime::Builder::new_current_thread()
1585            .enable_all()
1586            .build()
1587            .unwrap();
1588        let temp = tempdir().unwrap();
1589        let input = temp.path().join("blob-paths.tar");
1590        write_test_docker_blob_archive_from_layer(&input, "blob-paths:latest", simple_layer_tar());
1591
1592        let loaded = runtime
1593            .block_on(load_archive(
1594                &temp.path().join("cache"),
1595                &input,
1596                ImageLoadOptions::default(),
1597            ))
1598            .unwrap();
1599
1600        assert_eq!(loaded.len(), 1);
1601        assert_eq!(loaded[0].reference, "blob-paths:latest");
1602        assert_eq!(
1603            loaded[0].metadata.config.cmd,
1604            Some(vec!["cat".into(), "/hello.txt".into()])
1605        );
1606    }
1607
1608    #[test]
1609    fn docker_archive_rejects_mismatched_blob_layer_path() {
1610        let runtime = tokio::runtime::Builder::new_current_thread()
1611            .enable_all()
1612            .build()
1613            .unwrap();
1614        let temp = tempdir().unwrap();
1615        let input = temp.path().join("bad-blob-path.tar");
1616        let layer_bytes = simple_layer_tar();
1617        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
1618        let config_bytes = test_config_bytes(&diff_id);
1619        let config_name = format!("blobs/sha256/{}", sha256_hex(&config_bytes));
1620        let layer_name = format!("blobs/sha256/{:064x}", 1u8);
1621
1622        write_test_docker_archive_entries(
1623            &input,
1624            "bad-blob-path:latest",
1625            config_name,
1626            layer_name,
1627            config_bytes,
1628            layer_bytes,
1629        );
1630
1631        let err = runtime
1632            .block_on(load_archive(
1633                &temp.path().join("cache"),
1634                &input,
1635                ImageLoadOptions::default(),
1636            ))
1637            .unwrap_err();
1638
1639        assert!(err.to_string().contains("digest mismatch"));
1640    }
1641
1642    #[test]
1643    fn oci_layout_archive_load_save_load_roundtrip() {
1644        let runtime = tokio::runtime::Builder::new_current_thread()
1645            .enable_all()
1646            .build()
1647            .unwrap();
1648        let temp = tempdir().unwrap();
1649        let input = temp.path().join("oci-layout.tar");
1650        write_test_oci_archive_from_layer(&input, "oci-layout:latest", simple_layer_tar());
1651
1652        let first_cache = temp.path().join("cache-1");
1653        let loaded = runtime
1654            .block_on(load_archive(
1655                &first_cache,
1656                &input,
1657                ImageLoadOptions::default(),
1658            ))
1659            .unwrap();
1660
1661        assert_eq!(loaded.len(), 1);
1662        assert_eq!(loaded[0].reference, "oci-layout:latest");
1663
1664        let saved = temp.path().join("saved-oci-layout.tar");
1665        let request = save_request_from_loaded(&loaded[0]);
1666        let cache = GlobalCache::new(&first_cache).unwrap();
1667        save_archive(&cache, &saved, &[request], ImageArchiveFormat::Oci).unwrap();
1668
1669        let index_bytes = read_archive_entry(&saved, "index.json").unwrap().unwrap();
1670        let index: oci_spec::image::ImageIndex = serde_json::from_slice(&index_bytes).unwrap();
1671        assert_eq!(index.manifests().len(), 1);
1672        assert_eq!(
1673            index.manifests()[0]
1674                .annotations()
1675                .as_ref()
1676                .unwrap()
1677                .get(OCI_REF_NAME_ANNOTATION),
1678            Some(&"oci-layout:latest".to_string())
1679        );
1680
1681        let second_cache = temp.path().join("cache-2");
1682        let reloaded = runtime
1683            .block_on(load_archive(
1684                &second_cache,
1685                &saved,
1686                ImageLoadOptions::default(),
1687            ))
1688            .unwrap();
1689
1690        assert_eq!(reloaded.len(), 1);
1691        assert_eq!(reloaded[0].reference, "oci-layout:latest");
1692    }
1693
1694    #[test]
1695    fn docker_archive_save_preserves_layer_semantics() {
1696        let runtime = tokio::runtime::Builder::new_current_thread()
1697            .enable_all()
1698            .build()
1699            .unwrap();
1700        let temp = tempdir().unwrap();
1701        let input = temp.path().join("complex.tar");
1702        let layer_bytes = complex_layer_tar();
1703        write_test_docker_archive_from_layer(&input, "complex:latest", layer_bytes);
1704
1705        let first_cache = temp.path().join("cache-1");
1706        let loaded = runtime
1707            .block_on(load_archive(
1708                &first_cache,
1709                &input,
1710                ImageLoadOptions::default(),
1711            ))
1712            .unwrap();
1713
1714        let saved = temp.path().join("saved-complex.tar");
1715        let request = save_request_from_loaded(&loaded[0]);
1716        let cache = GlobalCache::new(&first_cache).unwrap();
1717        save_docker_archive(&cache, &saved, &[request]).unwrap();
1718
1719        let entries = saved_layer_entries(&saved);
1720        let config_entry = entries.get("etc/config.txt").unwrap();
1721        let config_link_entry = entries.get("etc/config.link").unwrap();
1722        let regular_config_paths = [
1723            ("etc/config.txt", config_entry),
1724            ("etc/config.link", config_link_entry),
1725        ]
1726        .into_iter()
1727        .filter(|(_, entry)| entry.entry_type == tar::EntryType::Regular)
1728        .collect::<Vec<_>>();
1729        let hardlink_config_paths = [
1730            ("etc/config.txt", config_entry),
1731            ("etc/config.link", config_link_entry),
1732        ]
1733        .into_iter()
1734        .filter(|(_, entry)| entry.entry_type == tar::EntryType::Link)
1735        .collect::<Vec<_>>();
1736
1737        assert_eq!(regular_config_paths.len(), 1);
1738        assert_eq!(hardlink_config_paths.len(), 1);
1739        assert_eq!(regular_config_paths[0].1.data, b"shared config\n");
1740        assert_eq!(
1741            hardlink_config_paths[0].1.link_name.as_deref(),
1742            Some(regular_config_paths[0].0)
1743        );
1744        assert_eq!(regular_config_paths[0].1.mode, 0o640);
1745        assert_eq!(regular_config_paths[0].1.uid, 1000);
1746        assert_eq!(regular_config_paths[0].1.gid, 1001);
1747        assert_eq!(regular_config_paths[0].1.mtime, 42);
1748
1749        let symlink_entry = entries.get("bin/config").unwrap();
1750        assert_eq!(symlink_entry.entry_type, tar::EntryType::Symlink);
1751        assert_eq!(
1752            symlink_entry.link_name.as_deref(),
1753            Some("../etc/config.txt")
1754        );
1755
1756        let whiteout_entry = entries.get("var/.wh.deleted").unwrap();
1757        assert_eq!(whiteout_entry.entry_type, tar::EntryType::Regular);
1758        assert!(whiteout_entry.data.is_empty());
1759
1760        let opaque_entry = entries.get("cache/.wh..wh..opq").unwrap();
1761        assert_eq!(opaque_entry.entry_type, tar::EntryType::Regular);
1762        assert!(opaque_entry.data.is_empty());
1763
1764        let second_cache = temp.path().join("cache-2");
1765        let reloaded = runtime
1766            .block_on(load_archive(
1767                &second_cache,
1768                &saved,
1769                ImageLoadOptions::default(),
1770            ))
1771            .unwrap();
1772
1773        assert_eq!(reloaded.len(), 1);
1774        assert_eq!(reloaded[0].reference, "complex:latest");
1775    }
1776
1777    #[test]
1778    fn docker_archive_save_preserves_raw_config_fields() {
1779        let runtime = tokio::runtime::Builder::new_current_thread()
1780            .enable_all()
1781            .build()
1782            .unwrap();
1783        let temp = tempdir().unwrap();
1784        let input = temp.path().join("config-fidelity.tar");
1785        let layer_bytes = simple_layer_tar();
1786        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
1787        let config_bytes = serde_json::to_vec(&serde_json::json!({
1788            "architecture": "arm64",
1789            "os": "linux",
1790            "author": "microsandbox-test",
1791            "config": {
1792                "Env": ["PATH=/usr/bin"],
1793                "Cmd": ["cat", "/hello.txt"],
1794            },
1795            "rootfs": {
1796                "type": "layers",
1797                "diff_ids": [diff_id],
1798            },
1799            "history": [{
1800                "created_by": "fixture",
1801                "comment": "keep me",
1802            }],
1803        }))
1804        .unwrap();
1805        let config_name = format!("{}.json", sha256_hex(&config_bytes));
1806
1807        write_test_docker_archive_entries(
1808            &input,
1809            "config-fidelity:latest",
1810            config_name,
1811            "layer/layer.tar".into(),
1812            config_bytes,
1813            layer_bytes,
1814        );
1815
1816        let first_cache = temp.path().join("cache-1");
1817        let loaded = runtime
1818            .block_on(load_archive(
1819                &first_cache,
1820                &input,
1821                ImageLoadOptions::default(),
1822            ))
1823            .unwrap();
1824        let saved = temp.path().join("saved-config-fidelity.tar");
1825        let request = save_request_from_loaded(&loaded[0]);
1826        let cache = GlobalCache::new(&first_cache).unwrap();
1827        save_docker_archive(&cache, &saved, &[request]).unwrap();
1828
1829        let manifest_bytes = read_archive_entry(&saved, "manifest.json")
1830            .unwrap()
1831            .unwrap();
1832        let manifest: Vec<DockerManifestEntry> = serde_json::from_slice(&manifest_bytes).unwrap();
1833        let saved_config = read_archive_entry(&saved, &manifest[0].config)
1834            .unwrap()
1835            .unwrap();
1836        let saved_config: serde_json::Value = serde_json::from_slice(&saved_config).unwrap();
1837
1838        assert_eq!(saved_config["author"], "microsandbox-test");
1839        assert_eq!(saved_config["history"][0]["comment"], "keep me");
1840    }
1841
1842    fn write_test_docker_archive(path: &Path, reference: &str) {
1843        write_test_docker_archive_from_layer(path, reference, simple_layer_tar());
1844    }
1845
1846    fn write_test_docker_archive_from_layer(path: &Path, reference: &str, layer_bytes: Vec<u8>) {
1847        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
1848        let config_bytes = test_config_bytes(&diff_id);
1849        let config_name = format!("{}.json", sha256_hex(&config_bytes));
1850
1851        write_test_docker_archive_entries(
1852            path,
1853            reference,
1854            config_name,
1855            "layer/layer.tar".into(),
1856            config_bytes,
1857            layer_bytes,
1858        );
1859    }
1860
1861    fn write_test_docker_blob_archive_from_layer(
1862        path: &Path,
1863        reference: &str,
1864        layer_bytes: Vec<u8>,
1865    ) {
1866        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
1867        let config_bytes = test_config_bytes(&diff_id);
1868        let config_name = format!("blobs/sha256/{}", sha256_hex(&config_bytes));
1869        let layer_name = format!("blobs/sha256/{}", sha256_hex(&layer_bytes));
1870
1871        write_test_docker_archive_entries(
1872            path,
1873            reference,
1874            config_name,
1875            layer_name,
1876            config_bytes,
1877            layer_bytes,
1878        );
1879    }
1880
1881    fn write_test_oci_archive_from_layer(path: &Path, reference: &str, layer_bytes: Vec<u8>) {
1882        let diff_id = format!("sha256:{}", sha256_hex(&layer_bytes));
1883        let config_bytes = test_config_bytes(&diff_id);
1884        let config_hex = sha256_hex(&config_bytes);
1885        let layer_hex = sha256_hex(&layer_bytes);
1886        let manifest_bytes = serde_json::to_vec(&serde_json::json!({
1887            "schemaVersion": 2,
1888            "mediaType": OCI_MANIFEST_MEDIA_TYPE,
1889            "config": {
1890                "mediaType": OCI_CONFIG_MEDIA_TYPE,
1891                "digest": format!("sha256:{config_hex}"),
1892                "size": config_bytes.len(),
1893            },
1894            "layers": [{
1895                "mediaType": OCI_LAYER_MEDIA_TYPE,
1896                "digest": format!("sha256:{layer_hex}"),
1897                "size": layer_bytes.len(),
1898            }],
1899        }))
1900        .unwrap();
1901        let manifest_hex = sha256_hex(&manifest_bytes);
1902        let host = Platform::host_linux();
1903        let index_bytes = serde_json::to_vec(&serde_json::json!({
1904            "schemaVersion": 2,
1905            "mediaType": OCI_INDEX_MEDIA_TYPE,
1906            "manifests": [{
1907                "mediaType": OCI_MANIFEST_MEDIA_TYPE,
1908                "digest": format!("sha256:{manifest_hex}"),
1909                "size": manifest_bytes.len(),
1910                "platform": {
1911                    "architecture": host.arch.to_string(),
1912                    "os": host.os.to_string(),
1913                },
1914                "annotations": {
1915                    (OCI_REF_NAME_ANNOTATION): reference,
1916                },
1917            }],
1918        }))
1919        .unwrap();
1920
1921        let file = File::create(path).unwrap();
1922        let mut archive = tar::Builder::new(file);
1923        append_bytes(
1924            &mut archive,
1925            "oci-layout",
1926            br#"{"imageLayoutVersion":"1.0.0"}"#,
1927        )
1928        .unwrap();
1929        append_bytes(&mut archive, "index.json", &index_bytes).unwrap();
1930        append_bytes(
1931            &mut archive,
1932            &format!("blobs/sha256/{config_hex}"),
1933            &config_bytes,
1934        )
1935        .unwrap();
1936        append_bytes(
1937            &mut archive,
1938            &format!("blobs/sha256/{manifest_hex}"),
1939            &manifest_bytes,
1940        )
1941        .unwrap();
1942        append_bytes(
1943            &mut archive,
1944            &format!("blobs/sha256/{layer_hex}"),
1945            &layer_bytes,
1946        )
1947        .unwrap();
1948        archive.finish().unwrap();
1949    }
1950
1951    fn simple_layer_tar() -> Vec<u8> {
1952        let mut layer_bytes = Vec::new();
1953        {
1954            let mut layer = tar::Builder::new(&mut layer_bytes);
1955            let data = b"hello from archive\n";
1956            let mut header = tar::Header::new_gnu();
1957            header.set_entry_type(tar::EntryType::Regular);
1958            header.set_mode(0o644);
1959            header.set_uid(0);
1960            header.set_gid(0);
1961            header.set_mtime(0);
1962            header.set_size(data.len() as u64);
1963            header.set_cksum();
1964            layer
1965                .append_data(&mut header, "hello.txt", Cursor::new(data))
1966                .unwrap();
1967            layer.finish().unwrap();
1968        }
1969
1970        layer_bytes
1971    }
1972
1973    fn test_config_bytes(diff_id: &str) -> Vec<u8> {
1974        serde_json::to_vec(&serde_json::json!({
1975            "architecture": "arm64",
1976            "os": "linux",
1977            "config": {
1978                "Env": ["PATH=/usr/bin"],
1979                "Cmd": ["cat", "/hello.txt"],
1980            },
1981            "rootfs": {
1982                "type": "layers",
1983                "diff_ids": [diff_id],
1984            },
1985        }))
1986        .unwrap()
1987    }
1988
1989    fn write_test_docker_archive_entries(
1990        path: &Path,
1991        reference: &str,
1992        config_name: String,
1993        layer_name: String,
1994        config_bytes: Vec<u8>,
1995        layer_bytes: Vec<u8>,
1996    ) {
1997        let manifest_bytes = serde_json::to_vec(&vec![DockerManifestOut {
1998            config: config_name.clone(),
1999            repo_tags: vec![reference.into()],
2000            layers: vec![layer_name.clone()],
2001        }])
2002        .unwrap();
2003
2004        let file = File::create(path).unwrap();
2005        let mut archive = tar::Builder::new(file);
2006        append_bytes(&mut archive, &config_name, &config_bytes).unwrap();
2007        append_bytes(&mut archive, "manifest.json", &manifest_bytes).unwrap();
2008
2009        let mut header = tar::Header::new_gnu();
2010        header.set_entry_type(tar::EntryType::Regular);
2011        header.set_mode(0o644);
2012        header.set_uid(0);
2013        header.set_gid(0);
2014        header.set_mtime(0);
2015        header.set_size(layer_bytes.len() as u64);
2016        header.set_cksum();
2017        archive
2018            .append_data(&mut header, layer_name, Cursor::new(layer_bytes))
2019            .unwrap();
2020        archive.finish().unwrap();
2021    }
2022
2023    fn complex_layer_tar() -> Vec<u8> {
2024        let mut layer_bytes = Vec::new();
2025        {
2026            let mut layer = tar::Builder::new(&mut layer_bytes);
2027            append_test_dir(&mut layer, "bin", 0o755, 0, 0, 1);
2028            append_test_dir(&mut layer, "cache", 0o755, 0, 0, 1);
2029            append_test_dir(&mut layer, "etc", 0o755, 0, 0, 1);
2030            append_test_dir(&mut layer, "var", 0o755, 0, 0, 1);
2031            append_test_file(
2032                &mut layer,
2033                "etc/config.txt",
2034                b"shared config\n",
2035                0o640,
2036                1000,
2037                1001,
2038                42,
2039            );
2040            append_test_hardlink(&mut layer, "etc/config.link", "etc/config.txt");
2041            append_test_symlink(&mut layer, "bin/config", "../etc/config.txt");
2042            append_test_file(&mut layer, "var/.wh.deleted", b"", 0o000, 0, 0, 1);
2043            append_test_file(&mut layer, "cache/.wh..wh..opq", b"", 0o000, 0, 0, 1);
2044            layer.finish().unwrap();
2045        }
2046        layer_bytes
2047    }
2048
2049    fn append_test_dir(
2050        layer: &mut tar::Builder<&mut Vec<u8>>,
2051        path: &str,
2052        mode: u32,
2053        uid: u64,
2054        gid: u64,
2055        mtime: u64,
2056    ) {
2057        let mut header = tar::Header::new_gnu();
2058        header.set_entry_type(tar::EntryType::Directory);
2059        header.set_mode(mode);
2060        header.set_uid(uid);
2061        header.set_gid(gid);
2062        header.set_mtime(mtime);
2063        header.set_size(0);
2064        header.set_cksum();
2065        layer.append_data(&mut header, path, io::empty()).unwrap();
2066    }
2067
2068    fn append_test_file(
2069        layer: &mut tar::Builder<&mut Vec<u8>>,
2070        path: &str,
2071        data: &[u8],
2072        mode: u32,
2073        uid: u64,
2074        gid: u64,
2075        mtime: u64,
2076    ) {
2077        let mut header = tar::Header::new_gnu();
2078        header.set_entry_type(tar::EntryType::Regular);
2079        header.set_mode(mode);
2080        header.set_uid(uid);
2081        header.set_gid(gid);
2082        header.set_mtime(mtime);
2083        header.set_size(data.len() as u64);
2084        header.set_cksum();
2085        layer
2086            .append_data(&mut header, path, Cursor::new(data))
2087            .unwrap();
2088    }
2089
2090    fn append_test_hardlink(layer: &mut tar::Builder<&mut Vec<u8>>, path: &str, target: &str) {
2091        let mut header = tar::Header::new_gnu();
2092        header.set_entry_type(tar::EntryType::Link);
2093        header.set_link_name(target).unwrap();
2094        header.set_size(0);
2095        header.set_cksum();
2096        layer.append_data(&mut header, path, io::empty()).unwrap();
2097    }
2098
2099    fn append_test_symlink(layer: &mut tar::Builder<&mut Vec<u8>>, path: &str, target: &str) {
2100        let mut header = tar::Header::new_gnu();
2101        header.set_entry_type(tar::EntryType::Symlink);
2102        header.set_link_name(target).unwrap();
2103        header.set_mode(0o777);
2104        header.set_size(0);
2105        header.set_cksum();
2106        layer.append_data(&mut header, path, io::empty()).unwrap();
2107    }
2108
2109    #[derive(Debug)]
2110    struct SavedLayerEntry {
2111        entry_type: tar::EntryType,
2112        link_name: Option<String>,
2113        mode: u32,
2114        uid: u64,
2115        gid: u64,
2116        mtime: u64,
2117        data: Vec<u8>,
2118    }
2119
2120    fn saved_layer_entries(path: &Path) -> BTreeMap<String, SavedLayerEntry> {
2121        let file = File::open(path).unwrap();
2122        let mut archive = tar::Archive::new(file);
2123        let mut layer_bytes = None;
2124
2125        for entry in archive.entries().unwrap() {
2126            let mut entry = entry.unwrap();
2127            let entry_path = entry.path().unwrap().to_string_lossy().into_owned();
2128            if entry_path.ends_with("/layer.tar") {
2129                assert!(layer_bytes.is_none());
2130                let mut data = Vec::new();
2131                entry.read_to_end(&mut data).unwrap();
2132                layer_bytes = Some(data);
2133            }
2134        }
2135
2136        let layer_bytes = layer_bytes.unwrap();
2137        let mut layer = tar::Archive::new(Cursor::new(layer_bytes));
2138        let mut entries = BTreeMap::new();
2139
2140        for entry in layer.entries().unwrap() {
2141            let mut entry = entry.unwrap();
2142            let path = entry.path().unwrap().to_string_lossy().into_owned();
2143            let header = entry.header();
2144            let entry_type = header.entry_type();
2145            let mode = header.mode().unwrap();
2146            let uid = header.uid().unwrap();
2147            let gid = header.gid().unwrap();
2148            let mtime = header.mtime().unwrap();
2149            let link_name = if matches!(entry_type, tar::EntryType::Link | tar::EntryType::Symlink)
2150            {
2151                Some(String::from_utf8_lossy(entry.link_name_bytes().unwrap().as_ref()).into())
2152            } else {
2153                None
2154            };
2155            let mut data = Vec::new();
2156            entry.read_to_end(&mut data).unwrap();
2157
2158            entries.insert(
2159                path,
2160                SavedLayerEntry {
2161                    entry_type,
2162                    link_name,
2163                    mode,
2164                    uid,
2165                    gid,
2166                    mtime,
2167                    data,
2168                },
2169            );
2170        }
2171
2172        entries
2173    }
2174
2175    fn save_request_from_loaded(image: &LoadedImage) -> ImageSaveRequest {
2176        let host = Platform::host_linux();
2177        ImageSaveRequest {
2178            reference: image.reference.clone(),
2179            config: ImageSaveConfig {
2180                architecture: Some(host.arch.to_string()),
2181                os: Some(host.os.to_string()),
2182                env: image.metadata.config.env.clone(),
2183                entrypoint: image.metadata.config.entrypoint.clone(),
2184                cmd: image.metadata.config.cmd.clone(),
2185                working_dir: image.metadata.config.working_dir.clone(),
2186                user: image.metadata.config.user.clone(),
2187                labels: image
2188                    .metadata
2189                    .config
2190                    .labels
2191                    .iter()
2192                    .map(|(key, value)| (key.clone(), value.clone()))
2193                    .collect(),
2194            },
2195            raw_config_json: image.metadata.raw_config_json.clone(),
2196            layers: image
2197                .metadata
2198                .layers
2199                .iter()
2200                .map(|layer| ImageSaveLayer {
2201                    diff_id: layer.diff_id.clone(),
2202                })
2203                .collect(),
2204        }
2205    }
2206}