fuzz: land the name_validation target (§3.3, wave 7)
This commit is contained in:
1 parent
f7c1bd79b0
commit
5043f7bcd8
52 files changed
+465
-6
No files matched your search
@@ -5,4 +5,5 @@
|
||||
|
||||
pub mod arbitrary_value;
|
||||
pub mod every_parse;
|
||||
pub mod name_validation;
|
||||
pub mod payload_codec;
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Invariant logic for the `name_validation` target (docs/research/
|
||||
//! fuzzing.md §3.3, `alkstore/src/validation.rs`): the entry-point
|
||||
//! name-validation surface over the whole string space — no-panic on
|
||||
//! any byte sequence (lossy-decoded and `arbitrary`-structured alike),
|
||||
//! deterministic accept/reject (double-invocation pinned), rejection
|
||||
//! only via the typed `InvalidName`/`ReservedName` taxonomy
|
||||
//! (ADR-008 §4/§5), the reserved-prefix rule byte-exact and
|
||||
//! leading-only with the shared/local kind split (the local kind takes
|
||||
//! the non-empty rule only — a leading `__alkstore_` is legal there),
|
||||
//! the error carrying the supplied name verbatim (names are not
|
||||
//! trimmed), and length-independence of the verdict class. No length
|
||||
//! cap is pinned (ADR-008 §4 pins non-empty + reserved-prefix only), so
|
||||
//! "exactness at the cap" is pinned as: nothing at any length flips a
|
||||
//! verdict — the only exact boundaries in the rule are empty↔one-char,
|
||||
//! whitespace-only↔content, and the byte-exact prefix itself.
|
||||
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
|
||||
use alkstore::{
|
||||
validate_local_name, validate_name, validate_shared_name, Error, NameKind,
|
||||
RESERVED_LISTENER_RECONNECTED, RESERVED_PREFIX,
|
||||
};
|
||||
|
||||
/// The probed length neighborhoods: the empty↔one-char boundary
|
||||
/// (0↔1), the reserved-prefix length (11↔12), the Postgres identifier
|
||||
/// bytes (63↔64), the target's max_len (4095↔4096), and the
|
||||
/// payload-cap scale (65536). None of these is a pinned cap — the ADR
|
||||
/// pins no length limit — so the invariant is that the verdict class
|
||||
/// never changes with length.
|
||||
const LENGTH_PROBES: &[usize] = &[0, 1, 2, 3, 11, 12, 62, 63, 64, 65, 4095, 4096, 65_536];
|
||||
|
||||
/// The verdict of one validation call, narrowed to the taxonomy's typed
|
||||
/// rejections: `Accept`, `InvalidName { name }`, or
|
||||
/// `ReservedName { name }`. Any other outcome is a contract violation
|
||||
/// and panics at construction — the typed-rejection invariant.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Verdict {
|
||||
Accept,
|
||||
InvalidName(String),
|
||||
ReservedName(String),
|
||||
}
|
||||
|
||||
pub fn fuzz_name_validation(data: &[u8]) {
|
||||
let lossy = String::from_utf8_lossy(data);
|
||||
assert_verdict_invariants(&lossy);
|
||||
assert_verdict_battery();
|
||||
assert_structured(data);
|
||||
assert_length_independence();
|
||||
}
|
||||
|
||||
/// One string across the whole invariant set: deterministic (the
|
||||
/// double-invocation pin), typed-only, all three entry points agreeing,
|
||||
/// and the observed verdicts matching the locally recomputed rule.
|
||||
fn assert_verdict_invariants(name: &str) {
|
||||
let shared = shared_verdict(name);
|
||||
assert_eq!(
|
||||
shared,
|
||||
shared_verdict(name),
|
||||
"the shared-kind verdict must be deterministic for {name:?}"
|
||||
);
|
||||
let local = local_verdict(name);
|
||||
assert_eq!(
|
||||
local,
|
||||
local_verdict(name),
|
||||
"the local-kind verdict must be deterministic for {name:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
shared,
|
||||
kind_verdict(NameKind::Shared, name),
|
||||
"`validate_shared_name` must agree with `validate_name(Shared)` for {name:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
local,
|
||||
kind_verdict(NameKind::Local, name),
|
||||
"`validate_local_name` must agree with `validate_name(Local)` for {name:?}"
|
||||
);
|
||||
assert_classification(name, &shared, &local);
|
||||
}
|
||||
|
||||
/// The rule of record recomputed locally and asserted against the
|
||||
/// observed verdicts: empty or whitespace-only → `InvalidName` on both
|
||||
/// kinds (names are not trimmed — the rejection never trims the name it
|
||||
/// reports); otherwise the byte-exact leading reserved prefix rejects
|
||||
/// `ReservedName` on the shared kind and is legal on the local kind;
|
||||
/// anything else accepts on both kinds.
|
||||
fn assert_classification(name: &str, shared: &Verdict, local: &Verdict) {
|
||||
if name.is_empty() || name.trim().is_empty() {
|
||||
assert_eq!(
|
||||
shared,
|
||||
&Verdict::InvalidName(name.to_owned()),
|
||||
"empty/whitespace-only must reject `InvalidName` on the shared kind for {name:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
local,
|
||||
&Verdict::InvalidName(name.to_owned()),
|
||||
"empty/whitespace-only must reject `InvalidName` on the local kind for {name:?}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if name.starts_with(RESERVED_PREFIX) {
|
||||
assert_eq!(
|
||||
shared,
|
||||
&Verdict::ReservedName(name.to_owned()),
|
||||
"a leading reserved prefix must reject `ReservedName` on the shared kind for {name:?} — the error carries the name verbatim"
|
||||
);
|
||||
assert_eq!(
|
||||
local,
|
||||
&Verdict::Accept,
|
||||
"the local kind takes the non-empty rule only — a leading reserved prefix is legal there: {name:?}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
shared,
|
||||
&Verdict::Accept,
|
||||
"a non-empty, non-whitespace shared name without the exact reserved prefix must accept: {name:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
local,
|
||||
&Verdict::Accept,
|
||||
"a non-empty, non-whitespace local name must accept: {name:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The casing/boundary battery for the reserved-prefix rule: the rule
|
||||
/// is byte-exact and leading-only, so the near-miss forms (casing
|
||||
/// deviations, the missing trailing underscore, embedded and
|
||||
/// whitespace-led prefix occurrences) must accept on the shared kind,
|
||||
/// while the byte-exact prefix (alone, suffixed, whitespace-suffixed —
|
||||
/// anything not all-whitespace) must reject.
|
||||
const BATTERY: &[&str] = &[
|
||||
"",
|
||||
" ",
|
||||
"\t\n",
|
||||
"\u{a0}",
|
||||
"a",
|
||||
"é-😀",
|
||||
"a\u{0}b",
|
||||
"__alkstore_",
|
||||
"__alkstore_x",
|
||||
RESERVED_LISTENER_RECONNECTED,
|
||||
"__alkstore_ ",
|
||||
"__alkstore_x_",
|
||||
"__alkstore",
|
||||
"__ALKSTORE_x",
|
||||
"__Alkstore_x",
|
||||
"__alkStore_x",
|
||||
"__alkstore_X",
|
||||
"x__alkstore_y",
|
||||
" __alkstore_x",
|
||||
];
|
||||
|
||||
fn assert_verdict_battery() {
|
||||
for name in BATTERY {
|
||||
assert_verdict_invariants(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// The structured path: `arbitrary`-derived strings plus their
|
||||
/// boundary-derivative forms (leading/trailing/embedded reserved
|
||||
/// prefix, whitespace-wrapped, case-shifted) through the same
|
||||
/// invariant set.
|
||||
fn assert_structured(data: &[u8]) {
|
||||
let mut u = Unstructured::new(data);
|
||||
for _ in 0..2 {
|
||||
let Ok(name) = String::arbitrary(&mut u) else {
|
||||
return;
|
||||
};
|
||||
assert_verdict_invariants(&name);
|
||||
for variant in derived_variants(&name) {
|
||||
assert_verdict_invariants(&variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn derived_variants(name: &str) -> Vec<String> {
|
||||
let mut variants = vec![
|
||||
format!("{}{}", RESERVED_PREFIX, name),
|
||||
format!("{}{}", name, RESERVED_PREFIX),
|
||||
format!("x{}{}y", RESERVED_PREFIX, name),
|
||||
format!(" {}", name),
|
||||
format!("{} ", name),
|
||||
];
|
||||
variants.push(name.to_uppercase());
|
||||
variants
|
||||
}
|
||||
|
||||
/// Verdict-class length-independence: the same content class (plain,
|
||||
/// whitespace-only, reserved-prefixed, casing near-miss, embedded
|
||||
/// prefix) holds its verdict at every probed length. No length cap is
|
||||
/// pinned — a length anywhere is not a rejection cause.
|
||||
fn assert_length_independence() {
|
||||
for &len in LENGTH_PROBES {
|
||||
let plain = "a".repeat(len);
|
||||
assert_verdict_invariants(&plain);
|
||||
let ws_only = " ".repeat(len);
|
||||
assert_verdict_invariants(&ws_only);
|
||||
let reserved = format!("{}{}", RESERVED_PREFIX, "a".repeat(len));
|
||||
assert_verdict_invariants(&reserved);
|
||||
let casing = format!("__ALKSTORE_{}", "a".repeat(len));
|
||||
assert_verdict_invariants(&casing);
|
||||
let embedded = format!("{}{}{}", "a".repeat(len), RESERVED_PREFIX, "b".repeat(len));
|
||||
assert_verdict_invariants(&embedded);
|
||||
}
|
||||
}
|
||||
|
||||
fn shared_verdict(name: &str) -> Verdict {
|
||||
match validate_shared_name(name) {
|
||||
Ok(()) => Verdict::Accept,
|
||||
Err(Error::InvalidName { name }) => Verdict::InvalidName(name),
|
||||
Err(Error::ReservedName { name }) => Verdict::ReservedName(name),
|
||||
Err(other) => panic!(
|
||||
"shared-name rejection must be typed `InvalidName`/`ReservedName`, got {other:?} for {name:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_verdict(name: &str) -> Verdict {
|
||||
match validate_local_name(name) {
|
||||
Ok(()) => Verdict::Accept,
|
||||
Err(Error::InvalidName { name }) => Verdict::InvalidName(name),
|
||||
Err(Error::ReservedName { name }) => Verdict::ReservedName(name),
|
||||
Err(other) => panic!(
|
||||
"local-name rejection must be typed `InvalidName`/`ReservedName`, got {other:?} for {name:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_verdict(kind: NameKind, name: &str) -> Verdict {
|
||||
match validate_name(kind, name) {
|
||||
Ok(()) => Verdict::Accept,
|
||||
Err(Error::InvalidName { name }) => Verdict::InvalidName(name),
|
||||
Err(Error::ReservedName { name }) => Verdict::ReservedName(name),
|
||||
Err(other) => panic!(
|
||||
"name rejection must be typed `InvalidName`/`ReservedName`, got {other:?} for {name:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod corpus_replay {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn committed_corpus_replays_through_the_invariants() {
|
||||
let corpus = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../corpus/name_validation");
|
||||
let mut count = 0usize;
|
||||
for entry in std::fs::read_dir(&corpus).expect("corpus directory is committed") {
|
||||
let path = entry.expect("corpus entry readable").path();
|
||||
let data = std::fs::read(&path).expect("corpus entry readable");
|
||||
fuzz_name_validation(&data);
|
||||
count += 1;
|
||||
}
|
||||
assert!(
|
||||
count >= 32,
|
||||
"committed seed corpus is present, found {count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invariants_hold_without_corpus() {
|
||||
fuzz_name_validation(b"");
|
||||
fuzz_name_validation(b"a");
|
||||
fuzz_name_validation(b" ");
|
||||
fuzz_name_validation(b"__alkstore_");
|
||||
fuzz_name_validation(b"__alkstore_listener_reconnected__");
|
||||
fuzz_name_validation(b"__alkstore_l\xffistener");
|
||||
fuzz_name_validation(&[0u8; 16]);
|
||||
fuzz_name_validation(&[b'a'; 65_537]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user