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
This commit is contained in:
2026-07-26 16:41:27 +02:00
parent d87f534e85
commit 57c56ee4e2
5 changed files with 147 additions and 28 deletions
+64 -4
View File
@@ -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<PathBuf>) {
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");
}
+21 -8
View File
@@ -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<PrimitiveBuilder>,
@@ -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"]);
}
+57 -11
View File
@@ -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<String, ProjectError> {
@@ -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<NativeEnv, ProjectErro
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 aura_core_fingerprint = unsafe { (*desc_ptr).aura_core_fingerprint };
let namespace_stamp = unsafe { (*desc_ptr).namespace };
let namespace = validate_c_tier(
magic,
descriptor_version,
rustc_version,
aura_core_version,
aura_core_fingerprint,
namespace_stamp,
&dylib_path,
)?;
@@ -806,7 +806,7 @@ mod tests {
AURA_DESCRIPTOR_MAGIC,
AURA_DESCRIPTOR_VERSION,
StrSlice::new(RUSTC_VERSION),
StrSlice::new(CORE_VERSION),
StrSlice::new(CORE_FINGERPRINT),
)
}
@@ -857,7 +857,7 @@ mod tests {
}
#[test]
fn validate_c_tier_rejects_aura_core_version_mismatch() {
fn validate_c_tier_rejects_aura_core_build_mismatch() {
let (magic, version, rustc, _) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let bad_core = StrSlice::new("9.9.9-fake");
@@ -865,10 +865,56 @@ mod tests {
.unwrap_err();
assert!(matches!(
err,
ProjectError::Incompatible { what: "aura-core version", .. }
ProjectError::Incompatible { what: "aura-core build", .. }
));
}
/// #348 / C30: the aura-core stamp compared at the load seam is a
/// build-identity fingerprint of aura-core's *sources*, not the frozen
/// crate version. Under C30's no-semver discipline the workspace version
/// is pinned at "0.1.0", so every stale pre-transition dylib stamps
/// exactly that string — a host whose own stamp were still the crate
/// version would wave any of them through (the disarmed handshake this
/// pin re-arms). The host side of the comparison must be a
/// source-derived fingerprint (never the frozen version string), the
/// refusal must echo both stamps, and its prose must name the rebuild
/// fix. The accept side — identical stamps still load — stays pinned by
/// `validate_c_tier_accepts_matching_stamps` above.
#[test]
fn validate_c_tier_rejects_the_frozen_crate_version_as_aura_core_stamp() {
let path = PathBuf::from("/tmp/x.so");
// What every stale, pre-fingerprint dylib carries as its aura-core
// stamp: the frozen workspace crate version.
let stale_stamp = StrSlice::new("0.1.0");
let err = validate_c_tier(
AURA_DESCRIPTOR_MAGIC,
AURA_DESCRIPTOR_VERSION,
StrSlice::new(RUSTC_VERSION),
stale_stamp,
StrSlice::new("demo"),
&path,
)
.unwrap_err();
let ProjectError::Incompatible { dylib, host, .. } = &err else {
panic!("expected Incompatible, got: {err}");
};
assert_eq!(dylib.as_str(), "0.1.0", "the refusal echoes the dylib's stale stamp");
assert_ne!(
host.as_str(),
"0.1.0",
"the host's stamp is a source-derived fingerprint, not the frozen crate version"
);
// Version-bump-proof twin of the literal above: whatever the crate
// version becomes, it must never be the host stamp again.
assert_ne!(
host.as_str(),
env!("CARGO_PKG_VERSION"),
"the host's stamp reverted to the crate version"
);
let msg = err.to_string();
assert!(msg.contains("rebuild"), "the refusal names the fix: {msg}");
}
/// 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.
@@ -22,7 +22,8 @@ A research project compiles to an external cdylib exporting one symbol,
`AURA_PROJECT`, a two-tier `#[repr(C)]` `ProjectDescriptor`
(`aura-core::project`, `crates/aura-core/src/project.rs`, emitted by the
`aura_project!` macro). A **C tier**`magic` (`AURAPROJ`),
`descriptor_version`, the rustc-version and aura-core-version stamps, the
`descriptor_version`, the rustc-version and aura-core build-fingerprint
stamps ([C30](c30-stability-discipline.md)), the
namespace, all C-compatible field types — is validated **before** any Rust-ABI
field is touched; a **Rust tier** — the vocabulary resolver
`fn(&str) -> Option<PrimitiveBuilder>` 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
`<ns>::`-prefixed, no duplicate, no collision against the std vocabulary,
@@ -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