Skip to main content

uv_netrc/
netrc.rs

1//! This parser and the tests are a translation of the official Python netrc library.
2
3use crate::lex::Lex;
4use std::collections::HashMap;
5
6#[derive(Debug)]
7pub struct ParsingError {
8    lineno: u32,
9    message: String,
10}
11
12impl std::fmt::Display for ParsingError {
13    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14        write!(f, "parsing error: {} (line {})", self.message, self.lineno)
15    }
16}
17
18/// Authenticators for host.
19#[derive(Debug, PartialEq, Eq, Clone, Default)]
20pub struct Authenticator {
21    /// Identify a user on the remote machine.
22    pub login: String,
23
24    /// Supply an additional account password.
25    pub account: String,
26
27    /// Supply a password
28    pub password: String,
29}
30
31impl Authenticator {
32    #[allow(dead_code)]
33    pub(crate) fn new(login: &str, account: &str, password: &str) -> Self {
34        Self {
35            login: login.to_owned(),
36            account: account.to_owned(),
37            password: password.to_owned(),
38        }
39    }
40}
41
42/// Represents the netrc file.
43#[derive(Debug, Default)]
44pub struct Netrc {
45    /// Dictionary mapping host names to the authenticators.
46    pub hosts: HashMap<String, Authenticator>,
47
48    /// Dictionary mapping macro names to string lists.
49    pub macros: HashMap<String, Vec<String>>,
50}
51
52impl std::fmt::Display for Netrc {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        for (host, attrs) in &self.hosts {
55            writeln!(f, "machine {host}")?;
56            writeln!(f, "\tlogin {}", attrs.login)?;
57            if !attrs.account.is_empty() {
58                writeln!(f, "\taccount  {}", attrs.account)?;
59            }
60            writeln!(f, "\tpassword  {}", attrs.password)?;
61        }
62        for (macro_, lines) in &self.macros {
63            writeln!(f, "macdef {macro_}")?;
64            for line in lines {
65                writeln!(f, "{line}")?;
66            }
67        }
68        Ok(())
69    }
70}
71
72impl std::str::FromStr for Netrc {
73    type Err = ParsingError;
74
75    fn from_str(s: &str) -> Result<Self, ParsingError> {
76        let mut res = Self::default();
77        let mut lexer = Lex::new(s);
78
79        loop {
80            let saved_lineno = lexer.lineno;
81            let tt = lexer.get_token();
82            if tt.is_empty() {
83                break;
84            }
85            if tt.chars().nth(0) == Some('#') {
86                if lexer.lineno == saved_lineno {
87                    lexer.read_line();
88                }
89                continue;
90            }
91
92            let entryname = match tt.as_str() {
93                "" => {
94                    break;
95                }
96                "machine" => lexer.get_token(),
97                "default" => String::from("default"),
98                "macdef" => {
99                    let entryname = lexer.get_token();
100                    let mut v = Vec::new();
101                    loop {
102                        let line = lexer.read_line();
103                        if line.trim().is_empty() {
104                            break;
105                        }
106                        v.push(line.trim().to_owned());
107                    }
108                    res.macros.insert(entryname, v);
109                    continue;
110                }
111                _ => {
112                    return Err(ParsingError {
113                        lineno: lexer.lineno,
114                        message: format!("bad toplevel token '{tt}'"),
115                    });
116                }
117            };
118            if entryname.is_empty() {
119                return Err(ParsingError {
120                    lineno: lexer.lineno,
121                    message: format!("missing '{tt}' name"),
122                });
123            }
124
125            let mut auth = Authenticator::default();
126
127            loop {
128                let prev_lineno = lexer.lineno;
129                let tt = lexer.get_token();
130                if tt.starts_with('#') {
131                    if lexer.lineno == prev_lineno {
132                        lexer.read_line();
133                    }
134                    continue;
135                }
136                match tt.as_str() {
137                    "" | "machine" | "default" | "macdef" => {
138                        res.hosts.insert(entryname, auth);
139                        lexer.push_token(&tt);
140                        break;
141                    }
142                    "login" | "user" => {
143                        auth.login = lexer.get_token();
144                    }
145                    "account" => {
146                        auth.account = lexer.get_token();
147                    }
148                    "password" => {
149                        auth.password = lexer.get_token();
150                    }
151                    _ => {
152                        return Err(ParsingError {
153                            lineno: lexer.lineno,
154                            message: format!("bad follower token '{tt}'"),
155                        });
156                    }
157                }
158            }
159        }
160
161        Ok(res)
162    }
163}
164
165#[cfg(test)]
166#[expect(
167    clippy::needless_raw_string_hashes,
168    reason = "Keep the vendored parser tests close to upstream."
169)]
170mod tests {
171    use std::str::FromStr;
172
173    use super::*;
174
175    #[test]
176    fn test_toplevel_non_ordered_tokens() {
177        let nrc = Netrc::from_str(
178            "\
179            machine host.domain.com password pass1 login log1 account acct1
180            default login log2 password pass2 account acct2
181        ",
182        )
183        .unwrap();
184
185        assert_eq!(
186            nrc.hosts["host.domain.com"],
187            Authenticator::new("log1", "acct1", "pass1")
188        );
189        assert_eq!(
190            nrc.hosts["default"],
191            Authenticator::new("log2", "acct2", "pass2")
192        );
193    }
194
195    #[test]
196    fn test_toplevel_tokens() {
197        let nrc = Netrc::from_str(
198            "\
199            machine host.domain.com login log1 password pass1 account acct1
200            default login log2 password pass2 account acct2
201        ",
202        )
203        .unwrap();
204        assert_eq!(
205            nrc.hosts["host.domain.com"],
206            Authenticator::new("log1", "acct1", "pass1")
207        );
208        assert_eq!(
209            nrc.hosts["default"],
210            Authenticator::new("log2", "acct2", "pass2")
211        );
212    }
213
214    #[test]
215    fn test_macros() {
216        let nrc = Netrc::from_str(
217            "\
218            macdef macro1
219            line1
220            line2
221
222            macdef macro2
223            line3
224            line4
225            ",
226        )
227        .unwrap();
228        assert_eq!(nrc.macros["macro1"], vec!["line1", "line2"]);
229        assert_eq!(nrc.macros["macro2"], vec!["line3", "line4"]);
230    }
231
232    #[test]
233    fn test_optional_tokens_machine() {
234        let data = vec![
235            "machine host.domain.com",
236            "machine host.domain.com login",
237            "machine host.domain.com account",
238            "machine host.domain.com password",
239            "machine host.domain.com login \"\" account",
240            "machine host.domain.com login \"\" password",
241            "machine host.domain.com account \"\" password",
242        ];
243
244        for item in data {
245            let nrc = Netrc::from_str(item).unwrap();
246            assert_eq!(nrc.hosts["host.domain.com"], Authenticator::new("", "", ""));
247        }
248    }
249
250    #[test]
251    fn test_optional_tokens_default() {
252        let data = vec![
253            "default",
254            "default login",
255            "default account",
256            "default password",
257            "default login \"\" account",
258            "default login \"\" password",
259            "default account \"\" password",
260        ];
261
262        for item in data {
263            let nrc = Netrc::from_str(item).unwrap();
264            assert_eq!(nrc.hosts["default"], Authenticator::new("", "", ""));
265        }
266    }
267
268    #[test]
269    fn test_invalid_tokens() {
270        let data = vec![
271            (
272                "invalid host.domain.com",
273                "parsing error: bad toplevel token 'invalid' (line 1)",
274            ),
275            (
276                "machine host.domain.com invalid",
277                "parsing error: bad follower token 'invalid' (line 1)",
278            ),
279            (
280                "machine host.domain.com login log password pass account acct invalid",
281                "parsing error: bad follower token 'invalid' (line 1)",
282            ),
283            (
284                "default host.domain.com invalid",
285                "parsing error: bad follower token 'host.domain.com' (line 1)",
286            ),
287            (
288                "default host.domain.com login log password pass account acct invalid",
289                "parsing error: bad follower token 'host.domain.com' (line 1)",
290            ),
291        ];
292
293        for (item, msg) in data {
294            let nrc = Netrc::from_str(item);
295            assert_eq!(nrc.unwrap_err().to_string(), msg);
296        }
297    }
298
299    fn test_token_x(data: &str, token: &str, value: &str) {
300        let nrc = Netrc::from_str(data).unwrap();
301        match token {
302            "login" => {
303                assert_eq!(
304                    nrc.hosts["host.domain.com"],
305                    Authenticator::new(value, "acct", "pass")
306                );
307            }
308            "account" => {
309                assert_eq!(
310                    nrc.hosts["host.domain.com"],
311                    Authenticator::new("log", value, "pass")
312                );
313            }
314            "password" => {
315                assert_eq!(
316                    nrc.hosts["host.domain.com"],
317                    Authenticator::new("log", "acct", value)
318                );
319            }
320            _ => {}
321        }
322    }
323
324    #[test]
325    fn test_token_value_quotes() {
326        test_token_x(
327            "\
328            machine host.domain.com login \"log\" password pass account acct
329            ",
330            "login",
331            "log",
332        );
333        test_token_x(
334            "\
335            machine host.domain.com login log password pass account \"acct\"
336            ",
337            "account",
338            "acct",
339        );
340        test_token_x(
341            "\
342            machine host.domain.com login log password \"pass\" account acct
343            ",
344            "password",
345            "pass",
346        );
347    }
348
349    #[test]
350    fn test_token_value_escape() {
351        test_token_x(
352            r#"machine host.domain.com login \"log password pass account acct"#,
353            "login",
354            "\"log",
355        );
356        test_token_x(
357            "\
358            machine host.domain.com login \"\\\"log\" password pass account acct
359            ",
360            "login",
361            "\"log",
362        );
363        test_token_x(
364            "\
365            machine host.domain.com login log password pass account \\\"acct
366            ",
367            "account",
368            "\"acct",
369        );
370        test_token_x(
371            "\
372            machine host.domain.com login log password pass account \"\\\"acct\"
373            ",
374            "account",
375            "\"acct",
376        );
377        test_token_x(
378            "\
379            machine host.domain.com login log password \\\"pass account acct
380            ",
381            "password",
382            "\"pass",
383        );
384        test_token_x(
385            "\
386            machine host.domain.com login log password \"\\\"pass\" account acct
387            ",
388            "password",
389            "\"pass",
390        );
391    }
392
393    #[test]
394    fn test_token_value_whitespace() {
395        test_token_x(
396            r#"machine host.domain.com login "lo g" password pass account acct"#,
397            "login",
398            "lo g",
399        );
400        test_token_x(
401            r#"machine host.domain.com login log password "pas s" account acct"#,
402            "password",
403            "pas s",
404        );
405        test_token_x(
406            r#"machine host.domain.com login log password pass account "acc t""#,
407            "account",
408            "acc t",
409        );
410    }
411
412    #[test]
413    fn test_token_value_non_ascii() {
414        test_token_x(
415            r#"machine host.domain.com login ¡¢ password pass account acct"#,
416            "login",
417            "¡¢",
418        );
419        test_token_x(
420            r#"machine host.domain.com login log password pass account ¡¢"#,
421            "account",
422            "¡¢",
423        );
424        test_token_x(
425            r#"machine host.domain.com login log password ¡¢ account acct"#,
426            "password",
427            "¡¢",
428        );
429    }
430
431    #[test]
432    fn test_token_value_leading_hash() {
433        test_token_x(
434            r#"machine host.domain.com login #log password pass account acct"#,
435            "login",
436            "#log",
437        );
438        test_token_x(
439            r#"machine host.domain.com login log password pass account #acct"#,
440            "account",
441            "#acct",
442        );
443        test_token_x(
444            r#"machine host.domain.com login log password #pass account acct"#,
445            "password",
446            "#pass",
447        );
448    }
449
450    #[test]
451    fn test_token_value_trailing_hash() {
452        test_token_x(
453            r#"machine host.domain.com login log# password pass account acct"#,
454            "login",
455            "log#",
456        );
457        test_token_x(
458            r#"machine host.domain.com login log password pass account acct#"#,
459            "account",
460            "acct#",
461        );
462        test_token_x(
463            r#"machine host.domain.com login log password pass# account acct"#,
464            "password",
465            "pass#",
466        );
467    }
468
469    #[test]
470    fn test_token_value_internal_hash() {
471        test_token_x(
472            r#"machine host.domain.com login lo#g password pass account acct"#,
473            "login",
474            "lo#g",
475        );
476        test_token_x(
477            r#"machine host.domain.com login log password pass account ac#ct"#,
478            "account",
479            "ac#ct",
480        );
481        test_token_x(
482            r#"machine host.domain.com login log password pa#ss account acct"#,
483            "password",
484            "pa#ss",
485        );
486    }
487
488    fn test_comment(data: &str) {
489        let nrc = Netrc::from_str(data).unwrap();
490        assert_eq!(
491            nrc.hosts["foo.domain.com"],
492            Authenticator::new("bar", "", "pass")
493        );
494        assert_eq!(
495            nrc.hosts["bar.domain.com"],
496            Authenticator::new("foo", "", "pass")
497        );
498    }
499
500    #[test]
501    fn test_comment_before_machine_line() {
502        test_comment(
503            r#"# comment
504            machine foo.domain.com login bar password pass
505            machine bar.domain.com login foo password pass
506            "#,
507        );
508    }
509    #[test]
510    fn test_comment_before_machine_line_no_space() {
511        test_comment(
512            r#"#comment
513            machine foo.domain.com login bar password pass
514            machine bar.domain.com login foo password pass
515            "#,
516        );
517    }
518
519    #[test]
520    fn test_comment_before_machine_line_hash_only() {
521        test_comment(
522            r#"#
523            machine foo.domain.com login bar password pass
524            machine bar.domain.com login foo password pass
525            "#,
526        );
527    }
528
529    #[test]
530    fn test_comment_before_machine_line_multiword_no_space() {
531        test_comment(
532            r#"#comment word2 word3
533            machine foo.domain.com login bar password pass
534            machine bar.domain.com login foo password pass
535            "#,
536        );
537    }
538
539    #[test]
540    fn test_comment_after_machine_line_multiword_no_space() {
541        test_comment(
542            r#"machine foo.domain.com login bar password pass
543            #comment word2 word3
544            machine bar.domain.com login foo password pass
545            "#,
546        );
547        test_comment(
548            r#"machine foo.domain.com login bar password pass
549            machine bar.domain.com login foo password pass
550            #comment word2 word3
551            "#,
552        );
553    }
554
555    #[test]
556    fn test_comment_after_machine_line() {
557        test_comment(
558            r#"machine foo.domain.com login bar password pass
559            # comment
560            machine bar.domain.com login foo password pass
561            "#,
562        );
563        test_comment(
564            r#"machine foo.domain.com login bar password pass
565            machine bar.domain.com login foo password pass
566            # comment
567            "#,
568        );
569    }
570
571    #[test]
572    fn test_comment_after_machine_line_no_space() {
573        test_comment(
574            r#"machine foo.domain.com login bar password pass
575            #comment
576            machine bar.domain.com login foo password pass
577            "#,
578        );
579        test_comment(
580            r#"machine foo.domain.com login bar password pass
581            machine bar.domain.com login foo password pass
582            #comment
583            "#,
584        );
585    }
586
587    #[test]
588    fn test_comment_after_machine_line_hash_only() {
589        test_comment(
590            r#"machine foo.domain.com login bar password pass
591            #
592            machine bar.domain.com login foo password pass
593            "#,
594        );
595        test_comment(
596            r#"machine foo.domain.com login bar password pass
597            machine bar.domain.com login foo password pass
598            #
599            "#,
600        );
601    }
602
603    #[test]
604    fn test_comment_at_end_of_machine_line() {
605        test_comment(
606            r#"machine foo.domain.com login bar password pass # comment
607            machine bar.domain.com login foo password pass
608            "#,
609        );
610    }
611
612    #[test]
613    fn test_comment_at_end_of_machine_line_no_space() {
614        test_comment(
615            r#"machine foo.domain.com login bar password pass #comment
616            machine bar.domain.com login foo password pass
617            "#,
618        );
619    }
620
621    #[test]
622    fn test_comment_at_end_of_machine_line_pass_has_hash() {
623        let nrc = Netrc::from_str(
624            r#"machine foo.domain.com login bar password #pass #comment
625            machine bar.domain.com login foo password pass
626        "#,
627        )
628        .unwrap();
629        assert_eq!(
630            nrc.hosts["foo.domain.com"],
631            Authenticator::new("bar", "", "#pass")
632        );
633        assert_eq!(
634            nrc.hosts["bar.domain.com"],
635            Authenticator::new("foo", "", "pass")
636        );
637    }
638
639    #[test]
640    fn test_lineno_after_macdef() {
641        let nrc = Netrc::from_str("macdef mymacro\nline1\nline2\n\nbad_token foo");
642        let err = nrc.unwrap_err();
643        assert_eq!(
644            err.to_string(),
645            "parsing error: bad toplevel token 'bad_token' (line 5)"
646        );
647    }
648    #[test]
649    fn test_lineno_after_comment() {
650        let nrc = Netrc::from_str("# comment\n# comment\nbad_token foo");
651        let err = nrc.unwrap_err();
652        assert_eq!(
653            err.to_string(),
654            "parsing error: bad toplevel token 'bad_token' (line 3)"
655        );
656    }
657}