use std::path::{Path, PathBuf};
use boxlite::runtime::options::VolumeSpec;
use boxlite::{
BoxCommand, BoxOptions, BoxliteOptions, BoxliteRuntime, LiteBox, NetworkSpec, RootfsSpec,
};
use futures::StreamExt as _;
use rto_graph::{Isolation, SourceIdentity};
use crate::adapter::NativeContext;
use crate::adapter::clippy::{self, Clippy, FeatureSet};
use crate::boxlite::{
EXEC_TIMEOUT, GUEST_CPUS, GUEST_INIT, GUEST_MEMORY_MIB, GUEST_WORKTREE, MAX_OUTPUT_BYTES,
SandboxError, SandboxProbe, pinned_digest, reference_is_present, sandbox_probe,
};
use crate::child_env::ChildEnv;
use crate::clock::rfc3339_utc;
use crate::guidance::{Guidance, Line};
use crate::lint::{LintError, LintOutcome, Toolchain, scratch_dir};
use crate::lint_grant::Backend;
use crate::snippet::WorktreeSnippets;
pub const GUEST_SCRATCH: &str = "/scratch";
pub const GUEST_CARGO_HOME: &str = "/cargo";
pub const GUEST_CARGO_REGISTRY: &str = "/cargo/registry";
pub const GUEST_CARGO_GIT: &str = "/cargo/git";
const SEE_THE_DOCUMENT: Line = Line::Note(&[
"See docs/SANDBOXED_LINTING.md for the two-line Dockerfile that satisfies this,",
"and pin the image you build by digest.",
]);
const PREFETCH_THE_IMAGE: Line =
Line::Command("roteiro security prefetch --analyzer clippy --allow-download");
const FETCH_ON_THE_HOST: Line = Line::Command("cargo fetch --locked");
pub const NO_IMAGE_CONFIGURED: Guidance = Guidance::new(&[
Line::Note(&[
"No image is configured. `roteiro lint` runs the linter inside an OCI image,",
"and roteiro ships no default: no first-party Rust image carries the `clippy`",
"component (rust-lang/docker-rust builds every stable and nightly variant",
"`--profile minimal`), and choosing a third party's would make somebody else's",
"container the boundary your build scripts run in — picked here and noticed by",
"nobody.",
]),
Line::Note(&["Supply one, pinned by digest:"]),
Line::Command("[lint]"),
Line::Command("image = \"registry/you/rust-clippy@sha256:<64 hex>\""),
Line::Note(&[
"in ~/.roteiro/config.toml (yours) or the repository's roteiro.toml (your",
"team's), then:",
]),
PREFETCH_THE_IMAGE,
SEE_THE_DOCUMENT,
]);
const BUILD_DEPENDENCY_HINT: Guidance = Guidance::new(&[Line::Note(&[
"It ran sandboxed, so check this first: the image has to be able to *build*",
"your tree, not just lint it. `cargo clippy` has `cargo check` semantics, so",
"every build script in the tree runs inside the image — and a native dependency",
"the image lacks (libclang, cmake, a C toolchain, protoc) fails there, where you",
"cannot open a shell. Add it to the image you supply; see",
"docs/SANDBOXED_LINTING.md.",
])]);
const IMAGE_LACKS_LINTER: Guidance = Guidance::new(&[
Line::Note(&[
"An official Rust image will not do — rust-lang/docker-rust builds every stable",
"and nightly variant with `rustup-init --profile minimal`, which installs rustc,",
"cargo and rust-std and stops. The `clippy` component has to be in the image you",
"supply, and roteiro will not choose one for you: an image is the boundary your",
"build scripts run in.",
]),
SEE_THE_DOCUMENT,
Line::Note(&["Then point `[lint] image` at it, and provision it:"]),
PREFETCH_THE_IMAGE,
]);
const COLD_CACHE: Guidance = Guidance::new(&[
Line::Note(&[
"Egress is denied by the hypervisor, and `--offline` is passed so cargo says so",
"rather than hanging. Fetch them on the host first, in the tree you are linting:",
]),
FETCH_ON_THE_HOST,
Line::Note(&[
"That both downloads and unpacks, which is what a read-only cache mount needs —",
"a `.crate` file that is present but unexpanded fails just as a missing one does,",
"because expanding it would be a write. Then lint again.",
]),
Line::Note(&["Nothing was reported, because a build that did not happen is not a clean tree."]),
]);
const NO_CACHE: Guidance = Guidance::new(&[
Line::Note(&[
"The guest builds from a read-only mount of this machine's cache, so there has to",
"be one. Create it by fetching this tree's dependencies on the host:",
]),
FETCH_ON_THE_HOST,
]);
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BuilderError {
#[error(transparent)]
Sandbox(#[from] SandboxError),
#[error(
"the image {reference} is not in the local store, and a run never pulls one.{}",
Guidance::new(&[
Line::Note(&[
"Provisioning fetches; running reads, so that a lint can never fail because a",
"registry was unreachable, nor succeed by quietly fetching something new.",
"Pull it first:",
]),
PREFETCH_THE_IMAGE,
])
)]
ImageNotProvisioned {
analyzer: String,
reference: String,
},
#[error(
"the image {reference} ran, and `{probe}` inside it did not work: it does not carry \
`{analyzer}`.{}{stderr}",
IMAGE_LACKS_LINTER
)]
ImageLacksLinter {
analyzer: String,
reference: String,
probe: String,
stderr: String,
},
#[error("`{probe}` failed inside the image {reference}.{stderr}")]
ProbeFailed {
probe: String,
reference: String,
stderr: String,
},
#[error(
"the build needs a dependency that this machine's cargo cache does not hold, and the \
guest has no network to fetch it with.{}{stderr}",
COLD_CACHE
)]
ColdCache {
stderr: String,
},
#[error(
"there is no cargo package cache to mount: {path} does not exist.{}",
NO_CACHE
)]
NoPackageCache {
path: String,
},
#[error(
"`{command}` exited {status} inside the sandbox, which `{analyzer}` does not use for a \
completed run (expected one of: {expected}).{stderr}"
)]
UnexpectedStatus {
analyzer: String,
command: String,
status: i32,
expected: String,
stderr: String,
},
#[error(
"`{command}` was killed by signal {signal} inside the sandbox and wrote nothing to \
stderr.\n The usual cause is the guest running out of memory: it is given {memory_mib} \
MiB, and compiling a large workspace can need more. This is not a finding — nothing was \
reported, because a build that was killed is not a clean tree."
)]
Killed {
command: String,
signal: i32,
memory_mib: u32,
},
#[error(
"`{command}` produced more than {max} bytes of output in the sandbox; refusing to read it"
)]
OutputTooLarge {
command: String,
max: usize,
},
}
pub fn run(
analyzer: &str,
root: &Path,
features: &FeatureSet,
image: &str,
) -> Result<LintOutcome, LintError> {
pinned_digest("`[lint] image`", image).map_err(BuilderError::from)?;
if let SandboxProbe::Unavailable(reason) = sandbox_probe() {
return Err(BuilderError::from(SandboxError::Unavailable { reason }).into());
}
let assets_root = crate::asset_paths::asset_root();
if !reference_is_present("`[lint] image`", image, &assets_root).map_err(BuilderError::from)? {
return Err(BuilderError::ImageNotProvisioned {
analyzer: analyzer.to_owned(),
reference: image.to_owned(),
}
.into());
}
let cache = PackageCache::on_this_host()?;
let scratch = scratch_dir(root, Backend::Sandbox)?;
let runner = Builder {
analyzer,
image: image.to_owned(),
root: root.to_path_buf(),
scratch: scratch.clone(),
cache,
assets_root,
};
let outcome = runner.execute(features)?;
Ok(outcome)
}
#[derive(Debug)]
struct PackageCache {
registry: PathBuf,
git: Option<PathBuf>,
}
impl PackageCache {
fn on_this_host() -> Result<Self, BuilderError> {
let home = std::env::var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|home| PathBuf::from(home).join(".cargo"))
})
.ok_or_else(|| BuilderError::NoPackageCache {
path: "$CARGO_HOME (unset, and no home directory either)".to_owned(),
})?;
Self::under(&home)
}
fn under(home: &Path) -> Result<Self, BuilderError> {
let registry = home.join("registry");
if !registry.is_dir() {
return Err(BuilderError::NoPackageCache {
path: registry.display().to_string(),
});
}
let git = home.join("git");
Ok(Self {
registry,
git: git.is_dir().then_some(git),
})
}
fn volumes(&self) -> Vec<VolumeSpec> {
std::iter::once((&self.registry, GUEST_CARGO_REGISTRY))
.chain(self.git.as_ref().map(|git| (git, GUEST_CARGO_GIT)))
.map(|(host, guest)| VolumeSpec {
host_path: host.to_string_lossy().into_owned(),
guest_path: guest.to_owned(),
read_only: true,
})
.collect()
}
}
struct Builder<'a> {
analyzer: &'a str,
image: String,
root: PathBuf,
scratch: PathBuf,
cache: PackageCache,
assets_root: PathBuf,
}
struct Captured {
stdout: String,
stderr: String,
status: i32,
}
impl Builder<'_> {
fn volumes(&self) -> Vec<VolumeSpec> {
let mut volumes = vec![
VolumeSpec {
host_path: self.root.to_string_lossy().into_owned(),
guest_path: GUEST_WORKTREE.to_owned(),
read_only: true,
},
VolumeSpec {
host_path: self.scratch.to_string_lossy().into_owned(),
guest_path: GUEST_SCRATCH.to_owned(),
read_only: false,
},
];
volumes.extend(self.cache.volumes());
volumes
}
fn execute(&self, features: &FeatureSet) -> Result<LintOutcome, LintError> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| {
BuilderError::from(SandboxError::Runtime {
stage: "runtime",
message: e.to_string(),
})
})?;
runtime.block_on(self.inside(features))
}
async fn inside(&self, features: &FeatureSet) -> Result<LintOutcome, LintError> {
let boxlite = BoxliteRuntime::new(BoxliteOptions {
home_dir: self.assets_root.join("boxlite-home"),
image_registries: Vec::new(),
})
.map_err(|e| {
BuilderError::from(SandboxError::Runtime {
stage: "open",
message: e.to_string(),
})
})?;
let options = BoxOptions {
cpus: Some(GUEST_CPUS),
memory_mib: Some(GUEST_MEMORY_MIB),
rootfs: RootfsSpec::Image(self.image.clone()),
network: NetworkSpec::Disabled,
volumes: self.volumes(),
env: guest_environment(),
working_dir: Some(GUEST_WORKTREE.to_owned()),
entrypoint: Some(GUEST_INIT.iter().map(|s| (*s).to_owned()).collect()),
cmd: Some(Vec::new()),
auto_remove: true,
detach: false,
..Default::default()
};
let boxed = boxlite.create(options, None).await.map_err(|e| {
BuilderError::from(SandboxError::Runtime {
stage: "create",
message: e.to_string(),
})
})?;
let result = self.lint_in(&boxed, features).await;
let stopped = boxed.stop().await;
let shutdown = boxlite.shutdown(Some(10)).await;
let outcome = result?;
stopped.map_err(|e| {
BuilderError::from(SandboxError::Runtime {
stage: "stop",
message: e.to_string(),
})
})?;
shutdown.map_err(|e| {
BuilderError::from(SandboxError::Runtime {
stage: "shutdown",
message: e.to_string(),
})
})?;
Ok(outcome)
}
async fn lint_in(
&self,
boxed: &LiteBox,
features: &FeatureSet,
) -> Result<LintOutcome, LintError> {
let toolchain = self.probe_toolchain(boxed).await?;
let invocation = Clippy::offline_invocation(features);
let command = crate::lint::argv(&invocation);
let started_at = rfc3339_utc(std::time::SystemTime::now());
let output = self
.exec(boxed, &invocation.program, &invocation.args)
.await?;
let ended_at = rfc3339_utc(std::time::SystemTime::now());
if cold_cache(&output.stderr) {
return Err(BuilderError::ColdCache {
stderr: stderr_tail(&output.stderr),
}
.into());
}
if crate::lint::lockfile_refused(output.stderr.as_bytes()) {
return Err(LintError::LockfileWouldBeWritten {
command: command.join(" "),
stderr: stderr_tail(&output.stderr),
});
}
if !invocation.success_statuses.contains(&output.status) {
return Err(self.failed(&command, &output, &invocation.success_statuses));
}
let snippets = WorktreeSnippets::new(&self.root);
let source = SourceIdentity::default();
let ctx = NativeContext {
started_at,
ended_at,
analyzer_version: Some(crate::lint::short_version(&toolchain.linter)),
exit_status: output.status,
source: &source,
rules_digest: None,
advisory_db: None,
worktree: Some(Path::new(GUEST_WORKTREE)),
snippets: &snippets,
};
let (report, summary) = Clippy::parse(output.stdout.as_bytes(), &ctx)?;
if !summary.build_succeeded && report.findings.is_empty() {
return Err(LintError::BuildProducedNothing {
command: command.join(" "),
status: output.status,
hint: Some(BUILD_DEPENDENCY_HINT),
stderr: stderr_tail(&output.stderr),
});
}
Ok(LintOutcome {
analyzer: clippy::ANALYZER,
report,
summary,
toolchain,
features: features.clone(),
isolation: Isolation::MicroVm,
command,
worktree: self.root.clone(),
image: Some(self.image.clone()),
scratch: self.scratch.clone(),
})
}
fn failed(&self, command: &[String], output: &Captured, expected: &[i32]) -> LintError {
if output.status < 0 && output.stderr.trim().is_empty() {
return BuilderError::Killed {
command: command.join(" "),
signal: -output.status,
memory_mib: GUEST_MEMORY_MIB,
}
.into();
}
BuilderError::UnexpectedStatus {
analyzer: self.analyzer.to_owned(),
command: command.join(" "),
status: output.status,
expected: expected
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", "),
stderr: stderr_tail(&output.stderr),
}
.into()
}
async fn probe_toolchain(&self, boxed: &LiteBox) -> Result<Toolchain, BuilderError> {
let clippy = self
.exec(
boxed,
"cargo",
&["clippy".to_owned(), "--version".to_owned()],
)
.await?;
if clippy.status != 0 || clippy.stdout.trim().is_empty() {
return Err(BuilderError::ImageLacksLinter {
analyzer: self.analyzer.to_owned(),
reference: self.image.clone(),
probe: "cargo clippy --version".to_owned(),
stderr: stderr_tail(&clippy.stderr),
});
}
let rustc = self.exec(boxed, "rustc", &["-vV".to_owned()]).await?;
if rustc.status != 0 {
return Err(BuilderError::ProbeFailed {
probe: "rustc -vV".to_owned(),
reference: self.image.clone(),
stderr: stderr_tail(&rustc.stderr),
});
}
let (version, host) = crate::lint::parse_rustc_verbose(&rustc.stdout);
Ok(Toolchain {
linter: crate::lint::first_line(&clippy.stdout),
rustc: version,
host,
})
}
async fn exec(
&self,
boxed: &LiteBox,
program: &str,
args: &[String],
) -> Result<Captured, BuilderError> {
let command = BoxCommand::new(program)
.args(args.to_vec())
.working_dir(GUEST_WORKTREE)
.timeout(EXEC_TIMEOUT);
let mut execution = boxed
.exec(command)
.await
.map_err(|e| SandboxError::Runtime {
stage: "exec",
message: e.to_string(),
})?;
let out = tokio::spawn(collect(execution.stdout()));
let err = tokio::spawn(collect(execution.stderr()));
let status = execution.wait().await.map_err(|e| SandboxError::Runtime {
stage: "wait",
message: e.to_string(),
})?;
let joined = |handle: tokio::task::JoinHandle<String>, what: &'static str| async move {
handle.await.map_err(|e| SandboxError::Runtime {
stage: what,
message: e.to_string(),
})
};
let stdout = joined(out, "stdout").await?;
let stderr = joined(err, "stderr").await?;
if stdout.len() > MAX_OUTPUT_BYTES {
return Err(BuilderError::OutputTooLarge {
command: program.to_owned(),
max: MAX_OUTPUT_BYTES,
});
}
Ok(Captured {
stdout,
stderr,
status: status.exit_code,
})
}
}
fn guest_environment() -> Vec<(String, String)> {
let set = [
("CARGO_TARGET_DIR", std::ffi::OsString::from(GUEST_SCRATCH)),
("CARGO_HOME", std::ffi::OsString::from(GUEST_CARGO_HOME)),
];
ChildEnv {
inherit: &[],
set: &set,
}
.guest_pairs()
}
async fn collect(stream: Option<impl futures::Stream<Item = String> + Unpin>) -> String {
let mut out = String::new();
if let Some(mut stream) = stream {
while let Some(chunk) = stream.next().await {
out.push_str(&chunk);
if out.len() > MAX_OUTPUT_BYTES {
break;
}
}
}
out
}
fn cold_cache(stderr: &str) -> bool {
stderr.contains("but --offline was specified") || stderr.contains("failed to unpack package")
}
fn stderr_tail(stderr: &str) -> String {
crate::subprocess::stderr_tail(stderr.as_bytes())
}
#[cfg(test)]
mod tests {
use super::{
BUILD_DEPENDENCY_HINT, Builder, BuilderError, COLD_CACHE, GUEST_CARGO_GIT,
GUEST_CARGO_HOME, GUEST_CARGO_REGISTRY, GUEST_SCRATCH, IMAGE_LACKS_LINTER, NO_CACHE,
NO_IMAGE_CONFIGURED, PackageCache, cold_cache, guest_environment,
};
use crate::boxlite::GUEST_WORKTREE;
use std::path::{Path, PathBuf};
fn builder(git: bool) -> Builder<'static> {
Builder {
analyzer: "clippy",
image: "registry/x@sha256:".to_owned() + &"a".repeat(64),
root: PathBuf::from("/repo"),
scratch: PathBuf::from("/scratch-on-host"),
cache: PackageCache {
registry: PathBuf::from("/home/you/.cargo/registry"),
git: git.then(|| PathBuf::from("/home/you/.cargo/git")),
},
assets_root: PathBuf::from("/assets"),
}
}
#[test]
fn the_scratch_is_the_only_thing_the_guest_may_write() {
let volumes = builder(true).volumes();
let writable: Vec<&str> = volumes
.iter()
.filter(|v| !v.read_only)
.map(|v| v.guest_path.as_str())
.collect();
assert_eq!(writable, vec![GUEST_SCRATCH]);
}
#[test]
fn the_worktree_and_the_package_cache_are_read_only() {
let volumes = builder(true).volumes();
for guest in [GUEST_WORKTREE, GUEST_CARGO_REGISTRY, GUEST_CARGO_GIT] {
let volume = volumes
.iter()
.find(|v| v.guest_path == guest)
.unwrap_or_else(|| panic!("{guest} is not mounted"));
assert!(volume.read_only, "{guest} is writable");
}
}
fn cargo_home(git: bool, credentials: bool) -> PathBuf {
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT: AtomicUsize = AtomicUsize::new(0);
let home = std::env::temp_dir().join(format!(
"rto-exec-cargo-home-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::remove_dir_all(&home).ok();
std::fs::create_dir_all(home.join("registry")).expect("registry");
if git {
std::fs::create_dir_all(home.join("git")).expect("git");
}
if credentials {
std::fs::write(
home.join("credentials.toml"),
"[registry]\ntoken = \"secret\"\n",
)
.expect("credentials");
}
home
}
#[test]
fn the_cargo_home_root_is_never_mounted_so_credentials_stay_here() {
let home = cargo_home(true, true);
let volumes = PackageCache::under(&home).expect("a cache").volumes();
assert!(!volumes.is_empty(), "nothing was mounted at all");
let root = home.to_string_lossy().into_owned();
for volume in &volumes {
assert_ne!(
volume.host_path, root,
"the CARGO_HOME root is mounted, which puts credentials.toml in front of the \
build scripts this boundary exists to contain"
);
assert!(
!Path::new(&volume.host_path)
.join("credentials.toml")
.exists(),
"credentials.toml is reachable inside {}",
volume.host_path
);
assert!(volume.read_only, "{} is writable", volume.host_path);
}
std::fs::remove_dir_all(&home).ok();
}
#[test]
fn a_git_cache_is_mounted_when_it_exists_and_not_when_it_does_not() {
let with = cargo_home(true, false);
let volumes = PackageCache::under(&with).expect("a cache").volumes();
assert!(
volumes.iter().any(|v| v.guest_path == GUEST_CARGO_GIT),
"a git cache that exists must be mounted"
);
let without = cargo_home(false, false);
let volumes = PackageCache::under(&without).expect("a cache").volumes();
assert!(
volumes.iter().all(|v| v.guest_path != GUEST_CARGO_GIT),
"a git cache that does not exist must not be mounted"
);
assert!(
volumes.iter().any(|v| v.guest_path == GUEST_CARGO_REGISTRY),
"the registry is not optional"
);
std::fs::remove_dir_all(&with).ok();
std::fs::remove_dir_all(&without).ok();
}
#[test]
fn a_missing_package_cache_refuses_and_names_the_way_forward() {
let absent = std::env::temp_dir().join("rto-exec-no-such-cargo-home");
std::fs::remove_dir_all(&absent).ok();
let err = PackageCache::under(&absent).expect_err("must refuse");
let message = crate::LintError::from(err).to_string();
assert!(message.contains("registry"), "{message}");
assert!(message.contains("cargo fetch --locked"), "{message}");
assert!(
message.contains("nothing fell back to this host"),
"{message}"
);
}
#[test]
fn the_guest_environment_names_both_constraints() {
let env = guest_environment();
assert!(env.contains(&("CARGO_TARGET_DIR".to_owned(), GUEST_SCRATCH.to_owned())));
assert!(env.contains(&("CARGO_HOME".to_owned(), GUEST_CARGO_HOME.to_owned())));
let names: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
for ambient in ["PATH", "HOME", "SSH_AUTH_SOCK", "CARGO_REGISTRY_TOKEN"] {
assert!(!names.contains(&ambient), "{ambient} reaches the guest");
}
}
#[test]
fn the_mounted_cache_is_underneath_the_cargo_home_the_guest_is_given() {
for guest in [GUEST_CARGO_REGISTRY, GUEST_CARGO_GIT] {
assert!(
guest.starts_with(&format!("{GUEST_CARGO_HOME}/")),
"{guest} is not inside {GUEST_CARGO_HOME}"
);
}
}
#[test]
fn both_cold_cache_wordings_are_recognised_and_nothing_else_is() {
assert!(cold_cache(
"error: failed to download `serde v1.0.229`\n\nCaused by:\n attempting to make an \
HTTP request, but --offline was specified"
));
assert!(cold_cache(
"error: failed to download `serde v1.0.229`\n\nCaused by:\n failed to unpack \
package `serde v1.0.229`"
));
assert!(!cold_cache(
"error: the lock file needs to be updated but --locked was passed to prevent this"
));
assert!(!cold_cache("error[E0308]: mismatched types"));
assert!(!cold_cache(""));
}
#[test]
fn every_refusal_names_the_thing_it_is_about() {
let reference = "registry.example/you/rust-clippy@sha256:0123456789abcdef";
let subjects: Vec<(String, &str)> = vec![
(
BuilderError::ImageNotProvisioned {
analyzer: "clippy".to_owned(),
reference: reference.to_owned(),
}
.to_string(),
reference,
),
(
BuilderError::ImageLacksLinter {
analyzer: "clippy".to_owned(),
reference: reference.to_owned(),
probe: "cargo clippy --version".to_owned(),
stderr: String::new(),
}
.to_string(),
reference,
),
(
BuilderError::ProbeFailed {
probe: "rustc -vV".to_owned(),
reference: reference.to_owned(),
stderr: String::new(),
}
.to_string(),
reference,
),
(
BuilderError::NoPackageCache {
path: "/nowhere/registry".to_owned(),
}
.to_string(),
"/nowhere/registry",
),
(
BuilderError::UnexpectedStatus {
analyzer: "clippy".to_owned(),
command: "cargo clippy --offline".to_owned(),
status: 42,
expected: "0, 101".to_owned(),
stderr: String::new(),
}
.to_string(),
"cargo clippy --offline",
),
];
for (message, subject) in subjects {
assert!(
message.contains(subject),
"a refusal that does not name {subject:?} narrows nothing:\n{message}"
);
}
}
#[test]
fn every_guidance_in_this_module_renders_without_defects() {
for guidance in [
NO_IMAGE_CONFIGURED,
BUILD_DEPENDENCY_HINT,
IMAGE_LACKS_LINTER,
COLD_CACHE,
NO_CACHE,
] {
assert!(guidance.defects().is_empty(), "{:?}", guidance.defects());
assert!(!guidance.to_string().is_empty());
}
}
#[test]
fn the_two_line_dockerfile_claim_matches_the_document() {
let doc = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../docs/SANDBOXED_LINTING.md")
.canonicalize()
.expect("the document the refusal points at must exist");
let text = std::fs::read_to_string(&doc).expect("readable");
let fence = text
.split("```dockerfile")
.nth(1)
.expect("the document must contain a dockerfile block")
.split("```")
.next()
.expect("an unterminated fence");
let lines = fence.lines().filter(|l| !l.trim().is_empty()).count();
let numeral = match lines {
2 => "two",
3 => "three",
4 => "four",
n => panic!(
"{} shows a {n}-line Dockerfile; teach this test the numeral",
doc.display()
),
};
assert!(
NO_IMAGE_CONFIGURED
.to_string()
.contains(&format!("{numeral}-line")),
"{} shows a {lines}-line Dockerfile, and the refusal does not say {numeral:?}:\n{}",
doc.display(),
NO_IMAGE_CONFIGURED
);
}
#[test]
fn the_unconfigured_image_refusal_says_what_to_do() {
let rendered = NO_IMAGE_CONFIGURED.to_string();
for needle in [
"[lint]",
"image = ",
"@sha256:",
"roteiro security prefetch",
"docs/SANDBOXED_LINTING.md",
] {
assert!(
rendered.contains(needle),
"the refusal does not mention {needle}"
);
}
}
}