1use std::fs;
2use std::path::{Path, PathBuf};
3
4use rns_crypto::identity::Identity;
5use rns_crypto::OsRng;
6use rns_net::storage;
7
8use crate::{Error, Result};
9
10pub fn default_rngit_dir() -> PathBuf {
11 if let Some(path) = std::env::var_os("RNGIT_CONFIG") {
12 return PathBuf::from(path);
13 }
14 if let Some(home) = std::env::var_os("HOME") {
15 let home = PathBuf::from(home);
16 let legacy = home.join(".rngit");
17 let xdg = std::env::var_os("XDG_CONFIG_HOME")
18 .map(PathBuf::from)
19 .unwrap_or_else(|| home.join(".config"))
20 .join("rngit");
21 if xdg.exists() || !legacy.exists() {
22 return xdg;
23 }
24 return legacy;
25 }
26 PathBuf::from(".rngit")
27}
28
29pub fn default_reticulum_dir() -> Option<PathBuf> {
30 std::env::var_os("RNS_CONFIG").map(PathBuf::from)
31}
32
33pub fn ensure_dir(path: &Path) -> Result<()> {
34 fs::create_dir_all(path)?;
35 Ok(())
36}
37
38pub fn load_or_create_identity(path: &Path) -> Result<Identity> {
39 if path.exists() {
40 return storage::load_identity(path).map_err(Error::from);
41 }
42
43 if let Some(parent) = path.parent() {
44 fs::create_dir_all(parent)?;
45 }
46 let identity = Identity::new(&mut OsRng);
47 storage::save_identity(&identity, path)?;
48 Ok(identity)
49}
50
51pub fn hex(bytes: &[u8]) -> String {
52 const CHARS: &[u8; 16] = b"0123456789abcdef";
53 let mut out = String::with_capacity(bytes.len() * 2);
54 for b in bytes {
55 out.push(CHARS[(b >> 4) as usize] as char);
56 out.push(CHARS[(b & 0x0f) as usize] as char);
57 }
58 out
59}
60
61pub fn parse_hex_16(s: &str) -> Result<[u8; 16]> {
62 let clean = s.trim();
63 if clean.len() != 32 {
64 return Err(Error::msg("expected 32 hex characters"));
65 }
66 let mut out = [0u8; 16];
67 for i in 0..16 {
68 out[i] = parse_hex_byte(&clean[i * 2..i * 2 + 2])?;
69 }
70 Ok(out)
71}
72
73fn parse_hex_byte(s: &str) -> Result<u8> {
74 u8::from_str_radix(s, 16).map_err(|_| Error::msg("invalid hex"))
75}
76
77pub fn parse_rns_url(url: &str) -> Result<([u8; 16], String)> {
78 let rest = url
79 .strip_prefix("rns://")
80 .ok_or_else(|| Error::msg("RNS Git URL must start with rns://"))?;
81 let (hash, repo) = rest
82 .split_once('/')
83 .ok_or_else(|| Error::msg("RNS Git URL must be rns://<destination>/<repo>"))?;
84 let dest_hash = parse_hex_16(hash)?;
85 validate_repo_name(repo)?;
86 Ok((dest_hash, repo.trim_matches('/').to_string()))
87}
88
89pub fn validate_repo_name(name: &str) -> Result<()> {
90 let trimmed = name.trim_matches('/');
91 if trimmed.is_empty() || trimmed.len() > 256 {
92 return Err(Error::msg("invalid repository name"));
93 }
94 for component in trimmed.split('/') {
95 if component.is_empty() || component == "." || component == ".." || component.contains('\\')
96 {
97 return Err(Error::msg("invalid repository name"));
98 }
99 }
100 Ok(())
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use std::sync::{Mutex, OnceLock};
107
108 #[test]
109 fn parse_url_extracts_destination_and_repo() {
110 let (hash, repo) =
111 parse_rns_url("rns://00112233445566778899aabbccddeeff/group/repo").unwrap();
112 assert_eq!(hash[0], 0x00);
113 assert_eq!(hash[15], 0xff);
114 assert_eq!(repo, "group/repo");
115 }
116
117 #[test]
118 fn rejects_path_traversal_repo_names() {
119 assert!(validate_repo_name("../repo").is_err());
120 assert!(validate_repo_name("group/../repo").is_err());
121 assert!(validate_repo_name("group/repo").is_ok());
122 }
123
124 #[test]
125 fn default_rngit_dir_prefers_xdg_config_path() {
126 let _guard = env_lock().lock().unwrap();
127 let tmp = tempfile::tempdir().unwrap();
128 let saved = SavedEnv::capture();
129 std::env::remove_var("RNGIT_CONFIG");
130 std::env::remove_var("XDG_CONFIG_HOME");
131 std::env::set_var("HOME", tmp.path());
132
133 assert_eq!(
134 default_rngit_dir(),
135 tmp.path().join(".config").join("rngit")
136 );
137
138 saved.restore();
139 }
140
141 #[test]
142 fn default_rngit_dir_keeps_existing_legacy_path() {
143 let _guard = env_lock().lock().unwrap();
144 let tmp = tempfile::tempdir().unwrap();
145 let saved = SavedEnv::capture();
146 std::env::remove_var("RNGIT_CONFIG");
147 std::env::remove_var("XDG_CONFIG_HOME");
148 std::env::set_var("HOME", tmp.path());
149 std::fs::create_dir_all(tmp.path().join(".rngit")).unwrap();
150
151 assert_eq!(default_rngit_dir(), tmp.path().join(".rngit"));
152
153 saved.restore();
154 }
155
156 fn env_lock() -> &'static Mutex<()> {
157 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
158 LOCK.get_or_init(|| Mutex::new(()))
159 }
160
161 struct SavedEnv {
162 home: Option<std::ffi::OsString>,
163 xdg_config_home: Option<std::ffi::OsString>,
164 rngit_config: Option<std::ffi::OsString>,
165 }
166
167 impl SavedEnv {
168 fn capture() -> Self {
169 Self {
170 home: std::env::var_os("HOME"),
171 xdg_config_home: std::env::var_os("XDG_CONFIG_HOME"),
172 rngit_config: std::env::var_os("RNGIT_CONFIG"),
173 }
174 }
175
176 fn restore(self) {
177 restore_var("HOME", self.home);
178 restore_var("XDG_CONFIG_HOME", self.xdg_config_home);
179 restore_var("RNGIT_CONFIG", self.rngit_config);
180 }
181 }
182
183 fn restore_var(key: &str, value: Option<std::ffi::OsString>) {
184 match value {
185 Some(value) => std::env::set_var(key, value),
186 None => std::env::remove_var(key),
187 }
188 }
189}