OXYGENHUB

Push errors name the bookmark and the failing step be451356e3ef

A refused update's reason now says which reference and what the server
was doing (reading or storing its attestation), and the protocol server
logs every refusal and every failed request with the repository and
operation, prefixing the client's message the same way ("refs of lib: …").
The server's own storage failing answers 500, not 400. One helper now
checks and stores an update's attestation for both updates and
deletions. HTTP test with a store that cannot be written.

closes #16
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 01:10 UTC
Signature
✔ signed by Andy McCall
Hash
be451356e3ef50e074343ccafba1c1174e261c2bfe9c5ff13d6a26ba884ee4d4
Depends on
750251b21a96 75167672ebcd 76f752503e7e 86e682c242c6 8c30d814f2cc 982864068a59 be0161a88dd7 e02bf0cd0533 f8c695764c95 ff0af1ee299d
Depended on by
642fa213de80 f53cbe7a204e 68a924846379
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
13 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/oxy-core/src/repo/sync.rs +28 −38
…
604 unchanged lines
605 605
        Ok(report)
606 606
    }
607 607
608 +
    /// Check an update's attestation against the latest one for its
609 +
    /// reference and store it as the new latest. Failures say what was
610 +
    /// being done, for which reference, so a refused push can be diagnosed
611 +
    /// from its reason alone.
612 +
    fn record_update_attest(
613 +
        &self,
614 +
        u: &RefUpdate,
615 +
        sa: &SignedAttest,
616 +
    ) -> std::result::Result<(), String> {
617 +
        let name = &u.name;
618 +
        let last = self
619 +
            .latest_ref_attest(name)
620 +
            .map_err(|e| format!("reading the last attestation of {name:?}: {e}"))?
621 +
            .and_then(|(_, a)| match a.attest.body {
622 +
                AttestBody::Ref { serial, .. } => Some(serial),
623 +
                _ => None,
624 +
            });
625 +
        self.check_ref_attest(sa, name, &u.old_state, &u.new_state, last, false)?;
626 +
        let store = |e: &dyn std::fmt::Display| format!("storing the attestation of {name:?}: {e}");
627 +
        let r = self.store.put_attest(&sa.attest).map_err(|e| store(&e))?;
628 +
        for sig in &sa.sigs {
629 +
            self.store.put_signature(&r, sig).map_err(|e| store(&e))?;
630 +
        }
631 +
        self.set_ref_attest(name, &r).map_err(|e| store(&e))
632 +
    }
633 +
608 634
    /// A deletion (spec §16.4): `new_state` is zero. The bookmark must exist
609 635
    /// at `old_state`, is never the bookmark a non-bare repository has
610 636
    /// checked out (a bare one's default is its host's to protect, see
…
40 unchanged lines
651 677
            }
652 678
        }
653 679
        if let Some(sa) = &u.attestation {
654 -
            let last = self
655 -
                .latest_ref_attest(&u.name)
656 -
                .map_err(|e| e.to_string())?
657 -
                .and_then(|(_, a)| match a.attest.body {
658 -
                    AttestBody::Ref { serial, .. } => Some(serial),
659 -
                    _ => None,
660 -
                });
661 -
            self.check_ref_attest(sa, &u.name, &u.old_state, &u.new_state, last, false)?;
662 -
            let r = self
663 -
                .store
664 -
                .put_attest(&sa.attest)
665 -
                .map_err(|e| e.to_string())?;
666 -
            for sig in &sa.sigs {
667 -
                self.store
668 -
                    .put_signature(&r, sig)
669 -
                    .map_err(|e| e.to_string())?;
670 -
            }
671 -
            self.set_ref_attest(&u.name, &r)
672 -
                .map_err(|e| e.to_string())?;
680 +
            self.record_update_attest(u, sa)?;
673 681
        }
674 682
        view.bookmarks.remove(&u.name);
675 683
        Ok(())
…
73 unchanged lines
749 757
            self.state_allowed(&old_heads, &new_state.heads, p, &u.name)?;
750 758
        }
751 759
        if let Some(sa) = &u.attestation {
752 -
            let last = self
753 -
                .latest_ref_attest(&u.name)
754 -
                .map_err(|e| e.to_string())?
755 -
                .and_then(|(_, a)| match a.attest.body {
756 -
                    AttestBody::Ref { serial, .. } => Some(serial),
757 -
                    _ => None,
758 -
                });
759 -
            self.check_ref_attest(sa, &u.name, &u.old_state, &u.new_state, last, false)?;
760 -
            let r = self
761 -
                .store
762 -
                .put_attest(&sa.attest)
763 -
                .map_err(|e| e.to_string())?;
764 -
            for sig in &sa.sigs {
765 -
                self.store
766 -
                    .put_signature(&r, sig)
767 -
                    .map_err(|e| e.to_string())?;
768 -
            }
769 -
            self.set_ref_attest(&u.name, &r)
770 -
                .map_err(|e| e.to_string())?;
760 +
            self.record_update_attest(u, sa)?;
771 761
        }
772 762
        view.bookmarks.insert(u.name.clone(), u.new_state);
773 763
        Ok(())
…
873 unchanged lines
modified crates/oxy-server/src/lib.rs +27 −7
…
89 unchanged lines
90 90
91 91
impl From<oxy_core::repo::RepoError> for Failure {
92 92
    fn from(e: oxy_core::repo::RepoError) -> Failure {
93 -
        Failure(StatusCode::BAD_REQUEST, e.to_string())
93 +
        use oxy_core::repo::RepoError;
94 +
        // The server's own storage failing is not the client's fault.
95 +
        let status = match &e {
96 +
            RepoError::Io(_) | RepoError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
97 +
            _ => StatusCode::BAD_REQUEST,
98 +
        };
99 +
        Failure(status, e.to_string())
94 100
    }
95 101
}
96 102
…
113 unchanged lines
210 216
            .and_then(hex_id);
211 217
        let server = Arc::clone(self);
212 218
        let request_headers = headers.clone();
219 +
        let repo = name.clone();
213 220
        let result = tokio::task::spawn_blocking(move || {
214 -
            server.respond(&name, endpoint, body, claimed, &request_headers)
221 +
            server.respond(&repo, endpoint, body, claimed, &request_headers)
215 222
        })
216 223
        .await;
217 -
        match result {
218 -
            Ok(Ok(r)) => r,
219 -
            Ok(Err(f)) => f.into_response(),
220 -
            Err(e) => Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
221 -
        }
224 +
        let failure = match result {
225 +
            Ok(Ok(r)) => return r,
226 +
            Ok(Err(f)) => f,
227 +
            Err(e) => Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
228 +
        };
229 +
        // Name the operation and repository, in the log and for the client.
230 +
        let f = Failure(
231 +
            failure.0,
232 +
            format!("{} of {name}: {}", endpoint.name(), failure.1),
233 +
        );
234 +
        eprintln!("oxy-server: {}: {}", f.0.as_u16(), f.1);
235 +
        f.into_response()
222 236
    }
223 237
224 238
    fn respond(
…
69 unchanged lines
294 308
                            u.name
295 309
                        ),
296 310
                    });
311 +
                }
312 +
                for r in report.results.iter().filter(|r| !r.ok) {
313 +
                    eprintln!(
314 +
                        "oxy-server: push to {name}: {} refused: {}",
315 +
                        r.name, r.reason
316 +
                    );
297 317
                }
298 318
                self.backend.after_push(name, &report, headers);
299 319
                ok(wire::encode_push_response(&report.results))
…
116 unchanged lines
modified crates/oxy-server/tests/http.rs +46 −0
…
643 unchanged lines
644 644
    );
645 645
    assert!(hosted.view().unwrap().bookmarks.contains_key("main"));
646 646
}
647 +
648 +
/// A push the server cannot store says which bookmark and what failed, so
649 +
/// it can be diagnosed from the client's message alone.
650 +
#[cfg(unix)]
651 +
#[test]
652 +
fn a_refused_push_names_the_bookmark_and_the_step() {
653 +
    hermetic();
654 +
    let dir = tempfile::tempdir().unwrap();
655 +
    let root = dir.path();
656 +
    let served = root.join("served");
657 +
    Repo::init_bare(&served.join("lib")).unwrap();
658 +
    // Break the server's attestation store for writing only: reading finds
659 +
    // nothing there, storing a new attestation fails.
660 +
    let attest = served.join("lib/.oxy/attest");
661 +
    fs::create_dir_all(&attest).unwrap();
662 +
    {
663 +
        use std::os::unix::fs::PermissionsExt;
664 +
        fs::set_permissions(&attest, fs::Permissions::from_mode(0o555)).unwrap();
665 +
    }
666 +
    let base = start(&served, vec![]);
667 +
    let work = root.join("work");
668 +
    let mut client = Repo::clone(&format!("{base}/lib"), &work)
669 +
        .unwrap()
670 +
        .with_home(root.join("home"));
671 +
    client
672 +
        .create_identity(
673 +
            "Ada",
674 +
            "[email protected]",
675 +
            oxy_core::repo::IdentityScope::Repo,
676 +
        )
677 +
        .unwrap();
678 +
    write(&work, "a.txt", b"a\n");
679 +
    client.commit("a", &[]).unwrap();
680 +
    client.bookmark_create("feature/x", None).unwrap();
681 +
    let r = client.push_bookmark("origin", "feature/x", false).unwrap();
682 +
    assert!(!r[0].ok);
683 +
    assert!(
684 +
        r[0].reason.contains("\"feature/x\"") && r[0].reason.contains("storing the attestation"),
685 +
        "{}",
686 +
        r[0].reason
687 +
    );
688 +
    {
689 +
        use std::os::unix::fs::PermissionsExt;
690 +
        fs::set_permissions(&attest, fs::Permissions::from_mode(0o755)).unwrap();
691 +
    }
692 +
}
modified docs/spec.md +1 −1
…
740 unchanged lines
741 741
742 742
### 16.4 Push validation
743 743
744 -
On push, the server MUST, in order: verify that every change has a valid signature or seal from a key embedded in policy and satisfies the author-or-delegate rule (§14.4), verify the attestation for each reference, check that `old_state` matches its current state (compare-and-swap), check policy rules for the reference including `allow_conflicts`, check that every dependency of every change is either included or already stored, and only then update references atomically. A failure rejects the whole push.
744 +
On push, the server MUST, in order: verify that every change has a valid signature or seal from a key embedded in policy and satisfies the author-or-delegate rule (§14.4), verify the attestation for each reference, check that `old_state` matches its current state (compare-and-swap), check policy rules for the reference including `allow_conflicts`, check that every dependency of every change is either included or already stored, and only then update references atomically. A failure rejects the whole push. A refusal's reason names the reference and, for a failure of the server's own storage, the step that failed; the reference server also logs every refusal and every failed request with its repository and operation, and answers a storage failure with 500 rather than 400.
745 745
746 746
**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).
747 747
…
264 unchanged lines