OXYGENHUB

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