use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::adapter::adapter_for;
use crate::clock::{age_in_days, rfc3339_utc};
use crate::runner::ExecError;
use crate::sha256_hex;
pub const BASELINE_RULES: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/rules/roteiro-baseline.yml"
));
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AssetSource {
Vendored(&'static [u8]),
External {
hint: &'static str,
},
Download {
files: &'static [DownloadFile],
},
PinnedArchive {
archives: &'static [crate::runtime_pins::PinnedArchive],
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DownloadFile {
pub path: &'static str,
pub url: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AssetKind {
Rules,
AdvisoryDb,
SandboxRuntime,
}
impl AssetKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Rules => "rules",
Self::AdvisoryDb => "advisory-db",
Self::SandboxRuntime => "sandbox-runtime",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct AssetSpec {
pub id: &'static str,
pub analyzer: &'static str,
pub kind: AssetKind,
pub source: AssetSource,
pub file: &'static str,
pub licence: &'static str,
}
pub static ASSETS: &[AssetSpec] = &[
AssetSpec {
id: crate::adapter::semgrep::RULES_ASSET,
analyzer: crate::adapter::semgrep::ANALYZER,
kind: AssetKind::Rules,
source: AssetSource::Vendored(BASELINE_RULES),
file: "roteiro-baseline.yml",
licence: "MIT OR Apache-2.0 (written for this repository)",
},
AssetSpec {
id: crate::adapter::cargo_audit::ADVISORY_DB_ASSET,
analyzer: crate::adapter::cargo_audit::ANALYZER,
kind: AssetKind::AdvisoryDb,
source: AssetSource::External {
hint: "git clone --depth 1 https://github.com/RustSec/advisory-db \
~/.roteiro/security/rustsec-advisory-db/db",
},
file: "",
licence: "CC0-1.0 (RustSec advisory database)",
},
AssetSpec {
id: crate::adapter::osv_scanner::DB_ASSET,
analyzer: crate::adapter::osv_scanner::ANALYZER,
kind: AssetKind::AdvisoryDb,
source: AssetSource::Download {
files: OSV_DATABASES,
},
file: "",
licence: "per-record, as published by OSV.dev \
(CC0-1.0 for RustSec, CC-BY-4.0 for the GitHub Advisory Database)",
},
AssetSpec {
id: crate::runtime_pins::RUNTIME_ASSET,
analyzer: SANDBOX,
kind: AssetKind::SandboxRuntime,
source: AssetSource::PinnedArchive {
archives: crate::runtime_pins::RUNTIME_ARCHIVES,
},
file: crate::runtime_pins::RUNTIME_FILE,
licence: "mixed: Apache-2.0 (boxlite-shim, boxlite-guest), \
GPL-2.0 (mke2fs, debugfs, libkrunfw), \
LGPL-2.0-or-later (bwrap) — see NOTICE-boxlite-runtime.md",
},
];
pub const SANDBOX: &str = "sandbox";
pub static OSV_DATABASES: &[DownloadFile] = &[
DownloadFile {
path: "osv-scalibr/crates.io/all.zip",
url: "https://osv-vulnerabilities.storage.googleapis.com/crates.io/all.zip",
},
DownloadFile {
path: "osv-scalibr/PyPI/all.zip",
url: "https://osv-vulnerabilities.storage.googleapis.com/PyPI/all.zip",
},
DownloadFile {
path: "osv-scalibr/Maven/all.zip",
url: "https://osv-vulnerabilities.storage.googleapis.com/Maven/all.zip",
},
DownloadFile {
path: "osv-scalibr/npm/all.zip",
url: "https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip",
},
];
#[must_use]
pub fn asset(id: &str) -> Option<&'static AssetSpec> {
ASSETS.iter().find(|a| a.id == id)
}
#[must_use]
pub fn assets_for(analyzer: &str) -> Vec<&'static AssetSpec> {
adapter_for(analyzer)
.map(|adapter| {
adapter
.asset_ids()
.iter()
.filter_map(|id| asset(id))
.collect()
})
.unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstalledAsset {
pub id: String,
pub kind: AssetKind,
pub digest: String,
pub fetched_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub files: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub published_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AssetStatus {
pub id: &'static str,
pub analyzer: &'static str,
pub kind: AssetKind,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installed: Option<InstalledAsset>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub age_days: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verified: Option<bool>,
}
pub use crate::asset_paths::asset_root;
#[must_use]
pub fn asset_dir(root: &Path, spec: &AssetSpec) -> PathBuf {
root.join(spec.id)
}
#[must_use]
pub fn asset_path(root: &Path, spec: &AssetSpec) -> PathBuf {
let dir = asset_dir(root, spec);
match spec.source {
AssetSource::Vendored(_) | AssetSource::PinnedArchive { .. } => dir.join(spec.file),
AssetSource::External { .. } | AssetSource::Download { .. } => dir.join("db"),
}
}
fn record_path(root: &Path, spec: &AssetSpec) -> PathBuf {
asset_dir(root, spec).join("installed.json")
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AssetError {
#[error(
"asset {id:?} is not provisioned: expected a directory at {path}\n \
obtain it with: {hint}\n \
then run: roteiro security prefetch --analyzer {analyzer}"
)]
ExternalMissing {
id: &'static str,
path: String,
hint: &'static str,
analyzer: &'static str,
},
#[error("unknown asset {0:?}")]
Unknown(String),
#[error(
"asset {id:?} is not provisioned and this code path does not download \
({files} file(s), starting with {first})\n \
fetch it with: roteiro security prefetch --analyzer {analyzer}"
)]
FetchNotPermitted {
id: &'static str,
files: usize,
first: &'static str,
analyzer: &'static str,
},
#[error("downloading {url} for asset {id:?}: {message}")]
Fetch {
id: &'static str,
url: &'static str,
message: String,
},
#[error("asset {id:?} declares an unsafe install path {path:?}")]
UnsafeInstallPath {
id: &'static str,
path: &'static str,
},
#[error(
"asset {id:?} has no pinned archive for this host ({os}/{arch}); \
pinned platforms are: {supported}"
)]
UnsupportedPlatform {
id: &'static str,
os: &'static str,
arch: &'static str,
supported: String,
},
#[error(
"asset {id:?} ({target}) is not provisioned and this code path does not download\n \
expected at: {path}\n \
fetch it with: roteiro security prefetch --allow-download"
)]
ArchiveMissing {
id: &'static str,
target: &'static str,
path: String,
},
#[error(
"asset {id:?} does not match its pinned digest — refusing it\n \
from: {url}\n \
expected: {expected} ({expected_bytes} bytes)\n \
actual: {actual} ({actual_bytes} bytes)"
)]
DigestMismatch {
id: &'static str,
url: String,
expected: &'static str,
expected_bytes: u64,
actual: String,
actual_bytes: u64,
},
#[error("asset cache I/O at {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("asset record: {0}")]
Record(#[from] serde_json::Error),
}
pub type Fetcher<'a> = dyn Fn(&str, &Path) -> Result<(), String> + 'a;
pub fn provision(root: &Path, spec: &AssetSpec) -> Result<InstalledAsset, AssetError> {
provision_with(root, spec, None)
}
pub fn provision_with(
root: &Path,
spec: &AssetSpec,
fetch: Option<&Fetcher<'_>>,
) -> Result<InstalledAsset, AssetError> {
let dir = asset_dir(root, spec);
std::fs::create_dir_all(&dir).map_err(|source| AssetError::Io {
path: dir.display().to_string(),
source,
})?;
let target = asset_path(root, spec);
let (digest, files) = match spec.source {
AssetSource::Vendored(bytes) => {
write_atomically(&target, bytes)?;
(sha256_hex(bytes), None)
}
AssetSource::External { hint } => {
if !target.is_dir() {
return Err(AssetError::ExternalMissing {
id: spec.id,
path: target.display().to_string(),
hint,
analyzer: spec.analyzer,
});
}
let (digest, count) = digest_tree(&target)?;
(digest, Some(count))
}
AssetSource::Download { files } => {
download_all(spec, files, &target, fetch)?;
let (digest, count) = digest_tree(&target)?;
(digest, Some(count))
}
AssetSource::PinnedArchive { archives } => {
let digest = provision_archive(spec, archives, &target, fetch)?;
(digest, None)
}
};
let published_at = published_at(&target);
let record = InstalledAsset {
id: spec.id.to_owned(),
kind: spec.kind,
digest,
fetched_at: rfc3339_utc(std::time::SystemTime::now()),
files,
published_at,
};
let json = serde_json::to_vec_pretty(&record)?;
write_atomically(&record_path(root, spec), &json)?;
Ok(record)
}
fn download_all(
spec: &AssetSpec,
files: &'static [DownloadFile],
target: &Path,
fetch: Option<&Fetcher<'_>>,
) -> Result<(), AssetError> {
for file in files {
if !is_safe_relative(file.path) {
return Err(AssetError::UnsafeInstallPath {
id: spec.id,
path: file.path,
});
}
}
let missing: Vec<&DownloadFile> = files
.iter()
.filter(|file| !target.join(file.path).is_file())
.collect();
if missing.is_empty() {
return Ok(());
}
let Some(fetch) = fetch else {
return Err(AssetError::FetchNotPermitted {
id: spec.id,
files: missing.len(),
first: missing[0].url,
analyzer: spec.analyzer,
});
};
for file in missing {
let destination = target.join(file.path);
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent).map_err(|source| AssetError::Io {
path: parent.display().to_string(),
source,
})?;
}
let partial = destination.with_extension("partial");
std::fs::remove_file(&partial).ok();
fetch(file.url, &partial).map_err(|message| {
std::fs::remove_file(&partial).ok();
AssetError::Fetch {
id: spec.id,
url: file.url,
message,
}
})?;
std::fs::rename(&partial, &destination).map_err(|source| {
std::fs::remove_file(&partial).ok();
AssetError::Io {
path: destination.display().to_string(),
source,
}
})?;
}
Ok(())
}
pub fn archive_for_host(
spec: &AssetSpec,
archives: &'static [crate::runtime_pins::PinnedArchive],
) -> Result<&'static crate::runtime_pins::PinnedArchive, AssetError> {
crate::runtime_pins::runtime_target(std::env::consts::OS, std::env::consts::ARCH)
.and_then(|target| archives.iter().find(|a| a.target == target))
.ok_or_else(|| AssetError::UnsupportedPlatform {
id: spec.id,
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
supported: archives
.iter()
.map(|a| a.target)
.collect::<Vec<_>>()
.join(", "),
})
}
fn provision_archive(
spec: &AssetSpec,
archives: &'static [crate::runtime_pins::PinnedArchive],
target: &Path,
fetch: Option<&Fetcher<'_>>,
) -> Result<String, AssetError> {
let archive = archive_for_host(spec, archives)?;
if target.is_file() {
return verify_archive(spec, archive, target, &target.display().to_string());
}
let Some(fetch) = fetch else {
return Err(AssetError::ArchiveMissing {
id: spec.id,
target: archive.target,
path: target.display().to_string(),
});
};
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|source| AssetError::Io {
path: parent.display().to_string(),
source,
})?;
}
let partial = target.with_extension("partial");
std::fs::remove_file(&partial).ok();
fetch(archive.url, &partial).map_err(|message| {
std::fs::remove_file(&partial).ok();
AssetError::Fetch {
id: spec.id,
url: archive.url,
message,
}
})?;
let digest = match verify_archive(spec, archive, &partial, archive.url) {
Ok(digest) => digest,
Err(e) => {
std::fs::remove_file(&partial).ok();
return Err(e);
}
};
std::fs::rename(&partial, target).map_err(|source| {
std::fs::remove_file(&partial).ok();
AssetError::Io {
path: target.display().to_string(),
source,
}
})?;
Ok(digest)
}
pub fn verify_archive(
spec: &AssetSpec,
archive: &crate::runtime_pins::PinnedArchive,
path: &Path,
origin: &str,
) -> Result<String, AssetError> {
let bytes = std::fs::read(path).map_err(|source| AssetError::Io {
path: path.display().to_string(),
source,
})?;
let digest = sha256_hex(&bytes);
let actual_bytes = bytes.len() as u64;
if digest != archive.sha256 || actual_bytes != archive.bytes {
return Err(AssetError::DigestMismatch {
id: spec.id,
url: origin.to_owned(),
expected: archive.sha256,
expected_bytes: archive.bytes,
actual: digest,
actual_bytes,
});
}
Ok(digest)
}
fn is_safe_relative(path: &str) -> bool {
!path.is_empty()
&& !Path::new(path).components().any(|component| {
matches!(
component,
std::path::Component::RootDir
| std::path::Component::Prefix(_)
| std::path::Component::ParentDir
)
})
}
#[must_use]
pub fn installed(root: &Path, spec: &AssetSpec) -> Option<InstalledAsset> {
let bytes = std::fs::read(record_path(root, spec)).ok()?;
serde_json::from_slice(&bytes).ok()
}
#[must_use]
pub fn status(root: &Path, analyzer: Option<&str>) -> Vec<AssetStatus> {
ASSETS
.iter()
.filter(|spec| analyzer.is_none_or(|name| spec.analyzer == name))
.map(|spec| {
let installed = installed(root, spec);
let now = rfc3339_utc(std::time::SystemTime::now());
let age_days = installed
.as_ref()
.and_then(|record| age_in_days(&record.fetched_at, &now));
let verified = installed.as_ref().map(|record| {
current_digest(root, spec).as_deref() == Some(record.digest.as_str())
});
AssetStatus {
id: spec.id,
analyzer: spec.analyzer,
kind: spec.kind,
path: asset_path(root, spec).display().to_string(),
installed,
age_days,
verified,
}
})
.collect()
}
fn published_at(dir: &Path) -> Option<String> {
let repo = rto_graph::Repo::discover(dir).ok()?;
if repo.workdir()? != dir {
return None;
}
let seconds = repo.head_commit_time().ok()?;
Some(rfc3339_utc(
std::time::UNIX_EPOCH + std::time::Duration::from_secs(u64::try_from(seconds).ok()?),
))
}
#[must_use]
pub fn advisory_db_evidence(root: &Path, analyzer: &str) -> Option<rto_graph::AdvisoryDb> {
let spec = assets_for(analyzer)
.into_iter()
.find(|s| s.kind == AssetKind::AdvisoryDb)?;
let record = installed(root, spec)?;
Some(rto_graph::AdvisoryDb {
digest: record.digest,
published_at: record.published_at,
})
}
fn current_digest(root: &Path, spec: &AssetSpec) -> Option<String> {
let target = asset_path(root, spec);
match spec.source {
AssetSource::Vendored(_) | AssetSource::PinnedArchive { .. } => {
Some(sha256_hex(&std::fs::read(target).ok()?))
}
AssetSource::External { .. } | AssetSource::Download { .. } => {
digest_tree(&target).ok().map(|(digest, _)| digest)
}
}
}
pub fn resolve(root: &Path, analyzer: &str) -> Result<Vec<(&'static str, PathBuf)>, ExecError> {
let specs = assets_for(analyzer);
let mut resolved = Vec::with_capacity(specs.len());
let mut missing = Vec::new();
for spec in specs {
let path = asset_path(root, spec);
match (installed(root, spec), current_digest(root, spec)) {
(Some(record), Some(digest)) if digest == record.digest => {
resolved.push((spec.id, path));
}
(Some(record), Some(_)) => missing.push(MissingAsset {
id: spec.id.to_owned(),
digest: record.digest.clone(),
reason: "the bytes on disk no longer match the recorded digest",
}),
(Some(record), None) => missing.push(MissingAsset {
id: spec.id.to_owned(),
digest: record.digest.clone(),
reason: "recorded as provisioned, but nothing is there now",
}),
(None, _) => missing.push(MissingAsset {
id: spec.id.to_owned(),
digest: "not yet pinned".to_owned(),
reason: "never provisioned",
}),
}
}
if missing.is_empty() {
Ok(resolved)
} else {
Err(ExecError::AssetsUnavailableOffline {
analyzer: analyzer.to_owned(),
missing,
command: format!("roteiro security prefetch --analyzer {analyzer}"),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MissingAsset {
pub id: String,
pub digest: String,
pub reason: &'static str,
}
impl std::fmt::Display for MissingAsset {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ({}; {})", self.id, self.digest, self.reason)
}
}
fn digest_tree(dir: &Path) -> Result<(String, usize), AssetError> {
let mut entries: BTreeMap<String, String> = BTreeMap::new();
walk(dir, dir, &mut entries)?;
let mut manifest = String::new();
for (path, digest) in &entries {
use std::fmt::Write as _;
let _ = writeln!(manifest, "{digest} {path}");
}
Ok((sha256_hex(manifest.as_bytes()), entries.len()))
}
fn walk(root: &Path, dir: &Path, into: &mut BTreeMap<String, String>) -> Result<(), AssetError> {
let read = std::fs::read_dir(dir).map_err(|source| AssetError::Io {
path: dir.display().to_string(),
source,
})?;
for entry in read {
let entry = entry.map_err(|source| AssetError::Io {
path: dir.display().to_string(),
source,
})?;
let path = entry.path();
let meta = std::fs::symlink_metadata(&path).map_err(|source| AssetError::Io {
path: path.display().to_string(),
source,
})?;
if meta.is_symlink() {
continue;
}
if meta.is_dir() {
if path.file_name().is_some_and(|n| n == ".git") {
continue;
}
walk(root, &path, into)?;
} else if meta.is_file() {
let bytes = std::fs::read(&path).map_err(|source| AssetError::Io {
path: path.display().to_string(),
source,
})?;
let relative = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
into.insert(relative, sha256_hex(&bytes));
}
}
Ok(())
}
fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), AssetError> {
let io = |source| AssetError::Io {
path: path.display().to_string(),
source,
};
let tmp = path.with_extension("partial");
std::fs::write(&tmp, bytes).map_err(io)?;
if path.exists() {
std::fs::remove_file(path).map_err(io)?;
}
std::fs::rename(&tmp, path).map_err(|source| {
std::fs::remove_file(&tmp).ok();
AssetError::Io {
path: path.display().to_string(),
source,
}
})
}
#[cfg(test)]
mod tests {
use super::{
ASSETS, AssetError, AssetKind, AssetSource, SANDBOX, asset, asset_path, assets_for,
installed, provision, resolve, status,
};
use crate::runner::ExecError;
use std::path::PathBuf;
struct Cache(PathBuf);
impl Cache {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("rto-exec-assets-{name}"));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("create");
Self(dir)
}
}
impl Drop for Cache {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).ok();
}
}
fn rules() -> &'static super::AssetSpec {
asset("semgrep-rules").expect("the baseline rule set is a known asset")
}
fn advisory_db() -> &'static super::AssetSpec {
asset("rustsec-advisory-db").expect("the advisory database is a known asset")
}
#[test]
fn every_asset_belongs_to_an_analyzer_that_asked_for_it() {
for spec in ASSETS {
if spec.analyzer == super::SANDBOX {
assert!(
assets_for(spec.analyzer).is_empty(),
"{} uses the shared-asset sentinel, so no adapter may claim it",
spec.id
);
} else {
assert!(
assets_for(spec.analyzer).iter().any(|s| s.id == spec.id),
"{} is not claimed by {}",
spec.id,
spec.analyzer
);
}
assert!(!spec.licence.is_empty(), "{} discloses no licence", spec.id);
}
}
#[test]
fn the_sandbox_analyzer_selects_the_runtime_archive_alone() {
assert!(
assets_for(SANDBOX).is_empty(),
"no adapter should declare the shared runtime; if one does, the fallback in \
run_security_prefetch is no longer what selects it"
);
let by_owner: Vec<&str> = ASSETS
.iter()
.filter(|spec| spec.analyzer == SANDBOX)
.map(|spec| spec.id)
.collect();
assert_eq!(
by_owner,
vec![crate::runtime_pins::RUNTIME_ASSET],
"`prefetch --analyzer sandbox` resolves by owner, so this is exactly what it \
provisions — it must be the runtime archive and nothing else"
);
}
#[test]
fn the_sandbox_runtime_discloses_every_licence_it_carries() {
let spec = asset(crate::runtime_pins::RUNTIME_ASSET).expect("the runtime is a known asset");
assert_eq!(spec.kind, AssetKind::SandboxRuntime);
for family in ["Apache-2.0", "GPL-2.0", "LGPL-2.0"] {
assert!(
spec.licence.contains(family),
"the disclosure does not mention {family}: {}",
spec.licence
);
}
assert!(
spec.licence.contains("NOTICE-boxlite-runtime.md"),
"the disclosure must point at the full record: {}",
spec.licence
);
}
#[test]
fn every_pinned_archive_is_complete_and_reachable() {
use crate::runtime_pins::{RUNTIME_ARCHIVES, archive_for, runtime_target};
assert!(!RUNTIME_ARCHIVES.is_empty());
for archive in RUNTIME_ARCHIVES {
assert_eq!(
archive.sha256.len(),
64,
"{} has no full sha256",
archive.target
);
assert!(
archive
.sha256
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()),
"{} digest must be lowercase hex",
archive.target
);
assert!(
archive.bytes > 1_000_000,
"{} size looks wrong",
archive.target
);
assert!(
archive.url.ends_with(".tar.gz") && archive.url.contains(archive.target),
"{} url does not name the target it is for: {}",
archive.target,
archive.url
);
}
for (os, arch) in [
("macos", "aarch64"),
("linux", "x86_64"),
("linux", "aarch64"),
] {
let target = runtime_target(os, arch).expect("a pinned platform");
let archive = archive_for(os, arch).expect("must resolve to an archive");
assert_eq!(archive.target, target);
}
assert!(runtime_target("windows", "x86_64").is_none());
assert!(archive_for("windows", "x86_64").is_none());
}
#[test]
fn a_pinned_archive_that_does_not_match_is_refused() {
use crate::runtime_pins::PinnedArchive;
let cache = Cache::new("pinned-mismatch");
let spec = asset(crate::runtime_pins::RUNTIME_ASSET).expect("known asset");
let archive = PinnedArchive {
target: "test-target",
url: "https://example.invalid/runtime.tar.gz",
sha256: "0000000000000000000000000000000000000000000000000000000000000000",
bytes: 999,
};
let path = cache.0.join("impostor.tar.gz");
std::fs::write(&path, b"not the pinned bytes").expect("write");
let err = super::verify_archive(spec, &archive, &path, archive.url)
.expect_err("bytes that do not match the pin must be refused");
let message = err.to_string();
assert!(matches!(err, AssetError::DigestMismatch { .. }));
assert!(message.contains(archive.sha256), "{message}");
assert!(message.contains("999 bytes"), "{message}");
assert!(message.contains("20 bytes"), "{message}");
}
#[test]
fn a_pinned_archive_is_not_fetched_by_a_path_that_may_not_download() {
let cache = Cache::new("pinned-cold");
let spec = asset(crate::runtime_pins::RUNTIME_ASSET).expect("known asset");
let err = provision(&cache.0, spec).expect_err("a cold cache must refuse");
let message = err.to_string();
match err {
AssetError::ArchiveMissing { .. } => {
assert!(message.contains("prefetch --allow-download"), "{message}");
}
AssetError::UnsupportedPlatform { .. } => {
assert!(message.contains("pinned platforms are"), "{message}");
}
other => panic!("unexpected refusal: {other}"),
}
}
fn pinned_to(body: &[u8]) -> Option<&'static super::AssetSpec> {
let target =
crate::runtime_pins::runtime_target(std::env::consts::OS, std::env::consts::ARCH)?;
let archives: &'static [crate::runtime_pins::PinnedArchive] =
Box::leak(Box::new([crate::runtime_pins::PinnedArchive {
target,
url: "https://example.invalid/runtime.tar.gz",
sha256: Box::leak(crate::sha256_hex(body).into_boxed_str()),
bytes: body.len() as u64,
}]));
Some(Box::leak(Box::new(super::AssetSpec {
id: "test-pinned-archive",
analyzer: super::SANDBOX,
kind: AssetKind::SandboxRuntime,
source: AssetSource::PinnedArchive { archives },
file: "fixture.tar.gz",
licence: "test fixture",
})))
}
#[test]
fn a_warm_pinned_archive_provisions_offline_and_is_still_verified() {
let body = b"pretend this is a runtime archive".to_vec();
let Some(spec) = pinned_to(&body) else {
eprintln!(
"SKIPPED: no sandbox runtime is pinned for {}/{}",
std::env::consts::OS,
std::env::consts::ARCH
);
return;
};
let cache = Cache::new("pinned-warm");
let target = asset_path(&cache.0, spec);
std::fs::create_dir_all(target.parent().expect("parent")).expect("mkdir");
std::fs::write(&target, b"tampered").expect("write");
let err = provision(&cache.0, spec).expect_err("a warm cache is still verified");
assert!(matches!(err, AssetError::DigestMismatch { .. }), "{err}");
std::fs::write(&target, &body).expect("write");
let record = provision(&cache.0, spec).expect("a matching warm cache provisions offline");
assert_eq!(record.kind, AssetKind::SandboxRuntime);
assert_eq!(record.digest, crate::sha256_hex(&body));
assert_eq!(
super::current_digest(&cache.0, spec).as_deref(),
Some(record.digest.as_str())
);
}
#[test]
fn a_lying_fetcher_cannot_install_a_pinned_archive() {
let body = b"the real runtime archive".to_vec();
let Some(spec) = pinned_to(&body) else {
eprintln!("SKIPPED: no sandbox runtime is pinned for this platform");
return;
};
let cache = Cache::new("pinned-lying-fetcher");
let liar: &super::Fetcher<'_> = &|_url: &str, dest: &std::path::Path| {
std::fs::write(dest, b"truncated").map_err(|e| e.to_string())
};
let err = super::provision_with(&cache.0, spec, Some(liar))
.expect_err("bytes that do not match the pin must be refused");
assert!(matches!(err, AssetError::DigestMismatch { .. }), "{err}");
let target = asset_path(&cache.0, spec);
assert!(!target.exists(), "a refused archive must not be installed");
assert!(
!target.with_extension("partial").exists(),
"staging file left behind"
);
let honest: &super::Fetcher<'_> = &|_url: &str, dest: &std::path::Path| {
std::fs::write(dest, b"the real runtime archive").map_err(|e| e.to_string())
};
let record = super::provision_with(&cache.0, spec, Some(honest)).expect("provision");
assert_eq!(record.digest, crate::sha256_hex(&body));
}
#[test]
fn provisioning_a_vendored_asset_installs_and_records_it() {
let cache = Cache::new("vendored");
let record = provision(&cache.0, rules()).expect("provision");
assert_eq!(record.kind, AssetKind::Rules);
assert_eq!(record.digest.len(), 64);
assert!(!record.fetched_at.is_empty());
let AssetSource::Vendored(bytes) = rules().source else {
panic!("the rule set is a vendored asset");
};
assert_eq!(
std::fs::read(asset_path(&cache.0, rules())).expect("read"),
bytes
);
assert_eq!(installed(&cache.0, rules()), Some(record));
}
#[test]
fn provisioning_is_idempotent() {
let cache = Cache::new("idempotent");
let first = provision(&cache.0, rules()).expect("first");
let second = provision(&cache.0, rules()).expect("second");
assert_eq!(first.digest, second.digest);
}
#[test]
fn a_cold_cache_fails_with_the_named_offline_error() {
let cache = Cache::new("cold");
let err = resolve(&cache.0, "semgrep").expect_err("a cold cache must fail");
let ExecError::AssetsUnavailableOffline {
analyzer,
missing,
command,
} = &err
else {
panic!("expected the offline error, got {err:?}");
};
assert_eq!(analyzer, "semgrep");
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].id, "semgrep-rules");
assert_eq!(command, "roteiro security prefetch --analyzer semgrep");
let message = err.to_string();
assert!(message.contains("assets-unavailable-offline"), "{message}");
assert!(message.contains("semgrep-rules"), "{message}");
assert!(
message.contains("roteiro security prefetch --analyzer semgrep"),
"{message}"
);
}
#[test]
fn a_warm_cache_resolves_to_the_provisioned_path() {
let cache = Cache::new("warm");
provision(&cache.0, rules()).expect("provision");
let resolved = resolve(&cache.0, "semgrep").expect("a warm cache must resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].0, "semgrep-rules");
assert_eq!(resolved[0].1, asset_path(&cache.0, rules()));
}
#[test]
fn an_asset_edited_after_provisioning_is_refused_not_warned_about() {
let cache = Cache::new("tampered");
provision(&cache.0, rules()).expect("provision");
std::fs::write(asset_path(&cache.0, rules()), b"rules: []\n").expect("tamper");
let err = resolve(&cache.0, "semgrep").expect_err("tampering must be refused");
let ExecError::AssetsUnavailableOffline { missing, .. } = &err else {
panic!("expected the offline error");
};
assert!(
missing[0].reason.contains("no longer match"),
"{}",
missing[0].reason
);
}
#[test]
fn an_absent_external_asset_is_explained_never_fetched() {
let cache = Cache::new("external");
let err = provision(&cache.0, advisory_db()).expect_err("must not be fetched");
let AssetError::ExternalMissing { hint, analyzer, .. } = &err else {
panic!("expected ExternalMissing, got {err:?}");
};
assert_eq!(*analyzer, "cargo-audit");
assert!(hint.contains("advisory-db"), "{hint}");
assert!(
err.to_string().contains("roteiro security prefetch"),
"{err}"
);
}
#[test]
fn a_directory_asset_is_digested_by_content_not_by_layout() {
let cache = Cache::new("tree");
let db = asset_path(&cache.0, advisory_db());
std::fs::create_dir_all(db.join("crates/openssl")).expect("create");
std::fs::write(db.join("crates/openssl/RUSTSEC-2026-0031.md"), b"a").expect("write");
std::fs::write(db.join("README.md"), b"b").expect("write");
let first = provision(&cache.0, advisory_db()).expect("provision");
assert_eq!(first.files, Some(2));
std::fs::create_dir_all(db.join(".git")).expect("create");
std::fs::write(db.join(".git/HEAD"), b"ref: refs/heads/main").expect("write");
assert_eq!(
provision(&cache.0, advisory_db()).expect("again").digest,
first.digest
);
std::fs::write(db.join("README.md"), b"c").expect("write");
assert_ne!(
provision(&cache.0, advisory_db()).expect("third").digest,
first.digest
);
}
#[test]
fn status_reports_what_is_provisioned_and_what_is_not() {
let cache = Cache::new("status");
let cold = status(&cache.0, Some("semgrep"));
assert_eq!(cold.len(), 1);
assert!(cold[0].installed.is_none());
assert!(cold[0].verified.is_none());
assert!(cold[0].age_days.is_none());
provision(&cache.0, rules()).expect("provision");
let warm = status(&cache.0, Some("semgrep"));
assert_eq!(warm[0].verified, Some(true));
assert_eq!(warm[0].age_days, Some(0));
assert_eq!(warm[0].installed.as_ref().map(|r| r.digest.len()), Some(64));
std::fs::write(asset_path(&cache.0, rules()), b"rules: []\n").expect("tamper");
assert_eq!(status(&cache.0, Some("semgrep"))[0].verified, Some(false));
}
#[test]
fn status_covers_every_analyzer_when_none_is_named() {
let cache = Cache::new("status-all");
assert_eq!(status(&cache.0, None).len(), ASSETS.len());
assert!(status(&cache.0, Some("no-such-analyzer")).is_empty());
}
#[test]
fn a_download_path_that_escapes_the_asset_directory_is_refused() {
static ESCAPING: &[super::DownloadFile] = &[super::DownloadFile {
path: "../../outside.zip",
url: "https://example.invalid/outside.zip",
}];
let cache = Cache::new("escape");
let spec = super::AssetSpec {
id: "escaping-asset",
analyzer: "osv-scanner",
kind: AssetKind::AdvisoryDb,
source: AssetSource::Download { files: ESCAPING },
file: "",
licence: "n/a",
};
let fetched = std::cell::Cell::new(false);
let fetch = |_: &str, _: &std::path::Path| {
fetched.set(true);
Ok(())
};
let err = super::provision_with(&cache.0, &spec, Some(&fetch))
.expect_err("an escaping path must be refused");
assert!(
matches!(err, AssetError::UnsafeInstallPath { .. }),
"{err:?}"
);
assert!(
!fetched.get(),
"the path is checked before anything is fetched"
);
}
#[test]
fn an_unknown_analyzer_needs_nothing() {
assert!(assets_for("no-such-analyzer").is_empty());
let cache = Cache::new("unknown");
assert!(
resolve(&cache.0, "no-such-analyzer")
.expect("no assets")
.is_empty()
);
}
}