winreg_artifacts/path_expansion.rs
1//! Unified registry path-expansion engine.
2//!
3//! Glob (`*`/`**`), control-set (`CurrentControlSet`), and multi-user
4//! (`HKU\%%sid%%`) resolution are the **same operation**: a catalog path with
5//! one or more **variable segments**, each ranging over an **enumerable
6//! domain**, expanded into concrete paths — each tagged with the [`Binding`]s
7//! that record which domain element produced it. Only the domain differs:
8//!
9//! - [`Wildcard::Subkey`] (`*` / `**`) → the subkeys of a node (intra-hive).
10//! - [`Wildcard::ControlSet`] (`CurrentControlSet`) → the `ControlSet00N` set
11//! selected by `Select\Current` (intra-SYSTEM-hive).
12//! - [`Wildcard::User`] (`HKU\%%sid%%` / `NtUser`) → the per-user profile hives
13//! (cross-file; bound by the caller, [`crate::catalog_scan::scan_users`]).
14//!
15//! This module owns the intra-hive walk for the `Subkey` and `ControlSet`
16//! domains; the `User` domain is bound one level up because it selects *which
17//! hive file* to walk. The proven glob matching/caps live here unchanged — the
18//! engine wraps them as the `Subkey` domain source rather than rewriting them.
19
20use winreg_core::key::Key;
21
22/// The domain a variable path segment ranges over.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
24pub enum Wildcard {
25 /// `*` / `**` — ranges over the subkeys of a node (intra-hive).
26 Subkey,
27 /// `CurrentControlSet` — ranges over the active `ControlSet00N`
28 /// (intra-SYSTEM-hive), selected by `Select\Current`.
29 ControlSet,
30 /// `HKU\%%sid%%` / per-user `NtUser` — ranges over the profile hives
31 /// (cross-file). Bound by the multi-user scan, not by this engine.
32 User,
33}
34
35/// One variable resolution, carried on each hit for provenance.
36///
37/// For example `{Subkey, "{CLSID…}"}`, `{ControlSet, "ControlSet002"}`, or
38/// `{User, "S-1-5-21-…-1001"}`.
39#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
40pub struct Binding {
41 /// Which domain this binding came from.
42 pub kind: Wildcard,
43 /// The concrete domain element selected (child-key name, control-set name,
44 /// or user SID/profile).
45 pub value: String,
46}
47
48impl Binding {
49 /// Construct a binding for `kind` selecting `value`.
50 pub fn new(kind: Wildcard, value: impl Into<String>) -> Self {
51 Self {
52 kind,
53 value: value.into(),
54 }
55 }
56}
57
58/// One component of an expansion template.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Segment {
61 /// An exact key name to descend into.
62 Literal(String),
63 /// A variable segment ranging over [`Wildcard`]'s domain. The string is the
64 /// match pattern: a glob (`*`, `*ControlSet*`) for `Subkey`/`ControlSet`,
65 /// otherwise contextual.
66 Variable(Wildcard, String),
67}
68
69/// Maximum key-tree depth a `**` recursive-descent walk will visit.
70///
71/// Untrusted hives can be crafted with pathological nesting; this bounds the
72/// recursion so a malicious image cannot drive unbounded stack/heap growth.
73pub(crate) const MAX_GLOB_DEPTH: usize = 64;
74
75/// Maximum number of concrete keys a single template may expand to.
76///
77/// Caps breadth so a hive with millions of sibling keys under a `*` cannot make
78/// one template produce an unbounded result set (allocation-bomb defence).
79pub(crate) const MAX_GLOB_MATCHES: usize = 4096;
80
81/// The set of `ControlSet00N` names a `CurrentControlSet` segment expands to,
82/// resolved from `SYSTEM\Select\Current`.
83///
84/// Normally a single active set; absent/unreadable `Select\Current` degrades to
85/// `ControlSet001` (see [`resolve_control_sets`]).
86#[derive(Debug, Clone)]
87pub struct ControlSetResolver {
88 /// The concrete `ControlSet00N` names the alias resolves to (in expansion
89 /// order). At least one element.
90 pub sets: Vec<String>,
91}
92
93/// Read `SYSTEM\Select\Current` (a `REG_DWORD`, value `N`) and resolve the
94/// `CurrentControlSet` alias to `ControlSet00N`.
95///
96/// Uses `Current` (the control set that was *running*), never `Default`. If
97/// `Select\Current` is absent, unreadable, or zero, falls back to
98/// `ControlSet001` — degrade, never panic. Reads are bounds-checked against the
99/// untrusted hive via winreg-core's value API.
100#[must_use]
101pub fn resolve_control_sets(root: &Key<'_>) -> ControlSetResolver {
102 let n = current_control_set_number(root).unwrap_or(1);
103 ControlSetResolver {
104 sets: vec![format!("ControlSet{n:03}")],
105 }
106}
107
108/// Read the active control-set number from `Select\Current`, or `None` when the
109/// key/value is absent, unreadable, or zero.
110fn current_control_set_number(root: &Key<'_>) -> Option<u32> {
111 let select = root.subkey("Select").ok()??;
112 let current = select.value("Current").ok()??;
113 // `as_u32` is bounds-checked and infallible on short data (returns 0).
114 let n = current.as_u32().ok()?;
115 if n == 0 {
116 None
117 } else {
118 Some(n)
119 }
120}
121
122/// Expand `segments` against the key tree rooted at `root`, invoking `emit` with
123/// `(bindings, concrete_path, &matched_key)` for every concrete key that matches
124/// the whole template.
125///
126/// `controlset` supplies the `ControlSet00N` names a [`Wildcard::ControlSet`]
127/// segment ranges over; it may be `None` when the template contains no
128/// `ControlSet` segment. `User` bindings are not produced here — the multi-user
129/// scan binds them when it selects the hive.
130pub fn expand(
131 root: &Key<'_>,
132 segments: &[Segment],
133 controlset: Option<&ControlSetResolver>,
134 emit: &mut dyn FnMut(&[Binding], &str, &Key<'_>),
135) {
136 let mut bindings: Vec<Binding> = Vec::new();
137 let mut matched = 0usize;
138 walk(
139 root,
140 segments,
141 controlset,
142 "",
143 0,
144 &mut matched,
145 &mut bindings,
146 emit,
147 );
148}
149
150/// Recursive template walk shared by every domain source.
151// The three match arms each recurse with the same 8-argument state; splitting
152// them into helpers would thread that state through extra signatures and
153// obscure the single recursion, so keep the arms inline.
154#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
155fn walk(
156 key: &Key<'_>,
157 segments: &[Segment],
158 controlset: Option<&ControlSetResolver>,
159 prefix: &str,
160 depth: usize,
161 matched: &mut usize,
162 bindings: &mut Vec<Binding>,
163 emit: &mut dyn FnMut(&[Binding], &str, &Key<'_>),
164) {
165 if *matched >= MAX_GLOB_MATCHES || depth > MAX_GLOB_DEPTH {
166 return;
167 }
168 let Some((head, rest)) = segments.split_first() else {
169 // All segments consumed — `key` is itself the concrete match.
170 *matched += 1;
171 emit(bindings, prefix, key);
172 return;
173 };
174
175 match head {
176 Segment::Literal(name) => {
177 let Ok(children) = key.subkeys() else { return };
178 for child in children {
179 if child.name().eq_ignore_ascii_case(name) {
180 let child_prefix = join_path(prefix, &child.name());
181 walk(
182 &child,
183 rest,
184 controlset,
185 &child_prefix,
186 depth + 1,
187 matched,
188 bindings,
189 emit,
190 );
191 break;
192 }
193 }
194 }
195 Segment::Variable(Wildcard::ControlSet, _) => {
196 // Domain = the active control set(s) from Select\Current. Default to
197 // ControlSet001 when no resolver was supplied (degrade, never panic).
198 let fallback = ControlSetResolver {
199 sets: vec!["ControlSet001".to_string()],
200 };
201 let resolver = controlset.unwrap_or(&fallback);
202 let Ok(children) = key.subkeys() else { return };
203 for set_name in &resolver.sets {
204 if *matched >= MAX_GLOB_MATCHES {
205 return;
206 }
207 for child in &children {
208 if child.name().eq_ignore_ascii_case(set_name) {
209 let child_prefix = join_path(prefix, &child.name());
210 bindings.push(Binding::new(Wildcard::ControlSet, child.name()));
211 walk(
212 child,
213 rest,
214 controlset,
215 &child_prefix,
216 depth + 1,
217 matched,
218 bindings,
219 emit,
220 );
221 bindings.pop();
222 break;
223 }
224 }
225 }
226 }
227 Segment::Variable(Wildcard::Subkey, pattern) => {
228 if pattern.contains("**") {
229 // `**` matches zero levels: try the remaining pattern here…
230 walk(
231 key, rest, controlset, prefix, depth, matched, bindings, emit,
232 );
233 // …and any number of levels: descend into every child, keeping `**`.
234 let Ok(children) = key.subkeys() else { return };
235 for child in children {
236 if *matched >= MAX_GLOB_MATCHES {
237 return;
238 }
239 let child_prefix = join_path(prefix, &child.name());
240 bindings.push(Binding::new(Wildcard::Subkey, child.name()));
241 walk(
242 &child,
243 segments,
244 controlset,
245 &child_prefix,
246 depth + 1,
247 matched,
248 bindings,
249 emit,
250 );
251 bindings.pop();
252 }
253 } else {
254 let Ok(children) = key.subkeys() else { return };
255 for child in children {
256 if *matched >= MAX_GLOB_MATCHES {
257 return;
258 }
259 if segment_matches(pattern, &child.name()) {
260 let child_prefix = join_path(prefix, &child.name());
261 bindings.push(Binding::new(Wildcard::Subkey, child.name()));
262 walk(
263 &child,
264 rest,
265 controlset,
266 &child_prefix,
267 depth + 1,
268 matched,
269 bindings,
270 emit,
271 );
272 bindings.pop();
273 }
274 }
275 }
276 }
277 // `User` is bound by the multi-user scan, never reached intra-hive.
278 Segment::Variable(Wildcard::User, _) => {} // cov:unreachable: User segments are stripped to a User binding by scan_users before expand() is called.
279 }
280}
281
282/// Join a hive-relative prefix with a child name using `\` separators.
283fn join_path(prefix: &str, name: &str) -> String {
284 if prefix.is_empty() {
285 name.to_string()
286 } else {
287 format!("{prefix}\\{name}")
288 }
289}
290
291/// Match a single path component against a glob `pattern` that may contain `*`
292/// wildcards anywhere (case-insensitive). `*` matches any run of characters.
293fn segment_matches(pattern: &str, name: &str) -> bool {
294 let pat: Vec<char> = pattern.to_ascii_lowercase().chars().collect();
295 let txt: Vec<char> = name.to_ascii_lowercase().chars().collect();
296 glob_match(&pat, &txt)
297}
298
299/// Iterative `*`-only glob matcher over char slices (no backtracking blow-up).
300fn glob_match(pat: &[char], txt: &[char]) -> bool {
301 let (mut p, mut t) = (0usize, 0usize);
302 let (mut star, mut mark) = (None, 0usize);
303 while t < txt.len() {
304 if p < pat.len() && pat[p] == '*' {
305 star = Some(p);
306 mark = t;
307 p += 1;
308 } else if p < pat.len() && pat[p] == txt[t] {
309 p += 1;
310 t += 1;
311 } else if let Some(sp) = star {
312 p = sp + 1;
313 mark += 1;
314 t = mark;
315 } else {
316 return false;
317 }
318 }
319 while p < pat.len() && pat[p] == '*' {
320 p += 1;
321 }
322 p == pat.len()
323}
324
325#[cfg(test)]
326#[allow(clippy::unwrap_used, clippy::expect_used)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn segment_match_handles_midsegment_wildcard() {
332 assert!(segment_matches("*ControlSet*", "ControlSet001"));
333 assert!(segment_matches("*", "anything"));
334 assert!(segment_matches("ABC*", "abcdef"));
335 assert!(!segment_matches("ABC*", "xyz"));
336 assert!(!segment_matches("Foo", "Bar"));
337 }
338
339 #[test]
340 fn binding_new_constructs() {
341 let b = Binding::new(Wildcard::ControlSet, "ControlSet002");
342 assert_eq!(b.kind, Wildcard::ControlSet);
343 assert_eq!(b.value, "ControlSet002");
344 }
345}