1use std::path::PathBuf;
15use std::sync::OnceLock;
16
17const PROFILE_ENV_VAR: &str = "MIMICK_PROFILE";
19const MAX_PROFILE_LEN: usize = 32;
21
22static PROFILE: OnceLock<Option<String>> = OnceLock::new();
24static DIR_SEGMENT: OnceLock<String> = OnceLock::new();
26
27pub fn name() -> Option<&'static str> {
29 profile().as_deref()
30}
31
32pub 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
45pub fn config_dir() -> Option<PathBuf> {
47 dirs::config_dir().map(|d| d.join(dir_segment()))
48}
49
50pub fn data_dir() -> Option<PathBuf> {
52 dirs::data_dir().map(|d| d.join(dir_segment()))
53}
54
55pub fn cache_dir() -> Option<PathBuf> {
57 dirs::cache_dir().map(|d| d.join(dir_segment()))
58}
59
60pub 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
70pub fn keyring_account() -> String {
75 match name() {
76 Some(profile) => format!("api_key-{}", profile),
77 None => "api_key".to_string(),
78 }
79}
80
81fn 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
89fn 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}