OXYGENHUB

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