OXYGENHUB

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