//! 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, /// Registry root, relative to the project root; default `"runs"`. #[serde(default)] pub runs: Option, } #[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, resolver: fn(&str) -> Option, type_id_list: &'static [&'static str], } impl ProjectEnv { fn resolve(&self, type_id: &str) -> Option { (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, } 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 { 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: `/` 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 { 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 { 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 { 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 { 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 { 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 `\::`-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, ) -> 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 { 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 { 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 { 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 { 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")); } }