Skip to main content

cli_shared/remote/
target.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Remote target resolution.
3
4use std::{
5    net::{SocketAddr, ToSocketAddrs},
6    path::PathBuf,
7};
8
9/// A remote target - either a network address or a local path.
10#[derive(Debug, Clone)]
11pub enum RemoteTarget {
12    /// Network address (host:port).
13    Network {
14        addr: SocketAddr,
15        repo_path: Option<String>,
16    },
17    /// Local filesystem path (file:// URL).
18    Local(PathBuf),
19}
20
21impl RemoteTarget {
22    /// Parse from a string.
23    ///
24    /// Accepts:
25    /// - `file:///path/to/repo` or `file://path/to/repo`
26    /// - `/path/to/repo` (raw path, if it exists as a directory)
27    /// - `host:port` (network address)
28    pub fn parse(s: &str) -> Result<Self, String> {
29        // Check for file:// protocol
30        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        // Check if it's a raw path (exists as a directory)
45        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    /// Parse a target under native repository source authority.
69    ///
70    /// Native repositories may use an HTTPS repository URL after the caller
71    /// has verified the server's well-known Iroh endpoint. The regular parser
72    /// deliberately keeps treating HTTPS as non-native so Git-owned callers
73    /// retain their existing transport classification.
74    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    /// Check if this is a local target.
84    pub fn is_local(&self) -> bool {
85        matches!(self, RemoteTarget::Local(_))
86    }
87
88    /// Check if this is a network target.
89    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}