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            return Ok(RemoteTarget::Local(PathBuf::from(path)));
32        }
33
34        if let Some((addr, repo_path)) = parse_network_with_repo_path(s) {
35            return Ok(RemoteTarget::Network { addr, repo_path });
36        }
37
38        // Check if it's a raw path (exists as a directory)
39        let path = PathBuf::from(s);
40        if path.exists() && path.is_dir() {
41            return Ok(RemoteTarget::Local(path));
42        }
43
44        Err(format!(
45            "invalid remote url (expected file://path or host:port): {}",
46            s
47        ))
48    }
49
50    /// Parse a target under native repository source authority.
51    ///
52    /// Native repositories may use an HTTPS repository URL after the caller
53    /// has verified the server's well-known Iroh endpoint. The regular parser
54    /// deliberately keeps treating HTTPS as non-native so Git-owned callers
55    /// retain their existing transport classification.
56    pub fn parse_native(s: &str) -> Result<Self, String> {
57        if let Some(rest) = s.strip_prefix("https://") {
58            let (addr, repo_path) = parse_https_network_with_repo_path(rest)
59                .ok_or_else(|| format!("invalid native HTTPS remote url: {s}"))?;
60            return Ok(RemoteTarget::Network { addr, repo_path });
61        }
62        Self::parse(s)
63    }
64
65    /// Check if this is a local target.
66    pub fn is_local(&self) -> bool {
67        matches!(self, RemoteTarget::Local(_))
68    }
69
70    /// Check if this is a network target.
71    pub fn is_network(&self) -> bool {
72        matches!(self, RemoteTarget::Network { .. })
73    }
74}
75
76impl std::fmt::Display for RemoteTarget {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            RemoteTarget::Network { addr, repo_path } => {
80                if let Some(repo_path) = repo_path {
81                    write!(f, "heddle://{}/{}", addr, repo_path)
82                } else {
83                    write!(f, "{}", addr)
84                }
85            }
86            RemoteTarget::Local(path) => write!(f, "file://{}", path.display()),
87        }
88    }
89}
90
91fn parse_network_with_repo_path(s: &str) -> Option<(SocketAddr, Option<String>)> {
92    if let Some(rest) = s.strip_prefix("heddle://") {
93        return parse_network_with_repo_path(rest);
94    }
95
96    if let Ok(addr) = s.parse::<SocketAddr>() {
97        return Some((addr, None));
98    }
99
100    if let Some(addr) = resolve_socket_addr(s) {
101        return Some((addr, None));
102    }
103
104    let slash = s.find('/')?;
105    let (addr_part, repo_part) = s.split_at(slash);
106    let addr = resolve_socket_addr(addr_part)?;
107    let repo_path = repo_part.trim_start_matches('/');
108    if repo_path.is_empty() {
109        return Some((addr, None));
110    }
111    Some((addr, Some(repo_path.to_string())))
112}
113
114fn parse_https_network_with_repo_path(s: &str) -> Option<(SocketAddr, Option<String>)> {
115    if s.is_empty() || s.contains(['?', '#', '@']) {
116        return None;
117    }
118    let (authority, repo_path) = match s.split_once('/') {
119        Some((authority, path)) => (authority, Some(path.trim_matches('/'))),
120        None => (s, None),
121    };
122    if authority.is_empty() {
123        return None;
124    }
125    let addr = resolve_socket_addr(authority).or_else(|| {
126        let host = authority
127            .strip_prefix('[')
128            .and_then(|host| host.strip_suffix(']'))
129            .unwrap_or(authority);
130        (host, 443).to_socket_addrs().ok()?.next()
131    })?;
132    let repo_path = repo_path
133        .filter(|path| !path.is_empty())
134        .map(str::to_string);
135    Some((addr, repo_path))
136}
137
138fn resolve_socket_addr(addr: &str) -> Option<SocketAddr> {
139    if let Ok(parsed) = addr.parse::<SocketAddr>() {
140        return Some(parsed);
141    }
142
143    addr.to_socket_addrs().ok()?.next()
144}
145
146#[cfg(test)]
147mod tests {
148    use super::RemoteTarget;
149
150    #[test]
151    fn parses_hostname_without_repo_path() {
152        let target = RemoteTarget::parse("localhost:8421").expect("parse localhost");
153        match target {
154            RemoteTarget::Network { addr, repo_path } => {
155                assert_eq!(addr.port(), 8421);
156                assert!(addr.ip().is_loopback());
157                assert!(repo_path.is_none());
158            }
159            other => panic!("expected network target, got {other:?}"),
160        }
161    }
162
163    #[test]
164    fn parses_hostname_with_repo_path() {
165        let target =
166            RemoteTarget::parse("localhost:8421/acme/heddle").expect("parse localhost repo path");
167        match target {
168            RemoteTarget::Network { addr, repo_path } => {
169                assert_eq!(addr.port(), 8421);
170                assert!(addr.ip().is_loopback());
171                assert_eq!(repo_path.as_deref(), Some("acme/heddle"));
172            }
173            other => panic!("expected network target, got {other:?}"),
174        }
175    }
176
177    #[test]
178    fn native_parser_accepts_https_without_changing_generic_classification() {
179        assert!(RemoteTarget::parse("https://127.0.0.1:8431/acme/heddle").is_err());
180
181        let target = RemoteTarget::parse_native("https://127.0.0.1:8431/acme/heddle")
182            .expect("parse native HTTPS URL");
183        match target {
184            RemoteTarget::Network { addr, repo_path } => {
185                assert_eq!(addr, "127.0.0.1:8431".parse().unwrap());
186                assert_eq!(repo_path.as_deref(), Some("acme/heddle"));
187            }
188            other => panic!("expected network target, got {other:?}"),
189        }
190    }
191
192    #[test]
193    fn native_https_parser_defaults_to_port_443() {
194        let target =
195            RemoteTarget::parse_native("https://127.0.0.1/acme/heddle").expect("parse HTTPS URL");
196        match target {
197            RemoteTarget::Network { addr, .. } => assert_eq!(addr.port(), 443),
198            other => panic!("expected network target, got {other:?}"),
199        }
200    }
201}