a3s_box_core/
rootfs_baseline.rs1use std::collections::BTreeMap;
4use std::path::{Component, Path};
5
6use serde::{Deserialize, Serialize};
7
8pub const GUEST_DIFF_BASELINE_FILE_NAME: &str = "baseline.json";
10pub const GUEST_DIFF_BASELINE_PATH: &str = "/run/a3s-box/terminal/baseline.json";
12pub const GUEST_DIFF_BASELINE_SCHEMA: &str = "a3s.box.guest-diff-baseline.v1";
14pub const MAX_GUEST_DIFF_BASELINE_BYTES: usize = 64 * 1024 * 1024;
16pub const MAX_GUEST_DIFF_BASELINE_ENTRIES: usize = 1_000_000;
18
19#[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#[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}