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, Copy)]
349#[cfg(any(target_os = "macos", all(unix, test)))]
350pub(crate) struct OciLayerBlob<'a> {
351 pub(crate) path: &'a Path,
352 pub(crate) digest: &'a str,
353 pub(crate) size: u64,
354}
355
356#[derive(Debug, Clone)]
358pub struct OciImageConfig {
359 pub entrypoint: Option<Vec<String>>,
361
362 pub cmd: Option<Vec<String>>,
364
365 pub env: Vec<(String, String)>,
367
368 pub working_dir: Option<String>,
370
371 pub user: Option<String>,
373
374 pub exposed_ports: Vec<String>,
376
377 pub labels: std::collections::HashMap<String, String>,
379
380 pub volumes: Vec<String>,
382
383 pub stop_signal: Option<String>,
385
386 pub health_check: Option<OciHealthCheck>,
388
389 pub onbuild: Vec<String>,
391}
392
393impl OciImage {
394 pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
412 let root_dir = path.as_ref().to_path_buf();
413
414 Self::validate_oci_layout(&root_dir)?;
416
417 let index = Self::load_index(&root_dir)?;
419
420 let manifest_descriptor = index
423 .manifests()
424 .first()
425 .cloned()
426 .ok_or_else(|| BoxError::OciImageError("No manifests in index.json".to_string()))?;
427
428 Self::from_validated_manifest_descriptor(root_dir, manifest_descriptor)
429 }
430
431 pub(crate) fn from_manifest_descriptor(
434 path: impl AsRef<Path>,
435 manifest_descriptor: Descriptor,
436 ) -> Result<Self> {
437 let root_dir = path.as_ref().to_path_buf();
438 Self::validate_oci_layout(&root_dir)?;
439 Self::from_validated_manifest_descriptor(root_dir, manifest_descriptor)
440 }
441
442 fn from_validated_manifest_descriptor(
443 root_dir: PathBuf,
444 manifest_descriptor: Descriptor,
445 ) -> Result<Self> {
446 let manifest = Self::load_manifest(&root_dir, &manifest_descriptor)?;
448
449 let (config, platform) = Self::load_config(&root_dir, manifest.config())?;
451
452 let layer_paths = manifest
455 .layers()
456 .iter()
457 .map(|layer| {
458 let (digest, size) = typed_descriptor_contract(layer, "layer blob")?;
459 verify_oci_blob_file(
460 &root_dir,
461 &digest,
462 size,
463 MAX_OCI_LAYER_BLOB_BYTES,
464 "layer blob",
465 )
466 })
467 .collect::<Result<Vec<_>>>()?;
468
469 let mut content_descriptors = Vec::with_capacity(manifest.layers().len() + 2);
470 content_descriptors.push(manifest_descriptor.clone());
471 content_descriptors.push(manifest.config().clone());
472 content_descriptors.extend(manifest.layers().iter().cloned());
473
474 Ok(Self {
475 root_dir,
476 manifest_descriptor,
477 manifest,
478 content_descriptors,
479 platform,
480 config,
481 layer_paths,
482 })
483 }
484
485 pub fn config(&self) -> &OciImageConfig {
487 &self.config
488 }
489
490 pub fn layer_paths(&self) -> &[PathBuf] {
492 &self.layer_paths
493 }
494
495 #[cfg(any(target_os = "macos", all(unix, test)))]
497 pub(crate) fn layer_blobs(&self) -> impl ExactSizeIterator<Item = OciLayerBlob<'_>> + '_ {
498 self.layer_paths
499 .iter()
500 .zip(self.manifest.layers())
501 .map(|(path, descriptor)| OciLayerBlob {
502 path,
503 digest: descriptor.digest().as_ref(),
504 size: descriptor.size(),
505 })
506 }
507
508 pub fn root_dir(&self) -> &Path {
510 &self.root_dir
511 }
512
513 pub fn manifest_digest(&self) -> &str {
515 self.manifest_descriptor.digest().as_ref()
516 }
517
518 pub(crate) fn manifest_descriptor(&self) -> &Descriptor {
520 &self.manifest_descriptor
521 }
522
523 pub(crate) fn manifest(&self) -> &ImageManifest {
525 &self.manifest
526 }
527
528 pub(crate) fn content_descriptors(&self) -> &[Descriptor] {
530 &self.content_descriptors
531 }
532
533 pub(crate) fn platform(&self) -> &BoxPlatform {
535 &self.platform
536 }
537
538 pub fn entrypoint(&self) -> Option<&[String]> {
542 self.config.entrypoint.as_deref()
543 }
544
545 pub fn cmd(&self) -> Option<&[String]> {
547 self.config.cmd.as_deref()
548 }
549
550 pub fn env(&self) -> &[(String, String)] {
552 &self.config.env
553 }
554
555 pub fn working_dir(&self) -> Option<&str> {
557 self.config.working_dir.as_deref()
558 }
559
560 pub fn label(&self, key: &str) -> Option<&str> {
562 self.config.labels.get(key).map(|s| s.as_str())
563 }
564
565 fn validate_oci_layout(root_dir: &Path) -> Result<()> {
567 validate_plain_directory(root_dir, "OCI image root")?;
568
569 let oci_layout_path = root_dir.join("oci-layout");
572 read_regular_file_bounded(&oci_layout_path, MAX_OCI_LAYOUT_BYTES, "oci-layout")?;
573
574 let index_path = root_dir.join("index.json");
575 read_regular_file_bounded(&index_path, MAX_OCI_INDEX_BYTES, "index.json")?;
576
577 let blobs_dir = root_dir.join("blobs");
578 validate_plain_directory(&blobs_dir, "OCI blobs")?;
579 validate_plain_directory(&blobs_dir.join("sha256"), "OCI sha256 blobs")?;
580
581 Ok(())
582 }
583
584 pub(crate) fn load_index(root_dir: &Path) -> Result<ImageIndex> {
586 let index_path = root_dir.join("index.json");
587 let content = read_regular_file_bounded(&index_path, MAX_OCI_INDEX_BYTES, "index.json")?;
588
589 serde_json::from_slice(&content)
590 .map_err(|e| BoxError::OciImageError(format!("Failed to parse index.json: {}", e)))
591 }
592
593 pub(crate) fn load_index_blob(root_dir: &Path, descriptor: &Descriptor) -> Result<ImageIndex> {
595 let (digest, size) = typed_descriptor_contract(descriptor, "image-index blob")?;
596 let content = read_verified_oci_blob(
597 root_dir,
598 &digest,
599 size,
600 MAX_OCI_INDEX_BYTES,
601 "image-index blob",
602 )?;
603 serde_json::from_slice(&content).map_err(|error| {
604 BoxError::OciImageError(format!("Failed to parse image-index blob: {error}"))
605 })
606 }
607
608 fn load_manifest(root_dir: &Path, descriptor: &Descriptor) -> Result<ImageManifest> {
610 let (digest, size) = typed_descriptor_contract(descriptor, "manifest blob")?;
611 let content = read_verified_oci_blob(
612 root_dir,
613 &digest,
614 size,
615 MAX_OCI_MANIFEST_BYTES,
616 "manifest blob",
617 )?;
618
619 serde_json::from_slice(&content)
620 .map_err(|e| BoxError::OciImageError(format!("Failed to parse manifest: {}", e)))
621 }
622
623 fn load_config(
625 root_dir: &Path,
626 descriptor: &Descriptor,
627 ) -> Result<(OciImageConfig, BoxPlatform)> {
628 let (digest, size) = typed_descriptor_contract(descriptor, "config blob")?;
629 let content =
630 read_verified_oci_blob(root_dir, &digest, size, MAX_OCI_CONFIG_BYTES, "config blob")?;
631
632 let oci_config: ImageConfiguration = serde_json::from_slice(&content)
633 .map_err(|e| BoxError::OciImageError(format!("Failed to parse config: {}", e)))?;
634
635 let raw_config: serde_json::Value = serde_json::from_slice(&content)
636 .map_err(|e| BoxError::OciImageError(format!("Failed to parse config JSON: {}", e)))?;
637
638 let onbuild: Vec<String> = raw_config
641 .get("config")
642 .and_then(|c| c.get("OnBuild"))
643 .cloned()
644 .and_then(|v| serde_json::from_value(v).ok())
645 .unwrap_or_default();
646 let health_check = Self::parse_health_check_from_raw(&raw_config);
647
648 let platform = BoxPlatform {
649 os: oci_config.os().to_string(),
650 architecture: oci_config.architecture().to_string(),
651 variant: oci_config.variant().clone(),
652 };
653 let mut config = OciImageConfig::from_oci_config(&oci_config, onbuild);
654 config.health_check = health_check;
655 Ok((config, platform))
656 }
657
658 fn parse_health_check_from_raw(raw_config: &serde_json::Value) -> Option<OciHealthCheck> {
660 let health = raw_config
661 .get("config")
662 .and_then(|c| c.get("Healthcheck").or_else(|| c.get("healthcheck")))?;
663
664 let test = health.get("Test").or_else(|| health.get("test"))?;
665 let test: Vec<String> = serde_json::from_value(test.clone()).ok()?;
666 if test.is_empty() {
667 return None;
668 }
669
670 if test
671 .first()
672 .is_some_and(|marker| marker.eq_ignore_ascii_case("NONE"))
673 {
674 return None;
675 }
676
677 Some(OciHealthCheck {
678 test,
679 interval: health
680 .get("Interval")
681 .or_else(|| health.get("interval"))
682 .and_then(duration_seconds_from_json),
683 timeout: health
684 .get("Timeout")
685 .or_else(|| health.get("timeout"))
686 .and_then(duration_seconds_from_json),
687 retries: health
688 .get("Retries")
689 .or_else(|| health.get("retries"))
690 .and_then(u32_from_json)
691 .filter(|value| *value > 0),
692 start_period: health
693 .get("StartPeriod")
694 .or_else(|| health.get("start_period"))
695 .and_then(duration_seconds_from_json),
696 })
697 }
698}
699
700fn duration_seconds_from_json(value: &serde_json::Value) -> Option<u64> {
701 let nanos = u64_from_json(value)?;
702 if nanos == 0 {
703 return None;
704 }
705 Some(nanos.div_ceil(1_000_000_000).max(1))
706}
707
708fn u64_from_json(value: &serde_json::Value) -> Option<u64> {
709 value
710 .as_u64()
711 .or_else(|| value.as_str().and_then(|s| s.parse::<u64>().ok()))
712}
713
714fn u32_from_json(value: &serde_json::Value) -> Option<u32> {
715 u64_from_json(value).and_then(|value| u32::try_from(value).ok())
716}
717
718impl OciImageConfig {
719 fn from_oci_config(oci_config: &ImageConfiguration, onbuild: Vec<String>) -> Self {
721 let config = oci_config.config();
722
723 let entrypoint = config.as_ref().and_then(|c| c.entrypoint().clone());
724 let cmd = config.as_ref().and_then(|c| c.cmd().clone());
725 let working_dir = config.as_ref().and_then(|c| c.working_dir().clone());
726 let user = config.as_ref().and_then(|c| c.user().clone());
727
728 let env = config
730 .as_ref()
731 .and_then(|c| c.env().as_ref())
732 .map(|env_list| {
733 env_list
734 .iter()
735 .filter_map(|e| {
736 let parts: Vec<&str> = e.splitn(2, '=').collect();
737 if parts.len() == 2 {
738 Some((parts[0].to_string(), parts[1].to_string()))
739 } else {
740 None
741 }
742 })
743 .collect()
744 })
745 .unwrap_or_default();
746
747 let exposed_ports = config
749 .as_ref()
750 .and_then(|c| c.exposed_ports().as_ref())
751 .map(|ports| ports.to_vec())
752 .unwrap_or_default();
753
754 let labels = config
756 .as_ref()
757 .and_then(|c| c.labels().clone())
758 .unwrap_or_default();
759
760 let volumes = config
762 .as_ref()
763 .and_then(|c| c.volumes().as_ref())
764 .map(|vols| vols.to_vec())
765 .unwrap_or_default();
766
767 let stop_signal = config.as_ref().and_then(|c| c.stop_signal().clone());
769
770 let health_check = None;
773
774 Self {
775 entrypoint,
776 cmd,
777 env,
778 working_dir,
779 user,
780 exposed_ports,
781 labels,
782 volumes,
783 stop_signal,
784 health_check,
785 onbuild,
786 }
787 }
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793 use std::fs;
794 use tempfile::TempDir;
795
796 #[test]
797 fn test_validate_oci_layout_missing_oci_layout_file() {
798 let temp_dir = TempDir::new().unwrap();
799
800 let result = OciImage::validate_oci_layout(temp_dir.path());
801
802 assert!(result.is_err());
803 assert!(result.unwrap_err().to_string().contains("oci-layout"));
804 }
805
806 #[test]
807 fn test_validate_oci_layout_missing_index_json() {
808 let temp_dir = TempDir::new().unwrap();
809
810 fs::write(
812 temp_dir.path().join("oci-layout"),
813 r#"{"imageLayoutVersion":"1.0.0"}"#,
814 )
815 .unwrap();
816
817 let result = OciImage::validate_oci_layout(temp_dir.path());
818
819 assert!(result.is_err());
820 assert!(result.unwrap_err().to_string().contains("index.json"));
821 }
822
823 #[test]
824 fn test_validate_oci_layout_missing_blobs() {
825 let temp_dir = TempDir::new().unwrap();
826
827 fs::write(
829 temp_dir.path().join("oci-layout"),
830 r#"{"imageLayoutVersion":"1.0.0"}"#,
831 )
832 .unwrap();
833
834 fs::write(temp_dir.path().join("index.json"), "{}").unwrap();
836
837 let result = OciImage::validate_oci_layout(temp_dir.path());
838
839 assert!(result.is_err());
840 assert!(result.unwrap_err().to_string().contains("blobs"));
841 }
842
843 #[test]
844 fn test_validate_oci_layout_valid() {
845 let temp_dir = TempDir::new().unwrap();
846
847 create_minimal_oci_layout(temp_dir.path());
849
850 let result = OciImage::validate_oci_layout(temp_dir.path());
851
852 assert!(result.is_ok());
853 }
854
855 #[test]
856 fn test_blob_path() {
857 let root = PathBuf::from("/images/test");
858 let digest = format!("sha256:{}", "a".repeat(64));
859
860 let path = blob_path(&root, &digest).unwrap();
861 assert_eq!(
862 path,
863 PathBuf::from(format!("/images/test/blobs/sha256/{}", "a".repeat(64)))
864 );
865
866 assert!(blob_path(&root, "abc123").is_err());
867 assert!(blob_path(
868 &root,
869 "sha256:../../../../../../../../windows/system32/drivers/etc/hosts"
870 )
871 .is_err());
872 }
873
874 #[test]
875 fn test_from_path_valid_image() {
876 let temp_dir = TempDir::new().unwrap();
877
878 create_complete_oci_image(temp_dir.path());
880
881 let image = OciImage::from_path(temp_dir.path()).unwrap();
882
883 assert_eq!(image.entrypoint(), Some(&["/bin/agent".to_string()][..]));
885 assert_eq!(
886 image.cmd(),
887 Some(&["--port".to_string(), "8080".to_string()][..])
888 );
889 assert_eq!(image.working_dir(), Some("/workspace"));
890
891 let env = image.env();
893 assert!(env
894 .iter()
895 .any(|(k, v)| k == "PATH" && v.contains("/usr/bin")));
896
897 assert_eq!(image.label("a3s.type"), Some("agent"));
899
900 let health_check = image.config().health_check.as_ref().unwrap();
902 assert_eq!(
903 health_check.test,
904 vec!["CMD-SHELL".to_string(), "test -f /tmp/healthy".to_string()]
905 );
906 assert_eq!(health_check.interval, Some(30));
907 assert_eq!(health_check.timeout, Some(2));
908 assert_eq!(health_check.retries, Some(2));
909 assert_eq!(health_check.start_period, Some(5));
910
911 assert_eq!(image.layer_paths().len(), 1);
913 }
914
915 #[test]
916 fn test_from_path_exposes_manifest_digest() {
917 let temp_dir = TempDir::new().unwrap();
918 let layout = create_complete_oci_image(temp_dir.path());
919 let image = OciImage::from_path(temp_dir.path()).unwrap();
920 assert_eq!(image.manifest_digest(), layout.manifest_digest);
921 }
922
923 #[test]
924 fn test_from_path_nonexistent() {
925 let result = OciImage::from_path("/nonexistent/path");
926
927 assert!(result.is_err());
928 }
929
930 #[test]
931 fn test_from_path_rejects_oversized_index() {
932 let temp_dir = TempDir::new().unwrap();
933 create_minimal_oci_layout(temp_dir.path());
934 fs::File::create(temp_dir.path().join("index.json"))
935 .unwrap()
936 .set_len(MAX_OCI_INDEX_BYTES + 1)
937 .unwrap();
938
939 let error = OciImage::from_path(temp_dir.path()).unwrap_err();
940 assert!(error.to_string().contains("limit"), "{error}");
941 }
942
943 #[test]
944 fn test_from_path_rejects_blob_digest_mismatch() {
945 let temp_dir = TempDir::new().unwrap();
946 let layout = create_complete_oci_image(temp_dir.path());
947 let config_path = temp_dir
948 .path()
949 .join("blobs/sha256")
950 .join(digest_hex(&layout.config_digest));
951 let mut content = fs::read(&config_path).unwrap();
952 content[0] ^= 1;
953 fs::write(config_path, content).unwrap();
954
955 let error = OciImage::from_path(temp_dir.path()).unwrap_err();
956 assert!(error.to_string().contains("digest"), "{error}");
957 }
958
959 #[test]
960 fn test_from_path_rejects_noncanonical_manifest_digest() {
961 let temp_dir = TempDir::new().unwrap();
962 create_minimal_oci_layout(temp_dir.path());
963 fs::write(
964 temp_dir.path().join("index.json"),
965 r#"{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:../../../../outside","size":0}]}"#,
966 )
967 .unwrap();
968
969 assert!(OciImage::from_path(temp_dir.path()).is_err());
970 }
971
972 #[test]
973 fn test_from_path_rejects_index_symlink() {
974 let temp_dir = TempDir::new().unwrap();
975 let layout = temp_dir.path().join("layout");
976 fs::create_dir_all(layout.join("blobs/sha256")).unwrap();
977 fs::write(
978 layout.join("oci-layout"),
979 r#"{"imageLayoutVersion":"1.0.0"}"#,
980 )
981 .unwrap();
982 let outside = temp_dir.path().join("outside-index.json");
983 fs::write(&outside, "{}").unwrap();
984 if !symlink_file_for_test(&outside, &layout.join("index.json")) {
985 return;
986 }
987
988 let error = OciImage::from_path(&layout).unwrap_err();
989 assert!(error.to_string().contains("index.json"), "{error}");
990 }
991
992 #[test]
993 fn test_from_path_rejects_layer_blob_symlink() {
994 let temp_dir = TempDir::new().unwrap();
995 let layout = create_complete_oci_image(temp_dir.path());
996 let layer_path = temp_dir
997 .path()
998 .join("blobs/sha256")
999 .join(digest_hex(&layout.layer_digest));
1000 let layer_content = fs::read(&layer_path).unwrap();
1001 let outside = temp_dir.path().join("outside-layer.tar.gz");
1002 fs::write(&outside, layer_content).unwrap();
1003 fs::remove_file(&layer_path).unwrap();
1004 if !symlink_file_for_test(&outside, &layer_path) {
1005 return;
1006 }
1007
1008 let error = OciImage::from_path(temp_dir.path()).unwrap_err();
1009 assert!(error.to_string().contains("layer blob"), "{error}");
1010 }
1011
1012 #[test]
1013 fn test_validate_layout_rejects_reparse_blobs_directory() {
1014 let temp_dir = TempDir::new().unwrap();
1015 let layout = temp_dir.path().join("layout");
1016 let outside_blobs = temp_dir.path().join("outside-blobs");
1017 fs::create_dir_all(&layout).unwrap();
1018 fs::create_dir_all(outside_blobs.join("sha256")).unwrap();
1019 fs::write(
1020 layout.join("oci-layout"),
1021 r#"{"imageLayoutVersion":"1.0.0"}"#,
1022 )
1023 .unwrap();
1024 fs::write(layout.join("index.json"), "{}").unwrap();
1025 if !symlink_dir_for_test(&outside_blobs, &layout.join("blobs")) {
1026 return;
1027 }
1028
1029 let error = OciImage::validate_oci_layout(&layout).unwrap_err();
1030 assert!(error.to_string().contains("plain directory"), "{error}");
1031 }
1032
1033 #[test]
1034 fn test_from_path_rejects_reparse_root_directory() {
1035 let temp_dir = TempDir::new().unwrap();
1036 let target = temp_dir.path().join("target-layout");
1037 fs::create_dir_all(&target).unwrap();
1038 create_complete_oci_image(&target);
1039 let linked_root = temp_dir.path().join("linked-layout");
1040 if !symlink_dir_for_test(&target, &linked_root) {
1041 return;
1042 }
1043
1044 let error = OciImage::from_path(linked_root).unwrap_err();
1045 assert!(error.to_string().contains("plain directory"), "{error}");
1046 }
1047
1048 #[cfg(unix)]
1049 fn symlink_file_for_test(target: &Path, link: &Path) -> bool {
1050 std::os::unix::fs::symlink(target, link).unwrap();
1051 true
1052 }
1053
1054 #[cfg(windows)]
1055 fn symlink_file_for_test(target: &Path, link: &Path) -> bool {
1056 windows_symlink_for_test(|| std::os::windows::fs::symlink_file(target, link))
1057 }
1058
1059 #[cfg(not(any(unix, windows)))]
1060 fn symlink_file_for_test(_target: &Path, _link: &Path) -> bool {
1061 false
1062 }
1063
1064 #[cfg(unix)]
1065 fn symlink_dir_for_test(target: &Path, link: &Path) -> bool {
1066 std::os::unix::fs::symlink(target, link).unwrap();
1067 true
1068 }
1069
1070 #[cfg(windows)]
1071 fn symlink_dir_for_test(target: &Path, link: &Path) -> bool {
1072 windows_symlink_for_test(|| std::os::windows::fs::symlink_dir(target, link))
1073 }
1074
1075 #[cfg(not(any(unix, windows)))]
1076 fn symlink_dir_for_test(_target: &Path, _link: &Path) -> bool {
1077 false
1078 }
1079
1080 #[cfg(windows)]
1081 fn windows_symlink_for_test(create: impl FnOnce() -> std::io::Result<()>) -> bool {
1082 match create() {
1083 Ok(()) => true,
1084 Err(error) if error.raw_os_error() == Some(1314) => false,
1085 Err(error) => panic!("failed to create Windows test symlink: {error}"),
1086 }
1087 }
1088
1089 fn create_minimal_oci_layout(path: &Path) {
1091 fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
1092
1093 fs::write(path.join("index.json"), "{}").unwrap();
1094
1095 fs::create_dir_all(path.join("blobs/sha256")).unwrap();
1096 }
1097
1098 #[derive(Debug)]
1099 struct CompleteLayout {
1100 manifest_digest: String,
1101 config_digest: String,
1102 layer_digest: String,
1103 }
1104
1105 fn sha256_digest(bytes: &[u8]) -> String {
1106 format!("sha256:{:x}", Sha256::digest(bytes))
1107 }
1108
1109 fn digest_hex(digest: &str) -> &str {
1110 digest.strip_prefix("sha256:").unwrap()
1111 }
1112
1113 fn write_blob(path: &Path, bytes: &[u8]) -> String {
1114 let digest = sha256_digest(bytes);
1115 fs::write(path.join("blobs/sha256").join(digest_hex(&digest)), bytes).unwrap();
1116 digest
1117 }
1118
1119 fn create_complete_oci_image(path: &Path) -> CompleteLayout {
1121 fs::create_dir_all(path.join("blobs/sha256")).unwrap();
1123
1124 fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
1126
1127 let config_content = r#"{
1129 "architecture": "amd64",
1130 "os": "linux",
1131 "config": {
1132 "Entrypoint": ["/bin/agent"],
1133 "Cmd": ["--port", "8080"],
1134 "Env": ["PATH=/usr/local/bin:/usr/bin:/bin"],
1135 "WorkingDir": "/workspace",
1136 "Labels": {
1137 "a3s.type": "agent",
1138 "a3s.version": "1.0.0"
1139 },
1140 "Healthcheck": {
1141 "Test": ["CMD-SHELL", "test -f /tmp/healthy"],
1142 "Interval": 30000000000,
1143 "Timeout": 1500000000,
1144 "Retries": 2,
1145 "StartPeriod": 5000000000
1146 }
1147 },
1148 "rootfs": {
1149 "type": "layers",
1150 "diff_ids": ["sha256:0000000000000000000000000000000000000000000000000000000000000000"]
1151 },
1152 "history": []
1153 }"#;
1154 let config_digest = write_blob(path, config_content.as_bytes());
1155
1156 let layer_build_path = path.join("fixture-layer.tar.gz");
1158 create_test_layer(&layer_build_path);
1159 let layer_content = fs::read(&layer_build_path).unwrap();
1160 fs::remove_file(layer_build_path).unwrap();
1161 let layer_digest = write_blob(path, &layer_content);
1162
1163 let manifest_content = format!(
1165 r#"{{
1166 "schemaVersion": 2,
1167 "mediaType": "application/vnd.oci.image.manifest.v1+json",
1168 "config": {{
1169 "mediaType": "application/vnd.oci.image.config.v1+json",
1170 "digest": "{}",
1171 "size": {}
1172 }},
1173 "layers": [
1174 {{
1175 "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1176 "digest": "{}",
1177 "size": {}
1178 }}
1179 ]
1180 }}"#,
1181 config_digest,
1182 config_content.len(),
1183 layer_digest,
1184 layer_content.len()
1185 );
1186 let manifest_digest = write_blob(path, manifest_content.as_bytes());
1187
1188 let index_content = format!(
1190 r#"{{
1191 "schemaVersion": 2,
1192 "mediaType": "application/vnd.oci.image.index.v1+json",
1193 "manifests": [
1194 {{
1195 "mediaType": "application/vnd.oci.image.manifest.v1+json",
1196 "digest": "{}",
1197 "size": {}
1198 }}
1199 ]
1200 }}"#,
1201 manifest_digest,
1202 manifest_content.len()
1203 );
1204 fs::write(path.join("index.json"), index_content).unwrap();
1205
1206 CompleteLayout {
1207 manifest_digest,
1208 config_digest,
1209 layer_digest,
1210 }
1211 }
1212
1213 #[test]
1214 fn test_from_oci_config_parses_volumes() {
1215 let config_json = r#"{
1217 "architecture": "amd64",
1218 "os": "linux",
1219 "config": {
1220 "Volumes": {
1221 "/data": {},
1222 "/var/log": {}
1223 }
1224 },
1225 "rootfs": {
1226 "type": "layers",
1227 "diff_ids": []
1228 },
1229 "history": []
1230 }"#;
1231 let oci_config: oci_spec::image::ImageConfiguration =
1232 serde_json::from_str(config_json).unwrap();
1233 let config = OciImageConfig::from_oci_config(&oci_config, Vec::new());
1234 assert_eq!(config.volumes.len(), 2);
1235 assert!(config.volumes.contains(&"/data".to_string()));
1236 assert!(config.volumes.contains(&"/var/log".to_string()));
1237 }
1238
1239 #[test]
1240 fn test_from_oci_config_no_volumes() {
1241 let config_json = r#"{
1242 "architecture": "amd64",
1243 "os": "linux",
1244 "config": {},
1245 "rootfs": {
1246 "type": "layers",
1247 "diff_ids": []
1248 },
1249 "history": []
1250 }"#;
1251 let oci_config: oci_spec::image::ImageConfiguration =
1252 serde_json::from_str(config_json).unwrap();
1253 let config = OciImageConfig::from_oci_config(&oci_config, Vec::new());
1254 assert!(config.volumes.is_empty());
1255 }
1256
1257 #[test]
1258 fn test_parse_health_check_cmd() {
1259 let raw = serde_json::json!({
1260 "config": {
1261 "Healthcheck": {
1262 "Test": ["CMD", "curl", "-f", "http://localhost/"],
1263 "Interval": 30000000000u64,
1264 "Timeout": 5000000000u64,
1265 "Retries": 3u64,
1266 "StartPeriod": 0u64
1267 }
1268 }
1269 });
1270
1271 let hc = OciImage::parse_health_check_from_raw(&raw).unwrap();
1272 assert_eq!(hc.test, vec!["CMD", "curl", "-f", "http://localhost/"]);
1273 assert_eq!(hc.interval, Some(30));
1274 assert_eq!(hc.timeout, Some(5));
1275 assert_eq!(hc.retries, Some(3));
1276 assert_eq!(hc.start_period, None);
1277 }
1278
1279 #[test]
1280 fn test_parse_health_check_cmd_shell_and_ceil_durations() {
1281 let raw = serde_json::json!({
1282 "config": {
1283 "Healthcheck": {
1284 "Test": ["CMD-SHELL", "wget -qO- http://localhost/health"],
1285 "Interval": 1500000000u64,
1286 "Timeout": "1",
1287 "Retries": "2",
1288 "StartPeriod": 1u64
1289 }
1290 }
1291 });
1292
1293 let hc = OciImage::parse_health_check_from_raw(&raw).unwrap();
1294 assert_eq!(
1295 hc.test,
1296 vec!["CMD-SHELL", "wget -qO- http://localhost/health"]
1297 );
1298 assert_eq!(hc.interval, Some(2));
1299 assert_eq!(hc.timeout, Some(1));
1300 assert_eq!(hc.retries, Some(2));
1301 assert_eq!(hc.start_period, Some(1));
1302 }
1303
1304 #[test]
1305 fn test_parse_health_check_none_disables() {
1306 let raw = serde_json::json!({
1307 "config": {
1308 "Healthcheck": {
1309 "Test": ["NONE"]
1310 }
1311 }
1312 });
1313
1314 assert!(OciImage::parse_health_check_from_raw(&raw).is_none());
1315 }
1316
1317 #[test]
1318 fn health_check_enabled_semantics_match_docker_forms() {
1319 let health_check = |test: &[&str]| OciHealthCheck {
1320 test: test.iter().map(|part| (*part).to_string()).collect(),
1321 interval: None,
1322 timeout: None,
1323 retries: None,
1324 start_period: None,
1325 };
1326
1327 assert!(health_check(&["CMD", "/bin/true"]).is_enabled());
1328 assert!(health_check(&["CMD-SHELL", "test -f /ready"]).is_enabled());
1329 assert!(health_check(&["future-form", "probe"]).is_enabled());
1330 assert!(!health_check(&[]).is_enabled());
1331 assert!(!health_check(&["NONE"]).is_enabled());
1332 assert!(!health_check(&["none", "ignored"]).is_enabled());
1333 assert!(!health_check(&["CMD"]).is_enabled());
1334 assert!(!health_check(&["CMD-SHELL", " "]).is_enabled());
1335 }
1336
1337 #[test]
1338 fn test_load_config_parses_onbuild_triggers() {
1339 let temp_dir = TempDir::new().unwrap();
1342 fs::create_dir_all(temp_dir.path().join("blobs/sha256")).unwrap();
1343 fs::write(
1344 temp_dir.path().join("oci-layout"),
1345 r#"{"imageLayoutVersion":"1.0.0"}"#,
1346 )
1347 .unwrap();
1348
1349 let config_content = r#"{
1350 "architecture": "amd64",
1351 "os": "linux",
1352 "config": {
1353 "OnBuild": ["RUN echo hello", "COPY . /app"]
1354 },
1355 "rootfs": {"type": "layers", "diff_ids": []},
1356 "history": []
1357 }"#;
1358 let config_digest = write_blob(temp_dir.path(), config_content.as_bytes());
1359
1360 let manifest_content = format!(
1361 r#"{{"schemaVersion":2,"config":{{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"{}","size":{}}},"layers":[]}}"#,
1362 config_digest,
1363 config_content.len()
1364 );
1365 let manifest_digest = write_blob(temp_dir.path(), manifest_content.as_bytes());
1366
1367 let index_content = format!(
1368 r#"{{"schemaVersion":2,"manifests":[{{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"{}","size":{}}}]}}"#,
1369 manifest_digest,
1370 manifest_content.len()
1371 );
1372 fs::write(temp_dir.path().join("index.json"), index_content).unwrap();
1373
1374 let image = OciImage::from_path(temp_dir.path()).unwrap();
1375 assert_eq!(
1376 image.config().onbuild,
1377 vec!["RUN echo hello", "COPY . /app"]
1378 );
1379 }
1380
1381 fn create_test_layer(path: &Path) {
1383 use flate2::write::GzEncoder;
1384 use flate2::Compression;
1385 use tar::Builder;
1386
1387 let file = fs::File::create(path).unwrap();
1388 let encoder = GzEncoder::new(file, Compression::default());
1389 let mut builder = Builder::new(encoder);
1390
1391 let mut header = tar::Header::new_gnu();
1393 header.set_size(5);
1394 header.set_mode(0o644);
1395 header.set_cksum();
1396
1397 builder
1398 .append_data(&mut header, "test.txt", b"hello" as &[u8])
1399 .unwrap();
1400 builder.finish().unwrap();
1401 }
1402}