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 |