Skip to content

Commit 73fea3b

Browse files
committed
sdk%test(codec): add TypeId uniqueness assertion at build-time
1 parent 3a46d9d commit 73fea3b

4 files changed

Lines changed: 185 additions & 2 deletions

File tree

Cargo.lock

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

contrib/codeql/lib/filters.qll

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,17 @@ predicate hasUnexpandedDerive(TypeItem t) {
6464
not exists(t.getADeriveMacroExpansion())
6565
}
6666

67+
/** Holds if `t` is defined in a build script. */
68+
predicate isBuildScript(TypeItem t) { fileOf(t).getAbsolutePath().matches("%/build.rs") }
69+
6770
/** Holds if `t` is a source type eligible for any check. */
6871
predicate isSourceType(TypeItem t) {
6972
(t instanceof Struct or t instanceof Enum or t instanceof Union) and
7073
fileOf(t).fromSource() and
71-
not isTestCode(t) and
74+
not isBuildScript(t) and
75+
not isLocalType(t) and
7276
not isMacroGenerated(t) and
73-
not isLocalType(t)
77+
not isTestCode(t)
7478
}
7579

7680
/** Holds if struct `s` has exactly one unnamed (tuple) field. */

pkgs/dev/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ json5 = { version = "0.4", optional = true }
3232
serde = { version = "1", default-features = false, features = ["derive", "alloc"], optional = true }
3333
serde_json = { version = "1", optional = true }
3434

35+
[build-dependencies]
36+
proc-macro2 = "1"
37+
syn = { version = "2", features = ["full", "visit"] }
38+
xxhash-rust = { version = "0.8", features = ["xxh32"] }
39+
3540
[lints]
3641
workspace = true
3742

pkgs/dev/build.rs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
//
2+
// Copyright (c) 2026-present, The Dash Core developers
3+
// SPDX-License-Identifier: MIT
4+
// See the accompanying file LICENSE or https://opensource.org/license/MIT
5+
//
6+
7+
//! Emits a `TypeId` collision-check table from workspace sources.
8+
9+
#![expect(clippy::unwrap_used, clippy::panic, reason = "build script")]
10+
11+
use proc_macro2::{TokenStream, TokenTree};
12+
use syn::visit::{visit_item_enum, visit_item_macro, visit_item_struct, Visit};
13+
use syn::{parse_file, Attribute, ItemEnum, ItemMacro, ItemStruct};
14+
use xxhash_rust::xxh32::xxh32;
15+
16+
use std::collections::{BTreeSet, HashMap};
17+
use std::path::{Path, PathBuf};
18+
use std::{env, fs};
19+
20+
const SCAN_DIRS: &[&str] = &[
21+
"pkgs/num/src",
22+
"pkgs/types/src",
23+
"pkgs/primitives/src",
24+
"pkgs/p2p_core/src",
25+
"pkgs/pkc/src",
26+
];
27+
28+
fn main() {
29+
let manifest = env::var("CARGO_MANIFEST_DIR").unwrap();
30+
let ws_root = PathBuf::from(&manifest)
31+
.parent()
32+
.unwrap()
33+
.parent()
34+
.unwrap()
35+
.to_path_buf();
36+
37+
let mut scan = ScanResult::default();
38+
39+
for dir in SCAN_DIRS {
40+
let src = ws_root.join(dir);
41+
if src.is_dir() {
42+
walk_rs_files(&src, &mut |path| scan_file(path, &mut scan));
43+
}
44+
println!("cargo::rerun-if-changed={}", ws_root.join(dir).display());
45+
}
46+
47+
for (macro_name, type_name) in &scan.pending {
48+
if scan.type_id_macros.contains(macro_name) {
49+
scan.names.insert(type_name.clone());
50+
}
51+
}
52+
53+
assert!(!scan.names.is_empty(), "scanner produced no TypeId entries");
54+
55+
let mut seen: HashMap<u32, &str> = HashMap::new();
56+
for name in &scan.names {
57+
let id = xxh32(name.as_bytes(), 0);
58+
if let Some(prev) = seen.insert(id, name) {
59+
panic!("TypeId collision: {name} and {prev} share id {id:#010x}");
60+
}
61+
}
62+
}
63+
64+
#[derive(Default)]
65+
struct ScanResult {
66+
/// `macro_rules!` names whose body contains `TypeId`.
67+
type_id_macros: BTreeSet<String>,
68+
/// Type names with direct `#[derive(TypeId)]`.
69+
names: BTreeSet<String>,
70+
/// Unresolved `(macro_name, type_name)` from invocation sites.
71+
pending: Vec<(String, String)>,
72+
}
73+
74+
fn walk_rs_files(dir: &Path, cb: &mut dyn FnMut(&Path)) {
75+
let entries = fs::read_dir(dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()));
76+
for entry in entries {
77+
let path = entry
78+
.unwrap_or_else(|e| panic!("cannot read entry in {}: {e}", dir.display()))
79+
.path();
80+
if path.is_dir() {
81+
walk_rs_files(&path, cb);
82+
} else if path.extension().is_some_and(|e| e == "rs") {
83+
cb(&path);
84+
}
85+
}
86+
}
87+
88+
fn scan_file(path: &Path, scan: &mut ScanResult) {
89+
let src = fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
90+
let Ok(file) = parse_file(&src) else {
91+
panic!("cannot parse {}", path.display());
92+
};
93+
94+
struct V<'a> {
95+
scan: &'a mut ScanResult,
96+
}
97+
98+
impl V<'_> {
99+
fn has_type_id_derive(attrs: &[Attribute]) -> bool {
100+
attrs.iter().any(|attr| {
101+
(attr.path().is_ident("derive") || attr.path().is_ident("cfg_attr")) && attr_contains_ident(attr, "TypeId")
102+
})
103+
}
104+
}
105+
106+
impl<'ast> Visit<'ast> for V<'_> {
107+
fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
108+
if Self::has_type_id_derive(&node.attrs) {
109+
self.scan.names.insert(node.ident.to_string());
110+
}
111+
visit_item_struct(self, node);
112+
}
113+
114+
fn visit_item_enum(&mut self, node: &'ast ItemEnum) {
115+
if Self::has_type_id_derive(&node.attrs) {
116+
self.scan.names.insert(node.ident.to_string());
117+
}
118+
visit_item_enum(self, node);
119+
}
120+
121+
fn visit_item_macro(&mut self, node: &'ast ItemMacro) {
122+
if let Some(ident) = &node.ident {
123+
if tokens_contain_ident(&node.mac.tokens, "TypeId") {
124+
self.scan.type_id_macros.insert(ident.to_string());
125+
}
126+
}
127+
if let Some((macro_name, type_name)) = extract_macro_invocation(node) {
128+
self.scan.pending.push((macro_name, type_name));
129+
}
130+
visit_item_macro(self, node);
131+
}
132+
}
133+
134+
V { scan }.visit_file(&file);
135+
}
136+
137+
/// Checks whether a token stream contains `target`, recursing into groups.
138+
fn tokens_contain_ident(tokens: &TokenStream, target: &str) -> bool {
139+
tokens.clone().into_iter().any(|tt| match tt {
140+
TokenTree::Ident(id) => id == target,
141+
TokenTree::Group(g) => tokens_contain_ident(&g.stream(), target),
142+
_ => false,
143+
})
144+
}
145+
146+
/// Checks whether an attribute contains `target` as a top-level ident.
147+
fn attr_contains_ident(attr: &Attribute, target: &str) -> bool {
148+
attr
149+
.meta
150+
.require_list()
151+
.ok()
152+
.into_iter()
153+
.flat_map(|list| list.tokens.clone())
154+
.any(|tt| matches!(tt, TokenTree::Ident(ref id) if id == target))
155+
}
156+
157+
/// Returns `(macro_name, last_UpperCamelCase_ident)` from an invocation.
158+
fn extract_macro_invocation(node: &ItemMacro) -> Option<(String, String)> {
159+
let macro_name = node.mac.path.segments.last()?.ident.to_string();
160+
161+
let mut last = None;
162+
for tt in node.mac.tokens.clone() {
163+
if let TokenTree::Ident(id) = &tt {
164+
let s = id.to_string();
165+
if s.starts_with(|c: char| c.is_uppercase()) {
166+
last = Some(s);
167+
}
168+
}
169+
}
170+
last.map(|name| (macro_name, name))
171+
}

0 commit comments

Comments
 (0)