1use a3s_box_core::error::{BoxError, Result};
6use oci_spec::image::{ImageConfiguration, ImageIndex, ImageManifest};
7use std::path::{Path, PathBuf};
8
9#[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#[derive(Debug)]
21pub struct OciImage {
22 root_dir: PathBuf,
24
25 manifest_digest: String,
27
28 config: OciImageConfig,
30
31 layer_paths: Vec<PathBuf>,
33}
34
35#[derive(Debug, Clone)]
37pub struct OciImageConfig {
38 pub entrypoint: Option<Vec<String>>,
40
41 pub cmd: Option<Vec<String>>,
43
44 pub env: Vec<(String, String)>,
46
47 pub working_dir: Option<String>,
49
50 pub user: Option<String>,
52
53 pub exposed_ports: Vec<String>,
55
56 pub labels: std::collections::HashMap<String, String>,
58
59 pub volumes: Vec<String>,
61
62 pub stop_signal: Option<String>,
64
65 pub health_check: Option<OciHealthCheck>,
67
68 pub onbuild: Vec<String>,
70}
71
72impl OciImage {
73 pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
91 let root_dir = path.as_ref().to_path_buf();
92
93 Self::validate_oci_layout(&root_dir)?;
95
96 let index = Self::load_index(&root_dir)?;
98
99 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 let manifest = Self::load_manifest(&root_dir, &manifest_digest)?;
109
110 let config_digest = manifest.config().digest().to_string();
112 let config = Self::load_config(&root_dir, &config_digest)?;
113
114 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 pub fn config(&self) -> &OciImageConfig {
131 &self.config
132 }
133
134 pub fn layer_paths(&self) -> &[PathBuf] {
136 &self.layer_paths
137 }
138
139 pub fn root_dir(&self) -> &Path {
141 &self.root_dir
142 }
143
144 pub fn manifest_digest(&self) -> &str {
146 &self.manifest_digest
147 }
148
149 pub fn entrypoint(&self) -> Option<&[String]> {
153 self.config.entrypoint.as_deref()
154 }
155
156 pub fn cmd(&self) -> Option<&[String]> {
158 self.config.cmd.as_deref()
159 }
160
161 pub fn env(&self) -> &[(String, String)] {
163 &self.config.env
164 }
165
166 pub fn working_dir(&self) -> Option<&str> {
168 self.config.working_dir.as_deref()
169 }
170
171 pub fn label(&self, key: &str) -> Option<&str> {
173 self.config.labels.get(key).map(|s| s.as_str())
174 }
175
176 fn validate_oci_layout(root_dir: &Path) -> Result<()> {
178 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 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 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 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 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 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 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 fn blob_path(root_dir: &Path, digest: &str) -> PathBuf {
272 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 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 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 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 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 let labels = config
381 .as_ref()
382 .and_then(|c| c.labels().clone())
383 .unwrap_or_default();
384
385 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 let stop_signal = config.as_ref().and_then(|c| c.stop_signal().clone());
394
395 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 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 fs::write(
454 temp_dir.path().join("oci-layout"),
455 r#"{"imageLayoutVersion":"1.0.0"}"#,
456 )
457 .unwrap();
458
459 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_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_complete_oci_image(temp_dir.path());
497
498 let image = OciImage::from_path(temp_dir.path()).unwrap();
499
500 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 let env = image.env();
510 assert!(env
511 .iter()
512 .any(|(k, v)| k == "PATH" && v.contains("/usr/bin")));
513
514 assert_eq!(image.label("a3s.type"), Some("agent"));
516
517 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 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 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 fn create_complete_oci_image(path: &Path) {
558 fs::create_dir_all(path.join("blobs/sha256")).unwrap();
560
561 fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
563
564 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 let layer_hash = "layerdef456";
596 create_test_layer(&path.join("blobs/sha256").join(layer_hash));
597
598 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 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 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 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 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 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}