Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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()
}