Skip to main content

usb_forensic/sources/
shellbag.rs

1//! Adapter: `peripheral-core` [`ShellbagEntry`]s (`BagMRU` ShellBags) → USB-history
2//! [`Claim`]s (the drive-letter browsed-folder join).
3//!
4//! A ShellBags `BagMRU` node records that a user browsed a folder in Explorer. When
5//! that folder lived on a **drive letter** (`E:\...`), it is per-user evidence that a
6//! volume was mounted at `E:` and that a directory on it was opened — but a shellbag,
7//! like an LNK, names a **drive letter / volume**, not a device. So this adapter emits
8//! join material keyed by the **drive-letter pseudo-device** (`E:`), exactly as the
9//! `VolumeInfoCache` label adapter does:
10//!
11//! - a [`Attribute::DriveLetter`] claim carrying the drive letter (so it is discoverable
12//!   and matchable), and
13//! - a [`Attribute::BrowsedFolder`] claim carrying the browsed path,
14//!
15//! both keyed by [`DeviceKey`]`("E:")`. [`reconcile_volume_serials`] then re-keys the
16//! pseudo-device onto the physical device that a registry source (`MountedDevices` /
17//! USBSTOR) reports was mounted at `E:`, so the browsed folder lands on the real device
18//! and corroborates its connection history.
19//!
20//! [`reconcile_volume_serials`]: crate::reconcile_volume_serials
21//!
22//! This is a pure mapping over [`ShellbagEntry`] values the reader has already decoded;
23//! it never touches raw bytes. Mirrors [`LnkSource`](crate::LnkSource).
24
25use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
26use peripheral_core::shellbag::ShellbagEntry;
27
28/// A [`HistorySource`] over decoded [`ShellbagEntry`]s.
29pub struct ShellbagSource<'a> {
30    entries: &'a [ShellbagEntry],
31}
32
33impl<'a> ShellbagSource<'a> {
34    /// Wrap decoded shellbag entries (from `peripheral_core::shellbag::parse_shellbags`).
35    #[must_use]
36    pub fn new(entries: &'a [ShellbagEntry]) -> Self {
37        Self { entries }
38    }
39}
40
41impl HistorySource for ShellbagSource<'_> {
42    fn claims(&self) -> Vec<Claim> {
43        let mut out = Vec::new();
44        for entry in self.entries {
45            push_entry_claims(entry, &mut out);
46        }
47        out
48    }
49}
50
51/// Emit the drive-letter join + browsed-folder claims for one shellbag entry. An
52/// entry with no clean drive letter carries no join key (mirrors an LNK with a `0`
53/// volume serial), so it contributes nothing.
54fn push_entry_claims(entry: &ShellbagEntry, out: &mut Vec<Claim>) {
55    let Some(letter) = entry.drive_letter else {
56        return;
57    };
58    let drive = format!("{letter}:");
59    let device = DeviceKey(drive.clone());
60    let provenance = Provenance {
61        source: SourceKind::Shellbag,
62        locator: entry
63            .source
64            .key_path
65            .clone()
66            .unwrap_or_else(|| entry.source.file.clone()),
67    };
68
69    // The drive letter itself — the matchable join key (mirrors LnkSource emitting
70    // its VolumeSerial). Key == value marks a pseudo-device, not a carrier assertion,
71    // so reconcile leaves the seeding to a physical source's DriveLetter claim.
72    out.push(Claim {
73        device: device.clone(),
74        attribute: Attribute::DriveLetter,
75        value: Value::Text(drive),
76        provenance: provenance.clone(),
77    });
78    out.push(Claim {
79        device,
80        attribute: Attribute::BrowsedFolder,
81        value: Value::Text(entry.path.clone()),
82        provenance,
83    });
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use peripheral_core::Provenance as PcProvenance;
90
91    fn entry(path: &str, drive: Option<char>, key_path: Option<&str>) -> ShellbagEntry {
92        ShellbagEntry {
93            path: path.to_string(),
94            drive_letter: drive,
95            last_write: Some(1_600_000_000),
96            source: PcProvenance {
97                file: "NTUSER.DAT".to_string(),
98                line: 0,
99                key_path: key_path.map(ToString::to_string),
100            },
101        }
102    }
103
104    fn claims_for(entries: &[ShellbagEntry]) -> Vec<Claim> {
105        ShellbagSource::new(entries).claims()
106    }
107
108    #[test]
109    fn drive_letter_entry_yields_drive_letter_and_browsed_folder() {
110        let entries = [entry(
111            "My Computer\\E:\\\\photos",
112            Some('E'),
113            Some("Software\\Microsoft\\Windows\\Shell\\BagMRU\\0\\0\\0"),
114        )];
115        let claims = claims_for(&entries);
116        assert_eq!(claims.len(), 2);
117
118        let dl = &claims[0];
119        assert_eq!(dl.device, DeviceKey("E:".to_string()));
120        assert_eq!(dl.attribute, Attribute::DriveLetter);
121        assert_eq!(dl.value, Value::Text("E:".to_string()));
122        assert_eq!(dl.provenance.source, SourceKind::Shellbag);
123        assert_eq!(
124            dl.provenance.locator,
125            "Software\\Microsoft\\Windows\\Shell\\BagMRU\\0\\0\\0"
126        );
127
128        let bf = &claims[1];
129        assert_eq!(bf.device, DeviceKey("E:".to_string()));
130        assert_eq!(bf.attribute, Attribute::BrowsedFolder);
131        assert_eq!(
132            bf.value,
133            Value::Text("My Computer\\E:\\\\photos".to_string())
134        );
135        assert_eq!(bf.provenance.source, SourceKind::Shellbag);
136    }
137
138    #[test]
139    fn entry_without_a_drive_letter_is_skipped() {
140        // A volume item with no clean drive-letter name carries no join key.
141        let entries = [entry("My Computer\\Some Volume", None, None)];
142        assert!(claims_for(&entries).is_empty());
143    }
144
145    #[test]
146    fn locator_falls_back_to_the_file_without_a_key_path() {
147        let entries = [entry("My Computer\\E:\\", Some('E'), None)];
148        let claims = claims_for(&entries);
149        assert_eq!(claims[0].provenance.locator, "NTUSER.DAT");
150    }
151
152    #[test]
153    fn multiple_entries_accumulate() {
154        let entries = [
155            entry("My Computer\\E:\\", Some('E'), None),
156            entry("My Computer\\F:\\docs", Some('F'), None),
157        ];
158        let claims = claims_for(&entries);
159        assert_eq!(claims.len(), 4);
160        assert_eq!(claims[0].device, DeviceKey("E:".to_string()));
161        assert_eq!(claims[3].device, DeviceKey("F:".to_string()));
162    }
163
164    #[test]
165    fn browsed_folder_is_reattributed_to_the_device_mounted_at_that_drive_letter() {
166        // End-to-end with reconcile: a physical device reports it was mounted at E:,
167        // so the shellbag's browsed folder re-keys onto that device.
168        let entries = [entry("My Computer\\E:\\\\photos", Some('E'), None)];
169        let mut all = claims_for(&entries);
170        all.push(Claim {
171            device: DeviceKey("USBSTOR-DEV-1".into()),
172            attribute: Attribute::DriveLetter,
173            value: Value::Text("E:".into()),
174            provenance: Provenance {
175                source: SourceKind::Usbstor,
176                locator: "x".into(),
177            },
178        });
179        let out = crate::reconcile_volume_serials(&all);
180        let bf = out
181            .iter()
182            .find(|c| c.attribute == Attribute::BrowsedFolder)
183            .expect("browsed folder present");
184        assert_eq!(bf.device, DeviceKey("USBSTOR-DEV-1".into()));
185    }
186}