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 |