FastCDC chunking with a pinned gear table; oxy checkout with checkout-safety rules f9c98deef326
- Author
- unknown
- When
- · 2026-09-26 18:06 UTC
- Signature
- ◈ sealed by Andy McCall
- Hash
f9c98deef32627274454b5fd0ee5d2b47a8c9c91dbdad9b717e9b9be7bf3d4b0- Depends on
- 55e5a16a2b9c ff0af1ee299d
- Depended on by
- 02e691881a4e ff1f62fee654 75167672ebcd fce44ae9ad29 5b9c6ad45f6e 86e682c242c6 20247a03a596 5b1114a04905 1983686fb7a7
- 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, 5 files
Browse the tree at this change
added crates/oxy-core/src/chunk.rs +162 −0
| 1 | + | //! FastCDC content-defined chunking (spec §13.2). |
|
| 2 | + | //! |
|
| 3 | + | //! Parameters: 32 KiB minimum, 64 KiB target, 256 KiB maximum, normalized |
|
| 4 | + | //! chunking level 2. The gear table is derived from BLAKE3 so that every |
|
| 5 | + | //! implementation produces identical chunk boundaries: |
|
| 6 | + | //! `GEAR[i] = u64::from_le_bytes(XOF("oxy1 fastcdc gear")[8*i .. 8*i+8])`. |
|
| 7 | + | //! The masks have `16 + 2` and `16 - 2` one-bits spread evenly over 64 bits |
|
| 8 | + | //! (see `spread_mask`). Cut-point skipping starts hashing at the minimum size, |
|
| 9 | + | //! as in the FastCDC paper (Xia et al., 2016). |
|
| 10 | + | ||
| 11 | + | use std::sync::OnceLock; |
|
| 12 | + | ||
| 13 | + | pub const MIN_SIZE: usize = 32 * 1024; |
|
| 14 | + | pub const AVG_SIZE: usize = 64 * 1024; |
|
| 15 | + | pub const MAX_SIZE: usize = 256 * 1024; |
|
| 16 | + | const AVG_BITS: u32 = 16; // log2(AVG_SIZE) |
|
| 17 | + | const NORMALIZATION: u32 = 2; |
|
| 18 | + | pub const GEAR_SEED: &[u8] = b"oxy1 fastcdc gear"; |
|
| 19 | + | ||
| 20 | + | fn gear() -> &'static [u64; 256] { |
|
| 21 | + | static GEAR: OnceLock<[u64; 256]> = OnceLock::new(); |
|
| 22 | + | GEAR.get_or_init(|| { |
|
| 23 | + | let mut out = [0u8; 256 * 8]; |
|
| 24 | + | blake3::Hasher::new().update(GEAR_SEED).finalize_xof().fill(&mut out); |
|
| 25 | + | let mut g = [0u64; 256]; |
|
| 26 | + | for (i, v) in g.iter_mut().enumerate() { |
|
| 27 | + | *v = u64::from_le_bytes(out[i * 8..i * 8 + 8].try_into().unwrap()); |
|
| 28 | + | } |
|
| 29 | + | g |
|
| 30 | + | }) |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | /// A mask with `ones` one-bits at bit positions `63 - floor(i * 64 / ones)`. |
|
| 34 | + | pub const fn spread_mask(ones: u32) -> u64 { |
|
| 35 | + | let mut m = 0u64; |
|
| 36 | + | let mut i = 0; |
|
| 37 | + | while i < ones { |
|
| 38 | + | m |= 1u64 << (63 - (i * 64 / ones)); |
|
| 39 | + | i += 1; |
|
| 40 | + | } |
|
| 41 | + | m |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | pub const MASK_S: u64 = spread_mask(AVG_BITS + NORMALIZATION); |
|
| 45 | + | pub const MASK_L: u64 = spread_mask(AVG_BITS - NORMALIZATION); |
|
| 46 | + | ||
| 47 | + | /// Length of the first chunk of `data`. |
|
| 48 | + | pub fn cut_point(data: &[u8]) -> usize { |
|
| 49 | + | let g = gear(); |
|
| 50 | + | let mut n = data.len(); |
|
| 51 | + | if n <= MIN_SIZE { |
|
| 52 | + | return n; |
|
| 53 | + | } |
|
| 54 | + | let mut normal = AVG_SIZE; |
|
| 55 | + | if n >= MAX_SIZE { |
|
| 56 | + | n = MAX_SIZE; |
|
| 57 | + | } else if n <= normal { |
|
| 58 | + | normal = n; |
|
| 59 | + | } |
|
| 60 | + | let mut hash: u64 = 0; |
|
| 61 | + | let mut i = MIN_SIZE; |
|
| 62 | + | while i < normal { |
|
| 63 | + | hash = (hash << 1).wrapping_add(g[data[i] as usize]); |
|
| 64 | + | if hash & MASK_S == 0 { |
|
| 65 | + | return i; |
|
| 66 | + | } |
|
| 67 | + | i += 1; |
|
| 68 | + | } |
|
| 69 | + | while i < n { |
|
| 70 | + | hash = (hash << 1).wrapping_add(g[data[i] as usize]); |
|
| 71 | + | if hash & MASK_L == 0 { |
|
| 72 | + | return i; |
|
| 73 | + | } |
|
| 74 | + | i += 1; |
|
| 75 | + | } |
|
| 76 | + | n |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | /// Split `data` into chunks; the concatenation of the result is `data`. |
|
| 80 | + | pub fn chunks(data: &[u8]) -> Vec<&[u8]> { |
|
| 81 | + | let mut out = Vec::new(); |
|
| 82 | + | let mut rest = data; |
|
| 83 | + | while !rest.is_empty() { |
|
| 84 | + | let n = cut_point(rest); |
|
| 85 | + | out.push(&rest[..n]); |
|
| 86 | + | rest = &rest[n..]; |
|
| 87 | + | } |
|
| 88 | + | out |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | #[cfg(test)] |
|
| 92 | + | mod tests { |
|
| 93 | + | use super::*; |
|
| 94 | + | use rand::{RngCore, SeedableRng}; |
|
| 95 | + | ||
| 96 | + | fn random(len: usize, seed: u64) -> Vec<u8> { |
|
| 97 | + | let mut v = vec![0u8; len]; |
|
| 98 | + | rand::rngs::StdRng::seed_from_u64(seed).fill_bytes(&mut v); |
|
| 99 | + | v |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | #[test] |
|
| 103 | + | fn masks_and_gear_are_pinned() { |
|
| 104 | + | assert_eq!(MASK_S.count_ones(), 18); |
|
| 105 | + | assert_eq!(MASK_L.count_ones(), 14); |
|
| 106 | + | assert_eq!(MASK_S & (1 << 63), 1 << 63); |
|
| 107 | + | // Pin the first gear entry so the table cannot drift silently. |
|
| 108 | + | let mut out = [0u8; 8]; |
|
| 109 | + | blake3::Hasher::new().update(GEAR_SEED).finalize_xof().fill(&mut out); |
|
| 110 | + | assert_eq!(gear()[0], u64::from_le_bytes(out)); |
|
| 111 | + | assert_ne!(gear()[1], gear()[2]); |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | #[test] |
|
| 115 | + | fn small_inputs_are_one_chunk() { |
|
| 116 | + | assert!(chunks(b"").is_empty()); |
|
| 117 | + | assert_eq!(chunks(b"abc"), vec![&b"abc"[..]]); |
|
| 118 | + | let d = random(MIN_SIZE, 1); |
|
| 119 | + | assert_eq!(chunks(&d).len(), 1); |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | #[test] |
|
| 123 | + | fn bounds_and_reassembly() { |
|
| 124 | + | let d = random(3 * 1024 * 1024, 2); |
|
| 125 | + | let cs = chunks(&d); |
|
| 126 | + | assert!(cs.len() > 10, "{}", cs.len()); |
|
| 127 | + | let total: usize = cs.iter().map(|c| c.len()).sum(); |
|
| 128 | + | assert_eq!(total, d.len()); |
|
| 129 | + | assert_eq!(cs.concat(), d); |
|
| 130 | + | for (i, c) in cs.iter().enumerate() { |
|
| 131 | + | assert!(c.len() <= MAX_SIZE); |
|
| 132 | + | if i + 1 < cs.len() { |
|
| 133 | + | assert!(c.len() >= MIN_SIZE, "chunk {i} is {} bytes", c.len()); |
|
| 134 | + | } |
|
| 135 | + | } |
|
| 136 | + | let avg = total / cs.len(); |
|
| 137 | + | assert!(avg > MIN_SIZE && avg < MAX_SIZE, "average {avg}"); |
|
| 138 | + | assert_eq!(chunks(&d), cs, "deterministic"); |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | #[test] |
|
| 142 | + | fn insertion_only_disturbs_local_chunks() { |
|
| 143 | + | let d = random(2 * 1024 * 1024, 3); |
|
| 144 | + | let mut e = d.clone(); |
|
| 145 | + | e.splice(1_000_000..1_000_000, random(100, 4)); |
|
| 146 | + | let a: std::collections::HashSet<[u8; 32]> = chunks(&d).iter().map(|c| *blake3::hash(c).as_bytes()).collect(); |
|
| 147 | + | let b: Vec<[u8; 32]> = chunks(&e).iter().map(|c| *blake3::hash(c).as_bytes()).collect(); |
|
| 148 | + | let shared = b.iter().filter(|h| a.contains(*h)).count(); |
|
| 149 | + | assert!(shared * 10 >= b.len() * 8, "only {shared} of {} chunks shared", b.len()); |
|
| 150 | + | } |
|
| 151 | + | ||
| 152 | + | #[test] |
|
| 153 | + | fn adversarial_constant_input_hits_bounds() { |
|
| 154 | + | let zeros = vec![0u8; 1024 * 1024]; |
|
| 155 | + | let cs = chunks(&zeros); |
|
| 156 | + | for c in &cs[..cs.len() - 1] { |
|
| 157 | + | assert!(c.len() >= MIN_SIZE && c.len() <= MAX_SIZE); |
|
| 158 | + | } |
|
| 159 | + | assert!(cs.len() >= 1024 * 1024 / MAX_SIZE); |
|
| 160 | + | assert!(cs.len() <= 1024 * 1024 / MIN_SIZE + 1); |
|
| 161 | + | } |
|
| 162 | + | } |
modified crates/oxy-core/src/lib.rs +1 −0
| … | 2 unchanged lines |
||
| 3 | 3 | //! See `docs/spec.md` for the specification this implements. |
|
| 4 | 4 | ||
| 5 | 5 | pub mod cbor; |
|
| 6 | + | pub mod chunk; |
|
| 6 | 7 | pub mod diff; |
|
| 7 | 8 | pub mod graph; |
|
| 8 | 9 | pub mod hash; |
|
| … | 7 unchanged lines |
modified crates/oxy-core/src/repo.rs +232 −6
| … | 1 unchanged lines |
||
| 2 | 2 | //! log, and the working-copy change (spec §12, §13.1). |
|
| 3 | 3 | //! |
|
| 4 | 4 | //! Not yet implemented from the spec: signatures and identities (the author |
|
| 5 | - | //! is `ObjectRef::ZERO`), packs, content-defined chunking (blobs use fixed |
|
| 6 | - | //! 256 KiB chunks), checkpoints, multiple workspaces, remotes, and resolving |
|
| 7 | - | //! conflicts by editing markers. |
|
| 5 | + | //! is `ObjectRef::ZERO`), packs, checkpoints, multiple workspaces, remotes, |
|
| 6 | + | //! and resolving conflicts by editing markers. |
|
| 8 | 7 | ||
| 9 | 8 | use crate::diff::{ranges, split_lines, text_edits}; |
|
| 10 | 9 | use crate::hash::{hash_object, ChangeHash, ObjectRef, ObjectType}; |
|
| … | 10 unchanged lines |
||
| 21 | 20 | ||
| 22 | 21 | pub const OXY_DIR: &str = ".oxy"; |
|
| 23 | 22 | pub const FORMAT_LINE: &str = "oxy repository format 1\n"; |
|
| 24 | - | pub const CHUNK_SIZE: usize = 256 * 1024; |
|
| 25 | 23 | const BINARY_SNIFF: usize = 8 * 1024; |
|
| 26 | 24 | ||
| 27 | 25 | #[derive(Debug, thiserror::Error)] |
|
| … | 301 unchanged lines |
||
| 329 | 327 | fn blob_for(content: &[u8]) -> Blob { |
|
| 330 | 328 | Blob { |
|
| 331 | 329 | length: content.len() as u64, |
|
| 332 | - | chunks: content.chunks(CHUNK_SIZE).map(|c| hash_object(ObjectType::Chunk, c)).collect(), |
|
| 330 | + | chunks: crate::chunk::chunks(content).into_iter().map(|c| hash_object(ObjectType::Chunk, c)).collect(), |
|
| 333 | 331 | } |
|
| 334 | 332 | } |
|
| 335 | 333 | ||
| … | 2 unchanged lines |
||
| 338 | 336 | } |
|
| 339 | 337 | ||
| 340 | 338 | pub fn store_blob(&self, content: &[u8]) -> Result<ObjectRef> { |
|
| 341 | - | for c in content.chunks(CHUNK_SIZE) { |
|
| 339 | + | for c in crate::chunk::chunks(content) { |
|
| 342 | 340 | self.store.put_chunk(c)?; |
|
| 343 | 341 | } |
|
| 344 | 342 | Ok(self.store.put(ObjectType::Blob, &Self::blob_for(content).encode())?) |
|
| … | 152 unchanged lines |
||
| 497 | 495 | Ok(hash) |
|
| 498 | 496 | } |
|
| 499 | 497 | ||
| 498 | + | /// Move the working copy to the state whose heads are `heads` (spec §12.3): |
|
| 499 | + | /// the working directory is rewritten to that state and a fresh |
|
| 500 | + | /// working-copy change is started on it. The bookmark does not move. |
|
| 501 | + | pub fn checkout(&self, heads: &[ChangeHash], discard: bool, command: &str) -> Result<CheckoutReport> { |
|
| 502 | + | let snap = self.snapshot(command)?; |
|
| 503 | + | if !discard && (!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()) { |
|
| 504 | + | return fail("the working copy has changes; commit them first or pass --discard"); |
|
| 505 | + | } |
|
| 506 | + | let target_state = self.load_state(heads)?; |
|
| 507 | + | let heads = target_state.maximal(heads); |
|
| 508 | + | let target = target_state.materialize(); |
|
| 509 | + | let current = self.walk_working_copy()?; |
|
| 510 | + | let mut report = CheckoutReport::default(); |
|
| 511 | + | ||
| 512 | + | // Remove tracked paths that the target does not have. |
|
| 513 | + | let mut dirs_to_prune: BTreeSet<PathBuf> = BTreeSet::new(); |
|
| 514 | + | for path in current.keys() { |
|
| 515 | + | if !target.files.contains_key(path) { |
|
| 516 | + | let full = self.safe_path(path)?; |
|
| 517 | + | fs::remove_file(&full)?; |
|
| 518 | + | report.removed += 1; |
|
| 519 | + | if let Some(parent) = full.parent() { |
|
| 520 | + | dirs_to_prune.insert(parent.to_path_buf()); |
|
| 521 | + | } |
|
| 522 | + | } |
|
| 523 | + | } |
|
| 524 | + | // Write target files that differ. |
|
| 525 | + | for (path, f) in &target.files { |
|
| 526 | + | match &f.content { |
|
| 527 | + | FileContent::Dir => continue, |
|
| 528 | + | FileContent::Text { bytes, .. } => { |
|
| 529 | + | let same = current.get(path).is_some_and(|c| c.kind == NodeKind::File && &c.content == bytes && c.exec == f.mode.exec); |
|
| 530 | + | if !same { |
|
| 531 | + | self.write_file(path, bytes, f.mode.exec)?; |
|
| 532 | + | report.written += 1; |
|
| 533 | + | } |
|
| 534 | + | } |
|
| 535 | + | FileContent::Opaque { blobs } => { |
|
| 536 | + | let Some(blob) = blobs.first() else { continue }; |
|
| 537 | + | let bytes = self.read_blob(blob)?; |
|
| 538 | + | if f.kind == NodeKind::Symlink { |
|
| 539 | + | let same = current.get(path).is_some_and(|c| c.kind == NodeKind::Symlink && c.content == bytes); |
|
| 540 | + | if !same { |
|
| 541 | + | self.write_symlink(path, &bytes)?; |
|
| 542 | + | report.written += 1; |
|
| 543 | + | } |
|
| 544 | + | } else { |
|
| 545 | + | let same = current.get(path).is_some_and(|c| c.kind == NodeKind::File && c.content == bytes && c.exec == f.mode.exec); |
|
| 546 | + | if !same { |
|
| 547 | + | self.write_file(path, &bytes, f.mode.exec)?; |
|
| 548 | + | report.written += 1; |
|
| 549 | + | } |
|
| 550 | + | } |
|
| 551 | + | if blobs.len() > 1 { |
|
| 552 | + | for (i, b) in blobs.iter().enumerate().skip(1) { |
|
| 553 | + | self.write_file(&format!("{path}.oxy-conflict-{}", i + 1), &self.read_blob(b)?, f.mode.exec)?; |
|
| 554 | + | } |
|
| 555 | + | } |
|
| 556 | + | } |
|
| 557 | + | } |
|
| 558 | + | } |
|
| 559 | + | // Prune directories left empty, deepest first. |
|
| 560 | + | for d in dirs_to_prune.iter().rev() { |
|
| 561 | + | let mut d = d.clone(); |
|
| 562 | + | while d != self.root && fs::read_dir(&d).map(|mut i| i.next().is_none()).unwrap_or(false) { |
|
| 563 | + | fs::remove_dir(&d)?; |
|
| 564 | + | let Some(p) = d.parent() else { break }; |
|
| 565 | + | d = p.to_path_buf(); |
|
| 566 | + | } |
|
| 567 | + | } |
|
| 568 | + | ||
| 569 | + | let (op_ref, op) = self.head_op()?; |
|
| 570 | + | let wc = self.new_working_copy(heads, ""); |
|
| 571 | + | let wc_hash = self.store.put_change(&wc)?; |
|
| 572 | + | let mut view = op.view.clone(); |
|
| 573 | + | view.workspaces.insert(self.workspace, wc_hash); |
|
| 574 | + | self.record_op(&[op_ref], command, view)?; |
|
| 575 | + | report.conflicted = target.is_conflicted(); |
|
| 576 | + | Ok(report) |
|
| 577 | + | } |
|
| 578 | + | ||
| 579 | + | /// The absolute path for a repository path, refusing anything that would |
|
| 580 | + | /// escape the workspace or write through a symlink (spec §7.7). |
|
| 581 | + | fn safe_path(&self, rel: &str) -> Result<PathBuf> { |
|
| 582 | + | let mut full = self.root.clone(); |
|
| 583 | + | let comps: Vec<&str> = rel.split('/').collect(); |
|
| 584 | + | for (i, comp) in comps.iter().enumerate() { |
|
| 585 | + | if comp.is_empty() || *comp == "." || *comp == ".." || comp.contains('\\') { |
|
| 586 | + | return fail(format!("refusing unsafe path {rel:?}")); |
|
| 587 | + | } |
|
| 588 | + | if i == 0 && *comp == OXY_DIR { |
|
| 589 | + | return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}")); |
|
| 590 | + | } |
|
| 591 | + | if i + 1 < comps.len() { |
|
| 592 | + | full.push(comp); |
|
| 593 | + | if let Ok(m) = fs::symlink_metadata(&full) { |
|
| 594 | + | if m.file_type().is_symlink() { |
|
| 595 | + | return fail(format!("refusing to write through symlink {}", full.display())); |
|
| 596 | + | } |
|
| 597 | + | } |
|
| 598 | + | } else { |
|
| 599 | + | full.push(comp); |
|
| 600 | + | } |
|
| 601 | + | } |
|
| 602 | + | Ok(full) |
|
| 603 | + | } |
|
| 604 | + | ||
| 605 | + | fn write_file(&self, rel: &str, bytes: &[u8], exec: bool) -> Result<()> { |
|
| 606 | + | let full = self.safe_path(rel)?; |
|
| 607 | + | if let Some(parent) = full.parent() { |
|
| 608 | + | fs::create_dir_all(parent)?; |
|
| 609 | + | } |
|
| 610 | + | if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink() || m.is_dir()) { |
|
| 611 | + | remove_any(&full)?; |
|
| 612 | + | } |
|
| 613 | + | fs::write(&full, bytes)?; |
|
| 614 | + | set_executable(&full, exec)?; |
|
| 615 | + | Ok(()) |
|
| 616 | + | } |
|
| 617 | + | ||
| 618 | + | fn write_symlink(&self, rel: &str, target: &[u8]) -> Result<()> { |
|
| 619 | + | let full = self.safe_path(rel)?; |
|
| 620 | + | if let Some(parent) = full.parent() { |
|
| 621 | + | fs::create_dir_all(parent)?; |
|
| 622 | + | } |
|
| 623 | + | if fs::symlink_metadata(&full).is_ok() { |
|
| 624 | + | remove_any(&full)?; |
|
| 625 | + | } |
|
| 626 | + | #[cfg(unix)] |
|
| 627 | + | { |
|
| 628 | + | std::os::unix::fs::symlink(String::from_utf8_lossy(target).as_ref(), &full)?; |
|
| 629 | + | Ok(()) |
|
| 630 | + | } |
|
| 631 | + | #[cfg(not(unix))] |
|
| 632 | + | { |
|
| 633 | + | // Spec §7.7: without symlink support, write the target as a plain file. |
|
| 634 | + | fs::write(&full, target)?; |
|
| 635 | + | Ok(()) |
|
| 636 | + | } |
|
| 637 | + | } |
|
| 638 | + | ||
| 500 | 639 | /// Changes reachable from the bookmark and the working copy, newest first. |
|
| 501 | 640 | pub fn log(&self) -> Result<Vec<LogEntry>> { |
|
| 502 | 641 | let (wc_hash, _) = self.working_copy()?; |
|
| … | 46 unchanged lines |
||
| 549 | 688 | } |
|
| 550 | 689 | } |
|
| 551 | 690 | ||
| 691 | + | #[derive(Debug, Default, Clone, PartialEq, Eq)] |
|
| 692 | + | pub struct CheckoutReport { |
|
| 693 | + | pub written: usize, |
|
| 694 | + | pub removed: usize, |
|
| 695 | + | pub conflicted: bool, |
|
| 696 | + | } |
|
| 697 | + | ||
| 698 | + | fn remove_any(path: &Path) -> std::io::Result<()> { |
|
| 699 | + | let m = fs::symlink_metadata(path)?; |
|
| 700 | + | if m.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) } |
|
| 701 | + | } |
|
| 702 | + | ||
| 552 | 703 | #[cfg(unix)] |
|
| 704 | + | fn set_executable(path: &Path, exec: bool) -> std::io::Result<()> { |
|
| 705 | + | use std::os::unix::fs::PermissionsExt; |
|
| 706 | + | let mut perms = fs::metadata(path)?.permissions(); |
|
| 707 | + | let mode = perms.mode(); |
|
| 708 | + | let want = if exec { mode | 0o111 } else { mode & !0o111 }; |
|
| 709 | + | if want != mode { |
|
| 710 | + | perms.set_mode(want); |
|
| 711 | + | fs::set_permissions(path, perms)?; |
|
| 712 | + | } |
|
| 713 | + | Ok(()) |
|
| 714 | + | } |
|
| 715 | + | #[cfg(not(unix))] |
|
| 716 | + | fn set_executable(_path: &Path, _exec: bool) -> std::io::Result<()> { |
|
| 717 | + | Ok(()) |
|
| 718 | + | } |
|
| 719 | + | ||
| 720 | + | #[cfg(unix)] |
|
| 553 | 721 | fn is_executable(m: &fs::Metadata) -> bool { |
|
| 554 | 722 | use std::os::unix::fs::PermissionsExt; |
|
| 555 | 723 | m.permissions().mode() & 0o111 != 0 |
|
| … | 483 unchanged lines |
||
| 1039 | 1207 | } |
|
| 1040 | 1208 | ||
| 1041 | 1209 | #[test] |
|
| 1210 | + | fn checkout_moves_the_working_copy() { |
|
| 1211 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1212 | + | let root = dir.path(); |
|
| 1213 | + | let repo = Repo::init(root).unwrap(); |
|
| 1214 | + | write(root, "a.txt", b"one\n"); |
|
| 1215 | + | write(root, "sub/deep/b.txt", b"b\n"); |
|
| 1216 | + | let h1 = repo.commit("first", &[]).unwrap(); |
|
| 1217 | + | write(root, "a.txt", b"two\n"); |
|
| 1218 | + | fs::remove_file(root.join("sub/deep/b.txt")).unwrap(); |
|
| 1219 | + | write(root, "bin.dat", &[0, 1, 2, 3]); |
|
| 1220 | + | let h2 = repo.commit("second", &[]).unwrap(); |
|
| 1221 | + | ||
| 1222 | + | let r = repo.checkout(&[h1], false, "oxy checkout").unwrap(); |
|
| 1223 | + | assert_eq!(r, CheckoutReport { written: 2, removed: 1, conflicted: false }); |
|
| 1224 | + | assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n"); |
|
| 1225 | + | assert_eq!(fs::read(root.join("sub/deep/b.txt")).unwrap(), b"b\n"); |
|
| 1226 | + | assert!(!root.join("bin.dat").exists()); |
|
| 1227 | + | let (_, wc) = repo.working_copy().unwrap(); |
|
| 1228 | + | assert_eq!(wc.deps, vec![h1]); |
|
| 1229 | + | assert!(!repo.snapshot("status").unwrap().changed, "checkout leaves a clean working copy"); |
|
| 1230 | + | // The bookmark still points at h2. |
|
| 1231 | + | assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]); |
|
| 1232 | + | ||
| 1233 | + | // Dirty working copy refuses without --discard. |
|
| 1234 | + | write(root, "a.txt", b"dirty\n"); |
|
| 1235 | + | assert!(repo.checkout(&[h2], false, "oxy checkout").is_err()); |
|
| 1236 | + | let r = repo.checkout(&[h2], true, "oxy checkout --discard").unwrap(); |
|
| 1237 | + | assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"two\n"); |
|
| 1238 | + | assert_eq!(fs::read(root.join("bin.dat")).unwrap(), [0, 1, 2, 3]); |
|
| 1239 | + | assert!(!root.join("sub").exists(), "empty directories are pruned"); |
|
| 1240 | + | assert_eq!(r.removed, 1); |
|
| 1241 | + | assert!(!repo.snapshot("status").unwrap().changed); |
|
| 1242 | + | ||
| 1243 | + | // Refuses to write through a symlinked directory. |
|
| 1244 | + | #[cfg(unix)] |
|
| 1245 | + | { |
|
| 1246 | + | fs::remove_dir_all(root.join("sub")).ok(); |
|
| 1247 | + | std::os::unix::fs::symlink("/tmp", root.join("sub")).unwrap(); |
|
| 1248 | + | assert!(repo.checkout(&[h1], true, "oxy checkout").is_err()); |
|
| 1249 | + | } |
|
| 1250 | + | } |
|
| 1251 | + | ||
| 1252 | + | #[test] |
|
| 1253 | + | fn large_files_are_chunked_and_read_back() { |
|
| 1254 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1255 | + | let repo = Repo::init(dir.path()).unwrap(); |
|
| 1256 | + | let mut big = vec![0u8; 700 * 1024]; |
|
| 1257 | + | for (i, b) in big.iter_mut().enumerate() { |
|
| 1258 | + | *b = (i as u32).wrapping_mul(2654435761).to_le_bytes()[0]; |
|
| 1259 | + | } |
|
| 1260 | + | let r = repo.store_blob(&big).unwrap(); |
|
| 1261 | + | let blob = Blob::decode(&repo.store().get_typed(&r, ObjectType::Blob).unwrap()).unwrap(); |
|
| 1262 | + | assert!(blob.chunks.len() >= 3, "{}", blob.chunks.len()); |
|
| 1263 | + | assert_eq!(repo.read_blob(&r).unwrap(), big); |
|
| 1264 | + | assert_eq!(Repo::blob_ref(&big), r); |
|
| 1265 | + | } |
|
| 1266 | + | ||
| 1267 | + | #[test] |
|
| 1042 | 1268 | fn undo_skips_snapshot_operations() { |
|
| 1043 | 1269 | let dir = tempfile::tempdir().unwrap(); |
|
| 1044 | 1270 | let root = dir.path(); |
|
| … | 79 unchanged lines |
modified docs/spec.md +10 −2
| … | 457 unchanged lines |
||
| 458 | 458 | ||
| 459 | 459 | ### 13.2 Chunking and blobs (G5) |
|
| 460 | 460 | ||
| 461 | - | File content is split with FastCDC using a 32 KiB minimum, 64 KiB target, and 256 KiB maximum chunk size, with a 48-byte gear window and normalized chunking level 2. Files of 32 KiB or less are a single chunk. The minimum and maximum bounds guarantee that adversarial content produces at most `len / 32 KiB` chunks and at least `len / 256 KiB` chunks, bounding both index growth and dedup loss. |
|
| 461 | + | File content is split with FastCDC (Xia et al., 2016) using a 32 KiB minimum, 64 KiB target, and 256 KiB maximum chunk size, with normalized chunking level 2 and cut-point skipping from the minimum size. Files of 32 KiB or less are a single chunk, and the final chunk of a file may be shorter than the minimum. The minimum and maximum bounds guarantee that adversarial content produces at most `len / 32 KiB` chunks and at least `len / 256 KiB` chunks, bounding both index growth and dedup loss. |
|
| 462 | + | ||
| 463 | + | So that every implementation produces identical boundaries, the parameters are fixed: |
|
| 464 | + | ||
| 465 | + | - **Gear table.** `GEAR[i]` for `i` in `0..256` is the little-endian `u64` at bytes `8i..8i+8` of the BLAKE3 extendable output of the ASCII string `oxy1 fastcdc gear`. |
|
| 466 | + | - **Rolling hash.** `h = (h << 1) + GEAR[byte]` in 64-bit wrapping arithmetic, starting from zero at the minimum size. |
|
| 467 | + | - **Masks.** A mask with `k` one-bits has them at bit positions `63 - floor(i * 64 / k)` for `i` in `0..k`. The small mask has 18 one-bits (used until the target size) and the large mask has 14 (used until the maximum). A cut occurs at the first position where `h & mask == 0`. |
|
| 462 | 468 | ||
| 463 | 469 | ``` |
|
| 464 | 470 | Blob { version [0]: 1, length [1]: u64, chunks [2]: [ObjectRef] } |
|
| … | 304 unchanged lines |
||
| 769 | 775 | ||
| 770 | 776 | --- |
|
| 771 | 777 | ||
| 772 | - | *Changelog.* Draft 0.4 (2026-09-26): overview no longer describes the constrained profile by word size; HLC drift bound replaces timestamp-based rejection; node kinds reduced to file, dir, symlink with a separate content mode and defined mode switching; symlinks always opaque; checkout safety rules (§7.7); workspace conflicts (§9.7) replace the case-folding tree conflict and cover normalization, reserved names, and path length; operation log entries suppressed for empty snapshots. |
|
| 778 | + | *Changelog.* Draft 0.4a (2026-09-26): §13.2 pins the FastCDC gear table, rolling hash, and masks so boundaries are identical across implementations. |
|
| 779 | + | ||
| 780 | + | Draft 0.4 (2026-09-26): overview no longer describes the constrained profile by word size; HLC drift bound replaces timestamp-based rejection; node kinds reduced to file, dir, symlink with a separate content mode and defined mode switching; symlinks always opaque; checkout safety rules (§7.7); workspace conflicts (§9.7) replace the case-folding tree conflict and cover normalization, reserved names, and path length; operation log entries suppressed for empty snapshots. |
|
| 773 | 781 | ||
| 774 | 782 | Draft 0.3 (2026-09-26): recursive directory moves with cycle handling; explicit semantic dependencies with the `semantic-deps` hook; seals redefined as push-time batch signatures carrying their hash list, with an author-or-delegate authentication rule and `delegates` on identities; `core.sign` setting; fetch, push, policy, and garbage collection updated for seals. |
|
| 775 | 783 | ||
| … | 1 unchanged lines |
modified src/main.rs +22 −0
| … | 31 unchanged lines |
||
| 32 | 32 | Diff, |
|
| 33 | 33 | /// Show one change (ChangeId prefix, #hash prefix, or @) |
|
| 34 | 34 | Show { change: String }, |
|
| 35 | + | /// Move the working copy to the state formed by CHANGE... (default: the bookmark heads) |
|
| 36 | + | Checkout { |
|
| 37 | + | changes: Vec<String>, |
|
| 38 | + | /// Throw away uncommitted working-copy changes |
|
| 39 | + | #[arg(long)] |
|
| 40 | + | discard: bool, |
|
| 41 | + | }, |
|
| 35 | 42 | /// Undo the last operation |
|
| 36 | 43 | Undo, |
|
| 37 | 44 | /// List operations |
|
| … | 150 unchanged lines |
||
| 188 | 195 | print!("{}", repo.describe_change(&h)?); |
|
| 189 | 196 | Ok(0) |
|
| 190 | 197 | } |
|
| 198 | + | Cmd::Checkout { changes, discard } => { |
|
| 199 | + | let repo = open()?; |
|
| 200 | + | let heads = if changes.is_empty() { |
|
| 201 | + | repo.bookmark_heads()? |
|
| 202 | + | } else { |
|
| 203 | + | changes.iter().map(|c| repo.resolve_change(c)).collect::<Result<Vec<_>, _>>()? |
|
| 204 | + | }; |
|
| 205 | + | let spec = if changes.is_empty() { repo.config.bookmark.clone() } else { changes.join(" ") }; |
|
| 206 | + | let r = repo.checkout(&heads, discard, &format!("oxy checkout {spec}"))?; |
|
| 207 | + | println!("Working copy now on {spec}: {} file(s) written, {} removed", r.written, r.removed); |
|
| 208 | + | if r.conflicted { |
|
| 209 | + | println!("This state has conflicts; see oxy status."); |
|
| 210 | + | } |
|
| 211 | + | Ok(0) |
|
| 212 | + | } |
|
| 191 | 213 | Cmd::Undo => { |
|
| 192 | 214 | let repo = open()?; |
|
| 193 | 215 | let op = repo.undo()?; |
|
| … | 20 unchanged lines |