Files
Aura/crates/aura-core/src/project.rs
T
claude 57c56ee4e2 feat(aura-core, aura-runner): re-arm the ABI handshake — source fingerprint replaces the frozen version stamp
The load seam's aura-core stamp becomes AURA_CORE_FINGERPRINT: an FNV-1a
64 hash over aura-core's sorted src/**/*.rs (prefix-free records: path,
NUL, u64-LE content length, contents), emitted by build.rs beside the
rustc stamp and baked into the descriptor by aura_project!. validate_c_tier
compares fingerprints ('aura-core build'); a stale dylib — same rustc,
different aura-core sources — is refused instead of trusted at the
Rust-ABI tier. RED-first: the headline test pins that the frozen crate
version is never again the host stamp (plus a version-bump-proof twin and
an aura-core shape pin: 16 hex, never CARGO_PKG_VERSION).

Descriptor field renamed aura_core_version -> aura_core_fingerprint
(engine-internal; field names are not ABI, order is). Documented accepted
C30 limit: source-level identity only — the consuming build's lockfile/
features stay outside the stamp. C13/C30 prose aligned.

closes #348
2026-07-26 16:41:27 +02:00

173 lines
6.5 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 build 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");
/// Build-identity fingerprint of aura-core's own sources (via build.rs),
/// per ledger contract C30: the load seam commits to a source-derived
/// fingerprint, never the frozen crate version (a fixed 0.1.0 under the
/// no-semver discipline would disarm the handshake it's meant to guard).
pub const CORE_FINGERPRINT: &str = env!("AURA_CORE_FINGERPRINT");
/// 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_fingerprint: 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_fingerprint: $crate::project::StrSlice::new(
$crate::project::CORE_FINGERPRINT,
),
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_FINGERPRINT.is_empty());
}
/// C30/#348: the stamp is a source-derived hash, never a constant that
/// would disarm the load-seam refusal — pin its mechanical shape (the
/// 16-hex FNV-1a rendering) and that it is not the crate version.
#[test]
fn core_fingerprint_is_a_source_hash_not_the_crate_version() {
assert_eq!(CORE_FINGERPRINT.len(), 16);
assert!(CORE_FINGERPRINT.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(CORE_FINGERPRINT, env!("CARGO_PKG_VERSION"));
}
#[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_fingerprint.as_str() }, Some(CORE_FINGERPRINT));
assert!((d.vocabulary)("nope").is_none());
assert_eq!((d.type_ids)(), &["t::A"]);
}
}