Skip to main content

gitlab_tracker_redmine/
keyring.rs

1use zeroize::Zeroizing;
2
3/// Keyring service name for the Redmine token — distinct from the GitLab one.
4const KEYRING_SERVICE: &str = "gitlab-tracker-redmine";
5
6/// Derives a stable, per-instance keyring account name from the Redmine URL.
7///
8/// Using the URL as the account key enables multi-tenant setups: each Redmine
9/// instance stores its token independently so switching projects never clobbers
10/// another instance's credentials.
11///
12/// Example: `"https://redmine.example.com"` → `"redmine_token::https://redmine.example.com"`
13fn account_for(redmine_url: &str) -> String {
14    format!("redmine_token::{}", redmine_url.trim_end_matches('/'))
15}
16
17/// Retrieves the Redmine API token for a specific Redmine instance URL using
18/// the following priority chain:
19///
20/// 1. `REDMINE_TOKEN` environment variable (shared across all instances; useful for CI).
21/// 2. OS keyring entry keyed by `redmine_url` (per-instance, multi-tenant safe).
22/// 3. Interactive hidden prompt (`rpassword`), then persisted to the keyring.
23///
24/// Returns `None` when the user explicitly skips the prompt (empty input),
25/// which causes the Redmine feature to stay inactive for this session.
26/// The token is wrapped in [`Zeroizing`] to erase it from memory on drop.
27pub fn get_or_prompt_token(redmine_url: &str) -> Option<Zeroizing<String>> {
28    // 1. Environment variable — highest priority (CI / dotenv workflows).
29    if let Ok(tok) = std::env::var("REDMINE_TOKEN") {
30        let tok = Zeroizing::new(tok.trim().to_string());
31        if !tok.is_empty() {
32            tracing::info!("REDMINE_TOKEN loaded from environment variable");
33            return Some(tok);
34        }
35    }
36
37    let account = account_for(redmine_url);
38
39    // 2. OS keyring — keyed per Redmine instance URL.
40    match keyring::Entry::new(KEYRING_SERVICE, &account) {
41        Ok(entry) => match entry.get_password() {
42            Ok(pwd) => {
43                let pwd = Zeroizing::new(pwd.trim().to_string());
44                if !pwd.is_empty() {
45                    tracing::info!(url = %redmine_url, "REDMINE_TOKEN loaded from OS keyring");
46                    return Some(pwd);
47                }
48                tracing::debug!("Redmine keyring entry found but token is empty");
49            }
50            Err(e) => {
51                tracing::debug!(error = %e, "No Redmine token in OS keyring");
52            }
53        },
54        Err(e) => {
55            tracing::warn!(error = %e, "Failed to open Redmine keyring entry");
56        }
57    }
58
59    // 3. Interactive prompt — the user may leave it empty to skip.
60    println!("🔑 No REDMINE_TOKEN found for {redmine_url}.");
61    println!("   Leave empty to disable Redmine integration for this project.");
62    match rpassword::prompt_password("Redmine API token: ") {
63        Ok(raw) => {
64            let token = Zeroizing::new(raw.trim().to_string());
65            if token.is_empty() {
66                tracing::info!("Redmine integration disabled — no token provided");
67                return None;
68            }
69            // Persist to keyring keyed by this Redmine instance URL.
70            match keyring::Entry::new(KEYRING_SERVICE, &account) {
71                Ok(entry) => match entry.set_password(&token) {
72                    Ok(_) => {
73                        tracing::info!(url = %redmine_url, "Redmine token saved to OS keyring");
74                        println!("✅ Redmine token securely saved to OS Keyring!\n");
75                    }
76                    Err(e) => {
77                        tracing::error!(error = %e, "Failed to save Redmine token to OS keyring");
78                    }
79                },
80                Err(e) => {
81                    tracing::error!(error = %e, "Failed to open Redmine keyring entry for writing");
82                }
83            }
84            Some(token)
85        }
86        Err(e) => {
87            tracing::error!(error = %e, "Failed to read Redmine token from prompt");
88            None
89        }
90    }
91}
92
93/// Removes the stored Redmine token for a specific Redmine instance from the OS keyring.
94///
95/// Returns `true` if the entry was deleted successfully, `false` otherwise.
96pub fn delete_token(redmine_url: &str) -> bool {
97    keyring::Entry::new(KEYRING_SERVICE, &account_for(redmine_url))
98        .and_then(|e| e.delete_credential())
99        .is_ok()
100}