Skip to main content

a3s_box_runtime/oci/
image.rs

1//! OCI image parsing and representation.
2//!
3//! Handles parsing of OCI image layout including manifest and configuration.
4
5use a3s_box_core::error::{BoxError, Result};
6use a3s_box_core::platform::Platform as BoxPlatform;
7use oci_spec::image::{Descriptor, ImageConfiguration, ImageIndex, ImageManifest};
8use sha2::{Digest, Sha256};
9use std::fs::File;
10use std::io::Read;
11use std::path::{Path, PathBuf};
12
13pub(crate) const MAX_OCI_LAYOUT_BYTES: u64 = 64 * 1024;
14pub(crate) const MAX_OCI_INDEX_BYTES: u64 = 4 * 1024 * 1024;
15pub(crate) const MAX_OCI_MANIFEST_BYTES: u64 = 16 * 1024 * 1024;
16pub(crate) const MAX_OCI_CONFIG_BYTES: u64 = 64 * 1024 * 1024;
17pub(crate) const MAX_OCI_LAYER_BLOB_BYTES: u64 = 16 * 1024 * 1024 * 1024;
18
19/// Validate the only digest form accepted for local OCI blob paths.
20///
21/// Requiring lowercase canonical SHA-256 makes the returned value safe as one
22/// path component and rejects alternate algorithms, separators, and `..`.
23pub(crate) fn canonical_sha256_digest_hex(digest: &str) -> Result<&str> {
24    digest
25        .strip_prefix("sha256:")
26        .filter(|hex| {
27            hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
28        })
29        .ok_or_else(|| {
30            BoxError::OciImageError(format!(
31                "malformed content digest (expected canonical OCI sha256:<64 lowercase hex>): {digest:?}"
32            ))
33        })
34}
35
36/// Reject symlink/reparse-backed directories before walking an OCI layout.
37pub(crate) fn validate_plain_directory(path: &Path, what: &str) -> Result<()> {
38    let metadata = std::fs::symlink_metadata(path).map_err(|error| {
39        BoxError::OciImageError(format!(
40            "Failed to inspect {what} directory {}: {error}",
41            path.display()
42        ))
43    })?;
44
45    #[cfg(windows)]
46    let is_link_or_reparse = {
47        use std::os::windows::fs::MetadataExt;
48        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
49        metadata.file_type().is_symlink()
50            || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
51    };
52    #[cfg(not(windows))]
53    let is_link_or_reparse = metadata.file_type().is_symlink();
54
55    if is_link_or_reparse || !metadata.is_dir() {
56        return Err(BoxError::OciImageError(format!(
57            "refusing {what} directory {} because it is not a plain directory (symlink/reparse or non-directory)",
58            path.display()
59        )));
60    }
61
62    Ok(())
63}
64
65pub(crate) fn open_regular_file_no_follow(path: &Path, what: &str) -> Result<File> {
66    #[cfg(windows)]
67    let opened = a3s_box_core::windows_file::open_regular_file(path, None).map(|(file, _)| file);
68
69    #[cfg(unix)]
70    let opened = {
71        use std::os::unix::fs::OpenOptionsExt;
72
73        std::fs::OpenOptions::new()
74            .read(true)
75            .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
76            .open(path)
77    };
78
79    #[cfg(not(any(windows, unix)))]
80    let opened = std::fs::File::open(path);
81
82    let file = opened.map_err(|error| {
83        BoxError::OciImageError(format!(
84            "Failed to open {what} at {} without following links: {error}",
85            path.display()
86        ))
87    })?;
88    let metadata = file.metadata().map_err(|error| {
89        BoxError::OciImageError(format!(
90            "Failed to inspect opened {what} at {}: {error}",
91            path.display()
92        ))
93    })?;
94    if !metadata.is_file() {
95        return Err(BoxError::OciImageError(format!(
96            "refusing {what} at {} because it is not a regular file",
97            path.display()
98        )));
99    }
100    Ok(file)
101}
102
103fn checked_opened_length(file: &File, path: &Path, what: &str, limit: u64) -> Result<u64> {
104    let length = file
105        .metadata()
106        .map_err(|error| {
107            BoxError::OciImageError(format!(
108                "Failed to inspect {what} at {}: {error}",
109                path.display()
110            ))
111        })?
112        .len();
113    if length > limit {
114        return Err(BoxError::OciImageError(format!(
115            "refusing {what} at {}: {length} bytes exceeds the {limit}-byte limit",
116            path.display()
117        )));
118    }
119    Ok(length)
120}
121
122/// Read a regular file through a no-follow handle with a hard byte ceiling.
123pub(crate) fn read_regular_file_bounded(path: &Path, limit: u64, what: &str) -> Result<Vec<u8>> {
124    let file = open_regular_file_no_follow(path, what)?;
125    let length = checked_opened_length(&file, path, what, limit)?;
126    let capacity = usize::try_from(length).map_err(|_| {
127        BoxError::OciImageError(format!(
128            "refusing {what} at {}: file length does not fit in memory",
129            path.display()
130        ))
131    })?;
132    let mut bytes = Vec::new();
133    bytes.try_reserve_exact(capacity).map_err(|error| {
134        BoxError::OciImageError(format!(
135            "Failed to reserve memory for {what} at {}: {error}",
136            path.display()
137        ))
138    })?;
139    file.take(limit.saturating_add(1))
140        .read_to_end(&mut bytes)
141        .map_err(|error| {
142            BoxError::OciImageError(format!(
143                "Failed to read {what} at {}: {error}",
144                path.display()
145            ))
146        })?;
147    if bytes.len() as u64 > limit {
148        return Err(BoxError::OciImageError(format!(
149            "refusing {what} at {}: content grew beyond the {limit}-byte limit while reading",
150            path.display()
151        )));
152    }
153    Ok(bytes)
154}
155
156fn expected_descriptor_size(size: i64, what: &str) -> Result<u64> {
157    u64::try_from(size).map_err(|_| {
158        BoxError::OciImageError(format!(
159            "refusing {what}: descriptor declares a negative size ({size})"
160        ))
161    })
162}
163
164fn typed_descriptor_contract(descriptor: &Descriptor, what: &str) -> Result<(String, i64)> {
165    let digest = descriptor.digest().to_string();
166    let size = i64::try_from(descriptor.size()).map_err(|_| {
167        BoxError::OciImageError(format!(
168            "refusing {what} {digest}: descriptor size {} exceeds the supported range",
169            descriptor.size()
170        ))
171    })?;
172    Ok((digest, size))
173}
174
175/// Verify descriptor size and SHA-256 against the exact bytes to be consumed.
176pub(crate) fn validate_descriptor_bytes(
177    digest: &str,
178    size: i64,
179    bytes: &[u8],
180    what: &str,
181) -> Result<()> {
182    let expected_hex = canonical_sha256_digest_hex(digest)?;
183    let expected_size = expected_descriptor_size(size, what)?;
184    let actual_size = bytes.len() as u64;
185    if actual_size != expected_size {
186        return Err(BoxError::OciImageError(format!(
187            "refusing {what} {digest}: descriptor size {expected_size} does not match actual size {actual_size}"
188        )));
189    }
190    let actual_hex = format!("{:x}", Sha256::digest(bytes));
191    if actual_hex != expected_hex {
192        return Err(BoxError::OciImageError(format!(
193            "refusing {what} {digest}: descriptor digest does not match actual bytes (sha256:{actual_hex})"
194        )));
195    }
196    Ok(())
197}
198
199fn blob_path(root_dir: &Path, digest: &str) -> Result<PathBuf> {
200    let hex = canonical_sha256_digest_hex(digest)?;
201    Ok(root_dir.join("blobs").join("sha256").join(hex))
202}
203
204pub(crate) fn read_verified_oci_blob(
205    root_dir: &Path,
206    digest: &str,
207    size: i64,
208    limit: u64,
209    what: &str,
210) -> Result<Vec<u8>> {
211    canonical_sha256_digest_hex(digest)?;
212    let expected_size = expected_descriptor_size(size, what)?;
213    if expected_size > limit {
214        return Err(BoxError::OciImageError(format!(
215            "refusing {what} {digest}: descriptor size {expected_size} exceeds the {limit}-byte limit"
216        )));
217    }
218    let path = blob_path(root_dir, digest)?;
219    let bytes = read_regular_file_bounded(&path, limit, what).map_err(|error| {
220        BoxError::OciImageError(format!("Failed to read {what} {digest}: {error}"))
221    })?;
222    validate_descriptor_bytes(digest, size, &bytes, what)?;
223    Ok(bytes)
224}
225
226pub(crate) fn verify_oci_blob_file(
227    root_dir: &Path,
228    digest: &str,
229    size: i64,
230    limit: u64,
231    what: &str,
232) -> Result<PathBuf> {
233    let expected_hex = canonical_sha256_digest_hex(digest)?;
234    let expected_size = expected_descriptor_size(size, what)?;
235    if expected_size > limit {
236        return Err(BoxError::OciImageError(format!(
237            "refusing {what} {digest}: descriptor size {expected_size} exceeds the {limit}-byte limit"
238        )));
239    }
240
241    let path = blob_path(root_dir, digest)?;
242    let mut file = open_regular_file_no_follow(&path, what).map_err(|error| {
243        BoxError::OciImageError(format!("Failed to open {what} {digest}: {error}"))
244    })?;
245    let opened_size = checked_opened_length(&file, &path, what, limit)?;
246    if opened_size != expected_size {
247        return Err(BoxError::OciImageError(format!(
248            "refusing {what} {digest}: descriptor size {expected_size} does not match actual size {opened_size}"
249        )));
250    }
251
252    let mut hasher = Sha256::new();
253    let mut total = 0u64;
254    let mut buffer = [0u8; 64 * 1024];
255    loop {
256        let read = file.read(&mut buffer).map_err(|error| {
257            BoxError::OciImageError(format!(
258                "Failed to read {what} at {}: {error}",
259                path.display()
260            ))
261        })?;
262        if read == 0 {
263            break;
264        }
265        total = total.saturating_add(read as u64);
266        if total > expected_size {
267            return Err(BoxError::OciImageError(format!(
268                "refusing {what} {digest}: content grew beyond its descriptor size {expected_size} while reading"
269            )));
270        }
271        hasher.update(&buffer[..read]);
272    }
273    if total != expected_size {
274        return Err(BoxError::OciImageError(format!(
275            "refusing {what} {digest}: descriptor size {expected_size} does not match bytes read {total}"
276        )));
277    }
278    let actual_hex = format!("{:x}", hasher.finalize());
279    if actual_hex != expected_hex {
280        return Err(BoxError::OciImageError(format!(
281            "refusing {what} {digest}: descriptor digest does not match actual bytes (sha256:{actual_hex})"
282        )));
283    }
284    Ok(path)
285}
286
287/// Health check configuration from OCI image config.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct OciHealthCheck {
290    pub test: Vec<String>,
291    pub interval: Option<u64>,
292    pub timeout: Option<u64>,
293    pub retries: Option<u32>,
294    pub start_period: Option<u64>,
295}
296
297impl OciHealthCheck {
298    /// Whether this Docker-compatible health check contains an executable test.
299    ///
300    /// `NONE` disables an inherited image health check. Malformed `CMD` and
301    /// `CMD-SHELL` arrays without a non-empty command are also non-effective;
302    /// unknown non-empty forms are kept fail-closed for forward compatibility.
303    pub fn is_enabled(&self) -> bool {
304        let Some(marker) = self.test.first() else {
305            return false;
306        };
307        if marker.eq_ignore_ascii_case("NONE") {
308            return false;
309        }
310        if marker.eq_ignore_ascii_case("CMD") || marker.eq_ignore_ascii_case("CMD-SHELL") {
311            return self
312                .test
313                .get(1..)
314                .is_some_and(|command| command.iter().any(|part| !part.trim().is_empty()));
315        }
316        self.test.iter().any(|part| !part.trim().is_empty())
317    }
318}
319
320/// Represents an OCI image loaded from disk.
321#[derive(Debug)]
322pub struct OciImage {
323    /// Root directory of the OCI image layout
324    root_dir: PathBuf,
325
326    /// Exact root manifest descriptor selected from the OCI index.
327    manifest_descriptor: Descriptor,
328
329    /// Authenticated image manifest selected from the OCI index.
330    manifest: ImageManifest,
331
332    /// Complete descriptor set whose bytes were verified while loading.
333    content_descriptors: Vec<Descriptor>,
334
335    /// Platform declared by the verified image configuration.
336    platform: BoxPlatform,
337
338    /// Image configuration
339    config: OciImageConfig,
340
341    /// Paths to layer blobs (in order, bottom to top)
342    layer_paths: Vec<PathBuf>,
343}
344
345/// One layer path paired with the descriptor authenticated by the image
346/// manifest. Consumers must still verify the bytes opened from `path` because
347/// the local OCI layout can change after [`OciImage`] is loaded.
348#[derive(Debug, Clone, Copy)]
349#[cfg(any(target_os = "macos", all(unix, test)))]
350pub(crate) struct OciLayerBlob<'a> {
351    pub(crate) path: &'a Path,
352    pub(crate) digest: &'a str,
353    pub(crate) size: u64,
354}
355
356/// Parsed OCI image configuration with entrypoint and environment.
357#[derive(Debug, Clone)]
358pub struct OciImageConfig {
359    /// Entrypoint command
360    pub entrypoint: Option<Vec<String>>,
361
362    /// Default command arguments
363    pub cmd: Option<Vec<String>>,
364
365    /// Environment variables
366    pub env: Vec<(String, String)>,
367
368    /// Working directory
369    pub working_dir: Option<String>,
370
371    /// User to run as
372    pub user: Option<String>,
373
374    /// Exposed ports
375    pub exposed_ports: Vec<String>,
376
377    /// Labels
378    pub labels: std::collections::HashMap<String, String>,
379
380    /// Volumes declared in the image (OCI VOLUME directive)
381    pub volumes: Vec<String>,
382
383    /// Stop signal
384    pub stop_signal: Option<String>,
385
386    /// Health check configuration
387    pub health_check: Option<OciHealthCheck>,
388
389    /// ONBUILD triggers
390    pub onbuild: Vec<String>,
391}
392
393impl OciImage {
394    /// Load an OCI image from a directory.
395    ///
396    /// The directory must contain a valid OCI image layout:
397    /// - oci-layout file
398    /// - index.json
399    /// - blobs/sha256/ directory with manifest, config, and layers
400    ///
401    /// # Arguments
402    ///
403    /// * `path` - Path to the OCI image directory
404    ///
405    /// # Errors
406    ///
407    /// Returns error if:
408    /// - Directory doesn't exist
409    /// - OCI layout is invalid
410    /// - Manifest or config cannot be parsed
411    pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
412        let root_dir = path.as_ref().to_path_buf();
413
414        // Validate OCI layout
415        Self::validate_oci_layout(&root_dir)?;
416
417        // Load index.json
418        let index = Self::load_index(&root_dir)?;
419
420        // Get the manifest descriptor from index. Its digest and size are both
421        // authenticated before the manifest bytes are parsed.
422        let manifest_descriptor = index
423            .manifests()
424            .first()
425            .cloned()
426            .ok_or_else(|| BoxError::OciImageError("No manifests in index.json".to_string()))?;
427
428        Self::from_validated_manifest_descriptor(root_dir, manifest_descriptor)
429    }
430
431    /// Load one exact manifest descriptor from an already materialized OCI
432    /// layout through the same config and layer verification as [`Self::from_path`].
433    pub(crate) fn from_manifest_descriptor(
434        path: impl AsRef<Path>,
435        manifest_descriptor: Descriptor,
436    ) -> Result<Self> {
437        let root_dir = path.as_ref().to_path_buf();
438        Self::validate_oci_layout(&root_dir)?;
439        Self::from_validated_manifest_descriptor(root_dir, manifest_descriptor)
440    }
441
442    fn from_validated_manifest_descriptor(
443        root_dir: PathBuf,
444        manifest_descriptor: Descriptor,
445    ) -> Result<Self> {
446        // Load manifest
447        let manifest = Self::load_manifest(&root_dir, &manifest_descriptor)?;
448
449        // Load config
450        let (config, platform) = Self::load_config(&root_dir, manifest.config())?;
451
452        // Verify every layer through a no-follow handle before exposing paths
453        // that extraction will subsequently consume.
454        let layer_paths = manifest
455            .layers()
456            .iter()
457            .map(|layer| {
458                let (digest, size) = typed_descriptor_contract(layer, "layer blob")?;
459                verify_oci_blob_file(
460                    &root_dir,
461                    &digest,
462                    size,
463                    MAX_OCI_LAYER_BLOB_BYTES,
464                    "layer blob",
465                )
466            })
467            .collect::<Result<Vec<_>>>()?;
468
469        let mut content_descriptors = Vec::with_capacity(manifest.layers().len() + 2);
470        content_descriptors.push(manifest_descriptor.clone());
471        content_descriptors.push(manifest.config().clone());
472        content_descriptors.extend(manifest.layers().iter().cloned());
473
474        Ok(Self {
475            root_dir,
476            manifest_descriptor,
477            manifest,
478            content_descriptors,
479            platform,
480            config,
481            layer_paths,
482        })
483    }
484
485    /// Get the image configuration.
486    pub fn config(&self) -> &OciImageConfig {
487        &self.config
488    }
489
490    /// Get paths to all layer blobs (in order, bottom to top).
491    pub fn layer_paths(&self) -> &[PathBuf] {
492        &self.layer_paths
493    }
494
495    /// Pair each local layer path with its authenticated manifest descriptor.
496    #[cfg(any(target_os = "macos", all(unix, test)))]
497    pub(crate) fn layer_blobs(&self) -> impl ExactSizeIterator<Item = OciLayerBlob<'_>> + '_ {
498        self.layer_paths
499            .iter()
500            .zip(self.manifest.layers())
501            .map(|(path, descriptor)| OciLayerBlob {
502                path,
503                digest: descriptor.digest().as_ref(),
504                size: descriptor.size(),
505            })
506    }
507
508    /// Get the root directory of the OCI image.
509    pub fn root_dir(&self) -> &Path {
510        &self.root_dir
511    }
512
513    /// Get the manifest digest (e.g. `"sha256:abc123..."`).
514    pub fn manifest_digest(&self) -> &str {
515        self.manifest_descriptor.digest().as_ref()
516    }
517
518    /// Exact root manifest descriptor selected from the OCI index.
519    pub(crate) fn manifest_descriptor(&self) -> &Descriptor {
520        &self.manifest_descriptor
521    }
522
523    /// Authenticated manifest selected from the OCI index.
524    pub(crate) fn manifest(&self) -> &ImageManifest {
525        &self.manifest
526    }
527
528    /// Complete descriptor set whose bytes were verified while loading.
529    pub(crate) fn content_descriptors(&self) -> &[Descriptor] {
530        &self.content_descriptors
531    }
532
533    /// Exact platform declared by the verified image configuration.
534    pub(crate) fn platform(&self) -> &BoxPlatform {
535        &self.platform
536    }
537
538    /// Get the entrypoint command.
539    ///
540    /// Returns the entrypoint from config, or None if not set.
541    pub fn entrypoint(&self) -> Option<&[String]> {
542        self.config.entrypoint.as_deref()
543    }
544
545    /// Get the default command arguments.
546    pub fn cmd(&self) -> Option<&[String]> {
547        self.config.cmd.as_deref()
548    }
549
550    /// Get environment variables.
551    pub fn env(&self) -> &[(String, String)] {
552        &self.config.env
553    }
554
555    /// Get the working directory.
556    pub fn working_dir(&self) -> Option<&str> {
557        self.config.working_dir.as_deref()
558    }
559
560    /// Get a label value by key.
561    pub fn label(&self, key: &str) -> Option<&str> {
562        self.config.labels.get(key).map(|s| s.as_str())
563    }
564
565    /// Validate that the directory contains a valid OCI layout.
566    fn validate_oci_layout(root_dir: &Path) -> Result<()> {
567        validate_plain_directory(root_dir, "OCI image root")?;
568
569        // Open the top-level metadata without following links. Reading it here
570        // also prevents oversized metadata from passing an existence-only check.
571        let oci_layout_path = root_dir.join("oci-layout");
572        read_regular_file_bounded(&oci_layout_path, MAX_OCI_LAYOUT_BYTES, "oci-layout")?;
573
574        let index_path = root_dir.join("index.json");
575        read_regular_file_bounded(&index_path, MAX_OCI_INDEX_BYTES, "index.json")?;
576
577        let blobs_dir = root_dir.join("blobs");
578        validate_plain_directory(&blobs_dir, "OCI blobs")?;
579        validate_plain_directory(&blobs_dir.join("sha256"), "OCI sha256 blobs")?;
580
581        Ok(())
582    }
583
584    /// Load the image index from index.json.
585    pub(crate) fn load_index(root_dir: &Path) -> Result<ImageIndex> {
586        let index_path = root_dir.join("index.json");
587        let content = read_regular_file_bounded(&index_path, MAX_OCI_INDEX_BYTES, "index.json")?;
588
589        serde_json::from_slice(&content)
590            .map_err(|e| BoxError::OciImageError(format!("Failed to parse index.json: {}", e)))
591    }
592
593    /// Load and authenticate an image-index blob referenced by a descriptor.
594    pub(crate) fn load_index_blob(root_dir: &Path, descriptor: &Descriptor) -> Result<ImageIndex> {
595        let (digest, size) = typed_descriptor_contract(descriptor, "image-index blob")?;
596        let content = read_verified_oci_blob(
597            root_dir,
598            &digest,
599            size,
600            MAX_OCI_INDEX_BYTES,
601            "image-index blob",
602        )?;
603        serde_json::from_slice(&content).map_err(|error| {
604            BoxError::OciImageError(format!("Failed to parse image-index blob: {error}"))
605        })
606    }
607
608    /// Load the image manifest from blobs.
609    fn load_manifest(root_dir: &Path, descriptor: &Descriptor) -> Result<ImageManifest> {
610        let (digest, size) = typed_descriptor_contract(descriptor, "manifest blob")?;
611        let content = read_verified_oci_blob(
612            root_dir,
613            &digest,
614            size,
615            MAX_OCI_MANIFEST_BYTES,
616            "manifest blob",
617        )?;
618
619        serde_json::from_slice(&content)
620            .map_err(|e| BoxError::OciImageError(format!("Failed to parse manifest: {}", e)))
621    }
622
623    /// Load the image configuration from blobs.
624    fn load_config(
625        root_dir: &Path,
626        descriptor: &Descriptor,
627    ) -> Result<(OciImageConfig, BoxPlatform)> {
628        let (digest, size) = typed_descriptor_contract(descriptor, "config blob")?;
629        let content =
630            read_verified_oci_blob(root_dir, &digest, size, MAX_OCI_CONFIG_BYTES, "config blob")?;
631
632        let oci_config: ImageConfiguration = serde_json::from_slice(&content)
633            .map_err(|e| BoxError::OciImageError(format!("Failed to parse config: {}", e)))?;
634
635        let raw_config: serde_json::Value = serde_json::from_slice(&content)
636            .map_err(|e| BoxError::OciImageError(format!("Failed to parse config JSON: {}", e)))?;
637
638        // oci-spec does not model OnBuild or Healthcheck, so parse those
639        // Docker-compatible image fields directly from raw JSON.
640        let onbuild: Vec<String> = raw_config
641            .get("config")
642            .and_then(|c| c.get("OnBuild"))
643            .cloned()
644            .and_then(|v| serde_json::from_value(v).ok())
645            .unwrap_or_default();
646        let health_check = Self::parse_health_check_from_raw(&raw_config);
647
648        let platform = BoxPlatform {
649            os: oci_config.os().to_string(),
650            architecture: oci_config.architecture().to_string(),
651            variant: oci_config.variant().clone(),
652        };
653        let mut config = OciImageConfig::from_oci_config(&oci_config, onbuild);
654        config.health_check = health_check;
655        Ok((config, platform))
656    }
657
658    /// Parse Docker-compatible Healthcheck metadata from raw image config JSON.
659    fn parse_health_check_from_raw(raw_config: &serde_json::Value) -> Option<OciHealthCheck> {
660        let health = raw_config
661            .get("config")
662            .and_then(|c| c.get("Healthcheck").or_else(|| c.get("healthcheck")))?;
663
664        let test = health.get("Test").or_else(|| health.get("test"))?;
665        let test: Vec<String> = serde_json::from_value(test.clone()).ok()?;
666        if test.is_empty() {
667            return None;
668        }
669
670        if test
671            .first()
672            .is_some_and(|marker| marker.eq_ignore_ascii_case("NONE"))
673        {
674            return None;
675        }
676
677        Some(OciHealthCheck {
678            test,
679            interval: health
680                .get("Interval")
681                .or_else(|| health.get("interval"))
682                .and_then(duration_seconds_from_json),
683            timeout: health
684                .get("Timeout")
685                .or_else(|| health.get("timeout"))
686                .and_then(duration_seconds_from_json),
687            retries: health
688                .get("Retries")
689                .or_else(|| health.get("retries"))
690                .and_then(u32_from_json)
691                .filter(|value| *value > 0),
692            start_period: health
693                .get("StartPeriod")
694                .or_else(|| health.get("start_period"))
695                .and_then(duration_seconds_from_json),
696        })
697    }
698}
699
700fn duration_seconds_from_json(value: &serde_json::Value) -> Option<u64> {
701    let nanos = u64_from_json(value)?;
702    if nanos == 0 {
703        return None;
704    }
705    Some(nanos.div_ceil(1_000_000_000).max(1))
706}
707
708fn u64_from_json(value: &serde_json::Value) -> Option<u64> {
709    value
710        .as_u64()
711        .or_else(|| value.as_str().and_then(|s| s.parse::<u64>().ok()))
712}
713
714fn u32_from_json(value: &serde_json::Value) -> Option<u32> {
715    u64_from_json(value).and_then(|value| u32::try_from(value).ok())
716}
717
718impl OciImageConfig {
719    /// Create from OCI spec ImageConfiguration.
720    fn from_oci_config(oci_config: &ImageConfiguration, onbuild: Vec<String>) -> Self {
721        let config = oci_config.config();
722
723        let entrypoint = config.as_ref().and_then(|c| c.entrypoint().clone());
724        let cmd = config.as_ref().and_then(|c| c.cmd().clone());
725        let working_dir = config.as_ref().and_then(|c| c.working_dir().clone());
726        let user = config.as_ref().and_then(|c| c.user().clone());
727
728        // Parse environment variables
729        let env = config
730            .as_ref()
731            .and_then(|c| c.env().as_ref())
732            .map(|env_list| {
733                env_list
734                    .iter()
735                    .filter_map(|e| {
736                        let parts: Vec<&str> = e.splitn(2, '=').collect();
737                        if parts.len() == 2 {
738                            Some((parts[0].to_string(), parts[1].to_string()))
739                        } else {
740                            None
741                        }
742                    })
743                    .collect()
744            })
745            .unwrap_or_default();
746
747        // Parse exposed ports
748        let exposed_ports = config
749            .as_ref()
750            .and_then(|c| c.exposed_ports().as_ref())
751            .map(|ports| ports.to_vec())
752            .unwrap_or_default();
753
754        // Parse labels
755        let labels = config
756            .as_ref()
757            .and_then(|c| c.labels().clone())
758            .unwrap_or_default();
759
760        // Parse volumes (OCI VOLUME directive)
761        let volumes = config
762            .as_ref()
763            .and_then(|c| c.volumes().as_ref())
764            .map(|vols| vols.to_vec())
765            .unwrap_or_default();
766
767        // Parse stop signal
768        let stop_signal = config.as_ref().and_then(|c| c.stop_signal().clone());
769
770        // Healthcheck is filled by load_config from raw JSON because oci-spec
771        // 0.6 does not expose the Docker-compatible field.
772        let health_check = None;
773
774        Self {
775            entrypoint,
776            cmd,
777            env,
778            working_dir,
779            user,
780            exposed_ports,
781            labels,
782            volumes,
783            stop_signal,
784            health_check,
785            onbuild,
786        }
787    }
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793    use std::fs;
794    use tempfile::TempDir;
795
796    #[test]
797    fn test_validate_oci_layout_missing_oci_layout_file() {
798        let temp_dir = TempDir::new().unwrap();
799
800        let result = OciImage::validate_oci_layout(temp_dir.path());
801
802        assert!(result.is_err());
803        assert!(result.unwrap_err().to_string().contains("oci-layout"));
804    }
805
806    #[test]
807    fn test_validate_oci_layout_missing_index_json() {
808        let temp_dir = TempDir::new().unwrap();
809
810        // Create oci-layout file
811        fs::write(
812            temp_dir.path().join("oci-layout"),
813            r#"{"imageLayoutVersion":"1.0.0"}"#,
814        )
815        .unwrap();
816
817        let result = OciImage::validate_oci_layout(temp_dir.path());
818
819        assert!(result.is_err());
820        assert!(result.unwrap_err().to_string().contains("index.json"));
821    }
822
823    #[test]
824    fn test_validate_oci_layout_missing_blobs() {
825        let temp_dir = TempDir::new().unwrap();
826
827        // Create oci-layout file
828        fs::write(
829            temp_dir.path().join("oci-layout"),
830            r#"{"imageLayoutVersion":"1.0.0"}"#,
831        )
832        .unwrap();
833
834        // Create index.json
835        fs::write(temp_dir.path().join("index.json"), "{}").unwrap();
836
837        let result = OciImage::validate_oci_layout(temp_dir.path());
838
839        assert!(result.is_err());
840        assert!(result.unwrap_err().to_string().contains("blobs"));
841    }
842
843    #[test]
844    fn test_validate_oci_layout_valid() {
845        let temp_dir = TempDir::new().unwrap();
846
847        // Create valid OCI layout structure
848        create_minimal_oci_layout(temp_dir.path());
849
850        let result = OciImage::validate_oci_layout(temp_dir.path());
851
852        assert!(result.is_ok());
853    }
854
855    #[test]
856    fn test_blob_path() {
857        let root = PathBuf::from("/images/test");
858        let digest = format!("sha256:{}", "a".repeat(64));
859
860        let path = blob_path(&root, &digest).unwrap();
861        assert_eq!(
862            path,
863            PathBuf::from(format!("/images/test/blobs/sha256/{}", "a".repeat(64)))
864        );
865
866        assert!(blob_path(&root, "abc123").is_err());
867        assert!(blob_path(
868            &root,
869            "sha256:../../../../../../../../windows/system32/drivers/etc/hosts"
870        )
871        .is_err());
872    }
873
874    #[test]
875    fn test_from_path_valid_image() {
876        let temp_dir = TempDir::new().unwrap();
877
878        // Create a complete OCI image layout
879        create_complete_oci_image(temp_dir.path());
880
881        let image = OciImage::from_path(temp_dir.path()).unwrap();
882
883        // Verify config was parsed
884        assert_eq!(image.entrypoint(), Some(&["/bin/agent".to_string()][..]));
885        assert_eq!(
886            image.cmd(),
887            Some(&["--port".to_string(), "8080".to_string()][..])
888        );
889        assert_eq!(image.working_dir(), Some("/workspace"));
890
891        // Verify env was parsed
892        let env = image.env();
893        assert!(env
894            .iter()
895            .any(|(k, v)| k == "PATH" && v.contains("/usr/bin")));
896
897        // Verify labels
898        assert_eq!(image.label("a3s.type"), Some("agent"));
899
900        // Verify Docker-compatible Healthcheck was parsed from raw config JSON.
901        let health_check = image.config().health_check.as_ref().unwrap();
902        assert_eq!(
903            health_check.test,
904            vec!["CMD-SHELL".to_string(), "test -f /tmp/healthy".to_string()]
905        );
906        assert_eq!(health_check.interval, Some(30));
907        assert_eq!(health_check.timeout, Some(2));
908        assert_eq!(health_check.retries, Some(2));
909        assert_eq!(health_check.start_period, Some(5));
910
911        // Verify layer paths
912        assert_eq!(image.layer_paths().len(), 1);
913    }
914
915    #[test]
916    fn test_from_path_exposes_manifest_digest() {
917        let temp_dir = TempDir::new().unwrap();
918        let layout = create_complete_oci_image(temp_dir.path());
919        let image = OciImage::from_path(temp_dir.path()).unwrap();
920        assert_eq!(image.manifest_digest(), layout.manifest_digest);
921    }
922
923    #[test]
924    fn test_from_path_nonexistent() {
925        let result = OciImage::from_path("/nonexistent/path");
926
927        assert!(result.is_err());
928    }
929
930    #[test]
931    fn test_from_path_rejects_oversized_index() {
932        let temp_dir = TempDir::new().unwrap();
933        create_minimal_oci_layout(temp_dir.path());
934        fs::File::create(temp_dir.path().join("index.json"))
935            .unwrap()
936            .set_len(MAX_OCI_INDEX_BYTES + 1)
937            .unwrap();
938
939        let error = OciImage::from_path(temp_dir.path()).unwrap_err();
940        assert!(error.to_string().contains("limit"), "{error}");
941    }
942
943    #[test]
944    fn test_from_path_rejects_blob_digest_mismatch() {
945        let temp_dir = TempDir::new().unwrap();
946        let layout = create_complete_oci_image(temp_dir.path());
947        let config_path = temp_dir
948            .path()
949            .join("blobs/sha256")
950            .join(digest_hex(&layout.config_digest));
951        let mut content = fs::read(&config_path).unwrap();
952        content[0] ^= 1;
953        fs::write(config_path, content).unwrap();
954
955        let error = OciImage::from_path(temp_dir.path()).unwrap_err();
956        assert!(error.to_string().contains("digest"), "{error}");
957    }
958
959    #[test]
960    fn test_from_path_rejects_noncanonical_manifest_digest() {
961        let temp_dir = TempDir::new().unwrap();
962        create_minimal_oci_layout(temp_dir.path());
963        fs::write(
964            temp_dir.path().join("index.json"),
965            r#"{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:../../../../outside","size":0}]}"#,
966        )
967        .unwrap();
968
969        assert!(OciImage::from_path(temp_dir.path()).is_err());
970    }
971
972    #[test]
973    fn test_from_path_rejects_index_symlink() {
974        let temp_dir = TempDir::new().unwrap();
975        let layout = temp_dir.path().join("layout");
976        fs::create_dir_all(layout.join("blobs/sha256")).unwrap();
977        fs::write(
978            layout.join("oci-layout"),
979            r#"{"imageLayoutVersion":"1.0.0"}"#,
980        )
981        .unwrap();
982        let outside = temp_dir.path().join("outside-index.json");
983        fs::write(&outside, "{}").unwrap();
984        if !symlink_file_for_test(&outside, &layout.join("index.json")) {
985            return;
986        }
987
988        let error = OciImage::from_path(&layout).unwrap_err();
989        assert!(error.to_string().contains("index.json"), "{error}");
990    }
991
992    #[test]
993    fn test_from_path_rejects_layer_blob_symlink() {
994        let temp_dir = TempDir::new().unwrap();
995        let layout = create_complete_oci_image(temp_dir.path());
996        let layer_path = temp_dir
997            .path()
998            .join("blobs/sha256")
999            .join(digest_hex(&layout.layer_digest));
1000        let layer_content = fs::read(&layer_path).unwrap();
1001        let outside = temp_dir.path().join("outside-layer.tar.gz");
1002        fs::write(&outside, layer_content).unwrap();
1003        fs::remove_file(&layer_path).unwrap();
1004        if !symlink_file_for_test(&outside, &layer_path) {
1005            return;
1006        }
1007
1008        let error = OciImage::from_path(temp_dir.path()).unwrap_err();
1009        assert!(error.to_string().contains("layer blob"), "{error}");
1010    }
1011
1012    #[test]
1013    fn test_validate_layout_rejects_reparse_blobs_directory() {
1014        let temp_dir = TempDir::new().unwrap();
1015        let layout = temp_dir.path().join("layout");
1016        let outside_blobs = temp_dir.path().join("outside-blobs");
1017        fs::create_dir_all(&layout).unwrap();
1018        fs::create_dir_all(outside_blobs.join("sha256")).unwrap();
1019        fs::write(
1020            layout.join("oci-layout"),
1021            r#"{"imageLayoutVersion":"1.0.0"}"#,
1022        )
1023        .unwrap();
1024        fs::write(layout.join("index.json"), "{}").unwrap();
1025        if !symlink_dir_for_test(&outside_blobs, &layout.join("blobs")) {
1026            return;
1027        }
1028
1029        let error = OciImage::validate_oci_layout(&layout).unwrap_err();
1030        assert!(error.to_string().contains("plain directory"), "{error}");
1031    }
1032
1033    #[test]
1034    fn test_from_path_rejects_reparse_root_directory() {
1035        let temp_dir = TempDir::new().unwrap();
1036        let target = temp_dir.path().join("target-layout");
1037        fs::create_dir_all(&target).unwrap();
1038        create_complete_oci_image(&target);
1039        let linked_root = temp_dir.path().join("linked-layout");
1040        if !symlink_dir_for_test(&target, &linked_root) {
1041            return;
1042        }
1043
1044        let error = OciImage::from_path(linked_root).unwrap_err();
1045        assert!(error.to_string().contains("plain directory"), "{error}");
1046    }
1047
1048    #[cfg(unix)]
1049    fn symlink_file_for_test(target: &Path, link: &Path) -> bool {
1050        std::os::unix::fs::symlink(target, link).unwrap();
1051        true
1052    }
1053
1054    #[cfg(windows)]
1055    fn symlink_file_for_test(target: &Path, link: &Path) -> bool {
1056        windows_symlink_for_test(|| std::os::windows::fs::symlink_file(target, link))
1057    }
1058
1059    #[cfg(not(any(unix, windows)))]
1060    fn symlink_file_for_test(_target: &Path, _link: &Path) -> bool {
1061        false
1062    }
1063
1064    #[cfg(unix)]
1065    fn symlink_dir_for_test(target: &Path, link: &Path) -> bool {
1066        std::os::unix::fs::symlink(target, link).unwrap();
1067        true
1068    }
1069
1070    #[cfg(windows)]
1071    fn symlink_dir_for_test(target: &Path, link: &Path) -> bool {
1072        windows_symlink_for_test(|| std::os::windows::fs::symlink_dir(target, link))
1073    }
1074
1075    #[cfg(not(any(unix, windows)))]
1076    fn symlink_dir_for_test(_target: &Path, _link: &Path) -> bool {
1077        false
1078    }
1079
1080    #[cfg(windows)]
1081    fn windows_symlink_for_test(create: impl FnOnce() -> std::io::Result<()>) -> bool {
1082        match create() {
1083            Ok(()) => true,
1084            Err(error) if error.raw_os_error() == Some(1314) => false,
1085            Err(error) => panic!("failed to create Windows test symlink: {error}"),
1086        }
1087    }
1088
1089    // Helper function to create minimal OCI layout structure
1090    fn create_minimal_oci_layout(path: &Path) {
1091        fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
1092
1093        fs::write(path.join("index.json"), "{}").unwrap();
1094
1095        fs::create_dir_all(path.join("blobs/sha256")).unwrap();
1096    }
1097
1098    #[derive(Debug)]
1099    struct CompleteLayout {
1100        manifest_digest: String,
1101        config_digest: String,
1102        layer_digest: String,
1103    }
1104
1105    fn sha256_digest(bytes: &[u8]) -> String {
1106        format!("sha256:{:x}", Sha256::digest(bytes))
1107    }
1108
1109    fn digest_hex(digest: &str) -> &str {
1110        digest.strip_prefix("sha256:").unwrap()
1111    }
1112
1113    fn write_blob(path: &Path, bytes: &[u8]) -> String {
1114        let digest = sha256_digest(bytes);
1115        fs::write(path.join("blobs/sha256").join(digest_hex(&digest)), bytes).unwrap();
1116        digest
1117    }
1118
1119    // Helper function to create a complete, cryptographically consistent OCI image.
1120    fn create_complete_oci_image(path: &Path) -> CompleteLayout {
1121        // Create directory structure
1122        fs::create_dir_all(path.join("blobs/sha256")).unwrap();
1123
1124        // Create oci-layout
1125        fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
1126
1127        // Create config blob
1128        let config_content = r#"{
1129            "architecture": "amd64",
1130            "os": "linux",
1131            "config": {
1132                "Entrypoint": ["/bin/agent"],
1133                "Cmd": ["--port", "8080"],
1134                "Env": ["PATH=/usr/local/bin:/usr/bin:/bin"],
1135                "WorkingDir": "/workspace",
1136                "Labels": {
1137                    "a3s.type": "agent",
1138                    "a3s.version": "1.0.0"
1139                },
1140                "Healthcheck": {
1141                    "Test": ["CMD-SHELL", "test -f /tmp/healthy"],
1142                    "Interval": 30000000000,
1143                    "Timeout": 1500000000,
1144                    "Retries": 2,
1145                    "StartPeriod": 5000000000
1146                }
1147            },
1148            "rootfs": {
1149                "type": "layers",
1150                "diff_ids": ["sha256:0000000000000000000000000000000000000000000000000000000000000000"]
1151            },
1152            "history": []
1153        }"#;
1154        let config_digest = write_blob(path, config_content.as_bytes());
1155
1156        // Create layer blob (minimal tar.gz for testing).
1157        let layer_build_path = path.join("fixture-layer.tar.gz");
1158        create_test_layer(&layer_build_path);
1159        let layer_content = fs::read(&layer_build_path).unwrap();
1160        fs::remove_file(layer_build_path).unwrap();
1161        let layer_digest = write_blob(path, &layer_content);
1162
1163        // Create manifest blob
1164        let manifest_content = format!(
1165            r#"{{
1166            "schemaVersion": 2,
1167            "mediaType": "application/vnd.oci.image.manifest.v1+json",
1168            "config": {{
1169                "mediaType": "application/vnd.oci.image.config.v1+json",
1170                "digest": "{}",
1171                "size": {}
1172            }},
1173            "layers": [
1174                {{
1175                    "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1176                    "digest": "{}",
1177                    "size": {}
1178                }}
1179            ]
1180        }}"#,
1181            config_digest,
1182            config_content.len(),
1183            layer_digest,
1184            layer_content.len()
1185        );
1186        let manifest_digest = write_blob(path, manifest_content.as_bytes());
1187
1188        // Create index.json
1189        let index_content = format!(
1190            r#"{{
1191            "schemaVersion": 2,
1192            "mediaType": "application/vnd.oci.image.index.v1+json",
1193            "manifests": [
1194                {{
1195                    "mediaType": "application/vnd.oci.image.manifest.v1+json",
1196                    "digest": "{}",
1197                    "size": {}
1198                }}
1199            ]
1200        }}"#,
1201            manifest_digest,
1202            manifest_content.len()
1203        );
1204        fs::write(path.join("index.json"), index_content).unwrap();
1205
1206        CompleteLayout {
1207            manifest_digest,
1208            config_digest,
1209            layer_digest,
1210        }
1211    }
1212
1213    #[test]
1214    fn test_from_oci_config_parses_volumes() {
1215        // Directly test OciImageConfig::from_oci_config with volumes
1216        let config_json = r#"{
1217            "architecture": "amd64",
1218            "os": "linux",
1219            "config": {
1220                "Volumes": {
1221                    "/data": {},
1222                    "/var/log": {}
1223                }
1224            },
1225            "rootfs": {
1226                "type": "layers",
1227                "diff_ids": []
1228            },
1229            "history": []
1230        }"#;
1231        let oci_config: oci_spec::image::ImageConfiguration =
1232            serde_json::from_str(config_json).unwrap();
1233        let config = OciImageConfig::from_oci_config(&oci_config, Vec::new());
1234        assert_eq!(config.volumes.len(), 2);
1235        assert!(config.volumes.contains(&"/data".to_string()));
1236        assert!(config.volumes.contains(&"/var/log".to_string()));
1237    }
1238
1239    #[test]
1240    fn test_from_oci_config_no_volumes() {
1241        let config_json = r#"{
1242            "architecture": "amd64",
1243            "os": "linux",
1244            "config": {},
1245            "rootfs": {
1246                "type": "layers",
1247                "diff_ids": []
1248            },
1249            "history": []
1250        }"#;
1251        let oci_config: oci_spec::image::ImageConfiguration =
1252            serde_json::from_str(config_json).unwrap();
1253        let config = OciImageConfig::from_oci_config(&oci_config, Vec::new());
1254        assert!(config.volumes.is_empty());
1255    }
1256
1257    #[test]
1258    fn test_parse_health_check_cmd() {
1259        let raw = serde_json::json!({
1260            "config": {
1261                "Healthcheck": {
1262                    "Test": ["CMD", "curl", "-f", "http://localhost/"],
1263                    "Interval": 30000000000u64,
1264                    "Timeout": 5000000000u64,
1265                    "Retries": 3u64,
1266                    "StartPeriod": 0u64
1267                }
1268            }
1269        });
1270
1271        let hc = OciImage::parse_health_check_from_raw(&raw).unwrap();
1272        assert_eq!(hc.test, vec!["CMD", "curl", "-f", "http://localhost/"]);
1273        assert_eq!(hc.interval, Some(30));
1274        assert_eq!(hc.timeout, Some(5));
1275        assert_eq!(hc.retries, Some(3));
1276        assert_eq!(hc.start_period, None);
1277    }
1278
1279    #[test]
1280    fn test_parse_health_check_cmd_shell_and_ceil_durations() {
1281        let raw = serde_json::json!({
1282            "config": {
1283                "Healthcheck": {
1284                    "Test": ["CMD-SHELL", "wget -qO- http://localhost/health"],
1285                    "Interval": 1500000000u64,
1286                    "Timeout": "1",
1287                    "Retries": "2",
1288                    "StartPeriod": 1u64
1289                }
1290            }
1291        });
1292
1293        let hc = OciImage::parse_health_check_from_raw(&raw).unwrap();
1294        assert_eq!(
1295            hc.test,
1296            vec!["CMD-SHELL", "wget -qO- http://localhost/health"]
1297        );
1298        assert_eq!(hc.interval, Some(2));
1299        assert_eq!(hc.timeout, Some(1));
1300        assert_eq!(hc.retries, Some(2));
1301        assert_eq!(hc.start_period, Some(1));
1302    }
1303
1304    #[test]
1305    fn test_parse_health_check_none_disables() {
1306        let raw = serde_json::json!({
1307            "config": {
1308                "Healthcheck": {
1309                    "Test": ["NONE"]
1310                }
1311            }
1312        });
1313
1314        assert!(OciImage::parse_health_check_from_raw(&raw).is_none());
1315    }
1316
1317    #[test]
1318    fn health_check_enabled_semantics_match_docker_forms() {
1319        let health_check = |test: &[&str]| OciHealthCheck {
1320            test: test.iter().map(|part| (*part).to_string()).collect(),
1321            interval: None,
1322            timeout: None,
1323            retries: None,
1324            start_period: None,
1325        };
1326
1327        assert!(health_check(&["CMD", "/bin/true"]).is_enabled());
1328        assert!(health_check(&["CMD-SHELL", "test -f /ready"]).is_enabled());
1329        assert!(health_check(&["future-form", "probe"]).is_enabled());
1330        assert!(!health_check(&[]).is_enabled());
1331        assert!(!health_check(&["NONE"]).is_enabled());
1332        assert!(!health_check(&["none", "ignored"]).is_enabled());
1333        assert!(!health_check(&["CMD"]).is_enabled());
1334        assert!(!health_check(&["CMD-SHELL", "  "]).is_enabled());
1335    }
1336
1337    #[test]
1338    fn test_load_config_parses_onbuild_triggers() {
1339        // Verify that OnBuild entries in the raw OCI config JSON are parsed
1340        // and surfaced in OciImageConfig.onbuild (oci-spec does not model this field).
1341        let temp_dir = TempDir::new().unwrap();
1342        fs::create_dir_all(temp_dir.path().join("blobs/sha256")).unwrap();
1343        fs::write(
1344            temp_dir.path().join("oci-layout"),
1345            r#"{"imageLayoutVersion":"1.0.0"}"#,
1346        )
1347        .unwrap();
1348
1349        let config_content = r#"{
1350            "architecture": "amd64",
1351            "os": "linux",
1352            "config": {
1353                "OnBuild": ["RUN echo hello", "COPY . /app"]
1354            },
1355            "rootfs": {"type": "layers", "diff_ids": []},
1356            "history": []
1357        }"#;
1358        let config_digest = write_blob(temp_dir.path(), config_content.as_bytes());
1359
1360        let manifest_content = format!(
1361            r#"{{"schemaVersion":2,"config":{{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"{}","size":{}}},"layers":[]}}"#,
1362            config_digest,
1363            config_content.len()
1364        );
1365        let manifest_digest = write_blob(temp_dir.path(), manifest_content.as_bytes());
1366
1367        let index_content = format!(
1368            r#"{{"schemaVersion":2,"manifests":[{{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"{}","size":{}}}]}}"#,
1369            manifest_digest,
1370            manifest_content.len()
1371        );
1372        fs::write(temp_dir.path().join("index.json"), index_content).unwrap();
1373
1374        let image = OciImage::from_path(temp_dir.path()).unwrap();
1375        assert_eq!(
1376            image.config().onbuild,
1377            vec!["RUN echo hello", "COPY . /app"]
1378        );
1379    }
1380
1381    // Helper function to create a test layer (minimal tar.gz)
1382    fn create_test_layer(path: &Path) {
1383        use flate2::write::GzEncoder;
1384        use flate2::Compression;
1385        use tar::Builder;
1386
1387        let file = fs::File::create(path).unwrap();
1388        let encoder = GzEncoder::new(file, Compression::default());
1389        let mut builder = Builder::new(encoder);
1390
1391        // Add a simple file
1392        let mut header = tar::Header::new_gnu();
1393        header.set_size(5);
1394        header.set_mode(0o644);
1395        header.set_cksum();
1396
1397        builder
1398            .append_data(&mut header, "test.txt", b"hello" as &[u8])
1399            .unwrap();
1400        builder.finish().unwrap();
1401    }
1402}