use anyhow::{Context, Result};
use std::path::Path;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NetrcEntry {
pub login: Option<String>,
pub password: Option<String>,
pub account: Option<String>,
}
pub fn lookup(path: &Path, host: &str, optional: bool) -> Result<Option<NetrcEntry>> {
if !path.exists() {
if optional {
return Ok(None);
}
anyhow::bail!(
"netrc: {} does not exist (pass --netrc-optional to silence)",
path.display()
);
}
let contents = std::fs::read_to_string(path)
.with_context(|| format!("netrc: read {}", path.display()))?;
Ok(find_host(&contents, host))
}
fn find_host(input: &str, host: &str) -> Option<NetrcEntry> {
let tokens = tokenize(input);
let mut iter = tokens.iter().peekable();
let mut current_host: Option<String> = None;
let mut current_is_default = false;
let mut current = NetrcEntry::default();
let mut matched_host: Option<NetrcEntry> = None;
let mut default_entry: Option<NetrcEntry> = None;
while let Some(tok) = iter.next() {
let k = tok.to_ascii_lowercase();
match k.as_str() {
"machine" => {
finalise(¤t_host, current_is_default, ¤t, host, &mut matched_host, &mut default_entry);
current_host = iter.next().cloned();
current_is_default = false;
current = NetrcEntry::default();
}
"default" => {
finalise(¤t_host, current_is_default, ¤t, host, &mut matched_host, &mut default_entry);
current_host = None;
current_is_default = true;
current = NetrcEntry::default();
}
"login" => {
if let Some(v) = iter.next() {
current.login = Some(v.clone());
}
}
"password" | "passwd" => {
if let Some(v) = iter.next() {
current.password = Some(v.clone());
}
}
"account" => {
if let Some(v) = iter.next() {
current.account = Some(v.clone());
}
}
"macdef" => {
iter.next();
while let Some(n) = iter.peek() {
let nl = n.to_ascii_lowercase();
if matches!(nl.as_str(), "machine" | "default" | "login" | "password" | "passwd" | "account" | "macdef") {
break;
}
iter.next();
}
}
_ => {
}
}
}
finalise(¤t_host, current_is_default, ¤t, host, &mut matched_host, &mut default_entry);
matched_host.or(default_entry)
}
fn finalise(
current_host: &Option<String>,
current_is_default: bool,
current: &NetrcEntry,
target: &str,
matched: &mut Option<NetrcEntry>,
default_entry: &mut Option<NetrcEntry>,
) {
if current.login.is_none() && current.password.is_none() && current.account.is_none() {
return;
}
if let Some(host) = current_host {
if host.eq_ignore_ascii_case(target) && matched.is_none() {
*matched = Some(current.clone());
}
} else if current_is_default && default_entry.is_none() {
*default_entry = Some(current.clone());
}
}
fn tokenize(input: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_comment = false;
for ch in input.chars() {
if in_comment {
if ch == '\n' {
in_comment = false;
}
continue;
}
match ch {
'#' => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
in_comment = true;
}
c if c.is_whitespace() => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
pub fn resolve_netrc_path(args: &crate::cli::Args) -> Option<std::path::PathBuf> {
if !args.netrc && args.netrc_file.is_none() && !args.netrc_optional {
return None;
}
if let Some(p) = args.netrc_file.as_ref() {
return Some(p.clone());
}
if let Ok(env) = std::env::var("NETRC") {
if !env.is_empty() {
return Some(std::path::PathBuf::from(env));
}
}
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
Some(std::path::PathBuf::from(home).join(".netrc"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_matching_machine_block() {
let input = r#"
machine api.example.com
login alice
password s3cr3t
machine other.example.com
login bob
password hunter2
"#;
let got = find_host(input, "api.example.com").unwrap();
assert_eq!(got.login.as_deref(), Some("alice"));
assert_eq!(got.password.as_deref(), Some("s3cr3t"));
}
#[test]
fn falls_back_to_default_block() {
let input = r#"
machine api.example.com
login alice
password s3cr3t
default
login guest
password x
"#;
let got = find_host(input, "somethingelse.com").unwrap();
assert_eq!(got.login.as_deref(), Some("guest"));
}
#[test]
fn returns_none_when_no_match_and_no_default() {
let input = r#"
machine api.example.com
login alice
password s3cr3t
"#;
assert!(find_host(input, "somethingelse.com").is_none());
}
#[test]
fn tolerates_comments_and_blank_lines() {
let input = r#"
# this is a comment
machine api.example.com # inline comment
login alice
password s3cr3t
"#;
let got = find_host(input, "api.example.com").unwrap();
assert_eq!(got.login.as_deref(), Some("alice"));
}
#[test]
fn ignores_macdef_body() {
let input = r#"
machine api.example.com
login alice
password s3cr3t
macdef init
cd /pub
ls
machine other.example.com
login bob
password hunter2
"#;
let got = find_host(input, "other.example.com").unwrap();
assert_eq!(got.login.as_deref(), Some("bob"));
}
#[test]
fn host_match_is_case_insensitive() {
let input = r#"
machine API.Example.com
login alice
password s3cr3t
"#;
let got = find_host(input, "api.example.com").unwrap();
assert_eq!(got.login.as_deref(), Some("alice"));
}
}