#[derive(Debug, Clone)]
pub struct NetrcEntry {
pub machine: Option<String>,
pub login: Option<String>,
pub password: Option<String>,
}
#[derive(Debug, Clone)]
pub struct NetrcSyntaxError;
#[must_use]
pub fn has_control_chars(s: &str) -> bool {
s.bytes().any(|b| b < 0x20)
}
pub fn lookup(contents: &str, hostname: &str) -> Result<Option<NetrcEntry>, NetrcSyntaxError> {
let entries = parse(contents)?;
let exact = entries
.iter()
.find(|e| e.machine.as_ref().is_some_and(|m| m.eq_ignore_ascii_case(hostname)));
if let Some(entry) = exact {
return Ok(Some(entry.clone()));
}
Ok(entries.into_iter().find(|e| e.machine.is_none()))
}
pub fn lookup_user(
contents: &str,
hostname: &str,
username: &str,
) -> Result<Option<NetrcEntry>, NetrcSyntaxError> {
let entries = parse(contents)?;
let matching: Vec<_> = entries
.into_iter()
.filter(|e| {
e.machine.as_ref().is_some_and(|m| m.eq_ignore_ascii_case(hostname))
&& e.login.as_ref().is_some_and(|l| l == username)
})
.collect();
let with_password = matching.iter().find(|e| e.password.is_some());
if let Some(entry) = with_password {
return Ok(Some(entry.clone()));
}
Ok(matching.into_iter().next())
}
fn parse(contents: &str) -> Result<Vec<NetrcEntry>, NetrcSyntaxError> {
let mut entries = Vec::new();
let mut current: Option<NetrcEntry> = None;
let tokens = tokenize(contents)?;
let mut i = 0;
while i < tokens.len() {
match tokens[i].as_str() {
"machine" => {
if let Some(entry) = current.take() {
entries.push(entry);
}
i += 1;
let machine = tokens.get(i).cloned();
current = Some(NetrcEntry { machine, login: None, password: None });
}
"default" => {
if let Some(entry) = current.take() {
entries.push(entry);
}
current = Some(NetrcEntry { machine: None, login: None, password: None });
}
"login" => {
i += 1;
if let Some(ref mut entry) = current {
entry.login = tokens.get(i).cloned();
}
}
"password" => {
i += 1;
if let Some(ref mut entry) = current {
entry.password = tokens.get(i).cloned();
}
}
"account" | "macdef" => {
i += 1;
}
_ => {}
}
i += 1;
}
if let Some(entry) = current {
entries.push(entry);
}
Ok(entries)
}
fn tokenize(contents: &str) -> Result<Vec<String>, NetrcSyntaxError> {
let mut tokens = Vec::new();
for line in contents.lines() {
let line = line.find('\0').map_or(line, |null_pos| &line[..null_pos]);
let line = line.trim();
if line.starts_with('#') {
continue;
}
let mut chars = line.chars().peekable();
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
let _ = chars.next();
continue;
}
if ch == '#' {
break; }
if ch == '"' {
let _ = chars.next(); let mut value = String::new();
let mut closed = false;
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => value.push('\n'),
Some('r') => value.push('\r'),
Some('t') => value.push('\t'),
Some('"') => value.push('"'),
Some('\\') => value.push('\\'),
Some(other) => value.push(other),
None => return Err(NetrcSyntaxError), }
} else if c == '"' {
closed = true;
break;
} else {
value.push(c);
}
}
if !closed {
return Err(NetrcSyntaxError); }
tokens.push(value);
} else {
let mut word = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() || c == '#' || c == '\0' {
break;
}
word.push(c);
let _ = chars.next();
}
tokens.push(word);
}
}
}
Ok(tokens)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn parse_basic_netrc() {
let contents = "machine example.com\nlogin myuser\npassword mypass\n";
let entry = lookup(contents, "example.com").unwrap().unwrap();
assert_eq!(entry.machine.as_deref(), Some("example.com"));
assert_eq!(entry.login.as_deref(), Some("myuser"));
assert_eq!(entry.password.as_deref(), Some("mypass"));
}
#[test]
fn parse_no_match() {
let contents = "machine example.com\nlogin myuser\npassword mypass\n";
let entry = lookup(contents, "other.com").unwrap();
assert!(entry.is_none());
}
#[test]
fn parse_default_fallback() {
let contents = "machine example.com\nlogin user1\npassword pass1\n\ndefault\nlogin anonymous\npassword anon@\n";
let entry = lookup(contents, "other.com").unwrap().unwrap();
assert!(entry.machine.is_none());
assert_eq!(entry.login.as_deref(), Some("anonymous"));
assert_eq!(entry.password.as_deref(), Some("anon@"));
}
#[test]
fn parse_case_insensitive_host() {
let contents = "machine Example.COM\nlogin user\npassword pass\n";
let entry = lookup(contents, "example.com").unwrap().unwrap();
assert_eq!(entry.login.as_deref(), Some("user"));
}
#[test]
fn parse_multiple_machines() {
let contents = "machine a.com\nlogin a\npassword pa\nmachine b.com\nlogin b\npassword pb\n";
let a = lookup(contents, "a.com").unwrap().unwrap();
assert_eq!(a.login.as_deref(), Some("a"));
let b = lookup(contents, "b.com").unwrap().unwrap();
assert_eq!(b.login.as_deref(), Some("b"));
}
#[test]
fn parse_inline_format() {
let contents = "machine example.com login user password pass\n";
let entry = lookup(contents, "example.com").unwrap().unwrap();
assert_eq!(entry.login.as_deref(), Some("user"));
assert_eq!(entry.password.as_deref(), Some("pass"));
}
#[test]
fn parse_comments() {
let contents = "# this is a comment\nmachine example.com\n# another comment\nlogin user\npassword pass\n";
let entry = lookup(contents, "example.com").unwrap().unwrap();
assert_eq!(entry.login.as_deref(), Some("user"));
}
#[test]
fn parse_empty() {
let entries = parse("").unwrap();
assert!(entries.is_empty());
}
#[test]
fn parse_login_only() {
let contents = "machine example.com\nlogin user\n";
let entry = lookup(contents, "example.com").unwrap().unwrap();
assert_eq!(entry.login.as_deref(), Some("user"));
assert!(entry.password.is_none());
}
#[test]
fn parse_account_skipped() {
let contents = "machine example.com\nlogin user\naccount acct\npassword pass\n";
let entry = lookup(contents, "example.com").unwrap().unwrap();
assert_eq!(entry.login.as_deref(), Some("user"));
assert_eq!(entry.password.as_deref(), Some("pass"));
}
#[test]
fn parse_quoted_password() {
let contents =
"machine example.com\nlogin user1\npassword \"with spaces and \\\"\\n\\r\\t\\a\"\n";
let entry = lookup(contents, "example.com").unwrap().unwrap();
assert_eq!(entry.login.as_deref(), Some("user1"));
assert_eq!(entry.password.as_deref(), Some("with spaces and \"\n\r\ta"));
}
#[test]
fn parse_unterminated_quote() {
let contents = "machine example.com\nlogin user1\npassword \"unterminated\n";
assert!(lookup(contents, "example.com").is_err());
}
#[test]
fn lookup_user_multiple_entries_prefer_with_password() {
let contents = "\
machine host.com\nlogin debbie\n\n\
machine host.com\nlogin debbie\npassword secret\n";
let entry = lookup_user(contents, "host.com", "debbie").unwrap().unwrap();
assert_eq!(entry.login.as_deref(), Some("debbie"));
assert_eq!(entry.password.as_deref(), Some("secret"));
}
#[test]
fn lookup_user_first_match_with_password() {
let contents = "machine host.com login user1 password passwd1\nmachine host.com login user2 password passwd2\n";
let entry = lookup_user(contents, "host.com", "user1").unwrap().unwrap();
assert_eq!(entry.password.as_deref(), Some("passwd1"));
}
#[test]
fn lookup_user_second_match() {
let contents = "machine host.com login user1 password passwd1\nmachine host.com login user2 password passwd2\n";
let entry = lookup_user(contents, "host.com", "user2").unwrap().unwrap();
assert_eq!(entry.password.as_deref(), Some("passwd2"));
}
#[test]
fn null_byte_truncates_line() {
let contents = "machine host.com login username \"password\"\0 hello\n";
let entry = lookup(contents, "host.com").unwrap().unwrap();
assert_eq!(entry.login.as_deref(), Some("username"));
assert!(entry.password.is_none());
}
#[test]
fn has_control_chars_detects_cr_lf() {
assert!(has_control_chars("hello\r\nworld"));
assert!(has_control_chars("password\rcommand"));
assert!(has_control_chars("password\ncommand"));
assert!(!has_control_chars("normalpassword"));
assert!(!has_control_chars("with spaces"));
}
}