forensicnomicon-core 1.5.1

Stable engine layer of the ForensicNomicon: the normalized DFIR report model (Finding/Severity/Observation) and structural format constants. Zero deps.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
//! **Desktop instant-messenger** artifact specs — Discord, Signal, Wire, WhatsApp.
//!
//! Where each desktop messenger keeps its evidence on disk, per OS: the profile
//! base directory, and which store within it holds messages, the account
//! identity/token, contacts, attachments, and the encryption key. This is the
//! KNOWLEDGE leaf a chat-artifact reader consults before it opens anything.
//!
//! This module is **facts only** — path templates, store roles, formats, and the
//! encryption posture. It performs no I/O and decodes nothing; the reader that
//! opens the SQLCipher DB, walks the Chromium LevelDB, or carves the Simple Cache
//! lives in the consuming crate (`browser-forensic` / a messenger carver).
//!
//! # Two architectures, two storage shapes
//!
//! Discord, Signal, and Wire are **Electron/Chromium** apps: their data lives in
//! Chromium storage under the standard Electron `userData` directory (Windows
//! `%AppData%`, macOS `~/Library/Application Support`, Linux `~/.config`, each
//! appended with the app name). See [`chromium_simple_cache`], [`chromium_indexeddb`],
//! and [`chromium_local_storage`] for those on-disk formats. WhatsApp Desktop is a
//! **native** app (a Windows Store UWP/WebView2 client, a macOS Catalyst client)
//! that keeps SEE/DPAPI-encrypted SQLite instead.
//!
//! Not every messenger keeps messages locally: Discord holds **no local message
//! database** — chats are fetched from the server and only *cached* — so its
//! recoverable message evidence is the Simple Cache, not a chat DB.
//!
//! # Desktop app vs web client — same format, different location
//!
//! Each Electron messenger also has a browser-hosted **web client** ([`WebClient`]).
//! The web version stores nothing in an app-named directory: its Chromium storage
//! is a slice of *the browser's* profile, partitioned by web **origin**. The
//! on-disk formats are identical to the desktop app's; only the location differs
//! (browser profile root + per-origin subtree vs the app's `userData` dir). The
//! browser-profile root is a browser-forensic concern, so [`WebClient`] records
//! the origin (and derives the per-origin IndexedDB directory) and leaves profile
//! discovery to the consumer. Signal has no web client; on Linux, WhatsApp's *only*
//! artifact is its web client (there is no native Linux WhatsApp desktop app).
//!
//! [`chromium_simple_cache`]: crate::chromium_simple_cache
//! [`chromium_indexeddb`]: crate::chromium_indexeddb
//! [`chromium_local_storage`]: crate::chromium_local_storage
//!
//! # Authoritative sources
//!
//! - Signal — Alexander Bilz, *A Forensic Gold Mine II: Forensic Analysis of
//!   Signal Messenger on Windows 10* (profile dirs, `sql/db.sqlite`, `config.json`,
//!   `attachments.noindex`, LevelDB/IndexedDB paths):
//!   <https://www.alexbilz.com/post/2021-06-07-forensic-artifacts-signal-desktop/>
//! - Discord — forensafe *Discord* artifact profile (`%AppData%\discord\Cache`):
//!   <https://www.forensafe.com/blogs/discord.html>; AhnLab ASEC (the
//!   `Local Storage\leveldb` token paths + `discordptb`/`discordcanary` variants):
//!   <https://asec.ahnlab.com/en/24512/>; Sankara Narayanan, *Simple Forensic
//!   Analysis on Discord in Windows 10* (the `%AppData%\discord` directory listing):
//!   <https://sankara-ns.medium.com/simple-forensic-analysis-on-discord-in-windows-10-d530506dcd81>
//! - Wire — hunjison, *Forensic Analysis of Wire Messenger in Windows OS* (the
//!   `IndexedDB\https_app.wire.com_0.indexeddb.leveldb` store + the `otr_key`):
//!   <https://velog.io/@hunjison/Forensic-Analysis-of-Wire-Messenger-in-Windows-OS>
//! - WhatsApp — Alberto Magno, *WhatsApp Desktop (WEBVIEW2 and UWP Archs) and Web
//!   live forensics* (the `5319275A.WhatsAppDesktop` LocalState package,
//!   `genericStorageDB`, `Session.db`, SEE/DPAPI-NG encryption):
//!   <https://medium.com/@alberto.magno/whatsapp-desktop-and-web-live-forensics-4n6-233f640e9fb3>;
//!   Belkasoft, *WhatsApp Forensics on Computers* (the macOS
//!   `~/Library/Containers/desktop.WhatsApp` container):
//!   <https://belkasoft.com/whatsapp_forensics_on_computers>
//! - Electron `userData`/`appData` per-OS defaults (the macOS/Linux base dirs for
//!   the Electron messengers):
//!   <https://www.electronjs.org/docs/latest/api/app>

use crate::catalog::types::Platform;

/// How a desktop messenger packages itself — this decides the storage shape.
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppKind {
    /// Electron/Chromium wrapper — data lives in Chromium storage under the
    /// standard `userData` directory.
    Electron,
    /// Native client (Windows UWP/WebView2, macOS Catalyst) — SEE/DPAPI-encrypted
    /// SQLite rather than Chromium storage.
    Native,
}

/// The forensic role a store plays within a messenger profile.
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoreRole {
    /// The chat/message record store.
    Messages,
    /// Account identity and/or the auth token.
    Account,
    /// The contact / conversation roster.
    Contacts,
    /// Attachment / media blobs.
    Attachments,
    /// Cached media & API responses (survives message deletion).
    MediaCache,
    /// The key material that unlocks the encrypted stores.
    EncryptionKey,
}

/// On-disk storage format of a messenger store.
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoreFormat {
    /// SQLCipher-encrypted SQLite (Signal).
    SqlCipher,
    /// SQLite encrypted with the SQLite Encryption Extension / DPAPI (WhatsApp).
    EncryptedSqlite,
    /// Plain SQLite.
    Sqlite,
    /// Chromium LevelDB (Local Storage / IndexedDB).
    ChromiumLevelDb,
    /// Chromium Simple Cache (one file per entry + index).
    ChromiumSimpleCache,
    /// A JSON document (e.g. `config.json`).
    Json,
    /// A directory of individually-encrypted blob files.
    EncryptedFiles,
}

/// One store within a messenger profile — a path relative to the per-OS base dir.
///
/// `relative_path` always uses `/` separators for OS neutrality; on Windows the
/// separator is a backslash and the base dir (see [`ProfilePath`]) carries the
/// native form.
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MessengerStore {
    /// What this store holds.
    pub role: StoreRole,
    /// Path relative to the profile base dir (forward-slash separated).
    pub relative_path: &'static str,
    /// On-disk format.
    pub format: StoreFormat,
    /// Whether the store is encrypted at rest.
    pub encrypted: bool,
    /// Which platforms this store applies to (empty ⇒ all profiles of the spec).
    pub platforms: &'static [Platform],
    /// Analyst-facing note: what fields/tables live here, caveats.
    pub note: &'static str,
}

/// A per-OS profile base directory for a messenger.
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProfilePath {
    /// The platform this base directory is for.
    pub platform: Platform,
    /// The profile base directory (native path style for the platform).
    pub base_dir: &'static str,
}

/// The web-client counterpart of a desktop messenger.
///
/// Unlike the desktop app — which owns an app-named Electron `userData`
/// directory (see [`ProfilePath`]) — the web client stores nothing app-named:
/// its Chromium storage is a *slice of the browser's profile*, partitioned by
/// web origin. The browser-profile root is a browser-forensic concern (profile
/// discovery); what is messenger-specific is the **origin**, and from it the
/// per-origin IndexedDB directory name and the Local Storage key prefix follow
/// by Chromium's naming convention. Same on-disk formats as the desktop app
/// ([`chromium_indexeddb`](crate::chromium_indexeddb),
/// [`chromium_local_storage`](crate::chromium_local_storage),
/// [`chromium_simple_cache`](crate::chromium_simple_cache)) — different location.
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WebClient {
    /// Serialized web origin, e.g. `https://discord.com`.
    pub origin: &'static str,
    /// Additional origins the same app serves (release channels, legacy hosts).
    pub alt_origins: &'static [&'static str],
    /// Analyst note: which browser-profile stores carry this origin's evidence.
    pub note: &'static str,
    /// Authoritative sources (all `https://`).
    pub sources: &'static [&'static str],
}

impl WebClient {
    /// Chromium's per-origin IndexedDB directory, relative to a browser profile
    /// root — e.g. `https://discord.com` → `IndexedDB/https_discord.com_0.indexeddb.leveldb`.
    ///
    /// Chromium names the directory `<scheme>_<host>_<port>.indexeddb.leveldb`
    /// (port `0` is the default-port placeholder). Join under any Chromium
    /// browser profile root (obtained from browser-forensic profile discovery).
    #[must_use]
    pub fn indexeddb_dir(&self) -> String {
        // `unwrap_or` (not `unwrap`) keeps this panic-free on a malformed origin.
        let (scheme, host) = self
            .origin
            .split_once("://")
            .unwrap_or(("https", self.origin));
        format!("IndexedDB/{scheme}_{host}_0.indexeddb.leveldb")
    }
}

/// A desktop messenger artifact spec.
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MessengerSpec {
    /// Canonical app name (lookup key).
    pub app: &'static str,
    /// Packaging kind — decides the storage shape.
    pub app_kind: AppKind,
    /// Per-OS profile base directories.
    pub profiles: &'static [ProfilePath],
    /// The stores within the profile.
    pub stores: &'static [MessengerStore],
    /// The browser-hosted web client, if the app has one (`None` for Signal).
    pub web: Option<WebClient>,
    /// Spec-level caveat (e.g. "no local message DB").
    pub note: &'static str,
    /// Authoritative sources that informed this spec (all `https://`).
    pub sources: &'static [&'static str],
}

impl MessengerSpec {
    /// The profile base directory for `platform`, if the app runs there.
    #[must_use]
    pub fn base_dir(&self, platform: Platform) -> Option<&'static str> {
        self.profiles
            .iter()
            .find(|p| p.platform == platform)
            .map(|p| p.base_dir)
    }

    /// The first store playing `role`, if any.
    #[must_use]
    pub fn store(&self, role: StoreRole) -> Option<&'static MessengerStore> {
        self.stores.iter().find(|s| s.role == role)
    }
}

/// All platforms — a store present under every listed profile.
const ALL_PLATFORMS: &[Platform] = &[Platform::Windows, Platform::MacOS, Platform::Linux];

/// Every desktop-messenger spec, keyed by [`MessengerSpec::app`].
pub const DESKTOP_MESSENGERS: &[MessengerSpec] = &[
    // ── Signal Desktop (Electron; SQLCipher) ─────────────────────────────────
    MessengerSpec {
        app: "Signal Desktop",
        app_kind: AppKind::Electron,
        // Source: https://www.alexbilz.com/post/2021-06-07-forensic-artifacts-signal-desktop/
        profiles: &[
            ProfilePath {
                platform: Platform::Windows,
                base_dir: r"%AppData%\Signal",
            },
            ProfilePath {
                platform: Platform::MacOS,
                base_dir: "~/Library/Application Support/Signal",
            },
            ProfilePath {
                platform: Platform::Linux,
                base_dir: "~/.config/Signal",
            },
        ],
        stores: &[
            MessengerStore {
                role: StoreRole::Messages,
                relative_path: "sql/db.sqlite",
                format: StoreFormat::SqlCipher,
                encrypted: true,
                platforms: ALL_PLATFORMS,
                // Source: https://www.alexbilz.com/post/2021-06-07-forensic-artifacts-signal-desktop/
                note: "SQLCipher (use SQLCipher 4 defaults); `messages` table = chat body, `conversations` table = contacts/groups.",
            },
            MessengerStore {
                role: StoreRole::Contacts,
                relative_path: "sql/db.sqlite",
                format: StoreFormat::SqlCipher,
                encrypted: true,
                platforms: ALL_PLATFORMS,
                note: "Same DB as messages; the `conversations` table holds contact/group rows.",
            },
            MessengerStore {
                role: StoreRole::EncryptionKey,
                relative_path: "config.json",
                format: StoreFormat::Json,
                encrypted: true,
                platforms: ALL_PLATFORMS,
                // Source: https://www.alexbilz.com/post/2021-06-07-forensic-artifacts-signal-desktop/
                note: "Legacy: plaintext `key`. Modern: `encryptedKey` wrapped by the OS keystore via `Local State` (Windows DPAPI / macOS Keychain 'Signal Safe Storage' / Linux libsecret).",
            },
            MessengerStore {
                role: StoreRole::Attachments,
                relative_path: "attachments.noindex",
                format: StoreFormat::EncryptedFiles,
                encrypted: true,
                platforms: ALL_PLATFORMS,
                note: "Per-attachment key derived from the SQLCipher master key.",
            },
        ],
        // Signal ships desktop + mobile only — no web client.
        web: None,
        note: "Also carries Chromium `Local Storage/leveldb` and `IndexedDB/file__0.indexeddb.leveldb` app-state stores.",
        sources: &["https://www.alexbilz.com/post/2021-06-07-forensic-artifacts-signal-desktop/"],
    },
    // ── Discord (Electron/Chromium; no local message DB) ─────────────────────
    MessengerSpec {
        app: "Discord",
        app_kind: AppKind::Electron,
        // Source: https://sankara-ns.medium.com/simple-forensic-analysis-on-discord-in-windows-10-d530506dcd81
        profiles: &[
            ProfilePath {
                platform: Platform::Windows,
                base_dir: r"%AppData%\discord",
            },
            // macOS/Linux follow the Electron userData convention.
            // Source: https://www.electronjs.org/docs/latest/api/app
            ProfilePath {
                platform: Platform::MacOS,
                base_dir: "~/Library/Application Support/discord",
            },
            ProfilePath {
                platform: Platform::Linux,
                base_dir: "~/.config/discord",
            },
        ],
        stores: &[
            MessengerStore {
                role: StoreRole::Account,
                relative_path: "Local Storage/leveldb",
                format: StoreFormat::ChromiumLevelDb,
                encrypted: true,
                platforms: ALL_PLATFORMS,
                // Source: https://asec.ahnlab.com/en/24512/
                note: "Auth token in the `.ldb`/`.log` files (DPAPI-protected in newer clients); prime info-stealer target. Test-build variants live under `discordptb` / `discordcanary`.",
            },
            MessengerStore {
                role: StoreRole::MediaCache,
                relative_path: "Cache/Cache_Data",
                format: StoreFormat::ChromiumSimpleCache,
                encrypted: false,
                platforms: ALL_PLATFORMS,
                // Source: https://www.forensafe.com/blogs/discord.html
                note: "Chromium Simple Cache: cached attachments, media, webhook URLs and API JSON. Survives message/channel/server deletion.",
            },
        ],
        web: Some(WebClient {
            origin: "https://discord.com",
            // Test/beta release channels serve the same app under distinct origins;
            // the desktop `discordptb`/`discordcanary` variants mirror these.
            alt_origins: &["https://ptb.discord.com", "https://canary.discord.com"],
            note: "In a browser profile: the auth token/app state is in the shared `Local Storage/leveldb` (keys prefixed with the origin), per-origin `IndexedDB/https_discord.com_0.indexeddb.leveldb`, and cached media in the browser's Simple Cache. Same formats as the desktop app, under the browser profile root.",
            sources: &[
                "https://asec.ahnlab.com/en/24512/",
                "https://www.forensafe.com/blogs/discord.html",
            ],
        }),
        // Source: https://sankara-ns.medium.com/simple-forensic-analysis-on-discord-in-windows-10-d530506dcd81
        note: "No local message database — chats are fetched from the server and only cached; recoverable message evidence is the Simple Cache, not a chat DB.",
        sources: &[
            "https://www.forensafe.com/blogs/discord.html",
            "https://asec.ahnlab.com/en/24512/",
            "https://sankara-ns.medium.com/simple-forensic-analysis-on-discord-in-windows-10-d530506dcd81",
        ],
    },
    // ── Wire (Electron/Chromium; messages in IndexedDB) ──────────────────────
    MessengerSpec {
        app: "Wire",
        app_kind: AppKind::Electron,
        // Source: https://velog.io/@hunjison/Forensic-Analysis-of-Wire-Messenger-in-Windows-OS
        profiles: &[
            ProfilePath {
                platform: Platform::Windows,
                base_dir: r"%AppData%\Wire",
            },
            // macOS/Linux follow the Electron userData convention.
            // Source: https://www.electronjs.org/docs/latest/api/app
            ProfilePath {
                platform: Platform::MacOS,
                base_dir: "~/Library/Application Support/Wire",
            },
            ProfilePath {
                platform: Platform::Linux,
                base_dir: "~/.config/Wire",
            },
        ],
        stores: &[
            MessengerStore {
                role: StoreRole::Messages,
                relative_path: "IndexedDB/https_app.wire.com_0.indexeddb.leveldb",
                format: StoreFormat::ChromiumLevelDb,
                encrypted: false,
                platforms: ALL_PLATFORMS,
                // Source: https://velog.io/@hunjison/Forensic-Analysis-of-Wire-Messenger-in-Windows-OS
                note: "Chat logs in the IndexedDB object stores: conversation id, sender, timestamp, message body.",
            },
            MessengerStore {
                role: StoreRole::Account,
                relative_path: "IndexedDB/https_app.wire.com_0.indexeddb.leveldb",
                format: StoreFormat::ChromiumLevelDb,
                encrypted: false,
                platforms: ALL_PLATFORMS,
                note: "Device class/model, verification status and account domain live in the same IndexedDB.",
            },
            MessengerStore {
                role: StoreRole::EncryptionKey,
                relative_path: "IndexedDB/https_app.wire.com_0.indexeddb.leveldb",
                format: StoreFormat::ChromiumLevelDb,
                encrypted: false,
                platforms: ALL_PLATFORMS,
                // Source: https://velog.io/@hunjison/Forensic-Analysis-of-Wire-Messenger-in-Windows-OS
                note: "`otr_key` (stored as decimal, convert to hex) decrypts attachments.",
            },
        ],
        web: Some(WebClient {
            origin: "https://app.wire.com",
            alt_origins: &[],
            note: "Same IndexedDB object stores as the desktop app (the Electron wrapper points at this origin), under the browser profile root instead of `userData`: `IndexedDB/https_app.wire.com_0.indexeddb.leveldb`.",
            sources: &["https://velog.io/@hunjison/Forensic-Analysis-of-Wire-Messenger-in-Windows-OS"],
        }),
        note: "Electron wrapper over the Wire web client; all evidence is in the Chromium IndexedDB.",
        sources: &["https://velog.io/@hunjison/Forensic-Analysis-of-Wire-Messenger-in-Windows-OS"],
    },
    // ── WhatsApp Desktop (native; SEE/DPAPI-encrypted SQLite) ────────────────
    MessengerSpec {
        app: "WhatsApp Desktop",
        app_kind: AppKind::Native,
        profiles: &[
            // Windows Store UWP/WebView2 client.
            // Source: https://medium.com/@alberto.magno/whatsapp-desktop-and-web-live-forensics-4n6-233f640e9fb3
            ProfilePath {
                platform: Platform::Windows,
                base_dir: r"%LocalAppData%\Packages\5319275A.WhatsAppDesktop_cv1g1gvanyjgm\LocalState",
            },
            // macOS Catalyst client container.
            // Source: https://belkasoft.com/whatsapp_forensics_on_computers
            ProfilePath {
                platform: Platform::MacOS,
                base_dir: "~/Library/Containers/desktop.WhatsApp",
            },
        ],
        stores: &[
            MessengerStore {
                role: StoreRole::Messages,
                relative_path: "genericStorageDB",
                format: StoreFormat::EncryptedSqlite,
                encrypted: true,
                platforms: &[Platform::Windows],
                // Source: https://medium.com/@alberto.magno/whatsapp-desktop-and-web-live-forensics-4n6-233f640e9fb3
                note: "WebView2 arch: `genericStorageDB` holds messages, SEE + DPAPI-NG encrypted. Older UWP arch used SEE-encrypted SQLite with `nondb_settings[0-9]{2}.dat` key files.",
            },
            MessengerStore {
                role: StoreRole::EncryptionKey,
                relative_path: "Session.db",
                format: StoreFormat::EncryptedSqlite,
                encrypted: true,
                platforms: &[Platform::Windows],
                // Source: https://medium.com/@alberto.magno/whatsapp-desktop-and-web-live-forensics-4n6-233f640e9fb3
                note: "`Session.db`/`session.db-wal` store the session clientKeys; per-session `nativeSettings.db` holds further key material (DPAPI-NG protected).",
            },
        ],
        // The macOS Catalyst client mirrors the iOS Core Data schema (ChatStorage.sqlite /
        // ZWAMESSAGE), but the cited desktop sources did not confirm its exact on-disk path,
        // so only the container is asserted here.
        web: Some(WebClient {
            origin: "https://web.whatsapp.com",
            alt_origins: &[],
            note: "WhatsApp Web keeps chats/contacts in the browser's per-origin `IndexedDB/https_web.whatsapp.com_0.indexeddb.leveldb` (model-storage object stores, Blink/V8-serialized values). There is NO native Linux desktop client, so on Linux this browser-profile store is the only WhatsApp artifact.",
            sources: &[
                "https://medium.com/@alberto.magno/whatsapp-desktop-and-web-live-forensics-4n6-233f640e9fb3",
            ],
        }),
        note: "macOS Catalyst client stores chats in a Core Data SQLite under the container; the exact desktop DB path was not confirmed by the cited sources. No native Linux desktop client — see the `web` client for Linux (and browser-based) coverage.",
        sources: &[
            "https://medium.com/@alberto.magno/whatsapp-desktop-and-web-live-forensics-4n6-233f640e9fb3",
            "https://belkasoft.com/whatsapp_forensics_on_computers",
        ],
    },
];

/// Look up a desktop messenger spec by its canonical [`MessengerSpec::app`] name.
#[must_use]
pub fn spec(app: &str) -> Option<&'static MessengerSpec> {
    DESKTOP_MESSENGERS.iter().find(|m| m.app == app)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn all_four_messengers_present() {
        assert!(spec("Signal Desktop").is_some());
        assert!(spec("Discord").is_some());
        assert!(spec("Wire").is_some());
        assert!(spec("WhatsApp Desktop").is_some());
        assert!(spec("does-not-exist").is_none());
        assert!(DESKTOP_MESSENGERS.len() >= 4);
    }

    #[test]
    fn signal_paths_and_stores() {
        let s = spec("Signal Desktop").expect("signal spec");
        assert_eq!(s.app_kind, AppKind::Electron);
        assert_eq!(s.base_dir(Platform::Windows), Some(r"%AppData%\Signal"));
        assert_eq!(
            s.base_dir(Platform::MacOS),
            Some("~/Library/Application Support/Signal")
        );
        assert_eq!(s.base_dir(Platform::Linux), Some("~/.config/Signal"));

        let msgs = s.store(StoreRole::Messages).expect("signal messages");
        assert_eq!(msgs.relative_path, "sql/db.sqlite");
        assert_eq!(msgs.format, StoreFormat::SqlCipher);
        assert!(msgs.encrypted);

        let key = s.store(StoreRole::EncryptionKey).expect("signal key");
        assert_eq!(key.relative_path, "config.json");
        assert_eq!(key.format, StoreFormat::Json);
    }

    #[test]
    fn discord_has_no_local_message_db() {
        let d = spec("Discord").expect("discord spec");
        assert_eq!(d.app_kind, AppKind::Electron);
        // No chat DB — messages are server-side, only cached.
        assert!(d.store(StoreRole::Messages).is_none());
        assert!(
            d.note.contains("server") || d.note.contains("cache") || d.note.contains("no local"),
            "discord note must explain the missing message DB: {:?}",
            d.note
        );

        let token = d.store(StoreRole::Account).expect("discord token");
        assert_eq!(token.relative_path, "Local Storage/leveldb");
        assert_eq!(token.format, StoreFormat::ChromiumLevelDb);

        let cache = d.store(StoreRole::MediaCache).expect("discord cache");
        assert_eq!(cache.format, StoreFormat::ChromiumSimpleCache);
    }

    #[test]
    fn wire_stores_messages_in_indexeddb() {
        let w = spec("Wire").expect("wire spec");
        assert_eq!(w.app_kind, AppKind::Electron);
        let msgs = w.store(StoreRole::Messages).expect("wire messages");
        assert!(msgs.relative_path.contains("wire.com"));
        assert!(msgs.relative_path.contains(".indexeddb.leveldb"));
        assert_eq!(msgs.format, StoreFormat::ChromiumLevelDb);
        assert!(w.store(StoreRole::EncryptionKey).is_some(), "otr_key store");
    }

    #[test]
    fn whatsapp_is_native_and_encrypted() {
        let wa = spec("WhatsApp Desktop").expect("whatsapp spec");
        assert_eq!(wa.app_kind, AppKind::Native);
        assert!(wa
            .base_dir(Platform::Windows)
            .expect("wa windows")
            .contains("5319275A.WhatsAppDesktop"));
        assert_eq!(
            wa.base_dir(Platform::MacOS),
            Some("~/Library/Containers/desktop.WhatsApp")
        );
        // No official Linux WhatsApp desktop client.
        assert_eq!(wa.base_dir(Platform::Linux), None);

        let msgs = wa.store(StoreRole::Messages).expect("wa messages");
        assert!(msgs.encrypted);
        assert_eq!(msgs.format, StoreFormat::EncryptedSqlite);
        assert_eq!(msgs.platforms, &[Platform::Windows]);
    }

    #[test]
    fn signal_has_no_web_client() {
        // Signal ships desktop + mobile only — there is no Signal web client.
        let s = spec("Signal Desktop").expect("signal spec");
        assert!(s.web.is_none());
    }

    #[test]
    fn web_clients_carry_the_right_origin() {
        assert_eq!(
            spec("Discord").expect("discord").web.expect("web").origin,
            "https://discord.com"
        );
        assert_eq!(
            spec("Wire").expect("wire").web.expect("web").origin,
            "https://app.wire.com"
        );
        assert_eq!(
            spec("WhatsApp Desktop")
                .expect("wa")
                .web
                .expect("web")
                .origin,
            "https://web.whatsapp.com"
        );
    }

    #[test]
    fn web_indexeddb_dir_follows_chromium_origin_naming() {
        let d = spec("Discord").expect("discord").web.expect("web");
        assert_eq!(
            d.indexeddb_dir(),
            "IndexedDB/https_discord.com_0.indexeddb.leveldb"
        );
        let wa = spec("WhatsApp Desktop").expect("wa").web.expect("web");
        assert_eq!(
            wa.indexeddb_dir(),
            "IndexedDB/https_web.whatsapp.com_0.indexeddb.leveldb"
        );
    }

    #[test]
    fn wire_web_and_desktop_indexeddb_paths_coincide() {
        // The Electron app is a Chromium pointed at the web origin, so the
        // per-origin IndexedDB directory name is identical to the web client's.
        let w = spec("Wire").expect("wire");
        let desktop_msgs = w.store(StoreRole::Messages).expect("wire messages");
        let web = w.web.expect("wire web");
        assert_eq!(desktop_msgs.relative_path, web.indexeddb_dir());
    }

    #[test]
    fn every_web_client_cites_https_sources() {
        for m in DESKTOP_MESSENGERS {
            if let Some(w) = m.web {
                assert!(!w.sources.is_empty(), "{} web has no sources", m.app);
                for url in w.sources {
                    assert!(
                        url.starts_with("https://"),
                        "{} web source is not https: {url}",
                        m.app
                    );
                }
            }
        }
    }

    #[test]
    fn relative_paths_use_forward_slashes() {
        for m in DESKTOP_MESSENGERS {
            for s in m.stores {
                assert!(
                    !s.relative_path.contains('\\'),
                    "{}/{:?} relative_path must use '/': {:?}",
                    m.app,
                    s.role,
                    s.relative_path
                );
            }
        }
    }

    #[test]
    fn every_spec_cites_https_sources() {
        for m in DESKTOP_MESSENGERS {
            assert!(!m.sources.is_empty(), "{} has no sources", m.app);
            for url in m.sources {
                assert!(
                    url.starts_with("https://"),
                    "{} source is not an https URL: {url}",
                    m.app
                );
            }
        }
    }
}