liburlx 0.2.2

A memory-safe URL transfer library — idiomatic Rust reimplementation of libcurl
Documentation
//! `.netrc` file parsing for credential lookup.
//!
//! Supports the standard `.netrc` format used by curl, ftp, and other tools:
//! ```text
//! machine example.com
//! login myuser
//! password mypassword
//!
//! default
//! login anonymous
//! password user@example.com
//! ```

/// A single credential entry from a `.netrc` file.
#[derive(Debug, Clone)]
pub struct NetrcEntry {
    /// The machine name, or `None` for the `default` entry.
    pub machine: Option<String>,
    /// Login/username.
    pub login: Option<String>,
    /// Password.
    pub password: Option<String>,
}

/// Error type for netrc parsing failures.
#[derive(Debug, Clone)]
pub struct NetrcSyntaxError;

/// Check whether a credential string contains ASCII control characters (bytes < 0x20).
///
/// Used to reject netrc credentials for protocols that cannot handle control codes
/// (e.g., POP3, SMTP, FTP). HTTP allows control chars in credentials.
#[must_use]
pub fn has_control_chars(s: &str) -> bool {
    s.bytes().any(|b| b < 0x20)
}

/// Parse a `.netrc` file and look up credentials for a host.
///
/// Returns the matching entry for the given hostname, or the `default`
/// entry if no host-specific entry matches.
/// # Errors
///
/// Returns [`NetrcSyntaxError`] for syntax errors (e.g., unterminated quotes).
pub fn lookup(contents: &str, hostname: &str) -> Result<Option<NetrcEntry>, NetrcSyntaxError> {
    let entries = parse(contents)?;
    // First try exact machine match
    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()));
    }
    // Fall back to default entry
    Ok(entries.into_iter().find(|e| e.machine.is_none()))
}

/// Parse a `.netrc` file and look up credentials for a host and specific user.
///
/// When multiple entries match the hostname and username, prefers the first entry
/// that also has a password set. If no entry with a password exists, returns the
/// first matching entry (curl compat: test 478).
///
/// # Errors
///
/// Returns [`NetrcSyntaxError`] for syntax errors (e.g., unterminated quotes).
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();

    // Prefer the first entry that has a password (curl compat: test 478).
    // If none has a password, return the first matching entry.
    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())
}

/// Parse all entries from a `.netrc` file.
///
/// Returns `Ok(entries)` on success, or `Err(NetrcSyntaxError)` if the file has a syntax
/// error (e.g., unterminated quoted string).
fn parse(contents: &str) -> Result<Vec<NetrcEntry>, NetrcSyntaxError> {
    let mut entries = Vec::new();
    let mut current: Option<NetrcEntry> = None;

    // Tokenize: split on whitespace, supporting quoted strings with escapes.
    // Comments start with # and run to end of line.
    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" => {
                // Skip account and macdef tokens (and their values)
                i += 1;
            }
            _ => {}
        }
        i += 1;
    }

    if let Some(entry) = current {
        entries.push(entry);
    }

    Ok(entries)
}

/// Tokenize a `.netrc` file, handling comments and quoted strings with escape sequences.
///
/// NULL bytes in the content are treated as line terminators (matching curl's behavior
/// where C strings are truncated at NULL bytes).
///
/// Returns `Err(NetrcSyntaxError)` if a quoted string is unterminated.
fn tokenize(contents: &str) -> Result<Vec<String>, NetrcSyntaxError> {
    let mut tokens = Vec::new();
    for line in contents.lines() {
        // Truncate line at NULL byte (curl compat: test 793).
        // In curl, file contents are stored as C strings where NULL terminates.
        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; // rest of line is comment
            }
            if ch == '"' {
                // Quoted string with escape sequences
                let _ = chars.next(); // consume opening quote
                let mut value = String::new();
                let mut closed = false;
                while let Some(c) = chars.next() {
                    if c == '\\' {
                        // Escape sequence
                        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), // escape at end of line
                        }
                    } else if c == '"' {
                        closed = true;
                        break;
                    } else {
                        value.push(c);
                    }
                }
                if !closed {
                    return Err(NetrcSyntaxError); // unterminated quote
                }
                tokens.push(value);
            } else {
                // Unquoted word: stop at whitespace, '#', or NULL
                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() {
        // curl test 478: multiple entries for same host/user, prefer one 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() {
        // curl test 682: multiple users, pick first matching
        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() {
        // curl test 683: pick the entry matching specific user
        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() {
        // curl test 793: NULL byte truncates the rest of the 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"));
        // After NULL truncation, "password" is a token, not a keyword value
        // The line becomes: machine host.com login 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"));
    }
}