strop_workspace/addr/
location.rs1use super::endpoint::RemoteEndpoint;
9use super::error::AddressError;
10use super::file::RemoteFile;
11use super::uri::{self, path_bytes};
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct RemoteLocation(LocationInner);
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20enum LocationInner {
21 File(RemoteFile),
22 Home {
23 endpoint: RemoteEndpoint,
24 relative: PathBuf,
26 },
27}
28
29impl RemoteLocation {
30 pub fn parse(value: &str) -> Result<Self, AddressError> {
34 let (endpoint, tail) = RemoteEndpoint::split_authority(value)?;
35 match tail.as_bytes().first() {
36 None => return Err(AddressError::AbsentPath),
37 Some(b'/') => {}
38 Some(_) => return Err(AddressError::QueryOrFragment),
39 }
40 if tail.contains(['?', '#']) {
41 return Err(AddressError::QueryOrFragment);
42 }
43 let home = tail.as_bytes().get(1) == Some(&b'~');
44 let path = uri::decode_path(tail)?;
45 let inner = if home {
46 LocationInner::Home {
47 endpoint,
48 relative: uri::strip_leading_slash(path),
49 }
50 } else {
51 LocationInner::File(RemoteFile::from_path(endpoint, path)?)
52 };
53 Ok(Self(inner))
54 }
55
56 pub fn endpoint(&self) -> &RemoteEndpoint {
59 match &self.0 {
60 LocationInner::File(file) => file.endpoint(),
61 LocationInner::Home { endpoint, .. } => endpoint,
62 }
63 }
64
65 pub fn absolute_file(&self) -> Option<&RemoteFile> {
69 match &self.0 {
70 LocationInner::File(file) => Some(file),
71 LocationInner::Home { .. } => None,
72 }
73 }
74
75 pub fn home_relative(&self) -> Option<&Path> {
77 match &self.0 {
78 LocationInner::File(_) => None,
79 LocationInner::Home { relative, .. } => Some(relative),
80 }
81 }
82
83 fn canonical(&self) -> String {
84 match &self.0 {
85 LocationInner::File(file) => file.to_string(),
86 LocationInner::Home { endpoint, relative } => {
87 let bytes = path_bytes(relative);
88 let mut out = String::with_capacity(endpoint.to_string().len() + bytes.len() * 3);
89 out.push_str(&endpoint.to_string());
90 out.push('/');
93 if bytes.first() == Some(&b'~') {
94 out.push('~');
95 uri::push_escaped(&mut out, &bytes[1..]);
96 } else {
97 uri::push_escaped(&mut out, bytes);
98 }
99 out
100 }
101 }
102 }
103}
104
105impl From<RemoteFile> for RemoteLocation {
106 fn from(file: RemoteFile) -> Self {
107 Self(LocationInner::File(file))
108 }
109}
110
111impl std::fmt::Display for RemoteLocation {
112 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 formatter.write_str(&self.canonical())
114 }
115}
116
117impl serde::Serialize for RemoteLocation {
120 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
121 serializer.serialize_str(&self.canonical())
122 }
123}
124
125impl<'de> serde::Deserialize<'de> for RemoteLocation {
126 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
127 let text = String::deserialize(deserializer)?;
128 RemoteLocation::parse(&text).map_err(serde::de::Error::custom)
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use std::path::Path;
136
137 fn ok(uri: &str) -> RemoteLocation {
138 RemoteLocation::parse(uri).unwrap_or_else(|e| panic!("expected parse: {uri}: {e}"))
139 }
140
141 #[test]
142 fn absolute_locations_expose_their_canonical_file() {
143 let location = ok("ssh://dev@box:2222/var/log/app.log");
144 let file = location.absolute_file().expect("absolute");
145 assert_eq!(file.path(), Path::new("/var/log/app.log"));
146 assert_eq!(location.endpoint().host(), "box");
147 assert_eq!(location.to_string(), "ssh://dev@box:2222/var/log/app.log");
148 assert_eq!(
149 ok("ssh://h/").absolute_file().unwrap().path(),
150 Path::new("/")
151 );
152 }
153
154 #[test]
155 fn home_queries_stay_unresolved_and_round_trip() {
156 for uri in ["ssh://h/~/log/app.log", "ssh://h/~", "ssh://h/~alice/x"] {
157 let location = ok(uri);
158 assert!(location.absolute_file().is_none(), "{uri}");
159 assert_eq!(location.to_string(), uri);
160 assert_eq!(ok(&location.to_string()), location);
161 }
162 assert_eq!(
163 ok("ssh://h/~/log/app.log").home_relative(),
164 Some(Path::new("~/log/app.log"))
165 );
166 assert_eq!(
167 ok("ssh://h/~alice/x").home_relative(),
168 Some(Path::new("~alice/x"))
169 );
170 }
171
172 #[test]
173 fn a_literal_escaped_tilde_is_not_a_home_query() {
174 let literal = ok("ssh://h/%7E/log/app.log");
175 let file = literal.absolute_file().expect("literal tilde is canonical");
176 assert_eq!(file.path(), Path::new("/~/log/app.log"));
177 assert_eq!(literal.to_string(), "ssh://h/%7E/log/app.log");
179 let home = ok("ssh://h/~/log/app.log");
180 assert_ne!(literal, home);
181 assert!(home.absolute_file().is_none());
182 let inner = ok("ssh://h/var/~tmp/x");
184 assert_eq!(
185 inner.absolute_file().unwrap().path(),
186 Path::new("/var/~tmp/x")
187 );
188 }
189
190 #[test]
191 fn files_convert_into_locations() {
192 let file = RemoteFile::parse("ssh://h/var%20log/a").unwrap();
193 let location = RemoteLocation::from(file.clone());
194 assert_eq!(location.absolute_file(), Some(&file));
195 assert_eq!(location, ok("ssh://h/var%20log/a"));
196 }
197
198 #[test]
199 fn admission_failures_match_the_file_grammar() {
200 for uri in ["ssh://host", "scp://host/x", "", "SSH://h/x"] {
201 assert!(RemoteLocation::parse(uri).is_err(), "{uri}");
202 }
203 assert!(matches!(
204 RemoteLocation::parse("ssh://h/a?b"),
205 Err(AddressError::QueryOrFragment)
206 ));
207 assert!(matches!(
208 RemoteLocation::parse("ssh://h/%zz"),
209 Err(AddressError::MalformedPercentEscape)
210 ));
211 }
212
213 #[test]
214 fn serde_roundtrips_both_forms() {
215 for uri in ["ssh://dev@box:2222/var%20log/a.log", "ssh://h/~/log"] {
216 let location = ok(uri);
217 let json = serde_json::to_string(&location).expect("serialize");
218 assert_eq!(
219 serde_json::from_str::<RemoteLocation>(&json).expect("deserialize"),
220 location
221 );
222 }
223 }
224}