OXYGENHUB

Compare keyless-tags … main

⇄

61 changes in main not in keyless-tags. 79 files differ between the two states.

Release 0.11.0 mainv0.11.0 head
Andy McCall via agent:claude@andymccall agent ·
Name moved subrepo pins in status and commit; changelog on the WASM tier (closes #162) mainv0.11.0 head
Andy McCall via agent:claude@andymccall agent ·
A stale author.key can no longer fork the identity (closes #163)
Andy McCall via agent:claude@andymccall agent ·
oxy 0.10.0 v0.10.0
Andy McCall via agent:claude@andymccall agent ·
HaikuPorts recipes: x86_64 only and no minimum rustc, as the haikuports review asked (refs #157) mainv0.10.0v0.11.0 head
Andy McCall via agent:claude@andymccall agent ·
HaikuPorts recipes: copyright 2026, as the LICENSE says (refs #157) mainv0.10.0v0.11.0 head
Andy McCall via agent:claude@andymccall agent ·
oxy 0.9.0 v0.9.0
Andy McCall via agent:claude@andymccall agent ·
The source tarball leaves out the job disk's lost+found (refs #157) mainv0.10.0v0.11.0v0.9.0 head
Andy McCall via agent:claude@andymccall agent ·
oxy 0.8.0 v0.8.0
Andy McCall via agent:claude@andymccall agent ·
CHANGELOG: one #148 entry (the merge kept both sides' copy) mainsubrepo-pin-statev0.10.0v0.11.0v0.8.0v0.9.0 head
Andy McCall via agent:claude@andymccall agent ·
Merge main into subrepo-pin-state: keep both changelog entries resolution
Andy McCall via agent:claude@andymccall agent ·
Merge main into identical-sides: keep both changelog entries identical-sides resolution
Andy McCall via agent:claude@andymccall agent ·
Merge main into subrepo-pin-state: keep both changelog entries resolution
Andy McCall via agent:claude@andymccall agent ·
oxy-server: a host can add a note to a reference a push moved (closes #148) push-notes
Andy McCall via agent:claude@andymccall agent ·
A shallow clone fetches its checkpoint in parts (refs #137)
Andy McCall via agent:claude@andymccall agent ·
oxy 0.7.0 v0.7.0
Andy McCall via agent:claude@andymccall agent ·
Read many objects in pack and offset order
Andy McCall via agent:claude@andymccall agent ·
Pass the workspace directory itself, as clippy asks (refs #143) identical-sidesmainpush-notessubrepo-pin-statev0.10.0v0.11.0v0.7.0v0.8.0v0.9.0 head
Andy McCall via agent:claude@andymccall agent ·
Windows paths print plainly (closes #143)
Andy McCall via agent:claude@andymccall agent ·
CI on the Rust image with cargo-nextest (refs #139) identical-sidesmainpush-notessubrepo-pin-statev0.10.0v0.11.0v0.7.0v0.8.0v0.9.0 head
Andy McCall via agent:claude@andymccall agent ·
renameat2's flags are unsigned on Android (refs #141) identical-sidesmainpush-notessubrepo-pin-statev0.10.0v0.11.0v0.7.0v0.8.0v0.9.0 head
Andy McCall via agent:claude@andymccall agent ·
The object store works where hard links are refused (closes #141)
Andy McCall via agent:claude@andymccall agent ·
oxy serve takes a push of more than 2 MB (closes #138) identical-sidesmainpush-notessubrepo-pin-statev0.10.0v0.11.0v0.7.0v0.8.0v0.9.0 head
Andy McCall via agent:claude@andymccall agent ·
oxy 0.6.0 v0.6.0
Andy McCall via agent:claude@andymccall agent ·
Depth 0 takes no change, and a checkpoint is gathered in parallel (refs #137) v0.6.0v0.7.0
Andy McCall via agent:claude@andymccall agent ·
Instant clone: the design, with tonight's measurements (refs #132)
Andy McCall via agent:claude@andymccall agent ·
A push no longer passes a proxy's request limit (closes #133)
Andy McCall via agent:claude@andymccall agent ·
A tag can be withdrawn, by a signed withdrawal (oxygenhub#301) identical-sidespush-notessubrepo-pin-statev0.6.0v0.7.0v0.8.0v0.9.0
Andy McCall via agent:claude@andymccall agent ·
oxy 0.5.1 v0.5.1
Andy McCall via agent:claude@andymccall agent ·
hide whitespace changes
added .config/nextest.toml +8 −0
1 +
# The CI profile (oxygen#139): every test runs, and a JUnit report is
2 +
# written to target/nextest/ci/junit.xml for the hub's test report
3 +
# (the job's `junit` artifact).
4 +
[profile.ci]
5 +
fail-fast = false
6 +
7 +
[profile.ci.junit]
8 +
path = "junit.xml"
added .flow/checks.wasm +0 −0

No line changes.

modified .flow/pipeline.kdl +30 −6
…
2 unchanged lines
3 3
// Earth; the jobs appear as checks on the change.
4 4
schema-version 1
5 5
6 +
// Cheap checks on the WASM tier (oxygenflow#96): OxygenFlow's checks module,
7 +
// copied from oxygenflow/.flow/checks.wasm (built there from
8 +
// crates/flow-checks; copy it again when it changes). No image and no KVM,
9 +
// so any runner with the WASM tier takes it: a phone, plex, windows-1.
10 +
sandbox "wasm" {
11 +
    runtime "wasm"
12 +
    memory-limit "64MiB"
13 +
}
14 +
15 +
// The changelog's shape, and a section for the version Cargo.toml says, so a
16 +
// release never finds its notes missing.
17 +
job "changelog" {
18 +
    sandbox "wasm"
19 +
    scope "CHANGELOG.md" "Cargo.toml" ".flow/checks.wasm"
20 +
    exec ".flow/checks.wasm" "changelog" "CHANGELOG.md" "Cargo.toml"
21 +
}
22 +
6 23
// The OxygenFlow build image: Rust with rustfmt and clippy, Node, and every
7 24
// crate of Cargo.lock vendored (oxygenflow/images/rust). Rebuild and re-pin
8 25
// it when Cargo.lock changes.
9 26
sandbox "rust" {
10 27
    runtime "microvm"
11 -
    rootfs "cas://blob/3fa57d4c1dc8aa5d4d92665bc13f332357f55042f658543dc21b1540b3aced69"
28 +
    rootfs "cas://blob/836461b613019f62399a960c61a25c482eb74e30054ff2cd44d5594c3305872b"
12 29
    memory-limit "8GiB"
13 30
    cpu-limit 4
14 31
    // Tests serve and connect on 127.0.0.1; nothing leaves the guest.
…
10 unchanged lines
25 42
26 43
sandbox "rust-small" {
27 44
    runtime "microvm"
28 -
    rootfs "cas://blob/3fa57d4c1dc8aa5d4d92665bc13f332357f55042f658543dc21b1540b3aced69"
45 +
    rootfs "cas://blob/836461b613019f62399a960c61a25c482eb74e30054ff2cd44d5594c3305872b"
29 46
    memory-limit "1GiB"
30 47
    cpu-limit 1
31 48
    network "isolated"
…
42 unchanged lines
74 91
    exec "sh" ".flow/warm-target.sh" "cargo" "clippy" "--workspace" "--all-targets" "--locked" "--" "-D" "warnings"
75 92
}
76 93
77 -
// The tests, run under cargo-llvm-cov so the hub's Coverage tab
78 -
// (oxygenhub#113) gets a report: the same tests, instrumented, and an LCOV
79 -
// file kept as the job's `coverage` artifact.
94 +
// The tests, run under cargo-nextest and cargo-llvm-cov: a test report
95 +
// (JUnit, kept whether or not they pass; #139) for the hub's Tests tab and
96 +
// line coverage (LCOV, oxygenhub#113) for its Coverage tab, from one run.
80 97
job "test" {
81 98
    sandbox "rust"
82 99
    depends-on "fmt"
…
11 unchanged lines
94 111
    env {
95 112
        OXY_BUILD "ci"
96 113
    }
97 -
    exec "cargo" "llvm-cov" "--workspace" "--locked" "--lcov" "--output-path" "/src/target/lcov.info"
114 +
    exec "cargo" "llvm-cov" "nextest" "--workspace" "--locked" "--profile" "ci" "--lcov" "--output-path" "/src/target/lcov.info"
98 115
    output {
116 +
        artifact "junit" {
117 +
            path "/src/target/nextest/ci/junit.xml"
118 +
            when "always"
119 +
        }
99 120
        artifact "coverage" {
100 121
            path "/src/target/lcov.info"
101 122
        }
…
74 unchanged lines
176 197
        artifact "oxy_riscv64.deb" {
177 198
            path "/src/target/dist-flat/oxy_riscv64.deb"
178 199
        }
200 +
        artifact "oxy-src.tar.gz" {
201 +
            path "/src/target/dist-flat/oxy-src.tar.gz"
202 +
        }
179 203
        artifact "SHA256SUMS" {
180 204
            path "/src/target/dist-flat/SHA256SUMS"
181 205
        }
…
30 unchanged lines
added .oxyattributes +2 −0
1 +
# oxygen#145: two changes adding changelog entries keep both.
2 +
CHANGELOG.md merge=union
modified CHANGELOG.md +71 −1
1 1
# Changelog
2 2
3 -
## Unreleased
3 +
## 0.11.0 (2026-11-04)
4 +
5 +
- **Changed: the changelog is checked on the WASM tier.** A `changelog` job in the pipeline runs OxygenFlow's checks module on any runner with the WASM tier, failing when the changelog's sections are out of shape or Cargo.toml's version has none.
6 +
- **Changed: moved subrepo pins are named (#162).** A pin moved by work inside a subrepo is a pin bump the parent's next commit records, and it used to go in unsaid. `oxy status` now lists it under *Subrepo pins* (old → new), and a subrepo's own uncommitted edits, which the parent does not record; a bare `oxy commit` that records a moved pin says so and how to leave it out (`oxy commit PATHS`).
7 +
- **Added: `oxy submodule remove --delete` (#164).** It deletes the subrepo's directory too, read-only object files and all, and refuses while the subrepo has uncommitted edits (`--force` to delete them anyway); without it, removal says the directory is left and how to delete it. A directory moved by hand used to fail halfway, after the copy, on the read-only files.
8 +
- **Changed: an agent's commit says "awaiting seal", not "unsigned" (#165).** In a checkout that signs keyless under an agent grant, a change of its own author that the next push will seal shows `◌ awaiting seal` in `oxy log` and `oxy show`; "unsigned" is kept for changes nothing will sign.
9 +
- **Fixed: a stale `author.key` can no longer fork your identity (#163).** A checkout that pinned `author.key` to a key a rotation made elsewhere had already succeeded built a second successor of it on `oxy identity delegate add`, splitting the identity in two. `rotate`, `renew` and `delegate add`/`remove` now refuse to start from a key that already has a successor in your home, naming the newest key and the `oxy config` that points at it; their report says which key they started from and which config named it; and `oxy identity show` warns when the configured key is not the newest, and names the key where an identity forks.
10 +
11 +
## 0.10.0 (2026-11-01)
12 +
13 +
- **Fixed: fetch, pull and clone are far faster with many bookmarks (#158).** They fetched each bookmark in its own exchange with the hub, and changes that bookmarks share were sent again for each one; a pull with nothing new still asked for a bundle per bookmark. Now every bookmark's heads are fetched in one exchange, and none when nothing is new. On a 47 MB repository with 160 bookmarks: a pull with nothing new 169 s → 3.5 s, a clone 324 s → 26 s. `OXY_TIMINGS` now also shows the refs, transparency log and per-bookmark checks of a fetch.
14 +
- **Added: `oxy git colocate`, a `.git` beside `.oxy` (oxygenhub#355).** Your editor's Git features (blame, history, gutter marks) and other Git tools now work on an Oxygen checkout: oxy keeps a `.git` there with the current bookmark exported after every command, Git's index at its tree so `git status` shows only your uncommitted work, and `.oxy/` excluded. One way: commit with oxy. `--off` stops it.
15 +
- **Changed: `.git` is never content.** oxy no longer picks up a `.git` directory at a checkout's top, and checkout refuses a path with a `.git` component at any depth (as Git does), so a change cannot plant Git hooks.
16 +
- **Added: a Git export from the change graph (oxygenhub#355).** oxy-core's `git_export_graph` writes every bookmark and tag to a bare Git repository so that the same changes give the same commits wherever it runs: each change a commit whose parents are its dependencies, each bookmark a branch (a merge of its heads when it has several), tags as tags, incrementally. It is what OxygenHub serves to `git clone`. (`oxy git export` still follows the checkout's own history.)
17 +
- **Added: `oxy mcp`, Oxygen for AI agents (#159).** An MCP server over stdio for a repository: an agent calls typed tools (status, diff, log, show, cat, blame, bookmarks, tags, conflicts, op log, identity, commit, amend, describe, fetch, pull, push, tlog verify) and gets each command's JSON back instead of reading a shell. It signs as the identity its environment configures, so started with an agent home every change is sealed via the agent with nothing to forget per command, and says who in its `initialize` reply. Commits name their paths (or `all: true`); undo and forced pushes are off unless started with `--allow-destructive`. The protocol itself is oxy-core's `mcp` module, for `oh mcp` to reuse. See docs/mcp.md.
18 +
- **Icons for HaikuDepot (#157).** `packaging/haikuports/icons/` has oxy's and oh's icons as HVIF (Haiku's vector format) with 16, 32 and 64 px PNGs, written by `make-icons.py`: the Oxygen mark on a pale tile for oxy, on OxygenHub's dark tile for oh, the same family as the VS Code extensions.
19 +
20 +
## 0.9.0 (2026-10-29)
21 +
22 +
- **Releases carry their source (#157).** Each release now has `oxy-VERSION-src.tar.gz`, the source as released, reproducible (sorted, fixed times and owners, gzip without a timestamp), listed in SHA256SUMS, for packagers to fetch at a fixed checksum. `packaging/haikuports/make-recipe.py oxy|oh TARBALL VERSION` writes the haikuports recipe from it (oh's from OxygenHub's source tarball); the 0.8.0 recipe, which used the on-demand archive, is gone.
23 +
- **Added: a haikuports recipe for oxy (#156).** `packaging/haikuports/` holds the recipe that builds oxy for HaikuDepot from the release source and every crate in `Cargo.lock`, and the script that writes it for a new release; the 0.8.0 recipe builds with haikuporter on Haiku R1/beta6.
24 +
- **Fixed: an expired checkout token no longer hides your sign-in (#155).** A checkout's own token for a remote (`remote.NAME.token`) is used first, so when it expired every fetch, pull and push answered 401 although `oxy auth login` had a good sign-in for the same hub. A checkout token the hub no longer knows (401) now falls back to that sign-in, with a warning naming `oxy config --unset remote.NAME.token` to drop the stale one; never to another checkout's token, and not when the token is refused for permission (403, 404), so a narrowly scoped token is never swapped for a wider sign-in. When both are refused, the error says to sign in again and unset it.
25 +
- **Added: the `artifact` object type (Cylinder C1, #108).** An artifact is now an object of its own in the store (oxy-core's `artifact` module), named by its object hash as every object is, and `oxy fsck` checks it; its tree stays a blob. Stores written before read as they did: the type is a new one at the end of the list.
26 +
- **Added: artifacts (Cylinder, step C1, #108).** `oxy artifact pack DIR --kind archive|package|container|runner-image|report [--arch --os --entrypoint --env K=V --label K=V]` packs a directory into an artifact: each file an ordinary blob of the repository's store, so files and chunks it holds already cost nothing, under a tree and an artifact encoding in canonical CBOR, named by the BLAKE3 of that encoding (docs/cylinder.md §4.1). `oxy artifact list | show NAME | unpack NAME DEST | verify NAME` (any unique prefix of six characters or more; `--json` on list, show and pack), and `oxy gc` keeps every artifact's blobs. The new `cylinder` crate holds the model.
27 +
- **Added: oxy on Haiku (#154).** oxy builds and runs on Haiku R1/beta6 (x86_64) with HaikuPorts' Rust; `oxy-VERSION-haiku-x86_64.tar.gz` is on the releases page from 0.8.0, with its own `SHA256SUMS-haiku`. The README says how to install it and how to build it (`ZSTD_SYS_USE_PKG_CONFIG=1`).
28 +
29 +
- **Fixed: a subrepo put back after its removal is pinned in the change that registers it (#152).** `oxy submodule remove` leaves a marker so the next snapshot drops the node while the directory stays; the snapshot cleared it only after reading it, so when nothing looked at the working copy between the removal's commit and the subrepo's return, the change that registered it again in `.oxymodules` left it out and a second commit was needed. A marker is now dropped before it is read once the removal is recorded or the path is registered again.
30 +
- **Fixed: `oxy identity create` needs no repository (#144).** Outside one it makes a user identity: the secret and identity go to the oxy home's keys and the user file names it as the author, so a new user or machine sets up an identity first; a repository made or cloned afterwards signs with it. Only `--local` needs a repository, and inside one it is kept there too, as before.
31 +
32 +
## 0.8.0 (2026-10-27)
33 +
34 +
- **A server can add a note to a reference a push moved (#148).** `oxy-server`'s `Backend` has a `push_notes` step: each note is added to that accepted reference's result, which `oxy push` shows in place of "updated". OxygenHub will use it to say when a push moves the bookmark of a proposal that has already landed (oxygenhub#329).
35 +
36 +
- **Identical sides settle by themselves (#146).** Two bookmarks that each resolved the same conflict to the same text conflicted again when merged, both sides the identical lines. A conflict whose sides are byte-identical now settles as `merge=union` adjacent insertions do (#145), in any file and keeping one side: a checkout writes it once ("kept one of its identical sides") and the next commit records the resolution, and a host landing a proposal settles it too.
37 +
38 +
- **A subrepo at its pinned state is unmodified on any bookmark (#147).** A subrepo whose heads were exactly its pin's but which sat on another bookmark than the pin named counted as modified, so `oxy bookmark switch` refused ("the working copy has changes"). Only the state counts now: the same repository at the same heads, or the same Git commit; the recorded pin stands.
39 +
40 +
- **A shallow clone fetches its checkpoint in parts (#137).** The checkpoint of a big state came as one answer, the whole state's graph blobs in one body (about 2 GB for the VS Code history), built and held whole on both sides. A server now answers `checkpoint-index` (the checkpoint and its history, no graphs), and the client fetches the graph blobs it lacks with `objects`, 2,048 at a time; `objects` reads them in pack order. An older server answers 404 and gets the one-piece request as before.
41 +
42 +
- **A keyless agent follows its owner's newest identity (#82).** Every delegation rotates its owner's identity, so the `author.key` an agent's home was given goes stale, and its changes named an identity two rotations old (verifying only because a grant from before a rotation still counts). With `author.for = ACCOUNT` in the home, the hub sends the account's newest linked identity with each certificate, with its chain back to the grant's key; oxy keeps it in the home (`keys/author.for`), stores the chain beside the change so the grant checks, and authors as it from then on. A repository's own `author.key` pin still wins.
43 +
44 +
- **Changelogs stop conflicting (#145).** A file marked `merge=union` in `.oxyattributes` (`CHANGELOG.md merge=union`) has its adjacent insertions settled by the checkout itself: both sides kept, the earlier change's first, as `oxy resolve --both` does, and the next commit records the resolution. Only adjacent insertions: where two changes changed the same lines, the markers stay. Oxygen's own changelog is marked so.
45 +
46 +
- **Two changes adding lines at the same place are settled with one command (#121).** Like Git, oxy leaves a conflict when two changes each add lines after the same line, since either order can be wrong for code; settling it meant editing out the markers by hand. `oxy resolve --both [PATH]…` now keeps every side of each such *adjacent insertion*, the earlier change's first (`--reverse` for the other order), and `oxy status` says which conflicts qualify. It leaves the markers where the sides changed the same lines (keeping both would keep two versions of them) or where the lines between the markers were edited. Conflict markers now list their sides earliest change first, not in hash order.
47 +
48 +
## 0.7.0 (2026-10-26)
49 +
50 +
- **Many objects are read in pack order (#137):** `Store::get_many` (a clone's checkpoint, a fetch's objects) reads packed objects by pack and offset, each thread a run of them, so a spinning disk sweeps instead of seeking across the packs; results keep the asked order. The server's gathering of a VS Code-sized checkpoint (tens of thousands of objects in ~95 packs) was the slow part of a depth-0 clone on Earth.
51 +
- **Windows paths print plainly (#143):** a repository's root showed as `\\?\C:\work\demo` in every message, Windows' verbatim form from `canonicalize`; oxy now drops the prefix where the plain path says the same (`oxy_core::portable::canonical`). Found running the shipped Windows build on a Windows 11 VM.
52 +
- **Oxygen's CI writes a test report beside its coverage (#139):** the test job runs under cargo-nextest (`.config/nextest.toml`, profile `ci`) as well as cargo-llvm-cov, keeping a JUnit report whether or not the tests pass, for the hub's Tests tab.
53 +
- **Pushing a new bookmark sends only what the remote lacks (#142).** A push was worked out against the remote's heads for that bookmark alone, so a bookmark new to the remote sent its whole history again: slow on a big repository, and OxygenHub's push protection scanned all of it (and refused a proposal's bookmark for an example key in a change main had had for weeks). The heads of every bookmark the remote lists now count as already there.
54 +
- **A binary file's or subrepo pin's conflict can be settled from the command line (#140).** Two changes that replaced the same binary file, or moved the same subrepo pin, leave a replace conflict with no markers to edit, and every commit was refused until it was settled, which only a hosted edit could do. `oxy resolve PATH…` now settles it with what the working copy holds at the path (the file, or the subrepo's pin): the next commit is a resolution replacing every version with it. A path with no replace conflict is refused, and a choice is forgotten once committed.
55 +
- **The object store works where hard links are refused (#141).** Android forbids apps hard links in their own storage, so the OxygenHub app's phone runner could clone nothing (`Permission denied`). Where a link is refused, objects are renamed into place with `renameat2(RENAME_NOREPLACE)`: as atomic, and still never over a name that is taken.
56 +
- **Rendering a big tree is many times faster, and clone, pull, switch and `oxy workspace add` stop rendering it twice (#137).** Measured on the VS Code history's depth-0 clone (19,545 files, 166,425 changes), `oxy workspace add` took 467 s on Earth and 176 s on an 8-core workstation, 11 GB at its peak; it now takes 7.5 s and 1.8 GB on the workstation. Three causes: (1) whether one change depends on another, which rendering asks about every deleted line, was answered by a depth-first walk that compared a change with the one sought only on coming back up to it, so it went through thousands of changes first although the answer is nearly always a direct dependency (spec §8.1): 262,678 questions took 4.4 billion steps and 838 CPU-seconds, and now take 5 CPU-seconds, with the same answers; (2) finding the subrepos rendered every file of the tree, after every clone, pull, switch and `workspace add`, where reading the tree and the subrepos' pins is enough; (3) a new workspace started on the target state with an empty directory, so the checkout's first snapshot found the whole tree deleted and rendered it to say so. `OXY_TIMINGS=1` now also says how a checkout's files were ordered (`graph: single order`…) and how its blobs were read.
57 +
- **`oxy serve` takes a push of more than 2 MB (#138).** It set no request limit of its own, so axum's default of 2 MB applied, and a larger push part was cut off with `Broken pipe` at the client and nothing in the server's log. It now takes up to 4 GiB a request (`oxy_server::BODY_LIMIT`), OxygenHub's default `push_body_limit`; OxygenHub was not affected.
58 +
- **A push no longer sends a replaced or removed binary file's old content again (#136).** A bundle carried both the old and the new blob of every binary file a change replaces, so a change removing ~1,100 tracked cache files (oxygenhub-android's set-up) pushed all of them again, over 100 MB, though the hub stored each when it was added. The old blob is always the new blob of a change the replacement depends on, which the receiver already holds or which the same bundle carries, so pushes and fetch answers now carry only the new one.
59 +
60 +
## 0.6.0 (2026-10-25)
61 +
62 +
- **A shallow clone of a big repository works, and quickly (#137).** `oxy clone --depth 0` of the VS Code history failed after eleven minutes ("object … not found"): the blobs of the state's binary files are named only by the changes that set them, which a depth-0 clone does not take, so nothing brought them. They are now fetched with the checkpoint (in parts), or left to be fetched on read under `--max-blob`. The checkpoint itself was stored object by object, each written loose and flushed (57,498 objects at 27 a second); it is now stored in one batch, as a fetch's objects are.
63 +
- **A checkpoint is served faster, and says where its time goes (#137).** A server gathered a checkpoint's graph blobs and their chunks one read at a time: six minutes for the VS Code state's 57,000. They are now read in two parallel batches. `OXY_TIMINGS=1` now times the checkpoint's phases on both sides (`checkpoint: gather its objects`, `serve: encode the checkpoint`, `remote: checkpoint answer`, `shallow: store the checkpoint` and the rest).
64 +
- **A depth-0 clone takes no change (#137).** `oxy clone --depth 0` took the head changes themselves (the VS Code import's main has 33,835 heads), though the checkpoint already holds every change's metadata, so depth 0 now takes none (spec §17's "a checkpoint plus zero history"): its heads become the shallow boundary, and commit, push and pull on top work as before (a shallow repository takes and builds on changes below what it cloned, which the remote holds).
65 +
- **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.
66 +
- **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.
67 +
- **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.
68 +
- **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.
69 +
70 +
## 0.5.1 (2026-10-20)
71 +
72 +
- **oxy 0.5.0 and older can clone a repository again after a keyless agent tagged it (#131).** Since #129 a release tag may be signed by a ten-minute certified key, which released clients do not know how to authorize: they refused the tag (`key … may not create tags/v0.5.0 under this repository's policy`), and with it the whole clone, fetch or `oxy submodule update`. Clients now send `User-Agent: oxy/<version>` and declare what they understand in `Oxy-Features` (`certified-tags` so far; docs/protocol.md, *Client capabilities*). Under a policy, a server leaves out of its refs answer to a client that does not declare `certified-tags` every tag that only a certificate authorizes, so that client clones the rest as if the tag did not exist. The rule is `Repo::refs_for_client` in oxy-core; `oxy-server` gains a `Backend::adjust_refs` hook, given the request headers, whose default applies it, so `oxy serve` and every embedding host do.
73 +
- **Keyless agents can create release tags (#129).** `oxy tag create` and `oxy push` under a keyless home was refused "unknown key": the push did not carry the tag signer's identity and certificate, and tags were authorized only by keys the policy names, never by a certificate under a subject grant. The push now carries them, and a receiver authorizes a short-lived key's tag by a subject grant at its own clock, as it does a bookmark move, and records when it accepted it (OxygenHub signs that with its key). A fetch checks such a tag at that acceptance, or at its integrated time when the hub's log gives one, so a release tag made with a ten-minute key is still fetched months later. A fetch never checks it at the time the tag claims: without an acceptance the tag is left out with a warning (`tags/NAME not updated`), and the rest is fetched. A client keeps the acceptance and serves and pushes it with the tag, so a tag passed on to another remote, or cloned from a clone, keeps its time. The wire change is additive: tags in `refs` and `push` gain keys 4 to 6 (acceptance, log place, cosigned checkpoints), which older readers ignore.
4 74
5 75
## 0.5.0 (2026-10-20)
6 76
…
249 unchanged lines
modified Cargo.lock +15 −4
…
571 unchanged lines
572 572
]
573 573
574 574
[[package]]
575 +
name = "cylinder"
576 +
version = "0.11.0"
577 +
dependencies = [
578 +
 "blake3",
579 +
 "oxy-core",
580 +
 "tempfile",
581 +
 "thiserror 1.0.69",
582 +
]
583 +
584 +
[[package]]
575 585
name = "data-encoding"
576 586
version = "2.11.1"
577 587
source = "registry+https://github.com/rust-lang/crates.io-index"
…
911 unchanged lines
1489 1499
1490 1500
[[package]]
1491 1501
name = "oxy"
1492 -
version = "0.5.0"
1502 +
version = "0.11.0"
1493 1503
dependencies = [
1494 1504
 "anyhow",
1495 1505
 "clap",
1496 1506
 "clap_complete",
1497 1507
 "clap_mangen",
1498 1508
 "console",
1509 +
 "cylinder",
1499 1510
 "indicatif",
1500 1511
 "libc",
1501 1512
 "mimalloc",
…
6 unchanged lines
1508 1519
1509 1520
[[package]]
1510 1521
name = "oxy-core"
1511 -
version = "0.5.0"
1522 +
version = "0.11.0"
1512 1523
dependencies = [
1513 1524
 "blake3",
1514 1525
 "ed25519-dalek",
…
20 unchanged lines
1535 1546
1536 1547
[[package]]
1537 1548
name = "oxy-server"
1538 -
version = "0.5.0"
1549 +
version = "0.11.0"
1539 1550
dependencies = [
1540 1551
 "axum",
1541 1552
 "axum-server",
…
7 unchanged lines
1549 1560
1550 1561
[[package]]
1551 1562
name = "oxy-strata"
1552 -
version = "0.5.0"
1563 +
version = "0.11.0"
1553 1564
dependencies = [
1554 1565
 "async-trait",
1555 1566
 "bytes",
…
1419 unchanged lines
modified Cargo.toml +3 −2
1 1
[workspace]
2 -
members = ["crates/oxy-core", "crates/oxy-server", "crates/oxy-strata", "."]
2 +
members = ["crates/oxy-core", "crates/oxy-server", "crates/oxy-strata", "crates/cylinder", "."]
3 3
resolver = "2"
4 4
5 5
[workspace.package]
6 -
version = "0.5.0"
6 +
version = "0.11.0"
7 7
edition = "2021"
8 8
license = "MIT OR Apache-2.0"
9 9
…
13 unchanged lines
23 23
oxy-core = { path = "crates/oxy-core" }
24 24
oxy-server = { path = "crates/oxy-server" }
25 25
oxy-strata = { path = "crates/oxy-strata" }
26 +
cylinder = { path = "crates/cylinder" }
26 27
clap = { version = "4", features = ["derive"] }
27 28
anyhow = "1"
28 29
libc = "0.2"
…
19 unchanged lines
modified README.md +7 −1
…
15 unchanged lines
16 16
17 17
Every change the agent makes is attributed to you and signed by the agent. `oxy log`, `oxy verify` and the hub therefore show who did the work, such as "Andy McCall · via Claude Code", and a policy can require that a person has approved it before it lands.
18 18
19 +
An agent can also work through `oxy mcp`, an MCP server for the repository: typed tools instead of a shell, signing as the identity it was started with, and nothing destructive unless you allow it (see [docs/mcp.md](docs/mcp.md)).
20 +
19 21
See [docs/ai_native_agent_workflows.md](docs/ai_native_agent_workflows.md) for the design and [docs/guide.md](docs/guide.md) §9 for the setup.
20 22
21 23
## What is different
…
13 unchanged lines
35 37
36 38
## Install
37 39
38 -
Builds for Linux, macOS and Windows are on the [releases page](https://oxygenhub.io/andymccall/oxygen/releases). These are early releases: the macOS and Windows builds are not signed and have had little testing.
40 +
Builds for Linux, macOS, Windows and Haiku are on the [releases page](https://oxygenhub.io/andymccall/oxygen/releases). These are early releases: the macOS and Windows builds are not signed and have had little testing.
39 41
40 42
- **Debian, Ubuntu and other Linux with apt** (x86_64, arm64 or riscv64): add the repository at https://oxygenhub.io/apt and install with apt, so `apt upgrade` keeps oxy current:
41 43
…
13 unchanged lines
55 57
- **Other Linux:** unpack `oxy-VERSION-linux-x86_64.tar.gz` (or `-linux-arm64`) and put `oxy` on your `PATH`.
56 58
- **macOS** (Apple Silicon or Intel): unpack `oxy-VERSION-macos-arm64.tar.gz` (or `-macos-x86_64`), then `xattr -d com.apple.quarantine oxy`, since the build is not notarised, and put it on your `PATH`.
57 59
- **Windows:** unzip `oxy-VERSION-windows-x86_64.zip` and put the folder on your `PATH`. SmartScreen may warn on first run, since the build is not signed.
60 +
- **Haiku** (R1/beta6, x86_64): unpack `oxy-VERSION-haiku-x86_64.tar.gz` and copy `oxy` to `~/config/non-packaged/bin`. It is built in a Haiku guest rather than by OxygenFlow, so it has its own `SHA256SUMS-haiku` and no build attestation.
58 61
59 62
`oh`, the OxygenHub command line, is released the same way from [OxygenHub's releases](https://oxygenhub.io/andymccall/oxygenhub/releases).
60 63
…
8 unchanged lines
69 72
70 73
The binary is `target/release/oxy`.
71 74
75 +
On Haiku, install `rust_bin` and `zstd_devel` with pkgman and build with `ZSTD_SYS_USE_PKG_CONFIG=1 cargo build --release`: zstd-sys then links Haiku's libzstd, since a system library also references zstd and ld refuses the bundled copy's hidden symbols.
76 +
72 77
**Changelog:** a commit that changes code adds a line under `## Unreleased` in CHANGELOG.md (Added, Changed, Fixed or Security, with the issue number); versions change only at release. `scripts/changelog-check.sh [PATH...]` checks it for the paths a commit takes (the working copy's changes without any) and runs in the gate; `NO_CHANGELOG=1` for code changes users won't notice.
73 78
74 79
## Quick start
…
77 unchanged lines
152 157
| `docs/strata.md` | Keeping a repository on your own storage: S3, GCS, Azure, a directory |
153 158
| `docs/protocol.md` | Wire protocol for other implementers |
154 159
| `docs/prompt.md` | Shell prompt integration |
160 +
| `docs/mcp.md` | `oxy mcp`: the repository as tools for AI agents |
155 161
156 162
## Licence
157 163
…
1 unchanged lines
added crates/cylinder/Cargo.toml +14 −0
1 +
[package]
2 +
name = "cylinder"
3 +
version.workspace = true
4 +
edition.workspace = true
5 +
license.workspace = true
6 +
description = "Cylinder: artifacts as trees of Oxygen chunks (docs/cylinder.md)"
7 +
8 +
[dependencies]
9 +
oxy-core = { path = "../oxy-core" }
10 +
blake3 = "1"
11 +
thiserror = "1"
12 +
13 +
[dev-dependencies]
14 +
tempfile = "3"
added crates/cylinder/src/lib.rs +454 −0
1 +
//! Cylinder, step C1 (docs/cylinder.md §4.1): an **artifact** is an
2 +
//! immutable, content-addressed object naming a tree of files, each kept as
3 +
//! an ordinary Oxygen blob of FastCDC BLAKE3 chunks. An image rebuilt with
4 +
//! one changed file moves only that file's changed chunks, and a binary in
5 +
//! a release archive and in a runner image is the same chunks.
6 +
//!
7 +
//! The model is oxy-core's ([`oxy_core::artifact`]): an `artifact` object
8 +
//! in canonical CBOR, named by its object hash, whose tree is a blob. This
9 +
//! crate packs a directory into one, opens, verifies and unpacks it, and
10 +
//! keeps it: `.oxy/artifacts/NAME` records each artifact this repository
11 +
//! keeps, which garbage collection keeps with its tree and files
12 +
//! ([`roots`]).
13 +
14 +
use oxy_core::hash::{ObjectRef, ObjectType};
15 +
use oxy_core::repo::Repo;
16 +
use std::collections::BTreeMap;
17 +
use std::path::{Path, PathBuf};
18 +
19 +
pub use oxy_core::artifact::{
20 +
    Artifact, DirTree, Entry, Kind, MODE_EXEC, MODE_FILE, MODE_LINK, VERSION,
21 +
};
22 +
23 +
#[derive(Debug, thiserror::Error)]
24 +
pub enum Error {
25 +
    #[error(transparent)]
26 +
    Repo(#[from] oxy_core::repo::RepoError),
27 +
    #[error("io: {0}")]
28 +
    Io(#[from] std::io::Error),
29 +
    #[error("not an artifact: {0}")]
30 +
    Object(#[from] oxy_core::objects::ObjectError),
31 +
    #[error("store: {0}")]
32 +
    Store(#[from] oxy_core::store::StoreError),
33 +
    #[error("{0}")]
34 +
    Bad(String),
35 +
}
36 +
37 +
pub type Result<T> = std::result::Result<T, Error>;
38 +
39 +
fn bad<T>(msg: impl Into<String>) -> Result<T> {
40 +
    Err(Error::Bad(msg.into()))
41 +
}
42 +
43 +
/// What to pack a directory as.
44 +
#[derive(Clone, Debug)]
45 +
pub struct Spec {
46 +
    pub kind: Kind,
47 +
    pub arch: Option<String>,
48 +
    pub os: Option<String>,
49 +
    pub entrypoint: Option<Vec<String>>,
50 +
    pub env: BTreeMap<String, String>,
51 +
    pub labels: BTreeMap<String, String>,
52 +
}
53 +
54 +
impl Spec {
55 +
    pub fn new(kind: Kind) -> Spec {
56 +
        Spec {
57 +
            kind,
58 +
            arch: None,
59 +
            os: None,
60 +
            entrypoint: None,
61 +
            env: BTreeMap::new(),
62 +
            labels: BTreeMap::new(),
63 +
        }
64 +
    }
65 +
}
66 +
67 +
/// An artifact kept in a repository, with its name.
68 +
#[derive(Clone, Debug)]
69 +
pub struct Kept {
70 +
    pub name: String,
71 +
    pub artifact: Artifact,
72 +
    /// Its object (its name, as a reference).
73 +
    pub object: ObjectRef,
74 +
}
75 +
76 +
fn kept_dir(repo: &Repo) -> PathBuf {
77 +
    repo.oxy_dir().join("artifacts")
78 +
}
79 +
80 +
/// Every file under `dir`, by relative path: regular files and links, in
81 +
/// path order. `.oxy` and `.git` directories are not part of an artifact.
82 +
fn walk(dir: &Path) -> Result<Vec<(String, PathBuf, std::fs::Metadata)>> {
83 +
    let mut out = Vec::new();
84 +
    let mut stack = vec![(dir.to_path_buf(), String::new())];
85 +
    while let Some((d, rel)) = stack.pop() {
86 +
        for e in std::fs::read_dir(&d)? {
87 +
            let e = e?;
88 +
            let name = e
89 +
                .file_name()
90 +
                .into_string()
91 +
                .map_err(|n| Error::Bad(format!("{n:?} is not UTF-8")))?;
92 +
            let path = if rel.is_empty() {
93 +
                name.clone()
94 +
            } else {
95 +
                format!("{rel}/{name}")
96 +
            };
97 +
            let md = std::fs::symlink_metadata(e.path())?;
98 +
            if md.is_dir() {
99 +
                if name != ".oxy" && name != ".git" {
100 +
                    stack.push((e.path(), path));
101 +
                }
102 +
            } else if md.is_file() || md.file_type().is_symlink() {
103 +
                out.push((path, e.path(), md));
104 +
            }
105 +
        }
106 +
    }
107 +
    out.sort_by(|a, b| a.0.cmp(&b.0));
108 +
    Ok(out)
109 +
}
110 +
111 +
#[cfg(unix)]
112 +
fn mode_of(md: &std::fs::Metadata) -> u32 {
113 +
    use std::os::unix::fs::PermissionsExt;
114 +
    if md.file_type().is_symlink() {
115 +
        MODE_LINK
116 +
    } else if md.permissions().mode() & 0o111 != 0 {
117 +
        MODE_EXEC
118 +
    } else {
119 +
        MODE_FILE
120 +
    }
121 +
}
122 +
123 +
#[cfg(not(unix))]
124 +
fn mode_of(md: &std::fs::Metadata) -> u32 {
125 +
    if md.file_type().is_symlink() {
126 +
        MODE_LINK
127 +
    } else {
128 +
        MODE_FILE
129 +
    }
130 +
}
131 +
132 +
/// Pack the files under `dir` into an artifact of `spec`'s kind in
133 +
/// `repo`'s store: each file a blob, so files and chunks the store holds
134 +
/// already cost nothing; then its tree and its encoding, kept under its
135 +
/// name.
136 +
pub fn pack(repo: &Repo, dir: &Path, spec: &Spec) -> Result<Kept> {
137 +
    if !dir.is_dir() {
138 +
        return bad(format!("{} is not a directory", dir.display()));
139 +
    }
140 +
    let mut tree = DirTree::default();
141 +
    for (path, full, md) in walk(dir)? {
142 +
        let mode = mode_of(&md);
143 +
        let bytes = if mode == MODE_LINK {
144 +
            std::fs::read_link(&full)?
145 +
                .to_string_lossy()
146 +
                .into_owned()
147 +
                .into_bytes()
148 +
        } else {
149 +
            std::fs::read(&full)?
150 +
        };
151 +
        let blob = repo.store_blob(&bytes)?;
152 +
        tree.entries.push(Entry {
153 +
            path,
154 +
            mode,
155 +
            blob,
156 +
            size: bytes.len() as u64,
157 +
        });
158 +
    }
159 +
    let artifact = Artifact {
160 +
        kind: spec.kind,
161 +
        arch: spec.arch.clone(),
162 +
        os: spec.os.clone(),
163 +
        tree: repo.store_blob(&tree.encode())?,
164 +
        entrypoint: spec.entrypoint.clone(),
165 +
        env: spec.env.clone(),
166 +
        labels: spec.labels.clone(),
167 +
        total_size: tree.entries.iter().map(|e| e.size).sum(),
168 +
        receipt: None,
169 +
    };
170 +
    let object = repo.store().put(ObjectType::Artifact, &artifact.encode())?;
171 +
    let name = object.to_hex();
172 +
    std::fs::create_dir_all(kept_dir(repo))?;
173 +
    std::fs::write(kept_dir(repo).join(&name), b"")?;
174 +
    Ok(Kept {
175 +
        name,
176 +
        artifact,
177 +
        object,
178 +
    })
179 +
}
180 +
181 +
/// The names of the artifacts kept in `repo`, sorted.
182 +
pub fn list(repo: &Repo) -> Result<Vec<String>> {
183 +
    let mut out = Vec::new();
184 +
    match std::fs::read_dir(kept_dir(repo)) {
185 +
        Ok(rd) => {
186 +
            for e in rd {
187 +
                let n = e?.file_name().to_string_lossy().into_owned();
188 +
                if n.len() == 64 && n.bytes().all(|b| b.is_ascii_hexdigit()) {
189 +
                    out.push(n);
190 +
                }
191 +
            }
192 +
        }
193 +
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
194 +
        Err(e) => return Err(e.into()),
195 +
    }
196 +
    out.sort();
197 +
    Ok(out)
198 +
}
199 +
200 +
/// The kept artifact `name` names, or a unique prefix of it (at least six
201 +
/// characters), read from the store, which checks it against its name.
202 +
pub fn open(repo: &Repo, name: &str) -> Result<Kept> {
203 +
    let name = name.to_ascii_lowercase();
204 +
    if name.len() < 6 {
205 +
        return bad("name an artifact by at least six characters of its name");
206 +
    }
207 +
    let found: Vec<String> = list(repo)?
208 +
        .into_iter()
209 +
        .filter(|n| n.starts_with(&name))
210 +
        .collect();
211 +
    let full = match found.as_slice() {
212 +
        [one] => one.clone(),
213 +
        [] => {
214 +
            return bad(format!(
215 +
                "no artifact {name} here; `oxy artifact list` shows them"
216 +
            ))
217 +
        }
218 +
        _ => {
219 +
            return bad(format!(
220 +
                "{name} names {} artifacts: give more of the name",
221 +
                found.len()
222 +
            ))
223 +
        }
224 +
    };
225 +
    let object = ObjectRef::from_hex(&full).map_or_else(
226 +
        || bad(format!("artifact {full}: its record is damaged")),
227 +
        Ok,
228 +
    )?;
229 +
    // The store checks the bytes against the object's name as it reads.
230 +
    let bytes = repo.store().get_typed(&object, ObjectType::Artifact)?;
231 +
    Ok(Kept {
232 +
        name: full,
233 +
        artifact: Artifact::decode(&bytes)?,
234 +
        object,
235 +
    })
236 +
}
237 +
238 +
/// An artifact's tree.
239 +
pub fn tree(repo: &Repo, a: &Artifact) -> Result<DirTree> {
240 +
    Ok(DirTree::decode(&repo.read_blob(&a.tree)?)?)
241 +
}
242 +
243 +
/// Check that every file of `k` is in the store whole: its bytes read, at
244 +
/// the size its tree says. Answers how many files were read.
245 +
pub fn verify(repo: &Repo, k: &Kept) -> Result<usize> {
246 +
    let t = tree(repo, &k.artifact)?;
247 +
    let mut total = 0;
248 +
    for e in &t.entries {
249 +
        let n = repo.read_blob(&e.blob)?.len() as u64;
250 +
        if n != e.size {
251 +
            return bad(format!(
252 +
                "{}: {n} bytes, but the tree says {}",
253 +
                e.path, e.size
254 +
            ));
255 +
        }
256 +
        total += n;
257 +
    }
258 +
    if total != k.artifact.total_size {
259 +
        return bad(format!(
260 +
            "the files are {total} bytes together, but the artifact says {}",
261 +
            k.artifact.total_size
262 +
        ));
263 +
    }
264 +
    Ok(t.entries.len())
265 +
}
266 +
267 +
/// Write `k`'s files under `dest`, which must not exist or be empty.
268 +
pub fn unpack(repo: &Repo, k: &Kept, dest: &Path) -> Result<usize> {
269 +
    if dest.exists() && std::fs::read_dir(dest)?.next().is_some() {
270 +
        return bad(format!("{} is not empty", dest.display()));
271 +
    }
272 +
    let t = tree(repo, &k.artifact)?;
273 +
    for e in &t.entries {
274 +
        let out = dest.join(&e.path);
275 +
        if let Some(p) = out.parent() {
276 +
            std::fs::create_dir_all(p)?;
277 +
        }
278 +
        let bytes = repo.read_blob(&e.blob)?;
279 +
        if e.mode == MODE_LINK {
280 +
            #[cfg(unix)]
281 +
            std::os::unix::fs::symlink(String::from_utf8_lossy(&bytes).as_ref(), &out)?;
282 +
            #[cfg(not(unix))]
283 +
            std::fs::write(&out, &bytes)?;
284 +
            continue;
285 +
        }
286 +
        std::fs::write(&out, &bytes)?;
287 +
        #[cfg(unix)]
288 +
        if e.mode == MODE_EXEC {
289 +
            use std::os::unix::fs::PermissionsExt;
290 +
            std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o755))?;
291 +
        }
292 +
    }
293 +
    Ok(t.entries.len())
294 +
}
295 +
296 +
/// The blobs garbage collection must keep for the kept artifacts: each
297 +
/// one's encoding, its tree and its files (`Repo::gc_keeping`).
298 +
pub fn roots(repo: &Repo) -> Result<Vec<ObjectRef>> {
299 +
    let mut out = Vec::new();
300 +
    for name in list(repo)? {
301 +
        let k = open(repo, &name)?;
302 +
        out.push(k.object);
303 +
        out.push(k.artifact.tree);
304 +
        out.extend(tree(repo, &k.artifact)?.entries.iter().map(|e| e.blob));
305 +
    }
306 +
    Ok(out)
307 +
}
308 +
309 +
#[cfg(test)]
310 +
mod tests {
311 +
    use super::*;
312 +
    use oxy_core::cbor::{self, Value};
313 +
314 +
    fn repo(dir: &Path) -> Repo {
315 +
        Repo::init(&dir.join("repo")).unwrap()
316 +
    }
317 +
318 +
    #[test]
319 +
    fn a_directory_packs_into_an_artifact_and_back() {
320 +
        let t = tempfile::tempdir().unwrap();
321 +
        let r = repo(t.path());
322 +
        let src = t.path().join("out");
323 +
        std::fs::create_dir_all(src.join("bin")).unwrap();
324 +
        std::fs::write(src.join("README"), b"hello\n").unwrap();
325 +
        std::fs::write(src.join("bin/tool"), vec![7u8; 300_000]).unwrap();
326 +
        #[cfg(unix)]
327 +
        {
328 +
            use std::os::unix::fs::PermissionsExt;
329 +
            std::fs::set_permissions(src.join("bin/tool"), std::fs::Permissions::from_mode(0o755))
330 +
                .unwrap();
331 +
            std::os::unix::fs::symlink("bin/tool", src.join("tool")).unwrap();
332 +
        }
333 +
        let mut spec = Spec::new(Kind::Archive);
334 +
        spec.arch = Some("x86_64".into());
335 +
        spec.labels.insert("version".into(), "0.1.0".into());
336 +
        let k = pack(&r, &src, &spec).unwrap();
337 +
        assert_eq!(k.name, k.artifact.name().to_hex());
338 +
        assert_eq!(
339 +
            k.artifact.total_size,
340 +
            300_006 + if cfg!(unix) { 8 } else { 0 }
341 +
        );
342 +
        assert_eq!(list(&r).unwrap(), vec![k.name.clone()]);
343 +
344 +
        let again = open(&r, &k.name[..10]).unwrap();
345 +
        assert_eq!(again.artifact, k.artifact);
346 +
        let paths: Vec<(String, u32)> = tree(&r, &again.artifact)
347 +
            .unwrap()
348 +
            .entries
349 +
            .into_iter()
350 +
            .map(|e| (e.path, e.mode))
351 +
            .collect();
352 +
        assert_eq!(paths[0], ("README".into(), MODE_FILE));
353 +
        assert_eq!(
354 +
            paths[1],
355 +
            (
356 +
                "bin/tool".into(),
357 +
                if cfg!(unix) { MODE_EXEC } else { MODE_FILE }
358 +
            )
359 +
        );
360 +
        assert_eq!(verify(&r, &again).unwrap(), paths.len());
361 +
362 +
        let dest = t.path().join("back");
363 +
        unpack(&r, &again, &dest).unwrap();
364 +
        assert_eq!(
365 +
            std::fs::read(dest.join("bin/tool")).unwrap(),
366 +
            vec![7u8; 300_000]
367 +
        );
368 +
        #[cfg(unix)]
369 +
        assert_eq!(
370 +
            std::fs::read_link(dest.join("tool")).unwrap(),
371 +
            Path::new("bin/tool")
372 +
        );
373 +
        assert!(unpack(&r, &again, &dest)
374 +
            .unwrap_err()
375 +
            .to_string()
376 +
            .contains("not empty"));
377 +
378 +
        // The same files pack to the same name; garbage collection keeps it.
379 +
        assert_eq!(pack(&r, &src, &spec).unwrap().name, k.name);
380 +
        let keep = roots(&r).unwrap();
381 +
        assert!(keep.contains(&k.object) && keep.contains(&k.artifact.tree));
382 +
        r.gc_keeping(true, &keep, &mut |_| {}).unwrap();
383 +
        // An artifact is an object of its own, which fsck reads and accepts.
384 +
        let report = r.fsck().unwrap();
385 +
        let errors: Vec<_> = report
386 +
            .problems
387 +
            .iter()
388 +
            .filter(|p| p.severity == oxy_core::repo::Severity::Error)
389 +
            .collect();
390 +
        assert!(errors.is_empty(), "{errors:?}");
391 +
        assert_eq!(
392 +
            verify(&r, &open(&r, &k.name).unwrap()).unwrap(),
393 +
            paths.len()
394 +
        );
395 +
    }
396 +
397 +
    #[test]
398 +
    fn encodings_are_canonical_and_checked() {
399 +
        let a = Artifact {
400 +
            kind: Kind::RunnerImage,
401 +
            arch: Some("riscv64".into()),
402 +
            os: Some("linux".into()),
403 +
            tree: ObjectRef([1; 32]),
404 +
            entrypoint: Some(vec!["/sbin/init".into()]),
405 +
            env: [("PATH".to_string(), "/bin".to_string())].into(),
406 +
            labels: BTreeMap::new(),
407 +
            total_size: 42,
408 +
            receipt: Some(ObjectRef([2; 32])),
409 +
        };
410 +
        let bytes = a.encode();
411 +
        assert_eq!(Artifact::decode(&bytes).unwrap(), a);
412 +
        assert_eq!(
413 +
            a.name(),
414 +
            oxy_core::hash::hash_object(ObjectType::Artifact, &bytes)
415 +
        );
416 +
        assert!(Kind::parse("tarball")
417 +
            .unwrap_err()
418 +
            .to_string()
419 +
            .contains("runner-image"));
420 +
421 +
        let wrong = |v: Value| Artifact::decode(&cbor::encode(&v)).unwrap_err().to_string();
422 +
        assert!(wrong(Value::map([(0, Value::Uint(2))])).contains("version"));
423 +
        assert!(wrong(Value::map([(0, Value::Uint(1)), (10, Value::Null)])).contains("key 10"));
424 +
425 +
        let t = DirTree {
426 +
            entries: vec![Entry {
427 +
                path: "a".into(),
428 +
                mode: MODE_FILE,
429 +
                blob: ObjectRef([3; 32]),
430 +
                size: 1,
431 +
            }],
432 +
        };
433 +
        assert_eq!(DirTree::decode(&t.encode()).unwrap(), t);
434 +
        let entry = |p: &str, mode: u32| {
435 +
            Value::Array(vec![
436 +
                Value::Text(p.into()),
437 +
                Value::Uint(mode as u64),
438 +
                Value::Bytes(vec![0; 32]),
439 +
                Value::Uint(0),
440 +
            ])
441 +
        };
442 +
        let tree = |items: Vec<Value>| {
443 +
            DirTree::decode(&cbor::encode(&Value::map([
444 +
                (0, Value::Uint(1)),
445 +
                (1, Value::Array(items)),
446 +
            ])))
447 +
            .unwrap_err()
448 +
            .to_string()
449 +
        };
450 +
        assert!(tree(vec![entry("../x", MODE_FILE)]).contains("clean"));
451 +
        assert!(tree(vec![entry("b", MODE_FILE), entry("a", MODE_FILE)]).contains("order"));
452 +
        assert!(tree(vec![entry("a", 0o040000)]).contains("mode"));
453 +
    }
454 +
}
added crates/oxy-core/src/artifact.rs +303 −0
1 +
//! Artifacts (Cylinder, docs/cylinder.md §4.1, oxygen#108): an immutable
2 +
//! object naming a tree of files, each an ordinary blob of FastCDC BLAKE3
3 +
//! chunks, so an image rebuilt with one changed file moves only that file's
4 +
//! changed chunks, and a binary in an archive and in an image is the same
5 +
//! chunks. An artifact is an object of its own (`artifact`) in canonical
6 +
//! CBOR; its tree is a blob. The `cylinder` crate packs, unpacks and keeps
7 +
//! them.
8 +
9 +
use crate::cbor::{self, Value};
10 +
use crate::hash::{hash_object, ObjectRef, ObjectType};
11 +
use crate::objects::{schema, ObjectError};
12 +
use std::collections::BTreeMap;
13 +
14 +
type R<T> = std::result::Result<T, ObjectError>;
15 +
16 +
/// The encoding's version this crate writes and reads.
17 +
pub const VERSION: u64 = 1;
18 +
19 +
/// What an artifact is for.
20 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21 +
pub enum Kind {
22 +
    Container,
23 +
    RunnerImage,
24 +
    Package,
25 +
    Archive,
26 +
    Report,
27 +
}
28 +
29 +
impl Kind {
30 +
    pub const ALL: [Kind; 5] = [
31 +
        Kind::Container,
32 +
        Kind::RunnerImage,
33 +
        Kind::Package,
34 +
        Kind::Archive,
35 +
        Kind::Report,
36 +
    ];
37 +
38 +
    pub fn as_str(self) -> &'static str {
39 +
        match self {
40 +
            Kind::Container => "container",
41 +
            Kind::RunnerImage => "runner-image",
42 +
            Kind::Package => "package",
43 +
            Kind::Archive => "archive",
44 +
            Kind::Report => "report",
45 +
        }
46 +
    }
47 +
48 +
    pub fn parse(s: &str) -> R<Kind> {
49 +
        Kind::ALL.into_iter().find(|k| k.as_str() == s).map_or_else(
50 +
            || {
51 +
                schema(format!(
52 +
                    "{s:?} is not a kind of artifact: {}",
53 +
                    Kind::ALL.map(|k| k.as_str()).join(", ")
54 +
                ))
55 +
            },
56 +
            Ok,
57 +
        )
58 +
    }
59 +
}
60 +
61 +
/// A file's mode in a tree: a regular file, an executable one, or a
62 +
/// symbolic link (whose blob is its target).
63 +
pub const MODE_FILE: u32 = 0o100644;
64 +
pub const MODE_EXEC: u32 = 0o100755;
65 +
pub const MODE_LINK: u32 = 0o120000;
66 +
67 +
/// A file of an artifact's tree.
68 +
#[derive(Clone, Debug, PartialEq, Eq)]
69 +
pub struct Entry {
70 +
    /// `/`-separated, relative, never `.` or `..` parts.
71 +
    pub path: String,
72 +
    pub mode: u32,
73 +
    pub blob: ObjectRef,
74 +
    pub size: u64,
75 +
}
76 +
77 +
/// An artifact's files, in path order: paths, modes, blobs and sizes.
78 +
#[derive(Clone, Debug, Default, PartialEq, Eq)]
79 +
pub struct DirTree {
80 +
    pub entries: Vec<Entry>,
81 +
}
82 +
83 +
fn path_ok(p: &str) -> bool {
84 +
    !p.is_empty()
85 +
        && !p.starts_with('/')
86 +
        && p.split('/').all(|s| !s.is_empty() && s != "." && s != "..")
87 +
}
88 +
89 +
fn obj(r: &ObjectRef) -> Value {
90 +
    Value::Bytes(r.0.to_vec())
91 +
}
92 +
93 +
fn get_obj(v: &Value, what: &str) -> R<ObjectRef> {
94 +
    match v.as_bytes() {
95 +
        Some(b) if b.len() == 32 => {
96 +
            let mut a = [0u8; 32];
97 +
            a.copy_from_slice(b);
98 +
            Ok(ObjectRef(a))
99 +
        }
100 +
        _ => schema(format!("{what} is not an object reference")),
101 +
    }
102 +
}
103 +
104 +
fn get_text(v: &Value, what: &str) -> R<String> {
105 +
    v.as_text()
106 +
        .map(str::to_string)
107 +
        .map_or_else(|| schema(format!("{what} is not text")), Ok)
108 +
}
109 +
110 +
fn get_uint(v: &Value, what: &str) -> R<u64> {
111 +
    v.as_uint()
112 +
        .map_or_else(|| schema(format!("{what} is not a number")), Ok)
113 +
}
114 +
115 +
impl DirTree {
116 +
    pub fn encode(&self) -> Vec<u8> {
117 +
        let entries = self
118 +
            .entries
119 +
            .iter()
120 +
            .map(|e| {
121 +
                Value::Array(vec![
122 +
                    Value::Text(e.path.clone()),
123 +
                    Value::Uint(e.mode as u64),
124 +
                    obj(&e.blob),
125 +
                    Value::Uint(e.size),
126 +
                ])
127 +
            })
128 +
            .collect();
129 +
        cbor::encode(&Value::map([
130 +
            (0, Value::Uint(VERSION)),
131 +
            (1, Value::Array(entries)),
132 +
        ]))
133 +
    }
134 +
135 +
    /// Read and check a tree: a known version, clean paths, each once, in
136 +
    /// order, with a known mode.
137 +
    pub fn decode(bytes: &[u8]) -> R<DirTree> {
138 +
        let v = cbor::decode(bytes)?;
139 +
        let m = v.as_map().map_or_else(|| schema("a tree is a map"), Ok)?;
140 +
        if m.get(&0).and_then(Value::as_uint) != Some(VERSION) {
141 +
            return schema(format!(
142 +
                "tree version {:?} is not known; this reads {VERSION}",
143 +
                m.get(&0)
144 +
            ));
145 +
        }
146 +
        let mut entries = Vec::new();
147 +
        for item in m.get(&1).and_then(Value::as_array).unwrap_or_default() {
148 +
            let [p, mode, blob, size] = item.as_array().unwrap_or_default() else {
149 +
                return schema("a tree entry is [path, mode, blob, size]");
150 +
            };
151 +
            let path = get_text(p, "a path")?;
152 +
            let mode = get_uint(mode, "a mode")? as u32;
153 +
            if !path_ok(&path) {
154 +
                return schema(format!("{path:?} is not a clean relative path"));
155 +
            }
156 +
            if ![MODE_FILE, MODE_EXEC, MODE_LINK].contains(&mode) {
157 +
                return schema(format!(
158 +
                    "{path}: mode {mode:o} is not a file, an executable or a link"
159 +
                ));
160 +
            }
161 +
            if entries.last().is_some_and(|l: &Entry| l.path >= path) {
162 +
                return schema(format!(
163 +
                    "{path}: entries are not in path order, or one is there twice"
164 +
                ));
165 +
            }
166 +
            entries.push(Entry {
167 +
                path,
168 +
                mode,
169 +
                blob: get_obj(blob, "a blob")?,
170 +
                size: get_uint(size, "a size")?,
171 +
            });
172 +
        }
173 +
        Ok(DirTree { entries })
174 +
    }
175 +
}
176 +
177 +
/// An artifact (docs/cylinder.md §4.1).
178 +
#[derive(Clone, Debug, PartialEq, Eq)]
179 +
pub struct Artifact {
180 +
    pub kind: Kind,
181 +
    /// `x86_64`, `riscv64`, `aarch64`.
182 +
    pub arch: Option<String>,
183 +
    /// `linux`, `darwin`, `none`.
184 +
    pub os: Option<String>,
185 +
    /// The [`DirTree`]'s blob.
186 +
    pub tree: ObjectRef,
187 +
    /// The default command, for a container or an image.
188 +
    pub entrypoint: Option<Vec<String>>,
189 +
    pub env: BTreeMap<String, String>,
190 +
    pub labels: BTreeMap<String, String>,
191 +
    /// The files' bytes together.
192 +
    pub total_size: u64,
193 +
    /// The receipt of the Flow job that built it, when one did.
194 +
    pub receipt: Option<ObjectRef>,
195 +
}
196 +
197 +
fn pairs(m: &BTreeMap<String, String>) -> Value {
198 +
    if m.is_empty() {
199 +
        return Value::Null;
200 +
    }
201 +
    Value::Array(
202 +
        m.iter()
203 +
            .map(|(k, v)| Value::Array(vec![Value::Text(k.clone()), Value::Text(v.clone())]))
204 +
            .collect(),
205 +
    )
206 +
}
207 +
208 +
fn get_pairs(v: Option<&Value>, what: &str) -> R<BTreeMap<String, String>> {
209 +
    let mut out = BTreeMap::new();
210 +
    let Some(v) = v.filter(|v| !v.is_null()) else {
211 +
        return Ok(out);
212 +
    };
213 +
    for p in v
214 +
        .as_array()
215 +
        .map_or_else(|| schema(format!("{what} is a list of pairs")), Ok)?
216 +
    {
217 +
        let [k, val] = p.as_array().unwrap_or_default() else {
218 +
            return schema(format!("{what}: a pair is [key, value]"));
219 +
        };
220 +
        let k = get_text(k, what)?;
221 +
        if out.insert(k.clone(), get_text(val, what)?).is_some() {
222 +
            return schema(format!("{what}: {k} is there twice"));
223 +
        }
224 +
    }
225 +
    Ok(out)
226 +
}
227 +
228 +
fn opt_text(v: &Option<String>) -> Value {
229 +
    v.as_ref().map_or(Value::Null, |s| Value::Text(s.clone()))
230 +
}
231 +
232 +
impl Artifact {
233 +
    /// Its canonical CBOR, keys as in docs/cylinder.md §4.1.
234 +
    pub fn encode(&self) -> Vec<u8> {
235 +
        let entry = self.entrypoint.as_ref().map_or(Value::Null, |a| {
236 +
            Value::Array(a.iter().cloned().map(Value::Text).collect())
237 +
        });
238 +
        cbor::encode(&Value::map([
239 +
            (0, Value::Uint(VERSION)),
240 +
            (1, Value::Text(self.kind.as_str().into())),
241 +
            (2, opt_text(&self.arch)),
242 +
            (3, opt_text(&self.os)),
243 +
            (4, obj(&self.tree)),
244 +
            (5, entry),
245 +
            (6, pairs(&self.env)),
246 +
            (7, pairs(&self.labels)),
247 +
            (8, Value::Uint(self.total_size)),
248 +
            (9, self.receipt.as_ref().map_or(Value::Null, obj)),
249 +
        ]))
250 +
    }
251 +
252 +
    pub fn decode(bytes: &[u8]) -> R<Artifact> {
253 +
        let v = cbor::decode(bytes)?;
254 +
        let m = v
255 +
            .as_map()
256 +
            .map_or_else(|| schema("an artifact is a map"), Ok)?;
257 +
        if m.get(&0).and_then(Value::as_uint) != Some(VERSION) {
258 +
            return schema(format!(
259 +
                "artifact version {:?} is not known; this reads {VERSION}",
260 +
                m.get(&0)
261 +
            ));
262 +
        }
263 +
        if let Some(k) = m.keys().find(|k| **k > 9) {
264 +
            return schema(format!("key {k} is not part of an artifact"));
265 +
        }
266 +
        let text = |k: u64, what: &str| -> R<Option<String>> {
267 +
            match m.get(&k) {
268 +
                None | Some(Value::Null) => Ok(None),
269 +
                Some(v) => get_text(v, what).map(Some),
270 +
            }
271 +
        };
272 +
        let entrypoint = match m.get(&5) {
273 +
            None | Some(Value::Null) => None,
274 +
            Some(v) => Some(
275 +
                v.as_array()
276 +
                    .unwrap_or_default()
277 +
                    .iter()
278 +
                    .map(|a| get_text(a, "the entrypoint"))
279 +
                    .collect::<R<_>>()?,
280 +
            ),
281 +
        };
282 +
        let receipt = match m.get(&9) {
283 +
            None | Some(Value::Null) => None,
284 +
            Some(v) => Some(get_obj(v, "the receipt")?),
285 +
        };
286 +
        Ok(Artifact {
287 +
            kind: Kind::parse(&text(1, "the kind")?.unwrap_or_default())?,
288 +
            arch: text(2, "the architecture")?,
289 +
            os: text(3, "the system")?,
290 +
            tree: get_obj(m.get(&4).unwrap_or(&Value::Null), "the tree")?,
291 +
            entrypoint,
292 +
            env: get_pairs(m.get(&6), "env")?,
293 +
            labels: get_pairs(m.get(&7), "labels")?,
294 +
            total_size: get_uint(m.get(&8).unwrap_or(&Value::Null), "the size")?,
295 +
            receipt,
296 +
        })
297 +
    }
298 +
299 +
    /// Its name: its object's hash, as every Oxygen object is named.
300 +
    pub fn name(&self) -> ObjectRef {
301 +
        hash_object(ObjectType::Artifact, &self.encode())
302 +
    }
303 +
}
modified crates/oxy-core/src/attest.rs +34 −0
…
25 unchanged lines
26 26
        state: ObjectRef,
27 27
        message: String,
28 28
    },
29 +
    /// Tag `name`, naming `state`, is withdrawn (spec §10.1): signed by a
30 +
    /// key the policy lets create the tag. The name is never used again.
31 +
    Untag {
32 +
        name: String,
33 +
        state: ObjectRef,
34 +
        reason: String,
35 +
    },
29 36
    /// The signing key's identity was replaced by `new` (spec §14.2).
30 37
    Rotate { new: KeyId },
31 38
    /// `key` must no longer be trusted (spec §14.2).
…
38 unchanged lines
70 77
            AttestBody::Ref { .. } => "ref",
71 78
            AttestBody::Seal { .. } => "seal",
72 79
            AttestBody::Tag { .. } => "tag",
80 +
            AttestBody::Untag { .. } => "untag",
73 81
            AttestBody::Rotate { .. } => "rotate",
74 82
            AttestBody::Revoke { .. } => "revoke",
75 83
            AttestBody::Receipt { .. } => "receipt",
…
38 unchanged lines
114 122
                (1, Value::Bytes(state.0.to_vec())),
115 123
                (2, Value::Text(message.clone())),
116 124
            ]),
125 +
            AttestBody::Untag {
126 +
                name,
127 +
                state,
128 +
                reason,
129 +
            } => Value::map([
130 +
                (0, Value::Text(name.clone())),
131 +
                (1, Value::Bytes(state.0.to_vec())),
132 +
                (2, Value::Text(reason.clone())),
133 +
            ]),
117 134
            AttestBody::Rotate { new } => Value::map([(0, Value::Bytes(new.0.to_vec()))]),
118 135
            AttestBody::Revoke { key, reason } => Value::map([
119 136
                (0, Value::Bytes(key.0.to_vec())),
…
84 unchanged lines
204 221
                state: oref(field(b, 1, "state")?, "state")?,
205 222
                message: text(field(b, 2, "message")?, "message")?.to_owned(),
206 223
            },
224 +
            "untag" => AttestBody::Untag {
225 +
                name: text(field(b, 0, "name")?, "name")?.to_owned(),
226 +
                state: oref(field(b, 1, "state")?, "state")?,
227 +
                reason: text(field(b, 2, "reason")?, "reason")?.to_owned(),
228 +
            },
207 229
            "rotate" => AttestBody::Rotate {
208 230
                new: oref(field(b, 0, "new")?, "new")?,
209 231
            },
…
84 unchanged lines
294 316
        };
295 317
        assert_eq!(Attest::decode(&t.encode()).unwrap(), t);
296 318
        assert_eq!(t.kind(), "tag");
319 +
        let u = Attest {
320 +
            repo: [1; 16],
321 +
            hlc: Hlc::default(),
322 +
            body: AttestBody::Untag {
323 +
                name: "v1".into(),
324 +
                state: ObjectRef([5; 32]),
325 +
                reason: "tagged the wrong change".into(),
326 +
            },
327 +
        };
328 +
        assert_eq!(Attest::decode(&u.encode()).unwrap(), u);
329 +
        assert_eq!(u.kind(), "untag");
330 +
        assert_ne!(u.hash(), t.hash());
297 331
    }
298 332
299 333
    #[test]
…
17 unchanged lines
modified crates/oxy-core/src/attributes.rs +13 −2
1 1
//! `.oxyattributes` (spec §19): per-path attributes in gitattributes syntax.
2 2
//! Each line is `pattern attr...` where an attribute is `binary`, `text`
3 -
//! (or `-binary`), `eol=lf`, `eol=crlf`, `nocompress`, or `lockable`. A
3 +
//! (or `-binary`), `eol=lf`, `eol=crlf`, `nocompress`, `lockable`, or
4 +
//! `merge=union`. A
4 5
//! pattern without a slash matches a basename anywhere below the file's
5 6
//! directory; one with a slash is anchored to that directory. Later rules
6 7
//! override earlier ones per attribute.
…
15 unchanged lines
22 23
    pub eol: Option<Eol>,
23 24
    pub nocompress: bool,
24 25
    pub lockable: bool,
26 +
    /// `merge=union` (oxygen#145): two changes adding lines at the same
27 +
    /// place keep both, without asking (`oxy resolve --both`, spec §9.5).
28 +
    pub union: bool,
25 29
}
26 30
27 31
struct Rule {
…
80 unchanged lines
108 112
                    ("-nocompress", None) => a.nocompress = false,
109 113
                    ("lockable", None) => a.lockable = true,
110 114
                    ("-lockable", None) => a.lockable = false,
115 +
                    ("merge", Some("union")) => a.union = true,
116 +
                    ("merge", Some(_)) | ("-merge", None) => a.union = false,
111 117
                    _ => {}
112 118
                }
113 119
            }
…
52 unchanged lines
166 172
                binary: Some(true),
167 173
                eol: None,
168 174
                nocompress: true,
169 -
                lockable: false
175 +
                lockable: false,
176 +
                union: false
170 177
            }
171 178
        );
172 179
        assert_eq!(a.lookup("notes.txt").eol, Some(Eol::Lf));
173 180
        assert_eq!(a.lookup("docs/notes.txt").eol, Some(Eol::Crlf));
174 181
        assert!(a.lookup("models/x/y.bin").lockable);
182 +
        let u = Attributes::from_files([(".oxyattributes", &b"CHANGELOG.md merge=union\n"[..])]);
183 +
        assert!(u.lookup("CHANGELOG.md").union);
184 +
        assert!(u.lookup("docs/CHANGELOG.md").union);
185 +
        assert!(!u.lookup("README.md").union);
175 186
        assert_eq!(a.lookup("src/deep/x.dat").binary, Some(true));
176 187
        assert_eq!(a.lookup("other/x.dat").binary, None);
177 188
        assert_eq!(a.lookup("src/gen.txt").eol, None, "deeper file overrides");
…
11 unchanged lines
modified crates/oxy-core/src/graph.rs +53 −0
…
392 unchanged lines
393 393
            .flat_map(|a| a.deleted_by.iter())
394 394
    }
395 395
396 +
    /// Whether the conflict at `m.segments[i]` is an adjacent insertion
397 +
    /// (oxygen#121): its sides only add lines between two lines that were
398 +
    /// next to each other, so keeping every side repeats nothing. It is not
399 +
    /// when a line there was deleted by a change only some sides build on:
400 +
    /// two changes that each replaced the same line conflict the same way,
401 +
    /// and keeping both would keep two versions of it. A line deleted in
402 +
    /// history every side shares does not count.
403 +
    pub fn adjacent_insertion(&self, m: &Materialized, i: usize, deps: &dyn DepOracle) -> bool {
404 +
        let Some(Segment::Conflict(sides)) = m.segments.get(i) else {
405 +
            return false;
406 +
        };
407 +
        let before = match i.checked_sub(1).map(|j| &m.segments[j]) {
408 +
            None => Vertex::Start,
409 +
            Some(Segment::Lines(a)) => match a.last() {
410 +
                Some(k) => Vertex::Atom(*k),
411 +
                None => return false,
412 +
            },
413 +
            Some(_) => return false,
414 +
        };
415 +
        let on_side: HashSet<AtomKey> = sides.iter().flatten().copied().collect();
416 +
        let side_changes: Vec<BTreeSet<ChangeHash>> = sides
417 +
            .iter()
418 +
            .map(|s| s.iter().map(|a| a.0).collect())
419 +
            .collect();
420 +
        let shared = |d: &ChangeHash| {
421 +
            side_changes
422 +
                .iter()
423 +
                .all(|s| s.iter().any(|c| deps.depends_on(c, d)))
424 +
        };
425 +
        // From the line before, through the sides' lines and deleted ones,
426 +
        // up to the next live line.
427 +
        let mut seen: HashSet<AtomKey> = HashSet::default();
428 +
        let mut todo = vec![before];
429 +
        while let Some(v) = todo.pop() {
430 +
            for &w in self.succ.get(&v).map(Edges::as_slice).unwrap_or(&[]) {
431 +
                let Vertex::Atom(k) = w else { continue };
432 +
                if !seen.insert(k) {
433 +
                    continue;
434 +
                }
435 +
                if !on_side.contains(&k) {
436 +
                    if self.is_live(&k) {
437 +
                        continue;
438 +
                    }
439 +
                    if self.deleters(&k).any(|d| !shared(d)) {
440 +
                        return false;
441 +
                    }
442 +
                }
443 +
                todo.push(w);
444 +
            }
445 +
        }
446 +
        true
447 +
    }
448 +
396 449
    /// Number of atoms introduced by `change` in this graph (for index bookkeeping).
397 450
    pub fn atom_count(&self) -> usize {
398 451
        self.atoms.len()
…
1484 unchanged lines
modified crates/oxy-core/src/hash.rs +6 −1
…
67 unchanged lines
68 68
    Certificate,
69 69
    /// An author's grant to a certified subject (§14.7).
70 70
    Grant,
71 +
    /// A tree of files as one immutable thing: an image, a package, an
72 +
    /// archive (Cylinder, docs/cylinder.md §4.1).
73 +
    Artifact,
71 74
}
72 75
73 76
impl ObjectType {
74 -
    pub const ALL: [ObjectType; 12] = [
77 +
    pub const ALL: [ObjectType; 13] = [
75 78
        ObjectType::Chunk,
76 79
        ObjectType::Blob,
77 80
        ObjectType::Change,
…
6 unchanged lines
84 87
        ObjectType::Receipt,
85 88
        ObjectType::Certificate,
86 89
        ObjectType::Grant,
90 +
        ObjectType::Artifact,
87 91
    ];
88 92
89 93
    pub fn name(self) -> &'static str {
…
10 unchanged lines
100 104
            ObjectType::Receipt => "receipt",
101 105
            ObjectType::Certificate => "certificate",
102 106
            ObjectType::Grant => "grant",
107 +
            ObjectType::Artifact => "artifact",
103 108
        }
104 109
    }
105 110
…
59 unchanged lines
modified crates/oxy-core/src/lib.rs +2 −0
…
6 unchanged lines
7 7
8 8
pub mod alloc_stats;
9 9
pub mod approval;
10 +
pub mod artifact;
10 11
pub mod attest;
11 12
pub mod attributes;
12 13
pub mod cbor;
…
11 unchanged lines
24 25
pub mod ids;
25 26
pub mod lanes;
26 27
pub mod lines;
28 +
pub mod mcp;
27 29
pub mod objects;
28 30
pub mod oplog;
29 31
pub mod policy;
…
13 unchanged lines
added crates/oxy-core/src/mcp.rs +353 −0
1 +
//! A Model Context Protocol server over stdio (oxygen#159): JSON-RPC 2.0,
2 +
//! one message per line, as MCP's stdio transport has it. This is only the
3 +
//! protocol: `initialize`, `ping`, `tools/list` and `tools/call`, with the
4 +
//! tools themselves supplied by the caller (`oxy mcp`, and `oh mcp` in the
5 +
//! hub, which reaches this module through its pinned oxygen).
6 +
//!
7 +
//! Nothing is written to stdout but protocol messages: a tool's own
8 +
//! output goes into its result.
9 +
10 +
use serde_json::{json, Value};
11 +
use std::io::{BufRead, Write};
12 +
13 +
/// The protocol versions this server speaks, newest first. A client asking
14 +
/// for one of them gets it back; any other gets the newest.
15 +
pub const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
16 +
17 +
/// One tool as `tools/list` describes it.
18 +
#[derive(Clone, Debug)]
19 +
pub struct Tool {
20 +
    pub name: String,
21 +
    pub description: String,
22 +
    /// A JSON Schema object for the arguments.
23 +
    pub input_schema: Value,
24 +
    /// Reads only: no change to the repository, the remote or the hub.
25 +
    pub read_only: bool,
26 +
    /// May lose work or history (undo, abandon, deletes).
27 +
    pub destructive: bool,
28 +
}
29 +
30 +
impl Tool {
31 +
    fn describe(&self) -> Value {
32 +
        json!({
33 +
            "name": self.name,
34 +
            "description": self.description,
35 +
            "inputSchema": self.input_schema,
36 +
            "annotations": {
37 +
                "readOnlyHint": self.read_only,
38 +
                "destructiveHint": self.destructive,
39 +
            },
40 +
        })
41 +
    }
42 +
}
43 +
44 +
/// What a tool call produced: text for the model to read, the same as JSON
45 +
/// when there is some, and whether it failed. A failed tool is a result, not
46 +
/// a protocol error, so the model sees why.
47 +
#[derive(Clone, Debug, Default, PartialEq)]
48 +
pub struct ToolOutput {
49 +
    pub text: String,
50 +
    pub structured: Option<Value>,
51 +
    pub is_error: bool,
52 +
}
53 +
54 +
impl ToolOutput {
55 +
    pub fn ok(text: impl Into<String>) -> ToolOutput {
56 +
        ToolOutput {
57 +
            text: text.into(),
58 +
            structured: None,
59 +
            is_error: false,
60 +
        }
61 +
    }
62 +
63 +
    pub fn error(text: impl Into<String>) -> ToolOutput {
64 +
        ToolOutput {
65 +
            text: text.into(),
66 +
            structured: None,
67 +
            is_error: true,
68 +
        }
69 +
    }
70 +
71 +
    fn to_json(&self) -> Value {
72 +
        let mut out = json!({
73 +
            "content": [{ "type": "text", "text": self.text }],
74 +
            "isError": self.is_error,
75 +
        });
76 +
        // Structured content must be an object; an array is wrapped.
77 +
        if let Some(s) = &self.structured {
78 +
            let obj = if s.is_object() {
79 +
                s.clone()
80 +
            } else {
81 +
                json!({ "result": s })
82 +
            };
83 +
            out["structuredContent"] = obj;
84 +
        }
85 +
        out
86 +
    }
87 +
}
88 +
89 +
/// The tools a server offers.
90 +
pub trait Tools {
91 +
    fn list(&self) -> Vec<Tool>;
92 +
    /// Run a tool. `None` when no tool has that name.
93 +
    fn call(&self, name: &str, args: &Value) -> Option<ToolOutput>;
94 +
}
95 +
96 +
/// Who the server is, for `initialize`.
97 +
#[derive(Clone, Debug)]
98 +
pub struct ServerInfo {
99 +
    pub name: String,
100 +
    pub version: String,
101 +
    /// Shown to the model: what the server is for and how to use it safely.
102 +
    pub instructions: String,
103 +
}
104 +
105 +
const PARSE_ERROR: i64 = -32700;
106 +
const INVALID_REQUEST: i64 = -32600;
107 +
const METHOD_NOT_FOUND: i64 = -32601;
108 +
const INVALID_PARAMS: i64 = -32602;
109 +
110 +
fn error(id: Value, code: i64, message: &str) -> Value {
111 +
    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
112 +
}
113 +
114 +
fn result(id: Value, result: Value) -> Value {
115 +
    json!({ "jsonrpc": "2.0", "id": id, "result": result })
116 +
}
117 +
118 +
/// Answer one message; `None` for a notification (no reply is due).
119 +
pub fn handle(info: &ServerInfo, tools: &dyn Tools, message: &Value) -> Option<Value> {
120 +
    let Some(obj) = message.as_object() else {
121 +
        return Some(error(
122 +
            Value::Null,
123 +
            INVALID_REQUEST,
124 +
            "a message is a JSON object",
125 +
        ));
126 +
    };
127 +
    let method = obj.get("method").and_then(Value::as_str);
128 +
    let id = obj.get("id").cloned();
129 +
    let Some(method) = method else {
130 +
        // A response to something we never sent, or nonsense: a request
131 +
        // without a method gets an error, anything else is ignored.
132 +
        return id.map(|id| error(id, INVALID_REQUEST, "a request needs a method"));
133 +
    };
134 +
    // Notifications (initialized, cancelled, …) carry no id and get no reply.
135 +
    let id = id?;
136 +
    let params = obj.get("params").cloned().unwrap_or(Value::Null);
137 +
    Some(match method {
138 +
        "initialize" => {
139 +
            let asked = params.get("protocolVersion").and_then(Value::as_str);
140 +
            let version = asked
141 +
                .filter(|v| PROTOCOL_VERSIONS.contains(v))
142 +
                .unwrap_or(PROTOCOL_VERSIONS[0]);
143 +
            result(
144 +
                id,
145 +
                json!({
146 +
                    "protocolVersion": version,
147 +
                    "capabilities": { "tools": { "listChanged": false } },
148 +
                    "serverInfo": { "name": info.name, "version": info.version },
149 +
                    "instructions": info.instructions,
150 +
                }),
151 +
            )
152 +
        }
153 +
        "ping" => result(id, json!({})),
154 +
        "tools/list" => result(
155 +
            id,
156 +
            json!({ "tools": tools.list().iter().map(Tool::describe).collect::<Vec<_>>() }),
157 +
        ),
158 +
        "tools/call" => {
159 +
            let Some(name) = params.get("name").and_then(Value::as_str) else {
160 +
                return Some(error(
161 +
                    id,
162 +
                    INVALID_PARAMS,
163 +
                    "tools/call needs the tool's name",
164 +
                ));
165 +
            };
166 +
            let args = params
167 +
                .get("arguments")
168 +
                .cloned()
169 +
                .unwrap_or_else(|| json!({}));
170 +
            if !args.is_object() {
171 +
                return Some(error(
172 +
                    id,
173 +
                    INVALID_PARAMS,
174 +
                    "a tool's arguments are a JSON object",
175 +
                ));
176 +
            }
177 +
            match tools.call(name, &args) {
178 +
                Some(out) => result(id, out.to_json()),
179 +
                None => error(id, INVALID_PARAMS, &format!("no tool named {name}")),
180 +
            }
181 +
        }
182 +
        other => error(id, METHOD_NOT_FOUND, &format!("{other} is not supported")),
183 +
    })
184 +
}
185 +
186 +
/// Serve until the input ends: read a message per line, write a reply per
187 +
/// request. A line that is not JSON gets a parse error; blank lines are
188 +
/// skipped.
189 +
pub fn serve(
190 +
    info: &ServerInfo,
191 +
    tools: &dyn Tools,
192 +
    input: impl BufRead,
193 +
    mut output: impl Write,
194 +
) -> std::io::Result<()> {
195 +
    for line in input.lines() {
196 +
        let line = line?;
197 +
        if line.trim().is_empty() {
198 +
            continue;
199 +
        }
200 +
        let reply = match serde_json::from_str::<Value>(&line) {
201 +
            Ok(Value::Array(batch)) => {
202 +
                let replies: Vec<Value> = batch
203 +
                    .iter()
204 +
                    .filter_map(|m| handle(info, tools, m))
205 +
                    .collect();
206 +
                (!replies.is_empty()).then_some(Value::Array(replies))
207 +
            }
208 +
            Ok(message) => handle(info, tools, &message),
209 +
            Err(e) => Some(error(Value::Null, PARSE_ERROR, &format!("not JSON: {e}"))),
210 +
        };
211 +
        if let Some(reply) = reply {
212 +
            writeln!(output, "{reply}")?;
213 +
            output.flush()?;
214 +
        }
215 +
    }
216 +
    Ok(())
217 +
}
218 +
219 +
/// A string argument, if given and a string.
220 +
pub fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
221 +
    args.get(key).and_then(Value::as_str)
222 +
}
223 +
224 +
/// A list of strings, if given; a single string counts as a list of one.
225 +
pub fn arg_strings(args: &Value, key: &str) -> Result<Vec<String>, String> {
226 +
    match args.get(key) {
227 +
        None | Some(Value::Null) => Ok(vec![]),
228 +
        Some(Value::String(s)) => Ok(vec![s.clone()]),
229 +
        Some(Value::Array(a)) => a
230 +
            .iter()
231 +
            .map(|v| {
232 +
                v.as_str()
233 +
                    .map(str::to_string)
234 +
                    .ok_or_else(|| format!("{key} must be a list of strings"))
235 +
            })
236 +
            .collect(),
237 +
        Some(_) => Err(format!("{key} must be a list of strings")),
238 +
    }
239 +
}
240 +
241 +
#[cfg(test)]
242 +
mod tests {
243 +
    use super::*;
244 +
245 +
    struct Echo;
246 +
247 +
    impl Tools for Echo {
248 +
        fn list(&self) -> Vec<Tool> {
249 +
            vec![Tool {
250 +
                name: "echo".into(),
251 +
                description: "says it back".into(),
252 +
                input_schema: json!({"type": "object"}),
253 +
                read_only: true,
254 +
                destructive: false,
255 +
            }]
256 +
        }
257 +
        fn call(&self, name: &str, args: &Value) -> Option<ToolOutput> {
258 +
            (name == "echo").then(|| ToolOutput {
259 +
                text: args.to_string(),
260 +
                structured: Some(json!([1, 2])),
261 +
                is_error: false,
262 +
            })
263 +
        }
264 +
    }
265 +
266 +
    fn info() -> ServerInfo {
267 +
        ServerInfo {
268 +
            name: "t".into(),
269 +
            version: "1".into(),
270 +
            instructions: "be careful".into(),
271 +
        }
272 +
    }
273 +
274 +
    fn talk(lines: &str) -> Vec<Value> {
275 +
        let mut out = Vec::new();
276 +
        serve(&info(), &Echo, lines.as_bytes(), &mut out).unwrap();
277 +
        String::from_utf8(out)
278 +
            .unwrap()
279 +
            .lines()
280 +
            .map(|l| serde_json::from_str(l).unwrap())
281 +
            .collect()
282 +
    }
283 +
284 +
    #[test]
285 +
    fn a_session_initializes_lists_and_calls() {
286 +
        let r = talk(concat!(
287 +
            r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}"#,
288 +
            "\n",
289 +
            r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
290 +
            "\n\n",
291 +
            r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
292 +
            "\n",
293 +
            r#"{"jsonrpc":"2.0","id":"three","method":"tools/call","params":{"name":"echo","arguments":{"a":1}}}"#,
294 +
            "\n",
295 +
        ));
296 +
        assert_eq!(r.len(), 3, "the notification gets no reply: {r:?}");
297 +
        assert_eq!(
298 +
            r[0]["result"]["protocolVersion"], "2025-03-26",
299 +
            "a version we speak is kept"
300 +
        );
301 +
        assert_eq!(r[0]["result"]["instructions"], "be careful");
302 +
        assert_eq!(r[1]["result"]["tools"][0]["name"], "echo");
303 +
        assert_eq!(
304 +
            r[1]["result"]["tools"][0]["annotations"]["readOnlyHint"],
305 +
            true
306 +
        );
307 +
        assert_eq!(r[2]["id"], "three");
308 +
        assert_eq!(r[2]["result"]["content"][0]["text"], r#"{"a":1}"#);
309 +
        assert_eq!(
310 +
            r[2]["result"]["structuredContent"],
311 +
            json!({"result": [1, 2]}),
312 +
            "an array is wrapped in an object"
313 +
        );
314 +
        assert_eq!(r[2]["result"]["isError"], false);
315 +
    }
316 +
317 +
    #[test]
318 +
    fn errors_are_protocol_errors_and_unknown_versions_get_the_newest() {
319 +
        let r = talk(concat!(
320 +
            "not json\n",
321 +
            r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1999-01-01"}}"#,
322 +
            "\n",
323 +
            r#"{"jsonrpc":"2.0","id":2,"method":"resources/list"}"#,
324 +
            "\n",
325 +
            r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"nope"}}"#,
326 +
            "\n",
327 +
            r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"echo","arguments":[1]}}"#,
328 +
            "\n",
329 +
            r#"[{"jsonrpc":"2.0","id":5,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled"}]"#,
330 +
            "\n",
331 +
        ));
332 +
        assert_eq!(r[0]["error"]["code"], PARSE_ERROR);
333 +
        assert_eq!(r[1]["result"]["protocolVersion"], PROTOCOL_VERSIONS[0]);
334 +
        assert_eq!(r[2]["error"]["code"], METHOD_NOT_FOUND);
335 +
        assert_eq!(r[3]["error"]["code"], INVALID_PARAMS);
336 +
        assert_eq!(r[4]["error"]["code"], INVALID_PARAMS);
337 +
        assert_eq!(
338 +
            r[5],
339 +
            json!([{"jsonrpc": "2.0", "id": 5, "result": {}}]),
340 +
            "a batch is answered as a batch"
341 +
        );
342 +
    }
343 +
344 +
    #[test]
345 +
    fn string_arguments_and_lists() {
346 +
        let a = json!({"one": "x", "many": ["a", "b"], "bad": [1]});
347 +
        assert_eq!(arg_str(&a, "one"), Some("x"));
348 +
        assert_eq!(arg_strings(&a, "one").unwrap(), vec!["x"]);
349 +
        assert_eq!(arg_strings(&a, "many").unwrap(), vec!["a", "b"]);
350 +
        assert_eq!(arg_strings(&a, "missing").unwrap(), Vec::<String>::new());
351 +
        assert!(arg_strings(&a, "bad").is_err());
352 +
    }
353 +
}
modified crates/oxy-core/src/portable.rs +65 −0
…
99 unchanged lines
100 100
    out
101 101
}
102 102
103 +
/// `path.canonicalize()`, but on Windows without the verbatim `\\?\`
104 +
/// prefix when the plain form names the same file (oxygen#143): a root
105 +
/// shown as `\\?\C:\work\demo` in every message otherwise.
106 +
pub fn canonical(path: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
107 +
    path.canonicalize().map(plain)
108 +
}
109 +
110 +
/// [`canonical`]'s rule alone: `\\?\C:\…` as `C:\…` and `\\?\UNC\…` as
111 +
/// `\\…`, when the result is under Windows' old length limit and has no
112 +
/// component the plain form would read differently (`.`, `..`, a trailing
113 +
/// dot or space, a reserved name). Anything else is left as it is.
114 +
pub fn plain(p: std::path::PathBuf) -> std::path::PathBuf {
115 +
    let Some(s) = p.to_str() else { return p };
116 +
    let unchanged_by_plain = |rest: &str| {
117 +
        rest.len() < 260
118 +
            && !rest.split('\\').any(|c| {
119 +
                c == "."
120 +
                    || c == ".."
121 +
                    || c.ends_with('.')
122 +
                    || c.ends_with(' ')
123 +
                    || (!c.is_empty() && windows_name_problem(c).is_some())
124 +
            })
125 +
    };
126 +
    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
127 +
        if unchanged_by_plain(rest) {
128 +
            return std::path::PathBuf::from(format!(r"\\{rest}"));
129 +
        }
130 +
    } else if let Some(rest) = s.strip_prefix(r"\\?\") {
131 +
        let b = rest.as_bytes();
132 +
        let drive = b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && b[2] == b'\\';
133 +
        if drive && unchanged_by_plain(&rest[3..]) {
134 +
            return std::path::PathBuf::from(rest);
135 +
        }
136 +
    }
137 +
    p
138 +
}
139 +
103 140
/// Why Windows would refuse a path, if it would.
104 141
pub fn windows_name_problem(path: &str) -> Option<&'static str> {
105 142
    const RESERVED: &[&str] = &[
…
22 unchanged lines
128 165
mod tests {
129 166
    use super::*;
130 167
168 +
    /// oxygen#143: Windows' verbatim prefix is dropped where the plain path
169 +
    /// says the same, and kept where it would not.
170 +
    #[test]
171 +
    fn a_verbatim_windows_path_is_shown_plain_when_it_can_be() {
172 +
        use std::path::PathBuf;
173 +
        let p = |s: &str| plain(PathBuf::from(s)).to_string_lossy().into_owned();
174 +
        assert_eq!(p(r"\\?\C:\work\demo"), r"C:\work\demo");
175 +
        assert_eq!(p(r"\\?\UNC\server\share\repo"), r"\\server\share\repo");
176 +
        assert_eq!(
177 +
            p(r"\\?\C:\work\CON"),
178 +
            r"\\?\C:\work\CON",
179 +
            "a reserved name stays verbatim"
180 +
        );
181 +
        assert_eq!(p(r"\\?\C:\work\trailing."), r"\\?\C:\work\trailing.");
182 +
        assert_eq!(
183 +
            p(r"\\?\Volume{1234}\x"),
184 +
            r"\\?\Volume{1234}\x",
185 +
            "not a drive path"
186 +
        );
187 +
        let long = format!(r"\\?\C:\{}", "a".repeat(300));
188 +
        assert_eq!(p(&long), long, "past the old length limit");
189 +
        assert_eq!(
190 +
            p("/home/andy/repo"),
191 +
            "/home/andy/repo",
192 +
            "nothing to do elsewhere"
193 +
        );
194 +
    }
195 +
131 196
    #[test]
132 197
    fn collisions_and_names() {
133 198
        assert_eq!(
…
24 unchanged lines
modified crates/oxy-core/src/protocol.rs +349 −67
…
63 unchanged lines
64 64
    /// name in cosignatures and its identity's bytes (key 7, only when it
65 65
    /// has any), which a client pins (spec §14.8, *Integrated time*).
66 66
    pub witnesses: Vec<(String, Vec<u8>)>,
67 +
    /// Tags withdrawn here (spec §10.1; key 8, only when there are any):
68 +
    /// each with its `untag` attestation as `attestation`, and that
69 +
    /// attestation's acceptance as `accepted` when a short-lived key
70 +
    /// signed it. Older clients ignore them and keep the tag they have.
71 +
    pub withdrawn: Vec<TagRef>,
67 72
}
68 73
69 74
/// Where a reference attestation sits in the host's transparency log
…
22 unchanged lines
92 97
    pub state: ObjectRef,
93 98
    pub heads: Vec<ChangeHash>,
94 99
    pub attestation: Option<SignedAttest>,
100 +
    /// The acceptance of `attestation` by its signer's certificate issuer
101 +
    /// when a short-lived key signed it (spec §14.7; key 4, only when there
102 +
    /// is one): the time a fetch checks the certificate at.
103 +
    pub accepted: Option<SignedAttest>,
104 +
    /// The attestation's place in the host's transparency log (key 5,
105 +
    /// only when it has one), as a bookmark's.
106 +
    pub logged: Option<Logged>,
107 +
    /// The cosigned checkpoints that first included `logged`'s entry (key
108 +
    /// 6, only when there are any).
109 +
    pub integration: Vec<Integration>,
110 +
}
111 +
112 +
impl TagRef {
113 +
    /// Whether this is a tag's withdrawal (its attestation an `untag`), as
114 +
    /// a push carries one (oxygenhub#301).
115 +
    pub fn is_withdrawal(&self) -> bool {
116 +
        self.attestation
117 +
            .as_ref()
118 +
            .is_some_and(|a| matches!(a.attest.body, crate::attest::AttestBody::Untag { .. }))
119 +
    }
120 +
121 +
    /// A tag with no acceptance or log place (yet).
122 +
    pub fn new(
123 +
        name: String,
124 +
        state: ObjectRef,
125 +
        heads: Vec<ChangeHash>,
126 +
        attestation: Option<SignedAttest>,
127 +
    ) -> TagRef {
128 +
        TagRef {
129 +
            name,
130 +
            state,
131 +
            heads,
132 +
            attestation,
133 +
            accepted: None,
134 +
            logged: None,
135 +
            integration: vec![],
136 +
        }
137 +
    }
95 138
}
96 139
97 140
/// One reference as a remote reports it.
…
81 unchanged lines
179 222
    /// A checkpoint of exactly `heads` (spec §16.1 `checkpoint`): the
180 223
    /// checkpoint object, its payload blob, and the blob's chunks.
181 224
    fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle>;
225 +
    /// A checkpoint of exactly `heads` without its graph blobs (spec §16.1
226 +
    /// `checkpoint-index`, oxygen#137), which the client then asks for in
227 +
    /// parts with `objects`. A remote that cannot answer it sends the
228 +
    /// whole checkpoint, which does as well.
229 +
    fn checkpoint_index(&self, heads: &[ChangeHash]) -> Result<Bundle> {
230 +
        self.checkpoint(heads)
231 +
    }
182 232
    /// The changes within `depth` dependency steps of `heads` (spec §17).
183 233
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>>;
184 234
    /// Push changes, bookmark updates, new tags, and optionally a newer
…
27 unchanged lines
212 262
    }
213 263
}
214 264
265 +
// Bundles a local remote served on this thread, for tests that count
266 +
// round trips (oxygen#158).
267 +
#[cfg(test)]
268 +
thread_local! {
269 +
    pub(crate) static LOCAL_FETCHES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
270 +
}
271 +
215 272
impl Remote for LocalRemote {
216 273
    fn repo_id(&self) -> Result<[u8; 16]> {
217 274
        self.repo.id()
…
5 unchanged lines
223 280
        self.repo.negotiate(have, want)
224 281
    }
225 282
    fn fetch(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle> {
283 +
        #[cfg(test)]
284 +
        LOCAL_FETCHES.with(|n| n.set(n.get() + 1));
226 285
        self.repo.bundle_raw_for(changes, max_blob)
227 286
    }
228 287
    fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> {
…
2 unchanged lines
231 290
    fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle> {
232 291
        self.repo.checkpoint_bundle_for(heads)
233 292
    }
293 +
    fn checkpoint_index(&self, heads: &[ChangeHash]) -> Result<Bundle> {
294 +
        self.repo.checkpoint_index_for(heads)
295 +
    }
234 296
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> {
235 297
        self.repo.shallow_changes(heads, depth)
236 298
    }
…
38 unchanged lines
275 337
    /// a temporary directory.
276 338
    pub cache_dir: Option<PathBuf>,
277 339
    /// Other tokens for the same hub, tried in turn when `token` is
278 -
    /// refused (401, 403 or 404): those of enclosing checkouts, when
279 -
    /// `token` came from the credential store rather than from this
280 -
    /// remote's own configuration (oxygen#112).
340 +
    /// refused (401, 403 or 404): the credential store's sign-in when
341 +
    /// `token` is the remote's own (oxygen#155), or those of enclosing
342 +
    /// checkouts when it came from the credential store (oxygen#112).
281 343
    pub fallback_tokens: Vec<String>,
344 +
    /// `token` is the remote's own: fall back only when the hub says it is
345 +
    /// not a token at all (HTTP 401: expired or unknown), never when it is
346 +
    /// one that may not do this (403, 404), so a narrowly scoped token is
347 +
    /// not quietly swapped for a wider sign-in (oxygen#155).
348 +
    pub fallback_on_401_only: bool,
282 349
}
283 350
284 351
/// Opens a store for a URL it understands, or passes (`None`). Stores that
…
32 unchanged lines
317 384
/// The id of a hosted repository that has not adopted one yet.
318 385
pub const UNBOUND_ID: [u8; 16] = [0; 16];
319 386
387 +
/// The request header in which a client declares the protocol features it
388 +
/// understands beyond the 0.5.0-era set: a comma-separated list, which a
389 +
/// server may tailor its answers to (docs/protocol.md, *Client
390 +
/// capabilities*). A request without it is a client of that era.
391 +
pub const FEATURES_HEADER: &str = "Oxy-Features";
392 +
393 +
/// The feature of a client that authorizes a tag signed by a short-lived,
394 +
/// certified key by its certificate at the time the host accepted it
395 +
/// (oxygen#129). A client without it refuses such a tag, and with it the
396 +
/// whole fetch, so servers leave those tags out of its `refs` (oxygen#131).
397 +
pub const FEATURE_CERTIFIED_TAGS: &str = "certified-tags";
398 +
399 +
/// The features this client declares in [`FEATURES_HEADER`].
400 +
pub const CLIENT_FEATURES: &[&str] = &[FEATURE_CERTIFIED_TAGS];
401 +
402 +
/// The `User-Agent` every request of this client sends: `oxy/<version>`.
403 +
pub fn user_agent() -> String {
404 +
    format!("oxy/{}", env!("CARGO_PKG_VERSION"))
405 +
}
406 +
407 +
/// Whether a [`FEATURES_HEADER`] value (absent: `None`) declares `feature`.
408 +
/// Entries are comma-separated, compared without surrounding blanks and
409 +
/// regardless of case; unknown ones are ignored.
410 +
pub fn declares_feature(header: Option<&str>, feature: &str) -> bool {
411 +
    header.is_some_and(|h| h.split(',').any(|f| f.trim().eq_ignore_ascii_case(feature)))
412 +
}
413 +
320 414
/// A remote served by `oxy-server` at `<base>/oxy/v1/...`.
321 415
pub struct HttpRemote {
322 416
    base: String,
323 417
    /// The token in use, and those still to try when it is refused.
324 418
    token: Mutex<Option<String>>,
325 419
    fallbacks: Mutex<Vec<String>>,
420 +
    fallback_on_401_only: bool,
326 421
    repo_id: Option<[u8; 16]>,
327 422
    agent: ureq::Agent,
328 423
}
…
36 unchanged lines
365 460
        refuse_plain_token(base, options)?;
366 461
        let agent = ureq::AgentBuilder::new()
367 462
            .tls_config(tls_client_config(options.ca.as_deref())?)
463 +
            .user_agent(&user_agent())
368 464
            .build();
369 465
        Ok(HttpRemote {
370 466
            base: base.trim_end_matches('/').to_string(),
371 467
            token: Mutex::new(options.token.clone()),
372 468
            fallbacks: Mutex::new(options.fallback_tokens.iter().rev().cloned().collect()),
469 +
            fallback_on_401_only: options.fallback_on_401_only,
373 470
            repo_id: options.repo_id,
374 471
            agent,
375 472
        })
…
5 unchanged lines
381 478
        loop {
382 479
            let token = self.token.lock().unwrap_or_else(|p| p.into_inner()).clone();
383 480
            match self.call_once(post, endpoint, body, token.as_deref()) {
384 -
                Err(RepoError::Msg(m)) if token.is_some() && refused(&m) => {
481 +
                Err(RepoError::Msg(m))
482 +
                    if token.is_some()
483 +
                        && refused(&m)
484 +
                        && (!self.fallback_on_401_only || m.contains(": HTTP 401")) =>
485 +
                {
385 486
                    let next = self
386 487
                        .fallbacks
387 488
                        .lock()
…
3 unchanged lines
391 492
                        return Err(RepoError::Msg(m));
392 493
                    };
393 494
                    crate::credentials::warn_once(&format!(
394 -
                        "{}: the stored token was refused; using the one an enclosing checkout has for this hub",
495 +
                        "{}: the token in use was refused; trying another this computer has for the hub \
496 +
                         (a stale remote.NAME.token goes with `oxy config --unset remote.NAME.token`)",
395 497
                        self.base
396 498
                    ));
397 499
                    *self.token.lock().unwrap_or_else(|p| p.into_inner()) = Some(next);
…
16 unchanged lines
414 516
        } else {
415 517
            self.agent.get(&url)
416 518
        };
417 -
        req = req.set("Accept", CONTENT_TYPE);
519 +
        req = req
520 +
            .set("Accept", CONTENT_TYPE)
521 +
            .set(FEATURES_HEADER, &CLIENT_FEATURES.join(","));
418 522
        if let Some(t) = token {
419 523
            req = req.set("Authorization", &format!("Bearer {t}"));
420 524
        }
…
16 unchanged lines
437 541
                let text = r.into_string().unwrap_or_default();
438 542
                // The hint comes from the status alone: the server's text
439 543
                // (a host's reason for refusing a push, several lines
440 -
                // perhaps) is shown as it is, never read for hints.
544 +
                // perhaps) is shown as it is, never read for hints. A
545 +
                // proxy's HTML error page is shown by its title only
546 +
                // (oxygen#133).
441 547
                let status = format!("{url}: HTTP {code}");
442 -
                let msg = format!("{status}: {}", text.trim());
548 +
                let msg = format!("{status}: {}", plain_error_text(&text));
443 549
                Err(RepoError::Msg(match hint_for(&status) {
444 550
                    Some(h) => format!("{msg}\n  hint: {h}"),
445 551
                    None => msg,
…
32 unchanged lines
478 584
        "the hub needs you signed in, or did not accept the token: run `oxy auth login HUB` (pushing needs repo:write)"
479 585
    } else if m.contains("http 403") {
480 586
        "the token or account lacks permission: pushing needs a token with repo:write and write access to the repository"
587 +
    } else if m.contains("http 413") {
588 +
        "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"
481 589
    } else if m.contains("http 404") {
482 590
        "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`)"
483 591
    } else if m.contains("unknownissuer")
…
17 unchanged lines
501 609
    Some(hint)
502 610
}
503 611
612 +
/// An error response's text as it is, unless it is an HTML page (a proxy's,
613 +
/// such as Cloudflare's 413): then its title, or a note that it was one.
614 +
fn plain_error_text(text: &str) -> String {
615 +
    let t = text.trim();
616 +
    let lower = t.to_ascii_lowercase();
617 +
    if !(lower.starts_with("<!doctype html") || lower.starts_with("<html")) {
618 +
        return t.to_string();
619 +
    }
620 +
    let title = lower
621 +
        .find("<title>")
622 +
        .and_then(|i| {
623 +
            lower[i + 7..]
624 +
                .find("</title>")
625 +
                .map(|j| t[i + 7..i + 7 + j].trim())
626 +
        })
627 +
        .filter(|s| !s.is_empty());
628 +
    match title {
629 +
        Some(s) => format!("{s} (an HTML error page, from a proxy in front of the server perhaps)"),
630 +
        None => "an HTML error page (from a proxy in front of the server perhaps)".into(),
631 +
    }
632 +
}
633 +
504 634
/// `message`, followed by what to do about it when that is known.
505 635
pub fn with_hint(message: String) -> String {
506 636
    match hint_for(&message) {
…
15 unchanged lines
522 652
    let agent = ureq::AgentBuilder::new()
523 653
        .tls_config(tls_client_config(options.ca.as_deref())?)
524 654
        .timeout(std::time::Duration::from_secs(60))
655 +
        .user_agent(&user_agent())
525 656
        .build();
526 657
    let mut req = agent.request(method, url).set("Accept", "application/json");
527 658
    if let Some(t) = &options.token {
…
19 unchanged lines
547 678
    refuse_plain_token(url, options)?;
548 679
    let agent = ureq::AgentBuilder::new()
549 680
        .tls_config(tls_client_config(options.ca.as_deref())?)
681 +
        .user_agent(&user_agent())
550 682
        .build();
551 683
    let mut req = agent.get(url).set("Accept", "application/json");
552 684
    if let Some(t) = &options.token {
…
47 unchanged lines
600 732
        )
601 733
    }
602 734
    fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle> {
603 -
        wire::decode_bundle(&self.call(true, "checkpoint", Some(&wire::encode_hashes(heads)))?)
735 +
        let body = {
736 +
            let _t = crate::timing::phase("remote: checkpoint answer");
737 +
            self.call(true, "checkpoint", Some(&wire::encode_hashes(heads)))?
738 +
        };
739 +
        let _t = crate::timing::phase("remote: decode the checkpoint");
740 +
        wire::decode_bundle(&body)
741 +
    }
742 +
    fn checkpoint_index(&self, heads: &[ChangeHash]) -> Result<Bundle> {
743 +
        let body = {
744 +
            let _t = crate::timing::phase("remote: checkpoint index");
745 +
            match self.call(true, "checkpoint-index", Some(&wire::encode_hashes(heads))) {
746 +
                Ok(b) => b,
747 +
                // A server from before checkpoint-index: the whole answer.
748 +
                Err(RepoError::Msg(m)) if m.contains("HTTP 404") => return self.checkpoint(heads),
749 +
                Err(e) => return Err(e),
750 +
            }
751 +
        };
752 +
        wire::decode_bundle(&body)
604 753
    }
605 754
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> {
606 755
        wire::decode_hashes(&self.call(
…
322 unchanged lines
929 1078
        Value::Array(
930 1079
            tags.iter()
931 1080
                .map(|t| {
932 -
                    Value::map([
1081 +
                    let mut v = Value::map([
933 1082
                        (0, Value::Text(t.name.clone())),
934 1083
                        (1, Value::Bytes(t.state.0.to_vec())),
935 1084
                        (2, hashes_value(&t.heads)),
936 1085
                        (3, opt_attest_value(&t.attestation)),
937 -
                    ])
1086 +
                    ]);
1087 +
                    // Only when there is one; older peers ignore them
1088 +
                    // (oxygen#129).
1089 +
                    if let Value::Map(m) = &mut v {
1090 +
                        if t.accepted.is_some() {
1091 +
                            m.insert(4, opt_attest_value(&t.accepted));
1092 +
                        }
1093 +
                        if let Some(l) = &t.logged {
1094 +
                            m.insert(5, logged_value(l));
1095 +
                        }
1096 +
                        if !t.integration.is_empty() {
1097 +
                            m.insert(6, integration_value(&t.integration));
1098 +
                        }
1099 +
                    }
1100 +
                    v
938 1101
                })
939 1102
                .collect(),
940 1103
        )
…
7 unchanged lines
948 1111
                state: o::oref(field(tm, 1, "state")?, "state")?,
949 1112
                heads: hashes_from(field(tm, 2, "heads")?)?,
950 1113
                attestation: opt_attest_from(field(tm, 3, "attestation")?)?,
1114 +
                accepted: match tm.get(&4) {
1115 +
                    Some(a) => opt_attest_from(a)?,
1116 +
                    None => None,
1117 +
                },
1118 +
                logged: tm.get(&5).map(logged_from).transpose()?,
1119 +
                integration: match tm.get(&6) {
1120 +
                    Some(l) => integration_from(l)?,
1121 +
                    None => Vec::new(),
1122 +
                },
1123 +
            });
1124 +
        }
1125 +
        Ok(out)
1126 +
    }
1127 +
1128 +
    /// The tags of a refs answer as oxy 0.5.0 read them (and the runners
1129 +
    /// built with it), keys 0 to 3 only: what an older reader makes of an
1130 +
    /// answer with keys 4 to 6.
1131 +
    #[cfg(test)]
1132 +
    pub(super) fn tags_as_0_5_read_them(bytes: &[u8]) -> Result<Vec<TagRef>> {
1133 +
        let v = cbor::decode(bytes).map_err(ObjectError::from)?;
1134 +
        let m = map(&v, "refs")?;
1135 +
        let mut out = Vec::new();
1136 +
        for t in o::array(field(m, 3, "tags")?, "tags")? {
1137 +
            let tm = map(t, "tag")?;
1138 +
            out.push(TagRef::new(
1139 +
                o::text(field(tm, 0, "name")?, "name")?.to_owned(),
1140 +
                o::oref(field(tm, 1, "state")?, "state")?,
1141 +
                hashes_from(field(tm, 2, "heads")?)?,
1142 +
                opt_attest_from(field(tm, 3, "attestation")?)?,
1143 +
            ));
1144 +
        }
1145 +
        Ok(out)
1146 +
    }
1147 +
1148 +
    /// A log place, `[entry number, entry bytes, [proof hash]]` (spec §14.8).
1149 +
    fn logged_value(l: &Logged) -> Value {
1150 +
        Value::Array(vec![
1151 +
            Value::Uint(l.index),
1152 +
            Value::Bytes(l.entry.clone()),
1153 +
            proof_value(&l.proof),
1154 +
        ])
1155 +
    }
1156 +
    fn logged_from(v: &Value) -> Result<Logged> {
1157 +
        let a = o::array(v, "logged")?;
1158 +
        if a.len() != 3 {
1159 +
            return Err(schema("logged: expected [index, entry, proof]"));
1160 +
        }
1161 +
        Ok(Logged {
1162 +
            index: o::uint(&a[0], "index")?,
1163 +
            entry: o::bytes(&a[1], "entry")?.to_vec(),
1164 +
            proof: proof_from(&a[2])?,
1165 +
        })
1166 +
    }
1167 +
1168 +
    /// Cosigned checkpoints holding an entry, `[[note, [proof hash]]]`.
1169 +
    fn integration_value(integration: &[Integration]) -> Value {
1170 +
        Value::Array(
1171 +
            integration
1172 +
                .iter()
1173 +
                .map(|i| Value::Array(vec![Value::Text(i.note.clone()), proof_value(&i.proof)]))
1174 +
                .collect(),
1175 +
        )
1176 +
    }
1177 +
    fn integration_from(v: &Value) -> Result<Vec<Integration>> {
1178 +
        let mut out = Vec::new();
1179 +
        for i in o::array(v, "integration")? {
1180 +
            let a = o::array(i, "integration")?;
1181 +
            if a.len() != 2 {
1182 +
                return Err(schema("integration: expected [note, proof]"));
1183 +
            }
1184 +
            out.push(Integration {
1185 +
                note: o::text(&a[0], "note")?.to_owned(),
1186 +
                proof: proof_from(&a[1])?,
951 1187
            });
952 1188
        }
953 1189
        Ok(out)
…
20 unchanged lines
974 1210
                                    m.insert(5, opt_attest_value(&r.accepted));
975 1211
                                }
976 1212
                                if let Some(l) = &r.logged {
977 -
                                    m.insert(
978 -
                                        6,
979 -
                                        Value::Array(vec![
980 -
                                            Value::Uint(l.index),
981 -
                                            Value::Bytes(l.entry.clone()),
982 -
                                            proof_value(&l.proof),
983 -
                                        ]),
984 -
                                    );
1213 +
                                    m.insert(6, logged_value(l));
985 1214
                                }
986 1215
                                if !r.integration.is_empty() {
987 -
                                    m.insert(
988 -
                                        7,
989 -
                                        Value::Array(
990 -
                                            r.integration
991 -
                                                .iter()
992 -
                                                .map(|i| {
993 -
                                                    Value::Array(vec![
994 -
                                                        Value::Text(i.note.clone()),
995 -
                                                        proof_value(&i.proof),
996 -
                                                    ])
997 -
                                                })
998 -
                                                .collect(),
999 -
                                        ),
1000 -
                                    );
1216 +
                                    m.insert(7, integration_value(&r.integration));
1001 1217
                                }
1002 1218
                            }
1003 1219
                            v
…
20 unchanged lines
1024 1240
            if let Some(log) = &resp.log {
1025 1241
                m.insert(6, Value::Text(log.clone()));
1026 1242
            }
1243 +
            if !resp.withdrawn.is_empty() {
1244 +
                m.insert(8, tags_value(&resp.withdrawn));
1245 +
            }
1027 1246
            if !resp.witnesses.is_empty() {
1028 1247
                m.insert(
1029 1248
                    7,
…
37 unchanged lines
1067 1286
                    Some(a) => opt_attest_from(a)?,
1068 1287
                    None => None,
1069 1288
                },
1070 -
                logged: match rm.get(&6) {
1071 -
                    Some(l) => {
1072 -
                        let a = o::array(l, "logged")?;
1073 -
                        if a.len() != 3 {
1074 -
                            return Err(schema("logged: expected [index, entry, proof]"));
1075 -
                        }
1076 -
                        Some(Logged {
1077 -
                            index: o::uint(&a[0], "index")?,
1078 -
                            entry: o::bytes(&a[1], "entry")?.to_vec(),
1079 -
                            proof: proof_from(&a[2])?,
1080 -
                        })
1081 -
                    }
1082 -
                    None => None,
1083 -
                },
1289 +
                logged: rm.get(&6).map(logged_from).transpose()?,
1084 1290
                integration: match rm.get(&7) {
1085 -
                    Some(l) => {
1086 -
                        let mut out = Vec::new();
1087 -
                        for i in o::array(l, "integration")? {
1088 -
                            let a = o::array(i, "integration")?;
1089 -
                            if a.len() != 2 {
1090 -
                                return Err(schema("integration: expected [note, proof]"));
1091 -
                            }
1092 -
                            out.push(Integration {
1093 -
                                note: o::text(&a[0], "note")?.to_owned(),
1094 -
                                proof: proof_from(&a[1])?,
1095 -
                            });
1096 -
                        }
1097 -
                        out
1098 -
                    }
1291 +
                    Some(l) => integration_from(l)?,
1099 1292
                    None => Vec::new(),
1100 1293
                },
1101 1294
            });
…
33 unchanged lines
1135 1328
                ));
1136 1329
            }
1137 1330
        }
1331 +
        let withdrawn = match m.get(&8) {
1332 +
            Some(t) => tags_from(t)?,
1333 +
            None => Vec::new(),
1334 +
        };
1138 1335
        Ok((
1139 1336
            id,
1140 1337
            RefsResponse {
…
4 unchanged lines
1145 1342
                default_bookmark,
1146 1343
                log,
1147 1344
                witnesses,
1345 +
                withdrawn,
1148 1346
            },
1149 1347
        ))
1150 1348
    }
…
172 unchanged lines
1323 1521
    use crate::attest::AttestBody;
1324 1522
    use crate::objects::Hlc;
1325 1523
1524 +
    /// oxygen#133: a proxy's HTML error page is shown by its title, and a
1525 +
    /// server's own text as it is; a 413 says to push in smaller parts.
1526 +
    #[test]
1527 +
    fn html_error_pages_are_shown_by_their_title() {
1528 +
        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>";
1529 +
        assert_eq!(
1530 +
            plain_error_text(cloudflare),
1531 +
            "413 Request Entity Too Large (an HTML error page, from a proxy in front of the server perhaps)"
1532 +
        );
1533 +
        assert_eq!(
1534 +
            plain_error_text("  refused: a secret\nat a.txt:3 "),
1535 +
            "refused: a secret\nat a.txt:3"
1536 +
        );
1537 +
        assert!(hint_for("https://hub/x: HTTP 413: too large")
1538 +
            .unwrap()
1539 +
            .contains("push.batch-bytes"));
1540 +
    }
1541 +
1326 1542
    #[test]
1543 +
    fn client_features_are_declared_and_read() {
1544 +
        assert_eq!(CLIENT_FEATURES.join(","), "certified-tags");
1545 +
        assert!(user_agent().starts_with("oxy/"));
1546 +
        assert!(declares_feature(
1547 +
            Some("certified-tags"),
1548 +
            FEATURE_CERTIFIED_TAGS
1549 +
        ));
1550 +
        assert!(declares_feature(
1551 +
            Some("later-thing, Certified-Tags ,x"),
1552 +
            FEATURE_CERTIFIED_TAGS
1553 +
        ));
1554 +
        assert!(!declares_feature(
1555 +
            Some("certified-tags-v2"),
1556 +
            FEATURE_CERTIFIED_TAGS
1557 +
        ));
1558 +
        assert!(!declares_feature(Some(""), FEATURE_CERTIFIED_TAGS));
1559 +
        assert!(!declares_feature(None, FEATURE_CERTIFIED_TAGS));
1560 +
    }
1561 +
1562 +
    #[test]
1327 1563
    fn wire_round_trips() {
1328 1564
        let c = crate::objects::tests::sample_change();
1329 1565
        let sk = crate::identity::SecretKey {
…
100 unchanged lines
1430 1666
                integration: vec![],
1431 1667
            },
1432 1668
        ];
1433 -
        let tag = TagRef {
1434 -
            name: "v1".into(),
1435 -
            state: ObjectRef([2; 32]),
1436 -
            heads: vec![c.hash()],
1437 -
            attestation: None,
1438 -
        };
1669 +
        let tag = TagRef::new("v1".into(), ObjectRef([2; 32]), vec![c.hash()], None);
1439 1670
        let mut resp = RefsResponse {
1440 1671
            refs,
1441 1672
            policy: Some(SignedPolicy {
…
8 unchanged lines
1450 1681
            default_bookmark: Some("develop".into()),
1451 1682
            log: Some("hub.example/log\n2\nroot\n\n- sig\n".into()),
1452 1683
            witnesses: vec![],
1684 +
            withdrawn: vec![],
1453 1685
        };
1454 1686
        // Without integrated times (spec §14.8) nothing is added: an old
1455 1687
        // peer's answer, and what it reads.
…
30 unchanged lines
1486 1718
        assert_eq!(back.history, resp.history);
1487 1719
        assert_eq!(back.tags, vec![tag.clone()]);
1488 1720
1721 +
        // A tag's acceptance and log place (oxygen#129): keys 4 to 6, only
1722 +
        // when there are any, so a tag without them encodes as before.
1723 +
        let crate::cbor::Value::Map(m) = crate::cbor::decode(&new).unwrap() else {
1724 +
            panic!()
1725 +
        };
1726 +
        let crate::cbor::Value::Array(tags) = &m[&3] else {
1727 +
            panic!()
1728 +
        };
1729 +
        let crate::cbor::Value::Map(plain) = &tags[0] else {
1730 +
            panic!()
1731 +
        };
1732 +
        assert_eq!(plain.keys().copied().collect::<Vec<_>>(), vec![0, 1, 2, 3]);
1733 +
        let mut full = tag.clone();
1734 +
        full.name = "v2".into();
1735 +
        full.attestation = Some(SignedAttest {
1736 +
            attest: att.clone(),
1737 +
            sigs: vec![sig.clone()],
1738 +
        });
1739 +
        full.accepted = full.attestation.clone();
1740 +
        full.logged = Some(Logged {
1741 +
            index: 7,
1742 +
            entry: vec![4],
1743 +
            proof: vec![[3; 32]],
1744 +
        });
1745 +
        full.integration = vec![Integration {
1746 +
            note: "hub.example/log\n8\nroot\n\n- sig\n".into(),
1747 +
            proof: vec![],
1748 +
        }];
1749 +
        resp.tags = vec![full.clone(), tag.clone()];
1750 +
        let bytes = wire::encode_refs([3; 16], &resp);
1751 +
        assert_eq!(wire::decode_refs(&bytes).unwrap().1.tags, resp.tags);
1752 +
        // In a push too.
1753 +
        let pushed = wire::encode_push(&Bundle::default(), &[], None, &resp.tags);
1754 +
        assert_eq!(wire::decode_push(&pushed).unwrap().3, resp.tags);
1755 +
        // A reader that knows only keys 0 to 3 (oxy 0.5.0 and older, and
1756 +
        // the runners built with it) reads the same tags from the new answer.
1757 +
        let stripped: Vec<TagRef> = resp
1758 +
            .tags
1759 +
            .iter()
1760 +
            .map(|t| {
1761 +
                TagRef::new(
1762 +
                    t.name.clone(),
1763 +
                    t.state,
1764 +
                    t.heads.clone(),
1765 +
                    t.attestation.clone(),
1766 +
                )
1767 +
            })
1768 +
            .collect();
1769 +
        assert_eq!(wire::tags_as_0_5_read_them(&bytes).unwrap(), stripped);
1770 +
1489 1771
        let (h, w) = wire::decode_negotiate(&wire::encode_negotiate(
1490 1772
            &[ObjectRef([1; 32])],
1491 1773
            &[ObjectRef([2; 32])],
…
67 unchanged lines
modified crates/oxy-core/src/repo/bookmarks.rs +1 −1
…
497 unchanged lines
498 498
                .get(p)
499 499
                .and_then(|m| m.bookmark.clone());
500 500
            super::subrepos::read_pin(&self.root.join(p), hint.as_deref())
501 -
                .is_ok_and(|have| have == want)
501 +
                .is_ok_and(|have| have.same_state(&want))
502 502
        };
503 503
        touched.retain(|p| !at_target_pin(p));
504 504
        let same = |p: &String| {
…
216 unchanged lines
modified crates/oxy-core/src/repo/checkout.rs +46 −1
…
12 unchanged lines
13 13
    pub notes: Vec<String>,
14 14
    /// How many changes of the state left behind the new one lacks.
15 15
    pub lacks: usize,
16 +
    /// Union files whose adjacent insertions were settled by keeping both
17 +
    /// sides (oxygen#145).
18 +
    pub kept_both: Vec<KeptBoth>,
16 19
}
17 20
18 21
/// Text files rendered at a time by a checkout: bounds its memory.
…
389 unchanged lines
408 411
        }
409 412
410 413
        drop(_render);
414 +
        // How the files rendered: which way each graph was ordered, summed
415 +
        // over every core (`OXY_TIMINGS`).
416 +
        crate::timing::report_totals();
411 417
        let recording = crate::timing::phase("checkout: record");
412 418
        let (op_ref, op) = self.head_op()?;
413 419
        let wc = self.new_working_copy(heads.clone(), "");
…
35 unchanged lines
449 455
            *self.walked_dirs.borrow_mut() = Some((dirs, old_manifest.nested.clone()));
450 456
        }
451 457
        self.write_manifest_entries(report.conflicted, false, &entries, &HashMap::new())?;
458 +
        if report.conflicted && !self.bare {
459 +
            report.kept_both = self.settle_conflicts(command)?;
460 +
        }
452 461
        let _ = self.run_hook(
453 462
            "post-checkout",
454 463
            &format!(
…
62 unchanged lines
517 526
            if i == 0 && *comp == OXY_DIR {
518 527
                return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}"));
519 528
            }
529 +
            // A `.git` path could plant hooks a side-by-side Git would run;
530 +
            // Git refuses them at any depth and in any case, and so does oxy.
531 +
            if comp.eq_ignore_ascii_case(".git") {
532 +
                return fail(format!("refusing to write a .git path: {rel:?}"));
533 +
            }
520 534
            if i + 1 < comps.len() {
521 535
                full.push(comp);
522 536
                if let Ok(m) = fs::symlink_metadata(&full) {
…
64 unchanged lines
587 601
}
588 602
589 603
/// Refuse a path that could escape the workspace (spec §7.7): an empty,
590 -
/// `.` or `..` component, a backslash, or a first component `.oxy`.
604 +
/// `.` or `..` component, a backslash, a first component `.oxy`, or a
605 +
/// `.git` component anywhere (it could plant Git hooks).
591 606
fn check_path_name(rel: &str) -> Result<()> {
592 607
    for (i, comp) in rel.split('/').enumerate() {
593 608
        if comp.is_empty() || comp == "." || comp == ".." || comp.contains('\\') {
…
2 unchanged lines
596 611
        if i == 0 && comp == OXY_DIR {
597 612
            return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}"));
598 613
        }
614 +
        if comp.eq_ignore_ascii_case(".git") {
615 +
            return fail(format!("refusing to write a .git path: {rel:?}"));
616 +
        }
599 617
    }
600 618
    Ok(())
601 619
}
…
37 unchanged lines
639 657
    fs::write(full, bytes)?;
640 658
    set_executable(full, exec)
641 659
}
660 +
661 +
#[cfg(test)]
662 +
mod path_name_tests {
663 +
    use super::check_path_name;
664 +
665 +
    #[test]
666 +
    fn git_paths_are_refused_at_any_depth_and_case() {
667 +
        for bad in [
668 +
            ".git/hooks/post-checkout",
669 +
            "sub/.git/config",
670 +
            ".GIT/hooks/x",
671 +
            "a/.Git",
672 +
            ".oxy/x",
673 +
            "../x",
674 +
        ] {
675 +
            assert!(check_path_name(bad).is_err(), "{bad}");
676 +
        }
677 +
        for good in [
678 +
            "src/main.rs",
679 +
            ".gitignore",
680 +
            ".github/workflows/ci.yml",
681 +
            "a/.gitmodules",
682 +
        ] {
683 +
            assert!(check_path_name(good).is_ok(), "{good}");
684 +
        }
685 +
    }
686 +
}
modified crates/oxy-core/src/repo/checkpoint.rs +52 −14
…
112 unchanged lines
113 113
        write_atomic(&self.checkpoint_pointer(bookmark), r.to_hex().as_bytes())
114 114
    }
115 115
116 -
    /// A bundle holding a checkpoint of exactly `heads` with its payload
117 -
    /// (spec §16.1 `checkpoint`); the current bookmark checkpoint is reused
118 -
    /// when it matches, otherwise one is made and, when `heads` are the
119 -
    /// bookmark's, kept.
120 -
    pub fn checkpoint_bundle_for(&self, heads: &[ChangeHash]) -> Result<Bundle> {
116 +
    /// The checkpoint of exactly `heads`: the bookmark's latest when it
117 +
    /// is that state, else one made now (kept as the bookmark's when the
118 +
    /// bookmark is at `heads`).
119 +
    fn checkpoint_ref_for(&self, heads: &[ChangeHash]) -> Result<ObjectRef> {
120 +
        let _t = crate::timing::phase("checkpoint: find or make");
121 121
        let wanted = StateObject::new(heads.to_vec()).heads;
122 122
        let bookmark = self.config.bookmark.clone();
123 123
        let current = self.bookmark_heads()?;
124 -
        let r = match self.latest_checkpoint(&bookmark)? {
124 +
        Ok(match self.latest_checkpoint(&bookmark)? {
125 125
            Some((r, cp)) if cp.heads == wanted => r,
126 126
            _ => {
127 127
                let state = self.load_state(&wanted)?;
…
9 unchanged lines
137 137
                    self.store.put(ObjectType::Checkpoint, &cp.encode())?
138 138
                }
139 139
            }
140 -
        };
140 +
        })
141 +
    }
142 +
143 +
    /// A checkpoint of exactly `heads` without its graph blobs (spec
144 +
    /// §16.1 `checkpoint-index`, oxygen#137): the checkpoint object, its
145 +
    /// payload blob and the blob's chunks. The client asks for the graph
146 +
    /// blobs the payload names in parts, with `objects`, so neither side
147 +
    /// holds a big state's whole answer at once.
148 +
    pub fn checkpoint_index_for(&self, heads: &[ChangeHash]) -> Result<Bundle> {
149 +
        let r = self.checkpoint_ref_for(heads)?;
141 150
        let cp = Checkpoint::decode(&self.store.get_typed(&r, ObjectType::Checkpoint)?)?;
142 151
        let mut bundle = Bundle::default();
143 -
        for c in Blob::decode(&self.store.get_typed(&cp.payload, ObjectType::Blob)?)?.chunks {
144 -
            bundle.objects.push(self.store.get(&c)?);
152 +
        let chunks = Blob::decode(&self.store.get_typed(&cp.payload, ObjectType::Blob)?)?.chunks;
153 +
        for got in self.store.get_many(&chunks) {
154 +
            bundle.objects.push(got?);
145 155
        }
146 156
        bundle.objects.push(self.store.get(&cp.payload)?);
147 -
        for g in self.checkpoint_graph_blobs(&cp)? {
148 -
            for c in Blob::decode(&self.store.get_typed(&g, ObjectType::Blob)?)?.chunks {
149 -
                bundle.objects.push(self.store.get(&c)?);
157 +
        bundle.objects.push((ObjectType::Checkpoint, cp.encode()));
158 +
        Ok(bundle)
159 +
    }
160 +
161 +
    /// A checkpoint of exactly `heads` with its graph blobs and their
162 +
    /// chunks, in one answer (spec §16.1 `checkpoint`): what a client
163 +
    /// older than `checkpoint-index` asks for.
164 +
    pub fn checkpoint_bundle_for(&self, heads: &[ChangeHash]) -> Result<Bundle> {
165 +
        let mut bundle = self.checkpoint_index_for(heads)?;
166 +
        let _t = crate::timing::phase("checkpoint: gather its objects");
167 +
        let cp = match bundle.objects.last() {
168 +
            Some((ObjectType::Checkpoint, body)) => Checkpoint::decode(body)?,
169 +
            _ => return fail("a checkpoint index without its checkpoint"),
170 +
        };
171 +
        // The graph blobs and their chunks in two parallel reads (#137): a
172 +
        // big state's are tens of thousands, and one by one they took six
173 +
        // minutes for VS Code.
174 +
        let graphs = self.checkpoint_graph_blobs(&cp)?;
175 +
        let mut blobs = Vec::with_capacity(graphs.len());
176 +
        for got in self.store.get_many(&graphs) {
177 +
            blobs.push(got?);
178 +
        }
179 +
        let mut chunks = Vec::new();
180 +
        for (ty, body) in &blobs {
181 +
            if *ty != ObjectType::Blob {
182 +
                return fail("a checkpoint graph is not a blob");
150 183
            }
151 -
            bundle.objects.push(self.store.get(&g)?);
184 +
            chunks.extend(Blob::decode(body)?.chunks);
185 +
        }
186 +
        let last = bundle.objects.pop();
187 +
        for got in self.store.get_many(&chunks) {
188 +
            bundle.objects.push(got?);
152 189
        }
153 -
        bundle.objects.push((ObjectType::Checkpoint, cp.encode()));
190 +
        bundle.objects.extend(blobs);
191 +
        bundle.objects.extend(last);
154 192
        Ok(bundle)
155 193
    }
156 194
…
80 unchanged lines
modified crates/oxy-core/src/repo/config.rs +32 −1
…
24 unchanged lines
25 25
    /// `keyless.hub`: the hub that certifies them (default: the default
26 26
    /// remote's hub).
27 27
    pub keyless_hub: Option<String>,
28 +
    /// `author.for` (oxygen#82): in a keyless agent's home, the account it
29 +
    /// acts for; the home then authors as that person's newest identity,
30 +
    /// which the hub names with each certificate, rather than a pinned
31 +
    /// `author.key` that goes stale with every rotation.
32 +
    pub author_for: Option<String>,
28 33
    /// `core.sign`: sign every change at commit (`true`) or leave signing to
29 34
    /// push-time seals (`false`, the spec default).
30 35
    pub sign_changes: bool,
…
10 unchanged lines
41 46
    /// `git.claim`: a Git import authors the commits whose Git author email
42 47
    /// is this repository's identity's with its key (oxygen#97).
43 48
    pub git_claim: bool,
49 +
    /// `git.colocate`: keep a `.git` beside `.oxy` with the current
50 +
    /// bookmark exported after every operation, so editors' Git features
51 +
    /// work on an Oxygen checkout (one way: oxy to Git).
52 +
    pub git_colocate: bool,
44 53
    /// `core.op-retention`: days for which operations keep their states reachable (spec §12.1, §21).
45 54
    pub op_retention_days: u64,
46 55
    /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21).
…
48 unchanged lines
95 104
                .and_then(|v| KeyId::from_hex(&v)),
96 105
            signer_keyless: std::env::var("OXY_SIGNER").is_ok_and(|v| v == "keyless"),
97 106
            keyless_hub: None,
107 +
            author_for: None,
98 108
            sign_changes: false,
99 109
            bookmark: "main".into(),
100 110
            remotes: BTreeMap::new(),
…
1 unchanged lines
102 112
            remote_cas: BTreeMap::new(),
103 113
            default_remote: None,
104 114
            git_claim: false,
115 +
            git_colocate: false,
105 116
            op_retention_days: 90,
106 117
            gc_grace_days: 14,
107 118
            max_new_file: 100 << 20,
108 119
            snapshot_retention_days: 7,
109 120
            checkpoint_interval: 256,
110 -
            push_batch_bytes: 256 << 20,
121 +
            // Well under the 100 MB a proxy in front of a hub may take
122 +
            // (oxygen#133).
123 +
            push_batch_bytes: 64 << 20,
111 124
            max_blob: None,
112 125
            sparse_paths: Vec::new(),
113 126
            semantic_deps: false,
…
65 unchanged lines
179 192
            }
180 193
            "workspace.bookmark" => self.bookmark = value.into(),
181 194
            "keyless.hub" => self.keyless_hub = (!value.is_empty()).then(|| value.to_string()),
195 +
            "author.for" => self.author_for = (!value.is_empty()).then(|| value.to_string()),
182 196
            // Read by the credential store from the user file.
183 197
            "auth.helper" => {}
184 198
            "core.op-retention" => self.op_retention_days = parse_days(value)?,
…
22 unchanged lines
207 221
            }
208 222
            "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"),
209 223
            "git.claim" => self.git_claim = matches!(value, "true" | "1" | "yes"),
224 +
            "git.colocate" => self.git_colocate = matches!(value, "true" | "1" | "yes"),
210 225
            "filter.paths" => {
211 226
                self.sparse_paths = value
212 227
                    .split(',')
…
77 unchanged lines
290 305
                c.repo_layer.insert(k, v);
291 306
            }
292 307
        }
308 +
        // author.for (oxygen#82): the identity the hub last named as the
309 +
        // person's newest, kept in the home; a repository's own
310 +
        // author.key pin still wins.
311 +
        if c.author_for.is_some() && !c.repo_layer.contains_key("author.key") {
312 +
            if let Some(k) = user_path
313 +
                .and_then(Path::parent)
314 +
                .and_then(|home| fs::read_to_string(home.join("keys").join(AUTHOR_FOR_FILE)).ok())
315 +
                .and_then(|s| KeyId::from_hex(s.trim()))
316 +
            {
317 +
                c.author_key = Some(k);
318 +
            }
319 +
        }
293 320
        Ok(c)
294 321
    }
295 322
…
7 unchanged lines
303 330
    }
304 331
}
305 332
333 +
/// In a home's `keys/`: the key of the identity `author.for` follows,
334 +
/// as the hub last named it (oxygen#82).
335 +
pub const AUTHOR_FOR_FILE: &str = "author.for";
336 +
306 337
/// A byte count with an optional `K`, `M`, or `G` suffix (binary units).
307 338
/// `storage.cache` when unset: 2 GiB.
308 339
pub const DEFAULT_STORAGE_CACHE: u64 = 2 << 30;
…
51 unchanged lines
modified crates/oxy-core/src/repo/fsck.rs +1 −0
…
102 unchanged lines
103 103
        ObjectType::Receipt => crate::receipt::Receipt::decode(body).map(|_| ()),
104 104
        ObjectType::Certificate => crate::cert::Certificate::decode(body).map(|_| ()),
105 105
        ObjectType::Grant => crate::cert::Grant::decode(body).map(|_| ()),
106 +
        ObjectType::Artifact => crate::artifact::Artifact::decode(body).map(|_| ()),
106 107
        ObjectType::State => crate::objects::StateObject::decode(body).map(|_| ()),
107 108
    };
108 109
    r.map_err(|e| e.to_string())
…
408 unchanged lines
modified crates/oxy-core/src/repo/gc.rs +4 −0
…
143 unchanged lines
144 144
        states.extend(view_now.tags.values().copied());
145 145
        for name in view_now.tags.keys() {
146 146
            if let Some((r, _)) = self.tag_attest(name)? {
147 +
                // With its issuer's acceptance (spec §14.7).
148 +
                if let Some(acc) = self.acceptance_of(&r)? {
149 +
                    attests.push(acc.attest.hash());
150 +
                }
147 151
                attests.push(r);
148 152
            }
149 153
        }
…
98 unchanged lines
modified crates/oxy-core/src/repo/git.rs +361 −13
…
455 unchanged lines
456 456
457 457
impl Repo {
458 458
    fn git_marks_dir(&self, git_dir: &Path) -> Result<PathBuf> {
459 -
        let canonical = git_dir
460 -
            .canonicalize()
461 -
            .unwrap_or_else(|_| git_dir.to_path_buf());
459 +
        let canonical =
460 +
            crate::portable::canonical(git_dir).unwrap_or_else(|_| git_dir.to_path_buf());
462 461
        let key = hash_object(ObjectType::Chunk, canonical.to_string_lossy().as_bytes()).short(16);
463 462
        let dir = self.oxy.join("git").join(key);
464 463
        fs::create_dir_all(&dir)?;
…
81 unchanged lines
546 545
    /// new changes join the bookmark like a pull.
547 546
    pub fn git_fetch(&self, url: &str, branch: &str) -> Result<GitImportReport> {
548 547
        // A local path, made absolute: git runs it from inside the mirror.
549 -
        let local = Path::new(url)
550 -
            .canonicalize()
548 +
        let local = crate::portable::canonical(Path::new(url))
551 549
            .ok()
552 550
            .filter(|_| !url.contains("://"));
553 551
        let absolute = local.as_ref().map(|p| p.to_string_lossy().into_owned());
…
1189 unchanged lines
1743 1741
    }
1744 1742
1745 1743
    fn git_author(&self, c: &Change) -> String {
1746 -
        match self.store.get_identity(&c.author) {
1747 -
            Ok(id) if !id.name.is_empty() => format!(
1748 -
                "{} <{}>",
1749 -
                id.name.replace(['<', '>', '\n'], " "),
1750 -
                id.email.replace(['<', '>', '\n', ' '], "")
1751 -
            ),
1752 -
            _ => "oxy <oxy@localhost>".to_string(),
1753 -
        }
1744 +
        self.git_author_key(&c.author)
1754 1745
    }
1755 1746
1756 1747
    #[allow(clippy::too_many_arguments)]
…
422 unchanged lines
2179 2170
        w.len()
2180 2171
    )
2181 2172
}
2173 +
2174 +
/// What `git_export_graph` wrote.
2175 +
#[derive(Debug, Clone, Default, PartialEq, Eq)]
2176 +
pub struct GitGraphReport {
2177 +
    /// Changes written as commits this time (the rest were exported before).
2178 +
    pub commits: usize,
2179 +
    /// Branches and tags pointed at their commits.
2180 +
    pub branches: usize,
2181 +
    pub tags: usize,
2182 +
    /// Bookmarks or tags whose names Git does not take as refs.
2183 +
    pub skipped_refs: Vec<String>,
2184 +
    /// Subrepo nodes with Oxygen pins, which Git cannot represent.
2185 +
    pub skipped_subrepos: usize,
2186 +
}
2187 +
2188 +
/// A bookmark or tag name Git takes as a ref without escaping.
2189 +
fn git_ref_ok(name: &str) -> bool {
2190 +
    !name.is_empty()
2191 +
        && !name.starts_with(['-', '/', '.'])
2192 +
        && !name.ends_with(['/', '.'])
2193 +
        && !name.contains("..")
2194 +
        && !name.contains("//")
2195 +
        && !name.ends_with(".lock")
2196 +
        && name
2197 +
            .bytes()
2198 +
            .all(|b| b.is_ascii_alphanumeric() || b"-_./+".contains(&b))
2199 +
}
2200 +
2201 +
impl Repo {
2202 +
    /// Export every bookmark and tag to the bare Git repository at
2203 +
    /// `git_dir` from the change graph (spec §23, oxygenhub D1), for a hub
2204 +
    /// to serve to Git clients. Unlike `git_export`, which follows one
2205 +
    /// checkout's operation log, this depends only on the changes, so the
2206 +
    /// same changes give the same Git commits wherever it runs:
2207 +
    ///
2208 +
    /// - each change is a commit whose tree is the state of the change and
2209 +
    ///   everything it depends on, whose parents are its dependencies'
2210 +
    ///   commits, with the change's author, time and message and its
2211 +
    ///   `Change-Id:` and `Oxy-Hash:` trailers (a change that depends on
2212 +
    ///   nothing is a root, so Git sees Oxygen's dependencies, not the order
2213 +
    ///   changes were made in);
2214 +
    /// - a bookmark with one head is a branch at that head's commit; with
2215 +
    ///   several, a merge of their commits whose tree is the bookmark's
2216 +
    ///   state, at the newest head's time;
2217 +
    /// - a tag points at its heads' commit, or such a merge.
2218 +
    ///
2219 +
    /// Commits already exported are remembered (marks), so a later run
2220 +
    /// writes only new changes. Branches for bookmarks that are gone are
2221 +
    /// deleted.
2222 +
    pub fn git_export_graph(&self, git_dir: &Path) -> Result<GitGraphReport> {
2223 +
        if !git_dir.join("HEAD").exists() {
2224 +
            fs::create_dir_all(git_dir)?;
2225 +
            git(git_dir, &["init", "--bare", "-q"])?;
2226 +
        }
2227 +
        let marks_dir = self.git_marks_dir(git_dir)?;
2228 +
        let git_marks = marks_dir.join("git-marks");
2229 +
        let graph_marks = marks_dir.join("graph-marks");
2230 +
        let mut marks = Self::read_oxy_marks(&graph_marks)?;
2231 +
        let mut next = marks.values().copied().max().unwrap_or(0) + 1;
2232 +
        let mut report = GitGraphReport::default();
2233 +
        let mut stream: Vec<u8> = Vec::new();
2234 +
2235 +
        let mut refs: Vec<(String, Vec<ChangeHash>)> = Vec::new();
2236 +
        for b in self.bookmarks()? {
2237 +
            if git_ref_ok(&b.name) {
2238 +
                refs.push((format!("refs/heads/{}", b.name), b.heads));
2239 +
            } else {
2240 +
                report.skipped_refs.push(b.name);
2241 +
            }
2242 +
        }
2243 +
        let tags = self.tags()?;
2244 +
        for t in &tags {
2245 +
            if git_ref_ok(&t.name) {
2246 +
                refs.push((format!("refs/tags/{}", t.name), t.heads.clone()));
2247 +
            } else {
2248 +
                report.skipped_refs.push(t.name.clone());
2249 +
            }
2250 +
        }
2251 +
2252 +
        // Every change the refs reach, dependencies before dependents: a
2253 +
        // depth-first walk in hash order, so the order is the same everywhere.
2254 +
        let mut order: Vec<ChangeHash> = Vec::new();
2255 +
        let mut seen: HashSet<ChangeHash> = HashSet::new();
2256 +
        let mut metas: HashMap<ChangeHash, crate::state::ChangeMeta> = HashMap::new();
2257 +
        let mut roots: Vec<ChangeHash> = refs.iter().flat_map(|(_, h)| h.iter().copied()).collect();
2258 +
        roots.sort();
2259 +
        roots.dedup();
2260 +
        for root in roots {
2261 +
            if !seen.insert(root) {
2262 +
                continue;
2263 +
            }
2264 +
            let mut stack: Vec<(ChangeHash, bool)> = vec![(root, false)];
2265 +
            while let Some((h, done)) = stack.pop() {
2266 +
                if done {
2267 +
                    order.push(h);
2268 +
                    continue;
2269 +
                }
2270 +
                stack.push((h, true));
2271 +
                let meta = self.store.get_change_meta(&h)?;
2272 +
                let mut deps = meta.deps.clone();
2273 +
                deps.sort();
2274 +
                for d in deps.into_iter().rev() {
2275 +
                    if seen.insert(d) {
2276 +
                        stack.push((d, false));
2277 +
                    }
2278 +
                }
2279 +
                metas.insert(h, meta);
2280 +
            }
2281 +
        }
2282 +
2283 +
        for h in &order {
2284 +
            if marks.contains_key(h) {
2285 +
                continue;
2286 +
            }
2287 +
            let meta = &metas[h];
2288 +
            let snapshot = self.load_state(&[*h])?.materialize();
2289 +
            let mut message = meta.message.clone();
2290 +
            if !message.ends_with('\n') {
2291 +
                message.push('\n');
2292 +
            }
2293 +
            message.push_str(&format!(
2294 +
                "\nChange-Id: {}\nOxy-Hash: {}\n",
2295 +
                meta.change_id.to_hex(),
2296 +
                h.to_hex()
2297 +
            ));
2298 +
            if snapshot.is_conflicted() {
2299 +
                message.push_str("Oxy-Conflicted: true\n");
2300 +
            }
2301 +
            let mut deps = meta.deps.clone();
2302 +
            deps.sort();
2303 +
            let parents: Vec<String> = deps
2304 +
                .iter()
2305 +
                .filter_map(|d| marks.get(d))
2306 +
                .map(|m| format!(":{m}"))
2307 +
                .collect();
2308 +
            let mark = next;
2309 +
            next += 1;
2310 +
            if parents.is_empty() {
2311 +
                // Without `from`, fast-import would make the scratch ref's
2312 +
                // tip its parent; a change that depends on nothing is a root.
2313 +
                stream.extend_from_slice(b"reset refs/oxy/export\n\n");
2314 +
            }
2315 +
            self.write_commit(
2316 +
                &mut stream,
2317 +
                "refs/oxy/export",
2318 +
                mark,
2319 +
                &self.git_author_key(&meta.author),
2320 +
                meta.hlc.wall_ms / 1000,
2321 +
                &message,
2322 +
                &parents,
2323 +
                &snapshot,
2324 +
            )?;
2325 +
            marks.insert(*h, mark);
2326 +
            report.commits += 1;
2327 +
        }
2328 +
2329 +
        let mut wanted: HashSet<String> = HashSet::new();
2330 +
        for (name, heads) in &refs {
2331 +
            let mut heads = heads.clone();
2332 +
            heads.sort();
2333 +
            heads.dedup();
2334 +
            let target = match heads.as_slice() {
2335 +
                [] => continue,
2336 +
                [one] => format!(":{}", marks[one]),
2337 +
                many => {
2338 +
                    // A merge of the heads, named by them so it is made once.
2339 +
                    let key = hash_object(
2340 +
                        ObjectType::Chunk,
2341 +
                        many.iter()
2342 +
                            .map(|h| h.to_hex())
2343 +
                            .collect::<Vec<_>>()
2344 +
                            .join(" ")
2345 +
                            .as_bytes(),
2346 +
                    );
2347 +
                    if let Some(m) = marks.get(&key) {
2348 +
                        format!(":{m}")
2349 +
                    } else {
2350 +
                        let snapshot = self.load_state(many)?.materialize();
2351 +
                        let newest = many.iter().map(|h| metas[h].hlc.wall_ms).max().unwrap_or(0);
2352 +
                        let mut message = format!(
2353 +
                            "Merge of {} heads\n\nOxy-Heads: {}\n",
2354 +
                            many.len(),
2355 +
                            many.iter()
2356 +
                                .map(|h| h.to_hex())
2357 +
                                .collect::<Vec<_>>()
2358 +
                                .join(" ")
2359 +
                        );
2360 +
                        if snapshot.is_conflicted() {
2361 +
                            message.push_str("Oxy-Conflicted: true\n");
2362 +
                        }
2363 +
                        let parents: Vec<String> =
2364 +
                            many.iter().map(|h| format!(":{}", marks[h])).collect();
2365 +
                        let mark = next;
2366 +
                        next += 1;
2367 +
                        self.write_commit(
2368 +
                            &mut stream,
2369 +
                            "refs/oxy/export",
2370 +
                            mark,
2371 +
                            "oxy <oxy@localhost>",
2372 +
                            newest / 1000,
2373 +
                            &message,
2374 +
                            &parents,
2375 +
                            &snapshot,
2376 +
                        )?;
2377 +
                        marks.insert(key, mark);
2378 +
                        format!(":{mark}")
2379 +
                    }
2380 +
                }
2381 +
            };
2382 +
            stream.extend_from_slice(format!("reset {name}\nfrom {target}\n\n").as_bytes());
2383 +
            wanted.insert(name.clone());
2384 +
            if name.starts_with("refs/tags/") {
2385 +
                report.tags += 1;
2386 +
            } else {
2387 +
                report.branches += 1;
2388 +
            }
2389 +
        }
2390 +
        stream.extend_from_slice(b"done\n");
2391 +
        run_fast_import(git_dir, &stream, &git_marks)?;
2392 +
        let text: String = marks
2393 +
            .iter()
2394 +
            .map(|(h, m)| format!("{} {m}\n", h.to_hex()))
2395 +
            .collect();
2396 +
        write_atomic(&graph_marks, text.as_bytes())?;
2397 +
        let _ = git(git_dir, &["update-ref", "-d", "refs/oxy/export"]);
2398 +
        // Branches and tags of bookmarks and tags that are gone.
2399 +
        let listed = git(
2400 +
            git_dir,
2401 +
            &[
2402 +
                "for-each-ref",
2403 +
                "--format=%(refname)",
2404 +
                "refs/heads",
2405 +
                "refs/tags",
2406 +
            ],
2407 +
        )?;
2408 +
        for r in listed
2409 +
            .lines()
2410 +
            .map(str::trim)
2411 +
            .filter(|r| !r.is_empty() && !wanted.contains(*r))
2412 +
        {
2413 +
            git(git_dir, &["update-ref", "-d", r])?;
2414 +
        }
2415 +
        // HEAD names the main bookmark when there is one, so a clone checks it out.
2416 +
        let main = ["main", "master", "trunk", "develop"]
2417 +
            .into_iter()
2418 +
            .find(|b| wanted.contains(&format!("refs/heads/{b}")));
2419 +
        if let Some(b) = main {
2420 +
            git(
2421 +
                git_dir,
2422 +
                &["symbolic-ref", "HEAD", &format!("refs/heads/{b}")],
2423 +
            )?;
2424 +
        }
2425 +
        report.skipped_subrepos = self.git_export_skipped.replace(0);
2426 +
        Ok(report)
2427 +
    }
2428 +
2429 +
    /// `Name <email>` for a Git commit, from the author's identity.
2430 +
    fn git_author_key(&self, key: &KeyId) -> String {
2431 +
        match self.store.get_identity(key) {
2432 +
            Ok(id) if !id.name.is_empty() => format!(
2433 +
                "{} <{}>",
2434 +
                id.name.replace(['<', '>', '\n'], " "),
2435 +
                id.email.replace(['<', '>', '\n', ' '], "")
2436 +
            ),
2437 +
            _ => "oxy <oxy@localhost>".to_string(),
2438 +
        }
2439 +
    }
2440 +
}
2441 +
2442 +
/// Feed a fast-import stream to `git fast-import` in `git_dir`, keeping its
2443 +
/// marks in `git_marks` so a later run can refer to commits it wrote.
2444 +
fn run_fast_import(git_dir: &Path, stream: &[u8], git_marks: &Path) -> Result<()> {
2445 +
    let mut cmd = Command::new("git");
2446 +
    // `--force`: a ref may move to an unrelated commit (a bookmark rewritten).
2447 +
    cmd.arg("-C")
2448 +
        .arg(git_dir)
2449 +
        .args(["fast-import", "--quiet", "--done", "--force"])
2450 +
        .arg(format!("--export-marks={}", git_marks.display()));
2451 +
    if git_marks.exists() {
2452 +
        cmd.arg(format!("--import-marks={}", git_marks.display()));
2453 +
    }
2454 +
    let mut child = cmd
2455 +
        .stdin(Stdio::piped())
2456 +
        .stdout(Stdio::null())
2457 +
        .stderr(Stdio::piped())
2458 +
        .spawn()
2459 +
        .map_err(|e| RepoError::Msg(format!("running git fast-import: {e}")))?;
2460 +
    child.stdin.take().unwrap().write_all(stream)?;
2461 +
    let out = child.wait_with_output()?;
2462 +
    if !out.status.success() {
2463 +
        return fail(format!(
2464 +
            "git fast-import failed: {}",
2465 +
            String::from_utf8_lossy(&out.stderr).trim()
2466 +
        ));
2467 +
    }
2468 +
    Ok(())
2469 +
}
2470 +
2471 +
impl Repo {
2472 +
    /// Bring the side-by-side Git repository up to date (`git.colocate`):
2473 +
    /// a `.git` beside `.oxy`, made with `git init` when missing and told to
2474 +
    /// leave `.oxy/` alone, holding the current bookmark as exported by
2475 +
    /// `git_export_as` (one commit per state the bookmark passed through),
2476 +
    /// with HEAD on that branch and Git's index at its tree, so `git status`
2477 +
    /// shows only the working copy's uncommitted work. Files on disk are
2478 +
    /// never touched. Does nothing when the bookmark has not moved since the
2479 +
    /// last time; `Ok(true)` when it exported.
2480 +
    pub fn git_colocate_sync(&self) -> Result<bool> {
2481 +
        if self.bare {
2482 +
            return fail("a bare repository has no working copy for a .git beside it");
2483 +
        }
2484 +
        let git_dir = self.root.join(".git");
2485 +
        if git_dir.is_file() {
2486 +
            return fail(".git is a file (a Git worktree or submodule link); remove it to keep a side-by-side Git repository here");
2487 +
        }
2488 +
        if !git_dir.join("HEAD").exists() {
2489 +
            git(&self.root, &["init", "-q"])?;
2490 +
        }
2491 +
        let exclude = git_dir.join("info").join("exclude");
2492 +
        let have = fs::read_to_string(&exclude).unwrap_or_default();
2493 +
        if !have.lines().any(|l| l.trim() == "/.oxy/") {
2494 +
            fs::create_dir_all(exclude.parent().expect("info/"))?;
2495 +
            let sep = if have.is_empty() || have.ends_with('\n') {
2496 +
                ""
2497 +
            } else {
2498 +
                "\n"
2499 +
            };
2500 +
            fs::write(
2501 +
                &exclude,
2502 +
                format!("{have}{sep}# Oxygen's own repository (oxy git colocate)\n/.oxy/\n"),
2503 +
            )?;
2504 +
        }
2505 +
        let bookmark = self.config.bookmark.clone();
2506 +
        let state = self
2507 +
            .view()?
2508 +
            .bookmarks
2509 +
            .get(&bookmark)
2510 +
            .map(|s| s.to_hex())
2511 +
            .unwrap_or_default();
2512 +
        let stamp_file = self.oxy.join("git").join("colocate-stamp");
2513 +
        let stamp = format!("{bookmark} {state}");
2514 +
        if fs::read_to_string(&stamp_file).is_ok_and(|s| s.trim() == stamp) {
2515 +
            return Ok(false);
2516 +
        }
2517 +
        if !state.is_empty() {
2518 +
            self.git_export_as(&git_dir, &bookmark)?;
2519 +
            git(
2520 +
                &self.root,
2521 +
                &["symbolic-ref", "HEAD", &format!("refs/heads/{bookmark}")],
2522 +
            )?;
2523 +
            git(&self.root, &["read-tree", "HEAD"])?;
2524 +
        }
2525 +
        fs::create_dir_all(stamp_file.parent().expect("git/"))?;
2526 +
        fs::write(&stamp_file, stamp)?;
2527 +
        Ok(!state.is_empty())
2528 +
    }
2529 +
}
modified crates/oxy-core/src/repo/hosted.rs +91 −0
…
237 unchanged lines
238 238
        })
239 239
    }
240 240
241 +
    /// Create tag `name` for the state of `heads` as a host does for a
242 +
    /// signed-in user (oxygenhub#295): attested by `signer` (a delegate of
243 +
    /// the user, or the host's own key), with `identities` it needs, and
244 +
    /// received as a pushed tag is, so the same policy checks apply.
245 +
    pub fn host_tag_create(
246 +
        &self,
247 +
        name: &str,
248 +
        heads: &[ChangeHash],
249 +
        message: &str,
250 +
        signer: (&KeyId, &SecretKey),
251 +
        identities: &[Identity],
252 +
    ) -> Result<PushReport> {
253 +
        crate::objects::validate_name(name)
254 +
            .map_err(|e| RepoError::Msg(format!("tag name: {e}")))?;
255 +
        let st = self.load_state_history(heads)?;
256 +
        let heads = st.maximal(heads);
257 +
        if heads.is_empty() {
258 +
            return fail("a tag needs at least one change");
259 +
        }
260 +
        let state_object = StateObject::new(heads.clone());
261 +
        let state = state_object.hash();
262 +
        let body = AttestBody::Tag {
263 +
            name: name.into(),
264 +
            state,
265 +
            message: message.into(),
266 +
        };
267 +
        let mut bundle = self.hosted_bundle(identities);
268 +
        bundle
269 +
            .objects
270 +
            .push((ObjectType::State, state_object.encode()));
271 +
        let t = crate::protocol::TagRef::new(
272 +
            name.into(),
273 +
            state,
274 +
            heads,
275 +
            Some(self.host_attest(body, signer)?),
276 +
        );
277 +
        self.receive_push_report(bundle, vec![], None, vec![t])
278 +
    }
279 +
280 +
    /// Withdraw tag `name` as a host does for a signed-in user
281 +
    /// (oxygenhub#301): an `untag` attestation by `signer`, received as a
282 +
    /// pushed withdrawal is.
283 +
    pub fn host_tag_withdrawal(
284 +
        &self,
285 +
        name: &str,
286 +
        reason: &str,
287 +
        signer: (&KeyId, &SecretKey),
288 +
        identities: &[Identity],
289 +
    ) -> Result<PushReport> {
290 +
        let Some(state) = self.view()?.tags.get(name).copied() else {
291 +
            return fail(format!("no tag {name:?}"));
292 +
        };
293 +
        let body = AttestBody::Untag {
294 +
            name: name.into(),
295 +
            state,
296 +
            reason: reason.into(),
297 +
        };
298 +
        let t = crate::protocol::TagRef::new(
299 +
            name.into(),
300 +
            state,
301 +
            vec![],
302 +
            Some(self.host_attest(body, signer)?),
303 +
        );
304 +
        self.receive_push_report(self.hosted_bundle(identities), vec![], None, vec![t])
305 +
    }
306 +
307 +
    fn host_attest(
308 +
        &self,
309 +
        body: AttestBody,
310 +
        (key, sk): (&KeyId, &SecretKey),
311 +
    ) -> Result<SignedAttest> {
312 +
        let attest = Attest {
313 +
            repo: self.id()?,
314 +
            hlc: Hlc::now(),
315 +
            body,
316 +
        };
317 +
        let sig = sk.sign(Domain::Attest, key, &attest.hash());
318 +
        Ok(SignedAttest {
319 +
            attest,
320 +
            sigs: vec![sig],
321 +
        })
322 +
    }
323 +
324 +
    fn hosted_bundle(&self, identities: &[Identity]) -> Bundle {
325 +
        let mut bundle = Bundle::default();
326 +
        for id in identities {
327 +
            bundle.objects.push((ObjectType::Identity, id.encode()));
328 +
        }
329 +
        bundle
330 +
    }
331 +
241 332
    /// Apply a host-made update as if it had been pushed: the new change
242 333
    /// (with its signatures) if any, the identities it needs, and the
243 334
    /// reference update, all through `receive_push_report`, so the same
…
747 unchanged lines
modified crates/oxy-core/src/repo/identity.rs +125 −3
…
17 unchanged lines
18 18
    Invalid(String),
19 19
}
20 20
21 +
/// Make a user identity with no repository (oxygen#144), so a first
22 +
/// identity comes before any repository: its secret and a copy of it go to
23 +
/// this home's keys, and the user file names it as the author, as `oxy
24 +
/// identity create` does inside one. A repository reads it from the keys
25 +
/// when it first signs with it. `name` and `email` default to the user
26 +
/// file's author, then to `$USER` and `OXY_AUTHOR_*`.
27 +
pub fn create_user_identity(
28 +
    name: Option<&str>,
29 +
    email: Option<&str>,
30 +
    expires: Option<u64>,
31 +
) -> Result<(KeyId, Identity)> {
32 +
    let user = default_user_config();
33 +
    let defaults = Config::load(Some(&user), Path::new(""))?;
34 +
    let name = name.map(str::to_string).unwrap_or(defaults.author_name);
35 +
    let email = email.map(str::to_string).unwrap_or(defaults.author_email);
36 +
    let sk = SecretKey::generate();
37 +
    let mut id = sk.identity(&name, &email, Hlc::now().wall_ms);
38 +
    id.expires = expires;
39 +
    let key = id.hash();
40 +
    let keys = default_keys_dir();
41 +
    fs::create_dir_all(&keys)?;
42 +
    crate::durable::write_atomic_private(
43 +
        &keys.join(format!("{}.secret", key.to_hex())),
44 +
        &sk.encode(&key),
45 +
    )?;
46 +
    write_atomic(
47 +
        &keys.join(format!("{}.identity", key.to_hex())),
48 +
        &id.encode(),
49 +
    )?;
50 +
    Config::set_user(&user, "author.name", &id.name)?;
51 +
    Config::set_user(&user, "author.email", &id.email)?;
52 +
    Config::set_user(&user, "author.key", &key.to_hex())?;
53 +
    Ok((key, id))
54 +
}
55 +
21 56
/// The outcome of replacing the configured identity (spec §14.2).
22 57
#[derive(Clone, Debug)]
23 58
pub struct Rotation {
…
113 unchanged lines
137 172
        if new.previous != Some(old) {
138 173
            return fail("the successor identity must name the current key as previous");
139 174
        }
175 +
        // A key that already has a successor is not the identity's newest:
176 +
        // building another from it forks the identity into two branches
177 +
        // (oxygen#163), as a checkout whose stale `author.key` pin outlived
178 +
        // a rotation made elsewhere once did.
179 +
        if let Some(newer) = self.successors(&old)?.into_iter().next() {
180 +
            let newest = self.newest(&newer)?;
181 +
            let fix = if self.config.is_repo_set("author.key") {
182 +
                format!(
183 +
                    "This repository's config pins author.key to the older key: `oxy config author.key {}` here \
184 +
                     (or `oxy config --unset author.key` to follow the user config), then run it again.",
185 +
                    newest.to_hex()
186 +
                )
187 +
            } else {
188 +
                format!(
189 +
                    "The user config names the older key: `oxy config --user author.key {}`, then run it again.",
190 +
                    newest.to_hex()
191 +
                )
192 +
            };
193 +
            return fail(format!(
194 +
                "`{command}` would start from key {}, which is not your identity's newest ({} succeeds it, and {} is the newest). \
195 +
                 Another successor would fork the identity into two branches.\n{fix}",
196 +
                old.short(12),
197 +
                newer.short(12),
198 +
                newest.short(12),
199 +
            ));
200 +
        }
140 201
        let new_key = self.install_key(&new, &sk)?;
141 202
        let attest = Attest {
142 203
            repo: self.id()?,
…
20 unchanged lines
163 224
        })
164 225
    }
165 226
227 +
    /// Keys in this home whose identity names `key` as `previous`: the
228 +
    /// successors already made from it. More than one means a fork.
229 +
    pub fn successors(&self, key: &KeyId) -> Result<Vec<KeyId>> {
230 +
        let mut found = Vec::new();
231 +
        let entries = match fs::read_dir(&self.keys_dir) {
232 +
            Ok(e) => e,
233 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found),
234 +
            Err(e) => return Err(e.into()),
235 +
        };
236 +
        for entry in entries {
237 +
            let path = entry?.path();
238 +
            if path.extension().and_then(|e| e.to_str()) != Some("identity") {
239 +
                continue;
240 +
            }
241 +
            let Ok(bytes) = fs::read(&path) else { continue };
242 +
            let Ok(id) = Identity::decode(&bytes) else {
243 +
                continue;
244 +
            };
245 +
            if id.previous == Some(*key) {
246 +
                found.push(id.hash());
247 +
            }
248 +
        }
249 +
        found.sort();
250 +
        Ok(found)
251 +
    }
252 +
253 +
    /// The newest key of the line through `key` in this home: its
254 +
    /// successors followed to the end (the first, where there is a fork).
255 +
    pub fn newest(&self, key: &KeyId) -> Result<KeyId> {
256 +
        let mut cur = *key;
257 +
        for _ in 0..256 {
258 +
            match self.successors(&cur)?.into_iter().next() {
259 +
                Some(next) => cur = next,
260 +
                None => break,
261 +
            }
262 +
        }
263 +
        Ok(cur)
264 +
    }
265 +
166 266
    /// Rotate to a fresh key pair; the new identity names the old one as
167 267
    /// `previous` and keeps its name, email, delegates, and expiry.
168 268
    pub fn rotate_identity(&mut self) -> Result<Rotation> {
…
334 unchanged lines
503 603
            .map_err(|_| format!("key {who}'s certificate names an unknown issuer"))?;
504 604
        sc.verify(&issuer)
505 605
            .map_err(|e| format!("key {who}'s certificate: {e}"))?;
506 -
        if g.grant.grantor != *author {
606 +
        // A grant by an earlier key of the author's identity counts while
607 +
        // it was made before that key was rotated away (oxygen#135).
608 +
        if g.grant.grantor != *author && !self.granted_before_rotation(author, &g.grant) {
507 609
            return Ok(neither());
508 610
        }
509 611
        let grantor = self
510 -
            .store
511 -
            .get_identity(author)
612 +
            .identity(&g.grant.grantor)
512 613
            .map_err(|_| format!("key {who}'s grant is by an unknown author"))?;
513 614
        g.verify(&grantor)
514 615
            .map_err(|e| format!("key {who}'s grant: {e}"))?;
…
15 unchanged lines
530 631
        Ok(None)
531 632
    }
532 633
634 +
    /// Whether `grant` is by a key `author`'s identity was rotated from,
635 +
    /// made before that rotation (oxygen#135). An owner rotates with every
636 +
    /// delegation, and a repository may name the newest key as the author
637 +
    /// while the agent's grant is by an older one; the grant stays good, as
638 +
    /// a policy keeps a predecessor's signatures from before the rotation
639 +
    /// (spec §14.5), but a key rotated away grants nothing new.
640 +
    fn granted_before_rotation(&self, author: &KeyId, grant: &crate::cert::Grant) -> bool {
641 +
        let mut cur = self.identity(author).ok();
642 +
        for _ in 0..32 {
643 +
            let Some(id) = cur else {
644 +
                return false;
645 +
            };
646 +
            match id.previous {
647 +
                Some(prev) if prev == grant.grantor => return grant.not_before < id.created,
648 +
                Some(prev) => cur = self.identity(&prev).ok(),
649 +
                None => return false,
650 +
            }
651 +
        }
652 +
        false
653 +
    }
654 +
533 655
    /// The subject a short-lived key was certified for (spec §14.7), such
534 656
    /// as `agent:claude@andy`: what names it, since its identity is only
535 657
    /// a fresh key's.
…
222 unchanged lines
modified crates/oxy-core/src/repo/keyless.rs +90 −1
…
50 unchanged lines
51 51
            .parent()
52 52
            .map(Path::to_path_buf)
53 53
            .unwrap_or_else(default_home);
54 -
        let creds = Credentials::at(home);
54 +
        let creds = Credentials::at(home.clone());
55 55
        let token = creds.grant(&hub).map_err(RepoError::Msg)?.ok_or_else(|| {
56 56
            RepoError::Msg(format!(
57 57
                "author.signer is keyless, but there is no agent grant for {hub}: its owner makes one with `oh agent grant NAME`, and `oxy auth grant TOKEN` keeps it"
…
62 unchanged lines
120 120
        };
121 121
        cert.verify(&issuer)
122 122
            .map_err(|e| RepoError::Msg(format!("{hub}'s certificate: {e}")))?;
123 +
        // The grant's author, so the grant can be checked here and wherever
124 +
        // this pushes (oxygen#134): from the hub's answer, else this home's
125 +
        // copy of it. Without it, a repository that never saw the owner's
126 +
        // current identity reads the change as signed by an unknown author.
127 +
        if let Some(g) = &cert.cert.grant {
128 +
            let grantor = g.grant.grantor;
129 +
            if self.store.get_identity(&grantor).is_err() {
130 +
                let sent = reply["grantor_identity"]
131 +
                    .as_str()
132 +
                    .and_then(crate::dsse::base64_decode)
133 +
                    .and_then(|b| Identity::decode(&b).ok());
134 +
                let kept = || {
135 +
                    std::fs::read(
136 +
                        home.join("keys")
137 +
                            .join(format!("{}.identity", grantor.to_hex())),
138 +
                    )
139 +
                    .ok()
140 +
                    .and_then(|b| Identity::decode(&b).ok())
141 +
                };
142 +
                match sent.filter(|i| i.hash() == grantor).or_else(|| kept().filter(|i| i.hash() == grantor)) {
143 +
                    Some(i) => {
144 +
                        self.store.put_identity(&i)?;
145 +
                    }
146 +
                    None => {
147 +
                        return fail(format!(
148 +
                            "the grant is signed by {}, whose identity neither {hub} nor this home has: export it there (oxy identity export) into this home's keys/",
149 +
                            grantor.short(12)
150 +
                        ))
151 +
                    }
152 +
                }
153 +
            }
154 +
        }
155 +
        if let (Some(owner), Some(g)) = (&self.config.author_for, &cert.cert.grant) {
156 +
            self.follow_author(owner, &cert.cert.subject, g.grant.grantor, &reply, &home)?;
157 +
        }
123 158
        self.store.put_identity(&issuer)?;
124 159
        self.store.put_identity(&id)?;
125 160
        self.store.put_certificate(&cert)?;
…
2 unchanged lines
128 163
    }
129 164
}
130 165
166 +
impl Repo {
167 +
    /// `author.for` (oxygen#82): keep the identity the hub names as the
168 +
    /// grant's owner's newest (`author_chain`, newest first, each the
169 +
    /// successor of the next, the last the grant's key's), store the chain
170 +
    /// here so the grant checks against it, and note its key in the home,
171 +
    /// so this and the home's next commands author as it.
172 +
    fn follow_author(
173 +
        &self,
174 +
        owner: &str,
175 +
        subject: &str,
176 +
        grantor: KeyId,
177 +
        reply: &serde_json::Value,
178 +
        home: &Path,
179 +
    ) -> Result<()> {
180 +
        let granted_by = subject.rsplit_once('@').map(|(_, o)| o).unwrap_or(subject);
181 +
        if !granted_by.eq_ignore_ascii_case(owner) {
182 +
            return fail(format!(
183 +
                "author.for is {owner}, but this home's grant is from {granted_by}: set author.for to the account that granted it"
184 +
            ));
185 +
        }
186 +
        let chain: Vec<Identity> = reply["author_chain"]
187 +
            .as_array()
188 +
            .map(|a| {
189 +
                a.iter()
190 +
                    .filter_map(|v| v.as_str().and_then(crate::dsse::base64_decode))
191 +
                    .filter_map(|b| Identity::decode(&b).ok())
192 +
                    .collect()
193 +
            })
194 +
            .unwrap_or_default();
195 +
        let linked = chain.windows(2).all(|w| w[0].previous == Some(w[1].hash()))
196 +
            && chain.last().is_none_or(|l| l.previous == Some(grantor));
197 +
        if !linked {
198 +
            return fail(
199 +
                "the hub's author chain does not lead back to the grant's key".to_string(),
200 +
            );
201 +
        }
202 +
        let keys = home.join("keys");
203 +
        std::fs::create_dir_all(&keys)?;
204 +
        for id in &chain {
205 +
            self.store.put_identity(id)?;
206 +
            let path = keys.join(format!("{}.identity", id.hash().to_hex()));
207 +
            if !path.exists() {
208 +
                crate::durable::write_atomic(&path, &id.encode())?;
209 +
            }
210 +
        }
211 +
        let newest = chain.first().map(Identity::hash).unwrap_or(grantor);
212 +
        crate::durable::write_atomic(
213 +
            &keys.join(super::config::AUTHOR_FOR_FILE),
214 +
            format!("{}\n", newest.to_hex()).as_bytes(),
215 +
        )?;
216 +
        Ok(())
217 +
    }
218 +
}
219 +
131 220
/// The key a home names as its author (`author.key` in its
132 221
/// `config.toml`), with its secret from the home's `keys/`: what signs
133 222
/// outside any repository, such as a grant to an agent (`oh agent grant`).
…
58 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +56 −16
…
142 unchanged lines
143 143
pub use git::{GitExportReport, GitImportReport, GitVerifyReport};
144 144
pub use hooks::{json_array, json_str, HookOutcome};
145 145
pub use hosted::FileEdit;
146 -
pub use identity::{Rotation, SignatureStatus};
146 +
pub use identity::{create_user_identity, Rotation, SignatureStatus};
147 147
pub use inspect::{BisectStep, BlameLine, Description};
148 148
pub use keyless::home_key;
149 149
pub use ops::{RestoreReport, RevertReport, Undone, ViewChange};
…
3 unchanged lines
153 153
pub use revset::{Revset, REVSET_BUDGET};
154 154
pub use snapshot::{
155 155
    added_node_paths, diff_files_of, has_conflict_markers, is_binary, referenced_changes,
156 -
    render_change, render_hunks, similarity, ChangeStatus, DiffHunk, FileDiff, Hunk, PathStatus,
157 -
    Selection, SnapshotResult,
156 +
    render_change, render_hunks, similarity, ChangeStatus, DiffHunk, FileDiff, Hunk, KeptBoth,
157 +
    PathStatus, Selection, SnapshotResult,
158 158
};
159 159
pub use subrepos::{SubrepoInfo, SubrepoSync};
160 160
pub use sync::{FetchReport, PullReport, PushReport, RefUpdated, OBJECTS_RESULT};
…
218 unchanged lines
379 379
    /// on the store at `storage` (`storage.url`), or in `.oxy/objects`.
380 380
    pub fn init_on(root: &Path, id: [u8; 16], storage: Option<&str>) -> Result<Repo> {
381 381
        fs::create_dir_all(root)?;
382 -
        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
382 +
        let root = crate::portable::canonical(root).unwrap_or_else(|_| root.to_path_buf());
383 383
        let oxy = root.join(OXY_DIR);
384 384
        if oxy.exists() {
385 385
            return fail(format!("{} is already an oxy repository", root.display()));
…
62 unchanged lines
448 448
    /// [`Repo::init_on`] for a bare repository.
449 449
    pub fn init_bare_on(root: &Path, id: [u8; 16], storage: Option<&str>) -> Result<Repo> {
450 450
        fs::create_dir_all(root)?;
451 -
        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
451 +
        let root = crate::portable::canonical(root).unwrap_or_else(|_| root.to_path_buf());
452 452
        let oxy = root.join(OXY_DIR);
453 453
        if oxy.exists() {
454 454
            return fail(format!("{} is already an oxy repository", root.display()));
…
74 unchanged lines
529 529
530 530
    /// Open the repository containing `start` (searching upwards).
531 531
    pub fn open(start: &Path) -> Result<Repo> {
532 -
        let start = start.canonicalize()?;
532 +
        let start = crate::portable::canonical(start)?;
533 533
        let mut dir: Option<&Path> = Some(&start);
534 534
        while let Some(d) = dir {
535 535
            let oxy = d.join(OXY_DIR);
…
164 unchanged lines
700 700
    pub fn root(&self) -> &Path {
701 701
        &self.root
702 702
    }
703 +
    /// The repository's `.oxy` directory (shared by its workspaces), for
704 +
    /// what other crates keep beside the store (Cylinder's artifacts).
705 +
    pub fn oxy_dir(&self) -> &Path {
706 +
        &self.oxy
707 +
    }
703 708
    pub fn store(&self) -> &Store {
704 709
        &self.store
705 710
    }
…
989 unchanged lines
1695 1700
    /// through [`Repo::read_blob`], which may fetch it lazily.
1696 1701
    pub fn read_blobs(&self, refs: &[ObjectRef]) -> Result<Vec<Vec<u8>>> {
1697 1702
        let mut blobs: Vec<Option<Blob>> = Vec::with_capacity(refs.len());
1698 -
        for (r, got) in refs.iter().zip(self.store.get_many(refs)) {
1703 +
        let t = crate::timing::phase("blobs: get_many blobs");
1704 +
        let got_all = self.store.get_many(refs);
1705 +
        drop(t);
1706 +
        for (r, got) in refs.iter().zip(got_all) {
1699 1707
            blobs.push(match got {
1700 1708
                Ok((ObjectType::Blob, body)) => Some(Blob::decode(&body)?),
1701 1709
                Ok((ty, _)) => return fail(format!("{} is a {ty:?}, not a blob", r.short(12))),
…
6 unchanged lines
1708 1716
            .flatten()
1709 1717
            .flat_map(|b| b.chunks.iter().copied())
1710 1718
            .collect();
1719 +
        let t = crate::timing::phase("blobs: get_many chunks");
1711 1720
        let mut chunks = self.store.get_many(&chunk_refs).into_iter();
1721 +
        drop(t);
1722 +
        if crate::timing::enabled() {
1723 +
            eprintln!(
1724 +
                "timing blobs: {} blobs, {} chunks, {} one by one",
1725 +
                refs.len(),
1726 +
                chunk_refs.len(),
1727 +
                blobs.iter().filter(|b| b.is_none()).count()
1728 +
            );
1729 +
        }
1712 1730
        let mut out = Vec::with_capacity(refs.len());
1713 1731
        for (r, blob) in refs.iter().zip(blobs) {
1714 1732
            match blob {
…
494 unchanged lines
2209 2227
        // already tracked as subrepo nodes, they contribute their pin;
2210 2228
        // unregistered ones are left alone.
2211 2229
        let modules = self.modules_on_disk();
2212 -
        let removed = self.removed_subrepos();
2213 -
        for rel in &removed {
2214 -
            if tracked.get(rel).map(|(k, _)| *k) != Some(NodeKind::Subrepo) {
2230 +
        // A subrepo at the state its recorded pin names is unmodified,
2231 +
        // whatever bookmark it sits on (#147): the recorded pin stands.
2232 +
        let subrepo_entry = |rel: &str, pin: crate::subrepo::Pin| -> FsEntry {
2233 +
            if let Some((NodeKind::Subrepo, Some(b))) = tracked.get(rel) {
2234 +
                let recorded = self
2235 +
                    .read_blob(b)
2236 +
                    .ok()
2237 +
                    .and_then(|x| crate::subrepo::Pin::decode(&x).ok());
2238 +
                if recorded.is_some_and(|r| r.same_state(&pin)) {
2239 +
                    return FsEntry {
2240 +
                        blob: Some(*b),
2241 +
                        ..FsEntry::new(NodeKind::Subrepo, false, vec![])
2242 +
                    };
2243 +
                }
2244 +
            }
2245 +
            FsEntry::new(NodeKind::Subrepo, false, pin.encode())
2246 +
        };
2247 +
        // A removal's marker is spent once the removal is recorded (no
2248 +
        // subrepo node is tracked there) or the path is registered again:
2249 +
        // dropped before it is read, or the change registering it again
2250 +
        // would leave it out (oxygen#152).
2251 +
        let mut removed = self.removed_subrepos();
2252 +
        removed.retain(|rel| {
2253 +
            let spent = tracked.get(rel).map(|(k, _)| *k) != Some(NodeKind::Subrepo)
2254 +
                || modules.entries.contains_key(rel);
2255 +
            if spent {
2215 2256
                self.clear_removed_subrepo(rel);
2216 2257
            }
2217 -
        }
2258 +
            !spent
2259 +
        });
2218 2260
        let mut untracked_nested = Vec::new();
2219 2261
        for dir in nested {
2220 2262
            let rel = self.rel_path(&dir);
…
7 unchanged lines
2228 2270
                continue;
2229 2271
            }
2230 2272
            let pin = subrepos::read_pin(&dir, registered.and_then(|m| m.bookmark.as_deref()))?;
2231 -
            out.insert(rel, FsEntry::new(NodeKind::Subrepo, false, pin.encode()));
2273 +
            let entry = subrepo_entry(&rel, pin);
2274 +
            out.insert(rel, entry);
2232 2275
        }
2233 2276
        untracked_nested.sort();
2234 2277
        *self.untracked_nested.borrow_mut() = untracked_nested;
…
14 unchanged lines
2249 2292
                        &path,
2250 2293
                        modules.entries.get(rel).and_then(|m| m.bookmark.as_deref()),
2251 2294
                    )?;
2252 -
                    out.insert(
2253 -
                        rel.clone(),
2254 -
                        FsEntry::new(NodeKind::Subrepo, false, pin.encode()),
2255 -
                    );
2295 +
                    out.insert(rel.clone(), subrepo_entry(rel, pin));
2256 2296
                } else if let Some(b) = blob {
2257 2297
                    // Not cloned (or emptied): the pin stays as recorded, as a
2258 2298
                    // Git gitlink does when its directory is absent.
…
650 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +342 −1
…
104 unchanged lines
105 105
    written: u128,
106 106
}
107 107
108 +
/// Whether `base` has two or more live versions of the opaque file or
109 +
/// subrepo pin at `path` (a replace conflict, spec §9.3).
110 +
fn has_replace_conflict(base: &Snapshot, path: &str) -> bool {
111 +
    matches!(base.files.get(path).map(|f| &f.content), Some(FileContent::Opaque { blobs }) if blobs.len() > 1)
112 +
}
113 +
114 +
/// What `oxy resolve --both` did to one file (oxygen#121).
115 +
#[derive(Debug, Default, Clone, PartialEq, Eq)]
116 +
pub struct KeptBoth {
117 +
    pub path: String,
118 +
    /// The conflicts settled by keeping every side, by number.
119 +
    pub settled: Vec<usize>,
120 +
    /// Of those, the ones whose sides were identical, settled by keeping
121 +
    /// one (oxygen#146).
122 +
    pub identical: Vec<usize>,
123 +
    /// The conflicts that keep their markers, by number, and why.
124 +
    pub left: Vec<(usize, &'static str)>,
125 +
}
126 +
127 +
/// `text` (a working-copy file) with the marker block of each conflict that
128 +
/// `sides` (from [`State::both_sides`]) says keeping both settles replaced
129 +
/// by its sides' lines; the conflicts settled, and those left and why.
130 +
pub(crate) fn keep_both_text(
131 +
    text: &[u8],
132 +
    sides: &[Option<Vec<Vec<u8>>>],
133 +
    reverse: bool,
134 +
) -> (Vec<u8>, Vec<usize>, Vec<(usize, &'static str)>) {
135 +
    let plans: Vec<Option<crate::state::Settle>> = sides
136 +
        .iter()
137 +
        .map(|s| {
138 +
            s.as_ref().map(|w| crate::state::Settle {
139 +
                sides: w.clone(),
140 +
                kept: w.clone(),
141 +
                identical: false,
142 +
            })
143 +
        })
144 +
        .collect();
145 +
    settle_text(text, &plans, reverse)
146 +
}
147 +
148 +
/// `text` (a working-copy file) with the marker block of each conflict
149 +
/// that `plans` (from [`State::settle_plans`]) settle replaced by its
150 +
/// kept text, the sides in order (`reverse`d on request); the conflicts
151 +
/// settled, and those left and why. A block whose text between the
152 +
/// markers is not its sides' keeps its markers.
153 +
pub(crate) fn settle_text(
154 +
    text: &[u8],
155 +
    plans: &[Option<crate::state::Settle>],
156 +
    reverse: bool,
157 +
) -> (Vec<u8>, Vec<usize>, Vec<(usize, &'static str)>) {
158 +
    // A side as the markers hold it: the renderer ends each with a newline.
159 +
    let with_newline = |s: &[u8]| {
160 +
        let mut s = s.to_vec();
161 +
        if !s.is_empty() && !s.ends_with(b"\n") {
162 +
            s.push(b'\n');
163 +
        }
164 +
        s
165 +
    };
166 +
    let lines: Vec<&[u8]> = text.split_inclusive(|&b| b == b'\n').collect();
167 +
    let (mut out, mut settled, mut left) = (Vec::new(), Vec::new(), Vec::new());
168 +
    let mut i = 0;
169 +
    while i < lines.len() {
170 +
        let Some((n, total)) = conflict_header(lines[i]) else {
171 +
            out.extend_from_slice(lines[i]);
172 +
            i += 1;
173 +
            continue;
174 +
        };
175 +
        let end = format!(">>>>>>> oxy conflict {n} of {total} end");
176 +
        let next_side = format!("======= oxy conflict {n} of {total}, side ");
177 +
        let Some(stop) =
178 +
            (i + 1..lines.len()).find(|&j| lines[j].trim_ascii_end() == end.as_bytes())
179 +
        else {
180 +
            left.push((n, "its end marker is missing; edit it by hand"));
181 +
            out.extend_from_slice(lines[i]);
182 +
            i += 1;
183 +
            continue;
184 +
        };
185 +
        let mut found: Vec<Vec<u8>> = vec![Vec::new()];
186 +
        for line in &lines[i + 1..stop] {
187 +
            if line.starts_with(next_side.as_bytes()) {
188 +
                found.push(Vec::new());
189 +
            } else {
190 +
                found.last_mut().unwrap().extend_from_slice(line);
191 +
            }
192 +
        }
193 +
        let wanted = match plans.get(n.wrapping_sub(1)) {
194 +
            _ if total != plans.len() => {
195 +
                Err("the markers are not the file's conflicts now; see `oxy status`")
196 +
            }
197 +
            Some(Some(w)) => Ok(w),
198 +
            _ => Err("both sides changed these lines; edit the markers"),
199 +
        };
200 +
        let keep = wanted.and_then(|w| {
201 +
            let mut a: Vec<Vec<u8>> = w.sides.iter().map(|s| with_newline(s)).collect();
202 +
            let mut b = found.clone();
203 +
            a.sort();
204 +
            b.sort();
205 +
            if a == b {
206 +
                Ok(&w.kept)
207 +
            } else {
208 +
                Err("the lines between its markers were edited; finish it by hand")
209 +
            }
210 +
        });
211 +
        match keep {
212 +
            Ok(w) => {
213 +
                let order: Vec<&Vec<u8>> = if reverse {
214 +
                    w.iter().rev().collect()
215 +
                } else {
216 +
                    w.iter().collect()
217 +
                };
218 +
                for s in order {
219 +
                    out.extend_from_slice(&with_newline(s));
220 +
                }
221 +
                settled.push(n);
222 +
            }
223 +
            Err(why) => {
224 +
                left.push((n, why));
225 +
                for line in &lines[i..=stop] {
226 +
                    out.extend_from_slice(line);
227 +
                }
228 +
            }
229 +
        }
230 +
        i = stop + 1;
231 +
    }
232 +
    (out, settled, left)
233 +
}
234 +
235 +
/// `(n, total)` from a conflict's opening marker (spec §9.5).
236 +
fn conflict_header(line: &[u8]) -> Option<(usize, usize)> {
237 +
    let rest = line.strip_prefix(b"<<<<<<< oxy conflict ")?;
238 +
    let rest = std::str::from_utf8(rest).ok()?;
239 +
    let (n, rest) = rest.split_once(" of ")?;
240 +
    let total = rest.split(|c: char| !c.is_ascii_digit()).next()?;
241 +
    Some((n.parse().ok()?, total.parse().ok()?))
242 +
}
243 +
108 244
pub fn is_binary(content: &[u8]) -> bool {
109 245
    content[..content.len().min(BINARY_SNIFF)].contains(&0)
110 246
}
…
298 unchanged lines
409 545
        Ok(hash)
410 546
    }
411 547
548 +
    fn resolutions_path(&self) -> PathBuf {
549 +
        self.oxy
550 +
            .join("workspaces")
551 +
            .join(self.workspace.to_hex())
552 +
            .join("resolve")
553 +
    }
554 +
555 +
    /// The paths whose replace conflicts the user chose to settle with what
556 +
    /// the working copy holds there (`oxy resolve PATH`, spec §9.3).
557 +
    pub fn resolutions(&self) -> Result<BTreeSet<String>> {
558 +
        match fs::read_to_string(self.resolutions_path()) {
559 +
            Ok(text) => Ok(text
560 +
                .lines()
561 +
                .filter(|l| !l.is_empty())
562 +
                .map(String::from)
563 +
                .collect()),
564 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeSet::new()),
565 +
            Err(e) => Err(e.into()),
566 +
        }
567 +
    }
568 +
569 +
    /// Settle the replace conflicts at `paths` (a file's or a subrepo pin's,
570 +
    /// spec §9.3) with what the working copy holds there: the next snapshot
571 +
    /// replaces every live candidate with it. Refused for a path whose base
572 +
    /// has no replace conflict.
573 +
    pub fn choose_resolutions(&self, base: &Snapshot, paths: &[String]) -> Result<()> {
574 +
        for p in paths {
575 +
            if !has_replace_conflict(base, p) {
576 +
                return Err(RepoError::Msg(format!(
577 +
                    "{p} has no replace conflict to settle (a text file's conflict is settled by editing out its markers, or `oxy resolve --both {p}`)"
578 +
                )));
579 +
            }
580 +
        }
581 +
        let mut all = self.resolutions()?;
582 +
        all.extend(paths.iter().cloned());
583 +
        let text: String = all.iter().map(|p| format!("{p}\n")).collect();
584 +
        crate::durable::write_atomic(&self.resolutions_path(), text.as_bytes())?;
585 +
        Ok(())
586 +
    }
587 +
588 +
    /// Settle the adjacent-insertion conflicts of the text files at `paths`
589 +
    /// (oxygen#121) by keeping every side, earliest change first (latest
590 +
    /// first with `reverse`): each one's markers in the working copy give
591 +
    /// way to its sides' lines, and the next snapshot records the result.
592 +
    /// A conflict whose sides changed the same lines, or whose text between
593 +
    /// the markers was edited, keeps its markers. With no paths, every
594 +
    /// conflicted text file.
595 +
    pub fn keep_both(
596 +
        &self,
597 +
        snap: &SnapshotResult,
598 +
        paths: &[String],
599 +
        reverse: bool,
600 +
    ) -> Result<Vec<KeptBoth>> {
601 +
        let paths: Vec<String> = if paths.is_empty() {
602 +
            snap.base_snapshot
603 +
                .files
604 +
                .iter()
605 +
                .filter(|(_, f)| matches!(&f.content, FileContent::Text { materialized, .. } if materialized.is_conflicted()))
606 +
                .map(|(p, _)| p.clone())
607 +
                .collect()
608 +
        } else {
609 +
            paths.to_vec()
610 +
        };
611 +
        let mut out = Vec::new();
612 +
        for p in paths {
613 +
            let conflicted = match snap.base_snapshot.files.get(&p).map(|f| (f, &f.content)) {
614 +
                Some((f, FileContent::Text { materialized, .. }))
615 +
                    if materialized.is_conflicted() =>
616 +
                {
617 +
                    f
618 +
                }
619 +
                Some((_, FileContent::Opaque { blobs })) if blobs.len() > 1 => {
620 +
                    return Err(RepoError::Msg(format!(
621 +
                        "{p} has a replace conflict: keep one version with `oxy resolve {p}`"
622 +
                    )))
623 +
                }
624 +
                _ => {
625 +
                    return Err(RepoError::Msg(format!(
626 +
                        "{p} has no text conflict to settle"
627 +
                    )))
628 +
                }
629 +
            };
630 +
            let sides = snap.base.both_sides(&conflicted.node);
631 +
            let disk = self.root().join(&p);
632 +
            let text = fs::read(&disk)?;
633 +
            let (bytes, settled, left) = keep_both_text(&text, &sides, reverse);
634 +
            if !settled.is_empty() {
635 +
                fs::write(&disk, bytes)?;
636 +
            }
637 +
            out.push(KeptBoth {
638 +
                path: p,
639 +
                settled,
640 +
                identical: Vec::new(),
641 +
                left,
642 +
            });
643 +
        }
644 +
        Ok(out)
645 +
    }
646 +
647 +
    /// Settle what a checkout may settle by itself in each conflicted text
648 +
    /// file ([`State::settle_plans`]): conflicts whose sides are identical,
649 +
    /// keeping one (oxygen#146), and in files whose `.oxyattributes` say
650 +
    /// `merge=union`, adjacent insertions, keeping both sides earliest
651 +
    /// change first (oxygen#145). The next commit records the resolution.
652 +
    /// The files it settled something in.
653 +
    pub(crate) fn settle_conflicts(&self, command: &str) -> Result<Vec<KeptBoth>> {
654 +
        let snap = self.snapshot(command)?;
655 +
        let mut out = Vec::new();
656 +
        for (p, f) in &snap.base_snapshot.files {
657 +
            if !matches!(&f.content, FileContent::Text { materialized, .. } if materialized.is_conflicted())
658 +
            {
659 +
                continue;
660 +
            }
661 +
            let plans = snap
662 +
                .base
663 +
                .settle_plans(&f.node, snap.attributes.lookup(p).union);
664 +
            if plans.iter().all(Option::is_none) {
665 +
                continue;
666 +
            }
667 +
            let disk = self.root().join(p);
668 +
            let Ok(text) = fs::read(&disk) else { continue };
669 +
            let (bytes, settled, left) = settle_text(&text, &plans, false);
670 +
            if settled.is_empty() {
671 +
                continue;
672 +
            }
673 +
            fs::write(&disk, bytes)?;
674 +
            let identical = settled
675 +
                .iter()
676 +
                .copied()
677 +
                .filter(|n| plans[n - 1].as_ref().is_some_and(|w| w.identical))
678 +
                .collect();
679 +
            out.push(KeptBoth {
680 +
                path: p.clone(),
681 +
                settled,
682 +
                identical,
683 +
                left,
684 +
            });
685 +
        }
686 +
        Ok(out)
687 +
    }
688 +
689 +
    /// Every conflict of `snap`, `state`'s materialization, settled as a
690 +
    /// checkout settles them ([`State::settle_plans`]): the new text of
691 +
    /// each conflicted file, when each is a text file whose every conflict
692 +
    /// has identical sides (one kept, oxygen#146) or, in a file its
693 +
    /// `.oxyattributes` mark `merge=union`, is an adjacent insertion (both
694 +
    /// kept, oxygen#145); `None` when any conflict needs a person, or there
695 +
    /// is none. A host lands a proposal with these as a resolution, as a
696 +
    /// checkout settles them in a working copy.
697 +
    pub fn union_resolution(state: &State, snap: &Snapshot) -> Option<Vec<(String, Vec<u8>)>> {
698 +
        if snap.tree.is_conflicted() {
699 +
            return None;
700 +
        }
701 +
        let attrs = Repo::state_attributes(snap);
702 +
        let mut out = Vec::new();
703 +
        for (p, f) in &snap.files {
704 +
            if !f.is_conflicted() {
705 +
                continue;
706 +
            }
707 +
            let FileContent::Text { bytes, .. } = &f.content else {
708 +
                return None;
709 +
            };
710 +
            let plans = state.settle_plans(&f.node, attrs.lookup(p).union);
711 +
            let (text, _, left) = settle_text(bytes, &plans, false);
712 +
            if !left.is_empty() || plans.iter().any(Option::is_none) {
713 +
                return None;
714 +
            }
715 +
            out.push((p.clone(), text));
716 +
        }
717 +
        (!out.is_empty()).then_some(out)
718 +
    }
719 +
720 +
    /// The chosen resolutions `base` still needs; the rest are forgotten.
721 +
    fn resolutions_for(&self, base: &Snapshot) -> Result<HashSet<String>> {
722 +
        let all = self.resolutions()?;
723 +
        if all.is_empty() {
724 +
            return Ok(HashSet::new());
725 +
        }
726 +
        let live: HashSet<String> = all
727 +
            .iter()
728 +
            .filter(|p| has_replace_conflict(base, p))
729 +
            .cloned()
730 +
            .collect();
731 +
        if live.is_empty() {
732 +
            match fs::remove_file(self.resolutions_path()) {
733 +
                Ok(()) => {}
734 +
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
735 +
                Err(e) => return Err(e.into()),
736 +
            }
737 +
        }
738 +
        Ok(live)
739 +
    }
740 +
412 741
    /// Snapshot the working copy into the working-copy change (spec §12.3).
413 742
    /// The result carries the full base state and its whole rendering.
414 743
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
…
122 unchanged lines
537 866
        // paths, so an unchanged working copy hashes identically (spec §12.3).
538 867
        let reuse = added_node_paths(&old_wc, &base_snapshot);
539 868
        self.platform_normalize(&mut fs_entries, &base_snapshot);
869 +
        // Replace conflicts the user settled with `oxy resolve PATH`
870 +
        // (oxygen#140): those still in the base; a choice the base no longer
871 +
        // needs (once committed) is forgotten.
872 +
        let resolve = self.resolutions_for(&base_snapshot)?;
540 873
        let built = {
541 874
            let _t = crate::timing::phase("snapshot: diff against base");
542 -
            if partial && self.config.sparse_paths.is_empty() {
875 +
            if !resolve.is_empty() {
876 +
                self.build_from_entries_resolving(
877 +
                    &base,
878 +
                    &base_snapshot,
879 +
                    reuse,
880 +
                    &fs_entries,
881 +
                    &resolve,
882 +
                )?
883 +
            } else if partial && self.config.sparse_paths.is_empty() {
543 884
                #[cfg(test)]
544 885
                let full =
545 886
                    self.build_from_entries(&base, &base_snapshot, reuse.clone(), &fs_entries)?;
…
2745 unchanged lines
modified crates/oxy-core/src/repo/strata.rs +11 −2
…
346 unchanged lines
347 347
        for t in &resp.tags {
348 348
            mirror.store.put_state(&StateObject::new(t.heads.clone()))?;
349 349
            mirror
350 -
                .adopt_tag(&mut view, t, policy.as_ref())
351 -
                .map_err(|e| RepoError::Msg(format!("{}: {e}", self.what())))?;
350 +
                .adopt_tag(
351 +
                    &mut view,
352 +
                    t,
353 +
                    policy.as_ref(),
354 +
                    &mut super::tags::TagCheck::Mirrored,
355 +
                )
356 +
                .map_err(|e| RepoError::Msg(format!("{}: {}", self.what(), e.reason)))?;
352 357
        }
353 358
        if view != op.view {
354 359
            mirror.record_op(&[op_ref], "strata sync", view)?;
…
60 unchanged lines
415 420
        self.with_mirror(|m| m.checkpoint_bundle_for(heads))
416 421
    }
417 422
423 +
    fn checkpoint_index(&self, heads: &[ChangeHash]) -> Result<Bundle> {
424 +
        self.with_mirror(|m| m.checkpoint_index_for(heads))
425 +
    }
426 +
418 427
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> {
419 428
        self.with_mirror(|m| m.shallow_changes(heads, depth))
420 429
    }
…
941 unchanged lines
modified crates/oxy-core/src/repo/subrepos.rs +12 −17
…
181 unchanged lines
182 182
            }
183 183
        }
184 184
        let actual = read_pin(&dir, module.as_ref().and_then(|m| m.bookmark.as_deref()))?;
185 -
        let same = match (pin, &actual) {
186 -
            (Pin::Oxy { heads: a, .. }, Pin::Oxy { heads: b, .. }) => {
187 -
                let (mut a, mut b) = (a.clone(), b.clone());
188 -
                a.sort();
189 -
                b.sort();
190 -
                a == b
191 -
            }
192 -
            (Pin::Git { sha: a, .. }, Pin::Git { sha: b, .. }) => a == b,
193 -
            _ => false,
194 -
        };
185 +
        let same = pin.same_state(&actual);
195 186
        if same {
196 187
            return Ok(if cloned {
197 188
                SubrepoSync::Updated
…
49 unchanged lines
247 238
    /// Every subrepo of the working copy's base state.
248 239
    pub fn subrepos(&self) -> Result<Vec<SubrepoInfo>> {
249 240
        let (_, wc) = self.working_copy()?;
250 -
        let snap = self.load_state(&wc.deps)?.materialize();
241 +
        // The tree and the subrepos' pins only: a materialized state
242 +
        // rendered every file of the tree to find them, after each clone,
243 +
        // pull, switch and `workspace add` (in the VS Code history, ~70 s
244 +
        // and 11 GB; oxygen#137).
245 +
        let mut state = self.load_state_indexed(&wc.deps)?;
246 +
        let tree = self.tree_view(&mut state)?;
251 247
        let modules = self.modules_on_disk();
252 248
        let mut out = Vec::new();
253 -
        for (path, f) in &snap.files {
254 -
            if f.kind != NodeKind::Subrepo {
249 +
        for (path, e) in tree.entries.iter() {
250 +
            if e.kind != NodeKind::Subrepo {
255 251
                continue;
256 252
            }
257 -
            let FileContent::Opaque { blobs } = &f.content else {
253 +
            let Some(b) = state.live_blobs(&e.node).first().copied() else {
258 254
                continue;
259 255
            };
260 -
            let Some(b) = blobs.first() else { continue };
261 -
            let pin = Pin::decode(&self.read_blob(b)?)?;
256 +
            let pin = Pin::decode(&self.read_blob(&b)?)?;
262 257
            let dir = self.root.join(path);
263 258
            let present = dir.join(OXY_DIR).exists() || dir.join(".git").exists();
264 259
            let actual = if present {
…
20 unchanged lines
285 280
        }
286 281
        // Registered but not committed yet: pinned at what they are now.
287 282
        for (path, m) in &modules.entries {
288 -
            if snap.files.contains_key(path) {
283 +
            if tree.entries.contains_key(path) {
289 284
                continue;
290 285
            }
291 286
            let dir = self.root.join(path);
…
144 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +605 −88
1 1
//! Serving and talking to remotes (spec §10.2, §16).
2 2
3 +
use super::tags::TagCheck;
3 4
use super::*;
4 5
5 6
/// What a push did, for hosts (hub spec §6.12).
…
21 unchanged lines
27 28
    pub refs: Vec<(String, Vec<ChangeHash>, usize)>,
28 29
    /// Tags adopted from the remote.
29 30
    pub new_tags: usize,
31 +
    /// Tags the remote withdrew, and so withdrawn here (oxygenhub#301).
32 +
    pub withdrawn_tags: Vec<String>,
30 33
    /// The remote has never presented a policy, so nothing was verified
31 34
    /// against one (spec §14.5: warn loudly).
32 35
    pub unverified: bool,
…
26 unchanged lines
59 62
/// not a reference.
60 63
pub const OBJECTS_RESULT: &str = "objects";
61 64
65 +
/// Graph blobs asked for in one `objects` request when a shallow clone
66 +
/// fetches its checkpoint in parts (#137): their chunk lists and chunks,
67 +
/// a few MiB a request rather than one answer of gigabytes.
68 +
const CHECKPOINT_PART: usize = 2048;
69 +
62 70
impl Repo {
63 71
    // ------------------------------------------------------------ serving (spec §16)
64 72
…
68 unchanged lines
133 141
            default_bookmark: Some(self.config.bookmark.clone()),
134 142
            log: None,
135 143
            witnesses: vec![],
144 +
            withdrawn: self.withdrawn_refs()?,
136 145
        })
137 146
    }
138 147
…
105 unchanged lines
244 253
        let mut done: BTreeSet<ChangeHash> = have.clone();
245 254
        done.extend(boundary.iter().copied());
246 255
        // Iterative post-order DFS.
256 +
        let shallow = self.is_shallow();
247 257
        let mut stack: Vec<(ChangeHash, bool)> = want.iter().rev().map(|h| (*h, false)).collect();
248 258
        while let Some((h, expanded)) = stack.pop() {
249 259
            if done.contains(&h) {
250 260
                continue;
251 261
            }
252 262
            if !self.store.has(&h) {
263 +
                // A shallow repository holds only part of its history; a
264 +
                // change it lacks is below what it cloned (in a depth-0
265 +
                // clone, any change of the checkpoint), which the remote
266 +
                // is assumed to hold, as it is for the boundary (#137).
267 +
                if shallow {
268 +
                    done.insert(h);
269 +
                    continue;
270 +
                }
253 271
                return fail(format!("change {} is not in this repository", h.short(12)));
254 272
            }
255 273
            if expanded {
…
19 unchanged lines
275 293
276 294
    /// Objects by reference for the `objects` endpoint; a blob brings its chunks.
277 295
    pub fn objects_for(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> {
278 -
        let mut out = Vec::new();
279 -
        for r in refs {
280 -
            let (ty, body) = match self.store.get(r) {
281 -
                Ok(x) => x,
282 -
                Err(StoreError::NotFound(_)) => continue,
296 +
        // In two reads in pack order (#137): a checkpoint's graph blobs come
297 +
        // this way in parts, tens of thousands for a big state.
298 +
        let mut found = Vec::with_capacity(refs.len());
299 +
        for got in self.store.get_many(refs) {
300 +
            match got {
301 +
                Ok(x) => found.push(x),
302 +
                Err(StoreError::NotFound(_)) => {}
283 303
                Err(e) => return Err(e.into()),
284 -
            };
285 -
            if ty == ObjectType::Blob {
286 -
                for c in Blob::decode(&body)?.chunks {
287 -
                    if let Ok((cty, cb)) = self.store.get(&c) {
288 -
                        out.push((cty, cb));
289 -
                    }
290 -
                }
304 +
        let mut chunks = Vec::new();
305 +
        for (ty, body) in &found {
306 +
            if *ty == ObjectType::Blob {
307 +
                chunks.extend(Blob::decode(body)?.chunks);
308 +
            }
309 +
        }
310 +
        let mut out = Vec::with_capacity(found.len() + chunks.len());
311 +
        for got in self.store.get_many(&chunks) {
312 +
            match got {
313 +
                Ok(x) => out.push(x),
314 +
                Err(StoreError::NotFound(_)) => {}
315 +
                Err(e) => return Err(e.into()),
316 +
            }
317 +
        }
318 +
        out.extend(found);
291 319
            }
292 -
            out.push((ty, body));
293 320
        }
294 321
        Ok(out)
295 322
    }
…
114 unchanged lines
410 437
            for s in &sigs {
411 438
                add_object(&s.key, &mut objects);
412 439
            }
440 +
            // A replacement's new content only: its old content is the new
441 +
            // content of a change it depends on, which the receiver holds or
442 +
            // which this bundle carries (oxygen#136: removing ~1,100 binary
443 +
            // files sent them all again).
413 444
            for e in &c.edits {
414 -
                if let Edit::Replace { old, new, .. } = e {
415 -
                    for b in [old, new].into_iter().flatten() {
416 -
                        if self.store.has(b) {
417 -
                            if let Ok(blob) =
418 -
                                Blob::decode(&self.store.get_typed(b, ObjectType::Blob)?)
419 -
                            {
420 -
                                if max_blob.is_some_and(|m| blob.length > m) {
421 -
                                    continue;
422 -
                                }
423 -
                                objects.insert(*b);
424 -
                                objects.extend(blob.chunks.iter().copied());
445 +
                if let Edit::Replace { new: Some(b), .. } = e {
446 +
                    if self.store.has(b) {
447 +
                        if let Ok(blob) = Blob::decode(&self.store.get_typed(b, ObjectType::Blob)?)
448 +
                        {
449 +
                            if max_blob.is_some_and(|m| blob.length > m) {
450 +
                                continue;
425 451
                            }
452 +
                            objects.insert(*b);
453 +
                            objects.extend(blob.chunks.iter().copied());
426 454
                        }
427 455
                    }
428 456
                }
…
74 unchanged lines
503 531
            {
504 532
                continue;
505 533
            }
506 -
            for id in [&k, &cert.cert.issuer] {
534 +
            // An agent's certificate brings its grant's signer too, so the
535 +
            // receiver can check the grant (oxygen#134).
536 +
            let grantor = cert.cert.grant.as_ref().map(|g| g.grant.grantor);
537 +
            for id in [Some(k), Some(cert.cert.issuer), grantor].iter().flatten() {
507 538
                if let Ok(body) = self.store.get_typed(id, ObjectType::Identity) {
508 539
                    if !bundle
509 540
                        .objects
…
462 unchanged lines
972 1003
        }
973 1004
        for t in &tags {
974 1005
            let name = format!("tags/{}", t.name);
1006 +
            if t.is_withdrawal() {
1007 +
                match self.adopt_withdrawal(&mut view, t, policy.as_ref(), &mut TagCheck::Received)
1008 +
                {
1009 +
                    Ok(removed) => {
1010 +
                        // A withdrawal is an update to nothing, as a
1011 +
                        // bookmark's deletion is.
1012 +
                        if removed {
1013 +
                            report.updated.push(RefUpdated {
1014 +
                                name: name.clone(),
1015 +
                                old_state: t.state,
1016 +
                                new_state: ObjectRef::ZERO,
1017 +
                                heads: vec![],
1018 +
                            });
1019 +
                        }
1020 +
                        names.push(name.clone());
1021 +
                        results.push(RefResult {
1022 +
                            name,
1023 +
                            ok: true,
1024 +
                            reason: if removed {
1025 +
                                "withdrawn"
1026 +
                            } else {
1027 +
                                "already withdrawn"
1028 +
                            }
1029 +
                            .into(),
1030 +
                        });
1031 +
                    }
1032 +
                    Err(e) => results.push(RefResult {
1033 +
                        name,
1034 +
                        ok: false,
1035 +
                        reason: e.reason,
1036 +
                    }),
1037 +
                }
1038 +
                continue;
1039 +
            }
975 1040
            let new = !view.tags.contains_key(&t.name);
976 -
            match self.adopt_tag(&mut view, t, policy.as_ref()) {
1041 +
            match self.adopt_tag(&mut view, t, policy.as_ref(), &mut TagCheck::Received) {
977 1042
                Ok(()) => {
978 1043
                    // A tag new here is an update too, so a hub can start
979 1044
                    // its pipeline's tag jobs (oxygenflow#24); one it
…
16 unchanged lines
996 1061
                Err(e) => results.push(RefResult {
997 1062
                    name,
998 1063
                    ok: false,
999 -
                    reason: e,
1064 +
                    reason: e.reason,
1000 1065
                }),
1001 1066
            }
1002 1067
        }
…
83 unchanged lines
1086 1151
        sa: &SignedAttest,
1087 1152
        accepted: Option<&SignedAttest>,
1088 1153
    ) -> u64 {
1089 -
        let own = sa.attest.hlc.wall_ms;
1090 -
        let Some(acc) = accepted else {
1091 -
            return own;
1092 -
        };
1093 -
        let Ok(Some(c)) = self.store.certificate_of(signer) else {
1094 -
            return own;
1095 -
        };
1096 -
        let Ok(cert) = self.store.get_certificate(&c) else {
1097 -
            return own;
1098 -
        };
1154 +
        self.issuer_acceptance(signer, sa, accepted)
1155 +
            .unwrap_or(sa.attest.hlc.wall_ms)
1156 +
    }
1157 +
1158 +
    /// The time of `accepted` when it accepts exactly `sa` and is signed by
1159 +
    /// the issuer of `signer`'s kept certificate (spec §14.7); else none.
1160 +
    pub(crate) fn issuer_acceptance(
1161 +
        &self,
1162 +
        signer: &KeyId,
1163 +
        sa: &SignedAttest,
1164 +
        accepted: Option<&SignedAttest>,
1165 +
    ) -> Option<u64> {
1166 +
        let acc = accepted?;
1167 +
        let c = self.store.certificate_of(signer).ok().flatten()?;
1168 +
        let cert = self.store.get_certificate(&c).ok()?;
1099 1169
        let AttestBody::Accepted { attest } = &acc.attest.body else {
1100 -
            return own;
1101 -
        };
1102 -
        let Some(sig) = acc.sigs.iter().find(|s| s.key == cert.cert.issuer) else {
1103 -
            return own;
1104 -
        };
1105 -
        let Ok(issuer) = self.store.get_identity(&cert.cert.issuer) else {
1106 -
            return own;
1170 +
            return None;
1107 1171
        };
1108 -
        if *attest == sa.attest.hash()
1109 -
            && verify_signature(&issuer, sig, Domain::Attest, &acc.attest.hash()).is_ok()
1110 -
        {
1111 -
            acc.attest.hlc.wall_ms
1112 -
        } else {
1113 -
            own
1172 +
        let sig = acc.sigs.iter().find(|s| s.key == cert.cert.issuer)?;
1173 +
        let issuer = self.store.get_identity(&cert.cert.issuer).ok()?;
1174 +
        (*attest == sa.attest.hash()
1175 +
            && verify_signature(&issuer, sig, Domain::Attest, &acc.attest.hash()).is_ok())
1176 +
        .then_some(acc.attest.hlc.wall_ms)
1177 +
    }
1178 +
1179 +
    /// Keep an acceptance this repository was given for attestation `r`
1180 +
    /// (one [`Repo::issuer_acceptance`] took), so it is served again with
1181 +
    /// what it accepts.
1182 +
    pub(crate) fn keep_acceptance(&self, r: &ObjectRef, acc: &SignedAttest) -> Result<()> {
1183 +
        let a = self.store.put_attest(&acc.attest)?;
1184 +
        for sig in &acc.sigs {
1185 +
            self.store.put_signature(&a, sig)?;
1114 1186
        }
1187 +
        self.store.index_add_pub("accepted", r, &a)?;
1188 +
        Ok(())
1115 1189
    }
1116 1190
1117 1191
    /// A fetched bookmark's attestation by a signer the policy does not
…
283 unchanged lines
1401 1475
        if options.token.is_some() && options.ca.is_some() {
1402 1476
            return;
1403 1477
        }
1404 -
        let explicit =
1405 -
            options.token.is_some() || std::env::var("OXY_TOKEN").is_ok_and(|t| !t.is_empty());
1478 +
        let own = options.token.is_some();
1479 +
        let env = std::env::var("OXY_TOKEN").is_ok_and(|t| !t.is_empty());
1480 +
        let explicit = own || env;
1406 1481
        let stored = self.credentials().options_for(url);
1482 +
        // A checkout's own token refused as expired or unknown (401) falls
1483 +
        // back to this computer's sign-in for the hub, never to another
1484 +
        // checkout's, and never when it is refused for permission (#155).
1485 +
        if own && !env {
1486 +
            if let Some(t) = stored
1487 +
                .token
1488 +
                .clone()
1489 +
                .filter(|t| options.token.as_ref() != Some(t))
1490 +
            {
1491 +
                options.fallback_tokens.push(t);
1492 +
                options.fallback_on_401_only = true;
1493 +
            }
1494 +
        }
1407 1495
        options.token = options.token.take().or(stored.token);
1408 1496
        options.ca = options.ca.take().or(stored.ca);
1409 1497
        let enclosing = self.enclosing_credentials(url);
…
12 unchanged lines
1422 1510
        }
1423 1511
    }
1424 1512
1513 +
    /// What to do when `remote`'s own token was refused (`message` says
1514 +
    /// HTTP 401) and so was this computer's sign-in for its hub, which it
1515 +
    /// falls back to (oxygen#155).
1516 +
    pub fn stale_token_note(&self, remote: &str, message: &str) -> Option<String> {
1517 +
        if !message.contains("HTTP 401") {
1518 +
            return None;
1519 +
        }
1520 +
        let own = self.config.remote_tokens.get(remote)?;
1521 +
        let url = self.config.remotes.get(remote)?;
1522 +
        let stored = self.credentials().options_for(url).token?;
1523 +
        (stored != *own).then(|| {
1524 +
            format!(
1525 +
                "the hub refused both this checkout's own token for {remote} (remote.{remote}.token) and your sign-in: \
1526 +
                 `oxy auth login HUB`, then `oxy config --unset remote.{remote}.token` so the sign-in is used"
1527 +
            )
1528 +
        })
1529 +
    }
1530 +
1425 1531
    /// The tokens and CAs of remotes on `url`'s hub in enclosing
1426 1532
    /// checkouts, nearest first.
1427 1533
    fn enclosing_credentials(&self, url: &str) -> Vec<(Option<String>, Option<PathBuf>)> {
…
81 unchanged lines
1509 1615
        }
1510 1616
    }
1511 1617
1512 -
    fn open_named_remote(&self, name: &str) -> Result<Box<dyn crate::protocol::Remote>> {
1618 +
    pub(crate) fn open_named_remote(&self, name: &str) -> Result<Box<dyn crate::protocol::Remote>> {
1513 1619
        open_remote(&self.remote_url(name)?, &self.remote_options(name))
1514 1620
    }
1515 1621
…
112 unchanged lines
1628 1734
    pub fn fetch(&self, remote_name: &str) -> Result<FetchReport> {
1629 1735
        let remote = self.open_named_remote(remote_name)?;
1630 1736
        self.check_remote_id(remote.as_ref(), remote_name, false)?;
1631 -
        let resp = remote.refs()?;
1737 +
        let resp = {
1738 +
            let _t = crate::timing::phase("fetch: refs");
1739 +
            remote.refs()?
1740 +
        };
1632 1741
        // The host's transparency log (spec §14.8): a fork fails here.
1742 +
        let log_phase = crate::timing::phase("fetch: transparency log");
1633 1743
        let log = self.check_log(
1634 1744
            &self.remote_url(remote_name)?,
1635 1745
            self.config.remote_cas.get(remote_name).map(Path::new),
1636 1746
            &resp,
1637 1747
        )?;
1748 +
        drop(log_phase);
1638 1749
        let mut log_notes = log.notes.clone();
1639 1750
        // Trust policy (spec §14.5). Adopt the remote's policy when it is newer
1640 1751
        // and signed by roots we trust, or on first contact. A remote that
…
47 unchanged lines
1688 1799
        let mut report = Vec::new();
1689 1800
        let mut skipped = Vec::new();
1690 1801
        let max_blob = self.config.max_blob;
1691 -
        for r in resp.refs {
1802 +
        // One negotiation and one bundle for every bookmark's heads at
1803 +
        // once (oxygen#158): a bookmark at a time cost a round trip each and
1804 +
        // sent again the changes bookmarks share, as `have` did not grow
1805 +
        // between them (160 bookmarks: 160 bundles, 49k changes encoded for
1806 +
        // a few hundred). Heads already here, with every attestation's
1807 +
        // signer known, need no round trip at all.
1808 +
        let mut wanted: Vec<ChangeHash> = resp
1809 +
            .refs
1810 +
            .iter()
1811 +
            .flat_map(|r| r.heads.iter().copied())
1812 +
            .filter(|h| !self.store.has(h))
1813 +
            .collect();
1814 +
        wanted.sort();
1815 +
        wanted.dedup();
1816 +
        // A bundle also carries the identities and certificates of the keys
1817 +
        // that signed the remote's attestations (spec §16.2): with no change
1818 +
        // wanted, ask anyway when one of those signers is unknown here.
1819 +
        let unknown_signer = resp
1820 +
            .refs
1821 +
            .iter()
1822 +
            .filter_map(|r| r.attestation.as_ref())
1823 +
            .chain(resp.tags.iter().filter_map(|t| t.attestation.as_ref()))
1824 +
            .flat_map(|a| a.sigs.iter().map(|s| s.key))
1825 +
            .any(|k| {
1826 +
                self.store.get_identity(&k).is_err()
1827 +
                    && !matches!(self.store.certificate_of(&k), Ok(Some(_)))
1828 +
            });
1829 +
        let shallow = self.is_shallow();
1830 +
        let imported: Vec<ChangeHash> = if wanted.is_empty() && !unknown_signer {
1831 +
            Vec::new()
1832 +
        } else {
1692 1833
            let needed = {
1693 1834
                let _t = crate::timing::phase("fetch: negotiate");
1694 -
                remote.negotiate(&have, &r.heads)?
1835 +
                remote.negotiate(&have, &wanted)?
1695 1836
            };
1696 1837
            let bundle = {
1697 1838
                let _t = crate::timing::phase("fetch: bundle");
1698 1839
                remote.fetch(&needed, max_blob)?
1699 1840
            };
1700 -
            // Changes are not individually re-verified here: the bookmark's
1841 +
            // Changes are not individually re-verified here: each bookmark's
1701 1842
            // attestation (checked below) binds its whole state, and the
1702 1843
            // receiver of the push verified the added changes at that time.
1703 -
            let imported = {
1704 -
                let _t = crate::timing::phase("fetch: import");
1844 +
            // A shallow repository takes changes whose dependencies it never
1845 +
            // held (below what it cloned), as its clone did (#137).
1846 +
            let _t = crate::timing::phase("fetch: import");
1847 +
            if shallow {
1848 +
                self.import_bundle_shallow(&bundle, false)?
1849 +
            } else {
1705 1850
                self.import_bundle(&bundle, false)?
1706 -
            };
1707 -
            if max_blob.is_some() {
1708 -
                self.register_missing_blobs(&imported, remote_name)?;
1709 1851
            }
1852 +
        };
1853 +
        if shallow {
1854 +
            self.refresh_boundary(&imported)?;
1855 +
        }
1856 +
        if max_blob.is_some() {
1857 +
            self.register_missing_blobs(&imported, remote_name)?;
1858 +
        }
1859 +
        let boundary = if shallow {
1860 +
            self.boundary()
1861 +
        } else {
1862 +
            BTreeSet::new()
1863 +
        };
1864 +
        // Each bookmark's new changes: those just imported that its heads
1865 +
        // reach, for the report.
1866 +
        let mut new_deps: HashMap<ChangeHash, Vec<ChangeHash>> = HashMap::new();
1867 +
        for h in &imported {
1868 +
            if let Ok(m) = self.store.get_change_meta(h) {
1869 +
                new_deps.insert(*h, m.deps);
1870 +
            }
1871 +
        }
1872 +
        let new_in = |heads: &[ChangeHash]| -> usize {
1873 +
            let mut seen: HashSet<ChangeHash> = HashSet::new();
1874 +
            let mut stack: Vec<ChangeHash> = heads
1875 +
                .iter()
1876 +
                .filter(|h| new_deps.contains_key(*h))
1877 +
                .copied()
1878 +
                .collect();
1879 +
            while let Some(h) = stack.pop() {
1880 +
                if seen.insert(h) {
1881 +
                    stack.extend(
1882 +
                        new_deps[&h]
1883 +
                            .iter()
1884 +
                            .filter(|d| new_deps.contains_key(*d) && !seen.contains(*d)),
1885 +
                    );
1886 +
                }
1887 +
            }
1888 +
            seen.len()
1889 +
        };
1890 +
        let checks = crate::timing::phase("fetch: check each bookmark");
1891 +
        for r in resp.refs {
1710 1892
            for h in &r.heads {
1711 -
                if !self.store.has(h) {
1893 +
                if !self.store.has(h) && !boundary.contains(h) {
1712 1894
                    return fail(format!("remote did not send head {}", h.short(12)));
1713 1895
                }
1714 1896
            }
…
72 unchanged lines
1787 1969
                .entry(remote_name.to_string())
1788 1970
                .or_default()
1789 1971
                .insert(r.name.clone(), state);
1790 -
            report.push((r.name, r.heads, imported.len()));
1972 +
            let n = new_in(&r.heads);
1973 +
            report.push((r.name, r.heads, n));
1791 1974
        }
1975 +
        drop(checks);
1976 +
        let tags_phase = crate::timing::phase("fetch: tags");
1792 1977
        let mut new_tags = 0;
1793 1978
        for t in &resp.tags {
1794 1979
            if view.tags.contains_key(&t.name) {
1795 1980
                continue;
1796 1981
            }
1982 +
            // Withdrawn here: the name is not used again.
1983 +
            if self.tag_withdrawal(&t.name)?.is_some() {
1984 +
                continue;
1985 +
            }
1797 1986
            // The tag's changes must be here: fetch them if the bookmarks did not bring them.
1798 1987
            let missing: Vec<ChangeHash> = t
1799 1988
                .heads
…
7 unchanged lines
1807 1996
                self.import_bundle(&bundle, false)?;
1808 1997
            }
1809 1998
            self.store.put_state(&StateObject::new(t.heads.clone()))?;
1810 -
            self.adopt_tag(&mut view, t, policy.as_ref())
1811 -
                .map_err(|e| RepoError::Msg(format!("{remote_name} {}: {e}", t.name)))?;
1812 -
            new_tags += 1;
1999 +
            let mut check = TagCheck::Fetched {
2000 +
                remote: remote_name,
2001 +
                log: &log,
2002 +
                notes: &mut log_notes,
2003 +
            };
2004 +
            match self.adopt_tag(&mut view, t, policy.as_ref(), &mut check) {
2005 +
                Ok(()) => new_tags += 1,
2006 +
                // Nothing says when a short-lived key signed it: the tag is
2007 +
                // left out, said so, and the rest is fetched (oxygen#129).
2008 +
                Err(e) if e.untimed => {
2009 +
                    skipped.push((format!("tags/{}", t.name), e.reason));
2010 +
                }
2011 +
                Err(e) => return fail(format!("{remote_name} {}: {}", t.name, e.reason)),
2012 +
            }
1813 2013
        }
2014 +
        // The remote withdrew a tag this repository has: so does this one,
2015 +
        // once the withdrawal is checked as a push's would be; one that is
2016 +
        // not is left as it is, said so (oxygenhub#301).
2017 +
        drop(tags_phase);
2018 +
        let mut withdrawn_tags = Vec::new();
2019 +
        for t in &resp.withdrawn {
2020 +
            if !view.tags.contains_key(&t.name) {
2021 +
                continue;
2022 +
            }
2023 +
            let mut check = TagCheck::Fetched {
2024 +
                remote: remote_name,
2025 +
                log: &log,
2026 +
                notes: &mut log_notes,
2027 +
            };
2028 +
            match self.adopt_withdrawal(&mut view, t, policy.as_ref(), &mut check) {
2029 +
                Ok(true) => withdrawn_tags.push(t.name.clone()),
2030 +
                Ok(false) => {}
2031 +
                Err(e) => skipped.push((
2032 +
                    format!("tags/{}", t.name),
2033 +
                    format!("withdrawn on {remote_name}, kept here: {}", e.reason),
2034 +
                )),
2035 +
            }
2036 +
        }
1814 2037
        if view != op.view {
1815 2038
            self.record_op(&[op_ref], &format!("oxy fetch {remote_name}"), view)?;
1816 2039
        }
…
13 unchanged lines
1830 2053
            refs: report,
1831 2054
            unverified,
1832 2055
            new_tags,
2056 +
            withdrawn_tags,
1833 2057
            log_notes,
1834 2058
            skipped,
1835 2059
        })
…
17 unchanged lines
1853 2077
        if r.heads.is_empty() {
1854 2078
            return Ok(0);
1855 2079
        }
1856 -
        // Base: the remote's checkpoint of exactly these heads.
1857 -
        let cp_bundle = remote.checkpoint(&r.heads)?;
1858 -
        let mut cp_ref = None;
1859 -
        for (ty, body) in &cp_bundle.objects {
1860 -
            let put = self.store.put(*ty, body)?;
1861 -
            if *ty == ObjectType::Checkpoint {
1862 -
                cp_ref = Some(put);
1863 -
            }
1864 -
        }
2080 +
        // Base: the remote's checkpoint of exactly these heads, without its
2081 +
        // graph blobs, which come in parts below (#137).
2082 +
        let cp_bundle = {
2083 +
            let _t = crate::timing::phase("shallow: checkpoint request");
2084 +
            remote.checkpoint_index(&r.heads)?
2085 +
        };
2086 +
        // In one batch: a big state's checkpoint is tens of thousands of
2087 +
        // graph blobs and chunks, which stored one by one were each written
2088 +
        // loose and flushed (a VS Code clone ran at 27 objects a second).
2089 +
        let objects: Vec<(ObjectType, &[u8])> = cp_bundle
2090 +
            .objects
2091 +
            .iter()
2092 +
            .map(|(ty, body)| (*ty, body.as_slice()))
2093 +
            .collect();
2094 +
        let t_store = crate::timing::phase("shallow: store the checkpoint");
2095 +
        let batch = self.store.begin_batch()?;
2096 +
        let stored = self.store.put_many(&objects);
2097 +
        batch.finish()?;
2098 +
        drop(t_store);
2099 +
        let cp_ref = objects
2100 +
            .iter()
2101 +
            .zip(stored?)
2102 +
            .find(|((ty, _), _)| *ty == ObjectType::Checkpoint)
2103 +
            .map(|(_, r)| r);
1865 2104
        let Some(cp_ref) = cp_ref else {
1866 2105
            return fail(format!("{remote_name} sent no checkpoint"));
1867 2106
        };
2107 +
        // The graph blobs the payload names, with their chunks, a part at
2108 +
        // a time (#137): one answer for a big state was gigabytes, held
2109 +
        // whole on both sides. A remote that sent the whole checkpoint has
2110 +
        // left none to ask for.
2111 +
        let cp = crate::objects::Checkpoint::decode(
2112 +
            &self.store.get_typed(&cp_ref, ObjectType::Checkpoint)?,
2113 +
        )?;
2114 +
        let graphs: Vec<ObjectRef> = self
2115 +
            .checkpoint_graph_blobs(&cp)?
2116 +
            .into_iter()
2117 +
            .filter(|b| !self.store.has(b))
2118 +
            .collect();
2119 +
        {
2120 +
            let _t = crate::timing::phase("shallow: the checkpoint's graphs, in parts");
2121 +
            for part in graphs.chunks(CHECKPOINT_PART) {
2122 +
                let objects = remote.objects(part)?;
2123 +
                let objects: Vec<(ObjectType, &[u8])> = objects
2124 +
                    .iter()
2125 +
                    .filter(|(ty, _)| matches!(ty, ObjectType::Blob | ObjectType::Chunk))
2126 +
                    .map(|(ty, body)| (*ty, body.as_slice()))
2127 +
                    .collect();
2128 +
                let batch = self.store.begin_batch()?;
2129 +
                let stored = self.store.put_many(&objects);
2130 +
                batch.finish()?;
2131 +
                stored?;
2132 +
            }
2133 +
            if let Some(b) = graphs.iter().find(|b| !self.store.has(b)) {
2134 +
                return fail(format!(
2135 +
                    "{remote_name} did not send graph blob {}",
2136 +
                    b.short(12)
2137 +
                ));
2138 +
            }
2139 +
        }
1868 2140
        self.install_checkpoint(&bookmark, &cp_ref)?;
1869 -
        // History window.
1870 -
        let wanted = remote.shallow(&r.heads, depth)?;
1871 -
        let bundle = remote.fetch(&wanted, self.config.max_blob)?;
1872 -
        let imported = self.import_bundle_shallow(&bundle, false)?;
2141 +
        let _t_blobs = crate::timing::phase("shallow: the binary files' blobs");
2142 +
        // The blobs of the state's opaque files come with no change when the
2143 +
        // window holds none of the changes that set them (#137): fetch them
2144 +
        // now, or under `filter.max-blob` leave them to be fetched on read.
2145 +
        let base = State::from_checkpoint_history(&self.read_blob(&cp.payload)?)?;
2146 +
        let mut missing: Vec<ObjectRef> = base
2147 +
            .opaque_nodes()
2148 +
            .iter()
2149 +
            .flat_map(|n| base.live_blobs(n))
2150 +
            .filter(|b| !self.store.has(b))
2151 +
            .collect();
2152 +
        missing.sort();
2153 +
        missing.dedup();
1873 2154
        if self.config.max_blob.is_some() {
2155 +
            for b in &missing {
2156 +
                self.register_lazy(b, remote_name)?;
2157 +
            }
2158 +
        } else {
2159 +
            for part in missing.chunks(512) {
2160 +
                let objects = remote.objects(part)?;
2161 +
                let objects: Vec<(ObjectType, &[u8])> = objects
2162 +
                    .iter()
2163 +
                    .filter(|(ty, _)| matches!(ty, ObjectType::Blob | ObjectType::Chunk))
2164 +
                    .map(|(ty, body)| (*ty, body.as_slice()))
2165 +
                    .collect();
2166 +
                let batch = self.store.begin_batch()?;
2167 +
                let stored = self.store.put_many(&objects);
2168 +
                batch.finish()?;
2169 +
                stored?;
2170 +
            }
2171 +
            if let Some(b) = missing.iter().find(|b| !self.store.has(b)) {
2172 +
                return fail(format!("{remote_name} did not send blob {}", b.short(12)));
2173 +
            }
2174 +
        }
2175 +
        // History window. Depth 0 takes none (#137): the checkpoint holds
2176 +
        // every change's metadata, and its heads become the boundary, so a
2177 +
        // state of many heads (33,835 in the VS Code import) costs nothing
2178 +
        // to fetch.
2179 +
        let imported = if depth == 0 {
2180 +
            let mut boundary = self.boundary();
2181 +
            boundary.extend(r.heads.iter().filter(|h| !self.store.has(h)).copied());
2182 +
            self.write_boundary(&boundary)?;
2183 +
            Vec::new()
2184 +
        } else {
2185 +
            let wanted = remote.shallow(&r.heads, depth)?;
2186 +
            let bundle = remote.fetch(&wanted, self.config.max_blob)?;
2187 +
            self.import_bundle_shallow(&bundle, false)?
2188 +
        };
2189 +
        if self.config.max_blob.is_some() {
1874 2190
            self.register_missing_blobs(&imported, remote_name)?;
1875 2191
        }
1876 2192
        self.refresh_boundary(&imported)?;
…
161 unchanged lines
2038 2354
        }
2039 2355
    }
2040 2356
2357 +
    /// Withdraw tag `tag` on `remote_name` (`oxy push --delete tags/NAME`,
2358 +
    /// oxygenhub#301): withdrawn here first when it still is a tag here
2359 +
    /// (`oxy tag delete`), then the withdrawal alone is sent, with its
2360 +
    /// signer's identity and certificate.
2361 +
    pub fn push_tag_withdrawal(&self, remote_name: &str, tag: &str) -> Result<Vec<RefResult>> {
2362 +
        let remote = self.open_named_remote(remote_name)?;
2363 +
        self.check_remote_id(remote.as_ref(), remote_name, true)?;
2364 +
        let resp = remote.refs()?;
2365 +
        let name = format!("tags/{tag}");
2366 +
        if resp.withdrawn.iter().any(|t| t.name == tag) {
2367 +
            return Ok(vec![RefResult {
2368 +
                name,
2369 +
                ok: true,
2370 +
                reason: "already withdrawn".into(),
2371 +
            }]);
2372 +
        }
2373 +
        let Some(there) = resp.tags.iter().find(|t| t.name == tag) else {
2374 +
            return fail(format!("{remote_name} has no tag {tag}"));
2375 +
        };
2376 +
        if resp.policy.is_some() && self.secret_key()?.is_none() {
2377 +
            return fail(format!("{remote_name} enforces a trust policy; withdrawing a tag needs an identity (`oxy identity create`)"));
2378 +
        }
2379 +
        if self.tag_withdrawal(tag)?.is_none() {
2380 +
            match self.view()?.tags.get(tag) {
2381 +
                Some(st) if *st == there.state => {
2382 +
                    self.tag_delete(tag, "")?;
2383 +
                }
2384 +
                Some(_) => {
2385 +
                    return fail(format!(
2386 +
                        "tag {tag} names another state here than on {remote_name}"
2387 +
                    ))
2388 +
                }
2389 +
                None => {
2390 +
                    return fail(format!(
2391 +
                        "there is no tag {tag} here to withdraw: `oxy fetch {remote_name}` first"
2392 +
                    ))
2393 +
                }
2394 +
            }
2395 +
        }
2396 +
        let Some(w) = self
2397 +
            .withdrawn_refs()?
2398 +
            .into_iter()
2399 +
            .find(|w| w.name == tag && w.state == there.state)
2400 +
        else {
2401 +
            return fail(format!(
2402 +
                "tag {tag} was withdrawn here at another state than {remote_name}'s"
2403 +
            ));
2404 +
        };
2405 +
        let mut bundle = Bundle::default();
2406 +
        let signers: Vec<KeyId> = w
2407 +
            .attestation
2408 +
            .iter()
2409 +
            .flat_map(|a| a.sigs.iter().map(|s| s.key))
2410 +
            .collect();
2411 +
        for k in &signers {
2412 +
            if let Ok(id) = self.identity(k) {
2413 +
                bundle.objects.push((ObjectType::Identity, id.encode()));
2414 +
            }
2415 +
        }
2416 +
        self.attach_certificates_for(&mut bundle, &signers)?;
2417 +
        remote.push(bundle, vec![], None, vec![w])
2418 +
    }
2419 +
2041 2420
    /// Push the current bookmark (spec §16.3), sealing this author's unsigned
2042 2421
    /// changes and attesting the reference when a key is available.
2043 2422
    /// Delete `bookmark` on a remote (spec §16.4): an update to the zero
2044 2423
    /// state, attested when we have an identity. The local bookmark, if
2045 2424
    /// any, is kept; only the remote-tracking entry goes.
2046 2425
    pub fn push_delete(&self, remote_name: &str, bookmark: &str) -> Result<Vec<RefResult>> {
2426 +
        if let Some(tag) = bookmark.strip_prefix("tags/") {
2427 +
            return self.push_tag_withdrawal(remote_name, tag);
2428 +
        }
2047 2429
        let remote = self.open_named_remote(remote_name)?;
2048 2430
        self.check_remote_id(remote.as_ref(), remote_name, true)?;
2049 2431
        let resp = remote.refs()?;
…
77 unchanged lines
2127 2509
        Ok(())
2128 2510
    }
2129 2511
2512 +
    /// Send `bundle`'s blobs and chunks ahead of it, in parts of about
2513 +
    /// `push.batch-bytes` of objects alone, when the whole would be larger
2514 +
    /// (oxygen#133): one change can bring more blobs than one request may
2515 +
    /// carry, and a proxy in front of a hub refuses a request past its limit
2516 +
    /// (Cloudflare's is 100 MB). What is left, the changes with their
2517 +
    /// identities and states, is returned to send as before. A receiver
2518 +
    /// stores objects as they come, and checks a change's blobs are stored.
2519 +
    fn send_objects_ahead(
2520 +
        &self,
2521 +
        remote: &dyn crate::protocol::Remote,
2522 +
        remote_name: &str,
2523 +
        mut bundle: Bundle,
2524 +
        progress: crate::store::Progress,
2525 +
    ) -> Result<Bundle> {
2526 +
        let limit = self.config.push_batch_bytes;
2527 +
        let changes: u64 = bundle
2528 +
            .changes
2529 +
            .iter()
2530 +
            .map(|(c, _)| c.encode().len() as u64)
2531 +
            .sum();
2532 +
        let objects: u64 = bundle.objects.iter().map(|(_, b)| b.len() as u64).sum();
2533 +
        if changes + objects <= limit {
2534 +
            return Ok(bundle);
2535 +
        }
2536 +
        let (mut kept, mut parts, mut part, mut bytes) = (Vec::new(), Vec::new(), Vec::new(), 0u64);
2537 +
        for (ty, body) in std::mem::take(&mut bundle.objects) {
2538 +
            if !matches!(ty, ObjectType::Blob | ObjectType::Chunk) {
2539 +
                kept.push((ty, body));
2540 +
                continue;
2541 +
            }
2542 +
            if !part.is_empty() && bytes + body.len() as u64 > limit {
2543 +
                parts.push(std::mem::take(&mut part));
2544 +
                bytes = 0;
2545 +
            }
2546 +
            bytes += body.len() as u64;
2547 +
            part.push((ty, body));
2548 +
        }
2549 +
        if !part.is_empty() {
2550 +
            parts.push(part);
2551 +
        }
2552 +
        let n = parts.len();
2553 +
        for (i, objects) in parts.into_iter().enumerate() {
2554 +
            progress(&format!(
2555 +
                "objects {} of {n}: {} blobs and chunks",
2556 +
                i + 1,
2557 +
                objects.len()
2558 +
            ));
2559 +
            let results = remote.push(
2560 +
                Bundle {
2561 +
                    objects,
2562 +
                    ..Default::default()
2563 +
                },
2564 +
                vec![],
2565 +
                None,
2566 +
                vec![],
2567 +
            )?;
2568 +
            match results.iter().find(|r| r.name == OBJECTS_RESULT) {
2569 +
                Some(r) if r.ok => {}
2570 +
                Some(r) => {
2571 +
                    return fail(format!(
2572 +
                        "{remote_name} refused objects part {} of {n}: {}",
2573 +
                        i + 1,
2574 +
                        r.reason
2575 +
                    ))
2576 +
                }
2577 +
                None => {
2578 +
                    return fail(format!(
2579 +
                        "{remote_name} does not take a push in parts, and this one is too large for one: update the server's oxy"
2580 +
                    ))
2581 +
                }
2582 +
            }
2583 +
        }
2584 +
        bundle.objects = kept;
2585 +
        Ok(bundle)
2586 +
    }
2587 +
2130 2588
    /// `changes`, in dependency order, cut into batches of about
2131 2589
    /// `push.batch-bytes` by what each change and its texts and blobs weigh.
2132 2590
    /// There is always one, perhaps empty.
…
48 unchanged lines
2181 2639
2182 2640
    /// `push_bookmark`, saying how a long push is going: one line per
2183 2641
    /// batch sent (oxygen#95).
2642 +
    /// What a push may take the remote to hold already: the heads of the
2643 +
    /// bookmark pushed, and of every other bookmark it lists that are held
2644 +
    /// here, so a new bookmark made from main sends only what main lacks
2645 +
    /// (oxygen#142), not its whole history again.
2646 +
    pub(crate) fn remote_holds(
2647 +
        &self,
2648 +
        refs: &[crate::protocol::RemoteRef],
2649 +
        bookmark_heads: &[ChangeHash],
2650 +
    ) -> Vec<ChangeHash> {
2651 +
        let mut have: Vec<ChangeHash> = bookmark_heads.to_vec();
2652 +
        for h in refs.iter().flat_map(|r| &r.heads) {
2653 +
            if !have.contains(h) && self.store.has(h) {
2654 +
                have.push(*h);
2655 +
            }
2656 +
        }
2657 +
        have
2658 +
    }
2659 +
2184 2660
    pub fn push_bookmark_with(
2185 2661
        &self,
2186 2662
        remote_name: &str,
…
37 unchanged lines
2224 2700
        if remote_has_policy && self.secret_key()?.is_none() {
2225 2701
            return fail(format!("{remote_name} enforces a trust policy; pushing needs an identity (`oxy identity create`)"));
2226 2702
        }
2227 -
        let remote_tags: BTreeSet<String> = resp.tags.iter().map(|t| t.name.clone()).collect();
2703 +
        let remote_tags: BTreeSet<String> = resp
2704 +
            .tags
2705 +
            .iter()
2706 +
            .chain(&resp.withdrawn)
2707 +
            .map(|t| t.name.clone())
2708 +
            .collect();
2228 2709
        let tags_to_send: Vec<crate::protocol::TagRef> = self
2229 2710
            .tag_refs()?
2230 2711
            .into_iter()
2231 2712
            .filter(|t| !remote_tags.contains(&t.name))
2232 2713
            .collect();
2714 +
        // Tags withdrawn here that the remote still has, at the same state
2715 +
        // (oxygenhub#301).
2716 +
        let withdrawals_to_send: Vec<crate::protocol::TagRef> = self
2717 +
            .withdrawn_refs()?
2718 +
            .into_iter()
2719 +
            .filter(|w| {
2720 +
                resp.tags
2721 +
                    .iter()
2722 +
                    .any(|t| t.name == w.name && t.state == w.state)
2723 +
            })
2724 +
            .collect();
2233 2725
        let refs = resp.refs;
2234 2726
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
2235 2727
        let (remote_state, remote_heads, last_serial) =
…
8 unchanged lines
2244 2736
        // state is unchanged, so the current attestation is always by a key
2245 2737
        // the current policy authorizes (a revoked key's attestation would
2246 2738
        // otherwise be refused by every later fetch).
2247 -
        if new_state == remote_state && policy_to_send.is_none() && tags_to_send.is_empty() {
2739 +
        if new_state == remote_state
2740 +
            && policy_to_send.is_none()
2741 +
            && tags_to_send.is_empty()
2742 +
            && withdrawals_to_send.is_empty()
2743 +
        {
2248 2744
            return Ok(vec![RefResult {
2249 2745
                name: bookmark,
2250 2746
                ok: true,
2251 2747
                reason: "already up to date".into(),
2252 2748
            }]);
2253 2749
        }
2750 +
        // A shallow repository knows the changes on its boundary without
2751 +
        // holding them: a depth-0 clone's are the heads it cloned (#137).
2752 +
        let boundary = self.boundary();
2254 2753
        for h in &remote_heads {
2255 -
            if !self.store.has(h) {
2754 +
            if !self.store.has(h) && !boundary.contains(h) {
2256 2755
                return fail(format!(
2257 2756
                    "the remote has change {} which you do not; pull first (`oxy pull {remote_name}`), then push again",
2258 2757
                    h.short(12)
…
2 unchanged lines
2261 2760
        }
2262 2761
        if !force {
2263 2762
            let closure = self.closure_in_store(&local_heads)?;
2264 -
            if let Some(h) = remote_heads.iter().find(|h| !closure.contains(h)) {
2763 +
            if let Some(h) = remote_heads
2764 +
                .iter()
2765 +
                .find(|h| !closure.contains(h) && !boundary.contains(h))
2766 +
            {
2265 2767
                return fail(format!("not a fast-forward: remote head {} is not in your state; pull first or use --force", h.short(12)));
2266 2768
            }
2267 2769
        }
2268 -
        let to_send = self.negotiate(&remote_heads, &local_heads)?;
2770 +
        let to_send = self.negotiate(&self.remote_holds(&refs, &remote_heads), &local_heads)?;
2269 2771
        let key = self.secret_key()?.map(|(k, _)| k);
2270 2772
        if let Some(key) = key {
2271 2773
            // Seal this author's changes that carry no valid authenticator,
…
25 unchanged lines
2297 2799
                batches.len(),
2298 2800
                batch.len()
2299 2801
            ));
2300 -
            let results = remote.push(self.bundle_for(batch)?, vec![], None, vec![])?;
2802 +
            let bundle = self.send_objects_ahead(
2803 +
                remote.as_ref(),
2804 +
                remote_name,
2805 +
                self.bundle_for(batch)?,
2806 +
                &mut *progress,
2807 +
            )?;
2808 +
            let results = remote.push(bundle, vec![], None, vec![])?;
2301 2809
            match results.iter().find(|r| r.name == OBJECTS_RESULT) {
2302 2810
                Some(r) if r.ok => {}
2303 2811
                Some(r) => {
…
18 unchanged lines
2322 2830
                last.len()
2323 2831
            ));
2324 2832
        }
2325 -
        let mut bundle = self.bundle_for(last)?;
2833 +
        let mut bundle = self.send_objects_ahead(
2834 +
            remote.as_ref(),
2835 +
            remote_name,
2836 +
            self.bundle_for(last)?,
2837 +
            &mut *progress,
2838 +
        )?;
2326 2839
        bundle.objects.push((
2327 2840
            ObjectType::State,
2328 2841
            StateObject::new(local_heads.clone()).encode(),
…
61 unchanged lines
2390 2903
        // receiver has never seen, so its identity and certificate go too.
2391 2904
        let tag_signers: Vec<KeyId> = tags_to_send
2392 2905
            .iter()
2906 +
            .chain(&withdrawals_to_send)
2393 2907
            .filter_map(|t| t.attestation.as_ref())
2394 2908
            .flat_map(|a| a.sigs.iter().map(|s| s.key))
2395 2909
            .collect();
…
33 unchanged lines
2429 2943
                attestation,
2430 2944
            }],
2431 2945
            policy_to_send,
2432 -
            tags_to_send,
2946 +
            tags_to_send
2947 +
                .into_iter()
2948 +
                .chain(withdrawals_to_send)
2949 +
                .collect(),
2433 2950
        )?;
2434 2951
        if let (Some(serial), true) = (
2435 2952
            sent_serial,
…
219 unchanged lines
modified crates/oxy-core/src/repo/tags.rs +464 −17
1 1
//! Tags (spec §10.1, §14.4): immutable named states. A tag is a `tags/<name>`
2 2
//! entry in the view pointing at a state object, plus a signed `tag`
3 3
//! attestation when the author has a key. A name is never reassigned; a
4 -
//! server refuses a second attestation for an existing tag name.
4 +
//! server refuses a second attestation for an existing tag name. A tag is
5 +
//! withdrawn by a signed `untag` attestation (oxygenhub#301), kept beside
6 +
//! its creation; the name is not used again.
5 7
6 8
use super::*;
7 9
10 +
/// How a tag's signer is judged when it is adopted (spec §10.1, §14.7).
11 +
pub(crate) enum TagCheck<'a> {
12 +
    /// Received in a push: a short-lived key's certificate is checked at
13 +
    /// the receiver's own clock, or at its issuer's acceptance when the
14 +
    /// push carries one (a tag passed on from another host).
15 +
    Received,
16 +
    /// Fetched from `remote`: at the tag attestation's integrated time when
17 +
    /// the host's log gives one, else at its certificate issuer's
18 +
    /// acceptance; never at the time the tag claims, which its signer chose.
19 +
    Fetched {
20 +
        remote: &'a str,
21 +
        log: &'a super::transparency::LogCheck,
22 +
        notes: &'a mut Vec<String>,
23 +
    },
24 +
    /// Replayed into a Strata mirror from its store's record: at the
25 +
    /// acceptance when there is one, else at the tag's own time. The fetch
26 +
    /// from the mirror then judges it as every fetch does.
27 +
    Mirrored,
28 +
}
29 +
30 +
/// Why a tag was not adopted.
31 +
#[derive(Debug)]
32 +
pub(crate) struct TagRefusal {
33 +
    pub reason: String,
34 +
    /// Signed by a short-lived key, with nothing that says when to check
35 +
    /// its certificate: a fetch leaves the tag out and goes on.
36 +
    pub untimed: bool,
37 +
}
38 +
39 +
impl From<String> for TagRefusal {
40 +
    fn from(reason: String) -> TagRefusal {
41 +
        TagRefusal {
42 +
            reason,
43 +
            untimed: false,
44 +
        }
45 +
    }
46 +
}
47 +
48 +
impl From<&str> for TagRefusal {
49 +
    fn from(reason: &str) -> TagRefusal {
50 +
        reason.to_string().into()
51 +
    }
52 +
}
53 +
8 54
#[derive(Debug, Clone, PartialEq, Eq)]
9 55
pub struct TagInfo {
10 56
    pub name: String,
…
33 unchanged lines
44 90
        write_atomic(&self.tag_attest_path(name), r.to_hex().as_bytes())
45 91
    }
46 92
93 +
    fn tag_withdrawal_path(&self, name: &str) -> PathBuf {
94 +
        self.oxy.join("attest").join("untags").join(name_file(name))
95 +
    }
96 +
97 +
    /// The withdrawal of tag `name`, when it was withdrawn here.
98 +
    pub fn tag_withdrawal(&self, name: &str) -> Result<Option<(ObjectRef, SignedAttest)>> {
99 +
        match fs::read_to_string(self.tag_withdrawal_path(name)) {
100 +
            Ok(h) => {
101 +
                let r = ObjectRef::from_hex(h.trim())
102 +
                    .ok_or_else(|| RepoError::Msg("corrupt tag withdrawal pointer".into()))?;
103 +
                Ok(Some((
104 +
                    r,
105 +
                    SignedAttest {
106 +
                        attest: self.store.get_attest(&r)?,
107 +
                        sigs: self.store.signatures(&r)?,
108 +
                    },
109 +
                )))
110 +
            }
111 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
112 +
            Err(e) => Err(e.into()),
113 +
        }
114 +
    }
115 +
116 +
    fn set_tag_withdrawal(&self, name: &str, r: &ObjectRef) -> Result<()> {
117 +
        fs::create_dir_all(self.oxy.join("attest").join("untags"))?;
118 +
        write_atomic(&self.tag_withdrawal_path(name), r.to_hex().as_bytes())
119 +
    }
120 +
121 +
    /// Withdraw tag `name` (spec §10.1): an `untag` attestation naming its
122 +
    /// state, signed when this repository has a key, and the tag leaves the
123 +
    /// view. The next push sends the withdrawal to a remote that still has
124 +
    /// the tag, under a policy only when the key may create the tag there.
125 +
    pub fn tag_delete(&self, name: &str, reason: &str) -> Result<TagInfo> {
126 +
        let (op_ref, op) = self.head_op()?;
127 +
        let Some(state) = op.view.tags.get(name).copied() else {
128 +
            return fail(format!("no tag {name:?}"));
129 +
        };
130 +
        let heads = self.store.get_state(&state)?.heads;
131 +
        let attest = Attest {
132 +
            repo: self.id()?,
133 +
            hlc: Hlc::now(),
134 +
            body: AttestBody::Untag {
135 +
                name: name.into(),
136 +
                state,
137 +
                reason: reason.into(),
138 +
            },
139 +
        };
140 +
        let mut signer = None;
141 +
        let r = if self.secret_key()?.is_some() {
142 +
            let sa = self.sign_attest(&attest)?;
143 +
            signer = sa
144 +
                .sigs
145 +
                .first()
146 +
                .and_then(|s| self.store.get_identity(&s.key).ok())
147 +
                .map(|i| i.name);
148 +
            sa.attest.hash()
149 +
        } else {
150 +
            self.store.put_attest(&attest)?
151 +
        };
152 +
        self.set_tag_withdrawal(name, &r)?;
153 +
        let mut view = op.view.clone();
154 +
        view.tags.remove(name);
155 +
        self.record_op(&[op_ref], &format!("oxy tag delete {name}"), view)?;
156 +
        Ok(TagInfo {
157 +
            name: name.into(),
158 +
            state,
159 +
            heads,
160 +
            message: reason.into(),
161 +
            signer,
162 +
        })
163 +
    }
164 +
165 +
    /// Every tag withdrawn here, as `refs` reports them: the `untag`
166 +
    /// attestation as the attestation, with its acceptance when a
167 +
    /// short-lived key signed it.
168 +
    pub(crate) fn withdrawn_refs(&self) -> Result<Vec<crate::protocol::TagRef>> {
169 +
        let dir = self.oxy.join("attest").join("untags");
170 +
        let entries = match fs::read_dir(&dir) {
171 +
            Ok(e) => e,
172 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
173 +
            Err(e) => return Err(e.into()),
174 +
        };
175 +
        let mut out = Vec::new();
176 +
        for e in entries {
177 +
            let e = e?;
178 +
            let Ok(h) = fs::read_to_string(e.path()) else {
179 +
                continue;
180 +
            };
181 +
            let Some(r) = ObjectRef::from_hex(h.trim()) else {
182 +
                continue;
183 +
            };
184 +
            let sa = SignedAttest {
185 +
                attest: self.store.get_attest(&r)?,
186 +
                sigs: self.store.signatures(&r)?,
187 +
            };
188 +
            let AttestBody::Untag { name, state, .. } = &sa.attest.body else {
189 +
                continue;
190 +
            };
191 +
            let heads = self
192 +
                .store
193 +
                .get_state(state)
194 +
                .map(|s| s.heads)
195 +
                .unwrap_or_default();
196 +
            let accepted = self.tag_acceptance(&sa)?;
197 +
            let mut t = crate::protocol::TagRef::new(name.clone(), *state, heads, Some(sa));
198 +
            t.accepted = accepted;
199 +
            out.push(t);
200 +
        }
201 +
        out.sort_by(|a, b| a.name.cmp(&b.name));
202 +
        Ok(out)
203 +
    }
204 +
47 205
    /// Create a tag named `name` for the state of `heads` (spec §10.1).
48 206
    pub fn tag_create(&self, name: &str, heads: &[ChangeHash], message: &str) -> Result<TagInfo> {
49 207
        crate::objects::validate_name(name)
…
2 unchanged lines
52 210
        if op.view.tags.contains_key(name) {
53 211
            return fail(format!("tag {name:?} already exists; tags are immutable"));
54 212
        }
213 +
        if self.tag_withdrawal(name)?.is_some() {
214 +
            return fail(format!(
215 +
                "tag {name:?} was withdrawn; a withdrawn tag's name is not used again"
216 +
            ));
217 +
        }
55 218
        let st = self.load_state_history(heads)?;
56 219
        let heads = st.maximal(heads);
57 220
        if heads.is_empty() {
…
86 unchanged lines
144 307
                .tag_attest(&t.name)?
145 308
                .map(|(_, a)| a)
146 309
                .filter(|a| !a.sigs.is_empty());
147 -
            out.push(crate::protocol::TagRef {
148 -
                name: t.name.clone(),
149 -
                state: t.state,
150 -
                heads: t.heads,
151 -
                attestation,
152 -
            });
310 +
            // A short-lived key's tag goes with its issuer's acceptance
311 +
            // (spec §14.7), for whoever fetches it later.
312 +
            let accepted = match &attestation {
313 +
                Some(a) => self.tag_acceptance(a)?,
314 +
                None => None,
315 +
            };
316 +
            let mut tag = crate::protocol::TagRef::new(t.name, t.state, t.heads, attestation);
317 +
            tag.accepted = accepted;
318 +
            out.push(tag);
153 319
        }
154 320
        Ok(out)
155 321
    }
156 322
323 +
    /// The acceptance kept for tag attestation `sa`: its signer's
324 +
    /// certificate issuer's when one of those kept is, else the first.
325 +
    fn tag_acceptance(&self, sa: &SignedAttest) -> Result<Option<SignedAttest>> {
326 +
        let r = sa.attest.hash();
327 +
        let mut first = None;
328 +
        for a in self.store.index_get_pub("accepted", &r)? {
329 +
            let acc = SignedAttest {
330 +
                attest: self.store.get_attest(&a)?,
331 +
                sigs: self.store.signatures(&a)?,
332 +
            };
333 +
            let signer = sa.sigs.first().map(|s| s.key);
334 +
            if signer.is_some_and(|k| self.issuer_acceptance(&k, sa, Some(&acc)).is_some()) {
335 +
                return Ok(Some(acc));
336 +
            }
337 +
            first.get_or_insert(acc);
338 +
        }
339 +
        Ok(first)
340 +
    }
341 +
342 +
    /// Whether policy `p` authorizes the signer of tag `name`'s attestation
343 +
    /// `sa` without a certificate: a key its rule lists, a delegate of one,
344 +
    /// or a key one of them was rotated from (spec §14.5, §14.2). These are
345 +
    /// the only ways a client before oxygen#129 authorizes a tag.
346 +
    fn tag_signer_listed(&self, p: &crate::policy::Policy, name: &str, sa: &SignedAttest) -> bool {
347 +
        let Some(sig) = sa.sigs.first() else {
348 +
            return false;
349 +
        };
350 +
        let reference = format!("tags/{name}");
351 +
        let at = sa.attest.hlc.wall_ms;
352 +
        p.authorizes(&sig.key, &reference)
353 +
            || self.delegate_authorized(p, &sig.key, &reference, at)
354 +
            || self.predecessor_authorized(p, &sig.key, &reference, at)
355 +
    }
356 +
357 +
    /// Tailor a `refs` answer of this repository to a client that declared
358 +
    /// `features` (the [`FEATURES_HEADER`] value; `None` when it sent none).
359 +
    /// A client without [`FEATURE_CERTIFIED_TAGS`] (oxy 0.5.0 and older)
360 +
    /// refuses a tag signed by a short-lived key that only its certificate
361 +
    /// authorizes, and with it the whole clone or fetch (oxygen#131), so
362 +
    /// under a policy each tag whose signer the policy does not list, as a
363 +
    /// key, a delegate or a rotation predecessor, is left out: that client
364 +
    /// sees the repository as if the tag did not exist. A tag with no
365 +
    /// signed attestation is left as it is (every client refuses it alike),
366 +
    /// as is everything without a policy.
367 +
    ///
368 +
    /// [`FEATURES_HEADER`]: crate::protocol::FEATURES_HEADER
369 +
    /// [`FEATURE_CERTIFIED_TAGS`]: crate::protocol::FEATURE_CERTIFIED_TAGS
370 +
    pub fn refs_for_client(
371 +
        &self,
372 +
        refs: &mut crate::protocol::RefsResponse,
373 +
        features: Option<&str>,
374 +
    ) {
375 +
        if crate::protocol::declares_feature(features, crate::protocol::FEATURE_CERTIFIED_TAGS) {
376 +
            return;
377 +
        }
378 +
        let Some(sp) = refs.policy.as_ref() else {
379 +
            return;
380 +
        };
381 +
        refs.tags.retain(|t| match &t.attestation {
382 +
            Some(sa) if !sa.sigs.is_empty() => self.tag_signer_listed(&sp.policy, &t.name, sa),
383 +
            _ => true,
384 +
        });
385 +
    }
386 +
157 387
    /// Adopt a tag received from a remote or a push. Verifies the
158 388
    /// attestation when present (and requires one, by an authorized key,
159 389
    /// under a policy). An existing tag with a different state is refused.
390 +
    /// A short-lived key's tag is authorized by a subject grant, its
391 +
    /// certificate checked at the time `check` says (spec §14.7), and its
392 +
    /// issuer's acceptance is kept with it, or recorded when this host
393 +
    /// receives it.
160 394
    pub(crate) fn adopt_tag(
161 395
        &self,
162 396
        view: &mut View,
163 397
        t: &crate::protocol::TagRef,
164 398
        policy: Option<&crate::policy::Policy>,
165 -
    ) -> std::result::Result<(), String> {
399 +
        check: &mut TagCheck,
400 +
    ) -> std::result::Result<(), TagRefusal> {
401 +
        if self
402 +
            .tag_withdrawal(&t.name)
403 +
            .map_err(|e| e.to_string())?
404 +
            .is_some()
405 +
        {
406 +
            return Err(format!(
407 +
                "tag {:?} was withdrawn here; a withdrawn tag's name is not used again",
408 +
                t.name
409 +
            )
410 +
            .into());
411 +
        }
166 412
        if let Some(existing) = view.tags.get(&t.name) {
167 413
            return if *existing == t.state {
168 414
                Ok(())
…
1 unchanged lines
170 416
                Err(format!(
171 417
                    "tag {:?} already exists with a different state; tags are immutable",
172 418
                    t.name
173 -
                ))
419 +
                )
420 +
                .into())
174 421
            };
175 422
        }
176 423
        let state = self
…
6 unchanged lines
183 430
                    "tag {:?} names change {}, which is missing",
184 431
                    t.name,
185 432
                    h.short(12)
186 -
                ));
433 +
                )
434 +
                .into());
187 435
            }
188 436
        }
189 437
        if let Some(sa) = &t.attestation {
…
19 unchanged lines
209 457
                .get_identity(&sig.key)
210 458
                .map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
211 459
            verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?;
460 +
            let certified = self.store.certificate_of(&sig.key).ok().flatten().is_some();
461 +
            // The issuer's acceptance this tag came with, when it is one.
462 +
            let accepted = self.issuer_acceptance(&sig.key, sa, t.accepted.as_ref());
212 463
            if let Some(p) = policy {
213 -
                let reference = format!("tags/{}", t.name);
214 -
                let at = sa.attest.hlc.wall_ms;
215 -
                if !p.authorizes(&sig.key, &reference)
216 -
                    && !self.delegate_authorized(p, &sig.key, &reference, at)
217 -
                    && !self.predecessor_authorized(p, &sig.key, &reference, at)
464 +
                let listed = self.tag_signer_listed(p, &t.name, sa);
465 +
                if !listed
466 +
                    && !(certified
467 +
                        && self.tag_certified_in_time(p, t, sa, &sig.key, accepted, check)?)
218 468
                {
219 469
                    return Err(format!(
220 470
                        "key {} may not create tags/{} under this repository's policy",
221 471
                        sig.key.short(12),
222 472
                        t.name
223 -
                    ));
473 +
                    )
474 +
                    .into());
224 475
                }
225 476
            }
226 477
            let stored = self
…
5 unchanged lines
232 483
                    .put_signature(&stored, s)
233 484
                    .map_err(|e| e.to_string())?;
234 485
            }
486 +
            if certified {
487 +
                match (&t.accepted, accepted) {
488 +
                    (Some(acc), Some(_)) => self.keep_acceptance(&stored, acc),
489 +
                    // Received here: this host says when, as it does for a
490 +
                    // bookmark's attestation.
491 +
                    _ if matches!(check, TagCheck::Received) => self.record_acceptance(&stored),
492 +
                    _ => Ok(()),
493 +
                }
494 +
                .map_err(|e| e.to_string())?;
495 +
            }
235 496
            self.set_tag_attest(&t.name, &stored)
236 497
                .map_err(|e| e.to_string())?;
237 498
        } else if policy.is_some() {
238 499
            return Err(format!(
239 500
                "tag {:?} needs a signed attestation under this repository's policy",
240 501
                t.name
241 -
            ));
502 +
            )
503 +
            .into());
242 504
        }
243 505
        view.tags.insert(t.name.clone(), t.state);
244 506
        Ok(())
245 507
    }
508 +
509 +
    /// Adopt the withdrawal of tag `t` (its `attestation` the `untag`
510 +
    /// attestation), received in a push or fetched: the tag must be here
511 +
    /// with the state the withdrawal names, and under a policy its signer
512 +
    /// must be one that may create the tag, a short-lived key at the time
513 +
    /// `check` says (as for creating it, except that a fetch checks at the
514 +
    /// issuer's acceptance only). The tag leaves `view` and the withdrawal
515 +
    /// is kept. Whether the tag was here and is now withdrawn: a withdrawal
516 +
    /// already kept is `false`.
517 +
    pub(crate) fn adopt_withdrawal(
518 +
        &self,
519 +
        view: &mut View,
520 +
        t: &crate::protocol::TagRef,
521 +
        policy: Option<&crate::policy::Policy>,
522 +
        check: &mut TagCheck,
523 +
    ) -> std::result::Result<bool, TagRefusal> {
524 +
        let sa = t
525 +
            .attestation
526 +
            .as_ref()
527 +
            .ok_or("a tag withdrawal carries no attestation")?;
528 +
        let AttestBody::Untag { name, state, .. } = &sa.attest.body else {
529 +
            return Err("tag withdrawal has the wrong kind".into());
530 +
        };
531 +
        if *name != t.name || *state != t.state {
532 +
            return Err("tag withdrawal does not describe this tag".into());
533 +
        }
534 +
        if sa.attest.repo != self.id().map_err(|e| e.to_string())? {
535 +
            return Err("tag withdrawal is for a different repository".into());
536 +
        }
537 +
        if self
538 +
            .tag_withdrawal(&t.name)
539 +
            .map_err(|e| e.to_string())?
540 +
            .is_some()
541 +
        {
542 +
            return Ok(false);
543 +
        }
544 +
        match view.tags.get(&t.name) {
545 +
            None => return Err(format!("there is no tag {:?} here", t.name).into()),
546 +
            Some(st) if *st != t.state => {
547 +
                return Err(format!(
548 +
                    "the withdrawal names another state of tag {:?} than this repository's",
549 +
                    t.name
550 +
                )
551 +
                .into())
552 +
            }
553 +
            Some(_) => {}
554 +
        }
555 +
        let r = sa.attest.hash();
556 +
        let mut certified = false;
557 +
        if let Some(sig) = sa.sigs.first() {
558 +
            let id = self
559 +
                .store
560 +
                .get_identity(&sig.key)
561 +
                .map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
562 +
            verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?;
563 +
            certified = self.store.certificate_of(&sig.key).ok().flatten().is_some();
564 +
            if let Some(p) = policy {
565 +
                let accepted = self.issuer_acceptance(&sig.key, sa, t.accepted.as_ref());
566 +
                let reference = format!("tags/{}", t.name);
567 +
                let in_time =
568 +
                    |ms: u64| self.certified_authorized(p, &sig.key, &reference, Scope::REFS, ms);
569 +
                let allowed = self.tag_signer_listed(p, &t.name, sa)
570 +
                    || (certified
571 +
                        && match check {
572 +
                            TagCheck::Received => {
573 +
                                in_time(Hlc::now().wall_ms) || accepted.is_some_and(in_time)
574 +
                            }
575 +
                            TagCheck::Mirrored => {
576 +
                                in_time(accepted.unwrap_or(sa.attest.hlc.wall_ms))
577 +
                            }
578 +
                            TagCheck::Fetched { remote, .. } => match accepted {
579 +
                                Some(ms) => in_time(ms),
580 +
                                None => {
581 +
                                    return Err(TagRefusal {
582 +
                                        reason: format!(
583 +
                                            "withdrawn by short-lived key {}, and {remote} gave no acceptance to check its certificate at",
584 +
                                            sig.key.short(12)
585 +
                                        ),
586 +
                                        untimed: true,
587 +
                                    })
588 +
                                }
589 +
                            },
590 +
                        });
591 +
                if !allowed {
592 +
                    return Err(format!(
593 +
                        "key {} may not withdraw tags/{} under this repository's policy",
594 +
                        sig.key.short(12),
595 +
                        t.name
596 +
                    )
597 +
                    .into());
598 +
                }
599 +
            }
600 +
        } else if policy.is_some() {
601 +
            return Err(format!(
602 +
                "withdrawing tag {:?} needs a signed attestation under this repository's policy",
603 +
                t.name
604 +
            )
605 +
            .into());
606 +
        }
607 +
        let stored = self
608 +
            .store
609 +
            .put_attest(&sa.attest)
610 +
            .map_err(|e| e.to_string())?;
611 +
        for s in &sa.sigs {
612 +
            self.store
613 +
                .put_signature(&stored, s)
614 +
                .map_err(|e| e.to_string())?;
615 +
        }
616 +
        if certified {
617 +
            match &t.accepted {
618 +
                Some(acc)
619 +
                    if sa.sigs.first().is_some_and(|s| {
620 +
                        self.issuer_acceptance(&s.key, sa, Some(acc)).is_some()
621 +
                    }) =>
622 +
                {
623 +
                    self.keep_acceptance(&stored, acc)
624 +
                }
625 +
                _ if matches!(check, TagCheck::Received) => self.record_acceptance(&stored),
626 +
                _ => Ok(()),
627 +
            }
628 +
            .map_err(|e| e.to_string())?;
629 +
        }
630 +
        self.set_tag_withdrawal(&t.name, &stored)
631 +
            .map_err(|e| e.to_string())?;
632 +
        view.tags.remove(&t.name);
633 +
        Ok(true)
634 +
    }
635 +
636 +
    /// Whether short-lived key `signer`'s certificate gives it tag `t`
637 +
    /// under policy `p` at the time `check` says (spec §14.7, §14.8):
638 +
    /// `accepted` is the issuer's acceptance time the tag came with.
639 +
    fn tag_certified_in_time(
640 +
        &self,
641 +
        p: &crate::policy::Policy,
642 +
        t: &crate::protocol::TagRef,
643 +
        sa: &SignedAttest,
644 +
        signer: &KeyId,
645 +
        accepted: Option<u64>,
646 +
        check: &mut TagCheck,
647 +
    ) -> std::result::Result<bool, TagRefusal> {
648 +
        use super::transparency::{judge, Timed};
649 +
        let reference = format!("tags/{}", t.name);
650 +
        let certified = |ms: u64| self.certified_authorized(p, signer, &reference, Scope::REFS, ms);
651 +
        Ok(match check {
652 +
            TagCheck::Received => certified(Hlc::now().wall_ms) || accepted.is_some_and(certified),
653 +
            TagCheck::Mirrored => certified(accepted.unwrap_or(sa.attest.hlc.wall_ms)),
654 +
            TagCheck::Fetched { remote, log, notes } => {
655 +
                let time = log.tag_time_of(&t.name);
656 +
                let integrated = time.as_ref().ok().map(|i| i.ms());
657 +
                if integrated.is_none() && accepted.is_none() {
658 +
                    return Err(TagRefusal {
659 +
                        reason: format!(
660 +
                            "signed by short-lived key {}, and {remote} gave no time to check its certificate at (neither its issuer's acceptance nor a witnessed log time: {}); a tag is never checked at the time it claims",
661 +
                            signer.short(12),
662 +
                            time.err().unwrap_or_default()
663 +
                        ),
664 +
                        untimed: true,
665 +
                    });
666 +
                }
667 +
                let what = format!("{remote} tag {}", t.name);
668 +
                match judge(integrated, certified, || accepted.is_some_and(certified)) {
669 +
                    Timed::Refused => false,
670 +
                    Timed::Integrated => true,
671 +
                    Timed::Disagrees => {
672 +
                        notes.push(format!(
673 +
                            "{what}: {}'s certificate had lapsed when witness {} first saw its attestation, though not at the issuer's acceptance; accepted while transparency logs are new",
674 +
                            signer.short(12),
675 +
                            time.as_ref().map(|i| i.witness.as_str()).unwrap_or("?")
676 +
                        ));
677 +
                        true
678 +
                    }
679 +
                    Timed::Fallback => {
680 +
                        if log.kept {
681 +
                            notes.push(format!(
682 +
                                "{what}: {} checked at the issuer's acceptance, not at a witnessed time: {}",
683 +
                                signer.short(12),
684 +
                                time.err().unwrap_or_default()
685 +
                            ));
686 +
                        }
687 +
                        true
688 +
                    }
689 +
                }
690 +
            }
691 +
        })
692 +
    }
246 693
}
modified crates/oxy-core/src/repo/tests.rs +1348 −8
…
234 unchanged lines
235 235
    assert!(!repo.load_state(&[h]).unwrap().materialize().is_conflicted());
236 236
}
237 237
238 +
/// oxygen#121: two changes each adding a line after the same line conflict;
239 +
/// the markers list the earlier change first, and `oxy resolve --both`
240 +
/// keeps both lines in that order (or the other with `--reverse`).
241 +
#[test]
242 +
fn adjacent_insertions_are_settled_by_keeping_both() {
243 +
    hermetic();
244 +
    for reverse in [false, true] {
245 +
        let dir = tempfile::tempdir().unwrap();
246 +
        let root = dir.path();
247 +
        let repo = Repo::init(root).unwrap();
248 +
        write(root, "f.txt", b"a\nb\n");
249 +
        let h1 = repo.commit("base", &[]).unwrap();
250 +
        write(root, "f.txt", b"a\nfirst\nb\n");
251 +
        repo.commit("first", &[]).unwrap();
252 +
        repo.checkout(&[h1], false, "checkout").unwrap();
253 +
        write(root, "f.txt", b"a\nsecond\nb\n");
254 +
        repo.commit("second", &[]).unwrap();
255 +
        repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
256 +
            .unwrap();
257 +
        let marked = fs::read_to_string(root.join("f.txt")).unwrap();
258 +
        let (f, s) = (
259 +
            marked.find("first\n").unwrap(),
260 +
            marked.find("second\n").unwrap(),
261 +
        );
262 +
        assert!(f < s, "the earlier change's side comes first: {marked}");
263 +
264 +
        let snap = repo.snapshot("status").unwrap();
265 +
        let node = snap.base_snapshot.files["f.txt"].node;
266 +
        assert_eq!(snap.base.both_sides(&node).iter().flatten().count(), 1);
267 +
        let kept = repo.keep_both(&snap, &[], reverse).unwrap();
268 +
        assert_eq!(
269 +
            kept,
270 +
            vec![KeptBoth {
271 +
                path: "f.txt".into(),
272 +
                settled: vec![1],
273 +
                identical: vec![],
274 +
                left: vec![]
275 +
            }]
276 +
        );
277 +
        let want: &[u8] = if reverse {
278 +
            b"a\nsecond\nfirst\nb\n"
279 +
        } else {
280 +
            b"a\nfirst\nsecond\nb\n"
281 +
        };
282 +
        assert_eq!(fs::read(root.join("f.txt")).unwrap(), want);
283 +
        let s = repo.snapshot("status").unwrap();
284 +
        assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::Resolved)]);
285 +
        let h = repo.commit("both", &[]).unwrap();
286 +
        assert_eq!(
287 +
            repo.store().get_change(&h).unwrap().kind,
288 +
            ChangeKind::Resolution
289 +
        );
290 +
        assert!(!repo.load_state(&[h]).unwrap().materialize().is_conflicted());
291 +
    }
292 +
}
293 +
294 +
/// oxygen#145: a file marked `merge=union` has its adjacent insertions
295 +
/// settled by the checkout itself, both kept, earliest first; the next
296 +
/// commit records the resolution. Other files keep their markers.
297 +
#[test]
298 +
fn union_files_keep_both_sides_on_checkout() {
299 +
    hermetic();
300 +
    let dir = tempfile::tempdir().unwrap();
301 +
    let root = dir.path();
302 +
    let repo = Repo::init(root).unwrap();
303 +
    write(root, ".oxyattributes", b"CHANGELOG.md merge=union\n");
304 +
    write(
305 +
        root,
306 +
        "CHANGELOG.md",
307 +
        b"# Changelog\n## Unreleased\n## 1.0\n",
308 +
    );
309 +
    write(root, "f.txt", b"a\nb\n");
310 +
    let h1 = repo.commit("base", &[]).unwrap();
311 +
    write(
312 +
        root,
313 +
        "CHANGELOG.md",
314 +
        b"# Changelog\n## Unreleased\n- one\n## 1.0\n",
315 +
    );
316 +
    write(root, "f.txt", b"a\none\nb\n");
317 +
    repo.commit("one", &[]).unwrap();
318 +
    repo.checkout(&[h1], false, "checkout").unwrap();
319 +
    write(
320 +
        root,
321 +
        "CHANGELOG.md",
322 +
        b"# Changelog\n## Unreleased\n- two\n## 1.0\n",
323 +
    );
324 +
    write(root, "f.txt", b"a\ntwo\nb\n");
325 +
    repo.commit("two", &[]).unwrap();
326 +
    let r = repo
327 +
        .checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
328 +
        .unwrap();
329 +
    assert!(r.conflicted);
330 +
    assert_eq!(r.kept_both.len(), 1, "{:?}", r.kept_both);
331 +
    assert_eq!(r.kept_both[0].path, "CHANGELOG.md");
332 +
    assert_eq!(
333 +
        fs::read_to_string(root.join("CHANGELOG.md")).unwrap(),
334 +
        "# Changelog\n## Unreleased\n- one\n- two\n## 1.0\n"
335 +
    );
336 +
    assert!(has_conflict_markers(&fs::read(root.join("f.txt")).unwrap()));
337 +
    let s = repo.snapshot("status").unwrap();
338 +
    assert_eq!(
339 +
        statuses(&s),
340 +
        vec![("CHANGELOG.md".into(), ChangeStatus::Resolved)]
341 +
    );
342 +
}
343 +
344 +
/// oxygen#146: two bookmarks that resolved the same conflict to the same
345 +
/// text agree. Merged, their resolutions are a conflict whose sides are
346 +
/// identical: a checkout keeps one, in any file, and a host settles it so
347 +
/// too; the next commit records it.
238 348
#[test]
349 +
fn identical_resolutions_on_two_bookmarks_settle_by_themselves() {
350 +
    hermetic();
351 +
    let dir = tempfile::tempdir().unwrap();
352 +
    let root = dir.path();
353 +
    let repo = Repo::init(root).unwrap();
354 +
    write(root, "f.txt", b"a\nx\nb\n");
355 +
    let base = repo.commit("base", &[]).unwrap();
356 +
    write(root, "f.txt", b"a\nX1\nb\n");
357 +
    let c1 = repo.commit("X1", &[]).unwrap();
358 +
    repo.checkout(&[base], false, "checkout").unwrap();
359 +
    write(root, "f.txt", b"a\nX2\nb\n");
360 +
    let c2 = repo.commit("X2", &[]).unwrap();
361 +
    // Each bookmark merges both and resolves the conflict the same way.
362 +
    repo.checkout(&[c1, c2], false, "checkout").unwrap();
363 +
    assert!(has_conflict_markers(&fs::read(root.join("f.txt")).unwrap()));
364 +
    write(root, "f.txt", b"a\nX\nb\n");
365 +
    let r1 = repo.commit("resolve on one", &[]).unwrap();
366 +
    repo.checkout(&[c1, c2], false, "checkout").unwrap();
367 +
    write(root, "f.txt", b"a\nX\nb\n");
368 +
    let r2 = repo.commit("resolve on two", &[]).unwrap();
369 +
    assert_ne!(r1, r2);
370 +
371 +
    let st = repo.load_state(&[r1, r2]).unwrap();
372 +
    assert!(
373 +
        st.materialize().is_conflicted(),
374 +
        "two resolutions, one place"
375 +
    );
376 +
    assert_eq!(
377 +
        Repo::union_resolution(&st, &st.materialize()),
378 +
        Some(vec![("f.txt".into(), b"a\nX\nb\n".to_vec())]),
379 +
        "a host settles it"
380 +
    );
381 +
    let r = repo.checkout(&[r1, r2], false, "checkout").unwrap();
382 +
    assert!(r.conflicted);
383 +
    assert_eq!(r.kept_both.len(), 1, "{:?}", r.kept_both);
384 +
    assert_eq!(
385 +
        (r.kept_both[0].path.as_str(), &r.kept_both[0].identical),
386 +
        ("f.txt", &vec![1])
387 +
    );
388 +
    assert_eq!(fs::read_to_string(root.join("f.txt")).unwrap(), "a\nX\nb\n");
389 +
    let s = repo.snapshot("status").unwrap();
390 +
    assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::Resolved)]);
391 +
    let merged = repo.commit("merge", &[]).unwrap();
392 +
    let st = repo.load_state(&[merged]).unwrap();
393 +
    assert!(!st.materialize().is_conflicted());
394 +
}
395 +
396 +
/// oxygen#145: a host (OxygenHub landing a proposal) settles a state whose
397 +
/// only conflicts are adjacent insertions in union files; any other
398 +
/// conflict needs a person.
399 +
#[test]
400 +
fn a_host_settles_a_state_whose_conflicts_are_all_union() {
401 +
    hermetic();
402 +
    let dir = tempfile::tempdir().unwrap();
403 +
    let root = dir.path();
404 +
    let repo = Repo::init(root).unwrap();
405 +
    write(root, ".oxyattributes", b"*.md merge=union\n");
406 +
    write(root, "A.md", b"a\nb\n");
407 +
    write(root, "B.md", b"x\ny\n");
408 +
    let h1 = repo.commit("base", &[]).unwrap();
409 +
    write(root, "A.md", b"a\none\nb\n");
410 +
    write(root, "B.md", b"x\nY1\n");
411 +
    let h2 = repo.commit("one", &[]).unwrap();
412 +
    repo.checkout(&[h1], false, "checkout").unwrap();
413 +
    write(root, "A.md", b"a\ntwo\nb\n");
414 +
    let h3 = repo.commit("two", &[]).unwrap();
415 +
    let st = repo.load_state(&[h2, h3]).unwrap();
416 +
    assert_eq!(
417 +
        Repo::union_resolution(&st, &st.materialize()),
418 +
        Some(vec![("A.md".into(), b"a\none\ntwo\nb\n".to_vec())])
419 +
    );
420 +
    // B.md's line replaced two ways: a person decides.
421 +
    repo.checkout(&[h3], false, "checkout").unwrap();
422 +
    write(root, "B.md", b"x\nY2\n");
423 +
    let h4 = repo.commit("Y2", &[]).unwrap();
424 +
    let st = repo.load_state(&[h2, h4]).unwrap();
425 +
    assert!(st.materialize().is_conflicted());
426 +
    assert_eq!(Repo::union_resolution(&st, &st.materialize()), None);
427 +
}
428 +
429 +
/// oxygen#121: two changes that each replaced the same line are not an
430 +
/// adjacent insertion: keeping both would keep two versions of the line,
431 +
/// so `--both` leaves the markers, as it does for markers already edited.
432 +
#[test]
433 +
fn keeping_both_refuses_replaced_or_edited_lines() {
434 +
    hermetic();
435 +
    let (dir, repo, _, _, _) = conflicted_repo();
436 +
    let root = dir.path();
437 +
    repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
438 +
        .unwrap();
439 +
    let marked = fs::read(root.join("f.txt")).unwrap();
440 +
    let snap = repo.snapshot("status").unwrap();
441 +
    let node = snap.base_snapshot.files["f.txt"].node;
442 +
    assert_eq!(snap.base.both_sides(&node), vec![None]);
443 +
    let kept = repo.keep_both(&snap, &["f.txt".into()], false).unwrap();
444 +
    assert!(kept[0].settled.is_empty());
445 +
    assert_eq!(kept[0].left[0].0, 1);
446 +
    assert!(kept[0].left[0].1.contains("both sides changed"), "{kept:?}");
447 +
    assert_eq!(fs::read(root.join("f.txt")).unwrap(), marked);
448 +
449 +
    // An adjacent insertion whose lines were edited between the markers.
450 +
    let sides = vec![Some(vec![b"x\n".to_vec(), b"y\n".to_vec()])];
451 +
    let text = b"a\n<<<<<<< oxy conflict 1 of 1, side 1 of 2: change k \"x\"\nx!\n======= oxy conflict 1 of 1, side 2 of 2: change q \"y\"\ny\n>>>>>>> oxy conflict 1 of 1 end\nb\n";
452 +
    let (out, settled, left) = super::snapshot::keep_both_text(text, &sides, false);
453 +
    assert_eq!(out, text.to_vec());
454 +
    assert!(settled.is_empty());
455 +
    assert!(left[0].1.contains("edited"), "{left:?}");
456 +
    // Unedited, in either order on disk: kept in the graph's order.
457 +
    let text = b"a\n<<<<<<< oxy conflict 1 of 1, side 1 of 2: change q \"y\"\ny\n======= oxy conflict 1 of 1, side 2 of 2: change k \"x\"\nx\n>>>>>>> oxy conflict 1 of 1 end\nb\n";
458 +
    let (out, settled, _) = super::snapshot::keep_both_text(text, &sides, false);
459 +
    assert_eq!(out, b"a\nx\ny\nb\n".to_vec());
460 +
    assert_eq!(settled, vec![1]);
461 +
}
462 +
463 +
#[test]
239 464
fn concurrent_edits_conflict_and_can_be_resolved() {
240 465
    hermetic();
241 466
    let (dir, repo, h1, h2, h3) = conflicted_repo();
…
619 unchanged lines
861 1086
            conflicted: false,
862 1087
            notes: vec![],
863 1088
            lacks: 1,
1089 +
            kept_both: vec![],
864 1090
        }
865 1091
    );
866 1092
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n");
…
764 unchanged lines
1631 1857
        .set_user_config("author.signer", &second.to_hex())
1632 1858
        .unwrap();
1633 1859
    let host = Repo::open(&root.join("origin")).unwrap();
1634 -
    assert!(host.store().get_identity(&first).is_err(), "the host has never seen the tag's key");
1860 +
    assert!(
1861 +
        host.store().get_identity(&first).is_err(),
1862 +
        "the host has never seen the tag's key"
1863 +
    );
1635 1864
    let r = agent.push("origin", false).unwrap();
1636 1865
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1637 1866
    let host = Repo::open(&root.join("origin")).unwrap();
1638 -
    assert!(host.tag_refs().unwrap().iter().any(|t| t.name == "v1"), "the tag arrived");
1867 +
    assert!(
1868 +
        host.tag_refs().unwrap().iter().any(|t| t.name == "v1"),
1869 +
        "the tag arrived"
1870 +
    );
1871 +
}
1872 +
1873 +
/// oxygen#129: a short-lived key's tag is checked on a fetch at a time its
1874 +
/// signer did not choose, its issuer's acceptance, never at the time it
1875 +
/// claims nor at the fetch's (when the ten-minute certificate has long
1876 +
/// expired). Without an acceptance it is left out and said so; with one
1877 +
/// inside the certificate's window it is fetched however much later, and
1878 +
/// a host it is pushed on to keeps and serves the acceptance.
1879 +
#[test]
1880 +
fn a_short_lived_key_s_tag_is_fetched_at_its_issuer_s_acceptance() {
1881 +
    use crate::cert::{Certificate, SignedCertificate};
1882 +
    use crate::identity::Scope;
1883 +
    hermetic();
1884 +
    let dir = tempfile::tempdir().unwrap();
1885 +
    let root = dir.path();
1886 +
    let _origin = Repo::init(&root.join("origin")).unwrap();
1887 +
    let url = root.join("origin").to_string_lossy().to_string();
1888 +
    let mut a = Repo::clone(&url, &root.join("a"))
1889 +
        .unwrap()
1890 +
        .with_home(root.join("home-a"));
1891 +
    a.create_identity("Ada", "[email protected]", IdentityScope::User)
1892 +
        .unwrap();
1893 +
    write(&root.join("a"), "a.txt", b"a\n");
1894 +
    a.commit("a", &[]).unwrap();
1895 +
    a.policy_init().unwrap();
1896 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1897 +
    let hub_sk = SecretKey::generate();
1898 +
    let hub = hub_sk.identity("hub", "[email protected]", 1);
1899 +
    a.policy_grant_subject(
1900 +
        "agent:claude@ada",
1901 +
        hub.clone(),
1902 +
        Scope::CHANGES | Scope::REFS,
1903 +
        Some("*"),
1904 +
    )
1905 +
    .unwrap();
1906 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1907 +
1908 +
    let now = Hlc::now().wall_ms;
1909 +
    // An agent whose key the hub certified from `from` to `to`.
1910 +
    let agent = |name: &str, from: u64, to: u64| {
1911 +
        let mut r = Repo::clone(&url, &root.join(name))
1912 +
            .unwrap()
1913 +
            .with_home(root.join(format!("home-{name}")));
1914 +
        r.store().put_identity(&hub).unwrap();
1915 +
        let (_, id) = r
1916 +
            .create_identity(name, "[email protected]", IdentityScope::User)
1917 +
            .unwrap();
1918 +
        let cert = SignedCertificate::sign(
1919 +
            Certificate {
1920 +
                key: id,
1921 +
                issuer: hub.hash(),
1922 +
                subject: "agent:claude@ada".into(),
1923 +
                claims: vec![],
1924 +
                scope: Scope::CHANGES | Scope::REFS,
1925 +
                not_before: from,
1926 +
                expires: to,
1927 +
                grant: None,
1928 +
            },
1929 +
            &hub,
1930 +
            &hub_sk,
1931 +
        );
1932 +
        r.store().put_certificate(&cert).unwrap();
1933 +
        r
1934 +
    };
1935 +
1936 +
    // A tag by a key certified now, pushed to a host that records no
1937 +
    // acceptance (a path on disk): it lands, but a fetch has no time to
1938 +
    // check its certificate at, so it is left out and the fetch goes on.
1939 +
    let claude = agent("claude", now - 60_000, now + 600_000);
1940 +
    let heads = claude.bookmark_heads().unwrap();
1941 +
    claude.tag_create("v1", &heads, "one").unwrap();
1942 +
    let r = claude.push("origin", false).unwrap();
1943 +
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1944 +
    let b = Repo::clone(&url, &root.join("b"))
1945 +
        .unwrap()
1946 +
        .with_home(root.join("home-b"));
1947 +
    let rep = b.fetch("origin").unwrap();
1948 +
    assert!(
1949 +
        rep.skipped
1950 +
            .iter()
1951 +
            .any(|(n, why)| n == "tags/v1" && why.contains("no time to check its certificate at")),
1952 +
        "{rep:?}"
1953 +
    );
1954 +
    assert_eq!(b.tag_heads("v1").unwrap(), None);
1955 +
1956 +
    // The host's acceptance, signed by the certificate's issuer, served
1957 +
    // with the tag: fetched.
1958 +
    let host = Repo::open(&root.join("origin"))
1959 +
        .unwrap()
1960 +
        .with_acceptor(hub.clone(), hub_sk.clone());
1961 +
    let (v1, _) = host.tag_attest("v1").unwrap().unwrap();
1962 +
    host.record_acceptance(&v1).unwrap();
1963 +
    let served = host.tag_refs().unwrap();
1964 +
    assert!(served
1965 +
        .iter()
1966 +
        .any(|t| t.name == "v1" && t.accepted.is_some()));
1967 +
    let rep = b.fetch("origin").unwrap();
1968 +
    assert!(rep.skipped.is_empty(), "{rep:?}");
1969 +
    assert_eq!(b.tag_heads("v1").unwrap(), Some(heads.clone()));
1970 +
    // gc keeps the acceptance with the tag.
1971 +
    host.gc(true).unwrap();
1972 +
    assert!(host.acceptance_of(&v1).unwrap().is_some());
1973 +
1974 +
    // Long after: a key whose certificate expired an hour ago may not
1975 +
    // create a tag now.
1976 +
    let late = agent("late", now - 7_200_000, now - 3_600_000);
1977 +
    late.tag_create("v2", &heads, "two").unwrap();
1978 +
    let r = late.push("origin", false).unwrap();
1979 +
    assert!(
1980 +
        r.iter()
1981 +
            .any(|x| x.name == "tags/v2" && !x.ok && x.reason.contains("may not create")),
1982 +
        "{r:?}"
1983 +
    );
1984 +
    // With its issuer's acceptance from inside the window (as a hub gave
1985 +
    // it then, passed on to another host), it is taken at that time: by
1986 +
    // the host it is pushed to, which keeps it, and by a fresh clone.
1987 +
    let (v2, _) = late.tag_attest("v2").unwrap().unwrap();
1988 +
    let acc = Attest {
1989 +
        repo: late.id().unwrap(),
1990 +
        hlc: Hlc {
1991 +
            wall_ms: now - 5_400_000,
1992 +
            logical: 0,
1993 +
        },
1994 +
        body: AttestBody::Accepted { attest: v2 },
1995 +
    };
1996 +
    let sig = hub_sk.sign(Domain::Attest, &hub.hash(), &acc.hash());
1997 +
    // One by any other key gives no time.
1998 +
    let stranger_sk = SecretKey::generate();
1999 +
    let stranger = stranger_sk.identity("stranger", "[email protected]", 1);
2000 +
    let forged = stranger_sk.sign(Domain::Attest, &stranger.hash(), &acc.hash());
2001 +
    late.keep_acceptance(
2002 +
        &v2,
2003 +
        &SignedAttest {
2004 +
            attest: acc.clone(),
2005 +
            sigs: vec![forged],
2006 +
        },
2007 +
    )
2008 +
    .unwrap();
2009 +
    let r = late.push("origin", false).unwrap();
2010 +
    assert!(
2011 +
        r.iter().any(|x| x.name == "tags/v2" && !x.ok),
2012 +
        "not the issuer's: {r:?}"
2013 +
    );
2014 +
    let theirs = SignedAttest {
2015 +
        attest: acc,
2016 +
        sigs: vec![sig],
2017 +
    };
2018 +
    // Kept beside the stranger's: the issuer's is the one sent.
2019 +
    late.keep_acceptance(&v2, &theirs).unwrap();
2020 +
    let r = late.push("origin", false).unwrap();
2021 +
    // (Its re-attestation of main is refused: an expired key moves no
2022 +
    // bookmark.)
2023 +
    assert!(r.iter().any(|x| x.name == "tags/v2" && x.ok), "{r:?}");
2024 +
    let host = Repo::open(&root.join("origin")).unwrap();
2025 +
    let (hv2, _) = host.tag_attest("v2").unwrap().unwrap();
2026 +
    assert_eq!(hv2, v2);
2027 +
    assert!(host
2028 +
        .store()
2029 +
        .index_get_pub("accepted", &v2)
2030 +
        .unwrap()
2031 +
        .contains(&theirs.attest.hash()));
2032 +
    let c = Repo::clone(&url, &root.join("c"))
2033 +
        .unwrap()
2034 +
        .with_home(root.join("home-c"));
2035 +
    assert_eq!(c.tag_heads("v2").unwrap(), Some(heads.clone()));
2036 +
    assert_eq!(c.tag_heads("v1").unwrap(), Some(heads));
1639 2037
}
1640 2038
1641 2039
/// oxygenhub#266: a short-lived key its issuer certified moves a bookmark
…
364 unchanged lines
2006 2404
/// subject the author granted, seals changes in the author's name; with
2007 2405
/// no grant, a stranger's, or one that had ended, it is neither the
2008 2406
/// author nor a delegate.
2407 +
/// oxygen#135: an owner renews or rotates with every delegation, and a
2408 +
/// repository may name the newest key as the author; the agent's grant by
2409 +
/// the earlier key still counts, but not one that key made after.
2410 +
#[test]
2411 +
fn a_grant_outlives_its_owner_s_rotation() {
2412 +
    use crate::cert::{Certificate, Grant, SignedCertificate, SignedGrant};
2413 +
    use crate::identity::Scope;
2414 +
    hermetic();
2415 +
    let dir = tempfile::tempdir().unwrap();
2416 +
    let root = dir.path();
2417 +
    let mut a = Repo::init(&root.join("a"))
2418 +
        .unwrap()
2419 +
        .with_home(root.join("home-a"));
2420 +
    let (old, _) = a
2421 +
        .create_identity("Ada", "[email protected]", IdentityScope::User)
2422 +
        .unwrap();
2423 +
    let (_, old_sk) = a.secret_key().unwrap().unwrap();
2424 +
    let old_id = a.identity(&old).unwrap();
2425 +
    let hub_sk = SecretKey::generate();
2426 +
    let hub = hub_sk.identity("hub", "[email protected]", 1);
2427 +
    let now = Hlc::now().wall_ms;
2428 +
    let grant = |from: u64| {
2429 +
        SignedGrant::sign(
2430 +
            Grant {
2431 +
                grantor: old,
2432 +
                subject: "agent:claude@ada".into(),
2433 +
                issuer: hub.hash(),
2434 +
                scope: Scope::CHANGES | Scope::SEAL,
2435 +
                not_before: from,
2436 +
                expires: now + 86_400_000,
2437 +
            },
2438 +
            &old_id,
2439 +
            &old_sk,
2440 +
        )
2441 +
    };
2442 +
    let before = grant(now - 60_000);
2443 +
    let after = grant(now + 60_000);
2444 +
    let rot = a.renew_identity(None).unwrap();
2445 +
    assert_ne!(rot.new, old);
2446 +
    let agent = |name: &str, g: SignedGrant| {
2447 +
        let mut r = Repo::init(&root.join(name))
2448 +
            .unwrap()
2449 +
            .with_home(root.join(format!("home-{name}")));
2450 +
        let (key, id) = r
2451 +
            .create_identity(name, "[email protected]", IdentityScope::User)
2452 +
            .unwrap();
2453 +
        for i in [&old_id, &rot.identity, &hub] {
2454 +
            r.store().put_identity(i).unwrap();
2455 +
        }
2456 +
        r.set_user_config("author.key", &rot.new.to_hex()).unwrap();
2457 +
        r.set_user_config("author.signer", &key.to_hex()).unwrap();
2458 +
        let cert = SignedCertificate::sign(
2459 +
            Certificate {
2460 +
                key: id,
2461 +
                issuer: hub.hash(),
2462 +
                subject: "agent:claude@ada".into(),
2463 +
                claims: vec![],
2464 +
                scope: Scope::CHANGES | Scope::REFS | Scope::SEAL,
2465 +
                not_before: now - 60_000,
2466 +
                expires: now + 600_000,
2467 +
                grant: Some(g),
2468 +
            },
2469 +
            &hub,
2470 +
            &hub_sk,
2471 +
        );
2472 +
        r.store().put_certificate(&cert).unwrap();
2473 +
        write(&root.join(name), "work.txt", b"agent work\n");
2474 +
        let h = r.commit("work", &[]).unwrap();
2475 +
        assert_eq!(r.store().get_change_meta(&h).unwrap().author, rot.new);
2476 +
        r.seal(&[h]).unwrap();
2477 +
        (r, h)
2478 +
    };
2479 +
    let (r, h) = agent("kept", before);
2480 +
    assert!(
2481 +
        matches!(
2482 +
            r.authenticate_change(&h).unwrap(),
2483 +
            SignatureStatus::Valid { .. }
2484 +
        ),
2485 +
        "{:?}",
2486 +
        r.authenticate_change(&h).unwrap()
2487 +
    );
2488 +
    let (r, h) = agent("late", after);
2489 +
    assert!(
2490 +
        matches!(r.authenticate_change(&h).unwrap(), SignatureStatus::Invalid(e) if e.contains("neither")),
2491 +
        "a grant the old key made after the rotation: {:?}",
2492 +
        r.authenticate_change(&h).unwrap()
2493 +
    );
2494 +
}
2495 +
2009 2496
#[test]
2010 2497
fn a_certified_key_acts_for_its_author_under_a_grant() {
2011 2498
    use crate::cert::{Certificate, Grant, SignedCertificate, SignedGrant};
…
1192 unchanged lines
3204 3691
    let _ = h1;
3205 3692
}
3206 3693
3694 +
/// #137: a depth-0 clone takes no change, so the blobs of the state's
3695 +
/// binary files came with nothing and the checkout failed ("object … not
3696 +
/// found"); they are fetched with the checkpoint, or left lazy under
3697 +
/// `--max-blob`.
3207 3698
#[test]
3699 +
fn a_depth_0_clone_brings_its_binary_files() {
3700 +
    hermetic();
3701 +
    let dir = tempfile::tempdir().unwrap();
3702 +
    let root = dir.path();
3703 +
    let origin_dir = root.join("origin");
3704 +
    let origin = Repo::init(&origin_dir)
3705 +
        .unwrap()
3706 +
        .with_home(root.join("home"));
3707 +
    let png: Vec<u8> = (0..4000u32)
3708 +
        .map(|i| (i * 7 % 251) as u8)
3709 +
        .chain([0, 0xff])
3710 +
        .collect();
3711 +
    // The logo comes with a.txt's first line, and the head edits that line,
3712 +
    // so the change that set the logo is below the head, not a head itself.
3713 +
    write(&origin_dir, "logo.png", &png);
3714 +
    write(&origin_dir, "a.txt", b"first\n");
3715 +
    origin.commit("logo", &[]).unwrap();
3716 +
    write(&origin_dir, "a.txt", b"later\n");
3717 +
    origin.commit("later", &[]).unwrap();
3718 +
    assert_eq!(origin.bookmark_heads().unwrap().len(), 1);
3719 +
    let url = origin_dir.to_string_lossy().to_string();
3720 +
    Repo::clone_shallow(&url, &root.join("a"), 0, None, &[]).unwrap();
3721 +
    assert_eq!(fs::read(root.join("a/logo.png")).unwrap(), png);
3722 +
    let lazy = Repo::clone_shallow(&url, &root.join("b"), 0, Some(0), &[]).unwrap();
3723 +
    assert_eq!(fs::read(root.join("b/a.txt")).unwrap(), b"later\n");
3724 +
    let blob = {
3725 +
        let heads = lazy.bookmark_heads().unwrap();
3726 +
        let st = lazy.load_state_history(&heads).unwrap();
3727 +
        st.opaque_nodes()
3728 +
            .iter()
3729 +
            .flat_map(|n| st.live_blobs(n))
3730 +
            .next()
3731 +
            .unwrap()
3732 +
    };
3733 +
    assert_eq!(lazy.read_blob(&blob).unwrap(), png, "fetched on read");
3734 +
    // Depth 0 takes no change: the head is the boundary; the log comes from
3735 +
    // the checkpoint; a change on top commits and pushes.
3736 +
    let mut a = Repo::open(&root.join("a"))
3737 +
        .unwrap()
3738 +
        .with_home(root.join("home"));
3739 +
    let head = origin.bookmark_heads().unwrap()[0];
3740 +
    assert!(!a.store().has(&head), "no change fetched");
3741 +
    assert!(a.boundary().contains(&head));
3742 +
    assert_eq!(
3743 +
        a.log()
3744 +
            .unwrap()
3745 +
            .iter()
3746 +
            .filter(|e| !e.is_working_copy)
3747 +
            .count(),
3748 +
        2,
3749 +
        "the log from the checkpoint"
3750 +
    );
3751 +
    write(&root.join("a"), "a.txt", b"later\nmine\n");
3752 +
    a.config.set("remote.origin.url", &url).unwrap();
3753 +
    a.save_config().unwrap();
3754 +
    let mine = a.commit("mine", &[]).unwrap();
3755 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
3756 +
    origin.refresh().unwrap();
3757 +
    assert!(origin.store().has(&mine));
3758 +
    assert_eq!(origin.bookmark_heads().unwrap(), vec![mine]);
3759 +
    // And pulls what comes after.
3760 +
    origin
3761 +
        .checkout(&origin.bookmark_heads().unwrap(), false, "co")
3762 +
        .unwrap();
3763 +
    write(&origin_dir, "a.txt", b"later\nmine\ntheirs\n");
3764 +
    origin.snapshot("t").unwrap();
3765 +
    origin.commit("theirs", &[]).unwrap();
3766 +
    a.pull("origin").unwrap();
3767 +
    assert_eq!(
3768 +
        fs::read(root.join("a/a.txt")).unwrap(),
3769 +
        b"later\nmine\ntheirs\n"
3770 +
    );
3771 +
}
3772 +
3773 +
#[test]
3208 3774
fn shallow_clone_and_deepen() {
3209 3775
    hermetic();
3210 3776
    let dir = tempfile::tempdir().unwrap();
…
80 unchanged lines
3291 3857
}
3292 3858
3293 3859
#[test]
3860 +
fn a_tag_is_withdrawn_by_a_signed_withdrawal() {
3861 +
    hermetic();
3862 +
    let dir = tempfile::tempdir().unwrap();
3863 +
    let root = dir.path();
3864 +
    let origin = Repo::init(&root.join("origin")).unwrap();
3865 +
    let url = root.join("origin").to_string_lossy().to_string();
3866 +
    let mut a = Repo::clone(&url, &root.join("a"))
3867 +
        .unwrap()
3868 +
        .with_home(root.join("home-a"));
3869 +
    a.create_identity("Ada", "[email protected]", IdentityScope::User)
3870 +
        .unwrap();
3871 +
    write(&root.join("a"), "f.txt", b"1\n");
3872 +
    let h1 = a.commit("one", &[]).unwrap();
3873 +
    a.tag_create("v1", &[h1], "first release").unwrap();
3874 +
    a.tag_create("v2", &[h1], "").unwrap();
3875 +
    a.policy_init().unwrap();
3876 +
    a.push("origin", false).unwrap();
3877 +
    origin.refresh().unwrap();
3878 +
    let original = a
3879 +
        .tag_refs()
3880 +
        .unwrap()
3881 +
        .into_iter()
3882 +
        .find(|t| t.name == "v1")
3883 +
        .unwrap();
3884 +
    let b = Repo::clone(&url, &root.join("b"))
3885 +
        .unwrap()
3886 +
        .with_home(root.join("home-b"));
3887 +
    assert_eq!(b.tag_heads("v1").unwrap(), Some(vec![h1]));
3888 +
3889 +
    // A key the policy does not let create the tag may not withdraw it.
3890 +
    let mut c = Repo::clone(&url, &root.join("c"))
3891 +
        .unwrap()
3892 +
        .with_home(root.join("home-c"));
3893 +
    c.create_identity("Cy", "[email protected]", IdentityScope::User)
3894 +
        .unwrap();
3895 +
    let r = c.push_delete("origin", "tags/v1").unwrap();
3896 +
    assert!(
3897 +
        !r[0].ok && r[0].reason.contains("may not withdraw"),
3898 +
        "{r:?}"
3899 +
    );
3900 +
    assert_eq!(c.tag_heads("v1").unwrap(), None, "withdrawn there first");
3901 +
    origin.refresh().unwrap();
3902 +
    assert_eq!(origin.tag_heads("v1").unwrap(), Some(vec![h1]));
3903 +
3904 +
    // Its creator's key may: the tag goes, the withdrawal stays.
3905 +
    let r = a.push_delete("origin", "tags/v1").unwrap();
3906 +
    assert!(r[0].ok && r[0].reason == "withdrawn", "{r:?}");
3907 +
    origin.refresh().unwrap();
3908 +
    assert_eq!(origin.tag_heads("v1").unwrap(), None);
3909 +
    assert_eq!(a.tag_heads("v1").unwrap(), None);
3910 +
    assert!(origin.tag_withdrawal("v1").unwrap().is_some());
3911 +
    assert!(origin.tag_attest("v1").unwrap().is_some(), "kept beside it");
3912 +
    let refs = origin.remote_refs().unwrap();
3913 +
    assert!(refs.tags.iter().all(|t| t.name != "v1"));
3914 +
    assert_eq!(refs.withdrawn.len(), 1);
3915 +
    let r = a.push_delete("origin", "tags/v1").unwrap();
3916 +
    assert!(r[0].ok && r[0].reason == "already withdrawn", "{r:?}");
3917 +
3918 +
    // A fetch withdraws it too, and the name is never used again.
3919 +
    let rep = b.fetch("origin").unwrap();
3920 +
    assert_eq!(rep.withdrawn_tags, vec!["v1".to_string()]);
3921 +
    assert_eq!(b.tag_heads("v1").unwrap(), None);
3922 +
    assert!(b.tag_create("v1", &[h1], "").is_err());
3923 +
    let r = origin
3924 +
        .receive_push(Bundle::default(), vec![], None, vec![original])
3925 +
        .unwrap();
3926 +
    assert!(!r[0].ok && r[0].reason.contains("withdrawn"), "{r:?}");
3927 +
3928 +
    // `oxy tag delete`, then a plain push, sends it.
3929 +
    a.tag_delete("v2", "tagged too early").unwrap();
3930 +
    let r = a.push("origin", false).unwrap();
3931 +
    assert!(
3932 +
        r.iter()
3933 +
            .any(|x| x.name == "tags/v2" && x.ok && x.reason == "withdrawn"),
3934 +
        "{r:?}"
3935 +
    );
3936 +
    origin.refresh().unwrap();
3937 +
    assert_eq!(origin.tag_heads("v2").unwrap(), None);
3938 +
    let (_, w) = origin.tag_withdrawal("v2").unwrap().unwrap();
3939 +
    assert!(
3940 +
        matches!(&w.attest.body, AttestBody::Untag { reason, .. } if reason == "tagged too early")
3941 +
    );
3942 +
}
3943 +
3944 +
#[test]
3294 3945
fn tags_are_immutable_named_states_and_travel() {
3295 3946
    hermetic();
3296 3947
    let dir = tempfile::tempdir().unwrap();
…
23 unchanged lines
3320 3971
    assert_eq!(a.resolve_heads("main").unwrap(), vec![h2]);
3321 3972
    let (_, sa) = a.tag_attest("v1").unwrap().unwrap();
3322 3973
    assert_eq!(sa.sigs[0].key, key_a);
3974 +
    // Without a policy every client is sent every tag, whatever features
3975 +
    // it declares (oxygen#131).
3976 +
    let mut refs = a.remote_refs().unwrap();
3977 +
    a.refs_for_client(&mut refs, None);
3978 +
    assert_eq!(refs.tags.len(), 1);
3323 3979
    // Checkout by tag.
3324 3980
    a.checkout(&a.resolve_heads("v1").unwrap(), false, "co")
3325 3981
        .unwrap();
…
40 unchanged lines
3366 4022
    a.policy_init().unwrap();
3367 4023
    a.push("origin", false).unwrap();
3368 4024
    origin.refresh().unwrap();
3369 -
    let unattested = crate::protocol::TagRef {
3370 -
        name: "v3".into(),
3371 -
        state: origin
4025 +
    let unattested = crate::protocol::TagRef::new(
4026 +
        "v3".into(),
4027 +
        origin
3372 4028
            .store()
3373 4029
            .put_state(&StateObject::new(vec![h2]))
3374 4030
            .unwrap(),
3375 -
        heads: vec![h2],
3376 -
        attestation: None,
3377 -
    };
4031 +
        vec![h2],
4032 +
        None,
4033 +
    );
3378 4034
    let r = origin
3379 4035
        .receive_push(Bundle::default(), vec![], None, vec![unattested])
3380 4036
        .unwrap();
…
37 unchanged lines
3418 4074
3419 4075
    // A second workspace elsewhere, checked out on the bookmark.
3420 4076
    let ws_dir = root.join("ws2");
4077 +
    let ops = repo.op_log().unwrap().len();
3421 4078
    let ws = repo.workspace_add(&ws_dir, None).unwrap();
4079 +
    assert_eq!(
4080 +
        ws.op_log().unwrap().len(),
4081 +
        ops + 2,
4082 +
        "the add and its checkout, and no snapshot finding the tree deleted (oxygen#137)"
4083 +
    );
3422 4084
    assert!(ws_dir.join(".oxy").is_file());
3423 4085
    assert_eq!(fs::read(ws_dir.join("a.txt")).unwrap(), b"a\n");
3424 4086
    assert_ne!(ws.workspace(), repo.workspace());
…
1285 unchanged lines
4710 5372
        !clone.snapshot("status").unwrap().changed,
4711 5373
        "an unregistered nested repository is ignored"
4712 5374
    );
5375 +
5376 +
    // Registered again, it is pinned again; removed and committed, then
5377 +
    // registered again with no snapshot between, the change that adds it
5378 +
    // back pins it too: the removal's marker is spent (oxygen#152).
5379 +
    let modules = format!("[libs/lib]\nurl = {lib_url}\n");
5380 +
    let pinned = |c: ChangeHash| {
5381 +
        clone
5382 +
            .load_state(&[c])
5383 +
            .unwrap()
5384 +
            .materialize()
5385 +
            .files
5386 +
            .get("libs/lib")
5387 +
            .map(|n| n.kind)
5388 +
    };
5389 +
    write(&clone_dir, ".oxymodules", modules.as_bytes());
5390 +
    let c = clone.commit("lib again", &[]).unwrap();
5391 +
    assert_eq!(pinned(c), Some(NodeKind::Subrepo));
5392 +
    clone.submodule_remove("libs/lib").unwrap();
5393 +
    let c = clone.commit("drop lib again", &[]).unwrap();
5394 +
    assert_eq!(pinned(c), None);
5395 +
    write(&clone_dir, ".oxymodules", modules.as_bytes());
5396 +
    let c = clone.commit("lib back", &[]).unwrap();
5397 +
    assert_eq!(
5398 +
        pinned(c),
5399 +
        Some(NodeKind::Subrepo),
5400 +
        "pinned in the change that registers it"
5401 +
    );
5402 +
    assert!(!clone.snapshot("status").unwrap().changed);
4713 5403
}
4714 5404
4715 5405
#[test]
…
4459 unchanged lines
9175 9865
    assert!(e.contains("no such bookmark"), "{e}");
9176 9866
}
9177 9867
9868 +
/// oxygen#140: a replace conflict is settled from the working copy, as
9869 +
/// `oxy resolve PATH` does: the path's content on disk becomes the
9870 +
/// resolution, and the commit that records it is a Resolution.
9871 +
#[test]
9872 +
fn a_replace_conflict_is_settled_from_the_working_copy() {
9873 +
    hermetic();
9874 +
    let dir = tempfile::tempdir().unwrap();
9875 +
    let root = dir.path();
9876 +
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
9877 +
    let blob = |n: u8| {
9878 +
        let mut v = vec![0u8; 64];
9879 +
        v[1] = n;
9880 +
        v
9881 +
    };
9882 +
    write(root, "img.bin", &blob(1));
9883 +
    write(root, "a.txt", b"a\n");
9884 +
    repo.commit("add img", &[]).unwrap();
9885 +
    repo.bookmark_create("other", None).unwrap();
9886 +
    write(root, "img.bin", &blob(2));
9887 +
    repo.commit("img two", &[]).unwrap();
9888 +
    repo.bookmark_switch("other", false).unwrap();
9889 +
    write(root, "img.bin", &blob(3));
9890 +
    repo.commit("img three", &[]).unwrap();
9891 +
    repo.bookmark_switch("main", false).unwrap();
9892 +
    repo.merge("other").unwrap();
9893 +
    // Without a choice, nothing the working copy holds settles it, and
9894 +
    // other work cannot be committed past it.
9895 +
    write(root, "img.bin", &blob(9));
9896 +
    write(root, "a.txt", b"a\nmore\n");
9897 +
    assert!(
9898 +
        repo.commit("edit a", &[]).is_err(),
9899 +
        "refused while conflicted"
9900 +
    );
9901 +
    let snap = repo.snapshot("oxy resolve").unwrap();
9902 +
    assert!(
9903 +
        repo.choose_resolutions(&snap.base_snapshot, &["a.txt".to_string()])
9904 +
            .is_err(),
9905 +
        "a.txt has no replace conflict"
9906 +
    );
9907 +
    repo.choose_resolutions(&snap.base_snapshot, &["img.bin".to_string()])
9908 +
        .unwrap();
9909 +
    let snap = repo.snapshot("oxy resolve").unwrap();
9910 +
    assert!(snap
9911 +
        .statuses
9912 +
        .iter()
9913 +
        .any(|s| s.path == "img.bin" && s.status == ChangeStatus::Resolved));
9914 +
    let c = repo.commit("settle img", &[]).unwrap();
9915 +
    assert_eq!(
9916 +
        repo.store().get_change(&c).unwrap().kind,
9917 +
        ChangeKind::Resolution
9918 +
    );
9919 +
    let after = repo
9920 +
        .load_state(&repo.bookmark_heads().unwrap())
9921 +
        .unwrap()
9922 +
        .materialize();
9923 +
    assert!(!after.is_conflicted());
9924 +
    assert_eq!(
9925 +
        after.files["img.bin"].content,
9926 +
        FileContent::Opaque {
9927 +
            blobs: vec![Repo::blob_ref(&blob(9))]
9928 +
        }
9929 +
    );
9930 +
    assert!(
9931 +
        matches!(&after.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nmore\n")
9932 +
    );
9933 +
    // Settled: the choice is forgotten, and the working copy is clean.
9934 +
    let snap = repo.snapshot("status").unwrap();
9935 +
    assert!(snap.statuses.is_empty(), "{:?}", snap.statuses);
9936 +
    assert!(repo.resolutions().unwrap().is_empty());
9937 +
}
9938 +
9939 +
/// oxygen#140: the same for a subrepo pin moved two ways at once.
9178 9940
#[test]
9941 +
fn a_pin_conflict_is_settled_from_the_working_copy() {
9942 +
    hermetic();
9943 +
    let dir = tempfile::tempdir().unwrap();
9944 +
    let root = dir.path();
9945 +
    let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap();
9946 +
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
9947 +
    let lib = Repo::clone(&lib_url, &root.join("lib"))
9948 +
        .unwrap()
9949 +
        .with_home(root.join("home"));
9950 +
    write(&root.join("lib"), "lib.txt", b"v1\n");
9951 +
    lib.commit("lib v1", &[]).unwrap();
9952 +
    assert!(lib.push("origin", false).unwrap()[0].ok);
9953 +
    let app_dir = root.join("app");
9954 +
    let mut app = Repo::init(&app_dir).unwrap().with_home(root.join("home"));
9955 +
    write(&app_dir, "main.txt", b"app\n");
9956 +
    app.submodule_add(&lib_url, "libs/lib", None).unwrap();
9957 +
    app.commit("pin v1", &[]).unwrap();
9958 +
    let nested = Repo::open(&app_dir.join("libs/lib"))
9959 +
        .unwrap()
9960 +
        .with_home(root.join("home"));
9961 +
    fn pin(app: &Repo, nested: &Repo, text: &[u8], message: &str) {
9962 +
        write(nested.root(), "lib.txt", text);
9963 +
        nested.commit(message, &[]).unwrap();
9964 +
        app.commit(&format!("pin {message}"), &[]).unwrap();
9965 +
    }
9966 +
    app.bookmark_create("other", None).unwrap();
9967 +
    pin(&app, &nested, b"v2\n", "v2");
9968 +
    app.bookmark_switch("other", false).unwrap();
9969 +
    pin(&app, &nested, b"v3\n", "v3");
9970 +
    app.bookmark_switch("main", false).unwrap();
9971 +
    app.merge("other").unwrap();
9972 +
    let merged = app
9973 +
        .load_state(&app.bookmark_heads().unwrap())
9974 +
        .unwrap()
9975 +
        .materialize();
9976 +
    assert!(
9977 +
        matches!(&merged.files["libs/lib"].content, FileContent::Opaque { blobs } if blobs.len() == 2),
9978 +
        "a pin conflict"
9979 +
    );
9980 +
    // The nested repository moves on once more; that pin is the choice.
9981 +
    write(&app_dir.join("libs/lib"), "lib.txt", b"v4\n");
9982 +
    nested.commit("v4", &[]).unwrap();
9983 +
    let snap = app.snapshot("oxy resolve").unwrap();
9984 +
    app.choose_resolutions(&snap.base_snapshot, &["libs/lib".to_string()])
9985 +
        .unwrap();
9986 +
    let c = app.commit("settle the pin", &[]).unwrap();
9987 +
    assert_eq!(
9988 +
        app.store().get_change(&c).unwrap().kind,
9989 +
        ChangeKind::Resolution
9990 +
    );
9991 +
    let after = app
9992 +
        .load_state(&app.bookmark_heads().unwrap())
9993 +
        .unwrap()
9994 +
        .materialize();
9995 +
    assert!(!after.is_conflicted());
9996 +
    assert!(app.snapshot("status").unwrap().statuses.is_empty());
9997 +
}
9998 +
9999 +
#[test]
9179 10000
fn carrying_work_over_a_subrepo_already_at_the_target_pin_is_no_conflict() {
9180 10001
    // oxygen#74: a stale state with .oxymodules but no pin, a newer one
9181 10002
    // pinning the subrepo, and the nested repository on disk at that pin.
…
26 unchanged lines
9208 10029
    assert!(app_dir.join("notes.txt").exists());
9209 10030
}
9210 10031
10032 +
/// oxygen#142: a new bookmark made from main sends what main lacks, not
10033 +
/// its whole history again.
9211 10034
#[test]
10035 +
fn a_new_bookmark_sends_only_what_the_remote_lacks() {
10036 +
    hermetic();
10037 +
    let dir = tempfile::tempdir().unwrap();
10038 +
    let root = dir.path();
10039 +
    let _origin = Repo::init(&root.join("origin")).unwrap();
10040 +
    let url = root.join("origin").to_string_lossy().to_string();
10041 +
    let mut one = Repo::clone(&url, &root.join("one"))
10042 +
        .unwrap()
10043 +
        .with_home(root.join("home"));
10044 +
    for n in 0..5 {
10045 +
        write(&root.join("one"), "a.txt", format!("{n}\n").as_bytes());
10046 +
        one.commit(&format!("main {n}"), &[]).unwrap();
10047 +
    }
10048 +
    assert!(one.push("origin", false).unwrap()[0].ok);
10049 +
    one.bookmark_create("feature", None).unwrap();
10050 +
    one.bookmark_switch("feature", false).unwrap();
10051 +
    write(&root.join("one"), "b.txt", b"feature\n");
10052 +
    let added = one.commit("feature", &[]).unwrap();
10053 +
    let heads = one.bookmark_heads().unwrap();
10054 +
    let refs = one
10055 +
        .open_named_remote("origin")
10056 +
        .unwrap()
10057 +
        .refs()
10058 +
        .unwrap()
10059 +
        .refs;
10060 +
    assert!(
10061 +
        refs.iter().all(|r| r.name != "feature"),
10062 +
        "new to the remote"
10063 +
    );
10064 +
    let sent = one
10065 +
        .negotiate(&one.remote_holds(&refs, &[]), &heads)
10066 +
        .unwrap();
10067 +
    assert_eq!(sent, vec![added], "only the change main lacks");
10068 +
    assert!(one.push_bookmark("origin", "feature", false).unwrap()[0].ok);
10069 +
    let two = Repo::clone(&url, &root.join("two")).unwrap();
10070 +
    assert!(two.store().has(&added));
10071 +
}
10072 +
10073 +
#[test]
9212 10074
fn a_bookmark_only_behind_its_remote_catches_up() {
9213 10075
    // oxygen#74: switch takes the remote's state rather than a stale one.
9214 10076
    hermetic();
…
364 unchanged lines
9579 10441
    assert!(got[0].ok && got[0].name == OBJECTS_RESULT, "{got:?}");
9580 10442
}
9581 10443
10444 +
/// oxygen#133: one change whose blobs pass `push.batch-bytes` sends them
10445 +
/// first, in parts of objects alone, so no request is larger than about
10446 +
/// that (a proxy in front of a hub refuses one past 100 MB); the change
10447 +
/// and the bookmark follow, and a clone has every file.
10448 +
#[test]
10449 +
fn a_heavy_change_sends_its_blobs_in_parts() {
10450 +
    hermetic();
10451 +
    let dir = tempfile::tempdir().unwrap();
10452 +
    let root = dir.path();
10453 +
    let origin = root.join("origin");
10454 +
    Repo::init_bare_unbound(&origin).unwrap();
10455 +
    let main = root.join("main");
10456 +
    let mut repo = Repo::init(&main).unwrap();
10457 +
    repo.config
10458 +
        .set("remote.origin.url", &origin.to_string_lossy())
10459 +
        .unwrap();
10460 +
    repo.config.set("push.batch-bytes", "50000").unwrap();
10461 +
    repo.save_config().unwrap();
10462 +
    let mut x: u64 = 0x9e3779b97f4a7c15;
10463 +
    let mut noise = |n: usize| -> Vec<u8> {
10464 +
        (0..n)
10465 +
            .map(|_| {
10466 +
                x ^= x << 13;
10467 +
                x ^= x >> 7;
10468 +
                x ^= x << 17;
10469 +
                (x >> 24) as u8
10470 +
            })
10471 +
            .collect()
10472 +
    };
10473 +
    let files: Vec<Vec<u8>> = (0..4).map(|_| noise(40_000)).collect();
10474 +
    for (i, f) in files.iter().enumerate() {
10475 +
        write(&main, &format!("bin{i}.dat"), f);
10476 +
    }
10477 +
    repo.commit("four blobs", &[]).unwrap();
10478 +
    let mut parts = Vec::new();
10479 +
    let results = repo
10480 +
        .push_bookmark_with("origin", "main", false, &mut |m| parts.push(m.to_string()))
10481 +
        .unwrap();
10482 +
    assert!(
10483 +
        results.iter().any(|r| r.ok && r.name == "main"),
10484 +
        "{results:?}"
10485 +
    );
10486 +
    assert!(
10487 +
        parts.len() >= 3 && parts[0].starts_with("objects 1 of"),
10488 +
        "{parts:?}"
10489 +
    );
10490 +
    let back = Repo::clone(&origin.to_string_lossy(), &root.join("back")).unwrap();
10491 +
    for (i, f) in files.iter().enumerate() {
10492 +
        assert_eq!(&fs::read(root.join(format!("back/bin{i}.dat"))).unwrap(), f);
10493 +
    }
10494 +
    drop(back);
10495 +
}
10496 +
10497 +
/// oxygen#136: a bundle carries a replaced binary file's new content
10498 +
/// only. Its old content came with the change that added it, so removing
10499 +
/// or replacing a file sends no old blob again; the remote and a clone of
10500 +
/// it are whole.
10501 +
#[test]
10502 +
fn a_replacement_does_not_send_the_old_content_again() {
10503 +
    hermetic();
10504 +
    let dir = tempfile::tempdir().unwrap();
10505 +
    let root = dir.path();
10506 +
    let origin = root.join("origin");
10507 +
    Repo::init_bare_unbound(&origin).unwrap();
10508 +
    let main = root.join("main");
10509 +
    let mut repo = Repo::init(&main).unwrap();
10510 +
    repo.config
10511 +
        .set("remote.origin.url", &origin.to_string_lossy())
10512 +
        .unwrap();
10513 +
    repo.save_config().unwrap();
10514 +
    let blobs = |b: &Bundle| {
10515 +
        b.objects
10516 +
            .iter()
10517 +
            .filter(|(ty, _)| *ty == ObjectType::Blob)
10518 +
            .count()
10519 +
    };
10520 +
    write(&main, "bin.dat", b"\0\x01first binary");
10521 +
    write(&main, "gone.dat", b"\0\x02to be removed");
10522 +
    let added = repo.commit("add", &[]).unwrap();
10523 +
    assert!(repo.push("origin", false).unwrap()[0].ok);
10524 +
    write(&main, "bin.dat", b"\0\x01second binary");
10525 +
    fs::remove_file(main.join("gone.dat")).unwrap();
10526 +
    let changed = repo.commit("replace and remove", &[]).unwrap();
10527 +
    assert_eq!(blobs(&repo.bundle_for(&[changed]).unwrap()), 1);
10528 +
    assert_eq!(blobs(&repo.bundle_for(&[added, changed]).unwrap()), 3);
10529 +
    assert!(repo.push("origin", false).unwrap()[0].ok);
10530 +
    let origin = Repo::open(&origin).unwrap();
10531 +
    assert_eq!(origin.fsck().unwrap().problems, vec![]);
10532 +
    let back = Repo::clone(&origin.root().to_string_lossy(), &root.join("back")).unwrap();
10533 +
    assert_eq!(back.fsck().unwrap().problems, vec![]);
10534 +
    assert_eq!(
10535 +
        fs::read(root.join("back/bin.dat")).unwrap(),
10536 +
        b"\0\x01second binary"
10537 +
    );
10538 +
    assert!(!root.join("back/gone.dat").exists());
10539 +
}
10540 +
9582 10541
/// oxygen#97: with `git.claim`, an import authors the importing identity's
9583 10542
/// own commits (Git author email = its email, any case) with its key, and
9584 10543
/// leaves anyone else's with the zero key; the history verifies as before.
…
465 unchanged lines
10050 11009
    assert!(format!("{:?}", e.unwrap_err()).contains("does not exist"));
10051 11010
}
10052 11011
11012 +
/// oxygen#147: a subrepo at its pinned state is unmodified whatever
11013 +
/// bookmark it sits on, so the parent's status is clean and a bookmark
11014 +
/// switch goes ahead; a new state in it is still a change.
11015 +
#[test]
11016 +
fn a_subrepo_at_its_pinned_state_on_another_bookmark_is_unmodified() {
11017 +
    hermetic();
11018 +
    let dir = tempfile::tempdir().unwrap();
11019 +
    let root = dir.path();
11020 +
    let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap();
11021 +
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
11022 +
    let lib = Repo::clone(&lib_url, &root.join("lib"))
11023 +
        .unwrap()
11024 +
        .with_home(root.join("home"));
11025 +
    write(&root.join("lib"), "lib.txt", b"v1\n");
11026 +
    lib.commit("lib v1", &[]).unwrap();
11027 +
    assert!(lib.push("origin", false).unwrap()[0].ok);
11028 +
    let _app_origin = Repo::init(&root.join("app-origin")).unwrap();
11029 +
    let app_dir = root.join("app");
11030 +
    let mut app = Repo::clone(&root.join("app-origin").to_string_lossy(), &app_dir)
11031 +
        .unwrap()
11032 +
        .with_home(root.join("home"));
11033 +
    write(&app_dir, "main.txt", b"app\n");
11034 +
    app.submodule_add(&lib_url, "libs/lib", None).unwrap();
11035 +
    let a1 = app.commit("app with lib", &[]).unwrap();
11036 +
11037 +
    // The nested repository moves to another bookmark at the same heads.
11038 +
    let mut nested = Repo::open(&app_dir.join("libs/lib"))
11039 +
        .unwrap()
11040 +
        .with_home(root.join("home"));
11041 +
    let heads = nested.bookmark_heads().unwrap();
11042 +
    nested.bookmark_create("feature", Some(&heads)).unwrap();
11043 +
    nested.bookmark_switch("feature", false).unwrap();
11044 +
    let snap = app.snapshot("status").unwrap();
11045 +
    assert!(
11046 +
        !snap.changed,
11047 +
        "only the bookmark differs: {:?}",
11048 +
        snap.statuses
11049 +
    );
11050 +
    app.bookmark_create("other", Some(&[a1])).unwrap();
11051 +
    app.bookmark_switch("other", false).unwrap();
11052 +
11053 +
    // A new state in it is a change, as before.
11054 +
    let nested = Repo::open(&app_dir.join("libs/lib"))
11055 +
        .unwrap()
11056 +
        .with_home(root.join("home"));
11057 +
    write(&app_dir.join("libs/lib"), "lib.txt", b"v2\n");
11058 +
    nested.commit("lib v2", &[]).unwrap();
11059 +
    let snap = app.snapshot("status").unwrap();
11060 +
    assert_eq!(
11061 +
        snap.statuses
11062 +
            .iter()
11063 +
            .map(|s| (s.path.as_str(), s.status))
11064 +
            .collect::<Vec<_>>(),
11065 +
        vec![("libs/lib", ChangeStatus::Modified)]
11066 +
    );
11067 +
}
11068 +
10053 11069
/// oxygenhub#249: a handle reads the operation head once and keeps it,
10054 11070
/// so one command or request never mixes two heads; another handle's
10055 11071
/// operation shows only after `refresh`. Its own operations move it, and
…
153 unchanged lines
10209 11225
    assert!(!backend.has("pack-copy.idx").unwrap());
10210 11226
    crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = None);
10211 11227
}
11228 +
11229 +
/// oxygen#82: a home with `author.for` authors as the identity its hub
11230 +
/// last named (kept in `keys/author.for`), not its stale `author.key`; a
11231 +
/// repository's own `author.key` pin still wins, and without `author.for`
11232 +
/// the note is ignored.
11233 +
#[test]
11234 +
fn author_for_follows_the_key_the_hub_named() {
11235 +
    let dir = tempfile::tempdir().unwrap();
11236 +
    let home = dir.path().join("home");
11237 +
    fs::create_dir_all(home.join("keys")).unwrap();
11238 +
    let user = home.join("config.toml");
11239 +
    let repo_cfg = dir.path().join("config");
11240 +
    let (old, new, pin) = (
11241 +
        KeyId::from_hex(&"a".repeat(64)).unwrap(),
11242 +
        KeyId::from_hex(&"b".repeat(64)).unwrap(),
11243 +
        KeyId::from_hex(&"c".repeat(64)).unwrap(),
11244 +
    );
11245 +
    fs::write(&user, format!("author.key = \"{}\"\n", old.to_hex())).unwrap();
11246 +
    fs::write(
11247 +
        home.join("keys").join(super::config::AUTHOR_FOR_FILE),
11248 +
        format!("{}\n", new.to_hex()),
11249 +
    )
11250 +
    .unwrap();
11251 +
    fs::write(&repo_cfg, "").unwrap();
11252 +
    assert_eq!(
11253 +
        Config::load(Some(&user), &repo_cfg).unwrap().author_key,
11254 +
        Some(old),
11255 +
        "no author.for: the note is ignored"
11256 +
    );
11257 +
    fs::write(
11258 +
        &user,
11259 +
        format!(
11260 +
            "author.key = \"{}\"\nauthor.for = \"andymccall\"\n",
11261 +
            old.to_hex()
11262 +
        ),
11263 +
    )
11264 +
    .unwrap();
11265 +
    assert_eq!(
11266 +
        Config::load(Some(&user), &repo_cfg).unwrap().author_key,
11267 +
        Some(new)
11268 +
    );
11269 +
    fs::write(&repo_cfg, format!("author.key = \"{}\"\n", pin.to_hex())).unwrap();
11270 +
    assert_eq!(
11271 +
        Config::load(Some(&user), &repo_cfg).unwrap().author_key,
11272 +
        Some(pin),
11273 +
        "a repository's pin wins"
11274 +
    );
11275 +
}
11276 +
11277 +
#[test]
11278 +
fn git_export_graph_is_deterministic_and_merges_heads() {
11279 +
    hermetic();
11280 +
    if std::process::Command::new("git")
11281 +
        .arg("--version")
11282 +
        .output()
11283 +
        .is_err()
11284 +
    {
11285 +
        eprintln!("git not installed; skipping");
11286 +
        return;
11287 +
    }
11288 +
    let dir = tempfile::tempdir().unwrap();
11289 +
    let root = dir.path();
11290 +
    let mut repo = Repo::init(&root.join("r"))
11291 +
        .unwrap()
11292 +
        .with_home(root.join("home"));
11293 +
    repo.create_identity("Ada Lovelace", "[email protected]", IdentityScope::User)
11294 +
        .unwrap();
11295 +
    let r = root.join("r");
11296 +
    write(&r, "README.md", b"hello\n");
11297 +
    let h1 = repo.commit("first", &[]).unwrap();
11298 +
    write(&r, "README.md", b"hello\nworld\n");
11299 +
    write(&r, "src/main.rs", b"fn main() {}\n");
11300 +
    let h2 = repo.commit("second", &[]).unwrap();
11301 +
    repo.tag_create("v1", &[h2], "the first release").unwrap();
11302 +
    // An independent change: main gets two heads.
11303 +
    repo.checkout(&[h1], false, "checkout").unwrap();
11304 +
    write(&r, "other.txt", b"o\n");
11305 +
    let h3 = repo.commit("third", &[]).unwrap();
11306 +
    repo.bookmark_create("gone", Some(&[h1])).unwrap();
11307 +
11308 +
    let git = |git_dir: &Path, args: &[&str]| -> String {
11309 +
        let out = std::process::Command::new("git")
11310 +
            .arg("-C")
11311 +
            .arg(git_dir)
11312 +
            .args(args)
11313 +
            .output()
11314 +
            .unwrap();
11315 +
        assert!(
11316 +
            out.status.success(),
11317 +
            "git {args:?}: {}",
11318 +
            String::from_utf8_lossy(&out.stderr)
11319 +
        );
11320 +
        String::from_utf8_lossy(&out.stdout).trim().to_string()
11321 +
    };
11322 +
    let a = root.join("a.git");
11323 +
    let rep = repo.git_export_graph(&a).unwrap();
11324 +
    assert_eq!((rep.commits, rep.tags), (3, 1), "{rep:?}");
11325 +
    assert_eq!(rep.branches, 2, "main and gone: {rep:?}");
11326 +
11327 +
    // Each change is a commit whose parents are exactly its dependencies'
11328 +
    // commits (Oxygen's dependencies, not the order changes were made in).
11329 +
    let by_hash = |hash: ChangeHash| {
11330 +
        git(
11331 +
            &a,
11332 +
            &[
11333 +
                "log",
11334 +
                "--all",
11335 +
                "--format=%H",
11336 +
                &format!("--grep=Oxy-Hash: {}", hash.to_hex()),
11337 +
            ],
11338 +
        )
11339 +
    };
11340 +
    let (c1, c2, c3) = (by_hash(h1), by_hash(h2), by_hash(h3));
11341 +
    for (h, c) in [(h1, &c1), (h2, &c2), (h3, &c3)] {
11342 +
        let mut want: Vec<String> = repo
11343 +
            .store()
11344 +
            .get_change_meta(&h)
11345 +
            .unwrap()
11346 +
            .deps
11347 +
            .iter()
11348 +
            .map(|d| by_hash(*d))
11349 +
            .collect();
11350 +
        want.sort();
11351 +
        let mut have: Vec<String> = git(&a, &["rev-list", "--parents", "-1", c])
11352 +
            .split(' ')
11353 +
            .skip(1)
11354 +
            .map(String::from)
11355 +
            .collect();
11356 +
        have.sort();
11357 +
        assert_eq!(have, want, "{h:?}'s commit's parents");
11358 +
    }
11359 +
    assert!(
11360 +
        !repo.store().get_change_meta(&h2).unwrap().deps.is_empty(),
11361 +
        "second edits first's README"
11362 +
    );
11363 +
    assert_eq!(
11364 +
        git(&a, &["show", &format!("{c2}:src/main.rs")]),
11365 +
        "fn main() {}"
11366 +
    );
11367 +
    assert!(
11368 +
        git(&a, &["ls-tree", "--name-only", &c3])
11369 +
            .lines()
11370 +
            .all(|l| l != "src"),
11371 +
        "a change's tree is its own closure, not the bookmark's"
11372 +
    );
11373 +
    assert_eq!(
11374 +
        git(&a, &["log", "-1", "--format=%an <%ae>", &c1]),
11375 +
        "Ada Lovelace <[email protected]>"
11376 +
    );
11377 +
11378 +
    // main has two heads: a merge of both, whose tree is the union.
11379 +
    let main = git(&a, &["rev-parse", "main"]);
11380 +
    let mut parents: Vec<String> = git(&a, &["rev-list", "--parents", "-1", "main"])
11381 +
        .split(' ')
11382 +
        .skip(1)
11383 +
        .map(String::from)
11384 +
        .collect();
11385 +
    parents.sort();
11386 +
    let mut want = vec![c2.clone(), c3.clone()];
11387 +
    want.sort();
11388 +
    assert_eq!(parents, want, "the merge's parents are the heads");
11389 +
    assert_eq!(
11390 +
        git(&a, &["ls-tree", "--name-only", "main"]),
11391 +
        "README.md\nother.txt\nsrc"
11392 +
    );
11393 +
    assert!(git(&a, &["log", "-1", "--format=%B", "main"]).contains("Merge of 2 heads"));
11394 +
    assert_eq!(
11395 +
        git(&a, &["rev-parse", "v1^{commit}"]),
11396 +
        c2,
11397 +
        "the tag is its head's commit"
11398 +
    );
11399 +
    assert_eq!(git(&a, &["symbolic-ref", "HEAD"]), "refs/heads/main");
11400 +
11401 +
    // Again: nothing new. A bookmark deleted takes its branch with it.
11402 +
    repo.bookmark_delete("gone").unwrap();
11403 +
    let rep = repo.git_export_graph(&a).unwrap();
11404 +
    assert_eq!(rep.commits, 0, "{rep:?}");
11405 +
    assert!(
11406 +
        git(&a, &["for-each-ref", "--format=%(refname)", "refs/heads"])
11407 +
            .lines()
11408 +
            .all(|r| r != "refs/heads/gone")
11409 +
    );
11410 +
    assert_eq!(
11411 +
        git(&a, &["rev-parse", "main"]),
11412 +
        main,
11413 +
        "the merge is made once"
11414 +
    );
11415 +
11416 +
    // The same changes elsewhere give the same commits.
11417 +
    let other = Repo::clone(r.to_str().unwrap(), &root.join("clone"))
11418 +
        .unwrap()
11419 +
        .with_home(root.join("home"));
11420 +
    let b = root.join("b.git");
11421 +
    other.git_export_graph(&b).unwrap();
11422 +
    assert_eq!(
11423 +
        git(&b, &["rev-parse", "main"]),
11424 +
        main,
11425 +
        "deterministic across repositories"
11426 +
    );
11427 +
    assert_eq!(git(&b, &["rev-parse", "v1^{commit}"]), c2);
11428 +
}
11429 +
11430 +
#[test]
11431 +
fn a_fetch_of_many_bookmarks_takes_shared_changes_once() {
11432 +
    // oxygen#158: every bookmark's heads are fetched in one exchange, so
11433 +
    // changes bookmarks share are not sent again for each; each bookmark
11434 +
    // still says how many of its changes were new, and a fetch with
11435 +
    // nothing new imports nothing.
11436 +
    hermetic();
11437 +
    let dir = tempfile::tempdir().unwrap();
11438 +
    let root = dir.path();
11439 +
    drop(Repo::init(&root.join("origin")).unwrap());
11440 +
    let url = root.join("origin").to_string_lossy().to_string();
11441 +
    let mut a = Repo::clone(&url, &root.join("a"))
11442 +
        .unwrap()
11443 +
        .with_home(root.join("home-a"));
11444 +
    let w = root.join("a");
11445 +
    write(&w, "f.txt", b"one\n");
11446 +
    a.commit("one", &[]).unwrap();
11447 +
    write(&w, "f.txt", b"one\ntwo\n");
11448 +
    a.commit("two", &[]).unwrap();
11449 +
    a.push("origin", false).unwrap();
11450 +
    // Five bookmarks on main's history, each one change of its own.
11451 +
    for i in 0..5 {
11452 +
        a.bookmark_create(&format!("feat{i}"), None).unwrap();
11453 +
        a.bookmark_switch(&format!("feat{i}"), false).unwrap();
11454 +
        write(&w, &format!("feat{i}.txt"), format!("{i}\n").as_bytes());
11455 +
        a.commit(&format!("feat {i}"), &[]).unwrap();
11456 +
        a.push("origin", false).unwrap();
11457 +
        a.bookmark_switch("main", false).unwrap();
11458 +
    }
11459 +
11460 +
    let b = Repo::clone(&url, &root.join("b"))
11461 +
        .unwrap()
11462 +
        .with_home(root.join("home-b"));
11463 +
    let report = b.fetch("origin").unwrap();
11464 +
    assert_eq!(report.refs.len(), 6, "every bookmark: {:?}", report.refs);
11465 +
    assert!(
11466 +
        report.refs.iter().all(|(_, _, n)| *n == 0),
11467 +
        "the clone brought them all: {:?}",
11468 +
        report.refs
11469 +
    );
11470 +
11471 +
    // One new change on one bookmark: counted there, nowhere else.
11472 +
    a.bookmark_switch("feat3", false).unwrap();
11473 +
    write(&w, "feat3.txt", b"3\nmore\n");
11474 +
    a.commit("feat 3 again", &[]).unwrap();
11475 +
    a.push("origin", false).unwrap();
11476 +
    let fetches = || crate::protocol::LOCAL_FETCHES.with(|n| n.get());
11477 +
    let at = fetches();
11478 +
    let report = b.fetch("origin").unwrap();
11479 +
    assert_eq!(fetches() - at, 1, "one bundle for all six bookmarks");
11480 +
    let counts: Vec<(String, usize)> = report
11481 +
        .refs
11482 +
        .iter()
11483 +
        .map(|(n, _, c)| (n.clone(), *c))
11484 +
        .collect();
11485 +
    assert!(counts.contains(&("feat3".to_string(), 1)), "{counts:?}");
11486 +
    assert_eq!(
11487 +
        counts.iter().filter(|(_, c)| *c > 0).count(),
11488 +
        1,
11489 +
        "only feat3 had anything new: {counts:?}"
11490 +
    );
11491 +
11492 +
    // Nothing new: no bundle at all, every bookmark still listed.
11493 +
    let at = fetches();
11494 +
    let report = b.fetch("origin").unwrap();
11495 +
    assert_eq!(fetches() - at, 0, "nothing to fetch, no round trip");
11496 +
    assert_eq!(report.refs.len(), 6);
11497 +
    assert!(
11498 +
        report.refs.iter().all(|(_, _, n)| *n == 0),
11499 +
        "{:?}",
11500 +
        report.refs
11501 +
    );
11502 +
}
11503 +
11504 +
#[test]
11505 +
fn a_stale_pinned_key_cannot_fork_the_identity() {
11506 +
    // oxygen#163: a checkout pinned author.key to a key that a rotation made
11507 +
    // elsewhere had already succeeded; delegating there built a second
11508 +
    // successor of it, splitting the identity in two.
11509 +
    hermetic();
11510 +
    let dir = tempfile::tempdir().unwrap();
11511 +
    let root = dir.path();
11512 +
    let home = root.join("home");
11513 +
    let mut a = Repo::init(&root.join("a")).unwrap().with_home(&home);
11514 +
    let (first, _) = a
11515 +
        .create_identity("Ada", "[email protected]", IdentityScope::User)
11516 +
        .unwrap();
11517 +
    let r = a.rotate_identity().unwrap();
11518 +
    assert_eq!((r.old, a.config.author_key), (first, Some(r.new)));
11519 +
    assert_eq!(a.successors(&first).unwrap(), vec![r.new]);
11520 +
    assert_eq!(a.newest(&first).unwrap(), r.new);
11521 +
11522 +
    // Another checkout still pins the first key.
11523 +
    let mut b = Repo::init(&root.join("b")).unwrap().with_home(&home);
11524 +
    b.config.set("author.key", &first.to_hex()).unwrap();
11525 +
    b.save_config().unwrap();
11526 +
    let mut b = Repo::open(&root.join("b")).unwrap().with_home(&home);
11527 +
    assert_eq!(b.config.author_key, Some(first));
11528 +
    for e in [
11529 +
        b.rotate_identity().unwrap_err().to_string(),
11530 +
        b.renew_identity(None).unwrap_err().to_string(),
11531 +
    ] {
11532 +
        assert!(e.contains("fork") && e.contains(&r.new.short(12)), "{e}");
11533 +
        assert!(
11534 +
            e.contains(&format!("oxy config author.key {}", r.new.to_hex())),
11535 +
            "{e}"
11536 +
        );
11537 +
    }
11538 +
    assert_eq!(
11539 +
        b.successors(&first).unwrap(),
11540 +
        vec![r.new],
11541 +
        "nothing was made"
11542 +
    );
11543 +
11544 +
    // Pointed at the newest key, it rotates as usual.
11545 +
    b.config.set("author.key", &r.new.to_hex()).unwrap();
11546 +
    b.save_config().unwrap();
11547 +
    let mut b = Repo::open(&root.join("b")).unwrap().with_home(&home);
11548 +
    let r2 = b.rotate_identity().unwrap();
11549 +
    assert_eq!(r2.old, r.new);
11550 +
    assert_eq!(b.newest(&first).unwrap(), r2.new);
11551 +
}
modified crates/oxy-core/src/repo/transparency.rs +48 −15
…
333 unchanged lines
334 334
        out.notes.extend(accepted.notes.iter().cloned());
335 335
        let witnesses = self.pin_witnesses(&hub, &resp.witnesses, &mut out.notes)?;
336 336
        let mut missing = 0;
337 -
        for r in &resp.refs {
338 -
            let Some(a) = &r.attestation else { continue };
339 -
            let Some(l) = &r.logged else {
337 +
        // Each attestation's place in the log, and its integrated time.
338 +
        let mut place = |name: &str,
339 +
                         kind: &str,
340 +
                         a: &SignedAttest,
341 +
                         logged: Option<&crate::protocol::Logged>,
342 +
                         integration: &[crate::protocol::Integration]|
343 +
         -> std::result::Result<Integrated, String> {
344 +
            let Some(l) = logged else {
340 345
                missing += 1;
341 -
                out.times
342 -
                    .insert(r.name.clone(), Err("it is not in the log yet".into()));
343 -
                continue;
346 +
                return Err("it is not in the log yet".into());
344 347
            };
345 348
            let fits = Entry::decode(&l.entry).is_ok_and(|e| {
346 -
                e.kind == "ref" && e.object == a.attest.hash() && e.repo == a.attest.repo
349 +
                e.kind == kind && e.object == a.attest.hash() && e.repo == a.attest.repo
347 350
            });
348 351
            let included = fits
349 352
                && tlog::check_inclusion(
…
6 unchanged lines
356 359
                .is_ok();
357 360
            if !included {
358 361
                out.notes.push(format!(
359 -
                    "{hub}'s log entry {} for {} does not prove its attestation is in the log",
360 -
                    l.index, r.name
362 +
                    "{hub}'s log entry {} for {name} does not prove its attestation is in the log",
363 +
                    l.index
361 364
                ));
362 365
            }
363 -
            let time = if !fits {
366 +
            if !fits {
364 367
                Err("its log entry is not its attestation's".to_string())
365 368
            } else if witnesses.is_empty() {
366 369
                Err(format!("{hub} names no witness"))
367 370
            } else {
368 -
                let cosigned: Vec<(&str, &[Hash])> = r
369 -
                    .integration
371 +
                let cosigned: Vec<(&str, &[Hash])> = integration
370 372
                    .iter()
371 373
                    .map(|i| (i.note.as_str(), i.proof.as_slice()))
372 374
                    .collect();
…
5 unchanged lines
378 380
                    &accepted.identity,
379 381
                    &witnesses,
380 382
                )
381 -
            };
382 -
            out.times.insert(r.name.clone(), time);
383 +
            }
384 +
        };
385 +
        let mut times = HashMap::new();
386 +
        for r in &resp.refs {
387 +
            let Some(a) = &r.attestation else { continue };
388 +
            let time = place(&r.name, "ref", a, r.logged.as_ref(), &r.integration);
389 +
            times.insert(r.name.clone(), time);
390 +
        }
391 +
        // Tags too (oxygen#129): a short-lived key's tag is checked at its
392 +
        // integrated time on every later fetch.
393 +
        let mut tag_times = HashMap::new();
394 +
        for t in &resp.tags {
395 +
            let Some(a) = &t.attestation else { continue };
396 +
            let name = format!("tags/{}", t.name);
397 +
            let time = place(&name, "tag", a, t.logged.as_ref(), &t.integration);
398 +
            tag_times.insert(t.name.clone(), time);
383 399
        }
400 +
        out.times = times;
401 +
        out.tag_times = tag_times;
384 402
        if missing > 0 {
385 403
            out.notes.push(format!(
386 404
                "{} of {hub}'s attestations {} not in its transparency log yet (logged within seconds of a push; checked on a later fetch)",
…
24 unchanged lines
411 429
    pub notes: Vec<String>,
412 430
    /// Each attested bookmark's integrated time, or why it has none.
413 431
    pub times: HashMap<String, std::result::Result<Integrated, String>>,
432 +
    /// Each attested tag's, by tag name.
433 +
    pub tag_times: HashMap<String, std::result::Result<Integrated, String>>,
414 434
}
415 435
416 436
impl LogCheck {
417 437
    /// Bookmark `name`'s integrated time, or why there is none.
418 438
    pub fn time_of(&self, name: &str) -> std::result::Result<&Integrated, String> {
439 +
        self.lookup(&self.times, name)
440 +
    }
441 +
442 +
    /// Tag `name`'s integrated time, or why there is none.
443 +
    pub fn tag_time_of(&self, name: &str) -> std::result::Result<&Integrated, String> {
444 +
        self.lookup(&self.tag_times, name)
445 +
    }
446 +
447 +
    fn lookup<'a>(
448 +
        &self,
449 +
        times: &'a HashMap<String, std::result::Result<Integrated, String>>,
450 +
        name: &str,
451 +
    ) -> std::result::Result<&'a Integrated, String> {
419 452
        if let Some(why) = &self.unchecked {
420 453
            return Err(format!("its host's log was not checked ({why})"));
421 454
        }
422 455
        if !self.kept {
423 456
            return Err("its host keeps no transparency log".into());
424 457
        }
425 -
        match self.times.get(name) {
458 +
        match times.get(name) {
426 459
            Some(Ok(i)) => Ok(i),
427 460
            Some(Err(why)) => Err(why.clone()),
428 461
            None => Err("it has no log entry".into()),
…
162 unchanged lines
modified crates/oxy-core/src/repo/walk.rs +3 −1
…
184 unchanged lines
185 185
    let mut mine = Listing::default();
186 186
    mine.dirs.push((rel.clone(), mtime));
187 187
    for (e, name) in entries.into_iter().zip(names) {
188 -
        if name == OXY_DIR {
188 +
        // `.git` is never content: a side-by-side Git repository
189 +
        // (`git.colocate`), or Git's own beside an import.
190 +
        if name == OXY_DIR || name.eq_ignore_ascii_case(".git") {
189 191
            continue;
190 192
        }
191 193
        let Ok(ft) = e.file_type() else { continue };
…
136 unchanged lines
modified crates/oxy-core/src/repo/workspace.rs +6 −2
…
200 unchanged lines
201 201
            return fail(format!("{} exists and is not empty", dir.display()));
202 202
        }
203 203
        fs::create_dir_all(dir)?;
204 -
        let dir = dir.canonicalize()?;
204 +
        let dir = crate::portable::canonical(dir)?;
205 205
        if dir == self.root || dir.starts_with(&self.root) || self.root.starts_with(&dir) {
206 206
            return fail("a workspace cannot be inside another workspace of the same repository");
207 207
        }
…
2 unchanged lines
210 210
            None => self.bookmark_heads()?,
211 211
        };
212 212
        let id = WorkspaceId::random();
213 -
        let wc = self.new_working_copy(heads.clone(), "");
213 +
        // On nothing, as the directory is, until the checkout: on `heads`,
214 +
        // the checkout's first snapshot found every file of the tree
215 +
        // deleted, rendering the whole tree to say so (195 s of a 467 s
216 +
        // `oxy workspace add` in the VS Code history, oxygen#137).
217 +
        let wc = self.new_working_copy(Vec::new(), "");
214 218
        let wc_hash = self.store.put_change(&wc)?;
215 219
        let (op_ref, op) = self.head_op()?;
216 220
        let mut view = op.view.clone();
…
141 unchanged lines
modified crates/oxy-core/src/state.rs +124 −1
…
749 unchanged lines
750 750
    }
751 751
}
752 752
753 +
/// How one conflict settles without a person ([`State::settle_plans`]).
754 +
#[derive(Clone, Debug, PartialEq, Eq)]
755 +
pub struct Settle {
756 +
    /// Its sides' text, as its markers show them.
757 +
    pub sides: Vec<Vec<u8>>,
758 +
    /// The text that takes the markers' place, side by side, in order.
759 +
    pub kept: Vec<Vec<u8>>,
760 +
    /// Its sides are identical, and one is kept.
761 +
    pub identical: bool,
762 +
}
763 +
753 764
/// See `State::conflict_regions`.
754 765
#[derive(Clone, Debug, Default, PartialEq, Eq)]
755 766
pub struct ConflictRegions {
…
1242 unchanged lines
1998 2009
            .collect()
1999 2010
    }
2000 2011
2012 +
    /// The nodes with replace records: opaque files, as their blobs go.
2013 +
    pub fn opaque_nodes(&self) -> Vec<NodeId> {
2014 +
        let mut nodes: Vec<NodeId> = self.replaces.keys().copied().collect();
2015 +
        nodes.sort();
2016 +
        nodes
2017 +
    }
2018 +
2001 2019
    /// Live blobs of an opaque node: every `new` that no later `Replace` retired.
2002 2020
    pub fn live_blobs(&self, node: &NodeId) -> Vec<ObjectRef> {
2003 2021
        let mut live: Vec<ObjectRef> = self
…
104 unchanged lines
2108 2126
        out
2109 2127
    }
2110 2128
2129 +
    /// A file graph materialized with each conflict's sides earliest change
2130 +
    /// first (oxygen#121), by the clock of the oldest change on a side, so
2131 +
    /// markers read in the order the work was done and `oxy resolve --both`
2132 +
    /// keeps that order.
2133 +
    fn materialize_ordered(&self, g: &FileGraph) -> Materialized {
2134 +
        let mut m = g.materialize(self);
2135 +
        let when = |h: &ChangeHash| match self.changes.get(h) {
2136 +
            Some(c) => (false, c.hlc, *h),
2137 +
            None => (true, Default::default(), *h),
2138 +
        };
2139 +
        for seg in &mut m.segments {
2140 +
            if let crate::graph::Segment::Conflict(sides) = seg {
2141 +
                sides.sort_by_cached_key(|s| s.iter().map(|a| when(&a.0)).min());
2142 +
            }
2143 +
        }
2144 +
        m
2145 +
    }
2146 +
2147 +
    /// For each conflict of a text node, in marker order (spec §9.5): the
2148 +
    /// text of its sides, earliest change first, when it is an adjacent
2149 +
    /// insertion that keeping both sides settles (oxygen#121); `None` for
2150 +
    /// the others.
2151 +
    pub fn both_sides(&self, node: &NodeId) -> Vec<Option<Vec<Vec<u8>>>> {
2152 +
        let Some(g) = self.graph(node) else {
2153 +
            return Vec::new();
2154 +
        };
2155 +
        let m = self.materialize_ordered(g);
2156 +
        let mut out = Vec::new();
2157 +
        for (i, seg) in m.segments.iter().enumerate() {
2158 +
            match seg {
2159 +
                crate::graph::Segment::Lines(_) => {}
2160 +
                crate::graph::Segment::Conflict(sides) if g.adjacent_insertion(&m, i, self) => {
2161 +
                    out.push(Some(
2162 +
                        sides
2163 +
                            .iter()
2164 +
                            .map(|s| {
2165 +
                                s.iter()
2166 +
                                    .flat_map(|a| g.content(a).unwrap_or_default().to_vec())
2167 +
                                    .collect()
2168 +
                            })
2169 +
                            .collect(),
2170 +
                    ));
2171 +
                }
2172 +
                _ => out.push(None),
2173 +
            }
2174 +
        }
2175 +
        out
2176 +
    }
2177 +
2178 +
    /// How each conflict of a text node settles without a person, in
2179 +
    /// marker order (spec §9.5); `None` for one that needs a person. A
2180 +
    /// conflict whose sides are identical keeps one of them, in any file
2181 +
    /// (oxygen#146): two bookmarks that resolved the same conflict the same
2182 +
    /// way agree. With `union` (the file is `merge=union`, oxygen#145), an
2183 +
    /// adjacent insertion keeps every side, earliest change first.
2184 +
    pub fn settle_plans(&self, node: &NodeId, union: bool) -> Vec<Option<Settle>> {
2185 +
        let Some(g) = self.graph(node) else {
2186 +
            return Vec::new();
2187 +
        };
2188 +
        let m = self.materialize_ordered(g);
2189 +
        let text = |s: &[AtomKey]| -> Vec<u8> {
2190 +
            s.iter()
2191 +
                .flat_map(|a| g.content(a).unwrap_or_default().to_vec())
2192 +
                .collect()
2193 +
        };
2194 +
        let mut out = Vec::new();
2195 +
        for (i, seg) in m.segments.iter().enumerate() {
2196 +
            match seg {
2197 +
                crate::graph::Segment::Lines(_) => {}
2198 +
                crate::graph::Segment::Conflict(sides) => {
2199 +
                    let texts: Vec<Vec<u8>> = sides.iter().map(|s| text(s)).collect();
2200 +
                    out.push(if texts.len() > 1 && texts.iter().all(|t| *t == texts[0]) {
2201 +
                        Some(Settle {
2202 +
                            kept: vec![texts[0].clone()],
2203 +
                            sides: texts,
2204 +
                            identical: true,
2205 +
                        })
2206 +
                    } else if union && g.adjacent_insertion(&m, i, self) {
2207 +
                        Some(Settle {
2208 +
                            kept: texts.clone(),
2209 +
                            sides: texts,
2210 +
                            identical: false,
2211 +
                        })
2212 +
                    } else {
2213 +
                        None
2214 +
                    });
2215 +
                }
2216 +
                _ => out.push(None),
2217 +
            }
2218 +
        }
2219 +
        out
2220 +
    }
2221 +
2111 2222
    pub fn render_file(&self, node: &NodeId) -> (Vec<u8>, Materialized) {
2112 2223
        let Some(g) = self.graph(node) else {
2113 2224
            return (Vec::new(), Materialized::default());
2114 2225
        };
2115 -
        let m = g.materialize(self);
2226 +
        let m = self.materialize_ordered(g);
2116 2227
        let bytes = g.render(&m, &|h| self.label(h));
2117 2228
        (bytes, m)
2118 2229
    }
…
164 unchanged lines
2283 2394
            return false;
2284 2395
        }
2285 2396
        let mut seen: crate::fasthash::FastSet<ChangeHash> = Default::default();
2397 +
        // A dependency is looked for as it is found, before the walk goes
2398 +
        // below it: most questions are about a direct one (spec §8.1 makes
2399 +
        // a deletion depend on the inserter of the line after it, which is
2400 +
        // what a render asks about), and a depth-first walk that only
2401 +
        // looked when it came back up went through thousands of changes
2402 +
        // first (oxygen#137: 4.4 billion steps rendering the VS Code tree).
2286 2403
        let mut stack: Vec<ChangeHash> = self
2287 2404
            .changes
2288 2405
            .get(a)
2289 2406
            .map(|m| m.deps.clone())
2290 2407
            .unwrap_or_default();
2408 +
        if stack.contains(b) {
2409 +
            return true;
2410 +
        }
2291 2411
        while let Some(c) = stack.pop() {
2292 2412
            if c == *b {
2293 2413
                return true;
…
2 unchanged lines
2296 2416
                continue;
2297 2417
            }
2298 2418
            if let Some(m) = self.changes.get(&c) {
2419 +
                if m.deps.contains(b) {
2420 +
                    return true;
2421 +
                }
2299 2422
                stack.extend(m.deps.iter().copied());
2300 2423
            }
2301 2424
        }
…
793 unchanged lines
modified crates/oxy-core/src/store.rs +50 −10
…
615 unchanged lines
616 616
        return None;
617 617
    }
618 618
    static STORES: OnceLock<Mutex<HashMap<PathBuf, Arc<MemoryStore>>>> = OnceLock::new();
619 -
    let key = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
619 +
    let key = crate::portable::canonical(dir).unwrap_or_else(|_| dir.to_path_buf());
620 620
    let mut map = STORES
621 621
        .get_or_init(Default::default)
622 622
        .lock()
…
1265 unchanged lines
1888 1888
            })
1889 1889
            .collect();
1890 1890
        let indexes: &[Arc<PackIndex>] = &packs;
1891 -
        let found: Vec<Option<Result<Object>>> = refs
1891 +
        // Read in (pack, offset) order, each thread a run of it, so a
1892 +
        // spinning disk sweeps rather than seeks (oxygen#137: a clone's
1893 +
        // checkpoint reads tens of thousands of objects across ~95 packs).
1894 +
        let order = read_order(&place);
1895 +
        let fetched: Vec<(usize, Option<Result<Object>>)> = order
1892 1896
            .par_iter()
1893 -
            .zip(place.par_iter())
1894 -
            .map(|(r, at)| match at {
1895 -
                Some((p, e)) => Some(indexes[*p].fetch(backend, r, *e)),
1896 -
                None => match get_optional(backend, &loose_key(r)) {
1897 -
                    Ok(Some(bytes)) => Some(parse_loose(&bytes, r)),
1898 -
                    Ok(None) => None,
1899 -
                    Err(e) => Some(Err(e)),
1900 -
                },
1897 +
            .map(|&i| {
1898 +
                let r = &refs[i];
1899 +
                let got = match &place[i] {
1900 +
                    Some((p, e)) => Some(indexes[*p].fetch(backend, r, *e)),
1901 +
                    None => match get_optional(backend, &loose_key(r)) {
1902 +
                        Ok(Some(bytes)) => Some(parse_loose(&bytes, r)),
1903 +
                        Ok(None) => None,
1904 +
                        Err(e) => Some(Err(e)),
1905 +
                    },
1906 +
                };
1907 +
                (i, got)
1901 1908
            })
1902 1909
            .collect();
1903 1910
        drop(packs);
1911 +
        let mut found: Vec<Option<Result<Object>>> = (0..refs.len()).map(|_| None).collect();
1912 +
        for (i, got) in fetched {
1913 +
            found[i] = got;
1914 +
        }
1904 1915
        found
1905 1916
            .into_iter()
1906 1917
            .zip(refs)
…
900 unchanged lines
2807 2818
}
2808 2819
2809 2820
/// A loose object's bytes, verified against `r`.
2821 +
/// The order to read objects in: packed ones by pack then offset, loose
2822 +
/// ones after, each group keeping the asked order otherwise.
2823 +
fn read_order(place: &[Option<(usize, PackEntry)>]) -> Vec<usize> {
2824 +
    let mut order: Vec<usize> = (0..place.len()).collect();
2825 +
    order.sort_by_key(|&i| match &place[i] {
2826 +
        Some((p, e)) => (0, *p, e.offset),
2827 +
        None => (1, 0, 0),
2828 +
    });
2829 +
    order
2830 +
}
2831 +
2810 2832
fn parse_loose(data: &[u8], r: &ObjectRef) -> Result<Object> {
2811 2833
    let nul = data
2812 2834
        .iter()
…
606 unchanged lines
3419 3441
    }
3420 3442
3421 3443
    #[test]
3444 +
    fn packed_objects_are_read_by_pack_then_offset() {
3445 +
        let at = |p: usize, offset: u64| {
3446 +
            Some((
3447 +
                p,
3448 +
                PackEntry {
3449 +
                    offset,
3450 +
                    stored_len: 1,
3451 +
                    raw_len: 1,
3452 +
                    ty: ObjectType::Chunk,
3453 +
                    flags: 0,
3454 +
                },
3455 +
            ))
3456 +
        };
3457 +
        let place = vec![at(1, 50), None, at(0, 900), at(1, 10), None, at(0, 5)];
3458 +
        assert_eq!(read_order(&place), [5, 2, 3, 0, 1, 4]);
3459 +
    }
3460 +
3461 +
    #[test]
3422 3462
    fn get_many_reads_loose_and_packed_objects_in_order() {
3423 3463
        let dir = tempfile::tempdir().unwrap();
3424 3464
        each_store(dir.path(), |which, s| {
…
1243 unchanged lines
modified crates/oxy-core/src/strata.rs +64 −3
…
293 unchanged lines
294 294
        .map(|d| d.as_millis() as u64)
295 295
}
296 296
297 +
/// Put `tmp` at `path` only if `path` is free, as `fs::hard_link` does
298 +
/// (`tmp` may remain). Android forbids apps hard links in their own data
299 +
/// (oxygen#141): there a create-only rename does the same, atomically.
300 +
fn place_new(tmp: &Path, path: &Path) -> io::Result<()> {
301 +
    match fs::hard_link(tmp, path) {
302 +
        Err(e) if e.kind() == io::ErrorKind::PermissionDenied => rename_new(tmp, path),
303 +
        r => r,
304 +
    }
305 +
}
306 +
307 +
#[cfg(any(target_os = "linux", target_os = "android"))]
308 +
fn rename_new(tmp: &Path, path: &Path) -> io::Result<()> {
309 +
    use std::ffi::CString;
310 +
    use std::os::unix::ffi::OsStrExt;
311 +
    let from = CString::new(tmp.as_os_str().as_bytes())?;
312 +
    let to = CString::new(path.as_os_str().as_bytes())?;
313 +
    let r = unsafe {
314 +
        libc::renameat2(
315 +
            libc::AT_FDCWD,
316 +
            from.as_ptr(),
317 +
            libc::AT_FDCWD,
318 +
            to.as_ptr(),
319 +
            libc::RENAME_NOREPLACE as _,
320 +
        )
321 +
    };
322 +
    if r == 0 {
323 +
        Ok(())
324 +
    } else {
325 +
        Err(io::Error::last_os_error())
326 +
    }
327 +
}
328 +
329 +
#[cfg(not(any(target_os = "linux", target_os = "android")))]
330 +
fn rename_new(tmp: &Path, path: &Path) -> io::Result<()> {
331 +
    if path.exists() {
332 +
        return Err(io::ErrorKind::AlreadyExists.into());
333 +
    }
334 +
    fs::rename(tmp, path)
335 +
}
336 +
297 337
impl LocalStore {
298 338
    /// Link `tmp` into `path`, create-only; the directory is synced and
299 339
    /// `tmp` removed either way. `Exists` when the name is taken.
300 340
    fn link_into_place(&self, key: &str, tmp: &Path, path: &Path) -> Result<()> {
301 -
        let linked = fs::hard_link(tmp, path);
341 +
        let linked = place_new(tmp, path);
302 342
        let _ = fs::remove_file(tmp);
303 343
        match linked {
304 344
            Ok(()) => {
…
78 unchanged lines
383 423
        crate::durable::step("rename loose objects")?;
384 424
        let mut dirs: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
385 425
        for (path, tmp, _) in &todo {
386 -
            let linked = fs::hard_link(tmp, path);
426 +
            let linked = place_new(tmp, path);
387 427
            let _ = fs::remove_file(tmp);
388 428
            match linked {
389 429
                Ok(()) => {
…
19 unchanged lines
409 449
            return Err(Error::Exists(key.to_string()));
410 450
        }
411 451
        self.ensure_dir(&path)?;
412 -
        match fs::hard_link(file, &path) {
452 +
        match place_new(file, &path) {
413 453
            Ok(()) => {
414 454
                let _ = fs::remove_file(file);
415 455
                if let Some(dir) = path.parent() {
…
671 unchanged lines
1087 1127
    use super::*;
1088 1128
    use std::sync::Arc;
1089 1129
1130 +
    /// Where hard links are refused (Android, oxygen#141), objects are
1131 +
    /// renamed into place, still never over a name that is taken.
1132 +
    #[test]
1133 +
    fn a_create_only_rename_never_replaces() {
1134 +
        let dir = tempfile::tempdir().unwrap();
1135 +
        let (a, b, to) = (
1136 +
            dir.path().join("a"),
1137 +
            dir.path().join("b"),
1138 +
            dir.path().join("to"),
1139 +
        );
1140 +
        fs::write(&a, b"first").unwrap();
1141 +
        fs::write(&b, b"second").unwrap();
1142 +
        rename_new(&a, &to).unwrap();
1143 +
        assert_eq!(
1144 +
            rename_new(&b, &to).unwrap_err().kind(),
1145 +
            io::ErrorKind::AlreadyExists
1146 +
        );
1147 +
        assert_eq!(fs::read(&to).unwrap(), b"first");
1148 +
        assert!(!a.exists() && b.exists());
1149 +
    }
1150 +
1090 1151
    #[test]
1091 1152
    fn keys_are_plain_relative_paths() {
1092 1153
        for ok in ["a", "packs/ab.pack", "a-b_c.d/e"] {
…
244 unchanged lines
modified crates/oxy-core/src/subrepo.rs +30 −0
…
25 unchanged lines
26 26
}
27 27
28 28
impl Pin {
29 +
    /// Whether two pins name the same state: the same repository at the
30 +
    /// same heads, in any order, or the same Git commit. The bookmark or
31 +
    /// branch each names does not count (#147): a subrepo at its pinned
32 +
    /// state is at it whatever bookmark it sits on.
33 +
    pub fn same_state(&self, other: &Pin) -> bool {
34 +
        match (self, other) {
35 +
            (
36 +
                Pin::Oxy {
37 +
                    repo: r1,
38 +
                    heads: h1,
39 +
                    ..
40 +
                },
41 +
                Pin::Oxy {
42 +
                    repo: r2,
43 +
                    heads: h2,
44 +
                    ..
45 +
                },
46 +
            ) => {
47 +
                let (mut a, mut b) = (h1.clone(), h2.clone());
48 +
                a.sort();
49 +
                a.dedup();
50 +
                b.sort();
51 +
                b.dedup();
52 +
                r1 == r2 && a == b
53 +
            }
54 +
            (Pin::Git { sha: a, .. }, Pin::Git { sha: b, .. }) => a == b,
55 +
            _ => false,
56 +
        }
57 +
    }
58 +
29 59
    pub fn encode(&self) -> Vec<u8> {
30 60
        let v = match self {
31 61
            Pin::Oxy {
…
278 unchanged lines
modified crates/oxy-server/src/lib.rs +112 −3
…
65 unchanged lines
66 66
    /// between, and the changes stored, for logs and cache invalidation.
67 67
    fn after_push(&self, _name: &str, _report: &oxy_core::repo::PushReport, _headers: &HeaderMap) {}
68 68
69 +
    /// Notes for the pusher on references this push moved: each
70 +
    /// `(reference, note)` is added to that accepted reference's result,
71 +
    /// which a client shows beside it (`Pushed origin/x - note`). For a
72 +
    /// push that did what the pusher may not expect, say moving the
73 +
    /// bookmark of a proposal that has landed (oxygenhub#329, #148). Called
74 +
    /// before [`Backend::after_push`], which sees the notes.
75 +
    fn push_notes(
76 +
        &self,
77 +
        _name: &str,
78 +
        _report: &oxy_core::repo::PushReport,
79 +
        _headers: &HeaderMap,
80 +
    ) -> Vec<(String, String)> {
81 +
        Vec::new()
82 +
    }
83 +
69 84
    /// Vet a push by what it adds before anything of it is stored or any
70 85
    /// reference moves (spec §16.1): `bundle` is the push as decoded, its
71 86
    /// changes with their lines, `updates` the references it would move.
…
20 unchanged lines
92 107
    /// without a log leaves it as it is.
93 108
    fn add_log(&self, _name: &str, _refs: &mut oxy_core::protocol::RefsResponse) {}
94 109
110 +
    /// Tailor a refs answer to the client asking, by its request headers
111 +
    /// (its `User-Agent` and the features it declares in `Oxy-Features`),
112 +
    /// after [`Backend::add_log`]. The default leaves out the tags a client
113 +
    /// that does not declare `certified-tags` would refuse
114 +
    /// ([`Repo::refs_for_client`], oxygen#131); a host that overrides it
115 +
    /// should apply that too ([`tailor_refs`]).
116 +
    fn adjust_refs(
117 +
        &self,
118 +
        _name: &str,
119 +
        repo: &Repo,
120 +
        refs: &mut oxy_core::protocol::RefsResponse,
121 +
        headers: &HeaderMap,
122 +
    ) {
123 +
        tailor_refs(repo, refs, headers);
124 +
    }
125 +
126 +
    /// Whether tag `tag` of `name` may be withdrawn (oxygenhub#301): called
127 +
    /// for each withdrawal a push carries, before the repository sees it.
128 +
    /// A host refuses one it depends on (a forge's release names the tag)
129 +
    /// with the reason, which is that withdrawal's result. The default
130 +
    /// allows every one; the policy is checked after, as for any push.
131 +
    fn vet_tag_withdrawal(&self, _name: &str, _tag: &str) -> Result<(), String> {
132 +
        Ok(())
133 +
    }
134 +
95 135
    /// The bookmark of `name` that pushes may never delete (its default),
96 136
    /// when the host keeps one apart from the repository's own config.
97 137
    fn default_bookmark(&self, _name: &str) -> Option<String> {
…
39 unchanged lines
137 177
    Fetch,
138 178
    Objects,
139 179
    Checkpoint,
180 +
    CheckpointIndex,
140 181
    Shallow,
141 182
    Push,
142 183
}
…
7 unchanged lines
150 191
            "fetch" => Endpoint::Fetch,
151 192
            "objects" => Endpoint::Objects,
152 193
            "checkpoint" => Endpoint::Checkpoint,
194 +
            "checkpoint-index" => Endpoint::CheckpointIndex,
153 195
            "shallow" => Endpoint::Shallow,
154 196
            "push" => Endpoint::Push,
155 197
            _ => return None,
…
8 unchanged lines
164 206
            Endpoint::Fetch => "fetch",
165 207
            Endpoint::Objects => "objects",
166 208
            Endpoint::Checkpoint => "checkpoint",
209 +
            Endpoint::CheckpointIndex => "checkpoint-index",
167 210
            Endpoint::Shallow => "shallow",
168 211
            Endpoint::Push => "push",
169 212
        }
…
110 unchanged lines
280 323
                    refs.default_bookmark = Some(d);
281 324
                }
282 325
                self.backend.add_log(name, &mut refs);
326 +
                self.backend.adjust_refs(name, &r, &mut refs, headers);
283 327
                ok(wire::encode_refs(r.id()?, &refs))
284 328
            }
285 329
            Endpoint::Negotiate => {
…
18 unchanged lines
304 348
                let heads = wire::decode_hashes(&body)?;
305 349
                let lock = self.lock_for(name);
306 350
                let _guard = lock.lock().unwrap();
307 -
                ok(wire::encode_bundle(&r.checkpoint_bundle_for(&heads)?))
351 +
                let bundle = r.checkpoint_bundle_for(&heads)?;
352 +
                let _t = oxy_core::timing::phase("serve: encode the checkpoint");
353 +
                ok(wire::encode_bundle(&bundle))
354 +
            }
355 +
            Endpoint::CheckpointIndex => {
356 +
                let heads = wire::decode_hashes(&body)?;
357 +
                let lock = self.lock_for(name);
358 +
                let _guard = lock.lock().unwrap();
359 +
                ok(wire::encode_bundle(&r.checkpoint_index_for(&heads)?))
308 360
            }
309 361
            Endpoint::Shallow => {
310 362
                let (heads, depth) = wire::decode_shallow(&body)?;
…
26 unchanged lines
337 389
                let (refused, updates): (Vec<_>, Vec<_>) = updates.into_iter().partition(|u| {
338 390
                    u.new_state.is_zero() && protected.as_deref() == Some(u.name.as_str())
339 391
                });
340 -
                let mut report = r.receive_push_report(bundle, updates, policy, tags)?;
392 +
                let mut kept_tags = Vec::new();
393 +
                let mut withdrawals_refused = Vec::new();
394 +
                for t in tags {
395 +
                    match t
396 +
                        .is_withdrawal()
397 +
                        .then(|| self.backend.vet_tag_withdrawal(name, &t.name))
398 +
                    {
399 +
                        Some(Err(reason)) => withdrawals_refused.push((t.name, reason)),
400 +
                        _ => kept_tags.push(t),
401 +
                    }
402 +
                }
403 +
                let mut report = r.receive_push_report(bundle, updates, policy, kept_tags)?;
341 404
                // The request named references, all refused here: it was no
342 405
                // push of objects alone, whatever the repository saw.
343 -
                if !refused.is_empty() {
406 +
                if !refused.is_empty() || !withdrawals_refused.is_empty() {
344 407
                    report
345 408
                        .results
346 409
                        .retain(|r| r.name != oxy_core::repo::OBJECTS_RESULT);
…
8 unchanged lines
355 418
                        ),
356 419
                    });
357 420
                }
421 +
                for (tag, reason) in withdrawals_refused {
422 +
                    report.results.push(oxy_core::protocol::RefResult {
423 +
                        name: format!("tags/{tag}"),
424 +
                        ok: false,
425 +
                        reason,
426 +
                    });
427 +
                }
358 428
                for r in report.results.iter().filter(|r| !r.ok) {
359 429
                    eprintln!(
360 430
                        "oxy-server: push to {name}: {} refused: {}",
361 431
                        r.name, r.reason
362 432
                    );
363 433
                }
434 +
                for (reference, note) in self.backend.push_notes(name, &report, headers) {
435 +
                    if let Some(r) = report
436 +
                        .results
437 +
                        .iter_mut()
438 +
                        .find(|r| r.ok && r.name == reference)
439 +
                    {
440 +
                        r.reason = if r.reason.is_empty() {
441 +
                            note
442 +
                        } else {
443 +
                            format!("{}; {note}", r.reason)
444 +
                        };
445 +
                    }
446 +
                }
364 447
                self.backend.after_push(name, &report, headers);
365 448
                ok(wire::encode_push_response(&report.results))
366 449
            }
…
50 unchanged lines
417 500
    Some(out)
418 501
}
419 502
503 +
/// The features a request declares in its `Oxy-Features` header, as the
504 +
/// header's text; `None` when it sent none (a client of the 0.5.0 era).
505 +
pub fn client_features(headers: &HeaderMap) -> Option<&str> {
506 +
    headers
507 +
        .get(oxy_core::protocol::FEATURES_HEADER)
508 +
        .and_then(|v| v.to_str().ok())
509 +
}
510 +
511 +
/// Tailor `refs` of `repo` to the client whose request carried `headers`:
512 +
/// the shared rule of [`Repo::refs_for_client`], which every host applies.
513 +
pub fn tailor_refs(repo: &Repo, refs: &mut oxy_core::protocol::RefsResponse, headers: &HeaderMap) {
514 +
    repo.refs_for_client(refs, client_features(headers));
515 +
}
516 +
420 517
/// The token of an `Authorization: Bearer` header, if any.
421 518
pub fn bearer_token(headers: &HeaderMap) -> Option<&str> {
422 519
    headers
…
16 unchanged lines
439 536
    }
440 537
}
441 538
539 +
/// The largest request `oxy serve` takes: a push part is about
540 +
/// `push.batch-bytes` (64 MiB), but one change can weigh more; OxygenHub's
541 +
/// default `push_body_limit` is the same.
542 +
pub const BODY_LIMIT: usize = if usize::BITS > 32 {
543 +
    4 << 30
544 +
} else {
545 +
    usize::MAX
546 +
};
547 +
442 548
/// The router of `oxy serve`: `/{repo}/oxy/v1/{endpoint}` over a [`DirBackend`].
443 549
pub fn router(cfg: ServerConfig) -> Router {
444 550
    let server = Server::new(DirBackend {
445 551
        root: cfg.root,
446 552
        tokens: cfg.tokens,
447 553
    });
554 +
    // Without a limit of its own a request takes axum's 2 MB, and a larger
555 +
    // push was cut off with no word in the log ("Broken pipe" at the client).
448 556
    Router::new()
449 557
        .route("/{repo}/oxy/v1/{endpoint}", any(dispatch))
558 +
        .layer(axum::extract::DefaultBodyLimit::max(BODY_LIMIT))
450 559
        .with_state(server)
451 560
}
452 561
…
29 unchanged lines
modified crates/oxy-server/tests/http.rs +373 −1
…
113 unchanged lines
114 114
        .contains("404"));
115 115
}
116 116
117 +
/// A push of more than axum's default 2 MB body is taken: `oxy serve`
118 +
/// sets its own limit, as OxygenHub does.
117 119
#[test]
120 +
fn a_push_of_more_than_two_megabytes_is_taken() {
121 +
    hermetic();
122 +
    let dir = tempfile::tempdir().unwrap();
123 +
    let root = dir.path();
124 +
    let served = root.join("served");
125 +
    Repo::init(&served.join("origin")).unwrap();
126 +
    let url = format!("{}/origin", start(&served, vec![]));
127 +
    let a = Repo::clone(&url, &root.join("a")).unwrap();
128 +
    let mut x: u64 = 0x9e3779b97f4a7c15;
129 +
    let noise: Vec<u8> = (0..3 << 20)
130 +
        .map(|_| {
131 +
            x ^= x << 13;
132 +
            x ^= x >> 7;
133 +
            x ^= x << 17;
134 +
            (x >> 24) as u8
135 +
        })
136 +
        .collect();
137 +
    write(&root.join("a"), "big.bin", &noise);
138 +
    let h = a.commit("three megabytes", &[]).unwrap();
139 +
    let r = a.push("origin", false).unwrap();
140 +
    assert!(r[0].ok, "{}", r[0].reason);
141 +
    let b = Repo::clone(&url, &root.join("b")).unwrap();
142 +
    assert_eq!(b.bookmark_heads().unwrap(), vec![h]);
143 +
    assert_eq!(fs::read(root.join("b/big.bin")).unwrap(), noise);
144 +
}
145 +
146 +
#[test]
118 147
fn objects_endpoint_serves_lazy_blobs() {
119 148
    hermetic();
120 149
    let dir = tempfile::tempdir().unwrap();
…
608 unchanged lines
729 758
        vec!["good".to_string()],
730 759
        "the parent's, to fall back on"
731 760
    );
761 +
    assert!(!options.fallback_on_401_only);
732 762
    write(&nested, "c.txt", b"child\n");
733 763
    child.commit("child work", &[]).unwrap();
734 764
    let pushed = child.push("origin", false).unwrap();
…
7 unchanged lines
742 772
        1
743 773
    );
744 774
745 -
    // Its own token, if wrong, stays wrong: what a checkout sets is its choice.
775 +
    // Its own token, if refused, falls back to this computer's sign-in,
776 +
    // never to the parent's (#155): stale too here, so the push fails and
777 +
    // says what to do.
746 778
    child.config.set("remote.origin.token", "wrong").unwrap();
747 779
    child.save_config().unwrap();
780 +
    let options = child.options_for_url(&format!("{base}/child"));
781 +
    assert_eq!(
782 +
        options.fallback_tokens,
783 +
        vec!["stale".to_string()],
784 +
        "the sign-in only"
785 +
    );
786 +
    assert!(
787 +
        options.fallback_on_401_only,
788 +
        "and only for a token the hub does not know: one refused for permission is not swapped"
789 +
    );
748 790
    write(&nested, "d.txt", b"more\n");
749 791
    child.commit("more", &[]).unwrap();
750 792
    let err = child.push("origin", false).err().unwrap().to_string();
751 793
    assert!(err.contains("401"), "{err}");
794 +
    let note = child.stale_token_note("origin", &err).expect("a note");
795 +
    assert!(
796 +
        note.contains("oxy auth login") && note.contains("oxy config --unset remote.origin.token"),
797 +
        "{note}"
798 +
    );
799 +
    assert!(child.stale_token_note("origin", "HTTP 500").is_none());
800 +
    // With a good sign-in, an expired checkout token no longer blocks.
801 +
    oxy_core::credentials::Credentials::at(&home)
802 +
        .save(&base, "good", Some("andy"), None)
803 +
        .unwrap();
804 +
    let child = Repo::open(&nested).unwrap().with_home(&home);
805 +
    let pushed = child.push("origin", false).unwrap();
806 +
    assert!(pushed[0].ok, "{}", pushed[0].reason);
752 807
}
753 808
754 809
/// A host refuses a push by what it adds (`Backend::vet_push`): the client
…
130 unchanged lines
885 940
        1
886 941
    );
887 942
}
943 +
944 +
/// A host adds a note to a reference a push moved (`Backend::push_notes`):
945 +
/// the client gets it as that reference's result, accepted as before,
946 +
/// and the host's `after_push` sees it too; a reference with no note
947 +
/// says nothing more.
948 +
#[test]
949 +
fn a_host_adds_a_note_to_an_accepted_reference() {
950 +
    use axum::extract::{Path as AxPath, State};
951 +
    use axum::http::{HeaderMap, Method};
952 +
    use axum::response::Response;
953 +
    use oxy_core::repo::PushReport;
954 +
    use oxy_server::{Access, Backend, DirBackend, Endpoint, Failure, Server};
955 +
    use std::sync::{Arc, Mutex};
956 +
957 +
    struct Noting(DirBackend, Arc<Mutex<Vec<String>>>);
958 +
    impl Backend for Noting {
959 +
        fn open(&self, name: &str) -> Result<Repo, Failure> {
960 +
            self.0.open(name)
961 +
        }
962 +
        fn authorize(
963 +
            &self,
964 +
            name: &str,
965 +
            access: Access,
966 +
            headers: &HeaderMap,
967 +
        ) -> Result<(), Failure> {
968 +
            self.0.authorize(name, access, headers)
969 +
        }
970 +
        fn push_notes(
971 +
            &self,
972 +
            _name: &str,
973 +
            report: &PushReport,
974 +
            _headers: &HeaderMap,
975 +
        ) -> Vec<(String, String)> {
976 +
            report
977 +
                .updated
978 +
                .iter()
979 +
                .filter(|u| u.name == "feature")
980 +
                .map(|u| (u.name.clone(), "its proposal #3 has landed".to_string()))
981 +
                .collect()
982 +
        }
983 +
        fn after_push(&self, _name: &str, report: &PushReport, _headers: &HeaderMap) {
984 +
            let mut seen = self.1.lock().unwrap();
985 +
            seen.extend(report.results.iter().map(|r| r.reason.clone()));
986 +
        }
987 +
    }
988 +
    async fn route(
989 +
        State(server): State<Arc<Server>>,
990 +
        AxPath((repo, endpoint)): AxPath<(String, String)>,
991 +
        method: Method,
992 +
        headers: HeaderMap,
993 +
        body: axum::body::Bytes,
994 +
    ) -> Response {
995 +
        let e = Endpoint::parse(&endpoint).unwrap();
996 +
        server.handle(repo, e, &method, &headers, body).await
997 +
    }
998 +
999 +
    hermetic();
1000 +
    let dir = tempfile::tempdir().unwrap();
1001 +
    let root = dir.path();
1002 +
    let served = root.join("served");
1003 +
    Repo::init_bare_unbound(&served.join("lib")).unwrap();
1004 +
    let seen = Arc::new(Mutex::new(Vec::new()));
1005 +
    let server = Server::new(Noting(
1006 +
        DirBackend {
1007 +
            root: served.clone(),
1008 +
            tokens: vec![],
1009 +
        },
1010 +
        seen.clone(),
1011 +
    ));
1012 +
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1013 +
    let port = listener.local_addr().unwrap().port();
1014 +
    std::thread::spawn(move || {
1015 +
        let rt = tokio::runtime::Builder::new_multi_thread()
1016 +
            .enable_all()
1017 +
            .build()
1018 +
            .unwrap();
1019 +
        rt.block_on(async move {
1020 +
            listener.set_nonblocking(true).unwrap();
1021 +
            let app = axum::Router::new()
1022 +
                .route("/{repo}/oxy/v1/{endpoint}", axum::routing::any(route))
1023 +
                .with_state(server);
1024 +
            axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app)
1025 +
                .await
1026 +
                .unwrap();
1027 +
        });
1028 +
    });
1029 +
    std::thread::sleep(std::time::Duration::from_millis(50));
1030 +
    let url = format!("http://127.0.0.1:{port}/lib");
1031 +
1032 +
    let work = root.join("work");
1033 +
    let mut local = Repo::init(&work).unwrap();
1034 +
    local.config.set("remote.origin.url", &url).unwrap();
1035 +
    local.save_config().unwrap();
1036 +
    write(&work, "a.txt", b"one\n");
1037 +
    local.snapshot("test").unwrap();
1038 +
    let base = local.commit("a", &[]).unwrap();
1039 +
    let results = local.push("origin", false).unwrap();
1040 +
    assert!(
1041 +
        results.iter().all(|r| r.ok && r.reason.is_empty()),
1042 +
        "{results:?}"
1043 +
    );
1044 +
    local.bookmark_create("feature", Some(&[base])).unwrap();
1045 +
    local.bookmark_switch("feature", false).unwrap();
1046 +
    write(&work, "a.txt", b"one\ntwo\n");
1047 +
    local.snapshot("test").unwrap();
1048 +
    local.commit("two", &[]).unwrap();
1049 +
    let results = local.push_bookmark("origin", "feature", false).unwrap();
1050 +
    let feature = results.iter().find(|r| r.name == "feature").unwrap();
1051 +
    assert!(feature.ok, "{results:?}");
1052 +
    assert_eq!(feature.reason, "its proposal #3 has landed");
1053 +
    assert!(seen
1054 +
        .lock()
1055 +
        .unwrap()
1056 +
        .iter()
1057 +
        .any(|r| r == "its proposal #3 has landed"));
1058 +
}
1059 +
1060 +
/// oxygen#131: oxy 0.5.0 and older authorize a tag only by a key the
1061 +
/// policy lists (or its delegate or predecessor) and fail the whole clone
1062 +
/// on any other, so a client that does not declare `certified-tags` is
1063 +
/// never sent a tag that only a short-lived key's certificate authorizes.
1064 +
/// It clones the rest without error; a current client still gets the tag;
1065 +
/// and an old client that holds such a tag pushes without trouble.
1066 +
#[test]
1067 +
fn a_client_without_certified_tags_never_sees_a_tag_only_a_certificate_authorizes() {
1068 +
    use axum::extract::{Path as AxPath, State};
1069 +
    use axum::http::{HeaderMap, Method, StatusCode};
1070 +
    use axum::response::{IntoResponse, Response};
1071 +
    use oxy_core::cert::{Certificate, SignedCertificate};
1072 +
    use oxy_core::identity::{Identity, Scope, SecretKey};
1073 +
    use oxy_core::protocol::{wire, RemoteOptions, FEATURES_HEADER};
1074 +
    use oxy_core::repo::IdentityScope;
1075 +
    use oxy_server::{Access, Backend, Endpoint, Failure, Server};
1076 +
    use std::sync::Arc;
1077 +
1078 +
    // A host that countersigns when it accepted a certified key's tag, as
1079 +
    // a hub does.
1080 +
    struct Host {
1081 +
        root: std::path::PathBuf,
1082 +
        acceptor: (Identity, SecretKey),
1083 +
    }
1084 +
    impl Backend for Host {
1085 +
        fn open(&self, name: &str) -> Result<Repo, Failure> {
1086 +
            Repo::open(&self.root.join(name))
1087 +
                .map(|r| r.with_acceptor(self.acceptor.0.clone(), self.acceptor.1.clone()))
1088 +
                .map_err(|e| Failure(StatusCode::NOT_FOUND, e.to_string()))
1089 +
        }
1090 +
        fn authorize(&self, _: &str, _: Access, _: &HeaderMap) -> Result<(), Failure> {
1091 +
            Ok(())
1092 +
        }
1093 +
    }
1094 +
    // `/new/...` as clients send it; `/old/...` without the features
1095 +
    // header, as oxy 0.5.0 sends it.
1096 +
    async fn route(
1097 +
        State(server): State<Arc<Server>>,
1098 +
        AxPath((era, repo, endpoint)): AxPath<(String, String, String)>,
1099 +
        method: Method,
1100 +
        mut headers: HeaderMap,
1101 +
        body: axum::body::Bytes,
1102 +
    ) -> Response {
1103 +
        if era == "old" {
1104 +
            headers.remove(FEATURES_HEADER);
1105 +
        }
1106 +
        match Endpoint::parse(&endpoint) {
1107 +
            Some(e) => server.handle(repo, e, &method, &headers, body).await,
1108 +
            None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(),
1109 +
        }
1110 +
    }
1111 +
1112 +
    hermetic();
1113 +
    let dir = tempfile::tempdir().unwrap();
1114 +
    let root = dir.path();
1115 +
    let served = root.join("served");
1116 +
    Repo::init(&served.join("origin")).unwrap();
1117 +
    let hub_sk = SecretKey::generate();
1118 +
    let hub = hub_sk.identity("hub", "[email protected]", 1);
1119 +
    let server = Server::new(Host {
1120 +
        root: served.clone(),
1121 +
        acceptor: (hub.clone(), hub_sk.clone()),
1122 +
    });
1123 +
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1124 +
    let port = listener.local_addr().unwrap().port();
1125 +
    std::thread::spawn(move || {
1126 +
        let rt = tokio::runtime::Builder::new_multi_thread()
1127 +
            .enable_all()
1128 +
            .build()
1129 +
            .unwrap();
1130 +
        rt.block_on(async move {
1131 +
            listener.set_nonblocking(true).unwrap();
1132 +
            let app = axum::Router::new()
1133 +
                .route("/{era}/{repo}/oxy/v1/{endpoint}", axum::routing::any(route))
1134 +
                .with_state(server);
1135 +
            axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app)
1136 +
                .await
1137 +
                .unwrap();
1138 +
        });
1139 +
    });
1140 +
    std::thread::sleep(std::time::Duration::from_millis(50));
1141 +
    let new_url = format!("http://127.0.0.1:{port}/new/origin");
1142 +
    let old_url = format!("http://127.0.0.1:{port}/old/origin");
1143 +
1144 +
    // Ada's policy grants the hub's certified agents; her own tag v0 is
1145 +
    // by a key the policy lists.
1146 +
    let mut a = Repo::clone(&new_url, &root.join("a"))
1147 +
        .unwrap()
1148 +
        .with_home(root.join("home-a"));
1149 +
    a.create_identity("Ada", "[email protected]", IdentityScope::User)
1150 +
        .unwrap();
1151 +
    write(&root.join("a"), "a.txt", b"a\n");
1152 +
    a.commit("a", &[]).unwrap();
1153 +
    a.policy_init().unwrap();
1154 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1155 +
    a.policy_grant_subject(
1156 +
        "agent:claude@ada",
1157 +
        hub.clone(),
1158 +
        Scope::CHANGES | Scope::REFS,
1159 +
        Some("*"),
1160 +
    )
1161 +
    .unwrap();
1162 +
    let heads = a.bookmark_heads().unwrap();
1163 +
    a.tag_create("v0", &heads, "zero").unwrap();
1164 +
    let r = a.push("origin", false).unwrap();
1165 +
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1166 +
1167 +
    // A keyless agent's tag v1, by a key the hub certified for ten minutes.
1168 +
    let now = oxy_core::objects::Hlc::now().wall_ms;
1169 +
    let mut c = Repo::clone(&new_url, &root.join("claude"))
1170 +
        .unwrap()
1171 +
        .with_home(root.join("home-claude"));
1172 +
    c.store().put_identity(&hub).unwrap();
1173 +
    let (_, id) = c
1174 +
        .create_identity("claude", "[email protected]", IdentityScope::User)
1175 +
        .unwrap();
1176 +
    let cert = SignedCertificate::sign(
1177 +
        Certificate {
1178 +
            key: id,
1179 +
            issuer: hub.hash(),
1180 +
            subject: "agent:claude@ada".into(),
1181 +
            claims: vec![],
1182 +
            scope: Scope::CHANGES | Scope::REFS,
1183 +
            not_before: now - 60_000,
1184 +
            expires: now + 600_000,
1185 +
            grant: None,
1186 +
        },
1187 +
        &hub,
1188 +
        &hub_sk,
1189 +
    );
1190 +
    c.store().put_certificate(&cert).unwrap();
1191 +
    c.tag_create("v1", &heads, "one").unwrap();
1192 +
    let r = c.push("origin", false).unwrap();
1193 +
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1194 +
1195 +
    // The refs answer: without the header, v1 is left out; with it, there.
1196 +
    let tags = |bytes: Vec<u8>| -> Vec<String> {
1197 +
        let (_, refs) = wire::decode_refs(&bytes).unwrap();
1198 +
        refs.tags.into_iter().map(|t| t.name).collect()
1199 +
    };
1200 +
    let bare =
1201 +
        oxy_core::protocol::http_get(&format!("{new_url}/oxy/v1/refs"), &RemoteOptions::default())
1202 +
            .unwrap();
1203 +
    assert_eq!(tags(bare), ["v0"], "no Oxy-Features header");
1204 +
    let remote = oxy_core::protocol::open_remote(&new_url, &RemoteOptions::default()).unwrap();
1205 +
    let mut current: Vec<String> = remote
1206 +
        .refs()
1207 +
        .unwrap()
1208 +
        .tags
1209 +
        .into_iter()
1210 +
        .map(|t| t.name)
1211 +
        .collect();
1212 +
    current.sort();
1213 +
    assert_eq!(
1214 +
        current,
1215 +
        ["v0", "v1"],
1216 +
        "a current client declares certified-tags"
1217 +
    );
1218 +
1219 +
    // An old-era client clones without error and sees no v1.
1220 +
    let old = Repo::clone(&old_url, &root.join("old")).unwrap();
1221 +
    assert_eq!(old.tag_heads("v0").unwrap(), Some(heads.clone()));
1222 +
    assert_eq!(old.tag_heads("v1").unwrap(), None);
1223 +
    let rep = old.fetch("origin").unwrap();
1224 +
    assert!(rep.skipped.is_empty(), "{rep:?}");
1225 +
1226 +
    // A current client adopts v1 by its certificate at the hub's acceptance.
1227 +
    let b = Repo::clone(&new_url, &root.join("b")).unwrap();
1228 +
    assert_eq!(b.tag_heads("v1").unwrap(), Some(heads.clone()));
1229 +
1230 +
    // A client holding v1 that pushes where v1 is hidden from it sends v1
1231 +
    // again: the host already has it, so it is taken as it is, and the
1232 +
    // bookmark moves.
1233 +
    let r = a.fetch("origin").unwrap();
1234 +
    assert!(r.skipped.is_empty(), "{r:?}");
1235 +
    assert_eq!(a.tag_heads("v1").unwrap(), Some(heads.clone()));
1236 +
    a.config.set("remote.origin.url", &old_url).unwrap();
1237 +
    write(&root.join("a"), "b.txt", b"b\n");
1238 +
    a.commit("b", &[]).unwrap();
1239 +
    let r = a.push("origin", false).unwrap();
1240 +
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1241 +
    assert!(r.iter().any(|x| x.name == "tags/v1"), "{r:?}");
1242 +
1243 +
    // `oxy serve` applies the same rule.
1244 +
    let base = start(&served, vec![]);
1245 +
    let bare = oxy_core::protocol::http_get(
1246 +
        &format!("{base}/origin/oxy/v1/refs"),
1247 +
        &RemoteOptions::default(),
1248 +
    )
1249 +
    .unwrap();
1250 +
    assert_eq!(tags(bare), ["v0"], "oxy serve, no header");
1251 +
    let remote =
1252 +
        oxy_core::protocol::open_remote(&format!("{base}/origin"), &RemoteOptions::default())
1253 +
            .unwrap();
1254 +
    assert_eq!(
1255 +
        remote.refs().unwrap().tags.len(),
1256 +
        2,
1257 +
        "oxy serve, current client"
1258 +
    );
1259 +
}
modified docs/cylinder.md +11 −0
…
395 unchanged lines
396 396
- **C1. The service boundary and artifact model.** Implement the
397 397
  `Artifact` CBOR object type and FastCDC chunk engine in `cylinder`;
398 398
  unify runner images with artifacts.
399 +
  *Started 2026-10-28 (oxygen#108):* `crates/cylinder` has the
400 +
  `Artifact` and `DirTree` encodings of §4.1 (canonical CBOR; a name is
401 +
  the BLAKE3 of the encoding; `env` and `labels` as sorted pairs, since
402 +
  the profile's maps take integer keys), packing a directory into the
403 +
  repository's own blobs and chunks, verifying and unpacking, and the
404 +
  roots garbage collection keeps; `oxy artifact pack | list | show |
405 +
  unpack | verify`. The artifact is an object type of its own
406 +
  (`artifact`, in oxy-core's `artifact` module, checked by `oxy fsck`),
407 +
  named by its object hash like every object; its tree is a blob, and
408 +
  `.oxy/artifacts/NAME` records the artifacts a repository keeps. Next:
409 +
  runner images as artifacts, then moving them between repositories (C2).
399 410
- **C2. Channels, provenance and consensus.** Flow publishes artifacts;
400 411
  signed channel move attestations; multi-party `reproduced(N)` consensus;
401 412
  revset predicates.
…
44 unchanged lines
modified docs/guide.md +32 −1
…
324 unchanged lines
325 325
name, appear as `<name>.oxy-conflict-<n>` candidates: keep the one you
326 326
want under the right name and delete the rest.
327 327
328 +
A binary file or a subrepo pin changed two ways has no markers to edit:
329 +
the checkout writes one version, and you say which content wins by
330 +
naming it. Put the bytes you want at the path (or move the subrepo to the
331 +
pin you want), then:
332 +
328 333
```
334 +
oxy resolve assets/logo.png deps/lib   # these paths' content settles them
335 +
```
336 +
337 +
Two changes that each add lines at the same place (two new imports, two
338 +
changelog entries) conflict too, since oxy will not guess their order.
339 +
`oxy status` says when a conflict is one of these; keep both sides, the
340 +
earlier change's first, with one command:
341 +
342 +
```
343 +
oxy resolve --both CHANGELOG.md   # --reverse for the later change's first
344 +
```
345 +
346 +
For files where the order never matters, such as a changelog, say so once
347 +
in `.oxyattributes` and checkouts keep both sides without asking:
348 +
349 +
```
350 +
CHANGELOG.md merge=union
351 +
```
352 +
353 +
```
329 354
oxy resolve      # which conflicts your edits resolve, which still have markers
330 355
oxy commit -m "Resolve retry vs refactor"
331 356
```
…
149 unchanged lines
481 506
oxy auth grant < token                # the agent's shell (its own OXY_HOME)
482 507
oxy config --user author.key    <YOUR KeyId>
483 508
oxy config --user author.signer keyless
509 +
oxy config --user author.for    <YOUR ACCOUNT>   # follow your newest identity
484 510
```
485 511
486 512
Each certificate lasts ten minutes and carries your grant, so the
…
2 unchanged lines
489 515
`oxy policy grant-subject agent:claude@YOU --issuer hub.identity --scope
490 516
changes` (add `refs` to let it push), with the hub's identity from
491 517
`/.well-known/oxyhub-key`. `oh agent list` shows your grants and `oh
492 -
agent revoke ID` stops one at once; there is no key to rotate.
518 +
agent revoke ID` stops one at once; there is no key to rotate. When you
519 +
rotate your own key (every delegation does), an agent with `author.for`
520 +
follows: the hub names your newest identity with each certificate, and
521 +
the agent authors as it from then on, rather than as the key you gave it.
522 +
A repository whose own config pins `author.key` keeps that pin.
493 523
494 524
```
495 525
oxy identity revoke                   # retire your own key
…
142 unchanged lines
638 668
| Key | Meaning |
639 669
|---|---|
640 670
| `author.name`, `author.email`, `author.key` | who you are; `key` is set by `identity create` |
671 +
| `author.for` | in a keyless agent's home, the account it acts for: it authors as that person's newest identity, as the hub names it (a repository's own `author.key` pin still wins) |
641 672
| `author.signer` | the key that signs instead of `author.key`: an agent's own key, which your identity lists as a delegate; or `keyless`, for fresh keys the hub certifies under the agent's grant (also `$OXY_SIGNER`) |
642 673
| `keyless.hub` | the hub that certifies keyless keys (default: the default remote's hub) |
643 674
| `core.sign` | `change` to sign every commit; otherwise seals at push |
…
83 unchanged lines
added docs/lazy-clone.md +195 −0
1 +
# Instant clone: a lazy working copy (oxygen#132)
2 +
3 +
Status: design, 2026-10-25. Nothing here is built yet beyond what it names
4 +
as existing. An experimental `oxy clone --lazy` (Linux) follows if this
5 +
measures well (§9).
6 +
7 +
## 1. The aim
8 +
9 +
`oxy clone URL` of any repository, however big, gives a working copy in
10 +
about a second: `ls`, `cd`, editors and builds see every file at once, a
11 +
file's bytes arrive the first time something reads it, and `status`,
12 +
`diff`, `commit` and `push` work at once and cost what the edits cost, not
13 +
what the repository weighs. Git's partial clone and VFS for Git do part of
14 +
this (VFS needs Windows and a special server); Oxygen can do all of it
15 +
with what it already has, because every object is content addressed and
16 +
a state's tree is known without its file contents.
17 +
18 +
## 2. What exists
19 +
20 +
- **Shallow clones** (spec §17): `oxy clone --depth 0` takes a checkpoint
21 +
  of the bookmark's heads and no history. The `checkpoint` message brings
22 +
  the checkpoint, its payload blob and **every file's graph blob** (spec
23 +
  §11, payload version 2; `Repo::checkpoint_bundle_for`,
24 +
  crates/oxy-core/src/repo/checkpoint.rs). A file graph holds the file's
25 +
  current text and its tombstones, so this is the whole state's text and
26 +
  more: for VS Code most of the clone's bytes.
27 +
- **Lazy blobs** (spec §13.5, §17): `--max-blob N` leaves opaque blobs
28 +
  larger than N to be fetched with `objects` on first read; `status` and
29 +
  `commit` never need them.
30 +
- **Sparse filters** (`--sparse PATH`): only those paths are materialized.
31 +
- **flow-casfs** (OxygenFlow, `crates/flow-casfs`): a read-only FUSE mount
32 +
  of a state from a local repository. The tree comes from the state
33 +
  index; a text file is rendered from its graph on first read or stat and
34 +
  cached on disk by graph key; an opaque file is its blob.
35 +
- **Content addressing everywhere**: an object fetched later is checked
36 +
  against the hash that named it, so a lazily fetched byte is as
37 +
  trustworthy as one cloned eagerly (spec §13).
38 +
39 +
## 3. The design
40 +
41 +
`oxy clone --lazy URL DIR`:
42 +
43 +
1. **Refs and the checkpoint skeleton.** Fetch `refs` (with the bookmark's
44 +
   attestation, checked against the policy as any clone does), then the
45 +
   checkpoint of its heads **without its graph blobs**: a `checkpoint`
46 +
   request with a new `graphs: false` field (additive; an older server
47 +
   ignores it and sends them, so the clone is merely not lazy). The
48 +
   payload's `files` entries name each graph blob by ObjectRef (graphs
49 +
   under 2 KiB are inline and come anyway), and its `tree` gives every
50 +
   path, mode and node. Clone writes the repository as a shallow one at
51 +
   depth 0 whose missing graph blobs are **lazy objects**, like lazy blobs
52 +
   today.
53 +
2. **The mount.** DIR is a FUSE mount (lower layer) of that state, built on
54 +
   flow-casfs: the directory tree is answered from the payload's tree at
55 +
   once; `stat` of a text file needs its length, so the payload gains
56 +
   (payload version 3, writer-optional) each file's rendered length, and
57 +
   until a server writes it the length comes with the graph on first
58 +
   `stat`. A read of a file whose graph is not stored fetches it with
59 +
   `objects` (hash checked), renders it, keeps both, and answers. Opaque
60 +
   files fetch their blob the same way. A small daemon (`oxy mount`,
61 +
   started by the clone, restarted by any oxy command that finds it gone)
62 +
   serves the mount; the repository itself stays in `DIR/.oxy`, which is
63 +
   a real directory beside the mount (bind or an overlay entry), so oxy
64 +
   never reads through FUSE.
65 +
3. **The writable layer.** Writes go to an upper directory
66 +
   (`.oxy/overlay/`): a written file is copied up whole on first write
67 +
   (its bytes fetched first if they were never read), a new file is
68 +
   created there, a removal is a whiteout, a rename is a whiteout plus a
69 +
   copy up. This is the ordinary overlay model, done in the daemon so it
70 +
   needs no privileges (kernel overlayfs over FUSE needs a user namespace
71 +
   and is not available everywhere).
72 +
4. **Status, diff and commit read the overlay only.** The upper layer is
73 +
   exactly the set of paths that may differ from the base state, so
74 +
   `status` lists it (comparing each copied-up file's bytes with the
75 +
   base rendering, which is stored, since copying up read it) and never
76 +
   walks the tree. `diff` and `commit` do the same, and the changes they
77 +
   build need only the touched files' graphs, all stored by then.
78 +
   `commit` clears the overlay entries it recorded: the mount's base
79 +
   state moves to the new heads.
80 +
5. **Fetch, pull, checkout.** A fetch brings changes as today (their
81 +
   blobs lazy). Moving the working copy to another state swaps the
82 +
   mount's base: the new state's tree is known from the changes and the
83 +
   stored checkpoint, the overlay is kept when its paths are unchanged
84 +
   between the two states (the `--carry` rule, §10.1), and graphs needed
85 +
   to render the new state are lazy again.
86 +
6. **Push.** As today: the pushed changes and the blobs they bring. A
87 +
   change built on a lazy base never needs the base's unread files.
88 +
89 +
## 4. Offline and failures
90 +
91 +
A file read once is kept, so it reads offline. Reading a file never read
92 +
while the hub cannot be reached fails with `EIO`, and the daemon logs the
93 +
path and the reason; `oxy status` says how many files are not local yet.
94 +
`oxy lazy fetch [PATH..]` brings them in ahead (a directory, the whole
95 +
state, or the files a build reads, as a background job), and `oxy lazy
96 +
materialize` turns the working copy into an ordinary one (unmounted, every
97 +
file written), after which nothing is lazy. A hash that does not match is
98 +
an error for that read, never a wrong byte.
99 +
100 +
## 5. Without FUSE
101 +
102 +
Where FUSE is not available (macOS without macFUSE, Windows, a container
103 +
without /dev/fuse), `--lazy` falls back to what exists: a depth-0 clone
104 +
with lazy blobs, and `--sparse` paths when given, saying so. Windows
105 +
ProjFS and macOS File Provider are later backends behind the same daemon
106 +
interface (lookup, read, write-through to the upper layer).
107 +
108 +
## 6. The protocol
109 +
110 +
- `checkpoint` gains `graphs: false`: leave out graph blobs (and their
111 +
  chunks) the payload names by reference.
112 +
- `objects` already fetches any object by hash, with a blob's chunks; it
113 +
  needs batching limits only (as `push.batch-bytes` bounds pushes).
114 +
- Optional later: `render` by graph key (the rendered bytes rather than
115 +
  the graph with its tombstones), smaller for long-lived files; a client
116 +
  checks it by rendering the graph when it has it, so it is an
117 +
  optimization a client may refuse.
118 +
- A host should keep `objects` cheap: it is now on the read path of
119 +
  editors and builds. OxygenHub's state cache and bucket stores (Strata)
120 +
  already serve objects by hash.
121 +
122 +
## 7. Trust
123 +
124 +
Nothing is weaker than an eager clone. The clone checks the bookmark's
125 +
attestation and the checkpoint against its heads (a checkpoint is valid
126 +
only if it reproduces from its heads, spec §11, which a lazy client cannot
127 +
check without the graphs: it trusts the attested heads and checks each
128 +
graph it fetches against the checkpoint's reference). `oxy verify` on a
129 +
lazy clone checks what is stored and says how much is not.
130 +
131 +
## 8. Not in scope
132 +
133 +
History (`log`, `blame` beyond the checkpoint) on a lazy clone fetches
134 +
changes on demand later (`oxy deepen` exists); the first version answers
135 +
`log` from the checkpoint's change list as a shallow clone does.
136 +
137 +
## 9. Measurements
138 +
139 +
On Earth (4 cores, SSD), 2026-10-25, the VS Code history
140 +
(`~/bench/vscode-full9.oxy`, 166k changes; `~/bench/vscode.git` for Git)
141 +
served over loopback by `oxy serve` and `git daemon`, oxy at main
142 +
(0.5.1 + #137), Git 2.53; one run each, a fresh directory, the server
143 +
warm (its checkpoint of the heads already made). Seconds:
144 +
145 +
| Step | git clone | git clone --filter=blob:none | oxy clone --depth 0 | oxy clone --depth 0 --max-blob 0 |
146 +
|---|---|---|---|---|
147 +
| clone | 91.4 | 30.5 | 457.9 | 849.0 |
148 +
| `ls -R \| wc -l` | 0.11 | 0.11 | 0.54 | 0.11 |
149 +
| read `src/vs/code/electron-main/main.ts` | 0.01 | 0.01 | 0.01 | 0.01 |
150 +
| append a line, `status` | 0.18 | 0.72 | 8.07 | 8.50 |
151 +
| `diff` | 0.10 | 0.03 | 0.23 | 0.23 |
152 +
| `commit` | 0.12 | 0.05 | 0.98 | 0.92 |
153 +
| disk after | 1.7 GB | 693 MB | 977 MB | 979 MB |
154 +
155 +
Before #137 the depth-0 clone failed after 11 minutes (the state's binary
156 +
files' blobs came with nothing) and, before that fix's other half, wrote
157 +
the checkpoint object by object: stopped after 38 minutes with 57,498
158 +
loose objects. The full `oxy clone` (12 GB of history) was not run
159 +
tonight.
160 +
161 +
What this says:
162 +
163 +
- A depth-0 clone is five times slower than Git's full clone and fifteen
164 +
  times slower than its blobless one. Timed on both sides (`OXY_TIMINGS=1`,
165 +
  oxygen#137): the server spent 375 s gathering the checkpoint's 57,000
166 +
  graph blobs and chunks one read at a time (239 s once read in parallel),
167 +
  19 s encoding one 2 GB answer, and the client 120 s on checkout, 111 s
168 +
  of it rendering 19,545 files on one core. Depth 0 also took the 33,835
169 +
  head changes, which it no longer does (spec §17), though that was not
170 +
  where the time went. A lazy clone avoids all three: no graph blobs move
171 +
  at clone time, and nothing is rendered until read.
172 +
- `--max-blob 0` without a mount is slower, not faster: the checkout then
173 +
  fetches every binary file's blob one request at a time. Lazy contents
174 +
  only pay with a mount that reads them when asked.
175 +
- The first `status` after a clone is 8 s (it builds the manifest); Git's
176 +
  is 0.2 s. A lazy clone's `status` from the overlay (§3.4) would not
177 +
  depend on the tree at all.
178 +
179 +
So the prototype's bar: clone in seconds (refs, the checkpoint skeleton
180 +
without graphs, the mount), `ls -R` at once from the payload's tree, a
181 +
first read in tens of milliseconds, `status` in milliseconds; and,
182 +
whatever happens to `--lazy`, the depth-0 clone itself should come down
183 +
to Git's order (streamed checkpoint in parts, rendering on every core).
184 +
185 +
## 10. The prototype (L2)
186 +
187 +
1. `checkpoint` with `graphs: false` in oxy-core and oxy-server, and a
188 +
   repository flag for lazy graph objects (fetch on load, like lazy blobs).
189 +
2. `oxy mount` (Linux, `fuser`, as flow-casfs) with the upper layer, in a
190 +
   new crate `oxy-mount` beside the CLI, behind a cargo feature so builds
191 +
   without FUSE are unaffected.
192 +
3. `status`/`diff`/`commit` from the overlay when the working copy is
193 +
   mounted.
194 +
4. `oxy clone --lazy`, `oxy lazy fetch`, `oxy lazy materialize`.
195 +
5. Measured as in §9; numbers on oxygen#132.
added docs/mcp.md +78 −0
1 +
# oxy mcp: Oxygen for AI agents
2 +
3 +
`oxy mcp` serves one repository to an AI agent over the
4 +
[Model Context Protocol](https://modelcontextprotocol.io) (MCP), on stdio
5 +
(oxygen#159). The agent calls typed tools (`status`, `log`, `commit`, …)
6 +
and gets JSON back, instead of running `oxy` in a shell and reading its
7 +
output.
8 +
9 +
## Who changes are signed as
10 +
11 +
The server signs as whatever identity its environment configures, exactly
12 +
as `oxy` would: it runs each tool as this same `oxy` binary, in the
13 +
repository, with the server's environment. Start it from an agent home and
14 +
every change the agent makes is sealed **via the agent**, with no
15 +
`OXY_HOME` to remember on each command:
16 +
17 +
```sh
18 +
OXY_HOME=~/.oxy-agents/claude oxy mcp ~/development/myproject
19 +
```
20 +
21 +
The server says who it signs as in its `initialize` reply (the first line
22 +
of `oxy identity show`), and the `identity` tool shows the rest. Without an
23 +
identity, reading works and committing is refused with oxy's own message.
24 +
25 +
## Tools
26 +
27 +
Each tool returns the command's `--json` output as structured content (an
28 +
array comes wrapped as `{"result": […]}`) and as text. A failed command is a
29 +
tool result with `isError: true` and oxy's error, so the agent sees why.
30 +
31 +
| Tool | Runs | Arguments |
32 +
|---|---|---|
33 +
| `status` | `oxy status --json` | |
34 +
| `diff` | `oxy diff [CHANGE] --json` | `change` |
35 +
| `log` | `oxy log --json` | `revset`, `path`, `limit` (default 20, at most 1000) |
36 +
| `show` | `oxy show CHANGE --json` | `change` (default `@`), `stat` |
37 +
| `cat` | `oxy cat PATH [-r CHANGE]` | `path`, `change` |
38 +
| `blame` | `oxy blame PATH --json` | `path` |
39 +
| `bookmarks` | `oxy bookmark list --json` | `all` |
40 +
| `tags` | `oxy tag list --json` | |
41 +
| `conflicts` | `oxy resolve --json` (report only) | |
42 +
| `op_log` | `oxy op log --json` | `limit` |
43 +
| `identity` | `oxy identity show` | |
44 +
| `commit` | `oxy commit -m MESSAGE PATHS…` | `message`, and `paths` or `all: true` |
45 +
| `amend` | `oxy amend [-m] PATHS…` | `paths` or `all: true`, `message` |
46 +
| `describe` | `oxy describe [CHANGE] -m MESSAGE` | `message`, `change` |
47 +
| `fetch`, `pull` | `oxy fetch\|pull [REMOTE] --json` | `remote` |
48 +
| `push` | `oxy push [REMOTE] --json` | `remote`, `bookmark`, `force` |
49 +
| `tlog_verify` | `oxy tlog verify --json` | |
50 +
| `undo` | `oxy undo` | only with `--allow-destructive` |
51 +
52 +
## Safety
53 +
54 +
- **Commits are deliberate.** `commit` and `amend` take the paths to
55 +
  include, or `all: true`; never neither.
56 +
- **Nothing destructive by default.** `undo` is not offered, and `push`
57 +
  refuses `force`, unless the server is started with `--allow-destructive`.
58 +
  Tag withdrawal, bookmark deletion and history rewriting are not tools at
59 +
  all. Each tool carries MCP's `readOnlyHint` and `destructiveHint`.
60 +
- **Values are never flags.** Arguments are passed as `--flag=value` and
61 +
  after `--`, so a path or message starting with `-` stays a value.
62 +
- **Your policy still applies.** Pushes go through the remote's checks and
63 +
  the repository's trust policy like any other; an agent's delegation
64 +
  scope limits what its seals may do.
65 +
- **Output is bounded.** A tool's text stops at 256 KiB, saying to narrow
66 +
  the request.
67 +
68 +
## Adding it to an agent
69 +
70 +
Claude Code, for one project (run in the project's root):
71 +
72 +
```sh
73 +
claude mcp add oxy -- env OXY_HOME=$HOME/.oxy-agents/claude oxy mcp
74 +
```
75 +
76 +
Any MCP client that starts stdio servers takes the same command: `oxy mcp
77 +
[DIR] [--allow-destructive]`, with `OXY_HOME` set for an agent's identity.
78 +
`DIR` defaults to the current directory and must be a repository's root.
modified docs/protocol.md +26 −5
…
68 unchanged lines
69 69
  with `400`. Clients send the header on every request when they have an
70 70
  id; servers ignore it on bound repositories.
71 71
72 +
Client capabilities: the reference client sends `User-Agent:
73 +
oxy/<version>` and `Oxy-Features: <feature>[,<feature>…]` on every request,
74 +
a comma-separated list (blanks around entries and case are ignored, unknown
75 +
entries too) of what it understands beyond the oxy 0.5.0 feature set.
76 +
Servers may tailor their answers to the features a request declares, and a
77 +
request without the header is a client of that era. Features so far:
78 +
79 +
- `certified-tags`: the client authorizes a tag signed by a short-lived,
80 +
  certified key by a subject grant, its certificate checked at the time
81 +
  the host accepted the tag (spec §14.7; oxygen#129). A client without it
82 +
  authorizes a tag only by a key the policy's rule lists, a delegate of one
83 +
  or a rotation predecessor, and fails the whole clone or fetch on any
84 +
  other. So under a policy, a server leaves out of a `refs` answer to such
85 +
  a client every tag whose attestation's signer none of those authorizes
86 +
  (`Repo::refs_for_client`, applied by `oxy serve` and by default by every
87 +
  `oxy_server` host): that client sees the repository as if those tags did
88 +
  not exist. If it holds one of them already and pushes, it sends the tag
89 +
  again (it is not in the answer); the server already has it in the same
90 +
  state and takes it as it is.
91 +
72 92
Embedding: `oxy_server::Server` answers any endpoint for a repository name
73 93
a host's `Backend` resolves and authorises (`open`, `authorize`, and an
74 -
`after_push` hook, and `vet_push`, with which a host refuses a push by
94 +
`after_push` hook, `adjust_refs`, which tailors a refs answer to the
95 +
request's headers and by default applies the rule above, and `vet_push`, with which a host refuses a push by
75 96
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
97 +
text, several lines perhaps, the client shows as it is; spec §16.4; and `push_notes`, with which a host adds a note to a reference the push moved, which the client shows in place of "updated", #148), so a hub with `owner/name` paths and its own accounts
77 98
routes `/{owner}/{name}/oxy/v1/{endpoint}` to `Server::handle`; `oxy serve`
78 99
is `DirBackend` on that same server.
79 100
…
11 unchanged lines
91 112
| Signed attestation | `[attest bytes, [Signature]]` |
92 113
| Signed policy | `[policy bytes, [Signature]]` (also the policy proposal file format) |
93 114
| Capabilities | `{0: [protocol versions], 1: repo id bytes(16), 2: [feature names]}`; version `1`; features currently `["seal"]` |
94 -
| Refs response | `{0: [ref], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}` |
115 +
| Refs response | `{0: [ref], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy], 8: [tag]}`; key 8 (optional, only when there are any; oxygenhub#301) lists the tags withdrawn there, each a tag whose attestation is its `untag` attestation and whose key 4 is that attestation's acceptance when a short-lived key signed it; older readers ignore it |
95 116
| ref | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null, 5: signed acceptance}`; the attestation is present only when it describes the current state; `last serial` is the highest attestation serial ever recorded for the name; key 5 (optional, only when there is one) is the host's `accepted` attestation of that attestation (spec §14.7); key 6 (optional) is `[entry number, entry bytes, [proof hash]]`, the attestation's inclusion proof in the host's transparency log against the response's key 6, the latest checkpoint as a signed note (spec §14.8) |
96 -
| tag | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` |
117 +
| tag | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: signed acceptance, 5: log place, 6: [cosigned checkpoint]}`; keys 4 to 6 (optional, each only when there is one; oxygen#129) are for the tag's attestation what a ref's keys 5 to 7 are for its own: the issuer's `accepted` attestation when a short-lived key signed the tag (spec §14.7), which a fetch checks the certificate at, and its place in the host's transparency log with the cosigned checkpoints that first held it (spec §14.8); a push carries key 4 when the client has one; older readers ignore them |
97 118
| Negotiate request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order (no change before its dependencies) |
98 119
| Fetch request | `{0: [hashes], 1: max blob size or null}`; response a Bundle whose blobs above the size (and their chunks) are omitted |
99 120
| Objects request | `{0: [refs]}`; response a Bundle carrying only objects; a blob is accompanied by its chunks |
100 121
| Checkpoint request | `{0: [heads]}`; response a Bundle of objects: the checkpoint of exactly those heads, its payload blob, and the blob's chunks; the server computes one when it has none for those heads |
101 122
| Shallow request | `{0: [heads], 1: depth}`; response `{0: [hashes]}`: the changes within `depth` dependency steps of the heads, dependencies first |
102 123
| Bundle | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}` |
103 -
| Push request | `{0: Bundle, 1: [update], 2: signed policy or null, 3: [tag]}` |
124 +
| Push request | `{0: Bundle, 1: [update], 2: signed policy or null, 3: [tag]}`; a tag whose attestation is an `untag` attestation withdraws that tag (oxygenhub#301), reported as `tags/<name>`; an older server refuses it as an attestation of the wrong kind |
104 125
| update | `{0: name, 1: old state ref, 2: new state ref, 3: force (bool), 4: signed attestation or null}` |
105 126
| Push response | `{0: [{0: name, 1: ok (bool), 2: reason (text)}]}`; a result named `policy` reports the policy update, `tags/<name>` each tag |
106 127
…
101 unchanged lines
modified docs/spec.md +27 −17
…
365 unchanged lines
366 366
367 367
Two live `Replace` edits with the same `old` and different `new` are a **replace conflict**. The state exposes both candidate blobs.
368 368
369 -
Which blobs are live: a replace record is superseded by a later replacement of its blob, a record whose `old` is its `new` and whose change depends on its change (§8.1 makes every replacement depend on the change that made its `old` current). Replacements that do not know each other both stay live, and a file reverted to an earlier blob keeps it. A replace conflict is **resolved** by a `kind = resolution` change with one `Replace` from each live blob to the chosen one, depending on every change that made a candidate live, so each is superseded and one blob remains. A working-copy snapshot never settles a replace conflict on its own (a checkout writes one candidate at the path); a tool that knows the user's choice, such as a forge's resolver, builds the resolution. A replacement recorded without that dependency (before Draft 0.8i) supersedes every record with its `new` that does not come after it, the rule until then.
369 +
Which blobs are live: a replace record is superseded by a later replacement of its blob, a record whose `old` is its `new` and whose change depends on its change (§8.1 makes every replacement depend on the change that made its `old` current). Replacements that do not know each other both stay live, and a file reverted to an earlier blob keeps it. A replace conflict is **resolved** by a `kind = resolution` change with one `Replace` from each live blob to the chosen one, depending on every change that made a candidate live, so each is superseded and one blob remains. A working-copy snapshot never settles a replace conflict on its own (a checkout writes one candidate at the path); a tool that knows the user's choice, such as a forge's resolver or `oxy resolve PATH` (which takes the working copy's content at the path), builds the resolution. A replacement recorded without that dependency (before Draft 0.8i) supersedes every record with its `new` that does not come after it, the rule until then.
370 370
371 371
### 9.4 Properties
372 372
…
13 unchanged lines
386 386
>>>>>>> oxy conflict 1 of 2 end
387 387
```
388 388
389 -
The short identifiers are ChangeId prefixes. Delete/insert conflicts render the deleted context as a `-------` section. Tree conflicts are materialized by writing each candidate with a suffix `.oxy-conflict-<n>` and are listed by `oxy status`. Replace conflicts materialize as `<name>.oxy-conflict-<n>` files.
389 +
The short identifiers are ChangeId prefixes. A conflict's sides are written earliest first, by the clock (§6.4) of the oldest change on each side, then by ChangeHash. Delete/insert conflicts render the deleted context as a `-------` section. Tree conflicts are materialized by writing each candidate with a suffix `.oxy-conflict-<n>` and are listed by `oxy status`. Replace conflicts materialize as `<name>.oxy-conflict-<n>` files.
390 390
391 391
`oxy status` MUST list every conflict, MUST exit with status 2 when any conflict exists in the working-copy state, MUST exit with status 3 when only workspace conflicts (§9.7) exist, and MUST support `--json` output enumerating conflicts by node, kind, and contributing ChangeIds. The JSON object has `bookmark`, `working_copy` (ChangeId), `heads`, `clean`, `conflicted`, `paths` (`[{path, status}]`) and `conflicts`: for a text or replace conflict `{kind, path, node, changes: [ChangeId], resolved: true | false | "markers"}`, for a tree conflict `{kind: name | multi-placement | orphan | cycle | mode, nodes, path?}`.
392 392
393 +
An **adjacent insertion** is an order conflict whose sides only add lines between two lines that were next to each other: no line between them was deleted by a change that not every side builds on (two changes that each replaced the same line conflict the same way, and are not one). Keeping every side settles it without losing or repeating a line, though either order can be wrong for code, so it is never done silently: `oxy resolve --both [PATH]..` replaces the markers of each adjacent insertion in the working copy with its sides, earliest first (`--reverse`: latest first), and leaves the markers of any other conflict, or of one whose lines between the markers were edited. `oxy status` says which conflicts qualify. A file whose `.oxyattributes` say `merge=union` (§19; changelogs, lists where order does not matter) needs no command: a checkout settles its adjacent insertions the same way at once, and the next commit records the resolution. The state is still conflicted until then; nothing about what a conflict is changes. A conflict whose sides are **identical**, byte for byte, settles the same way in any file, with one side kept: two bookmarks that resolved the same conflict to the same text, then merged, agree (oxygen#146; Git merges identical changes cleanly too). A host landing a proposal settles both kinds, and only them, as a checkout does.
394 +
393 395
### 9.6 Resolution
394 396
395 397
A **resolution** is a change with `kind = resolution` whose deletions and insertions leave the affected graphs totally ordered and the tree conflict-free. `oxy resolve` computes it from the edited working copy by parsing the markers; a resolution MAY also be authored by any tool that can produce a change. Because a resolution depends on all contributing changes (§8.1), any peer that fetches it necessarily has the conflict it resolves.
…
18 unchanged lines
414 416
```
415 417
bookmarks/<name>                 local bookmarks
416 418
remotes/<remote>/bookmarks/<name>  last-seen remote bookmarks
417 -
tags/<name>                      tags (immutable once created)
419 +
tags/<name>                      tags (immutable once created; may be withdrawn)
418 420
policy                           the trust policy head (§14.5)
419 421
```
420 422
…
13 unchanged lines
434 436
435 437
Every published bookmark update is accompanied by an attestation (§14.4) of kind `ref` that binds the repository ID, the reference name, the previous state, the new state, and a per-reference monotonically increasing `serial`. Servers MUST store the latest attestation per reference and serve it with the reference. Clients MUST verify the attestation against policy before updating a `remotes/` reference and MUST reject an attestation whose `serial` is lower than one they have already recorded for that reference (rollback protection). Clients SHOULD warn when the newest attestation's `hlc.wall_ms` is older than `remote.max-age` (default 30 days) and the remote claims no update. A fetched bookmark whose attestation cannot be verified because its signer's identity is unknown to the client or its signature does not verify is skipped, not fatal, when it is not the current bookmark: its `remotes/` reference stays as it was, the client warns naming it and why, and the other bookmarks are fetched (oxygenhub#266). For the current bookmark (the one a `pull` takes, or a `clone` checks out) the fetch, pull or clone still fails, and an attestation that is missing under a policy, unauthorized, rolled back or not describing the update fails the fetch as before.
436 438
437 -
Tags are attestations of kind `tag` and are never updated; a server MUST reject a second attestation for an existing tag name, and any receiver MUST refuse a tag whose name it already holds with a different state. `oxy tag create NAME [CHANGE..]` tags the named state (default the bookmark's heads); tags travel with `refs` and `push` (§16.3), and a policy rule for `tags/<name>` governs who may create them.
439 +
Tags are attestations of kind `tag` and are never updated; a server MUST reject a second attestation for an existing tag name, and any receiver MUST refuse a tag whose name it already holds with a different state. `oxy tag create NAME [CHANGE..]` tags the named state (default the bookmark's heads); tags travel with `refs` and `push` (§16.3), and a policy rule for `tags/<name>` governs who may create them. A tag signed by a short-lived key (§14.7), as a keyless agent's is, is authorized by a subject grant like a bookmark update: on a push at the receiver's clock, on a fetch at a time its signer did not choose (§14.7, *Acceptance*). The push carries the tag signer's identity and certificate as it does the bookmark signer's, since the tag may have been made by an earlier process's key (oxygen#129).
440 +
441 +
**Withdrawing a tag** (oxygenhub#301). A tag is never moved, but it can be withdrawn: `oxy tag delete NAME [-r REASON]` records an attestation of kind `untag`, `{0: name, 1: state, 2: reason}`, naming the tag's state and signed by the repository's key, and the tag leaves the view; its `tag` attestation stays beside the withdrawal. The next `oxy push` sends the withdrawal to a remote that lists the tag at that state (`oxy push REMOTE --delete tags/NAME` withdraws it here and there at once), in the push's tags as a tag whose attestation is the withdrawal. A receiver adopts a withdrawal only of a tag it holds at the state named; under a policy the signer MUST be a key the rule for `tags/<name>` authorizes to create the tag (a listed key, its delegate, a rotation predecessor, or a short-lived key under a subject grant, at the receiver's clock on a push and at its issuer's acceptance on a fetch), and an unsigned withdrawal is refused. A server lists the tags withdrawn there in its `refs` answer (`withdrawn`), and a fetch withdraws a tag this repository holds at the same state when the withdrawal verifies, noting it, and otherwise keeps the tag, saying why. A withdrawn name is never used again: `tag create`, a push and a fetch refuse or skip a tag of that name, so a tag name never names two states. A host MAY refuse to withdraw a tag it depends on (the reference server's `Backend::vet_tag_withdrawal`; a forge refuses while a release names the tag).
438 442
439 443
## 11. Checkpoints
440 444
…
273 unchanged lines
714 718
- The initial policy is signed by the root keys; every update MUST be signed by `roots.threshold` root keys of the policy it replaces and have a higher `serial`. An update that a single holder cannot complete is a **proposal**: the signed policy (`[policy bytes, [Signature]]`, §16.3) is passed to other root holders, each of whom adds a signature with `oxy policy sign`; whoever completes the threshold applies it. Rotating a root key (§14.2) substitutes the successor in the roots and rules; such a policy is signed by both the previous and the successor key so a fresh clone can bootstrap from it.
715 719
- A change's signer is authorized by a rule if the rule lists it, or lists a successor of it, or lists the change's `author` (or a successor) when the signer is that author's delegate.
716 720
- A reference attestation's signer is authorized by a rule if the rule lists it, or lists an identity whose `delegates` name it with the `refs` scope, not expired at the attestation's `hlc.wall_ms` (§14.2), or, on a fetch, at its integrated time when the host's log gives one (§14.8). Delegation is the identity's own signed statement and the policy names that identity, so a delegate with `refs` moves references exactly as far as its delegator may; revoking either key, or the delegation expiring, ends it. Hosts use this to land and to commit browser edits on a user's behalf with their own key. A delegate without `refs` (an agent) is never authorized for a reference or tag, even when its delegator is.
717 -
- A rule may also grant **subjects an issuer certifies** (§14.7): `oxy policy grant-subject SUBJECT --issuer IDENTITY --scope SCOPES` adds `{pattern, issuer, scope}` and embeds the issuer's identity. A signer is then authorized when it is a short-lived key whose certificate verifies with that issuer, names a subject the pattern matches, gives the scope needed (`changes` for changes, `refs` for reference attestations and tags) and covers a time the signer did not choose; it counts as one signer toward the threshold. A rule with subjects only has no keys, and its threshold counts keys and subjects together. The time is the receiver's own clock when it receives a push (changes and the reference attestation), and, on a fetch, the attestation's integrated time when the host's log gives one (§14.8, *Integrated time*); without one, the time the certificate's issuer says it accepted the update (§14.7, *Acceptance*), or the attestation's own time when there is no such acceptance, which is weaker: a key whose certificate expired could backdate an attestation within its scope. A policy without subjects encodes as before (field 5 is left out), so its hash is unchanged.
721 +
- A rule may also grant **subjects an issuer certifies** (§14.7): `oxy policy grant-subject SUBJECT --issuer IDENTITY --scope SCOPES` adds `{pattern, issuer, scope}` and embeds the issuer's identity. A signer is then authorized when it is a short-lived key whose certificate verifies with that issuer, names a subject the pattern matches, gives the scope needed (`changes` for changes, `refs` for reference attestations and tags) and covers a time the signer did not choose; it counts as one signer toward the threshold. A rule with subjects only has no keys, and its threshold counts keys and subjects together. The time is the receiver's own clock when it receives a push (changes and the reference attestation), and, on a fetch, the attestation's integrated time when the host's log gives one (§14.8, *Integrated time*); without one, the time the certificate's issuer says it accepted the update (§14.7, *Acceptance*), or the attestation's own time when there is no such acceptance, which is weaker: a key whose certificate expired could backdate an attestation within its scope. A tag is never checked at its own time: a fetch without an integrated time or an acceptance for it leaves the tag out and says why, and fetches the rest (oxygen#129). A policy without subjects encodes as before (field 5 is left out), so its hash is unchanged.
718 722
- A change or attestation is **authorized** for reference `r` if its signer is listed in the first rule whose pattern matches `r`, and the rule's threshold of distinct authorized signers is met across the attestation and each change in the closure of `new_state` not previously in the closure of `old_state`. `require` selects which authenticators count for changes: individual signatures only, seals only, or either. Every change MUST additionally satisfy the author-or-delegate rule of §14.4 regardless of `require`.
719 723
- Clients MUST verify policy on fetch, MUST refuse to update a `remotes/` reference whose attestation is unauthorized, and MUST warn loudly on an expired policy. Servers MUST enforce the same rules on push.
720 724
- A repository without a `policy` reference is **unverified**; every command that talks to a remote MUST print a one-line warning until one is created. `oxy policy init` creates one with the author's key as the sole root; `oxy policy grant` and `oxy policy revoke` produce successors.
…
61 unchanged lines
782 786
}
783 787
```
784 788
785 -
A signed grant is `[grant bytes, Signature]`: the grantor's composite signature, in domain `grant`, over the grant's ObjectRef (type `grant`). It needs no maximum lifetime: it gives nothing until an issuer certifies a key under it, and the grantor revokes it by asking the issuer to stop. The issuer puts it in each certificate it issues under it (field 8), so it travels with the certificate and a verifier needs nothing else. A signature by a key that is neither a change's author nor its delegate (§14.4) still satisfies the author-or-delegate rule when the key's certificate verifies with its issuer and carries a grant that verifies with the change's author, names the certificate's subject and issuer, gives the scope needed (`changes` for a change signature, `seal` for a seal) in both the grant and the certificate, and covers the change's `hlc.wall_ms`, as a delegation does. Whether the certificate covered the time the key signed is the policy's check (§14.5), at a time the signer did not choose.
789 +
A signed grant is `[grant bytes, Signature]`: the grantor's composite signature, in domain `grant`, over the grant's ObjectRef (type `grant`). It needs no maximum lifetime: it gives nothing until an issuer certifies a key under it, and the grantor revokes it by asking the issuer to stop. The issuer puts it in each certificate it issues under it (field 8), so it travels with the certificate and a verifier needs nothing else. A signature by a key that is neither a change's author nor its delegate (§14.4) still satisfies the author-or-delegate rule when the key's certificate verifies with its issuer and carries a grant that verifies with the change's author (or with a key the author's identity was rotated or renewed from, by its `previous` chain, when the grant's `not_before` is before that step's `created`: an owner's rotation keeps the grants they made, but a key rotated away grants nothing new; oxygen#135), names the certificate's subject and issuer, gives the scope needed (`changes` for a change signature, `seal` for a seal) in both the grant and the certificate, and covers the change's `hlc.wall_ms`, as a delegation does. Whether the certificate covered the time the key signed is the policy's check (§14.5), at a time the signer did not choose.
786 790
787 -
**Acceptance.** A host that issues certificates records when it accepted a reference attestation signed by a short-lived key: an `accepted` attestation (§14.4) naming that attestation's ObjectRef, its `hlc` the host's clock at the push, signed in domain `attest` by the issuer's key. The host keeps it indexed by the attestation it accepts (gc keeps it beside the bookmark's latest attestation) and serves it with the bookmark (§16, `refs` key 5). A fetch checks the certificate at the acceptance's time only when the acceptance names exactly the attestation being checked and verifies with the certificate's issuer; otherwise, including when there is none, at the attestation's own time. An acceptance by any other key changes nothing: the issuer is already trusted to vouch for the key, so it adds no trust it did not have. A fetch that has the attestation's integrated time (§14.8) checks at that instead: the acceptance is the issuer's clock, the integrated time its witnesses'.
791 +
**Acceptance.** A host that issues certificates records when it accepted a reference or tag attestation signed by a short-lived key: an `accepted` attestation (§14.4) naming that attestation's ObjectRef, its `hlc` the host's clock at the push, signed in domain `attest` by the issuer's key. The host keeps it indexed by the attestation it accepts (gc keeps it beside the bookmark's latest attestation or the tag's) and serves it with the bookmark (§16, `refs` entry key 5) or the tag (tag key 4). A fetch checks the certificate at the acceptance's time only when the acceptance names exactly the attestation being checked and verifies with the certificate's issuer; otherwise, including when there is none, at the attestation's own time for a bookmark, and not at all for a tag, which is left out (§14.5). Tags are kept for good and fetched long after a ten-minute certificate expired, so the issuer's acceptance travels with the tag: a client that fetched one keeps it and serves it with the tag, a push carries it, and a receiver checks a tag that comes with its issuer's acceptance at that time when its own clock is past the certificate's window (a tag passed on from the host that accepted it to another), keeping the acceptance rather than recording its own. An acceptance by any other key changes nothing: the issuer is already trusted to vouch for the key, so it adds no trust it did not have. A fetch that has the attestation's integrated time (§14.8) checks at that instead: the acceptance is the issuer's clock, the integrated time its witnesses'.
788 792
789 793
### 14.8 The transparency log
790 794
…
43 unchanged lines
834 838
**Integrated time** (oxygenhub#240). A cosignature is a witness's statement, by its own clock, that the checkpoint existed then, so every entry the checkpoint holds existed by that time, whatever its signer or the host claims. An entry's **integrated time** is the earliest time a witness the reader trusts cosigned a checkpoint that holds it: an upper bound on when its object was signed, in seconds, by a clock neither the signer nor the host chose.
835 839
- **Witness keys.** A host lists the witnesses it asks to cosign, each as its name in cosignatures and its identity (refs response key 7, §16.3; and `GET /log/witnesses`, one `<name> <base64 identity>` line each). A client pins them per user and hub beside the log key (`<oxy home>/tlog/<hub>.witnesses`): the first list silently, as the log key on first sight; a witness listed later, or listed under a pinned name with another key, is pinned with a note, as a new log key is. A witness the host stops listing stays pinned. A cosignature counts only when both its lines verify with a pinned witness's key; lines by anyone else are ignored. Trust-on-first-use gives no more than the host's own word on first contact, which is what the acceptance gives today (§14.7); after it, a host that names a witness of its own to move a time earlier is noted by every client that pinned the real ones.
836 840
- **What a host serves.** With a bookmark's entry (ref key 6), the cosigned checkpoints that first held it (ref key 7): each a checkpoint note with the log's signatures and its witnesses' cosignatures, and the entry's inclusion proof against it, one for each witness when they first cosigned different checkpoints. A client takes one when its origin is the log's, it verifies with the pinned log key, the entry is the attestation's (as above) and the proof verifies; the integrated time is the earliest trusted cosignature among those it takes. A host can only make the time later by serving a later checkpoint, which weakens the bound and helps no one but a signer it would refuse; it cannot make it earlier without a witness's key.
837 -
- **Use.** On a fetch, a check that depends on when a reference attestation was signed (whether a delegation with `refs` was in force, §14.5, or whether a certificate covered it, §14.7) is made at the integrated time when there is one. Without one (the host keeps no log, the entry is in no cosigned checkpoint yet, or no trusted witness cosigned it), it is made as before, at the issuer's acceptance or the attestation's own time, and the fetch says so when the host keeps a log. While logs are new (oxygenhub docs/transparency.md, decision 3), an attestation that fails at its integrated time but passes as before is accepted with a note rather than refused; one that fails both is refused, as before. A key the rule lists itself is authorized whenever it signed, and revocations are absolute (§14.5): neither depends on the time. A push is checked at the receiver's own clock, as before.
841 +
- **Use.** On a fetch, a check that depends on when a reference or tag attestation was signed (whether a delegation with `refs` was in force, §14.5, or whether a certificate covered it, §14.7) is made at the integrated time when there is one. Without one (the host keeps no log, the entry is in no cosigned checkpoint yet, or no trusted witness cosigned it), it is made as before, at the issuer's acceptance or the attestation's own time (for a tag, at the acceptance only, §14.7), and the fetch says so when the host keeps a log. While logs are new (oxygenhub docs/transparency.md, decision 3), an attestation that fails at its integrated time but passes as before is accepted with a note rather than refused; one that fails both is refused, as before. A key the rule lists itself is authorized whenever it signed, and revocations are absolute (§14.5): neither depends on the time. A push is checked at the receiver's own clock, as before.
838 842
- **A large gap.** For an attestation whose check depends on the time, a claimed time (`hlc.wall_ms`) more than 10 minutes before its integrated time, or more than 10 minutes after it, is noted, not refused: the attestation was backdated, held back before it was logged (one a host accepted before its log began, logged when first served), or signed on a wrong clock. A reference attestation is made at push and logged and cosigned within seconds, so ten minutes leaves room for drift and for a witness that was briefly away.
839 843
840 844
**`oxy tlog verify [HUB]`** audits a whole log: it accepts the checkpoint as a fetch does, downloads every entry bundle, and requires the entries to make the root the checkpoint signs. It names the trusted witnesses that cosigned the checkpoint and when (pinning those `GET /log/witnesses` lists, as a fetch does), a bound on when every entry was made. It then lists this repository's entries (`--key KEYID` for a key's, `--all` for every one, `--json` for machines).
…
55 unchanged lines
896 900
| Message | Encoding |
897 901
|---|---|
898 902
| Signed attestation | `[attest bytes, [Signature]]` |
899 -
| `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}`; changes in dependency order; objects are chunks, blobs, identities, and states only, and include the text blobs of every change in the bundle and their chunks, so a bundle can be read on its own; a push that moves a bookmark also carries the identity that signed the move and, for a delegate, the identity it acts for, so a receiver that has seen no change by that key can check it (oxygenhub#129); key 3 (optional) holds the policies between the receiver's serial and the one sent with a push, ascending; key 4 (optional, only when there are any) holds signed certificates (§14.7) of short-lived keys that signed what the bundle carries or the push's attestation, their keys' and issuers' identities among the objects; a receiver keeps each that verifies with its issuer and refuses a bundle with one that does not |
903 +
| `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}`; changes in dependency order; objects are chunks, blobs, identities, and states only, and include the text blobs of every change in the bundle and their chunks, so a bundle can be read on its own, and the `new` blob of every `Replace` (with its chunks) unless left out by size; never a replacement's `old` blob, which is the `new` of a change it depends on (§8.1), held by the receiver or carried by the same bundle (oxygen#136); a push that moves a bookmark also carries the identity that signed the move and, for a delegate, the identity it acts for, so a receiver that has seen no change by that key can check it (oxygenhub#129), and a push that sends tags carries each tag signer's identity and, for a short-lived key, its certificate likewise (oxygen#129); key 3 (optional) holds the policies between the receiver's serial and the one sent with a push, ascending; key 4 (optional, only when there are any) holds signed certificates (§14.7) of short-lived keys that signed what the bundle carries or the push's attestation, their keys' and issuers' identities among the objects; a receiver keeps each that verifies with its issuer and refuses a bundle with one that does not |
900 904
| `capabilities` response | `{0: [versions], 1: repo id bytes(16), 2: [feature names]}` |
901 905
| Signed policy | `[policy bytes, [Signature]]` |
902 -
| `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null, 5: signed acceptance}], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}`; key 4 (optional) lists earlier policies ascending by serial; key 5 (optional, text or null) names the default bookmark, which a clone checks out; the attestation is present only when it describes the current state; an entry's key 5 (optional, only when there is one) is the host's acceptance of that attestation (§14.7); an entry's key 6 (optional) is the attestation's place in the host's transparency log, `[entry number, entry bytes, [proof hash]]`, an inclusion proof against the checkpoint in the response's key 6 (§14.8); an entry's key 7 (optional, only beside key 6 and when there are any) lists the cosigned checkpoints that first held that log entry, `[[checkpoint note text, [proof hash]]]`, each with the entry's inclusion proof against it, from which its integrated time is read (§14.8); the response's key 6 (optional, text) is the host's latest checkpoint, a signed note; the response's key 7 (optional, only when there are any) lists the witnesses the host asks to cosign, `[[name, identity bytes]]`, which a client pins (§14.8); a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` |
906 +
| `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null, 5: signed acceptance}], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}`; key 4 (optional) lists earlier policies ascending by serial; key 5 (optional, text or null) names the default bookmark, which a clone checks out; the attestation is present only when it describes the current state; an entry's key 5 (optional, only when there is one) is the host's acceptance of that attestation (§14.7); an entry's key 6 (optional) is the attestation's place in the host's transparency log, `[entry number, entry bytes, [proof hash]]`, an inclusion proof against the checkpoint in the response's key 6 (§14.8); an entry's key 7 (optional, only beside key 6 and when there are any) lists the cosigned checkpoints that first held that log entry, `[[checkpoint note text, [proof hash]]]`, each with the entry's inclusion proof against it, from which its integrated time is read (§14.8); the response's key 6 (optional, text) is the host's latest checkpoint, a signed note; the response's key 7 (optional, only when there are any) lists the witnesses the host asks to cosign, `[[name, identity bytes]]`, which a client pins (§14.8); a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}`, and optionally, each only when there is one, `4: signed acceptance`, `5: log place`, `6: [cosigned checkpoint]`, meaning for the tag's attestation what a bookmark entry's keys 5 to 7 mean for its attestation (oxygen#129); a reader that does not know them ignores them |
903 907
| `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order |
904 908
| `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size; also when it names no change, the bundle carries the identities of the keys that signed the attestations served with `refs` (each bookmark's latest and each tag's) and, for a short-lived key, its certificate and its issuer's identity (§14.7), each once, so a client can check an attestation whose signer signed none of the changes it receives, such as a delegate's or a keyless agent's bookmark move (oxygenhub#266) |
905 909
| `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks |
906 -
| `checkpoint` request | `{0: [heads]}`; response a `Bundle` of objects: the checkpoint of exactly those heads, its payload blob, and the blob's chunks |
910 +
| `checkpoint` request | `{0: [heads]}`; response a `Bundle` of objects: the checkpoint of exactly those heads, its payload blob, and the blob's chunks, with the payload's graph blobs and their chunks (§11) |
911 +
| `checkpoint-index` request | as `checkpoint`, without the graph blobs: the client asks for those it lacks with `objects`, in parts (oxygen#137), so neither side holds a big state's whole answer; a server without it answers 404 and the client falls back to `checkpoint` |
907 912
| `shallow` request | `{0: [heads], 1: depth}`; response `{0: [hashes]}`, the changes within `depth` steps of the heads, dependencies first |
908 913
| `push` request | `{0: Bundle, 1: [{0: name, 1: old state, 2: new state, 3: force, 4: signed attestation or null}], 2: signed policy or null, 3: [tag]}`; response `{0: [{0: name, 1: ok, 2: reason}]}`, where a result named `policy` reports the policy update and `tags/<name>` each tag |
909 914
…
3 unchanged lines
913 918
914 919
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.
915 920
916 -
**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.
921 +
**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.
917 922
918 923
**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.
919 924
…
12 unchanged lines
932 937
933 938
- **Sparse** repositories hold the full change graph but only materialize the listed paths: the working copy contains nothing else, files outside the filter are neither reported nor removed, and changes touching other paths are stored and applied to the graph. A sparse clone sets `max-blob` to zero unless a size is given, so blobs outside the filter are fetched only if the filter later widens (§13.5). `oxy sparse PATH..` changes the filter of a clean working copy and re-checks it out.
934 939
- **Lazy blobs** are fetched via `objects` on first read. `oxy status` and `oxy commit` MUST NOT need them.
935 -
- **Shallow** repositories hold a checkpoint of their bookmark's heads (obtained with the `checkpoint` message) and the changes within `depth` dependency steps of those heads (the `shallow` message lists them). The *shallow boundary* is the set of dependencies of stored changes that are not stored (`.oxy/shallow`). Dependencies into the boundary are accepted as well-formed, negotiation stops at the boundary (the remote is assumed to hold it), and the log shows every change from the checkpoint's metadata. `oxy deepen N` fetches `N` more levels below the boundary; when nothing remains the repository is no longer shallow.
940 +
- **Shallow** repositories hold a checkpoint of their bookmark's heads (obtained with the `checkpoint` message) and the changes within `depth` dependency steps of those heads (the `shallow` message lists them); at depth 0 no change at all, the checkpoint's heads being the boundary (oxygen#137), and a shallow repository takes and builds on changes whose dependencies lie below what it cloned, which its remote is assumed to hold. The *shallow boundary* is the set of dependencies of stored changes that are not stored (`.oxy/shallow`). Dependencies into the boundary are accepted as well-formed, negotiation stops at the boundary (the remote is assumed to hold it), and the log shows every change from the checkpoint's metadata. `oxy deepen N` fetches `N` more levels below the boundary; when nothing remains the repository is no longer shallow.
936 941
- Thin clients (§22) always use `depth = 0`: a checkpoint plus zero history.
937 942
938 943
Servers MUST honour `filter` on `negotiate`, `fetch`, and `checkpoint`.
…
12 unchanged lines
951 956
| File | Purpose |
952 957
|---|---|
953 958
| `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. A directory's `.gitignore` and `.ignore` files are read too, with lower precedence than its `.oxyignore` (a `!` rule there re-includes what they exclude), so a project coming from Git leaves out what Git did. Rules apply to untracked paths only: a tracked path stays tracked, and its edits are seen, until it is deleted or removed from the state. |
954 -
| `.oxyattributes` | Per-path attributes: `binary` (force opaque), `text` (text unless NUL bytes), `eol=lf|crlf` (store LF; check out as given), `nocompress` (chunks packed raw), `lockable` (read-only until `oxy lock`), `filter=<name>`. Gitattributes syntax: a pattern without `/` matches basenames below the file's directory, one with `/` is anchored to it; later rules and deeper files override. The attributes in force for a change are those in the files being committed. |
959 +
| `.oxyattributes` | Per-path attributes: `binary` (force opaque), `text` (text unless NUL bytes), `eol=lf|crlf` (store LF; check out as given), `nocompress` (chunks packed raw), `lockable` (read-only until `oxy lock`), `merge=union` (adjacent insertions settled by keeping both sides, §9.5), `filter=<name>`. Gitattributes syntax: a pattern without `/` matches basenames below the file's directory, one with `/` is anchored to it; later rules and deeper files override. The attributes in force for a change are those in the files being committed. |
955 960
| `.oxy/config` | Repository-local settings, TOML. |
956 -
| `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `author.signer` (the key that signs and seals in place of `author.key`, which must list it as a delegate: an agent's own key; or `keyless`, for fresh keys the hub certifies under an agent's grant, §14.7, from the grant token `oxy auth grant` keeps; also `$OXY_SIGNER`), `keyless.hub` (the hub that certifies them; default the default remote's), `core.sign`, and `remote.<name>.token`; also `auth.helper`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. |
961 +
| `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `author.signer` (the key that signs and seals in place of `author.key`, which must list it as a delegate: an agent's own key; or `keyless`, for fresh keys the hub certifies under an agent's grant, §14.7, from the grant token `oxy auth grant` keeps; also `$OXY_SIGNER`), `keyless.hub` (the hub that certifies them; default the default remote's), `author.for` (in a keyless home, the account it acts for: it authors as that account's newest identity, which the hub sends with each certificate with its chain back to the grant's key and the home keeps in `keys/author.for`; a repository's own `author.key` wins), `core.sign`, and `remote.<name>.token`; also `auth.helper`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. |
957 962
| `~/.config/oxy/credentials.toml` | Tokens from `oxy auth login`, one `[scheme://host[:port]]` section per hub with `token`, `account` and `ca`. Written owner-only; ignored when others can read it. With `auth.helper` set, the token is kept by that helper (Git's credential-helper protocol, user name `oxy-token`) and the section holds the rest. |
958 963
959 964
Settings resolve in order: the repository file overrides the user file, which overrides environment variables (`OXY_AUTHOR_NAME`, `OXY_AUTHOR_EMAIL`, `OXY_TOKEN`) and built-in defaults. A remote's token and CA without `remote.<name>.token` or `.ca` come from `OXY_TOKEN` and `OXY_CA`, then from `credentials.toml` for the remote's hub, then from a remote on the same hub in an enclosing repository's `.oxy/config`. A client MUST NOT send a token over plain `http` except to a loopback address, and MUST NOT forward it across a redirect. `oxy config` writes the repository file; `oxy config --user` writes the user file. Secret keys live beside the user file in `keys/`, one `<KeyId>.secret` and one `<KeyId>.identity` per key, so any repository the user works in can store the identity object when it first signs.
…
48 unchanged lines
1008 1013
- **Fetch and push against a Git remote.** `oxy git fetch URL` keeps a bare mirror under `.oxy/git/` for the remote, fetches the branch into it, and imports the commits the bookmark does not have yet, seeding the import with the commits already known: an imported commit maps to its change, and a commit this repository exported maps to the bookmark state it was made from (so a Git commit on top of exported work is based on everything that work contained, including changes Git never saw). The bookmark moves to the union of its heads and the new changes, like a pull; the working copy follows when it was clean. A fetched state whose previous state was itself a Git tree is remembered as equal to the fetched tip. `oxy git push URL` refuses until the remote's tip is known, exports the bookmark's later states as commits chained onto that tip, and pushes the branch, so the push is always a fast-forward.
1009 1014
- **Submodules.** A Git gitlink entry (mode `160000`) imports as a `subrepo` node with a Git pin (§7.8) and the matching `.gitmodules` entry becomes a `.oxymodules` entry with `branch` set to the submodule's configured branch when there is one. Export writes a Git pin back as a gitlink, keeps a tracked `.gitmodules` as is, and otherwise derives `.gitmodules` from `.oxymodules`; an Oxygen pin has no Git form and is omitted from the exported tree, which the export report counts.
1010 1015
- **Merges of opaque files.** When the union of a merge commit's parents holds several live blobs for a file or submodule, the merge's change replaces each of them with the commit's blob and is a resolution: Git's merge is the authority, unlike a working copy, where a candidate on disk is not yet a decision.
1016 +
- **A side-by-side `.git`.** `oxy git colocate` (config `git.colocate`) keeps a `.git` beside `.oxy` so editors' and other tools' Git features work on an Oxygen checkout: made with `git init` when missing, `/.oxy/` added to its `info/exclude`, the current bookmark exported into it (as `oxy git export` does) after every oxy command that leaves the bookmark somewhere new, HEAD on that branch and the index at its tree, so `git status` shows exactly the working copy's uncommitted work. It is one way: commits made with Git are not read back. oxy never takes a `.git` directory as content, and checkout refuses to write a path with a `.git` component at any depth or case (as Git does), so a change cannot plant hooks there. `oxy git colocate --off` stops the updates and leaves `.git` in place.
1017 +
- **Export by change graph.** For a server serving Git clients (a hub's Git view), `git_export_graph` exports every bookmark and tag from the changes alone, so the same changes give the same commits wherever it runs: each change is a commit whose tree is the state of the change and its dependency closure and whose parents are its dependencies' commits (a change that depends on nothing is a root), with the change's author, time, message and `Change-Id:`/`Oxy-Hash:` trailers; a bookmark with one head is a branch at its commit, with several a merge of their commits (the bookmark's state, at the newest head's time, `Oxy-Heads:`); tags likewise. Marks make it incremental; branches of deleted bookmarks are removed.
1011 1018
- **Round trip.** Import followed by export MUST reproduce every tree hash. Export followed by import MUST reproduce every ChangeId. `oxy git verify GIT_DIR` checks an import commit by commit: for each commit, the state formed by the changes imported for it and all its ancestors must materialize to exactly that commit's tree (content, executable bits, symlink targets, submodule pins; `.oxymodules` is allowed beside submodules). `--last N` rebuilds only the last N commits, following the others, to look again at where an import went wrong.
1012 1019
1013 1020
## 24. Command-line interface
…
6 unchanged lines
1020 1027
oxy commit -m MSG [--depends-on CHANGE]..
1021 1028
oxy log [-r REVSET] [-n N] [-v] [-L S,E:PATH]   oxy show CHANGE | -r REVSET [--stat]
1022 1029
oxy describe [CHANGE] [-m MSG]      oxy amend [-m MSG] [--into CHANGE | PATH..]
1023 -
oxy split CHANGE [PATH]..           oxy abandon CHANGE | -r REVSET [--yes]   oxy resolve
1030 +
oxy split CHANGE [PATH]..           oxy abandon CHANGE | -r REVSET [--yes]   oxy resolve [--both [--reverse]] [PATH]..
1024 1031
oxy checkout [CHANGE].. [--discard|--carry] oxy undo                     oxy op log
1025 1032
oxy tag create NAME [CHANGE].. [-m MSG]   oxy tag list
1026 1033
oxy bookmark create|set|delete|list|switch  oxy merge BOOKMARK
…
13 unchanged lines
1040 1047
oxy doctor [--offline] [--json]     check identity, config and remotes (§21.2)
1041 1048
oxy completions SHELL               shell completion script     oxy man [--dir DIR]   man pages
1042 1049
oxy hunks                           oxy discard PATH.. | --hunks N,M
1050 +
oxy mcp [DIR] [--allow-destructive] serve the repository to an AI agent (MCP, stdio)
1043 1051
```
1044 1052
1045 1053
**Machine interface.** `status`, `log` (with `--path`), `show`, `diff`, `hunks`, `blame`, `resolve`, `op log`, `bookmark list`, `remote list`, `fetch`, `pull`, `push`, `submodule status`, `bisect status`, `verify`, `policy show`, `root` and `version` take `--json` and then print exactly one JSON document on stdout, with ids as hex strings and times in milliseconds since the epoch; errors go to stderr with a non-zero status. `commit --hunks N,M` and `discard --hunks N,M` take hunk numbers as `hunks` prints them. Editors and other tools MUST use these rather than reading `.oxy/`.
1046 1054
1055 +
**Agents.** `oxy mcp` serves the repository to an AI agent over the Model Context Protocol on stdio (docs/mcp.md): each tool runs one of the commands above with `--json`, in the repository and with the server's environment, so it signs as the identity `OXY_HOME` configures (an agent home seals every change via the agent). `commit` and `amend` take explicit paths or `all: true`; `undo` and a forced push are offered only with `--allow-destructive`.
1056 +
1047 1057
Change arguments accept a ChangeId prefix, a ChangeHash prefix (with `#`), a bookmark or tag name, and `@` for the working-copy change. Ambiguous prefixes are an error.
1048 1058
1049 1059
Reserved for a later version, with these names: `oxy new`, `oxy edit`, `oxy redo`, revsets (`log -r`), and `oxy remote trust` (with the Noise transport, §15).
…
49 unchanged lines
1099 1109
| HTTPS transport and endpoints (§16) | Implemented, HTTP only; Noise and ZCT (§15) reserved; negotiation is exact (no Merkle buckets); `refs` takes no `patterns` |
1100 1110
| Partial, sparse, shallow (§17) | Implemented |
1101 1111
| Locks (§18) | Local advisory locks only; server locks and the `lock` endpoint reserved |
1102 -
| Configuration, attributes, hooks (§19, §20) | Implemented (`.oxyattributes`: binary, text, eol, nocompress, lockable) |
1112 +
| Configuration, attributes, hooks (§19, §20) | Implemented (`.oxyattributes`: binary, text, eol, nocompress, lockable, merge=union) |
1103 1113
| Thin profile and proxy (§22) | Reserved |
1104 1114
| Git interoperability (§23) | Implemented: import, export, fetch, push, rename detection |
1105 1115
| Conformance suite (§26) | Partial: encoding, signature, permutation, conflict, rewrite, protocol, chunking and Git tests exist as unit and integration tests in the crates; a standalone vector suite is not published yet |
…
84 unchanged lines
added packaging/haikuports/README.md +40 −0
1 +
# oxy and oh on HaikuDepot
2 +
3 +
[haikuports](https://github.com/haikuports/haikuports) recipes for oxy
4 +
(`dev-vcs/oxy`) and OxygenHub's `oh` (`dev-vcs/oh`) are written by
5 +
`make-recipe.py` from a release's source tarball (oxygen#156, #157).
6 +
7 +
Each release's dist job attaches `NAME-VERSION-src.tar.gz`: the source as
8 +
released, reproducible (sorted, fixed times and owners, gzip without a
9 +
timestamp). oh's holds the oxygenhub tree with its subrepos inside, so it
10 +
builds alone. The recipe fetches it from the release's stable address,
11 +
`https://oxygenhub.io/andymccall/REPO/releases/download/vVERSION/NAME-VERSION-src.tar.gz`,
12 +
and every crates.io crate in its `Cargo.lock` as a source of its own with its
13 +
checksum, building offline as haikuports' other Rust recipes (ripgrep) do.
14 +
15 +
For a release (oxy shown; oh is the same with `oh` and `oxygenhub`):
16 +
17 +
```sh
18 +
curl -LO https://oxygenhub.io/andymccall/oxygen/releases/download/vX.Y.Z/oxy-X.Y.Z-src.tar.gz
19 +
python3 packaging/haikuports/make-recipe.py oxy oxy-X.Y.Z-src.tar.gz X.Y.Z > oxy-X.Y.Z.recipe
20 +
```
21 +
22 +
Test it on Haiku R1/beta6 with haikuports checked out and `haikuporter`
23 +
installed: copy the recipe to `haikuports/dev-vcs/oxy/` and run
24 +
`haikuporter --no-source-packages -j1 -y oxy`. Submit it as a pull request
25 +
to haikuports (`oxy: new recipe`, then `oxy: bump to X.Y.Z`).
26 +
27 +
Haiku needs the system's zstd (`ZSTD_SYS_USE_PKG_CONFIG=1`: the bundled
28 +
zstd's hidden symbols break the link), which the recipe sets. oh's tests
29 +
pull in the hub, whose pipeline parser needs a newer Rust than HaikuPorts'
30 +
1.94, so its recipe tests `hub-api` only.
31 +
32 +
## Icons
33 +
34 +
HaikuDepot takes a package's icon from HaikuDepotServer, not from the
35 +
recipe. `icons/` holds them, written by `make-icons.py icons/` (the PNGs
36 +
rendered from the SVGs): Oxygen (`oxy`) is the mark on a pale tile,
37 +
OxygenHub (`oh`) the mark on the hub's dark tile, as the VS Code
38 +
extensions' icons are. Once the packages are listed on
39 +
depot.haiku-os.org, upload `NAME.hvif` with `NAME-16.png`, `-32` and `-64`
40 +
as each package's icon.
added packaging/haikuports/dev-vcs/oh/oh-0.9.16.recipe +1472 −0
1 +
SUMMARY="The command line of OxygenHub, the forge for Oxygen repositories"
2 +
DESCRIPTION="oh talks to an OxygenHub (such as https://oxygenhub.io): issues, proposals \
3 +
and their reviews, releases, Flow runs, their logs and reports, and checks, \
4 +
from the terminal. It works alongside oxy, the Oxygen version control \
5 +
client."
6 +
HOMEPAGE="https://oxygenhub.io/andymccall/oxygenhub"
7 +
COPYRIGHT="2026 Andy McCall"
8 +
LICENSE="MIT
9 +
	Apache v2"
10 +
REVISION="1"
11 +
SOURCE_URI="$HOMEPAGE/releases/download/v$portVersion/oh-$portVersion-src.tar.gz"
12 +
CHECKSUM_SHA256="55770a8668647a44e57ed085315dc11bd97028d2a5c1a1640cd85af87b461200"
13 +
SOURCE_DIR="oxygenhub-$portVersion"
14 +
SOURCE_URI_2="https://static.crates.io/crates/adler2/adler2-2.0.1.crate"
15 +
CHECKSUM_SHA256_2="320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
16 +
SOURCE_FILENAME_2="adler2-2.0.1.tar.gz"
17 +
SOURCE_URI_3="https://static.crates.io/crates/aho-corasick/aho-corasick-1.1.5.crate"
18 +
CHECKSUM_SHA256_3="c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
19 +
SOURCE_FILENAME_3="aho-corasick-1.1.5.tar.gz"
20 +
SOURCE_URI_4="https://static.crates.io/crates/allocator-api2/allocator-api2-0.2.21.crate"
21 +
CHECKSUM_SHA256_4="683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
22 +
SOURCE_FILENAME_4="allocator-api2-0.2.21.tar.gz"
23 +
SOURCE_URI_5="https://static.crates.io/crates/ammonia/ammonia-4.2.0.crate"
24 +
CHECKSUM_SHA256_5="3f7ed3ac4252be4d3e36fb589ed5659356842587b21a31596a9dc89d3b2f5e2a"
25 +
SOURCE_FILENAME_5="ammonia-4.2.0.tar.gz"
26 +
SOURCE_URI_6="https://static.crates.io/crates/android_system_properties/android_system_properties-0.1.6.crate"
27 +
CHECKSUM_SHA256_6="ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
28 +
SOURCE_FILENAME_6="android_system_properties-0.1.6.tar.gz"
29 +
SOURCE_URI_7="https://static.crates.io/crates/anstream/anstream-1.0.0.crate"
30 +
CHECKSUM_SHA256_7="824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
31 +
SOURCE_FILENAME_7="anstream-1.0.0.tar.gz"
32 +
SOURCE_URI_8="https://static.crates.io/crates/anstyle/anstyle-1.0.14.crate"
33 +
CHECKSUM_SHA256_8="940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
34 +
SOURCE_FILENAME_8="anstyle-1.0.14.tar.gz"
35 +
SOURCE_URI_9="https://static.crates.io/crates/anstyle-parse/anstyle-parse-1.0.0.crate"
36 +
CHECKSUM_SHA256_9="52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
37 +
SOURCE_FILENAME_9="anstyle-parse-1.0.0.tar.gz"
38 +
SOURCE_URI_10="https://static.crates.io/crates/anstyle-query/anstyle-query-1.1.5.crate"
39 +
CHECKSUM_SHA256_10="40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
40 +
SOURCE_FILENAME_10="anstyle-query-1.1.5.tar.gz"
41 +
SOURCE_URI_11="https://static.crates.io/crates/anstyle-wincon/anstyle-wincon-3.0.11.crate"
42 +
CHECKSUM_SHA256_11="291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
43 +
SOURCE_FILENAME_11="anstyle-wincon-3.0.11.tar.gz"
44 +
SOURCE_URI_12="https://static.crates.io/crates/anyhow/anyhow-1.0.104.crate"
45 +
CHECKSUM_SHA256_12="330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
46 +
SOURCE_FILENAME_12="anyhow-1.0.104.tar.gz"
47 +
SOURCE_URI_13="https://static.crates.io/crates/arbitrary/arbitrary-1.4.2.crate"
48 +
CHECKSUM_SHA256_13="c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
49 +
SOURCE_FILENAME_13="arbitrary-1.4.2.tar.gz"
50 +
SOURCE_URI_14="https://static.crates.io/crates/arc-swap/arc-swap-1.9.2.crate"
51 +
CHECKSUM_SHA256_14="c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
52 +
SOURCE_FILENAME_14="arc-swap-1.9.2.tar.gz"
53 +
SOURCE_URI_15="https://static.crates.io/crates/argon2/argon2-0.5.3.crate"
54 +
CHECKSUM_SHA256_15="3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
55 +
SOURCE_FILENAME_15="argon2-0.5.3.tar.gz"
56 +
SOURCE_URI_16="https://static.crates.io/crates/arrayvec/arrayvec-0.7.8.crate"
57 +
CHECKSUM_SHA256_16="d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
58 +
SOURCE_FILENAME_16="arrayvec-0.7.8.tar.gz"
59 +
SOURCE_URI_17="https://static.crates.io/crates/askama/askama-0.14.0.crate"
60 +
CHECKSUM_SHA256_17="f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
61 +
SOURCE_FILENAME_17="askama-0.14.0.tar.gz"
62 +
SOURCE_URI_18="https://static.crates.io/crates/askama_derive/askama_derive-0.14.0.crate"
63 +
CHECKSUM_SHA256_18="129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
64 +
SOURCE_FILENAME_18="askama_derive-0.14.0.tar.gz"
65 +
SOURCE_URI_19="https://static.crates.io/crates/askama_parser/askama_parser-0.14.0.crate"
66 +
CHECKSUM_SHA256_19="d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
67 +
SOURCE_FILENAME_19="askama_parser-0.14.0.tar.gz"
68 +
SOURCE_URI_20="https://static.crates.io/crates/asn1-rs/asn1-rs-0.7.2.crate"
69 +
CHECKSUM_SHA256_20="b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
70 +
SOURCE_FILENAME_20="asn1-rs-0.7.2.tar.gz"
71 +
SOURCE_URI_21="https://static.crates.io/crates/asn1-rs-derive/asn1-rs-derive-0.6.0.crate"
72 +
CHECKSUM_SHA256_21="3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
73 +
SOURCE_FILENAME_21="asn1-rs-derive-0.6.0.tar.gz"
74 +
SOURCE_URI_22="https://static.crates.io/crates/asn1-rs-impl/asn1-rs-impl-0.2.0.crate"
75 +
CHECKSUM_SHA256_22="7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
76 +
SOURCE_FILENAME_22="asn1-rs-impl-0.2.0.tar.gz"
77 +
SOURCE_URI_23="https://static.crates.io/crates/async-compression/async-compression-0.4.48.crate"
78 +
CHECKSUM_SHA256_23="fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
79 +
SOURCE_FILENAME_23="async-compression-0.4.48.tar.gz"
80 +
SOURCE_URI_24="https://static.crates.io/crates/async-trait/async-trait-0.1.92.crate"
81 +
CHECKSUM_SHA256_24="82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
82 +
SOURCE_FILENAME_24="async-trait-0.1.92.tar.gz"
83 +
SOURCE_URI_25="https://static.crates.io/crates/atoi/atoi-2.0.0.crate"
84 +
CHECKSUM_SHA256_25="f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
85 +
SOURCE_FILENAME_25="atoi-2.0.0.tar.gz"
86 +
SOURCE_URI_26="https://static.crates.io/crates/atomic-waker/atomic-waker-1.1.2.crate"
87 +
CHECKSUM_SHA256_26="1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
88 +
SOURCE_FILENAME_26="atomic-waker-1.1.2.tar.gz"
89 +
SOURCE_URI_27="https://static.crates.io/crates/autocfg/autocfg-1.5.1.crate"
90 +
CHECKSUM_SHA256_27="f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
91 +
SOURCE_FILENAME_27="autocfg-1.5.1.tar.gz"
92 +
SOURCE_URI_28="https://static.crates.io/crates/axum/axum-0.8.9.crate"
93 +
CHECKSUM_SHA256_28="31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
94 +
SOURCE_FILENAME_28="axum-0.8.9.tar.gz"
95 +
SOURCE_URI_29="https://static.crates.io/crates/axum-core/axum-core-0.5.6.crate"
96 +
CHECKSUM_SHA256_29="08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
97 +
SOURCE_FILENAME_29="axum-core-0.5.6.tar.gz"
98 +
SOURCE_URI_30="https://static.crates.io/crates/axum-extra/axum-extra-0.10.3.crate"
99 +
CHECKSUM_SHA256_30="9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
100 +
SOURCE_FILENAME_30="axum-extra-0.10.3.tar.gz"
101 +
SOURCE_URI_31="https://static.crates.io/crates/axum-macros/axum-macros-0.5.1.crate"
102 +
CHECKSUM_SHA256_31="7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
103 +
SOURCE_FILENAME_31="axum-macros-0.5.1.tar.gz"
104 +
SOURCE_URI_32="https://static.crates.io/crates/axum-server/axum-server-0.8.0.crate"
105 +
CHECKSUM_SHA256_32="b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc"
106 +
SOURCE_FILENAME_32="axum-server-0.8.0.tar.gz"
107 +
SOURCE_URI_33="https://static.crates.io/crates/base32/base32-0.5.1.crate"
108 +
CHECKSUM_SHA256_33="022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
109 +
SOURCE_FILENAME_33="base32-0.5.1.tar.gz"
110 +
SOURCE_URI_34="https://static.crates.io/crates/base64/base64-0.22.1.crate"
111 +
CHECKSUM_SHA256_34="72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
112 +
SOURCE_FILENAME_34="base64-0.22.1.tar.gz"
113 +
SOURCE_URI_35="https://static.crates.io/crates/base64/base64-0.23.1.crate"
114 +
CHECKSUM_SHA256_35="ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
115 +
SOURCE_FILENAME_35="base64-0.23.1.tar.gz"
116 +
SOURCE_URI_36="https://static.crates.io/crates/base64ct/base64ct-1.8.3.crate"
117 +
CHECKSUM_SHA256_36="2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
118 +
SOURCE_FILENAME_36="base64ct-1.8.3.tar.gz"
119 +
SOURCE_URI_37="https://static.crates.io/crates/basic-toml/basic-toml-0.1.10.crate"
120 +
CHECKSUM_SHA256_37="ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
121 +
SOURCE_FILENAME_37="basic-toml-0.1.10.tar.gz"
122 +
SOURCE_URI_38="https://static.crates.io/crates/bincode/bincode-1.3.3.crate"
123 +
CHECKSUM_SHA256_38="b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
124 +
SOURCE_FILENAME_38="bincode-1.3.3.tar.gz"
125 +
SOURCE_URI_39="https://static.crates.io/crates/bit-set/bit-set-0.8.0.crate"
126 +
CHECKSUM_SHA256_39="08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
127 +
SOURCE_FILENAME_39="bit-set-0.8.0.tar.gz"
128 +
SOURCE_URI_40="https://static.crates.io/crates/bit-vec/bit-vec-0.8.0.crate"
129 +
CHECKSUM_SHA256_40="5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
130 +
SOURCE_FILENAME_40="bit-vec-0.8.0.tar.gz"
131 +
SOURCE_URI_41="https://static.crates.io/crates/bit-vec/bit-vec-0.9.1.crate"
132 +
CHECKSUM_SHA256_41="b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
133 +
SOURCE_FILENAME_41="bit-vec-0.9.1.tar.gz"
134 +
SOURCE_URI_42="https://static.crates.io/crates/bitflags/bitflags-2.13.2.crate"
135 +
CHECKSUM_SHA256_42="3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
136 +
SOURCE_FILENAME_42="bitflags-2.13.2.tar.gz"
137 +
SOURCE_URI_43="https://static.crates.io/crates/blake2/blake2-0.10.6.crate"
138 +
CHECKSUM_SHA256_43="46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
139 +
SOURCE_FILENAME_43="blake2-0.10.6.tar.gz"
140 +
SOURCE_URI_44="https://static.crates.io/crates/blake3/blake3-1.8.7.crate"
141 +
CHECKSUM_SHA256_44="6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
142 +
SOURCE_FILENAME_44="blake3-1.8.7.tar.gz"
143 +
SOURCE_URI_45="https://static.crates.io/crates/block-buffer/block-buffer-0.10.4.crate"
144 +
CHECKSUM_SHA256_45="3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
145 +
SOURCE_FILENAME_45="block-buffer-0.10.4.tar.gz"
146 +
SOURCE_URI_46="https://static.crates.io/crates/block-buffer/block-buffer-0.12.1.crate"
147 +
CHECKSUM_SHA256_46="d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
148 +
SOURCE_FILENAME_46="block-buffer-0.12.1.tar.gz"
149 +
SOURCE_URI_47="https://static.crates.io/crates/bstr/bstr-1.13.1.crate"
150 +
CHECKSUM_SHA256_47="6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
151 +
SOURCE_FILENAME_47="bstr-1.13.1.tar.gz"
152 +
SOURCE_URI_48="https://static.crates.io/crates/bumpalo/bumpalo-3.20.3.crate"
153 +
CHECKSUM_SHA256_48="72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
154 +
SOURCE_FILENAME_48="bumpalo-3.20.3.tar.gz"
155 +
SOURCE_URI_49="https://static.crates.io/crates/bytemuck/bytemuck-1.25.2.crate"
156 +
CHECKSUM_SHA256_49="95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
157 +
SOURCE_FILENAME_49="bytemuck-1.25.2.tar.gz"
158 +
SOURCE_URI_50="https://static.crates.io/crates/byteorder/byteorder-1.5.0.crate"
159 +
CHECKSUM_SHA256_50="1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
160 +
SOURCE_FILENAME_50="byteorder-1.5.0.tar.gz"
161 +
SOURCE_URI_51="https://static.crates.io/crates/byteorder-lite/byteorder-lite-0.1.0.crate"
162 +
CHECKSUM_SHA256_51="8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
163 +
SOURCE_FILENAME_51="byteorder-lite-0.1.0.tar.gz"
164 +
SOURCE_URI_52="https://static.crates.io/crates/bytes/bytes-1.12.1.crate"
165 +
CHECKSUM_SHA256_52="fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
166 +
SOURCE_FILENAME_52="bytes-1.12.1.tar.gz"
167 +
SOURCE_URI_53="https://static.crates.io/crates/cc/cc-1.5.1.crate"
168 +
CHECKSUM_SHA256_53="f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
169 +
SOURCE_FILENAME_53="cc-1.5.1.tar.gz"
170 +
SOURCE_URI_54="https://static.crates.io/crates/cfg-if/cfg-if-1.0.5.crate"
171 +
CHECKSUM_SHA256_54="4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
172 +
SOURCE_FILENAME_54="cfg-if-1.0.5.tar.gz"
173 +
SOURCE_URI_55="https://static.crates.io/crates/cfg_aliases/cfg_aliases-0.2.2.crate"
174 +
CHECKSUM_SHA256_55="f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
175 +
SOURCE_FILENAME_55="cfg_aliases-0.2.2.tar.gz"
176 +
SOURCE_URI_56="https://static.crates.io/crates/chacha20/chacha20-0.10.2.crate"
177 +
CHECKSUM_SHA256_56="65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
178 +
SOURCE_FILENAME_56="chacha20-0.10.2.tar.gz"
179 +
SOURCE_URI_57="https://static.crates.io/crates/chrono/chrono-0.4.45.crate"
180 +
CHECKSUM_SHA256_57="1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
181 +
SOURCE_FILENAME_57="chrono-0.4.45.tar.gz"
182 +
SOURCE_URI_58="https://static.crates.io/crates/clap/clap-4.6.7.crate"
183 +
CHECKSUM_SHA256_58="aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
184 +
SOURCE_FILENAME_58="clap-4.6.7.tar.gz"
185 +
SOURCE_URI_59="https://static.crates.io/crates/clap_builder/clap_builder-4.6.7.crate"
186 +
CHECKSUM_SHA256_59="ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
187 +
SOURCE_FILENAME_59="clap_builder-4.6.7.tar.gz"
188 +
SOURCE_URI_60="https://static.crates.io/crates/clap_derive/clap_derive-4.6.7.crate"
189 +
CHECKSUM_SHA256_60="f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
190 +
SOURCE_FILENAME_60="clap_derive-4.6.7.tar.gz"
191 +
SOURCE_URI_61="https://static.crates.io/crates/clap_lex/clap_lex-1.1.1.crate"
192 +
CHECKSUM_SHA256_61="1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
193 +
SOURCE_FILENAME_61="clap_lex-1.1.1.tar.gz"
194 +
SOURCE_URI_62="https://static.crates.io/crates/cmov/cmov-0.5.4.crate"
195 +
CHECKSUM_SHA256_62="0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
196 +
SOURCE_FILENAME_62="cmov-0.5.4.tar.gz"
197 +
SOURCE_URI_63="https://static.crates.io/crates/colorchoice/colorchoice-1.0.5.crate"
198 +
CHECKSUM_SHA256_63="1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
199 +
SOURCE_FILENAME_63="colorchoice-1.0.5.tar.gz"
200 +
SOURCE_URI_64="https://static.crates.io/crates/compression-codecs/compression-codecs-0.4.43.crate"
201 +
CHECKSUM_SHA256_64="bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
202 +
SOURCE_FILENAME_64="compression-codecs-0.4.43.tar.gz"
203 +
SOURCE_URI_65="https://static.crates.io/crates/compression-core/compression-core-0.4.33.crate"
204 +
CHECKSUM_SHA256_65="6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
205 +
SOURCE_FILENAME_65="compression-core-0.4.33.tar.gz"
206 +
SOURCE_URI_66="https://static.crates.io/crates/const-oid/const-oid-0.10.2.crate"
207 +
CHECKSUM_SHA256_66="a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
208 +
SOURCE_FILENAME_66="const-oid-0.10.2.tar.gz"
209 +
SOURCE_URI_67="https://static.crates.io/crates/const-oid/const-oid-0.9.6.crate"
210 +
CHECKSUM_SHA256_67="c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
211 +
SOURCE_FILENAME_67="const-oid-0.9.6.tar.gz"
212 +
SOURCE_URI_68="https://static.crates.io/crates/constant_time_eq/constant_time_eq-0.4.2.crate"
213 +
CHECKSUM_SHA256_68="3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
214 +
SOURCE_FILENAME_68="constant_time_eq-0.4.2.tar.gz"
215 +
SOURCE_URI_69="https://static.crates.io/crates/cookie/cookie-0.18.2.crate"
216 +
CHECKSUM_SHA256_69="1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
217 +
SOURCE_FILENAME_69="cookie-0.18.2.tar.gz"
218 +
SOURCE_URI_70="https://static.crates.io/crates/core_detect/core_detect-1.0.0.crate"
219 +
CHECKSUM_SHA256_70="7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
220 +
SOURCE_FILENAME_70="core_detect-1.0.0.tar.gz"
221 +
SOURCE_URI_71="https://static.crates.io/crates/cpufeatures/cpufeatures-0.2.17.crate"
222 +
CHECKSUM_SHA256_71="59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
223 +
SOURCE_FILENAME_71="cpufeatures-0.2.17.tar.gz"
224 +
SOURCE_URI_72="https://static.crates.io/crates/cpufeatures/cpufeatures-0.3.1.crate"
225 +
CHECKSUM_SHA256_72="5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
226 +
SOURCE_FILENAME_72="cpufeatures-0.3.1.tar.gz"
227 +
SOURCE_URI_73="https://static.crates.io/crates/crc/crc-3.4.0.crate"
228 +
CHECKSUM_SHA256_73="5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
229 +
SOURCE_FILENAME_73="crc-3.4.0.tar.gz"
230 +
SOURCE_URI_74="https://static.crates.io/crates/crc-catalog/crc-catalog-2.5.0.crate"
231 +
CHECKSUM_SHA256_74="217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
232 +
SOURCE_FILENAME_74="crc-catalog-2.5.0.tar.gz"
233 +
SOURCE_URI_75="https://static.crates.io/crates/crc32fast/crc32fast-1.5.2.crate"
234 +
CHECKSUM_SHA256_75="01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
235 +
SOURCE_FILENAME_75="crc32fast-1.5.2.tar.gz"
236 +
SOURCE_URI_76="https://static.crates.io/crates/crossbeam-deque/crossbeam-deque-0.8.8.crate"
237 +
CHECKSUM_SHA256_76="622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
238 +
SOURCE_FILENAME_76="crossbeam-deque-0.8.8.tar.gz"
239 +
SOURCE_URI_77="https://static.crates.io/crates/crossbeam-epoch/crossbeam-epoch-0.9.21.crate"
240 +
CHECKSUM_SHA256_77="dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
241 +
SOURCE_FILENAME_77="crossbeam-epoch-0.9.21.tar.gz"
242 +
SOURCE_URI_78="https://static.crates.io/crates/crossbeam-queue/crossbeam-queue-0.3.14.crate"
243 +
CHECKSUM_SHA256_78="03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
244 +
SOURCE_FILENAME_78="crossbeam-queue-0.3.14.tar.gz"
245 +
SOURCE_URI_79="https://static.crates.io/crates/crossbeam-utils/crossbeam-utils-0.8.23.crate"
246 +
CHECKSUM_SHA256_79="a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
247 +
SOURCE_FILENAME_79="crossbeam-utils-0.8.23.tar.gz"
248 +
SOURCE_URI_80="https://static.crates.io/crates/crypto-common/crypto-common-0.1.7.crate"
249 +
CHECKSUM_SHA256_80="78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
250 +
SOURCE_FILENAME_80="crypto-common-0.1.7.tar.gz"
251 +
SOURCE_URI_81="https://static.crates.io/crates/crypto-common/crypto-common-0.2.2.crate"
252 +
CHECKSUM_SHA256_81="ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
253 +
SOURCE_FILENAME_81="crypto-common-0.2.2.tar.gz"
254 +
SOURCE_URI_82="https://static.crates.io/crates/cssparser/cssparser-0.38.0.crate"
255 +
CHECKSUM_SHA256_82="11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59"
256 +
SOURCE_FILENAME_82="cssparser-0.38.0.tar.gz"
257 +
SOURCE_URI_83="https://static.crates.io/crates/ctutils/ctutils-0.4.2.crate"
258 +
CHECKSUM_SHA256_83="7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
259 +
SOURCE_FILENAME_83="ctutils-0.4.2.tar.gz"
260 +
SOURCE_URI_84="https://static.crates.io/crates/curve25519-dalek/curve25519-dalek-5.0.0.crate"
261 +
CHECKSUM_SHA256_84="b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
262 +
SOURCE_FILENAME_84="curve25519-dalek-5.0.0.tar.gz"
263 +
SOURCE_URI_85="https://static.crates.io/crates/curve25519-dalek-derive/curve25519-dalek-derive-0.1.1.crate"
264 +
CHECKSUM_SHA256_85="f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
265 +
SOURCE_FILENAME_85="curve25519-dalek-derive-0.1.1.tar.gz"
266 +
SOURCE_URI_86="https://static.crates.io/crates/data-encoding/data-encoding-2.11.1.crate"
267 +
CHECKSUM_SHA256_86="4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
268 +
SOURCE_FILENAME_86="data-encoding-2.11.1.tar.gz"
269 +
SOURCE_URI_87="https://static.crates.io/crates/der/der-0.7.10.crate"
270 +
CHECKSUM_SHA256_87="e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
271 +
SOURCE_FILENAME_87="der-0.7.10.tar.gz"
272 +
SOURCE_URI_88="https://static.crates.io/crates/der/der-0.8.2.crate"
273 +
CHECKSUM_SHA256_88="a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
274 +
SOURCE_FILENAME_88="der-0.8.2.tar.gz"
275 +
SOURCE_URI_89="https://static.crates.io/crates/der-parser/der-parser-10.0.0.crate"
276 +
CHECKSUM_SHA256_89="07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
277 +
SOURCE_FILENAME_89="der-parser-10.0.0.tar.gz"
278 +
SOURCE_URI_90="https://static.crates.io/crates/deranged/deranged-0.5.8.crate"
279 +
CHECKSUM_SHA256_90="7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
280 +
SOURCE_FILENAME_90="deranged-0.5.8.tar.gz"
281 +
SOURCE_URI_91="https://static.crates.io/crates/derive_arbitrary/derive_arbitrary-1.4.2.crate"
282 +
CHECKSUM_SHA256_91="1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
283 +
SOURCE_FILENAME_91="derive_arbitrary-1.4.2.tar.gz"
284 +
SOURCE_URI_92="https://static.crates.io/crates/digest/digest-0.10.7.crate"
285 +
CHECKSUM_SHA256_92="9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
286 +
SOURCE_FILENAME_92="digest-0.10.7.tar.gz"
287 +
SOURCE_URI_93="https://static.crates.io/crates/digest/digest-0.11.3.crate"
288 +
CHECKSUM_SHA256_93="f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
289 +
SOURCE_FILENAME_93="digest-0.11.3.tar.gz"
290 +
SOURCE_URI_94="https://static.crates.io/crates/displaydoc/displaydoc-0.2.7.crate"
291 +
CHECKSUM_SHA256_94="c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
292 +
SOURCE_FILENAME_94="displaydoc-0.2.7.tar.gz"
293 +
SOURCE_URI_95="https://static.crates.io/crates/dotenvy/dotenvy-0.15.7.crate"
294 +
CHECKSUM_SHA256_95="1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
295 +
SOURCE_FILENAME_95="dotenvy-0.15.7.tar.gz"
296 +
SOURCE_URI_96="https://static.crates.io/crates/dtoa/dtoa-1.0.11.crate"
297 +
CHECKSUM_SHA256_96="4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
298 +
SOURCE_FILENAME_96="dtoa-1.0.11.tar.gz"
299 +
SOURCE_URI_97="https://static.crates.io/crates/dtoa-short/dtoa-short-0.3.5.crate"
300 +
CHECKSUM_SHA256_97="cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
301 +
SOURCE_FILENAME_97="dtoa-short-0.3.5.tar.gz"
302 +
SOURCE_URI_98="https://static.crates.io/crates/dyn-clone/dyn-clone-1.0.20.crate"
303 +
CHECKSUM_SHA256_98="d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
304 +
SOURCE_FILENAME_98="dyn-clone-1.0.20.tar.gz"
305 +
SOURCE_URI_99="https://static.crates.io/crates/ed25519/ed25519-3.0.0.crate"
306 +
CHECKSUM_SHA256_99="29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
307 +
SOURCE_FILENAME_99="ed25519-3.0.0.tar.gz"
308 +
SOURCE_URI_100="https://static.crates.io/crates/ed25519-dalek/ed25519-dalek-3.0.0.crate"
309 +
CHECKSUM_SHA256_100="6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
310 +
SOURCE_FILENAME_100="ed25519-dalek-3.0.0.tar.gz"
311 +
SOURCE_URI_101="https://static.crates.io/crates/either/either-1.18.0.crate"
312 +
CHECKSUM_SHA256_101="252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
313 +
SOURCE_FILENAME_101="either-1.18.0.tar.gz"
314 +
SOURCE_URI_102="https://static.crates.io/crates/email-encoding/email-encoding-0.4.2.crate"
315 +
CHECKSUM_SHA256_102="420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd"
316 +
SOURCE_FILENAME_102="email-encoding-0.4.2.tar.gz"
317 +
SOURCE_URI_103="https://static.crates.io/crates/email_address/email_address-0.2.9.crate"
318 +
CHECKSUM_SHA256_103="e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
319 +
SOURCE_FILENAME_103="email_address-0.2.9.tar.gz"
320 +
SOURCE_URI_104="https://static.crates.io/crates/encoding_rs/encoding_rs-0.8.41.crate"
321 +
CHECKSUM_SHA256_104="7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a"
322 +
SOURCE_FILENAME_104="encoding_rs-0.8.41.tar.gz"
323 +
SOURCE_URI_105="https://static.crates.io/crates/equivalent/equivalent-1.0.2.crate"
324 +
CHECKSUM_SHA256_105="877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
325 +
SOURCE_FILENAME_105="equivalent-1.0.2.tar.gz"
326 +
SOURCE_URI_106="https://static.crates.io/crates/errno/errno-0.3.14.crate"
327 +
CHECKSUM_SHA256_106="39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
328 +
SOURCE_FILENAME_106="errno-0.3.14.tar.gz"
329 +
SOURCE_URI_107="https://static.crates.io/crates/etcetera/etcetera-0.8.0.crate"
330 +
CHECKSUM_SHA256_107="136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
331 +
SOURCE_FILENAME_107="etcetera-0.8.0.tar.gz"
332 +
SOURCE_URI_108="https://static.crates.io/crates/event-listener/event-listener-5.4.2.crate"
333 +
CHECKSUM_SHA256_108="5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
334 +
SOURCE_FILENAME_108="event-listener-5.4.2.tar.gz"
335 +
SOURCE_URI_109="https://static.crates.io/crates/fancy-regex/fancy-regex-0.16.2.crate"
336 +
CHECKSUM_SHA256_109="998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
337 +
SOURCE_FILENAME_109="fancy-regex-0.16.2.tar.gz"
338 +
SOURCE_URI_110="https://static.crates.io/crates/fastrand/fastrand-2.5.0.crate"
339 +
CHECKSUM_SHA256_110="da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
340 +
SOURCE_FILENAME_110="fastrand-2.5.0.tar.gz"
341 +
SOURCE_URI_111="https://static.crates.io/crates/fdeflate/fdeflate-0.3.7.crate"
342 +
CHECKSUM_SHA256_111="1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
343 +
SOURCE_FILENAME_111="fdeflate-0.3.7.tar.gz"
344 +
SOURCE_URI_112="https://static.crates.io/crates/fiat-crypto/fiat-crypto-0.3.0.crate"
345 +
CHECKSUM_SHA256_112="64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
346 +
SOURCE_FILENAME_112="fiat-crypto-0.3.0.tar.gz"
347 +
SOURCE_URI_113="https://static.crates.io/crates/filetime/filetime-0.2.29.crate"
348 +
CHECKSUM_SHA256_113="5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
349 +
SOURCE_FILENAME_113="filetime-0.2.29.tar.gz"
350 +
SOURCE_URI_114="https://static.crates.io/crates/find-msvc-tools/find-msvc-tools-0.1.14.crate"
351 +
CHECKSUM_SHA256_114="aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
352 +
SOURCE_FILENAME_114="find-msvc-tools-0.1.14.tar.gz"
353 +
SOURCE_URI_115="https://static.crates.io/crates/flate2/flate2-1.1.10.crate"
354 +
CHECKSUM_SHA256_115="6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
355 +
SOURCE_FILENAME_115="flate2-1.1.10.tar.gz"
356 +
SOURCE_URI_116="https://static.crates.io/crates/flume/flume-0.11.1.crate"
357 +
CHECKSUM_SHA256_116="da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
358 +
SOURCE_FILENAME_116="flume-0.11.1.tar.gz"
359 +
SOURCE_URI_117="https://static.crates.io/crates/fnv/fnv-1.0.7.crate"
360 +
CHECKSUM_SHA256_117="3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
361 +
SOURCE_FILENAME_117="fnv-1.0.7.tar.gz"
362 +
SOURCE_URI_118="https://static.crates.io/crates/foldhash/foldhash-0.1.5.crate"
363 +
CHECKSUM_SHA256_118="d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
364 +
SOURCE_FILENAME_118="foldhash-0.1.5.tar.gz"
365 +
SOURCE_URI_119="https://static.crates.io/crates/form_urlencoded/form_urlencoded-1.2.2.crate"
366 +
CHECKSUM_SHA256_119="cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
367 +
SOURCE_FILENAME_119="form_urlencoded-1.2.2.tar.gz"
368 +
SOURCE_URI_120="https://static.crates.io/crates/fs-err/fs-err-3.3.1.crate"
369 +
CHECKSUM_SHA256_120="b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a"
370 +
SOURCE_FILENAME_120="fs-err-3.3.1.tar.gz"
371 +
SOURCE_URI_121="https://static.crates.io/crates/futures/futures-0.3.34.crate"
372 +
CHECKSUM_SHA256_121="9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
373 +
SOURCE_FILENAME_121="futures-0.3.34.tar.gz"
374 +
SOURCE_URI_122="https://static.crates.io/crates/futures-channel/futures-channel-0.3.34.crate"
375 +
CHECKSUM_SHA256_122="b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
376 +
SOURCE_FILENAME_122="futures-channel-0.3.34.tar.gz"
377 +
SOURCE_URI_123="https://static.crates.io/crates/futures-core/futures-core-0.3.34.crate"
378 +
CHECKSUM_SHA256_123="92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
379 +
SOURCE_FILENAME_123="futures-core-0.3.34.tar.gz"
380 +
SOURCE_URI_124="https://static.crates.io/crates/futures-executor/futures-executor-0.3.34.crate"
381 +
CHECKSUM_SHA256_124="031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
382 +
SOURCE_FILENAME_124="futures-executor-0.3.34.tar.gz"
383 +
SOURCE_URI_125="https://static.crates.io/crates/futures-intrusive/futures-intrusive-0.5.0.crate"
384 +
CHECKSUM_SHA256_125="1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
385 +
SOURCE_FILENAME_125="futures-intrusive-0.5.0.tar.gz"
386 +
SOURCE_URI_126="https://static.crates.io/crates/futures-io/futures-io-0.3.34.crate"
387 +
CHECKSUM_SHA256_126="53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
388 +
SOURCE_FILENAME_126="futures-io-0.3.34.tar.gz"
389 +
SOURCE_URI_127="https://static.crates.io/crates/futures-macro/futures-macro-0.3.34.crate"
390 +
CHECKSUM_SHA256_127="9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
391 +
SOURCE_FILENAME_127="futures-macro-0.3.34.tar.gz"
392 +
SOURCE_URI_128="https://static.crates.io/crates/futures-sink/futures-sink-0.3.34.crate"
393 +
CHECKSUM_SHA256_128="1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
394 +
SOURCE_FILENAME_128="futures-sink-0.3.34.tar.gz"
395 +
SOURCE_URI_129="https://static.crates.io/crates/futures-task/futures-task-0.3.34.crate"
396 +
CHECKSUM_SHA256_129="cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
397 +
SOURCE_FILENAME_129="futures-task-0.3.34.tar.gz"
398 +
SOURCE_URI_130="https://static.crates.io/crates/futures-util/futures-util-0.3.34.crate"
399 +
CHECKSUM_SHA256_130="0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
400 +
SOURCE_FILENAME_130="futures-util-0.3.34.tar.gz"
401 +
SOURCE_URI_131="https://static.crates.io/crates/generic-array/generic-array-0.14.7.crate"
402 +
CHECKSUM_SHA256_131="85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
403 +
SOURCE_FILENAME_131="generic-array-0.14.7.tar.gz"
404 +
SOURCE_URI_132="https://static.crates.io/crates/getrandom/getrandom-0.2.17.crate"
405 +
CHECKSUM_SHA256_132="ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
406 +
SOURCE_FILENAME_132="getrandom-0.2.17.tar.gz"
407 +
SOURCE_URI_133="https://static.crates.io/crates/getrandom/getrandom-0.4.3.crate"
408 +
CHECKSUM_SHA256_133="300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
409 +
SOURCE_FILENAME_133="getrandom-0.4.3.tar.gz"
410 +
SOURCE_URI_134="https://static.crates.io/crates/globset/globset-0.4.20.crate"
411 +
CHECKSUM_SHA256_134="07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
412 +
SOURCE_FILENAME_134="globset-0.4.20.tar.gz"
413 +
SOURCE_URI_135="https://static.crates.io/crates/h2/h2-0.4.19.crate"
414 +
CHECKSUM_SHA256_135="ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
415 +
SOURCE_FILENAME_135="h2-0.4.19.tar.gz"
416 +
SOURCE_URI_136="https://static.crates.io/crates/hashbrown/hashbrown-0.15.5.crate"
417 +
CHECKSUM_SHA256_136="9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
418 +
SOURCE_FILENAME_136="hashbrown-0.15.5.tar.gz"
419 +
SOURCE_URI_137="https://static.crates.io/crates/hashbrown/hashbrown-0.17.1.crate"
420 +
CHECKSUM_SHA256_137="ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
421 +
SOURCE_FILENAME_137="hashbrown-0.17.1.tar.gz"
422 +
SOURCE_URI_138="https://static.crates.io/crates/hashlink/hashlink-0.10.0.crate"
423 +
CHECKSUM_SHA256_138="7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
424 +
SOURCE_FILENAME_138="hashlink-0.10.0.tar.gz"
425 +
SOURCE_URI_139="https://static.crates.io/crates/heck/heck-0.5.0.crate"
426 +
CHECKSUM_SHA256_139="2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
427 +
SOURCE_FILENAME_139="heck-0.5.0.tar.gz"
428 +
SOURCE_URI_140="https://static.crates.io/crates/hex/hex-0.4.3.crate"
429 +
CHECKSUM_SHA256_140="7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
430 +
SOURCE_FILENAME_140="hex-0.4.3.tar.gz"
431 +
SOURCE_URI_141="https://static.crates.io/crates/hkdf/hkdf-0.12.4.crate"
432 +
CHECKSUM_SHA256_141="7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
433 +
SOURCE_FILENAME_141="hkdf-0.12.4.tar.gz"
434 +
SOURCE_URI_142="https://static.crates.io/crates/hmac/hmac-0.12.1.crate"
435 +
CHECKSUM_SHA256_142="6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
436 +
SOURCE_FILENAME_142="hmac-0.12.1.tar.gz"
437 +
SOURCE_URI_143="https://static.crates.io/crates/home/home-0.5.12.crate"
438 +
CHECKSUM_SHA256_143="cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
439 +
SOURCE_FILENAME_143="home-0.5.12.tar.gz"
440 +
SOURCE_URI_144="https://static.crates.io/crates/html5ever/html5ever-0.40.1.crate"
441 +
CHECKSUM_SHA256_144="456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130"
442 +
SOURCE_FILENAME_144="html5ever-0.40.1.tar.gz"
443 +
SOURCE_URI_145="https://static.crates.io/crates/http/http-1.5.0.crate"
444 +
CHECKSUM_SHA256_145="918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
445 +
SOURCE_FILENAME_145="http-1.5.0.tar.gz"
446 +
SOURCE_URI_146="https://static.crates.io/crates/http-body/http-body-1.1.0.crate"
447 +
CHECKSUM_SHA256_146="ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
448 +
SOURCE_FILENAME_146="http-body-1.1.0.tar.gz"
449 +
SOURCE_URI_147="https://static.crates.io/crates/http-body-util/http-body-util-0.1.5.crate"
450 +
CHECKSUM_SHA256_147="23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
451 +
SOURCE_FILENAME_147="http-body-util-0.1.5.tar.gz"
452 +
SOURCE_URI_148="https://static.crates.io/crates/httparse/httparse-1.10.1.crate"
453 +
CHECKSUM_SHA256_148="6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
454 +
SOURCE_FILENAME_148="httparse-1.10.1.tar.gz"
455 +
SOURCE_URI_149="https://static.crates.io/crates/httpdate/httpdate-1.0.3.crate"
456 +
CHECKSUM_SHA256_149="df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
457 +
SOURCE_FILENAME_149="httpdate-1.0.3.tar.gz"
458 +
SOURCE_URI_150="https://static.crates.io/crates/humantime/humantime-2.4.0.crate"
459 +
CHECKSUM_SHA256_150="15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
460 +
SOURCE_FILENAME_150="humantime-2.4.0.tar.gz"
461 +
SOURCE_URI_151="https://static.crates.io/crates/hybrid-array/hybrid-array-0.4.15.crate"
462 +
CHECKSUM_SHA256_151="27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
463 +
SOURCE_FILENAME_151="hybrid-array-0.4.15.tar.gz"
464 +
SOURCE_URI_152="https://static.crates.io/crates/hyper/hyper-1.11.1.crate"
465 +
CHECKSUM_SHA256_152="27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
466 +
SOURCE_FILENAME_152="hyper-1.11.1.tar.gz"
467 +
SOURCE_URI_153="https://static.crates.io/crates/hyper-rustls/hyper-rustls-0.27.10.crate"
468 +
CHECKSUM_SHA256_153="dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
469 +
SOURCE_FILENAME_153="hyper-rustls-0.27.10.tar.gz"
470 +
SOURCE_URI_154="https://static.crates.io/crates/hyper-util/hyper-util-0.1.21.crate"
471 +
CHECKSUM_SHA256_154="ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
472 +
SOURCE_FILENAME_154="hyper-util-0.1.21.tar.gz"
473 +
SOURCE_URI_155="https://static.crates.io/crates/iana-time-zone-haiku/iana-time-zone-haiku-0.1.2.crate"
474 +
CHECKSUM_SHA256_155="f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
475 +
SOURCE_FILENAME_155="iana-time-zone-haiku-0.1.2.tar.gz"
476 +
SOURCE_URI_156="https://static.crates.io/crates/icu_collections/icu_collections-2.3.0.crate"
477 +
CHECKSUM_SHA256_156="fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
478 +
SOURCE_FILENAME_156="icu_collections-2.3.0.tar.gz"
479 +
SOURCE_URI_157="https://static.crates.io/crates/icu_locale_core/icu_locale_core-2.3.0.crate"
480 +
CHECKSUM_SHA256_157="d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
481 +
SOURCE_FILENAME_157="icu_locale_core-2.3.0.tar.gz"
482 +
SOURCE_URI_158="https://static.crates.io/crates/icu_normalizer/icu_normalizer-2.3.0.crate"
483 +
CHECKSUM_SHA256_158="12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
484 +
SOURCE_FILENAME_158="icu_normalizer-2.3.0.tar.gz"
485 +
SOURCE_URI_159="https://static.crates.io/crates/icu_normalizer_data/icu_normalizer_data-2.3.0.crate"
486 +
CHECKSUM_SHA256_159="1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
487 +
SOURCE_FILENAME_159="icu_normalizer_data-2.3.0.tar.gz"
488 +
SOURCE_URI_160="https://static.crates.io/crates/icu_properties/icu_properties-2.3.0.crate"
489 +
CHECKSUM_SHA256_160="7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
490 +
SOURCE_FILENAME_160="icu_properties-2.3.0.tar.gz"
491 +
SOURCE_URI_161="https://static.crates.io/crates/icu_properties_data/icu_properties_data-2.3.0.crate"
492 +
CHECKSUM_SHA256_161="e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
493 +
SOURCE_FILENAME_161="icu_properties_data-2.3.0.tar.gz"
494 +
SOURCE_URI_162="https://static.crates.io/crates/icu_provider/icu_provider-2.3.1.crate"
495 +
CHECKSUM_SHA256_162="d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
496 +
SOURCE_FILENAME_162="icu_provider-2.3.1.tar.gz"
497 +
SOURCE_URI_163="https://static.crates.io/crates/idna/idna-1.1.0.crate"
498 +
CHECKSUM_SHA256_163="3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
499 +
SOURCE_FILENAME_163="idna-1.1.0.tar.gz"
500 +
SOURCE_URI_164="https://static.crates.io/crates/idna_adapter/idna_adapter-1.2.2.crate"
501 +
CHECKSUM_SHA256_164="cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
502 +
SOURCE_FILENAME_164="idna_adapter-1.2.2.tar.gz"
503 +
SOURCE_URI_165="https://static.crates.io/crates/ignore/ignore-0.4.33.crate"
504 +
CHECKSUM_SHA256_165="00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f"
505 +
SOURCE_FILENAME_165="ignore-0.4.33.tar.gz"
506 +
SOURCE_URI_166="https://static.crates.io/crates/image/image-0.25.10.crate"
507 +
CHECKSUM_SHA256_166="85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
508 +
SOURCE_FILENAME_166="image-0.25.10.tar.gz"
509 +
SOURCE_URI_167="https://static.crates.io/crates/image-webp/image-webp-0.2.4.crate"
510 +
CHECKSUM_SHA256_167="525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
511 +
SOURCE_FILENAME_167="image-webp-0.2.4.tar.gz"
512 +
SOURCE_URI_168="https://static.crates.io/crates/indexmap/indexmap-2.14.2.crate"
513 +
CHECKSUM_SHA256_168="cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
514 +
SOURCE_FILENAME_168="indexmap-2.14.2.tar.gz"
515 +
SOURCE_URI_169="https://static.crates.io/crates/ipnet/ipnet-2.12.2.crate"
516 +
CHECKSUM_SHA256_169="791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
517 +
SOURCE_FILENAME_169="ipnet-2.12.2.tar.gz"
518 +
SOURCE_URI_170="https://static.crates.io/crates/is_terminal_polyfill/is_terminal_polyfill-1.70.2.crate"
519 +
CHECKSUM_SHA256_170="a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
520 +
SOURCE_FILENAME_170="is_terminal_polyfill-1.70.2.tar.gz"
521 +
SOURCE_URI_171="https://static.crates.io/crates/itertools/itertools-0.15.0.crate"
522 +
CHECKSUM_SHA256_171="8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
523 +
SOURCE_FILENAME_171="itertools-0.15.0.tar.gz"
524 +
SOURCE_URI_172="https://static.crates.io/crates/itoa/itoa-1.0.18.crate"
525 +
CHECKSUM_SHA256_172="8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
526 +
SOURCE_FILENAME_172="itoa-1.0.18.tar.gz"
527 +
SOURCE_URI_173="https://static.crates.io/crates/jobserver/jobserver-0.1.35.crate"
528 +
CHECKSUM_SHA256_173="1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
529 +
SOURCE_FILENAME_173="jobserver-0.1.35.tar.gz"
530 +
SOURCE_URI_174="https://static.crates.io/crates/js-sys/js-sys-0.3.106.crate"
531 +
CHECKSUM_SHA256_174="7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
532 +
SOURCE_FILENAME_174="js-sys-0.3.106.tar.gz"
533 +
SOURCE_URI_175="https://static.crates.io/crates/kdl/kdl-6.7.1.crate"
534 +
CHECKSUM_SHA256_175="082ddf81b2acd76fe04412655d17befedfff1837db772f8e74c38050d25ed670"
535 +
SOURCE_FILENAME_175="kdl-6.7.1.tar.gz"
536 +
SOURCE_URI_176="https://static.crates.io/crates/keccak/keccak-0.2.2.crate"
537 +
CHECKSUM_SHA256_176="d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598"
538 +
SOURCE_FILENAME_176="keccak-0.2.2.tar.gz"
539 +
SOURCE_URI_177="https://static.crates.io/crates/lazy_static/lazy_static-1.5.0.crate"
540 +
CHECKSUM_SHA256_177="bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
541 +
SOURCE_FILENAME_177="lazy_static-1.5.0.tar.gz"
542 +
SOURCE_URI_178="https://static.crates.io/crates/lettre/lettre-0.11.23.crate"
543 +
CHECKSUM_SHA256_178="f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae"
544 +
SOURCE_FILENAME_178="lettre-0.11.23.tar.gz"
545 +
SOURCE_URI_179="https://static.crates.io/crates/libc/libc-0.2.189.crate"
546 +
CHECKSUM_SHA256_179="3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
547 +
SOURCE_FILENAME_179="libc-0.2.189.tar.gz"
548 +
SOURCE_URI_180="https://static.crates.io/crates/libm/libm-0.2.16.crate"
549 +
CHECKSUM_SHA256_180="b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
550 +
SOURCE_FILENAME_180="libm-0.2.16.tar.gz"
551 +
SOURCE_URI_181="https://static.crates.io/crates/libredox/libredox-0.1.25.crate"
552 +
CHECKSUM_SHA256_181="61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
553 +
SOURCE_FILENAME_181="libredox-0.1.25.tar.gz"
554 +
SOURCE_URI_182="https://static.crates.io/crates/libsqlite3-sys/libsqlite3-sys-0.30.1.crate"
555 +
CHECKSUM_SHA256_182="2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
556 +
SOURCE_FILENAME_182="libsqlite3-sys-0.30.1.tar.gz"
557 +
SOURCE_URI_183="https://static.crates.io/crates/linked-hash-map/linked-hash-map-0.5.6.crate"
558 +
CHECKSUM_SHA256_183="0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
559 +
SOURCE_FILENAME_183="linked-hash-map-0.5.6.tar.gz"
560 +
SOURCE_URI_184="https://static.crates.io/crates/linux-raw-sys/linux-raw-sys-0.12.1.crate"
561 +
CHECKSUM_SHA256_184="32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
562 +
SOURCE_FILENAME_184="linux-raw-sys-0.12.1.tar.gz"
563 +
SOURCE_URI_185="https://static.crates.io/crates/litemap/litemap-0.8.3.crate"
564 +
CHECKSUM_SHA256_185="47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
565 +
SOURCE_FILENAME_185="litemap-0.8.3.tar.gz"
566 +
SOURCE_URI_186="https://static.crates.io/crates/lock_api/lock_api-0.4.14.crate"
567 +
CHECKSUM_SHA256_186="224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
568 +
SOURCE_FILENAME_186="lock_api-0.4.14.tar.gz"
569 +
SOURCE_URI_187="https://static.crates.io/crates/log/log-0.4.34.crate"
570 +
CHECKSUM_SHA256_187="f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
571 +
SOURCE_FILENAME_187="log-0.4.34.tar.gz"
572 +
SOURCE_URI_188="https://static.crates.io/crates/lru-slab/lru-slab-0.1.3.crate"
573 +
CHECKSUM_SHA256_188="4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
574 +
SOURCE_FILENAME_188="lru-slab-0.1.3.tar.gz"
575 +
SOURCE_URI_189="https://static.crates.io/crates/lzma-rs/lzma-rs-0.3.0.crate"
576 +
CHECKSUM_SHA256_189="297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
577 +
SOURCE_FILENAME_189="lzma-rs-0.3.0.tar.gz"
578 +
SOURCE_URI_190="https://static.crates.io/crates/maplit/maplit-1.0.2.crate"
579 +
CHECKSUM_SHA256_190="3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
580 +
SOURCE_FILENAME_190="maplit-1.0.2.tar.gz"
581 +
SOURCE_URI_191="https://static.crates.io/crates/markup5ever/markup5ever-0.40.0.crate"
582 +
CHECKSUM_SHA256_191="0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842"
583 +
SOURCE_FILENAME_191="markup5ever-0.40.0.tar.gz"
584 +
SOURCE_URI_192="https://static.crates.io/crates/matchers/matchers-0.2.0.crate"
585 +
CHECKSUM_SHA256_192="d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
586 +
SOURCE_FILENAME_192="matchers-0.2.0.tar.gz"
587 +
SOURCE_URI_193="https://static.crates.io/crates/matchit/matchit-0.8.4.crate"
588 +
CHECKSUM_SHA256_193="47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
589 +
SOURCE_FILENAME_193="matchit-0.8.4.tar.gz"
590 +
SOURCE_URI_194="https://static.crates.io/crates/md-5/md-5-0.10.6.crate"
591 +
CHECKSUM_SHA256_194="d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
592 +
SOURCE_FILENAME_194="md-5-0.10.6.tar.gz"
593 +
SOURCE_URI_195="https://static.crates.io/crates/md-5/md-5-0.11.0.crate"
594 +
CHECKSUM_SHA256_195="69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
595 +
SOURCE_FILENAME_195="md-5-0.11.0.tar.gz"
596 +
SOURCE_URI_196="https://static.crates.io/crates/memchr/memchr-2.8.3.crate"
597 +
CHECKSUM_SHA256_196="cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
598 +
SOURCE_FILENAME_196="memchr-2.8.3.tar.gz"
599 +
SOURCE_URI_197="https://static.crates.io/crates/miette/miette-7.6.0.crate"
600 +
CHECKSUM_SHA256_197="5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
601 +
SOURCE_FILENAME_197="miette-7.6.0.tar.gz"
602 +
SOURCE_URI_198="https://static.crates.io/crates/miette-derive/miette-derive-7.6.0.crate"
603 +
CHECKSUM_SHA256_198="db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
604 +
SOURCE_FILENAME_198="miette-derive-7.6.0.tar.gz"
605 +
SOURCE_URI_199="https://static.crates.io/crates/mime/mime-0.3.17.crate"
606 +
CHECKSUM_SHA256_199="6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
607 +
SOURCE_FILENAME_199="mime-0.3.17.tar.gz"
608 +
SOURCE_URI_200="https://static.crates.io/crates/minimal-lexical/minimal-lexical-0.2.1.crate"
609 +
CHECKSUM_SHA256_200="68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
610 +
SOURCE_FILENAME_200="minimal-lexical-0.2.1.tar.gz"
611 +
SOURCE_URI_201="https://static.crates.io/crates/miniz_oxide/miniz_oxide-0.8.9.crate"
612 +
CHECKSUM_SHA256_201="1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
613 +
SOURCE_FILENAME_201="miniz_oxide-0.8.9.tar.gz"
614 +
SOURCE_URI_202="https://static.crates.io/crates/miniz_oxide/miniz_oxide-0.9.1.crate"
615 +
CHECKSUM_SHA256_202="b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
616 +
SOURCE_FILENAME_202="miniz_oxide-0.9.1.tar.gz"
617 +
SOURCE_URI_203="https://static.crates.io/crates/mio/mio-1.2.3.crate"
618 +
CHECKSUM_SHA256_203="4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
619 +
SOURCE_FILENAME_203="mio-1.2.3.tar.gz"
620 +
SOURCE_URI_204="https://static.crates.io/crates/ml-dsa/ml-dsa-0.1.1.crate"
621 +
CHECKSUM_SHA256_204="add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d"
622 +
SOURCE_FILENAME_204="ml-dsa-0.1.1.tar.gz"
623 +
SOURCE_URI_205="https://static.crates.io/crates/module-lattice/module-lattice-0.2.3.crate"
624 +
CHECKSUM_SHA256_205="0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe"
625 +
SOURCE_FILENAME_205="module-lattice-0.2.3.tar.gz"
626 +
SOURCE_URI_206="https://static.crates.io/crates/moxcms/moxcms-0.8.1.crate"
627 +
CHECKSUM_SHA256_206="bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
628 +
SOURCE_FILENAME_206="moxcms-0.8.1.tar.gz"
629 +
SOURCE_URI_207="https://static.crates.io/crates/multer/multer-3.1.0.crate"
630 +
CHECKSUM_SHA256_207="83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
631 +
SOURCE_FILENAME_207="multer-3.1.0.tar.gz"
632 +
SOURCE_URI_208="https://static.crates.io/crates/multiversion/multiversion-0.9.0.crate"
633 +
CHECKSUM_SHA256_208="b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c"
634 +
SOURCE_FILENAME_208="multiversion-0.9.0.tar.gz"
635 +
SOURCE_URI_209="https://static.crates.io/crates/multiversion-macros/multiversion-macros-0.9.0.crate"
636 +
CHECKSUM_SHA256_209="0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83"
637 +
SOURCE_FILENAME_209="multiversion-macros-0.9.0.tar.gz"
638 +
SOURCE_URI_210="https://static.crates.io/crates/multiversion_no_op/multiversion_no_op-1.0.0.crate"
639 +
CHECKSUM_SHA256_210="743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
640 +
SOURCE_FILENAME_210="multiversion_no_op-1.0.0.tar.gz"
641 +
SOURCE_URI_211="https://static.crates.io/crates/new_debug_unreachable/new_debug_unreachable-1.0.6.crate"
642 +
CHECKSUM_SHA256_211="650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
643 +
SOURCE_FILENAME_211="new_debug_unreachable-1.0.6.tar.gz"
644 +
SOURCE_URI_212="https://static.crates.io/crates/nom/nom-7.1.3.crate"
645 +
CHECKSUM_SHA256_212="d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
646 +
SOURCE_FILENAME_212="nom-7.1.3.tar.gz"
647 +
SOURCE_URI_213="https://static.crates.io/crates/nom/nom-8.0.0.crate"
648 +
CHECKSUM_SHA256_213="df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
649 +
SOURCE_FILENAME_213="nom-8.0.0.tar.gz"
650 +
SOURCE_URI_214="https://static.crates.io/crates/nu-ansi-term/nu-ansi-term-0.50.3.crate"
651 +
CHECKSUM_SHA256_214="7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
652 +
SOURCE_FILENAME_214="nu-ansi-term-0.50.3.tar.gz"
653 +
SOURCE_URI_215="https://static.crates.io/crates/num-bigint/num-bigint-0.4.8.crate"
654 +
CHECKSUM_SHA256_215="c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
655 +
SOURCE_FILENAME_215="num-bigint-0.4.8.tar.gz"
656 +
SOURCE_URI_216="https://static.crates.io/crates/num-bigint-dig/num-bigint-dig-0.8.6.crate"
657 +
CHECKSUM_SHA256_216="e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
658 +
SOURCE_FILENAME_216="num-bigint-dig-0.8.6.tar.gz"
659 +
SOURCE_URI_217="https://static.crates.io/crates/num-conv/num-conv-0.2.2.crate"
660 +
CHECKSUM_SHA256_217="521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
661 +
SOURCE_FILENAME_217="num-conv-0.2.2.tar.gz"
662 +
SOURCE_URI_218="https://static.crates.io/crates/num-integer/num-integer-0.1.47.crate"
663 +
CHECKSUM_SHA256_218="7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
664 +
SOURCE_FILENAME_218="num-integer-0.1.47.tar.gz"
665 +
SOURCE_URI_219="https://static.crates.io/crates/num-iter/num-iter-0.1.46.crate"
666 +
CHECKSUM_SHA256_219="c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
667 +
SOURCE_FILENAME_219="num-iter-0.1.46.tar.gz"
668 +
SOURCE_URI_220="https://static.crates.io/crates/num-traits/num-traits-0.2.19.crate"
669 +
CHECKSUM_SHA256_220="071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
670 +
SOURCE_FILENAME_220="num-traits-0.2.19.tar.gz"
671 +
SOURCE_URI_221="https://static.crates.io/crates/object_store/object_store-0.14.2.crate"
672 +
CHECKSUM_SHA256_221="f1796bc93603f78c5760a69f2d58badc9618d22adade0a95385bb2adbae4eb94"
673 +
SOURCE_FILENAME_221="object_store-0.14.2.tar.gz"
674 +
SOURCE_URI_222="https://static.crates.io/crates/oid-registry/oid-registry-0.8.1.crate"
675 +
CHECKSUM_SHA256_222="12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
676 +
SOURCE_FILENAME_222="oid-registry-0.8.1.tar.gz"
677 +
SOURCE_URI_223="https://static.crates.io/crates/once_cell/once_cell-1.21.4.crate"
678 +
CHECKSUM_SHA256_223="9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
679 +
SOURCE_FILENAME_223="once_cell-1.21.4.tar.gz"
680 +
SOURCE_URI_224="https://static.crates.io/crates/once_cell_polyfill/once_cell_polyfill-1.70.2.crate"
681 +
CHECKSUM_SHA256_224="384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
682 +
SOURCE_FILENAME_224="once_cell_polyfill-1.70.2.tar.gz"
683 +
SOURCE_URI_225="https://static.crates.io/crates/parking/parking-2.2.1.crate"
684 +
CHECKSUM_SHA256_225="f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
685 +
SOURCE_FILENAME_225="parking-2.2.1.tar.gz"
686 +
SOURCE_URI_226="https://static.crates.io/crates/parking_lot/parking_lot-0.12.5.crate"
687 +
CHECKSUM_SHA256_226="93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
688 +
SOURCE_FILENAME_226="parking_lot-0.12.5.tar.gz"
689 +
SOURCE_URI_227="https://static.crates.io/crates/parking_lot_core/parking_lot_core-0.9.12.crate"
690 +
CHECKSUM_SHA256_227="2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
691 +
SOURCE_FILENAME_227="parking_lot_core-0.9.12.tar.gz"
692 +
SOURCE_URI_228="https://static.crates.io/crates/password-hash/password-hash-0.5.0.crate"
693 +
CHECKSUM_SHA256_228="346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
694 +
SOURCE_FILENAME_228="password-hash-0.5.0.tar.gz"
695 +
SOURCE_URI_229="https://static.crates.io/crates/pem/pem-4.0.0.crate"
696 +
CHECKSUM_SHA256_229="d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
697 +
SOURCE_FILENAME_229="pem-4.0.0.tar.gz"
698 +
SOURCE_URI_230="https://static.crates.io/crates/pem-rfc7468/pem-rfc7468-0.7.0.crate"
699 +
CHECKSUM_SHA256_230="88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
700 +
SOURCE_FILENAME_230="pem-rfc7468-0.7.0.tar.gz"
701 +
SOURCE_URI_231="https://static.crates.io/crates/percent-encoding/percent-encoding-2.3.2.crate"
702 +
CHECKSUM_SHA256_231="9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
703 +
SOURCE_FILENAME_231="percent-encoding-2.3.2.tar.gz"
704 +
SOURCE_URI_232="https://static.crates.io/crates/phf/phf-0.14.0.crate"
705 +
CHECKSUM_SHA256_232="010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
706 +
SOURCE_FILENAME_232="phf-0.14.0.tar.gz"
707 +
SOURCE_URI_233="https://static.crates.io/crates/phf_codegen/phf_codegen-0.14.0.crate"
708 +
CHECKSUM_SHA256_233="41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
709 +
SOURCE_FILENAME_233="phf_codegen-0.14.0.tar.gz"
710 +
SOURCE_URI_234="https://static.crates.io/crates/phf_generator/phf_generator-0.14.0.crate"
711 +
CHECKSUM_SHA256_234="aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
712 +
SOURCE_FILENAME_234="phf_generator-0.14.0.tar.gz"
713 +
SOURCE_URI_235="https://static.crates.io/crates/phf_shared/phf_shared-0.14.0.crate"
714 +
CHECKSUM_SHA256_235="c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
715 +
SOURCE_FILENAME_235="phf_shared-0.14.0.tar.gz"
716 +
SOURCE_URI_236="https://static.crates.io/crates/pin-project-lite/pin-project-lite-0.2.17.crate"
717 +
CHECKSUM_SHA256_236="a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
718 +
SOURCE_FILENAME_236="pin-project-lite-0.2.17.tar.gz"
719 +
SOURCE_URI_237="https://static.crates.io/crates/pkcs1/pkcs1-0.7.5.crate"
720 +
CHECKSUM_SHA256_237="c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
721 +
SOURCE_FILENAME_237="pkcs1-0.7.5.tar.gz"
722 +
SOURCE_URI_238="https://static.crates.io/crates/pkcs8/pkcs8-0.10.2.crate"
723 +
CHECKSUM_SHA256_238="f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
724 +
SOURCE_FILENAME_238="pkcs8-0.10.2.tar.gz"
725 +
SOURCE_URI_239="https://static.crates.io/crates/pkcs8/pkcs8-0.11.0.crate"
726 +
CHECKSUM_SHA256_239="451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
727 +
SOURCE_FILENAME_239="pkcs8-0.11.0.tar.gz"
728 +
SOURCE_URI_240="https://static.crates.io/crates/pkg-config/pkg-config-0.3.34.crate"
729 +
CHECKSUM_SHA256_240="f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
730 +
SOURCE_FILENAME_240="pkg-config-0.3.34.tar.gz"
731 +
SOURCE_URI_241="https://static.crates.io/crates/plain/plain-0.2.3.crate"
732 +
CHECKSUM_SHA256_241="b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
733 +
SOURCE_FILENAME_241="plain-0.2.3.tar.gz"
734 +
SOURCE_URI_242="https://static.crates.io/crates/plist/plist-1.10.1.crate"
735 +
CHECKSUM_SHA256_242="2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb"
736 +
SOURCE_FILENAME_242="plist-1.10.1.tar.gz"
737 +
SOURCE_URI_243="https://static.crates.io/crates/png/png-0.18.1.crate"
738 +
CHECKSUM_SHA256_243="60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
739 +
SOURCE_FILENAME_243="png-0.18.1.tar.gz"
740 +
SOURCE_URI_244="https://static.crates.io/crates/potential_utf/potential_utf-0.1.6.crate"
741 +
CHECKSUM_SHA256_244="d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
742 +
SOURCE_FILENAME_244="potential_utf-0.1.6.tar.gz"
743 +
SOURCE_URI_245="https://static.crates.io/crates/powerfmt/powerfmt-0.2.0.crate"
744 +
CHECKSUM_SHA256_245="439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
745 +
SOURCE_FILENAME_245="powerfmt-0.2.0.tar.gz"
746 +
SOURCE_URI_246="https://static.crates.io/crates/ppv-lite86/ppv-lite86-0.2.21.crate"
747 +
CHECKSUM_SHA256_246="85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
748 +
SOURCE_FILENAME_246="ppv-lite86-0.2.21.tar.gz"
749 +
SOURCE_URI_247="https://static.crates.io/crates/precomputed-hash/precomputed-hash-0.1.1.crate"
750 +
CHECKSUM_SHA256_247="925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
751 +
SOURCE_FILENAME_247="precomputed-hash-0.1.1.tar.gz"
752 +
SOURCE_URI_248="https://static.crates.io/crates/proc-macro2/proc-macro2-1.0.107.crate"
753 +
CHECKSUM_SHA256_248="985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
754 +
SOURCE_FILENAME_248="proc-macro2-1.0.107.tar.gz"
755 +
SOURCE_URI_249="https://static.crates.io/crates/pulldown-cmark/pulldown-cmark-0.13.4.crate"
756 +
CHECKSUM_SHA256_249="e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
757 +
SOURCE_FILENAME_249="pulldown-cmark-0.13.4.tar.gz"
758 +
SOURCE_URI_250="https://static.crates.io/crates/pulldown-cmark-escape/pulldown-cmark-escape-0.11.0.crate"
759 +
CHECKSUM_SHA256_250="007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
760 +
SOURCE_FILENAME_250="pulldown-cmark-escape-0.11.0.tar.gz"
761 +
SOURCE_URI_251="https://static.crates.io/crates/pxfm/pxfm-0.1.30.crate"
762 +
CHECKSUM_SHA256_251="d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
763 +
SOURCE_FILENAME_251="pxfm-0.1.30.tar.gz"
764 +
SOURCE_URI_252="https://static.crates.io/crates/qrcode/qrcode-0.14.1.crate"
765 +
CHECKSUM_SHA256_252="d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
766 +
SOURCE_FILENAME_252="qrcode-0.14.1.tar.gz"
767 +
SOURCE_URI_253="https://static.crates.io/crates/quick-error/quick-error-2.0.1.crate"
768 +
CHECKSUM_SHA256_253="a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
769 +
SOURCE_FILENAME_253="quick-error-2.0.1.tar.gz"
770 +
SOURCE_URI_254="https://static.crates.io/crates/quick-xml/quick-xml-0.41.0.crate"
771 +
CHECKSUM_SHA256_254="e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
772 +
SOURCE_FILENAME_254="quick-xml-0.41.0.tar.gz"
773 +
SOURCE_URI_255="https://static.crates.io/crates/quick-xml/quick-xml-0.42.0.crate"
774 +
CHECKSUM_SHA256_255="41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
775 +
SOURCE_FILENAME_255="quick-xml-0.42.0.tar.gz"
776 +
SOURCE_URI_256="https://static.crates.io/crates/quinn/quinn-0.11.12.crate"
777 +
CHECKSUM_SHA256_256="4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
778 +
SOURCE_FILENAME_256="quinn-0.11.12.tar.gz"
779 +
SOURCE_URI_257="https://static.crates.io/crates/quinn-proto/quinn-proto-0.11.18.crate"
780 +
CHECKSUM_SHA256_257="a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
781 +
SOURCE_FILENAME_257="quinn-proto-0.11.18.tar.gz"
782 +
SOURCE_URI_258="https://static.crates.io/crates/quinn-udp/quinn-udp-0.5.15.crate"
783 +
CHECKSUM_SHA256_258="35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
784 +
SOURCE_FILENAME_258="quinn-udp-0.5.15.tar.gz"
785 +
SOURCE_URI_259="https://static.crates.io/crates/quote/quote-1.0.47.crate"
786 +
CHECKSUM_SHA256_259="1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
787 +
SOURCE_FILENAME_259="quote-1.0.47.tar.gz"
788 +
SOURCE_URI_260="https://static.crates.io/crates/quoted_printable/quoted_printable-0.5.2.crate"
789 +
CHECKSUM_SHA256_260="478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
790 +
SOURCE_FILENAME_260="quoted_printable-0.5.2.tar.gz"
791 +
SOURCE_URI_261="https://static.crates.io/crates/r-efi/r-efi-6.0.0.crate"
792 +
CHECKSUM_SHA256_261="f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
793 +
SOURCE_FILENAME_261="r-efi-6.0.0.tar.gz"
794 +
SOURCE_URI_262="https://static.crates.io/crates/rand/rand-0.10.3.crate"
795 +
CHECKSUM_SHA256_262="65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
796 +
SOURCE_FILENAME_262="rand-0.10.3.tar.gz"
797 +
SOURCE_URI_263="https://static.crates.io/crates/rand/rand-0.8.8.crate"
798 +
CHECKSUM_SHA256_263="e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
799 +
SOURCE_FILENAME_263="rand-0.8.8.tar.gz"
800 +
SOURCE_URI_264="https://static.crates.io/crates/rand_chacha/rand_chacha-0.3.1.crate"
801 +
CHECKSUM_SHA256_264="e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
802 +
SOURCE_FILENAME_264="rand_chacha-0.3.1.tar.gz"
803 +
SOURCE_URI_265="https://static.crates.io/crates/rand_core/rand_core-0.10.1.crate"
804 +
CHECKSUM_SHA256_265="63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
805 +
SOURCE_FILENAME_265="rand_core-0.10.1.tar.gz"
806 +
SOURCE_URI_266="https://static.crates.io/crates/rand_core/rand_core-0.6.4.crate"
807 +
CHECKSUM_SHA256_266="ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
808 +
SOURCE_FILENAME_266="rand_core-0.6.4.tar.gz"
809 +
SOURCE_URI_267="https://static.crates.io/crates/rand_pcg/rand_pcg-0.10.2.crate"
810 +
CHECKSUM_SHA256_267="caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
811 +
SOURCE_FILENAME_267="rand_pcg-0.10.2.tar.gz"
812 +
SOURCE_URI_268="https://static.crates.io/crates/rayon/rayon-1.12.0.crate"
813 +
CHECKSUM_SHA256_268="fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
814 +
SOURCE_FILENAME_268="rayon-1.12.0.tar.gz"
815 +
SOURCE_URI_269="https://static.crates.io/crates/rayon-core/rayon-core-1.13.0.crate"
816 +
CHECKSUM_SHA256_269="22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
817 +
SOURCE_FILENAME_269="rayon-core-1.13.0.tar.gz"
818 +
SOURCE_URI_270="https://static.crates.io/crates/rcgen/rcgen-0.14.10.crate"
819 +
CHECKSUM_SHA256_270="8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
820 +
SOURCE_FILENAME_270="rcgen-0.14.10.tar.gz"
821 +
SOURCE_URI_271="https://static.crates.io/crates/redox_syscall/redox_syscall-0.5.18.crate"
822 +
CHECKSUM_SHA256_271="ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
823 +
SOURCE_FILENAME_271="redox_syscall-0.5.18.tar.gz"
824 +
SOURCE_URI_272="https://static.crates.io/crates/redox_syscall/redox_syscall-0.9.4.crate"
825 +
CHECKSUM_SHA256_272="737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
826 +
SOURCE_FILENAME_272="redox_syscall-0.9.4.tar.gz"
827 +
SOURCE_URI_273="https://static.crates.io/crates/ref-cast/ref-cast-1.0.27.crate"
828 +
CHECKSUM_SHA256_273="7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
829 +
SOURCE_FILENAME_273="ref-cast-1.0.27.tar.gz"
830 +
SOURCE_URI_274="https://static.crates.io/crates/ref-cast-impl/ref-cast-impl-1.0.27.crate"
831 +
CHECKSUM_SHA256_274="92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
832 +
SOURCE_FILENAME_274="ref-cast-impl-1.0.27.tar.gz"
833 +
SOURCE_URI_275="https://static.crates.io/crates/regex/regex-1.13.1.crate"
834 +
CHECKSUM_SHA256_275="f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
835 +
SOURCE_FILENAME_275="regex-1.13.1.tar.gz"
836 +
SOURCE_URI_276="https://static.crates.io/crates/regex-automata/regex-automata-0.4.18.crate"
837 +
CHECKSUM_SHA256_276="ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
838 +
SOURCE_FILENAME_276="regex-automata-0.4.18.tar.gz"
839 +
SOURCE_URI_277="https://static.crates.io/crates/regex-syntax/regex-syntax-0.8.11.crate"
840 +
CHECKSUM_SHA256_277="d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
841 +
SOURCE_FILENAME_277="regex-syntax-0.8.11.tar.gz"
842 +
SOURCE_URI_278="https://static.crates.io/crates/reqwest/reqwest-0.12.28.crate"
843 +
CHECKSUM_SHA256_278="eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
844 +
SOURCE_FILENAME_278="reqwest-0.12.28.tar.gz"
845 +
SOURCE_URI_279="https://static.crates.io/crates/ring/ring-0.17.14.crate"
846 +
CHECKSUM_SHA256_279="a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
847 +
SOURCE_FILENAME_279="ring-0.17.14.tar.gz"
848 +
SOURCE_URI_280="https://static.crates.io/crates/rsa/rsa-0.9.10.crate"
849 +
CHECKSUM_SHA256_280="b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
850 +
SOURCE_FILENAME_280="rsa-0.9.10.tar.gz"
851 +
SOURCE_URI_281="https://static.crates.io/crates/rustc-hash/rustc-hash-2.1.3.crate"
852 +
CHECKSUM_SHA256_281="6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
853 +
SOURCE_FILENAME_281="rustc-hash-2.1.3.tar.gz"
854 +
SOURCE_URI_282="https://static.crates.io/crates/rustc_version/rustc_version-0.4.1.crate"
855 +
CHECKSUM_SHA256_282="cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
856 +
SOURCE_FILENAME_282="rustc_version-0.4.1.tar.gz"
857 +
SOURCE_URI_283="https://static.crates.io/crates/rusticata-macros/rusticata-macros-4.1.0.crate"
858 +
CHECKSUM_SHA256_283="faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
859 +
SOURCE_FILENAME_283="rusticata-macros-4.1.0.tar.gz"
860 +
SOURCE_URI_284="https://static.crates.io/crates/rustix/rustix-1.1.5.crate"
861 +
CHECKSUM_SHA256_284="891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
862 +
SOURCE_FILENAME_284="rustix-1.1.5.tar.gz"
863 +
SOURCE_URI_285="https://static.crates.io/crates/rustls/rustls-0.23.45.crate"
864 +
CHECKSUM_SHA256_285="0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
865 +
SOURCE_FILENAME_285="rustls-0.23.45.tar.gz"
866 +
SOURCE_URI_286="https://static.crates.io/crates/rustls-pemfile/rustls-pemfile-2.2.0.crate"
867 +
CHECKSUM_SHA256_286="dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
868 +
SOURCE_FILENAME_286="rustls-pemfile-2.2.0.tar.gz"
869 +
SOURCE_URI_287="https://static.crates.io/crates/rustls-pki-types/rustls-pki-types-1.15.1.crate"
870 +
CHECKSUM_SHA256_287="2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
871 +
SOURCE_FILENAME_287="rustls-pki-types-1.15.1.tar.gz"
872 +
SOURCE_URI_288="https://static.crates.io/crates/rustls-webpki/rustls-webpki-0.103.15.crate"
873 +
CHECKSUM_SHA256_288="f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
874 +
SOURCE_FILENAME_288="rustls-webpki-0.103.15.tar.gz"
875 +
SOURCE_URI_289="https://static.crates.io/crates/rustversion/rustversion-1.0.23.crate"
876 +
CHECKSUM_SHA256_289="cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
877 +
SOURCE_FILENAME_289="rustversion-1.0.23.tar.gz"
878 +
SOURCE_URI_290="https://static.crates.io/crates/ryu/ryu-1.0.23.crate"
879 +
CHECKSUM_SHA256_290="9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
880 +
SOURCE_FILENAME_290="ryu-1.0.23.tar.gz"
881 +
SOURCE_URI_291="https://static.crates.io/crates/same-file/same-file-1.0.6.crate"
882 +
CHECKSUM_SHA256_291="93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
883 +
SOURCE_FILENAME_291="same-file-1.0.6.tar.gz"
884 +
SOURCE_URI_292="https://static.crates.io/crates/schemars/schemars-1.2.2.crate"
885 +
CHECKSUM_SHA256_292="687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
886 +
SOURCE_FILENAME_292="schemars-1.2.2.tar.gz"
887 +
SOURCE_URI_293="https://static.crates.io/crates/schemars_derive/schemars_derive-1.2.2.crate"
888 +
CHECKSUM_SHA256_293="d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
889 +
SOURCE_FILENAME_293="schemars_derive-1.2.2.tar.gz"
890 +
SOURCE_URI_294="https://static.crates.io/crates/scopeguard/scopeguard-1.2.0.crate"
891 +
CHECKSUM_SHA256_294="94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
892 +
SOURCE_FILENAME_294="scopeguard-1.2.0.tar.gz"
893 +
SOURCE_URI_295="https://static.crates.io/crates/semver/semver-1.0.28.crate"
894 +
CHECKSUM_SHA256_295="8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
895 +
SOURCE_FILENAME_295="semver-1.0.28.tar.gz"
896 +
SOURCE_URI_296="https://static.crates.io/crates/serde/serde-1.0.229.crate"
897 +
CHECKSUM_SHA256_296="4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
898 +
SOURCE_FILENAME_296="serde-1.0.229.tar.gz"
899 +
SOURCE_URI_297="https://static.crates.io/crates/serde_core/serde_core-1.0.229.crate"
900 +
CHECKSUM_SHA256_297="67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
901 +
SOURCE_FILENAME_297="serde_core-1.0.229.tar.gz"
902 +
SOURCE_URI_298="https://static.crates.io/crates/serde_derive/serde_derive-1.0.229.crate"
903 +
CHECKSUM_SHA256_298="e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
904 +
SOURCE_FILENAME_298="serde_derive-1.0.229.tar.gz"
905 +
SOURCE_URI_299="https://static.crates.io/crates/serde_derive_internals/serde_derive_internals-0.30.0.crate"
906 +
CHECKSUM_SHA256_299="f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
907 +
SOURCE_FILENAME_299="serde_derive_internals-0.30.0.tar.gz"
908 +
SOURCE_URI_300="https://static.crates.io/crates/serde_html_form/serde_html_form-0.2.8.crate"
909 +
CHECKSUM_SHA256_300="b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
910 +
SOURCE_FILENAME_300="serde_html_form-0.2.8.tar.gz"
911 +
SOURCE_URI_301="https://static.crates.io/crates/serde_json/serde_json-1.0.151.crate"
912 +
CHECKSUM_SHA256_301="c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
913 +
SOURCE_FILENAME_301="serde_json-1.0.151.tar.gz"
914 +
SOURCE_URI_302="https://static.crates.io/crates/serde_path_to_error/serde_path_to_error-0.1.20.crate"
915 +
CHECKSUM_SHA256_302="10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
916 +
SOURCE_FILENAME_302="serde_path_to_error-0.1.20.tar.gz"
917 +
SOURCE_URI_303="https://static.crates.io/crates/serde_spanned/serde_spanned-0.6.9.crate"
918 +
CHECKSUM_SHA256_303="bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
919 +
SOURCE_FILENAME_303="serde_spanned-0.6.9.tar.gz"
920 +
SOURCE_URI_304="https://static.crates.io/crates/serde_urlencoded/serde_urlencoded-0.7.1.crate"
921 +
CHECKSUM_SHA256_304="d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
922 +
SOURCE_FILENAME_304="serde_urlencoded-0.7.1.tar.gz"
923 +
SOURCE_URI_305="https://static.crates.io/crates/sha1/sha1-0.10.7.crate"
924 +
CHECKSUM_SHA256_305="a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
925 +
SOURCE_FILENAME_305="sha1-0.10.7.tar.gz"
926 +
SOURCE_URI_306="https://static.crates.io/crates/sha2/sha2-0.10.9.crate"
927 +
CHECKSUM_SHA256_306="a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
928 +
SOURCE_FILENAME_306="sha2-0.10.9.tar.gz"
929 +
SOURCE_URI_307="https://static.crates.io/crates/sha2/sha2-0.11.0.crate"
930 +
CHECKSUM_SHA256_307="446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
931 +
SOURCE_FILENAME_307="sha2-0.11.0.tar.gz"
932 +
SOURCE_URI_308="https://static.crates.io/crates/shake/shake-0.1.0.crate"
933 +
CHECKSUM_SHA256_308="09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a"
934 +
SOURCE_FILENAME_308="shake-0.1.0.tar.gz"
935 +
SOURCE_URI_309="https://static.crates.io/crates/sharded-slab/sharded-slab-0.1.7.crate"
936 +
CHECKSUM_SHA256_309="f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
937 +
SOURCE_FILENAME_309="sharded-slab-0.1.7.tar.gz"
938 +
SOURCE_URI_310="https://static.crates.io/crates/shlex/shlex-2.0.1.crate"
939 +
CHECKSUM_SHA256_310="f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
940 +
SOURCE_FILENAME_310="shlex-2.0.1.tar.gz"
941 +
SOURCE_URI_311="https://static.crates.io/crates/signal-hook-registry/signal-hook-registry-1.4.8.crate"
942 +
CHECKSUM_SHA256_311="c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
943 +
SOURCE_FILENAME_311="signal-hook-registry-1.4.8.tar.gz"
944 +
SOURCE_URI_312="https://static.crates.io/crates/signature/signature-2.2.0.crate"
945 +
CHECKSUM_SHA256_312="77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
946 +
SOURCE_FILENAME_312="signature-2.2.0.tar.gz"
947 +
SOURCE_URI_313="https://static.crates.io/crates/signature/signature-3.0.0.crate"
948 +
CHECKSUM_SHA256_313="28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
949 +
SOURCE_FILENAME_313="signature-3.0.0.tar.gz"
950 +
SOURCE_URI_314="https://static.crates.io/crates/simd-adler32/simd-adler32-0.3.10.crate"
951 +
CHECKSUM_SHA256_314="3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
952 +
SOURCE_FILENAME_314="simd-adler32-0.3.10.tar.gz"
953 +
SOURCE_URI_315="https://static.crates.io/crates/simdutf8/simdutf8-0.1.5.crate"
954 +
CHECKSUM_SHA256_315="e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
955 +
SOURCE_FILENAME_315="simdutf8-0.1.5.tar.gz"
956 +
SOURCE_URI_316="https://static.crates.io/crates/similar/similar-2.7.0.crate"
957 +
CHECKSUM_SHA256_316="bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
958 +
SOURCE_FILENAME_316="similar-2.7.0.tar.gz"
959 +
SOURCE_URI_317="https://static.crates.io/crates/siphasher/siphasher-1.0.4.crate"
960 +
CHECKSUM_SHA256_317="33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
961 +
SOURCE_FILENAME_317="siphasher-1.0.4.tar.gz"
962 +
SOURCE_URI_318="https://static.crates.io/crates/slab/slab-0.4.12.crate"
963 +
CHECKSUM_SHA256_318="0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
964 +
SOURCE_FILENAME_318="slab-0.4.12.tar.gz"
965 +
SOURCE_URI_319="https://static.crates.io/crates/smallvec/smallvec-1.16.2.crate"
966 +
CHECKSUM_SHA256_319="f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
967 +
SOURCE_FILENAME_319="smallvec-1.16.2.tar.gz"
968 +
SOURCE_URI_320="https://static.crates.io/crates/socket2/socket2-0.6.5.crate"
969 +
CHECKSUM_SHA256_320="c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
970 +
SOURCE_FILENAME_320="socket2-0.6.5.tar.gz"
971 +
SOURCE_URI_321="https://static.crates.io/crates/spin/spin-0.10.1.crate"
972 +
CHECKSUM_SHA256_321="023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
973 +
SOURCE_FILENAME_321="spin-0.10.1.tar.gz"
974 +
SOURCE_URI_322="https://static.crates.io/crates/spin/spin-0.9.9.crate"
975 +
CHECKSUM_SHA256_322="3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
976 +
SOURCE_FILENAME_322="spin-0.9.9.tar.gz"
977 +
SOURCE_URI_323="https://static.crates.io/crates/spki/spki-0.7.3.crate"
978 +
CHECKSUM_SHA256_323="d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
979 +
SOURCE_FILENAME_323="spki-0.7.3.tar.gz"
980 +
SOURCE_URI_324="https://static.crates.io/crates/spki/spki-0.8.0.crate"
981 +
CHECKSUM_SHA256_324="1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
982 +
SOURCE_FILENAME_324="spki-0.8.0.tar.gz"
983 +
SOURCE_URI_325="https://static.crates.io/crates/sponge-cursor/sponge-cursor-0.1.0.crate"
984 +
CHECKSUM_SHA256_325="3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
985 +
SOURCE_FILENAME_325="sponge-cursor-0.1.0.tar.gz"
986 +
SOURCE_URI_326="https://static.crates.io/crates/sqlx/sqlx-0.8.6.crate"
987 +
CHECKSUM_SHA256_326="1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
988 +
SOURCE_FILENAME_326="sqlx-0.8.6.tar.gz"
989 +
SOURCE_URI_327="https://static.crates.io/crates/sqlx-core/sqlx-core-0.8.6.crate"
990 +
CHECKSUM_SHA256_327="ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
991 +
SOURCE_FILENAME_327="sqlx-core-0.8.6.tar.gz"
992 +
SOURCE_URI_328="https://static.crates.io/crates/sqlx-macros/sqlx-macros-0.8.6.crate"
993 +
CHECKSUM_SHA256_328="a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
994 +
SOURCE_FILENAME_328="sqlx-macros-0.8.6.tar.gz"
995 +
SOURCE_URI_329="https://static.crates.io/crates/sqlx-macros-core/sqlx-macros-core-0.8.6.crate"
996 +
CHECKSUM_SHA256_329="19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
997 +
SOURCE_FILENAME_329="sqlx-macros-core-0.8.6.tar.gz"
998 +
SOURCE_URI_330="https://static.crates.io/crates/sqlx-mysql/sqlx-mysql-0.8.6.crate"
999 +
CHECKSUM_SHA256_330="aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
1000 +
SOURCE_FILENAME_330="sqlx-mysql-0.8.6.tar.gz"
1001 +
SOURCE_URI_331="https://static.crates.io/crates/sqlx-postgres/sqlx-postgres-0.8.6.crate"
1002 +
CHECKSUM_SHA256_331="db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
1003 +
SOURCE_FILENAME_331="sqlx-postgres-0.8.6.tar.gz"
1004 +
SOURCE_URI_332="https://static.crates.io/crates/sqlx-sqlite/sqlx-sqlite-0.8.6.crate"
1005 +
CHECKSUM_SHA256_332="c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
1006 +
SOURCE_FILENAME_332="sqlx-sqlite-0.8.6.tar.gz"
1007 +
SOURCE_URI_333="https://static.crates.io/crates/stable_deref_trait/stable_deref_trait-1.2.1.crate"
1008 +
CHECKSUM_SHA256_333="6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1009 +
SOURCE_FILENAME_333="stable_deref_trait-1.2.1.tar.gz"
1010 +
SOURCE_URI_334="https://static.crates.io/crates/streaming-iterator/streaming-iterator-0.1.9.crate"
1011 +
CHECKSUM_SHA256_334="2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
1012 +
SOURCE_FILENAME_334="streaming-iterator-0.1.9.tar.gz"
1013 +
SOURCE_URI_335="https://static.crates.io/crates/string_cache/string_cache-0.11.0.crate"
1014 +
CHECKSUM_SHA256_335="ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
1015 +
SOURCE_FILENAME_335="string_cache-0.11.0.tar.gz"
1016 +
SOURCE_URI_336="https://static.crates.io/crates/string_cache_codegen/string_cache_codegen-0.11.2.crate"
1017 +
CHECKSUM_SHA256_336="928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc"
1018 +
SOURCE_FILENAME_336="string_cache_codegen-0.11.2.tar.gz"
1019 +
SOURCE_URI_337="https://static.crates.io/crates/stringprep/stringprep-0.1.5.crate"
1020 +
CHECKSUM_SHA256_337="7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
1021 +
SOURCE_FILENAME_337="stringprep-0.1.5.tar.gz"
1022 +
SOURCE_URI_338="https://static.crates.io/crates/strsim/strsim-0.11.1.crate"
1023 +
CHECKSUM_SHA256_338="7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
1024 +
SOURCE_FILENAME_338="strsim-0.11.1.tar.gz"
1025 +
SOURCE_URI_339="https://static.crates.io/crates/subtle/subtle-2.6.1.crate"
1026 +
CHECKSUM_SHA256_339="13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1027 +
SOURCE_FILENAME_339="subtle-2.6.1.tar.gz"
1028 +
SOURCE_URI_340="https://static.crates.io/crates/syn/syn-2.0.119.crate"
1029 +
CHECKSUM_SHA256_340="872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
1030 +
SOURCE_FILENAME_340="syn-2.0.119.tar.gz"
1031 +
SOURCE_URI_341="https://static.crates.io/crates/syn/syn-3.0.6.crate"
1032 +
CHECKSUM_SHA256_341="8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
1033 +
SOURCE_FILENAME_341="syn-3.0.6.tar.gz"
1034 +
SOURCE_URI_342="https://static.crates.io/crates/sync_wrapper/sync_wrapper-1.0.2.crate"
1035 +
CHECKSUM_SHA256_342="0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1036 +
SOURCE_FILENAME_342="sync_wrapper-1.0.2.tar.gz"
1037 +
SOURCE_URI_343="https://static.crates.io/crates/synstructure/synstructure-0.13.2.crate"
1038 +
CHECKSUM_SHA256_343="728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1039 +
SOURCE_FILENAME_343="synstructure-0.13.2.tar.gz"
1040 +
SOURCE_URI_344="https://static.crates.io/crates/synstructure/synstructure-0.14.0.crate"
1041 +
CHECKSUM_SHA256_344="901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
1042 +
SOURCE_FILENAME_344="synstructure-0.14.0.tar.gz"
1043 +
SOURCE_URI_345="https://static.crates.io/crates/syntect/syntect-5.3.0.crate"
1044 +
CHECKSUM_SHA256_345="656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
1045 +
SOURCE_FILENAME_345="syntect-5.3.0.tar.gz"
1046 +
SOURCE_URI_346="https://static.crates.io/crates/tar/tar-0.4.46.crate"
1047 +
CHECKSUM_SHA256_346="3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
1048 +
SOURCE_FILENAME_346="tar-0.4.46.tar.gz"
1049 +
SOURCE_URI_347="https://static.crates.io/crates/tempfile/tempfile-3.27.0.crate"
1050 +
CHECKSUM_SHA256_347="32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
1051 +
SOURCE_FILENAME_347="tempfile-3.27.0.tar.gz"
1052 +
SOURCE_URI_348="https://static.crates.io/crates/tendril/tendril-0.5.1.crate"
1053 +
CHECKSUM_SHA256_348="5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
1054 +
SOURCE_FILENAME_348="tendril-0.5.1.tar.gz"
1055 +
SOURCE_URI_349="https://static.crates.io/crates/thiserror/thiserror-1.0.69.crate"
1056 +
CHECKSUM_SHA256_349="b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
1057 +
SOURCE_FILENAME_349="thiserror-1.0.69.tar.gz"
1058 +
SOURCE_URI_350="https://static.crates.io/crates/thiserror/thiserror-2.0.21.crate"
1059 +
CHECKSUM_SHA256_350="09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
1060 +
SOURCE_FILENAME_350="thiserror-2.0.21.tar.gz"
1061 +
SOURCE_URI_351="https://static.crates.io/crates/thiserror-impl/thiserror-impl-1.0.69.crate"
1062 +
CHECKSUM_SHA256_351="4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
1063 +
SOURCE_FILENAME_351="thiserror-impl-1.0.69.tar.gz"
1064 +
SOURCE_URI_352="https://static.crates.io/crates/thiserror-impl/thiserror-impl-2.0.21.crate"
1065 +
CHECKSUM_SHA256_352="fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
1066 +
SOURCE_FILENAME_352="thiserror-impl-2.0.21.tar.gz"
1067 +
SOURCE_URI_353="https://static.crates.io/crates/thread_local/thread_local-1.1.10.crate"
1068 +
CHECKSUM_SHA256_353="1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
1069 +
SOURCE_FILENAME_353="thread_local-1.1.10.tar.gz"
1070 +
SOURCE_URI_354="https://static.crates.io/crates/time/time-0.3.55.crate"
1071 +
CHECKSUM_SHA256_354="cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
1072 +
SOURCE_FILENAME_354="time-0.3.55.tar.gz"
1073 +
SOURCE_URI_355="https://static.crates.io/crates/time-core/time-core-0.1.9.crate"
1074 +
CHECKSUM_SHA256_355="9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
1075 +
SOURCE_FILENAME_355="time-core-0.1.9.tar.gz"
1076 +
SOURCE_URI_356="https://static.crates.io/crates/time-macros/time-macros-0.2.32.crate"
1077 +
CHECKSUM_SHA256_356="7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
1078 +
SOURCE_FILENAME_356="time-macros-0.2.32.tar.gz"
1079 +
SOURCE_URI_357="https://static.crates.io/crates/tinystr/tinystr-0.8.4.crate"
1080 +
CHECKSUM_SHA256_357="b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
1081 +
SOURCE_FILENAME_357="tinystr-0.8.4.tar.gz"
1082 +
SOURCE_URI_358="https://static.crates.io/crates/tinyvec/tinyvec-1.13.3.crate"
1083 +
CHECKSUM_SHA256_358="fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
1084 +
SOURCE_FILENAME_358="tinyvec-1.13.3.tar.gz"
1085 +
SOURCE_URI_359="https://static.crates.io/crates/tokio/tokio-1.53.1.crate"
1086 +
CHECKSUM_SHA256_359="202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
1087 +
SOURCE_FILENAME_359="tokio-1.53.1.tar.gz"
1088 +
SOURCE_URI_360="https://static.crates.io/crates/tokio-macros/tokio-macros-2.7.2.crate"
1089 +
CHECKSUM_SHA256_360="78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
1090 +
SOURCE_FILENAME_360="tokio-macros-2.7.2.tar.gz"
1091 +
SOURCE_URI_361="https://static.crates.io/crates/tokio-rustls/tokio-rustls-0.26.5.crate"
1092 +
CHECKSUM_SHA256_361="b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
1093 +
SOURCE_FILENAME_361="tokio-rustls-0.26.5.tar.gz"
1094 +
SOURCE_URI_362="https://static.crates.io/crates/tokio-stream/tokio-stream-0.1.19.crate"
1095 +
CHECKSUM_SHA256_362="a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
1096 +
SOURCE_FILENAME_362="tokio-stream-0.1.19.tar.gz"
1097 +
SOURCE_URI_363="https://static.crates.io/crates/tokio-util/tokio-util-0.7.19.crate"
1098 +
CHECKSUM_SHA256_363="494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
1099 +
SOURCE_FILENAME_363="tokio-util-0.7.19.tar.gz"
1100 +
SOURCE_URI_364="https://static.crates.io/crates/toml/toml-0.8.23.crate"
1101 +
CHECKSUM_SHA256_364="dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
1102 +
SOURCE_FILENAME_364="toml-0.8.23.tar.gz"
1103 +
SOURCE_URI_365="https://static.crates.io/crates/toml_datetime/toml_datetime-0.6.11.crate"
1104 +
CHECKSUM_SHA256_365="22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
1105 +
SOURCE_FILENAME_365="toml_datetime-0.6.11.tar.gz"
1106 +
SOURCE_URI_366="https://static.crates.io/crates/toml_edit/toml_edit-0.22.27.crate"
1107 +
CHECKSUM_SHA256_366="41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
1108 +
SOURCE_FILENAME_366="toml_edit-0.22.27.tar.gz"
1109 +
SOURCE_URI_367="https://static.crates.io/crates/toml_write/toml_write-0.1.2.crate"
1110 +
CHECKSUM_SHA256_367="5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
1111 +
SOURCE_FILENAME_367="toml_write-0.1.2.tar.gz"
1112 +
SOURCE_URI_368="https://static.crates.io/crates/tower/tower-0.5.3.crate"
1113 +
CHECKSUM_SHA256_368="ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
1114 +
SOURCE_FILENAME_368="tower-0.5.3.tar.gz"
1115 +
SOURCE_URI_369="https://static.crates.io/crates/tower-http/tower-http-0.6.11.crate"
1116 +
CHECKSUM_SHA256_369="4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
1117 +
SOURCE_FILENAME_369="tower-http-0.6.11.tar.gz"
1118 +
SOURCE_URI_370="https://static.crates.io/crates/tower-layer/tower-layer-0.3.3.crate"
1119 +
CHECKSUM_SHA256_370="121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
1120 +
SOURCE_FILENAME_370="tower-layer-0.3.3.tar.gz"
1121 +
SOURCE_URI_371="https://static.crates.io/crates/tower-service/tower-service-0.3.3.crate"
1122 +
CHECKSUM_SHA256_371="8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
1123 +
SOURCE_FILENAME_371="tower-service-0.3.3.tar.gz"
1124 +
SOURCE_URI_372="https://static.crates.io/crates/tracing/tracing-0.1.44.crate"
1125 +
CHECKSUM_SHA256_372="63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1126 +
SOURCE_FILENAME_372="tracing-0.1.44.tar.gz"
1127 +
SOURCE_URI_373="https://static.crates.io/crates/tracing-attributes/tracing-attributes-0.1.31.crate"
1128 +
CHECKSUM_SHA256_373="7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
1129 +
SOURCE_FILENAME_373="tracing-attributes-0.1.31.tar.gz"
1130 +
SOURCE_URI_374="https://static.crates.io/crates/tracing-core/tracing-core-0.1.36.crate"
1131 +
CHECKSUM_SHA256_374="db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
1132 +
SOURCE_FILENAME_374="tracing-core-0.1.36.tar.gz"
1133 +
SOURCE_URI_375="https://static.crates.io/crates/tracing-log/tracing-log-0.2.0.crate"
1134 +
CHECKSUM_SHA256_375="ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
1135 +
SOURCE_FILENAME_375="tracing-log-0.2.0.tar.gz"
1136 +
SOURCE_URI_376="https://static.crates.io/crates/tracing-subscriber/tracing-subscriber-0.3.23.crate"
1137 +
CHECKSUM_SHA256_376="cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
1138 +
SOURCE_FILENAME_376="tracing-subscriber-0.3.23.tar.gz"
1139 +
SOURCE_URI_377="https://static.crates.io/crates/tree-sitter/tree-sitter-0.27.0.crate"
1140 +
CHECKSUM_SHA256_377="2038684e0058edba0d17302619f62eabce4a8e11c6ac59506996a8d79848851d"
1141 +
SOURCE_FILENAME_377="tree-sitter-0.27.0.tar.gz"
1142 +
SOURCE_URI_378="https://static.crates.io/crates/tree-sitter-bash/tree-sitter-bash-0.25.1.crate"
1143 +
CHECKSUM_SHA256_378="9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062"
1144 +
SOURCE_FILENAME_378="tree-sitter-bash-0.25.1.tar.gz"
1145 +
SOURCE_URI_379="https://static.crates.io/crates/tree-sitter-language/tree-sitter-language-0.1.8.crate"
1146 +
CHECKSUM_SHA256_379="ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081"
1147 +
SOURCE_FILENAME_379="tree-sitter-language-0.1.8.tar.gz"
1148 +
SOURCE_URI_380="https://static.crates.io/crates/tree-sitter-python/tree-sitter-python-0.25.0.crate"
1149 +
CHECKSUM_SHA256_380="6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c"
1150 +
SOURCE_FILENAME_380="tree-sitter-python-0.25.0.tar.gz"
1151 +
SOURCE_URI_381="https://static.crates.io/crates/tree-sitter-rust/tree-sitter-rust-0.24.2.crate"
1152 +
CHECKSUM_SHA256_381="439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45"
1153 +
SOURCE_FILENAME_381="tree-sitter-rust-0.24.2.tar.gz"
1154 +
SOURCE_URI_382="https://static.crates.io/crates/tree-sitter-typescript/tree-sitter-typescript-0.23.2.crate"
1155 +
CHECKSUM_SHA256_382="6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
1156 +
SOURCE_FILENAME_382="tree-sitter-typescript-0.23.2.tar.gz"
1157 +
SOURCE_URI_383="https://static.crates.io/crates/try-lock/try-lock-0.2.5.crate"
1158 +
CHECKSUM_SHA256_383="e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
1159 +
SOURCE_FILENAME_383="try-lock-0.2.5.tar.gz"
1160 +
SOURCE_URI_384="https://static.crates.io/crates/typenum/typenum-1.20.1.crate"
1161 +
CHECKSUM_SHA256_384="b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
1162 +
SOURCE_FILENAME_384="typenum-1.20.1.tar.gz"
1163 +
SOURCE_URI_385="https://static.crates.io/crates/unicase/unicase-2.9.0.crate"
1164 +
CHECKSUM_SHA256_385="dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
1165 +
SOURCE_FILENAME_385="unicase-2.9.0.tar.gz"
1166 +
SOURCE_URI_386="https://static.crates.io/crates/unicode-bidi/unicode-bidi-0.3.18.crate"
1167 +
CHECKSUM_SHA256_386="5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
1168 +
SOURCE_FILENAME_386="unicode-bidi-0.3.18.tar.gz"
1169 +
SOURCE_URI_387="https://static.crates.io/crates/unicode-ident/unicode-ident-1.0.26.crate"
1170 +
CHECKSUM_SHA256_387="d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
1171 +
SOURCE_FILENAME_387="unicode-ident-1.0.26.tar.gz"
1172 +
SOURCE_URI_388="https://static.crates.io/crates/unicode-normalization/unicode-normalization-0.1.25.crate"
1173 +
CHECKSUM_SHA256_388="5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
1174 +
SOURCE_FILENAME_388="unicode-normalization-0.1.25.tar.gz"
1175 +
SOURCE_URI_389="https://static.crates.io/crates/unicode-properties/unicode-properties-0.1.4.crate"
1176 +
CHECKSUM_SHA256_389="7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
1177 +
SOURCE_FILENAME_389="unicode-properties-0.1.4.tar.gz"
1178 +
SOURCE_URI_390="https://static.crates.io/crates/unicode-width/unicode-width-0.1.14.crate"
1179 +
CHECKSUM_SHA256_390="7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
1180 +
SOURCE_FILENAME_390="unicode-width-0.1.14.tar.gz"
1181 +
SOURCE_URI_391="https://static.crates.io/crates/untrusted/untrusted-0.9.0.crate"
1182 +
CHECKSUM_SHA256_391="8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
1183 +
SOURCE_FILENAME_391="untrusted-0.9.0.tar.gz"
1184 +
SOURCE_URI_392="https://static.crates.io/crates/ureq/ureq-2.12.1.crate"
1185 +
CHECKSUM_SHA256_392="02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
1186 +
SOURCE_FILENAME_392="ureq-2.12.1.tar.gz"
1187 +
SOURCE_URI_393="https://static.crates.io/crates/url/url-2.5.8.crate"
1188 +
CHECKSUM_SHA256_393="ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
1189 +
SOURCE_FILENAME_393="url-2.5.8.tar.gz"
1190 +
SOURCE_URI_394="https://static.crates.io/crates/utf8_iter/utf8_iter-1.0.4.crate"
1191 +
CHECKSUM_SHA256_394="b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1192 +
SOURCE_FILENAME_394="utf8_iter-1.0.4.tar.gz"
1193 +
SOURCE_URI_395="https://static.crates.io/crates/utf8parse/utf8parse-0.2.2.crate"
1194 +
CHECKSUM_SHA256_395="06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1195 +
SOURCE_FILENAME_395="utf8parse-0.2.2.tar.gz"
1196 +
SOURCE_URI_396="https://static.crates.io/crates/valuable/valuable-0.1.1.crate"
1197 +
CHECKSUM_SHA256_396="ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1198 +
SOURCE_FILENAME_396="valuable-0.1.1.tar.gz"
1199 +
SOURCE_URI_397="https://static.crates.io/crates/vcpkg/vcpkg-0.2.15.crate"
1200 +
CHECKSUM_SHA256_397="accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
1201 +
SOURCE_FILENAME_397="vcpkg-0.2.15.tar.gz"
1202 +
SOURCE_URI_398="https://static.crates.io/crates/version_check/version_check-0.9.5.crate"
1203 +
CHECKSUM_SHA256_398="0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1204 +
SOURCE_FILENAME_398="version_check-0.9.5.tar.gz"
1205 +
SOURCE_URI_399="https://static.crates.io/crates/walkdir/walkdir-2.5.0.crate"
1206 +
CHECKSUM_SHA256_399="29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
1207 +
SOURCE_FILENAME_399="walkdir-2.5.0.tar.gz"
1208 +
SOURCE_URI_400="https://static.crates.io/crates/want/want-0.3.1.crate"
1209 +
CHECKSUM_SHA256_400="bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
1210 +
SOURCE_FILENAME_400="want-0.3.1.tar.gz"
1211 +
SOURCE_URI_401="https://static.crates.io/crates/wasi/wasi-0.11.1+wasi-snapshot-preview1.crate"
1212 +
CHECKSUM_SHA256_401="ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1213 +
SOURCE_FILENAME_401="wasi-0.11.1+wasi-snapshot-preview1.tar.gz"
1214 +
SOURCE_URI_402="https://static.crates.io/crates/wasite/wasite-0.1.0.crate"
1215 +
CHECKSUM_SHA256_402="b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
1216 +
SOURCE_FILENAME_402="wasite-0.1.0.tar.gz"
1217 +
SOURCE_URI_403="https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.129.crate"
1218 +
CHECKSUM_SHA256_403="9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
1219 +
SOURCE_FILENAME_403="wasm-bindgen-0.2.129.tar.gz"
1220 +
SOURCE_URI_404="https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.79.crate"
1221 +
CHECKSUM_SHA256_404="3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
1222 +
SOURCE_FILENAME_404="wasm-bindgen-futures-0.4.79.tar.gz"
1223 +
SOURCE_URI_405="https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.129.crate"
1224 +
CHECKSUM_SHA256_405="2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
1225 +
SOURCE_FILENAME_405="wasm-bindgen-macro-0.2.129.tar.gz"
1226 +
SOURCE_URI_406="https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.129.crate"
1227 +
CHECKSUM_SHA256_406="6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
1228 +
SOURCE_FILENAME_406="wasm-bindgen-macro-support-0.2.129.tar.gz"
1229 +
SOURCE_URI_407="https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.129.crate"
1230 +
CHECKSUM_SHA256_407="23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
1231 +
SOURCE_FILENAME_407="wasm-bindgen-shared-0.2.129.tar.gz"
1232 +
SOURCE_URI_408="https://static.crates.io/crates/web-sys/web-sys-0.3.106.crate"
1233 +
CHECKSUM_SHA256_408="88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
1234 +
SOURCE_FILENAME_408="web-sys-0.3.106.tar.gz"
1235 +
SOURCE_URI_409="https://static.crates.io/crates/web-time/web-time-1.1.0.crate"
1236 +
CHECKSUM_SHA256_409="5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
1237 +
SOURCE_FILENAME_409="web-time-1.1.0.tar.gz"
1238 +
SOURCE_URI_410="https://static.crates.io/crates/web_atoms/web_atoms-0.3.0.crate"
1239 +
CHECKSUM_SHA256_410="7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada"
1240 +
SOURCE_FILENAME_410="web_atoms-0.3.0.tar.gz"
1241 +
SOURCE_URI_411="https://static.crates.io/crates/webpki-roots/webpki-roots-0.26.11.crate"
1242 +
CHECKSUM_SHA256_411="521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
1243 +
SOURCE_FILENAME_411="webpki-roots-0.26.11.tar.gz"
1244 +
SOURCE_URI_412="https://static.crates.io/crates/webpki-roots/webpki-roots-1.0.9.crate"
1245 +
CHECKSUM_SHA256_412="7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
1246 +
SOURCE_FILENAME_412="webpki-roots-1.0.9.tar.gz"
1247 +
SOURCE_URI_413="https://static.crates.io/crates/whoami/whoami-1.6.1.crate"
1248 +
CHECKSUM_SHA256_413="5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
1249 +
SOURCE_FILENAME_413="whoami-1.6.1.tar.gz"
1250 +
SOURCE_URI_414="https://static.crates.io/crates/winapi-util/winapi-util-0.1.11.crate"
1251 +
CHECKSUM_SHA256_414="c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
1252 +
SOURCE_FILENAME_414="winapi-util-0.1.11.tar.gz"
1253 +
SOURCE_URI_415="https://static.crates.io/crates/windows-core/windows-core-0.62.2.crate"
1254 +
CHECKSUM_SHA256_415="b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
1255 +
SOURCE_FILENAME_415="windows-core-0.62.2.tar.gz"
1256 +
SOURCE_URI_416="https://static.crates.io/crates/windows-implement/windows-implement-0.60.2.crate"
1257 +
CHECKSUM_SHA256_416="053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
1258 +
SOURCE_FILENAME_416="windows-implement-0.60.2.tar.gz"
1259 +
SOURCE_URI_417="https://static.crates.io/crates/windows-interface/windows-interface-0.59.3.crate"
1260 +
CHECKSUM_SHA256_417="3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
1261 +
SOURCE_FILENAME_417="windows-interface-0.59.3.tar.gz"
1262 +
SOURCE_URI_418="https://static.crates.io/crates/windows-link/windows-link-0.2.1.crate"
1263 +
CHECKSUM_SHA256_418="f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
1264 +
SOURCE_FILENAME_418="windows-link-0.2.1.tar.gz"
1265 +
SOURCE_URI_419="https://static.crates.io/crates/windows-result/windows-result-0.4.1.crate"
1266 +
CHECKSUM_SHA256_419="7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
1267 +
SOURCE_FILENAME_419="windows-result-0.4.1.tar.gz"
1268 +
SOURCE_URI_420="https://static.crates.io/crates/windows-strings/windows-strings-0.5.1.crate"
1269 +
CHECKSUM_SHA256_420="7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
1270 +
SOURCE_FILENAME_420="windows-strings-0.5.1.tar.gz"
1271 +
SOURCE_URI_421="https://static.crates.io/crates/windows-sys/windows-sys-0.48.0.crate"
1272 +
CHECKSUM_SHA256_421="677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
1273 +
SOURCE_FILENAME_421="windows-sys-0.48.0.tar.gz"
1274 +
SOURCE_URI_422="https://static.crates.io/crates/windows-sys/windows-sys-0.52.0.crate"
1275 +
CHECKSUM_SHA256_422="282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
1276 +
SOURCE_FILENAME_422="windows-sys-0.52.0.tar.gz"
1277 +
SOURCE_URI_423="https://static.crates.io/crates/windows-sys/windows-sys-0.61.2.crate"
1278 +
CHECKSUM_SHA256_423="ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
1279 +
SOURCE_FILENAME_423="windows-sys-0.61.2.tar.gz"
1280 +
SOURCE_URI_424="https://static.crates.io/crates/windows-targets/windows-targets-0.48.5.crate"
1281 +
CHECKSUM_SHA256_424="9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
1282 +
SOURCE_FILENAME_424="windows-targets-0.48.5.tar.gz"
1283 +
SOURCE_URI_425="https://static.crates.io/crates/windows-targets/windows-targets-0.52.6.crate"
1284 +
CHECKSUM_SHA256_425="9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1285 +
SOURCE_FILENAME_425="windows-targets-0.52.6.tar.gz"
1286 +
SOURCE_URI_426="https://static.crates.io/crates/windows_aarch64_gnullvm/windows_aarch64_gnullvm-0.48.5.crate"
1287 +
CHECKSUM_SHA256_426="2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
1288 +
SOURCE_FILENAME_426="windows_aarch64_gnullvm-0.48.5.tar.gz"
1289 +
SOURCE_URI_427="https://static.crates.io/crates/windows_aarch64_gnullvm/windows_aarch64_gnullvm-0.52.6.crate"
1290 +
CHECKSUM_SHA256_427="32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1291 +
SOURCE_FILENAME_427="windows_aarch64_gnullvm-0.52.6.tar.gz"
1292 +
SOURCE_URI_428="https://static.crates.io/crates/windows_aarch64_msvc/windows_aarch64_msvc-0.48.5.crate"
1293 +
CHECKSUM_SHA256_428="dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
1294 +
SOURCE_FILENAME_428="windows_aarch64_msvc-0.48.5.tar.gz"
1295 +
SOURCE_URI_429="https://static.crates.io/crates/windows_aarch64_msvc/windows_aarch64_msvc-0.52.6.crate"
1296 +
CHECKSUM_SHA256_429="09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1297 +
SOURCE_FILENAME_429="windows_aarch64_msvc-0.52.6.tar.gz"
1298 +
SOURCE_URI_430="https://static.crates.io/crates/windows_i686_gnu/windows_i686_gnu-0.48.5.crate"
1299 +
CHECKSUM_SHA256_430="a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
1300 +
SOURCE_FILENAME_430="windows_i686_gnu-0.48.5.tar.gz"
1301 +
SOURCE_URI_431="https://static.crates.io/crates/windows_i686_gnu/windows_i686_gnu-0.52.6.crate"
1302 +
CHECKSUM_SHA256_431="8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1303 +
SOURCE_FILENAME_431="windows_i686_gnu-0.52.6.tar.gz"
1304 +
SOURCE_URI_432="https://static.crates.io/crates/windows_i686_gnullvm/windows_i686_gnullvm-0.52.6.crate"
1305 +
CHECKSUM_SHA256_432="0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1306 +
SOURCE_FILENAME_432="windows_i686_gnullvm-0.52.6.tar.gz"
1307 +
SOURCE_URI_433="https://static.crates.io/crates/windows_i686_msvc/windows_i686_msvc-0.48.5.crate"
1308 +
CHECKSUM_SHA256_433="8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
1309 +
SOURCE_FILENAME_433="windows_i686_msvc-0.48.5.tar.gz"
1310 +
SOURCE_URI_434="https://static.crates.io/crates/windows_i686_msvc/windows_i686_msvc-0.52.6.crate"
1311 +
CHECKSUM_SHA256_434="240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
1312 +
SOURCE_FILENAME_434="windows_i686_msvc-0.52.6.tar.gz"
1313 +
SOURCE_URI_435="https://static.crates.io/crates/windows_x86_64_gnu/windows_x86_64_gnu-0.48.5.crate"
1314 +
CHECKSUM_SHA256_435="53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
1315 +
SOURCE_FILENAME_435="windows_x86_64_gnu-0.48.5.tar.gz"
1316 +
SOURCE_URI_436="https://static.crates.io/crates/windows_x86_64_gnu/windows_x86_64_gnu-0.52.6.crate"
1317 +
CHECKSUM_SHA256_436="147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
1318 +
SOURCE_FILENAME_436="windows_x86_64_gnu-0.52.6.tar.gz"
1319 +
SOURCE_URI_437="https://static.crates.io/crates/windows_x86_64_gnullvm/windows_x86_64_gnullvm-0.48.5.crate"
1320 +
CHECKSUM_SHA256_437="0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
1321 +
SOURCE_FILENAME_437="windows_x86_64_gnullvm-0.48.5.tar.gz"
1322 +
SOURCE_URI_438="https://static.crates.io/crates/windows_x86_64_gnullvm/windows_x86_64_gnullvm-0.52.6.crate"
1323 +
CHECKSUM_SHA256_438="24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
1324 +
SOURCE_FILENAME_438="windows_x86_64_gnullvm-0.52.6.tar.gz"
1325 +
SOURCE_URI_439="https://static.crates.io/crates/windows_x86_64_msvc/windows_x86_64_msvc-0.48.5.crate"
1326 +
CHECKSUM_SHA256_439="ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
1327 +
SOURCE_FILENAME_439="windows_x86_64_msvc-0.48.5.tar.gz"
1328 +
SOURCE_URI_440="https://static.crates.io/crates/windows_x86_64_msvc/windows_x86_64_msvc-0.52.6.crate"
1329 +
CHECKSUM_SHA256_440="589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1330 +
SOURCE_FILENAME_440="windows_x86_64_msvc-0.52.6.tar.gz"
1331 +
SOURCE_URI_441="https://static.crates.io/crates/winnow/winnow-0.7.15.crate"
1332 +
CHECKSUM_SHA256_441="df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
1333 +
SOURCE_FILENAME_441="winnow-0.7.15.tar.gz"
1334 +
SOURCE_URI_442="https://static.crates.io/crates/writeable/writeable-0.6.4.crate"
1335 +
CHECKSUM_SHA256_442="3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
1336 +
SOURCE_FILENAME_442="writeable-0.6.4.tar.gz"
1337 +
SOURCE_URI_443="https://static.crates.io/crates/x509-parser/x509-parser-0.18.1.crate"
1338 +
CHECKSUM_SHA256_443="d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
1339 +
SOURCE_FILENAME_443="x509-parser-0.18.1.tar.gz"
1340 +
SOURCE_URI_444="https://static.crates.io/crates/xattr/xattr-1.6.1.crate"
1341 +
CHECKSUM_SHA256_444="32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
1342 +
SOURCE_FILENAME_444="xattr-1.6.1.tar.gz"
1343 +
SOURCE_URI_445="https://static.crates.io/crates/yaml-rust/yaml-rust-0.4.5.crate"
1344 +
CHECKSUM_SHA256_445="56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
1345 +
SOURCE_FILENAME_445="yaml-rust-0.4.5.tar.gz"
1346 +
SOURCE_URI_446="https://static.crates.io/crates/yasna/yasna-0.6.0.crate"
1347 +
CHECKSUM_SHA256_446="b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
1348 +
SOURCE_FILENAME_446="yasna-0.6.0.tar.gz"
1349 +
SOURCE_URI_447="https://static.crates.io/crates/yoke/yoke-0.8.3.crate"
1350 +
CHECKSUM_SHA256_447="709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
1351 +
SOURCE_FILENAME_447="yoke-0.8.3.tar.gz"
1352 +
SOURCE_URI_448="https://static.crates.io/crates/yoke-derive/yoke-derive-0.8.3.crate"
1353 +
CHECKSUM_SHA256_448="33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
1354 +
SOURCE_FILENAME_448="yoke-derive-0.8.3.tar.gz"
1355 +
SOURCE_URI_449="https://static.crates.io/crates/zerocopy/zerocopy-0.8.59.crate"
1356 +
CHECKSUM_SHA256_449="6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
1357 +
SOURCE_FILENAME_449="zerocopy-0.8.59.tar.gz"
1358 +
SOURCE_URI_450="https://static.crates.io/crates/zerocopy-derive/zerocopy-derive-0.8.59.crate"
1359 +
CHECKSUM_SHA256_450="ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
1360 +
SOURCE_FILENAME_450="zerocopy-derive-0.8.59.tar.gz"
1361 +
SOURCE_URI_451="https://static.crates.io/crates/zerofrom/zerofrom-0.1.8.crate"
1362 +
CHECKSUM_SHA256_451="0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
1363 +
SOURCE_FILENAME_451="zerofrom-0.1.8.tar.gz"
1364 +
SOURCE_URI_452="https://static.crates.io/crates/zerofrom-derive/zerofrom-derive-0.1.8.crate"
1365 +
CHECKSUM_SHA256_452="f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
1366 +
SOURCE_FILENAME_452="zerofrom-derive-0.1.8.tar.gz"
1367 +
SOURCE_URI_453="https://static.crates.io/crates/zeroize/zeroize-1.9.0.crate"
1368 +
CHECKSUM_SHA256_453="e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
1369 +
SOURCE_FILENAME_453="zeroize-1.9.0.tar.gz"
1370 +
SOURCE_URI_454="https://static.crates.io/crates/zerotrie/zerotrie-0.2.5.crate"
1371 +
CHECKSUM_SHA256_454="4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
1372 +
SOURCE_FILENAME_454="zerotrie-0.2.5.tar.gz"
1373 +
SOURCE_URI_455="https://static.crates.io/crates/zerovec/zerovec-0.11.8.crate"
1374 +
CHECKSUM_SHA256_455="bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
1375 +
SOURCE_FILENAME_455="zerovec-0.11.8.tar.gz"
1376 +
SOURCE_URI_456="https://static.crates.io/crates/zerovec-derive/zerovec-derive-0.11.6.crate"
1377 +
CHECKSUM_SHA256_456="34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
1378 +
SOURCE_FILENAME_456="zerovec-derive-0.11.6.tar.gz"
1379 +
SOURCE_URI_457="https://static.crates.io/crates/zip/zip-2.4.2.crate"
1380 +
CHECKSUM_SHA256_457="fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
1381 +
SOURCE_FILENAME_457="zip-2.4.2.tar.gz"
1382 +
SOURCE_URI_458="https://static.crates.io/crates/zlib-rs/zlib-rs-0.6.8.crate"
1383 +
CHECKSUM_SHA256_458="b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
1384 +
SOURCE_FILENAME_458="zlib-rs-0.6.8.tar.gz"
1385 +
SOURCE_URI_459="https://static.crates.io/crates/zmij/zmij-1.0.23.crate"
1386 +
CHECKSUM_SHA256_459="29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
1387 +
SOURCE_FILENAME_459="zmij-1.0.23.tar.gz"
1388 +
SOURCE_URI_460="https://static.crates.io/crates/zopfli/zopfli-0.8.3.crate"
1389 +
CHECKSUM_SHA256_460="f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
1390 +
SOURCE_FILENAME_460="zopfli-0.8.3.tar.gz"
1391 +
SOURCE_URI_461="https://static.crates.io/crates/zstd/zstd-0.13.3.crate"
1392 +
CHECKSUM_SHA256_461="e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
1393 +
SOURCE_FILENAME_461="zstd-0.13.3.tar.gz"
1394 +
SOURCE_URI_462="https://static.crates.io/crates/zstd-safe/zstd-safe-7.3.0.crate"
1395 +
CHECKSUM_SHA256_462="64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
1396 +
SOURCE_FILENAME_462="zstd-safe-7.3.0.tar.gz"
1397 +
SOURCE_URI_463="https://static.crates.io/crates/zstd-sys/zstd-sys-2.1.0+zstd.1.5.7.crate"
1398 +
CHECKSUM_SHA256_463="0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
1399 +
SOURCE_FILENAME_463="zstd-sys-2.1.0+zstd.1.5.7.tar.gz"
1400 +
SOURCE_URI_464="https://static.crates.io/crates/zune-core/zune-core-0.5.3.crate"
1401 +
CHECKSUM_SHA256_464="d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
1402 +
SOURCE_FILENAME_464="zune-core-0.5.3.tar.gz"
1403 +
SOURCE_URI_465="https://static.crates.io/crates/zune-jpeg/zune-jpeg-0.5.15.crate"
1404 +
CHECKSUM_SHA256_465="27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
1405 +
SOURCE_FILENAME_465="zune-jpeg-0.5.15.tar.gz"
1406 +
1407 +
ARCHITECTURES="!all x86_64"
1408 +
1409 +
PROVIDES="
1410 +
	oh$secondaryArchSuffix = $portVersion
1411 +
	cmd:oh
1412 +
	"
1413 +
REQUIRES="
1414 +
	haiku$secondaryArchSuffix
1415 +
	lib:libzstd$secondaryArchSuffix
1416 +
	"
1417 +
1418 +
BUILD_REQUIRES="
1419 +
	haiku${secondaryArchSuffix}_devel
1420 +
	devel:libzstd$secondaryArchSuffix
1421 +
	"
1422 +
BUILD_PREREQUIRES="
1423 +
	cmd:gcc$secondaryArchSuffix
1424 +
	cmd:pkg_config$secondaryArchSuffix
1425 +
	cmd:rustc$secondaryArchSuffix
1426 +
	"
1427 +
1428 +
BUILD()
1429 +
{
1430 +
	export CARGO_HOME=$sourceDir/../cargo
1431 +
	vendor=$CARGO_HOME/haiku
1432 +
	mkdir -p "$vendor"
1433 +
	for i in $(seq 2 465); do
1434 +
		eval "srcDir=\$sourceDir$i"
1435 +
		eval "sha256sum=\$CHECKSUM_SHA256_$i"
1436 +
		set -- "$srcDir"/*
1437 +
		ln -sf "$1" "$vendor"
1438 +
		cat <<-EOF >"$vendor/${1##*/}/.cargo-checksum.json"
1439 +
		{
1440 +
		  "package": "$sha256sum",
1441 +
		  "files": {}
1442 +
		}
1443 +
		EOF
1444 +
	done
1445 +
1446 +
	cat <<-EOF >"$CARGO_HOME"/config.toml
1447 +
	[source.haiku]
1448 +
	directory = "$vendor"
1449 +
1450 +
	[source.crates-io]
1451 +
	replace-with = "haiku"
1452 +
	EOF
1453 +
1454 +
	# The bundled zstd's hidden symbols break the link on Haiku: use the system's.
1455 +
	export ZSTD_SYS_USE_PKG_CONFIG=1
1456 +
	export OXY_BUILD=v$portVersion
1457 +
	cargo build --release --frozen -p oh
1458 +
}
1459 +
1460 +
INSTALL()
1461 +
{
1462 +
	install -v -m 755 -d "$prefix"/bin "$docDir"
1463 +
	install -v -m 755 ./target/release/oh "$prefix"/bin
1464 +
	install -v -m 644 README.md LICENSE docs/oh.md "$docDir"
1465 +
}
1466 +
1467 +
TEST()
1468 +
{
1469 +
	export CARGO_HOME=$sourceDir/../cargo
1470 +
	export ZSTD_SYS_USE_PKG_CONFIG=1
1471 +
	cargo test --release --frozen -p hub-api
1472 +
}
added packaging/haikuports/dev-vcs/oxy/oxy-0.9.0.recipe +990 −0
1 +
SUMMARY="A version control system of signed, commuting changes, with Git interop"
2 +
DESCRIPTION="oxy is the command line of Oxygen, a version control system built on \
3 +
patch theory: changes commute, conflicts are recorded rather than blocking, \
4 +
every change can be signed, and a trust policy says who may move what. It \
5 +
imports and exports Git history, clones from OxygenHub and other Oxygen \
6 +
hosts, and keeps repositories on disk or in S3, GCS or Azure buckets."
7 +
HOMEPAGE="https://oxygenhub.io/andymccall/oxygen"
8 +
COPYRIGHT="2026 Andy McCall"
9 +
LICENSE="MIT
10 +
	Apache v2"
11 +
REVISION="1"
12 +
SOURCE_URI="$HOMEPAGE/releases/download/v$portVersion/oxy-$portVersion-src.tar.gz"
13 +
CHECKSUM_SHA256="b284392c53677177429b7db7f47b0b3585c766a910654a24ab47a6a75555bb8a"
14 +
SOURCE_DIR="oxygen-$portVersion"
15 +
SOURCE_URI_2="https://static.crates.io/crates/adler2/adler2-2.0.1.crate"
16 +
CHECKSUM_SHA256_2="320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
17 +
SOURCE_FILENAME_2="adler2-2.0.1.tar.gz"
18 +
SOURCE_URI_3="https://static.crates.io/crates/aho-corasick/aho-corasick-1.1.5.crate"
19 +
CHECKSUM_SHA256_3="c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
20 +
SOURCE_FILENAME_3="aho-corasick-1.1.5.tar.gz"
21 +
SOURCE_URI_4="https://static.crates.io/crates/android_system_properties/android_system_properties-0.1.6.crate"
22 +
CHECKSUM_SHA256_4="ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
23 +
SOURCE_FILENAME_4="android_system_properties-0.1.6.tar.gz"
24 +
SOURCE_URI_5="https://static.crates.io/crates/anstream/anstream-1.0.0.crate"
25 +
CHECKSUM_SHA256_5="824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
26 +
SOURCE_FILENAME_5="anstream-1.0.0.tar.gz"
27 +
SOURCE_URI_6="https://static.crates.io/crates/anstyle/anstyle-1.0.14.crate"
28 +
CHECKSUM_SHA256_6="940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
29 +
SOURCE_FILENAME_6="anstyle-1.0.14.tar.gz"
30 +
SOURCE_URI_7="https://static.crates.io/crates/anstyle-parse/anstyle-parse-1.0.0.crate"
31 +
CHECKSUM_SHA256_7="52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
32 +
SOURCE_FILENAME_7="anstyle-parse-1.0.0.tar.gz"
33 +
SOURCE_URI_8="https://static.crates.io/crates/anstyle-query/anstyle-query-1.1.5.crate"
34 +
CHECKSUM_SHA256_8="40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
35 +
SOURCE_FILENAME_8="anstyle-query-1.1.5.tar.gz"
36 +
SOURCE_URI_9="https://static.crates.io/crates/anstyle-wincon/anstyle-wincon-3.0.11.crate"
37 +
CHECKSUM_SHA256_9="291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
38 +
SOURCE_FILENAME_9="anstyle-wincon-3.0.11.tar.gz"
39 +
SOURCE_URI_10="https://static.crates.io/crates/anyhow/anyhow-1.0.104.crate"
40 +
CHECKSUM_SHA256_10="330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
41 +
SOURCE_FILENAME_10="anyhow-1.0.104.tar.gz"
42 +
SOURCE_URI_11="https://static.crates.io/crates/arc-swap/arc-swap-1.9.2.crate"
43 +
CHECKSUM_SHA256_11="c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
44 +
SOURCE_FILENAME_11="arc-swap-1.9.2.tar.gz"
45 +
SOURCE_URI_12="https://static.crates.io/crates/arrayvec/arrayvec-0.7.8.crate"
46 +
CHECKSUM_SHA256_12="d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
47 +
SOURCE_FILENAME_12="arrayvec-0.7.8.tar.gz"
48 +
SOURCE_URI_13="https://static.crates.io/crates/asn1-rs/asn1-rs-0.7.2.crate"
49 +
CHECKSUM_SHA256_13="b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
50 +
SOURCE_FILENAME_13="asn1-rs-0.7.2.tar.gz"
51 +
SOURCE_URI_14="https://static.crates.io/crates/asn1-rs-derive/asn1-rs-derive-0.6.0.crate"
52 +
CHECKSUM_SHA256_14="3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
53 +
SOURCE_FILENAME_14="asn1-rs-derive-0.6.0.tar.gz"
54 +
SOURCE_URI_15="https://static.crates.io/crates/asn1-rs-impl/asn1-rs-impl-0.2.0.crate"
55 +
CHECKSUM_SHA256_15="7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
56 +
SOURCE_FILENAME_15="asn1-rs-impl-0.2.0.tar.gz"
57 +
SOURCE_URI_16="https://static.crates.io/crates/async-trait/async-trait-0.1.92.crate"
58 +
CHECKSUM_SHA256_16="82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
59 +
SOURCE_FILENAME_16="async-trait-0.1.92.tar.gz"
60 +
SOURCE_URI_17="https://static.crates.io/crates/atomic-waker/atomic-waker-1.1.2.crate"
61 +
CHECKSUM_SHA256_17="1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
62 +
SOURCE_FILENAME_17="atomic-waker-1.1.2.tar.gz"
63 +
SOURCE_URI_18="https://static.crates.io/crates/autocfg/autocfg-1.5.1.crate"
64 +
CHECKSUM_SHA256_18="f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
65 +
SOURCE_FILENAME_18="autocfg-1.5.1.tar.gz"
66 +
SOURCE_URI_19="https://static.crates.io/crates/axum/axum-0.8.9.crate"
67 +
CHECKSUM_SHA256_19="31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
68 +
SOURCE_FILENAME_19="axum-0.8.9.tar.gz"
69 +
SOURCE_URI_20="https://static.crates.io/crates/axum-core/axum-core-0.5.6.crate"
70 +
CHECKSUM_SHA256_20="08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
71 +
SOURCE_FILENAME_20="axum-core-0.5.6.tar.gz"
72 +
SOURCE_URI_21="https://static.crates.io/crates/axum-server/axum-server-0.8.0.crate"
73 +
CHECKSUM_SHA256_21="b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc"
74 +
SOURCE_FILENAME_21="axum-server-0.8.0.tar.gz"
75 +
SOURCE_URI_22="https://static.crates.io/crates/base64/base64-0.22.1.crate"
76 +
CHECKSUM_SHA256_22="72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
77 +
SOURCE_FILENAME_22="base64-0.22.1.tar.gz"
78 +
SOURCE_URI_23="https://static.crates.io/crates/base64/base64-0.23.1.crate"
79 +
CHECKSUM_SHA256_23="ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
80 +
SOURCE_FILENAME_23="base64-0.23.1.tar.gz"
81 +
SOURCE_URI_24="https://static.crates.io/crates/base64ct/base64ct-1.8.3.crate"
82 +
CHECKSUM_SHA256_24="2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
83 +
SOURCE_FILENAME_24="base64ct-1.8.3.tar.gz"
84 +
SOURCE_URI_25="https://static.crates.io/crates/bit-set/bit-set-0.8.0.crate"
85 +
CHECKSUM_SHA256_25="08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
86 +
SOURCE_FILENAME_25="bit-set-0.8.0.tar.gz"
87 +
SOURCE_URI_26="https://static.crates.io/crates/bit-vec/bit-vec-0.8.0.crate"
88 +
CHECKSUM_SHA256_26="5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
89 +
SOURCE_FILENAME_26="bit-vec-0.8.0.tar.gz"
90 +
SOURCE_URI_27="https://static.crates.io/crates/bit-vec/bit-vec-0.9.1.crate"
91 +
CHECKSUM_SHA256_27="b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
92 +
SOURCE_FILENAME_27="bit-vec-0.9.1.tar.gz"
93 +
SOURCE_URI_28="https://static.crates.io/crates/bitflags/bitflags-2.13.2.crate"
94 +
CHECKSUM_SHA256_28="3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
95 +
SOURCE_FILENAME_28="bitflags-2.13.2.tar.gz"
96 +
SOURCE_URI_29="https://static.crates.io/crates/blake3/blake3-1.8.7.crate"
97 +
CHECKSUM_SHA256_29="6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
98 +
SOURCE_FILENAME_29="blake3-1.8.7.tar.gz"
99 +
SOURCE_URI_30="https://static.crates.io/crates/block-buffer/block-buffer-0.12.1.crate"
100 +
CHECKSUM_SHA256_30="d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
101 +
SOURCE_FILENAME_30="block-buffer-0.12.1.tar.gz"
102 +
SOURCE_URI_31="https://static.crates.io/crates/bstr/bstr-1.13.1.crate"
103 +
CHECKSUM_SHA256_31="6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
104 +
SOURCE_FILENAME_31="bstr-1.13.1.tar.gz"
105 +
SOURCE_URI_32="https://static.crates.io/crates/bumpalo/bumpalo-3.20.3.crate"
106 +
CHECKSUM_SHA256_32="72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
107 +
SOURCE_FILENAME_32="bumpalo-3.20.3.tar.gz"
108 +
SOURCE_URI_33="https://static.crates.io/crates/bytes/bytes-1.12.1.crate"
109 +
CHECKSUM_SHA256_33="fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
110 +
SOURCE_FILENAME_33="bytes-1.12.1.tar.gz"
111 +
SOURCE_URI_34="https://static.crates.io/crates/cc/cc-1.5.1.crate"
112 +
CHECKSUM_SHA256_34="f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
113 +
SOURCE_FILENAME_34="cc-1.5.1.tar.gz"
114 +
SOURCE_URI_35="https://static.crates.io/crates/cfg-if/cfg-if-1.0.5.crate"
115 +
CHECKSUM_SHA256_35="4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
116 +
SOURCE_FILENAME_35="cfg-if-1.0.5.tar.gz"
117 +
SOURCE_URI_36="https://static.crates.io/crates/chacha20/chacha20-0.10.2.crate"
118 +
CHECKSUM_SHA256_36="65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
119 +
SOURCE_FILENAME_36="chacha20-0.10.2.tar.gz"
120 +
SOURCE_URI_37="https://static.crates.io/crates/chrono/chrono-0.4.45.crate"
121 +
CHECKSUM_SHA256_37="1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
122 +
SOURCE_FILENAME_37="chrono-0.4.45.tar.gz"
123 +
SOURCE_URI_38="https://static.crates.io/crates/clap/clap-4.6.7.crate"
124 +
CHECKSUM_SHA256_38="aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
125 +
SOURCE_FILENAME_38="clap-4.6.7.tar.gz"
126 +
SOURCE_URI_39="https://static.crates.io/crates/clap_builder/clap_builder-4.6.7.crate"
127 +
CHECKSUM_SHA256_39="ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
128 +
SOURCE_FILENAME_39="clap_builder-4.6.7.tar.gz"
129 +
SOURCE_URI_40="https://static.crates.io/crates/clap_complete/clap_complete-4.6.11.crate"
130 +
CHECKSUM_SHA256_40="037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f"
131 +
SOURCE_FILENAME_40="clap_complete-4.6.11.tar.gz"
132 +
SOURCE_URI_41="https://static.crates.io/crates/clap_derive/clap_derive-4.6.7.crate"
133 +
CHECKSUM_SHA256_41="f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
134 +
SOURCE_FILENAME_41="clap_derive-4.6.7.tar.gz"
135 +
SOURCE_URI_42="https://static.crates.io/crates/clap_lex/clap_lex-1.1.1.crate"
136 +
CHECKSUM_SHA256_42="1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
137 +
SOURCE_FILENAME_42="clap_lex-1.1.1.tar.gz"
138 +
SOURCE_URI_43="https://static.crates.io/crates/clap_mangen/clap_mangen-0.3.3.crate"
139 +
CHECKSUM_SHA256_43="211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4"
140 +
SOURCE_FILENAME_43="clap_mangen-0.3.3.tar.gz"
141 +
SOURCE_URI_44="https://static.crates.io/crates/cmov/cmov-0.5.4.crate"
142 +
CHECKSUM_SHA256_44="0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
143 +
SOURCE_FILENAME_44="cmov-0.5.4.tar.gz"
144 +
SOURCE_URI_45="https://static.crates.io/crates/colorchoice/colorchoice-1.0.5.crate"
145 +
CHECKSUM_SHA256_45="1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
146 +
SOURCE_FILENAME_45="colorchoice-1.0.5.tar.gz"
147 +
SOURCE_URI_46="https://static.crates.io/crates/console/console-0.15.11.crate"
148 +
CHECKSUM_SHA256_46="054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
149 +
SOURCE_FILENAME_46="console-0.15.11.tar.gz"
150 +
SOURCE_URI_47="https://static.crates.io/crates/const-oid/const-oid-0.10.2.crate"
151 +
CHECKSUM_SHA256_47="a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
152 +
SOURCE_FILENAME_47="const-oid-0.10.2.tar.gz"
153 +
SOURCE_URI_48="https://static.crates.io/crates/constant_time_eq/constant_time_eq-0.4.2.crate"
154 +
CHECKSUM_SHA256_48="3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
155 +
SOURCE_FILENAME_48="constant_time_eq-0.4.2.tar.gz"
156 +
SOURCE_URI_49="https://static.crates.io/crates/cpufeatures/cpufeatures-0.3.1.crate"
157 +
CHECKSUM_SHA256_49="5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
158 +
SOURCE_FILENAME_49="cpufeatures-0.3.1.tar.gz"
159 +
SOURCE_URI_50="https://static.crates.io/crates/crc32fast/crc32fast-1.5.2.crate"
160 +
CHECKSUM_SHA256_50="01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
161 +
SOURCE_FILENAME_50="crc32fast-1.5.2.tar.gz"
162 +
SOURCE_URI_51="https://static.crates.io/crates/crossbeam-deque/crossbeam-deque-0.8.8.crate"
163 +
CHECKSUM_SHA256_51="622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
164 +
SOURCE_FILENAME_51="crossbeam-deque-0.8.8.tar.gz"
165 +
SOURCE_URI_52="https://static.crates.io/crates/crossbeam-epoch/crossbeam-epoch-0.9.21.crate"
166 +
CHECKSUM_SHA256_52="dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
167 +
SOURCE_FILENAME_52="crossbeam-epoch-0.9.21.tar.gz"
168 +
SOURCE_URI_53="https://static.crates.io/crates/crossbeam-utils/crossbeam-utils-0.8.23.crate"
169 +
CHECKSUM_SHA256_53="a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
170 +
SOURCE_FILENAME_53="crossbeam-utils-0.8.23.tar.gz"
171 +
SOURCE_URI_54="https://static.crates.io/crates/crypto-common/crypto-common-0.1.7.crate"
172 +
CHECKSUM_SHA256_54="78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
173 +
SOURCE_FILENAME_54="crypto-common-0.1.7.tar.gz"
174 +
SOURCE_URI_55="https://static.crates.io/crates/crypto-common/crypto-common-0.2.2.crate"
175 +
CHECKSUM_SHA256_55="ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
176 +
SOURCE_FILENAME_55="crypto-common-0.2.2.tar.gz"
177 +
SOURCE_URI_56="https://static.crates.io/crates/ctutils/ctutils-0.4.2.crate"
178 +
CHECKSUM_SHA256_56="7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
179 +
SOURCE_FILENAME_56="ctutils-0.4.2.tar.gz"
180 +
SOURCE_URI_57="https://static.crates.io/crates/curve25519-dalek/curve25519-dalek-5.0.0.crate"
181 +
CHECKSUM_SHA256_57="b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
182 +
SOURCE_FILENAME_57="curve25519-dalek-5.0.0.tar.gz"
183 +
SOURCE_URI_58="https://static.crates.io/crates/curve25519-dalek-derive/curve25519-dalek-derive-0.1.1.crate"
184 +
CHECKSUM_SHA256_58="f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
185 +
SOURCE_FILENAME_58="curve25519-dalek-derive-0.1.1.tar.gz"
186 +
SOURCE_URI_59="https://static.crates.io/crates/data-encoding/data-encoding-2.11.1.crate"
187 +
CHECKSUM_SHA256_59="4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
188 +
SOURCE_FILENAME_59="data-encoding-2.11.1.tar.gz"
189 +
SOURCE_URI_60="https://static.crates.io/crates/der/der-0.8.2.crate"
190 +
CHECKSUM_SHA256_60="a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
191 +
SOURCE_FILENAME_60="der-0.8.2.tar.gz"
192 +
SOURCE_URI_61="https://static.crates.io/crates/der-parser/der-parser-10.0.0.crate"
193 +
CHECKSUM_SHA256_61="07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
194 +
SOURCE_FILENAME_61="der-parser-10.0.0.tar.gz"
195 +
SOURCE_URI_62="https://static.crates.io/crates/deranged/deranged-0.5.8.crate"
196 +
CHECKSUM_SHA256_62="7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
197 +
SOURCE_FILENAME_62="deranged-0.5.8.tar.gz"
198 +
SOURCE_URI_63="https://static.crates.io/crates/digest/digest-0.10.7.crate"
199 +
CHECKSUM_SHA256_63="9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
200 +
SOURCE_FILENAME_63="digest-0.10.7.tar.gz"
201 +
SOURCE_URI_64="https://static.crates.io/crates/digest/digest-0.11.3.crate"
202 +
CHECKSUM_SHA256_64="f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
203 +
SOURCE_FILENAME_64="digest-0.11.3.tar.gz"
204 +
SOURCE_URI_65="https://static.crates.io/crates/displaydoc/displaydoc-0.2.7.crate"
205 +
CHECKSUM_SHA256_65="c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
206 +
SOURCE_FILENAME_65="displaydoc-0.2.7.tar.gz"
207 +
SOURCE_URI_66="https://static.crates.io/crates/ed25519/ed25519-3.0.0.crate"
208 +
CHECKSUM_SHA256_66="29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
209 +
SOURCE_FILENAME_66="ed25519-3.0.0.tar.gz"
210 +
SOURCE_URI_67="https://static.crates.io/crates/ed25519-dalek/ed25519-dalek-3.0.0.crate"
211 +
CHECKSUM_SHA256_67="6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
212 +
SOURCE_FILENAME_67="ed25519-dalek-3.0.0.tar.gz"
213 +
SOURCE_URI_68="https://static.crates.io/crates/either/either-1.18.0.crate"
214 +
CHECKSUM_SHA256_68="252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
215 +
SOURCE_FILENAME_68="either-1.18.0.tar.gz"
216 +
SOURCE_URI_69="https://static.crates.io/crates/encode_unicode/encode_unicode-1.0.0.crate"
217 +
CHECKSUM_SHA256_69="34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
218 +
SOURCE_FILENAME_69="encode_unicode-1.0.0.tar.gz"
219 +
SOURCE_URI_70="https://static.crates.io/crates/equivalent/equivalent-1.0.2.crate"
220 +
CHECKSUM_SHA256_70="877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
221 +
SOURCE_FILENAME_70="equivalent-1.0.2.tar.gz"
222 +
SOURCE_URI_71="https://static.crates.io/crates/errno/errno-0.3.14.crate"
223 +
CHECKSUM_SHA256_71="39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
224 +
SOURCE_FILENAME_71="errno-0.3.14.tar.gz"
225 +
SOURCE_URI_72="https://static.crates.io/crates/fastrand/fastrand-2.5.0.crate"
226 +
CHECKSUM_SHA256_72="da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
227 +
SOURCE_FILENAME_72="fastrand-2.5.0.tar.gz"
228 +
SOURCE_URI_73="https://static.crates.io/crates/fiat-crypto/fiat-crypto-0.3.0.crate"
229 +
CHECKSUM_SHA256_73="64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
230 +
SOURCE_FILENAME_73="fiat-crypto-0.3.0.tar.gz"
231 +
SOURCE_URI_74="https://static.crates.io/crates/find-msvc-tools/find-msvc-tools-0.1.14.crate"
232 +
CHECKSUM_SHA256_74="aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
233 +
SOURCE_FILENAME_74="find-msvc-tools-0.1.14.tar.gz"
234 +
SOURCE_URI_75="https://static.crates.io/crates/flate2/flate2-1.1.10.crate"
235 +
CHECKSUM_SHA256_75="6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
236 +
SOURCE_FILENAME_75="flate2-1.1.10.tar.gz"
237 +
SOURCE_URI_76="https://static.crates.io/crates/fnv/fnv-1.0.7.crate"
238 +
CHECKSUM_SHA256_76="3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
239 +
SOURCE_FILENAME_76="fnv-1.0.7.tar.gz"
240 +
SOURCE_URI_77="https://static.crates.io/crates/form_urlencoded/form_urlencoded-1.2.2.crate"
241 +
CHECKSUM_SHA256_77="cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
242 +
SOURCE_FILENAME_77="form_urlencoded-1.2.2.tar.gz"
243 +
SOURCE_URI_78="https://static.crates.io/crates/fs-err/fs-err-3.3.1.crate"
244 +
CHECKSUM_SHA256_78="b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a"
245 +
SOURCE_FILENAME_78="fs-err-3.3.1.tar.gz"
246 +
SOURCE_URI_79="https://static.crates.io/crates/futures/futures-0.3.34.crate"
247 +
CHECKSUM_SHA256_79="9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
248 +
SOURCE_FILENAME_79="futures-0.3.34.tar.gz"
249 +
SOURCE_URI_80="https://static.crates.io/crates/futures-channel/futures-channel-0.3.34.crate"
250 +
CHECKSUM_SHA256_80="b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
251 +
SOURCE_FILENAME_80="futures-channel-0.3.34.tar.gz"
252 +
SOURCE_URI_81="https://static.crates.io/crates/futures-core/futures-core-0.3.34.crate"
253 +
CHECKSUM_SHA256_81="92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
254 +
SOURCE_FILENAME_81="futures-core-0.3.34.tar.gz"
255 +
SOURCE_URI_82="https://static.crates.io/crates/futures-executor/futures-executor-0.3.34.crate"
256 +
CHECKSUM_SHA256_82="031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
257 +
SOURCE_FILENAME_82="futures-executor-0.3.34.tar.gz"
258 +
SOURCE_URI_83="https://static.crates.io/crates/futures-io/futures-io-0.3.34.crate"
259 +
CHECKSUM_SHA256_83="53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
260 +
SOURCE_FILENAME_83="futures-io-0.3.34.tar.gz"
261 +
SOURCE_URI_84="https://static.crates.io/crates/futures-macro/futures-macro-0.3.34.crate"
262 +
CHECKSUM_SHA256_84="9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
263 +
SOURCE_FILENAME_84="futures-macro-0.3.34.tar.gz"
264 +
SOURCE_URI_85="https://static.crates.io/crates/futures-sink/futures-sink-0.3.34.crate"
265 +
CHECKSUM_SHA256_85="1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
266 +
SOURCE_FILENAME_85="futures-sink-0.3.34.tar.gz"
267 +
SOURCE_URI_86="https://static.crates.io/crates/futures-task/futures-task-0.3.34.crate"
268 +
CHECKSUM_SHA256_86="cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
269 +
SOURCE_FILENAME_86="futures-task-0.3.34.tar.gz"
270 +
SOURCE_URI_87="https://static.crates.io/crates/futures-util/futures-util-0.3.34.crate"
271 +
CHECKSUM_SHA256_87="0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
272 +
SOURCE_FILENAME_87="futures-util-0.3.34.tar.gz"
273 +
SOURCE_URI_88="https://static.crates.io/crates/generic-array/generic-array-0.14.7.crate"
274 +
CHECKSUM_SHA256_88="85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
275 +
SOURCE_FILENAME_88="generic-array-0.14.7.tar.gz"
276 +
SOURCE_URI_89="https://static.crates.io/crates/getrandom/getrandom-0.2.17.crate"
277 +
CHECKSUM_SHA256_89="ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
278 +
SOURCE_FILENAME_89="getrandom-0.2.17.tar.gz"
279 +
SOURCE_URI_90="https://static.crates.io/crates/getrandom/getrandom-0.3.4.crate"
280 +
CHECKSUM_SHA256_90="899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
281 +
SOURCE_FILENAME_90="getrandom-0.3.4.tar.gz"
282 +
SOURCE_URI_91="https://static.crates.io/crates/getrandom/getrandom-0.4.3.crate"
283 +
CHECKSUM_SHA256_91="300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
284 +
SOURCE_FILENAME_91="getrandom-0.4.3.tar.gz"
285 +
SOURCE_URI_92="https://static.crates.io/crates/globset/globset-0.4.20.crate"
286 +
CHECKSUM_SHA256_92="07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
287 +
SOURCE_FILENAME_92="globset-0.4.20.tar.gz"
288 +
SOURCE_URI_93="https://static.crates.io/crates/h2/h2-0.4.19.crate"
289 +
CHECKSUM_SHA256_93="ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
290 +
SOURCE_FILENAME_93="h2-0.4.19.tar.gz"
291 +
SOURCE_URI_94="https://static.crates.io/crates/hashbrown/hashbrown-0.17.1.crate"
292 +
CHECKSUM_SHA256_94="ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
293 +
SOURCE_FILENAME_94="hashbrown-0.17.1.tar.gz"
294 +
SOURCE_URI_95="https://static.crates.io/crates/heck/heck-0.5.0.crate"
295 +
CHECKSUM_SHA256_95="2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
296 +
SOURCE_FILENAME_95="heck-0.5.0.tar.gz"
297 +
SOURCE_URI_96="https://static.crates.io/crates/hex/hex-0.4.3.crate"
298 +
CHECKSUM_SHA256_96="7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
299 +
SOURCE_FILENAME_96="hex-0.4.3.tar.gz"
300 +
SOURCE_URI_97="https://static.crates.io/crates/http/http-1.5.0.crate"
301 +
CHECKSUM_SHA256_97="918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
302 +
SOURCE_FILENAME_97="http-1.5.0.tar.gz"
303 +
SOURCE_URI_98="https://static.crates.io/crates/http-body/http-body-1.1.0.crate"
304 +
CHECKSUM_SHA256_98="ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
305 +
SOURCE_FILENAME_98="http-body-1.1.0.tar.gz"
306 +
SOURCE_URI_99="https://static.crates.io/crates/http-body-util/http-body-util-0.1.5.crate"
307 +
CHECKSUM_SHA256_99="23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
308 +
SOURCE_FILENAME_99="http-body-util-0.1.5.tar.gz"
309 +
SOURCE_URI_100="https://static.crates.io/crates/httparse/httparse-1.10.1.crate"
310 +
CHECKSUM_SHA256_100="6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
311 +
SOURCE_FILENAME_100="httparse-1.10.1.tar.gz"
312 +
SOURCE_URI_101="https://static.crates.io/crates/httpdate/httpdate-1.0.3.crate"
313 +
CHECKSUM_SHA256_101="df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
314 +
SOURCE_FILENAME_101="httpdate-1.0.3.tar.gz"
315 +
SOURCE_URI_102="https://static.crates.io/crates/humantime/humantime-2.4.0.crate"
316 +
CHECKSUM_SHA256_102="15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
317 +
SOURCE_FILENAME_102="humantime-2.4.0.tar.gz"
318 +
SOURCE_URI_103="https://static.crates.io/crates/hybrid-array/hybrid-array-0.4.15.crate"
319 +
CHECKSUM_SHA256_103="27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
320 +
SOURCE_FILENAME_103="hybrid-array-0.4.15.tar.gz"
321 +
SOURCE_URI_104="https://static.crates.io/crates/hyper/hyper-1.11.1.crate"
322 +
CHECKSUM_SHA256_104="27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
323 +
SOURCE_FILENAME_104="hyper-1.11.1.tar.gz"
324 +
SOURCE_URI_105="https://static.crates.io/crates/hyper-util/hyper-util-0.1.21.crate"
325 +
CHECKSUM_SHA256_105="ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
326 +
SOURCE_FILENAME_105="hyper-util-0.1.21.tar.gz"
327 +
SOURCE_URI_106="https://static.crates.io/crates/iana-time-zone-haiku/iana-time-zone-haiku-0.1.2.crate"
328 +
CHECKSUM_SHA256_106="f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
329 +
SOURCE_FILENAME_106="iana-time-zone-haiku-0.1.2.tar.gz"
330 +
SOURCE_URI_107="https://static.crates.io/crates/icu_collections/icu_collections-2.3.0.crate"
331 +
CHECKSUM_SHA256_107="fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
332 +
SOURCE_FILENAME_107="icu_collections-2.3.0.tar.gz"
333 +
SOURCE_URI_108="https://static.crates.io/crates/icu_locale_core/icu_locale_core-2.3.0.crate"
334 +
CHECKSUM_SHA256_108="d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
335 +
SOURCE_FILENAME_108="icu_locale_core-2.3.0.tar.gz"
336 +
SOURCE_URI_109="https://static.crates.io/crates/icu_normalizer/icu_normalizer-2.3.0.crate"
337 +
CHECKSUM_SHA256_109="12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
338 +
SOURCE_FILENAME_109="icu_normalizer-2.3.0.tar.gz"
339 +
SOURCE_URI_110="https://static.crates.io/crates/icu_normalizer_data/icu_normalizer_data-2.3.0.crate"
340 +
CHECKSUM_SHA256_110="1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
341 +
SOURCE_FILENAME_110="icu_normalizer_data-2.3.0.tar.gz"
342 +
SOURCE_URI_111="https://static.crates.io/crates/icu_properties/icu_properties-2.3.0.crate"
343 +
CHECKSUM_SHA256_111="7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
344 +
SOURCE_FILENAME_111="icu_properties-2.3.0.tar.gz"
345 +
SOURCE_URI_112="https://static.crates.io/crates/icu_properties_data/icu_properties_data-2.3.0.crate"
346 +
CHECKSUM_SHA256_112="e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
347 +
SOURCE_FILENAME_112="icu_properties_data-2.3.0.tar.gz"
348 +
SOURCE_URI_113="https://static.crates.io/crates/icu_provider/icu_provider-2.3.1.crate"
349 +
CHECKSUM_SHA256_113="d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
350 +
SOURCE_FILENAME_113="icu_provider-2.3.1.tar.gz"
351 +
SOURCE_URI_114="https://static.crates.io/crates/idna/idna-1.1.0.crate"
352 +
CHECKSUM_SHA256_114="3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
353 +
SOURCE_FILENAME_114="idna-1.1.0.tar.gz"
354 +
SOURCE_URI_115="https://static.crates.io/crates/idna_adapter/idna_adapter-1.2.2.crate"
355 +
CHECKSUM_SHA256_115="cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
356 +
SOURCE_FILENAME_115="idna_adapter-1.2.2.tar.gz"
357 +
SOURCE_URI_116="https://static.crates.io/crates/ignore/ignore-0.4.33.crate"
358 +
CHECKSUM_SHA256_116="00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f"
359 +
SOURCE_FILENAME_116="ignore-0.4.33.tar.gz"
360 +
SOURCE_URI_117="https://static.crates.io/crates/indexmap/indexmap-2.14.2.crate"
361 +
CHECKSUM_SHA256_117="cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
362 +
SOURCE_FILENAME_117="indexmap-2.14.2.tar.gz"
363 +
SOURCE_URI_118="https://static.crates.io/crates/indicatif/indicatif-0.17.11.crate"
364 +
CHECKSUM_SHA256_118="183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
365 +
SOURCE_FILENAME_118="indicatif-0.17.11.tar.gz"
366 +
SOURCE_URI_119="https://static.crates.io/crates/is_terminal_polyfill/is_terminal_polyfill-1.70.2.crate"
367 +
CHECKSUM_SHA256_119="a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
368 +
SOURCE_FILENAME_119="is_terminal_polyfill-1.70.2.tar.gz"
369 +
SOURCE_URI_120="https://static.crates.io/crates/itertools/itertools-0.15.0.crate"
370 +
CHECKSUM_SHA256_120="8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
371 +
SOURCE_FILENAME_120="itertools-0.15.0.tar.gz"
372 +
SOURCE_URI_121="https://static.crates.io/crates/itoa/itoa-1.0.18.crate"
373 +
CHECKSUM_SHA256_121="8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
374 +
SOURCE_FILENAME_121="itoa-1.0.18.tar.gz"
375 +
SOURCE_URI_122="https://static.crates.io/crates/jobserver/jobserver-0.1.35.crate"
376 +
CHECKSUM_SHA256_122="1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
377 +
SOURCE_FILENAME_122="jobserver-0.1.35.tar.gz"
378 +
SOURCE_URI_123="https://static.crates.io/crates/js-sys/js-sys-0.3.106.crate"
379 +
CHECKSUM_SHA256_123="7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
380 +
SOURCE_FILENAME_123="js-sys-0.3.106.tar.gz"
381 +
SOURCE_URI_124="https://static.crates.io/crates/keccak/keccak-0.2.2.crate"
382 +
CHECKSUM_SHA256_124="d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598"
383 +
SOURCE_FILENAME_124="keccak-0.2.2.tar.gz"
384 +
SOURCE_URI_125="https://static.crates.io/crates/lazy_static/lazy_static-1.5.0.crate"
385 +
CHECKSUM_SHA256_125="bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
386 +
SOURCE_FILENAME_125="lazy_static-1.5.0.tar.gz"
387 +
SOURCE_URI_126="https://static.crates.io/crates/libc/libc-0.2.189.crate"
388 +
CHECKSUM_SHA256_126="3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
389 +
SOURCE_FILENAME_126="libc-0.2.189.tar.gz"
390 +
SOURCE_URI_127="https://static.crates.io/crates/libmimalloc-sys/libmimalloc-sys-0.1.49.crate"
391 +
CHECKSUM_SHA256_127="6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
392 +
SOURCE_FILENAME_127="libmimalloc-sys-0.1.49.tar.gz"
393 +
SOURCE_URI_128="https://static.crates.io/crates/linux-raw-sys/linux-raw-sys-0.12.1.crate"
394 +
CHECKSUM_SHA256_128="32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
395 +
SOURCE_FILENAME_128="linux-raw-sys-0.12.1.tar.gz"
396 +
SOURCE_URI_129="https://static.crates.io/crates/litemap/litemap-0.8.3.crate"
397 +
CHECKSUM_SHA256_129="47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
398 +
SOURCE_FILENAME_129="litemap-0.8.3.tar.gz"
399 +
SOURCE_URI_130="https://static.crates.io/crates/lock_api/lock_api-0.4.14.crate"
400 +
CHECKSUM_SHA256_130="224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
401 +
SOURCE_FILENAME_130="lock_api-0.4.14.tar.gz"
402 +
SOURCE_URI_131="https://static.crates.io/crates/log/log-0.4.34.crate"
403 +
CHECKSUM_SHA256_131="f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
404 +
SOURCE_FILENAME_131="log-0.4.34.tar.gz"
405 +
SOURCE_URI_132="https://static.crates.io/crates/matchit/matchit-0.8.4.crate"
406 +
CHECKSUM_SHA256_132="47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
407 +
SOURCE_FILENAME_132="matchit-0.8.4.tar.gz"
408 +
SOURCE_URI_133="https://static.crates.io/crates/md-5/md-5-0.11.0.crate"
409 +
CHECKSUM_SHA256_133="69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
410 +
SOURCE_FILENAME_133="md-5-0.11.0.tar.gz"
411 +
SOURCE_URI_134="https://static.crates.io/crates/memchr/memchr-2.8.3.crate"
412 +
CHECKSUM_SHA256_134="cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
413 +
SOURCE_FILENAME_134="memchr-2.8.3.tar.gz"
414 +
SOURCE_URI_135="https://static.crates.io/crates/mimalloc/mimalloc-0.1.52.crate"
415 +
CHECKSUM_SHA256_135="2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
416 +
SOURCE_FILENAME_135="mimalloc-0.1.52.tar.gz"
417 +
SOURCE_URI_136="https://static.crates.io/crates/mime/mime-0.3.17.crate"
418 +
CHECKSUM_SHA256_136="6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
419 +
SOURCE_FILENAME_136="mime-0.3.17.tar.gz"
420 +
SOURCE_URI_137="https://static.crates.io/crates/minimal-lexical/minimal-lexical-0.2.1.crate"
421 +
CHECKSUM_SHA256_137="68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
422 +
SOURCE_FILENAME_137="minimal-lexical-0.2.1.tar.gz"
423 +
SOURCE_URI_138="https://static.crates.io/crates/miniz_oxide/miniz_oxide-0.9.1.crate"
424 +
CHECKSUM_SHA256_138="b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
425 +
SOURCE_FILENAME_138="miniz_oxide-0.9.1.tar.gz"
426 +
SOURCE_URI_139="https://static.crates.io/crates/mio/mio-1.2.3.crate"
427 +
CHECKSUM_SHA256_139="4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
428 +
SOURCE_FILENAME_139="mio-1.2.3.tar.gz"
429 +
SOURCE_URI_140="https://static.crates.io/crates/ml-dsa/ml-dsa-0.1.1.crate"
430 +
CHECKSUM_SHA256_140="add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d"
431 +
SOURCE_FILENAME_140="ml-dsa-0.1.1.tar.gz"
432 +
SOURCE_URI_141="https://static.crates.io/crates/module-lattice/module-lattice-0.2.3.crate"
433 +
CHECKSUM_SHA256_141="0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe"
434 +
SOURCE_FILENAME_141="module-lattice-0.2.3.tar.gz"
435 +
SOURCE_URI_142="https://static.crates.io/crates/nom/nom-7.1.3.crate"
436 +
CHECKSUM_SHA256_142="d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
437 +
SOURCE_FILENAME_142="nom-7.1.3.tar.gz"
438 +
SOURCE_URI_143="https://static.crates.io/crates/num-bigint/num-bigint-0.4.8.crate"
439 +
CHECKSUM_SHA256_143="c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
440 +
SOURCE_FILENAME_143="num-bigint-0.4.8.tar.gz"
441 +
SOURCE_URI_144="https://static.crates.io/crates/num-conv/num-conv-0.2.2.crate"
442 +
CHECKSUM_SHA256_144="521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
443 +
SOURCE_FILENAME_144="num-conv-0.2.2.tar.gz"
444 +
SOURCE_URI_145="https://static.crates.io/crates/num-integer/num-integer-0.1.47.crate"
445 +
CHECKSUM_SHA256_145="7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
446 +
SOURCE_FILENAME_145="num-integer-0.1.47.tar.gz"
447 +
SOURCE_URI_146="https://static.crates.io/crates/num-traits/num-traits-0.2.19.crate"
448 +
CHECKSUM_SHA256_146="071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
449 +
SOURCE_FILENAME_146="num-traits-0.2.19.tar.gz"
450 +
SOURCE_URI_147="https://static.crates.io/crates/number_prefix/number_prefix-0.4.0.crate"
451 +
CHECKSUM_SHA256_147="830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
452 +
SOURCE_FILENAME_147="number_prefix-0.4.0.tar.gz"
453 +
SOURCE_URI_148="https://static.crates.io/crates/object_store/object_store-0.14.2.crate"
454 +
CHECKSUM_SHA256_148="f1796bc93603f78c5760a69f2d58badc9618d22adade0a95385bb2adbae4eb94"
455 +
SOURCE_FILENAME_148="object_store-0.14.2.tar.gz"
456 +
SOURCE_URI_149="https://static.crates.io/crates/oid-registry/oid-registry-0.8.1.crate"
457 +
CHECKSUM_SHA256_149="12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
458 +
SOURCE_FILENAME_149="oid-registry-0.8.1.tar.gz"
459 +
SOURCE_URI_150="https://static.crates.io/crates/once_cell/once_cell-1.21.4.crate"
460 +
CHECKSUM_SHA256_150="9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
461 +
SOURCE_FILENAME_150="once_cell-1.21.4.tar.gz"
462 +
SOURCE_URI_151="https://static.crates.io/crates/once_cell_polyfill/once_cell_polyfill-1.70.2.crate"
463 +
CHECKSUM_SHA256_151="384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
464 +
SOURCE_FILENAME_151="once_cell_polyfill-1.70.2.tar.gz"
465 +
SOURCE_URI_152="https://static.crates.io/crates/parking_lot/parking_lot-0.12.5.crate"
466 +
CHECKSUM_SHA256_152="93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
467 +
SOURCE_FILENAME_152="parking_lot-0.12.5.tar.gz"
468 +
SOURCE_URI_153="https://static.crates.io/crates/parking_lot_core/parking_lot_core-0.9.12.crate"
469 +
CHECKSUM_SHA256_153="2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
470 +
SOURCE_FILENAME_153="parking_lot_core-0.9.12.tar.gz"
471 +
SOURCE_URI_154="https://static.crates.io/crates/pem/pem-4.0.0.crate"
472 +
CHECKSUM_SHA256_154="d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
473 +
SOURCE_FILENAME_154="pem-4.0.0.tar.gz"
474 +
SOURCE_URI_155="https://static.crates.io/crates/percent-encoding/percent-encoding-2.3.2.crate"
475 +
CHECKSUM_SHA256_155="9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
476 +
SOURCE_FILENAME_155="percent-encoding-2.3.2.tar.gz"
477 +
SOURCE_URI_156="https://static.crates.io/crates/pin-project-lite/pin-project-lite-0.2.17.crate"
478 +
CHECKSUM_SHA256_156="a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
479 +
SOURCE_FILENAME_156="pin-project-lite-0.2.17.tar.gz"
480 +
SOURCE_URI_157="https://static.crates.io/crates/pkcs8/pkcs8-0.11.0.crate"
481 +
CHECKSUM_SHA256_157="451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
482 +
SOURCE_FILENAME_157="pkcs8-0.11.0.tar.gz"
483 +
SOURCE_URI_158="https://static.crates.io/crates/pkg-config/pkg-config-0.3.34.crate"
484 +
CHECKSUM_SHA256_158="f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
485 +
SOURCE_FILENAME_158="pkg-config-0.3.34.tar.gz"
486 +
SOURCE_URI_159="https://static.crates.io/crates/portable-atomic/portable-atomic-1.15.0.crate"
487 +
CHECKSUM_SHA256_159="05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
488 +
SOURCE_FILENAME_159="portable-atomic-1.15.0.tar.gz"
489 +
SOURCE_URI_160="https://static.crates.io/crates/potential_utf/potential_utf-0.1.6.crate"
490 +
CHECKSUM_SHA256_160="d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
491 +
SOURCE_FILENAME_160="potential_utf-0.1.6.tar.gz"
492 +
SOURCE_URI_161="https://static.crates.io/crates/powerfmt/powerfmt-0.2.0.crate"
493 +
CHECKSUM_SHA256_161="439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
494 +
SOURCE_FILENAME_161="powerfmt-0.2.0.tar.gz"
495 +
SOURCE_URI_162="https://static.crates.io/crates/ppv-lite86/ppv-lite86-0.2.21.crate"
496 +
CHECKSUM_SHA256_162="85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
497 +
SOURCE_FILENAME_162="ppv-lite86-0.2.21.tar.gz"
498 +
SOURCE_URI_163="https://static.crates.io/crates/proc-macro2/proc-macro2-1.0.107.crate"
499 +
CHECKSUM_SHA256_163="985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
500 +
SOURCE_FILENAME_163="proc-macro2-1.0.107.tar.gz"
501 +
SOURCE_URI_164="https://static.crates.io/crates/proptest/proptest-1.11.0.crate"
502 +
CHECKSUM_SHA256_164="4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
503 +
SOURCE_FILENAME_164="proptest-1.11.0.tar.gz"
504 +
SOURCE_URI_165="https://static.crates.io/crates/quick-error/quick-error-1.2.3.crate"
505 +
CHECKSUM_SHA256_165="a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
506 +
SOURCE_FILENAME_165="quick-error-1.2.3.tar.gz"
507 +
SOURCE_URI_166="https://static.crates.io/crates/quick-xml/quick-xml-0.41.0.crate"
508 +
CHECKSUM_SHA256_166="e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
509 +
SOURCE_FILENAME_166="quick-xml-0.41.0.tar.gz"
510 +
SOURCE_URI_167="https://static.crates.io/crates/quote/quote-1.0.47.crate"
511 +
CHECKSUM_SHA256_167="1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
512 +
SOURCE_FILENAME_167="quote-1.0.47.tar.gz"
513 +
SOURCE_URI_168="https://static.crates.io/crates/r-efi/r-efi-5.3.0.crate"
514 +
CHECKSUM_SHA256_168="69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
515 +
SOURCE_FILENAME_168="r-efi-5.3.0.tar.gz"
516 +
SOURCE_URI_169="https://static.crates.io/crates/r-efi/r-efi-6.0.0.crate"
517 +
CHECKSUM_SHA256_169="f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
518 +
SOURCE_FILENAME_169="r-efi-6.0.0.tar.gz"
519 +
SOURCE_URI_170="https://static.crates.io/crates/rand/rand-0.10.3.crate"
520 +
CHECKSUM_SHA256_170="65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
521 +
SOURCE_FILENAME_170="rand-0.10.3.tar.gz"
522 +
SOURCE_URI_171="https://static.crates.io/crates/rand/rand-0.8.8.crate"
523 +
CHECKSUM_SHA256_171="e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
524 +
SOURCE_FILENAME_171="rand-0.8.8.tar.gz"
525 +
SOURCE_URI_172="https://static.crates.io/crates/rand/rand-0.9.5.crate"
526 +
CHECKSUM_SHA256_172="b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
527 +
SOURCE_FILENAME_172="rand-0.9.5.tar.gz"
528 +
SOURCE_URI_173="https://static.crates.io/crates/rand_chacha/rand_chacha-0.3.1.crate"
529 +
CHECKSUM_SHA256_173="e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
530 +
SOURCE_FILENAME_173="rand_chacha-0.3.1.tar.gz"
531 +
SOURCE_URI_174="https://static.crates.io/crates/rand_chacha/rand_chacha-0.9.0.crate"
532 +
CHECKSUM_SHA256_174="d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
533 +
SOURCE_FILENAME_174="rand_chacha-0.9.0.tar.gz"
534 +
SOURCE_URI_175="https://static.crates.io/crates/rand_core/rand_core-0.10.1.crate"
535 +
CHECKSUM_SHA256_175="63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
536 +
SOURCE_FILENAME_175="rand_core-0.10.1.tar.gz"
537 +
SOURCE_URI_176="https://static.crates.io/crates/rand_core/rand_core-0.6.4.crate"
538 +
CHECKSUM_SHA256_176="ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
539 +
SOURCE_FILENAME_176="rand_core-0.6.4.tar.gz"
540 +
SOURCE_URI_177="https://static.crates.io/crates/rand_core/rand_core-0.9.5.crate"
541 +
CHECKSUM_SHA256_177="76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
542 +
SOURCE_FILENAME_177="rand_core-0.9.5.tar.gz"
543 +
SOURCE_URI_178="https://static.crates.io/crates/rand_xorshift/rand_xorshift-0.4.0.crate"
544 +
CHECKSUM_SHA256_178="513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
545 +
SOURCE_FILENAME_178="rand_xorshift-0.4.0.tar.gz"
546 +
SOURCE_URI_179="https://static.crates.io/crates/rayon/rayon-1.12.0.crate"
547 +
CHECKSUM_SHA256_179="fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
548 +
SOURCE_FILENAME_179="rayon-1.12.0.tar.gz"
549 +
SOURCE_URI_180="https://static.crates.io/crates/rayon-core/rayon-core-1.13.0.crate"
550 +
CHECKSUM_SHA256_180="22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
551 +
SOURCE_FILENAME_180="rayon-core-1.13.0.tar.gz"
552 +
SOURCE_URI_181="https://static.crates.io/crates/rcgen/rcgen-0.14.10.crate"
553 +
CHECKSUM_SHA256_181="8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
554 +
SOURCE_FILENAME_181="rcgen-0.14.10.tar.gz"
555 +
SOURCE_URI_182="https://static.crates.io/crates/redox_syscall/redox_syscall-0.5.18.crate"
556 +
CHECKSUM_SHA256_182="ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
557 +
SOURCE_FILENAME_182="redox_syscall-0.5.18.tar.gz"
558 +
SOURCE_URI_183="https://static.crates.io/crates/regex-automata/regex-automata-0.4.18.crate"
559 +
CHECKSUM_SHA256_183="ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
560 +
SOURCE_FILENAME_183="regex-automata-0.4.18.tar.gz"
561 +
SOURCE_URI_184="https://static.crates.io/crates/regex-syntax/regex-syntax-0.8.11.crate"
562 +
CHECKSUM_SHA256_184="d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
563 +
SOURCE_FILENAME_184="regex-syntax-0.8.11.tar.gz"
564 +
SOURCE_URI_185="https://static.crates.io/crates/ring/ring-0.17.14.crate"
565 +
CHECKSUM_SHA256_185="a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
566 +
SOURCE_FILENAME_185="ring-0.17.14.tar.gz"
567 +
SOURCE_URI_186="https://static.crates.io/crates/roff/roff-1.1.1.crate"
568 +
CHECKSUM_SHA256_186="323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
569 +
SOURCE_FILENAME_186="roff-1.1.1.tar.gz"
570 +
SOURCE_URI_187="https://static.crates.io/crates/rustc_version/rustc_version-0.4.1.crate"
571 +
CHECKSUM_SHA256_187="cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
572 +
SOURCE_FILENAME_187="rustc_version-0.4.1.tar.gz"
573 +
SOURCE_URI_188="https://static.crates.io/crates/rusticata-macros/rusticata-macros-4.1.0.crate"
574 +
CHECKSUM_SHA256_188="faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
575 +
SOURCE_FILENAME_188="rusticata-macros-4.1.0.tar.gz"
576 +
SOURCE_URI_189="https://static.crates.io/crates/rustix/rustix-1.1.5.crate"
577 +
CHECKSUM_SHA256_189="891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
578 +
SOURCE_FILENAME_189="rustix-1.1.5.tar.gz"
579 +
SOURCE_URI_190="https://static.crates.io/crates/rustls/rustls-0.23.45.crate"
580 +
CHECKSUM_SHA256_190="0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
581 +
SOURCE_FILENAME_190="rustls-0.23.45.tar.gz"
582 +
SOURCE_URI_191="https://static.crates.io/crates/rustls-pemfile/rustls-pemfile-2.2.0.crate"
583 +
CHECKSUM_SHA256_191="dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
584 +
SOURCE_FILENAME_191="rustls-pemfile-2.2.0.tar.gz"
585 +
SOURCE_URI_192="https://static.crates.io/crates/rustls-pki-types/rustls-pki-types-1.15.1.crate"
586 +
CHECKSUM_SHA256_192="2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
587 +
SOURCE_FILENAME_192="rustls-pki-types-1.15.1.tar.gz"
588 +
SOURCE_URI_193="https://static.crates.io/crates/rustls-webpki/rustls-webpki-0.103.15.crate"
589 +
CHECKSUM_SHA256_193="f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
590 +
SOURCE_FILENAME_193="rustls-webpki-0.103.15.tar.gz"
591 +
SOURCE_URI_194="https://static.crates.io/crates/rustversion/rustversion-1.0.23.crate"
592 +
CHECKSUM_SHA256_194="cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
593 +
SOURCE_FILENAME_194="rustversion-1.0.23.tar.gz"
594 +
SOURCE_URI_195="https://static.crates.io/crates/rusty-fork/rusty-fork-0.3.1.crate"
595 +
CHECKSUM_SHA256_195="cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
596 +
SOURCE_FILENAME_195="rusty-fork-0.3.1.tar.gz"
597 +
SOURCE_URI_196="https://static.crates.io/crates/ryu/ryu-1.0.23.crate"
598 +
CHECKSUM_SHA256_196="9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
599 +
SOURCE_FILENAME_196="ryu-1.0.23.tar.gz"
600 +
SOURCE_URI_197="https://static.crates.io/crates/same-file/same-file-1.0.6.crate"
601 +
CHECKSUM_SHA256_197="93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
602 +
SOURCE_FILENAME_197="same-file-1.0.6.tar.gz"
603 +
SOURCE_URI_198="https://static.crates.io/crates/scopeguard/scopeguard-1.2.0.crate"
604 +
CHECKSUM_SHA256_198="94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
605 +
SOURCE_FILENAME_198="scopeguard-1.2.0.tar.gz"
606 +
SOURCE_URI_199="https://static.crates.io/crates/semver/semver-1.0.28.crate"
607 +
CHECKSUM_SHA256_199="8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
608 +
SOURCE_FILENAME_199="semver-1.0.28.tar.gz"
609 +
SOURCE_URI_200="https://static.crates.io/crates/serde/serde-1.0.229.crate"
610 +
CHECKSUM_SHA256_200="4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
611 +
SOURCE_FILENAME_200="serde-1.0.229.tar.gz"
612 +
SOURCE_URI_201="https://static.crates.io/crates/serde_core/serde_core-1.0.229.crate"
613 +
CHECKSUM_SHA256_201="67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
614 +
SOURCE_FILENAME_201="serde_core-1.0.229.tar.gz"
615 +
SOURCE_URI_202="https://static.crates.io/crates/serde_derive/serde_derive-1.0.229.crate"
616 +
CHECKSUM_SHA256_202="e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
617 +
SOURCE_FILENAME_202="serde_derive-1.0.229.tar.gz"
618 +
SOURCE_URI_203="https://static.crates.io/crates/serde_json/serde_json-1.0.151.crate"
619 +
CHECKSUM_SHA256_203="c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
620 +
SOURCE_FILENAME_203="serde_json-1.0.151.tar.gz"
621 +
SOURCE_URI_204="https://static.crates.io/crates/serde_path_to_error/serde_path_to_error-0.1.20.crate"
622 +
CHECKSUM_SHA256_204="10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
623 +
SOURCE_FILENAME_204="serde_path_to_error-0.1.20.tar.gz"
624 +
SOURCE_URI_205="https://static.crates.io/crates/serde_urlencoded/serde_urlencoded-0.7.1.crate"
625 +
CHECKSUM_SHA256_205="d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
626 +
SOURCE_FILENAME_205="serde_urlencoded-0.7.1.tar.gz"
627 +
SOURCE_URI_206="https://static.crates.io/crates/sha2/sha2-0.11.0.crate"
628 +
CHECKSUM_SHA256_206="446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
629 +
SOURCE_FILENAME_206="sha2-0.11.0.tar.gz"
630 +
SOURCE_URI_207="https://static.crates.io/crates/shake/shake-0.1.0.crate"
631 +
CHECKSUM_SHA256_207="09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a"
632 +
SOURCE_FILENAME_207="shake-0.1.0.tar.gz"
633 +
SOURCE_URI_208="https://static.crates.io/crates/shlex/shlex-2.0.1.crate"
634 +
CHECKSUM_SHA256_208="f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
635 +
SOURCE_FILENAME_208="shlex-2.0.1.tar.gz"
636 +
SOURCE_URI_209="https://static.crates.io/crates/signature/signature-3.0.0.crate"
637 +
CHECKSUM_SHA256_209="28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
638 +
SOURCE_FILENAME_209="signature-3.0.0.tar.gz"
639 +
SOURCE_URI_210="https://static.crates.io/crates/simd-adler32/simd-adler32-0.3.10.crate"
640 +
CHECKSUM_SHA256_210="3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
641 +
SOURCE_FILENAME_210="simd-adler32-0.3.10.tar.gz"
642 +
SOURCE_URI_211="https://static.crates.io/crates/similar/similar-2.7.0.crate"
643 +
CHECKSUM_SHA256_211="bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
644 +
SOURCE_FILENAME_211="similar-2.7.0.tar.gz"
645 +
SOURCE_URI_212="https://static.crates.io/crates/slab/slab-0.4.12.crate"
646 +
CHECKSUM_SHA256_212="0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
647 +
SOURCE_FILENAME_212="slab-0.4.12.tar.gz"
648 +
SOURCE_URI_213="https://static.crates.io/crates/smallvec/smallvec-1.16.2.crate"
649 +
CHECKSUM_SHA256_213="f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
650 +
SOURCE_FILENAME_213="smallvec-1.16.2.tar.gz"
651 +
SOURCE_URI_214="https://static.crates.io/crates/socket2/socket2-0.6.5.crate"
652 +
CHECKSUM_SHA256_214="c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
653 +
SOURCE_FILENAME_214="socket2-0.6.5.tar.gz"
654 +
SOURCE_URI_215="https://static.crates.io/crates/spin/spin-0.10.1.crate"
655 +
CHECKSUM_SHA256_215="023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
656 +
SOURCE_FILENAME_215="spin-0.10.1.tar.gz"
657 +
SOURCE_URI_216="https://static.crates.io/crates/spki/spki-0.8.0.crate"
658 +
CHECKSUM_SHA256_216="1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
659 +
SOURCE_FILENAME_216="spki-0.8.0.tar.gz"
660 +
SOURCE_URI_217="https://static.crates.io/crates/sponge-cursor/sponge-cursor-0.1.0.crate"
661 +
CHECKSUM_SHA256_217="3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
662 +
SOURCE_FILENAME_217="sponge-cursor-0.1.0.tar.gz"
663 +
SOURCE_URI_218="https://static.crates.io/crates/stable_deref_trait/stable_deref_trait-1.2.1.crate"
664 +
CHECKSUM_SHA256_218="6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
665 +
SOURCE_FILENAME_218="stable_deref_trait-1.2.1.tar.gz"
666 +
SOURCE_URI_219="https://static.crates.io/crates/strsim/strsim-0.11.1.crate"
667 +
CHECKSUM_SHA256_219="7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
668 +
SOURCE_FILENAME_219="strsim-0.11.1.tar.gz"
669 +
SOURCE_URI_220="https://static.crates.io/crates/subtle/subtle-2.6.1.crate"
670 +
CHECKSUM_SHA256_220="13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
671 +
SOURCE_FILENAME_220="subtle-2.6.1.tar.gz"
672 +
SOURCE_URI_221="https://static.crates.io/crates/syn/syn-2.0.119.crate"
673 +
CHECKSUM_SHA256_221="872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
674 +
SOURCE_FILENAME_221="syn-2.0.119.tar.gz"
675 +
SOURCE_URI_222="https://static.crates.io/crates/syn/syn-3.0.6.crate"
676 +
CHECKSUM_SHA256_222="8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
677 +
SOURCE_FILENAME_222="syn-3.0.6.tar.gz"
678 +
SOURCE_URI_223="https://static.crates.io/crates/sync_wrapper/sync_wrapper-1.0.2.crate"
679 +
CHECKSUM_SHA256_223="0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
680 +
SOURCE_FILENAME_223="sync_wrapper-1.0.2.tar.gz"
681 +
SOURCE_URI_224="https://static.crates.io/crates/synstructure/synstructure-0.13.2.crate"
682 +
CHECKSUM_SHA256_224="728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
683 +
SOURCE_FILENAME_224="synstructure-0.13.2.tar.gz"
684 +
SOURCE_URI_225="https://static.crates.io/crates/synstructure/synstructure-0.14.0.crate"
685 +
CHECKSUM_SHA256_225="901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
686 +
SOURCE_FILENAME_225="synstructure-0.14.0.tar.gz"
687 +
SOURCE_URI_226="https://static.crates.io/crates/tempfile/tempfile-3.27.0.crate"
688 +
CHECKSUM_SHA256_226="32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
689 +
SOURCE_FILENAME_226="tempfile-3.27.0.tar.gz"
690 +
SOURCE_URI_227="https://static.crates.io/crates/thiserror/thiserror-1.0.69.crate"
691 +
CHECKSUM_SHA256_227="b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
692 +
SOURCE_FILENAME_227="thiserror-1.0.69.tar.gz"
693 +
SOURCE_URI_228="https://static.crates.io/crates/thiserror/thiserror-2.0.21.crate"
694 +
CHECKSUM_SHA256_228="09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
695 +
SOURCE_FILENAME_228="thiserror-2.0.21.tar.gz"
696 +
SOURCE_URI_229="https://static.crates.io/crates/thiserror-impl/thiserror-impl-1.0.69.crate"
697 +
CHECKSUM_SHA256_229="4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
698 +
SOURCE_FILENAME_229="thiserror-impl-1.0.69.tar.gz"
699 +
SOURCE_URI_230="https://static.crates.io/crates/thiserror-impl/thiserror-impl-2.0.21.crate"
700 +
CHECKSUM_SHA256_230="fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
701 +
SOURCE_FILENAME_230="thiserror-impl-2.0.21.tar.gz"
702 +
SOURCE_URI_231="https://static.crates.io/crates/time/time-0.3.55.crate"
703 +
CHECKSUM_SHA256_231="cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
704 +
SOURCE_FILENAME_231="time-0.3.55.tar.gz"
705 +
SOURCE_URI_232="https://static.crates.io/crates/time-core/time-core-0.1.9.crate"
706 +
CHECKSUM_SHA256_232="9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
707 +
SOURCE_FILENAME_232="time-core-0.1.9.tar.gz"
708 +
SOURCE_URI_233="https://static.crates.io/crates/time-macros/time-macros-0.2.32.crate"
709 +
CHECKSUM_SHA256_233="7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
710 +
SOURCE_FILENAME_233="time-macros-0.2.32.tar.gz"
711 +
SOURCE_URI_234="https://static.crates.io/crates/tinystr/tinystr-0.8.4.crate"
712 +
CHECKSUM_SHA256_234="b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
713 +
SOURCE_FILENAME_234="tinystr-0.8.4.tar.gz"
714 +
SOURCE_URI_235="https://static.crates.io/crates/tokio/tokio-1.53.1.crate"
715 +
CHECKSUM_SHA256_235="202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
716 +
SOURCE_FILENAME_235="tokio-1.53.1.tar.gz"
717 +
SOURCE_URI_236="https://static.crates.io/crates/tokio-macros/tokio-macros-2.7.2.crate"
718 +
CHECKSUM_SHA256_236="78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
719 +
SOURCE_FILENAME_236="tokio-macros-2.7.2.tar.gz"
720 +
SOURCE_URI_237="https://static.crates.io/crates/tokio-rustls/tokio-rustls-0.26.5.crate"
721 +
CHECKSUM_SHA256_237="b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
722 +
SOURCE_FILENAME_237="tokio-rustls-0.26.5.tar.gz"
723 +
SOURCE_URI_238="https://static.crates.io/crates/tokio-util/tokio-util-0.7.19.crate"
724 +
CHECKSUM_SHA256_238="494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
725 +
SOURCE_FILENAME_238="tokio-util-0.7.19.tar.gz"
726 +
SOURCE_URI_239="https://static.crates.io/crates/tower/tower-0.5.3.crate"
727 +
CHECKSUM_SHA256_239="ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
728 +
SOURCE_FILENAME_239="tower-0.5.3.tar.gz"
729 +
SOURCE_URI_240="https://static.crates.io/crates/tower-layer/tower-layer-0.3.3.crate"
730 +
CHECKSUM_SHA256_240="121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
731 +
SOURCE_FILENAME_240="tower-layer-0.3.3.tar.gz"
732 +
SOURCE_URI_241="https://static.crates.io/crates/tower-service/tower-service-0.3.3.crate"
733 +
CHECKSUM_SHA256_241="8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
734 +
SOURCE_FILENAME_241="tower-service-0.3.3.tar.gz"
735 +
SOURCE_URI_242="https://static.crates.io/crates/tracing/tracing-0.1.44.crate"
736 +
CHECKSUM_SHA256_242="63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
737 +
SOURCE_FILENAME_242="tracing-0.1.44.tar.gz"
738 +
SOURCE_URI_243="https://static.crates.io/crates/tracing-attributes/tracing-attributes-0.1.31.crate"
739 +
CHECKSUM_SHA256_243="7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
740 +
SOURCE_FILENAME_243="tracing-attributes-0.1.31.tar.gz"
741 +
SOURCE_URI_244="https://static.crates.io/crates/tracing-core/tracing-core-0.1.36.crate"
742 +
CHECKSUM_SHA256_244="db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
743 +
SOURCE_FILENAME_244="tracing-core-0.1.36.tar.gz"
744 +
SOURCE_URI_245="https://static.crates.io/crates/typenum/typenum-1.20.1.crate"
745 +
CHECKSUM_SHA256_245="b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
746 +
SOURCE_FILENAME_245="typenum-1.20.1.tar.gz"
747 +
SOURCE_URI_246="https://static.crates.io/crates/unarray/unarray-0.1.4.crate"
748 +
CHECKSUM_SHA256_246="eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
749 +
SOURCE_FILENAME_246="unarray-0.1.4.tar.gz"
750 +
SOURCE_URI_247="https://static.crates.io/crates/unicode-ident/unicode-ident-1.0.26.crate"
751 +
CHECKSUM_SHA256_247="d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
752 +
SOURCE_FILENAME_247="unicode-ident-1.0.26.tar.gz"
753 +
SOURCE_URI_248="https://static.crates.io/crates/unicode-width/unicode-width-0.2.2.crate"
754 +
CHECKSUM_SHA256_248="b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
755 +
SOURCE_FILENAME_248="unicode-width-0.2.2.tar.gz"
756 +
SOURCE_URI_249="https://static.crates.io/crates/untrusted/untrusted-0.9.0.crate"
757 +
CHECKSUM_SHA256_249="8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
758 +
SOURCE_FILENAME_249="untrusted-0.9.0.tar.gz"
759 +
SOURCE_URI_250="https://static.crates.io/crates/ureq/ureq-2.12.1.crate"
760 +
CHECKSUM_SHA256_250="02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
761 +
SOURCE_FILENAME_250="ureq-2.12.1.tar.gz"
762 +
SOURCE_URI_251="https://static.crates.io/crates/url/url-2.5.8.crate"
763 +
CHECKSUM_SHA256_251="ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
764 +
SOURCE_FILENAME_251="url-2.5.8.tar.gz"
765 +
SOURCE_URI_252="https://static.crates.io/crates/utf8_iter/utf8_iter-1.0.4.crate"
766 +
CHECKSUM_SHA256_252="b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
767 +
SOURCE_FILENAME_252="utf8_iter-1.0.4.tar.gz"
768 +
SOURCE_URI_253="https://static.crates.io/crates/utf8parse/utf8parse-0.2.2.crate"
769 +
CHECKSUM_SHA256_253="06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
770 +
SOURCE_FILENAME_253="utf8parse-0.2.2.tar.gz"
771 +
SOURCE_URI_254="https://static.crates.io/crates/version_check/version_check-0.9.5.crate"
772 +
CHECKSUM_SHA256_254="0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
773 +
SOURCE_FILENAME_254="version_check-0.9.5.tar.gz"
774 +
SOURCE_URI_255="https://static.crates.io/crates/wait-timeout/wait-timeout-0.2.1.crate"
775 +
CHECKSUM_SHA256_255="09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
776 +
SOURCE_FILENAME_255="wait-timeout-0.2.1.tar.gz"
777 +
SOURCE_URI_256="https://static.crates.io/crates/walkdir/walkdir-2.5.0.crate"
778 +
CHECKSUM_SHA256_256="29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
779 +
SOURCE_FILENAME_256="walkdir-2.5.0.tar.gz"
780 +
SOURCE_URI_257="https://static.crates.io/crates/wasi/wasi-0.11.1+wasi-snapshot-preview1.crate"
781 +
CHECKSUM_SHA256_257="ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
782 +
SOURCE_FILENAME_257="wasi-0.11.1+wasi-snapshot-preview1.tar.gz"
783 +
SOURCE_URI_258="https://static.crates.io/crates/wasip2/wasip2-1.0.4+wasi-0.2.12.crate"
784 +
CHECKSUM_SHA256_258="b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
785 +
SOURCE_FILENAME_258="wasip2-1.0.4+wasi-0.2.12.tar.gz"
786 +
SOURCE_URI_259="https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.129.crate"
787 +
CHECKSUM_SHA256_259="9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
788 +
SOURCE_FILENAME_259="wasm-bindgen-0.2.129.tar.gz"
789 +
SOURCE_URI_260="https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.79.crate"
790 +
CHECKSUM_SHA256_260="3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
791 +
SOURCE_FILENAME_260="wasm-bindgen-futures-0.4.79.tar.gz"
792 +
SOURCE_URI_261="https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.129.crate"
793 +
CHECKSUM_SHA256_261="2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
794 +
SOURCE_FILENAME_261="wasm-bindgen-macro-0.2.129.tar.gz"
795 +
SOURCE_URI_262="https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.129.crate"
796 +
CHECKSUM_SHA256_262="6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
797 +
SOURCE_FILENAME_262="wasm-bindgen-macro-support-0.2.129.tar.gz"
798 +
SOURCE_URI_263="https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.129.crate"
799 +
CHECKSUM_SHA256_263="23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
800 +
SOURCE_FILENAME_263="wasm-bindgen-shared-0.2.129.tar.gz"
801 +
SOURCE_URI_264="https://static.crates.io/crates/web-time/web-time-1.1.0.crate"
802 +
CHECKSUM_SHA256_264="5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
803 +
SOURCE_FILENAME_264="web-time-1.1.0.tar.gz"
804 +
SOURCE_URI_265="https://static.crates.io/crates/webpki-roots/webpki-roots-0.26.11.crate"
805 +
CHECKSUM_SHA256_265="521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
806 +
SOURCE_FILENAME_265="webpki-roots-0.26.11.tar.gz"
807 +
SOURCE_URI_266="https://static.crates.io/crates/webpki-roots/webpki-roots-1.0.9.crate"
808 +
CHECKSUM_SHA256_266="7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
809 +
SOURCE_FILENAME_266="webpki-roots-1.0.9.tar.gz"
810 +
SOURCE_URI_267="https://static.crates.io/crates/winapi-util/winapi-util-0.1.11.crate"
811 +
CHECKSUM_SHA256_267="c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
812 +
SOURCE_FILENAME_267="winapi-util-0.1.11.tar.gz"
813 +
SOURCE_URI_268="https://static.crates.io/crates/windows-core/windows-core-0.62.2.crate"
814 +
CHECKSUM_SHA256_268="b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
815 +
SOURCE_FILENAME_268="windows-core-0.62.2.tar.gz"
816 +
SOURCE_URI_269="https://static.crates.io/crates/windows-implement/windows-implement-0.60.2.crate"
817 +
CHECKSUM_SHA256_269="053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
818 +
SOURCE_FILENAME_269="windows-implement-0.60.2.tar.gz"
819 +
SOURCE_URI_270="https://static.crates.io/crates/windows-interface/windows-interface-0.59.3.crate"
820 +
CHECKSUM_SHA256_270="3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
821 +
SOURCE_FILENAME_270="windows-interface-0.59.3.tar.gz"
822 +
SOURCE_URI_271="https://static.crates.io/crates/windows-link/windows-link-0.2.1.crate"
823 +
CHECKSUM_SHA256_271="f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
824 +
SOURCE_FILENAME_271="windows-link-0.2.1.tar.gz"
825 +
SOURCE_URI_272="https://static.crates.io/crates/windows-result/windows-result-0.4.1.crate"
826 +
CHECKSUM_SHA256_272="7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
827 +
SOURCE_FILENAME_272="windows-result-0.4.1.tar.gz"
828 +
SOURCE_URI_273="https://static.crates.io/crates/windows-strings/windows-strings-0.5.1.crate"
829 +
CHECKSUM_SHA256_273="7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
830 +
SOURCE_FILENAME_273="windows-strings-0.5.1.tar.gz"
831 +
SOURCE_URI_274="https://static.crates.io/crates/windows-sys/windows-sys-0.52.0.crate"
832 +
CHECKSUM_SHA256_274="282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
833 +
SOURCE_FILENAME_274="windows-sys-0.52.0.tar.gz"
834 +
SOURCE_URI_275="https://static.crates.io/crates/windows-sys/windows-sys-0.59.0.crate"
835 +
CHECKSUM_SHA256_275="1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
836 +
SOURCE_FILENAME_275="windows-sys-0.59.0.tar.gz"
837 +
SOURCE_URI_276="https://static.crates.io/crates/windows-sys/windows-sys-0.61.2.crate"
838 +
CHECKSUM_SHA256_276="ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
839 +
SOURCE_FILENAME_276="windows-sys-0.61.2.tar.gz"
840 +
SOURCE_URI_277="https://static.crates.io/crates/windows-targets/windows-targets-0.52.6.crate"
841 +
CHECKSUM_SHA256_277="9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
842 +
SOURCE_FILENAME_277="windows-targets-0.52.6.tar.gz"
843 +
SOURCE_URI_278="https://static.crates.io/crates/windows_aarch64_gnullvm/windows_aarch64_gnullvm-0.52.6.crate"
844 +
CHECKSUM_SHA256_278="32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
845 +
SOURCE_FILENAME_278="windows_aarch64_gnullvm-0.52.6.tar.gz"
846 +
SOURCE_URI_279="https://static.crates.io/crates/windows_aarch64_msvc/windows_aarch64_msvc-0.52.6.crate"
847 +
CHECKSUM_SHA256_279="09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
848 +
SOURCE_FILENAME_279="windows_aarch64_msvc-0.52.6.tar.gz"
849 +
SOURCE_URI_280="https://static.crates.io/crates/windows_i686_gnu/windows_i686_gnu-0.52.6.crate"
850 +
CHECKSUM_SHA256_280="8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
851 +
SOURCE_FILENAME_280="windows_i686_gnu-0.52.6.tar.gz"
852 +
SOURCE_URI_281="https://static.crates.io/crates/windows_i686_gnullvm/windows_i686_gnullvm-0.52.6.crate"
853 +
CHECKSUM_SHA256_281="0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
854 +
SOURCE_FILENAME_281="windows_i686_gnullvm-0.52.6.tar.gz"
855 +
SOURCE_URI_282="https://static.crates.io/crates/windows_i686_msvc/windows_i686_msvc-0.52.6.crate"
856 +
CHECKSUM_SHA256_282="240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
857 +
SOURCE_FILENAME_282="windows_i686_msvc-0.52.6.tar.gz"
858 +
SOURCE_URI_283="https://static.crates.io/crates/windows_x86_64_gnu/windows_x86_64_gnu-0.52.6.crate"
859 +
CHECKSUM_SHA256_283="147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
860 +
SOURCE_FILENAME_283="windows_x86_64_gnu-0.52.6.tar.gz"
861 +
SOURCE_URI_284="https://static.crates.io/crates/windows_x86_64_gnullvm/windows_x86_64_gnullvm-0.52.6.crate"
862 +
CHECKSUM_SHA256_284="24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
863 +
SOURCE_FILENAME_284="windows_x86_64_gnullvm-0.52.6.tar.gz"
864 +
SOURCE_URI_285="https://static.crates.io/crates/windows_x86_64_msvc/windows_x86_64_msvc-0.52.6.crate"
865 +
CHECKSUM_SHA256_285="589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
866 +
SOURCE_FILENAME_285="windows_x86_64_msvc-0.52.6.tar.gz"
867 +
SOURCE_URI_286="https://static.crates.io/crates/wit-bindgen/wit-bindgen-0.57.1.crate"
868 +
CHECKSUM_SHA256_286="1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
869 +
SOURCE_FILENAME_286="wit-bindgen-0.57.1.tar.gz"
870 +
SOURCE_URI_287="https://static.crates.io/crates/writeable/writeable-0.6.4.crate"
871 +
CHECKSUM_SHA256_287="3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
872 +
SOURCE_FILENAME_287="writeable-0.6.4.tar.gz"
873 +
SOURCE_URI_288="https://static.crates.io/crates/x509-parser/x509-parser-0.18.1.crate"
874 +
CHECKSUM_SHA256_288="d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
875 +
SOURCE_FILENAME_288="x509-parser-0.18.1.tar.gz"
876 +
SOURCE_URI_289="https://static.crates.io/crates/yasna/yasna-0.6.0.crate"
877 +
CHECKSUM_SHA256_289="b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
878 +
SOURCE_FILENAME_289="yasna-0.6.0.tar.gz"
879 +
SOURCE_URI_290="https://static.crates.io/crates/yoke/yoke-0.8.3.crate"
880 +
CHECKSUM_SHA256_290="709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
881 +
SOURCE_FILENAME_290="yoke-0.8.3.tar.gz"
882 +
SOURCE_URI_291="https://static.crates.io/crates/yoke-derive/yoke-derive-0.8.3.crate"
883 +
CHECKSUM_SHA256_291="33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
884 +
SOURCE_FILENAME_291="yoke-derive-0.8.3.tar.gz"
885 +
SOURCE_URI_292="https://static.crates.io/crates/zerocopy/zerocopy-0.8.59.crate"
886 +
CHECKSUM_SHA256_292="6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
887 +
SOURCE_FILENAME_292="zerocopy-0.8.59.tar.gz"
888 +
SOURCE_URI_293="https://static.crates.io/crates/zerocopy-derive/zerocopy-derive-0.8.59.crate"
889 +
CHECKSUM_SHA256_293="ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
890 +
SOURCE_FILENAME_293="zerocopy-derive-0.8.59.tar.gz"
891 +
SOURCE_URI_294="https://static.crates.io/crates/zerofrom/zerofrom-0.1.8.crate"
892 +
CHECKSUM_SHA256_294="0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
893 +
SOURCE_FILENAME_294="zerofrom-0.1.8.tar.gz"
894 +
SOURCE_URI_295="https://static.crates.io/crates/zerofrom-derive/zerofrom-derive-0.1.8.crate"
895 +
CHECKSUM_SHA256_295="f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
896 +
SOURCE_FILENAME_295="zerofrom-derive-0.1.8.tar.gz"
897 +
SOURCE_URI_296="https://static.crates.io/crates/zeroize/zeroize-1.9.0.crate"
898 +
CHECKSUM_SHA256_296="e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
899 +
SOURCE_FILENAME_296="zeroize-1.9.0.tar.gz"
900 +
SOURCE_URI_297="https://static.crates.io/crates/zerotrie/zerotrie-0.2.5.crate"
901 +
CHECKSUM_SHA256_297="4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
902 +
SOURCE_FILENAME_297="zerotrie-0.2.5.tar.gz"
903 +
SOURCE_URI_298="https://static.crates.io/crates/zerovec/zerovec-0.11.8.crate"
904 +
CHECKSUM_SHA256_298="bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
905 +
SOURCE_FILENAME_298="zerovec-0.11.8.tar.gz"
906 +
SOURCE_URI_299="https://static.crates.io/crates/zerovec-derive/zerovec-derive-0.11.6.crate"
907 +
CHECKSUM_SHA256_299="34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
908 +
SOURCE_FILENAME_299="zerovec-derive-0.11.6.tar.gz"
909 +
SOURCE_URI_300="https://static.crates.io/crates/zlib-rs/zlib-rs-0.6.8.crate"
910 +
CHECKSUM_SHA256_300="b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
911 +
SOURCE_FILENAME_300="zlib-rs-0.6.8.tar.gz"
912 +
SOURCE_URI_301="https://static.crates.io/crates/zmij/zmij-1.0.23.crate"
913 +
CHECKSUM_SHA256_301="29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
914 +
SOURCE_FILENAME_301="zmij-1.0.23.tar.gz"
915 +
SOURCE_URI_302="https://static.crates.io/crates/zstd/zstd-0.13.3.crate"
916 +
CHECKSUM_SHA256_302="e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
917 +
SOURCE_FILENAME_302="zstd-0.13.3.tar.gz"
918 +
SOURCE_URI_303="https://static.crates.io/crates/zstd-safe/zstd-safe-7.3.0.crate"
919 +
CHECKSUM_SHA256_303="64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
920 +
SOURCE_FILENAME_303="zstd-safe-7.3.0.tar.gz"
921 +
SOURCE_URI_304="https://static.crates.io/crates/zstd-sys/zstd-sys-2.1.0+zstd.1.5.7.crate"
922 +
CHECKSUM_SHA256_304="0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
923 +
SOURCE_FILENAME_304="zstd-sys-2.1.0+zstd.1.5.7.tar.gz"
924 +
925 +
ARCHITECTURES="!all x86_64"
926 +
927 +
PROVIDES="
928 +
	oxy$secondaryArchSuffix = $portVersion
929 +
	cmd:oxy
930 +
	"
931 +
REQUIRES="
932 +
	haiku$secondaryArchSuffix
933 +
	lib:libzstd$secondaryArchSuffix
934 +
	"
935 +
936 +
BUILD_REQUIRES="
937 +
	haiku${secondaryArchSuffix}_devel
938 +
	devel:libzstd$secondaryArchSuffix
939 +
	"
940 +
BUILD_PREREQUIRES="
941 +
	cmd:gcc$secondaryArchSuffix
942 +
	cmd:pkg_config$secondaryArchSuffix
943 +
	cmd:rustc$secondaryArchSuffix
944 +
	"
945 +
946 +
BUILD()
947 +
{
948 +
	export CARGO_HOME=$sourceDir/../cargo
949 +
	vendor=$CARGO_HOME/haiku
950 +
	mkdir -p "$vendor"
951 +
	for i in $(seq 2 304); do
952 +
		eval "srcDir=\$sourceDir$i"
953 +
		eval "sha256sum=\$CHECKSUM_SHA256_$i"
954 +
		set -- "$srcDir"/*
955 +
		ln -sf "$1" "$vendor"
956 +
		cat <<-EOF >"$vendor/${1##*/}/.cargo-checksum.json"
957 +
		{
958 +
		  "package": "$sha256sum",
959 +
		  "files": {}
960 +
		}
961 +
		EOF
962 +
	done
963 +
964 +
	cat <<-EOF >"$CARGO_HOME"/config.toml
965 +
	[source.haiku]
966 +
	directory = "$vendor"
967 +
968 +
	[source.crates-io]
969 +
	replace-with = "haiku"
970 +
	EOF
971 +
972 +
	# The bundled zstd's hidden symbols break the link on Haiku: use the system's.
973 +
	export ZSTD_SYS_USE_PKG_CONFIG=1
974 +
	export OXY_BUILD=v$portVersion
975 +
	cargo build --release --frozen -p oxy
976 +
}
977 +
978 +
INSTALL()
979 +
{
980 +
	install -v -m 755 -d "$prefix"/bin "$docDir"
981 +
	install -v -m 755 ./target/release/oxy "$prefix"/bin
982 +
	install -v -m 644 README.md LICENSE* "$docDir"
983 +
}
984 +
985 +
TEST()
986 +
{
987 +
	export CARGO_HOME=$sourceDir/../cargo
988 +
	export ZSTD_SYS_USE_PKG_CONFIG=1
989 +
	cargo test --release --frozen -p oxy-core
990 +
}
added packaging/haikuports/icons/oh-16.png +0 −0

No line changes.

added packaging/haikuports/icons/oh-32.png +0 −0

No line changes.

added packaging/haikuports/icons/oh-64.png +0 −0

No line changes.

added packaging/haikuports/icons/oh.hvif +0 −0

No line changes.

added packaging/haikuports/icons/oh.svg +17 −0
1 +
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2 +
  <defs>
3 +
    <linearGradient id="g" gradientUnits="userSpaceOnUse" x1="-22" y1="-11" x2="22" y2="11">
4 +
      <stop offset="0%" stop-color="#00F0FF"/>
5 +
      <stop offset="100%" stop-color="#0072FF"/>
6 +
    </linearGradient>
7 +
  </defs>
8 +
  <rect width="64" height="64" rx="14" fill="#070c18"/>
9 +
  <g transform="translate(32 32)">
10 +
    <path d="M -11 -6 L 11 -6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
11 +
    <path d="M -11 6 L 11 6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
12 +
    <circle cx="-11" cy="0" r="10" fill="none" stroke="url(#g)" stroke-width="3"/>
13 +
    <circle cx="-11" cy="0" r="3.5" fill="#00F0FF"/>
14 +
    <circle cx="11" cy="0" r="10" fill="none" stroke="url(#g)" stroke-width="3"/>
15 +
    <circle cx="11" cy="0" r="3.5" fill="#FFFFFF"/>
16 +
  </g>
17 +
</svg>
added packaging/haikuports/icons/oxy-16.png +0 −0

No line changes.

added packaging/haikuports/icons/oxy-32.png +0 −0

No line changes.

added packaging/haikuports/icons/oxy-64.png +0 −0

No line changes.

added packaging/haikuports/icons/oxy.hvif +0 −0

No line changes.

added packaging/haikuports/icons/oxy.svg +17 −0
1 +
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2 +
  <defs>
3 +
    <linearGradient id="g" gradientUnits="userSpaceOnUse" x1="-22" y1="-11" x2="22" y2="11">
4 +
      <stop offset="0%" stop-color="#00F0FF"/>
5 +
      <stop offset="100%" stop-color="#0072FF"/>
6 +
    </linearGradient>
7 +
  </defs>
8 +
  <rect width="64" height="64" rx="14" fill="#C9DCF5"/><rect x="1.5" y="1.5" width="61" height="61" rx="12.5" fill="#F2F7FF"/>
9 +
  <g transform="translate(32 32)">
10 +
    <path d="M -11 -6 L 11 -6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
11 +
    <path d="M -11 6 L 11 6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
12 +
    <circle cx="-11" cy="0" r="10" fill="none" stroke="url(#g)" stroke-width="3"/>
13 +
    <circle cx="-11" cy="0" r="3.5" fill="#00F0FF"/>
14 +
    <circle cx="11" cy="0" r="10" fill="none" stroke="url(#g)" stroke-width="3"/>
15 +
    <circle cx="11" cy="0" r="3.5" fill="#0072FF"/>
16 +
  </g>
17 +
</svg>
added packaging/haikuports/make-icons.py +163 −0
1 +
#!/usr/bin/env python3
2 +
"""HaikuDepot icons for oxy and oh (oxygen#157): the Oxygen mark as Haiku's
3 +
vector icon format (HVIF), and as SVG to render the 16, 32 and 64 px PNGs.
4 +
5 +
A family of two (Andy, 2026-10-29): Oxygen (oxy, the Oxygen VCS extension)
6 +
is the mark on a pale rounded tile with a blue right dot; OxygenHub (oh, the
7 +
OxygenHub extension) is the mark on the hub's dark tile with a white one, as
8 +
oxygenhub/assets/icons/oxygenhub.svg. Each keeps its edge on light and dark
9 +
backgrounds alike.
10 +
11 +
    make-icons.py OUTDIR    writes oxy.hvif, oh.hvif, oxy.svg, oh.svg
12 +
13 +
HVIF as Haiku's FlatIconImporter reads it: "ncif", then styles, paths and
14 +
shapes, each a count byte and its items; coordinates in a 64-unit canvas.
15 +
"""
16 +
import math
17 +
import struct
18 +
import sys
19 +
20 +
CYAN, BLUE, WHITE, TILE = (0x00, 0xF0, 0xFF), (0x00, 0x72, 0xFF), (0xFF, 0xFF, 0xFF), (0x07, 0x0C, 0x18)
21 +
LIGHT, EDGE = (0xF2, 0xF7, 0xFF), (0xC9, 0xDC, 0xF5)
22 +
K = 0.5522847498  # a circle's quarter as one cubic Bézier
23 +
24 +
25 +
def coord(v):
26 +
    if v == int(v) and -32 <= v <= 95:
27 +
        return bytes([int(v) + 32])
28 +
    n = int(round((v + 128.0) * 102.0))
29 +
    return bytes([(n >> 8) | 0x80, n & 0xFF])
30 +
31 +
32 +
def float24(v):
33 +
    if v == 0:
34 +
        return b"\0\0\0"
35 +
    bits = struct.unpack(">I", struct.pack(">f", v))[0]
36 +
    sign, exp, man = bits >> 31, ((bits >> 23) & 0xFF) - 127, bits & 0x7FFFFF
37 +
    if not -32 <= exp < 32:
38 +
        raise ValueError(f"{v} does not fit a float24")
39 +
    n = (sign << 23) | ((exp + 32) << 17) | (man >> 6)
40 +
    return n.to_bytes(3, "big")
41 +
42 +
43 +
def solid(rgb):
44 +
    return bytes([3, *rgb])  # STYLE_TYPE_SOLID_COLOR_NO_ALPHA
45 +
46 +
47 +
def linear(p1, p2, stops):
48 +
    """A linear gradient from p1 to p2: gradient space runs x from -64 to 64."""
49 +
    dx, dy = (p2[0] - p1[0]) / 128.0, (p2[1] - p1[1]) / 128.0
50 +
    mx, my = (p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2
51 +
    out = bytes([2, 0, (1 << 1) | (1 << 2), len(stops)])  # gradient, linear, transform | no alpha
52 +
    for v in (dx, dy, -dy, dx, mx, my):
53 +
        out += float24(v)
54 +
    for off, rgb in stops:
55 +
        out += bytes([off, *rgb])
56 +
    return out
57 +
58 +
59 +
def line(a, b):
60 +
    return bytes([1 << 3, 2]) + coord(a[0]) + coord(a[1]) + coord(b[0]) + coord(b[1])  # no curves, open
61 +
62 +
63 +
def curve_path(points, closed=True):
64 +
    """points: (x, y, in_x, in_y, out_x, out_y)."""
65 +
    out = bytes([(1 << 1) if closed else 0, len(points)])
66 +
    for p in points:
67 +
        for v in p:
68 +
            out += coord(v)
69 +
    return out
70 +
71 +
72 +
def circle(cx, cy, r):
73 +
    k = r * K
74 +
    pts = [(cx, cy - r, cx - k, cy - r, cx + k, cy - r), (cx + r, cy, cx + r, cy - k, cx + r, cy + k),
75 +
           (cx, cy + r, cx + k, cy + r, cx - k, cy + r), (cx - r, cy, cx - r, cy + k, cx - r, cy - k)]
76 +
    return curve_path(pts)
77 +
78 +
79 +
def rounded(x, y, w, h, r):
80 +
    k = r * (1 - K)
81 +
    x2, y2 = x + w, y + h
82 +
    pts = [
83 +
        (x + r, y, x + k, y, x + r, y), (x2 - r, y, x2 - r, y, x2 - k, y),
84 +
        (x2, y + r, x2, y + k, x2, y + r), (x2, y2 - r, x2, y2 - r, x2, y2 - k),
85 +
        (x2 - r, y2, x2 - k, y2, x2 - r, y2), (x + r, y2, x + r, y2, x + k, y2),
86 +
        (x, y2 - r, x, y2 - k, x, y2 - r), (x, y + r, x, y + r, x, y + k),
87 +
    ]
88 +
    return curve_path(pts)
89 +
90 +
91 +
def shape(style, paths, stroke=None, cap=0):
92 +
    out = bytes([10, style, len(paths), *paths])
93 +
    if stroke is None:
94 +
        return out + bytes([0])
95 +
    # has transformers: one stroke (width + 128, cap << 4 | round join, miter limit)
96 +
    return out + bytes([1 << 4, 1, 23, int(round(stroke)) + 128, (cap << 4) | 2, 4])
97 +
98 +
99 +
def mark(scale, ox, oy):
100 +
    """The mark's geometry, scaled about its centre and moved to (ox, oy)."""
101 +
    p = lambda x, y: (ox + x * scale, oy + y * scale)
102 +
    return {
103 +
        "top": (p(-11, -6), p(11, -6)), "bottom": (p(-11, 6), p(11, 6)),
104 +
        "left": (p(-11, 0), 10 * scale), "right": (p(11, 0), 10 * scale),
105 +
        "dot": 3.5 * scale, "stroke": 3 * scale, "grad": (p(-22, -11), p(22, 11)),
106 +
    }
107 +
108 +
109 +
def hvif(dark):
110 +
    m = mark(1.0, 32, 32)
111 +
    styles = [linear(*m["grad"], [(0, CYAN), (255, BLUE)]), solid(CYAN), solid(WHITE if dark else BLUE)]
112 +
    paths = [line(*m["top"]), line(*m["bottom"]), circle(*m["left"][0], m["left"][1]),
113 +
             circle(*m["right"][0], m["right"][1]), circle(*m["left"][0], m["dot"]), circle(*m["right"][0], m["dot"])]
114 +
    if dark:
115 +
        styles.append(solid(TILE))
116 +
        paths.append(rounded(0, 0, 64, 64, 14))
117 +
        tile = [shape(3, [6])]
118 +
    else:
119 +
        # A pale tile with a thin edge: the edge's colour, then the tile inset by it.
120 +
        styles += [solid(EDGE), solid(LIGHT)]
121 +
        paths += [rounded(0, 0, 64, 64, 14), rounded(1.5, 1.5, 61, 61, 12.5)]
122 +
        tile = [shape(3, [6]), shape(4, [7])]
123 +
    shapes = tile + [
124 +
        shape(0, [0], m["stroke"], cap=2), shape(0, [1], m["stroke"], cap=2),
125 +
        shape(0, [2], m["stroke"]), shape(0, [3], m["stroke"]),
126 +
        shape(1, [4]), shape(2, [5]),
127 +
    ]
128 +
    out = b"ncif"
129 +
    for section in (styles, paths, shapes):
130 +
        out += bytes([len(section)]) + b"".join(section)
131 +
    return out
132 +
133 +
134 +
def svg(dark):
135 +
    if dark:
136 +
        bg, dot = '<rect width="64" height="64" rx="14" fill="#070c18"/>', "#FFFFFF"
137 +
    else:
138 +
        bg = '<rect width="64" height="64" rx="14" fill="#C9DCF5"/><rect x="1.5" y="1.5" width="61" height="61" rx="12.5" fill="#F2F7FF"/>'
139 +
        dot = "#0072FF"
140 +
    return f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
141 +
  <defs>
142 +
    <linearGradient id="g" gradientUnits="userSpaceOnUse" x1="-22" y1="-11" x2="22" y2="11">
143 +
      <stop offset="0%" stop-color="#00F0FF"/>
144 +
      <stop offset="100%" stop-color="#0072FF"/>
145 +
    </linearGradient>
146 +
  </defs>
147 +
  {bg}
148 +
  <g transform="translate(32 32)">
149 +
    <path d="M -11 -6 L 11 -6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
150 +
    <path d="M -11 6 L 11 6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
151 +
    <circle cx="-11" cy="0" r="10" fill="none" stroke="url(#g)" stroke-width="3"/>
152 +
    <circle cx="-11" cy="0" r="3.5" fill="#00F0FF"/>
153 +
    <circle cx="11" cy="0" r="10" fill="none" stroke="url(#g)" stroke-width="3"/>
154 +
    <circle cx="11" cy="0" r="3.5" fill="{dot}"/>
155 +
  </g>
156 +
</svg>
157 +
'''
158 +
159 +
160 +
out = sys.argv[1]
161 +
for name, dark in (("oxy", False), ("oh", True)):
162 +
    open(f"{out}/{name}.hvif", "wb").write(hvif(dark))
163 +
    open(f"{out}/{name}.svg", "w").write(svg(dark))
added packaging/haikuports/make-recipe.py +140 −0
1 +
#!/usr/bin/env python3
2 +
"""Write a haikuports recipe for oxy or oh (oxygen#156, #157) from a release's
3 +
source tarball, NAME-VERSION-src.tar.gz, as the release job attaches it: the
4 +
tarball itself from the release's stable download address, and every
5 +
crates.io crate in its Cargo.lock as a source of its own with its checksum,
6 +
vendored offline in BUILD() as haikuports' Rust recipes (ripgrep) do.
7 +
8 +
    make-recipe.py oxy|oh SRC_TARBALL VERSION > NAME-VERSION.recipe
9 +
"""
10 +
import hashlib
11 +
import sys
12 +
import tarfile
13 +
import tomllib
14 +
15 +
PORTS = {
16 +
    "oxy": {
17 +
        "repo": "oxygen",
18 +
        "package": "oxy",
19 +
        "summary": "A version control system of signed, commuting changes, with Git interop",
20 +
        "description": [
21 +
            "oxy is the command line of Oxygen, a version control system built on",
22 +
            "patch theory: changes commute, conflicts are recorded rather than blocking,",
23 +
            "every change can be signed, and a trust policy says who may move what. It",
24 +
            "imports and exports Git history, clones from OxygenHub and other Oxygen",
25 +
            "hosts, and keeps repositories on disk or in S3, GCS or Azure buckets.",
26 +
        ],
27 +
        "docs": "README.md LICENSE*",
28 +
        "test": "oxy-core",
29 +
    },
30 +
    "oh": {
31 +
        "repo": "oxygenhub",
32 +
        "package": "oh",
33 +
        "summary": "The command line of OxygenHub, the forge for Oxygen repositories",
34 +
        "description": [
35 +
            "oh talks to an OxygenHub (such as https://oxygenhub.io): issues, proposals",
36 +
            "and their reviews, releases, Flow runs, their logs and reports, and checks,",
37 +
            "from the terminal. It works alongside oxy, the Oxygen version control",
38 +
            "client.",
39 +
        ],
40 +
        "docs": "README.md LICENSE docs/oh.md",
41 +
        "test": "hub-api",
42 +
    },
43 +
}
44 +
45 +
name, tarball, version = sys.argv[1], sys.argv[2], sys.argv[3]
46 +
port = PORTS[name]
47 +
prefix = f"{port['repo']}-{version}"
48 +
with tarfile.open(tarball) as t:
49 +
    lock = tomllib.load(t.extractfile(f"{prefix}/Cargo.lock"))
50 +
crates = sorted(
51 +
    (p["name"], p["version"], p["checksum"])
52 +
    for p in lock["package"]
53 +
    if p.get("source", "").startswith("registry+")
54 +
)
55 +
sha = hashlib.sha256(open(tarball, "rb").read()).hexdigest()
56 +
57 +
out = []
58 +
w = out.append
59 +
w(f'SUMMARY="{port["summary"]}"')
60 +
w('DESCRIPTION="' + " \\\n".join(port["description"]) + '"')
61 +
w(f'HOMEPAGE="https://oxygenhub.io/andymccall/{port["repo"]}"')
62 +
w('COPYRIGHT="2026 Andy McCall"')
63 +
w('LICENSE="MIT\n\tApache v2"')
64 +
w('REVISION="1"')
65 +
w(f'SOURCE_URI="$HOMEPAGE/releases/download/v$portVersion/{name}-$portVersion-src.tar.gz"')
66 +
w(f'CHECKSUM_SHA256="{sha}"')
67 +
w(f'SOURCE_DIR="{port["repo"]}-$portVersion"')
68 +
for i, (crate, ver, ck) in enumerate(crates, start=2):
69 +
    w(f'SOURCE_URI_{i}="https://static.crates.io/crates/{crate}/{crate}-{ver}.crate"')
70 +
    w(f'CHECKSUM_SHA256_{i}="{ck}"')
71 +
    w(f'SOURCE_FILENAME_{i}="{crate}-{ver}.tar.gz"')
72 +
last = len(crates) + 1
73 +
w(f'''
74 +
ARCHITECTURES="!all x86_64"
75 +
76 +
PROVIDES="
77 +
	{name}$secondaryArchSuffix = $portVersion
78 +
	cmd:{name}
79 +
	"
80 +
REQUIRES="
81 +
	haiku$secondaryArchSuffix
82 +
	lib:libzstd$secondaryArchSuffix
83 +
	"
84 +
85 +
BUILD_REQUIRES="
86 +
	haiku${{secondaryArchSuffix}}_devel
87 +
	devel:libzstd$secondaryArchSuffix
88 +
	"
89 +
BUILD_PREREQUIRES="
90 +
	cmd:gcc$secondaryArchSuffix
91 +
	cmd:pkg_config$secondaryArchSuffix
92 +
	cmd:rustc$secondaryArchSuffix
93 +
	"
94 +
95 +
BUILD()
96 +
{{
97 +
	export CARGO_HOME=$sourceDir/../cargo
98 +
	vendor=$CARGO_HOME/haiku
99 +
	mkdir -p "$vendor"
100 +
	for i in $(seq 2 {last}); do
101 +
		eval "srcDir=\\$sourceDir$i"
102 +
		eval "sha256sum=\\$CHECKSUM_SHA256_$i"
103 +
		set -- "$srcDir"/*
104 +
		ln -sf "$1" "$vendor"
105 +
		cat <<-EOF >"$vendor/${{1##*/}}/.cargo-checksum.json"
106 +
		{{
107 +
		  "package": "$sha256sum",
108 +
		  "files": {{}}
109 +
		}}
110 +
		EOF
111 +
	done
112 +
113 +
	cat <<-EOF >"$CARGO_HOME"/config.toml
114 +
	[source.haiku]
115 +
	directory = "$vendor"
116 +
117 +
	[source.crates-io]
118 +
	replace-with = "haiku"
119 +
	EOF
120 +
121 +
	# The bundled zstd's hidden symbols break the link on Haiku: use the system's.
122 +
	export ZSTD_SYS_USE_PKG_CONFIG=1
123 +
	export OXY_BUILD=v$portVersion
124 +
	cargo build --release --frozen -p {port["package"]}
125 +
}}
126 +
127 +
INSTALL()
128 +
{{
129 +
	install -v -m 755 -d "$prefix"/bin "$docDir"
130 +
	install -v -m 755 ./target/release/{name} "$prefix"/bin
131 +
	install -v -m 644 {port["docs"]} "$docDir"
132 +
}}
133 +
134 +
TEST()
135 +
{{
136 +
	export CARGO_HOME=$sourceDir/../cargo
137 +
	export ZSTD_SYS_USE_PKG_CONFIG=1
138 +
	cargo test --release --frozen -p {port["test"]}
139 +
}}''')
140 +
print("\n".join(out))
modified scripts/dist.sh +11 −0
…
136 unchanged lines
137 137
  fi
138 138
done
139 139
140 +
# The source as released (oxygen#157): the oxygen tree,
141 +
# under oxygen-VERSION/, for packagers (haikuports) to fetch from the
142 +
# release at a fixed checksum. Reproducible: sorted, fixed times and
143 +
# owners, gzip without a timestamp.
144 +
if [ -z "${ONLY:-}" ] || [[ src == $ONLY ]]; then
145 +
  echo "== src"
146 +
  tar --sort=name --mtime='1970-01-01 00:00:00Z' --owner=0 --group=0 --numeric-owner \
147 +
    --mode='u+rwX,go+rX,go-w' --exclude=./target --exclude=./lost+found --exclude=.oxy \
148 +
    --transform "s|^\.|oxygen-$VERSION|" -cf - . | gzip -9n >"$OUT/$BIN-$VERSION-src.tar.gz"
149 +
fi
150 +
140 151
rm -rf "$OUT/stage"
141 152
(cd "$OUT" && shopt -s nullglob && sha256sum -- *.tar.gz *.zip *.deb >SHA256SUMS)
142 153
# DIST_FLAT=DIR: the same files again under names without the version, for
…
12 unchanged lines
added src/cli/artifact.rs +180 −0
1 +
//! `oxy artifact`: Cylinder's artifacts (docs/cylinder.md §4.1, step C1):
2 +
//! a directory packed into a tree of the repository's own chunks, named by
3 +
//! the BLAKE3 of its canonical encoding.
4 +
5 +
use super::json;
6 +
use super::open;
7 +
use super::ui;
8 +
use anyhow::Result;
9 +
use cylinder::{Kind, Spec};
10 +
use std::path::Path;
11 +
12 +
/// `KEY=VALUE` pairs, refused when one has no `=`.
13 +
fn pairs(list: &[String], what: &str) -> Result<std::collections::BTreeMap<String, String>> {
14 +
    list.iter()
15 +
        .map(|p| {
16 +
            p.split_once('=')
17 +
                .map(|(k, v)| (k.to_string(), v.to_string()))
18 +
                .ok_or_else(|| anyhow::anyhow!("{what} {p:?}: write it KEY=VALUE"))
19 +
        })
20 +
        .collect()
21 +
}
22 +
23 +
fn summary(k: &cylinder::Kept) -> String {
24 +
    let a = &k.artifact;
25 +
    let mut parts = vec![a.kind.as_str().to_string()];
26 +
    parts.extend(a.os.clone());
27 +
    parts.extend(a.arch.clone());
28 +
    format!(
29 +
        "{} {} {}",
30 +
        ui::hash(&k.name[..12]),
31 +
        parts.join(" "),
32 +
        ui::dim(format!("{} bytes", a.total_size))
33 +
    )
34 +
}
35 +
36 +
fn as_json(k: &cylinder::Kept, files: usize) -> String {
37 +
    let a = &k.artifact;
38 +
    let text = |v: &Option<String>| v.as_deref().map(json::s).unwrap_or_else(|| "null".into());
39 +
    let map = |m: &std::collections::BTreeMap<String, String>| {
40 +
        let inner: Vec<String> = m
41 +
            .iter()
42 +
            .map(|(k, v)| format!("{}:{}", json::s(k), json::s(v)))
43 +
            .collect();
44 +
        format!("{{{}}}", inner.join(","))
45 +
    };
46 +
    json::obj([
47 +
        ("name", json::s(&k.name)),
48 +
        ("kind", json::s(a.kind.as_str())),
49 +
        ("arch", text(&a.arch)),
50 +
        ("os", text(&a.os)),
51 +
        ("tree", json::s(&a.tree.to_hex())),
52 +
        ("files", json::n(files as u64)),
53 +
        ("total_size", json::n(a.total_size)),
54 +
        ("env", map(&a.env)),
55 +
        ("labels", map(&a.labels)),
56 +
    ])
57 +
}
58 +
59 +
#[allow(clippy::too_many_arguments)]
60 +
pub fn pack(
61 +
    dir: &Path,
62 +
    kind: &str,
63 +
    arch: Option<String>,
64 +
    os: Option<String>,
65 +
    entrypoint: Vec<String>,
66 +
    env: &[String],
67 +
    labels: &[String],
68 +
    as_json_out: bool,
69 +
) -> Result<i32> {
70 +
    let repo = open()?;
71 +
    let mut spec = Spec::new(Kind::parse(kind)?);
72 +
    spec.arch = arch;
73 +
    spec.os = os;
74 +
    spec.entrypoint = (!entrypoint.is_empty()).then_some(entrypoint);
75 +
    spec.env = pairs(env, "env")?;
76 +
    spec.labels = pairs(labels, "label")?;
77 +
    let sp = ui::spinner("Packing…");
78 +
    let k = cylinder::pack(&repo, dir, &spec)?;
79 +
    sp.clear();
80 +
    let files = cylinder::tree(&repo, &k.artifact)?.entries.len();
81 +
    if as_json_out {
82 +
        println!("{}", as_json(&k, files));
83 +
    } else {
84 +
        println!(
85 +
            "{} {} as {}",
86 +
            ui::done("Packed"),
87 +
            ui::path(dir.display()),
88 +
            summary(&k)
89 +
        );
90 +
        println!("{}", ui::kv("name", &k.name));
91 +
        println!("{}", ui::kv("files", files.to_string()));
92 +
    }
93 +
    Ok(0)
94 +
}
95 +
96 +
pub fn list(as_json_out: bool) -> Result<i32> {
97 +
    let repo = open()?;
98 +
    let names = cylinder::list(&repo)?;
99 +
    let kept: Vec<cylinder::Kept> = names
100 +
        .iter()
101 +
        .map(|n| cylinder::open(&repo, n))
102 +
        .collect::<std::result::Result<_, _>>()?;
103 +
    if as_json_out {
104 +
        let items: Vec<String> = kept
105 +
            .iter()
106 +
            .map(|k| {
107 +
                let files = cylinder::tree(&repo, &k.artifact)
108 +
                    .map(|t| t.entries.len())
109 +
                    .unwrap_or(0);
110 +
                as_json(k, files)
111 +
            })
112 +
            .collect();
113 +
        println!("[{}]", items.join(","));
114 +
        return Ok(0);
115 +
    }
116 +
    if kept.is_empty() {
117 +
        println!("No artifacts yet: oxy artifact pack DIR --kind archive");
118 +
    }
119 +
    for k in &kept {
120 +
        println!("{}", summary(k));
121 +
    }
122 +
    Ok(0)
123 +
}
124 +
125 +
pub fn show(name: &str, as_json_out: bool) -> Result<i32> {
126 +
    let repo = open()?;
127 +
    let k = cylinder::open(&repo, name)?;
128 +
    let t = cylinder::tree(&repo, &k.artifact)?;
129 +
    if as_json_out {
130 +
        println!("{}", as_json(&k, t.entries.len()));
131 +
        return Ok(0);
132 +
    }
133 +
    println!("{}", summary(&k));
134 +
    println!("{}", ui::kv("name", &k.name));
135 +
    if let Some(e) = &k.artifact.entrypoint {
136 +
        println!("{}", ui::kv("entrypoint", e.join(" ")));
137 +
    }
138 +
    for (key, v) in &k.artifact.labels {
139 +
        println!("{}", ui::kv("label", format!("{key}={v}")));
140 +
    }
141 +
    for (key, v) in &k.artifact.env {
142 +
        println!("{}", ui::kv("env", format!("{key}={v}")));
143 +
    }
144 +
    for e in &t.entries {
145 +
        let mode = match e.mode {
146 +
            cylinder::MODE_EXEC => "x",
147 +
            cylinder::MODE_LINK => "l",
148 +
            _ => " ",
149 +
        };
150 +
        println!("  {mode} {:>10}  {}", e.size, e.path);
151 +
    }
152 +
    Ok(0)
153 +
}
154 +
155 +
pub fn unpack(name: &str, dest: &Path) -> Result<i32> {
156 +
    let repo = open()?;
157 +
    let k = cylinder::open(&repo, name)?;
158 +
    let n = cylinder::unpack(&repo, &k, dest)?;
159 +
    println!(
160 +
        "{} {} files of {} into {}",
161 +
        ui::done("Unpacked"),
162 +
        n,
163 +
        ui::hash(&k.name[..12]),
164 +
        ui::path(dest.display())
165 +
    );
166 +
    Ok(0)
167 +
}
168 +
169 +
pub fn verify(name: &str) -> Result<i32> {
170 +
    let repo = open()?;
171 +
    let k = cylinder::open(&repo, name)?;
172 +
    let n = cylinder::verify(&repo, &k)?;
173 +
    println!(
174 +
        "{} {}: its name, tree and {} files check out",
175 +
        ui::done("Verified"),
176 +
        ui::hash(&k.name[..12]),
177 +
        n
178 +
    );
179 +
    Ok(0)
180 +
}
modified src/cli/format.rs +25 −3
…
41 unchanged lines
42 42
43 43
/// One line describing how a change is authenticated.
44 44
pub fn signature_line(repo: &Repo, h: &ChangeHash) -> Result<String> {
45 -
    Ok(match repo.authenticate_change(h)? {
45 +
    let status = repo.authenticate_change(h)?;
46 +
    let awaiting = repo
47 +
        .store()
48 +
        .get_change(h)
49 +
        .is_ok_and(|c| awaits_seal(repo, &status, &c.author));
50 +
    Ok(match status {
51 +
        SignatureStatus::Unsigned if awaiting => {
52 +
            "awaiting seal (the next push seals it under the agent's grant)".to_string()
53 +
        }
46 54
        SignatureStatus::Unsigned => "unsigned".to_string(),
47 55
        SignatureStatus::Valid {
48 56
            key,
…
81 unchanged lines
130 138
    }
131 139
}
132 140
141 +
/// Whether an unsigned change is one the next push will seal: this
142 +
/// checkout signs keyless (an agent under a grant) and the change is its
143 +
/// configured author's (oxygen#165). Read cold, "unsigned" looks like an
144 +
/// error; it is not one until the push.
145 +
pub fn awaits_seal(repo: &Repo, status: &SignatureStatus, author: &oxy_core::hash::KeyId) -> bool {
146 +
    matches!(status, SignatureStatus::Unsigned)
147 +
        && repo.config.signer_keyless
148 +
        && repo.config.author_key == Some(*author)
149 +
}
150 +
133 151
/// A compact, styled authentication marker for log lines, from a status
134 -
/// already in hand (the caller also reads who signed, see [`via`]).
135 -
pub fn signature_mark_of(status: &SignatureStatus) -> String {
152 +
/// already in hand (the caller also reads who signed, see [`via`]);
153 +
/// `awaiting` from [`awaits_seal`].
154 +
pub fn signature_mark_of(status: &SignatureStatus, awaiting: bool) -> String {
136 155
    match status {
156 +
        SignatureStatus::Unsigned if awaiting => {
157 +
            ui::dim(format!("{} awaiting seal", ui::glyph("◌", "o")))
158 +
        }
137 159
        SignatureStatus::Unsigned => ui::dim(format!("{} unsigned", ui::glyph("·", "-"))),
138 160
        SignatureStatus::Valid {
139 161
            sealed,
…
64 unchanged lines
modified src/cli/identity.rs +85 −2
…
131 unchanged lines
132 132
        ui::author(&r.identity.name),
133 133
        r.identity.email
134 134
    );
135 -
    println!("{}", ui::kv("old key", ui::dim(r.old.to_hex())));
135 +
    // Which key it started from, and where that came from (oxygen#163).
136 +
    let from = if repo.config.is_repo_set("author.key") {
137 +
        "this repository's author.key"
138 +
    } else {
139 +
        "the user config's author.key"
140 +
    };
141 +
    println!(
142 +
        "{}",
143 +
        ui::kv(
144 +
            "old key",
145 +
            format!(
146 +
                "{} {}",
147 +
                ui::dim(r.old.to_hex()),
148 +
                ui::dim(format!("(from {from})"))
149 +
            )
150 +
        )
151 +
    );
136 152
    println!("{}", ui::kv("new key", r.new.to_hex()));
137 153
    print_delegates(repo, &r.identity.delegates);
138 154
    if let Some(e) = r.identity.expires {
…
16 unchanged lines
155 171
    local: bool,
156 172
    expires: Option<u64>,
157 173
) -> Result<i32> {
158 -
    let mut repo = open()?;
174 +
    // A first identity comes before any repository (oxygen#144): only
175 +
    // --local needs one; inside one, it is kept there too.
176 +
    let mut repo = match open() {
177 +
        Ok(r) => r,
178 +
        Err(e) if local => return Err(e),
179 +
        Err(_) => {
180 +
            let (key, id) = repo::create_user_identity(
181 +
                name.as_deref(),
182 +
                email.as_deref(),
183 +
                expires.and_then(expiry_from_days),
184 +
            )?;
185 +
            println!(
186 +
                "{} identity {} <{}>",
187 +
                ui::done("Created"),
188 +
                ui::author(&id.name),
189 +
                id.email
190 +
            );
191 +
            println!("{}", ui::kv("key", key));
192 +
            println!(
193 +
                "{}",
194 +
                ui::kv("secret", ui::path(repo::default_keys_dir().display()))
195 +
            );
196 +
            println!(
197 +
                "{}",
198 +
                ui::kv(
199 +
                    "scope",
200 +
                    format!(
201 +
                        "default author in {}",
202 +
                        ui::path(repo::default_user_config().display())
203 +
                    )
204 +
                )
205 +
            );
206 +
            println!("  {} {}", ui::note(), ui::dim("changes in every repository carry this author; run oxy config --user core.sign change to sign each commit"));
207 +
            return Ok(0);
208 +
        }
209 +
    };
159 210
    let name = name.unwrap_or_else(|| repo.config.author_name.clone());
160 211
    let email = email.unwrap_or_else(|| repo.config.author_email.clone());
161 212
    let scope = if local {
…
121 unchanged lines
283 334
            );
284 335
        }
285 336
    }
337 +
    // A key with a successor is stale, and a key with two is a fork: both
338 +
    // are how an identity splits without anyone noticing (oxygen#163).
339 +
    let next = repo.successors(&key)?;
340 +
    if let Some(n) = next.first() {
341 +
        println!(
342 +
            "  {} {}",
343 +
            ui::caution(),
344 +
            ui::warn(format!(
345 +
                "this key is not your identity's newest: {} succeeds it, {} is the newest. Rotating or delegating from it would fork the identity; point author.key at the newest",
346 +
                n.short(12),
347 +
                repo.newest(n)?.short(12)
348 +
            ))
349 +
        );
350 +
    }
351 +
    let mut cur = Some(key);
352 +
    for _ in 0..256 {
353 +
        let Some(k) = cur else { break };
354 +
        let after = repo.successors(&k)?;
355 +
        if after.len() > 1 {
356 +
            println!(
357 +
                "  {} {}",
358 +
                ui::caution(),
359 +
                ui::err(format!(
360 +
                    "the identity forks at {}: {} each succeed it. Keep one branch: revoke the others' keys (oxy identity revoke KEY) and point every home's author.key at the one you keep",
361 +
                    k.short(12),
362 +
                    after.iter().map(|a| a.short(12)).collect::<Vec<_>>().join(" and ")
363 +
                ))
364 +
            );
365 +
            break;
366 +
        }
367 +
        cur = repo.identity(&k).ok().and_then(|i| i.previous);
368 +
    }
286 369
    print_delegates(&repo, &id.delegates);
287 370
    if repo.is_revoked(&key)? {
288 371
        println!(
…
714 unchanged lines
added src/cli/mcp.rs +543 −0
1 +
//! `oxy mcp` (oxygen#159): a Model Context Protocol server over stdio for
2 +
//! the repository it starts in, so an agent works through typed tools
3 +
//! instead of parsing a shell's output.
4 +
//!
5 +
//! Each tool runs this same oxy binary with the command's `--json`, in the
6 +
//! repository, with the server's environment: so a tool returns exactly
7 +
//! what the command does, and signs as whatever `OXY_HOME` configures. In
8 +
//! an agent home every change is sealed via the agent, with no environment
9 +
//! variable to forget per call. Tools that can lose work (undo, a forced
10 +
//! push) are offered only with `--allow-destructive`.
11 +
12 +
use anyhow::{bail, Result};
13 +
use oxy_core::mcp::{self, arg_str, arg_strings, ServerInfo, Tool, ToolOutput, Tools};
14 +
use serde_json::{json, Value};
15 +
use std::path::{Path, PathBuf};
16 +
use std::process::{Command, Stdio};
17 +
18 +
/// A tool's text is cut here; its structured result is then left out.
19 +
const MAX_TEXT: usize = 256 * 1024;
20 +
21 +
type Build = fn(&Value, bool) -> Result<Vec<String>, String>;
22 +
23 +
struct Spec {
24 +
    name: &'static str,
25 +
    description: &'static str,
26 +
    schema: fn() -> Value,
27 +
    build: Build,
28 +
    json: bool,
29 +
    read_only: bool,
30 +
    destructive: bool,
31 +
}
32 +
33 +
fn obj(props: Value, required: &[&str]) -> Value {
34 +
    json!({ "type": "object", "properties": props, "required": required, "additionalProperties": false })
35 +
}
36 +
37 +
fn none() -> Value {
38 +
    obj(json!({}), &[])
39 +
}
40 +
41 +
/// An option's value as `--flag=value`, so a value starting with `-` is
42 +
/// never read as another flag.
43 +
fn opt(flag: &str, value: &str) -> String {
44 +
    format!("{flag}={value}")
45 +
}
46 +
47 +
/// Positional values after `--`, for the same reason.
48 +
fn positional(out: &mut Vec<String>, values: Vec<String>) {
49 +
    if !values.is_empty() {
50 +
        out.push("--".into());
51 +
        out.extend(values);
52 +
    }
53 +
}
54 +
55 +
fn required<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
56 +
    arg_str(args, key)
57 +
        .filter(|s| !s.trim().is_empty())
58 +
        .ok_or_else(|| format!("{key} is required"))
59 +
}
60 +
61 +
/// Paths to act on, or every path when `all` is true; never neither, so a
62 +
/// commit or amend is always a deliberate choice of what goes in.
63 +
fn paths_or_all(args: &Value) -> Result<Vec<String>, String> {
64 +
    let paths = arg_strings(args, "paths")?;
65 +
    let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);
66 +
    match (paths.is_empty(), all) {
67 +
        (false, false) => Ok(paths),
68 +
        (true, true) => Ok(vec![]),
69 +
        (false, true) => Err("give paths or all: true, not both".into()),
70 +
        (true, false) => {
71 +
            Err("give the paths to include, or all: true for every changed path".into())
72 +
        }
73 +
    }
74 +
}
75 +
76 +
fn limit(args: &Value, default: u64) -> String {
77 +
    args.get("limit")
78 +
        .and_then(Value::as_u64)
79 +
        .unwrap_or(default)
80 +
        .clamp(1, 1000)
81 +
        .to_string()
82 +
}
83 +
84 +
fn specs() -> Vec<Spec> {
85 +
    vec![
86 +
        Spec {
87 +
            name: "status",
88 +
            description: "The working copy: the bookmark, changed paths, and every conflict with its kind and the changes behind it.",
89 +
            schema: none,
90 +
            build: |_, _| Ok(vec!["status".into(), "--json".into()]),
91 +
            json: true,
92 +
            read_only: true,
93 +
            destructive: false,
94 +
        },
95 +
        Spec {
96 +
            name: "diff",
97 +
            description: "Uncommitted edits in the working copy, or one change's edits when `change` is given (a ChangeId or #hash prefix).",
98 +
            schema: || obj(json!({ "change": { "type": "string" } }), &[]),
99 +
            build: |a, _| {
100 +
                let mut v = vec!["diff".into(), "--json".into()];
101 +
                positional(&mut v, arg_str(a, "change").map(|c| vec![c.to_string()]).unwrap_or_default());
102 +
                Ok(v)
103 +
            },
104 +
            json: true,
105 +
            read_only: true,
106 +
            destructive: false,
107 +
        },
108 +
        Spec {
109 +
            name: "log",
110 +
            description: "Changes, newest first: a revset (`main`, `@`, …) or the current bookmark, optionally only those touching `path`; `limit` defaults to 20.",
111 +
            schema: || obj(json!({ "revset": { "type": "string" }, "path": { "type": "string" }, "limit": { "type": "integer", "minimum": 1 } }), &[]),
112 +
            build: |a, _| {
113 +
                let mut v = vec!["log".into(), "--json".into(), opt("--limit", &limit(a, 20))];
114 +
                if let Some(r) = arg_str(a, "revset") {
115 +
                    v.push(opt("--revset", r));
116 +
                }
117 +
                if let Some(p) = arg_str(a, "path") {
118 +
                    v.push(opt("--path", p));
119 +
                }
120 +
                Ok(v)
121 +
            },
122 +
            json: true,
123 +
            read_only: true,
124 +
            destructive: false,
125 +
        },
126 +
        Spec {
127 +
            name: "show",
128 +
            description: "One change: its message, author, signatures and edits (`stat: true` for files and line counts only). `change` defaults to @, the working copy.",
129 +
            schema: || obj(json!({ "change": { "type": "string" }, "stat": { "type": "boolean" } }), &[]),
130 +
            build: |a, _| {
131 +
                let mut v = vec!["show".into(), "--json".into()];
132 +
                if a.get("stat").and_then(Value::as_bool).unwrap_or(false) {
133 +
                    v.push("--stat".into());
134 +
                }
135 +
                positional(&mut v, vec![arg_str(a, "change").unwrap_or("@").to_string()]);
136 +
                Ok(v)
137 +
            },
138 +
            json: true,
139 +
            read_only: true,
140 +
            destructive: false,
141 +
        },
142 +
        Spec {
143 +
            name: "cat",
144 +
            description: "A file's content as a bookmark, tag or change has it (`change`), or as committed on the working copy's base.",
145 +
            schema: || obj(json!({ "path": { "type": "string" }, "change": { "type": "string" } }), &["path"]),
146 +
            build: |a, _| {
147 +
                let mut v = vec!["cat".into()];
148 +
                if let Some(c) = arg_str(a, "change") {
149 +
                    v.push(opt("--change", c));
150 +
                }
151 +
                positional(&mut v, vec![required(a, "path")?.to_string()]);
152 +
                Ok(v)
153 +
            },
154 +
            json: false,
155 +
            read_only: true,
156 +
            destructive: false,
157 +
        },
158 +
        Spec {
159 +
            name: "blame",
160 +
            description: "Which change wrote each line of a file, with its line atoms.",
161 +
            schema: || obj(json!({ "path": { "type": "string" } }), &["path"]),
162 +
            build: |a, _| {
163 +
                let mut v = vec!["blame".into(), "--json".into()];
164 +
                positional(&mut v, vec![required(a, "path")?.to_string()]);
165 +
                Ok(v)
166 +
            },
167 +
            json: true,
168 +
            read_only: true,
169 +
            destructive: false,
170 +
        },
171 +
        Spec {
172 +
            name: "bookmarks",
173 +
            description: "Bookmarks with their heads and how far each is ahead of or behind its remote; `all: true` adds remotes' bookmarks.",
174 +
            schema: || obj(json!({ "all": { "type": "boolean" } }), &[]),
175 +
            build: |a, _| {
176 +
                let mut v = vec!["bookmark".into(), "list".into(), "--json".into()];
177 +
                if a.get("all").and_then(Value::as_bool).unwrap_or(false) {
178 +
                    v.push("--all".into());
179 +
                }
180 +
                Ok(v)
181 +
            },
182 +
            json: true,
183 +
            read_only: true,
184 +
            destructive: false,
185 +
        },
186 +
        Spec {
187 +
            name: "tags",
188 +
            description: "Tags with their states, messages and signers.",
189 +
            schema: none,
190 +
            build: |_, _| Ok(vec!["tag".into(), "list".into(), "--json".into()]),
191 +
            json: true,
192 +
            read_only: true,
193 +
            destructive: false,
194 +
        },
195 +
        Spec {
196 +
            name: "conflicts",
197 +
            description: "Which conflicts of the working copy's base the current files resolve, and which remain.",
198 +
            schema: none,
199 +
            build: |_, _| Ok(vec!["resolve".into(), "--json".into()]),
200 +
            json: true,
201 +
            read_only: true,
202 +
            destructive: false,
203 +
        },
204 +
        Spec {
205 +
            name: "op_log",
206 +
            description: "The operation log, newest first (what undo would take back); `limit` defaults to 20.",
207 +
            schema: || obj(json!({ "limit": { "type": "integer", "minimum": 1 } }), &[]),
208 +
            build: |a, _| Ok(vec!["op".into(), "log".into(), "--json".into(), opt("--limit", &limit(a, 20))]),
209 +
            json: true,
210 +
            read_only: true,
211 +
            destructive: false,
212 +
        },
213 +
        Spec {
214 +
            name: "identity",
215 +
            description: "Who changes are signed as here: the author, the key, delegates and the signer (an agent home signs keyless, via the agent).",
216 +
            schema: none,
217 +
            build: |_, _| Ok(vec!["identity".into(), "show".into()]),
218 +
            json: false,
219 +
            read_only: true,
220 +
            destructive: false,
221 +
        },
222 +
        Spec {
223 +
            name: "commit",
224 +
            description: "Record a change with a message: only `paths`, or every changed path with `all: true`. Signed as this server's identity.",
225 +
            schema: || obj(json!({ "message": { "type": "string" }, "paths": { "type": "array", "items": { "type": "string" } }, "all": { "type": "boolean" } }), &["message"]),
226 +
            build: |a, _| {
227 +
                let mut v = vec!["commit".into(), opt("--message", required(a, "message")?)];
228 +
                positional(&mut v, paths_or_all(a)?);
229 +
                Ok(v)
230 +
            },
231 +
            json: false,
232 +
            read_only: false,
233 +
            destructive: false,
234 +
        },
235 +
        Spec {
236 +
            name: "amend",
237 +
            description: "Fold edits into the change the working copy sits on: only `paths`, or all with `all: true`; `message` replaces its message too.",
238 +
            schema: || obj(json!({ "message": { "type": "string" }, "paths": { "type": "array", "items": { "type": "string" } }, "all": { "type": "boolean" } }), &[]),
239 +
            build: |a, _| {
240 +
                let mut v = vec!["amend".into()];
241 +
                if let Some(m) = arg_str(a, "message") {
242 +
                    v.push(opt("--message", m));
243 +
                }
244 +
                positional(&mut v, paths_or_all(a)?);
245 +
                Ok(v)
246 +
            },
247 +
            json: false,
248 +
            read_only: false,
249 +
            destructive: false,
250 +
        },
251 +
        Spec {
252 +
            name: "describe",
253 +
            description: "Change a head's message (`change` defaults to the one the working copy sits on).",
254 +
            schema: || obj(json!({ "message": { "type": "string" }, "change": { "type": "string" } }), &["message"]),
255 +
            build: |a, _| {
256 +
                let mut v = vec!["describe".into(), "--json".into(), opt("--message", required(a, "message")?)];
257 +
                positional(&mut v, arg_str(a, "change").map(|c| vec![c.to_string()]).unwrap_or_default());
258 +
                Ok(v)
259 +
            },
260 +
            json: true,
261 +
            read_only: false,
262 +
            destructive: false,
263 +
        },
264 +
        Spec {
265 +
            name: "fetch",
266 +
            description: "Fetch from a remote (default origin) without moving local bookmarks.",
267 +
            schema: || obj(json!({ "remote": { "type": "string" } }), &[]),
268 +
            build: |a, _| remote_cmd("fetch", a),
269 +
            json: true,
270 +
            read_only: false,
271 +
            destructive: false,
272 +
        },
273 +
        Spec {
274 +
            name: "pull",
275 +
            description: "Fetch from a remote (default origin) and bring the current bookmark and working copy up to date.",
276 +
            schema: || obj(json!({ "remote": { "type": "string" } }), &[]),
277 +
            build: |a, _| remote_cmd("pull", a),
278 +
            json: true,
279 +
            read_only: false,
280 +
            destructive: false,
281 +
        },
282 +
        Spec {
283 +
            name: "push",
284 +
            description: "Push the current bookmark (or `bookmark`) to a remote (default origin). Changes are sealed at push as this server's identity. `force` (drop heads the remote has) needs the server started with --allow-destructive.",
285 +
            schema: || obj(json!({ "remote": { "type": "string" }, "bookmark": { "type": "string" }, "force": { "type": "boolean" } }), &[]),
286 +
            build: |a, destructive| {
287 +
                let mut v = vec!["push".to_string(), "--json".into()];
288 +
                if let Some(b) = arg_str(a, "bookmark") {
289 +
                    v.push(opt("--bookmark", b));
290 +
                }
291 +
                if a.get("force").and_then(Value::as_bool).unwrap_or(false) {
292 +
                    if !destructive {
293 +
                        return Err("a forced push can drop the remote's heads; start oxy mcp with --allow-destructive to allow it".into());
294 +
                    }
295 +
                    v.push("--force".into());
296 +
                }
297 +
                positional(&mut v, arg_str(a, "remote").map(|r| vec![r.to_string()]).unwrap_or_default());
298 +
                Ok(v)
299 +
            },
300 +
            json: true,
301 +
            read_only: false,
302 +
            destructive: false,
303 +
        },
304 +
        Spec {
305 +
            name: "tlog_verify",
306 +
            description: "Check the hub's transparency log: that it shows everyone the same history, with this repository's entries and each witness's cosignature.",
307 +
            schema: none,
308 +
            build: |_, _| Ok(vec!["tlog".into(), "verify".into(), "--json".into()]),
309 +
            json: true,
310 +
            read_only: true,
311 +
            destructive: false,
312 +
        },
313 +
        Spec {
314 +
            name: "undo",
315 +
            description: "Take back the last operation (see op_log). Offered only when the server was started with --allow-destructive.",
316 +
            schema: none,
317 +
            build: |_, _| Ok(vec!["undo".into()]),
318 +
            json: false,
319 +
            read_only: false,
320 +
            destructive: true,
321 +
        },
322 +
    ]
323 +
}
324 +
325 +
fn remote_cmd(command: &str, a: &Value) -> Result<Vec<String>, String> {
326 +
    let mut v = vec![command.to_string(), "--json".into()];
327 +
    positional(
328 +
        &mut v,
329 +
        arg_str(a, "remote")
330 +
            .map(|r| vec![r.to_string()])
331 +
            .unwrap_or_default(),
332 +
    );
333 +
    Ok(v)
334 +
}
335 +
336 +
struct OxyTools {
337 +
    exe: PathBuf,
338 +
    dir: PathBuf,
339 +
    allow_destructive: bool,
340 +
}
341 +
342 +
impl OxyTools {
343 +
    fn offered(&self) -> impl Iterator<Item = Spec> + '_ {
344 +
        specs()
345 +
            .into_iter()
346 +
            .filter(|s| self.allow_destructive || !s.destructive)
347 +
    }
348 +
349 +
    fn run(&self, spec: &Spec, argv: Vec<String>) -> ToolOutput {
350 +
        let out = Command::new(&self.exe)
351 +
            .arg("--plain")
352 +
            .args(&argv)
353 +
            .current_dir(&self.dir)
354 +
            .stdin(Stdio::null())
355 +
            .output();
356 +
        let out = match out {
357 +
            Ok(o) => o,
358 +
            Err(e) => return ToolOutput::error(format!("could not run oxy: {e}")),
359 +
        };
360 +
        let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
361 +
        let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
362 +
        if !out.status.success() {
363 +
            let why = if stderr.trim().is_empty() {
364 +
                stdout
365 +
            } else {
366 +
                stderr
367 +
            };
368 +
            return ToolOutput::error(format!("oxy {} failed: {}", argv.join(" "), why.trim()));
369 +
        }
370 +
        let mut result = ToolOutput::ok(stdout.clone());
371 +
        if spec.json {
372 +
            result.structured = serde_json::from_str(&stdout).ok();
373 +
        } else if !stderr.trim().is_empty() && stdout.trim().is_empty() {
374 +
            // Commands that report on stderr (commit's summary) still say what they did.
375 +
            result.text = stderr;
376 +
        }
377 +
        if result.text.len() > MAX_TEXT {
378 +
            let mut cut = MAX_TEXT;
379 +
            while !result.text.is_char_boundary(cut) {
380 +
                cut -= 1;
381 +
            }
382 +
            result.text.truncate(cut);
383 +
            result.text.push_str(
384 +
                "\n… (cut at 256 KiB: narrow the request, e.g. a smaller limit or one path)",
385 +
            );
386 +
            result.structured = None;
387 +
        }
388 +
        result
389 +
    }
390 +
}
391 +
392 +
impl Tools for OxyTools {
393 +
    fn list(&self) -> Vec<Tool> {
394 +
        self.offered()
395 +
            .map(|s| Tool {
396 +
                name: s.name.into(),
397 +
                description: s.description.into(),
398 +
                input_schema: (s.schema)(),
399 +
                read_only: s.read_only,
400 +
                destructive: s.destructive,
401 +
            })
402 +
            .collect()
403 +
    }
404 +
405 +
    fn call(&self, name: &str, args: &Value) -> Option<ToolOutput> {
406 +
        let spec = self.offered().find(|s| s.name == name)?;
407 +
        Some(match (spec.build)(args, self.allow_destructive) {
408 +
            Ok(argv) => self.run(&spec, argv),
409 +
            Err(why) => ToolOutput::error(why),
410 +
        })
411 +
    }
412 +
}
413 +
414 +
/// The first line of `oxy identity show`, said in `initialize` so the client
415 +
/// knows who its changes will be signed as.
416 +
fn signer_line(exe: &Path, dir: &Path) -> String {
417 +
    let out = Command::new(exe)
418 +
        .args(["--plain", "identity", "show"])
419 +
        .current_dir(dir)
420 +
        .stdin(Stdio::null())
421 +
        .output();
422 +
    match out {
423 +
        Ok(o) if o.status.success() => {
424 +
            let text = String::from_utf8_lossy(&o.stdout);
425 +
            let who = text.lines().next().unwrap_or("").trim_start_matches(['#', '▍', ' ']).trim().to_string();
426 +
            let signing = text.lines().find(|l| l.trim_start().starts_with("signing")).map(|l| l.trim().to_string());
427 +
            match signing {
428 +
                Some(s) => format!("{who}; {s}"),
429 +
                None => who,
430 +
            }
431 +
        }
432 +
        _ => "no identity is configured: commits will be refused until one is (oxy identity create, or an agent home with OXY_HOME)".into(),
433 +
    }
434 +
}
435 +
436 +
pub fn serve(dir: Option<PathBuf>, allow_destructive: bool) -> Result<i32> {
437 +
    let dir = match dir {
438 +
        Some(d) => d,
439 +
        None => std::env::current_dir()?,
440 +
    };
441 +
    if !dir.join(oxy_core::repo::OXY_DIR).is_dir() {
442 +
        bail!("{} is not the root of an Oxygen repository (no {}): start oxy mcp in a checkout's root, or name it", dir.display(), oxy_core::repo::OXY_DIR);
443 +
    }
444 +
    let exe = std::env::current_exe()?;
445 +
    let signer = signer_line(&exe, &dir);
446 +
    let info = ServerInfo {
447 +
        name: "oxy".into(),
448 +
        version: env!("CARGO_PKG_VERSION").into(),
449 +
        instructions: format!(
450 +
            "Oxygen version control for the repository at {}. Changes are signed as: {signer}. Prefer these tools to running oxy in a shell. \
451 +
             commit and amend take explicit paths (or all: true). {}",
452 +
            dir.display(),
453 +
            if allow_destructive { "Destructive tools (undo, forced push) are enabled: use them only when asked." } else { "Destructive tools (undo, forced push) are off." }
454 +
        ),
455 +
    };
456 +
    let tools = OxyTools {
457 +
        exe,
458 +
        dir,
459 +
        allow_destructive,
460 +
    };
461 +
    let stdin = std::io::stdin();
462 +
    mcp::serve(&info, &tools, stdin.lock(), std::io::stdout().lock())?;
463 +
    Ok(0)
464 +
}
465 +
466 +
#[cfg(test)]
467 +
mod tests {
468 +
    use super::*;
469 +
470 +
    fn build(name: &str, args: Value, destructive: bool) -> Result<Vec<String>, String> {
471 +
        let spec = specs().into_iter().find(|s| s.name == name).unwrap();
472 +
        (spec.build)(&args, destructive)
473 +
    }
474 +
475 +
    #[test]
476 +
    fn values_never_read_as_flags() {
477 +
        assert_eq!(
478 +
            build(
479 +
                "commit",
480 +
                json!({"message": "-m oops", "paths": ["-rf"]}),
481 +
                false
482 +
            )
483 +
            .unwrap(),
484 +
            ["commit", "--message=-m oops", "--", "-rf"]
485 +
        );
486 +
        assert_eq!(
487 +
            build("cat", json!({"path": "--help", "change": "main"}), false).unwrap(),
488 +
            ["cat", "--change=main", "--", "--help"]
489 +
        );
490 +
        assert_eq!(
491 +
            build("log", json!({"limit": 5000}), false).unwrap(),
492 +
            ["log", "--json", "--limit=1000"],
493 +
            "limit is capped"
494 +
        );
495 +
    }
496 +
497 +
    #[test]
498 +
    fn commits_name_what_goes_in() {
499 +
        assert!(build("commit", json!({"message": "x"}), false)
500 +
            .unwrap_err()
501 +
            .contains("all: true"));
502 +
        assert!(build(
503 +
            "commit",
504 +
            json!({"message": "x", "paths": ["a"], "all": true}),
505 +
            false
506 +
        )
507 +
        .is_err());
508 +
        assert_eq!(
509 +
            build("commit", json!({"message": "x", "all": true}), false).unwrap(),
510 +
            ["commit", "--message=x"]
511 +
        );
512 +
        assert!(build("commit", json!({"message": " ", "all": true}), false)
513 +
            .unwrap_err()
514 +
            .contains("message"));
515 +
    }
516 +
517 +
    #[test]
518 +
    fn destructive_tools_need_the_flag() {
519 +
        let off = OxyTools {
520 +
            exe: "oxy".into(),
521 +
            dir: ".".into(),
522 +
            allow_destructive: false,
523 +
        };
524 +
        let on = OxyTools {
525 +
            exe: "oxy".into(),
526 +
            dir: ".".into(),
527 +
            allow_destructive: true,
528 +
        };
529 +
        assert!(!off.list().iter().any(|t| t.name == "undo"));
530 +
        assert!(
531 +
            off.call("undo", &json!({})).is_none(),
532 +
            "an unoffered tool is unknown"
533 +
        );
534 +
        assert!(on.list().iter().any(|t| t.name == "undo" && t.destructive));
535 +
        assert!(build("push", json!({"force": true}), false)
536 +
            .unwrap_err()
537 +
            .contains("--allow-destructive"));
538 +
        assert_eq!(
539 +
            build("push", json!({"force": true, "remote": "earth"}), true).unwrap(),
540 +
            ["push", "--json", "--force", "--", "earth"]
541 +
        );
542 +
    }
543 +
}
modified src/cli/mod.rs +166 −5
1 1
//! Command-line definitions and dispatch.
2 2
3 3
mod approve;
4 +
mod artifact;
4 5
mod auth;
5 6
mod format;
6 7
mod identity;
7 8
mod json;
9 +
mod mcp;
8 10
mod proposal;
9 11
mod receipt;
10 12
mod sync;
…
196 unchanged lines
207 209
        #[arg(long)]
208 210
        json: bool,
209 211
    },
212 +
    /// Serve this repository to an AI agent over the Model Context Protocol (stdio), signing as this checkout's identity
213 +
    Mcp {
214 +
        /// The repository's root (default: the current directory)
215 +
        dir: Option<PathBuf>,
216 +
        /// Also offer tools that can lose work: undo, and a forced push
217 +
        #[arg(long)]
218 +
        allow_destructive: bool,
219 +
    },
210 220
    /// Print the version of oxy, its repository format, and protocol
211 221
    Version {
212 222
        /// Machine-readable output: one JSON document on stdout
…
84 unchanged lines
297 307
        #[arg(long, conflicts_with = "discard")]
298 308
        carry: bool,
299 309
    },
300 -
    /// Report which conflicts of the working copy's base are resolved by the current files
310 +
    /// Report which conflicts of the working copy's base are resolved by the current files;
311 +
    /// with paths, settle their replace conflicts (binary files, subrepo pins) with what the
312 +
    /// working copy holds there
301 313
    Resolve {
314 +
        /// Opaque files or subrepos whose replace conflict to settle with their current content;
315 +
        /// with --both, the text files whose conflicts to settle (all of them when none)
316 +
        paths: Vec<String>,
317 +
        /// Settle each adjacent insertion (two changes adding lines at the same place) by keeping
318 +
        /// every side, earliest change first; refused where the sides changed the same lines
319 +
        #[arg(long)]
320 +
        both: bool,
321 +
        /// With --both, the latest change first
322 +
        #[arg(long, requires = "both")]
323 +
        reverse: bool,
302 324
        /// Machine-readable output: one JSON document on stdout
303 325
        #[arg(long)]
304 326
        json: bool,
…
94 unchanged lines
399 421
        #[command(subcommand)]
400 422
        cmd: TlogCmd,
401 423
    },
424 +
    /// Artifacts: a directory packed into a tree of this repository's chunks (Cylinder, docs/cylinder.md)
425 +
    Artifact {
426 +
        #[command(subcommand)]
427 +
        cmd: ArtifactCmd,
428 +
    },
402 429
    /// Execution receipts: signed records of what a runner ran on a state
403 430
    Receipt {
404 431
        #[command(subcommand)]
…
36 unchanged lines
441 468
        #[arg(short = 'b', long)]
442 469
        bookmark: Option<String>,
443 470
        /// Delete this bookmark on the remote (never its default bookmark);
444 -
        /// the local bookmark is kept
471 +
        /// the local bookmark is kept. `tags/NAME` withdraws a tag there
472 +
        /// (and here, as `oxy tag delete` does)
445 473
        #[arg(long, value_name = "NAME", conflicts_with_all = ["bookmark", "force"])]
446 474
        delete: Option<String>,
447 475
        /// Machine-readable output: one JSON document on stdout
…
69 unchanged lines
517 545
        #[command(subcommand)]
518 546
        cmd: PolicyCmd,
519 547
    },
520 -
    /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, core.checkpoint-interval, filter.max-blob, filter.paths, storage.cache, workspace.bookmark, remote.<name>.url|token|ca, auth.helper with --user)
548 +
    /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, core.checkpoint-interval, filter.max-blob, filter.paths, storage.cache, workspace.bookmark, remote.<name>.url|token|ca, git.claim, git.colocate, auth.helper with --user)
521 549
    Config {
522 550
        /// The key, such as author.name or remote.origin.token
523 551
        key: String,
…
280 unchanged lines
804 832
    Remove {
805 833
        /// The subrepo to unregister
806 834
        path: String,
835 +
        /// Delete its directory too (#164); refused while it has uncommitted edits
836 +
        #[arg(long)]
837 +
        delete: bool,
838 +
        /// With --delete: delete it even with uncommitted edits
839 +
        #[arg(long, requires = "delete")]
840 +
        force: bool,
807 841
    },
808 842
}
809 843
810 844
#[derive(Subcommand)]
845 +
enum ArtifactCmd {
846 +
    /// Pack DIR into an artifact: each file a blob of this repository's store, so files and chunks it holds cost nothing
847 +
    Pack {
848 +
        /// The directory whose files make the artifact
849 +
        dir: std::path::PathBuf,
850 +
        /// container, runner-image, package, archive or report
851 +
        #[arg(long, default_value = "archive")]
852 +
        kind: String,
853 +
        /// x86_64, riscv64, aarch64…
854 +
        #[arg(long)]
855 +
        arch: Option<String>,
856 +
        /// linux, darwin, none…
857 +
        #[arg(long)]
858 +
        os: Option<String>,
859 +
        /// The default command, one word per flag
860 +
        #[arg(long = "entrypoint", value_name = "WORD")]
861 +
        entrypoint: Vec<String>,
862 +
        /// KEY=VALUE for the environment (repeatable)
863 +
        #[arg(long = "env", value_name = "KEY=VALUE")]
864 +
        env: Vec<String>,
865 +
        /// KEY=VALUE metadata (repeatable)
866 +
        #[arg(long = "label", value_name = "KEY=VALUE")]
867 +
        labels: Vec<String>,
868 +
        /// Machine-readable output: one JSON document on stdout
869 +
        #[arg(long)]
870 +
        json: bool,
871 +
    },
872 +
    /// The artifacts kept here
873 +
    List {
874 +
        /// Machine-readable output: one JSON document on stdout
875 +
        #[arg(long)]
876 +
        json: bool,
877 +
    },
878 +
    /// One artifact and its files
879 +
    Show {
880 +
        /// Its name, or a unique prefix of at least six characters
881 +
        name: String,
882 +
        /// Machine-readable output: one JSON document on stdout
883 +
        #[arg(long)]
884 +
        json: bool,
885 +
    },
886 +
    /// Write an artifact's files into DEST (missing or empty)
887 +
    Unpack {
888 +
        /// Its name, or a unique prefix of at least six characters
889 +
        name: String,
890 +
        /// Where to write its files
891 +
        dest: std::path::PathBuf,
892 +
    },
893 +
    /// Check an artifact's name, tree and every file against the store
894 +
    Verify {
895 +
        /// Its name, or a unique prefix of at least six characters
896 +
        name: String,
897 +
    },
898 +
}
899 +
900 +
#[derive(Subcommand)]
811 901
enum ReceiptCmd {
812 902
    /// The receipts kept for a state (default: the bookmark heads)
813 903
    List {
…
57 unchanged lines
871 961
        #[arg(short, long, default_value = "")]
872 962
        message: String,
873 963
    },
964 +
    /// Withdraw a tag: a signed withdrawal, kept beside the tag's creation;
965 +
    /// `oxy push` sends it to a remote that has the tag (the name is never
966 +
    /// used again)
967 +
    Delete {
968 +
        /// Name of the tag
969 +
        name: String,
970 +
        /// Why it is withdrawn, recorded with the withdrawal
971 +
        #[arg(short, long, default_value = "")]
972 +
        reason: String,
973 +
    },
874 974
    /// List tags and the states they name
875 975
    List {
876 976
        /// Machine-readable output: one JSON document on stdout
…
146 unchanged lines
1023 1123
1024 1124
#[derive(Subcommand)]
1025 1125
enum GitCmd {
1126 +
    /// Keep a .git beside .oxy, up to date after every oxy command, so editors' Git features work here (one way: oxy to Git)
1127 +
    Colocate {
1128 +
        /// Stop updating it (the .git is left as it is)
1129 +
        #[arg(long)]
1130 +
        off: bool,
1131 +
    },
1026 1132
    /// Export the current bookmark to a Git repository (created bare if absent)
1027 1133
    Export {
1028 1134
        /// Git repository to write to (created bare if absent)
…
141 unchanged lines
1170 1276
    },
1171 1277
}
1172 1278
1279 +
/// After a command, bring the side-by-side `.git` up to date when this
1280 +
/// checkout keeps one (`git.colocate`). It never fails the command: a
1281 +
/// problem is a warning, and the next command tries again.
1282 +
pub fn after_command() {
1283 +
    if AT_OP.get().and_then(|o| o.as_deref()).is_some() {
1284 +
        return;
1285 +
    }
1286 +
    let Ok(dir) = std::env::current_dir() else {
1287 +
        return;
1288 +
    };
1289 +
    let Ok(repo) = Repo::open(&dir) else { return };
1290 +
    if !repo.config.git_colocate || repo.is_bare() {
1291 +
        return;
1292 +
    }
1293 +
    if let Err(e) = repo.git_colocate_sync() {
1294 +
        eprintln!(
1295 +
            "{} the .git beside this checkout was not updated: {e}",
1296 +
            ui::warn("!")
1297 +
        );
1298 +
    }
1299 +
}
1300 +
1173 1301
/// Open the repository containing the current directory.
1174 1302
fn open() -> Result<Repo> {
1175 1303
    let repo = Repo::open(&std::env::current_dir()?).context("opening repository")?;
…
72 unchanged lines
1248 1376
        }
1249 1377
        Cmd::Cat { path, change } => workspace::cat(&path, change.as_deref()),
1250 1378
        Cmd::Root { json } => workspace::root(json),
1379 +
        Cmd::Mcp {
1380 +
            dir,
1381 +
            allow_destructive,
1382 +
        } => mcp::serve(dir, allow_destructive),
1251 1383
        Cmd::Version { json } => workspace::version(json),
1252 1384
        Cmd::Discard { paths, hunks } => workspace::discard(&paths, &hunks),
1253 1385
        Cmd::Hunks { json } => workspace::hunks(json),
…
64 unchanged lines
1318 1450
            discard,
1319 1451
            carry,
1320 1452
        } => workspace::checkout(&changes, discard, carry),
1321 -
        Cmd::Resolve { json } => workspace::resolve(json),
1453 +
        Cmd::Resolve {
1454 +
            paths,
1455 +
            both,
1456 +
            reverse,
1457 +
            json,
1458 +
        } => workspace::resolve(&paths, both, reverse, json),
1322 1459
        Cmd::Undo => workspace::undo(),
1323 1460
        Cmd::Gc {
1324 1461
            now,
…
71 unchanged lines
1396 1533
            } => sync::submodule_add(&url, &path, bookmark.as_deref()),
1397 1534
            SubmoduleCmd::Update { init, paths } => sync::submodule_update(init, &paths),
1398 1535
            SubmoduleCmd::Status { json } => sync::submodule_status(json),
1399 -
            SubmoduleCmd::Remove { path } => sync::submodule_remove(&path),
1536 +
            SubmoduleCmd::Remove {
1537 +
                path,
1538 +
                delete,
1539 +
                force,
1540 +
            } => sync::submodule_remove(&path, delete, force),
1400 1541
        },
1401 1542
        Cmd::Witness {
1402 1543
            cmd:
…
14 unchanged lines
1417 1558
                json,
1418 1559
            } => tlog::verify(hub.as_deref(), key.as_deref(), all, json),
1419 1560
        },
1561 +
        Cmd::Artifact { cmd } => match cmd {
1562 +
            ArtifactCmd::Pack {
1563 +
                dir,
1564 +
                kind,
1565 +
                arch,
1566 +
                os,
1567 +
                entrypoint,
1568 +
                env,
1569 +
                labels,
1570 +
                json,
1571 +
            } => artifact::pack(&dir, &kind, arch, os, entrypoint, &env, &labels, json),
1572 +
            ArtifactCmd::List { json } => artifact::list(json),
1573 +
            ArtifactCmd::Show { name, json } => artifact::show(&name, json),
1574 +
            ArtifactCmd::Unpack { name, dest } => artifact::unpack(&name, &dest),
1575 +
            ArtifactCmd::Verify { name } => artifact::verify(&name),
1576 +
        },
1420 1577
        Cmd::Receipt { cmd } => match cmd {
1421 1578
            ReceiptCmd::List { rev, json } => receipt::list(rev.as_deref(), json),
1422 1579
            ReceiptCmd::Show {
…
16 unchanged lines
1439 1596
                changes,
1440 1597
                message,
1441 1598
            } => workspace::tag_create(&name, &changes, &message),
1599 +
            TagCmd::Delete { name, reason } => workspace::tag_delete(&name, &reason),
1442 1600
            TagCmd::List { json } => workspace::tag_list(json),
1443 1601
        },
1444 1602
        Cmd::Remote { cmd } => match cmd {
…
63 unchanged lines
1508 1666
            },
1509 1667
        },
1510 1668
        Cmd::Git {
1669 +
            cmd: GitCmd::Colocate { off },
1670 +
        } => sync::git_colocate(off),
1671 +
        Cmd::Git {
1511 1672
            cmd: GitCmd::Export { git_dir, branch },
1512 1673
        } => sync::git_export(&git_dir, branch.as_deref()),
1513 1674
        Cmd::Git {
…
120 unchanged lines
modified src/cli/sync.rs +128 −9
…
371 unchanged lines
372 372
    let repo = open()?;
373 373
    let remote = remote_name(&repo, remote);
374 374
    let sp = ui::spinner(&format!("Fetching from {remote}…"));
375 -
    let report = repo.fetch(&remote)?;
375 +
    let report = noted(&repo, &remote, repo.fetch(&remote))?;
376 376
    sp.clear();
377 377
    if as_json {
378 378
        println!(
…
20 unchanged lines
399 399
                    }))
400 400
                ),
401 401
                ("new_tags", json::n(report.new_tags)),
402 +
                (
403 +
                    "withdrawn_tags",
404 +
                    json::arr(report.withdrawn_tags.iter().map(|n| json::s(n))),
405 +
                ),
402 406
                ("unverified", json::b(report.unverified)),
403 407
                (
404 408
                    "log_notes",
…
56 unchanged lines
461 465
            ui::dim(format!("{} adopted", plural(report.new_tags, "tag")))
462 466
        );
463 467
    }
468 +
    for t in &report.withdrawn_tags {
469 +
        println!(
470 +
            "  {} {}",
471 +
            ui::note(),
472 +
            ui::dim(format!("tag {t} was withdrawn on {remote}, and here"))
473 +
        );
474 +
    }
464 475
    if report.unverified {
465 476
        eprintln!(
466 477
            "{} {remote} has no trust policy; nothing fetched from it was verified",
…
61 unchanged lines
528 539
    let repo = open()?;
529 540
    let remote = remote_name(&repo, remote);
530 541
    let sp = ui::spinner(&format!("Pulling from {remote}…"));
531 -
    let r = repo.pull(&remote)?;
542 +
    let r = noted(&repo, &remote, repo.pull(&remote))?;
532 543
    if as_json {
533 544
        sp.clear();
534 545
        let conflicted = repo
…
107 unchanged lines
642 653
    let mut code = 0;
643 654
    for r in results {
644 655
        if r.ok {
656 +
            let done = if r.name.starts_with("tags/") {
657 +
                "Withdrew"
658 +
            } else {
659 +
                "Deleted"
660 +
            };
645 661
            println!(
646 662
                "{} {}",
647 -
                ui::done("Deleted"),
663 +
                ui::done(done),
648 664
                ui::id(format!("{remote}/{}", r.name))
649 665
            );
650 666
        } else {
…
22 unchanged lines
673 689
    let sp = ui::spinner(&format!("Pushing to {remote}…"));
674 690
    let bookmark_name = bookmark.map_or_else(|| repo.config.bookmark.clone(), String::from);
675 691
    // A long push goes in parts: the spinner says which.
676 -
    let results = repo.push_bookmark_with(&remote, &bookmark_name, force, &mut |m| {
677 -
        sp.set(&format!("Pushing to {remote}… {m}"))
678 -
    })?;
692 +
    let results = noted(
693 +
        &repo,
694 +
        &remote,
695 +
        repo.push_bookmark_with(&remote, &bookmark_name, force, &mut |m| {
696 +
            sp.set(&format!("Pushing to {remote}… {m}"))
697 +
        }),
698 +
    )?;
679 699
    sp.clear();
680 700
    if as_json {
681 701
        // The state each accepted bookmark now holds on the remote (its local
…
55 unchanged lines
737 757
    tls: Option<(PathBuf, PathBuf)>,
738 758
) -> Result<i32> {
739 759
    let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding {bind}"))?;
740 -
    let root = root.canonicalize()?;
760 +
    let root = oxy_core::portable::canonical(&root)?;
741 761
    let tls = tls.map(|(cert, key)| oxy_server::TlsFiles { cert, key });
742 762
    println!(
743 763
        "Serving repositories under {} on {}://{bind}/<repo>/oxy/v1/ ({})",
…
16 unchanged lines
760 780
    Ok(0)
761 781
}
762 782
783 +
/// `oxy git colocate [--off]`: keep a `.git` beside `.oxy` (spec §23).
784 +
pub fn git_colocate(off: bool) -> Result<i32> {
785 +
    let mut repo = open()?;
786 +
    if repo.is_bare() {
787 +
        anyhow::bail!("a bare repository has no working copy for a .git beside it");
788 +
    }
789 +
    repo.config
790 +
        .set("git.colocate", if off { "false" } else { "true" })?;
791 +
    repo.save_config()?;
792 +
    if off {
793 +
        println!(
794 +
            "{} the .git beside this checkout is no longer updated {}",
795 +
            ui::done("Stopped:"),
796 +
            ui::dim("(it is left as it is: delete .git yourself if you want it gone)")
797 +
        );
798 +
        return Ok(0);
799 +
    }
800 +
    let sp = ui::spinner("Exporting to .git…");
801 +
    repo.git_colocate_sync()?;
802 +
    sp.clear();
803 +
    println!(
804 +
        "{} .git beside .oxy holds {} {}",
805 +
        ui::done("Colocated:"),
806 +
        ui::bookmark(&repo.config.bookmark),
807 +
        ui::dim("(updated after every oxy command; Git's own commits are not read back: commit with oxy)")
808 +
    );
809 +
    Ok(0)
810 +
}
811 +
763 812
pub fn git_export(git_dir: &std::path::Path, branch: Option<&str>) -> Result<i32> {
764 813
    let repo = open()?;
765 814
    let sp = ui::spinner("Exporting to Git…");
…
421 unchanged lines
1187 1236
    Ok(0)
1188 1237
}
1189 1238
1190 -
pub fn submodule_remove(path: &str) -> Result<i32> {
1239 +
pub fn submodule_remove(path: &str, delete: bool, force: bool) -> Result<i32> {
1191 1240
    let repo = open()?;
1241 +
    let dir = repo.root().join(path.trim_end_matches('/'));
1242 +
    // Look before unregistering, so a refused delete changes nothing.
1243 +
    if delete && dir.exists() && !force {
1244 +
        if let Ok(sub) = oxy_core::repo::Repo::open(&dir) {
1245 +
            let snap = sub.snapshot("oxy submodule remove --delete")?;
1246 +
            if !snap.statuses.is_empty() {
1247 +
                anyhow::bail!(
1248 +
                    "{path} has {} with uncommitted edits; commit them in the subrepo (and push them if you want them kept), or pass --force to delete them",
1249 +
                    plural(snap.statuses.len(), "file")
1250 +
                );
1251 +
            }
1252 +
        }
1253 +
    }
1192 1254
    repo.submodule_remove(path)?;
1255 +
    if !delete {
1256 +
        println!(
1257 +
            "{} subrepo {} {}",
1258 +
            ui::done("Removed"),
1259 +
            ui::path(path),
1260 +
            ui::dim("(directory left in place; commit to record the removal)")
1261 +
        );
1262 +
        println!(
1263 +
            "  {} {}",
1264 +
            ui::note(),
1265 +
            ui::dim(format!(
1266 +
                "to delete the directory too: oxy submodule remove {path} --delete"
1267 +
            ))
1268 +
        );
1269 +
        return Ok(0);
1270 +
    }
1271 +
    if dir.exists() {
1272 +
        delete_tree(&dir)?;
1273 +
    }
1193 1274
    println!(
1194 1275
        "{} subrepo {} {}",
1195 1276
        ui::done("Removed"),
1196 1277
        ui::path(path),
1197 -
        ui::dim("(directory left in place; commit to record the removal)")
1278 +
        ui::dim("and its directory (commit to record the removal)")
1198 1279
    );
1199 1280
    Ok(0)
1200 1281
}
1201 1282
1283 +
/// Delete a directory tree that holds a repository: its object files are
1284 +
/// read-only, so each is made writable first (#164).
1285 +
fn delete_tree(dir: &std::path::Path) -> Result<()> {
1286 +
    fn writable(p: &std::path::Path) -> std::io::Result<()> {
1287 +
        let meta = std::fs::symlink_metadata(p)?;
1288 +
        if meta.file_type().is_symlink() {
1289 +
            return Ok(());
1290 +
        }
1291 +
        let mut perms = meta.permissions();
1292 +
        if perms.readonly() {
1293 +
            #[allow(clippy::permissions_set_readonly_false)]
1294 +
            perms.set_readonly(false);
1295 +
            std::fs::set_permissions(p, perms)?;
1296 +
        }
1297 +
        if meta.is_dir() {
1298 +
            for e in std::fs::read_dir(p)? {
1299 +
                writable(&e?.path())?;
1300 +
            }
1301 +
        }
1302 +
        Ok(())
1303 +
    }
1304 +
    writable(dir).map_err(|e| anyhow::anyhow!("{}: {e}", dir.display()))?;
1305 +
    std::fs::remove_dir_all(dir).map_err(|e| anyhow::anyhow!("{}: {e}", dir.display()))?;
1306 +
    Ok(())
1307 +
}
1308 +
1202 1309
pub fn deepen(remote: &str, depth: u32) -> Result<i32> {
1203 1310
    let repo = open()?;
1204 1311
    if !repo.is_shallow() {
…
17 unchanged lines
1222 1329
    ));
1223 1330
    Ok(0)
1224 1331
}
1332 +
1333 +
/// `r`, with a note when the remote's own token was refused while another
1334 +
/// sign-in for its hub would do (oxygen#155).
1335 +
fn noted<T>(repo: &oxy_core::repo::Repo, remote: &str, r: oxy_core::repo::Result<T>) -> Result<T> {
1336 +
    r.map_err(|e| {
1337 +
        let m = e.to_string();
1338 +
        match repo.stale_token_note(remote, &m) {
1339 +
            Some(note) => anyhow::anyhow!("{m}\n  note: {note}"),
1340 +
            None => e.into(),
1341 +
        }
1342 +
    })
1343 +
}
modified src/cli/workspace.rs +200 −6
1 1
//! Commands that act on the local repository and working copy.
2 2
3 3
use super::format::{
4 -
    author_name, deps_line, deps_of, plural, short_id, signature_line, signature_mark_of,
5 -
    subject_of, via, when,
4 +
    author_name, awaits_seal, deps_line, deps_of, plural, short_id, signature_line,
5 +
    signature_mark_of, subject_of, via, when,
6 6
};
7 7
use super::json;
8 8
use super::open;
…
235 unchanged lines
244 244
        );
245 245
        print_behind(&repo);
246 246
        print_statuses(&statuses);
247 +
        print_pin_moves(&pin_moves(&repo, None));
247 248
        print_untracked_nested(&repo, &repo.untracked_nested());
248 249
        print_skipped_large(&repo);
249 250
        return Ok(0);
…
25 unchanged lines
275 276
    );
276 277
    print_behind(&repo);
277 278
    print_statuses(&snap.statuses);
279 +
    print_pin_moves(&pin_moves(&repo, None));
278 280
    print_untracked_nested(&repo, &snap.untracked_nested);
279 281
    print_skipped_large(&repo);
280 282
    if !snap.base_snapshot.is_conflicted() {
…
25 unchanged lines
306 308
                _ => ui::err(format!("{} unresolved", ui::glyph("✱", "!"))),
307 309
            };
308 310
            println!("  {}  {state}", ui::path(p));
311 +
            let resolved = matches!(
312 +
                snap.statuses
313 +
                    .iter()
314 +
                    .find(|s| s.path == *p)
315 +
                    .map(|s| s.status),
316 +
                Some(ChangeStatus::Resolved)
317 +
            );
318 +
            let both = if resolved {
319 +
                0
320 +
            } else {
321 +
                snap.base.both_sides(&f.node).iter().flatten().count()
322 +
            };
323 +
            if both > 0 {
324 +
                println!(
325 +
                    "     {}",
326 +
                    ui::dim(format!(
327 +
                        "{} adjacent {}: `oxy resolve --both {p}` keeps both sides, earliest first",
328 +
                        both,
329 +
                        if both == 1 { "insertion" } else { "insertions" }
330 +
                    ))
331 +
                );
332 +
            }
309 333
        }
310 334
    }
311 335
    Ok(2)
…
87 unchanged lines
399 423
    } else {
400 424
        oxy_core::repo::Selection::Paths(paths.to_vec())
401 425
    };
426 +
    // Pins this commit records as moved, said after it (oxygen#162): a pin
427 +
    // moved by work inside a subrepo is a pin bump, easy to make unasked.
428 +
    let moves = match &selection {
429 +
        oxy_core::repo::Selection::All => pin_moves(&repo, None),
430 +
        oxy_core::repo::Selection::Paths(p) => pin_moves(&repo, Some(p)),
431 +
        oxy_core::repo::Selection::Hunks(_) => Vec::new(),
432 +
    };
402 433
    let sp = ui::spinner("Committing…");
403 434
    let (h, partial) = repo.commit_selection_partial(message, &deps, &selection)?;
404 435
    // The metadata only: a first commit's change holds the whole tree.
…
5 unchanged lines
410 441
        ui::hash(format!("#{}", h.short(12))),
411 442
        ui::subject(subject_of(message))
412 443
    ));
444 +
    for m in &moves {
445 +
        let line = match (&m.to, m.dirty) {
446 +
            (Some(to), _) => format!(
447 +
                "recorded subrepo {}'s pin moving {} {} {}: a pin bump. Meant only your own files? `oxy commit PATHS` leaves pins out",
448 +
                m.path,
449 +
                m.from,
450 +
                ui::glyph("→", "->"),
451 +
                to
452 +
            ),
453 +
            (None, true) => format!(
454 +
                "subrepo {} has uncommitted edits of its own: the pin names its last committed state; the edits stay in it",
455 +
                m.path
456 +
            ),
457 +
            (None, false) => continue,
458 +
        };
459 +
        println!("  {} {}", ui::caution(), ui::warn(line));
460 +
    }
413 461
    print_untracked_nested(&repo, &repo.untracked_nested());
414 462
    print_skipped_large(&repo);
415 463
    // Only a commit that took part of the working copy leaves anything.
…
14 unchanged lines
430 478
    Ok(0)
431 479
}
432 480
481 +
/// A subrepo whose pin the next commit would move (`to`), or which has
482 +
/// uncommitted edits of its own (oxygen#162).
483 +
struct PinMove {
484 +
    path: String,
485 +
    from: String,
486 +
    to: Option<String>,
487 +
    dirty: bool,
488 +
}
489 +
490 +
/// The subrepos (among `paths`, when given) whose checkout is not at the
491 +
/// pin the base records, or holds uncommitted edits. Only a checkout with
492 +
/// subrepos (an `.oxymodules`) pays for the look.
493 +
fn pin_moves(repo: &oxy_core::repo::Repo, paths: Option<&[String]>) -> Vec<PinMove> {
494 +
    if !repo.root().join(".oxymodules").exists() {
495 +
        return Vec::new();
496 +
    }
497 +
    let Ok(subs) = repo.subrepos() else {
498 +
        return Vec::new();
499 +
    };
500 +
    let chosen = |p: &str| {
501 +
        paths.is_none_or(|ps| {
502 +
            ps.iter().any(|x| {
503 +
                let x = x.trim_end_matches('/');
504 +
                p == x || p.starts_with(&format!("{x}/")) || x.is_empty() || x == "."
505 +
            })
506 +
        })
507 +
    };
508 +
    subs.into_iter()
509 +
        .filter(|s| s.committed && chosen(&s.path))
510 +
        .filter_map(|s| {
511 +
            let to = s
512 +
                .actual
513 +
                .as_ref()
514 +
                .filter(|a| !a.same_state(&s.pin))
515 +
                .map(|a| a.summary());
516 +
            (to.is_some() || s.dirty).then(|| PinMove {
517 +
                from: s.pin.summary(),
518 +
                to,
519 +
                dirty: s.dirty,
520 +
                path: s.path,
521 +
            })
522 +
        })
523 +
        .collect()
524 +
}
525 +
526 +
/// `oxy status`'s word on subrepos: a moved pin is a change the next commit
527 +
/// records; edits inside a subrepo are not.
528 +
fn print_pin_moves(moves: &[PinMove]) {
529 +
    if moves.is_empty() {
530 +
        return;
531 +
    }
532 +
    println!("{}", ui::section("Subrepo pins"));
533 +
    for m in moves {
534 +
        if let Some(to) = &m.to {
535 +
            println!(
536 +
                "  {} {}  {} {} {}  {}",
537 +
                ui::warn(ui::glyph("↕", "<>")),
538 +
                ui::path(&m.path),
539 +
                ui::dim(&m.from),
540 +
                ui::dim(ui::glyph("→", "->")),
541 +
                to,
542 +
                ui::dim("pin moved: a bare `oxy commit` records it")
543 +
            );
544 +
        }
545 +
        if m.dirty {
546 +
            println!(
547 +
                "  {} {}  {}",
548 +
                ui::warn(ui::glyph("✎", "~")),
549 +
                ui::path(&m.path),
550 +
                ui::dim("uncommitted edits inside it: commit them there; the parent records only its pin")
551 +
            );
552 +
        }
553 +
    }
554 +
}
555 +
433 556
pub fn blame(path: &str, json: bool) -> Result<i32> {
434 557
    let repo = open()?;
435 558
    let lines = repo.blame(path)?;
…
368 unchanged lines
804 927
            if let Some(agent) = via(&repo, &status, &c.author) {
805 928
                head.push(ui::dim(format!("via {}", agent.name)));
806 929
            }
807 -
            head.push(signature_mark_of(&status));
930 +
            head.push(signature_mark_of(
931 +
                &status,
932 +
                awaits_seal(&repo, &status, &c.author),
933 +
            ));
808 934
        }
809 935
        println!("{}{}", node_line(row, &bullet), head.join(" "));
810 936
        let subject = if e.is_working_copy && c.message.is_empty() {
…
634 unchanged lines
1445 1571
    for n in &r.notes {
1446 1572
        println!("  {} {n}", ui::note());
1447 1573
    }
1574 +
    print_kept_both(&r);
1448 1575
    if r.conflicted {
1449 1576
        println!(
1450 1577
            "  {} {}",
…
5 unchanged lines
1456 1583
    Ok(0)
1457 1584
}
1458 1585
1459 -
pub fn resolve(as_json: bool) -> Result<i32> {
1586 +
/// Union files (`merge=union`, oxygen#145) a checkout settled itself.
1587 +
fn print_kept_both(r: &oxy_core::repo::CheckoutReport) {
1588 +
    for k in &r.kept_both {
1589 +
        let how = if k.identical.len() == k.settled.len() {
1590 +
            "kept one of its identical sides"
1591 +
        } else if k.identical.is_empty() {
1592 +
            "kept both sides (merge=union)"
1593 +
        } else {
1594 +
            "kept one of identical sides and both of others (merge=union)"
1595 +
        };
1596 +
        println!(
1597 +
            "  {} settled {}: {how}; the next commit records it",
1598 +
            ui::note(),
1599 +
            ui::path(&k.path)
1600 +
        );
1601 +
    }
1602 +
}
1603 +
1604 +
pub fn resolve(paths: &[String], both: bool, reverse: bool, as_json: bool) -> Result<i32> {
1460 1605
    let repo = open()?;
1461 -
    let snap = repo.snapshot("oxy resolve")?;
1606 +
    let mut snap = repo.snapshot("oxy resolve")?;
1607 +
    let paths: Vec<String> = paths
1608 +
        .iter()
1609 +
        .map(|p| p.trim_start_matches("./").trim_end_matches('/').to_string())
1610 +
        .collect();
1611 +
    if both {
1612 +
        // Adjacent insertions (oxygen#121): every side kept, in order.
1613 +
        let kept = repo.keep_both(&snap, &paths, reverse)?;
1614 +
        if !as_json {
1615 +
            for k in &kept {
1616 +
                if !k.settled.is_empty() {
1617 +
                    let n: Vec<String> = k.settled.iter().map(|n| n.to_string()).collect();
1618 +
                    println!("kept both  {}: conflict {}", k.path, n.join(", "));
1619 +
                }
1620 +
                for (n, why) in &k.left {
1621 +
                    println!("markers    {}: conflict {n}: {why}", k.path);
1622 +
                }
1623 +
            }
1624 +
        }
1625 +
        snap = repo.snapshot("oxy resolve")?;
1626 +
    } else if !paths.is_empty() {
1627 +
        // A replace conflict has no markers to edit out: the user says which
1628 +
        // content wins (oxygen#140, spec §9.3), and the snapshot builds it.
1629 +
        repo.choose_resolutions(&snap.base_snapshot, &paths)?;
1630 +
        snap = repo.snapshot("oxy resolve")?;
1631 +
    }
1462 1632
    if as_json {
1463 1633
        println!("{}", repo.status_json(&snap)?);
1464 1634
        return Ok(if snap.base_snapshot.is_conflicted() {
…
320 unchanged lines
1785 1955
        repo.config.snapshot_retention_days = 0;
1786 1956
    }
1787 1957
    let sp = ui::spinner("Packing and pruning…");
1788 -
    let r = repo.gc_with(now, &mut |phase| sp.set(&format!("{phase}…")))?;
1958 +
    // Cylinder's artifacts are named from outside the history: keep them.
1959 +
    let artifacts = cylinder::roots(&repo)?;
1960 +
    let r = repo.gc_keeping(now, &artifacts, &mut |phase| sp.set(&format!("{phase}…")))?;
1789 1961
    sp.clear();
1790 1962
    if r.packed.objects > 0 {
1791 1963
        println!(
…
532 unchanged lines
2324 2496
    Ok(0)
2325 2497
}
2326 2498
2499 +
pub fn tag_delete(name: &str, reason: &str) -> Result<i32> {
2500 +
    let repo = open()?;
2501 +
    let t = repo.tag_delete(name, reason)?;
2502 +
    println!(
2503 +
        "{} {} {} {}{}",
2504 +
        ui::done("Withdrew"),
2505 +
        ui::bookmark(&t.name),
2506 +
        ui::dim(ui::glyph("→", "->")),
2507 +
        ui::hash(format!("#{}", t.state.short(12))),
2508 +
        t.signer
2509 +
            .map(|s| format!(", signed by {s}"))
2510 +
            .unwrap_or_else(|| ui::dim(", unsigned"))
2511 +
    );
2512 +
    println!(
2513 +
        "  {} {}",
2514 +
        ui::note(),
2515 +
        ui::dim("`oxy push` sends the withdrawal to a remote that has the tag; the name is not used again")
2516 +
    );
2517 +
    Ok(0)
2518 +
}
2519 +
2327 2520
pub fn tag_list(as_json: bool) -> Result<i32> {
2328 2521
    let repo = open()?;
2329 2522
    let tags = repo.tags()?;
…
583 unchanged lines
2913 3106
    ));
2914 3107
    print_removed(&r);
2915 3108
    print_carried(&carried);
3109 +
    print_kept_both(&r);
2916 3110
    if r.conflicted {
2917 3111
        println!(
2918 3112
            "  {} {}",
…
166 unchanged lines
modified src/main.rs +3 −0
…
34 unchanged lines
35 35
    }
36 36
    let command = oxy_core::timing::phase("command");
37 37
    let result = cli::run();
38 +
    if result.is_ok() {
39 +
        cli::after_command();
40 +
    }
38 41
    drop(command);
39 42
    oxy_core::timing::memory_summary();
40 43
    match result {
…
8 unchanged lines
added tests/cli_colocate.rs +110 −0
1 +
//! `oxy git colocate` (oxygenhub#355, part two): a `.git` beside `.oxy`,
2 +
//! kept up to date after every oxy command, so Git tools read an Oxygen
3 +
//! checkout; oxy never takes `.git` as content.
4 +
5 +
use std::path::Path;
6 +
use std::process::Command;
7 +
8 +
fn oxy(dir: &Path, home: &Path, args: &[&str]) -> (bool, String) {
9 +
    let out = Command::new(env!("CARGO_BIN_EXE_oxy"))
10 +
        .arg("--plain")
11 +
        .args(args)
12 +
        .current_dir(dir)
13 +
        .env("OXY_HOME", home)
14 +
        .env("NO_COLOR", "1")
15 +
        .output()
16 +
        .unwrap();
17 +
    (
18 +
        out.status.success(),
19 +
        format!(
20 +
            "{}{}",
21 +
            String::from_utf8_lossy(&out.stdout),
22 +
            String::from_utf8_lossy(&out.stderr)
23 +
        ),
24 +
    )
25 +
}
26 +
27 +
fn git(dir: &Path, args: &[&str]) -> String {
28 +
    let out = Command::new("git")
29 +
        .args(args)
30 +
        .current_dir(dir)
31 +
        .env("GIT_CONFIG_NOSYSTEM", "1")
32 +
        .env("HOME", dir)
33 +
        .output()
34 +
        .unwrap();
35 +
    assert!(
36 +
        out.status.success(),
37 +
        "git {args:?}: {}",
38 +
        String::from_utf8_lossy(&out.stderr)
39 +
    );
40 +
    String::from_utf8_lossy(&out.stdout).trim().to_string()
41 +
}
42 +
43 +
#[test]
44 +
fn a_colocated_git_follows_the_bookmark() {
45 +
    if Command::new("git").arg("--version").output().is_err() {
46 +
        eprintln!("git not installed; skipping");
47 +
        return;
48 +
    }
49 +
    let tmp = tempfile::tempdir().unwrap();
50 +
    let home = tmp.path().join("home");
51 +
    std::fs::create_dir_all(&home).unwrap();
52 +
    let r = tmp.path().join("r");
53 +
    assert!(oxy(tmp.path(), &home, &["init", r.to_str().unwrap()]).0);
54 +
    assert!(
55 +
        oxy(
56 +
            &r,
57 +
            &home,
58 +
            &[
59 +
                "identity",
60 +
                "create",
61 +
                "--name",
62 +
                "Ada",
63 +
                "--email",
64 +
                "[email protected]"
65 +
            ]
66 +
        )
67 +
        .0
68 +
    );
69 +
    std::fs::write(r.join("a.txt"), "one\n").unwrap();
70 +
    assert!(oxy(&r, &home, &["commit", "-m", "first"]).0);
71 +
72 +
    let (ok, out) = oxy(&r, &home, &["git", "colocate"]);
73 +
    assert!(ok, "{out}");
74 +
    assert!(r.join(".git/HEAD").exists());
75 +
    assert_eq!(git(&r, &["log", "--format=%s"]), "first");
76 +
    assert_eq!(git(&r, &["symbolic-ref", "HEAD"]), "refs/heads/main");
77 +
    assert_eq!(
78 +
        git(&r, &["status", "--porcelain"]),
79 +
        "",
80 +
        "Git sees no difference, and not .oxy either"
81 +
    );
82 +
    let (_, status) = oxy(&r, &home, &["status"]);
83 +
    assert!(
84 +
        !status.contains(".git"),
85 +
        "oxy never takes .git as content: {status}"
86 +
    );
87 +
88 +
    // An oxy commit shows in Git without asking.
89 +
    std::fs::write(r.join("a.txt"), "one\ntwo\n").unwrap();
90 +
    assert_eq!(
91 +
        git(&r, &["status", "--porcelain"]),
92 +
        "M a.txt",
93 +
        "uncommitted work shows as modified"
94 +
    );
95 +
    assert!(oxy(&r, &home, &["commit", "-m", "second"]).0);
96 +
    assert_eq!(git(&r, &["log", "--format=%s"]), "second\nfirst");
97 +
    assert_eq!(git(&r, &["status", "--porcelain"]), "");
98 +
    assert_eq!(git(&r, &["show", "HEAD:a.txt"]), "one\ntwo");
99 +
100 +
    // Off: no more updates, the .git stays.
101 +
    assert!(oxy(&r, &home, &["git", "colocate", "--off"]).0);
102 +
    std::fs::write(r.join("b.txt"), "b\n").unwrap();
103 +
    assert!(oxy(&r, &home, &["commit", "-m", "third"]).0);
104 +
    assert_eq!(
105 +
        git(&r, &["log", "--format=%s"]),
106 +
        "second\nfirst",
107 +
        "no longer followed"
108 +
    );
109 +
    assert!(r.join(".git/HEAD").exists());
110 +
}
modified tests/cli_json.rs +118 −0
…
261 unchanged lines
262 262
263 263
/// `oxy commit -F`, `oxy describe` naming and tags as changes (feedback
264 264
/// 4 and 6).
265 +
/// Cylinder C1 (oxygen#108): a directory packs into an artifact of this
266 +
/// repository's chunks, lists and shows as JSON, survives gc, and unpacks.
267 +
#[test]
268 +
fn artifacts_pack_show_survive_gc_and_unpack() {
269 +
    let tmp = tempfile::tempdir().unwrap();
270 +
    let home = tmp.path().join("home");
271 +
    let dir = tmp.path().join("r");
272 +
    std::fs::create_dir_all(&dir).unwrap();
273 +
    assert_eq!(oxy(&dir, &home, &["init"]).0, 0);
274 +
    write(tmp.path(), "out/bin/tool", "#!/bin/sh\necho hi\n");
275 +
    write(tmp.path(), "out/README", "a tool\n");
276 +
    let out = tmp.path().join("out");
277 +
    let pack = [
278 +
        "artifact",
279 +
        "pack",
280 +
        out.to_str().unwrap(),
281 +
        "--kind",
282 +
        "package",
283 +
        "--arch",
284 +
        "x86_64",
285 +
        "--label",
286 +
        "version=0.1.0",
287 +
        "--json",
288 +
    ];
289 +
    let a = json(&dir, &home, &pack);
290 +
    let name = a["name"].as_str().unwrap().to_string();
291 +
    assert_eq!(
292 +
        (
293 +
            a["kind"].as_str(),
294 +
            a["files"].as_u64(),
295 +
            a["labels"]["version"].as_str()
296 +
        ),
297 +
        (Some("package"), Some(2), Some("0.1.0"))
298 +
    );
299 +
    let list = json(&dir, &home, &["artifact", "list", "--json"]);
300 +
    assert_eq!(list[0]["name"], name.as_str());
301 +
    let (code, _, err) = oxy(
302 +
        &dir,
303 +
        &home,
304 +
        &[
305 +
            "artifact",
306 +
            "pack",
307 +
            out.to_str().unwrap(),
308 +
            "--kind",
309 +
            "tarball",
310 +
        ],
311 +
    );
312 +
    assert_ne!(code, 0);
313 +
    assert!(err.contains("runner-image"), "{err}");
314 +
    assert_eq!(oxy(&dir, &home, &["gc", "--now"]).0, 0);
315 +
    let (code, verified, err) = oxy(&dir, &home, &["artifact", "verify", &name[..8]]);
316 +
    assert_eq!(code, 0, "{err}");
317 +
    assert!(String::from_utf8_lossy(&verified).contains("2 files check out"));
318 +
    let back = tmp.path().join("back");
319 +
    assert_eq!(
320 +
        oxy(
321 +
            &dir,
322 +
            &home,
323 +
            &["artifact", "unpack", &name, back.to_str().unwrap()]
324 +
        )
325 +
        .0,
326 +
        0
327 +
    );
328 +
    assert_eq!(
329 +
        std::fs::read_to_string(back.join("bin/tool")).unwrap(),
330 +
        "#!/bin/sh\necho hi\n"
331 +
    );
332 +
}
333 +
334 +
/// oxygen#144: a first identity needs no repository; the next repository
335 +
/// signs as it.
336 +
#[test]
337 +
fn a_first_identity_needs_no_repository() {
338 +
    let tmp = tempfile::tempdir().unwrap();
339 +
    let home = tmp.path().join("home");
340 +
    let nowhere = tmp.path().join("nowhere");
341 +
    std::fs::create_dir_all(&nowhere).unwrap();
342 +
    let local = ["identity", "create", "--local"];
343 +
    let (code, _, err) = oxy(&nowhere, &home, &local);
344 +
    assert_ne!(code, 0, "--local needs a repository");
345 +
    assert!(err.contains("no oxy repository"), "{err}");
346 +
    let ident = [
347 +
        "identity",
348 +
        "create",
349 +
        "--name",
350 +
        "Ada",
351 +
        "--email",
352 +
        "[email protected]",
353 +
    ];
354 +
    let (code, out, err) = oxy(&nowhere, &home, &ident);
355 +
    assert_eq!(code, 0, "{err}");
356 +
    let out = String::from_utf8_lossy(&out);
357 +
    assert!(
358 +
        out.contains("Created identity Ada <[email protected]>"),
359 +
        "{out}"
360 +
    );
361 +
    let user = oxy_core::repo::read_config_file(&home.join("config.toml")).unwrap();
362 +
    let key = user["author.key"].clone();
363 +
    assert_eq!(user["author.name"], "Ada");
364 +
    assert!(home.join("keys").join(format!("{key}.secret")).exists());
365 +
    assert!(!nowhere.join(".oxy").exists(), "no repository made");
366 +
367 +
    // A repository made afterwards signs its changes with it.
368 +
    let dir = tmp.path().join("r");
369 +
    std::fs::create_dir_all(&dir).unwrap();
370 +
    assert_eq!(oxy(&dir, &home, &["init"]).0, 0);
371 +
    assert_eq!(oxy(&dir, &home, &["config", "core.sign", "change"]).0, 0);
372 +
    write(&dir, "a.txt", "a\n");
373 +
    let (code, _, err) = oxy(&dir, &home, &["commit", "-m", "First"]);
374 +
    assert_eq!(code, 0, "{err}");
375 +
    let log = json(&dir, &home, &["log", "--json", "-n", "2"]);
376 +
    let first = &log["changes"][1];
377 +
    assert_eq!(first["message"], "First");
378 +
    assert_eq!(first["signature"]["status"], "signed", "{first}");
379 +
    assert_eq!(first["signature"]["key"], key.as_str());
380 +
    assert_eq!(first["signature"]["name"], "Ada");
381 +
}
382 +
265 383
#[test]
266 384
fn commit_from_a_file_and_describe_for_builds() {
267 385
    let tmp = tempfile::tempdir().unwrap();
…
500 unchanged lines
added tests/cli_mcp.rs +231 −0
1 +
//! `oxy mcp` (oxygen#159) end to end: an MCP client talks to the server over
2 +
//! stdio, lists its tools, reads the repository, commits by path as the
3 +
//! configured identity, and is refused what it may not do.
4 +
5 +
use serde_json::{json, Value};
6 +
use std::io::{BufRead, BufReader, Write};
7 +
use std::path::Path;
8 +
use std::process::{Command, Stdio};
9 +
10 +
fn oxy(dir: &Path, home: &Path, args: &[&str]) -> i32 {
11 +
    Command::new(env!("CARGO_BIN_EXE_oxy"))
12 +
        .arg("--plain")
13 +
        .args(args)
14 +
        .current_dir(dir)
15 +
        .env("OXY_HOME", home)
16 +
        .env("NO_COLOR", "1")
17 +
        .status()
18 +
        .unwrap()
19 +
        .code()
20 +
        .unwrap_or(-1)
21 +
}
22 +
23 +
/// Send each request in turn and collect the replies, one per request (the
24 +
/// notification gets none).
25 +
fn session(dir: &Path, home: &Path, extra: &[&str], requests: &[Value]) -> Vec<Value> {
26 +
    let mut child = Command::new(env!("CARGO_BIN_EXE_oxy"))
27 +
        .arg("mcp")
28 +
        .args(extra)
29 +
        .current_dir(dir)
30 +
        .env("OXY_HOME", home)
31 +
        .env("NO_COLOR", "1")
32 +
        .stdin(Stdio::piped())
33 +
        .stdout(Stdio::piped())
34 +
        .stderr(Stdio::null())
35 +
        .spawn()
36 +
        .unwrap();
37 +
    let mut stdin = child.stdin.take().unwrap();
38 +
    let mut stdout = BufReader::new(child.stdout.take().unwrap());
39 +
    let mut replies = vec![];
40 +
    for r in requests {
41 +
        writeln!(stdin, "{r}").unwrap();
42 +
        stdin.flush().unwrap();
43 +
        if r.get("id").is_some() {
44 +
            let mut line = String::new();
45 +
            stdout.read_line(&mut line).unwrap();
46 +
            replies.push(
47 +
                serde_json::from_str(&line).unwrap_or_else(|e| panic!("not JSON ({e}): {line}")),
48 +
            );
49 +
        }
50 +
    }
51 +
    drop(stdin);
52 +
    assert!(
53 +
        child.wait().unwrap().success(),
54 +
        "the server ends cleanly when its input does"
55 +
    );
56 +
    replies
57 +
}
58 +
59 +
fn call(id: u64, name: &str, args: Value) -> Value {
60 +
    json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": args}})
61 +
}
62 +
63 +
#[test]
64 +
fn an_agent_reads_and_commits_through_mcp() {
65 +
    let tmp = tempfile::tempdir().unwrap();
66 +
    let home = tmp.path().join("home");
67 +
    std::fs::create_dir_all(&home).unwrap();
68 +
    let repo = tmp.path().join("repo");
69 +
    assert_eq!(oxy(tmp.path(), &home, &["init", repo.to_str().unwrap()]), 0);
70 +
    assert_eq!(
71 +
        oxy(
72 +
            &repo,
73 +
            &home,
74 +
            &[
75 +
                "identity",
76 +
                "create",
77 +
                "--name",
78 +
                "Ada",
79 +
                "--email",
80 +
                "[email protected]"
81 +
            ]
82 +
        ),
83 +
        0
84 +
    );
85 +
    assert_eq!(
86 +
        oxy(&repo, &home, &["config", "--user", "core.sign", "change"]),
87 +
        0
88 +
    );
89 +
    std::fs::write(repo.join("a.txt"), "one\n").unwrap();
90 +
    std::fs::write(repo.join("b.txt"), "two\n").unwrap();
91 +
92 +
    let r = session(
93 +
        &repo,
94 +
        &home,
95 +
        &[],
96 +
        &[
97 +
            json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}}),
98 +
            json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
99 +
            json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
100 +
            call(3, "status", json!({})),
101 +
            call(4, "commit", json!({"message": "everything"})),
102 +
            call(5, "commit", json!({"message": "Add a", "paths": ["a.txt"]})),
103 +
            call(6, "log", json!({"limit": 5})),
104 +
            call(7, "status", json!({})),
105 +
            call(8, "undo", json!({})),
106 +
            call(9, "push", json!({"force": true})),
107 +
            call(10, "cat", json!({"path": "a.txt", "change": "@"})),
108 +
        ],
109 +
    );
110 +
111 +
    let init = &r[0]["result"];
112 +
    assert_eq!(init["serverInfo"]["name"], "oxy");
113 +
    let said = init["instructions"].as_str().unwrap();
114 +
    assert!(
115 +
        said.contains("Ada"),
116 +
        "initialize says who changes are signed as: {said}"
117 +
    );
118 +
    assert!(
119 +
        said.contains("Destructive tools (undo, forced push) are off"),
120 +
        "{said}"
121 +
    );
122 +
123 +
    let names: Vec<&str> = r[1]["result"]["tools"]
124 +
        .as_array()
125 +
        .unwrap()
126 +
        .iter()
127 +
        .map(|t| t["name"].as_str().unwrap())
128 +
        .collect();
129 +
    for t in [
130 +
        "status",
131 +
        "diff",
132 +
        "log",
133 +
        "show",
134 +
        "cat",
135 +
        "blame",
136 +
        "bookmarks",
137 +
        "commit",
138 +
        "amend",
139 +
        "push",
140 +
        "pull",
141 +
        "tlog_verify",
142 +
    ] {
143 +
        assert!(names.contains(&t), "{t} is offered: {names:?}");
144 +
    }
145 +
    assert!(!names.contains(&"undo"), "undo needs --allow-destructive");
146 +
147 +
    let before = &r[2]["result"];
148 +
    assert_eq!(before["isError"], false, "{before}");
149 +
    assert!(
150 +
        before["structuredContent"].is_object(),
151 +
        "status comes back as JSON: {before}"
152 +
    );
153 +
    assert!(before["content"][0]["text"]
154 +
        .as_str()
155 +
        .unwrap()
156 +
        .contains("b.txt"));
157 +
158 +
    assert_eq!(
159 +
        r[3]["result"]["isError"], true,
160 +
        "a commit names its paths or all: {}",
161 +
        r[3]
162 +
    );
163 +
    assert!(r[3]["result"]["content"][0]["text"]
164 +
        .as_str()
165 +
        .unwrap()
166 +
        .contains("all: true"));
167 +
168 +
    assert_eq!(r[4]["result"]["isError"], false, "commit by path: {}", r[4]);
169 +
170 +
    let log = r[5]["result"]["content"][0]["text"].as_str().unwrap();
171 +
    assert!(log.contains("Add a"), "{log}");
172 +
    assert!(
173 +
        r[5]["result"]["structuredContent"].is_object(),
174 +
        "a JSON array comes wrapped: {}",
175 +
        r[5]
176 +
    );
177 +
178 +
    let after = r[6]["result"]["content"][0]["text"].as_str().unwrap();
179 +
    assert!(
180 +
        after.contains("b.txt") && !after.contains("a.txt"),
181 +
        "only a.txt went in: {after}"
182 +
    );
183 +
184 +
    assert!(
185 +
        r[7]["error"]["message"]
186 +
            .as_str()
187 +
            .unwrap()
188 +
            .contains("no tool named undo"),
189 +
        "{}",
190 +
        r[7]
191 +
    );
192 +
    assert_eq!(r[8]["result"]["isError"], true);
193 +
    assert!(r[8]["result"]["content"][0]["text"]
194 +
        .as_str()
195 +
        .unwrap()
196 +
        .contains("--allow-destructive"));
197 +
    assert_eq!(r[9]["result"]["content"][0]["text"], "one\n");
198 +
}
199 +
200 +
#[test]
201 +
fn destructive_tools_come_with_the_flag_and_a_bad_directory_is_refused() {
202 +
    let tmp = tempfile::tempdir().unwrap();
203 +
    let home = tmp.path().join("home");
204 +
    std::fs::create_dir_all(&home).unwrap();
205 +
    let repo = tmp.path().join("repo");
206 +
    assert_eq!(oxy(tmp.path(), &home, &["init", repo.to_str().unwrap()]), 0);
207 +
    let r = session(
208 +
        &repo,
209 +
        &home,
210 +
        &["--allow-destructive"],
211 +
        &[json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})],
212 +
    );
213 +
    let undo = r[0]["result"]["tools"]
214 +
        .as_array()
215 +
        .unwrap()
216 +
        .iter()
217 +
        .find(|t| t["name"] == "undo")
218 +
        .cloned();
219 +
    assert_eq!(
220 +
        undo.expect("undo is offered")["annotations"]["destructiveHint"],
221 +
        true
222 +
    );
223 +
224 +
    let out = Command::new(env!("CARGO_BIN_EXE_oxy"))
225 +
        .args(["mcp", tmp.path().to_str().unwrap()])
226 +
        .env("OXY_HOME", &home)
227 +
        .output()
228 +
        .unwrap();
229 +
    assert!(!out.status.success());
230 +
    assert!(String::from_utf8_lossy(&out.stderr).contains("not the root of an Oxygen repository"));
231 +
}
added tests/cli_subrepo.rs +94 −0
1 +
//! oxygen#162: a subrepo pin moved by work inside the subrepo is a change
2 +
//! the parent's next commit records; `oxy status` and `oxy commit` say so,
3 +
//! and say when a subrepo holds uncommitted edits of its own.
4 +
5 +
use std::path::Path;
6 +
use std::process::Command;
7 +
8 +
fn oxy(dir: &Path, home: &Path, args: &[&str]) -> (i32, String) {
9 +
    let out = Command::new(env!("CARGO_BIN_EXE_oxy"))
10 +
        .arg("--plain")
11 +
        .args(args)
12 +
        .current_dir(dir)
13 +
        .env("OXY_HOME", home)
14 +
        .env("NO_COLOR", "1")
15 +
        .output()
16 +
        .unwrap();
17 +
    let text = format!(
18 +
        "{}{}",
19 +
        String::from_utf8_lossy(&out.stdout),
20 +
        String::from_utf8_lossy(&out.stderr)
21 +
    );
22 +
    (out.status.code().unwrap_or(-1), text)
23 +
}
24 +
25 +
fn ok(dir: &Path, home: &Path, args: &[&str]) -> String {
26 +
    let (code, out) = oxy(dir, home, args);
27 +
    assert_eq!(code, 0, "oxy {args:?}: {out}");
28 +
    out
29 +
}
30 +
31 +
#[test]
32 +
fn moved_pins_and_edits_inside_subrepos_are_named() {
33 +
    let tmp = tempfile::tempdir().unwrap();
34 +
    let t = tmp.path();
35 +
    let home = t.join("home");
36 +
    std::fs::create_dir_all(&home).unwrap();
37 +
    ok(
38 +
        t,
39 +
        &home,
40 +
        &[
41 +
            "identity",
42 +
            "create",
43 +
            "--name",
44 +
            "Ada",
45 +
            "--email",
46 +
            "[email protected]",
47 +
        ],
48 +
    );
49 +
    // The subrepo's own repository, with one change.
50 +
    let lib = t.join("lib");
51 +
    ok(t, &home, &["init", lib.to_str().unwrap()]);
52 +
    std::fs::write(lib.join("lib.txt"), "one\n").unwrap();
53 +
    ok(&lib, &home, &["commit", "-m", "lib one"]);
54 +
    // The parent pins it.
55 +
    let app = t.join("app");
56 +
    ok(t, &home, &["init", app.to_str().unwrap()]);
57 +
    std::fs::write(app.join("app.txt"), "app\n").unwrap();
58 +
    ok(
59 +
        &app,
60 +
        &home,
61 +
        &["submodule", "add", lib.to_str().unwrap(), "deps/lib"],
62 +
    );
63 +
    ok(&app, &home, &["commit", "-m", "app with lib"]);
64 +
    let quiet = ok(&app, &home, &["status"]);
65 +
    assert!(!quiet.contains("Subrepo pins"), "{quiet}");
66 +
67 +
    // Edits inside the subrepo, not committed there: named, not a pin move.
68 +
    let inner = app.join("deps/lib");
69 +
    std::fs::write(inner.join("lib.txt"), "two\n").unwrap();
70 +
    let st = ok(&app, &home, &["status"]);
71 +
    assert!(
72 +
        st.contains("Subrepo pins") && st.contains("uncommitted edits inside it"),
73 +
        "{st}"
74 +
    );
75 +
    assert!(!st.contains("pin moved"), "{st}");
76 +
77 +
    // Committed inside: the pin has moved, and a bare commit says it records it.
78 +
    ok(&inner, &home, &["commit", "-m", "lib two"]);
79 +
    let st = ok(&app, &home, &["status"]);
80 +
    assert!(st.contains("deps/lib") && st.contains("pin moved"), "{st}");
81 +
    std::fs::write(app.join("app.txt"), "app 2\n").unwrap();
82 +
    let by_path = ok(&app, &home, &["commit", "-m", "app only", "app.txt"]);
83 +
    assert!(
84 +
        !by_path.contains("recorded subrepo"),
85 +
        "a commit by path leaves the pin out: {by_path}"
86 +
    );
87 +
    let bare = ok(&app, &home, &["commit", "-m", "everything"]);
88 +
    assert!(
89 +
        bare.contains("recorded subrepo deps/lib's pin moving") && bare.contains("a pin bump"),
90 +
        "{bare}"
91 +
    );
92 +
    let after = ok(&app, &home, &["status"]);
93 +
    assert!(!after.contains("Subrepo pins"), "recorded: {after}");
94 +
}