Skip to main content

winreg_artifacts/
catalog_scan.rs

1//! Catalog-driven registry artifact scanner.
2//!
3//! The artifact *knowledge* — which keys matter, what they mean, and how to
4//! decode them — comes entirely from [`forensicnomicon`]'s registry catalog,
5//! never from constants hardcoded here. This module is the thin resolver that
6//! walks an open [`Hive`], looks up every catalog descriptor whose hive matches
7//! the hive under analysis, opens the descriptor's key, and emits the decoded
8//! value(s).
9//!
10//! winreg-core owns the registry-specific byte mechanics (`REG_SZ` is UTF-16LE on
11//! disk, `REG_DWORD` is little-endian, …); the catalog owns the *meaning*. The two
12//! meet here: the catalog's [`Decoder`] selects how winreg-core renders the
13//! bytes, and the catalog supplies the path, label, MITRE mapping, and id.
14//!
15//! ## Scope and catalog quirks
16//!
17//! Some catalog `key_path` values are not directly resolvable against an offline
18//! hive and are skipped (they simply produce no hit):
19//!
20//! - **Wildcards** (`*`, `**`) — the descriptor matches a family of keys, not a
21//!   single key. Glob expansion is out of scope for this resolver.
22//! - **SID / variable placeholders** (`%%users.sid%%`, `HKEY_USERS\…`) — the
23//!   Velociraptor/forensic-artifacts-sourced descriptors carry live-system
24//!   placeholders with no offline-hive equivalent.
25//!
26//! Two normalizations are applied so curated descriptors resolve cleanly:
27//!
28//! - A redundant leading hive prefix (`HKLM\`, `HKCU\`, or a leading `SOFTWARE\`
29//!   / `SYSTEM\` that merely repeats the hive name) is stripped — catalog paths
30//!   are nominally hive-relative, but some entries repeat the hive.
31//! - `CurrentControlSet` (the SYSTEM-hive symlink the live registry resolves) is
32//!   expanded by the [`crate::path_expansion`] engine to whichever
33//!   `ControlSet00N` the hive's `Select\Current` names — not assumed to be 001.
34//!
35//! Glob (`*`/`**`), control-set, and multi-user resolution all route through the
36//! single [`crate::path_expansion::expand`] engine: each is a template with one
37//! or more variable segments ranging over a domain, expanded to concrete paths
38//! tagged with [`crate::path_expansion::Binding`]s for provenance.
39//!
40//! Complex binary artifacts (`UserAssist`, Shimcache/AppCompatCache, Amcache,
41//! ShellBags, SAM) keep their dedicated decoders in the sibling modules; this
42//! scanner flags such hits via [`CatalogHit::needs_specialized_decoder`] and
43//! renders a best-effort placeholder, so callers can route to the right module.
44
45use std::io::Cursor;
46use std::path::Path;
47
48use forensicnomicon::catalog::{
49    ArtifactDescriptor, ArtifactLocation, Decoder, HiveTarget, CATALOG,
50};
51use winreg_core::detect::HiveType;
52use winreg_core::hive::Hive;
53use winreg_core::key::{filetime_to_datetime, Key};
54use winreg_core::value::{decode_multi_sz, decode_utf16le, Value};
55
56use crate::path_expansion::{
57    expand, resolve_control_sets, Binding, ControlSetResolver, Segment, Wildcard,
58};
59
60/// A single decoded artifact value surfaced by the catalog-driven scan.
61#[derive(Debug, Clone, serde::Serialize)]
62pub struct CatalogHit {
63    /// The catalog descriptor id that produced this hit (e.g. `"run_key_hklm"`).
64    pub catalog_id: &'static str,
65    /// Human-readable artifact name from the catalog.
66    pub artifact_name: &'static str,
67    /// Forensic meaning / significance from the catalog.
68    pub meaning: &'static str,
69    /// Registry key path actually opened (post-normalization, hive-relative).
70    pub key_path: String,
71    /// Value name, or `None` for a key-level descriptor's default value.
72    pub value_name: Option<String>,
73    /// Decoded value rendered as a string per the descriptor's decoder.
74    pub value_data: String,
75    /// MITRE ATT&CK techniques associated with the artifact (catalog-supplied).
76    pub mitre_techniques: &'static [&'static str],
77    /// `true` when the artifact needs one of the specialized binary decoders
78    /// (`UserAssist`, Shimcache, …) rather than this generic value renderer.
79    pub needs_specialized_decoder: bool,
80    /// The user this hit is attributed to, or `None` for machine-wide hives
81    /// (SYSTEM/SOFTWARE/SAM/SECURITY) scanned via [`scan`].
82    ///
83    /// Derived from this hit's [`Wildcard::User`] binding (when present); kept as
84    /// a distinct field so existing callers continue to work unchanged.
85    pub user: Option<UserIdentity>,
86    /// Every variable resolution that produced this hit, for provenance — the
87    /// expanded subkey name(s), the active `ControlSet00N`, and/or the user.
88    pub bindings: Vec<Binding>,
89    /// The resolved key's `LastWriteTime` — approximately when this artifact
90    /// value was last written. `None` when the key carries no timestamp.
91    pub last_written: Option<chrono::DateTime<chrono::Utc>>,
92}
93
94/// Identity of the user a per-user [`CatalogHit`] is attributed to.
95///
96/// Offline, a per-user artifact lives in one user's `NTUSER.DAT` / `UsrClass.dat`.
97/// At least one of `profile` / `sid` is populated; both may be present when the
98/// caller could resolve the SID (e.g. from `ProfileList` or the hive path).
99#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
100pub struct UserIdentity {
101    /// Profile/account name, typically the profile directory name (e.g. `"alice"`).
102    pub profile: Option<String>,
103    /// Security identifier (e.g. `"S-1-5-21-…-1001"`) when known.
104    pub sid: Option<String>,
105}
106
107/// Scan an open hive against the forensicnomicon registry catalog.
108///
109/// Only descriptors whose hive matches the hive under analysis are resolved.
110/// Descriptors whose key path is not present, or is a wildcard / SID-placeholder
111/// path, simply produce no hit.
112#[must_use]
113pub fn scan(hive: &Hive<Cursor<Vec<u8>>>) -> Vec<CatalogHit> {
114    let Some(target) = hive_target_for(hive.detect_hive_type()) else {
115        return Vec::new();
116    };
117
118    let mut hits = Vec::new();
119    let Ok(root) = hive.root_key() else {
120        return hits;
121    };
122    // HKLM SOFTWARE/SYSTEM paths sometimes repeat the hive name as a leading
123    // `SOFTWARE\`/`SYSTEM\`; that redundancy is stripped only for those hives.
124    let strip_hive_root = matches!(target, HiveTarget::HklmSoftware | HiveTarget::HklmSystem);
125    // The `CurrentControlSet` alias resolves to whichever set `Select\Current`
126    // names — only meaningful for the SYSTEM hive.
127    let control_sets = (target == HiveTarget::HklmSystem).then(|| resolve_control_sets(&root));
128    for descriptor in CATALOG.list() {
129        if !is_registry(descriptor.artifact_type) {
130            continue;
131        }
132        if descriptor.hive != Some(target) {
133            continue;
134        }
135        resolve_descriptor(
136            &root,
137            descriptor,
138            descriptor.key_path,
139            strip_hive_root,
140            control_sets.as_ref(),
141            &[],
142            &mut hits,
143        );
144    }
145    hits
146}
147
148/// A user's registry hive paired with the identity it belongs to.
149///
150/// Built by the caller (or [`discover_user_hives`]) for each `NTUSER.DAT` /
151/// `UsrClass.dat` found under a mounted image's profile root.
152pub struct UserHive {
153    /// Who this hive belongs to (profile name and/or SID).
154    pub identity: UserIdentity,
155    /// The opened per-user hive.
156    pub hive: Hive<Cursor<Vec<u8>>>,
157}
158
159/// Scan a set of per-user hives against the catalog, attributing every hit to
160/// the user it came from.
161///
162/// For each hive this applies:
163/// - the `NtUser` / `UsrClass` hive-tagged descriptors matching the hive's
164///   detected type, and
165/// - the `hive: None` registry descriptors whose path carries a live-system
166///   per-user placeholder (`HKEY_USERS\%%users.sid%%\…`, `HKU\*\…`) — offline,
167///   the placeholder segment *is* the user, so the remainder resolves against
168///   this user's hive root.
169///
170/// Every resulting [`CatalogHit`] carries `user = Some(identity)`. Machine
171/// hives (SYSTEM/SOFTWARE/SAM/SECURITY) are handled by [`scan`] instead and are
172/// unaffected.
173#[must_use]
174pub fn scan_users(user_hives: &[UserHive]) -> Vec<CatalogHit> {
175    let mut hits = Vec::new();
176    for uh in user_hives {
177        let target = hive_target_for(uh.hive.detect_hive_type());
178        let Ok(root) = uh.hive.root_key() else {
179            continue;
180        };
181        // The `User` domain binding: this hive *is* the user, so every hit it
182        // produces is tagged with the SID (preferred) or profile name.
183        let user_binding = user_binding_for(&uh.identity);
184        for descriptor in CATALOG.list() {
185            if !is_registry(descriptor.artifact_type) {
186                continue;
187            }
188            // Hive-tagged per-user descriptor whose target matches this hive.
189            let raw_path = if descriptor.hive == target {
190                Some(descriptor.key_path)
191            } else if descriptor.hive.is_none() || descriptor.hive == Some(HiveTarget::None) {
192                // Untagged descriptor that addresses a user via an HKU placeholder.
193                strip_user_placeholder_prefix(descriptor.key_path)
194            } else {
195                None
196            };
197            if let Some(path) = raw_path {
198                // Per-user hives keep `Software\…` literally — never strip it.
199                resolve_descriptor(
200                    &root,
201                    descriptor,
202                    path,
203                    false,
204                    None,
205                    user_binding.as_slice(),
206                    &mut hits,
207                );
208            }
209        }
210        // Backfill the legacy `user` field on this user's hits (the engine only
211        // carries it as a binding).
212        for hit in &mut hits {
213            if hit.user.is_none() && hit.bindings.iter().any(|b| b.kind == Wildcard::User) {
214                hit.user = Some(uh.identity.clone());
215            }
216        }
217    }
218    hits
219}
220
221/// The `User`-domain binding for an identity: the SID when known, else the
222/// profile name. Empty (no binding) only if the identity carries neither.
223fn user_binding_for(identity: &UserIdentity) -> Vec<Binding> {
224    let value = identity.sid.clone().or_else(|| identity.profile.clone());
225    match value {
226        Some(v) => vec![Binding::new(Wildcard::User, v)],
227        None => Vec::new(),
228    }
229}
230
231/// Discover every per-user hive under a mounted-image root and open it into a
232/// profile-tagged [`UserHive`], ready for [`scan_users`].
233///
234/// Delegates the filesystem walk to [`winreg_discover::discover_hives`], then
235/// keeps only the `NTUSER.DAT` / `UsrClass.dat` sources, opening each and
236/// deriving the profile name from its `Users/<name>/…` path. A hive that fails
237/// to open (truncated, wrong format) is skipped rather than aborting the scan.
238///
239/// The SID is left `None` here — it is not recoverable from the profile path
240/// alone; a caller that has the SOFTWARE hive's `ProfileList` can fill it in.
241#[must_use]
242pub fn discover_user_hives(evidence_root: &Path) -> Vec<UserHive> {
243    let mut out = Vec::new();
244    for source in winreg_discover::discover_hives(evidence_root) {
245        if !matches!(source.hive_type, HiveType::NtUser | HiveType::UsrClass) {
246            continue;
247        }
248        let Ok(hive) = Hive::from_path(&source.path) else {
249            continue;
250        };
251        out.push(UserHive {
252            identity: UserIdentity {
253                profile: profile_name_from_path(&source.path),
254                sid: None,
255            },
256            hive,
257        });
258    }
259    out
260}
261
262/// Derive the profile/account name from a `…/Users/<name>/…` hive path.
263fn profile_name_from_path(path: &Path) -> Option<String> {
264    let components: Vec<String> = path
265        .components()
266        .map(|c| c.as_os_str().to_string_lossy().to_string())
267        .collect();
268    let idx = components
269        .iter()
270        .position(|c| c.eq_ignore_ascii_case("Users"))?;
271    components.get(idx + 1).cloned()
272}
273
274/// Strip a live-system per-user root prefix (`HKEY_USERS\<sid>\` or `HKU\<sid>\`)
275/// from a descriptor path, returning the user-hive-relative remainder.
276///
277/// The `<sid>` segment is the SID placeholder the descriptor uses to address a
278/// specific user (`%%users.sid%%`, `*`, or a literal SID); offline that segment
279/// selects *which* hive, so we drop it and resolve the rest against the user's
280/// own hive root. Returns `None` if the path does not start with such a root.
281fn strip_user_placeholder_prefix(raw: &str) -> Option<&str> {
282    let rest = strip_prefix_ci(raw, "HKEY_USERS\\").or_else(|| strip_prefix_ci(raw, "HKU\\"))?;
283    // Drop the next segment (the SID / placeholder) and keep the remainder.
284    let (_sid_segment, remainder) = rest.split_once('\\')?;
285    if remainder.is_empty() {
286        None
287    } else {
288        Some(remainder)
289    }
290}
291
292/// Resolve a single descriptor against an already-open key tree rooted at
293/// `root`, routing it through the unified [`expand`] engine and pushing every
294/// produced [`CatalogHit`] onto `hits`.
295///
296/// `raw_path` is taken explicitly rather than read from `descriptor.key_path` so
297/// the multi-user scan can feed a SID-placeholder-stripped, hive-relative path
298/// while still attributing the hit to the original descriptor.
299///
300/// `control_sets` supplies the active `ControlSet00N` for any `CurrentControlSet`
301/// segment (SYSTEM hive only); `prefix_bindings` carries cross-file bindings the
302/// engine cannot derive itself — currently the per-user [`Wildcard::User`]
303/// binding from the multi-user scan.
304fn resolve_descriptor(
305    root: &Key<'_>,
306    descriptor: &ArtifactDescriptor,
307    raw_path: &str,
308    strip_hive_root: bool,
309    control_sets: Option<&ControlSetResolver>,
310    prefix_bindings: &[Binding],
311    hits: &mut Vec<CatalogHit>,
312) {
313    let Some(segments) = template_segments(raw_path, strip_hive_root) else {
314        return;
315    };
316    expand(root, &segments, control_sets, &mut |bindings, path, key| {
317        let mut all: Vec<Binding> = prefix_bindings.to_vec();
318        all.extend_from_slice(bindings);
319        emit_key(descriptor, path, key, &all, hits);
320    });
321}
322
323/// Emit the descriptor's value(s) for one concrete, already-opened key.
324fn emit_key(
325    descriptor: &ArtifactDescriptor,
326    key_path: &str,
327    key: &Key<'_>,
328    bindings: &[Binding],
329    hits: &mut Vec<CatalogHit>,
330) {
331    let last_written = key.last_written();
332    if let Some(vname) = descriptor.value_name {
333        // Single named value.
334        if let Ok(Some(val)) = key.value(vname) {
335            hits.push(make_hit(
336                descriptor,
337                key_path,
338                Some(vname.to_string()),
339                &val,
340                bindings,
341                last_written,
342            ));
343        }
344    } else {
345        // Key-level descriptor: every child value is a hit.
346        let Ok(values) = key.values() else { return };
347        for val in values {
348            hits.push(make_hit(
349                descriptor,
350                key_path,
351                Some(val.name()),
352                &val,
353                bindings,
354                last_written,
355            ));
356        }
357    }
358}
359
360/// Map winreg-core's detected hive type to a forensicnomicon hive target.
361fn hive_target_for(hive_type: HiveType) -> Option<HiveTarget> {
362    match hive_type {
363        HiveType::Software => Some(HiveTarget::HklmSoftware),
364        HiveType::System => Some(HiveTarget::HklmSystem),
365        HiveType::NtUser => Some(HiveTarget::NtUser),
366        HiveType::UsrClass => Some(HiveTarget::UsrClass),
367        HiveType::Sam => Some(HiveTarget::HklmSam),
368        HiveType::Security => Some(HiveTarget::HklmSecurity),
369        HiveType::Amcache => Some(HiveTarget::Amcache),
370        _ => None,
371    }
372}
373
374fn is_registry(at: ArtifactLocation) -> bool {
375    matches!(
376        at,
377        ArtifactLocation::RegistryKey | ArtifactLocation::RegistryValue
378    )
379}
380
381/// Normalize a catalog key path into hive-relative expansion [`Segment`]s, or
382/// `None` if the path carries a live-system variable placeholder (`%`) or an
383/// unsupported separator/root the offline resolver cannot map.
384///
385/// This is the single entry the unified engine consumes: concrete paths become
386/// all-`Literal` templates (expanded to a single key), `*`/`**` segments become
387/// [`Wildcard::Subkey`] variables, and a leading `CurrentControlSet` becomes a
388/// [`Wildcard::ControlSet`] variable resolved via `Select\Current`.
389///
390/// The catalog stores backslash separators; some forensic-artifacts-sourced
391/// entries carry doubled backslashes (`\\`) as ordinary string contents — those
392/// are collapsed in [`normalize_path_prefixes`].
393fn template_segments(raw: &str, strip_hive_root: bool) -> Option<Vec<Segment>> {
394    // Live-system SID placeholders (`%`) and POSIX separators are out of scope.
395    if raw.contains('%') || raw.contains('/') {
396        return None;
397    }
398    let normalized = normalize_path_prefixes(raw, strip_hive_root)?;
399    let segments: Vec<Segment> = normalized
400        .split('\\')
401        .filter(|s| !s.is_empty())
402        .map(parse_segment)
403        .collect();
404    if segments.is_empty() {
405        None
406    } else {
407        Some(segments)
408    }
409}
410
411/// Classify one raw path component into an expansion [`Segment`].
412fn parse_segment(seg: &str) -> Segment {
413    if seg.eq_ignore_ascii_case("CurrentControlSet") {
414        // The SYSTEM-hive symlink — a variable over the active `ControlSet00N`.
415        Segment::Variable(Wildcard::ControlSet, seg.to_string())
416    } else if seg.contains('*') {
417        // `*` / `**` (incl. forensic-artifacts repeat suffixes like `**5`) — a
418        // variable over the subkeys of the current node.
419        Segment::Variable(Wildcard::Subkey, seg.to_string())
420    } else {
421        Segment::Literal(seg.to_string())
422    }
423}
424
425/// Apply the hive-prefix / doubled-backslash normalizations shared by every
426/// template, returning the hive-relative path string (or `None` for an
427/// unsupported placeholder root or empty result). Wildcard and
428/// `CurrentControlSet` segments are preserved verbatim for [`parse_segment`].
429///
430/// `strip_hive_root` controls whether a leading `SOFTWARE\` / `SYSTEM\` (which
431/// merely repeats an HKLM hive name) is dropped. It must be `true` for HKLM
432/// SOFTWARE/SYSTEM hives but `false` for per-user (`NtUser`/`UsrClass`) hives,
433/// where `Software` is a genuine first-level subkey, not a redundant prefix.
434fn normalize_path_prefixes(raw: &str, strip_hive_root: bool) -> Option<String> {
435    // Collapse any doubled backslashes to single separators.
436    let collapsed = raw.replace("\\\\", "\\");
437
438    // Drop a leading hive-name prefix that merely repeats the hive.
439    let mut path = collapsed.as_str();
440    for prefix in [
441        "HKEY_LOCAL_MACHINE\\",
442        "HKEY_CURRENT_USER\\",
443        "HKEY_USERS\\",
444        "HKLM\\",
445        "HKCU\\",
446        "HKU\\",
447    ] {
448        if let Some(stripped) = strip_prefix_ci(path, prefix) {
449            path = stripped;
450        }
451    }
452    // An `HK*`-prefixed path that wasn't stripped is a placeholder form we skip.
453    if path.starts_with("HK") && path.contains('\\') && looks_like_hive_root(path) {
454        return None;
455    }
456    // Strip a redundant leading SOFTWARE\ or SYSTEM\ that repeats the hive root
457    // — only for the HKLM hives where it is a duplicate, never for user hives.
458    if strip_hive_root {
459        for prefix in ["SOFTWARE\\", "SYSTEM\\"] {
460            if let Some(stripped) = strip_prefix_ci(path, prefix) {
461                path = stripped;
462            }
463        }
464    }
465
466    if path.is_empty() {
467        None
468    } else {
469        Some(path.to_string())
470    }
471}
472
473/// Case-insensitive prefix strip on `\`-delimited registry paths.
474fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
475    if s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) {
476        Some(&s[prefix.len()..])
477    } else {
478        None
479    }
480}
481
482/// Heuristic: the first segment looks like an `HKEY_*` root that survived
483/// prefix-stripping (i.e. an unsupported placeholder root).
484fn looks_like_hive_root(path: &str) -> bool {
485    path.split('\\')
486        .next()
487        .is_some_and(|seg| seg.eq_ignore_ascii_case("HKEY_USERS") || seg.starts_with("HKEY_"))
488}
489
490/// Build a [`CatalogHit`], rendering the value per the descriptor's decoder.
491fn make_hit(
492    descriptor: &ArtifactDescriptor,
493    key_path: &str,
494    value_name: Option<String>,
495    val: &Value<'_>,
496    bindings: &[Binding],
497    last_written: Option<chrono::DateTime<chrono::Utc>>,
498) -> CatalogHit {
499    let (value_data, specialized) = render_value(descriptor.decoder, val);
500    CatalogHit {
501        catalog_id: descriptor.id,
502        artifact_name: descriptor.name,
503        meaning: descriptor.meaning,
504        key_path: key_path.to_string(),
505        value_name,
506        value_data,
507        mitre_techniques: descriptor.mitre_techniques,
508        needs_specialized_decoder: specialized,
509        // The multi-user scan backfills this from the matching `User` binding;
510        // machine scans leave it `None`.
511        user: None,
512        bindings: bindings.to_vec(),
513        last_written,
514    }
515}
516
517/// Render a registry value to a display string using the catalog's decoder to
518/// select the interpretation, and winreg-core for the registry byte mechanics.
519///
520/// Returns `(rendered, needs_specialized_decoder)`.
521fn render_value(decoder: Decoder, val: &Value<'_>) -> (String, bool) {
522    let raw = val.raw_data().unwrap_or_default();
523    match decoder {
524        // REG_SZ / REG_EXPAND_SZ text — UTF-16LE on disk.
525        Decoder::Identity | Decoder::Utf16Le => (decode_utf16le(&raw), false),
526        Decoder::DwordLe => (val.as_u32().unwrap_or(0).to_string(), false),
527        Decoder::MultiSz => (decode_multi_sz(&raw).join("; "), false),
528        Decoder::FiletimeAt { offset } => {
529            let ts = raw
530                .get(offset..offset + 8)
531                .map(|b| winreg_core::bytes::le_u64(b, 0))
532                .and_then(filetime_to_datetime)
533                .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string());
534            (ts.unwrap_or_default(), false)
535        }
536        // Binary record / ROT13 / ESE artifacts have dedicated decoders elsewhere.
537        Decoder::Rot13Name
538        | Decoder::Rot13NameWithBinaryValue(_)
539        | Decoder::BinaryRecord(_)
540        | Decoder::MruListEx
541        | Decoder::EseDatabase
542        | Decoder::PipeDelimited { .. } => {
543            // Best-effort: surface the raw value as text so the hit is not empty,
544            // and flag that a specialized decoder should be consulted.
545            (decode_utf16le(&raw), true)
546        }
547        // `Decoder` is `#[non_exhaustive]`: degrade gracefully on future variants.
548        _ => (decode_utf16le(&raw), true),
549    }
550}
551
552#[cfg(test)]
553#[allow(clippy::unwrap_used, clippy::expect_used)]
554mod tests {
555    use super::*;
556
557    fn literals(segs: &[Segment]) -> Vec<&str> {
558        segs.iter()
559            .map(|s| match s {
560                Segment::Literal(n) => n.as_str(),
561                Segment::Variable(_, p) => p.as_str(),
562            })
563            .collect()
564    }
565
566    #[test]
567    fn template_strips_redundant_software_prefix() {
568        let segs =
569            template_segments(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", true).unwrap();
570        assert_eq!(
571            literals(&segs),
572            vec!["Microsoft", "Windows NT", "CurrentVersion"]
573        );
574        assert!(segs.iter().all(|s| matches!(s, Segment::Literal(_))));
575    }
576
577    #[test]
578    fn template_keeps_software_for_user_hive() {
579        // Per-user hives store `Software\…` literally — it must NOT be stripped.
580        let segs =
581            template_segments(r"Software\Microsoft\Windows\CurrentVersion\Run", false).unwrap();
582        assert_eq!(
583            literals(&segs),
584            vec!["Software", "Microsoft", "Windows", "CurrentVersion", "Run"]
585        );
586    }
587
588    #[test]
589    fn template_current_control_set_is_a_variable_segment() {
590        // The hardcoded ControlSet001 rewrite is gone: CurrentControlSet is now a
591        // ControlSet-domain variable, resolved at walk time via Select\Current.
592        let segs = template_segments(r"CurrentControlSet\Services", true).unwrap();
593        assert_eq!(
594            segs,
595            vec![
596                Segment::Variable(Wildcard::ControlSet, "CurrentControlSet".into()),
597                Segment::Literal("Services".into()),
598            ]
599        );
600    }
601
602    #[test]
603    fn template_rejects_placeholder() {
604        assert!(template_segments(r"HKEY_USERS\%%users.sid%%\Software\X", true).is_none());
605    }
606
607    #[test]
608    fn template_collapses_doubled_backslashes() {
609        let segs = template_segments(r"Microsoft\\Windows\\CurrentVersion\\Run", true).unwrap();
610        assert_eq!(
611            literals(&segs),
612            vec!["Microsoft", "Windows", "CurrentVersion", "Run"]
613        );
614    }
615
616    #[test]
617    fn template_strips_hk_prefix() {
618        let segs = template_segments(r"HKLM\Microsoft\Foo", true).unwrap();
619        assert_eq!(literals(&segs), vec!["Microsoft", "Foo"]);
620    }
621
622    #[test]
623    fn template_parses_wildcard_segments() {
624        let segs = template_segments(r"Microsoft\Foo\*\Bar\**", true).unwrap();
625        assert_eq!(
626            segs,
627            vec![
628                Segment::Literal("Microsoft".into()),
629                Segment::Literal("Foo".into()),
630                Segment::Variable(Wildcard::Subkey, "*".into()),
631                Segment::Literal("Bar".into()),
632                Segment::Variable(Wildcard::Subkey, "**".into()),
633            ]
634        );
635    }
636
637    #[test]
638    fn template_rejects_placeholder_in_wildcard_path() {
639        assert!(template_segments(r"Foo\%%users.sid%%\*", true).is_none());
640    }
641
642    #[test]
643    fn parse_segment_classifies_double_star_and_control_set() {
644        assert_eq!(
645            parse_segment("**5"),
646            Segment::Variable(Wildcard::Subkey, "**5".into())
647        );
648        assert_eq!(
649            parse_segment("currentcontrolset"),
650            Segment::Variable(Wildcard::ControlSet, "currentcontrolset".into())
651        );
652    }
653
654    #[test]
655    fn strips_hku_and_users_placeholder_prefix() {
656        assert_eq!(
657            strip_user_placeholder_prefix(r"HKEY_USERS\%%users.sid%%\Software\X\Y"),
658            Some(r"Software\X\Y")
659        );
660        assert_eq!(
661            strip_user_placeholder_prefix(r"HKU\*\Software\Run"),
662            Some(r"Software\Run")
663        );
664        // Not an HKU-rooted path.
665        assert!(strip_user_placeholder_prefix(r"Software\X").is_none());
666        // No remainder after the SID segment.
667        assert!(strip_user_placeholder_prefix(r"HKEY_USERS\S-1-5-21").is_none());
668    }
669}