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