1use std::collections::BTreeMap;
39use std::fs::{self, File};
40use std::io::{self, BufReader, Read};
41use std::path::{Path, PathBuf};
42use std::time::{SystemTime, UNIX_EPOCH};
43
44use serde::{Deserialize, Serialize};
45use sha2::{Digest, Sha256};
46use thiserror::Error;
47
48use crate::copy::EntryKind;
49
50pub const SCHEMA_VERSION: u32 = 1;
52
53#[derive(Debug, Error)]
57pub enum ManifestError {
58 #[error("manifest io {path:?}: {source}")]
60 Io {
61 path: PathBuf,
63 #[source]
65 source: io::Error,
66 },
67
68 #[error("manifest parse {path:?}: {source}")]
70 Parse {
71 path: PathBuf,
73 #[source]
75 source: serde_json::Error,
76 },
77
78 #[error("manifest encode: {0}")]
80 Encode(#[source] serde_json::Error),
81
82 #[error("unsupported manifest version {found}, expected {expected}")]
84 UnsupportedVersion {
85 found: u32,
87 expected: u32,
89 },
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Manifest {
97 pub version: u32,
99
100 pub krypt_version: String,
102
103 pub deployed_at: u64,
105
106 pub repo_path: PathBuf,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub repo_commit: Option<String>,
112
113 #[serde(with = "entries_as_list")]
118 pub entries: BTreeMap<PathBuf, ManifestEntry>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123pub struct ManifestEntry {
124 pub src: PathBuf,
126
127 pub dst: PathBuf,
129
130 pub kind: EntryKind,
132
133 pub hash_src: String,
136
137 pub hash_dst: String,
140
141 pub deployed_at: u64,
143}
144
145impl Manifest {
146 pub fn new(repo_path: PathBuf) -> Self {
149 Self {
150 version: SCHEMA_VERSION,
151 krypt_version: crate::VERSION.to_string(),
152 deployed_at: now_unix(),
153 repo_path,
154 repo_commit: None,
155 entries: BTreeMap::new(),
156 }
157 }
158
159 pub fn load(path: &Path) -> Result<Option<Self>, ManifestError> {
162 let bytes = match fs::read(path) {
163 Ok(b) => b,
164 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
165 Err(e) => {
166 return Err(ManifestError::Io {
167 path: path.to_path_buf(),
168 source: e,
169 });
170 }
171 };
172 let m: Manifest =
173 serde_json::from_slice(&bytes).map_err(|source| ManifestError::Parse {
174 path: path.to_path_buf(),
175 source,
176 })?;
177 if m.version != SCHEMA_VERSION {
178 return Err(ManifestError::UnsupportedVersion {
179 found: m.version,
180 expected: SCHEMA_VERSION,
181 });
182 }
183 Ok(Some(m))
184 }
185
186 pub fn save(&self, path: &Path) -> Result<(), ManifestError> {
188 let mk_io = |source: io::Error| ManifestError::Io {
189 path: path.to_path_buf(),
190 source,
191 };
192
193 if let Some(parent) = path.parent() {
194 fs::create_dir_all(parent).map_err(mk_io)?;
195 }
196 let bytes = serde_json::to_vec_pretty(self).map_err(ManifestError::Encode)?;
197
198 let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
199 tmp_name.push(format!(".krypt-tmp-{}", std::process::id()));
200 let tmp = path.with_file_name(tmp_name);
201 let _ = fs::remove_file(&tmp);
202 fs::write(&tmp, &bytes).map_err(mk_io)?;
203 fs::rename(&tmp, path).map_err(mk_io)?;
204 Ok(())
205 }
206
207 pub fn record(&mut self, entry: ManifestEntry) {
209 self.entries.insert(entry.dst.clone(), entry);
210 self.deployed_at = now_unix();
211 }
212
213 pub fn forget(&mut self, dst: &Path) -> Option<ManifestEntry> {
216 self.entries.remove(dst)
217 }
218}
219
220pub fn hash_file(path: &Path) -> io::Result<String> {
224 let f = File::open(path)?;
225 let mut r = BufReader::new(f);
226 let mut hasher = Sha256::new();
227 let mut buf = [0u8; 8192];
228 loop {
229 let n = r.read(&mut buf)?;
230 if n == 0 {
231 break;
232 }
233 hasher.update(&buf[..n]);
234 }
235 let digest = hasher.finalize();
236 Ok(format!("sha256:{:x}", digest))
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum DriftStatus {
244 Clean,
246 Drifted,
248 DstMissing,
250}
251
252#[derive(Debug, Clone)]
254pub struct DriftRecord {
255 pub src: PathBuf,
257 pub dst: PathBuf,
259 pub kind: EntryKind,
261 pub recorded_hash: String,
263 pub current_hash: Option<String>,
265 pub status: DriftStatus,
267}
268
269pub fn detect_drift(manifest: &Manifest) -> Vec<DriftRecord> {
273 let mut out = Vec::with_capacity(manifest.entries.len());
274 for entry in manifest.entries.values() {
275 let (current_hash, status) = if !entry.dst.exists() {
276 (None, DriftStatus::DstMissing)
277 } else {
278 match hash_file(&entry.dst) {
279 Ok(h) if h == entry.hash_dst => (Some(h), DriftStatus::Clean),
280 Ok(h) => (Some(h), DriftStatus::Drifted),
281 Err(_) => (None, DriftStatus::Drifted),
282 }
283 };
284 out.push(DriftRecord {
285 src: entry.src.clone(),
286 dst: entry.dst.clone(),
287 kind: entry.kind,
288 recorded_hash: entry.hash_dst.clone(),
289 current_hash,
290 status,
291 });
292 }
293 out
294}
295
296fn now_unix() -> u64 {
299 SystemTime::now()
300 .duration_since(UNIX_EPOCH)
301 .map(|d| d.as_secs())
302 .unwrap_or(0)
303}
304
305mod entries_as_list {
308 use super::{ManifestEntry, PathBuf};
309 use serde::Deserialize;
310 use serde::de::{Deserializer, Error};
311 use serde::ser::{SerializeSeq, Serializer};
312 use std::collections::BTreeMap;
313
314 pub fn serialize<S>(map: &BTreeMap<PathBuf, ManifestEntry>, ser: S) -> Result<S::Ok, S::Error>
315 where
316 S: Serializer,
317 {
318 let mut seq = ser.serialize_seq(Some(map.len()))?;
319 for entry in map.values() {
320 seq.serialize_element(entry)?;
321 }
322 seq.end()
323 }
324
325 pub fn deserialize<'de, D>(de: D) -> Result<BTreeMap<PathBuf, ManifestEntry>, D::Error>
326 where
327 D: Deserializer<'de>,
328 {
329 let list: Vec<ManifestEntry> = Vec::deserialize(de)?;
330 let mut map = BTreeMap::new();
331 for entry in list {
332 if map.insert(entry.dst.clone(), entry.clone()).is_some() {
333 return Err(D::Error::custom(format!(
334 "duplicate manifest entry for {:?}",
335 entry.dst
336 )));
337 }
338 }
339 Ok(map)
340 }
341}
342
343#[cfg(test)]
346mod tests {
347 use super::*;
348 use std::io::Write;
349 use tempfile::tempdir;
350
351 fn fake_entry(src: &str, dst: PathBuf, hash: &str) -> ManifestEntry {
352 ManifestEntry {
353 src: src.into(),
354 dst,
355 kind: EntryKind::Link,
356 hash_src: hash.into(),
357 hash_dst: hash.into(),
358 deployed_at: 0,
359 }
360 }
361
362 #[test]
363 fn hash_file_matches_known_vector() {
364 let dir = tempdir().unwrap();
365 let p = dir.path().join("a.txt");
366 fs::write(&p, b"hello").unwrap();
367 assert_eq!(
369 hash_file(&p).unwrap(),
370 "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
371 );
372 }
373
374 #[test]
375 fn load_missing_returns_none() {
376 let dir = tempdir().unwrap();
377 assert!(
378 Manifest::load(&dir.path().join("nope.json"))
379 .unwrap()
380 .is_none()
381 );
382 }
383
384 #[test]
385 fn save_then_load_roundtrips() {
386 let dir = tempdir().unwrap();
387 let path = dir.path().join("manifest.json");
388
389 let mut m = Manifest::new(dir.path().to_path_buf());
390 m.record(fake_entry(".gitconfig", dir.path().join("a"), "sha256:aa"));
391 m.record(fake_entry(".tmux.conf", dir.path().join("b"), "sha256:bb"));
392 m.save(&path).unwrap();
393
394 let loaded = Manifest::load(&path).unwrap().unwrap();
395 assert_eq!(loaded.version, SCHEMA_VERSION);
396 assert_eq!(loaded.entries.len(), 2);
397 assert_eq!(loaded.entries[&dir.path().join("a")].hash_dst, "sha256:aa");
398 }
399
400 #[test]
401 fn unsupported_version_rejected() {
402 let dir = tempdir().unwrap();
403 let path = dir.path().join("manifest.json");
404 let mut f = File::create(&path).unwrap();
405 write!(
406 f,
407 r#"{{"version":999,"krypt_version":"x","deployed_at":0,"repo_path":"/","entries":[]}}"#
408 )
409 .unwrap();
410
411 let err = Manifest::load(&path).unwrap_err();
412 assert!(matches!(
413 err,
414 ManifestError::UnsupportedVersion {
415 found: 999,
416 expected: SCHEMA_VERSION
417 }
418 ));
419 }
420
421 #[test]
422 fn detect_drift_classifies_three_cases() {
423 let dir = tempdir().unwrap();
424
425 let clean = dir.path().join("clean.txt");
427 fs::write(&clean, b"original").unwrap();
428 let clean_hash = hash_file(&clean).unwrap();
429
430 let drifted = dir.path().join("drifted.txt");
432 fs::write(&drifted, b"changed").unwrap();
433 let stale_hash = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
434
435 let missing = dir.path().join("missing.txt");
437
438 let mut m = Manifest::new(dir.path().to_path_buf());
439 m.record(ManifestEntry {
440 src: "a".into(),
441 dst: clean.clone(),
442 kind: EntryKind::Link,
443 hash_src: clean_hash.clone(),
444 hash_dst: clean_hash,
445 deployed_at: 0,
446 });
447 m.record(ManifestEntry {
448 src: "b".into(),
449 dst: drifted.clone(),
450 kind: EntryKind::Link,
451 hash_src: stale_hash.into(),
452 hash_dst: stale_hash.into(),
453 deployed_at: 0,
454 });
455 m.record(ManifestEntry {
456 src: "c".into(),
457 dst: missing.clone(),
458 kind: EntryKind::Template,
459 hash_src: stale_hash.into(),
460 hash_dst: stale_hash.into(),
461 deployed_at: 0,
462 });
463
464 let drift = detect_drift(&m);
465 let by_dst: BTreeMap<_, _> = drift.into_iter().map(|d| (d.dst.clone(), d)).collect();
466
467 assert_eq!(by_dst[&clean].status, DriftStatus::Clean);
468 assert_eq!(by_dst[&drifted].status, DriftStatus::Drifted);
469 assert_eq!(by_dst[&missing].status, DriftStatus::DstMissing);
470 }
471
472 #[test]
473 fn duplicate_dst_in_file_rejected() {
474 let dir = tempdir().unwrap();
475 let path = dir.path().join("manifest.json");
476 let body = format!(
477 r#"{{"version":{},"krypt_version":"x","deployed_at":0,"repo_path":"/","entries":[
478 {{"src":"a","dst":"/x","kind":"link","hash_src":"sha256:1","hash_dst":"sha256:1","deployed_at":0}},
479 {{"src":"a","dst":"/x","kind":"link","hash_src":"sha256:2","hash_dst":"sha256:2","deployed_at":0}}
480 ]}}"#,
481 SCHEMA_VERSION
482 );
483 fs::write(&path, body).unwrap();
484 assert!(matches!(
485 Manifest::load(&path),
486 Err(ManifestError::Parse { .. })
487 ));
488 }
489}