Skip to main content

a3s_box_runtime/rootfs/
baseline.rs

1//! Durable filesystem baseline used by `a3s-box diff`.
2
3use std::collections::HashMap;
4use std::path::{Component, Path};
5
6use a3s_box_core::error::{BoxError, Result};
7use serde::{Deserialize, Serialize};
8
9/// Filename stored beside each box rootfs.
10pub const DIFF_BASELINE_FILE: &str = "rootfs_snapshot.json";
11
12/// Minimal file metadata needed to classify filesystem changes.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct RootfsFileInfo {
15    pub size: u64,
16    pub mode: u32,
17    pub is_dir: bool,
18}
19
20/// Capture a rootfs tree while excluding runtime-owned control files.
21pub fn walk_rootfs(root: &Path) -> Result<HashMap<String, RootfsFileInfo>> {
22    let mut entries = HashMap::new();
23    walk_recursive(root, root, &mut entries)?;
24    Ok(entries)
25}
26
27/// Persist the first pristine baseline for a box without ever replacing it.
28///
29/// Runtime callers invoke this after all host-side rootfs preparation and before
30/// the workload starts. Later starts and monitor recovery therefore preserve the
31/// original generation's baseline instead of racing a fast container command.
32pub fn create_diff_baseline_if_absent(box_dir: &Path, rootfs: &Path) -> Result<()> {
33    let destination = box_dir.join(DIFF_BASELINE_FILE);
34    if destination.exists() {
35        return Ok(());
36    }
37
38    let entries = walk_rootfs(rootfs)?;
39    let encoded = serde_json::to_vec(&entries).map_err(|error| {
40        BoxError::BuildError(format!("failed to encode rootfs baseline: {error}"))
41    })?;
42    let temporary = box_dir.join(format!(
43        ".{DIFF_BASELINE_FILE}.{}.tmp",
44        uuid::Uuid::new_v4().simple()
45    ));
46    std::fs::write(&temporary, encoded).map_err(BoxError::IoError)?;
47
48    // hard_link installs the fully-written file only when the destination is
49    // still absent. A concurrent lifecycle helper that won the race remains the
50    // authority; unlike rename, this never overwrites its earlier baseline.
51    let install = std::fs::hard_link(&temporary, &destination);
52    let _ = std::fs::remove_file(&temporary);
53    match install {
54        Ok(()) => Ok(()),
55        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
56        Err(error) => Err(BoxError::IoError(error)),
57    }
58}
59
60fn walk_recursive(
61    root: &Path,
62    current: &Path,
63    entries: &mut HashMap<String, RootfsFileInfo>,
64) -> Result<()> {
65    let directory = match std::fs::read_dir(current) {
66        Ok(directory) => directory,
67        Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok(()),
68        Err(error) => return Err(BoxError::IoError(error)),
69    };
70
71    for entry in directory {
72        let entry = entry.map_err(BoxError::IoError)?;
73        let path = entry.path();
74        let relative = match path.strip_prefix(root) {
75            Ok(relative) => relative,
76            Err(_) => continue,
77        };
78        if a3s_box_core::rootfs_metadata::is_runtime_internal_rootfs_path(relative) {
79            continue;
80        }
81
82        // Do not follow symlinks while walking a container-controlled tree.
83        let metadata = match std::fs::symlink_metadata(&path) {
84            Ok(metadata) => metadata,
85            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
86            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => continue,
87            Err(error) => return Err(BoxError::IoError(error)),
88        };
89
90        #[cfg(unix)]
91        let mode = {
92            use std::os::unix::fs::MetadataExt;
93            metadata.mode()
94        };
95        #[cfg(not(unix))]
96        let mode = 0;
97
98        entries.insert(
99            rootfs_path_string(relative),
100            RootfsFileInfo {
101                size: metadata.len(),
102                mode,
103                is_dir: metadata.is_dir(),
104            },
105        );
106        if metadata.is_dir() {
107            walk_recursive(root, &path, entries)?;
108        }
109    }
110    Ok(())
111}
112
113fn rootfs_path_string(relative: &Path) -> String {
114    let segments = relative
115        .components()
116        .filter_map(|component| match component {
117            Component::Normal(segment) => Some(segment.to_string_lossy()),
118            _ => None,
119        })
120        .collect::<Vec<_>>();
121    format!("/{}", segments.join("/"))
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn baseline_is_first_writer_wins_and_excludes_runtime_control_files() {
130        let directory = tempfile::tempdir().unwrap();
131        let box_dir = directory.path().join("box");
132        let rootfs = box_dir.join("rootfs");
133        std::fs::create_dir_all(rootfs.join("root")).unwrap();
134        std::fs::write(rootfs.join("root/original.txt"), b"original").unwrap();
135        std::fs::write(rootfs.join(".a3s-box-exec.json"), b"runtime").unwrap();
136
137        create_diff_baseline_if_absent(&box_dir, &rootfs).unwrap();
138        std::fs::write(rootfs.join("root/later.txt"), b"later").unwrap();
139        create_diff_baseline_if_absent(&box_dir, &rootfs).unwrap();
140
141        let encoded = std::fs::read(box_dir.join(DIFF_BASELINE_FILE)).unwrap();
142        let baseline: HashMap<String, RootfsFileInfo> = serde_json::from_slice(&encoded).unwrap();
143        assert!(baseline.contains_key("/root/original.txt"));
144        assert!(!baseline.contains_key("/root/later.txt"));
145        assert!(!baseline.contains_key("/.a3s-box-exec.json"));
146    }
147
148    #[cfg(unix)]
149    #[test]
150    fn walk_does_not_follow_rootfs_symlinks() {
151        let directory = tempfile::tempdir().unwrap();
152        let outside = directory.path().join("outside");
153        let rootfs = directory.path().join("rootfs");
154        std::fs::create_dir_all(&outside).unwrap();
155        std::fs::create_dir_all(&rootfs).unwrap();
156        std::fs::write(outside.join("secret"), b"secret").unwrap();
157        std::os::unix::fs::symlink(&outside, rootfs.join("escape")).unwrap();
158
159        let entries = walk_rootfs(&rootfs).unwrap();
160        assert!(entries.contains_key("/escape"));
161        assert!(!entries.contains_key("/escape/secret"));
162    }
163}