Packed storage: zstd packs with sorted indexes, oxy gc, auto-pack at 4096 loose objects; README d02e545dc265
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-26 19:28 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
d02e545dc265940fdff440a6459e9b42cfec48124827b5f3c613b95b53842c74- Depends on
- 1983686fb7a7 2b5ee371060e 5e388efbde57 648724d8e0de 750251b21a96 83e0677b07fe 982864068a59 e993a086c13f f4324e406906 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 91334b7fa574 e4595038502a 767ac6cd0545 7e0e8e5ad882 a67914bced23 b689779e9e74 54e2e6309aa3 37f1914b62a8 1f53d5b91eb5 da58bbbbc4d9 450d595070bb 648189388f1a 47ce8b8e2d4b 9ae4d15a0e4b f874dc8659c2 8e1194860efa 8d80356371c5 236f35c0baba b30a880fa409 ff1f62fee654 a17fc622ca43 25c6adfaa559 da652d6392d8 afaba5f1306d 75167672ebcd 728da0f92161 ee06a6c557b7 8f10a07a1dbe 2b872d074ddb 30ea9c1e70fa 21b2aec279ff 94084087a876 28fc23d7706a 6d3eefbce2f0 cdb5beba7f05 135212de5370 ba160f60219e f77698833f9f 614541c3cebd
- 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
- 36 atoms, 0 tree operations, 10 files
Browse the tree at this change
modified Cargo.lock +47 −0
| … | 290 unchanged lines |
||
| 291 | 291 | checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" |
|
| 292 | 292 | dependencies = [ |
|
| 293 | 293 | "find-msvc-tools", |
|
| 294 | + | "jobserver", |
|
| 295 | + | "libc", |
|
| 294 | 296 | "shlex", |
|
| 295 | 297 | ] |
|
| 296 | 298 | ||
| … | 658 unchanged lines |
||
| 955 | 957 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" |
|
| 956 | 958 | ||
| 957 | 959 | [[package]] |
|
| 960 | + | name = "jobserver" |
|
| 961 | + | version = "0.1.35" |
|
| 962 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 963 | + | checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" |
|
| 964 | + | dependencies = [ |
|
| 965 | + | "getrandom 0.4.3", |
|
| 966 | + | "libc", |
|
| 967 | + | ] |
|
| 968 | + | ||
| 969 | + | [[package]] |
|
| 958 | 970 | name = "keccak" |
|
| 959 | 971 | version = "0.2.2" |
|
| 960 | 972 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 199 unchanged lines |
||
| 1160 | 1172 | "thiserror 1.0.69", |
|
| 1161 | 1173 | "ureq", |
|
| 1162 | 1174 | "webpki-roots 1.0.9", |
|
| 1175 | + | "zstd", |
|
| 1163 | 1176 | ] |
|
| 1164 | 1177 | ||
| 1165 | 1178 | [[package]] |
|
| … | 42 unchanged lines |
||
| 1208 | 1221 | ] |
|
| 1209 | 1222 | ||
| 1210 | 1223 | [[package]] |
|
| 1224 | + | name = "pkg-config" |
|
| 1225 | + | version = "0.3.34" |
|
| 1226 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1227 | + | checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" |
|
| 1228 | + | ||
| 1229 | + | [[package]] |
|
| 1211 | 1230 | name = "potential_utf" |
|
| 1212 | 1231 | version = "0.1.6" |
|
| 1213 | 1232 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 974 unchanged lines |
||
| 2188 | 2207 | version = "1.0.23" |
|
| 2189 | 2208 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2190 | 2209 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" |
|
| 2210 | + | ||
| 2211 | + | [[package]] |
|
| 2212 | + | name = "zstd" |
|
| 2213 | + | version = "0.13.3" |
|
| 2214 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2215 | + | checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" |
|
| 2216 | + | dependencies = [ |
|
| 2217 | + | "zstd-safe", |
|
| 2218 | + | ] |
|
| 2219 | + | ||
| 2220 | + | [[package]] |
|
| 2221 | + | name = "zstd-safe" |
|
| 2222 | + | version = "7.3.0" |
|
| 2223 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2224 | + | checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" |
|
| 2225 | + | dependencies = [ |
|
| 2226 | + | "zstd-sys", |
|
| 2227 | + | ] |
|
| 2228 | + | ||
| 2229 | + | [[package]] |
|
| 2230 | + | name = "zstd-sys" |
|
| 2231 | + | version = "2.1.0+zstd.1.5.7" |
|
| 2232 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2233 | + | checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" |
|
| 2234 | + | dependencies = [ |
|
| 2235 | + | "cc", |
|
| 2236 | + | "pkg-config", |
|
| 2237 | + | ] |
modified README.md +69 −2
| 1 | 1 | # Oxygen |
|
| 2 | 2 | ||
| 3 | - | A distributed, commutative version control system written in Rust for large repositories. See docs/spec.md. |
|
| 3 | + | Oxygen (`oxy`) is a distributed, commutative version control system written in Rust for large repositories. See [docs/spec.md](docs/spec.md) for the specification. |
|
| 4 | + | ||
| 5 | + | It is self-hosting: this directory is an Oxygen repository, and the project's own history is kept with `oxy`, not with Git. |
|
| 6 | + | ||
| 7 | + | ## What is different |
|
| 8 | + | ||
| 9 | + | - **History is a set of changes, not a chain of snapshots.** A change records what it did to identified lines and tree nodes and which earlier changes it depends on. Changes that do not depend on each other commute, so there are no merge commits and rebasing independent work rewrites nothing. |
|
| 10 | + | - **Conflicts are data.** A conflicted state can be committed, pushed and pulled. Resolving one is an ordinary edit followed by a commit. |
|
| 11 | + | - **Every change has a stable ID and a content hash.** The ID survives rewrites; the hash makes history tamper-evident. |
|
| 12 | + | - **Everything is signed.** Composite Ed25519 + ML-DSA-65 signatures on changes and on every bookmark update, verified against a trust policy that lives in the repository. |
|
| 13 | + | - **Every operation is undoable** through an operation log. |
|
| 14 | + | - **Large files are chunked** with content-defined chunking into a BLAKE3-addressed store. |
|
| 15 | + | ||
| 16 | + | ## Status |
|
| 17 | + | ||
| 18 | + | Early, but usable for its own development. Implemented: the object model, the change graph with conflict detection and resolution, the working copy (`status`, `commit`, `checkout`, `diff`, `undo`), identities and signatures, remotes over local paths and HTTP(S) with `clone`, `fetch`, `pull`, `push`, the reference server `oxy serve`, the trust policy, and packed storage. |
|
| 19 | + | ||
| 20 | + | Not yet: checkpoints for very long histories, history editing (`rebase`, `split`, `edit`), multiple workspaces, garbage collection with retention, and the Thin client profile for constrained machines. |
|
| 21 | + | ||
| 22 | + | ## Building |
|
| 23 | + | ||
| 24 | + | ``` |
|
| 25 | + | cargo build --release |
|
| 26 | + | cargo test --workspace |
|
| 27 | + | ``` |
|
| 28 | + | ||
| 29 | + | The binary is `target/release/oxy`. |
|
| 4 | 30 | ||
| 5 | - | Cloned and pushed back over the local transport. |
|
| 31 | + | ## Quick start |
|
| 32 | + | ||
| 33 | + | ``` |
|
| 34 | + | oxy identity create --name "Your Name" --email [email protected] |
|
| 35 | + | oxy config --user core.sign change # sign every commit |
|
| 36 | + | oxy init # in a project directory |
|
| 37 | + | oxy status |
|
| 38 | + | oxy commit -m "First change" |
|
| 39 | + | oxy log |
|
| 40 | + | ``` |
|
| 41 | + | ||
| 42 | + | Collaborating: |
|
| 43 | + | ||
| 44 | + | ``` |
|
| 45 | + | oxy serve --root /srv/oxy --bind 0.0.0.0:8890 --tls-cert cert.pem --tls-key key.pem --token SECRET |
|
| 46 | + | oxy clone https://host:8890/project |
|
| 47 | + | oxy config remote.origin.token SECRET |
|
| 48 | + | oxy push |
|
| 49 | + | oxy pull |
|
| 50 | + | ``` |
|
| 51 | + | ||
| 52 | + | Trust policy, so a server accepts only changes from keys you have granted: |
|
| 53 | + | ||
| 54 | + | ``` |
|
| 55 | + | oxy policy init # your key becomes the root |
|
| 56 | + | oxy identity export # a contributor sends you this file |
|
| 57 | + | oxy policy grant alice.identity |
|
| 58 | + | oxy push # publishes the policy |
|
| 59 | + | ``` |
|
| 60 | + | ||
| 61 | + | ## Layout |
|
| 62 | + | ||
| 63 | + | | Path | Contents | |
|
| 64 | + | |---|---| |
|
| 65 | + | | `crates/oxy-core` | Library: encoding, hashing, objects, the change graph, storage, identities, policy, protocol, repository operations | |
|
| 66 | + | | `crates/oxy-server` | Reference HTTP(S) server for the wire protocol | |
|
| 67 | + | | `src/cli` | The `oxy` command-line interface | |
|
| 68 | + | | `docs/spec.md` | The specification | |
|
| 69 | + | ||
| 70 | + | ## Licence |
|
| 71 | + | ||
| 72 | + | MIT or Apache-2.0, at your option. |
modified crates/oxy-core/Cargo.toml +1 −0
| … | 17 unchanged lines |
||
| 18 | 18 | rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } |
|
| 19 | 19 | rustls-pemfile = "2" |
|
| 20 | 20 | webpki-roots = "1" |
|
| 21 | + | zstd = "0.13" |
|
| 21 | 22 | ||
| 22 | 23 | [dev-dependencies] |
|
| 23 | 24 | tempfile = "3" |
modified crates/oxy-core/src/repo/mod.rs +17 −0
| … | 27 unchanged lines |
||
| 28 | 28 | pub const FORMAT_LINE: &str = "oxy repository format 2\n"; |
|
| 29 | 29 | const FORMAT1_LINE: &str = "oxy repository format 1\n"; |
|
| 30 | 30 | const BINARY_SNIFF: usize = 8 * 1024; |
|
| 31 | + | /// Loose objects tolerated before a commit packs them (spec §13.4). |
|
| 32 | + | pub const AUTOPACK_THRESHOLD: usize = 4096; |
|
| 31 | 33 | ||
| 32 | 34 | #[derive(Debug, thiserror::Error)] |
|
| 33 | 35 | pub enum RepoError { |
|
| … | 185 unchanged lines |
||
| 219 | 221 | Ok(()) |
|
| 220 | 222 | } |
|
| 221 | 223 | ||
| 224 | + | /// Pack every loose object (spec §13.4). Pruning unreachable objects |
|
| 225 | + | /// waits for operation-log retention, since every operation keeps its |
|
| 226 | + | /// states reachable for now. |
|
| 227 | + | pub fn gc(&self) -> Result<crate::store::PackReport> { |
|
| 228 | + | Ok(self.store.pack_loose()?) |
|
| 229 | + | } |
|
| 230 | + | ||
| 231 | + | /// Pack automatically once many loose objects have accumulated. |
|
| 232 | + | pub(crate) fn maybe_autopack(&self) -> Result<()> { |
|
| 233 | + | if self.store.loose_count()? > AUTOPACK_THRESHOLD { |
|
| 234 | + | self.store.pack_loose()?; |
|
| 235 | + | } |
|
| 236 | + | Ok(()) |
|
| 237 | + | } |
|
| 238 | + | ||
| 222 | 239 | /// The repository ID (spec §14.4). Repositories made before IDs existed get one on first use. |
|
| 223 | 240 | pub fn id(&self) -> Result<[u8; 16]> { |
|
| 224 | 241 | let path = self.oxy.join("id"); |
|
| … | 335 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +1 −0
| … | 175 unchanged lines |
||
| 176 | 176 | view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(new_heads))?); |
|
| 177 | 177 | view.workspaces.insert(self.workspace, wc_hash); |
|
| 178 | 178 | self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?; |
|
| 179 | + | self.maybe_autopack()?; |
|
| 179 | 180 | Ok(hash) |
|
| 180 | 181 | } |
|
| 181 | 182 | } |
|
| … | 375 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +37 −0
| … | 748 unchanged lines |
||
| 749 | 749 | assert!(c.current_policy().unwrap().unwrap().1.policy.roots.contains(&key_a)); |
|
| 750 | 750 | assert!(!c.snapshot("status").unwrap().changed); |
|
| 751 | 751 | } |
|
| 752 | + | ||
| 753 | + | #[test] |
|
| 754 | + | fn gc_packs_objects_and_history_still_works() { |
|
| 755 | + | hermetic(); |
|
| 756 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 757 | + | let root = dir.path(); |
|
| 758 | + | let mut repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 759 | + | repo.create_identity("Ada", "a@b", IdentityScope::User).unwrap(); |
|
| 760 | + | repo.set_user_config("core.sign", "change").unwrap(); |
|
| 761 | + | write(root, "a.txt", b"one\n"); |
|
| 762 | + | let h1 = repo.commit("one", &[]).unwrap(); |
|
| 763 | + | write(root, "a.txt", b"two\n"); |
|
| 764 | + | write(root, "big.bin", &vec![0u8; 100_000]); |
|
| 765 | + | let h2 = repo.commit("two", &[]).unwrap(); |
|
| 766 | + | let before = repo.store().list().unwrap(); |
|
| 767 | + | assert!(repo.store().loose_count().unwrap() > 0); |
|
| 768 | + | let r = repo.gc().unwrap(); |
|
| 769 | + | assert_eq!(r.objects, before.len()); |
|
| 770 | + | assert_eq!(repo.store().loose_count().unwrap(), 0); |
|
| 771 | + | assert_eq!(repo.store().list().unwrap(), before); |
|
| 772 | + | // Reopen: reads come from the pack; signatures, states, ops all resolve. |
|
| 773 | + | let repo = Repo::open(root).unwrap().with_home(root.join("home")); |
|
| 774 | + | assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]); |
|
| 775 | + | assert!(matches!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { .. })); |
|
| 776 | + | assert_eq!(repo.log().unwrap().len(), 3); |
|
| 777 | + | repo.checkout(&[h1], false, "checkout").unwrap(); |
|
| 778 | + | assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n"); |
|
| 779 | + | repo.checkout(&[h2], false, "checkout").unwrap(); |
|
| 780 | + | assert_eq!(fs::read(root.join("big.bin")).unwrap().len(), 100_000); |
|
| 781 | + | assert!(!repo.snapshot("status").unwrap().changed); |
|
| 782 | + | // New work after packing goes loose and commits fine. |
|
| 783 | + | write(root, "a.txt", b"three\n"); |
|
| 784 | + | repo.commit("three", &[]).unwrap(); |
|
| 785 | + | assert!(repo.store().loose_count().unwrap() > 0); |
|
| 786 | + | assert_eq!(repo.gc().unwrap().objects, repo.store().list().unwrap().len() - before.len()); |
|
| 787 | + | assert_eq!(repo.store().pack_count(), 2); |
|
| 788 | + | } |
modified crates/oxy-core/src/store.rs +286 −26
| 1 | - | //! Loose object store (spec §13.1): one file per object under |
|
| 2 | - | //! `objects/loose/<hh>/<rest>`, written atomically and verified on read. |
|
| 3 | - | //! Packs come later. |
|
| 1 | + | //! Object store (spec §13.1, §13.3, §13.4): loose objects, one file per |
|
| 2 | + | //! object under `objects/loose/<hh>/<rest>`, and packs, `pack-<id>.pack` |
|
| 3 | + | //! with a sorted `pack-<id>.idx`, holding zstd-compressed objects. Reads |
|
| 4 | + | //! check loose objects first, then every pack; every read re-hashes. |
|
| 5 | + | //! |
|
| 6 | + | //! Pack file: `OXYPACK1` then entries of |
|
| 7 | + | //! `type u8 | flags u8 (bit 0: zstd) | stored_len u32 | raw_len u32 | ref 32 | body`. |
|
| 8 | + | //! Index file: `OXYIDX01` then `count u32` then sorted entries of |
|
| 9 | + | //! `ref 32 | offset u64 | stored_len u32 | raw_len u32 | type u8 | flags u8`. |
|
| 4 | 10 | ||
| 5 | 11 | use crate::attest::Attest; |
|
| 6 | 12 | use crate::hash::{hash_object, header, ObjectRef, ObjectType}; |
|
| 7 | 13 | use crate::identity::{Identity, Signature}; |
|
| 8 | 14 | use crate::objects::{Change, ObjectError, StateObject}; |
|
| 9 | 15 | use rand::RngCore; |
|
| 16 | + | use std::cell::RefCell; |
|
| 10 | 17 | use std::fs; |
|
| 11 | - | use std::io::{self, Write}; |
|
| 18 | + | use std::io::{self, Read, Seek, SeekFrom, Write}; |
|
| 12 | 19 | use std::path::{Path, PathBuf}; |
|
| 13 | 20 | ||
| 21 | + | const PACK_MAGIC: &[u8; 8] = b"OXYPACK1"; |
|
| 22 | + | const IDX_MAGIC: &[u8; 8] = b"OXYIDX01"; |
|
| 23 | + | const IDX_ENTRY: usize = 32 + 8 + 4 + 4 + 1 + 1; |
|
| 24 | + | const FLAG_ZSTD: u8 = 1; |
|
| 25 | + | const ZSTD_LEVEL: i32 = 3; |
|
| 26 | + | ||
| 27 | + | #[derive(Clone, Copy, Debug)] |
|
| 28 | + | struct PackEntry { |
|
| 29 | + | offset: u64, |
|
| 30 | + | stored_len: u32, |
|
| 31 | + | raw_len: u32, |
|
| 32 | + | ty: ObjectType, |
|
| 33 | + | flags: u8, |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | /// One pack's index, fully in memory and sorted by ObjectRef. |
|
| 37 | + | #[derive(Debug)] |
|
| 38 | + | struct PackIndex { |
|
| 39 | + | path: PathBuf, |
|
| 40 | + | refs: Vec<ObjectRef>, |
|
| 41 | + | entries: Vec<PackEntry>, |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | impl PackIndex { |
|
| 45 | + | fn load(idx_path: &Path, pack_path: &Path) -> Result<PackIndex> { |
|
| 46 | + | let data = fs::read(idx_path)?; |
|
| 47 | + | let bad = |m: &str| StoreError::Corrupt(ObjectRef::ZERO, format!("{}: {m}", idx_path.display())); |
|
| 48 | + | if data.len() < 12 || &data[..8] != IDX_MAGIC { |
|
| 49 | + | return Err(bad("bad index header")); |
|
| 50 | + | } |
|
| 51 | + | let count = u32::from_le_bytes(data[8..12].try_into().unwrap()) as usize; |
|
| 52 | + | if data.len() != 12 + count * IDX_ENTRY { |
|
| 53 | + | return Err(bad("index length mismatch")); |
|
| 54 | + | } |
|
| 55 | + | let mut refs = Vec::with_capacity(count); |
|
| 56 | + | let mut entries = Vec::with_capacity(count); |
|
| 57 | + | for i in 0..count { |
|
| 58 | + | let e = &data[12 + i * IDX_ENTRY..12 + (i + 1) * IDX_ENTRY]; |
|
| 59 | + | let r = ObjectRef::from_slice(&e[..32]).unwrap(); |
|
| 60 | + | if let Some(prev) = refs.last() { |
|
| 61 | + | if r <= *prev { |
|
| 62 | + | return Err(bad("index not sorted")); |
|
| 63 | + | } |
|
| 64 | + | } |
|
| 65 | + | refs.push(r); |
|
| 66 | + | entries.push(PackEntry { |
|
| 67 | + | offset: u64::from_le_bytes(e[32..40].try_into().unwrap()), |
|
| 68 | + | stored_len: u32::from_le_bytes(e[40..44].try_into().unwrap()), |
|
| 69 | + | raw_len: u32::from_le_bytes(e[44..48].try_into().unwrap()), |
|
| 70 | + | ty: ObjectType::ALL.get(e[48] as usize).copied().ok_or_else(|| bad("unknown object type"))?, |
|
| 71 | + | flags: e[49], |
|
| 72 | + | }); |
|
| 73 | + | } |
|
| 74 | + | Ok(PackIndex { path: pack_path.to_path_buf(), refs, entries }) |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | fn find(&self, r: &ObjectRef) -> Option<PackEntry> { |
|
| 78 | + | self.refs.binary_search(r).ok().map(|i| self.entries[i]) |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | fn read(&self, r: &ObjectRef, e: PackEntry) -> Result<(ObjectType, Vec<u8>)> { |
|
| 82 | + | let mut f = fs::File::open(&self.path)?; |
|
| 83 | + | f.seek(SeekFrom::Start(e.offset))?; |
|
| 84 | + | let mut stored = vec![0u8; e.stored_len as usize]; |
|
| 85 | + | f.read_exact(&mut stored)?; |
|
| 86 | + | let body = if e.flags & FLAG_ZSTD != 0 { |
|
| 87 | + | zstd::bulk::decompress(&stored, e.raw_len as usize).map_err(|err| StoreError::Corrupt(*r, format!("zstd: {err}")))? |
|
| 88 | + | } else { |
|
| 89 | + | stored |
|
| 90 | + | }; |
|
| 91 | + | if body.len() != e.raw_len as usize || hash_object(e.ty, &body) != *r { |
|
| 92 | + | return Err(StoreError::Corrupt(*r, "packed object does not match its reference".into())); |
|
| 93 | + | } |
|
| 94 | + | Ok((e.ty, body)) |
|
| 95 | + | } |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | fn type_index(ty: ObjectType) -> u8 { |
|
| 99 | + | ObjectType::ALL.iter().position(|t| *t == ty).unwrap() as u8 |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | /// What `pack_loose` did. |
|
| 103 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
|
| 104 | + | pub struct PackReport { |
|
| 105 | + | pub objects: usize, |
|
| 106 | + | pub raw_bytes: u64, |
|
| 107 | + | pub packed_bytes: u64, |
|
| 108 | + | } |
|
| 109 | + | ||
| 14 | 110 | #[derive(Debug, thiserror::Error)] |
|
| 15 | 111 | pub enum StoreError { |
|
| 16 | 112 | #[error("io: {0}")] |
|
| … | 10 unchanged lines |
||
| 27 | 123 | ||
| 28 | 124 | pub type Result<T> = std::result::Result<T, StoreError>; |
|
| 29 | 125 | ||
| 30 | - | #[derive(Debug, Clone)] |
|
| 126 | + | #[derive(Debug)] |
|
| 31 | 127 | pub struct Store { |
|
| 32 | 128 | dir: PathBuf, |
|
| 129 | + | packs: RefCell<Vec<PackIndex>>, |
|
| 33 | 130 | } |
|
| 34 | 131 | ||
| 35 | 132 | impl Store { |
|
| 36 | 133 | pub fn open(dir: impl Into<PathBuf>) -> io::Result<Store> { |
|
| 37 | 134 | let dir = dir.into(); |
|
| 38 | 135 | fs::create_dir_all(dir.join("loose"))?; |
|
| 39 | - | Ok(Store { dir }) |
|
| 136 | + | let store = Store { dir, packs: RefCell::new(Vec::new()) }; |
|
| 137 | + | store.reload_packs().map_err(|e| io::Error::other(e.to_string()))?; |
|
| 138 | + | Ok(store) |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | fn reload_packs(&self) -> Result<()> { |
|
| 142 | + | let mut packs = Vec::new(); |
|
| 143 | + | let mut names: Vec<PathBuf> = fs::read_dir(&self.dir)? |
|
| 144 | + | .filter_map(|e| e.ok().map(|e| e.path())) |
|
| 145 | + | .filter(|p| p.extension().is_some_and(|x| x == "idx") && p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("pack-"))) |
|
| 146 | + | .collect(); |
|
| 147 | + | names.sort(); |
|
| 148 | + | for idx in names { |
|
| 149 | + | let pack = idx.with_extension("pack"); |
|
| 150 | + | if pack.is_file() { |
|
| 151 | + | packs.push(PackIndex::load(&idx, &pack)?); |
|
| 152 | + | } |
|
| 153 | + | } |
|
| 154 | + | *self.packs.borrow_mut() = packs; |
|
| 155 | + | Ok(()) |
|
| 156 | + | } |
|
| 157 | + | ||
| 158 | + | pub fn pack_count(&self) -> usize { |
|
| 159 | + | self.packs.borrow().len() |
|
| 160 | + | } |
|
| 161 | + | ||
| 162 | + | fn find_packed(&self, r: &ObjectRef) -> Option<(usize, PackEntry)> { |
|
| 163 | + | self.packs.borrow().iter().enumerate().find_map(|(i, p)| p.find(r).map(|e| (i, e))) |
|
| 164 | + | } |
|
| 165 | + | ||
| 166 | + | /// Loose objects on disk, with their paths. |
|
| 167 | + | fn loose_objects(&self) -> io::Result<Vec<(ObjectRef, PathBuf)>> { |
|
| 168 | + | let mut out = Vec::new(); |
|
| 169 | + | let loose = self.dir.join("loose"); |
|
| 170 | + | for d in fs::read_dir(&loose)? { |
|
| 171 | + | let d = d?; |
|
| 172 | + | if !d.file_type()?.is_dir() { |
|
| 173 | + | continue; |
|
| 174 | + | } |
|
| 175 | + | let prefix = d.file_name().to_string_lossy().to_string(); |
|
| 176 | + | for f in fs::read_dir(d.path())? { |
|
| 177 | + | let f = f?; |
|
| 178 | + | let name = f.file_name().to_string_lossy().to_string(); |
|
| 179 | + | if let Some(r) = ObjectRef::from_hex(&format!("{prefix}{name}")) { |
|
| 180 | + | out.push((r, f.path())); |
|
| 181 | + | } |
|
| 182 | + | } |
|
| 183 | + | } |
|
| 184 | + | out.sort(); |
|
| 185 | + | Ok(out) |
|
| 186 | + | } |
|
| 187 | + | ||
| 188 | + | pub fn loose_count(&self) -> io::Result<usize> { |
|
| 189 | + | Ok(self.loose_objects()?.len()) |
|
| 190 | + | } |
|
| 191 | + | ||
| 192 | + | /// Move every loose object into a new pack (spec §13.4). Loose files are |
|
| 193 | + | /// deleted only after the pack and its index are durable. |
|
| 194 | + | pub fn pack_loose(&self) -> Result<PackReport> { |
|
| 195 | + | let loose = self.loose_objects()?; |
|
| 196 | + | if loose.is_empty() { |
|
| 197 | + | return Ok(PackReport::default()); |
|
| 198 | + | } |
|
| 199 | + | let mut nonce = [0u8; 8]; |
|
| 200 | + | rand::rngs::OsRng.fill_bytes(&mut nonce); |
|
| 201 | + | let id = hex::encode(nonce); |
|
| 202 | + | let pack_path = self.dir.join(format!("pack-{id}.pack")); |
|
| 203 | + | let idx_path = self.dir.join(format!("pack-{id}.idx")); |
|
| 204 | + | let tmp_pack = self.dir.join(format!("tmp-{id}.pack")); |
|
| 205 | + | let tmp_idx = self.dir.join(format!("tmp-{id}.idx")); |
|
| 206 | + | let mut report = PackReport::default(); |
|
| 207 | + | let mut index: Vec<(ObjectRef, PackEntry)> = Vec::with_capacity(loose.len()); |
|
| 208 | + | { |
|
| 209 | + | let mut f = io::BufWriter::new(fs::File::create(&tmp_pack)?); |
|
| 210 | + | f.write_all(PACK_MAGIC)?; |
|
| 211 | + | let mut offset = PACK_MAGIC.len() as u64; |
|
| 212 | + | for (r, _) in &loose { |
|
| 213 | + | let (ty, body) = self.get(r)?; |
|
| 214 | + | let compressed = zstd::bulk::compress(&body, ZSTD_LEVEL).map_err(|e| StoreError::Corrupt(*r, format!("zstd: {e}")))?; |
|
| 215 | + | let (flags, stored) = if compressed.len() < body.len() { (FLAG_ZSTD, compressed) } else { (0, body.clone()) }; |
|
| 216 | + | let header_len = 1 + 1 + 4 + 4 + 32; |
|
| 217 | + | f.write_all(&[type_index(ty), flags])?; |
|
| 218 | + | f.write_all(&(stored.len() as u32).to_le_bytes())?; |
|
| 219 | + | f.write_all(&(body.len() as u32).to_le_bytes())?; |
|
| 220 | + | f.write_all(&r.0)?; |
|
| 221 | + | f.write_all(&stored)?; |
|
| 222 | + | index.push((*r, PackEntry { offset: offset + header_len, stored_len: stored.len() as u32, raw_len: body.len() as u32, ty, flags })); |
|
| 223 | + | offset += header_len + stored.len() as u64; |
|
| 224 | + | report.objects += 1; |
|
| 225 | + | report.raw_bytes += body.len() as u64; |
|
| 226 | + | report.packed_bytes += stored.len() as u64; |
|
| 227 | + | } |
|
| 228 | + | f.flush()?; |
|
| 229 | + | f.get_ref().sync_all()?; |
|
| 230 | + | } |
|
| 231 | + | { |
|
| 232 | + | let mut f = io::BufWriter::new(fs::File::create(&tmp_idx)?); |
|
| 233 | + | f.write_all(IDX_MAGIC)?; |
|
| 234 | + | f.write_all(&(index.len() as u32).to_le_bytes())?; |
|
| 235 | + | for (r, e) in &index { |
|
| 236 | + | f.write_all(&r.0)?; |
|
| 237 | + | f.write_all(&e.offset.to_le_bytes())?; |
|
| 238 | + | f.write_all(&e.stored_len.to_le_bytes())?; |
|
| 239 | + | f.write_all(&e.raw_len.to_le_bytes())?; |
|
| 240 | + | f.write_all(&[type_index(e.ty), e.flags])?; |
|
| 241 | + | } |
|
| 242 | + | f.flush()?; |
|
| 243 | + | f.get_ref().sync_all()?; |
|
| 244 | + | } |
|
| 245 | + | fs::rename(&tmp_pack, &pack_path)?; |
|
| 246 | + | fs::rename(&tmp_idx, &idx_path)?; |
|
| 247 | + | self.reload_packs()?; |
|
| 248 | + | for (_, path) in &loose { |
|
| 249 | + | fs::remove_file(path)?; |
|
| 250 | + | if let Some(dir) = path.parent() { |
|
| 251 | + | let _ = fs::remove_dir(dir); |
|
| 252 | + | } |
|
| 253 | + | } |
|
| 254 | + | Ok(report) |
|
| 40 | 255 | } |
|
| 41 | 256 | ||
| 42 | 257 | pub fn dir(&self) -> &Path { |
|
| … | 6 unchanged lines |
||
| 49 | 264 | } |
|
| 50 | 265 | ||
| 51 | 266 | pub fn has(&self, r: &ObjectRef) -> bool { |
|
| 52 | - | self.path_for(r).is_file() |
|
| 267 | + | self.path_for(r).is_file() || self.find_packed(r).is_some() |
|
| 53 | 268 | } |
|
| 54 | 269 | ||
| 55 | 270 | /// Store `body` as an object of type `ty`; idempotent. |
|
| 56 | 271 | pub fn put(&self, ty: ObjectType, body: &[u8]) -> Result<ObjectRef> { |
|
| 57 | 272 | let r = hash_object(ty, body); |
|
| 58 | 273 | let path = self.path_for(&r); |
|
| 59 | - | if path.is_file() { |
|
| 274 | + | if path.is_file() || self.find_packed(&r).is_some() { |
|
| 60 | 275 | return Ok(r); |
|
| 61 | 276 | } |
|
| 62 | 277 | fs::create_dir_all(path.parent().unwrap())?; |
|
| … | 10 unchanged lines |
||
| 73 | 288 | Ok(r) |
|
| 74 | 289 | } |
|
| 75 | 290 | ||
| 76 | - | /// Read and verify an object. |
|
| 291 | + | /// Read and verify an object, loose or packed. |
|
| 77 | 292 | pub fn get(&self, r: &ObjectRef) -> Result<(ObjectType, Vec<u8>)> { |
|
| 78 | 293 | let path = self.path_for(r); |
|
| 79 | 294 | let data = match fs::read(&path) { |
|
| 80 | 295 | Ok(d) => d, |
|
| 81 | - | Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(StoreError::NotFound(*r)), |
|
| 296 | + | Err(e) if e.kind() == io::ErrorKind::NotFound => { |
|
| 297 | + | return match self.find_packed(r) { |
|
| 298 | + | Some((i, e)) => self.packs.borrow()[i].read(r, e), |
|
| 299 | + | None => Err(StoreError::NotFound(*r)), |
|
| 300 | + | }; |
|
| 301 | + | } |
|
| 82 | 302 | Err(e) => return Err(e.into()), |
|
| 83 | 303 | }; |
|
| 84 | 304 | let nul = data.iter().position(|&b| b == 0).ok_or_else(|| StoreError::Corrupt(*r, "no header".into()))?; |
|
| … | 123 unchanged lines |
||
| 208 | 428 | Ok(()) |
|
| 209 | 429 | } |
|
| 210 | 430 | ||
| 211 | - | /// Every object reference in the store (slow; for gc and tests). |
|
| 431 | + | /// Every object reference in the store, loose and packed (slow; for gc and tests). |
|
| 212 | 432 | pub fn list(&self) -> io::Result<Vec<ObjectRef>> { |
|
| 213 | - | let mut out = Vec::new(); |
|
| 214 | - | let loose = self.dir.join("loose"); |
|
| 215 | - | for d in fs::read_dir(&loose)? { |
|
| 216 | - | let d = d?; |
|
| 217 | - | if !d.file_type()?.is_dir() { |
|
| 218 | - | continue; |
|
| 219 | - | } |
|
| 220 | - | let prefix = d.file_name().to_string_lossy().to_string(); |
|
| 221 | - | for f in fs::read_dir(d.path())? { |
|
| 222 | - | let f = f?; |
|
| 223 | - | let name = f.file_name().to_string_lossy().to_string(); |
|
| 224 | - | if let Some(r) = ObjectRef::from_hex(&format!("{prefix}{name}")) { |
|
| 225 | - | out.push(r); |
|
| 226 | - | } |
|
| 227 | - | } |
|
| 433 | + | let mut out: Vec<ObjectRef> = self.loose_objects()?.into_iter().map(|(r, _)| r).collect(); |
|
| 434 | + | for p in self.packs.borrow().iter() { |
|
| 435 | + | out.extend(p.refs.iter().copied()); |
|
| 228 | 436 | } |
|
| 229 | 437 | out.sort(); |
|
| 438 | + | out.dedup(); |
|
| 230 | 439 | Ok(out) |
|
| 231 | 440 | } |
|
| 232 | 441 | } |
|
| … | 24 unchanged lines |
||
| 257 | 466 | } |
|
| 258 | 467 | ||
| 259 | 468 | #[test] |
|
| 469 | + | fn packing_keeps_every_object_readable() { |
|
| 470 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 471 | + | let s = Store::open(dir.path()).unwrap(); |
|
| 472 | + | let mut refs = Vec::new(); |
|
| 473 | + | for i in 0..300u32 { |
|
| 474 | + | let body = format!("object {i} ").repeat(1 + (i % 7) as usize); |
|
| 475 | + | refs.push((s.put(ObjectType::Chunk, body.as_bytes()).unwrap(), body)); |
|
| 476 | + | } |
|
| 477 | + | let c = crate::objects::tests::sample_change(); |
|
| 478 | + | let cr = s.put_change(&c).unwrap(); |
|
| 479 | + | assert_eq!(s.loose_count().unwrap(), 301); |
|
| 480 | + | let report = s.pack_loose().unwrap(); |
|
| 481 | + | assert_eq!(report.objects, 301); |
|
| 482 | + | assert!(report.packed_bytes < report.raw_bytes, "{report:?}"); |
|
| 483 | + | assert_eq!(s.loose_count().unwrap(), 0); |
|
| 484 | + | assert_eq!(s.pack_count(), 1); |
|
| 485 | + | for (r, body) in &refs { |
|
| 486 | + | assert!(s.has(r)); |
|
| 487 | + | assert_eq!(s.get(r).unwrap(), (ObjectType::Chunk, body.as_bytes().to_vec())); |
|
| 488 | + | } |
|
| 489 | + | assert_eq!(s.get_change(&cr).unwrap(), c); |
|
| 490 | + | assert_eq!(s.list().unwrap().len(), 301); |
|
| 491 | + | // Re-putting a packed object is a no-op; new objects go loose and a |
|
| 492 | + | // second pack coexists with the first. |
|
| 493 | + | assert_eq!(s.put(ObjectType::Chunk, refs[0].1.as_bytes()).unwrap(), refs[0].0); |
|
| 494 | + | assert_eq!(s.loose_count().unwrap(), 0); |
|
| 495 | + | let extra = s.put(ObjectType::Chunk, b"after packing").unwrap(); |
|
| 496 | + | s.pack_loose().unwrap(); |
|
| 497 | + | assert_eq!(s.pack_count(), 2); |
|
| 498 | + | assert_eq!(s.get(&extra).unwrap().1, b"after packing"); |
|
| 499 | + | // A reopened store sees the packs. |
|
| 500 | + | let s2 = Store::open(dir.path()).unwrap(); |
|
| 501 | + | assert_eq!(s2.pack_count(), 2); |
|
| 502 | + | assert_eq!(s2.get(&refs[5].0).unwrap().1, refs[5].1.as_bytes()); |
|
| 503 | + | assert!(matches!(s2.get(&ObjectRef([9; 32])), Err(StoreError::NotFound(_)))); |
|
| 504 | + | // Corrupting an object's body inside the pack is detected on read. |
|
| 505 | + | let target = refs[5].0; |
|
| 506 | + | let (pack_path, offset) = { |
|
| 507 | + | let packs = s2.packs.borrow(); |
|
| 508 | + | let (i, e) = s2.find_packed(&target).unwrap(); |
|
| 509 | + | (packs[i].path.clone(), e.offset as usize) |
|
| 510 | + | }; |
|
| 511 | + | let mut data = fs::read(&pack_path).unwrap(); |
|
| 512 | + | data[offset] ^= 0xff; |
|
| 513 | + | fs::write(&pack_path, data).unwrap(); |
|
| 514 | + | let s3 = Store::open(dir.path()).unwrap(); |
|
| 515 | + | assert!(matches!(s3.get(&target), Err(StoreError::Corrupt(..)))); |
|
| 516 | + | assert_eq!(s3.get(&refs[6].0).unwrap().1, refs[6].1.as_bytes(), "other objects are unaffected"); |
|
| 517 | + | } |
|
| 518 | + | ||
| 519 | + | #[test] |
|
| 260 | 520 | fn signatures_are_stored_beside_objects() { |
|
| 261 | 521 | use crate::identity::{Domain, SecretKey}; |
|
| 262 | 522 | let dir = tempfile::tempdir().unwrap(); |
|
| … | 25 unchanged lines |
modified docs/spec.md +14 −2
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.6 | |
|
| 5 | + | | **Status** | Draft 0.6a | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 498 unchanged lines |
||
| 507 | 507 | ||
| 508 | 508 | `oxy gc` (and an automatic trigger when loose object count exceeds 4096) writes loose objects into a new pack and merges packs when there are more than 16. Pack indexes MUST support lookup by ObjectRef prefix and MUST be memory-mappable. A repository with two million objects MUST be openable with under 64 MiB of resident index memory on the Full profile. |
|
| 509 | 509 | ||
| 510 | + | The pack and index formats are fixed so that tools can read them without the implementation: |
|
| 511 | + | ||
| 512 | + | ``` |
|
| 513 | + | pack: "OXYPACK1" || entry* |
|
| 514 | + | entry: type u8 || flags u8 || stored_len u32le || raw_len u32le || ref bytes(32) || body[stored_len] |
|
| 515 | + | index: "OXYIDX01" || count u32le || ( ref bytes(32) || offset u64le || stored_len u32le || raw_len u32le || type u8 || flags u8 )* |
|
| 516 | + | ``` |
|
| 517 | + | ||
| 518 | + | `type` indexes the object types in the order of §5.3 (chunk 0, blob 1, change 2, checkpoint 3, op 4, attest 5, identity 6, policy 7, state 8). Flag bit 0 means the body is zstd-compressed and `raw_len` is its decompressed size; otherwise the body is stored raw and the two lengths are equal. `offset` is the position of the body, not of the entry. Index entries are sorted by `ref`. A reader MUST re-hash every object it reads from a pack and reject a mismatch. |
|
| 519 | + | ||
| 510 | 520 | ### 13.5 Lazy objects |
|
| 511 | 521 | ||
| 512 | 522 | A blob or chunk may be **absent** in a partial repository (§17). The object store records absent refs it has been told about with the remote that promised them, and fetches on first read. Changes and checkpoints are never lazy: a repository that has a change has all of its inline data. |
|
| … | 311 unchanged lines |
||
| 824 | 834 | ||
| 825 | 835 | --- |
|
| 826 | 836 | ||
| 827 | - | *Changelog.* Draft 0.6 (2026-09-26): trust policy propagation over the wire, the `policy` signing domain, per-remote downgrade protection, the bootstrap rule for unverified remotes, and the rule that policies govern only changes added after them. |
|
| 837 | + | *Changelog.* Draft 0.6a (2026-09-26): §13.4 fixes the pack and index file formats. |
|
| 838 | + | ||
| 839 | + | Draft 0.6 (2026-09-26): trust policy propagation over the wire, the `policy` signing domain, per-remote downgrade protection, the bootstrap rule for unverified remotes, and the rule that policies govern only changes added after them. |
|
| 828 | 840 | ||
| 829 | 841 | Draft 0.5b (2026-09-26): §19 defines the user configuration file's keys, the precedence of repository over user over environment, and the key directory layout. |
|
| 830 | 842 | ||
| … | 13 unchanged lines |
modified src/cli/mod.rs +3 −0
| … | 49 unchanged lines |
||
| 50 | 50 | Resolve, |
|
| 51 | 51 | /// Undo the last operation |
|
| 52 | 52 | Undo, |
|
| 53 | + | /// Pack loose objects into a compressed pack |
|
| 54 | + | Gc, |
|
| 53 | 55 | /// List operations |
|
| 54 | 56 | #[command(name = "op")] |
|
| 55 | 57 | Op { |
|
| … | 120 unchanged lines |
||
| 176 | 178 | Cmd::Checkout { changes, discard } => workspace::checkout(&changes, discard), |
|
| 177 | 179 | Cmd::Resolve => workspace::resolve(), |
|
| 178 | 180 | Cmd::Undo => workspace::undo(), |
|
| 181 | + | Cmd::Gc => workspace::gc(), |
|
| 179 | 182 | Cmd::Op { cmd: OpCmd::Log } => workspace::op_log(), |
|
| 180 | 183 | Cmd::Clone { url, dest } => sync::clone(&url, dest), |
|
| 181 | 184 | Cmd::Remote { cmd } => match cmd { |
|
| … | 23 unchanged lines |
modified src/cli/workspace.rs +18 −0
| … | 182 unchanged lines |
||
| 183 | 183 | } |
|
| 184 | 184 | Ok(0) |
|
| 185 | 185 | } |
|
| 186 | + | ||
| 187 | + | pub fn gc() -> Result<i32> { |
|
| 188 | + | let repo = open()?; |
|
| 189 | + | let r = repo.gc()?; |
|
| 190 | + | if r.objects == 0 { |
|
| 191 | + | println!("Nothing to pack; {} pack(s) present.", repo.store().pack_count()); |
|
| 192 | + | } else { |
|
| 193 | + | println!( |
|
| 194 | + | "Packed {} into one pack: {} bytes -> {} bytes ({:.0}%); {} pack(s) present.", |
|
| 195 | + | plural(r.objects, "object"), |
|
| 196 | + | r.raw_bytes, |
|
| 197 | + | r.packed_bytes, |
|
| 198 | + | 100.0 * r.packed_bytes as f64 / r.raw_bytes.max(1) as f64, |
|
| 199 | + | repo.store().pack_count() |
|
| 200 | + | ); |
|
| 201 | + | } |
|
| 202 | + | Ok(0) |
|
| 203 | + | } |