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, RunnerKind};
use crate::adapter::{Adapter, AssetPaths, Invocation, NativeContext};
use crate::assets;
use crate::child_env::ChildEnv;
use crate::clock::rfc3339_utc;
use crate::ingest::assemble;
use crate::runner::{AnalysisRequest, AnalysisResponse, AnalyzerRunner, ExecError, check_request};
use crate::snippet::WorktreeSnippets;
pub const GUEST_WORKTREE: &str = "/work";
pub const GUEST_ASSETS: &str = "/assets";
pub const MAX_OUTPUT_BYTES: usize = 256 << 20;
pub const GUEST_INIT: &[&str] = &["sh", "-c", "while : ; do sleep 86400 ; done"];
pub const GUEST_MEMORY_MIB: u32 = 4096;
pub const GUEST_CPUS: u8 = 2;
pub const EXEC_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SandboxImage {
pub analyzer: &'static str,
pub reference: &'static str,
pub digest: &'static str,
pub analyzer_version: &'static str,
}
pub static SANDBOX_IMAGES: &[SandboxImage] = &[SandboxImage {
analyzer: crate::adapter::semgrep::ANALYZER,
reference: "docker.io/semgrep/semgrep@sha256:67319956da3dcb58baf5b322899c15458e3963e7018a86aeeb5cd224e69cb77a",
digest: "sha256:67319956da3dcb58baf5b322899c15458e3963e7018a86aeeb5cd224e69cb77a",
analyzer_version: "1.173.0",
}];
#[must_use]
pub fn image_for(analyzer: &str) -> Option<&'static SandboxImage> {
SANDBOX_IMAGES.iter().find(|i| i.analyzer == analyzer)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ImageSource {
BuiltIn,
UserDeclared,
Overrides,
}
impl ImageSource {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::BuiltIn => "built-in",
Self::UserDeclared => "user-declared",
Self::Overrides => "user-declared (replaces the built-in pin)",
}
}
#[must_use]
pub fn is_built_in(self) -> bool {
matches!(self, Self::BuiltIn)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ResolvedImage {
pub analyzer: String,
pub reference: String,
pub digest: String,
pub analyzer_version: Option<String>,
pub source: ImageSource,
}
impl ResolvedImage {
#[must_use]
pub fn what(&self) -> String {
if self.source.is_built_in() {
format!("the pinned image for {}", self.analyzer)
} else {
format!("`[security.images] {}`", self.analyzer)
}
}
}
pub fn resolve_image(
analyzer: &str,
declared: Option<&str>,
) -> Result<ResolvedImage, SandboxError> {
let built_in = image_for(analyzer);
let Some(reference) = declared else {
let image = built_in.ok_or_else(|| SandboxError::NoImage {
requested: analyzer.to_owned(),
known: known_images(),
})?;
return Ok(ResolvedImage {
analyzer: image.analyzer.to_owned(),
reference: image.reference.to_owned(),
digest: image.digest.to_owned(),
analyzer_version: Some(image.analyzer_version.to_owned()),
source: ImageSource::BuiltIn,
});
};
if crate::adapter::adapter_for(analyzer).is_none() {
return Err(SandboxError::NoAdapter {
requested: analyzer.to_owned(),
known: crate::adapter::known_analyzers().join(", "),
});
}
let digest = pinned_digest(&format!("`[security.images] {analyzer}`"), reference)?;
Ok(ResolvedImage {
analyzer: analyzer.to_owned(),
reference: reference.to_owned(),
digest: digest.to_owned(),
analyzer_version: None,
source: if built_in.is_some() {
ImageSource::Overrides
} else {
ImageSource::UserDeclared
},
})
}
pub fn image_inventory(declared: &[(String, String)]) -> Result<Vec<ResolvedImage>, SandboxError> {
let mut names: Vec<&str> = SANDBOX_IMAGES.iter().map(|i| i.analyzer).collect();
for (analyzer, _) in declared {
if !names.contains(&analyzer.as_str()) {
names.push(analyzer);
}
}
names.sort_unstable();
names
.into_iter()
.map(|analyzer| {
let declared = declared
.iter()
.find(|(name, _)| name == analyzer)
.map(|(_, reference)| reference.as_str());
resolve_image(analyzer, declared)
})
.collect()
}
fn known_images() -> String {
let known = SANDBOX_IMAGES
.iter()
.map(|i| i.analyzer)
.collect::<Vec<_>>()
.join(", ");
if known.is_empty() {
"none".to_owned()
} else {
known
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SandboxProbe {
Available,
Unavailable(String),
}
impl SandboxProbe {
#[must_use]
pub fn is_available(&self) -> bool {
matches!(self, Self::Available)
}
#[must_use]
pub fn reason(&self) -> Option<&str> {
match self {
Self::Available => None,
Self::Unavailable(why) => Some(why),
}
}
}
#[must_use]
pub fn sandbox_probe() -> SandboxProbe {
match boxlite::system_check::SystemCheck::run() {
Ok(_) => SandboxProbe::Available,
Err(e) => SandboxProbe::Unavailable(probe_reason(&e.to_string())),
}
}
fn probe_reason(raw: &str) -> String {
if raw.trim().is_empty() {
return "the hypervisor probe failed without giving a reason".to_owned();
}
raw.trim().to_owned()
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SandboxError {
#[error(
"no sandbox is available on this host: {reason}\n \
run the analyzer with `--allow-unsandboxed` to accept an unisolated run \
(its evidence will record isolation=none), or ingest a report produced elsewhere."
)]
Unavailable {
reason: String,
},
#[error(
"no sandbox image for analyzer {requested} — this build pins one for: {known}.\n \
Roteiro pins an image only where one is published, addressable by digest and of a \
knowable version; inventing one would make it the publisher of a security tool's \
container.\n \
Declare your own, pinned by digest, and it will be used:\n \
[security.images]\n \
{requested} = \"registry.example/you/{requested}@sha256:<64 hex>\"\n \
Then `roteiro security prefetch --analyzer {requested} --allow-download` to obtain it. \
See docs/SANDBOXED_LINTING.md. The alternatives are unchanged: run it elsewhere and \
`roteiro security ingest` the report, or accept an unisolated run with \
`--allow-unsandboxed`, whose evidence records isolation=none."
)]
NoImage {
requested: String,
known: String,
},
#[error(
"assets-unavailable-offline: the image for {analyzer} is not in the local store\n \
image: {reference}\n \
fetch it with: roteiro security prefetch --analyzer {analyzer} --allow-download\n \
(roteiro never pulls an image during a run, so a scan can never depend on \
a registry being reachable — and that is as true of an image you declared \
in `[security.images]` as of one Roteiro pinned)"
)]
ImageNotProvisioned {
analyzer: String,
reference: String,
},
#[error(transparent)]
ImageNotPinned(#[from] crate::image_ref::NotPinned),
#[error(
"`[security.images] {requested}` names an analyzer this build cannot read the output of \
(it can read: {known}).\n \
An image can only serve an analyzer Roteiro already has an adapter for — the parser is \
Rust in `ADAPTERS` and cannot be supplied alongside the image. An image carrying some \
other tool boots perfectly and produces nothing Roteiro can normalise.\n \
To have findings from a tool that is not on that list, run it yourself and \
`roteiro security ingest` its report."
)]
NoAdapter {
requested: String,
known: String,
},
#[error("sandbox {stage}: {message}")]
Runtime {
stage: &'static str,
message: String,
},
#[error(
"`{program}` exited with status {status} inside the sandbox, which it does not use for a \
completed scan (expected one of: {expected}). A scan that failed is not a clean result, \
so nothing was stored.{stderr}"
)]
UnexpectedStatus {
program: String,
status: i32,
expected: String,
stderr: String,
},
#[error(
"`{program}` 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 a large tree can need more.\n This is not a finding — nothing \
was stored, because a scan that was killed is not a clean result."
)]
Killed {
program: String,
signal: i32,
memory_mib: u32,
},
#[error(
"`{program}` produced more than {max} bytes of output in the sandbox; refusing to read it"
)]
OutputTooLarge {
program: String,
max: usize,
},
}
pub struct BoxliteRunner {
adapter: &'static dyn Adapter,
image: ResolvedImage,
assets: Vec<(&'static str, PathBuf)>,
guest_assets: Vec<(&'static str, PathBuf)>,
assets_root: PathBuf,
runtime: tokio::runtime::Runtime,
}
impl std::fmt::Debug for BoxliteRunner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BoxliteRunner")
.field("adapter", &self.adapter)
.field("image", &self.image)
.field("assets_root", &self.assets_root)
.finish_non_exhaustive()
}
}
impl BoxliteRunner {
pub fn new(
analyzer: &str,
assets_root: &Path,
declared: Option<&str>,
) -> Result<Self, ExecError> {
let adapter =
crate::adapter::adapter_for(analyzer).ok_or_else(|| ExecError::UnknownAnalyzer {
requested: analyzer.to_owned(),
known: crate::adapter::known_analyzers().join(", "),
})?;
if let SandboxProbe::Unavailable(reason) = sandbox_probe() {
return Err(SandboxError::Unavailable { reason }.into());
}
let image = resolve_image(analyzer, declared)?;
let assets = assets::resolve(assets_root, analyzer)?;
let guest_assets = assets
.iter()
.map(|(id, host)| {
let relative = host.strip_prefix(assets_root).unwrap_or(host.as_path());
(*id, Path::new(GUEST_ASSETS).join(relative))
})
.collect();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SandboxError::Runtime {
stage: "runtime",
message: e.to_string(),
})?;
Ok(Self {
adapter,
image,
assets,
guest_assets,
assets_root: assets_root.to_path_buf(),
runtime,
})
}
#[must_use]
pub fn adapter(&self) -> &'static dyn Adapter {
self.adapter
}
#[must_use]
pub fn image(&self) -> &ResolvedImage {
&self.image
}
#[must_use]
pub fn invocation(&self) -> Invocation {
self.adapter.command(&AssetPaths::new(&self.guest_assets))
}
fn rules_digest(&self) -> Option<String> {
self.assets.iter().find_map(|(id, _)| {
let spec = assets::asset(id)?;
(spec.kind == assets::AssetKind::Rules)
.then(|| assets::installed(&self.assets_root, spec).map(|record| record.digest))
.flatten()
})
}
fn boxlite_home(&self) -> PathBuf {
self.assets_root.join("boxlite-home")
}
fn open_runtime(&self) -> Result<BoxliteRuntime, SandboxError> {
BoxliteRuntime::new(BoxliteOptions {
home_dir: self.boxlite_home(),
image_registries: Vec::new(),
})
.map_err(|e| SandboxError::Runtime {
stage: "open",
message: e.to_string(),
})
}
}
impl AnalyzerRunner for BoxliteRunner {
fn kind(&self) -> RunnerKind {
RunnerKind::Sandboxed
}
fn isolation(&self) -> Isolation {
Isolation::MicroVm
}
fn run(&self, request: &AnalysisRequest) -> Result<AnalysisResponse, ExecError> {
check_request(request)?;
let invocation = self.invocation();
let started_at = rfc3339_utc(std::time::SystemTime::now());
let output = self.runtime.block_on(self.execute(&invocation, request))?;
let ended_at = rfc3339_utc(std::time::SystemTime::now());
let snippets = WorktreeSnippets::new(&request.worktree.path);
let ctx = NativeContext {
started_at,
ended_at,
analyzer_version: self.image.analyzer_version.clone(),
exit_status: output.status,
source: &request.source,
rules_digest: self.rules_digest(),
advisory_db: assets::advisory_db_evidence(&self.assets_root, &request.analyzer),
worktree: Some(Path::new(GUEST_WORKTREE)),
snippets: &snippets,
};
let mut report = self.adapter.normalize(&output.stdout, &ctx)?;
report.image_digest = Some(self.image.digest.clone());
assemble(
report,
request,
self.kind(),
self.isolation(),
&output.stdout,
)
}
}
struct Captured {
stdout: Vec<u8>,
status: i32,
}
impl BoxliteRunner {
async fn execute(
&self,
invocation: &Invocation,
request: &AnalysisRequest,
) -> Result<Captured, SandboxError> {
let runtime = self.open_runtime()?;
self.require_image(&runtime).await?;
let options = BoxOptions {
cpus: Some(GUEST_CPUS),
memory_mib: Some(GUEST_MEMORY_MIB),
rootfs: RootfsSpec::Image(self.image.reference.clone()),
network: NetworkSpec::Disabled,
volumes: vec![
VolumeSpec {
host_path: request.worktree.path.to_string_lossy().into_owned(),
guest_path: GUEST_WORKTREE.to_owned(),
read_only: true,
},
VolumeSpec {
host_path: self.assets_root.to_string_lossy().into_owned(),
guest_path: GUEST_ASSETS.to_owned(),
read_only: true,
},
],
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 = runtime
.create(options, None)
.await
.map_err(|e| SandboxError::Runtime {
stage: "create",
message: e.to_string(),
})?;
let result = self.exec_in(&boxed, invocation).await;
let stopped = boxed.stop().await;
let shutdown = runtime.shutdown(Some(10)).await;
let captured = result?;
stopped.map_err(|e| SandboxError::Runtime {
stage: "stop",
message: e.to_string(),
})?;
shutdown.map_err(|e| SandboxError::Runtime {
stage: "shutdown",
message: e.to_string(),
})?;
Ok(captured)
}
async fn require_image(&self, runtime: &BoxliteRuntime) -> Result<(), SandboxError> {
if image_present(runtime, &self.image).await? {
Ok(())
} else {
Err(SandboxError::ImageNotProvisioned {
analyzer: self.adapter.analyzer().to_owned(),
reference: self.image.reference.clone(),
})
}
}
async fn exec_in(
&self,
boxed: &LiteBox,
invocation: &Invocation,
) -> Result<Captured, SandboxError> {
let command = BoxCommand::new(&invocation.program)
.args(invocation.args.clone())
.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 stdout = execution.stdout();
let stderr = execution.stderr();
let out = tokio::spawn(collect(stdout));
let err = tokio::spawn(collect(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(SandboxError::OutputTooLarge {
program: invocation.program.clone(),
max: MAX_OUTPUT_BYTES,
});
}
if !invocation.success_statuses.contains(&status.exit_code) {
if status.exit_code < 0 && stderr.trim().is_empty() {
return Err(SandboxError::Killed {
program: invocation.program.clone(),
signal: -status.exit_code,
memory_mib: GUEST_MEMORY_MIB,
});
}
return Err(SandboxError::UnexpectedStatus {
program: invocation.program.clone(),
status: status.exit_code,
expected: invocation
.success_statuses
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", "),
stderr: stderr_tail(&stderr),
});
}
Ok(Captured {
stdout: stdout.into_bytes(),
status: status.exit_code,
})
}
}
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 guest_environment() -> Vec<(String, String)> {
ChildEnv::default().guest_pairs()
}
fn stderr_tail(stderr: &str) -> String {
const MAX_LINES: usize = 8;
const MAX_BYTES: usize = 4_000;
let trimmed = stderr.trim_end();
if trimmed.is_empty() {
return String::new();
}
let tail: Vec<&str> = trimmed
.lines()
.rev()
.take(MAX_LINES)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let mut joined = tail.join("\n ");
if joined.len() > MAX_BYTES {
joined.truncate(MAX_BYTES);
joined.push('…');
}
format!("\n its stderr ended:\n {joined}")
}
pub fn provision_image(
analyzer: &str,
assets_root: &Path,
declared: Option<&str>,
) -> Result<String, SandboxError> {
let image = resolve_image(analyzer, declared)?;
blocking(async {
open_store(assets_root)?
.images()
.map_err(|e| SandboxError::Runtime {
stage: "images",
message: e.to_string(),
})?
.pull(&image.reference)
.await
.map_err(|e| SandboxError::Runtime {
stage: "pull",
message: e.to_string(),
})?;
Ok(image.digest.clone())
})
}
pub fn image_is_provisioned(
analyzer: &str,
assets_root: &Path,
declared: Option<&str>,
) -> Result<bool, SandboxError> {
let image = resolve_image(analyzer, declared)?;
blocking(async move { image_present(&open_store(assets_root)?, &image).await })
}
async fn image_present(
runtime: &BoxliteRuntime,
image: &ResolvedImage,
) -> Result<bool, SandboxError> {
let images = runtime
.images()
.map_err(|e| SandboxError::Runtime {
stage: "images",
message: e.to_string(),
})?
.list()
.await
.map_err(|e| SandboxError::Runtime {
stage: "images",
message: e.to_string(),
})?;
Ok(images
.iter()
.any(|i| i.id == image.digest || i.reference == image.reference))
}
pub fn pinned_digest<'a>(what: &str, reference: &'a str) -> Result<&'a str, SandboxError> {
crate::image_ref::pinned_digest(what, reference).map_err(SandboxError::from)
}
fn open_store(assets_root: &Path) -> Result<BoxliteRuntime, SandboxError> {
BoxliteRuntime::new(BoxliteOptions {
home_dir: assets_root.join("boxlite-home"),
image_registries: Vec::new(),
})
.map_err(|e| SandboxError::Runtime {
stage: "open",
message: e.to_string(),
})
}
fn blocking<T>(
body: impl std::future::Future<Output = Result<T, SandboxError>>,
) -> Result<T, SandboxError> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SandboxError::Runtime {
stage: "runtime",
message: e.to_string(),
})?
.block_on(body)
}
pub fn pull_reference(what: &str, reference: &str, assets_root: &Path) -> Result<(), SandboxError> {
pinned_digest(what, reference)?;
blocking(async {
open_store(assets_root)?
.images()
.map_err(|e| SandboxError::Runtime {
stage: "images",
message: e.to_string(),
})?
.pull(reference)
.await
.map_err(|e| SandboxError::Runtime {
stage: "pull",
message: e.to_string(),
})?;
Ok(())
})
}
pub fn reference_is_present(
what: &str,
reference: &str,
assets_root: &Path,
) -> Result<bool, SandboxError> {
let digest = pinned_digest(what, reference)?.to_owned();
blocking(async move {
let images = open_store(assets_root)?
.images()
.map_err(|e| SandboxError::Runtime {
stage: "images",
message: e.to_string(),
})?
.list()
.await
.map_err(|e| SandboxError::Runtime {
stage: "images",
message: e.to_string(),
})?;
Ok(images
.iter()
.any(|i| i.id == digest || i.reference == reference))
})
}
#[cfg(test)]
mod tests {
use super::{
GUEST_ASSETS, GUEST_WORKTREE, ImageSource, MAX_OUTPUT_BYTES, SANDBOX_IMAGES,
guest_environment, image_for, image_inventory, resolve_image, sandbox_probe, stderr_tail,
};
fn reference(name: &str) -> String {
format!("registry.example/you/{name}@sha256:{}", "b".repeat(64))
}
fn pinned_analyzer() -> &'static str {
SANDBOX_IMAGES.first().expect("a pinned image").analyzer
}
#[test]
fn every_pinned_image_is_addressed_by_its_recorded_digest() {
assert!(!SANDBOX_IMAGES.is_empty());
for image in SANDBOX_IMAGES {
assert!(
image.digest.starts_with("sha256:"),
"{} digest is not a sha256 reference: {}",
image.analyzer,
image.digest
);
assert!(
image.reference.ends_with(image.digest),
"{} is not pinned to the digest it records: {} vs {}",
image.analyzer,
image.reference,
image.digest
);
assert!(
!image.reference.contains(':') || image.reference.contains('@'),
"{} must be pinned by digest, not by tag: {}",
image.analyzer,
image.reference
);
assert!(
!image.analyzer_version.is_empty(),
"{} does not say what version it carries",
image.analyzer
);
}
}
#[test]
fn an_undeclared_analyzer_resolves_to_its_built_in_pin() {
for image in SANDBOX_IMAGES {
let resolved = resolve_image(image.analyzer, None).expect("a pinned analyzer");
assert_eq!(resolved.reference, image.reference);
assert_eq!(resolved.digest, image.digest);
assert_eq!(resolved.source, ImageSource::BuiltIn);
assert_eq!(
resolved.analyzer_version.as_deref(),
Some(image.analyzer_version),
"a pinned image states what it carries, and that is why it is pinned"
);
}
}
#[test]
fn a_declared_image_gives_an_unpinned_analyzer_a_sandbox() {
let unpinned = crate::adapter::known_analyzers()
.into_iter()
.find(|a| image_for(a).is_none())
.expect("an analyzer with no built-in image — the whole premise of #434");
let refusal = resolve_image(unpinned, None).expect_err("no image, no sandbox");
let message = refusal.to_string();
assert!(message.contains("[security.images]"), "{message}");
assert!(message.contains(unpinned), "{message}");
assert!(message.contains("security ingest"), "{message}");
let declared = reference(unpinned);
let resolved = resolve_image(unpinned, Some(&declared)).expect("declared");
assert_eq!(resolved.reference, declared);
assert_eq!(resolved.digest, format!("sha256:{}", "b".repeat(64)));
assert_eq!(resolved.source, ImageSource::UserDeclared);
}
#[test]
fn a_declared_image_replaces_a_built_in_pin_and_stops_asserting_its_version() {
let analyzer = pinned_analyzer();
let pinned = resolve_image(analyzer, None).expect("pinned");
assert!(pinned.analyzer_version.is_some());
let declared = reference(analyzer);
let resolved = resolve_image(analyzer, Some(&declared)).expect("declared");
assert_eq!(resolved.reference, declared);
assert_ne!(resolved.reference, pinned.reference);
assert_eq!(
resolved.source,
ImageSource::Overrides,
"replacing a pin is its own state, because it is the case somebody might not have meant"
);
assert_eq!(
resolved.analyzer_version, None,
"Roteiro must not restate a table's version for an image it did not choose"
);
assert!(resolved.source.as_str().contains("replaces"));
assert!(!resolved.source.is_built_in());
}
#[test]
fn a_declared_tag_is_refused_and_names_its_own_key() {
let analyzer = pinned_analyzer();
let err = resolve_image(analyzer, Some("registry.example/you/thing:latest"))
.expect_err("a tag must not be accepted from config either");
let message = err.to_string();
assert!(
message.contains(&format!("`[security.images] {analyzer}`")),
"{message}"
);
assert!(
message.contains("registry.example/you/thing:latest"),
"{message}"
);
assert!(message.contains("@sha256:"), "{message}");
}
#[test]
fn a_declared_image_for_an_analyzer_with_no_adapter_is_refused() {
let declared = reference("my-favourite-linter");
let err = resolve_image("my-favourite-linter", Some(&declared))
.expect_err("no adapter, no findings");
let message = err.to_string();
assert!(message.contains("my-favourite-linter"), "{message}");
assert!(message.contains("adapter"), "{message}");
assert!(!message.contains("tag rather than a digest"), "{message}");
assert!(message.contains("security ingest"), "{message}");
let tagged =
resolve_image("my-favourite-linter", Some("x:latest")).expect_err("still no adapter");
assert!(tagged.to_string().contains("adapter"), "{tagged}");
}
#[test]
fn the_inventory_is_the_union_of_the_table_and_the_declarations() {
let analyzer = pinned_analyzer();
let unpinned = crate::adapter::known_analyzers()
.into_iter()
.find(|a| image_for(a).is_none())
.expect("an analyzer with no built-in image");
let declared = vec![(unpinned.to_owned(), reference(unpinned))];
let inventory = image_inventory(&declared).expect("valid");
let names: Vec<&str> = inventory.iter().map(|i| i.analyzer.as_str()).collect();
assert!(names.contains(&analyzer), "the pin survives: {names:?}");
assert!(
names.contains(&unpinned),
"the declaration joins: {names:?}"
);
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(
names, sorted,
"a status listing must not reorder run to run"
);
let bad = vec![("nonesuch".to_owned(), reference("nonesuch"))];
let err = image_inventory(&bad).expect_err("an unknown analyzer is a refusal");
assert!(err.to_string().contains("nonesuch"), "{err}");
assert_eq!(
image_inventory(&[]).expect("no declarations").len(),
SANDBOX_IMAGES.len(),
"with nothing declared the inventory is exactly the table"
);
}
#[test]
fn the_image_registry_answers_for_every_analyzer_it_lists() {
for image in SANDBOX_IMAGES {
assert_eq!(image_for(image.analyzer).expect("registered"), image);
}
assert!(image_for("no-such-analyzer").is_none());
}
#[test]
fn the_guest_environment_carries_no_ambient_credentials() {
let env = guest_environment();
let names: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
for secret in [
"GITHUB_TOKEN",
"AWS_ACCESS_KEY_ID",
"SEMGREP_APP_TOKEN",
"SSH_AUTH_SOCK",
"HOME",
"PATH",
] {
assert!(!names.contains(&secret), "{secret} reaches the guest");
}
assert!(names.contains(&"LC_ALL"));
}
#[test]
fn the_guest_mount_points_are_absolute_and_distinct() {
assert!(GUEST_WORKTREE.starts_with('/'));
assert!(GUEST_ASSETS.starts_with('/'));
assert_ne!(GUEST_WORKTREE, GUEST_ASSETS);
assert!(!GUEST_ASSETS.starts_with(&format!("{GUEST_WORKTREE}/")));
assert!(!GUEST_WORKTREE.starts_with(&format!("{GUEST_ASSETS}/")));
}
#[cfg(feature = "exec-subprocess")]
#[test]
fn both_backends_bound_analyzer_output_identically() {
assert_eq!(MAX_OUTPUT_BYTES, crate::subprocess::MAX_OUTPUT_BYTES);
}
#[test]
fn a_failure_message_carries_a_bounded_tail_of_stderr() {
assert_eq!(stderr_tail(""), "");
assert_eq!(stderr_tail(" \n "), "");
assert!(stderr_tail("line1\nline2\nline3").contains("line3"));
let noisy: Vec<String> = (0..500).map(|i| format!("line {i}")).collect();
let tail = stderr_tail(&noisy.join("\n"));
assert!(tail.contains("line 499"), "the tail must be the end");
assert!(!tail.contains("line 100"), "and not the whole thing");
}
#[test]
fn the_capability_probe_always_answers() {
let probe = sandbox_probe();
match &probe {
super::SandboxProbe::Available => assert!(probe.reason().is_none()),
super::SandboxProbe::Unavailable(why) => {
assert!(!why.is_empty(), "an unavailable sandbox must say why");
assert_eq!(probe.reason(), Some(why.as_str()));
}
}
}
#[test]
fn an_unavailable_sandbox_always_gives_a_printable_reason() {
assert_eq!(super::probe_reason("no /dev/kvm"), "no /dev/kvm");
assert_eq!(super::probe_reason(" padded "), "padded");
for empty in ["", " ", "\n\t "] {
let reason = super::probe_reason(empty);
assert!(
!reason.trim().is_empty(),
"an empty probe failure must still print something"
);
assert!(reason.contains("without giving a reason"), "{reason}");
}
}
}