trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
//! Persistent Telegram pairing record.
//!
//! Why: the daemon binds a single Telegram `chat_id` after a `/pair` handshake,
//! but that binding lived only in memory — a daemon restart silently dropped it
//! and the operator had to re-pair. The pairing must survive restarts.
//! What: [`PairingRecord`] is the on-disk shape (`chat_id` + `paired_at`);
//! [`load`] reads `~/.trusty-mpm/pairing.json` at startup, [`save`] writes it
//! atomically (write `.tmp`, then rename), and [`clear`] deletes it when the
//! pairing is reset.
//! Test: `cargo test -p trusty-mpm-daemon pairing_store` round-trips a record
//! through save / load / clear against a temp directory.

use std::path::{Path, PathBuf};
use std::time::Duration;

use serde::{Deserialize, Serialize};

/// File name of the pairing record under the framework root.
const PAIRING_FILE: &str = "pairing.json";

/// File name of the *pending* (not-yet-confirmed) pairing code.
///
/// Why: the live pairing CODE used to live only in the daemon's in-memory
/// `pair_code` mutex (see #1500). That made the mint surface (`tm telegram pair`
/// → `POST /pair/request`) and the confirm surface (the bot's `/pair <code>`)
/// agree ONLY when both happened to hit the very same daemon process. A second
/// daemon instance (e.g. the silent ephemeral-port duplicate from #1499) holds a
/// DIFFERENT empty mutex, so a code minted on one is rejected as "invalid" on the
/// other — which is exactly the symptom operators worked around by pre-seeding
/// `pairing.json`. Persisting the pending code to a single canonical file under
/// the framework root makes it a shared source of truth that survives a restart
/// and is visible to every surface rooted at the same `~/.trusty-mpm`.
const PENDING_PAIR_FILE: &str = "pending_pair.json";

/// The persisted Telegram-pairing record.
///
/// Why: a daemon restart must restore the paired chat without a fresh `/pair`
/// handshake; the record is the minimal state needed to do so.
/// What: the paired Telegram `chat_id` and the ISO-8601 instant it was paired.
/// Test: `save_then_load_round_trips`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairingRecord {
    /// The paired Telegram chat id.
    pub chat_id: i64,
    /// ISO-8601 timestamp of when the pairing was confirmed.
    pub paired_at: String,
}

impl PairingRecord {
    /// Build a record for `chat_id` stamped with the current UTC time.
    ///
    /// Why: `POST /pair/confirm` persists the binding the moment it succeeds.
    /// What: pairs `chat_id` with `chrono::Utc::now()` in RFC-3339 form.
    /// Test: `new_stamps_current_time`.
    pub fn new(chat_id: i64) -> Self {
        Self {
            chat_id,
            paired_at: chrono::Utc::now().to_rfc3339(),
        }
    }
}

/// Resolve the pairing file path under the given framework root.
///
/// Why: tests must point this at a temp directory rather than the real
/// `~/.trusty-mpm`; production callers pass the home-relative root.
/// What: joins `root/pairing.json`.
/// Test: exercised by `save_then_load_round_trips`.
pub fn pairing_path(root: &Path) -> PathBuf {
    root.join(PAIRING_FILE)
}

/// Load the pairing record from `root/pairing.json`, if present and valid.
///
/// Why: daemon startup restores the paired chat so push alerts keep working
/// across restarts without a fresh handshake.
/// What: returns `Some(record)` when the file exists and parses; `None` when it
/// is absent or malformed (a corrupt file must not abort startup — it is
/// logged and treated as "not paired").
/// Test: `load_missing_is_none`, `save_then_load_round_trips`.
pub fn load(root: &Path) -> Option<PairingRecord> {
    let path = pairing_path(root);
    let contents = std::fs::read_to_string(&path).ok()?;
    match serde_json::from_str::<PairingRecord>(&contents) {
        Ok(record) => Some(record),
        Err(e) => {
            tracing::warn!("ignoring malformed {}: {e}", path.display());
            None
        }
    }
}

/// Atomically write the pairing record to `root/pairing.json`.
///
/// Why: a crash mid-write must never leave a half-written, unparseable file;
/// writing to a `.tmp` sibling and renaming makes the swap atomic.
/// What: ensures `root` exists, serializes `record` to a `.tmp` file, then
/// renames it over the final path.
/// Test: `save_then_load_round_trips`.
pub fn save(root: &Path, record: &PairingRecord) -> std::io::Result<()> {
    crate::core::home_write_fence::check(root); // #8545
    std::fs::create_dir_all(root)?;
    let path = pairing_path(root);
    let tmp = path.with_extension("json.tmp");
    let json = serde_json::to_string_pretty(record)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    std::fs::write(&tmp, json)?;
    std::fs::rename(&tmp, &path)?;
    Ok(())
}

/// Delete the pairing record from `root/pairing.json`.
///
/// Why: `POST /pair/reset` (or any explicit unpair) must drop the persisted
/// binding so a restart does not resurrect it.
/// What: removes the file; an already-absent file is treated as success.
/// Test: `clear_removes_file`.
pub fn clear(root: &Path) -> std::io::Result<()> {
    let path = pairing_path(root);
    match std::fs::remove_file(&path) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(e),
    }
}

/// A pending, not-yet-confirmed pairing code persisted to disk.
///
/// Why: see [`PENDING_PAIR_FILE`] — the live code must be a shared source of
/// truth so any daemon instance (or `tm telegram` surface) rooted at the same
/// framework root validates a `/pair <code>` against the SAME outstanding code,
/// rather than each holding a private in-memory copy.
/// What: the six-character code plus the wall-clock instant it was issued, stored
/// as Unix epoch milliseconds so TTL math is portable across processes (an
/// in-memory `std::time::Instant` is not comparable across processes).
/// Test: `pending_round_trips`, `pending_expiry_uses_issued_at`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingPairCode {
    /// The outstanding one-time pairing code.
    pub code: String,
    /// Unix epoch milliseconds at which the code was issued.
    pub issued_at_ms: u64,
}

impl PendingPairCode {
    /// Build a pending code stamped with the current wall-clock time.
    ///
    /// Why: `POST /pair/request` mints a code and must record WHEN so a later
    /// confirm can enforce the TTL window using a process-portable timestamp.
    /// What: pairs `code` with the current `SystemTime` as epoch milliseconds.
    /// Test: `pending_round_trips`.
    pub fn new(code: String) -> Self {
        Self {
            code,
            issued_at_ms: now_unix_ms(),
        }
    }

    /// True when this code is still within `ttl` of its issue time.
    ///
    /// Why: confirmation must reject codes older than the TTL identically no
    /// matter which surface reads them, so the expiry rule lives here next to
    /// the timestamp it compares against rather than being re-derived per caller.
    /// What: returns `true` when `now - issued_at_ms < ttl`; a clock that has
    /// gone backwards (now < issued_at) is treated as "not expired" (saturating).
    /// Test: `pending_expiry_uses_issued_at`.
    pub fn is_fresh(&self, ttl: Duration) -> bool {
        let elapsed_ms = now_unix_ms().saturating_sub(self.issued_at_ms);
        elapsed_ms < u64::try_from(ttl.as_millis()).unwrap_or(u64::MAX)
    }
}

/// Current Unix time in milliseconds (saturating at the epoch on a skewed clock).
///
/// Why: pending-code TTL is compared across processes, so it needs a wall-clock
/// timestamp rather than a monotonic `Instant`.
/// What: returns `SystemTime::now()` since the Unix epoch in whole milliseconds;
/// a pre-epoch clock yields `0`.
/// Test: exercised by `pending_round_trips` (issued_at is non-zero).
fn now_unix_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
        .unwrap_or(0)
}

/// Resolve the pending-pair file path under the given framework root.
///
/// Why: tests redirect this at a temp directory; production callers pass the
/// home-relative root so every surface agrees on one canonical location.
/// What: joins `root/pending_pair.json`.
/// Test: exercised by `pending_round_trips`.
pub fn pending_path(root: &Path) -> PathBuf {
    root.join(PENDING_PAIR_FILE)
}

/// Persist the outstanding pending pairing code under `root`.
///
/// Why: minting a code must make it visible to every confirm surface rooted at
/// the same framework root, not just the in-memory state of the minting process.
/// What: atomically writes `pending_pair.json` (write `.tmp`, then rename),
/// creating `root` if needed.
/// Test: `pending_round_trips`.
pub fn save_pending(root: &Path, pending: &PendingPairCode) -> std::io::Result<()> {
    crate::core::home_write_fence::check(root); // #8545
    std::fs::create_dir_all(root)?;
    let path = pending_path(root);
    let tmp = path.with_extension("json.tmp");
    let json = serde_json::to_string_pretty(pending)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    std::fs::write(&tmp, json)?;
    std::fs::rename(&tmp, &path)?;
    Ok(())
}

/// Load the outstanding pending pairing code from `root`, if present and valid.
///
/// Why: the confirm path reads the shared outstanding code so a code minted by a
/// different surface (or before a restart) still validates.
/// What: returns `Some(pending)` when the file exists and parses; `None` when it
/// is absent or malformed (a corrupt file is logged and treated as "no code").
/// Test: `pending_round_trips`, `load_pending_missing_is_none`.
pub fn load_pending(root: &Path) -> Option<PendingPairCode> {
    let path = pending_path(root);
    let contents = std::fs::read_to_string(&path).ok()?;
    match serde_json::from_str::<PendingPairCode>(&contents) {
        Ok(pending) => Some(pending),
        Err(e) => {
            tracing::warn!("ignoring malformed {}: {e}", path.display());
            None
        }
    }
}

/// Maximum age of a `.claim.*` temp file before `cleanup_stale_claims` removes it.
///
/// Why: a crash between the rename and the delete leaves an orphan `.claim.*` that
/// permanently blocks pairing on the same framework root. Any orphan older than
/// this threshold is considered crash-leftover and is safe to remove.
const STALE_CLAIM_THRESHOLD: std::time::Duration = std::time::Duration::from_secs(60);

/// Build a collision-resistant claim path for the pending pair file.
///
/// Why: using only PID as the distinguishing suffix creates a PID-reuse collision
/// when a previous process with the same PID crashed leaving an orphan `.claim.<pid>`
/// file and a new process with the reused PID attempts a claim. Adding total
/// nanoseconds since the epoch and a UUID component makes the name unique with
/// overwhelming probability.
/// What: returns `<base_filename>.claim.<pid>.<total_nanos>.<uuid_prefix>` as a
/// sibling path of `base`. The suffix is **appended** to the full base filename
/// (including its `.json` extension) so `pending_pair.json` becomes
/// `pending_pair.json.claim.…` — not `pending_pair.json.claim.…` with a
/// replaced extension, which `Path::with_extension` would produce incorrectly.
/// Test: exercised by `claim_pending` callers; `claim_name_is_unique` checks
/// that two successive calls produce distinct names; `claim_path_appends_not_replaces`
/// asserts the base filename is preserved as a prefix of the claim filename.
fn claim_path(base: &std::path::Path) -> std::path::PathBuf {
    let pid = std::process::id();
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    let uid = uuid::Uuid::new_v4().simple().to_string();
    let short_uid = &uid[..8];
    // Append the claim suffix to the full OS string so the `.json` extension is
    // preserved rather than replaced (Path::with_extension replaces, not appends).
    let mut claim = base.as_os_str().to_os_string();
    claim.push(format!(".claim.{pid}.{nanos}.{short_uid}"));
    std::path::PathBuf::from(claim)
}

/// Quarantine path for a structurally-corrupt pending-pair file.
///
/// Why: a permanently-corrupt `pending_pair.json` must not be restored to the
/// live path after a parse failure — doing so would cause every `claim_pending`
/// call to loop: claim → parse-fail → restore → claim → … forever. Instead the
/// corrupt file is renamed here so it is preserved for debugging but can never
/// block future pairing attempts.
/// What: returns `<root>/pending_pair.json.bad`.
/// Test: `claim_pending_quarantines_malformed` verifies the sidecar is created.
fn quarantine_path(root: &Path) -> std::path::PathBuf {
    root.join(format!("{PENDING_PAIR_FILE}.bad"))
}

/// Atomically claim and load the outstanding pending pairing code from `root`.
///
/// Why: confirming a code must be atomic so two concurrent `/pair` requests to
/// different daemon processes cannot both read the file before either deletes it.
/// What: renames `pending_pair.json` to a collision-resistant unique temp name
/// (`<pid>.<total_nanos>.<uuid-prefix>`) first; only the process that succeeds at
/// the rename "wins" the claim. On a successful read and parse, the temp file is
/// deleted. On a **transient read I/O error** (bytes could not be read) the temp
/// file is renamed back to `pending_pair.json` so the next attempt can retry.
/// On a **parse failure** (bytes read OK but structurally invalid JSON) the temp
/// file is quarantined as `pending_pair.json.bad` — restoring it would cause an
/// infinite claim-parse-restore loop on permanently-corrupt data; the sidecar
/// preserves the bytes for debugging. Returns `Some(pending)` if claimed and
/// parseable; `None` otherwise.
/// Test: `concurrent_pairing_confirms`, `claim_pending_restores_on_read_failure`,
/// `claim_pending_quarantines_malformed`.
pub fn claim_pending(root: &Path) -> Option<PendingPairCode> {
    let path = pending_path(root);
    let tmp = claim_path(&path);

    // Atomically claim ownership of the file by renaming it.
    if std::fs::rename(&path, &tmp).is_err() {
        return None;
    }

    // Read the claimed file.
    // TRANSIENT READ ERROR → restore to live path so the next attempt can retry.
    let contents = match std::fs::read_to_string(&tmp) {
        Ok(c) => c,
        Err(e) => {
            tracing::warn!(
                "failed to read claimed pending pair file {}, restoring: {e}",
                tmp.display()
            );
            if let Err(re) = std::fs::rename(&tmp, &path) {
                tracing::warn!("could not restore pending pair file after read failure: {re}");
            }
            return None;
        }
    };

    // Parse the content.
    // PARSE FAILURE (structurally corrupt) → quarantine, do NOT restore.
    // Restoring would cause an infinite claim → parse-fail → restore loop.
    match serde_json::from_str::<PendingPairCode>(&contents) {
        Ok(pending) => {
            // Only delete on success — the data is safely in memory.
            let _ = std::fs::remove_file(&tmp);
            Some(pending)
        }
        Err(e) => {
            let bad = quarantine_path(root);
            tracing::error!(
                "pending pair file {} is structurally corrupt ({e}); quarantining to {} — \
                 re-issue a pairing code to recover",
                tmp.display(),
                bad.display()
            );
            if let Err(re) = std::fs::rename(&tmp, &bad) {
                tracing::warn!(
                    "could not quarantine corrupt pending pair file {}: {re}; \
                     leaving orphan temp file in place",
                    tmp.display()
                );
            }
            None
        }
    }
}

/// Remove stale orphan `.claim.*` files left by a crashed process.
///
/// Why: a process that crashes between the rename-claim and the delete leaves a
/// `pending_pair.json.claim.*` file that permanently blocks pairing unless cleaned
/// up. Calling this function at daemon startup recovers from such crashes.
/// What: delegates to [`cleanup_stale_claims_with_threshold`] using the default
/// [`STALE_CLAIM_THRESHOLD`] of 60 seconds.
/// Test: `cleanup_stale_claims_preserves_fresh_claim`,
/// `cleanup_stale_claims_noop_on_missing_root`.
pub fn cleanup_stale_claims(root: &Path) {
    cleanup_stale_claims_with_threshold(root, STALE_CLAIM_THRESHOLD);
}

/// Remove stale orphan `.claim.*` files older than `max_age`.
///
/// Why: the injectable threshold allows tests to exercise the stale-removal
/// branch without needing to backdate file mtimes or wait 60 seconds. Production
/// callers use [`cleanup_stale_claims`] which applies the standard 60-second
/// threshold.
/// What: scans `root` for files matching `pending_pair.json.claim.*`; any file
/// whose mtime age exceeds `max_age` is deleted. Files whose age cannot be
/// determined are conservatively LEFT ALONE (they may belong to a live concurrent
/// claim — deleting them risks race-deleting a live claim). Files younger than
/// the threshold are also left alone. A missing or unreadable directory is
/// silently ignored so startup is never blocked.
/// Test: `cleanup_stale_claims_removes_old_orphan_with_threshold`,
/// `cleanup_stale_claims_preserves_fresh_claim`.
pub fn cleanup_stale_claims_with_threshold(root: &Path, max_age: std::time::Duration) {
    let prefix = format!("{}.claim.", PENDING_PAIR_FILE);
    let entries = match std::fs::read_dir(root) {
        Ok(e) => e,
        Err(_) => return,
    };
    for entry in entries.flatten() {
        let name = entry.file_name();
        let name_str = name.to_string_lossy();
        if !name_str.starts_with(&prefix) {
            continue;
        }
        // Check mtime to determine if this orphan is old enough to remove.
        // Conservative default: if age is undeterminable, leave the file alone
        // to avoid race-deleting a live claim whose metadata is temporarily
        // unavailable.
        let is_stale = entry
            .metadata()
            .ok()
            .and_then(|m| m.modified().ok())
            .and_then(|mtime| mtime.elapsed().ok())
            .map(|age| age > max_age)
            .unwrap_or(false); // if age is undeterminable, conservatively keep
        if is_stale {
            let path = entry.path();
            if let Err(e) = std::fs::remove_file(&path) {
                tracing::warn!("failed to remove stale claim file {}: {e}", path.display());
            } else {
                tracing::info!("removed stale pairing claim file {}", path.display());
            }
        }
    }
}

/// Delete the outstanding pending pairing code under `root`.
///
/// Why: a code is single-use — once confirmed (or explicitly invalidated) the
/// shared store must drop it so it can never validate twice.
/// What: removes `pending_pair.json`; an already-absent file is success.
/// Test: `clear_pending_removes_file`.
pub fn clear_pending(root: &Path) -> std::io::Result<()> {
    let path = pending_path(root);
    match std::fs::remove_file(&path) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(e),
    }
}

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

    #[test]
    fn new_stamps_current_time() {
        let record = PairingRecord::new(42);
        assert_eq!(record.chat_id, 42);
        // RFC-3339 timestamps always contain a `T` separator.
        assert!(record.paired_at.contains('T'));
    }

    #[test]
    fn load_missing_is_none() {
        let dir = tempfile::tempdir().expect("temp dir");
        assert!(load(dir.path()).is_none());
    }

    #[test]
    fn save_then_load_round_trips() {
        let dir = tempfile::tempdir().expect("temp dir");
        let record = PairingRecord::new(123_456_789);
        save(dir.path(), &record).expect("save succeeds");
        let loaded = load(dir.path()).expect("record loads");
        assert_eq!(loaded, record);
    }

    #[test]
    fn save_creates_missing_root() {
        let dir = tempfile::tempdir().expect("temp dir");
        let nested = dir.path().join("does/not/exist");
        let record = PairingRecord::new(7);
        save(&nested, &record).expect("save creates the directory");
        assert_eq!(load(&nested), Some(record));
    }

    #[test]
    fn clear_removes_file() {
        let dir = tempfile::tempdir().expect("temp dir");
        save(dir.path(), &PairingRecord::new(1)).expect("save");
        assert!(load(dir.path()).is_some());
        clear(dir.path()).expect("clear succeeds");
        assert!(load(dir.path()).is_none());
        // Clearing an already-absent file is not an error.
        clear(dir.path()).expect("idempotent clear");
    }

    #[test]
    fn load_malformed_is_none() {
        let dir = tempfile::tempdir().expect("temp dir");
        std::fs::write(pairing_path(dir.path()), "{ not json").expect("write garbage");
        assert!(load(dir.path()).is_none());
    }

    #[test]
    fn pending_round_trips() {
        // A minted code persists to a canonical path and reads back identically,
        // with a non-zero issue timestamp — the basis for cross-process sharing.
        let dir = tempfile::tempdir().expect("temp dir");
        let pending = PendingPairCode::new("ABC123".to_string());
        assert!(pending.issued_at_ms > 0);
        save_pending(dir.path(), &pending).expect("save_pending succeeds");
        let loaded = load_pending(dir.path()).expect("pending loads");
        assert_eq!(loaded, pending);
    }

    #[test]
    fn load_pending_missing_is_none() {
        let dir = tempfile::tempdir().expect("temp dir");
        assert!(load_pending(dir.path()).is_none());
    }

    #[test]
    fn clear_pending_removes_file() {
        let dir = tempfile::tempdir().expect("temp dir");
        save_pending(dir.path(), &PendingPairCode::new("XYZ999".into())).expect("save");
        assert!(load_pending(dir.path()).is_some());
        clear_pending(dir.path()).expect("clear succeeds");
        assert!(load_pending(dir.path()).is_none());
        // Clearing an already-absent file is not an error.
        clear_pending(dir.path()).expect("idempotent clear");
    }

    #[test]
    fn pending_expiry_uses_issued_at() {
        // A code issued 10 minutes ago is stale under a 5-minute TTL; a code
        // issued "now" is fresh. Expiry is derived from the persisted timestamp
        // so every surface agrees regardless of process-local clocks.
        let ttl = Duration::from_secs(300);
        let fresh = PendingPairCode::new("FRESH1".into());
        assert!(fresh.is_fresh(ttl));

        let stale = PendingPairCode {
            code: "STALE1".into(),
            issued_at_ms: now_unix_ms().saturating_sub(600_000),
        };
        assert!(!stale.is_fresh(ttl));
    }

    #[test]
    fn load_pending_malformed_is_none() {
        let dir = tempfile::tempdir().expect("temp dir");
        std::fs::write(pending_path(dir.path()), "{ not json").expect("write garbage");
        assert!(load_pending(dir.path()).is_none());
    }

    #[test]
    fn claim_name_is_unique() {
        // Two successive calls to `claim_path` must produce distinct names even
        // within the same process and nanosecond — the UUID component guarantees it.
        let dir = tempfile::tempdir().expect("temp dir");
        let base = pending_path(dir.path());
        let p1 = claim_path(&base);
        let p2 = claim_path(&base);
        assert_ne!(
            p1, p2,
            "claim_path must generate unique names to prevent PID-reuse collisions"
        );
    }

    #[test]
    fn claim_path_appends_not_replaces() {
        // Finding 1: claim_path must APPEND the claim suffix to the full filename
        // (including its .json extension) rather than replacing the last extension.
        // For `pending_pair.json` the claim filename must start with
        // `pending_pair.json.claim.` — not `pending_pair.json.claim.<…>` with
        // the `.json` stripped.
        let dir = tempfile::tempdir().expect("temp dir");
        let base = pending_path(dir.path()); // …/pending_pair.json
        let claim = claim_path(&base);
        let claim_name = claim
            .file_name()
            .expect("claim has a filename")
            .to_string_lossy();
        assert!(
            claim_name.starts_with("pending_pair.json.claim."),
            "claim filename must start with 'pending_pair.json.claim.' \
             (i.e. base name preserved, suffix appended); got: {claim_name}"
        );
    }

    #[test]
    fn claim_pending_returns_code_and_removes_temp() {
        // Happy path: claim_pending claims a valid file, returns the code, and
        // leaves neither the original pending_pair.json nor the temp claim file.
        let dir = tempfile::tempdir().expect("temp dir");
        let root = dir.path();
        let pending = PendingPairCode::new("HAPPY1".into());
        save_pending(root, &pending).expect("save");
        let claimed = claim_pending(root).expect("claim succeeds");
        assert_eq!(claimed, pending);
        // Neither the original path nor any temp file should remain.
        assert!(!pending_path(root).exists(), "original file must be gone");
        let leftover: Vec<_> = std::fs::read_dir(root)
            .unwrap()
            .flatten()
            .filter(|e| {
                e.file_name()
                    .to_string_lossy()
                    .starts_with("pending_pair.json.claim.")
            })
            .collect();
        assert!(leftover.is_empty(), "no temp claim files must remain");
    }

    #[test]
    fn claim_pending_quarantines_malformed() {
        // Finding 2: a permanently-corrupt pending_pair.json must NOT be restored
        // to the live path after a parse failure — that would cause an infinite
        // claim → parse-fail → restore → claim loop. Instead it must be
        // quarantined as `pending_pair.json.bad`, the live path must NOT exist,
        // and a second call to claim_pending must return None without looping.
        let dir = tempfile::tempdir().expect("temp dir");
        let root = dir.path();
        // Write a malformed file as pending_pair.json.
        std::fs::write(pending_path(root), "{ this is not valid json }")
            .expect("write malformed pending");
        // First claim_pending: should return None and quarantine the corrupt file.
        let result = claim_pending(root);
        assert!(result.is_none(), "malformed file must yield None");
        // The live pending_pair.json must NOT be restored (it was corrupt).
        assert!(
            !pending_path(root).exists(),
            "pending_pair.json must NOT be present after quarantine — \
             restoring it would cause an infinite loop"
        );
        // A quarantine sidecar must exist.
        let bad = quarantine_path(root);
        assert!(
            bad.exists(),
            "a quarantine sidecar (pending_pair.json.bad) must be created \
             to preserve the corrupt bytes for debugging"
        );
        // A second call must return None cleanly (no live file to claim).
        let second = claim_pending(root);
        assert!(
            second.is_none(),
            "second claim_pending must return None without spinning"
        );
    }

    #[test]
    fn concurrent_pairing_confirms() {
        // Finding 4: two threads race to claim the pending code; a Barrier
        // synchronises them so both hit the rename simultaneously. Exactly one
        // must win (return Some) and the other must lose (return None), so each
        // code is consumed exactly once.
        use std::sync::{Arc, Barrier};

        let dir = tempfile::tempdir().expect("temp dir");
        let root = dir.path().to_path_buf();
        let pending = PendingPairCode::new("RACE01".into());
        save_pending(&root, &pending).expect("save");

        // Barrier ensures both threads reach the claim call at the same instant.
        let barrier = Arc::new(Barrier::new(2));

        let root1 = root.clone();
        let barrier1 = Arc::clone(&barrier);
        let t1 = std::thread::spawn(move || {
            barrier1.wait(); // synchronise with t2
            claim_pending(&root1)
        });

        let root2 = root.clone();
        let barrier2 = Arc::clone(&barrier);
        let t2 = std::thread::spawn(move || {
            barrier2.wait(); // synchronise with t1
            claim_pending(&root2)
        });

        let r1 = t1.join().expect("t1 did not panic");
        let r2 = t2.join().expect("t2 did not panic");

        // Exactly one thread wins the rename-claim; the other sees the file gone.
        let (winner, loser) = match (&r1, &r2) {
            (Some(_), None) => (r1, r2),
            (None, Some(_)) => (r2, r1),
            _ => panic!("expected exactly one winner and one loser; got r1={r1:?} r2={r2:?}"),
        };
        assert_eq!(
            winner.unwrap(),
            pending,
            "winner must hold the original code"
        );
        assert!(loser.is_none(), "loser must receive None");
    }

    #[test]
    fn cleanup_stale_claims_removes_old_orphan_with_threshold() {
        // Finding 4: the real stale-removal branch must be exercised.
        // By injecting a zero-duration threshold every claim file is "older than
        // threshold" the instant it is created, so remove_file is called on it.
        let dir = tempfile::tempdir().expect("temp dir");
        let root = dir.path();

        let orphan = root.join(format!("{PENDING_PAIR_FILE}.claim.99999.123.deadbeef"));
        std::fs::write(&orphan, "orphan").expect("write orphan");
        assert!(orphan.exists(), "orphan must exist before cleanup");

        // A zero-duration threshold means every file is stale.
        cleanup_stale_claims_with_threshold(root, Duration::ZERO);

        assert!(
            !orphan.exists(),
            "orphan claim file must be removed when older than threshold"
        );
    }

    #[test]
    fn cleanup_stale_claims_preserves_fresh_claim() {
        // A very recent `.claim.*` file (created moments ago) must not be
        // deleted by cleanup — it belongs to a live concurrent claim.
        let dir = tempfile::tempdir().expect("temp dir");
        let root = dir.path();
        let fresh = root.join(format!("{PENDING_PAIR_FILE}.claim.12345.999.abcdef12"));
        std::fs::write(&fresh, "live-claim").expect("write fresh claim");

        cleanup_stale_claims(root);

        assert!(
            fresh.exists(),
            "a just-created claim file must not be removed by cleanup"
        );
    }

    #[test]
    fn cleanup_stale_claims_ignores_unrelated_files() {
        // Unrelated files in root must never be touched by cleanup.
        let dir = tempfile::tempdir().expect("temp dir");
        let root = dir.path();
        let unrelated = root.join("pairing.json");
        std::fs::write(&unrelated, "keep me").expect("write unrelated");

        cleanup_stale_claims(root);

        assert!(
            unrelated.exists(),
            "cleanup must not remove files that are not .claim.* files"
        );
    }

    #[test]
    fn cleanup_stale_claims_noop_on_missing_root() {
        // A non-existent directory must not panic or error.
        let missing = std::path::Path::new("/tmp/trusty-mpm-no-such-root-cleanup-test");
        cleanup_stale_claims(missing);
        // If we get here, no panic occurred — the function handled it gracefully.
    }
}