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