1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use rns_crypto::identity::Identity;
6use rns_crypto::OsRng;
7use rns_net::storage;
8
9use crate::{Error, Result};
10
11pub fn default_rngit_dir() -> PathBuf {
12 if let Some(path) = std::env::var_os("RNGIT_CONFIG") {
13 return PathBuf::from(path);
14 }
15 if let Some(home) = std::env::var_os("HOME") {
16 let home = PathBuf::from(home);
17 let legacy = home.join(".rngit");
18 let xdg = std::env::var_os("XDG_CONFIG_HOME")
19 .map(PathBuf::from)
20 .unwrap_or_else(|| home.join(".config"))
21 .join("rngit");
22 if xdg.exists() || !legacy.exists() {
23 return xdg;
24 }
25 return legacy;
26 }
27 PathBuf::from(".rngit")
28}
29
30pub fn default_reticulum_dir() -> Option<PathBuf> {
31 std::env::var_os("RNS_CONFIG").map(PathBuf::from)
32}
33
34pub fn ensure_dir(path: &Path) -> Result<()> {
35 fs::create_dir_all(path)?;
36 Ok(())
37}
38
39pub fn load_or_create_identity(path: &Path) -> Result<Identity> {
40 if path.exists() {
41 return storage::load_identity(path).map_err(Error::from);
42 }
43
44 if let Some(parent) = path.parent() {
45 fs::create_dir_all(parent)?;
46 }
47 let identity = Identity::new(&mut OsRng);
48 storage::save_identity(&identity, path)?;
49 Ok(identity)
50}
51
52pub fn hex(bytes: &[u8]) -> String {
53 const CHARS: &[u8; 16] = b"0123456789abcdef";
54 let mut out = String::with_capacity(bytes.len() * 2);
55 for b in bytes {
56 out.push(CHARS[(b >> 4) as usize] as char);
57 out.push(CHARS[(b & 0x0f) as usize] as char);
58 }
59 out
60}
61
62pub fn parse_hex_16(s: &str) -> Result<[u8; 16]> {
63 let clean = s.trim();
64 if clean.len() != 32 {
65 return Err(Error::msg("expected 32 hex characters"));
66 }
67 let mut out = [0u8; 16];
68 for i in 0..16 {
69 out[i] = parse_hex_byte(&clean[i * 2..i * 2 + 2])?;
70 }
71 Ok(out)
72}
73
74fn parse_hex_byte(s: &str) -> Result<u8> {
75 u8::from_str_radix(s, 16).map_err(|_| Error::msg("invalid hex"))
76}
77
78pub fn parse_rns_url(url: &str) -> Result<([u8; 16], String)> {
79 parse_rns_url_with_aliases(url, &BTreeMap::new())
80}
81
82pub fn parse_rns_url_with_aliases(
83 url: &str,
84 destination_aliases: &BTreeMap<String, [u8; 16]>,
85) -> Result<([u8; 16], String)> {
86 let rest = url
87 .strip_prefix("rns://")
88 .ok_or_else(|| Error::msg("RNS Git URL must start with rns://"))?;
89 let (hash, repo) = rest
90 .split_once('/')
91 .ok_or_else(|| Error::msg("RNS Git URL must be rns://<destination>/<repo>"))?;
92 let dest_hash = destination_aliases
93 .get(hash)
94 .copied()
95 .map(Ok)
96 .unwrap_or_else(|| parse_hex_16(hash))?;
97 validate_repo_name(repo)?;
98 Ok((dest_hash, repo.trim_matches('/').to_string()))
99}
100
101pub fn resolve_rns_url_aliases(
102 url: &str,
103 destination_aliases: &BTreeMap<String, [u8; 16]>,
104) -> Result<String> {
105 if !url.starts_with("rns://") {
106 return Ok(url.to_string());
107 }
108 let (dest_hash, repo) = parse_rns_url_with_aliases(url, destination_aliases)?;
109 Ok(format!("rns://{}/{}", hex(&dest_hash), repo))
110}
111
112pub fn validate_repo_name(name: &str) -> Result<()> {
113 let trimmed = name.trim_matches('/');
114 if trimmed.is_empty() || trimmed.len() > 256 {
115 return Err(Error::msg("invalid repository name"));
116 }
117 for component in trimmed.split('/') {
118 if component.is_empty() || component == "." || component == ".." || component.contains('\\')
119 {
120 return Err(Error::msg("invalid repository name"));
121 }
122 }
123 Ok(())
124}
125
126pub const MAX_UPSTREAM_SOURCE_LEN: usize = 2048;
128
129pub const UPSTREAM_SOURCE_SCHEMES: &[&str] = &["https://", "http://", "git://", "ssh://", "rns://"];
131
132pub fn validate_upstream_source(source: &str) -> Result<()> {
139 let invalid = || Error::msg("invalid upstream source");
140 if source.is_empty()
141 || source.len() > MAX_UPSTREAM_SOURCE_LEN
142 || source.starts_with('-')
143 || source.chars().any(|c| c.is_control() || c.is_whitespace())
144 {
145 return Err(invalid());
146 }
147 let rest = UPSTREAM_SOURCE_SCHEMES
148 .iter()
149 .find_map(|scheme| source.strip_prefix(scheme))
150 .ok_or_else(|| Error::msg("unsupported upstream source scheme"))?;
151 if source.starts_with("rns://") {
152 return parse_rns_url(source).map(|_| ()).map_err(|_| invalid());
153 }
154 let authority = rest.split('/').next().unwrap_or_default();
155 let host = authority.rsplit('@').next().unwrap_or_default();
156 if authority.starts_with('-') || host.is_empty() || host.starts_with('-') {
157 return Err(invalid());
158 }
159 Ok(())
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use std::sync::{Mutex, OnceLock};
166
167 #[test]
168 fn parse_url_extracts_destination_and_repo() {
169 let (hash, repo) =
170 parse_rns_url("rns://00112233445566778899aabbccddeeff/group/repo").unwrap();
171 assert_eq!(hash[0], 0x00);
172 assert_eq!(hash[15], 0xff);
173 assert_eq!(repo, "group/repo");
174 }
175
176 #[test]
177 fn parse_url_resolves_destination_aliases() {
178 let mut aliases = BTreeMap::new();
179 aliases.insert(
180 "home".to_string(),
181 [
182 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
183 0xee, 0xff,
184 ],
185 );
186
187 let (hash, repo) = parse_rns_url_with_aliases("rns://home/group/repo", &aliases).unwrap();
188
189 assert_eq!(hash[0], 0x00);
190 assert_eq!(hash[15], 0xff);
191 assert_eq!(repo, "group/repo");
192 }
193
194 #[test]
195 fn resolve_url_aliases_canonicalizes_rns_urls() {
196 let mut aliases = BTreeMap::new();
197 aliases.insert(
198 "home".to_string(),
199 [
200 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
201 0xee, 0xff,
202 ],
203 );
204
205 assert_eq!(
206 resolve_rns_url_aliases("rns://home/group/repo", &aliases).unwrap(),
207 "rns://00112233445566778899aabbccddeeff/group/repo"
208 );
209 assert_eq!(
210 resolve_rns_url_aliases("https://example.invalid/repo.git", &aliases).unwrap(),
211 "https://example.invalid/repo.git"
212 );
213 }
214
215 #[test]
216 fn parse_url_rejects_unknown_destination_aliases() {
217 let aliases = BTreeMap::new();
218 assert!(parse_rns_url_with_aliases("rns://unknown/group/repo", &aliases).is_err());
219 }
220
221 #[test]
222 fn rejects_path_traversal_repo_names() {
223 assert!(validate_repo_name("../repo").is_err());
224 assert!(validate_repo_name("group/../repo").is_err());
225 assert!(validate_repo_name("group/repo").is_ok());
226 }
227
228 #[test]
229 fn default_rngit_dir_prefers_xdg_config_path() {
230 let _guard = env_lock().lock().unwrap();
231 let tmp = tempfile::tempdir().unwrap();
232 let saved = SavedEnv::capture();
233 std::env::remove_var("RNGIT_CONFIG");
234 std::env::remove_var("XDG_CONFIG_HOME");
235 std::env::set_var("HOME", tmp.path());
236
237 assert_eq!(
238 default_rngit_dir(),
239 tmp.path().join(".config").join("rngit")
240 );
241
242 saved.restore();
243 }
244
245 #[test]
246 fn default_rngit_dir_keeps_existing_legacy_path() {
247 let _guard = env_lock().lock().unwrap();
248 let tmp = tempfile::tempdir().unwrap();
249 let saved = SavedEnv::capture();
250 std::env::remove_var("RNGIT_CONFIG");
251 std::env::remove_var("XDG_CONFIG_HOME");
252 std::env::set_var("HOME", tmp.path());
253 std::fs::create_dir_all(tmp.path().join(".rngit")).unwrap();
254
255 assert_eq!(default_rngit_dir(), tmp.path().join(".rngit"));
256
257 saved.restore();
258 }
259
260 #[test]
261 fn default_rngit_dir_prefers_config_dir_over_legacy_path() {
262 let _guard = env_lock().lock().unwrap();
263 let tmp = tempfile::tempdir().unwrap();
264 let saved = SavedEnv::capture();
265 std::env::remove_var("RNGIT_CONFIG");
266 std::env::remove_var("XDG_CONFIG_HOME");
267 std::env::set_var("HOME", tmp.path());
268 std::fs::create_dir_all(tmp.path().join(".rngit")).unwrap();
269 std::fs::create_dir_all(tmp.path().join(".config").join("rngit")).unwrap();
270
271 assert_eq!(
272 default_rngit_dir(),
273 tmp.path().join(".config").join("rngit")
274 );
275
276 saved.restore();
277 }
278
279 fn env_lock() -> &'static Mutex<()> {
280 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
281 LOCK.get_or_init(|| Mutex::new(()))
282 }
283
284 struct SavedEnv {
285 home: Option<std::ffi::OsString>,
286 xdg_config_home: Option<std::ffi::OsString>,
287 rngit_config: Option<std::ffi::OsString>,
288 }
289
290 impl SavedEnv {
291 fn capture() -> Self {
292 Self {
293 home: std::env::var_os("HOME"),
294 xdg_config_home: std::env::var_os("XDG_CONFIG_HOME"),
295 rngit_config: std::env::var_os("RNGIT_CONFIG"),
296 }
297 }
298
299 fn restore(self) {
300 restore_var("HOME", self.home);
301 restore_var("XDG_CONFIG_HOME", self.xdg_config_home);
302 restore_var("RNGIT_CONFIG", self.rngit_config);
303 }
304 }
305
306 fn restore_var(key: &str, value: Option<std::ffi::OsString>) {
307 match value {
308 Some(value) => std::env::set_var(key, value),
309 None => std::env::remove_var(key),
310 }
311 }
312
313 #[test]
314 fn upstream_source_accepts_supported_schemes() {
315 for source in [
316 "https://example.invalid/project.git",
317 "http://example.invalid:8080/project.git",
318 "git://127.0.0.1:9418/project",
319 "ssh://git@example.invalid/project.git",
320 "rns://00112233445566778899aabbccddeeff/group/repo",
321 ] {
322 assert!(
323 validate_upstream_source(source).is_ok(),
324 "{source:?} should be accepted"
325 );
326 }
327 }
328
329 #[test]
330 fn upstream_source_rejects_unsupported_or_malformed_values() {
331 let too_long = format!(
332 "https://example.invalid/{}",
333 "a".repeat(MAX_UPSTREAM_SOURCE_LEN)
334 );
335 for source in [
336 "",
337 "--option=value",
338 "-oflag=value",
339 "/srv/git/project.git",
340 "../project.git",
341 "file:///srv/git/project.git",
342 "ext::example",
343 "git@example.invalid:project.git",
344 "HTTPS://example.invalid/project.git",
345 "https://",
346 "https:///project.git",
347 "ssh://-flag=value/project",
348 "ssh://user@-host/project",
349 "https://example.invalid/pro ject.git",
350 "https://example.invalid/project.git\n",
351 "https://example.invalid/\tproject.git",
352 "https://example.invalid/project\u{7f}.git",
353 "rns://not-a-hash/group/repo",
354 "rns://00112233445566778899aabbccddeeff/../repo",
355 too_long.as_str(),
356 ] {
357 assert!(
358 validate_upstream_source(source).is_err(),
359 "{source:?} should be rejected"
360 );
361 }
362 }
363}