use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use rto_graph::{Isolation, SourceIdentity};
use crate::adapter::clippy::{self, Clippy, FeatureSet, Summary};
use crate::adapter::{Invocation, LINT_ANALYZERS, NativeContext, UNKNOWN_VERSION};
use crate::clock::rfc3339_utc;
use crate::guidance::{Guidance, Line};
use crate::ingest::NormalizedReport;
use crate::runner::{ExecError, worktree_id};
use crate::lint_grant::{Backend, Decision, Reason};
use crate::snippet::WorktreeSnippets;
use crate::subprocess::{ChildEnv, SubprocessError, execute, scrub_environment, stderr_tail};
const TOOLCHAIN_ENV: &[&str] = &["CARGO_HOME", "RUSTUP_HOME", "RUSTUP_TOOLCHAIN"];
const NOT_AVAILABLE: &str =
"`roteiro lint` runs the linter sandboxed, and the sandbox is not available here.";
const NEVER_FELL_BACK: Guidance = Guidance::new(&[Line::Note(&[
"Nothing ran, and nothing fell back to this host: asking for isolation and",
"getting execution is the one outcome this command will not produce.",
])]);
const UNKNOWN_TOOL: Guidance = Guidance::new(&[Line::Note(&[
"Roteiro does not install toolchains, and this build knows no install",
"command for that program.",
])]);
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LintError {
#[error("{}{what}{}{}", NOT_AVAILABLE, NEVER_FELL_BACK, .escape.map(|e| e.to_string()).unwrap_or_default())]
SandboxUnavailable {
what: Guidance,
escape: Option<Guidance>,
},
#[cfg(feature = "exec-boxlite")]
#[error("{sandbox}{}", NEVER_FELL_BACK)]
Sandbox {
#[from]
sandbox: crate::lint_sandbox::BuilderError,
},
#[error(
"`{requested}` is not a linter roteiro can run (known: {known}). \
For an analyzer whose findings are stored, use `roteiro security run`."
)]
UnknownAnalyzer {
requested: String,
known: String,
},
#[error(
"`{program}` was not found on PATH, so `{analyzer}` could not be run. Nothing is \
reported, because a missing tool must never read as a clean tree.{}",
.install.map_or(UNKNOWN_TOOL, |hint| hint)
)]
ToolchainMissing {
program: String,
analyzer: String,
install: Option<Guidance>,
},
#[error(
"`{analyzer}` is not installed for this toolchain: `{command}` failed. Install it with \
`{install}`. Nothing is reported, because a missing linter must never read as a clean \
tree.{stderr}"
)]
AnalyzerNotInstalled {
analyzer: String,
command: String,
install: String,
stderr: String,
},
#[error(
"no cargo project found at or above {dir}: `{command}` failed, so there is nothing for \
`{analyzer}` to lint.{stderr}"
)]
NoCargoProject {
dir: String,
command: String,
analyzer: String,
stderr: String,
},
#[error("`{command}` failed while working out what would run.{stderr}")]
ProbeFailed {
command: String,
stderr: String,
},
#[error(transparent)]
Run(#[from] SubprocessError),
#[error(transparent)]
Report(#[from] ExecError),
#[error(
"could not create the scratch build directory {path}: {source}. Nothing ran — the build \
would otherwise have written into the tree being linted, which `roteiro lint` does not do."
)]
ScratchUnavailable {
path: String,
source: std::io::Error,
},
#[error(
"{variable} is set to a relative path ({path}), and `roteiro lint` needs an absolute \
one. A relative build directory is resolved by cargo against the tree being linted, so \
it would put the build inside it. Set {variable} to an absolute path."
)]
ScratchRootNotAbsolute {
variable: &'static str,
path: String,
},
#[error(
"every candidate build directory is inside {root}, which is the tree being linted: \
{candidates}. `roteiro lint` will not build into the tree it is reporting on. Point \
`ROTEIRO_HOME` somewhere outside it."
)]
ScratchWouldBeInsideTheTree {
root: String,
candidates: String,
},
#[error(
"`{command}` needs `Cargo.lock` to be written, and `roteiro lint` passes `--locked` so \
that it is not: a lint must not modify the tree it is reporting on. Run `cargo \
generate-lockfile` (or `cargo check`) yourself and lint again.{stderr}"
)]
LockfileWouldBeWritten {
command: String,
stderr: String,
},
#[error(
"`{command}` exited {status} without completing the build and without a single \
diagnostic, so there is nothing to report — this is not a clean tree.{}{stderr}",
.hint.map(|h| h.to_string()).unwrap_or_default()
)]
BuildProducedNothing {
command: String,
status: i32,
hint: Option<Guidance>,
stderr: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Toolchain {
pub linter: String,
pub rustc: String,
pub host: String,
}
#[derive(Debug, Clone)]
pub struct LintOutcome {
pub analyzer: &'static str,
pub report: NormalizedReport,
pub summary: Summary,
pub toolchain: Toolchain,
pub features: FeatureSet,
pub isolation: Isolation,
pub command: Vec<String>,
pub worktree: PathBuf,
pub image: Option<String>,
pub scratch: PathBuf,
}
#[must_use]
pub fn invocation(analyzer: &str, features: &FeatureSet, backend: Backend) -> Option<Invocation> {
(analyzer == clippy::ANALYZER).then(|| match backend {
Backend::Host => Clippy::invocation(features),
Backend::Sandbox => Clippy::offline_invocation(features),
})
}
pub fn run(
analyzer: &str,
dir: &Path,
features: &FeatureSet,
decision: Decision,
image: Option<&str>,
) -> Result<LintOutcome, LintError> {
if !LINT_ANALYZERS.contains(&analyzer) {
return Err(LintError::UnknownAnalyzer {
requested: analyzer.to_owned(),
known: LINT_ANALYZERS.join(", "),
});
}
let root = workspace_root(dir, analyzer)?;
match decision.backend() {
Backend::Host => run_on_host(analyzer, &root, features),
Backend::Sandbox => run_sandboxed(analyzer, &root, features, image, decision.reason),
}
}
#[cfg(feature = "exec-boxlite")]
fn run_sandboxed(
analyzer: &str,
root: &Path,
features: &FeatureSet,
image: Option<&str>,
reason: Reason,
) -> Result<LintOutcome, LintError> {
let Some(image) = image else {
return Err(LintError::SandboxUnavailable {
what: crate::lint_sandbox::NO_IMAGE_CONFIGURED,
escape: reason.host_escape(),
});
};
crate::lint_sandbox::run(analyzer, root, features, image)
}
#[cfg(not(feature = "exec-boxlite"))]
const NO_SANDBOX_IN_THIS_BUILD: Guidance = Guidance::new(&[
Line::Note(&[
"This build was compiled without the `exec-boxlite` feature, so it contains no",
"sandboxed backend at all — not one that failed, one that is not there.",
]),
Line::Note(&["Provision the runtime first, then rebuild with the feature:"]),
Line::Command("roteiro security prefetch --analyzer sandbox --allow-download"),
Line::Command("cargo install roteiro --features exec-boxlite"),
Line::Note(&[
"That order is not arbitrary: the feature's build script verifies the runtime",
"archive at compile time, so a rebuild without it fails rather than degrades.",
"It also needs `protoc >= 3.12` on the build host.",
]),
Line::Note(&[
"Or ingest a report produced elsewhere, which needs no sandbox at all:",
"`roteiro security ingest`.",
]),
]);
#[cfg(not(feature = "exec-boxlite"))]
fn run_sandboxed(
_analyzer: &str,
_root: &Path,
_features: &FeatureSet,
_image: Option<&str>,
reason: Reason,
) -> Result<LintOutcome, LintError> {
Err(LintError::SandboxUnavailable {
what: NO_SANDBOX_IN_THIS_BUILD,
escape: reason.host_escape(),
})
}
fn run_on_host(
analyzer: &str,
root: &Path,
features: &FeatureSet,
) -> Result<LintOutcome, LintError> {
let scratch = scratch_dir(root, Backend::Host)?;
let set = [(
"CARGO_TARGET_DIR",
std::ffi::OsString::from(scratch.as_os_str()),
)];
let env = ChildEnv {
inherit: TOOLCHAIN_ENV,
set: &set,
};
let toolchain = probe_toolchain(root, analyzer, &env)?;
let invocation = Clippy::invocation(features);
let command = argv(&invocation);
let started_at = rfc3339_utc(std::time::SystemTime::now());
let output = execute(&invocation, root, analyzer, &env)?;
let ended_at = rfc3339_utc(std::time::SystemTime::now());
if lockfile_refused(&output.stderr) {
return Err(LintError::LockfileWouldBeWritten {
command: command.join(" "),
stderr: stderr_tail(&output.stderr),
});
}
let snippets = WorktreeSnippets::new(root);
let source = SourceIdentity::default();
let ctx = NativeContext {
started_at,
ended_at,
analyzer_version: Some(short_version(&toolchain.linter)),
exit_status: output.status,
source: &source,
rules_digest: None,
advisory_db: None,
worktree: Some(root),
snippets: &snippets,
};
let (report, summary) = Clippy::parse(&output.stdout, &ctx)?;
if !summary.build_succeeded && report.findings.is_empty() {
return Err(LintError::BuildProducedNothing {
command: command.join(" "),
status: output.status,
hint: None,
stderr: stderr_tail(&output.stderr),
});
}
Ok(LintOutcome {
analyzer: clippy::ANALYZER,
report,
summary,
toolchain,
features: features.clone(),
isolation: Isolation::None,
command,
worktree: root.to_path_buf(),
image: None,
scratch,
})
}
pub(crate) fn scratch_dir(root: &Path, backend: Backend) -> Result<PathBuf, LintError> {
let dir = scratch_path(
&scratch_roots_from(
std::env::var_os("ROTEIRO_HOME").map(PathBuf::from),
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from),
std::env::temp_dir(),
),
root,
backend,
)?;
std::fs::create_dir_all(&dir).map_err(|source| LintError::ScratchUnavailable {
path: dir.display().to_string(),
source,
})?;
Ok(dir)
}
fn scratch_path(roots: &[Candidate], root: &Path, backend: Backend) -> Result<PathBuf, LintError> {
let id = worktree_id(root).map_err(|source| LintError::ScratchUnavailable {
path: root.display().to_string(),
source: std::io::Error::other(source.to_string()),
})?;
let leaf = match backend {
Backend::Host => id.as_str().to_owned(),
Backend::Sandbox => format!("{}.sandbox", id.as_str()),
};
for candidate in roots {
if !candidate.path.is_absolute() {
return Err(LintError::ScratchRootNotAbsolute {
variable: candidate.variable,
path: candidate.path.display().to_string(),
});
}
if !is_inside(&candidate.path, root) {
return Ok(candidate.path.join(&leaf));
}
}
Err(LintError::ScratchWouldBeInsideTheTree {
root: root.display().to_string(),
candidates: roots
.iter()
.map(|c| format!("{} ({})", c.path.display(), c.variable))
.collect::<Vec<_>>()
.join(", "),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Candidate {
variable: &'static str,
path: PathBuf,
}
fn scratch_roots_from(
roteiro_home: Option<PathBuf>,
home: Option<PathBuf>,
temp: PathBuf,
) -> Vec<Candidate> {
roteiro_home
.map(|dir| ("ROTEIRO_HOME", dir))
.into_iter()
.chain(home.map(|dir| ("HOME", dir.join(".roteiro"))))
.chain(std::iter::once(("TMPDIR", temp)))
.map(|(variable, dir)| Candidate {
variable,
path: dir.join("lint").join("target"),
})
.collect()
}
fn is_inside(candidate: &Path, root: &Path) -> bool {
let candidate = resolved(candidate);
std::iter::once(resolved(root))
.chain(std::path::absolute(root).ok())
.any(|spelling| candidate.starts_with(&spelling))
}
fn resolved(path: &Path) -> PathBuf {
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let mut suffix = PathBuf::new();
let mut here = absolute.as_path();
loop {
if let Ok(real) = here.canonicalize() {
return real.join(&suffix);
}
let (Some(name), Some(parent)) = (here.file_name(), here.parent()) else {
return absolute;
};
suffix = Path::new(name).join(&suffix);
here = parent;
}
}
pub(crate) fn lockfile_refused(stderr: &[u8]) -> bool {
String::from_utf8_lossy(stderr).contains("--locked was passed")
}
pub(crate) fn argv(invocation: &Invocation) -> Vec<String> {
let mut command = vec![invocation.program.clone()];
command.extend(invocation.args.iter().cloned());
command
}
fn workspace_root(dir: &Path, analyzer: &str) -> Result<PathBuf, LintError> {
let args = ["locate-project", "--workspace", "--message-format", "plain"];
let probe = probe(
"cargo",
&args,
dir,
analyzer,
&ChildEnv {
inherit: TOOLCHAIN_ENV,
..ChildEnv::default()
},
)?;
let no_project = |why: String| LintError::NoCargoProject {
dir: dir.display().to_string(),
command: rendered("cargo", &args),
analyzer: analyzer.to_owned(),
stderr: why,
};
if !probe.ok {
return Err(no_project(stderr_tail(probe.stderr.as_bytes())));
}
let manifest = probe.stdout.trim();
if manifest.is_empty() {
return Err(no_project(
"\n cargo exited cleanly and named no manifest".to_owned(),
));
}
let Some(root) = Path::new(manifest).parent() else {
return Err(no_project(format!(
"\n cargo answered {manifest:?}, which has no directory"
)));
};
Ok(root.to_path_buf())
}
fn probe_toolchain(
root: &Path,
analyzer: &str,
env: &ChildEnv<'_>,
) -> Result<Toolchain, LintError> {
let clippy_args = ["clippy", "--version"];
let clippy = probe("cargo", &clippy_args, root, analyzer, env)?;
if !clippy.ok || clippy.stdout.trim().is_empty() {
return Err(LintError::AnalyzerNotInstalled {
analyzer: analyzer.to_owned(),
command: rendered("cargo", &clippy_args),
install: crate::adapter::clippy::COMPONENT_ADD.to_owned(),
stderr: stderr_tail(clippy.stderr.as_bytes()),
});
}
let rustc_args = ["-vV"];
let rustc = probe("rustc", &rustc_args, root, analyzer, env)?;
if !rustc.ok {
return Err(LintError::ProbeFailed {
command: rendered("rustc", &rustc_args),
stderr: stderr_tail(rustc.stderr.as_bytes()),
});
}
let (version, host) = parse_rustc_verbose(&rustc.stdout);
Ok(Toolchain {
linter: first_line(&clippy.stdout),
rustc: version,
host,
})
}
struct Probe {
stdout: String,
stderr: String,
ok: bool,
}
fn probe(
program: &str,
args: &[&str],
dir: &Path,
analyzer: &str,
env: &ChildEnv<'_>,
) -> Result<Probe, LintError> {
let mut command = Command::new(program);
command
.args(args)
.current_dir(dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
scrub_environment(&mut command, env);
let output = command.output().map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
LintError::ToolchainMissing {
program: program.to_owned(),
analyzer: analyzer.to_owned(),
install: crate::adapter::install_hint(program),
}
} else {
LintError::ProbeFailed {
command: rendered(program, args),
stderr: format!("\n {source}"),
}
}
})?;
Ok(Probe {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
ok: output.status.success(),
})
}
fn rendered(program: &str, args: &[&str]) -> String {
std::iter::once(program)
.chain(args.iter().copied())
.collect::<Vec<_>>()
.join(" ")
}
pub(crate) fn first_line(text: &str) -> String {
text.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("")
.to_owned()
}
pub(crate) fn short_version(line: &str) -> String {
let mut tokens = line.split_whitespace();
let version = match (tokens.next(), tokens.next()) {
(Some("clippy" | "cargo-clippy"), Some(version)) => version,
_ => line.trim(),
};
if version.is_empty() {
UNKNOWN_VERSION.to_owned()
} else {
version.to_owned()
}
}
pub(crate) fn parse_rustc_verbose(text: &str) -> (String, String) {
let host = text
.lines()
.find_map(|line| line.strip_prefix("host:"))
.map(str::trim)
.filter(|h| !h.is_empty())
.unwrap_or(UNKNOWN_VERSION)
.to_owned();
let version = first_line(text);
let version = if version.is_empty() {
UNKNOWN_VERSION.to_owned()
} else {
version
};
(version, host)
}
#[cfg(test)]
mod tests {
use super::{
Candidate, Guidance, LINT_ANALYZERS, Line, LintError, TOOLCHAIN_ENV, first_line, is_inside,
lockfile_refused, parse_rustc_verbose, run, scratch_path, scratch_roots_from,
short_version,
};
use crate::adapter::clippy::FeatureSet;
use crate::lint_grant::{Backend, ConfigGrant, Requested, decide};
use std::path::{Path, PathBuf};
fn granted() -> crate::lint_grant::Decision {
let decision = decide(ConfigGrant::default(), Requested::Host);
assert!(decision.granted(), "the fixture must actually grant");
decision
}
#[test]
fn a_selected_sandbox_that_cannot_be_had_refuses_rather_than_running_here() {
let sandboxed = decide(ConfigGrant::default(), Requested::Unset);
assert!(!sandboxed.granted(), "the fixture must select the sandbox");
let err = run(
"clippy",
std::path::Path::new("."),
&FeatureSet::Defaults,
sandboxed,
None,
)
.expect_err("a sandbox that cannot be had must refuse");
assert!(
matches!(err, LintError::SandboxUnavailable { .. }),
"a selected sandbox must never become a host run: {err:?}"
);
}
#[test]
fn no_route_to_the_sandbox_ends_up_on_the_host() {
let routes = [
(ConfigGrant::default(), Requested::Unset),
(ConfigGrant::default(), Requested::Sandbox),
(
ConfigGrant::from_layers(None, Some(false)),
Requested::Unset,
),
(ConfigGrant::from_layers(Some(false), None), Requested::Host),
];
for (config, requested) in routes {
let decision = decide(config, requested);
assert_eq!(decision.backend(), crate::lint_grant::Backend::Sandbox);
let err = run(
"clippy",
std::path::Path::new("."),
&FeatureSet::Defaults,
decision,
None,
)
.expect_err("must refuse");
assert!(
matches!(err, LintError::SandboxUnavailable { .. }),
"{:?} fell out of the sandbox: {err:?}",
decision.reason
);
}
}
#[test]
fn a_refusal_offers_the_host_only_to_someone_who_could_take_it() {
let denied = decide(ConfigGrant::from_layers(Some(false), None), Requested::Host);
let err = run(
"clippy",
std::path::Path::new("."),
&FeatureSet::Defaults,
denied,
None,
)
.expect_err("must refuse")
.to_string();
assert!(
!err.contains("--allow-unsandboxed"),
"a project denial cannot be escaped, so offering the flag wastes the reader's time: \
{err}"
);
let ordinary = decide(ConfigGrant::default(), Requested::Unset);
let err = run(
"clippy",
std::path::Path::new("."),
&FeatureSet::Defaults,
ordinary,
None,
)
.expect_err("must refuse")
.to_string();
assert!(err.contains("--allow-unsandboxed"), "{err}");
}
#[test]
fn refuses_an_analyzer_it_does_not_drive_before_running_anything() {
let err = run(
"semgrep",
std::path::Path::new("/nonexistent"),
&FeatureSet::Defaults,
granted(),
None,
)
.expect_err("must refuse");
assert!(matches!(err, LintError::UnknownAnalyzer { .. }));
let message = err.to_string();
assert!(message.contains("clippy"), "{message}");
assert!(message.contains("roteiro security run"), "{message}");
}
#[test]
fn every_absence_names_what_to_install_and_refuses_to_report() {
let missing_toolchain = LintError::ToolchainMissing {
program: "cargo".to_owned(),
analyzer: "clippy".to_owned(),
install: crate::adapter::install_hint("cargo"),
}
.to_string();
assert!(missing_toolchain.contains("not found on PATH"));
assert!(missing_toolchain.contains("https://rustup.rs"));
assert!(missing_toolchain.contains("must never read as a clean tree"));
assert!(
!missing_toolchain.contains(crate::adapter::clippy::COMPONENT_ADD),
"{missing_toolchain}"
);
let missing_component = LintError::AnalyzerNotInstalled {
analyzer: "clippy".to_owned(),
command: "cargo clippy --version".to_owned(),
install: crate::adapter::clippy::COMPONENT_ADD.to_owned(),
stderr: String::new(),
}
.to_string();
assert!(missing_component.contains("rustup component add clippy"));
assert!(missing_component.contains("must never read as a clean tree"));
let nothing = LintError::BuildProducedNothing {
command: "cargo clippy".to_owned(),
status: 101,
hint: None,
stderr: String::new(),
}
.to_string();
assert!(
nothing.contains("this is not a clean tree"),
"{nothing}: a build that said nothing must not read as zero findings"
);
let hinted = LintError::BuildProducedNothing {
command: "cargo clippy".to_owned(),
status: 101,
hint: Some(Guidance::new(&[Line::Note(&[
"It ran sandboxed, so check the image first.",
])])),
stderr: String::new(),
}
.to_string();
assert!(hinted.contains("ran sandboxed"), "{hinted}");
assert!(
!nothing.contains("sandboxed"),
"a host failure must not be explained by a boundary it did not have: {nothing}"
);
}
#[test]
fn the_disclosed_argv_is_the_one_that_will_run() {
let features = FeatureSet::Defaults;
let host = super::invocation("clippy", &features, Backend::Host).expect("an argv");
let sandbox = super::invocation("clippy", &features, Backend::Sandbox).expect("an argv");
assert!(
!host.args.contains(&"--offline".to_owned()),
"the host may reach a registry, and conditions 1-2 did not change that: {:?}",
host.args
);
assert!(
sandbox.args.contains(&"--offline".to_owned()),
"a guest with no interface must be told so: {:?}",
sandbox.args
);
let without: Vec<&String> = sandbox
.args
.iter()
.filter(|a| a.as_str() != "--offline")
.collect();
assert_eq!(without, host.args.iter().collect::<Vec<_>>());
assert_eq!(sandbox.program, host.program);
assert_eq!(sandbox.success_statuses, host.success_statuses);
for backend in [Backend::Host, Backend::Sandbox] {
assert!(super::invocation("semgrep", &features, backend).is_none());
}
}
#[cfg(feature = "exec-boxlite")]
#[test]
fn every_sandbox_refusal_promises_that_nothing_fell_back_to_the_host() {
use crate::lint_sandbox::BuilderError;
let variants: Vec<LintError> = vec![
BuilderError::ImageNotProvisioned {
analyzer: "clippy".to_owned(),
reference: "x@sha256:0".to_owned(),
}
.into(),
BuilderError::ImageLacksLinter {
analyzer: "clippy".to_owned(),
reference: "x@sha256:0".to_owned(),
probe: "cargo clippy --version".to_owned(),
stderr: String::new(),
}
.into(),
BuilderError::ProbeFailed {
probe: "rustc -vV".to_owned(),
reference: "x@sha256:0".to_owned(),
stderr: String::new(),
}
.into(),
BuilderError::ColdCache {
stderr: String::new(),
}
.into(),
BuilderError::NoPackageCache {
path: "/nowhere".to_owned(),
}
.into(),
BuilderError::Killed {
command: "cargo clippy".to_owned(),
signal: 9,
memory_mib: 4096,
}
.into(),
BuilderError::OutputTooLarge {
command: "cargo clippy".to_owned(),
max: 1,
}
.into(),
BuilderError::UnexpectedStatus {
analyzer: "clippy".to_owned(),
command: "cargo clippy".to_owned(),
status: 42,
expected: "0, 101".to_owned(),
stderr: String::new(),
}
.into(),
];
for error in variants {
let message = error.to_string();
assert!(
message.contains("nothing fell back to this host"),
"a sandbox refusal that does not say nothing ran here:\n{message}"
);
assert_eq!(
message.matches("nothing fell back to this host").count(),
1,
"said more than once:\n{message}"
);
}
}
#[test]
fn the_two_backends_never_share_a_build_directory() {
let roots = vec![Candidate {
variable: "TMPDIR",
path: PathBuf::from("/elsewhere/lint/target"),
}];
let root = Path::new("/repo");
let host = scratch_path(&roots, root, Backend::Host).expect("host scratch");
let sandbox = scratch_path(&roots, root, Backend::Sandbox).expect("sandbox scratch");
assert_ne!(host, sandbox);
assert!(
!sandbox.starts_with(&host),
"{sandbox:?} is inside {host:?}"
);
assert!(
!host.starts_with(&sandbox),
"{host:?} is inside {sandbox:?}"
);
assert_eq!(host.parent(), sandbox.parent());
}
#[test]
fn the_lint_registry_is_not_the_storable_one() {
assert_eq!(LINT_ANALYZERS, &["clippy"]);
for storable in crate::known_analyzers() {
assert!(
!LINT_ANALYZERS.contains(&storable),
"{storable} stores its findings and must not be offered as a lint"
);
}
}
#[test]
fn reads_a_clippy_version_without_mistaking_it_for_a_date() {
assert_eq!(
short_version("clippy 0.1.94 (5e2a1e56d 2026-06-27)"),
"0.1.94"
);
assert_eq!(short_version("cargo-clippy 0.1.94"), "0.1.94");
assert_eq!(
short_version("something else entirely"),
"something else entirely"
);
assert_eq!(short_version(" "), "unknown");
}
#[test]
fn reads_the_rustc_version_and_host_triple() {
let verbose = "rustc 1.94.0 (0123abcd 2026-06-26)\n\
binary: rustc\n\
commit-hash: 0123abcd\n\
host: aarch64-apple-darwin\n\
release: 1.94.0\n";
let (version, host) = parse_rustc_verbose(verbose);
assert_eq!(version, "rustc 1.94.0 (0123abcd 2026-06-26)");
assert_eq!(host, "aarch64-apple-darwin");
let (version, host) = parse_rustc_verbose("");
assert_eq!(version, "unknown");
assert_eq!(host, "unknown");
}
#[test]
fn takes_the_first_non_empty_line() {
assert_eq!(first_line("\n\n hello \nworld"), "hello");
assert_eq!(first_line(""), "");
}
#[test]
fn the_toolchain_variables_are_locators_and_nothing_else() {
for key in TOOLCHAIN_ENV {
assert!(
key.ends_with("_HOME") || key.ends_with("_TOOLCHAIN"),
"{key} is not a locator"
);
assert!(!key.contains("TOKEN") && !key.contains("KEY"), "{key}");
}
}
#[test]
fn the_target_directory_is_never_something_this_module_inherits() {
assert!(
!TOOLCHAIN_ENV.contains(&"CARGO_TARGET_DIR"),
"`CARGO_TARGET_DIR` is inherited again — a name can only pass along \
the caller's value, and where the build writes is this module's \
guarantee to make, not the caller's to supply. Set it in `run`."
);
}
#[test]
fn two_trees_never_share_a_scratch_directory() {
let roots = scratch_roots_from(Some("/state".into()), None, "/tmp".into());
let for_tree = |path: &str| {
scratch_path(&roots, Path::new(path), Backend::Host).expect("a scratch path")
};
assert_ne!(
for_tree("/repos/alpha"),
for_tree("/repos/beta"),
"two repositories sharing a build scratch is the cache-shaped hole \
in the execution boundary ADR-0014 draws"
);
assert_ne!(for_tree("/repos/alpha"), for_tree("/repos/alpha-wt"));
assert_eq!(for_tree("/repos/alpha"), for_tree("/repos/alpha"));
}
#[test]
fn the_scratch_root_never_falls_back_into_the_working_directory() {
let paths = |roots: Vec<super::Candidate>| -> Vec<PathBuf> {
roots.into_iter().map(|c| c.path).collect()
};
assert_eq!(
paths(scratch_roots_from(
Some("/state".into()),
Some("/home/u".into()),
"/tmp".into()
)),
vec![
PathBuf::from("/state/lint/target"),
PathBuf::from("/home/u/.roteiro/lint/target"),
PathBuf::from("/tmp/lint/target"),
],
"ROTEIRO_HOME first, as it is for the asset cache, then the home dir"
);
assert_eq!(
paths(scratch_roots_from(
None,
Some("/home/u".into()),
"/tmp".into()
)),
vec![
PathBuf::from("/home/u/.roteiro/lint/target"),
PathBuf::from("/tmp/lint/target"),
]
);
for candidate in scratch_roots_from(None, None, std::env::temp_dir()) {
assert!(
candidate.path.is_absolute(),
"{} is relative, so cargo would resolve it against the worktree",
candidate.path.display()
);
}
}
#[test]
fn a_candidate_inside_the_tree_under_review_is_rejected() {
let root = Path::new("/repos/alpha");
assert!(is_inside(
Path::new("/repos/alpha/.state/lint/target"),
root
));
assert!(is_inside(Path::new("/repos/alpha"), root));
assert!(!is_inside(Path::new("/repos/alpha-sibling/x"), root));
assert!(!is_inside(Path::new("/tmp/lint/target"), root));
let candidates =
scratch_roots_from(Some("/repos/alpha/.state".into()), None, "/tmp".into());
let chosen = scratch_path(&candidates, root, Backend::Host).expect("a scratch path");
assert!(
chosen.starts_with("/tmp/lint/target"),
"{} — the candidate inside the tree should have been skipped",
chosen.display()
);
let all_inside = scratch_roots_from(
Some("/repos/alpha/.state".into()),
Some("/repos/alpha".into()),
"/repos/alpha/tmp".into(),
);
let err = scratch_path(&all_inside, root, Backend::Host).expect_err("must refuse");
assert!(
matches!(err, LintError::ScratchWouldBeInsideTheTree { .. }),
"{err:?}"
);
assert!(err.to_string().contains("ROTEIRO_HOME"), "{err}");
}
#[test]
fn a_relative_scratch_root_is_refused_rather_than_resolved() {
let root = Path::new("/repos/alpha");
for (source, roots) in [
(
"ROTEIRO_HOME",
scratch_roots_from(Some("relstate".into()), None, "/tmp".into()),
),
(
"HOME",
scratch_roots_from(None, Some("relhome".into()), "/tmp".into()),
),
("TMPDIR", scratch_roots_from(None, None, "reltmp".into())),
] {
let err =
scratch_path(&roots, root, Backend::Host).expect_err("a relative root must refuse");
assert!(
matches!(err, LintError::ScratchRootNotAbsolute { .. }),
"{source}: {err:?}"
);
assert!(err.to_string().contains(source), "{err}");
}
let outside_cwd_verdict = is_inside(Path::new("relstate/lint/target"), root);
assert!(
!outside_cwd_verdict,
"this assertion documents the hazard: from a working directory \
outside {root:?}, the containment check passes a relative candidate \
that cargo would resolve into the tree. It is unreachable now only \
because `scratch_path` refuses first."
);
}
#[test]
fn an_accepted_scratch_path_is_always_absolute() {
let root = Path::new("/repos/alpha");
for roots in [
scratch_roots_from(Some("/state".into()), None, "/tmp".into()),
scratch_roots_from(None, Some("/home/u".into()), "/tmp".into()),
scratch_roots_from(None, None, "/tmp".into()),
scratch_roots_from(Some("/repos/alpha/.state".into()), None, "/tmp".into()),
scratch_roots_from(Some("relstate".into()), None, "/tmp".into()),
scratch_roots_from(None, Some("relhome".into()), "/tmp".into()),
scratch_roots_from(None, None, "reltmp".into()),
] {
if let Ok(path) = scratch_path(&roots, root, Backend::Host) {
assert!(
path.is_absolute(),
"{} is relative — cargo would resolve it against the worktree",
path.display()
);
}
}
}
#[test]
fn absoluteness_is_checked_before_containment_not_after() {
let cwd = std::env::current_dir().expect("a working directory");
let roots = scratch_roots_from(Some("relstate".into()), None, std::env::temp_dir());
let err = scratch_path(&roots, &cwd, Backend::Host).expect_err(
"a relative ROTEIRO_HOME must refuse, not be quietly skipped as \
'inside the tree' and replaced by the next candidate",
);
assert!(
matches!(err, LintError::ScratchRootNotAbsolute { .. }),
"{err:?}"
);
}
#[test]
fn a_candidate_reached_through_a_symlink_is_still_inside_the_tree() {
let base = std::env::temp_dir().join(format!(
"roteiro-inside-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::remove_dir_all(&base).ok();
let real = base.join("real");
std::fs::create_dir_all(real.join("src")).expect("mkdir");
let link = base.join("link");
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link).expect("symlink");
#[cfg(not(unix))]
{
std::fs::remove_dir_all(&base).ok();
return;
}
assert!(
is_inside(&link.join(".state/lint/target"), &real),
"a candidate under {} was not seen as inside {}",
link.display(),
real.display()
);
assert!(is_inside(&real.join(".state/lint/target"), &link));
assert!(!is_inside(&base.join("elsewhere/lint/target"), &real));
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn a_refused_lockfile_write_is_recognised_in_either_wording() {
for stderr in [
"error: cannot create the lock file /r/Cargo.lock because --locked was passed to \
prevent this",
"error: cannot update the lock file /r/Cargo.lock because --locked was passed to \
prevent this",
"error: the lock file /r/Cargo.lock needs to be updated but --locked was passed to \
prevent this",
] {
assert!(lockfile_refused(stderr.as_bytes()), "{stderr}");
}
assert!(!lockfile_refused(
b"error: could not compile `x` due to 3 errors"
));
assert!(!lockfile_refused(b""));
}
#[test]
fn the_invocation_forbids_cargo_from_writing_the_lockfile() {
for features in [FeatureSet::Defaults, FeatureSet::All] {
let invocation = crate::adapter::clippy::Clippy::invocation(&features);
assert!(
invocation.args.iter().any(|a| a == "--locked"),
"{:?} may rewrite Cargo.lock in the tree being linted",
invocation.args
);
}
}
}