OXYGENHUB

Hardening: CBOR and pack corruption tests, index bounds validation, concurrent HTTP push test, 1 GiB benchmark (spec 0.6q) 2b872d074ddb

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 23:57 UTC
Signature
✔ signed by Andy McCall
Hash
2b872d074ddb4dd751e6a78161dac263dd6f5af7ec1d206bb288f084bedcf494
Depends on
2b5ee371060e 30ea9c1e70fa 750251b21a96 96f7610fa666 982864068a59 d02e545dc265 ff0af1ee299d
Depended on by
767ac6cd0545 648189388f1a da652d6392d8 a9f686edf60b 75167672ebcd e02bf0cd0533 b7f102fcbb17 8f10a07a1dbe
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
16 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/oxy-core/src/cbor.rs +97 −0
…
318 unchanged lines
319 319
        assert_eq!(decode(&[0x5b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), Err(CborError::LengthOverflow));
320 320
        assert!(decode(&[0x9b, 0x00, 0x00, 0x00, 0x01, 0, 0, 0, 0]).is_err());
321 321
    }
322 +
323 +
    fn xorshift(state: &mut u64) -> u64 {
324 +
        *state ^= *state << 13;
325 +
        *state ^= *state >> 7;
326 +
        *state ^= *state << 17;
327 +
        *state
328 +
    }
329 +
330 +
    fn random_value(seed: &mut u64, depth: u32) -> Value {
331 +
        match xorshift(seed) % if depth > 4 { 5 } else { 7 } {
332 +
            0 => Value::Uint(match xorshift(seed) % 4 {
333 +
                0 => xorshift(seed) % 24,
334 +
                1 => xorshift(seed) % 256,
335 +
                2 => xorshift(seed) % 65536,
336 +
                _ => xorshift(seed),
337 +
            }),
338 +
            1 => Value::Bool(xorshift(seed).is_multiple_of(2)),
339 +
            2 => Value::Null,
340 +
            3 => {
341 +
                let n = (xorshift(seed) % 40) as usize;
342 +
                Value::Bytes((0..n).map(|_| (xorshift(seed) & 0xff) as u8).collect())
343 +
            }
344 +
            4 => {
345 +
                let n = (xorshift(seed) % 12) as usize;
346 +
                Value::Text((0..n).map(|_| char::from_u32((xorshift(seed) % 0x24f) as u32 + 1).unwrap_or('x')).collect())
347 +
            }
348 +
            5 => {
349 +
                let n = (xorshift(seed) % 5) as usize;
350 +
                Value::Array((0..n).map(|_| random_value(seed, depth + 1)).collect())
351 +
            }
352 +
            _ => {
353 +
                let n = (xorshift(seed) % 5) as usize;
354 +
                let mut m = std::collections::BTreeMap::new();
355 +
                for _ in 0..n {
356 +
                    m.insert(xorshift(seed) % 300, random_value(seed, depth + 1));
357 +
                }
358 +
                Value::Map(m)
359 +
            }
360 +
        }
361 +
    }
362 +
363 +
    /// Random values round-trip and re-encode identically (canonical form).
364 +
    #[test]
365 +
    fn random_values_round_trip() {
366 +
        let mut seed = 0x2545f4914f6cdd1du64;
367 +
        for _ in 0..2000 {
368 +
            let v = random_value(&mut seed, 0);
369 +
            let b = encode(&v);
370 +
            let back = decode(&b).unwrap();
371 +
            assert_eq!(back, v);
372 +
            assert_eq!(encode(&back), b);
373 +
        }
374 +
    }
375 +
376 +
    /// Random bytes and mutated encodings never panic; whatever decodes is
377 +
    /// canonical, so re-encoding it reproduces the input exactly.
378 +
    #[test]
379 +
    fn random_and_mutated_input_never_panics() {
380 +
        let mut seed = 0xdeadbeefcafef00du64;
381 +
        for _ in 0..3000 {
382 +
            let n = (xorshift(&mut seed) % 64) as usize;
383 +
            let bytes: Vec<u8> = (0..n).map(|_| (xorshift(&mut seed) & 0xff) as u8).collect();
384 +
            if let Ok(v) = decode(&bytes) {
385 +
                assert_eq!(encode(&v), bytes, "accepted input must be canonical: {bytes:02x?}");
386 +
            }
387 +
        }
388 +
        for _ in 0..3000 {
389 +
            let v = random_value(&mut seed, 0);
390 +
            let mut b = encode(&v);
391 +
            if b.is_empty() {
392 +
                continue;
393 +
            }
394 +
            match xorshift(&mut seed) % 3 {
395 +
                0 => {
396 +
                    let at = (xorshift(&mut seed) as usize) % b.len();
397 +
                    b[at] ^= 1 << (xorshift(&mut seed) % 8);
398 +
                }
399 +
                1 => {
400 +
                    let keep = (xorshift(&mut seed) as usize) % b.len();
401 +
                    b.truncate(keep);
402 +
                }
403 +
                _ => {
404 +
                    let at = (xorshift(&mut seed) as usize) % b.len();
405 +
                    b.insert(at, (xorshift(&mut seed) & 0xff) as u8);
406 +
                }
407 +
            }
408 +
            if let Ok(v2) = decode(&b) {
409 +
                assert_eq!(encode(&v2), b, "accepted mutation must be canonical");
410 +
            }
411 +
        }
412 +
        // Deep nesting is refused rather than overflowing the stack.
413 +
        let mut deep = vec![0x81u8; 100_000];
414 +
        deep.push(0x00);
415 +
        assert!(matches!(decode(&deep), Err(CborError::Unsupported(_))));
416 +
        // Lengths beyond the input are refused without allocating them.
417 +
        assert!(matches!(decode(&[0x5b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), Err(CborError::LengthOverflow) | Err(CborError::UnexpectedEnd)));
418 +
    }
322 419
}
modified crates/oxy-core/src/repo/tests.rs +65 −0
…
1866 unchanged lines
1867 1867
    assert_eq!(git_in(&work, &["log", "--format=%s", "main"]).lines().count(), 5);
1868 1868
}
1869 1869
1870 +
/// Benchmark, not a gate: `cargo test --release -p oxy-core --lib bench_one_gibibyte -- --ignored --nocapture`.
1871 +
/// Commits, stores, clones, and reads back a 1 GiB incompressible file.
1872 +
#[test]
1873 +
#[ignore]
1874 +
fn bench_one_gibibyte_file() {
1875 +
    hermetic();
1876 +
    // /tmp is often a small tmpfs; `OXY_BENCH_DIR` picks a roomier disk.
1877 +
    let dir = match std::env::var_os("OXY_BENCH_DIR") {
1878 +
        Some(d) => tempfile::tempdir_in(d).unwrap(),
1879 +
        None => tempfile::tempdir().unwrap(),
1880 +
    };
1881 +
    let root = dir.path();
1882 +
    let repo = Repo::init(&root.join("r")).unwrap().with_home(root.join("home"));
1883 +
    let size = 1usize << 30;
1884 +
    let mut data = vec![0u8; size];
1885 +
    let mut x = 0x9e3779b97f4a7c15u64;
1886 +
    for chunk in data.chunks_mut(8) {
1887 +
        x ^= x << 13;
1888 +
        x ^= x >> 7;
1889 +
        x ^= x << 17;
1890 +
        let b = x.to_le_bytes();
1891 +
        chunk.copy_from_slice(&b[..chunk.len()]);
1892 +
    }
1893 +
    let t = std::time::Instant::now();
1894 +
    write(&root.join("r"), "big.bin", &data);
1895 +
    eprintln!("write 1 GiB to disk: {:.2?}", t.elapsed());
1896 +
    let t = std::time::Instant::now();
1897 +
    let h = repo.commit("big", &[]).unwrap();
1898 +
    eprintln!("commit (chunk + hash + store): {:.2?}", t.elapsed());
1899 +
    let t = std::time::Instant::now();
1900 +
    assert!(!repo.snapshot("status").unwrap().changed);
1901 +
    eprintln!("status on a clean 1 GiB working copy: {:.2?}", t.elapsed());
1902 +
    let t = std::time::Instant::now();
1903 +
    let objects = repo.store().list().unwrap().len();
1904 +
    eprintln!("objects stored: {objects} ({:.2?} to list)", t.elapsed());
1905 +
    let t = std::time::Instant::now();
1906 +
    let g = repo.gc(true).unwrap();
1907 +
    eprintln!("gc (pack): {:.2?} {g:?}", t.elapsed());
1908 +
    let t = std::time::Instant::now();
1909 +
    let clone = Repo::clone(&root.join("r").to_string_lossy(), &root.join("c")).unwrap();
1910 +
    eprintln!("clone (fetch + checkout): {:.2?}", t.elapsed());
1911 +
    let t = std::time::Instant::now();
1912 +
    let back = fs::read(root.join("c/big.bin")).unwrap();
1913 +
    assert_eq!(blake3::hash(&back), blake3::hash(&data));
1914 +
    eprintln!("read back and verify: {:.2?}", t.elapsed());
1915 +
    assert_eq!(clone.bookmark_heads().unwrap(), vec![h]);
1916 +
    let store_bytes: u64 = walkdir_size(&root.join("r/.oxy/objects"));
1917 +
    eprintln!("store size: {:.1} MiB for 1 GiB of data", store_bytes as f64 / (1024.0 * 1024.0));
1918 +
}
1919 +
1920 +
fn walkdir_size(p: &Path) -> u64 {
1921 +
    let mut total = 0;
1922 +
    if let Ok(rd) = fs::read_dir(p) {
1923 +
        for e in rd.flatten() {
1924 +
            let path = e.path();
1925 +
            if path.is_dir() {
1926 +
                total += walkdir_size(&path);
1927 +
            } else if let Ok(m) = fs::metadata(&path) {
1928 +
                total += m.len();
1929 +
            }
1930 +
        }
1931 +
    }
1932 +
    total
1933 +
}
1934 +
modified crates/oxy-core/src/store.rs +87 −2
…
22 unchanged lines
23 23
const IDX_ENTRY: usize = 32 + 8 + 4 + 4 + 1 + 1;
24 24
const FLAG_ZSTD: u8 = 1;
25 25
const ZSTD_LEVEL: i32 = 3;
26 +
/// Largest object a pack entry may claim; anything bigger is corruption,
27 +
/// and refusing it keeps a bad index from driving huge allocations.
28 +
const MAX_OBJECT_LEN: u32 = 1 << 30;
26 29
27 30
#[derive(Clone, Copy, Debug)]
28 31
struct PackEntry {
…
23 unchanged lines
52 55
        if data.len() != 12 + count * IDX_ENTRY {
53 56
            return Err(bad("index length mismatch"));
54 57
        }
58 +
        let pack_len = fs::metadata(pack_path)?.len();
55 59
        let mut refs = Vec::with_capacity(count);
56 60
        let mut entries = Vec::with_capacity(count);
57 61
        for i in 0..count {
…
5 unchanged lines
63 67
                }
64 68
            }
65 69
            refs.push(r);
66 -
            entries.push(PackEntry {
70 +
            let entry = PackEntry {
67 71
                offset: u64::from_le_bytes(e[32..40].try_into().unwrap()),
68 72
                stored_len: u32::from_le_bytes(e[40..44].try_into().unwrap()),
69 73
                raw_len: u32::from_le_bytes(e[44..48].try_into().unwrap()),
70 74
                ty: ObjectType::ALL.get(e[48] as usize).copied().ok_or_else(|| bad("unknown object type"))?,
71 75
                flags: e[49],
72 -
            });
76 +
            };
77 +
            if entry.stored_len > MAX_OBJECT_LEN || entry.raw_len > MAX_OBJECT_LEN || entry.offset.saturating_add(entry.stored_len as u64) > pack_len {
78 +
                return Err(bad("index entry points outside the pack"));
79 +
            }
80 +
            entries.push(entry);
73 81
        }
74 82
        Ok(PackIndex { path: pack_path.to_path_buf(), refs, entries })
75 83
    }
…
671 unchanged lines
747 755
        assert_eq!(r, c.hash());
748 756
        assert_eq!(s.get_change(&r).unwrap(), c);
749 757
    }
758 +
759 +
    fn xorshift(state: &mut u64) -> u64 {
760 +
        *state ^= *state << 13;
761 +
        *state ^= *state >> 7;
762 +
        *state ^= *state << 17;
763 +
        *state
764 +
    }
765 +
766 +
    /// Random corruption of pack and index files never panics: reads fail
767 +
    /// with an error or the store refuses to open, and untouched objects
768 +
    /// stay readable when the index survives.
769 +
    #[test]
770 +
    fn corrupted_packs_and_indexes_fail_cleanly() {
771 +
        let dir = tempfile::tempdir().unwrap();
772 +
        let objects = dir.path().join("objects");
773 +
        let s = Store::open(&objects).unwrap();
774 +
        let mut refs = Vec::new();
775 +
        let mut seed = 0x9e3779b97f4a7c15u64;
776 +
        for i in 0..40u32 {
777 +
            let len = (xorshift(&mut seed) % 5000) as usize;
778 +
            let mut body = vec![0u8; len];
779 +
            for b in body.iter_mut() {
780 +
                *b = if i % 2 == 0 { (xorshift(&mut seed) & 0xff) as u8 } else { b'a' + (i % 26) as u8 };
781 +
            }
782 +
            refs.push(s.put(ObjectType::Chunk, &body).unwrap());
783 +
        }
784 +
        s.pack_loose().unwrap();
785 +
        let pack = fs::read_dir(&objects).unwrap().filter_map(|e| e.ok().map(|e| e.path())).find(|p| p.extension().is_some_and(|x| x == "pack")).unwrap();
786 +
        let idx = pack.with_extension("idx");
787 +
        let pristine_pack = fs::read(&pack).unwrap();
788 +
        let pristine_idx = fs::read(&idx).unwrap();
789 +
790 +
        for round in 0..300u32 {
791 +
            let (path, pristine) = if round % 2 == 0 { (&pack, &pristine_pack) } else { (&idx, &pristine_idx) };
792 +
            let mut data = pristine.clone();
793 +
            match xorshift(&mut seed) % 4 {
794 +
                0 => {
795 +
                    let at = (xorshift(&mut seed) as usize) % data.len();
796 +
                    data[at] ^= 1 << (xorshift(&mut seed) % 8);
797 +
                }
798 +
                1 => {
799 +
                    let at = (xorshift(&mut seed) as usize) % data.len();
800 +
                    data[at] = (xorshift(&mut seed) & 0xff) as u8;
801 +
                }
802 +
                2 => {
803 +
                    let keep = (xorshift(&mut seed) as usize) % data.len();
804 +
                    data.truncate(keep);
805 +
                }
806 +
                _ => {
807 +
                    let at = (xorshift(&mut seed) as usize) % data.len();
808 +
                    let n = ((xorshift(&mut seed) % 8) + 1) as usize;
809 +
                    for k in 0..n.min(data.len() - at) {
810 +
                        data[at + k] = 0xff;
811 +
                    }
812 +
                }
813 +
            }
814 +
            fs::write(path, &data).unwrap();
815 +
            // Opening may refuse a broken index; a store that opens must
816 +
            // either return each object intact or report it corrupt/missing.
817 +
            if let Ok(s2) = Store::open(&objects) {
818 +
                for r in &refs {
819 +
                    match s2.get(r) {
820 +
                        Ok((ty, body)) => assert_eq!(hash_object(ty, &body), *r, "a returned object always matches its reference"),
821 +
                        Err(StoreError::Corrupt(..)) | Err(StoreError::NotFound(_)) | Err(StoreError::Io(_)) => {}
822 +
                        Err(e) => panic!("unexpected error {e}"),
823 +
                    }
824 +
                }
825 +
            }
826 +
            fs::write(path, pristine).unwrap();
827 +
        }
828 +
        // Restored files read back in full.
829 +
        let s3 = Store::open(&objects).unwrap();
830 +
        for r in &refs {
831 +
            let (ty, body) = s3.get(r).unwrap();
832 +
            assert_eq!(hash_object(ty, &body), *r);
833 +
        }
834 +
    }
750 835
}
modified crates/oxy-server/tests/http.rs +67 −0
…
168 unchanged lines
169 169
    a.save_config().unwrap();
170 170
    assert!(a.fetch("origin").is_err());
171 171
}
172 +
173 +
/// Eight clones push independent changes at the same moment. The server
174 +
/// serialises pushes per repository, so every push either lands or is
175 +
/// refused because the bookmark moved, and a pull-then-push retry lands it.
176 +
#[test]
177 +
fn concurrent_pushes_are_serialised() {
178 +
    hermetic();
179 +
    let dir = tempfile::tempdir().unwrap();
180 +
    let root = dir.path();
181 +
    let served = root.join("served");
182 +
    Repo::init(&served.join("origin")).unwrap();
183 +
    let base = start(&served, vec![]);
184 +
    let url = format!("{base}/origin");
185 +
    const N: usize = 8;
186 +
    let mut clones = Vec::new();
187 +
    for i in 0..N {
188 +
        let path = root.join(format!("c{i}"));
189 +
        Repo::clone(&url, &path).unwrap();
190 +
        clones.push(path);
191 +
    }
192 +
    let barrier = std::sync::Arc::new(std::sync::Barrier::new(N));
193 +
    let mut handles = Vec::new();
194 +
    for (i, path) in clones.into_iter().enumerate() {
195 +
        let barrier = barrier.clone();
196 +
        handles.push(std::thread::spawn(move || {
197 +
            let repo = Repo::open(&path).unwrap();
198 +
            write(&path, &format!("file-{i}.txt"), format!("from clone {i}\n").as_bytes());
199 +
            let h = repo.commit(&format!("change {i}"), &[]).unwrap();
200 +
            barrier.wait();
201 +
            // A push is refused either up front (the remote moved since the
202 +
            // clone's last fetch) or by the server's compare-and-swap; both
203 +
            // are answered by pulling and pushing again.
204 +
            for attempt in 0..(N * 4) {
205 +
                match repo.push("origin", false) {
206 +
                    Ok(r) if r.iter().all(|x| x.ok) => return (h, attempt),
207 +
                    Ok(_) => {}
208 +
                    Err(e) if e.to_string().contains("pull first") || e.to_string().contains("moved") => {}
209 +
                    Err(e) => panic!("clone {i}: {e}"),
210 +
                }
211 +
                repo.pull("origin").unwrap_or_else(|e| panic!("clone {i} pull: {e}"));
212 +
            }
213 +
            panic!("clone {i} never managed to push");
214 +
        }));
215 +
    }
216 +
    let mut hashes = Vec::new();
217 +
    let mut total_rejected = 0;
218 +
    for h in handles {
219 +
        let (hash, attempts) = h.join().unwrap();
220 +
        hashes.push(hash);
221 +
        total_rejected += attempts;
222 +
    }
223 +
    let origin = Repo::open(&served.join("origin")).unwrap();
224 +
    let mut heads = origin.bookmark_heads().unwrap();
225 +
    heads.sort();
226 +
    hashes.sort();
227 +
    assert_eq!(heads, hashes, "every independent change is a head");
228 +
    let snap = origin.load_state(&heads).unwrap().materialize();
229 +
    for i in 0..N {
230 +
        assert!(snap.files.contains_key(&format!("file-{i}.txt")));
231 +
    }
232 +
    assert!(!snap.is_conflicted());
233 +
    eprintln!("{total_rejected} pushes were refused for a moved bookmark and retried");
234 +
    // The operation log is a single chain: each accepted push builds on the last.
235 +
    let ops = origin.op_log().unwrap();
236 +
    assert!(ops.len() > N);
237 +
}
238 +
modified docs/plan.md +4 −1
…
29 unchanged lines
30 30
- [x] Identity lifecycle: `oxy identity rotate|revoke`, expiry, delegates (`oxy identity delegate add|remove`), multi-signer policy updates. (Done: rotation as supersession with chain-aware policy authorization, renew, delegates, revocation attestations and local markers, expiry checks, root add/remove/threshold, proposal files and `oxy policy sign`; spec 0.6n.)
31 31
- [x] Push and serve the chain of policies since the receiver's serial, so several offline rotations or updates publish in one push or fetch. (Done in iteration 12: `.oxy/policies/` index, `refs` history, bundle policies.)
32 32
- [x] Rename detection on Git import (similarity ≥ 50%); `oxy git fetch`/`push` against a Git remote via a mirror. (Done: detection lives in the change builder, so working-copy renames are tracked too; fetch/push through `.oxy/git/<key>/mirror.git` with fast-forward chaining; spec 0.6p.)
33 -
- [ ] Hardening: property tests for the CBOR decoder and pack reader against random and mutated inputs; a concurrent-push test against the server; a 1 GB single-file benchmark for chunking and blobs.
33 +
- [x] Hardening: property tests for the CBOR decoder and pack reader against random and mutated inputs; a concurrent-push test against the server; a 1 GB single-file benchmark for chunking and blobs. (Done: random/mutated CBOR never panics and accepted input is canonical; 300 rounds of pack/index corruption fail cleanly, with new index bounds checks; 8 concurrent HTTP pushes serialise with pull-and-retry; `bench_one_gibibyte_file` is an ignored release-mode test, run with `OXY_BENCH_DIR` on a real disk.)
34 +
- [ ] Performance follow-up from the 1 GiB benchmark: commit takes 5.6 s on tmpfs but 63 s on the SSD, and clone 49 s, because every object is written with its own fsync (13,715 chunks). Write committed and fetched objects straight into a pack with one fsync, or batch loose writes.
35 +
- [ ] Performance follow-up: `oxy status` re-hashes a clean 1 GiB working copy (2.4 s). Record size and mtime in the manifest and skip hashing files whose stat is unchanged (spec allows a stat cache).
34 36
- [ ] Documentation: `docs/guide.md` (user guide by task) and `docs/protocol.md` (for other implementers, derived from spec §16 and the wire encodings).
35 37
- [ ] Spec review pass: resolve or re-word every "MAY ... until" allowance introduced during implementation, and close what can be closed in §27.
36 38
37 39
## Log
38 40
39 41
- 2026-09-26 23:01 plan created.
42 +
- 2026-09-27 08:05 iteration 14: hardening (CBOR and pack fuzz-style tests, concurrent pushes, 1 GiB benchmark: write 0.7 s, commit 63 s on SSD / 5.6 s on tmpfs, status 2.4 s, clone 49 s, read back 1.0 s, store 1025.6 MiB, 13,715 objects). 110 tests, 1 ignored benchmark.
40 43
- 2026-09-27 07:20 iteration 13: rename detection (snapshots and Git import) and oxy git fetch/push via a mirror (spec 0.6p). 108 tests.
41 44
- 2026-09-27 06:30 iteration 12: identity lifecycle and multi-signer policy updates; policy chains on fetch and push (spec 0.6n). 106 tests.
42 45
- 2026-09-27 05:45 iteration 11: modern CLI output across all commands (user request). 105 tests.
…
10 unchanged lines
modified docs/spec.md +5 −3
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.6p |
5 +
| **Status** | Draft 0.6q |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
477 unchanged lines
486 486
  policy/             cached policy and identities
487 487
```
488 488
489 -
Object writes go to a temporary file, are `fsync`ed, and are renamed into place. Pack files are `fsync`ed before their index is written. An interrupted write can never corrupt an existing object. The refs cache is derivable from the operation log and is rebuilt if missing.
489 +
Object writes go to a temporary file, are `fsync`ed, and are renamed into place. Pack files are `fsync`ed before their index is written. An interrupted write can never corrupt an existing object. A reader MUST validate an index before trusting it: entries sorted and unique, each `offset + length` inside the pack, and lengths below an implementation limit (1 GiB), so a damaged index can neither drive out-of-bounds reads nor huge allocations; every object read is re-hashed regardless. The refs cache is derivable from the operation log and is rebuilt if missing.
490 490
491 491
### 13.2 Chunking and blobs (G5)
492 492
…
354 unchanged lines
847 847
848 848
---
849 849
850 -
*Changelog.* Draft 0.6p (2026-09-27): `oxy git fetch` and `oxy git push` against a Git remote through a mirror, with incremental import seeded by imported and exported commits (§23).
850 +
*Changelog.* Draft 0.6q (2026-09-27): pack index validation rules (§13.1).
851 +
852 +
Draft 0.6p (2026-09-27): `oxy git fetch` and `oxy git push` against a Git remote through a mirror, with incremental import seeded by imported and exported commits (§23).
851 853
852 854
Draft 0.6o (2026-09-27): rename detection on snapshot and Git import records moves of the existing node (§7.1, §23).
853 855
…
45 unchanged lines