OXYGENHUB

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