Skip to content

Commit e62a490

Browse files
heyoubclaude
andcommitted
feat(core): verify_registry() release-startup collision check + opt-in ctor (#133)
Store::open refuses linked EventKind collisions (FailFast), but a RELEASE binary that registers colliding payloads and never opens a Store got no check (the derive's collision check is cfg(test)-only). The derive's inventory registration is already unconditional, so no derive change is needed — only a scan-invocation path. Two paths (owner: A4 + optional ctor): - verify_registry() — a documented public alias over validate_event_payload_registry() (re-exported at event::payload / event / prelude). Call it once at startup if your binary registers EventPayload types but may not open a Store. Portable, no dep. - `startup-registry-check` (NON-default) cargo feature -> optional `ctor` dep + one central #[ctor] fn that scans at load and, on a collision, writes a diagnostic via stderr().write_all (not eprintln — print_stderr is banned) then process::abort(). Native automatic life-before-main; the default build pulls NO ctor (cargo tree confirmed). Red fixtures (crates/core/fixtures/registry-startup-{collision,ctor}/ + driver event_payload_registry_startup.rs, --release subprocess, mirroring the downstream fixture precedent): collide_verify -> exit 1 + "duplicate kind assignment" stderr; collide_ctor (--features) -> SIGABRT before main; clean_verify (control) -> exit 0. Baseline +3 (verify_registry at the 3 paths); syncbat/netbat byte-identical. Both builds green: fmt, clippy x2, build x2 (no ctor by default), structural ok. ctor clears cargo-deny (MIT/Apache). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHio8XCrH89gdEcycCumr6
1 parent 704d454 commit e62a490

13 files changed

Lines changed: 431 additions & 8 deletions

File tree

bpk-lib/Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bpk-lib/crates/core/Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ dangerous-test-hooks = ["dep:fastrand", "batpak-testkit/dangerous-test-hooks"]
3838
# OOM-handling paths deterministically. Neither alters production behavior.
3939
alloc-count = []
4040
fault-alloc = []
41+
# Opt-in automation for the register-but-never-open collision check (item #133).
42+
# When enabled, ONE process-wide `#[ctor::ctor]` constructor runs
43+
# `verify_registry()` before `main` and aborts on a linked `EventKind`
44+
# collision, so a release binary that registers colliding `EventPayload` types
45+
# and never opens a `Store` still fails fast. NOT a default feature: the
46+
# always-on, portable path is the explicit `verify_registry()` call, which needs
47+
# no constructor and no extra dependency. `dep:ctor` keeps `ctor` out of a
48+
# default build entirely.
49+
startup-registry-check = ["dep:ctor"]
4150
# Gauntlet Phase 0B sentinel RED fixtures are gated behind the
4251
# `--cfg gauntlet_red_fixture` compile flag, NOT a Cargo feature. A red fixture
4352
# is designed to FAIL (it asserts the illegal/old behavior), so it must never be
@@ -76,6 +85,10 @@ tempfile = ">=3.24, <3.25"
7685
libc = "0.2"
7786
ed25519-compact = { version = "2.2.0", default-features = false, features = ["std"] }
7887
zeroize = "1"
88+
# Optional: only pulled in by the non-default `startup-registry-check` feature,
89+
# which installs a before-`main` constructor that runs the payload-registry
90+
# collision check (item #133). MIT OR Apache-2.0, clears deny.toml.
91+
ctor = { version = "0.2", optional = true }
7992
# NO TOKIO. Invariant 1.
8093

8194
[dev-dependencies]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[package]
2+
name = "batpak-registry-startup-collision"
3+
version = "0.1.0"
4+
edition = "2021"
5+
publish = false
6+
7+
# Nested workspace: keeps this fixture out of the parent batpak workspace so its
8+
# deliberately-colliding registrations never leak into the crate's own build.
9+
[workspace]
10+
11+
# `collide_verify`: a NON-test binary that registers two colliding EventPayload
12+
# kinds and calls `verify_registry()` in `main` (the always-on A4 path).
13+
[[bin]]
14+
name = "collide_verify"
15+
path = "src/collide_verify.rs"
16+
17+
# `clean_verify`: identical shape with NON-colliding registrations. Exits 0,
18+
# proving the collide_verify exit-1 is caused by the collision (confirms RED).
19+
[[bin]]
20+
name = "clean_verify"
21+
path = "src/clean_verify.rs"
22+
23+
[dependencies]
24+
batpak = { path = "../.." }
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//! GREEN control for the item #133 RED fixture.
2+
//!
3+
//! Same shape as `collide_verify`, but the two registrations use DISTINCT
4+
//! `(category, type_id)` pairs, so `verify_registry()` returns `Ok` and `main`
5+
//! exits 0. The driver asserts this exit-0 to prove the sibling `collide_verify`
6+
//! exit-1 is caused by the seeded collision and not by the harness or a broken
7+
//! entry point (this is how RED is confirmed).
8+
9+
use std::io::Write;
10+
use std::process::ExitCode;
11+
12+
const CATEGORY: u8 = 0xE;
13+
const KIND_A: u16 = ((CATEGORY as u16) << 12) | 0x331;
14+
const KIND_B: u16 = ((CATEGORY as u16) << 12) | 0x332;
15+
16+
batpak::__private::inventory::submit! {
17+
batpak::__private::EventPayloadRegistration {
18+
kind_bits: KIND_A,
19+
payload_version: 1,
20+
type_name: "registry_startup_clean::FirstDistinct",
21+
}
22+
}
23+
24+
batpak::__private::inventory::submit! {
25+
batpak::__private::EventPayloadRegistration {
26+
kind_bits: KIND_B,
27+
payload_version: 1,
28+
type_name: "registry_startup_clean::SecondDistinct",
29+
}
30+
}
31+
32+
fn main() -> ExitCode {
33+
match batpak::event::verify_registry() {
34+
Ok(()) => ExitCode::SUCCESS,
35+
Err(error) => {
36+
// Never expected: a clean registry must verify cleanly.
37+
let message = format!("registry-startup-clean UNEXPECTED collision: {error}\n");
38+
let mut stderr = std::io::stderr();
39+
let _ = stderr.write_all(message.as_bytes());
40+
let _ = stderr.flush();
41+
ExitCode::from(2)
42+
}
43+
}
44+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//! RED fixture for item #133 (A4, always-on path).
2+
//!
3+
//! A non-test binary that registers TWO `EventPayload` kinds claiming the same
4+
//! `(category, type_id)` and NEVER opens a `Store`. Because this is a `[[bin]]`
5+
//! target, `cfg(test)` is false, so the `#[derive(EventPayload)]` per-type
6+
//! collision test (which is `#[cfg(test)]`-only) is absent here exactly as it is
7+
//! in a release binary. `main` calls the portable `verify_registry()` entry
8+
//! point and fails the process on the collision, proving a release binary can
9+
//! catch a linked-kind collision it otherwise would not see.
10+
//!
11+
//! The colliding registrations are emitted directly via `inventory::submit!`
12+
//! (rather than `#[derive(EventPayload)]`) so this binary carries a real
13+
//! link-time collision without also pulling in the derive's generated
14+
//! `#[cfg(test)]` collision test.
15+
16+
use std::io::Write;
17+
use std::process::ExitCode;
18+
19+
const COLLIDING_CATEGORY: u8 = 0xE;
20+
const COLLIDING_TYPE_ID: u16 = 0x321;
21+
const COLLIDING_KIND_BITS: u16 = ((COLLIDING_CATEGORY as u16) << 12) | COLLIDING_TYPE_ID;
22+
23+
batpak::__private::inventory::submit! {
24+
batpak::__private::EventPayloadRegistration {
25+
kind_bits: COLLIDING_KIND_BITS,
26+
payload_version: 1,
27+
type_name: "registry_startup_collision::FirstColliding",
28+
}
29+
}
30+
31+
batpak::__private::inventory::submit! {
32+
batpak::__private::EventPayloadRegistration {
33+
kind_bits: COLLIDING_KIND_BITS,
34+
payload_version: 1,
35+
type_name: "registry_startup_collision::SecondColliding",
36+
}
37+
}
38+
39+
// `std::process::exit` is a repo-banned method (LAW-001: it skips Drop). Returning
40+
// `ExitCode` propagates a non-zero status cleanly instead.
41+
fn main() -> ExitCode {
42+
match batpak::event::verify_registry() {
43+
Ok(()) => ExitCode::SUCCESS,
44+
Err(error) => {
45+
let message = format!("registry-startup-collision: {error}\n");
46+
let mut stderr = std::io::stderr();
47+
let _ = stderr.write_all(message.as_bytes());
48+
let _ = stderr.flush();
49+
ExitCode::from(1)
50+
}
51+
}
52+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[package]
2+
name = "batpak-registry-startup-ctor"
3+
version = "0.1.0"
4+
edition = "2021"
5+
publish = false
6+
7+
# Nested workspace: isolates the `startup-registry-check` feature (and its ctor)
8+
# to this fixture only, so the crate's own test binaries never link the ctor.
9+
[workspace]
10+
11+
# `collide_ctor`: TWO colliding registrations + an effectively-empty `main`.
12+
# Built with batpak's `startup-registry-check` feature, so the library's
13+
# before-`main` constructor detects the collision and aborts. If `main` is
14+
# reached (constructor failed to fire) it prints a sentinel and exits 0.
15+
[[bin]]
16+
name = "collide_ctor"
17+
path = "src/collide_ctor.rs"
18+
19+
[dependencies]
20+
batpak = { path = "../..", features = ["startup-registry-check"] }
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//! RED fixture for item #133 (opt-in `startup-registry-check` / ctor path).
2+
//!
3+
//! Registers TWO colliding `EventPayload` kinds and has an effectively-empty
4+
//! `main`. This crate depends on batpak with `features = ["startup-registry-check"]`,
5+
//! so the library installs one process-wide `#[ctor::ctor]` constructor that runs
6+
//! `verify_registry()` BEFORE `main`, writes a diagnostic to stderr, and aborts
7+
//! on the collision.
8+
//!
9+
//! Because `main` would exit 0 if it were ever reached, a non-zero / aborting
10+
//! exit PROVES the constructor fired before `main`. If the constructor failed to
11+
//! run, `main` prints the `REACHED_MAIN_WITHOUT_ABORT` sentinel so the driver can
12+
//! distinguish that failure from a correct abort.
13+
14+
use std::io::Write;
15+
use std::process::ExitCode;
16+
17+
const COLLIDING_CATEGORY: u8 = 0xE;
18+
const COLLIDING_TYPE_ID: u16 = 0x654;
19+
const COLLIDING_KIND_BITS: u16 = ((COLLIDING_CATEGORY as u16) << 12) | COLLIDING_TYPE_ID;
20+
21+
batpak::__private::inventory::submit! {
22+
batpak::__private::EventPayloadRegistration {
23+
kind_bits: COLLIDING_KIND_BITS,
24+
payload_version: 1,
25+
type_name: "registry_startup_ctor::FirstColliding",
26+
}
27+
}
28+
29+
batpak::__private::inventory::submit! {
30+
batpak::__private::EventPayloadRegistration {
31+
kind_bits: COLLIDING_KIND_BITS,
32+
payload_version: 1,
33+
type_name: "registry_startup_ctor::SecondColliding",
34+
}
35+
}
36+
37+
fn main() -> ExitCode {
38+
// Reaching this line means the startup constructor did NOT fire, which is the
39+
// bug this fixture guards against. Emit a sentinel so the driver sees it.
40+
let mut stdout = std::io::stdout();
41+
let _ = stdout.write_all(b"REACHED_MAIN_WITHOUT_ABORT\n");
42+
let _ = stdout.flush();
43+
ExitCode::SUCCESS
44+
}

bpk-lib/crates/core/src/event/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ pub use hash::HashChain;
1818
pub use header::EventHeader;
1919
pub use kind::{EventKind, EventKindError};
2020
pub use payload::{
21-
revalidate_event_payload_registry, validate_event_payload_registry, EventPayload,
22-
EventPayloadKindCollision, EventPayloadRegistryError, EventPayloadValidation,
21+
revalidate_event_payload_registry, validate_event_payload_registry, verify_registry,
22+
EventPayload, EventPayloadKindCollision, EventPayloadRegistryError, EventPayloadValidation,
2323
};
2424
pub use sourcing::{
2525
EventSourced, JsonValueInput, MultiDispatchError, MultiReactive, ProjectionEvent,

bpk-lib/crates/core/src/event/payload.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ pub trait EventPayload: Serialize + DeserializeOwned {
6666
/// signing-policy and receipt-hashing idiom: the safe behavior is the default
6767
/// and the looser behavior is an explicit escape hatch.
6868
///
69+
/// This policy only runs at `Store::open`. A binary that registers colliding
70+
/// payloads but **never opens a store** is not covered here; call
71+
/// [`verify_registry`] once at startup (or enable the non-default
72+
/// `startup-registry-check` feature) to catch that case in a release build.
73+
///
6974
/// Set via
7075
/// [`StoreConfig::with_event_payload_validation`](crate::store::StoreConfig::with_event_payload_validation).
7176
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
@@ -175,6 +180,60 @@ pub fn validate_event_payload_registry() -> Result<(), EventPayloadRegistryError
175180
}
176181
}
177182

183+
/// Release-startup entry point: verify the linked `EventPayload` registry has no
184+
/// duplicate-kind collisions.
185+
///
186+
/// Call this once at process startup **if your binary registers `EventPayload`
187+
/// types (directly or through a dependency crate) but may never open a
188+
/// [`Store`](crate::store::Store)**. `Store::open` already runs this same check
189+
/// under the default [`EventPayloadValidation::FailFast`] policy, so a binary
190+
/// that opens a store is covered. A binary that registers colliding payloads and
191+
/// never opens a store would otherwise get **no** collision check in a release
192+
/// build: the `#[derive(EventPayload)]` macro's own collision test is
193+
/// `#[cfg(test)]`-only and is absent from a non-test binary. This entry point
194+
/// closes that gap and catches the linked-kind collision a release build would
195+
/// not see.
196+
///
197+
/// For automatic enforcement without a manual call, enable the non-default
198+
/// `startup-registry-check` feature: it installs one process-wide startup
199+
/// constructor that runs this check and aborts on a collision before `main`.
200+
///
201+
/// This is a thin, clearly-named alias for
202+
/// [`validate_event_payload_registry`]; the two are interchangeable.
203+
///
204+
/// # Errors
205+
/// Returns [`EventPayloadRegistryError`] if two or more linked payload types
206+
/// register the same `(category, type_id)` pair.
207+
pub fn verify_registry() -> Result<(), EventPayloadRegistryError> {
208+
validate_event_payload_registry()
209+
}
210+
211+
/// Process-wide startup constructor installed by the non-default
212+
/// `startup-registry-check` feature.
213+
///
214+
/// Runs before `main`, so a release binary that registers colliding
215+
/// `EventPayload` kinds and never opens a `Store` still fails fast: it writes a
216+
/// diagnostic to `stderr` and aborts the process. One central constructor covers
217+
/// the whole binary (the derive emits no per-type startup hook), so this is
218+
/// idempotent by construction. The diagnostic is written with `write_all` on
219+
/// `std::io::stderr()` rather than `eprintln!` to honor the crate's
220+
/// no-`print_stderr` discipline, and the write result is deliberately ignored:
221+
/// if `stderr` itself is unwritable the process must still abort so the collision
222+
/// can never be silently accepted at startup.
223+
#[cfg(feature = "startup-registry-check")]
224+
#[ctor::ctor]
225+
fn __batpak_verify_registry_at_startup() {
226+
use std::io::Write;
227+
228+
if let Err(error) = verify_registry() {
229+
let message = format!("batpak startup-registry-check: aborting before main: {error}\n");
230+
let mut stderr = std::io::stderr();
231+
let _ = stderr.write_all(message.as_bytes());
232+
let _ = stderr.flush();
233+
std::process::abort();
234+
}
235+
}
236+
178237
/// Re-scan the linked payload registry and refresh the cached open-time result.
179238
///
180239
/// Most applications never need this because registrations are static once the

bpk-lib/crates/core/src/lib.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,12 @@
6969
//! [`EventPayloadValidation`](crate::event::EventPayloadValidation) defaults to
7070
//! `FailFast`, so a duplicate-kind collision or an incomplete upcast chain is
7171
//! rejected at [`Store::open`](crate::store::Store::open) (opt out explicitly
72-
//! with `Warn`/`Silent`). Receipt signing is governed by
72+
//! with `Warn`/`Silent`). A binary that registers `EventPayload` types but may
73+
//! never open a store should call
74+
//! [`verify_registry`](crate::event::verify_registry) once at startup (or enable
75+
//! the non-default `startup-registry-check` feature for automatic enforcement),
76+
//! since the derive's own collision test is `#[cfg(test)]`-only and a release
77+
//! binary would otherwise see no check. Receipt signing is governed by
7378
//! [`SigningPolicy`](crate::store::SigningPolicy): the default `Optional`
7479
//! permits a keyless store, while `Required` refuses to open without a signing
7580
//! key so an unsigned receipt is never accepted. A configured signer fails the

0 commit comments

Comments
 (0)