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,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AssetKind {
Rules,
AdvisoryDb,
}
impl AssetKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Rules => "rules",
Self::AdvisoryDb => "advisory-db",
}
}
}
#[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)",
},
];
#[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>,
}
fn root_from(
security_root: Option<PathBuf>,
roteiro_home: Option<PathBuf>,
home: Option<PathBuf>,
) -> PathBuf {
if let Some(dir) = security_root {
return dir;
}
if let Some(dir) = roteiro_home {
return dir.join("security");
}
home.unwrap_or_else(|| PathBuf::from("."))
.join(".roteiro")
.join("security")
}
#[must_use]
pub fn asset_root() -> PathBuf {
root_from(
std::env::var_os("ROTEIRO_SECURITY_ASSETS").map(PathBuf::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),
)
}
#[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(_) => dir.join(spec.file),
AssetSource::External { .. } => 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 cache I/O at {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("asset record: {0}")]
Record(#[from] serde_json::Error),
}
pub fn provision(root: &Path, spec: &AssetSpec) -> 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))
}
};
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)
}
#[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(_) => Some(sha256_hex(&std::fs::read(target).ok()?)),
AssetSource::External { .. } => 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, asset, asset_path, assets_for, installed,
provision, resolve, root_from, 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 {
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_cache_root_prefers_the_explicit_override_then_roteiro_home() {
assert_eq!(
root_from(
Some("/explicit".into()),
Some("/home/.roteiro".into()),
None
),
PathBuf::from("/explicit")
);
assert_eq!(
root_from(None, Some("/home/.roteiro".into()), None),
PathBuf::from("/home/.roteiro/security")
);
assert_eq!(
root_from(None, None, Some("/home/me".into())),
PathBuf::from("/home/me/.roteiro/security")
);
}
#[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 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()
);
}
}