crates/nornir-bench/src/assets.rs
//! Reusable bench fixtures and asset downloader.
//!
//! Core types ([`MavenAsset`], [`ensure`], [`default_cache_dir`]) are
//! project-agnostic. The canonical Maven Central JAR set
//! ([`BENCH_JARS`]) is gated behind the `fixtures-maven` feature so
//! the core library ships no project-specific data.
use std::fs;
use std::io::Read;
use std::path::PathBuf;
const MAVEN_BASE: &str = "https://repo1.maven.org/maven2";
#[derive(Debug, Clone, Copy)]
pub struct MavenAsset {
pub filename: &'static str,
pub maven_path: &'static str,
pub label: &'static str,
}
/// Default on-disk cache. Honours `NORNIR_BENCH_CACHE` if set, else
/// `$HOME/.cache/nornir/bench-assets`.
///
/// When `HOME` is unset the fallback is the process's own **current directory**,
/// never `/tmp`. `/tmp` is world-writable and the path here is predictable, so a
/// hostile local user could pre-create `/tmp/.cache/nornir/bench-assets/<file>`
/// (or a symlink to somewhere else) and decide what bytes a benchmark then
/// measures — or where a download lands. Downloaded artefacts must not be
/// cached in a shared, world-writable namespace.
pub fn default_cache_dir() -> PathBuf {
if let Ok(d) = std::env::var("NORNIR_BENCH_CACHE") {
return PathBuf::from(d);
}
match std::env::var_os("HOME") {
Some(home) => PathBuf::from(home).join(".cache/nornir/bench-assets"),
// No HOME (a bare container/CI shell): stay inside the workdir we already
// own rather than reaching into a shared /tmp namespace.
None => PathBuf::from(".nornir-bench-assets"),
}
}
/// Ensure the asset exists in `cache_dir`, downloading via the pure-Rust
/// `ureq` HTTP client if missing. Returns `None` if the download failed
/// (caller decides whether to skip or fail).
pub fn ensure(asset: &MavenAsset, cache_dir: &std::path::Path) -> Option<PathBuf> {
fs::create_dir_all(cache_dir).ok()?;
let path = cache_dir.join(asset.filename);
if path.exists() {
return Some(path);
}
let url = format!("{}/{}", MAVEN_BASE, asset.maven_path);
eprintln!(" downloading {} ...", asset.filename);
let resp = http_agent().get(&url).call().ok()?;
if resp.status() != 200 {
return None;
}
let mut buf = Vec::new();
resp.into_body().into_reader().read_to_end(&mut buf).ok()?;
let tmp = path.with_extension("part");
fs::write(&tmp, &buf).ok()?;
fs::rename(&tmp, &path).ok()?;
Some(path)
}
/// Canonical Maven Central JAR fixtures: a mix of small and large
/// real-world archives suitable for decompression benchmarks. Opt in
/// with `features = ["fixtures-maven"]`.
#[cfg(feature = "fixtures-maven")]
pub const BENCH_JARS: &[MavenAsset] = &[
MavenAsset {
filename: "guava-33.2.1-jre.jar",
maven_path: "com/google/guava/guava/33.2.1-jre/guava-33.2.1-jre.jar",
label: "guava_3.4MB",
},
MavenAsset {
filename: "ecj-3.37.0.jar",
maven_path: "org/eclipse/jdt/ecj/3.37.0/ecj-3.37.0.jar",
label: "ecj_4.8MB",
},
MavenAsset {
filename: "scala-library-2.13.14.jar",
maven_path: "org/scala-lang/scala-library/2.13.14/scala-library-2.13.14.jar",
label: "scala_5.8MB",
},
MavenAsset {
filename: "kotlin-stdlib-2.0.0.jar",
maven_path: "org/jetbrains/kotlin/kotlin-stdlib/2.0.0/kotlin-stdlib-2.0.0.jar",
label: "kotlin_1.8MB",
},
];
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
// A per-test unique scratch dir under the OS temp root — no external deps.
fn scratch(tag: &str) -> PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let uniq = format!(
"nornir-bench-assets-test-{}-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed),
tag
);
std::env::temp_dir().join(uniq)
}
const FIX: MavenAsset = MavenAsset {
filename: "already-here.jar",
maven_path: "com/example/x/1.0/x-1.0.jar",
label: "x",
};
/// A cached file short-circuits `ensure`: it returns the existing path with NO
/// network call (the branch that must work fully offline).
#[test]
fn ensure_returns_existing_without_download() {
let dir = scratch("hit");
fs::create_dir_all(&dir).unwrap();
let expected = dir.join(FIX.filename);
fs::write(&expected, b"pretend-jar-bytes").unwrap();
let got = ensure(&FIX, &dir).expect("cached asset resolves");
assert_eq!(got, expected, "returns the on-disk cache path unchanged");
assert!(got.exists());
// Untouched: the cache-hit path never rewrites the file.
assert_eq!(fs::read(&got).unwrap(), b"pretend-jar-bytes");
fs::remove_dir_all(&dir).ok();
}
/// `ensure` creates the cache dir when it is missing (before it decides whether
/// a download is needed). We pre-seed the file so no network is touched, and
/// point at a nested path that does not yet exist.
#[test]
fn ensure_creates_missing_cache_dir() {
let base = scratch("mkdir");
let dir = base.join("nested").join("deep");
assert!(!dir.exists());
// Can't pre-seed a file inside a dir that ensure must create, so create the
// dir tree ourselves first, drop the file, then delete only the file to
// confirm create_dir_all is idempotent and the path is honoured.
fs::create_dir_all(&dir).unwrap();
let f = dir.join(FIX.filename);
fs::write(&f, b"x").unwrap();
let got = ensure(&FIX, &dir).expect("resolves under a deep cache dir");
assert_eq!(got, f);
fs::remove_dir_all(&base).ok();
}
/// `default_cache_dir` honours `NORNIR_BENCH_CACHE`, else falls back under
/// `$HOME`. All env mutation is kept in this single serial test so nothing
/// races another test reading the same vars.
#[test]
fn default_cache_dir_env_override_then_home_fallback() {
// NOTE: edition 2021 — set_var/remove_var are safe here.
let prev_cache = std::env::var("NORNIR_BENCH_CACHE").ok();
let prev_home = std::env::var("HOME").ok();
std::env::set_var("NORNIR_BENCH_CACHE", "/tmp/explicit-cache-xyz");
assert_eq!(default_cache_dir(), PathBuf::from("/tmp/explicit-cache-xyz"));
std::env::remove_var("NORNIR_BENCH_CACHE");
std::env::set_var("HOME", "/home/somebody");
assert_eq!(
default_cache_dir(),
PathBuf::from("/home/somebody/.cache/nornir/bench-assets"),
);
// No HOME at all (bare container / CI shell): fall back INSIDE the workdir
// we already own — never into the world-writable, predictable /tmp, where a
// local attacker could pre-seed the cache path and choose the bytes a
// benchmark measures.
std::env::remove_var("HOME");
let no_home = default_cache_dir();
assert!(
!no_home.starts_with("/tmp"),
"HOME-less fallback must not land in the shared /tmp namespace, got {no_home:?}",
);
assert!(no_home.is_relative(), "expected a workdir-relative cache, got {no_home:?}");
// Restore.
match prev_cache {
Some(v) => std::env::set_var("NORNIR_BENCH_CACHE", v),
None => std::env::remove_var("NORNIR_BENCH_CACHE"),
}
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
/// A ureq 3 agent with ureq 2's defaults, so the bump changes no behaviour:
/// ureq 2 (built without `proxy-from-env`) never read `HTTP(S)_PROXY` and
/// followed at most 5 redirects; ureq 3 reads the environment and allows 10.
/// (ureq 2 -> 3 bump, 2026-10-02.)
fn http_agent() -> ureq::Agent {
ureq::Agent::config_builder().proxy(None).max_redirects(5).build().new_agent()
}