1use std::ffi::{OsStr, OsString};
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4
5const ROOTS: &[(&str, &str)] = &[
6 ("HOME", "home"),
7 ("XDG_DATA_HOME", "data"),
8 ("XDG_RUNTIME_DIR", "runtime"),
9 ("XDG_CONFIG_HOME", "config"),
10 ("XDG_STATE_HOME", "state"),
11 ("XDG_CACHE_HOME", "cache"),
12 ("TMPDIR", "tmp"),
13];
14const PASSTHROUGH: &[&str] = &[
15 "PATH",
16 "LANG",
17 "LANGUAGE",
18 "LC_ALL",
19 "LC_CTYPE",
20 "LC_COLLATE",
21 "LC_MESSAGES",
22 "LC_NUMERIC",
23 "LC_TIME",
24 "LC_MONETARY",
25 "TZ",
26 "USER",
27 "LOGNAME",
28 "RUST_BACKTRACE",
29 #[cfg(windows)]
30 "SystemRoot",
31];
32
33pub fn fenced_subc_env_dir(scratch: &Path, var: &str) -> PathBuf {
37 ROOTS
38 .iter()
39 .find(|(name, _)| *name == var)
40 .map(|(_, dir)| scratch.join(dir))
41 .unwrap_or_else(|| panic!("{var} is not a fenced daemon env root"))
42}
43
44pub fn fenced_subc_daemon_env(scratch: &Path) -> Vec<(OsString, OsString)> {
48 fenced_env_with_roots(scratch, &[])
49}
50
51pub(crate) fn fenced_env_with_roots(
52 scratch: &Path,
53 extra_roots: &[(&str, &str)],
54) -> Vec<(OsString, OsString)> {
55 let mut names = std::collections::HashSet::new();
56 for (name, directory) in extra_roots {
57 assert!(
58 !name.is_empty()
59 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
60 && !name.as_bytes()[0].is_ascii_digit(),
61 "extra root must have a valid environment variable name"
62 );
63 assert!(
64 !ROOTS
65 .iter()
66 .any(|(builtin, _)| name.eq_ignore_ascii_case(builtin)),
67 "extra roots cannot replace a built-in directory variable"
68 );
69 assert!(
70 names.insert(name.to_ascii_uppercase()),
71 "duplicate extra root variable"
72 );
73 plain_name(directory);
74 }
75 ROOTS
76 .iter()
77 .chain(extra_roots)
78 .map(|(name, dir)| {
79 let path = scratch.join(dir);
80 std::fs::create_dir_all(&path).unwrap_or_else(|e| panic!("create fenced {name}: {e}"));
81 let metadata = std::fs::symlink_metadata(&path).expect("inspect test directory root");
82 assert!(
83 metadata.is_dir() && !metadata.is_symlink(),
84 "test directory root must be a real directory"
85 );
86 (OsString::from(name), path.into_os_string())
87 })
88 .collect()
89}
90
91pub fn fenced_command(program: impl AsRef<OsStr>, scratch: &Path) -> Command {
99 fenced_command_with_roots(program, scratch, &[])
100}
101
102pub fn fenced_command_with_roots(
108 program: impl AsRef<OsStr>,
109 scratch: &Path,
110 extra_roots: &[(&str, &str)],
111) -> Command {
112 let program = crate::stage_test_binary(Path::new(program.as_ref()));
113 let mut command = crate::dev_command(program);
114 command.env_clear();
115 for name in PASSTHROUGH {
116 if let Some(value) = std::env::var_os(name) {
117 command.env(name, value);
118 }
119 }
120 command.envs(fenced_env_with_roots(scratch, extra_roots));
121 command
122}
123
124pub fn warm_exec_fenced(binary: &Path) {
127 let scratch = crate::ScratchDir::new("warm-exec");
128 if let Err(error) = crate::checked_output(
129 fenced_command(binary, scratch.path())
130 .arg("--version")
131 .stderr(Stdio::null()),
132 ) {
133 eprintln!("warm-exec: {error}");
134 }
135}
136
137pub fn sign_test_binary(path: &Path, identity: Option<&OsStr>) {
143 #[cfg(target_os = "macos")]
144 {
145 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
146 let _guard = LOCK.lock().unwrap_or_else(|p| p.into_inner());
147 let env = std::env::var_os("CORTEXKIT_TEST_SIGNING_IDENTITY");
148 let Some(identity) = identity.or(env.as_deref()) else {
149 return;
150 };
151 if Command::new("codesign")
152 .arg("-v")
153 .arg(path)
154 .output()
155 .is_ok_and(|o| o.status.success())
156 {
157 return;
158 }
159 if let Err(e) = crate::checked_output(
160 Command::new("codesign")
161 .args(["-f", "-s"])
162 .arg(identity)
163 .arg(path),
164 ) {
165 eprintln!("sign-test-binary: {e}; running {} anyway", path.display());
166 }
167 }
168 #[cfg(not(target_os = "macos"))]
169 let _ = (path, identity);
170}
171
172pub fn sibling_cargo_build(repo: &Path, target_dir: &Path, bin: &str) -> Command {
174 let mut command = Command::new(env!("CARGO"));
175 command
176 .current_dir(repo)
177 .args(["build", "--locked", "--bin", bin])
178 .env("CARGO_TARGET_DIR", target_dir);
179 command
180}
181
182fn workspace_root() -> PathBuf {
183 if let Some(root) = std::env::var_os("CORTEXKIT_TEST_WORKSPACE_ROOT") {
184 return PathBuf::from(root);
185 }
186 let output = Command::new("git")
189 .args(["rev-parse", "--show-toplevel"])
190 .output()
191 .expect("locate test workspace");
192 assert!(
193 output.status.success(),
194 "set CORTEXKIT_TEST_WORKSPACE_ROOT outside a git workspace"
195 );
196 PathBuf::from(String::from_utf8(output.stdout).unwrap().trim())
197}
198pub(crate) fn plain_name(name: &str) {
199 assert!(
200 !name.is_empty()
201 && name
202 .bytes()
203 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
204 "expected a plain directory name (ASCII letters, digits, '-' or '_')"
205 );
206}
207pub fn sibling_target_dir(name: &str) -> PathBuf {
209 plain_name(name);
210 let dir = workspace_root().join("target/sibling-target").join(name);
211 std::fs::create_dir_all(&dir).unwrap();
212 dir
213}
214pub fn sibling_checkout(name: &str) -> Result<PathBuf, String> {
217 plain_name(name);
218 let workspace = workspace_root();
219 let direct = workspace.join("..").join(name);
220 let primary = || {
221 let marker = std::fs::read_to_string(workspace.join(".git")).ok()?;
222 let git_dir = workspace.join(marker.trim().strip_prefix("gitdir: ")?);
223 let primary = git_dir
224 .ancestors()
225 .find(|p| p.file_name().is_some_and(|n| n == ".git"))?
226 .parent()?;
227 Some(primary.parent()?.join(name))
228 };
229 [Some(direct.clone()), primary()]
230 .into_iter()
231 .flatten()
232 .find(|p| p.exists())
233 .and_then(|p| p.canonicalize().ok())
234 .ok_or_else(|| {
235 format!(
236 "sibling {name} checkout missing (looked at {})",
237 direct.display()
238 )
239 })
240}
241
242#[derive(Debug, Clone)]
246pub struct SubcDaemonBinary {
247 path: PathBuf,
248}
249impl SubcDaemonBinary {
250 pub fn build(dependency_checkouts: &[&str]) -> Self {
251 Self::try_build(dependency_checkouts).unwrap_or_else(|reason| panic!("{reason}"))
252 }
253 pub fn try_build(dependency_checkouts: &[&str]) -> Result<Self, String> {
257 let repo = sibling_checkout("subconscious")?;
258 Ok(Self {
259 path: crate::build_sibling_binary(
260 &repo,
261 "subconscious",
262 "ck-subc",
263 dependency_checkouts,
264 ),
265 })
266 }
267 pub fn command(&self, scratch: &Path) -> crate::TestDaemonCommand {
268 crate::TestDaemonCommand::new(&self.path, scratch)
269 }
270}
271
272#[cfg(all(test, unix))]
273mod tests {
274 use super::*;
275 #[test]
276 fn caller_selected_root_lands_under_scratch() {
277 let scratch = crate::ScratchDir::new("extra-command-root");
278 let output = crate::checked_output(&mut fenced_command_with_roots(
279 "/usr/bin/env",
280 &scratch,
281 &[("EXAMPLE_STORAGE_ROOT", "storage")],
282 ))
283 .unwrap();
284 let text = String::from_utf8(output.stdout).unwrap();
285 assert!(text
286 .lines()
287 .any(|line| line
288 == format!("EXAMPLE_STORAGE_ROOT={}", scratch.join("storage").display())));
289 assert!(scratch.join("storage").is_dir());
290 }
291
292 #[test]
293 fn extra_roots_cannot_replace_builtin_variables_or_escape_scratch() {
294 let scratch = crate::ScratchDir::new("extra-root-validation");
295 for roots in [
296 vec![("XDG_DATA_HOME", "other")],
297 vec![("xdg_data_home", "other")],
298 vec![("EXTRA_ROOT", "..")],
299 vec![("EXTRA_ROOT", "/outside")],
300 vec![("EXTRA_ROOT", "../outside")],
301 vec![("BAD=NAME", "data")],
302 vec![("EXTRA_ROOT", "first"), ("EXTRA_ROOT", "second")],
303 ] {
304 assert!(
305 std::panic::catch_unwind(|| fenced_command_with_roots("env", &scratch, &roots))
306 .is_err(),
307 "accepted invalid roots: {roots:?}"
308 );
309 }
310 let outside = crate::ScratchDir::new("extra-root-outside");
311 std::os::unix::fs::symlink(outside.path(), scratch.join("linked")).unwrap();
312 assert!(std::panic::catch_unwind(|| fenced_command_with_roots(
313 "env",
314 &scratch,
315 &[("EXTRA_ROOT", "linked")]
316 ))
317 .is_err());
318 }
319
320 #[test]
321 fn fenced_command_drops_ambient_variables_and_keeps_the_allowlist() {
322 let scratch = crate::ScratchDir::new("fence-env");
323 std::env::set_var("CORTEXKIT_TEST_LEAK_CANARY", "secret");
324 let output =
325 crate::checked_output(fenced_command("/usr/bin/env", &scratch).env("EXPLICIT", "kept"))
326 .unwrap();
327 let text = String::from_utf8(output.stdout).unwrap();
328 assert!(!text.contains("CORTEXKIT_TEST_LEAK_CANARY="));
329 assert!(text.lines().any(|s| s == "EXPLICIT=kept"));
330 assert!(text
331 .lines()
332 .any(|s| s == format!("PATH={}", std::env::var("PATH").unwrap())));
333 for (name, dir) in ROOTS {
334 assert!(text
335 .lines()
336 .any(|s| s == format!("{name}={}", scratch.join(dir).display())));
337 }
338 for line in text.lines() {
339 let name = line.split_once('=').unwrap().0;
340 assert!(
341 ROOTS.iter().any(|(root, _)| *root == name)
342 || PASSTHROUGH.contains(&name)
343 || name == "EXPLICIT",
344 "unexpected variable {name}"
345 );
346 }
347 std::env::remove_var("CORTEXKIT_TEST_LEAK_CANARY");
348 }
349 #[test]
350 fn sibling_cargo_build_fails_on_a_stale_lock_and_leaves_it_untouched() {
351 let scratch = crate::ScratchDir::new("locked-build");
352 std::fs::write(scratch.join("Cargo.toml"), "[package]\nname='lockprobe'\nversion='0.2.0'\nedition='2021'\n[[bin]]\nname='lockprobe'\npath='main.rs'\n[workspace]\n").unwrap();
353 std::fs::write(scratch.join("main.rs"), "fn main() {}\n").unwrap();
354 let lock = |version: &str| {
355 format!("version = 3\n\n[[package]]\nname = \"lockprobe\"\nversion = \"{version}\"\n")
356 };
357 std::fs::write(scratch.join("Cargo.lock"), lock("0.1.0")).unwrap();
358 let build = || {
359 sibling_cargo_build(&scratch, &scratch.join("target"), "lockprobe")
360 .stdout(Stdio::null())
361 .stderr(Stdio::null())
362 .status()
363 .unwrap()
364 };
365 assert!(!build().success());
366 assert_eq!(
367 std::fs::read_to_string(scratch.join("Cargo.lock")).unwrap(),
368 lock("0.1.0")
369 );
370 std::fs::write(scratch.join("Cargo.lock"), lock("0.2.0")).unwrap();
371 assert!(build().success());
372 }
373}