OXYGENHUB

Old clients are not sent tags only a certificate authorizes (closes #131) ee130dd9eef2

oxy 0.5.0 and older authorize a tag only by a key the policy lists, a
delegate or a rotation predecessor, and fail the whole clone on any other,
so a release tag made by a keyless agent's certified key (#129) broke every
released client's clone of a repository with a policy.

Clients now send User-Agent: oxy/<version> and Oxy-Features:
certified-tags. Repo::refs_for_client leaves out of a refs answer, under a
policy, every tag an undeclaring client would refuse; oxy-server's new
Backend::adjust_refs hook (given the request headers, after add_log)
applies it by default, so oxy serve and every embedding host do. Documented
under Client capabilities in docs/protocol.md.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key d786a28aac3b
When
· 2026-10-20 15:36 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
ee130dd9eef23d3479ff18c9c91ba1f7055c31aa59440ab1d568fce21672d148
Depends on
132b884b9a8d 14edbaaf5291 1ec6fdbc6bcb 4b9d06778581 5b9c6ad45f6e 67a4fe1aa2d5 68a924846379 750251b21a96 75167672ebcd 8fd78eb52ad2 982864068a59 d82e44097323 e02bf0cd0533 e4595038502a e5d9b0513f3e eae595568832 ee06a6c557b7 f8c695764c95 ff1f62fee654
Depended on by
642fa213de80 d3b270909c78 b63fc7a77fc6 82202a4d0025
In bookmarks
identical-sides main push-notes subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
19 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **oxy 0.5.0 and older can clone a repository again after a keyless agent tagged it (#131).** Since #129 a release tag may be signed by a ten-minute certified key, which released clients do not know how to authorize: they refused the tag (`key … may not create tags/v0.5.0 under this repository's policy`), and with it the whole clone, fetch or `oxy submodule update`. Clients now send `User-Agent: oxy/<version>` and declare what they understand in `Oxy-Features` (`certified-tags` so far; docs/protocol.md, *Client capabilities*). Under a policy, a server leaves out of its refs answer to a client that does not declare `certified-tags` every tag that only a certificate authorizes, so that client clones the rest as if the tag did not exist. The rule is `Repo::refs_for_client` in oxy-core; `oxy-server` gains a `Backend::adjust_refs` hook, given the request headers, whose default applies it, so `oxy serve` and every embedding host do.
5 6
- **Keyless agents can create release tags (#129).** `oxy tag create` and `oxy push` under a keyless home was refused "unknown key": the push did not carry the tag signer's identity and certificate, and tags were authorized only by keys the policy names, never by a certificate under a subject grant. The push now carries them, and a receiver authorizes a short-lived key's tag by a subject grant at its own clock, as it does a bookmark move, and records when it accepted it (OxygenHub signs that with its key). A fetch checks such a tag at that acceptance, or at its integrated time when the hub's log gives one, so a release tag made with a ten-minute key is still fetched months later. A fetch never checks it at the time the tag claims: without an acceptance the tag is left out with a warning (`tags/NAME not updated`), and the rest is fetched. A client keeps the acceptance and serves and pushes it with the tag, so a tag passed on to another remote, or cloned from a clone, keeps its time. The wire change is additive: tags in `refs` and `push` gain keys 4 to 6 (acceptance, log place, cosigned checkpoints), which older readers ignore.
6 7
7 8
## 0.5.0 (2026-10-20)
…
250 unchanged lines
modified crates/oxy-core/src/protocol.rs +53 −1
…
346 unchanged lines
347 347
/// The id of a hosted repository that has not adopted one yet.
348 348
pub const UNBOUND_ID: [u8; 16] = [0; 16];
349 349
350 +
/// The request header in which a client declares the protocol features it
351 +
/// understands beyond the 0.5.0-era set: a comma-separated list, which a
352 +
/// server may tailor its answers to (docs/protocol.md, *Client
353 +
/// capabilities*). A request without it is a client of that era.
354 +
pub const FEATURES_HEADER: &str = "Oxy-Features";
355 +
356 +
/// The feature of a client that authorizes a tag signed by a short-lived,
357 +
/// certified key by its certificate at the time the host accepted it
358 +
/// (oxygen#129). A client without it refuses such a tag, and with it the
359 +
/// whole fetch, so servers leave those tags out of its `refs` (oxygen#131).
360 +
pub const FEATURE_CERTIFIED_TAGS: &str = "certified-tags";
361 +
362 +
/// The features this client declares in [`FEATURES_HEADER`].
363 +
pub const CLIENT_FEATURES: &[&str] = &[FEATURE_CERTIFIED_TAGS];
364 +
365 +
/// The `User-Agent` every request of this client sends: `oxy/<version>`.
366 +
pub fn user_agent() -> String {
367 +
    format!("oxy/{}", env!("CARGO_PKG_VERSION"))
368 +
}
369 +
370 +
/// Whether a [`FEATURES_HEADER`] value (absent: `None`) declares `feature`.
371 +
/// Entries are comma-separated, compared without surrounding blanks and
372 +
/// regardless of case; unknown ones are ignored.
373 +
pub fn declares_feature(header: Option<&str>, feature: &str) -> bool {
374 +
    header.is_some_and(|h| h.split(',').any(|f| f.trim().eq_ignore_ascii_case(feature)))
375 +
}
376 +
350 377
/// A remote served by `oxy-server` at `<base>/oxy/v1/...`.
351 378
pub struct HttpRemote {
352 379
    base: String,
…
41 unchanged lines
394 421
        refuse_plain_token(base, options)?;
395 422
        let agent = ureq::AgentBuilder::new()
396 423
            .tls_config(tls_client_config(options.ca.as_deref())?)
424 +
            .user_agent(&user_agent())
397 425
            .build();
398 426
        Ok(HttpRemote {
399 427
            base: base.trim_end_matches('/').to_string(),
…
43 unchanged lines
443 471
        } else {
444 472
            self.agent.get(&url)
445 473
        };
446 -
        req = req.set("Accept", CONTENT_TYPE);
474 +
        req = req
475 +
            .set("Accept", CONTENT_TYPE)
476 +
            .set(FEATURES_HEADER, &CLIENT_FEATURES.join(","));
447 477
        if let Some(t) = token {
448 478
            req = req.set("Authorization", &format!("Bearer {t}"));
449 479
        }
…
101 unchanged lines
551 581
    let agent = ureq::AgentBuilder::new()
552 582
        .tls_config(tls_client_config(options.ca.as_deref())?)
553 583
        .timeout(std::time::Duration::from_secs(60))
584 +
        .user_agent(&user_agent())
554 585
        .build();
555 586
    let mut req = agent.request(method, url).set("Accept", "application/json");
556 587
    if let Some(t) = &options.token {
…
19 unchanged lines
576 607
    refuse_plain_token(url, options)?;
577 608
    let agent = ureq::AgentBuilder::new()
578 609
        .tls_config(tls_client_config(options.ca.as_deref())?)
610 +
        .user_agent(&user_agent())
579 611
        .build();
580 612
    let mut req = agent.get(url).set("Accept", "application/json");
581 613
    if let Some(t) = &options.token {
…
810 unchanged lines
1392 1424
    use super::*;
1393 1425
    use crate::attest::AttestBody;
1394 1426
    use crate::objects::Hlc;
1427 +
1428 +
    #[test]
1429 +
    fn client_features_are_declared_and_read() {
1430 +
        assert_eq!(CLIENT_FEATURES.join(","), "certified-tags");
1431 +
        assert!(user_agent().starts_with("oxy/"));
1432 +
        assert!(declares_feature(
1433 +
            Some("certified-tags"),
1434 +
            FEATURE_CERTIFIED_TAGS
1435 +
        ));
1436 +
        assert!(declares_feature(
1437 +
            Some("later-thing, Certified-Tags ,x"),
1438 +
            FEATURE_CERTIFIED_TAGS
1439 +
        ));
1440 +
        assert!(!declares_feature(
1441 +
            Some("certified-tags-v2"),
1442 +
            FEATURE_CERTIFIED_TAGS
1443 +
        ));
1444 +
        assert!(!declares_feature(Some(""), FEATURE_CERTIFIED_TAGS));
1445 +
        assert!(!declares_feature(None, FEATURE_CERTIFIED_TAGS));
1446 +
    }
1395 1447
1396 1448
    #[test]
1397 1449
    fn wire_round_trips() {
…
276 unchanged lines
modified crates/oxy-core/src/repo/tags.rs +46 −5
…
219 unchanged lines
220 220
        Ok(first)
221 221
    }
222 222
223 +
    /// Whether policy `p` authorizes the signer of tag `name`'s attestation
224 +
    /// `sa` without a certificate: a key its rule lists, a delegate of one,
225 +
    /// or a key one of them was rotated from (spec §14.5, §14.2). These are
226 +
    /// the only ways a client before oxygen#129 authorizes a tag.
227 +
    fn tag_signer_listed(&self, p: &crate::policy::Policy, name: &str, sa: &SignedAttest) -> bool {
228 +
        let Some(sig) = sa.sigs.first() else {
229 +
            return false;
230 +
        };
231 +
        let reference = format!("tags/{name}");
232 +
        let at = sa.attest.hlc.wall_ms;
233 +
        p.authorizes(&sig.key, &reference)
234 +
            || self.delegate_authorized(p, &sig.key, &reference, at)
235 +
            || self.predecessor_authorized(p, &sig.key, &reference, at)
236 +
    }
237 +
238 +
    /// Tailor a `refs` answer of this repository to a client that declared
239 +
    /// `features` (the [`FEATURES_HEADER`] value; `None` when it sent none).
240 +
    /// A client without [`FEATURE_CERTIFIED_TAGS`] (oxy 0.5.0 and older)
241 +
    /// refuses a tag signed by a short-lived key that only its certificate
242 +
    /// authorizes, and with it the whole clone or fetch (oxygen#131), so
243 +
    /// under a policy each tag whose signer the policy does not list, as a
244 +
    /// key, a delegate or a rotation predecessor, is left out: that client
245 +
    /// sees the repository as if the tag did not exist. A tag with no
246 +
    /// signed attestation is left as it is (every client refuses it alike),
247 +
    /// as is everything without a policy.
248 +
    ///
249 +
    /// [`FEATURES_HEADER`]: crate::protocol::FEATURES_HEADER
250 +
    /// [`FEATURE_CERTIFIED_TAGS`]: crate::protocol::FEATURE_CERTIFIED_TAGS
251 +
    pub fn refs_for_client(
252 +
        &self,
253 +
        refs: &mut crate::protocol::RefsResponse,
254 +
        features: Option<&str>,
255 +
    ) {
256 +
        if crate::protocol::declares_feature(features, crate::protocol::FEATURE_CERTIFIED_TAGS) {
257 +
            return;
258 +
        }
259 +
        let Some(sp) = refs.policy.as_ref() else {
260 +
            return;
261 +
        };
262 +
        refs.tags.retain(|t| match &t.attestation {
263 +
            Some(sa) if !sa.sigs.is_empty() => self.tag_signer_listed(&sp.policy, &t.name, sa),
264 +
            _ => true,
265 +
        });
266 +
    }
267 +
223 268
    /// Adopt a tag received from a remote or a push. Verifies the
224 269
    /// attestation when present (and requires one, by an authorized key,
225 270
    /// under a policy). An existing tag with a different state is refused.
…
60 unchanged lines
286 331
            // The issuer's acceptance this tag came with, when it is one.
287 332
            let accepted = self.issuer_acceptance(&sig.key, sa, t.accepted.as_ref());
288 333
            if let Some(p) = policy {
289 -
                let reference = format!("tags/{}", t.name);
290 -
                let at = sa.attest.hlc.wall_ms;
291 -
                let listed = p.authorizes(&sig.key, &reference)
292 -
                    || self.delegate_authorized(p, &sig.key, &reference, at)
293 -
                    || self.predecessor_authorized(p, &sig.key, &reference, at);
334 +
                let listed = self.tag_signer_listed(p, &t.name, sa);
294 335
                if !listed
295 336
                    && !(certified
296 337
                        && self.tag_certified_in_time(p, t, sa, &sig.key, accepted, check)?)
…
99 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +5 −0
…
3051 unchanged lines
3052 3052
    assert_eq!(a.resolve_heads("main").unwrap(), vec![h2]);
3053 3053
    let (_, sa) = a.tag_attest("v1").unwrap().unwrap();
3054 3054
    assert_eq!(sa.sigs[0].key, key_a);
3055 +
    // Without a policy every client is sent every tag, whatever features
3056 +
    // it declares (oxygen#131).
3057 +
    let mut refs = a.remote_refs().unwrap();
3058 +
    a.refs_for_client(&mut refs, None);
3059 +
    assert_eq!(refs.tags.len(), 1);
3055 3060
    // Checkout by tag.
3056 3061
    a.checkout(&a.resolve_heads("v1").unwrap(), false, "co")
3057 3062
        .unwrap();
…
6808 unchanged lines
modified crates/oxy-server/src/lib.rs +31 −0
…
89 unchanged lines
90 90
    /// without a log leaves it as it is.
91 91
    fn add_log(&self, _name: &str, _refs: &mut oxy_core::protocol::RefsResponse) {}
92 92
93 +
    /// Tailor a refs answer to the client asking, by its request headers
94 +
    /// (its `User-Agent` and the features it declares in `Oxy-Features`),
95 +
    /// after [`Backend::add_log`]. The default leaves out the tags a client
96 +
    /// that does not declare `certified-tags` would refuse
97 +
    /// ([`Repo::refs_for_client`], oxygen#131); a host that overrides it
98 +
    /// should apply that too ([`tailor_refs`]).
99 +
    fn adjust_refs(
100 +
        &self,
101 +
        _name: &str,
102 +
        repo: &Repo,
103 +
        refs: &mut oxy_core::protocol::RefsResponse,
104 +
        headers: &HeaderMap,
105 +
    ) {
106 +
        tailor_refs(repo, refs, headers);
107 +
    }
108 +
93 109
    /// The bookmark of `name` that pushes may never delete (its default),
94 110
    /// when the host keeps one apart from the repository's own config.
95 111
    fn default_bookmark(&self, _name: &str) -> Option<String> {
…
182 unchanged lines
278 294
                    refs.default_bookmark = Some(d);
279 295
                }
280 296
                self.backend.add_log(name, &mut refs);
297 +
                self.backend.adjust_refs(name, &r, &mut refs, headers);
281 298
                ok(wire::encode_refs(r.id()?, &refs))
282 299
            }
283 300
            Endpoint::Negotiate => {
…
129 unchanged lines
413 430
        *byte = u8::from_str_radix(&h[2 * i..2 * i + 2], 16).ok()?;
414 431
    }
415 432
    Some(out)
433 +
}
434 +
435 +
/// The features a request declares in its `Oxy-Features` header, as the
436 +
/// header's text; `None` when it sent none (a client of the 0.5.0 era).
437 +
pub fn client_features(headers: &HeaderMap) -> Option<&str> {
438 +
    headers
439 +
        .get(oxy_core::protocol::FEATURES_HEADER)
440 +
        .and_then(|v| v.to_str().ok())
441 +
}
442 +
443 +
/// Tailor `refs` of `repo` to the client whose request carried `headers`:
444 +
/// the shared rule of [`Repo::refs_for_client`], which every host applies.
445 +
pub fn tailor_refs(repo: &Repo, refs: &mut oxy_core::protocol::RefsResponse, headers: &HeaderMap) {
446 +
    repo.refs_for_client(refs, client_features(headers));
416 447
}
417 448
418 449
/// The token of an `Authorization: Bearer` header, if any.
…
61 unchanged lines
modified crates/oxy-server/tests/http.rs +201 −0
…
884 unchanged lines
885 885
        1
886 886
    );
887 887
}
888 +
889 +
/// oxygen#131: oxy 0.5.0 and older authorize a tag only by a key the
890 +
/// policy lists (or its delegate or predecessor) and fail the whole clone
891 +
/// on any other, so a client that does not declare `certified-tags` is
892 +
/// never sent a tag that only a short-lived key's certificate authorizes.
893 +
/// It clones the rest without error; a current client still gets the tag;
894 +
/// and an old client that holds such a tag pushes without trouble.
895 +
#[test]
896 +
fn a_client_without_certified_tags_never_sees_a_tag_only_a_certificate_authorizes() {
897 +
    use axum::extract::{Path as AxPath, State};
898 +
    use axum::http::{HeaderMap, Method, StatusCode};
899 +
    use axum::response::{IntoResponse, Response};
900 +
    use oxy_core::cert::{Certificate, SignedCertificate};
901 +
    use oxy_core::identity::{Identity, Scope, SecretKey};
902 +
    use oxy_core::protocol::{wire, RemoteOptions, FEATURES_HEADER};
903 +
    use oxy_core::repo::IdentityScope;
904 +
    use oxy_server::{Access, Backend, Endpoint, Failure, Server};
905 +
    use std::sync::Arc;
906 +
907 +
    // A host that countersigns when it accepted a certified key's tag, as
908 +
    // a hub does.
909 +
    struct Host {
910 +
        root: std::path::PathBuf,
911 +
        acceptor: (Identity, SecretKey),
912 +
    }
913 +
    impl Backend for Host {
914 +
        fn open(&self, name: &str) -> Result<Repo, Failure> {
915 +
            Repo::open(&self.root.join(name))
916 +
                .map(|r| r.with_acceptor(self.acceptor.0.clone(), self.acceptor.1.clone()))
917 +
                .map_err(|e| Failure(StatusCode::NOT_FOUND, e.to_string()))
918 +
        }
919 +
        fn authorize(&self, _: &str, _: Access, _: &HeaderMap) -> Result<(), Failure> {
920 +
            Ok(())
921 +
        }
922 +
    }
923 +
    // `/new/...` as clients send it; `/old/...` without the features
924 +
    // header, as oxy 0.5.0 sends it.
925 +
    async fn route(
926 +
        State(server): State<Arc<Server>>,
927 +
        AxPath((era, repo, endpoint)): AxPath<(String, String, String)>,
928 +
        method: Method,
929 +
        mut headers: HeaderMap,
930 +
        body: axum::body::Bytes,
931 +
    ) -> Response {
932 +
        if era == "old" {
933 +
            headers.remove(FEATURES_HEADER);
934 +
        }
935 +
        match Endpoint::parse(&endpoint) {
936 +
            Some(e) => server.handle(repo, e, &method, &headers, body).await,
937 +
            None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(),
938 +
        }
939 +
    }
940 +
941 +
    hermetic();
942 +
    let dir = tempfile::tempdir().unwrap();
943 +
    let root = dir.path();
944 +
    let served = root.join("served");
945 +
    Repo::init(&served.join("origin")).unwrap();
946 +
    let hub_sk = SecretKey::generate();
947 +
    let hub = hub_sk.identity("hub", "[email protected]", 1);
948 +
    let server = Server::new(Host {
949 +
        root: served.clone(),
950 +
        acceptor: (hub.clone(), hub_sk.clone()),
951 +
    });
952 +
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
953 +
    let port = listener.local_addr().unwrap().port();
954 +
    std::thread::spawn(move || {
955 +
        let rt = tokio::runtime::Builder::new_multi_thread()
956 +
            .enable_all()
957 +
            .build()
958 +
            .unwrap();
959 +
        rt.block_on(async move {
960 +
            listener.set_nonblocking(true).unwrap();
961 +
            let app = axum::Router::new()
962 +
                .route("/{era}/{repo}/oxy/v1/{endpoint}", axum::routing::any(route))
963 +
                .with_state(server);
964 +
            axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app)
965 +
                .await
966 +
                .unwrap();
967 +
        });
968 +
    });
969 +
    std::thread::sleep(std::time::Duration::from_millis(50));
970 +
    let new_url = format!("http://127.0.0.1:{port}/new/origin");
971 +
    let old_url = format!("http://127.0.0.1:{port}/old/origin");
972 +
973 +
    // Ada's policy grants the hub's certified agents; her own tag v0 is
974 +
    // by a key the policy lists.
975 +
    let mut a = Repo::clone(&new_url, &root.join("a"))
976 +
        .unwrap()
977 +
        .with_home(root.join("home-a"));
978 +
    a.create_identity("Ada", "[email protected]", IdentityScope::User)
979 +
        .unwrap();
980 +
    write(&root.join("a"), "a.txt", b"a\n");
981 +
    a.commit("a", &[]).unwrap();
982 +
    a.policy_init().unwrap();
983 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
984 +
    a.policy_grant_subject(
985 +
        "agent:claude@ada",
986 +
        hub.clone(),
987 +
        Scope::CHANGES | Scope::REFS,
988 +
        Some("*"),
989 +
    )
990 +
    .unwrap();
991 +
    let heads = a.bookmark_heads().unwrap();
992 +
    a.tag_create("v0", &heads, "zero").unwrap();
993 +
    let r = a.push("origin", false).unwrap();
994 +
    assert!(r.iter().all(|x| x.ok), "{r:?}");
995 +
996 +
    // A keyless agent's tag v1, by a key the hub certified for ten minutes.
997 +
    let now = oxy_core::objects::Hlc::now().wall_ms;
998 +
    let mut c = Repo::clone(&new_url, &root.join("claude"))
999 +
        .unwrap()
1000 +
        .with_home(root.join("home-claude"));
1001 +
    c.store().put_identity(&hub).unwrap();
1002 +
    let (_, id) = c
1003 +
        .create_identity("claude", "[email protected]", IdentityScope::User)
1004 +
        .unwrap();
1005 +
    let cert = SignedCertificate::sign(
1006 +
        Certificate {
1007 +
            key: id,
1008 +
            issuer: hub.hash(),
1009 +
            subject: "agent:claude@ada".into(),
1010 +
            claims: vec![],
1011 +
            scope: Scope::CHANGES | Scope::REFS,
1012 +
            not_before: now - 60_000,
1013 +
            expires: now + 600_000,
1014 +
            grant: None,
1015 +
        },
1016 +
        &hub,
1017 +
        &hub_sk,
1018 +
    );
1019 +
    c.store().put_certificate(&cert).unwrap();
1020 +
    c.tag_create("v1", &heads, "one").unwrap();
1021 +
    let r = c.push("origin", false).unwrap();
1022 +
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1023 +
1024 +
    // The refs answer: without the header, v1 is left out; with it, there.
1025 +
    let tags = |bytes: Vec<u8>| -> Vec<String> {
1026 +
        let (_, refs) = wire::decode_refs(&bytes).unwrap();
1027 +
        refs.tags.into_iter().map(|t| t.name).collect()
1028 +
    };
1029 +
    let bare =
1030 +
        oxy_core::protocol::http_get(&format!("{new_url}/oxy/v1/refs"), &RemoteOptions::default())
1031 +
            .unwrap();
1032 +
    assert_eq!(tags(bare), ["v0"], "no Oxy-Features header");
1033 +
    let remote = oxy_core::protocol::open_remote(&new_url, &RemoteOptions::default()).unwrap();
1034 +
    let mut current: Vec<String> = remote
1035 +
        .refs()
1036 +
        .unwrap()
1037 +
        .tags
1038 +
        .into_iter()
1039 +
        .map(|t| t.name)
1040 +
        .collect();
1041 +
    current.sort();
1042 +
    assert_eq!(
1043 +
        current,
1044 +
        ["v0", "v1"],
1045 +
        "a current client declares certified-tags"
1046 +
    );
1047 +
1048 +
    // An old-era client clones without error and sees no v1.
1049 +
    let old = Repo::clone(&old_url, &root.join("old")).unwrap();
1050 +
    assert_eq!(old.tag_heads("v0").unwrap(), Some(heads.clone()));
1051 +
    assert_eq!(old.tag_heads("v1").unwrap(), None);
1052 +
    let rep = old.fetch("origin").unwrap();
1053 +
    assert!(rep.skipped.is_empty(), "{rep:?}");
1054 +
1055 +
    // A current client adopts v1 by its certificate at the hub's acceptance.
1056 +
    let b = Repo::clone(&new_url, &root.join("b")).unwrap();
1057 +
    assert_eq!(b.tag_heads("v1").unwrap(), Some(heads.clone()));
1058 +
1059 +
    // A client holding v1 that pushes where v1 is hidden from it sends v1
1060 +
    // again: the host already has it, so it is taken as it is, and the
1061 +
    // bookmark moves.
1062 +
    let r = a.fetch("origin").unwrap();
1063 +
    assert!(r.skipped.is_empty(), "{r:?}");
1064 +
    assert_eq!(a.tag_heads("v1").unwrap(), Some(heads.clone()));
1065 +
    a.config.set("remote.origin.url", &old_url).unwrap();
1066 +
    write(&root.join("a"), "b.txt", b"b\n");
1067 +
    a.commit("b", &[]).unwrap();
1068 +
    let r = a.push("origin", false).unwrap();
1069 +
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1070 +
    assert!(r.iter().any(|x| x.name == "tags/v1"), "{r:?}");
1071 +
1072 +
    // `oxy serve` applies the same rule.
1073 +
    let base = start(&served, vec![]);
1074 +
    let bare = oxy_core::protocol::http_get(
1075 +
        &format!("{base}/origin/oxy/v1/refs"),
1076 +
        &RemoteOptions::default(),
1077 +
    )
1078 +
    .unwrap();
1079 +
    assert_eq!(tags(bare), ["v0"], "oxy serve, no header");
1080 +
    let remote =
1081 +
        oxy_core::protocol::open_remote(&format!("{base}/origin"), &RemoteOptions::default())
1082 +
            .unwrap();
1083 +
    assert_eq!(
1084 +
        remote.refs().unwrap().tags.len(),
1085 +
        2,
1086 +
        "oxy serve, current client"
1087 +
    );
1088 +
}
modified docs/protocol.md +22 −1
…
68 unchanged lines
69 69
  with `400`. Clients send the header on every request when they have an
70 70
  id; servers ignore it on bound repositories.
71 71
72 +
Client capabilities: the reference client sends `User-Agent:
73 +
oxy/<version>` and `Oxy-Features: <feature>[,<feature>…]` on every request,
74 +
a comma-separated list (blanks around entries and case are ignored, unknown
75 +
entries too) of what it understands beyond the oxy 0.5.0 feature set.
76 +
Servers may tailor their answers to the features a request declares, and a
77 +
request without the header is a client of that era. Features so far:
78 +
79 +
- `certified-tags`: the client authorizes a tag signed by a short-lived,
80 +
  certified key by a subject grant, its certificate checked at the time
81 +
  the host accepted the tag (spec §14.7; oxygen#129). A client without it
82 +
  authorizes a tag only by a key the policy's rule lists, a delegate of one
83 +
  or a rotation predecessor, and fails the whole clone or fetch on any
84 +
  other. So under a policy, a server leaves out of a `refs` answer to such
85 +
  a client every tag whose attestation's signer none of those authorizes
86 +
  (`Repo::refs_for_client`, applied by `oxy serve` and by default by every
87 +
  `oxy_server` host): that client sees the repository as if those tags did
88 +
  not exist. If it holds one of them already and pushes, it sends the tag
89 +
  again (it is not in the answer); the server already has it in the same
90 +
  state and takes it as it is.
91 +
72 92
Embedding: `oxy_server::Server` answers any endpoint for a repository name
73 93
a host's `Backend` resolves and authorises (`open`, `authorize`, and an
74 -
`after_push` hook, and `vet_push`, with which a host refuses a push by
94 +
`after_push` hook, `adjust_refs`, which tailors a refs answer to the
95 +
request's headers and by default applies the rule above, and `vet_push`, with which a host refuses a push by
75 96
what it adds before anything is stored: an HTTP error, `422` say, whose
76 97
text, several lines perhaps, the client shows as it is; spec §16.4), so a hub with `owner/name` paths and its own accounts
77 98
routes `/{owner}/{name}/oxy/v1/{endpoint}` to `Server::handle`; `oxy serve`
…
130 unchanged lines