Skip to main content

ferroui_hot_patch/
inputs.rs

1//! Capture explicit build-input trees before admission, and stage their exact bytes.
2//!
3//! This is not Cargo dependency discovery. The caller must include every local path
4//! dependency inside the root and supply compiler/configuration/environment inputs.
5//! Builds must place output outside the staged tree. Network inputs, generated inputs
6//! outside the tree, and mutable external dependencies require restart.
7use crate::SourceSnapshot;
8use std::{
9    collections::BTreeMap,
10    fs, io,
11    path::{Component, Path, PathBuf},
12};
13
14/// An owned, byte-exact revision. Rust sources remain text for function admission;
15/// every other input is encoded so binary assets are covered without lossy decoding.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct BuildInputs {
18    files: BTreeMap<String, Vec<u8>>,
19    #[cfg(unix)]
20    modes: BTreeMap<String, u32>,
21    context: BTreeMap<String, String>,
22}
23
24impl BuildInputs {
25    /// Read all regular files recursively. Exclusions are explicit root-relative
26    /// directories, normally `.git` and the build output directory. Never exclude
27    /// a dependency or compiler input. Symlinks are rejected instead of silently
28    /// overlooking external inputs or making the staged build resolve differently.
29    /// Two identical reads reject ordinary edits during capture; the staged bytes,
30    /// rather than the mutable source directory, must be used for compilation.
31    pub fn capture(
32        root: &Path,
33        excluded_directories: &[PathBuf],
34        context: BTreeMap<String, String>,
35    ) -> io::Result<Self> {
36        for path in excluded_directories {
37            if path.as_os_str().is_empty()
38                || path
39                    .components()
40                    .any(|part| !matches!(part, Component::Normal(_)))
41            {
42                return Err(invalid(
43                    "Exclusions must be nonempty relative directory paths",
44                ));
45            }
46        }
47        if fs::symlink_metadata(root)?.file_type().is_symlink() || !root.is_dir() {
48            return Err(invalid("Input root must be a real directory"));
49        }
50        let read = || {
51            let mut files = BTreeMap::new();
52            let mut modes = BTreeMap::new();
53            collect(
54                root,
55                Path::new(""),
56                excluded_directories,
57                &mut files,
58                &mut modes,
59            )?;
60            Ok::<_, io::Error>((files, modes))
61        };
62        let captured = read()?;
63        if captured != read()? {
64            return Err(io::Error::new(
65                io::ErrorKind::Interrupted,
66                "Build inputs changed during capture",
67            ));
68        }
69        Ok(Self {
70            files: captured.0,
71            #[cfg(unix)]
72            modes: captured.1,
73            context,
74        })
75    }
76
77    /// Exact comparison inputs for `PatchSupervisor`. Context keys live outside the
78    /// filesystem namespace and must include toolchain identity and relevant env.
79    pub fn snapshot(&self) -> io::Result<SourceSnapshot> {
80        let mut result = BTreeMap::new();
81        for (name, bytes) in &self.files {
82            let value = if name.ends_with(".rs") {
83                String::from_utf8(bytes.clone()).map_err(|_| invalid("Rust input is not UTF-8"))?
84            } else {
85                let mut encoded = String::with_capacity(bytes.len() * 2);
86                const HEX: &[u8] = b"0123456789abcdef";
87                for byte in bytes {
88                    encoded.push(HEX[(byte >> 4) as usize] as char);
89                    encoded.push(HEX[(byte & 15) as usize] as char);
90                }
91                encoded
92            };
93            result.insert(name.clone(), value);
94        }
95        for (name, value) in &self.context {
96            result.insert(format!("@context/{name}"), value.clone());
97        }
98        #[cfg(unix)]
99        for (name, mode) in &self.modes {
100            result.insert(format!("@permissions/{name}"), mode.to_string());
101        }
102        Ok(result)
103    }
104
105    /// Create an isolated source tree from the captured bytes, never by rereading
106    /// live sources. Keep this object alive for the entire build and verification.
107    pub fn stage(&self) -> io::Result<StagedInputs> {
108        let directory = tempfile::tempdir()?;
109        for (name, bytes) in &self.files {
110            let path = directory.path().join(name);
111            if let Some(parent) = path.parent() {
112                fs::create_dir_all(parent)?;
113            }
114            fs::write(&path, bytes)?;
115            #[cfg(unix)]
116            {
117                use std::os::unix::fs::PermissionsExt;
118                fs::set_permissions(path, fs::Permissions::from_mode(self.modes[name]))?;
119            }
120        }
121        Ok(StagedInputs {
122            directory,
123            inputs: self.clone(),
124        })
125    }
126}
127
128/// An isolated build tree. Build scripts can still write to it: verification fails
129/// closed if they do. This is source isolation, not a sandbox for arbitrary builds.
130pub struct StagedInputs {
131    directory: tempfile::TempDir,
132    inputs: BuildInputs,
133}
134impl StagedInputs {
135    pub fn path(&self) -> &Path {
136        self.directory.path()
137    }
138    pub fn inputs(&self) -> &BuildInputs {
139        &self.inputs
140    }
141    pub fn verify(&self) -> io::Result<()> {
142        let current = BuildInputs::capture(self.path(), &[], self.inputs.context.clone())?;
143        if current != self.inputs {
144            return Err(invalid("Staged build inputs changed; discard the artifact"));
145        }
146        Ok(())
147    }
148}
149
150fn invalid(message: &str) -> io::Error {
151    io::Error::new(io::ErrorKind::InvalidData, message)
152}
153fn collect(
154    root: &Path,
155    relative: &Path,
156    excluded: &[PathBuf],
157    files: &mut BTreeMap<String, Vec<u8>>,
158    modes: &mut BTreeMap<String, u32>,
159) -> io::Result<()> {
160    for entry in fs::read_dir(root.join(relative))? {
161        let entry = entry?;
162        let path = relative.join(entry.file_name());
163        let kind = entry.file_type()?;
164        if kind.is_dir() && excluded.contains(&path) {
165            continue;
166        }
167        if kind.is_symlink() {
168            return Err(invalid(
169                "Symlink build inputs require an explicit resolved input tree",
170            ));
171        }
172        if kind.is_dir() {
173            collect(root, &path, excluded, files, modes)?;
174        } else if kind.is_file() {
175            let name = path
176                .components()
177                .map(|part| {
178                    part.as_os_str()
179                        .to_str()
180                        .ok_or_else(|| invalid("Input path is not UTF-8"))
181                })
182                .collect::<io::Result<Vec<_>>>()?
183                .join("/");
184            // Reserve context keys and reject names whose meaning differs on Windows.
185            if name.starts_with("@context/")
186                || name.starts_with("@permissions/")
187                || name.contains(['\\', ':'])
188            {
189                return Err(invalid(
190                    "Input path is not portable or uses the context namespace",
191                ));
192            }
193            #[cfg(unix)]
194            {
195                use std::os::unix::fs::PermissionsExt;
196                modes.insert(name.clone(), entry.metadata()?.permissions().mode() & 0o777);
197            }
198            files.insert(name, fs::read(entry.path())?);
199        } else {
200            return Err(invalid("Build inputs must be regular files or directories"));
201        }
202    }
203    Ok(())
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::PatchSupervisor;
210    use std::collections::BTreeSet;
211    fn fixture() -> tempfile::TempDir {
212        let root = tempfile::tempdir().unwrap();
213        fs::write(root.path().join("main.rs"), "fn click() { let x = 1; }").unwrap();
214        fs::write(root.path().join("Cargo.toml"), "manifest").unwrap();
215        fs::write(root.path().join("asset.bin"), [0, 255]).unwrap();
216        root
217    }
218    fn capture(root: &Path) -> BuildInputs {
219        BuildInputs::capture(
220            root,
221            &[],
222            BTreeMap::from([("rustc".into(), "1.93.0".into())]),
223        )
224        .unwrap()
225    }
226    #[test]
227    fn admitted_revision_is_isolated_from_later_saves_and_detects_build_writes() {
228        let root = fixture();
229        let baseline = capture(root.path());
230        let supervisor = PatchSupervisor::new(
231            baseline.snapshot().unwrap(),
232            BTreeMap::from([("main.rs".into(), BTreeSet::from(["click".into()]))]),
233        );
234        fs::write(root.path().join("main.rs"), "fn click() { let x = 2; }").unwrap();
235        let admitted = capture(root.path());
236        assert!(supervisor.admit(admitted.snapshot().unwrap()).is_ok());
237        fs::write(root.path().join("main.rs"), "struct Incompatible;").unwrap();
238        let staged = admitted.stage().unwrap();
239        assert_eq!(
240            fs::read_to_string(staged.path().join("main.rs")).unwrap(),
241            "fn click() { let x = 2; }"
242        );
243        staged.verify().unwrap();
244        fs::write(staged.path().join("asset.bin"), [0, 254]).unwrap();
245        assert!(staged.verify().is_err());
246    }
247    #[test]
248    fn dependencies_binary_assets_and_configuration_require_restart() {
249        let root = fixture();
250        let before = capture(root.path());
251        let supervisor = PatchSupervisor::new(before.snapshot().unwrap(), BTreeMap::new());
252        fs::write(root.path().join("asset.bin"), [0, 254]).unwrap();
253        assert!(supervisor
254            .admit(capture(root.path()).snapshot().unwrap())
255            .unwrap_err()
256            .contains("asset.bin"));
257        let mut changed = before.clone();
258        changed.context.insert("rustc".into(), "other".into());
259        assert!(supervisor
260            .admit(changed.snapshot().unwrap())
261            .unwrap_err()
262            .contains("@context/rustc"));
263        fs::write(root.path().join("new.rs"), "fn new() {}").unwrap();
264        assert!(supervisor
265            .admit(capture(root.path()).snapshot().unwrap())
266            .unwrap_err()
267            .contains("input set"));
268    }
269    #[test]
270    fn exclusions_are_explicit_directories_and_cannot_escape() {
271        let root = fixture();
272        fs::create_dir(root.path().join("target")).unwrap();
273        fs::write(root.path().join("target/output"), "output").unwrap();
274        let inputs =
275            BuildInputs::capture(root.path(), &["target".into()], BTreeMap::new()).unwrap();
276        assert!(!inputs.snapshot().unwrap().contains_key("target/output"));
277        assert!(
278            BuildInputs::capture(root.path(), &["../external".into()], BTreeMap::new()).is_err()
279        );
280        let stage = inputs.stage().unwrap();
281        fs::write(stage.path().join("generated.rs"), "generated").unwrap();
282        assert!(stage.verify().is_err());
283    }
284    #[cfg(unix)]
285    #[test]
286    fn symlinks_fail_closed() {
287        let root = fixture();
288        std::os::unix::fs::symlink("main.rs", root.path().join("linked.rs")).unwrap();
289        assert!(BuildInputs::capture(root.path(), &[], BTreeMap::new()).is_err());
290    }
291    #[cfg(unix)]
292    #[test]
293    fn executable_permissions_are_preserved_and_admitted_as_build_inputs() {
294        use std::os::unix::fs::PermissionsExt;
295        let root = fixture();
296        let path = root.path().join("build.sh");
297        fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
298        fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
299        let before = capture(root.path());
300        let staged = before.stage().unwrap();
301        assert_eq!(
302            fs::metadata(staged.path().join("build.sh"))
303                .unwrap()
304                .permissions()
305                .mode()
306                & 0o777,
307            0o755
308        );
309        staged.verify().unwrap();
310        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
311        let supervisor = PatchSupervisor::new(before.snapshot().unwrap(), BTreeMap::new());
312        assert!(supervisor
313            .admit(capture(root.path()).snapshot().unwrap())
314            .unwrap_err()
315            .contains("@permissions/build.sh"));
316    }
317}