1mod 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 #[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
149pub fn resolve_remote(repo: &Repository, remote_arg: Option<&str>) -> Result<RemoteTarget> {
151 Ok(resolve_remote_with_key(repo, remote_arg)?.0)
152}
153
154pub 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
164pub 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 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
202pub 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
213pub 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
221pub fn credential_key_from_remote_url(url: &str) -> Option<String> {
225 credential_key_from_url(url)
226}
227
228fn credential_key_from_url(url: &str) -> Option<String> {
230 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 if rest.starts_with('/') || url.starts_with("file://") {
239 return None;
240 }
241
242 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 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}