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