OXYGENHUB

oxy fsck: check a repository's integrity 387335ff7f4d

Four read-only passes: every stored object (header, length, hash, and
that it decodes as its type); the operation log through all parents;
the current view's references and the dependency closure of every
change they name, with author identities, blobs, chunks and signatures
or seals; and derived data (each bookmark forms a valid state, each
checkpoint restores exactly the state it names, reference and tag
attestations verify). Each problem names its object. Shallow and
partial clones are understood. Exit 1 on any error; --json for tools.
Spec Draft 0.8h adds it as §21.1.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 23:27 UTC
Signature
✔ signed by Andy McCall
Hash
387335ff7f4d4a02548c947b566d78f3f9d3a748f58e81dbe47d491b10609a0b
Depends on
252e8cb23fbc 614541c3cebd 79d03f826ecd 84b9e9ac0ab4 982864068a59 b3c5a59a875a ba160f60219e c6aa5bd9d1c8 c792033af344 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
Depended on by
6c3dbe2018e0 ad2496f7e608 767ac6cd0545 a2387d547373 02e691881a4e dd21fd82687d 648189388f1a d8d9367878a6 acadb5ed28a1 d77e1702d770 0f93f49ce059 da652d6392d8 01a47cd7f0c8 69816d688300 6f8aeb996558 fc1a466a1e66 afaba5f1306d 62794ecc7f74 d42369abf180 3e5772ccf9da 4dcb8c0916b7
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
15 atoms, 1 tree operation, 8 files

Browse the tree at this change

added crates/oxy-core/src/repo/fsck.rs +452 −0
1 +
//! Repository integrity check (spec §21.1): `oxy fsck`.
2 +
//!
3 +
//! Reads everything and changes nothing. Four passes:
4 +
//!
5 +
//! 1. every stored object: header, length, hash, and that its body decodes
6 +
//!    as its type says;
7 +
//! 2. the operation log: every operation reachable from the head is
8 +
//!    present and decodes, and clocks never run backwards;
9 +
//! 3. references: the current view's bookmarks, tags, remote bookmarks,
10 +
//!    workspaces and policy, then the dependency closure of everything
11 +
//!    they name, with each change's author identity, blobs and chunks,
12 +
//!    and each change's signatures or seals;
13 +
//! 4. derived data: every bookmark's state validates, every checkpoint
14 +
//!    restores exactly the state it names, and reference and tag
15 +
//!    attestations carry good signatures.
16 +
//!
17 +
//! Missing blobs are warnings, not errors, in a partial clone
18 +
//! (`filter.max-blob` or a sparse filter), and changes beyond a shallow
19 +
//! boundary are expected to be absent.
20 +
21 +
use super::*;
22 +
use std::collections::{HashSet, VecDeque};
23 +
24 +
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
25 +
pub enum Severity {
26 +
    /// Worth knowing; the repository is still sound.
27 +
    Warning,
28 +
    /// Data is missing, corrupt or fails verification.
29 +
    Error,
30 +
}
31 +
32 +
#[derive(Clone, Debug, PartialEq, Eq)]
33 +
pub struct Problem {
34 +
    pub severity: Severity,
35 +
    /// The object the problem is about, when there is one.
36 +
    pub object: Option<ObjectRef>,
37 +
    pub what: String,
38 +
}
39 +
40 +
#[derive(Clone, Debug, Default, PartialEq, Eq)]
41 +
pub struct FsckReport {
42 +
    pub objects: usize,
43 +
    pub ops: usize,
44 +
    /// Changes checked, working-copy changes included.
45 +
    pub changes: usize,
46 +
    /// Committed changes authenticated by their own signature or by a seal.
47 +
    pub signed: usize,
48 +
    pub unsigned: usize,
49 +
    pub checkpoints: usize,
50 +
    pub attestations: usize,
51 +
    pub problems: Vec<Problem>,
52 +
}
53 +
54 +
impl FsckReport {
55 +
    pub fn errors(&self) -> usize {
56 +
        self.problems
57 +
            .iter()
58 +
            .filter(|p| p.severity == Severity::Error)
59 +
            .count()
60 +
    }
61 +
62 +
    pub fn warnings(&self) -> usize {
63 +
        self.problems
64 +
            .iter()
65 +
            .filter(|p| p.severity == Severity::Warning)
66 +
            .count()
67 +
    }
68 +
69 +
    fn error(&mut self, object: Option<ObjectRef>, what: impl Into<String>) {
70 +
        self.problems.push(Problem {
71 +
            severity: Severity::Error,
72 +
            object,
73 +
            what: what.into(),
74 +
        });
75 +
    }
76 +
77 +
    fn warn(&mut self, object: Option<ObjectRef>, what: impl Into<String>) {
78 +
        self.problems.push(Problem {
79 +
            severity: Severity::Warning,
80 +
            object,
81 +
            what: what.into(),
82 +
        });
83 +
    }
84 +
}
85 +
86 +
/// Whether `body` decodes as an object of type `ty`.
87 +
fn decodes(ty: ObjectType, body: &[u8]) -> std::result::Result<(), String> {
88 +
    let r = match ty {
89 +
        ObjectType::Chunk => Ok(()),
90 +
        ObjectType::Blob => Blob::decode(body).map(|_| ()),
91 +
        ObjectType::Change => Change::decode(body).map(|_| ()),
92 +
        ObjectType::Checkpoint => crate::objects::Checkpoint::decode(body).map(|_| ()),
93 +
        ObjectType::Op => match Op::decode(body) {
94 +
            Ok(_) => Ok(()),
95 +
            Err(e) => Op::decode_format1(body).map(|_| ()).map_err(|_| e),
96 +
        },
97 +
        ObjectType::Attest => Attest::decode(body).map(|_| ()),
98 +
        ObjectType::Identity => Identity::decode(body).map(|_| ()),
99 +
        ObjectType::Policy => crate::policy::Policy::decode(body).map(|_| ()),
100 +
        ObjectType::State => crate::objects::StateObject::decode(body).map(|_| ()),
101 +
    };
102 +
    r.map_err(|e| e.to_string())
103 +
}
104 +
105 +
impl Repo {
106 +
    /// Check the whole repository; see the module documentation.
107 +
    pub fn fsck(&self) -> Result<FsckReport> {
108 +
        let mut rep = FsckReport::default();
109 +
        let bad = self.fsck_objects(&mut rep)?;
110 +
        self.fsck_ops(&mut rep, &bad);
111 +
        self.fsck_references(&mut rep, &bad)?;
112 +
        self.fsck_derived(&mut rep)?;
113 +
        rep.problems.sort_by_key(|p| std::cmp::Reverse(p.severity));
114 +
        Ok(rep)
115 +
    }
116 +
117 +
    /// Pass 1. Returns the objects that failed, so later passes report
118 +
    /// them once rather than at every reference.
119 +
    fn fsck_objects(&self, rep: &mut FsckReport) -> Result<HashSet<ObjectRef>> {
120 +
        let mut bad = HashSet::new();
121 +
        for r in self.store.list()? {
122 +
            rep.objects += 1;
123 +
            match self.store.get(&r) {
124 +
                Ok((ty, body)) => {
125 +
                    if let Err(e) = decodes(ty, &body) {
126 +
                        rep.error(
127 +
                            Some(r),
128 +
                            format!("{} object does not decode: {e}", ty.name()),
129 +
                        );
130 +
                        bad.insert(r);
131 +
                    }
132 +
                }
133 +
                Err(e) => {
134 +
                    rep.error(Some(r), format!("unreadable object: {e}"));
135 +
                    bad.insert(r);
136 +
                }
137 +
            }
138 +
        }
139 +
        Ok(bad)
140 +
    }
141 +
142 +
    /// Pass 2.
143 +
    fn fsck_ops(&self, rep: &mut FsckReport, bad: &HashSet<ObjectRef>) {
144 +
        let head = match fs::read_to_string(self.oxy.join("ops").join("head")) {
145 +
            Ok(h) => h,
146 +
            Err(e) => {
147 +
                rep.error(None, format!("cannot read .oxy/ops/head: {e}"));
148 +
                return;
149 +
            }
150 +
        };
151 +
        let Some(head) = ObjectRef::from_hex(head.trim()) else {
152 +
            rep.error(None, ".oxy/ops/head does not hold an object reference");
153 +
            return;
154 +
        };
155 +
        let mut seen = HashSet::new();
156 +
        let mut queue: VecDeque<(ObjectRef, Option<(ObjectRef, u64)>)> =
157 +
            VecDeque::from([(head, None)]);
158 +
        while let Some((r, child)) = queue.pop_front() {
159 +
            if !seen.insert(r) {
160 +
                continue;
161 +
            }
162 +
            if bad.contains(&r) {
163 +
                continue;
164 +
            }
165 +
            let body = match self.store.get_typed(&r, ObjectType::Op) {
166 +
                Ok(b) => b,
167 +
                Err(StoreError::NotFound(_)) => {
168 +
                    let by = child
169 +
                        .map(|(c, _)| format!(", the parent of operation {}", c.short(12)))
170 +
                        .unwrap_or_default();
171 +
                    rep.error(
172 +
                        Some(r),
173 +
                        format!("operation missing{by}; the log is cut here"),
174 +
                    );
175 +
                    continue;
176 +
                }
177 +
                Err(e) => {
178 +
                    rep.error(Some(r), format!("operation unreadable: {e}"));
179 +
                    continue;
180 +
                }
181 +
            };
182 +
            rep.ops += 1;
183 +
            let (parents, wall_ms) = match Op::decode(&body) {
184 +
                Ok(op) => (op.parents, op.hlc.wall_ms),
185 +
                Err(_) => match Op::decode_format1(&body) {
186 +
                    Ok(op) => (op.parents, op.hlc.wall_ms),
187 +
                    Err(e) => {
188 +
                        rep.error(Some(r), format!("operation does not decode: {e}"));
189 +
                        continue;
190 +
                    }
191 +
                },
192 +
            };
193 +
            if let Some((c, child_ms)) = child {
194 +
                if wall_ms > child_ms {
195 +
                    rep.warn(
196 +
                        Some(c),
197 +
                        format!(
198 +
                            "operation is timed before its parent {} (a clock was wrong)",
199 +
                            r.short(12)
200 +
                        ),
201 +
                    );
202 +
                }
203 +
            }
204 +
            queue.extend(parents.into_iter().map(|p| (p, Some((r, wall_ms)))));
205 +
        }
206 +
    }
207 +
208 +
    /// Pass 3.
209 +
    fn fsck_references(&self, rep: &mut FsckReport, bad: &HashSet<ObjectRef>) -> Result<()> {
210 +
        let view = match self.view() {
211 +
            Ok(v) => v,
212 +
            Err(e) => {
213 +
                rep.error(None, format!("cannot read the current view: {e}"));
214 +
                return Ok(());
215 +
            }
216 +
        };
217 +
        let boundary = self.boundary();
218 +
        let partial = self.config.max_blob.is_some() || !self.config.sparse_paths.is_empty();
219 +
220 +
        let mut roots: Vec<(ChangeHash, String)> = Vec::new();
221 +
        let mut states: Vec<(ObjectRef, String)> = Vec::new();
222 +
        states.extend(
223 +
            view.bookmarks
224 +
                .iter()
225 +
                .map(|(n, s)| (*s, format!("bookmark {n}"))),
226 +
        );
227 +
        states.extend(view.tags.iter().map(|(n, s)| (*s, format!("tag {n}"))));
228 +
        for (remote, bms) in &view.remotes {
229 +
            states.extend(bms.iter().map(|(n, s)| (*s, format!("{remote}/{n}"))));
230 +
        }
231 +
        for (st, what) in states {
232 +
            match self.store.get_state(&st) {
233 +
                Ok(s) => roots.extend(s.heads.into_iter().map(|h| (h, what.clone()))),
234 +
                Err(e) if !bad.contains(&st) => {
235 +
                    rep.error(Some(st), format!("state of {what} unusable: {e}"))
236 +
                }
237 +
                Err(_) => {}
238 +
            }
239 +
        }
240 +
        for (ws, h) in &view.workspaces {
241 +
            roots.push((*h, format!("workspace {}", ws.to_hex())));
242 +
        }
243 +
        if let Some(p) = view.policy {
244 +
            match self.store.get_typed(&p, ObjectType::Policy) {
245 +
                Ok(bytes) => {
246 +
                    if let Ok(policy) = crate::policy::Policy::decode(&bytes) {
247 +
                        for id in &policy.identities {
248 +
                            if !self.store.has(&id.hash()) {
249 +
                                rep.warn(
250 +
                                    Some(id.hash()),
251 +
                                    "an identity the policy names is not stored",
252 +
                                );
253 +
                            }
254 +
                        }
255 +
                    }
256 +
                }
257 +
                Err(e) if !bad.contains(&p) => {
258 +
                    rep.error(Some(p), format!("the policy is unusable: {e}"))
259 +
                }
260 +
                Err(_) => {}
261 +
            }
262 +
        }
263 +
264 +
        // Working-copy changes are never signed; they are checked but not tallied.
265 +
        let working: HashSet<ChangeHash> = view.workspaces.values().copied().collect();
266 +
        let mut seen: HashSet<ChangeHash> = HashSet::new();
267 +
        let mut blobs_seen: HashSet<ObjectRef> = HashSet::new();
268 +
        let mut stack = roots;
269 +
        while let Some((h, needed_by)) = stack.pop() {
270 +
            if !seen.insert(h) {
271 +
                continue;
272 +
            }
273 +
            let c = match self.store.get_change(&h) {
274 +
                Ok(c) => c,
275 +
                Err(StoreError::NotFound(_)) if boundary.contains(&h) => continue,
276 +
                Err(StoreError::NotFound(_)) => {
277 +
                    rep.error(Some(h), format!("change missing, needed by {needed_by}"));
278 +
                    continue;
279 +
                }
280 +
                Err(e) => {
281 +
                    if !bad.contains(&h) {
282 +
                        rep.error(Some(h), format!("change unusable: {e}"));
283 +
                    }
284 +
                    continue;
285 +
                }
286 +
            };
287 +
            rep.changes += 1;
288 +
            let label = format!("change {}", h.short(12));
289 +
            stack.extend(c.deps.iter().map(|d| (*d, label.clone())));
290 +
            if !c.author.is_zero() && !self.store.has(&c.author) {
291 +
                rep.warn(
292 +
                    Some(h),
293 +
                    format!("author identity {} is not stored", c.author.short(12)),
294 +
                );
295 +
            }
296 +
            for e in &c.edits {
297 +
                let Edit::Replace { old, new, .. } = e else {
298 +
                    continue;
299 +
                };
300 +
                for b in [old, new].into_iter().flatten() {
301 +
                    if !blobs_seen.insert(*b) {
302 +
                        continue;
303 +
                    }
304 +
                    self.fsck_blob(rep, *b, &label, partial, bad);
305 +
                }
306 +
            }
307 +
            if working.contains(&h) {
308 +
                continue;
309 +
            }
310 +
            match self.authenticate_change(&h) {
311 +
                Ok(SignatureStatus::Valid { .. }) => rep.signed += 1,
312 +
                Ok(SignatureStatus::Unsigned) => rep.unsigned += 1,
313 +
                Ok(SignatureStatus::Invalid(why)) => {
314 +
                    rep.error(Some(h), format!("bad signature: {why}"))
315 +
                }
316 +
                Err(e) => rep.error(Some(h), format!("cannot check signatures: {e}")),
317 +
            }
318 +
        }
319 +
        Ok(())
320 +
    }
321 +
322 +
    fn fsck_blob(
323 +
        &self,
324 +
        rep: &mut FsckReport,
325 +
        b: ObjectRef,
326 +
        used_by: &str,
327 +
        partial: bool,
328 +
        bad: &HashSet<ObjectRef>,
329 +
    ) {
330 +
        match self.store.get_typed(&b, ObjectType::Blob) {
331 +
            Ok(bytes) => {
332 +
                let Ok(blob) = Blob::decode(&bytes) else {
333 +
                    return;
334 +
                };
335 +
                for ch in blob.chunks {
336 +
                    if !self.store.has(&ch) {
337 +
                        rep.error(
338 +
                            Some(ch),
339 +
                            format!("chunk missing from blob {} ({used_by})", b.short(12)),
340 +
                        );
341 +
                    }
342 +
                }
343 +
            }
344 +
            Err(StoreError::NotFound(_)) if partial => rep.warn(
345 +
                Some(b),
346 +
                format!("blob not fetched ({used_by}); expected in a partial clone"),
347 +
            ),
348 +
            Err(StoreError::NotFound(_)) => rep.error(Some(b), format!("blob missing ({used_by})")),
349 +
            Err(e) if !bad.contains(&b) => {
350 +
                rep.error(Some(b), format!("blob unusable ({used_by}): {e}"))
351 +
            }
352 +
            Err(_) => {}
353 +
        }
354 +
    }
355 +
356 +
    /// Pass 4.
357 +
    fn fsck_derived(&self, rep: &mut FsckReport) -> Result<()> {
358 +
        let Ok(view) = self.view() else { return Ok(()) };
359 +
        let shallow = self.is_shallow();
360 +
        if !shallow {
361 +
            for (name, st) in &view.bookmarks {
362 +
                let Ok(s) = self.store.get_state(st) else {
363 +
                    continue;
364 +
                };
365 +
                if let Err(e) = self.load_state_full(&s.heads) {
366 +
                    rep.error(
367 +
                        Some(*st),
368 +
                        format!("bookmark {name} does not form a valid state: {e}"),
369 +
                    );
370 +
                }
371 +
            }
372 +
        }
373 +
        for r in self.checkpoint_refs()? {
374 +
            rep.checkpoints += 1;
375 +
            let cp = match self.store.get_typed(&r, ObjectType::Checkpoint) {
376 +
                Ok(b) => match crate::objects::Checkpoint::decode(&b) {
377 +
                    Ok(cp) => cp,
378 +
                    Err(_) => continue,
379 +
                },
380 +
                Err(StoreError::NotFound(_)) => {
381 +
                    rep.warn(
382 +
                        Some(r),
383 +
                        "checkpoint pointer names a missing checkpoint; it will be rewritten",
384 +
                    );
385 +
                    continue;
386 +
                }
387 +
                Err(_) => continue,
388 +
            };
389 +
            let payload = match self.read_blob(&cp.payload) {
390 +
                Ok(p) => p,
391 +
                Err(e) => {
392 +
                    rep.error(Some(r), format!("checkpoint payload unreadable: {e}"));
393 +
                    continue;
394 +
                }
395 +
            };
396 +
            if State::from_checkpoint_payload(&payload).is_err() {
397 +
                rep.error(Some(r), "checkpoint payload does not decode");
398 +
                continue;
399 +
            }
400 +
            if !shallow {
401 +
                match self.load_state_full(&cp.heads) {
402 +
                    Ok(full) if full.checkpoint_payload() != payload => {
403 +
                        rep.error(Some(r), "checkpoint disagrees with the changes it names")
404 +
                    }
405 +
                    Ok(_) => {}
406 +
                    Err(e) => rep.error(
407 +
                        Some(r),
408 +
                        format!("checkpoint names changes that do not form a state: {e}"),
409 +
                    ),
410 +
                }
411 +
            }
412 +
        }
413 +
        let mut attests = Vec::new();
414 +
        for name in view.bookmarks.keys() {
415 +
            if let Ok(Some((r, _))) = self.latest_ref_attest(name) {
416 +
                attests.push((r, format!("bookmark {name}")));
417 +
            }
418 +
        }
419 +
        for name in view.tags.keys() {
420 +
            if let Ok(Some((r, _))) = self.tag_attest(name) {
421 +
                attests.push((r, format!("tag {name}")));
422 +
            }
423 +
        }
424 +
        for (r, what) in attests {
425 +
            rep.attestations += 1;
426 +
            let sigs = self.store.signatures(&r)?;
427 +
            if sigs.is_empty() {
428 +
                rep.warn(Some(r), format!("attestation for {what} is unsigned"));
429 +
            }
430 +
            for sig in sigs {
431 +
                match self.store.get_identity(&sig.key) {
432 +
                    Ok(id) => {
433 +
                        if let Err(e) = verify_signature(&id, &sig, Domain::Attest, &r) {
434 +
                            rep.error(
435 +
                                Some(r),
436 +
                                format!("attestation for {what} has a bad signature: {e}"),
437 +
                            );
438 +
                        }
439 +
                    }
440 +
                    Err(_) => rep.warn(
441 +
                        Some(r),
442 +
                        format!(
443 +
                            "attestation for {what} is signed by {}, whose identity is not stored",
444 +
                            sig.key.short(12)
445 +
                        ),
446 +
                    ),
447 +
                }
448 +
            }
449 +
        }
450 +
        Ok(())
451 +
    }
452 +
}
modified crates/oxy-core/src/repo/mod.rs +2 −0
…
61 unchanged lines
62 62
mod checkout;
63 63
mod checkpoint;
64 64
mod config;
65 +
mod fsck;
65 66
mod gc;
66 67
mod git;
67 68
mod history;
…
14 unchanged lines
82 83
pub use bookmarks::{BookmarkInfo, MergeReport};
83 84
pub use checkout::CheckoutReport;
84 85
pub use config::{parse_size, read_config_file, Config};
86 +
pub use fsck::{FsckReport, Problem, Severity};
85 87
pub use gc::GcReport;
86 88
pub use git::{GitExportReport, GitImportReport};
87 89
pub use hooks::{json_array, json_str, HookOutcome};
…
1136 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +94 −0
…
3877 unchanged lines
3878 3878
        "an unsigned attestation is not advertised"
3879 3879
    );
3880 3880
}
3881 +
3882 +
#[test]
3883 +
fn fsck_passes_a_sound_repository_and_names_each_fault() {
3884 +
    hermetic();
3885 +
    let dir = tempfile::tempdir().unwrap();
3886 +
    let root = dir.path();
3887 +
    let origin_path = root.join("origin");
3888 +
    let origin = Repo::init(&origin_path).unwrap();
3889 +
    let mut a = Repo::clone(&origin_path.to_string_lossy(), &root.join("a"))
3890 +
        .unwrap()
3891 +
        .with_home(root.join("home-a"));
3892 +
    a.create_identity("Ada", "[email protected]", IdentityScope::Repo)
3893 +
        .unwrap();
3894 +
    write(&root.join("a"), "f.txt", b"one\n");
3895 +
    write(&root.join("a"), "bin.dat", b"\0\x01\x02binary");
3896 +
    let h1 = a.commit("first", &[]).unwrap();
3897 +
    write(&root.join("a"), "f.txt", b"one\ntwo\n");
3898 +
    let h2 = a.commit("second", &[]).unwrap();
3899 +
    a.tag_create("v1", &[h2], "first release").unwrap();
3900 +
    a.checkpoint().unwrap();
3901 +
    assert!(a.push("origin", false).unwrap()[0].ok);
3902 +
3903 +
    // Sound: signed changes, a seal, a tag, a checkpoint, a ref attestation.
3904 +
    for repo in [&a, &origin] {
3905 +
        let rep = repo.fsck().unwrap();
3906 +
        assert_eq!(rep.problems, vec![], "{rep:?}");
3907 +
        // The two commits, plus the working copy's change in `a`.
3908 +
        assert!(rep.changes >= 2 && rep.signed >= 2, "{rep:?}");
3909 +
        assert!(rep.ops > 1 && rep.objects > 5);
3910 +
    }
3911 +
    assert!(origin.fsck().unwrap().attestations >= 1);
3912 +
3913 +
    // A flipped byte in a stored object is corruption, named by hash.
3914 +
    let c1 = a.store().path_for(&h1);
3915 +
    let pristine = fs::read(&c1).unwrap();
3916 +
    let mut bytes = pristine.clone();
3917 +
    *bytes.last_mut().unwrap() ^= 1;
3918 +
    fs::write(&c1, &bytes).unwrap();
3919 +
    let rep = a.fsck().unwrap();
3920 +
    assert!(rep.errors() >= 1);
3921 +
    assert!(
3922 +
        rep.problems
3923 +
            .iter()
3924 +
            .any(|p| p.object == Some(h1) && p.what.contains("unreadable")),
3925 +
        "{rep:?}"
3926 +
    );
3927 +
    fs::write(&c1, &pristine).unwrap();
3928 +
    assert_eq!(a.fsck().unwrap().errors(), 0);
3929 +
3930 +
    // A missing change is named with what needed it.
3931 +
    fs::remove_file(&c1).unwrap();
3932 +
    let rep = a.fsck().unwrap();
3933 +
    assert!(
3934 +
        rep.problems.iter().any(|p| p.severity == Severity::Error
3935 +
            && p.object == Some(h1)
3936 +
            && p.what.contains("change missing, needed by change")),
3937 +
        "{rep:?}"
3938 +
    );
3939 +
    fs::write(&c1, &pristine).unwrap();
3940 +
3941 +
    // A missing blob is an error in a full clone.
3942 +
    let c2 = a.store().get_change(&h1).unwrap();
3943 +
    let blob = c2
3944 +
        .edits
3945 +
        .iter()
3946 +
        .find_map(|e| match e {
3947 +
            Edit::Replace { new: Some(b), .. } => Some(*b),
3948 +
            _ => None,
3949 +
        })
3950 +
        .expect("the binary file is a blob");
3951 +
    let bp = a.store().path_for(&blob);
3952 +
    let blob_bytes = fs::read(&bp).unwrap();
3953 +
    fs::remove_file(&bp).unwrap();
3954 +
    let rep = a.fsck().unwrap();
3955 +
    assert!(
3956 +
        rep.problems
3957 +
            .iter()
3958 +
            .any(|p| p.object == Some(blob) && p.what.contains("blob missing")),
3959 +
        "{rep:?}"
3960 +
    );
3961 +
    fs::write(&bp, &blob_bytes).unwrap();
3962 +
3963 +
    // A broken op log head is reported, not a crash.
3964 +
    let head = root.join("a/.oxy/ops/head");
3965 +
    let saved = fs::read(&head).unwrap();
3966 +
    fs::write(&head, b"not a hash").unwrap();
3967 +
    let rep = a.fsck().unwrap();
3968 +
    assert!(
3969 +
        rep.problems.iter().any(|p| p.what.contains("ops/head")),
3970 +
        "{rep:?}"
3971 +
    );
3972 +
    fs::write(&head, saved).unwrap();
3973 +
    assert_eq!(a.fsck().unwrap().problems, vec![]);
3974 +
}
modified crates/oxy-core/src/store.rs +1 −1
…
525 unchanged lines
526 526
        &self.dir
527 527
    }
528 528
529 -
    fn path_for(&self, r: &ObjectRef) -> PathBuf {
529 +
    pub(crate) fn path_for(&self, r: &ObjectRef) -> PathBuf {
530 530
        let h = r.to_hex();
531 531
        self.dir.join("loose").join(&h[..2]).join(&h[2..])
532 532
    }
…
783 unchanged lines
modified docs/guide.md +1 −0
…
399 unchanged lines
400 400
```
401 401
oxy gc              # pack loose objects, prune unreachable ones past the grace period
402 402
oxy gc --now        # ignore the grace period (only when nothing else is writing)
403 +
oxy fsck            # check every object, the op log, references, signatures, checkpoints
403 404
oxy checkpoint      # serialise the bookmark's state so large histories load instantly
404 405
```
405 406
…
24 unchanged lines
modified docs/spec.md +7 −2
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.8g |
5 +
| **Status** | Draft 0.8h |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
776 unchanged lines
785 785
786 786
An object is **reachable** if it is referenced, directly or transitively, from any bookmark, tag, policy, the current operation heads, any operation newer than `core.op-retention`, any workspace's working-copy change, or any checkpoint of a reachable state. `oxy gc` deletes unreachable objects that are older than `core.gc-grace` (default 14 days, to protect concurrent writers) and repacks. Tombstone atoms are never collected independently of their change. Divergent changes are reachable if any of their variants is. A seal is reachable if any change it covers is reachable, and reference attestations are reachable while they are the latest for their reference or are within `core.op-retention`.
787 787
788 +
### 21.1 Integrity check
789 +
790 +
`oxy fsck` reads everything and changes nothing. It checks (1) every stored object: header, length, ObjectRef, and that the body decodes as its type; (2) the operation log: every operation reachable from the head through all parents is present and decodes, and no operation's clock is earlier than its parent's (a warning); (3) the current view's bookmarks, tags, remote bookmarks, workspaces and policy, and the dependency closure of every change they name, with each change's author identity, blobs and chunks, and each committed change's signature or seal (§14.4); (4) that every bookmark forms a valid state, that every checkpoint restores exactly the state of the changes it names (§11), and that reference and tag attestations carry valid signatures. A change beyond the shallow boundary (§17) may be absent; a blob absent from a partial clone (`filter.max-blob` or a sparse filter) is a warning, not an error. Each problem names the object it concerns. The exit status is 1 when any error is found and 0 otherwise; `--json` gives the counts and problems.
791 +
788 792
## 22. Client profiles and retro hardware
789 793
790 794
| | Full | Lite | Thin |
…
47 unchanged lines
838 842
oxy git import|export|fetch|push   (fetch and push go through a bare mirror under .oxy/git/)
839 843
oxy checkpoint                      oxy gc [--now]               oxy config [--user] KEY VALUE
840 844
oxy root                            oxy cat PATH [-r CHANGE]     oxy version
845 +
oxy fsck [--json]                   check integrity (§21.1)
841 846
oxy hunks                           oxy discard PATH.. | --hunks N,M
842 847
```
843 848
…
75 unchanged lines
919 924
920 925
---
921 926
922 -
*Changelog.* Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
927 +
*Changelog.* Draft 0.8h (2026-09-28): `oxy fsck` (§21.1). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
923 928
924 929
Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23).
925 930
…
59 unchanged lines
modified src/cli/mod.rs +6 −0
…
205 unchanged lines
206 206
    Undo,
207 207
    /// Write a checkpoint of the current bookmark's state now
208 208
    Checkpoint,
209 +
    /// Check the repository's integrity: objects, operation log, references, signatures, checkpoints
210 +
    Fsck {
211 +
        #[arg(long)]
212 +
        json: bool,
213 +
    },
209 214
    /// Pack loose objects and prune unreachable ones older than the grace period
210 215
    Gc {
211 216
        /// Ignore the grace period (core.gc-grace) and prune immediately
…
442 unchanged lines
654 659
        Cmd::Resolve { json } => workspace::resolve(json),
655 660
        Cmd::Undo => workspace::undo(),
656 661
        Cmd::Gc { now } => workspace::gc(now),
662 +
        Cmd::Fsck { json } => workspace::fsck(json),
657 663
        Cmd::Checkpoint => workspace::checkpoint(),
658 664
        Cmd::Op {
659 665
            cmd: OpCmd::Log { json },
…
122 unchanged lines
modified src/cli/workspace.rs +85 −0
…
1011 unchanged lines
1012 1012
    Ok(0)
1013 1013
}
1014 1014
1015 +
/// `oxy fsck`: exit 0 when sound (warnings allowed), 1 on any error.
1016 +
pub fn fsck(as_json: bool) -> Result<i32> {
1017 +
    use oxy_core::repo::Severity;
1018 +
    let repo = open()?;
1019 +
    let sp = ui::spinner("Checking every object…");
1020 +
    let r = repo.fsck()?;
1021 +
    sp.clear();
1022 +
    let code = if r.errors() > 0 { 1 } else { 0 };
1023 +
    if as_json {
1024 +
        let problems: Vec<String> = r
1025 +
            .problems
1026 +
            .iter()
1027 +
            .map(|p| {
1028 +
                json::obj([
1029 +
                    (
1030 +
                        "severity",
1031 +
                        json::s(match p.severity {
1032 +
                            Severity::Error => "error",
1033 +
                            Severity::Warning => "warning",
1034 +
                        }),
1035 +
                    ),
1036 +
                    (
1037 +
                        "object",
1038 +
                        json::opt(p.object.map(|o| o.to_hex()).as_deref(), json::s),
1039 +
                    ),
1040 +
                    ("what", json::s(&p.what)),
1041 +
                ])
1042 +
            })
1043 +
            .collect();
1044 +
        println!(
1045 +
            "{}",
1046 +
            json::obj([
1047 +
                ("ok", json::b(code == 0)),
1048 +
                ("objects", json::n(r.objects)),
1049 +
                ("ops", json::n(r.ops)),
1050 +
                ("changes", json::n(r.changes)),
1051 +
                ("signed", json::n(r.signed)),
1052 +
                ("unsigned", json::n(r.unsigned)),
1053 +
                ("checkpoints", json::n(r.checkpoints)),
1054 +
                ("attestations", json::n(r.attestations)),
1055 +
                ("errors", json::n(r.errors())),
1056 +
                ("warnings", json::n(r.warnings())),
1057 +
                ("problems", json::arr(problems)),
1058 +
            ])
1059 +
        );
1060 +
        return Ok(code);
1061 +
    }
1062 +
    println!(
1063 +
        "Checked {}, {}, {} ({} signed or sealed, {} unsigned), {}, {}.",
1064 +
        plural(r.objects, "object"),
1065 +
        plural(r.ops, "operation"),
1066 +
        plural(r.changes, "change"),
1067 +
        r.signed,
1068 +
        r.unsigned,
1069 +
        plural(r.checkpoints, "checkpoint"),
1070 +
        plural(r.attestations, "attestation"),
1071 +
    );
1072 +
    for p in &r.problems {
1073 +
        let obj = p
1074 +
            .object
1075 +
            .map(|o| format!("{} ", ui::hash(format!("#{}", o.short(12)))))
1076 +
            .unwrap_or_default();
1077 +
        match p.severity {
1078 +
            Severity::Error => println!("{} {obj}{}", ui::err(ui::glyph("✖", "x")), p.what),
1079 +
            Severity::Warning => println!("{} {obj}{}", ui::caution(), p.what),
1080 +
        }
1081 +
    }
1082 +
    if r.problems.is_empty() {
1083 +
        println!("{}", ui::done("No problems found."));
1084 +
    } else if code == 0 {
1085 +
        println!(
1086 +
            "{}",
1087 +
            ui::done(format!("Sound, with {}.", plural(r.warnings(), "warning")))
1088 +
        );
1089 +
    } else {
1090 +
        println!(
1091 +
            "{} {} and {}. Fetch missing objects from a remote with `oxy pull`, or clone afresh and compare.",
1092 +
            ui::err(ui::glyph("✖", "x")),
1093 +
            plural(r.errors(), "error"),
1094 +
            plural(r.warnings(), "warning")
1095 +
        );
1096 +
    }
1097 +
    Ok(code)
1098 +
}
1099 +
1015 1100
/// The head the working copy sits on, for commands that default to it. With
1016 1101
/// several heads beneath the working copy, the most recently made one is
1017 1102
/// chosen and announced.
…
544 unchanged lines