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 |