use std::fs;
use std::path::PathBuf;
use serde_json::Value;
use strum::{EnumIter, IntoEnumIterator};
#[derive(Debug, EnumIter)]
#[allow(dead_code)]
enum ErrorVariantName {
InvalidHrp,
MixedCase,
InvalidStringLength,
InvalidChar,
BchUncorrectable,
UnsupportedCardType,
MalformedPayloadPadding,
ChunkSetIdMismatch,
ChunkedHeaderMalformed,
MixedHeaderTypes,
CrossChunkHashMismatch,
UnsupportedVersion,
ReservedBitsSet,
InvalidPolicyIdStubCount,
InvalidPathIndicator,
PathTooDeep,
InvalidPathComponent,
InvalidXpubVersion,
InvalidXpubPublicKey,
UnexpectedEnd,
TrailingBytes,
CardPayloadTooLarge,
}
impl ErrorVariantName {
fn display_prefix(&self) -> &'static str {
match self {
Self::InvalidHrp => "invalid HRP",
Self::MixedCase => "mixed case",
Self::InvalidStringLength => "invalid data-part length",
Self::InvalidChar => "invalid character",
Self::BchUncorrectable => "BCH uncorrectable",
Self::UnsupportedCardType => "unsupported card type",
Self::MalformedPayloadPadding => "malformed payload padding",
Self::ChunkSetIdMismatch => "chunk_set_id mismatch",
Self::ChunkedHeaderMalformed => "chunked-header malformed",
Self::MixedHeaderTypes => "mixed string-layer header types",
Self::CrossChunkHashMismatch => "cross-chunk integrity hash mismatch",
Self::UnsupportedVersion => "unsupported version",
Self::ReservedBitsSet => "reserved bits set",
Self::InvalidPolicyIdStubCount => "policy_id_stub_count must be >= 1",
Self::InvalidPathIndicator => "invalid path indicator byte",
Self::PathTooDeep => "path too deep",
Self::InvalidPathComponent => "invalid path component",
Self::InvalidXpubVersion => "invalid xpub version",
Self::InvalidXpubPublicKey => "invalid xpub public key",
Self::UnexpectedEnd => "unexpected end of bytecode",
Self::TrailingBytes => "trailing bytes after xpub",
Self::CardPayloadTooLarge => "card payload too large",
}
}
}
fn is_exempt(variant: &ErrorVariantName) -> Option<&'static str> {
match variant {
ErrorVariantName::CardPayloadTooLarge => Some(
"encoder-only: emitted from `split_into_chunks` (chunk.rs); not \
reachable via `decode`'s string-input path because chunked input \
is bounded by `MAX_CHUNKS=32 × 53-byte fragments = 1696 bytes` \
stream, exactly the encoder's emit ceiling.",
),
_ => None,
}
}
const VECTOR_FILE: &str = "src/test_vectors/v0.1.json";
fn read_corpus() -> Value {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(VECTOR_FILE);
let bytes = fs::read(&path).expect("read src/test_vectors/v0.1.json");
serde_json::from_slice(&bytes).expect("parse vectors JSON")
}
#[test]
fn every_error_variant_is_exercised_or_explicitly_exempt() {
let doc = read_corpus();
let vectors = doc["vectors"].as_array().expect("vectors is array");
let mut missing: Vec<String> = Vec::new();
for variant in ErrorVariantName::iter() {
let prefix = variant.display_prefix();
if let Some(reason) = is_exempt(&variant) {
let leaked = vectors.iter().any(|v| {
v["expected_error"]
.as_str()
.map(|s| s.starts_with(prefix))
.unwrap_or(false)
});
assert!(
!leaked,
"variant {variant:?} is exempt ({reason}) but a corpus vector \
carries `expected_error` starting with {prefix:?} — \
either remove the vector or remove the exemption"
);
continue;
}
let covered = vectors.iter().any(|v| {
v["expected_error"]
.as_str()
.map(|s| s.starts_with(prefix))
.unwrap_or(false)
});
if !covered {
missing.push(format!("{variant:?} (expected prefix: {prefix:?})"));
}
}
assert!(
missing.is_empty(),
"negative-vector parity gap — the following Error variants have no \
corpus vector pinning a matching `expected_error` prefix:\n {}\n\n\
To resolve: either add a negative vector to src/test_vectors/v0.1.json \
(regenerate via gen_mk_vectors), or add the variant to is_exempt() \
in this file with a one-line rationale.",
missing.join("\n ")
);
}
#[test]
fn every_negative_vector_maps_to_a_known_variant() {
let doc = read_corpus();
let vectors = doc["vectors"].as_array().expect("vectors is array");
let prefixes: Vec<&'static str> = ErrorVariantName::iter()
.map(|v| v.display_prefix())
.collect();
let mut orphans: Vec<String> = Vec::new();
for v in vectors {
let name = v["name"].as_str().unwrap_or("<unnamed>").to_string();
let Some(expected) = v["expected_error"].as_str() else {
continue; };
let matches_any = prefixes.iter().any(|p| expected.starts_with(p));
if !matches_any {
orphans.push(format!("{name}: {expected:?}"));
}
}
assert!(
orphans.is_empty(),
"the following negative vectors carry an `expected_error` that doesn't \
start with any known Error variant's Display prefix — either fix the \
vector or update ErrorVariantName / display_prefix() in this file:\n {}",
orphans.join("\n ")
);
}