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