Skip to main content

strop_remote/
address.rs

1//! Validated remote-file locations (0034 "Implementation boundaries" 2):
2//! `ssh://[user@]host[:port]/absolute/path` as a pure URI domain.
3//!
4//! Parsing is the only place remote identity is textual. Every field is
5//! admitted here — before any OpenSSH subprocess exists — so a
6//! [`RemoteFile`] can never carry an option-shaped endpoint, a password,
7//! a query, a fragment or a control byte toward `ssh` argv. The bounded
8//! grammar needs no URL crate: aliases and IPv4 reuse `std` parsing,
9//! bracketed IPv6 reuses `std` validation. Paths decode to native
10//! filename bytes — Unix keeps arbitrary non-NUL bytes, other platforms
11//! require UTF-8 rather than a lossy stand-in. `Display` renders the
12//! canonical URI (necessary percent escapes only) and serde round-trips
13//! through that same string, so deserialized values are re-validated,
14//! never trusted fields. Identity is the decoded endpoint: spellings
15//! that decode to the same bytes, port or address are one value, while a
16//! decoded path is never trimmed or normalized — `.`, `..` and `//`
17//! belong to the remote host.
18
19use std::num::NonZeroU16;
20use std::path::{Path, PathBuf};
21
22use serde::de::Error as _;
23use serde::{Deserialize, Deserializer, Serialize, Serializer};
24
25/// A validated `ssh://[user@]host[:port]/absolute/path` location.
26///
27/// Private fields leave [`RemoteFile::parse`] as the only constructor, so
28/// every value — including deserialized ones — passed the same admission.
29#[derive(Debug, Clone, Eq)]
30pub struct RemoteFile {
31    user: Option<String>,
32    /// Alias/hostname verbatim, or the canonical unbracketed IP form.
33    host: String,
34    port: Option<NonZeroU16>,
35    path: PathBuf,
36}
37
38impl PartialEq for RemoteFile {
39    fn eq(&self, other: &Self) -> bool {
40        (&self.user, &self.host, self.port, self.path.as_os_str())
41            == (&other.user, &other.host, other.port, other.path.as_os_str())
42    }
43}
44impl std::hash::Hash for RemoteFile {
45    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
46        (&self.user, &self.host, self.port, self.path.as_os_str()).hash(state);
47    }
48}
49
50/// Why a textual remote location was refused. Every variant is a pure
51/// admission failure; none implies a subprocess was ever started.
52#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
53pub enum AddressError {
54    #[error("remote locations use the form `ssh://[user@]host[:port]/absolute/path`")]
55    NotSshUri,
56    #[error(
57        "SSH locations cannot carry a password (`user:pass@`); authenticate with keys or an agent instead"
58    )]
59    PasswordInUri,
60    #[error("empty username before `@`")]
61    EmptyUser,
62    #[error("host is empty")]
63    EmptyHost,
64    #[error("port must be 1-65535 written in digits, e.g. `:2222`")]
65    InvalidPort,
66    #[error(
67        "refusing an option-shaped username/host (leading `-` or any `=`): SSH would read it as an option"
68    )]
69    OptionShapedEndpoint,
70    #[error("IPv6 hosts need brackets, e.g. `ssh://[2001:db8::1]:22/var/log/app.log`")]
71    UnbracketedIpv6,
72    #[error("bracketed IPv6 host is missing its closing `]`")]
73    UnclosedIpv6,
74    #[error("only `:port` may follow a bracketed IPv6 host")]
75    JunkAfterIpv6,
76    #[error("{literal:?} is not a valid IPv6 address")]
77    InvalidIpv6 { literal: String },
78    #[error("{literal:?} looks like an IPv4 address but is not a valid one")]
79    InvalidIpv4 { literal: String },
80    #[error(
81        "remote path is missing: the location must end in an absolute path like `/var/log/app.log`"
82    )]
83    AbsentPath,
84    #[error(
85        "SSH locations carry no query string or fragment; percent-encode those bytes in the path instead (`?` -> `%3F`, `#` -> `%23`)"
86    )]
87    QueryOrFragment,
88    #[error("percent escapes need two hexadecimal digits, e.g. `%20`")]
89    MalformedPercentEscape,
90    #[error("path cannot contain a NUL byte")]
91    NulInPath,
92    #[error("path contains a byte that must be percent-encoded (a space is `%20`)")]
93    UnencodedPathByte,
94    #[cfg(not(unix))]
95    #[error("the decoded path bytes are not representable as a native filename on this platform")]
96    UnrepresentablePath,
97    #[error("username/host may only contain ASCII letters, digits and `-._+`")]
98    InvalidAuthorityCharacter,
99}
100
101impl RemoteFile {
102    /// Admit one textual location. Pure: no filesystem, no subprocess.
103    pub fn parse(value: &str) -> Result<Self, AddressError> {
104        let rest = value
105            .strip_prefix("ssh://")
106            .ok_or(AddressError::NotSshUri)?;
107        let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
108        let (user, host_port) = split_user(&rest[..authority_end])?;
109        let (host, port) = split_host(host_port)?;
110        let tail = &rest[authority_end..];
111        let path_text = match tail.as_bytes().first() {
112            None => return Err(AddressError::AbsentPath),
113            Some(b'/') => tail,
114            Some(_) => return Err(AddressError::QueryOrFragment),
115        };
116        if path_text.contains(['?', '#']) {
117            return Err(AddressError::QueryOrFragment);
118        }
119        let path = decode_path(path_text)?;
120        Ok(Self {
121            user,
122            host,
123            port,
124            path,
125        })
126    }
127
128    /// Hostname, alias, or the canonical unbracketed IPv4/IPv6 form.
129    pub fn host(&self) -> &str {
130        &self.host
131    }
132
133    pub fn user(&self) -> Option<&str> {
134        self.user.as_deref()
135    }
136
137    pub fn port(&self) -> Option<NonZeroU16> {
138        self.port
139    }
140
141    /// The decoded remote path. A remote POSIX path, not a local one.
142    pub fn path(&self) -> &Path {
143        &self.path
144    }
145
146    /// The canonical URI: this is the identity replayed, traced and shown.
147    fn canonical(&self) -> String {
148        use std::fmt::Write as _;
149        let mut out = String::with_capacity(
150            "ssh://".len()
151                + self.host.len()
152                + self.user.as_deref().map_or(0, str::len)
153                + 6
154                + path_bytes(&self.path).len().saturating_mul(3),
155        );
156        out.push_str("ssh://");
157        if let Some(user) = &self.user {
158            out.push_str(user);
159            out.push('@');
160        }
161        if self.host.contains(':') {
162            out.push('[');
163            out.push_str(&self.host);
164            out.push(']');
165        } else {
166            out.push_str(&self.host);
167        }
168        if let Some(port) = self.port {
169            let _ = write!(out, ":{port}");
170        }
171        for &byte in path_bytes(&self.path) {
172            if is_raw_path_byte(byte) {
173                out.push(byte as char);
174            } else {
175                out.push('%');
176                out.push(HEX[(byte >> 4) as usize] as char);
177                out.push(HEX[(byte & 0x0F) as usize] as char);
178            }
179        }
180        out
181    }
182}
183
184impl std::fmt::Display for RemoteFile {
185    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        formatter.write_str(&self.canonical())
187    }
188}
189
190/// Serde carries the canonical validated URI — one string, re-parsed on
191/// the way in, so stored values can never bypass admission.
192impl Serialize for RemoteFile {
193    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
194        serializer.serialize_str(&self.canonical())
195    }
196}
197
198impl<'de> Deserialize<'de> for RemoteFile {
199    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
200        let text = String::deserialize(deserializer)?;
201        RemoteFile::parse(&text).map_err(D::Error::custom)
202    }
203}
204
205const HEX: &[u8; 16] = b"0123456789ABCDEF";
206
207fn split_user(authority: &str) -> Result<(Option<String>, &str), AddressError> {
208    let Some(at) = authority.find('@') else {
209        return Ok((None, authority));
210    };
211    let (user, host_port) = (&authority[..at], &authority[at + 1..]);
212    if host_port.contains('@') {
213        return Err(AddressError::InvalidAuthorityCharacter);
214    }
215    if user.is_empty() {
216        return Err(AddressError::EmptyUser);
217    }
218    if user.contains(':') {
219        return Err(AddressError::PasswordInUri);
220    }
221    check_endpoint_token(user)?;
222    Ok((Some(user.to_owned()), host_port))
223}
224
225fn split_host(host_port: &str) -> Result<(String, Option<NonZeroU16>), AddressError> {
226    if let Some(bracketed) = host_port.strip_prefix('[') {
227        let close = bracketed.find(']').ok_or(AddressError::UnclosedIpv6)?;
228        let inner = &bracketed[..close];
229        let after = &bracketed[close + 1..];
230        let port = if after.is_empty() {
231            None
232        } else if let Some(digits) = after.strip_prefix(':') {
233            Some(parse_port(digits)?)
234        } else {
235            return Err(AddressError::JunkAfterIpv6);
236        };
237        let address: std::net::Ipv6Addr = inner.parse().map_err(|_| AddressError::InvalidIpv6 {
238            literal: inner.to_owned(),
239        })?;
240        return Ok((address.to_string(), port));
241    }
242    if host_port.matches(':').count() > 1 {
243        return Err(AddressError::UnbracketedIpv6);
244    }
245    let (host, port) = match host_port.split_once(':') {
246        Some((host, digits)) => (host, Some(parse_port(digits)?)),
247        None => (host_port, None),
248    };
249    if host.is_empty() {
250        return Err(AddressError::EmptyHost);
251    }
252    // Digits-and-dots is IPv4-shaped: admit it only as a real address,
253    // so `01.2.3.4` or `999.1.1.1` cannot reach getaddrinfo as an alias.
254    if host.bytes().all(|b| b.is_ascii_digit() || b == b'.') {
255        let address: std::net::Ipv4Addr = host.parse().map_err(|_| AddressError::InvalidIpv4 {
256            literal: host.to_owned(),
257        })?;
258        return Ok((address.to_string(), port));
259    }
260    check_endpoint_token(host)?;
261    Ok((host.to_owned(), port))
262}
263
264fn check_endpoint_token(token: &str) -> Result<(), AddressError> {
265    if token.starts_with('-') || token.contains('=') {
266        return Err(AddressError::OptionShapedEndpoint);
267    }
268    if !token.bytes().all(is_endpoint_byte) {
269        return Err(AddressError::InvalidAuthorityCharacter);
270    }
271    Ok(())
272}
273
274fn is_endpoint_byte(byte: u8) -> bool {
275    // OpenSSH config can interpolate %h/%r in ProxyCommand/Match exec. Keep
276    // shell metacharacters out even when an older ssh accepts them as argv.
277    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'+')
278}
279
280fn parse_port(digits: &str) -> Result<NonZeroU16, AddressError> {
281    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
282        return Err(AddressError::InvalidPort);
283    }
284    match digits.parse::<u16>() {
285        Ok(value) => NonZeroU16::new(value).ok_or(AddressError::InvalidPort),
286        Err(_) => Err(AddressError::InvalidPort),
287    }
288}
289
290/// Decode the path region to native filename bytes. Raw bytes outside the
291/// URI path grammar are refused (percent-encode them); raw non-ASCII
292/// UTF-8 passes through as data. Remote metacharacters are never special.
293fn decode_path(text: &str) -> Result<PathBuf, AddressError> {
294    let bytes = text.as_bytes();
295    let mut out = Vec::with_capacity(bytes.len());
296    let mut i = 0;
297    while i < bytes.len() {
298        let byte = bytes[i];
299        if byte == b'%' {
300            let high = bytes.get(i + 1).copied().filter(|b| b.is_ascii_hexdigit());
301            let low = bytes.get(i + 2).copied().filter(|b| b.is_ascii_hexdigit());
302            match (high, low) {
303                (Some(high), Some(low)) => {
304                    out.push((hex_value(high) << 4) | hex_value(low));
305                    i += 3;
306                }
307                _ => return Err(AddressError::MalformedPercentEscape),
308            }
309        } else if byte == 0 {
310            return Err(AddressError::NulInPath);
311        } else if is_raw_path_byte(byte) || byte >= 0x80 {
312            out.push(byte);
313            i += 1;
314        } else {
315            return Err(AddressError::UnencodedPathByte);
316        }
317    }
318    if out.contains(&0) {
319        return Err(AddressError::NulInPath);
320    }
321    bytes_to_path(out)
322}
323
324/// Caller checked `is_ascii_hexdigit`.
325fn hex_value(byte: u8) -> u8 {
326    match byte {
327        b'0'..=b'9' => byte - b'0',
328        b'a'..=b'f' => byte - b'a' + 10,
329        b'A'..=b'F' => byte - b'A' + 10,
330        _ => unreachable!("hex_value called on a non-hex byte"),
331    }
332}
333
334/// RFC 3986 pchar plus `/`: bytes a canonical URI carries raw.
335fn is_raw_path_byte(byte: u8) -> bool {
336    matches!(
337        byte,
338        b'a'..=b'z'
339            | b'A'..=b'Z'
340            | b'0'..=b'9'
341            | b'-'
342            | b'.'
343            | b'_'
344            | b'~'
345            | b'!'
346            | b'$'
347            | b'&'
348            | b'\''
349            | b'('
350            | b')'
351            | b'*'
352            | b'+'
353            | b','
354            | b';'
355            | b'='
356            | b':'
357            | b'@'
358            | b'/'
359    )
360}
361
362#[cfg(unix)]
363fn bytes_to_path(bytes: Vec<u8>) -> Result<PathBuf, AddressError> {
364    use std::ffi::OsString;
365    use std::os::unix::ffi::OsStringExt;
366    Ok(PathBuf::from(OsString::from_vec(bytes)))
367}
368
369#[cfg(unix)]
370fn path_bytes(path: &Path) -> &[u8] {
371    use std::os::unix::ffi::OsStrExt;
372    path.as_os_str().as_bytes()
373}
374
375/// Platforms without byte filenames get strict UTF-8, never a lossy
376/// replacement character that could name the wrong remote file.
377#[cfg(not(unix))]
378fn bytes_to_path(bytes: Vec<u8>) -> Result<PathBuf, AddressError> {
379    let text = String::from_utf8(bytes).map_err(|_| AddressError::UnrepresentablePath)?;
380    Ok(PathBuf::from(text))
381}
382
383#[cfg(not(unix))]
384fn path_bytes(path: &Path) -> &[u8] {
385    // Built only from strict UTF-8 here, so this is the exact path text.
386    path.as_os_str().as_encoded_bytes()
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    fn ok(uri: &str) -> RemoteFile {
394        RemoteFile::parse(uri).unwrap_or_else(|error| panic!("expected parse: {uri}: {error}"))
395    }
396
397    fn refused(uri: &str) -> AddressError {
398        RemoteFile::parse(uri).unwrap_err()
399    }
400
401    #[test]
402    fn alias_with_user_and_port() {
403        let file = ok("ssh://dev@bbgithub:2222/var/log/app.log");
404        assert_eq!(file.user(), Some("dev"));
405        assert_eq!(file.host(), "bbgithub");
406        assert_eq!(file.port(), NonZeroU16::new(2222));
407        assert_eq!(file.path(), Path::new("/var/log/app.log"));
408        assert_eq!(file.to_string(), "ssh://dev@bbgithub:2222/var/log/app.log");
409    }
410
411    #[test]
412    fn aliases_keep_their_spelling() {
413        // Preserve the chosen alias spelling; this identity layer does not
414        // infer equivalence between OpenSSH configuration targets.
415        assert_ne!(ok("ssh://Box/x"), ok("ssh://box/x"));
416        assert_eq!(ok("ssh://dev_01.a/x").host(), "dev_01.a");
417    }
418
419    #[test]
420    fn ipv6_brackets_canonicalize_and_roundtrip() {
421        let file = ok("ssh://root@[2001:0db8:0000::0001]:22/var/log/a.log");
422        assert_eq!(file.user(), Some("root"));
423        assert_eq!(file.host(), "2001:db8::1");
424        assert_eq!(file.port(), NonZeroU16::new(22));
425        assert_eq!(
426            file.to_string(),
427            "ssh://root@[2001:db8::1]:22/var/log/a.log"
428        );
429        assert_eq!(
430            file,
431            ok("ssh://root@[2001:db8:0:0:0:0:0:1]:0022/var/log/a.log")
432        );
433        let bare = ok("ssh://[::1]/x");
434        assert_eq!(bare.host(), "::1");
435        assert_eq!(bare.port(), None);
436        assert_eq!(bare.to_string(), "ssh://[::1]/x");
437    }
438
439    #[test]
440    fn ipv6_hostility() {
441        assert!(matches!(
442            refused("ssh://fe80::1/x"),
443            AddressError::UnbracketedIpv6
444        ));
445        assert!(matches!(
446            refused("ssh://user@::1/x"),
447            AddressError::UnbracketedIpv6
448        ));
449        assert!(matches!(
450            refused("ssh://[::1/x"),
451            AddressError::UnclosedIpv6
452        ));
453        assert!(matches!(
454            refused("ssh://[::1]extra/x"),
455            AddressError::JunkAfterIpv6
456        ));
457        assert!(matches!(
458            refused("ssh://[zz]/x"),
459            AddressError::InvalidIpv6 { .. }
460        ));
461    }
462
463    #[test]
464    fn ipv4_shapes_must_be_addresses() {
465        assert_eq!(ok("ssh://127.0.0.1/x").host(), "127.0.0.1");
466        for uri in [
467            "ssh://999.1.1.1/x",
468            "ssh://1.2.3.4.5/x",
469            "ssh://1.2.3/x",
470            "ssh://0/x",
471        ] {
472            assert!(
473                matches!(refused(uri), AddressError::InvalidIpv4 { .. }),
474                "{uri}"
475            );
476        }
477    }
478
479    #[test]
480    fn port_boundaries() {
481        assert_eq!(ok("ssh://h:022/x").port(), NonZeroU16::new(22));
482        for uri in [
483            "ssh://h:0/x",
484            "ssh://h:/x",
485            "ssh://h:65536/x",
486            "ssh://h:notaport/x",
487            "ssh://h:+2/x",
488            "ssh://h: 2/x",
489        ] {
490            assert!(matches!(refused(uri), AddressError::InvalidPort), "{uri}");
491        }
492    }
493
494    #[test]
495    fn hostile_authorities() {
496        assert!(matches!(
497            refused("ssh://user:secret@host/x"),
498            AddressError::PasswordInUri
499        ));
500        assert!(matches!(
501            refused("ssh://-oProxyCommand=evil@host/x"),
502            AddressError::OptionShapedEndpoint
503        ));
504        assert!(matches!(
505            refused("ssh://name=-x@host/x"),
506            AddressError::OptionShapedEndpoint
507        ));
508        assert!(matches!(
509            refused("ssh://-flag/x"),
510            AddressError::OptionShapedEndpoint
511        ));
512        assert!(matches!(refused("ssh://@host/x"), AddressError::EmptyUser));
513        assert!(matches!(refused("ssh://h@/x"), AddressError::EmptyHost));
514        assert!(matches!(refused("ssh:///x"), AddressError::EmptyHost));
515        assert!(matches!(
516            refused("ssh://a@b@c/x"),
517            AddressError::InvalidAuthorityCharacter
518        ));
519        assert!(matches!(
520            refused("ssh://ho%st/x"),
521            AddressError::InvalidAuthorityCharacter
522        ));
523        assert!(matches!(
524            refused("ssh://ho st/x"),
525            AddressError::InvalidAuthorityCharacter
526        ));
527        for uri in ["host/x", "scp://host/x", "", "SSH://host/x"] {
528            assert!(matches!(refused(uri), AddressError::NotSshUri), "{uri}");
529        }
530    }
531
532    #[test]
533    fn absent_path_is_refused() {
534        for uri in ["ssh://host", "ssh://host:22"] {
535            assert!(matches!(refused(uri), AddressError::AbsentPath), "{uri}");
536        }
537    }
538
539    #[test]
540    fn query_and_fragment_are_refused() {
541        for uri in [
542            "ssh://h/a?b",
543            "ssh://h/a#b",
544            "ssh://h?q",
545            "ssh://h#f",
546            "ssh://h/a%3Fb?c",
547        ] {
548            assert!(
549                matches!(refused(uri), AddressError::QueryOrFragment),
550                "{uri}"
551            );
552        }
553    }
554
555    #[test]
556    fn percent_decoding_and_canonical_display() {
557        let file = ok("ssh://h/var%20log/a%23b%25c%3Fd");
558        assert_eq!(file.path(), Path::new("/var log/a#b%c?d"));
559        assert_eq!(file.to_string(), "ssh://h/var%20log/a%23b%25c%3Fd");
560        assert_eq!(file, ok(&file.to_string()));
561    }
562
563    #[test]
564    fn malformed_percent_and_nul() {
565        for uri in [
566            "ssh://h/%",
567            "ssh://h/%2",
568            "ssh://h/%G1",
569            "ssh://h/a%zz",
570            "ssh://h/a%2G",
571        ] {
572            assert!(
573                matches!(refused(uri), AddressError::MalformedPercentEscape),
574                "{uri}"
575            );
576        }
577        assert!(matches!(refused("ssh://h/%00"), AddressError::NulInPath));
578        assert!(matches!(
579            refused("ssh://h/a\u{0}b"),
580            AddressError::NulInPath
581        ));
582    }
583
584    #[test]
585    fn shell_metacharacters_are_path_data() {
586        let uri = "ssh://h/log/$x&(rm);'q'*+,.~!@:x";
587        let file = ok(uri);
588        assert_eq!(file.path(), Path::new("/log/$x&(rm);'q'*+,.~!@:x"));
589        assert_eq!(file.to_string(), uri);
590    }
591
592    #[test]
593    fn unencodable_raw_bytes_are_refused() {
594        for uri in [
595            "ssh://h/a b",
596            "ssh://h/a`b",
597            "ssh://h/a\"b",
598            "ssh://h/a\x01b",
599            "ssh://h/a\x7fb",
600            "ssh://h/a\\b",
601        ] {
602            assert!(
603                matches!(refused(uri), AddressError::UnencodedPathByte),
604                "{uri}"
605            );
606        }
607    }
608
609    #[test]
610    fn unicode_path_data_roundtrips_through_escapes() {
611        let file = ok("ssh://h/日本語-légère.log");
612        assert_eq!(file.path(), Path::new("/日本語-légère.log"));
613        let displayed = file.to_string();
614        assert_eq!(
615            displayed,
616            "ssh://h/%E6%97%A5%E6%9C%AC%E8%AA%9E-l%C3%A9g%C3%A8re.log"
617        );
618        assert_eq!(ok(&displayed), file);
619    }
620
621    #[test]
622    fn paths_are_never_trimmed_or_normalized() {
623        let file = ok("ssh://h/a/../b//c/./d/");
624        assert_eq!(file.path(), Path::new("/a/../b//c/./d/"));
625        assert_eq!(file.to_string(), "ssh://h/a/../b//c/./d/");
626        assert_eq!(ok("ssh://h/").path(), Path::new("/"));
627    }
628
629    #[test]
630    fn identity_follows_the_decoded_endpoint() {
631        assert_eq!(ok("ssh://h/%61%62"), ok("ssh://h/ab"));
632        assert_eq!(ok("ssh://h/a%2Fb"), ok("ssh://h/a/b"));
633        assert_eq!(ok("ssh://h:022/x"), ok("ssh://h:22/x"));
634        assert_eq!(ok("ssh://[::1]/x"), ok("ssh://[0:0:0:0:0:0:0:1]/x"));
635        assert_ne!(ok("ssh://h/log"), ok("ssh://h/log/"));
636        assert_ne!(ok("ssh://h/a/b"), ok("ssh://h/a//b"));
637        let mut seen = std::collections::HashSet::new();
638        seen.insert(ok("ssh://h:022/x"));
639        seen.insert(ok("ssh://h:22/x"));
640        seen.insert(ok("ssh://[::1]/x"));
641        seen.insert(ok("ssh://[0::1]/x"));
642        assert_eq!(seen.len(), 2);
643    }
644
645    #[cfg(unix)]
646    #[test]
647    fn native_filename_bytes_roundtrip() {
648        use std::os::unix::ffi::OsStrExt;
649        let file = ok("ssh://h/l%FCg%FF");
650        assert_eq!(file.path().as_os_str().as_bytes(), b"/l\xFCg\xFF");
651        assert_eq!(file.to_string(), "ssh://h/l%FCg%FF");
652        assert_eq!(ok(&file.to_string()), file);
653    }
654
655    #[cfg(not(unix))]
656    #[test]
657    fn unrepresentable_native_bytes_are_refused() {
658        assert!(matches!(
659            refused("ssh://h/l%FCg"),
660            AddressError::UnrepresentablePath
661        ));
662    }
663
664    #[test]
665    fn serde_roundtrips_the_canonical_uri() {
666        let file = ok("ssh://dev@box:2222/var%20log/a.log");
667        let json = serde_json::to_string(&file).expect("serialize");
668        assert_eq!(json, "\"ssh://dev@box:2222/var%20log/a.log\"");
669        assert_eq!(
670            serde_json::from_str::<RemoteFile>(&json).expect("deserialize"),
671            file
672        );
673    }
674
675    #[test]
676    fn serde_rejects_invalid_values() {
677        assert!(serde_json::from_str::<RemoteFile>("\"ssh://h/%zz\"").is_err());
678        assert!(serde_json::from_str::<RemoteFile>("\"/local/path\"").is_err());
679        // Field-shaped values cannot bypass the URI admission either.
680        let shaped = serde_json::json!({"host": "h", "path": "/x"});
681        assert!(serde_json::from_value::<RemoteFile>(shaped).is_err());
682    }
683}