OXYGENHUB

Remotes: local transport, push with seals and reference attestations, fetch, pull as state union, clone 5b9c6ad45f6e

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 18:36 UTC
Signature
✔ signed by Andy McCall
Hash
5b9c6ad45f6e6baa0f84f725b4f001cb5f4e08cb5b63a3c023e2df24c9dc9d77
Depends on
1983686fb7a7 20247a03a596 86e682c242c6 f9c98deef326 ff0af1ee299d
Depended on by
2f0b4b0651ea 6c3dbe2018e0 0588de69a84d 569802ebae0e 80b069facc69 f3811eb20bea d3b270909c78 b63fc7a77fc6 ee130dd9eef2 1ec6fdbc6bcb 67a4fe1aa2d5 028545dfa7a4 4b9d06778581 a2387d547373 f53cbe7a204e f0a517d28237 b582948f1912 68a924846379 648189388f1a 8d80356371c5 9f9e72b93e4e 827281eed2ed 14edbaaf5291 ff1f62fee654 0f93f49ce059 2c75672fb2fe 598009a75f6c d443639bcf35 6f8aeb996558 e293c90dca62 fc1a466a1e66 11c958021e1a c863c7a7a2d1 3e5772ccf9da 4dcb8c0916b7 eae595568832 84b9e9ac0ab4 4213c41dfc64 d82e44097323 75167672ebcd b3c5a59a875a 28fc23d7706a cdb5beba7f05 132b884b9a8d 2863456a0f6d 83e0677b07fe 2b5ee371060e fce44ae9ad29 750251b21a96 4161262eeb29 adec9a0796b8
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
39 atoms, 2 tree operations, 7 files

Browse the tree at this change

added crates/oxy-core/src/attest.rs +125 −0
1 +
//! Attestations (spec §14.4): signed statements about references and batches
2 +
//! of changes. Only the `ref` and `seal` kinds exist so far.
3 +
4 +
use crate::cbor::{self, Value};
5 +
use crate::hash::{hash_object, ChangeHash, ObjectRef, ObjectType};
6 +
use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, Hlc, ObjectError, FORMAT_VERSION};
7 +
8 +
pub const MAX_SEAL_HASHES: usize = 4096;
9 +
10 +
#[derive(Clone, Debug, PartialEq, Eq)]
11 +
pub enum AttestBody {
12 +
    /// A bookmark moved from `old_state` to `new_state` (spec §10.2).
13 +
    Ref { name: String, old_state: ObjectRef, new_state: ObjectRef, serial: u64 },
14 +
    /// One signature covering a batch of changes (spec §14.4).
15 +
    Seal { hashes: Vec<ChangeHash> },
16 +
}
17 +
18 +
#[derive(Clone, Debug, PartialEq, Eq)]
19 +
pub struct Attest {
20 +
    /// Repository ID chosen at init.
21 +
    pub repo: [u8; 16],
22 +
    pub hlc: Hlc,
23 +
    pub body: AttestBody,
24 +
}
25 +
26 +
impl Attest {
27 +
    pub fn kind(&self) -> &'static str {
28 +
        match self.body {
29 +
            AttestBody::Ref { .. } => "ref",
30 +
            AttestBody::Seal { .. } => "seal",
31 +
        }
32 +
    }
33 +
34 +
    pub fn seal(repo: [u8; 16], mut hashes: Vec<ChangeHash>) -> Attest {
35 +
        hashes.sort();
36 +
        hashes.dedup();
37 +
        Attest { repo, hlc: Hlc::now(), body: AttestBody::Seal { hashes } }
38 +
    }
39 +
40 +
    pub fn to_value(&self) -> Value {
41 +
        let body = match &self.body {
42 +
            AttestBody::Ref { name, old_state, new_state, serial } => Value::map([
43 +
                (0, Value::Text(name.clone())),
44 +
                (1, Value::Bytes(old_state.0.to_vec())),
45 +
                (2, Value::Bytes(new_state.0.to_vec())),
46 +
                (3, Value::Uint(*serial)),
47 +
            ]),
48 +
            AttestBody::Seal { hashes } => {
49 +
                Value::map([(0, Value::Array(hashes.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()))])
50 +
            }
51 +
        };
52 +
        Value::map([
53 +
            (0, Value::Uint(FORMAT_VERSION)),
54 +
            (1, Value::Text(self.kind().into())),
55 +
            (2, Value::Bytes(self.repo.to_vec())),
56 +
            (3, self.hlc.to_value()),
57 +
            (4, body),
58 +
        ])
59 +
    }
60 +
61 +
    pub fn encode(&self) -> Vec<u8> {
62 +
        cbor::encode(&self.to_value())
63 +
    }
64 +
65 +
    pub fn hash(&self) -> ObjectRef {
66 +
        hash_object(ObjectType::Attest, &self.encode())
67 +
    }
68 +
69 +
    pub fn decode(b: &[u8]) -> Result<Attest, ObjectError> {
70 +
        let v = cbor::decode(b)?;
71 +
        let m = map(&v, "attest")?;
72 +
        check_version(m)?;
73 +
        let kind = text(field(m, 1, "kind")?, "kind")?;
74 +
        let repo: [u8; 16] = bytes(field(m, 2, "repo")?, "repo")?
75 +
            .try_into()
76 +
            .map_err(|_| ObjectError::Schema("repo: expected 16 bytes".into()))?;
77 +
        let hlc = Hlc::from_value(field(m, 3, "hlc")?)?;
78 +
        let b = map(field(m, 4, "body")?, "body")?;
79 +
        let body = match kind {
80 +
            "ref" => AttestBody::Ref {
81 +
                name: text(field(b, 0, "name")?, "name")?.to_owned(),
82 +
                old_state: oref(field(b, 1, "old_state")?, "old_state")?,
83 +
                new_state: oref(field(b, 2, "new_state")?, "new_state")?,
84 +
                serial: uint(field(b, 3, "serial")?, "serial")?,
85 +
            },
86 +
            "seal" => {
87 +
                let hashes = array(field(b, 0, "hashes")?, "hashes")?.iter().map(|h| oref(h, "hash")).collect::<Result<Vec<_>, _>>()?;
88 +
                if hashes.is_empty() || hashes.len() > MAX_SEAL_HASHES || !hashes.windows(2).all(|w| w[0] < w[1]) {
89 +
                    return Err(ObjectError::Schema("seal: hashes must be 1..4096, sorted and unique".into()));
90 +
                }
91 +
                AttestBody::Seal { hashes }
92 +
            }
93 +
            other => return Err(ObjectError::Schema(format!("unknown attestation kind {other:?}"))),
94 +
        };
95 +
        Ok(Attest { repo, hlc, body })
96 +
    }
97 +
}
98 +
99 +
#[cfg(test)]
100 +
mod tests {
101 +
    use super::*;
102 +
103 +
    #[test]
104 +
    fn round_trips() {
105 +
        let r = Attest {
106 +
            repo: [1; 16],
107 +
            hlc: Hlc { wall_ms: 3, logical: 0 },
108 +
            body: AttestBody::Ref { name: "main".into(), old_state: ObjectRef([1; 32]), new_state: ObjectRef([2; 32]), serial: 7 },
109 +
        };
110 +
        assert_eq!(Attest::decode(&r.encode()).unwrap(), r);
111 +
        assert_eq!(r.kind(), "ref");
112 +
        let s = Attest::seal([1; 16], vec![ObjectRef([9; 32]), ObjectRef([3; 32]), ObjectRef([9; 32])]);
113 +
        let d = Attest::decode(&s.encode()).unwrap();
114 +
        assert!(matches!(&d.body, AttestBody::Seal { hashes } if hashes == &vec![ObjectRef([3; 32]), ObjectRef([9; 32])]));
115 +
        assert_ne!(s.hash(), r.hash());
116 +
    }
117 +
118 +
    #[test]
119 +
    fn rejects_bad_seals() {
120 +
        let empty = Attest { repo: [0; 16], hlc: Hlc::default(), body: AttestBody::Seal { hashes: vec![] } };
121 +
        assert!(Attest::decode(&empty.encode()).is_err());
122 +
        let unsorted = Attest { repo: [0; 16], hlc: Hlc::default(), body: AttestBody::Seal { hashes: vec![ObjectRef([2; 32]), ObjectRef([1; 32])] } };
123 +
        assert!(Attest::decode(&unsorted.encode()).is_err());
124 +
    }
125 +
}
added crates/oxy-core/src/protocol.rs +108 −0
1 +
//! Transport-agnostic protocol (spec §16): what a client asks of a remote and
2 +
//! what a server does with a push. `LocalRemote` serves another repository
3 +
//! directory in-process; an HTTPS transport implements the same trait and the
4 +
//! server side reuses `Repo::receive_push` and friends unchanged.
5 +
6 +
use crate::attest::Attest;
7 +
use crate::hash::{ChangeHash, ObjectRef, ObjectType};
8 +
use crate::identity::Signature;
9 +
use crate::objects::Change;
10 +
use crate::repo::{Repo, RepoError, Result};
11 +
use std::path::Path;
12 +
13 +
/// An attestation together with the signatures over its ObjectRef.
14 +
#[derive(Clone, Debug, PartialEq, Eq)]
15 +
pub struct SignedAttest {
16 +
    pub attest: Attest,
17 +
    pub sigs: Vec<Signature>,
18 +
}
19 +
20 +
/// One reference as a remote reports it.
21 +
#[derive(Clone, Debug)]
22 +
pub struct RemoteRef {
23 +
    pub name: String,
24 +
    pub state: ObjectRef,
25 +
    pub heads: Vec<ChangeHash>,
26 +
    pub attestation: Option<SignedAttest>,
27 +
}
28 +
29 +
/// Everything needed to add a set of changes to a repository.
30 +
#[derive(Clone, Debug, Default)]
31 +
pub struct Bundle {
32 +
    /// In dependency order, each with its individual signatures.
33 +
    pub changes: Vec<(Change, Vec<Signature>)>,
34 +
    /// Seals covering the changes.
35 +
    pub attests: Vec<SignedAttest>,
36 +
    /// Chunks, blobs, identities, and state objects referenced by the changes.
37 +
    pub objects: Vec<(ObjectType, Vec<u8>)>,
38 +
}
39 +
40 +
impl Bundle {
41 +
    pub fn is_empty(&self) -> bool {
42 +
        self.changes.is_empty() && self.attests.is_empty() && self.objects.is_empty()
43 +
    }
44 +
}
45 +
46 +
/// A requested bookmark move (spec §10.2, §16.3).
47 +
#[derive(Clone, Debug)]
48 +
pub struct RefUpdate {
49 +
    pub name: String,
50 +
    pub old_state: ObjectRef,
51 +
    pub new_state: ObjectRef,
52 +
    pub force: bool,
53 +
    pub attestation: Option<SignedAttest>,
54 +
}
55 +
56 +
#[derive(Clone, Debug, PartialEq, Eq)]
57 +
pub struct RefResult {
58 +
    pub name: String,
59 +
    pub ok: bool,
60 +
    pub reason: String,
61 +
}
62 +
63 +
pub trait Remote {
64 +
    fn repo_id(&self) -> Result<[u8; 16]>;
65 +
    fn refs(&self) -> Result<Vec<RemoteRef>>;
66 +
    /// Changes in the closure of `want` but not of `have`, in dependency order.
67 +
    fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>>;
68 +
    fn fetch(&self, changes: &[ChangeHash]) -> Result<Bundle>;
69 +
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>) -> Result<Vec<RefResult>>;
70 +
}
71 +
72 +
/// Another repository on this machine, served in-process.
73 +
pub struct LocalRemote {
74 +
    pub repo: Repo,
75 +
}
76 +
77 +
impl LocalRemote {
78 +
    pub fn open(path: &Path) -> Result<LocalRemote> {
79 +
        Ok(LocalRemote { repo: Repo::open(path)? })
80 +
    }
81 +
}
82 +
83 +
impl Remote for LocalRemote {
84 +
    fn repo_id(&self) -> Result<[u8; 16]> {
85 +
        self.repo.id()
86 +
    }
87 +
    fn refs(&self) -> Result<Vec<RemoteRef>> {
88 +
        self.repo.remote_refs()
89 +
    }
90 +
    fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
91 +
        self.repo.negotiate(have, want)
92 +
    }
93 +
    fn fetch(&self, changes: &[ChangeHash]) -> Result<Bundle> {
94 +
        self.repo.bundle_for(changes)
95 +
    }
96 +
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>) -> Result<Vec<RefResult>> {
97 +
        self.repo.receive_push(bundle, updates)
98 +
    }
99 +
}
100 +
101 +
/// Open a remote by URL. Only local paths (optionally `file://`) exist so far.
102 +
pub fn open_remote(url: &str) -> Result<Box<dyn Remote>> {
103 +
    let path = url.strip_prefix("file://").unwrap_or(url);
104 +
    if path.contains("://") {
105 +
        return Err(RepoError::Msg(format!("unsupported remote URL {url:?}: only local paths are implemented")));
106 +
    }
107 +
    Ok(Box::new(LocalRemote::open(Path::new(path))?))
108 +
}
modified crates/oxy-core/src/lib.rs +2 −0
…
1 unchanged lines
2 2
//!
3 3
//! See `docs/spec.md` for the specification this implements.
4 4
5 +
pub mod attest;
5 6
pub mod cbor;
6 7
pub mod chunk;
7 8
pub mod diff;
…
3 unchanged lines
11 12
pub mod ids;
12 13
pub mod objects;
13 14
pub mod oplog;
15 +
pub mod protocol;
14 16
pub mod repo;
15 17
pub mod state;
16 18
pub mod store;
…
1 unchanged lines
modified crates/oxy-core/src/oplog.rs +37 −0
…
17 unchanged lines
18 18
    /// Bookmark name → ObjectRef of a `state` object (spec §10.1).
19 19
    pub bookmarks: BTreeMap<String, ObjectRef>,
20 20
    pub workspaces: BTreeMap<WorkspaceId, ChangeHash>,
21 +
    /// `remotes/<remote>/bookmarks/<name>`: remote → bookmark → state ObjectRef.
22 +
    pub remotes: BTreeMap<String, BTreeMap<String, ObjectRef>>,
21 23
}
22 24
23 25
/// A format-1 view, only produced by `Op::decode_format1`.
…
32 unchanged lines
56 58
                        .collect(),
57 59
                ),
58 60
            ),
61 +
            (
62 +
                2,
63 +
                Value::Array(
64 +
                    self.remotes
65 +
                        .iter()
66 +
                        .map(|(r, bms)| {
67 +
                            Value::Array(vec![
68 +
                                Value::Text(r.clone()),
69 +
                                Value::Array(
70 +
                                    bms.iter().map(|(n, st)| Value::Array(vec![Value::Text(n.clone()), Value::Bytes(st.0.to_vec())])).collect(),
71 +
                                ),
72 +
                            ])
73 +
                        })
74 +
                        .collect(),
75 +
                ),
76 +
            ),
59 77
        ])
60 78
    }
61 79
…
9 unchanged lines
71 89
            view.bookmarks.insert(name, oref(&pair[1], "bookmark state")?);
72 90
        }
73 91
        view.workspaces = workspaces_from_value(field(m, 1, "workspaces")?)?;
92 +
        if let Some(rs) = m.get(&2) {
93 +
            for r in array(rs, "remotes")? {
94 +
                let pair = array(r, "remote")?;
95 +
                if pair.len() != 2 {
96 +
                    return Err(ObjectError::Schema("remote: expected [name, bookmarks]".into()));
97 +
                }
98 +
                let name = text(&pair[0], "remote name")?.to_owned();
99 +
                let mut bms = BTreeMap::new();
100 +
                for b in array(&pair[1], "remote bookmarks")? {
101 +
                    let bp = array(b, "remote bookmark")?;
102 +
                    if bp.len() != 2 {
103 +
                        return Err(ObjectError::Schema("remote bookmark: expected [name, state]".into()));
104 +
                    }
105 +
                    bms.insert(text(&bp[0], "bookmark name")?.to_owned(), oref(&bp[1], "bookmark state")?);
106 +
                }
107 +
                view.remotes.insert(name, bms);
108 +
            }
109 +
        }
74 110
        Ok(view)
75 111
    }
76 112
}
…
145 unchanged lines
222 258
        view.bookmarks.insert("main".into(), ObjectRef([2; 32]));
223 259
        view.bookmarks.insert("dev".into(), ObjectRef([3; 32]));
224 260
        view.workspaces.insert(WorkspaceId([5; 16]), ObjectRef([9; 32]));
261 +
        view.remotes.entry("origin".into()).or_default().insert("main".into(), ObjectRef([4; 32]));
225 262
        let op = Op { parents: vec![ObjectRef([7; 32])], hlc: Hlc { wall_ms: 5, logical: 1 }, command: "oxy commit".into(), view };
226 263
        let d = Op::decode(&op.encode()).unwrap();
227 264
        assert_eq!(d, op);
…
11 unchanged lines
modified crates/oxy-core/src/repo.rs +721 −19
1 1
//! A repository on disk: the `.oxy` directory, one workspace, the operation
2 2
//! log, and the working-copy change (spec §12, §13.1).
3 3
//!
4 -
//! Not yet implemented from the spec: seals, attestations and trust policy,
5 -
//! packs, checkpoints, multiple workspaces, and remotes. Changes are signed individually when `core.sign` is
4 +
//! Not yet implemented from the spec: trust policy (every remote is
5 +
//! "unverified", so unsigned pushes are accepted), packs, checkpoints,
6 +
//! multiple workspaces, and network transports (only local paths). Changes are signed individually when `core.sign` is
6 7
//! `change`; the default `seal` leaves them unsigned until push exists.
7 8
9 +
use crate::attest::{Attest, AttestBody};
8 10
use crate::diff::{ranges, split_lines, text_edits};
11 +
use crate::protocol::{open_remote, Bundle, RefResult, RefUpdate, RemoteRef, SignedAttest};
9 12
use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType};
10 13
use crate::identity::{self, Domain, Identity, SecretKey, Signature};
11 14
use crate::ids::{ChangeId, NodeId, WorkspaceId};
…
2 unchanged lines
14 17
use crate::state::{FileContent, Snapshot, State};
15 18
use crate::store::{Store, StoreError};
16 19
use crate::tree::OpKey;
20 +
use rand::RngCore;
17 21
use std::collections::{BTreeMap, BTreeSet, HashMap};
18 22
use std::fs;
19 23
use std::io::Write;
…
35 unchanged lines
55 59
    /// push-time seals (`false`, the spec default).
56 60
    pub sign_changes: bool,
57 61
    pub bookmark: String,
62 +
    /// `remote.<name>.url`.
63 +
    pub remotes: BTreeMap<String, String>,
58 64
}
59 65
60 66
impl Config {
…
5 unchanged lines
66 72
            author_key: None,
67 73
            sign_changes: false,
68 74
            bookmark: "main".into(),
75 +
            remotes: BTreeMap::new(),
69 76
        }
70 77
    }
71 78
…
17 unchanged lines
89 96
                }
90 97
            }
91 98
            "workspace.bookmark" => self.bookmark = value.into(),
99 +
            k if k.starts_with("remote.") && k.ends_with(".url") => {
100 +
                let name = &k["remote.".len()..k.len() - ".url".len()];
101 +
                if name.is_empty() || name.contains('/') {
102 +
                    return fail(format!("invalid remote name in {key:?}"));
103 +
                }
104 +
                if value.is_empty() {
105 +
                    self.remotes.remove(name);
106 +
                } else {
107 +
                    self.remotes.insert(name.to_string(), value.into());
108 +
                }
109 +
            }
92 110
            _ => return fail(format!("unknown configuration key {key:?}")),
93 111
        }
94 112
        Ok(())
…
14 unchanged lines
109 127
    }
110 128
111 129
    fn save(&self, path: &Path) -> Result<()> {
112 -
        let text = format!(
130 +
        let mut text = format!(
113 131
            "author.name = \"{}\"\nauthor.email = \"{}\"\nauthor.key = \"{}\"\ncore.sign = \"{}\"\nworkspace.bookmark = \"{}\"\n",
114 132
            self.author_name,
115 133
            self.author_email,
…
1 unchanged lines
117 135
            if self.sign_changes { "change" } else { "seal" },
118 136
            self.bookmark
119 137
        );
138 +
        for (name, url) in &self.remotes {
139 +
            text.push_str(&format!("remote.{name}.url = \"{url}\"\n"));
140 +
        }
120 141
        write_atomic(path, text.as_bytes())
121 142
    }
122 143
}
…
87 unchanged lines
210 231
#[derive(Clone, Debug, PartialEq, Eq)]
211 232
pub enum SignatureStatus {
212 233
    Unsigned,
213 -
    Valid { key: KeyId, name: String, email: String },
234 +
    Valid { key: KeyId, name: String, email: String, sealed: bool },
214 235
    Invalid(String),
215 236
}
216 237
…
5 unchanged lines
222 243
    // ------------------------------------------------------------ lifecycle
223 244
224 245
    pub fn init(root: &Path) -> Result<Repo> {
246 +
        let mut id = [0u8; 16];
247 +
        rand::rngs::OsRng.fill_bytes(&mut id);
248 +
        Self::init_with_id(root, id)
249 +
    }
250 +
251 +
    /// Initialize a repository with a given repository ID (clones share the
252 +
    /// ID of their origin, spec §14.4).
253 +
    pub fn init_with_id(root: &Path, id: [u8; 16]) -> Result<Repo> {
254 +
        fs::create_dir_all(root)?;
225 255
        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
226 256
        let oxy = root.join(OXY_DIR);
227 257
        if oxy.exists() {
…
1 unchanged lines
229 259
        }
230 260
        fs::create_dir_all(oxy.join("ops"))?;
231 261
        write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?;
262 +
        write_atomic(&oxy.join("id"), hex::encode(id).as_bytes())?;
232 263
        let config = Config::default_for_user();
233 264
        config.save(&oxy.join("config"))?;
234 265
        let workspace = WorkspaceId::random();
…
64 unchanged lines
299 330
        Ok(())
300 331
    }
301 332
333 +
    /// The repository ID (spec §14.4). Repositories made before IDs existed get one on first use.
334 +
    pub fn id(&self) -> Result<[u8; 16]> {
335 +
        let path = self.oxy.join("id");
336 +
        match fs::read_to_string(&path) {
337 +
            Ok(h) => hex::decode(h.trim()).ok().and_then(|v| v.try_into().ok()).ok_or_else(|| RepoError::Msg("corrupt .oxy/id".into())),
338 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
339 +
                let mut id = [0u8; 16];
340 +
                rand::rngs::OsRng.fill_bytes(&mut id);
341 +
                write_atomic(&path, hex::encode(id).as_bytes())?;
342 +
                Ok(id)
343 +
            }
344 +
            Err(e) => Err(e.into()),
345 +
        }
346 +
    }
347 +
302 348
    pub fn root(&self) -> &Path {
303 349
        &self.root
304 350
    }
…
81 unchanged lines
386 432
        }
387 433
        let mut last_err = String::new();
388 434
        for sig in &sigs {
389 -
            let id = match self.store.get_identity(&sig.key) {
390 -
                Ok(id) => id,
391 -
                Err(_) => {
392 -
                    last_err = format!("unknown key {}", sig.key.short(12));
393 -
                    continue;
435 +
            match self.check_signer(&change, sig, Domain::Change, hash) {
436 +
                Ok(id) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email, sealed: false }),
437 +
                Err(e) => last_err = e,
438 +
            }
439 +
        }
440 +
        Ok(SignatureStatus::Invalid(last_err))
441 +
    }
442 +
443 +
    /// Verify `sig` over `target` and check the author-or-delegate rule for `change`.
444 +
    fn check_signer(&self, change: &Change, sig: &Signature, domain: Domain, target: &ObjectRef) -> std::result::Result<Identity, String> {
445 +
        let id = self.store.get_identity(&sig.key).map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
446 +
        let authorised =
447 +
            sig.key == change.author || self.store.get_identity(&change.author).map(|a| a.delegates.contains(&sig.key)).unwrap_or(false);
448 +
        if !authorised {
449 +
            return Err(format!("key {} is neither the author nor a delegate", sig.key.short(12)));
450 +
        }
451 +
        identity::verify(&id, sig, domain, target).map_err(|e| e.to_string())?;
452 +
        Ok(id)
453 +
    }
454 +
455 +
    /// Like `verify_change`, but a change with no valid individual signature
456 +
    /// is also authenticated by a valid seal covering it (spec §14.4).
457 +
    pub fn authenticate_change(&self, hash: &ChangeHash) -> Result<SignatureStatus> {
458 +
        let direct = self.verify_change(hash)?;
459 +
        if matches!(direct, SignatureStatus::Valid { .. }) {
460 +
            return Ok(direct);
461 +
        }
462 +
        let change = self.store.get_change(hash)?;
463 +
        let mut last_err = match &direct {
464 +
            SignatureStatus::Invalid(e) => e.clone(),
465 +
            _ => String::new(),
466 +
        };
467 +
        for seal_ref in self.store.seals_for(hash)? {
468 +
            let attest = self.store.get_attest(&seal_ref)?;
469 +
            let AttestBody::Seal { hashes } = &attest.body else { continue };
470 +
            if !hashes.contains(hash) {
471 +
                continue;
472 +
            }
473 +
            for sig in self.store.signatures(&seal_ref)? {
474 +
                match self.check_signer(&change, &sig, Domain::Attest, &seal_ref) {
475 +
                    Ok(id) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email, sealed: true }),
476 +
                    Err(e) => last_err = e,
477 +
                }
478 +
            }
479 +
        }
480 +
        if last_err.is_empty() { Ok(SignatureStatus::Unsigned) } else { Ok(SignatureStatus::Invalid(last_err)) }
481 +
    }
482 +
483 +
    /// Store and sign an attestation with the configured key.
484 +
    fn sign_attest(&self, attest: &Attest) -> Result<SignedAttest> {
485 +
        let Some((key, sk)) = self.secret_key()? else {
486 +
            return fail("no secret key: run `oxy identity create` first");
487 +
        };
488 +
        let r = self.store.put_attest(attest)?;
489 +
        let sig = sk.sign(Domain::Attest, &key, &r);
490 +
        self.store.put_signature(&r, &sig)?;
491 +
        if let AttestBody::Seal { hashes } = &attest.body {
492 +
            for h in hashes {
493 +
                self.store.index_seal(h, &r)?;
494 +
            }
495 +
        }
496 +
        Ok(SignedAttest { attest: attest.clone(), sigs: vec![sig] })
497 +
    }
498 +
499 +
    // ------------------------------------------------------------ serving (spec §16)
500 +
501 +
    fn ref_attest_path(&self, bookmark: &str) -> PathBuf {
502 +
        self.oxy.join("attest").join(bookmark)
503 +
    }
504 +
505 +
    /// The latest reference attestation stored for a bookmark.
506 +
    pub fn latest_ref_attest(&self, bookmark: &str) -> Result<Option<(ObjectRef, SignedAttest)>> {
507 +
        match fs::read_to_string(self.ref_attest_path(bookmark)) {
508 +
            Ok(h) => {
509 +
                let r = ObjectRef::from_hex(h.trim()).ok_or_else(|| RepoError::Msg("corrupt attestation pointer".into()))?;
510 +
                Ok(Some((r, SignedAttest { attest: self.store.get_attest(&r)?, sigs: self.store.signatures(&r)? })))
511 +
            }
512 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
513 +
            Err(e) => Err(e.into()),
514 +
        }
515 +
    }
516 +
517 +
    fn set_ref_attest(&self, bookmark: &str, r: &ObjectRef) -> Result<()> {
518 +
        fs::create_dir_all(self.oxy.join("attest"))?;
519 +
        write_atomic(&self.ref_attest_path(bookmark), r.to_hex().as_bytes())
520 +
    }
521 +
522 +
    pub fn remote_refs(&self) -> Result<Vec<RemoteRef>> {
523 +
        let view = self.view()?;
524 +
        let mut out = Vec::new();
525 +
        for (name, state) in &view.bookmarks {
526 +
            out.push(RemoteRef {
527 +
                name: name.clone(),
528 +
                state: *state,
529 +
                heads: self.store.get_state(state)?.heads,
530 +
                attestation: self.latest_ref_attest(name)?.map(|(_, a)| a),
531 +
            });
532 +
        }
533 +
        Ok(out)
534 +
    }
535 +
536 +
    /// Every change reachable from `heads` that is in the store.
537 +
    fn closure_in_store(&self, heads: &[ChangeHash]) -> Result<BTreeSet<ChangeHash>> {
538 +
        let mut seen = BTreeSet::new();
539 +
        let mut stack: Vec<ChangeHash> = heads.to_vec();
540 +
        while let Some(h) = stack.pop() {
541 +
            if seen.contains(&h) || !self.store.has(&h) {
542 +
                continue;
543 +
            }
544 +
            seen.insert(h);
545 +
            stack.extend(self.store.get_change(&h)?.deps);
546 +
        }
547 +
        Ok(seen)
548 +
    }
549 +
550 +
    /// `closure(want) \ closure(have)`, dependencies first (spec §16.2).
551 +
    pub fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
552 +
        let have = self.closure_in_store(have)?;
553 +
        let mut out = Vec::new();
554 +
        let mut done: BTreeSet<ChangeHash> = have.clone();
555 +
        // Iterative post-order DFS.
556 +
        let mut stack: Vec<(ChangeHash, bool)> = want.iter().rev().map(|h| (*h, false)).collect();
557 +
        while let Some((h, expanded)) = stack.pop() {
558 +
            if done.contains(&h) {
559 +
                continue;
560 +
            }
561 +
            if !self.store.has(&h) {
562 +
                return fail(format!("change {} is not in this repository", h.short(12)));
563 +
            }
564 +
            if expanded {
565 +
                done.insert(h);
566 +
                out.push(h);
567 +
                continue;
568 +
            }
569 +
            stack.push((h, true));
570 +
            for d in self.store.get_change(&h)?.deps {
571 +
                if !done.contains(&d) {
572 +
                    stack.push((d, false));
394 573
                }
574 +
            }
575 +
        }
576 +
        Ok(out)
577 +
    }
578 +
579 +
    /// Bundle changes (in the given dependency order) with their signatures,
580 +
    /// seals, identities, blobs, chunks.
581 +
    pub fn bundle_for(&self, changes: &[ChangeHash]) -> Result<Bundle> {
582 +
        let mut bundle = Bundle::default();
583 +
        let mut objects: BTreeSet<ObjectRef> = BTreeSet::new();
584 +
        let mut seals: BTreeSet<ObjectRef> = BTreeSet::new();
585 +
        let add_object = |r: &ObjectRef, objects: &mut BTreeSet<ObjectRef>| {
586 +
            if !r.is_zero() && self.store.has(r) {
587 +
                objects.insert(*r);
588 +
            }
589 +
        };
590 +
        for h in changes {
591 +
            let c = self.store.get_change(h)?;
592 +
            let sigs = self.store.signatures(h)?;
593 +
            add_object(&c.author, &mut objects);
594 +
            for s in &sigs {
595 +
                add_object(&s.key, &mut objects);
596 +
            }
597 +
            for e in &c.edits {
598 +
                if let Edit::Replace { old, new, .. } = e {
599 +
                    for b in [old, new].into_iter().flatten() {
600 +
                        if self.store.has(b) {
601 +
                            objects.insert(*b);
602 +
                            if let Ok(blob) = Blob::decode(&self.store.get_typed(b, ObjectType::Blob)?) {
603 +
                                objects.extend(blob.chunks.iter().copied());
604 +
                            }
605 +
                        }
606 +
                    }
607 +
                }
608 +
            }
609 +
            for s in self.store.seals_for(h)? {
610 +
                seals.insert(s);
611 +
            }
612 +
            bundle.changes.push((c, sigs));
613 +
        }
614 +
        for r in seals {
615 +
            let attest = self.store.get_attest(&r)?;
616 +
            let sigs = self.store.signatures(&r)?;
617 +
            for s in &sigs {
618 +
                add_object(&s.key, &mut objects);
619 +
            }
620 +
            bundle.attests.push(SignedAttest { attest, sigs });
621 +
        }
622 +
        for r in objects {
623 +
            let (ty, body) = self.store.get(&r)?;
624 +
            bundle.objects.push((ty, body));
625 +
        }
626 +
        Ok(bundle)
627 +
    }
628 +
629 +
    /// Store a bundle's contents. With `strict`, every change must
630 +
    /// authenticate (signature or seal by its author or a delegate).
631 +
    pub fn import_bundle(&self, bundle: &Bundle, strict: bool) -> Result<Vec<ChangeHash>> {
632 +
        for (ty, body) in &bundle.objects {
633 +
            match ty {
634 +
                ObjectType::Chunk | ObjectType::Blob | ObjectType::Identity | ObjectType::State => {
635 +
                    self.store.put(*ty, body)?;
636 +
                }
637 +
                other => return fail(format!("bundle may not carry {} objects", other.name())),
638 +
            }
639 +
        }
640 +
        for sa in &bundle.attests {
641 +
            let r = self.store.put_attest(&sa.attest)?;
642 +
            for sig in &sa.sigs {
643 +
                self.store.put_signature(&r, sig)?;
644 +
            }
645 +
            if let AttestBody::Seal { hashes } = &sa.attest.body {
646 +
                for h in hashes {
647 +
                    self.store.index_seal(h, &r)?;
648 +
                }
649 +
            }
650 +
        }
651 +
        let incoming: BTreeSet<ChangeHash> = bundle.changes.iter().map(|(c, _)| c.hash()).collect();
652 +
        let mut imported = Vec::new();
653 +
        for (c, sigs) in &bundle.changes {
654 +
            let h = c.hash();
655 +
            for d in &c.deps {
656 +
                if !self.store.has(d) && !incoming.contains(d) {
657 +
                    return fail(format!("change {} depends on {}, which is neither stored nor in the bundle", h.short(12), d.short(12)));
658 +
                }
659 +
            }
660 +
            self.store.put_change(c)?;
661 +
            for sig in sigs {
662 +
                self.store.put_signature(&h, sig)?;
663 +
            }
664 +
            imported.push(h);
665 +
        }
666 +
        if strict {
667 +
            for h in &imported {
668 +
                if !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) {
669 +
                    return fail(format!("change {} is not authenticated by its author", h.short(12)));
670 +
                }
671 +
            }
672 +
        }
673 +
        Ok(imported)
674 +
    }
675 +
676 +
    /// Verify a reference attestation against an update it claims to describe.
677 +
    fn check_ref_attest(&self, sa: &SignedAttest, name: &str, old_state: &ObjectRef, new_state: &ObjectRef, last_serial: Option<u64>) -> std::result::Result<u64, String> {
678 +
        let AttestBody::Ref { name: n, old_state: o, new_state: nw, serial } = &sa.attest.body else {
679 +
            return Err("attestation is not a ref attestation".into());
680 +
        };
681 +
        if n != name || o != old_state || nw != new_state {
682 +
            return Err("attestation does not describe this update".into());
683 +
        }
684 +
        if sa.attest.repo != self.id().map_err(|e| e.to_string())? {
685 +
            return Err("attestation is for a different repository".into());
686 +
        }
687 +
        if let Some(last) = last_serial {
688 +
            if *serial <= last {
689 +
                return Err(format!("attestation serial {serial} is not greater than the last seen {last}"));
690 +
            }
691 +
        }
692 +
        let r = sa.attest.hash();
693 +
        let sig = sa.sigs.first().ok_or("attestation carries no signature")?;
694 +
        let id = self.store.get_identity(&sig.key).map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
695 +
        identity::verify(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?;
696 +
        Ok(*serial)
697 +
    }
698 +
699 +
    /// Accept a push (spec §16.3). Without a trust policy every repository
700 +
    /// is unverified, so unsigned changes and unattested updates are accepted;
701 +
    /// attestations that are present must still be valid.
702 +
    pub fn receive_push(&self, bundle: Bundle, updates: Vec<RefUpdate>) -> Result<Vec<RefResult>> {
703 +
        self.import_bundle(&bundle, false)?;
704 +
        let (op_ref, op) = self.head_op()?;
705 +
        let mut view = op.view.clone();
706 +
        let mut results = Vec::new();
707 +
        let mut names = Vec::new();
708 +
        for u in updates {
709 +
            let outcome = self.apply_update(&mut view, &u);
710 +
            let (ok, reason) = match outcome {
711 +
                Ok(()) => (true, String::new()),
712 +
                Err(e) => (false, e),
395 713
            };
396 -
            let authorised = sig.key == change.author
397 -
                || self.store.get_identity(&change.author).map(|a| a.delegates.contains(&sig.key)).unwrap_or(false);
398 -
            if !authorised {
399 -
                last_err = format!("key {} is neither the author nor a delegate", sig.key.short(12));
400 -
                continue;
714 +
            if ok {
715 +
                names.push(u.name.clone());
716 +
            }
717 +
            results.push(RefResult { name: u.name, ok, reason });
718 +
        }
719 +
        if !names.is_empty() {
720 +
            self.record_op(&[op_ref], &format!("push received: {}", names.join(" ")), view)?;
721 +
        }
722 +
        Ok(results)
723 +
    }
724 +
725 +
    fn apply_update(&self, view: &mut View, u: &RefUpdate) -> std::result::Result<(), String> {
726 +
        let empty = self.store.put_state(&StateObject::new(vec![])).map_err(|e| e.to_string())?;
727 +
        let current = view.bookmarks.get(&u.name).copied().unwrap_or(empty);
728 +
        if current != u.old_state {
729 +
            return Err("stale: the remote bookmark has moved; pull first".into());
730 +
        }
731 +
        let new_state = self.store.get_state(&u.new_state).map_err(|_| "new state object is missing".to_string())?;
732 +
        for h in &new_state.heads {
733 +
            if !self.store.has(h) {
734 +
                return Err(format!("head {} is missing", h.short(12)));
735 +
            }
736 +
        }
737 +
        if !u.force {
738 +
            let old_heads = self.store.get_state(&current).map_err(|e| e.to_string())?.heads;
739 +
            let closure = self.closure_in_store(&new_state.heads).map_err(|e| e.to_string())?;
740 +
            if let Some(h) = old_heads.iter().find(|h| !closure.contains(h)) {
741 +
                return Err(format!("not a fast-forward: current head {} would be dropped (use --force)", h.short(12)));
742 +
            }
743 +
        }
744 +
        if let Some(sa) = &u.attestation {
745 +
            let last = self.latest_ref_attest(&u.name).map_err(|e| e.to_string())?.and_then(|(_, a)| match a.attest.body {
746 +
                AttestBody::Ref { serial, .. } => Some(serial),
747 +
                _ => None,
748 +
            });
749 +
            self.check_ref_attest(sa, &u.name, &u.old_state, &u.new_state, last)?;
750 +
            let r = self.store.put_attest(&sa.attest).map_err(|e| e.to_string())?;
751 +
            for sig in &sa.sigs {
752 +
                self.store.put_signature(&r, sig).map_err(|e| e.to_string())?;
753 +
            }
754 +
            self.set_ref_attest(&u.name, &r).map_err(|e| e.to_string())?;
755 +
        }
756 +
        view.bookmarks.insert(u.name.clone(), u.new_state);
757 +
        Ok(())
758 +
    }
759 +
760 +
    // ------------------------------------------------------------ client side
761 +
762 +
    fn remote_url(&self, name: &str) -> Result<String> {
763 +
        self.config.remotes.get(name).cloned().ok_or_else(|| RepoError::Msg(format!("no remote named {name:?}; add one with `oxy remote add`")))
764 +
    }
765 +
766 +
    fn serial_path(&self, remote: &str, bookmark: &str) -> PathBuf {
767 +
        self.oxy.join("remotes").join(remote).join(format!("{bookmark}.serial"))
768 +
    }
769 +
770 +
    fn last_seen_serial(&self, remote: &str, bookmark: &str) -> Option<u64> {
771 +
        fs::read_to_string(self.serial_path(remote, bookmark)).ok().and_then(|s| s.trim().parse().ok())
772 +
    }
773 +
774 +
    fn record_serial(&self, remote: &str, bookmark: &str, serial: u64) -> Result<()> {
775 +
        let p = self.serial_path(remote, bookmark);
776 +
        fs::create_dir_all(p.parent().unwrap())?;
777 +
        write_atomic(&p, serial.to_string().as_bytes())
778 +
    }
779 +
780 +
    /// Every head this repository knows: bookmarks, remote tracking, working copy.
781 +
    fn known_heads(&self, view: &View) -> Result<Vec<ChangeHash>> {
782 +
        let mut have: BTreeSet<ChangeHash> = BTreeSet::new();
783 +
        for st in view.bookmarks.values().chain(view.remotes.values().flat_map(|b| b.values())) {
784 +
            if let Ok(s) = self.store.get_state(st) {
785 +
                have.extend(s.heads);
786 +
            }
787 +
        }
788 +
        have.extend(view.workspaces.values().copied());
789 +
        Ok(have.into_iter().collect())
790 +
    }
791 +
792 +
    /// Fetch every bookmark of a remote into `remotes/<name>/bookmarks/*` (spec §10.2).
793 +
    pub fn fetch(&self, remote_name: &str) -> Result<Vec<(String, Vec<ChangeHash>, usize)>> {
794 +
        let remote = open_remote(&self.remote_url(remote_name)?)?;
795 +
        if remote.repo_id()? != self.id()? {
796 +
            return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)"));
797 +
        }
798 +
        let (op_ref, op) = self.head_op()?;
799 +
        let mut view = op.view.clone();
800 +
        let have = self.known_heads(&view)?;
801 +
        let mut report = Vec::new();
802 +
        for r in remote.refs()? {
803 +
            let needed = remote.negotiate(&have, &r.heads)?;
804 +
            let bundle = remote.fetch(&needed)?;
805 +
            let imported = self.import_bundle(&bundle, false)?;
806 +
            for h in &r.heads {
807 +
                if !self.store.has(h) {
808 +
                    return fail(format!("remote did not send head {}", h.short(12)));
809 +
                }
810 +
            }
811 +
            let state = self.store.put_state(&StateObject::new(r.heads.clone()))?;
812 +
            if state != r.state {
813 +
                return fail(format!("remote state {} does not match its heads", r.state.short(12)));
814 +
            }
815 +
            if let Some(sa) = &r.attestation {
816 +
                let AttestBody::Ref { old_state, new_state, .. } = &sa.attest.body else {
817 +
                    return fail("remote sent a non-ref attestation for a bookmark");
818 +
                };
819 +
                let (old_state, new_state) = (*old_state, *new_state);
820 +
                let serial = self
821 +
                    .check_ref_attest(sa, &r.name, &old_state, &new_state, self.last_seen_serial(remote_name, &r.name))
822 +
                    .map_err(|e| RepoError::Msg(format!("attestation for {}/{}: {e}", remote_name, r.name)))?;
823 +
                if new_state != r.state {
824 +
                    return fail(format!("attestation for {}/{} does not cover its current state", remote_name, r.name));
825 +
                }
826 +
                let ar = self.store.put_attest(&sa.attest)?;
827 +
                for sig in &sa.sigs {
828 +
                    self.store.put_signature(&ar, sig)?;
829 +
                }
830 +
                self.record_serial(remote_name, &r.name, serial)?;
831 +
            }
832 +
            view.remotes.entry(remote_name.to_string()).or_default().insert(r.name.clone(), state);
833 +
            report.push((r.name, r.heads, imported.len()));
834 +
        }
835 +
        if view != op.view {
836 +
            self.record_op(&[op_ref], &format!("oxy fetch {remote_name}"), view)?;
837 +
        }
838 +
        Ok(report)
839 +
    }
840 +
841 +
    /// Fetch, then take the union of the local bookmark's state with the
842 +
    /// remote's: no merge change, conflicts become reified (spec §9.4).
843 +
    pub fn pull(&self, remote_name: &str) -> Result<PullReport> {
844 +
        self.fetch(remote_name)?;
845 +
        let (op_ref, op) = self.head_op()?;
846 +
        let bookmark = self.config.bookmark.clone();
847 +
        let local = self.heads_of(&op.view, &bookmark)?;
848 +
        let Some(remote_state) = op.view.remotes.get(remote_name).and_then(|b| b.get(&bookmark)).copied() else {
849 +
            return Ok(PullReport { heads: local, changed: false, checked_out: false });
850 +
        };
851 +
        let remote_heads = self.store.get_state(&remote_state)?.heads;
852 +
        let mut all = local.clone();
853 +
        all.extend(remote_heads);
854 +
        let st = self.load_state(&all)?;
855 +
        let mut heads = st.maximal(&all);
856 +
        heads.sort();
857 +
        let mut sorted_local = local.clone();
858 +
        sorted_local.sort();
859 +
        if heads == sorted_local {
860 +
            return Ok(PullReport { heads, changed: false, checked_out: false });
861 +
        }
862 +
        let mut view = op.view.clone();
863 +
        view.bookmarks.insert(bookmark.clone(), self.store.put_state(&StateObject::new(heads.clone()))?);
864 +
        self.record_op(&[op_ref], &format!("oxy pull {remote_name}"), view)?;
865 +
        // Follow along if the working copy was clean and on the old heads.
866 +
        let snap = self.snapshot("oxy pull")?;
867 +
        let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty();
868 +
        let mut base = snap.base.maximal(&snap.wc.deps);
869 +
        base.sort();
870 +
        let mut checked_out = false;
871 +
        if clean && base == sorted_local {
872 +
            self.checkout(&heads, false, "oxy pull (checkout)")?;
873 +
            checked_out = true;
874 +
        }
875 +
        Ok(PullReport { heads, changed: true, checked_out })
876 +
    }
877 +
878 +
    /// Push the current bookmark (spec §16.3), sealing this author's unsigned
879 +
    /// changes and attesting the reference when a key is available.
880 +
    pub fn push(&self, remote_name: &str, force: bool) -> Result<Vec<RefResult>> {
881 +
        let remote = open_remote(&self.remote_url(remote_name)?)?;
882 +
        if remote.repo_id()? != self.id()? {
883 +
            return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)"));
884 +
        }
885 +
        let (op_ref, op) = self.head_op()?;
886 +
        let bookmark = self.config.bookmark.clone();
887 +
        let local_heads = self.heads_of(&op.view, &bookmark)?;
888 +
        let refs = remote.refs()?;
889 +
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
890 +
        let (remote_state, remote_heads, last_serial) = match refs.iter().find(|r| r.name == bookmark) {
891 +
            Some(r) => (
892 +
                r.state,
893 +
                r.heads.clone(),
894 +
                r.attestation.as_ref().and_then(|a| match a.attest.body {
895 +
                    AttestBody::Ref { serial, .. } => Some(serial),
896 +
                    _ => None,
897 +
                }),
898 +
            ),
899 +
            None => (empty, vec![], None),
900 +
        };
901 +
        let new_state = self.store.put_state(&StateObject::new(local_heads.clone()))?;
902 +
        if new_state == remote_state {
903 +
            return Ok(vec![RefResult { name: bookmark, ok: true, reason: "already up to date".into() }]);
904 +
        }
905 +
        for h in &remote_heads {
906 +
            if !self.store.has(h) {
907 +
                return fail(format!("the remote has change {} which you do not; pull first", h.short(12)));
908 +
            }
909 +
        }
910 +
        if !force {
911 +
            let closure = self.closure_in_store(&local_heads)?;
912 +
            if let Some(h) = remote_heads.iter().find(|h| !closure.contains(h)) {
913 +
                return fail(format!("not a fast-forward: remote head {} is not in your state; pull first or use --force", h.short(12)));
914 +
            }
915 +
        }
916 +
        let to_send = self.negotiate(&remote_heads, &local_heads)?;
917 +
        let mut bundle = self.bundle_for(&to_send)?;
918 +
        let key = self.secret_key()?.map(|(k, _)| k);
919 +
        if let Some(key) = key {
920 +
            // Seal this author's changes that carry no valid authenticator.
921 +
            let mut unsigned = Vec::new();
922 +
            for h in &to_send {
923 +
                let c = self.store.get_change(h)?;
924 +
                if c.author == key && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) {
925 +
                    unsigned.push(*h);
926 +
                }
927 +
            }
928 +
            for chunk in unsigned.chunks(crate::attest::MAX_SEAL_HASHES) {
929 +
                let sa = self.sign_attest(&Attest::seal(self.id()?, chunk.to_vec()))?;
930 +
                bundle.attests.push(sa);
401 931
            }
402 -
            match identity::verify(&id, sig, Domain::Change, hash) {
403 -
                Ok(()) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email }),
404 -
                Err(e) => last_err = e.to_string(),
932 +
            bundle = self.bundle_for(&to_send)?;
933 +
        }
934 +
        bundle.objects.push((ObjectType::State, StateObject::new(local_heads.clone()).encode()));
935 +
        let attestation = match key {
936 +
            Some(_) => Some(self.sign_attest(&Attest {
937 +
                repo: self.id()?,
938 +
                hlc: Hlc::now(),
939 +
                body: AttestBody::Ref { name: bookmark.clone(), old_state: remote_state, new_state, serial: last_serial.unwrap_or(0) + 1 },
940 +
            })?),
941 +
            None => None,
942 +
        };
943 +
        let serial = attestation.as_ref().and_then(|a| match a.attest.body {
944 +
            AttestBody::Ref { serial, .. } => Some(serial),
945 +
            _ => None,
946 +
        });
947 +
        let results = remote.push(bundle, vec![RefUpdate { name: bookmark.clone(), old_state: remote_state, new_state, force, attestation }])?;
948 +
        if results.iter().any(|r| r.ok) {
949 +
            let mut view = op.view.clone();
950 +
            view.remotes.entry(remote_name.to_string()).or_default().insert(bookmark.clone(), new_state);
951 +
            self.record_op(&[op_ref], &format!("oxy push {remote_name}"), view)?;
952 +
            if let Some(s) = serial {
953 +
                self.record_serial(remote_name, &bookmark, s)?;
405 954
            }
406 955
        }
407 -
        Ok(SignatureStatus::Invalid(last_err))
956 +
        Ok(results)
408 957
    }
409 958
959 +
    /// Clone a remote into `dest`: same repository ID, `origin` remote,
960 +
    /// bookmark set to the remote's, working copy checked out.
961 +
    pub fn clone(url: &str, dest: &Path) -> Result<Repo> {
962 +
        let remote = open_remote(url)?;
963 +
        let id = remote.repo_id()?;
964 +
        let mut repo = Repo::init_with_id(dest, id)?;
965 +
        repo.config.remotes.insert("origin".into(), url.to_string());
966 +
        repo.save_config()?;
967 +
        repo.fetch("origin")?;
968 +
        let (op_ref, op) = repo.head_op()?;
969 +
        let bookmark = repo.config.bookmark.clone();
970 +
        if let Some(state) = op.view.remotes.get("origin").and_then(|b| b.get(&bookmark)).copied() {
971 +
            let heads = repo.store.get_state(&state)?.heads;
972 +
            let mut view = op.view.clone();
973 +
            view.bookmarks.insert(bookmark, state);
974 +
            repo.record_op(&[op_ref], &format!("oxy clone {url}"), view)?;
975 +
            repo.checkout(&heads, false, "oxy clone (checkout)")?;
976 +
        }
977 +
        Ok(repo)
978 +
    }
979 +
980 +
410 981
    fn new_working_copy(&self, deps: Vec<ChangeHash>, message: &str) -> Change {
411 982
        let mut c = Change {
412 983
            change_id: ChangeId::random(),
…
504 unchanged lines
917 1488
    }
918 1489
}
919 1490
1491 +
#[derive(Debug, Clone, PartialEq, Eq)]
1492 +
pub struct PullReport {
1493 +
    pub heads: Vec<ChangeHash>,
1494 +
    pub changed: bool,
1495 +
    pub checked_out: bool,
1496 +
}
1497 +
920 1498
#[derive(Debug, Default, Clone, PartialEq, Eq)]
921 1499
pub struct CheckoutReport {
922 1500
    pub written: usize,
…
694 unchanged lines
1617 2195
        repo.save_config().unwrap();
1618 2196
        write(root, "a.txt", b"a\nb\nc\n");
1619 2197
        let h2 = repo.commit("signed", &[]).unwrap();
1620 -
        assert_eq!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, name: "Ada".into(), email: "[email protected]".into() });
2198 +
        assert_eq!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, name: "Ada".into(), email: "[email protected]".into(), sealed: false });
1621 2199
1622 2200
        // A signature by a key that is not the author or a delegate is rejected.
1623 2201
        let other = SecretKey::generate();
…
74 unchanged lines
1698 2276
    }
1699 2277
1700 2278
    #[test]
2279 +
    fn clone_push_pull_with_seals_and_attestations() {
2280 +
        let dir = tempfile::tempdir().unwrap();
2281 +
        let root = dir.path();
2282 +
        let origin_path = root.join("origin");
2283 +
        let origin = Repo::init(&origin_path).unwrap();
2284 +
        let origin_url = origin_path.to_string_lossy().to_string();
2285 +
2286 +
        // A clones, creates an identity (default core.sign = seal), commits, pushes.
2287 +
        let mut a = Repo::clone(&origin_url, &root.join("a")).unwrap().with_keys_dir(root.join("keys-a"));
2288 +
        assert_eq!(a.id().unwrap(), origin.id().unwrap());
2289 +
        let (key_a, _) = a.create_identity("Ada", "[email protected]").unwrap();
2290 +
        write(&root.join("a"), "f.txt", b"a\n");
2291 +
        let h1 = a.commit("first", &[]).unwrap();
2292 +
        let results = a.push("origin", false).unwrap();
2293 +
        assert!(results[0].ok, "{}", results[0].reason);
2294 +
        assert_eq!(origin.bookmark_heads().unwrap(), vec![h1]);
2295 +
        // The change was sealed at push and the reference attested.
2296 +
        assert!(matches!(origin.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { key, sealed: true, .. } if key == key_a));
2297 +
        assert_eq!(origin.verify_change(&h1).unwrap(), SignatureStatus::Unsigned);
2298 +
        let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap();
2299 +
        assert!(matches!(att.attest.body, AttestBody::Ref { serial: 1, .. }));
2300 +
        let again = a.push("origin", false).unwrap();
2301 +
        assert!(again[0].ok && again[0].reason.contains("up to date"));
2302 +
        let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap();
2303 +
        assert!(matches!(att.attest.body, AttestBody::Ref { serial: 1, .. }), "a no-op push does not attest again");
2304 +
2305 +
        // B clones and sees the file and the seal.
2306 +
        let b = Repo::clone(&origin_url, &root.join("b")).unwrap();
2307 +
        assert_eq!(fs::read(root.join("b/f.txt")).unwrap(), b"a\n");
2308 +
        assert_eq!(b.bookmark_heads().unwrap(), vec![h1]);
2309 +
        assert!(matches!(b.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { sealed: true, .. }));
2310 +
2311 +
        // B (no identity) commits and pushes unsigned; the unverified origin accepts.
2312 +
        write(&root.join("b"), "f.txt", b"a\nb\n");
2313 +
        let h2 = b.commit("second", &[]).unwrap();
2314 +
        assert!(b.push("origin", false).unwrap()[0].ok);
2315 +
        assert_eq!(origin.bookmark_heads().unwrap(), vec![h2]);
2316 +
2317 +
        // A commits an independent change; push is refused until A pulls.
2318 +
        write(&root.join("a"), "g.txt", b"g\n");
2319 +
        let h3 = a.commit("third", &[]).unwrap();
2320 +
        assert!(a.push("origin", false).is_err());
2321 +
        let pr = a.pull("origin").unwrap();
2322 +
        assert!(pr.changed && pr.checked_out);
2323 +
        let mut want = vec![h2, h3];
2324 +
        want.sort();
2325 +
        assert_eq!(pr.heads, want);
2326 +
        assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nb\n");
2327 +
        assert_eq!(fs::read(root.join("a/g.txt")).unwrap(), b"g\n");
2328 +
        let results = a.push("origin", false).unwrap();
2329 +
        assert!(results[0].ok, "{}", results[0].reason);
2330 +
        let mut origin_heads = origin.bookmark_heads().unwrap();
2331 +
        origin_heads.sort();
2332 +
        assert_eq!(origin_heads, want);
2333 +
        let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap();
2334 +
        assert!(matches!(att.attest.body, AttestBody::Ref { serial: 2, .. }));
2335 +
2336 +
        // B pulls and ends up identical.
2337 +
        let pr = b.pull("origin").unwrap();
2338 +
        assert_eq!(pr.heads, want);
2339 +
        assert_eq!(fs::read(root.join("b/g.txt")).unwrap(), b"g\n");
2340 +
        assert!(!b.snapshot("status").unwrap().changed);
2341 +
2342 +
        // Concurrent edits of the same line: the union is a reified conflict.
2343 +
        write(&root.join("a"), "f.txt", b"a\nA\n");
2344 +
        a.commit("a-edit", &[]).unwrap();
2345 +
        a.push("origin", false).unwrap();
2346 +
        write(&root.join("b"), "f.txt", b"a\nB\n");
2347 +
        b.commit("b-edit", &[]).unwrap();
2348 +
        let pr = b.pull("origin").unwrap();
2349 +
        assert!(pr.changed && pr.checked_out);
2350 +
        let text = fs::read_to_string(root.join("b/f.txt")).unwrap();
2351 +
        assert!(text.contains("<<<<<<< oxy conflict"), "{text}");
2352 +
        write(&root.join("b"), "f.txt", b"a\nAB\n");
2353 +
        let hr = b.commit("resolve", &[]).unwrap();
2354 +
        assert_eq!(b.store().get_change(&hr).unwrap().kind, ChangeKind::Resolution);
2355 +
        assert!(b.push("origin", false).unwrap()[0].ok);
2356 +
        a.pull("origin").unwrap();
2357 +
        assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nAB\n");
2358 +
        assert_eq!(a.bookmark_heads().unwrap(), vec![hr]);
2359 +
    }
2360 +
2361 +
    #[test]
2362 +
    fn receive_push_rejects_bad_updates() {
2363 +
        let dir = tempfile::tempdir().unwrap();
2364 +
        let root = dir.path();
2365 +
        let origin = Repo::init(&root.join("origin")).unwrap();
2366 +
        let url = root.join("origin").to_string_lossy().to_string();
2367 +
        let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_keys_dir(root.join("keys"));
2368 +
        let (key, _) = a.create_identity("Ada", "a@b", ).unwrap();
2369 +
        write(&root.join("a"), "f.txt", b"x\n");
2370 +
        let h1 = a.commit("one", &[]).unwrap();
2371 +
        assert!(a.push("origin", false).unwrap()[0].ok);
2372 +
        let empty = origin.store().put_state(&StateObject::new(vec![])).unwrap();
2373 +
        let s1 = origin.store().put_state(&StateObject::new(vec![h1])).unwrap();
2374 +
2375 +
        // Missing head.
2376 +
        let ghost = origin.store().put_state(&StateObject::new(vec![ObjectRef([9; 32])])).unwrap();
2377 +
        let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: ghost, force: false, attestation: None }]).unwrap();
2378 +
        assert!(!r[0].ok && r[0].reason.contains("missing"), "{}", r[0].reason);
2379 +
2380 +
        // Stale old_state.
2381 +
        let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: None }]).unwrap();
2382 +
        assert!(!r[0].ok && r[0].reason.contains("stale"), "{}", r[0].reason);
2383 +
2384 +
        // Non-fast-forward without force is refused; with force it passes.
2385 +
        let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: false, attestation: None }]).unwrap();
2386 +
        assert!(!r[0].ok && r[0].reason.contains("fast-forward"), "{}", r[0].reason);
2387 +
        let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: true, attestation: None }]).unwrap();
2388 +
        assert!(r[0].ok);
2389 +
        assert_eq!(origin.bookmark_heads().unwrap(), vec![]);
2390 +
2391 +
        // Attestation with a stale serial is a rollback and is refused.
2392 +
        let sa = a.sign_attest(&Attest { repo: a.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: "main".into(), old_state: empty, new_state: s1, serial: 1 } }).unwrap();
2393 +
        let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa.clone()) }]).unwrap();
2394 +
        assert!(!r[0].ok && r[0].reason.contains("serial"), "{}", r[0].reason);
2395 +
        // An attestation for a different update is refused too.
2396 +
        let sa2 = a.sign_attest(&Attest { repo: a.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: "main".into(), old_state: s1, new_state: empty, serial: 5 } }).unwrap();
2397 +
        let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa2) }]).unwrap();
2398 +
        assert!(!r[0].ok && r[0].reason.contains("describe"), "{}", r[0].reason);
2399 +
        let _ = key;
2400 +
    }
2401 +
2402 +
    #[test]
1701 2403
    fn format1_repositories_are_migrated_on_open() {
1702 2404
        use crate::oplog::{Format1Op, Format1View};
1703 2405
        let dir = tempfile::tempdir().unwrap();
…
112 unchanged lines
modified crates/oxy-core/src/store.rs +40 −0
…
1 unchanged lines
2 2
//! `objects/loose/<hh>/<rest>`, written atomically and verified on read.
3 3
//! Packs come later.
4 4
5 +
use crate::attest::Attest;
5 6
use crate::hash::{hash_object, header, ObjectRef, ObjectType};
6 7
use crate::identity::{Identity, Signature};
7 8
use crate::objects::{Change, ObjectError, StateObject};
…
118 unchanged lines
126 127
        Ok(StateObject::decode(&self.get_typed(r, ObjectType::State)?)?)
127 128
    }
128 129
130 +
    pub fn put_attest(&self, a: &Attest) -> Result<ObjectRef> {
131 +
        self.put(ObjectType::Attest, &a.encode())
132 +
    }
133 +
134 +
    pub fn get_attest(&self, r: &ObjectRef) -> Result<Attest> {
135 +
        Ok(Attest::decode(&self.get_typed(r, ObjectType::Attest)?)?)
136 +
    }
137 +
138 +
    fn seal_index_path(&self, change: &ObjectRef) -> PathBuf {
139 +
        let h = change.to_hex();
140 +
        self.dir.join("seals").join(&h[..2]).join(&h[2..])
141 +
    }
142 +
143 +
    /// Record that seal attestation `attest` covers `change`.
144 +
    pub fn index_seal(&self, change: &ObjectRef, attest: &ObjectRef) -> Result<()> {
145 +
        let mut seals = self.seals_for(change)?;
146 +
        if seals.contains(attest) {
147 +
            return Ok(());
148 +
        }
149 +
        seals.push(*attest);
150 +
        seals.sort();
151 +
        let path = self.seal_index_path(change);
152 +
        fs::create_dir_all(path.parent().unwrap())?;
153 +
        let text: String = seals.iter().map(|s| format!("{}\n", s.to_hex())).collect();
154 +
        let tmp = path.with_extension("tmp");
155 +
        fs::write(&tmp, text)?;
156 +
        fs::rename(&tmp, &path)?;
157 +
        Ok(())
158 +
    }
159 +
160 +
    /// Seal attestations known to cover `change`.
161 +
    pub fn seals_for(&self, change: &ObjectRef) -> Result<Vec<ObjectRef>> {
162 +
        match fs::read_to_string(self.seal_index_path(change)) {
163 +
            Ok(t) => Ok(t.lines().filter_map(ObjectRef::from_hex).collect()),
164 +
            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(vec![]),
165 +
            Err(e) => Err(e.into()),
166 +
        }
167 +
    }
168 +
129 169
    pub fn put_identity(&self, id: &Identity) -> Result<ObjectRef> {
130 170
        self.put(ObjectType::Identity, &id.encode())
131 171
    }
…
116 unchanged lines
modified src/main.rs +104 −2
…
15 unchanged lines
16 16
enum Cmd {
17 17
    /// Create a repository in PATH (default: current directory)
18 18
    Init { path: Option<PathBuf> },
19 +
    /// Clone a repository from a local path
20 +
    Clone { url: String, dest: Option<PathBuf> },
21 +
    /// Manage remotes
22 +
    Remote {
23 +
        #[command(subcommand)]
24 +
        cmd: RemoteCmd,
25 +
    },
26 +
    /// Fetch every bookmark of a remote into its tracking references
27 +
    Fetch { remote: Option<String> },
28 +
    /// Fetch, then union the remote bookmark into the local one (no merge change)
29 +
    Pull { remote: Option<String> },
30 +
    /// Push the current bookmark to a remote
31 +
    Push {
32 +
        remote: Option<String>,
33 +
        /// Allow dropping heads the remote has
34 +
        #[arg(long)]
35 +
        force: bool,
36 +
    },
19 37
    /// Show the working copy: changed paths and conflicts
20 38
    Status,
21 39
    /// Record the working copy as a change on the current bookmark
…
44 unchanged lines
66 84
}
67 85
68 86
#[derive(Subcommand)]
87 +
enum RemoteCmd {
88 +
    Add { name: String, url: String },
89 +
    Remove { name: String },
90 +
    List,
91 +
}
92 +
93 +
#[derive(Subcommand)]
69 94
enum IdentityCmd {
70 95
    /// Generate a composite Ed25519 + ML-DSA-65 key pair and make it the author
71 96
    Create {
…
7 unchanged lines
79 104
}
80 105
81 106
fn signature_line(repo: &Repo, h: &oxy_core::hash::ChangeHash) -> Result<String> {
82 -
    Ok(match repo.verify_change(h)? {
107 +
    Ok(match repo.authenticate_change(h)? {
83 108
        SignatureStatus::Unsigned => "unsigned".to_string(),
84 -
        SignatureStatus::Valid { key, name, email } => format!("signed by {name} <{email}> key {}", key.short(12)),
109 +
        SignatureStatus::Valid { key, name, email, sealed } => {
110 +
            format!("{} by {name} <{email}> key {}", if sealed { "sealed" } else { "signed" }, key.short(12))
111 +
        }
85 112
        SignatureStatus::Invalid(why) => format!("INVALID signature: {why}"),
86 113
    })
87 114
}
…
50 unchanged lines
138 165
            println!("Bookmark: {}   Author: {} <{}>", repo.config.bookmark, repo.config.author_name, repo.config.author_email);
139 166
            Ok(0)
140 167
        }
168 +
        Cmd::Clone { url, dest } => {
169 +
            let dest = dest.unwrap_or_else(|| {
170 +
                let base = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo");
171 +
                PathBuf::from(base)
172 +
            });
173 +
            let repo = Repo::clone(&url, &dest)?;
174 +
            let heads = repo.bookmark_heads()?;
175 +
            println!("Cloned into {} ({} on {}: {} head{})", repo.root().display(), url, repo.config.bookmark, heads.len(), if heads.len() == 1 { "" } else { "s" });
176 +
            Ok(0)
177 +
        }
178 +
        Cmd::Remote { cmd } => {
179 +
            let mut repo = open()?;
180 +
            match cmd {
181 +
                RemoteCmd::Add { name, url } => {
182 +
                    repo.config.set(&format!("remote.{name}.url"), &url)?;
183 +
                    repo.save_config()?;
184 +
                    println!("Added remote {name} = {url}");
185 +
                }
186 +
                RemoteCmd::Remove { name } => {
187 +
                    repo.config.set(&format!("remote.{name}.url"), "")?;
188 +
                    repo.save_config()?;
189 +
                    println!("Removed remote {name}");
190 +
                }
191 +
                RemoteCmd::List => {
192 +
                    let view = repo.view()?;
193 +
                    for (name, url) in &repo.config.remotes {
194 +
                        println!("{name}\t{url}");
195 +
                        if let Some(bms) = view.remotes.get(name) {
196 +
                            for (bm, st) in bms {
197 +
                                let heads = repo.store().get_state(st).map(|s| s.heads.len()).unwrap_or(0);
198 +
                                println!("    {bm}: {heads} head{}", if heads == 1 { "" } else { "s" });
199 +
                            }
200 +
                        }
201 +
                    }
202 +
                }
203 +
            }
204 +
            Ok(0)
205 +
        }
206 +
        Cmd::Fetch { remote } => {
207 +
            let repo = open()?;
208 +
            let remote = remote.unwrap_or_else(|| "origin".into());
209 +
            for (name, heads, n) in repo.fetch(&remote)? {
210 +
                println!("{remote}/{name}: {} head{}, {n} new change{}", heads.len(), if heads.len() == 1 { "" } else { "s" }, if n == 1 { "" } else { "s" });
211 +
            }
212 +
            Ok(0)
213 +
        }
214 +
        Cmd::Pull { remote } => {
215 +
            let repo = open()?;
216 +
            let remote = remote.unwrap_or_else(|| "origin".into());
217 +
            let r = repo.pull(&remote)?;
218 +
            if !r.changed {
219 +
                println!("Already up to date.");
220 +
            } else {
221 +
                println!("{} now has {} head{}{}", repo.config.bookmark, r.heads.len(), if r.heads.len() == 1 { "" } else { "s" }, if r.checked_out { "; working copy updated" } else { "; run `oxy checkout` to update the working copy" });
222 +
                let snap = repo.snapshot("oxy pull")?;
223 +
                if snap.base_snapshot.is_conflicted() {
224 +
                    println!("The result has conflicts; see oxy status.");
225 +
                }
226 +
            }
227 +
            Ok(0)
228 +
        }
229 +
        Cmd::Push { remote, force } => {
230 +
            let repo = open()?;
231 +
            let remote = remote.unwrap_or_else(|| "origin".into());
232 +
            let mut code = 0;
233 +
            for r in repo.push(&remote, force)? {
234 +
                if r.ok {
235 +
                    println!("{remote}/{}: updated", r.name);
236 +
                } else {
237 +
                    println!("{remote}/{}: rejected: {}", r.name, r.reason);
238 +
                    code = 1;
239 +
                }
240 +
            }
241 +
            Ok(code)
242 +
        }
141 243
        Cmd::Status => {
142 244
            let repo = open()?;
143 245
            let snap = repo.snapshot("oxy status")?;
…
224 unchanged lines