#![cfg(feature = "execution")]
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const BIN: &str = env!("CARGO_BIN_EXE_roteiro");
struct Fixture {
repo: PathBuf,
}
impl Fixture {
fn new(name: &str) -> Self {
let repo = std::env::temp_dir().join(format!("roteiro-lint-{}-{name}", std::process::id()));
std::fs::remove_dir_all(&repo).ok();
std::fs::create_dir_all(repo.join("src")).expect("mkdir");
std::fs::create_dir_all(repo.join(".home")).expect("mkdir");
git(&repo, &["init", "-q"]);
git(&repo, &["config", "user.email", "test@example.invalid"]);
git(&repo, &["config", "user.name", "Test"]);
std::fs::write(
repo.join("Cargo.toml"),
"[package]\nname = \"lintfix\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\
\n[features]\nextra = []\n",
)
.expect("write");
std::fs::write(
repo.join("src/lib.rs"),
"//! A crate with a lint in it.\npub fn count(v: &Vec<i32>) -> usize {\n v.len()\n}\n",
)
.expect("write");
let lock = Command::new("cargo")
.args(["generate-lockfile"])
.current_dir(&repo)
.output()
.expect("run cargo");
assert!(lock.status.success(), "generate-lockfile failed: {lock:?}");
std::fs::write(
repo.join("audit.json"),
std::fs::read(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../rto-exec/tests/fixtures/native/cargo-audit.json"),
)
.expect("read the cargo-audit capture"),
)
.expect("write");
git(&repo, &["add", "-A"]);
git(&repo, &["commit", "-qm", "init"]);
let fixture = Self { repo };
let ingest = fixture.roteiro(
&[
"security",
"ingest",
"audit.json",
"--analyzer",
"cargo-audit",
"--json",
],
&[],
);
assert!(ingest.status.success(), "ingest failed: {ingest:?}");
fixture
}
fn project_layer(&self, allow_unsandboxed: Option<bool>) {
write_layer(&self.repo.join("roteiro.toml"), allow_unsandboxed);
}
fn user_layer(&self, allow_unsandboxed: Option<bool>) {
let dir = self.repo.join(".home");
std::fs::create_dir_all(&dir).expect("mkdir");
write_layer(&dir.join("config.toml"), allow_unsandboxed);
}
fn built_anything(&self) -> bool {
self.repo.join("target").exists()
}
fn roteiro(&self, args: &[&str], env: &[(&str, &Path)]) -> std::process::Output {
let mut command = Command::new(BIN);
command
.args(args)
.current_dir(&self.repo)
.env("ROTEIRO_HOME", self.repo.join(".home"))
.env("HOME", self.repo.join(".home"))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for key in ["CARGO_HOME", "RUSTUP_HOME"] {
if let Some(value) = std::env::var_os(key) {
command.env(key, value);
}
}
command.env_remove("CARGO_TARGET_DIR");
for (key, value) in env {
command.env(key, value);
}
command.output().expect("run roteiro")
}
fn tree_snapshot(&self) -> Vec<String> {
fn digest(path: &Path) -> String {
use std::hash::{Hash, Hasher};
match std::fs::read(path) {
Ok(bytes) => {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
Err(err) => format!("unreadable: {}", err.kind()),
}
}
fn walk(dir: &Path, base: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.file_name().is_some_and(|n| n == ".git") {
continue;
}
let rel = path
.strip_prefix(base)
.unwrap_or(&path)
.display()
.to_string();
if path.is_dir() {
out.push(format!("{rel}/"));
walk(&path, base, out);
} else {
out.push(format!("{rel} {}", digest(&path)));
}
}
}
let mut out = Vec::new();
walk(&self.repo, &self.repo, &mut out);
out.sort();
out
}
fn findings_listing(&self) -> Vec<u8> {
let out = self.roteiro(&["security", "list", "--json"], &[]);
assert!(out.status.success(), "list failed: {out:?}");
out.stdout
}
}
impl Drop for Fixture {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.repo).ok();
}
}
fn write_layer(path: &Path, allow_unsandboxed: Option<bool>) {
match allow_unsandboxed {
Some(value) => {
std::fs::write(path, format!("[lint]\nallow_unsandboxed = {value}\n")).expect("write");
}
None => {
std::fs::remove_file(path).ok();
}
}
}
fn git(repo: &Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.expect("run git");
assert!(out.status.success(), "git {args:?} failed: {out:?}");
}
fn linter_is_absent(stderr: &str) -> bool {
stderr.contains("rustup component add clippy") || stderr.contains("not found on PATH")
}
#[test]
fn a_lint_run_writes_nothing_into_the_tree_it_is_linting() {
let fixture = Fixture::new("read-only-source");
let state = std::env::temp_dir().join(format!("roteiro-lint-state-{}", std::process::id()));
std::fs::remove_dir_all(&state).ok();
std::fs::create_dir_all(&state).expect("mkdir");
let before = fixture.tree_snapshot();
let names = |snapshot: &[String]| -> Vec<String> {
snapshot
.iter()
.map(|e| e.split(" ").next().unwrap_or(e).to_owned())
.collect()
};
assert!(
names(&before).contains(&"Cargo.lock".to_owned())
&& names(&before).contains(&"src/".to_owned()),
"the snapshot must actually see the tree: {before:?}"
);
assert!(
before.iter().any(|e| e.starts_with("Cargo.lock ")
&& e.split(" ").nth(1).is_some_and(|d| d.len() == 16)),
"the snapshot records names without contents, so an in-place edit would \
leave it identical: {before:?}"
);
let lint = fixture.roteiro(
&["lint", "clippy", "--allow-unsandboxed", "--json"],
&[("ROTEIRO_HOME", &state)],
);
let stderr = String::from_utf8_lossy(&lint.stderr).into_owned();
if !lint.status.success() {
assert!(
linter_is_absent(&stderr),
"lint failed unexpectedly: {stderr}"
);
std::fs::remove_dir_all(&state).ok();
return;
}
let report: serde_json::Value = serde_json::from_slice(&lint.stdout).expect("lint emits JSON");
assert!(
!fixture.built_anything(),
"`target/` was written into the tree being linted"
);
assert_eq!(
before,
fixture.tree_snapshot(),
"`roteiro lint` wrote into the tree it was reporting on"
);
assert_eq!(report["build_succeeded"], true, "{report}");
assert!(
report["counts"]["reported"].as_u64().expect("a count") > 0,
"the fixture crate has a lint in it; a run reporting none did not compile it: {report}"
);
let scratch = PathBuf::from(report["scratch"].as_str().expect("a scratch path"));
assert!(
scratch.starts_with(&state),
"the build directory {} is not under the state root {}",
scratch.display(),
state.display()
);
assert!(
!scratch.starts_with(&fixture.repo),
"the build directory is inside the worktree: {}",
scratch.display()
);
assert!(
scratch.join("debug").exists(),
"nothing was built into {} — the build went somewhere this test cannot see",
scratch.display()
);
std::fs::remove_dir_all(&state).ok();
}
#[test]
fn a_stale_lockfile_is_refused_and_the_tree_is_left_alone() {
let fixture = Fixture::new("stale-lockfile");
std::fs::write(
fixture.repo.join("Cargo.toml"),
"[package]\nname = \"lintfix\"\nversion = \"0.0.1\"\nedition = \"2021\"\n\
\n[features]\nextra = []\n",
)
.expect("write");
let before = fixture.tree_snapshot();
let lint = fixture.roteiro(&["lint", "clippy", "--allow-unsandboxed"], &[]);
let stderr = String::from_utf8_lossy(&lint.stderr).into_owned();
if linter_is_absent(&stderr) {
return;
}
assert_eq!(
before,
fixture.tree_snapshot(),
"`roteiro lint` wrote into the tree it was reporting on"
);
assert!(
!lint.status.success(),
"a stale lockfile must refuse: {lint:?}"
);
assert!(
stderr.contains("--locked") && stderr.contains("generate-lockfile"),
"the refusal must name the flag and the remedy: {stderr}"
);
}
#[test]
fn a_lint_run_leaves_the_findings_tables_byte_identical() {
let fixture = Fixture::new("nothing-stored");
let before = fixture.findings_listing();
assert!(
String::from_utf8_lossy(&before).contains("cargo-audit"),
"the control must be non-empty, or this test cannot fail"
);
let lint = fixture.roteiro(&["lint", "clippy", "--allow-unsandboxed", "--json"], &[]);
let after = fixture.findings_listing();
assert_eq!(
String::from_utf8_lossy(&before),
String::from_utf8_lossy(&after),
"`roteiro lint` must leave the findings store byte-identical"
);
let stderr = String::from_utf8_lossy(&lint.stderr).into_owned();
if !lint.status.success() {
assert!(
linter_is_absent(&stderr),
"lint failed unexpectedly: {stderr}"
);
return;
}
let report: serde_json::Value = serde_json::from_slice(&lint.stdout).expect("lint emits JSON");
assert_eq!(
report["stored"], false,
"the report must say it stored nothing"
);
assert_eq!(report["analyzer"], "clippy");
assert_eq!(
report["counts"]["reported"].as_u64().expect("a count"),
report["findings"].as_array().expect("findings").len() as u64
);
assert!(
!String::from_utf8_lossy(&after).contains("clippy"),
"a clippy layer appeared in the store: {}",
String::from_utf8_lossy(&after)
);
}
#[test]
fn the_report_says_what_produced_it() {
let fixture = Fixture::new("evidence");
let lint = fixture.roteiro(
&[
"lint",
"clippy",
"--allow-unsandboxed",
"--all-features",
"--json",
],
&[],
);
let stderr = String::from_utf8_lossy(&lint.stderr).into_owned();
if !lint.status.success() {
assert!(
linter_is_absent(&stderr),
"lint failed unexpectedly: {stderr}"
);
return;
}
let report: serde_json::Value = serde_json::from_slice(&lint.stdout).expect("lint emits JSON");
assert!(
report["analyzer_version"]
.as_str()
.is_some_and(|v| !v.is_empty()),
"the linter's version"
);
for field in ["linter", "rustc", "host"] {
assert!(
report["toolchain"][field]
.as_str()
.is_some_and(|v| !v.is_empty()),
"the toolchain must name its {field}: {report}"
);
}
assert!(
report["features"]
.as_str()
.expect("a feature set")
.contains("all-features"),
"the feature set actually used must be named: {report}"
);
assert_eq!(
report["isolation"], "none",
"and the boundary it really had"
);
assert!(
report["build_succeeded"].is_boolean(),
"the report must say whether the build completed: {report}"
);
let command: Vec<String> = serde_json::from_value(report["command"].clone()).expect("argv");
assert_eq!(command.first().map(String::as_str), Some("cargo"));
assert!(
command.contains(&"--all-features".to_owned()),
"{command:?}"
);
assert_eq!(
report["caveats"].as_array().map(Vec::len),
Some(4),
"{report}"
);
assert!(
report.get("image").is_none(),
"a host run must not name an image it did not have: {report}"
);
assert!(
!command.contains(&"--offline".to_owned()),
"the host path is unchanged by conditions 1-2: {command:?}"
);
}
#[test]
fn a_missing_toolchain_is_an_error_that_names_what_to_install() {
let fixture = Fixture::new("no-toolchain");
let before = fixture.findings_listing();
let empty = fixture.repo.join(".empty-path");
std::fs::create_dir_all(&empty).expect("mkdir");
let lint = fixture.roteiro(
&["lint", "clippy", "--allow-unsandboxed"],
&[("PATH", empty.as_path())],
);
assert!(
!lint.status.success(),
"a missing toolchain must fail: {lint:?}"
);
let stderr = String::from_utf8_lossy(&lint.stderr);
assert!(stderr.contains("not found on PATH"), "{stderr}");
assert!(stderr.contains("https://rustup.rs"), "{stderr}");
assert!(
stderr.contains("must never read as a clean tree"),
"the refusal must say why it is not a result: {stderr}"
);
assert!(
!String::from_utf8_lossy(&lint.stdout).contains("diagnostic(s)"),
"nothing may be reported: {}",
String::from_utf8_lossy(&lint.stdout)
);
assert_eq!(
String::from_utf8_lossy(&before),
String::from_utf8_lossy(&fixture.findings_listing()),
"a failed lint must leave the store byte-identical too"
);
}
#[test]
fn a_storing_analyzer_is_refused_and_the_disclosure_does_not_lie_about_it() {
let fixture = Fixture::new("wrong-analyzer");
let out = fixture.roteiro(&["lint", "semgrep", "--allow-unsandboxed"], &[]);
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("roteiro security run"), "{stderr}");
assert!(
!stderr.contains("running semgrep"),
"no run was disclosed for an analyzer that cannot run: {stderr}"
);
}
#[test]
fn by_default_it_selects_the_sandbox_and_never_falls_back_to_the_host() {
let fixture = Fixture::new("default-refuses");
let before = fixture.findings_listing();
assert!(!fixture.built_anything(), "the fixture starts unbuilt");
let out = fixture.roteiro(&["lint", "clippy"], &[]);
assert!(
!out.status.success(),
"a sandbox that cannot be had must refuse: {out:?}"
);
assert!(
!fixture.built_anything(),
"a refusal compiled the tree — nothing may fall back to the host"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("sandboxed"), "{stderr}");
assert!(
stderr.contains("nothing fell back to this host"),
"the one promise this refusal exists to keep: {stderr}"
);
assert!(stderr.contains("--allow-unsandboxed"), "{stderr}");
assert!(stderr.contains("allow_unsandboxed = true"), "{stderr}");
assert!(stderr.contains("~/.roteiro/config.toml"), "{stderr}");
assert!(
stderr.contains("cannot grant"),
"and that the committed file is not the place: {stderr}"
);
assert!(
stderr.contains("do not need both"),
"and that either remedy suffices — otherwise the config key reads as a \
second step and nobody stops typing the flag: {stderr}"
);
assert_missing_thing_is_named(&stderr);
assert!(
String::from_utf8_lossy(&out.stdout).trim().is_empty(),
"a refusal reports nothing at all"
);
assert_eq!(
String::from_utf8_lossy(&before),
String::from_utf8_lossy(&fixture.findings_listing()),
"and it still writes nothing to the store"
);
}
#[cfg(feature = "exec-boxlite")]
fn assert_missing_thing_is_named(stderr: &str) {
assert!(stderr.contains("No image is configured"), "{stderr}");
assert!(stderr.contains("[lint]"), "{stderr}");
assert!(stderr.contains("@sha256:"), "{stderr}");
assert!(stderr.contains("docs/SANDBOXED_LINTING.md"), "{stderr}");
}
#[cfg(not(feature = "exec-boxlite"))]
fn assert_missing_thing_is_named(stderr: &str) {
assert!(stderr.contains("exec-boxlite"), "{stderr}");
assert!(
stderr.contains("no sandboxed backend at all"),
"a feature that is not compiled in is not a backend that failed: {stderr}"
);
assert!(
stderr.contains("roteiro security prefetch --analyzer sandbox --allow-download"),
"the bootstrap order matters and must be spelled out: {stderr}"
);
assert!(
stderr.contains("cargo install roteiro --features exec-boxlite"),
"{stderr}"
);
assert!(
stderr.contains("roteiro security ingest"),
"and the path that needs no sandbox at all: {stderr}"
);
}
#[cfg(feature = "exec-boxlite")]
#[test]
fn an_image_pinned_by_tag_is_refused_and_says_how_to_pin_it() {
let fixture = Fixture::new("tagged-image");
let out = fixture.roteiro(
&["lint", "clippy", "--image", "docker.io/you/rust:1.97.1"],
&[],
);
assert!(!out.status.success(), "a tag must be refused: {out:?}");
assert!(!fixture.built_anything(), "and nothing may have been built");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("tag rather than a digest"), "{stderr}");
assert!(
stderr.contains("@sha256:"),
"the refusal must show the shape it wants: {stderr}"
);
assert!(
stderr.contains("imagetools inspect"),
"and how to obtain it: {stderr}"
);
}
#[cfg(not(feature = "exec-boxlite"))]
#[test]
fn a_build_without_the_sandbox_refuses_every_input_by_naming_the_feature() {
let fixture = Fixture::new("no-backend");
let refusal = |args: &[&str]| {
let out = fixture.roteiro(args, &[]);
assert!(!out.status.success(), "{args:?} must be refused: {out:?}");
assert!(!fixture.built_anything(), "{args:?} built something");
String::from_utf8_lossy(&out.stderr).into_owned()
};
let no_image = refusal(&["lint", "clippy"]);
let tagged = refusal(&["lint", "clippy", "--image", "docker.io/you/rust:1.97.1"]);
let pinned = refusal(&[
"lint",
"clippy",
"--image",
"docker.io/you/rust-clippy@sha256:0000000000000000000000000000000000000000000000000000000000000000",
]);
assert_eq!(
no_image, tagged,
"a tag cannot be judged by a build with nothing to run it in"
);
assert_eq!(no_image, pinned, "nor can a digest");
assert_missing_thing_is_named(&no_image);
assert!(
!no_image.contains("tag rather than a digest"),
"no image was examined, so nothing may claim one was: {no_image}"
);
}
#[cfg(feature = "exec-boxlite")]
#[test]
fn an_unprovisioned_image_refuses_and_names_the_prefetch_that_obtains_it() {
let fixture = Fixture::new("unprovisioned-image");
let absent = format!("docker.io/you/rust-clippy@sha256:{}", "0".repeat(64));
let out = fixture.roteiro(&["lint", "clippy", "--image", &absent], &[]);
assert!(!out.status.success(), "{out:?}");
assert!(!fixture.built_anything(), "and nothing may have been built");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("nothing fell back to this host"),
"the one promise this refusal exists to keep: {stderr}"
);
match rto_exec::sandbox_probe() {
rto_exec::SandboxProbe::Available => {
assert!(
stderr.contains("roteiro security prefetch --analyzer clippy --allow-download"),
"a run never pulls, so the refusal must name the command that does: {stderr}"
);
assert!(
stderr.contains(&absent),
"and which image it could not find: {stderr}"
);
}
rto_exec::SandboxProbe::Unavailable(why) => {
eprintln!(
"PARTIAL: no microVM on this host, so the run refused before it could \
consult the image store: {why}\n (the never-fell-back invariant was \
still checked; the prefetch sentence needs a host that can start a guest)"
);
assert!(
stderr.contains("no sandbox is available on this host"),
"a host with no hypervisor must say so: {stderr}"
);
}
}
}
#[test]
fn a_committed_project_grant_does_not_enable_host_execution() {
let fixture = Fixture::new("project-grant");
fixture.project_layer(Some(true));
let out = fixture.roteiro(&["lint", "clippy"], &[]);
assert!(
!out.status.success(),
"a committed file may never grant host execution: {out:?}"
);
assert!(!fixture.built_anything(), "and nothing may have been built");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("read and ignored"), "{stderr}");
assert!(stderr.contains("roteiro.toml"), "{stderr}");
}
#[test]
fn a_project_deny_overrides_a_user_grant() {
let fixture = Fixture::new("project-deny");
fixture.user_layer(Some(true));
fixture.project_layer(Some(false));
for args in [
&["lint", "clippy"][..],
&["lint", "clippy", "--allow-unsandboxed"][..],
] {
let out = fixture.roteiro(args, &[]);
assert!(
!out.status.success(),
"{args:?} must be refused by the project layer: {out:?}"
);
assert!(!fixture.built_anything(), "{args:?} built something");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("roteiro.toml"), "{args:?}: {stderr}");
assert!(
!stderr.contains("Pass `--allow-unsandboxed`"),
"{args:?}: a remedy that would not work must not be offered: {stderr}"
);
}
}
#[test]
fn a_user_layer_grant_is_enough_on_its_own() {
let fixture = Fixture::new("user-grant");
fixture.user_layer(Some(true));
let out = fixture.roteiro(&["lint", "clippy", "--json"], &[]);
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
if !out.status.success() {
assert!(
linter_is_absent(&stderr),
"lint failed unexpectedly: {stderr}"
);
return;
}
let report: serde_json::Value = serde_json::from_slice(&out.stdout).expect("lint emits JSON");
assert_eq!(
report["stored"], false,
"a grant changes who chose, not what is kept"
);
assert_eq!(
report["isolation"], "none",
"and it does not upgrade the isolation it reports"
);
}
#[test]
fn asking_for_the_sandbox_refuses_and_never_falls_back_to_the_host() {
let fixture = Fixture::new("sandboxed");
fixture.user_layer(Some(true));
let out = fixture.roteiro(&["lint", "clippy", "--sandboxed"], &[]);
assert!(
!out.status.success(),
"no sandbox exists to honour it: {out:?}"
);
assert!(
!fixture.built_anything(),
"asking for isolation must never produce execution"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--sandboxed"), "{stderr}");
assert!(
stderr.contains("fell back") || stderr.contains("not produce"),
"the refusal must say it did not downgrade: {stderr}"
);
}
#[test]
fn the_reported_value_agrees_with_the_refusal_when_a_project_denies() {
let fixture = Fixture::new("deny-echo");
fixture.project_layer(Some(false));
fixture.user_layer(Some(true));
let refused = fixture.roteiro(&["lint", "clippy"], &[]);
assert!(!refused.status.success(), "the project denied: {refused:?}");
let out = fixture.roteiro(&["config"], &[]);
assert!(out.status.success(), "config failed: {out:?}");
let stdout = String::from_utf8_lossy(&out.stdout);
let line = stdout
.lines()
.find(|l| l.trim_start().starts_with("allow_unsandboxed"))
.unwrap_or_else(|| panic!("no allow_unsandboxed line in:\n{stdout}"));
assert!(
line.contains("allow_unsandboxed = Some(false)"),
"the effective value must echo the denial that actually took effect, not \
the user grant it overruled: {line}"
);
assert!(line.contains("project: Some(false)"), "{line}");
assert!(line.contains("user: Some(true)"), "{line}");
}
#[test]
fn roteiro_config_shows_the_key_and_the_layer_that_decided_it() {
let fixture = Fixture::new("config-report");
fixture.project_layer(Some(true));
fixture.user_layer(Some(false));
let out = fixture.roteiro(&["config"], &[]);
assert!(out.status.success(), "config failed: {out:?}");
let stdout = String::from_utf8_lossy(&out.stdout);
let section: String = stdout
.lines()
.skip_while(|l| !l.starts_with("[lint]"))
.take_while(|l| !l.starts_with("[debt]"))
.collect::<Vec<_>>()
.join("\n");
assert!(!section.is_empty(), "no [lint] section in:\n{stdout}");
assert!(section.contains("allow_unsandboxed"), "{section}");
assert!(section.contains("project: Some(true)"), "{section}");
assert!(section.contains("user: Some(false)"), "{section}");
assert!(
section.contains("may deny, never grant"),
"each layer must be labelled with what it is allowed to do: {section}"
);
assert!(
section.contains("read and ignored"),
"and the discarded project grant must be called out: {section}"
);
assert!(section.contains("sandboxed by default"), "{section}");
}