Files
Aura/crates/aura-core/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

160 lines
5.7 KiB
Rust

//! The project-cdylib descriptor contract (C13/C16, cycle 0102).
//!
//! A research project compiles to a cdylib exporting ONE symbol,
//! `AURA_PROJECT`, a [`ProjectDescriptor`]. The descriptor has two ABI tiers:
//! a **C tier** (`magic`, `descriptor_version`, the version stamps, the
//! namespace — all C-compatible field types) the host validates BEFORE
//! trusting anything, and a **Rust tier** (the vocabulary resolver and the
//! enumerable type-id list) the host touches only after both stamps match.
//! The stamp cannot certify the channel it rides on, so it must not itself
//! depend on the Rust ABI it certifies.
//!
//! The stamps are baked at *aura-core compile time* — per consuming build —
//! so host and dylib each carry their own side's truth.
use crate::node::PrimitiveBuilder;
/// First 8 bytes of the C tier; ASCII "AURAPROJ".
pub const AURA_DESCRIPTOR_MAGIC: u64 = 0x4155_5241_5052_4F4A;
/// Bumped on any layout change of [`ProjectDescriptor`].
pub const AURA_DESCRIPTOR_VERSION: u32 = 1;
/// The one exported symbol name (NUL-terminated for the symbol lookup).
pub const AURA_PROJECT_SYMBOL: &[u8] = b"AURA_PROJECT\0";
/// The compiling rustc's `--version` line (via build.rs).
pub const RUSTC_VERSION: &str = env!("AURA_RUSTC_VERSION");
/// This aura-core's crate version.
pub const CORE_VERSION: &str = env!("CARGO_PKG_VERSION");
/// C-ABI string slice: pointer + length over `'static` UTF-8 bytes.
/// No NUL convention needed; readable without trusting the Rust ABI.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct StrSlice {
pub ptr: *const u8,
pub len: usize,
}
impl StrSlice {
pub const fn new(s: &'static str) -> Self {
Self { ptr: s.as_ptr(), len: s.len() }
}
/// Read the slice back as UTF-8.
///
/// # Safety
/// `ptr`/`len` must describe live UTF-8 bytes — true for any descriptor
/// built by [`aura_project!`](crate::aura_project), whose strings are
/// `'static` and whose library is loaded-and-held (never unloaded).
pub unsafe fn as_str(&self) -> Option<&str> {
if self.ptr.is_null() {
return None;
}
let bytes = unsafe { core::slice::from_raw_parts(self.ptr, self.len) };
core::str::from_utf8(bytes).ok()
}
}
/// The exported project descriptor. Field ORDER is ABI: C tier first,
/// Rust tier last; validated front-to-back.
#[repr(C)]
pub struct ProjectDescriptor {
// ---- C tier: validated BEFORE any Rust-ABI field is touched ----
pub magic: u64,
pub descriptor_version: u32,
pub rustc_version: StrSlice,
pub aura_core_version: StrSlice,
pub namespace: StrSlice,
// ---- Rust tier: only after both stamps match ----
pub vocabulary: fn(&str) -> Option<PrimitiveBuilder>,
pub type_ids: fn() -> &'static [&'static str],
}
// The raw pointers are to 'static data; the fn pointers are Sync by nature.
unsafe impl Sync for ProjectDescriptor {}
/// Declare a project crate's export in one place. Emits the `AURA_PROJECT`
/// static with this build's stamps baked in.
#[macro_export]
macro_rules! aura_project {
(namespace: $ns:literal, vocabulary: $vocab:path, type_ids: $ids:path $(,)?) => {
#[unsafe(no_mangle)]
pub static AURA_PROJECT: $crate::project::ProjectDescriptor =
$crate::project::ProjectDescriptor {
magic: $crate::project::AURA_DESCRIPTOR_MAGIC,
descriptor_version: $crate::project::AURA_DESCRIPTOR_VERSION,
rustc_version: $crate::project::StrSlice::new(
$crate::project::RUSTC_VERSION,
),
aura_core_version: $crate::project::StrSlice::new(
$crate::project::CORE_VERSION,
),
namespace: $crate::project::StrSlice::new($ns),
vocabulary: $vocab,
type_ids: $ids,
};
};
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_vocab(type_id: &str) -> Option<PrimitiveBuilder> {
let _ = type_id;
None
}
fn fake_ids() -> &'static [&'static str] {
&["t::A"]
}
aura_project! {
namespace: "t",
vocabulary: fake_vocab,
type_ids: fake_ids,
}
#[test]
fn stamps_are_baked_and_nonempty() {
assert!(RUSTC_VERSION.starts_with("rustc "));
assert!(!CORE_VERSION.is_empty());
}
#[test]
fn str_slice_round_trips() {
let s = StrSlice::new("hello");
assert_eq!(unsafe { s.as_str() }, Some("hello"));
}
/// `as_str` refuses a null pointer instead of dereferencing it — the
/// loader's boundary-safety primitive for a descriptor field it hasn't
/// otherwise validated yet.
#[test]
fn str_slice_null_ptr_is_none() {
let s = StrSlice { ptr: std::ptr::null(), len: 0 };
assert_eq!(unsafe { s.as_str() }, None);
}
/// `as_str` refuses non-UTF-8 bytes rather than producing a mangled or
/// panicking read, so a corrupt/foreign descriptor field degrades to
/// `None` instead of undefined behaviour downstream.
#[test]
fn str_slice_invalid_utf8_is_none() {
static BAD: [u8; 2] = [0xFF, 0xFE];
let s = StrSlice { ptr: BAD.as_ptr(), len: BAD.len() };
assert_eq!(unsafe { s.as_str() }, None);
}
#[test]
fn macro_emits_a_valid_descriptor() {
let d = &AURA_PROJECT;
assert_eq!(d.magic, AURA_DESCRIPTOR_MAGIC);
assert_eq!(d.descriptor_version, AURA_DESCRIPTOR_VERSION);
assert_eq!(unsafe { d.namespace.as_str() }, Some("t"));
assert_eq!(unsafe { d.rustc_version.as_str() }, Some(RUSTC_VERSION));
assert_eq!(unsafe { d.aura_core_version.as_str() }, Some(CORE_VERSION));
assert!((d.vocabulary)("nope").is_none());
assert_eq!((d.type_ids)(), &["t::A"]);
}
}