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
86 #[cfg(unix)]
88 let file = {
89 use std::os::unix::fs::OpenOptionsExt;
90 fs::OpenOptions::new()
91 .create(true)
92 .write(true)
93 .truncate(true)
94 .custom_flags(libc::O_NOFOLLOW)
95 .open(&lp)?
96 };
97 #[cfg(not(unix))]
98 let file = {
99 if lp.is_symlink() {
101 return Err(VaultError::Io(std::io::Error::new(
102 std::io::ErrorKind::InvalidInput,
103 format!(
104 "lock file is a symlink — refusing to follow: {}",
105 lp.display()
106 ),
107 )));
108 }
109 File::create(&lp)?
110 };
111 file.lock_exclusive().map_err(|e| {
112 VaultError::Io(std::io::Error::new(
113 e.kind(),
114 format!("failed to acquire vault lock: {e}"),
115 ))
116 })?;
117 Ok(VaultLock {
118 _file: file,
119 _path: lp,
120 })
121}
122
123pub fn write(path: &Path, vault: &Vault) -> Result<(), VaultError> {
128 let json = serde_json::to_string_pretty(vault)
129 .map_err(|e| VaultError::Parse(format!("failed to serialize vault: {e}")))?;
130
131 let dir = path.parent().unwrap_or(Path::new("."));
133 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
134 tmp.write_all(json.as_bytes())?;
135 tmp.write_all(b"\n")?;
136 tmp.as_file().sync_all()?;
137 tmp.persist(path).map_err(|e| e.error)?;
138 Ok(())
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION};
145 use std::collections::BTreeMap;
146
147 fn test_vault() -> Vault {
148 let mut schema = BTreeMap::new();
149 schema.insert(
150 "DATABASE_URL".into(),
151 SchemaEntry {
152 description: "postgres connection string".into(),
153 example: Some("postgres://user:pass@host/db".into()),
154 tags: vec![],
155 ..Default::default()
156 },
157 );
158
159 Vault {
160 version: VAULT_VERSION.into(),
161 created: "2026-02-27T00:00:00Z".into(),
162 vault_name: ".murk".into(),
163 repo: String::new(),
164 recipients: vec!["age1test".into()],
165 schema,
166 secrets: BTreeMap::new(),
167 meta: "encrypted-meta".into(),
168 }
169 }
170
171 #[test]
172 fn roundtrip_read_write() {
173 let dir = std::env::temp_dir().join("murk_test_vault_v2");
174 fs::create_dir_all(&dir).unwrap();
175 let path = dir.join("test.murk");
176
177 let mut vault = test_vault();
178 vault.secrets.insert(
179 "DATABASE_URL".into(),
180 SecretEntry {
181 shared: "encrypted-value".into(),
182 scoped: BTreeMap::new(),
183 },
184 );
185
186 write(&path, &vault).unwrap();
187 let read_vault = read(&path).unwrap();
188
189 assert_eq!(read_vault.version, VAULT_VERSION);
190 assert_eq!(read_vault.recipients[0], "age1test");
191 assert!(read_vault.schema.contains_key("DATABASE_URL"));
192 assert!(read_vault.secrets.contains_key("DATABASE_URL"));
193
194 fs::remove_dir_all(&dir).unwrap();
195 }
196
197 #[test]
198 fn schema_is_sorted() {
199 let dir = std::env::temp_dir().join("murk_test_sorted_v2");
200 fs::create_dir_all(&dir).unwrap();
201 let path = dir.join("test.murk");
202
203 let mut vault = test_vault();
204 vault.schema.insert(
205 "ZZZ_KEY".into(),
206 SchemaEntry {
207 description: "last".into(),
208 example: None,
209 tags: vec![],
210 ..Default::default()
211 },
212 );
213 vault.schema.insert(
214 "AAA_KEY".into(),
215 SchemaEntry {
216 description: "first".into(),
217 example: None,
218 tags: vec![],
219 ..Default::default()
220 },
221 );
222
223 write(&path, &vault).unwrap();
224 let contents = fs::read_to_string(&path).unwrap();
225
226 let aaa_pos = contents.find("AAA_KEY").unwrap();
228 let db_pos = contents.find("DATABASE_URL").unwrap();
229 let zzz_pos = contents.find("ZZZ_KEY").unwrap();
230 assert!(aaa_pos < db_pos);
231 assert!(db_pos < zzz_pos);
232
233 fs::remove_dir_all(&dir).unwrap();
234 }
235
236 #[test]
237 fn missing_file_errors() {
238 let result = read(Path::new("/tmp/null.murk"));
239 assert!(result.is_err());
240 }
241
242 #[test]
243 fn parse_invalid_json() {
244 let result = parse("not json at all");
245 assert!(result.is_err());
246 let err = result.unwrap_err();
247 let msg = err.to_string();
248 assert!(msg.contains("vault parse error"));
249 assert!(msg.contains("Vault may be corrupted"));
250 }
251
252 #[test]
253 fn parse_empty_string() {
254 let result = parse("");
255 assert!(result.is_err());
256 }
257
258 #[test]
259 fn parse_valid_json() {
260 let json = serde_json::to_string(&test_vault()).unwrap();
261 let result = parse(&json);
262 assert!(result.is_ok());
263 assert_eq!(result.unwrap().version, VAULT_VERSION);
264 }
265
266 #[test]
267 fn parse_rejects_unknown_major_version() {
268 let mut vault = test_vault();
269 vault.version = "99.0".into();
270 let json = serde_json::to_string(&vault).unwrap();
271 let result = parse(&json);
272 let err = result.unwrap_err().to_string();
273 assert!(err.contains("unsupported vault version: 99.0"));
274 }
275
276 #[test]
277 fn parse_accepts_minor_version_bump() {
278 let mut vault = test_vault();
279 vault.version = "2.1".into();
280 let json = serde_json::to_string(&vault).unwrap();
281 let result = parse(&json);
282 assert!(result.is_ok());
283 }
284
285 #[test]
286 fn error_display_not_found() {
287 let err = VaultError::Io(std::io::Error::new(
288 std::io::ErrorKind::NotFound,
289 "no such file",
290 ));
291 let msg = err.to_string();
292 assert!(msg.contains("vault file not found"));
293 assert!(msg.contains("murk init"));
294 }
295
296 #[test]
297 fn error_display_io() {
298 let err = VaultError::Io(std::io::Error::new(
299 std::io::ErrorKind::PermissionDenied,
300 "denied",
301 ));
302 let msg = err.to_string();
303 assert!(msg.contains("vault I/O error"));
304 }
305
306 #[test]
307 fn error_display_parse() {
308 let err = VaultError::Parse("bad data".into());
309 assert!(err.to_string().contains("vault parse error: bad data"));
310 }
311
312 #[test]
313 fn error_from_io() {
314 let io_err = std::io::Error::new(std::io::ErrorKind::Other, "test");
315 let vault_err: VaultError = io_err.into();
316 assert!(matches!(vault_err, VaultError::Io(_)));
317 }
318
319 #[test]
320 fn scoped_entries_roundtrip() {
321 let dir = std::env::temp_dir().join("murk_test_scoped_rt");
322 fs::create_dir_all(&dir).unwrap();
323 let path = dir.join("test.murk");
324
325 let mut vault = test_vault();
326 let mut scoped = BTreeMap::new();
327 scoped.insert("age1bob".into(), "encrypted-for-bob".into());
328
329 vault.secrets.insert(
330 "DATABASE_URL".into(),
331 SecretEntry {
332 shared: "encrypted-value".into(),
333 scoped,
334 },
335 );
336
337 write(&path, &vault).unwrap();
338 let read_vault = read(&path).unwrap();
339
340 let entry = &read_vault.secrets["DATABASE_URL"];
341 assert_eq!(entry.scoped["age1bob"], "encrypted-for-bob");
342
343 fs::remove_dir_all(&dir).unwrap();
344 }
345}