1use std::fs::{self, File};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use fs2::FileExt;
6
7use crate::types::Vault;
8
9#[derive(Debug)]
11pub enum VaultError {
12 Io(std::io::Error),
13 Parse(String),
14}
15
16impl std::fmt::Display for VaultError {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 match self {
19 VaultError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {
20 write!(f, "vault file not found. Run `murk init` to create one")
21 }
22 VaultError::Io(e) => write!(f, "vault I/O error: {e}"),
23 VaultError::Parse(msg) => write!(f, "vault parse error: {msg}"),
24 }
25 }
26}
27
28impl From<std::io::Error> for VaultError {
29 fn from(e: std::io::Error) -> Self {
30 VaultError::Io(e)
31 }
32}
33
34pub fn parse(contents: &str) -> Result<Vault, VaultError> {
39 let vault: Vault = serde_json::from_str(contents).map_err(|e| {
40 VaultError::Parse(format!(
41 "invalid vault JSON: {e}. Vault may be corrupted — restore from git"
42 ))
43 })?;
44
45 let major = vault.version.split('.').next().unwrap_or("");
47 if major != "2" {
48 return Err(VaultError::Parse(format!(
49 "unsupported vault version: {}. This build of murk supports version 2.x",
50 vault.version
51 )));
52 }
53
54 Ok(vault)
55}
56
57pub fn read(path: &Path) -> Result<Vault, VaultError> {
59 let contents = fs::read_to_string(path)?;
60 parse(&contents)
61}
62
63pub struct VaultLock {
68 _file: File,
69 _path: PathBuf,
70}
71
72fn lock_path(vault_path: &Path) -> PathBuf {
74 let mut p = vault_path.as_os_str().to_owned();
75 p.push(".lock");
76 PathBuf::from(p)
77}
78
79pub fn lock(vault_path: &Path) -> Result<VaultLock, VaultError> {
84 let lp = lock_path(vault_path);
85 let file = File::create(&lp)?;
86 file.lock_exclusive().map_err(|e| {
87 VaultError::Io(std::io::Error::new(
88 e.kind(),
89 format!("failed to acquire vault lock: {e}"),
90 ))
91 })?;
92 Ok(VaultLock {
93 _file: file,
94 _path: lp,
95 })
96}
97
98pub fn write(path: &Path, vault: &Vault) -> Result<(), VaultError> {
103 let json = serde_json::to_string_pretty(vault)
104 .map_err(|e| VaultError::Parse(format!("failed to serialize vault: {e}")))?;
105
106 let dir = path.parent().unwrap_or(Path::new("."));
108 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
109 tmp.write_all(json.as_bytes())?;
110 tmp.write_all(b"\n")?;
111 tmp.as_file().sync_all()?;
112 tmp.persist(path).map_err(|e| e.error)?;
113 Ok(())
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION};
120 use std::collections::BTreeMap;
121
122 fn test_vault() -> Vault {
123 let mut schema = BTreeMap::new();
124 schema.insert(
125 "DATABASE_URL".into(),
126 SchemaEntry {
127 description: "postgres connection string".into(),
128 example: Some("postgres://user:pass@host/db".into()),
129 tags: vec![],
130 },
131 );
132
133 Vault {
134 version: VAULT_VERSION.into(),
135 created: "2026-02-27T00:00:00Z".into(),
136 vault_name: ".murk".into(),
137 repo: String::new(),
138 recipients: vec!["age1test".into()],
139 schema,
140 secrets: BTreeMap::new(),
141 meta: "encrypted-meta".into(),
142 }
143 }
144
145 #[test]
146 fn roundtrip_read_write() {
147 let dir = std::env::temp_dir().join("murk_test_vault_v2");
148 fs::create_dir_all(&dir).unwrap();
149 let path = dir.join("test.murk");
150
151 let mut vault = test_vault();
152 vault.secrets.insert(
153 "DATABASE_URL".into(),
154 SecretEntry {
155 shared: "encrypted-value".into(),
156 scoped: BTreeMap::new(),
157 },
158 );
159
160 write(&path, &vault).unwrap();
161 let read_vault = read(&path).unwrap();
162
163 assert_eq!(read_vault.version, VAULT_VERSION);
164 assert_eq!(read_vault.recipients[0], "age1test");
165 assert!(read_vault.schema.contains_key("DATABASE_URL"));
166 assert!(read_vault.secrets.contains_key("DATABASE_URL"));
167
168 fs::remove_dir_all(&dir).unwrap();
169 }
170
171 #[test]
172 fn schema_is_sorted() {
173 let dir = std::env::temp_dir().join("murk_test_sorted_v2");
174 fs::create_dir_all(&dir).unwrap();
175 let path = dir.join("test.murk");
176
177 let mut vault = test_vault();
178 vault.schema.insert(
179 "ZZZ_KEY".into(),
180 SchemaEntry {
181 description: "last".into(),
182 example: None,
183 tags: vec![],
184 },
185 );
186 vault.schema.insert(
187 "AAA_KEY".into(),
188 SchemaEntry {
189 description: "first".into(),
190 example: None,
191 tags: vec![],
192 },
193 );
194
195 write(&path, &vault).unwrap();
196 let contents = fs::read_to_string(&path).unwrap();
197
198 let aaa_pos = contents.find("AAA_KEY").unwrap();
200 let db_pos = contents.find("DATABASE_URL").unwrap();
201 let zzz_pos = contents.find("ZZZ_KEY").unwrap();
202 assert!(aaa_pos < db_pos);
203 assert!(db_pos < zzz_pos);
204
205 fs::remove_dir_all(&dir).unwrap();
206 }
207
208 #[test]
209 fn missing_file_errors() {
210 let result = read(Path::new("/tmp/null.murk"));
211 assert!(result.is_err());
212 }
213
214 #[test]
215 fn parse_invalid_json() {
216 let result = parse("not json at all");
217 assert!(result.is_err());
218 let err = result.unwrap_err();
219 let msg = err.to_string();
220 assert!(msg.contains("vault parse error"));
221 assert!(msg.contains("Vault may be corrupted"));
222 }
223
224 #[test]
225 fn parse_empty_string() {
226 let result = parse("");
227 assert!(result.is_err());
228 }
229
230 #[test]
231 fn parse_valid_json() {
232 let json = serde_json::to_string(&test_vault()).unwrap();
233 let result = parse(&json);
234 assert!(result.is_ok());
235 assert_eq!(result.unwrap().version, VAULT_VERSION);
236 }
237
238 #[test]
239 fn parse_rejects_unknown_major_version() {
240 let mut vault = test_vault();
241 vault.version = "99.0".into();
242 let json = serde_json::to_string(&vault).unwrap();
243 let result = parse(&json);
244 let err = result.unwrap_err().to_string();
245 assert!(err.contains("unsupported vault version: 99.0"));
246 }
247
248 #[test]
249 fn parse_accepts_minor_version_bump() {
250 let mut vault = test_vault();
251 vault.version = "2.1".into();
252 let json = serde_json::to_string(&vault).unwrap();
253 let result = parse(&json);
254 assert!(result.is_ok());
255 }
256
257 #[test]
258 fn error_display_not_found() {
259 let err = VaultError::Io(std::io::Error::new(
260 std::io::ErrorKind::NotFound,
261 "no such file",
262 ));
263 let msg = err.to_string();
264 assert!(msg.contains("vault file not found"));
265 assert!(msg.contains("murk init"));
266 }
267
268 #[test]
269 fn error_display_io() {
270 let err = VaultError::Io(std::io::Error::new(
271 std::io::ErrorKind::PermissionDenied,
272 "denied",
273 ));
274 let msg = err.to_string();
275 assert!(msg.contains("vault I/O error"));
276 }
277
278 #[test]
279 fn error_display_parse() {
280 let err = VaultError::Parse("bad data".into());
281 assert!(err.to_string().contains("vault parse error: bad data"));
282 }
283
284 #[test]
285 fn error_from_io() {
286 let io_err = std::io::Error::new(std::io::ErrorKind::Other, "test");
287 let vault_err: VaultError = io_err.into();
288 assert!(matches!(vault_err, VaultError::Io(_)));
289 }
290
291 #[test]
292 fn scoped_entries_roundtrip() {
293 let dir = std::env::temp_dir().join("murk_test_scoped_rt");
294 fs::create_dir_all(&dir).unwrap();
295 let path = dir.join("test.murk");
296
297 let mut vault = test_vault();
298 let mut scoped = BTreeMap::new();
299 scoped.insert("age1bob".into(), "encrypted-for-bob".into());
300
301 vault.secrets.insert(
302 "DATABASE_URL".into(),
303 SecretEntry {
304 shared: "encrypted-value".into(),
305 scoped,
306 },
307 );
308
309 write(&path, &vault).unwrap();
310 let read_vault = read(&path).unwrap();
311
312 let entry = &read_vault.secrets["DATABASE_URL"];
313 assert_eq!(entry.scoped["age1bob"], "encrypted-for-bob");
314
315 fs::remove_dir_all(&dir).unwrap();
316 }
317}