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