From 143c87c0aeca8a7ff6422a7479cb6137967bd296 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 10 Jul 2026 20:16:02 +0200 Subject: [PATCH] =?UTF-8?q?test(cli):=20RED=20=E2=80=94=20a=20stale=20proj?= =?UTF-8?q?ect=20dylib=20loads=20silently=20with=20no=20staleness=20warnin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing project src/ without cargo build silently runs the previous dylib with plausible stale numbers (role-2 authoring-loop footgun). Pins: source mtime newer than the dylib -> stderr warning naming both timestamps, run proceeds unchanged (warn, not refuse — a stale run is still deterministic and manifest-stamped). refs #237 --- crates/aura-cli/tests/project_load.rs | 84 +++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/crates/aura-cli/tests/project_load.rs b/crates/aura-cli/tests/project_load.rs index abfe371..96c9268 100644 --- a/crates/aura-cli/tests/project_load.rs +++ b/crates/aura-cli/tests/project_load.rs @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::sync::OnceLock; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; fn fixture_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo-project") @@ -179,6 +180,89 @@ fn vocabulary_charter_violation_refuses_end_to_end() { assert!(err.contains("lacks the project prefix"), "stderr must name the charter cause: {err}"); } +/// Force a file's modification time to a fixed instant (explicit mtime, no +/// sleeping — mtime granularity would make a "touch then compare" race). +fn set_mtime(path: &Path, secs_since_epoch: u64) { + let t = UNIX_EPOCH + Duration::from_secs(secs_since_epoch); + let f = std::fs::OpenOptions::new() + .write(true) + .open(path) + .unwrap_or_else(|e| panic!("open {} to set mtime: {e}", path.display())); + f.set_modified(t) + .unwrap_or_else(|e| panic!("set mtime on {}: {e}", path.display())); +} + +/// The role-2 authoring loop is edit -> `cargo build` -> run (C25). A forgotten +/// build is a silent-wrong-results footgun: the loader load-and-holds the +/// *previous* dylib and reports plausible, stale numbers. The property: when a +/// source file under the project is newer (mtime) than the built dylib, the run +/// still proceeds — a stale run is deterministic and manifest-stamped, so a +/// refusal is not warranted — but it surfaces the staleness on stderr, naming +/// both the dylib's and the source's modification times so the omission is +/// visible instead of silent. +#[test] +fn stale_dylib_warns_naming_both_timestamps_but_still_runs() { + let dir = built_fixture(); + let src = dir.join("src/lib.rs"); + let dylib = dir.join("target/debug").join(format!( + "{}demo_project{}", + std::env::consts::DLL_PREFIX, + std::env::consts::DLL_SUFFIX, + )); + assert!( + dylib.is_file(), + "built fixture must have produced a dylib at {}", + dylib.display() + ); + + // Pin two far-apart, distinctive mtimes so any human-readable rendering of + // either timestamp carries a stable, non-colliding year token. Source + // (2033) is newer than the dylib (2001): stale. + const DYLIB_MTIME: u64 = 993_859_200; // 2001-06-30 UTC + const SOURCE_MTIME: u64 = 2_003_702_400; // 2033-06-30 UTC + set_mtime(&dylib, DYLIB_MTIME); + set_mtime(&src, SOURCE_MTIME); + + let out = aura(&["run", "demo_signal.json"], dir); + + // Restore sane mtimes before asserting, so a failed assertion does not + // leave the shared fixture wedged "stale" (or with a future mtime that + // makes every later `cargo build` re-fingerprint it). + let now = SystemTime::now(); + let _ = std::fs::OpenOptions::new() + .write(true) + .open(&dylib) + .and_then(|f| f.set_modified(now)); + let _ = std::fs::OpenOptions::new() + .write(true) + .open(&src) + .and_then(|f| f.set_modified(now)); + + // Warn, not refuse: the run still succeeds and still emits its JSON report. + assert!( + out.status.success(), + "a stale dylib warns but the run proceeds (exit 0): {}", + String::from_utf8_lossy(&out.stderr) + ); + let report: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("stale run still emits its JSON report"); + assert_eq!( + report["manifest"]["project"]["namespace"], "demo", + "the stale run behaves exactly like a fresh one" + ); + + // Names both timestamps: the dylib's (2001) and the newer source's (2033). + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("2001"), + "stderr warning must name the dylib's modification time (2001): {err}" + ); + assert!( + err.contains("2033"), + "stderr warning must name the newer source's modification time (2033): {err}" + ); +} + #[test] fn missing_artifact_refuses_with_build_hint() { // A minimal never-built project: valid cargo metadata, no dylib on disk.