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 file_type = entry.file_type().map_err(|e| {
140            BoxError::BuildError(format!(
141                "Failed to read file type for {}: {}",
142                path.display(),
143                e
144            ))
145        })?;
146        let metadata = std::fs::symlink_metadata(&path).map_err(|e| {
147            BoxError::BuildError(format!(
148                "Failed to read metadata for {}: {}",
149                path.display(),
150                e
151            ))
152        })?;
153
154        let mtime = metadata
155            .modified()
156            .map(|t| {
157                t.duration_since(std::time::UNIX_EPOCH)
158                    .unwrap_or_default()
159                    .as_secs() as i64
160            })
161            .unwrap_or(0);
162
163        #[cfg(unix)]
164        let (mode, uid, gid) = {
165            use std::os::unix::fs::{MetadataExt, PermissionsExt};
166            (
167                metadata.permissions().mode(),
168                metadata.uid(),
169                metadata.gid(),
170            )
171        };
172        #[cfg(not(unix))]
173        let (mode, uid, gid) = (0u32, 0u32, 0u32);
174
175        entries.insert(
176            relative.clone(),
177            FileEntry {
178                path: relative,
179                size: metadata.len(),
180                mtime,
181                mode,
182                uid,
183                gid,
184                is_dir: file_type.is_dir(),
185            },
186        );
187
188        if file_type.is_dir() {
189            walk_dir(root, &path, entries)?;
190        }
191    }
192
193    Ok(())
194}
195
196/// Create a tar.gz layer from a list of changed files in a rootfs.
197///
198/// Returns the path to the created layer file and its SHA256 digest.
199/// When `chown` is `Some((uid, gid))`, all tar entry headers are stamped with
200/// that uid/gid (regardless of the host filesystem owner), which is how Docker
201/// implements `COPY --chown` without requiring elevated permissions.
202pub fn create_layer(
203    rootfs: &Path,
204    changed_files: &[PathBuf],
205    output_path: &Path,
206) -> Result<LayerInfo> {
207    create_layer_with_chown(rootfs, changed_files, &[], output_path, None)
208}
209
210/// `create_layer`, additionally writing OCI whiteout markers for `deleted_files`
211/// so files removed by a `RUN` are deleted from lower layers instead of silently
212/// persisting in the built image (which also keeps the image from ever shrinking).
213pub fn create_layer_with_deletions(
214    rootfs: &Path,
215    changed_files: &[PathBuf],
216    deleted_files: &[PathBuf],
217    output_path: &Path,
218) -> Result<LayerInfo> {
219    create_layer_with_chown(rootfs, changed_files, deleted_files, output_path, None)
220}
221
222/// Internal: `create_layer` with optional uid/gid override for tar headers and
223/// OCI whiteout markers (`.wh.<name>`) for `deleted_files`.
224pub(super) fn create_layer_with_chown(
225    rootfs: &Path,
226    changed_files: &[PathBuf],
227    deleted_files: &[PathBuf],
228    output_path: &Path,
229    chown: Option<(u32, u32)>,
230) -> Result<LayerInfo> {
231    use flate2::write::GzEncoder;
232    use flate2::Compression;
233
234    let file = std::fs::File::create(output_path).map_err(|e| {
235        BoxError::BuildError(format!(
236            "Failed to create layer file {}: {}",
237            output_path.display(),
238            e
239        ))
240    })?;
241
242    let encoder = GzEncoder::new(file, Compression::default());
243    let mut builder = tar::Builder::new(encoder);
244    // Preserve symlinks (e.g. created by `RUN ln -s`) as symlink entries.
245    builder.follow_symlinks(false);
246
247    for relative_path in changed_files {
248        let full_path = rootfs.join(relative_path);
249        // No-follow stat: captures symlinks (incl. dangling ones) without
250        // following, so a symlink is added as a symlink, not its target.
251        let meta = match std::fs::symlink_metadata(&full_path) {
252            Ok(meta) => meta,
253            Err(_) => continue,
254        };
255
256        if meta.is_dir() {
257            append_dir_with_chown(&mut builder, relative_path, &full_path, chown)?;
258        } else {
259            append_file_with_chown(&mut builder, relative_path, &full_path, &meta, chown)?;
260        }
261    }
262
263    // OCI whiteouts for deleted paths: an empty `.wh.<name>` marker beside the
264    // removed entry tells the layer extractor (and any OCI runtime) to delete it
265    // from the lower layers. Without these, a file removed by a `RUN rm` would
266    // silently persist in the built image.
267    for deleted in deleted_files {
268        let Some(file_name) = deleted.file_name() else {
269            continue;
270        };
271        let wh_name = format!(".wh.{}", file_name.to_string_lossy());
272        let wh_path = match deleted.parent() {
273            Some(parent) if !parent.as_os_str().is_empty() => parent.join(&wh_name),
274            _ => PathBuf::from(&wh_name),
275        };
276
277        let mut header = tar::Header::new_gnu();
278        header.set_size(0);
279        header.set_entry_type(tar::EntryType::Regular);
280        header.set_mode(0o644);
281        header.set_mtime(0);
282        if let Some((uid, gid)) = chown {
283            header.set_uid(uid as u64);
284            header.set_gid(gid as u64);
285        }
286        header.set_cksum();
287        builder
288            .append_data(&mut header, &wh_path, std::io::empty())
289            .map_err(|e| {
290                BoxError::BuildError(format!(
291                    "Failed to append whiteout {}: {}",
292                    wh_path.display(),
293                    e
294                ))
295            })?;
296    }
297
298    // Finish the tar archive AND the gzip stream so every byte is flushed to
299    // disk before we hash the file. `Builder::finish()` alone only writes the
300    // tar trailer into the still-buffered GzEncoder; the gzip data is not
301    // flushed to the file until the encoder is dropped/finished. Hashing before
302    // that flush would digest an incomplete file (the bug that gave every layer
303    // the same digest of the partial 10-byte gzip header).
304    let encoder = builder
305        .into_inner()
306        .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer tar: {}", e)))?;
307    encoder
308        .finish()
309        .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer gzip: {}", e)))?;
310
311    // Compute SHA256 digest of the layer file
312    let digest = sha256_file(output_path)?;
313    let size = std::fs::metadata(output_path).map(|m| m.len()).unwrap_or(0);
314
315    Ok(LayerInfo {
316        path: output_path.to_path_buf(),
317        digest,
318        size,
319    })
320}
321
322/// Create a tar.gz layer from an entire directory (used for COPY).
323///
324/// All files under `src_dir` are added to the layer with paths relative
325/// to `target_prefix` (the destination path inside the image).
326pub fn create_layer_from_dir(
327    src_dir: &Path,
328    target_prefix: &Path,
329    output_path: &Path,
330) -> Result<LayerInfo> {
331    create_layer_from_dir_with_chown(src_dir, target_prefix, output_path, None)
332}
333
334/// Internal: `create_layer_from_dir` with optional uid/gid override.
335pub(super) fn create_layer_from_dir_with_chown(
336    src_dir: &Path,
337    target_prefix: &Path,
338    output_path: &Path,
339    chown: Option<(u32, u32)>,
340) -> Result<LayerInfo> {
341    use flate2::write::GzEncoder;
342    use flate2::Compression;
343
344    let file = std::fs::File::create(output_path).map_err(|e| {
345        BoxError::BuildError(format!(
346            "Failed to create layer file {}: {}",
347            output_path.display(),
348            e
349        ))
350    })?;
351
352    let encoder = GzEncoder::new(file, Compression::default());
353    let mut builder = tar::Builder::new(encoder);
354    // Preserve symlinks as symlink entries instead of copying their targets.
355    builder.follow_symlinks(false);
356
357    add_dir_to_tar(&mut builder, src_dir, src_dir, target_prefix, chown)?;
358
359    // Finish the tar AND flush the gzip stream to disk before hashing (see
360    // `create_layer` for why hashing before the gzip flush is incorrect).
361    let encoder = builder
362        .into_inner()
363        .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer tar: {}", e)))?;
364    encoder
365        .finish()
366        .map_err(|e| BoxError::BuildError(format!("Failed to finalize layer gzip: {}", e)))?;
367
368    let digest = sha256_file(output_path)?;
369    let size = std::fs::metadata(output_path).map(|m| m.len()).unwrap_or(0);
370
371    Ok(LayerInfo {
372        path: output_path.to_path_buf(),
373        digest,
374        size,
375    })
376}
377
378/// Recursively add a directory's contents to a tar builder.
379fn add_dir_to_tar<W: std::io::Write>(
380    builder: &mut tar::Builder<W>,
381    root: &Path,
382    current: &Path,
383    target_prefix: &Path,
384    chown: Option<(u32, u32)>,
385) -> Result<()> {
386    let entries = std::fs::read_dir(current).map_err(|e| {
387        BoxError::BuildError(format!(
388            "Failed to read directory {}: {}",
389            current.display(),
390            e
391        ))
392    })?;
393
394    for entry in entries {
395        let entry =
396            entry.map_err(|e| BoxError::BuildError(format!("Failed to read entry: {}", e)))?;
397
398        let path = entry.path();
399        let relative = path
400            .strip_prefix(root)
401            .map_err(|e| BoxError::BuildError(format!("Failed to strip prefix: {}", e)))?;
402        let tar_path = target_prefix.join(relative);
403
404        // No-follow type check: a symlink (even one pointing at a directory)
405        // must be added as a symlink entry, not recursed into.
406        let file_type = entry
407            .file_type()
408            .map_err(|e| BoxError::BuildError(format!("Failed to stat entry: {}", e)))?;
409        let meta = std::fs::symlink_metadata(&path)
410            .map_err(|e| BoxError::BuildError(format!("Failed to stat entry: {}", e)))?;
411
412        if file_type.is_dir() {
413            append_dir_with_chown(builder, &tar_path, &path, chown)?;
414            add_dir_to_tar(builder, root, &path, target_prefix, chown)?;
415        } else {
416            append_file_with_chown(builder, &tar_path, &path, &meta, chown)?;
417        }
418    }
419
420    Ok(())
421}
422
423/// Append a directory entry, overriding uid/gid when `chown` is set.
424fn append_dir_with_chown<W: std::io::Write>(
425    builder: &mut tar::Builder<W>,
426    tar_path: &Path,
427    dir_path: &Path,
428    chown: Option<(u32, u32)>,
429) -> Result<()> {
430    if let Some((uid, gid)) = chown {
431        let mut header = tar::Header::new_gnu();
432        let meta = std::fs::symlink_metadata(dir_path).map_err(|e| {
433            BoxError::BuildError(format!("Failed to stat {}: {}", dir_path.display(), e))
434        })?;
435        header.set_metadata_in_mode(&meta, tar::HeaderMode::Complete);
436        header.set_uid(uid as u64);
437        header.set_gid(gid as u64);
438        header.set_username("").ok();
439        header.set_groupname("").ok();
440        header.set_cksum();
441        builder
442            .append_data(&mut header, tar_path, std::io::empty())
443            .map_err(|e| BoxError::BuildError(format!("Failed to add dir to layer: {}", e)))
444    } else {
445        builder
446            .append_dir(tar_path, dir_path)
447            .map_err(|e| BoxError::BuildError(format!("Failed to add directory to layer: {}", e)))
448    }
449}
450
451/// Append a file/symlink entry, overriding uid/gid when `chown` is set.
452fn append_file_with_chown<W: std::io::Write>(
453    builder: &mut tar::Builder<W>,
454    tar_path: &Path,
455    file_path: &Path,
456    meta: &std::fs::Metadata,
457    chown: Option<(u32, u32)>,
458) -> Result<()> {
459    if let Some((uid, gid)) = chown {
460        let mut header = tar::Header::new_gnu();
461        header.set_metadata_in_mode(meta, tar::HeaderMode::Complete);
462        header.set_uid(uid as u64);
463        header.set_gid(gid as u64);
464        header.set_username("").ok();
465        header.set_groupname("").ok();
466        if meta.file_type().is_symlink() {
467            let target = std::fs::read_link(file_path).map_err(|e| {
468                BoxError::BuildError(format!(
469                    "Failed to read layer symlink {}: {}",
470                    file_path.display(),
471                    e
472                ))
473            })?;
474            header.set_entry_type(tar::EntryType::Symlink);
475            header.set_size(0);
476            header.set_link_name(&target).map_err(|e| {
477                BoxError::BuildError(format!(
478                    "Failed to encode layer symlink {} -> {}: {}",
479                    file_path.display(),
480                    target.display(),
481                    e
482                ))
483            })?;
484        }
485        header.set_cksum();
486        // For symlinks, the data is empty (target is in link_name header field).
487        let body: Box<dyn std::io::Read> = if meta.file_type().is_symlink() {
488            Box::new(std::io::empty())
489        } else {
490            Box::new(std::fs::File::open(file_path).map_err(|e| {
491                BoxError::BuildError(format!("Failed to open {}: {}", file_path.display(), e))
492            })?)
493        };
494        builder
495            .append_data(&mut header, tar_path, body)
496            .map_err(|e| BoxError::BuildError(format!("Failed to add file to layer: {}", e)))
497    } else {
498        builder
499            .append_path_with_name(file_path, tar_path)
500            .map_err(|e| BoxError::BuildError(format!("Failed to add file to layer: {}", e)))
501    }
502}
503
504/// Information about a created layer.
505#[derive(Debug, Clone)]
506pub struct LayerInfo {
507    /// Path to the layer tar.gz file
508    pub path: PathBuf,
509    /// SHA256 digest (hex string, without "sha256:" prefix)
510    pub digest: String,
511    /// Size in bytes
512    pub size: u64,
513}
514
515impl LayerInfo {
516    /// Get the digest with "sha256:" prefix.
517    pub fn prefixed_digest(&self) -> String {
518        format!("sha256:{}", self.digest)
519    }
520}
521
522/// Compute SHA256 digest of a file by streaming its contents.
523pub(super) fn sha256_file(path: &Path) -> Result<String> {
524    use sha2::Digest as _;
525    use std::io::Read as _;
526
527    let mut file = std::fs::File::open(path).map_err(|e| {
528        BoxError::BuildError(format!(
529            "Failed to open file for hashing {}: {}",
530            path.display(),
531            e
532        ))
533    })?;
534    let mut hasher = Sha256::new();
535    let mut buf = [0u8; 65536];
536    loop {
537        let n = file.read(&mut buf).map_err(|e| {
538            BoxError::BuildError(format!(
539                "Failed to read file for hashing {}: {}",
540                path.display(),
541                e
542            ))
543        })?;
544        if n == 0 {
545            break;
546        }
547        hasher.update(&buf[..n]);
548    }
549    Ok(hex::encode(hasher.finalize()))
550}
551
552/// Compute SHA256 digest of raw bytes.
553pub(super) fn sha256_bytes(data: &[u8]) -> String {
554    hex::encode(Sha256::digest(data))
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use std::collections::HashMap;
561    use std::fs;
562    use tempfile::TempDir;
563
564    // --- DirSnapshot ---
565
566    #[test]
567    fn test_snapshot_empty_dir() {
568        let tmp = TempDir::new().unwrap();
569        let snap = DirSnapshot::capture(tmp.path()).unwrap();
570        assert!(snap.entries.is_empty());
571    }
572
573    #[test]
574    fn test_snapshot_with_files() {
575        let tmp = TempDir::new().unwrap();
576        fs::write(tmp.path().join("a.txt"), "hello").unwrap();
577        fs::create_dir(tmp.path().join("sub")).unwrap();
578        fs::write(tmp.path().join("sub").join("b.txt"), "world").unwrap();
579
580        let snap = DirSnapshot::capture(tmp.path()).unwrap();
581        assert!(snap.entries.contains_key(&PathBuf::from("a.txt")));
582        assert!(snap.entries.contains_key(&PathBuf::from("sub")));
583        assert!(snap.entries.contains_key(&PathBuf::from("sub/b.txt")));
584    }
585
586    #[test]
587    fn test_snapshot_diff_new_file() {
588        let tmp = TempDir::new().unwrap();
589        fs::write(tmp.path().join("a.txt"), "hello").unwrap();
590
591        let before = DirSnapshot::capture(tmp.path()).unwrap();
592
593        fs::write(tmp.path().join("b.txt"), "world").unwrap();
594
595        let after = DirSnapshot::capture(tmp.path()).unwrap();
596
597        let diff = before.diff(&after);
598        assert_eq!(diff, vec![PathBuf::from("b.txt")]);
599    }
600
601    #[test]
602    fn test_snapshot_deletions_top_level_file() {
603        let tmp = TempDir::new().unwrap();
604        fs::write(tmp.path().join("keep.txt"), "k").unwrap();
605        fs::write(tmp.path().join("gone.txt"), "g").unwrap();
606        let before = DirSnapshot::capture(tmp.path()).unwrap();
607
608        fs::remove_file(tmp.path().join("gone.txt")).unwrap();
609        let after = DirSnapshot::capture(tmp.path()).unwrap();
610
611        assert_eq!(before.deletions(&after), vec![PathBuf::from("gone.txt")]);
612    }
613
614    #[test]
615    fn test_snapshot_deletions_collapses_deleted_dir_to_top_level() {
616        let tmp = TempDir::new().unwrap();
617        fs::create_dir(tmp.path().join("d")).unwrap();
618        fs::write(tmp.path().join("d").join("a.txt"), "a").unwrap();
619        fs::write(tmp.path().join("d").join("b.txt"), "b").unwrap();
620        let before = DirSnapshot::capture(tmp.path()).unwrap();
621
622        fs::remove_dir_all(tmp.path().join("d")).unwrap();
623        let after = DirSnapshot::capture(tmp.path()).unwrap();
624
625        // Only the directory itself, not its children: whiteout-ing the dir
626        // implicitly removes them.
627        assert_eq!(before.deletions(&after), vec![PathBuf::from("d")]);
628    }
629
630    #[test]
631    fn test_create_layer_emits_whiteout_for_deletions() {
632        use flate2::read::GzDecoder;
633
634        let rootfs = TempDir::new().unwrap();
635        let out = TempDir::new().unwrap();
636        fs::write(rootfs.path().join("kept.txt"), "kept").unwrap();
637        let layer_path = out.path().join("layer.tar.gz");
638
639        create_layer_with_deletions(
640            rootfs.path(),
641            &[PathBuf::from("kept.txt")],
642            &[PathBuf::from("usr/share/doc/removed.txt")],
643            &layer_path,
644        )
645        .unwrap();
646
647        let tar_gz = fs::File::open(&layer_path).unwrap();
648        let mut archive = tar::Archive::new(GzDecoder::new(tar_gz));
649        let names: Vec<String> = archive
650            .entries()
651            .unwrap()
652            .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
653            .collect();
654
655        assert!(
656            names.iter().any(|n| n == "usr/share/doc/.wh.removed.txt"),
657            "expected an OCI whiteout marker, got: {names:?}"
658        );
659        assert!(names.iter().any(|n| n == "kept.txt"));
660    }
661
662    #[test]
663    fn test_snapshot_diff_modified_file() {
664        let tmp = TempDir::new().unwrap();
665        fs::write(tmp.path().join("a.txt"), "hello").unwrap();
666
667        let before = DirSnapshot::capture(tmp.path()).unwrap();
668
669        // Modify the file (change size)
670        fs::write(tmp.path().join("a.txt"), "hello world").unwrap();
671
672        let after = DirSnapshot::capture(tmp.path()).unwrap();
673
674        let diff = before.diff(&after);
675        assert_eq!(diff, vec![PathBuf::from("a.txt")]);
676    }
677
678    /// Regression: a `chmod`-only change (e.g. `RUN chmod +x`) changes the mode
679    /// but not size or mtime, and must still be detected so the new mode lands
680    /// in the layer (else a COPY'd script stays non-executable -> exec EACCES).
681    #[test]
682    #[cfg(unix)]
683    fn test_snapshot_diff_detects_chmod_only() {
684        use std::os::unix::fs::PermissionsExt;
685        let tmp = TempDir::new().unwrap();
686        let f = tmp.path().join("entry.sh");
687        fs::write(&f, "#!/bin/sh\necho hi\n").unwrap();
688        fs::set_permissions(&f, fs::Permissions::from_mode(0o644)).unwrap();
689
690        let before = DirSnapshot::capture(tmp.path()).unwrap();
691        // chmod +x: mode 0644 -> 0755, same content/size, same mtime.
692        fs::set_permissions(&f, fs::Permissions::from_mode(0o755)).unwrap();
693        let after = DirSnapshot::capture(tmp.path()).unwrap();
694
695        assert_eq!(before.diff(&after), vec![PathBuf::from("entry.sh")]);
696    }
697
698    #[test]
699    fn test_snapshot_diff_detects_chown_only() {
700        let path = PathBuf::from("app/node_modules/pkg/package.json");
701        let before = DirSnapshot {
702            entries: HashMap::from([(
703                path.clone(),
704                FileEntry {
705                    path: path.clone(),
706                    size: 1300,
707                    mtime: 1,
708                    mode: 0o100640,
709                    uid: 0,
710                    gid: 0,
711                    is_dir: false,
712                },
713            )]),
714        };
715        let after = DirSnapshot {
716            entries: HashMap::from([(
717                path.clone(),
718                FileEntry {
719                    path: path.clone(),
720                    size: 1300,
721                    mtime: 1,
722                    mode: 0o100640,
723                    uid: 1001,
724                    gid: 1001,
725                    is_dir: false,
726                },
727            )]),
728        };
729
730        assert_eq!(before.diff(&after), vec![path]);
731    }
732
733    #[test]
734    fn test_snapshot_diff_no_changes() {
735        let tmp = TempDir::new().unwrap();
736        fs::write(tmp.path().join("a.txt"), "hello").unwrap();
737
738        let before = DirSnapshot::capture(tmp.path()).unwrap();
739        let after = DirSnapshot::capture(tmp.path()).unwrap();
740
741        let diff = before.diff(&after);
742        assert!(diff.is_empty());
743    }
744
745    // --- create_layer ---
746
747    #[test]
748    fn test_create_layer_from_files() {
749        let rootfs = TempDir::new().unwrap();
750        let output_dir = TempDir::new().unwrap();
751
752        fs::write(rootfs.path().join("hello.txt"), "hello").unwrap();
753        fs::write(rootfs.path().join("world.txt"), "world").unwrap();
754
755        let output_path = output_dir.path().join("layer.tar.gz");
756        let changed = vec![PathBuf::from("hello.txt"), PathBuf::from("world.txt")];
757
758        let info = create_layer(rootfs.path(), &changed, &output_path).unwrap();
759
760        assert!(info.path.exists());
761        assert!(info.size > 0);
762        assert!(!info.digest.is_empty());
763        assert_eq!(info.digest.len(), 64); // SHA256 hex
764    }
765
766    #[test]
767    fn test_create_layer_empty() {
768        let rootfs = TempDir::new().unwrap();
769        let output_dir = TempDir::new().unwrap();
770        let output_path = output_dir.path().join("layer.tar.gz");
771
772        let info = create_layer(rootfs.path(), &[], &output_path).unwrap();
773        assert!(info.path.exists());
774    }
775
776    /// Regression: the recorded digest/size must reflect the COMPLETE file
777    /// (gzip stream fully flushed), not a partially-written one. Previously the
778    /// digest was computed before the GzEncoder was finished, so every layer
779    /// recorded the same hash of the 10-byte gzip header.
780    #[test]
781    fn test_create_layer_digest_matches_completed_file() {
782        let rootfs = TempDir::new().unwrap();
783        let output_dir = TempDir::new().unwrap();
784        fs::write(rootfs.path().join("a.txt"), "AAAA-content").unwrap();
785
786        let out = output_dir.path().join("layer.tar.gz");
787        let info = create_layer(rootfs.path(), &[PathBuf::from("a.txt")], &out).unwrap();
788
789        // The digest/size recorded must equal the on-disk file, re-read after
790        // the function returned (i.e. the file was complete when hashed).
791        let on_disk = sha256_file(&info.path).unwrap();
792        let on_disk_size = fs::metadata(&info.path).unwrap().len();
793        assert_eq!(
794            info.digest, on_disk,
795            "recorded digest must match completed file"
796        );
797        assert_eq!(
798            info.size, on_disk_size,
799            "recorded size must match completed file"
800        );
801        assert!(
802            info.size > 20,
803            "a real one-file layer is larger than an empty gzip header"
804        );
805    }
806
807    /// Regression: single-file layers with different content must produce
808    /// different digests (else the content-addressed store/cache collides).
809    #[test]
810    fn test_create_layer_distinct_content_distinct_digest() {
811        let rootfs = TempDir::new().unwrap();
812        let out_dir = TempDir::new().unwrap();
813
814        fs::write(rootfs.path().join("a.txt"), "AAAA-content").unwrap();
815        let a = create_layer(
816            rootfs.path(),
817            &[PathBuf::from("a.txt")],
818            &out_dir.path().join("a.tgz"),
819        )
820        .unwrap();
821
822        fs::write(rootfs.path().join("b.txt"), "BBBB-different-longer").unwrap();
823        let b = create_layer(
824            rootfs.path(),
825            &[PathBuf::from("b.txt")],
826            &out_dir.path().join("b.tgz"),
827        )
828        .unwrap();
829
830        assert_ne!(
831            a.digest, b.digest,
832            "distinct layer content must yield distinct digests"
833        );
834    }
835
836    // --- create_layer_from_dir ---
837
838    #[test]
839    fn test_create_layer_from_dir() {
840        let src = TempDir::new().unwrap();
841        let output_dir = TempDir::new().unwrap();
842
843        fs::write(src.path().join("app.py"), "print('hi')").unwrap();
844        fs::create_dir(src.path().join("lib")).unwrap();
845        fs::write(src.path().join("lib").join("util.py"), "pass").unwrap();
846
847        let output_path = output_dir.path().join("layer.tar.gz");
848        let info = create_layer_from_dir(src.path(), Path::new("workspace"), &output_path).unwrap();
849
850        assert!(info.path.exists());
851        assert!(info.size > 0);
852
853        // Verify the tar contains files under workspace/
854        let file = fs::File::open(&info.path).unwrap();
855        let decoder = flate2::read::GzDecoder::new(file);
856        let mut archive = tar::Archive::new(decoder);
857        let paths: Vec<String> = archive
858            .entries()
859            .unwrap()
860            .filter_map(|e| e.ok())
861            .map(|e| e.path().unwrap().to_string_lossy().to_string())
862            .collect();
863
864        assert!(paths.iter().any(|p| p.contains("workspace/app.py")));
865        assert!(paths.iter().any(|p| p.contains("workspace/lib")));
866    }
867
868    /// Regression: symlinks must be stored as symlink entries (Docker copies
869    /// them verbatim), not followed into a duplicate of their target.
870    #[test]
871    #[cfg(unix)]
872    fn test_create_layer_from_dir_preserves_symlinks() {
873        let src = TempDir::new().unwrap();
874        let output_dir = TempDir::new().unwrap();
875        fs::write(src.path().join("libfoo.so.1"), "real").unwrap();
876        std::os::unix::fs::symlink("libfoo.so.1", src.path().join("libfoo.so")).unwrap();
877
878        let out = output_dir.path().join("layer.tar.gz");
879        create_layer_from_dir(src.path(), Path::new("lib"), &out).unwrap();
880
881        let file = fs::File::open(&out).unwrap();
882        let decoder = flate2::read::GzDecoder::new(file);
883        let mut archive = tar::Archive::new(decoder);
884        let mut found_symlink = false;
885        for entry in archive.entries().unwrap() {
886            let entry = entry.unwrap();
887            if entry.path().unwrap().to_string_lossy() == "lib/libfoo.so" {
888                assert_eq!(entry.header().entry_type(), tar::EntryType::Symlink);
889                assert_eq!(
890                    entry.link_name().unwrap().unwrap().to_string_lossy(),
891                    "libfoo.so.1"
892                );
893                found_symlink = true;
894            }
895        }
896        assert!(found_symlink, "symlink entry must be present in the layer");
897    }
898
899    #[cfg(unix)]
900    #[test]
901    fn test_snapshot_does_not_follow_symlink_outside_rootfs() {
902        let tmp = TempDir::new().unwrap();
903        let rootfs = tmp.path().join("rootfs");
904        let outside = tmp.path().join("outside");
905        fs::create_dir_all(&rootfs).unwrap();
906        fs::create_dir_all(&outside).unwrap();
907        fs::write(outside.join("host-secret"), "secret").unwrap();
908        std::os::unix::fs::symlink(&outside, rootfs.join("escape")).unwrap();
909
910        let snapshot = DirSnapshot::capture(&rootfs).unwrap();
911
912        assert!(snapshot.entries.contains_key(Path::new("escape")));
913        assert!(!snapshot
914            .entries
915            .contains_key(Path::new("escape/host-secret")));
916        assert!(!snapshot.entries[Path::new("escape")].is_dir);
917    }
918
919    #[test]
920    #[cfg(unix)]
921    fn test_create_layer_with_chown_preserves_symlink_target() {
922        let rootfs = TempDir::new().unwrap();
923        let output_dir = TempDir::new().unwrap();
924        std::os::unix::fs::symlink("libfoo.so.1", rootfs.path().join("libfoo.so")).unwrap();
925        let out = output_dir.path().join("layer.tar.gz");
926
927        create_layer_with_chown(
928            rootfs.path(),
929            &[PathBuf::from("libfoo.so")],
930            &[],
931            &out,
932            Some((123, 456)),
933        )
934        .unwrap();
935
936        let decoder = flate2::read::GzDecoder::new(fs::File::open(out).unwrap());
937        let mut archive = tar::Archive::new(decoder);
938        let entry = archive.entries().unwrap().next().unwrap().unwrap();
939        assert_eq!(entry.header().entry_type(), tar::EntryType::Symlink);
940        assert_eq!(entry.header().uid().unwrap(), 123);
941        assert_eq!(entry.header().gid().unwrap(), 456);
942        assert_eq!(
943            entry.link_name().unwrap().unwrap().to_string_lossy(),
944            "libfoo.so.1"
945        );
946    }
947
948    // --- sha256 ---
949
950    #[test]
951    fn test_sha256_file() {
952        let tmp = TempDir::new().unwrap();
953        let path = tmp.path().join("test.txt");
954        fs::write(&path, "hello").unwrap();
955
956        let digest = sha256_file(&path).unwrap();
957        assert_eq!(digest.len(), 64);
958        // Known SHA256 of "hello"
959        assert_eq!(
960            digest,
961            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
962        );
963    }
964
965    #[test]
966    fn test_sha256_bytes() {
967        let digest = sha256_bytes(b"hello");
968        assert_eq!(
969            digest,
970            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
971        );
972    }
973
974    #[test]
975    fn test_layer_info_prefixed_digest() {
976        let info = LayerInfo {
977            path: PathBuf::from("/tmp/layer.tar.gz"),
978            digest: "abc123".to_string(),
979            size: 100,
980        };
981        assert_eq!(info.prefixed_digest(), "sha256:abc123");
982    }
983}