Skip to main content

rget/
netrc.rs

1//! `.netrc` credential lookup, so `rget` picks up saved logins the same way
2//! `wget` and `curl` do (PRD-adjacent: no flag should be required for a host
3//! the user has already told the rest of their tools how to authenticate to).
4
5use std::path::PathBuf;
6
7use directories::UserDirs;
8
9/// `user:password` for `host`, read from `$NETRC` or `~/.netrc` (`_netrc` on
10/// Windows). `None` if there is no file, no entry for this host, and no
11/// `default` entry.
12pub fn lookup(host: &str) -> Option<(String, String)> {
13    let path = netrc_path()?;
14    let content = std::fs::read_to_string(path).ok()?;
15    find(&content, host)
16}
17
18fn netrc_path() -> Option<PathBuf> {
19    if let Ok(p) = std::env::var("NETRC") {
20        return Some(PathBuf::from(p));
21    }
22    let home = UserDirs::new()?.home_dir().to_path_buf();
23    #[cfg(windows)]
24    let name = "_netrc";
25    #[cfg(not(windows))]
26    let name = ".netrc";
27    Some(home.join(name))
28}
29
30/// A `login`/`password` pair, as collected while scanning one `machine` or
31/// `default` block.
32#[derive(Default)]
33struct Entry {
34    login: Option<String>,
35    password: Option<String>,
36}
37
38impl Entry {
39    fn into_pair(self) -> Option<(String, String)> {
40        Some((self.login?, self.password?))
41    }
42}
43
44/// Find the credentials for `host`, falling back to a `default` entry.
45///
46/// Exact match wins even if it appears after `default` in the file, since a
47/// specific entry is always more useful than a catch-all.
48fn find(content: &str, host: &str) -> Option<(String, String)> {
49    let tokens = tokenize(content);
50    let mut matched: Option<Entry> = None;
51    let mut default_entry: Option<Entry> = None;
52
53    let mut i = 0;
54    while i < tokens.len() {
55        match tokens[i].as_str() {
56            "machine" => {
57                i += 1;
58                let name = tokens.get(i).cloned();
59                i += 1;
60                let (entry, next) = read_entry(&tokens, i);
61                i = next;
62                if name.as_deref() == Some(host) {
63                    matched = Some(entry);
64                }
65            }
66            "default" => {
67                i += 1;
68                let (entry, next) = read_entry(&tokens, i);
69                i = next;
70                default_entry = Some(entry);
71            }
72            _ => i += 1,
73        }
74    }
75
76    matched
77        .and_then(Entry::into_pair)
78        .or_else(|| default_entry.and_then(Entry::into_pair))
79}
80
81/// Consume `login`/`password`/`account` pairs until the next `machine`,
82/// `default`, or `macdef` keyword (or end of input).
83fn read_entry(tokens: &[String], mut i: usize) -> (Entry, usize) {
84    let mut entry = Entry::default();
85    while i < tokens.len() {
86        match tokens[i].as_str() {
87            "machine" | "default" | "macdef" => break,
88            "login" => {
89                entry.login = tokens.get(i + 1).cloned();
90                i += 2;
91            }
92            "password" => {
93                entry.password = tokens.get(i + 1).cloned();
94                i += 2;
95            }
96            _ => i += 1, // `account`, or anything unrecognised: skip the keyword
97        }
98    }
99    (entry, i)
100}
101
102/// Split into whitespace-separated words, skipping comment lines and the
103/// unstructured body of `macdef` macros (terminated by a blank line), which
104/// otherwise would be misread as more `machine` blocks.
105fn tokenize(content: &str) -> Vec<String> {
106    let mut out = Vec::new();
107    let mut lines = content.lines();
108    while let Some(line) = lines.next() {
109        let trimmed = line.trim();
110        if trimmed.starts_with('#') {
111            continue;
112        }
113        let words: Vec<&str> = trimmed.split_whitespace().collect();
114        if words.first() == Some(&"macdef") {
115            for l in lines.by_ref() {
116                if l.trim().is_empty() {
117                    break;
118                }
119            }
120            continue;
121        }
122        out.extend(words.into_iter().map(str::to_string));
123    }
124    out
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn finds_matching_machine() {
133        let content = "machine example.com\n  login alice\n  password s3cret\n";
134        assert_eq!(
135            find(content, "example.com"),
136            Some(("alice".into(), "s3cret".into()))
137        );
138    }
139
140    #[test]
141    fn ignores_other_machines() {
142        let content = "machine a.example\nlogin a\npassword pa\n\
143                        machine b.example\nlogin b\npassword pb\n";
144        assert_eq!(find(content, "b.example"), Some(("b".into(), "pb".into())));
145        assert_eq!(find(content, "c.example"), None);
146    }
147
148    #[test]
149    fn falls_back_to_default() {
150        let content = "machine a.example\nlogin a\npassword pa\n\
151                        default\nlogin anon\npassword anon-pass\n";
152        assert_eq!(
153            find(content, "unlisted.example"),
154            Some(("anon".into(), "anon-pass".into()))
155        );
156        // An exact match still wins over `default`.
157        assert_eq!(find(content, "a.example"), Some(("a".into(), "pa".into())));
158    }
159
160    #[test]
161    fn handles_single_line_form() {
162        let content = "machine example.com login alice password s3cret";
163        assert_eq!(
164            find(content, "example.com"),
165            Some(("alice".into(), "s3cret".into()))
166        );
167    }
168
169    #[test]
170    fn skips_account_field() {
171        let content = "machine example.com\nlogin alice\naccount ignored\npassword s3cret\n";
172        assert_eq!(
173            find(content, "example.com"),
174            Some(("alice".into(), "s3cret".into()))
175        );
176    }
177
178    #[test]
179    fn skips_macdef_body() {
180        let content = "macdef init\ncurl something\nmachine fake.example\n\n\
181                        machine example.com\nlogin alice\npassword s3cret\n";
182        // The `machine fake.example` line inside the macro body must not be
183        // parsed as a real entry.
184        assert_eq!(find(content, "fake.example"), None);
185        assert_eq!(
186            find(content, "example.com"),
187            Some(("alice".into(), "s3cret".into()))
188        );
189    }
190
191    #[test]
192    fn ignores_comment_lines() {
193        let content = "# a comment\nmachine example.com\nlogin alice\npassword s3cret\n";
194        assert_eq!(
195            find(content, "example.com"),
196            Some(("alice".into(), "s3cret".into()))
197        );
198    }
199
200    #[test]
201    fn incomplete_entry_yields_nothing() {
202        let content = "machine example.com\nlogin alice\n";
203        assert_eq!(find(content, "example.com"), None);
204    }
205}