Skip to main content

a3s_box_runtime/oci/build/
layer.rs

1//! Layer creation utilities for image building.
2//!
3//! Provides filesystem snapshotting, diffing, and tar.gz layer creation
4//! for producing OCI image layers from build steps.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use a3s_box_core::error::{BoxError, Result};
10use sha2::{Digest, Sha256};
11
12/// Metadata for a single file in a snapshot.
13#[derive(Debug, Clone, PartialEq)]
14pub struct FileEntry {
15    /// Relative path from rootfs root
16    pub path: PathBuf,
17    /// File size in bytes
18    pub size: u64,
19    /// Modification time (seconds since epoch)
20    pub mtime: i64,
21    /// Unix permission/mode bits. A `chmod` (e.g. `RUN chmod +x`) changes only
22    /// this — not size or mtime — so it must be part of the change check or the
23    /// new mode is silently dropped from the layer.
24    pub mode: u32,
25    /// Whether this is a directory
26    pub is_dir: bool,
27}
28
29/// A snapshot of a directory's file state.
30#[derive(Debug, Clone)]
31pub struct DirSnapshot {
32    /// Map of relative path → file entry
33    pub entries: HashMap<PathBuf, FileEntry>,
34}
35
36impl DirSnapshot {
37    /// Take a snapshot of a directory, recording all files and their metadata.
38    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    /// Compute the diff between this snapshot (before) and another (after).
45    ///
46    /// Returns paths of files that were added or modified.
47    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                    // New file
54                    changed.push(path.clone());
55                }
56                Some(before_entry) => {
57                    // Modified: size, mtime, or mode changed. Mode matters for
58                    // `RUN chmod` (e.g. making a COPY'd script executable), which
59                    // touches neither size nor mtime.
60                    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        // Sort for deterministic output
71        changed.sort();
72        changed
73    }
74
75    /// Paths present in `self` (before) but absent in `after` (deleted),
76    /// reduced to top-level deletions: a deleted entry whose parent directory
77    /// still exists in `after` (or is the root). Whiteout-ing a deleted
78    /// directory implicitly removes its children, so emitting child whiteouts
79    /// too would be redundant and could confuse extraction order.
80    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; // still present
86            }
87            // Only emit when the parent still exists in `after` (so the parent
88            // dir wasn't itself deleted, which would already whiteout this).
89            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, // root-level entry
94            };
95            if parent_present {
96                deleted.push(path.clone());
97            }
98        }
99
100        // Sort for deterministic output
101        deleted.sort();
102        deleted
103    }
104}
105
106/// Recursively walk a directory and collect file entries.
107fn 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
176/// Create a tar.gz layer from a list of changed files in a rootfs.
177///
178/// Returns the path to the created layer file and its SHA256 digest.
179/// When `chown` is `Some((uid, gid))`, all tar entry headers are stamped with
180/// that uid/gid (regardless of the host filesystem owner), which is how Docker
181/// implements `COPY --chown` without requiring elevated permissions.
182pub 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
190/// `create_layer`, additionally writing OCI whiteout markers for `deleted_files`
191/// so files removed by a `RUN` are deleted from lower layers instead of silently
192/// persisting in the built image (which also keeps the image from ever shrinking).
193pub 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
202/// Internal: `create_layer` with optional uid/gid override for tar headers and
203/// OCI whiteout markers (`.wh.<name>`) for `deleted_files`.
204pub(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    // Preserve symlinks (e.g. created by `RUN ln -s`) as symlink entries.
225    builder.follow_symlinks(false);
226
227    for relative_path in changed_files {
228        let full_path = rootfs.join(relative_path);
229        // No-follow stat: captures symlinks (incl. dangling ones) without
230        // following, so a symlink is added as a symlink, not its target.
231        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    // OCI whiteouts for deleted paths: an empty `.wh.<name>` marker beside the
244    // removed entry tells the layer extractor (and any OCI runtime) to delete it
245    // from the lower layers. Without these, a file removed by a `RUN rm` would
246    // silently persist in the built image.
247    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    // Finish the tar archive AND the gzip stream so every byte is flushed to
279    // disk before we hash the file. `Builder::finish()` alone only writes the
280    // tar trailer into the still-buffered GzEncoder; the gzip data is not
281    // flushed to the file until the encoder is dropped/finished. Hashing before
282    // that flush would digest an incomplete file (the bug that gave every layer
283    // the same digest of the partial 10-byte gzip header).
284    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    // Compute SHA256 digest of the layer file
292    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
302/// Create a tar.gz layer from an entire directory (used for COPY).
303///
304/// All files under `src_dir` are added to the layer with paths relative
305/// to `target_prefix` (the destination path inside the image).
306pub 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
314/// Internal: `create_layer_from_dir` with optional uid/gid override.
315pub(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    // Preserve symlinks as symlink entries instead of copying their targets.
335    builder.follow_symlinks(false);
336
337    add_dir_to_tar(&mut builder, src_dir, src_dir, target_prefix, chown)?;
338
339    // Finish the tar AND flush the gzip stream to disk before hashing (see
340    // `create_layer` for why hashing before the gzip flush is incorrect).
341    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
358/// Recursively add a directory's contents to a tar builder.
359fn 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        // No-follow type check: a symlink (even one pointing at a directory)
385        // must be added as a symlink entry, not recursed into.
386        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
404/// Append a directory entry, overriding uid/gid when `chown` is set.
405fn 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
432/// Append a file/symlink entry, overriding uid/gid when `chown` is set.
433fn 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        // For symlinks, the data is empty (target is in link_name header field).
449        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/// Information about a created layer.
467#[derive(Debug, Clone)]
468pub struct LayerInfo {
469    /// Path to the layer tar.gz file
470    pub path: PathBuf,
471    /// SHA256 digest (hex string, without "sha256:" prefix)
472    pub digest: String,
473    /// Size in bytes
474    pub size: u64,
475}
476
477impl LayerInfo {
478    /// Get the digest with "sha256:" prefix.
479    pub fn prefixed_digest(&self) -> String {
480        format!("sha256:{}", self.digest)
481    }
482}
483
484/// Compute SHA256 digest of a file by streaming its contents.
485pub(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
514/// Compute SHA256 digest of raw bytes.
515pub(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    // --- DirSnapshot ---
526
527    #[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        // Only the directory itself, not its children: whiteout-ing the dir
587        // implicitly removes them.
588        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        // Modify the file (change size)
631        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    /// Regression: a `chmod`-only change (e.g. `RUN chmod +x`) changes the mode
640    /// but not size or mtime, and must still be detected so the new mode lands
641    /// in the layer (else a COPY'd script stays non-executable -> exec EACCES).
642    #[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        // chmod +x: mode 0644 -> 0755, same content/size, same mtime.
653        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    // --- create_layer ---
672
673    #[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); // SHA256 hex
690    }
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    /// Regression: the recorded digest/size must reflect the COMPLETE file
703    /// (gzip stream fully flushed), not a partially-written one. Previously the
704    /// digest was computed before the GzEncoder was finished, so every layer
705    /// recorded the same hash of the 10-byte gzip header.
706    #[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        // The digest/size recorded must equal the on-disk file, re-read after
716        // the function returned (i.e. the file was complete when hashed).
717        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    /// Regression: single-file layers with different content must produce
734    /// different digests (else the content-addressed store/cache collides).
735    #[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    // --- create_layer_from_dir ---
763
764    #[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        // Verify the tar contains files under workspace/
780        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    /// Regression: symlinks must be stored as symlink entries (Docker copies
795    /// them verbatim), not followed into a duplicate of their target.
796    #[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    // --- sha256 ---
826
827    #[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        // Known SHA256 of "hello"
836        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}