sparsync 0.1.12

rsync-style high-performance file synchronization over QUIC and Spargio
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncProfile {
    pub name: String,
    pub server: String,
    pub server_name: String,
    pub ca: PathBuf,
    pub client_cert: Option<PathBuf>,
    pub client_key: Option<PathBuf>,
    pub ssh_target: Option<String>,
    pub destination: Option<String>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
struct ProfileStore {
    profiles: Vec<SyncProfile>,
}

fn env_root(var: &str) -> Option<PathBuf> {
    let value = std::env::var(var).ok()?;
    let path = PathBuf::from(value);
    if path.as_os_str().is_empty() {
        return None;
    }
    Some(path)
}

fn config_root() -> PathBuf {
    if let Ok(dir) = std::env::var("SPARSYNC_CONFIG_DIR") {
        let path = PathBuf::from(dir);
        if !path.as_os_str().is_empty() {
            return path;
        }
    }

    if let Some(mut path) = env_root("XDG_CONFIG_HOME") {
        path.push("sparsync");
        return path;
    }

    if let Ok(home) = std::env::var("HOME") {
        let mut path = PathBuf::from(home);
        path.push(".config");
        path.push("sparsync");
        return path;
    }

    PathBuf::from(".sparsync")
}

fn data_root() -> PathBuf {
    if let Some(path) = env_root("SPARSYNC_DATA_DIR") {
        return path;
    }

    if let Some(mut path) = env_root("XDG_DATA_HOME") {
        path.push("sparsync");
        return path;
    }

    if let Ok(home) = std::env::var("HOME") {
        let mut path = PathBuf::from(home);
        path.push(".local");
        path.push("share");
        path.push("sparsync");
        return path;
    }

    PathBuf::from(".sparsync-data")
}

fn set_mode_if_unix(path: &std::path::Path, mode: u32) -> Result<()> {
    #[cfg(unix)]
    {
        let mut perms = std::fs::metadata(path)
            .with_context(|| format!("stat {}", path.display()))?
            .permissions();
        perms.set_mode(mode);
        std::fs::set_permissions(path, perms)
            .with_context(|| format!("chmod {:o} {}", mode, path.display()))?;
    }
    #[cfg(not(unix))]
    {
        let _ = (path, mode);
    }
    Ok(())
}

fn ensure_dir(path: &std::path::Path, mode: u32) -> Result<()> {
    std::fs::create_dir_all(path).with_context(|| format!("create {}", path.display()))?;
    set_mode_if_unix(path, mode)
}

pub fn ensure_config_root() -> Result<PathBuf> {
    let dir = config_root();
    ensure_dir(&dir, 0o755)?;
    Ok(dir)
}

pub fn ensure_data_root() -> Result<PathBuf> {
    let dir = data_root();
    ensure_dir(&dir, 0o755)?;
    Ok(dir)
}

pub fn ensure_secrets_root() -> Result<PathBuf> {
    let mut dir = ensure_data_root()?;
    dir.push("secrets");
    ensure_dir(&dir, 0o700)?;
    Ok(dir)
}

pub fn profiles_path() -> Result<PathBuf> {
    let mut path = ensure_config_root()?;
    path.push("profiles.json");
    Ok(path)
}

fn profile_component(value: &str) -> Result<String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        bail!("profile name cannot be empty");
    }
    let out: String = trimmed
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
                c
            } else {
                '_'
            }
        })
        .collect();
    if out.trim_matches('_').is_empty() {
        bail!("profile name '{}' has no usable path component", value);
    }
    Ok(out)
}

pub fn ensure_profile_secret_dir(profile_name: &str) -> Result<PathBuf> {
    let profile_component = profile_component(profile_name)?;
    let mut root = ensure_secrets_root()?;
    root.push("profiles");
    ensure_dir(&root, 0o700)?;

    let mut path = root;
    path.push(profile_component);
    ensure_dir(&path, 0o700)?;
    migrate_legacy_bootstrap(profile_name, &path)?;
    Ok(path)
}

fn migrate_legacy_bootstrap(profile_name: &str, target_dir: &std::path::Path) -> Result<()> {
    let mut legacy = ensure_config_root()?;
    legacy.push("bootstrap");
    legacy.push(profile_name);
    if !legacy.exists() || legacy == target_dir {
        return Ok(());
    }

    let entries = std::fs::read_dir(&legacy)
        .with_context(|| format!("read legacy bootstrap dir {}", legacy.display()))?;
    for entry in entries {
        let entry = entry.with_context(|| format!("read entry in {}", legacy.display()))?;
        let file_type = entry
            .file_type()
            .with_context(|| format!("stat {}", entry.path().display()))?;
        if !file_type.is_file() {
            continue;
        }
        let dest = target_dir.join(entry.file_name());
        if dest.exists() {
            continue;
        }
        std::fs::copy(entry.path(), &dest).with_context(|| {
            format!(
                "copy legacy secret {} -> {}",
                entry.path().display(),
                dest.display()
            )
        })?;
        set_mode_if_unix(&dest, 0o600)?;
    }
    Ok(())
}

pub fn write_secret_file(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
    if let Some(parent) = path.parent() {
        ensure_dir(parent, 0o700)?;
    }
    std::fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?;
    set_mode_if_unix(path, 0o600)?;
    Ok(())
}

pub fn enforce_private_file(path: &std::path::Path) -> Result<()> {
    if !path.exists() {
        return Ok(());
    }
    #[cfg(unix)]
    {
        let mode = std::fs::metadata(path)
            .with_context(|| format!("stat {}", path.display()))?
            .permissions()
            .mode()
            & 0o777;
        if mode & 0o077 != 0 {
            bail!(
                "secret file {} has insecure permissions {:o} (expected 0600 or stricter)",
                path.display(),
                mode
            );
        }
    }
    Ok(())
}

fn load_store() -> Result<ProfileStore> {
    let path = profiles_path()?;
    let bytes = match std::fs::read(&path) {
        Ok(v) => v,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            return Ok(ProfileStore::default());
        }
        Err(err) => {
            return Err(err).with_context(|| format!("read {}", path.display()));
        }
    };
    let store: ProfileStore =
        serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
    Ok(store)
}

fn save_store(store: &ProfileStore) -> Result<()> {
    let path = profiles_path()?;
    let bytes = serde_json::to_vec_pretty(store).context("serialize profile store")?;
    std::fs::write(&path, bytes).with_context(|| format!("write {}", path.display()))?;
    Ok(())
}

pub fn get_profile(name: &str) -> Result<SyncProfile> {
    let store = load_store()?;
    let profile = store
        .profiles
        .into_iter()
        .find(|p| p.name == name)
        .ok_or_else(|| anyhow::anyhow!("profile '{}' not found", name))?;
    if let Some(client_key) = profile.client_key.as_deref() {
        enforce_private_file(client_key)?;
    }
    Ok(profile)
}

pub fn upsert_profile(profile: SyncProfile) -> Result<()> {
    if profile.name.trim().is_empty() {
        bail!("profile name cannot be empty");
    }
    let mut store = load_store()?;
    if let Some(existing) = store.profiles.iter_mut().find(|v| v.name == profile.name) {
        *existing = profile;
    } else {
        store.profiles.push(profile);
    }
    save_store(&store)
}