Skip to main content

cli_shared/remote/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Remote configuration management.
3//!
4//! Remote aliases remain repository-scoped and live in `.heddle/remotes.toml`.
5
6mod target;
7
8use std::{
9    collections::HashMap,
10    fs,
11    path::{Path, PathBuf},
12};
13
14use objects::fs_atomic::write_file_atomic;
15use repo::{Repository, RepositoryCapability};
16use serde::{Deserialize, Serialize};
17pub use target::RemoteTarget;
18
19#[derive(Debug, thiserror::Error)]
20pub enum RemoteError {
21    #[error("io error: {0}")]
22    Io(#[from] std::io::Error),
23
24    #[error("toml error: {0}")]
25    Toml(#[from] toml::de::Error),
26
27    #[error("toml serialize error: {0}")]
28    TomlSerialize(#[from] toml::ser::Error),
29
30    #[error("remote not found: {0}")]
31    NotFound(String),
32
33    #[error("no default remote configured")]
34    NoDefaultRemote,
35
36    #[error("invalid remote url: {0}")]
37    InvalidUrl(String),
38}
39
40pub type Result<T> = std::result::Result<T, RemoteError>;
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Remote {
44    pub url: String,
45    /// Explicitly allow cleartext (non-TLS) connections to non-loopback hosts
46    /// for this remote. Equivalent to CLI `--insecure` for push/pull/clone.
47    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
48    pub insecure: bool,
49}
50
51#[derive(Debug, Default, Serialize, Deserialize)]
52pub struct RemotesFile {
53    #[serde(default)]
54    pub default: Option<String>,
55    #[serde(default)]
56    pub remotes: HashMap<String, Remote>,
57}
58
59impl RemotesFile {
60    pub fn load(path: &Path) -> Result<Self> {
61        match fs::read_to_string(path) {
62            Ok(contents) => Ok(toml::from_str(&contents)?),
63            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
64            Err(err) => Err(err.into()),
65        }
66    }
67
68    pub fn save(&self, path: &Path) -> Result<()> {
69        if let Some(parent) = path.parent() {
70            fs::create_dir_all(parent)?;
71        }
72        let contents = toml::to_string_pretty(self)?;
73        write_file_atomic(path, contents.as_bytes())?;
74        Ok(())
75    }
76}
77
78pub struct RemoteConfig {
79    path: PathBuf,
80    file: RemotesFile,
81}
82
83impl RemoteConfig {
84    pub fn open(repo: &Repository) -> Result<Self> {
85        let path = repo.heddle_dir().join("remotes.toml");
86        let file = RemotesFile::load(&path)?;
87        Ok(Self { path, file })
88    }
89
90    pub fn list(&self) -> Vec<(String, Remote)> {
91        let mut items: Vec<_> = self
92            .file
93            .remotes
94            .iter()
95            .map(|(name, remote)| (name.clone(), remote.clone()))
96            .collect();
97        items.sort_by(|a, b| a.0.cmp(&b.0));
98        items
99    }
100
101    pub fn get(&self, name: &str) -> Result<Remote> {
102        self.file
103            .remotes
104            .get(name)
105            .cloned()
106            .ok_or_else(|| RemoteError::NotFound(name.to_string()))
107    }
108
109    pub fn add(&mut self, name: &str, remote: Remote) -> Result<()> {
110        if self.file.default.is_none() {
111            self.file.default = Some(name.to_string());
112        }
113        self.file.remotes.insert(name.to_string(), remote);
114        self.file.save(&self.path)?;
115        Ok(())
116    }
117
118    pub fn remove(&mut self, name: &str) -> Result<()> {
119        if self.file.remotes.remove(name).is_none() {
120            return Err(RemoteError::NotFound(name.to_string()));
121        }
122        if self.file.default.as_deref() == Some(name) {
123            self.file.default = None;
124        }
125        self.file.save(&self.path)?;
126        Ok(())
127    }
128
129    pub fn clear_default(&mut self) -> Result<()> {
130        self.file.default = None;
131        self.file.save(&self.path)?;
132        Ok(())
133    }
134
135    pub fn set_default(&mut self, name: &str) -> Result<()> {
136        if !self.file.remotes.contains_key(name) {
137            return Err(RemoteError::NotFound(name.to_string()));
138        }
139        self.file.default = Some(name.to_string());
140        self.file.save(&self.path)?;
141        Ok(())
142    }
143
144    pub fn default_name(&self) -> Option<&str> {
145        self.file.default.as_deref()
146    }
147}
148
149/// Resolve a remote argument (name or URL) into a concrete target.
150pub fn resolve_remote(repo: &Repository, remote_arg: Option<&str>) -> Result<RemoteTarget> {
151    Ok(resolve_remote_with_key(repo, remote_arg)?.0)
152}
153
154/// Resolve a remote argument (name or URL) into a concrete target, also
155/// returning the raw URL string that can be used as a credential store key.
156pub fn resolve_remote_with_key(
157    repo: &Repository,
158    remote_arg: Option<&str>,
159) -> Result<(RemoteTarget, Option<String>)> {
160    let (target, key, _insecure) = resolve_remote_with_key_and_insecure(repo, remote_arg)?;
161    Ok((target, key))
162}
163
164/// Resolve a remote argument into target, credential key, and the remote's
165/// configured `insecure` flag (false for ad-hoc URL specs).
166pub fn resolve_remote_with_key_and_insecure(
167    repo: &Repository,
168    remote_arg: Option<&str>,
169) -> Result<(RemoteTarget, Option<String>, bool)> {
170    let cfg = RemoteConfig::open(repo)?;
171    let spec = match remote_arg {
172        Some(spec) => spec.to_string(),
173        None => cfg
174            .default_name()
175            .ok_or(RemoteError::NoDefaultRemote)?
176            .to_string(),
177    };
178
179    // Named remote first so configured `insecure` applies even when the
180    // name also happens to parse as a bare host:port.
181    if let Ok(remote) = cfg.get(&spec)
182        && let Ok(target) = parse_target_for_repository(repo, &remote.url)
183    {
184        let key = credential_key_from_url(&remote.url);
185        return Ok((target, key, remote.insecure));
186    }
187
188    if let Ok(target) = parse_target_for_repository(repo, &spec) {
189        let key = credential_key_from_url(&spec);
190        return Ok((target, key, false));
191    }
192
193    let remote = cfg.get(&spec)?;
194    if let Ok(target) = parse_target_for_repository(repo, &remote.url) {
195        let key = credential_key_from_url(&remote.url);
196        return Ok((target, key, remote.insecure));
197    }
198
199    Err(RemoteError::InvalidUrl(remote.url))
200}
201
202/// Parse a remote using the repository's source authority to interpret HTTPS.
203pub fn parse_target_for_repository(
204    repo: &Repository,
205    url: &str,
206) -> std::result::Result<RemoteTarget, String> {
207    match repo.capability() {
208        RepositoryCapability::NativeHeddle => RemoteTarget::parse_native(url),
209        RepositoryCapability::GitOverlay => RemoteTarget::parse(url),
210    }
211}
212
213/// Whether a named remote (or the default) has `insecure = true` in
214/// `.heddle/remotes.toml`. Returns false for URL specs / missing remotes.
215pub fn remote_allows_insecure(repo: &Repository, remote_arg: Option<&str>) -> bool {
216    resolve_remote_with_key_and_insecure(repo, remote_arg)
217        .map(|(_, _, insecure)| insecure)
218        .unwrap_or(false)
219}
220
221/// Extract the hostname (credential store key) from a remote URL string.
222///
223/// Returns `None` for local paths (file:// or bare paths).
224pub fn credential_key_from_remote_url(url: &str) -> Option<String> {
225    credential_key_from_url(url)
226}
227
228/// Internal implementation of credential key extraction.
229fn credential_key_from_url(url: &str) -> Option<String> {
230    // Strip known scheme prefixes.
231    let rest = url
232        .strip_prefix("heddle://")
233        .or_else(|| url.strip_prefix("https://"))
234        .or_else(|| url.strip_prefix("http://"))
235        .unwrap_or(url);
236
237    // Skip local paths.
238    if rest.starts_with('/') || url.starts_with("file://") {
239        return None;
240    }
241
242    // The credential key is the host part (before the first '/').
243    let host_part = rest.split('/').next().unwrap_or(rest);
244    if host_part.is_empty() {
245        return None;
246    }
247    Some(host_part.to_string())
248}
249
250#[cfg(test)]
251mod tests {
252    use std::{
253        fs,
254        path::PathBuf,
255        time::{SystemTime, UNIX_EPOCH},
256    };
257
258    use repo::Repository;
259
260    use super::*;
261
262    fn unique_temp_dir(prefix: &str) -> PathBuf {
263        let unique = SystemTime::now()
264            .duration_since(UNIX_EPOCH)
265            .expect("system time before unix epoch")
266            .as_nanos();
267        std::env::temp_dir().join(format!("{prefix}-{unique}-{}", std::process::id()))
268    }
269
270    #[test]
271    fn remote_config_save_uses_atomic_write_and_persists() {
272        // Mutex serializes env-var access across this crate's tests so
273        // parallel runs don't observe each other's writes.
274        static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
275        let _guard = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
276        let temp = unique_temp_dir("heddle-remote-test");
277        fs::create_dir_all(&temp).expect("create temp dir");
278        let repo = Repository::init_default(&temp).expect("init repo");
279
280        {
281            let mut cfg = RemoteConfig::open(&repo).expect("open config");
282            cfg.add(
283                "origin",
284                Remote {
285                    url: "http://heddle.example:8421/repo".to_string(),
286                    insecure: false,
287                },
288            )
289            .expect("add remote");
290        }
291
292        let path = repo.heddle_dir().join("remotes.toml");
293        assert!(path.exists(), "expected remotes file to exist");
294
295        let contents = fs::read_to_string(&path).expect("read remotes file");
296        assert!(contents.contains("origin"));
297        assert!(contents.contains("heddle.example:8421"));
298
299        let reopened = RemoteConfig::open(&repo).expect("reopen config");
300        let remote = reopened.get("origin").expect("load remote");
301        assert_eq!(remote.url, "http://heddle.example:8421/repo");
302
303        let _ = fs::remove_dir_all(temp);
304    }
305
306    #[test]
307    fn native_https_remote_round_trips_without_rewriting_the_scheme() {
308        let temp = unique_temp_dir("heddle-https-remote-test");
309        fs::create_dir_all(&temp).expect("create temp dir");
310        let repo = Repository::init_default(&temp).expect("init repo");
311        let url = "https://127.0.0.1:8431/acme/heddle";
312
313        let mut cfg = RemoteConfig::open(&repo).expect("open config");
314        cfg.add(
315            "origin",
316            Remote {
317                url: url.to_string(),
318                insecure: false,
319            },
320        )
321        .expect("add HTTPS remote");
322
323        let reopened = RemoteConfig::open(&repo).expect("reopen config");
324        assert_eq!(reopened.get("origin").expect("load remote").url, url);
325        let (target, key) =
326            resolve_remote_with_key(&repo, Some("origin")).expect("resolve native HTTPS remote");
327        assert!(matches!(target, RemoteTarget::Network { .. }));
328        assert_eq!(key.as_deref(), Some("127.0.0.1:8431"));
329
330        let _ = fs::remove_dir_all(temp);
331    }
332
333    #[test]
334    fn configured_host_port_remote_honors_insecure_policy() {
335        let temp = unique_temp_dir("heddle-host-port-policy-test");
336        fs::create_dir_all(&temp).expect("create temp dir");
337        let repo = Repository::init_default(&temp).expect("init repo");
338        let mut cfg = RemoteConfig::open(&repo).expect("open config");
339        cfg.add(
340            "127.0.0.1:8421",
341            Remote {
342                url: "heddle://127.0.0.1:9999/acme/repo".to_string(),
343                insecure: true,
344            },
345        )
346        .expect("add host:port-named remote");
347
348        let (target, key, insecure) =
349            resolve_remote_with_key_and_insecure(&repo, Some("127.0.0.1:8421"))
350                .expect("resolve host:port-named remote");
351        match target {
352            RemoteTarget::Network { addr, repo_path } => {
353                assert_eq!(addr.port(), 9999);
354                assert_eq!(repo_path.as_deref(), Some("acme/repo"));
355            }
356            other => panic!("expected network target, got {other:?}"),
357        }
358        assert_eq!(key.as_deref(), Some("127.0.0.1:9999"));
359        assert!(
360            insecure,
361            "configured insecure policy must survive host:port-like remote names"
362        );
363
364        let _ = fs::remove_dir_all(temp);
365    }
366}