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    /// Unix owner user ID. A `chown` can change only ownership, so uid/gid must
26    /// be tracked or the generated layer silently drops the change.
27    pub uid: u32,
28    /// Unix owner group ID.
29    pub gid: u32,
30    /// Whether this is a directory
31    pub is_dir: bool,
32}
33
34/// A snapshot of a directory's file state.
35#[derive(Debug, Clone)]
36pub struct DirSnapshot {
37    /// Map of relative path → file entry
38    pub entries: HashMap<PathBuf, FileEntry>,
39}
40
41impl DirSnapshot {
42    /// Take a snapshot of a directory, recording all files and their metadata.
43    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    /// Compute the diff between this snapshot (before) and another (after).
50    ///
51    /// Returns paths of files that were added or modified.
52    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                    // New file
59                    changed.push(path.clone());
60                }
61                Some(before_entry) => {
62                    // Modified: size, mtime, or mode changed. Mode matters for
63                    // `RUN chmod` (e.g. making a COPY'd script executable), which
64                    // touches neither size nor mtime.
65                    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        // Sort for deterministic output
78        changed.sort();
79        changed
80    }
81
82    /// Paths present in `self` (before) but absent in `after` (deleted),
83    /// reduced to top-level deletions: a deleted entry whose parent directory
84    /// still exists in `after` (or is the root). Whiteout-ing a deleted
85    /// directory implicitly removes its children, so emitting child whiteouts
86    /// too would be redundant and could confuse extraction order.
87    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; // still present
93            }
94            // Only emit when the parent still exists in `after` (so the parent
95            // dir wasn't itself deleted, which would already whiteout this).
96            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, // root-level entry
101            };
102            if parent_present {
103                deleted.push(path.clone());
104            }
105        }
106
107        // Sort for deterministic output
108        deleted.sort();
109        deleted
110    }
111}
112
113/// Recursively walk a directory and collect file entries.
114fn 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
189/// Create a tar.gz layer from a list of changed files in a rootfs.
190///
191/// Returns the path to the created layer file and its SHA256 digest.
192/// When `chown` is `Some((uid, gid))`, all tar entry headers are stamped with
193/// that uid/gid (regardless of the host filesystem owner), which is how Docker
194/// implements `COPY --chown` without requiring elevated permissions.
195pub 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
203/// `create_layer`, additionally writing OCI whiteout markers for `deleted_files`
204/// so files removed by a `RUN` are deleted from lower layers instead of silently
205/// persisting in the built image (which also keeps the image from ever shrinking).
206pub 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
215/// Internal: `create_layer` with optional uid/gid override for tar headers and
216/// OCI whiteout markers (`.wh.<name>`) for `deleted_files`.
217pub(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    // Preserve symlinks (e.g. created by `RUN ln -s`) as symlink entries.
238    builder.follow_symlinks(false);
239
240    for relative_path in changed_files {
241        let full_path = rootfs.join(relative_path);
242        // No-follow stat: captures symlinks (incl. dangling ones) without
243        // following, so a symlink is added as a symlink, not its target.
244        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    // OCI whiteouts for deleted paths: an empty `.wh.<name>` marker beside the
257    // removed entry tells the layer extractor (and any OCI runtime) to delete it
258    // from the lower layers. Without these, a file removed by a `RUN rm` would
259    // silently persist in the built image.
260    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    // Finish the tar archive AND the gzip stream so every byte is flushed to
292    // disk before we hash the file. `Builder::finish()` alone only writes the
293    // tar trailer into the still-buffered GzEncoder; the gzip data is not
294    // flushed to the file until the encoder is dropped/finished. Hashing before
295    // that flush would digest an incomplete file (the bug that gave every layer
296    // the same digest of the partial 10-byte gzip header).
297    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    // Compute SHA256 digest of the layer file
305    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
315/// Create a tar.gz layer from an entire directory (used for COPY).
316///
317/// All files under `src_dir` are added to the layer with paths relative
318/// to `target_prefix` (the destination path inside the image).
319pub 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
327/// Internal: `create_layer_from_dir` with optional uid/gid override.
328pub(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    // Preserve symlinks as symlink entries instead of copying their targets.
348    builder.follow_symlinks(false);
349
350    add_dir_to_tar(&mut builder, src_dir, src_dir, target_prefix, chown)?;
351
352    // Finish the tar AND flush the gzip stream to disk before hashing (see
353    // `create_layer` for why hashing before the gzip flush is incorrect).
354    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
371/// Recursively add a directory's contents to a tar builder.
372fn 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        // No-follow type check: a symlink (even one pointing at a directory)
398        // must be added as a symlink entry, not recursed into.
399        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
417/// Append a directory entry, overriding uid/gid when `chown` is set.
418fn 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
445/// Append a file/symlink entry, overriding uid/gid when `chown` is set.
446fn 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        // For symlinks, the data is empty (target is in link_name header field).
462        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/// Information about a created layer.
480#[derive(Debug, Clone)]
481pub struct LayerInfo {
482    /// Path to the layer tar.gz file
483    pub path: PathBuf,
484    /// SHA256 digest (hex string, without "sha256:" prefix)
485    pub digest: String,
486    /// Size in bytes
487    pub size: u64,
488}
489
490impl LayerInfo {
491    /// Get the digest with "sha256:" prefix.
492    pub fn prefixed_digest(&self) -> String {
493        format!("sha256:{}", self.digest)
494    }
495}
496
497/// Compute SHA256 digest of a file by streaming its contents.
498pub(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
527/// Compute SHA256 digest of raw bytes.
528pub(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    // --- DirSnapshot ---
540
541    #[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        // Only the directory itself, not its children: whiteout-ing the dir
601        // implicitly removes them.
602        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        // Modify the file (change size)
645        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    /// Regression: a `chmod`-only change (e.g. `RUN chmod +x`) changes the mode
654    /// but not size or mtime, and must still be detected so the new mode lands
655    /// in the layer (else a COPY'd script stays non-executable -> exec EACCES).
656    #[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        // chmod +x: mode 0644 -> 0755, same content/size, same mtime.
667        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    // --- create_layer ---
721
722    #[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); // SHA256 hex
739    }
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    /// Regression: the recorded digest/size must reflect the COMPLETE file
752    /// (gzip stream fully flushed), not a partially-written one. Previously the
753    /// digest was computed before the GzEncoder was finished, so every layer
754    /// recorded the same hash of the 10-byte gzip header.
755    #[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        // The digest/size recorded must equal the on-disk file, re-read after
765        // the function returned (i.e. the file was complete when hashed).
766        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    /// Regression: single-file layers with different content must produce
783    /// different digests (else the content-addressed store/cache collides).
784    #[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    // --- create_layer_from_dir ---
812
813    #[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        // Verify the tar contains files under workspace/
829        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    /// Regression: symlinks must be stored as symlink entries (Docker copies
844    /// them verbatim), not followed into a duplicate of their target.
845    #[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    // --- sha256 ---
875
876    #[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        // Known SHA256 of "hello"
885        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}