xh 0.25.3

Friendly and fast tool for sending HTTP requests
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
//! See https://www.gnu.org/software/inetutils/manual/html_node/The-_002enetrc-file.html
//!
//! And https://github.com/curl/curl/blob/b01165680450364bdc770da3c7ede190872286c8/lib/netrc.c
//!
//! HTTPie has this behavior:
//!
//! - Entries must have both a login and a password or they'll be ignored or misbehave.
//!
//! - Fields from a default entry are not mixed with those of another entry.
//!
//! - An incomplete entry doesn't allow the default entry as a fallback.
//!
//! - The default entry doesn't have to be at the end of the file.
//!
//! HTTPie uses the implementation from Python's standard library
//! (with a wrapper from requests).
//!
//! This implementation is not at all strict, files are never rejected outright.
//! We'd ignore errors anyway to match HTTPie so that might be for the best.
//! (HTTPie's parser is strict, so a minor problem will silently stop the file
//! from being used.)
//!
//! This implementation additionally handles entries with just a password and no login,
//! to support using .netrc for bearer auth.
//!
//! This is too specialized for our use case to be a crate, but feel free to
//! copy/paste into another project and modify.

use std::{
    fs::File,
    io::{self, BufRead, BufReader},
};

use encoding_rs::UTF_8;
use encoding_rs_io::DecodeReaderBytesBuilder;

use crate::utils::get_home_dir;

#[derive(Debug, PartialEq, Eq)]
pub struct Entry {
    pub login: Option<String>,
    pub password: String,
}

pub fn find_entry(host: url::Host<&str>) -> Option<Entry> {
    let file = open_netrc()?;
    // UTF-16 is detected if it has a BOM.
    // Invalid UTF-8 is sanitized with replacement characters. That way it
    // at least won't stop us from parsing the rest of the file.
    let file = DecodeReaderBytesBuilder::new()
        .encoding(Some(UTF_8))
        .bom_override(true)
        .build(file);
    let file = BufReader::new(file);
    let parser = Parser::new(file, host);
    // Logging I/O errors would be nice.
    parser.parse().ok()?
}

fn open_netrc() -> Option<File> {
    match std::env::var_os("NETRC") {
        Some(path) => File::open(path).ok(),
        None => {
            let home_dir = get_home_dir()?;
            for name in [".netrc", "_netrc"] {
                let path = home_dir.join(name);
                if let Ok(file) = File::open(path) {
                    return Some(file);
                }
            }
            None
        }
    }
}

#[derive(Copy, Clone)]
enum EntryState {
    /// We're outside any entry, or in one for the wrong host.
    Wrong,
    /// We're inside the entry for the host we want.
    Correct,
    /// We're inside the default entry.
    Default,
}

struct Parser<'a, R> {
    reader: R,
    /// The current line.
    buf: String,
    /// The index in `buf` to start looking for the next word.
    pos: usize,
    /// The host we're looking for.
    host: url::Host<&'a str>,
    /// Info about the entry we're handling.
    state: EntryState,
    /// The data collected for the current entry.
    login: Option<String>,
    password: Option<String>,
    account: Option<String>,
    /// Whether to block the default entry from being returned.
    suppress_default: bool,
    /// The default entry, to return if no other can be found.
    default: Option<Entry>,
    /// A complete relevant entry, to be returned ASAP.
    entry: Option<Entry>,
}

impl<'a, R: BufRead> Parser<'a, R> {
    fn new(reader: R, host: url::Host<&'a str>) -> Self {
        Parser {
            reader,
            buf: String::new(),
            pos: 0,
            host,
            state: EntryState::Wrong,
            login: None,
            password: None,
            account: None,
            suppress_default: false,
            default: None,
            entry: None,
        }
    }

    fn parse(mut self) -> io::Result<Option<Entry>> {
        while let Some(word) = self.word()? {
            // curl does a case-insensitive comparison here but that
            // seems unnecessary.
            match word {
                "default" => {
                    // The default entry. Some implementations want you to put it at the
                    // end of the file so they can unconditionally stop after finding it,
                    // we'll use it as a true fallback (like Python does).
                    self.finish_entry();
                    self.state = EntryState::Default;
                }
                "machine" => {
                    self.finish_entry();
                    if let Some(new_host) = self.word()? {
                        match url::Host::parse(new_host) {
                            Ok(new_host) if self.host == new_host => {
                                self.state = EntryState::Correct;
                                self.suppress_default = true;
                            }
                            _ => {
                                self.state = EntryState::Wrong;
                            }
                        }
                    }
                }
                "login" => {
                    if let Some(login) = self.arg()? {
                        self.login = Some(login);
                    }
                }
                "password" => {
                    if let Some(password) = self.arg()? {
                        // Some implementations check the permissions of the file here.
                        // It should be owned by the current user and not be readable by
                        // anyone else. (Unless it contains no passwords.)
                        // But that's a lot of work and somewhat less vital in the
                        // single-user age. Python's stdlib does it by default, but
                        // requests/HTTPie avoids that check.
                        self.password = Some(password);
                    }
                }
                "account" => {
                    // requests/HTTPie uses this as a fallback for login.
                    if let Some(account) = self.arg()? {
                        self.account = Some(account);
                    }
                }
                "macdef" => {
                    // Macro definition. We ignore these.
                    self.finish_entry();
                    // Consume the macro's name.
                    self.word()?;
                    // Skip until the next blank line.
                    // (We consider a line with just whitespace blank.)
                    self.advance_line()?;
                    while !self.buf.trim().is_empty() {
                        self.advance_line()?;
                    }
                }
                word if word.starts_with('#') => {
                    // Comment, skip the rest of the line.
                    // By doing the check here instead of in Reader::word() we allow
                    // arguments to machine/login/password/account to start with #. Curl
                    // doesn't do this.
                    // Python supports comments but seems to dislike blank lines inbetween
                    // commented lines.
                    self.advance_line()?;
                }
                _ => {
                    // Unknown word. We don't crash, but do consider this the end
                    // of the entry.
                    self.finish_entry();
                }
            }
            if let Some(entry) = self.entry {
                return Ok(Some(entry));
            }
        }
        self.finish_entry();
        if let Some(entry) = self.entry {
            Ok(Some(entry))
        } else if self.suppress_default {
            Ok(None)
        } else {
            Ok(self.default)
        }
    }

    /// Reset the current entry state. Try to build an entry out of what was gathered.
    fn finish_entry(&mut self) {
        let login = self.login.take();
        let account = self.account.take();
        let password = self.password.take();

        let state = self.state;
        self.state = EntryState::Wrong;

        if let (login, Some(password)) = (login.or(account), password) {
            let entry = Entry { login, password };
            match state {
                EntryState::Wrong => unreachable!("netrc: Should not have been storing info"),
                EntryState::Correct => self.entry = Some(entry),
                EntryState::Default => self.default = Some(entry),
            }
        }
    }

    /// Consume the next word. Return it only if we're processing a relevant entry.
    fn arg(&mut self) -> io::Result<Option<String>> {
        let state = self.state;
        let word = self.word()?;
        match state {
            EntryState::Wrong => Ok(None),
            EntryState::Correct | EntryState::Default => Ok(word.map(str::to_owned)),
        }
    }

    /// Advance the reader/buffer to the next line.
    fn advance_line(&mut self) -> io::Result<usize> {
        self.buf.clear();
        self.pos = 0;
        self.reader.read_line(&mut self.buf)
    }

    /// Read the next word, if any.
    fn word(&mut self) -> io::Result<Option<&str>> {
        loop {
            match self.buf[self.pos..].chars().next() {
                Some(ch) if ch.is_whitespace() => self.pos += ch.len_utf8(),
                Some(_) => {
                    let text = self.buf[self.pos..].split_whitespace().next().unwrap();
                    self.pos += text.len();
                    return Ok(Some(text));
                }
                None => {
                    if self.advance_line()? == 0 {
                        return Ok(None);
                    }
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    use std::net::Ipv4Addr;

    #[test]
    fn cases() {
        const COM: url::Host<&str> = url::Host::Domain("example.com");
        const ORG: url::Host<&str> = url::Host::Domain("example.org");
        const UNI: url::Host<&str> = url::Host::Domain("xn--9ca.com");
        const IP1: url::Host<&str> = url::Host::Ipv4(Ipv4Addr::new(1, 1, 1, 1));
        const IP2: url::Host<&str> = url::Host::Ipv4(Ipv4Addr::new(2, 2, 2, 2));

        const SIMPLE: &str = "
            machine example.com
            login user
            password pass
        ";
        found(SIMPLE, COM, "user", "pass");
        notfound(SIMPLE, ORG);
        notfound(SIMPLE, UNI);
        notfound(SIMPLE, IP1);

        const ONELINE: &str = "
            machine example.com login user password pass
        ";
        found(ONELINE, COM, "user", "pass");
        notfound(ONELINE, ORG);

        const MULTI: &str = "
            machine example.com login user password pass
            machine example.org login foo password bar
        ";
        found(MULTI, COM, "user", "pass");
        found(MULTI, ORG, "foo", "bar");
        notfound(MULTI, UNI);

        const UNICODE: &str = "
            machine É.com login user password pass
        ";
        found(UNICODE, UNI, "user", "pass");
        notfound(UNICODE, COM);

        const MISSING_PASS: &str = "
            machine example.com login user
        ";
        notfound(MISSING_PASS, COM);

        const MISSING_USER: &str = "
            machine example.com password pass
            default login user
        ";
        found(MISSING_USER, COM, None, "pass");
        notfound(MISSING_USER, ORG);

        const DEFAULT_PASSWORD_MISSING_USER: &str = "
            machine example.com password pass
            default password def
        ";
        found(DEFAULT_PASSWORD_MISSING_USER, COM, None, "pass");
        found(DEFAULT_PASSWORD_MISSING_USER, ORG, None, "def");

        const DEFAULT_LAST: &str = "
            machine example.com login ex password am
            default login def password ault
        ";
        found(DEFAULT_LAST, COM, "ex", "am");
        found(DEFAULT_LAST, ORG, "def", "ault");

        const DEFAULT_FIRST: &str = "
            default login def password ault
            machine example.com login ex password am
        ";
        found(DEFAULT_FIRST, COM, "ex", "am");
        found(DEFAULT_FIRST, ORG, "def", "ault");

        const ACCOUNT_FALLBACK: &str = "
            machine example.com account acc password pass
        ";
        found(ACCOUNT_FALLBACK, COM, "acc", "pass");

        const ACCOUNT_NOT_PREFERRED: &str = "
            machine example.com password pass login log account acc
            machine example.org password pass account acc login log
        ";
        found(ACCOUNT_NOT_PREFERRED, COM, "log", "pass");
        found(ACCOUNT_NOT_PREFERRED, ORG, "log", "pass");

        const WITH_IP: &str = "
            machine 1.1.1.1 login us password pa
        ";
        found(WITH_IP, IP1, "us", "pa");
        notfound(WITH_IP, IP2);
        notfound(WITH_IP, COM);

        const WEIRD_IP: &str = "
            machine 16843009 login us password pa
        ";
        found(WEIRD_IP, IP1, "us", "pa");
        notfound(WEIRD_IP, IP2);
        notfound(WEIRD_IP, COM);

        const MALFORMED: &str = "
            I'm a malformed netrc!
        ";
        notfound(MALFORMED, COM);

        const COMMENT: &str = "
            # machine example.com login user password pass
            machine example.org login lo password pa
        ";
        notfound(COMMENT, COM);
        found(COMMENT, ORG, "lo", "pa");

        const OCTOTHORPE_IN_VALUE: &str = "
            machine example.com login #!@$ password pass
        ";
        found(OCTOTHORPE_IN_VALUE, COM, "#!@$", "pass");

        const SUDDEN_END: &str = "
            machine example.com login
        ";
        notfound(SUDDEN_END, COM);

        const INCOMPLETE_AND_DEFAULT: &str = "
            machine example.com login user
            default login u password p
        ";
        notfound(INCOMPLETE_AND_DEFAULT, COM);
        found(INCOMPLETE_AND_DEFAULT, ORG, "u", "p");

        const UNKNOWN_TOKEN_INTERRUPT: &str = "
            machine example.com
            login user
            foo bar
            password pass
        ";
        notfound(UNKNOWN_TOKEN_INTERRUPT, COM);

        const MACRO: &str = "
            macdef foo
            machine example.com login mac password def
            qux

            machine example.com login user password pass
        ";
        found(MACRO, COM, "user", "pass");
        notfound(MACRO, ORG);

        const MACRO_UNTERMINATED: &str = "
            macdef foo
            machine example.com login mac password def
            qux
            machine example.com login user password pass";
        notfound(MACRO_UNTERMINATED, COM);

        const MACRO_BLANK_LINE_BEFORE_NAME: &str = "
            macdef

            foo
            machine example.com login mac password def";
        notfound(MACRO_BLANK_LINE_BEFORE_NAME, COM);

        const MANY_LINES: &str = "
            machine
            example.com
            login

            user
            password
            pass
        ";
        found(MANY_LINES, COM, "user", "pass");

        const STRANGE_CHARACTERS: &str = "
            machine\u{2029}oké\t\u{2029}login  u   password  p\t\t\t\r\n
        ";
        notfound(STRANGE_CHARACTERS, COM);
    }

    #[track_caller]
    fn found(
        netrc: &str,
        host: url::Host<&str>,
        login: impl Into<Option<&'static str>>,
        password: &str,
    ) {
        let entry = Parser::new(netrc.as_bytes(), host).parse().unwrap();
        let entry = entry.expect("Didn't find entry");
        assert_eq!(entry.login.as_deref(), login.into());
        assert_eq!(entry.password, password);
    }

    #[track_caller]
    fn notfound(netrc: &str, host: url::Host<&str>) {
        let entry = Parser::new(netrc.as_bytes(), host).parse().unwrap();
        assert!(entry.is_none(), "Found entry");
    }
}