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