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
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use std::sync::{Mutex, OnceLock};
130
131 #[test]
132 fn parse_url_extracts_destination_and_repo() {
133 let (hash, repo) =
134 parse_rns_url("rns://00112233445566778899aabbccddeeff/group/repo").unwrap();
135 assert_eq!(hash[0], 0x00);
136 assert_eq!(hash[15], 0xff);
137 assert_eq!(repo, "group/repo");
138 }
139
140 #[test]
141 fn parse_url_resolves_destination_aliases() {
142 let mut aliases = BTreeMap::new();
143 aliases.insert(
144 "home".to_string(),
145 [
146 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
147 0xee, 0xff,
148 ],
149 );
150
151 let (hash, repo) = parse_rns_url_with_aliases("rns://home/group/repo", &aliases).unwrap();
152
153 assert_eq!(hash[0], 0x00);
154 assert_eq!(hash[15], 0xff);
155 assert_eq!(repo, "group/repo");
156 }
157
158 #[test]
159 fn resolve_url_aliases_canonicalizes_rns_urls() {
160 let mut aliases = BTreeMap::new();
161 aliases.insert(
162 "home".to_string(),
163 [
164 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
165 0xee, 0xff,
166 ],
167 );
168
169 assert_eq!(
170 resolve_rns_url_aliases("rns://home/group/repo", &aliases).unwrap(),
171 "rns://00112233445566778899aabbccddeeff/group/repo"
172 );
173 assert_eq!(
174 resolve_rns_url_aliases("https://example.invalid/repo.git", &aliases).unwrap(),
175 "https://example.invalid/repo.git"
176 );
177 }
178
179 #[test]
180 fn parse_url_rejects_unknown_destination_aliases() {
181 let aliases = BTreeMap::new();
182 assert!(parse_rns_url_with_aliases("rns://unknown/group/repo", &aliases).is_err());
183 }
184
185 #[test]
186 fn rejects_path_traversal_repo_names() {
187 assert!(validate_repo_name("../repo").is_err());
188 assert!(validate_repo_name("group/../repo").is_err());
189 assert!(validate_repo_name("group/repo").is_ok());
190 }
191
192 #[test]
193 fn default_rngit_dir_prefers_xdg_config_path() {
194 let _guard = env_lock().lock().unwrap();
195 let tmp = tempfile::tempdir().unwrap();
196 let saved = SavedEnv::capture();
197 std::env::remove_var("RNGIT_CONFIG");
198 std::env::remove_var("XDG_CONFIG_HOME");
199 std::env::set_var("HOME", tmp.path());
200
201 assert_eq!(
202 default_rngit_dir(),
203 tmp.path().join(".config").join("rngit")
204 );
205
206 saved.restore();
207 }
208
209 #[test]
210 fn default_rngit_dir_keeps_existing_legacy_path() {
211 let _guard = env_lock().lock().unwrap();
212 let tmp = tempfile::tempdir().unwrap();
213 let saved = SavedEnv::capture();
214 std::env::remove_var("RNGIT_CONFIG");
215 std::env::remove_var("XDG_CONFIG_HOME");
216 std::env::set_var("HOME", tmp.path());
217 std::fs::create_dir_all(tmp.path().join(".rngit")).unwrap();
218
219 assert_eq!(default_rngit_dir(), tmp.path().join(".rngit"));
220
221 saved.restore();
222 }
223
224 fn env_lock() -> &'static Mutex<()> {
225 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
226 LOCK.get_or_init(|| Mutex::new(()))
227 }
228
229 struct SavedEnv {
230 home: Option<std::ffi::OsString>,
231 xdg_config_home: Option<std::ffi::OsString>,
232 rngit_config: Option<std::ffi::OsString>,
233 }
234
235 impl SavedEnv {
236 fn capture() -> Self {
237 Self {
238 home: std::env::var_os("HOME"),
239 xdg_config_home: std::env::var_os("XDG_CONFIG_HOME"),
240 rngit_config: std::env::var_os("RNGIT_CONFIG"),
241 }
242 }
243
244 fn restore(self) {
245 restore_var("HOME", self.home);
246 restore_var("XDG_CONFIG_HOME", self.xdg_config_home);
247 restore_var("RNGIT_CONFIG", self.rngit_config);
248 }
249 }
250
251 fn restore_var(key: &str, value: Option<std::ffi::OsString>) {
252 match value {
253 Some(value) => std::env::set_var(key, value),
254 None => std::env::remove_var(key),
255 }
256 }
257}