Skip to main content

open_envault/
core.rs

1use anyhow::{Context, bail};
2use serde::{Deserialize, Serialize};
3use std::{
4    collections::BTreeMap,
5    fs::{self, OpenOptions},
6    io::Write,
7    path::{Path, PathBuf},
8    time::{SystemTime, UNIX_EPOCH},
9};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Project {
13    pub project: String,
14    pub environments: BTreeMap<String, Environment>,
15}
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Environment {
18    pub file: String,
19    #[serde(default = "default_schema")]
20    pub schema: String,
21    #[serde(default)]
22    pub recipients: Vec<String>,
23    #[serde(default)]
24    pub key_file: Option<String>,
25    #[serde(default)]
26    pub editor: Option<String>,
27}
28fn default_schema() -> String {
29    "config/env.schema.yaml".into()
30}
31pub fn discover(start: &Path) -> anyhow::Result<PathBuf> {
32    let mut current = start.canonicalize()?;
33    loop {
34        let candidate = current.join("open-envault.yaml");
35        if candidate.is_file() {
36            return Ok(candidate);
37        }
38        if !current.pop() {
39            bail!("open-envault.yaml not found")
40        }
41    }
42}
43pub fn load(path: &Path) -> anyhow::Result<Project> {
44    let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
45    let project: Project = yaml_serde::from_str(&text).context("parse open-envault.yaml")?;
46    if project.project.trim().is_empty() {
47        bail!("project name must not be empty")
48    }
49    if project.environments.is_empty() {
50        bail!("project must define at least one environment")
51    }
52    for (name, profile) in &project.environments {
53        if name.trim().is_empty() || name.contains('/') || name.contains('\\') {
54            bail!("environment name is not path-safe: {name}")
55        }
56        if profile.file.trim().is_empty() || profile.schema.trim().is_empty() {
57            bail!("environment {name} must define file and schema paths")
58        }
59        for recipient in &profile.recipients {
60            crate::crypto::keys::parse_recipient(recipient)
61                .with_context(|| format!("invalid recipient configured for {name}"))?;
62        }
63    }
64    Ok(project)
65}
66pub fn environment<'a>(project: &'a Project, name: &str) -> anyhow::Result<&'a Environment> {
67    project
68        .environments
69        .get(name)
70        .with_context(|| format!("unknown environment: {name}"))
71}
72pub fn atomic_write(path: &Path, content: &[u8]) -> anyhow::Result<()> {
73    let parent = path.parent().unwrap_or_else(|| Path::new("."));
74    fs::create_dir_all(parent)?;
75    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("out");
76    let nonce = SystemTime::now()
77        .duration_since(UNIX_EPOCH)
78        .unwrap_or_default()
79        .as_nanos();
80    let tmp = parent.join(format!(".{name}.{}.{}.tmp", std::process::id(), nonce));
81    let mut file = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
82    file.write_all(content)?;
83    file.sync_all()?;
84    drop(file);
85    replace_file(&tmp, path).inspect_err(|_error| {
86        let _ = fs::remove_file(&tmp);
87    })?;
88    #[cfg(unix)]
89    fs::File::open(parent)?.sync_all()?;
90    Ok(())
91}
92
93#[cfg(not(windows))]
94fn replace_file(source: &Path, destination: &Path) -> std::io::Result<()> {
95    fs::rename(source, destination)
96}
97
98#[cfg(windows)]
99fn replace_file(source: &Path, destination: &Path) -> std::io::Result<()> {
100    use std::os::windows::ffi::OsStrExt;
101    use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MoveFileExW};
102    let source: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
103    let destination: Vec<u16> = destination
104        .as_os_str()
105        .encode_wide()
106        .chain(Some(0))
107        .collect();
108    if unsafe {
109        MoveFileExW(
110            source.as_ptr(),
111            destination.as_ptr(),
112            MOVEFILE_REPLACE_EXISTING,
113        )
114    } == 0
115    {
116        Err(std::io::Error::last_os_error())
117    } else {
118        Ok(())
119    }
120}
121
122pub fn resolve_path(value: &str, base: &Path) -> PathBuf {
123    let expanded = if let Some(rest) = value.strip_prefix("~/") {
124        std::env::var_os("HOME")
125            .or_else(|| std::env::var_os("USERPROFILE"))
126            .map(PathBuf::from)
127            .unwrap_or_default()
128            .join(rest)
129    } else {
130        PathBuf::from(value)
131    };
132    if expanded.is_absolute() {
133        expanded
134    } else {
135        base.join(expanded)
136    }
137}