OXYGENHUB

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

430 of 480 lines run, 61 of 88 functions · run #336 on paths-for-resolutions-and-amend · 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.
372766×
pub fn hub_of(url: &str) -> Option<String> {
382766×
    let (scheme, rest) = url.split_once("://")?;
39502×
    let scheme = scheme.to_ascii_lowercase();
40502×
    if scheme != "http" && scheme != "https" {
41114×
        return None;
42388×
    }
43388×
    let host = rest.split(['/', '?', '#']).next()?;
44
    // Never key a hub on credentials embedded in the URL.
45388×
    let host = host.rsplit('@').next()?;
46388×
    if host.is_empty() {
470×
        return None;
48388×
    }
49388×
    Some(format!("{scheme}://{}", host.to_ascii_lowercase()))
502766×
}
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).
5415×
pub fn token_allowed(url: &str) -> bool {
5515×
    let Some(hub) = hub_of(url) else {
560×
        return true;
57
    };
5815×
    match hub.split_once("://") {
5915×
        Some(("https", _)) => true,
6014×
        Some((_, host)) => {
6114×
            let name = match host.strip_prefix('[') {
621×
                Some(v6) => v6.split(']').next().unwrap_or(""),
6313×
                None => host.rsplit_once(':').map_or(host, |(h, _)| h),
64
            };
6514×
            name == "localhost"
6613×
                || name == "::1"
6712×
                || name
6812×
                    .parse::<std::net::Ipv4Addr>()
6912×
                    .is_ok_and(|a| a.is_loopback())
70
        }
710×
        None => false,
72
    }
7315×
}
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
    /// An agent's grant token for keyless signing (oxygenhub#241), when it
87
    /// is in the file (not with a helper).
88
    pub grant: Option<String>,
89
}
90
91
/// Where a saved token went.
92
#[derive(Clone, Debug, PartialEq, Eq)]
93
pub enum Stored {
94
    /// The credentials file, owner-only.
95
    File(PathBuf),
96
    /// A credential helper, by its `auth.helper` value.
97
    Helper(String),
98
}
99
100
/// The credential store of one oxy home directory.
101
#[derive(Clone, Debug)]
102
pub struct Credentials {
103
    home: PathBuf,
104
}
105
106
impl Credentials {
107
    /// The store in `home` (an oxy home directory).
1081310×
    pub fn at(home: impl Into<PathBuf>) -> Credentials {
1091310×
        Credentials { home: home.into() }
1101310×
    }
111
112
    /// The store in the user's oxy home directory.
113249×
    pub fn user() -> Credentials {
114249×
        Credentials::at(crate::repo::default_home())
115249×
    }
116
117
    /// `credentials.toml` in the home directory.
118115×
    pub fn path(&self) -> PathBuf {
119115×
        self.home.join("credentials.toml")
120115×
    }
121
122
    /// The configured credential helper (`auth.helper` in the user file).
12353×
    pub fn helper(&self) -> Option<String> {
12453×
        crate::repo::read_config_file(&self.home.join("config.toml"))
12553×
            .ok()?
12653×
            .remove("auth.helper")
12753×
            .filter(|h| !h.trim().is_empty())
12853×
    }
129
130
    /// Every hub in the file, tokens as stored there (none with a helper).
1311×
    pub fn entries(&self) -> Result<Vec<HubEntry>, String> {
1321×
        Ok(self.read()?.into_values().collect())
1331×
    }
134
135
    /// Whether the file can be read by anyone but its owner, in which case
136
    /// lookups ignore it.
13736×
    pub fn too_open(&self) -> bool {
138
        #[cfg(unix)]
139
        {
140
            use std::os::unix::fs::PermissionsExt;
14136×
            if let Ok(m) = std::fs::metadata(self.path()) {
14212×
                return m.permissions().mode() & 0o077 != 0;
14324×
            }
144
        }
14524×
        false
14636×
    }
147
148
    /// The entry for `hub`, its token fetched from the helper if one is set.
14940×
    pub fn get(&self, hub: &str) -> Result<Option<HubEntry>, String> {
15040×
        let entry = self.read()?.remove(hub);
15140×
        let helper = self.helper();
15240×
        let mut entry = match (entry, &helper) {
15311×
            (Some(e), _) => e,
154
            // A helper may hold a token stored before the file was cleared.
1550×
            (None, Some(_)) => HubEntry {
1560×
                hub: hub.to_string(),
1570×
                ..HubEntry::default()
1580×
            },
15929×
            (None, None) => return Ok(None),
160
        };
16111×
        if entry.token.is_none() {
1624×
            if let Some(h) = &helper {
1632×
                entry.token = run_helper(h, "get", hub, None)?;
1642×
            }
1657×
        }
16611×
        if entry.token.is_none()
1672×
            && entry.account.is_none()
1682×
            && entry.ca.is_none()
1692×
            && entry.grant.is_none()
170
        {
1710×
            return Ok(None);
17211×
        }
17311×
        Ok(Some(entry))
17440×
    }
175
176
    /// Save a token (and what is known about it) for `hub`.
1777×
    pub fn save(
1787×
        &self,
1797×
        hub: &str,
1807×
        token: &str,
1817×
        account: Option<&str>,
1827×
        ca: Option<&Path>,
1837×
    ) -> Result<Stored, String> {
1847×
        let mut all = self.read()?;
1857×
        let helper = self.helper();
1867×
        let entry = all.entry(hub.to_string()).or_insert_with(|| HubEntry {
1877×
            hub: hub.to_string(),
1887×
            ..HubEntry::default()
1897×
        });
1907×
        entry.account = account.map(String::from).or(entry.account.take());
1917×
        if let Some(ca) = ca {
1921×
            entry.ca = Some(ca.to_path_buf());
1936×
        }
1947×
        let stored = match &helper {
1951×
            Some(h) => {
1961×
                run_helper(h, "store", hub, Some(token))?;
1971×
                entry.token = None;
1981×
                Stored::Helper(h.clone())
199
            }
200
            None => {
2016×
                entry.token = Some(token.to_string());
2026×
                Stored::File(self.path())
203
            }
204
        };
2057×
        self.write(&all)?;
2067×
        forget_cached();
2077×
        Ok(stored)
2087×
    }
209
210
    /// Save an agent's grant token for `hub` (keyless signing, spec §14.7):
211
    /// in the file, or the helper under its own user name.
2122×
    pub fn save_grant(&self, hub: &str, token: &str) -> Result<Stored, String> {
2132×
        let mut all = self.read()?;
2142×
        let entry = all.entry(hub.to_string()).or_insert_with(|| HubEntry {
2151×
            hub: hub.to_string(),
2161×
            ..HubEntry::default()
2171×
        });
2182×
        let stored = match self.helper() {
2190×
            Some(h) => {
2200×
                run_helper_as(&h, "store", hub, GRANT_USER, Some(token))?;
2210×
                entry.grant = None;
2220×
                Stored::Helper(h)
223
            }
224
            None => {
2252×
                entry.grant = Some(token.to_string());
2262×
                Stored::File(self.path())
227
            }
228
        };
2292×
        self.write(&all)?;
2302×
        Ok(stored)
2312×
    }
232
233
    /// The agent grant token kept for `hub`, if any.
2342×
    pub fn grant(&self, hub: &str) -> Result<Option<String>, String> {
2352×
        if let Some(g) = self.read()?.get(hub).and_then(|e| e.grant.clone()) {
2361×
            return Ok(Some(g));
2371×
        }
2381×
        match self.helper() {
2390×
            Some(h) => run_helper_as(&h, "get", hub, GRANT_USER, None),
2401×
            None => Ok(None),
241
        }
2422×
    }
243
244
    /// Remove `hub` from the file and the helper; returns what it held,
245
    /// with the token, so the caller can revoke it.
2462×
    pub fn remove(&self, hub: &str) -> Result<Option<HubEntry>, String> {
2472×
        let found = self.get(hub)?;
2482×
        let mut all = self.read()?;
2492×
        if all.remove(hub).is_some() {
2502×
            self.write(&all)?;
2510×
        }
2522×
        if let Some(h) = self.helper() {
2531×
            run_helper(&h, "erase", hub, None)?;
2541×
        }
2552×
        forget_cached();
2562×
        Ok(found)
2572×
    }
258
25954×
    fn read(&self) -> Result<BTreeMap<String, HubEntry>, String> {
26054×
        let path = self.path();
26154×
        let text = match std::fs::read_to_string(&path) {
26222×
            Ok(t) => t,
26332×
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
2640×
            Err(e) => return Err(format!("reading {}: {e}", path.display())),
265
        };
26622×
        let mut out = BTreeMap::new();
26722×
        let mut current: Option<HubEntry> = None;
268150×
        for (n, line) in text.lines().enumerate() {
269150×
            let line = line.trim();
270150×
            if line.is_empty() || line.starts_with('#') {
27154×
                continue;
27296×
            }
27396×
            if let Some(hub) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
27432×
                if let Some(e) = current.take() {
27510×
                    out.insert(e.hub.clone(), e);
27622×
                }
27732×
                current = Some(HubEntry {
27832×
                    hub: hub.trim().to_string(),
27932×
                    ..HubEntry::default()
28032×
                });
28132×
                continue;
28264×
            }
28364×
            let bad = || {
2840×
                format!(
285
                    "{} line {}: expected key = \"value\"",
2860×
                    path.display(),
2870×
                    n + 1
288
                )
2890×
            };
29064×
            let (k, v) = line.split_once('=').ok_or_else(bad)?;
29164×
            let v = unquote(v.trim()).ok_or_else(bad)?;
29264×
            let Some(e) = current.as_mut() else {
2930×
                return Err(format!(
2940×
                    "{} line {}: a key before any [hub] section",
2950×
                    path.display(),
2960×
                    n + 1
2970×
                ));
298
            };
29964×
            match k.trim() {
30064×
                "account" => e.account = Some(v),
30144×
                "token" => e.token = Some(v),
30218×
                "ca" => e.ca = Some(PathBuf::from(v)),
3039×
                "grant" => e.grant = Some(v),
304
                // Unknown keys are kept out of lookups, not fatal.
3050×
                _ => {}
306
            }
307
        }
30822×
        if let Some(e) = current {
30922×
            out.insert(e.hub.clone(), e);
31022×
        }
31122×
        Ok(out)
31254×
    }
313
31411×
    fn write(&self, all: &BTreeMap<String, HubEntry>) -> Result<(), String> {
31511×
        let mut text =
31611×
            String::from("# oxy credentials: a token for each hub. Keep this file private.\n");
31712×
        for e in all.values() {
31812×
            text.push_str(&format!("\n[{}]\n", e.hub));
31912×
            if let Some(a) = &e.account {
3208×
                text.push_str(&format!("account = {}\n", quote(a)));
3218×
            }
32212×
            if let Some(t) = &e.token {
32310×
                text.push_str(&format!("token = {}\n", quote(t)));
32410×
            }
32512×
            if let Some(c) = &e.ca {
3262×
                text.push_str(&format!("ca = {}\n", quote(&c.to_string_lossy())));
32710×
            }
32812×
            if let Some(g) = &e.grant {
3293×
                text.push_str(&format!("grant = {}\n", quote(g)));
3309×
            }
331
        }
33211×
        let path = self.path();
33311×
        std::fs::create_dir_all(&self.home)
33411×
            .map_err(|e| format!("creating {}: {e}", self.home.display()))?;
33511×
        crate::durable::write_atomic_private(&path, text.as_bytes())
33611×
            .map_err(|e| format!("writing {}: {e}", path.display()))
33711×
    }
338
339
    /// The stored token and CA for `url`'s hub, if any: memoized, so a
340
    /// helper runs once per hub in a process. A file anyone else can read
341
    /// is ignored, with a warning.
3421312×
    pub fn lookup(&self, url: &str) -> (Option<String>, Option<PathBuf>) {
3431312×
        let Some(hub) = hub_of(url) else {
3441139×
            return (None, None);
345
        };
346173×
        let key = (self.home.clone(), hub.clone());
347173×
        if let Some(hit) = cache().lock().unwrap().get(&key) {
348139×
            return hit.clone();
34934×
        }
35034×
        let found = if self.too_open() {
3511×
            warn_once(&format!(
3521×
                "warning: {} can be read by other users, so oxy ignores it; run chmod 600 on it",
3531×
                self.path().display()
3541×
            ));
3551×
            (None, None)
356
        } else {
35733×
            match self.get(&hub) {
358
                // An agent's grant token serves when the home holds no
359
                // other: the hub takes it for the owner's repositories
360
                // (keyless agents, oxygenhub#241).
3615×
                Ok(Some(e)) => (
3625×
                    e.token
3635×
                        .or(e.grant)
3645×
                        .or_else(|| self.grant(&hub).ok().flatten()),
3655×
                    e.ca,
366
                ),
36728×
                Ok(None) => (None, None),
3680×
                Err(e) => {
3690×
                    warn_once(&format!("warning: credentials for {hub}: {e}"));
3700×
                    (None, None)
371
                }
372
            }
373
        };
37434×
        cache().lock().unwrap().insert(key, found.clone());
37534×
        found
3761312×
    }
377
378
    /// Options for reaching `url` without a configured remote: `$OXY_TOKEN`
379
    /// and `$OXY_CA`, else what this store holds for its hub.
3801304×
    pub fn options_for(&self, url: &str) -> RemoteOptions {
3811304×
        let env_token = std::env::var("OXY_TOKEN").ok().filter(|t| !t.is_empty());
3821304×
        let env_ca = std::env::var("OXY_CA").ok().map(PathBuf::from);
3831304×
        let (token, ca) = match (&env_token, &env_ca) {
3840×
            (Some(_), Some(_)) => (None, None),
3851304×
            _ => self.lookup(url),
386
        };
3871304×
        RemoteOptions {
3881304×
            token: env_token.or(token),
3891304×
            ca: env_ca.or(ca),
3901304×
            ..RemoteOptions::default()
3911304×
        }
3921304×
    }
393
}
394
395
type Cache = Mutex<HashMap<(PathBuf, String), (Option<String>, Option<PathBuf>)>>;
396
397216×
fn cache() -> &'static Cache {
398
    static CACHE: OnceLock<Cache> = OnceLock::new();
399216×
    CACHE.get_or_init(Default::default)
400216×
}
401
4029×
fn forget_cached() {
4039×
    cache().lock().unwrap().clear();
4049×
}
405
4062×
pub(crate) fn warn_once(message: &str) {
407
    static SEEN: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
4082×
    if SEEN
4092×
        .get_or_init(Default::default)
4102×
        .lock()
4112×
        .unwrap()
4122×
        .insert(message.to_string())
4132×
    {
4142×
        eprintln!("{message}");
4152×
    }
4162×
}
417
41823×
fn quote(v: &str) -> String {
41923×
    let mut out = String::from('"');
420134×
    for c in v.chars() {
421134×
        match c {
4222×
            '"' => out.push_str("\\\""),
4230×
            '\\' => out.push_str("\\\\"),
4240×
            '\n' => out.push_str("\\n"),
425132×
            c => out.push(c),
426
        }
427
    }
42823×
    out.push('"');
42923×
    out
43023×
}
431
43264×
fn unquote(v: &str) -> Option<String> {
43364×
    let inner = v.strip_prefix('"')?.strip_suffix('"')?;
43464×
    let mut out = String::new();
43564×
    let mut chars = inner.chars();
436457×
    while let Some(c) = chars.next() {
437393×
        if c == '\\' {
4388×
            match chars.next()? {
4390×
                'n' => out.push('\n'),
4408×
                c => out.push(c),
441
            }
442385×
        } else {
443385×
            out.push(c);
444385×
        }
445
    }
44664×
    Some(out)
44764×
}
448
449
/// Run a credential helper (Git's protocol): `get`, `store` or `erase` for
450
/// `hub`. `get` returns the password the helper answered with.
451
///
452
/// `auth.helper` is a helper name (run as `git credential-NAME`), an
453
/// absolute path, or a shell command after `!`, as in Git.
4544×
fn run_helper(
4554×
    helper: &str,
4564×
    action: &str,
4574×
    hub: &str,
4584×
    token: Option<&str>,
4594×
) -> Result<Option<String>, String> {
4604×
    run_helper_as(helper, action, hub, HELPER_USER, token)
4614×
}
462
463
/// The helper's user name for an agent's grant token, beside the hub's
464
/// own token.
465
const GRANT_USER: &str = "oxy-agent-grant";
466
4674×
fn run_helper_as(
4684×
    helper: &str,
4694×
    action: &str,
4704×
    hub: &str,
4714×
    user: &str,
4724×
    token: Option<&str>,
4734×
) -> Result<Option<String>, String> {
4744×
    let (protocol, host) = hub
4754×
        .split_once("://")
4764×
        .ok_or_else(|| format!("{hub} is not a hub URL"))?;
4774×
    let mut cmd = if let Some(shell) = helper.strip_prefix('!') {
4784×
        let mut c = Command::new("sh");
4794×
        c.arg("-c").arg(format!("{shell} {action}"));
4804×
        c
4810×
    } else if Path::new(helper).is_absolute() {
4820×
        let mut c = Command::new(helper);
4830×
        c.arg(action);
4840×
        c
485
    } else {
4860×
        let mut c = Command::new("git");
4870×
        c.arg(format!("credential-{helper}")).arg(action);
4880×
        c
489
    };
4904×
    let mut input = format!("protocol={protocol}\nhost={host}\nusername={user}\n");
4914×
    if let Some(t) = token {
4921×
        input.push_str(&format!("password={t}\n"));
4933×
    }
4944×
    input.push('\n');
4954×
    let mut child = cmd
4964×
        .stdin(Stdio::piped())
4974×
        .stdout(Stdio::piped())
4984×
        .stderr(Stdio::inherit())
4994×
        .spawn()
5004×
        .map_err(|e| format!("running credential helper {helper:?}: {e}"))?;
501
    // A helper need not read what it is told (an `erase` may just remove
502
    // its file and exit); as Git does, a closed pipe is no error. Whether
503
    // the helper succeeded is its exit status.
5044×
    match child
5054×
        .stdin
5064×
        .take()
5074×
        .expect("piped")
5084×
        .write_all(input.as_bytes())
509
    {
5100×
        Err(e) if e.kind() != std::io::ErrorKind::BrokenPipe => {
5110×
            return Err(format!("credential helper {helper:?}: {e}"));
512
        }
5134×
        _ => {}
514
    }
5154×
    let out = child
5164×
        .wait_with_output()
5174×
        .map_err(|e| format!("credential helper {helper:?}: {e}"))?;
5184×
    if !out.status.success() {
5190×
        return Err(format!("credential helper {helper:?} {action} failed"));
5204×
    }
5214×
    if action != "get" {
5222×
        return Ok(None);
5232×
    }
5242×
    Ok(String::from_utf8_lossy(&out.stdout)
5252×
        .lines()
5262×
        .find_map(|l| l.strip_prefix("password="))
5272×
        .map(String::from)
5282×
        .filter(|p| !p.is_empty()))
5294×
}
530
531
#[cfg(test)]
532
mod tests {
533
    use super::*;
534
535
    /// oxygenhub#241: an agent's grant token is kept beside the hub's own
536
    /// token, owner-only, and read back without disturbing it.
537
    #[test]
5381×
    fn a_grant_token_is_kept_beside_the_hub_token() {
5391×
        let dir = tempfile::tempdir().unwrap();
5401×
        let store = Credentials::at(dir.path());
5411×
        let hub = "https://oxygenhub.io";
5421×
        assert_eq!(store.grant(hub).unwrap(), None);
5431×
        store.save(hub, "oxyhub_t", Some("andy"), None).unwrap();
5441×
        store.save_grant(hub, "oxyagent_g").unwrap();
5451×
        assert_eq!(store.grant(hub).unwrap().as_deref(), Some("oxyagent_g"));
5461×
        let e = store.get(hub).unwrap().unwrap();
5471×
        assert_eq!(e.token.as_deref(), Some("oxyhub_t"));
5481×
        assert_eq!(e.account.as_deref(), Some("andy"));
549
        // A grant alone is an entry too.
5501×
        store
5511×
            .save_grant("https://elsewhere.example", "oxyagent_h")
5521×
            .unwrap();
5531×
        assert!(store.get("https://elsewhere.example").unwrap().is_some());
554
        // The grant is the credential a remote there is reached with;
555
        // beside a hub token, the hub token is.
5561×
        assert_eq!(
5571×
            store.lookup("https://elsewhere.example/a/b").0.as_deref(),
558
            Some("oxyagent_h")
559
        );
5601×
        assert_eq!(
5611×
            store.lookup("https://oxygenhub.io/a/b").0.as_deref(),
562
            Some("oxyhub_t")
563
        );
564
        #[cfg(unix)]
565
        {
566
            use std::os::unix::fs::PermissionsExt;
5671×
            let mode = std::fs::metadata(store.path())
5681×
                .unwrap()
5691×
                .permissions()
5701×
                .mode();
5711×
            assert_eq!(mode & 0o077, 0, "owner only");
572
        }
5731×
    }
574
575
    #[test]
5761×
    fn hubs_are_scheme_host_and_port() {
5771×
        assert_eq!(
5781×
            hub_of("https://OxygenHub.io/doomsdayone/metalgear").as_deref(),
579
            Some("https://oxygenhub.io")
580
        );
5811×
        assert_eq!(
5821×
            hub_of("http://127.0.0.1:7300/a/b").as_deref(),
583
            Some("http://127.0.0.1:7300")
584
        );
5851×
        assert_eq!(
5861×
            hub_of("https://user:[email protected]/x").as_deref(),
587
            Some("https://hub.example")
588
        );
5891×
        assert_eq!(hub_of("/srv/repo"), None);
5901×
        assert_eq!(hub_of("file:///srv/repo"), None);
5911×
    }
592
593
    #[test]
5941×
    fn tokens_go_over_https_or_to_this_machine() {
5951×
        assert!(token_allowed("https://oxygenhub.io/a/b"));
5961×
        assert!(token_allowed("http://127.0.0.1:7300/a/b"));
5971×
        assert!(token_allowed("http://localhost/a"));
5981×
        assert!(token_allowed("http://[::1]:8080/a"));
5991×
        assert!(!token_allowed("http://oxygenhub.io/a/b"));
6001×
        assert!(!token_allowed("http://10.0.0.1/a"));
6011×
        assert!(!token_allowed("http://127.0.0.1.evil.example/a"));
6021×
    }
603
604
    #[test]
6051×
    fn save_get_and_remove_round_trip_privately() {
6061×
        let dir = tempfile::tempdir().unwrap();
6071×
        let store = Credentials::at(dir.path());
6081×
        assert_eq!(store.get("https://hub.example").unwrap(), None);
6091×
        let stored = store
6101×
            .save("https://hub.example", "tok\"en", Some("andy"), None)
6111×
            .unwrap();
6121×
        assert_eq!(stored, Stored::File(store.path()));
6131×
        store
6141×
            .save(
6151×
                "http://127.0.0.1:7300",
6161×
                "local",
6171×
                None,
6181×
                Some(Path::new("/ca.pem")),
619
            )
6201×
            .unwrap();
6211×
        let e = store.get("https://hub.example").unwrap().unwrap();
6221×
        assert_eq!(e.token.as_deref(), Some("tok\"en"));
6231×
        assert_eq!(e.account.as_deref(), Some("andy"));
6241×
        assert!(!store.too_open());
6251×
        assert_eq!(
6261×
            store.lookup("https://hub.example/o/r"),
6271×
            (Some("tok\"en".into()), None)
628
        );
629
        // Another host, scheme or port gets nothing.
6301×
        assert_eq!(store.lookup("https://other.example/o/r"), (None, None));
6311×
        assert_eq!(store.lookup("http://hub.example/o/r"), (None, None));
6321×
        assert_eq!(store.lookup("https://hub.example:8443/o/r"), (None, None));
6331×
        let gone = store.remove("https://hub.example").unwrap().unwrap();
6341×
        assert_eq!(gone.token.as_deref(), Some("tok\"en"));
6351×
        assert_eq!(store.lookup("https://hub.example/o/r"), (None, None));
6361×
        assert_eq!(store.entries().unwrap().len(), 1);
6371×
    }
638
639
    #[cfg(unix)]
640
    #[test]
6411×
    fn a_file_others_can_read_is_ignored() {
642
        use std::os::unix::fs::PermissionsExt;
6431×
        let dir = tempfile::tempdir().unwrap();
6441×
        let store = Credentials::at(dir.path());
6451×
        store.save("https://open.example", "t", None, None).unwrap();
6461×
        let mode = std::fs::metadata(store.path())
6471×
            .unwrap()
6481×
            .permissions()
6491×
            .mode();
6501×
        assert_eq!(mode & 0o777, 0o600);
6511×
        std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
6521×
        assert!(store.too_open());
6531×
        assert_eq!(store.lookup("https://open.example/o/r"), (None, None));
6541×
    }
655
656
    #[cfg(unix)]
657
    #[test]
6581×
    fn a_helper_keeps_the_token_out_of_the_file() {
6591×
        let dir = tempfile::tempdir().unwrap();
6601×
        let vault = dir.path().join("vault");
661
        // A helper that keeps one password in a file, as a keyring would.
6621×
        let helper = format!(
663
            "!f() {{ case $1 in get) [ -f {v} ] && echo password=$(cat {v});; store) sed -n 's/^password=//p' > {v};; erase) rm -f {v};; esac; }}; f",
6641×
            v = vault.display()
665
        );
6661×
        std::fs::write(
6671×
            dir.path().join("config.toml"),
6681×
            format!("auth.helper = \"{helper}\"\n"),
669
        )
6701×
        .unwrap();
6711×
        let store = Credentials::at(dir.path());
6721×
        assert!(store.helper().is_some());
6731×
        let stored = store
6741×
            .save("https://hub.example", "secret", Some("andy"), None)
6751×
            .unwrap();
6761×
        assert!(matches!(stored, Stored::Helper(_)));
6771×
        let text = std::fs::read_to_string(store.path()).unwrap();
6781×
        assert!(!text.contains("secret"), "{text}");
6791×
        assert_eq!(
6801×
            store
6811×
                .get("https://hub.example")
6821×
                .unwrap()
6831×
                .unwrap()
6841×
                .token
6851×
                .as_deref(),
686
            Some("secret")
687
        );
6881×
        store.remove("https://hub.example").unwrap();
6891×
        assert!(!vault.exists());
6901×
    }
691
}