Skip to main content

mimick/
profile.rs

1//! Per-profile state isolation driven by the `MIMICK_PROFILE` env var.
2//!
3//! When unset (the default), every state directory uses the literal
4//! `mimick` segment under the user's XDG config / data / cache roots.
5//! When set to e.g. `dev`, the segment becomes `mimick-dev`, isolating
6//! config, the sharded SyncIndex, retries, and the thumbnail cache from
7//! the default profile.
8//!
9//! Names are restricted to `[A-Za-z][A-Za-z0-9_-]{0,31}` so they remain valid
10//! both as filesystem path segments (no traversal) and as D-Bus / GTK
11//! application-id segments (must start with a letter). Invalid values are
12//! logged and ignored.
13
14use std::path::PathBuf;
15use std::sync::OnceLock;
16
17/// Environment variable key driving profile isolation.
18const PROFILE_ENV_VAR: &str = "MIMICK_PROFILE";
19/// Maximum character length of a valid profile name segment.
20const MAX_PROFILE_LEN: usize = 32;
21
22/// Cached profile name parsed from environment.
23static PROFILE: OnceLock<Option<String>> = OnceLock::new();
24/// Cached directory path segment mapped from environment.
25static DIR_SEGMENT: OnceLock<String> = OnceLock::new();
26
27/// Active profile name, or `None` when the default profile is in use.
28pub fn name() -> Option<&'static str> {
29    profile().as_deref()
30}
31
32/// Directory segment to use inside `dirs::*_dir()` paths.
33///
34/// Returns `"mimick"` for the default profile and `"mimick-{name}"`
35/// for a named profile.
36pub fn dir_segment() -> &'static str {
37    DIR_SEGMENT
38        .get_or_init(|| match profile() {
39            Some(name) => format!("mimick-{}", name),
40            None => "mimick".to_string(),
41        })
42        .as_str()
43}
44
45/// Resolve user's local configuration directory parent based on profile.
46pub fn config_dir() -> Option<PathBuf> {
47    dirs::config_dir().map(|d| d.join(dir_segment()))
48}
49
50/// Resolve user's local data directory parent based on profile.
51pub fn data_dir() -> Option<PathBuf> {
52    dirs::data_dir().map(|d| d.join(dir_segment()))
53}
54
55/// Resolve user's local cache directory parent based on profile.
56pub fn cache_dir() -> Option<PathBuf> {
57    dirs::cache_dir().map(|d| d.join(dir_segment()))
58}
59
60/// GTK / D-Bus application id. Varies by profile so multiple profiles can
61/// run as independent GTK instances. Always remains a valid D-Bus name
62/// because `name()` is sanitised to start with a letter.
63pub fn application_id() -> String {
64    match name() {
65        Some(profile) => format!("dev.nicx.mimick.{}", profile),
66        None => "dev.nicx.mimick".to_string(),
67    }
68}
69
70/// Value for the keyring `account` attribute, scoping the API key per
71/// profile so switching profiles doesn't overwrite the default's secret.
72/// The default profile keeps the historical `"api_key"` value so existing
73/// installations continue to find their stored key.
74pub fn keyring_account() -> String {
75    match name() {
76        Some(profile) => format!("api_key-{}", profile),
77        None => "api_key".to_string(),
78    }
79}
80
81/// Helper to retrieve the current profile string, parsing and caching it once.
82fn profile() -> &'static Option<String> {
83    PROFILE.get_or_init(|| match std::env::var(PROFILE_ENV_VAR) {
84        Ok(raw) => sanitise(&raw),
85        Err(_) => None,
86    })
87}
88
89/// Validate that a profile name is safe for filesystem and D-Bus usage.
90fn sanitise(raw: &str) -> Option<String> {
91    let trimmed = raw.trim();
92    if trimmed.is_empty() {
93        return None;
94    }
95    if trimmed.len() > MAX_PROFILE_LEN {
96        log::warn!(
97            "Ignoring {}: name longer than {} characters",
98            PROFILE_ENV_VAR,
99            MAX_PROFILE_LEN
100        );
101        return None;
102    }
103    if !trimmed
104        .chars()
105        .next()
106        .is_some_and(|c| c.is_ascii_alphabetic())
107    {
108        log::warn!("Ignoring {}: must start with a letter", PROFILE_ENV_VAR);
109        return None;
110    }
111    if !trimmed
112        .chars()
113        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
114    {
115        log::warn!(
116            "Ignoring {}: only [A-Za-z0-9_-] are allowed",
117            PROFILE_ENV_VAR
118        );
119        return None;
120    }
121    Some(trimmed.to_string())
122}
123
124#[cfg(test)]
125mod tests {
126    use super::sanitise;
127
128    #[test]
129    fn sanitise_accepts_typical_names() {
130        assert_eq!(sanitise("dev"), Some("dev".to_string()));
131        assert_eq!(sanitise("staging-2"), Some("staging-2".to_string()));
132        assert_eq!(
133            sanitise("personal_local"),
134            Some("personal_local".to_string())
135        );
136    }
137
138    #[test]
139    fn sanitise_rejects_path_traversal() {
140        assert_eq!(sanitise("../etc"), None);
141        assert_eq!(sanitise("foo/bar"), None);
142        assert_eq!(sanitise(".."), None);
143    }
144
145    #[test]
146    fn sanitise_requires_letter_start_for_dbus_validity() {
147        assert_eq!(sanitise("2024test"), None);
148        assert_eq!(sanitise("-leading-dash"), None);
149        assert_eq!(sanitise("_underscore"), None);
150        assert_eq!(sanitise("a2024"), Some("a2024".to_string()));
151    }
152
153    #[test]
154    fn sanitise_rejects_empty_and_oversized() {
155        assert_eq!(sanitise(""), None);
156        assert_eq!(sanitise("   "), None);
157        assert_eq!(sanitise(&"a".repeat(33)), None);
158    }
159
160    #[test]
161    fn sanitise_trims_whitespace() {
162        assert_eq!(sanitise("  dev  "), Some("dev".to_string()));
163    }
164}