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        let guard = a3s_box_core::windows_symlink::WindowsSymlinkPrivilegeGuard::acquire();
1083        let assigned_privilege_enabled = guard.assigned_privilege_enabled();
1084        match create() {
1085            Ok(()) => true,
1086            Err(error)
1087                if a3s_box_core::windows_symlink::is_capability_denial(
1088                    &error,
1089                    assigned_privilege_enabled,
1090                ) =>
1091            {
1092                false
1093            }
1094            Err(error) => panic!("failed to create Windows test symlink: {error}"),
1095        }
1096    }
1097
1098    // Helper function to create minimal OCI layout structure
1099    fn create_minimal_oci_layout(path: &Path) {
1100        fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
1101
1102        fs::write(path.join("index.json"), "{}").unwrap();
1103
1104        fs::create_dir_all(path.join("blobs/sha256")).unwrap();
1105    }
1106
1107    #[derive(Debug)]
1108    struct CompleteLayout {
1109        manifest_digest: String,
1110        config_digest: String,
1111        layer_digest: String,
1112    }
1113
1114    fn sha256_digest(bytes: &[u8]) -> String {
1115        format!("sha256:{:x}", Sha256::digest(bytes))
1116    }
1117
1118    fn digest_hex(digest: &str) -> &str {
1119        digest.strip_prefix("sha256:").unwrap()
1120    }
1121
1122    fn write_blob(path: &Path, bytes: &[u8]) -> String {
1123        let digest = sha256_digest(bytes);
1124        fs::write(path.join("blobs/sha256").join(digest_hex(&digest)), bytes).unwrap();
1125        digest
1126    }
1127
1128    // Helper function to create a complete, cryptographically consistent OCI image.
1129    fn create_complete_oci_image(path: &Path) -> CompleteLayout {
1130        // Create directory structure
1131        fs::create_dir_all(path.join("blobs/sha256")).unwrap();
1132
1133        // Create oci-layout
1134        fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
1135
1136        // Create config blob
1137        let config_content = r#"{
1138            "architecture": "amd64",
1139            "os": "linux",
1140            "config": {
1141                "Entrypoint": ["/bin/agent"],
1142                "Cmd": ["--port", "8080"],
1143                "Env": ["PATH=/usr/local/bin:/usr/bin:/bin"],
1144                "WorkingDir": "/workspace",
1145                "Labels": {
1146                    "a3s.type": "agent",
1147                    "a3s.version": "1.0.0"
1148                },
1149                "Healthcheck": {
1150                    "Test": ["CMD-SHELL", "test -f /tmp/healthy"],
1151                    "Interval": 30000000000,
1152                    "Timeout": 1500000000,
1153                    "Retries": 2,
1154                    "StartPeriod": 5000000000
1155                }
1156            },
1157            "rootfs": {
1158                "type": "layers",
1159                "diff_ids": ["sha256:0000000000000000000000000000000000000000000000000000000000000000"]
1160            },
1161            "history": []
1162        }"#;
1163        let config_digest = write_blob(path, config_content.as_bytes());
1164
1165        // Create layer blob (minimal tar.gz for testing).
1166        let layer_build_path = path.join("fixture-layer.tar.gz");
1167        create_test_layer(&layer_build_path);
1168        let layer_content = fs::read(&layer_build_path).unwrap();
1169        fs::remove_file(layer_build_path).unwrap();
1170        let layer_digest = write_blob(path, &layer_content);
1171
1172        // Create manifest blob
1173        let manifest_content = format!(
1174            r#"{{
1175            "schemaVersion": 2,
1176            "mediaType": "application/vnd.oci.image.manifest.v1+json",
1177            "config": {{
1178                "mediaType": "application/vnd.oci.image.config.v1+json",
1179                "digest": "{}",
1180                "size": {}
1181            }},
1182            "layers": [
1183                {{
1184                    "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1185                    "digest": "{}",
1186                    "size": {}
1187                }}
1188            ]
1189        }}"#,
1190            config_digest,
1191            config_content.len(),
1192            layer_digest,
1193            layer_content.len()
1194        );
1195        let manifest_digest = write_blob(path, manifest_content.as_bytes());
1196
1197        // Create index.json
1198        let index_content = format!(
1199            r#"{{
1200            "schemaVersion": 2,
1201            "mediaType": "application/vnd.oci.image.index.v1+json",
1202            "manifests": [
1203                {{
1204                    "mediaType": "application/vnd.oci.image.manifest.v1+json",
1205                    "digest": "{}",
1206                    "size": {}
1207                }}
1208            ]
1209        }}"#,
1210            manifest_digest,
1211            manifest_content.len()
1212        );
1213        fs::write(path.join("index.json"), index_content).unwrap();
1214
1215        CompleteLayout {
1216            manifest_digest,
1217            config_digest,
1218            layer_digest,
1219        }
1220    }
1221
1222    #[test]
1223    fn test_from_oci_config_parses_volumes() {
1224        // Directly test OciImageConfig::from_oci_config with volumes
1225        let config_json = r#"{
1226            "architecture": "amd64",
1227            "os": "linux",
1228            "config": {
1229                "Volumes": {
1230                    "/data": {},
1231                    "/var/log": {}
1232                }
1233            },
1234            "rootfs": {
1235                "type": "layers",
1236                "diff_ids": []
1237            },
1238            "history": []
1239        }"#;
1240        let oci_config: oci_spec::image::ImageConfiguration =
1241            serde_json::from_str(config_json).unwrap();
1242        let config = OciImageConfig::from_oci_config(&oci_config, Vec::new());
1243        assert_eq!(config.volumes.len(), 2);
1244        assert!(config.volumes.contains(&"/data".to_string()));
1245        assert!(config.volumes.contains(&"/var/log".to_string()));
1246    }
1247
1248    #[test]
1249    fn test_from_oci_config_no_volumes() {
1250        let config_json = r#"{
1251            "architecture": "amd64",
1252            "os": "linux",
1253            "config": {},
1254            "rootfs": {
1255                "type": "layers",
1256                "diff_ids": []
1257            },
1258            "history": []
1259        }"#;
1260        let oci_config: oci_spec::image::ImageConfiguration =
1261            serde_json::from_str(config_json).unwrap();
1262        let config = OciImageConfig::from_oci_config(&oci_config, Vec::new());
1263        assert!(config.volumes.is_empty());
1264    }
1265
1266    #[test]
1267    fn test_parse_health_check_cmd() {
1268        let raw = serde_json::json!({
1269            "config": {
1270                "Healthcheck": {
1271                    "Test": ["CMD", "curl", "-f", "http://localhost/"],
1272                    "Interval": 30000000000u64,
1273                    "Timeout": 5000000000u64,
1274                    "Retries": 3u64,
1275                    "StartPeriod": 0u64
1276                }
1277            }
1278        });
1279
1280        let hc = OciImage::parse_health_check_from_raw(&raw).unwrap();
1281        assert_eq!(hc.test, vec!["CMD", "curl", "-f", "http://localhost/"]);
1282        assert_eq!(hc.interval, Some(30));
1283        assert_eq!(hc.timeout, Some(5));
1284        assert_eq!(hc.retries, Some(3));
1285        assert_eq!(hc.start_period, None);
1286    }
1287
1288    #[test]
1289    fn test_parse_health_check_cmd_shell_and_ceil_durations() {
1290        let raw = serde_json::json!({
1291            "config": {
1292                "Healthcheck": {
1293                    "Test": ["CMD-SHELL", "wget -qO- http://localhost/health"],
1294                    "Interval": 1500000000u64,
1295                    "Timeout": "1",
1296                    "Retries": "2",
1297                    "StartPeriod": 1u64
1298                }
1299            }
1300        });
1301
1302        let hc = OciImage::parse_health_check_from_raw(&raw).unwrap();
1303        assert_eq!(
1304            hc.test,
1305            vec!["CMD-SHELL", "wget -qO- http://localhost/health"]
1306        );
1307        assert_eq!(hc.interval, Some(2));
1308        assert_eq!(hc.timeout, Some(1));
1309        assert_eq!(hc.retries, Some(2));
1310        assert_eq!(hc.start_period, Some(1));
1311    }
1312
1313    #[test]
1314    fn test_parse_health_check_none_disables() {
1315        let raw = serde_json::json!({
1316            "config": {
1317                "Healthcheck": {
1318                    "Test": ["NONE"]
1319                }
1320            }
1321        });
1322
1323        assert!(OciImage::parse_health_check_from_raw(&raw).is_none());
1324    }
1325
1326    #[test]
1327    fn health_check_enabled_semantics_match_docker_forms() {
1328        let health_check = |test: &[&str]| OciHealthCheck {
1329            test: test.iter().map(|part| (*part).to_string()).collect(),
1330            interval: None,
1331            timeout: None,
1332            retries: None,
1333            start_period: None,
1334        };
1335
1336        assert!(health_check(&["CMD", "/bin/true"]).is_enabled());
1337        assert!(health_check(&["CMD-SHELL", "test -f /ready"]).is_enabled());
1338        assert!(health_check(&["future-form", "probe"]).is_enabled());
1339        assert!(!health_check(&[]).is_enabled());
1340        assert!(!health_check(&["NONE"]).is_enabled());
1341        assert!(!health_check(&["none", "ignored"]).is_enabled());
1342        assert!(!health_check(&["CMD"]).is_enabled());
1343        assert!(!health_check(&["CMD-SHELL", "  "]).is_enabled());
1344    }
1345
1346    #[test]
1347    fn test_load_config_parses_onbuild_triggers() {
1348        // Verify that OnBuild entries in the raw OCI config JSON are parsed
1349        // and surfaced in OciImageConfig.onbuild (oci-spec does not model this field).
1350        let temp_dir = TempDir::new().unwrap();
1351        fs::create_dir_all(temp_dir.path().join("blobs/sha256")).unwrap();
1352        fs::write(
1353            temp_dir.path().join("oci-layout"),
1354            r#"{"imageLayoutVersion":"1.0.0"}"#,
1355        )
1356        .unwrap();
1357
1358        let config_content = r#"{
1359            "architecture": "amd64",
1360            "os": "linux",
1361            "config": {
1362                "OnBuild": ["RUN echo hello", "COPY . /app"]
1363            },
1364            "rootfs": {"type": "layers", "diff_ids": []},
1365            "history": []
1366        }"#;
1367        let config_digest = write_blob(temp_dir.path(), config_content.as_bytes());
1368
1369        let manifest_content = format!(
1370            r#"{{"schemaVersion":2,"config":{{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"{}","size":{}}},"layers":[]}}"#,
1371            config_digest,
1372            config_content.len()
1373        );
1374        let manifest_digest = write_blob(temp_dir.path(), manifest_content.as_bytes());
1375
1376        let index_content = format!(
1377            r#"{{"schemaVersion":2,"manifests":[{{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"{}","size":{}}}]}}"#,
1378            manifest_digest,
1379            manifest_content.len()
1380        );
1381        fs::write(temp_dir.path().join("index.json"), index_content).unwrap();
1382
1383        let image = OciImage::from_path(temp_dir.path()).unwrap();
1384        assert_eq!(
1385            image.config().onbuild,
1386            vec!["RUN echo hello", "COPY . /app"]
1387        );
1388    }
1389
1390    // Helper function to create a test layer (minimal tar.gz)
1391    fn create_test_layer(path: &Path) {
1392        use flate2::write::GzEncoder;
1393        use flate2::Compression;
1394        use tar::Builder;
1395
1396        let file = fs::File::create(path).unwrap();
1397        let encoder = GzEncoder::new(file, Compression::default());
1398        let mut builder = Builder::new(encoder);
1399
1400        // Add a simple file
1401        let mut header = tar::Header::new_gnu();
1402        header.set_size(5);
1403        header.set_mode(0o644);
1404        header.set_cksum();
1405
1406        builder
1407            .append_data(&mut header, "test.txt", b"hello" as &[u8])
1408            .unwrap();
1409        builder.finish().unwrap();
1410    }
1411}