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::{ImageConfiguration, ImageIndex, ImageManifest};
7use std::path::{Path, PathBuf};
8
9/// Health check configuration from OCI image config.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct OciHealthCheck {
12    pub test: Vec<String>,
13    pub interval: Option<u64>,
14    pub timeout: Option<u64>,
15    pub retries: Option<u32>,
16    pub start_period: Option<u64>,
17}
18
19/// Represents an OCI image loaded from disk.
20#[derive(Debug)]
21pub struct OciImage {
22    /// Root directory of the OCI image layout
23    root_dir: PathBuf,
24
25    /// Manifest digest (e.g. "sha256:abc123...")
26    manifest_digest: String,
27
28    /// Image configuration
29    config: OciImageConfig,
30
31    /// Paths to layer blobs (in order, bottom to top)
32    layer_paths: Vec<PathBuf>,
33}
34
35/// Parsed OCI image configuration with entrypoint and environment.
36#[derive(Debug, Clone)]
37pub struct OciImageConfig {
38    /// Entrypoint command
39    pub entrypoint: Option<Vec<String>>,
40
41    /// Default command arguments
42    pub cmd: Option<Vec<String>>,
43
44    /// Environment variables
45    pub env: Vec<(String, String)>,
46
47    /// Working directory
48    pub working_dir: Option<String>,
49
50    /// User to run as
51    pub user: Option<String>,
52
53    /// Exposed ports
54    pub exposed_ports: Vec<String>,
55
56    /// Labels
57    pub labels: std::collections::HashMap<String, String>,
58
59    /// Volumes declared in the image (OCI VOLUME directive)
60    pub volumes: Vec<String>,
61
62    /// Stop signal
63    pub stop_signal: Option<String>,
64
65    /// Health check configuration
66    pub health_check: Option<OciHealthCheck>,
67
68    /// ONBUILD triggers
69    pub onbuild: Vec<String>,
70}
71
72impl OciImage {
73    /// Load an OCI image from a directory.
74    ///
75    /// The directory must contain a valid OCI image layout:
76    /// - oci-layout file
77    /// - index.json
78    /// - blobs/sha256/ directory with manifest, config, and layers
79    ///
80    /// # Arguments
81    ///
82    /// * `path` - Path to the OCI image directory
83    ///
84    /// # Errors
85    ///
86    /// Returns error if:
87    /// - Directory doesn't exist
88    /// - OCI layout is invalid
89    /// - Manifest or config cannot be parsed
90    pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
91        let root_dir = path.as_ref().to_path_buf();
92
93        // Validate OCI layout
94        Self::validate_oci_layout(&root_dir)?;
95
96        // Load index.json
97        let index = Self::load_index(&root_dir)?;
98
99        // Get manifest digest from index
100        let manifest_digest = index
101            .manifests()
102            .first()
103            .ok_or_else(|| BoxError::OciImageError("No manifests in index.json".to_string()))?
104            .digest()
105            .to_string();
106
107        // Load manifest
108        let manifest = Self::load_manifest(&root_dir, &manifest_digest)?;
109
110        // Load config
111        let config_digest = manifest.config().digest().to_string();
112        let config = Self::load_config(&root_dir, &config_digest)?;
113
114        // Get layer paths
115        let layer_paths = manifest
116            .layers()
117            .iter()
118            .map(|layer| Self::blob_path(&root_dir, layer.digest()))
119            .collect();
120
121        Ok(Self {
122            root_dir,
123            manifest_digest,
124            config,
125            layer_paths,
126        })
127    }
128
129    /// Get the image configuration.
130    pub fn config(&self) -> &OciImageConfig {
131        &self.config
132    }
133
134    /// Get paths to all layer blobs (in order, bottom to top).
135    pub fn layer_paths(&self) -> &[PathBuf] {
136        &self.layer_paths
137    }
138
139    /// Get the root directory of the OCI image.
140    pub fn root_dir(&self) -> &Path {
141        &self.root_dir
142    }
143
144    /// Get the manifest digest (e.g. `"sha256:abc123..."`).
145    pub fn manifest_digest(&self) -> &str {
146        &self.manifest_digest
147    }
148
149    /// Get the entrypoint command.
150    ///
151    /// Returns the entrypoint from config, or None if not set.
152    pub fn entrypoint(&self) -> Option<&[String]> {
153        self.config.entrypoint.as_deref()
154    }
155
156    /// Get the default command arguments.
157    pub fn cmd(&self) -> Option<&[String]> {
158        self.config.cmd.as_deref()
159    }
160
161    /// Get environment variables.
162    pub fn env(&self) -> &[(String, String)] {
163        &self.config.env
164    }
165
166    /// Get the working directory.
167    pub fn working_dir(&self) -> Option<&str> {
168        self.config.working_dir.as_deref()
169    }
170
171    /// Get a label value by key.
172    pub fn label(&self, key: &str) -> Option<&str> {
173        self.config.labels.get(key).map(|s| s.as_str())
174    }
175
176    /// Validate that the directory contains a valid OCI layout.
177    fn validate_oci_layout(root_dir: &Path) -> Result<()> {
178        // Check oci-layout file exists
179        let oci_layout_path = root_dir.join("oci-layout");
180        if !oci_layout_path.exists() {
181            return Err(BoxError::OciImageError(format!(
182                "Not a valid OCI layout: missing oci-layout file in {}",
183                root_dir.display()
184            )));
185        }
186
187        // Check index.json exists
188        let index_path = root_dir.join("index.json");
189        if !index_path.exists() {
190            return Err(BoxError::OciImageError(format!(
191                "Not a valid OCI layout: missing index.json in {}",
192                root_dir.display()
193            )));
194        }
195
196        // Check blobs directory exists
197        let blobs_dir = root_dir.join("blobs");
198        if !blobs_dir.exists() {
199            return Err(BoxError::OciImageError(format!(
200                "Not a valid OCI layout: missing blobs directory in {}",
201                root_dir.display()
202            )));
203        }
204
205        Ok(())
206    }
207
208    /// Load the image index from index.json.
209    fn load_index(root_dir: &Path) -> Result<ImageIndex> {
210        let index_path = root_dir.join("index.json");
211        let content = std::fs::read_to_string(&index_path).map_err(|e| {
212            BoxError::OciImageError(format!(
213                "Failed to read index.json at {}: {}",
214                index_path.display(),
215                e
216            ))
217        })?;
218
219        serde_json::from_str(&content)
220            .map_err(|e| BoxError::OciImageError(format!("Failed to parse index.json: {}", e)))
221    }
222
223    /// Load the image manifest from blobs.
224    fn load_manifest(root_dir: &Path, digest: &str) -> Result<ImageManifest> {
225        let blob_path = Self::blob_path(root_dir, digest);
226        let content = std::fs::read_to_string(&blob_path).map_err(|e| {
227            BoxError::OciImageError(format!(
228                "Failed to read manifest at {}: {}",
229                blob_path.display(),
230                e
231            ))
232        })?;
233
234        serde_json::from_str(&content)
235            .map_err(|e| BoxError::OciImageError(format!("Failed to parse manifest: {}", e)))
236    }
237
238    /// Load the image configuration from blobs.
239    fn load_config(root_dir: &Path, digest: &str) -> Result<OciImageConfig> {
240        let blob_path = Self::blob_path(root_dir, digest);
241        let content = std::fs::read_to_string(&blob_path).map_err(|e| {
242            BoxError::OciImageError(format!(
243                "Failed to read config at {}: {}",
244                blob_path.display(),
245                e
246            ))
247        })?;
248
249        let oci_config: ImageConfiguration = serde_json::from_str(&content)
250            .map_err(|e| BoxError::OciImageError(format!("Failed to parse config: {}", e)))?;
251
252        let raw_config: serde_json::Value = serde_json::from_str(&content)
253            .map_err(|e| BoxError::OciImageError(format!("Failed to parse config JSON: {}", e)))?;
254
255        // oci-spec 0.6 does not model OnBuild or Healthcheck, so parse those
256        // Docker-compatible image fields directly from raw JSON.
257        let onbuild: Vec<String> = raw_config
258            .get("config")
259            .and_then(|c| c.get("OnBuild"))
260            .cloned()
261            .and_then(|v| serde_json::from_value(v).ok())
262            .unwrap_or_default();
263        let health_check = Self::parse_health_check_from_raw(&raw_config);
264
265        let mut config = OciImageConfig::from_oci_config(&oci_config, onbuild);
266        config.health_check = health_check;
267        Ok(config)
268    }
269
270    /// Get the path to a blob by digest.
271    fn blob_path(root_dir: &Path, digest: &str) -> PathBuf {
272        // Digest format: "sha256:abc123..."
273        let parts: Vec<&str> = digest.split(':').collect();
274        let (algorithm, hash) = if parts.len() == 2 {
275            (parts[0], parts[1])
276        } else {
277            ("sha256", digest)
278        };
279
280        root_dir.join("blobs").join(algorithm).join(hash)
281    }
282
283    /// Parse Docker-compatible Healthcheck metadata from raw image config JSON.
284    fn parse_health_check_from_raw(raw_config: &serde_json::Value) -> Option<OciHealthCheck> {
285        let health = raw_config
286            .get("config")
287            .and_then(|c| c.get("Healthcheck").or_else(|| c.get("healthcheck")))?;
288
289        let test = health.get("Test").or_else(|| health.get("test"))?;
290        let test: Vec<String> = serde_json::from_value(test.clone()).ok()?;
291        if test.is_empty() {
292            return None;
293        }
294
295        if test
296            .first()
297            .is_some_and(|marker| marker.eq_ignore_ascii_case("NONE"))
298        {
299            return None;
300        }
301
302        Some(OciHealthCheck {
303            test,
304            interval: health
305                .get("Interval")
306                .or_else(|| health.get("interval"))
307                .and_then(duration_seconds_from_json),
308            timeout: health
309                .get("Timeout")
310                .or_else(|| health.get("timeout"))
311                .and_then(duration_seconds_from_json),
312            retries: health
313                .get("Retries")
314                .or_else(|| health.get("retries"))
315                .and_then(u32_from_json)
316                .filter(|value| *value > 0),
317            start_period: health
318                .get("StartPeriod")
319                .or_else(|| health.get("start_period"))
320                .and_then(duration_seconds_from_json),
321        })
322    }
323}
324
325fn duration_seconds_from_json(value: &serde_json::Value) -> Option<u64> {
326    let nanos = u64_from_json(value)?;
327    if nanos == 0 {
328        return None;
329    }
330    Some(nanos.div_ceil(1_000_000_000).max(1))
331}
332
333fn u64_from_json(value: &serde_json::Value) -> Option<u64> {
334    value
335        .as_u64()
336        .or_else(|| value.as_str().and_then(|s| s.parse::<u64>().ok()))
337}
338
339fn u32_from_json(value: &serde_json::Value) -> Option<u32> {
340    u64_from_json(value).and_then(|value| u32::try_from(value).ok())
341}
342
343impl OciImageConfig {
344    /// Create from OCI spec ImageConfiguration.
345    fn from_oci_config(oci_config: &ImageConfiguration, onbuild: Vec<String>) -> Self {
346        let config = oci_config.config();
347
348        let entrypoint = config.as_ref().and_then(|c| c.entrypoint().clone());
349        let cmd = config.as_ref().and_then(|c| c.cmd().clone());
350        let working_dir = config.as_ref().and_then(|c| c.working_dir().clone());
351        let user = config.as_ref().and_then(|c| c.user().clone());
352
353        // Parse environment variables
354        let env = config
355            .as_ref()
356            .and_then(|c| c.env().as_ref())
357            .map(|env_list| {
358                env_list
359                    .iter()
360                    .filter_map(|e| {
361                        let parts: Vec<&str> = e.splitn(2, '=').collect();
362                        if parts.len() == 2 {
363                            Some((parts[0].to_string(), parts[1].to_string()))
364                        } else {
365                            None
366                        }
367                    })
368                    .collect()
369            })
370            .unwrap_or_default();
371
372        // Parse exposed ports
373        let exposed_ports = config
374            .as_ref()
375            .and_then(|c| c.exposed_ports().as_ref())
376            .map(|ports| ports.to_vec())
377            .unwrap_or_default();
378
379        // Parse labels
380        let labels = config
381            .as_ref()
382            .and_then(|c| c.labels().clone())
383            .unwrap_or_default();
384
385        // Parse volumes (OCI VOLUME directive)
386        let volumes = config
387            .as_ref()
388            .and_then(|c| c.volumes().as_ref())
389            .map(|vols| vols.to_vec())
390            .unwrap_or_default();
391
392        // Parse stop signal
393        let stop_signal = config.as_ref().and_then(|c| c.stop_signal().clone());
394
395        // Healthcheck is filled by load_config from raw JSON because oci-spec
396        // 0.6 does not expose the Docker-compatible field.
397        let health_check = None;
398
399        Self {
400            entrypoint,
401            cmd,
402            env,
403            working_dir,
404            user,
405            exposed_ports,
406            labels,
407            volumes,
408            stop_signal,
409            health_check,
410            onbuild,
411        }
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use std::fs;
419    use tempfile::TempDir;
420
421    #[test]
422    fn test_validate_oci_layout_missing_oci_layout_file() {
423        let temp_dir = TempDir::new().unwrap();
424
425        let result = OciImage::validate_oci_layout(temp_dir.path());
426
427        assert!(result.is_err());
428        assert!(result.unwrap_err().to_string().contains("oci-layout"));
429    }
430
431    #[test]
432    fn test_validate_oci_layout_missing_index_json() {
433        let temp_dir = TempDir::new().unwrap();
434
435        // Create oci-layout file
436        fs::write(
437            temp_dir.path().join("oci-layout"),
438            r#"{"imageLayoutVersion":"1.0.0"}"#,
439        )
440        .unwrap();
441
442        let result = OciImage::validate_oci_layout(temp_dir.path());
443
444        assert!(result.is_err());
445        assert!(result.unwrap_err().to_string().contains("index.json"));
446    }
447
448    #[test]
449    fn test_validate_oci_layout_missing_blobs() {
450        let temp_dir = TempDir::new().unwrap();
451
452        // Create oci-layout file
453        fs::write(
454            temp_dir.path().join("oci-layout"),
455            r#"{"imageLayoutVersion":"1.0.0"}"#,
456        )
457        .unwrap();
458
459        // Create index.json
460        fs::write(temp_dir.path().join("index.json"), "{}").unwrap();
461
462        let result = OciImage::validate_oci_layout(temp_dir.path());
463
464        assert!(result.is_err());
465        assert!(result.unwrap_err().to_string().contains("blobs"));
466    }
467
468    #[test]
469    fn test_validate_oci_layout_valid() {
470        let temp_dir = TempDir::new().unwrap();
471
472        // Create valid OCI layout structure
473        create_minimal_oci_layout(temp_dir.path());
474
475        let result = OciImage::validate_oci_layout(temp_dir.path());
476
477        assert!(result.is_ok());
478    }
479
480    #[test]
481    fn test_blob_path() {
482        let root = PathBuf::from("/images/test");
483
484        let path = OciImage::blob_path(&root, "sha256:abc123");
485        assert_eq!(path, PathBuf::from("/images/test/blobs/sha256/abc123"));
486
487        let path = OciImage::blob_path(&root, "abc123");
488        assert_eq!(path, PathBuf::from("/images/test/blobs/sha256/abc123"));
489    }
490
491    #[test]
492    fn test_from_path_valid_image() {
493        let temp_dir = TempDir::new().unwrap();
494
495        // Create a complete OCI image layout
496        create_complete_oci_image(temp_dir.path());
497
498        let image = OciImage::from_path(temp_dir.path()).unwrap();
499
500        // Verify config was parsed
501        assert_eq!(image.entrypoint(), Some(&["/bin/agent".to_string()][..]));
502        assert_eq!(
503            image.cmd(),
504            Some(&["--port".to_string(), "8080".to_string()][..])
505        );
506        assert_eq!(image.working_dir(), Some("/workspace"));
507
508        // Verify env was parsed
509        let env = image.env();
510        assert!(env
511            .iter()
512            .any(|(k, v)| k == "PATH" && v.contains("/usr/bin")));
513
514        // Verify labels
515        assert_eq!(image.label("a3s.type"), Some("agent"));
516
517        // Verify Docker-compatible Healthcheck was parsed from raw config JSON.
518        let health_check = image.config().health_check.as_ref().unwrap();
519        assert_eq!(
520            health_check.test,
521            vec!["CMD-SHELL".to_string(), "test -f /tmp/healthy".to_string()]
522        );
523        assert_eq!(health_check.interval, Some(30));
524        assert_eq!(health_check.timeout, Some(2));
525        assert_eq!(health_check.retries, Some(2));
526        assert_eq!(health_check.start_period, Some(5));
527
528        // Verify layer paths
529        assert_eq!(image.layer_paths().len(), 1);
530    }
531
532    #[test]
533    fn test_from_path_exposes_manifest_digest() {
534        let temp_dir = TempDir::new().unwrap();
535        create_complete_oci_image(temp_dir.path());
536        let image = OciImage::from_path(temp_dir.path()).unwrap();
537        assert_eq!(image.manifest_digest(), "sha256:manifestxyz789");
538    }
539
540    #[test]
541    fn test_from_path_nonexistent() {
542        let result = OciImage::from_path("/nonexistent/path");
543
544        assert!(result.is_err());
545    }
546
547    // Helper function to create minimal OCI layout structure
548    fn create_minimal_oci_layout(path: &Path) {
549        fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
550
551        fs::write(path.join("index.json"), "{}").unwrap();
552
553        fs::create_dir_all(path.join("blobs/sha256")).unwrap();
554    }
555
556    // Helper function to create a complete OCI image for testing
557    fn create_complete_oci_image(path: &Path) {
558        // Create directory structure
559        fs::create_dir_all(path.join("blobs/sha256")).unwrap();
560
561        // Create oci-layout
562        fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
563
564        // Create config blob
565        let config_content = r#"{
566            "architecture": "amd64",
567            "os": "linux",
568            "config": {
569                "Entrypoint": ["/bin/agent"],
570                "Cmd": ["--port", "8080"],
571                "Env": ["PATH=/usr/local/bin:/usr/bin:/bin"],
572                "WorkingDir": "/workspace",
573                "Labels": {
574                    "a3s.type": "agent",
575                    "a3s.version": "1.0.0"
576                },
577                "Healthcheck": {
578                    "Test": ["CMD-SHELL", "test -f /tmp/healthy"],
579                    "Interval": 30000000000,
580                    "Timeout": 1500000000,
581                    "Retries": 2,
582                    "StartPeriod": 5000000000
583                }
584            },
585            "rootfs": {
586                "type": "layers",
587                "diff_ids": ["sha256:layer1hash"]
588            },
589            "history": []
590        }"#;
591        let config_hash = "configabc123";
592        fs::write(path.join("blobs/sha256").join(config_hash), config_content).unwrap();
593
594        // Create layer blob (empty tar.gz for testing)
595        let layer_hash = "layerdef456";
596        create_test_layer(&path.join("blobs/sha256").join(layer_hash));
597
598        // Create manifest blob
599        let manifest_content = format!(
600            r#"{{
601            "schemaVersion": 2,
602            "mediaType": "application/vnd.oci.image.manifest.v1+json",
603            "config": {{
604                "mediaType": "application/vnd.oci.image.config.v1+json",
605                "digest": "sha256:{}",
606                "size": {}
607            }},
608            "layers": [
609                {{
610                    "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
611                    "digest": "sha256:{}",
612                    "size": 100
613                }}
614            ]
615        }}"#,
616            config_hash,
617            config_content.len(),
618            layer_hash
619        );
620        let manifest_hash = "manifestxyz789";
621        fs::write(
622            path.join("blobs/sha256").join(manifest_hash),
623            &manifest_content,
624        )
625        .unwrap();
626
627        // Create index.json
628        let index_content = format!(
629            r#"{{
630            "schemaVersion": 2,
631            "mediaType": "application/vnd.oci.image.index.v1+json",
632            "manifests": [
633                {{
634                    "mediaType": "application/vnd.oci.image.manifest.v1+json",
635                    "digest": "sha256:{}",
636                    "size": {}
637                }}
638            ]
639        }}"#,
640            manifest_hash,
641            manifest_content.len()
642        );
643        fs::write(path.join("index.json"), index_content).unwrap();
644    }
645
646    #[test]
647    fn test_from_oci_config_parses_volumes() {
648        // Directly test OciImageConfig::from_oci_config with volumes
649        let config_json = r#"{
650            "architecture": "amd64",
651            "os": "linux",
652            "config": {
653                "Volumes": {
654                    "/data": {},
655                    "/var/log": {}
656                }
657            },
658            "rootfs": {
659                "type": "layers",
660                "diff_ids": []
661            },
662            "history": []
663        }"#;
664        let oci_config: oci_spec::image::ImageConfiguration =
665            serde_json::from_str(config_json).unwrap();
666        let config = OciImageConfig::from_oci_config(&oci_config, Vec::new());
667        assert_eq!(config.volumes.len(), 2);
668        assert!(config.volumes.contains(&"/data".to_string()));
669        assert!(config.volumes.contains(&"/var/log".to_string()));
670    }
671
672    #[test]
673    fn test_from_oci_config_no_volumes() {
674        let config_json = r#"{
675            "architecture": "amd64",
676            "os": "linux",
677            "config": {},
678            "rootfs": {
679                "type": "layers",
680                "diff_ids": []
681            },
682            "history": []
683        }"#;
684        let oci_config: oci_spec::image::ImageConfiguration =
685            serde_json::from_str(config_json).unwrap();
686        let config = OciImageConfig::from_oci_config(&oci_config, Vec::new());
687        assert!(config.volumes.is_empty());
688    }
689
690    #[test]
691    fn test_parse_health_check_cmd() {
692        let raw = serde_json::json!({
693            "config": {
694                "Healthcheck": {
695                    "Test": ["CMD", "curl", "-f", "http://localhost/"],
696                    "Interval": 30000000000u64,
697                    "Timeout": 5000000000u64,
698                    "Retries": 3u64,
699                    "StartPeriod": 0u64
700                }
701            }
702        });
703
704        let hc = OciImage::parse_health_check_from_raw(&raw).unwrap();
705        assert_eq!(hc.test, vec!["CMD", "curl", "-f", "http://localhost/"]);
706        assert_eq!(hc.interval, Some(30));
707        assert_eq!(hc.timeout, Some(5));
708        assert_eq!(hc.retries, Some(3));
709        assert_eq!(hc.start_period, None);
710    }
711
712    #[test]
713    fn test_parse_health_check_cmd_shell_and_ceil_durations() {
714        let raw = serde_json::json!({
715            "config": {
716                "Healthcheck": {
717                    "Test": ["CMD-SHELL", "wget -qO- http://localhost/health"],
718                    "Interval": 1500000000u64,
719                    "Timeout": "1",
720                    "Retries": "2",
721                    "StartPeriod": 1u64
722                }
723            }
724        });
725
726        let hc = OciImage::parse_health_check_from_raw(&raw).unwrap();
727        assert_eq!(
728            hc.test,
729            vec!["CMD-SHELL", "wget -qO- http://localhost/health"]
730        );
731        assert_eq!(hc.interval, Some(2));
732        assert_eq!(hc.timeout, Some(1));
733        assert_eq!(hc.retries, Some(2));
734        assert_eq!(hc.start_period, Some(1));
735    }
736
737    #[test]
738    fn test_parse_health_check_none_disables() {
739        let raw = serde_json::json!({
740            "config": {
741                "Healthcheck": {
742                    "Test": ["NONE"]
743                }
744            }
745        });
746
747        assert!(OciImage::parse_health_check_from_raw(&raw).is_none());
748    }
749
750    #[test]
751    fn test_load_config_parses_onbuild_triggers() {
752        // Verify that OnBuild entries in the raw OCI config JSON are parsed
753        // and surfaced in OciImageConfig.onbuild (oci-spec 0.6 doesn't model this field).
754        let temp_dir = TempDir::new().unwrap();
755        fs::create_dir_all(temp_dir.path().join("blobs/sha256")).unwrap();
756        fs::write(
757            temp_dir.path().join("oci-layout"),
758            r#"{"imageLayoutVersion":"1.0.0"}"#,
759        )
760        .unwrap();
761
762        let config_content = r#"{
763            "architecture": "amd64",
764            "os": "linux",
765            "config": {
766                "OnBuild": ["RUN echo hello", "COPY . /app"]
767            },
768            "rootfs": {"type": "layers", "diff_ids": []},
769            "history": []
770        }"#;
771        let config_hash = "onbuildcfg001";
772        fs::write(
773            temp_dir.path().join("blobs/sha256").join(config_hash),
774            config_content,
775        )
776        .unwrap();
777
778        let manifest_content = format!(
779            r#"{{"schemaVersion":2,"config":{{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:{}","size":{}}},"layers":[]}}"#,
780            config_hash,
781            config_content.len()
782        );
783        let manifest_hash = "onbuildmfst001";
784        fs::write(
785            temp_dir.path().join("blobs/sha256").join(manifest_hash),
786            &manifest_content,
787        )
788        .unwrap();
789
790        let index_content = format!(
791            r#"{{"schemaVersion":2,"manifests":[{{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:{}","size":{}}}]}}"#,
792            manifest_hash,
793            manifest_content.len()
794        );
795        fs::write(temp_dir.path().join("index.json"), index_content).unwrap();
796
797        let image = OciImage::from_path(temp_dir.path()).unwrap();
798        assert_eq!(
799            image.config().onbuild,
800            vec!["RUN echo hello", "COPY . /app"]
801        );
802    }
803
804    // Helper function to create a test layer (minimal tar.gz)
805    fn create_test_layer(path: &Path) {
806        use flate2::write::GzEncoder;
807        use flate2::Compression;
808        use tar::Builder;
809
810        let file = fs::File::create(path).unwrap();
811        let encoder = GzEncoder::new(file, Compression::default());
812        let mut builder = Builder::new(encoder);
813
814        // Add a simple file
815        let mut header = tar::Header::new_gnu();
816        header.set_size(5);
817        header.set_mode(0o644);
818        header.set_cksum();
819
820        builder
821            .append_data(&mut header, "test.txt", b"hello" as &[u8])
822            .unwrap();
823        builder.finish().unwrap();
824    }
825}