Skip to main content

peripheral_core/
volume_info.rs

1//! Volume-label source: decode `VolumeInfoCache` from a Windows `SOFTWARE` hive.
2//!
3//! `SOFTWARE\Microsoft\Windows Search\VolumeInfoCache\<X:>` caches, per mounted volume,
4//! the drive letter it was seen at and its volume label. For a removable device this is
5//! the human-readable name the user gave the stick ("Authorized USB", "IAMAN"), which
6//! corroborates the device that mounted that drive letter. This reader emits those
7//! `(drive_letter, volume_label)` facts; the correlation layer joins them to a device via
8//! the drive letter (`MountedDevices`).
9
10use crate::Provenance;
11use std::io::Cursor;
12use winreg_core::hive::Hive;
13use winreg_core::key::Key;
14
15/// The Windows Search `VolumeInfoCache` path (a `SOFTWARE`-hive key).
16const CACHE_PATH: &str = "Microsoft\\Windows Search\\VolumeInfoCache";
17
18/// A cached volume label keyed by the drive letter it was mounted at.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct VolumeLabel {
21    /// The drive letter the volume was mounted as (upper-cased, e.g. `E`).
22    pub drive_letter: char,
23    /// The volume label (never empty — unlabelled volumes are omitted).
24    pub volume_label: String,
25    /// Where the record was decoded from.
26    pub source: Provenance,
27}
28
29/// Parse `VolumeInfoCache` volume labels from an already-opened `SOFTWARE` hive.
30///
31/// Total over a valid hive: a subkey that is not a `X:` drive letter, or that carries no
32/// non-empty string `VolumeLabel`, is skipped rather than panicked on. `file` is recorded
33/// as each record's [`Provenance`] file (the hive name, e.g. `SOFTWARE`).
34#[must_use]
35pub fn parse_volume_info_cache(hive: &Hive<Cursor<Vec<u8>>>, file: &str) -> Vec<VolumeLabel> {
36    let Ok(Some(cache)) = hive.open_key(CACHE_PATH) else {
37        return Vec::new();
38    };
39    let Ok(subkeys) = cache.subkeys() else {
40        return Vec::new(); // cov:unreachable: subkeys() only errors on hive corruption
41    };
42    let mut out = Vec::new();
43    for sub in subkeys {
44        let name = sub.name();
45        let Some(letter) = drive_letter(&name) else {
46            continue;
47        };
48        let Some(label) = string_value(&sub, "VolumeLabel") else {
49            continue;
50        };
51        out.push(VolumeLabel {
52            drive_letter: letter,
53            volume_label: label,
54            source: Provenance {
55                file: file.to_string(),
56                line: 0,
57                key_path: Some(format!("{CACHE_PATH}\\{name}")),
58            },
59        });
60    }
61    out
62}
63
64/// Extract the drive letter from an `X:` subkey name, upper-cased; `None` for any other
65/// name (a non-letter, a multi-character prefix).
66fn drive_letter(name: &str) -> Option<char> {
67    let mut chars = name.chars();
68    let letter = chars.next()?;
69    if letter.is_ascii_alphabetic() && chars.next() == Some(':') && chars.next().is_none() {
70        Some(letter.to_ascii_uppercase())
71    } else {
72        None
73    }
74}
75
76/// Read a non-empty string value; `None` if absent, non-string, or empty.
77fn string_value(key: &Key<'_>, name: &str) -> Option<String> {
78    key.value(name)
79        .ok()
80        .flatten()
81        .and_then(|v| v.as_string().ok())
82        .filter(|s| !s.is_empty())
83}
84
85#[cfg(test)]
86#[allow(clippy::unwrap_used, clippy::expect_used)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn synthetic_volume_info_cache_decodes_labelled_drives_only() {
92        // Synthetic SOFTWARE hive: E: labelled "TESTLABEL", C: unlabelled (DWORD 0),
93        // ZZ: a bogus non-drive-letter name. Only E: yields a record.
94        const HIVE: &[u8] = include_bytes!("../../tests/data/synthetic_volume_info_cache.hive");
95        let hive = Hive::from_bytes(HIVE.to_vec()).expect("valid REGF");
96        let labels = parse_volume_info_cache(&hive, "SOFTWARE");
97        assert_eq!(labels.len(), 1);
98        assert_eq!(labels[0].drive_letter, 'E');
99        assert_eq!(labels[0].volume_label, "TESTLABEL");
100        assert!(labels[0].source.key_path.is_some());
101    }
102
103    #[test]
104    fn drive_letter_accepts_only_well_formed_names() {
105        assert_eq!(drive_letter("E:"), Some('E'));
106        assert_eq!(drive_letter("c:"), Some('C'));
107        assert_eq!(drive_letter("ZZ:"), None);
108        assert_eq!(drive_letter("E"), None);
109        assert_eq!(drive_letter("1:"), None);
110        assert_eq!(drive_letter(""), None);
111    }
112
113    #[test]
114    fn a_hive_without_the_cache_yields_nothing() {
115        // The synthetic USBSTOR SYSTEM hive has no VolumeInfoCache → empty, no panic.
116        const HIVE: &[u8] = include_bytes!("../../tests/data/synthetic_usb_system.hive");
117        let hive = Hive::from_bytes(HIVE.to_vec()).expect("valid REGF");
118        assert!(parse_volume_info_cache(&hive, "SYSTEM").is_empty());
119    }
120}