open-envault 0.4.0

Local-first SOPS-compatible secrets for Git and Rust
Documentation
use anyhow::{Context, bail};
use serde::{Deserialize, Serialize};
use std::{
    collections::BTreeMap,
    fs::{self, OpenOptions},
    io::Write,
    path::{Path, PathBuf},
    time::{SystemTime, UNIX_EPOCH},
};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
    pub project: String,
    pub environments: BTreeMap<String, Environment>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Environment {
    pub file: String,
    #[serde(default = "default_schema")]
    pub schema: String,
    #[serde(default)]
    pub recipients: Vec<String>,
    #[serde(default)]
    pub key_file: Option<String>,
    #[serde(default)]
    pub editor: Option<String>,
}
fn default_schema() -> String {
    "config/env.schema.yaml".into()
}
pub fn discover(start: &Path) -> anyhow::Result<PathBuf> {
    let mut current = start.canonicalize()?;
    loop {
        let candidate = current.join("open-envault.yaml");
        if candidate.is_file() {
            return Ok(candidate);
        }
        if !current.pop() {
            bail!("open-envault.yaml not found")
        }
    }
}
pub fn load(path: &Path) -> anyhow::Result<Project> {
    let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
    let project: Project = yaml_serde::from_str(&text).context("parse open-envault.yaml")?;
    if project.project.trim().is_empty() {
        bail!("project name must not be empty")
    }
    if project.environments.is_empty() {
        bail!("project must define at least one environment")
    }
    for (name, profile) in &project.environments {
        if name.trim().is_empty() || name.contains('/') || name.contains('\\') {
            bail!("environment name is not path-safe: {name}")
        }
        if profile.file.trim().is_empty() || profile.schema.trim().is_empty() {
            bail!("environment {name} must define file and schema paths")
        }
        for recipient in &profile.recipients {
            crate::crypto::keys::parse_recipient(recipient)
                .with_context(|| format!("invalid recipient configured for {name}"))?;
        }
    }
    Ok(project)
}
pub fn environment<'a>(project: &'a Project, name: &str) -> anyhow::Result<&'a Environment> {
    project
        .environments
        .get(name)
        .with_context(|| format!("unknown environment: {name}"))
}
pub fn atomic_write(path: &Path, content: &[u8]) -> anyhow::Result<()> {
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    fs::create_dir_all(parent)?;
    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("out");
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    let tmp = parent.join(format!(".{name}.{}.{}.tmp", std::process::id(), nonce));
    let mut file = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
    file.write_all(content)?;
    file.sync_all()?;
    drop(file);
    replace_file(&tmp, path).inspect_err(|_error| {
        let _ = fs::remove_file(&tmp);
    })?;
    #[cfg(unix)]
    fs::File::open(parent)?.sync_all()?;
    Ok(())
}

#[cfg(not(windows))]
fn replace_file(source: &Path, destination: &Path) -> std::io::Result<()> {
    fs::rename(source, destination)
}

#[cfg(windows)]
fn replace_file(source: &Path, destination: &Path) -> std::io::Result<()> {
    use std::os::windows::ffi::OsStrExt;
    use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MoveFileExW};
    let source: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
    let destination: Vec<u16> = destination
        .as_os_str()
        .encode_wide()
        .chain(Some(0))
        .collect();
    if unsafe {
        MoveFileExW(
            source.as_ptr(),
            destination.as_ptr(),
            MOVEFILE_REPLACE_EXISTING,
        )
    } == 0
    {
        Err(std::io::Error::last_os_error())
    } else {
        Ok(())
    }
}

pub fn resolve_path(value: &str, base: &Path) -> PathBuf {
    let expanded = if let Some(rest) = value.strip_prefix("~/") {
        std::env::var_os("HOME")
            .or_else(|| std::env::var_os("USERPROFILE"))
            .map(PathBuf::from)
            .unwrap_or_default()
            .join(rest)
    } else {
        PathBuf::from(value)
    };
    if expanded.is_absolute() {
        expanded
    } else {
        base.join(expanded)
    }
}