1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
//! Checkpoint manifests: the inventory of one captured state.
//!
//! A manifest maps file paths to `(mode, size, mtime, content hash)`.
//! The `(size, mtime)` pair doubles as the persistent stat cache: a later
//! checkpoint reuses the recorded hash for any file whose stat fingerprint
//! is unchanged, so unchanged files are never re-read.
//!
//! Manifests are themselves content-addressed: the manifest id is the
//! SHA-256 of the canonical JSON serialization (`BTreeMap` keys give a
//! stable order), so identical states dedup to a single stored manifest.
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fs;
use std::path::PathBuf;
use std::time::UNIX_EPOCH;
use serde::Deserialize;
use serde::Serialize;
use crate::blob::BlobStore;
use crate::error::Result;
use crate::error::SnapshotError;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileEntry {
/// Unix permission bits (`mode & 0o7777`), or `None` when they were never
/// observed.
///
/// `None` is not "default permissions" — it is the absence of an
/// observation, and a restore leaves such a file's permissions exactly as
/// it finds them. Two things produce it: content that arrived from an
/// edit rather than from the filesystem, which has no stat behind it at
/// all, and a platform with no permission bits to report.
///
/// It was `u32` with an invented `0o644` in both cases, which was not
/// inert: the planner compares mode and the applier chmods, so restoring
/// a pre-edit image of an executable script stripped its `+x`, and a file
/// whose content already matched was rewritten anyway because an invented
/// mode never equals a real one.
pub mode: Option<u32>,
pub size: u64,
pub mtime_secs: i64,
pub mtime_nanos: u32,
/// SHA-256 of the file content, stored in the blob store.
pub hash: String,
}
impl FileEntry {
/// Stat-cache check: does `meta` match this entry's fingerprint?
pub fn stat_matches(&self, meta: &fs::Metadata) -> bool {
// A zero mtime is the marker for an entry whose fingerprint was never
// trustworthy — captured within its own write's timestamp tick, or
// reconstructed from a pre-edit image that had no stat at all. Such an
// entry must never satisfy the fast path, including against a file
// that genuinely carries an epoch mtime.
if (self.mtime_secs, self.mtime_nanos) == (0, 0) {
return false;
}
let (secs, nanos) = mtime_parts(meta);
self.size == meta.len() && self.mtime_secs == secs && self.mtime_nanos == nanos
}
}
/// Extract `(secs, nanos)` of the mtime relative to the unix epoch.
/// Pre-epoch or unreadable mtimes collapse to `(0, 0)`, which simply
/// disables the stat-cache fast path for that file.
pub fn mtime_parts(meta: &fs::Metadata) -> (i64, u32) {
meta.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map_or((0, 0), |d| (d.as_secs() as i64, d.subsec_nanos()))
}
/// Permission bits to record for a file (`mode & 0o7777`), or `None` where
/// the platform has none to report.
///
/// `None` off-unix rather than a plausible `0o644`: inventing one would make
/// a restore chmod every file it wrote on Windows to permissions nothing ever
/// observed.
pub fn mode_of(meta: &fs::Metadata) -> Option<u32> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
Some(meta.permissions().mode() & 0o7777)
}
#[cfg(not(unix))]
{
let _ = meta;
None
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
/// Format version of this record.
///
/// The store's own directory carries a version too, and this is the finer
/// half of the same guarantee: the path catches a store a different build
/// wrote, and this catches a record that build left inside one of ours —
/// which is what a migration interrupted halfway looks like.
pub version: u32,
/// Path → entry. Paths are stored as strings (lossy UTF-8) so
/// manifests serialize portably; keys are absolute paths.
pub entries: BTreeMap<String, FileEntry>,
/// Paths this capture looked for and did not find.
///
/// The only evidence a restore may delete on. Earlier designs inferred it
/// instead — "the scan covered this directory exhaustively, so absence
/// proves non-existence" — which made the inference only as sound as the
/// scan was exhaustive, and therefore required scanning everything. That
/// cost was unbounded and the claim was fragile: a manifest also carries
/// paths the edit hook picked up from anywhere on disk, which no scan ever
/// covered.
///
/// Recording the observation directly costs one set and needs no premise.
/// A path the capture was asked about and found *missing* appears here,
/// whether it came from the index, from recency, or from an edit that
/// created a file where none was.
///
/// **Not every path the capture was asked about is accounted for**, and
/// the earlier wording here said it was. Three branches record neither an
/// entry nor a tombstone: a stat error that is not `NotFound`, a
/// non-regular file, and a read failure. That is exactly right — II.4
/// requires it, because a failed read verifies nothing and a tombstone is
/// a licence to delete — but II.1's deletion rule reads off this comment,
/// so an auditor checking the stronger claim would be verifying something
/// the code does not promise (C10, VII.4). Those paths are counted in
/// [`crate::CheckpointStats::dropped`] instead.
/// `default` is safe here only because `version` is checked first. An
/// empty set is omitted from the JSON, so reading has to tolerate the key
/// being absent — and without the version guard that same tolerance would
/// silently read a record from a build that predated tombstones as "this
/// capture looked for nothing", voiding every deletion it licensed.
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub absent: BTreeSet<String>,
}
impl Default for Manifest {
fn default() -> Self {
Self {
version: crate::workspace::FORMAT_VERSION,
entries: BTreeMap::new(),
absent: BTreeSet::new(),
}
}
}
impl Manifest {
/// Content-addressed id: SHA-256 of the canonical JSON serialization.
pub fn id(&self) -> Result<String> {
let bytes = serde_json::to_vec(self)?;
Ok(BlobStore::hash_bytes(&bytes))
}
}
pub struct ManifestStore {
root: PathBuf,
}
impl ManifestStore {
pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
let root = root.into();
fs::create_dir_all(&root).map_err(|e| SnapshotError::io(&root, e))?;
Ok(Self { root })
}
/// Persist `manifest`, returning its id. Idempotent.
pub fn save(&self, manifest: &Manifest) -> Result<String> {
let id = manifest.id()?;
let path = self.manifest_path(&id)?;
if path.exists() {
// Manifest ids are content-addressed too, so this dedups exactly
// as blobs do — and carries the same hazard, which the
// constitution states for content only. A live session that
// recaptures unchanged state re-derives an existing id and writes
// nothing, leaving that manifest instantly collectable while it
// is about to become live. Its log entry would then point at a
// manifest that is gone and the session would stop capturing
// entirely. See `BlobStore::store_bytes`.
crate::sweep::freshen(&path);
return Ok(id);
}
let tmp = crate::sweep::tmp_name(&path);
let bytes = serde_json::to_vec_pretty(manifest)?;
fs::write(&tmp, bytes).map_err(|e| SnapshotError::io(&tmp, e))?;
fs::rename(&tmp, &path).map_err(|e| SnapshotError::io(&path, e))?;
Ok(id)
}
pub fn load(&self, id: &str) -> Result<Manifest> {
let path = self.manifest_path(id)?;
let bytes = fs::read(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
SnapshotError::MissingManifest(id.to_string())
} else {
SnapshotError::io(&path, e)
}
})?;
let manifest: Manifest = serde_json::from_slice(&bytes)?;
// Refuse rather than guess. A record this build does not understand
// may mean anything at all, and the one thing it must not do is look
// like a record that means something else.
if manifest.version != crate::workspace::FORMAT_VERSION {
return Err(SnapshotError::UnknownRecordVersion {
kind: "manifest",
id: id.to_string(),
found: manifest.version,
supported: crate::workspace::FORMAT_VERSION,
});
}
Ok(manifest)
}
/// Where `id` lives, so the sweep can ask how old it is.
pub(crate) fn path_for(&self, id: &str) -> Result<PathBuf> {
self.manifest_path(id)
}
pub fn remove(&self, id: &str) -> Result<()> {
let path = self.manifest_path(id)?;
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(SnapshotError::io(&path, e)),
}
}
/// Enumerate stored manifest ids (for garbage collection sweeps).
pub fn ids(&self) -> Result<BTreeSet<String>> {
let mut out = BTreeSet::new();
let entries = fs::read_dir(&self.root).map_err(|e| SnapshotError::io(&self.root, e))?;
for entry in entries {
let entry = entry.map_err(|e| SnapshotError::io(&self.root, e))?;
let name = entry.file_name().to_string_lossy().into_owned();
if let Some(id) = name.strip_suffix(".json")
&& crate::id::is_object_name(id)
{
out.insert(id.to_string());
}
}
Ok(out)
}
/// Where manifest `id` lives, once proven to be one. See
/// [`crate::blob::BlobStore`]'s equivalent for why an internally minted
/// id is still checked.
fn manifest_path(&self, id: &str) -> Result<PathBuf> {
crate::id::validate_object("manifest id", id)?;
Ok(self.root.join(format!("{id}.json")))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use pretty_assertions::assert_eq;
fn entry(hash: &str) -> FileEntry {
FileEntry {
mode: Some(0o644),
size: 1,
mtime_secs: 10,
mtime_nanos: 0,
hash: hash.to_string(),
}
}
#[test]
fn id_is_stable_and_content_addressed() {
let mut a = Manifest::default();
a.entries.insert("/x".to_string(), entry("h1"));
let mut b = Manifest::default();
b.entries.insert("/x".to_string(), entry("h1"));
assert_eq!(a.id().unwrap(), b.id().unwrap());
b.entries.insert("/y".to_string(), entry("h2"));
assert_ne!(a.id().unwrap(), b.id().unwrap());
}
#[test]
fn save_load_roundtrip_and_dedup() {
let dir = tempfile::tempdir().unwrap();
let store = ManifestStore::open(dir.path().join("manifests")).unwrap();
let mut m = Manifest::default();
m.entries.insert("/a".to_string(), entry("abc"));
let id1 = store.save(&m).unwrap();
let id2 = store.save(&m).unwrap();
assert_eq!(id1, id2);
assert_eq!(store.ids().unwrap().len(), 1);
assert_eq!(store.load(&id1).unwrap(), m);
}
#[test]
fn missing_manifest_is_typed_error() {
let dir = tempfile::tempdir().unwrap();
let store = ManifestStore::open(dir.path().join("manifests")).unwrap();
assert!(matches!(
store.load(&"a".repeat(64)),
Err(SnapshotError::MissingManifest(_))
));
}
/// A manifest id that is not the shape we mint is refused before it
/// becomes a path. See the equivalent in `blob.rs`.
#[test]
fn a_malformed_manifest_id_is_refused() {
let dir = tempfile::tempdir().unwrap();
let store = ManifestStore::open(dir.path().join("manifests")).unwrap();
assert!(matches!(
store.load("nope"),
Err(SnapshotError::InvalidId { .. })
));
}
#[test]
fn stat_matches_tracks_size_and_mtime() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f");
fs::write(&file, b"ab").unwrap();
let meta = fs::metadata(&file).unwrap();
let (secs, nanos) = mtime_parts(&meta);
let e = FileEntry {
mode: mode_of(&meta),
size: 2,
mtime_secs: secs,
mtime_nanos: nanos,
hash: "h".to_string(),
};
assert!(e.stat_matches(&meta));
fs::write(&file, b"abc").unwrap();
let meta2 = fs::metadata(&file).unwrap();
assert!(!e.stat_matches(&meta2));
}
/// A record this build does not understand is refused, not guessed at.
/// Reading it anyway is the failure versioning exists to prevent: the
/// same bytes can mean different things in different formats, and the
/// one thing an unknown record must not do is look like a known one.
#[test]
fn a_manifest_from_an_unknown_format_is_refused() {
let dir = tempfile::tempdir().unwrap();
let store = ManifestStore::open(dir.path()).unwrap();
let mut manifest = Manifest::default();
manifest.entries.insert("/a".into(), entry("h"));
let id = store.save(&manifest).unwrap();
assert_eq!(store.load(&id).unwrap(), manifest);
// Rewrite it as a record from a build that does not exist yet.
let raw = fs::read(dir.path().join(format!("{id}.json"))).unwrap();
let mut value: serde_json::Value = serde_json::from_slice(&raw).unwrap();
value["version"] = serde_json::json!(crate::workspace::FORMAT_VERSION + 1);
fs::write(
dir.path().join(format!("{id}.json")),
serde_json::to_vec(&value).unwrap(),
)
.unwrap();
let err = store.load(&id).unwrap_err();
assert!(
matches!(
err,
SnapshotError::UnknownRecordVersion {
kind: "manifest",
..
}
),
"expected a refusal naming the record, got {err:?}"
);
}
/// The tombstone set is omitted from the JSON when empty, so reading has
/// to tolerate the key being absent — and that tolerance is only safe
/// because the version is checked first. Without it, a record from a
/// build that predated tombstones would read as "this capture looked for
/// nothing" and quietly void every deletion it had licensed.
#[test]
fn an_empty_tombstone_set_round_trips_without_the_key() {
let dir = tempfile::tempdir().unwrap();
let store = ManifestStore::open(dir.path()).unwrap();
let mut manifest = Manifest::default();
manifest.entries.insert("/a".into(), entry("h"));
let id = store.save(&manifest).unwrap();
let raw = fs::read(dir.path().join(format!("{id}.json"))).unwrap();
let value: serde_json::Value = serde_json::from_slice(&raw).unwrap();
assert!(
value.get("absent").is_none(),
"an empty set is not written at all"
);
assert_eq!(store.load(&id).unwrap().absent, BTreeSet::new());
}
}