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 std::env::var_os("RNGIT_CONFIG")
12 .map(PathBuf::from)
13 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".rngit")))
14 .unwrap_or_else(|| PathBuf::from(".rngit"))
15}
16
17pub fn default_reticulum_dir() -> Option<PathBuf> {
18 std::env::var_os("RNS_CONFIG").map(PathBuf::from)
19}
20
21pub fn ensure_dir(path: &Path) -> Result<()> {
22 fs::create_dir_all(path)?;
23 Ok(())
24}
25
26pub fn load_or_create_identity(path: &Path) -> Result<Identity> {
27 if path.exists() {
28 return storage::load_identity(path).map_err(Error::from);
29 }
30
31 if let Some(parent) = path.parent() {
32 fs::create_dir_all(parent)?;
33 }
34 let identity = Identity::new(&mut OsRng);
35 storage::save_identity(&identity, path)?;
36 Ok(identity)
37}
38
39pub fn hex(bytes: &[u8]) -> String {
40 const CHARS: &[u8; 16] = b"0123456789abcdef";
41 let mut out = String::with_capacity(bytes.len() * 2);
42 for b in bytes {
43 out.push(CHARS[(b >> 4) as usize] as char);
44 out.push(CHARS[(b & 0x0f) as usize] as char);
45 }
46 out
47}
48
49pub fn parse_hex_16(s: &str) -> Result<[u8; 16]> {
50 let clean = s.trim();
51 if clean.len() != 32 {
52 return Err(Error::msg("expected 32 hex characters"));
53 }
54 let mut out = [0u8; 16];
55 for i in 0..16 {
56 out[i] = parse_hex_byte(&clean[i * 2..i * 2 + 2])?;
57 }
58 Ok(out)
59}
60
61fn parse_hex_byte(s: &str) -> Result<u8> {
62 u8::from_str_radix(s, 16).map_err(|_| Error::msg("invalid hex"))
63}
64
65pub fn parse_rns_url(url: &str) -> Result<([u8; 16], String)> {
66 let rest = url
67 .strip_prefix("rns://")
68 .ok_or_else(|| Error::msg("RNS Git URL must start with rns://"))?;
69 let (hash, repo) = rest
70 .split_once('/')
71 .ok_or_else(|| Error::msg("RNS Git URL must be rns://<destination>/<repo>"))?;
72 let dest_hash = parse_hex_16(hash)?;
73 validate_repo_name(repo)?;
74 Ok((dest_hash, repo.trim_matches('/').to_string()))
75}
76
77pub fn validate_repo_name(name: &str) -> Result<()> {
78 let trimmed = name.trim_matches('/');
79 if trimmed.is_empty() || trimmed.len() > 256 {
80 return Err(Error::msg("invalid repository name"));
81 }
82 for component in trimmed.split('/') {
83 if component.is_empty() || component == "." || component == ".." || component.contains('\\')
84 {
85 return Err(Error::msg("invalid repository name"));
86 }
87 }
88 Ok(())
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[test]
96 fn parse_url_extracts_destination_and_repo() {
97 let (hash, repo) =
98 parse_rns_url("rns://00112233445566778899aabbccddeeff/group/repo").unwrap();
99 assert_eq!(hash[0], 0x00);
100 assert_eq!(hash[15], 0xff);
101 assert_eq!(repo, "group/repo");
102 }
103
104 #[test]
105 fn rejects_path_traversal_repo_names() {
106 assert!(validate_repo_name("../repo").is_err());
107 assert!(validate_repo_name("group/../repo").is_err());
108 assert!(validate_repo_name("group/repo").is_ok());
109 }
110}