1use 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#[derive(Debug, Clone)]
51struct NetrcEntry {
52 machine: String,
53 login: String,
54 password: String,
55}
56
57#[derive(Debug)]
63pub struct NetrcCredentialHelper {
64 entries: Vec<NetrcEntry>,
65 skip: Mutex<HashSet<String>>,
66}
67
68impl NetrcCredentialHelper {
69 pub fn from_contents(content: &str) -> Self {
71 Self {
72 entries: parse_netrc(content),
73 skip: Mutex::new(HashSet::new()),
74 }
75 }
76
77 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 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 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 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
159fn 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
199fn 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
218fn 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 tokens.next();
266 }
267 "macdef" => {
268 tokens.next();
275 }
276 _ => {
277 }
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 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 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 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 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}