traits/src/test_support.rs
//! **Shared test support** — behind the `test-support` feature, pulled in only as a
//! dev-dependency (`traits = { ..., features = ["test-support"] }`).
//!
//! One home for what every test file used to copy:
//!
//! | helper | replaces |
//! |---|---|
//! | [`fstatus`] | 34 copies of the `nornir_testmatrix::functional_status` wrapper |
//! | [`MemReader`], [`CountingReader`] | the in-memory archive fixture of the 12 `znippy-*` crates |
//! | [`hold_port`], [`free_port`], [`bound_listener`] | the racy "bind :0, read the port, drop it" copies |
//! | [`wasm_guest`] | the hand-built guests (`cargo xtask build-wasm-plugins` needed podman) |
//! | [`lfs_bytes`], [`assert_not_lfs_pointer`] | a pointer file that failed with a misleading message |
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr, TcpListener};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use znippy_common::ZnippyReader;
/// Emit one functional-status row into the `nornir test` matrix.
///
/// The same function every test used to wrap. It records only when the build
/// turns on `nornir-testmatrix/testmatrix` (each crate's own `testmatrix`
/// feature does), and is an empty inline stub otherwise.
pub use nornir_testmatrix::functional_status as fstatus;
// ── in-memory archive fixtures ──────────────────────────────────────────────
/// An in-memory [`ZnippyReader`]: a path → bytes map standing in for an archive.
pub struct MemReader {
files: HashMap<String, Vec<u8>>,
}
impl MemReader {
/// A reader over `files` (`&str` or `String` paths).
pub fn new<S: Into<String>>(files: impl IntoIterator<Item = (S, Vec<u8>)>) -> Arc<Self> {
Arc::new(Self {
files: files.into_iter().map(|(k, v)| (k.into(), v)).collect(),
})
}
}
impl ZnippyReader for MemReader {
fn list_files(&self) -> anyhow::Result<Vec<String>> {
Ok(self.files.keys().cloned().collect())
}
fn extract_file(&self, path: &str) -> anyhow::Result<Vec<u8>> {
self.files
.get(path)
.cloned()
.ok_or_else(|| anyhow::anyhow!("not found: {}", path))
}
fn contains(&self, path: &str) -> bool {
self.files.contains_key(path)
}
fn file_size(&self, path: &str) -> Option<u64> {
self.files.get(path).map(|v| v.len() as u64)
}
}
/// A [`MemReader`] that counts the work a request caused.
///
/// * [`table_reads`](Self::table_reads): `list_files` calls (the archive file table).
/// * [`member_reads`](Self::member_reads): every `extract_file` call.
/// * [`hits`](Self::hits): the `extract_file` calls that found the member and
/// moved bytes (a probe for a missing path is a lookup, not work).
pub struct CountingReader {
inner: Arc<MemReader>,
table_reads: AtomicUsize,
member_reads: AtomicUsize,
hits: AtomicUsize,
}
impl CountingReader {
/// Count the reads made through `inner`.
pub fn new(inner: Arc<MemReader>) -> Arc<Self> {
Arc::new(Self {
inner,
table_reads: AtomicUsize::new(0),
member_reads: AtomicUsize::new(0),
hits: AtomicUsize::new(0),
})
}
/// How many times the file table was listed.
pub fn table_reads(&self) -> usize {
self.table_reads.load(Ordering::SeqCst)
}
/// How many members were asked for, found or not.
pub fn member_reads(&self) -> usize {
self.member_reads.load(Ordering::SeqCst)
}
/// How many members were found and extracted.
pub fn hits(&self) -> usize {
self.hits.load(Ordering::SeqCst)
}
}
impl ZnippyReader for CountingReader {
fn list_files(&self) -> anyhow::Result<Vec<String>> {
self.table_reads.fetch_add(1, Ordering::SeqCst);
self.inner.list_files()
}
fn extract_file(&self, path: &str) -> anyhow::Result<Vec<u8>> {
self.member_reads.fetch_add(1, Ordering::SeqCst);
let got = self.inner.extract_file(path);
if got.is_ok() {
self.hits.fetch_add(1, Ordering::SeqCst);
}
got
}
fn contains(&self, path: &str) -> bool {
self.inner.contains(path)
}
fn file_size(&self, path: &str) -> Option<u64> {
self.inner.file_size(path)
}
}
// ── ports without a race ────────────────────────────────────────────────────
/// A loopback port held by a bound (not listening) socket for as long as the
/// lease lives.
///
/// The old copies bound `:0`, read the port and dropped the listener, so a
/// parallel test (or an outgoing connection's ephemeral port) could take it
/// before the server bound it. A lease keeps the port bound the whole time:
/// the kernel hands it to no other `bind(:0)` and to no outgoing connection.
/// The socket carries `SO_REUSEADDR` and never listens, so the server under
/// test (std and tokio listeners set `SO_REUSEADDR` too) binds and listens on
/// the same port while the lease still holds it. Keep the lease alive until the
/// test ends; restarts on the same port keep working.
///
/// It prints as the bare port number, so `format!("127.0.0.1:{port}")` reads
/// the same as before.
pub struct PortLease {
_socket: socket2::Socket,
addr: SocketAddr,
}
impl PortLease {
/// The leased port.
pub fn port(&self) -> u16 {
self.addr.port()
}
/// The leased address (ip + port).
pub fn addr(&self) -> SocketAddr {
self.addr
}
}
impl std::fmt::Display for PortLease {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.addr.port())
}
}
impl std::fmt::Debug for PortLease {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "PortLease({})", self.addr)
}
}
/// Lease a free port on `127.0.0.1`. See [`PortLease`].
pub fn free_port() -> PortLease {
free_port_on("127.0.0.1")
}
/// Lease a free port on `ip` (for example `127.0.0.2` in the two-address tests).
pub fn free_port_on(ip: &str) -> PortLease {
let ip: IpAddr = ip
.parse()
.unwrap_or_else(|e| panic!("free_port_on({ip:?}): {e}"));
let addr = SocketAddr::new(ip, 0);
let socket = socket2::Socket::new(
socket2::Domain::for_address(addr),
socket2::Type::STREAM,
None,
)
.expect("a TCP socket for the port lease");
socket
.set_reuse_address(true)
.expect("SO_REUSEADDR on the port lease");
socket
.bind(&addr.into())
.unwrap_or_else(|e| panic!("bind {addr} for a port lease: {e}"));
let addr = socket
.local_addr()
.expect("lease address")
.as_socket()
.expect("an inet address");
PortLease {
_socket: socket,
addr,
}
}
/// A listening socket on a free loopback port, for code that can take the
/// listener itself (a mock server, `serve_with_incoming`). Nothing to race.
pub fn bound_listener() -> TcpListener {
TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1:0")
}
/// Lease a loopback port and hold it until the test process exits; returns the
/// number. For a test that hands the port to a config, a helper or a child
/// process: no lease variable to keep alive, and a restart on the same port
/// stays safe. See [`PortLease`].
pub fn hold_port() -> u16 {
hold_port_on("127.0.0.1")
}
/// [`hold_port`] on `ip` (for example `127.0.0.2`).
pub fn hold_port_on(ip: &str) -> u16 {
static HELD: Mutex<Vec<PortLease>> = Mutex::new(Vec::new());
let lease = free_port_on(ip);
let port = lease.port();
HELD.lock().unwrap_or_else(|p| p.into_inner()).push(lease);
port
}
// ── wasm guests, built on demand ────────────────────────────────────────────
/// The wasm target every guest is built for.
pub const WASM_TARGET: &str = "wasm32-unknown-unknown";
/// The holger repo root (this crate is `<root>/traits`).
pub fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("traits/.. is the holger repo root")
.to_path_buf()
}
/// Where the guests are built: `<target>/wasm-guests`, its own target dir, so
/// the nested cargo never waits on the lock of the build that runs the test.
pub fn wasm_guest_target_dir() -> PathBuf {
let target = std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| repo_root().join("target"));
target.join("wasm-guests")
}
/// The module file name cargo writes for `package`.
pub fn wasm_file_name(package: &str) -> String {
format!("{}.wasm", package.replace('-', "_"))
}
/// **Build the wasm guest `package` and return its module.**
///
/// Runs `cargo build --release --target wasm32-unknown-unknown -p <package>` in
/// [`wasm_guest_target_dir`]. Cargo's own freshness check decides whether
/// anything is rebuilt, so the module is never stale. No container and no
/// podman. Built once per test process; parallel processes queue on cargo's
/// lock of that directory.
///
/// Panics with the fix when the wasm32 target is missing
/// (`rustup target add wasm32-unknown-unknown`) or the build fails. It never
/// skips: a test that skipped over a missing module would be a permanent green.
pub fn wasm_guest(package: &str) -> PathBuf {
static BUILT: OnceLock<Mutex<HashMap<String, PathBuf>>> = OnceLock::new();
let built = BUILT.get_or_init(|| Mutex::new(HashMap::new()));
let mut built = built.lock().unwrap_or_else(|p| p.into_inner());
if let Some(p) = built.get(package) {
return p.clone();
}
assert_wasm_target_installed();
let target = wasm_guest_target_dir();
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let build = |manifest: Option<&str>| {
let mut cmd = std::process::Command::new(&cargo);
cmd.current_dir(repo_root())
.env("CARGO_TARGET_DIR", &target)
// The outer build's flags are for the host target, not for a guest.
.env_remove("RUSTFLAGS")
.env_remove("CARGO_ENCODED_RUSTFLAGS")
.args(["build", "--release", "--target", WASM_TARGET, "-p", package]);
if let Some(m) = manifest {
cmd.args(["--manifest-path", m]);
}
cmd.output().unwrap_or_else(|e| {
panic!(
"run {} build for the wasm guest {package}: {e}",
Path::new(&cargo).display()
)
})
};
// A guest is a member of the root workspace or of `estate/` (H1: holger-build-thing).
let mut out = build(None);
if !out.status.success()
&& String::from_utf8_lossy(&out.stderr).contains("did not match any packages")
{
out = build(Some("estate/Cargo.toml"));
}
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let hint = if stderr.contains("target may not be installed")
|| stderr.contains("can't find crate for `core`")
{
"\nThe wasm32 target is missing. Install it with:\n rustup target add wasm32-unknown-unknown\n"
} else {
""
};
panic!("building the wasm guest {package} failed{hint}\n{stderr}");
}
let module = target
.join(WASM_TARGET)
.join("release")
.join(wasm_file_name(package));
let len = std::fs::metadata(&module).map(|m| m.len()).unwrap_or(0);
assert!(
len > 0,
"the build of {package} succeeded but wrote no module at {} (does the crate declare `crate-type = [\"cdylib\"]`?)",
module.display()
);
built.insert(package.to_string(), module.clone());
module
}
/// Panic with `rustup target add wasm32-unknown-unknown` when the active
/// toolchain has no wasm32 standard library.
pub fn assert_wasm_target_installed() {
let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
let out = std::process::Command::new(&rustc)
.args(["--print", "target-libdir", "--target", WASM_TARGET])
.output()
.unwrap_or_else(|e| {
panic!(
"run {} --print target-libdir: {e}",
Path::new(&rustc).display()
)
});
let libdir = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim());
let has_core = std::fs::read_dir(&libdir)
.map(|d| {
d.flatten()
.any(|e| e.file_name().to_string_lossy().starts_with("libcore-"))
})
.unwrap_or(false);
assert!(
out.status.success() && has_core,
"the wasm32 target is not installed for this toolchain (no core library under {}).\n\
Install it with:\n rustup target add wasm32-unknown-unknown",
libdir.display()
);
}
// ── Git LFS ─────────────────────────────────────────────────────────────────
/// The first line of every Git LFS pointer file.
pub const LFS_POINTER_PREFIX: &[u8] = b"version https://git-lfs.github.com/spec/";
/// True when `bytes` is a Git LFS pointer, not the real file.
pub fn is_lfs_pointer(bytes: &[u8]) -> bool {
bytes.starts_with(LFS_POINTER_PREFIX)
}
/// Panic with "run git lfs pull" when `bytes` (read from `what`) is an LFS pointer.
pub fn assert_not_lfs_pointer(what: &str, bytes: &[u8]) {
assert!(
!is_lfs_pointer(bytes),
"{what} is a Git LFS pointer, not the real file. Fetch the LFS files first:\n \
git lfs install --local && git lfs pull"
);
}
/// Read an LFS-tracked file, failing with "run git lfs pull" if it is a pointer.
pub fn lfs_bytes(path: impl AsRef<Path>) -> Vec<u8> {
let path = path.as_ref();
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
assert_not_lfs_pointer(&path.display().to_string(), &bytes);
bytes
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_leased_port_is_not_handed_out_again_and_the_server_can_still_bind_it() {
let lease = free_port();
// While the lease lives, no other lease gets the same port.
let others: Vec<PortLease> = (0..64).map(|_| free_port()).collect();
assert!(
others.iter().all(|o| o.port() != lease.port()),
"a held port was handed out twice"
);
// The server under test binds and listens on the leased port.
let server = TcpListener::bind(("127.0.0.1", lease.port()))
.expect("the server binds the leased port");
assert_eq!(server.local_addr().unwrap().port(), lease.port());
let _client =
std::net::TcpStream::connect(lease.addr()).expect("a client reaches the server");
assert_eq!(
lease.to_string(),
lease.port().to_string(),
"prints as the bare port"
);
}
#[test]
fn a_held_port_stays_held_and_the_server_can_bind_it_twice() {
let port = hold_port();
let others: Vec<PortLease> = (0..64).map(|_| free_port()).collect();
assert!(
others.iter().all(|o| o.port() != port),
"a held port was handed out again"
);
// A server binds it, stops, and binds it again (a restart).
drop(TcpListener::bind(("127.0.0.1", port)).expect("first bind"));
let again =
TcpListener::bind(("127.0.0.1", port)).expect("the restart binds the same port");
assert_eq!(again.local_addr().unwrap().port(), port);
}
#[test]
fn counting_reader_counts_tables_members_and_hits() {
let r = CountingReader::new(MemReader::new([("a", b"1".to_vec())]));
r.list_files().unwrap();
assert_eq!(r.extract_file("a").unwrap(), b"1");
assert!(r.extract_file("missing").is_err());
assert_eq!((r.table_reads(), r.member_reads(), r.hits()), (1, 2, 1));
assert!(r.contains("a") && !r.contains("b"));
assert_eq!(r.file_size("a"), Some(1));
}
#[test]
fn an_lfs_pointer_is_named_with_the_fix() {
let pointer = b"version https://git-lfs.github.com/spec/v1\noid sha256:00\nsize 1\n";
assert!(is_lfs_pointer(pointer));
assert!(!is_lfs_pointer(b"RIFF....WEBP"));
let msg =
std::panic::catch_unwind(|| assert_not_lfs_pointer("x.webp", pointer)).unwrap_err();
let msg = msg.downcast_ref::<String>().cloned().unwrap_or_default();
assert!(
msg.contains("git lfs pull"),
"the message names the fix: {msg}"
);
}
#[test]
fn the_module_name_follows_the_package() {
assert_eq!(
wasm_file_name("holger-handler-skidbladnir-wasm"),
"holger_handler_skidbladnir_wasm.wasm"
);
assert!(wasm_guest_target_dir().ends_with("wasm-guests"));
}
}