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 is_dir: bool,
27}
28
29#[derive(Debug, Clone)]
31pub struct DirSnapshot {
32 pub entries: HashMap<PathBuf, FileEntry>,
34}
35
36impl DirSnapshot {
37 pub fn capture(root: &Path) -> Result<Self> {
39 let mut entries = HashMap::new();
40 walk_dir(root, root, &mut entries)?;
41 Ok(DirSnapshot { entries })
42 }
43
44 pub fn diff(&self, after: &DirSnapshot) -> Vec<PathBuf> {
48 let mut changed = Vec::new();
49
50 for (path, after_entry) in &after.entries {
51 match self.entries.get(path) {
52 None => {
53 changed.push(path.clone());
55 }
56 Some(before_entry) => {
57 if before_entry.size != after_entry.size
61 || before_entry.mtime != after_entry.mtime
62 || before_entry.mode != after_entry.mode
63 {
64 changed.push(path.clone());
65 }
66 }
67 }
68 }
69
70 changed.sort();
72 changed
73 }
74
75 pub fn deletions(&self, after: &DirSnapshot) -> Vec<PathBuf> {
81 let mut deleted = Vec::new();
82
83 for path in self.entries.keys() {
84 if after.entries.contains_key(path) {
85 continue; }
87 let parent_present = match path.parent() {
90 Some(parent) if !parent.as_os_str().is_empty() => {
91 after.entries.contains_key(parent)
92 }
93 _ => true, };
95 if parent_present {
96 deleted.push(path.clone());
97 }
98 }
99
100 deleted.sort();
102 deleted
103 }
104}
105
106fn walk_dir(root: &Path, current: &Path, entries: &mut HashMap<PathBuf, FileEntry>) -> Result<()> {
108 let read_dir = std::fs::read_dir(current).map_err(|e| {
109 BoxError::BuildError(format!(
110 "Failed to read directory {}: {}",
111 current.display(),
112 e
113 ))
114 })?;
115
116 for entry in read_dir {
117 let entry = entry
118 .map_err(|e| BoxError::BuildError(format!("Failed to read directory entry: {}", e)))?;
119
120 let path = entry.path();
121 let relative = path
122 .strip_prefix(root)
123 .map_err(|e| {
124 BoxError::BuildError(format!(
125 "Failed to compute relative path for {}: {}",
126 path.display(),
127 e
128 ))
129 })?
130 .to_path_buf();
131
132 let metadata = entry.metadata().map_err(|e| {
133 BoxError::BuildError(format!(
134 "Failed to read metadata for {}: {}",
135 path.display(),
136 e
137 ))
138 })?;
139
140 let mtime = metadata
141 .modified()
142 .map(|t| {
143 t.duration_since(std::time::UNIX_EPOCH)
144 .unwrap_or_default()
145 .as_secs() as i64
146 })
147 .unwrap_or(0);
148
149 #[cfg(unix)]
150 let mode = {
151 use std::os::unix::fs::PermissionsExt;
152 metadata.permissions().mode()
153 };
154 #[cfg(not(unix))]
155 let mode = 0u32;
156
157 entries.insert(
158 relative.clone(),
159 FileEntry {
160 path: relative,
161 size: metadata.len(),
162 mtime,
163 mode,
164 is_dir: metadata.is_dir(),
165 },
166 );
167
168 if metadata.is_dir() {
169 walk_dir(root, &path, entries)?;
170 }
171 }
172
173 Ok(())
174}
175
176pub fn create_layer(
183 rootfs: &Path,
184 changed_files: &[PathBuf],
185 output_path: &Path,
186) -> Result<LayerInfo> {
187 create_layer_with_chown(rootfs, changed_files, &[], output_path, None)
188}
189
190pub fn create_layer_with_deletions(
194 rootfs: &Path,
195 changed_files: &[PathBuf],
196 deleted_files: &[PathBuf],
197 output_path: &Path,
198) -> Result<LayerInfo> {
199 create_layer_with_chown(rootfs, changed_files, deleted_files, output_path, None)
200}
201
202pub(super) fn create_layer_with_chown(
205 rootfs: &Path,
206 changed_files: &[PathBuf],
207 deleted_files: &[PathBuf],
208 output_path: &Path,
209 chown: Option<(u32, u32)>,
210) -> Result<LayerInfo> {
211 use flate2::write::GzEncoder;
212 use flate2::Compression;
213
214 let file = std::fs::File::create(output_path).map_err(|e| {
215 BoxError::BuildError(format!(
216 "Failed to create layer file {}: {}",
217 output_path.display(),
218 e
219 ))
220 })?;
221
222 let encoder = GzEncoder::new(file, Compression::default());
223 let mut builder = tar::Builder::new(encoder);
224 builder.follow_symlinks(false);
226
227 for relative_path in changed_files {
228 let full_path = rootfs.join(relative_path);
229 let meta = match std::fs::symlink_metadata(&full_path) {
232 Ok(meta) => meta,
233 Err(_) => continue,
234 };
235
236 if meta.is_dir() {
237 append_dir_with_chown(&mut builder, relative_path, &full_path, chown)?;
238 } else {
239 append_file_with_chown(&mut builder, relative_path, &full_path, &meta, chown)?;
240 }
241 }
242
243 for deleted in deleted_files {
248 let Some(file_name) = deleted.file_name() else {
249 continue;
250 };
251 let wh_name = format!(".wh.{}", file_name.to_string_lossy());
252 let wh_path = match deleted.parent() {
253 Some(parent) if !parent.as_os_str().is_empty() => parent.join(&wh_name),
254 _ => PathBuf::from(&wh_name),
255 };
256
257 let mut header = tar::Header::new_gnu();
258 header.set_size(0);
259 header.set_entry_type(tar::EntryType::Regular);
260 header.set_mode(0o644);
261 header.set_mtime(0);
262 if let Some((uid, gid)) = chown {
263 header.set_uid(uid as u64);
264 header.set_gid(gid as u64);
265 }
266 header.set_cksum();
267 builder
268 .append_data(&mut header, &wh_path, std::io::empty())
269 .map_err(|e| {
270 BoxError::BuildError(format!(
271 "Failed to append whiteout {}: {}",
272 wh_path.display(),
273 e
274 ))
275 })?;
276 }
277
278 let encoder = builder
285 .into_inner()
286 .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer tar: {}", e)))?;
287 encoder
288 .finish()
289 .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer gzip: {}", e)))?;
290
291 let digest = sha256_file(output_path)?;
293 let size = std::fs::metadata(output_path).map(|m| m.len()).unwrap_or(0);
294
295 Ok(LayerInfo {
296 path: output_path.to_path_buf(),
297 digest,
298 size,
299 })
300}
301
302pub fn create_layer_from_dir(
307 src_dir: &Path,
308 target_prefix: &Path,
309 output_path: &Path,
310) -> Result<LayerInfo> {
311 create_layer_from_dir_with_chown(src_dir, target_prefix, output_path, None)
312}
313
314pub(super) fn create_layer_from_dir_with_chown(
316 src_dir: &Path,
317 target_prefix: &Path,
318 output_path: &Path,
319 chown: Option<(u32, u32)>,
320) -> Result<LayerInfo> {
321 use flate2::write::GzEncoder;
322 use flate2::Compression;
323
324 let file = std::fs::File::create(output_path).map_err(|e| {
325 BoxError::BuildError(format!(
326 "Failed to create layer file {}: {}",
327 output_path.display(),
328 e
329 ))
330 })?;
331
332 let encoder = GzEncoder::new(file, Compression::default());
333 let mut builder = tar::Builder::new(encoder);
334 builder.follow_symlinks(false);
336
337 add_dir_to_tar(&mut builder, src_dir, src_dir, target_prefix, chown)?;
338
339 let encoder = builder
342 .into_inner()
343 .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer tar: {}", e)))?;
344 encoder
345 .finish()
346 .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer gzip: {}", e)))?;
347
348 let digest = sha256_file(output_path)?;
349 let size = std::fs::metadata(output_path).map(|m| m.len()).unwrap_or(0);
350
351 Ok(LayerInfo {
352 path: output_path.to_path_buf(),
353 digest,
354 size,
355 })
356}
357
358fn add_dir_to_tar<W: std::io::Write>(
360 builder: &mut tar::Builder<W>,
361 root: &Path,
362 current: &Path,
363 target_prefix: &Path,
364 chown: Option<(u32, u32)>,
365) -> Result<()> {
366 let entries = std::fs::read_dir(current).map_err(|e| {
367 BoxError::BuildError(format!(
368 "Failed to read directory {}: {}",
369 current.display(),
370 e
371 ))
372 })?;
373
374 for entry in entries {
375 let entry =
376 entry.map_err(|e| BoxError::BuildError(format!("Failed to read entry: {}", e)))?;
377
378 let path = entry.path();
379 let relative = path
380 .strip_prefix(root)
381 .map_err(|e| BoxError::BuildError(format!("Failed to strip prefix: {}", e)))?;
382 let tar_path = target_prefix.join(relative);
383
384 let file_type = entry
387 .file_type()
388 .map_err(|e| BoxError::BuildError(format!("Failed to stat entry: {}", e)))?;
389 let meta = entry
390 .metadata()
391 .map_err(|e| BoxError::BuildError(format!("Failed to stat entry: {}", e)))?;
392
393 if file_type.is_dir() {
394 append_dir_with_chown(builder, &tar_path, &path, chown)?;
395 add_dir_to_tar(builder, root, &path, target_prefix, chown)?;
396 } else {
397 append_file_with_chown(builder, &tar_path, &path, &meta, chown)?;
398 }
399 }
400
401 Ok(())
402}
403
404fn append_dir_with_chown<W: std::io::Write>(
406 builder: &mut tar::Builder<W>,
407 tar_path: &Path,
408 dir_path: &Path,
409 chown: Option<(u32, u32)>,
410) -> Result<()> {
411 if let Some((uid, gid)) = chown {
412 let mut header = tar::Header::new_gnu();
413 let meta = std::fs::symlink_metadata(dir_path).map_err(|e| {
414 BoxError::BuildError(format!("Failed to stat {}: {}", dir_path.display(), e))
415 })?;
416 header.set_metadata_in_mode(&meta, tar::HeaderMode::Complete);
417 header.set_uid(uid as u64);
418 header.set_gid(gid as u64);
419 header.set_username("").ok();
420 header.set_groupname("").ok();
421 header.set_cksum();
422 builder
423 .append_data(&mut header, tar_path, std::io::empty())
424 .map_err(|e| BoxError::BuildError(format!("Failed to add dir to layer: {}", e)))
425 } else {
426 builder
427 .append_dir(tar_path, dir_path)
428 .map_err(|e| BoxError::BuildError(format!("Failed to add directory to layer: {}", e)))
429 }
430}
431
432fn append_file_with_chown<W: std::io::Write>(
434 builder: &mut tar::Builder<W>,
435 tar_path: &Path,
436 file_path: &Path,
437 meta: &std::fs::Metadata,
438 chown: Option<(u32, u32)>,
439) -> Result<()> {
440 if let Some((uid, gid)) = chown {
441 let mut header = tar::Header::new_gnu();
442 header.set_metadata_in_mode(meta, tar::HeaderMode::Complete);
443 header.set_uid(uid as u64);
444 header.set_gid(gid as u64);
445 header.set_username("").ok();
446 header.set_groupname("").ok();
447 header.set_cksum();
448 let body: Box<dyn std::io::Read> = if meta.file_type().is_symlink() {
450 Box::new(std::io::empty())
451 } else {
452 Box::new(std::fs::File::open(file_path).map_err(|e| {
453 BoxError::BuildError(format!("Failed to open {}: {}", file_path.display(), e))
454 })?)
455 };
456 builder
457 .append_data(&mut header, tar_path, body)
458 .map_err(|e| BoxError::BuildError(format!("Failed to add file to layer: {}", e)))
459 } else {
460 builder
461 .append_path_with_name(file_path, tar_path)
462 .map_err(|e| BoxError::BuildError(format!("Failed to add file to layer: {}", e)))
463 }
464}
465
466#[derive(Debug, Clone)]
468pub struct LayerInfo {
469 pub path: PathBuf,
471 pub digest: String,
473 pub size: u64,
475}
476
477impl LayerInfo {
478 pub fn prefixed_digest(&self) -> String {
480 format!("sha256:{}", self.digest)
481 }
482}
483
484pub(super) fn sha256_file(path: &Path) -> Result<String> {
486 use sha2::Digest as _;
487 use std::io::Read as _;
488
489 let mut file = std::fs::File::open(path).map_err(|e| {
490 BoxError::BuildError(format!(
491 "Failed to open file for hashing {}: {}",
492 path.display(),
493 e
494 ))
495 })?;
496 let mut hasher = Sha256::new();
497 let mut buf = [0u8; 65536];
498 loop {
499 let n = file.read(&mut buf).map_err(|e| {
500 BoxError::BuildError(format!(
501 "Failed to read file for hashing {}: {}",
502 path.display(),
503 e
504 ))
505 })?;
506 if n == 0 {
507 break;
508 }
509 hasher.update(&buf[..n]);
510 }
511 Ok(hex::encode(hasher.finalize()))
512}
513
514pub(super) fn sha256_bytes(data: &[u8]) -> String {
516 hex::encode(Sha256::digest(data))
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use std::fs;
523 use tempfile::TempDir;
524
525 #[test]
528 fn test_snapshot_empty_dir() {
529 let tmp = TempDir::new().unwrap();
530 let snap = DirSnapshot::capture(tmp.path()).unwrap();
531 assert!(snap.entries.is_empty());
532 }
533
534 #[test]
535 fn test_snapshot_with_files() {
536 let tmp = TempDir::new().unwrap();
537 fs::write(tmp.path().join("a.txt"), "hello").unwrap();
538 fs::create_dir(tmp.path().join("sub")).unwrap();
539 fs::write(tmp.path().join("sub").join("b.txt"), "world").unwrap();
540
541 let snap = DirSnapshot::capture(tmp.path()).unwrap();
542 assert!(snap.entries.contains_key(&PathBuf::from("a.txt")));
543 assert!(snap.entries.contains_key(&PathBuf::from("sub")));
544 assert!(snap.entries.contains_key(&PathBuf::from("sub/b.txt")));
545 }
546
547 #[test]
548 fn test_snapshot_diff_new_file() {
549 let tmp = TempDir::new().unwrap();
550 fs::write(tmp.path().join("a.txt"), "hello").unwrap();
551
552 let before = DirSnapshot::capture(tmp.path()).unwrap();
553
554 fs::write(tmp.path().join("b.txt"), "world").unwrap();
555
556 let after = DirSnapshot::capture(tmp.path()).unwrap();
557
558 let diff = before.diff(&after);
559 assert_eq!(diff, vec![PathBuf::from("b.txt")]);
560 }
561
562 #[test]
563 fn test_snapshot_deletions_top_level_file() {
564 let tmp = TempDir::new().unwrap();
565 fs::write(tmp.path().join("keep.txt"), "k").unwrap();
566 fs::write(tmp.path().join("gone.txt"), "g").unwrap();
567 let before = DirSnapshot::capture(tmp.path()).unwrap();
568
569 fs::remove_file(tmp.path().join("gone.txt")).unwrap();
570 let after = DirSnapshot::capture(tmp.path()).unwrap();
571
572 assert_eq!(before.deletions(&after), vec![PathBuf::from("gone.txt")]);
573 }
574
575 #[test]
576 fn test_snapshot_deletions_collapses_deleted_dir_to_top_level() {
577 let tmp = TempDir::new().unwrap();
578 fs::create_dir(tmp.path().join("d")).unwrap();
579 fs::write(tmp.path().join("d").join("a.txt"), "a").unwrap();
580 fs::write(tmp.path().join("d").join("b.txt"), "b").unwrap();
581 let before = DirSnapshot::capture(tmp.path()).unwrap();
582
583 fs::remove_dir_all(tmp.path().join("d")).unwrap();
584 let after = DirSnapshot::capture(tmp.path()).unwrap();
585
586 assert_eq!(before.deletions(&after), vec![PathBuf::from("d")]);
589 }
590
591 #[test]
592 fn test_create_layer_emits_whiteout_for_deletions() {
593 use flate2::read::GzDecoder;
594
595 let rootfs = TempDir::new().unwrap();
596 let out = TempDir::new().unwrap();
597 fs::write(rootfs.path().join("kept.txt"), "kept").unwrap();
598 let layer_path = out.path().join("layer.tar.gz");
599
600 create_layer_with_deletions(
601 rootfs.path(),
602 &[PathBuf::from("kept.txt")],
603 &[PathBuf::from("usr/share/doc/removed.txt")],
604 &layer_path,
605 )
606 .unwrap();
607
608 let tar_gz = fs::File::open(&layer_path).unwrap();
609 let mut archive = tar::Archive::new(GzDecoder::new(tar_gz));
610 let names: Vec<String> = archive
611 .entries()
612 .unwrap()
613 .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
614 .collect();
615
616 assert!(
617 names.iter().any(|n| n == "usr/share/doc/.wh.removed.txt"),
618 "expected an OCI whiteout marker, got: {names:?}"
619 );
620 assert!(names.iter().any(|n| n == "kept.txt"));
621 }
622
623 #[test]
624 fn test_snapshot_diff_modified_file() {
625 let tmp = TempDir::new().unwrap();
626 fs::write(tmp.path().join("a.txt"), "hello").unwrap();
627
628 let before = DirSnapshot::capture(tmp.path()).unwrap();
629
630 fs::write(tmp.path().join("a.txt"), "hello world").unwrap();
632
633 let after = DirSnapshot::capture(tmp.path()).unwrap();
634
635 let diff = before.diff(&after);
636 assert_eq!(diff, vec![PathBuf::from("a.txt")]);
637 }
638
639 #[test]
643 #[cfg(unix)]
644 fn test_snapshot_diff_detects_chmod_only() {
645 use std::os::unix::fs::PermissionsExt;
646 let tmp = TempDir::new().unwrap();
647 let f = tmp.path().join("entry.sh");
648 fs::write(&f, "#!/bin/sh\necho hi\n").unwrap();
649 fs::set_permissions(&f, fs::Permissions::from_mode(0o644)).unwrap();
650
651 let before = DirSnapshot::capture(tmp.path()).unwrap();
652 fs::set_permissions(&f, fs::Permissions::from_mode(0o755)).unwrap();
654 let after = DirSnapshot::capture(tmp.path()).unwrap();
655
656 assert_eq!(before.diff(&after), vec![PathBuf::from("entry.sh")]);
657 }
658
659 #[test]
660 fn test_snapshot_diff_no_changes() {
661 let tmp = TempDir::new().unwrap();
662 fs::write(tmp.path().join("a.txt"), "hello").unwrap();
663
664 let before = DirSnapshot::capture(tmp.path()).unwrap();
665 let after = DirSnapshot::capture(tmp.path()).unwrap();
666
667 let diff = before.diff(&after);
668 assert!(diff.is_empty());
669 }
670
671 #[test]
674 fn test_create_layer_from_files() {
675 let rootfs = TempDir::new().unwrap();
676 let output_dir = TempDir::new().unwrap();
677
678 fs::write(rootfs.path().join("hello.txt"), "hello").unwrap();
679 fs::write(rootfs.path().join("world.txt"), "world").unwrap();
680
681 let output_path = output_dir.path().join("layer.tar.gz");
682 let changed = vec![PathBuf::from("hello.txt"), PathBuf::from("world.txt")];
683
684 let info = create_layer(rootfs.path(), &changed, &output_path).unwrap();
685
686 assert!(info.path.exists());
687 assert!(info.size > 0);
688 assert!(!info.digest.is_empty());
689 assert_eq!(info.digest.len(), 64); }
691
692 #[test]
693 fn test_create_layer_empty() {
694 let rootfs = TempDir::new().unwrap();
695 let output_dir = TempDir::new().unwrap();
696 let output_path = output_dir.path().join("layer.tar.gz");
697
698 let info = create_layer(rootfs.path(), &[], &output_path).unwrap();
699 assert!(info.path.exists());
700 }
701
702 #[test]
707 fn test_create_layer_digest_matches_completed_file() {
708 let rootfs = TempDir::new().unwrap();
709 let output_dir = TempDir::new().unwrap();
710 fs::write(rootfs.path().join("a.txt"), "AAAA-content").unwrap();
711
712 let out = output_dir.path().join("layer.tar.gz");
713 let info = create_layer(rootfs.path(), &[PathBuf::from("a.txt")], &out).unwrap();
714
715 let on_disk = sha256_file(&info.path).unwrap();
718 let on_disk_size = fs::metadata(&info.path).unwrap().len();
719 assert_eq!(
720 info.digest, on_disk,
721 "recorded digest must match completed file"
722 );
723 assert_eq!(
724 info.size, on_disk_size,
725 "recorded size must match completed file"
726 );
727 assert!(
728 info.size > 20,
729 "a real one-file layer is larger than an empty gzip header"
730 );
731 }
732
733 #[test]
736 fn test_create_layer_distinct_content_distinct_digest() {
737 let rootfs = TempDir::new().unwrap();
738 let out_dir = TempDir::new().unwrap();
739
740 fs::write(rootfs.path().join("a.txt"), "AAAA-content").unwrap();
741 let a = create_layer(
742 rootfs.path(),
743 &[PathBuf::from("a.txt")],
744 &out_dir.path().join("a.tgz"),
745 )
746 .unwrap();
747
748 fs::write(rootfs.path().join("b.txt"), "BBBB-different-longer").unwrap();
749 let b = create_layer(
750 rootfs.path(),
751 &[PathBuf::from("b.txt")],
752 &out_dir.path().join("b.tgz"),
753 )
754 .unwrap();
755
756 assert_ne!(
757 a.digest, b.digest,
758 "distinct layer content must yield distinct digests"
759 );
760 }
761
762 #[test]
765 fn test_create_layer_from_dir() {
766 let src = TempDir::new().unwrap();
767 let output_dir = TempDir::new().unwrap();
768
769 fs::write(src.path().join("app.py"), "print('hi')").unwrap();
770 fs::create_dir(src.path().join("lib")).unwrap();
771 fs::write(src.path().join("lib").join("util.py"), "pass").unwrap();
772
773 let output_path = output_dir.path().join("layer.tar.gz");
774 let info = create_layer_from_dir(src.path(), Path::new("workspace"), &output_path).unwrap();
775
776 assert!(info.path.exists());
777 assert!(info.size > 0);
778
779 let file = fs::File::open(&info.path).unwrap();
781 let decoder = flate2::read::GzDecoder::new(file);
782 let mut archive = tar::Archive::new(decoder);
783 let paths: Vec<String> = archive
784 .entries()
785 .unwrap()
786 .filter_map(|e| e.ok())
787 .map(|e| e.path().unwrap().to_string_lossy().to_string())
788 .collect();
789
790 assert!(paths.iter().any(|p| p.contains("workspace/app.py")));
791 assert!(paths.iter().any(|p| p.contains("workspace/lib")));
792 }
793
794 #[test]
797 #[cfg(unix)]
798 fn test_create_layer_from_dir_preserves_symlinks() {
799 let src = TempDir::new().unwrap();
800 let output_dir = TempDir::new().unwrap();
801 fs::write(src.path().join("libfoo.so.1"), "real").unwrap();
802 std::os::unix::fs::symlink("libfoo.so.1", src.path().join("libfoo.so")).unwrap();
803
804 let out = output_dir.path().join("layer.tar.gz");
805 create_layer_from_dir(src.path(), Path::new("lib"), &out).unwrap();
806
807 let file = fs::File::open(&out).unwrap();
808 let decoder = flate2::read::GzDecoder::new(file);
809 let mut archive = tar::Archive::new(decoder);
810 let mut found_symlink = false;
811 for entry in archive.entries().unwrap() {
812 let entry = entry.unwrap();
813 if entry.path().unwrap().to_string_lossy() == "lib/libfoo.so" {
814 assert_eq!(entry.header().entry_type(), tar::EntryType::Symlink);
815 assert_eq!(
816 entry.link_name().unwrap().unwrap().to_string_lossy(),
817 "libfoo.so.1"
818 );
819 found_symlink = true;
820 }
821 }
822 assert!(found_symlink, "symlink entry must be present in the layer");
823 }
824
825 #[test]
828 fn test_sha256_file() {
829 let tmp = TempDir::new().unwrap();
830 let path = tmp.path().join("test.txt");
831 fs::write(&path, "hello").unwrap();
832
833 let digest = sha256_file(&path).unwrap();
834 assert_eq!(digest.len(), 64);
835 assert_eq!(
837 digest,
838 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
839 );
840 }
841
842 #[test]
843 fn test_sha256_bytes() {
844 let digest = sha256_bytes(b"hello");
845 assert_eq!(
846 digest,
847 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
848 );
849 }
850
851 #[test]
852 fn test_layer_info_prefixed_digest() {
853 let info = LayerInfo {
854 path: PathBuf::from("/tmp/layer.tar.gz"),
855 digest: "abc123".to_string(),
856 size: 100,
857 };
858 assert_eq!(info.prefixed_digest(), "sha256:abc123");
859 }
860}