From 57c56ee4e29c06b8a8ab8db9211cc8225faf123d Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 26 Jul 2026 16:41:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(aura-core,=20aura-runner):=20re-arm=20the?= =?UTF-8?q?=20ABI=20handshake=20=E2=80=94=20source=20fingerprint=20replace?= =?UTF-8?q?s=20the=20frozen=20version=20stamp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/aura-core/build.rs | 68 +++++++++++++++++-- crates/aura-core/src/project.rs | 29 +++++--- crates/aura-runner/src/project.rs | 68 ++++++++++++++++--- .../contracts/c13-hot-reload-frozen-deploy.md | 5 +- .../contracts/c30-stability-discipline.md | 5 +- 5 files changed, 147 insertions(+), 28 deletions(-) diff --git a/crates/aura-core/build.rs b/crates/aura-core/build.rs index 009b180..a0b1d7b 100644 --- a/crates/aura-core/build.rs +++ b/crates/aura-core/build.rs @@ -1,7 +1,37 @@ -//! Bakes the compiling toolchain's version into `AURA_RUSTC_VERSION` so the -//! project descriptor (src/project.rs) can stamp it. Runs per consuming build: -//! the host binary and a project cdylib each recompile aura-core under their -//! own toolchain, which is exactly what makes the two stamps comparable. +//! Bakes the compiling toolchain's version into `AURA_RUSTC_VERSION`, and a +//! deterministic fingerprint of aura-core's own sources into +//! `AURA_CORE_FINGERPRINT`, so the project descriptor (src/project.rs) can +//! stamp both. Runs per consuming build: the host binary and a project +//! cdylib each recompile aura-core under their own toolchain / from their own +//! checkout, which is exactly what makes the two stamps comparable (C30: the +//! load seam commits to a build-identity fingerprint, never the frozen crate +//! version). + +use std::path::{Path, PathBuf}; + +const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0100_0000_01b3; + +fn fnv1a(bytes: &[u8], mut hash: u64) -> u64 { + for &b in bytes { + hash ^= b as u64; + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} + +/// Recursively collect every `*.rs` file under `dir`. +fn collect_rs_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_rs_files(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } +} fn main() { let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()); @@ -13,4 +43,34 @@ fn main() { "cargo:rustc-env=AURA_RUSTC_VERSION={}", String::from_utf8_lossy(&out.stdout).trim() ); + + // Deterministic staleness fingerprint: hash (relative path + contents) of + // every source file under src/, in sorted order. Not a security hash — + // FNV-1a is dependency-free and plenty for detecting a stale build. This + // is *source-level* identity only, an accepted C30 limit: the consuming + // build resolves its own lockfile and features, so shared-dependency + // types riding the Rust tier (e.g. chrono-tz's) are NOT guarded by this + // stamp — the seam refuses staleness of aura-core itself, not the full + // link graph. + let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + collect_rs_files(&src_dir, &mut files); + files.sort(); + + let mut hash = FNV_OFFSET_BASIS; + for path in &files { + // The walk starts at src_dir, so every path strips; a silent fallback + // here would make the fingerprint checkout-dependent. + let rel = path.strip_prefix(&src_dir).expect("walked file lies outside src/"); + let contents = std::fs::read(path).expect("read aura-core source file"); + // Prefix-free record: path NUL length-prefixed contents (paths never + // contain NUL, the length pins the content boundary). + hash = fnv1a(rel.to_string_lossy().as_bytes(), hash); + hash = fnv1a(&[0], hash); + hash = fnv1a(&(contents.len() as u64).to_le_bytes(), hash); + hash = fnv1a(&contents, hash); + } + + println!("cargo:rustc-env=AURA_CORE_FINGERPRINT={hash:016x}"); + println!("cargo:rerun-if-changed=src"); } diff --git a/crates/aura-core/src/project.rs b/crates/aura-core/src/project.rs index 2cc2148..906ff0b 100644 --- a/crates/aura-core/src/project.rs +++ b/crates/aura-core/src/project.rs @@ -2,7 +2,7 @@ //! //! 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 +//! 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. @@ -23,8 +23,11 @@ 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"); +/// 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. @@ -63,7 +66,7 @@ pub struct ProjectDescriptor { pub magic: u64, pub descriptor_version: u32, pub rustc_version: StrSlice, - pub aura_core_version: StrSlice, + pub aura_core_fingerprint: StrSlice, pub namespace: StrSlice, // ---- Rust tier: only after both stamps match ---- pub vocabulary: fn(&str) -> Option, @@ -86,8 +89,8 @@ macro_rules! aura_project { rustc_version: $crate::project::StrSlice::new( $crate::project::RUSTC_VERSION, ), - aura_core_version: $crate::project::StrSlice::new( - $crate::project::CORE_VERSION, + aura_core_fingerprint: $crate::project::StrSlice::new( + $crate::project::CORE_FINGERPRINT, ), namespace: $crate::project::StrSlice::new($ns), vocabulary: $vocab, @@ -117,7 +120,17 @@ mod tests { #[test] fn stamps_are_baked_and_nonempty() { assert!(RUSTC_VERSION.starts_with("rustc ")); - assert!(!CORE_VERSION.is_empty()); + 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] @@ -152,7 +165,7 @@ mod tests { 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_eq!(unsafe { d.aura_core_fingerprint.as_str() }, Some(CORE_FINGERPRINT)); assert!((d.vocabulary)("nope").is_none()); assert_eq!((d.type_ids)(), &["t::A"]); } diff --git a/crates/aura-runner/src/project.rs b/crates/aura-runner/src/project.rs index 071e66e..b6b956d 100644 --- a/crates/aura-runner/src/project.rs +++ b/crates/aura-runner/src/project.rs @@ -10,7 +10,7 @@ use aura_core::{DocGateFault, PrimitiveBuilder, doc_gate}; use aura_core::project::{ AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL, - CORE_VERSION, ProjectDescriptor, RUSTC_VERSION, StrSlice, + CORE_FINGERPRINT, ProjectDescriptor, RUSTC_VERSION, StrSlice, }; use aura_engine::ProjectProvenance; use aura_registry::{Registry, TraceStore}; @@ -378,7 +378,7 @@ fn validate_c_tier( magic: u64, descriptor_version: u32, rustc_version: StrSlice, - aura_core_version: StrSlice, + aura_core_fingerprint: StrSlice, namespace: StrSlice, dylib_path: &Path, ) -> Result { @@ -401,13 +401,13 @@ fn validate_c_tier( host: RUSTC_VERSION.to_string(), }); } - let dylib_core = unsafe { aura_core_version.as_str() } + let dylib_core = unsafe { aura_core_fingerprint.as_str() } .ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?; - if dylib_core != CORE_VERSION { + if dylib_core != CORE_FINGERPRINT { return Err(ProjectError::Incompatible { - what: "aura-core version", + what: "aura-core build", dylib: dylib_core.to_string(), - host: CORE_VERSION.to_string(), + host: CORE_FINGERPRINT.to_string(), }); } unsafe { namespace.as_str() } @@ -580,13 +580,13 @@ fn load_crate(crate_root: &Path, release: bool) -> Result Option` and the enumerable type-id list — is read @@ -37,7 +38,7 @@ via `cargo metadata` (debug default, `--release` opt-in) and loads it **load-and-hold** — the `Library` is leaked, never unloaded, so its `'static` strings and fn-pointers stay valid (`aura-runner::project::load`, `crates/aura-runner/src/project.rs`). `validate_c_tier` checks the four stamps -front-to-back; a mismatch of either version stamp refuses with exit 1 naming +front-to-back; a mismatch of either stamp refuses with exit 1 naming both sides (`ProjectError::Incompatible`). The project vocabulary is charter-checked at load (`check_charter`): non-empty namespace, every id `::`-prefixed, no duplicate, no collision against the std vocabulary, diff --git a/docs/design/contracts/c30-stability-discipline.md b/docs/design/contracts/c30-stability-discipline.md index b2411f5..3802011 100644 --- a/docs/design/contracts/c30-stability-discipline.md +++ b/docs/design/contracts/c30-stability-discipline.md @@ -37,9 +37,8 @@ hand-maintained version. built from. A hand-maintained version that never moves disarms the refusal (the pre-C30 state: `CORE_VERSION` frozen at 0.1.0 matched every stale dylib); a hand-bumped one re-arms it only between bumps — - an implicit ABI promise this contract refuses to make. *(Transition: - the shipped handshake still stamps the crate version; re-arming it is - tracked as #348.)* + an implicit ABI promise this contract refuses to make. *(Re-armed by + #348: a source-derived fingerprint replaced the crate-version stamp.)* 4. **The artifact plane is the stable tier.** What was already law, stated as the positive promise: registered artifacts are never