Tags: immutable named states with tag attestations, in views and on the wire, policy-governed, exported to Git (spec 0.6h) 132b884b9a8d
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-26 22:39 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
132b884b9a8d9894c9ad7624370a26285446f9d49dbfc762e1ef16e0a2e88133- Depends on
- 252e8cb23fbc 2863456a0f6d 5b1114a04905 5b9c6ad45f6e 7421309f5372 750251b21a96 79ab8528ff62 83e0677b07fe 86e682c242c6 982864068a59 ba160f60219e fce44ae9ad29 ff0af1ee299d
- Depended on by
- 2f0b4b0651ea b63fc7a77fc6 ee130dd9eef2 1ec6fdbc6bcb d8524a9f0117 d36ba2f79f95 028545dfa7a4 767ac6cd0545 e59512f2f075 4b9d06778581 e5d9b0513f3e 7ba155f20aa0 b2ef93bb2366 a46f3e4f50b1 14edbaaf5291 2c75672fb2fe 57eaac9107df 6caacfff1d4f fc1a466a1e66 6a81dbab471d 54cd08a1e7bf cc7a00565161 d42369abf180 19555025a2ac 3526d339519f eae595568832 84b9e9ac0ab4 75167672ebcd e02bf0cd0533 c792033af344 1196b29b5fa9 4dee277c327a 7b9809e67c3d 30ea9c1e70fa 94084087a876 28fc23d7706a 6d3eefbce2f0 4e88e774777a
- 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
- 72 atoms, 1 tree operation, 14 files
Browse the tree at this change
added crates/oxy-core/src/repo/tags.rs +145 −0
| 1 | + | //! Tags (spec §10.1, §14.4): immutable named states. A tag is a `tags/<name>` |
|
| 2 | + | //! entry in the view pointing at a state object, plus a signed `tag` |
|
| 3 | + | //! attestation when the author has a key. A name is never reassigned; a |
|
| 4 | + | //! server refuses a second attestation for an existing tag name. |
|
| 5 | + | ||
| 6 | + | use super::*; |
|
| 7 | + | ||
| 8 | + | #[derive(Debug, Clone, PartialEq, Eq)] |
|
| 9 | + | pub struct TagInfo { |
|
| 10 | + | pub name: String, |
|
| 11 | + | pub state: ObjectRef, |
|
| 12 | + | pub heads: Vec<ChangeHash>, |
|
| 13 | + | pub message: String, |
|
| 14 | + | /// The signer's identity name, when attested by a known key. |
|
| 15 | + | pub signer: Option<String>, |
|
| 16 | + | } |
|
| 17 | + | ||
| 18 | + | impl Repo { |
|
| 19 | + | fn tag_attest_path(&self, name: &str) -> PathBuf { |
|
| 20 | + | self.oxy.join("attest").join("tags").join(name) |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | /// The attestation recorded for a tag, if any. |
|
| 24 | + | pub fn tag_attest(&self, name: &str) -> Result<Option<(ObjectRef, SignedAttest)>> { |
|
| 25 | + | match fs::read_to_string(self.tag_attest_path(name)) { |
|
| 26 | + | Ok(h) => { |
|
| 27 | + | let r = ObjectRef::from_hex(h.trim()).ok_or_else(|| RepoError::Msg("corrupt tag attestation pointer".into()))?; |
|
| 28 | + | Ok(Some((r, SignedAttest { attest: self.store.get_attest(&r)?, sigs: self.store.signatures(&r)? }))) |
|
| 29 | + | } |
|
| 30 | + | Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), |
|
| 31 | + | Err(e) => Err(e.into()), |
|
| 32 | + | } |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | fn set_tag_attest(&self, name: &str, r: &ObjectRef) -> Result<()> { |
|
| 36 | + | fs::create_dir_all(self.oxy.join("attest").join("tags"))?; |
|
| 37 | + | write_atomic(&self.tag_attest_path(name), r.to_hex().as_bytes()) |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | /// Create a tag named `name` for the state of `heads` (spec §10.1). |
|
| 41 | + | pub fn tag_create(&self, name: &str, heads: &[ChangeHash], message: &str) -> Result<TagInfo> { |
|
| 42 | + | crate::objects::validate_name(name).map_err(|e| RepoError::Msg(format!("tag name: {e}")))?; |
|
| 43 | + | let (op_ref, op) = self.head_op()?; |
|
| 44 | + | if op.view.tags.contains_key(name) { |
|
| 45 | + | return fail(format!("tag {name:?} already exists; tags are immutable")); |
|
| 46 | + | } |
|
| 47 | + | let st = self.load_state(heads)?; |
|
| 48 | + | let heads = st.maximal(heads); |
|
| 49 | + | if heads.is_empty() { |
|
| 50 | + | return fail("a tag needs at least one change"); |
|
| 51 | + | } |
|
| 52 | + | let state = self.store.put_state(&StateObject::new(heads.clone()))?; |
|
| 53 | + | let mut signer = None; |
|
| 54 | + | if self.secret_key()?.is_some() { |
|
| 55 | + | let sa = self.sign_attest(&Attest { repo: self.id()?, hlc: Hlc::now(), body: AttestBody::Tag { name: name.into(), state, message: message.into() } })?; |
|
| 56 | + | let r = sa.attest.hash(); |
|
| 57 | + | self.set_tag_attest(name, &r)?; |
|
| 58 | + | signer = sa.sigs.first().and_then(|s| self.store.get_identity(&s.key).ok()).map(|i| i.name); |
|
| 59 | + | } |
|
| 60 | + | let mut view = op.view.clone(); |
|
| 61 | + | view.tags.insert(name.into(), state); |
|
| 62 | + | self.record_op(&[op_ref], &format!("oxy tag create {name}"), view)?; |
|
| 63 | + | Ok(TagInfo { name: name.into(), state, heads, message: message.into(), signer }) |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | /// Every tag, by name. |
|
| 67 | + | pub fn tags(&self) -> Result<Vec<TagInfo>> { |
|
| 68 | + | let view = self.view()?; |
|
| 69 | + | let mut out = Vec::new(); |
|
| 70 | + | for (name, state) in &view.tags { |
|
| 71 | + | let heads = self.store.get_state(state)?.heads; |
|
| 72 | + | let (message, signer) = match self.tag_attest(name)? { |
|
| 73 | + | Some((_, sa)) => { |
|
| 74 | + | let message = match &sa.attest.body { |
|
| 75 | + | AttestBody::Tag { message, .. } => message.clone(), |
|
| 76 | + | _ => String::new(), |
|
| 77 | + | }; |
|
| 78 | + | (message, sa.sigs.first().and_then(|s| self.store.get_identity(&s.key).ok()).map(|i| i.name)) |
|
| 79 | + | } |
|
| 80 | + | None => (String::new(), None), |
|
| 81 | + | }; |
|
| 82 | + | out.push(TagInfo { name: name.clone(), state: *state, heads, message, signer }); |
|
| 83 | + | } |
|
| 84 | + | Ok(out) |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | /// The heads a tag names, if it exists. |
|
| 88 | + | pub fn tag_heads(&self, name: &str) -> Result<Option<Vec<ChangeHash>>> { |
|
| 89 | + | match self.view()?.tags.get(name) { |
|
| 90 | + | Some(st) => Ok(Some(self.store.get_state(st)?.heads)), |
|
| 91 | + | None => Ok(None), |
|
| 92 | + | } |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | /// Tags as `refs` reports them. |
|
| 96 | + | pub(crate) fn tag_refs(&self) -> Result<Vec<crate::protocol::TagRef>> { |
|
| 97 | + | let mut out = Vec::new(); |
|
| 98 | + | for t in self.tags()? { |
|
| 99 | + | out.push(crate::protocol::TagRef { name: t.name.clone(), state: t.state, heads: t.heads, attestation: self.tag_attest(&t.name)?.map(|(_, a)| a) }); |
|
| 100 | + | } |
|
| 101 | + | Ok(out) |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | /// Adopt a tag received from a remote or a push. Verifies the |
|
| 105 | + | /// attestation when present (and requires one, by an authorized key, |
|
| 106 | + | /// under a policy). An existing tag with a different state is refused. |
|
| 107 | + | pub(crate) fn adopt_tag(&self, view: &mut View, t: &crate::protocol::TagRef, policy: Option<&crate::policy::Policy>) -> std::result::Result<(), String> { |
|
| 108 | + | if let Some(existing) = view.tags.get(&t.name) { |
|
| 109 | + | return if *existing == t.state { Ok(()) } else { Err(format!("tag {:?} already exists with a different state; tags are immutable", t.name)) }; |
|
| 110 | + | } |
|
| 111 | + | let state = self.store.get_state(&t.state).map_err(|_| "tag state object is missing".to_string())?; |
|
| 112 | + | for h in &state.heads { |
|
| 113 | + | if !self.store.has(h) && !self.boundary().contains(h) { |
|
| 114 | + | return Err(format!("tag {:?} names change {}, which is missing", t.name, h.short(12))); |
|
| 115 | + | } |
|
| 116 | + | } |
|
| 117 | + | if let Some(sa) = &t.attestation { |
|
| 118 | + | let AttestBody::Tag { name, state: st, .. } = &sa.attest.body else { return Err("tag attestation has the wrong kind".into()) }; |
|
| 119 | + | if *name != t.name || *st != t.state { |
|
| 120 | + | return Err("tag attestation does not describe this tag".into()); |
|
| 121 | + | } |
|
| 122 | + | if sa.attest.repo != self.id().map_err(|e| e.to_string())? { |
|
| 123 | + | return Err("tag attestation is for a different repository".into()); |
|
| 124 | + | } |
|
| 125 | + | let r = sa.attest.hash(); |
|
| 126 | + | let sig = sa.sigs.first().ok_or("tag attestation carries no signature")?; |
|
| 127 | + | let id = self.store.get_identity(&sig.key).map_err(|_| format!("unknown key {}", sig.key.short(12)))?; |
|
| 128 | + | verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?; |
|
| 129 | + | if let Some(p) = policy { |
|
| 130 | + | if !p.authorizes(&sig.key, &format!("tags/{}", t.name)) { |
|
| 131 | + | return Err(format!("key {} may not create tags/{} under this repository's policy", sig.key.short(12), t.name)); |
|
| 132 | + | } |
|
| 133 | + | } |
|
| 134 | + | let stored = self.store.put_attest(&sa.attest).map_err(|e| e.to_string())?; |
|
| 135 | + | for s in &sa.sigs { |
|
| 136 | + | self.store.put_signature(&stored, s).map_err(|e| e.to_string())?; |
|
| 137 | + | } |
|
| 138 | + | self.set_tag_attest(&t.name, &stored).map_err(|e| e.to_string())?; |
|
| 139 | + | } else if policy.is_some() { |
|
| 140 | + | return Err(format!("tag {:?} needs a signed attestation under this repository's policy", t.name)); |
|
| 141 | + | } |
|
| 142 | + | view.tags.insert(t.name.clone(), t.state); |
|
| 143 | + | Ok(()) |
|
| 144 | + | } |
|
| 145 | + | } |
modified crates/oxy-core/src/attest.rs +14 −0
| … | 12 unchanged lines |
||
| 13 | 13 | Ref { name: String, old_state: ObjectRef, new_state: ObjectRef, serial: u64 }, |
|
| 14 | 14 | /// One signature covering a batch of changes (spec §14.4). |
|
| 15 | 15 | Seal { hashes: Vec<ChangeHash> }, |
|
| 16 | + | /// An immutable named state (spec §10.1). |
|
| 17 | + | Tag { name: String, state: ObjectRef, message: String }, |
|
| 16 | 18 | } |
|
| 17 | 19 | ||
| 18 | 20 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| … | 9 unchanged lines |
||
| 28 | 30 | match self.body { |
|
| 29 | 31 | AttestBody::Ref { .. } => "ref", |
|
| 30 | 32 | AttestBody::Seal { .. } => "seal", |
|
| 33 | + | AttestBody::Tag { .. } => "tag", |
|
| 31 | 34 | } |
|
| 32 | 35 | } |
|
| 33 | 36 | ||
| … | 14 unchanged lines |
||
| 48 | 51 | AttestBody::Seal { hashes } => { |
|
| 49 | 52 | Value::map([(0, Value::Array(hashes.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()))]) |
|
| 50 | 53 | } |
|
| 54 | + | AttestBody::Tag { name, state, message } => { |
|
| 55 | + | Value::map([(0, Value::Text(name.clone())), (1, Value::Bytes(state.0.to_vec())), (2, Value::Text(message.clone()))]) |
|
| 56 | + | } |
|
| 51 | 57 | }; |
|
| 52 | 58 | Value::map([ |
|
| 53 | 59 | (0, Value::Uint(FORMAT_VERSION)), |
|
| … | 36 unchanged lines |
||
| 90 | 96 | } |
|
| 91 | 97 | AttestBody::Seal { hashes } |
|
| 92 | 98 | } |
|
| 99 | + | "tag" => AttestBody::Tag { |
|
| 100 | + | name: text(field(b, 0, "name")?, "name")?.to_owned(), |
|
| 101 | + | state: oref(field(b, 1, "state")?, "state")?, |
|
| 102 | + | message: text(field(b, 2, "message")?, "message")?.to_owned(), |
|
| 103 | + | }, |
|
| 93 | 104 | other => return Err(ObjectError::Schema(format!("unknown attestation kind {other:?}"))), |
|
| 94 | 105 | }; |
|
| 95 | 106 | Ok(Attest { repo, hlc, body }) |
|
| … | 17 unchanged lines |
||
| 113 | 124 | let d = Attest::decode(&s.encode()).unwrap(); |
|
| 114 | 125 | assert!(matches!(&d.body, AttestBody::Seal { hashes } if hashes == &vec![ObjectRef([3; 32]), ObjectRef([9; 32])])); |
|
| 115 | 126 | assert_ne!(s.hash(), r.hash()); |
|
| 127 | + | let t = Attest { repo: [1; 16], hlc: Hlc::default(), body: AttestBody::Tag { name: "v1".into(), state: ObjectRef([5; 32]), message: "release".into() } }; |
|
| 128 | + | assert_eq!(Attest::decode(&t.encode()).unwrap(), t); |
|
| 129 | + | assert_eq!(t.kind(), "tag"); |
|
| 116 | 130 | } |
|
| 117 | 131 | ||
| 118 | 132 | #[test] |
|
| … | 7 unchanged lines |
modified crates/oxy-core/src/oplog.rs +13 −0
| … | 21 unchanged lines |
||
| 22 | 22 | pub remotes: BTreeMap<String, BTreeMap<String, ObjectRef>>, |
|
| 23 | 23 | /// The `policy` reference (spec §10.1, §14.5): ObjectRef of the current policy. |
|
| 24 | 24 | pub policy: Option<ObjectRef>, |
|
| 25 | + | /// `tags/<name>`: immutable states (spec §10.1). |
|
| 26 | + | pub tags: BTreeMap<String, ObjectRef>, |
|
| 25 | 27 | } |
|
| 26 | 28 | ||
| 27 | 29 | /// A format-1 view, only produced by `Op::decode_format1`. |
|
| … | 49 unchanged lines |
||
| 77 | 79 | ), |
|
| 78 | 80 | ), |
|
| 79 | 81 | (3, self.policy.map(|p| Value::Bytes(p.0.to_vec())).unwrap_or(Value::Null)), |
|
| 82 | + | (4, Value::Array(self.tags.iter().map(|(n, st)| Value::Array(vec![Value::Text(n.clone()), Value::Bytes(st.0.to_vec())])).collect())), |
|
| 80 | 83 | ]) |
|
| 81 | 84 | } |
|
| 82 | 85 | ||
| … | 30 unchanged lines |
||
| 113 | 116 | if let Some(p) = m.get(&3) { |
|
| 114 | 117 | view.policy = if p.is_null() { None } else { Some(oref(p, "policy")?) }; |
|
| 115 | 118 | } |
|
| 119 | + | if let Some(ts) = m.get(&4) { |
|
| 120 | + | for t in array(ts, "tags")? { |
|
| 121 | + | let pair = array(t, "tag")?; |
|
| 122 | + | if pair.len() != 2 { |
|
| 123 | + | return Err(ObjectError::Schema("tag: expected [name, state]".into())); |
|
| 124 | + | } |
|
| 125 | + | view.tags.insert(text(&pair[0], "tag name")?.to_owned(), oref(&pair[1], "tag state")?); |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 116 | 128 | Ok(view) |
|
| 117 | 129 | } |
|
| 118 | 130 | } |
|
| … | 147 unchanged lines |
||
| 266 | 278 | view.workspaces.insert(WorkspaceId([5; 16]), ObjectRef([9; 32])); |
|
| 267 | 279 | view.remotes.entry("origin".into()).or_default().insert("main".into(), ObjectRef([4; 32])); |
|
| 268 | 280 | view.policy = Some(ObjectRef([6; 32])); |
|
| 281 | + | view.tags.insert("v1".into(), ObjectRef([8; 32])); |
|
| 269 | 282 | let op = Op { parents: vec![ObjectRef([7; 32])], hlc: Hlc { wall_ms: 5, logical: 1 }, command: "oxy commit".into(), view }; |
|
| 270 | 283 | let d = Op::decode(&op.encode()).unwrap(); |
|
| 271 | 284 | assert_eq!(d, op); |
|
| … | 11 unchanged lines |
modified crates/oxy-core/src/protocol.rs +69 −14
| … | 32 unchanged lines |
||
| 33 | 33 | pub sigs: Vec<Signature>, |
|
| 34 | 34 | } |
|
| 35 | 35 | ||
| 36 | - | /// What `refs` returns: every bookmark plus the current policy, if any. |
|
| 36 | + | /// What `refs` returns: every bookmark, every tag, and the current policy. |
|
| 37 | 37 | #[derive(Clone, Debug, Default)] |
|
| 38 | 38 | pub struct RefsResponse { |
|
| 39 | 39 | pub refs: Vec<RemoteRef>, |
|
| 40 | 40 | pub policy: Option<SignedPolicy>, |
|
| 41 | + | pub tags: Vec<TagRef>, |
|
| 41 | 42 | } |
|
| 42 | 43 | ||
| 44 | + | /// A tag as a remote reports it (spec §10.1). |
|
| 45 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 46 | + | pub struct TagRef { |
|
| 47 | + | pub name: String, |
|
| 48 | + | pub state: ObjectRef, |
|
| 49 | + | pub heads: Vec<ChangeHash>, |
|
| 50 | + | pub attestation: Option<SignedAttest>, |
|
| 51 | + | } |
|
| 52 | + | ||
| 43 | 53 | /// One reference as a remote reports it. |
|
| 44 | 54 | #[derive(Clone, Debug)] |
|
| 45 | 55 | pub struct RemoteRef { |
|
| … | 53 unchanged lines |
||
| 99 | 109 | fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle>; |
|
| 100 | 110 | /// The changes within `depth` dependency steps of `heads` (spec §17). |
|
| 101 | 111 | fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>>; |
|
| 102 | - | /// Push changes, bookmark updates, and optionally a newer policy. The |
|
| 103 | - | /// policy is applied first; a result named `policy` reports it. |
|
| 104 | - | fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>>; |
|
| 112 | + | /// Push changes, bookmark updates, new tags, and optionally a newer |
|
| 113 | + | /// policy. The policy is applied first; a result named `policy` reports |
|
| 114 | + | /// it, and each tag reports as `tags/<name>`. |
|
| 115 | + | fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>, tags: Vec<TagRef>) -> Result<Vec<RefResult>>; |
|
| 105 | 116 | } |
|
| 106 | 117 | ||
| 107 | 118 | // ---------------------------------------------------------------- local |
|
| … | 31 unchanged lines |
||
| 139 | 150 | fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> { |
|
| 140 | 151 | self.repo.shallow_changes(heads, depth) |
|
| 141 | 152 | } |
|
| 142 | - | fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> { |
|
| 143 | - | self.repo.receive_push(bundle, updates, policy) |
|
| 153 | + | fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>, tags: Vec<TagRef>) -> Result<Vec<RefResult>> { |
|
| 154 | + | self.repo.receive_push(bundle, updates, policy, tags) |
|
| 144 | 155 | } |
|
| 145 | 156 | } |
|
| 146 | 157 | ||
| … | 94 unchanged lines |
||
| 241 | 252 | fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> { |
|
| 242 | 253 | wire::decode_hashes(&self.call(true, "shallow", Some(&wire::encode_shallow(heads, depth)))?) |
|
| 243 | 254 | } |
|
| 244 | - | fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> { |
|
| 245 | - | wire::decode_push_response(&self.call(true, "push", Some(&wire::encode_push(&bundle, &updates, policy.as_ref())))?) |
|
| 255 | + | fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>, tags: Vec<TagRef>) -> Result<Vec<RefResult>> { |
|
| 256 | + | wire::decode_push_response(&self.call(true, "push", Some(&wire::encode_push(&bundle, &updates, policy.as_ref(), &tags)))?) |
|
| 246 | 257 | } |
|
| 247 | 258 | } |
|
| 248 | 259 | ||
| … | 133 unchanged lines |
||
| 382 | 393 | o::bytes(field(m, 1, "repo id")?, "repo id")?.try_into().map_err(|_| schema("repo id: expected 16 bytes")) |
|
| 383 | 394 | } |
|
| 384 | 395 | ||
| 396 | + | fn tags_value(tags: &[TagRef]) -> Value { |
|
| 397 | + | Value::Array( |
|
| 398 | + | tags.iter() |
|
| 399 | + | .map(|t| { |
|
| 400 | + | Value::map([ |
|
| 401 | + | (0, Value::Text(t.name.clone())), |
|
| 402 | + | (1, Value::Bytes(t.state.0.to_vec())), |
|
| 403 | + | (2, hashes_value(&t.heads)), |
|
| 404 | + | (3, opt_attest_value(&t.attestation)), |
|
| 405 | + | ]) |
|
| 406 | + | }) |
|
| 407 | + | .collect(), |
|
| 408 | + | ) |
|
| 409 | + | } |
|
| 410 | + | fn tags_from(v: &Value) -> Result<Vec<TagRef>> { |
|
| 411 | + | let mut out = Vec::new(); |
|
| 412 | + | for t in o::array(v, "tags")? { |
|
| 413 | + | let tm = map(t, "tag")?; |
|
| 414 | + | out.push(TagRef { |
|
| 415 | + | name: o::text(field(tm, 0, "name")?, "name")?.to_owned(), |
|
| 416 | + | state: o::oref(field(tm, 1, "state")?, "state")?, |
|
| 417 | + | heads: hashes_from(field(tm, 2, "heads")?)?, |
|
| 418 | + | attestation: opt_attest_from(field(tm, 3, "attestation")?)?, |
|
| 419 | + | }); |
|
| 420 | + | } |
|
| 421 | + | Ok(out) |
|
| 422 | + | } |
|
| 423 | + | ||
| 385 | 424 | pub fn encode_refs(id: [u8; 16], resp: &RefsResponse) -> Vec<u8> { |
|
| 386 | 425 | cbor::encode(&Value::map([ |
|
| 387 | 426 | ( |
|
| … | 15 unchanged lines |
||
| 403 | 442 | ), |
|
| 404 | 443 | (1, Value::Bytes(id.to_vec())), |
|
| 405 | 444 | (2, opt_policy_value(resp.policy.as_ref())), |
|
| 445 | + | (3, tags_value(&resp.tags)), |
|
| 406 | 446 | ])) |
|
| 407 | 447 | } |
|
| 408 | 448 | pub fn decode_refs(bytes: &[u8]) -> Result<([u8; 16], RefsResponse)> { |
|
| … | 16 unchanged lines |
||
| 425 | 465 | Some(p) => opt_policy_from(p)?, |
|
| 426 | 466 | None => None, |
|
| 427 | 467 | }; |
|
| 428 | - | Ok((id, RefsResponse { refs, policy })) |
|
| 468 | + | let tags = match m.get(&3) { |
|
| 469 | + | Some(t) => tags_from(t)?, |
|
| 470 | + | None => Vec::new(), |
|
| 471 | + | }; |
|
| 472 | + | Ok((id, RefsResponse { refs, policy, tags })) |
|
| 429 | 473 | } |
|
| 430 | 474 | ||
| 431 | 475 | pub fn encode_negotiate(have: &[ChangeHash], want: &[ChangeHash]) -> Vec<u8> { |
|
| … | 37 unchanged lines |
||
| 469 | 513 | hashes_from(field(map(&v, "hashes")?, 0, "hashes")?) |
|
| 470 | 514 | } |
|
| 471 | 515 | ||
| 472 | - | pub fn encode_push(bundle: &Bundle, updates: &[RefUpdate], policy: Option<&SignedPolicy>) -> Vec<u8> { |
|
| 516 | + | pub fn encode_push(bundle: &Bundle, updates: &[RefUpdate], policy: Option<&SignedPolicy>, tags: &[TagRef]) -> Vec<u8> { |
|
| 473 | 517 | cbor::encode(&Value::map([ |
|
| 474 | 518 | (0, bundle_value(bundle)), |
|
| 475 | 519 | (2, opt_policy_value(policy)), |
|
| 520 | + | (3, tags_value(tags)), |
|
| 476 | 521 | ( |
|
| 477 | 522 | 1, |
|
| 478 | 523 | Value::Array( |
|
| … | 13 unchanged lines |
||
| 492 | 537 | ), |
|
| 493 | 538 | ])) |
|
| 494 | 539 | } |
|
| 495 | - | pub fn decode_push(bytes: &[u8]) -> Result<(Bundle, Vec<RefUpdate>, Option<SignedPolicy>)> { |
|
| 540 | + | /// A decoded push: bundle, bookmark updates, policy, tags. |
|
| 541 | + | pub type PushParts = (Bundle, Vec<RefUpdate>, Option<SignedPolicy>, Vec<TagRef>); |
|
| 542 | + | ||
| 543 | + | pub fn decode_push(bytes: &[u8]) -> Result<PushParts> { |
|
| 496 | 544 | let v = cbor::decode(bytes).map_err(ObjectError::from)?; |
|
| 497 | 545 | let m = map(&v, "push")?; |
|
| 498 | 546 | let bundle = bundle_from_value(field(m, 0, "bundle")?)?; |
|
| … | 12 unchanged lines |
||
| 511 | 559 | Some(p) => opt_policy_from(p)?, |
|
| 512 | 560 | None => None, |
|
| 513 | 561 | }; |
|
| 514 | - | Ok((bundle, updates, policy)) |
|
| 562 | + | let tags = match m.get(&3) { |
|
| 563 | + | Some(t) => tags_from(t)?, |
|
| 564 | + | None => Vec::new(), |
|
| 565 | + | }; |
|
| 566 | + | Ok((bundle, updates, policy, tags)) |
|
| 515 | 567 | } |
|
| 516 | 568 | ||
| 517 | 569 | pub fn encode_push_response(results: &[RefResult]) -> Vec<u8> { |
|
| … | 55 unchanged lines |
||
| 573 | 625 | ]; |
|
| 574 | 626 | let policy = Policy::single_root([3; 16], id.clone(), 99); |
|
| 575 | 627 | let psig = sk.sign(crate::identity::Domain::Policy, &key, &policy.hash()); |
|
| 576 | - | let resp = RefsResponse { refs, policy: Some(SignedPolicy { policy: policy.clone(), sigs: vec![psig.clone()] }) }; |
|
| 628 | + | let tag = TagRef { name: "v1".into(), state: ObjectRef([2; 32]), heads: vec![c.hash()], attestation: None }; |
|
| 629 | + | let resp = RefsResponse { refs, policy: Some(SignedPolicy { policy: policy.clone(), sigs: vec![psig.clone()] }), tags: vec![tag.clone()] }; |
|
| 577 | 630 | let (id_back, back) = wire::decode_refs(&wire::encode_refs([3; 16], &resp)).unwrap(); |
|
| 578 | 631 | assert_eq!(id_back, [3; 16]); |
|
| 579 | 632 | assert_eq!(back.refs.len(), 2); |
|
| 580 | 633 | assert_eq!(back.refs[0].attestation.as_ref().unwrap().attest, att); |
|
| 581 | 634 | assert_eq!(back.refs[1].last_serial, None); |
|
| 582 | 635 | assert_eq!(back.policy.as_ref().unwrap().policy, policy); |
|
| 636 | + | assert_eq!(back.tags, vec![tag.clone()]); |
|
| 583 | 637 | ||
| 584 | 638 | let (h, w) = wire::decode_negotiate(&wire::encode_negotiate(&[ObjectRef([1; 32])], &[ObjectRef([2; 32])])).unwrap(); |
|
| 585 | 639 | assert_eq!((h, w), (vec![ObjectRef([1; 32])], vec![ObjectRef([2; 32])])); |
|
| … | 6 unchanged lines |
||
| 592 | 646 | ||
| 593 | 647 | let up = RefUpdate { name: "main".into(), old_state: ObjectRef([1; 32]), new_state: ObjectRef([2; 32]), force: true, attestation: Some(SignedAttest { attest: att, sigs: vec![sig] }) }; |
|
| 594 | 648 | let sp = SignedPolicy { policy, sigs: vec![psig] }; |
|
| 595 | - | let (b2, ups, pol) = wire::decode_push(&wire::encode_push(&bundle, std::slice::from_ref(&up), Some(&sp))).unwrap(); |
|
| 649 | + | let (b2, ups, pol, tags) = wire::decode_push(&wire::encode_push(&bundle, std::slice::from_ref(&up), Some(&sp), std::slice::from_ref(&tag))).unwrap(); |
|
| 596 | 650 | assert_eq!(pol, Some(sp)); |
|
| 651 | + | assert_eq!(tags, vec![tag]); |
|
| 597 | 652 | assert_eq!(b2.changes.len(), 1); |
|
| 598 | 653 | assert_eq!(ups[0].name, "main"); |
|
| 599 | 654 | assert!(ups[0].force); |
|
| … | 6 unchanged lines |
modified crates/oxy-core/src/repo/gc.rs +8 −0
| … | 61 unchanged lines |
||
| 62 | 62 | } |
|
| 63 | 63 | } |
|
| 64 | 64 | } |
|
| 65 | + | // Tags: their states and attestations. |
|
| 66 | + | let view_now = self.view()?; |
|
| 67 | + | states.extend(view_now.tags.values().copied()); |
|
| 68 | + | for name in view_now.tags.keys() { |
|
| 69 | + | if let Some((r, _)) = self.tag_attest(name)? { |
|
| 70 | + | attests.push(r); |
|
| 71 | + | } |
|
| 72 | + | } |
|
| 65 | 73 | // Reference attestations. |
|
| 66 | 74 | for name in self.view()?.bookmarks.keys() { |
|
| 67 | 75 | if let Some((r, _)) = self.latest_ref_attest(name)? { |
|
| … | 61 unchanged lines |
modified crates/oxy-core/src/repo/git.rs +22 −3
| … | 255 unchanged lines |
||
| 256 | 256 | prev = Some((state_ref, state.heads.clone())); |
|
| 257 | 257 | report.exported += 1; |
|
| 258 | 258 | } |
|
| 259 | - | match last_mark { |
|
| 260 | - | Some(m) => stream.extend_from_slice(format!("reset refs/heads/{bookmark}\nfrom :{m}\n\ndone\n").as_bytes()), |
|
| 261 | - | None => stream.extend_from_slice(b"done\n"), |
|
| 259 | + | if let Some(m) = last_mark { |
|
| 260 | + | stream.extend_from_slice(format!("reset refs/heads/{bookmark}\nfrom :{m}\n\n").as_bytes()); |
|
| 262 | 261 | } |
|
| 262 | + | // Tags: a tag whose state was exported as a bookmark state points at |
|
| 263 | + | // that commit; otherwise its state gets a commit of its own. |
|
| 264 | + | for t in self.tags()? { |
|
| 265 | + | let mark = match marks.get(&t.state) { |
|
| 266 | + | Some(m) => *m, |
|
| 267 | + | None => { |
|
| 268 | + | let loaded = self.load_state(&t.heads)?; |
|
| 269 | + | let snapshot = loaded.materialize(); |
|
| 270 | + | let mark = next_mark; |
|
| 271 | + | next_mark += 1; |
|
| 272 | + | let message = format!("oxy: tag {}\n\n{}Oxy-State: {}\n", t.name, if t.message.is_empty() { String::new() } else { format!("{}\n\n", t.message) }, t.state.to_hex()); |
|
| 273 | + | self.write_commit(&mut stream, "refs/oxy/export", mark, "oxy <oxy@localhost>", Hlc::now().wall_ms / 1000, &message, &[], &snapshot)?; |
|
| 274 | + | marks.insert(t.state, mark); |
|
| 275 | + | report.exported += 1; |
|
| 276 | + | mark |
|
| 277 | + | } |
|
| 278 | + | }; |
|
| 279 | + | stream.extend_from_slice(format!("reset refs/tags/{}\nfrom :{mark}\n\n", t.name).as_bytes()); |
|
| 280 | + | } |
|
| 281 | + | stream.extend_from_slice(b"done\n"); |
|
| 263 | 282 | ||
| 264 | 283 | let mut cmd = Command::new("git"); |
|
| 265 | 284 | // `--force`: the scratch ref may jump between unrelated states. |
|
| … | 166 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +14 −0
| … | 61 unchanged lines |
||
| 62 | 62 | mod prompt; |
|
| 63 | 63 | mod snapshot; |
|
| 64 | 64 | mod sync; |
|
| 65 | + | mod tags; |
|
| 65 | 66 | #[cfg(test)] |
|
| 66 | 67 | mod tests; |
|
| 67 | 68 | ||
| … | 5 unchanged lines |
||
| 73 | 74 | pub use identity::SignatureStatus; |
|
| 74 | 75 | pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, ChangeStatus, PathStatus, SnapshotResult}; |
|
| 75 | 76 | pub use sync::{FetchReport, PullReport}; |
|
| 77 | + | pub use tags::TagInfo; |
|
| 76 | 78 | ||
| 77 | 79 | fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { |
|
| 78 | 80 | let tmp = path.with_extension("tmp"); |
|
| … | 440 unchanged lines |
||
| 519 | 521 | Ok(entries) |
|
| 520 | 522 | } |
|
| 521 | 523 | ||
| 524 | + | /// Resolve a spec to a set of heads: a tag name, the current bookmark's |
|
| 525 | + | /// name, or a single change (`resolve_change`). |
|
| 526 | + | pub fn resolve_heads(&self, spec: &str) -> Result<Vec<ChangeHash>> { |
|
| 527 | + | if spec == self.config.bookmark { |
|
| 528 | + | return self.bookmark_heads(); |
|
| 529 | + | } |
|
| 530 | + | if let Some(heads) = self.tag_heads(spec)? { |
|
| 531 | + | return Ok(heads); |
|
| 532 | + | } |
|
| 533 | + | Ok(vec![self.resolve_change(spec)?]) |
|
| 534 | + | } |
|
| 535 | + | ||
| 522 | 536 | /// Resolve a change by ChangeId prefix, `#hash` prefix, `@` (working copy), or bookmark. |
|
| 523 | 537 | pub fn resolve_change(&self, spec: &str) -> Result<ChangeHash> { |
|
| 524 | 538 | if spec == "@" { |
|
| … | 76 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +47 −5
| … | 6 unchanged lines |
||
| 7 | 7 | pub struct FetchReport { |
|
| 8 | 8 | /// Bookmark name, its heads, and how many changes were new. |
|
| 9 | 9 | pub refs: Vec<(String, Vec<ChangeHash>, usize)>, |
|
| 10 | + | /// Tags adopted from the remote. |
|
| 11 | + | pub new_tags: usize, |
|
| 10 | 12 | /// The remote has never presented a policy, so nothing was verified |
|
| 11 | 13 | /// against one (spec §14.5: warn loudly). |
|
| 12 | 14 | pub unverified: bool, |
|
| … | 42 unchanged lines |
||
| 55 | 57 | let attestation = latest.filter(|a| matches!(&a.attest.body, AttestBody::Ref { new_state, .. } if new_state == state)); |
|
| 56 | 58 | out.push(RemoteRef { name: name.clone(), state: *state, heads: self.store.get_state(state)?.heads, attestation, last_serial }); |
|
| 57 | 59 | } |
|
| 58 | - | Ok(RefsResponse { refs: out, policy: self.current_policy()?.map(|(_, sp)| sp) }) |
|
| 60 | + | Ok(RefsResponse { refs: out, policy: self.current_policy()?.map(|(_, sp)| sp), tags: self.tag_refs()? }) |
|
| 59 | 61 | } |
|
| 60 | 62 | ||
| 61 | 63 | /// Every change reachable from `heads` that is in the store. |
|
| … | 290 unchanged lines |
||
| 352 | 354 | /// attestations that are present must still be valid. With a policy, |
|
| 353 | 355 | /// every change and update is checked against it. A newer policy in the |
|
| 354 | 356 | /// push is applied first when it is signed by the current roots. |
|
| 355 | - | pub fn receive_push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> { |
|
| 357 | + | pub fn receive_push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>, tags: Vec<crate::protocol::TagRef>) -> Result<Vec<RefResult>> { |
|
| 356 | 358 | let mut results = Vec::new(); |
|
| 357 | 359 | if let Some(sp) = policy { |
|
| 358 | 360 | let current = self.current_policy()?.map(|(_, c)| c.policy); |
|
| … | 32 unchanged lines |
||
| 391 | 393 | } |
|
| 392 | 394 | results.push(RefResult { name: u.name, ok, reason }); |
|
| 393 | 395 | } |
|
| 396 | + | for t in &tags { |
|
| 397 | + | let name = format!("tags/{}", t.name); |
|
| 398 | + | match self.adopt_tag(&mut view, t, policy.as_ref()) { |
|
| 399 | + | Ok(()) => { |
|
| 400 | + | names.push(name.clone()); |
|
| 401 | + | results.push(RefResult { name, ok: true, reason: String::new() }); |
|
| 402 | + | } |
|
| 403 | + | Err(e) => results.push(RefResult { name, ok: false, reason: e }), |
|
| 404 | + | } |
|
| 405 | + | } |
|
| 394 | 406 | if !names.is_empty() { |
|
| 395 | 407 | self.record_op(&[op_ref], &format!("push received: {}", names.join(" ")), view)?; |
|
| 396 | 408 | } |
|
| … | 258 unchanged lines |
||
| 655 | 667 | view.remotes.entry(remote_name.to_string()).or_default().insert(r.name.clone(), state); |
|
| 656 | 668 | report.push((r.name, r.heads, imported.len())); |
|
| 657 | 669 | } |
|
| 670 | + | let mut new_tags = 0; |
|
| 671 | + | for t in &resp.tags { |
|
| 672 | + | if view.tags.contains_key(&t.name) { |
|
| 673 | + | continue; |
|
| 674 | + | } |
|
| 675 | + | // The tag's changes must be here: fetch them if the bookmarks did not bring them. |
|
| 676 | + | let missing: Vec<ChangeHash> = t.heads.iter().filter(|h| !self.store.has(h)).copied().collect(); |
|
| 677 | + | if !missing.is_empty() { |
|
| 678 | + | let needed = remote.negotiate(&self.known_heads(&view)?, &missing)?; |
|
| 679 | + | let bundle = remote.fetch(&needed, max_blob)?; |
|
| 680 | + | self.import_bundle(&bundle, false)?; |
|
| 681 | + | } |
|
| 682 | + | self.store.put_state(&StateObject::new(t.heads.clone()))?; |
|
| 683 | + | self.adopt_tag(&mut view, t, policy.as_ref()).map_err(|e| RepoError::Msg(format!("{remote_name} {}: {e}", t.name)))?; |
|
| 684 | + | new_tags += 1; |
|
| 685 | + | } |
|
| 658 | 686 | if view != op.view { |
|
| 659 | 687 | self.record_op(&[op_ref], &format!("oxy fetch {remote_name}"), view)?; |
|
| 660 | 688 | } |
|
| 661 | - | Ok(FetchReport { refs: report, unverified }) |
|
| 689 | + | Ok(FetchReport { refs: report, unverified, new_tags }) |
|
| 662 | 690 | } |
|
| 663 | 691 | ||
| 664 | 692 | /// Shallow fetch of the remote's bookmark: its checkpoint as our base, |
|
| … | 114 unchanged lines |
||
| 779 | 807 | if remote_has_policy && self.secret_key()?.is_none() { |
|
| 780 | 808 | return fail(format!("{remote_name} enforces a trust policy; pushing needs an identity (`oxy identity create`)")); |
|
| 781 | 809 | } |
|
| 810 | + | let remote_tags: BTreeSet<String> = resp.tags.iter().map(|t| t.name.clone()).collect(); |
|
| 811 | + | let tags_to_send: Vec<crate::protocol::TagRef> = self.tag_refs()?.into_iter().filter(|t| !remote_tags.contains(&t.name)).collect(); |
|
| 782 | 812 | let refs = resp.refs; |
|
| 783 | 813 | let empty = self.store.put_state(&StateObject::new(vec![]))?; |
|
| 784 | 814 | let (remote_state, remote_heads, last_serial) = match refs.iter().find(|r| r.name == bookmark) { |
|
| … | 5 unchanged lines |
||
| 790 | 820 | // state is unchanged, so the current attestation is always by a key |
|
| 791 | 821 | // the current policy authorizes (a revoked key's attestation would |
|
| 792 | 822 | // otherwise be refused by every later fetch). |
|
| 793 | - | if new_state == remote_state && policy_to_send.is_none() { |
|
| 823 | + | if new_state == remote_state && policy_to_send.is_none() && tags_to_send.is_empty() { |
|
| 794 | 824 | return Ok(vec![RefResult { name: bookmark, ok: true, reason: "already up to date".into() }]); |
|
| 795 | 825 | } |
|
| 796 | 826 | for h in &remote_heads { |
|
| … | 39 unchanged lines |
||
| 836 | 866 | _ => None, |
|
| 837 | 867 | }); |
|
| 838 | 868 | let sent_serial = policy_to_send.as_ref().map(|p| p.policy.serial); |
|
| 839 | - | let results = remote.push(bundle, vec![RefUpdate { name: bookmark.clone(), old_state: remote_state, new_state, force, attestation }], policy_to_send)?; |
|
| 869 | + | // Tag states must be present on the remote: bundle their changes too. |
|
| 870 | + | for t in &tags_to_send { |
|
| 871 | + | let extra = self.negotiate(&remote_heads, &t.heads)?; |
|
| 872 | + | let extra: Vec<ChangeHash> = extra.into_iter().filter(|h| !to_send.contains(h)).collect(); |
|
| 873 | + | if !extra.is_empty() { |
|
| 874 | + | let more = self.bundle_for(&extra)?; |
|
| 875 | + | bundle.changes.extend(more.changes); |
|
| 876 | + | bundle.attests.extend(more.attests); |
|
| 877 | + | bundle.objects.extend(more.objects); |
|
| 878 | + | } |
|
| 879 | + | bundle.objects.push((ObjectType::State, StateObject::new(t.heads.clone()).encode())); |
|
| 880 | + | } |
|
| 881 | + | let results = remote.push(bundle, vec![RefUpdate { name: bookmark.clone(), old_state: remote_state, new_state, force, attestation }], policy_to_send, tags_to_send)?; |
|
| 840 | 882 | if let (Some(serial), true) = (sent_serial, results.iter().any(|r| r.ok && r.name == "policy")) { |
|
| 841 | 883 | self.record_policy_serial(remote_name, serial)?; |
|
| 842 | 884 | } |
|
| … | 65 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +70 −8
| … | 476 unchanged lines |
||
| 477 | 477 | ||
| 478 | 478 | // Missing head. |
|
| 479 | 479 | let ghost = origin.store().put_state(&StateObject::new(vec![ObjectRef([9; 32])])).unwrap(); |
|
| 480 | - | let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: ghost, force: false, attestation: None }], None).unwrap(); |
|
| 480 | + | let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: ghost, force: false, attestation: None }], None, vec![]).unwrap(); |
|
| 481 | 481 | assert!(!r[0].ok && r[0].reason.contains("missing"), "{}", r[0].reason); |
|
| 482 | 482 | ||
| 483 | 483 | // Stale old_state. |
|
| 484 | - | let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: None }], None).unwrap(); |
|
| 484 | + | let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: None }], None, vec![]).unwrap(); |
|
| 485 | 485 | assert!(!r[0].ok && r[0].reason.contains("stale"), "{}", r[0].reason); |
|
| 486 | 486 | ||
| 487 | 487 | // Non-fast-forward without force is refused; with force it passes. |
|
| 488 | - | let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: false, attestation: None }], None).unwrap(); |
|
| 488 | + | let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: false, attestation: None }], None, vec![]).unwrap(); |
|
| 489 | 489 | assert!(!r[0].ok && r[0].reason.contains("fast-forward"), "{}", r[0].reason); |
|
| 490 | - | let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: true, attestation: None }], None).unwrap(); |
|
| 490 | + | let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: true, attestation: None }], None, vec![]).unwrap(); |
|
| 491 | 491 | assert!(r[0].ok); |
|
| 492 | 492 | assert_eq!(origin.bookmark_heads().unwrap(), vec![]); |
|
| 493 | 493 | ||
| 494 | 494 | // Attestation with a stale serial is a rollback and is refused. |
|
| 495 | 495 | 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(); |
|
| 496 | - | let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa.clone()) }], None).unwrap(); |
|
| 496 | + | let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa.clone()) }], None, vec![]).unwrap(); |
|
| 497 | 497 | assert!(!r[0].ok && r[0].reason.contains("serial"), "{}", r[0].reason); |
|
| 498 | 498 | // An attestation for a different update is refused too. |
|
| 499 | 499 | 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(); |
|
| 500 | - | let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa2) }], None).unwrap(); |
|
| 500 | + | let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa2) }], None, vec![]).unwrap(); |
|
| 501 | 501 | assert!(!r[0].ok && r[0].reason.contains("describe"), "{}", r[0].reason); |
|
| 502 | 502 | let _ = key; |
|
| 503 | 503 | } |
|
| … | 220 unchanged lines |
||
| 724 | 724 | let fh = forged.hash(); |
|
| 725 | 725 | let (_, sk_b) = b.secret_key().unwrap().unwrap(); |
|
| 726 | 726 | let forged = SignedPolicy { policy: forged, sigs: vec![sk_b.sign(Domain::Policy, &key_b, &fh)] }; |
|
| 727 | - | let r = origin.receive_push(Bundle::default(), vec![], Some(forged)).unwrap(); |
|
| 727 | + | let r = origin.receive_push(Bundle::default(), vec![], Some(forged), vec![]).unwrap(); |
|
| 728 | 728 | assert!(!r[0].ok && r[0].reason.contains("root"), "{:?}", r); |
|
| 729 | 729 | let stale = a.current_policy().unwrap().unwrap().1; |
|
| 730 | 730 | let mut old = stale.clone(); |
|
| 731 | 731 | old.policy.serial = 1; |
|
| 732 | - | let r = origin.receive_push(Bundle::default(), vec![], Some(old)).unwrap(); |
|
| 732 | + | let r = origin.receive_push(Bundle::default(), vec![], Some(old), vec![]).unwrap(); |
|
| 733 | 733 | assert!(!r[0].ok && r[0].reason.contains("serial"), "{:?}", r); |
|
| 734 | 734 | ||
| 735 | 735 | // A revokes B; after B pulls the policy, B's pushes are refused again. |
|
| … | 579 unchanged lines |
||
| 1315 | 1315 | assert!(a.store().has(&hashes[0])); |
|
| 1316 | 1316 | assert_eq!(a.load_state_full(&a.bookmark_heads().unwrap()).unwrap().len(), 10); |
|
| 1317 | 1317 | } |
|
| 1318 | + | ||
| 1319 | + | #[test] |
|
| 1320 | + | fn tags_are_immutable_named_states_and_travel() { |
|
| 1321 | + | hermetic(); |
|
| 1322 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1323 | + | let root = dir.path(); |
|
| 1324 | + | let origin = Repo::init(&root.join("origin")).unwrap(); |
|
| 1325 | + | let url = root.join("origin").to_string_lossy().to_string(); |
|
| 1326 | + | let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home-a")); |
|
| 1327 | + | let (key_a, _) = a.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap(); |
|
| 1328 | + | write(&root.join("a"), "f.txt", b"1\n"); |
|
| 1329 | + | let h1 = a.commit("one", &[]).unwrap(); |
|
| 1330 | + | write(&root.join("a"), "f.txt", b"1\n2\n"); |
|
| 1331 | + | let h2 = a.commit("two", &[]).unwrap(); |
|
| 1332 | + | ||
| 1333 | + | let t = a.tag_create("v1", &[h1], "first release").unwrap(); |
|
| 1334 | + | assert_eq!((t.heads.clone(), t.signer.as_deref()), (vec![h1], Some("Ada"))); |
|
| 1335 | + | assert!(a.tag_create("v1", &[h2], "").is_err(), "immutable"); |
|
| 1336 | + | assert!(a.tag_create("bad/name", &[h2], "").is_err()); |
|
| 1337 | + | assert_eq!(a.tag_heads("v1").unwrap(), Some(vec![h1])); |
|
| 1338 | + | assert_eq!(a.resolve_heads("v1").unwrap(), vec![h1]); |
|
| 1339 | + | assert_eq!(a.resolve_heads("main").unwrap(), vec![h2]); |
|
| 1340 | + | let (_, sa) = a.tag_attest("v1").unwrap().unwrap(); |
|
| 1341 | + | assert_eq!(sa.sigs[0].key, key_a); |
|
| 1342 | + | // Checkout by tag. |
|
| 1343 | + | a.checkout(&a.resolve_heads("v1").unwrap(), false, "co").unwrap(); |
|
| 1344 | + | assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"1\n"); |
|
| 1345 | + | a.checkout(&a.bookmark_heads().unwrap(), false, "co").unwrap(); |
|
| 1346 | + | ||
| 1347 | + | // Push carries the tag; a second attestation for the name is refused. |
|
| 1348 | + | let r = a.push("origin", false).unwrap(); |
|
| 1349 | + | assert!(r.iter().any(|x| x.name == "tags/v1" && x.ok), "{r:?}"); |
|
| 1350 | + | assert_eq!(origin.tag_heads("v1").unwrap(), Some(vec![h1])); |
|
| 1351 | + | let mut forged = a.tag_refs().unwrap().into_iter().find(|t| t.name == "v1").unwrap(); |
|
| 1352 | + | forged.state = origin.store().put_state(&StateObject::new(vec![h2])).unwrap(); |
|
| 1353 | + | forged.heads = vec![h2]; |
|
| 1354 | + | forged.attestation = None; |
|
| 1355 | + | let r = origin.receive_push(Bundle::default(), vec![], None, vec![forged]).unwrap(); |
|
| 1356 | + | assert!(!r[0].ok && r[0].reason.contains("immutable"), "{r:?}"); |
|
| 1357 | + | ||
| 1358 | + | // A clone gets the tag; a later tag arrives on fetch. |
|
| 1359 | + | let b = Repo::clone(&url, &root.join("b")).unwrap().with_home(root.join("home-b")); |
|
| 1360 | + | assert_eq!(b.tag_heads("v1").unwrap(), Some(vec![h1])); |
|
| 1361 | + | assert!(b.tags().unwrap()[0].signer.as_deref() == Some("Ada")); |
|
| 1362 | + | a.tag_create("v2", &[h2], "second").unwrap(); |
|
| 1363 | + | a.push("origin", false).unwrap(); |
|
| 1364 | + | let rep = b.fetch("origin").unwrap(); |
|
| 1365 | + | assert_eq!(rep.new_tags, 1); |
|
| 1366 | + | assert_eq!(b.tag_heads("v2").unwrap(), Some(vec![h2])); |
|
| 1367 | + | ||
| 1368 | + | // Under a policy, an unattested tag is refused and an attested one by the root passes. |
|
| 1369 | + | a.policy_init().unwrap(); |
|
| 1370 | + | a.push("origin", false).unwrap(); |
|
| 1371 | + | let unattested = crate::protocol::TagRef { name: "v3".into(), state: origin.store().put_state(&StateObject::new(vec![h2])).unwrap(), heads: vec![h2], attestation: None }; |
|
| 1372 | + | let r = origin.receive_push(Bundle::default(), vec![], None, vec![unattested]).unwrap(); |
|
| 1373 | + | assert!(!r[0].ok && r[0].reason.contains("attestation"), "{r:?}"); |
|
| 1374 | + | a.tag_create("v3", &[h2], "").unwrap(); |
|
| 1375 | + | let r = a.push("origin", false).unwrap(); |
|
| 1376 | + | assert!(r.iter().any(|x| x.name == "tags/v3" && x.ok), "{r:?}"); |
|
| 1377 | + | assert!(a.gc(true).is_ok()); |
|
| 1378 | + | assert_eq!(a.tags().unwrap().len(), 3, "tags survive gc"); |
|
| 1379 | + | } |
modified crates/oxy-server/src/lib.rs +2 −2
| … | 170 unchanged lines |
||
| 171 | 171 | app.authorize_push(&headers)?; |
|
| 172 | 172 | blocking(move || { |
|
| 173 | 173 | let r = app.open(&repo)?; |
|
| 174 | - | let (bundle, updates, policy) = wire::decode_push(&body)?; |
|
| 174 | + | let (bundle, updates, policy, tags) = wire::decode_push(&body)?; |
|
| 175 | 175 | let lock = app.lock_for(&repo); |
|
| 176 | 176 | let _guard = lock.lock().unwrap(); |
|
| 177 | - | Ok(ok(wire::encode_push_response(&r.receive_push(bundle, updates, policy)?))) |
|
| 177 | + | Ok(ok(wire::encode_push_response(&r.receive_push(bundle, updates, policy, tags)?))) |
|
| 178 | 178 | }) |
|
| 179 | 179 | .await |
|
| 180 | 180 | } |
|
| … | 39 unchanged lines |
modified docs/spec.md +8 −6
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.6g | |
|
| 5 | + | | **Status** | Draft 0.6h | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 176 unchanged lines |
||
| 185 | 185 | | `OpRef` (placement or mode reference) | `[bytes(32) change hash, uint index]`; the all-zero hash means this change. | |
|
| 186 | 186 | | `AtomRef` | `0` for `START`, `1` for `END`, otherwise `[bytes(32) change hash, uint index]` with the all-zero hash meaning this change. | |
|
| 187 | 187 | | `AtomRange` | `[bytes(32) change hash, uint from, uint to]`, half-open, non-empty. | |
|
| 188 | - | | `View` (in `Op`) | Key 0 bookmarks: array of `[text name, bytes(32) state ref]` sorted by name; key 1 workspaces: array of `[bytes(16) id, bytes(32) change]` sorted by id. Remote and tag namespaces are added in later drafts. Repository format 1 stored `[name, [heads]]`; format 2 stores the state ref. | |
|
| 188 | + | | `View` (in `Op`) | Key 0 bookmarks: array of `[text name, bytes(32) state ref]` sorted by name; key 1 workspaces: array of `[bytes(16) id, bytes(32) change]` sorted by id; key 2 remotes: array of `[text remote, [[text bookmark, bytes(32) state ref]]]`; key 3 policy ref or null; key 4 tags: array of `[text name, bytes(32) state ref]`. Repository format 1 stored `[name, [heads]]`; format 2 stores the state ref. | |
|
| 189 | 189 | | `Signature` | Map: 0 key id, 1 alg (1 = Ed25519 + ML-DSA-65), 2 Ed25519 bytes(64), 3 ML-DSA-65 bytes(3309). A signature list is a CBOR array of these. | |
|
| 190 | 190 | | Secret key file | Map: 0 version, 1 key id, 2 Ed25519 seed bytes(32), 3 ML-DSA-65 seed bytes(32). Kept only in the user's key directory. | |
|
| 191 | 191 | ||
| … | 212 unchanged lines |
||
| 404 | 404 | ||
| 405 | 405 | Every published bookmark update is accompanied by an attestation (§14.4) of kind `ref` that binds the repository ID, the reference name, the previous state, the new state, and a per-reference monotonically increasing `serial`. Servers MUST store the latest attestation per reference and serve it with the reference. Clients MUST verify the attestation against policy before updating a `remotes/` reference and MUST reject an attestation whose `serial` is lower than one they have already recorded for that reference (rollback protection). Clients SHOULD warn when the newest attestation's `hlc.wall_ms` is older than `remote.max-age` (default 30 days) and the remote claims no update. |
|
| 406 | 406 | ||
| 407 | - | Tags are attestations of kind `tag` and are never updated; a server MUST reject a second attestation for an existing tag name. |
|
| 407 | + | Tags are attestations of kind `tag` and are never updated; a server MUST reject a second attestation for an existing tag name, and any receiver MUST refuse a tag whose name it already holds with a different state. `oxy tag create NAME [CHANGE..]` tags the named state (default the bookmark's heads); tags travel with `refs` and `push` (§16.3), and a policy rule for `tags/<name>` governs who may create them. |
|
| 408 | 408 | ||
| 409 | 409 | ## 11. Checkpoints |
|
| 410 | 410 | ||
| … | 277 unchanged lines |
||
| 688 | 688 | | `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]]}`; changes in dependency order; objects are chunks, blobs, identities, and states only | |
|
| 689 | 689 | | `capabilities` response | `{0: [versions], 1: repo id bytes(16), 2: [feature names]}` | |
|
| 690 | 690 | | Signed policy | `[policy bytes, [Signature]]` | |
|
| 691 | - | | `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 1: repo id, 2: signed policy or null}`; the attestation is present only when it describes the current state | |
|
| 691 | + | | `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 1: repo id, 2: signed policy or null, 3: [tag]}`; the attestation is present only when it describes the current state; a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` | |
|
| 692 | 692 | | `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order | |
|
| 693 | 693 | | `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size | |
|
| 694 | 694 | | `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks | |
|
| 695 | 695 | | `checkpoint` request | `{0: [heads]}`; response a `Bundle` of objects: the checkpoint of exactly those heads, its payload blob, and the blob's chunks | |
|
| 696 | 696 | | `shallow` request | `{0: [heads], 1: depth}`; response `{0: [hashes]}`, the changes within `depth` steps of the heads, dependencies first | |
|
| 697 | - | | `push` request | `{0: Bundle, 1: [{0: name, 1: old state, 2: new state, 3: force, 4: signed attestation or null}], 2: signed policy or null}`; response `{0: [{0: name, 1: ok, 2: reason}]}`, where a result named `policy` reports the policy update | |
|
| 697 | + | | `push` request | `{0: Bundle, 1: [{0: name, 1: old state, 2: new state, 3: force, 4: signed attestation or null}], 2: signed policy or null, 3: [tag]}`; response `{0: [{0: name, 1: ok, 2: reason}]}`, where a result named `policy` reports the policy update and `tags/<name>` each tag | |
|
| 698 | 698 | ||
| 699 | 699 | The HTTP content type is `application/oxy-cbor`. |
|
| 700 | 700 | ||
| … | 141 unchanged lines |
||
| 842 | 842 | ||
| 843 | 843 | --- |
|
| 844 | 844 | ||
| 845 | - | *Changelog.* Draft 0.6g (2026-09-27): shallow repositories as checkpoint plus history window, the `checkpoint` and `shallow` messages, deepening (§16.3, §17). |
|
| 845 | + | *Changelog.* Draft 0.6h (2026-09-27): tags in views, on the wire, and under policy (§5.5, §10.2, §16.3). |
|
| 846 | + | ||
| 847 | + | Draft 0.6g (2026-09-27): shallow repositories as checkpoint plus history window, the `checkpoint` and `shallow` messages, deepening (§16.3, §17). |
|
| 846 | 848 | ||
| 847 | 849 | Draft 0.6f (2026-09-27): sparse checkout semantics (§17). |
|
| 848 | 850 | ||
| … | 27 unchanged lines |
modified src/cli/mod.rs +21 −0
| … | 107 unchanged lines |
||
| 108 | 108 | #[command(subcommand)] |
|
| 109 | 109 | cmd: OpCmd, |
|
| 110 | 110 | }, |
|
| 111 | + | /// Tags: immutable named states |
|
| 112 | + | Tag { |
|
| 113 | + | #[command(subcommand)] |
|
| 114 | + | cmd: TagCmd, |
|
| 115 | + | }, |
|
| 111 | 116 | /// Manage remotes |
|
| 112 | 117 | Remote { |
|
| 113 | 118 | #[command(subcommand)] |
|
| … | 60 unchanged lines |
||
| 174 | 179 | } |
|
| 175 | 180 | ||
| 176 | 181 | #[derive(Subcommand)] |
|
| 182 | + | enum TagCmd { |
|
| 183 | + | /// Tag the state of CHANGE... (default: the bookmark heads) |
|
| 184 | + | Create { |
|
| 185 | + | name: String, |
|
| 186 | + | changes: Vec<String>, |
|
| 187 | + | #[arg(short, long, default_value = "")] |
|
| 188 | + | message: String, |
|
| 189 | + | }, |
|
| 190 | + | List, |
|
| 191 | + | } |
|
| 192 | + | ||
| 193 | + | #[derive(Subcommand)] |
|
| 177 | 194 | enum RemoteCmd { |
|
| 178 | 195 | Add { name: String, url: String }, |
|
| 179 | 196 | Remove { name: String }, |
|
| … | 77 unchanged lines |
||
| 257 | 274 | Cmd::Clone { url, dest, max_blob, sparse, depth } => sync::clone(&url, dest, max_blob.as_deref(), &sparse, depth), |
|
| 258 | 275 | Cmd::Deepen { depth, remote } => sync::deepen(&remote, depth), |
|
| 259 | 276 | Cmd::Sparse { paths } => workspace::sparse(&paths), |
|
| 277 | + | Cmd::Tag { cmd } => match cmd { |
|
| 278 | + | TagCmd::Create { name, changes, message } => workspace::tag_create(&name, &changes, &message), |
|
| 279 | + | TagCmd::List => workspace::tag_list(), |
|
| 280 | + | }, |
|
| 260 | 281 | Cmd::Remote { cmd } => match cmd { |
|
| 261 | 282 | RemoteCmd::Add { name, url } => sync::remote_add(&name, &url), |
|
| 262 | 283 | RemoteCmd::Remove { name } => sync::remote_remove(&name), |
|
| … | 23 unchanged lines |
modified src/cli/sync.rs +3 −0
| … | 66 unchanged lines |
||
| 67 | 67 | for (name, heads, n) in &report.refs { |
|
| 68 | 68 | println!("{} {}, {}", ui::id(format!("{remote}/{name}")), plural(heads.len(), "head"), plural(*n, "new change")); |
|
| 69 | 69 | } |
|
| 70 | + | if report.new_tags > 0 { |
|
| 71 | + | println!("{}", ui::dim(format!("{} adopted", plural(report.new_tags, "tag")))); |
|
| 72 | + | } |
|
| 70 | 73 | if report.unverified { |
|
| 71 | 74 | eprintln!("{} {remote} has no trust policy; nothing fetched from it was verified", ui::warn("warning:")); |
|
| 72 | 75 | } |
|
| … | 109 unchanged lines |
modified src/cli/workspace.rs +48 −1
| … | 132 unchanged lines |
||
| 133 | 133 | let heads = if changes.is_empty() { |
|
| 134 | 134 | repo.bookmark_heads()? |
|
| 135 | 135 | } else { |
|
| 136 | - | changes.iter().map(|c| repo.resolve_change(c)).collect::<Result<Vec<_>, _>>()? |
|
| 136 | + | let mut heads = Vec::new(); |
|
| 137 | + | for c in changes { |
|
| 138 | + | heads.extend(repo.resolve_heads(c)?); |
|
| 139 | + | } |
|
| 140 | + | heads |
|
| 137 | 141 | }; |
|
| 138 | 142 | let spec = if changes.is_empty() { repo.config.bookmark.clone() } else { changes.join(" ") }; |
|
| 139 | 143 | let sp = ui::spinner(&format!("Checking out {spec}…")); |
|
| … | 161 unchanged lines |
||
| 301 | 305 | sp.done(&format!("{} {}: {} written, {} removed", ui::ok("Sparse filter set to"), ui::path(paths.join(",")), plural(r.written, "file"), r.removed)); |
|
| 302 | 306 | Ok(0) |
|
| 303 | 307 | } |
|
| 308 | + | ||
| 309 | + | pub fn tag_create(name: &str, changes: &[String], message: &str) -> Result<i32> { |
|
| 310 | + | let repo = open()?; |
|
| 311 | + | repo.snapshot("oxy tag")?; |
|
| 312 | + | let heads = if changes.is_empty() { |
|
| 313 | + | repo.bookmark_heads()? |
|
| 314 | + | } else { |
|
| 315 | + | let mut heads = Vec::new(); |
|
| 316 | + | for c in changes { |
|
| 317 | + | heads.extend(repo.resolve_heads(c)?); |
|
| 318 | + | } |
|
| 319 | + | heads |
|
| 320 | + | }; |
|
| 321 | + | let t = repo.tag_create(name, &heads, message)?; |
|
| 322 | + | println!( |
|
| 323 | + | "{} {} -> {} ({}){}", |
|
| 324 | + | ui::ok("Tagged"), |
|
| 325 | + | ui::id(&t.name), |
|
| 326 | + | ui::hash(format!("#{}", t.state.short(12))), |
|
| 327 | + | plural(t.heads.len(), "head"), |
|
| 328 | + | t.signer.map(|s| format!(", signed by {s}")).unwrap_or_else(|| ui::dim(", unsigned")) |
|
| 329 | + | ); |
|
| 330 | + | Ok(0) |
|
| 331 | + | } |
|
| 332 | + | ||
| 333 | + | pub fn tag_list() -> Result<i32> { |
|
| 334 | + | let repo = open()?; |
|
| 335 | + | let tags = repo.tags()?; |
|
| 336 | + | if tags.is_empty() { |
|
| 337 | + | println!("{}", ui::dim("No tags.")); |
|
| 338 | + | } |
|
| 339 | + | for t in tags { |
|
| 340 | + | println!( |
|
| 341 | + | "{} {} {}{}{}", |
|
| 342 | + | ui::id(&t.name), |
|
| 343 | + | ui::hash(format!("#{}", t.state.short(12))), |
|
| 344 | + | ui::dim(format!("({})", plural(t.heads.len(), "head"))), |
|
| 345 | + | if t.message.is_empty() { String::new() } else { format!(" {}", t.message.lines().next().unwrap_or("")) }, |
|
| 346 | + | t.signer.map(|s| ui::dim(format!(" [{s}]"))).unwrap_or_default() |
|
| 347 | + | ); |
|
| 348 | + | } |
|
| 349 | + | Ok(0) |
|
| 350 | + | } |