rns-git 0.1.0

Reticulum Git transport tools
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use rns_crypto::identity::Identity;
use rns_crypto::OsRng;
use rns_net::storage;

use crate::{Error, Result};

pub fn default_rngit_dir() -> PathBuf {
    std::env::var_os("RNGIT_CONFIG")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".rngit")))
        .unwrap_or_else(|| PathBuf::from(".rngit"))
}

pub fn default_reticulum_dir() -> Option<PathBuf> {
    std::env::var_os("RNS_CONFIG").map(PathBuf::from)
}

pub fn ensure_dir(path: &Path) -> Result<()> {
    fs::create_dir_all(path)?;
    Ok(())
}

pub fn load_or_create_identity(path: &Path) -> Result<Identity> {
    if path.exists() {
        return storage::load_identity(path).map_err(Error::from);
    }

    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let identity = Identity::new(&mut OsRng);
    storage::save_identity(&identity, path)?;
    Ok(identity)
}

pub fn hex(bytes: &[u8]) -> String {
    const CHARS: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        out.push(CHARS[(b >> 4) as usize] as char);
        out.push(CHARS[(b & 0x0f) as usize] as char);
    }
    out
}

pub fn parse_hex_16(s: &str) -> Result<[u8; 16]> {
    let clean = s.trim();
    if clean.len() != 32 {
        return Err(Error::msg("expected 32 hex characters"));
    }
    let mut out = [0u8; 16];
    for i in 0..16 {
        out[i] = parse_hex_byte(&clean[i * 2..i * 2 + 2])?;
    }
    Ok(out)
}

fn parse_hex_byte(s: &str) -> Result<u8> {
    u8::from_str_radix(s, 16).map_err(|_| Error::msg("invalid hex"))
}

pub fn parse_rns_url(url: &str) -> Result<([u8; 16], String)> {
    let rest = url
        .strip_prefix("rns://")
        .ok_or_else(|| Error::msg("RNS Git URL must start with rns://"))?;
    let (hash, repo) = rest
        .split_once('/')
        .ok_or_else(|| Error::msg("RNS Git URL must be rns://<destination>/<repo>"))?;
    let dest_hash = parse_hex_16(hash)?;
    validate_repo_name(repo)?;
    Ok((dest_hash, repo.trim_matches('/').to_string()))
}

pub fn validate_repo_name(name: &str) -> Result<()> {
    let trimmed = name.trim_matches('/');
    if trimmed.is_empty() || trimmed.len() > 256 {
        return Err(Error::msg("invalid repository name"));
    }
    for component in trimmed.split('/') {
        if component.is_empty() || component == "." || component == ".." || component.contains('\\')
        {
            return Err(Error::msg("invalid repository name"));
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_url_extracts_destination_and_repo() {
        let (hash, repo) =
            parse_rns_url("rns://00112233445566778899aabbccddeeff/group/repo").unwrap();
        assert_eq!(hash[0], 0x00);
        assert_eq!(hash[15], 0xff);
        assert_eq!(repo, "group/repo");
    }

    #[test]
    fn rejects_path_traversal_repo_names() {
        assert!(validate_repo_name("../repo").is_err());
        assert!(validate_repo_name("group/../repo").is_err());
        assert!(validate_repo_name("group/repo").is_ok());
    }
}