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/// Longest upstream source URL accepted for forks and mirrors.
127pub const MAX_UPSTREAM_SOURCE_LEN: usize = 2048;
128
129/// URL schemes accepted as fork and mirror upstream sources.
130pub const UPSTREAM_SOURCE_SCHEMES: &[&str] = &["https://", "http://", "git://", "ssh://", "rns://"];
131
132/// Validate a fork or mirror upstream source before passing it to git.
133///
134/// Accepts only URLs with a scheme from [`UPSTREAM_SOURCE_SCHEMES`] and a
135/// non-empty host, without whitespace or control characters, and at most
136/// [`MAX_UPSTREAM_SOURCE_LEN`] bytes long. `rns://` sources must be canonical
137/// `rns://<destination_hash>/<repository>` URLs.
138pub 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    #[test]
261    fn default_rngit_dir_prefers_config_dir_over_legacy_path() {
262        let _guard = env_lock().lock().unwrap();
263        let tmp = tempfile::tempdir().unwrap();
264        let saved = SavedEnv::capture();
265        std::env::remove_var("RNGIT_CONFIG");
266        std::env::remove_var("XDG_CONFIG_HOME");
267        std::env::set_var("HOME", tmp.path());
268        std::fs::create_dir_all(tmp.path().join(".rngit")).unwrap();
269        std::fs::create_dir_all(tmp.path().join(".config").join("rngit")).unwrap();
270
271        assert_eq!(
272            default_rngit_dir(),
273            tmp.path().join(".config").join("rngit")
274        );
275
276        saved.restore();
277    }
278
279    fn env_lock() -> &'static Mutex<()> {
280        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
281        LOCK.get_or_init(|| Mutex::new(()))
282    }
283
284    struct SavedEnv {
285        home: Option<std::ffi::OsString>,
286        xdg_config_home: Option<std::ffi::OsString>,
287        rngit_config: Option<std::ffi::OsString>,
288    }
289
290    impl SavedEnv {
291        fn capture() -> Self {
292            Self {
293                home: std::env::var_os("HOME"),
294                xdg_config_home: std::env::var_os("XDG_CONFIG_HOME"),
295                rngit_config: std::env::var_os("RNGIT_CONFIG"),
296            }
297        }
298
299        fn restore(self) {
300            restore_var("HOME", self.home);
301            restore_var("XDG_CONFIG_HOME", self.xdg_config_home);
302            restore_var("RNGIT_CONFIG", self.rngit_config);
303        }
304    }
305
306    fn restore_var(key: &str, value: Option<std::ffi::OsString>) {
307        match value {
308            Some(value) => std::env::set_var(key, value),
309            None => std::env::remove_var(key),
310        }
311    }
312
313    #[test]
314    fn upstream_source_accepts_supported_schemes() {
315        for source in [
316            "https://example.invalid/project.git",
317            "http://example.invalid:8080/project.git",
318            "git://127.0.0.1:9418/project",
319            "ssh://git@example.invalid/project.git",
320            "rns://00112233445566778899aabbccddeeff/group/repo",
321        ] {
322            assert!(
323                validate_upstream_source(source).is_ok(),
324                "{source:?} should be accepted"
325            );
326        }
327    }
328
329    #[test]
330    fn upstream_source_rejects_unsupported_or_malformed_values() {
331        let too_long = format!(
332            "https://example.invalid/{}",
333            "a".repeat(MAX_UPSTREAM_SOURCE_LEN)
334        );
335        for source in [
336            "",
337            "--option=value",
338            "-oflag=value",
339            "/srv/git/project.git",
340            "../project.git",
341            "file:///srv/git/project.git",
342            "ext::example",
343            "git@example.invalid:project.git",
344            "HTTPS://example.invalid/project.git",
345            "https://",
346            "https:///project.git",
347            "ssh://-flag=value/project",
348            "ssh://user@-host/project",
349            "https://example.invalid/pro ject.git",
350            "https://example.invalid/project.git\n",
351            "https://example.invalid/\tproject.git",
352            "https://example.invalid/project\u{7f}.git",
353            "rns://not-a-hash/group/repo",
354            "rns://00112233445566778899aabbccddeeff/../repo",
355            too_long.as_str(),
356        ] {
357            assert!(
358                validate_upstream_source(source).is_err(),
359                "{source:?} should be rejected"
360            );
361        }
362    }
363}