OXYGENHUB

Coverage / crates/oxy-core/src/credentials.rs 89.2%

365 of 409 lines run, 46 of 76 functions · run #282 on main · run missed

1
//! Per-user credentials for hubs: one sign-in serves every repository on a
2
//! hub, and nothing secret is written into a checkout.
3
//!
4
//! The store is `credentials.toml` in the user's oxy directory (see
5
//! [`crate::repo::default_home`]), one section per hub:
6
//!
7
//! ```text
8
//! [https://oxygenhub.io]
9
//! account = "andymccall"
10
//! token = "oxyhub_…"
11
//! ```
12
//!
13
//! A hub is a scheme, host and port: a token is only ever sent to the hub
14
//! it was stored for. The file is written readable by its owner only, and
15
//! is ignored with a warning when anyone else can read it. With
16
//! `auth.helper` set in the user's `config.toml`, tokens go to that helper
17
//! instead (Git's credential-helper protocol, so `libsecret`,
18
//! `osxkeychain` and `manager` keep them in the system keyring) and the
19
//! file keeps only what is not secret.
20
//!
21
//! A token is sent over `https`, or over plain `http` to this machine only
22
//! ([`token_allowed`]); hub transports drop it on any redirect.
23
24
use crate::protocol::RemoteOptions;
25
use std::collections::{BTreeMap, HashMap};
26
use std::io::Write;
27
use std::path::{Path, PathBuf};
28
use std::process::{Command, Stdio};
29
use std::sync::{Mutex, OnceLock};
30
31
/// The user name oxy's tokens are stored under in a credential helper, so
32
/// they stay apart from Git credentials for the same host.
33
const HELPER_USER: &str = "oxy-token";
34
35
/// The hub of a URL: `scheme://host[:port]`, lower case, for `http` and
36
/// `https` URLs only.
372247×
pub fn hub_of(url: &str) -> Option<String> {
382247×
    let (scheme, rest) = url.split_once("://")?;
39414×
    let scheme = scheme.to_ascii_lowercase();
40414×
    if scheme != "http" && scheme != "https" {
41102×
        return None;
42312×
    }
43312×
    let host = rest.split(['/', '?', '#']).next()?;
44
    // Never key a hub on credentials embedded in the URL.
45312×
    let host = host.rsplit('@').next()?;
46312×
    if host.is_empty() {
470×
        return None;
48312×
    }
49312×
    Some(format!("{scheme}://{}", host.to_ascii_lowercase()))
502247×
}
51
52
/// Whether a token may be sent to `url`: always over `https`, and over
53
/// plain `http` only to this machine (a hub under test, or a runner's own).
5411×
pub fn token_allowed(url: &str) -> bool {
5511×
    let Some(hub) = hub_of(url) else {
560×
        return true;
57
    };
5811×
    match hub.split_once("://") {
5911×
        Some(("https", _)) => true,
6010×
        Some((_, host)) => {
6110×
            let name = match host.strip_prefix('[') {
621×
                Some(v6) => v6.split(']').next().unwrap_or(""),
639×
                None => host.rsplit_once(':').map_or(host, |(h, _)| h),
64
            };
6510×
            name == "localhost"
669×
                || name == "::1"
678×
                || name
688×
                    .parse::<std::net::Ipv4Addr>()
698×
                    .is_ok_and(|a| a.is_loopback())
70
        }
710×
        None => false,
72
    }
7311×
}
74
75
/// What the store holds for one hub.
76
#[derive(Clone, Debug, Default, PartialEq, Eq)]
77
pub struct HubEntry {
78
    /// `scheme://host[:port]`.
79
    pub hub: String,
80
    /// The account the token signs in as, recorded at sign-in.
81
    pub account: Option<String>,
82
    /// The token, when it is in the file (not with a helper).
83
    pub token: Option<String>,
84
    /// Extra roots to trust for this hub (a self-signed hub).
85
    pub ca: Option<PathBuf>,
86
}
87
88
/// Where a saved token went.
89
#[derive(Clone, Debug, PartialEq, Eq)]
90
pub enum Stored {
91
    /// The credentials file, owner-only.
92
    File(PathBuf),
93
    /// A credential helper, by its `auth.helper` value.
94
    Helper(String),
95
}
96
97
/// The credential store of one oxy home directory.
98
#[derive(Clone, Debug)]
99
pub struct Credentials {
100
    home: PathBuf,
101
}
102
103
impl Credentials {
104
    /// The store in `home` (an oxy home directory).
1051232×
    pub fn at(home: impl Into<PathBuf>) -> Credentials {
1061232×
        Credentials { home: home.into() }
1071232×
    }
108
109
    /// The store in the user's oxy home directory.
110234×
    pub fn user() -> Credentials {
111234×
        Credentials::at(crate::repo::default_home())
112234×
    }
113
114
    /// `credentials.toml` in the home directory.
11586×
    pub fn path(&self) -> PathBuf {
11686×
        self.home.join("credentials.toml")
11786×
    }
118
119
    /// The configured credential helper (`auth.helper` in the user file).
12040×
    pub fn helper(&self) -> Option<String> {
12140×
        crate::repo::read_config_file(&self.home.join("config.toml"))
12240×
            .ok()?
12340×
            .remove("auth.helper")
12440×
            .filter(|h| !h.trim().is_empty())
12540×
    }
126
127
    /// Every hub in the file, tokens as stored there (none with a helper).
1281×
    pub fn entries(&self) -> Result<Vec<HubEntry>, String> {
1291×
        Ok(self.read()?.into_values().collect())
1301×
    }
131
132
    /// Whether the file can be read by anyone but its owner, in which case
133
    /// lookups ignore it.
13430×
    pub fn too_open(&self) -> bool {
135
        #[cfg(unix)]
136
        {
137
            use std::os::unix::fs::PermissionsExt;
13830×
            if let Ok(m) = std::fs::metadata(self.path()) {
1399×
                return m.permissions().mode() & 0o077 != 0;
14021×
            }
141
        }
14221×
        false
14330×
    }
144
145
    /// The entry for `hub`, its token fetched from the helper if one is set.
14632×
    pub fn get(&self, hub: &str) -> Result<Option<HubEntry>, String> {
14732×
        let entry = self.read()?.remove(hub);
14832×
        let helper = self.helper();
14932×
        let mut entry = match (entry, &helper) {
1506×
            (Some(e), _) => e,
151
            // A helper may hold a token stored before the file was cleared.
1520×
            (None, Some(_)) => HubEntry {
1530×
                hub: hub.to_string(),
1540×
                ..HubEntry::default()
1550×
            },
15626×
            (None, None) => return Ok(None),
157
        };
1586×
        if entry.token.is_none() {
1592×
            if let Some(h) = &helper {
1602×
                entry.token = run_helper(h, "get", hub, None)?;
1610×
            }
1624×
        }
1636×
        if entry.token.is_none() && entry.account.is_none() && entry.ca.is_none() {
1640×
            return Ok(None);
1656×
        }
1666×
        Ok(Some(entry))
16732×
    }
168
169
    /// Save a token (and what is known about it) for `hub`.
1705×
    pub fn save(
1715×
        &self,
1725×
        hub: &str,
1735×
        token: &str,
1745×
        account: Option<&str>,
1755×
        ca: Option<&Path>,
1765×
    ) -> Result<Stored, String> {
1775×
        let mut all = self.read()?;
1785×
        let helper = self.helper();
1795×
        let entry = all.entry(hub.to_string()).or_insert_with(|| HubEntry {
1805×
            hub: hub.to_string(),
1815×
            ..HubEntry::default()
1825×
        });
1835×
        entry.account = account.map(String::from).or(entry.account.take());
1845×
        if let Some(ca) = ca {
1851×
            entry.ca = Some(ca.to_path_buf());
1864×
        }
1875×
        let stored = match &helper {
1881×
            Some(h) => {
1891×
                run_helper(h, "store", hub, Some(token))?;
1901×
                entry.token = None;
1911×
                Stored::Helper(h.clone())
192
            }
193
            None => {
1944×
                entry.token = Some(token.to_string());
1954×
                Stored::File(self.path())
196
            }
197
        };
1985×
        self.write(&all)?;
1995×
        forget_cached();
2005×
        Ok(stored)
2015×
    }
202
203
    /// Remove `hub` from the file and the helper; returns what it held,
204
    /// with the token, so the caller can revoke it.
2052×
    pub fn remove(&self, hub: &str) -> Result<Option<HubEntry>, String> {
2062×
        let found = self.get(hub)?;
2072×
        let mut all = self.read()?;
2082×
        if all.remove(hub).is_some() {
2092×
            self.write(&all)?;
2100×
        }
2112×
        if let Some(h) = self.helper() {
2121×
            run_helper(&h, "erase", hub, None)?;
2131×
        }
2142×
        forget_cached();
2152×
        Ok(found)
2162×
    }
217
21840×
    fn read(&self) -> Result<BTreeMap<String, HubEntry>, String> {
21940×
        let path = self.path();
22040×
        let text = match std::fs::read_to_string(&path) {
22114×
            Ok(t) => t,
22226×
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
2230×
            Err(e) => return Err(format!("reading {}: {e}", path.display())),
224
        };
22514×
        let mut out = BTreeMap::new();
22614×
        let mut current: Option<HubEntry> = None;
22795×
        for (n, line) in text.lines().enumerate() {
22895×
            let line = line.trim();
22995×
            if line.is_empty() || line.starts_with('#') {
23035×
                continue;
23160×
            }
23260×
            if let Some(hub) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
23321×
                if let Some(e) = current.take() {
2347×
                    out.insert(e.hub.clone(), e);
23514×
                }
23621×
                current = Some(HubEntry {
23721×
                    hub: hub.trim().to_string(),
23821×
                    ..HubEntry::default()
23921×
                });
24021×
                continue;
24139×
            }
24239×
            let bad = || {
2430×
                format!(
244
                    "{} line {}: expected key = \"value\"",
2450×
                    path.display(),
2460×
                    n + 1
247
                )
2480×
            };
24939×
            let (k, v) = line.split_once('=').ok_or_else(bad)?;
25039×
            let v = unquote(v.trim()).ok_or_else(bad)?;
25139×
            let Some(e) = current.as_mut() else {
2520×
                return Err(format!(
2530×
                    "{} line {}: a key before any [hub] section",
2540×
                    path.display(),
2550×
                    n + 1
2560×
                ));
257
            };
25839×
            match k.trim() {
25939×
                "account" => e.account = Some(v),
26027×
                "token" => e.token = Some(v),
2619×
                "ca" => e.ca = Some(PathBuf::from(v)),
262
                // Unknown keys are kept out of lookups, not fatal.
2630×
                _ => {}
264
            }
265
        }
26614×
        if let Some(e) = current {
26714×
            out.insert(e.hub.clone(), e);
26814×
        }
26914×
        Ok(out)
27040×
    }
271
2727×
    fn write(&self, all: &BTreeMap<String, HubEntry>) -> Result<(), String> {
2737×
        let mut text =
2747×
            String::from("# oxy credentials: a token for each hub. Keep this file private.\n");
2757×
        for e in all.values() {
2767×
            text.push_str(&format!("\n[{}]\n", e.hub));
2777×
            if let Some(a) = &e.account {
2784×
                text.push_str(&format!("account = {}\n", quote(a)));
2794×
            }
2807×
            if let Some(t) = &e.token {
2816×
                text.push_str(&format!("token = {}\n", quote(t)));
2826×
            }
2837×
            if let Some(c) = &e.ca {
2842×
                text.push_str(&format!("ca = {}\n", quote(&c.to_string_lossy())));
2855×
            }
286
        }
2877×
        let path = self.path();
2887×
        std::fs::create_dir_all(&self.home)
2897×
            .map_err(|e| format!("creating {}: {e}", self.home.display()))?;
2907×
        crate::durable::write_atomic_private(&path, text.as_bytes())
2917×
            .map_err(|e| format!("writing {}: {e}", path.display()))
2927×
    }
293
294
    /// The stored token and CA for `url`'s hub, if any: memoized, so a
295
    /// helper runs once per hub in a process. A file anyone else can read
296
    /// is ignored, with a warning.
2971234×
    pub fn lookup(&self, url: &str) -> (Option<String>, Option<PathBuf>) {
2981234×
        let Some(hub) = hub_of(url) else {
2991072×
            return (None, None);
300
        };
301162×
        let key = (self.home.clone(), hub.clone());
302162×
        if let Some(hit) = cache().lock().unwrap().get(&key) {
303134×
            return hit.clone();
30428×
        }
30528×
        let found = if self.too_open() {
3061×
            warn_once(&format!(
3071×
                "warning: {} can be read by other users, so oxy ignores it; run chmod 600 on it",
3081×
                self.path().display()
3091×
            ));
3101×
            (None, None)
311
        } else {
31227×
            match self.get(&hub) {
3132×
                Ok(Some(e)) => (e.token, e.ca),
31425×
                Ok(None) => (None, None),
3150×
                Err(e) => {
3160×
                    warn_once(&format!("warning: credentials for {hub}: {e}"));
3170×
                    (None, None)
318
                }
319
            }
320
        };
32128×
        cache().lock().unwrap().insert(key, found.clone());
32228×
        found
3231234×
    }
324
325
    /// Options for reaching `url` without a configured remote: `$OXY_TOKEN`
326
    /// and `$OXY_CA`, else what this store holds for its hub.
3271228×
    pub fn options_for(&self, url: &str) -> RemoteOptions {
3281228×
        let env_token = std::env::var("OXY_TOKEN").ok().filter(|t| !t.is_empty());
3291228×
        let env_ca = std::env::var("OXY_CA").ok().map(PathBuf::from);
3301228×
        let (token, ca) = match (&env_token, &env_ca) {
3310×
            (Some(_), Some(_)) => (None, None),
3321228×
            _ => self.lookup(url),
333
        };
3341228×
        RemoteOptions {
3351228×
            token: env_token.or(token),
3361228×
            ca: env_ca.or(ca),
3371228×
            repo_id: None,
3381228×
            cache_dir: None,
3391228×
        }
3401228×
    }
341
}
342
343
type Cache = Mutex<HashMap<(PathBuf, String), (Option<String>, Option<PathBuf>)>>;
344
345197×
fn cache() -> &'static Cache {
346
    static CACHE: OnceLock<Cache> = OnceLock::new();
347197×
    CACHE.get_or_init(Default::default)
348197×
}
349
3507×
fn forget_cached() {
3517×
    cache().lock().unwrap().clear();
3527×
}
353
3541×
fn warn_once(message: &str) {
355
    static SEEN: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
3561×
    if SEEN
3571×
        .get_or_init(Default::default)
3581×
        .lock()
3591×
        .unwrap()
3601×
        .insert(message.to_string())
3611×
    {
3621×
        eprintln!("{message}");
3631×
    }
3641×
}
365
36612×
fn quote(v: &str) -> String {
36712×
    let mut out = String::from('"');
36859×
    for c in v.chars() {
36959×
        match c {
3702×
            '"' => out.push_str("\\\""),
3710×
            '\\' => out.push_str("\\\\"),
3720×
            '\n' => out.push_str("\\n"),
37357×
            c => out.push(c),
374
        }
375
    }
37612×
    out.push('"');
37712×
    out
37812×
}
379
38039×
fn unquote(v: &str) -> Option<String> {
38139×
    let inner = v.strip_prefix('"')?.strip_suffix('"')?;
38239×
    let mut out = String::new();
38339×
    let mut chars = inner.chars();
384249×
    while let Some(c) = chars.next() {
385210×
        if c == '\\' {
3868×
            match chars.next()? {
3870×
                'n' => out.push('\n'),
3888×
                c => out.push(c),
389
            }
390202×
        } else {
391202×
            out.push(c);
392202×
        }
393
    }
39439×
    Some(out)
39539×
}
396
397
/// Run a credential helper (Git's protocol): `get`, `store` or `erase` for
398
/// `hub`. `get` returns the password the helper answered with.
399
///
400
/// `auth.helper` is a helper name (run as `git credential-NAME`), an
401
/// absolute path, or a shell command after `!`, as in Git.
4024×
fn run_helper(
4034×
    helper: &str,
4044×
    action: &str,
4054×
    hub: &str,
4064×
    token: Option<&str>,
4074×
) -> Result<Option<String>, String> {
4084×
    let (protocol, host) = hub
4094×
        .split_once("://")
4104×
        .ok_or_else(|| format!("{hub} is not a hub URL"))?;
4114×
    let mut cmd = if let Some(shell) = helper.strip_prefix('!') {
4124×
        let mut c = Command::new("sh");
4134×
        c.arg("-c").arg(format!("{shell} {action}"));
4144×
        c
4150×
    } else if Path::new(helper).is_absolute() {
4160×
        let mut c = Command::new(helper);
4170×
        c.arg(action);
4180×
        c
419
    } else {
4200×
        let mut c = Command::new("git");
4210×
        c.arg(format!("credential-{helper}")).arg(action);
4220×
        c
423
    };
4244×
    let mut input = format!("protocol={protocol}\nhost={host}\nusername={HELPER_USER}\n");
4254×
    if let Some(t) = token {
4261×
        input.push_str(&format!("password={t}\n"));
4273×
    }
4284×
    input.push('\n');
4294×
    let mut child = cmd
4304×
        .stdin(Stdio::piped())
4314×
        .stdout(Stdio::piped())
4324×
        .stderr(Stdio::inherit())
4334×
        .spawn()
4344×
        .map_err(|e| format!("running credential helper {helper:?}: {e}"))?;
4354×
    child
4364×
        .stdin
4374×
        .take()
4384×
        .expect("piped")
4394×
        .write_all(input.as_bytes())
4404×
        .map_err(|e| format!("credential helper {helper:?}: {e}"))?;
4414×
    let out = child
4424×
        .wait_with_output()
4434×
        .map_err(|e| format!("credential helper {helper:?}: {e}"))?;
4444×
    if !out.status.success() {
4450×
        return Err(format!("credential helper {helper:?} {action} failed"));
4464×
    }
4474×
    if action != "get" {
4482×
        return Ok(None);
4492×
    }
4502×
    Ok(String::from_utf8_lossy(&out.stdout)
4512×
        .lines()
4522×
        .find_map(|l| l.strip_prefix("password="))
4532×
        .map(String::from)
4542×
        .filter(|p| !p.is_empty()))
4554×
}
456
457
#[cfg(test)]
458
mod tests {
459
    use super::*;
460
461
    #[test]
4621×
    fn hubs_are_scheme_host_and_port() {
4631×
        assert_eq!(
4641×
            hub_of("https://OxygenHub.io/doomsdayone/metalgear").as_deref(),
465
            Some("https://oxygenhub.io")
466
        );
4671×
        assert_eq!(
4681×
            hub_of("http://127.0.0.1:7300/a/b").as_deref(),
469
            Some("http://127.0.0.1:7300")
470
        );
4711×
        assert_eq!(
4721×
            hub_of("https://user:[email protected]/x").as_deref(),
473
            Some("https://hub.example")
474
        );
4751×
        assert_eq!(hub_of("/srv/repo"), None);
4761×
        assert_eq!(hub_of("file:///srv/repo"), None);
4771×
    }
478
479
    #[test]
4801×
    fn tokens_go_over_https_or_to_this_machine() {
4811×
        assert!(token_allowed("https://oxygenhub.io/a/b"));
4821×
        assert!(token_allowed("http://127.0.0.1:7300/a/b"));
4831×
        assert!(token_allowed("http://localhost/a"));
4841×
        assert!(token_allowed("http://[::1]:8080/a"));
4851×
        assert!(!token_allowed("http://oxygenhub.io/a/b"));
4861×
        assert!(!token_allowed("http://10.0.0.1/a"));
4871×
        assert!(!token_allowed("http://127.0.0.1.evil.example/a"));
4881×
    }
489
490
    #[test]
4911×
    fn save_get_and_remove_round_trip_privately() {
4921×
        let dir = tempfile::tempdir().unwrap();
4931×
        let store = Credentials::at(dir.path());
4941×
        assert_eq!(store.get("https://hub.example").unwrap(), None);
4951×
        let stored = store
4961×
            .save("https://hub.example", "tok\"en", Some("andy"), None)
4971×
            .unwrap();
4981×
        assert_eq!(stored, Stored::File(store.path()));
4991×
        store
5001×
            .save(
5011×
                "http://127.0.0.1:7300",
5021×
                "local",
5031×
                None,
5041×
                Some(Path::new("/ca.pem")),
505
            )
5061×
            .unwrap();
5071×
        let e = store.get("https://hub.example").unwrap().unwrap();
5081×
        assert_eq!(e.token.as_deref(), Some("tok\"en"));
5091×
        assert_eq!(e.account.as_deref(), Some("andy"));
5101×
        assert!(!store.too_open());
5111×
        assert_eq!(
5121×
            store.lookup("https://hub.example/o/r"),
5131×
            (Some("tok\"en".into()), None)
514
        );
515
        // Another host, scheme or port gets nothing.
5161×
        assert_eq!(store.lookup("https://other.example/o/r"), (None, None));
5171×
        assert_eq!(store.lookup("http://hub.example/o/r"), (None, None));
5181×
        assert_eq!(store.lookup("https://hub.example:8443/o/r"), (None, None));
5191×
        let gone = store.remove("https://hub.example").unwrap().unwrap();
5201×
        assert_eq!(gone.token.as_deref(), Some("tok\"en"));
5211×
        assert_eq!(store.lookup("https://hub.example/o/r"), (None, None));
5221×
        assert_eq!(store.entries().unwrap().len(), 1);
5231×
    }
524
525
    #[cfg(unix)]
526
    #[test]
5271×
    fn a_file_others_can_read_is_ignored() {
528
        use std::os::unix::fs::PermissionsExt;
5291×
        let dir = tempfile::tempdir().unwrap();
5301×
        let store = Credentials::at(dir.path());
5311×
        store.save("https://open.example", "t", None, None).unwrap();
5321×
        let mode = std::fs::metadata(store.path())
5331×
            .unwrap()
5341×
            .permissions()
5351×
            .mode();
5361×
        assert_eq!(mode & 0o777, 0o600);
5371×
        std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
5381×
        assert!(store.too_open());
5391×
        assert_eq!(store.lookup("https://open.example/o/r"), (None, None));
5401×
    }
541
542
    #[cfg(unix)]
543
    #[test]
5441×
    fn a_helper_keeps_the_token_out_of_the_file() {
5451×
        let dir = tempfile::tempdir().unwrap();
5461×
        let vault = dir.path().join("vault");
547
        // A helper that keeps one password in a file, as a keyring would.
5481×
        let helper = format!(
549
            "!f() {{ case $1 in get) [ -f {v} ] && echo password=$(cat {v});; store) sed -n 's/^password=//p' > {v};; erase) rm -f {v};; esac; }}; f",
5501×
            v = vault.display()
551
        );
5521×
        std::fs::write(
5531×
            dir.path().join("config.toml"),
5541×
            format!("auth.helper = \"{helper}\"\n"),
555
        )
5561×
        .unwrap();
5571×
        let store = Credentials::at(dir.path());
5581×
        assert!(store.helper().is_some());
5591×
        let stored = store
5601×
            .save("https://hub.example", "secret", Some("andy"), None)
5611×
            .unwrap();
5621×
        assert!(matches!(stored, Stored::Helper(_)));
5631×
        let text = std::fs::read_to_string(store.path()).unwrap();
5641×
        assert!(!text.contains("secret"), "{text}");
5651×
        assert_eq!(
5661×
            store
5671×
                .get("https://hub.example")
5681×
                .unwrap()
5691×
                .unwrap()
5701×
                .token
5711×
                .as_deref(),
572
            Some("secret")
573
        );
5741×
        store.remove("https://hub.example").unwrap();
5751×
        assert!(!vault.exists());
5761×
    }
577
}