Skip to main content

rns_git/
util.rs

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}