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