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
This commit is contained in:
@@ -7,9 +7,13 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aura_engine::{blueprint_to_json, replay, BindOpError, GraphSession, Op, OpError, Scalar, ScalarKind};
|
||||
use aura_std::{std_vocabulary, std_vocabulary_types};
|
||||
use serde::Deserialize;
|
||||
|
||||
// `std_vocabulary` is now only reached through `crate::project::Env::resolve` in
|
||||
// production code; tests still exercise it directly.
|
||||
#[cfg(test)]
|
||||
use aura_std::std_vocabulary;
|
||||
|
||||
/// The wire DTO for one construction op — the document's by-identifier shape,
|
||||
/// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind
|
||||
/// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized
|
||||
@@ -106,11 +110,11 @@ fn format_op_error(e: &OpError) -> String {
|
||||
/// the emitted #155 blueprint JSON — or a `op N (kind): cause` message (a per-op
|
||||
/// fault, attributed by the retained op-kind list) / `finalize: cause` (a
|
||||
/// holistic fault past the last op).
|
||||
pub fn build_from_str(doc: &str) -> Result<String, String> {
|
||||
pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
|
||||
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
||||
match replay("graph", ops, &std_vocabulary) {
|
||||
match replay("graph", ops, &|t| env.resolve(t)) {
|
||||
Ok(composite) => blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")),
|
||||
Err((idx, err)) => {
|
||||
let cause = format_op_error(&err);
|
||||
@@ -124,14 +128,14 @@ pub fn build_from_str(doc: &str) -> Result<String, String> {
|
||||
|
||||
/// `aura graph build`: read the op-list document from stdin, build, and print the
|
||||
/// blueprint to stdout — or the cause to stderr and exit non-zero.
|
||||
pub fn build_cmd() {
|
||||
pub fn build_cmd(env: &crate::project::Env) {
|
||||
use std::io::Read;
|
||||
let mut doc = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
|
||||
eprintln!("aura: reading stdin: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
match build_from_str(&doc) {
|
||||
match build_from_str(&doc, env) {
|
||||
// `print!`, not `println!`: the canonical artifact is exactly the library
|
||||
// `blueprint_to_json` bytes (the form #158 content-addresses) — a JSON value
|
||||
// with no trailing newline. The CLI is a transport; it must not frame the
|
||||
@@ -147,8 +151,8 @@ pub fn build_cmd() {
|
||||
/// `aura graph introspect --node <T>`: a type's ports + kinds + param paths,
|
||||
/// read off the pre-build schema (no graph built). `Err` if `T` is not in the
|
||||
/// closed vocabulary.
|
||||
pub fn introspect_node(type_id: &str) -> Result<String, String> {
|
||||
let builder = std_vocabulary(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
|
||||
pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
|
||||
let schema = builder.schema();
|
||||
let mut out = format!("{}\n", builder.label());
|
||||
for port in &schema.inputs {
|
||||
@@ -165,9 +169,10 @@ pub fn introspect_node(type_id: &str) -> Result<String, String> {
|
||||
|
||||
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
|
||||
/// op-list document, by-identifier (applies the ops, does NOT finalize).
|
||||
pub fn introspect_unwired(doc: &str) -> Result<String, String> {
|
||||
pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||
let mut session = GraphSession::new("introspect", &std_vocabulary);
|
||||
let resolver = |t: &str| env.resolve(t);
|
||||
let mut session = GraphSession::new("introspect", &resolver);
|
||||
for (i, d) in docs.into_iter().enumerate() {
|
||||
session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?;
|
||||
}
|
||||
@@ -181,7 +186,7 @@ pub fn introspect_unwired(doc: &str) -> Result<String, String> {
|
||||
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
|
||||
/// `--vocabulary` / `--node <T>` / `--unwired` / `--content-id` must be set;
|
||||
/// zero or more than one is the usage error (exit 2).
|
||||
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) {
|
||||
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) {
|
||||
let count = cmd.vocabulary as usize
|
||||
+ cmd.node.is_some() as usize
|
||||
+ cmd.unwired as usize
|
||||
@@ -193,11 +198,11 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) {
|
||||
std::process::exit(2);
|
||||
}
|
||||
if cmd.vocabulary {
|
||||
for t in std_vocabulary_types() {
|
||||
for t in env.type_ids() {
|
||||
println!("{t}");
|
||||
}
|
||||
} else if let Some(type_id) = cmd.node.as_deref() {
|
||||
match introspect_node(type_id) {
|
||||
match introspect_node(type_id, env) {
|
||||
Ok(s) => print!("{s}"),
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
@@ -211,7 +216,7 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) {
|
||||
eprintln!("aura: reading stdin: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
match introspect_unwired(&doc) {
|
||||
match introspect_unwired(&doc, env) {
|
||||
Ok(s) => print!("{s}"),
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
@@ -229,7 +234,7 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) {
|
||||
eprintln!("aura: reading stdin: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
match build_from_str(&doc) {
|
||||
match build_from_str(&doc, env) {
|
||||
Ok(json) => println!("{}", crate::content_id(&json)),
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
@@ -283,7 +288,7 @@ mod tests {
|
||||
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
let json = super::build_from_str(doc).expect("valid document builds");
|
||||
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("valid document builds");
|
||||
assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}");
|
||||
assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
|
||||
}
|
||||
@@ -299,14 +304,14 @@ mod tests {
|
||||
{"op":"feed","role":"price","into":["fast.series"]},
|
||||
{"op":"expose","from":"fast.value","as":"out"}
|
||||
]"#;
|
||||
let json = super::build_from_str(doc).expect("name-keyed add builds");
|
||||
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("name-keyed add builds");
|
||||
assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_from_str_reports_unknown_node_at_its_op_index() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#;
|
||||
let err = super::build_from_str(doc).unwrap_err();
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
|
||||
}
|
||||
|
||||
@@ -321,7 +326,7 @@ mod tests {
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
]"#;
|
||||
let err = super::build_from_str(doc).unwrap_err();
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
assert!(err.starts_with("finalize: "), "finalize fault, got: {err}");
|
||||
assert!(err.contains("sub.rhs") && err.contains("is unconnected"),
|
||||
"names the unconnected slot by-identifier, got: {err}");
|
||||
@@ -330,11 +335,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn introspect_node_lists_ports_kinds_and_params() {
|
||||
let out = super::introspect_node("SMA").expect("SMA is in the vocabulary");
|
||||
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
assert!(out.contains("series"), "lists the input port: {out}");
|
||||
assert!(out.contains("value"), "lists the output field: {out}");
|
||||
assert!(out.contains("length"), "lists the param path: {out}");
|
||||
assert!(super::introspect_node("Nope").is_err(), "rejects an unknown type");
|
||||
assert!(super::introspect_node("Nope", &crate::project::Env::std()).is_err(), "rejects an unknown type");
|
||||
}
|
||||
|
||||
/// A bind whose value-kind mismatches the param reads as prose, like the connect
|
||||
@@ -342,7 +347,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_from_str_bad_bind_kind_reads_as_prose() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#;
|
||||
let err = super::build_from_str(doc).unwrap_err();
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64");
|
||||
}
|
||||
|
||||
@@ -351,7 +356,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_from_str_unknown_bind_param_reads_as_prose() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#;
|
||||
let err = super::build_from_str(doc).unwrap_err();
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 0 (add): node fast has no param \"window\"");
|
||||
}
|
||||
|
||||
@@ -372,7 +377,7 @@ mod tests {
|
||||
/// does not have to read source to learn the `{"I64": <v>}` wrapping.
|
||||
#[test]
|
||||
fn introspect_node_shows_the_bind_form() {
|
||||
let out = super::introspect_node("SMA").expect("SMA is in the vocabulary");
|
||||
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
assert!(out.contains("(bind {\"I64\": <v>})"), "shows the bind-value form: {out}");
|
||||
}
|
||||
|
||||
@@ -383,7 +388,7 @@ mod tests {
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
]"#;
|
||||
let out = super::introspect_unwired(doc).expect("partial document introspects");
|
||||
let out = super::introspect_unwired(doc, &crate::project::Env::std()).expect("partial document introspects");
|
||||
assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}");
|
||||
assert!(out.contains("fast.series"), "fast.series is still open: {out}");
|
||||
assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}");
|
||||
|
||||
+353
-247
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,589 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user