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 metadata = entry.metadata().map_err(|e| {
140 BoxError::BuildError(format!(
141 "Failed to read metadata for {}: {}",
142 path.display(),
143 e
144 ))
145 })?;
146
147 let mtime = metadata
148 .modified()
149 .map(|t| {
150 t.duration_since(std::time::UNIX_EPOCH)
151 .unwrap_or_default()
152 .as_secs() as i64
153 })
154 .unwrap_or(0);
155
156 #[cfg(unix)]
157 let (mode, uid, gid) = {
158 use std::os::unix::fs::{MetadataExt, PermissionsExt};
159 (
160 metadata.permissions().mode(),
161 metadata.uid(),
162 metadata.gid(),
163 )
164 };
165 #[cfg(not(unix))]
166 let (mode, uid, gid) = (0u32, 0u32, 0u32);
167
168 entries.insert(
169 relative.clone(),
170 FileEntry {
171 path: relative,
172 size: metadata.len(),
173 mtime,
174 mode,
175 uid,
176 gid,
177 is_dir: metadata.is_dir(),
178 },
179 );
180
181 if metadata.is_dir() {
182 walk_dir(root, &path, entries)?;
183 }
184 }
185
186 Ok(())
187}
188
189pub fn create_layer(
196 rootfs: &Path,
197 changed_files: &[PathBuf],
198 output_path: &Path,
199) -> Result<LayerInfo> {
200 create_layer_with_chown(rootfs, changed_files, &[], output_path, None)
201}
202
203pub fn create_layer_with_deletions(
207 rootfs: &Path,
208 changed_files: &[PathBuf],
209 deleted_files: &[PathBuf],
210 output_path: &Path,
211) -> Result<LayerInfo> {
212 create_layer_with_chown(rootfs, changed_files, deleted_files, output_path, None)
213}
214
215pub(super) fn create_layer_with_chown(
218 rootfs: &Path,
219 changed_files: &[PathBuf],
220 deleted_files: &[PathBuf],
221 output_path: &Path,
222 chown: Option<(u32, u32)>,
223) -> Result<LayerInfo> {
224 use flate2::write::GzEncoder;
225 use flate2::Compression;
226
227 let file = std::fs::File::create(output_path).map_err(|e| {
228 BoxError::BuildError(format!(
229 "Failed to create layer file {}: {}",
230 output_path.display(),
231 e
232 ))
233 })?;
234
235 let encoder = GzEncoder::new(file, Compression::default());
236 let mut builder = tar::Builder::new(encoder);
237 builder.follow_symlinks(false);
239
240 for relative_path in changed_files {
241 let full_path = rootfs.join(relative_path);
242 let meta = match std::fs::symlink_metadata(&full_path) {
245 Ok(meta) => meta,
246 Err(_) => continue,
247 };
248
249 if meta.is_dir() {
250 append_dir_with_chown(&mut builder, relative_path, &full_path, chown)?;
251 } else {
252 append_file_with_chown(&mut builder, relative_path, &full_path, &meta, chown)?;
253 }
254 }
255
256 for deleted in deleted_files {
261 let Some(file_name) = deleted.file_name() else {
262 continue;
263 };
264 let wh_name = format!(".wh.{}", file_name.to_string_lossy());
265 let wh_path = match deleted.parent() {
266 Some(parent) if !parent.as_os_str().is_empty() => parent.join(&wh_name),
267 _ => PathBuf::from(&wh_name),
268 };
269
270 let mut header = tar::Header::new_gnu();
271 header.set_size(0);
272 header.set_entry_type(tar::EntryType::Regular);
273 header.set_mode(0o644);
274 header.set_mtime(0);
275 if let Some((uid, gid)) = chown {
276 header.set_uid(uid as u64);
277 header.set_gid(gid as u64);
278 }
279 header.set_cksum();
280 builder
281 .append_data(&mut header, &wh_path, std::io::empty())
282 .map_err(|e| {
283 BoxError::BuildError(format!(
284 "Failed to append whiteout {}: {}",
285 wh_path.display(),
286 e
287 ))
288 })?;
289 }
290
291 let encoder = builder
298 .into_inner()
299 .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer tar: {}", e)))?;
300 encoder
301 .finish()
302 .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer gzip: {}", e)))?;
303
304 let digest = sha256_file(output_path)?;
306 let size = std::fs::metadata(output_path).map(|m| m.len()).unwrap_or(0);
307
308 Ok(LayerInfo {
309 path: output_path.to_path_buf(),
310 digest,
311 size,
312 })
313}
314
315pub fn create_layer_from_dir(
320 src_dir: &Path,
321 target_prefix: &Path,
322 output_path: &Path,
323) -> Result<LayerInfo> {
324 create_layer_from_dir_with_chown(src_dir, target_prefix, output_path, None)
325}
326
327pub(super) fn create_layer_from_dir_with_chown(
329 src_dir: &Path,
330 target_prefix: &Path,
331 output_path: &Path,
332 chown: Option<(u32, u32)>,
333) -> Result<LayerInfo> {
334 use flate2::write::GzEncoder;
335 use flate2::Compression;
336
337 let file = std::fs::File::create(output_path).map_err(|e| {
338 BoxError::BuildError(format!(
339 "Failed to create layer file {}: {}",
340 output_path.display(),
341 e
342 ))
343 })?;
344
345 let encoder = GzEncoder::new(file, Compression::default());
346 let mut builder = tar::Builder::new(encoder);
347 builder.follow_symlinks(false);
349
350 add_dir_to_tar(&mut builder, src_dir, src_dir, target_prefix, chown)?;
351
352 let encoder = builder
355 .into_inner()
356 .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer tar: {}", e)))?;
357 encoder
358 .finish()
359 .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer gzip: {}", e)))?;
360
361 let digest = sha256_file(output_path)?;
362 let size = std::fs::metadata(output_path).map(|m| m.len()).unwrap_or(0);
363
364 Ok(LayerInfo {
365 path: output_path.to_path_buf(),
366 digest,
367 size,
368 })
369}
370
371fn add_dir_to_tar<W: std::io::Write>(
373 builder: &mut tar::Builder<W>,
374 root: &Path,
375 current: &Path,
376 target_prefix: &Path,
377 chown: Option<(u32, u32)>,
378) -> Result<()> {
379 let entries = std::fs::read_dir(current).map_err(|e| {
380 BoxError::BuildError(format!(
381 "Failed to read directory {}: {}",
382 current.display(),
383 e
384 ))
385 })?;
386
387 for entry in entries {
388 let entry =
389 entry.map_err(|e| BoxError::BuildError(format!("Failed to read entry: {}", e)))?;
390
391 let path = entry.path();
392 let relative = path
393 .strip_prefix(root)
394 .map_err(|e| BoxError::BuildError(format!("Failed to strip prefix: {}", e)))?;
395 let tar_path = target_prefix.join(relative);
396
397 let file_type = entry
400 .file_type()
401 .map_err(|e| BoxError::BuildError(format!("Failed to stat entry: {}", e)))?;
402 let meta = entry
403 .metadata()
404 .map_err(|e| BoxError::BuildError(format!("Failed to stat entry: {}", e)))?;
405
406 if file_type.is_dir() {
407 append_dir_with_chown(builder, &tar_path, &path, chown)?;
408 add_dir_to_tar(builder, root, &path, target_prefix, chown)?;
409 } else {
410 append_file_with_chown(builder, &tar_path, &path, &meta, chown)?;
411 }
412 }
413
414 Ok(())
415}
416
417fn append_dir_with_chown<W: std::io::Write>(
419 builder: &mut tar::Builder<W>,
420 tar_path: &Path,
421 dir_path: &Path,
422 chown: Option<(u32, u32)>,
423) -> Result<()> {
424 if let Some((uid, gid)) = chown {
425 let mut header = tar::Header::new_gnu();
426 let meta = std::fs::symlink_metadata(dir_path).map_err(|e| {
427 BoxError::BuildError(format!("Failed to stat {}: {}", dir_path.display(), e))
428 })?;
429 header.set_metadata_in_mode(&meta, tar::HeaderMode::Complete);
430 header.set_uid(uid as u64);
431 header.set_gid(gid as u64);
432 header.set_username("").ok();
433 header.set_groupname("").ok();
434 header.set_cksum();
435 builder
436 .append_data(&mut header, tar_path, std::io::empty())
437 .map_err(|e| BoxError::BuildError(format!("Failed to add dir to layer: {}", e)))
438 } else {
439 builder
440 .append_dir(tar_path, dir_path)
441 .map_err(|e| BoxError::BuildError(format!("Failed to add directory to layer: {}", e)))
442 }
443}
444
445fn append_file_with_chown<W: std::io::Write>(
447 builder: &mut tar::Builder<W>,
448 tar_path: &Path,
449 file_path: &Path,
450 meta: &std::fs::Metadata,
451 chown: Option<(u32, u32)>,
452) -> Result<()> {
453 if let Some((uid, gid)) = chown {
454 let mut header = tar::Header::new_gnu();
455 header.set_metadata_in_mode(meta, tar::HeaderMode::Complete);
456 header.set_uid(uid as u64);
457 header.set_gid(gid as u64);
458 header.set_username("").ok();
459 header.set_groupname("").ok();
460 header.set_cksum();
461 let body: Box<dyn std::io::Read> = if meta.file_type().is_symlink() {
463 Box::new(std::io::empty())
464 } else {
465 Box::new(std::fs::File::open(file_path).map_err(|e| {
466 BoxError::BuildError(format!("Failed to open {}: {}", file_path.display(), e))
467 })?)
468 };
469 builder
470 .append_data(&mut header, tar_path, body)
471 .map_err(|e| BoxError::BuildError(format!("Failed to add file to layer: {}", e)))
472 } else {
473 builder
474 .append_path_with_name(file_path, tar_path)
475 .map_err(|e| BoxError::BuildError(format!("Failed to add file to layer: {}", e)))
476 }
477}
478
479#[derive(Debug, Clone)]
481pub struct LayerInfo {
482 pub path: PathBuf,
484 pub digest: String,
486 pub size: u64,
488}
489
490impl LayerInfo {
491 pub fn prefixed_digest(&self) -> String {
493 format!("sha256:{}", self.digest)
494 }
495}
496
497pub(super) fn sha256_file(path: &Path) -> Result<String> {
499 use sha2::Digest as _;
500 use std::io::Read as _;
501
502 let mut file = std::fs::File::open(path).map_err(|e| {
503 BoxError::BuildError(format!(
504 "Failed to open file for hashing {}: {}",
505 path.display(),
506 e
507 ))
508 })?;
509 let mut hasher = Sha256::new();
510 let mut buf = [0u8; 65536];
511 loop {
512 let n = file.read(&mut buf).map_err(|e| {
513 BoxError::BuildError(format!(
514 "Failed to read file for hashing {}: {}",
515 path.display(),
516 e
517 ))
518 })?;
519 if n == 0 {
520 break;
521 }
522 hasher.update(&buf[..n]);
523 }
524 Ok(hex::encode(hasher.finalize()))
525}
526
527pub(super) fn sha256_bytes(data: &[u8]) -> String {
529 hex::encode(Sha256::digest(data))
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use std::collections::HashMap;
536 use std::fs;
537 use tempfile::TempDir;
538
539 #[test]
542 fn test_snapshot_empty_dir() {
543 let tmp = TempDir::new().unwrap();
544 let snap = DirSnapshot::capture(tmp.path()).unwrap();
545 assert!(snap.entries.is_empty());
546 }
547
548 #[test]
549 fn test_snapshot_with_files() {
550 let tmp = TempDir::new().unwrap();
551 fs::write(tmp.path().join("a.txt"), "hello").unwrap();
552 fs::create_dir(tmp.path().join("sub")).unwrap();
553 fs::write(tmp.path().join("sub").join("b.txt"), "world").unwrap();
554
555 let snap = DirSnapshot::capture(tmp.path()).unwrap();
556 assert!(snap.entries.contains_key(&PathBuf::from("a.txt")));
557 assert!(snap.entries.contains_key(&PathBuf::from("sub")));
558 assert!(snap.entries.contains_key(&PathBuf::from("sub/b.txt")));
559 }
560
561 #[test]
562 fn test_snapshot_diff_new_file() {
563 let tmp = TempDir::new().unwrap();
564 fs::write(tmp.path().join("a.txt"), "hello").unwrap();
565
566 let before = DirSnapshot::capture(tmp.path()).unwrap();
567
568 fs::write(tmp.path().join("b.txt"), "world").unwrap();
569
570 let after = DirSnapshot::capture(tmp.path()).unwrap();
571
572 let diff = before.diff(&after);
573 assert_eq!(diff, vec![PathBuf::from("b.txt")]);
574 }
575
576 #[test]
577 fn test_snapshot_deletions_top_level_file() {
578 let tmp = TempDir::new().unwrap();
579 fs::write(tmp.path().join("keep.txt"), "k").unwrap();
580 fs::write(tmp.path().join("gone.txt"), "g").unwrap();
581 let before = DirSnapshot::capture(tmp.path()).unwrap();
582
583 fs::remove_file(tmp.path().join("gone.txt")).unwrap();
584 let after = DirSnapshot::capture(tmp.path()).unwrap();
585
586 assert_eq!(before.deletions(&after), vec![PathBuf::from("gone.txt")]);
587 }
588
589 #[test]
590 fn test_snapshot_deletions_collapses_deleted_dir_to_top_level() {
591 let tmp = TempDir::new().unwrap();
592 fs::create_dir(tmp.path().join("d")).unwrap();
593 fs::write(tmp.path().join("d").join("a.txt"), "a").unwrap();
594 fs::write(tmp.path().join("d").join("b.txt"), "b").unwrap();
595 let before = DirSnapshot::capture(tmp.path()).unwrap();
596
597 fs::remove_dir_all(tmp.path().join("d")).unwrap();
598 let after = DirSnapshot::capture(tmp.path()).unwrap();
599
600 assert_eq!(before.deletions(&after), vec![PathBuf::from("d")]);
603 }
604
605 #[test]
606 fn test_create_layer_emits_whiteout_for_deletions() {
607 use flate2::read::GzDecoder;
608
609 let rootfs = TempDir::new().unwrap();
610 let out = TempDir::new().unwrap();
611 fs::write(rootfs.path().join("kept.txt"), "kept").unwrap();
612 let layer_path = out.path().join("layer.tar.gz");
613
614 create_layer_with_deletions(
615 rootfs.path(),
616 &[PathBuf::from("kept.txt")],
617 &[PathBuf::from("usr/share/doc/removed.txt")],
618 &layer_path,
619 )
620 .unwrap();
621
622 let tar_gz = fs::File::open(&layer_path).unwrap();
623 let mut archive = tar::Archive::new(GzDecoder::new(tar_gz));
624 let names: Vec<String> = archive
625 .entries()
626 .unwrap()
627 .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
628 .collect();
629
630 assert!(
631 names.iter().any(|n| n == "usr/share/doc/.wh.removed.txt"),
632 "expected an OCI whiteout marker, got: {names:?}"
633 );
634 assert!(names.iter().any(|n| n == "kept.txt"));
635 }
636
637 #[test]
638 fn test_snapshot_diff_modified_file() {
639 let tmp = TempDir::new().unwrap();
640 fs::write(tmp.path().join("a.txt"), "hello").unwrap();
641
642 let before = DirSnapshot::capture(tmp.path()).unwrap();
643
644 fs::write(tmp.path().join("a.txt"), "hello world").unwrap();
646
647 let after = DirSnapshot::capture(tmp.path()).unwrap();
648
649 let diff = before.diff(&after);
650 assert_eq!(diff, vec![PathBuf::from("a.txt")]);
651 }
652
653 #[test]
657 #[cfg(unix)]
658 fn test_snapshot_diff_detects_chmod_only() {
659 use std::os::unix::fs::PermissionsExt;
660 let tmp = TempDir::new().unwrap();
661 let f = tmp.path().join("entry.sh");
662 fs::write(&f, "#!/bin/sh\necho hi\n").unwrap();
663 fs::set_permissions(&f, fs::Permissions::from_mode(0o644)).unwrap();
664
665 let before = DirSnapshot::capture(tmp.path()).unwrap();
666 fs::set_permissions(&f, fs::Permissions::from_mode(0o755)).unwrap();
668 let after = DirSnapshot::capture(tmp.path()).unwrap();
669
670 assert_eq!(before.diff(&after), vec![PathBuf::from("entry.sh")]);
671 }
672
673 #[test]
674 fn test_snapshot_diff_detects_chown_only() {
675 let path = PathBuf::from("app/node_modules/pkg/package.json");
676 let before = DirSnapshot {
677 entries: HashMap::from([(
678 path.clone(),
679 FileEntry {
680 path: path.clone(),
681 size: 1300,
682 mtime: 1,
683 mode: 0o100640,
684 uid: 0,
685 gid: 0,
686 is_dir: false,
687 },
688 )]),
689 };
690 let after = DirSnapshot {
691 entries: HashMap::from([(
692 path.clone(),
693 FileEntry {
694 path: path.clone(),
695 size: 1300,
696 mtime: 1,
697 mode: 0o100640,
698 uid: 1001,
699 gid: 1001,
700 is_dir: false,
701 },
702 )]),
703 };
704
705 assert_eq!(before.diff(&after), vec![path]);
706 }
707
708 #[test]
709 fn test_snapshot_diff_no_changes() {
710 let tmp = TempDir::new().unwrap();
711 fs::write(tmp.path().join("a.txt"), "hello").unwrap();
712
713 let before = DirSnapshot::capture(tmp.path()).unwrap();
714 let after = DirSnapshot::capture(tmp.path()).unwrap();
715
716 let diff = before.diff(&after);
717 assert!(diff.is_empty());
718 }
719
720 #[test]
723 fn test_create_layer_from_files() {
724 let rootfs = TempDir::new().unwrap();
725 let output_dir = TempDir::new().unwrap();
726
727 fs::write(rootfs.path().join("hello.txt"), "hello").unwrap();
728 fs::write(rootfs.path().join("world.txt"), "world").unwrap();
729
730 let output_path = output_dir.path().join("layer.tar.gz");
731 let changed = vec![PathBuf::from("hello.txt"), PathBuf::from("world.txt")];
732
733 let info = create_layer(rootfs.path(), &changed, &output_path).unwrap();
734
735 assert!(info.path.exists());
736 assert!(info.size > 0);
737 assert!(!info.digest.is_empty());
738 assert_eq!(info.digest.len(), 64); }
740
741 #[test]
742 fn test_create_layer_empty() {
743 let rootfs = TempDir::new().unwrap();
744 let output_dir = TempDir::new().unwrap();
745 let output_path = output_dir.path().join("layer.tar.gz");
746
747 let info = create_layer(rootfs.path(), &[], &output_path).unwrap();
748 assert!(info.path.exists());
749 }
750
751 #[test]
756 fn test_create_layer_digest_matches_completed_file() {
757 let rootfs = TempDir::new().unwrap();
758 let output_dir = TempDir::new().unwrap();
759 fs::write(rootfs.path().join("a.txt"), "AAAA-content").unwrap();
760
761 let out = output_dir.path().join("layer.tar.gz");
762 let info = create_layer(rootfs.path(), &[PathBuf::from("a.txt")], &out).unwrap();
763
764 let on_disk = sha256_file(&info.path).unwrap();
767 let on_disk_size = fs::metadata(&info.path).unwrap().len();
768 assert_eq!(
769 info.digest, on_disk,
770 "recorded digest must match completed file"
771 );
772 assert_eq!(
773 info.size, on_disk_size,
774 "recorded size must match completed file"
775 );
776 assert!(
777 info.size > 20,
778 "a real one-file layer is larger than an empty gzip header"
779 );
780 }
781
782 #[test]
785 fn test_create_layer_distinct_content_distinct_digest() {
786 let rootfs = TempDir::new().unwrap();
787 let out_dir = TempDir::new().unwrap();
788
789 fs::write(rootfs.path().join("a.txt"), "AAAA-content").unwrap();
790 let a = create_layer(
791 rootfs.path(),
792 &[PathBuf::from("a.txt")],
793 &out_dir.path().join("a.tgz"),
794 )
795 .unwrap();
796
797 fs::write(rootfs.path().join("b.txt"), "BBBB-different-longer").unwrap();
798 let b = create_layer(
799 rootfs.path(),
800 &[PathBuf::from("b.txt")],
801 &out_dir.path().join("b.tgz"),
802 )
803 .unwrap();
804
805 assert_ne!(
806 a.digest, b.digest,
807 "distinct layer content must yield distinct digests"
808 );
809 }
810
811 #[test]
814 fn test_create_layer_from_dir() {
815 let src = TempDir::new().unwrap();
816 let output_dir = TempDir::new().unwrap();
817
818 fs::write(src.path().join("app.py"), "print('hi')").unwrap();
819 fs::create_dir(src.path().join("lib")).unwrap();
820 fs::write(src.path().join("lib").join("util.py"), "pass").unwrap();
821
822 let output_path = output_dir.path().join("layer.tar.gz");
823 let info = create_layer_from_dir(src.path(), Path::new("workspace"), &output_path).unwrap();
824
825 assert!(info.path.exists());
826 assert!(info.size > 0);
827
828 let file = fs::File::open(&info.path).unwrap();
830 let decoder = flate2::read::GzDecoder::new(file);
831 let mut archive = tar::Archive::new(decoder);
832 let paths: Vec<String> = archive
833 .entries()
834 .unwrap()
835 .filter_map(|e| e.ok())
836 .map(|e| e.path().unwrap().to_string_lossy().to_string())
837 .collect();
838
839 assert!(paths.iter().any(|p| p.contains("workspace/app.py")));
840 assert!(paths.iter().any(|p| p.contains("workspace/lib")));
841 }
842
843 #[test]
846 #[cfg(unix)]
847 fn test_create_layer_from_dir_preserves_symlinks() {
848 let src = TempDir::new().unwrap();
849 let output_dir = TempDir::new().unwrap();
850 fs::write(src.path().join("libfoo.so.1"), "real").unwrap();
851 std::os::unix::fs::symlink("libfoo.so.1", src.path().join("libfoo.so")).unwrap();
852
853 let out = output_dir.path().join("layer.tar.gz");
854 create_layer_from_dir(src.path(), Path::new("lib"), &out).unwrap();
855
856 let file = fs::File::open(&out).unwrap();
857 let decoder = flate2::read::GzDecoder::new(file);
858 let mut archive = tar::Archive::new(decoder);
859 let mut found_symlink = false;
860 for entry in archive.entries().unwrap() {
861 let entry = entry.unwrap();
862 if entry.path().unwrap().to_string_lossy() == "lib/libfoo.so" {
863 assert_eq!(entry.header().entry_type(), tar::EntryType::Symlink);
864 assert_eq!(
865 entry.link_name().unwrap().unwrap().to_string_lossy(),
866 "libfoo.so.1"
867 );
868 found_symlink = true;
869 }
870 }
871 assert!(found_symlink, "symlink entry must be present in the layer");
872 }
873
874 #[test]
877 fn test_sha256_file() {
878 let tmp = TempDir::new().unwrap();
879 let path = tmp.path().join("test.txt");
880 fs::write(&path, "hello").unwrap();
881
882 let digest = sha256_file(&path).unwrap();
883 assert_eq!(digest.len(), 64);
884 assert_eq!(
886 digest,
887 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
888 );
889 }
890
891 #[test]
892 fn test_sha256_bytes() {
893 let digest = sha256_bytes(b"hello");
894 assert_eq!(
895 digest,
896 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
897 );
898 }
899
900 #[test]
901 fn test_layer_info_prefixed_digest() {
902 let info = LayerInfo {
903 path: PathBuf::from("/tmp/layer.tar.gz"),
904 digest: "abc123".to_string(),
905 size: 100,
906 };
907 assert_eq!(info.prefixed_digest(), "sha256:abc123");
908 }
909}