rsurl 0.0.4

A pure-Rust implementation of curl. Library, C FFI, and CLI for HTTP/HTTPS/FTP/FTPS.
Documentation
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
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
//! SSH transports: SFTP (`sftp://`) and SCP (`scp://`), download and upload.
//!
//! Built on the first-party pure-Rust [`puressh`] crate (same lab, on top of
//! `purecrypto`). Both schemes default to port 22 and share connection, auth,
//! and host-key handling; they differ only in how bytes move:
//!
//!   * **SFTP** speaks the SFTP subsystem over a session channel. Download
//!     opens the remote path `FXF_READ` and loops `read` until EOF; upload
//!     opens `FXF_WRITE|FXF_CREAT|FXF_TRUNC` and streams the body in chunks.
//!   * **SCP** drives the remote `scp -t`/`scp -f` helper. puressh's SCP API
//!     is path-oriented (it reads/writes a *local* file), so we bridge through
//!     a temp file: download fetches into a temp file then slurps it; upload
//!     writes the body to a temp file then sends it. The temp file is always
//!     removed, success or failure.
//!
//! ## Authentication
//!
//! The user is taken from the URL userinfo, else `-u`, else `$USER`/`$USERNAME`
//! (like OpenSSH). Credentials are collected in order — public keys first
//! (explicit `--key` identity, else the existing default keys
//! `~/.ssh/id_ed25519`, `~/.ssh/id_ecdsa`, `~/.ssh/id_rsa`), then the password
//! if one was supplied — and handed to a single `authenticate` call, which
//! tries each until one is accepted.
//!
//! ## Host-key verification (TOFU)
//!
//! By default we verify against `~/.ssh/known_hosts` with trust-on-first-use:
//! an unknown host is accepted and persisted; a host whose key has *changed*
//! is rejected. `-k`/`--insecure` downgrades to accept-any (OpenSSH
//! `StrictHostKeyChecking=no`).
//!
//! [`puressh`]: https://crates.io/crates/puressh

use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;

use puressh::auth::ClientCredential;
use puressh::client::{Client, Config, HostKeyPolicy, KnownHostsPolicy, TofuAction};
use puressh::key::PrivateKey;
use puressh::known_hosts::KnownHosts;
use puressh::sftp::{Attrs, FXF_CREAT, FXF_READ, FXF_TRUNC, FXF_WRITE};

use crate::error::{Error, Result};
use crate::url::Url;

/// Chunk size for SFTP reads and writes. 32 KiB stays well under the SSH
/// channel window and the SFTP packet ceiling while keeping round-trips low.
const SFTP_CHUNK: usize = 32 * 1024;

/// Connection/auth knobs derived from the CLI and URL. Carries no secret
/// beyond `password`, which is never logged.
#[derive(Clone, Default)]
pub struct SshOptions {
    /// Password from URL userinfo or `-u`. `None` means "no password method".
    pub password: Option<String>,
    /// Explicit identity file(s) from `--key`. When empty, default keys under
    /// `~/.ssh` are probed instead.
    pub identity_files: Vec<PathBuf>,
    /// Passphrase for an encrypted identity file (from `-u`'s password half,
    /// reused; OpenSSH-style prompting is not available in a one-shot CLI).
    pub key_passphrase: Option<String>,
    /// `-k`/`--insecure`: accept any host key instead of TOFU/known_hosts.
    pub insecure: bool,
    /// Override the `known_hosts` path (defaults to `~/.ssh/known_hosts`).
    pub known_hosts_path: Option<PathBuf>,
    /// Per-operation socket timeout.
    pub timeout: Option<Duration>,
}

/// Map a `puressh::Error` to our crate error, keeping the message but never
/// leaking credentials (puressh's errors are static strings / io errors and
/// carry no secret).
fn ssh_err(e: puressh::Error) -> Error {
    Error::Ssh(e.to_string())
}

/// Reject a URL-derived string carrying an ASCII control byte (CR/LF/NUL/DEL,
/// or anything `< 0x20`). Mirrors the guard in `ftp`/`imap`: a control byte in
/// the user or remote path could corrupt the SSH/SFTP/SCP request framing.
fn reject_ctl(s: &str, what: &str) -> Result<()> {
    if let Some(b) = s.bytes().find(|b| *b < 0x20 || *b == 0x7f) {
        return Err(Error::Ssh(format!(
            "{what} contains illegal control byte {b:#04x}"
        )));
    }
    Ok(())
}

/// Resolve `~/.ssh`. `None` if no home directory is discoverable.
fn ssh_dir() -> Option<PathBuf> {
    home_dir().map(|h| h.join(".ssh"))
}

/// Best-effort home directory: `$HOME` on unix, `$USERPROFILE` on Windows.
fn home_dir() -> Option<PathBuf> {
    if let Ok(h) = std::env::var("HOME") {
        if !h.is_empty() {
            return Some(PathBuf::from(h));
        }
    }
    if let Ok(h) = std::env::var("USERPROFILE") {
        if !h.is_empty() {
            return Some(PathBuf::from(h));
        }
    }
    None
}

/// Split `user[:pass]` userinfo into `(Option<user>, Option<pass>)`. An empty
/// password half (`user:`) is treated as no password.
fn split_userinfo(ui: Option<&str>) -> (Option<String>, Option<String>) {
    match ui {
        None => (None, None),
        Some(s) => match s.split_once(':') {
            Some((u, p)) => (
                (!u.is_empty()).then(|| u.to_string()),
                (!p.is_empty()).then(|| p.to_string()),
            ),
            None => ((!s.is_empty()).then(|| s.to_string()), None),
        },
    }
}

/// Extract `(Option<user>, Option<password>)` from a URL's userinfo. Public so
/// the transfer dispatcher and the CLI can derive the password without
/// duplicating the parse.
pub fn userinfo_password(url: &Url) -> (Option<String>, Option<String>) {
    split_userinfo(url.userinfo.as_deref())
}

/// Resolve the SSH username for `url` given the parsed `opts`. URL userinfo
/// wins, then `opts.password`-bearing `-u` user (threaded by the CLI into
/// `opts` is the password only, so the user must come from the URL or the
/// `user` arg), then `$USER`/`$USERNAME` like OpenSSH. Returns an error only
/// if nothing yields a name.
pub fn resolve_user(url: &Url, cli_user: Option<&str>) -> Result<String> {
    let (url_user, _) = split_userinfo(url.userinfo.as_deref());
    if let Some(u) = url_user {
        return Ok(u);
    }
    if let Some(u) = cli_user {
        if !u.is_empty() {
            return Ok(u.to_string());
        }
    }
    for var in ["USER", "USERNAME", "LOGNAME"] {
        if let Ok(v) = std::env::var(var) {
            if !v.is_empty() {
                return Ok(v);
            }
        }
    }
    Err(Error::Ssh(
        "no SSH user: none in URL, -u, or $USER".to_string(),
    ))
}

/// The default known_hosts path (`~/.ssh/known_hosts`), or `None` if no home.
fn default_known_hosts() -> Option<PathBuf> {
    ssh_dir().map(|d| d.join("known_hosts"))
}

/// Default identity files to probe when `--key` isn't given: the existing
/// `~/.ssh/id_ed25519`, `~/.ssh/id_ecdsa`, `~/.ssh/id_rsa` (in OpenSSH's
/// preference order). Only files that actually exist are returned.
fn default_identity_files() -> Vec<PathBuf> {
    let Some(dir) = ssh_dir() else {
        return Vec::new();
    };
    discover_default_keys(&dir)
}

/// Pure helper for [`default_identity_files`]: given an `.ssh` directory,
/// return the existing default key files in preference order. Split out so a
/// unit test can point it at a temp dir.
fn discover_default_keys(ssh_dir: &Path) -> Vec<PathBuf> {
    ["id_ed25519", "id_ecdsa", "id_rsa"]
        .iter()
        .map(|n| ssh_dir.join(n))
        .filter(|p| p.is_file())
        .collect()
}

/// Build the host-key policy `Config` for this connection. `-k` ⇒ accept-any;
/// otherwise TOFU against known_hosts (accept+persist unknown, reject changed).
fn build_config(opts: &SshOptions) -> Config {
    if opts.insecure {
        return Config {
            host_key_policy: HostKeyPolicy::AcceptAny,
            timeout: opts.timeout,
        };
    }
    let kh_path = opts.known_hosts_path.clone().or_else(default_known_hosts);
    // Load the existing store if present; start empty otherwise (a fresh
    // known_hosts the first TOFU accept will create).
    let store = match &kh_path {
        Some(p) => KnownHosts::load(p).unwrap_or_else(|_| KnownHosts::new()),
        None => KnownHosts::new(),
    };
    let policy = KnownHostsPolicy {
        store: Arc::new(Mutex::new(store)),
        save_path: kh_path,
        hash_new: false,
        on_unknown: TofuAction::Accept,
        on_mismatch: TofuAction::Reject,
    };
    Config {
        host_key_policy: HostKeyPolicy::KnownHosts(policy),
        timeout: opts.timeout,
    }
}

/// Load one identity file into a `ClientCredential::PublicKey`. Encrypted keys
/// require a passphrase; without one we surface a clear error rather than
/// silently skipping (so a typo'd `--key` doesn't quietly fall back to
/// password auth).
fn load_identity(path: &Path, passphrase: Option<&str>) -> Result<ClientCredential> {
    let pem = std::fs::read_to_string(path)
        .map_err(|e| Error::Ssh(format!("reading identity {}: {e}", path.display())))?;
    let pass = passphrase.map(|p| p.as_bytes());
    let key = PrivateKey::parse_openssh_pem(&pem, pass).map_err(|e| {
        Error::Ssh(format!(
            "loading identity {}: {e} (encrypted keys need a passphrase via -u)",
            path.display()
        ))
    })?;
    let host_key = key.into_host_key().map_err(ssh_err)?;
    Ok(ClientCredential::PublicKey(host_key))
}

/// Assemble the credential list for `authenticate`, in try order: explicit
/// identity files (or discovered defaults), then password. Identity-load
/// failures on the *explicit* `--key` path are fatal; failures discovering
/// optional default keys are swallowed (a missing/encrypted default key just
/// means "skip it").
fn collect_credentials(opts: &SshOptions) -> Result<Vec<ClientCredential>> {
    let mut creds = Vec::new();
    if !opts.identity_files.is_empty() {
        // Explicit `--key`: a load error is the user's intent failing, so
        // surface it.
        for path in &opts.identity_files {
            creds.push(load_identity(path, opts.key_passphrase.as_deref())?);
        }
    } else {
        // Default keys: probe only the ones that exist, and tolerate a key we
        // can't load (e.g. encrypted with no passphrase available).
        for path in default_identity_files() {
            if let Ok(cred) = load_identity(&path, opts.key_passphrase.as_deref()) {
                creds.push(cred);
            }
        }
    }
    if let Some(pw) = &opts.password {
        creds.push(ClientCredential::Password(pw.clone()));
    }
    if creds.is_empty() {
        return Err(Error::Ssh(
            "no usable credentials: no identity key found and no password given".to_string(),
        ));
    }
    Ok(creds)
}

/// Connect to `url`'s host:port, verify the host key, and authenticate `user`.
/// Returns the ready [`Client`]. `trace` (if `Some`) receives `* `-prefixed
/// progress lines on the verbose path, mirroring the other protocols' style.
fn connect_auth(
    url: &Url,
    user: &str,
    opts: &SshOptions,
    mut trace: Option<&mut (dyn std::io::Write + '_)>,
) -> Result<Client> {
    reject_ctl(user, "ssh user")?;
    if let Some(t) = trace.as_mut() {
        let _ = writeln!(t, "* Trying {}:{}...", url.host, url.port);
    }
    let cfg = build_config(opts);
    let mut client = Client::connect_to_host(&url.host, url.port, cfg).map_err(ssh_err)?;
    if let Some(t) = trace.as_mut() {
        let _ = writeln!(t, "* SSH connected to {}:{}", url.host, url.port);
    }
    let creds = collect_credentials(opts)?;
    client.authenticate(user, creds).map_err(ssh_err)?;
    if let Some(t) = trace.as_mut() {
        let _ = writeln!(t, "* SSH authenticated as {user}");
    }
    Ok(client)
}

/// The remote path for SFTP/SCP: the URL path with a single leading `/`
/// preserved (SFTP paths are absolute from the server root). Empty path is an
/// error — there's no file to name.
fn remote_path<'a>(url: &'a Url, what: &str) -> Result<&'a str> {
    reject_ctl(&url.path, what)?;
    if url.path.is_empty() || url.path == "/" {
        return Err(Error::Ssh(format!("{what}: URL names no remote file")));
    }
    Ok(&url.path)
}

/// Download the file at `url.path`. For `sftp://` this opens+reads over the
/// SFTP subsystem; for `scp://` it bridges through a temp file. Returns the
/// raw bytes (the transfer layer writes them to `-o`/stdout).
pub fn fetch(url: &Url, opts: &SshOptions, user: &str) -> Result<Vec<u8>> {
    fetch_traced(url, opts, user, None)
}

/// [`fetch`] with an optional verbose trace sink.
pub fn fetch_traced(
    url: &Url,
    opts: &SshOptions,
    user: &str,
    mut trace: Option<&mut (dyn std::io::Write + '_)>,
) -> Result<Vec<u8>> {
    let path = remote_path(url, "sftp/scp path")?.to_string();
    let mut client = connect_auth(url, user, opts, trace.as_deref_mut())?;
    match url.scheme.as_str() {
        "sftp" => {
            let bytes = sftp_download(&mut client, &path)?;
            if let Some(t) = trace.as_mut() {
                let _ = writeln!(t, "* SFTP downloaded {} bytes", bytes.len());
            }
            Ok(bytes)
        }
        "scp" => {
            let bytes = scp_download(&mut client, &path)?;
            if let Some(t) = trace.as_mut() {
                let _ = writeln!(t, "* SCP downloaded {} bytes", bytes.len());
            }
            Ok(bytes)
        }
        other => Err(Error::UnsupportedScheme(other.to_string())),
    }
}

/// Upload `body` to `url.path`. `sftp://` writes over the SFTP subsystem;
/// `scp://` bridges through a temp file.
pub fn upload(url: &Url, body: &[u8], opts: &SshOptions, user: &str) -> Result<()> {
    upload_traced(url, body, opts, user, None)
}

/// [`upload`] with an optional verbose trace sink.
pub fn upload_traced(
    url: &Url,
    body: &[u8],
    opts: &SshOptions,
    user: &str,
    mut trace: Option<&mut (dyn std::io::Write + '_)>,
) -> Result<()> {
    let path = remote_path(url, "sftp/scp path")?.to_string();
    let mut client = connect_auth(url, user, opts, trace.as_deref_mut())?;
    match url.scheme.as_str() {
        "sftp" => {
            sftp_upload(&mut client, &path, body)?;
            if let Some(t) = trace.as_mut() {
                let _ = writeln!(t, "* SFTP uploaded {} bytes", body.len());
            }
            Ok(())
        }
        "scp" => {
            scp_upload(&mut client, &path, body)?;
            if let Some(t) = trace.as_mut() {
                let _ = writeln!(t, "* SCP uploaded {} bytes", body.len());
            }
            Ok(())
        }
        other => Err(Error::UnsupportedScheme(other.to_string())),
    }
}

/// SFTP download: open the remote path read-only and loop `read` (advancing
/// the offset) until a short/empty read signals EOF.
fn sftp_download(client: &mut Client, path: &str) -> Result<Vec<u8>> {
    let mut sftp = client.sftp().map_err(ssh_err)?;
    let handle = sftp
        .open(path.as_bytes(), FXF_READ, Attrs::default())
        .map_err(|e| Error::Ssh(format!("sftp open {path:?}: {e}")))?;
    let mut out = Vec::new();
    let mut offset: u64 = 0;
    loop {
        let chunk = sftp
            .read(&handle, offset, SFTP_CHUNK as u32)
            .map_err(|e| Error::Ssh(format!("sftp read {path:?}: {e}")))?;
        if chunk.is_empty() {
            break;
        }
        offset += chunk.len() as u64;
        out.extend_from_slice(&chunk);
        // A short read does not necessarily mean EOF in SFTP; only an empty
        // (EOF status) read does. Keep looping until the empty read above.
    }
    let _ = sftp.close(&handle);
    Ok(out)
}

/// SFTP upload: open `WRITE|CREAT|TRUNC` and stream `body` in chunks.
fn sftp_upload(client: &mut Client, path: &str, body: &[u8]) -> Result<()> {
    let mut sftp = client.sftp().map_err(ssh_err)?;
    let handle = sftp
        .open(
            path.as_bytes(),
            FXF_WRITE | FXF_CREAT | FXF_TRUNC,
            Attrs::default(),
        )
        .map_err(|e| Error::Ssh(format!("sftp open(w) {path:?}: {e}")))?;
    let mut offset: u64 = 0;
    for chunk in body.chunks(SFTP_CHUNK) {
        sftp.write(&handle, offset, chunk)
            .map_err(|e| Error::Ssh(format!("sftp write {path:?}: {e}")))?;
        offset += chunk.len() as u64;
    }
    sftp.close(&handle)
        .map_err(|e| Error::Ssh(format!("sftp close {path:?}: {e}")))?;
    Ok(())
}

/// A temp file that removes itself on drop, so the SCP bridge never leaves
/// stray files behind even on an early `?` return.
struct TempFile {
    path: PathBuf,
}

impl TempFile {
    /// Create a unique temp path under the system temp dir. Uses pid + a
    /// monotonically increasing counter for uniqueness without extra deps.
    fn new(tag: &str) -> Self {
        use std::sync::atomic::{AtomicU64, Ordering};
        static COUNTER: AtomicU64 = AtomicU64::new(0);
        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
        let mut path = std::env::temp_dir();
        path.push(format!("rsurl-scp-{}-{}-{}", std::process::id(), tag, n));
        TempFile { path }
    }
}

impl Drop for TempFile {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.path);
    }
}

/// SCP download: drive `scp -f` into a temp file, then read the bytes back.
/// The temp file is removed by [`TempFile`]'s `Drop`.
fn scp_download(client: &mut Client, path: &str) -> Result<Vec<u8>> {
    let tmp = TempFile::new("recv");
    // We're fetching a single file to a concrete local path, not into a dir.
    let opts = puressh::scp::ScpRecvOptions {
        target_is_file: true,
        ..Default::default()
    };
    client
        .scp_recv_from(path, &tmp.path, opts)
        .map_err(|e| Error::Ssh(format!("scp recv {path:?}: {e}")))?;
    let bytes = std::fs::read(&tmp.path)
        .map_err(|e| Error::Ssh(format!("scp recv: reading temp file: {e}")))?;
    Ok(bytes)
}

/// SCP upload: write `body` to a temp file, then `scp -t` it to the remote
/// path. The temp file is removed by [`TempFile`]'s `Drop`.
fn scp_upload(client: &mut Client, path: &str, body: &[u8]) -> Result<()> {
    let tmp = TempFile::new("send");
    std::fs::write(&tmp.path, body)
        .map_err(|e| Error::Ssh(format!("scp send: writing temp file: {e}")))?;
    let opts = puressh::scp::ScpSendOptions::default();
    let sources: [&Path; 1] = [tmp.path.as_path()];
    client
        .scp_send_to(&sources, path, opts)
        .map_err(|e| Error::Ssh(format!("scp send {path:?}: {e}")))?;
    Ok(())
}

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

    #[test]
    fn sftp_url_parses_with_userinfo_and_port() {
        let u = Url::parse("sftp://user@host:2222/path/to/file").unwrap();
        assert_eq!(u.scheme, "sftp");
        assert_eq!(u.userinfo.as_deref(), Some("user"));
        assert_eq!(u.host, "host");
        assert_eq!(u.port, 2222);
        assert_eq!(u.path, "/path/to/file");
    }

    #[test]
    fn scp_url_defaults_to_port_22() {
        let u = Url::parse("scp://host/path").unwrap();
        assert_eq!(u.scheme, "scp");
        assert_eq!(u.port, 22);
        assert_eq!(u.userinfo, None);
        assert_eq!(u.path, "/path");
    }

    #[test]
    fn sftp_url_userinfo_with_password() {
        let u = Url::parse("sftp://alice:secret@host/f").unwrap();
        let (user, pass) = split_userinfo(u.userinfo.as_deref());
        assert_eq!(user.as_deref(), Some("alice"));
        assert_eq!(pass.as_deref(), Some("secret"));
    }

    #[test]
    fn split_userinfo_variants() {
        assert_eq!(split_userinfo(None), (None, None));
        assert_eq!(split_userinfo(Some("bob")), (Some("bob".to_string()), None));
        assert_eq!(
            split_userinfo(Some("bob:pw")),
            (Some("bob".to_string()), Some("pw".to_string()))
        );
        // Empty password half is treated as "no password".
        assert_eq!(
            split_userinfo(Some("bob:")),
            (Some("bob".to_string()), None)
        );
    }

    #[test]
    fn resolve_user_prefers_url_then_cli_then_env() {
        let u = Url::parse("sftp://alice@host/f").unwrap();
        // URL userinfo wins over the CLI -u user.
        assert_eq!(resolve_user(&u, Some("bob")).unwrap(), "alice");

        // No URL user → CLI -u user.
        let u2 = Url::parse("sftp://host/f").unwrap();
        assert_eq!(resolve_user(&u2, Some("carol")).unwrap(), "carol");

        // No URL user, no CLI user → $USER. Set it deterministically.
        // SAFETY: single-threaded test; we restore nothing because the value
        // we set is what we assert on.
        unsafe { std::env::set_var("USER", "envuser") };
        assert_eq!(resolve_user(&u2, None).unwrap(), "envuser");
    }

    #[test]
    fn discover_default_keys_finds_existing_in_order() {
        let dir =
            std::env::temp_dir().join(format!("rsurl-ssh-keys-{}-{}", std::process::id(), "disc"));
        std::fs::create_dir_all(&dir).unwrap();
        // Create id_rsa and id_ed25519 but NOT id_ecdsa.
        std::fs::write(dir.join("id_rsa"), b"x").unwrap();
        std::fs::write(dir.join("id_ed25519"), b"x").unwrap();
        let found = discover_default_keys(&dir);
        let _ = std::fs::remove_dir_all(&dir);
        // Preference order: ed25519 before rsa; ecdsa absent.
        assert_eq!(found.len(), 2);
        assert!(found[0].ends_with("id_ed25519"));
        assert!(found[1].ends_with("id_rsa"));
    }

    #[test]
    fn discover_default_keys_empty_when_none() {
        let dir =
            std::env::temp_dir().join(format!("rsurl-ssh-keys-{}-{}", std::process::id(), "empty"));
        std::fs::create_dir_all(&dir).unwrap();
        let found = discover_default_keys(&dir);
        let _ = std::fs::remove_dir_all(&dir);
        assert!(found.is_empty());
    }

    #[test]
    fn remote_path_rejects_empty_and_root() {
        let u = Url::parse("sftp://host/").unwrap();
        assert!(matches!(remote_path(&u, "p"), Err(Error::Ssh(_))));
        let u2 = Url::parse("sftp://host/file").unwrap();
        assert_eq!(remote_path(&u2, "p").unwrap(), "/file");
    }

    #[test]
    fn reject_ctl_flags_control_bytes() {
        assert!(reject_ctl("alice", "ssh user").is_ok());
        assert!(reject_ctl("/a/b/c.txt", "ssh path").is_ok());
        assert!(reject_ctl("a\rb", "ssh user").is_err());
        assert!(reject_ctl("a\nb", "ssh path").is_err());
        assert!(reject_ctl("a\0b", "ssh user").is_err());
        assert!(reject_ctl("a\x7fb", "ssh user").is_err());
    }

    #[test]
    fn collect_credentials_password_only() {
        // No identity files on the opts, no default keys (point HOME away).
        // We can't easily clear default-key discovery here, so just assert
        // that a password is included when present.
        let opts = SshOptions {
            password: Some("pw".to_string()),
            identity_files: vec![],
            ..Default::default()
        };
        let creds = collect_credentials(&opts).unwrap();
        assert!(creds
            .iter()
            .any(|c| matches!(c, ClientCredential::Password(_))));
    }

    #[test]
    fn collect_credentials_errors_when_empty() {
        // No password and an explicit (nonexistent) identity → load error.
        let opts = SshOptions {
            password: None,
            identity_files: vec![PathBuf::from("/nonexistent/rsurl/id_test")],
            ..Default::default()
        };
        assert!(collect_credentials(&opts).is_err());
    }

    #[test]
    fn build_config_insecure_is_accept_any() {
        let opts = SshOptions {
            insecure: true,
            ..Default::default()
        };
        let cfg = build_config(&opts);
        assert!(matches!(cfg.host_key_policy, HostKeyPolicy::AcceptAny));
    }

    #[test]
    fn build_config_tofu_uses_known_hosts_policy() {
        let opts = SshOptions {
            insecure: false,
            known_hosts_path: Some(std::env::temp_dir().join("rsurl-kh-nonexistent")),
            ..Default::default()
        };
        let cfg = build_config(&opts);
        match cfg.host_key_policy {
            HostKeyPolicy::KnownHosts(p) => {
                assert!(matches!(p.on_unknown, TofuAction::Accept));
                assert!(matches!(p.on_mismatch, TofuAction::Reject));
                assert!(p.save_path.is_some());
            }
            _ => panic!("expected KnownHosts policy"),
        }
    }

    #[test]
    fn scp_recv_options_target_is_file() {
        // The SCP download bridge sets `target_is_file` so puressh writes the
        // single remote file to our concrete temp path rather than into a dir.
        let opts = puressh::scp::ScpRecvOptions {
            target_is_file: true,
            ..Default::default()
        };
        assert!(opts.target_is_file);
        assert!(!opts.recursive);
    }

    #[test]
    fn temp_file_removed_on_drop() {
        let path;
        {
            let tmp = TempFile::new("droptest");
            path = tmp.path.clone();
            std::fs::write(&tmp.path, b"data").unwrap();
            assert!(path.exists());
        }
        assert!(!path.exists(), "temp file should be removed on drop");
    }
}