1use 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 Seatbelt,
34 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 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 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 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 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 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 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 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 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
325fn 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
335fn 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
344struct Closed {
346 secrets: Vec<PathBuf>,
348 store: Option<PathBuf>,
351 home: Option<PathBuf>,
352}
353
354fn 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
388fn 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
436pub 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 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 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 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 #[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 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 #[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 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 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 #[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 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 #[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 #[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 #[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}