Skip to main content

rns_git/
util.rs

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}