oxy-server: a host may refuse a push by what it adds (Backend::vet_push) 67a4fe1aa2d5
A new Backend method, vet_push(name, repo, bundle, updates, headers), called in the push handler once the push is decoded and before the push lock, the binding of an unbound repository, or anything is stored. The default accepts everything at no cost. A host returns a Failure to refuse the whole push (or the whole part of a push in parts); its text reaches the client as the push's error, every line of it. OxygenHub uses it for push protection (secret scanning). The client now takes a failed request's hint from its status alone, so a host's reason is shown as it is and never read for hints. Test: a backend that refuses a push carrying a given text; the client sees the reason, nothing is stored, bound or moved, and the push without it then lands. Spec §16.4 and docs/protocol.md say a host may refuse a push by its content (an HTTP error, 422 say, not 401/403/404). Refs andymccall/oxygenhub#268
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 09af59508e90
- When
- · 2026-10-19 20:39 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
67a4fe1aa2d5a0bfe41169218b33b279738b31d3737fe648168d7930da7b05b0- Depends on
- 3e5772ccf9da 4b9d06778581 5b9c6ad45f6e 68a924846379 750251b21a96 8c30d814f2cc cb8b8082fa73 e02bf0cd0533 ee06a6c557b7 f8c695764c95 ff0af1ee299d
- Depended on by
- 642fa213de80 d3b270909c78 ee130dd9eef2
- In bookmarks
- identical-sides keyless-tags main push-notes subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 9 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/oxy-core/src/protocol.rs +9 −2
| … | 417 unchanged lines |
||
| 418 | 418 | } |
|
| 419 | 419 | Err(ureq::Error::Status(code, r)) => { |
|
| 420 | 420 | let text = r.into_string().unwrap_or_default(); |
|
| 421 | - | let msg = format!("{url}: HTTP {code}: {}", text.trim()); |
|
| 422 | - | Err(RepoError::Msg(with_hint(msg))) |
|
| 421 | + | // The hint comes from the status alone: the server's text |
|
| 422 | + | // (a host's reason for refusing a push, several lines |
|
| 423 | + | // perhaps) is shown as it is, never read for hints. |
|
| 424 | + | let status = format!("{url}: HTTP {code}"); |
|
| 425 | + | let msg = format!("{status}: {}", text.trim()); |
|
| 426 | + | Err(RepoError::Msg(match hint_for(&status) { |
|
| 427 | + | Some(h) => format!("{msg}\n hint: {h}"), |
|
| 428 | + | None => msg, |
|
| 429 | + | })) |
|
| 423 | 430 | } |
|
| 424 | 431 | // The transport error already names the URL. |
|
| 425 | 432 | Err(e) => Err(RepoError::Msg(with_hint(e.to_string()))), |
|
| … | 1018 unchanged lines |
modified crates/oxy-server/src/lib.rs +25 −0
| … | 63 unchanged lines |
||
| 64 | 64 | /// between, and the changes stored, for logs and cache invalidation. |
|
| 65 | 65 | fn after_push(&self, _name: &str, _report: &oxy_core::repo::PushReport, _headers: &HeaderMap) {} |
|
| 66 | 66 | ||
| 67 | + | /// Vet a push by what it adds before anything of it is stored or any |
|
| 68 | + | /// reference moves (spec §16.1): `bundle` is the push as decoded, its |
|
| 69 | + | /// changes with their lines, `updates` the references it would move. |
|
| 70 | + | /// A host refuses the whole push by returning a [`Failure`], whose |
|
| 71 | + | /// text reaches the client as the push's error, as it is, several |
|
| 72 | + | /// lines included; it should answer neither 401, 403 nor 404, which a |
|
| 73 | + | /// client takes for a credential it should change. Called for every |
|
| 74 | + | /// part of a push sent in parts (each part's changes are its own), |
|
| 75 | + | /// outside the repository's push lock, so a slow vet holds up no |
|
| 76 | + | /// other push. The default accepts everything at no cost. |
|
| 77 | + | fn vet_push( |
|
| 78 | + | &self, |
|
| 79 | + | _name: &str, |
|
| 80 | + | _repo: &Repo, |
|
| 81 | + | _bundle: &oxy_core::protocol::Bundle, |
|
| 82 | + | _updates: &[oxy_core::protocol::RefUpdate], |
|
| 83 | + | _headers: &HeaderMap, |
|
| 84 | + | ) -> Result<(), Failure> { |
|
| 85 | + | Ok(()) |
|
| 86 | + | } |
|
| 87 | + | ||
| 67 | 88 | /// Add the host's transparency log to a refs answer (spec §14.8): its |
|
| 68 | 89 | /// checkpoint, and each attestation's entry and inclusion proof. A host |
|
| 69 | 90 | /// without a log leaves it as it is. |
|
| … | 219 unchanged lines |
||
| 289 | 310 | } |
|
| 290 | 311 | Endpoint::Push => { |
|
| 291 | 312 | let (bundle, updates, policy, tags) = wire::decode_push(&body)?; |
|
| 313 | + | // The host vets what the push adds before anything is stored |
|
| 314 | + | // or bound: a refused push leaves the repository as it was. |
|
| 315 | + | self.backend |
|
| 316 | + | .vet_push(name, &r, &bundle, &updates, headers)?; |
|
| 292 | 317 | let lock = self.lock_for(name); |
|
| 293 | 318 | let _guard = lock.lock().unwrap(); |
|
| 294 | 319 | if r.is_unbound() { |
|
| … | 160 unchanged lines |
modified crates/oxy-server/tests/http.rs +135 −0
| … | 749 unchanged lines |
||
| 750 | 750 | let err = child.push("origin", false).err().unwrap().to_string(); |
|
| 751 | 751 | assert!(err.contains("401"), "{err}"); |
|
| 752 | 752 | } |
|
| 753 | + | ||
| 754 | + | /// A host refuses a push by what it adds (`Backend::vet_push`): the client |
|
| 755 | + | /// sees the host's reason, every line of it, and nothing is stored, bound |
|
| 756 | + | /// or moved; a push without that content then lands. |
|
| 757 | + | #[test] |
|
| 758 | + | fn a_host_refuses_a_push_by_its_content() { |
|
| 759 | + | use axum::extract::{Path as AxPath, State}; |
|
| 760 | + | use axum::http::{HeaderMap, Method, StatusCode}; |
|
| 761 | + | use axum::response::Response; |
|
| 762 | + | use oxy_core::objects::Edit; |
|
| 763 | + | use oxy_core::protocol::{Bundle, RefUpdate}; |
|
| 764 | + | use oxy_server::{Access, Backend, DirBackend, Endpoint, Failure, Server}; |
|
| 765 | + | use std::sync::Arc; |
|
| 766 | + | ||
| 767 | + | struct Vetting(DirBackend); |
|
| 768 | + | impl Backend for Vetting { |
|
| 769 | + | fn open(&self, name: &str) -> Result<Repo, Failure> { |
|
| 770 | + | self.0.open(name) |
|
| 771 | + | } |
|
| 772 | + | fn authorize( |
|
| 773 | + | &self, |
|
| 774 | + | name: &str, |
|
| 775 | + | access: Access, |
|
| 776 | + | headers: &HeaderMap, |
|
| 777 | + | ) -> Result<(), Failure> { |
|
| 778 | + | self.0.authorize(name, access, headers) |
|
| 779 | + | } |
|
| 780 | + | fn vet_push( |
|
| 781 | + | &self, |
|
| 782 | + | _name: &str, |
|
| 783 | + | _repo: &Repo, |
|
| 784 | + | bundle: &Bundle, |
|
| 785 | + | updates: &[RefUpdate], |
|
| 786 | + | _headers: &HeaderMap, |
|
| 787 | + | ) -> Result<(), Failure> { |
|
| 788 | + | let adds_it = bundle.changes.iter().any(|(c, _)| { |
|
| 789 | + | c.edits.iter().any(|e| match e { |
|
| 790 | + | Edit::Insert { lines, .. } => lines |
|
| 791 | + | .iter() |
|
| 792 | + | .any(|l| l.windows(11).any(|w| w == b"DO NOT PUSH")), |
|
| 793 | + | _ => false, |
|
| 794 | + | }) |
|
| 795 | + | }); |
|
| 796 | + | if adds_it { |
|
| 797 | + | return Err(Failure( |
|
| 798 | + | StatusCode::UNPROCESSABLE_ENTITY, |
|
| 799 | + | format!( |
|
| 800 | + | "refused: a change adds DO NOT PUSH\n a.txt:2\nremove it and push {} again", |
|
| 801 | + | updates.first().map(|u| u.name.as_str()).unwrap_or("") |
|
| 802 | + | ), |
|
| 803 | + | )); |
|
| 804 | + | } |
|
| 805 | + | Ok(()) |
|
| 806 | + | } |
|
| 807 | + | } |
|
| 808 | + | async fn route( |
|
| 809 | + | State(server): State<Arc<Server>>, |
|
| 810 | + | AxPath((repo, endpoint)): AxPath<(String, String)>, |
|
| 811 | + | method: Method, |
|
| 812 | + | headers: HeaderMap, |
|
| 813 | + | body: axum::body::Bytes, |
|
| 814 | + | ) -> Response { |
|
| 815 | + | let e = Endpoint::parse(&endpoint).unwrap(); |
|
| 816 | + | server.handle(repo, e, &method, &headers, body).await |
|
| 817 | + | } |
|
| 818 | + | ||
| 819 | + | hermetic(); |
|
| 820 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 821 | + | let root = dir.path(); |
|
| 822 | + | let served = root.join("served"); |
|
| 823 | + | let hosted = Repo::init_bare_unbound(&served.join("lib")).unwrap(); |
|
| 824 | + | let server = Server::new(Vetting(DirBackend { |
|
| 825 | + | root: served.clone(), |
|
| 826 | + | tokens: vec![], |
|
| 827 | + | })); |
|
| 828 | + | let listener = TcpListener::bind("127.0.0.1:0").unwrap(); |
|
| 829 | + | let port = listener.local_addr().unwrap().port(); |
|
| 830 | + | std::thread::spawn(move || { |
|
| 831 | + | let rt = tokio::runtime::Builder::new_multi_thread() |
|
| 832 | + | .enable_all() |
|
| 833 | + | .build() |
|
| 834 | + | .unwrap(); |
|
| 835 | + | rt.block_on(async move { |
|
| 836 | + | listener.set_nonblocking(true).unwrap(); |
|
| 837 | + | let app = axum::Router::new() |
|
| 838 | + | .route("/{repo}/oxy/v1/{endpoint}", axum::routing::any(route)) |
|
| 839 | + | .with_state(server); |
|
| 840 | + | axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app) |
|
| 841 | + | .await |
|
| 842 | + | .unwrap(); |
|
| 843 | + | }); |
|
| 844 | + | }); |
|
| 845 | + | std::thread::sleep(std::time::Duration::from_millis(50)); |
|
| 846 | + | let url = format!("http://127.0.0.1:{port}/lib"); |
|
| 847 | + | ||
| 848 | + | let work = root.join("work"); |
|
| 849 | + | let mut local = Repo::init(&work).unwrap(); |
|
| 850 | + | local.config.set("remote.origin.url", &url).unwrap(); |
|
| 851 | + | local.save_config().unwrap(); |
|
| 852 | + | write(&work, "a.txt", b"one\nDO NOT PUSH\n"); |
|
| 853 | + | local.snapshot("test").unwrap(); |
|
| 854 | + | let bad = local.commit("a", &[]).unwrap(); |
|
| 855 | + | let err = local.push("origin", false).err().unwrap().to_string(); |
|
| 856 | + | assert!( |
|
| 857 | + | err.contains("HTTP 422") |
|
| 858 | + | && err.contains( |
|
| 859 | + | "refused: a change adds DO NOT PUSH\n a.txt:2\nremove it and push main again" |
|
| 860 | + | ) |
|
| 861 | + | && !err.contains("hint:"), |
|
| 862 | + | "{err}" |
|
| 863 | + | ); |
|
| 864 | + | let hosted_now = Repo::open(&served.join("lib")).unwrap(); |
|
| 865 | + | assert!(hosted_now.is_unbound(), "a refused push binds nothing"); |
|
| 866 | + | assert!(hosted_now.bookmark_heads().unwrap().is_empty()); |
|
| 867 | + | assert!( |
|
| 868 | + | hosted_now.store().get_change(&bad).is_err(), |
|
| 869 | + | "nothing stored" |
|
| 870 | + | ); |
|
| 871 | + | drop(hosted); |
|
| 872 | + | ||
| 873 | + | // Without it, the push lands. |
|
| 874 | + | write(&work, "a.txt", b"one\n"); |
|
| 875 | + | local.snapshot("test").unwrap(); |
|
| 876 | + | local.amend(None).unwrap(); |
|
| 877 | + | let results = local.push("origin", false).unwrap(); |
|
| 878 | + | assert!(results.iter().all(|r| r.ok), "{results:?}"); |
|
| 879 | + | assert_eq!( |
|
| 880 | + | Repo::open(&served.join("lib")) |
|
| 881 | + | .unwrap() |
|
| 882 | + | .bookmark_heads() |
|
| 883 | + | .unwrap() |
|
| 884 | + | .len(), |
|
| 885 | + | 1 |
|
| 886 | + | ); |
|
| 887 | + | } |
modified docs/protocol.md +6 −2
| … | 51 unchanged lines |
||
| 52 | 52 | when the server was started with tokens; a missing or wrong token is `401`. |
|
| 53 | 53 | - Errors are plain-text bodies with a 4xx or 5xx status; the client shows the |
|
| 54 | 54 | text. A push that is *refused* (policy, compare-and-swap) is a `200` with |
|
| 55 | - | `ok = false` in the push response, not an HTTP error. |
|
| 55 | + | `ok = false` in the push response, not an HTTP error; one a host |
|
| 56 | + | refuses by its content (`vet_push`, spec §16.4) is an HTTP error with |
|
| 57 | + | the host's reason as its text, and nothing of it is stored. |
|
| 56 | 58 | - TLS is ordinary HTTPS. The reference client trusts the system web roots |
|
| 57 | 59 | plus an optional PEM file (`remote.<name>.ca` or `OXY_CA`); servers with |
|
| 58 | 60 | self-signed certificates are expected to hand that file out. |
|
| … | 10 unchanged lines |
||
| 69 | 71 | ||
| 70 | 72 | Embedding: `oxy_server::Server` answers any endpoint for a repository name |
|
| 71 | 73 | a host's `Backend` resolves and authorises (`open`, `authorize`, and an |
|
| 72 | - | `after_push` hook), so a hub with `owner/name` paths and its own accounts |
|
| 74 | + | `after_push` hook, and `vet_push`, with which a host refuses a push by |
|
| 75 | + | what it adds before anything is stored: an HTTP error, `422` say, whose |
|
| 76 | + | text, several lines perhaps, the client shows as it is; spec §16.4), so a hub with `owner/name` paths and its own accounts |
|
| 73 | 77 | routes `/{owner}/{name}/oxy/v1/{endpoint}` to `Server::handle`; `oxy serve` |
|
| 74 | 78 | is `DirBackend` on that same server. |
|
| 75 | 79 | ||
| … | 128 unchanged lines |
modified docs/spec.md +2 −0
| … | 811 unchanged lines |
||
| 812 | 812 | ||
| 813 | 813 | **A push in parts.** A push may carry no reference update and no tag: its changes and objects alone, which the server validates as above and stores, reporting the outcome as one result named `objects` (`ok`, and as its reason how many changes it stored or why it refused them). A client sends a long history this way, in dependency order, in parts of about `push.batch-bytes` (default 256 MiB) of changes and their texts and blobs, and the last part carries the reference updates, so that neither side holds the whole history at once and no request passes the server's body limit. A part already stored is harmless if the push stops, and a server MAY collect its changes under §21 if no reference ever reaches them. |
|
| 814 | 814 | ||
| 815 | + | **Refused by content.** Before any of the checks above, and before a push binds an unbound repository, a host MAY refuse a push by what it adds: the lines its changes insert, the blobs it carries (a forge's secret scanning is one such check). The refusal rejects the whole push, or the whole part of a push in parts, with nothing stored, bound or moved, and is an HTTP error (the reference server's hosts choose `422`, never `401`, `403` or `404`, which a client takes for a credential to change) whose plain-text body is the host's reason, which MAY run to several lines; a client MUST show it as it is. In the reference server this is `Backend::vet_push`, which accepts everything unless a host implements it. |
|
| 816 | + | ||
| 815 | 817 | **Deleting a reference.** An update whose `new_state` is the zero ObjectRef deletes the bookmark (`oxy push REMOTE --delete NAME`). The bookmark MUST exist at `old_state`. A server MUST refuse to delete its default bookmark: in a bare repository that is the host's to name (the reference server's is the repository's configured bookmark, a forge's its default-bookmark setting), and in a non-bare repository it is the checked-out bookmark. The update is attested like any other: under a policy the attestation is required and its key MUST be authorized for the reference. The deletion's attestation stays the reference's latest, so an older attestation cannot be replayed to bring the bookmark back. The first attestation of a reference the remote does not list takes the client's clock in milliseconds as its serial, which is past the serial of any deleted namesake; later ones increment it. Changes of a deleted bookmark stay until unreachable (§21). |
|
| 816 | 818 | ||
| 817 | 819 | ## 17. Partial, sparse, and shallow repositories |
|
| … | 267 unchanged lines |