1use std::path::PathBuf;
6
7use directories::UserDirs;
8
9pub 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#[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
44fn 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
81fn 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, }
98 }
99 (entry, i)
100}
101
102fn 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 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 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}