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}