1use std::fs;
2use std::path::Path;
3
4use crate::types::Vault;
5
6#[derive(Debug)]
8pub enum VaultError {
9 Io(std::io::Error),
10 Parse(String),
11}
12
13impl std::fmt::Display for VaultError {
14 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15 match self {
16 VaultError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {
17 write!(f, "vault file not found. Run `murk init` to create one")
18 }
19 VaultError::Io(e) => write!(f, "vault I/O error: {e}"),
20 VaultError::Parse(msg) => write!(f, "vault parse error: {msg}"),
21 }
22 }
23}
24
25impl From<std::io::Error> for VaultError {
26 fn from(e: std::io::Error) -> Self {
27 VaultError::Io(e)
28 }
29}
30
31pub fn parse(contents: &str) -> Result<Vault, VaultError> {
33 serde_json::from_str(contents).map_err(|e| {
34 VaultError::Parse(format!(
35 "invalid vault JSON: {e}. Vault may be corrupted — restore from git"
36 ))
37 })
38}
39
40pub fn read(path: &Path) -> Result<Vault, VaultError> {
42 let contents = fs::read_to_string(path)?;
43 parse(&contents)
44}
45
46pub fn write(path: &Path, vault: &Vault) -> Result<(), VaultError> {
48 let json = serde_json::to_string_pretty(vault)
49 .map_err(|e| VaultError::Parse(format!("failed to serialize vault: {e}")))?;
50 fs::write(path, json + "\n")?;
51 Ok(())
52}
53
54#[cfg(test)]
55mod tests {
56 use super::*;
57 use crate::types::{SchemaEntry, SecretEntry};
58 use std::collections::BTreeMap;
59
60 fn test_vault() -> Vault {
61 let mut schema = BTreeMap::new();
62 schema.insert(
63 "DATABASE_URL".into(),
64 SchemaEntry {
65 description: "postgres connection string".into(),
66 example: Some("postgres://user:pass@host/db".into()),
67 tags: vec![],
68 },
69 );
70
71 Vault {
72 version: "2.0".into(),
73 created: "2026-02-27T00:00:00Z".into(),
74 vault_name: ".murk".into(),
75 repo: String::new(),
76 recipients: vec!["age1test".into()],
77 schema,
78 secrets: BTreeMap::new(),
79 meta: "encrypted-meta".into(),
80 }
81 }
82
83 #[test]
84 fn roundtrip_read_write() {
85 let dir = std::env::temp_dir().join("murk_test_vault_v2");
86 fs::create_dir_all(&dir).unwrap();
87 let path = dir.join("test.murk");
88
89 let mut vault = test_vault();
90 vault.secrets.insert(
91 "DATABASE_URL".into(),
92 SecretEntry {
93 shared: "encrypted-value".into(),
94 scoped: BTreeMap::new(),
95 },
96 );
97
98 write(&path, &vault).unwrap();
99 let read_vault = read(&path).unwrap();
100
101 assert_eq!(read_vault.version, "2.0");
102 assert_eq!(read_vault.recipients[0], "age1test");
103 assert!(read_vault.schema.contains_key("DATABASE_URL"));
104 assert!(read_vault.secrets.contains_key("DATABASE_URL"));
105
106 fs::remove_dir_all(&dir).unwrap();
107 }
108
109 #[test]
110 fn schema_is_sorted() {
111 let dir = std::env::temp_dir().join("murk_test_sorted_v2");
112 fs::create_dir_all(&dir).unwrap();
113 let path = dir.join("test.murk");
114
115 let mut vault = test_vault();
116 vault.schema.insert(
117 "ZZZ_KEY".into(),
118 SchemaEntry {
119 description: "last".into(),
120 example: None,
121 tags: vec![],
122 },
123 );
124 vault.schema.insert(
125 "AAA_KEY".into(),
126 SchemaEntry {
127 description: "first".into(),
128 example: None,
129 tags: vec![],
130 },
131 );
132
133 write(&path, &vault).unwrap();
134 let contents = fs::read_to_string(&path).unwrap();
135
136 let aaa_pos = contents.find("AAA_KEY").unwrap();
138 let db_pos = contents.find("DATABASE_URL").unwrap();
139 let zzz_pos = contents.find("ZZZ_KEY").unwrap();
140 assert!(aaa_pos < db_pos);
141 assert!(db_pos < zzz_pos);
142
143 fs::remove_dir_all(&dir).unwrap();
144 }
145
146 #[test]
147 fn missing_file_errors() {
148 let result = read(Path::new("/tmp/null.murk"));
149 assert!(result.is_err());
150 }
151
152 #[test]
153 fn parse_invalid_json() {
154 let result = parse("not json at all");
155 assert!(result.is_err());
156 let err = result.unwrap_err();
157 let msg = err.to_string();
158 assert!(msg.contains("vault parse error"));
159 assert!(msg.contains("Vault may be corrupted"));
160 }
161
162 #[test]
163 fn parse_empty_string() {
164 let result = parse("");
165 assert!(result.is_err());
166 }
167
168 #[test]
169 fn parse_valid_json() {
170 let json = serde_json::to_string(&test_vault()).unwrap();
171 let result = parse(&json);
172 assert!(result.is_ok());
173 assert_eq!(result.unwrap().version, "2.0");
174 }
175
176 #[test]
177 fn error_display_not_found() {
178 let err = VaultError::Io(std::io::Error::new(
179 std::io::ErrorKind::NotFound,
180 "no such file",
181 ));
182 let msg = err.to_string();
183 assert!(msg.contains("vault file not found"));
184 assert!(msg.contains("murk init"));
185 }
186
187 #[test]
188 fn error_display_io() {
189 let err = VaultError::Io(std::io::Error::new(
190 std::io::ErrorKind::PermissionDenied,
191 "denied",
192 ));
193 let msg = err.to_string();
194 assert!(msg.contains("vault I/O error"));
195 }
196
197 #[test]
198 fn error_display_parse() {
199 let err = VaultError::Parse("bad data".into());
200 assert!(err.to_string().contains("vault parse error: bad data"));
201 }
202
203 #[test]
204 fn error_from_io() {
205 let io_err = std::io::Error::new(std::io::ErrorKind::Other, "test");
206 let vault_err: VaultError = io_err.into();
207 assert!(matches!(vault_err, VaultError::Io(_)));
208 }
209
210 #[test]
211 fn scoped_entries_roundtrip() {
212 let dir = std::env::temp_dir().join("murk_test_scoped_rt");
213 fs::create_dir_all(&dir).unwrap();
214 let path = dir.join("test.murk");
215
216 let mut vault = test_vault();
217 let mut scoped = BTreeMap::new();
218 scoped.insert("age1bob".into(), "encrypted-for-bob".into());
219
220 vault.secrets.insert(
221 "DATABASE_URL".into(),
222 SecretEntry {
223 shared: "encrypted-value".into(),
224 scoped,
225 },
226 );
227
228 write(&path, &vault).unwrap();
229 let read_vault = read(&path).unwrap();
230
231 let entry = &read_vault.secrets["DATABASE_URL"];
232 assert_eq!(entry.scoped["age1bob"], "encrypted-for-bob");
233
234 fs::remove_dir_all(&dir).unwrap();
235 }
236}