1use std::collections::BTreeSet;
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9
10use std::time::Duration;
11
12use anyhow::{Context, Result};
13use perspt_coding::LanguageId;
14use tokio::process::Command;
15
16pub(crate) struct VerifierExecution {
18 pub(crate) success: bool,
19 pub(crate) output: String,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct VerifierTimeouts {
26 pub default_secs: u64,
27 pub syntax: Option<u64>,
28 pub build: Option<u64>,
29 pub test: Option<u64>,
30 pub lint: Option<u64>,
31 pub format: Option<u64>,
32}
33
34impl Default for VerifierTimeouts {
35 fn default() -> Self {
36 Self {
37 default_secs: 180,
38 syntax: None,
39 build: None,
40 test: None,
41 lint: None,
42 format: None,
43 }
44 }
45}
46
47impl VerifierTimeouts {
48 pub fn for_stage(&self, stage: Option<perspt_core::plugin::VerifierStage>) -> Duration {
50 use perspt_core::plugin::VerifierStage;
51 let secs = match stage {
52 Some(VerifierStage::SyntaxCheck) => self.syntax,
53 Some(VerifierStage::Build) => self.build,
54 Some(VerifierStage::Test) => self.test,
55 Some(VerifierStage::Lint) => self.lint,
56 Some(VerifierStage::Format) => self.format,
57 None => None,
58 };
59 Duration::from_secs(secs.unwrap_or(self.default_secs).max(1))
60 }
61}
62
63pub(crate) struct VerifierJob {
64 pub(crate) plugin: String,
65 pub(crate) adapter_id: LanguageId,
66 pub(crate) stage: perspt_core::plugin::VerifierStage,
67 pub(crate) command: String,
68 pub(crate) root: PathBuf,
69}
70
71pub(crate) async fn run_governed_verifier(
72 root: PathBuf,
73 command: String,
74 allow_unisolated: bool,
75 target_suffix: String,
76 extra_env: Vec<(String, String)>,
77 timeout: Duration,
78) -> Result<VerifierExecution> {
79 let tmp = root.join(".perspt-tmp").join(&target_suffix);
80 let target = root.join(".perspt-target").join(&target_suffix);
81 std::fs::create_dir_all(&tmp)?;
82 std::fs::create_dir_all(&target)?;
83 let read_roots = verifier_read_roots(&root, &extra_env);
84 let mut process = if allow_unisolated {
85 host_shell(&root, &command)
86 } else {
87 isolated_command(&root, &command, &read_roots)?
88 };
89 let uv_cache = root.join(".perspt-tmp").join("uv-cache");
94 let isolated_home = root.join(".perspt-home");
95 std::fs::create_dir_all(&uv_cache)?;
96 std::fs::create_dir_all(&isolated_home)?;
97 let original_home = std::env::var_os("HOME").map(PathBuf::from);
98 let cargo_home = std::env::var_os("CARGO_HOME")
99 .map(PathBuf::from)
100 .or_else(|| original_home.as_ref().map(|home| home.join(".cargo")));
101 let rustup_home = std::env::var_os("RUSTUP_HOME")
102 .map(PathBuf::from)
103 .or_else(|| original_home.as_ref().map(|home| home.join(".rustup")));
104 process
105 .env_clear()
106 .envs(verifier_environment())
107 .env("HOME", &isolated_home)
108 .env("CARGO_NET_OFFLINE", "true")
109 .env("CARGO_TARGET_DIR", target)
110 .env("UV_CACHE_DIR", uv_cache)
111 .env("UV_OFFLINE", "1")
112 .env("TMPDIR", tmp)
113 .env("TEMP", root.join(".perspt-tmp").join(&target_suffix))
114 .env("TMP", root.join(".perspt-tmp").join(&target_suffix))
115 .envs(extra_env)
116 .stdout(Stdio::piped())
117 .stderr(Stdio::piped())
118 .kill_on_drop(true);
119 if let Some(path) = cargo_home.filter(|path| path.is_dir()) {
120 process.env("CARGO_HOME", path);
121 }
122 if let Some(path) = rustup_home.filter(|path| path.is_dir()) {
123 process.env("RUSTUP_HOME", path);
124 }
125 configure_native_toolchain(&mut process);
126 let output = tokio::time::timeout(timeout, process.output())
127 .await
128 .with_context(|| {
129 format!(
130 "governed verifier exceeded its {} second limit \
131 ([verification] stage_timeout_secs raises it)",
132 timeout.as_secs()
133 )
134 })??;
135 Ok(VerifierExecution {
136 success: output.status.success(),
137 output: format!(
138 "{}\n{}",
139 String::from_utf8_lossy(&output.stdout),
140 String::from_utf8_lossy(&output.stderr)
141 ),
142 })
143}
144
145fn verifier_environment() -> Vec<(String, String)> {
146 let portable = [
147 "PATH",
148 "LANG",
149 "LC_ALL",
150 "SystemRoot",
151 "SYSTEMROOT",
152 "WINDIR",
153 "COMSPEC",
154 "PATHEXT",
155 ];
156 portable
157 .into_iter()
158 .chain(native_toolchain_environment())
159 .filter_map(|key| std::env::var(key).ok().map(|value| (key.into(), value)))
160 .collect()
161}
162
163#[cfg(windows)]
164fn native_toolchain_environment() -> impl Iterator<Item = &'static str> {
165 [
172 "ProgramFiles",
173 "ProgramFiles(x86)",
174 "ProgramW6432",
175 "VCINSTALLDIR",
176 "VSINSTALLDIR",
177 "VCToolsInstallDir",
178 "VCToolsVersion",
179 "VSCMD_ARG_HOST_ARCH",
180 "VSCMD_ARG_TGT_ARCH",
181 "WindowsSdkDir",
182 "WindowsSDKVersion",
183 "UniversalCRTSdkDir",
184 "UCRTVersion",
185 "INCLUDE",
186 "LIB",
187 "LIBPATH",
188 ]
189 .into_iter()
190}
191
192#[cfg(not(windows))]
193fn native_toolchain_environment() -> impl Iterator<Item = &'static str> {
194 std::iter::empty()
195}
196
197#[cfg(all(windows, target_env = "msvc"))]
198fn configure_native_toolchain(process: &mut Command) {
199 let Some((target, linker_variable)) = native_msvc_target() else {
200 return;
201 };
202 let Some(linker) = find_msvc_tools::find_tool(target, "link.exe") else {
203 return;
204 };
205 for (key, value) in linker.env() {
206 process.env(key, value);
207 }
208 process.env(linker_variable, linker.path());
211}
212
213#[cfg(all(windows, target_env = "msvc"))]
214fn native_msvc_target() -> Option<(&'static str, &'static str)> {
215 match std::env::consts::ARCH {
216 "x86_64" => Some((
217 "x86_64-pc-windows-msvc",
218 "CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER",
219 )),
220 "x86" => Some((
221 "i686-pc-windows-msvc",
222 "CARGO_TARGET_I686_PC_WINDOWS_MSVC_LINKER",
223 )),
224 "aarch64" => Some((
225 "aarch64-pc-windows-msvc",
226 "CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER",
227 )),
228 _ => None,
229 }
230}
231
232#[cfg(not(all(windows, target_env = "msvc")))]
233fn configure_native_toolchain(_process: &mut Command) {}
234
235#[cfg(windows)]
236fn host_shell(root: &Path, command: &str) -> Command {
237 let mut process = Command::new("cmd.exe");
238 process
239 .args(["/D", "/S", "/C"])
240 .arg(command)
241 .current_dir(root);
242 process
243}
244
245#[cfg(not(windows))]
246fn host_shell(root: &Path, command: &str) -> Command {
247 let mut process = Command::new("/bin/sh");
248 process.arg("-c").arg(command).current_dir(root);
249 process
250}
251
252#[cfg(target_os = "macos")]
253fn isolated_command(root: &Path, command: &str, read_roots: &[PathBuf]) -> Result<Command> {
254 let profile = macos_sandbox_profile(root, read_roots);
255 let mut process = Command::new("/usr/bin/sandbox-exec");
256 process
257 .arg("-p")
258 .arg(profile)
259 .arg("/bin/sh")
260 .arg("-c")
261 .arg(command)
262 .current_dir(root);
263 Ok(process)
264}
265
266#[cfg(target_os = "macos")]
267fn macos_sandbox_profile(root: &Path, read_roots: &[PathBuf]) -> String {
268 let escaped = root.to_string_lossy().replace('"', "\\\"");
269 let mut readable = read_roots.to_vec();
270 readable.push(root.to_path_buf());
271 let users_deny = deny_data_except("/Users", &readable);
272 let home_deny = deny_data_except("/home", &readable);
273 let root_home_deny = deny_data_except("/root", &readable);
274 let private_tmp_deny = deny_data_except("/private/tmp", &readable);
275 let user_tmp_deny = deny_data_except("/private/var/folders", &readable);
276 let volumes_deny = deny_data_except("/Volumes", &readable);
277 let network_deny = deny_data_except("/Network", &readable);
278 let extra_reads = read_roots
279 .iter()
280 .map(|path| {
281 format!(
282 "(allow file-read* (subpath \"{}\"))",
283 path.to_string_lossy()
284 .replace('\\', "\\\\")
285 .replace('"', "\\\"")
286 )
287 })
288 .collect::<Vec<_>>()
289 .join("\n");
290 format!(
291 "(version 1)\n\
292 (deny default)\n\
293 (allow process*)\n\
294 (allow sysctl-read)\n\
295 (allow file-read*)\n\
296 {users_deny}\n\
297 {home_deny}\n\
298 {root_home_deny}\n\
299 {private_tmp_deny}\n\
300 {user_tmp_deny}\n\
301 {volumes_deny}\n\
302 {network_deny}\n\
303 (allow file-read* (subpath \"{escaped}\"))\n\
304 {extra_reads}\n\
305 (allow file-write* (literal \"/dev/null\"))\n\
306 (allow file-write* (subpath \"{escaped}\"))\n\
307 (deny network*)"
308 )
309}
310
311#[cfg(target_os = "macos")]
312fn deny_data_except(parent: &str, allowed: &[PathBuf]) -> String {
313 let exceptions = allowed
314 .iter()
315 .filter(|path| path.starts_with(parent))
316 .map(|path| {
317 let escaped = path
318 .to_string_lossy()
319 .replace('\\', "\\\\")
320 .replace('"', "\\\"");
321 format!("(require-not (subpath \"{escaped}\"))")
322 })
323 .collect::<Vec<_>>();
324 if exceptions.is_empty() {
325 format!("(deny file-read-data (subpath \"{parent}\"))")
326 } else {
327 format!(
328 "(deny file-read-data (require-all (subpath \"{parent}\") {}))",
329 exceptions.join(" ")
330 )
331 }
332}
333
334#[cfg(target_os = "linux")]
335fn isolated_command(root: &Path, command: &str, read_roots: &[PathBuf]) -> Result<Command> {
336 let bwrap = ["/usr/bin/bwrap", "/bin/bwrap"]
337 .into_iter()
338 .find(|path| Path::new(path).is_file())
339 .context("bubblewrap is required for governed verifier execution")?;
340 let mut process = Command::new(bwrap);
341 process.args([
342 "--die-with-parent",
343 "--unshare-net",
344 "--ro-bind",
345 "/",
346 "/",
347 "--dev",
348 "/dev",
349 ]);
350 for sensitive in ["/home", "/root", "/Users"] {
351 if Path::new(sensitive).exists() {
352 process.arg("--tmpfs").arg(sensitive);
353 }
354 }
355 process.arg("--tmpfs").arg("/tmp");
356 for read_root in read_roots {
357 if !read_root.starts_with(root) {
358 prepare_bwrap_destination(&mut process, read_root);
359 process.arg("--ro-bind").arg(read_root).arg(read_root);
360 }
361 }
362 prepare_bwrap_destination(&mut process, root);
363 process
364 .arg("--bind")
365 .arg(root)
366 .arg(root)
367 .arg("--chdir")
368 .arg(root)
369 .arg("/bin/sh")
370 .arg("-c")
371 .arg(command);
372 Ok(process)
373}
374
375#[cfg(target_os = "linux")]
376fn prepare_bwrap_destination(process: &mut Command, destination: &Path) {
377 for directory in bwrap_destination_parents(destination) {
378 process.arg("--dir").arg(directory);
379 }
380}
381
382#[cfg(any(test, target_os = "linux"))]
383fn bwrap_destination_parents(destination: &Path) -> Vec<PathBuf> {
384 let Some(parent) = destination.parent() else {
385 return Vec::new();
386 };
387 let masked_roots = [
388 Path::new("/home"),
389 Path::new("/root"),
390 Path::new("/Users"),
391 Path::new("/tmp"),
392 ];
393 let Some(masked) = masked_roots
394 .into_iter()
395 .find(|masked| parent.starts_with(masked))
396 else {
397 return Vec::new();
398 };
399 let mut missing_parents: Vec<_> = parent
400 .ancestors()
401 .take_while(|ancestor| *ancestor != masked)
402 .map(Path::to_path_buf)
403 .collect();
404 missing_parents.reverse();
405 missing_parents
406}
407
408#[cfg(not(any(target_os = "macos", target_os = "linux")))]
409fn isolated_command(_root: &Path, _command: &str, _read_roots: &[PathBuf]) -> Result<Command> {
410 anyhow::bail!("this platform has no registered governed process sandbox")
411}
412
413fn verifier_read_roots(root: &Path, extra_env: &[(String, String)]) -> Vec<PathBuf> {
414 let mut roots = BTreeSet::new();
415 for (_, value) in extra_env {
416 let path = PathBuf::from(value);
417 if path.exists() {
418 roots.insert(path.canonicalize().unwrap_or_else(|_| path.clone()));
419 for interpreter in [
420 path.join("bin/python"),
421 path.join("bin/python3"),
422 path.join("Scripts/python.exe"),
423 ] {
424 if let Ok(target) = interpreter.canonicalize() {
425 if let Some(installation) = target.parent().and_then(Path::parent) {
426 roots.insert(installation.to_path_buf());
427 }
428 }
429 }
430 }
431 }
432 if let Ok(value) = std::env::var("CARGO_HOME") {
433 extend_cargo_read_roots(&mut roots, &PathBuf::from(value));
434 }
435 if let Ok(value) = std::env::var("RUSTUP_HOME") {
436 let path = PathBuf::from(value);
437 if path.exists() {
438 roots.insert(path.canonicalize().unwrap_or(path));
439 }
440 }
441 if let Ok(home) = std::env::var("HOME") {
442 let home = PathBuf::from(home);
443 extend_cargo_read_roots(&mut roots, &home.join(".cargo"));
444 for relative in [".rustup", ".local/bin"] {
445 let path = home.join(relative);
446 if path.exists() {
447 roots.insert(path.canonicalize().unwrap_or(path));
448 }
449 }
450 }
451 if let Ok(path) = std::env::var("PATH") {
452 for directory in std::env::split_paths(&path) {
453 if directory.exists()
454 && !directory.starts_with("/usr")
455 && !directory.starts_with("/bin")
456 {
457 roots.insert(directory.canonicalize().unwrap_or(directory));
458 }
459 }
460 }
461 roots.remove(root);
462 roots.into_iter().collect()
463}
464
465fn extend_cargo_read_roots(roots: &mut BTreeSet<PathBuf>, cargo_home: &Path) {
466 for relative in ["bin", "config", "config.toml", "git", "registry"] {
469 let path = cargo_home.join(relative);
470 if path.exists() {
471 roots.insert(path.canonicalize().unwrap_or(path));
472 }
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 #[cfg(target_os = "macos")]
481 #[test]
482 fn macos_profile_only_allows_candidate_and_null_device_writes() {
483 let profile = macos_sandbox_profile(
484 Path::new("/private/tmp/candidate"),
485 &[PathBuf::from("/Users/test/.rustup")],
486 );
487 assert!(profile.contains("(allow file-write* (literal \"/dev/null\"))"));
488 assert!(profile.contains("(allow file-write* (subpath \"/private/tmp/candidate\"))"));
489 assert_eq!(profile.matches("allow file-write*").count(), 2);
490 assert!(profile
491 .lines()
492 .any(|line| line.trim() == "(allow file-read*)"));
493 assert!(profile.contains("(deny file-read-data (require-all (subpath \"/Users\")"));
494 assert!(profile.contains("(deny file-read-data (require-all (subpath \"/private/tmp\")"));
495 assert!(profile.contains("/Users/test/.rustup"));
496 assert!(profile.contains("(deny network*)"));
497 }
498
499 #[cfg(unix)]
500 #[test]
501 fn verifier_roots_follow_virtualenv_interpreter_symlinks() {
502 let root = tempfile::tempdir().unwrap();
503 let install = root.path().join("uv-python");
504 let venv = root.path().join("project/.venv");
505 std::fs::create_dir_all(install.join("bin")).unwrap();
506 std::fs::create_dir_all(venv.join("bin")).unwrap();
507 std::fs::write(install.join("bin/python3"), "binary").unwrap();
508 std::os::unix::fs::symlink(install.join("bin/python3"), venv.join("bin/python3")).unwrap();
509
510 let roots = verifier_read_roots(
511 root.path(),
512 &[("UV_PROJECT_ENVIRONMENT".into(), venv.display().to_string())],
513 );
514 assert!(roots.contains(&install.canonicalize().unwrap()));
515 }
516
517 #[test]
518 fn cargo_verifier_roots_exclude_credentials() {
519 let root = tempfile::tempdir().unwrap();
520 let cargo_home = root.path().join("cargo-home");
521 std::fs::create_dir_all(cargo_home.join("bin")).unwrap();
522 std::fs::create_dir_all(cargo_home.join("registry")).unwrap();
523 std::fs::write(cargo_home.join("credentials.toml"), "[registry]").unwrap();
524
525 let mut roots = BTreeSet::new();
526 extend_cargo_read_roots(&mut roots, &cargo_home);
527 assert!(roots.contains(&cargo_home.join("bin").canonicalize().unwrap()));
528 assert!(roots.contains(&cargo_home.join("registry").canonicalize().unwrap()));
529 assert!(!roots.contains(&cargo_home));
530 assert!(!roots.contains(&cargo_home.join("credentials.toml")));
531 }
532
533 #[test]
534 fn bubblewrap_rebinds_recreate_only_masked_destination_parents() {
535 assert_eq!(
536 bwrap_destination_parents(Path::new("/home/user/.cargo/registry")),
537 [
538 PathBuf::from("/home/user"),
539 PathBuf::from("/home/user/.cargo")
540 ]
541 );
542 assert!(bwrap_destination_parents(Path::new("/usr/bin/cargo")).is_empty());
543 }
544}