Skip to main content

useract_forensic/
lib.rs

1//! `useract-forensic` — the user-activity correlation layer.
2//!
3//! A thin **meta / orchestration** crate: it does not parse any raw format
4//! itself. It consumes already-decoded forensic reader types —
5//! [`shellhist_core::HistoryEntry`], [`peripheral_core::DeviceConnection`], SRUM
6//! records ([`srum_core`]), registry artifacts ([`winreg_artifacts`]), and Shell
7//! Link targets ([`lnk_core::ShellLink`]) — normalizes them into one uniform
8//! [`UserActivity`] event, builds a per-user timeline, and emits cross-source
9//! [`forensicnomicon::report::Finding`]s that no single source could produce alone.
10//!
11//! Every finding is an **observation** ("consistent with …"); the examiner draws
12//! the conclusions. MITRE techniques are narrated as consistency, never a verdict.
13//!
14//! ## 30-second example
15//!
16//! ```
17//! use useract_forensic::{build_timeline, audit, ShellHistorySource, DeviceSource};
18//! use shellhist_core::{HistoryEntry, Shell};
19//!
20//! // (sources are normally produced by the reader crates; constructed here inline)
21//! let entries = shellhist_core::parse_auto(b"#1700000000\ncurl http://x | sh\n", Some(".bash_history"));
22//! let shell = ShellHistorySource::new(&entries);
23//! let devices = DeviceSource::new(&[]);
24//!
25//! let timeline = build_timeline(&[&shell, &devices]);
26//! let findings = audit(&timeline);
27//! for f in &findings {
28//!     println!("{} — {}", f.code, f.note);
29//! }
30//! ```
31//!
32//! ## Sources
33//!
34//! Every source slots in behind the [`ActivitySource`] trait: shell history and
35//! peripheral devices (v0.1) plus SRUM (per-user app/network usage by SID — the
36//! first actor-attributing source), registry artifacts (UserAssist / TypedURLs /
37//! ShellBags), and recent-file LNK targets (carrying the volume serial that
38//! completes the device join). See `docs/roadmap.md` for the v0.3 sources.
39
40#![forbid(unsafe_code)]
41
42use forensicnomicon::report::{Category, ExternalRef, Finding, Severity, Source};
43use peripheral_core::{Bus, DeviceConnection};
44use shellhist_core::HistoryEntry;
45
46/// What a user did to a [`Subject`].
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum Action {
49    /// Ran a program or command.
50    Executed,
51    /// Opened or read a file/folder.
52    Accessed,
53    /// Attached / connected a device.
54    Connected,
55    /// Issued a search query.
56    Searched,
57    /// Typed text (e.g. a typed URL / run-box entry).
58    Typed,
59    /// Disabled, cleared, or otherwise tampered with an activity record.
60    HistoryTampered,
61}
62
63/// The thing an [`Action`] was performed on.
64#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub enum Subject {
66    /// A shell command or program invocation.
67    Command(String),
68    /// A file path, carrying the **volume serial** of the volume it lives on when
69    /// the source knows it (LNK `VolumeID`). The serial is the join key to a
70    /// [`Subject::Device`] with the same volume serial (see
71    /// [`device_file_volume_joins`]).
72    File {
73        /// The file path.
74        path: String,
75        /// NTFS/FAT volume serial of the file's volume, when known.
76        volume_serial: Option<u32>,
77    },
78    /// A folder path, carrying the **volume serial** of the volume it lives on when
79    /// the source knows it (shellbag / LNK directory target).
80    Folder {
81        /// The folder path.
82        path: String,
83        /// NTFS/FAT volume serial of the folder's volume, when known.
84        volume_serial: Option<u32>,
85    },
86    /// An external device, with its volume serial kept distinct so an LNK /
87    /// shellbag [`Subject::File`] carrying the same NTFS/FAT volume serial can be
88    /// joined to it (see [`device_file_volume_joins`]).
89    Device {
90        /// Device instance id (the stable primary key).
91        id: String,
92        /// NTFS/FAT volume serial of the device's volume, when known.
93        volume_serial: Option<u32>,
94    },
95    /// A search / lookup query.
96    Query(String),
97}
98
99impl Subject {
100    /// A file path with no known volume serial.
101    #[must_use]
102    pub fn file(path: impl Into<String>) -> Self {
103        Self::File {
104            path: path.into(),
105            volume_serial: None,
106        }
107    }
108
109    /// A folder path with no known volume serial.
110    #[must_use]
111    pub fn folder(path: impl Into<String>) -> Self {
112        Self::Folder {
113            path: path.into(),
114            volume_serial: None,
115        }
116    }
117}
118
119/// Which reader the activity was normalized from.
120///
121/// Extensible: marked `#[non_exhaustive]` so adding a variant is non-breaking;
122/// consumers must use a `_` arm when matching.
123#[non_exhaustive]
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
125pub enum SourceKind {
126    /// `shellhist-core` — shell command history.
127    ShellHistory,
128    /// `peripheral-core` — external-device connections.
129    PeripheralDevice,
130    /// `srum-core` / `srum-parser` — per-user app execution and network bytes,
131    /// attributed to a user SID (the first by-SID source).
132    Srum,
133    /// `winreg-artifacts` — registry per-user artifacts (UserAssist, TypedURLs,
134    /// ShellBags).
135    Registry,
136    /// `lnk-core` — Windows Shell Link (`.lnk`) targets, carrying the volume
137    /// serial that completes the device join.
138    LnkFile,
139    /// `lnk-core` Jump Lists (`*.automaticDestinations-ms` /
140    /// `*.customDestinations-ms`) — per-application recent/pinned items, with the
141    /// `DestList` MRU last-access time and origin host.
142    JumpList,
143}
144
145/// One normalized user-activity event: *who* did *what*, *when*, to *which* subject.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct UserActivity {
148    /// Unix epoch seconds, when the source records it. `None` when the source
149    /// carries no usable timestamp (e.g. plain bash / PowerShell PSReadLine).
150    pub timestamp: Option<i64>,
151    /// The acting user / SID, when the source attributes it. Most v0.1 sources do
152    /// not attribute a user; SRUM (v0.2) is the first by-SID source.
153    pub actor: Option<String>,
154    /// What was done.
155    pub action: Action,
156    /// What it was done to.
157    pub subject: Subject,
158    /// Which reader produced this event.
159    pub source: SourceKind,
160    /// A human-readable detail string for the event.
161    pub detail: String,
162}
163
164/// A producer of [`UserActivity`] events.
165///
166/// Implementing this trait is the v0.2 extension seam: a new reader wrapper
167/// (`lnk-core`, `shellbag-core`, `srum-core`, `winreg-artifacts`) implements
168/// `activities` and slots into [`build_timeline`] with no API change.
169pub trait ActivitySource {
170    /// The activities this source contributes to the timeline.
171    fn activities(&self) -> Vec<UserActivity>;
172}
173
174/// Does this shell command disable or clear command history?
175///
176/// Recognizes the common anti-forensic primitives across bash/zsh/PowerShell. The
177/// match is on structure (the verb + the well-known history target), not on a
178/// hardcoded full command line, so any member of the class is caught.
179fn is_history_tamper(cmd: &str) -> bool {
180    let c = cmd.to_ascii_lowercase();
181    let c = c.trim();
182    // bash/zsh: unset the history file, point it at the bit bucket, or clear it.
183    c.contains("unset histfile")
184        || c.contains("histfile=/dev/null")
185        || c.contains("histsize=0")
186        || c.contains("histfilesize=0")
187        || (c.contains("history") && (c.contains(" -c") || c.ends_with("-c")))
188        || c.contains("history -c")
189        // PowerShell PSReadLine history file removal.
190        || (c.contains("clear-history"))
191        || (c.contains("remove-item") && c.contains("consolehost_history"))
192        // Truncate/remove the history file directly.
193        || (c.contains("rm ") && c.contains(".bash_history"))
194        || (c.contains("rm ") && c.contains(".zsh_history"))
195        || (c.starts_with("> ") && c.contains("history"))
196}
197
198/// A [`ShellHistorySource`] wraps a borrowed slice of decoded history entries.
199///
200/// Each command becomes an [`Action::Executed`] [`UserActivity`]; a command that
201/// disables or clears history becomes an [`Action::HistoryTampered`] event instead
202/// (the clearing itself is the activity worth surfacing).
203pub struct ShellHistorySource<'a> {
204    entries: &'a [HistoryEntry],
205    actor: Option<String>,
206}
207
208impl<'a> ShellHistorySource<'a> {
209    /// Wrap decoded history entries with no attributed actor.
210    #[must_use]
211    pub fn new(entries: &'a [HistoryEntry]) -> Self {
212        Self {
213            entries,
214            actor: None,
215        }
216    }
217
218    /// Wrap decoded history entries, attributing them to a known user/account.
219    #[must_use]
220    pub fn for_actor(entries: &'a [HistoryEntry], actor: impl Into<String>) -> Self {
221        Self {
222            entries,
223            actor: Some(actor.into()),
224        }
225    }
226}
227
228impl ActivitySource for ShellHistorySource<'_> {
229    fn activities(&self) -> Vec<UserActivity> {
230        from_shell_history(self.entries, self.actor.as_deref())
231    }
232}
233
234/// Normalize a decoded shell-history stream into [`UserActivity`] events.
235///
236/// Each command → [`Action::Executed`]; a history-clearing command →
237/// [`Action::HistoryTampered`]. The `actor` (when known) is carried onto every
238/// event.
239#[must_use]
240pub fn from_shell_history(entries: &[HistoryEntry], actor: Option<&str>) -> Vec<UserActivity> {
241    entries
242        .iter()
243        .map(|e| {
244            let action = if is_history_tamper(&e.command) {
245                Action::HistoryTampered
246            } else {
247                Action::Executed
248            };
249            UserActivity {
250                timestamp: e.timestamp,
251                actor: actor.map(ToString::to_string),
252                action,
253                subject: Subject::Command(e.command.clone()),
254                source: SourceKind::ShellHistory,
255                detail: e.command.clone(),
256            }
257        })
258        .collect()
259}
260
261/// A [`DeviceSource`] wraps a borrowed slice of decoded device connections.
262///
263/// Each connection becomes an [`Action::Connected`] [`UserActivity`] whose
264/// [`Subject::Device`] carries the device instance id and the **volume serial**, so
265/// the v0.2 LNK/shellbag join can light up.
266pub struct DeviceSource<'a> {
267    connections: &'a [DeviceConnection],
268}
269
270impl<'a> DeviceSource<'a> {
271    /// Wrap decoded device connections.
272    #[must_use]
273    pub fn new(connections: &'a [DeviceConnection]) -> Self {
274        Self { connections }
275    }
276}
277
278impl ActivitySource for DeviceSource<'_> {
279    fn activities(&self) -> Vec<UserActivity> {
280        from_device_connections(self.connections)
281    }
282}
283
284/// Normalize a decoded device-connection stream into [`UserActivity`] events.
285///
286/// Each connection → [`Action::Connected`], carrying the device id and the volume
287/// serial. The timestamp is the device's first-install/first-seen stamp when the
288/// source recorded one.
289#[must_use]
290pub fn from_device_connections(connections: &[DeviceConnection]) -> Vec<UserActivity> {
291    connections
292        .iter()
293        .map(|c| {
294            let timestamp = c
295                .first_install
296                .or(c.last_arrival)
297                .or(c.last_install)
298                .map(|s| s.value);
299            UserActivity {
300                timestamp,
301                actor: None,
302                action: Action::Connected,
303                subject: Subject::Device {
304                    id: c.device_instance_id.clone(),
305                    volume_serial: c.volume_serial,
306                },
307                source: SourceKind::PeripheralDevice,
308                detail: c.device_instance_id.clone(),
309            }
310        })
311        .collect()
312}
313
314/// A [`SrumSource`] wraps borrowed SRUM network-usage and app-usage records plus
315/// the `SruDbIdMapTable` that resolves their integer `user_id` / `app_id` foreign
316/// keys to user SIDs and application paths.
317///
318/// SRUM is the first source that **attributes** activity to a specific user: each
319/// row becomes an [`Action::Executed`] [`UserActivity`] whose `actor` is the
320/// resolved user SID. Network rows additionally carry the per-interval byte volume
321/// in `detail`, sharpening the exfiltration lens.
322pub struct SrumSource<'a> {
323    network: &'a [srum_core::NetworkUsageRecord],
324    app_usage: &'a [srum_core::AppUsageRecord],
325    id_map: &'a [srum_core::IdMapEntry],
326}
327
328impl<'a> SrumSource<'a> {
329    /// Wrap decoded SRUM records with the id-map needed to resolve users and apps.
330    #[must_use]
331    pub fn new(
332        network: &'a [srum_core::NetworkUsageRecord],
333        app_usage: &'a [srum_core::AppUsageRecord],
334        id_map: &'a [srum_core::IdMapEntry],
335    ) -> Self {
336        Self {
337            network,
338            app_usage,
339            id_map,
340        }
341    }
342}
343
344impl ActivitySource for SrumSource<'_> {
345    fn activities(&self) -> Vec<UserActivity> {
346        from_srum(self.network, self.app_usage, self.id_map)
347    }
348}
349
350/// Resolve a SRUM integer id to its mapped name via the `SruDbIdMapTable`.
351///
352/// Returns `None` when the id is absent from the map — the caller substitutes a
353/// stable synthetic token so the foreign key is never silently dropped.
354fn resolve_id(id: i32, id_map: &[srum_core::IdMapEntry]) -> Option<String> {
355    id_map
356        .iter()
357        .find(|e| e.id == id)
358        .map(|e| e.name.clone())
359        .filter(|n| !n.is_empty())
360}
361
362/// Normalize SRUM network-usage and app-usage records into [`UserActivity`] events.
363///
364/// Each record → [`Action::Executed`], attributed to the user SID resolved from the
365/// id-map (falling back to a `user-id:<n>` token when unresolved). The application
366/// resolves to its path (falling back to `app-id:<n>`). Network rows carry their
367/// `<bytes_sent>↑ / <bytes_recv>↓ bytes` in `detail`; app-usage rows carry their
368/// foreground/background CPU cycles. The `DateTime<Utc>` timestamp becomes Unix
369/// epoch seconds.
370#[must_use]
371pub fn from_srum(
372    network: &[srum_core::NetworkUsageRecord],
373    app_usage: &[srum_core::AppUsageRecord],
374    id_map: &[srum_core::IdMapEntry],
375) -> Vec<UserActivity> {
376    let mut acts = Vec::with_capacity(network.len() + app_usage.len());
377
378    for r in network {
379        let actor =
380            resolve_id(r.user_id, id_map).unwrap_or_else(|| format!("user-id:{}", r.user_id));
381        let app = resolve_id(r.app_id, id_map).unwrap_or_else(|| format!("app-id:{}", r.app_id));
382        acts.push(UserActivity {
383            timestamp: Some(r.timestamp.timestamp()),
384            actor: Some(actor),
385            action: Action::Executed,
386            subject: Subject::Command(app),
387            source: SourceKind::Srum,
388            detail: format!(
389                "{}\u{2191} / {}\u{2193} bytes (SRUM network usage)",
390                r.bytes_sent, r.bytes_recv
391            ),
392        });
393    }
394
395    for r in app_usage {
396        let actor =
397            resolve_id(r.user_id, id_map).unwrap_or_else(|| format!("user-id:{}", r.user_id));
398        let app = resolve_id(r.app_id, id_map).unwrap_or_else(|| format!("app-id:{}", r.app_id));
399        acts.push(UserActivity {
400            timestamp: Some(r.timestamp.timestamp()),
401            actor: Some(actor),
402            action: Action::Executed,
403            subject: Subject::Command(app),
404            source: SourceKind::Srum,
405            detail: format!(
406                "{} foreground / {} background CPU cycles (SRUM app usage)",
407                r.foreground_cycles, r.background_cycles
408            ),
409        });
410    }
411
412    acts
413}
414
415/// A [`LnkSource`] wraps borrowed Windows Shell Link targets parsed by `lnk-core`.
416///
417/// Each [`ShellLink`](lnk_core::ShellLink) → an [`Action::Accessed`]
418/// [`Subject::File`] whose path is the link's local base path (or the network
419/// target's UNC name) and whose `volume_serial` is the `VolumeID`
420/// `DriveSerialNumber` — the structured key that completes the device join. The
421/// target's last-write FILETIME becomes the activity timestamp.
422pub struct LnkSource<'a> {
423    links: &'a [lnk_core::ShellLink],
424    actor: Option<String>,
425}
426
427impl<'a> LnkSource<'a> {
428    /// Wrap parsed shell links, attributing them to a user when known.
429    #[must_use]
430    pub fn new(links: &'a [lnk_core::ShellLink], actor: Option<&str>) -> Self {
431        Self {
432            links,
433            actor: actor.map(ToString::to_string),
434        }
435    }
436}
437
438impl ActivitySource for LnkSource<'_> {
439    fn activities(&self) -> Vec<UserActivity> {
440        from_lnk(self.links, self.actor.as_deref())
441    }
442}
443
444/// Normalize parsed Shell Links into [`Action::Accessed`] file [`UserActivity`]s.
445///
446/// Each link's target path comes from `link_info.local_base_path`; when that is
447/// absent, the `CommonNetworkRelativeLink` net name (a UNC share) is used. A link
448/// with no `LinkInfo` and no resolvable target is skipped rather than emitting a
449/// pathless event. The target's `write_time` FILETIME (already Unix epoch seconds,
450/// 0 = unset) becomes the timestamp; the `VolumeID` drive serial is carried on the
451/// [`Subject::File`] as the device-join key.
452#[must_use]
453pub fn from_lnk(links: &[lnk_core::ShellLink], actor: Option<&str>) -> Vec<UserActivity> {
454    links
455        .iter()
456        .filter_map(|link| {
457            let info = link.link_info.as_ref()?;
458            let path = info.local_base_path.clone().or_else(|| {
459                info.common_network_relative_link
460                    .as_ref()
461                    .and_then(|c| c.net_name.clone())
462            })?;
463            let volume_serial = info.volume_id.as_ref().map(|v| v.drive_serial_number);
464            // lnk-core already maps a zero "not set" FILETIME to 0 epoch seconds.
465            let timestamp = (link.header.write_time != 0).then_some(link.header.write_time);
466            Some(UserActivity {
467                timestamp,
468                actor: actor.map(ToString::to_string),
469                action: Action::Accessed,
470                subject: Subject::File {
471                    path: path.clone(),
472                    volume_serial,
473                },
474                source: SourceKind::LnkFile,
475                detail: format!("LNK target: {path}"),
476            })
477        })
478        .collect()
479}
480
481/// A [`JumpListSource`] wraps parsed `lnk-core` Jump Lists — the per-application
482/// MRU of recently opened (and pinned) items. Automatic destinations carry a
483/// `DestList` with the authoritative per-target access time and origin host;
484/// custom destinations are a flat list of embedded shell links.
485pub struct JumpListSource<'a> {
486    lists: &'a [lnk_core::JumpList],
487    actor: Option<String>,
488}
489
490impl<'a> JumpListSource<'a> {
491    /// Wrap parsed Jump Lists, attributing them to a user when known.
492    #[must_use]
493    pub fn new(lists: &'a [lnk_core::JumpList], actor: Option<&str>) -> Self {
494        Self {
495            lists,
496            actor: actor.map(ToString::to_string),
497        }
498    }
499}
500
501impl ActivitySource for JumpListSource<'_> {
502    fn activities(&self) -> Vec<UserActivity> {
503        from_jumplists(self.lists, self.actor.as_deref())
504    }
505}
506
507/// Normalize parsed Jump Lists into [`Action::Accessed`] file [`UserActivity`]s.
508///
509/// Each entry yields one event. The target path and access time prefer the
510/// `DestList` record (the authoritative MRU metadata of an automatic-destinations
511/// list); when there is no `DestList` (a custom-destinations entry) the embedded
512/// shell link supplies them, exactly as a loose `.lnk` does via [`from_lnk`]. The
513/// embedded link's `VolumeID` drive serial is carried on the [`Subject::File`] as
514/// the device-join key. An entry with no resolvable target path is skipped rather
515/// than emitting a pathless event.
516#[must_use]
517pub fn from_jumplists(lists: &[lnk_core::JumpList], actor: Option<&str>) -> Vec<UserActivity> {
518    let mut out = Vec::new();
519    for list in lists {
520        let app = list.app_id.as_deref().map_or_else(
521            || "unknown app".to_string(),
522            |id| {
523                forensicnomicon::jumplist::appid_name(id)
524                    .map_or_else(|| format!("AppID {id}"), ToString::to_string)
525            },
526        );
527        for entry in &list.entries {
528            let info = entry.link.link_info.as_ref();
529            let link_path = info.and_then(|i| {
530                i.local_base_path.clone().or_else(|| {
531                    i.common_network_relative_link
532                        .as_ref()
533                        .and_then(|c| c.net_name.clone())
534                })
535            });
536            // Prefer the DestList-recorded target; fall back to the embedded link.
537            let path = match entry.destlist.as_ref() {
538                Some(d) if !d.path.is_empty() => Some(d.path.clone()),
539                _ => link_path,
540            };
541            let Some(path) = path else { continue };
542
543            let volume_serial =
544                info.and_then(|i| i.volume_id.as_ref().map(|v| v.drive_serial_number));
545            // DestList last-access is authoritative; else the link write_time. Both
546            // use 0 as the "not set" sentinel.
547            let dl_ts = entry
548                .destlist
549                .as_ref()
550                .and_then(|d| (d.last_access != 0).then_some(d.last_access));
551            let timestamp = dl_ts.or_else(|| {
552                (entry.link.header.write_time != 0).then_some(entry.link.header.write_time)
553            });
554
555            let detail = match entry.destlist.as_ref() {
556                Some(d) => format!("JumpList ({app}) recent item on {}: {path}", d.hostname),
557                None => format!("JumpList ({app}) recent item: {path}"),
558            };
559            out.push(UserActivity {
560                timestamp,
561                actor: actor.map(ToString::to_string),
562                action: Action::Accessed,
563                subject: Subject::File {
564                    path,
565                    volume_serial,
566                },
567                source: SourceKind::JumpList,
568                detail,
569            });
570        }
571    }
572    out
573}
574
575/// Parse an ISO-8601 `%Y-%m-%dT%H:%M:%SZ` UTC timestamp (the form
576/// `winreg-artifacts` emits) into Unix epoch seconds. Returns [`None`] for an
577/// absent or unparseable value — a missing timestamp is forensically meaningful,
578/// not an error.
579fn iso8601_to_epoch(s: Option<&str>) -> Option<i64> {
580    let s = s?;
581    chrono::DateTime::parse_from_rfc3339(s)
582        .ok()
583        .map(|dt| dt.timestamp())
584}
585
586/// A [`RegistrySource`] wraps borrowed per-user registry artifacts decoded by
587/// `winreg-artifacts` from an `NTUSER.DAT` / `USRCLASS.DAT` hive.
588///
589/// It normalizes the three published per-user artifacts:
590/// [`UserAssist`](winreg_artifacts::userassist) → [`Action::Executed`],
591/// [`TypedURLs`](winreg_artifacts::typed_urls) → [`Action::Typed`], and
592/// [`ShellBags`](winreg_artifacts::shellbags) → [`Action::Accessed`] (folder).
593///
594/// `winreg-artifacts` v0.1 publishes exactly these three per-user decoders; it has
595/// no separate RecentDocs / RunMRU / MountPoints2 / TypedPaths modules, so the
596/// adapter maps the artifacts that actually exist.
597pub struct RegistrySource<'a> {
598    userassist: &'a [winreg_artifacts::userassist::UserAssistEntry],
599    typed_urls: &'a [winreg_artifacts::typed_urls::TypedUrl],
600    shellbags: &'a [winreg_artifacts::shellbags::ShellbagEntry],
601    actor: Option<String>,
602}
603
604impl<'a> RegistrySource<'a> {
605    /// Wrap decoded registry artifacts, attributing them to a user when known (the
606    /// hive owner — the SID/account the `NTUSER.DAT` belongs to).
607    #[must_use]
608    pub fn new(
609        userassist: &'a [winreg_artifacts::userassist::UserAssistEntry],
610        typed_urls: &'a [winreg_artifacts::typed_urls::TypedUrl],
611        shellbags: &'a [winreg_artifacts::shellbags::ShellbagEntry],
612        actor: Option<&str>,
613    ) -> Self {
614        Self {
615            userassist,
616            typed_urls,
617            shellbags,
618            actor: actor.map(ToString::to_string),
619        }
620    }
621}
622
623impl ActivitySource for RegistrySource<'_> {
624    fn activities(&self) -> Vec<UserActivity> {
625        from_registry(
626            self.userassist,
627            self.typed_urls,
628            self.shellbags,
629            self.actor.as_deref(),
630        )
631    }
632}
633
634/// Normalize UserAssist entries into [`Action::Executed`] [`UserActivity`] events.
635///
636/// Each entry → an `Executed` activity whose subject is the program path; the run
637/// count is carried in `detail` and the ROT13-decoded last-run timestamp parsed to
638/// epoch. The `actor` (the hive owner) is carried when known.
639#[must_use]
640pub fn from_userassist(
641    entries: &[winreg_artifacts::userassist::UserAssistEntry],
642    actor: Option<&str>,
643) -> Vec<UserActivity> {
644    entries
645        .iter()
646        .map(|e| UserActivity {
647            timestamp: iso8601_to_epoch(e.last_run.as_deref()),
648            actor: actor.map(ToString::to_string),
649            action: Action::Executed,
650            subject: Subject::Command(e.program.clone()),
651            source: SourceKind::Registry,
652            detail: format!("UserAssist: {} run {} time(s)", e.program, e.run_count),
653        })
654        .collect()
655}
656
657/// Normalize IE/Edge TypedURLs into [`Action::Typed`] [`UserActivity`] events.
658///
659/// Each typed URL → a `Typed` activity carrying the URL as a [`Subject::Query`]
660/// (an address-bar entry is a typed lookup); the companion `TypedURLsTime`
661/// timestamp parsed to epoch.
662#[must_use]
663pub fn from_typed_urls(
664    urls: &[winreg_artifacts::typed_urls::TypedUrl],
665    actor: Option<&str>,
666) -> Vec<UserActivity> {
667    urls.iter()
668        .map(|u| {
669            let detail = match &u.suspicious_reason {
670                Some(reason) => format!("TypedURL: {} ({reason})", u.url),
671                None => format!("TypedURL: {}", u.url),
672            };
673            UserActivity {
674                timestamp: iso8601_to_epoch(u.last_visited.as_deref()),
675                actor: actor.map(ToString::to_string),
676                action: Action::Typed,
677                subject: Subject::Query(u.url.clone()),
678                source: SourceKind::Registry,
679                detail,
680            }
681        })
682        .collect()
683}
684
685/// Normalize ShellBags into [`Action::Accessed`] folder [`UserActivity`] events.
686///
687/// Each BagMRU entry → an `Accessed` activity whose [`Subject::Folder`] is the
688/// reconstructed folder path; the key's `LastWriteTime` parsed to epoch.
689#[must_use]
690pub fn from_shellbags(
691    bags: &[winreg_artifacts::shellbags::ShellbagEntry],
692    actor: Option<&str>,
693) -> Vec<UserActivity> {
694    bags.iter()
695        .map(|b| UserActivity {
696            timestamp: iso8601_to_epoch(b.last_written.as_deref()),
697            actor: actor.map(ToString::to_string),
698            action: Action::Accessed,
699            subject: Subject::folder(b.path.clone()),
700            source: SourceKind::Registry,
701            detail: format!("ShellBag {}: {}", b.key_path, b.path),
702        })
703        .collect()
704}
705
706/// Normalize all three per-user registry artifacts into one [`UserActivity`] stream.
707///
708/// Concatenates [`from_userassist`], [`from_typed_urls`], and [`from_shellbags`],
709/// attributing every event to the hive owner when known.
710#[must_use]
711pub fn from_registry(
712    userassist: &[winreg_artifacts::userassist::UserAssistEntry],
713    typed_urls: &[winreg_artifacts::typed_urls::TypedUrl],
714    shellbags: &[winreg_artifacts::shellbags::ShellbagEntry],
715    actor: Option<&str>,
716) -> Vec<UserActivity> {
717    let mut acts = from_userassist(userassist, actor);
718    acts.extend(from_typed_urls(typed_urls, actor));
719    acts.extend(from_shellbags(shellbags, actor));
720    acts
721}
722
723/// Merge any number of [`ActivitySource`]s into one timeline, sorted by timestamp.
724///
725/// Events with a timestamp come first in ascending epoch order; `None`-timestamp
726/// events are kept (their order is forensically meaningful too) and ordered stably
727/// at the end, preserving source/insertion order among themselves.
728#[must_use]
729pub fn build_timeline(sources: &[&dyn ActivitySource]) -> Vec<UserActivity> {
730    let mut events: Vec<UserActivity> = sources.iter().flat_map(|s| s.activities()).collect();
731    // Stable sort keeps None-timestamp events in source order; the key puts
732    // timestamped events first (ascending), untimestamped last.
733    events.sort_by_key(|e| (e.timestamp.is_none(), e.timestamp.unwrap_or(i64::MAX)));
734    events
735}
736
737/// The default temporal window (seconds) for the exec-during-removable-media join.
738///
739/// One hour: wide enough to catch a command run while a stick is mounted, tight
740/// enough to keep the temporal coincidence meaningful and the false-positive rate
741/// low.
742pub const REMOVABLE_MEDIA_WINDOW_SECS: i64 = 3600;
743
744/// The conservative per-interval `bytes_sent` threshold above which a SRUM network
745/// row is surfaced as a graded exfiltration **lead** (`USERACT-NETWORK-EXFIL-VOLUME`).
746///
747/// SRUM aggregates per process per ~1-hour interval. 256 MiB sent by a single
748/// process in one interval is well above routine background/telemetry traffic yet
749/// low enough to catch a deliberate bulk upload; it is a deliberately conservative
750/// lead, not a verdict — a backup client or large legitimate upload can also cross
751/// it, so the examiner adjudicates.
752pub const NETWORK_EXFIL_BYTES_THRESHOLD: u64 = 256 * 1024 * 1024;
753
754/// The [`Source`] stamp for findings this analyzer emits.
755#[must_use]
756pub fn source(scope: impl Into<String>) -> Source {
757    Source {
758        analyzer: "useract-forensic".to_string(),
759        scope: scope.into(),
760        version: Some(env!("CARGO_PKG_VERSION").to_string()),
761    }
762}
763
764/// Generic volume-serial join: pair every [`Subject::Device`] activity with every
765/// [`Subject::File`] / [`Subject::Folder`] activity that names the **same volume
766/// serial**.
767///
768/// Active in v0.2: a [`Subject::File`] / [`Subject::Folder`] carrying a
769/// `volume_serial` (from `lnk-core`'s `VolumeID`) joins to a [`Subject::Device`]
770/// connected with the same serial. Returns `(device_index, file_index)` pairs into
771/// `events`.
772///
773/// The volume serial is read first from the subject's structured `volume_serial`
774/// field; a `vol:<serial>` token in [`UserActivity::detail`] is honored as a
775/// fallback so an out-of-band source that only annotates the detail still joins.
776#[must_use]
777pub fn device_file_volume_joins(events: &[UserActivity]) -> Vec<(usize, usize)> {
778    let mut pairs = Vec::new();
779    for (di, dev) in events.iter().enumerate() {
780        let Subject::Device {
781            volume_serial: Some(dev_serial),
782            ..
783        } = &dev.subject
784        else {
785            continue;
786        };
787        for (fi, file) in events.iter().enumerate() {
788            if file_volume_serial(file) == Some(*dev_serial) {
789                pairs.push((di, fi));
790            }
791        }
792    }
793    pairs
794}
795
796/// Extract a file/folder activity's volume serial: the subject's structured
797/// `volume_serial` field, else a `vol:<serial>` token in its
798/// [`UserActivity::detail`]. Non-file subjects yield [`None`].
799fn file_volume_serial(activity: &UserActivity) -> Option<u32> {
800    let structured = match &activity.subject {
801        Subject::File { volume_serial, .. } | Subject::Folder { volume_serial, .. } => {
802            *volume_serial
803        }
804        _ => return None,
805    };
806    if structured.is_some() {
807        return structured;
808    }
809    for tok in activity.detail.split_whitespace() {
810        if let Some(rest) = tok.strip_prefix("vol:") {
811            if let Ok(serial) = rest.parse::<u32>() {
812                return Some(serial);
813            }
814        }
815    }
816    None
817}
818
819/// Audit a merged timeline for cross-source user-activity findings.
820///
821/// Emits hedged, low-false-positive observations achievable from the v0.1 sources:
822///
823/// - `USERACT-EXEC-DURING-REMOVABLE-MEDIA` — a shell command executed within
824///   [`REMOVABLE_MEDIA_WINDOW_SECS`] of a removable mass-storage device connection
825///   (temporal cross-source join). Consistent with activity involving external
826///   media (MITRE T1052 / T1091).
827/// - `USERACT-HISTORY-TAMPERED` — a history-clearing activity present in the
828///   timeline (re-surfaced at the user-activity layer; MITRE T1070.003).
829///
830/// Every finding is an observation, never a verdict.
831#[must_use]
832pub fn audit(events: &[UserActivity]) -> Vec<Finding> {
833    audit_with(events, &source("host"))
834}
835
836/// [`audit`] with a caller-supplied [`Source`] stamp (scope/version).
837#[must_use]
838pub fn audit_with(events: &[UserActivity], src: &Source) -> Vec<Finding> {
839    let mut findings = Vec::new();
840
841    // Removable mass-storage connection windows: (epoch, device id).
842    //
843    // Eligibility is derived structurally from the device instance id's leading
844    // enumerator token (`USBSTOR`, `USB`, `SD`, `SCSI`, …) via the published
845    // `peripheral_core::Bus` classifier — not a hardcoded device list — so any
846    // mass-storage member of the class qualifies and HID/Bluetooth/MTP devices do
847    // not.
848    let media_windows: Vec<(i64, &str)> = events
849        .iter()
850        .filter_map(|e| match (&e.action, &e.subject, e.timestamp) {
851            (Action::Connected, Subject::Device { id, .. }, Some(ts)) if is_mass_storage_id(id) => {
852                Some((ts, id.as_str()))
853            }
854            _ => None,
855        })
856        .collect();
857
858    // USERACT-FILE-ON-EXTERNAL-DEVICE — a file/folder accessed on a volume whose
859    // serial matches a connected external device (the volume-serial join).
860    for (di, fi) in device_file_volume_joins(events) {
861        findings.push(file_on_external_device_finding(
862            &events[di],
863            &events[fi],
864            src,
865        ));
866    }
867
868    for event in events {
869        // USERACT-HISTORY-TAMPERED — re-surface the clearing signal here.
870        if event.action == Action::HistoryTampered {
871            findings.push(history_tampered_finding(event, src));
872            continue;
873        }
874
875        // USERACT-NETWORK-EXFIL-VOLUME — a SRUM network row whose per-interval
876        // bytes_sent crosses the conservative threshold (graded lead, not a verdict).
877        if event.source == SourceKind::Srum {
878            if let Some(bytes_sent) = srum_network_bytes_sent(event) {
879                if bytes_sent >= NETWORK_EXFIL_BYTES_THRESHOLD {
880                    findings.push(network_exfil_volume_finding(event, bytes_sent, src));
881                }
882            }
883        }
884
885        // USERACT-EXEC-DURING-REMOVABLE-MEDIA — temporal cross-source join.
886        if let (Action::Executed, Some(ts), Subject::Command(cmd)) =
887            (event.action, event.timestamp, &event.subject)
888        {
889            if let Some((win_ts, dev_id)) = media_windows
890                .iter()
891                .find(|(dev_ts, _)| (ts - dev_ts).abs() <= REMOVABLE_MEDIA_WINDOW_SECS)
892            {
893                findings.push(exec_during_media_finding(cmd, ts, *win_ts, dev_id, src));
894            }
895        }
896    }
897
898    findings
899}
900
901/// Is this device instance id a removable mass-storage transport?
902///
903/// Classifies the leading enumerator token (the part before the first `\`) with the
904/// published [`peripheral_core::Bus`] classifier. A bare id with no separator is
905/// treated as its own enumerator. Structural, not a device allow-list.
906fn is_mass_storage_id(instance_id: &str) -> bool {
907    let enumerator = instance_id.split('\\').next().unwrap_or(instance_id);
908    Bus::from_enumerator(enumerator).is_mass_storage()
909}
910
911fn history_tampered_finding(event: &UserActivity, src: &Source) -> Finding {
912    let cmd = match &event.subject {
913        Subject::Command(c) => c.as_str(),
914        _ => event.detail.as_str(),
915    };
916    Finding::observation(
917        Severity::Medium,
918        Category::Concealment,
919        "USERACT-HISTORY-TAMPERED",
920    )
921    .source(src.clone())
922    .note(format!(
923        "user activity {cmd:?} disables or clears the activity record; consistent with \
924             anti-forensic history tampering (MITRE T1070.003)"
925    ))
926    .evidence("command", cmd.to_string())
927    .external_ref(ExternalRef::mitre_attack("T1070.003"))
928    .build()
929}
930
931fn exec_during_media_finding(
932    cmd: &str,
933    cmd_ts: i64,
934    dev_ts: i64,
935    dev_id: &str,
936    src: &Source,
937) -> Finding {
938    Finding::observation(
939        Severity::Low,
940        Category::Threat,
941        "USERACT-EXEC-DURING-REMOVABLE-MEDIA",
942    )
943    .source(src.clone())
944    .note(format!(
945        "the command {cmd:?} ran within {REMOVABLE_MEDIA_WINDOW_SECS}s of removable mass-storage \
946         device {dev_id:?} being connected; consistent with activity involving external media \
947         (MITRE T1052 / T1091)"
948    ))
949    .evidence("command", cmd.to_string())
950    .evidence("device", dev_id.to_string())
951    .evidence("command_epoch", cmd_ts.to_string())
952    .evidence("device_epoch", dev_ts.to_string())
953    .external_ref(ExternalRef::mitre_attack("T1052"))
954    .external_ref(ExternalRef::mitre_attack("T1091"))
955    .build()
956}
957
958/// Recover the `bytes_sent` value a SRUM network-usage activity advertises in its
959/// `detail` (the `<n>\u{2191} …` prefix [`from_srum`] writes). Returns `None` for
960/// any non-network SRUM activity (e.g. an app-usage row).
961fn srum_network_bytes_sent(activity: &UserActivity) -> Option<u64> {
962    let prefix = activity.detail.split('\u{2191}').next()?;
963    prefix.trim().parse::<u64>().ok()
964}
965
966fn network_exfil_volume_finding(event: &UserActivity, bytes_sent: u64, src: &Source) -> Finding {
967    let app = match &event.subject {
968        Subject::Command(c) => c.as_str(),
969        _ => event.detail.as_str(), // cov:unreachable: caller is a SRUM network row, always Subject::Command
970    };
971    let actor = event.actor.as_deref().unwrap_or("(unattributed)");
972    Finding::observation(
973        Severity::Medium,
974        Category::Threat,
975        "USERACT-NETWORK-EXFIL-VOLUME",
976    )
977    .source(src.clone())
978    .note(format!(
979        "SRUM records {bytes_sent} bytes sent in one interval by {app:?} attributed to user \
980         {actor:?}; the volume exceeds the {NETWORK_EXFIL_BYTES_THRESHOLD}-byte lead threshold and \
981         is consistent with bulk data exfiltration (MITRE T1048 / T1052) — a graded lead for the \
982         examiner, not a verdict"
983    ))
984    .evidence("application", app.to_string())
985    .evidence("actor", actor.to_string())
986    .evidence("bytes_sent", bytes_sent.to_string())
987    .external_ref(ExternalRef::mitre_attack("T1048"))
988    .external_ref(ExternalRef::mitre_attack("T1052"))
989    .build()
990}
991
992fn file_on_external_device_finding(
993    device: &UserActivity,
994    file: &UserActivity,
995    src: &Source,
996) -> Finding {
997    let path = match &file.subject {
998        Subject::File { path, .. } | Subject::Folder { path, .. } => path.as_str(),
999        _ => file.detail.as_str(), // cov:unreachable: join only pairs File/Folder subjects here
1000    };
1001    let dev_id = match &device.subject {
1002        Subject::Device { id, .. } => id.as_str(),
1003        _ => device.detail.as_str(), // cov:unreachable: join only pairs Device subjects here
1004    };
1005    let serial = match &device.subject {
1006        Subject::Device {
1007            volume_serial: Some(s),
1008            ..
1009        } => *s,
1010        _ => 0, // cov:unreachable: join requires Device { volume_serial: Some(_) }
1011    };
1012    Finding::observation(
1013        Severity::Medium,
1014        Category::Threat,
1015        "USERACT-FILE-ON-EXTERNAL-DEVICE",
1016    )
1017    .source(src.clone())
1018    .note(format!(
1019        "a user accessed {path:?} on a volume (serial {serial:#010x}) whose serial matches the \
1020         connected external device {dev_id:?}; consistent with data movement to/from removable \
1021         media (MITRE T1052 / T1091)"
1022    ))
1023    .evidence("file", path.to_string())
1024    .evidence("device", dev_id.to_string())
1025    .evidence("volume_serial", format!("{serial:#010x}"))
1026    .external_ref(ExternalRef::mitre_attack("T1052"))
1027    .external_ref(ExternalRef::mitre_attack("T1091"))
1028    .build()
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::*;
1034    use peripheral_core::{Bus, Provenance, Stamp};
1035    use shellhist_core::{HistoryEntry, Shell};
1036
1037    fn entry(cmd: &str, ts: Option<i64>) -> HistoryEntry {
1038        HistoryEntry {
1039            shell: Shell::Bash,
1040            command: cmd.to_string(),
1041            timestamp: ts,
1042            elapsed: None,
1043            paths: Vec::new(),
1044        }
1045    }
1046
1047    fn device(
1048        instance_id: &str,
1049        bus: Bus,
1050        first_install: Option<i64>,
1051        vol: Option<u32>,
1052    ) -> DeviceConnection {
1053        DeviceConnection {
1054            bus,
1055            device_class_guid: None,
1056            vid: None,
1057            pid: None,
1058            device_serial: None,
1059            serial_is_os_generated: false,
1060            friendly_name: None,
1061            device_instance_id: instance_id.to_string(),
1062            first_install: first_install.map(Stamp::authoritative),
1063            last_install: None,
1064            last_arrival: None,
1065            last_removal: None,
1066            parent_id_prefix: None,
1067            volume_guid: None,
1068            drive_letter: None,
1069            volume_serial: vol,
1070            disk_signature: None,
1071            dma_capable: bus.is_dma_capable(),
1072            mitre: Vec::new(),
1073            source: Provenance {
1074                file: "setupapi.dev.log".to_string(),
1075                line: 1,
1076            },
1077        }
1078    }
1079
1080    // ── from_shell_history ────────────────────────────────────────────────────
1081
1082    #[test]
1083    fn shell_command_becomes_executed_activity() {
1084        let entries = [entry("ls -la /tmp", Some(1_700_000_000))];
1085        let acts = from_shell_history(&entries, None);
1086        assert_eq!(acts.len(), 1);
1087        assert_eq!(acts[0].action, Action::Executed);
1088        assert_eq!(acts[0].source, SourceKind::ShellHistory);
1089        assert_eq!(acts[0].timestamp, Some(1_700_000_000));
1090        assert_eq!(acts[0].subject, Subject::Command("ls -la /tmp".to_string()));
1091        assert_eq!(acts[0].actor, None);
1092    }
1093
1094    #[test]
1095    fn shell_actor_is_carried_when_known() {
1096        let entries = [entry("whoami", None)];
1097        let acts = from_shell_history(&entries, Some("alice"));
1098        assert_eq!(acts[0].actor.as_deref(), Some("alice"));
1099    }
1100
1101    #[test]
1102    fn history_clearing_command_becomes_tampered() {
1103        for cmd in [
1104            "unset HISTFILE",
1105            "history -c",
1106            "export HISTFILE=/dev/null",
1107            "Clear-History",
1108            "rm ~/.bash_history",
1109        ] {
1110            let entries = [entry(cmd, Some(1))];
1111            let acts = from_shell_history(&entries, None);
1112            assert_eq!(acts[0].action, Action::HistoryTampered);
1113        }
1114    }
1115
1116    #[test]
1117    fn benign_command_is_not_tampered() {
1118        let entries = [entry("git log --oneline", Some(1))];
1119        let acts = from_shell_history(&entries, None);
1120        assert_eq!(acts[0].action, Action::Executed);
1121    }
1122
1123    // ── from_device_connections ───────────────────────────────────────────────
1124
1125    #[test]
1126    fn device_becomes_connected_with_volume_serial() {
1127        let conns = [device(
1128            "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB",
1129            Bus::Usb,
1130            Some(1_700_000_500),
1131            Some(0xDEAD_BEEF),
1132        )];
1133        let acts = from_device_connections(&conns);
1134        assert_eq!(acts.len(), 1);
1135        assert_eq!(acts[0].action, Action::Connected);
1136        assert_eq!(acts[0].source, SourceKind::PeripheralDevice);
1137        assert_eq!(acts[0].timestamp, Some(1_700_000_500));
1138        assert_eq!(
1139            acts[0].subject,
1140            Subject::Device {
1141                id: "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB".to_string(),
1142                volume_serial: Some(0xDEAD_BEEF),
1143            }
1144        );
1145    }
1146
1147    #[test]
1148    fn device_timestamp_falls_back_through_stamps() {
1149        let mut conn = device("USB\\VID_0781", Bus::Usb, None, None);
1150        conn.last_arrival = Some(Stamp::inferred(42));
1151        let acts = from_device_connections(&[conn]);
1152        assert_eq!(acts[0].timestamp, Some(42));
1153    }
1154
1155    #[test]
1156    fn device_without_any_stamp_has_no_timestamp() {
1157        let conn = device("USB\\VID_0781", Bus::Usb, None, None);
1158        let acts = from_device_connections(&[conn]);
1159        assert_eq!(acts[0].timestamp, None);
1160    }
1161
1162    // ── build_timeline ────────────────────────────────────────────────────────
1163
1164    #[test]
1165    fn timeline_merges_and_sorts_by_timestamp() {
1166        let entries = [entry("late", Some(300)), entry("early", Some(100))];
1167        let conns = [device("USBSTOR\\x", Bus::Usb, Some(200), None)];
1168        let shell = ShellHistorySource::new(&entries);
1169        let devices = DeviceSource::new(&conns);
1170        let tl = build_timeline(&[&shell, &devices]);
1171        let ts: Vec<Option<i64>> = tl.iter().map(|e| e.timestamp).collect();
1172        assert_eq!(ts, vec![Some(100), Some(200), Some(300)]);
1173    }
1174
1175    #[test]
1176    fn timeline_orders_untimestamped_events_last_and_stably() {
1177        let entries = [
1178            entry("no_ts_a", None),
1179            entry("ts", Some(50)),
1180            entry("no_ts_b", None),
1181        ];
1182        let shell = ShellHistorySource::new(&entries);
1183        let tl = build_timeline(&[&shell]);
1184        assert_eq!(tl[0].timestamp, Some(50));
1185        assert_eq!(tl[1].detail, "no_ts_a");
1186        assert_eq!(tl[2].detail, "no_ts_b");
1187    }
1188
1189    // ── audit: USERACT-HISTORY-TAMPERED ───────────────────────────────────────
1190
1191    #[test]
1192    fn audit_surfaces_history_tampered() {
1193        let entries = [entry("unset HISTFILE", Some(10))];
1194        let acts = from_shell_history(&entries, None);
1195        let findings = audit(&acts);
1196        let f = findings
1197            .iter()
1198            .find(|f| f.code == "USERACT-HISTORY-TAMPERED")
1199            .expect("history-tampered finding must fire");
1200        assert_eq!(f.severity, Some(Severity::Medium));
1201        assert_eq!(f.category, Category::Concealment);
1202    }
1203
1204    // ── audit: USERACT-EXEC-DURING-REMOVABLE-MEDIA ────────────────────────────
1205
1206    #[test]
1207    fn audit_fires_exec_during_removable_media_within_window() {
1208        let entries = [entry("tar czf /media/usb/out.tgz .", Some(1_000))];
1209        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_500), None)];
1210        let shell = ShellHistorySource::new(&entries);
1211        let devices = DeviceSource::new(&conns);
1212        let tl = build_timeline(&[&shell, &devices]);
1213        let findings = audit(&tl);
1214        assert!(findings
1215            .iter()
1216            .any(|f| f.code == "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1217    }
1218
1219    #[test]
1220    fn audit_does_not_fire_outside_window() {
1221        let entries = [entry("ls", Some(1_000))];
1222        let conns = [device(
1223            "USBSTOR\\Disk",
1224            Bus::Usb,
1225            Some(1_000 + REMOVABLE_MEDIA_WINDOW_SECS + 1),
1226            None,
1227        )];
1228        let shell = ShellHistorySource::new(&entries);
1229        let devices = DeviceSource::new(&conns);
1230        let tl = build_timeline(&[&shell, &devices]);
1231        let findings = audit(&tl);
1232        assert!(findings
1233            .iter()
1234            .all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1235    }
1236
1237    #[test]
1238    fn audit_does_not_fire_for_non_mass_storage_device() {
1239        // A Bluetooth HID device is NOT mass storage → no exec-during-media finding.
1240        let entries = [entry("ls", Some(1_000))];
1241        let conns = [device("BTHENUM\\Dev", Bus::Bluetooth, Some(1_000), None)];
1242        let shell = ShellHistorySource::new(&entries);
1243        let devices = DeviceSource::new(&conns);
1244        let tl = build_timeline(&[&shell, &devices]);
1245        let findings = audit(&tl);
1246        assert!(findings
1247            .iter()
1248            .all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1249    }
1250
1251    #[test]
1252    fn audit_with_custom_source_stamps_scope() {
1253        let entries = [entry("history -c", Some(1))];
1254        let acts = from_shell_history(&entries, None);
1255        let findings = audit_with(&acts, &source("CASE-001/host-7"));
1256        let f = &findings[0];
1257        assert_eq!(f.source.scope, "CASE-001/host-7");
1258        assert_eq!(f.source.analyzer, "useract-forensic");
1259    }
1260
1261    // ── findings are observations, never verdicts ─────────────────────────────
1262
1263    #[test]
1264    fn findings_are_hedged_observations_never_verdicts() {
1265        let entries = [
1266            entry("unset HISTFILE", Some(1_000)),
1267            entry("cp x /media/usb", Some(1_010)),
1268        ];
1269        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_005), None)];
1270        let shell = ShellHistorySource::new(&entries);
1271        let devices = DeviceSource::new(&conns);
1272        let tl = build_timeline(&[&shell, &devices]);
1273        let findings = audit(&tl);
1274        assert!(!findings.is_empty());
1275        for f in &findings {
1276            let note = f.note.to_ascii_lowercase();
1277            assert!(!note.contains("proves"));
1278            assert!(!note.contains("confirms"));
1279            assert!(!note.contains("definitely"));
1280            assert!(note.contains("consistent with"));
1281        }
1282    }
1283
1284    // ── volume-serial join seam (v0.2 activation, proven by construction) ──────
1285
1286    #[test]
1287    fn volume_serial_join_is_empty_for_v01_sources() {
1288        // v0.1 emits no File/Folder subjects carrying a volume serial → no joins.
1289        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1290        let acts = from_device_connections(&conns);
1291        assert!(device_file_volume_joins(&acts).is_empty());
1292    }
1293
1294    #[test]
1295    fn volume_serial_join_lights_up_for_a_v02_style_file_event() {
1296        // A synthetic v0.2-shape File activity advertising the same volume serial as
1297        // a connected device joins to it — proving the seam is correct by construction.
1298        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1299        let mut acts = from_device_connections(&conns);
1300        acts.push(UserActivity {
1301            timestamp: Some(2),
1302            actor: None,
1303            action: Action::Accessed,
1304            subject: Subject::file("\\\\?\\E:\\secret.docx"),
1305            source: SourceKind::PeripheralDevice, // placeholder
1306            detail: "opened E:\\secret.docx vol:4660".to_string(), // 0x1234 == 4660
1307        });
1308        let joins = device_file_volume_joins(&acts);
1309        assert_eq!(joins, vec![(0, 1)]);
1310    }
1311
1312    #[test]
1313    fn volume_serial_join_ignores_mismatched_serials() {
1314        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1315        let mut acts = from_device_connections(&conns);
1316        acts.push(UserActivity {
1317            timestamp: Some(2),
1318            actor: None,
1319            action: Action::Accessed,
1320            subject: Subject::file("x"),
1321            source: SourceKind::PeripheralDevice,
1322            detail: "vol:9999".to_string(),
1323        });
1324        assert!(device_file_volume_joins(&acts).is_empty());
1325    }
1326
1327    #[test]
1328    fn volume_serial_join_skips_files_without_a_volume_token() {
1329        // A folder activity that advertises no `vol:` token never joins (the
1330        // file_volume_serial None path).
1331        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1332        let mut acts = from_device_connections(&conns);
1333        acts.push(UserActivity {
1334            timestamp: Some(2),
1335            actor: None,
1336            action: Action::Accessed,
1337            subject: Subject::folder("E:\\photos"),
1338            source: SourceKind::PeripheralDevice,
1339            detail: "opened folder with no serial hint".to_string(),
1340        });
1341        // And a file whose `vol:` token is non-numeric (parse Err path) also never joins.
1342        acts.push(UserActivity {
1343            timestamp: Some(3),
1344            actor: None,
1345            action: Action::Accessed,
1346            subject: Subject::file("E:\\x"),
1347            source: SourceKind::PeripheralDevice,
1348            detail: "vol:notanumber".to_string(),
1349        });
1350        assert!(device_file_volume_joins(&acts).is_empty());
1351    }
1352
1353    #[test]
1354    fn history_tampered_finding_falls_back_to_detail_for_non_command_subject() {
1355        // Defensive: a HistoryTampered activity whose subject is not a Command still
1356        // produces a finding, using detail for the command text.
1357        let act = UserActivity {
1358            timestamp: Some(1),
1359            actor: None,
1360            action: Action::HistoryTampered,
1361            subject: Subject::file("ConsoleHost_history.txt"),
1362            source: SourceKind::ShellHistory,
1363            detail: "Remove-Item ConsoleHost_history.txt".to_string(),
1364        };
1365        let findings = audit(&[act]);
1366        assert_eq!(findings.len(), 1);
1367        assert_eq!(findings[0].code, "USERACT-HISTORY-TAMPERED");
1368        assert!(findings[0]
1369            .note
1370            .contains("Remove-Item ConsoleHost_history.txt"));
1371    }
1372
1373    #[test]
1374    fn is_mass_storage_id_classifies_bare_and_separated_ids() {
1375        assert!(is_mass_storage_id("USBSTOR\\Disk&Ven"));
1376        assert!(is_mass_storage_id("USBSTOR"));
1377        assert!(!is_mass_storage_id("BTHENUM\\Dev"));
1378        assert!(!is_mass_storage_id(""));
1379    }
1380
1381    #[test]
1382    fn activitysource_trait_dispatches() {
1383        let entries = [entry("ls", Some(1))];
1384        let s = ShellHistorySource::for_actor(&entries, "bob");
1385        let acts: Vec<UserActivity> = s.activities();
1386        assert_eq!(acts[0].actor.as_deref(), Some("bob"));
1387    }
1388
1389    // ── SRUM adapter (v0.2) ───────────────────────────────────────────────────
1390
1391    use srum_core::{AppUsageRecord, IdMapEntry, NetworkUsageRecord};
1392
1393    fn utc(epoch: i64) -> chrono::DateTime<chrono::Utc> {
1394        chrono::DateTime::from_timestamp(epoch, 0).expect("valid epoch")
1395    }
1396
1397    #[test]
1398    fn srum_network_row_is_executed_and_actor_attributed() {
1399        // user_id and app_id are integers resolved through the id-map.
1400        let id_map = [
1401            IdMapEntry {
1402                id: 7,
1403                name: "S-1-5-21-1-2-3-1001".to_string(),
1404            },
1405            IdMapEntry {
1406                id: 42,
1407                name: "\\Device\\HarddiskVolume3\\Windows\\explorer.exe".to_string(),
1408            },
1409        ];
1410        let net = [NetworkUsageRecord {
1411            app_id: 42,
1412            user_id: 7,
1413            timestamp: utc(1_700_000_000),
1414            bytes_sent: 4096,
1415            bytes_recv: 1024,
1416            auto_inc_id: 0,
1417        }];
1418        let acts = from_srum(&net, &[], &id_map);
1419        assert_eq!(acts.len(), 1);
1420        let a = &acts[0];
1421        assert_eq!(a.action, Action::Executed);
1422        assert_eq!(a.source, SourceKind::Srum);
1423        assert_eq!(a.timestamp, Some(1_700_000_000));
1424        // First source that ATTRIBUTES to a specific user SID.
1425        assert_eq!(a.actor.as_deref(), Some("S-1-5-21-1-2-3-1001"));
1426        // App resolves through the id-map.
1427        assert_eq!(
1428            a.subject,
1429            Subject::Command("\\Device\\HarddiskVolume3\\Windows\\explorer.exe".to_string())
1430        );
1431        // Network volume surfaced in the detail.
1432        assert!(a.detail.contains("4096"));
1433        assert!(a.detail.contains("1024"));
1434    }
1435
1436    #[test]
1437    fn srum_unresolved_user_id_falls_back_to_numeric_token() {
1438        // No id-map entry for the user → actor is a stable synthetic token, never lost.
1439        let net = [NetworkUsageRecord {
1440            app_id: 1,
1441            user_id: 99,
1442            timestamp: utc(10),
1443            bytes_sent: 1,
1444            bytes_recv: 2,
1445            auto_inc_id: 0,
1446        }];
1447        let acts = from_srum(&net, &[], &[]);
1448        assert_eq!(acts.len(), 1);
1449        assert_eq!(acts[0].actor.as_deref(), Some("user-id:99"));
1450        // App also falls back when unresolved.
1451        assert_eq!(acts[0].subject, Subject::Command("app-id:1".to_string()));
1452    }
1453
1454    #[test]
1455    fn srum_app_usage_row_is_executed_and_actor_attributed() {
1456        let id_map = [
1457            IdMapEntry {
1458                id: 5,
1459                name: "S-1-5-21-9-9-9-500".to_string(),
1460            },
1461            IdMapEntry {
1462                id: 8,
1463                name: "C:\\Tools\\rclone.exe".to_string(),
1464            },
1465        ];
1466        let app = [AppUsageRecord {
1467            app_id: 8,
1468            user_id: 5,
1469            timestamp: utc(1_700_000_500),
1470            foreground_cycles: 900_000,
1471            background_cycles: 100,
1472            auto_inc_id: 0,
1473        }];
1474        let acts = from_srum(&[], &app, &id_map);
1475        assert_eq!(acts.len(), 1);
1476        assert_eq!(acts[0].action, Action::Executed);
1477        assert_eq!(acts[0].source, SourceKind::Srum);
1478        assert_eq!(acts[0].actor.as_deref(), Some("S-1-5-21-9-9-9-500"));
1479        assert_eq!(
1480            acts[0].subject,
1481            Subject::Command("C:\\Tools\\rclone.exe".to_string())
1482        );
1483    }
1484
1485    #[test]
1486    fn srum_source_adapter_dispatches() {
1487        let net = [NetworkUsageRecord {
1488            app_id: 1,
1489            user_id: 1,
1490            timestamp: utc(1),
1491            bytes_sent: 1,
1492            bytes_recv: 1,
1493            auto_inc_id: 0,
1494        }];
1495        let s = SrumSource::new(&net, &[], &[]);
1496        let acts = s.activities();
1497        assert_eq!(acts.len(), 1);
1498        assert_eq!(acts[0].source, SourceKind::Srum);
1499    }
1500
1501    // ── audit: USERACT-NETWORK-EXFIL-VOLUME (v0.2) ────────────────────────────
1502
1503    #[test]
1504    fn audit_fires_network_exfil_volume_above_threshold() {
1505        let id_map = [
1506            IdMapEntry {
1507                id: 7,
1508                name: "S-1-5-21-1-2-3-1001".to_string(),
1509            },
1510            IdMapEntry {
1511                id: 42,
1512                name: "rclone.exe".to_string(),
1513            },
1514        ];
1515        let net = [NetworkUsageRecord {
1516            app_id: 42,
1517            user_id: 7,
1518            timestamp: utc(1_700_000_000),
1519            bytes_sent: NETWORK_EXFIL_BYTES_THRESHOLD + 1,
1520            bytes_recv: 0,
1521            auto_inc_id: 0,
1522        }];
1523        let acts = from_srum(&net, &[], &id_map);
1524        let findings = audit(&acts);
1525        let f = findings
1526            .iter()
1527            .find(|f| f.code == "USERACT-NETWORK-EXFIL-VOLUME")
1528            .expect("network-exfil-volume must fire above threshold");
1529        assert_eq!(f.severity, Some(Severity::Medium));
1530        assert_eq!(f.category, Category::Threat);
1531    }
1532
1533    #[test]
1534    fn audit_does_not_fire_network_exfil_below_threshold() {
1535        let net = [NetworkUsageRecord {
1536            app_id: 1,
1537            user_id: 1,
1538            timestamp: utc(1),
1539            bytes_sent: NETWORK_EXFIL_BYTES_THRESHOLD - 1,
1540            bytes_recv: 0,
1541            auto_inc_id: 0,
1542        }];
1543        let acts = from_srum(&net, &[], &[]);
1544        let findings = audit(&acts);
1545        assert!(findings
1546            .iter()
1547            .all(|f| f.code != "USERACT-NETWORK-EXFIL-VOLUME"));
1548    }
1549
1550    #[test]
1551    fn audit_skips_exfil_check_for_srum_app_usage_rows() {
1552        // An app-usage SRUM row carries CPU cycles, not bytes, so its detail has no
1553        // bytes-sent prefix: the exfil check sees None and never fires (regardless
1554        // of how large the cycle counts are).
1555        let app = [AppUsageRecord {
1556            app_id: 1,
1557            user_id: 1,
1558            timestamp: utc(1),
1559            foreground_cycles: u64::MAX,
1560            background_cycles: u64::MAX,
1561            auto_inc_id: 0,
1562        }];
1563        let acts = from_srum(&[], &app, &[]);
1564        let findings = audit(&acts);
1565        assert!(findings
1566            .iter()
1567            .all(|f| f.code != "USERACT-NETWORK-EXFIL-VOLUME"));
1568    }
1569
1570    // ── winreg-artifacts adapter (v0.2) ───────────────────────────────────────
1571
1572    use winreg_artifacts::shellbags::ShellbagEntry;
1573    use winreg_artifacts::typed_urls::TypedUrl;
1574    use winreg_artifacts::userassist::UserAssistEntry;
1575
1576    fn ua(program: &str, run_count: u32, last_run: Option<&str>) -> UserAssistEntry {
1577        UserAssistEntry {
1578            program: program.to_string(),
1579            run_count,
1580            focus_count: 0,
1581            focus_duration_ms: 0,
1582            last_run: last_run.map(ToString::to_string),
1583            guid: "{CEBFF5CD-ACE2-4F4F-9178-9926F41749EA}".to_string(),
1584        }
1585    }
1586
1587    #[test]
1588    fn userassist_entry_becomes_executed_with_run_count() {
1589        let entries = [ua(
1590            "C:\\Windows\\System32\\cmd.exe",
1591            5,
1592            Some("2024-06-15T08:00:00Z"),
1593        )];
1594        let acts = from_userassist(&entries, Some("alice"));
1595        assert_eq!(acts.len(), 1);
1596        let a = &acts[0];
1597        assert_eq!(a.action, Action::Executed);
1598        assert_eq!(a.source, SourceKind::Registry);
1599        assert_eq!(
1600            a.subject,
1601            Subject::Command("C:\\Windows\\System32\\cmd.exe".to_string())
1602        );
1603        // ISO last_run is parsed to epoch (2024-06-15T08:00:00Z = 1718438400).
1604        assert_eq!(a.timestamp, Some(1_718_438_400));
1605        assert_eq!(a.actor.as_deref(), Some("alice"));
1606        // Run count carried in detail.
1607        assert!(a.detail.contains('5'));
1608    }
1609
1610    #[test]
1611    fn userassist_without_last_run_has_no_timestamp() {
1612        let entries = [ua("notepad.exe", 1, None)];
1613        let acts = from_userassist(&entries, None);
1614        assert_eq!(acts[0].timestamp, None);
1615        assert_eq!(acts[0].actor, None);
1616    }
1617
1618    #[test]
1619    fn typed_url_becomes_typed_activity() {
1620        let urls = [TypedUrl {
1621            url: "https://pastebin.com/abc".to_string(),
1622            last_visited: Some("2024-01-02T03:04:05Z".to_string()),
1623            is_suspicious: true,
1624            suspicious_reason: Some("suspicious domain: pastebin.com".to_string()),
1625        }];
1626        let acts = from_typed_urls(&urls, None);
1627        assert_eq!(acts.len(), 1);
1628        assert_eq!(acts[0].action, Action::Typed);
1629        assert_eq!(acts[0].source, SourceKind::Registry);
1630        assert_eq!(
1631            acts[0].subject,
1632            Subject::Query("https://pastebin.com/abc".to_string())
1633        );
1634        assert!(acts[0].timestamp.is_some());
1635    }
1636
1637    #[test]
1638    fn shellbag_becomes_accessed_folder() {
1639        let bags = [ShellbagEntry {
1640            path: "BagMRU[slot=0, size=120 bytes]".to_string(),
1641            key_path: "Software\\Microsoft\\Windows\\Shell\\BagMRU\\0".to_string(),
1642            last_written: Some("2024-03-04T05:06:07Z".to_string()),
1643            mru_order: vec!["0".to_string()],
1644        }];
1645        let acts = from_shellbags(&bags, Some("bob"));
1646        assert_eq!(acts.len(), 1);
1647        assert_eq!(acts[0].action, Action::Accessed);
1648        assert_eq!(acts[0].source, SourceKind::Registry);
1649        assert!(matches!(acts[0].subject, Subject::Folder { .. }));
1650        assert_eq!(acts[0].actor.as_deref(), Some("bob"));
1651    }
1652
1653    #[test]
1654    fn from_registry_merges_all_three_registry_artifacts() {
1655        let ua_entries = [ua("cmd.exe", 1, Some("2024-06-15T08:00:00Z"))];
1656        let urls = [TypedUrl {
1657            url: "https://x.test".to_string(),
1658            last_visited: None,
1659            is_suspicious: false,
1660            suspicious_reason: None,
1661        }];
1662        let bags = [ShellbagEntry {
1663            path: "BagMRU[slot=0, size=10 bytes]".to_string(),
1664            key_path: "k".to_string(),
1665            last_written: None,
1666            mru_order: vec![],
1667        }];
1668        let acts = from_registry(&ua_entries, &urls, &bags, Some("alice"));
1669        assert_eq!(acts.len(), 3);
1670        assert!(acts.iter().any(|a| a.action == Action::Executed));
1671        assert!(acts.iter().any(|a| a.action == Action::Typed));
1672        assert!(acts.iter().any(|a| a.action == Action::Accessed));
1673        assert!(acts.iter().all(|a| a.source == SourceKind::Registry));
1674        assert!(acts.iter().all(|a| a.actor.as_deref() == Some("alice")));
1675    }
1676
1677    #[test]
1678    fn registry_source_adapter_dispatches() {
1679        let ua_entries = [ua("cmd.exe", 1, None)];
1680        let s = RegistrySource::new(&ua_entries, &[], &[], None);
1681        let acts = s.activities();
1682        assert_eq!(acts.len(), 1);
1683        assert_eq!(acts[0].source, SourceKind::Registry);
1684    }
1685
1686    // ── LNK adapter (v0.2) ────────────────────────────────────────────────────
1687
1688    use lnk_core::{LinkInfo, ShellLink, ShellLinkHeader, StringData, VolumeId};
1689
1690    fn shell_link(
1691        local_base_path: Option<&str>,
1692        drive_serial: Option<u32>,
1693        write_time: i64,
1694        net_name: Option<&str>,
1695    ) -> ShellLink {
1696        let volume_id = drive_serial.map(|s| VolumeId {
1697            drive_type: lnk_core::drive_type::REMOVABLE,
1698            drive_serial_number: s,
1699            volume_label: None,
1700        });
1701        let cnrl = net_name.map(|n| lnk_core::CommonNetworkRelativeLink {
1702            net_name: Some(n.to_string()),
1703            device_name: None,
1704        });
1705        ShellLink {
1706            header: ShellLinkHeader {
1707                link_flags: 0,
1708                file_attributes: 0,
1709                creation_time: 0,
1710                access_time: 0,
1711                write_time,
1712                file_size: 0,
1713                icon_index: 0,
1714                show_command: 1,
1715                hotkey: 0,
1716            },
1717            link_target_idlist: None,
1718            link_info: Some(LinkInfo {
1719                volume_id,
1720                local_base_path: local_base_path.map(ToString::to_string),
1721                common_network_relative_link: cnrl,
1722            }),
1723            string_data: StringData::default(),
1724            tracker: None,
1725        }
1726    }
1727
1728    #[test]
1729    fn lnk_target_becomes_accessed_file_with_volume_serial() {
1730        let links = [shell_link(
1731            Some("E:\\secret.docx"),
1732            Some(0xDEAD_BEEF),
1733            1_700_000_000,
1734            None,
1735        )];
1736        let acts = from_lnk(&links, Some("alice"));
1737        assert_eq!(acts.len(), 1);
1738        let a = &acts[0];
1739        assert_eq!(a.action, Action::Accessed);
1740        assert_eq!(a.source, SourceKind::LnkFile);
1741        // The target write time becomes the activity timestamp.
1742        assert_eq!(a.timestamp, Some(1_700_000_000));
1743        assert_eq!(a.actor.as_deref(), Some("alice"));
1744        // The File subject carries the structured volume serial (the join key).
1745        assert_eq!(
1746            a.subject,
1747            Subject::File {
1748                path: "E:\\secret.docx".to_string(),
1749                volume_serial: Some(0xDEAD_BEEF),
1750            }
1751        );
1752    }
1753
1754    #[test]
1755    fn lnk_without_volume_id_has_no_serial() {
1756        let links = [shell_link(Some("C:\\x.txt"), None, 0, None)];
1757        let acts = from_lnk(&links, None);
1758        assert_eq!(acts.len(), 1);
1759        assert_eq!(
1760            acts[0].subject,
1761            Subject::File {
1762                path: "C:\\x.txt".to_string(),
1763                volume_serial: None,
1764            }
1765        );
1766        // write_time 0 (the FILETIME "not set" sentinel) → no timestamp.
1767        assert_eq!(acts[0].timestamp, None);
1768    }
1769
1770    #[test]
1771    fn lnk_network_target_falls_back_to_unc_path() {
1772        // No local_base_path, but a CommonNetworkRelativeLink net name → use it.
1773        let links = [shell_link(None, None, 5, Some("\\\\server\\share"))];
1774        let acts = from_lnk(&links, None);
1775        assert_eq!(acts.len(), 1);
1776        assert_eq!(
1777            acts[0].subject,
1778            Subject::File {
1779                path: "\\\\server\\share".to_string(),
1780                volume_serial: None,
1781            }
1782        );
1783    }
1784
1785    #[test]
1786    fn lnk_without_link_info_is_skipped() {
1787        // A link with no LinkInfo and no usable target is dropped, not crashed.
1788        let mut link = shell_link(None, None, 0, None);
1789        link.link_info = None;
1790        let acts = from_lnk(&[link], None);
1791        assert!(acts.is_empty());
1792    }
1793
1794    fn destlist(path: &str, host: &str, last_access: i64) -> lnk_core::DestListEntry {
1795        lnk_core::DestListEntry {
1796            droid_volume_guid: String::new(),
1797            droid_file_guid: String::new(),
1798            birth_droid_volume_guid: String::new(),
1799            birth_droid_file_guid: String::new(),
1800            hostname: host.to_string(),
1801            entry_number: 1,
1802            last_access,
1803            pinned: false,
1804            access_count: Some(3),
1805            path: path.to_string(),
1806        }
1807    }
1808
1809    #[test]
1810    fn jumplist_automatic_entry_becomes_accessed_file() {
1811        // An automatic-destinations entry: the DestList records the target path,
1812        // the MRU last-access time, and the origin host; the embedded link carries
1813        // the volume serial (the device join key).
1814        let link = shell_link(
1815            Some("C:\\Users\\bob\\q3.xlsx"),
1816            Some(0x1234_5678),
1817            1_700_000_000,
1818            None,
1819        );
1820        let lists = [lnk_core::JumpList {
1821            kind: lnk_core::JumpListKind::Automatic,
1822            app_id: Some("1b4dd67f29cb1962".to_string()),
1823            entries: vec![lnk_core::JumpListEntry {
1824                destlist: Some(destlist("C:\\Users\\bob\\q3.xlsx", "WS01", 1_700_000_500)),
1825                link,
1826            }],
1827        }];
1828        let acts = from_jumplists(&lists, Some("bob"));
1829        assert_eq!(acts.len(), 1);
1830        let a = &acts[0];
1831        assert_eq!(a.action, Action::Accessed);
1832        assert_eq!(a.source, SourceKind::JumpList);
1833        // The DestList last-access time is authoritative (preferred over the link's
1834        // write_time) — it is the precise per-target MRU access timestamp.
1835        assert_eq!(a.timestamp, Some(1_700_000_500));
1836        assert_eq!(a.actor.as_deref(), Some("bob"));
1837        assert_eq!(
1838            a.subject,
1839            Subject::File {
1840                path: "C:\\Users\\bob\\q3.xlsx".to_string(),
1841                volume_serial: Some(0x1234_5678),
1842            }
1843        );
1844    }
1845
1846    #[test]
1847    fn jumplist_custom_entry_falls_back_to_embedded_link() {
1848        // A custom-destinations entry has no DestList: the path and timestamp come
1849        // from the embedded shell link, exactly like a loose .lnk.
1850        let link = shell_link(
1851            Some("D:\\report.pdf"),
1852            Some(0xAABB_CCDD),
1853            1_690_000_000,
1854            None,
1855        );
1856        let lists = [lnk_core::JumpList {
1857            kind: lnk_core::JumpListKind::Custom,
1858            app_id: None,
1859            entries: vec![lnk_core::JumpListEntry {
1860                destlist: None,
1861                link,
1862            }],
1863        }];
1864        let acts = from_jumplists(&lists, None);
1865        assert_eq!(acts.len(), 1);
1866        assert_eq!(acts[0].source, SourceKind::JumpList);
1867        assert_eq!(acts[0].timestamp, Some(1_690_000_000));
1868        assert_eq!(
1869            acts[0].subject,
1870            Subject::File {
1871                path: "D:\\report.pdf".to_string(),
1872                volume_serial: Some(0xAABB_CCDD),
1873            }
1874        );
1875    }
1876
1877    #[test]
1878    fn lnk_source_adapter_dispatches() {
1879        let links = [shell_link(Some("E:\\f"), Some(1), 1, None)];
1880        let s = LnkSource::new(&links, None);
1881        let acts = s.activities();
1882        assert_eq!(acts.len(), 1);
1883        assert_eq!(acts[0].source, SourceKind::LnkFile);
1884    }
1885
1886    // ── The volume-serial join activates end-to-end (LNK File ⋈ Device) ───────
1887
1888    #[test]
1889    fn lnk_file_joins_connected_device_on_volume_serial() {
1890        let links = [shell_link(
1891            Some("E:\\loot.zip"),
1892            Some(0xCAFE_F00D),
1893            100,
1894            None,
1895        )];
1896        let conns = [device(
1897            "USBSTOR\\Disk",
1898            Bus::Usb,
1899            Some(50),
1900            Some(0xCAFE_F00D),
1901        )];
1902        let lnk = LnkSource::new(&links, Some("alice"));
1903        let devices = DeviceSource::new(&conns);
1904        let timeline = build_timeline(&[&lnk, &devices]);
1905        let findings = audit(&timeline);
1906        let f = findings
1907            .iter()
1908            .find(|f| f.code == "USERACT-FILE-ON-EXTERNAL-DEVICE")
1909            .expect("file-on-external-device must fire when serials match");
1910        assert_eq!(f.severity, Some(Severity::Medium));
1911        assert_eq!(f.category, Category::Threat);
1912    }
1913}