Skip to main content

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}