1use std::collections::BTreeMap;
21use std::env::VarError;
22use std::path::PathBuf;
23use std::sync::RwLock;
24
25pub const ALLOWED: &[&str] = &[
27 "GITHUB_TOKEN",
28 "GITHUB_API_URL",
29 "GITHUB_REPOSITORY",
30 "GITLAB_TOKEN",
31 "GITLAB_HOST",
32 "CI_PROJECT_PATH",
33 "JIRA_URL",
34 "JIRA_EMAIL",
35 "JIRA_TOKEN",
36 "JIRA_PROJECT",
37 "JIRA_DEPLOYMENT",
38 "DATABASE_URL",
39];
40
41const SOURCE_NAMES: &[(&str, &[&str])] = &[
43 (
44 "github",
45 &["GITHUB_TOKEN", "GITHUB_API_URL", "GITHUB_REPOSITORY"],
46 ),
47 (
48 "gitlab",
49 &["GITLAB_TOKEN", "GITLAB_HOST", "CI_PROJECT_PATH"],
50 ),
51 (
52 "jira",
53 &[
54 "JIRA_URL",
55 "JIRA_EMAIL",
56 "JIRA_TOKEN",
57 "JIRA_PROJECT",
58 "JIRA_DEPLOYMENT",
59 ],
60 ),
61 ("postgres", &["DATABASE_URL"]),
62];
63
64pub const SECRET_NAMES: &[&str] = &[
66 "GITHUB_TOKEN",
67 "GH_TOKEN",
68 "LEAN_CTX_GITHUB_TOKEN",
69 "GITLAB_TOKEN",
70 "LEAN_CTX_GITLAB_TOKEN",
71 "CI_JOB_TOKEN",
72 "JIRA_TOKEN",
73 "DATABASE_URL",
74];
75
76const MAX_VALUE: usize = 4096;
77const MAX_NAME: usize = 64;
78pub const MAX_INPUT_BYTES: usize = 64 * 1024;
80
81type Store = BTreeMap<String, String>;
82
83static CANDIDATES: RwLock<Option<Store>> = RwLock::new(None);
84
85pub fn names_for(provider: &str) -> Option<&'static [&'static str]> {
87 SOURCE_NAMES
88 .iter()
89 .find(|(id, _)| *id == provider)
90 .map(|(_, names)| *names)
91}
92
93pub fn validate_name(name: &str) -> Result<(), String> {
97 let shaped = !name.is_empty()
98 && name.len() <= MAX_NAME
99 && name.starts_with(|c: char| c.is_ascii_uppercase())
100 && name
101 .chars()
102 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
103 if !shaped {
104 return Err(
105 "a setting name uses only A-Z, 0-9 and _ (for example GITHUB_TOKEN)".to_string(),
106 );
107 }
108 if !ALLOWED.contains(&name) {
109 return Err(format!("{name} is not a source setting this store keeps"));
110 }
111 Ok(())
112}
113
114fn validate_pair(name: &str, value: &str) -> Result<(), String> {
115 validate_name(name)?;
116 if value.trim().is_empty() {
117 return Err(format!("{name} is empty"));
118 }
119 if value.len() > MAX_VALUE || value.contains(['\n', '\r', '\0']) {
120 return Err(format!(
121 "{name} must be one line of at most {MAX_VALUE} bytes"
122 ));
123 }
124 Ok(())
125}
126
127pub fn set_candidates(provider: &str, pairs: &[(String, String)]) -> Result<(), String> {
130 let names = names_for(provider).ok_or_else(|| format!("{provider} keeps no settings here"))?;
131 for (name, value) in pairs {
132 validate_pair(name, value)?;
133 if !names.contains(&name.as_str()) {
134 return Err(format!("{name} is not a setting of {provider}"));
135 }
136 }
137 let store = pairs
138 .iter()
139 .map(|(name, value)| (name.clone(), value.trim().to_string()))
140 .collect();
141 *CANDIDATES
142 .write()
143 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(store);
144 Ok(())
145}
146
147pub fn clear_candidates() {
149 *CANDIDATES
150 .write()
151 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
152}
153
154pub fn candidates_active() -> bool {
156 CANDIDATES
157 .read()
158 .unwrap_or_else(std::sync::PoisonError::into_inner)
159 .is_some()
160}
161
162fn path() -> Result<PathBuf, String> {
163 Ok(crate::core::paths::data_dir()?
164 .join("credentials")
165 .join("providers.json"))
166}
167
168pub fn load() -> Store {
169 let Ok(path) = path() else {
170 return Store::new();
171 };
172 let Ok(bytes) = std::fs::read(path) else {
173 return Store::new();
174 };
175 let store: Store = serde_json::from_slice(&bytes).unwrap_or_default();
176 store
177 .into_iter()
178 .filter(|(name, _)| ALLOWED.contains(&name.as_str()))
179 .collect()
180}
181
182fn save(store: &Store) -> Result<(), String> {
183 let path = path()?;
184 let parent = path
185 .parent()
186 .ok_or_else(|| "credential store has no parent folder".to_string())?;
187 std::fs::create_dir_all(parent)
188 .map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
189 let json = serde_json::to_vec_pretty(store).map_err(|e| format!("serialize error: {e}"))?;
190 let tmp = path.with_extension("json.tmp");
191 write_private(&tmp, &json)?;
192 std::fs::rename(&tmp, &path).map_err(|e| format!("cannot persist credentials: {e}"))
193}
194
195#[cfg(unix)]
196fn write_private(path: &PathBuf, bytes: &[u8]) -> Result<(), String> {
197 use std::io::Write;
198 use std::os::unix::fs::OpenOptionsExt;
199 let mut file = std::fs::OpenOptions::new()
200 .write(true)
201 .create(true)
202 .truncate(true)
203 .mode(0o600)
204 .open(path)
205 .map_err(|e| format!("cannot open {}: {e}", path.display()))?;
206 file.write_all(bytes)
207 .map_err(|e| format!("cannot write {}: {e}", path.display()))
208}
209
210#[cfg(not(unix))]
211fn write_private(path: &PathBuf, bytes: &[u8]) -> Result<(), String> {
212 std::fs::write(path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))
213}
214
215pub fn var(name: &str) -> Result<String, VarError> {
218 if let Some(candidates) = CANDIDATES
219 .read()
220 .unwrap_or_else(std::sync::PoisonError::into_inner)
221 .as_ref()
222 {
223 return candidates.get(name).cloned().ok_or(VarError::NotPresent);
224 }
225 match std::env::var(name) {
226 Ok(value) => Ok(value),
227 Err(missing) => load().remove(name).ok_or(missing),
228 }
229}
230
231pub fn is_set(name: &str) -> bool {
233 var(name).is_ok()
234}
235
236pub fn stored_names() -> Vec<String> {
238 load().into_keys().collect()
239}
240
241pub fn set(pairs: &[(String, String)]) -> Result<Vec<String>, String> {
244 for (name, value) in pairs {
245 validate_pair(name, value)?;
246 }
247 let mut store = load();
248 for (name, value) in pairs {
249 store.insert(name.clone(), value.trim().to_string());
250 }
251 save(&store)?;
252 Ok(pairs.iter().map(|(name, _)| name.clone()).collect())
253}
254
255pub fn remove(names: &[String]) -> Result<usize, String> {
257 for name in names {
258 validate_name(name)?;
259 }
260 let mut store = load();
261 let before = store.len();
262 store.retain(|name, _| !names.contains(name));
263 let removed = before - store.len();
264 if removed > 0 {
265 save(&store)?;
266 }
267 Ok(removed)
268}
269
270pub fn parse_pairs(input: &str) -> Result<Vec<(String, String)>, String> {
272 input
273 .lines()
274 .map(str::trim)
275 .filter(|line| !line.is_empty() && !line.starts_with('#'))
276 .map(|line| {
277 line.split_once('=')
278 .map(|(name, value)| (name.trim().to_string(), value.to_string()))
279 .ok_or_else(|| "each line must be NAME=value".to_string())
280 })
281 .collect()
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 fn with_store<T>(run: impl FnOnce() -> T) -> T {
289 let _lock = crate::core::data_dir::test_env_lock();
290 let data = tempfile::tempdir().unwrap();
291 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
292 crate::test_env::remove_var("JIRA_PROJECT");
293 crate::test_env::remove_var("GITLAB_HOST");
294 let result = run();
295 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
296 result
297 }
298
299 #[test]
300 fn stored_values_fill_the_environment_gap() {
301 with_store(|| {
302 assert!(var("JIRA_PROJECT").is_err());
303 set(&[("JIRA_PROJECT".into(), " LEAN".into())]).unwrap();
304 assert_eq!(var("JIRA_PROJECT").unwrap(), "LEAN");
305 crate::test_env::set_var("JIRA_PROJECT", "ENV");
306 assert_eq!(var("JIRA_PROJECT").unwrap(), "ENV");
307 crate::test_env::remove_var("JIRA_PROJECT");
308 assert_eq!(stored_names(), vec!["JIRA_PROJECT".to_string()]);
309 assert_eq!(remove(&["JIRA_PROJECT".into()]).unwrap(), 1);
310 assert!(var("JIRA_PROJECT").is_err());
311 set(&[("JIRA_PROJECT".into(), "STORED".into())]).unwrap();
314 crate::test_env::set_var("JIRA_TOKEN", "env-token");
315 set_candidates("jira", &[("JIRA_URL".into(), "https://c.example".into())]).unwrap();
316 let seen = (var("JIRA_URL"), var("JIRA_PROJECT"), var("JIRA_TOKEN"));
317 clear_candidates();
318 crate::test_env::remove_var("JIRA_TOKEN");
319 assert_eq!(seen.0.unwrap(), "https://c.example");
320 assert!(seen.1.is_err() && seen.2.is_err());
321 assert_eq!(var("JIRA_PROJECT").unwrap(), "STORED");
322 });
323 }
324
325 #[test]
326 fn only_known_single_line_values_are_stored() {
327 with_store(|| {
328 assert!(set(&[("PATH".into(), "/bin".into())]).is_err());
329 for bad in [
332 concat!("gh", "p_SECRETVALUE"),
333 "GITHUB TOKEN",
334 "../GITHUB_TOKEN",
335 "",
336 ] {
337 let error = set(&[(bad.into(), "x".into())]).unwrap_err();
338 assert!(bad.is_empty() || !error.contains(bad), "{error}");
339 assert!(remove(&[bad.to_string()]).is_err());
340 }
341 assert!(set_candidates("github", &[("JIRA_TOKEN".into(), "t".into())]).is_err());
343 assert!(!candidates_active());
344 assert!(set(&[("GITLAB_HOST".into(), "a\nb".into())]).is_err());
345 assert!(set(&[("GITLAB_HOST".into(), " ".into())]).is_err());
346 assert!(
348 set(&[
349 ("GITLAB_HOST".into(), "gitlab.example".into()),
350 ("HOME".into(), "/x".into())
351 ])
352 .is_err()
353 );
354 assert!(stored_names().is_empty());
355 });
356 }
357
358 #[cfg(unix)]
359 #[test]
360 fn the_store_is_private_to_the_user() {
361 use std::os::unix::fs::PermissionsExt;
362 with_store(|| {
363 set(&[("GITLAB_HOST".into(), "gitlab.example".into())]).unwrap();
364 let mode = std::fs::metadata(path().unwrap())
365 .unwrap()
366 .permissions()
367 .mode();
368 assert_eq!(mode & 0o777, 0o600);
369 });
370 }
371
372 #[test]
373 fn pairs_parse_from_lines() {
374 let pairs =
375 parse_pairs("# token\nGITLAB_HOST=git.example\n\nJIRA_URL=https://x=y\n").unwrap();
376 assert_eq!(
377 pairs,
378 vec![
379 ("GITLAB_HOST".to_string(), "git.example".to_string()),
380 ("JIRA_URL".to_string(), "https://x=y".to_string())
381 ]
382 );
383 assert!(parse_pairs("no equals").is_err());
384 }
385}