Skip to main content

pidge_core/
account.rs

1//! Account types — represents a single Microsoft account signed into pidge.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6/// Where pidge stores OAuth tokens for an account.
7///
8/// `Keychain` is the OS-native credential store (macOS Keychain, Windows
9/// Credential Manager, Linux libsecret) — encrypted, OS-managed access control.
10/// `File` is a JSON file at `~/.config/pidge/tokens/<email>.json` with mode 0600
11/// (user-only read/write). The file backend is useful for headless or dev
12/// scenarios where the keychain prompts are friction, but it stores refresh
13/// tokens in plaintext on disk and is less safe than the keychain.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum TokenStorage {
17    #[default]
18    Keychain,
19    File,
20}
21
22/// A signed-in Microsoft account.
23///
24/// This is metadata only — no tokens. Tokens live in the OS keychain
25/// or in a per-account file at `~/.config/pidge/tokens/<email>.json`,
26/// keyed by `email`. The `storage` field tells pidge which backend to look in.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Account {
29    pub email: String,
30    pub tenant_id: String,
31    pub home_account_id: String,
32    pub added_at: DateTime<Utc>,
33    #[serde(default)]
34    pub storage: TokenStorage,
35}
36
37impl Account {
38    /// Well-known tenant ID for personal Microsoft accounts (outlook.com, live.com, hotmail.com).
39    /// Microsoft documents this as the "MSA" tenant.
40    pub const PERSONAL_MSA_TENANT: &'static str = "9188040d-6c67-4c5b-b112-36a304b66dad";
41
42    /// True if this account is a personal Microsoft account.
43    pub fn is_personal(&self) -> bool {
44        self.tenant_id == Self::PERSONAL_MSA_TENANT
45    }
46
47    /// A short human label for the tenant — "personal MSA" for MSA, GUID prefix otherwise.
48    pub fn tenant_label(&self) -> String {
49        if self.is_personal() {
50            "personal MSA".to_string()
51        } else {
52            let prefix: String = self.tenant_id.chars().take(8).collect();
53            format!("{prefix}…")
54        }
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    fn make_account(tenant_id: &str) -> Account {
63        Account {
64            email: "x@example.com".into(),
65            tenant_id: tenant_id.into(),
66            home_account_id: "home".into(),
67            added_at: DateTime::parse_from_rfc3339("2026-05-13T22:00:00Z")
68                .unwrap()
69                .to_utc(),
70            storage: TokenStorage::default(),
71        }
72    }
73
74    #[test]
75    fn token_storage_default_is_keychain() {
76        assert!(matches!(TokenStorage::default(), TokenStorage::Keychain));
77    }
78
79    #[test]
80    fn token_storage_serializes_lowercase() {
81        assert_eq!(
82            serde_json::to_string(&TokenStorage::Keychain).unwrap(),
83            "\"keychain\""
84        );
85        assert_eq!(
86            serde_json::to_string(&TokenStorage::File).unwrap(),
87            "\"file\""
88        );
89    }
90
91    #[test]
92    fn account_without_storage_field_deserializes_as_keychain() {
93        let yaml = r#"
94email: a@b.com
95tenant_id: tid
96home_account_id: hid
97added_at: "2026-05-13T22:00:00Z"
98"#;
99        let a: Account = serde_yaml::from_str(yaml).unwrap();
100        assert!(matches!(a.storage, TokenStorage::Keychain));
101    }
102
103    #[test]
104    fn personal_msa_tenant_is_recognised() {
105        assert!(make_account(Account::PERSONAL_MSA_TENANT).is_personal());
106    }
107
108    #[test]
109    fn org_tenant_is_not_personal() {
110        assert!(!make_account("11111111-2222-3333-4444-555555555555").is_personal());
111    }
112
113    #[test]
114    fn tenant_label_for_msa() {
115        assert_eq!(
116            make_account(Account::PERSONAL_MSA_TENANT).tenant_label(),
117            "personal MSA"
118        );
119    }
120
121    #[test]
122    fn tenant_label_for_org_truncates_to_8_chars() {
123        assert_eq!(
124            make_account("11111111-2222-3333-4444-555555555555").tenant_label(),
125            "11111111…"
126        );
127    }
128}