A tag can be withdrawn, by a signed withdrawal (oxygenhub#301) b63fc7a77fc6
oxy tag delete NAME records an untag attestation naming the tag's state; the next push sends it to a remote that has the tag, and oxy push --delete tags/NAME does both. Under a policy only a key that may create the tag may withdraw it. Servers list withdrawn tags in refs (key 8), fetch withdraws them here, and a withdrawn name is never used again. oxy-server gains Backend::vet_tag_withdrawal; Repo::host_tag_create and host_tag_withdrawal let a host do both for a signed-in user.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ccef3f15f0c3
- When
- · 2026-10-24 22:03 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
b63fc7a77fc6e79d1acf216a9a0bc47bfeccd51b96e9a0337d16dec00bfeb4a7- Depends on
- 028545dfa7a4 0ed150d62994 1196b29b5fa9 132b884b9a8d 1ec6fdbc6bcb 28fc23d7706a 4b9d06778581 5b9c6ad45f6e 750251b21a96 75167672ebcd 83e0677b07fe 84b9e9ac0ab4 982864068a59 b2ef93bb2366 c6aa5bd9d1c8 cb8b8082fa73 d42369abf180 d8524a9f0117 ee06a6c557b7 ee130dd9eef2 f6dccc4533c8 f8c695764c95 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 2f0b4b0651ea
- In bookmarks
- identical-sides main push-notes subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 55 atoms, 0 tree operations, 12 files
Browse the tree at this change
modified crates/oxy-core/src/attest.rs +34 −0
| … | 25 unchanged lines |
||
| 26 | 26 | state: ObjectRef, |
|
| 27 | 27 | message: String, |
|
| 28 | 28 | }, |
|
| 29 | + | /// Tag `name`, naming `state`, is withdrawn (spec §10.1): signed by a |
|
| 30 | + | /// key the policy lets create the tag. The name is never used again. |
|
| 31 | + | Untag { |
|
| 32 | + | name: String, |
|
| 33 | + | state: ObjectRef, |
|
| 34 | + | reason: String, |
|
| 35 | + | }, |
|
| 29 | 36 | /// The signing key's identity was replaced by `new` (spec §14.2). |
|
| 30 | 37 | Rotate { new: KeyId }, |
|
| 31 | 38 | /// `key` must no longer be trusted (spec §14.2). |
|
| … | 38 unchanged lines |
||
| 70 | 77 | AttestBody::Ref { .. } => "ref", |
|
| 71 | 78 | AttestBody::Seal { .. } => "seal", |
|
| 72 | 79 | AttestBody::Tag { .. } => "tag", |
|
| 80 | + | AttestBody::Untag { .. } => "untag", |
|
| 73 | 81 | AttestBody::Rotate { .. } => "rotate", |
|
| 74 | 82 | AttestBody::Revoke { .. } => "revoke", |
|
| 75 | 83 | AttestBody::Receipt { .. } => "receipt", |
|
| … | 37 unchanged lines |
||
| 113 | 121 | (0, Value::Text(name.clone())), |
|
| 114 | 122 | (1, Value::Bytes(state.0.to_vec())), |
|
| 115 | 123 | (2, Value::Text(message.clone())), |
|
| 124 | + | ]), |
|
| 125 | + | AttestBody::Untag { |
|
| 126 | + | name, |
|
| 127 | + | state, |
|
| 128 | + | reason, |
|
| 129 | + | } => Value::map([ |
|
| 130 | + | (0, Value::Text(name.clone())), |
|
| 131 | + | (1, Value::Bytes(state.0.to_vec())), |
|
| 132 | + | (2, Value::Text(reason.clone())), |
|
| 116 | 133 | ]), |
|
| 117 | 134 | AttestBody::Rotate { new } => Value::map([(0, Value::Bytes(new.0.to_vec()))]), |
|
| 118 | 135 | AttestBody::Revoke { key, reason } => Value::map([ |
|
| … | 85 unchanged lines |
||
| 204 | 221 | state: oref(field(b, 1, "state")?, "state")?, |
|
| 205 | 222 | message: text(field(b, 2, "message")?, "message")?.to_owned(), |
|
| 206 | 223 | }, |
|
| 224 | + | "untag" => AttestBody::Untag { |
|
| 225 | + | name: text(field(b, 0, "name")?, "name")?.to_owned(), |
|
| 226 | + | state: oref(field(b, 1, "state")?, "state")?, |
|
| 227 | + | reason: text(field(b, 2, "reason")?, "reason")?.to_owned(), |
|
| 228 | + | }, |
|
| 207 | 229 | "rotate" => AttestBody::Rotate { |
|
| 208 | 230 | new: oref(field(b, 0, "new")?, "new")?, |
|
| 209 | 231 | }, |
|
| … | 84 unchanged lines |
||
| 294 | 316 | }; |
|
| 295 | 317 | assert_eq!(Attest::decode(&t.encode()).unwrap(), t); |
|
| 296 | 318 | assert_eq!(t.kind(), "tag"); |
|
| 319 | + | let u = Attest { |
|
| 320 | + | repo: [1; 16], |
|
| 321 | + | hlc: Hlc::default(), |
|
| 322 | + | body: AttestBody::Untag { |
|
| 323 | + | name: "v1".into(), |
|
| 324 | + | state: ObjectRef([5; 32]), |
|
| 325 | + | reason: "tagged the wrong change".into(), |
|
| 326 | + | }, |
|
| 327 | + | }; |
|
| 328 | + | assert_eq!(Attest::decode(&u.encode()).unwrap(), u); |
|
| 329 | + | assert_eq!(u.kind(), "untag"); |
|
| 330 | + | assert_ne!(u.hash(), t.hash()); |
|
| 297 | 331 | } |
|
| 298 | 332 | ||
| 299 | 333 | #[test] |
|
| … | 17 unchanged lines |
modified crates/oxy-core/src/protocol.rs +22 −0
| … | 63 unchanged lines |
||
| 64 | 64 | /// name in cosignatures and its identity's bytes (key 7, only when it |
|
| 65 | 65 | /// has any), which a client pins (spec §14.8, *Integrated time*). |
|
| 66 | 66 | pub witnesses: Vec<(String, Vec<u8>)>, |
|
| 67 | + | /// Tags withdrawn here (spec §10.1; key 8, only when there are any): |
|
| 68 | + | /// each with its `untag` attestation as `attestation`, and that |
|
| 69 | + | /// attestation's acceptance as `accepted` when a short-lived key |
|
| 70 | + | /// signed it. Older clients ignore them and keep the tag they have. |
|
| 71 | + | pub withdrawn: Vec<TagRef>, |
|
| 67 | 72 | } |
|
| 68 | 73 | ||
| 69 | 74 | /// Where a reference attestation sits in the host's transparency log |
|
| … | 35 unchanged lines |
||
| 105 | 110 | } |
|
| 106 | 111 | ||
| 107 | 112 | impl TagRef { |
|
| 113 | + | /// Whether this is a tag's withdrawal (its attestation an `untag`), as |
|
| 114 | + | /// a push carries one (oxygenhub#301). |
|
| 115 | + | pub fn is_withdrawal(&self) -> bool { |
|
| 116 | + | self.attestation |
|
| 117 | + | .as_ref() |
|
| 118 | + | .is_some_and(|a| matches!(a.attest.body, crate::attest::AttestBody::Untag { .. })) |
|
| 119 | + | } |
|
| 120 | + | ||
| 108 | 121 | /// A tag with no acceptance or log place (yet). |
|
| 109 | 122 | pub fn new( |
|
| 110 | 123 | name: String, |
|
| … | 1040 unchanged lines |
||
| 1151 | 1164 | if let Value::Map(m) = &mut v { |
|
| 1152 | 1165 | if let Some(log) = &resp.log { |
|
| 1153 | 1166 | m.insert(6, Value::Text(log.clone())); |
|
| 1167 | + | } |
|
| 1168 | + | if !resp.withdrawn.is_empty() { |
|
| 1169 | + | m.insert(8, tags_value(&resp.withdrawn)); |
|
| 1154 | 1170 | } |
|
| 1155 | 1171 | if !resp.witnesses.is_empty() { |
|
| 1156 | 1172 | m.insert( |
|
| … | 80 unchanged lines |
||
| 1237 | 1253 | )); |
|
| 1238 | 1254 | } |
|
| 1239 | 1255 | } |
|
| 1256 | + | let withdrawn = match m.get(&8) { |
|
| 1257 | + | Some(t) => tags_from(t)?, |
|
| 1258 | + | None => Vec::new(), |
|
| 1259 | + | }; |
|
| 1240 | 1260 | Ok(( |
|
| 1241 | 1261 | id, |
|
| 1242 | 1262 | RefsResponse { |
|
| … | 4 unchanged lines |
||
| 1247 | 1267 | default_bookmark, |
|
| 1248 | 1268 | log, |
|
| 1249 | 1269 | witnesses, |
|
| 1270 | + | withdrawn, |
|
| 1250 | 1271 | }, |
|
| 1251 | 1272 | )) |
|
| 1252 | 1273 | } |
|
| … | 314 unchanged lines |
||
| 1567 | 1588 | default_bookmark: Some("develop".into()), |
|
| 1568 | 1589 | log: Some("hub.example/log\n2\nroot\n\n- sig\n".into()), |
|
| 1569 | 1590 | witnesses: vec![], |
|
| 1591 | + | withdrawn: vec![], |
|
| 1570 | 1592 | }; |
|
| 1571 | 1593 | // Without integrated times (spec §14.8) nothing is added: an old |
|
| 1572 | 1594 | // peer's answer, and what it reads. |
|
| … | 153 unchanged lines |
modified crates/oxy-core/src/repo/hosted.rs +91 −0
| … | 237 unchanged lines |
||
| 238 | 238 | }) |
|
| 239 | 239 | } |
|
| 240 | 240 | ||
| 241 | + | /// Create tag `name` for the state of `heads` as a host does for a |
|
| 242 | + | /// signed-in user (oxygenhub#295): attested by `signer` (a delegate of |
|
| 243 | + | /// the user, or the host's own key), with `identities` it needs, and |
|
| 244 | + | /// received as a pushed tag is, so the same policy checks apply. |
|
| 245 | + | pub fn host_tag_create( |
|
| 246 | + | &self, |
|
| 247 | + | name: &str, |
|
| 248 | + | heads: &[ChangeHash], |
|
| 249 | + | message: &str, |
|
| 250 | + | signer: (&KeyId, &SecretKey), |
|
| 251 | + | identities: &[Identity], |
|
| 252 | + | ) -> Result<PushReport> { |
|
| 253 | + | crate::objects::validate_name(name) |
|
| 254 | + | .map_err(|e| RepoError::Msg(format!("tag name: {e}")))?; |
|
| 255 | + | let st = self.load_state_history(heads)?; |
|
| 256 | + | let heads = st.maximal(heads); |
|
| 257 | + | if heads.is_empty() { |
|
| 258 | + | return fail("a tag needs at least one change"); |
|
| 259 | + | } |
|
| 260 | + | let state_object = StateObject::new(heads.clone()); |
|
| 261 | + | let state = state_object.hash(); |
|
| 262 | + | let body = AttestBody::Tag { |
|
| 263 | + | name: name.into(), |
|
| 264 | + | state, |
|
| 265 | + | message: message.into(), |
|
| 266 | + | }; |
|
| 267 | + | let mut bundle = self.hosted_bundle(identities); |
|
| 268 | + | bundle |
|
| 269 | + | .objects |
|
| 270 | + | .push((ObjectType::State, state_object.encode())); |
|
| 271 | + | let t = crate::protocol::TagRef::new( |
|
| 272 | + | name.into(), |
|
| 273 | + | state, |
|
| 274 | + | heads, |
|
| 275 | + | Some(self.host_attest(body, signer)?), |
|
| 276 | + | ); |
|
| 277 | + | self.receive_push_report(bundle, vec![], None, vec![t]) |
|
| 278 | + | } |
|
| 279 | + | ||
| 280 | + | /// Withdraw tag `name` as a host does for a signed-in user |
|
| 281 | + | /// (oxygenhub#301): an `untag` attestation by `signer`, received as a |
|
| 282 | + | /// pushed withdrawal is. |
|
| 283 | + | pub fn host_tag_withdrawal( |
|
| 284 | + | &self, |
|
| 285 | + | name: &str, |
|
| 286 | + | reason: &str, |
|
| 287 | + | signer: (&KeyId, &SecretKey), |
|
| 288 | + | identities: &[Identity], |
|
| 289 | + | ) -> Result<PushReport> { |
|
| 290 | + | let Some(state) = self.view()?.tags.get(name).copied() else { |
|
| 291 | + | return fail(format!("no tag {name:?}")); |
|
| 292 | + | }; |
|
| 293 | + | let body = AttestBody::Untag { |
|
| 294 | + | name: name.into(), |
|
| 295 | + | state, |
|
| 296 | + | reason: reason.into(), |
|
| 297 | + | }; |
|
| 298 | + | let t = crate::protocol::TagRef::new( |
|
| 299 | + | name.into(), |
|
| 300 | + | state, |
|
| 301 | + | vec![], |
|
| 302 | + | Some(self.host_attest(body, signer)?), |
|
| 303 | + | ); |
|
| 304 | + | self.receive_push_report(self.hosted_bundle(identities), vec![], None, vec![t]) |
|
| 305 | + | } |
|
| 306 | + | ||
| 307 | + | fn host_attest( |
|
| 308 | + | &self, |
|
| 309 | + | body: AttestBody, |
|
| 310 | + | (key, sk): (&KeyId, &SecretKey), |
|
| 311 | + | ) -> Result<SignedAttest> { |
|
| 312 | + | let attest = Attest { |
|
| 313 | + | repo: self.id()?, |
|
| 314 | + | hlc: Hlc::now(), |
|
| 315 | + | body, |
|
| 316 | + | }; |
|
| 317 | + | let sig = sk.sign(Domain::Attest, key, &attest.hash()); |
|
| 318 | + | Ok(SignedAttest { |
|
| 319 | + | attest, |
|
| 320 | + | sigs: vec![sig], |
|
| 321 | + | }) |
|
| 322 | + | } |
|
| 323 | + | ||
| 324 | + | fn hosted_bundle(&self, identities: &[Identity]) -> Bundle { |
|
| 325 | + | let mut bundle = Bundle::default(); |
|
| 326 | + | for id in identities { |
|
| 327 | + | bundle.objects.push((ObjectType::Identity, id.encode())); |
|
| 328 | + | } |
|
| 329 | + | bundle |
|
| 330 | + | } |
|
| 331 | + | ||
| 241 | 332 | /// Apply a host-made update as if it had been pushed: the new change |
|
| 242 | 333 | /// (with its signatures) if any, the identities it needs, and the |
|
| 243 | 334 | /// reference update, all through `receive_push_report`, so the same |
|
| … | 747 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +157 −3
| … | 27 unchanged lines |
||
| 28 | 28 | pub refs: Vec<(String, Vec<ChangeHash>, usize)>, |
|
| 29 | 29 | /// Tags adopted from the remote. |
|
| 30 | 30 | pub new_tags: usize, |
|
| 31 | + | /// Tags the remote withdrew, and so withdrawn here (oxygenhub#301). |
|
| 32 | + | pub withdrawn_tags: Vec<String>, |
|
| 31 | 33 | /// The remote has never presented a policy, so nothing was verified |
|
| 32 | 34 | /// against one (spec §14.5: warn loudly). |
|
| 33 | 35 | pub unverified: bool, |
|
| … | 100 unchanged lines |
||
| 134 | 136 | default_bookmark: Some(self.config.bookmark.clone()), |
|
| 135 | 137 | log: None, |
|
| 136 | 138 | witnesses: vec![], |
|
| 139 | + | withdrawn: self.withdrawn_refs()?, |
|
| 137 | 140 | }) |
|
| 138 | 141 | } |
|
| 139 | 142 | ||
| … | 804 unchanged lines |
||
| 944 | 947 | } |
|
| 945 | 948 | for t in &tags { |
|
| 946 | 949 | let name = format!("tags/{}", t.name); |
|
| 950 | + | if t.is_withdrawal() { |
|
| 951 | + | match self.adopt_withdrawal(&mut view, t, policy.as_ref(), &mut TagCheck::Received) |
|
| 952 | + | { |
|
| 953 | + | Ok(removed) => { |
|
| 954 | + | // A withdrawal is an update to nothing, as a |
|
| 955 | + | // bookmark's deletion is. |
|
| 956 | + | if removed { |
|
| 957 | + | report.updated.push(RefUpdated { |
|
| 958 | + | name: name.clone(), |
|
| 959 | + | old_state: t.state, |
|
| 960 | + | new_state: ObjectRef::ZERO, |
|
| 961 | + | heads: vec![], |
|
| 962 | + | }); |
|
| 963 | + | } |
|
| 964 | + | names.push(name.clone()); |
|
| 965 | + | results.push(RefResult { |
|
| 966 | + | name, |
|
| 967 | + | ok: true, |
|
| 968 | + | reason: if removed { |
|
| 969 | + | "withdrawn" |
|
| 970 | + | } else { |
|
| 971 | + | "already withdrawn" |
|
| 972 | + | } |
|
| 973 | + | .into(), |
|
| 974 | + | }); |
|
| 975 | + | } |
|
| 976 | + | Err(e) => results.push(RefResult { |
|
| 977 | + | name, |
|
| 978 | + | ok: false, |
|
| 979 | + | reason: e.reason, |
|
| 980 | + | }), |
|
| 981 | + | } |
|
| 982 | + | continue; |
|
| 983 | + | } |
|
| 947 | 984 | let new = !view.tags.contains_key(&t.name); |
|
| 948 | 985 | match self.adopt_tag(&mut view, t, policy.as_ref(), &mut TagCheck::Received) { |
|
| 949 | 986 | Ok(()) => { |
|
| … | 823 unchanged lines |
||
| 1773 | 1810 | let mut new_tags = 0; |
|
| 1774 | 1811 | for t in &resp.tags { |
|
| 1775 | 1812 | if view.tags.contains_key(&t.name) { |
|
| 1813 | + | continue; |
|
| 1814 | + | } |
|
| 1815 | + | // Withdrawn here: the name is not used again. |
|
| 1816 | + | if self.tag_withdrawal(&t.name)?.is_some() { |
|
| 1776 | 1817 | continue; |
|
| 1777 | 1818 | } |
|
| 1778 | 1819 | // The tag's changes must be here: fetch them if the bookmarks did not bring them. |
|
| … | 24 unchanged lines |
||
| 1803 | 1844 | Err(e) => return fail(format!("{remote_name} {}: {}", t.name, e.reason)), |
|
| 1804 | 1845 | } |
|
| 1805 | 1846 | } |
|
| 1847 | + | // The remote withdrew a tag this repository has: so does this one, |
|
| 1848 | + | // once the withdrawal is checked as a push's would be; one that is |
|
| 1849 | + | // not is left as it is, said so (oxygenhub#301). |
|
| 1850 | + | let mut withdrawn_tags = Vec::new(); |
|
| 1851 | + | for t in &resp.withdrawn { |
|
| 1852 | + | if !view.tags.contains_key(&t.name) { |
|
| 1853 | + | continue; |
|
| 1854 | + | } |
|
| 1855 | + | let mut check = TagCheck::Fetched { |
|
| 1856 | + | remote: remote_name, |
|
| 1857 | + | log: &log, |
|
| 1858 | + | notes: &mut log_notes, |
|
| 1859 | + | }; |
|
| 1860 | + | match self.adopt_withdrawal(&mut view, t, policy.as_ref(), &mut check) { |
|
| 1861 | + | Ok(true) => withdrawn_tags.push(t.name.clone()), |
|
| 1862 | + | Ok(false) => {} |
|
| 1863 | + | Err(e) => skipped.push(( |
|
| 1864 | + | format!("tags/{}", t.name), |
|
| 1865 | + | format!("withdrawn on {remote_name}, kept here: {}", e.reason), |
|
| 1866 | + | )), |
|
| 1867 | + | } |
|
| 1868 | + | } |
|
| 1806 | 1869 | if view != op.view { |
|
| 1807 | 1870 | self.record_op(&[op_ref], &format!("oxy fetch {remote_name}"), view)?; |
|
| 1808 | 1871 | } |
|
| … | 13 unchanged lines |
||
| 1822 | 1885 | refs: report, |
|
| 1823 | 1886 | unverified, |
|
| 1824 | 1887 | new_tags, |
|
| 1888 | + | withdrawn_tags, |
|
| 1825 | 1889 | log_notes, |
|
| 1826 | 1890 | skipped, |
|
| 1827 | 1891 | }) |
|
| … | 199 unchanged lines |
||
| 2027 | 2091 | Ok(self |
|
| 2028 | 2092 | .checkout_carry(heads, bookmark, &format!("{command} (checkout --carry)")) |
|
| 2029 | 2093 | .is_ok()) |
|
| 2094 | + | } |
|
| 2095 | + | } |
|
| 2096 | + | ||
| 2097 | + | /// Withdraw tag `tag` on `remote_name` (`oxy push --delete tags/NAME`, |
|
| 2098 | + | /// oxygenhub#301): withdrawn here first when it still is a tag here |
|
| 2099 | + | /// (`oxy tag delete`), then the withdrawal alone is sent, with its |
|
| 2100 | + | /// signer's identity and certificate. |
|
| 2101 | + | pub fn push_tag_withdrawal(&self, remote_name: &str, tag: &str) -> Result<Vec<RefResult>> { |
|
| 2102 | + | let remote = self.open_named_remote(remote_name)?; |
|
| 2103 | + | self.check_remote_id(remote.as_ref(), remote_name, true)?; |
|
| 2104 | + | let resp = remote.refs()?; |
|
| 2105 | + | let name = format!("tags/{tag}"); |
|
| 2106 | + | if resp.withdrawn.iter().any(|t| t.name == tag) { |
|
| 2107 | + | return Ok(vec![RefResult { |
|
| 2108 | + | name, |
|
| 2109 | + | ok: true, |
|
| 2110 | + | reason: "already withdrawn".into(), |
|
| 2111 | + | }]); |
|
| 2112 | + | } |
|
| 2113 | + | let Some(there) = resp.tags.iter().find(|t| t.name == tag) else { |
|
| 2114 | + | return fail(format!("{remote_name} has no tag {tag}")); |
|
| 2115 | + | }; |
|
| 2116 | + | if resp.policy.is_some() && self.secret_key()?.is_none() { |
|
| 2117 | + | return fail(format!("{remote_name} enforces a trust policy; withdrawing a tag needs an identity (`oxy identity create`)")); |
|
| 2030 | 2118 | } |
|
| 2119 | + | if self.tag_withdrawal(tag)?.is_none() { |
|
| 2120 | + | match self.view()?.tags.get(tag) { |
|
| 2121 | + | Some(st) if *st == there.state => { |
|
| 2122 | + | self.tag_delete(tag, "")?; |
|
| 2123 | + | } |
|
| 2124 | + | Some(_) => { |
|
| 2125 | + | return fail(format!( |
|
| 2126 | + | "tag {tag} names another state here than on {remote_name}" |
|
| 2127 | + | )) |
|
| 2128 | + | } |
|
| 2129 | + | None => { |
|
| 2130 | + | return fail(format!( |
|
| 2131 | + | "there is no tag {tag} here to withdraw: `oxy fetch {remote_name}` first" |
|
| 2132 | + | )) |
|
| 2133 | + | } |
|
| 2134 | + | } |
|
| 2135 | + | } |
|
| 2136 | + | let Some(w) = self |
|
| 2137 | + | .withdrawn_refs()? |
|
| 2138 | + | .into_iter() |
|
| 2139 | + | .find(|w| w.name == tag && w.state == there.state) |
|
| 2140 | + | else { |
|
| 2141 | + | return fail(format!( |
|
| 2142 | + | "tag {tag} was withdrawn here at another state than {remote_name}'s" |
|
| 2143 | + | )); |
|
| 2144 | + | }; |
|
| 2145 | + | let mut bundle = Bundle::default(); |
|
| 2146 | + | let signers: Vec<KeyId> = w |
|
| 2147 | + | .attestation |
|
| 2148 | + | .iter() |
|
| 2149 | + | .flat_map(|a| a.sigs.iter().map(|s| s.key)) |
|
| 2150 | + | .collect(); |
|
| 2151 | + | for k in &signers { |
|
| 2152 | + | if let Ok(id) = self.identity(k) { |
|
| 2153 | + | bundle.objects.push((ObjectType::Identity, id.encode())); |
|
| 2154 | + | } |
|
| 2155 | + | } |
|
| 2156 | + | self.attach_certificates_for(&mut bundle, &signers)?; |
|
| 2157 | + | remote.push(bundle, vec![], None, vec![w]) |
|
| 2031 | 2158 | } |
|
| 2032 | 2159 | ||
| 2033 | 2160 | /// Push the current bookmark (spec §16.3), sealing this author's unsigned |
|
| … | 2 unchanged lines |
||
| 2036 | 2163 | /// state, attested when we have an identity. The local bookmark, if |
|
| 2037 | 2164 | /// any, is kept; only the remote-tracking entry goes. |
|
| 2038 | 2165 | pub fn push_delete(&self, remote_name: &str, bookmark: &str) -> Result<Vec<RefResult>> { |
|
| 2166 | + | if let Some(tag) = bookmark.strip_prefix("tags/") { |
|
| 2167 | + | return self.push_tag_withdrawal(remote_name, tag); |
|
| 2168 | + | } |
|
| 2039 | 2169 | let remote = self.open_named_remote(remote_name)?; |
|
| 2040 | 2170 | self.check_remote_id(remote.as_ref(), remote_name, true)?; |
|
| 2041 | 2171 | let resp = remote.refs()?; |
|
| … | 174 unchanged lines |
||
| 2216 | 2346 | if remote_has_policy && self.secret_key()?.is_none() { |
|
| 2217 | 2347 | return fail(format!("{remote_name} enforces a trust policy; pushing needs an identity (`oxy identity create`)")); |
|
| 2218 | 2348 | } |
|
| 2219 | - | let remote_tags: BTreeSet<String> = resp.tags.iter().map(|t| t.name.clone()).collect(); |
|
| 2349 | + | let remote_tags: BTreeSet<String> = resp |
|
| 2350 | + | .tags |
|
| 2351 | + | .iter() |
|
| 2352 | + | .chain(&resp.withdrawn) |
|
| 2353 | + | .map(|t| t.name.clone()) |
|
| 2354 | + | .collect(); |
|
| 2220 | 2355 | let tags_to_send: Vec<crate::protocol::TagRef> = self |
|
| 2221 | 2356 | .tag_refs()? |
|
| 2222 | 2357 | .into_iter() |
|
| 2223 | 2358 | .filter(|t| !remote_tags.contains(&t.name)) |
|
| 2359 | + | .collect(); |
|
| 2360 | + | // Tags withdrawn here that the remote still has, at the same state |
|
| 2361 | + | // (oxygenhub#301). |
|
| 2362 | + | let withdrawals_to_send: Vec<crate::protocol::TagRef> = self |
|
| 2363 | + | .withdrawn_refs()? |
|
| 2364 | + | .into_iter() |
|
| 2365 | + | .filter(|w| { |
|
| 2366 | + | resp.tags |
|
| 2367 | + | .iter() |
|
| 2368 | + | .any(|t| t.name == w.name && t.state == w.state) |
|
| 2369 | + | }) |
|
| 2224 | 2370 | .collect(); |
|
| 2225 | 2371 | let refs = resp.refs; |
|
| 2226 | 2372 | let empty = self.store.put_state(&StateObject::new(vec![]))?; |
|
| … | 9 unchanged lines |
||
| 2236 | 2382 | // state is unchanged, so the current attestation is always by a key |
|
| 2237 | 2383 | // the current policy authorizes (a revoked key's attestation would |
|
| 2238 | 2384 | // otherwise be refused by every later fetch). |
|
| 2239 | - | if new_state == remote_state && policy_to_send.is_none() && tags_to_send.is_empty() { |
|
| 2385 | + | if new_state == remote_state |
|
| 2386 | + | && policy_to_send.is_none() |
|
| 2387 | + | && tags_to_send.is_empty() |
|
| 2388 | + | && withdrawals_to_send.is_empty() |
|
| 2389 | + | { |
|
| 2240 | 2390 | return Ok(vec![RefResult { |
|
| 2241 | 2391 | name: bookmark, |
|
| 2242 | 2392 | ok: true, |
|
| … | 139 unchanged lines |
||
| 2382 | 2532 | // receiver has never seen, so its identity and certificate go too. |
|
| 2383 | 2533 | let tag_signers: Vec<KeyId> = tags_to_send |
|
| 2384 | 2534 | .iter() |
|
| 2535 | + | .chain(&withdrawals_to_send) |
|
| 2385 | 2536 | .filter_map(|t| t.attestation.as_ref()) |
|
| 2386 | 2537 | .flat_map(|a| a.sigs.iter().map(|s| s.key)) |
|
| 2387 | 2538 | .collect(); |
|
| … | 33 unchanged lines |
||
| 2421 | 2572 | attestation, |
|
| 2422 | 2573 | }], |
|
| 2423 | 2574 | policy_to_send, |
|
| 2424 | - | tags_to_send, |
|
| 2575 | + | tags_to_send |
|
| 2576 | + | .into_iter() |
|
| 2577 | + | .chain(withdrawals_to_send) |
|
| 2578 | + | .collect(), |
|
| 2425 | 2579 | )?; |
|
| 2426 | 2580 | if let (Some(serial), true) = ( |
|
| 2427 | 2581 | sent_serial, |
|
| … | 219 unchanged lines |
modified crates/oxy-core/src/repo/tags.rs +258 −1
| 1 | 1 | //! Tags (spec §10.1, §14.4): immutable named states. A tag is a `tags/<name>` |
|
| 2 | 2 | //! entry in the view pointing at a state object, plus a signed `tag` |
|
| 3 | 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. |
|
| 4 | + | //! server refuses a second attestation for an existing tag name. A tag is |
|
| 5 | + | //! withdrawn by a signed `untag` attestation (oxygenhub#301), kept beside |
|
| 6 | + | //! its creation; the name is not used again. |
|
| 5 | 7 | ||
| 6 | 8 | use super::*; |
|
| 7 | 9 | ||
| … | 80 unchanged lines |
||
| 88 | 90 | write_atomic(&self.tag_attest_path(name), r.to_hex().as_bytes()) |
|
| 89 | 91 | } |
|
| 90 | 92 | ||
| 93 | + | fn tag_withdrawal_path(&self, name: &str) -> PathBuf { |
|
| 94 | + | self.oxy.join("attest").join("untags").join(name_file(name)) |
|
| 95 | + | } |
|
| 96 | + | ||
| 97 | + | /// The withdrawal of tag `name`, when it was withdrawn here. |
|
| 98 | + | pub fn tag_withdrawal(&self, name: &str) -> Result<Option<(ObjectRef, SignedAttest)>> { |
|
| 99 | + | match fs::read_to_string(self.tag_withdrawal_path(name)) { |
|
| 100 | + | Ok(h) => { |
|
| 101 | + | let r = ObjectRef::from_hex(h.trim()) |
|
| 102 | + | .ok_or_else(|| RepoError::Msg("corrupt tag withdrawal pointer".into()))?; |
|
| 103 | + | Ok(Some(( |
|
| 104 | + | r, |
|
| 105 | + | SignedAttest { |
|
| 106 | + | attest: self.store.get_attest(&r)?, |
|
| 107 | + | sigs: self.store.signatures(&r)?, |
|
| 108 | + | }, |
|
| 109 | + | ))) |
|
| 110 | + | } |
|
| 111 | + | Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), |
|
| 112 | + | Err(e) => Err(e.into()), |
|
| 113 | + | } |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | fn set_tag_withdrawal(&self, name: &str, r: &ObjectRef) -> Result<()> { |
|
| 117 | + | fs::create_dir_all(self.oxy.join("attest").join("untags"))?; |
|
| 118 | + | write_atomic(&self.tag_withdrawal_path(name), r.to_hex().as_bytes()) |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | /// Withdraw tag `name` (spec §10.1): an `untag` attestation naming its |
|
| 122 | + | /// state, signed when this repository has a key, and the tag leaves the |
|
| 123 | + | /// view. The next push sends the withdrawal to a remote that still has |
|
| 124 | + | /// the tag, under a policy only when the key may create the tag there. |
|
| 125 | + | pub fn tag_delete(&self, name: &str, reason: &str) -> Result<TagInfo> { |
|
| 126 | + | let (op_ref, op) = self.head_op()?; |
|
| 127 | + | let Some(state) = op.view.tags.get(name).copied() else { |
|
| 128 | + | return fail(format!("no tag {name:?}")); |
|
| 129 | + | }; |
|
| 130 | + | let heads = self.store.get_state(&state)?.heads; |
|
| 131 | + | let attest = Attest { |
|
| 132 | + | repo: self.id()?, |
|
| 133 | + | hlc: Hlc::now(), |
|
| 134 | + | body: AttestBody::Untag { |
|
| 135 | + | name: name.into(), |
|
| 136 | + | state, |
|
| 137 | + | reason: reason.into(), |
|
| 138 | + | }, |
|
| 139 | + | }; |
|
| 140 | + | let mut signer = None; |
|
| 141 | + | let r = if self.secret_key()?.is_some() { |
|
| 142 | + | let sa = self.sign_attest(&attest)?; |
|
| 143 | + | signer = sa |
|
| 144 | + | .sigs |
|
| 145 | + | .first() |
|
| 146 | + | .and_then(|s| self.store.get_identity(&s.key).ok()) |
|
| 147 | + | .map(|i| i.name); |
|
| 148 | + | sa.attest.hash() |
|
| 149 | + | } else { |
|
| 150 | + | self.store.put_attest(&attest)? |
|
| 151 | + | }; |
|
| 152 | + | self.set_tag_withdrawal(name, &r)?; |
|
| 153 | + | let mut view = op.view.clone(); |
|
| 154 | + | view.tags.remove(name); |
|
| 155 | + | self.record_op(&[op_ref], &format!("oxy tag delete {name}"), view)?; |
|
| 156 | + | Ok(TagInfo { |
|
| 157 | + | name: name.into(), |
|
| 158 | + | state, |
|
| 159 | + | heads, |
|
| 160 | + | message: reason.into(), |
|
| 161 | + | signer, |
|
| 162 | + | }) |
|
| 163 | + | } |
|
| 164 | + | ||
| 165 | + | /// Every tag withdrawn here, as `refs` reports them: the `untag` |
|
| 166 | + | /// attestation as the attestation, with its acceptance when a |
|
| 167 | + | /// short-lived key signed it. |
|
| 168 | + | pub(crate) fn withdrawn_refs(&self) -> Result<Vec<crate::protocol::TagRef>> { |
|
| 169 | + | let dir = self.oxy.join("attest").join("untags"); |
|
| 170 | + | let entries = match fs::read_dir(&dir) { |
|
| 171 | + | Ok(e) => e, |
|
| 172 | + | Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), |
|
| 173 | + | Err(e) => return Err(e.into()), |
|
| 174 | + | }; |
|
| 175 | + | let mut out = Vec::new(); |
|
| 176 | + | for e in entries { |
|
| 177 | + | let e = e?; |
|
| 178 | + | let Ok(h) = fs::read_to_string(e.path()) else { |
|
| 179 | + | continue; |
|
| 180 | + | }; |
|
| 181 | + | let Some(r) = ObjectRef::from_hex(h.trim()) else { |
|
| 182 | + | continue; |
|
| 183 | + | }; |
|
| 184 | + | let sa = SignedAttest { |
|
| 185 | + | attest: self.store.get_attest(&r)?, |
|
| 186 | + | sigs: self.store.signatures(&r)?, |
|
| 187 | + | }; |
|
| 188 | + | let AttestBody::Untag { name, state, .. } = &sa.attest.body else { |
|
| 189 | + | continue; |
|
| 190 | + | }; |
|
| 191 | + | let heads = self |
|
| 192 | + | .store |
|
| 193 | + | .get_state(state) |
|
| 194 | + | .map(|s| s.heads) |
|
| 195 | + | .unwrap_or_default(); |
|
| 196 | + | let accepted = self.tag_acceptance(&sa)?; |
|
| 197 | + | let mut t = crate::protocol::TagRef::new(name.clone(), *state, heads, Some(sa)); |
|
| 198 | + | t.accepted = accepted; |
|
| 199 | + | out.push(t); |
|
| 200 | + | } |
|
| 201 | + | out.sort_by(|a, b| a.name.cmp(&b.name)); |
|
| 202 | + | Ok(out) |
|
| 203 | + | } |
|
| 204 | + | ||
| 91 | 205 | /// Create a tag named `name` for the state of `heads` (spec §10.1). |
|
| 92 | 206 | pub fn tag_create(&self, name: &str, heads: &[ChangeHash], message: &str) -> Result<TagInfo> { |
|
| 93 | 207 | crate::objects::validate_name(name) |
|
| … | 1 unchanged lines |
||
| 95 | 209 | let (op_ref, op) = self.head_op()?; |
|
| 96 | 210 | if op.view.tags.contains_key(name) { |
|
| 97 | 211 | return fail(format!("tag {name:?} already exists; tags are immutable")); |
|
| 212 | + | } |
|
| 213 | + | if self.tag_withdrawal(name)?.is_some() { |
|
| 214 | + | return fail(format!( |
|
| 215 | + | "tag {name:?} was withdrawn; a withdrawn tag's name is not used again" |
|
| 216 | + | )); |
|
| 98 | 217 | } |
|
| 99 | 218 | let st = self.load_state_history(heads)?; |
|
| 100 | 219 | let heads = st.maximal(heads); |
|
| … | 178 unchanged lines |
||
| 279 | 398 | policy: Option<&crate::policy::Policy>, |
|
| 280 | 399 | check: &mut TagCheck, |
|
| 281 | 400 | ) -> std::result::Result<(), TagRefusal> { |
|
| 401 | + | if self |
|
| 402 | + | .tag_withdrawal(&t.name) |
|
| 403 | + | .map_err(|e| e.to_string())? |
|
| 404 | + | .is_some() |
|
| 405 | + | { |
|
| 406 | + | return Err(format!( |
|
| 407 | + | "tag {:?} was withdrawn here; a withdrawn tag's name is not used again", |
|
| 408 | + | t.name |
|
| 409 | + | ) |
|
| 410 | + | .into()); |
|
| 411 | + | } |
|
| 282 | 412 | if let Some(existing) = view.tags.get(&t.name) { |
|
| 283 | 413 | return if *existing == t.state { |
|
| 284 | 414 | Ok(()) |
|
| … | 89 unchanged lines |
||
| 374 | 504 | } |
|
| 375 | 505 | view.tags.insert(t.name.clone(), t.state); |
|
| 376 | 506 | Ok(()) |
|
| 507 | + | } |
|
| 508 | + | ||
| 509 | + | /// Adopt the withdrawal of tag `t` (its `attestation` the `untag` |
|
| 510 | + | /// attestation), received in a push or fetched: the tag must be here |
|
| 511 | + | /// with the state the withdrawal names, and under a policy its signer |
|
| 512 | + | /// must be one that may create the tag, a short-lived key at the time |
|
| 513 | + | /// `check` says (as for creating it, except that a fetch checks at the |
|
| 514 | + | /// issuer's acceptance only). The tag leaves `view` and the withdrawal |
|
| 515 | + | /// is kept. Whether the tag was here and is now withdrawn: a withdrawal |
|
| 516 | + | /// already kept is `false`. |
|
| 517 | + | pub(crate) fn adopt_withdrawal( |
|
| 518 | + | &self, |
|
| 519 | + | view: &mut View, |
|
| 520 | + | t: &crate::protocol::TagRef, |
|
| 521 | + | policy: Option<&crate::policy::Policy>, |
|
| 522 | + | check: &mut TagCheck, |
|
| 523 | + | ) -> std::result::Result<bool, TagRefusal> { |
|
| 524 | + | let sa = t |
|
| 525 | + | .attestation |
|
| 526 | + | .as_ref() |
|
| 527 | + | .ok_or("a tag withdrawal carries no attestation")?; |
|
| 528 | + | let AttestBody::Untag { name, state, .. } = &sa.attest.body else { |
|
| 529 | + | return Err("tag withdrawal has the wrong kind".into()); |
|
| 530 | + | }; |
|
| 531 | + | if *name != t.name || *state != t.state { |
|
| 532 | + | return Err("tag withdrawal does not describe this tag".into()); |
|
| 533 | + | } |
|
| 534 | + | if sa.attest.repo != self.id().map_err(|e| e.to_string())? { |
|
| 535 | + | return Err("tag withdrawal is for a different repository".into()); |
|
| 536 | + | } |
|
| 537 | + | if self |
|
| 538 | + | .tag_withdrawal(&t.name) |
|
| 539 | + | .map_err(|e| e.to_string())? |
|
| 540 | + | .is_some() |
|
| 541 | + | { |
|
| 542 | + | return Ok(false); |
|
| 543 | + | } |
|
| 544 | + | match view.tags.get(&t.name) { |
|
| 545 | + | None => return Err(format!("there is no tag {:?} here", t.name).into()), |
|
| 546 | + | Some(st) if *st != t.state => { |
|
| 547 | + | return Err(format!( |
|
| 548 | + | "the withdrawal names another state of tag {:?} than this repository's", |
|
| 549 | + | t.name |
|
| 550 | + | ) |
|
| 551 | + | .into()) |
|
| 552 | + | } |
|
| 553 | + | Some(_) => {} |
|
| 554 | + | } |
|
| 555 | + | let r = sa.attest.hash(); |
|
| 556 | + | let mut certified = false; |
|
| 557 | + | if let Some(sig) = sa.sigs.first() { |
|
| 558 | + | let id = self |
|
| 559 | + | .store |
|
| 560 | + | .get_identity(&sig.key) |
|
| 561 | + | .map_err(|_| format!("unknown key {}", sig.key.short(12)))?; |
|
| 562 | + | verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?; |
|
| 563 | + | certified = self.store.certificate_of(&sig.key).ok().flatten().is_some(); |
|
| 564 | + | if let Some(p) = policy { |
|
| 565 | + | let accepted = self.issuer_acceptance(&sig.key, sa, t.accepted.as_ref()); |
|
| 566 | + | let reference = format!("tags/{}", t.name); |
|
| 567 | + | let in_time = |
|
| 568 | + | |ms: u64| self.certified_authorized(p, &sig.key, &reference, Scope::REFS, ms); |
|
| 569 | + | let allowed = self.tag_signer_listed(p, &t.name, sa) |
|
| 570 | + | || (certified |
|
| 571 | + | && match check { |
|
| 572 | + | TagCheck::Received => { |
|
| 573 | + | in_time(Hlc::now().wall_ms) || accepted.is_some_and(in_time) |
|
| 574 | + | } |
|
| 575 | + | TagCheck::Mirrored => { |
|
| 576 | + | in_time(accepted.unwrap_or(sa.attest.hlc.wall_ms)) |
|
| 577 | + | } |
|
| 578 | + | TagCheck::Fetched { remote, .. } => match accepted { |
|
| 579 | + | Some(ms) => in_time(ms), |
|
| 580 | + | None => { |
|
| 581 | + | return Err(TagRefusal { |
|
| 582 | + | reason: format!( |
|
| 583 | + | "withdrawn by short-lived key {}, and {remote} gave no acceptance to check its certificate at", |
|
| 584 | + | sig.key.short(12) |
|
| 585 | + | ), |
|
| 586 | + | untimed: true, |
|
| 587 | + | }) |
|
| 588 | + | } |
|
| 589 | + | }, |
|
| 590 | + | }); |
|
| 591 | + | if !allowed { |
|
| 592 | + | return Err(format!( |
|
| 593 | + | "key {} may not withdraw tags/{} under this repository's policy", |
|
| 594 | + | sig.key.short(12), |
|
| 595 | + | t.name |
|
| 596 | + | ) |
|
| 597 | + | .into()); |
|
| 598 | + | } |
|
| 599 | + | } |
|
| 600 | + | } else if policy.is_some() { |
|
| 601 | + | return Err(format!( |
|
| 602 | + | "withdrawing tag {:?} needs a signed attestation under this repository's policy", |
|
| 603 | + | t.name |
|
| 604 | + | ) |
|
| 605 | + | .into()); |
|
| 606 | + | } |
|
| 607 | + | let stored = self |
|
| 608 | + | .store |
|
| 609 | + | .put_attest(&sa.attest) |
|
| 610 | + | .map_err(|e| e.to_string())?; |
|
| 611 | + | for s in &sa.sigs { |
|
| 612 | + | self.store |
|
| 613 | + | .put_signature(&stored, s) |
|
| 614 | + | .map_err(|e| e.to_string())?; |
|
| 615 | + | } |
|
| 616 | + | if certified { |
|
| 617 | + | match &t.accepted { |
|
| 618 | + | Some(acc) |
|
| 619 | + | if sa.sigs.first().is_some_and(|s| { |
|
| 620 | + | self.issuer_acceptance(&s.key, sa, Some(acc)).is_some() |
|
| 621 | + | }) => |
|
| 622 | + | { |
|
| 623 | + | self.keep_acceptance(&stored, acc) |
|
| 624 | + | } |
|
| 625 | + | _ if matches!(check, TagCheck::Received) => self.record_acceptance(&stored), |
|
| 626 | + | _ => Ok(()), |
|
| 627 | + | } |
|
| 628 | + | .map_err(|e| e.to_string())?; |
|
| 629 | + | } |
|
| 630 | + | self.set_tag_withdrawal(&t.name, &stored) |
|
| 631 | + | .map_err(|e| e.to_string())?; |
|
| 632 | + | view.tags.remove(&t.name); |
|
| 633 | + | Ok(true) |
|
| 377 | 634 | } |
|
| 378 | 635 | ||
| 379 | 636 | /// Whether short-lived key `signer`'s certificate gives it tag `t` |
|
| … | 57 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +85 −0
| … | 3022 unchanged lines |
||
| 3023 | 3023 | } |
|
| 3024 | 3024 | ||
| 3025 | 3025 | #[test] |
|
| 3026 | + | fn a_tag_is_withdrawn_by_a_signed_withdrawal() { |
|
| 3027 | + | hermetic(); |
|
| 3028 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 3029 | + | let root = dir.path(); |
|
| 3030 | + | let origin = Repo::init(&root.join("origin")).unwrap(); |
|
| 3031 | + | let url = root.join("origin").to_string_lossy().to_string(); |
|
| 3032 | + | let mut a = Repo::clone(&url, &root.join("a")) |
|
| 3033 | + | .unwrap() |
|
| 3034 | + | .with_home(root.join("home-a")); |
|
| 3035 | + | a.create_identity("Ada", "[email protected]", IdentityScope::User) |
|
| 3036 | + | .unwrap(); |
|
| 3037 | + | write(&root.join("a"), "f.txt", b"1\n"); |
|
| 3038 | + | let h1 = a.commit("one", &[]).unwrap(); |
|
| 3039 | + | a.tag_create("v1", &[h1], "first release").unwrap(); |
|
| 3040 | + | a.tag_create("v2", &[h1], "").unwrap(); |
|
| 3041 | + | a.policy_init().unwrap(); |
|
| 3042 | + | a.push("origin", false).unwrap(); |
|
| 3043 | + | origin.refresh().unwrap(); |
|
| 3044 | + | let original = a |
|
| 3045 | + | .tag_refs() |
|
| 3046 | + | .unwrap() |
|
| 3047 | + | .into_iter() |
|
| 3048 | + | .find(|t| t.name == "v1") |
|
| 3049 | + | .unwrap(); |
|
| 3050 | + | let b = Repo::clone(&url, &root.join("b")) |
|
| 3051 | + | .unwrap() |
|
| 3052 | + | .with_home(root.join("home-b")); |
|
| 3053 | + | assert_eq!(b.tag_heads("v1").unwrap(), Some(vec![h1])); |
|
| 3054 | + | ||
| 3055 | + | // A key the policy does not let create the tag may not withdraw it. |
|
| 3056 | + | let mut c = Repo::clone(&url, &root.join("c")) |
|
| 3057 | + | .unwrap() |
|
| 3058 | + | .with_home(root.join("home-c")); |
|
| 3059 | + | c.create_identity("Cy", "[email protected]", IdentityScope::User) |
|
| 3060 | + | .unwrap(); |
|
| 3061 | + | let r = c.push_delete("origin", "tags/v1").unwrap(); |
|
| 3062 | + | assert!( |
|
| 3063 | + | !r[0].ok && r[0].reason.contains("may not withdraw"), |
|
| 3064 | + | "{r:?}" |
|
| 3065 | + | ); |
|
| 3066 | + | assert_eq!(c.tag_heads("v1").unwrap(), None, "withdrawn there first"); |
|
| 3067 | + | origin.refresh().unwrap(); |
|
| 3068 | + | assert_eq!(origin.tag_heads("v1").unwrap(), Some(vec![h1])); |
|
| 3069 | + | ||
| 3070 | + | // Its creator's key may: the tag goes, the withdrawal stays. |
|
| 3071 | + | let r = a.push_delete("origin", "tags/v1").unwrap(); |
|
| 3072 | + | assert!(r[0].ok && r[0].reason == "withdrawn", "{r:?}"); |
|
| 3073 | + | origin.refresh().unwrap(); |
|
| 3074 | + | assert_eq!(origin.tag_heads("v1").unwrap(), None); |
|
| 3075 | + | assert_eq!(a.tag_heads("v1").unwrap(), None); |
|
| 3076 | + | assert!(origin.tag_withdrawal("v1").unwrap().is_some()); |
|
| 3077 | + | assert!(origin.tag_attest("v1").unwrap().is_some(), "kept beside it"); |
|
| 3078 | + | let refs = origin.remote_refs().unwrap(); |
|
| 3079 | + | assert!(refs.tags.iter().all(|t| t.name != "v1")); |
|
| 3080 | + | assert_eq!(refs.withdrawn.len(), 1); |
|
| 3081 | + | let r = a.push_delete("origin", "tags/v1").unwrap(); |
|
| 3082 | + | assert!(r[0].ok && r[0].reason == "already withdrawn", "{r:?}"); |
|
| 3083 | + | ||
| 3084 | + | // A fetch withdraws it too, and the name is never used again. |
|
| 3085 | + | let rep = b.fetch("origin").unwrap(); |
|
| 3086 | + | assert_eq!(rep.withdrawn_tags, vec!["v1".to_string()]); |
|
| 3087 | + | assert_eq!(b.tag_heads("v1").unwrap(), None); |
|
| 3088 | + | assert!(b.tag_create("v1", &[h1], "").is_err()); |
|
| 3089 | + | let r = origin |
|
| 3090 | + | .receive_push(Bundle::default(), vec![], None, vec![original]) |
|
| 3091 | + | .unwrap(); |
|
| 3092 | + | assert!(!r[0].ok && r[0].reason.contains("withdrawn"), "{r:?}"); |
|
| 3093 | + | ||
| 3094 | + | // `oxy tag delete`, then a plain push, sends it. |
|
| 3095 | + | a.tag_delete("v2", "tagged too early").unwrap(); |
|
| 3096 | + | let r = a.push("origin", false).unwrap(); |
|
| 3097 | + | assert!( |
|
| 3098 | + | r.iter() |
|
| 3099 | + | .any(|x| x.name == "tags/v2" && x.ok && x.reason == "withdrawn"), |
|
| 3100 | + | "{r:?}" |
|
| 3101 | + | ); |
|
| 3102 | + | origin.refresh().unwrap(); |
|
| 3103 | + | assert_eq!(origin.tag_heads("v2").unwrap(), None); |
|
| 3104 | + | let (_, w) = origin.tag_withdrawal("v2").unwrap().unwrap(); |
|
| 3105 | + | assert!( |
|
| 3106 | + | matches!(&w.attest.body, AttestBody::Untag { reason, .. } if reason == "tagged too early") |
|
| 3107 | + | ); |
|
| 3108 | + | } |
|
| 3109 | + | ||
| 3110 | + | #[test] |
|
| 3026 | 3111 | fn tags_are_immutable_named_states_and_travel() { |
|
| 3027 | 3112 | hermetic(); |
|
| 3028 | 3113 | let dir = tempfile::tempdir().unwrap(); |
|
| … | 6842 unchanged lines |
modified crates/oxy-server/src/lib.rs +29 −2
| … | 105 unchanged lines |
||
| 106 | 106 | tailor_refs(repo, refs, headers); |
|
| 107 | 107 | } |
|
| 108 | 108 | ||
| 109 | + | /// Whether tag `tag` of `name` may be withdrawn (oxygenhub#301): called |
|
| 110 | + | /// for each withdrawal a push carries, before the repository sees it. |
|
| 111 | + | /// A host refuses one it depends on (a forge's release names the tag) |
|
| 112 | + | /// with the reason, which is that withdrawal's result. The default |
|
| 113 | + | /// allows every one; the policy is checked after, as for any push. |
|
| 114 | + | fn vet_tag_withdrawal(&self, _name: &str, _tag: &str) -> Result<(), String> { |
|
| 115 | + | Ok(()) |
|
| 116 | + | } |
|
| 117 | + | ||
| 109 | 118 | /// The bookmark of `name` that pushes may never delete (its default), |
|
| 110 | 119 | /// when the host keeps one apart from the repository's own config. |
|
| 111 | 120 | fn default_bookmark(&self, _name: &str) -> Option<String> { |
|
| … | 240 unchanged lines |
||
| 352 | 361 | let (refused, updates): (Vec<_>, Vec<_>) = updates.into_iter().partition(|u| { |
|
| 353 | 362 | u.new_state.is_zero() && protected.as_deref() == Some(u.name.as_str()) |
|
| 354 | 363 | }); |
|
| 355 | - | let mut report = r.receive_push_report(bundle, updates, policy, tags)?; |
|
| 364 | + | let mut kept_tags = Vec::new(); |
|
| 365 | + | let mut withdrawals_refused = Vec::new(); |
|
| 366 | + | for t in tags { |
|
| 367 | + | match t |
|
| 368 | + | .is_withdrawal() |
|
| 369 | + | .then(|| self.backend.vet_tag_withdrawal(name, &t.name)) |
|
| 370 | + | { |
|
| 371 | + | Some(Err(reason)) => withdrawals_refused.push((t.name, reason)), |
|
| 372 | + | _ => kept_tags.push(t), |
|
| 373 | + | } |
|
| 374 | + | } |
|
| 375 | + | let mut report = r.receive_push_report(bundle, updates, policy, kept_tags)?; |
|
| 356 | 376 | // The request named references, all refused here: it was no |
|
| 357 | 377 | // push of objects alone, whatever the repository saw. |
|
| 358 | - | if !refused.is_empty() { |
|
| 378 | + | if !refused.is_empty() || !withdrawals_refused.is_empty() { |
|
| 359 | 379 | report |
|
| 360 | 380 | .results |
|
| 361 | 381 | .retain(|r| r.name != oxy_core::repo::OBJECTS_RESULT); |
|
| … | 6 unchanged lines |
||
| 368 | 388 | "{:?} is this repository's default bookmark and cannot be deleted", |
|
| 369 | 389 | u.name |
|
| 370 | 390 | ), |
|
| 391 | + | }); |
|
| 392 | + | } |
|
| 393 | + | for (tag, reason) in withdrawals_refused { |
|
| 394 | + | report.results.push(oxy_core::protocol::RefResult { |
|
| 395 | + | name: format!("tags/{tag}"), |
|
| 396 | + | ok: false, |
|
| 397 | + | reason, |
|
| 371 | 398 | }); |
|
| 372 | 399 | } |
|
| 373 | 400 | for r in report.results.iter().filter(|r| !r.ok) { |
|
| … | 137 unchanged lines |
modified docs/protocol.md +2 −2
| … | 111 unchanged lines |
||
| 112 | 112 | | Signed attestation | `[attest bytes, [Signature]]` | |
|
| 113 | 113 | | Signed policy | `[policy bytes, [Signature]]` (also the policy proposal file format) | |
|
| 114 | 114 | | Capabilities | `{0: [protocol versions], 1: repo id bytes(16), 2: [feature names]}`; version `1`; features currently `["seal"]` | |
|
| 115 | - | | Refs response | `{0: [ref], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}` | |
|
| 115 | + | | Refs response | `{0: [ref], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy], 8: [tag]}`; key 8 (optional, only when there are any; oxygenhub#301) lists the tags withdrawn there, each a tag whose attestation is its `untag` attestation and whose key 4 is that attestation's acceptance when a short-lived key signed it; older readers ignore it | |
|
| 116 | 116 | | ref | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null, 5: signed acceptance}`; the attestation is present only when it describes the current state; `last serial` is the highest attestation serial ever recorded for the name; key 5 (optional, only when there is one) is the host's `accepted` attestation of that attestation (spec §14.7); key 6 (optional) is `[entry number, entry bytes, [proof hash]]`, the attestation's inclusion proof in the host's transparency log against the response's key 6, the latest checkpoint as a signed note (spec §14.8) | |
|
| 117 | 117 | | tag | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: signed acceptance, 5: log place, 6: [cosigned checkpoint]}`; keys 4 to 6 (optional, each only when there is one; oxygen#129) are for the tag's attestation what a ref's keys 5 to 7 are for its own: the issuer's `accepted` attestation when a short-lived key signed the tag (spec §14.7), which a fetch checks the certificate at, and its place in the host's transparency log with the cosigned checkpoints that first held it (spec §14.8); a push carries key 4 when the client has one; older readers ignore them | |
|
| 118 | 118 | | Negotiate request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order (no change before its dependencies) | |
|
| … | 2 unchanged lines |
||
| 121 | 121 | | Checkpoint request | `{0: [heads]}`; response a Bundle of objects: the checkpoint of exactly those heads, its payload blob, and the blob's chunks; the server computes one when it has none for those heads | |
|
| 122 | 122 | | Shallow request | `{0: [heads], 1: depth}`; response `{0: [hashes]}`: the changes within `depth` dependency steps of the heads, dependencies first | |
|
| 123 | 123 | | Bundle | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}` | |
|
| 124 | - | | Push request | `{0: Bundle, 1: [update], 2: signed policy or null, 3: [tag]}` | |
|
| 124 | + | | Push request | `{0: Bundle, 1: [update], 2: signed policy or null, 3: [tag]}`; a tag whose attestation is an `untag` attestation withdraws that tag (oxygenhub#301), reported as `tags/<name>`; an older server refuses it as an attestation of the wrong kind | |
|
| 125 | 125 | | update | `{0: name, 1: old state ref, 2: new state ref, 3: force (bool), 4: signed attestation or null}` | |
|
| 126 | 126 | | Push response | `{0: [{0: name, 1: ok (bool), 2: reason (text)}]}`; a result named `policy` reports the policy update, `tags/<name>` each tag | |
|
| 127 | 127 | ||
| … | 101 unchanged lines |
modified docs/spec.md +3 −1
| … | 413 unchanged lines |
||
| 414 | 414 | ``` |
|
| 415 | 415 | bookmarks/<name> local bookmarks |
|
| 416 | 416 | remotes/<remote>/bookmarks/<name> last-seen remote bookmarks |
|
| 417 | - | tags/<name> tags (immutable once created) |
|
| 417 | + | tags/<name> tags (immutable once created; may be withdrawn) |
|
| 418 | 418 | policy the trust policy head (§14.5) |
|
| 419 | 419 | ``` |
|
| 420 | 420 | ||
| … | 15 unchanged lines |
||
| 436 | 436 | ||
| 437 | 437 | 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. A tag signed by a short-lived key (§14.7), as a keyless agent's is, is authorized by a subject grant like a bookmark update: on a push at the receiver's clock, on a fetch at a time its signer did not choose (§14.7, *Acceptance*). The push carries the tag signer's identity and certificate as it does the bookmark signer's, since the tag may have been made by an earlier process's key (oxygen#129). |
|
| 438 | 438 | ||
| 439 | + | **Withdrawing a tag** (oxygenhub#301). A tag is never moved, but it can be withdrawn: `oxy tag delete NAME [-r REASON]` records an attestation of kind `untag`, `{0: name, 1: state, 2: reason}`, naming the tag's state and signed by the repository's key, and the tag leaves the view; its `tag` attestation stays beside the withdrawal. The next `oxy push` sends the withdrawal to a remote that lists the tag at that state (`oxy push REMOTE --delete tags/NAME` withdraws it here and there at once), in the push's tags as a tag whose attestation is the withdrawal. A receiver adopts a withdrawal only of a tag it holds at the state named; under a policy the signer MUST be a key the rule for `tags/<name>` authorizes to create the tag (a listed key, its delegate, a rotation predecessor, or a short-lived key under a subject grant, at the receiver's clock on a push and at its issuer's acceptance on a fetch), and an unsigned withdrawal is refused. A server lists the tags withdrawn there in its `refs` answer (`withdrawn`), and a fetch withdraws a tag this repository holds at the same state when the withdrawal verifies, noting it, and otherwise keeps the tag, saying why. A withdrawn name is never used again: `tag create`, a push and a fetch refuse or skip a tag of that name, so a tag name never names two states. A host MAY refuse to withdraw a tag it depends on (the reference server's `Backend::vet_tag_withdrawal`; a forge refuses while a release names the tag). |
|
| 440 | + | ||
| 439 | 441 | ## 11. Checkpoints |
|
| 440 | 442 | ||
| 441 | 443 | Materializing a state by replaying every change is linear in history length. Checkpoints make checkout proportional to state size instead. |
|
| … | 747 unchanged lines |
modified src/cli/mod.rs +13 −1
| … | 426 unchanged lines |
||
| 427 | 427 | #[arg(short = 'b', long)] |
|
| 428 | 428 | bookmark: Option<String>, |
|
| 429 | 429 | /// Delete this bookmark on the remote (never its default bookmark); |
|
| 430 | - | /// the local bookmark is kept |
|
| 430 | + | /// the local bookmark is kept. `tags/NAME` withdraws a tag there |
|
| 431 | + | /// (and here, as `oxy tag delete` does) |
|
| 431 | 432 | #[arg(long, value_name = "NAME", conflicts_with_all = ["bookmark", "force"])] |
|
| 432 | 433 | delete: Option<String>, |
|
| 433 | 434 | /// Machine-readable output: one JSON document on stdout |
|
| … | 390 unchanged lines |
||
| 824 | 825 | #[arg(short, long, default_value = "")] |
|
| 825 | 826 | message: String, |
|
| 826 | 827 | }, |
|
| 828 | + | /// Withdraw a tag: a signed withdrawal, kept beside the tag's creation; |
|
| 829 | + | /// `oxy push` sends it to a remote that has the tag (the name is never |
|
| 830 | + | /// used again) |
|
| 831 | + | Delete { |
|
| 832 | + | /// Name of the tag |
|
| 833 | + | name: String, |
|
| 834 | + | /// Why it is withdrawn, recorded with the withdrawal |
|
| 835 | + | #[arg(short, long, default_value = "")] |
|
| 836 | + | reason: String, |
|
| 837 | + | }, |
|
| 827 | 838 | /// List tags and the states they name |
|
| 828 | 839 | List { |
|
| 829 | 840 | /// Machine-readable output: one JSON document on stdout |
|
| … | 540 unchanged lines |
||
| 1370 | 1381 | changes, |
|
| 1371 | 1382 | message, |
|
| 1372 | 1383 | } => workspace::tag_create(&name, &changes, &message), |
|
| 1384 | + | TagCmd::Delete { name, reason } => workspace::tag_delete(&name, &reason), |
|
| 1373 | 1385 | TagCmd::List { json } => workspace::tag_list(json), |
|
| 1374 | 1386 | }, |
|
| 1375 | 1387 | Cmd::Remote { cmd } => match cmd { |
|
| … | 183 unchanged lines |
modified src/cli/sync.rs +17 −1
| … | 398 unchanged lines |
||
| 399 | 399 | })) |
|
| 400 | 400 | ), |
|
| 401 | 401 | ("new_tags", json::n(report.new_tags)), |
|
| 402 | + | ( |
|
| 403 | + | "withdrawn_tags", |
|
| 404 | + | json::arr(report.withdrawn_tags.iter().map(|n| json::s(n))), |
|
| 405 | + | ), |
|
| 402 | 406 | ("unverified", json::b(report.unverified)), |
|
| 403 | 407 | ( |
|
| 404 | 408 | "log_notes", |
|
| … | 56 unchanged lines |
||
| 461 | 465 | ui::dim(format!("{} adopted", plural(report.new_tags, "tag"))) |
|
| 462 | 466 | ); |
|
| 463 | 467 | } |
|
| 468 | + | for t in &report.withdrawn_tags { |
|
| 469 | + | println!( |
|
| 470 | + | " {} {}", |
|
| 471 | + | ui::note(), |
|
| 472 | + | ui::dim(format!("tag {t} was withdrawn on {remote}, and here")) |
|
| 473 | + | ); |
|
| 474 | + | } |
|
| 464 | 475 | if report.unverified { |
|
| 465 | 476 | eprintln!( |
|
| 466 | 477 | "{} {remote} has no trust policy; nothing fetched from it was verified", |
|
| … | 175 unchanged lines |
||
| 642 | 653 | let mut code = 0; |
|
| 643 | 654 | for r in results { |
|
| 644 | 655 | if r.ok { |
|
| 656 | + | let done = if r.name.starts_with("tags/") { |
|
| 657 | + | "Withdrew" |
|
| 658 | + | } else { |
|
| 659 | + | "Deleted" |
|
| 660 | + | }; |
|
| 645 | 661 | println!( |
|
| 646 | 662 | "{} {}", |
|
| 647 | - | ui::done("Deleted"), |
|
| 663 | + | ui::done(done), |
|
| 648 | 664 | ui::id(format!("{remote}/{}", r.name)) |
|
| 649 | 665 | ); |
|
| 650 | 666 | } else { |
|
| … | 519 unchanged lines |
modified src/cli/workspace.rs +21 −0
| … | 2226 unchanged lines |
||
| 2227 | 2227 | Ok(0) |
|
| 2228 | 2228 | } |
|
| 2229 | 2229 | ||
| 2230 | + | pub fn tag_delete(name: &str, reason: &str) -> Result<i32> { |
|
| 2231 | + | let repo = open()?; |
|
| 2232 | + | let t = repo.tag_delete(name, reason)?; |
|
| 2233 | + | println!( |
|
| 2234 | + | "{} {} {} {}{}", |
|
| 2235 | + | ui::done("Withdrew"), |
|
| 2236 | + | ui::bookmark(&t.name), |
|
| 2237 | + | ui::dim(ui::glyph("→", "->")), |
|
| 2238 | + | ui::hash(format!("#{}", t.state.short(12))), |
|
| 2239 | + | t.signer |
|
| 2240 | + | .map(|s| format!(", signed by {s}")) |
|
| 2241 | + | .unwrap_or_else(|| ui::dim(", unsigned")) |
|
| 2242 | + | ); |
|
| 2243 | + | println!( |
|
| 2244 | + | " {} {}", |
|
| 2245 | + | ui::note(), |
|
| 2246 | + | ui::dim("`oxy push` sends the withdrawal to a remote that has the tag; the name is not used again") |
|
| 2247 | + | ); |
|
| 2248 | + | Ok(0) |
|
| 2249 | + | } |
|
| 2250 | + | ||
| 2230 | 2251 | pub fn tag_list(as_json: bool) -> Result<i32> { |
|
| 2231 | 2252 | let repo = open()?; |
|
| 2232 | 2253 | let tags = repo.tags()?; |
|
| … | 722 unchanged lines |