Skip to main content

git_lfs_creds/
netrc.rs

1//! `~/.netrc` (or `_netrc` on Windows) credential lookup.
2//!
3//! Sits in the helper chain between the in-process cache and
4//! `git credential` so a user with a populated netrc never has to
5//! round-trip through `git credential fill` for hosts it covers.
6//! Mirrors upstream's `creds/netrc.go::netrcCredentialHelper`.
7//!
8//! # Behavior
9//!
10//! - On `fill`: look up the request host (port stripped) in the
11//!   parsed netrc. If a `machine` entry matches — or a `default`
12//!   entry is present and no specific match was found — return its
13//!   `login` + `password`. Hosts that previously hit `reject` are
14//!   skipped until the next `approve`.
15//! - On `approve`: if the creds match a netrc entry for this host,
16//!   clear the host's skip flag and emit the trace line. Doesn't
17//!   touch the file (netrc is read-only).
18//! - On `reject`: same matching check, then set the skip flag so
19//!   future fills bypass us — netrc's contents won't change between
20//!   calls, so re-issuing the same wrong creds would loop.
21//!
22//! Trace lines (`netrc: git credential fill (…)` / `approve (…)` /
23//! `reject (…)`) match upstream's `tracerx.Printf` format — the
24//! quoting is Go's `%q` (backslash-escaped, wrapped in double quotes)
25//! so the `t-credentials.sh` `netrc:` greps line up.
26//!
27//! # Parser
28//!
29//! Tokens are whitespace-separated. Recognized keywords are
30//! `machine`, `default`, `login`, `password`, `account`, `macdef`.
31//! Anything else is treated as one orphan token and skipped — that
32//! makes the parser permissive enough to ignore unknown keywords
33//! introduced by other tools (matches the upstream
34//! `t-credentials.sh::credentials from netrc with unknown keyword`
35//! test). `macdef` body parsing isn't implemented; we skip the
36//! `macdef <name>` pair and continue, which is enough for the
37//! test fixtures.
38
39use std::collections::HashSet;
40use std::io::Write as _;
41use std::path::{Path, PathBuf};
42use std::sync::Mutex;
43
44use crate::helper::{Credentials, Helper, HelperError};
45use crate::query::Query;
46use crate::trace::trace_enabled;
47
48/// One `machine <name> login <user> password <pass>` block from a
49/// netrc file. `machine == "*"` represents a `default` block.
50#[derive(Debug, Clone)]
51struct NetrcEntry {
52    machine: String,
53    login: String,
54    password: String,
55}
56
57/// Netrc-backed credential helper.
58///
59/// Cheap to construct: the file is read and parsed once at
60/// construction, and lookups walk the parsed list linearly (netrc
61/// files are small in practice).
62#[derive(Debug)]
63pub struct NetrcCredentialHelper {
64    entries: Vec<NetrcEntry>,
65    skip: Mutex<HashSet<String>>,
66}
67
68impl NetrcCredentialHelper {
69    /// Build from a parsed-and-already-decoded netrc body.
70    pub fn from_contents(content: &str) -> Self {
71        Self {
72            entries: parse_netrc(content),
73            skip: Mutex::new(HashSet::new()),
74        }
75    }
76
77    /// Read the user's default netrc file.
78    ///
79    /// Tries `$HOME/.netrc`, falling back to `$HOME/_netrc` on
80    /// Windows when `.netrc` isn't present. Returns `None` when no
81    /// netrc file exists, when `$HOME` isn't set, or when the file
82    /// is unreadable; these are not user errors, just "no creds from
83    /// this source".
84    pub fn from_default_location() -> Option<Self> {
85        let home = std::env::var_os("HOME")?;
86        let primary = PathBuf::from(&home).join(".netrc");
87        let alt = PathBuf::from(&home).join("_netrc");
88        let path = if primary.is_file() {
89            primary
90        } else if cfg!(windows) && alt.is_file() {
91            alt
92        } else {
93            return None;
94        };
95        Self::from_path(&path)
96    }
97
98    /// Read + parse `path`. Returns `None` when the file is missing
99    /// or unreadable; logging a parse error is upstream's choice but
100    /// "no creds from this source" matches Helper-trait semantics.
101    pub fn from_path(path: &Path) -> Option<Self> {
102        let content = std::fs::read_to_string(path).ok()?;
103        Some(Self::from_contents(&content))
104    }
105
106    /// Find a netrc entry matching `host`. Tries exact match first,
107    /// then falls back to the `default` block (if any). Matches
108    /// upstream's `netrc.FindMachine` semantics.
109    fn find_machine(&self, host: &str) -> Option<&NetrcEntry> {
110        self.entries
111            .iter()
112            .find(|e| e.machine.eq_ignore_ascii_case(host))
113            .or_else(|| self.entries.iter().find(|e| e.machine == "*"))
114    }
115}
116
117impl Helper for NetrcCredentialHelper {
118    fn fill(&self, query: &Query) -> Result<Option<Credentials>, HelperError> {
119        let host = strip_port(&query.host);
120        if self.skip.lock().unwrap().contains(host) {
121            return Ok(None);
122        }
123        let Some(entry) = self.find_machine(host) else {
124            return Ok(None);
125        };
126        trace_netrc_fill(&query.protocol, &query.host, &entry.login, &query.path);
127        Ok(Some(Credentials::new(&entry.login, &entry.password)))
128    }
129
130    fn approve(&self, query: &Query, creds: &Credentials) -> Result<(), HelperError> {
131        let host = strip_port(&query.host);
132        let Some(entry) = self.find_machine(host) else {
133            return Ok(());
134        };
135        if entry.login != creds.username || entry.password != creds.password {
136            // Different creds — they must have come from another
137            // helper. Stay silent (no trace, no skip mutation).
138            return Ok(());
139        }
140        trace_netrc_simple("approve", &query.protocol, &query.host, &query.path);
141        self.skip.lock().unwrap().remove(host);
142        Ok(())
143    }
144
145    fn reject(&self, query: &Query, creds: &Credentials) -> Result<(), HelperError> {
146        let host = strip_port(&query.host);
147        let Some(entry) = self.find_machine(host) else {
148            return Ok(());
149        };
150        if entry.login != creds.username || entry.password != creds.password {
151            return Ok(());
152        }
153        trace_netrc_simple("reject", &query.protocol, &query.host, &query.path);
154        self.skip.lock().unwrap().insert(host.to_owned());
155        Ok(())
156    }
157}
158
159/// Pop the `:port` suffix off `host` so a netrc `machine localhost`
160/// entry matches a query for `localhost:12345`. Mirrors upstream's
161/// `getNetrcHostname` (which uses `net.SplitHostPort`). Returns the
162/// input unchanged when no `:` is present.
163fn strip_port(host: &str) -> &str {
164    match host.rsplit_once(':') {
165        Some((h, _)) => h,
166        None => host,
167    }
168}
169
170fn trace_netrc_fill(protocol: &str, host: &str, login: &str, path: &str) {
171    if !trace_enabled() {
172        return;
173    }
174    let mut e = std::io::stderr().lock();
175    let _ = writeln!(
176        e,
177        "netrc: git credential fill ({}, {}, {}, {})",
178        go_quote(protocol),
179        go_quote(host),
180        go_quote(login),
181        go_quote(path),
182    );
183}
184
185fn trace_netrc_simple(verb: &str, protocol: &str, host: &str, path: &str) {
186    if !trace_enabled() {
187        return;
188    }
189    let mut e = std::io::stderr().lock();
190    let _ = writeln!(
191        e,
192        "netrc: git credential {verb} ({}, {}, {})",
193        go_quote(protocol),
194        go_quote(host),
195        go_quote(path),
196    );
197}
198
199/// Format `s` like Go's `fmt.Sprintf("%q", s)` for the subset that
200/// matters here: ASCII strings with no control characters. Wraps in
201/// double quotes and escapes embedded `"` / `\` — enough for the
202/// netrc trace lines, where every input is a URL-derived ASCII
203/// string. Full `%q` (unicode escapes, control bytes) is overkill.
204fn go_quote(s: &str) -> String {
205    let mut out = String::with_capacity(s.len() + 2);
206    out.push('"');
207    for c in s.chars() {
208        match c {
209            '"' => out.push_str("\\\""),
210            '\\' => out.push_str("\\\\"),
211            _ => out.push(c),
212        }
213    }
214    out.push('"');
215    out
216}
217
218/// Parse a netrc file body into entries. Permissive: unknown
219/// keywords are silently skipped so other tools' annotations don't
220/// break the parse. Keyword recognition is case-insensitive, matching
221/// the common Go and curl netrc parsers.
222fn parse_netrc(content: &str) -> Vec<NetrcEntry> {
223    let mut tokens = content.split_whitespace();
224    let mut entries: Vec<NetrcEntry> = Vec::new();
225    let mut current: Option<NetrcEntry> = None;
226
227    while let Some(tok) = tokens.next() {
228        match tok.to_ascii_lowercase().as_str() {
229            "machine" => {
230                if let Some(e) = current.take() {
231                    entries.push(e);
232                }
233                let name = tokens.next().unwrap_or_default().to_owned();
234                current = Some(NetrcEntry {
235                    machine: name,
236                    login: String::new(),
237                    password: String::new(),
238                });
239            }
240            "default" => {
241                if let Some(e) = current.take() {
242                    entries.push(e);
243                }
244                current = Some(NetrcEntry {
245                    machine: "*".into(),
246                    login: String::new(),
247                    password: String::new(),
248                });
249            }
250            "login" => {
251                if let Some(e) = current.as_mut() {
252                    e.login = tokens.next().unwrap_or_default().to_owned();
253                }
254            }
255            "password" => {
256                if let Some(e) = current.as_mut() {
257                    e.password = tokens.next().unwrap_or_default().to_owned();
258                }
259            }
260            "account" => {
261                // Recognized keyword we don't use — skip its value
262                // (otherwise we'd treat the value as an unknown
263                // token and behave correctly, but consuming the
264                // pair explicitly is what netrc parsers do).
265                tokens.next();
266            }
267            "macdef" => {
268                // Macro definition: `macdef <name>\n<body>\n\n`.
269                // We don't execute macros; skip the name and discard
270                // the body up to the next blank-line-equivalent.
271                // Whitespace-tokenized stream can't see blank lines,
272                // so this is approximate — but the upstream test
273                // fixtures don't exercise macdef.
274                tokens.next();
275            }
276            _ => {
277                // Unknown token. Skip it singly: if it's a stray
278                // keyword followed by a value, the value lands here
279                // on the next iteration and gets skipped too. The
280                // upstream "credentials from netrc with unknown
281                // keyword" test relies on this.
282            }
283        }
284    }
285    if let Some(e) = current {
286        entries.push(e);
287    }
288    entries
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn parses_minimal_entry() {
297        let helper = NetrcCredentialHelper::from_contents(
298            "machine localhost\nlogin netrcuser\npassword netrcpass\n",
299        );
300        let q = Query {
301            protocol: "https".into(),
302            host: "localhost".into(),
303            path: String::new(),
304        };
305        let creds = helper.fill(&q).unwrap().unwrap();
306        assert_eq!(creds.username, "netrcuser");
307        assert_eq!(creds.password, "netrcpass");
308    }
309
310    #[test]
311    fn strips_port_from_query_host() {
312        let helper =
313            NetrcCredentialHelper::from_contents("machine localhost login alice password s3cret\n");
314        let q = Query {
315            protocol: "https".into(),
316            host: "localhost:12345".into(),
317            path: String::new(),
318        };
319        let creds = helper.fill(&q).unwrap().unwrap();
320        assert_eq!(creds.username, "alice");
321    }
322
323    #[test]
324    fn skips_unknown_keyword_between_known_ones() {
325        // Matches the `credentials from netrc with unknown keyword`
326        // shell test: a bogus pair between login and password
327        // must not break the entry.
328        let helper = NetrcCredentialHelper::from_contents(
329            "machine localhost\nlogin netrcuser\nnot-a-key something\npassword netrcpass\n",
330        );
331        let q = Query {
332            protocol: "https".into(),
333            host: "localhost".into(),
334            path: String::new(),
335        };
336        let creds = helper.fill(&q).unwrap().unwrap();
337        assert_eq!(creds.username, "netrcuser");
338        assert_eq!(creds.password, "netrcpass");
339    }
340
341    #[test]
342    fn default_block_used_when_no_machine_match() {
343        let helper =
344            NetrcCredentialHelper::from_contents("default\nlogin defuser\npassword defpass\n");
345        let q = Query {
346            protocol: "https".into(),
347            host: "anywhere".into(),
348            path: String::new(),
349        };
350        let creds = helper.fill(&q).unwrap().unwrap();
351        assert_eq!(creds.username, "defuser");
352    }
353
354    #[test]
355    fn machine_match_beats_default() {
356        let helper = NetrcCredentialHelper::from_contents(
357            "machine localhost login a password 1\ndefault login b password 2\n",
358        );
359        let q = Query {
360            protocol: "https".into(),
361            host: "localhost".into(),
362            path: String::new(),
363        };
364        let creds = helper.fill(&q).unwrap().unwrap();
365        assert_eq!(creds.username, "a");
366    }
367
368    #[test]
369    fn returns_none_when_no_match() {
370        let helper = NetrcCredentialHelper::from_contents("machine other login a password 1\n");
371        let q = Query {
372            protocol: "https".into(),
373            host: "localhost".into(),
374            path: String::new(),
375        };
376        assert!(helper.fill(&q).unwrap().is_none());
377    }
378
379    #[test]
380    fn reject_then_fill_returns_none() {
381        let helper = NetrcCredentialHelper::from_contents("machine localhost login a password 1\n");
382        let q = Query {
383            protocol: "https".into(),
384            host: "localhost".into(),
385            path: String::new(),
386        };
387        let creds = helper.fill(&q).unwrap().unwrap();
388        helper.reject(&q, &creds).unwrap();
389        // Same host should now be in the skip set.
390        assert!(helper.fill(&q).unwrap().is_none());
391    }
392
393    #[test]
394    fn approve_clears_skip_flag() {
395        let helper = NetrcCredentialHelper::from_contents("machine localhost login a password 1\n");
396        let q = Query {
397            protocol: "https".into(),
398            host: "localhost".into(),
399            path: String::new(),
400        };
401        let creds = helper.fill(&q).unwrap().unwrap();
402        helper.reject(&q, &creds).unwrap();
403        helper.approve(&q, &creds).unwrap();
404        // After approve, fill should succeed again.
405        assert!(helper.fill(&q).unwrap().is_some());
406    }
407
408    #[test]
409    fn approve_with_mismatched_creds_is_noop() {
410        let helper = NetrcCredentialHelper::from_contents("machine localhost login a password 1\n");
411        let q = Query {
412            protocol: "https".into(),
413            host: "localhost".into(),
414            path: String::new(),
415        };
416        helper.skip.lock().unwrap().insert("localhost".into());
417        let mismatched = Credentials::new("b", "2");
418        helper.approve(&q, &mismatched).unwrap();
419        // Mismatch: skip flag should still be set.
420        assert!(helper.fill(&q).unwrap().is_none());
421    }
422
423    #[test]
424    fn go_quote_escapes_specials() {
425        assert_eq!(go_quote("hello"), "\"hello\"");
426        assert_eq!(go_quote(r#"a"b"#), "\"a\\\"b\"");
427        assert_eq!(go_quote(r"a\b"), "\"a\\\\b\"");
428        assert_eq!(go_quote(""), "\"\"");
429    }
430}