heimdall-consent: one version rule for every version field (positive, no leading zeros, <= 4294967295)
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
check_version is the single rule; the sample id's @N and MemberTerms.terms_version both call it, so terms_version "2026-11" is now refused and the golden vector carries 4294967295 (the harness now validates the terms record). notice_version stays u32 and validate() refuses 0. Tests: boundary table (1, 4294967295 accepted; 0, 4294967296, 4294967297, 01, +1, -1, empty, 2026-11 refused) across all three; each rule checked by mutation (u64 wrap, leading-zero, sign, terms unchecked, returned value, empty sample, zero in both records) and killed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
27d7e48cd6
commit
af0b1b6817
4 changed files with 50 additions and 17 deletions
|
|
@ -14,7 +14,7 @@ Entry points: `src/lib.rs`; golden vectors `tests/vectors/consent_golden.csv`, r
|
|||
| Ruling | Doc | Crate |
|
||||
|---|---|---|
|
||||
| Timestamps are Unix ms UTC, signed 64-bit | L98-99; `received_at` is collector-assigned, L75 | `i64` on `accepted_at`, `received_at`, `terms_accepted_at` |
|
||||
| Ids are non-empty opaque strings, `event_id` verbatim; versions unsigned 32-bit and positive | L100-103 | `validate()`; `notice_version: u32`; `check_sample_id` parses `u32` and refuses a leading zero in the version (`s@01`) so one version has one key; the doc says only "positive integer", so Heimdall may instead rule that the crate normalises |
|
||||
| Ids are non-empty opaque strings, `event_id` verbatim; every version field (`notice_version`, the `@N` of a sample id, `terms_version`) is a positive integer, no leading zeros, no sign, at most 4294967295 (Heimdall ruling 2026-11-07, one rule) | L100-103 | `check_version` is the single rule; `check_sample_id` and `MemberTerms::validate` call it; `notice_version: u32` with `validate()` refusing 0; golden vector `newcomer` carries `terms_version` 4294967295 |
|
||||
| Key parts joined with U+001F; empty part or part containing it refused | L104-105 | `key()` |
|
||||
| Tombstone is the exited `Answer` with `text_hash` (32 bytes); HMAC-SHA-256 keyed by a per-member salt held by the member-side service; the collector cannot compute it | L74, L132-152, L186-193 | `Answer.text_hash`; `redacted([u8; 32])` takes the service's output and refuses an answer with no `text` (L148-149); `validate()` requires 32 bytes and `exited` |
|
||||
| A reply with no established notice version or scope is stored with both absent, writes no acceptance, is excluded from every use | L87-94 | `notice_version`/`scope` optional and absent together (`validate()`); vector case `no-scope` |
|
||||
|
|
|
|||
|
|
@ -35,17 +35,25 @@ fn nonempty(name: &str, value: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// `<sample>@<version>`, version a positive unsigned 32-bit integer written without leading zeros
|
||||
/// (`s@01` and `s@1` would be two keys for one version). The version is part of the id, so it is part
|
||||
/// of every answer key and no key can name a sample without one.
|
||||
/// The one version rule (Heimdall ruling 2026-11-07): a positive integer written without leading
|
||||
/// zeros or a sign, at most 4294967295 (`u32::MAX`). `01` and `1` would be two keys for one version.
|
||||
pub fn check_version(version: &str) -> Result<u32> {
|
||||
match version.parse::<u32>() {
|
||||
Ok(v) if v > 0 && !version.starts_with(['+', '0']) => Ok(v),
|
||||
_ => bail!("version {version:?} must be a positive integer, no leading zeros, at most {}", u32::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<sample>@<version>`, the version under `check_version`. The version is part of the id, so it is
|
||||
/// part of every answer key and no key can name a sample without one.
|
||||
pub fn check_sample_id(sample_id: &str) -> Result<(&str, u32)> {
|
||||
let Some((sample, version)) = sample_id.rsplit_once('@') else {
|
||||
bail!("sample id {sample_id:?} has no @version");
|
||||
};
|
||||
match version.parse::<u32>() {
|
||||
Ok(v) if v > 0 && !sample.is_empty() && !version.starts_with(['+', '0']) => Ok((sample, v)),
|
||||
_ => bail!("sample id {sample_id:?} needs a non-empty sample and a positive integer version without leading zeros"),
|
||||
if sample.is_empty() {
|
||||
bail!("sample id {sample_id:?} needs a non-empty sample");
|
||||
}
|
||||
Ok((sample, check_version(version)?))
|
||||
}
|
||||
|
||||
/// The first reply a member posts under a notice version. Written once, never updated.
|
||||
|
|
@ -207,7 +215,7 @@ pub struct MemberTerms {
|
|||
impl MemberTerms {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
nonempty("member_id", &self.member_id)?;
|
||||
nonempty("terms_version", &self.terms_version)
|
||||
check_version(&self.terms_version).map(drop)
|
||||
}
|
||||
|
||||
/// `member_id`.
|
||||
|
|
|
|||
|
|
@ -154,13 +154,9 @@ fn golden_vectors() {
|
|||
put_answer(&mut cache, &a.redacted(hash).unwrap());
|
||||
}
|
||||
"terms" => {
|
||||
cache
|
||||
.put(MemberTerms::cache_key(member).unwrap(), &MemberTerms {
|
||||
member_id: member.into(),
|
||||
terms_version: payload.into(),
|
||||
terms_accepted_at: ts,
|
||||
})
|
||||
.unwrap();
|
||||
let terms = MemberTerms { member_id: member.into(), terms_version: payload.into(), terms_accepted_at: ts };
|
||||
terms.validate().unwrap();
|
||||
cache.put(MemberTerms::cache_key(member).unwrap(), &terms).unwrap();
|
||||
}
|
||||
"expect_terms" => {
|
||||
let t = cache.get_required::<MemberTerms>(&MemberTerms::cache_key(member).unwrap()).unwrap();
|
||||
|
|
@ -226,6 +222,35 @@ fn key_refuses_unversioned_sample_ids() {
|
|||
assert!(Answer::cache_key("r", "s@1", "m").unwrap() != Answer::cache_key("r", "s@2", "m").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_version_rule_everywhere() {
|
||||
for ok in ["1", "2", "4294967295"] {
|
||||
let n: u32 = ok.parse().unwrap();
|
||||
assert_eq!(check_version(ok).unwrap(), n, "{ok}");
|
||||
assert_eq!(check_sample_id(&format!("s@{ok}")).unwrap(), ("s", n), "s@{ok}");
|
||||
assert!(MemberTerms { member_id: "m".into(), terms_version: ok.into(), terms_accepted_at: 1 }.validate().is_ok(), "terms {ok}");
|
||||
}
|
||||
for bad in ["0", "4294967296", "4294967297", "8589934593", "99999999999999999999", "01", "+1", "-1", "", "2026-11"] {
|
||||
assert!(check_version(bad).is_err(), "{bad}");
|
||||
assert!(check_sample_id(&format!("s@{bad}")).is_err(), "s@{bad}");
|
||||
assert!(MemberTerms { member_id: "m".into(), terms_version: bad.into(), terms_accepted_at: 1 }.validate().is_err(), "terms {bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notice_version_zero_is_refused_in_both_records() {
|
||||
let acc = NoticeAcceptance {
|
||||
member_id: "m".into(),
|
||||
experiment_id: "r".into(),
|
||||
notice_version: 0,
|
||||
scope: Scope::Train,
|
||||
accepted_at: 1,
|
||||
event_id: "e".into(),
|
||||
};
|
||||
assert!(acc.validate().is_err());
|
||||
assert!(NoticeAcceptance { notice_version: u32::MAX, ..acc }.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acceptance_after_the_answer_does_not_cover_it() {
|
||||
let a = Answer {
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ redacted-reply,redact,m6,room1,,,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5
|
|||
redacted-reply,expect_exited,m6,room1,,,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90,100,e9
|
||||
redacted-reply,expect_train,,,,,,e10,,
|
||||
redacted-reply,expect_counts,,,,,,answers=2;acceptances=2,,
|
||||
newcomer,terms,m8,,,,,2026-11,50,
|
||||
newcomer,expect_terms,m8,,,,,2026-11,50,
|
||||
newcomer,terms,m8,,,,,4294967295,50,
|
||||
newcomer,expect_terms,m8,,,,,4294967295,50,
|
||||
newcomer,expect_no_terms,m9,,,,,,,
|
||||
newcomer,reply,m8,room1,3,train,s1@1,hello,100,e11
|
||||
newcomer,reply,m9,room1,3,train,s1@1,veteran,110,e12
|
||||
|
|
|
|||
|
Loading…
Reference in a new issue