Skip to main content

a3s_box_core/
rootfs_baseline.rs

1//! Versioned guest-to-host contract for the pristine rootfs diff baseline.
2
3use std::collections::BTreeMap;
4use std::path::{Component, Path};
5
6use serde::{Deserialize, Serialize};
7
8/// Host-side file name in the private lifecycle-control share.
9pub const GUEST_DIFF_BASELINE_FILE_NAME: &str = "baseline.json";
10/// Fixed path opened by guest-init before the lifecycle-control share is detached.
11pub const GUEST_DIFF_BASELINE_PATH: &str = "/run/a3s-box/terminal/baseline.json";
12/// Versioned schema emitted by guest-init and validated by the host runtime.
13pub const GUEST_DIFF_BASELINE_SCHEMA: &str = "a3s.box.guest-diff-baseline.v1";
14/// Bound host memory and disk consumption for one serialized baseline.
15pub const MAX_GUEST_DIFF_BASELINE_BYTES: usize = 64 * 1024 * 1024;
16/// Bound pathological trees independently of their serialized byte size.
17pub const MAX_GUEST_DIFF_BASELINE_ENTRIES: usize = 1_000_000;
18
19/// Minimal file metadata needed to classify rootfs changes.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct RootfsFileInfo {
23    pub size: u64,
24    pub mode: u32,
25    pub is_dir: bool,
26}
27
28/// Pristine rootfs metadata captured inside the guest before workload launch.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct GuestDiffBaseline {
32    pub schema: String,
33    pub entries: BTreeMap<String, RootfsFileInfo>,
34}
35
36impl GuestDiffBaseline {
37    pub fn new(entries: BTreeMap<String, RootfsFileInfo>) -> Self {
38        Self {
39            schema: GUEST_DIFF_BASELINE_SCHEMA.to_string(),
40            entries,
41        }
42    }
43
44    pub fn validate(&self) -> Result<(), String> {
45        if self.schema != GUEST_DIFF_BASELINE_SCHEMA {
46            return Err(format!(
47                "unsupported guest diff baseline schema: {}",
48                self.schema
49            ));
50        }
51        if self.entries.len() > MAX_GUEST_DIFF_BASELINE_ENTRIES {
52            return Err(format!(
53                "guest diff baseline contains {} entries; limit is {}",
54                self.entries.len(),
55                MAX_GUEST_DIFF_BASELINE_ENTRIES
56            ));
57        }
58        for path in self.entries.keys() {
59            validate_rootfs_path(path)?;
60        }
61        Ok(())
62    }
63}
64
65fn validate_rootfs_path(path: &str) -> Result<(), String> {
66    if path == "/" || path.contains('\0') {
67        return Err(format!("invalid guest diff baseline path {path:?}"));
68    }
69
70    let parsed = Path::new(path);
71    let mut components = parsed.components();
72    if components.next() != Some(Component::RootDir) {
73        return Err(format!(
74            "guest diff baseline path is not absolute: {path:?}"
75        ));
76    }
77    let mut names = Vec::new();
78    for component in components {
79        match component {
80            Component::Normal(name) => names.push(name.to_string_lossy()),
81            Component::CurDir
82            | Component::ParentDir
83            | Component::RootDir
84            | Component::Prefix(_) => {
85                return Err(format!("guest diff baseline path is unsafe: {path:?}"));
86            }
87        }
88    }
89    let canonical = format!("/{}", names.join("/"));
90    if canonical != path {
91        return Err(format!(
92            "guest diff baseline path is not canonical: {path:?}"
93        ));
94    }
95    let relative = Path::new(path.trim_start_matches('/'));
96    if crate::rootfs_metadata::is_runtime_internal_rootfs_path(relative) {
97        return Err(format!(
98            "guest diff baseline contains runtime-owned path {path:?}"
99        ));
100    }
101    Ok(())
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn versioned_baseline_accepts_canonical_guest_paths() {
110        let baseline = GuestDiffBaseline::new(BTreeMap::from([(
111            "/usr/bin/tool".to_string(),
112            RootfsFileInfo {
113                size: 7,
114                mode: 0o100755,
115                is_dir: false,
116            },
117        )]));
118
119        assert!(baseline.validate().is_ok());
120    }
121
122    #[test]
123    fn baseline_rejects_unsafe_and_runtime_owned_paths() {
124        for path in [
125            "relative",
126            "/",
127            "/usr//bin",
128            "/usr/../etc/passwd",
129            "/run/a3s-box/terminal/status.json",
130        ] {
131            let baseline = GuestDiffBaseline::new(BTreeMap::from([(
132                path.to_string(),
133                RootfsFileInfo {
134                    size: 0,
135                    mode: 0,
136                    is_dir: false,
137                },
138            )]));
139            assert!(baseline.validate().is_err(), "accepted {path:?}");
140        }
141    }
142}