Skip to main content

krypt_core/
manifest.rs

1//! Deployment manifest — record of what `krypt` has written to disk.
2//!
3//! The manifest is a versioned JSON document persisted at
4//! `${XDG_STATE}/krypt/manifest.json`. It records, for every file the
5//! engine has deployed, the source path, destination path, and a
6//! SHA-256 hash of both. The hash of the destination at deploy time is
7//! later used to detect **drift** — edits made to a deployed file
8//! outside the repo.
9//!
10//! ## Schema
11//!
12//! ```json
13//! {
14//!   "version": 1,
15//!   "krypt_version": "0.0.2",
16//!   "deployed_at": 1715896800,
17//!   "repo_path": "/home/x/.config/krypt/repo",
18//!   "repo_commit": null,
19//!   "entries": [
20//!     { "src": ".gitconfig", "dst": "/home/x/.gitconfig",
21//!       "kind": "link", "hash_src": "sha256:...", "hash_dst": "sha256:...",
22//!       "deployed_at": 1715896800 }
23//!   ]
24//! }
25//! ```
26//!
27//! ## Versioning
28//!
29//! The top-level `version` field is checked on load. A future bump (say,
30//! v2 adding new fields) will land alongside a migration step here.
31//!
32//! ## Atomicity
33//!
34//! [`Manifest::save`] writes to a sibling tmp file and renames, mirroring
35//! [`crate::copy`]'s deploy strategy. A torn write can't corrupt the
36//! existing manifest on disk.
37
38use 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
50/// Current manifest schema version.
51pub const SCHEMA_VERSION: u32 = 1;
52
53// ─── Errors ─────────────────────────────────────────────────────────────────
54
55/// Errors loading, saving, or comparing a manifest.
56#[derive(Debug, Error)]
57pub enum ManifestError {
58    /// I/O failure reading or writing the manifest file.
59    #[error("manifest io {path:?}: {source}")]
60    Io {
61        /// The path involved.
62        path: PathBuf,
63        /// Underlying error.
64        #[source]
65        source: io::Error,
66    },
67
68    /// JSON deserialize failure.
69    #[error("manifest parse {path:?}: {source}")]
70    Parse {
71        /// Path of the bad file.
72        path: PathBuf,
73        /// Underlying serde error.
74        #[source]
75        source: serde_json::Error,
76    },
77
78    /// JSON serialize failure (unexpected; would indicate a serde bug).
79    #[error("manifest encode: {0}")]
80    Encode(#[source] serde_json::Error),
81
82    /// Schema version we don't know how to read.
83    #[error("unsupported manifest version {found}, expected {expected}")]
84    UnsupportedVersion {
85        /// Version read from the file.
86        found: u32,
87        /// Version this build understands.
88        expected: u32,
89    },
90}
91
92// ─── Top-level manifest ─────────────────────────────────────────────────────
93
94/// A complete deploy record.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Manifest {
97    /// Schema version. Currently [`SCHEMA_VERSION`].
98    pub version: u32,
99
100    /// The `krypt` binary version that wrote this manifest.
101    pub krypt_version: String,
102
103    /// Unix timestamp (seconds since epoch, UTC) of the last write.
104    pub deployed_at: u64,
105
106    /// Absolute path to the dotfiles repo root.
107    pub repo_path: PathBuf,
108
109    /// `git rev-parse HEAD` of the repo at deploy time, if available.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub repo_commit: Option<String>,
112
113    /// Per-file deploy records, keyed by `dst` for fast lookup.
114    ///
115    /// We store this as a list on disk (more readable) but expose it as
116    /// a map at the API layer so callers can look up by destination.
117    #[serde(with = "entries_as_list")]
118    pub entries: BTreeMap<PathBuf, ManifestEntry>,
119}
120
121/// Per-file deploy record.
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123pub struct ManifestEntry {
124    /// Source path *relative to the repo root* (e.g. `.gitconfig`).
125    pub src: PathBuf,
126
127    /// Absolute destination path.
128    pub dst: PathBuf,
129
130    /// Whether the entry came from a `[[link]]` or `[[template]]`.
131    pub kind: EntryKind,
132
133    /// SHA-256 hash of the source at deploy time, formatted as
134    /// `sha256:<hex>`.
135    pub hash_src: String,
136
137    /// SHA-256 hash of the destination right after the copy — used by
138    /// drift detection to spot post-deploy edits.
139    pub hash_dst: String,
140
141    /// Unix timestamp (seconds) when this entry was last (re)deployed.
142    pub deployed_at: u64,
143}
144
145impl Manifest {
146    /// Build an empty manifest stamped with the current time + crate
147    /// version. Repo path defaults to "" — set it before saving.
148    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    /// Load a manifest from disk. Returns `Ok(None)` if the file does
160    /// not exist — callers treat that as "nothing deployed yet".
161    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    /// Atomically write the manifest to disk. Creates parent dirs.
187    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    /// Upsert a record for `dst`, refreshing hashes + timestamp.
208    pub fn record(&mut self, entry: ManifestEntry) {
209        self.entries.insert(entry.dst.clone(), entry);
210        self.deployed_at = now_unix();
211    }
212
213    /// Forget a destination — used when a `[[link]]` is removed from
214    /// the config and the file is unlinked.
215    pub fn forget(&mut self, dst: &Path) -> Option<ManifestEntry> {
216        self.entries.remove(dst)
217    }
218}
219
220// ─── Hashing ────────────────────────────────────────────────────────────────
221
222/// Compute the SHA-256 of a file as `sha256:<hex>`.
223pub 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// ─── Drift detection ────────────────────────────────────────────────────────
240
241/// Why a manifest entry looks different from disk.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum DriftStatus {
244    /// Hashes match — destination is in sync with what was deployed.
245    Clean,
246    /// Destination exists but its hash differs from the recorded one.
247    Drifted,
248    /// Manifest knows about this entry but the destination file is gone.
249    DstMissing,
250}
251
252/// One row in a drift report.
253#[derive(Debug, Clone)]
254pub struct DriftRecord {
255    /// Source path (relative to repo root).
256    pub src: PathBuf,
257    /// Absolute destination path.
258    pub dst: PathBuf,
259    /// Whether it came from `[[link]]` or `[[template]]`.
260    pub kind: EntryKind,
261    /// What we wrote to disk on the last deploy.
262    pub recorded_hash: String,
263    /// What the destination hashes to now (None if missing or unreadable).
264    pub current_hash: Option<String>,
265    /// Drift classification.
266    pub status: DriftStatus,
267}
268
269/// Walk every entry in the manifest, hashing the current destination and
270/// classifying drift. Read errors (e.g. permission denied) are treated
271/// as `Drifted` with `current_hash = None` — better to surface than swallow.
272pub 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
296// ─── Internals ──────────────────────────────────────────────────────────────
297
298fn now_unix() -> u64 {
299    SystemTime::now()
300        .duration_since(UNIX_EPOCH)
301        .map(|d| d.as_secs())
302        .unwrap_or(0)
303}
304
305/// Serialize the entries map as a JSON list (one object per entry) for
306/// readable on-disk format. On the way in, list → map keyed by `dst`.
307mod 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// ─── Tests ──────────────────────────────────────────────────────────────────
344
345#[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        // sha256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
368        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        // Clean entry — write a file and record its actual hash.
426        let clean = dir.path().join("clean.txt");
427        fs::write(&clean, b"original").unwrap();
428        let clean_hash = hash_file(&clean).unwrap();
429
430        // Drifted entry — record one hash but write different bytes.
431        let drifted = dir.path().join("drifted.txt");
432        fs::write(&drifted, b"changed").unwrap();
433        let stale_hash = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
434
435        // Missing entry — recorded but no file on disk.
436        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}