A push no longer passes a proxy's request limit (closes #133) d3b270909c78
Parts are about 64 MiB, a part whose blobs pass that sends them first in parts of objects alone, a proxy's HTML error page is shown by its title, and a 413 says how to push in smaller parts.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f3577efecd96
- When
- · 2026-10-24 22:27 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
d3b270909c7868793963ef2e081f34998da051fda4f9b3abe1ffe30fdffe5e20- Depends on
- 252e8cb23fbc 34fad4a92451 3e5772ccf9da 45a6f5ec85cf 5b9c6ad45f6e 67a4fe1aa2d5 750251b21a96 75167672ebcd 8fd78eb52ad2 982864068a59 b3c5a59a875a cb8b8082fa73 d6e0d5b8b339 e4595038502a ee130dd9eef2 f8c695764c95 ff0af1ee299d
- Depended on by
- 570e35310f31 f3811eb20bea 7bd805ffb418
- In bookmarks
- identical-sides main push-notes subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 16 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **A push no longer passes a proxy's request limit (#133).** A push to a hub behind Cloudflare, which takes at most 100 MB in one request, was refused `HTTP 413` with Cloudflare's HTML page printed raw: parts of a long push were about 256 MiB, and one change's blobs went in one request however many there were. Parts are now about 64 MiB (`push.batch-bytes`), a part whose blobs pass that sends them first in parts of objects alone, an HTML error page is shown by its title, and a 413 says how to push in smaller parts. |
|
| 5 | 6 | - **A keyless agent's grant survives its owner's key rotation (#135).** Every delegation an owner adds or removes renews their identity, and a repository that names the newest key as `author.key` read every change their agent sealed as INVALID ("key … is neither the author nor a delegate"), and its pushes were refused, because the agent's grant was signed by the owner's earlier key. A grant by a key the author's identity was rotated or renewed from now counts when it was made before that rotation; one an old key made after it does not. |
|
| 6 | 7 | - **A tag can be withdrawn, by a signed withdrawal (oxygenhub#301).** There was no way to remove a tag anywhere: a mistaken release tag stayed for good. `oxy tag delete NAME [-r REASON]` records an `untag` attestation naming the tag's state, signed like a tag (a ten-minute keyless key too), and the next `oxy push` sends it to a remote that still has the tag; `oxy push --delete tags/NAME` does both at once. A receiver under a policy accepts it only from a key that may create the tag, keeps it beside the tag's creation, and lists withdrawn tags in its refs answer (key 8), so a `fetch` withdraws the tag there too (`tag v1 was withdrawn on origin, and here`). A withdrawn name is never used again, so a tag name never names two states. `oxy-server`'s `Backend` gains `vet_tag_withdrawal` (OxygenHub refuses while a release uses the tag), and `Repo::host_tag_create` and `host_tag_withdrawal` let a host make or withdraw a tag for a signed-in user. The wire change is additive, but an older server refuses a withdrawal (wrong attestation kind), and an older client keeps a withdrawn tag. |
|
| 7 | 8 | - **A keyless agent's changes verify in a repository that has never seen its owner (#134).** An agent's ten-minute key is certified under its owner's signed grant, and a change it signed only verified where the owner's identity was already stored: in a new repository, or one the owner had not pushed to since a key rotation, `oxy log --verify` and the hub said INVALID ("grant is by an unknown author"). OxygenHub now sends the grant's signer with the certificate (`grantor_identity`, additive), `oxy` keeps it (else uses `<oxy home>/keys/<key>.identity`, or refuses with how to put it there), and a push brings it beside the certificate, so the receiver can check the grant. |
|
| … | 257 unchanged lines |
modified crates/oxy-core/src/protocol.rs +46 −2
| … | 495 unchanged lines |
||
| 496 | 496 | let text = r.into_string().unwrap_or_default(); |
|
| 497 | 497 | // The hint comes from the status alone: the server's text |
|
| 498 | 498 | // (a host's reason for refusing a push, several lines |
|
| 499 | - | // perhaps) is shown as it is, never read for hints. |
|
| 499 | + | // perhaps) is shown as it is, never read for hints. A |
|
| 500 | + | // proxy's HTML error page is shown by its title only |
|
| 501 | + | // (oxygen#133). |
|
| 500 | 502 | let status = format!("{url}: HTTP {code}"); |
|
| 501 | - | let msg = format!("{status}: {}", text.trim()); |
|
| 503 | + | let msg = format!("{status}: {}", plain_error_text(&text)); |
|
| 502 | 504 | Err(RepoError::Msg(match hint_for(&status) { |
|
| 503 | 505 | Some(h) => format!("{msg}\n hint: {h}"), |
|
| 504 | 506 | None => msg, |
|
| … | 32 unchanged lines |
||
| 537 | 539 | "the hub needs you signed in, or did not accept the token: run `oxy auth login HUB` (pushing needs repo:write)" |
|
| 538 | 540 | } else if m.contains("http 403") { |
|
| 539 | 541 | "the token or account lacks permission: pushing needs a token with repo:write and write access to the repository" |
|
| 542 | + | } else if m.contains("http 413") { |
|
| 543 | + | "the request was larger than the server, or a proxy in front of it, takes at once (Cloudflare's limit is 100 MB): push in smaller parts with `oxy config push.batch-bytes 33554432`, then push again" |
|
| 540 | 544 | } else if m.contains("http 404") { |
|
| 541 | 545 | "nothing at this URL that you can see: check the owner and repository name; a private repository needs you signed in (`oxy auth login HUB`)" |
|
| 542 | 546 | } else if m.contains("unknownissuer") |
|
| … | 17 unchanged lines |
||
| 560 | 564 | Some(hint) |
|
| 561 | 565 | } |
|
| 562 | 566 | ||
| 567 | + | /// An error response's text as it is, unless it is an HTML page (a proxy's, |
|
| 568 | + | /// such as Cloudflare's 413): then its title, or a note that it was one. |
|
| 569 | + | fn plain_error_text(text: &str) -> String { |
|
| 570 | + | let t = text.trim(); |
|
| 571 | + | let lower = t.to_ascii_lowercase(); |
|
| 572 | + | if !(lower.starts_with("<!doctype html") || lower.starts_with("<html")) { |
|
| 573 | + | return t.to_string(); |
|
| 574 | + | } |
|
| 575 | + | let title = lower |
|
| 576 | + | .find("<title>") |
|
| 577 | + | .and_then(|i| { |
|
| 578 | + | lower[i + 7..] |
|
| 579 | + | .find("</title>") |
|
| 580 | + | .map(|j| t[i + 7..i + 7 + j].trim()) |
|
| 581 | + | }) |
|
| 582 | + | .filter(|s| !s.is_empty()); |
|
| 583 | + | match title { |
|
| 584 | + | Some(s) => format!("{s} (an HTML error page, from a proxy in front of the server perhaps)"), |
|
| 585 | + | None => "an HTML error page (from a proxy in front of the server perhaps)".into(), |
|
| 586 | + | } |
|
| 587 | + | } |
|
| 588 | + | ||
| 563 | 589 | /// `message`, followed by what to do about it when that is known. |
|
| 564 | 590 | pub fn with_hint(message: String) -> String { |
|
| 565 | 591 | match hint_for(&message) { |
|
| … | 858 unchanged lines |
||
| 1424 | 1450 | use super::*; |
|
| 1425 | 1451 | use crate::attest::AttestBody; |
|
| 1426 | 1452 | use crate::objects::Hlc; |
|
| 1453 | + | ||
| 1454 | + | /// oxygen#133: a proxy's HTML error page is shown by its title, and a |
|
| 1455 | + | /// server's own text as it is; a 413 says to push in smaller parts. |
|
| 1456 | + | #[test] |
|
| 1457 | + | fn html_error_pages_are_shown_by_their_title() { |
|
| 1458 | + | let cloudflare = "<html>\r\n<head><title>413 Request Entity Too Large</title></head>\r\n<body><center><h1>413</h1></center><hr><center>cloudflare</center></body></html>"; |
|
| 1459 | + | assert_eq!( |
|
| 1460 | + | plain_error_text(cloudflare), |
|
| 1461 | + | "413 Request Entity Too Large (an HTML error page, from a proxy in front of the server perhaps)" |
|
| 1462 | + | ); |
|
| 1463 | + | assert_eq!( |
|
| 1464 | + | plain_error_text(" refused: a secret\nat a.txt:3 "), |
|
| 1465 | + | "refused: a secret\nat a.txt:3" |
|
| 1466 | + | ); |
|
| 1467 | + | assert!(hint_for("https://hub/x: HTTP 413: too large") |
|
| 1468 | + | .unwrap() |
|
| 1469 | + | .contains("push.batch-bytes")); |
|
| 1470 | + | } |
|
| 1427 | 1471 | ||
| 1428 | 1472 | #[test] |
|
| 1429 | 1473 | fn client_features_are_declared_and_read() { |
|
| … | 296 unchanged lines |
modified crates/oxy-core/src/repo/config.rs +3 −1
| … | 106 unchanged lines |
||
| 107 | 107 | max_new_file: 100 << 20, |
|
| 108 | 108 | snapshot_retention_days: 7, |
|
| 109 | 109 | checkpoint_interval: 256, |
|
| 110 | - | push_batch_bytes: 256 << 20, |
|
| 110 | + | // Well under the 100 MB a proxy in front of a hub may take |
|
| 111 | + | // (oxygen#133). |
|
| 112 | + | push_batch_bytes: 64 << 20, |
|
| 111 | 113 | max_blob: None, |
|
| 112 | 114 | sparse_paths: Vec::new(), |
|
| 113 | 115 | semantic_deps: false, |
|
| … | 245 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +89 −2
| … | 2121 unchanged lines |
||
| 2122 | 2122 | Ok(()) |
|
| 2123 | 2123 | } |
|
| 2124 | 2124 | ||
| 2125 | + | /// Send `bundle`'s blobs and chunks ahead of it, in parts of about |
|
| 2126 | + | /// `push.batch-bytes` of objects alone, when the whole would be larger |
|
| 2127 | + | /// (oxygen#133): one change can bring more blobs than one request may |
|
| 2128 | + | /// carry, and a proxy in front of a hub refuses a request past its limit |
|
| 2129 | + | /// (Cloudflare's is 100 MB). What is left, the changes with their |
|
| 2130 | + | /// identities and states, is returned to send as before. A receiver |
|
| 2131 | + | /// stores objects as they come, and checks a change's blobs are stored. |
|
| 2132 | + | fn send_objects_ahead( |
|
| 2133 | + | &self, |
|
| 2134 | + | remote: &dyn crate::protocol::Remote, |
|
| 2135 | + | remote_name: &str, |
|
| 2136 | + | mut bundle: Bundle, |
|
| 2137 | + | progress: crate::store::Progress, |
|
| 2138 | + | ) -> Result<Bundle> { |
|
| 2139 | + | let limit = self.config.push_batch_bytes; |
|
| 2140 | + | let changes: u64 = bundle |
|
| 2141 | + | .changes |
|
| 2142 | + | .iter() |
|
| 2143 | + | .map(|(c, _)| c.encode().len() as u64) |
|
| 2144 | + | .sum(); |
|
| 2145 | + | let objects: u64 = bundle.objects.iter().map(|(_, b)| b.len() as u64).sum(); |
|
| 2146 | + | if changes + objects <= limit { |
|
| 2147 | + | return Ok(bundle); |
|
| 2148 | + | } |
|
| 2149 | + | let (mut kept, mut parts, mut part, mut bytes) = (Vec::new(), Vec::new(), Vec::new(), 0u64); |
|
| 2150 | + | for (ty, body) in std::mem::take(&mut bundle.objects) { |
|
| 2151 | + | if !matches!(ty, ObjectType::Blob | ObjectType::Chunk) { |
|
| 2152 | + | kept.push((ty, body)); |
|
| 2153 | + | continue; |
|
| 2154 | + | } |
|
| 2155 | + | if !part.is_empty() && bytes + body.len() as u64 > limit { |
|
| 2156 | + | parts.push(std::mem::take(&mut part)); |
|
| 2157 | + | bytes = 0; |
|
| 2158 | + | } |
|
| 2159 | + | bytes += body.len() as u64; |
|
| 2160 | + | part.push((ty, body)); |
|
| 2161 | + | } |
|
| 2162 | + | if !part.is_empty() { |
|
| 2163 | + | parts.push(part); |
|
| 2164 | + | } |
|
| 2165 | + | let n = parts.len(); |
|
| 2166 | + | for (i, objects) in parts.into_iter().enumerate() { |
|
| 2167 | + | progress(&format!( |
|
| 2168 | + | "objects {} of {n}: {} blobs and chunks", |
|
| 2169 | + | i + 1, |
|
| 2170 | + | objects.len() |
|
| 2171 | + | )); |
|
| 2172 | + | let results = remote.push( |
|
| 2173 | + | Bundle { |
|
| 2174 | + | objects, |
|
| 2175 | + | ..Default::default() |
|
| 2176 | + | }, |
|
| 2177 | + | vec![], |
|
| 2178 | + | None, |
|
| 2179 | + | vec![], |
|
| 2180 | + | )?; |
|
| 2181 | + | match results.iter().find(|r| r.name == OBJECTS_RESULT) { |
|
| 2182 | + | Some(r) if r.ok => {} |
|
| 2183 | + | Some(r) => { |
|
| 2184 | + | return fail(format!( |
|
| 2185 | + | "{remote_name} refused objects part {} of {n}: {}", |
|
| 2186 | + | i + 1, |
|
| 2187 | + | r.reason |
|
| 2188 | + | )) |
|
| 2189 | + | } |
|
| 2190 | + | None => { |
|
| 2191 | + | return fail(format!( |
|
| 2192 | + | "{remote_name} does not take a push in parts, and this one is too large for one: update the server's oxy" |
|
| 2193 | + | )) |
|
| 2194 | + | } |
|
| 2195 | + | } |
|
| 2196 | + | } |
|
| 2197 | + | bundle.objects = kept; |
|
| 2198 | + | Ok(bundle) |
|
| 2199 | + | } |
|
| 2200 | + | ||
| 2125 | 2201 | /// `changes`, in dependency order, cut into batches of about |
|
| 2126 | 2202 | /// `push.batch-bytes` by what each change and its texts and blobs weigh. |
|
| 2127 | 2203 | /// There is always one, perhaps empty. |
|
| … | 164 unchanged lines |
||
| 2292 | 2368 | batches.len(), |
|
| 2293 | 2369 | batch.len() |
|
| 2294 | 2370 | )); |
|
| 2295 | - | let results = remote.push(self.bundle_for(batch)?, vec![], None, vec![])?; |
|
| 2371 | + | let bundle = self.send_objects_ahead( |
|
| 2372 | + | remote.as_ref(), |
|
| 2373 | + | remote_name, |
|
| 2374 | + | self.bundle_for(batch)?, |
|
| 2375 | + | &mut *progress, |
|
| 2376 | + | )?; |
|
| 2377 | + | let results = remote.push(bundle, vec![], None, vec![])?; |
|
| 2296 | 2378 | match results.iter().find(|r| r.name == OBJECTS_RESULT) { |
|
| 2297 | 2379 | Some(r) if r.ok => {} |
|
| 2298 | 2380 | Some(r) => { |
|
| … | 18 unchanged lines |
||
| 2317 | 2399 | last.len() |
|
| 2318 | 2400 | )); |
|
| 2319 | 2401 | } |
|
| 2320 | - | let mut bundle = self.bundle_for(last)?; |
|
| 2402 | + | let mut bundle = self.send_objects_ahead( |
|
| 2403 | + | remote.as_ref(), |
|
| 2404 | + | remote_name, |
|
| 2405 | + | self.bundle_for(last)?, |
|
| 2406 | + | &mut *progress, |
|
| 2407 | + | )?; |
|
| 2321 | 2408 | bundle.objects.push(( |
|
| 2322 | 2409 | ObjectType::State, |
|
| 2323 | 2410 | StateObject::new(local_heads.clone()).encode(), |
|
| … | 326 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +53 −0
| … | 9326 unchanged lines |
||
| 9327 | 9327 | assert!(got[0].ok && got[0].name == OBJECTS_RESULT, "{got:?}"); |
|
| 9328 | 9328 | } |
|
| 9329 | 9329 | ||
| 9330 | + | /// oxygen#133: one change whose blobs pass `push.batch-bytes` sends them |
|
| 9331 | + | /// first, in parts of objects alone, so no request is larger than about |
|
| 9332 | + | /// that (a proxy in front of a hub refuses one past 100 MB); the change |
|
| 9333 | + | /// and the bookmark follow, and a clone has every file. |
|
| 9334 | + | #[test] |
|
| 9335 | + | fn a_heavy_change_sends_its_blobs_in_parts() { |
|
| 9336 | + | hermetic(); |
|
| 9337 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 9338 | + | let root = dir.path(); |
|
| 9339 | + | let origin = root.join("origin"); |
|
| 9340 | + | Repo::init_bare_unbound(&origin).unwrap(); |
|
| 9341 | + | let main = root.join("main"); |
|
| 9342 | + | let mut repo = Repo::init(&main).unwrap(); |
|
| 9343 | + | repo.config |
|
| 9344 | + | .set("remote.origin.url", &origin.to_string_lossy()) |
|
| 9345 | + | .unwrap(); |
|
| 9346 | + | repo.config.set("push.batch-bytes", "50000").unwrap(); |
|
| 9347 | + | repo.save_config().unwrap(); |
|
| 9348 | + | let mut x: u64 = 0x9e3779b97f4a7c15; |
|
| 9349 | + | let mut noise = |n: usize| -> Vec<u8> { |
|
| 9350 | + | (0..n) |
|
| 9351 | + | .map(|_| { |
|
| 9352 | + | x ^= x << 13; |
|
| 9353 | + | x ^= x >> 7; |
|
| 9354 | + | x ^= x << 17; |
|
| 9355 | + | (x >> 24) as u8 |
|
| 9356 | + | }) |
|
| 9357 | + | .collect() |
|
| 9358 | + | }; |
|
| 9359 | + | let files: Vec<Vec<u8>> = (0..4).map(|_| noise(40_000)).collect(); |
|
| 9360 | + | for (i, f) in files.iter().enumerate() { |
|
| 9361 | + | write(&main, &format!("bin{i}.dat"), f); |
|
| 9362 | + | } |
|
| 9363 | + | repo.commit("four blobs", &[]).unwrap(); |
|
| 9364 | + | let mut parts = Vec::new(); |
|
| 9365 | + | let results = repo |
|
| 9366 | + | .push_bookmark_with("origin", "main", false, &mut |m| parts.push(m.to_string())) |
|
| 9367 | + | .unwrap(); |
|
| 9368 | + | assert!( |
|
| 9369 | + | results.iter().any(|r| r.ok && r.name == "main"), |
|
| 9370 | + | "{results:?}" |
|
| 9371 | + | ); |
|
| 9372 | + | assert!( |
|
| 9373 | + | parts.len() >= 3 && parts[0].starts_with("objects 1 of"), |
|
| 9374 | + | "{parts:?}" |
|
| 9375 | + | ); |
|
| 9376 | + | let back = Repo::clone(&origin.to_string_lossy(), &root.join("back")).unwrap(); |
|
| 9377 | + | for (i, f) in files.iter().enumerate() { |
|
| 9378 | + | assert_eq!(&fs::read(root.join(format!("back/bin{i}.dat"))).unwrap(), f); |
|
| 9379 | + | } |
|
| 9380 | + | drop(back); |
|
| 9381 | + | } |
|
| 9382 | + | ||
| 9330 | 9383 | /// oxygen#97: with `git.claim`, an import authors the importing identity's |
|
| 9331 | 9384 | /// own commits (Git author email = its email, any case) with its key, and |
|
| 9332 | 9385 | /// leaves anyone else's with the zero key; the history verifies as before. |
|
| … | 627 unchanged lines |
modified docs/spec.md +1 −1
| … | 911 unchanged lines |
||
| 912 | 912 | ||
| 913 | 913 | 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. |
|
| 914 | 914 | ||
| 915 | - | **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. |
|
| 915 | + | **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 64 MiB, under the 100 MB a proxy in front of a hub may take) of changes and their texts and blobs, and the last part carries the reference updates; when one part's changes bring more than that in blobs, the blobs and chunks go first in parts of their own, objects alone (oxygen#133), 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. |
|
| 916 | 916 | ||
| 917 | 917 | **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. |
|
| 918 | 918 | ||
| … | 270 unchanged lines |