1use 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
18pub(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
35pub(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
121pub(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
163pub(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#[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 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#[derive(Debug)]
310pub struct OciImage {
311 root_dir: PathBuf,
313
314 manifest_digest: String,
316
317 config: OciImageConfig,
319
320 layer_paths: Vec<PathBuf>,
322}
323
324#[derive(Debug, Clone)]
326pub struct OciImageConfig {
327 pub entrypoint: Option<Vec<String>>,
329
330 pub cmd: Option<Vec<String>>,
332
333 pub env: Vec<(String, String)>,
335
336 pub working_dir: Option<String>,
338
339 pub user: Option<String>,
341
342 pub exposed_ports: Vec<String>,
344
345 pub labels: std::collections::HashMap<String, String>,
347
348 pub volumes: Vec<String>,
350
351 pub stop_signal: Option<String>,
353
354 pub health_check: Option<OciHealthCheck>,
356
357 pub onbuild: Vec<String>,
359}
360
361impl OciImage {
362 pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
380 let root_dir = path.as_ref().to_path_buf();
381
382 Self::validate_oci_layout(&root_dir)?;
384
385 let index = Self::load_index(&root_dir)?;
387
388 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 let manifest = Self::load_manifest(&root_dir, manifest_descriptor)?;
398
399 let config = Self::load_config(&root_dir, manifest.config())?;
401
402 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 pub fn config(&self) -> &OciImageConfig {
428 &self.config
429 }
430
431 pub fn layer_paths(&self) -> &[PathBuf] {
433 &self.layer_paths
434 }
435
436 pub fn root_dir(&self) -> &Path {
438 &self.root_dir
439 }
440
441 pub fn manifest_digest(&self) -> &str {
443 &self.manifest_digest
444 }
445
446 pub fn entrypoint(&self) -> Option<&[String]> {
450 self.config.entrypoint.as_deref()
451 }
452
453 pub fn cmd(&self) -> Option<&[String]> {
455 self.config.cmd.as_deref()
456 }
457
458 pub fn env(&self) -> &[(String, String)] {
460 &self.config.env
461 }
462
463 pub fn working_dir(&self) -> Option<&str> {
465 self.config.working_dir.as_deref()
466 }
467
468 pub fn label(&self, key: &str) -> Option<&str> {
470 self.config.labels.get(key).map(|s| s.as_str())
471 }
472
473 fn validate_oci_layout(root_dir: &Path) -> Result<()> {
475 validate_plain_directory(root_dir, "OCI image root")?;
476
477 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 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 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 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 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 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 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 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 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 let labels = config
644 .as_ref()
645 .and_then(|c| c.labels().clone())
646 .unwrap_or_default();
647
648 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 let stop_signal = config.as_ref().and_then(|c| c.stop_signal().clone());
657
658 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 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 fs::write(
717 temp_dir.path().join("oci-layout"),
718 r#"{"imageLayoutVersion":"1.0.0"}"#,
719 )
720 .unwrap();
721
722 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_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_complete_oci_image(temp_dir.path());
768
769 let image = OciImage::from_path(temp_dir.path()).unwrap();
770
771 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 let env = image.env();
781 assert!(env
782 .iter()
783 .any(|(k, v)| k == "PATH" && v.contains("/usr/bin")));
784
785 assert_eq!(image.label("a3s.type"), Some("agent"));
787
788 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 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 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 fn create_complete_oci_image(path: &Path) -> CompleteLayout {
1009 fs::create_dir_all(path.join("blobs/sha256")).unwrap();
1011
1012 fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
1014
1015 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 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 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 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 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 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 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 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}