use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use syn::{Fields, Item, ItemStruct, Meta, Type};
const NULLABLE_BY_DESIGN: &[(&str, &str, &str)] = &[(
"GetKeyResponseBody",
"key",
"`keys/show/0.1#response` types this `oneOf: [KeyRecord, null]` and \
requires it present. Null is the answer meaning \"the custodian holds \
no key for this identifier\" — a successful answer, not an error, and \
a caller that cannot tell absence from failure retries a lookup that \
will never succeed. Omitting the member would violate `required`.",
)];
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Violation {
file: String,
strukt: String,
field: String,
}
fn protocols_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("protocols")
}
fn rust_sources(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).expect("protocols/ is readable") {
let path = entry.expect("readable dir entry").path();
if path.is_dir() {
rust_sources(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
fn derives_serialize(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| {
attr.path().is_ident("derive")
&& matches!(&attr.meta, Meta::List(l) if l.tokens.to_string().contains("Serialize"))
})
}
fn skips_none(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| {
attr.path().is_ident("serde")
&& matches!(&attr.meta, Meta::List(l) if l.tokens.to_string().contains("skip_serializing_if"))
})
}
fn is_option(ty: &Type) -> bool {
let Type::Path(p) = ty else { return false };
p.path
.segments
.last()
.is_some_and(|seg| seg.ident == "Option")
}
fn is_cfg_test(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| {
attr.path().is_ident("cfg")
&& matches!(&attr.meta, Meta::List(l) if l.tokens.to_string().contains("test"))
})
}
fn inspect_struct(s: &ItemStruct, file: &str, inspected: &mut usize, out: &mut Vec<Violation>) {
if !derives_serialize(&s.attrs) {
return;
}
let Fields::Named(fields) = &s.fields else {
return;
};
for field in &fields.named {
if !is_option(&field.ty) {
continue;
}
*inspected += 1;
let name = field
.ident
.as_ref()
.expect("named field has an ident")
.to_string();
if skips_none(&field.attrs) {
continue;
}
if NULLABLE_BY_DESIGN
.iter()
.any(|(st, f, _)| s.ident == st && *f == name)
{
continue;
}
out.push(Violation {
file: file.to_string(),
strukt: s.ident.to_string(),
field: name,
});
}
}
fn walk_items(items: &[Item], file: &str, inspected: &mut usize, out: &mut Vec<Violation>) {
for item in items {
match item {
Item::Struct(s) if !is_cfg_test(&s.attrs) => inspect_struct(s, file, inspected, out),
Item::Mod(m) if !is_cfg_test(&m.attrs) => {
if let Some((_, inner)) = &m.content {
walk_items(inner, file, inspected, out);
}
}
_ => {}
}
}
}
#[test]
fn no_wire_member_serializes_as_null() {
let root = protocols_dir();
let mut files = Vec::new();
rust_sources(&root, &mut files);
files.sort();
assert!(
files.len() > 10,
"found only {} source files under {} — the walk is broken, and a \
census that inspects nothing passes vacuously",
files.len(),
root.display()
);
let mut inspected = 0usize;
let mut violations = Vec::new();
for path in &files {
let src = fs::read_to_string(path).expect("source file is readable");
let parsed = syn::parse_file(&src)
.unwrap_or_else(|e| panic!("{}: does not parse: {e}", path.display()));
let rel = path
.strip_prefix(&root)
.unwrap_or(path)
.display()
.to_string();
walk_items(&parsed.items, &rel, &mut inspected, &mut violations);
}
assert!(
inspected > 100,
"the census inspected only {inspected} Option members across {} files. \
There are well over a hundred; this means the parser stopped \
recognising them, not that they went away.",
files.len()
);
violations.sort();
assert!(
violations.is_empty(),
"these wire members serialize as `null` when unset, and every Trust \
Task schema that types them rejects null:\n\n{}\n\n\
Add `#[serde(default, skip_serializing_if = \"Option::is_none\")]` so \
an unset member is absent from the payload. If `null` really is the \
correct wire form — the spec must say so, as `keys/show`'s `key` does \
— add it to NULLABLE_BY_DESIGN with that reason.\n\n\
This has shipped twice as a production outage (#895, #919). It is one \
attribute.",
violations
.iter()
.map(|v| format!(" {}: {}::{}", v.file, v.strukt, v.field))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn every_allowlist_entry_is_still_live() {
let root = protocols_dir();
let mut files = Vec::new();
rust_sources(&root, &mut files);
let mut unskipped_options: BTreeSet<(String, String)> = BTreeSet::new();
for path in &files {
let src = fs::read_to_string(path).expect("source file is readable");
let parsed = syn::parse_file(&src).expect("source parses");
collect_unskipped(&parsed.items, &mut unskipped_options);
}
for (strukt, field, reason) in NULLABLE_BY_DESIGN {
assert!(
!reason.trim().is_empty(),
"{strukt}::{field}: an exemption must state why null is correct"
);
assert!(
unskipped_options.contains(&(strukt.to_string(), field.to_string())),
"NULLABLE_BY_DESIGN exempts {strukt}::{field}, but no such \
unskipped Option member exists any more — it was renamed, \
removed, or given the skip. Drop the entry."
);
}
}
fn collect_unskipped(items: &[Item], out: &mut BTreeSet<(String, String)>) {
for item in items {
match item {
Item::Struct(s) if !is_cfg_test(&s.attrs) && derives_serialize(&s.attrs) => {
if let Fields::Named(fields) = &s.fields {
for field in &fields.named {
if is_option(&field.ty) && !skips_none(&field.attrs) {
out.insert((
s.ident.to_string(),
field
.ident
.as_ref()
.expect("named field has an ident")
.to_string(),
));
}
}
}
}
Item::Mod(m) if !is_cfg_test(&m.attrs) => {
if let Some((_, inner)) = &m.content {
collect_unskipped(inner, out);
}
}
_ => {}
}
}
}