Skip to main content

lonca_verify/
lib.rs

1//! Evidence producers. The Gatekeeper runs build and test commands itself,
2//! in a sandbox, on the merged tree; the agent's own reports are ignored.
3//!
4//! Results are nondeterministic by nature, so they are recorded as
5//! [`Attestation`]s in the operation log and never recomputed on replay.
6
7use std::collections::BTreeMap;
8use std::ffi::{OsStr, OsString};
9use std::fs;
10use std::os::unix::process::ExitStatusExt;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::time::{Duration, Instant};
14
15use lonca_core::canonical::sha256_hex;
16use lonca_core::ops::{Attestation, CheckKind, CheckResult};
17use lonca_core::{FileMap, FileMode, GitId, Result, Store};
18use lonca_policy::config::Verification;
19use lonca_symbols::engine::{ENGINE_CURRENT, reads_text};
20use lonca_symbols::{Lang, parse};
21
22pub mod jail;
23mod process;
24pub mod sweep;
25
26use jail::Jail;
27
28const BWRAP: [&str; 3] = ["/usr/bin/bwrap", "/bin/bwrap", "/usr/local/bin/bwrap"];
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum Sandbox {
32    /// macOS Seatbelt (`sandbox-exec`).
33    Seatbelt,
34    /// Linux bubblewrap.
35    Bubblewrap,
36    None,
37}
38
39impl Sandbox {
40    pub fn detect() -> Self {
41        let exists = |p: &str| Path::new(p).exists();
42        if cfg!(target_os = "macos") && exists("/usr/bin/sandbox-exec") {
43            Sandbox::Seatbelt
44        } else if BWRAP.iter().any(|p| exists(p)) {
45            Sandbox::Bubblewrap
46        } else {
47            Sandbox::None
48        }
49    }
50
51    /// The program that holds a command in this sandbox, by absolute path:
52    /// `PATH` is the environment's to change.
53    pub fn program(self) -> Option<&'static str> {
54        match self {
55            Sandbox::Seatbelt => Some("/usr/bin/sandbox-exec"),
56            Sandbox::Bubblewrap => BWRAP.iter().copied().find(|p| Path::new(p).exists()),
57            Sandbox::None => None,
58        }
59    }
60
61    pub fn name(self) -> &'static str {
62        match self {
63            Sandbox::Seatbelt => "seatbelt",
64            Sandbox::Bubblewrap => "bwrap",
65            Sandbox::None => "none",
66        }
67    }
68}
69
70pub struct Verifier {
71    pub sandbox: Sandbox,
72    /// Directory under which work trees are created.
73    pub scratch: PathBuf,
74}
75
76impl Verifier {
77    pub fn new(scratch: PathBuf) -> Self {
78        Self {
79            sandbox: Sandbox::detect(),
80            scratch,
81        }
82    }
83
84    /// Static syntax check of every supported file that the change touched.
85    pub fn syntax(
86        &self,
87        store: &Store,
88        files: &FileMap,
89        touched: &[String],
90        subject: &GitId,
91    ) -> Result<Attestation> {
92        let mut bad = Vec::new();
93        for p in touched {
94            let (Some(lang), Some(e)) = (Lang::detect(p), files.get(p)) else {
95                continue;
96            };
97            // A grammar that can not read valid code must not refuse it.
98            if !lang.is_strict() {
99                continue;
100            }
101            let bytes = store.read_blob(&e.blob)?;
102            let ok = std::str::from_utf8(&bytes)
103                .map(|t| !reads_text(ENGINE_CURRENT, lang, t) || !parse(lang, t).has_error)
104                .unwrap_or(false);
105            if !ok {
106                bad.push(p.clone());
107            }
108        }
109        let mut details = BTreeMap::new();
110        details.insert("files_checked".into(), touched.len().to_string());
111        if !bad.is_empty() {
112            details.insert("errors".into(), bad.join(","));
113        }
114        Ok(Attestation {
115            check: CheckKind::Syntax,
116            result: if bad.is_empty() {
117                CheckResult::Pass
118            } else {
119                CheckResult::Fail
120            },
121            subject_tree: subject.clone(),
122            details,
123            env: "tree-sitter".into(),
124            log: None,
125            producer: "lonca-verify/syntax".into(),
126        })
127    }
128
129    /// Runs the configured build and test commands on `files`, then the
130    /// named checks in `cfg.checks`, in name order. One with no command (a
131    /// name the policy no longer defines) does not run: there is nothing
132    /// to pass it.
133    pub fn run(
134        &self,
135        store: &Store,
136        files: &FileMap,
137        subject: &GitId,
138        cfg: &Verification,
139    ) -> Result<Vec<Attestation>> {
140        let mut out = Vec::new();
141        let builtin = [
142            (CheckKind::Build, &cfg.build),
143            (CheckKind::Tests, &cfg.test),
144            (CheckKind::Extended, &cfg.on_signature_change),
145        ];
146        let builtin = builtin
147            .into_iter()
148            .filter_map(|(k, c)| c.as_ref().map(|c| (k, None, c)));
149        let named = cfg.checks.iter().filter(|(_, c)| !c.trim().is_empty());
150        let named = named.map(|(n, c)| (CheckKind::Named, Some(n), c));
151        let steps: Vec<(CheckKind, Option<&String>, &String)> = builtin.chain(named).collect();
152        if steps.is_empty() {
153            return Ok(out);
154        }
155        fs::create_dir_all(&self.scratch)?;
156        let work = tempfile::Builder::new()
157            .prefix("verify-")
158            .tempdir_in(&self.scratch)?;
159        let root = work.path().canonicalize()?;
160        materialize(store, files, &root.join("tree"))?;
161        let mut failed = false;
162        for (kind, name, cmd) in steps {
163            let named = || {
164                let name = name.map(|n| ("name".to_string(), n.clone()));
165                name.into_iter().collect::<BTreeMap<_, _>>()
166            };
167            if failed {
168                out.push(attestation(
169                    kind,
170                    CheckResult::Skipped,
171                    subject,
172                    named(),
173                    self.env(cmd),
174                    None,
175                ));
176                continue;
177            }
178            let started = Instant::now();
179            let mut attempts = 0;
180            let (result, details, log) = loop {
181                attempts += 1;
182                let run = self.exec(cmd, &root, Duration::from_secs(cfg.timeout_seconds), cfg);
183                let done = run.0 == CheckResult::Pass || attempts > cfg.retries;
184                if done {
185                    break run;
186                }
187            };
188            let mut details = details;
189            details.extend(named());
190            let took = started.elapsed().as_millis().to_string();
191            details.insert("duration_ms".into(), took);
192            details.insert("attempts".into(), attempts.to_string());
193            details.insert("sandbox".into(), self.sandbox.name().into());
194            let log_id = store.write_blob(log.as_bytes())?;
195            failed = result != CheckResult::Pass;
196            out.push(attestation(
197                kind,
198                result,
199                subject,
200                details,
201                self.env(cmd),
202                Some(log_id),
203            ));
204        }
205        Ok(out)
206    }
207
208    /// The sandbox a command runs in, from this process's environment.
209    fn jail(&self, tree: &Path, writable: Vec<PathBuf>, cfg: &Verification) -> Jail {
210        let env = |name: &str| std::env::var_os(name).map(PathBuf::from);
211        let home = env("HOME");
212        let named = [
213            env("LONCA_HOME"),
214            env("LONCA_HUMANS"),
215            home.as_ref().map(|h| h.join(".lonca")),
216            home.as_ref().map(|h| h.join(".config/lonca")),
217        ];
218        let real = |p: PathBuf| p.canonicalize().ok();
219        let secrets = named.into_iter().flatten().filter_map(real).collect();
220        let store = self.scratch.parent().and_then(|p| real(p.to_path_buf()));
221        let passed: Vec<PathBuf> = cfg.env_passthrough.iter().filter_map(|n| env(n)).collect();
222        let path = command_path(cfg);
223        let closed = Closed {
224            secrets,
225            store,
226            home,
227        };
228        held_jail(tree, writable, &closed, &path, &passed, cfg)
229    }
230
231    fn env(&self, cmd: &str) -> String {
232        sha256_hex(format!("{}\0{cmd}", self.sandbox.name()).as_bytes())
233    }
234
235    /// Runs one command. The environment is cleared and rebuilt: PATH, a
236    /// scratch HOME, LANG, LONCA_VERIFY, then the names the policy passes
237    /// through (copied from the daemon's environment; absent ones are
238    /// skipped) and the fixed values it sets. The log starts with the
239    /// names that were set, never their values, so a failure explains
240    /// itself.
241    fn exec(
242        &self,
243        cmd: &str,
244        root: &Path,
245        timeout: Duration,
246        cfg: &Verification,
247    ) -> (CheckResult, BTreeMap<String, String>, String) {
248        let tree = root.join("tree");
249        let home = root.join("home");
250        let tmp = root.join("tmp");
251        let _ = fs::create_dir_all(&home);
252        let _ = fs::create_dir_all(&tmp);
253        let mut jail = self.jail(&tree, vec![home.clone(), tmp.clone()], cfg);
254        jail.mark = jail::mark_of(self.sandbox, "verify", root);
255        let argv: [OsString; 3] = ["/bin/sh".into(), "-c".into(), cmd.into()];
256        let mut command = jail.command(self.sandbox, &argv).unwrap_or_else(|| {
257            let mut c = Command::new("/bin/sh");
258            c.arg("-c").arg(cmd);
259            c
260        });
261        command
262            .current_dir(&tree)
263            .env_clear()
264            .env("PATH", command_path(cfg))
265            .env("HOME", &home)
266            .env("TMPDIR", &tmp)
267            .env("LANG", "C.UTF-8")
268            .env("LONCA_VERIFY", "1");
269        let mut passed: Vec<String> = Vec::new();
270        for name in &cfg.env_passthrough {
271            // The sandbox's own: nothing else can be written.
272            if lonca_policy::config::env_name_forbidden(name) || name == "TMPDIR" {
273                continue;
274            }
275            if let Some(v) = std::env::var_os(name) {
276                command.env(name, v);
277                passed.push(name.clone());
278            }
279        }
280        for (name, value) in &cfg.env {
281            if lonca_policy::config::env_name_forbidden(name) {
282                continue;
283            }
284            command.env(name, value);
285            passed.push(format!("{name}="));
286        }
287        let header = format!(
288            "lonca verify: sandbox {}, env PATH HOME TMPDIR LANG LONCA_VERIFY{}{}\n",
289            self.sandbox.name(),
290            if passed.is_empty() { "" } else { " " },
291            passed.join(" ")
292        );
293        let mut details = BTreeMap::new();
294        let running = match process::start(command, cfg) {
295            Ok(r) => r,
296            Err(e) => {
297                details.insert("error".into(), format!("spawn: {e}"));
298                return (CheckResult::Error, details, header);
299            }
300        };
301        let ended = process::watch(running, timeout, jail.mark.as_deref());
302        let log = format!("{header}{}", String::from_utf8_lossy(&ended.output));
303        let left = ended.left.is_some();
304        details.extend(ended.left);
305        let result = match ended.status {
306            None => {
307                details.insert("error".into(), "timeout".into());
308                CheckResult::Error
309            }
310            Some(s) => {
311                details.insert(
312                    "exit".into(),
313                    s.code()
314                        .map(|c| c.to_string())
315                        .unwrap_or_else(|| "signal".into()),
316                );
317                details.extend(s.signal().map(|n| ("signal".into(), n.to_string())));
318                outcome(s.success(), left)
319            }
320        };
321        (result, details, log)
322    }
323}
324
325/// What a command that ended comes to. What is still running can change the
326/// tree and the outcome, so a pass can not stand on it.
327fn outcome(success: bool, left: bool) -> CheckResult {
328    match (success, left) {
329        (_, true) => CheckResult::Error,
330        (true, false) => CheckResult::Pass,
331        (false, false) => CheckResult::Fail,
332    }
333}
334
335/// The commands' `PATH`: the policy's, or else the daemon's.
336fn command_path(cfg: &Verification) -> OsString {
337    if cfg.path.is_empty() {
338        std::env::var_os("PATH").unwrap_or_else(|| "/usr/bin:/bin".into())
339    } else {
340        std::env::join_paths(&cfg.path).unwrap_or_default()
341    }
342}
343
344/// What stays closed to a command.
345struct Closed {
346    /// Lonca's keys and stores.
347    secrets: Vec<PathBuf>,
348    /// The daemon's directory. It holds the run's own tree, so it can not be
349    /// closed as a whole: nothing that is opened may touch it.
350    store: Option<PathBuf>,
351    home: Option<PathBuf>,
352}
353
354/// The sandbox of a command. It is the agent's code that runs, so this is the
355/// agent's own sandbox, with no way out at all: no socket, no proxy, and of
356/// what is closed only what the policy names.
357fn held_jail(
358    tree: &Path,
359    writable: Vec<PathBuf>,
360    closed: &Closed,
361    path: &OsStr,
362    passed: &[PathBuf],
363    cfg: &Verification,
364) -> Jail {
365    let home = closed.home.as_deref();
366    let real = home.and_then(|h| h.canonicalize().ok());
367    let stores: Vec<PathBuf> = closed
368        .secrets
369        .iter()
370        .chain(&closed.store)
371        .cloned()
372        .collect();
373    let held: Vec<PathBuf> = stores.iter().chain(&real).cloned().collect();
374    let mut readable = jail::program_trees(path, &[], &held, &[tree.to_path_buf()]);
375    readable.extend(opened(cfg, passed, home, &stores, real.as_deref()));
376    Jail {
377        workspace: tree.to_path_buf(),
378        writable,
379        readable,
380        secrets: stores
381            .into_iter()
382            .filter(|s| !tree.starts_with(s))
383            .collect(),
384        ..Default::default()
385    }
386}
387
388/// What the policy opens for reading: `verification.read`, and the directories
389/// in `passed`, the values of `env_passthrough`, as they really are. What holds
390/// or lies in something closed stays closed, and so does the home directory as
391/// a whole, which is what `HOME` in `env_passthrough` would otherwise open.
392fn opened(
393    cfg: &Verification,
394    passed: &[PathBuf],
395    home: Option<&Path>,
396    closed: &[PathBuf],
397    real_home: Option<&Path>,
398) -> Vec<PathBuf> {
399    let named = cfg.read.iter().filter_map(|p| match p.strip_prefix("~/") {
400        Some(rest) => home.map(|h| h.join(rest)),
401        None => Some(PathBuf::from(p)),
402    });
403    let passed = passed.iter().filter(|p| p.is_absolute() && p.is_dir());
404    let mut out = Vec::new();
405    for p in named.chain(passed.cloned()) {
406        let Ok(p) = p.canonicalize() else {
407            continue;
408        };
409        let holds_home = real_home.is_some_and(|h| h.starts_with(&p));
410        if !holds_home && jail::holding_secrets(&p, closed).is_none() && !out.contains(&p) {
411            out.push(p);
412        }
413    }
414    out
415}
416
417fn attestation(
418    check: CheckKind,
419    result: CheckResult,
420    subject: &GitId,
421    details: BTreeMap<String, String>,
422    env: String,
423    log: Option<GitId>,
424) -> Attestation {
425    Attestation {
426        check,
427        result,
428        subject_tree: subject.clone(),
429        details,
430        env,
431        log,
432        producer: "lonca-verify".into(),
433    }
434}
435
436/// Writes `files` below `dir`. Symlinks are written as plain files holding
437/// their target so a tree can never point the verifier outside `dir`.
438pub fn materialize(store: &Store, files: &FileMap, dir: &Path) -> Result<()> {
439    fs::create_dir_all(dir)?;
440    for (path, entry) in files {
441        lonca_core::paths::validate_repo_path(path)?;
442        if entry.mode == FileMode::Submodule {
443            continue;
444        }
445        let target = dir.join(path);
446        if let Some(parent) = target.parent() {
447            fs::create_dir_all(parent)?;
448        }
449        fs::write(&target, store.read_blob(&entry.blob)?)?;
450        #[cfg(unix)]
451        if entry.mode == FileMode::Executable {
452            use std::os::unix::fs::PermissionsExt;
453            fs::set_permissions(&target, fs::Permissions::from_mode(0o755))?;
454        }
455    }
456    Ok(())
457}
458
459#[cfg(test)]
460mod tests {
461    use lonca_symbols::engine::MAX_READ;
462
463    use super::*;
464    use lonca_core::FileEntry;
465
466    fn setup(files: &[(&str, &str)]) -> (tempfile::TempDir, Store, FileMap, GitId) {
467        let dir = tempfile::tempdir().unwrap();
468        let store = Store::init_bare(&dir.path().join("s.git")).unwrap();
469        let mut map = FileMap::new();
470        for (p, t) in files {
471            map.insert(
472                p.to_string(),
473                FileEntry {
474                    mode: FileMode::Regular,
475                    blob: store.write_blob(t.as_bytes()).unwrap(),
476                },
477            );
478        }
479        let tree = store.write_tree(&map).unwrap();
480        (dir, store, map, tree)
481    }
482
483    fn cfg(build: Option<&str>, test: Option<&str>) -> Verification {
484        Verification {
485            build: build.map(String::from),
486            test: test.map(String::from),
487            timeout_seconds: 20,
488            ..Default::default()
489        }
490    }
491
492    #[test]
493    fn what_is_left_running_stands_in_the_way_of_a_pass() {
494        assert_eq!(outcome(true, false), CheckResult::Pass);
495        assert_eq!(outcome(false, false), CheckResult::Fail);
496        assert_eq!(outcome(true, true), CheckResult::Error);
497        assert_eq!(outcome(false, true), CheckResult::Error);
498    }
499
500    #[test]
501    fn pass_fail_and_skip_after_failure() {
502        let (dir, store, files, tree) = setup(&[("ok.txt", "yes\n")]);
503        let v = Verifier::new(dir.path().join("scratch"));
504        let r = v
505            .run(
506                &store,
507                &files,
508                &tree,
509                &cfg(Some("test -f ok.txt"), Some("exit 3")),
510            )
511            .unwrap();
512        assert_eq!(r[0].result, CheckResult::Pass);
513        assert_eq!(r[1].result, CheckResult::Fail);
514        assert_eq!(r[1].details["exit"], "3");
515        let r = v
516            .run(&store, &files, &tree, &cfg(Some("false"), Some("true")))
517            .unwrap();
518        assert_eq!(
519            (r[0].result, r[1].result),
520            (CheckResult::Fail, CheckResult::Skipped)
521        );
522    }
523
524    /// Named checks run after the built-in ones, in name order, each named
525    /// in its evidence; one with no command does not run; and a failure
526    /// skips what comes after, by name too.
527    #[test]
528    fn named_checks_run_after_the_built_in_ones() {
529        let (dir, store, files, tree) = setup(&[("ok.txt", "yes\n")]);
530        let v = Verifier::new(dir.path().join("scratch"));
531        let mut c = cfg(Some("true"), None);
532        c.checks.insert("e2e".into(), "exit 4".into());
533        c.checks.insert("api".into(), "test -f ok.txt".into());
534        c.checks.insert("gone".into(), " ".into());
535        c.checks.insert("zz".into(), "true".into());
536        let r = v.run(&store, &files, &tree, &c).unwrap();
537        let seen: Vec<_> = r
538            .iter()
539            .map(|a| (a.check, a.details.get("name").cloned(), a.result))
540            .collect();
541        let named = |n: &str| Some(n.to_string());
542        assert_eq!(
543            seen,
544            [
545                (CheckKind::Build, None, CheckResult::Pass),
546                (CheckKind::Named, named("api"), CheckResult::Pass),
547                (CheckKind::Named, named("e2e"), CheckResult::Fail),
548                (CheckKind::Named, named("zz"), CheckResult::Skipped),
549            ]
550        );
551        assert_eq!(r[2].details["exit"], "4");
552        assert!(r[2].log.is_some());
553        assert_eq!(r[3].log, None);
554        // Only the named ones: there is still a tree to run them on.
555        let mut only = cfg(None, None);
556        only.checks.insert("api".into(), "test -f ok.txt".into());
557        let r = v.run(&store, &files, &tree, &only).unwrap();
558        assert_eq!(r.len(), 1);
559        assert_eq!(r[0].result, CheckResult::Pass);
560        // A name with no command alone runs nothing.
561        let mut none = cfg(None, None);
562        none.checks.insert("gone".into(), String::new());
563        assert!(v.run(&store, &files, &tree, &none).unwrap().is_empty());
564    }
565
566    #[test]
567    fn timeout_is_an_error() {
568        let (dir, store, files, tree) = setup(&[("a", "")]);
569        let v = Verifier::new(dir.path().join("scratch"));
570        let mut c = cfg(None, Some("sleep 5"));
571        c.timeout_seconds = 1;
572        let r = v.run(&store, &files, &tree, &c).unwrap();
573        assert_eq!(r[0].result, CheckResult::Error);
574        assert_eq!(r[0].details["error"], "timeout");
575    }
576
577    /// What a check took is every attempt's time, so a quota sees retries.
578    #[test]
579    fn duration_is_every_attempt() {
580        let (dir, store, files, tree) = setup(&[("a", "")]);
581        let v = Verifier::new(dir.path().join("scratch"));
582        let mut c = cfg(None, Some("sleep 0.3; exit 1"));
583        c.retries = 2;
584        let r = v.run(&store, &files, &tree, &c).unwrap();
585        assert_eq!(r[0].result, CheckResult::Fail);
586        assert_eq!(r[0].details["attempts"], "3");
587        let ms: u64 = r[0].details["duration_ms"].parse().unwrap();
588        assert!((900..60_000).contains(&ms), "{ms}");
589    }
590
591    fn log_of(store: &Store, a: &Attestation) -> String {
592        String::from_utf8(store.read_blob(a.log.as_ref().unwrap()).unwrap()).unwrap()
593    }
594
595    /// Both ways of leaving something behind that keeps the output open.
596    #[test]
597    fn what_a_command_leaves_running_does_not_hold_the_run() {
598        let (dir, store, files, tree) = setup(&[("a", "")]);
599        let v = Verifier::new(dir.path().join("scratch"));
600        let c = cfg(None, Some("(sleep 1000000 &); exit 0"));
601        let r = v.run(&store, &files, &tree, &c).unwrap();
602        assert_eq!(r[0].result, CheckResult::Pass, "{:?}", r[0]);
603        let mut c = cfg(None, Some("(sleep 1000000 &); sleep 1000000"));
604        c.timeout_seconds = 1;
605        let r = v.run(&store, &files, &tree, &c).unwrap();
606        assert_eq!(r[0].result, CheckResult::Error);
607        assert_eq!(r[0].details["error"], "timeout");
608    }
609
610    /// Bare, because a sandbox ends this some other way: the group is what
611    /// is under test.
612    #[test]
613    fn nothing_in_the_group_outlives_the_command() {
614        let (dir, store, files, tree) = setup(&[("a", "")]);
615        let v = Verifier {
616            sandbox: Sandbox::None,
617            scratch: dir.path().join("scratch"),
618        };
619        let (exited, timed_out) = (dir.path().join("exited"), dir.path().join("timed-out"));
620        let after =
621            |file: &Path, end: &str| format!("(sleep 1; touch '{}') & {end}", file.display());
622        let c = cfg(None, Some(&after(&exited, "exit 0")));
623        let r = v.run(&store, &files, &tree, &c).unwrap();
624        assert_eq!(r[0].result, CheckResult::Pass, "{:?}", r[0]);
625        let mut c = cfg(None, Some(&after(&timed_out, "sleep 1000000")));
626        c.timeout_seconds = 0;
627        let r = v.run(&store, &files, &tree, &c).unwrap();
628        assert_eq!(r[0].details["error"], "timeout");
629        std::thread::sleep(Duration::from_millis(1500));
630        assert!(!exited.exists(), "it ran on after the command exited");
631        assert!(!timed_out.exists(), "it ran on after the timeout");
632    }
633
634    /// With no sandbox, what left the group can not be reached. It still
635    /// must not hold anything up, and the result is the command's own.
636    #[test]
637    fn what_left_the_group_is_waited_for_only_briefly() {
638        let (dir, store, files, tree) = setup(&[("a", "")]);
639        let v = Verifier {
640            sandbox: Sandbox::None,
641            scratch: dir.path().join("scratch"),
642        };
643        let cmd = "exec /usr/bin/perl -MPOSIX -e 'fork and exit; POSIX::setsid(); sleep 20'";
644        if !Path::new("/usr/bin/perl").exists() {
645            return;
646        }
647        let started = Instant::now();
648        let r = v.run(&store, &files, &tree, &cfg(None, Some(cmd))).unwrap();
649        assert_eq!(r[0].result, CheckResult::Pass, "{:?}", r[0]);
650        assert!(started.elapsed() < Duration::from_secs(12));
651    }
652
653    /// The leader of a group can move to another one, and leave nothing in
654    /// its own for a kill to reach, unless it leads a session.
655    #[test]
656    fn a_command_can_not_take_itself_out_of_reach() {
657        let (dir, store, files, tree) = setup(&[("a", "")]);
658        let v = Verifier {
659            sandbox: Sandbox::None,
660            scratch: dir.path().join("scratch"),
661        };
662        if !Path::new("/usr/bin/perl").exists() {
663            return;
664        }
665        let cmd = "exec /usr/bin/perl -MPOSIX -e '\
666                   my $c = fork; if (!$c) { POSIX::setpgid(0, 0); sleep 20; exit } \
667                   select(undef, undef, undef, 0.2); POSIX::setpgid(0, $c); sleep 20'";
668        let mut c = cfg(None, Some(cmd));
669        c.timeout_seconds = 1;
670        let started = Instant::now();
671        let r = v.run(&store, &files, &tree, &c).unwrap();
672        assert!(started.elapsed() < Duration::from_secs(12), "{:?}", r[0]);
673        assert_eq!(r[0].details["error"], "timeout");
674    }
675
676    /// A process whose program is gone has no path, and is no zombie.
677    #[cfg(target_os = "macos")]
678    #[test]
679    fn a_sandbox_finds_what_deleted_its_own_program() {
680        let (dir, store, mut files, _) = setup(&[("a", "")]);
681        let source = dir.path().join("tool.c");
682        fs::write(
683            &source,
684            "#include <stdio.h>\n#include <unistd.h>\n\
685             int main(int c, char **v) {\n\
686               unlink(v[0]);\n\
687               if (fork()) return 0;\n\
688               setsid();\n\
689               FILE *f = fopen(\"pid\", \"w\");\n\
690               fprintf(f, \"%d\", getpid());\n\
691               fclose(f);\n\
692               sleep(30);\n\
693             }\n",
694        )
695        .unwrap();
696        let built = dir.path().join("tool");
697        let cc = std::process::Command::new("/usr/bin/cc")
698            .arg(&source)
699            .arg("-o")
700            .arg(&built)
701            .output();
702        if !cc.is_ok_and(|o| o.status.success()) {
703            eprintln!("no compiler; skipping");
704            return;
705        }
706        files.insert(
707            "tool".into(),
708            FileEntry {
709                mode: FileMode::Executable,
710                blob: store.write_blob(&fs::read(&built).unwrap()).unwrap(),
711            },
712        );
713        let tree = store.write_tree(&files).unwrap();
714        let v = Verifier::new(dir.path().join("scratch"));
715        let name = format!("left-{}", std::process::id());
716        // The command fails unless that process is alive.
717        let cmd = format!("./tool {name} && sleep 1 && kill -0 \"$(cat pid)\"");
718        let r = v
719            .run(&store, &files, &tree, &cfg(None, Some(&cmd)))
720            .unwrap();
721        assert_eq!(r[0].result, CheckResult::Pass, "{}", log_of(&store, &r[0]));
722        let found = std::process::Command::new("/usr/bin/pgrep")
723            .args(["-f", &name])
724            .output()
725            .unwrap();
726        let _ = std::process::Command::new("/usr/bin/pkill")
727            .args(["-f", &name])
728            .status();
729        assert!(found.stdout.is_empty(), "still running");
730    }
731
732    /// Only Seatbelt can leave something running out of the group.
733    #[cfg(target_os = "macos")]
734    #[test]
735    fn a_sandbox_keeps_nothing_that_left_the_group() {
736        let (dir, store, files, tree) = setup(&[("a", "")]);
737        let v = Verifier::new(dir.path().join("scratch"));
738        let name = format!("left-by-{}", dir.path().display());
739        let cmd = format!(
740            "exec /usr/bin/perl -MPOSIX -e 'fork and exit; POSIX::setsid(); sleep 30' '{name}'"
741        );
742        let r = v
743            .run(&store, &files, &tree, &cfg(None, Some(&cmd)))
744            .unwrap();
745        assert_eq!(r[0].result, CheckResult::Pass, "{:?}", r[0]);
746        assert!(!r[0].details.contains_key("left_running"), "{:?}", r[0]);
747        let found = std::process::Command::new("/usr/bin/pgrep")
748            .args(["-f", &name])
749            .output()
750            .unwrap();
751        assert!(found.stdout.is_empty(), "still running");
752    }
753
754    #[test]
755    fn the_log_has_the_streams_as_they_were_written() {
756        let (dir, store, files, tree) = setup(&[("a", "")]);
757        let v = Verifier::new(dir.path().join("scratch"));
758        let cmd = "echo 1 >&2; echo 2; echo 3 >&2; echo 4";
759        let r = v.run(&store, &files, &tree, &cfg(None, Some(cmd))).unwrap();
760        assert!(log_of(&store, &r[0]).ends_with("\n1\n2\n3\n4\n"));
761    }
762
763    #[test]
764    fn the_log_has_both_streams_and_only_the_end_of_them() {
765        let (dir, store, files, tree) = setup(&[("a", "")]);
766        let v = Verifier::new(dir.path().join("scratch"));
767        let cmd = "echo out; echo err >&2; head -c 200000 /dev/zero | tr '\\0' x; echo; echo last";
768        let r = v.run(&store, &files, &tree, &cfg(None, Some(cmd))).unwrap();
769        assert_eq!(r[0].result, CheckResult::Pass, "{:?}", r[0]);
770        let log = log_of(&store, &r[0]);
771        assert!(log.ends_with("x\nlast\n"), "{}", &log[log.len() - 40..]);
772        assert!(!log.contains("out\n"), "the beginning is gone");
773        let r = v
774            .run(
775                &store,
776                &files,
777                &tree,
778                &cfg(None, Some("echo out; echo err >&2")),
779            )
780            .unwrap();
781        let log = log_of(&store, &r[0]);
782        assert!(log.contains("out\n") && log.contains("err\n"), "{log}");
783        assert!(log.len() < 64 * 1024 + 1024);
784    }
785
786    #[test]
787    fn the_limits_reach_the_command() {
788        let (dir, store, files, tree) = setup(&[("a", "")]);
789        let v = Verifier::new(dir.path().join("scratch"));
790        let mut c = cfg(None, Some("ulimit -t; ulimit -n"));
791        c.limit_cpu_seconds = 7;
792        c.limit_open_files = 64;
793        let r = v.run(&store, &files, &tree, &c).unwrap();
794        assert_eq!(r[0].result, CheckResult::Pass, "{:?}", r[0]);
795        assert!(
796            log_of(&store, &r[0]).ends_with("\n7\n64\n"),
797            "{:?}",
798            log_of(&store, &r[0])
799        );
800    }
801
802    #[cfg(target_os = "linux")]
803    #[test]
804    fn linux_limits_processes_and_address_space_too() {
805        let (dir, store, files, tree) = setup(&[("a", "")]);
806        let v = Verifier::new(dir.path().join("scratch"));
807        let mut c = cfg(None, Some("cat /proc/self/limits"));
808        c.limit_processes = 2000;
809        c.limit_address_bytes = 1 << 34;
810        let r = v.run(&store, &files, &tree, &c).unwrap();
811        assert_eq!(r[0].result, CheckResult::Pass, "{:?}", r[0]);
812        let log = log_of(&store, &r[0]);
813        let limit = |name: &str| {
814            let line = log.lines().find(|l| l.starts_with(name));
815            line.and_then(|l| l[name.len()..].split_whitespace().next().map(String::from))
816        };
817        assert_eq!(limit("Max processes").as_deref(), Some("2000"), "{log}");
818        let bytes = (1u64 << 34).to_string();
819        assert_eq!(limit("Max address space"), Some(bytes), "{log}");
820    }
821
822    /// A writer that does not ignore the signal ends with it. `exec`, and
823    /// bare, for the signal to be the leader's own: a shell or bubblewrap
824    /// turns it into the exit code 128 + its number.
825    #[test]
826    fn a_file_at_its_limit_ends_the_writer_and_the_evidence_says_how() {
827        let (dir, store, files, tree) = setup(&[("a", "")]);
828        let v = Verifier {
829            sandbox: Sandbox::None,
830            scratch: dir.path().join("scratch"),
831        };
832        let cmd = "exec dd if=/dev/zero of=big bs=1024 count=1024";
833        let mut c = cfg(None, Some(cmd));
834        c.limit_file_bytes = 64 * 1024;
835        let r = v.run(&store, &files, &tree, &c).unwrap();
836        assert_eq!(r[0].result, CheckResult::Fail, "{:?}", r[0]);
837        assert_eq!(r[0].details["exit"], "signal");
838        assert_eq!(r[0].details["signal"], libc::SIGXFSZ.to_string());
839    }
840
841    #[test]
842    fn a_file_stops_at_its_limit_with_efbig() {
843        let (dir, store, files, tree) = setup(&[("a", "")]);
844        let v = Verifier::new(dir.path().join("scratch"));
845        let cmd = "trap '' XFSZ; dd if=/dev/zero of=big bs=1024 count=1024 2>&1";
846        let mut c = cfg(None, Some(cmd));
847        c.limit_file_bytes = 64 * 1024;
848        let r = v.run(&store, &files, &tree, &c).unwrap();
849        assert_ne!(r[0].result, CheckResult::Pass, "{:?}", r[0]);
850        let log = log_of(&store, &r[0]);
851        assert!(log.contains("File too large"), "{log}");
852    }
853
854    /// Never on a developer's Mac: there the limit does not exist.
855    #[cfg(target_os = "linux")]
856    #[test]
857    fn a_fork_bomb_stays_inside_its_limits() {
858        let (dir, store, files, tree) = setup(&[("a", "")]);
859        let v = Verifier::new(dir.path().join("scratch"));
860        let mut c = cfg(None, Some("b() { b | b; }; b"));
861        c.limit_processes = 512;
862        c.timeout_seconds = 2;
863        let started = Instant::now();
864        let r = v.run(&store, &files, &tree, &c).unwrap();
865        // It can die out at the limit, or be killed at the timeout.
866        assert!(started.elapsed() < Duration::from_secs(15), "{:?}", r[0]);
867        let log = log_of(&store, &r[0]);
868        assert!(log.contains("fork"), "nothing was refused: {log}");
869    }
870
871    #[test]
872    fn the_policy_names_the_path() {
873        let (dir, store, files, tree) = setup(&[("a", "")]);
874        let v = Verifier::new(dir.path().join("scratch"));
875        let tools = dir.path().canonicalize().unwrap().join("tools");
876        fs::create_dir_all(&tools).unwrap();
877        let tool = tools.join("the-tool");
878        fs::write(&tool, "#!/bin/sh\necho from-the-tool\n").unwrap();
879        {
880            use std::os::unix::fs::PermissionsExt;
881            fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).unwrap();
882        }
883        let mut c = cfg(None, Some("the-tool; echo \"$PATH\""));
884        let r = v.run(&store, &files, &tree, &c).unwrap();
885        assert!(!log_of(&store, &r[0]).contains("from-the-tool"));
886        let daemon = std::env::var("PATH").unwrap();
887        assert!(
888            log_of(&store, &r[0]).contains(&daemon),
889            "the daemon's by default"
890        );
891        c.path = vec![tools.display().to_string(), "/usr/bin".into()];
892        let r = v.run(&store, &files, &tree, &c).unwrap();
893        let log = log_of(&store, &r[0]);
894        assert!(log.contains("from-the-tool"), "on it, and readable: {log}");
895        assert!(
896            log.ends_with(&format!("\n{}:/usr/bin\n", tools.display())),
897            "{log}"
898        );
899    }
900
901    #[test]
902    fn sandbox_blocks_network_and_outside_writes() {
903        let (dir, store, files, tree) = setup(&[("a", "")]);
904        let v = Verifier::new(dir.path().join("scratch"));
905        if v.sandbox == Sandbox::None {
906            eprintln!("no sandbox on this machine; skipping");
907            return;
908        }
909        let outside = dirs_home().join(format!("lonca-sandbox-probe-{}", std::process::id()));
910        let cmd = format!("touch '{}'", outside.display());
911        let r = v
912            .run(&store, &files, &tree, &cfg(None, Some(&cmd)))
913            .unwrap();
914        assert_eq!(
915            r[0].result,
916            CheckResult::Fail,
917            "write outside the work dir must fail"
918        );
919        assert!(!outside.exists());
920        let r = v
921            .run(
922                &store,
923                &files,
924                &tree,
925                &cfg(None, Some("touch inside.txt && test -f inside.txt")),
926            )
927            .unwrap();
928        assert_eq!(r[0].result, CheckResult::Pass, "{:?}", r[0]);
929        let net = "/usr/bin/curl -sS -m 3 https://example.com >/dev/null";
930        let r = v.run(&store, &files, &tree, &cfg(None, Some(net))).unwrap();
931        assert_ne!(r[0].result, CheckResult::Pass, "network must be blocked");
932        assert_eq!(r[0].details["sandbox"], v.sandbox.name());
933    }
934
935    /// The code a change brings runs here, so what it can reach matters as
936    /// much as in `lonca run`.
937    #[test]
938    fn proposed_code_is_held_like_the_agent_that_wrote_it() {
939        let (dir, store, files, tree) = setup(&[("a", "")]);
940        let v = Verifier::new(dir.path().join("store/scratch"));
941        if v.sandbox == Sandbox::None {
942            return;
943        }
944        // Something real in the home directory, not on the way to a directory
945        // on PATH: those are mounted, so the home itself can be listed, and
946        // shows nothing else. This test must not write there.
947        let path: Vec<PathBuf> =
948            std::env::split_paths(&std::env::var_os("PATH").unwrap()).collect();
949        let held = fs::read_dir(dirs_home())
950            .unwrap()
951            .flatten()
952            .map(|e| e.path());
953        let held = held
954            .into_iter()
955            .find(|p| !path.iter().any(|d| d.starts_with(p)));
956        if let Some(held) = held {
957            let probe = format!("test -e '{}'", held.display());
958            let mut c = cfg(None, Some(&probe));
959            c.env_passthrough = vec!["HOME".into()];
960            let r = v.run(&store, &files, &tree, &c).unwrap();
961            assert_eq!(
962                r[0].result,
963                CheckResult::Fail,
964                "{} stays closed",
965                held.display()
966            );
967            let shown = format!("ls -A '{}'", dirs_home().display());
968            let mut c = cfg(None, Some(&shown));
969            c.env_passthrough = vec!["HOME".into()];
970            let r = v.run(&store, &files, &tree, &c).unwrap();
971            let log =
972                String::from_utf8(store.read_blob(r[0].log.as_ref().unwrap()).unwrap()).unwrap();
973            let name = held.file_name().unwrap().to_string_lossy();
974            assert!(!log.lines().any(|l| l == name), "{name} is listed: {log}");
975        }
976        let store_file = dir.path().join("store/key");
977        fs::create_dir_all(store_file.parent().unwrap()).unwrap();
978        fs::write(&store_file, "secret").unwrap();
979        let probe = format!("cat {}", store_file.display());
980        let mut c = cfg(None, Some(&probe));
981        c.read = vec![dir.path().join("store").display().to_string()];
982        let r = v.run(&store, &files, &tree, &c).unwrap();
983        assert_eq!(r[0].result, CheckResult::Fail, "the store stays closed");
984        let r = v
985            .run(
986                &store,
987                &files,
988                &tree,
989                &cfg(None, Some("echo x > \"$TMPDIR/t\"")),
990            )
991            .unwrap();
992        assert_eq!(r[0].result, CheckResult::Pass, "{:?}", r[0]);
993    }
994
995    #[test]
996    fn the_policy_opens_a_tree_for_reading() {
997        let (dir, store, files, tree) = setup(&[("a", "")]);
998        let v = Verifier::new(dir.path().join("store/scratch"));
999        if v.sandbox == Sandbox::None {
1000            return;
1001        }
1002        let tool = dir.path().join("tool");
1003        fs::create_dir_all(&tool).unwrap();
1004        fs::write(tool.join("config"), "x").unwrap();
1005        let probe = format!("cat {}/config", tool.display());
1006        let mut c = cfg(None, Some(&probe));
1007        let r = v.run(&store, &files, &tree, &c).unwrap();
1008        assert_eq!(r[0].result, CheckResult::Fail, "closed until named");
1009        c.read = vec![tool.display().to_string()];
1010        let r = v.run(&store, &files, &tree, &c).unwrap();
1011        assert_eq!(r[0].result, CheckResult::Pass, "{:?}", r[0]);
1012    }
1013
1014    #[test]
1015    fn what_is_opened_is_named_exists_and_stays_off_what_is_closed() {
1016        let dir = tempfile::tempdir().unwrap();
1017        let root = dir.path().canonicalize().unwrap();
1018        let home = root.join("home");
1019        let cargo = home.join(".cargo");
1020        let store = root.join("store");
1021        fs::create_dir_all(&cargo).unwrap();
1022        fs::create_dir_all(store.join("sub")).unwrap();
1023        let c = Verification {
1024            read: vec![
1025                "~/.cargo".into(),
1026                store.display().to_string(),
1027                store.join("sub").display().to_string(),
1028                root.display().to_string(),
1029                home.display().to_string(),
1030                "/no/such/tree".into(),
1031                cargo.display().to_string(),
1032            ],
1033            ..Default::default()
1034        };
1035        let closed = [store.clone()];
1036        let got = opened(&c, &[], Some(&home), &closed, Some(&home));
1037        assert_eq!(got, std::slice::from_ref(&cargo));
1038        // With no home known, `~` means nothing.
1039        assert!(
1040            opened(&c, &[], None, &closed, None)
1041                .iter()
1042                .all(|p| p != &root.join(".cargo"))
1043        );
1044    }
1045
1046    #[test]
1047    fn a_value_passed_opens_only_if_it_is_an_absolute_directory() {
1048        let dir = tempfile::tempdir().unwrap();
1049        let root = dir.path().canonicalize().unwrap();
1050        let file = root.join("file");
1051        fs::write(&file, "x").unwrap();
1052        let c = Verification::default();
1053        // `src` is a directory where the tests run, and only there.
1054        let passed = [root.clone(), file, PathBuf::from("src")];
1055        assert_eq!(opened(&c, &passed, None, &[], None), [root]);
1056    }
1057
1058    #[test]
1059    fn a_jail_closes_what_does_not_hold_the_tree_and_keeps_every_read_off_the_rest() {
1060        let dir = tempfile::tempdir().unwrap();
1061        let root = dir.path().canonicalize().unwrap();
1062        let store = root.join("store");
1063        let tree = store.join("scratch/verify/tree");
1064        let keys = root.join("keys");
1065        let home = root.join("home");
1066        let bin = home.join(".cargo/bin");
1067        for d in [&tree, &keys, &bin] {
1068            fs::create_dir_all(d).unwrap();
1069        }
1070        let closed = Closed {
1071            secrets: vec![keys.clone(), store.clone()],
1072            store: Some(store.clone()),
1073            home: Some(home.clone()),
1074        };
1075        let path = std::env::join_paths([&bin, &home, &store]).unwrap();
1076        let c = Verification {
1077            read: vec![keys.display().to_string(), store.display().to_string()],
1078            ..Default::default()
1079        };
1080        let writable = vec![store.join("w")];
1081        let passed = std::slice::from_ref(&home);
1082        let j = held_jail(&tree, writable.clone(), &closed, &path, passed, &c);
1083        assert_eq!(j.workspace, tree);
1084        assert_eq!(j.writable, writable);
1085        assert_eq!(
1086            j.secrets,
1087            [keys],
1088            "the store holds the tree: closing it closes that"
1089        );
1090        assert_eq!(
1091            j.readable,
1092            [bin],
1093            "not the home, not the store, not the keys"
1094        );
1095        assert_eq!(j.socket, None);
1096        assert_eq!(j.proxy_port, None);
1097        assert_eq!(j.relay, None);
1098        assert!(!j.loopback);
1099    }
1100
1101    /// The names a policy could never set still must not pass, and `TMPDIR`
1102    /// stays the sandbox's when a policy lists it.
1103    #[test]
1104    #[allow(unsafe_code, reason = "a test sets the environment the verifier reads")]
1105    fn what_passes_stops_at_the_forbidden_and_at_tmpdir() {
1106        let (dir, store, files, tree) = setup(&[("a", "")]);
1107        let v = Verifier::new(dir.path().join("scratch"));
1108        let outer = std::env::temp_dir();
1109        // SAFETY: the value set is the one in force, or a name nothing reads.
1110        unsafe {
1111            std::env::set_var("TMPDIR", &outer);
1112            std::env::set_var("CARGO_BUILD_RUSTC", "/no/such/rustc");
1113        }
1114        let probe = format!(
1115            "test -z \"$CARGO_BUILD_RUSTC\" && test \"$TMPDIR\" != '{}'",
1116            outer.display()
1117        );
1118        let mut c = cfg(None, Some(&probe));
1119        c.env_passthrough = vec!["CARGO_BUILD_RUSTC".into(), "TMPDIR".into()];
1120        let r = v.run(&store, &files, &tree, &c).unwrap();
1121        assert_eq!(r[0].result, CheckResult::Pass, "{:?}", r[0]);
1122    }
1123
1124    fn dirs_home() -> PathBuf {
1125        PathBuf::from(std::env::var("HOME").unwrap())
1126    }
1127
1128    /// Only listed names reach the command; fixed values too; forbidden
1129    /// names never, even if a policy object carries them; the log says
1130    /// which names were set and never a value.
1131    #[test]
1132    #[allow(unsafe_code, reason = "a test sets the environment the verifier reads")]
1133    fn the_environment_is_deny_by_default_and_named_in_the_log() {
1134        let (dir, store, files, tree) = setup(&[("a", "")]);
1135        let v = Verifier::new(dir.path().join("scratch"));
1136        // SAFETY: tests in this module that read the environment run
1137        // single-threaded with respect to this variable.
1138        unsafe { std::env::set_var("PROBE_FOR_VERIFY_TEST", "s3cr3t-value") };
1139        let probe = "test -n \"$PROBE_FOR_VERIFY_TEST\" && test \"$FIXED\" = 42";
1140        let r = v
1141            .run(&store, &files, &tree, &cfg(None, Some(probe)))
1142            .unwrap();
1143        assert_eq!(r[0].result, CheckResult::Fail, "nothing passes unasked");
1144        let log = String::from_utf8(store.read_blob(r[0].log.as_ref().unwrap()).unwrap()).unwrap();
1145        assert!(log.starts_with("lonca verify: sandbox "), "{log}");
1146        assert!(
1147            log.contains("env PATH HOME TMPDIR LANG LONCA_VERIFY\n"),
1148            "{log}"
1149        );
1150
1151        let mut c = cfg(None, Some(probe));
1152        c.env_passthrough = vec![
1153            "PROBE_FOR_VERIFY_TEST".into(),
1154            "PROBE_ABSENT_FOR_VERIFY_TEST".into(),
1155        ];
1156        c.env.insert("FIXED".into(), "42".into());
1157        let r = v.run(&store, &files, &tree, &c).unwrap();
1158        assert_eq!(r[0].result, CheckResult::Pass, "{:?}", r[0]);
1159        let log = String::from_utf8(store.read_blob(r[0].log.as_ref().unwrap()).unwrap()).unwrap();
1160        assert!(
1161            log.contains("LONCA_VERIFY PROBE_FOR_VERIFY_TEST FIXED=\n"),
1162            "{log}"
1163        );
1164        assert!(!log.contains("s3cr3t") && !log.contains("42"), "{log}");
1165
1166        // A forbidden name in a config object that skipped the parser.
1167        let mut c = cfg(
1168            None,
1169            Some("test -z \"$LD_PRELOAD\" && test -z \"$RUSTFLAGS\""),
1170        );
1171        c.env_passthrough = vec!["LD_PRELOAD".into()];
1172        c.env.insert("RUSTFLAGS".into(), "-C x".into());
1173        let r = v.run(&store, &files, &tree, &c).unwrap();
1174        assert_eq!(r[0].result, CheckResult::Pass, "{:?}", r[0]);
1175    }
1176
1177    /// A Rust crate under rustup verifies with the passthrough list from
1178    /// the guide and nothing else. Without a sandbox: the point is the
1179    /// environment, and the sandbox has its own test.
1180    #[test]
1181    fn a_rust_crate_verifies_under_rustup_with_the_documented_passthrough() {
1182        if std::process::Command::new("cargo")
1183            .arg("--version")
1184            .output()
1185            .map(|o| !o.status.success())
1186            .unwrap_or(true)
1187        {
1188            eprintln!("no cargo; skipping");
1189            return;
1190        }
1191        let (dir, store, files, tree) = setup(&[
1192            (
1193                "Cargo.toml",
1194                "[package]\nname = \"probe\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n",
1195            ),
1196            (
1197                "src/lib.rs",
1198                "pub fn two() -> u32 { 2 }\n#[cfg(test)] mod t { #[test] fn ok() { assert_eq!(super::two(), 2); } }\n",
1199            ),
1200        ]);
1201        let v = Verifier {
1202            sandbox: Sandbox::None,
1203            scratch: dir.path().join("scratch"),
1204        };
1205        let bare = v
1206            .run(
1207                &store,
1208                &files,
1209                &tree,
1210                &cfg(Some("cargo build --offline"), None),
1211            )
1212            .unwrap();
1213        let mut c = cfg(Some("cargo build --offline"), Some("cargo test --offline"));
1214        c.timeout_seconds = 600;
1215        c.env_passthrough = ["HOME", "CARGO_HOME", "RUSTUP_HOME", "RUSTUP_TOOLCHAIN"]
1216            .into_iter()
1217            .map(String::from)
1218            .collect();
1219        c.read = vec!["~/.cargo".into(), "~/.rustup".into()];
1220        let r = v.run(&store, &files, &tree, &c).unwrap();
1221        assert_eq!(
1222            (r[0].result, r[1].result),
1223            (CheckResult::Pass, CheckResult::Pass),
1224            "{:?}",
1225            r
1226        );
1227        if bare[0].result != CheckResult::Pass {
1228            // Under rustup, the bare run is the failure the guide explains.
1229            let log =
1230                String::from_utf8(store.read_blob(bare[0].log.as_ref().unwrap()).unwrap()).unwrap();
1231            assert!(
1232                log.contains("env PATH HOME TMPDIR LANG LONCA_VERIFY\n"),
1233                "{log}"
1234            );
1235        }
1236    }
1237
1238    #[test]
1239    fn syntax_check() {
1240        let (dir, store, files, tree) = setup(&[
1241            ("a.rs", "fn ok() {}\n"),
1242            ("b.rs", "fn bad( {\n"),
1243            ("c.md", "x"),
1244        ]);
1245        let v = Verifier::new(dir.path().join("scratch"));
1246        let a = v
1247            .syntax(&store, &files, &["a.rs".into(), "c.md".into()], &tree)
1248            .unwrap();
1249        assert_eq!(a.result, CheckResult::Pass);
1250        let a = v
1251            .syntax(&store, &files, &["a.rs".into(), "b.rs".into()], &tree)
1252            .unwrap();
1253        assert_eq!(a.result, CheckResult::Fail);
1254        assert_eq!(a.details["errors"], "b.rs");
1255    }
1256
1257    /// A file past the limit goes by its lines, whatever a grammar would say.
1258    #[test]
1259    fn syntax_leaves_a_file_past_the_limit_to_its_lines() {
1260        let fill = |len: usize| format!("fn bad( {{\n{}\n", "/".repeat(len - 11));
1261        let (at, past) = (fill(MAX_READ), fill(MAX_READ + 1));
1262        let (dir, store, files, tree) =
1263            setup(&[("at.rs", at.as_str()), ("past.rs", past.as_str())]);
1264        let v = Verifier::new(dir.path().join("scratch"));
1265        let a = v.syntax(&store, &files, &["past.rs".into()], &tree);
1266        assert_eq!(a.unwrap().result, CheckResult::Pass);
1267        let a = v.syntax(&store, &files, &["at.rs".into()], &tree);
1268        assert_eq!(a.unwrap().result, CheckResult::Fail);
1269    }
1270
1271    /// So does a file the grammar does not get through within its budget.
1272    #[test]
1273    fn syntax_leaves_a_file_past_the_parse_budget_to_its_lines() {
1274        let filler = |n: usize| format!("fn f() {{ {} }}\n", "-!-a;".repeat(n));
1275        let (within, past) = (format!("fn bad( {{\n{}", filler(1)), filler(30_000));
1276        let past = format!("fn bad( {{\n{past}");
1277        let (dir, store, files, tree) =
1278            setup(&[("within.rs", within.as_str()), ("past.rs", past.as_str())]);
1279        let v = Verifier::new(dir.path().join("scratch"));
1280        let a = v.syntax(&store, &files, &["past.rs".into()], &tree);
1281        assert_eq!(a.unwrap().result, CheckResult::Pass);
1282        let a = v.syntax(&store, &files, &["within.rs".into()], &tree);
1283        assert_eq!(a.unwrap().result, CheckResult::Fail);
1284    }
1285
1286    /// A grammar that can not read a file says nothing about the file.
1287    #[test]
1288    fn syntax_passes_what_a_lenient_grammar_can_not_read() {
1289        let unread = "class C { fun f() = 1 }\n";
1290        let (dir, store, files, tree) = setup(&[("a.kt", unread)]);
1291        assert!(parse(Lang::detect("a.kt").unwrap(), unread).has_error);
1292        let v = Verifier::new(dir.path().join("scratch"));
1293        let a = v.syntax(&store, &files, &["a.kt".into()], &tree).unwrap();
1294        assert_eq!(a.result, CheckResult::Pass);
1295    }
1296}