Files
Aura/crates/aura-cli/src/project.rs
T
Brummel e44b0cbb3e feat(cli): warn when the loaded project dylib is stale against the source tree
Editing a project's src/ without cargo build silently ran the previous
dylib with plausible stale numbers — the role-2 authoring-loop footgun.
The loader now compares the newest mtime under src/ + Cargo.toml against
the dylib's mtime right after the ArtifactMissing check and prints one
stderr warning naming both timestamps (hand-rolled Hinnant
civil_from_days render, no new dependency), then proceeds unchanged:
warn, never refuse — a stale run is still deterministic and
manifest-stamped. Every I/O failure in the scan degrades to
skip-and-continue; staleness can never become a refusal. Fresh builds
stay silent (inverse unit-pinned).

Verified: headline e2e green, project_load 8/8, full workspace suite
green, clippy -D warnings clean; independent quality review approved.

closes #237
2026-07-10 21:04:00 +02:00

719 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Project discovery + the cdylib load boundary (cycle 0102, C13/C16).
//!
//! `discover_from` walks up to the nearest `Aura.toml` (the way cargo finds
//! `Cargo.toml`); `load` locates the project's cdylib via `cargo metadata`,
//! loads it **load-and-hold** (leaked, never unloaded), validates the C tier
//! of the descriptor BEFORE touching any Rust-ABI field, then applies the
//! vocabulary charter. `Env` is the per-invocation context every verb reads:
//! merged resolver, runs root, data path, provenance.
use aura_core::PrimitiveBuilder;
use aura_core::project::{
AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL,
CORE_VERSION, ProjectDescriptor, RUSTC_VERSION, StrSlice,
};
use aura_engine::ProjectProvenance;
use aura_registry::{Registry, TraceStore};
use aura_std::{std_vocabulary, std_vocabulary_types};
use sha2::{Digest, Sha256};
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// Parsed `Aura.toml` — static project context only (C17): paths, nothing else.
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
pub struct AuraToml {
#[serde(default)]
pub paths: AuraPaths,
}
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
pub struct AuraPaths {
/// Data archive root; default: the data-server default.
#[serde(default)]
pub data: Option<String>,
/// Registry root, relative to the project root; default `"runs"`.
#[serde(default)]
pub runs: Option<PathBuf>,
}
#[derive(Debug)]
pub enum ProjectError {
Toml(PathBuf, String),
CargoMetadata(String),
ArtifactMissing(PathBuf),
Load(PathBuf, String),
NotAProjectDylib(PathBuf),
Incompatible { what: &'static str, dylib: String, host: String },
Charter(String),
}
impl fmt::Display for ProjectError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Toml(p, e) => write!(f, "failed to parse {}: {e}", p.display()),
Self::CargoMetadata(e) => write!(f, "cargo metadata failed: {e}"),
Self::ArtifactMissing(p) => write!(
f,
"project dylib not found at {} — run `cargo build` in the \
project first (or pass --release to load the release build)",
p.display()
),
Self::Load(p, e) => write!(f, "failed to load {}: {e}", p.display()),
Self::NotAProjectDylib(p) => write!(
f,
"{} exports no valid AURA_PROJECT descriptor (not an aura \
project dylib, or an incompatible descriptor layout)",
p.display()
),
Self::Incompatible { what, dylib, host } => write!(
f,
"project dylib is ABI-incompatible: {what} mismatch \
(dylib: {dylib}, host: {host}) — rebuild the project with \
the host's toolchain/aura-core"
),
Self::Charter(e) => write!(f, "project vocabulary rejected: {e}"),
}
}
}
/// A successfully loaded project. The `Library` behind `resolver`/`type_ids`
/// is leaked (load-and-hold): the fn pointers are valid for 'static.
pub struct ProjectEnv {
pub root: PathBuf,
pub toml: AuraToml,
pub namespace: String,
pub dylib_sha256: String,
pub commit: Option<String>,
resolver: fn(&str) -> Option<PrimitiveBuilder>,
type_id_list: &'static [&'static str],
}
impl ProjectEnv {
fn resolve(&self, type_id: &str) -> Option<PrimitiveBuilder> {
(self.resolver)(type_id)
}
}
/// The per-invocation context every verb reads. Outside a project every
/// accessor collapses to today's literal defaults (byte-identical behaviour).
pub struct Env {
project: Option<ProjectEnv>,
}
impl Env {
pub fn std() -> Self {
Self { project: None }
}
pub fn with_project(p: ProjectEnv) -> Self {
Self { project: Some(p) }
}
/// The one resolver every verb consumes: project first, then std. The
/// charter makes overlap impossible, so order is not load-bearing.
pub fn resolve(&self, type_id: &str) -> Option<PrimitiveBuilder> {
match &self.project {
Some(p) => p.resolve(type_id).or_else(|| std_vocabulary(type_id)),
None => std_vocabulary(type_id),
}
}
/// project std type ids, project first (for `introspect --vocabulary`).
pub fn type_ids(&self) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
if let Some(p) = &self.project {
out.extend_from_slice(p.type_id_list);
}
out.extend_from_slice(std_vocabulary_types());
out
}
/// The registry root: `<project>/<paths.runs|"runs">` inside a project,
/// the literal `runs` outside (today's behaviour).
pub fn runs_root(&self) -> PathBuf {
match &self.project {
Some(p) => {
let runs = p.toml.paths.runs.clone().unwrap_or_else(|| "runs".into());
p.root.join(runs)
}
None => PathBuf::from("runs"),
}
}
pub fn registry(&self) -> Registry {
Registry::open(self.runs_root().join("runs.jsonl"))
}
pub fn trace_store(&self) -> TraceStore {
TraceStore::open(self.runs_root())
}
/// The data archive root: `paths.data` inside a project when set,
/// the data-server default otherwise.
pub fn data_path(&self) -> String {
self.project
.as_ref()
.and_then(|p| p.toml.paths.data.clone())
.unwrap_or_else(|| data_server::DEFAULT_DATA_PATH.to_string())
}
pub fn provenance(&self) -> Option<ProjectProvenance> {
self.project.as_ref().map(|p| ProjectProvenance {
namespace: p.namespace.clone(),
dylib_sha256: p.dylib_sha256.clone(),
commit: p.commit.clone(),
})
}
}
/// Walk up from `start` to the nearest directory containing `Aura.toml`.
pub fn discover_from(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
if d.join("Aura.toml").is_file() {
return Some(d.to_path_buf());
}
dir = d.parent();
}
None
}
fn parse_aura_toml(root: &Path) -> Result<AuraToml, ProjectError> {
let path = root.join("Aura.toml");
let text = std::fs::read_to_string(&path)
.map_err(|e| ProjectError::Toml(path.clone(), e.to_string()))?;
toml::from_str(&text).map_err(|e| ProjectError::Toml(path, e.to_string()))
}
/// Derive the cdylib artifact path from `cargo metadata` output (pure —
/// unit-testable on a canned JSON document).
fn artifact_from_metadata(
metadata_json: &str,
release: bool,
) -> Result<PathBuf, ProjectError> {
let v: serde_json::Value = serde_json::from_str(metadata_json)
.map_err(|e| ProjectError::CargoMetadata(e.to_string()))?;
let target_dir = v["target_directory"]
.as_str()
.ok_or_else(|| ProjectError::CargoMetadata("no target_directory".into()))?;
let packages = v["packages"]
.as_array()
.ok_or_else(|| ProjectError::CargoMetadata("no packages".into()))?;
let lib_name = packages
.iter()
.flat_map(|p| p["targets"].as_array().into_iter().flatten())
.find(|t| {
t["kind"]
.as_array()
.is_some_and(|k| k.iter().any(|s| s.as_str() == Some("cdylib")))
})
.and_then(|t| t["name"].as_str())
.ok_or_else(|| {
ProjectError::CargoMetadata(
"no cdylib target in the project crate (is `crate-type = \
[\"cdylib\"]` set?)"
.into(),
)
})?;
let profile = if release { "release" } else { "debug" };
let file = format!(
"{}{}{}",
std::env::consts::DLL_PREFIX,
lib_name.replace('-', "_"),
std::env::consts::DLL_SUFFIX
);
Ok(Path::new(target_dir).join(profile).join(file))
}
fn artifact_path(root: &Path, release: bool) -> Result<PathBuf, ProjectError> {
let out = std::process::Command::new("cargo")
.args(["metadata", "--format-version", "1", "--no-deps"])
.current_dir(root)
.output()
.map_err(|e| ProjectError::CargoMetadata(e.to_string()))?;
if !out.status.success() {
return Err(ProjectError::CargoMetadata(
String::from_utf8_lossy(&out.stderr).trim().to_string(),
));
}
artifact_from_metadata(&String::from_utf8_lossy(&out.stdout), release)
}
/// The vocabulary charter (decision log on the milestone reference issue):
/// non-empty namespace; every listed id `\<ns\>::`-prefixed; no duplicate in
/// the merged project std set; every listed id resolves (list↔resolver
/// cross-check). Pure — unit-testable without a dylib.
fn check_charter(
namespace: &str,
ids: &[&str],
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<(), ProjectError> {
if namespace.is_empty() {
return Err(ProjectError::Charter("empty namespace".into()));
}
let prefix = format!("{namespace}::");
let mut seen = std::collections::BTreeSet::new();
for id in ids {
if !id.starts_with(&prefix) {
return Err(ProjectError::Charter(format!(
"type id `{id}` lacks the project prefix `{prefix}`"
)));
}
if !seen.insert(*id) {
return Err(ProjectError::Charter(format!("duplicate type id `{id}`")));
}
if std_vocabulary(id).is_some() || std_vocabulary_types().contains(id) {
return Err(ProjectError::Charter(format!(
"type id `{id}` collides with the std vocabulary"
)));
}
if resolve(id).is_none() {
return Err(ProjectError::Charter(format!(
"listed type id `{id}` does not resolve through the project \
resolver (list/resolver drift)"
)));
}
}
Ok(())
}
fn project_commit(root: &Path) -> Option<String> {
let head = std::process::Command::new("git")
.args(["-C"])
.arg(root)
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())?;
let mut sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
let dirty = std::process::Command::new("git")
.args(["-C"])
.arg(root)
.args(["status", "--porcelain"])
.output()
.ok()
.filter(|o| o.status.success())?;
if !dirty.stdout.is_empty() {
sha.push_str("-dirty");
}
Some(sha)
}
/// Validate the C tier of a descriptor's already-extracted fields — pure
/// value checks, no dylib/pointer work (the raw reads happen once, in
/// `load`, before this runs). Front-to-back: magic, descriptor version,
/// rustc stamp, aura-core stamp, then the namespace. Returns the namespace
/// on success. Unit-testable without a dylib by constructing the fields
/// directly, the same way `check_charter` is testable without a resolver.
fn validate_c_tier(
magic: u64,
descriptor_version: u32,
rustc_version: StrSlice,
aura_core_version: StrSlice,
namespace: StrSlice,
dylib_path: &Path,
) -> Result<String, ProjectError> {
if magic != AURA_DESCRIPTOR_MAGIC {
return Err(ProjectError::NotAProjectDylib(dylib_path.to_path_buf()));
}
if descriptor_version != AURA_DESCRIPTOR_VERSION {
return Err(ProjectError::Incompatible {
what: "descriptor version",
dylib: descriptor_version.to_string(),
host: AURA_DESCRIPTOR_VERSION.to_string(),
});
}
let dylib_rustc = unsafe { rustc_version.as_str() }
.ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?;
if dylib_rustc != RUSTC_VERSION {
return Err(ProjectError::Incompatible {
what: "rustc version",
dylib: dylib_rustc.to_string(),
host: RUSTC_VERSION.to_string(),
});
}
let dylib_core = unsafe { aura_core_version.as_str() }
.ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?;
if dylib_core != CORE_VERSION {
return Err(ProjectError::Incompatible {
what: "aura-core version",
dylib: dylib_core.to_string(),
host: CORE_VERSION.to_string(),
});
}
unsafe { namespace.as_str() }
.ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))
.map(str::to_string)
}
/// Render a `SystemTime` as a human-readable UTC timestamp with a 4-digit
/// year (`YYYY-MM-DD HH:MM:SS UTC`). Hand-rolled (Howard Hinnant's
/// civil-from-days algorithm) rather than pulling in a date-formatting
/// dependency for this one warning line.
fn format_mtime(t: SystemTime) -> String {
let secs = t
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let days = secs.div_euclid(86_400);
let day_secs = secs.rem_euclid(86_400);
let (y, m, d) = civil_from_days(days);
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} UTC",
day_secs / 3600,
(day_secs % 3600) / 60,
day_secs % 60
)
}
/// Days-since-epoch -> (year, month, day), UTC civil calendar. See
/// <https://howardhinnant.github.io/date_algorithms.html#civil_from_days>.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
(if m <= 2 { y + 1 } else { y }, m, d)
}
/// The role-2 authoring loop's staleness check (#237): if `source_mtime` is
/// strictly newer than `dylib_mtime`, a forgotten `cargo build` would run the
/// previous dylib and report plausible-but-stale numbers. Returns the warning
/// line naming both timestamps, or `None` when the dylib is not stale (the
/// only case where this fires — a fresh build stays silent).
fn stale_warning(
dylib_path: &Path,
dylib_mtime: SystemTime,
source_mtime: SystemTime,
) -> Option<String> {
if source_mtime > dylib_mtime {
Some(format!(
"warning: {} may be stale — dylib built {} but a project source \
file was modified {} (run `cargo build` to refresh); proceeding \
with the existing dylib",
dylib_path.display(),
format_mtime(dylib_mtime),
format_mtime(source_mtime),
))
} else {
None
}
}
/// The newest modification time among the project's `Cargo.toml` and every
/// file under its `src/` tree (the set that, changed, invalidates the built
/// dylib). `None` if neither exists / is readable — never fatal, staleness
/// is a warning, not a refusal.
fn newest_source_mtime(root: &Path) -> Option<SystemTime> {
let mut newest: Option<SystemTime> = None;
let mut consider = |t: SystemTime| {
if newest.is_none_or(|n| t > n) {
newest = Some(t);
}
};
if let Ok(meta) = std::fs::metadata(root.join("Cargo.toml"))
&& let Ok(t) = meta.modified()
{
consider(t);
}
let mut stack = vec![root.join("src")];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
match entry.file_type() {
Ok(ft) if ft.is_dir() => stack.push(path),
Ok(_) => {
if let Ok(t) = entry.metadata().and_then(|m| m.modified()) {
consider(t);
}
}
Err(_) => {}
}
}
}
newest
}
/// Locate, load (load-and-hold), verify, and charter-check the project dylib.
pub fn load(root: &Path, release: bool) -> Result<ProjectEnv, ProjectError> {
let toml = parse_aura_toml(root)?;
let dylib_path = artifact_path(root, release)?;
if !dylib_path.is_file() {
return Err(ProjectError::ArtifactMissing(dylib_path));
}
if let Ok(dylib_mtime) = std::fs::metadata(&dylib_path).and_then(|m| m.modified())
&& let Some(source_mtime) = newest_source_mtime(root)
&& let Some(warning) = stale_warning(&dylib_path, dylib_mtime, source_mtime)
{
eprintln!("{warning}");
}
let bytes = std::fs::read(&dylib_path)
.map_err(|e| ProjectError::Load(dylib_path.clone(), e.to_string()))?;
let dylib_sha256 = format!("{:x}", Sha256::digest(&bytes));
// Load-and-hold: leak the Library so every pointer read from the
// descriptor is valid for 'static. There is deliberately no unload path
// (one-shot process; ledger C13 note).
let lib = unsafe { libloading::Library::new(&dylib_path) }
.map_err(|e| ProjectError::Load(dylib_path.clone(), e.to_string()))?;
let lib: &'static libloading::Library = Box::leak(Box::new(lib));
let sym = unsafe { lib.get::<*const ProjectDescriptor>(AURA_PROJECT_SYMBOL) }
.map_err(|_| ProjectError::NotAProjectDylib(dylib_path.clone()))?;
let desc_ptr: *const ProjectDescriptor = *sym;
if desc_ptr.is_null() {
return Err(ProjectError::NotAProjectDylib(dylib_path));
}
// ---- C tier, validated front-to-back; no Rust-ABI field touched yet ----
let magic = unsafe { (*desc_ptr).magic };
let descriptor_version = unsafe { (*desc_ptr).descriptor_version };
let rustc_version = unsafe { (*desc_ptr).rustc_version };
let aura_core_version = unsafe { (*desc_ptr).aura_core_version };
let namespace_stamp = unsafe { (*desc_ptr).namespace };
let namespace = validate_c_tier(
magic,
descriptor_version,
rustc_version,
aura_core_version,
namespace_stamp,
&dylib_path,
)?;
// ---- Rust tier: stamps match, the ABI is trusted from here on ----
let desc: &'static ProjectDescriptor = unsafe { &*desc_ptr };
let resolver = desc.vocabulary;
let type_id_list = (desc.type_ids)();
check_charter(&namespace, type_id_list, &|t| resolver(t))?;
Ok(ProjectEnv {
root: root.to_path_buf(),
toml,
namespace,
dylib_sha256,
commit: project_commit(root),
resolver,
type_id_list,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discover_walks_up_to_aura_toml() {
let tmp = std::env::temp_dir().join(format!("aura-disc-{}", std::process::id()));
let nested = tmp.join("a/b/c");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(discover_from(&nested), None);
std::fs::write(tmp.join("Aura.toml"), "").unwrap();
assert_eq!(discover_from(&nested), Some(tmp.clone()));
assert_eq!(discover_from(&tmp), Some(tmp.clone()));
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn aura_toml_parses_empty_partial_and_unknown_keys() {
let t: AuraToml = toml::from_str("").unwrap();
assert_eq!(t, AuraToml::default());
let t: AuraToml = toml::from_str("[paths]\nruns = \"r\"").unwrap();
assert_eq!(t.paths.runs, Some(PathBuf::from("r")));
assert_eq!(t.paths.data, None);
// unknown keys tolerated (forward-compat; serde default is lenient)
let t: AuraToml = toml::from_str("[future]\nx = 1").unwrap();
assert_eq!(t, AuraToml::default());
}
#[test]
fn artifact_path_derives_from_metadata_json() {
let json = r#"{
"target_directory": "/tmp/proj/target",
"packages": [{"targets": [
{"kind": ["cdylib"], "name": "demo-project"}
]}]
}"#;
let p = artifact_from_metadata(json, false).unwrap();
let expect = format!(
"/tmp/proj/target/debug/{}demo_project{}",
std::env::consts::DLL_PREFIX,
std::env::consts::DLL_SUFFIX
);
assert_eq!(p, PathBuf::from(expect));
let p = artifact_from_metadata(json, true).unwrap();
assert!(p.to_string_lossy().contains("/release/"));
// no cdylib target -> named error
let bad = r#"{"target_directory":"/t","packages":[{"targets":[{"kind":["lib"],"name":"x"}]}]}"#;
assert!(matches!(
artifact_from_metadata(bad, false),
Err(ProjectError::CargoMetadata(_))
));
}
fn none_resolver(_: &str) -> Option<PrimitiveBuilder> {
None
}
#[test]
fn charter_rejects_each_violation_and_accepts_valid() {
let ok_resolver = |t: &str| {
if t == "p::A" || t == "p::B" { std_vocabulary("SMA") } else { None }
};
// valid
assert!(check_charter("p", &["p::A", "p::B"], &ok_resolver).is_ok());
// empty namespace
assert!(matches!(
check_charter("", &[], &none_resolver),
Err(ProjectError::Charter(_))
));
// unprefixed id
let e = check_charter("p", &["A"], &ok_resolver).unwrap_err();
assert!(e.to_string().contains("lacks the project prefix"));
// duplicate id
let e = check_charter("p", &["p::A", "p::A"], &ok_resolver).unwrap_err();
assert!(e.to_string().contains("duplicate"));
// list/resolver drift
let e = check_charter("p", &["p::C"], &ok_resolver).unwrap_err();
assert!(e.to_string().contains("does not resolve"));
}
fn matching_stamps() -> (u64, u32, StrSlice, StrSlice) {
(
AURA_DESCRIPTOR_MAGIC,
AURA_DESCRIPTOR_VERSION,
StrSlice::new(RUSTC_VERSION),
StrSlice::new(CORE_VERSION),
)
}
#[test]
fn validate_c_tier_accepts_matching_stamps() {
let (magic, version, rustc, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let ns = validate_c_tier(magic, version, rustc, core, StrSlice::new("demo"), &path)
.unwrap();
assert_eq!(ns, "demo");
}
/// A magic mismatch means the symbol is not an `AURA_PROJECT` descriptor
/// at all (foreign dylib) — refused as `NotAProjectDylib`, not
/// `Incompatible` (there is no known-good stamp to compare against).
#[test]
fn validate_c_tier_rejects_magic_mismatch() {
let (_, version, rustc, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let err = validate_c_tier(0xdead_beef, version, rustc, core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(err, ProjectError::NotAProjectDylib(_)));
}
#[test]
fn validate_c_tier_rejects_descriptor_version_mismatch() {
let (magic, _, rustc, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let err = validate_c_tier(magic, 99, rustc, core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(
err,
ProjectError::Incompatible { what: "descriptor version", .. }
));
}
#[test]
fn validate_c_tier_rejects_rustc_version_mismatch() {
let (magic, version, _, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let bad_rustc = StrSlice::new("rustc 0.0.0-fake");
let err = validate_c_tier(magic, version, bad_rustc, core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(
err,
ProjectError::Incompatible { what: "rustc version", .. }
));
}
#[test]
fn validate_c_tier_rejects_aura_core_version_mismatch() {
let (magic, version, rustc, _) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let bad_core = StrSlice::new("9.9.9-fake");
let err = validate_c_tier(magic, version, rustc, bad_core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(
err,
ProjectError::Incompatible { what: "aura-core version", .. }
));
}
/// A null stamp pointer (e.g. a zeroed/corrupt descriptor) refuses rather
/// than dereferencing it, same as the standalone `StrSlice::as_str` test
/// in aura-core — here exercised through the loader's own refusal path.
#[test]
fn validate_c_tier_rejects_null_stamp() {
let (magic, version, _, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let null_rustc = StrSlice { ptr: std::ptr::null(), len: 0 };
let err = validate_c_tier(magic, version, null_rustc, core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(err, ProjectError::NotAProjectDylib(_)));
}
/// A non-UTF-8 stamp (foreign/corrupt bytes at the pointer) refuses
/// rather than mangling a comparison or panicking.
#[test]
fn validate_c_tier_rejects_non_utf8_stamp() {
let (magic, version, rustc, _) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
static BAD: [u8; 2] = [0xFF, 0xFE];
let bad_core = StrSlice { ptr: BAD.as_ptr(), len: BAD.len() };
let err = validate_c_tier(magic, version, rustc, bad_core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(err, ProjectError::NotAProjectDylib(_)));
}
#[test]
fn env_std_collapses_to_current_defaults() {
let env = Env::std();
assert_eq!(env.runs_root(), PathBuf::from("runs"));
assert_eq!(env.data_path(), data_server::DEFAULT_DATA_PATH.to_string());
assert!(env.provenance().is_none());
assert!(env.resolve("SMA").is_some());
assert!(env.resolve("demo::Identity").is_none());
assert!(env.type_ids().contains(&"SMA"));
}
/// The role-2 authoring-loop staleness check (#237) is a pure comparison:
/// a source mtime strictly newer than the dylib's produces a warning
/// naming both timestamps in human-readable (4-digit-year) form.
#[test]
fn stale_warning_fires_and_names_both_timestamps_when_source_is_newer() {
use std::time::{Duration, UNIX_EPOCH};
let dylib_mtime = UNIX_EPOCH + Duration::from_secs(993_859_200); // 2001-06-30
let source_mtime = UNIX_EPOCH + Duration::from_secs(2_003_702_400); // 2033-06-30
let path = PathBuf::from("/tmp/x.so");
let msg = stale_warning(&path, dylib_mtime, source_mtime).expect("source is newer");
assert!(msg.contains("2001"), "names the dylib's mtime: {msg}");
assert!(msg.contains("2033"), "names the source's newer mtime: {msg}");
}
/// The inverse of the above: a fresh (or equally-timed) dylib produces no
/// warning at all — staleness is the only trigger, never routine builds.
#[test]
fn stale_warning_is_silent_when_source_is_not_newer() {
use std::time::{Duration, UNIX_EPOCH};
let t = UNIX_EPOCH + Duration::from_secs(1_000_000_000);
let path = PathBuf::from("/tmp/x.so");
assert!(stale_warning(&path, t, t).is_none(), "equal mtimes: not stale");
assert!(
stale_warning(&path, t, t - Duration::from_secs(10)).is_none(),
"older source: not stale"
);
}
}