cli_shared/remote/
target.rs1use std::{
5 net::{SocketAddr, ToSocketAddrs},
6 path::PathBuf,
7};
8
9#[derive(Debug, Clone)]
11pub enum RemoteTarget {
12 Network {
14 addr: SocketAddr,
15 repo_path: Option<String>,
16 },
17 Local(PathBuf),
19}
20
21impl RemoteTarget {
22 pub fn parse(s: &str) -> Result<Self, String> {
29 if let Some(path) = s.strip_prefix("file://") {
31 let path = PathBuf::from(path);
32 if path.is_dir() {
33 return Ok(RemoteTarget::Local(path));
34 }
35 return Err(format!(
36 "invalid remote url (local path does not exist): {s}"
37 ));
38 }
39
40 if let Some((addr, repo_path)) = parse_network_with_repo_path(s) {
41 return Ok(RemoteTarget::Network { addr, repo_path });
42 }
43
44 let path = PathBuf::from(s);
46 if path.exists() && path.is_dir() {
47 return Ok(RemoteTarget::Local(path));
48 }
49
50 if s.starts_with("heddle://") {
51 return Err(format!(
52 "invalid remote url: {s} (`heddle://` carries no addressing that push can use; use https://<host>/<repo> or host:port/repo)"
53 ));
54 }
55
56 if looks_like_unresolved_local_path(s) {
57 return Err(format!(
58 "invalid remote url (local path does not exist): {s}"
59 ));
60 }
61
62 Err(format!(
63 "invalid remote url (expected file://path or host:port): {}",
64 s
65 ))
66 }
67
68 pub fn parse_native(s: &str) -> Result<Self, String> {
75 if let Some(rest) = s.strip_prefix("https://") {
76 let (addr, repo_path) = parse_https_network_with_repo_path(rest)
77 .ok_or_else(|| format!("invalid native HTTPS remote url: {s}"))?;
78 return Ok(RemoteTarget::Network { addr, repo_path });
79 }
80 Self::parse(s)
81 }
82
83 pub fn is_local(&self) -> bool {
85 matches!(self, RemoteTarget::Local(_))
86 }
87
88 pub fn is_network(&self) -> bool {
90 matches!(self, RemoteTarget::Network { .. })
91 }
92}
93
94impl std::fmt::Display for RemoteTarget {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 match self {
97 RemoteTarget::Network { addr, repo_path } => {
98 if let Some(repo_path) = repo_path {
99 write!(f, "heddle://{}/{}", addr, repo_path)
100 } else {
101 write!(f, "{}", addr)
102 }
103 }
104 RemoteTarget::Local(path) => write!(f, "file://{}", path.display()),
105 }
106 }
107}
108
109fn parse_network_with_repo_path(s: &str) -> Option<(SocketAddr, Option<String>)> {
110 if let Some(rest) = s.strip_prefix("heddle://") {
111 return parse_network_with_repo_path(rest);
112 }
113
114 if let Ok(addr) = s.parse::<SocketAddr>() {
115 return Some((addr, None));
116 }
117
118 if let Some(addr) = resolve_socket_addr(s) {
119 return Some((addr, None));
120 }
121
122 let slash = s.find('/')?;
123 let (addr_part, repo_part) = s.split_at(slash);
124 let addr = resolve_socket_addr(addr_part)?;
125 let repo_path = repo_part.trim_start_matches('/');
126 if repo_path.is_empty() {
127 return Some((addr, None));
128 }
129 Some((addr, Some(repo_path.to_string())))
130}
131
132fn parse_https_network_with_repo_path(s: &str) -> Option<(SocketAddr, Option<String>)> {
133 if s.is_empty() || s.contains(['?', '#', '@']) {
134 return None;
135 }
136 let (authority, repo_path) = match s.split_once('/') {
137 Some((authority, path)) => (authority, Some(path.trim_matches('/'))),
138 None => (s, None),
139 };
140 if authority.is_empty() {
141 return None;
142 }
143 let addr = resolve_socket_addr(authority).or_else(|| {
144 let host = authority
145 .strip_prefix('[')
146 .and_then(|host| host.strip_suffix(']'))
147 .unwrap_or(authority);
148 (host, 443).to_socket_addrs().ok()?.next()
149 })?;
150 let repo_path = repo_path
151 .filter(|path| !path.is_empty())
152 .map(str::to_string);
153 Some((addr, repo_path))
154}
155
156fn resolve_socket_addr(addr: &str) -> Option<SocketAddr> {
157 if let Ok(parsed) = addr.parse::<SocketAddr>() {
158 return Some(parsed);
159 }
160
161 addr.to_socket_addrs().ok()?.next()
162}
163
164fn looks_like_unresolved_local_path(value: &str) -> bool {
165 value.starts_with('/')
166 || value.starts_with("./")
167 || value.starts_with("../")
168 || value.starts_with("~/")
169 || value.contains('\\')
170 || (!value.contains("://") && !value.contains(':'))
171}
172
173#[cfg(test)]
174mod tests {
175 use super::RemoteTarget;
176
177 #[test]
178 fn parses_hostname_without_repo_path() {
179 let target = RemoteTarget::parse("localhost:8421").expect("parse localhost");
180 match target {
181 RemoteTarget::Network { addr, repo_path } => {
182 assert_eq!(addr.port(), 8421);
183 assert!(addr.ip().is_loopback());
184 assert!(repo_path.is_none());
185 }
186 other => panic!("expected network target, got {other:?}"),
187 }
188 }
189
190 #[test]
191 fn parses_hostname_with_repo_path() {
192 let target =
193 RemoteTarget::parse("localhost:8421/acme/heddle").expect("parse localhost repo path");
194 match target {
195 RemoteTarget::Network { addr, repo_path } => {
196 assert_eq!(addr.port(), 8421);
197 assert!(addr.ip().is_loopback());
198 assert_eq!(repo_path.as_deref(), Some("acme/heddle"));
199 }
200 other => panic!("expected network target, got {other:?}"),
201 }
202 }
203
204 #[test]
205 fn native_parser_accepts_https_without_changing_generic_classification() {
206 assert!(RemoteTarget::parse("https://127.0.0.1:8431/acme/heddle").is_err());
207
208 let target = RemoteTarget::parse_native("https://127.0.0.1:8431/acme/heddle")
209 .expect("parse native HTTPS URL");
210 match target {
211 RemoteTarget::Network { addr, repo_path } => {
212 assert_eq!(addr, "127.0.0.1:8431".parse().unwrap());
213 assert_eq!(repo_path.as_deref(), Some("acme/heddle"));
214 }
215 other => panic!("expected network target, got {other:?}"),
216 }
217 }
218
219 #[test]
220 fn native_https_parser_defaults_to_port_443() {
221 let target =
222 RemoteTarget::parse_native("https://127.0.0.1/acme/heddle").expect("parse HTTPS URL");
223 match target {
224 RemoteTarget::Network { addr, .. } => assert_eq!(addr.port(), 443),
225 other => panic!("expected network target, got {other:?}"),
226 }
227 }
228
229 #[test]
230 fn heddle_scheme_without_port_is_not_a_push_url() {
231 let error = RemoteTarget::parse("heddle://api.heddle.sh/luke/tiny-notes")
232 .expect_err("schemeless heddle:// host has no addressing");
233 assert!(
234 error.contains("heddle://") && error.contains("no addressing"),
235 "refusal must name the scheme gap: {error}"
236 );
237 assert!(RemoteTarget::parse_native("heddle://api.heddle.sh/luke/tiny-notes").is_err());
238 assert!(RemoteTarget::parse("heddle://127.0.0.1:8421/luke/tiny-notes").is_ok());
239 }
240
241 #[test]
242 fn nonexistent_local_paths_fail_closed() {
243 let missing = "/tmp/heddle-missing-remote-path-does-not-exist";
244 assert!(RemoteTarget::parse(missing).is_err());
245 assert!(RemoteTarget::parse(&format!("file://{missing}")).is_err());
246 assert!(RemoteTarget::parse("tiny-notes-mirror").is_err());
247 }
248}