| spec | SPEC-FASTCDC |
|---|---|
| version | 1 |
| status | stable |
| audience | implementers of compatible chunkers; anyone reproducing chunked_blob hashes across producers |
Status: Normative for mkit v1 (content-defined chunking of large files). Scope: gear table derivation, chunking parameters, and the determinism contract that makes chunked_blob hashes stable across producers.
Resolves red-team R-46 (gear-seed determinism gap).
For files larger than a fixed threshold, mkit uses FastCDC (Fast
Content-Defined Chunking) to cut the file at content-defined
boundaries, store each chunk as its own blob, and reference them from
a chunked_blob object (SPEC-OBJECTS §7).
This lets edits near the end of a large file reuse most of the prior version's chunks, which is critical for dedup across pack transfers (see SPEC-PACKFILE §3.2 delta vs. SPEC-OBJECTS §7 chunked dedup — these are complementary layers).
Chunk boundaries, and therefore chunked_blob hashes, are fully
determined by:
- The input file bytes.
- The gear table seed (§3).
- The chunking parameters
(min_size, avg_size, max_size)(§4). - The mask derivation (§5).
Any change to any of (2), (3), or (4) breaks chunk boundary reproducibility. The chunk hashes change, and blob-level dedup across the change boundary fails for every large file.
This is why (2) through (4) are specified as normative constants in v1 and can only be changed through a format-version bump.
The gear table is 256 u64 entries, derived from a splitmix64 initial
state seeded with an 8-byte ASCII literal. mkit does not use the
gear table from the FastCDC paper (Xia et al., USENIX ATC '16); the
table is mkit-specific and produced by the procedure below.
state = 0x4D4B495446434443 # ASCII "MKITFCDC" interpreted as big-endian u64
for each of 256 entries:
state = state +% 0x9e3779b97f4a7c15 # wrapping add (splitmix64 gamma)
z = state
z = (z ^ (z >> 30)) *% 0xbf58476d1ce4e5b9 # wrapping mul
z = (z ^ (z >> 27)) *% 0x94d049bb133111eb
z = z ^ (z >> 31)
table[i] = z
The reference Rust implementation lives in
mkit-core::chunker::build_gear_table and uses a std::sync::OnceLock
to memoise the 2 KiB table on first use. The exported constant SEED
is the same 0x4D4B_4954_4643_4443 value.
The seed ASCII is "MKITFCDC", frozen for the lifetime of v1.
The seed is not user-visible; it is 8 bytes inside the chunker.
Future format versions that want to change chunking MUST bump the format version and publish a migration path. A future reader that needs to chunk for a different chunk-family MAY use a different seed and namespace the result accordingly, but v1 does NOT provide a mechanism for this.
Hard-coded defaults (applied whenever the worktree takes the chunked path):
min_size = 16 * 1024 = 16 KiB
avg_size = 64 * 1024 = 64 KiB
max_size = 256 * 1024 = 256 KiB
Normative constraint: avg_size MUST be a power of two (the mask
derivation in §5 uses log2(avg_size)); min_size < avg_size < max_size MUST hold strictly. mkit's v1 constants satisfy "all three
are powers of two", but the format only requires this of avg_size.
Files of size ≤ min_size are NOT chunked — cut returns the full
input length, so the caller stores the file as a single blob. The
threshold for taking the chunked path is an implementer choice; in
mkit-core the worktree picks CHUNK_THRESHOLD = 1 MiB
(worktree::CHUNK_THRESHOLD). v1 specifies only that once a file
is chunked, the parameters above MUST be used.
Implementation status: end-to-end wiring is in place across the hashing, staging, commit, and read-back paths (issue #203).
A single helper, worktree::store_file_object, is the one place that
decides a regular file's object representation: at or below
CHUNK_THRESHOLD it writes a single Blob; above it, it splits with
FastCdc::v1, stores each chunk as its own Blob, and writes a
ChunkedBlob manifest (with chunk_size = 0 to denote content-defined
chunking) whose hash lands in the parent tree. Every caller routes
through it, so a given file produces the same object/hash regardless of
the path that computes it:
worktree::{hash_file, build_tree}— diff, status, tree, and therm/checkoutdirty-checks;mkit add(add_one/stage_tracked_changes) — staging into the index;worktree::build_tree_from_index— the commit/index tree build now accepts aChunkedBlobunder a regular-file (Blob/Executable) entry, so committing a > 1 MiB file no longer fails with a "non-blob object" error.
Read-back is symmetric: worktree::read_blob reassembles a Blob or
ChunkedBlob into the full byte stream, and backs mkit cat,
mkit diff, and worktree restore (mkit checkout). mkit cat on a
ChunkedBlob hash streams the reassembled content rather than an object
placeholder.
The byte-level chunk boundaries are pinned by the goldens in §8; the
worktree round-trip is covered by
worktree::tests::large_file_becomes_chunked_blob, the index-path
acceptance by worktree::tests::from_index_accepts_chunked_blob_for_file_entry,
and the full add → commit → status/diff/rm → checkout → cat round-trip
by the chunked_blob_roundtrip unit test in mkit-core::serialize.
Mask derivation:
bits = log2(avg_size)
mask = (1 << bits) - 1
mask_s = mask | (mask << 1) # stricter: fewer boundaries
mask_l = mask >> 1 # looser: more boundaries
For avg_size = 64 KiB = 2^16, bits = 16:
mask = 0x0000_FFFF
mask_s = 0x0001_FFFF
mask_l = 0x0000_7FFF
Cut algorithm (normative):
cut(data) -> length:
if len(data) <= min_size: return len(data)
hash = 0
# Strict region: min_size .. avg_size
for i in min_size .. min(avg_size, len(data)):
hash = (hash << 1) +% gear_table[data[i]]
if (hash & mask_s) == 0: return i
# Loose region: avg_size .. max_size
for i in avg_size .. min(max_size, len(data)):
hash = (hash << 1) +% gear_table[data[i]]
if (hash & mask_l) == 0: return i
# Forced cut at max_size
return min(max_size, len(data))
The result length is the number of bytes in the first chunk. The
caller consumes those bytes, then calls cut again on the remainder.
The iteration continues until all bytes are consumed.
See SPEC-OBJECTS §7 for the container. Key points for chunking:
total_size= sum of all chunk byte lengths = file size.chunk_size= 0 (not the file chunk size; 0 is the sentinel for content-defined chunking).chunks= ordered list of chunk BLAKE3 hashes.- Each chunk is itself stored as a
blobobject (SPEC-OBJECTS §3) under its content hash.
A reader reassembles the file by concatenating chunks[0].data || chunks[1].data || ... || chunks[n-1].data and verifying the length
equals total_size.
Any two conformant implementations chunking the same file with the same (seed, parameters, mask) MUST produce:
- The same number of chunks.
- The same chunk boundaries (byte offsets).
- The same chunk hashes.
- The same
chunked_blobobject hash.
Conformance tests SHOULD include at least one 10 MiB pseudo-random file and one 10 MiB highly-repetitive file, asserting byte-identical chunk lists across platforms.
The vectors below are exercised by
rust/crates/mkit-core/tests/golden_pack.rs (boundaries on disk under
rust/tests/golden/fastcdc/) plus the unit tests in
rust/crates/mkit-core/src/chunker.rs::tests.
- Gear table hash:
gear_table_digest()returnsBLAKE3of the 256u64entries little-endian-encoded (2 048 bytes total). Any implementation that derives the same table for seed"MKITFCDC"will produce the same digest. Stability is asserted bygear_table_digest_is_stable; the table is also checked to be all-unique and non-zero bygear_table_is_unique_and_nonzero. - Small file fits in single chunk: short input →
cutreturnsdata.len()immediately. Nochunked_blobproduced (small_input_is_single_chunk). min_size-or-smaller is single chunk:data.len() <= min_size→cutreturnsdata.len()(cut_at_exactly_min_size_returns_full).- Pseudo-random 1 MiB file, splitmix64 stream from seed
0xA5A5_F00D_DEAD_BEEF: chunk boundary offsets are pinned inrust/tests/golden/fastcdc/fastcdc_boundaries_1mib.bin(fastcdc_boundaries_1mib_match_golden). - Pseudo-random 256 KiB file, splitmix64 stream from seed
0xCAFE_BABE_1234_5678: pinned inrust/tests/golden/fastcdc/fastcdc_boundaries_256k.bin(fastcdc_boundaries_256k_match_golden). - Repetitive buffer: all-zero data with a tiny chunker exercises
the forced-cut-at-
max_sizepath (cut_forces_boundary_at_max_size). - Boundary stability under a 1-byte insert: insert one byte at
32 KiB into a 64 KiB pseudo-random file; ≤ 3 chunks differ
(
boundary_stability_single_byte_insert). The shifted-chunks identity test is subsumed by this regression. - Iterator total covers the input: chunk lengths sum to the
input length (
fastcdc_iterator_total_equals_input_length). - Different
avg_sizeyields different boundaries: a chunker with(8K, 32K, 128K)does not produce the v1 boundaries (different_avg_size_yields_different_boundaries). - Seed misuse detector: if an implementation is accidentally
built with any seed other than
"MKITFCDC", the gear-table-hash pin and the boundary goldens both fail loudly at test time.
- Per-file-type tuning (for example, larger
avg_sizefor media files) is out of scope. - A sliding-window gear hash implementation variant would produce different boundaries; not a v1 alternative.
| Invariant | Enforced by |
|---|---|
Chunk boundaries — and therefore chunked_blob hashes — are a pure function of the input bytes |
frozen seed (§3), parameters (§4), and mask derivation (§5); any change requires a format-version bump (§2) |
| Every implementation derives the identical gear table | the "MKITFCDC" + splitmix64 procedure (§3); gear-table digest pin and uniqueness/non-zero checks (§8.1) |
avg_size is a power of two; min_size < avg_size < max_size holds strictly |
normative parameter constraint (§4) |
Input of ≤ min_size bytes is a single chunk, never a chunked_blob |
cut returns the full input length (§4, §5) |
No chunk exceeds max_size |
forced cut at max_size (§5) |
Chunk lengths sum to the input length; total_size equals the file size |
iterative consumption until all bytes are cut (§5); reassembly length check against total_size (§6); pinned by §8 vector 8 |
| Content-defined manifests are distinguishable from fixed-size ones | chunk_size = 0 sentinel (§6; SPEC-OBJECTS §7) |
| Any two conformant chunkers agree on chunk count, byte offsets, chunk hashes, and manifest hash | cross-implementation contract (§7); boundary goldens (§8 vectors 4–5) |
| A build with the wrong seed or parameters cannot ship silently | gear-table-hash pin and boundary goldens fail loudly at test time (§8 vectors 9–10) |