Skip to main content

strop_remote/address/
location.rs

1//! What a user actually types: a remote location that may still be
2//! unresolved. Only the *raw* URI spelling decides whether a leading `~`
3//! component is a home query (`/~/log`) or a literal tilde directory
4//! (`/%7E/log`) — the decoded bytes alone cannot, and a session must never
5//! guess. Home queries stay unresolved here until a negotiated
6//! `expand-path@openssh.com` exchange publishes the canonical file.
7
8use super::endpoint::RemoteEndpoint;
9use super::error::AddressError;
10use super::file::RemoteFile;
11use super::uri::{self, path_bytes};
12use std::path::{Path, PathBuf};
13
14/// A remote location as entered: either an already-canonical file, or an
15/// unresolved home-relative query on one endpoint.
16#[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        /// `~/log`, `~alice/log` — bytes exactly as decoded, tilde included.
25        relative: PathBuf,
26    },
27}
28
29impl RemoteLocation {
30    /// Admit one textual location. A raw `~` first component yields the
31    /// unresolved home form; everything else must decode to a canonical
32    /// absolute file. Pure: no filesystem, no subprocess, no home guessing.
33    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    /// The endpoint this location addresses — the pooled-session identity
57    /// even before the path is resolved.
58    pub fn endpoint(&self) -> &RemoteEndpoint {
59        match &self.0 {
60            LocationInner::File(file) => file.endpoint(),
61            LocationInner::Home { endpoint, .. } => endpoint,
62        }
63    }
64
65    /// The canonical file, when this location is already absolute and
66    /// resolved. A home query returns `None`: only a session that negotiated
67    /// `expand-path@openssh.com` may publish its canonical identity.
68    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    /// The unresolved home-relative bytes (`~/log`), exactly as decoded.
76    pub(crate) 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                // The raw `~` is what marks a home query; later `~` bytes
91                // inside the path are ordinary data.
92                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
117/// Serde carries the canonical validated URI — one string, re-parsed on
118/// the way in, so stored values can never bypass admission.
119impl 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        // Identity survives the round trip without ever becoming `~`.
178        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        // A `~` beyond the first component is data, not a home marker.
183        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}