use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
const PAIRING_FILE: &str = "pairing.json";
const PENDING_PAIR_FILE: &str = "pending_pair.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairingRecord {
pub chat_id: i64,
pub paired_at: String,
}
impl PairingRecord {
pub fn new(chat_id: i64) -> Self {
Self {
chat_id,
paired_at: chrono::Utc::now().to_rfc3339(),
}
}
}
pub fn pairing_path(root: &Path) -> PathBuf {
root.join(PAIRING_FILE)
}
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
}
}
}
pub fn save(root: &Path, record: &PairingRecord) -> std::io::Result<()> {
crate::core::home_write_fence::check(root); 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(())
}
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),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingPairCode {
pub code: String,
pub issued_at_ms: u64,
}
impl PendingPairCode {
pub fn new(code: String) -> Self {
Self {
code,
issued_at_ms: now_unix_ms(),
}
}
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)
}
}
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)
}
pub fn pending_path(root: &Path) -> PathBuf {
root.join(PENDING_PAIR_FILE)
}
pub fn save_pending(root: &Path, pending: &PendingPairCode) -> std::io::Result<()> {
crate::core::home_write_fence::check(root); 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(())
}
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
}
}
}
const STALE_CLAIM_THRESHOLD: std::time::Duration = std::time::Duration::from_secs(60);
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];
let mut claim = base.as_os_str().to_os_string();
claim.push(format!(".claim.{pid}.{nanos}.{short_uid}"));
std::path::PathBuf::from(claim)
}
fn quarantine_path(root: &Path) -> std::path::PathBuf {
root.join(format!("{PENDING_PAIR_FILE}.bad"))
}
pub fn claim_pending(root: &Path) -> Option<PendingPairCode> {
let path = pending_path(root);
let tmp = claim_path(&path);
if std::fs::rename(&path, &tmp).is_err() {
return None;
}
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;
}
};
match serde_json::from_str::<PendingPairCode>(&contents) {
Ok(pending) => {
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
}
}
}
pub fn cleanup_stale_claims(root: &Path) {
cleanup_stale_claims_with_threshold(root, STALE_CLAIM_THRESHOLD);
}
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;
}
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 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());
}
}
}
}
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);
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());
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() {
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());
clear_pending(dir.path()).expect("idempotent clear");
}
#[test]
fn pending_expiry_uses_issued_at() {
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() {
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() {
let dir = tempfile::tempdir().expect("temp dir");
let base = pending_path(dir.path()); 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() {
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);
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() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path();
std::fs::write(pending_path(root), "{ this is not valid json }")
.expect("write malformed pending");
let result = claim_pending(root);
assert!(result.is_none(), "malformed file must yield None");
assert!(
!pending_path(root).exists(),
"pending_pair.json must NOT be present after quarantine — \
restoring it would cause an infinite loop"
);
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"
);
let second = claim_pending(root);
assert!(
second.is_none(),
"second claim_pending must return None without spinning"
);
}
#[test]
fn concurrent_pairing_confirms() {
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");
let barrier = Arc::new(Barrier::new(2));
let root1 = root.clone();
let barrier1 = Arc::clone(&barrier);
let t1 = std::thread::spawn(move || {
barrier1.wait(); claim_pending(&root1)
});
let root2 = root.clone();
let barrier2 = Arc::clone(&barrier);
let t2 = std::thread::spawn(move || {
barrier2.wait(); claim_pending(&root2)
});
let r1 = t1.join().expect("t1 did not panic");
let r2 = t2.join().expect("t2 did not panic");
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() {
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");
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() {
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() {
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() {
let missing = std::path::Path::new("/tmp/trusty-mpm-no-such-root-cleanup-test");
cleanup_stale_claims(missing);
}
}