b39fd63396
Phase 4 of the Stratification milestone. aura-std held four C28 ladder layers in one roster; this cuts them into layer-aligned, aura-core-only node crates so the import direction is enforced by the crate graph: - aura-std — engine nodes only (arithmetic/logic/rolling + sinks) - aura-market — session, resample - aura-strategy — bias, stops, sizer, cost-model machinery - aura-backtest — sim_broker, position_management - aura-vocabulary — the relocated closed std_vocabulary roster Node modules move verbatim (byte-identical renames); consumers are rewired by import path only. A new structural test (aura-vocabulary/tests/c28_layering.rs) asserts each node crate's [dependencies] stay within its C28-permitted inner set, catching the acyclic-but-outward violation the compiler misses. Behaviour byte-identical: full workspace suite green (1448 tests), no golden edited, clippy -D warnings clean. C28 Status block updated. closes #288
907 lines
36 KiB
Rust
907 lines
36 KiB
Rust
//! 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_vocabulary::{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,
|
||
#[serde(default)]
|
||
pub nodes: AuraNodes,
|
||
}
|
||
|
||
#[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>,
|
||
}
|
||
|
||
/// Node-crate pointers (C17: static context). Paths resolve relative to the
|
||
/// project root (`Path::join`, so absolute paths pass through). The format
|
||
/// carries a list; this iteration loads exactly one entry and refuses more.
|
||
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
|
||
pub struct AuraNodes {
|
||
#[serde(default)]
|
||
pub crates: Vec<String>,
|
||
}
|
||
|
||
#[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),
|
||
MultiCrate(usize),
|
||
NodesPointer { pointer: String, inner: Box<ProjectError> },
|
||
/// #245: the `[nodes]` pointer target does not exist — checked before
|
||
/// handing off to `load_crate`, so this never reaches the cargo-metadata
|
||
/// probe (whose raw os-error text would otherwise leak through).
|
||
PointerDirMissing(PathBuf),
|
||
}
|
||
|
||
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}"),
|
||
Self::MultiCrate(n) => write!(
|
||
f,
|
||
"multi-crate loading is not yet supported (found {n} entries \
|
||
in [nodes].crates — only one is accepted)"
|
||
),
|
||
Self::NodesPointer { pointer, inner } => write!(
|
||
f,
|
||
"node crate at `{pointer}` (from [nodes] in Aura.toml): {inner}"
|
||
),
|
||
Self::PointerDirMissing(p) => write!(f, "{} does not exist", p.display()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A discovered project. `native` is present only when a node crate is
|
||
/// loaded (the crate's `Library` is leaked — load-and-hold, 'static).
|
||
#[derive(Debug)]
|
||
pub struct ProjectEnv {
|
||
pub root: PathBuf,
|
||
pub toml: AuraToml,
|
||
pub commit: Option<String>,
|
||
pub native: Option<NativeEnv>,
|
||
}
|
||
|
||
/// The loaded node crate: vocabulary + identity stamps.
|
||
#[derive(Debug)]
|
||
pub struct NativeEnv {
|
||
pub namespace: String,
|
||
pub dylib_sha256: String,
|
||
resolver: fn(&str) -> Option<PrimitiveBuilder>,
|
||
type_id_list: &'static [&'static str],
|
||
}
|
||
|
||
impl ProjectEnv {
|
||
fn resolve(&self, type_id: &str) -> Option<PrimitiveBuilder> {
|
||
self.native.as_ref().and_then(|n| (n.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(n) = self.project.as_ref().and_then(|p| p.native.as_ref()) {
|
||
out.extend_from_slice(n.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
|
||
/// (relative values resolved against the project root, same as
|
||
/// `runs_root()` and the `[nodes]` pointers), the data-server default
|
||
/// otherwise.
|
||
pub fn data_path(&self) -> String {
|
||
self.project
|
||
.as_ref()
|
||
.and_then(|p| {
|
||
p.toml.paths.data.as_ref().map(|data| {
|
||
let path = Path::new(data);
|
||
if path.is_absolute() {
|
||
data.clone()
|
||
} else {
|
||
p.root.join(path).to_string_lossy().to_string()
|
||
}
|
||
})
|
||
})
|
||
.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.native.as_ref().map(|n| n.namespace.clone()),
|
||
dylib_sha256: p.native.as_ref().map(|n| n.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()))
|
||
}
|
||
|
||
/// Read a project's `Aura.toml` (for scaffold-side tooling that must not
|
||
/// trigger a crate load, e.g. `aura nodes new`).
|
||
pub fn read_aura_toml(root: &Path) -> Result<AuraToml, ProjectError> {
|
||
parse_aura_toml(root)
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// Discover the project's tier and load it. Data-only (no `[nodes]`, no root
|
||
/// crate) loads nothing; a `[nodes]` pointer or a root Cargo.toml routes into
|
||
/// the unchanged crate pipeline (`load_crate`).
|
||
pub fn load(root: &Path, release: bool) -> Result<ProjectEnv, ProjectError> {
|
||
let toml = parse_aura_toml(root)?;
|
||
let native = match toml.nodes.crates.as_slice() {
|
||
[] => {
|
||
if root.join("Cargo.toml").is_file() {
|
||
Some(load_crate(root, release)?)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
[pointer] => {
|
||
let dir = root.join(pointer);
|
||
if !dir.is_dir() {
|
||
return Err(ProjectError::NodesPointer {
|
||
pointer: pointer.clone(),
|
||
inner: Box::new(ProjectError::PointerDirMissing(dir)),
|
||
});
|
||
}
|
||
Some(load_crate(&dir, release).map_err(|e| ProjectError::NodesPointer {
|
||
pointer: pointer.clone(),
|
||
inner: Box::new(e),
|
||
})?)
|
||
}
|
||
many => return Err(ProjectError::MultiCrate(many.len())),
|
||
};
|
||
Ok(ProjectEnv {
|
||
root: root.to_path_buf(),
|
||
toml,
|
||
commit: project_commit(root),
|
||
native,
|
||
})
|
||
}
|
||
|
||
/// Locate, load (load-and-hold), verify, and charter-check a node crate's
|
||
/// dylib.
|
||
fn load_crate(crate_root: &Path, release: bool) -> Result<NativeEnv, ProjectError> {
|
||
let dylib_path = artifact_path(crate_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(crate_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(NativeEnv { namespace, dylib_sha256, resolver, type_id_list })
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn discover_walks_up_to_aura_toml() {
|
||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||
.join("aura-disc");
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
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_a_nodes_pointer_list() {
|
||
let t: AuraToml = toml::from_str("[nodes]\ncrates = [\"../my-nodes\"]").unwrap();
|
||
assert_eq!(t.nodes.crates, vec!["../my-nodes".to_string()]);
|
||
let t: AuraToml = toml::from_str("").unwrap();
|
||
assert!(t.nodes.crates.is_empty());
|
||
}
|
||
|
||
/// #241: an Aura.toml with neither a [nodes] pointer nor a root crate is
|
||
/// the data-only tier — it loads with no native env, resolves std-only,
|
||
/// and stamps commit-only provenance.
|
||
#[test]
|
||
fn data_only_project_loads_without_a_crate() {
|
||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||
.join("aura-dataonly");
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
std::fs::create_dir_all(&tmp).unwrap();
|
||
std::fs::write(tmp.join("Aura.toml"), "[paths]\nruns = \"runs\"\n").unwrap();
|
||
let p = load(&tmp, false).expect("data-only load");
|
||
assert!(p.native.is_none());
|
||
let env = Env::with_project(p);
|
||
assert!(env.resolve("SMA").is_some());
|
||
assert!(env.resolve("demo::Identity").is_none());
|
||
assert_eq!(env.runs_root(), tmp.join("runs"));
|
||
let prov = env.provenance().expect("a data-only project is a project");
|
||
assert!(prov.namespace.is_none());
|
||
assert!(prov.dylib_sha256.is_none());
|
||
std::fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
/// #254: a relative `paths.data` must resolve against the project root
|
||
/// (`p.root`), matching `runs_root()`'s and the `[nodes]` pointers'
|
||
/// resolution — not against the invoking process's cwd, which silently
|
||
/// changes the data location depending on the caller's working directory.
|
||
#[test]
|
||
fn data_path_resolves_a_relative_paths_data_against_the_project_root() {
|
||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||
.join("aura-datarel");
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
std::fs::create_dir_all(&tmp).unwrap();
|
||
std::fs::write(tmp.join("Aura.toml"), "[paths]\ndata = \"data\"\n").unwrap();
|
||
let p = load(&tmp, false).expect("data-only load");
|
||
let env = Env::with_project(p);
|
||
assert_eq!(env.data_path(), tmp.join("data").to_string_lossy().to_string());
|
||
std::fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn two_nodes_pointers_refuse_with_multi_crate() {
|
||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||
.join("aura-multi");
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
std::fs::create_dir_all(&tmp).unwrap();
|
||
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"a\", \"b\"]\n").unwrap();
|
||
let e = load(&tmp, false).unwrap_err();
|
||
assert!(e.to_string().contains("multi-crate loading is not yet supported"), "{e}");
|
||
std::fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn a_broken_nodes_pointer_names_the_pointer() {
|
||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||
.join("aura-badptr");
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
std::fs::create_dir_all(&tmp).unwrap();
|
||
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"../nope\"]\n").unwrap();
|
||
let e = load(&tmp, false).unwrap_err();
|
||
let msg = e.to_string();
|
||
assert!(msg.contains("../nope"), "{msg}");
|
||
assert!(msg.contains("[nodes]"), "{msg}");
|
||
std::fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
/// #245: a `[nodes]` pointer at a directory that does not exist refuses
|
||
/// with a clean message — it names the missing directory and keeps the
|
||
/// `[nodes]` pointer context, but does NOT leak the raw `cargo metadata`
|
||
/// toolchain error. That wrap is reserved for a directory that exists but
|
||
/// is not a valid crate; a plainly-absent directory should say exactly
|
||
/// that (`load` still refuses → CLI exit 1).
|
||
#[test]
|
||
fn a_missing_nodes_pointer_dir_refuses_without_the_cargo_metadata_leak() {
|
||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||
.join("aura-missingptr");
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
std::fs::create_dir_all(&tmp).unwrap();
|
||
// `missing-node-crate` is never created — the pointer target is absent.
|
||
std::fs::write(
|
||
tmp.join("Aura.toml"),
|
||
"[nodes]\ncrates = [\"missing-node-crate\"]\n",
|
||
)
|
||
.unwrap();
|
||
let e = load(&tmp, false).unwrap_err();
|
||
let msg = e.to_string();
|
||
// names the offending pointer path ...
|
||
assert!(msg.contains("missing-node-crate"), "names the pointer path: {msg}");
|
||
// ... says plainly that the directory is not there ...
|
||
assert!(msg.contains("does not exist"), "states the directory is absent: {msg}");
|
||
// ... keeps the [nodes] pointer context ...
|
||
assert!(msg.contains("[nodes]"), "keeps the pointer context: {msg}");
|
||
// ... and does not leak the raw toolchain error for a simple absence.
|
||
assert!(
|
||
!msg.contains("cargo metadata"),
|
||
"no cargo-metadata leak for an absent directory: {msg}"
|
||
);
|
||
std::fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
}
|
||
}
|