Files
Aura/crates/aura-cli/src/project.rs
T
Brummel 4928e289f7 feat(project): the project-as-crate load boundary (cycle 0102)
A research project is now a loadable external cdylib crate. Inside a
directory whose ancestry holds an Aura.toml, aura discovers the project
root cargo-style, locates the compiled dylib via cargo metadata (debug
default, --release opt-in), loads it load-and-hold, and refuses
mismatches before trusting anything: the AURA_PROJECT descriptor
(aura-core::project, #[repr(C)]) carries a C-ABI stamp prefix (rustc +
aura-core version, baked per consuming build by the new aura-core
build.rs) validated before any Rust-ABI field is read. The vocabulary
charter gates the merged resolution: project type ids are ::-namespaced
(std stays bare), duplicates refuse, and the enumerable type-id list
must agree with the resolver, so introspection can never silently omit
a project type.

All blueprint verbs resolve through the merged project + std vocabulary
via a per-invocation Env threaded through the dispatch chains;
registry, trace-store, and data paths anchor at the project runs root
(Aura.toml [paths], paths-only by design — instrument geometry stays
the recorded sidecar, C15). RunManifest gains the Tier-1 project
provenance field (namespace + dylib sha256 + best-effort commit),
stamped beside topology_hash on the blueprint-run paths; pre-0102
registry lines load unchanged. Default node names strip the namespace,
so :: never reaches the param-path address space.

Proven by the demo-project fixture (built by the e2e via cargo,
path-dep on this workspace): run twice bit-identical, provenance
recorded, introspection lists demo::* beside std, registry anchors at
the discovered root from a subdirectory; the badcharter fixture proves
the charter refusal through the real libloading path; a never-built
project refuses with a cargo-build hint. Outside a project every path
collapses to the previous literals — goldens and manifest pins
byte-identical.

Verification: cargo build --workspace clean; cargo test --workspace 862
passed / 0 failed (incl. 7 project_load e2e); clippy -D warnings clean
(one precedent-matching allow(too_many_arguments) on run_oos_blueprint,
whose arity the Env threading raised to 8); doc build unchanged.
Docs/ledger aligned: Aura.toml field lists are paths-only in
project-layout.md, glossary, C16/C17; new C13 realization note records
the per-invocation-reload reading and the load-and-hold one-shot scope
boundary.

New deps, per-case review (aura-cli leaf binary only, never the frozen
artifact): libloading, toml.

refs #180
2026-07-02 18:13:37 +02:00

590 lines
22 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};
/// 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)
}
/// 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));
}
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"));
}
}