1use super::endpoint::RemoteEndpoint;
8use super::error::AddressError;
9use super::uri::{self, path_bytes};
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone)]
14pub struct RemoteFile {
15 endpoint: RemoteEndpoint,
16 path: PathBuf,
17}
18
19impl RemoteFile {
20 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 pub fn endpoint(&self) -> &RemoteEndpoint {
42 &self.endpoint
43 }
44
45 pub fn path(&self) -> &Path {
47 &self.path
48 }
49
50 pub fn with_path(&self, path: PathBuf) -> Result<Self, AddressError> {
55 Self::from_path(self.endpoint.clone(), path)
56 }
57
58 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 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 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
113impl 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 let literal = ok("ssh://h/%7E/log/app.log");
274 assert_eq!(literal.path(), Path::new("/~/log/app.log"));
275 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 let shaped = serde_json::json!({"host": "h", "path": "/x"});
337 assert!(serde_json::from_value::<RemoteFile>(shaped).is_err());
338 }
339}