Skip to main content

strop_remote/address/
file.rs

1//! A canonical remote file: one validated endpoint plus an absolute native
2//! path. This is the identity buffers, diagnostics and replay carry — it is
3//! never an unresolved home query, so a `~`-leading first component can only
4//! be the literal tilde directory, and the canonical URI keeps it escaped
5//! (`%7E`) so re-parsing cannot silently turn it into a home query.
6
7use super::endpoint::RemoteEndpoint;
8use super::error::AddressError;
9use super::uri::{self, path_bytes};
10use std::path::{Path, PathBuf};
11
12/// A canonical absolute remote file on one endpoint.
13#[derive(Debug, Clone)]
14pub struct RemoteFile {
15    endpoint: RemoteEndpoint,
16    path: PathBuf,
17}
18
19impl RemoteFile {
20    /// Admit one canonical file location `ssh://[user@]host[:port]/abs/path`.
21    /// A home query (`/~/…`) is refused: it is not canonical until a session
22    /// expands it. Pure: no filesystem, no subprocess.
23    pub fn parse(value: &str) -> Result<Self, AddressError> {
24        let (endpoint, tail) = RemoteEndpoint::split_authority(value)?;
25        match tail.as_bytes().first() {
26            None => return Err(AddressError::AbsentPath),
27            Some(b'/') => {}
28            Some(_) => return Err(AddressError::QueryOrFragment),
29        }
30        if tail.contains(['?', '#']) {
31            return Err(AddressError::QueryOrFragment);
32        }
33        if tail.as_bytes().get(1) == Some(&b'~') {
34            return Err(AddressError::UnresolvedHome);
35        }
36        let path = uri::decode_path(tail)?;
37        Self::from_path(endpoint, path)
38    }
39
40    /// The endpoint this file lives on — the pooled-session identity.
41    pub fn endpoint(&self) -> &RemoteEndpoint {
42        &self.endpoint
43    }
44
45    /// The decoded remote path. A remote POSIX path, not a local one.
46    pub fn path(&self) -> &Path {
47        &self.path
48    }
49
50    /// The same endpoint addressing a different absolute native path:
51    /// navigating a directory entry, a resolved home, a Git target.
52    /// The path is admitted (absolute, no NUL, natively representable);
53    /// it is never trimmed or normalized.
54    pub fn with_path(&self, path: PathBuf) -> Result<Self, AddressError> {
55        Self::from_path(self.endpoint.clone(), path)
56    }
57
58    /// Shared checked constructor: endpoint + already-native path bytes.
59    pub fn from_path(endpoint: RemoteEndpoint, path: PathBuf) -> Result<Self, AddressError> {
60        if !path.is_absolute() {
61            return Err(AddressError::RelativePath);
62        }
63        if path_bytes(&path).contains(&0) {
64            return Err(AddressError::NulInPath);
65        }
66        Ok(Self { endpoint, path })
67    }
68
69    /// The canonical URI: this is the identity replayed, traced and shown.
70    fn canonical(&self) -> String {
71        let bytes = path_bytes(&self.path);
72        let mut out = String::with_capacity(
73            self.endpoint.to_string().len() + bytes.len().saturating_mul(3) + 3,
74        );
75        out.push_str(&self.endpoint.to_string());
76        // A canonical file is never a home query: a literal `~` naming the
77        // first component stays escaped so the URI re-parses as this file.
78        if bytes.get(1) == Some(&b'~') {
79            out.push_str("/%7E");
80            uri::push_escaped(&mut out, &bytes[2..]);
81        } else {
82            uri::push_escaped(&mut out, bytes);
83        }
84        out
85    }
86}
87
88impl PartialEq for RemoteFile {
89    fn eq(&self, other: &Self) -> bool {
90        self.endpoint == other.endpoint && self.path.as_os_str() == other.path.as_os_str()
91    }
92}
93impl Eq for RemoteFile {}
94impl std::hash::Hash for RemoteFile {
95    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
96        self.endpoint.hash(state);
97        self.path.as_os_str().hash(state);
98    }
99}
100
101impl From<RemoteFile> for RemoteEndpoint {
102    fn from(file: RemoteFile) -> Self {
103        file.endpoint
104    }
105}
106
107impl std::fmt::Display for RemoteFile {
108    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        formatter.write_str(&self.canonical())
110    }
111}
112
113/// Serde carries the canonical validated URI — one string, re-parsed on
114/// the way in, so stored values can never bypass admission.
115impl serde::Serialize for RemoteFile {
116    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
117        serializer.serialize_str(&self.canonical())
118    }
119}
120
121impl<'de> serde::Deserialize<'de> for RemoteFile {
122    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
123        let text = String::deserialize(deserializer)?;
124        RemoteFile::parse(&text).map_err(serde::de::Error::custom)
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::collections::HashSet;
132    use std::num::NonZeroU16;
133
134    fn ok(uri: &str) -> RemoteFile {
135        RemoteFile::parse(uri).unwrap_or_else(|error| panic!("expected parse: {uri}: {error}"))
136    }
137
138    fn refused(uri: &str) -> AddressError {
139        RemoteFile::parse(uri).unwrap_err()
140    }
141
142    #[test]
143    fn fields_come_from_the_endpoint_and_the_decoded_path() {
144        let file = ok("ssh://dev@bbgithub:2222/var/log/app.log");
145        assert_eq!(file.endpoint().host(), "bbgithub");
146        assert_eq!(file.endpoint().user(), Some("dev"));
147        assert_eq!(file.endpoint().port(), NonZeroU16::new(2222));
148        assert_eq!(file.path(), Path::new("/var/log/app.log"));
149        assert_eq!(file.to_string(), "ssh://dev@bbgithub:2222/var/log/app.log");
150    }
151
152    #[test]
153    fn a_home_query_is_not_canonical() {
154        for uri in ["ssh://h/~/log", "ssh://h/~", "ssh://h/~alice/x"] {
155            assert!(
156                matches!(refused(uri), AddressError::UnresolvedHome),
157                "{uri}"
158            );
159        }
160    }
161
162    #[test]
163    fn with_path_keeps_the_endpoint_and_checks_admission() {
164        let file = ok("ssh://dev@box:2222/var/log/app.log");
165        let moved = file
166            .with_path(PathBuf::from("/var/log/rotated.log"))
167            .expect("absolute path");
168        assert_eq!(moved.endpoint(), file.endpoint());
169        assert_eq!(moved.path(), Path::new("/var/log/rotated.log"));
170        assert_eq!(moved.to_string(), "ssh://dev@box:2222/var/log/rotated.log");
171        assert!(matches!(
172            file.with_path(PathBuf::from("relative/log")),
173            Err(AddressError::RelativePath)
174        ));
175    }
176
177    #[test]
178    fn absent_path_and_query_are_refused() {
179        for uri in ["ssh://host", "ssh://host:22"] {
180            assert!(matches!(refused(uri), AddressError::AbsentPath), "{uri}");
181        }
182        for uri in [
183            "ssh://h/a?b",
184            "ssh://h/a#b",
185            "ssh://h?q",
186            "ssh://h#f",
187            "ssh://h/a%3Fb?c",
188        ] {
189            assert!(
190                matches!(refused(uri), AddressError::QueryOrFragment),
191                "{uri}"
192            );
193        }
194    }
195
196    #[test]
197    fn percent_decoding_and_canonical_display() {
198        let file = ok("ssh://h/var%20log/a%23b%25c%3Fd");
199        assert_eq!(file.path(), Path::new("/var log/a#b%c?d"));
200        assert_eq!(file.to_string(), "ssh://h/var%20log/a%23b%25c%3Fd");
201        assert_eq!(file, ok(&file.to_string()));
202    }
203
204    #[test]
205    fn malformed_percent_and_nul() {
206        for uri in [
207            "ssh://h/%",
208            "ssh://h/%2",
209            "ssh://h/%G1",
210            "ssh://h/a%zz",
211            "ssh://h/a%2G",
212        ] {
213            assert!(
214                matches!(refused(uri), AddressError::MalformedPercentEscape),
215                "{uri}"
216            );
217        }
218        assert!(matches!(refused("ssh://h/%00"), AddressError::NulInPath));
219        assert!(matches!(
220            refused("ssh://h/a\u{0}b"),
221            AddressError::NulInPath
222        ));
223    }
224
225    #[test]
226    fn shell_metacharacters_are_path_data() {
227        let uri = "ssh://h/log/$x&(rm);'q'*+,.~!@:x";
228        let file = ok(uri);
229        assert_eq!(file.path(), Path::new("/log/$x&(rm);'q'*+,.~!@:x"));
230        assert_eq!(file.to_string(), uri);
231    }
232
233    #[test]
234    fn unencodable_raw_bytes_are_refused() {
235        for uri in [
236            "ssh://h/a b",
237            "ssh://h/a`b",
238            "ssh://h/a\"b",
239            "ssh://h/a\x01b",
240            "ssh://h/a\x7fb",
241            "ssh://h/a\\b",
242        ] {
243            assert!(
244                matches!(refused(uri), AddressError::UnencodedPathByte),
245                "{uri}"
246            );
247        }
248    }
249
250    #[test]
251    fn unicode_path_data_roundtrips_through_escapes() {
252        let file = ok("ssh://h/日本語-légère.log");
253        assert_eq!(file.path(), Path::new("/日本語-légère.log"));
254        let displayed = file.to_string();
255        assert_eq!(
256            displayed,
257            "ssh://h/%E6%97%A5%E6%9C%AC%E8%AA%9E-l%C3%A9g%C3%A8re.log"
258        );
259        assert_eq!(ok(&displayed), file);
260    }
261
262    #[test]
263    fn paths_are_never_trimmed_or_normalized() {
264        let file = ok("ssh://h/a/../b//c/./d/");
265        assert_eq!(file.path(), Path::new("/a/../b//c/./d/"));
266        assert_eq!(file.to_string(), "ssh://h/a/../b//c/./d/");
267        assert_eq!(ok("ssh://h/").path(), Path::new("/"));
268    }
269
270    #[test]
271    fn a_literal_tilde_directory_is_not_aliased_to_a_home_query() {
272        // `%7E` names a directory literally called `~` under `/`.
273        let literal = ok("ssh://h/%7E/log/app.log");
274        assert_eq!(literal.path(), Path::new("/~/log/app.log"));
275        // The canonical spelling re-escapes it, so identity round-trips
276        // and can never re-enter as the unresolved home form.
277        assert_eq!(literal.to_string(), "ssh://h/%7E/log/app.log");
278        assert_eq!(ok(&literal.to_string()), literal);
279        assert!(matches!(
280            RemoteFile::parse("ssh://h/~/log/app.log"),
281            Err(AddressError::UnresolvedHome)
282        ));
283    }
284
285    #[test]
286    fn identity_follows_the_decoded_value() {
287        assert_eq!(ok("ssh://h/%61%62"), ok("ssh://h/ab"));
288        assert_eq!(ok("ssh://h/a%2Fb"), ok("ssh://h/a/b"));
289        assert_eq!(ok("ssh://h:022/x"), ok("ssh://h:22/x"));
290        assert_eq!(ok("ssh://[::1]/x"), ok("ssh://[0:0:0:0:0:0:0:1]/x"));
291        assert_ne!(ok("ssh://h/log"), ok("ssh://h/log/"));
292        assert_ne!(ok("ssh://h/a/b"), ok("ssh://h/a//b"));
293        let mut seen = HashSet::new();
294        seen.insert(ok("ssh://h:022/x"));
295        seen.insert(ok("ssh://h:22/x"));
296        seen.insert(ok("ssh://[::1]/x"));
297        seen.insert(ok("ssh://[0::1]/x"));
298        assert_eq!(seen.len(), 2);
299    }
300
301    #[cfg(unix)]
302    #[test]
303    fn native_filename_bytes_roundtrip() {
304        use std::os::unix::ffi::OsStrExt;
305        let file = ok("ssh://h/l%FCg%FF");
306        assert_eq!(file.path().as_os_str().as_bytes(), b"/l\xFCg\xFF");
307        assert_eq!(file.to_string(), "ssh://h/l%FCg%FF");
308        assert_eq!(ok(&file.to_string()), file);
309    }
310
311    #[cfg(not(unix))]
312    #[test]
313    fn unrepresentable_native_bytes_are_refused() {
314        assert!(matches!(
315            refused("ssh://h/l%FCg"),
316            Err(AddressError::UnrepresentablePath)
317        ));
318    }
319
320    #[test]
321    fn serde_roundtrips_the_canonical_uri() {
322        let file = ok("ssh://dev@box:2222/var%20log/a.log");
323        let json = serde_json::to_string(&file).expect("serialize");
324        assert_eq!(json, "\"ssh://dev@box:2222/var%20log/a.log\"");
325        assert_eq!(
326            serde_json::from_str::<RemoteFile>(&json).expect("deserialize"),
327            file
328        );
329    }
330
331    #[test]
332    fn serde_rejects_invalid_values() {
333        assert!(serde_json::from_str::<RemoteFile>("\"ssh://h/%zz\"").is_err());
334        assert!(serde_json::from_str::<RemoteFile>("\"/local/path\"").is_err());
335        // Field-shaped values cannot bypass the URI admission either.
336        let shaped = serde_json::json!({"host": "h", "path": "/x"});
337        assert!(serde_json::from_value::<RemoteFile>(shaped).is_err());
338    }
339}