OXYGENHUB

Strata: a repository kept on a store, as a remote 9f9e72b93e4e

A remote on any Store (docs/stores.md §4): oxygen.toml names the
repository, each push is one bundle under bundles/, and one refs
record (the protocol's refs response) moves by compare-and-swap. A
store cannot run code, so the client keeps a mirror, a bare repository
under .oxy/remotes/<name>/strata, brought level with the store before
every use, and does what a hub does against it: receive_push for the
policy, attestation and fast-forward checks, then the upload, then the
record. A record that moved meanwhile puts the mirror back and reports
the push as stale, as a hub would. Every object is re-hashed on import
and the fetch verifies each bookmark's attestation, so the store is
never trusted.

dir://PATH is a store in a directory, which the tests and the CLI use
with no network; other schemes come from openers a program registers
(the cloud stores, in their own crate). RemoteOptions carries where a
store remote keeps its mirror.

refs #63
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-03 21:40 UTC
Signature
✔ signed by Andy McCall
Hash
9f9e72b93e4ef9a6390152209ab1a77838ca360292de8992686aaf340985dc26
Depends on
14edbaaf5291 2b5ee371060e 5b9c6ad45f6e 750251b21a96 75167672ebcd 827281eed2ed 982864068a59 a07f60a37fb2 a70dce536476 ff1f62fee654
Depended on by
80b069facc69 1ec6fdbc6bcb d36ba2f79f95 767ac6cd0545 68a924846379 d069701a4997 d8d9367878a6 8d80356371c5
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.10.0 v0.11.0 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
24 atoms, 1 tree operation, 7 files

Browse the tree at this change

added crates/oxy-core/src/repo/strata.rs +869 −0
1 +
//! A repository kept on a [`Store`] (Oxygen Strata, docs/stores.md): what
2 +
//! `oxy push dir://…` or `oxy clone s3://…` talk to.
3 +
//!
4 +
//! The store holds three kinds of key:
5 +
//!
6 +
//! - `oxygen.toml`: the layout's format and the repository's id, written
7 +
//!   once by the first push;
8 +
//! - `bundles/<seq>-<hash>.bundle`: each push's changes, signatures,
9 +
//!   seals, objects and policies, as the wire protocol encodes a bundle,
10 +
//!   written once and named by content;
11 +
//! - `refs`: every bookmark with its heads and attestation, every tag, the
12 +
//!   policy and its history, encoded as the protocol's `refs` response,
13 +
//!   moved by compare-and-swap.
14 +
//!
15 +
//! A store cannot run code, so the client does what a hub does on a push,
16 +
//! against a **mirror**: a bare repository of its own, kept beside the
17 +
//! remote's other state under `.oxy/remotes/<name>/strata/`, brought level
18 +
//! with the store before every use (the record read, the bundles it has
19 +
//! not seen imported, the view set from the record), and then used as a
20 +
//! [`LocalRemote`] would be. A push runs the hub's own checks
21 +
//! (`receive_push`) on the mirror, uploads the bundle, and moves `refs`
22 +
//! from the version it read; if the record moved meanwhile, the mirror is
23 +
//! put back and the push reports it, exactly as a hub reports a stale
24 +
//! push. Nothing in the store is trusted: every object is re-hashed as it
25 +
//! is imported, and the fetch that follows verifies each bookmark's
26 +
//! attestation under the policy, as it does for any remote.
27 +
28 +
use super::{fail, Repo, RepoError, Result};
29 +
use crate::hash::{ChangeHash, ObjectRef, ObjectType};
30 +
use crate::objects::StateObject;
31 +
use crate::protocol::{
32 +
    wire, Bundle, RefResult, RefUpdate, RefsResponse, Remote, RemoteOptions, SignedPolicy, TagRef,
33 +
    UNBOUND_ID,
34 +
};
35 +
use crate::strata::{Error as StoreError, Store, Version};
36 +
use std::cell::RefCell;
37 +
use std::collections::BTreeSet;
38 +
use std::fs;
39 +
use std::path::{Path, PathBuf};
40 +
41 +
const MARKER_KEY: &str = "oxygen.toml";
42 +
const REFS_KEY: &str = "refs";
43 +
const BUNDLES_PREFIX: &str = "bundles/";
44 +
const FORMAT: u32 = 1;
45 +
46 +
impl From<StoreError> for RepoError {
47 +
    fn from(e: StoreError) -> RepoError {
48 +
        RepoError::Msg(e.to_string())
49 +
    }
50 +
}
51 +
52 +
/// The `oxygen.toml` marker: two lines, written once.
53 +
fn marker(id: [u8; 16]) -> Vec<u8> {
54 +
    format!("format = {FORMAT}\nrepo = \"{}\"\n", hex::encode(id)).into_bytes()
55 +
}
56 +
57 +
fn parse_marker(bytes: &[u8], what: &str) -> Result<[u8; 16]> {
58 +
    let text = String::from_utf8_lossy(bytes);
59 +
    let mut format = None;
60 +
    let mut repo = None;
61 +
    for line in text.lines() {
62 +
        let Some((k, v)) = line.split_once('=') else {
63 +
            continue;
64 +
        };
65 +
        let v = v.trim().trim_matches('"');
66 +
        match k.trim() {
67 +
            "format" => format = v.parse::<u32>().ok(),
68 +
            "repo" => {
69 +
                repo = hex::decode(v)
70 +
                    .ok()
71 +
                    .and_then(|b| <[u8; 16]>::try_from(b).ok())
72 +
            }
73 +
            _ => {}
74 +
        }
75 +
    }
76 +
    match (format, repo) {
77 +
        (Some(f), _) if f > FORMAT => fail(format!(
78 +
            "{what} holds a repository in Strata format {f}, newer than this oxy understands (format {FORMAT}): upgrade oxy"
79 +
        )),
80 +
        (Some(_), Some(id)) => Ok(id),
81 +
        _ => fail(format!("{what}: its oxygen.toml is not a Strata marker")),
82 +
    }
83 +
}
84 +
85 +
/// A remote kept on a store.
86 +
pub struct BucketRemote {
87 +
    store: Box<dyn Store>,
88 +
    /// Where the mirror lives: the remote's directory under `.oxy/remotes`,
89 +
    /// or a temporary directory when there is no repository yet (a clone),
90 +
    /// removed on drop.
91 +
    dir: PathBuf,
92 +
    temporary: bool,
93 +
    claimed_id: Option<[u8; 16]>,
94 +
    mirror: RefCell<Option<Repo>>,
95 +
}
96 +
97 +
impl Drop for BucketRemote {
98 +
    fn drop(&mut self) {
99 +
        if self.temporary {
100 +
            let _ = fs::remove_dir_all(&self.dir);
101 +
        }
102 +
    }
103 +
}
104 +
105 +
/// What a sync found: the record's version (to move it from) and the
106 +
/// repository id the store holds, if any.
107 +
struct Synced {
108 +
    version: Option<Version>,
109 +
    id: Option<[u8; 16]>,
110 +
}
111 +
112 +
impl BucketRemote {
113 +
    pub fn open(store: Box<dyn Store>, options: &RemoteOptions) -> Result<BucketRemote> {
114 +
        let (dir, temporary) = match &options.cache_dir {
115 +
            Some(d) => (d.clone(), false),
116 +
            None => {
117 +
                let mut nonce = [0u8; 6];
118 +
                rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce);
119 +
                (
120 +
                    std::env::temp_dir().join(format!(
121 +
                        "oxy-strata-{}-{}",
122 +
                        std::process::id(),
123 +
                        hex::encode(nonce)
124 +
                    )),
125 +
                    true,
126 +
                )
127 +
            }
128 +
        };
129 +
        Ok(BucketRemote {
130 +
            store,
131 +
            dir,
132 +
            temporary,
133 +
            claimed_id: options.repo_id,
134 +
            mirror: RefCell::new(None),
135 +
        })
136 +
    }
137 +
138 +
    fn what(&self) -> String {
139 +
        self.store.describe()
140 +
    }
141 +
142 +
    /// The repository id the store holds: `None` until a first push.
143 +
    fn stored_id(&self) -> Result<Option<[u8; 16]>> {
144 +
        match self.store.get(MARKER_KEY) {
145 +
            Ok(b) => Ok(Some(parse_marker(&b, &self.what())?)),
146 +
            Err(StoreError::NotFound(_)) => Ok(None),
147 +
            Err(e) => Err(e.into()),
148 +
        }
149 +
    }
150 +
151 +
    fn mirror_dir(&self) -> PathBuf {
152 +
        self.dir.join("mirror")
153 +
    }
154 +
155 +
    fn seen_path(&self) -> PathBuf {
156 +
        self.dir.join("seen")
157 +
    }
158 +
159 +
    /// The bundles already imported into the mirror.
160 +
    fn seen(&self) -> BTreeSet<String> {
161 +
        fs::read_to_string(self.seen_path())
162 +
            .map(|s| s.lines().map(str::to_string).collect())
163 +
            .unwrap_or_default()
164 +
    }
165 +
166 +
    fn mark_seen(&self, key: &str) -> Result<()> {
167 +
        let mut seen = self.seen();
168 +
        if seen.insert(key.to_string()) {
169 +
            let text: String = seen.iter().map(|k| format!("{k}\n")).collect();
170 +
            crate::durable::write_atomic(&self.seen_path(), text.as_bytes())?;
171 +
        }
172 +
        Ok(())
173 +
    }
174 +
175 +
    /// Open the mirror for repository `id`, creating it on first use.
176 +
    fn open_mirror(&self, id: [u8; 16]) -> Result<()> {
177 +
        if self.mirror.borrow().is_some() {
178 +
            return Ok(());
179 +
        }
180 +
        let dir = self.mirror_dir();
181 +
        let repo = if dir.join(super::OXY_DIR).is_dir() {
182 +
            let repo = Repo::open(&dir)?;
183 +
            if repo.id()? != id {
184 +
                // The store was emptied and reused for another repository:
185 +
                // start the mirror over.
186 +
                drop(repo);
187 +
                fs::remove_dir_all(&self.dir)?;
188 +
                fs::create_dir_all(&self.dir)?;
189 +
                Repo::init_bare_with_id(&dir, id)?
190 +
            } else {
191 +
                repo
192 +
            }
193 +
        } else {
194 +
            fs::create_dir_all(&self.dir)?;
195 +
            Repo::init_bare_with_id(&dir, id)?
196 +
        };
197 +
        *self.mirror.borrow_mut() = Some(repo);
198 +
        Ok(())
199 +
    }
200 +
201 +
    fn with_mirror<T>(&self, f: impl FnOnce(&Repo) -> Result<T>) -> Result<T> {
202 +
        let m = self.mirror.borrow();
203 +
        match m.as_ref() {
204 +
            Some(repo) => f(repo),
205 +
            None => fail(format!(
206 +
                "{} holds no repository yet: push one to it first",
207 +
                self.what()
208 +
            )),
209 +
        }
210 +
    }
211 +
212 +
    /// Bring the mirror level with the store.
213 +
    fn sync(&self) -> Result<Synced> {
214 +
        let Some(id) = self.stored_id()? else {
215 +
            return Ok(Synced {
216 +
                version: None,
217 +
                id: None,
218 +
            });
219 +
        };
220 +
        self.open_mirror(id)?;
221 +
        let record = self.store.read_ref(REFS_KEY)?;
222 +
        let (resp, version) = match record {
223 +
            Some((bytes, version)) => {
224 +
                let (rid, resp) = wire::decode_refs(&bytes)?;
225 +
                if rid != id {
226 +
                    return fail(format!(
227 +
                        "{}: its refs record names a different repository than its oxygen.toml; the store is damaged",
228 +
                        self.what()
229 +
                    ));
230 +
                }
231 +
                (resp, Some(version))
232 +
            }
233 +
            None => (RefsResponse::default(), None),
234 +
        };
235 +
        self.with_mirror(|mirror| {
236 +
            // The policy first: bundles are then imported under it.
237 +
            if let Some(sp) = &resp.policy {
238 +
                let have = mirror.current_policy()?.map(|(_, p)| p.policy.serial);
239 +
                if have.is_none_or(|s| s < sp.policy.serial) {
240 +
                    mirror
241 +
                        .adopt_policy_chain(&resp.history, sp, "strata sync")
242 +
                        .map_err(|e| RepoError::Msg(format!("policy from {}: {e}", self.what())))?;
243 +
                }
244 +
            }
245 +
            self.import_new_bundles(mirror)?;
246 +
            self.set_view(mirror, &resp)?;
247 +
            Ok(())
248 +
        })?;
249 +
        Ok(Synced {
250 +
            version,
251 +
            id: Some(id),
252 +
        })
253 +
    }
254 +
255 +
    /// Import every bundle in the store the mirror has not seen. Bundles
256 +
    /// are named in the order they were pushed, but two pushes from the
257 +
    /// same base may interleave, so a bundle whose dependencies are not
258 +
    /// there yet is tried again after the others.
259 +
    fn import_new_bundles(&self, mirror: &Repo) -> Result<()> {
260 +
        let seen = self.seen();
261 +
        let mut pending: Vec<String> = self
262 +
            .store
263 +
            .list(BUNDLES_PREFIX)?
264 +
            .into_iter()
265 +
            .map(|e| e.key)
266 +
            .filter(|k| !seen.contains(k))
267 +
            .collect();
268 +
        let strict = mirror.current_policy()?.is_some();
269 +
        while !pending.is_empty() {
270 +
            let mut deferred = Vec::new();
271 +
            let mut last_error = None;
272 +
            for key in pending.drain(..) {
273 +
                let bytes = self.store.get(&key)?;
274 +
                let bundle = wire::decode_bundle(&bytes)
275 +
                    .map_err(|e| RepoError::Msg(format!("{}: {key}: {e}", self.what())))?;
276 +
                match mirror.import_bundle(&bundle, strict) {
277 +
                    Ok(_) => self.mark_seen(&key)?,
278 +
                    Err(RepoError::Msg(m)) if m.contains("neither stored nor in the bundle") => {
279 +
                        last_error = Some(m);
280 +
                        deferred.push(key);
281 +
                    }
282 +
                    Err(e) => return Err(RepoError::Msg(format!("{}: {key}: {e}", self.what()))),
283 +
                }
284 +
            }
285 +
            if deferred.len() == pending.len() + deferred.len() && !deferred.is_empty() {
286 +
                // No progress: something those bundles need was never
287 +
                // pushed, or a push is still in progress.
288 +
                return fail(format!(
289 +
                    "{}: {} cannot be imported ({}); a push may be in progress, so try again, else the store is missing changes",
290 +
                    self.what(),
291 +
                    deferred.join(", "),
292 +
                    last_error.unwrap_or_default()
293 +
                ));
294 +
            }
295 +
            pending = deferred;
296 +
        }
297 +
        Ok(())
298 +
    }
299 +
300 +
    /// Make the mirror's view say what the record says.
301 +
    fn set_view(&self, mirror: &Repo, resp: &RefsResponse) -> Result<()> {
302 +
        let (op_ref, op) = mirror.head_op()?;
303 +
        let mut view = op.view.clone();
304 +
        view.bookmarks.clear();
305 +
        for r in &resp.refs {
306 +
            for h in &r.heads {
307 +
                if !mirror.store.has(h) {
308 +
                    return fail(format!(
309 +
                        "{}: bookmark {:?} names change {}, which no bundle there holds; a push may be in progress, so try again",
310 +
                        self.what(),
311 +
                        r.name,
312 +
                        h.short(12)
313 +
                    ));
314 +
                }
315 +
            }
316 +
            let state = mirror.store.put_state(&StateObject::new(r.heads.clone()))?;
317 +
            if state != r.state {
318 +
                return fail(format!(
319 +
                    "{}: bookmark {:?} names a state that does not match its heads; the store is damaged",
320 +
                    self.what(),
321 +
                    r.name
322 +
                ));
323 +
            }
324 +
            if let Some(sa) = &r.attestation {
325 +
                let current = mirror.latest_ref_attest(&r.name)?.map(|(_, a)| a);
326 +
                if current.as_ref() != Some(sa) {
327 +
                    let a = mirror.store.put_attest(&sa.attest)?;
328 +
                    for sig in &sa.sigs {
329 +
                        mirror.store.put_signature(&a, sig)?;
330 +
                    }
331 +
                    mirror.set_ref_attest(&r.name, &a)?;
332 +
                }
333 +
            }
334 +
            view.bookmarks.insert(r.name.clone(), state);
335 +
        }
336 +
        let policy = mirror.current_policy()?.map(|(_, sp)| sp.policy);
337 +
        for t in &resp.tags {
338 +
            mirror.store.put_state(&StateObject::new(t.heads.clone()))?;
339 +
            mirror
340 +
                .adopt_tag(&mut view, t, policy.as_ref())
341 +
                .map_err(|e| RepoError::Msg(format!("{}: {e}", self.what())))?;
342 +
        }
343 +
        if view != op.view {
344 +
            mirror.record_op(&[op_ref], "strata sync", view)?;
345 +
        }
346 +
        Ok(())
347 +
    }
348 +
349 +
    /// The record for the mirror's current references.
350 +
    fn record(&self, id: [u8; 16]) -> Result<Vec<u8>> {
351 +
        self.with_mirror(|mirror| {
352 +
            let mut resp = mirror.remote_refs()?;
353 +
            // A mirror has no checkout; the store's clone picks a bookmark
354 +
            // as any other remote's does.
355 +
            resp.default_bookmark = None;
356 +
            Ok(wire::encode_refs(id, &resp))
357 +
        })
358 +
    }
359 +
360 +
    /// Store a bundle under the next sequence number; returns its key.
361 +
    fn upload(&self, bundle: &Bundle) -> Result<String> {
362 +
        let bytes = wire::encode_bundle(bundle);
363 +
        let hash = blake3::hash(&bytes).to_hex();
364 +
        let seq = self.store.list(BUNDLES_PREFIX)?.len() + 1;
365 +
        let key = format!("{BUNDLES_PREFIX}{seq:08}-{}.bundle", &hash[..16]);
366 +
        match self.store.put_new(&key, &bytes) {
367 +
            Ok(()) | Err(StoreError::Exists(_)) => {}
368 +
            Err(e) => return Err(e.into()),
369 +
        }
370 +
        self.mark_seen(&key)?;
371 +
        Ok(key)
372 +
    }
373 +
}
374 +
375 +
impl Remote for BucketRemote {
376 +
    fn repo_id(&self) -> Result<[u8; 16]> {
377 +
        Ok(self.stored_id()?.unwrap_or(UNBOUND_ID))
378 +
    }
379 +
380 +
    fn refs(&self) -> Result<RefsResponse> {
381 +
        let synced = self.sync()?;
382 +
        if synced.id.is_none() {
383 +
            return Ok(RefsResponse::default());
384 +
        }
385 +
        self.with_mirror(|mirror| {
386 +
            let mut resp = mirror.remote_refs()?;
387 +
            resp.default_bookmark = None;
388 +
            Ok(resp)
389 +
        })
390 +
    }
391 +
392 +
    fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
393 +
        self.with_mirror(|m| m.negotiate(have, want))
394 +
    }
395 +
396 +
    fn fetch(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle> {
397 +
        self.with_mirror(|m| m.bundle_raw_for(changes, max_blob))
398 +
    }
399 +
400 +
    fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> {
401 +
        self.with_mirror(|m| m.objects_for(refs))
402 +
    }
403 +
404 +
    fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle> {
405 +
        self.with_mirror(|m| m.checkpoint_bundle_for(heads))
406 +
    }
407 +
408 +
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> {
409 +
        self.with_mirror(|m| m.shallow_changes(heads, depth))
410 +
    }
411 +
412 +
    fn push(
413 +
        &self,
414 +
        bundle: Bundle,
415 +
        updates: Vec<RefUpdate>,
416 +
        policy: Option<SignedPolicy>,
417 +
        tags: Vec<TagRef>,
418 +
    ) -> Result<Vec<RefResult>> {
419 +
        let mut synced = self.sync()?;
420 +
        let id = match synced.id {
421 +
            Some(id) => id,
422 +
            None => {
423 +
                // The first push binds the store to this repository. Two
424 +
                // first pushes race on the marker; the loser reads the
425 +
                // winner's and goes on only if it is the same repository.
426 +
                let Some(id) = self.claimed_id else {
427 +
                    return fail(format!(
428 +
                        "{} is empty, and this client did not say which repository it is",
429 +
                        self.what()
430 +
                    ));
431 +
                };
432 +
                match self.store.put_new(MARKER_KEY, &marker(id)) {
433 +
                    Ok(()) => {}
434 +
                    Err(StoreError::Exists(_)) => {}
435 +
                    Err(e) => return Err(e.into()),
436 +
                }
437 +
                synced = self.sync()?;
438 +
                match synced.id {
439 +
                    Some(stored) if stored == id => id,
440 +
                    _ => {
441 +
                        return fail(format!(
442 +
                            "{} was just bound to another repository by a concurrent push",
443 +
                            self.what()
444 +
                        ))
445 +
                    }
446 +
                }
447 +
            }
448 +
        };
449 +
        if let Some(claimed) = self.claimed_id {
450 +
            if claimed != id {
451 +
                return fail(format!(
452 +
                    "{} holds a different repository (id {}) than this one ({}): a store holds one repository; use another prefix or bucket",
453 +
                    self.what(),
454 +
                    hex::encode(id),
455 +
                    hex::encode(claimed)
456 +
                ));
457 +
            }
458 +
        }
459 +
        let (before_ref, before) = self.with_mirror(|m| m.head_op())?;
460 +
        let results =
461 +
            self.with_mirror(|m| m.receive_push(bundle.clone(), updates, policy, tags))?;
462 +
        if !results.iter().any(|r| r.ok) {
463 +
            return Ok(results);
464 +
        }
465 +
        if !bundle.is_empty() {
466 +
            self.upload(&bundle)?;
467 +
        }
468 +
        let record = self.record(id)?;
469 +
        match self
470 +
            .store
471 +
            .write_ref(REFS_KEY, &record, synced.version.as_ref())
472 +
        {
473 +
            Ok(_) => Ok(results),
474 +
            Err(StoreError::Conflict(_)) => {
475 +
                // Someone else moved the record meanwhile. The bundle is in
476 +
                // the store and harmless; the mirror goes back to what the
477 +
                // store says, and the push is reported as a hub would.
478 +
                self.with_mirror(|m| {
479 +
                    let (now, _) = m.head_op()?;
480 +
                    if now != before_ref {
481 +
                        m.record_op(
482 +
                            &[now],
483 +
                            "strata push undone: the remote moved",
484 +
                            before.view.clone(),
485 +
                        )?;
486 +
                    }
487 +
                    Ok(())
488 +
                })?;
489 +
                let _ = self.sync();
490 +
                Ok(results
491 +
                    .into_iter()
492 +
                    .map(|r| RefResult {
493 +
                        name: r.name,
494 +
                        ok: false,
495 +
                        reason: "the remote moved while pushing (another push landed first); pull, then push again".into(),
496 +
                    })
497 +
                    .collect())
498 +
            }
499 +
            Err(e) => Err(e.into()),
500 +
        }
501 +
    }
502 +
}
503 +
504 +
/// Open a remote on `store` for the local repository described by `options`.
505 +
pub fn open_store_remote(
506 +
    store: Box<dyn Store>,
507 +
    options: &RemoteOptions,
508 +
) -> Result<Box<dyn Remote>> {
509 +
    Ok(Box::new(BucketRemote::open(store, options)?))
510 +
}
511 +
512 +
/// Whether `path` holds a Strata store rather than a repository.
513 +
pub fn is_store_dir(path: &Path) -> bool {
514 +
    path.join(MARKER_KEY).is_file() || path.join(REFS_KEY).is_file()
515 +
}
516 +
517 +
#[cfg(test)]
518 +
mod tests {
519 +
    use super::*;
520 +
    use crate::repo::IdentityScope;
521 +
    use crate::strata::{Entry, LocalStore, MemoryStore};
522 +
    use std::ops::Range;
523 +
    use std::sync::Mutex;
524 +
525 +
    fn hermetic() {
526 +
        static ONCE: std::sync::Once = std::sync::Once::new();
527 +
        static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
528 +
        ONCE.call_once(|| {
529 +
            let d = tempfile::tempdir().unwrap();
530 +
            std::env::set_var("OXY_HOME", d.path());
531 +
            DIR.set(d).ok();
532 +
        });
533 +
    }
534 +
535 +
    fn write(root: &Path, rel: &str, content: &[u8]) {
536 +
        let p = root.join(rel);
537 +
        fs::create_dir_all(p.parent().unwrap()).unwrap();
538 +
        fs::write(p, content).unwrap();
539 +
    }
540 +
541 +
    fn add_remote(repo: &mut Repo, name: &str, url: &str) {
542 +
        repo.config.set(&format!("remote.{name}.url"), url).unwrap();
543 +
        repo.save_config().unwrap();
544 +
    }
545 +
546 +
    fn keys(store: &dyn Store, prefix: &str) -> Vec<String> {
547 +
        store
548 +
            .list(prefix)
549 +
            .unwrap()
550 +
            .into_iter()
551 +
            .map(|e| e.key)
552 +
            .collect()
553 +
    }
554 +
555 +
    #[test]
556 +
    fn push_clone_and_pull_through_a_directory_store() {
557 +
        hermetic();
558 +
        let dir = tempfile::tempdir().unwrap();
559 +
        let root = dir.path();
560 +
        let url = format!("dir://{}", root.join("bucket").display());
561 +
        let store = LocalStore::open(root.join("bucket")).unwrap();
562 +
563 +
        // A signs, commits, tags and pushes: the store gets the marker, one
564 +
        // bundle, and a record.
565 +
        let mut a = Repo::init(&root.join("a"))
566 +
            .unwrap()
567 +
            .with_home(root.join("home-a"));
568 +
        a.create_identity("Ada", "[email protected]", IdentityScope::Repo)
569 +
            .unwrap();
570 +
        add_remote(&mut a, "bucket", &url);
571 +
        write(&root.join("a"), "f.txt", b"a\n");
572 +
        let h1 = a.commit("first", &[]).unwrap();
573 +
        a.tag_create("v1", &[h1], "one").unwrap();
574 +
        let results = a.push("bucket", false).unwrap();
575 +
        assert!(results.iter().all(|r| r.ok), "{results:?}");
576 +
        assert_eq!(
577 +
            keys(&store, ""),
578 +
            [
579 +
                "bundles/00000001-".to_string() + &keys(&store, "bundles/")[0][17..],
580 +
                "oxygen.toml".into(),
581 +
                "refs".into()
582 +
            ]
583 +
        );
584 +
        assert_eq!(
585 +
            parse_marker(&store.get("oxygen.toml").unwrap(), "t").unwrap(),
586 +
            a.id().unwrap()
587 +
        );
588 +
        let again = a.push("bucket", false).unwrap();
589 +
        assert!(
590 +
            again[0].ok && again[0].reason.contains("up to date"),
591 +
            "{again:?}"
592 +
        );
593 +
        assert_eq!(
594 +
            keys(&store, "bundles/").len(),
595 +
            1,
596 +
            "a no-op push uploads nothing"
597 +
        );
598 +
599 +
        // B clones from the store: same id, same file, the tag, and the
600 +
        // seal on the change.
601 +
        let b = Repo::clone(&url, &root.join("b")).unwrap();
602 +
        assert_eq!(b.id().unwrap(), a.id().unwrap());
603 +
        assert_eq!(fs::read(root.join("b/f.txt")).unwrap(), b"a\n");
604 +
        assert_eq!(b.bookmark_heads().unwrap(), vec![h1]);
605 +
        assert_eq!(
606 +
            b.tags()
607 +
                .unwrap()
608 +
                .iter()
609 +
                .map(|t| t.name.clone())
610 +
                .collect::<Vec<_>>(),
611 +
            ["v1"]
612 +
        );
613 +
        assert!(matches!(
614 +
            b.authenticate_change(&h1).unwrap(),
615 +
            crate::repo::SignatureStatus::Valid { sealed: true, .. }
616 +
        ));
617 +
        assert!(
618 +
            root.join("b/.oxy/remotes/origin/strata/mirror/.oxy")
619 +
                .is_dir(),
620 +
            "the mirror lives with the remote's state"
621 +
        );
622 +
        assert!(
623 +
            !std::env::temp_dir()
624 +
                .read_dir()
625 +
                .unwrap()
626 +
                .flatten()
627 +
                .any(|e| e.file_name().to_string_lossy().starts_with("oxy-strata-")),
628 +
            "a clone's temporary mirror is removed"
629 +
        );
630 +
631 +
        // B commits and pushes; A pulls it. A then commits and pushes.
632 +
        write(&root.join("b"), "f.txt", b"a\nb\n");
633 +
        let h2 = b.commit("second", &[]).unwrap();
634 +
        assert!(b.push("origin", false).unwrap()[0].ok);
635 +
        assert_eq!(keys(&store, "bundles/").len(), 2);
636 +
        let pr = a.pull("bucket").unwrap();
637 +
        assert!(pr.changed && pr.checked_out);
638 +
        assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nb\n");
639 +
        write(&root.join("a"), "g.txt", b"g\n");
640 +
        let h3 = a.commit("third", &[]).unwrap();
641 +
        assert!(a.push("bucket", false).unwrap()[0].ok);
642 +
        let pr = b.pull("origin").unwrap();
643 +
        assert!(pr.changed);
644 +
        // g.txt is new, so its change commutes with B's: two heads.
645 +
        let mut want = vec![h2, h3];
646 +
        want.sort();
647 +
        assert_eq!(b.bookmark_heads().unwrap(), want);
648 +
        assert_eq!(fs::read(root.join("b/g.txt")).unwrap(), b"g\n");
649 +
650 +
        // A third clone gets everything from the three bundles.
651 +
        let c = Repo::clone(&url, &root.join("c")).unwrap();
652 +
        assert_eq!(c.bookmark_heads().unwrap(), want);
653 +
        assert_eq!(fs::read(root.join("c/f.txt")).unwrap(), b"a\nb\n");
654 +
    }
655 +
656 +
    #[test]
657 +
    fn a_push_behind_the_remote_is_refused_before_anything_is_uploaded() {
658 +
        hermetic();
659 +
        let dir = tempfile::tempdir().unwrap();
660 +
        let root = dir.path();
661 +
        let url = format!("dir://{}", root.join("bucket").display());
662 +
        let store = LocalStore::open(root.join("bucket")).unwrap();
663 +
        let mut a = Repo::init(&root.join("a")).unwrap();
664 +
        add_remote(&mut a, "bucket", &url);
665 +
        write(&root.join("a"), "f.txt", b"a\n");
666 +
        a.commit("first", &[]).unwrap();
667 +
        assert!(a.push("bucket", false).unwrap()[0].ok);
668 +
        let b = Repo::clone(&url, &root.join("b")).unwrap();
669 +
        write(&root.join("b"), "f.txt", b"b\n");
670 +
        b.commit("from b", &[]).unwrap();
671 +
        assert!(b.push("origin", false).unwrap()[0].ok);
672 +
        write(&root.join("a"), "g.txt", b"g\n");
673 +
        a.commit("from a", &[]).unwrap();
674 +
        let e = a.push("bucket", false).unwrap_err().to_string();
675 +
        assert!(e.contains("pull first"), "{e}");
676 +
        assert_eq!(keys(&store, "bundles/").len(), 2);
677 +
        assert!(a.pull("bucket").unwrap().changed);
678 +
        assert!(a.push("bucket", false).unwrap()[0].ok);
679 +
        assert_eq!(keys(&store, "bundles/").len(), 3);
680 +
    }
681 +
682 +
    /// A store whose record moves under a writer the first time it is
683 +
    /// written to: what a concurrent push looks like between the read and
684 +
    /// the compare-and-swap.
685 +
    struct Racing {
686 +
        inner: MemoryStore,
687 +
        raced: Mutex<bool>,
688 +
    }
689 +
690 +
    impl Store for Racing {
691 +
        fn describe(&self) -> String {
692 +
            "racing".into()
693 +
        }
694 +
        fn has(&self, key: &str) -> crate::strata::Result<bool> {
695 +
            self.inner.has(key)
696 +
        }
697 +
        fn get(&self, key: &str) -> crate::strata::Result<Vec<u8>> {
698 +
            self.inner.get(key)
699 +
        }
700 +
        fn get_range(&self, key: &str, range: Range<u64>) -> crate::strata::Result<Vec<u8>> {
701 +
            self.inner.get_range(key, range)
702 +
        }
703 +
        fn put_new(&self, key: &str, bytes: &[u8]) -> crate::strata::Result<()> {
704 +
            self.inner.put_new(key, bytes)
705 +
        }
706 +
        fn list(&self, prefix: &str) -> crate::strata::Result<Vec<Entry>> {
707 +
            self.inner.list(prefix)
708 +
        }
709 +
        fn delete(&self, key: &str) -> crate::strata::Result<()> {
710 +
            self.inner.delete(key)
711 +
        }
712 +
        fn read_ref(&self, key: &str) -> crate::strata::Result<Option<(Vec<u8>, Version)>> {
713 +
            self.inner.read_ref(key)
714 +
        }
715 +
        fn write_ref(
716 +
            &self,
717 +
            key: &str,
718 +
            bytes: &[u8],
719 +
            expected: Option<&Version>,
720 +
        ) -> crate::strata::Result<Version> {
721 +
            let mut raced = self.raced.lock().unwrap();
722 +
            if !*raced && expected.is_some() {
723 +
                *raced = true;
724 +
                // Someone else lands a record: the same references with a
725 +
                // different detail, so the version moves.
726 +
                let (current, v) = self.inner.read_ref(key)?.unwrap();
727 +
                let (id, mut resp) =
728 +
                    wire::decode_refs(&current).map_err(|e| StoreError::Backend(e.to_string()))?;
729 +
                resp.default_bookmark = Some("raced".into());
730 +
                self.inner
731 +
                    .write_ref(key, &wire::encode_refs(id, &resp), Some(&v))?;
732 +
            }
733 +
            self.inner.write_ref(key, bytes, expected)
734 +
        }
735 +
    }
736 +
737 +
    #[test]
738 +
    fn a_record_moved_during_a_push_fails_the_push_and_keeps_the_mirror_honest() {
739 +
        hermetic();
740 +
        let dir = tempfile::tempdir().unwrap();
741 +
        let root = dir.path();
742 +
        let a = Repo::init(&root.join("a")).unwrap();
743 +
        write(&root.join("a"), "f.txt", b"a\n");
744 +
        let h1 = a.commit("first", &[]).unwrap();
745 +
        let store = Box::new(Racing {
746 +
            inner: MemoryStore::new(),
747 +
            raced: Mutex::new(false),
748 +
        });
749 +
        let options = RemoteOptions {
750 +
            repo_id: Some(a.id().unwrap()),
751 +
            cache_dir: Some(root.join("cache")),
752 +
            ..Default::default()
753 +
        };
754 +
        let remote = BucketRemote::open(store, &options).unwrap();
755 +
        // The first push creates the record (expected None: no race yet).
756 +
        let state = a.store.put_state(&StateObject::new(vec![h1])).unwrap();
757 +
        let empty = a.store.put_state(&StateObject::new(vec![])).unwrap();
758 +
        let mut bundle = a.bundle_for(&[h1]).unwrap();
759 +
        bundle
760 +
            .objects
761 +
            .push((ObjectType::State, StateObject::new(vec![h1]).encode()));
762 +
        let update = |old, new| RefUpdate {
763 +
            name: "main".into(),
764 +
            old_state: old,
765 +
            new_state: new,
766 +
            force: false,
767 +
            attestation: None,
768 +
        };
769 +
        let r = remote
770 +
            .push(bundle, vec![update(empty, state)], None, vec![])
771 +
            .unwrap();
772 +
        assert!(r[0].ok, "{r:?}");
773 +
        // The second push meets the race: refused, nothing moved.
774 +
        write(&root.join("a"), "f.txt", b"ab\n");
775 +
        let h2 = a.commit("second", &[]).unwrap();
776 +
        let state2 = a.store.put_state(&StateObject::new(vec![h2])).unwrap();
777 +
        let mut bundle = a.bundle_for(&[h2]).unwrap();
778 +
        bundle
779 +
            .objects
780 +
            .push((ObjectType::State, StateObject::new(vec![h2]).encode()));
781 +
        let r = remote
782 +
            .push(bundle, vec![update(state, state2)], None, vec![])
783 +
            .unwrap();
784 +
        assert!(
785 +
            !r[0].ok && r[0].reason.contains("moved while pushing"),
786 +
            "{r:?}"
787 +
        );
788 +
        let refs = remote.refs().unwrap();
789 +
        assert_eq!(
790 +
            refs.refs[0].heads,
791 +
            vec![h1],
792 +
            "the mirror says what the store says"
793 +
        );
794 +
    }
795 +
796 +
    #[test]
797 +
    fn a_store_holds_one_repository() {
798 +
        hermetic();
799 +
        let dir = tempfile::tempdir().unwrap();
800 +
        let root = dir.path();
801 +
        let url = format!("dir://{}", root.join("bucket").display());
802 +
        let mut a = Repo::init(&root.join("a")).unwrap();
803 +
        add_remote(&mut a, "bucket", &url);
804 +
        write(&root.join("a"), "f.txt", b"a\n");
805 +
        a.commit("first", &[]).unwrap();
806 +
        assert!(a.push("bucket", false).unwrap()[0].ok);
807 +
        let mut other = Repo::init(&root.join("other")).unwrap();
808 +
        add_remote(&mut other, "bucket", &url);
809 +
        write(&root.join("other"), "f.txt", b"o\n");
810 +
        other.commit("mine", &[]).unwrap();
811 +
        let e = other.push("bucket", false).unwrap_err().to_string();
812 +
        assert!(
813 +
            e.contains("different repository") || e.contains("another repository"),
814 +
            "{e}"
815 +
        );
816 +
        assert_eq!(
817 +
            keys(&LocalStore::open(root.join("bucket")).unwrap(), "bundles/").len(),
818 +
            1
819 +
        );
820 +
    }
821 +
822 +
    #[test]
823 +
    fn a_damaged_bundle_is_caught_and_an_empty_store_cannot_be_cloned() {
824 +
        hermetic();
825 +
        let dir = tempfile::tempdir().unwrap();
826 +
        let root = dir.path();
827 +
        let url = format!("dir://{}", root.join("bucket").display());
828 +
        let e = Repo::clone(&url, &root.join("nothing"))
829 +
            .err()
830 +
            .expect("refused")
831 +
            .to_string();
832 +
        assert!(e.contains("empty and unbound"), "{e}");
833 +
        let mut a = Repo::init(&root.join("a")).unwrap();
834 +
        add_remote(&mut a, "bucket", &url);
835 +
        write(&root.join("a"), "f.txt", b"a\n");
836 +
        a.commit("first", &[]).unwrap();
837 +
        assert!(a.push("bucket", false).unwrap()[0].ok);
838 +
        let store = LocalStore::open(root.join("bucket")).unwrap();
839 +
        let key = keys(&store, "bundles/").remove(0);
840 +
        let mut bytes = store.get(&key).unwrap();
841 +
        let mid = bytes.len() / 2;
842 +
        bytes[mid] ^= 0xff;
843 +
        store.delete(&key).unwrap();
844 +
        store.put_new(&key, &bytes).unwrap();
845 +
        let e = Repo::clone(&url, &root.join("b"))
846 +
            .err()
847 +
            .expect("refused")
848 +
            .to_string();
849 +
        assert!(
850 +
            e.contains(&key) || e.contains("bundle") || e.contains("hash"),
851 +
            "{e}"
852 +
        );
853 +
        assert!(!root.join("b/f.txt").exists());
854 +
    }
855 +
856 +
    #[test]
857 +
    fn markers_parse_and_refuse_newer_formats() {
858 +
        let id = [7u8; 16];
859 +
        assert_eq!(parse_marker(&marker(id), "s").unwrap(), id);
860 +
        let e = parse_marker(
861 +
            b"format = 9\nrepo = \"07070707070707070707070707070707\"\n",
862 +
            "s",
863 +
        )
864 +
        .unwrap_err()
865 +
        .to_string();
866 +
        assert!(e.contains("format 9"), "{e}");
867 +
        assert!(parse_marker(b"hello", "s").is_err());
868 +
    }
869 +
}
modified crates/oxy-core/src/credentials.rs +1 −0
…
334 unchanged lines
335 335
            token: env_token.or(token),
336 336
            ca: env_ca.or(ca),
337 337
            repo_id: None,
338 +
            cache_dir: None,
338 339
        }
339 340
    }
340 341
}
…
236 unchanged lines
modified crates/oxy-core/src/protocol.rs +45 −5
…
13 unchanged lines
14 14
use crate::policy::Policy;
15 15
use crate::repo::{Repo, RepoError, Result};
16 16
use std::path::{Path, PathBuf};
17 -
use std::sync::Arc;
17 +
use std::sync::{Arc, Mutex};
18 18
19 19
pub const CONTENT_TYPE: &str = "application/oxy-cbor";
20 20
pub const PROTOCOL_VERSION: u64 = 1;
…
204 unchanged lines
225 225
    /// The local repository's id, sent as `Oxy-Repo-Id` so an unbound
226 226
    /// hosted repository can adopt it on the first push (spec §16.1).
227 227
    pub repo_id: Option<[u8; 16]>,
228 +
    /// Where a remote kept on a store (`dir://`, `s3://`, …) keeps its
229 +
    /// mirror and what it has seen: the remote's directory under
230 +
    /// `.oxy/remotes`. None (a clone, before there is a repository) means
231 +
    /// a temporary directory.
232 +
    pub cache_dir: Option<PathBuf>,
228 233
}
229 234
235 +
/// Opens a store for a URL it understands, or passes (`None`). Stores that
236 +
/// need more than the standard library (the cloud ones) are added by the
237 +
/// program that links them, with [`register_store_opener`].
238 +
pub type StoreOpener =
239 +
    fn(&str, &RemoteOptions) -> Option<crate::strata::Result<Box<dyn crate::strata::Store>>>;
240 +
241 +
static STORE_OPENERS: Mutex<Vec<StoreOpener>> = Mutex::new(Vec::new());
242 +
243 +
pub fn register_store_opener(opener: StoreOpener) {
244 +
    STORE_OPENERS
245 +
        .lock()
246 +
        .unwrap_or_else(|p| p.into_inner())
247 +
        .push(opener);
248 +
}
249 +
250 +
/// A store for `url`, from `dir://` or any registered opener.
251 +
pub fn open_store(
252 +
    url: &str,
253 +
    options: &RemoteOptions,
254 +
) -> Option<crate::strata::Result<Box<dyn crate::strata::Store>>> {
255 +
    if let Some(path) = url.strip_prefix("dir://") {
256 +
        return Some(
257 +
            crate::strata::LocalStore::open(path)
258 +
                .map(|s| Box::new(s) as Box<dyn crate::strata::Store>),
259 +
        );
260 +
    }
261 +
    let openers = STORE_OPENERS.lock().unwrap_or_else(|p| p.into_inner());
262 +
    openers.iter().find_map(|open| open(url, options))
263 +
}
264 +
230 265
/// The request header carrying the client's repository id.
231 266
pub const REPO_ID_HEADER: &str = "Oxy-Repo-Id";
232 267
…
267 unchanged lines
500 535
    if url.starts_with("http://") || url.starts_with("https://") {
501 536
        return url.to_string();
502 537
    }
503 -
    let (prefix, path) = match url.strip_prefix("file://") {
504 -
        Some(p) => ("file://", p),
505 -
        None => ("", url),
538 +
    let (prefix, path) = match (url.strip_prefix("file://"), url.strip_prefix("dir://")) {
539 +
        (Some(p), _) => ("file://", p),
540 +
        (_, Some(p)) => ("dir://", p),
541 +
        _ => ("", url),
506 542
    };
507 543
    match std::path::absolute(path) {
508 544
        Ok(abs) => format!("{prefix}{}", abs.display()),
…
1 unchanged lines
510 546
    }
511 547
}
512 548
513 -
/// Open a remote by URL: a local path (optionally `file://`), or `http(s)://`.
549 +
/// Open a remote by URL: a local path (optionally `file://`), `http(s)://`
550 +
/// (a hub), or a store (`dir://`, and whatever openers are registered).
514 551
pub fn open_remote(url: &str, options: &RemoteOptions) -> Result<Box<dyn Remote>> {
515 552
    if url.starts_with("http://") || url.starts_with("https://") {
516 553
        return Ok(Box::new(HttpRemote::new(url, options)?));
554 +
    }
555 +
    if let Some(store) = open_store(url, options) {
556 +
        return crate::repo::strata::open_store_remote(store?, options);
517 557
    }
518 558
    let path = url.strip_prefix("file://").unwrap_or(url);
519 559
    if path.contains("://") {
…
685 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +1 −0
…
79 unchanged lines
80 80
mod prompt;
81 81
mod receipts;
82 82
mod snapshot;
83 +
pub mod strata;
83 84
mod subrepos;
84 85
mod sync;
85 86
mod tags;
…
2059 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +13 −2
…
69 unchanged lines
70 70
        }
71 71
    }
72 72
73 -
    fn set_ref_attest(&self, bookmark: &str, r: &ObjectRef) -> Result<()> {
73 +
    pub(crate) fn set_ref_attest(&self, bookmark: &str, r: &ObjectRef) -> Result<()> {
74 74
        fs::create_dir_all(self.oxy.join("attest"))?;
75 75
        write_atomic(&self.ref_attest_path(bookmark), r.to_hex().as_bytes())
76 76
    }
…
838 unchanged lines
915 915
            repo_id: self.id().ok(),
916 916
            token: self.config.remote_tokens.get(name).cloned(),
917 917
            ca: self.config.remote_cas.get(name).map(PathBuf::from),
918 +
            cache_dir: Some(self.oxy.join("remotes").join(name).join("strata")),
918 919
        };
919 920
        self.fill_credentials(&url, &mut options);
920 921
        options
…
855 unchanged lines
1776 1777
    fn remote_on_same_hub(&self, url: &str) -> Option<String> {
1777 1778
        let origin = |u: &str| -> Option<String> {
1778 1779
            let (scheme, rest) = u.split_once("://")?;
1780 +
            if !matches!(scheme, "http" | "https") {
1781 +
                return None;
1782 +
            }
1779 1783
            let host = rest.split('/').next()?;
1780 1784
            Some(format!(
1781 1785
                "{}://{}",
…
17 unchanged lines
1799 1803
        match self.remote_on_same_hub(url) {
1800 1804
            Some(name) => RemoteOptions {
1801 1805
                repo_id: None,
1806 +
                cache_dir: None,
1802 1807
                ..self.remote_options(&name)
1803 1808
            },
1804 1809
            None => {
…
39 unchanged lines
1844 1849
        let (op_ref, op) = repo.head_op()?;
1845 1850
        // The remote's default bookmark, else ours (main), else its only one.
1846 1851
        let theirs = op.view.remotes.get("origin").cloned().unwrap_or_default();
1847 -
        let advertised = remote.refs().ok().and_then(|r| r.default_bookmark);
1852 +
        // Asked of the remote as now configured, so a store-backed one
1853 +
        // answers from the mirror the fetch just filled.
1854 +
        let advertised = repo
1855 +
            .open_named_remote("origin")
1856 +
            .ok()
1857 +
            .and_then(|r| r.refs().ok())
1858 +
            .and_then(|r| r.default_bookmark);
1848 1859
        let bookmark = advertised
1849 1860
            .filter(|b| theirs.contains_key(b))
1850 1861
            .or_else(|| {
…
41 unchanged lines
modified docs/stores.md +32 −13
…
84 unchanged lines
85 85
  a repository *from* a bucket (no local `.oxy/`) is out of scope here;
86 86
  it is what the virtual working copy (§8) is for.
87 87
88 -
## 4. Layout in the bucket
88 +
## 4. Layout in the bucket (as built, 2026-10-03)
89 89
90 90
```
91 91
PREFIX/
92 -
  oxygen.toml          format version and repository id, written once
93 -
  packs/<id>.pack      immutable, zstd-compressed objects
94 -
  packs/<id>.idx       sorted index of the pack (read with range GETs)
95 -
  refs                 bookmarks, tags and the remote's operation head: one object, updated by compare-and-swap
96 -
  receipts/, attest/   only if they are kept outside packs
92 +
  oxygen.toml                      format version and repository id, written once by the first push
93 +
  bundles/<seq>-<hash>.bundle      one per push: changes, signatures, seals, objects, policies
94 +
                                   (the wire protocol's bundle encoding), written once, named by content
95 +
  refs                             every bookmark with its heads and attestation, every tag, the policy
96 +
                                   and its history (the protocol's refs response), moved by compare-and-swap
97 97
```
98 98
99 -
- **Pushing** uploads one pack and its index, then updates `refs`.
100 -
- **Packs never change.** A pack named in `refs` is complete, because
101 -
  the pack is written before `refs` moves.
102 -
- **One `refs` object** so that a push moving several bookmarks is
103 -
  atomic. Per-reference objects would scale to more writers but lose
99 +
- **Pushing** uploads one bundle, then updates `refs` from the version
100 +
  it read. A bundle named in `refs` is complete, because the bundle is
101 +
  written before `refs` moves.
102 +
- **One `refs` record** so that a push moving several bookmarks is
103 +
  atomic. Per-reference records would scale to more writers but lose
104 104
  atomic pushes; one engineer and small teams do not need them.
105 -
- **Many small packs from many pushes** are merged by `oxy gc` against
106 -
  the remote, as `merge_packs` does locally.
105 +
- **Bundles are whole-file transfers.** Each push is one bundle, so a
106 +
  pull downloads only the bundles it has not seen. Packs with indexes
107 +
  for range reads, as first planned, are an optimisation for later
108 +
  (`oxy gc` against a remote merges bundles, and could write a pack).
109 +
- **The client does what a hub does.** A store cannot run code, so each
110 +
  client keeps a **mirror**, a bare repository of its own under
111 +
  `.oxy/remotes/<name>/strata/`, brought level with the store before
112 +
  every use: the record read, the bundles it has not seen imported (each
113 +
  object re-hashed), the view set from the record. A push runs the hub's
114 +
  own checks (`receive_push`: policy, attestations, fast-forward) on the
115 +
  mirror, uploads the bundle, then moves `refs`. A fetch verifies each
116 +
  bookmark's attestation under the policy, as for any remote. The
117 +
  mirror costs a second copy of the objects on disk; sharing them with
118 +
  the repository's own store is a later optimisation.
119 +
- **The record moved meanwhile:** the mirror is put back to what the
120 +
  store says and the push is reported as a hub reports a stale push:
121 +
  pull, then push again. Merging two pushes to different bookmarks
122 +
  without a round trip is ST5's work.
123 +
- **A directory is a store too:** `oxy remote add usb dir:///media/usb/repo`
124 +
  (`dir://`, since a plain path or `file://` is another repository).
125 +
  Tests run the whole flow through it with no network.
107 126
108 127
## 5. Concurrency: compare-and-swap on `refs`
109 128
…
87 unchanged lines
modified src/cli/auth.rs +3 −0
…
170 unchanged lines
171 171
        token: None,
172 172
        ca: ca.clone(),
173 173
        repo_id: None,
174 +
        cache_dir: None,
174 175
    };
175 176
    let token = if with_token {
176 177
        let mut line = String::new();
…
84 unchanged lines
261 262
            token: Some(token.clone()),
262 263
            ca: e.ca.clone(),
263 264
            repo_id: None,
265 +
            cache_dir: None,
264 266
        };
265 267
        let check = match whoami(&e.hub, &options) {
266 268
            Ok(Ok(a)) => ui::ok(format!("valid, signed in as {a}")),
…
47 unchanged lines
314 316
                token: Some(t.clone()),
315 317
                ca: entry.ca.clone(),
316 318
                repo_id: None,
319 +
                cache_dir: None,
317 320
            };
318 321
            matches!(
319 322
                http_json(
…
26 unchanged lines