1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use a3s_box_core::error::{BoxError, Result};
10use sha2::{Digest, Sha256};
11
12#[derive(Debug, Clone, PartialEq)]
14pub struct FileEntry {
15 pub path: PathBuf,
17 pub size: u64,
19 pub mtime: i64,
21 pub mode: u32,
25 pub uid: u32,
28 pub gid: u32,
30 pub is_dir: bool,
32}
33
34#[derive(Debug, Clone)]
36pub struct DirSnapshot {
37 pub entries: HashMap<PathBuf, FileEntry>,
39}
40
41impl DirSnapshot {
42 pub fn capture(root: &Path) -> Result<Self> {
44 let mut entries = HashMap::new();
45 walk_dir(root, root, &mut entries)?;
46 Ok(DirSnapshot { entries })
47 }
48
49 pub fn diff(&self, after: &DirSnapshot) -> Vec<PathBuf> {
53 let mut changed = Vec::new();
54
55 for (path, after_entry) in &after.entries {
56 match self.entries.get(path) {
57 None => {
58 changed.push(path.clone());
60 }
61 Some(before_entry) => {
62 if before_entry.size != after_entry.size
66 || before_entry.mtime != after_entry.mtime
67 || before_entry.mode != after_entry.mode
68 || before_entry.uid != after_entry.uid
69 || before_entry.gid != after_entry.gid
70 {
71 changed.push(path.clone());
72 }
73 }
74 }
75 }
76
77 changed.sort();
79 changed
80 }
81
82 pub fn deletions(&self, after: &DirSnapshot) -> Vec<PathBuf> {
88 let mut deleted = Vec::new();
89
90 for path in self.entries.keys() {
91 if after.entries.contains_key(path) {
92 continue; }
94 let parent_present = match path.parent() {
97 Some(parent) if !parent.as_os_str().is_empty() => {
98 after.entries.contains_key(parent)
99 }
100 _ => true, };
102 if parent_present {
103 deleted.push(path.clone());
104 }
105 }
106
107 deleted.sort();
109 deleted
110 }
111}
112
113fn walk_dir(root: &Path, current: &Path, entries: &mut HashMap<PathBuf, FileEntry>) -> Result<()> {
115 let read_dir = std::fs::read_dir(current).map_err(|e| {
116 BoxError::BuildError(format!(
117 "Failed to read directory {}: {}",
118 current.display(),
119 e
120 ))
121 })?;
122
123 for entry in read_dir {
124 let entry = entry
125 .map_err(|e| BoxError::BuildError(format!("Failed to read directory entry: {}", e)))?;
126
127 let path = entry.path();
128 let relative = path
129 .strip_prefix(root)
130 .map_err(|e| {
131 BoxError::BuildError(format!(
132 "Failed to compute relative path for {}: {}",
133 path.display(),
134 e
135 ))
136 })?
137 .to_path_buf();
138
139 let file_type = entry.file_type().map_err(|e| {
140 BoxError::BuildError(format!(
141 "Failed to read file type for {}: {}",
142 path.display(),
143 e
144 ))
145 })?;
146 let metadata = std::fs::symlink_metadata(&path).map_err(|e| {
147 BoxError::BuildError(format!(
148 "Failed to read metadata for {}: {}",
149 path.display(),
150 e
151 ))
152 })?;
153
154 let mtime = metadata
155 .modified()
156 .map(|t| {
157 t.duration_since(std::time::UNIX_EPOCH)
158 .unwrap_or_default()
159 .as_secs() as i64
160 })
161 .unwrap_or(0);
162
163 #[cfg(unix)]
164 let (mode, uid, gid) = {
165 use std::os::unix::fs::{MetadataExt, PermissionsExt};
166 (
167 metadata.permissions().mode(),
168 metadata.uid(),
169 metadata.gid(),
170 )
171 };
172 #[cfg(not(unix))]
173 let (mode, uid, gid) = (0u32, 0u32, 0u32);
174
175 entries.insert(
176 relative.clone(),
177 FileEntry {
178 path: relative,
179 size: metadata.len(),
180 mtime,
181 mode,
182 uid,
183 gid,
184 is_dir: file_type.is_dir(),
185 },
186 );
187
188 if file_type.is_dir() {
189 walk_dir(root, &path, entries)?;
190 }
191 }
192
193 Ok(())
194}
195
196pub fn create_layer(
203 rootfs: &Path,
204 changed_files: &[PathBuf],
205 output_path: &Path,
206) -> Result<LayerInfo> {
207 create_layer_with_chown(rootfs, changed_files, &[], output_path, None)
208}
209
210pub fn create_layer_with_deletions(
214 rootfs: &Path,
215 changed_files: &[PathBuf],
216 deleted_files: &[PathBuf],
217 output_path: &Path,
218) -> Result<LayerInfo> {
219 create_layer_with_chown(rootfs, changed_files, deleted_files, output_path, None)
220}
221
222pub(super) fn create_layer_with_chown(
225 rootfs: &Path,
226 changed_files: &[PathBuf],
227 deleted_files: &[PathBuf],
228 output_path: &Path,
229 chown: Option<(u32, u32)>,
230) -> Result<LayerInfo> {
231 use flate2::write::GzEncoder;
232 use flate2::Compression;
233
234 let file = std::fs::File::create(output_path).map_err(|e| {
235 BoxError::BuildError(format!(
236 "Failed to create layer file {}: {}",
237 output_path.display(),
238 e
239 ))
240 })?;
241
242 let encoder = GzEncoder::new(file, Compression::default());
243 let mut builder = tar::Builder::new(encoder);
244 builder.follow_symlinks(false);
246
247 for relative_path in changed_files {
248 let full_path = rootfs.join(relative_path);
249 let meta = match std::fs::symlink_metadata(&full_path) {
252 Ok(meta) => meta,
253 Err(_) => continue,
254 };
255
256 if meta.is_dir() {
257 append_dir_with_chown(&mut builder, relative_path, &full_path, chown)?;
258 } else {
259 append_file_with_chown(&mut builder, relative_path, &full_path, &meta, chown)?;
260 }
261 }
262
263 for deleted in deleted_files {
268 let Some(file_name) = deleted.file_name() else {
269 continue;
270 };
271 let wh_name = format!(".wh.{}", file_name.to_string_lossy());
272 let wh_path = match deleted.parent() {
273 Some(parent) if !parent.as_os_str().is_empty() => parent.join(&wh_name),
274 _ => PathBuf::from(&wh_name),
275 };
276
277 let mut header = tar::Header::new_gnu();
278 header.set_size(0);
279 header.set_entry_type(tar::EntryType::Regular);
280 header.set_mode(0o644);
281 header.set_mtime(0);
282 if let Some((uid, gid)) = chown {
283 header.set_uid(uid as u64);
284 header.set_gid(gid as u64);
285 }
286 header.set_cksum();
287 builder
288 .append_data(&mut header, &wh_path, std::io::empty())
289 .map_err(|e| {
290 BoxError::BuildError(format!(
291 "Failed to append whiteout {}: {}",
292 wh_path.display(),
293 e
294 ))
295 })?;
296 }
297
298 let encoder = builder
305 .into_inner()
306 .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer tar: {}", e)))?;
307 encoder
308 .finish()
309 .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer gzip: {}", e)))?;
310
311 let digest = sha256_file(output_path)?;
313 let size = std::fs::metadata(output_path).map(|m| m.len()).unwrap_or(0);
314
315 Ok(LayerInfo {
316 path: output_path.to_path_buf(),
317 digest,
318 size,
319 })
320}
321
322pub fn create_layer_from_dir(
327 src_dir: &Path,
328 target_prefix: &Path,
329 output_path: &Path,
330) -> Result<LayerInfo> {
331 create_layer_from_dir_with_chown(src_dir, target_prefix, output_path, None)
332}
333
334pub(super) fn create_layer_from_dir_with_chown(
336 src_dir: &Path,
337 target_prefix: &Path,
338 output_path: &Path,
339 chown: Option<(u32, u32)>,
340) -> Result<LayerInfo> {
341 use flate2::write::GzEncoder;
342 use flate2::Compression;
343
344 let file = std::fs::File::create(output_path).map_err(|e| {
345 BoxError::BuildError(format!(
346 "Failed to create layer file {}: {}",
347 output_path.display(),
348 e
349 ))
350 })?;
351
352 let encoder = GzEncoder::new(file, Compression::default());
353 let mut builder = tar::Builder::new(encoder);
354 builder.follow_symlinks(false);
356
357 add_dir_to_tar(&mut builder, src_dir, src_dir, target_prefix, chown)?;
358
359 let encoder = builder
362 .into_inner()
363 .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer tar: {}", e)))?;
364 encoder
365 .finish()
366 .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer gzip: {}", e)))?;
367
368 let digest = sha256_file(output_path)?;
369 let size = std::fs::metadata(output_path).map(|m| m.len()).unwrap_or(0);
370
371 Ok(LayerInfo {
372 path: output_path.to_path_buf(),
373 digest,
374 size,
375 })
376}
377
378fn add_dir_to_tar<W: std::io::Write>(
380 builder: &mut tar::Builder<W>,
381 root: &Path,
382 current: &Path,
383 target_prefix: &Path,
384 chown: Option<(u32, u32)>,
385) -> Result<()> {
386 let entries = std::fs::read_dir(current).map_err(|e| {
387 BoxError::BuildError(format!(
388 "Failed to read directory {}: {}",
389 current.display(),
390 e
391 ))
392 })?;
393
394 for entry in entries {
395 let entry =
396 entry.map_err(|e| BoxError::BuildError(format!("Failed to read entry: {}", e)))?;
397
398 let path = entry.path();
399 let relative = path
400 .strip_prefix(root)
401 .map_err(|e| BoxError::BuildError(format!("Failed to strip prefix: {}", e)))?;
402 let tar_path = target_prefix.join(relative);
403
404 let file_type = entry
407 .file_type()
408 .map_err(|e| BoxError::BuildError(format!("Failed to stat entry: {}", e)))?;
409 let meta = std::fs::symlink_metadata(&path)
410 .map_err(|e| BoxError::BuildError(format!("Failed to stat entry: {}", e)))?;
411
412 if file_type.is_dir() {
413 append_dir_with_chown(builder, &tar_path, &path, chown)?;
414 add_dir_to_tar(builder, root, &path, target_prefix, chown)?;
415 } else {
416 append_file_with_chown(builder, &tar_path, &path, &meta, chown)?;
417 }
418 }
419
420 Ok(())
421}
422
423fn append_dir_with_chown<W: std::io::Write>(
425 builder: &mut tar::Builder<W>,
426 tar_path: &Path,
427 dir_path: &Path,
428 chown: Option<(u32, u32)>,
429) -> Result<()> {
430 if let Some((uid, gid)) = chown {
431 let mut header = tar::Header::new_gnu();
432 let meta = std::fs::symlink_metadata(dir_path).map_err(|e| {
433 BoxError::BuildError(format!("Failed to stat {}: {}", dir_path.display(), e))
434 })?;
435 header.set_metadata_in_mode(&meta, tar::HeaderMode::Complete);
436 header.set_uid(uid as u64);
437 header.set_gid(gid as u64);
438 header.set_username("").ok();
439 header.set_groupname("").ok();
440 header.set_cksum();
441 builder
442 .append_data(&mut header, tar_path, std::io::empty())
443 .map_err(|e| BoxError::BuildError(format!("Failed to add dir to layer: {}", e)))
444 } else {
445 builder
446 .append_dir(tar_path, dir_path)
447 .map_err(|e| BoxError::BuildError(format!("Failed to add directory to layer: {}", e)))
448 }
449}
450
451fn append_file_with_chown<W: std::io::Write>(
453 builder: &mut tar::Builder<W>,
454 tar_path: &Path,
455 file_path: &Path,
456 meta: &std::fs::Metadata,
457 chown: Option<(u32, u32)>,
458) -> Result<()> {
459 if let Some((uid, gid)) = chown {
460 let mut header = tar::Header::new_gnu();
461 header.set_metadata_in_mode(meta, tar::HeaderMode::Complete);
462 header.set_uid(uid as u64);
463 header.set_gid(gid as u64);
464 header.set_username("").ok();
465 header.set_groupname("").ok();
466 if meta.file_type().is_symlink() {
467 let target = std::fs::read_link(file_path).map_err(|e| {
468 BoxError::BuildError(format!(
469 "Failed to read layer symlink {}: {}",
470 file_path.display(),
471 e
472 ))
473 })?;
474 header.set_entry_type(tar::EntryType::Symlink);
475 header.set_size(0);
476 header.set_link_name(&target).map_err(|e| {
477 BoxError::BuildError(format!(
478 "Failed to encode layer symlink {} -> {}: {}",
479 file_path.display(),
480 target.display(),
481 e
482 ))
483 })?;
484 }
485 header.set_cksum();
486 let body: Box<dyn std::io::Read> = if meta.file_type().is_symlink() {
488 Box::new(std::io::empty())
489 } else {
490 Box::new(std::fs::File::open(file_path).map_err(|e| {
491 BoxError::BuildError(format!("Failed to open {}: {}", file_path.display(), e))
492 })?)
493 };
494 builder
495 .append_data(&mut header, tar_path, body)
496 .map_err(|e| BoxError::BuildError(format!("Failed to add file to layer: {}", e)))
497 } else {
498 builder
499 .append_path_with_name(file_path, tar_path)
500 .map_err(|e| BoxError::BuildError(format!("Failed to add file to layer: {}", e)))
501 }
502}
503
504#[derive(Debug, Clone)]
506pub struct LayerInfo {
507 pub path: PathBuf,
509 pub digest: String,
511 pub size: u64,
513}
514
515impl LayerInfo {
516 pub fn prefixed_digest(&self) -> String {
518 format!("sha256:{}", self.digest)
519 }
520}
521
522pub(super) fn sha256_file(path: &Path) -> Result<String> {
524 use sha2::Digest as _;
525 use std::io::Read as _;
526
527 let mut file = std::fs::File::open(path).map_err(|e| {
528 BoxError::BuildError(format!(
529 "Failed to open file for hashing {}: {}",
530 path.display(),
531 e
532 ))
533 })?;
534 let mut hasher = Sha256::new();
535 let mut buf = [0u8; 65536];
536 loop {
537 let n = file.read(&mut buf).map_err(|e| {
538 BoxError::BuildError(format!(
539 "Failed to read file for hashing {}: {}",
540 path.display(),
541 e
542 ))
543 })?;
544 if n == 0 {
545 break;
546 }
547 hasher.update(&buf[..n]);
548 }
549 Ok(hex::encode(hasher.finalize()))
550}
551
552pub(super) fn sha256_bytes(data: &[u8]) -> String {
554 hex::encode(Sha256::digest(data))
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560 use std::collections::HashMap;
561 use std::fs;
562 use tempfile::TempDir;
563
564 #[test]
567 fn test_snapshot_empty_dir() {
568 let tmp = TempDir::new().unwrap();
569 let snap = DirSnapshot::capture(tmp.path()).unwrap();
570 assert!(snap.entries.is_empty());
571 }
572
573 #[test]
574 fn test_snapshot_with_files() {
575 let tmp = TempDir::new().unwrap();
576 fs::write(tmp.path().join("a.txt"), "hello").unwrap();
577 fs::create_dir(tmp.path().join("sub")).unwrap();
578 fs::write(tmp.path().join("sub").join("b.txt"), "world").unwrap();
579
580 let snap = DirSnapshot::capture(tmp.path()).unwrap();
581 assert!(snap.entries.contains_key(&PathBuf::from("a.txt")));
582 assert!(snap.entries.contains_key(&PathBuf::from("sub")));
583 assert!(snap.entries.contains_key(&PathBuf::from("sub/b.txt")));
584 }
585
586 #[test]
587 fn test_snapshot_diff_new_file() {
588 let tmp = TempDir::new().unwrap();
589 fs::write(tmp.path().join("a.txt"), "hello").unwrap();
590
591 let before = DirSnapshot::capture(tmp.path()).unwrap();
592
593 fs::write(tmp.path().join("b.txt"), "world").unwrap();
594
595 let after = DirSnapshot::capture(tmp.path()).unwrap();
596
597 let diff = before.diff(&after);
598 assert_eq!(diff, vec![PathBuf::from("b.txt")]);
599 }
600
601 #[test]
602 fn test_snapshot_deletions_top_level_file() {
603 let tmp = TempDir::new().unwrap();
604 fs::write(tmp.path().join("keep.txt"), "k").unwrap();
605 fs::write(tmp.path().join("gone.txt"), "g").unwrap();
606 let before = DirSnapshot::capture(tmp.path()).unwrap();
607
608 fs::remove_file(tmp.path().join("gone.txt")).unwrap();
609 let after = DirSnapshot::capture(tmp.path()).unwrap();
610
611 assert_eq!(before.deletions(&after), vec![PathBuf::from("gone.txt")]);
612 }
613
614 #[test]
615 fn test_snapshot_deletions_collapses_deleted_dir_to_top_level() {
616 let tmp = TempDir::new().unwrap();
617 fs::create_dir(tmp.path().join("d")).unwrap();
618 fs::write(tmp.path().join("d").join("a.txt"), "a").unwrap();
619 fs::write(tmp.path().join("d").join("b.txt"), "b").unwrap();
620 let before = DirSnapshot::capture(tmp.path()).unwrap();
621
622 fs::remove_dir_all(tmp.path().join("d")).unwrap();
623 let after = DirSnapshot::capture(tmp.path()).unwrap();
624
625 assert_eq!(before.deletions(&after), vec![PathBuf::from("d")]);
628 }
629
630 #[test]
631 fn test_create_layer_emits_whiteout_for_deletions() {
632 use flate2::read::GzDecoder;
633
634 let rootfs = TempDir::new().unwrap();
635 let out = TempDir::new().unwrap();
636 fs::write(rootfs.path().join("kept.txt"), "kept").unwrap();
637 let layer_path = out.path().join("layer.tar.gz");
638
639 create_layer_with_deletions(
640 rootfs.path(),
641 &[PathBuf::from("kept.txt")],
642 &[PathBuf::from("usr/share/doc/removed.txt")],
643 &layer_path,
644 )
645 .unwrap();
646
647 let tar_gz = fs::File::open(&layer_path).unwrap();
648 let mut archive = tar::Archive::new(GzDecoder::new(tar_gz));
649 let names: Vec<String> = archive
650 .entries()
651 .unwrap()
652 .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
653 .collect();
654
655 assert!(
656 names.iter().any(|n| n == "usr/share/doc/.wh.removed.txt"),
657 "expected an OCI whiteout marker, got: {names:?}"
658 );
659 assert!(names.iter().any(|n| n == "kept.txt"));
660 }
661
662 #[test]
663 fn test_snapshot_diff_modified_file() {
664 let tmp = TempDir::new().unwrap();
665 fs::write(tmp.path().join("a.txt"), "hello").unwrap();
666
667 let before = DirSnapshot::capture(tmp.path()).unwrap();
668
669 fs::write(tmp.path().join("a.txt"), "hello world").unwrap();
671
672 let after = DirSnapshot::capture(tmp.path()).unwrap();
673
674 let diff = before.diff(&after);
675 assert_eq!(diff, vec![PathBuf::from("a.txt")]);
676 }
677
678 #[test]
682 #[cfg(unix)]
683 fn test_snapshot_diff_detects_chmod_only() {
684 use std::os::unix::fs::PermissionsExt;
685 let tmp = TempDir::new().unwrap();
686 let f = tmp.path().join("entry.sh");
687 fs::write(&f, "#!/bin/sh\necho hi\n").unwrap();
688 fs::set_permissions(&f, fs::Permissions::from_mode(0o644)).unwrap();
689
690 let before = DirSnapshot::capture(tmp.path()).unwrap();
691 fs::set_permissions(&f, fs::Permissions::from_mode(0o755)).unwrap();
693 let after = DirSnapshot::capture(tmp.path()).unwrap();
694
695 assert_eq!(before.diff(&after), vec![PathBuf::from("entry.sh")]);
696 }
697
698 #[test]
699 fn test_snapshot_diff_detects_chown_only() {
700 let path = PathBuf::from("app/node_modules/pkg/package.json");
701 let before = DirSnapshot {
702 entries: HashMap::from([(
703 path.clone(),
704 FileEntry {
705 path: path.clone(),
706 size: 1300,
707 mtime: 1,
708 mode: 0o100640,
709 uid: 0,
710 gid: 0,
711 is_dir: false,
712 },
713 )]),
714 };
715 let after = DirSnapshot {
716 entries: HashMap::from([(
717 path.clone(),
718 FileEntry {
719 path: path.clone(),
720 size: 1300,
721 mtime: 1,
722 mode: 0o100640,
723 uid: 1001,
724 gid: 1001,
725 is_dir: false,
726 },
727 )]),
728 };
729
730 assert_eq!(before.diff(&after), vec![path]);
731 }
732
733 #[test]
734 fn test_snapshot_diff_no_changes() {
735 let tmp = TempDir::new().unwrap();
736 fs::write(tmp.path().join("a.txt"), "hello").unwrap();
737
738 let before = DirSnapshot::capture(tmp.path()).unwrap();
739 let after = DirSnapshot::capture(tmp.path()).unwrap();
740
741 let diff = before.diff(&after);
742 assert!(diff.is_empty());
743 }
744
745 #[test]
748 fn test_create_layer_from_files() {
749 let rootfs = TempDir::new().unwrap();
750 let output_dir = TempDir::new().unwrap();
751
752 fs::write(rootfs.path().join("hello.txt"), "hello").unwrap();
753 fs::write(rootfs.path().join("world.txt"), "world").unwrap();
754
755 let output_path = output_dir.path().join("layer.tar.gz");
756 let changed = vec![PathBuf::from("hello.txt"), PathBuf::from("world.txt")];
757
758 let info = create_layer(rootfs.path(), &changed, &output_path).unwrap();
759
760 assert!(info.path.exists());
761 assert!(info.size > 0);
762 assert!(!info.digest.is_empty());
763 assert_eq!(info.digest.len(), 64); }
765
766 #[test]
767 fn test_create_layer_empty() {
768 let rootfs = TempDir::new().unwrap();
769 let output_dir = TempDir::new().unwrap();
770 let output_path = output_dir.path().join("layer.tar.gz");
771
772 let info = create_layer(rootfs.path(), &[], &output_path).unwrap();
773 assert!(info.path.exists());
774 }
775
776 #[test]
781 fn test_create_layer_digest_matches_completed_file() {
782 let rootfs = TempDir::new().unwrap();
783 let output_dir = TempDir::new().unwrap();
784 fs::write(rootfs.path().join("a.txt"), "AAAA-content").unwrap();
785
786 let out = output_dir.path().join("layer.tar.gz");
787 let info = create_layer(rootfs.path(), &[PathBuf::from("a.txt")], &out).unwrap();
788
789 let on_disk = sha256_file(&info.path).unwrap();
792 let on_disk_size = fs::metadata(&info.path).unwrap().len();
793 assert_eq!(
794 info.digest, on_disk,
795 "recorded digest must match completed file"
796 );
797 assert_eq!(
798 info.size, on_disk_size,
799 "recorded size must match completed file"
800 );
801 assert!(
802 info.size > 20,
803 "a real one-file layer is larger than an empty gzip header"
804 );
805 }
806
807 #[test]
810 fn test_create_layer_distinct_content_distinct_digest() {
811 let rootfs = TempDir::new().unwrap();
812 let out_dir = TempDir::new().unwrap();
813
814 fs::write(rootfs.path().join("a.txt"), "AAAA-content").unwrap();
815 let a = create_layer(
816 rootfs.path(),
817 &[PathBuf::from("a.txt")],
818 &out_dir.path().join("a.tgz"),
819 )
820 .unwrap();
821
822 fs::write(rootfs.path().join("b.txt"), "BBBB-different-longer").unwrap();
823 let b = create_layer(
824 rootfs.path(),
825 &[PathBuf::from("b.txt")],
826 &out_dir.path().join("b.tgz"),
827 )
828 .unwrap();
829
830 assert_ne!(
831 a.digest, b.digest,
832 "distinct layer content must yield distinct digests"
833 );
834 }
835
836 #[test]
839 fn test_create_layer_from_dir() {
840 let src = TempDir::new().unwrap();
841 let output_dir = TempDir::new().unwrap();
842
843 fs::write(src.path().join("app.py"), "print('hi')").unwrap();
844 fs::create_dir(src.path().join("lib")).unwrap();
845 fs::write(src.path().join("lib").join("util.py"), "pass").unwrap();
846
847 let output_path = output_dir.path().join("layer.tar.gz");
848 let info = create_layer_from_dir(src.path(), Path::new("workspace"), &output_path).unwrap();
849
850 assert!(info.path.exists());
851 assert!(info.size > 0);
852
853 let file = fs::File::open(&info.path).unwrap();
855 let decoder = flate2::read::GzDecoder::new(file);
856 let mut archive = tar::Archive::new(decoder);
857 let paths: Vec<String> = archive
858 .entries()
859 .unwrap()
860 .filter_map(|e| e.ok())
861 .map(|e| e.path().unwrap().to_string_lossy().to_string())
862 .collect();
863
864 assert!(paths.iter().any(|p| p.contains("workspace/app.py")));
865 assert!(paths.iter().any(|p| p.contains("workspace/lib")));
866 }
867
868 #[test]
871 #[cfg(unix)]
872 fn test_create_layer_from_dir_preserves_symlinks() {
873 let src = TempDir::new().unwrap();
874 let output_dir = TempDir::new().unwrap();
875 fs::write(src.path().join("libfoo.so.1"), "real").unwrap();
876 std::os::unix::fs::symlink("libfoo.so.1", src.path().join("libfoo.so")).unwrap();
877
878 let out = output_dir.path().join("layer.tar.gz");
879 create_layer_from_dir(src.path(), Path::new("lib"), &out).unwrap();
880
881 let file = fs::File::open(&out).unwrap();
882 let decoder = flate2::read::GzDecoder::new(file);
883 let mut archive = tar::Archive::new(decoder);
884 let mut found_symlink = false;
885 for entry in archive.entries().unwrap() {
886 let entry = entry.unwrap();
887 if entry.path().unwrap().to_string_lossy() == "lib/libfoo.so" {
888 assert_eq!(entry.header().entry_type(), tar::EntryType::Symlink);
889 assert_eq!(
890 entry.link_name().unwrap().unwrap().to_string_lossy(),
891 "libfoo.so.1"
892 );
893 found_symlink = true;
894 }
895 }
896 assert!(found_symlink, "symlink entry must be present in the layer");
897 }
898
899 #[cfg(unix)]
900 #[test]
901 fn test_snapshot_does_not_follow_symlink_outside_rootfs() {
902 let tmp = TempDir::new().unwrap();
903 let rootfs = tmp.path().join("rootfs");
904 let outside = tmp.path().join("outside");
905 fs::create_dir_all(&rootfs).unwrap();
906 fs::create_dir_all(&outside).unwrap();
907 fs::write(outside.join("host-secret"), "secret").unwrap();
908 std::os::unix::fs::symlink(&outside, rootfs.join("escape")).unwrap();
909
910 let snapshot = DirSnapshot::capture(&rootfs).unwrap();
911
912 assert!(snapshot.entries.contains_key(Path::new("escape")));
913 assert!(!snapshot
914 .entries
915 .contains_key(Path::new("escape/host-secret")));
916 assert!(!snapshot.entries[Path::new("escape")].is_dir);
917 }
918
919 #[test]
920 #[cfg(unix)]
921 fn test_create_layer_with_chown_preserves_symlink_target() {
922 let rootfs = TempDir::new().unwrap();
923 let output_dir = TempDir::new().unwrap();
924 std::os::unix::fs::symlink("libfoo.so.1", rootfs.path().join("libfoo.so")).unwrap();
925 let out = output_dir.path().join("layer.tar.gz");
926
927 create_layer_with_chown(
928 rootfs.path(),
929 &[PathBuf::from("libfoo.so")],
930 &[],
931 &out,
932 Some((123, 456)),
933 )
934 .unwrap();
935
936 let decoder = flate2::read::GzDecoder::new(fs::File::open(out).unwrap());
937 let mut archive = tar::Archive::new(decoder);
938 let entry = archive.entries().unwrap().next().unwrap().unwrap();
939 assert_eq!(entry.header().entry_type(), tar::EntryType::Symlink);
940 assert_eq!(entry.header().uid().unwrap(), 123);
941 assert_eq!(entry.header().gid().unwrap(), 456);
942 assert_eq!(
943 entry.link_name().unwrap().unwrap().to_string_lossy(),
944 "libfoo.so.1"
945 );
946 }
947
948 #[test]
951 fn test_sha256_file() {
952 let tmp = TempDir::new().unwrap();
953 let path = tmp.path().join("test.txt");
954 fs::write(&path, "hello").unwrap();
955
956 let digest = sha256_file(&path).unwrap();
957 assert_eq!(digest.len(), 64);
958 assert_eq!(
960 digest,
961 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
962 );
963 }
964
965 #[test]
966 fn test_sha256_bytes() {
967 let digest = sha256_bytes(b"hello");
968 assert_eq!(
969 digest,
970 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
971 );
972 }
973
974 #[test]
975 fn test_layer_info_prefixed_digest() {
976 let info = LayerInfo {
977 path: PathBuf::from("/tmp/layer.tar.gz"),
978 digest: "abc123".to_string(),
979 size: 100,
980 };
981 assert_eq!(info.prefixed_digest(), "sha256:abc123");
982 }
983}