1use crate::SourceSnapshot;
8use std::{
9 collections::BTreeMap,
10 fs, io,
11 path::{Component, Path, PathBuf},
12};
13
14#[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 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 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 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
128pub 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 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}