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 |