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 fn env_lock() -> &'static Mutex<()> {
261 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
262 LOCK.get_or_init(|| Mutex::new(()))
263 }
264
265 struct SavedEnv {
266 home: Option<std::ffi::OsString>,
267 xdg_config_home: Option<std::ffi::OsString>,
268 rngit_config: Option<std::ffi::OsString>,
269 }
270
271 impl SavedEnv {
272 fn capture() -> Self {
273 Self {
274 home: std::env::var_os("HOME"),
275 xdg_config_home: std::env::var_os("XDG_CONFIG_HOME"),
276 rngit_config: std::env::var_os("RNGIT_CONFIG"),
277 }
278 }
279
280 fn restore(self) {
281 restore_var("HOME", self.home);
282 restore_var("XDG_CONFIG_HOME", self.xdg_config_home);
283 restore_var("RNGIT_CONFIG", self.rngit_config);
284 }
285 }
286
287 fn restore_var(key: &str, value: Option<std::ffi::OsString>) {
288 match value {
289 Some(value) => std::env::set_var(key, value),
290 None => std::env::remove_var(key),
291 }
292 }
293
294 #[test]
295 fn upstream_source_accepts_supported_schemes() {
296 for source in [
297 "https://example.invalid/project.git",
298 "http://example.invalid:8080/project.git",
299 "git://127.0.0.1:9418/project",
300 "ssh://git@example.invalid/project.git",
301 "rns://00112233445566778899aabbccddeeff/group/repo",
302 ] {
303 assert!(
304 validate_upstream_source(source).is_ok(),
305 "{source:?} should be accepted"
306 );
307 }
308 }
309
310 #[test]
311 fn upstream_source_rejects_unsupported_or_malformed_values() {
312 let too_long = format!(
313 "https://example.invalid/{}",
314 "a".repeat(MAX_UPSTREAM_SOURCE_LEN)
315 );
316 for source in [
317 "",
318 "--option=value",
319 "-oflag=value",
320 "/srv/git/project.git",
321 "../project.git",
322 "file:///srv/git/project.git",
323 "ext::example",
324 "git@example.invalid:project.git",
325 "HTTPS://example.invalid/project.git",
326 "https://",
327 "https:///project.git",
328 "ssh://-flag=value/project",
329 "ssh://user@-host/project",
330 "https://example.invalid/pro ject.git",
331 "https://example.invalid/project.git\n",
332 "https://example.invalid/\tproject.git",
333 "https://example.invalid/project\u{7f}.git",
334 "rns://not-a-hash/group/repo",
335 "rns://00112233445566778899aabbccddeeff/../repo",
336 too_long.as_str(),
337 ] {
338 assert!(
339 validate_upstream_source(source).is_err(),
340 "{source:?} should be rejected"
341 );
342 }
343 }
344}