Compare main … carry-identical
0 changes in carry-identical not in main; 137 the other way. 107 files differ between the two states.
removed .config/nextest.toml +0 −8
| 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" |
removed .flow/checks.wasm +0 −0
No line changes.
modified .flow/pipeline.kdl +6 −102
| … | 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 | - | ||
| 23 | 6 | // The OxygenFlow build image: Rust with rustfmt and clippy, Node, and every |
|
| 24 | 7 | // crate of Cargo.lock vendored (oxygenflow/images/rust). Rebuild and re-pin |
|
| 25 | 8 | // it when Cargo.lock changes. |
|
| 26 | 9 | sandbox "rust" { |
|
| 27 | 10 | runtime "microvm" |
|
| 28 | - | rootfs "cas://blob/836461b613019f62399a960c61a25c482eb74e30054ff2cd44d5594c3305872b" |
|
| 11 | + | rootfs "cas://blob/7705d699a4fc06862e4297b7069396cf3b5b35b1d3f15191c46dc2031af17133" |
|
| 29 | 12 | memory-limit "8GiB" |
|
| 30 | 13 | cpu-limit 4 |
|
| 31 | 14 | // Tests serve and connect on 127.0.0.1; nothing leaves the guest. |
|
| … | 10 unchanged lines |
||
| 42 | 25 | ||
| 43 | 26 | sandbox "rust-small" { |
|
| 44 | 27 | runtime "microvm" |
|
| 45 | - | rootfs "cas://blob/836461b613019f62399a960c61a25c482eb74e30054ff2cd44d5594c3305872b" |
|
| 28 | + | rootfs "cas://blob/7705d699a4fc06862e4297b7069396cf3b5b35b1d3f15191c46dc2031af17133" |
|
| 46 | 29 | memory-limit "1GiB" |
|
| 47 | 30 | cpu-limit 1 |
|
| 48 | 31 | network "isolated" |
|
| 49 | - | } |
|
| 50 | - | ||
| 51 | - | // The riscv64 build image on narvi-1 (oxygenflow#69): Ubuntu 24.04 riscv64 |
|
| 52 | - | // with Rust 1.97.1 and the same vendored crates, in the syscall tier (the |
|
| 53 | - | // board has no hardware virtualisation, so no microVM). `runs-on` keeps it |
|
| 54 | - | // on a RISC-V runner. |
|
| 55 | - | sandbox "rust-riscv64" { |
|
| 56 | - | runtime "syscall" |
|
| 57 | - | rootfs "cas://blob/b4edba9173ddf63aa1df46222e6a59b265071b19f2815c89d0153b593835c616" |
|
| 58 | - | runs-on "riscv64" |
|
| 59 | - | memory-limit "4GiB" |
|
| 60 | - | cpu-limit 6 |
|
| 61 | - | network "loopback" |
|
| 62 | - | env { |
|
| 63 | - | CARGO_INCREMENTAL "0" |
|
| 64 | - | CARGO_PROFILE_RELEASE_DEBUG "0" |
|
| 65 | - | CARGO_BUILD_JOBS "4" |
|
| 66 | - | } |
|
| 67 | 32 | } |
|
| 68 | 33 | ||
| 69 | 34 | job "fmt" { |
|
| … | 8 unchanged lines |
||
| 78 | 43 | source "ephemeral://disk" |
|
| 79 | 44 | target "/src/target" |
|
| 80 | 45 | size "24GiB" |
|
| 81 | - | // Kept from this job's last passing run (oxygenflow#28): cargo |
|
| 82 | - | // builds only what changed since. |
|
| 83 | - | cache "target" |
|
| 84 | - | key "Cargo.lock" |
|
| 85 | - | } |
|
| 86 | - | // One build identity for every CI state, so the cached target stays |
|
| 87 | - | // fresh from one push to the next (oxygenflow#52). |
|
| 88 | - | env { |
|
| 89 | - | OXY_BUILD "ci" |
|
| 90 | 46 | } |
|
| 91 | 47 | exec "sh" ".flow/warm-target.sh" "cargo" "clippy" "--workspace" "--all-targets" "--locked" "--" "-D" "warnings" |
|
| 92 | 48 | } |
|
| 93 | 49 | ||
| 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. |
|
| 50 | + | // The tests, run under cargo-llvm-cov so the hub's Coverage tab |
|
| 51 | + | // (oxygenhub#113) gets a report: the same tests, instrumented, and an LCOV |
|
| 52 | + | // file kept as the job's `coverage` artifact. |
|
| 97 | 53 | job "test" { |
|
| 98 | 54 | sandbox "rust" |
|
| 99 | 55 | depends-on "fmt" |
|
| … | 1 unchanged lines |
||
| 101 | 57 | source "ephemeral://disk" |
|
| 102 | 58 | target "/src/target" |
|
| 103 | 59 | size "24GiB" |
|
| 104 | - | // Kept from this job's last passing run (oxygenflow#28): cargo |
|
| 105 | - | // builds only what changed since. |
|
| 106 | - | cache "target" |
|
| 107 | - | key "Cargo.lock" |
|
| 108 | 60 | } |
|
| 109 | - | // One build identity for every CI state, so the cached target stays |
|
| 110 | - | // fresh from one push to the next (oxygenflow#52). |
|
| 111 | - | env { |
|
| 112 | - | OXY_BUILD "ci" |
|
| 113 | - | } |
|
| 114 | - | exec "cargo" "llvm-cov" "nextest" "--workspace" "--locked" "--profile" "ci" "--lcov" "--output-path" "/src/target/lcov.info" |
|
| 61 | + | exec "cargo" "llvm-cov" "--workspace" "--locked" "--lcov" "--output-path" "/src/target/lcov.info" |
|
| 115 | 62 | output { |
|
| 116 | - | artifact "junit" { |
|
| 117 | - | path "/src/target/nextest/ci/junit.xml" |
|
| 118 | - | when "always" |
|
| 119 | - | } |
|
| 120 | 63 | artifact "coverage" { |
|
| 121 | 64 | path "/src/target/lcov.info" |
|
| 122 | 65 | } |
|
| … | 7 unchanged lines |
||
| 130 | 73 | source "ephemeral://disk" |
|
| 131 | 74 | target "/src/target" |
|
| 132 | 75 | size "24GiB" |
|
| 133 | - | // Kept from this job's last passing run (oxygenflow#28): cargo |
|
| 134 | - | // builds only what changed since. |
|
| 135 | - | cache "target" |
|
| 136 | - | key "Cargo.lock" |
|
| 137 | 76 | } |
|
| 138 | 77 | exec "sh" ".flow/warm-target.sh" "cargo" "build" "--release" "--locked" |
|
| 139 | 78 | // The binary, kept as a blob of this repository and named by the |
|
| … | 19 unchanged lines |
||
| 159 | 98 | source "ephemeral://disk" |
|
| 160 | 99 | target "/src/target" |
|
| 161 | 100 | size "24GiB" |
|
| 162 | - | // Kept from this job's last passing run (oxygenflow#28): cargo |
|
| 163 | - | // builds only what changed since. |
|
| 164 | - | cache "target" |
|
| 165 | - | key "Cargo.lock" |
|
| 166 | 101 | } |
|
| 167 | 102 | env { |
|
| 168 | 103 | DIST_FLAT "/src/target/dist-flat" |
|
| … | 27 unchanged lines |
||
| 196 | 131 | } |
|
| 197 | 132 | artifact "oxy_riscv64.deb" { |
|
| 198 | 133 | path "/src/target/dist-flat/oxy_riscv64.deb" |
|
| 199 | - | } |
|
| 200 | - | artifact "oxy-src.tar.gz" { |
|
| 201 | - | path "/src/target/dist-flat/oxy-src.tar.gz" |
|
| 202 | 134 | } |
|
| 203 | 135 | artifact "SHA256SUMS" { |
|
| 204 | 136 | path "/src/target/dist-flat/SHA256SUMS" |
|
| 205 | - | } |
|
| 206 | - | } |
|
| 207 | - | } |
|
| 208 | - | ||
| 209 | - | // The native RISC-V check (oxygenflow#69): oxy built and used on narvi-1, |
|
| 210 | - | // a real riscv64 board, so each release is known to run there and not |
|
| 211 | - | // only to cross-compile (dist). Like dist, a push runs it only when it |
|
| 212 | - | // touches these paths; "Run pipeline" and reruns always do. Its target |
|
| 213 | - | // directory is cached between passing runs (oxygenflow#70). |
|
| 214 | - | job "riscv64" { |
|
| 215 | - | sandbox "rust-riscv64" |
|
| 216 | - | depends-on "fmt" |
|
| 217 | - | scope "Cargo.toml" "scripts/dist.sh" "packaging/**" ".flow/riscv64-check.sh" |
|
| 218 | - | timeout "3h" |
|
| 219 | - | mount { |
|
| 220 | - | source "ephemeral://disk" |
|
| 221 | - | target "/src/target" |
|
| 222 | - | size "16GiB" |
|
| 223 | - | // Kept from this job's last passing run in the syscall tier too |
|
| 224 | - | // (oxygenflow#70): the first build is cold (~40 min on narvi-1), |
|
| 225 | - | // later ones build only what changed. |
|
| 226 | - | cache "target" |
|
| 227 | - | key "Cargo.lock" |
|
| 228 | - | } |
|
| 229 | - | exec "sh" ".flow/riscv64-check.sh" |
|
| 230 | - | output { |
|
| 231 | - | artifact "oxy-linux-riscv64-native" { |
|
| 232 | - | path "/src/target/release/oxy" |
|
| 233 | 137 | } |
|
| 234 | 138 | } |
|
| 235 | 139 | } |
removed .flow/riscv64-check.sh +0 −29
| 1 | - | #!/bin/sh |
|
| 2 | - | # The native RISC-V check (oxygenflow#69): on narvi-1, a riscv64 board, |
|
| 3 | - | # build oxy natively and use it, so a release is known to work on real |
|
| 4 | - | # RISC-V hardware, not only to cross-compile for it (dist does that). |
|
| 5 | - | set -eu |
|
| 6 | - | uname -m |
|
| 7 | - | cargo build --release --locked -p oxy |
|
| 8 | - | oxy=/src/target/release/oxy |
|
| 9 | - | "$oxy" --version |
|
| 10 | - | # A repository's life in miniature: an identity, a signed change, a |
|
| 11 | - | # bookmark, a merge, and a full integrity check. |
|
| 12 | - | export OXY_HOME="$(mktemp -d)" |
|
| 13 | - | cd "$(mktemp -d)" |
|
| 14 | - | "$oxy" --plain init |
|
| 15 | - | "$oxy" --plain identity create --name "narvi-1 check" --email [email protected] |
|
| 16 | - | "$oxy" --plain config --user core.sign change |
|
| 17 | - | echo one > a.txt |
|
| 18 | - | "$oxy" --plain commit -m "first" |
|
| 19 | - | "$oxy" --plain bookmark create side |
|
| 20 | - | "$oxy" --plain bookmark switch side |
|
| 21 | - | echo two > b.txt |
|
| 22 | - | "$oxy" --plain commit -m "second, on side" |
|
| 23 | - | "$oxy" --plain bookmark switch main |
|
| 24 | - | "$oxy" --plain merge side |
|
| 25 | - | "$oxy" --plain log |
|
| 26 | - | "$oxy" --plain verify |
|
| 27 | - | "$oxy" --plain fsck |
|
| 28 | - | test -f b.txt |
|
| 29 | - | echo "oxy works natively on $(uname -m)" |
removed .oxyattributes +0 −2
| 1 | - | # oxygen#145: two changes adding changelog entries keep both. |
|
| 2 | - | CHANGELOG.md merge=union |
modified CHANGELOG.md +2 −144
| 1 | 1 | # Changelog |
|
| 2 | 2 | ||
| 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. |
|
| 3 | + | ## Unreleased |
|
| 45 | 4 | ||
| 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. |
|
| 74 | - | ||
| 75 | - | ## 0.5.0 (2026-10-20) |
|
| 76 | - | ||
| 77 | - | - **A repository kept in a bucket finds signatures and seals without a LIST each (#116).** Every lookup of an object's signatures, seals, receipts, approvals or certificate listed a prefix in the bucket, and most of these lookups found nothing. Writers now also add each such entry to a journal (`pack-entries`, updated by compare-and-swap), and `oxy gc` folds them all into one list. Once a store has been folded, a reader reads the lists and the journal when it opens it, and again after 30 seconds (as long as the pack listing is kept), and looks everything up from them. Another writer's entry is seen within 30 seconds, as its pack would be, even by a handle a server keeps open. The first `oxy gc` also copies the one-list keys of much older stores (`sigs/`, `seals/`, `certs/`, …) into the current layout and marks them folded, so readers stop requesting them; until now nearly every lookup made one of these requests and found nothing. On the test store, 40 lookups went from 85 requests (40 LISTs) to 7 (one journal read and no LIST). Older readers are unaffected, because every entry still has its own key. Entries written by oxy 0.4.6 or older after the fold are missed until the next `oxy gc`, so upgrade every client that writes to the bucket first, then run `oxy gc` once. A server's `/health` counts journal reads as `read_journal`. A repository's own directory works as before. |
|
| 78 | - | ||
| 79 | - | - **A server can refuse a push by what it adds (oxygenhub#268).** `oxy-server`'s `Backend` has a `vet_push` step, called with the push's changes before anything is stored or moved; OxygenHub uses it for push protection (secret scanning). The refusal reaches `oxy push` as the server's own text, several lines included, and a refusal that mentions a certificate or a timeout no longer picks up a misleading hint. |
|
| 80 | - | ||
| 81 | - | - **Fetch checks the hub's transparency log (oxygenhub#238).** A hub that keeps a log sends its latest checkpoint with every refs answer, and each bookmark's attestation with its log entry and inclusion proof. The first fetch pins the hub's log key in `<oxy home>/tlog/<hub>` with the last checkpoint seen. A later checkpoint must be signed by that key and extend the last one: a smaller log, another root at the same size or a failed consistency proof fails the fetch, saying the hub showed two histories. An attestation not logged yet, or not proved, is only a note for now, printed by `fetch` and `pull` and in `--json`. A hub that comes back with a new log key (a new key, a reused address, a rename) is a loud note, and the new key is pinned. Older hubs and clients ignore the new fields. |
|
| 82 | - | ||
| 83 | - | - **`oxy tlog verify` audits a hub's whole log (oxygenhub#238).** It accepts the hub's checkpoint by the same rules as fetch, downloads every entry and requires them to make the root the checkpoint signs, then lists this repository's entries, a key's (`--key`) or all of them (`--all`); `--json` too. It also shows which witnesses cosigned and when. |
|
| 84 | - | ||
| 85 | - | - **Trusted time from witnesses (oxygenhub#240).** An entry's integrated time is the earliest time a trusted witness cosigned a checkpoint that holds it, by the witness's clock, so a hub can only make it later, never earlier. A fetch pins the hub's witnesses beside its log key (`<oxy home>/tlog/<hub>.witnesses`; a new or changed witness key is a note), counts a cosignature only when it verifies against a pinned key, and checks a delegate's delegation and a short-lived key's certificate at that time. Without one it checks them as before, at the hub's acceptance or the attestation's own time. A claimed time more than 10 minutes off the integrated time is noted, not refused, for now. The wire change is additive. |
|
| 86 | - | ||
| 87 | - | - **`oxy witness serve`: a witness anyone can run (oxygenhub#238).** `oxy witness serve --name HOST --log ORIGIN=FILE …` serves the C2SP tlog-witness `add-checkpoint` call. It cosigns, with this oxy home's key and its own clock, each checkpoint that verifies with the log's key and extends the last one it cosigned, and refuses a fork. What it cosigned is kept per log in `--state`, so it survives a restart. |
|
| 88 | - | ||
| 89 | - | - **`oxy show --stat` (#119).** Lists the files a change touched instead of its diff: each path with lines added and removed and its kind, renames as from → to, and a total. `--json` gives each file's counts. |
|
| 90 | - | ||
| 91 | - | - **`oxy merge` takes a remote's bookmark (#117).** `oxy merge oxygenhub/develop` said "no bookmark": merge looked only at local bookmarks, so merging what a remote has meant switching to it, pulling and switching back. It now takes a remote's tracking bookmark as last fetched, as revsets do, and says where it looked when neither exists. |
|
| 92 | - | ||
| 93 | - | - **Commit a resolution by paths, and amend only some paths (#118).** A conflict resolution can be committed by paths, leaving other work uncommitted on top of the resolved state, as long as the paths include every file it resolves, whole; otherwise the refusal names them. `oxy amend PATH…` folds only those paths into the head and leaves the rest uncommitted. |
|
| 94 | - | ||
| 95 | - | - **`oxy doctor` checks the trust policy (#127).** A repository with no policy was "Everything looks right" while every fetch warned that nothing was verified. Doctor now has a trust line: no policy is a warning that says who sets one and how; a policy shows its serial, roots, rules and expiry, warns within 30 days of expiry and fails once it has expired. |
|
| 96 | - | ||
| 97 | - | - **The same file added on both sides is one file (#101).** The same path added on two bookmarks with identical content (files, symlinks, or subrepos at the same pin) now merges with no conflict and nothing to resolve. Every peer and the hub show it the same way whatever order the changes came in, and it stays one file through later edits, moves, removals and partial commits. Different content or a different mode is still a name conflict, and directories are not unified. The stored and signed formats are unchanged. |
|
| 98 | - | ||
| 99 | - | - **A bookmark nobody can verify no longer breaks every fetch (oxygenhub#266, oxygenhub#271).** A delegate or short-lived key that moved a bookmark without signing any of its changes left every client failing the whole fetch with "unknown key". A fetch now carries the identities and certificates of the keys that signed every bookmark's latest attestation and every tag's, so clients, older ones included, can verify the move. A client that still cannot verify a bookmark other than the current one leaves that bookmark as it was, warns naming it and why, and fetches the rest. The current bookmark (the one a pull takes or a clone checks out) still fails, as does a missing, unauthorised or rolled-back attestation. The signers are gathered once per operation, not on every fetch call: a repository in a bucket with 145 bookmarks had taken 14 s a fetch. |
|
| 100 | - | ||
| 101 | - | - **Readers never wait for a push or gc (oxygenhub#249).** A repository can now be read safely while another process writes it. A handle reads the operation head once and keeps it, so one request never mixes two views. A read that misses reloads the pack list and tries once more. A repack retires the packs and loose objects it replaces instead of deleting them, and `gc` deletes them only an hour later; `oxy gc` says what it deleted or kept for readers. A forge no longer needs to hold readers back while it pushes or collects garbage. |
|
| 102 | - | ||
| 103 | - | - **A repository kept in a bucket lists its packs less often (#116).** Every open listed the bucket's packs, and a forge opens the store once per request. The pack listing is now kept for 30 seconds and forgotten the moment this process writes or deletes a pack, so its own packs are never hidden; another writer's new pack is seen within 30 seconds. Opening a store no longer sends a request per pack, and a bucket store's stat is a HEAD of the key, not a listing. A server's `/health` counts pack listings, kept listings and other listings apart. |
|
| 104 | - | ||
| 105 | - | - **Text that looks like a conflict marker no longer blocks a resolution (#126).** A file that showed oxy's own conflict markers, such as documentation or a test fixture, could never be committed as resolved ("markers remain"). A file now still has markers only when it holds a marker line oxy wrote for its conflicts. A real marker left behind is still caught. |
|
| 106 | - | ||
| 107 | - | - **A carried switch no longer says carried files were removed (#120).** After a switch that carried the working copy's changes, oxy said new files were "removed from disk" although they were still there. Carried files are now left out of what was removed, and the switch names what it carried. |
|
| 108 | - | ||
| 109 | - | ## 0.4.6 (2026-10-17) |
|
| 110 | - | ||
| 111 | - | - **A keyless agent's grant token is its only credential (oxygenhub#241).** A home holding an agent grant token and no hub token now offers the grant to that hub, which accepts it for fetch and push as an agent's token of the owner's account (writing when the grant gives `refs`). The agent pushes its own work, so its seal is its own; it needs no personal access token. |
|
| 5 | + | - **`--carry` takes work the target already has (#100).** Switching bookmarks or checking out with `--carry` refused when uncommitted files were byte-for-byte what the target state holds, as after a proposal of that work landed, leaving only `--discard`. Such work is now already applied: no conflict, nothing to carry, and the rest of the work comes along. |
|
| 112 | 6 | ||
| 113 | - | - **Agents' signatures say whose they are.** A short-lived key is named by the subject it was certified for, so `oxy log` shows `via agent:claude@andymccall` and `oxy verify` shows `signed by agent:claude@andymccall`, rather than the author's name with an unfamiliar key. |
|
| 114 | - | ||
| 115 | - | - **`oxy config --unset core.sign` works.** It failed with "core.sign must be change or seal"; unsetting now returns to the default. |
|
| 116 | - | ||
| 117 | - | ## 0.4.5 (2026-10-17) |
|
| 118 | - | ||
| 119 | - | - **Agents sign with no key of their own (oxygenhub#241).** With `author.signer = keyless`, oxy signs with a fresh key the hub certifies for ten minutes whenever it must sign, kept in memory only. The agent proves itself with a grant token its owner made with `oh agent grant NAME` and `oxy auth grant` keeps. The owner's own key signs the grant, which travels inside every certificate issued under it, so the agent's commits count in the owner's name without trusting the hub for authorship. The owner revokes the grant on the hub, and there is no key left to rotate or leak. `keyless.hub` names the hub (default: the default remote's). |
|
| 120 | - | ||
| 121 | - | - **Certificates for short-lived keys (spec §14.7).** A hub's key certifies a fresh key for a subject such as `repo:OWNER/NAME:bookmark:B` or `agent:NAME@ACCOUNT`, with a scope and a window of at most an hour. Pushes and fetches carry the certificates of the keys that signed what they bring. `oxy receipt verify` reads receipts that OxygenFlow runners sign with such keys. |
|
| 122 | - | ||
| 123 | - | - **Policies grant subjects an issuer certifies.** `oxy policy grant-subject SUBJECT --issuer IDENTITY --scope SCOPES` lets keys certified for a subject sign changes and move bookmarks. A push is checked at the receiver's clock. A fetch is checked at the time the issuer says it accepted the update, a countersigned `accepted` attestation the hub serves with the bookmark, so an expired key cannot backdate one. |
|
| 124 | - | ||
| 125 | - | - **A push seals for the author it signs for.** A delegate or a certified key set as `author.signer` now seals the author's unsigned changes at push, so it no longer has to sign each change at commit (`core.sign = change`). |
|
| 126 | - | ||
| 127 | - | - **gc keeps short-lived keys' certificates** for every change and attestation it keeps, not only receipts', so a fresh clone can still check a certified bookmark after a gc. |
|
| 128 | - | ||
| 129 | - | ## 0.4.4 (2026-10-17) |
|
| 130 | - | ||
| 131 | - | - **A fresh clone takes tags made before a key rotation (#114).** A tag's signature was checked against the current trust policy with no regard for rotation, so every tag signed by a key since rotated away from was refused, and a fresh `oxy clone` of a repository with such a tag failed (this repository's own v0.1.0). A tag signed by a predecessor of a key the policy names is accepted when it is dated before that key's successor was made; a superseded key makes no new ones, and revoked keys never count. |
|
| 132 | - | ||
| 133 | - | - **SLSA provenance of a receipt (oxygenhub#239).** `oxy receipt export --format slsa` writes what a valid receipt says as an in-toto Statement with an SLSA provenance v1 predicate, in a DSSE envelope signed with your key, the runner's signed receipt riding along inside it. `oxy identity export --pem` writes the public key cosign and other DSSE verifiers check it with. |
|
| 134 | - | ||
| 135 | - | - **Repositories kept in a bucket answer quickly (oxygenhub#228, #234).** A forge serving a repository from GCS or S3 opened a new client for every request, read large packs by range without keeping what it read, asked the bucket for a loose copy of every packed object it read, and asked again and again about objects it lacked. Clients are now shared per bucket and credentials, ranges and sizes are kept on disk, packs are looked in first, and a key found missing is taken as missing for 30 seconds unless this process writes it. On a hosted repository in GCS, negotiate went from 7-10 s to well under a second and bucket requests from about 540 a minute to about 30. The store counts its requests by kind, for a server to show. |
|
| 136 | - | ||
| 137 | - | - **A quicker log and an instant clean diff (#94).** The workspace's state index keeps its changes newest first, and the log takes them as a list, with no map of every change and no sort. A diff whose working copy changes nothing is empty without loading the base. On VS Code at 20 000 commits, `log -n 20` went from 34 ms to 30 ms and a clean `diff` from 48 ms to 30 ms. |
|
| 138 | - | ||
| 139 | - | - **A credential helper that does not read its input works (#115).** An `erase` that exits without reading what oxy sends it failed now and then with Broken pipe; a closed pipe is no error now, as in Git. |
|
| 140 | - | ||
| 141 | - | - **gc leaves a fully packed store as it is.** gc repacks at its harder compression level, and a store that was already one such pack with nothing loose was rewritten every time, the same pack again: 37 seconds for this repository's 48 000 objects on a forge, nightly, under the repository's write lock. Such a store is now left alone. |
|
| 142 | - | ||
| 143 | - | - **A switch names what it takes off disk (#102).** `oxy bookmark switch` and `oxy checkout` printed only how many files they removed, easy to miss when a switch to a bookmark lacking 80 files took an editor's project file with them. They now name the first few removed files, with a warning when twenty or more go. |
|
| 144 | - | ||
| 145 | 7 | - **Binary files' current versions found without whole closures (#107).** Which replacement of a binary file is live was decided by building each replacing change's entire closure; on a history as long as VS Code's (166,000 commits) each is a hundred thousand changes, the memo could not keep them, and `oxy git verify` spent its day rebuilding them. A bounded walk that keeps nothing answers the same. |
|
| 146 | - | ||
| 147 | - | - **`--carry` takes work the target already has (#100).** Switching bookmarks or checking out with `--carry` refused when uncommitted files were byte-for-byte what the target state holds, as after a proposal of that work landed, leaving only `--discard`. Such work is now already applied: no conflict, nothing to carry, and the rest of the work comes along. |
|
| 148 | - | ||
| 149 | - | - **Full hashes as `--json` prints them (#103).** A change named by its full 64-digit hash without the `#`, as `oxy bookmark show --json` and the hub's API print heads, resolves wherever a change is named (`bookmark set`, `bookmark create`, `checkout`, …), so JSON output feeds back into commands. |
|
| 150 | 8 | ||
| 151 | 9 | - **Large new files are not snapshotted unseen, and past snapshots expire (#98).** A 1 GB image left untracked in a working tree was taken in by the next `status` and kept by the operation log for good: deleting the file and running `gc --now` freed nothing. Now a new, untracked file over `snapshot.max-new-file-size` (default 100 MiB) is left out and named by `status`, with how to ignore it or raise the limit; tracked files and files already in the working copy are unaffected. Past working-copy snapshots keep their files only for `core.snapshot-retention` (default 7 days), so `gc` prunes what only they held, and `oxy gc --expire-snapshots --now` does it at once. `undo` and `op restore` to an operation whose working copy has expired keep the files as they are. |
|
| 152 | 10 | ||
| … | 173 unchanged lines |
modified Cargo.lock +4 −19
| … | 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]] |
|
| 585 | 575 | name = "data-encoding" |
|
| 586 | 576 | version = "2.11.1" |
|
| 587 | 577 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 45 unchanged lines |
||
| 633 | 623 | checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" |
|
| 634 | 624 | dependencies = [ |
|
| 635 | 625 | "block-buffer", |
|
| 636 | - | "const-oid", |
|
| 637 | 626 | "crypto-common 0.2.2", |
|
| 638 | 627 | ] |
|
| 639 | 628 | ||
| … | 859 unchanged lines |
||
| 1499 | 1488 | ||
| 1500 | 1489 | [[package]] |
|
| 1501 | 1490 | name = "oxy" |
|
| 1502 | - | version = "0.11.0" |
|
| 1491 | + | version = "0.4.3" |
|
| 1503 | 1492 | dependencies = [ |
|
| 1504 | 1493 | "anyhow", |
|
| 1505 | 1494 | "clap", |
|
| 1506 | 1495 | "clap_complete", |
|
| 1507 | 1496 | "clap_mangen", |
|
| 1508 | 1497 | "console", |
|
| 1509 | - | "cylinder", |
|
| 1510 | 1498 | "indicatif", |
|
| 1511 | 1499 | "libc", |
|
| 1512 | 1500 | "mimalloc", |
|
| … | 6 unchanged lines |
||
| 1519 | 1507 | ||
| 1520 | 1508 | [[package]] |
|
| 1521 | 1509 | name = "oxy-core" |
|
| 1522 | - | version = "0.11.0" |
|
| 1510 | + | version = "0.4.3" |
|
| 1523 | 1511 | dependencies = [ |
|
| 1524 | 1512 | "blake3", |
|
| 1525 | 1513 | "ed25519-dalek", |
|
| … | 8 unchanged lines |
||
| 1534 | 1522 | "rayon", |
|
| 1535 | 1523 | "rustls", |
|
| 1536 | 1524 | "rustls-pemfile", |
|
| 1537 | - | "serde_json", |
|
| 1538 | - | "sha2", |
|
| 1539 | 1525 | "similar", |
|
| 1540 | 1526 | "tempfile", |
|
| 1541 | 1527 | "thiserror 1.0.69", |
|
| … | 4 unchanged lines |
||
| 1546 | 1532 | ||
| 1547 | 1533 | [[package]] |
|
| 1548 | 1534 | name = "oxy-server" |
|
| 1549 | - | version = "0.11.0" |
|
| 1535 | + | version = "0.4.3" |
|
| 1550 | 1536 | dependencies = [ |
|
| 1551 | 1537 | "axum", |
|
| 1552 | 1538 | "axum-server", |
|
| … | 2 unchanged lines |
||
| 1555 | 1541 | "rustls", |
|
| 1556 | 1542 | "tempfile", |
|
| 1557 | 1543 | "tokio", |
|
| 1558 | - | "tower", |
|
| 1559 | 1544 | ] |
|
| 1560 | 1545 | ||
| 1561 | 1546 | [[package]] |
|
| 1562 | 1547 | name = "oxy-strata" |
|
| 1563 | - | version = "0.11.0" |
|
| 1548 | + | version = "0.4.3" |
|
| 1564 | 1549 | dependencies = [ |
|
| 1565 | 1550 | "async-trait", |
|
| 1566 | 1551 | "bytes", |
|
| … | 1419 unchanged lines |
modified Cargo.toml +2 −3
| 1 | 1 | [workspace] |
|
| 2 | - | members = ["crates/oxy-core", "crates/oxy-server", "crates/oxy-strata", "crates/cylinder", "."] |
|
| 2 | + | members = ["crates/oxy-core", "crates/oxy-server", "crates/oxy-strata", "."] |
|
| 3 | 3 | resolver = "2" |
|
| 4 | 4 | ||
| 5 | 5 | [workspace.package] |
|
| 6 | - | version = "0.11.0" |
|
| 6 | + | version = "0.4.3" |
|
| 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" } |
|
| 27 | 26 | clap = { version = "4", features = ["derive"] } |
|
| 28 | 27 | anyhow = "1" |
|
| 29 | 28 | libc = "0.2" |
|
| … | 19 unchanged lines |
modified README.md +1 −9
| … | 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 | - | ||
| 21 | 19 | 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. |
|
| 22 | 20 | ||
| 23 | 21 | ## What is different |
|
| … | 13 unchanged lines |
||
| 37 | 35 | ||
| 38 | 36 | ## Install |
|
| 39 | 37 | ||
| 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. |
|
| 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. |
|
| 41 | 39 | ||
| 42 | 40 | - **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: |
|
| 43 | 41 | ||
| … | 13 unchanged lines |
||
| 57 | 55 | - **Other Linux:** unpack `oxy-VERSION-linux-x86_64.tar.gz` (or `-linux-arm64`) and put `oxy` on your `PATH`. |
|
| 58 | 56 | - **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`. |
|
| 59 | 57 | - **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. |
|
| 61 | 58 | ||
| 62 | 59 | `oh`, the OxygenHub command line, is released the same way from [OxygenHub's releases](https://oxygenhub.io/andymccall/oxygenhub/releases). |
|
| 63 | 60 | ||
| … | 8 unchanged lines |
||
| 72 | 69 | ||
| 73 | 70 | The binary is `target/release/oxy`. |
|
| 74 | 71 | ||
| 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 | - | ||
| 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. |
|
| 78 | - | ||
| 79 | 72 | ## Quick start |
|
| 80 | 73 | ||
| 81 | 74 | ``` |
|
| … | 75 unchanged lines |
||
| 157 | 150 | | `docs/strata.md` | Keeping a repository on your own storage: S3, GCS, Azure, a directory | |
|
| 158 | 151 | | `docs/protocol.md` | Wire protocol for other implementers | |
|
| 159 | 152 | | `docs/prompt.md` | Shell prompt integration | |
|
| 160 | - | | `docs/mcp.md` | `oxy mcp`: the repository as tools for AI agents | |
|
| 161 | 153 | ||
| 162 | 154 | ## Licence |
|
| 163 | 155 | ||
| … | 1 unchanged lines |
modified build.rs +1 −5
| … | 2 unchanged lines |
||
| 3 | 3 | //! building, else "local build". |
|
| 4 | 4 | fn main() { |
|
| 5 | 5 | println!("cargo:rerun-if-env-changed=OXY_BUILD"); |
|
| 6 | - | // The heads name the build only when OXY_BUILD does not: a CI job that |
|
| 7 | - | // sets it stays fresh from one state to the next (oxygenflow#52). |
|
| 8 | - | if !std::env::var("OXY_BUILD").is_ok_and(|b| !b.trim().is_empty()) { |
|
| 9 | - | println!("cargo:rerun-if-env-changed=FLOW_HEADS"); |
|
| 10 | - | } |
|
| 6 | + | println!("cargo:rerun-if-env-changed=FLOW_HEADS"); |
|
| 11 | 7 | let build = std::env::var("OXY_BUILD") |
|
| 12 | 8 | .ok() |
|
| 13 | 9 | .map(|b| b.trim().to_string()) |
|
| … | 18 unchanged lines |
removed crates/cylinder/Cargo.toml +0 −14
| 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" |
removed crates/cylinder/src/lib.rs +0 −454
| 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 | - | } |
modified crates/oxy-core/Cargo.toml +0 −2
| … | 5 unchanged lines |
||
| 6 | 6 | description = "Core data model, change graph, and storage for Oxygen" |
|
| 7 | 7 | ||
| 8 | 8 | [dependencies] |
|
| 9 | - | serde_json = "1" |
|
| 10 | - | sha2 = "0.11" |
|
| 11 | 9 | blake3 = { version = "1", features = ["rayon"] } |
|
| 12 | 10 | hex = "0.4" |
|
| 13 | 11 | rand = "0.8" |
|
| … | 20 unchanged lines |
removed crates/oxy-core/src/approval.rs +0 −228
| 1 | - | //! A person's approval of one job of one run, signed (oxygenhub#235): the |
|
| 2 | - | //! approval attestation, the signer's signature over it, and the signer's |
|
| 3 | - | //! identity, so it verifies with nothing else. The hub decides whether the |
|
| 4 | - | //! signer may approve; the signature makes the decision theirs, and binds |
|
| 5 | - | //! it to exactly that job, state, plan and environment. |
|
| 6 | - | ||
| 7 | - | use crate::attest::{Attest, AttestBody}; |
|
| 8 | - | use crate::cbor::{self, Value}; |
|
| 9 | - | use crate::hash::ObjectRef; |
|
| 10 | - | use crate::identity::{verify, Domain, Identity, SecretKey, Signature}; |
|
| 11 | - | use crate::objects::{array, bytes, Hlc, ObjectError}; |
|
| 12 | - | ||
| 13 | - | /// What is approved. |
|
| 14 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 15 | - | pub struct Statement { |
|
| 16 | - | pub repo: [u8; 16], |
|
| 17 | - | /// The run, as its URL on the hub. |
|
| 18 | - | pub run: String, |
|
| 19 | - | pub job: String, |
|
| 20 | - | /// The job's hash with its sandbox (OxygenFlow spec 6.2). |
|
| 21 | - | pub job_hash: ObjectRef, |
|
| 22 | - | pub state: ObjectRef, |
|
| 23 | - | /// The blobs the job takes as inputs (a plan), any order. |
|
| 24 | - | pub inputs: Vec<ObjectRef>, |
|
| 25 | - | pub environment: String, |
|
| 26 | - | pub approve: bool, |
|
| 27 | - | } |
|
| 28 | - | ||
| 29 | - | #[derive(Debug, thiserror::Error)] |
|
| 30 | - | pub enum ApprovalError { |
|
| 31 | - | #[error("the attestation is not an approval")] |
|
| 32 | - | NotApproval, |
|
| 33 | - | #[error("no signature by the signer {0}")] |
|
| 34 | - | Unsigned(String), |
|
| 35 | - | #[error("the signature does not verify: {0}")] |
|
| 36 | - | BadSignature(String), |
|
| 37 | - | #[error("{0}")] |
|
| 38 | - | Schema(String), |
|
| 39 | - | } |
|
| 40 | - | ||
| 41 | - | impl From<ObjectError> for ApprovalError { |
|
| 42 | - | fn from(e: ObjectError) -> Self { |
|
| 43 | - | ApprovalError::Schema(e.to_string()) |
|
| 44 | - | } |
|
| 45 | - | } |
|
| 46 | - | ||
| 47 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 48 | - | pub struct SignedApproval { |
|
| 49 | - | pub attest: Attest, |
|
| 50 | - | pub sigs: Vec<Signature>, |
|
| 51 | - | pub signer: Identity, |
|
| 52 | - | } |
|
| 53 | - | ||
| 54 | - | impl SignedApproval { |
|
| 55 | - | pub fn sign(s: &Statement, signer: &Identity, sk: &SecretKey) -> SignedApproval { |
|
| 56 | - | let mut inputs = s.inputs.clone(); |
|
| 57 | - | inputs.sort(); |
|
| 58 | - | inputs.dedup(); |
|
| 59 | - | let attest = Attest { |
|
| 60 | - | repo: s.repo, |
|
| 61 | - | hlc: Hlc::now(), |
|
| 62 | - | body: AttestBody::Approval { |
|
| 63 | - | run: s.run.clone(), |
|
| 64 | - | job: s.job.clone(), |
|
| 65 | - | job_hash: s.job_hash, |
|
| 66 | - | state: s.state, |
|
| 67 | - | inputs, |
|
| 68 | - | environment: s.environment.clone(), |
|
| 69 | - | approve: s.approve, |
|
| 70 | - | }, |
|
| 71 | - | }; |
|
| 72 | - | let sig = sk.sign(Domain::Attest, &signer.hash(), &attest.hash()); |
|
| 73 | - | SignedApproval { |
|
| 74 | - | attest, |
|
| 75 | - | sigs: vec![sig], |
|
| 76 | - | signer: signer.clone(), |
|
| 77 | - | } |
|
| 78 | - | } |
|
| 79 | - | ||
| 80 | - | /// What was approved, read back from the attestation. |
|
| 81 | - | pub fn statement(&self) -> Result<Statement, ApprovalError> { |
|
| 82 | - | let AttestBody::Approval { |
|
| 83 | - | run, |
|
| 84 | - | job, |
|
| 85 | - | job_hash, |
|
| 86 | - | state, |
|
| 87 | - | inputs, |
|
| 88 | - | environment, |
|
| 89 | - | approve, |
|
| 90 | - | } = &self.attest.body |
|
| 91 | - | else { |
|
| 92 | - | return Err(ApprovalError::NotApproval); |
|
| 93 | - | }; |
|
| 94 | - | Ok(Statement { |
|
| 95 | - | repo: self.attest.repo, |
|
| 96 | - | run: run.clone(), |
|
| 97 | - | job: job.clone(), |
|
| 98 | - | job_hash: *job_hash, |
|
| 99 | - | state: *state, |
|
| 100 | - | inputs: inputs.clone(), |
|
| 101 | - | environment: environment.clone(), |
|
| 102 | - | approve: *approve, |
|
| 103 | - | }) |
|
| 104 | - | } |
|
| 105 | - | ||
| 106 | - | /// It is an approval and the signer signed it. Whether the signer may |
|
| 107 | - | /// approve is the reader's call. |
|
| 108 | - | pub fn verify(&self) -> Result<(), ApprovalError> { |
|
| 109 | - | self.statement()?; |
|
| 110 | - | let key = self.signer.hash(); |
|
| 111 | - | let sig = self |
|
| 112 | - | .sigs |
|
| 113 | - | .iter() |
|
| 114 | - | .find(|s| s.key == key) |
|
| 115 | - | .ok_or_else(|| ApprovalError::Unsigned(key.short(12)))?; |
|
| 116 | - | verify(&self.signer, sig, Domain::Attest, &self.attest.hash()) |
|
| 117 | - | .map_err(|e| ApprovalError::BadSignature(e.to_string())) |
|
| 118 | - | } |
|
| 119 | - | ||
| 120 | - | /// Whether `s` is what this approves (the time it was made aside). |
|
| 121 | - | pub fn covers(&self, s: &Statement) -> bool { |
|
| 122 | - | let mut want = s.inputs.clone(); |
|
| 123 | - | want.sort(); |
|
| 124 | - | want.dedup(); |
|
| 125 | - | self.statement().is_ok_and(|mine| { |
|
| 126 | - | mine == Statement { |
|
| 127 | - | inputs: want, |
|
| 128 | - | ..s.clone() |
|
| 129 | - | } |
|
| 130 | - | }) |
|
| 131 | - | } |
|
| 132 | - | ||
| 133 | - | /// `[attest, [Signature], identity]`, each object as its bytes. |
|
| 134 | - | pub fn encode(&self) -> Vec<u8> { |
|
| 135 | - | cbor::encode(&Value::Array(vec![ |
|
| 136 | - | Value::Bytes(self.attest.encode()), |
|
| 137 | - | Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()), |
|
| 138 | - | Value::Bytes(self.signer.encode()), |
|
| 139 | - | ])) |
|
| 140 | - | } |
|
| 141 | - | ||
| 142 | - | pub fn decode(b: &[u8]) -> Result<SignedApproval, ApprovalError> { |
|
| 143 | - | let v = cbor::decode(b).map_err(ObjectError::from)?; |
|
| 144 | - | let a = array(&v, "signed approval")?; |
|
| 145 | - | if a.len() != 3 { |
|
| 146 | - | return Err(ApprovalError::Schema( |
|
| 147 | - | "signed approval: expected [attest, sigs, identity]".into(), |
|
| 148 | - | )); |
|
| 149 | - | } |
|
| 150 | - | Ok(SignedApproval { |
|
| 151 | - | attest: Attest::decode(bytes(&a[0], "attest")?)?, |
|
| 152 | - | sigs: array(&a[1], "sigs")? |
|
| 153 | - | .iter() |
|
| 154 | - | .map(Signature::from_value) |
|
| 155 | - | .collect::<Result<_, _>>()?, |
|
| 156 | - | signer: Identity::decode(bytes(&a[2], "identity")?)?, |
|
| 157 | - | }) |
|
| 158 | - | } |
|
| 159 | - | } |
|
| 160 | - | ||
| 161 | - | #[cfg(test)] |
|
| 162 | - | mod tests { |
|
| 163 | - | use super::*; |
|
| 164 | - | use crate::identity::SecretKey; |
|
| 165 | - | ||
| 166 | - | fn statement() -> Statement { |
|
| 167 | - | Statement { |
|
| 168 | - | repo: [7; 16], |
|
| 169 | - | run: "https://hub.example/andy/infra/flow/8".into(), |
|
| 170 | - | job: "apply".into(), |
|
| 171 | - | job_hash: ObjectRef([1; 32]), |
|
| 172 | - | state: ObjectRef([2; 32]), |
|
| 173 | - | inputs: vec![ObjectRef([4; 32]), ObjectRef([3; 32])], |
|
| 174 | - | environment: "prod".into(), |
|
| 175 | - | approve: true, |
|
| 176 | - | } |
|
| 177 | - | } |
|
| 178 | - | ||
| 179 | - | #[test] |
|
| 180 | - | fn an_approval_signs_exactly_one_job_state_plan_and_environment() { |
|
| 181 | - | let sk = SecretKey::generate(); |
|
| 182 | - | let id = sk.identity("Andy", "[email protected]", 1); |
|
| 183 | - | let sa = SignedApproval::sign(&statement(), &id, &sk); |
|
| 184 | - | sa.verify().unwrap(); |
|
| 185 | - | let back = SignedApproval::decode(&sa.encode()).unwrap(); |
|
| 186 | - | assert_eq!(back, sa, "round trip"); |
|
| 187 | - | assert!(back.covers(&statement()), "inputs in any order"); |
|
| 188 | - | for other in [ |
|
| 189 | - | Statement { |
|
| 190 | - | inputs: vec![ObjectRef([9; 32])], |
|
| 191 | - | ..statement() |
|
| 192 | - | }, |
|
| 193 | - | Statement { |
|
| 194 | - | state: ObjectRef([8; 32]), |
|
| 195 | - | ..statement() |
|
| 196 | - | }, |
|
| 197 | - | Statement { |
|
| 198 | - | job_hash: ObjectRef([8; 32]), |
|
| 199 | - | ..statement() |
|
| 200 | - | }, |
|
| 201 | - | Statement { |
|
| 202 | - | run: "https://hub.example/andy/infra/flow/9".into(), |
|
| 203 | - | ..statement() |
|
| 204 | - | }, |
|
| 205 | - | Statement { |
|
| 206 | - | environment: "dev".into(), |
|
| 207 | - | ..statement() |
|
| 208 | - | }, |
|
| 209 | - | Statement { |
|
| 210 | - | approve: false, |
|
| 211 | - | ..statement() |
|
| 212 | - | }, |
|
| 213 | - | ] { |
|
| 214 | - | assert!(!back.covers(&other), "{other:?}"); |
|
| 215 | - | } |
|
| 216 | - | // Changed after signing: the signature no longer holds. |
|
| 217 | - | let mut forged = sa.clone(); |
|
| 218 | - | if let AttestBody::Approval { inputs, .. } = &mut forged.attest.body { |
|
| 219 | - | inputs[0] = ObjectRef([9; 32]); |
|
| 220 | - | } |
|
| 221 | - | assert!(forged.verify().is_err()); |
|
| 222 | - | // Signed by someone else than the identity it carries. |
|
| 223 | - | let other_sk = SecretKey::generate(); |
|
| 224 | - | let mut stolen = SignedApproval::sign(&statement(), &id, &other_sk); |
|
| 225 | - | stolen.signer = id.clone(); |
|
| 226 | - | assert!(stolen.verify().is_err()); |
|
| 227 | - | } |
|
| 228 | - | } |
removed crates/oxy-core/src/artifact.rs +0 −303
| 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 +0 −101
| … | 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 | - | }, |
|
| 36 | 29 | /// The signing key's identity was replaced by `new` (spec §14.2). |
|
| 37 | 30 | Rotate { new: KeyId }, |
|
| 38 | 31 | /// `key` must no longer be trusted (spec §14.2). |
|
| … | 3 unchanged lines |
||
| 42 | 35 | Receipt { |
|
| 43 | 36 | receipt: ObjectRef, |
|
| 44 | 37 | state: ObjectRef, |
|
| 45 | - | run: String, |
|
| 46 | - | }, |
|
| 47 | - | /// A person approved (or rejected) one job of one run (OxygenFlow |
|
| 48 | - | /// approval gates; oxygenhub#235): the job as hashed with its sandbox, |
|
| 49 | - | /// on that state, taking exactly those input blobs (a reviewed plan), |
|
| 50 | - | /// for that environment. It approves nothing else. |
|
| 51 | - | Approval { |
|
| 52 | 38 | run: String, |
|
| 53 | - | job: String, |
|
| 54 | - | job_hash: ObjectRef, |
|
| 55 | - | state: ObjectRef, |
|
| 56 | - | inputs: Vec<ObjectRef>, |
|
| 57 | - | environment: String, |
|
| 58 | - | approve: bool, |
|
| 59 | 39 | }, |
|
| 60 | - | /// A host accepted the reference attestation `attest`, at this |
|
| 61 | - | /// attestation's own time, the host's clock (§14.7): the time a |
|
| 62 | - | /// certificate of a short-lived key is checked at on a fetch. |
|
| 63 | - | Accepted { attest: ObjectRef }, |
|
| 64 | 40 | } |
|
| 65 | 41 | ||
| 66 | 42 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| … | 10 unchanged lines |
||
| 77 | 53 | AttestBody::Ref { .. } => "ref", |
|
| 78 | 54 | AttestBody::Seal { .. } => "seal", |
|
| 79 | 55 | AttestBody::Tag { .. } => "tag", |
|
| 80 | - | AttestBody::Untag { .. } => "untag", |
|
| 81 | 56 | AttestBody::Rotate { .. } => "rotate", |
|
| 82 | 57 | AttestBody::Revoke { .. } => "revoke", |
|
| 83 | 58 | AttestBody::Receipt { .. } => "receipt", |
|
| 84 | - | AttestBody::Approval { .. } => "approval", |
|
| 85 | - | AttestBody::Accepted { .. } => "accepted", |
|
| 86 | 59 | } |
|
| 87 | 60 | } |
|
| 88 | 61 | ||
| … | 33 unchanged lines |
||
| 122 | 95 | (1, Value::Bytes(state.0.to_vec())), |
|
| 123 | 96 | (2, Value::Text(message.clone())), |
|
| 124 | 97 | ]), |
|
| 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 | - | ]), |
|
| 134 | 98 | AttestBody::Rotate { new } => Value::map([(0, Value::Bytes(new.0.to_vec()))]), |
|
| 135 | 99 | AttestBody::Revoke { key, reason } => Value::map([ |
|
| 136 | 100 | (0, Value::Bytes(key.0.to_vec())), |
|
| … | 8 unchanged lines |
||
| 145 | 109 | (1, Value::Bytes(state.0.to_vec())), |
|
| 146 | 110 | (2, Value::Text(run.clone())), |
|
| 147 | 111 | ]), |
|
| 148 | - | AttestBody::Accepted { attest } => Value::map([(0, Value::Bytes(attest.0.to_vec()))]), |
|
| 149 | - | AttestBody::Approval { |
|
| 150 | - | run, |
|
| 151 | - | job, |
|
| 152 | - | job_hash, |
|
| 153 | - | state, |
|
| 154 | - | inputs, |
|
| 155 | - | environment, |
|
| 156 | - | approve, |
|
| 157 | - | } => Value::map([ |
|
| 158 | - | (0, Value::Text(run.clone())), |
|
| 159 | - | (1, Value::Text(job.clone())), |
|
| 160 | - | (2, Value::Bytes(job_hash.0.to_vec())), |
|
| 161 | - | (3, Value::Bytes(state.0.to_vec())), |
|
| 162 | - | ( |
|
| 163 | - | 4, |
|
| 164 | - | Value::Array(inputs.iter().map(|i| Value::Bytes(i.0.to_vec())).collect()), |
|
| 165 | - | ), |
|
| 166 | - | (5, Value::Text(environment.clone())), |
|
| 167 | - | (6, Value::Uint(u64::from(*approve))), |
|
| 168 | - | ]), |
|
| 169 | 112 | }; |
|
| 170 | 113 | Value::map([ |
|
| 171 | 114 | (0, Value::Uint(FORMAT_VERSION)), |
|
| … | 49 unchanged lines |
||
| 221 | 164 | state: oref(field(b, 1, "state")?, "state")?, |
|
| 222 | 165 | message: text(field(b, 2, "message")?, "message")?.to_owned(), |
|
| 223 | 166 | }, |
|
| 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 | - | }, |
|
| 229 | 167 | "rotate" => AttestBody::Rotate { |
|
| 230 | 168 | new: oref(field(b, 0, "new")?, "new")?, |
|
| 231 | 169 | }, |
|
| … | 5 unchanged lines |
||
| 237 | 175 | receipt: oref(field(b, 0, "receipt")?, "receipt")?, |
|
| 238 | 176 | state: oref(field(b, 1, "state")?, "state")?, |
|
| 239 | 177 | run: text(field(b, 2, "run")?, "run")?.to_owned(), |
|
| 240 | - | }, |
|
| 241 | - | "approval" => { |
|
| 242 | - | let inputs = array(field(b, 4, "inputs")?, "inputs")? |
|
| 243 | - | .iter() |
|
| 244 | - | .map(|h| oref(h, "input")) |
|
| 245 | - | .collect::<Result<Vec<_>, _>>()?; |
|
| 246 | - | if !inputs.windows(2).all(|w| w[0] < w[1]) { |
|
| 247 | - | return Err(ObjectError::Schema( |
|
| 248 | - | "approval: inputs must be sorted and unique".into(), |
|
| 249 | - | )); |
|
| 250 | - | } |
|
| 251 | - | AttestBody::Approval { |
|
| 252 | - | run: text(field(b, 0, "run")?, "run")?.to_owned(), |
|
| 253 | - | job: text(field(b, 1, "job")?, "job")?.to_owned(), |
|
| 254 | - | job_hash: oref(field(b, 2, "job_hash")?, "job_hash")?, |
|
| 255 | - | state: oref(field(b, 3, "state")?, "state")?, |
|
| 256 | - | inputs, |
|
| 257 | - | environment: text(field(b, 5, "environment")?, "environment")?.to_owned(), |
|
| 258 | - | approve: match uint(field(b, 6, "approve")?, "approve")? { |
|
| 259 | - | 0 => false, |
|
| 260 | - | 1 => true, |
|
| 261 | - | _ => return Err(ObjectError::Schema("approval: approve is 0 or 1".into())), |
|
| 262 | - | }, |
|
| 263 | - | } |
|
| 264 | - | } |
|
| 265 | - | "accepted" => AttestBody::Accepted { |
|
| 266 | - | attest: oref(field(b, 0, "attest")?, "attest")?, |
|
| 267 | 178 | }, |
|
| 268 | 179 | other => { |
|
| 269 | 180 | return Err(ObjectError::Schema(format!( |
|
| … | 46 unchanged lines |
||
| 316 | 227 | }; |
|
| 317 | 228 | assert_eq!(Attest::decode(&t.encode()).unwrap(), t); |
|
| 318 | 229 | 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()); |
|
| 331 | 230 | } |
|
| 332 | 231 | ||
| 333 | 232 | #[test] |
|
| … | 17 unchanged lines |
modified crates/oxy-core/src/attributes.rs +2 −13
| 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`, `lockable`, or |
|
| 4 | - | //! `merge=union`. A |
|
| 3 | + | //! (or `-binary`), `eol=lf`, `eol=crlf`, `nocompress`, or `lockable`. A |
|
| 5 | 4 | //! pattern without a slash matches a basename anywhere below the file's |
|
| 6 | 5 | //! directory; one with a slash is anchored to that directory. Later rules |
|
| 7 | 6 | //! override earlier ones per attribute. |
|
| … | 15 unchanged lines |
||
| 23 | 22 | pub eol: Option<Eol>, |
|
| 24 | 23 | pub nocompress: bool, |
|
| 25 | 24 | 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, |
|
| 29 | 25 | } |
|
| 30 | 26 | ||
| 31 | 27 | struct Rule { |
|
| … | 80 unchanged lines |
||
| 112 | 108 | ("-nocompress", None) => a.nocompress = false, |
|
| 113 | 109 | ("lockable", None) => a.lockable = true, |
|
| 114 | 110 | ("-lockable", None) => a.lockable = false, |
|
| 115 | - | ("merge", Some("union")) => a.union = true, |
|
| 116 | - | ("merge", Some(_)) | ("-merge", None) => a.union = false, |
|
| 117 | 111 | _ => {} |
|
| 118 | 112 | } |
|
| 119 | 113 | } |
|
| … | 52 unchanged lines |
||
| 172 | 166 | binary: Some(true), |
|
| 173 | 167 | eol: None, |
|
| 174 | 168 | nocompress: true, |
|
| 175 | - | lockable: false, |
|
| 176 | - | union: false |
|
| 169 | + | lockable: false |
|
| 177 | 170 | } |
|
| 178 | 171 | ); |
|
| 179 | 172 | assert_eq!(a.lookup("notes.txt").eol, Some(Eol::Lf)); |
|
| 180 | 173 | assert_eq!(a.lookup("docs/notes.txt").eol, Some(Eol::Crlf)); |
|
| 181 | 174 | 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); |
|
| 186 | 175 | assert_eq!(a.lookup("src/deep/x.dat").binary, Some(true)); |
|
| 187 | 176 | assert_eq!(a.lookup("other/x.dat").binary, None); |
|
| 188 | 177 | assert_eq!(a.lookup("src/gen.txt").eol, None, "deeper file overrides"); |
|
| … | 11 unchanged lines |
removed crates/oxy-core/src/cert.rs +0 −514
| 1 | - | //! Certificates for short-lived keys (spec §14.7, oxygenhub#241, |
|
| 2 | - | //! oxygenhub docs/keyless.md). An issuer, a hub's key, binds a fresh key |
|
| 3 | - | //! to the identity that proved itself (an OIDC subject) for minutes: a Flow |
|
| 4 | - | //! job or an agent signs with that key and throws it away. The certificate |
|
| 5 | - | //! is signed with the issuer's composite key, so it is post-quantum like |
|
| 6 | - | //! every other signature in Oxygen. |
|
| 7 | - | //! |
|
| 8 | - | //! Whether to trust an issuer, and for which subjects, is the trust |
|
| 9 | - | //! policy's (§14.5); whether a signature was made inside the certificate's |
|
| 10 | - | //! window needs time the signer does not choose, which the caller passes |
|
| 11 | - | //! in (the transparency log's integrated time, §14.8, else a host's |
|
| 12 | - | //! acceptance). |
|
| 13 | - | ||
| 14 | - | use crate::cbor::{self, Value}; |
|
| 15 | - | use crate::hash::{hash_object, KeyId, ObjectRef, ObjectType}; |
|
| 16 | - | use crate::identity::{verify, Domain, Identity, Scope, SecretKey, Signature}; |
|
| 17 | - | use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, ObjectError}; |
|
| 18 | - | ||
| 19 | - | /// The longest a certificate may be valid. Jobs get ten minutes; an hour |
|
| 20 | - | /// leaves room for an issuer that rounds up, and no more. |
|
| 21 | - | pub const MAX_LIFETIME_MS: u64 = 60 * 60 * 1000; |
|
| 22 | - | ||
| 23 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 24 | - | pub struct Certificate { |
|
| 25 | - | /// The short-lived key's public identity. |
|
| 26 | - | pub key: Identity, |
|
| 27 | - | /// The issuer's KeyId (a hub's key). |
|
| 28 | - | pub issuer: KeyId, |
|
| 29 | - | /// Who proved it: `repo:OWNER/NAME:bookmark:B`, `agent:NAME@ACCOUNT`. |
|
| 30 | - | pub subject: String, |
|
| 31 | - | /// What else the proof said (environment, run, job), sorted by name. |
|
| 32 | - | pub claims: Vec<(String, String)>, |
|
| 33 | - | /// What the key may sign: changes, refs, seals. |
|
| 34 | - | pub scope: Scope, |
|
| 35 | - | /// Valid from and until, in wall milliseconds. |
|
| 36 | - | pub not_before: u64, |
|
| 37 | - | pub expires: u64, |
|
| 38 | - | /// The grant that lets the subject act for an author, when an agent |
|
| 39 | - | /// asked with one (field 8, only when present). |
|
| 40 | - | pub grant: Option<SignedGrant>, |
|
| 41 | - | } |
|
| 42 | - | ||
| 43 | - | impl Certificate { |
|
| 44 | - | pub fn to_value(&self) -> Value { |
|
| 45 | - | let mut claims = self.claims.clone(); |
|
| 46 | - | claims.sort(); |
|
| 47 | - | let mut v = Value::map([ |
|
| 48 | - | (0, Value::Uint(1)), |
|
| 49 | - | (1, Value::Bytes(self.key.encode())), |
|
| 50 | - | (2, Value::Bytes(self.issuer.0.to_vec())), |
|
| 51 | - | (3, Value::Text(self.subject.clone())), |
|
| 52 | - | ( |
|
| 53 | - | 4, |
|
| 54 | - | Value::Array( |
|
| 55 | - | claims |
|
| 56 | - | .into_iter() |
|
| 57 | - | .map(|(k, v)| Value::Array(vec![Value::Text(k), Value::Text(v)])) |
|
| 58 | - | .collect(), |
|
| 59 | - | ), |
|
| 60 | - | ), |
|
| 61 | - | ( |
|
| 62 | - | 5, |
|
| 63 | - | Value::Array( |
|
| 64 | - | self.scope |
|
| 65 | - | .names() |
|
| 66 | - | .into_iter() |
|
| 67 | - | .map(|n| Value::Text(n.to_string())) |
|
| 68 | - | .collect(), |
|
| 69 | - | ), |
|
| 70 | - | ), |
|
| 71 | - | (6, Value::Uint(self.not_before)), |
|
| 72 | - | (7, Value::Uint(self.expires)), |
|
| 73 | - | ]); |
|
| 74 | - | if let (Some(g), Value::Map(m)) = (&self.grant, &mut v) { |
|
| 75 | - | m.insert(8, Value::Bytes(g.encode())); |
|
| 76 | - | } |
|
| 77 | - | v |
|
| 78 | - | } |
|
| 79 | - | ||
| 80 | - | pub fn encode(&self) -> Vec<u8> { |
|
| 81 | - | cbor::encode(&self.to_value()) |
|
| 82 | - | } |
|
| 83 | - | ||
| 84 | - | pub fn hash(&self) -> ObjectRef { |
|
| 85 | - | hash_object(ObjectType::Certificate, &self.encode()) |
|
| 86 | - | } |
|
| 87 | - | ||
| 88 | - | /// The short-lived key's KeyId: what its signatures name. |
|
| 89 | - | pub fn key_id(&self) -> KeyId { |
|
| 90 | - | self.key.hash() |
|
| 91 | - | } |
|
| 92 | - | ||
| 93 | - | pub fn decode(b: &[u8]) -> Result<Certificate, ObjectError> { |
|
| 94 | - | let v = cbor::decode(b)?; |
|
| 95 | - | let m = map(&v, "certificate")?; |
|
| 96 | - | check_version(m)?; |
|
| 97 | - | let key = Identity::decode(bytes(field(m, 1, "key")?, "key")?)?; |
|
| 98 | - | let mut claims = Vec::new(); |
|
| 99 | - | for c in array(field(m, 4, "claims")?, "claims")? { |
|
| 100 | - | let pair = array(c, "claim")?; |
|
| 101 | - | if pair.len() != 2 { |
|
| 102 | - | return Err(ObjectError::Schema("claim: expected [name, value]".into())); |
|
| 103 | - | } |
|
| 104 | - | claims.push(( |
|
| 105 | - | text(&pair[0], "claim name")?.to_owned(), |
|
| 106 | - | text(&pair[1], "claim value")?.to_owned(), |
|
| 107 | - | )); |
|
| 108 | - | } |
|
| 109 | - | let mut scope = Scope::NONE; |
|
| 110 | - | for n in array(field(m, 5, "scope")?, "scope")? { |
|
| 111 | - | let n = text(n, "scope")?; |
|
| 112 | - | scope = scope |
|
| 113 | - | | Scope::named(n).ok_or_else(|| { |
|
| 114 | - | ObjectError::Schema(format!("certificate: unknown scope {n:?}")) |
|
| 115 | - | })?; |
|
| 116 | - | } |
|
| 117 | - | Ok(Certificate { |
|
| 118 | - | key, |
|
| 119 | - | issuer: oref(field(m, 2, "issuer")?, "issuer")?, |
|
| 120 | - | subject: text(field(m, 3, "subject")?, "subject")?.to_owned(), |
|
| 121 | - | claims, |
|
| 122 | - | scope, |
|
| 123 | - | not_before: uint(field(m, 6, "not_before")?, "not_before")?, |
|
| 124 | - | expires: uint(field(m, 7, "expires")?, "expires")?, |
|
| 125 | - | grant: match m.get(&8) { |
|
| 126 | - | Some(g) => Some( |
|
| 127 | - | SignedGrant::decode(bytes(g, "grant")?) |
|
| 128 | - | .map_err(|e| ObjectError::Schema(format!("grant: {e}")))?, |
|
| 129 | - | ), |
|
| 130 | - | None => None, |
|
| 131 | - | }, |
|
| 132 | - | }) |
|
| 133 | - | } |
|
| 134 | - | ||
| 135 | - | /// Whether the key may sign within `scope` at `at_ms`, a time the |
|
| 136 | - | /// signer did not choose. |
|
| 137 | - | pub fn covers(&self, scope: Scope, at_ms: u64) -> bool { |
|
| 138 | - | !scope.is_empty() |
|
| 139 | - | && self.scope.has(scope) |
|
| 140 | - | && self.not_before <= at_ms |
|
| 141 | - | && at_ms <= self.expires |
|
| 142 | - | } |
|
| 143 | - | ||
| 144 | - | /// The claim called `name`, if the certificate has one. |
|
| 145 | - | pub fn claim(&self, name: &str) -> Option<&str> { |
|
| 146 | - | self.claims |
|
| 147 | - | .iter() |
|
| 148 | - | .find(|(k, _)| k == name) |
|
| 149 | - | .map(|(_, v)| v.as_str()) |
|
| 150 | - | } |
|
| 151 | - | } |
|
| 152 | - | ||
| 153 | - | /// An author's grant (spec §14.7): it lets the keys an issuer certifies |
|
| 154 | - | /// for `subject` act for the author, as a delegate does, without a key of |
|
| 155 | - | /// their own. An agent's fresh keys then sign and seal changes in the |
|
| 156 | - | /// author's name while the grant lasts; the grant travels inside their |
|
| 157 | - | /// certificates, so a verifier needs nothing else. Signed by the author. |
|
| 158 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 159 | - | pub struct Grant { |
|
| 160 | - | /// The author's KeyId, which signs the grant. |
|
| 161 | - | pub grantor: KeyId, |
|
| 162 | - | /// The subject whose certified keys act for it: `agent:NAME@ACCOUNT`. |
|
| 163 | - | pub subject: String, |
|
| 164 | - | /// The issuer whose certificates count. |
|
| 165 | - | pub issuer: KeyId, |
|
| 166 | - | /// What they may do for the author: changes, seal. |
|
| 167 | - | pub scope: Scope, |
|
| 168 | - | /// Valid from and until, in wall milliseconds. |
|
| 169 | - | pub not_before: u64, |
|
| 170 | - | pub expires: u64, |
|
| 171 | - | } |
|
| 172 | - | ||
| 173 | - | impl Grant { |
|
| 174 | - | pub fn to_value(&self) -> Value { |
|
| 175 | - | Value::map([ |
|
| 176 | - | (0, Value::Uint(1)), |
|
| 177 | - | (1, Value::Bytes(self.grantor.0.to_vec())), |
|
| 178 | - | (2, Value::Text(self.subject.clone())), |
|
| 179 | - | (3, Value::Bytes(self.issuer.0.to_vec())), |
|
| 180 | - | ( |
|
| 181 | - | 4, |
|
| 182 | - | Value::Array( |
|
| 183 | - | self.scope |
|
| 184 | - | .names() |
|
| 185 | - | .into_iter() |
|
| 186 | - | .map(|n| Value::Text(n.to_string())) |
|
| 187 | - | .collect(), |
|
| 188 | - | ), |
|
| 189 | - | ), |
|
| 190 | - | (5, Value::Uint(self.not_before)), |
|
| 191 | - | (6, Value::Uint(self.expires)), |
|
| 192 | - | ]) |
|
| 193 | - | } |
|
| 194 | - | ||
| 195 | - | pub fn encode(&self) -> Vec<u8> { |
|
| 196 | - | cbor::encode(&self.to_value()) |
|
| 197 | - | } |
|
| 198 | - | ||
| 199 | - | pub fn hash(&self) -> ObjectRef { |
|
| 200 | - | hash_object(ObjectType::Grant, &self.encode()) |
|
| 201 | - | } |
|
| 202 | - | ||
| 203 | - | pub fn decode(b: &[u8]) -> Result<Grant, ObjectError> { |
|
| 204 | - | let v = cbor::decode(b)?; |
|
| 205 | - | let m = map(&v, "grant")?; |
|
| 206 | - | check_version(m)?; |
|
| 207 | - | let mut scope = Scope::NONE; |
|
| 208 | - | for n in array(field(m, 4, "scope")?, "scope")? { |
|
| 209 | - | let n = text(n, "scope")?; |
|
| 210 | - | scope = scope |
|
| 211 | - | | Scope::named(n) |
|
| 212 | - | .ok_or_else(|| ObjectError::Schema(format!("grant: unknown scope {n:?}")))?; |
|
| 213 | - | } |
|
| 214 | - | Ok(Grant { |
|
| 215 | - | grantor: oref(field(m, 1, "grantor")?, "grantor")?, |
|
| 216 | - | subject: text(field(m, 2, "subject")?, "subject")?.to_owned(), |
|
| 217 | - | issuer: oref(field(m, 3, "issuer")?, "issuer")?, |
|
| 218 | - | scope, |
|
| 219 | - | not_before: uint(field(m, 5, "not_before")?, "not_before")?, |
|
| 220 | - | expires: uint(field(m, 6, "expires")?, "expires")?, |
|
| 221 | - | }) |
|
| 222 | - | } |
|
| 223 | - | ||
| 224 | - | /// Whether it lets its subject do `scope` for the author at `at_ms` |
|
| 225 | - | /// (the change's own time, as for a delegation). |
|
| 226 | - | pub fn covers(&self, scope: Scope, at_ms: u64) -> bool { |
|
| 227 | - | !scope.is_empty() |
|
| 228 | - | && self.scope.has(scope) |
|
| 229 | - | && self.not_before <= at_ms |
|
| 230 | - | && at_ms <= self.expires |
|
| 231 | - | } |
|
| 232 | - | } |
|
| 233 | - | ||
| 234 | - | /// A grant with its author's signature over its ObjectRef. |
|
| 235 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 236 | - | pub struct SignedGrant { |
|
| 237 | - | pub grant: Grant, |
|
| 238 | - | pub sig: Signature, |
|
| 239 | - | } |
|
| 240 | - | ||
| 241 | - | impl SignedGrant { |
|
| 242 | - | /// Grant `grant`, signed by the author `grantor` holds the secret of. |
|
| 243 | - | pub fn sign(grant: Grant, grantor: &Identity, sk: &SecretKey) -> SignedGrant { |
|
| 244 | - | let sig = sk.sign(Domain::Grant, &grantor.hash(), &grant.hash()); |
|
| 245 | - | SignedGrant { grant, sig } |
|
| 246 | - | } |
|
| 247 | - | ||
| 248 | - | /// Signed by `grantor`, which it names, and well formed. |
|
| 249 | - | pub fn verify(&self, grantor: &Identity) -> Result<(), CertError> { |
|
| 250 | - | let g = &self.grant; |
|
| 251 | - | if grantor.hash() != g.grantor || self.sig.key != g.grantor { |
|
| 252 | - | return Err(CertError::WrongIssuer(g.grantor.short(12))); |
|
| 253 | - | } |
|
| 254 | - | verify(grantor, &self.sig, Domain::Grant, &g.hash()) |
|
| 255 | - | .map_err(|e| CertError::BadSignature(e.to_string()))?; |
|
| 256 | - | if g.expires <= g.not_before { |
|
| 257 | - | return Err(CertError::BadLifetime); |
|
| 258 | - | } |
|
| 259 | - | if g.scope.is_empty() { |
|
| 260 | - | return Err(CertError::NoScope); |
|
| 261 | - | } |
|
| 262 | - | Ok(()) |
|
| 263 | - | } |
|
| 264 | - | ||
| 265 | - | /// `[grant bytes, signature]`. |
|
| 266 | - | pub fn encode(&self) -> Vec<u8> { |
|
| 267 | - | cbor::encode(&Value::Array(vec![ |
|
| 268 | - | Value::Bytes(self.grant.encode()), |
|
| 269 | - | self.sig.to_value(), |
|
| 270 | - | ])) |
|
| 271 | - | } |
|
| 272 | - | ||
| 273 | - | pub fn decode(b: &[u8]) -> Result<SignedGrant, CertError> { |
|
| 274 | - | let v = cbor::decode(b).map_err(ObjectError::from)?; |
|
| 275 | - | let a = array(&v, "signed grant")?; |
|
| 276 | - | if a.len() != 2 { |
|
| 277 | - | return Err(CertError::Schema( |
|
| 278 | - | "signed grant: expected [grant, signature]".into(), |
|
| 279 | - | )); |
|
| 280 | - | } |
|
| 281 | - | Ok(SignedGrant { |
|
| 282 | - | grant: Grant::decode(bytes(&a[0], "grant")?)?, |
|
| 283 | - | sig: Signature::from_value(&a[1])?, |
|
| 284 | - | }) |
|
| 285 | - | } |
|
| 286 | - | } |
|
| 287 | - | ||
| 288 | - | /// What a fresh key signs (a challenge, §14.3) to show its holder asks for |
|
| 289 | - | /// the certificate: bound to the credential it is asked with, so the |
|
| 290 | - | /// proof cannot be replayed with another. |
|
| 291 | - | pub fn certify_challenge(credential: &str) -> Vec<u8> { |
|
| 292 | - | let mut c = b"oxy1 certify ".to_vec(); |
|
| 293 | - | c.extend_from_slice(blake3::hash(credential.as_bytes()).as_bytes()); |
|
| 294 | - | c |
|
| 295 | - | } |
|
| 296 | - | ||
| 297 | - | #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] |
|
| 298 | - | pub enum CertError { |
|
| 299 | - | #[error("the certificate names issuer {0}, not this one")] |
|
| 300 | - | WrongIssuer(String), |
|
| 301 | - | #[error("the issuer's signature does not verify: {0}")] |
|
| 302 | - | BadSignature(String), |
|
| 303 | - | #[error("the certificate is valid for no time, or for longer than an hour")] |
|
| 304 | - | BadLifetime, |
|
| 305 | - | #[error("the certificate gives no scope")] |
|
| 306 | - | NoScope, |
|
| 307 | - | #[error("{0}")] |
|
| 308 | - | Schema(String), |
|
| 309 | - | } |
|
| 310 | - | ||
| 311 | - | impl From<ObjectError> for CertError { |
|
| 312 | - | fn from(e: ObjectError) -> Self { |
|
| 313 | - | CertError::Schema(e.to_string()) |
|
| 314 | - | } |
|
| 315 | - | } |
|
| 316 | - | ||
| 317 | - | /// A certificate with its issuer's signature over its ObjectRef. |
|
| 318 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 319 | - | pub struct SignedCertificate { |
|
| 320 | - | pub cert: Certificate, |
|
| 321 | - | pub sig: Signature, |
|
| 322 | - | } |
|
| 323 | - | ||
| 324 | - | impl SignedCertificate { |
|
| 325 | - | /// Issue `cert`, signed by the issuer `issuer` holds the secret of. |
|
| 326 | - | pub fn sign(mut cert: Certificate, issuer: &Identity, sk: &SecretKey) -> SignedCertificate { |
|
| 327 | - | // Claims in their canonical order, as encoded. |
|
| 328 | - | cert.claims.sort(); |
|
| 329 | - | let sig = sk.sign(Domain::Certificate, &issuer.hash(), &cert.hash()); |
|
| 330 | - | SignedCertificate { cert, sig } |
|
| 331 | - | } |
|
| 332 | - | ||
| 333 | - | /// Signed by `issuer`, which it names, and well formed. Whether to |
|
| 334 | - | /// trust the issuer for its subject is the policy's question. |
|
| 335 | - | pub fn verify(&self, issuer: &Identity) -> Result<(), CertError> { |
|
| 336 | - | let c = &self.cert; |
|
| 337 | - | if issuer.hash() != c.issuer || self.sig.key != c.issuer { |
|
| 338 | - | return Err(CertError::WrongIssuer(c.issuer.short(12))); |
|
| 339 | - | } |
|
| 340 | - | verify(issuer, &self.sig, Domain::Certificate, &c.hash()) |
|
| 341 | - | .map_err(|e| CertError::BadSignature(e.to_string()))?; |
|
| 342 | - | if c.expires <= c.not_before || c.expires - c.not_before > MAX_LIFETIME_MS { |
|
| 343 | - | return Err(CertError::BadLifetime); |
|
| 344 | - | } |
|
| 345 | - | if c.scope.is_empty() { |
|
| 346 | - | return Err(CertError::NoScope); |
|
| 347 | - | } |
|
| 348 | - | Ok(()) |
|
| 349 | - | } |
|
| 350 | - | ||
| 351 | - | /// `[certificate bytes, signature]`. |
|
| 352 | - | pub fn encode(&self) -> Vec<u8> { |
|
| 353 | - | cbor::encode(&Value::Array(vec![ |
|
| 354 | - | Value::Bytes(self.cert.encode()), |
|
| 355 | - | self.sig.to_value(), |
|
| 356 | - | ])) |
|
| 357 | - | } |
|
| 358 | - | ||
| 359 | - | pub fn decode(b: &[u8]) -> Result<SignedCertificate, CertError> { |
|
| 360 | - | let v = cbor::decode(b).map_err(ObjectError::from)?; |
|
| 361 | - | let a = array(&v, "signed certificate")?; |
|
| 362 | - | if a.len() != 2 { |
|
| 363 | - | return Err(CertError::Schema( |
|
| 364 | - | "signed certificate: expected [certificate, signature]".into(), |
|
| 365 | - | )); |
|
| 366 | - | } |
|
| 367 | - | Ok(SignedCertificate { |
|
| 368 | - | cert: Certificate::decode(bytes(&a[0], "certificate")?)?, |
|
| 369 | - | sig: Signature::from_value(&a[1])?, |
|
| 370 | - | }) |
|
| 371 | - | } |
|
| 372 | - | } |
|
| 373 | - | ||
| 374 | - | #[cfg(test)] |
|
| 375 | - | mod tests { |
|
| 376 | - | use super::*; |
|
| 377 | - | ||
| 378 | - | fn issue( |
|
| 379 | - | issuer_sk: &SecretKey, |
|
| 380 | - | issuer: &Identity, |
|
| 381 | - | minutes: u64, |
|
| 382 | - | ) -> (SecretKey, SignedCertificate) { |
|
| 383 | - | let job_sk = SecretKey::generate(); |
|
| 384 | - | let cert = Certificate { |
|
| 385 | - | key: job_sk.identity("job 12 build", "[email protected]", 1_000), |
|
| 386 | - | issuer: issuer.hash(), |
|
| 387 | - | subject: "repo:andy/demo:bookmark:main".into(), |
|
| 388 | - | claims: vec![ |
|
| 389 | - | ("run".into(), "12".into()), |
|
| 390 | - | ("environment".into(), "prod".into()), |
|
| 391 | - | ], |
|
| 392 | - | scope: Scope::CHANGES | Scope::REFS, |
|
| 393 | - | not_before: 1_000, |
|
| 394 | - | expires: 1_000 + minutes * 60_000, |
|
| 395 | - | grant: None, |
|
| 396 | - | }; |
|
| 397 | - | (job_sk, SignedCertificate::sign(cert, issuer, issuer_sk)) |
|
| 398 | - | } |
|
| 399 | - | ||
| 400 | - | #[test] |
|
| 401 | - | fn a_certificate_round_trips_and_verifies_with_its_issuer_only() { |
|
| 402 | - | let hub_sk = SecretKey::generate(); |
|
| 403 | - | let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1); |
|
| 404 | - | let (_, sc) = issue(&hub_sk, &hub, 10); |
|
| 405 | - | let back = SignedCertificate::decode(&sc.encode()).unwrap(); |
|
| 406 | - | assert_eq!(back, sc); |
|
| 407 | - | assert_eq!(back.cert.claims[0].0, "environment", "claims kept sorted"); |
|
| 408 | - | assert_eq!(back.cert.claim("run"), Some("12")); |
|
| 409 | - | assert!(back.verify(&hub).is_ok()); |
|
| 410 | - | let other_sk = SecretKey::generate(); |
|
| 411 | - | let other = other_sk.identity("elsewhere", "[email protected]", 1); |
|
| 412 | - | assert!(matches!( |
|
| 413 | - | back.verify(&other), |
|
| 414 | - | Err(CertError::WrongIssuer(_)) |
|
| 415 | - | )); |
|
| 416 | - | // Changed after signing: the signature no longer covers it. |
|
| 417 | - | let mut forged = back.clone(); |
|
| 418 | - | forged.cert.subject = "repo:andy/demo:bookmark:release".into(); |
|
| 419 | - | assert!(matches!( |
|
| 420 | - | forged.verify(&hub), |
|
| 421 | - | Err(CertError::BadSignature(_)) |
|
| 422 | - | )); |
|
| 423 | - | // Signed by another key in the issuer's name. |
|
| 424 | - | let mut stolen = back.clone(); |
|
| 425 | - | stolen.sig = other_sk.sign(Domain::Certificate, &hub.hash(), &back.cert.hash()); |
|
| 426 | - | assert!(matches!( |
|
| 427 | - | stolen.verify(&hub), |
|
| 428 | - | Err(CertError::BadSignature(_)) |
|
| 429 | - | )); |
|
| 430 | - | } |
|
| 431 | - | ||
| 432 | - | #[test] |
|
| 433 | - | fn a_grant_rides_in_its_certificate_and_verifies_with_its_author_only() { |
|
| 434 | - | let hub_sk = SecretKey::generate(); |
|
| 435 | - | let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1); |
|
| 436 | - | let ada_sk = SecretKey::generate(); |
|
| 437 | - | let ada = ada_sk.identity("Ada", "[email protected]", 1); |
|
| 438 | - | let g = SignedGrant::sign( |
|
| 439 | - | Grant { |
|
| 440 | - | grantor: ada.hash(), |
|
| 441 | - | subject: "agent:claude@ada".into(), |
|
| 442 | - | issuer: hub.hash(), |
|
| 443 | - | scope: Scope::CHANGES | Scope::SEAL, |
|
| 444 | - | not_before: 1_000, |
|
| 445 | - | expires: 2_000, |
|
| 446 | - | }, |
|
| 447 | - | &ada, |
|
| 448 | - | &ada_sk, |
|
| 449 | - | ); |
|
| 450 | - | assert!(g.verify(&ada).is_ok()); |
|
| 451 | - | assert!(g.verify(&hub).is_err(), "only its author's"); |
|
| 452 | - | assert!(g.grant.covers(Scope::SEAL, 1_500)); |
|
| 453 | - | assert!(!g.grant.covers(Scope::REFS, 1_500)); |
|
| 454 | - | assert!(!g.grant.covers(Scope::SEAL, 2_001)); |
|
| 455 | - | let (_, plain) = issue(&hub_sk, &hub, 10); |
|
| 456 | - | assert!( |
|
| 457 | - | !matches!(cbor::decode(&plain.cert.encode()).unwrap(), Value::Map(m) if m.contains_key(&8)), |
|
| 458 | - | "no grant, no field 8" |
|
| 459 | - | ); |
|
| 460 | - | let mut with = plain.cert.clone(); |
|
| 461 | - | with.grant = Some(g.clone()); |
|
| 462 | - | let sc = SignedCertificate::sign(with, &hub, &hub_sk); |
|
| 463 | - | let back = SignedCertificate::decode(&sc.encode()).unwrap(); |
|
| 464 | - | assert_eq!(back.cert.grant, Some(g)); |
|
| 465 | - | assert!(back.verify(&hub).is_ok()); |
|
| 466 | - | assert_ne!(back.cert.hash(), plain.cert.hash()); |
|
| 467 | - | } |
|
| 468 | - | ||
| 469 | - | #[test] |
|
| 470 | - | fn it_covers_its_scope_and_window_only() { |
|
| 471 | - | let hub_sk = SecretKey::generate(); |
|
| 472 | - | let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1); |
|
| 473 | - | let (_, sc) = issue(&hub_sk, &hub, 10); |
|
| 474 | - | let c = &sc.cert; |
|
| 475 | - | assert!(c.covers(Scope::CHANGES, 1_000) && c.covers(Scope::REFS, 1_000 + 10 * 60_000)); |
|
| 476 | - | assert!(!c.covers(Scope::SEAL, 2_000), "not a scope it gives"); |
|
| 477 | - | assert!(!c.covers(Scope::CHANGES, 999), "before it"); |
|
| 478 | - | assert!( |
|
| 479 | - | !c.covers(Scope::CHANGES, 1_000 + 10 * 60_000 + 1), |
|
| 480 | - | "after it" |
|
| 481 | - | ); |
|
| 482 | - | assert!(!c.covers(Scope::NONE, 2_000)); |
|
| 483 | - | let (_, long) = issue(&hub_sk, &hub, 61); |
|
| 484 | - | assert_eq!(long.verify(&hub), Err(CertError::BadLifetime)); |
|
| 485 | - | } |
|
| 486 | - | ||
| 487 | - | #[test] |
|
| 488 | - | fn the_proof_of_possession_is_bound_to_its_credential() { |
|
| 489 | - | let sk = SecretKey::generate(); |
|
| 490 | - | let id = sk.identity("job", "[email protected]", 1); |
|
| 491 | - | let proof = sk |
|
| 492 | - | .sign_challenge(&id.hash(), &certify_challenge("flowjob_aaa")) |
|
| 493 | - | .unwrap(); |
|
| 494 | - | assert!( |
|
| 495 | - | crate::identity::verify_challenge(&id, &proof, &certify_challenge("flowjob_aaa")) |
|
| 496 | - | .is_ok() |
|
| 497 | - | ); |
|
| 498 | - | assert!( |
|
| 499 | - | crate::identity::verify_challenge(&id, &proof, &certify_challenge("flowjob_bbb")) |
|
| 500 | - | .is_err() |
|
| 501 | - | ); |
|
| 502 | - | } |
|
| 503 | - | ||
| 504 | - | #[test] |
|
| 505 | - | fn a_signature_by_the_short_lived_key_names_it() { |
|
| 506 | - | let hub_sk = SecretKey::generate(); |
|
| 507 | - | let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1); |
|
| 508 | - | let (job_sk, sc) = issue(&hub_sk, &hub, 10); |
|
| 509 | - | let target = ObjectRef([5; 32]); |
|
| 510 | - | let sig = job_sk.sign(Domain::Change, &sc.cert.key_id(), &target); |
|
| 511 | - | assert_eq!(sig.key, sc.cert.key_id()); |
|
| 512 | - | assert!(verify(&sc.cert.key, &sig, Domain::Change, &target).is_ok()); |
|
| 513 | - | } |
|
| 514 | - | } |
modified crates/oxy-core/src/credentials.rs +8 −122
| … | 82 unchanged lines |
||
| 83 | 83 | pub token: Option<String>, |
|
| 84 | 84 | /// Extra roots to trust for this hub (a self-signed hub). |
|
| 85 | 85 | pub ca: Option<PathBuf>, |
|
| 86 | - | /// An agent's grant token for keyless signing (oxygenhub#241), when it |
|
| 87 | - | /// is in the file (not with a helper). |
|
| 88 | - | pub grant: Option<String>, |
|
| 89 | 86 | } |
|
| 90 | 87 | ||
| 91 | 88 | /// Where a saved token went. |
|
| … | 71 unchanged lines |
||
| 163 | 160 | entry.token = run_helper(h, "get", hub, None)?; |
|
| 164 | 161 | } |
|
| 165 | 162 | } |
|
| 166 | - | if entry.token.is_none() |
|
| 167 | - | && entry.account.is_none() |
|
| 168 | - | && entry.ca.is_none() |
|
| 169 | - | && entry.grant.is_none() |
|
| 170 | - | { |
|
| 163 | + | if entry.token.is_none() && entry.account.is_none() && entry.ca.is_none() { |
|
| 171 | 164 | return Ok(None); |
|
| 172 | 165 | } |
|
| 173 | 166 | Ok(Some(entry)) |
|
| … | 33 unchanged lines |
||
| 207 | 200 | Ok(stored) |
|
| 208 | 201 | } |
|
| 209 | 202 | ||
| 210 | - | /// Save an agent's grant token for `hub` (keyless signing, spec §14.7): |
|
| 211 | - | /// in the file, or the helper under its own user name. |
|
| 212 | - | pub fn save_grant(&self, hub: &str, token: &str) -> Result<Stored, String> { |
|
| 213 | - | let mut all = self.read()?; |
|
| 214 | - | let entry = all.entry(hub.to_string()).or_insert_with(|| HubEntry { |
|
| 215 | - | hub: hub.to_string(), |
|
| 216 | - | ..HubEntry::default() |
|
| 217 | - | }); |
|
| 218 | - | let stored = match self.helper() { |
|
| 219 | - | Some(h) => { |
|
| 220 | - | run_helper_as(&h, "store", hub, GRANT_USER, Some(token))?; |
|
| 221 | - | entry.grant = None; |
|
| 222 | - | Stored::Helper(h) |
|
| 223 | - | } |
|
| 224 | - | None => { |
|
| 225 | - | entry.grant = Some(token.to_string()); |
|
| 226 | - | Stored::File(self.path()) |
|
| 227 | - | } |
|
| 228 | - | }; |
|
| 229 | - | self.write(&all)?; |
|
| 230 | - | Ok(stored) |
|
| 231 | - | } |
|
| 232 | - | ||
| 233 | - | /// The agent grant token kept for `hub`, if any. |
|
| 234 | - | pub fn grant(&self, hub: &str) -> Result<Option<String>, String> { |
|
| 235 | - | if let Some(g) = self.read()?.get(hub).and_then(|e| e.grant.clone()) { |
|
| 236 | - | return Ok(Some(g)); |
|
| 237 | - | } |
|
| 238 | - | match self.helper() { |
|
| 239 | - | Some(h) => run_helper_as(&h, "get", hub, GRANT_USER, None), |
|
| 240 | - | None => Ok(None), |
|
| 241 | - | } |
|
| 242 | - | } |
|
| 243 | - | ||
| 244 | 203 | /// Remove `hub` from the file and the helper; returns what it held, |
|
| 245 | 204 | /// with the token, so the caller can revoke it. |
|
| 246 | 205 | pub fn remove(&self, hub: &str) -> Result<Option<HubEntry>, String> { |
|
| … | 53 unchanged lines |
||
| 300 | 259 | "account" => e.account = Some(v), |
|
| 301 | 260 | "token" => e.token = Some(v), |
|
| 302 | 261 | "ca" => e.ca = Some(PathBuf::from(v)), |
|
| 303 | - | "grant" => e.grant = Some(v), |
|
| 304 | 262 | // Unknown keys are kept out of lookups, not fatal. |
|
| 305 | 263 | _ => {} |
|
| 306 | 264 | } |
|
| … | 17 unchanged lines |
||
| 324 | 282 | } |
|
| 325 | 283 | if let Some(c) = &e.ca { |
|
| 326 | 284 | text.push_str(&format!("ca = {}\n", quote(&c.to_string_lossy()))); |
|
| 327 | - | } |
|
| 328 | - | if let Some(g) = &e.grant { |
|
| 329 | - | text.push_str(&format!("grant = {}\n", quote(g))); |
|
| 330 | 285 | } |
|
| 331 | 286 | } |
|
| 332 | 287 | let path = self.path(); |
|
| … | 22 unchanged lines |
||
| 355 | 310 | (None, None) |
|
| 356 | 311 | } else { |
|
| 357 | 312 | match self.get(&hub) { |
|
| 358 | - | // An agent's grant token serves when the home holds no |
|
| 359 | - | // other: the hub takes it for the owner's repositories |
|
| 360 | - | // (keyless agents, oxygenhub#241). |
|
| 361 | - | Ok(Some(e)) => ( |
|
| 362 | - | e.token |
|
| 363 | - | .or(e.grant) |
|
| 364 | - | .or_else(|| self.grant(&hub).ok().flatten()), |
|
| 365 | - | e.ca, |
|
| 366 | - | ), |
|
| 313 | + | Ok(Some(e)) => (e.token, e.ca), |
|
| 367 | 314 | Ok(None) => (None, None), |
|
| 368 | 315 | Err(e) => { |
|
| 369 | 316 | warn_once(&format!("warning: credentials for {hub}: {e}")); |
|
| … | 17 unchanged lines |
||
| 387 | 334 | RemoteOptions { |
|
| 388 | 335 | token: env_token.or(token), |
|
| 389 | 336 | ca: env_ca.or(ca), |
|
| 390 | - | ..RemoteOptions::default() |
|
| 337 | + | repo_id: None, |
|
| 338 | + | cache_dir: None, |
|
| 391 | 339 | } |
|
| 392 | 340 | } |
|
| 393 | 341 | } |
|
| … | 9 unchanged lines |
||
| 403 | 351 | cache().lock().unwrap().clear(); |
|
| 404 | 352 | } |
|
| 405 | 353 | ||
| 406 | - | pub(crate) fn warn_once(message: &str) { |
|
| 354 | + | fn warn_once(message: &str) { |
|
| 407 | 355 | static SEEN: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new(); |
|
| 408 | 356 | if SEEN |
|
| 409 | 357 | .get_or_init(Default::default) |
|
| … | 42 unchanged lines |
||
| 452 | 400 | /// `auth.helper` is a helper name (run as `git credential-NAME`), an |
|
| 453 | 401 | /// absolute path, or a shell command after `!`, as in Git. |
|
| 454 | 402 | fn run_helper( |
|
| 455 | - | helper: &str, |
|
| 456 | - | action: &str, |
|
| 457 | - | hub: &str, |
|
| 458 | - | token: Option<&str>, |
|
| 459 | - | ) -> Result<Option<String>, String> { |
|
| 460 | - | run_helper_as(helper, action, hub, HELPER_USER, token) |
|
| 461 | - | } |
|
| 462 | - | ||
| 463 | - | /// The helper's user name for an agent's grant token, beside the hub's |
|
| 464 | - | /// own token. |
|
| 465 | - | const GRANT_USER: &str = "oxy-agent-grant"; |
|
| 466 | - | ||
| 467 | - | fn run_helper_as( |
|
| 468 | 403 | helper: &str, |
|
| 469 | 404 | action: &str, |
|
| 470 | 405 | hub: &str, |
|
| 471 | - | user: &str, |
|
| 472 | 406 | token: Option<&str>, |
|
| 473 | 407 | ) -> Result<Option<String>, String> { |
|
| 474 | 408 | let (protocol, host) = hub |
|
| … | 12 unchanged lines |
||
| 487 | 421 | c.arg(format!("credential-{helper}")).arg(action); |
|
| 488 | 422 | c |
|
| 489 | 423 | }; |
|
| 490 | - | let mut input = format!("protocol={protocol}\nhost={host}\nusername={user}\n"); |
|
| 424 | + | let mut input = format!("protocol={protocol}\nhost={host}\nusername={HELPER_USER}\n"); |
|
| 491 | 425 | if let Some(t) = token { |
|
| 492 | 426 | input.push_str(&format!("password={t}\n")); |
|
| 493 | 427 | } |
|
| … | 4 unchanged lines |
||
| 498 | 432 | .stderr(Stdio::inherit()) |
|
| 499 | 433 | .spawn() |
|
| 500 | 434 | .map_err(|e| format!("running credential helper {helper:?}: {e}"))?; |
|
| 501 | - | // A helper need not read what it is told (an `erase` may just remove |
|
| 502 | - | // its file and exit); as Git does, a closed pipe is no error. Whether |
|
| 503 | - | // the helper succeeded is its exit status. |
|
| 504 | - | match child |
|
| 435 | + | child |
|
| 505 | 436 | .stdin |
|
| 506 | 437 | .take() |
|
| 507 | 438 | .expect("piped") |
|
| 508 | 439 | .write_all(input.as_bytes()) |
|
| 509 | - | { |
|
| 510 | - | Err(e) if e.kind() != std::io::ErrorKind::BrokenPipe => { |
|
| 511 | - | return Err(format!("credential helper {helper:?}: {e}")); |
|
| 512 | - | } |
|
| 513 | - | _ => {} |
|
| 514 | - | } |
|
| 440 | + | .map_err(|e| format!("credential helper {helper:?}: {e}"))?; |
|
| 515 | 441 | let out = child |
|
| 516 | 442 | .wait_with_output() |
|
| 517 | 443 | .map_err(|e| format!("credential helper {helper:?}: {e}"))?; |
|
| … | 13 unchanged lines |
||
| 531 | 457 | #[cfg(test)] |
|
| 532 | 458 | mod tests { |
|
| 533 | 459 | use super::*; |
|
| 534 | - | ||
| 535 | - | /// oxygenhub#241: an agent's grant token is kept beside the hub's own |
|
| 536 | - | /// token, owner-only, and read back without disturbing it. |
|
| 537 | - | #[test] |
|
| 538 | - | fn a_grant_token_is_kept_beside_the_hub_token() { |
|
| 539 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 540 | - | let store = Credentials::at(dir.path()); |
|
| 541 | - | let hub = "https://oxygenhub.io"; |
|
| 542 | - | assert_eq!(store.grant(hub).unwrap(), None); |
|
| 543 | - | store.save(hub, "oxyhub_t", Some("andy"), None).unwrap(); |
|
| 544 | - | store.save_grant(hub, "oxyagent_g").unwrap(); |
|
| 545 | - | assert_eq!(store.grant(hub).unwrap().as_deref(), Some("oxyagent_g")); |
|
| 546 | - | let e = store.get(hub).unwrap().unwrap(); |
|
| 547 | - | assert_eq!(e.token.as_deref(), Some("oxyhub_t")); |
|
| 548 | - | assert_eq!(e.account.as_deref(), Some("andy")); |
|
| 549 | - | // A grant alone is an entry too. |
|
| 550 | - | store |
|
| 551 | - | .save_grant("https://elsewhere.example", "oxyagent_h") |
|
| 552 | - | .unwrap(); |
|
| 553 | - | assert!(store.get("https://elsewhere.example").unwrap().is_some()); |
|
| 554 | - | // The grant is the credential a remote there is reached with; |
|
| 555 | - | // beside a hub token, the hub token is. |
|
| 556 | - | assert_eq!( |
|
| 557 | - | store.lookup("https://elsewhere.example/a/b").0.as_deref(), |
|
| 558 | - | Some("oxyagent_h") |
|
| 559 | - | ); |
|
| 560 | - | assert_eq!( |
|
| 561 | - | store.lookup("https://oxygenhub.io/a/b").0.as_deref(), |
|
| 562 | - | Some("oxyhub_t") |
|
| 563 | - | ); |
|
| 564 | - | #[cfg(unix)] |
|
| 565 | - | { |
|
| 566 | - | use std::os::unix::fs::PermissionsExt; |
|
| 567 | - | let mode = std::fs::metadata(store.path()) |
|
| 568 | - | .unwrap() |
|
| 569 | - | .permissions() |
|
| 570 | - | .mode(); |
|
| 571 | - | assert_eq!(mode & 0o077, 0, "owner only"); |
|
| 572 | - | } |
|
| 573 | - | } |
|
| 574 | 460 | ||
| 575 | 461 | #[test] |
|
| 576 | 462 | fn hubs_are_scheme_host_and_port() { |
|
| … | 115 unchanged lines |
removed crates/oxy-core/src/dsse.rs +0 −151
| 1 | - | //! DSSE envelopes, the signature wrapper in-toto, SLSA and cosign read, and |
|
| 2 | - | //! the PEM public key cosign's `--key` takes: how Oxygen's attestations |
|
| 3 | - | //! reach tools outside it (spec §14.6, oxygenhub#239). Only the Ed25519 half |
|
| 4 | - | //! of a composite key signs here, since DSSE verifiers know Ed25519 and not |
|
| 5 | - | //! ML-DSA-65; DSSE's own pre-authentication encoding separates the domain |
|
| 6 | - | //! from Oxygen's signatures (which begin `oxy1 sig`). |
|
| 7 | - | ||
| 8 | - | use crate::identity::{Identity, SecretKey, VerifyError}; |
|
| 9 | - | ||
| 10 | - | /// DSSE's payload type for in-toto statements. |
|
| 11 | - | pub const IN_TOTO: &str = "application/vnd.in-toto+json"; |
|
| 12 | - | ||
| 13 | - | /// DSSE's pre-authentication encoding: what the signature covers. |
|
| 14 | - | pub fn pae(payload_type: &str, payload: &[u8]) -> Vec<u8> { |
|
| 15 | - | let mut out = format!( |
|
| 16 | - | "DSSEv1 {} {} {} ", |
|
| 17 | - | payload_type.len(), |
|
| 18 | - | payload_type, |
|
| 19 | - | payload.len() |
|
| 20 | - | ) |
|
| 21 | - | .into_bytes(); |
|
| 22 | - | out.extend_from_slice(payload); |
|
| 23 | - | out |
|
| 24 | - | } |
|
| 25 | - | ||
| 26 | - | /// A DSSE envelope over `payload`, signed by `sk`, as JSON. `keyid` names |
|
| 27 | - | /// the key to a reader; DSSE gives it no meaning. |
|
| 28 | - | pub fn envelope(payload_type: &str, payload: &[u8], keyid: &str, sk: &SecretKey) -> String { |
|
| 29 | - | let sig = sk.sign_ed25519(&pae(payload_type, payload)); |
|
| 30 | - | format!( |
|
| 31 | - | "{{\"payloadType\":{},\"payload\":\"{}\",\"signatures\":[{{\"keyid\":{},\"sig\":\"{}\"}}]}}", |
|
| 32 | - | crate::repo::json_str(payload_type), |
|
| 33 | - | base64(payload), |
|
| 34 | - | crate::repo::json_str(keyid), |
|
| 35 | - | base64(&sig) |
|
| 36 | - | ) |
|
| 37 | - | } |
|
| 38 | - | ||
| 39 | - | /// Check one envelope signature, its parts already decoded. |
|
| 40 | - | pub fn verify( |
|
| 41 | - | identity: &Identity, |
|
| 42 | - | payload_type: &str, |
|
| 43 | - | payload: &[u8], |
|
| 44 | - | sig: &[u8], |
|
| 45 | - | ) -> Result<(), VerifyError> { |
|
| 46 | - | identity.verify_ed25519(&pae(payload_type, payload), sig) |
|
| 47 | - | } |
|
| 48 | - | ||
| 49 | - | /// The identity's Ed25519 public key as a PEM SubjectPublicKeyInfo. |
|
| 50 | - | pub fn public_pem(identity: &Identity) -> String { |
|
| 51 | - | // SEQUENCE { SEQUENCE { OID 1.3.101.112 }, BIT STRING { key } } |
|
| 52 | - | let mut der = vec![ |
|
| 53 | - | 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, |
|
| 54 | - | ]; |
|
| 55 | - | der.extend_from_slice(&identity.ed25519_pk); |
|
| 56 | - | format!( |
|
| 57 | - | "-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----\n", |
|
| 58 | - | base64(&der) |
|
| 59 | - | ) |
|
| 60 | - | } |
|
| 61 | - | ||
| 62 | - | const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; |
|
| 63 | - | ||
| 64 | - | /// Standard base64 with padding, as DSSE and PEM use. |
|
| 65 | - | pub fn base64(bytes: &[u8]) -> String { |
|
| 66 | - | let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); |
|
| 67 | - | for chunk in bytes.chunks(3) { |
|
| 68 | - | let n = chunk.iter().fold(0u32, |acc, b| (acc << 8) | *b as u32) << (8 * (3 - chunk.len())); |
|
| 69 | - | for i in 0..4 { |
|
| 70 | - | if i <= chunk.len() { |
|
| 71 | - | out.push(ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] as char); |
|
| 72 | - | } else { |
|
| 73 | - | out.push('='); |
|
| 74 | - | } |
|
| 75 | - | } |
|
| 76 | - | } |
|
| 77 | - | out |
|
| 78 | - | } |
|
| 79 | - | ||
| 80 | - | /// [`base64`] back to bytes; `None` for anything that is not. |
|
| 81 | - | pub fn base64_decode(text: &str) -> Option<Vec<u8>> { |
|
| 82 | - | let text = text.trim_end_matches('='); |
|
| 83 | - | let mut out = Vec::with_capacity(text.len() * 3 / 4); |
|
| 84 | - | let (mut acc, mut bits) = (0u32, 0); |
|
| 85 | - | for c in text.bytes() { |
|
| 86 | - | let v = ALPHABET.iter().position(|a| *a == c)? as u32; |
|
| 87 | - | acc = (acc << 6) | v; |
|
| 88 | - | bits += 6; |
|
| 89 | - | if bits >= 8 { |
|
| 90 | - | bits -= 8; |
|
| 91 | - | out.push((acc >> bits) as u8); |
|
| 92 | - | acc &= (1 << bits) - 1; |
|
| 93 | - | } |
|
| 94 | - | } |
|
| 95 | - | Some(out) |
|
| 96 | - | } |
|
| 97 | - | ||
| 98 | - | #[cfg(test)] |
|
| 99 | - | mod tests { |
|
| 100 | - | use super::*; |
|
| 101 | - | ||
| 102 | - | #[test] |
|
| 103 | - | fn pae_and_base64_are_the_specs() { |
|
| 104 | - | // DSSE's own example: PAE("http://example.com/HelloWorld", "hello world"). |
|
| 105 | - | assert_eq!( |
|
| 106 | - | pae("http://example.com/HelloWorld", b"hello world"), |
|
| 107 | - | b"DSSEv1 29 http://example.com/HelloWorld 11 hello world".to_vec() |
|
| 108 | - | ); |
|
| 109 | - | for (plain, coded) in [ |
|
| 110 | - | ("", ""), |
|
| 111 | - | ("f", "Zg=="), |
|
| 112 | - | ("fo", "Zm8="), |
|
| 113 | - | ("foo", "Zm9v"), |
|
| 114 | - | ("foobar", "Zm9vYmFy"), |
|
| 115 | - | ] { |
|
| 116 | - | assert_eq!(base64(plain.as_bytes()), coded); |
|
| 117 | - | assert_eq!(base64_decode(coded).unwrap(), plain.as_bytes()); |
|
| 118 | - | } |
|
| 119 | - | assert!(base64_decode("Zm9v!").is_none()); |
|
| 120 | - | } |
|
| 121 | - | ||
| 122 | - | #[test] |
|
| 123 | - | fn an_envelope_verifies_with_its_key_and_nothing_else() { |
|
| 124 | - | let sk = SecretKey::generate(); |
|
| 125 | - | let id = sk.identity("Ada", "[email protected]", 1); |
|
| 126 | - | let other = SecretKey::generate().identity("Eve", "[email protected]", 1); |
|
| 127 | - | let payload = br#"{"_type":"https://in-toto.io/Statement/v1"}"#; |
|
| 128 | - | let env = envelope(IN_TOTO, payload, "k", &sk); |
|
| 129 | - | let sig_b64 = env |
|
| 130 | - | .rsplit("\"sig\":\"") |
|
| 131 | - | .next() |
|
| 132 | - | .unwrap() |
|
| 133 | - | .trim_end_matches("\"}]}"); |
|
| 134 | - | let sig = base64_decode(sig_b64).unwrap(); |
|
| 135 | - | assert!(env.contains(&format!("\"payload\":\"{}\"", base64(payload)))); |
|
| 136 | - | assert!(verify(&id, IN_TOTO, payload, &sig).is_ok()); |
|
| 137 | - | assert!(verify(&other, IN_TOTO, payload, &sig).is_err()); |
|
| 138 | - | assert!(verify(&id, IN_TOTO, b"{}", &sig).is_err()); |
|
| 139 | - | assert!(verify(&id, "text/plain", payload, &sig).is_err()); |
|
| 140 | - | } |
|
| 141 | - | ||
| 142 | - | #[test] |
|
| 143 | - | fn the_pem_holds_the_ed25519_key() { |
|
| 144 | - | let id = SecretKey::generate().identity("Ada", "[email protected]", 1); |
|
| 145 | - | let pem = public_pem(&id); |
|
| 146 | - | let body: String = pem.lines().filter(|l| !l.starts_with("-----")).collect(); |
|
| 147 | - | let der = base64_decode(&body).unwrap(); |
|
| 148 | - | assert_eq!(der.len(), 44); |
|
| 149 | - | assert_eq!(&der[12..], &id.ed25519_pk); |
|
| 150 | - | } |
|
| 151 | - | } |
modified crates/oxy-core/src/graph.rs +0 −53
| … | 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 | - | ||
| 449 | 396 | /// Number of atoms introduced by `change` in this graph (for index bookkeeping). |
|
| 450 | 397 | pub fn atom_count(&self) -> usize { |
|
| 451 | 398 | self.atoms.len() |
|
| … | 1484 unchanged lines |
modified crates/oxy-core/src/hash.rs +1 −14
| … | 63 unchanged lines |
||
| 64 | 64 | Policy, |
|
| 65 | 65 | State, |
|
| 66 | 66 | Receipt, |
|
| 67 | - | /// A short-lived key's certificate (oxygenhub#241). |
|
| 68 | - | Certificate, |
|
| 69 | - | /// An author's grant to a certified subject (§14.7). |
|
| 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, |
|
| 74 | 67 | } |
|
| 75 | 68 | ||
| 76 | 69 | impl ObjectType { |
|
| 77 | - | pub const ALL: [ObjectType; 13] = [ |
|
| 70 | + | pub const ALL: [ObjectType; 10] = [ |
|
| 78 | 71 | ObjectType::Chunk, |
|
| 79 | 72 | ObjectType::Blob, |
|
| 80 | 73 | ObjectType::Change, |
|
| … | 4 unchanged lines |
||
| 85 | 78 | ObjectType::Policy, |
|
| 86 | 79 | ObjectType::State, |
|
| 87 | 80 | ObjectType::Receipt, |
|
| 88 | - | ObjectType::Certificate, |
|
| 89 | - | ObjectType::Grant, |
|
| 90 | - | ObjectType::Artifact, |
|
| 91 | 81 | ]; |
|
| 92 | 82 | ||
| 93 | 83 | pub fn name(self) -> &'static str { |
|
| … | 8 unchanged lines |
||
| 102 | 92 | ObjectType::Policy => "policy", |
|
| 103 | 93 | ObjectType::State => "state", |
|
| 104 | 94 | ObjectType::Receipt => "receipt", |
|
| 105 | - | ObjectType::Certificate => "certificate", |
|
| 106 | - | ObjectType::Grant => "grant", |
|
| 107 | - | ObjectType::Artifact => "artifact", |
|
| 108 | 95 | } |
|
| 109 | 96 | } |
|
| 110 | 97 | ||
| … | 59 unchanged lines |
modified crates/oxy-core/src/identity.rs +1 −25
| … | 59 unchanged lines |
||
| 60 | 60 | .map(|(_, n)| *n) |
|
| 61 | 61 | .collect() |
|
| 62 | 62 | } |
|
| 63 | - | pub(crate) fn named(name: &str) -> Option<Scope> { |
|
| 63 | + | fn named(name: &str) -> Option<Scope> { |
|
| 64 | 64 | Self::NAMES |
|
| 65 | 65 | .iter() |
|
| 66 | 66 | .find(|(_, n)| *n == name) |
|
| … | 226 unchanged lines |
||
| 293 | 293 | } |
|
| 294 | 294 | } |
|
| 295 | 295 | ||
| 296 | - | /// Check a [`SecretKey::sign_ed25519`] signature by this identity. |
|
| 297 | - | pub fn verify_ed25519(&self, msg: &[u8], sig: &[u8]) -> Result<(), VerifyError> { |
|
| 298 | - | let sig = EdSignature::from_slice(sig).map_err(|_| VerifyError::Ed25519)?; |
|
| 299 | - | self.ed_key()? |
|
| 300 | - | .verify_strict(msg, &sig) |
|
| 301 | - | .map_err(|_| VerifyError::Ed25519) |
|
| 302 | - | } |
|
| 303 | - | ||
| 304 | 296 | fn ed_key(&self) -> Result<EdVerifyingKey, VerifyError> { |
|
| 305 | 297 | EdVerifyingKey::from_bytes(&self.ed25519_pk).map_err(|_| VerifyError::BadKey) |
|
| 306 | 298 | } |
|
| … | 12 unchanged lines |
||
| 319 | 311 | /// A server-issued challenge, proving possession of a key to a host |
|
| 320 | 312 | /// (hub spec §5.3, §5.4). Never a stored object. |
|
| 321 | 313 | Challenge, |
|
| 322 | - | /// A certificate an issuer gives a short-lived key (§14.7). |
|
| 323 | - | Certificate, |
|
| 324 | - | /// An author's grant to a certified subject (§14.7). |
|
| 325 | - | Grant, |
|
| 326 | - | /// A transparency log's checkpoint note (§14.8). |
|
| 327 | - | Checkpoint, |
|
| 328 | 314 | } |
|
| 329 | 315 | ||
| 330 | 316 | impl Domain { |
|
| … | 3 unchanged lines |
||
| 334 | 320 | Domain::Attest => "attest", |
|
| 335 | 321 | Domain::Policy => "policy", |
|
| 336 | 322 | Domain::Challenge => "challenge", |
|
| 337 | - | Domain::Certificate => "certificate", |
|
| 338 | - | Domain::Grant => "grant", |
|
| 339 | - | Domain::Checkpoint => "checkpoint", |
|
| 340 | 323 | } |
|
| 341 | 324 | } |
|
| 342 | 325 | } |
|
| … | 151 unchanged lines |
||
| 494 | 477 | previous: None, |
|
| 495 | 478 | delegates: vec![], |
|
| 496 | 479 | } |
|
| 497 | - | } |
|
| 498 | - | ||
| 499 | - | /// Ed25519 alone over `msg`, for a format that separates its own |
|
| 500 | - | /// domain and whose verifiers know only Ed25519 (DSSE: see |
|
| 501 | - | /// [`crate::dsse`]). Oxygen's own objects are signed with [`Self::sign`]. |
|
| 502 | - | pub fn sign_ed25519(&self, msg: &[u8]) -> [u8; 64] { |
|
| 503 | - | self.ed().sign(msg).to_bytes() |
|
| 504 | 480 | } |
|
| 505 | 481 | ||
| 506 | 482 | pub fn sign(&self, domain: Domain, key: &KeyId, target: &ObjectRef) -> Signature { |
|
| … | 269 unchanged lines |
modified crates/oxy-core/src/lib.rs +0 −6
| … | 5 unchanged lines |
||
| 6 | 6 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); |
|
| 7 | 7 | ||
| 8 | 8 | pub mod alloc_stats; |
|
| 9 | - | pub mod approval; |
|
| 10 | - | pub mod artifact; |
|
| 11 | 9 | pub mod attest; |
|
| 12 | 10 | pub mod attributes; |
|
| 13 | 11 | pub mod cbor; |
|
| 14 | - | pub mod cert; |
|
| 15 | 12 | pub mod chunk; |
|
| 16 | 13 | pub mod credentials; |
|
| 17 | 14 | pub mod diff; |
|
| 18 | - | pub mod dsse; |
|
| 19 | 15 | pub mod durable; |
|
| 20 | 16 | pub mod fasthash; |
|
| 21 | 17 | pub mod flat; |
|
| … | 3 unchanged lines |
||
| 25 | 21 | pub mod ids; |
|
| 26 | 22 | pub mod lanes; |
|
| 27 | 23 | pub mod lines; |
|
| 28 | - | pub mod mcp; |
|
| 29 | 24 | pub mod objects; |
|
| 30 | 25 | pub mod oplog; |
|
| 31 | 26 | pub mod policy; |
|
| … | 8 unchanged lines |
||
| 40 | 35 | pub mod strata; |
|
| 41 | 36 | pub mod subrepo; |
|
| 42 | 37 | pub mod timing; |
|
| 43 | - | pub mod tlog; |
|
| 44 | 38 | pub mod tree; |
removed crates/oxy-core/src/mcp.rs +0 −353
| 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/policy.rs +4 −162
| … | 53 unchanged lines |
||
| 54 | 54 | pub threshold: u32, |
|
| 55 | 55 | pub require: Require, |
|
| 56 | 56 | pub allow_conflicts: bool, |
|
| 57 | - | /// Who else may sign, by what an issuer certified (§14.7): a short-lived |
|
| 58 | - | /// key whose certificate is the issuer's, for a subject the pattern |
|
| 59 | - | /// matches, counts as one signer within the scope given. |
|
| 60 | - | pub subjects: Vec<SubjectGrant>, |
|
| 61 | 57 | } |
|
| 62 | 58 | ||
| 63 | - | /// A rule's grant to the holders of certificates an issuer gives (§14.7): |
|
| 64 | - | /// `repo:andy/demo:bookmark:main`, `agent:claude@andy`, with `*` as in |
|
| 65 | - | /// reference patterns. |
|
| 66 | 59 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 67 | - | pub struct SubjectGrant { |
|
| 68 | - | pub pattern: String, |
|
| 69 | - | pub issuer: KeyId, |
|
| 70 | - | pub scope: identity::Scope, |
|
| 71 | - | } |
|
| 72 | - | ||
| 73 | - | impl SubjectGrant { |
|
| 74 | - | fn to_value(&self) -> Value { |
|
| 75 | - | Value::map([ |
|
| 76 | - | (0, Value::Text(self.pattern.clone())), |
|
| 77 | - | (1, Value::Bytes(self.issuer.0.to_vec())), |
|
| 78 | - | ( |
|
| 79 | - | 2, |
|
| 80 | - | Value::Array( |
|
| 81 | - | self.scope |
|
| 82 | - | .names() |
|
| 83 | - | .into_iter() |
|
| 84 | - | .map(|n| Value::Text(n.to_string())) |
|
| 85 | - | .collect(), |
|
| 86 | - | ), |
|
| 87 | - | ), |
|
| 88 | - | ]) |
|
| 89 | - | } |
|
| 90 | - | ||
| 91 | - | fn from_value(v: &Value) -> Result<SubjectGrant, ObjectError> { |
|
| 92 | - | let m = map(v, "subject")?; |
|
| 93 | - | let mut scope = identity::Scope::NONE; |
|
| 94 | - | for n in array(field(m, 2, "subject scope")?, "subject scope")? { |
|
| 95 | - | let n = text(n, "subject scope")?; |
|
| 96 | - | scope = scope |
|
| 97 | - | | identity::Scope::named(n) |
|
| 98 | - | .ok_or_else(|| ObjectError::Schema(format!("subject: unknown scope {n:?}")))?; |
|
| 99 | - | } |
|
| 100 | - | Ok(SubjectGrant { |
|
| 101 | - | pattern: text(field(m, 0, "subject pattern")?, "subject pattern")?.to_owned(), |
|
| 102 | - | issuer: oref(field(m, 1, "subject issuer")?, "subject issuer")?, |
|
| 103 | - | scope, |
|
| 104 | - | }) |
|
| 105 | - | } |
|
| 106 | - | } |
|
| 107 | - | ||
| 108 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 109 | 60 | pub struct Policy { |
|
| 110 | 61 | pub repo: [u8; 16], |
|
| 111 | 62 | pub serial: u64, |
|
| … | 50 unchanged lines |
||
| 162 | 113 | self.rules |
|
| 163 | 114 | .iter() |
|
| 164 | 115 | .map(|r| { |
|
| 165 | - | let mut v = Value::map([ |
|
| 116 | + | Value::map([ |
|
| 166 | 117 | (0, Value::Text(r.pattern.clone())), |
|
| 167 | 118 | (1, Self::keys_value(&r.keys)), |
|
| 168 | 119 | (2, Value::Uint(r.threshold as u64)), |
|
| 169 | 120 | (3, Value::Uint(r.require.tag())), |
|
| 170 | 121 | (4, Value::Bool(r.allow_conflicts)), |
|
| 171 | - | ]); |
|
| 172 | - | // Only when there are any, so a policy without |
|
| 173 | - | // them encodes, and hashes, as before. |
|
| 174 | - | if !r.subjects.is_empty() { |
|
| 175 | - | if let Value::Map(m) = &mut v { |
|
| 176 | - | m.insert( |
|
| 177 | - | 5, |
|
| 178 | - | Value::Array( |
|
| 179 | - | r.subjects.iter().map(SubjectGrant::to_value).collect(), |
|
| 180 | - | ), |
|
| 181 | - | ); |
|
| 182 | - | } |
|
| 183 | - | } |
|
| 184 | - | v |
|
| 122 | + | ]) |
|
| 185 | 123 | }) |
|
| 186 | 124 | .collect(), |
|
| 187 | 125 | ), |
|
| … | 36 unchanged lines |
||
| 224 | 162 | allow_conflicts: field(rm, 4, "allow_conflicts")? |
|
| 225 | 163 | .as_bool() |
|
| 226 | 164 | .ok_or_else(|| ObjectError::Schema("allow_conflicts: expected bool".into()))?, |
|
| 227 | - | subjects: match rm.get(&5) { |
|
| 228 | - | None => vec![], |
|
| 229 | - | Some(v) => array(v, "subjects")? |
|
| 230 | - | .iter() |
|
| 231 | - | .map(SubjectGrant::from_value) |
|
| 232 | - | .collect::<Result<_, _>>()?, |
|
| 233 | - | }, |
|
| 234 | 165 | }); |
|
| 235 | 166 | } |
|
| 236 | 167 | let identities = array(field(m, 5, "identities")?, "identities")? |
|
| … | 18 unchanged lines |
||
| 255 | 186 | )); |
|
| 256 | 187 | } |
|
| 257 | 188 | for r in &p.rules { |
|
| 258 | - | if r.threshold == 0 || r.threshold as usize > r.keys.len() + r.subjects.len() { |
|
| 189 | + | if r.threshold == 0 || r.threshold as usize > r.keys.len() { |
|
| 259 | 190 | return Err(ObjectError::Schema(format!( |
|
| 260 | - | "rule {:?}: threshold must be between 1 and the number of keys and subjects", |
|
| 191 | + | "rule {:?}: threshold must be between 1 and the number of keys", |
|
| 261 | 192 | r.pattern |
|
| 262 | 193 | ))); |
|
| 263 | 194 | } |
|
| … | 18 unchanged lines |
||
| 282 | 213 | self.rules |
|
| 283 | 214 | .iter() |
|
| 284 | 215 | .find(|r| glob_matches(&r.pattern, reference)) |
|
| 285 | - | } |
|
| 286 | - | ||
| 287 | - | /// Whether the rule for `reference` lets a certificate from `issuer` |
|
| 288 | - | /// for `subject` sign within `scope` (§14.7). The certificate itself, |
|
| 289 | - | /// and the time it is checked at, are the caller's to verify. |
|
| 290 | - | pub fn subject_grant( |
|
| 291 | - | &self, |
|
| 292 | - | reference: &str, |
|
| 293 | - | issuer: &KeyId, |
|
| 294 | - | subject: &str, |
|
| 295 | - | scope: identity::Scope, |
|
| 296 | - | ) -> bool { |
|
| 297 | - | !self.is_revoked(issuer) |
|
| 298 | - | && self.rule_for(reference).is_some_and(|r| { |
|
| 299 | - | r.subjects.iter().any(|g| { |
|
| 300 | - | g.issuer == *issuer && g.scope.has(scope) && glob_matches(&g.pattern, subject) |
|
| 301 | - | }) |
|
| 302 | - | }) |
|
| 303 | 216 | } |
|
| 304 | 217 | ||
| 305 | 218 | /// Whether `key` may update `reference`: listed in the matching rule and not revoked. |
|
| … | 73 unchanged lines |
||
| 379 | 292 | threshold: 1, |
|
| 380 | 293 | require: Require::Any, |
|
| 381 | 294 | allow_conflicts: true, |
|
| 382 | - | subjects: vec![], |
|
| 383 | 295 | }], |
|
| 384 | 296 | identities: vec![root], |
|
| 385 | 297 | revoked: vec![], |
|
| … | 81 unchanged lines |
||
| 467 | 379 | assert!(glob_matches("", "")); |
|
| 468 | 380 | } |
|
| 469 | 381 | ||
| 470 | - | /// Subjects an issuer certifies (oxygenhub#241): kept through |
|
| 471 | - | /// encoding, absent from a policy without them, and granted only for |
|
| 472 | - | /// their issuer, pattern and scope, and not when the issuer is revoked. |
|
| 473 | 382 | #[test] |
|
| 474 | - | fn a_rule_grants_certified_subjects() { |
|
| 475 | - | use crate::identity::Scope; |
|
| 476 | - | let root = sk(1).identity("Root", "r@x", 0); |
|
| 477 | - | let hub = sk(3).identity("Hub", "h@x", 0); |
|
| 478 | - | let mut p = Policy::single_root([9; 16], root.clone(), 10); |
|
| 479 | - | let plain = p.encode(); |
|
| 480 | - | p.rules[0].subjects.push(SubjectGrant { |
|
| 481 | - | pattern: "repo:andy/demo:bookmark:main".into(), |
|
| 482 | - | issuer: hub.hash(), |
|
| 483 | - | scope: Scope::REFS | Scope::CHANGES, |
|
| 484 | - | }); |
|
| 485 | - | p.rules[0].subjects.push(SubjectGrant { |
|
| 486 | - | pattern: "agent:*@andy".into(), |
|
| 487 | - | issuer: hub.hash(), |
|
| 488 | - | scope: Scope::CHANGES, |
|
| 489 | - | }); |
|
| 490 | - | let back = Policy::decode(&p.encode()).unwrap(); |
|
| 491 | - | assert_eq!(back, p); |
|
| 492 | - | let mut again = back.clone(); |
|
| 493 | - | again.rules[0].subjects.clear(); |
|
| 494 | - | assert_eq!( |
|
| 495 | - | again.encode(), |
|
| 496 | - | plain, |
|
| 497 | - | "no subjects, the same bytes as before" |
|
| 498 | - | ); |
|
| 499 | - | ||
| 500 | - | let main = "repo:andy/demo:bookmark:main"; |
|
| 501 | - | assert!(p.subject_grant("main", &hub.hash(), main, Scope::REFS)); |
|
| 502 | - | assert!(p.subject_grant("main", &hub.hash(), "agent:claude@andy", Scope::CHANGES)); |
|
| 503 | - | assert!( |
|
| 504 | - | !p.subject_grant("main", &hub.hash(), "agent:claude@andy", Scope::REFS), |
|
| 505 | - | "not its scope" |
|
| 506 | - | ); |
|
| 507 | - | assert!( |
|
| 508 | - | !p.subject_grant( |
|
| 509 | - | "main", |
|
| 510 | - | &hub.hash(), |
|
| 511 | - | "repo:andy/demo:bookmark:dev", |
|
| 512 | - | Scope::REFS |
|
| 513 | - | ), |
|
| 514 | - | "not its pattern" |
|
| 515 | - | ); |
|
| 516 | - | assert!( |
|
| 517 | - | !p.subject_grant("main", &root.hash(), main, Scope::REFS), |
|
| 518 | - | "not its issuer" |
|
| 519 | - | ); |
|
| 520 | - | p.revoked.push(hub.hash()); |
|
| 521 | - | assert!( |
|
| 522 | - | !p.subject_grant("main", &hub.hash(), main, Scope::REFS), |
|
| 523 | - | "a revoked issuer grants nothing" |
|
| 524 | - | ); |
|
| 525 | - | ||
| 526 | - | // A rule of subjects alone is a rule. |
|
| 527 | - | let mut only = Policy::single_root([9; 16], root, 10); |
|
| 528 | - | only.rules[0].keys.clear(); |
|
| 529 | - | only.rules[0].subjects.push(SubjectGrant { |
|
| 530 | - | pattern: "*".into(), |
|
| 531 | - | issuer: hub.hash(), |
|
| 532 | - | scope: Scope::REFS, |
|
| 533 | - | }); |
|
| 534 | - | assert!(Policy::decode(&only.encode()).is_ok()); |
|
| 535 | - | only.rules[0].subjects.clear(); |
|
| 536 | - | assert!(Policy::decode(&only.encode()).is_err(), "nobody to sign"); |
|
| 537 | - | } |
|
| 538 | - | ||
| 539 | - | #[test] |
|
| 540 | 383 | fn round_trip_and_rules() { |
|
| 541 | 384 | let root = sk(1).identity("Root", "r@x", 0); |
|
| 542 | 385 | let dev = sk(2).identity("Dev", "d@x", 0); |
|
| … | 6 unchanged lines |
||
| 549 | 392 | threshold: 1, |
|
| 550 | 393 | require: Require::Signed, |
|
| 551 | 394 | allow_conflicts: false, |
|
| 552 | - | subjects: vec![], |
|
| 553 | 395 | }, |
|
| 554 | 396 | ); |
|
| 555 | 397 | p.rules[1].keys.push(dev.hash()); |
|
| … | 54 unchanged lines |
modified crates/oxy-core/src/portable.rs +0 −65
| … | 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 | - | ||
| 140 | 103 | /// Why Windows would refuse a path, if it would. |
|
| 141 | 104 | pub fn windows_name_problem(path: &str) -> Option<&'static str> { |
|
| 142 | 105 | const RESERVED: &[&str] = &[ |
|
| … | 21 unchanged lines |
||
| 164 | 127 | #[cfg(test)] |
|
| 165 | 128 | mod tests { |
|
| 166 | 129 | use super::*; |
|
| 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 | 130 | ||
| 196 | 131 | #[test] |
|
| 197 | 132 | fn collisions_and_names() { |
|
| … | 25 unchanged lines |
modified crates/oxy-core/src/protocol.rs +23 −613
| … | 55 unchanged lines |
||
| 56 | 56 | pub history: Vec<SignedPolicy>, |
|
| 57 | 57 | /// The bookmark a clone checks out (spec §16.1), when the remote names one. |
|
| 58 | 58 | pub default_bookmark: Option<String>, |
|
| 59 | - | /// The host's latest transparency-log checkpoint, a signed note (spec |
|
| 60 | - | /// §14.8), against which `RemoteRef::logged` proofs are made (key 6, |
|
| 61 | - | /// only when the host keeps a log). |
|
| 62 | - | pub log: Option<String>, |
|
| 63 | - | /// The witnesses the host asks to cosign its checkpoints: each one's |
|
| 64 | - | /// name in cosignatures and its identity's bytes (key 7, only when it |
|
| 65 | - | /// has any), which a client pins (spec §14.8, *Integrated time*). |
|
| 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>, |
|
| 72 | - | } |
|
| 73 | - | ||
| 74 | - | /// Where a reference attestation sits in the host's transparency log |
|
| 75 | - | /// (spec §14.8): its entry number, the entry, and the inclusion proof |
|
| 76 | - | /// against the checkpoint served beside it. |
|
| 77 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 78 | - | pub struct Logged { |
|
| 79 | - | pub index: u64, |
|
| 80 | - | pub entry: Vec<u8>, |
|
| 81 | - | pub proof: Vec<[u8; 32]>, |
|
| 82 | - | } |
|
| 83 | - | ||
| 84 | - | /// A checkpoint witnesses cosigned that includes a logged entry (spec |
|
| 85 | - | /// §14.8, *Integrated time*): the cosigned note, and the entry's inclusion |
|
| 86 | - | /// proof against it. |
|
| 87 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 88 | - | pub struct Integration { |
|
| 89 | - | pub note: String, |
|
| 90 | - | pub proof: Vec<[u8; 32]>, |
|
| 91 | 59 | } |
|
| 92 | 60 | ||
| 93 | 61 | /// A tag as a remote reports it (spec §10.1). |
|
| … | 3 unchanged lines |
||
| 97 | 65 | pub state: ObjectRef, |
|
| 98 | 66 | pub heads: Vec<ChangeHash>, |
|
| 99 | 67 | 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 | - | } |
|
| 138 | 68 | } |
|
| 139 | 69 | ||
| 140 | 70 | /// One reference as a remote reports it. |
|
| … | 8 unchanged lines |
||
| 149 | 79 | /// Serial of the latest attestation ever recorded, stale or not, so the |
|
| 150 | 80 | /// next attestation continues the sequence. |
|
| 151 | 81 | pub last_serial: Option<u64>, |
|
| 152 | - | /// The host's acceptance of `attestation` when a short-lived key signed |
|
| 153 | - | /// it (spec §14.7): the time its certificate is checked at. |
|
| 154 | - | pub accepted: Option<SignedAttest>, |
|
| 155 | - | /// The attestation's place in the host's transparency log (key 6, only |
|
| 156 | - | /// when it has one). |
|
| 157 | - | pub logged: Option<Logged>, |
|
| 158 | - | /// The cosigned checkpoints that first included `logged`'s entry (key |
|
| 159 | - | /// 7, only when there are any): what its integrated time is read from. |
|
| 160 | - | pub integration: Vec<Integration>, |
|
| 161 | 82 | } |
|
| 162 | 83 | ||
| 163 | 84 | /// Everything needed to add a set of changes to a repository. |
|
| … | 16 unchanged lines |
||
| 180 | 101 | /// fetch between repositories on one machine: their text blobs and |
|
| 181 | 102 | /// chunks are among `objects`. Never on the wire. |
|
| 182 | 103 | pub raw_changes: Vec<(Vec<u8>, Vec<Signature>)>, |
|
| 183 | - | /// Certificates of short-lived keys that signed what the bundle carries |
|
| 184 | - | /// (spec §14.7), their issuers' identities among `objects`. |
|
| 185 | - | pub certificates: Vec<crate::cert::SignedCertificate>, |
|
| 186 | 104 | } |
|
| 187 | 105 | ||
| 188 | 106 | impl Bundle { |
|
| … | 33 unchanged lines |
||
| 222 | 140 | /// A checkpoint of exactly `heads` (spec §16.1 `checkpoint`): the |
|
| 223 | 141 | /// checkpoint object, its payload blob, and the blob's chunks. |
|
| 224 | 142 | 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 | - | } |
|
| 232 | 143 | /// The changes within `depth` dependency steps of `heads` (spec §17). |
|
| 233 | 144 | fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>>; |
|
| 234 | 145 | /// Push changes, bookmark updates, new tags, and optionally a newer |
|
| … | 25 unchanged lines |
||
| 260 | 171 | claimed_id: None, |
|
| 261 | 172 | }) |
|
| 262 | 173 | } |
|
| 263 | - | } |
|
| 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 | 174 | } |
|
| 271 | 175 | ||
| 272 | 176 | impl Remote for LocalRemote { |
|
| … | 7 unchanged lines |
||
| 280 | 184 | self.repo.negotiate(have, want) |
|
| 281 | 185 | } |
|
| 282 | 186 | fn fetch(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle> { |
|
| 283 | - | #[cfg(test)] |
|
| 284 | - | LOCAL_FETCHES.with(|n| n.set(n.get() + 1)); |
|
| 285 | 187 | self.repo.bundle_raw_for(changes, max_blob) |
|
| 286 | 188 | } |
|
| 287 | 189 | fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> { |
|
| … | 1 unchanged lines |
||
| 289 | 191 | } |
|
| 290 | 192 | fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle> { |
|
| 291 | 193 | self.repo.checkpoint_bundle_for(heads) |
|
| 292 | - | } |
|
| 293 | - | fn checkpoint_index(&self, heads: &[ChangeHash]) -> Result<Bundle> { |
|
| 294 | - | self.repo.checkpoint_index_for(heads) |
|
| 295 | 194 | } |
|
| 296 | 195 | fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> { |
|
| 297 | 196 | self.repo.shallow_changes(heads, depth) |
|
| … | 38 unchanged lines |
||
| 336 | 235 | /// `.oxy/remotes`. None (a clone, before there is a repository) means |
|
| 337 | 236 | /// a temporary directory. |
|
| 338 | 237 | pub cache_dir: Option<PathBuf>, |
|
| 339 | - | /// Other tokens for the same hub, tried in turn when `token` is |
|
| 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). |
|
| 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, |
|
| 349 | 238 | } |
|
| 350 | 239 | ||
| 351 | 240 | /// Opens a store for a URL it understands, or passes (`None`). Stores that |
|
| … | 31 unchanged lines |
||
| 383 | 272 | ||
| 384 | 273 | /// The id of a hosted repository that has not adopted one yet. |
|
| 385 | 274 | pub const UNBOUND_ID: [u8; 16] = [0; 16]; |
|
| 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 | 275 | ||
| 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 | - | ||
| 414 | 276 | /// A remote served by `oxy-server` at `<base>/oxy/v1/...`. |
|
| 415 | 277 | pub struct HttpRemote { |
|
| 416 | 278 | base: String, |
|
| 417 | - | /// The token in use, and those still to try when it is refused. |
|
| 418 | - | token: Mutex<Option<String>>, |
|
| 419 | - | fallbacks: Mutex<Vec<String>>, |
|
| 420 | - | fallback_on_401_only: bool, |
|
| 279 | + | token: Option<String>, |
|
| 421 | 280 | repo_id: Option<[u8; 16]>, |
|
| 422 | 281 | agent: ureq::Agent, |
|
| 423 | 282 | } |
|
| … | 36 unchanged lines |
||
| 460 | 319 | refuse_plain_token(base, options)?; |
|
| 461 | 320 | let agent = ureq::AgentBuilder::new() |
|
| 462 | 321 | .tls_config(tls_client_config(options.ca.as_deref())?) |
|
| 463 | - | .user_agent(&user_agent()) |
|
| 464 | 322 | .build(); |
|
| 465 | 323 | Ok(HttpRemote { |
|
| 466 | 324 | base: base.trim_end_matches('/').to_string(), |
|
| 467 | - | token: Mutex::new(options.token.clone()), |
|
| 468 | - | fallbacks: Mutex::new(options.fallback_tokens.iter().rev().cloned().collect()), |
|
| 469 | - | fallback_on_401_only: options.fallback_on_401_only, |
|
| 325 | + | token: options.token.clone(), |
|
| 470 | 326 | repo_id: options.repo_id, |
|
| 471 | 327 | agent, |
|
| 472 | 328 | }) |
|
| 473 | 329 | } |
|
| 474 | 330 | ||
| 475 | - | /// A request, tried again with the next fallback token while the |
|
| 476 | - | /// hub refuses the one in use (oxygen#112). |
|
| 477 | 331 | fn call(&self, post: bool, endpoint: &str, body: Option<&[u8]>) -> Result<Vec<u8>> { |
|
| 478 | - | loop { |
|
| 479 | - | let token = self.token.lock().unwrap_or_else(|p| p.into_inner()).clone(); |
|
| 480 | - | match self.call_once(post, endpoint, body, token.as_deref()) { |
|
| 481 | - | Err(RepoError::Msg(m)) |
|
| 482 | - | if token.is_some() |
|
| 483 | - | && refused(&m) |
|
| 484 | - | && (!self.fallback_on_401_only || m.contains(": HTTP 401")) => |
|
| 485 | - | { |
|
| 486 | - | let next = self |
|
| 487 | - | .fallbacks |
|
| 488 | - | .lock() |
|
| 489 | - | .unwrap_or_else(|p| p.into_inner()) |
|
| 490 | - | .pop(); |
|
| 491 | - | let Some(next) = next else { |
|
| 492 | - | return Err(RepoError::Msg(m)); |
|
| 493 | - | }; |
|
| 494 | - | crate::credentials::warn_once(&format!( |
|
| 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`)", |
|
| 497 | - | self.base |
|
| 498 | - | )); |
|
| 499 | - | *self.token.lock().unwrap_or_else(|p| p.into_inner()) = Some(next); |
|
| 500 | - | } |
|
| 501 | - | other => return other, |
|
| 502 | - | } |
|
| 503 | - | } |
|
| 504 | - | } |
|
| 505 | - | ||
| 506 | - | fn call_once( |
|
| 507 | - | &self, |
|
| 508 | - | post: bool, |
|
| 509 | - | endpoint: &str, |
|
| 510 | - | body: Option<&[u8]>, |
|
| 511 | - | token: Option<&str>, |
|
| 512 | - | ) -> Result<Vec<u8>> { |
|
| 513 | 332 | let url = format!("{}/oxy/v1/{endpoint}", self.base); |
|
| 514 | 333 | let mut req = if post { |
|
| 515 | 334 | self.agent.post(&url) |
|
| 516 | 335 | } else { |
|
| 517 | 336 | self.agent.get(&url) |
|
| 518 | 337 | }; |
|
| 519 | - | req = req |
|
| 520 | - | .set("Accept", CONTENT_TYPE) |
|
| 521 | - | .set(FEATURES_HEADER, &CLIENT_FEATURES.join(",")); |
|
| 522 | - | if let Some(t) = token { |
|
| 338 | + | req = req.set("Accept", CONTENT_TYPE); |
|
| 339 | + | if let Some(t) = &self.token { |
|
| 523 | 340 | req = req.set("Authorization", &format!("Bearer {t}")); |
|
| 524 | 341 | } |
|
| 525 | 342 | if let Some(id) = &self.repo_id { |
|
| … | 13 unchanged lines |
||
| 539 | 356 | } |
|
| 540 | 357 | Err(ureq::Error::Status(code, r)) => { |
|
| 541 | 358 | let text = r.into_string().unwrap_or_default(); |
|
| 542 | - | // The hint comes from the status alone: the server's text |
|
| 543 | - | // (a host's reason for refusing a push, several lines |
|
| 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). |
|
| 547 | - | let status = format!("{url}: HTTP {code}"); |
|
| 548 | - | let msg = format!("{status}: {}", plain_error_text(&text)); |
|
| 549 | - | Err(RepoError::Msg(match hint_for(&status) { |
|
| 550 | - | Some(h) => format!("{msg}\n hint: {h}"), |
|
| 551 | - | None => msg, |
|
| 552 | - | })) |
|
| 359 | + | let msg = format!("{url}: HTTP {code}: {}", text.trim()); |
|
| 360 | + | Err(RepoError::Msg(with_hint(msg))) |
|
| 553 | 361 | } |
|
| 554 | 362 | // The transport error already names the URL. |
|
| 555 | 363 | Err(e) => Err(RepoError::Msg(with_hint(e.to_string()))), |
|
| 556 | 364 | } |
|
| 557 | 365 | } |
|
| 558 | - | } |
|
| 559 | - | ||
| 560 | - | /// Whether a failed request's message says the hub refused its token: |
|
| 561 | - | /// HTTP 401, 403, or 404 (a hub hides a private repository from a token |
|
| 562 | - | /// that may not read it). |
|
| 563 | - | fn refused(message: &str) -> bool { |
|
| 564 | - | [": HTTP 401", ": HTTP 403", ": HTTP 404"] |
|
| 565 | - | .iter() |
|
| 566 | - | .any(|c| message.contains(c)) |
|
| 567 | 366 | } |
|
| 568 | 367 | ||
| 569 | 368 | /// Refuse to send a token where anyone on the way could read it: plain |
|
| … | 14 unchanged lines |
||
| 584 | 383 | "the hub needs you signed in, or did not accept the token: run `oxy auth login HUB` (pushing needs repo:write)" |
|
| 585 | 384 | } else if m.contains("http 403") { |
|
| 586 | 385 | "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" |
|
| 589 | 386 | } else if m.contains("http 404") { |
|
| 590 | 387 | "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`)" |
|
| 591 | 388 | } else if m.contains("unknownissuer") |
|
| … | 15 unchanged lines |
||
| 607 | 404 | return None; |
|
| 608 | 405 | }; |
|
| 609 | 406 | Some(hint) |
|
| 610 | - | } |
|
| 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 | 407 | } |
|
| 633 | 408 | ||
| 634 | 409 | /// `message`, followed by what to do about it when that is known. |
|
| … | 17 unchanged lines |
||
| 652 | 427 | let agent = ureq::AgentBuilder::new() |
|
| 653 | 428 | .tls_config(tls_client_config(options.ca.as_deref())?) |
|
| 654 | 429 | .timeout(std::time::Duration::from_secs(60)) |
|
| 655 | - | .user_agent(&user_agent()) |
|
| 656 | 430 | .build(); |
|
| 657 | 431 | let mut req = agent.request(method, url).set("Accept", "application/json"); |
|
| 658 | 432 | if let Some(t) = &options.token { |
|
| … | 19 unchanged lines |
||
| 678 | 452 | refuse_plain_token(url, options)?; |
|
| 679 | 453 | let agent = ureq::AgentBuilder::new() |
|
| 680 | 454 | .tls_config(tls_client_config(options.ca.as_deref())?) |
|
| 681 | - | .user_agent(&user_agent()) |
|
| 682 | 455 | .build(); |
|
| 683 | 456 | let mut req = agent.get(url).set("Accept", "application/json"); |
|
| 684 | 457 | if let Some(t) = &options.token { |
|
| … | 47 unchanged lines |
||
| 732 | 505 | ) |
|
| 733 | 506 | } |
|
| 734 | 507 | fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle> { |
|
| 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) |
|
| 508 | + | wire::decode_bundle(&self.call(true, "checkpoint", Some(&wire::encode_hashes(heads)))?) |
|
| 753 | 509 | } |
|
| 754 | 510 | fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> { |
|
| 755 | 511 | wire::decode_hashes(&self.call( |
|
| … | 176 unchanged lines |
||
| 932 | 688 | } |
|
| 933 | 689 | changes.push(Value::Array(vec![Value::Bytes(bytes), sigs_value(s)])); |
|
| 934 | 690 | } |
|
| 935 | - | let mut v = Value::map([ |
|
| 691 | + | Value::map([ |
|
| 936 | 692 | (0, Value::Array(changes)), |
|
| 937 | 693 | ( |
|
| 938 | 694 | 1, |
|
| … | 18 unchanged lines |
||
| 957 | 713 | 3, |
|
| 958 | 714 | Value::Array(b.policies.iter().map(policy_value).collect()), |
|
| 959 | 715 | ), |
|
| 960 | - | ]); |
|
| 961 | - | // Only when there are any; older peers ignore the field. |
|
| 962 | - | if !b.certificates.is_empty() { |
|
| 963 | - | if let Value::Map(m) = &mut v { |
|
| 964 | - | m.insert( |
|
| 965 | - | 4, |
|
| 966 | - | Value::Array( |
|
| 967 | - | b.certificates |
|
| 968 | - | .iter() |
|
| 969 | - | .map(|c| Value::Bytes(c.encode())) |
|
| 970 | - | .collect(), |
|
| 971 | - | ), |
|
| 972 | - | ); |
|
| 973 | - | } |
|
| 974 | - | } |
|
| 975 | - | v |
|
| 716 | + | ]) |
|
| 976 | 717 | } |
|
| 977 | 718 | ||
| 978 | 719 | pub fn bundle_from_value(v: &Value) -> Result<Bundle> { |
|
| … | 53 unchanged lines |
||
| 1032 | 773 | b.policies.push(policy_from(p)?); |
|
| 1033 | 774 | } |
|
| 1034 | 775 | } |
|
| 1035 | - | if let Some(cs) = m.get(&4) { |
|
| 1036 | - | for c in o::array(cs, "certificates")? { |
|
| 1037 | - | b.certificates.push( |
|
| 1038 | - | crate::cert::SignedCertificate::decode(o::bytes(c, "certificate")?) |
|
| 1039 | - | .map_err(|e| schema(&format!("certificate: {e}")))?, |
|
| 1040 | - | ); |
|
| 1041 | - | } |
|
| 1042 | - | } |
|
| 1043 | 776 | Ok(b) |
|
| 1044 | 777 | } |
|
| 1045 | 778 | ||
| … | 32 unchanged lines |
||
| 1078 | 811 | Value::Array( |
|
| 1079 | 812 | tags.iter() |
|
| 1080 | 813 | .map(|t| { |
|
| 1081 | - | let mut v = Value::map([ |
|
| 814 | + | Value::map([ |
|
| 1082 | 815 | (0, Value::Text(t.name.clone())), |
|
| 1083 | 816 | (1, Value::Bytes(t.state.0.to_vec())), |
|
| 1084 | 817 | (2, hashes_value(&t.heads)), |
|
| 1085 | 818 | (3, opt_attest_value(&t.attestation)), |
|
| 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 |
|
| 819 | + | ]) |
|
| 1101 | 820 | }) |
|
| 1102 | 821 | .collect(), |
|
| 1103 | 822 | ) |
|
| … | 7 unchanged lines |
||
| 1111 | 830 | state: o::oref(field(tm, 1, "state")?, "state")?, |
|
| 1112 | 831 | heads: hashes_from(field(tm, 2, "heads")?)?, |
|
| 1113 | 832 | 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])?, |
|
| 1187 | 833 | }); |
|
| 1188 | 834 | } |
|
| 1189 | 835 | Ok(out) |
|
| 1190 | 836 | } |
|
| 1191 | 837 | ||
| 1192 | 838 | pub fn encode_refs(id: [u8; 16], resp: &RefsResponse) -> Vec<u8> { |
|
| 1193 | - | let mut v = Value::map([ |
|
| 839 | + | cbor::encode(&Value::map([ |
|
| 1194 | 840 | ( |
|
| 1195 | 841 | 0, |
|
| 1196 | 842 | Value::Array( |
|
| 1197 | 843 | resp.refs |
|
| 1198 | 844 | .iter() |
|
| 1199 | 845 | .map(|r| { |
|
| 1200 | - | let mut v = Value::map([ |
|
| 846 | + | Value::map([ |
|
| 1201 | 847 | (0, Value::Text(r.name.clone())), |
|
| 1202 | 848 | (1, Value::Bytes(r.state.0.to_vec())), |
|
| 1203 | 849 | (2, hashes_value(&r.heads)), |
|
| 1204 | 850 | (3, opt_attest_value(&r.attestation)), |
|
| 1205 | 851 | (4, r.last_serial.map(Value::Uint).unwrap_or(Value::Null)), |
|
| 1206 | - | ]); |
|
| 1207 | - | // Only when there is one; older peers ignore it. |
|
| 1208 | - | if let Value::Map(m) = &mut v { |
|
| 1209 | - | if r.accepted.is_some() { |
|
| 1210 | - | m.insert(5, opt_attest_value(&r.accepted)); |
|
| 1211 | - | } |
|
| 1212 | - | if let Some(l) = &r.logged { |
|
| 1213 | - | m.insert(6, logged_value(l)); |
|
| 1214 | - | } |
|
| 1215 | - | if !r.integration.is_empty() { |
|
| 1216 | - | m.insert(7, integration_value(&r.integration)); |
|
| 1217 | - | } |
|
| 1218 | - | } |
|
| 1219 | - | v |
|
| 852 | + | ]) |
|
| 1220 | 853 | }) |
|
| 1221 | 854 | .collect(), |
|
| 1222 | 855 | ), |
|
| … | 12 unchanged lines |
||
| 1235 | 868 | .map(Value::Text) |
|
| 1236 | 869 | .unwrap_or(Value::Null), |
|
| 1237 | 870 | ), |
|
| 1238 | - | ]); |
|
| 1239 | - | if let Value::Map(m) = &mut v { |
|
| 1240 | - | if let Some(log) = &resp.log { |
|
| 1241 | - | m.insert(6, Value::Text(log.clone())); |
|
| 1242 | - | } |
|
| 1243 | - | if !resp.withdrawn.is_empty() { |
|
| 1244 | - | m.insert(8, tags_value(&resp.withdrawn)); |
|
| 1245 | - | } |
|
| 1246 | - | if !resp.witnesses.is_empty() { |
|
| 1247 | - | m.insert( |
|
| 1248 | - | 7, |
|
| 1249 | - | Value::Array( |
|
| 1250 | - | resp.witnesses |
|
| 1251 | - | .iter() |
|
| 1252 | - | .map(|(name, id)| { |
|
| 1253 | - | Value::Array(vec![ |
|
| 1254 | - | Value::Text(name.clone()), |
|
| 1255 | - | Value::Bytes(id.clone()), |
|
| 1256 | - | ]) |
|
| 1257 | - | }) |
|
| 1258 | - | .collect(), |
|
| 1259 | - | ), |
|
| 1260 | - | ); |
|
| 1261 | - | } |
|
| 1262 | - | } |
|
| 1263 | - | cbor::encode(&v) |
|
| 871 | + | ])) |
|
| 1264 | 872 | } |
|
| 1265 | 873 | pub fn decode_refs(bytes: &[u8]) -> Result<([u8; 16], RefsResponse)> { |
|
| 1266 | 874 | let v = cbor::decode(bytes).map_err(ObjectError::from)?; |
|
| … | 14 unchanged lines |
||
| 1281 | 889 | None |
|
| 1282 | 890 | } else { |
|
| 1283 | 891 | Some(o::uint(ls, "last serial")?) |
|
| 1284 | - | }, |
|
| 1285 | - | accepted: match rm.get(&5) { |
|
| 1286 | - | Some(a) => opt_attest_from(a)?, |
|
| 1287 | - | None => None, |
|
| 1288 | - | }, |
|
| 1289 | - | logged: rm.get(&6).map(logged_from).transpose()?, |
|
| 1290 | - | integration: match rm.get(&7) { |
|
| 1291 | - | Some(l) => integration_from(l)?, |
|
| 1292 | - | None => Vec::new(), |
|
| 1293 | 892 | }, |
|
| 1294 | 893 | }); |
|
| 1295 | 894 | } |
|
| … | 12 unchanged lines |
||
| 1308 | 907 | } |
|
| 1309 | 908 | } |
|
| 1310 | 909 | let default_bookmark = match m.get(&5) { |
|
| 1311 | - | Some(Value::Text(t)) => Some(t.clone()), |
|
| 1312 | - | _ => None, |
|
| 1313 | - | }; |
|
| 1314 | - | let log = match m.get(&6) { |
|
| 1315 | 910 | Some(Value::Text(t)) => Some(t.clone()), |
|
| 1316 | 911 | _ => None, |
|
| 1317 | 912 | }; |
|
| 1318 | - | let mut witnesses = Vec::new(); |
|
| 1319 | - | if let Some(w) = m.get(&7) { |
|
| 1320 | - | for x in o::array(w, "witnesses")? { |
|
| 1321 | - | let a = o::array(x, "witness")?; |
|
| 1322 | - | if a.len() != 2 { |
|
| 1323 | - | return Err(schema("witness: expected [name, identity]")); |
|
| 1324 | - | } |
|
| 1325 | - | witnesses.push(( |
|
| 1326 | - | o::text(&a[0], "witness name")?.to_owned(), |
|
| 1327 | - | o::bytes(&a[1], "witness identity")?.to_vec(), |
|
| 1328 | - | )); |
|
| 1329 | - | } |
|
| 1330 | - | } |
|
| 1331 | - | let withdrawn = match m.get(&8) { |
|
| 1332 | - | Some(t) => tags_from(t)?, |
|
| 1333 | - | None => Vec::new(), |
|
| 1334 | - | }; |
|
| 1335 | 913 | Ok(( |
|
| 1336 | 914 | id, |
|
| 1337 | 915 | RefsResponse { |
|
| … | 2 unchanged lines |
||
| 1340 | 918 | tags, |
|
| 1341 | 919 | history, |
|
| 1342 | 920 | default_bookmark, |
|
| 1343 | - | log, |
|
| 1344 | - | witnesses, |
|
| 1345 | - | withdrawn, |
|
| 1346 | 921 | }, |
|
| 1347 | 922 | )) |
|
| 1348 | 923 | } |
|
| 1349 | 924 | ||
| 1350 | - | /// A proof's hashes, from the leaf upwards (spec §14.8). |
|
| 1351 | - | fn proof_value(proof: &[[u8; 32]]) -> Value { |
|
| 1352 | - | Value::Array(proof.iter().map(|h| Value::Bytes(h.to_vec())).collect()) |
|
| 1353 | - | } |
|
| 1354 | - | ||
| 1355 | - | fn proof_from(v: &Value) -> Result<Vec<[u8; 32]>> { |
|
| 1356 | - | let mut proof = Vec::new(); |
|
| 1357 | - | for h in o::array(v, "proof")? { |
|
| 1358 | - | proof.push( |
|
| 1359 | - | o::bytes(h, "proof hash")? |
|
| 1360 | - | .try_into() |
|
| 1361 | - | .map_err(|_| schema("proof hash: expected 32 bytes"))?, |
|
| 1362 | - | ); |
|
| 1363 | - | } |
|
| 1364 | - | Ok(proof) |
|
| 1365 | - | } |
|
| 1366 | - | ||
| 1367 | 925 | pub fn encode_negotiate(have: &[ChangeHash], want: &[ChangeHash]) -> Vec<u8> { |
|
| 1368 | 926 | cbor::encode(&Value::map([ |
|
| 1369 | 927 | (0, hashes_value(have)), |
|
| … | 150 unchanged lines |
||
| 1520 | 1078 | use super::*; |
|
| 1521 | 1079 | use crate::attest::AttestBody; |
|
| 1522 | 1080 | use crate::objects::Hlc; |
|
| 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 | - | ||
| 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 | 1081 | ||
| 1562 | 1082 | #[test] |
|
| 1563 | 1083 | fn wire_round_trips() { |
|
| … | 25 unchanged lines |
||
| 1589 | 1109 | }], |
|
| 1590 | 1110 | hashes: vec![], |
|
| 1591 | 1111 | raw_changes: vec![], |
|
| 1592 | - | certificates: vec![], |
|
| 1593 | 1112 | }; |
|
| 1594 | 1113 | let d = wire::decode_bundle(&wire::encode_bundle(&bundle)).unwrap(); |
|
| 1595 | 1114 | assert_eq!( |
|
| … | 5 unchanged lines |
||
| 1601 | 1120 | assert_eq!(d.attests, bundle.attests); |
|
| 1602 | 1121 | assert_eq!(d.objects, bundle.objects); |
|
| 1603 | 1122 | assert_eq!(d.policies, bundle.policies); |
|
| 1604 | - | assert!(d.certificates.is_empty()); |
|
| 1605 | - | // Certificates (keyless, oxygenhub#241) travel as field 4, only |
|
| 1606 | - | // when there are any: a bundle without them encodes as before. |
|
| 1607 | - | let plain = wire::encode_bundle(&bundle); |
|
| 1608 | - | let mut with = bundle.clone(); |
|
| 1609 | - | let issuer_sk = crate::identity::SecretKey::generate(); |
|
| 1610 | - | let issuer = issuer_sk.identity("hub", "h@x", 1); |
|
| 1611 | - | with.certificates.push(crate::cert::SignedCertificate::sign( |
|
| 1612 | - | crate::cert::Certificate { |
|
| 1613 | - | key: id.clone(), |
|
| 1614 | - | issuer: issuer.hash(), |
|
| 1615 | - | subject: "agent:a@b".into(), |
|
| 1616 | - | claims: vec![], |
|
| 1617 | - | scope: crate::identity::Scope::CHANGES, |
|
| 1618 | - | not_before: 1, |
|
| 1619 | - | expires: 2, |
|
| 1620 | - | grant: None, |
|
| 1621 | - | }, |
|
| 1622 | - | &issuer, |
|
| 1623 | - | &issuer_sk, |
|
| 1624 | - | )); |
|
| 1625 | - | let encoded = wire::encode_bundle(&with); |
|
| 1626 | - | assert_ne!(encoded, plain); |
|
| 1627 | - | assert_eq!( |
|
| 1628 | - | wire::decode_bundle(&encoded).unwrap().certificates, |
|
| 1629 | - | with.certificates |
|
| 1630 | - | ); |
|
| 1631 | - | with.certificates.clear(); |
|
| 1632 | - | assert_eq!(wire::encode_bundle(&with), plain); |
|
| 1633 | 1123 | ||
| 1634 | 1124 | let att = Attest { |
|
| 1635 | 1125 | repo: [3; 16], |
|
| … | 15 unchanged lines |
||
| 1651 | 1141 | sigs: vec![sig.clone()], |
|
| 1652 | 1142 | }), |
|
| 1653 | 1143 | last_serial: Some(4), |
|
| 1654 | - | accepted: None, |
|
| 1655 | - | logged: None, |
|
| 1656 | - | integration: vec![], |
|
| 1657 | 1144 | }, |
|
| 1658 | 1145 | RemoteRef { |
|
| 1659 | 1146 | name: "dev".into(), |
|
| … | 1 unchanged lines |
||
| 1661 | 1148 | heads: vec![], |
|
| 1662 | 1149 | attestation: None, |
|
| 1663 | 1150 | last_serial: None, |
|
| 1664 | - | accepted: None, |
|
| 1665 | - | logged: None, |
|
| 1666 | - | integration: vec![], |
|
| 1667 | 1151 | }, |
|
| 1668 | 1152 | ]; |
|
| 1669 | - | let tag = TagRef::new("v1".into(), ObjectRef([2; 32]), vec![c.hash()], None); |
|
| 1670 | - | let mut resp = RefsResponse { |
|
| 1153 | + | let tag = TagRef { |
|
| 1154 | + | name: "v1".into(), |
|
| 1155 | + | state: ObjectRef([2; 32]), |
|
| 1156 | + | heads: vec![c.hash()], |
|
| 1157 | + | attestation: None, |
|
| 1158 | + | }; |
|
| 1159 | + | let resp = RefsResponse { |
|
| 1671 | 1160 | refs, |
|
| 1672 | 1161 | policy: Some(SignedPolicy { |
|
| 1673 | 1162 | policy: policy.clone(), |
|
| … | 5 unchanged lines |
||
| 1679 | 1168 | sigs: vec![], |
|
| 1680 | 1169 | }], |
|
| 1681 | 1170 | default_bookmark: Some("develop".into()), |
|
| 1682 | - | log: Some("hub.example/log\n2\nroot\n\n- sig\n".into()), |
|
| 1683 | - | witnesses: vec![], |
|
| 1684 | - | withdrawn: vec![], |
|
| 1685 | 1171 | }; |
|
| 1686 | - | // Without integrated times (spec §14.8) nothing is added: an old |
|
| 1687 | - | // peer's answer, and what it reads. |
|
| 1688 | - | let old = wire::encode_refs([3; 16], &resp); |
|
| 1689 | - | let back = wire::decode_refs(&old).unwrap().1; |
|
| 1690 | - | assert!(back.witnesses.is_empty() && back.refs.iter().all(|r| r.integration.is_empty())); |
|
| 1691 | - | resp.refs[1].logged = Some(Logged { |
|
| 1692 | - | index: 41, |
|
| 1693 | - | entry: vec![1, 2, 3], |
|
| 1694 | - | proof: vec![[9; 32], [8; 32]], |
|
| 1695 | - | }); |
|
| 1696 | - | resp.refs[1].integration = vec![Integration { |
|
| 1697 | - | note: "hub.example/log\n42\nroot\n\n- sig\n".into(), |
|
| 1698 | - | proof: vec![[7; 32]], |
|
| 1699 | - | }]; |
|
| 1700 | - | resp.witnesses = vec![("w.example".into(), vec![4, 5, 6])]; |
|
| 1701 | - | let new = wire::encode_refs([3; 16], &resp); |
|
| 1702 | - | let (id_back, back) = wire::decode_refs(&new).unwrap(); |
|
| 1703 | - | assert_eq!(back.log, resp.log); |
|
| 1704 | - | assert_eq!(back.refs[1].logged, resp.refs[1].logged); |
|
| 1705 | - | assert_eq!(back.refs[1].integration, resp.refs[1].integration); |
|
| 1706 | - | assert_eq!(back.witnesses, resp.witnesses); |
|
| 1707 | - | assert_eq!(back.refs[0].logged, None); |
|
| 1708 | - | // The new keys are 7s an old reader never asks for. |
|
| 1709 | - | let crate::cbor::Value::Map(m) = crate::cbor::decode(&new).unwrap() else { |
|
| 1710 | - | panic!() |
|
| 1711 | - | }; |
|
| 1712 | - | assert!(m.contains_key(&7)); |
|
| 1172 | + | let (id_back, back) = wire::decode_refs(&wire::encode_refs([3; 16], &resp)).unwrap(); |
|
| 1713 | 1173 | assert_eq!(id_back, [3; 16]); |
|
| 1714 | 1174 | assert_eq!(back.refs.len(), 2); |
|
| 1715 | 1175 | assert_eq!(back.refs[0].attestation.as_ref().unwrap().attest, att); |
|
| … | 1 unchanged lines |
||
| 1717 | 1177 | assert_eq!(back.policy.as_ref().unwrap().policy, policy); |
|
| 1718 | 1178 | assert_eq!(back.history, resp.history); |
|
| 1719 | 1179 | assert_eq!(back.tags, vec![tag.clone()]); |
|
| 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 | 1180 | ||
| 1771 | 1181 | let (h, w) = wire::decode_negotiate(&wire::encode_negotiate( |
|
| 1772 | 1182 | &[ObjectRef([1; 32])], |
|
| … | 68 unchanged lines |
modified crates/oxy-core/src/receipt.rs +6 −163
| … | 90 unchanged lines |
||
| 91 | 91 | Unsigned(String), |
|
| 92 | 92 | #[error("the runner's signature does not verify: {0}")] |
|
| 93 | 93 | BadSignature(String), |
|
| 94 | - | #[error("its certificate does not hold: {0}")] |
|
| 95 | - | Certificate(String), |
|
| 96 | 94 | #[error("{0}")] |
|
| 97 | 95 | Schema(String), |
|
| 98 | 96 | } |
|
| … | 6 unchanged lines |
||
| 105 | 103 | ||
| 106 | 104 | /// A receipt as a runner hands it over: the receipt, the attestation |
|
| 107 | 105 | /// binding it to a state, the runner's signature over that attestation, and |
|
| 108 | - | /// the runner's identity, so it verifies with nothing else. A runner that |
|
| 109 | - | /// signs with a short-lived key (keyless, oxygenhub#241) adds that key's |
|
| 110 | - | /// certificate and its issuer's identity. |
|
| 106 | + | /// the runner's identity, so it verifies with nothing else. |
|
| 111 | 107 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 112 | 108 | pub struct SignedReceipt { |
|
| 113 | 109 | pub receipt: Receipt, |
|
| 114 | 110 | pub attest: Attest, |
|
| 115 | 111 | pub sigs: Vec<Signature>, |
|
| 116 | 112 | pub runner: Identity, |
|
| 117 | - | pub certificate: Option<CertifiedBy>, |
|
| 118 | - | } |
|
| 119 | - | ||
| 120 | - | /// A short-lived signing key's certificate, with the identity of the key |
|
| 121 | - | /// that issued it (spec §14.7). |
|
| 122 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 123 | - | pub struct CertifiedBy { |
|
| 124 | - | pub cert: crate::cert::SignedCertificate, |
|
| 125 | - | pub issuer: Identity, |
|
| 126 | 113 | } |
|
| 127 | 114 | ||
| 128 | 115 | impl SignedReceipt { |
|
| … | 22 unchanged lines |
||
| 151 | 138 | attest, |
|
| 152 | 139 | sigs: vec![sig], |
|
| 153 | 140 | runner: runner.clone(), |
|
| 154 | - | certificate: None, |
|
| 155 | 141 | } |
|
| 156 | 142 | } |
|
| 157 | 143 | ||
| 158 | - | /// The same, signed by a short-lived key whose certificate `cert` |
|
| 159 | - | /// `issuer` issued: the certificate rides along. |
|
| 160 | - | pub fn with_certificate( |
|
| 161 | - | mut self, |
|
| 162 | - | cert: crate::cert::SignedCertificate, |
|
| 163 | - | issuer: Identity, |
|
| 164 | - | ) -> SignedReceipt { |
|
| 165 | - | self.certificate = Some(CertifiedBy { cert, issuer }); |
|
| 166 | - | self |
|
| 167 | - | } |
|
| 168 | - | ||
| 169 | 144 | /// The state the receipt was made on. |
|
| 170 | 145 | pub fn state(&self) -> ObjectRef { |
|
| 171 | 146 | match &self.attest.body { |
|
| … | 26 unchanged lines |
||
| 198 | 173 | .find(|s| s.key == key) |
|
| 199 | 174 | .ok_or_else(|| ReceiptError::Unsigned(key.short(12)))?; |
|
| 200 | 175 | verify(&self.runner, sig, Domain::Attest, &target) |
|
| 201 | - | .map_err(|e| ReceiptError::BadSignature(e.to_string()))?; |
|
| 202 | - | if let Some(c) = &self.certificate { |
|
| 203 | - | c.cert |
|
| 204 | - | .verify(&c.issuer) |
|
| 205 | - | .map_err(|e| ReceiptError::Certificate(e.to_string()))?; |
|
| 206 | - | if c.cert.cert.key_id() != key { |
|
| 207 | - | return Err(ReceiptError::Certificate( |
|
| 208 | - | "it certifies another key than the one that signed".into(), |
|
| 209 | - | )); |
|
| 210 | - | } |
|
| 211 | - | // The attestation's own time is the signer's claim: a host |
|
| 212 | - | // checks the time it accepted the receipt as well. |
|
| 213 | - | if !c |
|
| 214 | - | .cert |
|
| 215 | - | .cert |
|
| 216 | - | .covers(crate::identity::Scope::SEAL, self.attest.hlc.wall_ms) |
|
| 217 | - | { |
|
| 218 | - | return Err(ReceiptError::Certificate( |
|
| 219 | - | "the receipt was signed outside the certificate's window, or it gives no seal scope".into(), |
|
| 220 | - | )); |
|
| 221 | - | } |
|
| 222 | - | } |
|
| 223 | - | Ok(()) |
|
| 176 | + | .map_err(|e| ReceiptError::BadSignature(e.to_string())) |
|
| 224 | 177 | } |
|
| 225 | 178 | ||
| 226 | 179 | /// `[receipt, attest, [Signature], identity]`, each object as its bytes. |
|
| 227 | - | /// `[receipt, attest, [Signature], identity]`, and |
|
| 228 | - | /// `[certificate, issuer identity]` after them when a short-lived key |
|
| 229 | - | /// signed. |
|
| 230 | 180 | pub fn encode(&self) -> Vec<u8> { |
|
| 231 | - | let mut parts = vec![ |
|
| 181 | + | cbor::encode(&Value::Array(vec![ |
|
| 232 | 182 | Value::Bytes(self.receipt.encode()), |
|
| 233 | 183 | Value::Bytes(self.attest.encode()), |
|
| 234 | 184 | Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()), |
|
| 235 | 185 | Value::Bytes(self.runner.encode()), |
|
| 236 | - | ]; |
|
| 237 | - | if let Some(c) = &self.certificate { |
|
| 238 | - | parts.push(Value::Array(vec![ |
|
| 239 | - | Value::Bytes(c.cert.encode()), |
|
| 240 | - | Value::Bytes(c.issuer.encode()), |
|
| 241 | - | ])); |
|
| 242 | - | } |
|
| 243 | - | cbor::encode(&Value::Array(parts)) |
|
| 186 | + | ])) |
|
| 244 | 187 | } |
|
| 245 | 188 | ||
| 246 | 189 | pub fn decode(b: &[u8]) -> Result<SignedReceipt, ReceiptError> { |
|
| 247 | 190 | let v = cbor::decode(b).map_err(ObjectError::from)?; |
|
| 248 | 191 | let a = array(&v, "signed receipt")?; |
|
| 249 | - | if a.len() != 4 && a.len() != 5 { |
|
| 192 | + | if a.len() != 4 { |
|
| 250 | 193 | return Err(ReceiptError::Schema( |
|
| 251 | - | "signed receipt: expected [receipt, attest, sigs, identity, (certificate, issuer)]" |
|
| 252 | - | .into(), |
|
| 194 | + | "signed receipt: expected [receipt, attest, sigs, identity]".into(), |
|
| 253 | 195 | )); |
|
| 254 | 196 | } |
|
| 255 | - | let certificate = match a.get(4) { |
|
| 256 | - | None => None, |
|
| 257 | - | Some(c) => { |
|
| 258 | - | let c = array(c, "certificate")?; |
|
| 259 | - | if c.len() != 2 { |
|
| 260 | - | return Err(ReceiptError::Schema( |
|
| 261 | - | "signed receipt: expected [certificate, issuer identity]".into(), |
|
| 262 | - | )); |
|
| 263 | - | } |
|
| 264 | - | Some(CertifiedBy { |
|
| 265 | - | cert: crate::cert::SignedCertificate::decode(bytes(&c[0], "certificate")?) |
|
| 266 | - | .map_err(|e| ReceiptError::Certificate(e.to_string()))?, |
|
| 267 | - | issuer: Identity::decode(bytes(&c[1], "issuer")?)?, |
|
| 268 | - | }) |
|
| 269 | - | } |
|
| 270 | - | }; |
|
| 271 | 197 | Ok(SignedReceipt { |
|
| 272 | 198 | receipt: Receipt::decode(bytes(&a[0], "receipt")?)?, |
|
| 273 | 199 | attest: Attest::decode(bytes(&a[1], "attest")?)?, |
|
| … | 2 unchanged lines |
||
| 276 | 202 | .map(Signature::from_value) |
|
| 277 | 203 | .collect::<Result<_, _>>()?, |
|
| 278 | 204 | runner: Identity::decode(bytes(&a[3], "identity")?)?, |
|
| 279 | - | certificate, |
|
| 280 | 205 | }) |
|
| 281 | 206 | } |
|
| 282 | 207 | } |
|
| … | 63 unchanged lines |
||
| 346 | 271 | let mut swapped = sr.clone(); |
|
| 347 | 272 | swapped.runner = other; |
|
| 348 | 273 | assert!(matches!(swapped.verify(), Err(ReceiptError::Unsigned(_)))); |
|
| 349 | - | } |
|
| 350 | - | ||
| 351 | - | /// A receipt signed with a short-lived key carries its certificate |
|
| 352 | - | /// (oxygenhub#241): it verifies, round-trips, and is refused when the |
|
| 353 | - | /// certificate is another key's, gives no seal scope, or does not cover |
|
| 354 | - | /// when it was signed. |
|
| 355 | - | #[test] |
|
| 356 | - | fn a_receipt_by_a_certified_key_carries_and_checks_its_certificate() { |
|
| 357 | - | use crate::cert::{Certificate, SignedCertificate}; |
|
| 358 | - | use crate::identity::Scope; |
|
| 359 | - | let hub_sk = SecretKey::generate(); |
|
| 360 | - | let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1); |
|
| 361 | - | let job_sk = SecretKey::generate(); |
|
| 362 | - | let job = job_sk.identity("job 7 test", "[email protected]", 1); |
|
| 363 | - | let certify = |key: &Identity, scope: Scope, from: u64, to: u64| { |
|
| 364 | - | SignedCertificate::sign( |
|
| 365 | - | Certificate { |
|
| 366 | - | key: key.clone(), |
|
| 367 | - | issuer: hub.hash(), |
|
| 368 | - | subject: "repo:andy/demo:bookmark:main".into(), |
|
| 369 | - | claims: vec![("job".into(), "test".into())], |
|
| 370 | - | scope, |
|
| 371 | - | not_before: from, |
|
| 372 | - | expires: to, |
|
| 373 | - | grant: None, |
|
| 374 | - | }, |
|
| 375 | - | &hub, |
|
| 376 | - | &hub_sk, |
|
| 377 | - | ) |
|
| 378 | - | }; |
|
| 379 | - | let sr = SignedReceipt::sign( |
|
| 380 | - | sample(), |
|
| 381 | - | [7; 16], |
|
| 382 | - | ObjectRef([9; 32]), |
|
| 383 | - | "andy/demo#7", |
|
| 384 | - | &job, |
|
| 385 | - | &job_sk, |
|
| 386 | - | ); |
|
| 387 | - | let now = sr.attest.hlc.wall_ms; |
|
| 388 | - | let good = sr.clone().with_certificate( |
|
| 389 | - | certify(&job, Scope::SEAL, now - 1000, now + 600_000), |
|
| 390 | - | hub.clone(), |
|
| 391 | - | ); |
|
| 392 | - | good.verify().unwrap(); |
|
| 393 | - | let back = SignedReceipt::decode(&good.encode()).unwrap(); |
|
| 394 | - | assert_eq!(back, good); |
|
| 395 | - | assert_eq!( |
|
| 396 | - | SignedReceipt::decode(&sr.encode()).unwrap().certificate, |
|
| 397 | - | None, |
|
| 398 | - | "a plain receipt reads as before" |
|
| 399 | - | ); |
|
| 400 | - | ||
| 401 | - | let other = SecretKey::generate().identity("other", "o@x", 1); |
|
| 402 | - | let wrong_key = sr.clone().with_certificate( |
|
| 403 | - | certify(&other, Scope::SEAL, now - 1000, now + 600_000), |
|
| 404 | - | hub.clone(), |
|
| 405 | - | ); |
|
| 406 | - | assert!( |
|
| 407 | - | matches!(wrong_key.verify(), Err(ReceiptError::Certificate(m)) if m.contains("another key")) |
|
| 408 | - | ); |
|
| 409 | - | let no_seal = sr.clone().with_certificate( |
|
| 410 | - | certify(&job, Scope::CHANGES, now - 1000, now + 600_000), |
|
| 411 | - | hub.clone(), |
|
| 412 | - | ); |
|
| 413 | - | assert!(matches!( |
|
| 414 | - | no_seal.verify(), |
|
| 415 | - | Err(ReceiptError::Certificate(_)) |
|
| 416 | - | )); |
|
| 417 | - | let too_late = sr.clone().with_certificate( |
|
| 418 | - | certify(&job, Scope::SEAL, now - 700_000, now - 100_000), |
|
| 419 | - | hub.clone(), |
|
| 420 | - | ); |
|
| 421 | - | assert!( |
|
| 422 | - | matches!(too_late.verify(), Err(ReceiptError::Certificate(m)) if m.contains("window")) |
|
| 423 | - | ); |
|
| 424 | - | let wrong_issuer = sr |
|
| 425 | - | .clone() |
|
| 426 | - | .with_certificate(certify(&job, Scope::SEAL, now - 1000, now + 600_000), other); |
|
| 427 | - | assert!(matches!( |
|
| 428 | - | wrong_issuer.verify(), |
|
| 429 | - | Err(ReceiptError::Certificate(_)) |
|
| 430 | - | )); |
|
| 431 | 274 | } |
|
| 432 | 275 | } |
modified crates/oxy-core/src/repo/bookmarks.rs +6 −25
| … | 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.same_state(&want)) |
|
| 501 | + | .is_ok_and(|have| have == want) |
|
| 502 | 502 | }; |
|
| 503 | 503 | touched.retain(|p| !at_target_pin(p)); |
|
| 504 | 504 | let same = |p: &String| { |
|
| … | 73 unchanged lines |
||
| 578 | 578 | }; |
|
| 579 | 579 | carried.push((p.clone(), item)); |
|
| 580 | 580 | } |
|
| 581 | - | let mut report = self.checkout_keeping(heads, true, command, &keep)?; |
|
| 581 | + | let report = self.checkout_keeping(heads, true, command, &keep)?; |
|
| 582 | 582 | for (p, item) in &carried { |
|
| 583 | 583 | match item { |
|
| 584 | 584 | Carried::Symlink(target) => self.write_symlink(p, target)?, |
|
| … | 4 unchanged lines |
||
| 589 | 589 | Carried::Subrepo => {} |
|
| 590 | 590 | } |
|
| 591 | 591 | } |
|
| 592 | - | // What was carried is on disk, not removed (oxygen#120): a new file |
|
| 593 | - | // the target lacks is taken off by the checkout and written back, so |
|
| 594 | - | // the report names only what is really gone. |
|
| 595 | - | let back: BTreeSet<&str> = carried |
|
| 596 | - | .iter() |
|
| 597 | - | .filter(|(p, item)| { |
|
| 598 | - | matches!(item, Carried::File(..) | Carried::Symlink(_)) |
|
| 599 | - | && !target_state.files.contains_key(p) |
|
| 600 | - | }) |
|
| 601 | - | .map(|(p, _)| p.as_str()) |
|
| 602 | - | .collect(); |
|
| 603 | - | report.removed = report.removed.saturating_sub(back.len()); |
|
| 604 | - | report.removed_paths.retain(|p| !back.contains(p.as_str())); |
|
| 605 | 592 | self.drop_manifest(); |
|
| 606 | 593 | self.snapshot(command)?; |
|
| 607 | 594 | Ok((report, carried.into_iter().map(|(p, _)| p).collect())) |
|
| … | 7 unchanged lines |
||
| 615 | 602 | } |
|
| 616 | 603 | ||
| 617 | 604 | /// Union the heads of bookmark `name` into the current bookmark (spec |
|
| 618 | - | /// §10.1): a bookmark here, or a remote's as its tracking bookmark |
|
| 619 | - | /// holds it (`origin/develop`, oxygen#117), so merging what a remote |
|
| 620 | - | /// has needs no switch. A working copy without changes follows. |
|
| 605 | + | /// §10.1). A working copy without changes follows. |
|
| 621 | 606 | pub fn merge(&self, name: &str) -> Result<MergeReport> { |
|
| 622 | 607 | let _wc = self.lock_working_copy()?; |
|
| 623 | 608 | let (_, op) = self.head_op()?; |
|
| 624 | 609 | if name == self.config.bookmark { |
|
| 625 | 610 | return fail(format!("{name} is the current bookmark")); |
|
| 626 | 611 | } |
|
| 627 | - | let here = op.view.bookmarks.get(name).copied(); |
|
| 628 | - | let Some(state) = here.or_else(|| remote_bookmark(&op.view, name).map(|(_, _, st)| st)) |
|
| 629 | - | else { |
|
| 630 | - | return fail(format!( |
|
| 631 | - | "no bookmark {name}, here or at a remote (oxy bookmark list --all lists them)" |
|
| 632 | - | )); |
|
| 612 | + | let Some(st) = op.view.bookmarks.get(name) else { |
|
| 613 | + | return fail(format!("no bookmark {name}")); |
|
| 633 | 614 | }; |
|
| 634 | - | let other = self.store.get_state(&state)?.heads; |
|
| 615 | + | let other = self.store.get_state(st)?.heads; |
|
| 635 | 616 | self.merge_heads(other, &format!("oxy merge {name}")) |
|
| 636 | 617 | } |
|
| 637 | 618 | ||
| … | 83 unchanged lines |
modified crates/oxy-core/src/repo/checkout.rs +2 −52
| … | 5 unchanged lines |
||
| 6 | 6 | pub struct CheckoutReport { |
|
| 7 | 7 | pub written: usize, |
|
| 8 | 8 | pub removed: usize, |
|
| 9 | - | /// The first files removed, to name them (oxygen#102). |
|
| 10 | - | pub removed_paths: Vec<String>, |
|
| 11 | 9 | pub conflicted: bool, |
|
| 12 | 10 | /// Nested repositories that could not be moved, and other remarks. |
|
| 13 | 11 | pub notes: Vec<String>, |
|
| 14 | 12 | /// How many changes of the state left behind the new one lacks. |
|
| 15 | 13 | pub lacks: usize, |
|
| 16 | - | /// Union files whose adjacent insertions were settled by keeping both |
|
| 17 | - | /// sides (oxygen#145). |
|
| 18 | - | pub kept_both: Vec<KeptBoth>, |
|
| 19 | 14 | } |
|
| 20 | 15 | ||
| 21 | 16 | /// Text files rendered at a time by a checkout: bounds its memory. |
|
| … | 44 unchanged lines |
||
| 66 | 61 | let mut target = self.load_state_indexed(heads)?; |
|
| 67 | 62 | let heads = target.maximal(heads); |
|
| 68 | 63 | let tv = crate::timing::phase("checkout: target view"); |
|
| 69 | - | let tview = self.tree_view(&mut target)?; |
|
| 64 | + | let tview = target.tree().view(); |
|
| 70 | 65 | drop(tv); |
|
| 71 | 66 | let mut report = CheckoutReport::default(); |
|
| 72 | 67 | // Paths this file system cannot hold side by side (spec §7.7). |
|
| … | 28 unchanged lines |
||
| 101 | 96 | } |
|
| 102 | 97 | fs::remove_file(&full)?; |
|
| 103 | 98 | report.removed += 1; |
|
| 104 | - | if report.removed_paths.len() < 5 { |
|
| 105 | - | report.removed_paths.push(path.to_string()); |
|
| 106 | - | } |
|
| 107 | 99 | if let Some(parent) = full.parent() { |
|
| 108 | 100 | dirs_to_prune.insert(parent.to_path_buf()); |
|
| 109 | 101 | } |
|
| … | 301 unchanged lines |
||
| 411 | 403 | } |
|
| 412 | 404 | ||
| 413 | 405 | 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(); |
|
| 417 | 406 | let recording = crate::timing::phase("checkout: record"); |
|
| 418 | 407 | let (op_ref, op) = self.head_op()?; |
|
| 419 | 408 | let wc = self.new_working_copy(heads.clone(), ""); |
|
| … | 35 unchanged lines |
||
| 455 | 444 | *self.walked_dirs.borrow_mut() = Some((dirs, old_manifest.nested.clone())); |
|
| 456 | 445 | } |
|
| 457 | 446 | 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 | - | } |
|
| 461 | 447 | let _ = self.run_hook( |
|
| 462 | 448 | "post-checkout", |
|
| 463 | 449 | &format!( |
|
| … | 61 unchanged lines |
||
| 525 | 511 | } |
|
| 526 | 512 | if i == 0 && *comp == OXY_DIR { |
|
| 527 | 513 | return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}")); |
|
| 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 | 514 | } |
|
| 534 | 515 | if i + 1 < comps.len() { |
|
| 535 | 516 | full.push(comp); |
|
| … | 65 unchanged lines |
||
| 601 | 582 | } |
|
| 602 | 583 | ||
| 603 | 584 | /// Refuse a path that could escape the workspace (spec §7.7): an empty, |
|
| 604 | - | /// `.` or `..` component, a backslash, a first component `.oxy`, or a |
|
| 605 | - | /// `.git` component anywhere (it could plant Git hooks). |
|
| 585 | + | /// `.` or `..` component, a backslash, or a first component `.oxy`. |
|
| 606 | 586 | fn check_path_name(rel: &str) -> Result<()> { |
|
| 607 | 587 | for (i, comp) in rel.split('/').enumerate() { |
|
| 608 | 588 | if comp.is_empty() || comp == "." || comp == ".." || comp.contains('\\') { |
|
| … | 1 unchanged lines |
||
| 610 | 590 | } |
|
| 611 | 591 | if i == 0 && comp == OXY_DIR { |
|
| 612 | 592 | return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}")); |
|
| 613 | - | } |
|
| 614 | - | if comp.eq_ignore_ascii_case(".git") { |
|
| 615 | - | return fail(format!("refusing to write a .git path: {rel:?}")); |
|
| 616 | 593 | } |
|
| 617 | 594 | } |
|
| 618 | 595 | Ok(()) |
|
| … | 37 unchanged lines |
||
| 656 | 633 | } |
|
| 657 | 634 | fs::write(full, bytes)?; |
|
| 658 | 635 | set_executable(full, exec) |
|
| 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 | 636 | } |
modified crates/oxy-core/src/repo/checkpoint.rs +14 −52
| … | 112 unchanged lines |
||
| 113 | 113 | write_atomic(&self.checkpoint_pointer(bookmark), r.to_hex().as_bytes()) |
|
| 114 | 114 | } |
|
| 115 | 115 | ||
| 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"); |
|
| 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> { |
|
| 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 | - | Ok(match self.latest_checkpoint(&bookmark)? { |
|
| 124 | + | let r = 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 | - | }) |
|
| 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)?; |
|
| 140 | + | }; |
|
| 150 | 141 | let cp = Checkpoint::decode(&self.store.get_typed(&r, ObjectType::Checkpoint)?)?; |
|
| 151 | 142 | let mut bundle = Bundle::default(); |
|
| 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?); |
|
| 143 | + | for c in Blob::decode(&self.store.get_typed(&cp.payload, ObjectType::Blob)?)?.chunks { |
|
| 144 | + | bundle.objects.push(self.store.get(&c)?); |
|
| 155 | 145 | } |
|
| 156 | 146 | bundle.objects.push(self.store.get(&cp.payload)?); |
|
| 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"); |
|
| 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)?); |
|
| 183 | 150 | } |
|
| 184 | - | chunks.extend(Blob::decode(body)?.chunks); |
|
| 151 | + | bundle.objects.push(self.store.get(&g)?); |
|
| 185 | 152 | } |
|
| 186 | - | let last = bundle.objects.pop(); |
|
| 187 | - | for got in self.store.get_many(&chunks) { |
|
| 188 | - | bundle.objects.push(got?); |
|
| 189 | - | } |
|
| 190 | - | bundle.objects.extend(blobs); |
|
| 191 | - | bundle.objects.extend(last); |
|
| 153 | + | bundle.objects.push((ObjectType::Checkpoint, cp.encode())); |
|
| 192 | 154 | Ok(bundle) |
|
| 193 | 155 | } |
|
| 194 | 156 | ||
| … | 80 unchanged lines |
modified crates/oxy-core/src/repo/config.rs +4 −49
| … | 17 unchanged lines |
||
| 18 | 18 | /// agent sets it to its own key, so its changes carry the human as |
|
| 19 | 19 | /// author and the agent's signature (spec §14.2, agent workflows §2). |
|
| 20 | 20 | pub signer_key: Option<KeyId>, |
|
| 21 | - | /// `author.signer = "keyless"`: sign with fresh keys the hub certifies |
|
| 22 | - | /// under the agent's grant, keeping no key on disk (spec §14.7, |
|
| 23 | - | /// oxygenhub#241). |
|
| 24 | - | pub signer_keyless: bool, |
|
| 25 | - | /// `keyless.hub`: the hub that certifies them (default: the default |
|
| 26 | - | /// remote's hub). |
|
| 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>, |
|
| 33 | 21 | /// `core.sign`: sign every change at commit (`true`) or leave signing to |
|
| 34 | 22 | /// push-time seals (`false`, the spec default). |
|
| 35 | 23 | pub sign_changes: bool, |
|
| … | 10 unchanged lines |
||
| 46 | 34 | /// `git.claim`: a Git import authors the commits whose Git author email |
|
| 47 | 35 | /// is this repository's identity's with its key (oxygen#97). |
|
| 48 | 36 | 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, |
|
| 53 | 37 | /// `core.op-retention`: days for which operations keep their states reachable (spec §12.1, §21). |
|
| 54 | 38 | pub op_retention_days: u64, |
|
| 55 | 39 | /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21). |
|
| … | 46 unchanged lines |
||
| 102 | 86 | signer_key: std::env::var("OXY_SIGNER") |
|
| 103 | 87 | .ok() |
|
| 104 | 88 | .and_then(|v| KeyId::from_hex(&v)), |
|
| 105 | - | signer_keyless: std::env::var("OXY_SIGNER").is_ok_and(|v| v == "keyless"), |
|
| 106 | - | keyless_hub: None, |
|
| 107 | - | author_for: None, |
|
| 108 | 89 | sign_changes: false, |
|
| 109 | 90 | bookmark: "main".into(), |
|
| 110 | 91 | remotes: BTreeMap::new(), |
|
| … | 1 unchanged lines |
||
| 112 | 93 | remote_cas: BTreeMap::new(), |
|
| 113 | 94 | default_remote: None, |
|
| 114 | 95 | git_claim: false, |
|
| 115 | - | git_colocate: false, |
|
| 116 | 96 | op_retention_days: 90, |
|
| 117 | 97 | gc_grace_days: 14, |
|
| 118 | 98 | max_new_file: 100 << 20, |
|
| 119 | 99 | snapshot_retention_days: 7, |
|
| 120 | 100 | checkpoint_interval: 256, |
|
| 121 | - | // Well under the 100 MB a proxy in front of a hub may take |
|
| 122 | - | // (oxygen#133). |
|
| 123 | - | push_batch_bytes: 64 << 20, |
|
| 101 | + | push_batch_bytes: 256 << 20, |
|
| 124 | 102 | max_blob: None, |
|
| 125 | 103 | sparse_paths: Vec::new(), |
|
| 126 | 104 | semantic_deps: false, |
|
| … | 44 unchanged lines |
||
| 171 | 149 | } |
|
| 172 | 150 | } |
|
| 173 | 151 | "author.signer" => { |
|
| 174 | - | self.signer_keyless = value == "keyless"; |
|
| 175 | - | self.signer_key = if value.is_empty() || self.signer_keyless { |
|
| 152 | + | self.signer_key = if value.is_empty() { |
|
| 176 | 153 | None |
|
| 177 | 154 | } else { |
|
| 178 | 155 | Some(KeyId::from_hex(value).ok_or_else(|| { |
|
| 179 | - | RepoError::Msg( |
|
| 180 | - | "author.signer must be a 64-character hex KeyId, or \"keyless\"".into(), |
|
| 181 | - | ) |
|
| 156 | + | RepoError::Msg("author.signer must be a 64-character hex KeyId".into()) |
|
| 182 | 157 | })?) |
|
| 183 | 158 | } |
|
| 184 | 159 | } |
|
| 185 | 160 | "core.sign" => { |
|
| 186 | 161 | self.sign_changes = match value { |
|
| 187 | 162 | "change" => true, |
|
| 188 | - | // Empty: unset, the default. |
|
| 189 | - | "seal" | "" => false, |
|
| 163 | + | "seal" => false, |
|
| 190 | 164 | _ => return fail("core.sign must be \"change\" or \"seal\""), |
|
| 191 | 165 | } |
|
| 192 | 166 | } |
|
| 193 | 167 | "workspace.bookmark" => self.bookmark = value.into(), |
|
| 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()), |
|
| 196 | 168 | // Read by the credential store from the user file. |
|
| 197 | 169 | "auth.helper" => {} |
|
| 198 | 170 | "core.op-retention" => self.op_retention_days = parse_days(value)?, |
|
| … | 22 unchanged lines |
||
| 221 | 193 | } |
|
| 222 | 194 | "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"), |
|
| 223 | 195 | "git.claim" => self.git_claim = matches!(value, "true" | "1" | "yes"), |
|
| 224 | - | "git.colocate" => self.git_colocate = matches!(value, "true" | "1" | "yes"), |
|
| 225 | 196 | "filter.paths" => { |
|
| 226 | 197 | self.sparse_paths = value |
|
| 227 | 198 | .split(',') |
|
| … | 75 unchanged lines |
||
| 303 | 274 | for (k, v) in read_config_file(repo_path)? { |
|
| 304 | 275 | if c.apply(&k, &v).is_ok() { |
|
| 305 | 276 | c.repo_layer.insert(k, v); |
|
| 306 | - | } |
|
| 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 | 277 | } |
|
| 319 | 278 | } |
|
| 320 | 279 | Ok(c) |
|
| … | 8 unchanged lines |
||
| 329 | 288 | &self.repo_layer |
|
| 330 | 289 | } |
|
| 331 | 290 | } |
|
| 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 | 291 | ||
| 337 | 292 | /// A byte count with an optional `K`, `M`, or `G` suffix (binary units). |
|
| 338 | 293 | /// `storage.cache` when unset: 2 GiB. |
|
| … | 52 unchanged lines |
modified crates/oxy-core/src/repo/doctor.rs +2 −68
| … | 61 unchanged lines |
||
| 62 | 62 | self.doctor_identity(&mut rep); |
|
| 63 | 63 | self.doctor_author(&mut rep); |
|
| 64 | 64 | self.doctor_portability(&mut rep); |
|
| 65 | - | self.doctor_trust(&mut rep); |
|
| 66 | 65 | for name in self.config.remotes.keys() { |
|
| 67 | 66 | self.doctor_remote(&mut rep, name, online); |
|
| 68 | 67 | } |
|
| … | 141 unchanged lines |
||
| 210 | 209 | let Ok(heads) = self.bookmark_heads() else { |
|
| 211 | 210 | return; |
|
| 212 | 211 | }; |
|
| 213 | - | let Ok(mut state) = self.load_state_history(&heads) else { |
|
| 214 | - | return; |
|
| 215 | - | }; |
|
| 216 | - | let Ok(view) = self.tree_view(&mut state) else { |
|
| 212 | + | let Ok(state) = self.load_state_history(&heads) else { |
|
| 217 | 213 | return; |
|
| 218 | 214 | }; |
|
| 215 | + | let view = state.tree().view(); |
|
| 219 | 216 | let paths: Vec<&str> = view.entries.keys().map(String::as_str).collect(); |
|
| 220 | 217 | let mut problems: Vec<String> = |
|
| 221 | 218 | crate::portable::Platform::windows().problems(paths.iter().copied()); |
|
| … | 41 unchanged lines |
||
| 263 | 260 | "author", |
|
| 264 | 261 | Health::Ok, |
|
| 265 | 262 | format!("{} <{}>", self.config.author_name, self.config.author_email), |
|
| 266 | - | None, |
|
| 267 | - | ); |
|
| 268 | - | } |
|
| 269 | - | } |
|
| 270 | - | ||
| 271 | - | /// The trust policy (spec §14.5): without one, nothing fetched is |
|
| 272 | - | /// verified, which fetch says each time; doctor says it too, with who |
|
| 273 | - | /// fixes it (oxygen#127). |
|
| 274 | - | fn doctor_trust(&self, rep: &mut DoctorReport) { |
|
| 275 | - | let policy = match self.current_policy() { |
|
| 276 | - | Ok(p) => p, |
|
| 277 | - | Err(e) => { |
|
| 278 | - | rep.push( |
|
| 279 | - | "trust", |
|
| 280 | - | Health::Fail, |
|
| 281 | - | format!("the trust policy does not load: {e}"), |
|
| 282 | - | Some("run `oxy fsck` for details".into()), |
|
| 283 | - | ); |
|
| 284 | - | return; |
|
| 285 | - | } |
|
| 286 | - | }; |
|
| 287 | - | let Some((r, sp)) = policy else { |
|
| 288 | - | rep.push( |
|
| 289 | - | "trust", |
|
| 290 | - | Health::Warn, |
|
| 291 | - | "no trust policy: changes and bookmark moves fetched here are not verified", |
|
| 292 | - | Some( |
|
| 293 | - | "the repository's owner sets one: `oxy policy init`, then `oxy policy grant FILE` for each \ |
|
| 294 | - | contributor and `oxy policy grant-subject --issuer HUB_KEY agent:NAME@ACCOUNT` for keyless \ |
|
| 295 | - | agents, and `oxy push`" |
|
| 296 | - | .into(), |
|
| 297 | - | ), |
|
| 298 | - | ); |
|
| 299 | - | return; |
|
| 300 | - | }; |
|
| 301 | - | let p = &sp.policy; |
|
| 302 | - | let now = Hlc::now().wall_ms; |
|
| 303 | - | let what = format!( |
|
| 304 | - | "policy #{} serial {}: {} root{}, {} rule{}", |
|
| 305 | - | &r.to_hex()[..12], |
|
| 306 | - | p.serial, |
|
| 307 | - | p.roots.len(), |
|
| 308 | - | if p.roots.len() == 1 { "" } else { "s" }, |
|
| 309 | - | p.rules.len(), |
|
| 310 | - | if p.rules.len() == 1 { "" } else { "s" }, |
|
| 311 | - | ); |
|
| 312 | - | let renew = Some( |
|
| 313 | - | "a root of the policy publishes a new serial (any `oxy policy` change does)".into(), |
|
| 314 | - | ); |
|
| 315 | - | if p.is_expired(now) { |
|
| 316 | - | rep.push("trust", Health::Fail, format!("{what}, expired"), renew); |
|
| 317 | - | } else if p.expires - now < 30 * 86_400_000 { |
|
| 318 | - | rep.push( |
|
| 319 | - | "trust", |
|
| 320 | - | Health::Warn, |
|
| 321 | - | format!("{what}, expires in {} days", (p.expires - now) / 86_400_000), |
|
| 322 | - | renew, |
|
| 323 | - | ); |
|
| 324 | - | } else { |
|
| 325 | - | rep.push( |
|
| 326 | - | "trust", |
|
| 327 | - | Health::Ok, |
|
| 328 | - | format!("{what}, expires in {} days", (p.expires - now) / 86_400_000), |
|
| 329 | 263 | None, |
|
| 330 | 264 | ); |
|
| 331 | 265 | } |
|
| … | 78 unchanged lines |
modified crates/oxy-core/src/repo/fsck.rs +0 −5
| … | 100 unchanged lines |
||
| 101 | 101 | ObjectType::Identity => Identity::decode(body).map(|_| ()), |
|
| 102 | 102 | ObjectType::Policy => crate::policy::Policy::decode(body).map(|_| ()), |
|
| 103 | 103 | ObjectType::Receipt => crate::receipt::Receipt::decode(body).map(|_| ()), |
|
| 104 | - | ObjectType::Certificate => crate::cert::Certificate::decode(body).map(|_| ()), |
|
| 105 | - | ObjectType::Grant => crate::cert::Grant::decode(body).map(|_| ()), |
|
| 106 | - | ObjectType::Artifact => crate::artifact::Artifact::decode(body).map(|_| ()), |
|
| 107 | 104 | ObjectType::State => crate::objects::StateObject::decode(body).map(|_| ()), |
|
| 108 | 105 | }; |
|
| 109 | 106 | r.map_err(|e| e.to_string()) |
|
| … | 2 unchanged lines |
||
| 112 | 109 | impl Repo { |
|
| 113 | 110 | /// Check the whole repository; see the module documentation. |
|
| 114 | 111 | pub fn fsck(&self) -> Result<FsckReport> { |
|
| 115 | - | // The head as it is now, as the operations pass reads it. |
|
| 116 | - | self.forget_head(); |
|
| 117 | 112 | let mut rep = FsckReport::default(); |
|
| 118 | 113 | let bad = { |
|
| 119 | 114 | let _t = crate::timing::phase("fsck: objects"); |
|
| … | 398 unchanged lines |
modified crates/oxy-core/src/repo/gc.rs +5 −44
| … | 9 unchanged lines |
||
| 10 | 10 | //! that far back may fail once its objects are gone. |
|
| 11 | 11 | ||
| 12 | 12 | use super::*; |
|
| 13 | - | use crate::store::{PackReport, PruneReport, RetiredReport}; |
|
| 13 | + | use crate::store::{PackReport, PruneReport}; |
|
| 14 | 14 | use std::collections::HashSet; |
|
| 15 | 15 | ||
| 16 | 16 | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
|
| … | 3 unchanged lines |
||
| 20 | 20 | pub reachable: usize, |
|
| 21 | 21 | /// Packs folded into the one a full repack writes. |
|
| 22 | 22 | pub merged_packs: usize, |
|
| 23 | - | /// Packs and loose objects earlier repacks retired, deleted now that |
|
| 24 | - | /// [`crate::store::RETIRED_FOR`] has passed (oxygenhub#249). |
|
| 25 | - | pub retired: RetiredReport, |
|
| 26 | 23 | } |
|
| 27 | 24 | ||
| 28 | 25 | impl Repo { |
|
| 29 | - | /// Delete what earlier repacks retired more than |
|
| 30 | - | /// [`crate::store::RETIRED_FOR`] ago, prune unreachable objects older |
|
| 31 | - | /// than the grace period (`now` ignores it, but not the retired |
|
| 32 | - | /// files' hour), then repack everything into one pack (spec §13.4). |
|
| 33 | - | /// The packs and loose objects this repack replaces are retired in |
|
| 34 | - | /// turn, for the next collection. A store that is not this disk's |
|
| 35 | - | /// directory then folds its signatures and index entries (§13.7). |
|
| 26 | + | /// Prune unreachable objects older than the grace period (`now` |
|
| 27 | + | /// ignores it), then repack everything into one pack (spec §13.4). |
|
| 36 | 28 | pub fn gc(&self, now: bool) -> Result<GcReport> { |
|
| 37 | 29 | self.gc_with(now, &mut |_| {}) |
|
| 38 | 30 | } |
|
| … | 14 unchanged lines |
||
| 53 | 45 | extra: &[ObjectRef], |
|
| 54 | 46 | progress: crate::store::Progress, |
|
| 55 | 47 | ) -> Result<GcReport> { |
|
| 56 | - | // Retired files first: the readers that used them have had their |
|
| 57 | - | // hour, and what replaced them is in the packs read from here on. |
|
| 58 | - | let retired = self |
|
| 59 | - | .store |
|
| 60 | - | .expire_retired(crate::store::RETIRED_FOR, progress)?; |
|
| 61 | 48 | progress("finding what is reachable"); |
|
| 62 | 49 | let mut keep = self.reachable()?; |
|
| 63 | 50 | for r in extra { |
|
| … | 17 unchanged lines |
||
| 81 | 68 | let (packed, merged_packs) = self |
|
| 82 | 69 | .store |
|
| 83 | 70 | .repack_all_with(crate::store::REPACK_ZSTD_LEVEL, progress)?; |
|
| 84 | - | // Last, on a store that is not this disk's directory, every |
|
| 85 | - | // signature and index entry into one list (spec §13.7, #116). |
|
| 86 | - | self.store.fold_entries(progress)?; |
|
| 87 | 71 | Ok(GcReport { |
|
| 88 | 72 | packed, |
|
| 89 | 73 | pruned, |
|
| 90 | 74 | reachable: keep.len(), |
|
| 91 | 75 | merged_packs, |
|
| 92 | - | retired, |
|
| 93 | 76 | }) |
|
| 94 | 77 | } |
|
| 95 | 78 | ||
| … | 48 unchanged lines |
||
| 144 | 127 | states.extend(view_now.tags.values().copied()); |
|
| 145 | 128 | for name in view_now.tags.keys() { |
|
| 146 | 129 | 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 | - | } |
|
| 151 | 130 | attests.push(r); |
|
| 152 | 131 | } |
|
| 153 | 132 | } |
|
| 154 | 133 | // Reference attestations. |
|
| 155 | 134 | for name in self.view()?.bookmarks.keys() { |
|
| 156 | 135 | if let Some((r, _)) = self.latest_ref_attest(name)? { |
|
| 157 | - | // With a host's acceptance of it (spec §14.7). |
|
| 158 | - | if let Some(acc) = self.acceptance_of(&r)? { |
|
| 159 | - | attests.push(acc.attest.hash()); |
|
| 160 | - | } |
|
| 161 | 136 | attests.push(r); |
|
| 162 | 137 | } |
|
| 163 | 138 | } |
|
| … | 45 unchanged lines |
||
| 209 | 184 | keep.insert(c.author); |
|
| 210 | 185 | } |
|
| 211 | 186 | for sig in self.store.signatures(&h)? { |
|
| 212 | - | self.keep_signer(&mut keep, sig.key)?; |
|
| 187 | + | keep.insert(sig.key); |
|
| 213 | 188 | } |
|
| 214 | 189 | for e in &c.edits { |
|
| 215 | 190 | if let Edit::Replace { old, new, .. } = e { |
|
| … | 9 unchanged lines |
||
| 225 | 200 | attests.extend(self.store.seals_for(&h)?); |
|
| 226 | 201 | } |
|
| 227 | 202 | self.reachable_receipts(&mut keep)?; |
|
| 228 | - | self.reachable_approvals(&mut keep)?; |
|
| 229 | 203 | for a in attests { |
|
| 230 | 204 | if keep.insert(a) { |
|
| 231 | 205 | for sig in self.store.signatures(&a)? { |
|
| 232 | - | self.keep_signer(&mut keep, sig.key)?; |
|
| 206 | + | keep.insert(sig.key); |
|
| 233 | 207 | } |
|
| 234 | 208 | } |
|
| 235 | 209 | } |
|
| 236 | 210 | Ok(keep) |
|
| 237 | - | } |
|
| 238 | - | ||
| 239 | - | /// Keep a signer's identity and, for a short-lived key, its |
|
| 240 | - | /// certificate and the certificate's issuer (spec §14.7). |
|
| 241 | - | pub(crate) fn keep_signer(&self, keep: &mut HashSet<ObjectRef>, key: ObjectRef) -> Result<()> { |
|
| 242 | - | keep.insert(key); |
|
| 243 | - | if let Some(c) = self.store.certificate_of(&key)? { |
|
| 244 | - | keep.insert(c); |
|
| 245 | - | if let Ok(cert) = self.store.get_certificate(&c) { |
|
| 246 | - | keep.insert(cert.cert.issuer); |
|
| 247 | - | } |
|
| 248 | - | } |
|
| 249 | - | Ok(()) |
|
| 250 | 211 | } |
|
| 251 | 212 | } |
modified crates/oxy-core/src/repo/git.rs +13 −361
| … | 455 unchanged lines |
||
| 456 | 456 | ||
| 457 | 457 | impl Repo { |
|
| 458 | 458 | fn git_marks_dir(&self, git_dir: &Path) -> Result<PathBuf> { |
|
| 459 | - | let canonical = |
|
| 460 | - | crate::portable::canonical(git_dir).unwrap_or_else(|_| git_dir.to_path_buf()); |
|
| 459 | + | let canonical = git_dir |
|
| 460 | + | .canonicalize() |
|
| 461 | + | .unwrap_or_else(|_| git_dir.to_path_buf()); |
|
| 461 | 462 | let key = hash_object(ObjectType::Chunk, canonical.to_string_lossy().as_bytes()).short(16); |
|
| 462 | 463 | let dir = self.oxy.join("git").join(key); |
|
| 463 | 464 | fs::create_dir_all(&dir)?; |
|
| … | 81 unchanged lines |
||
| 545 | 546 | /// new changes join the bookmark like a pull. |
|
| 546 | 547 | pub fn git_fetch(&self, url: &str, branch: &str) -> Result<GitImportReport> { |
|
| 547 | 548 | // A local path, made absolute: git runs it from inside the mirror. |
|
| 548 | - | let local = crate::portable::canonical(Path::new(url)) |
|
| 549 | + | let local = Path::new(url) |
|
| 550 | + | .canonicalize() |
|
| 549 | 551 | .ok() |
|
| 550 | 552 | .filter(|_| !url.contains("://")); |
|
| 551 | 553 | let absolute = local.as_ref().map(|p| p.to_string_lossy().into_owned()); |
|
| … | 1189 unchanged lines |
||
| 1741 | 1743 | } |
|
| 1742 | 1744 | ||
| 1743 | 1745 | fn git_author(&self, c: &Change) -> String { |
|
| 1744 | - | self.git_author_key(&c.author) |
|
| 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 | + | } |
|
| 1745 | 1754 | } |
|
| 1746 | 1755 | ||
| 1747 | 1756 | #[allow(clippy::too_many_arguments)] |
|
| … | 421 unchanged lines |
||
| 2169 | 2178 | h.len(), |
|
| 2170 | 2179 | w.len() |
|
| 2171 | 2180 | ) |
|
| 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 | 2181 | } |
modified crates/oxy-core/src/repo/history.rs +0 −28
| … | 199 unchanged lines |
||
| 200 | 200 | .unwrap()) |
|
| 201 | 201 | } |
|
| 202 | 202 | ||
| 203 | - | /// Fold only `paths` of the working copy into the head it sits on |
|
| 204 | - | /// (oxygen#118): they are committed as a change of their own, on the |
|
| 205 | - | /// head, which is then squashed into it; the rest stays uncommitted |
|
| 206 | - | /// work. A new `message` replaces the head's. |
|
| 207 | - | pub fn amend_paths(&self, message: Option<&str>, paths: &[String]) -> Result<ChangeHash> { |
|
| 208 | - | let snap = self.snapshot("oxy amend")?; |
|
| 209 | - | let base_heads = snap.base.maximal(&snap.wc.deps); |
|
| 210 | - | let [head] = base_heads.as_slice() else { |
|
| 211 | - | return fail(format!( |
|
| 212 | - | "the working copy sits on {} heads; check out the single change to amend", |
|
| 213 | - | base_heads.len() |
|
| 214 | - | )); |
|
| 215 | - | }; |
|
| 216 | - | let head = *head; |
|
| 217 | - | self.rewritable_head(&head)?; |
|
| 218 | - | drop(snap); |
|
| 219 | - | let (part, _) = self.commit_selection_partial( |
|
| 220 | - | "oxy amend: the paths to fold in", |
|
| 221 | - | &[head], |
|
| 222 | - | &Selection::Paths(paths.to_vec()), |
|
| 223 | - | )?; |
|
| 224 | - | let folded = self.squash(&part, &head)?; |
|
| 225 | - | match message { |
|
| 226 | - | Some(m) => self.describe(&folded, m), |
|
| 227 | - | None => Ok(folded), |
|
| 228 | - | } |
|
| 229 | - | } |
|
| 230 | - | ||
| 231 | 203 | /// Fold the working copy into the head it sits on: the head is rebuilt |
|
| 232 | 204 | /// as the difference between the state beneath it and the files on disk. |
|
| 233 | 205 | pub fn amend(&self, message: Option<&str>) -> Result<ChangeHash> { |
|
| … | 1006 unchanged lines |
modified crates/oxy-core/src/repo/hosted.rs +2 −324
| … | 25 unchanged lines |
||
| 26 | 26 | message: &str, |
|
| 27 | 27 | author: KeyId, |
|
| 28 | 28 | ) -> Result<Change> { |
|
| 29 | - | self.build_change_with(heads, edits, &[], message, author) |
|
| 30 | - | } |
|
| 31 | - | ||
| 32 | - | /// Build (but do not store or sign) a change that moves the subrepo at |
|
| 33 | - | /// `path` in the state with `heads` to `pin`: a hub moving a consumer's |
|
| 34 | - | /// pin for Diffusion (oxygenhub#148). The path must already be a |
|
| 35 | - | /// subrepo; nothing else changes. |
|
| 36 | - | pub fn build_pin_change( |
|
| 37 | - | &self, |
|
| 38 | - | heads: &[ChangeHash], |
|
| 39 | - | path: &str, |
|
| 40 | - | pin: &crate::subrepo::Pin, |
|
| 41 | - | message: &str, |
|
| 42 | - | author: KeyId, |
|
| 43 | - | ) -> Result<Change> { |
|
| 44 | - | self.build_change_with( |
|
| 45 | - | heads, |
|
| 46 | - | &[], |
|
| 47 | - | &[(path.to_string(), pin.clone())], |
|
| 48 | - | message, |
|
| 49 | - | author, |
|
| 50 | - | ) |
|
| 51 | - | } |
|
| 52 | - | ||
| 53 | - | fn build_change_with( |
|
| 54 | - | &self, |
|
| 55 | - | heads: &[ChangeHash], |
|
| 56 | - | edits: &[FileEdit], |
|
| 57 | - | pins: &[(String, crate::subrepo::Pin)], |
|
| 58 | - | message: &str, |
|
| 59 | - | author: KeyId, |
|
| 60 | - | ) -> Result<Change> { |
|
| 61 | 29 | if message.trim().is_empty() { |
|
| 62 | 30 | return fail("a change message is required"); |
|
| 63 | 31 | } |
|
| 64 | - | if edits.is_empty() && pins.is_empty() { |
|
| 32 | + | if edits.is_empty() { |
|
| 65 | 33 | return fail("nothing to change"); |
|
| 66 | 34 | } |
|
| 67 | 35 | let base = self.load_state(heads)?; |
|
| … | 59 unchanged lines |
||
| 127 | 95 | } |
|
| 128 | 96 | } |
|
| 129 | 97 | } |
|
| 130 | - | } |
|
| 131 | - | } |
|
| 132 | - | for (path, pin) in pins { |
|
| 133 | - | let path = path.trim_matches('/'); |
|
| 134 | - | match base_snapshot.files.get(path) { |
|
| 135 | - | Some(f) if f.kind == NodeKind::Subrepo => {} |
|
| 136 | - | Some(_) => return fail(format!("{path} is not a subrepo")), |
|
| 137 | - | None => return fail(format!("{path} does not exist")), |
|
| 138 | 98 | } |
|
| 139 | - | entries.insert( |
|
| 140 | - | path.to_string(), |
|
| 141 | - | FsEntry::new(NodeKind::Subrepo, false, pin.encode()), |
|
| 142 | - | ); |
|
| 143 | 99 | } |
|
| 144 | 100 | // A path the user writes settles its replace conflict, if any: they |
|
| 145 | 101 | // chose that content (spec §9.3). |
|
| … | 1 unchanged lines |
||
| 147 | 103 | .iter() |
|
| 148 | 104 | .filter(|e| e.content.is_some()) |
|
| 149 | 105 | .map(|e| e.path.trim_matches('/').to_string()) |
|
| 150 | - | .chain(pins.iter().map(|(p, _)| p.trim_matches('/').to_string())) |
|
| 151 | 106 | .collect(); |
|
| 152 | 107 | let built = self.build_from_entries_resolving( |
|
| 153 | 108 | &base, |
|
| … | 84 unchanged lines |
||
| 238 | 193 | }) |
|
| 239 | 194 | } |
|
| 240 | 195 | ||
| 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 | - | ||
| 332 | 196 | /// Apply a host-made update as if it had been pushed: the new change |
|
| 333 | 197 | /// (with its signatures) if any, the identities it needs, and the |
|
| 334 | 198 | /// reference update, all through `receive_push_report`, so the same |
|
| … | 388 unchanged lines |
||
| 723 | 587 | user.delegate_add(agent_id.clone(), Scope::CHANGES, None) |
|
| 724 | 588 | .unwrap(); |
|
| 725 | 589 | assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok)); |
|
| 726 | - | host.refresh().unwrap(); |
|
| 727 | 590 | let owner = { |
|
| 728 | 591 | let _ = owner; |
|
| 729 | 592 | user.config.author_key.unwrap() |
|
| … | 23 unchanged lines |
||
| 753 | 616 | // The agent cannot push: its attestation of the reference is |
|
| 754 | 617 | // refused under the policy. The owner can. |
|
| 755 | 618 | let r = as_agent.push("origin", false).unwrap(); |
|
| 756 | - | host.refresh().unwrap(); |
|
| 757 | 619 | assert!(r.iter().all(|x| !x.ok), "{r:?}"); |
|
| 758 | 620 | assert!(!host.bookmark_heads().unwrap().contains(&h)); |
|
| 759 | 621 | // The owner's own handle (its key directory was fixed at `setup`; |
|
| 760 | - | // the environment's `OXY_HOME` may have moved since), which reads |
|
| 761 | - | // the head again to see the agent's commit. |
|
| 762 | - | user.refresh().unwrap(); |
|
| 622 | + | // the environment's `OXY_HOME` may have moved since). |
|
| 763 | 623 | let r = user.push("origin", false).unwrap(); |
|
| 764 | - | host.refresh().unwrap(); |
|
| 765 | 624 | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 766 | 625 | assert!(host.bookmark_heads().unwrap().contains(&h)); |
|
| 767 | 626 | assert!(matches!( |
|
| 768 | 627 | host.authenticate_change(&h).unwrap(), |
|
| 769 | 628 | SignatureStatus::Valid { key, .. } if key == agent |
|
| 770 | - | )); |
|
| 771 | - | } |
|
| 772 | - | ||
| 773 | - | /// oxygenhub#129: a delegate that may move references makes a bookmark |
|
| 774 | - | /// at its owner's change, signing no change of its own. The push |
|
| 775 | - | /// carries its identity, so the host can check the move (it was |
|
| 776 | - | /// refused as "unknown key" until the delegate had pushed a change). |
|
| 777 | - | #[test] |
|
| 778 | - | fn a_delegate_s_first_push_of_a_bookmark_carries_its_identity() { |
|
| 779 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 780 | - | let (host, mut user, _, _, _) = setup(dir.path(), true, false); |
|
| 781 | - | let agent_home = dir.path().join("agent-home"); |
|
| 782 | - | let mut agent_repo = Repo::init(&dir.path().join("agent-work")) |
|
| 783 | - | .unwrap() |
|
| 784 | - | .with_home(agent_home.clone()); |
|
| 785 | - | let (agent, agent_id) = agent_repo |
|
| 786 | - | .create_identity("agent", "agent@example", IdentityScope::User) |
|
| 787 | - | .unwrap(); |
|
| 788 | - | user.delegate_add(agent_id, Scope::CHANGES | Scope::REFS, None) |
|
| 789 | - | .unwrap(); |
|
| 790 | - | assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok)); |
|
| 791 | - | let work = dir.path().join("work"); |
|
| 792 | - | let mut as_agent = Repo::open(&work).unwrap().with_home(agent_home); |
|
| 793 | - | as_agent |
|
| 794 | - | .set_user_config("author.signer", &agent.to_hex()) |
|
| 795 | - | .unwrap(); |
|
| 796 | - | assert!( |
|
| 797 | - | host.store().get_identity(&agent).is_err(), |
|
| 798 | - | "the host has never seen the agent" |
|
| 799 | - | ); |
|
| 800 | - | let heads = as_agent.bookmark_heads().unwrap(); |
|
| 801 | - | as_agent.bookmark_create("feature", Some(&heads)).unwrap(); |
|
| 802 | - | let r = as_agent.push_bookmark("origin", "feature", false).unwrap(); |
|
| 803 | - | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 804 | - | assert!( |
|
| 805 | - | host.store().get_identity(&agent).is_ok(), |
|
| 806 | - | "its identity came with the move" |
|
| 807 | - | ); |
|
| 808 | - | } |
|
| 809 | - | ||
| 810 | - | /// oxygenhub#266: a bookmark whose latest attestation a delegate made, |
|
| 811 | - | /// signing no change, is fetched by a third party. The fetch bundle |
|
| 812 | - | /// carries the delegate's identity even when it asks for no change, |
|
| 813 | - | /// so a client as it was (import the bundle, then check each |
|
| 814 | - | /// bookmark's attestation) verifies the move instead of failing the |
|
| 815 | - | /// whole fetch with "unknown key". |
|
| 816 | - | #[test] |
|
| 817 | - | fn a_bookmark_attested_by_a_key_that_signed_no_change_is_fetched() { |
|
| 818 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 819 | - | let (host, mut user, _, _, _) = setup(dir.path(), true, false); |
|
| 820 | - | let agent_home = dir.path().join("agent-home"); |
|
| 821 | - | let mut agent_repo = Repo::init(&dir.path().join("agent-work")) |
|
| 822 | - | .unwrap() |
|
| 823 | - | .with_home(agent_home.clone()); |
|
| 824 | - | let (agent, agent_id) = agent_repo |
|
| 825 | - | .create_identity("agent", "agent@example", IdentityScope::User) |
|
| 826 | - | .unwrap(); |
|
| 827 | - | user.delegate_add(agent_id, Scope::CHANGES | Scope::REFS, None) |
|
| 828 | - | .unwrap(); |
|
| 829 | - | assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok)); |
|
| 830 | - | let mut as_agent = Repo::open(&dir.path().join("work")) |
|
| 831 | - | .unwrap() |
|
| 832 | - | .with_home(agent_home); |
|
| 833 | - | as_agent |
|
| 834 | - | .set_user_config("author.signer", &agent.to_hex()) |
|
| 835 | - | .unwrap(); |
|
| 836 | - | let heads = as_agent.bookmark_heads().unwrap(); |
|
| 837 | - | as_agent.bookmark_create("feature", Some(&heads)).unwrap(); |
|
| 838 | - | let r = as_agent.push_bookmark("origin", "feature", false).unwrap(); |
|
| 839 | - | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 840 | - | host.refresh().unwrap(); |
|
| 841 | - | let (_, sa) = host.latest_ref_attest("feature").unwrap().unwrap(); |
|
| 842 | - | assert_eq!(sa.sigs[0].key, agent, "the agent attested the move"); |
|
| 843 | - | ||
| 844 | - | // What a fetch that names no change brings: the agent's identity. |
|
| 845 | - | let remote = crate::protocol::LocalRemote::open(&dir.path().join("host")).unwrap(); |
|
| 846 | - | let carries = |b: &Bundle| { |
|
| 847 | - | b.objects |
|
| 848 | - | .iter() |
|
| 849 | - | .any(|(t, body)| *t == ObjectType::Identity && hash_object(*t, body) == agent) |
|
| 850 | - | }; |
|
| 851 | - | assert!(carries( |
|
| 852 | - | &crate::protocol::Remote::fetch(&remote, &[], None).unwrap() |
|
| 853 | 629 | )); |
|
| 854 | - | assert!(carries(&host.bundle_for_filtered(&[], None).unwrap())); |
|
| 855 | - | ||
| 856 | - | // A third party's clone takes both bookmarks, verified. |
|
| 857 | - | let theirs = Repo::clone( |
|
| 858 | - | dir.path().join("host").to_str().unwrap(), |
|
| 859 | - | &dir.path().join("theirs"), |
|
| 860 | - | ) |
|
| 861 | - | .unwrap(); |
|
| 862 | - | let tracked = theirs.view().unwrap().remotes["origin"].clone(); |
|
| 863 | - | assert_eq!( |
|
| 864 | - | tracked.get("feature"), |
|
| 865 | - | host.view().unwrap().bookmarks.get("feature") |
|
| 866 | - | ); |
|
| 867 | - | assert!(theirs.store().get_identity(&agent).is_ok()); |
|
| 868 | - | let report = theirs.fetch("origin").unwrap(); |
|
| 869 | - | assert!(report.skipped.is_empty(), "{:?}", report.skipped); |
|
| 870 | - | } |
|
| 871 | - | ||
| 872 | - | /// Make `bookmark`'s latest attestation on `host` one signed as `key` |
|
| 873 | - | /// with `sk`: by a key the host never saw (as a host that did not send |
|
| 874 | - | /// the signer's identity looked), or a bad signature. |
|
| 875 | - | fn reattest(host: &Repo, bookmark: &str, key: KeyId, sk: &SecretKey) { |
|
| 876 | - | let state = host.view().unwrap().bookmarks[bookmark]; |
|
| 877 | - | let (_, last) = host.latest_ref_attest(bookmark).unwrap().unwrap(); |
|
| 878 | - | let AttestBody::Ref { |
|
| 879 | - | old_state, serial, .. |
|
| 880 | - | } = last.attest.body |
|
| 881 | - | else { |
|
| 882 | - | panic!("not a ref attestation") |
|
| 883 | - | }; |
|
| 884 | - | let attest = Attest { |
|
| 885 | - | repo: host.id().unwrap(), |
|
| 886 | - | hlc: Hlc::now(), |
|
| 887 | - | body: AttestBody::Ref { |
|
| 888 | - | name: bookmark.into(), |
|
| 889 | - | old_state, |
|
| 890 | - | new_state: state, |
|
| 891 | - | serial: serial + 1, |
|
| 892 | - | }, |
|
| 893 | - | }; |
|
| 894 | - | let r = host.store().put_attest(&attest).unwrap(); |
|
| 895 | - | host.store() |
|
| 896 | - | .put_signature(&r, &sk.sign(Domain::Attest, &key, &attest.hash())) |
|
| 897 | - | .unwrap(); |
|
| 898 | - | host.set_ref_attest(bookmark, &r).unwrap(); |
|
| 899 | - | } |
|
| 900 | - | ||
| 901 | - | /// oxygenhub#266: a bookmark whose attestation a client cannot verify |
|
| 902 | - | /// (its signer unknown, or its signature bad) is left as it was, named |
|
| 903 | - | /// with why, and the rest are fetched. The current bookmark is not |
|
| 904 | - | /// skipped: a clone, fetch or pull that would take it fails. |
|
| 905 | - | #[test] |
|
| 906 | - | fn an_unverifiable_bookmark_is_skipped_but_never_the_current_one() { |
|
| 907 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 908 | - | let (host, user, _, _, _) = setup(dir.path(), false, false); |
|
| 909 | - | let author = user.config.author_key.unwrap(); |
|
| 910 | - | let heads = user.bookmark_heads().unwrap(); |
|
| 911 | - | for b in ["unknown", "forged", "fine"] { |
|
| 912 | - | user.bookmark_create(b, Some(&heads)).unwrap(); |
|
| 913 | - | let r = user.push_bookmark("origin", b, false).unwrap(); |
|
| 914 | - | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 915 | - | } |
|
| 916 | - | host.refresh().unwrap(); |
|
| 917 | - | let stranger_sk = SecretKey::generate(); |
|
| 918 | - | let stranger = stranger_sk.identity("s", "s@example", 1).hash(); |
|
| 919 | - | reattest(&host, "unknown", stranger, &stranger_sk); |
|
| 920 | - | reattest(&host, "forged", author, &stranger_sk); |
|
| 921 | - | ||
| 922 | - | let url = dir.path().join("host").to_string_lossy().to_string(); |
|
| 923 | - | let (theirs, skipped) = |
|
| 924 | - | Repo::clone_reporting(&url, &dir.path().join("theirs"), None, &[], None).unwrap(); |
|
| 925 | - | let names: Vec<&str> = skipped.iter().map(|(n, _)| n.as_str()).collect(); |
|
| 926 | - | assert_eq!(names, ["forged", "unknown"]); |
|
| 927 | - | assert!(skipped[1].1.contains("unknown key"), "{skipped:?}"); |
|
| 928 | - | assert!(skipped[1].1.contains("origin/unknown"), "{skipped:?}"); |
|
| 929 | - | let tracked = theirs.view().unwrap().remotes["origin"].clone(); |
|
| 930 | - | assert!(tracked.contains_key("main") && tracked.contains_key("fine")); |
|
| 931 | - | assert!(!tracked.contains_key("unknown") && !tracked.contains_key("forged")); |
|
| 932 | - | assert_eq!(theirs.config.bookmark, "main"); |
|
| 933 | - | assert_eq!(theirs.fetch("origin").unwrap().skipped.len(), 2); |
|
| 934 | - | assert_eq!(theirs.pull("origin").unwrap().skipped.len(), 2); |
|
| 935 | - | ||
| 936 | - | // The current bookmark's attestation unverifiable: a fetch and a |
|
| 937 | - | // pull fail, and so does a fresh clone. |
|
| 938 | - | reattest(&host, "main", stranger, &stranger_sk); |
|
| 939 | - | for err in [ |
|
| 940 | - | theirs.fetch("origin").unwrap_err(), |
|
| 941 | - | theirs.pull("origin").unwrap_err(), |
|
| 942 | - | Repo::clone(&url, &dir.path().join("again")) |
|
| 943 | - | .err() |
|
| 944 | - | .expect("the clone fails"), |
|
| 945 | - | ] { |
|
| 946 | - | let err = err.to_string(); |
|
| 947 | - | assert!( |
|
| 948 | - | err.contains("attestation for origin/main: unknown key"), |
|
| 949 | - | "{err}" |
|
| 950 | - | ); |
|
| 951 | - | } |
|
| 952 | 630 | } |
|
| 953 | 631 | ||
| 954 | 632 | /// A delegation past its expiry authenticates nothing made after it, |
|
| … | 127 unchanged lines |
modified crates/oxy-core/src/repo/identity.rs +8 −222
| … | 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 | - | ||
| 56 | 21 | /// The outcome of replacing the configured identity (spec §14.2). |
|
| 57 | 22 | #[derive(Clone, Debug)] |
|
| 58 | 23 | pub struct Rotation { |
|
| … | 113 unchanged lines |
||
| 172 | 137 | if new.previous != Some(old) { |
|
| 173 | 138 | return fail("the successor identity must name the current key as previous"); |
|
| 174 | 139 | } |
|
| 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 | - | } |
|
| 201 | 140 | let new_key = self.install_key(&new, &sk)?; |
|
| 202 | 141 | let attest = Attest { |
|
| 203 | 142 | repo: self.id()?, |
|
| … | 18 unchanged lines |
||
| 222 | 161 | identity: new, |
|
| 223 | 162 | policy, |
|
| 224 | 163 | }) |
|
| 225 | - | } |
|
| 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 | 164 | } |
|
| 252 | 165 | ||
| 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 | - | ||
| 266 | 166 | /// Rotate to a fresh key pair; the new identity names the old one as |
|
| 267 | 167 | /// `previous` and keeps its name, email, delegates, and expiry. |
|
| 268 | 168 | pub fn rotate_identity(&mut self) -> Result<Rotation> { |
|
| … | 159 unchanged lines |
||
| 428 | 328 | /// The key this repository signs with: `author.signer` when set (an |
|
| 429 | 329 | /// agent's own key, acting for the author), else `author.key`. |
|
| 430 | 330 | pub fn signing_key(&self) -> Option<KeyId> { |
|
| 431 | - | if self.config.signer_keyless { |
|
| 432 | - | // The key certified last, once this command has signed. |
|
| 433 | - | return self.keyless_key.borrow().as_ref().map(|(k, _, _)| *k); |
|
| 434 | - | } |
|
| 435 | 331 | self.config.signer_key.or(self.config.author_key) |
|
| 436 | 332 | } |
|
| 437 | 333 | ||
| … | 2 unchanged lines |
||
| 440 | 336 | /// so a repository that has never seen it (a fresh clone) can store it |
|
| 441 | 337 | /// when it first signs. |
|
| 442 | 338 | pub fn secret_key(&self) -> Result<Option<(KeyId, SecretKey)>> { |
|
| 443 | - | if self.config.signer_keyless { |
|
| 444 | - | return self.keyless_secret().map(Some); |
|
| 445 | - | } |
|
| 446 | 339 | let Some(key) = self.signing_key() else { |
|
| 447 | 340 | return Ok(None); |
|
| 448 | 341 | }; |
|
| … | 106 unchanged lines |
||
| 555 | 448 | Ok(id) => { |
|
| 556 | 449 | return Ok(SignatureStatus::Valid { |
|
| 557 | 450 | key: sig.key, |
|
| 558 | - | name: self.certified_subject(&sig.key).unwrap_or(id.name), |
|
| 451 | + | name: id.name, |
|
| 559 | 452 | email: id.email, |
|
| 560 | 453 | sealed: false, |
|
| 561 | 454 | revoked: self.is_revoked(&sig.key)?, |
|
| … | 3 unchanged lines |
||
| 565 | 458 | } |
|
| 566 | 459 | } |
|
| 567 | 460 | Ok(SignatureStatus::Invalid(last_err)) |
|
| 568 | - | } |
|
| 569 | - | ||
| 570 | - | /// Whether `signer` is a short-lived key acting for `author` under a |
|
| 571 | - | /// grant (spec §14.7): its certificate verifies with its issuer and |
|
| 572 | - | /// carries a grant `author` signed, for the certificate's subject and |
|
| 573 | - | /// issuer, giving `needed` at `at_ms` (the change's time, as for a |
|
| 574 | - | /// delegation). Whether the certificate covered the time it signed is |
|
| 575 | - | /// the policy's check, at a time the signer did not choose. `None` |
|
| 576 | - | /// when it does; otherwise why not. |
|
| 577 | - | fn granted( |
|
| 578 | - | &self, |
|
| 579 | - | author: &KeyId, |
|
| 580 | - | signer: &KeyId, |
|
| 581 | - | needed: Scope, |
|
| 582 | - | at_ms: u64, |
|
| 583 | - | ) -> std::result::Result<Option<String>, String> { |
|
| 584 | - | let neither = || { |
|
| 585 | - | Some(format!( |
|
| 586 | - | "key {} is neither the author nor a delegate", |
|
| 587 | - | signer.short(12) |
|
| 588 | - | )) |
|
| 589 | - | }; |
|
| 590 | - | let Ok(Some(c)) = self.store.certificate_of(signer) else { |
|
| 591 | - | return Ok(neither()); |
|
| 592 | - | }; |
|
| 593 | - | let Ok(sc) = self.store.get_certificate(&c) else { |
|
| 594 | - | return Ok(neither()); |
|
| 595 | - | }; |
|
| 596 | - | let Some(g) = &sc.cert.grant else { |
|
| 597 | - | return Ok(neither()); |
|
| 598 | - | }; |
|
| 599 | - | let who = signer.short(12); |
|
| 600 | - | let issuer = self |
|
| 601 | - | .store |
|
| 602 | - | .get_identity(&sc.cert.issuer) |
|
| 603 | - | .map_err(|_| format!("key {who}'s certificate names an unknown issuer"))?; |
|
| 604 | - | sc.verify(&issuer) |
|
| 605 | - | .map_err(|e| format!("key {who}'s certificate: {e}"))?; |
|
| 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) { |
|
| 609 | - | return Ok(neither()); |
|
| 610 | - | } |
|
| 611 | - | let grantor = self |
|
| 612 | - | .identity(&g.grant.grantor) |
|
| 613 | - | .map_err(|_| format!("key {who}'s grant is by an unknown author"))?; |
|
| 614 | - | g.verify(&grantor) |
|
| 615 | - | .map_err(|e| format!("key {who}'s grant: {e}"))?; |
|
| 616 | - | if g.grant.subject != sc.cert.subject || g.grant.issuer != sc.cert.issuer { |
|
| 617 | - | return Err(format!( |
|
| 618 | - | "key {who}'s grant is for {} from issuer {}, not its certificate's", |
|
| 619 | - | g.grant.subject, |
|
| 620 | - | g.grant.issuer.short(12) |
|
| 621 | - | )); |
|
| 622 | - | } |
|
| 623 | - | if !sc.cert.scope.has(needed) || !g.grant.scope.has(needed) { |
|
| 624 | - | return Err(format!("key {who} is not granted {needed}")); |
|
| 625 | - | } |
|
| 626 | - | if !g.grant.covers(needed, at_ms) { |
|
| 627 | - | return Err(format!( |
|
| 628 | - | "key {who}'s grant did not cover the time the change was made" |
|
| 629 | - | )); |
|
| 630 | - | } |
|
| 631 | - | Ok(None) |
|
| 632 | 461 | } |
|
| 633 | 462 | ||
| 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 | - | ||
| 655 | - | /// The subject a short-lived key was certified for (spec §14.7), such |
|
| 656 | - | /// as `agent:claude@andy`: what names it, since its identity is only |
|
| 657 | - | /// a fresh key's. |
|
| 658 | - | pub fn certified_subject(&self, key: &KeyId) -> Option<String> { |
|
| 659 | - | let c = self.store.certificate_of(key).ok()??; |
|
| 660 | - | Some(self.store.get_certificate(&c).ok()?.cert.subject) |
|
| 661 | - | } |
|
| 662 | - | ||
| 663 | 463 | /// Verify `sig` over `target` and check the author-or-delegate rule for `change`. |
|
| 664 | 464 | fn check_signer( |
|
| 665 | 465 | &self, |
|
| … | 23 unchanged lines |
||
| 689 | 489 | .ok() |
|
| 690 | 490 | .and_then(|a| a.delegate(&sig.key).copied()); |
|
| 691 | 491 | match delegation { |
|
| 692 | - | // A certified key acting under the author's grant (§14.7). |
|
| 693 | 492 | None => { |
|
| 694 | - | if let Some(why) = |
|
| 695 | - | self.granted(&change.author, &sig.key, needed, change.hlc.wall_ms)? |
|
| 696 | - | { |
|
| 697 | - | return Err(why); |
|
| 698 | - | } |
|
| 493 | + | return Err(format!( |
|
| 494 | + | "key {} is neither the author nor a delegate", |
|
| 495 | + | sig.key.short(12) |
|
| 496 | + | )) |
|
| 699 | 497 | } |
|
| 700 | 498 | Some(d) if !d.scope.has(needed) => { |
|
| 701 | 499 | return Err(format!( |
|
| … | 75 unchanged lines |
||
| 777 | 575 | Ok(id) => { |
|
| 778 | 576 | return Ok(SignatureStatus::Valid { |
|
| 779 | 577 | key: sig.key, |
|
| 780 | - | name: self.certified_subject(&sig.key).unwrap_or(id.name), |
|
| 578 | + | name: id.name, |
|
| 781 | 579 | email: id.email, |
|
| 782 | 580 | sealed: true, |
|
| 783 | 581 | revoked: self.is_revoked(&sig.key)?, |
|
| … | 41 unchanged lines |
||
| 825 | 623 | self.store.index_seal(h, &r)?; |
|
| 826 | 624 | } |
|
| 827 | 625 | Ok(r) |
|
| 828 | - | } |
|
| 829 | - | ||
| 830 | - | /// Whether `signer` seals changes by `author`: its own, anonymous ones |
|
| 831 | - | /// (§14.4), and, when it signs for the configured author (a delegate, |
|
| 832 | - | /// or a short-lived key under the author's grant, §14.7), the |
|
| 833 | - | /// author's. A receiver still checks it may. |
|
| 834 | - | pub(crate) fn seals_for_author(&self, author: &KeyId, signer: &KeyId) -> bool { |
|
| 835 | - | author == signer |
|
| 836 | - | || author.is_zero() |
|
| 837 | - | || ((self.config.signer_key.is_some() || self.config.signer_keyless) |
|
| 838 | - | && self.config.author_key == Some(*author)) |
|
| 839 | 626 | } |
|
| 840 | 627 | ||
| 841 | 628 | /// The changes of the current bookmark this identity can authenticate |
|
| 842 | - | /// but has not: its own unsigned ones, anonymous ones, and the |
|
| 843 | - | /// author's when it signs for them. |
|
| 629 | + | /// but has not: its own unsigned ones and anonymous ones. |
|
| 844 | 630 | pub fn sealable(&self) -> Result<Vec<ChangeHash>> { |
|
| 845 | 631 | let Some((key, _)) = self.secret_key()? else { |
|
| 846 | 632 | return fail("no identity: run oxy identity create"); |
|
| … | 2 unchanged lines |
||
| 849 | 635 | let state = self.load_state_history(&heads)?; |
|
| 850 | 636 | let mut out = Vec::new(); |
|
| 851 | 637 | for (h, meta) in state.changes() { |
|
| 852 | - | if self.seals_for_author(&meta.author, &key) |
|
| 638 | + | if (meta.author == key || meta.author.is_zero()) |
|
| 853 | 639 | && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) |
|
| 854 | 640 | { |
|
| 855 | 641 | out.push(*h); |
|
| … | 24 unchanged lines |
modified crates/oxy-core/src/repo/inspect.rs +2 −2
| … | 61 unchanged lines |
||
| 62 | 62 | let (_, wc) = self.working_copy()?; |
|
| 63 | 63 | // The working copy's base is what the state index holds. |
|
| 64 | 64 | let mut state = self.load_state_indexed(&wc.deps)?; |
|
| 65 | - | let tree = self.tree_view(&mut state)?; |
|
| 65 | + | let tree = state.tree().view(); |
|
| 66 | 66 | if let Some(e) = tree.entries.get(path) { |
|
| 67 | 67 | self.load_graphs_of(&mut state, &[e.node])?; |
|
| 68 | 68 | } |
|
| … | 8 unchanged lines |
||
| 77 | 77 | /// Blame `path` in the state with these heads, building only its graph. |
|
| 78 | 78 | fn blame_heads(&self, path: &str, heads: &[ChangeHash], what: &str) -> Result<Vec<BlameLine>> { |
|
| 79 | 79 | let mut state = self.load_state_history(heads)?; |
|
| 80 | - | let tree = self.tree_view(&mut state)?; |
|
| 80 | + | let tree = state.tree().view(); |
|
| 81 | 81 | if let Some(e) = tree.entries.get(path) { |
|
| 82 | 82 | self.load_graphs_of(&mut state, &[e.node])?; |
|
| 83 | 83 | } |
|
| … | 397 unchanged lines |
removed crates/oxy-core/src/repo/keyless.rs +0 −280
| 1 | - | //! Signing with short-lived keys (spec §14.7, oxygenhub#241): with |
|
| 2 | - | //! `author.signer = keyless`, oxy asks the hub to certify a fresh key |
|
| 3 | - | //! whenever it must sign, with the agent's grant token as the credential, |
|
| 4 | - | //! and keeps the key in memory for its certificate's minutes. Nothing that |
|
| 5 | - | //! signs is ever written to disk: the home holds only the grant token, |
|
| 6 | - | //! which its owner revokes on the hub. The certificate carries the owner's |
|
| 7 | - | //! grant, so what the key signs counts in the owner's name. |
|
| 8 | - | ||
| 9 | - | use super::*; |
|
| 10 | - | use crate::cert::{certify_challenge, SignedCertificate}; |
|
| 11 | - | use crate::credentials::{hub_of, Credentials}; |
|
| 12 | - | use crate::protocol::{http_get, http_json, RemoteOptions}; |
|
| 13 | - | ||
| 14 | - | /// A certified key is used until this long before its certificate ends, |
|
| 15 | - | /// so a push started near the end does not outlive it. |
|
| 16 | - | const MARGIN_MS: u64 = 2 * 60 * 1000; |
|
| 17 | - | ||
| 18 | - | impl Repo { |
|
| 19 | - | /// The hub that certifies keys: `keyless.hub`, else the default |
|
| 20 | - | /// remote's. |
|
| 21 | - | fn keyless_hub(&self) -> Result<String> { |
|
| 22 | - | let url = match &self.config.keyless_hub { |
|
| 23 | - | Some(h) => h.clone(), |
|
| 24 | - | None => self |
|
| 25 | - | .config |
|
| 26 | - | .remotes |
|
| 27 | - | .get(&self.default_remote()) |
|
| 28 | - | .cloned() |
|
| 29 | - | .ok_or_else(|| { |
|
| 30 | - | RepoError::Msg( |
|
| 31 | - | "author.signer is keyless, but neither keyless.hub nor a default remote names the hub that certifies keys" |
|
| 32 | - | .into(), |
|
| 33 | - | ) |
|
| 34 | - | })?, |
|
| 35 | - | }; |
|
| 36 | - | hub_of(&url).ok_or_else(|| RepoError::Msg(format!("{url} is not a hub URL"))) |
|
| 37 | - | } |
|
| 38 | - | ||
| 39 | - | /// The key to sign with now: the one certified last while it has more |
|
| 40 | - | /// than a couple of minutes left, else a fresh one the hub certifies. |
|
| 41 | - | pub(crate) fn keyless_secret(&self) -> Result<(KeyId, SecretKey)> { |
|
| 42 | - | let now = Hlc::now().wall_ms; |
|
| 43 | - | if let Some((k, sk, until)) = self.keyless_key.borrow().as_ref() { |
|
| 44 | - | if now + MARGIN_MS < *until { |
|
| 45 | - | return Ok((*k, sk.clone())); |
|
| 46 | - | } |
|
| 47 | - | } |
|
| 48 | - | let hub = self.keyless_hub()?; |
|
| 49 | - | let home = self |
|
| 50 | - | .user_config |
|
| 51 | - | .parent() |
|
| 52 | - | .map(Path::to_path_buf) |
|
| 53 | - | .unwrap_or_else(default_home); |
|
| 54 | - | let creds = Credentials::at(home.clone()); |
|
| 55 | - | let token = creds.grant(&hub).map_err(RepoError::Msg)?.ok_or_else(|| { |
|
| 56 | - | RepoError::Msg(format!( |
|
| 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" |
|
| 58 | - | )) |
|
| 59 | - | })?; |
|
| 60 | - | let options = RemoteOptions { |
|
| 61 | - | token: Some(token.clone()), |
|
| 62 | - | ca: creds.get(&hub).map_err(RepoError::Msg)?.and_then(|e| e.ca), |
|
| 63 | - | ..RemoteOptions::default() |
|
| 64 | - | }; |
|
| 65 | - | let sk = SecretKey::generate(); |
|
| 66 | - | let id = sk.identity(&self.config.author_name, &self.config.author_email, now); |
|
| 67 | - | let key = id.hash(); |
|
| 68 | - | let proof = sk |
|
| 69 | - | .sign_challenge(&key, &certify_challenge(&token)) |
|
| 70 | - | .map_err(|e| RepoError::Msg(e.to_string()))?; |
|
| 71 | - | let body = serde_json::json!({ |
|
| 72 | - | "identity": crate::dsse::base64(&id.encode()), |
|
| 73 | - | "proof": crate::dsse::base64(&crate::cbor::encode(&proof.to_value())), |
|
| 74 | - | "scope": "changes,refs,seal", |
|
| 75 | - | }); |
|
| 76 | - | let (code, answer) = http_json( |
|
| 77 | - | "POST", |
|
| 78 | - | &format!("{hub}/api/v1/keyless/certify"), |
|
| 79 | - | Some(&body.to_string()), |
|
| 80 | - | &options, |
|
| 81 | - | )?; |
|
| 82 | - | let reply: serde_json::Value = serde_json::from_str(&answer).unwrap_or_default(); |
|
| 83 | - | if !(200..300).contains(&code) { |
|
| 84 | - | let why = reply["error"].as_str().unwrap_or(answer.trim()); |
|
| 85 | - | return fail(format!( |
|
| 86 | - | "{hub} would not certify a key (HTTP {code}): {why}" |
|
| 87 | - | )); |
|
| 88 | - | } |
|
| 89 | - | let cert = reply["certificate"] |
|
| 90 | - | .as_str() |
|
| 91 | - | .and_then(crate::dsse::base64_decode) |
|
| 92 | - | .ok_or_else(|| RepoError::Msg(format!("{hub} answered with no certificate")))?; |
|
| 93 | - | let cert = SignedCertificate::decode(&cert) |
|
| 94 | - | .map_err(|e| RepoError::Msg(format!("{hub}'s certificate: {e}")))?; |
|
| 95 | - | if cert.cert.key_id() != key { |
|
| 96 | - | return fail(format!( |
|
| 97 | - | "{hub} certified another key than the one it was asked for" |
|
| 98 | - | )); |
|
| 99 | - | } |
|
| 100 | - | // The issuer's identity, so this repository (and whoever it pushes |
|
| 101 | - | // to) can check the certificate: the hub's own, as it publishes it. |
|
| 102 | - | let issuer = match self.store.get_identity(&cert.cert.issuer) { |
|
| 103 | - | Ok(i) => i, |
|
| 104 | - | Err(_) => { |
|
| 105 | - | let bytes = http_get( |
|
| 106 | - | &format!("{hub}/.well-known/oxyhub-key"), |
|
| 107 | - | &RemoteOptions { |
|
| 108 | - | token: None, |
|
| 109 | - | ..options.clone() |
|
| 110 | - | }, |
|
| 111 | - | )?; |
|
| 112 | - | let i = Identity::decode(&bytes)?; |
|
| 113 | - | if i.hash() != cert.cert.issuer { |
|
| 114 | - | return fail(format!( |
|
| 115 | - | "{hub}'s published key is not the one that signed its certificate" |
|
| 116 | - | )); |
|
| 117 | - | } |
|
| 118 | - | i |
|
| 119 | - | } |
|
| 120 | - | }; |
|
| 121 | - | cert.verify(&issuer) |
|
| 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 | - | } |
|
| 158 | - | self.store.put_identity(&issuer)?; |
|
| 159 | - | self.store.put_identity(&id)?; |
|
| 160 | - | self.store.put_certificate(&cert)?; |
|
| 161 | - | *self.keyless_key.borrow_mut() = Some((key, sk.clone(), cert.cert.expires)); |
|
| 162 | - | Ok((key, sk)) |
|
| 163 | - | } |
|
| 164 | - | } |
|
| 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 | - | ||
| 220 | - | /// The key a home names as its author (`author.key` in its |
|
| 221 | - | /// `config.toml`), with its secret from the home's `keys/`: what signs |
|
| 222 | - | /// outside any repository, such as a grant to an agent (`oh agent grant`). |
|
| 223 | - | /// `None` when the home names no key or does not hold its secret. |
|
| 224 | - | pub fn home_key(home: &Path) -> Result<Option<(Identity, SecretKey)>> { |
|
| 225 | - | let config = read_config_file(&home.join("config.toml"))?; |
|
| 226 | - | let Some(hex) = config.get("author.key").filter(|v| !v.is_empty()) else { |
|
| 227 | - | return Ok(None); |
|
| 228 | - | }; |
|
| 229 | - | let key = KeyId::from_hex(hex) |
|
| 230 | - | .ok_or_else(|| RepoError::Msg("author.key must be a 64-character hex KeyId".into()))?; |
|
| 231 | - | let keys = home.join("keys"); |
|
| 232 | - | let secret = match fs::read(keys.join(format!("{hex}.secret"))) { |
|
| 233 | - | Ok(b) => b, |
|
| 234 | - | Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), |
|
| 235 | - | Err(e) => return Err(e.into()), |
|
| 236 | - | }; |
|
| 237 | - | let (k, sk) = SecretKey::decode(&secret)?; |
|
| 238 | - | let id = Identity::decode(&fs::read(keys.join(format!("{hex}.identity")))?)?; |
|
| 239 | - | if k != key || id.hash() != key { |
|
| 240 | - | return fail(format!( |
|
| 241 | - | "{} holds another key than author.key", |
|
| 242 | - | keys.display() |
|
| 243 | - | )); |
|
| 244 | - | } |
|
| 245 | - | Ok(Some((id, sk))) |
|
| 246 | - | } |
|
| 247 | - | ||
| 248 | - | #[cfg(test)] |
|
| 249 | - | mod tests { |
|
| 250 | - | use super::*; |
|
| 251 | - | ||
| 252 | - | #[test] |
|
| 253 | - | fn a_home_s_key_signs_outside_any_repository() { |
|
| 254 | - | static HOME: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
|
| 255 | - | HOME.get_or_init(|| { |
|
| 256 | - | let d = tempfile::tempdir().unwrap(); |
|
| 257 | - | std::env::set_var("OXY_HOME", d.path()); |
|
| 258 | - | d |
|
| 259 | - | }); |
|
| 260 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 261 | - | let home = dir.path().join("home"); |
|
| 262 | - | assert!(home_key(&home).unwrap().is_none(), "no config, no key"); |
|
| 263 | - | let mut r = Repo::init(&dir.path().join("work")) |
|
| 264 | - | .unwrap() |
|
| 265 | - | .with_home(home.clone()); |
|
| 266 | - | let (key, id) = r |
|
| 267 | - | .create_identity("Andy", "[email protected]", IdentityScope::User) |
|
| 268 | - | .unwrap(); |
|
| 269 | - | let (got, sk) = home_key(&home).unwrap().expect("the home's author key"); |
|
| 270 | - | assert_eq!(got, id); |
|
| 271 | - | let sig = sk.sign(Domain::Grant, &key, &ObjectRef([1; 32])); |
|
| 272 | - | assert!(verify_signature(&id, &sig, Domain::Grant, &ObjectRef([1; 32])).is_ok()); |
|
| 273 | - | // An agent whose signer is keyless signs nothing until it asks. |
|
| 274 | - | r.set_user_config("author.signer", "keyless").unwrap(); |
|
| 275 | - | assert!(r.config.signer_keyless && r.config.signer_key.is_none()); |
|
| 276 | - | assert_eq!(r.signing_key(), None); |
|
| 277 | - | let err = r.secret_key().unwrap_err().to_string(); |
|
| 278 | - | assert!(err.contains("keyless.hub nor a default remote"), "{err}"); |
|
| 279 | - | } |
|
| 280 | - | } |
modified crates/oxy-core/src/repo/mod.rs +52 −194
| … | 115 unchanged lines |
||
| 116 | 116 | mod hosted; |
|
| 117 | 117 | mod identity; |
|
| 118 | 118 | mod inspect; |
|
| 119 | - | mod keyless; |
|
| 120 | 119 | mod ops; |
|
| 121 | 120 | mod policy; |
|
| 122 | 121 | mod prompt; |
|
| … | 7 unchanged lines |
||
| 130 | 129 | #[cfg(test)] |
|
| 131 | 130 | mod tests; |
|
| 132 | 131 | mod touched; |
|
| 133 | - | mod transparency; |
|
| 134 | 132 | mod walk; |
|
| 135 | 133 | mod workspace; |
|
| 136 | 134 | ||
| … | 6 unchanged lines |
||
| 143 | 141 | pub use git::{GitExportReport, GitImportReport, GitVerifyReport}; |
|
| 144 | 142 | pub use hooks::{json_array, json_str, HookOutcome}; |
|
| 145 | 143 | pub use hosted::FileEdit; |
|
| 146 | - | pub use identity::{create_user_identity, Rotation, SignatureStatus}; |
|
| 144 | + | pub use identity::{Rotation, SignatureStatus}; |
|
| 147 | 145 | pub use inspect::{BisectStep, BlameLine, Description}; |
|
| 148 | - | pub use keyless::home_key; |
|
| 149 | 146 | pub use ops::{RestoreReport, RevertReport, Undone, ViewChange}; |
|
| 150 | 147 | pub use policy::PolicyUpdate; |
|
| 151 | 148 | pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT, GLYPH_PROMPT_FORMAT}; |
|
| … | 1 unchanged lines |
||
| 153 | 150 | pub use revset::{Revset, REVSET_BUDGET}; |
|
| 154 | 151 | pub use snapshot::{ |
|
| 155 | 152 | added_node_paths, diff_files_of, has_conflict_markers, is_binary, referenced_changes, |
|
| 156 | - | render_change, render_hunks, similarity, ChangeStatus, DiffHunk, FileDiff, Hunk, KeptBoth, |
|
| 157 | - | PathStatus, Selection, SnapshotResult, |
|
| 153 | + | render_change, render_hunks, similarity, ChangeStatus, DiffHunk, FileDiff, Hunk, PathStatus, |
|
| 154 | + | Selection, SnapshotResult, |
|
| 158 | 155 | }; |
|
| 159 | 156 | pub use subrepos::{SubrepoInfo, SubrepoSync}; |
|
| 160 | 157 | pub use sync::{FetchReport, PullReport, PushReport, RefUpdated, OBJECTS_RESULT}; |
|
| 161 | 158 | pub use tags::TagInfo; |
|
| 162 | - | pub use transparency::LogAudit; |
|
| 163 | 159 | pub use workspace::{FileLock, SnapshotLock, WorkingCopyLock, WorkspaceInfo}; |
|
| 164 | 160 | ||
| 165 | 161 | /// The file name under which per-bookmark or per-tag data is kept: `/` |
|
| … | 86 unchanged lines |
||
| 252 | 248 | } |
|
| 253 | 249 | ||
| 254 | 250 | pub struct Repo { |
|
| 255 | - | /// The key a host accepts certified updates with (spec §14.7): when |
|
| 256 | - | /// set, accepting a reference update signed by a short-lived key also |
|
| 257 | - | /// records the host's acceptance and its time. |
|
| 258 | - | pub(crate) acceptor: Option<std::sync::Arc<(Identity, crate::identity::SecretKey)>>, |
|
| 259 | - | /// With `author.signer = keyless`, the fresh key the hub last |
|
| 260 | - | /// certified and when its certificate ends (spec §14.7): kept in memory |
|
| 261 | - | /// only, for one command. |
|
| 262 | - | pub(crate) keyless_key: std::cell::RefCell<Option<(KeyId, crate::identity::SecretKey, u64)>>, |
|
| 263 | 251 | /// Oxygen-pinned subrepos skipped by the running Git export. |
|
| 264 | 252 | pub(crate) git_export_skipped: std::cell::Cell<usize>, |
|
| 265 | 253 | /// The manifest as last read, with the file identity it was read from, |
|
| … | 26 unchanged lines |
||
| 292 | 280 | /// `--at-op`: read the repository as it was after this operation; |
|
| 293 | 281 | /// recording an operation is refused. |
|
| 294 | 282 | at_op: Option<ObjectRef>, |
|
| 295 | - | /// The operation head this handle reads (oxygenhub#249): read once, on |
|
| 296 | - | /// first use, and moved only by this handle's own operations or by |
|
| 297 | - | /// [`Repo::refresh`], so one command or request never mixes two heads. |
|
| 298 | - | head_seen: std::cell::Cell<Option<ObjectRef>>, |
|
| 299 | 283 | /// The directories (with their times) and nested repositories of the |
|
| 300 | 284 | /// last walk, for the manifest written from it. |
|
| 301 | 285 | pub(crate) walked_dirs: std::cell::RefCell<Option<WalkedDirs>>, |
|
| … | 77 unchanged lines |
||
| 379 | 363 | /// on the store at `storage` (`storage.url`), or in `.oxy/objects`. |
|
| 380 | 364 | pub fn init_on(root: &Path, id: [u8; 16], storage: Option<&str>) -> Result<Repo> { |
|
| 381 | 365 | fs::create_dir_all(root)?; |
|
| 382 | - | let root = crate::portable::canonical(root).unwrap_or_else(|_| root.to_path_buf()); |
|
| 366 | + | let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); |
|
| 383 | 367 | let oxy = root.join(OXY_DIR); |
|
| 384 | 368 | if oxy.exists() { |
|
| 385 | 369 | return fail(format!("{} is already an oxy repository", root.display())); |
|
| … | 10 unchanged lines |
||
| 396 | 380 | write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?; |
|
| 397 | 381 | let store = open_objects(&oxy, &config)?; |
|
| 398 | 382 | let repo = Repo { |
|
| 399 | - | acceptor: None, |
|
| 400 | - | keyless_key: Default::default(), |
|
| 401 | 383 | last_change: Default::default(), |
|
| 402 | 384 | at_op: None, |
|
| 403 | - | head_seen: Default::default(), |
|
| 404 | 385 | walked_dirs: Default::default(), |
|
| 405 | 386 | listed_dirs: Default::default(), |
|
| 406 | 387 | git_export_skipped: Default::default(), |
|
| … | 41 unchanged lines |
||
| 448 | 429 | /// [`Repo::init_on`] for a bare repository. |
|
| 449 | 430 | pub fn init_bare_on(root: &Path, id: [u8; 16], storage: Option<&str>) -> Result<Repo> { |
|
| 450 | 431 | fs::create_dir_all(root)?; |
|
| 451 | - | let root = crate::portable::canonical(root).unwrap_or_else(|_| root.to_path_buf()); |
|
| 432 | + | let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); |
|
| 452 | 433 | let oxy = root.join(OXY_DIR); |
|
| 453 | 434 | if oxy.exists() { |
|
| 454 | 435 | return fail(format!("{} is already an oxy repository", root.display())); |
|
| … | 9 unchanged lines |
||
| 464 | 445 | config.save(&oxy.join("config"))?; |
|
| 465 | 446 | let store = open_objects(&oxy, &config)?; |
|
| 466 | 447 | let repo = Repo { |
|
| 467 | - | acceptor: None, |
|
| 468 | - | keyless_key: Default::default(), |
|
| 469 | 448 | last_change: Default::default(), |
|
| 470 | 449 | at_op: None, |
|
| 471 | - | head_seen: Default::default(), |
|
| 472 | 450 | walked_dirs: Default::default(), |
|
| 473 | 451 | listed_dirs: Default::default(), |
|
| 474 | 452 | git_export_skipped: Default::default(), |
|
| … | 54 unchanged lines |
||
| 529 | 507 | ||
| 530 | 508 | /// Open the repository containing `start` (searching upwards). |
|
| 531 | 509 | pub fn open(start: &Path) -> Result<Repo> { |
|
| 532 | - | let start = crate::portable::canonical(start)?; |
|
| 510 | + | let start = start.canonicalize()?; |
|
| 533 | 511 | let mut dir: Option<&Path> = Some(&start); |
|
| 534 | 512 | while let Some(d) = dir { |
|
| 535 | 513 | let oxy = d.join(OXY_DIR); |
|
| … | 10 unchanged lines |
||
| 546 | 524 | let config = Config::load(Some(&default_user_config()), &shared.join("config"))?; |
|
| 547 | 525 | let store = open_objects(&shared, &config)?; |
|
| 548 | 526 | let mut repo = Repo { |
|
| 549 | - | acceptor: None, |
|
| 550 | - | keyless_key: Default::default(), |
|
| 551 | 527 | last_change: Default::default(), |
|
| 552 | 528 | at_op: None, |
|
| 553 | - | head_seen: Default::default(), |
|
| 554 | 529 | walked_dirs: Default::default(), |
|
| 555 | 530 | listed_dirs: Default::default(), |
|
| 556 | 531 | git_export_skipped: Default::default(), |
|
| … | 26 unchanged lines |
||
| 583 | 558 | }; |
|
| 584 | 559 | let store = open_objects(&oxy, &config)?; |
|
| 585 | 560 | let mut repo = Repo { |
|
| 586 | - | acceptor: None, |
|
| 587 | - | keyless_key: Default::default(), |
|
| 588 | 561 | last_change: Default::default(), |
|
| 589 | 562 | at_op: None, |
|
| 590 | - | head_seen: Default::default(), |
|
| 591 | 563 | walked_dirs: Default::default(), |
|
| 592 | 564 | listed_dirs: Default::default(), |
|
| 593 | 565 | git_export_skipped: Default::default(), |
|
| … | 69 unchanged lines |
||
| 663 | 635 | Ok(()) |
|
| 664 | 636 | } |
|
| 665 | 637 | ||
| 666 | - | /// Pack automatically once many loose objects have accumulated, and |
|
| 667 | - | /// delete what packing retired over [`crate::store::RETIRED_FOR`] ago |
|
| 668 | - | /// (a store nobody runs `oxy gc` on would keep both copies forever). |
|
| 638 | + | /// Pack automatically once many loose objects have accumulated. |
|
| 669 | 639 | pub(crate) fn maybe_autopack(&self) -> Result<()> { |
|
| 670 | - | self.store |
|
| 671 | - | .expire_retired(crate::store::RETIRED_FOR, &mut |_| {})?; |
|
| 672 | 640 | if self.store.loose_count()? > AUTOPACK_THRESHOLD { |
|
| 673 | 641 | self.store.pack_loose()?; |
|
| 674 | 642 | self.store.merge_packs()?; |
|
| … | 25 unchanged lines |
||
| 700 | 668 | pub fn root(&self) -> &Path { |
|
| 701 | 669 | &self.root |
|
| 702 | 670 | } |
|
| 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 | - | } |
|
| 708 | 671 | pub fn store(&self) -> &Store { |
|
| 709 | 672 | &self.store |
|
| 710 | 673 | } |
|
| … | 5 unchanged lines |
||
| 716 | 679 | /// (tests, or `$OXY_HOME`). Reloads the effective configuration. |
|
| 717 | 680 | /// Act as if the working copy were on another platform's file system |
|
| 718 | 681 | /// (tests of Windows and macOS behaviour on any host). |
|
| 719 | - | /// Accept certified updates as `identity` (a host's key), recording when |
|
| 720 | - | /// (spec §14.7). |
|
| 721 | - | pub fn with_acceptor(mut self, identity: Identity, sk: crate::identity::SecretKey) -> Repo { |
|
| 722 | - | self.acceptor = Some(std::sync::Arc::new((identity, sk))); |
|
| 723 | - | self |
|
| 724 | - | } |
|
| 725 | - | ||
| 726 | 682 | pub fn with_platform(mut self, platform: crate::portable::Platform) -> Repo { |
|
| 727 | 683 | self.platform = platform; |
|
| 728 | 684 | self |
|
| … | 167 unchanged lines |
||
| 896 | 852 | ) |
|
| 897 | 853 | } |
|
| 898 | 854 | ||
| 899 | - | /// The operation head this handle reads: the one it first read, or |
|
| 900 | - | /// the last one it moved the head to (see [`Repo::refresh`]). |
|
| 901 | 855 | fn head_ref(&self) -> Result<ObjectRef> { |
|
| 902 | - | if let Some(r) = self.head_seen.get() { |
|
| 903 | - | return Ok(r); |
|
| 904 | - | } |
|
| 905 | - | let r = self.read_head()?.0; |
|
| 906 | - | self.head_seen.set(Some(r)); |
|
| 907 | - | Ok(r) |
|
| 908 | - | } |
|
| 909 | - | ||
| 910 | - | /// Read the operation head again: what another process or handle |
|
| 911 | - | /// recorded since this handle first read it becomes visible. A handle |
|
| 912 | - | /// keeps the head it first read for the rest of its life otherwise, |
|
| 913 | - | /// so whatever it reads comes from one view (oxygenhub#249); a loop |
|
| 914 | - | /// that waits for others' operations, or a step that must see what |
|
| 915 | - | /// another handle just recorded, refreshes. Packs written since are |
|
| 916 | - | /// picked up too. |
|
| 917 | - | pub fn refresh(&self) -> Result<()> { |
|
| 918 | - | self.forget_head(); |
|
| 919 | - | self.store.refresh()?; |
|
| 920 | - | Ok(()) |
|
| 921 | - | } |
|
| 922 | - | ||
| 923 | - | /// Read the operation head again on next use, as [`Repo::refresh`] |
|
| 924 | - | /// does, leaving the packs as they are. |
|
| 925 | - | pub(crate) fn forget_head(&self) { |
|
| 926 | - | self.head_seen.set(None); |
|
| 856 | + | Ok(self.read_head()?.0) |
|
| 927 | 857 | } |
|
| 928 | 858 | ||
| 929 | 859 | /// The operation head, and on a store its record's version for |
|
| … | 36 unchanged lines |
||
| 966 | 896 | fn swap_head(&self, to: &ObjectRef, expected: Option<&crate::strata::Version>) -> Result<bool> { |
|
| 967 | 897 | if self.store.is_local() { |
|
| 968 | 898 | write_atomic(&self.oxy.join("ops").join("head"), to.to_hex().as_bytes())?; |
|
| 969 | - | self.head_seen.set(Some(*to)); |
|
| 970 | 899 | return Ok(true); |
|
| 971 | 900 | } |
|
| 972 | 901 | match self |
|
| … | 1 unchanged lines |
||
| 974 | 903 | .backend() |
|
| 975 | 904 | .write_ref(HEAD_RECORD, to.to_hex().as_bytes(), expected) |
|
| 976 | 905 | { |
|
| 977 | - | Ok(_) => { |
|
| 978 | - | self.head_seen.set(Some(*to)); |
|
| 979 | - | Ok(true) |
|
| 980 | - | } |
|
| 906 | + | Ok(_) => Ok(true), |
|
| 981 | 907 | Err(crate::strata::Error::Conflict(_)) => Ok(false), |
|
| 982 | 908 | Err(e) => fail(format!("move the operation head: {e}")), |
|
| 983 | 909 | } |
|
| … | 496 unchanged lines |
||
| 1480 | 1406 | .map_err(|e| self.index_damage(e)) |
|
| 1481 | 1407 | } |
|
| 1482 | 1408 | ||
| 1483 | - | /// The tree of `state` as every reader sees it ([`State::view`]), with |
|
| 1484 | - | /// the graphs built that it compares: same-path additions with |
|
| 1485 | - | /// identical content are one entry, not a name conflict. |
|
| 1486 | - | pub fn tree_view(&self, state: &mut State) -> Result<crate::tree::TreeView> { |
|
| 1487 | - | let need = state.unify_candidates(); |
|
| 1488 | - | if !need.is_empty() { |
|
| 1489 | - | self.load_graphs_of(state, &need)?; |
|
| 1490 | - | } |
|
| 1491 | - | Ok(state.view()) |
|
| 1492 | - | } |
|
| 1493 | - | ||
| 1494 | 1409 | /// An error building graphs, and when it is a state index found |
|
| 1495 | 1410 | /// damaged (a file's history that does not match its hash), the |
|
| 1496 | 1411 | /// workspace's state indexes removed: they are rebuilt from history by |
|
| … | 203 unchanged lines |
||
| 1700 | 1615 | /// through [`Repo::read_blob`], which may fetch it lazily. |
|
| 1701 | 1616 | pub fn read_blobs(&self, refs: &[ObjectRef]) -> Result<Vec<Vec<u8>>> { |
|
| 1702 | 1617 | let mut blobs: Vec<Option<Blob>> = Vec::with_capacity(refs.len()); |
|
| 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) { |
|
| 1618 | + | for (r, got) in refs.iter().zip(self.store.get_many(refs)) { |
|
| 1707 | 1619 | blobs.push(match got { |
|
| 1708 | 1620 | Ok((ObjectType::Blob, body)) => Some(Blob::decode(&body)?), |
|
| 1709 | 1621 | Ok((ty, _)) => return fail(format!("{} is a {ty:?}, not a blob", r.short(12))), |
|
| … | 6 unchanged lines |
||
| 1716 | 1628 | .flatten() |
|
| 1717 | 1629 | .flat_map(|b| b.chunks.iter().copied()) |
|
| 1718 | 1630 | .collect(); |
|
| 1719 | - | let t = crate::timing::phase("blobs: get_many chunks"); |
|
| 1720 | 1631 | 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 | - | } |
|
| 1730 | 1632 | let mut out = Vec::with_capacity(refs.len()); |
|
| 1731 | 1633 | for (r, blob) in refs.iter().zip(blobs) { |
|
| 1732 | 1634 | match blob { |
|
| … | 252 unchanged lines |
||
| 1985 | 1887 | #[allow(clippy::type_complexity)] |
|
| 1986 | 1888 | pub(crate) fn read_state_index_changes( |
|
| 1987 | 1889 | &self, |
|
| 1988 | - | ) -> Option<(Vec<ChangeHash>, Vec<(ChangeHash, crate::state::ChangeMeta)>)> { |
|
| 1890 | + | ) -> Option<( |
|
| 1891 | + | Vec<ChangeHash>, |
|
| 1892 | + | crate::fasthash::FastMap<ChangeHash, std::sync::Arc<crate::state::ChangeMeta>>, |
|
| 1893 | + | )> { |
|
| 1989 | 1894 | use std::io::Read; |
|
| 1990 | 1895 | let _t = crate::timing::phase("load: state index changes"); |
|
| 1991 | 1896 | let mut f = fs::File::open(self.state_indexes().first()?).ok()?; |
|
| … | 20 unchanged lines |
||
| 2012 | 1917 | } |
|
| 2013 | 1918 | drop(checking); |
|
| 2014 | 1919 | let _decoding = crate::timing::phase("load: of which decoding"); |
|
| 2015 | - | Some((heads, State::read_index_changes_listed(&changes[4..]).ok()?)) |
|
| 1920 | + | Some((heads, State::read_index_changes(&changes[4..]).ok()?)) |
|
| 2016 | 1921 | } |
|
| 2017 | 1922 | ||
| 2018 | 1923 | /// Save `st`, the history-only state of `deps`, as this workspace's |
|
| … | 208 unchanged lines |
||
| 2227 | 2132 | // already tracked as subrepo nodes, they contribute their pin; |
|
| 2228 | 2133 | // unregistered ones are left alone. |
|
| 2229 | 2134 | let modules = self.modules_on_disk(); |
|
| 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 { |
|
| 2135 | + | let removed = self.removed_subrepos(); |
|
| 2136 | + | for rel in &removed { |
|
| 2137 | + | if tracked.get(rel).map(|(k, _)| *k) != Some(NodeKind::Subrepo) { |
|
| 2256 | 2138 | self.clear_removed_subrepo(rel); |
|
| 2257 | 2139 | } |
|
| 2258 | - | !spent |
|
| 2259 | - | }); |
|
| 2140 | + | } |
|
| 2260 | 2141 | let mut untracked_nested = Vec::new(); |
|
| 2261 | 2142 | for dir in nested { |
|
| 2262 | 2143 | let rel = self.rel_path(&dir); |
|
| … | 7 unchanged lines |
||
| 2270 | 2151 | continue; |
|
| 2271 | 2152 | } |
|
| 2272 | 2153 | let pin = subrepos::read_pin(&dir, registered.and_then(|m| m.bookmark.as_deref()))?; |
|
| 2273 | - | let entry = subrepo_entry(&rel, pin); |
|
| 2274 | - | out.insert(rel, entry); |
|
| 2154 | + | out.insert(rel, FsEntry::new(NodeKind::Subrepo, false, pin.encode())); |
|
| 2275 | 2155 | } |
|
| 2276 | 2156 | untracked_nested.sort(); |
|
| 2277 | 2157 | *self.untracked_nested.borrow_mut() = untracked_nested; |
|
| … | 14 unchanged lines |
||
| 2292 | 2172 | &path, |
|
| 2293 | 2173 | modules.entries.get(rel).and_then(|m| m.bookmark.as_deref()), |
|
| 2294 | 2174 | )?; |
|
| 2295 | - | out.insert(rel.clone(), subrepo_entry(rel, pin)); |
|
| 2175 | + | out.insert( |
|
| 2176 | + | rel.clone(), |
|
| 2177 | + | FsEntry::new(NodeKind::Subrepo, false, pin.encode()), |
|
| 2178 | + | ); |
|
| 2296 | 2179 | } else if let Some(b) = blob { |
|
| 2297 | 2180 | // Not cloned (or emptied): the pin stays as recorded, as a |
|
| 2298 | 2181 | // Git gitlink does when its directory is absent. |
|
| … | 102 unchanged lines |
||
| 2401 | 2284 | let _t = crate::timing::phase("log: metadata"); |
|
| 2402 | 2285 | let mut metas = self.metas_of(&below)?; |
|
| 2403 | 2286 | if !metas.iter().any(|(h, _)| *h == wc_hash) { |
|
| 2404 | - | // First, where the log puts it, so the order may hold. |
|
| 2405 | - | metas.insert(0, (wc_hash, crate::state::ChangeMeta::of(&wc))); |
|
| 2287 | + | metas.push((wc_hash, crate::state::ChangeMeta::of(&wc))); |
|
| 2406 | 2288 | } |
|
| 2407 | 2289 | metas |
|
| 2408 | 2290 | }; |
|
| 2409 | - | entries.extend(metas.into_iter().map(|(h, meta)| LogEntry { |
|
| 2410 | - | hash: h, |
|
| 2411 | - | change: meta, |
|
| 2412 | - | is_working_copy: h == wc_hash, |
|
| 2413 | - | is_head: head_set.contains(&h), |
|
| 2414 | - | })); |
|
| 2291 | + | for (h, meta) in metas { |
|
| 2292 | + | entries.push(LogEntry { |
|
| 2293 | + | hash: h, |
|
| 2294 | + | change: meta, |
|
| 2295 | + | is_working_copy: h == wc_hash, |
|
| 2296 | + | is_head: head_set.contains(&h), |
|
| 2297 | + | }); |
|
| 2298 | + | } |
|
| 2415 | 2299 | let _sorting = crate::timing::phase("log: sort"); |
|
| 2416 | 2300 | // The hash makes the order total, so an unstable sort gives the same. |
|
| 2417 | - | // From the state index the order already holds (oxygen#94). |
|
| 2418 | - | let order = |a: &LogEntry, b: &LogEntry| { |
|
| 2301 | + | entries.sort_unstable_by(|a, b| { |
|
| 2419 | 2302 | b.is_working_copy |
|
| 2420 | 2303 | .cmp(&a.is_working_copy) |
|
| 2421 | - | .then(crate::state::log_order( |
|
| 2422 | - | (&a.hash, &a.change.hlc), |
|
| 2423 | - | (&b.hash, &b.change.hlc), |
|
| 2424 | - | )) |
|
| 2425 | - | }; |
|
| 2426 | - | if !entries.is_sorted_by(|a, b| order(a, b).is_le()) { |
|
| 2427 | - | entries.sort_unstable_by(order); |
|
| 2428 | - | } |
|
| 2304 | + | .then(b.change.hlc.cmp(&a.change.hlc)) |
|
| 2305 | + | .then(b.hash.cmp(&a.hash)) |
|
| 2306 | + | }); |
|
| 2429 | 2307 | Ok(entries) |
|
| 2430 | 2308 | } |
|
| 2431 | 2309 | ||
| … | 31 unchanged lines |
||
| 2463 | 2341 | is_working_copy: false, |
|
| 2464 | 2342 | }) |
|
| 2465 | 2343 | .collect(); |
|
| 2466 | - | let order = |a: &LogEntry, b: &LogEntry| { |
|
| 2467 | - | crate::state::log_order((&a.hash, &a.change.hlc), (&b.hash, &b.change.hlc)) |
|
| 2468 | - | }; |
|
| 2469 | - | if !entries.is_sorted_by(|a, b| order(a, b).is_le()) { |
|
| 2470 | - | entries.sort_unstable_by(order); |
|
| 2471 | - | } |
|
| 2344 | + | entries.sort_unstable_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash))); |
|
| 2472 | 2345 | Ok(entries) |
|
| 2473 | 2346 | } |
|
| 2474 | 2347 | ||
| … | 14 unchanged lines |
||
| 2489 | 2362 | // (it is closed), and no walk is needed to find it. |
|
| 2490 | 2363 | if let Some((index_heads, all)) = &read { |
|
| 2491 | 2364 | let asked: crate::fasthash::FastSet<&ChangeHash> = heads.iter().collect(); |
|
| 2492 | - | let in_index = || { |
|
| 2493 | - | let index_heads: crate::fasthash::FastSet<&ChangeHash> = |
|
| 2494 | - | index_heads.iter().collect(); |
|
| 2495 | - | // Usually the heads asked are the index's: no set of all. |
|
| 2496 | - | heads.iter().all(|h| index_heads.contains(h)) || { |
|
| 2497 | - | let all: crate::fasthash::FastSet<&ChangeHash> = |
|
| 2498 | - | all.iter().map(|(h, _)| h).collect(); |
|
| 2499 | - | heads.iter().all(|h| all.contains(h)) |
|
| 2500 | - | } |
|
| 2501 | - | }; |
|
| 2502 | - | if index_heads.iter().all(|h| asked.contains(h)) && in_index() { |
|
| 2365 | + | if heads.iter().all(|h| all.contains_key(h)) |
|
| 2366 | + | && index_heads.iter().all(|h| asked.contains(h)) |
|
| 2367 | + | { |
|
| 2503 | 2368 | let (_, all) = read.expect("matched above"); |
|
| 2504 | 2369 | #[cfg(test)] |
|
| 2505 | 2370 | { |
|
| … | 5 unchanged lines |
||
| 2511 | 2376 | stack.extend(self.store.get_change_meta(&h)?.deps); |
|
| 2512 | 2377 | } |
|
| 2513 | 2378 | } |
|
| 2514 | - | assert_eq!(seen, all.iter().map(|(h, _)| *h).collect::<HashSet<_>>()); |
|
| 2379 | + | assert_eq!(seen, all.keys().copied().collect::<HashSet<_>>()); |
|
| 2515 | 2380 | } |
|
| 2516 | - | return Ok(all); |
|
| 2381 | + | return Ok(all |
|
| 2382 | + | .into_iter() |
|
| 2383 | + | .map(|(h, m)| (h, std::sync::Arc::unwrap_or_clone(m))) |
|
| 2384 | + | .collect()); |
|
| 2517 | 2385 | } |
|
| 2518 | 2386 | } |
|
| 2519 | - | let mut indexed: Option<crate::fasthash::FastMap<ChangeHash, crate::state::ChangeMeta>> = |
|
| 2520 | - | read.map(|(_, all)| all.into_iter().collect()); |
|
| 2387 | + | let mut indexed = read.map(|(_, all)| all); |
|
| 2521 | 2388 | let _walk = crate::timing::phase("metadata: walk"); |
|
| 2522 | 2389 | let (mut stored, mut from_store) = (0usize, std::time::Duration::ZERO); |
|
| 2523 | 2390 | let mut seen: crate::fasthash::FastSet<ChangeHash> = Default::default(); |
|
| … | 7 unchanged lines |
||
| 2531 | 2398 | if let Some(meta) = indexed.as_mut().and_then(|m| m.remove(&h)) { |
|
| 2532 | 2399 | #[cfg(test)] |
|
| 2533 | 2400 | assert_eq!( |
|
| 2534 | - | meta, |
|
| 2401 | + | *meta, |
|
| 2535 | 2402 | self.store.get_change_meta(&h)?, |
|
| 2536 | 2403 | "the state index holds a change's metadata" |
|
| 2537 | 2404 | ); |
|
| 2405 | + | let meta = std::sync::Arc::unwrap_or_clone(meta); |
|
| 2538 | 2406 | stack.extend(meta.deps.iter().copied()); |
|
| 2539 | 2407 | out.push((h, meta)); |
|
| 2540 | 2408 | continue; |
|
| … | 33 unchanged lines |
||
| 2574 | 2442 | )); |
|
| 2575 | 2443 | } |
|
| 2576 | 2444 | let mut state = self.load_state_history(heads)?; |
|
| 2577 | - | let Some(node) = self |
|
| 2578 | - | .tree_view(&mut state)? |
|
| 2579 | - | .entries |
|
| 2580 | - | .get(path) |
|
| 2581 | - | .map(|e| e.node) |
|
| 2582 | - | else { |
|
| 2445 | + | let Some(node) = state.tree().view().entries.get(path).map(|e| e.node) else { |
|
| 2583 | 2446 | return fail(format!("{path} is not in that state")); |
|
| 2584 | 2447 | }; |
|
| 2585 | 2448 | self.load_graphs_of(&mut state, &[node])?; |
|
| … | 72 unchanged lines |
||
| 2658 | 2521 | return fail("empty change specifier"); |
|
| 2659 | 2522 | } |
|
| 2660 | 2523 | let entries = self.log()?; |
|
| 2661 | - | // A full hash as `--json` prints it, without its `#`: 64 hex digits |
|
| 2662 | - | // cannot be a ChangeId, which has 32 (oxygen#103). |
|
| 2663 | - | let full_hash = |
|
| 2664 | - | (spec.len() == 64 && spec.bytes().all(|b| b.is_ascii_hexdigit())).then_some(spec); |
|
| 2665 | - | let matches: Vec<&LogEntry> = if let Some(hp) = spec.strip_prefix('#').or(full_hash) { |
|
| 2524 | + | let matches: Vec<&LogEntry> = if let Some(hp) = spec.strip_prefix('#') { |
|
| 2666 | 2525 | entries |
|
| 2667 | 2526 | .iter() |
|
| 2668 | 2527 | .filter(|e| e.hash.starts_with_hex(hp)) |
|
| … | 97 unchanged lines |
||
| 2766 | 2625 | let nodes: Vec<NodeId> = edited.into_iter().collect(); |
|
| 2767 | 2626 | self.load_graphs_of(&mut base, &nodes)?; |
|
| 2768 | 2627 | let snapshot = Snapshot { |
|
| 2769 | - | tree: self.tree_view(&mut base)?, |
|
| 2628 | + | tree: base.tree().view(), |
|
| 2770 | 2629 | files: BTreeMap::new(), |
|
| 2771 | 2630 | unrendered: BTreeSet::new(), |
|
| 2772 | 2631 | }; |
|
| … | 12 unchanged lines |
||
| 2785 | 2644 | change.edits.iter().map(|e| e.node()).collect(); |
|
| 2786 | 2645 | let nodes: Vec<NodeId> = edited.iter().copied().collect(); |
|
| 2787 | 2646 | self.load_graphs_of(&mut base, &nodes)?; |
|
| 2788 | - | self.tree_view(&mut base)?; |
|
| 2789 | 2647 | let snapshot = { |
|
| 2790 | 2648 | let _t = crate::timing::phase("diff: of which materialize"); |
|
| 2791 | 2649 | base.materialize_nodes(&edited) |
|
| … | 157 unchanged lines |
modified crates/oxy-core/src/repo/policy.rs +2 −57
| … | 335 unchanged lines |
||
| 336 | 336 | threshold: 1, |
|
| 337 | 337 | require: Require::Any, |
|
| 338 | 338 | allow_conflicts: true, |
|
| 339 | - | subjects: vec![], |
|
| 340 | 339 | }), |
|
| 341 | 340 | } |
|
| 342 | 341 | if p.identity(&key).is_none() { |
|
| … | 2 unchanged lines |
||
| 345 | 344 | self.sign_policy(p, &format!("oxy policy grant {}", key.short(12))) |
|
| 346 | 345 | } |
|
| 347 | 346 | ||
| 348 | - | /// Let holders of certificates `issuer` gives, for subjects matching |
|
| 349 | - | /// `subject`, sign within `scope` on `pattern` (default every |
|
| 350 | - | /// bookmark; §14.7). The issuer's identity is embedded so verifiers need |
|
| 351 | - | /// no fetch. |
|
| 352 | - | pub fn policy_grant_subject( |
|
| 353 | - | &self, |
|
| 354 | - | subject: &str, |
|
| 355 | - | issuer: Identity, |
|
| 356 | - | scope: crate::identity::Scope, |
|
| 357 | - | pattern: Option<&str>, |
|
| 358 | - | ) -> Result<PolicyUpdate> { |
|
| 359 | - | if subject.trim().is_empty() { |
|
| 360 | - | return fail("a subject pattern is needed, such as repo:OWNER/NAME:bookmark:main"); |
|
| 361 | - | } |
|
| 362 | - | let mut p = self.next_policy()?; |
|
| 363 | - | let key = issuer.hash(); |
|
| 364 | - | let pattern = pattern.unwrap_or("*"); |
|
| 365 | - | let grant = crate::policy::SubjectGrant { |
|
| 366 | - | pattern: subject.into(), |
|
| 367 | - | issuer: key, |
|
| 368 | - | scope, |
|
| 369 | - | }; |
|
| 370 | - | match p.rules.iter_mut().find(|r| r.pattern == pattern) { |
|
| 371 | - | Some(rule) => { |
|
| 372 | - | rule.subjects |
|
| 373 | - | .retain(|g| !(g.pattern == grant.pattern && g.issuer == grant.issuer)); |
|
| 374 | - | rule.subjects.push(grant); |
|
| 375 | - | } |
|
| 376 | - | None => p.rules.push(Rule { |
|
| 377 | - | pattern: pattern.into(), |
|
| 378 | - | keys: vec![], |
|
| 379 | - | threshold: 1, |
|
| 380 | - | require: Require::Any, |
|
| 381 | - | allow_conflicts: true, |
|
| 382 | - | subjects: vec![grant], |
|
| 383 | - | }), |
|
| 384 | - | } |
|
| 385 | - | if p.identity(&key).is_none() { |
|
| 386 | - | p.identities.push(issuer); |
|
| 387 | - | } |
|
| 388 | - | self.sign_policy(p, &format!("oxy policy grant-subject {subject}")) |
|
| 389 | - | } |
|
| 390 | - | ||
| 391 | 347 | /// Revoke `key`: it may no longer update anything, and existing rules |
|
| 392 | 348 | /// drop it. A root key can be revoked by another root as long as the |
|
| 393 | 349 | /// remaining roots still meet the threshold. |
|
| … | 16 unchanged lines |
||
| 410 | 366 | for r in &mut p.rules { |
|
| 411 | 367 | r.keys.retain(|k| k != key); |
|
| 412 | 368 | } |
|
| 413 | - | // A rule of certified subjects alone stays. |
|
| 414 | - | p.rules |
|
| 415 | - | .retain(|r| !r.keys.is_empty() || !r.subjects.is_empty()); |
|
| 369 | + | p.rules.retain(|r| !r.keys.is_empty()); |
|
| 416 | 370 | if !p.revoked.contains(key) { |
|
| 417 | 371 | p.revoked.push(*key); |
|
| 418 | 372 | p.revoked.sort(); |
|
| … | 30 unchanged lines |
||
| 449 | 403 | .map(|c| c.author) |
|
| 450 | 404 | .map_err(|e| e.to_string())?; |
|
| 451 | 405 | let principal = if author.is_zero() { key } else { author }; |
|
| 452 | - | // A change is checked when it is received: now is a time |
|
| 453 | - | // its signer did not choose. |
|
| 454 | 406 | let covered = rule |
|
| 455 | 407 | .keys |
|
| 456 | 408 | .iter() |
|
| 457 | - | .any(|k| policy.succeeds(k, &key) || policy.succeeds(k, &principal)) |
|
| 458 | - | || self.certified_authorized( |
|
| 459 | - | policy, |
|
| 460 | - | &key, |
|
| 461 | - | reference, |
|
| 462 | - | crate::identity::Scope::CHANGES, |
|
| 463 | - | crate::objects::Hlc::now().wall_ms, |
|
| 464 | - | ); |
|
| 409 | + | .any(|k| policy.succeeds(k, &key) || policy.succeeds(k, &principal)); |
|
| 465 | 410 | if !covered { |
|
| 466 | 411 | return Err(format!( |
|
| 467 | 412 | "change {} is signed by {}, which may not update {reference:?}", |
|
| … | 48 unchanged lines |
modified crates/oxy-core/src/repo/receipts.rs +1 −63
| … | 26 unchanged lines |
||
| 27 | 27 | return fail("that receipt is for another repository"); |
|
| 28 | 28 | } |
|
| 29 | 29 | self.store.put_identity(&sr.runner)?; |
|
| 30 | - | if let Some(c) = &sr.certificate { |
|
| 31 | - | self.store.put_identity(&c.issuer)?; |
|
| 32 | - | self.store.put_certificate(&c.cert)?; |
|
| 33 | - | } |
|
| 34 | 30 | self.store.put_receipt(&sr.receipt)?; |
|
| 35 | 31 | let r = self.store.put_attest(&sr.attest)?; |
|
| 36 | 32 | for sig in &sr.sigs { |
|
| … | 15 unchanged lines |
||
| 52 | 48 | return fail(format!("receipt #{} has no signature", r.short(12))); |
|
| 53 | 49 | }; |
|
| 54 | 50 | let runner = self.store.get_identity(&key)?; |
|
| 55 | - | // Signed by a short-lived key: its certificate and issuer too. |
|
| 56 | - | let certificate = match self.store.certificate_of(&key)? { |
|
| 57 | - | Some(c) => { |
|
| 58 | - | let cert = self.store.get_certificate(&c)?; |
|
| 59 | - | let issuer = self.store.get_identity(&cert.cert.issuer)?; |
|
| 60 | - | Some(crate::receipt::CertifiedBy { cert, issuer }) |
|
| 61 | - | } |
|
| 62 | - | None => None, |
|
| 63 | - | }; |
|
| 64 | 51 | let signed = SignedReceipt { |
|
| 65 | 52 | receipt, |
|
| 66 | 53 | attest, |
|
| 67 | 54 | sigs, |
|
| 68 | 55 | runner, |
|
| 69 | - | certificate, |
|
| 70 | 56 | }; |
|
| 71 | 57 | let problem = signed.verify().err().map(|e| e.to_string()); |
|
| 72 | 58 | Ok(ReceiptInfo { |
|
| … | 22 unchanged lines |
||
| 95 | 81 | ||
| 96 | 82 | /// Receipts to keep through garbage collection: those whose state's |
|
| 97 | 83 | /// heads `keep` holds, with their receipts, runners and outputs. |
|
| 98 | - | /// Keep a signed approval (oxygenhub#235): verified, for this repository, |
|
| 99 | - | /// with the signer's identity and indexed by the state it approves, so |
|
| 100 | - | /// garbage collection keeps it while that state lives. Who may approve is |
|
| 101 | - | /// the caller's rule. |
|
| 102 | - | pub fn add_approval(&self, sa: &crate::approval::SignedApproval) -> Result<ObjectRef> { |
|
| 103 | - | sa.verify() |
|
| 104 | - | .map_err(|e| RepoError::Msg(format!("approval does not verify: {e}")))?; |
|
| 105 | - | if sa.attest.repo != self.id()? { |
|
| 106 | - | return fail("that approval is for another repository"); |
|
| 107 | - | } |
|
| 108 | - | let state = sa |
|
| 109 | - | .statement() |
|
| 110 | - | .map_err(|e| RepoError::Msg(e.to_string()))? |
|
| 111 | - | .state; |
|
| 112 | - | self.store.put_identity(&sa.signer)?; |
|
| 113 | - | let r = self.store.put_attest(&sa.attest)?; |
|
| 114 | - | for sig in &sa.sigs { |
|
| 115 | - | self.store.put_signature(&r, sig)?; |
|
| 116 | - | } |
|
| 117 | - | self.store.index_approval(&state, &r)?; |
|
| 118 | - | Ok(r) |
|
| 119 | - | } |
|
| 120 | - | ||
| 121 | - | /// Approvals of states garbage collection keeps are kept, with their |
|
| 122 | - | /// signatures and signers. |
|
| 123 | - | pub(crate) fn reachable_approvals(&self, keep: &mut HashSet<ObjectRef>) -> Result<()> { |
|
| 124 | - | for state in self.store.approval_states()? { |
|
| 125 | - | if !keep.contains(&state) { |
|
| 126 | - | let heads = match self.store.get_state(&state) { |
|
| 127 | - | Ok(s) => s.heads, |
|
| 128 | - | Err(StoreError::NotFound(_)) => vec![], |
|
| 129 | - | Err(e) => return Err(e.into()), |
|
| 130 | - | }; |
|
| 131 | - | if !heads.iter().all(|h| keep.contains(h)) { |
|
| 132 | - | continue; |
|
| 133 | - | } |
|
| 134 | - | keep.insert(state); |
|
| 135 | - | } |
|
| 136 | - | for a in self.store.approvals_for(&state)? { |
|
| 137 | - | keep.insert(a); |
|
| 138 | - | for sig in self.store.signatures(&a)? { |
|
| 139 | - | keep.insert(sig.key); |
|
| 140 | - | } |
|
| 141 | - | } |
|
| 142 | - | } |
|
| 143 | - | Ok(()) |
|
| 144 | - | } |
|
| 145 | - | ||
| 146 | 84 | pub(crate) fn reachable_receipts(&self, keep: &mut HashSet<ObjectRef>) -> Result<()> { |
|
| 147 | 85 | for state in self.store.receipt_states()? { |
|
| 148 | 86 | let heads = match self.store.get_state(&state) { |
|
| … | 10 unchanged lines |
||
| 159 | 97 | for a in self.store.receipts_for(&state)? { |
|
| 160 | 98 | keep.insert(a); |
|
| 161 | 99 | for sig in self.store.signatures(&a)? { |
|
| 162 | - | self.keep_signer(keep, sig.key)?; |
|
| 100 | + | keep.insert(sig.key); |
|
| 163 | 101 | } |
|
| 164 | 102 | let Ok(attest) = self.store.get_attest(&a) else { |
|
| 165 | 103 | continue; |
|
| … | 20 unchanged lines |
modified crates/oxy-core/src/repo/revset.rs +6 −12
| … | 267 unchanged lines |
||
| 268 | 268 | .build() |
|
| 269 | 269 | .map_err(|e| err(format!("touches({glob:?}): {e}")))? |
|
| 270 | 270 | .compile_matcher(); |
|
| 271 | - | // The tree alone names the nodes: no file graph is built (but for |
|
| 272 | - | // same-path additions it compares). The working copy's base comes |
|
| 273 | - | // from the workspace's state index. |
|
| 274 | - | let mut state = match self.working_copy() { |
|
| 271 | + | // The tree alone names the nodes: no file graph is built. The |
|
| 272 | + | // working copy's base comes from the workspace's state index. |
|
| 273 | + | let state = match self.working_copy() { |
|
| 275 | 274 | Some(wc) => { |
|
| 276 | 275 | let deps = self |
|
| 277 | 276 | .graph |
|
| … | 5 unchanged lines |
||
| 283 | 282 | } |
|
| 284 | 283 | None => self.repo.load_state_history(&self.repo.bookmark_heads()?)?, |
|
| 285 | 284 | }; |
|
| 286 | - | let view = self.repo.tree_view(&mut state)?; |
|
| 287 | - | let mut nodes: HashSet<NodeId> = view |
|
| 285 | + | let nodes: HashSet<NodeId> = state |
|
| 286 | + | .tree() |
|
| 287 | + | .view() |
|
| 288 | 288 | .entries |
|
| 289 | 289 | .iter() |
|
| 290 | 290 | .filter(|(path, _)| matcher.is_match(path)) |
|
| 291 | 291 | .map(|(_, e)| e.node) |
|
| 292 | 292 | .collect(); |
|
| 293 | - | // A file shown as one of identical additions is all of them. |
|
| 294 | - | for (dup, shown) in &view.unified { |
|
| 295 | - | if nodes.contains(shown) { |
|
| 296 | - | nodes.insert(*dup); |
|
| 297 | - | } |
|
| 298 | - | } |
|
| 299 | 293 | if nodes.is_empty() { |
|
| 300 | 294 | return Ok(Set::new()); |
|
| 301 | 295 | } |
|
| … | 530 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +28 −550
| … | 25 unchanged lines |
||
| 26 | 26 | }) |
|
| 27 | 27 | } |
|
| 28 | 28 | ||
| 29 | - | /// Whether `content` still holds a conflict marker oxy wrote for this file |
|
| 30 | - | /// (oxygen#126): a marker-like line of the materialized conflicted file |
|
| 31 | - | /// that is not one of its unconflicted lines (`plain`). Text that merely |
|
| 32 | - | /// looks like a marker, as oxy's own documentation or a test fixture has, |
|
| 33 | - | /// is in the unconflicted lines, so it is not taken for one. |
|
| 34 | - | pub fn has_own_conflict_markers(content: &[u8], materialized: &[u8], plain: &[&[u8]]) -> bool { |
|
| 35 | - | let line = |l: &[u8]| -> Vec<u8> { |
|
| 36 | - | let l = l.strip_suffix(b"\n").unwrap_or(l); |
|
| 37 | - | l.strip_suffix(b"\r").unwrap_or(l).to_vec() |
|
| 38 | - | }; |
|
| 39 | - | let marker = |l: &[u8]| { |
|
| 40 | - | l.starts_with(b"<<<<<<< oxy conflict") |
|
| 41 | - | || l.starts_with(b"======= oxy conflict") |
|
| 42 | - | || l.starts_with(b">>>>>>> oxy conflict") |
|
| 43 | - | }; |
|
| 44 | - | let unconflicted: HashSet<Vec<u8>> = plain.iter().map(|l| line(l)).collect(); |
|
| 45 | - | let own: HashSet<Vec<u8>> = split_lines(materialized) |
|
| 46 | - | .into_iter() |
|
| 47 | - | .map(line) |
|
| 48 | - | .filter(|l| marker(l) && !unconflicted.contains(l)) |
|
| 49 | - | .collect(); |
|
| 50 | - | !own.is_empty() |
|
| 51 | - | && split_lines(content) |
|
| 52 | - | .into_iter() |
|
| 53 | - | .any(|l| own.contains(&line(l))) |
|
| 54 | - | } |
|
| 55 | - | ||
| 56 | 29 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 57 | 30 | pub struct PathStatus { |
|
| 58 | 31 | pub path: String, |
|
| … | 44 unchanged lines |
||
| 103 | 76 | blobs: HashMap<String, ObjectRef>, |
|
| 104 | 77 | dirs: Option<super::WalkedDirs>, |
|
| 105 | 78 | written: u128, |
|
| 106 | - | } |
|
| 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 | 79 | } |
|
| 113 | 80 | ||
| 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 | - | ||
| 244 | 81 | pub fn is_binary(content: &[u8]) -> bool { |
|
| 245 | 82 | content[..content.len().min(BINARY_SNIFF)].contains(&0) |
|
| 246 | 83 | } |
|
| … | 266 unchanged lines |
||
| 513 | 350 | if seed.is_some() { |
|
| 514 | 351 | b.retire_stray_placements(); |
|
| 515 | 352 | } |
|
| 516 | - | b.retire_unified(); |
|
| 517 | 353 | laps.lap("build: stray placements"); |
|
| 518 | 354 | let mut statuses = b.statuses; |
|
| 519 | 355 | statuses.sort_by(|a, b| a.path.cmp(&b.path)); |
|
| … | 23 unchanged lines |
||
| 543 | 379 | self.sign_change(&hash)?; |
|
| 544 | 380 | } |
|
| 545 | 381 | Ok(hash) |
|
| 546 | - | } |
|
| 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 | 382 | } |
|
| 688 | 383 | ||
| 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 | - | ||
| 741 | 384 | /// Snapshot the working copy into the working-copy change (spec §12.3). |
|
| 742 | 385 | /// The result carries the full base state and its whole rendering. |
|
| 743 | 386 | pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> { |
|
| … | 49 unchanged lines |
||
| 793 | 436 | }; |
|
| 794 | 437 | let view = { |
|
| 795 | 438 | let _t = crate::timing::phase("snapshot: tree view"); |
|
| 796 | - | self.tree_view(&mut base)? |
|
| 439 | + | base.tree().view() |
|
| 797 | 440 | }; |
|
| 798 | 441 | let manifest = { |
|
| 799 | 442 | let _t = crate::timing::phase("snapshot: read manifest"); |
|
| … | 66 unchanged lines |
||
| 866 | 509 | // paths, so an unchanged working copy hashes identically (spec §12.3). |
|
| 867 | 510 | let reuse = added_node_paths(&old_wc, &base_snapshot); |
|
| 868 | 511 | 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)?; |
|
| 873 | 512 | let built = { |
|
| 874 | 513 | let _t = crate::timing::phase("snapshot: diff against base"); |
|
| 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() { |
|
| 514 | + | if partial && self.config.sparse_paths.is_empty() { |
|
| 884 | 515 | #[cfg(test)] |
|
| 885 | 516 | let full = |
|
| 886 | 517 | self.build_from_entries(&base, &base_snapshot, reuse.clone(), &fs_entries)?; |
|
| … | 314 unchanged lines |
||
| 1201 | 832 | attrs: &crate::attributes::Attributes, |
|
| 1202 | 833 | ) -> HashMap<String, [u8; 32]> { |
|
| 1203 | 834 | let _t = crate::timing::phase("commit: graph keys"); |
|
| 1204 | - | let view = st.view(); |
|
| 835 | + | let view = st.tree().view(); |
|
| 1205 | 836 | paths |
|
| 1206 | 837 | .iter() |
|
| 1207 | 838 | .filter_map(|p| { |
|
| … | 102 unchanged lines |
||
| 1310 | 941 | (o, e, partial) |
|
| 1311 | 942 | } |
|
| 1312 | 943 | Selection::Hunks(indices) => { |
|
| 1313 | - | let (o, e) = select_hunks(&snap.wc, indices, &snap.base_snapshot.tree.unified); |
|
| 944 | + | let (o, e) = select_hunks(&snap.wc, indices); |
|
| 1314 | 945 | let partial = o.len() != snap.wc.tree_ops.len() || e.len() != snap.wc.edits.len(); |
|
| 1315 | 946 | (o, e, partial) |
|
| 1316 | 947 | } |
|
| … | 1 unchanged lines |
||
| 1318 | 949 | if tree_ops.is_empty() && edits.is_empty() { |
|
| 1319 | 950 | return fail("nothing selected to commit"); |
|
| 1320 | 951 | } |
|
| 1321 | - | // A resolution may leave other work uncommitted (oxygen#118), but |
|
| 1322 | - | // takes every file it resolves, whole. |
|
| 1323 | 952 | if partial && snap.resolves { |
|
| 1324 | - | let resolved = snap |
|
| 1325 | - | .statuses |
|
| 1326 | - | .iter() |
|
| 1327 | - | .filter(|s| s.status == ChangeStatus::Resolved) |
|
| 1328 | - | .map(|s| s.path.as_str()); |
|
| 1329 | - | let missing: Vec<&str> = match selection { |
|
| 1330 | - | Selection::Paths(paths) => resolved |
|
| 1331 | - | .filter(|p| { |
|
| 1332 | - | !paths.iter().any(|x| { |
|
| 1333 | - | p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))) |
|
| 1334 | - | }) |
|
| 1335 | - | }) |
|
| 1336 | - | .collect(), |
|
| 1337 | - | _ => resolved.collect(), |
|
| 1338 | - | }; |
|
| 1339 | - | if !missing.is_empty() { |
|
| 1340 | - | return fail(format!( |
|
| 1341 | - | "a conflict resolution takes every file it resolves, whole: commit by paths, including {}", |
|
| 1342 | - | missing.join(", ") |
|
| 1343 | - | )); |
|
| 1344 | - | } |
|
| 953 | + | return fail("a conflict resolution must be committed whole"); |
|
| 1345 | 954 | } |
|
| 1346 | 955 | // pre-commit hook: may veto. |
|
| 1347 | 956 | self.run_hook( |
|
| … | 232 unchanged lines |
||
| 1580 | 1189 | { |
|
| 1581 | 1190 | if path_of_node(&n, &by_node, &new_names).is_some_and(|p| selected(&p)) { |
|
| 1582 | 1191 | nodes.insert(n); |
|
| 1583 | - | } |
|
| 1584 | - | } |
|
| 1585 | - | // The nodes shown as one with a selected node are retired with it. |
|
| 1586 | - | for (dup, shown) in &base_snapshot.tree.unified { |
|
| 1587 | - | if nodes.contains(shown) { |
|
| 1588 | - | nodes.insert(*dup); |
|
| 1589 | 1192 | } |
|
| 1590 | 1193 | } |
|
| 1591 | 1194 | // New parents of selected new nodes come along. |
|
| … | 24 unchanged lines |
||
| 1616 | 1219 | /// The hunks `indices` name plus whatever they refer to within the working |
|
| 1617 | 1220 | /// copy (the parent directory of an added node, the insert that introduced |
|
| 1618 | 1221 | /// an anchor line), renumbered so the result is a well-formed change. |
|
| 1619 | - | /// The nodes `unified` shows as one with another (a base's |
|
| 1620 | - | /// [`TreeView::unified`](crate::tree::TreeView::unified)) are retired with |
|
| 1621 | - | /// any hunk of the node shown. |
|
| 1622 | - | fn select_hunks( |
|
| 1623 | - | wc: &Change, |
|
| 1624 | - | indices: &[usize], |
|
| 1625 | - | unified: &BTreeMap<NodeId, NodeId>, |
|
| 1626 | - | ) -> (Vec<TreeOp>, Vec<Edit>) { |
|
| 1222 | + | fn select_hunks(wc: &Change, indices: &[usize]) -> (Vec<TreeOp>, Vec<Edit>) { |
|
| 1627 | 1223 | let n_ops = wc.tree_ops.len(); |
|
| 1628 | 1224 | let mut ops_sel = vec![false; n_ops]; |
|
| 1629 | 1225 | let mut edits_sel = vec![false; wc.edits.len()]; |
|
| … | 93 unchanged lines |
||
| 1723 | 1319 | } |
|
| 1724 | 1320 | } |
|
| 1725 | 1321 | } |
|
| 1726 | - | // The retirement of a node shown as one with a selected node. |
|
| 1727 | - | if !unified.is_empty() { |
|
| 1728 | - | let chosen: HashSet<NodeId> = (0..n_ops) |
|
| 1729 | - | .filter(|i| ops_sel[*i]) |
|
| 1730 | - | .map(|i| wc.tree_ops[i].node()) |
|
| 1731 | - | .chain( |
|
| 1732 | - | (0..wc.edits.len()) |
|
| 1733 | - | .filter(|j| edits_sel[*j]) |
|
| 1734 | - | .map(|j| wc.edits[j].node()), |
|
| 1735 | - | ) |
|
| 1736 | - | .collect(); |
|
| 1737 | - | for (i, o) in wc.tree_ops.iter().enumerate() { |
|
| 1738 | - | if let TreeOp::RemoveNode { node, .. } = o { |
|
| 1739 | - | if unified.get(node).is_some_and(|s| chosen.contains(s)) { |
|
| 1740 | - | ops_sel[i] = true; |
|
| 1741 | - | } |
|
| 1742 | - | } |
|
| 1743 | - | } |
|
| 1744 | - | } |
|
| 1745 | 1322 | // Renumber self-references: old atom index → new index among selected inserts. |
|
| 1746 | 1323 | let mut remap: HashMap<(NodeId, u32), u32> = HashMap::new(); |
|
| 1747 | 1324 | let mut new_counts: HashMap<NodeId, u32> = HashMap::new(); |
|
| … | 63 unchanged lines |
||
| 1811 | 1388 | } |
|
| 1812 | 1389 | let mut out = Vec::new(); |
|
| 1813 | 1390 | for (i, op) in change.tree_ops.iter().enumerate() { |
|
| 1814 | - | // A retired duplicate goes with the node shown: no hunk of its own. |
|
| 1815 | - | if is_unified_retirement(op, base_snapshot) { |
|
| 1816 | - | continue; |
|
| 1817 | - | } |
|
| 1818 | 1391 | let one = Change { |
|
| 1819 | 1392 | tree_ops: vec![op.clone()], |
|
| 1820 | 1393 | edits: vec![], |
|
| … | 66 unchanged lines |
||
| 1887 | 1460 | (None, Some(b)) => self.load_state_indexed(&b)?, |
|
| 1888 | 1461 | (None, None) => return fail("this is a bare repository: it has no working copy"), |
|
| 1889 | 1462 | }; |
|
| 1890 | - | let view = self.tree_view(&mut state)?; |
|
| 1463 | + | let view = state.tree().view(); |
|
| 1891 | 1464 | let Some(node) = view.entries.get(path).map(|e| e.node) else { |
|
| 1892 | 1465 | return fail(format!("{path} is not in that state")); |
|
| 1893 | 1466 | }; |
|
| … | 109 unchanged lines |
||
| 2003 | 1576 | .map(|h| h.index) |
|
| 2004 | 1577 | .filter(|i| !indices.contains(i)) |
|
| 2005 | 1578 | .collect(); |
|
| 2006 | - | let (ops, edits) = select_hunks(&snap.wc, &keep, &snap.base_snapshot.tree.unified); |
|
| 1579 | + | let (ops, edits) = select_hunks(&snap.wc, &keep); |
|
| 2007 | 1580 | let dropped_nodes: BTreeSet<NodeId> = hunks |
|
| 2008 | 1581 | .iter() |
|
| 2009 | 1582 | .filter(|h| indices.contains(&h.index)) |
|
| … | 92 unchanged lines |
||
| 2102 | 1675 | let (base, snapshot) = self.change_base(&c)?; |
|
| 2103 | 1676 | (c, base, snapshot) |
|
| 2104 | 1677 | } |
|
| 2105 | - | None => match self.working_copy_with_base_unless_unchanged()? { |
|
| 2106 | - | Some(all) => all, |
|
| 2107 | - | None => return Ok(Vec::new()), |
|
| 2108 | - | }, |
|
| 1678 | + | None => self.working_copy_with_base()?, |
|
| 2109 | 1679 | }; |
|
| 2110 | 1680 | Ok(diff_files_of(&c, &base, &base_snapshot)) |
|
| 2111 | 1681 | } |
|
| … | 52 unchanged lines |
||
| 2164 | 1734 | /// answer, no snapshot is taken, and the base's snapshot holds its tree |
|
| 2165 | 1735 | /// but no file; otherwise a snapshot makes all three. |
|
| 2166 | 1736 | pub fn working_copy_with_base(&self) -> Result<(Change, State, Snapshot)> { |
|
| 2167 | - | match self.working_copy_with_base_unless_unchanged()? { |
|
| 2168 | - | Some(all) => Ok(all), |
|
| 2169 | - | None => { |
|
| 2170 | - | let (_, wc) = self.working_copy()?; |
|
| 2171 | - | let base = self.load_state_indexed(&wc.deps)?; |
|
| 2172 | - | let (base, snapshot) = self.tree_base_for(&wc, base)?; |
|
| 2173 | - | Ok((wc, base, snapshot)) |
|
| 2174 | - | } |
|
| 2175 | - | } |
|
| 2176 | - | } |
|
| 2177 | - | ||
| 2178 | - | /// [`Repo::working_copy_with_base`], or `None` when the status cache |
|
| 2179 | - | /// holds and the working-copy change changes nothing: its diff is |
|
| 2180 | - | /// empty, and the base is not loaded for it (oxygen#94). |
|
| 2181 | - | pub fn working_copy_with_base_unless_unchanged( |
|
| 2182 | - | &self, |
|
| 2183 | - | ) -> Result<Option<(Change, State, Snapshot)>> { |
|
| 2184 | 1737 | if self.status_by_manifest()?.is_some() { |
|
| 2185 | 1738 | let (_, wc) = self.working_copy()?; |
|
| 2186 | - | if wc.tree_ops.is_empty() && wc.edits.is_empty() { |
|
| 2187 | - | return Ok(None); |
|
| 2188 | - | } |
|
| 2189 | 1739 | let base = self.load_state_indexed(&wc.deps)?; |
|
| 2190 | 1740 | let (base, snapshot) = self.tree_base_for(&wc, base)?; |
|
| 2191 | - | return Ok(Some((wc, base, snapshot))); |
|
| 1741 | + | return Ok((wc, base, snapshot)); |
|
| 2192 | 1742 | } |
|
| 2193 | 1743 | let snap = self.snapshot_status("oxy diff")?; |
|
| 2194 | - | Ok(Some((snap.wc, snap.base, snap.base_snapshot))) |
|
| 1744 | + | Ok((snap.wc, snap.base, snap.base_snapshot)) |
|
| 2195 | 1745 | } |
|
| 2196 | 1746 | ||
| 2197 | 1747 | /// The log restricted to changes touching `path` (its node, so renames |
|
| … | 8 unchanged lines |
||
| 2206 | 1756 | // The path's node from the trees alone: the base's (from the |
|
| 2207 | 1757 | // workspace's state index), else the bookmark's. Nothing is |
|
| 2208 | 1758 | // rendered. |
|
| 2209 | - | let node_in = |mut state: State| -> Result<Option<HashSet<NodeId>>> { |
|
| 2210 | - | Ok(nodes_at(&self.tree_view(&mut state)?, path)) |
|
| 2211 | - | }; |
|
| 2212 | - | let nodes = match node_in(self.load_state_indexed(&wc.deps)?)? { |
|
| 1759 | + | let node_in = |state: State| state.tree().view().entries.get(path).map(|e| e.node); |
|
| 1760 | + | let node = match node_in(self.load_state_indexed(&wc.deps)?) { |
|
| 2213 | 1761 | Some(n) => Some(n), |
|
| 2214 | - | None => node_in(self.load_state_history(&heads)?)?, |
|
| 1762 | + | None => node_in(self.load_state_history(&heads)?), |
|
| 2215 | 1763 | }; |
|
| 2216 | - | let Some(nodes) = nodes else { |
|
| 1764 | + | let Some(node) = node else { |
|
| 2217 | 1765 | return fail(format!("{path} is not tracked")); |
|
| 2218 | 1766 | }; |
|
| 2219 | - | Ok(self.entries_touching_up_to(entries, nodes, None)) |
|
| 1767 | + | Ok(self.entries_touching(entries, node)) |
|
| 2220 | 1768 | } |
|
| 2221 | 1769 | ||
| 2222 | 1770 | /// The log of the state with these heads restricted to changes touching |
|
| … | 11 unchanged lines |
||
| 2234 | 1782 | path: &str, |
|
| 2235 | 1783 | limit: Option<usize>, |
|
| 2236 | 1784 | ) -> Result<Vec<LogEntry>> { |
|
| 2237 | - | let Some(nodes) = nodes_at(&state.view(), path) else { |
|
| 1785 | + | let Some(node) = state.tree().view().entries.get(path).map(|e| e.node) else { |
|
| 2238 | 1786 | return fail(format!("{path} is not in that state")); |
|
| 2239 | 1787 | }; |
|
| 2240 | - | Ok(self.entries_touching_up_to(self.log_of(state), nodes, limit)) |
|
| 1788 | + | Ok(self.entries_touching_up_to(self.log_of(state), node, limit)) |
|
| 1789 | + | } |
|
| 1790 | + | ||
| 1791 | + | fn entries_touching(&self, entries: Vec<LogEntry>, node: NodeId) -> Vec<LogEntry> { |
|
| 1792 | + | self.entries_touching_up_to(entries, node, None) |
|
| 2241 | 1793 | } |
|
| 2242 | 1794 | ||
| 2243 | 1795 | fn entries_touching_up_to( |
|
| 2244 | 1796 | &self, |
|
| 2245 | 1797 | entries: Vec<LogEntry>, |
|
| 2246 | - | wanted: HashSet<NodeId>, |
|
| 1798 | + | node: NodeId, |
|
| 2247 | 1799 | limit: Option<usize>, |
|
| 2248 | 1800 | ) -> Vec<LogEntry> { |
|
| 2249 | 1801 | let mut touched = self.touched(); |
|
| … | 1 unchanged lines |
||
| 2251 | 1803 | let hashes: Vec<ChangeHash> = entries.iter().map(|e| e.hash).collect(); |
|
| 2252 | 1804 | touched.fill(self, &hashes); |
|
| 2253 | 1805 | } |
|
| 1806 | + | let wanted = HashSet::from([node]); |
|
| 2254 | 1807 | let mut out = Vec::new(); |
|
| 2255 | 1808 | for e in entries { |
|
| 2256 | 1809 | if limit.is_some_and(|n| out.len() >= n) { |
|
| … | 6 unchanged lines |
||
| 2263 | 1816 | touched.save(); |
|
| 2264 | 1817 | out |
|
| 2265 | 1818 | } |
|
| 2266 | - | } |
|
| 2267 | - | ||
| 2268 | - | /// The node at `path` in `view`, with the nodes it shows as one with it |
|
| 2269 | - | /// (identical additions, spec §9.2): the file's history is all of theirs. |
|
| 2270 | - | fn nodes_at(view: &crate::tree::TreeView, path: &str) -> Option<HashSet<NodeId>> { |
|
| 2271 | - | let node = view.entries.get(path)?.node; |
|
| 2272 | - | let mut out = HashSet::from([node]); |
|
| 2273 | - | out.extend( |
|
| 2274 | - | view.unified |
|
| 2275 | - | .iter() |
|
| 2276 | - | .filter(|(_, shown)| **shown == node) |
|
| 2277 | - | .map(|(dup, _)| *dup), |
|
| 2278 | - | ); |
|
| 2279 | - | Some(out) |
|
| 2280 | 1819 | } |
|
| 2281 | 1820 | ||
| 2282 | 1821 | /// Per-file hunks of `change` against `base`. |
|
| … | 28 unchanged lines |
||
| 2311 | 1850 | }); |
|
| 2312 | 1851 | } |
|
| 2313 | 1852 | }; |
|
| 2314 | - | for op in change |
|
| 2315 | - | .tree_ops |
|
| 2316 | - | .iter() |
|
| 2317 | - | .filter(|o| !is_unified_retirement(o, base_snapshot)) |
|
| 2318 | - | { |
|
| 1853 | + | for op in &change.tree_ops { |
|
| 2319 | 1854 | match op { |
|
| 2320 | 1855 | TreeOp::AddNode { node, kind, .. } => { |
|
| 2321 | 1856 | if *kind != NodeKind::Dir { |
|
| … | 514 unchanged lines |
||
| 2836 | 2371 | if !extra.is_empty() { |
|
| 2837 | 2372 | self.resolves = true; |
|
| 2838 | 2373 | self.tree_ops.extend(extra); |
|
| 2839 | - | } |
|
| 2840 | - | } |
|
| 2841 | - | ||
| 2842 | - | /// Nodes the base shows as one with another (identical additions at one |
|
| 2843 | - | /// path, spec §9.2) are retired by the change that first touches the |
|
| 2844 | - | /// node shown: edited, moved, removed or given a mode alone, it would |
|
| 2845 | - | /// differ from them and the name conflict would come back. Nothing |
|
| 2846 | - | /// else changes for the user, so no status reports it. |
|
| 2847 | - | fn retire_unified(&mut self) { |
|
| 2848 | - | let unified = &self.base_snapshot.tree.unified; |
|
| 2849 | - | if unified.is_empty() { |
|
| 2850 | - | return; |
|
| 2851 | - | } |
|
| 2852 | - | let touched: HashSet<NodeId> = self |
|
| 2853 | - | .tree_ops |
|
| 2854 | - | .iter() |
|
| 2855 | - | .map(|o| o.node()) |
|
| 2856 | - | .chain(self.edits.iter().map(|e| e.node())) |
|
| 2857 | - | .collect(); |
|
| 2858 | - | let mut retired: HashSet<(ChangeHash, u32)> = HashSet::new(); |
|
| 2859 | - | for op in &self.tree_ops { |
|
| 2860 | - | match op { |
|
| 2861 | - | TreeOp::Move { old, .. } => { |
|
| 2862 | - | retired.insert((old.change, old.index)); |
|
| 2863 | - | } |
|
| 2864 | - | TreeOp::RemoveNode { placement, .. } => { |
|
| 2865 | - | retired.insert((placement.change, placement.index)); |
|
| 2866 | - | } |
|
| 2867 | - | TreeOp::AddNode { .. } | TreeOp::SetMode { .. } => {} |
|
| 2868 | - | } |
|
| 2869 | - | } |
|
| 2870 | - | let mut extra = Vec::new(); |
|
| 2871 | - | for (dup, shown) in unified { |
|
| 2872 | - | if !touched.contains(shown) { |
|
| 2873 | - | continue; |
|
| 2874 | - | } |
|
| 2875 | - | for (k, _) in self.base.tree().live_placements(dup) { |
|
| 2876 | - | if retired.insert(k) { |
|
| 2877 | - | extra.push(TreeOp::RemoveNode { |
|
| 2878 | - | node: *dup, |
|
| 2879 | - | placement: op_ref(k), |
|
| 2880 | - | }); |
|
| 2881 | - | } |
|
| 2882 | - | } |
|
| 2883 | 2374 | } |
|
| 2884 | - | self.tree_ops.extend(extra); |
|
| 2885 | 2375 | } |
|
| 2886 | 2376 | ||
| 2887 | 2377 | /// The node's other live placements at the same place as `k`, the one |
|
| … | 91 unchanged lines |
||
| 2979 | 2469 | if bytes == &*disk_text { |
|
| 2980 | 2470 | return Ok(()); |
|
| 2981 | 2471 | } |
|
| 2982 | - | let regions = self.base.conflict_regions(&node); |
|
| 2983 | - | let plain: Vec<&[u8]> = |
|
| 2984 | - | regions.plain.iter().map(|(_, l)| l.as_slice()).collect(); |
|
| 2985 | - | if has_own_conflict_markers(&e.content, bytes, &plain) { |
|
| 2472 | + | if has_conflict_markers(&e.content) { |
|
| 2986 | 2473 | self.statuses.push(PathStatus { |
|
| 2987 | 2474 | path: path.to_string(), |
|
| 2988 | 2475 | status: ChangeStatus::ConflictEdited, |
|
| … | 4 unchanged lines |
||
| 2993 | 2480 | // with each region as a gap, then retire every region atom. |
|
| 2994 | 2481 | // Whatever the author kept from a region is re-inserted as |
|
| 2995 | 2482 | // new atoms, which leaves the graph totally ordered. |
|
| 2483 | + | let regions = self.base.conflict_regions(&node); |
|
| 2996 | 2484 | let mut edits = text_edits(node, ®ions.plain, &disk_text); |
|
| 2997 | 2485 | if !regions.region_atoms.is_empty() { |
|
| 2998 | 2486 | edits.insert( |
|
| … | 318 unchanged lines |
||
| 3317 | 2805 | } |
|
| 3318 | 2806 | } |
|
| 3319 | 2807 | out |
|
| 3320 | - | } |
|
| 3321 | - | ||
| 3322 | - | /// Whether `op` retires a node `base_snapshot` shows as one with another |
|
| 3323 | - | /// (spec §9.2): part of touching that node, never a removal the user made. |
|
| 3324 | - | fn is_unified_retirement(op: &TreeOp, base_snapshot: &Snapshot) -> bool { |
|
| 3325 | - | matches!(op, TreeOp::RemoveNode { node, .. } if base_snapshot.tree.unified.contains_key(node)) |
|
| 3326 | 2808 | } |
|
| 3327 | 2809 | ||
| 3328 | 2810 | fn path_of_node( |
|
| … | 45 unchanged lines |
||
| 3374 | 2856 | }; |
|
| 3375 | 2857 | ||
| 3376 | 2858 | let mut out = String::new(); |
|
| 3377 | - | for op in change |
|
| 3378 | - | .tree_ops |
|
| 3379 | - | .iter() |
|
| 3380 | - | .filter(|o| !is_unified_retirement(o, base_snapshot)) |
|
| 3381 | - | { |
|
| 2859 | + | for op in &change.tree_ops { |
|
| 3382 | 2860 | match op { |
|
| 3383 | 2861 | TreeOp::AddNode { |
|
| 3384 | 2862 | node, kind, mode, .. |
|
| … | 247 unchanged lines |
modified crates/oxy-core/src/repo/strata.rs +2 −14
| … | 232 unchanged lines |
||
| 233 | 233 | None => (RefsResponse::default(), None), |
|
| 234 | 234 | }; |
|
| 235 | 235 | self.with_mirror(|mirror| { |
|
| 236 | - | // Another process may have brought the mirror level since this |
|
| 237 | - | // handle read its head. |
|
| 238 | - | mirror.refresh()?; |
|
| 239 | 236 | // The policy first: bundles are then imported under it. |
|
| 240 | 237 | if let Some(sp) = &resp.policy { |
|
| 241 | 238 | let have = mirror.current_policy()?.map(|(_, p)| p.policy.serial); |
|
| … | 105 unchanged lines |
||
| 347 | 344 | for t in &resp.tags { |
|
| 348 | 345 | mirror.store.put_state(&StateObject::new(t.heads.clone()))?; |
|
| 349 | 346 | mirror |
|
| 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)))?; |
|
| 347 | + | .adopt_tag(&mut view, t, policy.as_ref()) |
|
| 348 | + | .map_err(|e| RepoError::Msg(format!("{}: {e}", self.what())))?; |
|
| 357 | 349 | } |
|
| 358 | 350 | if view != op.view { |
|
| 359 | 351 | mirror.record_op(&[op_ref], "strata sync", view)?; |
|
| … | 58 unchanged lines |
||
| 418 | 410 | ||
| 419 | 411 | fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle> { |
|
| 420 | 412 | self.with_mirror(|m| m.checkpoint_bundle_for(heads)) |
|
| 421 | - | } |
|
| 422 | - | ||
| 423 | - | fn checkpoint_index(&self, heads: &[ChangeHash]) -> Result<Bundle> { |
|
| 424 | - | self.with_mirror(|m| m.checkpoint_index_for(heads)) |
|
| 425 | 413 | } |
|
| 426 | 414 | ||
| 427 | 415 | fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> { |
|
| … | 943 unchanged lines |
modified crates/oxy-core/src/repo/subrepos.rs +17 −12
| … | 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 = pin.same_state(&actual); |
|
| 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 | + | }; |
|
| 186 | 195 | if same { |
|
| 187 | 196 | return Ok(if cloned { |
|
| 188 | 197 | SubrepoSync::Updated |
|
| … | 49 unchanged lines |
||
| 238 | 247 | /// Every subrepo of the working copy's base state. |
|
| 239 | 248 | pub fn subrepos(&self) -> Result<Vec<SubrepoInfo>> { |
|
| 240 | 249 | let (_, wc) = self.working_copy()?; |
|
| 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)?; |
|
| 250 | + | let snap = self.load_state(&wc.deps)?.materialize(); |
|
| 247 | 251 | let modules = self.modules_on_disk(); |
|
| 248 | 252 | let mut out = Vec::new(); |
|
| 249 | - | for (path, e) in tree.entries.iter() { |
|
| 250 | - | if e.kind != NodeKind::Subrepo { |
|
| 253 | + | for (path, f) in &snap.files { |
|
| 254 | + | if f.kind != NodeKind::Subrepo { |
|
| 251 | 255 | continue; |
|
| 252 | 256 | } |
|
| 253 | - | let Some(b) = state.live_blobs(&e.node).first().copied() else { |
|
| 257 | + | let FileContent::Opaque { blobs } = &f.content else { |
|
| 254 | 258 | continue; |
|
| 255 | 259 | }; |
|
| 256 | - | let pin = Pin::decode(&self.read_blob(&b)?)?; |
|
| 260 | + | let Some(b) = blobs.first() else { continue }; |
|
| 261 | + | let pin = Pin::decode(&self.read_blob(b)?)?; |
|
| 257 | 262 | let dir = self.root.join(path); |
|
| 258 | 263 | let present = dir.join(OXY_DIR).exists() || dir.join(".git").exists(); |
|
| 259 | 264 | let actual = if present { |
|
| … | 20 unchanged lines |
||
| 280 | 285 | } |
|
| 281 | 286 | // Registered but not committed yet: pinned at what they are now. |
|
| 282 | 287 | for (path, m) in &modules.entries { |
|
| 283 | - | if tree.entries.contains_key(path) { |
|
| 288 | + | if snap.files.contains_key(path) { |
|
| 284 | 289 | continue; |
|
| 285 | 290 | } |
|
| 286 | 291 | let dir = self.root.join(path); |
|
| … | 144 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +99 −1138
| 1 | 1 | //! Serving and talking to remotes (spec §10.2, §16). |
|
| 2 | 2 | ||
| 3 | - | use super::tags::TagCheck; |
|
| 4 | 3 | use super::*; |
|
| 5 | 4 | ||
| 6 | 5 | /// What a push did, for hosts (hub spec §6.12). |
|
| … | 21 unchanged lines |
||
| 28 | 27 | pub refs: Vec<(String, Vec<ChangeHash>, usize)>, |
|
| 29 | 28 | /// Tags adopted from the remote. |
|
| 30 | 29 | pub new_tags: usize, |
|
| 31 | - | /// Tags the remote withdrew, and so withdrawn here (oxygenhub#301). |
|
| 32 | - | pub withdrawn_tags: Vec<String>, |
|
| 33 | 30 | /// The remote has never presented a policy, so nothing was verified |
|
| 34 | 31 | /// against one (spec §14.5: warn loudly). |
|
| 35 | 32 | pub unverified: bool, |
|
| 36 | - | /// What checking the host's transparency log found short of a fork |
|
| 37 | - | /// (spec §14.8): attestations not logged yet, a log that could not be |
|
| 38 | - | /// read. A fork fails the fetch instead. |
|
| 39 | - | pub log_notes: Vec<String>, |
|
| 40 | - | /// Bookmarks left as they were because their attestation could not be |
|
| 41 | - | /// verified (its signer unknown here, or its signature bad), each with |
|
| 42 | - | /// why (oxygenhub#266). The current bookmark is never skipped: its |
|
| 43 | - | /// fetch fails instead. |
|
| 44 | - | pub skipped: Vec<(String, String)>, |
|
| 45 | 33 | } |
|
| 46 | 34 | ||
| 47 | 35 | #[derive(Debug, Clone, PartialEq, Eq)] |
|
| … | 6 unchanged lines |
||
| 54 | 42 | /// Other bookmarks the pull moved: each local bookmark tracks the |
|
| 55 | 43 | /// remote's of the same name. |
|
| 56 | 44 | pub others: Vec<String>, |
|
| 57 | - | /// What the fetch left as it was ([`FetchReport::skipped`]). |
|
| 58 | - | pub skipped: Vec<(String, String)>, |
|
| 59 | 45 | } |
|
| 60 | 46 | ||
| 61 | 47 | /// The result a push of objects alone reports (a batch of a long history): |
|
| 62 | 48 | /// not a reference. |
|
| 63 | 49 | pub const OBJECTS_RESULT: &str = "objects"; |
|
| 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 | 50 | ||
| 70 | 51 | impl Repo { |
|
| 71 | 52 | // ------------------------------------------------------------ serving (spec §16) |
|
| … | 36 unchanged lines |
||
| 108 | 89 | _ => None, |
|
| 109 | 90 | }); |
|
| 110 | 91 | let attestation = latest.filter(|a| matches!(&a.attest.body, AttestBody::Ref { new_state, .. } if new_state == state)); |
|
| 111 | - | let accepted = match &attestation { |
|
| 112 | - | Some(a) => self.acceptance_of(&a.attest.hash())?, |
|
| 113 | - | None => None, |
|
| 114 | - | }; |
|
| 115 | 92 | out.push(RemoteRef { |
|
| 116 | 93 | name: name.clone(), |
|
| 117 | 94 | state: *state, |
|
| 118 | 95 | heads: self.store.get_state(state)?.heads, |
|
| 119 | 96 | attestation, |
|
| 120 | 97 | last_serial, |
|
| 121 | - | accepted, |
|
| 122 | - | logged: None, |
|
| 123 | - | integration: vec![], |
|
| 124 | 98 | }); |
|
| 125 | 99 | } |
|
| 126 | 100 | let policy = self.current_policy()?.map(|(_, sp)| sp); |
|
| … | 12 unchanged lines |
||
| 139 | 113 | tags: self.tag_refs()?, |
|
| 140 | 114 | history, |
|
| 141 | 115 | default_bookmark: Some(self.config.bookmark.clone()), |
|
| 142 | - | log: None, |
|
| 143 | - | witnesses: vec![], |
|
| 144 | - | withdrawn: self.withdrawn_refs()?, |
|
| 145 | 116 | }) |
|
| 146 | 117 | } |
|
| 147 | 118 | ||
| … | 105 unchanged lines |
||
| 253 | 224 | let mut done: BTreeSet<ChangeHash> = have.clone(); |
|
| 254 | 225 | done.extend(boundary.iter().copied()); |
|
| 255 | 226 | // Iterative post-order DFS. |
|
| 256 | - | let shallow = self.is_shallow(); |
|
| 257 | 227 | let mut stack: Vec<(ChangeHash, bool)> = want.iter().rev().map(|h| (*h, false)).collect(); |
|
| 258 | 228 | while let Some((h, expanded)) = stack.pop() { |
|
| 259 | 229 | if done.contains(&h) { |
|
| 260 | 230 | continue; |
|
| 261 | 231 | } |
|
| 262 | 232 | 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 | - | } |
|
| 271 | 233 | return fail(format!("change {} is not in this repository", h.short(12))); |
|
| 272 | 234 | } |
|
| 273 | 235 | if expanded { |
|
| … | 14 unchanged lines |
||
| 288 | 250 | /// Bundle changes (in the given dependency order) with their signatures, |
|
| 289 | 251 | /// seals, identities, blobs, chunks. |
|
| 290 | 252 | pub fn bundle_for(&self, changes: &[ChangeHash]) -> Result<Bundle> { |
|
| 291 | - | self.bundle_of(changes, None, false, false) |
|
| 253 | + | self.bundle_for_filtered(changes, None) |
|
| 292 | 254 | } |
|
| 293 | 255 | ||
| 294 | 256 | /// Objects by reference for the `objects` endpoint; a blob brings its chunks. |
|
| 295 | 257 | pub fn objects_for(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> { |
|
| 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(_)) => {} |
|
| 303 | - | Err(e) => return Err(e.into()), |
|
| 304 | - | } |
|
| 305 | - | } |
|
| 306 | - | let mut chunks = Vec::new(); |
|
| 307 | - | for (ty, body) in &found { |
|
| 308 | - | if *ty == ObjectType::Blob { |
|
| 309 | - | chunks.extend(Blob::decode(body)?.chunks); |
|
| 310 | - | } |
|
| 311 | - | } |
|
| 312 | - | let mut out = Vec::with_capacity(found.len() + chunks.len()); |
|
| 313 | - | for got in self.store.get_many(&chunks) { |
|
| 314 | - | match got { |
|
| 315 | - | Ok(x) => out.push(x), |
|
| 316 | - | Err(StoreError::NotFound(_)) => {} |
|
| 258 | + | let mut out = Vec::new(); |
|
| 259 | + | for r in refs { |
|
| 260 | + | let (ty, body) = match self.store.get(r) { |
|
| 261 | + | Ok(x) => x, |
|
| 262 | + | Err(StoreError::NotFound(_)) => continue, |
|
| 317 | 263 | Err(e) => return Err(e.into()), |
|
| 264 | + | }; |
|
| 265 | + | if ty == ObjectType::Blob { |
|
| 266 | + | for c in Blob::decode(&body)?.chunks { |
|
| 267 | + | if let Ok((cty, cb)) = self.store.get(&c) { |
|
| 268 | + | out.push((cty, cb)); |
|
| 269 | + | } |
|
| 270 | + | } |
|
| 318 | 271 | } |
|
| 272 | + | out.push((ty, body)); |
|
| 319 | 273 | } |
|
| 320 | - | out.extend(found); |
|
| 321 | 274 | Ok(out) |
|
| 322 | 275 | } |
|
| 323 | 276 | ||
| 324 | 277 | /// Like `bundle_for`, leaving out blobs larger than `max_blob` bytes |
|
| 325 | 278 | /// (and their chunks) so the receiver fetches them lazily (spec §13.5). |
|
| 326 | - | /// |
|
| 327 | - | /// This is what a server answers a fetch with, so it also carries what |
|
| 328 | - | /// verifies the attestations it serves beside it (see |
|
| 329 | - | /// [`Repo::served_attest_signers`]), also when `changes` is empty. |
|
| 330 | 279 | pub fn bundle_for_filtered( |
|
| 331 | 280 | &self, |
|
| 332 | 281 | changes: &[ChangeHash], |
|
| 333 | 282 | max_blob: Option<u64>, |
|
| 334 | 283 | ) -> Result<Bundle> { |
|
| 335 | - | self.bundle_of(changes, max_blob, false, true) |
|
| 284 | + | self.bundle_of(changes, max_blob, false) |
|
| 336 | 285 | } |
|
| 337 | 286 | ||
| 338 | 287 | /// Like [`Repo::bundle_for_filtered`] for a fetch between repositories |
|
| … | 1 unchanged lines |
||
| 340 | 289 | /// with their text blobs and chunks among the objects, so neither side |
|
| 341 | 290 | /// decodes or encodes their text. |
|
| 342 | 291 | pub fn bundle_raw_for(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle> { |
|
| 343 | - | self.bundle_of(changes, max_blob, true, true) |
|
| 344 | - | } |
|
| 345 | - | ||
| 346 | - | /// The keys that signed the attestations this repository serves with |
|
| 347 | - | /// its references: each bookmark's latest and each tag's. A fetch |
|
| 348 | - | /// bundle carries their identities and certificates (spec §16.2), so a |
|
| 349 | - | /// client can check an attestation whose signer signed none of the |
|
| 350 | - | /// changes it receives, a delegate or a short-lived key that only |
|
| 351 | - | /// moved a bookmark (oxygenhub#266). One pointer and one signature |
|
| 352 | - | /// list per reference, computed once per operation head and kept |
|
| 353 | - | /// (every bookmark move and tag is an operation): a client sends one |
|
| 354 | - | /// fetch per bookmark, and on a bucket store each signature list is a |
|
| 355 | - | /// LIST request, so recomputing per fetch cost metalgear2 (145 |
|
| 356 | - | /// bookmarks) 14 s a fetch. |
|
| 357 | - | pub fn served_attest_signers(&self) -> Result<BTreeSet<KeyId>> { |
|
| 358 | - | type Signers = std::collections::HashMap<(PathBuf, ObjectRef), BTreeSet<KeyId>>; |
|
| 359 | - | static SERVED: std::sync::Mutex<Option<Signers>> = std::sync::Mutex::new(None); |
|
| 360 | - | let key = (self.oxy.clone(), self.head_op()?.0); |
|
| 361 | - | if let Some(keys) = SERVED |
|
| 362 | - | .lock() |
|
| 363 | - | .unwrap_or_else(|p| p.into_inner()) |
|
| 364 | - | .as_ref() |
|
| 365 | - | .and_then(|m| m.get(&key)) |
|
| 366 | - | { |
|
| 367 | - | return Ok(keys.clone()); |
|
| 368 | - | } |
|
| 369 | - | let keys = self.attest_signers_now()?; |
|
| 370 | - | let mut served = SERVED.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 371 | - | let map = served.get_or_insert_with(Default::default); |
|
| 372 | - | // One entry per repository: an older operation head is never asked |
|
| 373 | - | // for again once a newer one is current. |
|
| 374 | - | map.retain(|(oxy, _), _| *oxy != key.0); |
|
| 375 | - | if map.len() >= 1024 { |
|
| 376 | - | map.clear(); |
|
| 377 | - | } |
|
| 378 | - | map.insert(key, keys.clone()); |
|
| 379 | - | Ok(keys) |
|
| 380 | - | } |
|
| 381 | - | ||
| 382 | - | fn attest_signers_now(&self) -> Result<BTreeSet<KeyId>> { |
|
| 383 | - | let view = self.view()?; |
|
| 384 | - | let pointers = view |
|
| 385 | - | .bookmarks |
|
| 386 | - | .keys() |
|
| 387 | - | .map(|b| self.ref_attest_path(b)) |
|
| 388 | - | .chain(view.tags.keys().map(|t| self.tag_attest_path(t))); |
|
| 389 | - | let mut keys = BTreeSet::new(); |
|
| 390 | - | for p in pointers { |
|
| 391 | - | let Ok(text) = fs::read_to_string(&p) else { |
|
| 392 | - | continue; |
|
| 393 | - | }; |
|
| 394 | - | let Some(r) = ObjectRef::from_hex(text.trim()) else { |
|
| 395 | - | continue; |
|
| 396 | - | }; |
|
| 397 | - | keys.extend(self.store.signatures(&r)?.into_iter().map(|s| s.key)); |
|
| 398 | - | } |
|
| 399 | - | Ok(keys) |
|
| 292 | + | self.bundle_of(changes, max_blob, true) |
|
| 400 | 293 | } |
|
| 401 | 294 | ||
| 402 | - | /// `serving`: a fetch's answer, which carries the identities and |
|
| 403 | - | /// certificates of [`Repo::served_attest_signers`] too. |
|
| 404 | 295 | fn bundle_of( |
|
| 405 | 296 | &self, |
|
| 406 | 297 | changes: &[ChangeHash], |
|
| 407 | 298 | max_blob: Option<u64>, |
|
| 408 | 299 | raw: bool, |
|
| 409 | - | serving: bool, |
|
| 410 | 300 | ) -> Result<Bundle> { |
|
| 411 | 301 | let mut bundle = Bundle::default(); |
|
| 412 | 302 | let mut objects: BTreeSet<ObjectRef> = BTreeSet::new(); |
|
| … | 24 unchanged lines |
||
| 437 | 327 | for s in &sigs { |
|
| 438 | 328 | add_object(&s.key, &mut objects); |
|
| 439 | 329 | } |
|
| 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). |
|
| 444 | 330 | for e in &c.edits { |
|
| 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; |
|
| 331 | + | if let Edit::Replace { old, new, .. } = e { |
|
| 332 | + | for b in [old, new].into_iter().flatten() { |
|
| 333 | + | if self.store.has(b) { |
|
| 334 | + | if let Ok(blob) = |
|
| 335 | + | Blob::decode(&self.store.get_typed(b, ObjectType::Blob)?) |
|
| 336 | + | { |
|
| 337 | + | if max_blob.is_some_and(|m| blob.length > m) { |
|
| 338 | + | continue; |
|
| 339 | + | } |
|
| 340 | + | objects.insert(*b); |
|
| 341 | + | objects.extend(blob.chunks.iter().copied()); |
|
| 451 | 342 | } |
|
| 452 | - | objects.insert(*b); |
|
| 453 | - | objects.extend(blob.chunks.iter().copied()); |
|
| 454 | 343 | } |
|
| 455 | 344 | } |
|
| 456 | 345 | } |
|
| … | 17 unchanged lines |
||
| 474 | 363 | } |
|
| 475 | 364 | bundle.attests.push(SignedAttest { attest, sigs }); |
|
| 476 | 365 | } |
|
| 477 | - | let signers: Vec<KeyId> = if serving { |
|
| 478 | - | let signers = self.served_attest_signers()?; |
|
| 479 | - | for k in &signers { |
|
| 480 | - | add_object(k, &mut objects); |
|
| 481 | - | } |
|
| 482 | - | signers.into_iter().collect() |
|
| 483 | - | } else { |
|
| 484 | - | Vec::new() |
|
| 485 | - | }; |
|
| 486 | 366 | for r in objects { |
|
| 487 | 367 | let (ty, body) = self.store.get(&r)?; |
|
| 488 | 368 | bundle.objects.push((ty, body)); |
|
| 489 | 369 | } |
|
| 490 | - | self.attach_certificates_for(&mut bundle, &signers)?; |
|
| 491 | 370 | Ok(bundle) |
|
| 492 | 371 | } |
|
| 493 | 372 | ||
| 494 | - | /// Add the certificates of short-lived keys that signed what `bundle` |
|
| 495 | - | /// carries, and their issuers' identities, so a receiver can tell who |
|
| 496 | - | /// they stand for (spec §14.7). `also` names more signers (a reference |
|
| 497 | - | /// attestation sent beside the bundle). |
|
| 498 | - | pub(crate) fn attach_certificates_for( |
|
| 499 | - | &self, |
|
| 500 | - | bundle: &mut Bundle, |
|
| 501 | - | also: &[KeyId], |
|
| 502 | - | ) -> Result<()> { |
|
| 503 | - | let mut keys: BTreeSet<KeyId> = also.iter().copied().collect(); |
|
| 504 | - | keys.extend( |
|
| 505 | - | bundle |
|
| 506 | - | .changes |
|
| 507 | - | .iter() |
|
| 508 | - | .flat_map(|(_, s)| s.iter().map(|s| s.key)), |
|
| 509 | - | ); |
|
| 510 | - | keys.extend( |
|
| 511 | - | bundle |
|
| 512 | - | .raw_changes |
|
| 513 | - | .iter() |
|
| 514 | - | .flat_map(|(_, s)| s.iter().map(|s| s.key)), |
|
| 515 | - | ); |
|
| 516 | - | keys.extend( |
|
| 517 | - | bundle |
|
| 518 | - | .attests |
|
| 519 | - | .iter() |
|
| 520 | - | .flat_map(|a| a.sigs.iter().map(|s| s.key)), |
|
| 521 | - | ); |
|
| 522 | - | for k in keys { |
|
| 523 | - | let Some(r) = self.store.certificate_of(&k)? else { |
|
| 524 | - | continue; |
|
| 525 | - | }; |
|
| 526 | - | let cert = self.store.get_certificate(&r)?; |
|
| 527 | - | if bundle |
|
| 528 | - | .certificates |
|
| 529 | - | .iter() |
|
| 530 | - | .any(|c| c.cert.hash() == cert.cert.hash()) |
|
| 531 | - | { |
|
| 532 | - | continue; |
|
| 533 | - | } |
|
| 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() { |
|
| 538 | - | if let Ok(body) = self.store.get_typed(id, ObjectType::Identity) { |
|
| 539 | - | if !bundle |
|
| 540 | - | .objects |
|
| 541 | - | .iter() |
|
| 542 | - | .any(|(t, b)| *t == ObjectType::Identity && *b == body) |
|
| 543 | - | { |
|
| 544 | - | bundle.objects.push((ObjectType::Identity, body)); |
|
| 545 | - | } |
|
| 546 | - | } |
|
| 547 | - | } |
|
| 548 | - | bundle.certificates.push(cert); |
|
| 549 | - | } |
|
| 550 | - | Ok(()) |
|
| 551 | - | } |
|
| 552 | - | ||
| 553 | - | /// Keep the certificates a bundle brings once each verifies with the |
|
| 554 | - | /// identity of the issuer it names; one that does not refuses the bundle. |
|
| 555 | - | fn adopt_certificates(&self, certs: &[crate::cert::SignedCertificate]) -> Result<()> { |
|
| 556 | - | for c in certs { |
|
| 557 | - | let issuer = self.store.get_identity(&c.cert.issuer).map_err(|_| { |
|
| 558 | - | RepoError::Msg(format!( |
|
| 559 | - | "a certificate names issuer {}, whose identity did not come with it", |
|
| 560 | - | c.cert.issuer.short(12) |
|
| 561 | - | )) |
|
| 562 | - | })?; |
|
| 563 | - | c.verify(&issuer) |
|
| 564 | - | .map_err(|e| RepoError::Msg(format!("a certificate does not verify: {e}")))?; |
|
| 565 | - | self.store.put_identity(&c.cert.key)?; |
|
| 566 | - | self.store.put_certificate(c)?; |
|
| 567 | - | } |
|
| 568 | - | Ok(()) |
|
| 569 | - | } |
|
| 570 | - | ||
| 571 | - | /// A signer authorized for `reference` by what an issuer certified it |
|
| 572 | - | /// as (spec §14.7): a short-lived key with a kept certificate that |
|
| 573 | - | /// verifies with its issuer, gives `scope`, covers `at_ms` (a time the |
|
| 574 | - | /// signer did not choose) and names a subject the policy grants. |
|
| 575 | - | pub(crate) fn certified_authorized( |
|
| 576 | - | &self, |
|
| 577 | - | p: &crate::policy::Policy, |
|
| 578 | - | signer: &KeyId, |
|
| 579 | - | reference: &str, |
|
| 580 | - | scope: Scope, |
|
| 581 | - | at_ms: u64, |
|
| 582 | - | ) -> bool { |
|
| 583 | - | let Ok(Some(r)) = self.store.certificate_of(signer) else { |
|
| 584 | - | return false; |
|
| 585 | - | }; |
|
| 586 | - | let Ok(c) = self.store.get_certificate(&r) else { |
|
| 587 | - | return false; |
|
| 588 | - | }; |
|
| 589 | - | let Some(issuer) = p |
|
| 590 | - | .identity(&c.cert.issuer) |
|
| 591 | - | .cloned() |
|
| 592 | - | .or_else(|| self.store.get_identity(&c.cert.issuer).ok()) |
|
| 593 | - | else { |
|
| 594 | - | return false; |
|
| 595 | - | }; |
|
| 596 | - | c.verify(&issuer).is_ok() |
|
| 597 | - | && c.cert.key_id() == *signer |
|
| 598 | - | && c.cert.covers(scope, at_ms) |
|
| 599 | - | && p.subject_grant(reference, &c.cert.issuer, &c.cert.subject, scope) |
|
| 600 | - | } |
|
| 601 | - | ||
| 602 | 373 | /// Store a bundle's contents. With `strict`, every change must |
|
| 603 | 374 | /// authenticate (signature or seal by its author or a delegate). |
|
| 604 | 375 | pub fn import_bundle(&self, bundle: &Bundle, strict: bool) -> Result<Vec<ChangeHash>> { |
|
| … | 43 unchanged lines |
||
| 648 | 419 | .map(|(ty, body)| (*ty, body.as_slice())) |
|
| 649 | 420 | .collect(); |
|
| 650 | 421 | self.store.put_many(&objects)?; |
|
| 651 | - | self.adopt_certificates(&bundle.certificates)?; |
|
| 652 | 422 | for sa in &bundle.attests { |
|
| 653 | 423 | let r = self.store.put_attest(&sa.attest)?; |
|
| 654 | 424 | for sig in &sa.sigs { |
|
| … | 118 unchanged lines |
||
| 773 | 543 | }) |
|
| 774 | 544 | } |
|
| 775 | 545 | ||
| 776 | - | /// A lasting attestation (a tag) signed by a key the policy has since |
|
| 777 | - | /// rotated away from (spec §14.2) is authorized as the key that |
|
| 778 | - | /// succeeded it: a key the rule lists reaches the signer through |
|
| 779 | - | /// `previous` links, and the attestation is dated (`at_ms`) before the |
|
| 780 | - | /// signer's successor was made, so a superseded key makes nothing new. |
|
| 781 | - | /// Revoked keys authorize nothing. |
|
| 782 | - | pub(crate) fn predecessor_authorized( |
|
| 783 | - | &self, |
|
| 784 | - | p: &crate::policy::Policy, |
|
| 785 | - | signer: &KeyId, |
|
| 786 | - | reference: &str, |
|
| 787 | - | at_ms: u64, |
|
| 788 | - | ) -> bool { |
|
| 789 | - | if p.is_revoked(signer) { |
|
| 790 | - | return false; |
|
| 791 | - | } |
|
| 792 | - | let Some(rule) = p.rule_for(reference) else { |
|
| 793 | - | return false; |
|
| 794 | - | }; |
|
| 795 | - | let identity = |k: &KeyId| { |
|
| 796 | - | p.identity(k) |
|
| 797 | - | .cloned() |
|
| 798 | - | .or_else(|| self.store.get_identity(k).ok()) |
|
| 799 | - | }; |
|
| 800 | - | rule.keys.iter().any(|k| { |
|
| 801 | - | if p.is_revoked(k) { |
|
| 802 | - | return false; |
|
| 803 | - | } |
|
| 804 | - | let mut cur = identity(k); |
|
| 805 | - | for _ in 0..32 { |
|
| 806 | - | let Some(id) = cur else { |
|
| 807 | - | return false; |
|
| 808 | - | }; |
|
| 809 | - | match id.previous { |
|
| 810 | - | Some(prev) if prev == *signer => return at_ms < id.created, |
|
| 811 | - | Some(prev) if !p.is_revoked(&prev) => cur = identity(&prev), |
|
| 812 | - | _ => return false, |
|
| 813 | - | } |
|
| 814 | - | } |
|
| 815 | - | false |
|
| 816 | - | }) |
|
| 817 | - | } |
|
| 818 | - | ||
| 819 | 546 | fn check_ref_attest( |
|
| 820 | 547 | &self, |
|
| 821 | 548 | sa: &SignedAttest, |
|
| … | 3 unchanged lines |
||
| 825 | 552 | last_serial: Option<u64>, |
|
| 826 | 553 | allow_equal: bool, |
|
| 827 | 554 | ) -> std::result::Result<u64, String> { |
|
| 828 | - | let serial = |
|
| 829 | - | self.check_ref_attest_body(sa, name, old_state, new_state, last_serial, allow_equal)?; |
|
| 830 | - | self.verify_attest_signature(sa)?; |
|
| 831 | - | Ok(serial) |
|
| 832 | - | } |
|
| 833 | - | ||
| 834 | - | /// What [`Repo::check_ref_attest`] checks short of the signature: the |
|
| 835 | - | /// attestation describes this update of this repository with a serial |
|
| 836 | - | /// past the last seen. |
|
| 837 | - | fn check_ref_attest_body( |
|
| 838 | - | &self, |
|
| 839 | - | sa: &SignedAttest, |
|
| 840 | - | name: &str, |
|
| 841 | - | old_state: &ObjectRef, |
|
| 842 | - | new_state: &ObjectRef, |
|
| 843 | - | last_serial: Option<u64>, |
|
| 844 | - | allow_equal: bool, |
|
| 845 | - | ) -> std::result::Result<u64, String> { |
|
| 846 | 555 | let AttestBody::Ref { |
|
| 847 | 556 | name: n, |
|
| 848 | 557 | old_state: o, |
|
| … | 16 unchanged lines |
||
| 865 | 574 | )); |
|
| 866 | 575 | } |
|
| 867 | 576 | } |
|
| 868 | - | Ok(*serial) |
|
| 869 | - | } |
|
| 870 | - | ||
| 871 | - | /// An attestation's first signature verifies with its signer's |
|
| 872 | - | /// identity, which must be stored here. |
|
| 873 | - | fn verify_attest_signature(&self, sa: &SignedAttest) -> std::result::Result<(), String> { |
|
| 874 | 577 | let r = sa.attest.hash(); |
|
| 875 | 578 | let sig = sa.sigs.first().ok_or("attestation carries no signature")?; |
|
| 876 | 579 | let id = self |
|
| 877 | 580 | .store |
|
| 878 | 581 | .get_identity(&sig.key) |
|
| 879 | 582 | .map_err(|_| format!("unknown key {}", sig.key.short(12)))?; |
|
| 880 | - | verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string()) |
|
| 583 | + | verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?; |
|
| 584 | + | Ok(*serial) |
|
| 881 | 585 | } |
|
| 882 | 586 | ||
| 883 | 587 | /// Accept a push (spec §16.4). Without a trust policy every repository |
|
| … | 23 unchanged lines |
||
| 907 | 611 | policy: Option<SignedPolicy>, |
|
| 908 | 612 | tags: Vec<crate::protocol::TagRef>, |
|
| 909 | 613 | ) -> Result<PushReport> { |
|
| 910 | - | // Updates are checked against the head as it is now, not as this |
|
| 911 | - | // handle first read it (oxygenhub#249): another push may have |
|
| 912 | - | // landed since. |
|
| 913 | - | self.refresh()?; |
|
| 914 | 614 | let mut report = PushReport::default(); |
|
| 915 | 615 | let results = &mut report.results; |
|
| 916 | 616 | if let Some(sp) = policy { |
|
| … | 86 unchanged lines |
||
| 1003 | 703 | } |
|
| 1004 | 704 | for t in &tags { |
|
| 1005 | 705 | 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 | - | } |
|
| 1040 | 706 | let new = !view.tags.contains_key(&t.name); |
|
| 1041 | - | match self.adopt_tag(&mut view, t, policy.as_ref(), &mut TagCheck::Received) { |
|
| 707 | + | match self.adopt_tag(&mut view, t, policy.as_ref()) { |
|
| 1042 | 708 | Ok(()) => { |
|
| 1043 | 709 | // A tag new here is an update too, so a hub can start |
|
| 1044 | 710 | // its pipeline's tag jobs (oxygenflow#24); one it |
|
| … | 16 unchanged lines |
||
| 1061 | 727 | Err(e) => results.push(RefResult { |
|
| 1062 | 728 | name, |
|
| 1063 | 729 | ok: false, |
|
| 1064 | - | reason: e.reason, |
|
| 730 | + | reason: e, |
|
| 1065 | 731 | }), |
|
| 1066 | 732 | } |
|
| 1067 | 733 | } |
|
| … | 29 unchanged lines |
||
| 1097 | 763 | let r = self.store.put_attest(&sa.attest).map_err(|e| store(&e))?; |
|
| 1098 | 764 | for sig in &sa.sigs { |
|
| 1099 | 765 | self.store.put_signature(&r, sig).map_err(|e| store(&e))?; |
|
| 1100 | - | } |
|
| 1101 | - | self.set_ref_attest(name, &r).map_err(|e| store(&e))?; |
|
| 1102 | - | // Signed by a short-lived key: a host records when it accepted it. |
|
| 1103 | - | let certified = sa |
|
| 1104 | - | .sigs |
|
| 1105 | - | .first() |
|
| 1106 | - | .is_some_and(|s| self.store.certificate_of(&s.key).ok().flatten().is_some()); |
|
| 1107 | - | if certified { |
|
| 1108 | - | self.record_acceptance(&r).map_err(|e| store(&e))?; |
|
| 1109 | 766 | } |
|
| 1110 | - | Ok(()) |
|
| 1111 | - | } |
|
| 1112 | - | ||
| 1113 | - | /// Record that this host accepted attestation `r` now, signed with its |
|
| 1114 | - | /// acceptor key (spec §14.7); nothing without one. |
|
| 1115 | - | pub(crate) fn record_acceptance(&self, r: &ObjectRef) -> Result<()> { |
|
| 1116 | - | let Some(acceptor) = &self.acceptor else { |
|
| 1117 | - | return Ok(()); |
|
| 1118 | - | }; |
|
| 1119 | - | let (id, sk) = (&acceptor.0, &acceptor.1); |
|
| 1120 | - | let attest = Attest { |
|
| 1121 | - | repo: self.id()?, |
|
| 1122 | - | hlc: Hlc::now(), |
|
| 1123 | - | body: AttestBody::Accepted { attest: *r }, |
|
| 1124 | - | }; |
|
| 1125 | - | let sig = sk.sign(Domain::Attest, &id.hash(), &attest.hash()); |
|
| 1126 | - | self.store.put_identity(id)?; |
|
| 1127 | - | let a = self.store.put_attest(&attest)?; |
|
| 1128 | - | self.store.put_signature(&a, &sig)?; |
|
| 1129 | - | self.store.index_add_pub("accepted", r, &a)?; |
|
| 1130 | - | Ok(()) |
|
| 1131 | - | } |
|
| 1132 | - | ||
| 1133 | - | /// The host's acceptance of attestation `r`, if one was recorded. |
|
| 1134 | - | pub fn acceptance_of(&self, r: &ObjectRef) -> Result<Option<SignedAttest>> { |
|
| 1135 | - | let Some(a) = self.store.index_get_pub("accepted", r)?.into_iter().next() else { |
|
| 1136 | - | return Ok(None); |
|
| 1137 | - | }; |
|
| 1138 | - | Ok(Some(SignedAttest { |
|
| 1139 | - | attest: self.store.get_attest(&a)?, |
|
| 1140 | - | sigs: self.store.signatures(&a)?, |
|
| 1141 | - | })) |
|
| 1142 | - | } |
|
| 1143 | - | ||
| 1144 | - | /// When a certified signer's attestation `sa` is checked on a fetch: the |
|
| 1145 | - | /// host's acceptance time, if `accepted` is signed by the issuer of the |
|
| 1146 | - | /// signer's certificate and accepts `sa`; else the attestation's own |
|
| 1147 | - | /// time, which the signer chose. |
|
| 1148 | - | pub(crate) fn certified_time( |
|
| 1149 | - | &self, |
|
| 1150 | - | signer: &KeyId, |
|
| 1151 | - | sa: &SignedAttest, |
|
| 1152 | - | accepted: Option<&SignedAttest>, |
|
| 1153 | - | ) -> u64 { |
|
| 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()?; |
|
| 1169 | - | let AttestBody::Accepted { attest } = &acc.attest.body else { |
|
| 1170 | - | return None; |
|
| 1171 | - | }; |
|
| 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)?; |
|
| 1186 | - | } |
|
| 1187 | - | self.store.index_add_pub("accepted", r, &a)?; |
|
| 1188 | - | Ok(()) |
|
| 1189 | - | } |
|
| 1190 | - | ||
| 1191 | - | /// A fetched bookmark's attestation by a signer the policy does not |
|
| 1192 | - | /// list itself: a delegate or a short-lived key, whose authority |
|
| 1193 | - | /// depends on when it signed (spec §14.5, §14.8). Checked at the |
|
| 1194 | - | /// attestation's integrated time when the host's log gives one; else |
|
| 1195 | - | /// as before, at the host's acceptance for a certified signer, when it |
|
| 1196 | - | /// countersigned one, or at the attestation's own time, and said so |
|
| 1197 | - | /// when the host keeps a log. Failing at the integrated time but not |
|
| 1198 | - | /// as before is a note while logs are new; a large gap between the |
|
| 1199 | - | /// claimed and the integrated time is a note. |
|
| 1200 | - | #[allow(clippy::too_many_arguments)] |
|
| 1201 | - | pub(crate) fn authorize_in_time( |
|
| 1202 | - | &self, |
|
| 1203 | - | p: &crate::policy::Policy, |
|
| 1204 | - | remote_name: &str, |
|
| 1205 | - | r: &crate::protocol::RemoteRef, |
|
| 1206 | - | sa: &SignedAttest, |
|
| 1207 | - | signer: &KeyId, |
|
| 1208 | - | log: &super::transparency::LogCheck, |
|
| 1209 | - | notes: &mut Vec<String>, |
|
| 1210 | - | ) -> Result<()> { |
|
| 1211 | - | use super::transparency::{gap_note, judge, Timed}; |
|
| 1212 | - | let own = sa.attest.hlc.wall_ms; |
|
| 1213 | - | let at = self.certified_time(signer, sa, r.accepted.as_ref()); |
|
| 1214 | - | let time = log.time_of(&r.name); |
|
| 1215 | - | let check = |t_delegate: u64, t_cert: u64| { |
|
| 1216 | - | self.delegate_authorized(p, signer, &r.name, t_delegate) |
|
| 1217 | - | || self.certified_authorized(p, signer, &r.name, Scope::REFS, t_cert) |
|
| 1218 | - | }; |
|
| 1219 | - | let before = if at != own { |
|
| 1220 | - | "the host's acceptance" |
|
| 1221 | - | } else { |
|
| 1222 | - | "the time it claims" |
|
| 1223 | - | }; |
|
| 1224 | - | let what = format!("{remote_name}/{}", r.name); |
|
| 1225 | - | match judge(time.as_ref().ok().map(|i| i.ms()), |t| check(t, t), || check(own, at)) { |
|
| 1226 | - | Timed::Refused => { |
|
| 1227 | - | return fail(format!( |
|
| 1228 | - | "{what}: attested by {}, which the policy does not authorize for this bookmark; a policy owner can grant it with `oxy policy grant`", |
|
| 1229 | - | signer.short(12) |
|
| 1230 | - | )) |
|
| 1231 | - | } |
|
| 1232 | - | Timed::Integrated => {} |
|
| 1233 | - | Timed::Disagrees => notes.push(format!( |
|
| 1234 | - | "{what}: {}'s authority had lapsed when witness {} first saw its attestation, though not at {before}; accepted while transparency logs are new", |
|
| 1235 | - | signer.short(12), |
|
| 1236 | - | time.as_ref().map(|i| i.witness.as_str()).unwrap_or("?") |
|
| 1237 | - | )), |
|
| 1238 | - | Timed::Fallback if log.kept => notes.push(format!( |
|
| 1239 | - | "{what}: {} checked at {before}, not at a witnessed time: {}", |
|
| 1240 | - | signer.short(12), |
|
| 1241 | - | time.as_ref().err().map(String::as_str).unwrap_or("none") |
|
| 1242 | - | )), |
|
| 1243 | - | Timed::Fallback => {} |
|
| 1244 | - | } |
|
| 1245 | - | if let Some(n) = time.ok().and_then(|i| gap_note(own, i)) { |
|
| 1246 | - | notes.push(format!("{what}'s attestation {n}")); |
|
| 1247 | - | } |
|
| 1248 | - | Ok(()) |
|
| 767 | + | self.set_ref_attest(name, &r).map_err(|e| store(&e)) |
|
| 1249 | 768 | } |
|
| 1250 | 769 | ||
| 1251 | 770 | /// A deletion (spec §16.4): `new_state` is zero. The bookmark must exist |
|
| … | 33 unchanged lines |
||
| 1285 | 804 | .first() |
|
| 1286 | 805 | .map(|s| s.key) |
|
| 1287 | 806 | .ok_or("attestation carries no signature")?; |
|
| 1288 | - | // Received now: the receiver's own clock is the trusted time. |
|
| 1289 | - | let now = Hlc::now().wall_ms; |
|
| 1290 | 807 | if !p.authorizes(&signer, &u.name) |
|
| 1291 | 808 | && !self.delegate_authorized(p, &signer, &u.name, sa.attest.hlc.wall_ms) |
|
| 1292 | - | && !self.certified_authorized(p, &signer, &u.name, Scope::REFS, now) |
|
| 1293 | 809 | { |
|
| 1294 | 810 | return Err(format!( |
|
| 1295 | 811 | "key {} may not delete {:?} under this repository's policy", |
|
| … | 68 unchanged lines |
||
| 1364 | 880 | .first() |
|
| 1365 | 881 | .map(|s| s.key) |
|
| 1366 | 882 | .ok_or("attestation carries no signature")?; |
|
| 1367 | - | // Received now: the receiver's own clock is the trusted time. |
|
| 1368 | - | let now = Hlc::now().wall_ms; |
|
| 1369 | 883 | if !p.authorizes(&signer, &u.name) |
|
| 1370 | 884 | && !self.delegate_authorized(p, &signer, &u.name, sa.attest.hlc.wall_ms) |
|
| 1371 | - | && !self.certified_authorized(p, &signer, &u.name, Scope::REFS, now) |
|
| 1372 | 885 | { |
|
| 1373 | 886 | return Err(format!( |
|
| 1374 | 887 | "key {} may not update {:?} under this repository's policy", |
|
| … | 72 unchanged lines |
||
| 1447 | 960 | token: self.config.remote_tokens.get(name).cloned(), |
|
| 1448 | 961 | ca: self.config.remote_cas.get(name).map(PathBuf::from), |
|
| 1449 | 962 | cache_dir: Some(self.oxy.join("remotes").join(name).join("strata")), |
|
| 1450 | - | ..RemoteOptions::default() |
|
| 1451 | 963 | }; |
|
| 1452 | 964 | self.fill_credentials(&url, &mut options); |
|
| 1453 | 965 | options |
|
| … | 13 unchanged lines |
||
| 1467 | 979 | /// Fill in what `options` lacks for `url`: `$OXY_TOKEN` and `$OXY_CA`, |
|
| 1468 | 980 | /// then the user's credential store for its hub, then a remote on the |
|
| 1469 | 981 | /// same hub in an enclosing checkout (a subrepo borrows its parent's). |
|
| 1470 | - | /// When the token is not this remote's own or the environment's, the |
|
| 1471 | - | /// other tokens enclosing checkouts hold for the hub are kept to try |
|
| 1472 | - | /// if it is refused: a stale stored login must not hide a parent's |
|
| 1473 | - | /// good token (oxygen#112). |
|
| 1474 | 982 | fn fill_credentials(&self, url: &str, options: &mut RemoteOptions) { |
|
| 1475 | 983 | if options.token.is_some() && options.ca.is_some() { |
|
| 1476 | 984 | return; |
|
| 1477 | 985 | } |
|
| 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; |
|
| 1481 | 986 | 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 | - | } |
|
| 1495 | 987 | options.token = options.token.take().or(stored.token); |
|
| 1496 | 988 | options.ca = options.ca.take().or(stored.ca); |
|
| 1497 | - | let enclosing = self.enclosing_credentials(url); |
|
| 1498 | - | if options.token.is_none() { |
|
| 1499 | - | options.token = enclosing.iter().find_map(|(t, _)| t.clone()); |
|
| 1500 | - | } |
|
| 1501 | - | if options.ca.is_none() { |
|
| 1502 | - | options.ca = enclosing.iter().find_map(|(_, c)| c.clone()); |
|
| 1503 | - | } |
|
| 1504 | - | if !explicit { |
|
| 1505 | - | for t in enclosing.into_iter().filter_map(|(t, _)| t) { |
|
| 1506 | - | if options.token.as_ref() != Some(&t) && !options.fallback_tokens.contains(&t) { |
|
| 1507 | - | options.fallback_tokens.push(t); |
|
| 1508 | - | } |
|
| 1509 | - | } |
|
| 1510 | - | } |
|
| 1511 | - | } |
|
| 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; |
|
| 989 | + | if options.token.is_some() && options.ca.is_some() { |
|
| 990 | + | return; |
|
| 1519 | 991 | } |
|
| 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 | - | ||
| 1531 | - | /// The tokens and CAs of remotes on `url`'s hub in enclosing |
|
| 1532 | - | /// checkouts, nearest first. |
|
| 1533 | - | fn enclosing_credentials(&self, url: &str) -> Vec<(Option<String>, Option<PathBuf>)> { |
|
| 1534 | - | let mut out = Vec::new(); |
|
| 1535 | 992 | let Some(hub) = crate::credentials::hub_of(url) else { |
|
| 1536 | - | return out; |
|
| 993 | + | return; |
|
| 1537 | 994 | }; |
|
| 1538 | 995 | let mut at = self.root.parent(); |
|
| 1539 | 996 | while let Some(dir) = at { |
|
| … | 10 unchanged lines |
||
| 1550 | 1007 | if crate::credentials::hub_of(remote_url).as_deref() != Some(hub.as_str()) { |
|
| 1551 | 1008 | continue; |
|
| 1552 | 1009 | } |
|
| 1553 | - | out.push(( |
|
| 1554 | - | cfg.get(&format!("remote.{name}.token")).cloned(), |
|
| 1555 | - | cfg.get(&format!("remote.{name}.ca")).map(PathBuf::from), |
|
| 1556 | - | )); |
|
| 1010 | + | if options.token.is_none() { |
|
| 1011 | + | options.token = cfg.get(&format!("remote.{name}.token")).cloned(); |
|
| 1012 | + | } |
|
| 1013 | + | if options.ca.is_none() { |
|
| 1014 | + | options.ca = cfg.get(&format!("remote.{name}.ca")).map(PathBuf::from); |
|
| 1015 | + | } |
|
| 1016 | + | } |
|
| 1017 | + | if options.token.is_some() && options.ca.is_some() { |
|
| 1018 | + | return; |
|
| 1557 | 1019 | } |
|
| 1558 | 1020 | at = dir.parent(); |
|
| 1559 | 1021 | } |
|
| 1560 | - | out |
|
| 1561 | 1022 | } |
|
| 1562 | 1023 | ||
| 1563 | 1024 | /// Push seal attestations on their own (hub spec §7.4): a bundle with |
|
| … | 51 unchanged lines |
||
| 1615 | 1076 | } |
|
| 1616 | 1077 | } |
|
| 1617 | 1078 | ||
| 1618 | - | pub(crate) fn open_named_remote(&self, name: &str) -> Result<Box<dyn crate::protocol::Remote>> { |
|
| 1079 | + | fn open_named_remote(&self, name: &str) -> Result<Box<dyn crate::protocol::Remote>> { |
|
| 1619 | 1080 | open_remote(&self.remote_url(name)?, &self.remote_options(name)) |
|
| 1620 | 1081 | } |
|
| 1621 | 1082 | ||
| … | 112 unchanged lines |
||
| 1734 | 1195 | pub fn fetch(&self, remote_name: &str) -> Result<FetchReport> { |
|
| 1735 | 1196 | let remote = self.open_named_remote(remote_name)?; |
|
| 1736 | 1197 | self.check_remote_id(remote.as_ref(), remote_name, false)?; |
|
| 1737 | - | let resp = { |
|
| 1738 | - | let _t = crate::timing::phase("fetch: refs"); |
|
| 1739 | - | remote.refs()? |
|
| 1740 | - | }; |
|
| 1741 | - | // The host's transparency log (spec §14.8): a fork fails here. |
|
| 1742 | - | let log_phase = crate::timing::phase("fetch: transparency log"); |
|
| 1743 | - | let log = self.check_log( |
|
| 1744 | - | &self.remote_url(remote_name)?, |
|
| 1745 | - | self.config.remote_cas.get(remote_name).map(Path::new), |
|
| 1746 | - | &resp, |
|
| 1747 | - | )?; |
|
| 1748 | - | drop(log_phase); |
|
| 1749 | - | let mut log_notes = log.notes.clone(); |
|
| 1198 | + | let resp = remote.refs()?; |
|
| 1750 | 1199 | // Trust policy (spec §14.5). Adopt the remote's policy when it is newer |
|
| 1751 | 1200 | // and signed by roots we trust, or on first contact. A remote that |
|
| 1752 | 1201 | // once presented a policy may never present none or an older one: |
|
| … | 44 unchanged lines |
||
| 1797 | 1246 | let mut view = op.view.clone(); |
|
| 1798 | 1247 | let have = self.known_heads(&view)?; |
|
| 1799 | 1248 | let mut report = Vec::new(); |
|
| 1800 | - | let mut skipped = Vec::new(); |
|
| 1801 | 1249 | let max_blob = self.config.max_blob; |
|
| 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 { |
|
| 1250 | + | for r in resp.refs { |
|
| 1833 | 1251 | let needed = { |
|
| 1834 | 1252 | let _t = crate::timing::phase("fetch: negotiate"); |
|
| 1835 | - | remote.negotiate(&have, &wanted)? |
|
| 1253 | + | remote.negotiate(&have, &r.heads)? |
|
| 1836 | 1254 | }; |
|
| 1837 | 1255 | let bundle = { |
|
| 1838 | 1256 | let _t = crate::timing::phase("fetch: bundle"); |
|
| 1839 | 1257 | remote.fetch(&needed, max_blob)? |
|
| 1840 | 1258 | }; |
|
| 1841 | - | // Changes are not individually re-verified here: each bookmark's |
|
| 1259 | + | // Changes are not individually re-verified here: the bookmark's |
|
| 1842 | 1260 | // attestation (checked below) binds its whole state, and the |
|
| 1843 | 1261 | // receiver of the push verified the added changes at that time. |
|
| 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 { |
|
| 1262 | + | let imported = { |
|
| 1263 | + | let _t = crate::timing::phase("fetch: import"); |
|
| 1850 | 1264 | self.import_bundle(&bundle, false)? |
|
| 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 | - | } |
|
| 1265 | + | }; |
|
| 1266 | + | if max_blob.is_some() { |
|
| 1267 | + | self.register_missing_blobs(&imported, remote_name)?; |
|
| 1887 | 1268 | } |
|
| 1888 | - | seen.len() |
|
| 1889 | - | }; |
|
| 1890 | - | let checks = crate::timing::phase("fetch: check each bookmark"); |
|
| 1891 | - | for r in resp.refs { |
|
| 1892 | 1269 | for h in &r.heads { |
|
| 1893 | - | if !self.store.has(h) && !boundary.contains(h) { |
|
| 1270 | + | if !self.store.has(h) { |
|
| 1894 | 1271 | return fail(format!("remote did not send head {}", h.short(12))); |
|
| 1895 | 1272 | } |
|
| 1896 | 1273 | } |
|
| … | 16 unchanged lines |
||
| 1913 | 1290 | .first() |
|
| 1914 | 1291 | .map(|s| s.key) |
|
| 1915 | 1292 | .ok_or_else(|| RepoError::Msg("attestation carries no signature".into()))?; |
|
| 1916 | - | if !p.authorizes(&signer, &r.name) { |
|
| 1917 | - | self.authorize_in_time(p, remote_name, &r, sa, &signer, &log, &mut log_notes)?; |
|
| 1293 | + | if !p.authorizes(&signer, &r.name) |
|
| 1294 | + | && !self.delegate_authorized(p, &signer, &r.name, sa.attest.hlc.wall_ms) |
|
| 1295 | + | { |
|
| 1296 | + | return fail(format!( |
|
| 1297 | + | "{remote_name}/{}: attested by {}, which the policy does not authorize for this bookmark; a policy owner can grant it with `oxy policy grant`", |
|
| 1298 | + | r.name, |
|
| 1299 | + | signer.short(12) |
|
| 1300 | + | )); |
|
| 1918 | 1301 | } |
|
| 1919 | 1302 | } |
|
| 1920 | 1303 | if let Some(sa) = &r.attestation { |
|
| … | 7 unchanged lines |
||
| 1928 | 1311 | }; |
|
| 1929 | 1312 | let (old_state, new_state) = (*old_state, *new_state); |
|
| 1930 | 1313 | let serial = self |
|
| 1931 | - | .check_ref_attest_body( |
|
| 1314 | + | .check_ref_attest( |
|
| 1932 | 1315 | sa, |
|
| 1933 | 1316 | &r.name, |
|
| 1934 | 1317 | &old_state, |
|
| … | 9 unchanged lines |
||
| 1944 | 1327 | "attestation for {}/{} does not cover its current state", |
|
| 1945 | 1328 | remote_name, r.name |
|
| 1946 | 1329 | )); |
|
| 1947 | - | } |
|
| 1948 | - | // A signature this client cannot verify (its signer unknown |
|
| 1949 | - | // here, as from a host that did not send the identity, or |
|
| 1950 | - | // bad) fails that bookmark alone: it stays as it was and |
|
| 1951 | - | // the rest are fetched (oxygenhub#266). The current |
|
| 1952 | - | // bookmark, which a pull would take, still fails the fetch. |
|
| 1953 | - | // A policy's requirements were checked above and still fail. |
|
| 1954 | - | if let Err(e) = self.verify_attest_signature(sa) { |
|
| 1955 | - | let why = format!("attestation for {remote_name}/{}: {e}", r.name); |
|
| 1956 | - | if r.name == self.config.bookmark { |
|
| 1957 | - | return fail(why); |
|
| 1958 | - | } |
|
| 1959 | - | skipped.push((r.name, why)); |
|
| 1960 | - | continue; |
|
| 1961 | 1330 | } |
|
| 1962 | 1331 | let ar = self.store.put_attest(&sa.attest)?; |
|
| 1963 | 1332 | for sig in &sa.sigs { |
|
| … | 5 unchanged lines |
||
| 1969 | 1338 | .entry(remote_name.to_string()) |
|
| 1970 | 1339 | .or_default() |
|
| 1971 | 1340 | .insert(r.name.clone(), state); |
|
| 1972 | - | let n = new_in(&r.heads); |
|
| 1973 | - | report.push((r.name, r.heads, n)); |
|
| 1341 | + | report.push((r.name, r.heads, imported.len())); |
|
| 1974 | 1342 | } |
|
| 1975 | - | drop(checks); |
|
| 1976 | - | let tags_phase = crate::timing::phase("fetch: tags"); |
|
| 1977 | 1343 | let mut new_tags = 0; |
|
| 1978 | 1344 | for t in &resp.tags { |
|
| 1979 | 1345 | if view.tags.contains_key(&t.name) { |
|
| 1980 | 1346 | continue; |
|
| 1981 | 1347 | } |
|
| 1982 | - | // Withdrawn here: the name is not used again. |
|
| 1983 | - | if self.tag_withdrawal(&t.name)?.is_some() { |
|
| 1984 | - | continue; |
|
| 1985 | - | } |
|
| 1986 | 1348 | // The tag's changes must be here: fetch them if the bookmarks did not bring them. |
|
| 1987 | 1349 | let missing: Vec<ChangeHash> = t |
|
| 1988 | 1350 | .heads |
|
| … | 7 unchanged lines |
||
| 1996 | 1358 | self.import_bundle(&bundle, false)?; |
|
| 1997 | 1359 | } |
|
| 1998 | 1360 | self.store.put_state(&StateObject::new(t.heads.clone()))?; |
|
| 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 | - | } |
|
| 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 | - | } |
|
| 1361 | + | self.adopt_tag(&mut view, t, policy.as_ref()) |
|
| 1362 | + | .map_err(|e| RepoError::Msg(format!("{remote_name} {}: {e}", t.name)))?; |
|
| 1363 | + | new_tags += 1; |
|
| 2036 | 1364 | } |
|
| 2037 | 1365 | if view != op.view { |
|
| 2038 | 1366 | self.record_op(&[op_ref], &format!("oxy fetch {remote_name}"), view)?; |
|
| … | 14 unchanged lines |
||
| 2053 | 1381 | refs: report, |
|
| 2054 | 1382 | unverified, |
|
| 2055 | 1383 | new_tags, |
|
| 2056 | - | withdrawn_tags, |
|
| 2057 | - | log_notes, |
|
| 2058 | - | skipped, |
|
| 2059 | 1384 | }) |
|
| 2060 | 1385 | } |
|
| 2061 | 1386 | ||
| … | 15 unchanged lines |
||
| 2077 | 1402 | if r.heads.is_empty() { |
|
| 2078 | 1403 | return Ok(0); |
|
| 2079 | 1404 | } |
|
| 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); |
|
| 1405 | + | // Base: the remote's checkpoint of exactly these heads. |
|
| 1406 | + | let cp_bundle = remote.checkpoint(&r.heads)?; |
|
| 1407 | + | let mut cp_ref = None; |
|
| 1408 | + | for (ty, body) in &cp_bundle.objects { |
|
| 1409 | + | let put = self.store.put(*ty, body)?; |
|
| 1410 | + | if *ty == ObjectType::Checkpoint { |
|
| 1411 | + | cp_ref = Some(put); |
|
| 1412 | + | } |
|
| 1413 | + | } |
|
| 2104 | 1414 | let Some(cp_ref) = cp_ref else { |
|
| 2105 | 1415 | return fail(format!("{remote_name} sent no checkpoint")); |
|
| 2106 | 1416 | }; |
|
| 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 | - | } |
|
| 2140 | 1417 | self.install_checkpoint(&bookmark, &cp_ref)?; |
|
| 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(); |
|
| 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 | - | }; |
|
| 1418 | + | // History window. |
|
| 1419 | + | let wanted = remote.shallow(&r.heads, depth)?; |
|
| 1420 | + | let bundle = remote.fetch(&wanted, self.config.max_blob)?; |
|
| 1421 | + | let imported = self.import_bundle_shallow(&bundle, false)?; |
|
| 2189 | 1422 | if self.config.max_blob.is_some() { |
|
| 2190 | 1423 | self.register_missing_blobs(&imported, remote_name)?; |
|
| 2191 | 1424 | } |
|
| … | 26 unchanged lines |
||
| 2218 | 1451 | /// remote's: no merge change, conflicts become reified (spec §9.4). |
|
| 2219 | 1452 | pub fn pull(&self, remote_name: &str) -> Result<PullReport> { |
|
| 2220 | 1453 | let _wc = self.lock_working_copy()?; |
|
| 2221 | - | let skipped = self.fetch(remote_name)?.skipped; |
|
| 1454 | + | self.fetch(remote_name)?; |
|
| 2222 | 1455 | let (op_ref, op) = self.head_op()?; |
|
| 2223 | 1456 | let current = self.config.bookmark.clone(); |
|
| 2224 | 1457 | let mut local_current = self.heads_of(&op.view, ¤t)?; |
|
| … | 60 unchanged lines |
||
| 2285 | 1518 | changed, |
|
| 2286 | 1519 | checked_out, |
|
| 2287 | 1520 | others, |
|
| 2288 | - | skipped, |
|
| 2289 | 1521 | }) |
|
| 2290 | 1522 | } |
|
| 2291 | 1523 | ||
| … | 59 unchanged lines |
||
| 2351 | 1583 | Ok(self |
|
| 2352 | 1584 | .checkout_carry(heads, bookmark, &format!("{command} (checkout --carry)")) |
|
| 2353 | 1585 | .is_ok()) |
|
| 2354 | - | } |
|
| 2355 | - | } |
|
| 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 | 1586 | } |
|
| 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 | 1587 | } |
|
| 2419 | 1588 | ||
| 2420 | 1589 | /// Push the current bookmark (spec §16.3), sealing this author's unsigned |
|
| … | 2 unchanged lines |
||
| 2423 | 1592 | /// state, attested when we have an identity. The local bookmark, if |
|
| 2424 | 1593 | /// any, is kept; only the remote-tracking entry goes. |
|
| 2425 | 1594 | 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 | - | } |
|
| 2429 | 1595 | let remote = self.open_named_remote(remote_name)?; |
|
| 2430 | 1596 | self.check_remote_id(remote.as_ref(), remote_name, true)?; |
|
| 2431 | 1597 | let resp = remote.refs()?; |
|
| … | 77 unchanged lines |
||
| 2509 | 1675 | Ok(()) |
|
| 2510 | 1676 | } |
|
| 2511 | 1677 | ||
| 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 | - | ||
| 2588 | 1678 | /// `changes`, in dependency order, cut into batches of about |
|
| 2589 | 1679 | /// `push.batch-bytes` by what each change and its texts and blobs weigh. |
|
| 2590 | 1680 | /// There is always one, perhaps empty. |
|
| … | 48 unchanged lines |
||
| 2639 | 1729 | ||
| 2640 | 1730 | /// `push_bookmark`, saying how a long push is going: one line per |
|
| 2641 | 1731 | /// 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 | - | ||
| 2660 | 1732 | pub fn push_bookmark_with( |
|
| 2661 | 1733 | &self, |
|
| 2662 | 1734 | remote_name: &str, |
|
| … | 37 unchanged lines |
||
| 2700 | 1772 | if remote_has_policy && self.secret_key()?.is_none() { |
|
| 2701 | 1773 | return fail(format!("{remote_name} enforces a trust policy; pushing needs an identity (`oxy identity create`)")); |
|
| 2702 | 1774 | } |
|
| 2703 | - | let remote_tags: BTreeSet<String> = resp |
|
| 2704 | - | .tags |
|
| 2705 | - | .iter() |
|
| 2706 | - | .chain(&resp.withdrawn) |
|
| 2707 | - | .map(|t| t.name.clone()) |
|
| 2708 | - | .collect(); |
|
| 1775 | + | let remote_tags: BTreeSet<String> = resp.tags.iter().map(|t| t.name.clone()).collect(); |
|
| 2709 | 1776 | let tags_to_send: Vec<crate::protocol::TagRef> = self |
|
| 2710 | 1777 | .tag_refs()? |
|
| 2711 | 1778 | .into_iter() |
|
| 2712 | 1779 | .filter(|t| !remote_tags.contains(&t.name)) |
|
| 2713 | 1780 | .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(); |
|
| 2725 | 1781 | let refs = resp.refs; |
|
| 2726 | 1782 | let empty = self.store.put_state(&StateObject::new(vec![]))?; |
|
| 2727 | 1783 | let (remote_state, remote_heads, last_serial) = |
|
| … | 8 unchanged lines |
||
| 2736 | 1792 | // state is unchanged, so the current attestation is always by a key |
|
| 2737 | 1793 | // the current policy authorizes (a revoked key's attestation would |
|
| 2738 | 1794 | // otherwise be refused by every later fetch). |
|
| 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 | - | { |
|
| 1795 | + | if new_state == remote_state && policy_to_send.is_none() && tags_to_send.is_empty() { |
|
| 2744 | 1796 | return Ok(vec![RefResult { |
|
| 2745 | 1797 | name: bookmark, |
|
| 2746 | 1798 | ok: true, |
|
| 2747 | 1799 | reason: "already up to date".into(), |
|
| 2748 | 1800 | }]); |
|
| 2749 | 1801 | } |
|
| 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(); |
|
| 2753 | 1802 | for h in &remote_heads { |
|
| 2754 | - | if !self.store.has(h) && !boundary.contains(h) { |
|
| 1803 | + | if !self.store.has(h) { |
|
| 2755 | 1804 | return fail(format!( |
|
| 2756 | 1805 | "the remote has change {} which you do not; pull first (`oxy pull {remote_name}`), then push again", |
|
| 2757 | 1806 | h.short(12) |
|
| … | 2 unchanged lines |
||
| 2760 | 1809 | } |
|
| 2761 | 1810 | if !force { |
|
| 2762 | 1811 | let closure = self.closure_in_store(&local_heads)?; |
|
| 2763 | - | if let Some(h) = remote_heads |
|
| 2764 | - | .iter() |
|
| 2765 | - | .find(|h| !closure.contains(h) && !boundary.contains(h)) |
|
| 2766 | - | { |
|
| 1812 | + | if let Some(h) = remote_heads.iter().find(|h| !closure.contains(h)) { |
|
| 2767 | 1813 | return fail(format!("not a fast-forward: remote head {} is not in your state; pull first or use --force", h.short(12))); |
|
| 2768 | 1814 | } |
|
| 2769 | 1815 | } |
|
| 2770 | - | let to_send = self.negotiate(&self.remote_holds(&refs, &remote_heads), &local_heads)?; |
|
| 1816 | + | let to_send = self.negotiate(&remote_heads, &local_heads)?; |
|
| 2771 | 1817 | let key = self.secret_key()?.map(|(k, _)| k); |
|
| 2772 | 1818 | if let Some(key) = key { |
|
| 2773 | 1819 | // Seal this author's changes that carry no valid authenticator, |
|
| … | 2 unchanged lines |
||
| 2776 | 1822 | let mut unsigned = Vec::new(); |
|
| 2777 | 1823 | for h in &to_send { |
|
| 2778 | 1824 | let c = self.store.get_change_meta(h)?; |
|
| 2779 | - | if self.seals_for_author(&c.author, &key) |
|
| 1825 | + | if (c.author == key || c.author.is_zero()) |
|
| 2780 | 1826 | && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) |
|
| 2781 | 1827 | { |
|
| 2782 | 1828 | unsigned.push(*h); |
|
| … | 16 unchanged lines |
||
| 2799 | 1845 | batches.len(), |
|
| 2800 | 1846 | batch.len() |
|
| 2801 | 1847 | )); |
|
| 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![])?; |
|
| 1848 | + | let results = remote.push(self.bundle_for(batch)?, vec![], None, vec![])?; |
|
| 2809 | 1849 | match results.iter().find(|r| r.name == OBJECTS_RESULT) { |
|
| 2810 | 1850 | Some(r) if r.ok => {} |
|
| 2811 | 1851 | Some(r) => { |
|
| … | 18 unchanged lines |
||
| 2830 | 1870 | last.len() |
|
| 2831 | 1871 | )); |
|
| 2832 | 1872 | } |
|
| 2833 | - | let mut bundle = self.send_objects_ahead( |
|
| 2834 | - | remote.as_ref(), |
|
| 2835 | - | remote_name, |
|
| 2836 | - | self.bundle_for(last)?, |
|
| 2837 | - | &mut *progress, |
|
| 2838 | - | )?; |
|
| 1873 | + | let mut bundle = self.bundle_for(last)?; |
|
| 2839 | 1874 | bundle.objects.push(( |
|
| 2840 | 1875 | ObjectType::State, |
|
| 2841 | 1876 | StateObject::new(local_heads.clone()).encode(), |
|
| … | 11 unchanged lines |
||
| 2853 | 1888 | })?), |
|
| 2854 | 1889 | None => None, |
|
| 2855 | 1890 | }; |
|
| 2856 | - | // Whoever signed the move, and whom a delegate acts for, travel with |
|
| 2857 | - | // it: a receiver that has never seen a change by this delegate could |
|
| 2858 | - | // not otherwise check its signature (oxygenhub#129). |
|
| 2859 | - | if attestation.is_some() { |
|
| 2860 | - | for k in [self.signing_key(), self.config.author_key] |
|
| 2861 | - | .into_iter() |
|
| 2862 | - | .flatten() |
|
| 2863 | - | { |
|
| 2864 | - | if let Ok(id) = self.identity(&k) { |
|
| 2865 | - | let body = id.encode(); |
|
| 2866 | - | if !bundle |
|
| 2867 | - | .objects |
|
| 2868 | - | .iter() |
|
| 2869 | - | .any(|(t, b)| *t == ObjectType::Identity && *b == body) |
|
| 2870 | - | { |
|
| 2871 | - | bundle.objects.push((ObjectType::Identity, body)); |
|
| 2872 | - | } |
|
| 2873 | - | } |
|
| 2874 | - | } |
|
| 2875 | - | // A short-lived signing key's certificate goes with it (§14.7). |
|
| 2876 | - | let signers: Vec<KeyId> = self.signing_key().into_iter().collect(); |
|
| 2877 | - | self.attach_certificates_for(&mut bundle, &signers)?; |
|
| 2878 | - | } |
|
| 2879 | 1891 | let serial = attestation.as_ref().and_then(|a| match a.attest.body { |
|
| 2880 | 1892 | AttestBody::Ref { serial, .. } => Some(serial), |
|
| 2881 | 1893 | _ => None, |
|
| … | 16 unchanged lines |
||
| 2898 | 1910 | StateObject::new(t.heads.clone()).encode(), |
|
| 2899 | 1911 | )); |
|
| 2900 | 1912 | } |
|
| 2901 | - | // A tag's signer travels with it, as the bookmark's does: a tag made |
|
| 2902 | - | // by `oxy tag create` under a short-lived key was signed by a key the |
|
| 2903 | - | // receiver has never seen, so its identity and certificate go too. |
|
| 2904 | - | let tag_signers: Vec<KeyId> = tags_to_send |
|
| 2905 | - | .iter() |
|
| 2906 | - | .chain(&withdrawals_to_send) |
|
| 2907 | - | .filter_map(|t| t.attestation.as_ref()) |
|
| 2908 | - | .flat_map(|a| a.sigs.iter().map(|s| s.key)) |
|
| 2909 | - | .collect(); |
|
| 2910 | - | for k in &tag_signers { |
|
| 2911 | - | if let Ok(id) = self.identity(k) { |
|
| 2912 | - | let body = id.encode(); |
|
| 2913 | - | if !bundle |
|
| 2914 | - | .objects |
|
| 2915 | - | .iter() |
|
| 2916 | - | .any(|(t, b)| *t == ObjectType::Identity && *b == body) |
|
| 2917 | - | { |
|
| 2918 | - | bundle.objects.push((ObjectType::Identity, body)); |
|
| 2919 | - | } |
|
| 2920 | - | } |
|
| 2921 | - | } |
|
| 2922 | - | if !tag_signers.is_empty() { |
|
| 2923 | - | self.attach_certificates_for(&mut bundle, &tag_signers)?; |
|
| 2924 | - | } |
|
| 2925 | 1913 | if let Some(l) = &policy_to_send { |
|
| 2926 | 1914 | let remote_serial = resp_policy_serial; |
|
| 2927 | 1915 | bundle.policies = self |
|
| … | 15 unchanged lines |
||
| 2943 | 1931 | attestation, |
|
| 2944 | 1932 | }], |
|
| 2945 | 1933 | policy_to_send, |
|
| 2946 | - | tags_to_send |
|
| 2947 | - | .into_iter() |
|
| 2948 | - | .chain(withdrawals_to_send) |
|
| 2949 | - | .collect(), |
|
| 1934 | + | tags_to_send, |
|
| 2950 | 1935 | )?; |
|
| 2951 | 1936 | if let (Some(serial), true) = ( |
|
| 2952 | 1937 | sent_serial, |
|
| … | 52 unchanged lines |
||
| 3005 | 1990 | sparse: &[String], |
|
| 3006 | 1991 | depth: Option<u32>, |
|
| 3007 | 1992 | ) -> Result<Repo> { |
|
| 3008 | - | Ok(Self::clone_reporting(url, dest, max_blob, sparse, depth)?.0) |
|
| 3009 | - | } |
|
| 3010 | - | ||
| 3011 | - | /// Clone as [`Repo::clone_with`] (or [`Repo::clone_shallow`] with a |
|
| 3012 | - | /// `depth`), also returning the bookmarks the fetch left out because |
|
| 3013 | - | /// their attestation could not be verified ([`FetchReport::skipped`]), |
|
| 3014 | - | /// for the caller to warn about. The bookmark checked out is never one |
|
| 3015 | - | /// of them: that fails the clone. |
|
| 3016 | - | pub fn clone_reporting( |
|
| 3017 | - | url: &str, |
|
| 3018 | - | dest: &Path, |
|
| 3019 | - | max_blob: Option<u64>, |
|
| 3020 | - | sparse: &[String], |
|
| 3021 | - | depth: Option<u32>, |
|
| 3022 | - | ) -> Result<(Repo, Vec<(String, String)>)> { |
|
| 3023 | 1993 | let options = crate::credentials::Credentials::user().options_for(url); |
|
| 3024 | 1994 | Self::clone_using(url, dest, max_blob, sparse, depth, &options) |
|
| 3025 | 1995 | } |
|
| … | 3 unchanged lines |
||
| 3029 | 1999 | /// subrepo: it finds the same ones again through its parent. |
|
| 3030 | 2000 | pub(crate) fn clone_as_subrepo(&self, url: &str, dest: &Path) -> Result<Repo> { |
|
| 3031 | 2001 | let options = self.credentials_for(url); |
|
| 3032 | - | Ok(Self::clone_using(url, dest, None, &[], None, &options)?.0) |
|
| 2002 | + | Self::clone_using(url, dest, None, &[], None, &options) |
|
| 3033 | 2003 | } |
|
| 3034 | 2004 | ||
| 3035 | 2005 | /// The name of a remote of this repository on the same hub (scheme, |
|
| … | 45 unchanged lines |
||
| 3081 | 2051 | sparse: &[String], |
|
| 3082 | 2052 | depth: Option<u32>, |
|
| 3083 | 2053 | options: &RemoteOptions, |
|
| 3084 | - | ) -> Result<(Repo, Vec<(String, String)>)> { |
|
| 2054 | + | ) -> Result<Repo> { |
|
| 3085 | 2055 | let remote = open_remote(url, options)?; |
|
| 3086 | 2056 | let id = remote.repo_id()?; |
|
| 3087 | 2057 | if id == crate::protocol::UNBOUND_ID { |
|
| … | 13 unchanged lines |
||
| 3101 | 2071 | repo.config.set("filter.paths", &sparse.join(","))?; |
|
| 3102 | 2072 | } |
|
| 3103 | 2073 | repo.save_config()?; |
|
| 3104 | - | let skipped = if let Some(depth) = depth { |
|
| 2074 | + | if let Some(depth) = depth { |
|
| 3105 | 2075 | repo.fetch_shallow("origin", depth)?; |
|
| 3106 | - | Vec::new() |
|
| 3107 | 2076 | } else { |
|
| 3108 | - | repo.fetch("origin")?.skipped |
|
| 3109 | - | }; |
|
| 3110 | - | let (op_ref, op) = repo.head_op()?; |
|
| 3111 | - | // The remote's default bookmark, else ours (main), else its only |
|
| 3112 | - | // one; a bookmark the fetch skipped counts, so the clone fails |
|
| 3113 | - | // rather than check out another (oxygenhub#266). |
|
| 3114 | - | let mut theirs = op.view.remotes.get("origin").cloned().unwrap_or_default(); |
|
| 3115 | - | for (name, _) in &skipped { |
|
| 3116 | - | theirs.insert(name.clone(), ObjectRef::ZERO); |
|
| 2077 | + | repo.fetch("origin")?; |
|
| 3117 | 2078 | } |
|
| 2079 | + | let (op_ref, op) = repo.head_op()?; |
|
| 2080 | + | // The remote's default bookmark, else ours (main), else its only one. |
|
| 2081 | + | let theirs = op.view.remotes.get("origin").cloned().unwrap_or_default(); |
|
| 3118 | 2082 | // Asked of the remote as now configured, so a store-backed one |
|
| 3119 | 2083 | // answers from the mirror the fetch just filled. |
|
| 3120 | 2084 | let advertised = repo |
|
| … | 14 unchanged lines |
||
| 3135 | 2099 | .flatten() |
|
| 3136 | 2100 | }) |
|
| 3137 | 2101 | .unwrap_or_else(|| repo.config.bookmark.clone()); |
|
| 3138 | - | if let Some((_, why)) = skipped.iter().find(|(name, _)| *name == bookmark) { |
|
| 3139 | - | return fail(format!("cannot check out {bookmark}: {why}")); |
|
| 3140 | - | } |
|
| 3141 | 2102 | let initial = repo.config.bookmark.clone(); |
|
| 3142 | 2103 | if bookmark != repo.config.bookmark { |
|
| 3143 | 2104 | fs::create_dir_all(repo.workspace_bookmark_path().parent().unwrap())?; |
|
| … | 11 unchanged lines |
||
| 3155 | 2116 | repo.record_op(&[op_ref], &format!("oxy clone {url}"), view)?; |
|
| 3156 | 2117 | repo.checkout(&heads, false, "oxy clone (checkout)")?; |
|
| 3157 | 2118 | } |
|
| 3158 | - | Ok((repo, skipped)) |
|
| 2119 | + | Ok(repo) |
|
| 3159 | 2120 | } |
|
| 3160 | 2121 | } |
|
| 3161 | 2122 | ||
| … | 10 unchanged lines |
modified crates/oxy-core/src/repo/tags.rs +16 −465
| 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. A tag is |
|
| 5 | - | //! withdrawn by a signed `untag` attestation (oxygenhub#301), kept beside |
|
| 6 | - | //! its creation; the name is not used again. |
|
| 4 | + | //! server refuses a second attestation for an existing tag name. |
|
| 7 | 5 | ||
| 8 | 6 | use super::*; |
|
| 9 | 7 | ||
| 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 | - | ||
| 54 | 8 | #[derive(Debug, Clone, PartialEq, Eq)] |
|
| 55 | 9 | pub struct TagInfo { |
|
| 56 | 10 | pub name: String, |
|
| … | 5 unchanged lines |
||
| 62 | 16 | } |
|
| 63 | 17 | ||
| 64 | 18 | impl Repo { |
|
| 65 | - | pub(crate) fn tag_attest_path(&self, name: &str) -> PathBuf { |
|
| 19 | + | fn tag_attest_path(&self, name: &str) -> PathBuf { |
|
| 66 | 20 | self.oxy.join("attest").join("tags").join(name_file(name)) |
|
| 67 | 21 | } |
|
| 68 | 22 | ||
| … | 19 unchanged lines |
||
| 88 | 42 | fn set_tag_attest(&self, name: &str, r: &ObjectRef) -> Result<()> { |
|
| 89 | 43 | fs::create_dir_all(self.oxy.join("attest").join("tags"))?; |
|
| 90 | 44 | write_atomic(&self.tag_attest_path(name), r.to_hex().as_bytes()) |
|
| 91 | - | } |
|
| 92 | - | ||
| 93 | - | fn tag_withdrawal_path(&self, name: &str) -> PathBuf { |
|
| 94 | - | self.oxy.join("attest").join("untags").join(name_file(name)) |
|
| 95 | 45 | } |
|
| 96 | 46 | ||
| 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 | - | ||
| 205 | 47 | /// Create a tag named `name` for the state of `heads` (spec §10.1). |
|
| 206 | 48 | pub fn tag_create(&self, name: &str, heads: &[ChangeHash], message: &str) -> Result<TagInfo> { |
|
| 207 | 49 | crate::objects::validate_name(name) |
|
| … | 1 unchanged lines |
||
| 209 | 51 | let (op_ref, op) = self.head_op()?; |
|
| 210 | 52 | if op.view.tags.contains_key(name) { |
|
| 211 | 53 | return fail(format!("tag {name:?} already exists; tags are immutable")); |
|
| 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 | 54 | } |
|
| 218 | 55 | let st = self.load_state_history(heads)?; |
|
| 219 | 56 | let heads = st.maximal(heads); |
|
| … | 87 unchanged lines |
||
| 307 | 144 | .tag_attest(&t.name)? |
|
| 308 | 145 | .map(|(_, a)| a) |
|
| 309 | 146 | .filter(|a| !a.sigs.is_empty()); |
|
| 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); |
|
| 147 | + | out.push(crate::protocol::TagRef { |
|
| 148 | + | name: t.name.clone(), |
|
| 149 | + | state: t.state, |
|
| 150 | + | heads: t.heads, |
|
| 151 | + | attestation, |
|
| 152 | + | }); |
|
| 319 | 153 | } |
|
| 320 | 154 | Ok(out) |
|
| 321 | - | } |
|
| 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 | 155 | } |
|
| 356 | 156 | ||
| 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 | - | ||
| 387 | 157 | /// Adopt a tag received from a remote or a push. Verifies the |
|
| 388 | 158 | /// attestation when present (and requires one, by an authorized key, |
|
| 389 | 159 | /// 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. |
|
| 394 | 160 | pub(crate) fn adopt_tag( |
|
| 395 | 161 | &self, |
|
| 396 | 162 | view: &mut View, |
|
| 397 | 163 | t: &crate::protocol::TagRef, |
|
| 398 | 164 | policy: Option<&crate::policy::Policy>, |
|
| 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 | - | } |
|
| 165 | + | ) -> std::result::Result<(), String> { |
|
| 412 | 166 | if let Some(existing) = view.tags.get(&t.name) { |
|
| 413 | 167 | return if *existing == t.state { |
|
| 414 | 168 | Ok(()) |
|
| … | 1 unchanged lines |
||
| 416 | 170 | Err(format!( |
|
| 417 | 171 | "tag {:?} already exists with a different state; tags are immutable", |
|
| 418 | 172 | t.name |
|
| 419 | - | ) |
|
| 420 | - | .into()) |
|
| 173 | + | )) |
|
| 421 | 174 | }; |
|
| 422 | 175 | } |
|
| 423 | 176 | let state = self |
|
| … | 6 unchanged lines |
||
| 430 | 183 | "tag {:?} names change {}, which is missing", |
|
| 431 | 184 | t.name, |
|
| 432 | 185 | h.short(12) |
|
| 433 | - | ) |
|
| 434 | - | .into()); |
|
| 186 | + | )); |
|
| 435 | 187 | } |
|
| 436 | 188 | } |
|
| 437 | 189 | if let Some(sa) = &t.attestation { |
|
| … | 19 unchanged lines |
||
| 457 | 209 | .get_identity(&sig.key) |
|
| 458 | 210 | .map_err(|_| format!("unknown key {}", sig.key.short(12)))?; |
|
| 459 | 211 | 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()); |
|
| 463 | 212 | if let Some(p) = policy { |
|
| 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)?) |
|
| 213 | + | let reference = format!("tags/{}", t.name); |
|
| 214 | + | if !p.authorizes(&sig.key, &reference) |
|
| 215 | + | && !self.delegate_authorized(p, &sig.key, &reference, sa.attest.hlc.wall_ms) |
|
| 468 | 216 | { |
|
| 469 | 217 | return Err(format!( |
|
| 470 | 218 | "key {} may not create tags/{} under this repository's policy", |
|
| 471 | 219 | sig.key.short(12), |
|
| 472 | 220 | t.name |
|
| 473 | - | ) |
|
| 474 | - | .into()); |
|
| 221 | + | )); |
|
| 475 | 222 | } |
|
| 476 | 223 | } |
|
| 477 | 224 | let stored = self |
|
| … | 4 unchanged lines |
||
| 482 | 229 | self.store |
|
| 483 | 230 | .put_signature(&stored, s) |
|
| 484 | 231 | .map_err(|e| e.to_string())?; |
|
| 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 | 232 | } |
|
| 496 | 233 | self.set_tag_attest(&t.name, &stored) |
|
| 497 | 234 | .map_err(|e| e.to_string())?; |
|
| … | 1 unchanged lines |
||
| 499 | 236 | return Err(format!( |
|
| 500 | 237 | "tag {:?} needs a signed attestation under this repository's policy", |
|
| 501 | 238 | t.name |
|
| 502 | - | ) |
|
| 503 | - | .into()); |
|
| 239 | + | )); |
|
| 504 | 240 | } |
|
| 505 | 241 | view.tags.insert(t.name.clone(), t.state); |
|
| 506 | 242 | Ok(()) |
|
| 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 | 243 | } |
|
| 693 | 244 | } |
modified crates/oxy-core/src/repo/tests.rs +9 −2929
| … | 175 unchanged lines |
||
| 176 | 176 | (dir, repo, h1, h2, h3) |
|
| 177 | 177 | } |
|
| 178 | 178 | ||
| 179 | - | /// oxygen#126: a file whose text includes an example of oxy's markers (as |
|
| 180 | - | /// the spec's §9.5 does) can still have its conflict resolved; a real |
|
| 181 | - | /// marker left behind is still caught. |
|
| 182 | - | #[test] |
|
| 183 | - | fn marker_like_text_does_not_block_a_resolution() { |
|
| 184 | - | hermetic(); |
|
| 185 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 186 | - | let root = dir.path(); |
|
| 187 | - | let repo = Repo::init(root).unwrap(); |
|
| 188 | - | let example = "```\n<<<<<<< oxy conflict 1 of 2, side 1: change k7m2p9 \"Add retry loop\"\n... lines ...\n======= oxy conflict 1 of 2, side 2: change q3x8w1 \"Refactor client\"\n>>>>>>> oxy conflict 1 of 2 end\n```\n"; |
|
| 189 | - | write( |
|
| 190 | - | root, |
|
| 191 | - | "doc.md", |
|
| 192 | - | format!("intro\n{example}b\nend\n").as_bytes(), |
|
| 193 | - | ); |
|
| 194 | - | let h1 = repo.commit("base", &[]).unwrap(); |
|
| 195 | - | write( |
|
| 196 | - | root, |
|
| 197 | - | "doc.md", |
|
| 198 | - | format!("intro\n{example}B\nend\n").as_bytes(), |
|
| 199 | - | ); |
|
| 200 | - | repo.commit("upper", &[]).unwrap(); |
|
| 201 | - | repo.checkout(&[h1], false, "checkout").unwrap(); |
|
| 202 | - | write( |
|
| 203 | - | root, |
|
| 204 | - | "doc.md", |
|
| 205 | - | format!("intro\n{example}X\nend\n").as_bytes(), |
|
| 206 | - | ); |
|
| 207 | - | repo.commit("ex", &[]).unwrap(); |
|
| 208 | - | repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") |
|
| 209 | - | .unwrap(); |
|
| 210 | - | let marked = fs::read_to_string(root.join("doc.md")).unwrap(); |
|
| 211 | - | assert!(marked.contains("oxy conflict 1 of 1"), "{marked}"); |
|
| 212 | - | // A real marker left: still caught. |
|
| 213 | - | write(root, "doc.md", marked.replace("B\n", "BB\n").as_bytes()); |
|
| 214 | - | let s = repo.snapshot("status").unwrap(); |
|
| 215 | - | assert_eq!( |
|
| 216 | - | statuses(&s), |
|
| 217 | - | vec![("doc.md".into(), ChangeStatus::ConflictEdited)] |
|
| 218 | - | ); |
|
| 219 | - | // Resolved, the example kept: a resolution. |
|
| 220 | - | write( |
|
| 221 | - | root, |
|
| 222 | - | "doc.md", |
|
| 223 | - | format!("intro\n{example}X\nend\n").as_bytes(), |
|
| 224 | - | ); |
|
| 225 | - | let s = repo.snapshot("status").unwrap(); |
|
| 226 | - | assert_eq!( |
|
| 227 | - | statuses(&s), |
|
| 228 | - | vec![("doc.md".into(), ChangeStatus::Resolved)] |
|
| 229 | - | ); |
|
| 230 | - | let h = repo.commit("resolve", &[]).unwrap(); |
|
| 231 | - | assert_eq!( |
|
| 232 | - | repo.store().get_change(&h).unwrap().kind, |
|
| 233 | - | ChangeKind::Resolution |
|
| 234 | - | ); |
|
| 235 | - | assert!(!repo.load_state(&[h]).unwrap().materialize().is_conflicted()); |
|
| 236 | - | } |
|
| 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. |
|
| 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 | 179 | #[test] |
|
| 464 | 180 | fn concurrent_edits_conflict_and_can_be_resolved() { |
|
| 465 | 181 | hermetic(); |
|
| … | 63 unchanged lines |
||
| 529 | 245 | st.validate().unwrap(); |
|
| 530 | 246 | assert!( |
|
| 531 | 247 | matches!(&st.materialize().files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n") |
|
| 532 | - | ); |
|
| 533 | - | } |
|
| 534 | - | ||
| 535 | - | /// oxygen#118 (Metal Gear): a resolution commits by paths, leaving other |
|
| 536 | - | /// work uncommitted, so long as it takes every file it resolves; and |
|
| 537 | - | /// `amend PATHS` folds only those paths into the head. |
|
| 538 | - | #[test] |
|
| 539 | - | fn resolutions_and_amends_take_paths() { |
|
| 540 | - | hermetic(); |
|
| 541 | - | let (dir, repo, _h1, _h2, _h3) = conflicted_repo(); |
|
| 542 | - | let root = dir.path(); |
|
| 543 | - | repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main") |
|
| 544 | - | .unwrap(); |
|
| 545 | - | // The conflict resolved, and other work beside it. |
|
| 546 | - | write(root, "f.txt", b"a\nX\nc\n"); |
|
| 547 | - | write(root, "notes.txt", b"pending\n"); |
|
| 548 | - | let s = repo.snapshot("status").unwrap(); |
|
| 549 | - | assert!(s.resolves); |
|
| 550 | - | let err = repo |
|
| 551 | - | .commit_selection_partial("resolve", &[], &Selection::Paths(vec!["notes.txt".into()])) |
|
| 552 | - | .unwrap_err() |
|
| 553 | - | .to_string(); |
|
| 554 | - | assert!( |
|
| 555 | - | err.contains("takes every file it resolves") && err.contains("f.txt"), |
|
| 556 | - | "{err}" |
|
| 557 | - | ); |
|
| 558 | - | let (h4, partial) = repo |
|
| 559 | - | .commit_selection_partial("resolve", &[], &Selection::Paths(vec!["f.txt".into()])) |
|
| 560 | - | .unwrap(); |
|
| 561 | - | assert!(partial); |
|
| 562 | - | assert_eq!( |
|
| 563 | - | repo.store().get_change(&h4).unwrap().kind, |
|
| 564 | - | ChangeKind::Resolution |
|
| 565 | - | ); |
|
| 566 | - | assert!(!repo |
|
| 567 | - | .load_state(&[h4]) |
|
| 568 | - | .unwrap() |
|
| 569 | - | .materialize() |
|
| 570 | - | .is_conflicted()); |
|
| 571 | - | let s = repo.snapshot("status").unwrap(); |
|
| 572 | - | assert_eq!( |
|
| 573 | - | statuses(&s), |
|
| 574 | - | vec![("notes.txt".into(), ChangeStatus::Added)], |
|
| 575 | - | "the other work waits" |
|
| 576 | - | ); |
|
| 577 | - | ||
| 578 | - | // amend PATHS: only those paths join the head; it keeps its ChangeId. |
|
| 579 | - | // On the resolution, so the bookmark has one head to amend. |
|
| 580 | - | let h5 = repo.commit("notes", &[h4]).unwrap(); |
|
| 581 | - | let id5 = repo.store().get_change(&h5).unwrap().change_id; |
|
| 582 | - | write(root, "notes.txt", b"pending\nmore\n"); |
|
| 583 | - | write(root, "other.txt", b"later\n"); |
|
| 584 | - | let h6 = repo |
|
| 585 | - | .amend_paths(Some("notes, amended"), &["notes.txt".into()]) |
|
| 586 | - | .unwrap(); |
|
| 587 | - | let c6 = repo.store().get_change(&h6).unwrap(); |
|
| 588 | - | assert_eq!((c6.change_id, c6.message.as_str()), (id5, "notes, amended")); |
|
| 589 | - | assert_eq!(repo.bookmark_heads().unwrap(), vec![h6]); |
|
| 590 | - | let snap6 = repo.load_state(&[h6]).unwrap().materialize(); |
|
| 591 | - | assert!( |
|
| 592 | - | matches!(&snap6.files["notes.txt"].content, FileContent::Text { bytes, .. } if bytes == b"pending\nmore\n") |
|
| 593 | 248 | ); |
|
| 594 | - | assert!( |
|
| 595 | - | !snap6.files.contains_key("other.txt"), |
|
| 596 | - | "other.txt stays out" |
|
| 597 | - | ); |
|
| 598 | - | let s = repo.snapshot("status").unwrap(); |
|
| 599 | - | assert_eq!( |
|
| 600 | - | statuses(&s), |
|
| 601 | - | vec![("other.txt".into(), ChangeStatus::Added)] |
|
| 602 | - | ); |
|
| 603 | 249 | } |
|
| 604 | 250 | ||
| 605 | 251 | #[test] |
|
| … | 80 unchanged lines |
||
| 686 | 332 | ); |
|
| 687 | 333 | } |
|
| 688 | 334 | ||
| 689 | - | /// Each of two bookmarks adds `path` (a file written by `side` on each) |
|
| 690 | - | /// from the same base, and the second is merged into the first. Returns |
|
| 691 | - | /// the repository on `main`, the merge's heads, and the three changes. |
|
| 692 | - | fn merged_additions( |
|
| 693 | - | side: impl Fn(&Path, bool), |
|
| 694 | - | ) -> (tempfile::TempDir, Repo, Vec<ChangeHash>, [ChangeHash; 3]) { |
|
| 695 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 696 | - | let root = dir.path().to_path_buf(); |
|
| 697 | - | let mut repo = Repo::init(&root).unwrap(); |
|
| 698 | - | write(&root, "keep.txt", b"k\n"); |
|
| 699 | - | let h1 = repo.commit("base", &[]).unwrap(); |
|
| 700 | - | repo.bookmark_create("side", None).unwrap(); |
|
| 701 | - | side(&root, false); |
|
| 702 | - | let ha = repo.commit("add on main", &[]).unwrap(); |
|
| 703 | - | repo.bookmark_switch("side", false).unwrap(); |
|
| 704 | - | side(&root, true); |
|
| 705 | - | let hb = repo.commit("add on side", &[]).unwrap(); |
|
| 706 | - | repo.bookmark_switch("main", false).unwrap(); |
|
| 707 | - | let m = repo.merge("side").unwrap(); |
|
| 708 | - | assert!(m.changed && m.checked_out); |
|
| 709 | - | (dir, repo, m.heads, [h1, ha, hb]) |
|
| 710 | - | } |
|
| 711 | - | ||
| 712 | - | fn conflict_copies(root: &Path) -> Vec<String> { |
|
| 713 | - | let mut out: Vec<String> = fs::read_dir(root) |
|
| 714 | - | .unwrap() |
|
| 715 | - | .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) |
|
| 716 | - | .filter(|n| n.contains(".oxy-conflict-")) |
|
| 717 | - | .collect(); |
|
| 718 | - | out.sort(); |
|
| 719 | - | out |
|
| 720 | - | } |
|
| 721 | - | ||
| 722 | - | /// The same path added on two bookmarks with identical content is one |
|
| 723 | - | /// file after the merge (oxygen#101): no conflict, nothing to resolve, |
|
| 724 | - | /// nothing recorded; and editing or removing it later keeps it one. |
|
| 725 | - | #[test] |
|
| 726 | - | fn identical_additions_unify_on_merge() { |
|
| 727 | - | hermetic(); |
|
| 728 | - | let (dir, repo, heads, [h1, ha, hb]) = |
|
| 729 | - | merged_additions(|root, _| write(root, "x.txt", b"same\nlines\n")); |
|
| 730 | - | let root = dir.path(); |
|
| 731 | - | // The tree graph has the name conflict; every reader sees one file. |
|
| 732 | - | let st = repo.load_state(&heads).unwrap(); |
|
| 733 | - | assert!(st |
|
| 734 | - | .tree() |
|
| 735 | - | .view() |
|
| 736 | - | .conflicts |
|
| 737 | - | .iter() |
|
| 738 | - | .any(|c| matches!(c, crate::tree::TreeConflict::Name { .. }))); |
|
| 739 | - | let snap = st.materialize(); |
|
| 740 | - | assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts); |
|
| 741 | - | assert_eq!( |
|
| 742 | - | snap.files.keys().cloned().collect::<Vec<_>>(), |
|
| 743 | - | ["keep.txt", "x.txt"] |
|
| 744 | - | ); |
|
| 745 | - | assert!( |
|
| 746 | - | matches!(&snap.files["x.txt"].content, FileContent::Text { bytes, .. } if bytes == b"same\nlines\n") |
|
| 747 | - | ); |
|
| 748 | - | assert_eq!(snap.tree.unified.len(), 1); |
|
| 749 | - | // On disk once, the working copy clean, nothing to commit. |
|
| 750 | - | assert_eq!(fs::read(root.join("x.txt")).unwrap(), b"same\nlines\n"); |
|
| 751 | - | assert!( |
|
| 752 | - | conflict_copies(root).is_empty(), |
|
| 753 | - | "{:?}", |
|
| 754 | - | conflict_copies(root) |
|
| 755 | - | ); |
|
| 756 | - | let s = repo.snapshot("status").unwrap(); |
|
| 757 | - | assert!(s.statuses.is_empty(), "{:?}", statuses(&s)); |
|
| 758 | - | assert!(!s.changed && !s.resolves); |
|
| 759 | - | assert!(!s.base_snapshot.is_conflicted()); |
|
| 760 | - | assert!(s.wc.tree_ops.is_empty() && s.wc.edits.is_empty()); |
|
| 761 | - | let json = repo.status_json(&s).unwrap(); |
|
| 762 | - | assert!(json.contains("\"conflicted\":false"), "{json}"); |
|
| 763 | - | assert!(repo.commit("nothing", &[]).is_err()); |
|
| 764 | - | assert_eq!(repo.file_at("x.txt", None).unwrap(), b"same\nlines\n"); |
|
| 765 | - | // A peer holding the same changes, added in another order, agrees. |
|
| 766 | - | let mut peer = State::new(); |
|
| 767 | - | for h in [hb, ha, h1] { |
|
| 768 | - | peer.add_unchecked(repo.store().get_change(&h).unwrap()); |
|
| 769 | - | } |
|
| 770 | - | peer.validate().unwrap(); |
|
| 771 | - | let ps = peer.materialize(); |
|
| 772 | - | assert!(!ps.is_conflicted()); |
|
| 773 | - | assert_eq!(ps.tree.entries["x.txt"], snap.tree.entries["x.txt"]); |
|
| 774 | - | assert_eq!(ps.tree.unified, snap.tree.unified); |
|
| 775 | - | // The file's history is both additions. |
|
| 776 | - | let log: Vec<ChangeHash> = repo |
|
| 777 | - | .log_for_path("x.txt") |
|
| 778 | - | .unwrap() |
|
| 779 | - | .iter() |
|
| 780 | - | .map(|e| e.hash) |
|
| 781 | - | .collect(); |
|
| 782 | - | assert!(log.contains(&ha) && log.contains(&hb), "{log:?}"); |
|
| 783 | - | ||
| 784 | - | // Edited and committed: one file still, the duplicate retired with it. |
|
| 785 | - | write(root, "x.txt", b"same\nedited\n"); |
|
| 786 | - | let s = repo.snapshot("status").unwrap(); |
|
| 787 | - | assert_eq!(statuses(&s), vec![("x.txt".into(), ChangeStatus::Modified)]); |
|
| 788 | - | let he = repo.commit("edit x", &[]).unwrap(); |
|
| 789 | - | let c = repo.store().get_change(&he).unwrap(); |
|
| 790 | - | assert_eq!(c.kind, ChangeKind::Normal); |
|
| 791 | - | let (dup, _) = snap.tree.unified.iter().next().unwrap(); |
|
| 792 | - | assert!( |
|
| 793 | - | c.tree_ops |
|
| 794 | - | .iter() |
|
| 795 | - | .any(|o| matches!(o, TreeOp::RemoveNode { node, .. } if node == dup)), |
|
| 796 | - | "{:?}", |
|
| 797 | - | c.tree_ops |
|
| 798 | - | ); |
|
| 799 | - | let d = repo.describe_change(&he).unwrap(); |
|
| 800 | - | assert!(!d.contains("D x.txt"), "{d}"); |
|
| 801 | - | let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap(); |
|
| 802 | - | assert!( |
|
| 803 | - | st.tree().view().conflicts.is_empty(), |
|
| 804 | - | "nothing left to unify" |
|
| 805 | - | ); |
|
| 806 | - | let snap = st.materialize(); |
|
| 807 | - | assert!(!snap.is_conflicted()); |
|
| 808 | - | assert!( |
|
| 809 | - | matches!(&snap.files["x.txt"].content, FileContent::Text { bytes, .. } if bytes == b"same\nedited\n") |
|
| 810 | - | ); |
|
| 811 | - | assert!(!repo.snapshot("status").unwrap().changed); |
|
| 812 | - | ||
| 813 | - | // Removed and committed: gone. |
|
| 814 | - | fs::remove_file(root.join("x.txt")).unwrap(); |
|
| 815 | - | repo.commit("drop x", &[]).unwrap(); |
|
| 816 | - | let snap = repo |
|
| 817 | - | .load_state(&repo.bookmark_heads().unwrap()) |
|
| 818 | - | .unwrap() |
|
| 819 | - | .materialize(); |
|
| 820 | - | assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts); |
|
| 821 | - | assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), ["keep.txt"]); |
|
| 822 | - | assert!(!root.join("x.txt").exists()); |
|
| 823 | - | } |
|
| 824 | - | ||
| 825 | - | /// Removing, renaming, or committing just the path of a unified file |
|
| 826 | - | /// retires its duplicate with it, so the conflict never comes back. |
|
| 827 | - | #[test] |
|
| 828 | - | fn unified_additions_stay_one_when_removed_renamed_or_committed_alone() { |
|
| 829 | - | hermetic(); |
|
| 830 | - | let add = |root: &Path, _: bool| write(root, "x.txt", b"same\n"); |
|
| 831 | - | for how in ["remove", "rename", "path", "hunk"] { |
|
| 832 | - | let (dir, repo, _, _) = merged_additions(add); |
|
| 833 | - | let root = dir.path(); |
|
| 834 | - | match how { |
|
| 835 | - | "remove" => fs::remove_file(root.join("x.txt")).unwrap(), |
|
| 836 | - | "rename" => fs::rename(root.join("x.txt"), root.join("y.txt")).unwrap(), |
|
| 837 | - | _ => { |
|
| 838 | - | write(root, "x.txt", b"same\nmore\n"); |
|
| 839 | - | write(root, "other.txt", b"o\n"); |
|
| 840 | - | } |
|
| 841 | - | } |
|
| 842 | - | let s = repo.snapshot("status").unwrap(); |
|
| 843 | - | let h = match how { |
|
| 844 | - | "path" => repo |
|
| 845 | - | .commit_selection("x alone", &[], &Selection::Paths(vec!["x.txt".into()])) |
|
| 846 | - | .unwrap(), |
|
| 847 | - | "hunk" => { |
|
| 848 | - | let (_, hunks) = repo.hunks().unwrap(); |
|
| 849 | - | // The duplicate's retirement is no hunk of its own. |
|
| 850 | - | assert!(hunks.iter().all(|h| !h.text.starts_with("D ")), "{hunks:?}"); |
|
| 851 | - | let x: Vec<usize> = hunks |
|
| 852 | - | .iter() |
|
| 853 | - | .filter(|h| h.path == "x.txt") |
|
| 854 | - | .map(|h| h.index) |
|
| 855 | - | .collect(); |
|
| 856 | - | assert!(!x.is_empty()); |
|
| 857 | - | repo.commit_selection("x hunks", &[], &Selection::Hunks(x)) |
|
| 858 | - | .unwrap() |
|
| 859 | - | } |
|
| 860 | - | _ => repo.commit(how, &[]).unwrap(), |
|
| 861 | - | }; |
|
| 862 | - | assert!(!s.base_snapshot.is_conflicted()); |
|
| 863 | - | let heads = repo.bookmark_heads().unwrap(); |
|
| 864 | - | assert!(heads.contains(&h)); |
|
| 865 | - | let snap = repo.load_state(&heads).unwrap().materialize(); |
|
| 866 | - | assert!(!snap.is_conflicted(), "{how}: {:?}", snap.tree.conflicts); |
|
| 867 | - | let want: &[&str] = match how { |
|
| 868 | - | "remove" => &["keep.txt"], |
|
| 869 | - | "rename" => &["keep.txt", "y.txt"], |
|
| 870 | - | _ => &["keep.txt", "x.txt"], |
|
| 871 | - | }; |
|
| 872 | - | assert_eq!( |
|
| 873 | - | snap.files.keys().cloned().collect::<Vec<_>>(), |
|
| 874 | - | want, |
|
| 875 | - | "{how}" |
|
| 876 | - | ); |
|
| 877 | - | assert!(conflict_copies(root).is_empty(), "{how}"); |
|
| 878 | - | } |
|
| 879 | - | } |
|
| 880 | - | ||
| 881 | - | /// The same path added with different content, or a different mode, is a |
|
| 882 | - | /// name conflict as before. |
|
| 883 | 335 | #[test] |
|
| 884 | - | fn additions_that_differ_still_conflict() { |
|
| 885 | - | hermetic(); |
|
| 886 | - | #[cfg(unix)] |
|
| 887 | - | use std::os::unix::fs::PermissionsExt; |
|
| 888 | - | let content = |
|
| 889 | - | |root: &Path, side: bool| write(root, "x.txt", if side { b"two\n" } else { b"one\n" }); |
|
| 890 | - | let mode = |root: &Path, side: bool| { |
|
| 891 | - | write(root, "x.txt", b"same\n"); |
|
| 892 | - | #[cfg(unix)] |
|
| 893 | - | if side { |
|
| 894 | - | fs::set_permissions(root.join("x.txt"), fs::Permissions::from_mode(0o755)).unwrap(); |
|
| 895 | - | } |
|
| 896 | - | }; |
|
| 897 | - | let check = |what: &str, side: &dyn Fn(&Path, bool)| { |
|
| 898 | - | let (dir, repo, heads, _) = merged_additions(side); |
|
| 899 | - | let snap = repo.load_state(&heads).unwrap().materialize(); |
|
| 900 | - | assert!(snap.tree.unified.is_empty(), "{what}"); |
|
| 901 | - | assert!( |
|
| 902 | - | snap.tree.conflicts.iter().any( |
|
| 903 | - | |c| matches!(c, crate::tree::TreeConflict::Name { name, .. } if name == "x.txt") |
|
| 904 | - | ), |
|
| 905 | - | "{what}: {:?}", |
|
| 906 | - | snap.tree.conflicts |
|
| 907 | - | ); |
|
| 908 | - | assert_eq!( |
|
| 909 | - | conflict_copies(dir.path()), |
|
| 910 | - | ["x.txt.oxy-conflict-1", "x.txt.oxy-conflict-2"], |
|
| 911 | - | "{what}" |
|
| 912 | - | ); |
|
| 913 | - | assert!(!dir.path().join("x.txt").exists(), "{what}"); |
|
| 914 | - | }; |
|
| 915 | - | check("content", &content); |
|
| 916 | - | #[cfg(unix)] |
|
| 917 | - | check("mode", &mode); |
|
| 918 | - | #[cfg(not(unix))] |
|
| 919 | - | let _ = mode; |
|
| 920 | - | } |
|
| 921 | - | ||
| 922 | - | /// The same subrepo at the same pin, added on two bookmarks, is one |
|
| 923 | - | /// subrepo after the merge (oxygen#101), as is the identical |
|
| 924 | - | /// `.oxymodules` beside it. |
|
| 925 | - | #[test] |
|
| 926 | - | fn the_same_subrepo_added_on_two_bookmarks_unifies() { |
|
| 927 | - | hermetic(); |
|
| 928 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 929 | - | let root = dir.path(); |
|
| 930 | - | let lib_origin = Repo::init(&root.join("lib-origin")).unwrap(); |
|
| 931 | - | let lib_url = root.join("lib-origin").to_string_lossy().to_string(); |
|
| 932 | - | let lib = Repo::clone(&lib_url, &root.join("lib")) |
|
| 933 | - | .unwrap() |
|
| 934 | - | .with_home(root.join("home")); |
|
| 935 | - | write(&root.join("lib"), "lib.txt", b"v1\n"); |
|
| 936 | - | lib.commit("lib v1", &[]).unwrap(); |
|
| 937 | - | assert!(lib.push("origin", false).unwrap()[0].ok); |
|
| 938 | - | let _ = lib_origin; |
|
| 939 | - | ||
| 940 | - | let app_dir = root.join("app"); |
|
| 941 | - | let mut app = Repo::init(&app_dir).unwrap().with_home(root.join("home")); |
|
| 942 | - | write(&app_dir, "main.txt", b"app\n"); |
|
| 943 | - | app.commit("base", &[]).unwrap(); |
|
| 944 | - | app.bookmark_create("side", None).unwrap(); |
|
| 945 | - | app.submodule_add(&lib_url, "rstar-api", None).unwrap(); |
|
| 946 | - | let modules = fs::read(app_dir.join(".oxymodules")).unwrap(); |
|
| 947 | - | app.commit("rstar-api on main", &[]).unwrap(); |
|
| 948 | - | // On the other bookmark the nested repository is still there (it is |
|
| 949 | - | // never deleted); registering it again adds it at the same pin. |
|
| 950 | - | app.bookmark_switch("side", false).unwrap(); |
|
| 951 | - | assert!(app_dir.join("rstar-api/.oxy").is_dir()); |
|
| 952 | - | write(&app_dir, ".oxymodules", &modules); |
|
| 953 | - | let s = app.snapshot("status").unwrap(); |
|
| 954 | - | assert_eq!( |
|
| 955 | - | statuses(&s), |
|
| 956 | - | vec![ |
|
| 957 | - | (".oxymodules".into(), ChangeStatus::Added), |
|
| 958 | - | ("rstar-api".into(), ChangeStatus::Added) |
|
| 959 | - | ] |
|
| 960 | - | ); |
|
| 961 | - | app.commit("rstar-api on side", &[]).unwrap(); |
|
| 962 | - | app.bookmark_switch("main", false).unwrap(); |
|
| 963 | - | ||
| 964 | - | let m = app.merge("side").unwrap(); |
|
| 965 | - | assert!(m.changed && m.checked_out); |
|
| 966 | - | let snap = app.load_state(&m.heads).unwrap().materialize(); |
|
| 967 | - | assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts); |
|
| 968 | - | assert_eq!(snap.tree.unified.len(), 2); |
|
| 969 | - | assert_eq!(snap.files["rstar-api"].kind, NodeKind::Subrepo); |
|
| 970 | - | assert!(conflict_copies(&app_dir).is_empty()); |
|
| 971 | - | let s = app.snapshot("status").unwrap(); |
|
| 972 | - | assert!(s.statuses.is_empty(), "{:?}", statuses(&s)); |
|
| 973 | - | assert!(!s.base_snapshot.is_conflicted()); |
|
| 974 | - | assert_eq!( |
|
| 975 | - | fs::read_to_string(app_dir.join("rstar-api/lib.txt")).unwrap(), |
|
| 976 | - | "v1\n" |
|
| 977 | - | ); |
|
| 978 | - | } |
|
| 979 | - | ||
| 980 | - | #[test] |
|
| 981 | 336 | fn identities_and_signed_commits() { |
|
| 982 | 337 | hermetic(); |
|
| 983 | 338 | let dir = tempfile::tempdir().unwrap(); |
|
| … | 98 unchanged lines |
||
| 1082 | 437 | CheckoutReport { |
|
| 1083 | 438 | written: 2, |
|
| 1084 | 439 | removed: 1, |
|
| 1085 | - | removed_paths: vec!["bin.dat".into()], |
|
| 1086 | 440 | conflicted: false, |
|
| 1087 | 441 | notes: vec![], |
|
| 1088 | 442 | lacks: 1, |
|
| 1089 | - | kept_both: vec![], |
|
| 1090 | 443 | } |
|
| 1091 | 444 | ); |
|
| 1092 | 445 | assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n"); |
|
| … | 65 unchanged lines |
||
| 1158 | 511 | write(&root.join("a"), "f.txt", b"a\n"); |
|
| 1159 | 512 | let h1 = a.commit("first", &[]).unwrap(); |
|
| 1160 | 513 | let results = a.push("origin", false).unwrap(); |
|
| 1161 | - | origin.refresh().unwrap(); |
|
| 1162 | 514 | assert!(results[0].ok, "{}", results[0].reason); |
|
| 1163 | 515 | assert_eq!(origin.bookmark_heads().unwrap(), vec![h1]); |
|
| 1164 | 516 | // The change was sealed at push and the reference attested. |
|
| … | 14 unchanged lines |
||
| 1179 | 531 | let first = serial_of(&att); |
|
| 1180 | 532 | assert!(first > 1_000_000_000_000, "{first}"); |
|
| 1181 | 533 | let again = a.push("origin", false).unwrap(); |
|
| 1182 | - | origin.refresh().unwrap(); |
|
| 1183 | 534 | assert!(again[0].ok && again[0].reason.contains("up to date")); |
|
| 1184 | 535 | let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); |
|
| 1185 | 536 | assert_eq!(serial_of(&att), first, "a no-op push does not attest again"); |
|
| … | 11 unchanged lines |
||
| 1197 | 548 | write(&root.join("b"), "f.txt", b"a\nb\n"); |
|
| 1198 | 549 | let h2 = b.commit("second", &[]).unwrap(); |
|
| 1199 | 550 | assert!(b.push("origin", false).unwrap()[0].ok); |
|
| 1200 | - | origin.refresh().unwrap(); |
|
| 1201 | 551 | assert_eq!(origin.bookmark_heads().unwrap(), vec![h2]); |
|
| 1202 | 552 | ||
| 1203 | 553 | // A commits an independent change; push is refused until A pulls. |
|
| … | 8 unchanged lines |
||
| 1212 | 562 | assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nb\n"); |
|
| 1213 | 563 | assert_eq!(fs::read(root.join("a/g.txt")).unwrap(), b"g\n"); |
|
| 1214 | 564 | let results = a.push("origin", false).unwrap(); |
|
| 1215 | - | origin.refresh().unwrap(); |
|
| 1216 | 565 | assert!(results[0].ok, "{}", results[0].reason); |
|
| 1217 | 566 | let mut origin_heads = origin.bookmark_heads().unwrap(); |
|
| 1218 | 567 | origin_heads.sort(); |
|
| … | 11 unchanged lines |
||
| 1230 | 579 | write(&root.join("a"), "f.txt", b"a\nA\n"); |
|
| 1231 | 580 | a.commit("a-edit", &[]).unwrap(); |
|
| 1232 | 581 | a.push("origin", false).unwrap(); |
|
| 1233 | - | origin.refresh().unwrap(); |
|
| 1234 | 582 | write(&root.join("b"), "f.txt", b"a\nB\n"); |
|
| 1235 | 583 | b.commit("b-edit", &[]).unwrap(); |
|
| 1236 | 584 | let pr = b.pull("origin").unwrap(); |
|
| … | 7 unchanged lines |
||
| 1244 | 592 | ChangeKind::Resolution |
|
| 1245 | 593 | ); |
|
| 1246 | 594 | assert!(b.push("origin", false).unwrap()[0].ok); |
|
| 1247 | - | origin.refresh().unwrap(); |
|
| 1248 | 595 | a.pull("origin").unwrap(); |
|
| 1249 | 596 | assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nAB\n"); |
|
| 1250 | 597 | // The resolution depends on the conflicting edits, but nothing depends |
|
| … | 390 unchanged lines |
||
| 1641 | 988 | assert!( |
|
| 1642 | 989 | repo.snapshot("status").unwrap().statuses.is_empty(), |
|
| 1643 | 990 | "nothing left to commit" |
|
| 1644 | - | ); |
|
| 1645 | - | } |
|
| 1646 | - | ||
| 1647 | - | /// Keyless step 4 (oxygenhub#241): an agent signing with a short-lived |
|
| 1648 | - | /// key whose certificate an issuer gave pushes when the policy grants its |
|
| 1649 | - | /// subject, and a fetch accepts what it attested; an expired certificate, |
|
| 1650 | - | /// or a subject the policy does not grant, may not move the bookmark. |
|
| 1651 | - | #[test] |
|
| 1652 | - | fn a_certified_key_pushes_under_a_subject_grant() { |
|
| 1653 | - | use crate::cert::{Certificate, SignedCertificate}; |
|
| 1654 | - | use crate::identity::Scope; |
|
| 1655 | - | hermetic(); |
|
| 1656 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 1657 | - | let root = dir.path(); |
|
| 1658 | - | let _origin = Repo::init(&root.join("origin")).unwrap(); |
|
| 1659 | - | let url = root.join("origin").to_string_lossy().to_string(); |
|
| 1660 | - | let mut a = Repo::clone(&url, &root.join("a")) |
|
| 1661 | - | .unwrap() |
|
| 1662 | - | .with_home(root.join("home-a")); |
|
| 1663 | - | a.create_identity("Ada", "[email protected]", IdentityScope::User) |
|
| 1664 | - | .unwrap(); |
|
| 1665 | - | write(&root.join("a"), "a.txt", b"a\n"); |
|
| 1666 | - | a.commit("a", &[]).unwrap(); |
|
| 1667 | - | a.policy_init().unwrap(); |
|
| 1668 | - | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1669 | - | let hub_sk = SecretKey::generate(); |
|
| 1670 | - | let hub = hub_sk.identity("hub", "[email protected]", 1); |
|
| 1671 | - | a.policy_grant_subject( |
|
| 1672 | - | "agent:claude@ada", |
|
| 1673 | - | hub.clone(), |
|
| 1674 | - | Scope::CHANGES | Scope::REFS, |
|
| 1675 | - | None, |
|
| 1676 | - | ) |
|
| 1677 | - | .unwrap(); |
|
| 1678 | - | assert!(a |
|
| 1679 | - | .push("origin", false) |
|
| 1680 | - | .unwrap() |
|
| 1681 | - | .iter() |
|
| 1682 | - | .any(|r| r.name == "policy" && r.ok)); |
|
| 1683 | - | ||
| 1684 | - | let now = Hlc::now().wall_ms; |
|
| 1685 | - | // An agent repository whose key the hub certified for `subject`, from |
|
| 1686 | - | // `from` to `to`. |
|
| 1687 | - | let agent = |name: &str, subject: &str, from: u64, to: u64| { |
|
| 1688 | - | let mut r = Repo::clone(&url, &root.join(name)) |
|
| 1689 | - | .unwrap() |
|
| 1690 | - | .with_home(root.join(format!("home-{name}"))); |
|
| 1691 | - | let (_, id) = r |
|
| 1692 | - | .create_identity(name, "[email protected]", IdentityScope::User) |
|
| 1693 | - | .unwrap(); |
|
| 1694 | - | let cert = SignedCertificate::sign( |
|
| 1695 | - | Certificate { |
|
| 1696 | - | key: id, |
|
| 1697 | - | issuer: hub.hash(), |
|
| 1698 | - | subject: subject.into(), |
|
| 1699 | - | claims: vec![], |
|
| 1700 | - | scope: Scope::CHANGES | Scope::REFS, |
|
| 1701 | - | not_before: from, |
|
| 1702 | - | expires: to, |
|
| 1703 | - | grant: None, |
|
| 1704 | - | }, |
|
| 1705 | - | &hub, |
|
| 1706 | - | &hub_sk, |
|
| 1707 | - | ); |
|
| 1708 | - | r.store().put_identity(&hub).unwrap(); |
|
| 1709 | - | r.store().put_certificate(&cert).unwrap(); |
|
| 1710 | - | write(&root.join(name), &format!("{name}.txt"), b"agent work\n"); |
|
| 1711 | - | r.commit("work", &[]).unwrap(); |
|
| 1712 | - | r |
|
| 1713 | - | }; |
|
| 1714 | - | ||
| 1715 | - | let claude = agent("claude", "agent:claude@ada", now - 60_000, now + 600_000); |
|
| 1716 | - | let r = claude.push("origin", false).unwrap(); |
|
| 1717 | - | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 1718 | - | // Ada's fetch accepts the bookmark the certified key attested. |
|
| 1719 | - | a.pull("origin").unwrap(); |
|
| 1720 | - | assert!(a.bookmark_heads().unwrap().iter().any(|h| a |
|
| 1721 | - | .store() |
|
| 1722 | - | .get_change(h) |
|
| 1723 | - | .map(|c| c.message == "work") |
|
| 1724 | - | .unwrap_or(false))); |
|
| 1725 | - | ||
| 1726 | - | // Step 4c: a host with an acceptor key records when it accepted the |
|
| 1727 | - | // certified update and serves it; a fetch checks the certificate at |
|
| 1728 | - | // that time only when the certificate's issuer signed it for exactly |
|
| 1729 | - | // this attestation, else at the attestation's own time. |
|
| 1730 | - | let host = Repo::open(&root.join("origin")) |
|
| 1731 | - | .unwrap() |
|
| 1732 | - | .with_acceptor(hub.clone(), hub_sk.clone()); |
|
| 1733 | - | let (attest_ref, sa) = host.latest_ref_attest("main").unwrap().unwrap(); |
|
| 1734 | - | host.record_acceptance(&attest_ref).unwrap(); |
|
| 1735 | - | let served = host.remote_refs().unwrap(); |
|
| 1736 | - | let acc = served |
|
| 1737 | - | .refs |
|
| 1738 | - | .iter() |
|
| 1739 | - | .find(|r| r.name == "main") |
|
| 1740 | - | .unwrap() |
|
| 1741 | - | .accepted |
|
| 1742 | - | .clone() |
|
| 1743 | - | .expect("served with the refs"); |
|
| 1744 | - | let signer = sa.sigs[0].key; |
|
| 1745 | - | assert_eq!( |
|
| 1746 | - | a.certified_time(&signer, &sa, Some(&acc)), |
|
| 1747 | - | acc.attest.hlc.wall_ms |
|
| 1748 | - | ); |
|
| 1749 | - | assert_eq!(a.certified_time(&signer, &sa, None), sa.attest.hlc.wall_ms); |
|
| 1750 | - | let stranger_sk = SecretKey::generate(); |
|
| 1751 | - | let stranger = stranger_sk.identity("stranger", "s@x", 1); |
|
| 1752 | - | let mut forged = acc.clone(); |
|
| 1753 | - | forged.sigs = vec![stranger_sk.sign(Domain::Attest, &stranger.hash(), &acc.attest.hash())]; |
|
| 1754 | - | assert_eq!( |
|
| 1755 | - | a.certified_time(&signer, &sa, Some(&forged)), |
|
| 1756 | - | sa.attest.hlc.wall_ms, |
|
| 1757 | - | "not the issuer's" |
|
| 1758 | - | ); |
|
| 1759 | - | let mut elsewhere = acc.clone(); |
|
| 1760 | - | elsewhere.attest.body = AttestBody::Accepted { |
|
| 1761 | - | attest: ObjectRef([7; 32]), |
|
| 1762 | - | }; |
|
| 1763 | - | elsewhere.sigs = vec![hub_sk.sign(Domain::Attest, &hub.hash(), &elsewhere.attest.hash())]; |
|
| 1764 | - | assert_eq!( |
|
| 1765 | - | a.certified_time(&signer, &sa, Some(&elsewhere)), |
|
| 1766 | - | sa.attest.hlc.wall_ms, |
|
| 1767 | - | "another attestation's" |
|
| 1768 | - | ); |
|
| 1769 | - | a.pull("origin").unwrap(); |
|
| 1770 | - | // gc keeps the acceptance with the attestation. |
|
| 1771 | - | host.gc(true).unwrap(); |
|
| 1772 | - | assert!(host.acceptance_of(&attest_ref).unwrap().is_some()); |
|
| 1773 | - | ||
| 1774 | - | let late = agent("late", "agent:claude@ada", now - 1_200_000, now - 600_000); |
|
| 1775 | - | let r = late.push("origin", false).unwrap(); |
|
| 1776 | - | assert!( |
|
| 1777 | - | !r[0].ok && r[0].reason.contains("may not update"), |
|
| 1778 | - | "an expired certificate: {r:?}" |
|
| 1779 | - | ); |
|
| 1780 | - | let other = agent("other", "agent:mallory@ada", now - 60_000, now + 600_000); |
|
| 1781 | - | let r = other.push("origin", false).unwrap(); |
|
| 1782 | - | assert!( |
|
| 1783 | - | !r[0].ok && r[0].reason.contains("may not update"), |
|
| 1784 | - | "a subject not granted: {r:?}" |
|
| 1785 | - | ); |
|
| 1786 | - | } |
|
| 1787 | - | ||
| 1788 | - | /// A tag made under one short-lived key and pushed under another: its |
|
| 1789 | - | /// signer travels with the push (identity and certificate), so a receiver |
|
| 1790 | - | /// that has never seen that key accepts it. A tag-only push by a keyless |
|
| 1791 | - | /// agent was refused "unknown key" (found releasing OxygenFlow 0.2.0). |
|
| 1792 | - | #[test] |
|
| 1793 | - | fn a_tag_signed_by_an_earlier_short_lived_key_is_pushed() { |
|
| 1794 | - | use crate::cert::{Certificate, SignedCertificate}; |
|
| 1795 | - | use crate::identity::Scope; |
|
| 1796 | - | hermetic(); |
|
| 1797 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 1798 | - | let root = dir.path(); |
|
| 1799 | - | let _origin = Repo::init(&root.join("origin")).unwrap(); |
|
| 1800 | - | let url = root.join("origin").to_string_lossy().to_string(); |
|
| 1801 | - | let mut a = Repo::clone(&url, &root.join("a")) |
|
| 1802 | - | .unwrap() |
|
| 1803 | - | .with_home(root.join("home-a")); |
|
| 1804 | - | a.create_identity("Ada", "[email protected]", IdentityScope::User) |
|
| 1805 | - | .unwrap(); |
|
| 1806 | - | write(&root.join("a"), "a.txt", b"a\n"); |
|
| 1807 | - | a.commit("a", &[]).unwrap(); |
|
| 1808 | - | a.policy_init().unwrap(); |
|
| 1809 | - | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1810 | - | let hub_sk = SecretKey::generate(); |
|
| 1811 | - | let hub = hub_sk.identity("hub", "[email protected]", 1); |
|
| 1812 | - | a.policy_grant_subject( |
|
| 1813 | - | "agent:claude@ada", |
|
| 1814 | - | hub.clone(), |
|
| 1815 | - | Scope::CHANGES | Scope::REFS, |
|
| 1816 | - | Some("*"), |
|
| 1817 | - | ) |
|
| 1818 | - | .unwrap(); |
|
| 1819 | - | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1820 | - | ||
| 1821 | - | let now = Hlc::now().wall_ms; |
|
| 1822 | - | let mut agent = Repo::clone(&url, &root.join("agent")) |
|
| 1823 | - | .unwrap() |
|
| 1824 | - | .with_home(root.join("home-agent")); |
|
| 1825 | - | agent.store().put_identity(&hub).unwrap(); |
|
| 1826 | - | let certify = |agent: &Repo, id| { |
|
| 1827 | - | let cert = SignedCertificate::sign( |
|
| 1828 | - | Certificate { |
|
| 1829 | - | key: id, |
|
| 1830 | - | issuer: hub.hash(), |
|
| 1831 | - | subject: "agent:claude@ada".into(), |
|
| 1832 | - | claims: vec![], |
|
| 1833 | - | scope: Scope::CHANGES | Scope::REFS, |
|
| 1834 | - | not_before: now - 60_000, |
|
| 1835 | - | expires: now + 600_000, |
|
| 1836 | - | grant: None, |
|
| 1837 | - | }, |
|
| 1838 | - | &hub, |
|
| 1839 | - | &hub_sk, |
|
| 1840 | - | ); |
|
| 1841 | - | agent.store().put_certificate(&cert).unwrap(); |
|
| 1842 | - | }; |
|
| 1843 | - | // The tag, under the first short-lived key. |
|
| 1844 | - | let (first, first_id) = agent |
|
| 1845 | - | .create_identity("claude", "[email protected]", IdentityScope::User) |
|
| 1846 | - | .unwrap(); |
|
| 1847 | - | certify(&agent, first_id); |
|
| 1848 | - | let heads = agent.bookmark_heads().unwrap(); |
|
| 1849 | - | agent.tag_create("v1", &heads, "first").unwrap(); |
|
| 1850 | - | // The push, under a second one (a later process's ten-minute key). |
|
| 1851 | - | let (second, second_id) = agent |
|
| 1852 | - | .create_identity("claude", "[email protected]", IdentityScope::User) |
|
| 1853 | - | .unwrap(); |
|
| 1854 | - | assert_ne!(first, second); |
|
| 1855 | - | certify(&agent, second_id); |
|
| 1856 | - | agent |
|
| 1857 | - | .set_user_config("author.signer", &second.to_hex()) |
|
| 1858 | - | .unwrap(); |
|
| 1859 | - | let host = Repo::open(&root.join("origin")).unwrap(); |
|
| 1860 | - | assert!( |
|
| 1861 | - | host.store().get_identity(&first).is_err(), |
|
| 1862 | - | "the host has never seen the tag's key" |
|
| 1863 | - | ); |
|
| 1864 | - | let r = agent.push("origin", false).unwrap(); |
|
| 1865 | - | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 1866 | - | let host = Repo::open(&root.join("origin")).unwrap(); |
|
| 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)); |
|
| 2037 | - | } |
|
| 2038 | - | ||
| 2039 | - | /// oxygenhub#266: a short-lived key its issuer certified moves a bookmark |
|
| 2040 | - | /// and signs no change (a keyless agent naming a bookmark). A third |
|
| 2041 | - | /// party's clone gets the key's identity, its certificate and the |
|
| 2042 | - | /// issuer's identity with the fetch, so the policy's subject grant |
|
| 2043 | - | /// authorizes the move and the signature verifies. |
|
| 2044 | - | #[test] |
|
| 2045 | - | fn a_bookmark_moved_by_a_certified_key_alone_is_fetched() { |
|
| 2046 | - | use crate::cert::{Certificate, SignedCertificate}; |
|
| 2047 | - | use crate::identity::Scope; |
|
| 2048 | - | hermetic(); |
|
| 2049 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 2050 | - | let root = dir.path(); |
|
| 2051 | - | let _origin = Repo::init(&root.join("origin")).unwrap(); |
|
| 2052 | - | let url = root.join("origin").to_string_lossy().to_string(); |
|
| 2053 | - | let mut a = Repo::clone(&url, &root.join("a")) |
|
| 2054 | - | .unwrap() |
|
| 2055 | - | .with_home(root.join("home-a")); |
|
| 2056 | - | a.create_identity("Ada", "[email protected]", IdentityScope::User) |
|
| 2057 | - | .unwrap(); |
|
| 2058 | - | write(&root.join("a"), "a.txt", b"a\n"); |
|
| 2059 | - | a.commit("a", &[]).unwrap(); |
|
| 2060 | - | a.policy_init().unwrap(); |
|
| 2061 | - | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2062 | - | let hub_sk = SecretKey::generate(); |
|
| 2063 | - | let hub = hub_sk.identity("hub", "[email protected]", 1); |
|
| 2064 | - | a.policy_grant_subject( |
|
| 2065 | - | "agent:claude@ada", |
|
| 2066 | - | hub.clone(), |
|
| 2067 | - | Scope::CHANGES | Scope::REFS, |
|
| 2068 | - | None, |
|
| 2069 | - | ) |
|
| 2070 | - | .unwrap(); |
|
| 2071 | - | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2072 | - | ||
| 2073 | - | let now = Hlc::now().wall_ms; |
|
| 2074 | - | let mut agent = Repo::clone(&url, &root.join("agent")) |
|
| 2075 | - | .unwrap() |
|
| 2076 | - | .with_home(root.join("home-agent")); |
|
| 2077 | - | let (key, id) = agent |
|
| 2078 | - | .create_identity("claude", "[email protected]", IdentityScope::User) |
|
| 2079 | - | .unwrap(); |
|
| 2080 | - | let cert = SignedCertificate::sign( |
|
| 2081 | - | Certificate { |
|
| 2082 | - | key: id, |
|
| 2083 | - | issuer: hub.hash(), |
|
| 2084 | - | subject: "agent:claude@ada".into(), |
|
| 2085 | - | claims: vec![], |
|
| 2086 | - | scope: Scope::CHANGES | Scope::REFS, |
|
| 2087 | - | not_before: now - 60_000, |
|
| 2088 | - | expires: now + 600_000, |
|
| 2089 | - | grant: None, |
|
| 2090 | - | }, |
|
| 2091 | - | &hub, |
|
| 2092 | - | &hub_sk, |
|
| 2093 | - | ); |
|
| 2094 | - | agent.store().put_identity(&hub).unwrap(); |
|
| 2095 | - | agent.store().put_certificate(&cert).unwrap(); |
|
| 2096 | - | let heads = agent.bookmark_heads().unwrap(); |
|
| 2097 | - | agent.bookmark_create("agent-work", Some(&heads)).unwrap(); |
|
| 2098 | - | let r = agent.push_bookmark("origin", "agent-work", false).unwrap(); |
|
| 2099 | - | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 2100 | - | let host = Repo::open(&root.join("origin")).unwrap(); |
|
| 2101 | - | let (_, sa) = host.latest_ref_attest("agent-work").unwrap().unwrap(); |
|
| 2102 | - | assert_eq!(sa.sigs[0].key, key, "the certified key alone moved it"); |
|
| 2103 | - | ||
| 2104 | - | // A fetch naming no change brings the certificate and both identities. |
|
| 2105 | - | let bundle = host.bundle_raw_for(&[], None).unwrap(); |
|
| 2106 | - | assert!(bundle.changes.is_empty() && bundle.raw_changes.is_empty()); |
|
| 2107 | - | assert_eq!(bundle.certificates.len(), 1); |
|
| 2108 | - | for k in [key, hub.hash()] { |
|
| 2109 | - | assert!(bundle |
|
| 2110 | - | .objects |
|
| 2111 | - | .iter() |
|
| 2112 | - | .any(|(t, b)| *t == ObjectType::Identity && hash_object(*t, b) == k)); |
|
| 2113 | - | } |
|
| 2114 | - | ||
| 2115 | - | let c = Repo::clone(&url, &root.join("c")).unwrap(); |
|
| 2116 | - | let tracked = c.view().unwrap().remotes["origin"].clone(); |
|
| 2117 | - | assert_eq!( |
|
| 2118 | - | tracked.get("agent-work"), |
|
| 2119 | - | host.view().unwrap().bookmarks.get("agent-work") |
|
| 2120 | - | ); |
|
| 2121 | - | assert!(c.store().certificate_of(&key).unwrap().is_some()); |
|
| 2122 | - | } |
|
| 2123 | - | ||
| 2124 | - | /// Trusted time (oxygenhub#240, spec §14.8): a fetched attestation by a |
|
| 2125 | - | /// short-lived key is judged at its integrated time, the earliest time a |
|
| 2126 | - | /// pinned witness cosigned a checkpoint holding it. A certificate valid |
|
| 2127 | - | /// then but expired now passes; a witness this user does not trust gives |
|
| 2128 | - | /// no time (the earlier rule, said so); a backdated attestation is noted; |
|
| 2129 | - | /// an answer without the new fields is judged as before. |
|
| 2130 | - | #[test] |
|
| 2131 | - | fn a_fetched_attestation_is_judged_at_its_integrated_time() { |
|
| 2132 | - | use super::transparency::LogCheck; |
|
| 2133 | - | use crate::cert::{Certificate, SignedCertificate}; |
|
| 2134 | - | use crate::identity::Scope; |
|
| 2135 | - | use crate::protocol::{Integration, Logged, RefsResponse, RemoteRef}; |
|
| 2136 | - | use crate::tlog::{self, Checkpoint, Entry, Hash, Integrated, Note}; |
|
| 2137 | - | hermetic(); |
|
| 2138 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 2139 | - | let root = dir.path(); |
|
| 2140 | - | let mut a = Repo::init(&root.join("a")) |
|
| 2141 | - | .unwrap() |
|
| 2142 | - | .with_home(root.join("home-a")); |
|
| 2143 | - | a.create_identity("Ada", "[email protected]", IdentityScope::User) |
|
| 2144 | - | .unwrap(); |
|
| 2145 | - | write(&root.join("a"), "a.txt", b"a\n"); |
|
| 2146 | - | a.commit("a", &[]).unwrap(); |
|
| 2147 | - | a.policy_init().unwrap(); |
|
| 2148 | - | let issuer_sk = SecretKey::generate(); |
|
| 2149 | - | let issuer = issuer_sk.identity("hub", "[email protected]", 1); |
|
| 2150 | - | a.policy_grant_subject( |
|
| 2151 | - | "agent:claude@ada", |
|
| 2152 | - | issuer.clone(), |
|
| 2153 | - | Scope::CHANGES | Scope::REFS, |
|
| 2154 | - | None, |
|
| 2155 | - | ) |
|
| 2156 | - | .unwrap(); |
|
| 2157 | - | let p = a.current_policy().unwrap().unwrap().1.policy; |
|
| 2158 | - | ||
| 2159 | - | // An agent key the issuer certified for ten minutes two hours ago. |
|
| 2160 | - | let now = Hlc::now().wall_ms; |
|
| 2161 | - | let t0 = now - 2 * 3_600_000; |
|
| 2162 | - | let agent_sk = SecretKey::generate(); |
|
| 2163 | - | let agent = agent_sk.identity("claude", "[email protected]", t0); |
|
| 2164 | - | let cert = SignedCertificate::sign( |
|
| 2165 | - | Certificate { |
|
| 2166 | - | key: agent.clone(), |
|
| 2167 | - | issuer: issuer.hash(), |
|
| 2168 | - | subject: "agent:claude@ada".into(), |
|
| 2169 | - | claims: vec![], |
|
| 2170 | - | scope: Scope::CHANGES | Scope::REFS, |
|
| 2171 | - | not_before: t0, |
|
| 2172 | - | expires: t0 + 600_000, |
|
| 2173 | - | grant: None, |
|
| 2174 | - | }, |
|
| 2175 | - | &issuer, |
|
| 2176 | - | &issuer_sk, |
|
| 2177 | - | ); |
|
| 2178 | - | a.store().put_identity(&agent).unwrap(); |
|
| 2179 | - | a.store().put_certificate(&cert).unwrap(); |
|
| 2180 | - | let signer = agent.hash(); |
|
| 2181 | - | let attested = |claimed: u64| { |
|
| 2182 | - | let attest = Attest { |
|
| 2183 | - | repo: a.id().unwrap(), |
|
| 2184 | - | hlc: Hlc { |
|
| 2185 | - | wall_ms: claimed, |
|
| 2186 | - | logical: 0, |
|
| 2187 | - | }, |
|
| 2188 | - | body: AttestBody::Ref { |
|
| 2189 | - | name: "main".into(), |
|
| 2190 | - | old_state: ObjectRef([1; 32]), |
|
| 2191 | - | new_state: ObjectRef([2; 32]), |
|
| 2192 | - | serial: 1, |
|
| 2193 | - | }, |
|
| 2194 | - | }; |
|
| 2195 | - | let sig = agent_sk.sign(Domain::Attest, &signer, &attest.hash()); |
|
| 2196 | - | SignedAttest { |
|
| 2197 | - | attest, |
|
| 2198 | - | sigs: vec![sig], |
|
| 2199 | - | } |
|
| 2200 | - | }; |
|
| 2201 | - | ||
| 2202 | - | // The hub's log: eight entries, the attestation's at 3, the latest |
|
| 2203 | - | // checkpoint of 8, and one of 4 that witnesses cosigned. |
|
| 2204 | - | let log_sk = SecretKey::generate(); |
|
| 2205 | - | let log_key = log_sk.identity("hub.example", "[email protected]", 1); |
|
| 2206 | - | let origin = "hub.example/log"; |
|
| 2207 | - | let hub = "https://hub.example"; |
|
| 2208 | - | let url = "https://hub.example/ada/a"; |
|
| 2209 | - | let w_sk = SecretKey::generate(); |
|
| 2210 | - | let w = w_sk.identity("w", "[email protected]", 1); |
|
| 2211 | - | let stranger_sk = SecretKey::generate(); |
|
| 2212 | - | let stranger = stranger_sk.identity("s", "[email protected]", 1); |
|
| 2213 | - | let sa = attested(t0 + 300_000); |
|
| 2214 | - | let entry_of = |i: u64| { |
|
| 2215 | - | let (kind, object) = if i == 3 { |
|
| 2216 | - | ("ref", sa.attest.hash()) |
|
| 2217 | - | } else { |
|
| 2218 | - | ("seal", ObjectRef([i as u8; 32])) |
|
| 2219 | - | }; |
|
| 2220 | - | Entry { |
|
| 2221 | - | kind: kind.into(), |
|
| 2222 | - | repo: sa.attest.repo, |
|
| 2223 | - | object, |
|
| 2224 | - | signers: vec![signer], |
|
| 2225 | - | received: t0 + 300_000, |
|
| 2226 | - | } |
|
| 2227 | - | .encode() |
|
| 2228 | - | }; |
|
| 2229 | - | let mut stored: Vec<Hash> = Vec::new(); |
|
| 2230 | - | for i in 0..8 { |
|
| 2231 | - | let add = tlog::stored_hashes_for_leaf(i, tlog::leaf_hash(&entry_of(i)), &stored).unwrap(); |
|
| 2232 | - | stored.extend(add); |
|
| 2233 | - | } |
|
| 2234 | - | let note_of = |n: u64, cosigners: &[(&str, &Identity, &SecretKey, u64)]| { |
|
| 2235 | - | let mut note = Checkpoint { |
|
| 2236 | - | origin: origin.into(), |
|
| 2237 | - | size: n, |
|
| 2238 | - | root: tlog::tree_hash(n, &stored).unwrap(), |
|
| 2239 | - | extensions: vec![], |
|
| 2240 | - | } |
|
| 2241 | - | .sign(&log_key, &log_sk) |
|
| 2242 | - | .unwrap(); |
|
| 2243 | - | for (name, id, sk, t) in cosigners { |
|
| 2244 | - | note.sigs |
|
| 2245 | - | .extend(tlog::cosign(¬e.text, name, id, sk, *t).unwrap()); |
|
| 2246 | - | } |
|
| 2247 | - | note.encode() |
|
| 2248 | - | }; |
|
| 2249 | - | let latest = note_of(8, &[]); |
|
| 2250 | - | let witnessed_at = (t0 + 360_000) / 1000; |
|
| 2251 | - | let cosigned = note_of( |
|
| 2252 | - | 4, |
|
| 2253 | - | &[ |
|
| 2254 | - | ("w.example", &w, &w_sk, witnessed_at), |
|
| 2255 | - | ("s.example", &stranger, &stranger_sk, 1), |
|
| 2256 | - | ], |
|
| 2257 | - | ); |
|
| 2258 | - | let answer = |integration: Vec<Integration>, witnesses: Vec<(String, Vec<u8>)>| RefsResponse { |
|
| 2259 | - | refs: vec![RemoteRef { |
|
| 2260 | - | name: "main".into(), |
|
| 2261 | - | state: ObjectRef([2; 32]), |
|
| 2262 | - | heads: vec![], |
|
| 2263 | - | attestation: Some(sa.clone()), |
|
| 2264 | - | last_serial: None, |
|
| 2265 | - | accepted: None, |
|
| 2266 | - | logged: Some(Logged { |
|
| 2267 | - | index: 3, |
|
| 2268 | - | entry: entry_of(3), |
|
| 2269 | - | proof: tlog::prove_inclusion(8, 3, &stored).unwrap(), |
|
| 2270 | - | }), |
|
| 2271 | - | integration, |
|
| 2272 | - | }], |
|
| 2273 | - | log: Some(latest.clone()), |
|
| 2274 | - | witnesses, |
|
| 2275 | - | ..RefsResponse::default() |
|
| 2276 | - | }; |
|
| 2277 | - | let proof4 = tlog::prove_inclusion(4, 3, &stored).unwrap(); |
|
| 2278 | - | let served = vec![Integration { |
|
| 2279 | - | note: cosigned.clone(), |
|
| 2280 | - | proof: proof4.clone(), |
|
| 2281 | - | }]; |
|
| 2282 | - | let listed = vec![("w.example".to_string(), w.encode())]; |
|
| 2283 | - | // This user saw the hub's log before (pinned, so nothing is fetched). |
|
| 2284 | - | a.remember_log(hub, &log_key, &Note::parse(&latest).unwrap()) |
|
| 2285 | - | .unwrap(); |
|
| 2286 | - | ||
| 2287 | - | // The witness is pinned on first sight, silently; the stranger's |
|
| 2288 | - | // earlier cosignature counts for nothing. |
|
| 2289 | - | let resp = answer(served.clone(), listed.clone()); |
|
| 2290 | - | let lc = a.check_log(url, None, &resp).unwrap(); |
|
| 2291 | - | assert!(lc.notes.is_empty(), "{:?}", lc.notes); |
|
| 2292 | - | let t = lc.time_of("main").unwrap(); |
|
| 2293 | - | assert_eq!((t.time, t.witness.as_str()), (witnessed_at, "w.example")); |
|
| 2294 | - | // The certificate expired an hour and fifty minutes ago, but covered |
|
| 2295 | - | // the integrated time: accepted, and nothing to say. |
|
| 2296 | - | assert!(cert.cert.expires < now); |
|
| 2297 | - | let mut notes = Vec::new(); |
|
| 2298 | - | a.authorize_in_time(&p, "origin", &resp.refs[0], &sa, &signer, &lc, &mut notes) |
|
| 2299 | - | .unwrap(); |
|
| 2300 | - | assert!(notes.is_empty(), "{notes:?}"); |
|
| 2301 | - | ||
| 2302 | - | // Only the stranger cosigned: no witnessed time, so the earlier rule |
|
| 2303 | - | // (the attestation's own time, inside the window), said so. |
|
| 2304 | - | let lonely = vec![Integration { |
|
| 2305 | - | note: note_of(4, &[("s.example", &stranger, &stranger_sk, 1)]), |
|
| 2306 | - | proof: proof4.clone(), |
|
| 2307 | - | }]; |
|
| 2308 | - | let resp = answer(lonely, vec![]); |
|
| 2309 | - | let lc = a.check_log(url, None, &resp).unwrap(); |
|
| 2310 | - | assert!(lc |
|
| 2311 | - | .time_of("main") |
|
| 2312 | - | .unwrap_err() |
|
| 2313 | - | .contains("no witness this user trusts")); |
|
| 2314 | - | let mut notes = Vec::new(); |
|
| 2315 | - | a.authorize_in_time(&p, "origin", &resp.refs[0], &sa, &signer, &lc, &mut notes) |
|
| 2316 | - | .unwrap(); |
|
| 2317 | - | assert!( |
|
| 2318 | - | notes.len() == 1 && notes[0].contains("not at a witnessed time"), |
|
| 2319 | - | "{notes:?}" |
|
| 2320 | - | ); |
|
| 2321 | - | ||
| 2322 | - | // A witness listed later under the pinned name with another key is |
|
| 2323 | - | // pinned with a note, as a new log key is. |
|
| 2324 | - | let resp = answer( |
|
| 2325 | - | served.clone(), |
|
| 2326 | - | vec![("w.example".into(), stranger.encode())], |
|
| 2327 | - | ); |
|
| 2328 | - | let lc = a.check_log(url, None, &resp).unwrap(); |
|
| 2329 | - | assert!( |
|
| 2330 | - | lc.notes.iter().any(|n| n.contains("another key")), |
|
| 2331 | - | "{:?}", |
|
| 2332 | - | lc.notes |
|
| 2333 | - | ); |
|
| 2334 | - | assert!( |
|
| 2335 | - | lc.time_of("main").is_err(), |
|
| 2336 | - | "w's cosignature is by its old key" |
|
| 2337 | - | ); |
|
| 2338 | - | a.check_log(url, None, &answer(served.clone(), listed.clone())) |
|
| 2339 | - | .unwrap(); |
|
| 2340 | - | ||
| 2341 | - | // An old peer's answer: no log, no witnesses, no integration. Judged |
|
| 2342 | - | // as before, silently. |
|
| 2343 | - | let mut old = answer(vec![], vec![]); |
|
| 2344 | - | old.log = None; |
|
| 2345 | - | old.refs[0].logged = None; |
|
| 2346 | - | let lc = a.check_log(url, None, &old).unwrap(); |
|
| 2347 | - | assert!(!lc.kept && lc.notes.is_empty()); |
|
| 2348 | - | let mut notes = Vec::new(); |
|
| 2349 | - | a.authorize_in_time(&p, "origin", &old.refs[0], &sa, &signer, &lc, &mut notes) |
|
| 2350 | - | .unwrap(); |
|
| 2351 | - | assert!(notes.is_empty(), "{notes:?}"); |
|
| 2352 | - | ||
| 2353 | - | // Judged at given integrated times. |
|
| 2354 | - | let at = |secs_after_t0: u64| { |
|
| 2355 | - | let mut times = std::collections::HashMap::new(); |
|
| 2356 | - | times.insert( |
|
| 2357 | - | "main".to_string(), |
|
| 2358 | - | Ok(Integrated { |
|
| 2359 | - | time: (t0 + secs_after_t0 * 1000) / 1000, |
|
| 2360 | - | witness: "w.example".into(), |
|
| 2361 | - | size: 4, |
|
| 2362 | - | }), |
|
| 2363 | - | ); |
|
| 2364 | - | LogCheck { |
|
| 2365 | - | kept: true, |
|
| 2366 | - | times, |
|
| 2367 | - | ..LogCheck::default() |
|
| 2368 | - | } |
|
| 2369 | - | }; |
|
| 2370 | - | let judged = |sa: &SignedAttest, lc: &LogCheck| { |
|
| 2371 | - | let mut notes = Vec::new(); |
|
| 2372 | - | a.authorize_in_time(&p, "origin", &old.refs[0], sa, &signer, lc, &mut notes) |
|
| 2373 | - | .map(|_| notes) |
|
| 2374 | - | }; |
|
| 2375 | - | // Claimed inside the window, first witnessed an hour later, after |
|
| 2376 | - | // the certificate expired: backdated, or held back. Accepted by the |
|
| 2377 | - | // earlier rule while logs are new, with both said. |
|
| 2378 | - | let notes = judged(&sa, &at(3600)).unwrap(); |
|
| 2379 | - | assert!( |
|
| 2380 | - | notes |
|
| 2381 | - | .iter() |
|
| 2382 | - | .any(|n| n.contains("had lapsed when witness w.example")) |
|
| 2383 | - | && notes |
|
| 2384 | - | .iter() |
|
| 2385 | - | .any(|n| n.contains("55 minutes before witness")), |
|
| 2386 | - | "{notes:?}" |
|
| 2387 | - | ); |
|
| 2388 | - | // Claimed after the certificate expired, but witnessed inside its |
|
| 2389 | - | // window: the clock the signer did not choose decides. The gap is |
|
| 2390 | - | // still noted. |
|
| 2391 | - | let fast = attested(now); |
|
| 2392 | - | let notes = judged(&fast, &at(400)).unwrap(); |
|
| 2393 | - | assert!( |
|
| 2394 | - | notes.len() == 1 && notes[0].contains("after witness w.example saw it"), |
|
| 2395 | - | "{notes:?}" |
|
| 2396 | - | ); |
|
| 2397 | - | // Claimed and witnessed after it expired: refused, as before. |
|
| 2398 | - | assert!(judged(&fast, &at(3600)).is_err()); |
|
| 2399 | - | // A gap within ten minutes is nothing to note. |
|
| 2400 | - | assert!(judged(&sa, &at(500)).unwrap().is_empty()); |
|
| 2401 | - | } |
|
| 2402 | - | ||
| 2403 | - | /// Keyless step 5 (oxygenhub#241): an agent's fresh key, certified for a |
|
| 2404 | - | /// subject the author granted, seals changes in the author's name; with |
|
| 2405 | - | /// no grant, a stranger's, or one that had ended, it is neither the |
|
| 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 | - | ||
| 2496 | - | #[test] |
|
| 2497 | - | fn a_certified_key_acts_for_its_author_under_a_grant() { |
|
| 2498 | - | use crate::cert::{Certificate, Grant, SignedCertificate, SignedGrant}; |
|
| 2499 | - | use crate::identity::{Identity, Scope}; |
|
| 2500 | - | hermetic(); |
|
| 2501 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 2502 | - | let root = dir.path(); |
|
| 2503 | - | let _origin = Repo::init(&root.join("origin")).unwrap(); |
|
| 2504 | - | let url = root.join("origin").to_string_lossy().to_string(); |
|
| 2505 | - | let mut a = Repo::clone(&url, &root.join("a")) |
|
| 2506 | - | .unwrap() |
|
| 2507 | - | .with_home(root.join("home-a")); |
|
| 2508 | - | let (ada, _) = a |
|
| 2509 | - | .create_identity("Ada", "[email protected]", IdentityScope::User) |
|
| 2510 | - | .unwrap(); |
|
| 2511 | - | write(&root.join("a"), "a.txt", b"a\n"); |
|
| 2512 | - | a.commit("a", &[]).unwrap(); |
|
| 2513 | - | a.policy_init().unwrap(); |
|
| 2514 | - | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2515 | - | let hub_sk = SecretKey::generate(); |
|
| 2516 | - | let hub = hub_sk.identity("hub", "[email protected]", 1); |
|
| 2517 | - | a.policy_grant_subject( |
|
| 2518 | - | "agent:claude@ada", |
|
| 2519 | - | hub.clone(), |
|
| 2520 | - | Scope::CHANGES | Scope::REFS, |
|
| 2521 | - | None, |
|
| 2522 | - | ) |
|
| 2523 | - | .unwrap(); |
|
| 2524 | - | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2525 | - | let (_, ada_sk) = a.secret_key().unwrap().unwrap(); |
|
| 2526 | - | let ada_id = a.identity(&ada).unwrap(); |
|
| 2527 | - | ||
| 2528 | - | let now = Hlc::now().wall_ms; |
|
| 2529 | - | let grant = |by: &Identity, sk: &SecretKey, from: u64, to: u64| { |
|
| 2530 | - | SignedGrant::sign( |
|
| 2531 | - | Grant { |
|
| 2532 | - | grantor: by.hash(), |
|
| 2533 | - | subject: "agent:claude@ada".into(), |
|
| 2534 | - | issuer: hub.hash(), |
|
| 2535 | - | scope: Scope::CHANGES | Scope::SEAL, |
|
| 2536 | - | not_before: from, |
|
| 2537 | - | expires: to, |
|
| 2538 | - | }, |
|
| 2539 | - | by, |
|
| 2540 | - | sk, |
|
| 2541 | - | ) |
|
| 2542 | - | }; |
|
| 2543 | - | // Ada's work as an agent runs it: Ada the author, a fresh key the hub |
|
| 2544 | - | // certified (with `g` inside) the signer. |
|
| 2545 | - | let agent = |name: &str, g: Option<SignedGrant>| { |
|
| 2546 | - | let mut r = Repo::clone(&url, &root.join(name)) |
|
| 2547 | - | .unwrap() |
|
| 2548 | - | .with_home(root.join(format!("home-{name}"))); |
|
| 2549 | - | let (key, id) = r |
|
| 2550 | - | .create_identity(name, "[email protected]", IdentityScope::User) |
|
| 2551 | - | .unwrap(); |
|
| 2552 | - | r.set_user_config("author.key", &ada.to_hex()).unwrap(); |
|
| 2553 | - | r.set_user_config("author.signer", &key.to_hex()).unwrap(); |
|
| 2554 | - | let cert = SignedCertificate::sign( |
|
| 2555 | - | Certificate { |
|
| 2556 | - | key: id, |
|
| 2557 | - | issuer: hub.hash(), |
|
| 2558 | - | subject: "agent:claude@ada".into(), |
|
| 2559 | - | claims: vec![], |
|
| 2560 | - | scope: Scope::CHANGES | Scope::REFS | Scope::SEAL, |
|
| 2561 | - | not_before: now - 60_000, |
|
| 2562 | - | expires: now + 600_000, |
|
| 2563 | - | grant: g, |
|
| 2564 | - | }, |
|
| 2565 | - | &hub, |
|
| 2566 | - | &hub_sk, |
|
| 2567 | - | ); |
|
| 2568 | - | r.store().put_identity(&hub).unwrap(); |
|
| 2569 | - | r.store().put_certificate(&cert).unwrap(); |
|
| 2570 | - | write(&root.join(name), &format!("{name}.txt"), b"agent work\n"); |
|
| 2571 | - | let h = r.commit("work", &[]).unwrap(); |
|
| 2572 | - | assert_eq!(r.store().get_change_meta(&h).unwrap().author, ada); |
|
| 2573 | - | (r, key, h) |
|
| 2574 | - | }; |
|
| 2575 | - | ||
| 2576 | - | let (claude, key, h) = agent( |
|
| 2577 | - | "claude", |
|
| 2578 | - | Some(grant(&ada_id, &ada_sk, now - 60_000, now + 86_400_000)), |
|
| 2579 | - | ); |
|
| 2580 | - | let r = claude.push("origin", false).unwrap(); |
|
| 2581 | - | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 2582 | - | let host = Repo::open(&root.join("origin")).unwrap(); |
|
| 2583 | - | assert!( |
|
| 2584 | - | matches!( |
|
| 2585 | - | host.authenticate_change(&h).unwrap(), |
|
| 2586 | - | SignatureStatus::Valid { key: k, sealed: true, name, .. } if k == key && name == "agent:claude@ada" |
|
| 2587 | - | ), |
|
| 2588 | - | "named by its certificate's subject" |
|
| 2589 | - | ); |
|
| 2590 | - | a.pull("origin").unwrap(); |
|
| 2591 | - | assert!(matches!( |
|
| 2592 | - | a.authenticate_change(&h).unwrap(), |
|
| 2593 | - | SignatureStatus::Valid { key: k, .. } if k == key |
|
| 2594 | - | )); |
|
| 2595 | - | ||
| 2596 | - | let (none, _, _) = agent("none", None); |
|
| 2597 | - | let r = none.push("origin", false).unwrap(); |
|
| 2598 | - | assert!(r.iter().all(|x| !x.ok), "no grant: {r:?}"); |
|
| 2599 | - | let mallory_sk = SecretKey::generate(); |
|
| 2600 | - | let mallory = mallory_sk.identity("Mallory", "[email protected]", 1); |
|
| 2601 | - | let (stranger, _, _) = agent( |
|
| 2602 | - | "stranger", |
|
| 2603 | - | Some(grant(&mallory, &mallory_sk, now - 60_000, now + 600_000)), |
|
| 2604 | - | ); |
|
| 2605 | - | let r = stranger.push("origin", false).unwrap(); |
|
| 2606 | - | assert!(r.iter().all(|x| !x.ok), "a stranger's grant: {r:?}"); |
|
| 2607 | - | let (ended, _, eh) = agent( |
|
| 2608 | - | "ended", |
|
| 2609 | - | Some(grant(&ada_id, &ada_sk, now - 7_200_000, now - 3_600_000)), |
|
| 2610 | 991 | ); |
|
| 2611 | - | let r = ended.push("origin", false).unwrap(); |
|
| 2612 | - | assert!(r.iter().all(|x| !x.ok), "an ended grant: {r:?}"); |
|
| 2613 | - | assert!(matches!( |
|
| 2614 | - | ended.authenticate_change(&eh).unwrap(), |
|
| 2615 | - | SignatureStatus::Invalid(e) if e.contains("grant did not cover") |
|
| 2616 | - | )); |
|
| 2617 | 992 | } |
|
| 2618 | 993 | ||
| 2619 | 994 | #[test] |
|
| … | 17 unchanged lines |
||
| 2637 | 1012 | write(&root.join("b"), "b.txt", b"b\n"); |
|
| 2638 | 1013 | b.commit("unsigned before policy", &[]).unwrap(); |
|
| 2639 | 1014 | assert!(b.push("origin", false).unwrap()[0].ok); |
|
| 2640 | - | origin.refresh().unwrap(); |
|
| 2641 | 1015 | ||
| 2642 | 1016 | // A creates the policy and pushes it; origin now enforces it. |
|
| 2643 | 1017 | write(&root.join("a"), "a.txt", b"a\n"); |
|
| … | 2 unchanged lines |
||
| 2646 | 1020 | assert_eq!(sp.policy.serial, 1); |
|
| 2647 | 1021 | a.pull("origin").unwrap(); |
|
| 2648 | 1022 | let results = a.push("origin", false).unwrap(); |
|
| 2649 | - | origin.refresh().unwrap(); |
|
| 2650 | 1023 | assert!(results.iter().all(|r| r.ok), "{results:?}"); |
|
| 2651 | 1024 | assert_eq!(origin.current_policy().unwrap().unwrap().1.policy.serial, 1); |
|
| 2652 | 1025 | ||
| … | 12 unchanged lines |
||
| 2665 | 1038 | let h_b2 = b.commit("b2 as Bob", &[]).unwrap(); |
|
| 2666 | 1039 | assert_eq!(b.store().get_change(&h_b2).unwrap().author, key_b); |
|
| 2667 | 1040 | let r = b.push("origin", false).unwrap(); |
|
| 2668 | - | origin.refresh().unwrap(); |
|
| 2669 | 1041 | assert!( |
|
| 2670 | 1042 | !r[0].ok && r[0].reason.contains("may not update"), |
|
| 2671 | 1043 | "{:?}", |
|
| … | 4 unchanged lines |
||
| 2676 | 1048 | let sp = a.policy_grant(id_b.clone(), None).unwrap(); |
|
| 2677 | 1049 | assert_eq!(sp.policy.serial, 2); |
|
| 2678 | 1050 | let r = a.push("origin", false).unwrap(); |
|
| 2679 | - | origin.refresh().unwrap(); |
|
| 2680 | 1051 | assert!(r.iter().any(|x| x.name == "policy" && x.ok), "{r:?}"); |
|
| 2681 | 1052 | // B must fetch the new policy before pushing. |
|
| 2682 | 1053 | assert!(b |
|
| … | 8 unchanged lines |
||
| 2691 | 1062 | b.pull("origin").unwrap(); |
|
| 2692 | 1063 | assert_eq!(b.current_policy().unwrap().unwrap().1.policy.serial, 2); |
|
| 2693 | 1064 | let r = b.push("origin", false).unwrap(); |
|
| 2694 | - | origin.refresh().unwrap(); |
|
| 2695 | 1065 | assert!(r[0].ok, "{:?}", r); |
|
| 2696 | 1066 | // Origin verified B's change under the policy. |
|
| 2697 | 1067 | let heads = origin.bookmark_heads().unwrap(); |
|
| … | 36 unchanged lines |
||
| 2734 | 1104 | a.pull("origin").unwrap(); |
|
| 2735 | 1105 | a.policy_revoke(&key_b).unwrap(); |
|
| 2736 | 1106 | let r = a.push("origin", false).unwrap(); |
|
| 2737 | - | origin.refresh().unwrap(); |
|
| 2738 | 1107 | assert!(r.iter().any(|x| x.name == "policy" && x.ok), "{r:?}"); |
|
| 2739 | 1108 | b.pull("origin").unwrap(); |
|
| 2740 | 1109 | write(&root.join("b"), "b.txt", b"b3\n"); |
|
| 2741 | 1110 | b.commit("b3", &[]).unwrap(); |
|
| 2742 | 1111 | let r = b.push("origin", false).unwrap(); |
|
| 2743 | - | origin.refresh().unwrap(); |
|
| 2744 | 1112 | assert!(!r[0].ok, "{:?}", r); |
|
| 2745 | 1113 | ||
| 2746 | 1114 | // A clone by a third party receives and verifies everything. |
|
| … | 912 unchanged lines |
||
| 3659 | 2027 | "the state still has everything" |
|
| 3660 | 2028 | ); |
|
| 3661 | 2029 | assert!(a.push("origin", false).unwrap()[0].ok); |
|
| 3662 | - | origin.refresh().unwrap(); |
|
| 3663 | 2030 | // Files created outside the scope are ignored by status. |
|
| 3664 | 2031 | write(&root.join("a"), "docs/new.md", b"ignored\n"); |
|
| 3665 | 2032 | assert!(a.snapshot("status").unwrap().statuses.is_empty()); |
|
| … | 23 unchanged lines |
||
| 3689 | 2056 | ); |
|
| 3690 | 2057 | assert!(full.files.contains_key("README.md")); |
|
| 3691 | 2058 | let _ = h1; |
|
| 3692 | - | } |
|
| 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`. |
|
| 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 | 2059 | } |
|
| 3772 | 2060 | ||
| 3773 | 2061 | #[test] |
|
| … | 48 unchanged lines |
||
| 3822 | 2110 | write(&root.join("a"), "a.txt", b"0\n1\n2\n3\n4\n5\n6\n7\n8\n"); |
|
| 3823 | 2111 | let h_new = a.commit("c8", &[]).unwrap(); |
|
| 3824 | 2112 | assert!(a.push("origin", false).unwrap()[0].ok); |
|
| 3825 | - | origin.refresh().unwrap(); |
|
| 3826 | 2113 | assert_eq!(origin.bookmark_heads().unwrap(), vec![h_new]); |
|
| 3827 | 2114 | // Fetching new work from origin into the shallow clone works too. |
|
| 3828 | 2115 | write(&origin_dir, "b.txt", b"b\n"); |
|
| … | 24 unchanged lines |
||
| 3853 | 2140 | .unwrap() |
|
| 3854 | 2141 | .len(), |
|
| 3855 | 2142 | 10 |
|
| 3856 | - | ); |
|
| 3857 | - | } |
|
| 3858 | - | ||
| 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 | 2143 | ); |
|
| 3942 | 2144 | } |
|
| 3943 | 2145 | ||
| … | 27 unchanged lines |
||
| 3971 | 2173 | assert_eq!(a.resolve_heads("main").unwrap(), vec![h2]); |
|
| 3972 | 2174 | let (_, sa) = a.tag_attest("v1").unwrap().unwrap(); |
|
| 3973 | 2175 | 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); |
|
| 3979 | 2176 | // Checkout by tag. |
|
| 3980 | 2177 | a.checkout(&a.resolve_heads("v1").unwrap(), false, "co") |
|
| 3981 | 2178 | .unwrap(); |
|
| … | 3 unchanged lines |
||
| 3985 | 2182 | ||
| 3986 | 2183 | // Push carries the tag; a second attestation for the name is refused. |
|
| 3987 | 2184 | let r = a.push("origin", false).unwrap(); |
|
| 3988 | - | origin.refresh().unwrap(); |
|
| 3989 | 2185 | assert!(r.iter().any(|x| x.name == "tags/v1" && x.ok), "{r:?}"); |
|
| 3990 | 2186 | assert_eq!(origin.tag_heads("v1").unwrap(), Some(vec![h1])); |
|
| 3991 | 2187 | let mut forged = a |
|
| … | 21 unchanged lines |
||
| 4013 | 2209 | assert!(b.tags().unwrap()[0].signer.as_deref() == Some("Ada")); |
|
| 4014 | 2210 | a.tag_create("v2", &[h2], "second").unwrap(); |
|
| 4015 | 2211 | a.push("origin", false).unwrap(); |
|
| 4016 | - | origin.refresh().unwrap(); |
|
| 4017 | 2212 | let rep = b.fetch("origin").unwrap(); |
|
| 4018 | 2213 | assert_eq!(rep.new_tags, 1); |
|
| 4019 | 2214 | assert_eq!(b.tag_heads("v2").unwrap(), Some(vec![h2])); |
|
| … | 1 unchanged lines |
||
| 4021 | 2216 | // Under a policy, an unattested tag is refused and an attested one by the root passes. |
|
| 4022 | 2217 | a.policy_init().unwrap(); |
|
| 4023 | 2218 | a.push("origin", false).unwrap(); |
|
| 4024 | - | origin.refresh().unwrap(); |
|
| 4025 | - | let unattested = crate::protocol::TagRef::new( |
|
| 4026 | - | "v3".into(), |
|
| 4027 | - | origin |
|
| 2219 | + | let unattested = crate::protocol::TagRef { |
|
| 2220 | + | name: "v3".into(), |
|
| 2221 | + | state: origin |
|
| 4028 | 2222 | .store() |
|
| 4029 | 2223 | .put_state(&StateObject::new(vec![h2])) |
|
| 4030 | 2224 | .unwrap(), |
|
| 4031 | - | vec![h2], |
|
| 4032 | - | None, |
|
| 4033 | - | ); |
|
| 2225 | + | heads: vec![h2], |
|
| 2226 | + | attestation: None, |
|
| 2227 | + | }; |
|
| 4034 | 2228 | let r = origin |
|
| 4035 | 2229 | .receive_push(Bundle::default(), vec![], None, vec![unattested]) |
|
| 4036 | 2230 | .unwrap(); |
|
| 4037 | 2231 | assert!(!r[0].ok && r[0].reason.contains("attestation"), "{r:?}"); |
|
| 4038 | 2232 | a.tag_create("v3", &[h2], "").unwrap(); |
|
| 4039 | 2233 | let r = a.push("origin", false).unwrap(); |
|
| 4040 | - | origin.refresh().unwrap(); |
|
| 4041 | 2234 | assert!(r.iter().any(|x| x.name == "tags/v3" && x.ok), "{r:?}"); |
|
| 4042 | 2235 | assert!(a.gc(true).is_ok()); |
|
| 4043 | 2236 | assert_eq!(a.tags().unwrap().len(), 3, "tags survive gc"); |
|
| 4044 | - | ||
| 4045 | - | // After a rotation the policy names only the new key, yet a fresh clone |
|
| 4046 | - | // still takes the tags the old key made before it; the old key makes |
|
| 4047 | - | // nothing new. |
|
| 4048 | - | let rot = a.rotate_identity().unwrap(); |
|
| 4049 | - | let r = a.push("origin", false).unwrap(); |
|
| 4050 | - | origin.refresh().unwrap(); |
|
| 4051 | - | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 4052 | - | let c = Repo::clone(&url, &root.join("c")) |
|
| 4053 | - | .unwrap() |
|
| 4054 | - | .with_home(root.join("home-c")); |
|
| 4055 | - | assert_eq!(c.tag_heads("v3").unwrap(), Some(vec![h2])); |
|
| 4056 | - | let (_, sp) = c.current_policy().unwrap().unwrap(); |
|
| 4057 | - | assert!(!sp.policy.authorizes(&key_a, "tags/v9")); |
|
| 4058 | - | let made = c.identity(&rot.new).unwrap().created; |
|
| 4059 | - | assert!(c.predecessor_authorized(&sp.policy, &key_a, "tags/v9", made - 1)); |
|
| 4060 | - | assert!(!c.predecessor_authorized(&sp.policy, &key_a, "tags/v9", made)); |
|
| 4061 | - | assert!(!c.predecessor_authorized(&sp.policy, &rot.new, "tags/v9", 0)); |
|
| 4062 | 2237 | } |
|
| 4063 | 2238 | ||
| 4064 | 2239 | #[test] |
|
| … | 9 unchanged lines |
||
| 4074 | 2249 | ||
| 4075 | 2250 | // A second workspace elsewhere, checked out on the bookmark. |
|
| 4076 | 2251 | let ws_dir = root.join("ws2"); |
|
| 4077 | - | let ops = repo.op_log().unwrap().len(); |
|
| 4078 | 2252 | 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 | - | ); |
|
| 4084 | 2253 | assert!(ws_dir.join(".oxy").is_file()); |
|
| 4085 | 2254 | assert_eq!(fs::read(ws_dir.join("a.txt")).unwrap(), b"a\n"); |
|
| 4086 | 2255 | assert_ne!(ws.workspace(), repo.workspace()); |
|
| … | 13 unchanged lines |
||
| 4100 | 2269 | "the first workspace is untouched" |
|
| 4101 | 2270 | ); |
|
| 4102 | 2271 | let h2 = ws.commit("b from ws2", &[]).unwrap(); |
|
| 4103 | - | repo.refresh().unwrap(); |
|
| 4104 | 2272 | let mut heads = repo.bookmark_heads().unwrap(); |
|
| 4105 | 2273 | heads.sort(); |
|
| 4106 | 2274 | let mut want = vec![h1, h2]; |
|
| … | 278 unchanged lines |
||
| 4385 | 2553 | let h1 = a.commit("one", &[]).unwrap(); |
|
| 4386 | 2554 | assert!(a.policy_init().unwrap().applied); |
|
| 4387 | 2555 | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 4388 | - | origin.refresh().unwrap(); |
|
| 4389 | 2556 | ||
| 4390 | 2557 | // Expiry: an identity that had expired when the change was made does not authenticate it. |
|
| 4391 | 2558 | let mut e = Repo::init(&root.join("e")) |
|
| … | 49 unchanged lines |
||
| 4441 | 2608 | ); |
|
| 4442 | 2609 | // Origin accepts the rotated policy (signed by the old root) and the new key's change. |
|
| 4443 | 2610 | let res = a.push("origin", false).unwrap(); |
|
| 4444 | - | origin.refresh().unwrap(); |
|
| 4445 | 2611 | assert!(res.iter().all(|x| x.ok), "{res:?}"); |
|
| 4446 | 2612 | assert_eq!(origin.current_policy().unwrap().unwrap().1.policy.serial, 2); |
|
| 4447 | 2613 | let key_a = r.new; |
|
| … | 22 unchanged lines |
||
| 4470 | 2636 | "already a delegate" |
|
| 4471 | 2637 | ); |
|
| 4472 | 2638 | assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok)); |
|
| 4473 | - | origin.refresh().unwrap(); |
|
| 4474 | 2639 | write(&root.join("a"), "a.txt", b"a3\n"); |
|
| 4475 | 2640 | let h3 = a.commit("three", &[]).unwrap(); |
|
| 4476 | 2641 | assert!( |
|
| … | 19 unchanged lines |
||
| 4496 | 2661 | assert!(a.delegate_remove(&key_b).is_err()); |
|
| 4497 | 2662 | let key_a3 = r.new; |
|
| 4498 | 2663 | let res = a.push("origin", false).unwrap(); |
|
| 4499 | - | origin.refresh().unwrap(); |
|
| 4500 | 2664 | assert!(res.iter().all(|x| x.ok), "{res:?}"); |
|
| 4501 | 2665 | ||
| 4502 | 2666 | // Multi-signer policy: Bob becomes a root and the threshold rises to 2, |
|
| … | 3 unchanged lines |
||
| 4506 | 2670 | assert!(a.policy_set_threshold(3).is_err()); |
|
| 4507 | 2671 | assert!(a.policy_set_threshold(2).unwrap().applied); |
|
| 4508 | 2672 | assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok)); |
|
| 4509 | - | origin.refresh().unwrap(); |
|
| 4510 | 2673 | b.pull("origin").unwrap(); |
|
| 4511 | 2674 | assert_eq!( |
|
| 4512 | 2675 | b.current_policy().unwrap().unwrap().1.policy.root_threshold, |
|
| … | 37 unchanged lines |
||
| 4550 | 2713 | .unwrap() |
|
| 4551 | 2714 | .iter() |
|
| 4552 | 2715 | .any(|x| x.name == "policy" && x.ok)); |
|
| 4553 | - | origin.refresh().unwrap(); |
|
| 4554 | 2716 | a.pull("origin").unwrap(); |
|
| 4555 | 2717 | assert_eq!( |
|
| 4556 | 2718 | a.current_policy().unwrap().unwrap().1.policy.serial, |
|
| … | 586 unchanged lines |
||
| 5143 | 3305 | write(&r, "a.txt", b"a\n"); |
|
| 5144 | 3306 | let h_a = repo.commit("a", &[]).unwrap(); |
|
| 5145 | 3307 | assert!(repo.push("origin", false).unwrap()[0].ok); |
|
| 5146 | - | origin.refresh().unwrap(); |
|
| 5147 | 3308 | ||
| 5148 | 3309 | // A bookmark at the working copy's base, then work on it. |
|
| 5149 | 3310 | assert!(repo.bookmark_create("bad name..", None).is_err()); |
|
| … | 52 unchanged lines |
||
| 5202 | 3363 | assert!(repo.merge("main").is_err()); |
|
| 5203 | 3364 | // The other bookmark can be pushed by name and appears at origin. |
|
| 5204 | 3365 | assert!(repo.push_bookmark("origin", "feature", false).unwrap()[0].ok); |
|
| 5205 | - | origin.refresh().unwrap(); |
|
| 5206 | 3366 | assert_eq!(origin.resolve_heads("feature").unwrap(), vec![h_b]); |
|
| 5207 | 3367 | assert!(repo.push("origin", false).unwrap()[0].ok); |
|
| 5208 | - | origin.refresh().unwrap(); |
|
| 5209 | 3368 | assert_eq!( |
|
| 5210 | 3369 | repo.bookmarks() |
|
| 5211 | 3370 | .unwrap() |
|
| … | 159 unchanged lines |
||
| 5371 | 3530 | assert!( |
|
| 5372 | 3531 | !clone.snapshot("status").unwrap().changed, |
|
| 5373 | 3532 | "an unregistered nested repository is ignored" |
|
| 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 | 3533 | ); |
|
| 5402 | - | assert!(!clone.snapshot("status").unwrap().changed); |
|
| 5403 | 3534 | } |
|
| 5404 | 3535 | ||
| 5405 | 3536 | #[test] |
|
| … | 514 unchanged lines |
||
| 5920 | 4051 | a.tag_create("v1", &[h2], "first release").unwrap(); |
|
| 5921 | 4052 | a.checkpoint().unwrap(); |
|
| 5922 | 4053 | assert!(a.push("origin", false).unwrap()[0].ok); |
|
| 5923 | - | origin.refresh().unwrap(); |
|
| 5924 | 4054 | ||
| 5925 | 4055 | // Sound: signed changes, a seal, a tag, a checkpoint, a ref attestation. |
|
| 5926 | 4056 | for repo in [&a, &origin] { |
|
| … | 350 unchanged lines |
||
| 6277 | 4407 | .unwrap() |
|
| 6278 | 4408 | .contains("oxy identity create")); |
|
| 6279 | 4409 | assert!(find(&r, "repository", Health::Ok).is_some()); |
|
| 6280 | - | // oxygen#127: no policy is said, with who sets one. |
|
| 6281 | - | assert!(find(&r, "trust", Health::Warn) |
|
| 6282 | - | .unwrap() |
|
| 6283 | - | .hint |
|
| 6284 | - | .unwrap() |
|
| 6285 | - | .contains("oxy policy init")); |
|
| 6286 | 4410 | ||
| 6287 | 4411 | repo.create_identity("Ada", "[email protected]", IdentityScope::Repo) |
|
| 6288 | 4412 | .unwrap(); |
|
| 6289 | - | repo.policy_init().unwrap(); |
|
| 6290 | - | let r = repo.doctor(false); |
|
| 6291 | - | let trust = find(&r, "trust", Health::Ok).expect("a fresh policy is fine"); |
|
| 6292 | - | assert!( |
|
| 6293 | - | trust.message.contains("serial 1") && trust.message.contains("1 root"), |
|
| 6294 | - | "{trust:?}" |
|
| 6295 | - | ); |
|
| 6296 | 4413 | repo.config.set("author.name", "Ada").unwrap(); |
|
| 6297 | 4414 | repo.config.set("author.email", "[email protected]").unwrap(); |
|
| 6298 | 4415 | repo.config |
|
| … | 703 unchanged lines |
||
| 7002 | 5119 | write(&r, "a.txt", b"a\n"); |
|
| 7003 | 5120 | let h_a = repo.commit("a", &[]).unwrap(); |
|
| 7004 | 5121 | assert!(repo.push("origin", false).unwrap()[0].ok); |
|
| 7005 | - | origin.refresh().unwrap(); |
|
| 7006 | 5122 | repo.bookmark_create("feature", None).unwrap(); |
|
| 7007 | 5123 | repo.bookmark_create("feature/x", None).unwrap(); |
|
| 7008 | 5124 | repo.bookmark_switch("feature/x", false).unwrap(); |
|
| … | 1 unchanged lines |
||
| 7010 | 5126 | let h_b = repo.commit("b", &[]).unwrap(); |
|
| 7011 | 5127 | for name in ["feature", "feature/x"] { |
|
| 7012 | 5128 | let res = repo.push_bookmark("origin", name, false).unwrap(); |
|
| 7013 | - | origin.refresh().unwrap(); |
|
| 7014 | 5129 | assert!(res[0].ok, "{name}: {res:?}"); |
|
| 7015 | 5130 | } |
|
| 7016 | 5131 | assert_eq!(origin.resolve_heads("feature").unwrap(), vec![h_a]); |
|
| … | 32 unchanged lines |
||
| 7049 | 5164 | write(&r, "a.txt", b"a\n"); |
|
| 7050 | 5165 | repo.commit("a", &[]).unwrap(); |
|
| 7051 | 5166 | assert!(repo.push("origin", false).unwrap()[0].ok); |
|
| 7052 | - | origin.refresh().unwrap(); |
|
| 7053 | 5167 | repo.bookmark_create("feature/x", None).unwrap(); |
|
| 7054 | 5168 | assert!(repo.push_bookmark("origin", "feature/x", false).unwrap()[0].ok); |
|
| 7055 | - | origin.refresh().unwrap(); |
|
| 7056 | 5169 | assert!(origin.resolve_heads("feature/x").is_ok()); |
|
| 7057 | 5170 | ||
| 7058 | 5171 | let res = repo.push_delete("origin", "feature/x").unwrap(); |
|
| 7059 | - | origin.refresh().unwrap(); |
|
| 7060 | 5172 | assert!(res[0].ok, "{res:?}"); |
|
| 7061 | 5173 | assert!(!origin.view().unwrap().bookmarks.contains_key("feature/x")); |
|
| 7062 | 5174 | let (_, att) = origin.latest_ref_attest("feature/x").unwrap().unwrap(); |
|
| … | 14 unchanged lines |
||
| 7077 | 5189 | ||
| 7078 | 5190 | // Pushing it again creates it again, past the deletion's serial. |
|
| 7079 | 5191 | let res = repo.push_bookmark("origin", "feature/x", false).unwrap(); |
|
| 7080 | - | origin.refresh().unwrap(); |
|
| 7081 | 5192 | assert!(res[0].ok, "{res:?}"); |
|
| 7082 | 5193 | assert!(origin.view().unwrap().bookmarks.contains_key("feature/x")); |
|
| 7083 | 5194 | ||
| 7084 | 5195 | // Nothing to delete, and never the remote's own bookmark. |
|
| 7085 | 5196 | assert!(repo.push_delete("origin", "nope").is_err()); |
|
| 7086 | 5197 | let res = repo.push_delete("origin", "main").unwrap(); |
|
| 7087 | - | origin.refresh().unwrap(); |
|
| 7088 | 5198 | assert!( |
|
| 7089 | 5199 | !res[0].ok && res[0].reason.contains("default bookmark"), |
|
| 7090 | 5200 | "{res:?}" |
|
| … | 19 unchanged lines |
||
| 7110 | 5220 | write(root, "a.txt", b"a\nmore\n"); |
|
| 7111 | 5221 | write(root, "new.txt", b"new\n"); |
|
| 7112 | 5222 | fs::remove_file(root.join("gone.txt")).unwrap(); |
|
| 7113 | - | let (report, carried) = repo.bookmark_switch_carry("feature").unwrap(); |
|
| 5223 | + | let (_, carried) = repo.bookmark_switch_carry("feature").unwrap(); |
|
| 7114 | 5224 | assert_eq!(carried, vec!["a.txt", "gone.txt", "new.txt"]); |
|
| 7115 | - | // oxygen#120: the new file was carried, not removed. |
|
| 7116 | - | assert_eq!( |
|
| 7117 | - | (report.removed, report.removed_paths.len()), |
|
| 7118 | - | (0, 0), |
|
| 7119 | - | "{report:?}" |
|
| 7120 | - | ); |
|
| 7121 | 5225 | assert_eq!(repo.config.bookmark, "feature"); |
|
| 7122 | 5226 | assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"a\nmore\n"); |
|
| 7123 | 5227 | assert_eq!(fs::read(root.join("new.txt")).unwrap(), b"new\n"); |
|
| … | 364 unchanged lines |
||
| 7488 | 5592 | .to_string() |
|
| 7489 | 5593 | .contains("does not verify")); |
|
| 7490 | 5594 | ||
| 7491 | - | // A receipt signed by a short-lived key keeps its certificate and the |
|
| 7492 | - | // issuer's identity, and reads back with them (oxygenhub#241). |
|
| 7493 | - | let hub_sk = SecretKey::generate(); |
|
| 7494 | - | let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1); |
|
| 7495 | - | let job_sk = SecretKey::generate(); |
|
| 7496 | - | let job = job_sk.identity("job 2 test", "[email protected]", 1); |
|
| 7497 | - | let mut test_receipt = receipt.clone(); |
|
| 7498 | - | test_receipt.job_name = "test".into(); |
|
| 7499 | - | let plain = SignedReceipt::sign( |
|
| 7500 | - | test_receipt, |
|
| 7501 | - | repo.id().unwrap(), |
|
| 7502 | - | state, |
|
| 7503 | - | "demo#2", |
|
| 7504 | - | &job, |
|
| 7505 | - | &job_sk, |
|
| 7506 | - | ); |
|
| 7507 | - | let now = plain.attest.hlc.wall_ms; |
|
| 7508 | - | let cert = crate::cert::SignedCertificate::sign( |
|
| 7509 | - | crate::cert::Certificate { |
|
| 7510 | - | key: job.clone(), |
|
| 7511 | - | issuer: hub.hash(), |
|
| 7512 | - | subject: "repo:andy/demo:bookmark:main".into(), |
|
| 7513 | - | claims: vec![], |
|
| 7514 | - | scope: crate::identity::Scope::SEAL, |
|
| 7515 | - | not_before: now - 1_000, |
|
| 7516 | - | expires: now + 600_000, |
|
| 7517 | - | grant: None, |
|
| 7518 | - | }, |
|
| 7519 | - | &hub, |
|
| 7520 | - | &hub_sk, |
|
| 7521 | - | ); |
|
| 7522 | - | let certified = plain.with_certificate(cert, hub.clone()); |
|
| 7523 | - | repo.add_receipt(&certified).unwrap(); |
|
| 7524 | - | let found = repo.receipts(&heads).unwrap(); |
|
| 7525 | - | let got = found |
|
| 7526 | - | .iter() |
|
| 7527 | - | .find(|i| i.signed.receipt.job_name == "test") |
|
| 7528 | - | .unwrap(); |
|
| 7529 | - | assert_eq!(got.signed, certified); |
|
| 7530 | - | assert!(got.problem.is_none(), "{:?}", got.problem); |
|
| 7531 | - | ||
| 7532 | - | // gc keeps the receipts, their runners, the certificate and its issuer, |
|
| 7533 | - | // and the output while the state lives. |
|
| 5595 | + | // gc keeps the receipt, its runner and its output while the state lives. |
|
| 7534 | 5596 | repo.gc(true).unwrap(); |
|
| 7535 | - | let found = repo.receipts(&heads).unwrap(); |
|
| 7536 | - | assert_eq!(found.len(), 2); |
|
| 7537 | - | let got = found |
|
| 7538 | - | .iter() |
|
| 7539 | - | .find(|i| i.signed.receipt.job_name == "test") |
|
| 7540 | - | .unwrap(); |
|
| 7541 | - | assert_eq!(got.signed, certified, "the certificate survives gc"); |
|
| 7542 | - | assert!(got.problem.is_none()); |
|
| 5597 | + | assert_eq!(repo.receipts(&heads).unwrap().len(), 1); |
|
| 7543 | 5598 | assert_eq!(repo.read_blob(&out).unwrap(), b"built\n"); |
|
| 7544 | - | assert!(repo.fsck().unwrap().problems.is_empty()); |
|
| 7545 | - | } |
|
| 7546 | - | ||
| 7547 | - | #[test] |
|
| 7548 | - | fn approvals_are_kept_with_their_state_and_survive_gc() { |
|
| 7549 | - | hermetic(); |
|
| 7550 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 7551 | - | let root = dir.path(); |
|
| 7552 | - | let repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 7553 | - | write(root, "a.txt", b"1\n"); |
|
| 7554 | - | repo.commit("A", &[]).unwrap(); |
|
| 7555 | - | let heads = repo.bookmark_heads().unwrap(); |
|
| 7556 | - | let state = repo.store().put_state(&StateObject::new(heads)).unwrap(); |
|
| 7557 | - | let plan = repo.store_blob(b"the reviewed plan\n").unwrap(); |
|
| 7558 | - | let sk = SecretKey::generate(); |
|
| 7559 | - | let andy = sk.identity("Andy", "[email protected]", 1); |
|
| 7560 | - | let statement = crate::approval::Statement { |
|
| 7561 | - | repo: repo.id().unwrap(), |
|
| 7562 | - | run: "https://hub.example/andy/infra/flow/8".into(), |
|
| 7563 | - | job: "apply".into(), |
|
| 7564 | - | job_hash: ObjectRef([1; 32]), |
|
| 7565 | - | state, |
|
| 7566 | - | inputs: vec![plan], |
|
| 7567 | - | environment: "prod".into(), |
|
| 7568 | - | approve: true, |
|
| 7569 | - | }; |
|
| 7570 | - | let sa = crate::approval::SignedApproval::sign(&statement, &andy, &sk); |
|
| 7571 | - | let r = repo.add_approval(&sa).unwrap(); |
|
| 7572 | - | assert_eq!(repo.store().approvals_for(&state).unwrap(), vec![r]); |
|
| 7573 | - | // Another repository's approval, or a changed one, is refused. |
|
| 7574 | - | let elsewhere = crate::approval::SignedApproval::sign( |
|
| 7575 | - | &crate::approval::Statement { |
|
| 7576 | - | repo: [0; 16], |
|
| 7577 | - | ..statement.clone() |
|
| 7578 | - | }, |
|
| 7579 | - | &andy, |
|
| 7580 | - | &sk, |
|
| 7581 | - | ); |
|
| 7582 | - | assert!(repo |
|
| 7583 | - | .add_approval(&elsewhere) |
|
| 7584 | - | .unwrap_err() |
|
| 7585 | - | .to_string() |
|
| 7586 | - | .contains("another repository")); |
|
| 7587 | - | let mut forged = sa.clone(); |
|
| 7588 | - | if let AttestBody::Approval { environment, .. } = &mut forged.attest.body { |
|
| 7589 | - | *environment = "dev".into(); |
|
| 7590 | - | } |
|
| 7591 | - | assert!(repo |
|
| 7592 | - | .add_approval(&forged) |
|
| 7593 | - | .unwrap_err() |
|
| 7594 | - | .to_string() |
|
| 7595 | - | .contains("does not verify")); |
|
| 7596 | - | // gc keeps it, its signature and its signer while the state lives. |
|
| 7597 | - | repo.gc(true).unwrap(); |
|
| 7598 | - | assert_eq!(repo.store().approvals_for(&state).unwrap(), vec![r]); |
|
| 7599 | - | assert_eq!(repo.store().get_attest(&r).unwrap(), sa.attest); |
|
| 7600 | - | assert_eq!(repo.store().signatures(&r).unwrap(), sa.sigs); |
|
| 7601 | 5599 | assert!(repo.fsck().unwrap().problems.is_empty()); |
|
| 7602 | 5600 | } |
|
| 7603 | 5601 | ||
| … | 224 unchanged lines |
||
| 7828 | 5826 | write(&root.join("b"), "base.txt", b"base\n"); |
|
| 7829 | 5827 | b.commit("base", &[]).unwrap(); |
|
| 7830 | 5828 | assert!(b.push("origin", false).unwrap()[0].ok); |
|
| 7831 | - | origin.refresh().unwrap(); |
|
| 7832 | 5829 | ||
| 7833 | 5830 | // A feature bookmark with one change, pushed. |
|
| 7834 | 5831 | b.bookmark_create("feat", None).unwrap(); |
|
| … | 1 unchanged lines |
||
| 7836 | 5833 | write(&root.join("b"), "x.txt", b"x\n"); |
|
| 7837 | 5834 | let x = b.commit("x", &[]).unwrap(); |
|
| 7838 | 5835 | assert!(b.push("origin", false).unwrap()[0].ok); |
|
| 7839 | - | origin.refresh().unwrap(); |
|
| 7840 | 5836 | b.bookmark_switch("main", false).unwrap(); |
|
| 7841 | 5837 | ||
| 7842 | 5838 | // The remote lands it: its main moves to the feature's state. |
|
| … | 63 unchanged lines |
||
| 7906 | 5902 | write(&root.join("a"), "m.txt", b"m\n"); |
|
| 7907 | 5903 | let m = a.commit("m", &[]).unwrap(); |
|
| 7908 | 5904 | assert!(a.push("origin", false).unwrap()[0].ok); |
|
| 7909 | - | // oxygen#117: a remote's bookmark merges without switching to it. |
|
| 7910 | - | let f = Repo::clone(&url, &root.join("f")).unwrap(); |
|
| 7911 | - | assert!(!f.view().unwrap().bookmarks.contains_key("develop")); |
|
| 7912 | - | let merged = f.merge("origin/develop").unwrap(); |
|
| 7913 | - | assert!(merged.changed, "{merged:?}"); |
|
| 7914 | - | assert!( |
|
| 7915 | - | f.load_state_indexed(&f.bookmark_heads().unwrap()) |
|
| 7916 | - | .unwrap() |
|
| 7917 | - | .contains(&d1), |
|
| 7918 | - | "main holds develop's change" |
|
| 7919 | - | ); |
|
| 7920 | - | assert!(f |
|
| 7921 | - | .merge("origin/nowhere") |
|
| 7922 | - | .unwrap_err() |
|
| 7923 | - | .to_string() |
|
| 7924 | - | .contains("no bookmark origin/nowhere")); |
|
| 7925 | 5905 | let r = c.pull("origin").unwrap(); |
|
| 7926 | 5906 | assert!(r.changed && r.checked_out, "{r:?}"); |
|
| 7927 | 5907 | assert_eq!(r.heads, vec![d2]); |
|
| … | 169 unchanged lines |
||
| 8097 | 6077 | let (wc, base, snapshot) = repo.working_copy_with_base().unwrap(); |
|
| 8098 | 6078 | assert_eq!(render_change(&wc, &base, &snapshot), slow); |
|
| 8099 | 6079 | assert_eq!(repo.diff_files(None).unwrap().len(), 3); |
|
| 8100 | - | ||
| 8101 | - | // Committed, nothing changes: the diff is empty without loading the |
|
| 8102 | - | // base (oxygen#94), and the same as rendering it. |
|
| 8103 | - | repo.commit("all", &[]).unwrap(); |
|
| 8104 | - | repo.snapshot_status("oxy status").unwrap(); |
|
| 8105 | - | assert!(repo.status_by_manifest().unwrap().is_some()); |
|
| 8106 | - | assert!(repo |
|
| 8107 | - | .working_copy_with_base_unless_unchanged() |
|
| 8108 | - | .unwrap() |
|
| 8109 | - | .is_none()); |
|
| 8110 | - | let (wc, base, snapshot) = repo.working_copy_with_base().unwrap(); |
|
| 8111 | - | assert_eq!(render_change(&wc, &base, &snapshot), ""); |
|
| 8112 | - | assert!(repo.diff_files(None).unwrap().is_empty()); |
|
| 8113 | 6080 | } |
|
| 8114 | 6081 | ||
| 8115 | 6082 | /// oxygen#41: a manifest written from a walk keeps every directory's time; |
|
| … | 1562 unchanged lines |
||
| 9678 | 7645 | let (h, v) = a.read_head().unwrap(); |
|
| 9679 | 7646 | assert!(b.swap_head(&rb, v.as_ref()).unwrap()); |
|
| 9680 | 7647 | assert!(!a.swap_head(&h, v.as_ref()).unwrap(), "stale"); |
|
| 9681 | - | a.refresh().unwrap(); |
|
| 9682 | 7648 | assert_eq!(a.head_ref().unwrap(), rb); |
|
| 9683 | 7649 | ||
| 9684 | 7650 | let other = tempfile::tempdir().unwrap(); |
|
| … | 180 unchanged lines |
||
| 9865 | 7831 | assert!(e.contains("no such bookmark"), "{e}"); |
|
| 9866 | 7832 | } |
|
| 9867 | 7833 | ||
| 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 | 7834 | #[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. |
|
| 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] |
|
| 10000 | 7835 | fn carrying_work_over_a_subrepo_already_at_the_target_pin_is_no_conflict() { |
|
| 10001 | 7836 | // oxygen#74: a stale state with .oxymodules but no pin, a newer one |
|
| 10002 | 7837 | // pinning the subrepo, and the nested repository on disk at that pin. |
|
| … | 26 unchanged lines |
||
| 10029 | 7864 | assert!(app_dir.join("notes.txt").exists()); |
|
| 10030 | 7865 | } |
|
| 10031 | 7866 | ||
| 10032 | - | /// oxygen#142: a new bookmark made from main sends what main lacks, not |
|
| 10033 | - | /// its whole history again. |
|
| 10034 | 7867 | #[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] |
|
| 10074 | 7868 | fn a_bookmark_only_behind_its_remote_catches_up() { |
|
| 10075 | 7869 | // oxygen#74: switch takes the remote's state rather than a stale one. |
|
| 10076 | 7870 | hermetic(); |
|
| … | 362 unchanged lines |
||
| 10439 | 8233 | .unwrap(); |
|
| 10440 | 8234 | assert_eq!(got.len(), 1); |
|
| 10441 | 8235 | assert!(got[0].ok && got[0].name == OBJECTS_RESULT, "{got:?}"); |
|
| 10442 | - | } |
|
| 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 | 8236 | } |
|
| 10540 | 8237 | ||
| 10541 | 8238 | /// oxygen#97: with `git.claim`, an import authors the importing identity's |
|
| … | 249 unchanged lines |
||
| 10791 | 8488 | // Both on main: A commits, then B builds on it, editing A's file. |
|
| 10792 | 8489 | write(&root, "a.txt", b"a\n"); |
|
| 10793 | 8490 | a.commit("from a", &[]).unwrap(); |
|
| 10794 | - | b.refresh().unwrap(); |
|
| 10795 | 8491 | let heads = b.resolve_heads("main").unwrap(); |
|
| 10796 | 8492 | b.checkout(&heads, false, "oxy checkout").unwrap(); |
|
| 10797 | 8493 | write(&ws_dir, "a.txt", b"a, then b\n"); |
|
| … | 134 unchanged lines |
||
| 10932 | 8628 | .unwrap_err() |
|
| 10933 | 8629 | .to_string(); |
|
| 10934 | 8630 | assert!(e.contains("a.txt") && e.contains("--discard"), "{e}"); |
|
| 10935 | - | } |
|
| 10936 | - | ||
| 10937 | - | /// oxygen#103: a full change hash as `--json` prints it, without `#`, |
|
| 10938 | - | /// resolves, so JSON output feeds back into commands. |
|
| 10939 | - | #[test] |
|
| 10940 | - | fn a_full_hash_resolves_without_its_hash_sign() { |
|
| 10941 | - | hermetic(); |
|
| 10942 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 10943 | - | let root = dir.path(); |
|
| 10944 | - | let repo = Repo::init(root).unwrap(); |
|
| 10945 | - | write(root, "a.txt", b"a\n"); |
|
| 10946 | - | let h = repo.commit("a", &[]).unwrap(); |
|
| 10947 | - | assert_eq!(repo.resolve_change(&h.to_hex()).unwrap(), h); |
|
| 10948 | - | assert_eq!(repo.resolve_change(&format!("#{}", h.to_hex())).unwrap(), h); |
|
| 10949 | - | assert!(repo |
|
| 10950 | - | .resolve_change(&"0".repeat(64)) |
|
| 10951 | - | .unwrap_err() |
|
| 10952 | - | .to_string() |
|
| 10953 | - | .contains("no change matches")); |
|
| 10954 | - | } |
|
| 10955 | - | ||
| 10956 | - | #[test] |
|
| 10957 | - | fn a_pin_change_moves_only_the_pin() { |
|
| 10958 | - | hermetic(); |
|
| 10959 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 10960 | - | let root = dir.path(); |
|
| 10961 | - | let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap(); |
|
| 10962 | - | let lib_url = root.join("lib-origin").to_string_lossy().to_string(); |
|
| 10963 | - | let lib = Repo::clone(&lib_url, &root.join("lib")) |
|
| 10964 | - | .unwrap() |
|
| 10965 | - | .with_home(root.join("home")); |
|
| 10966 | - | write(&root.join("lib"), "lib.txt", b"v1\n"); |
|
| 10967 | - | lib.commit("lib v1", &[]).unwrap(); |
|
| 10968 | - | assert!(lib.push("origin", false).unwrap()[0].ok); |
|
| 10969 | - | let _app_origin = Repo::init(&root.join("app-origin")).unwrap(); |
|
| 10970 | - | let app_dir = root.join("app"); |
|
| 10971 | - | let app = Repo::clone(&root.join("app-origin").to_string_lossy(), &app_dir) |
|
| 10972 | - | .unwrap() |
|
| 10973 | - | .with_home(root.join("home")); |
|
| 10974 | - | write(&app_dir, "main.txt", b"app\n"); |
|
| 10975 | - | let pin1 = app.submodule_add(&lib_url, "libs/lib", None).unwrap(); |
|
| 10976 | - | let a1 = app.commit("app with lib", &[]).unwrap(); |
|
| 10977 | - | // The library moves on. |
|
| 10978 | - | write(&root.join("lib"), "more.txt", b"v2\n"); |
|
| 10979 | - | let l2 = lib.commit("lib v2", &[]).unwrap(); |
|
| 10980 | - | let crate::subrepo::Pin::Oxy { repo, bookmark, .. } = pin1 else { |
|
| 10981 | - | panic!("an Oxygen pin") |
|
| 10982 | - | }; |
|
| 10983 | - | let pin2 = crate::subrepo::Pin::Oxy { |
|
| 10984 | - | repo, |
|
| 10985 | - | heads: vec![l2], |
|
| 10986 | - | bookmark, |
|
| 10987 | - | }; |
|
| 10988 | - | let author = app.store().get_change(&a1).unwrap().author; |
|
| 10989 | - | let change = app |
|
| 10990 | - | .build_pin_change(&[a1], "libs/lib", &pin2, "Move lib to v2", author) |
|
| 10991 | - | .unwrap(); |
|
| 10992 | - | let mut state = app.load_state(&[a1]).unwrap(); |
|
| 10993 | - | state.add(change).unwrap(); |
|
| 10994 | - | let files = state.materialize().files; |
|
| 10995 | - | assert_eq!(files["libs/lib"].kind, NodeKind::Subrepo); |
|
| 10996 | - | let FileContent::Opaque { blobs } = &files["libs/lib"].content else { |
|
| 10997 | - | panic!("a pin is opaque") |
|
| 10998 | - | }; |
|
| 10999 | - | let read = crate::subrepo::Pin::decode(&app.read_blob(&blobs[0]).unwrap()).unwrap(); |
|
| 11000 | - | assert_eq!(read, pin2, "the new pin"); |
|
| 11001 | - | assert!( |
|
| 11002 | - | matches!(&files["main.txt"].content, FileContent::Text { bytes, .. } if bytes.as_slice() == b"app\n"), |
|
| 11003 | - | "nothing else changes" |
|
| 11004 | - | ); |
|
| 11005 | - | // Only a subrepo's pin can be moved this way. |
|
| 11006 | - | let e = app.build_pin_change(&[a1], "main.txt", &pin2, "x", author); |
|
| 11007 | - | assert!(format!("{:?}", e.unwrap_err()).contains("not a subrepo")); |
|
| 11008 | - | let e = app.build_pin_change(&[a1], "libs/none", &pin2, "x", author); |
|
| 11009 | - | assert!(format!("{:?}", e.unwrap_err()).contains("does not exist")); |
|
| 11010 | - | } |
|
| 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 | - | ||
| 11069 | - | /// oxygenhub#249: a handle reads the operation head once and keeps it, |
|
| 11070 | - | /// so one command or request never mixes two heads; another handle's |
|
| 11071 | - | /// operation shows only after `refresh`. Its own operations move it, and |
|
| 11072 | - | /// one recorded from the head it kept merges with what landed since, as |
|
| 11073 | - | /// any concurrent operation does. |
|
| 11074 | - | #[test] |
|
| 11075 | - | fn one_handle_keeps_one_head_until_it_refreshes() { |
|
| 11076 | - | hermetic(); |
|
| 11077 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 11078 | - | let root = dir.path(); |
|
| 11079 | - | let writer = Repo::init(root).unwrap(); |
|
| 11080 | - | write(root, "a.txt", b"one\n"); |
|
| 11081 | - | let h1 = writer.commit("one", &[]).unwrap(); |
|
| 11082 | - | let reader = Repo::open(root).unwrap(); |
|
| 11083 | - | let before = reader.view().unwrap(); |
|
| 11084 | - | assert_eq!(reader.bookmark_heads().unwrap(), vec![h1]); |
|
| 11085 | - | write(root, "a.txt", b"two\n"); |
|
| 11086 | - | let h2 = writer.commit("two", &[]).unwrap(); |
|
| 11087 | - | assert_eq!( |
|
| 11088 | - | writer.bookmark_heads().unwrap(), |
|
| 11089 | - | vec![h2], |
|
| 11090 | - | "its own operation" |
|
| 11091 | - | ); |
|
| 11092 | - | assert_eq!(reader.view().unwrap(), before, "the head it read first"); |
|
| 11093 | - | assert_eq!(reader.bookmark_heads().unwrap(), vec![h1]); |
|
| 11094 | - | reader.refresh().unwrap(); |
|
| 11095 | - | assert_eq!(reader.bookmark_heads().unwrap(), vec![h2]); |
|
| 11096 | - | ||
| 11097 | - | // A handle recording from an older head merges with the newer one. |
|
| 11098 | - | let stale = Repo::open(root).unwrap(); |
|
| 11099 | - | assert_eq!(stale.bookmark_heads().unwrap(), vec![h2]); |
|
| 11100 | - | write(root, "a.txt", b"three\n"); |
|
| 11101 | - | let h3 = writer.commit("three", &[]).unwrap(); |
|
| 11102 | - | stale.bookmark_create("feature", Some(&[h1])).unwrap(); |
|
| 11103 | - | let view = stale.view().unwrap(); |
|
| 11104 | - | assert!(view.bookmarks.contains_key("feature")); |
|
| 11105 | - | assert_eq!( |
|
| 11106 | - | stale.bookmark_heads().unwrap(), |
|
| 11107 | - | vec![h3], |
|
| 11108 | - | "merged, not lost" |
|
| 11109 | - | ); |
|
| 11110 | - | let fresh = Repo::open(root).unwrap(); |
|
| 11111 | - | assert_eq!(fresh.view().unwrap(), view); |
|
| 11112 | - | } |
|
| 11113 | - | ||
| 11114 | - | /// oxygenhub#249 over a store (a bucket's compare-and-swap): the same. |
|
| 11115 | - | #[test] |
|
| 11116 | - | fn over_a_store_one_handle_keeps_one_head() { |
|
| 11117 | - | hermetic(); |
|
| 11118 | - | let backend = std::sync::Arc::new(crate::strata::MemoryStore::new()); |
|
| 11119 | - | crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = Some(backend.clone())); |
|
| 11120 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 11121 | - | let a = Repo::init(dir.path()).unwrap(); |
|
| 11122 | - | let (h0, _) = a.head_op().unwrap(); |
|
| 11123 | - | let b = Repo::open(dir.path()).unwrap(); |
|
| 11124 | - | assert_eq!(b.head_op().unwrap().0, h0); |
|
| 11125 | - | let state = a.store.put_state(&StateObject::new(vec![])).unwrap(); |
|
| 11126 | - | let mut view = a.view().unwrap(); |
|
| 11127 | - | view.bookmarks.insert("x".into(), state); |
|
| 11128 | - | let r = a.record_op(&[h0], "bookmark x", view).unwrap(); |
|
| 11129 | - | assert_eq!(a.head_op().unwrap().0, r); |
|
| 11130 | - | assert_eq!(b.head_op().unwrap().0, h0, "b keeps its head"); |
|
| 11131 | - | b.refresh().unwrap(); |
|
| 11132 | - | assert_eq!(b.head_op().unwrap().0, r); |
|
| 11133 | - | crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = None); |
|
| 11134 | - | } |
|
| 11135 | - | ||
| 11136 | - | /// oxygenhub#249: gc retires what it repacks rather than deleting it; a |
|
| 11137 | - | /// reader opened before reads on, then and after the next gc deletes the |
|
| 11138 | - | /// retired files once their hour is up. Younger ones are kept. |
|
| 11139 | - | #[test] |
|
| 11140 | - | fn gc_retires_and_a_later_gc_deletes_after_the_hour() { |
|
| 11141 | - | hermetic(); |
|
| 11142 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 11143 | - | let root = dir.path(); |
|
| 11144 | - | let repo = Repo::init(root).unwrap(); |
|
| 11145 | - | let mut heads = Vec::new(); |
|
| 11146 | - | for i in 0..4 { |
|
| 11147 | - | write(root, &format!("f{i}.txt"), format!("{i}\n").as_bytes()); |
|
| 11148 | - | heads.push(repo.commit(&format!("c{i}"), &[]).unwrap()); |
|
| 11149 | - | } |
|
| 11150 | - | let reader = Repo::open(root).unwrap(); |
|
| 11151 | - | assert_eq!(reader.log().unwrap().len(), 5); |
|
| 11152 | - | let r = repo.gc(true).unwrap(); |
|
| 11153 | - | assert_eq!(r.retired, crate::store::RetiredReport::default()); |
|
| 11154 | - | assert_eq!(repo.store().loose_count().unwrap(), 0); |
|
| 11155 | - | assert_eq!(reader.log().unwrap().len(), 5, "read on from what it had"); |
|
| 11156 | - | // Nothing is an hour old yet: a gc keeps it all. |
|
| 11157 | - | let r = repo.gc(true).unwrap(); |
|
| 11158 | - | assert_eq!((r.retired.packs, r.retired.loose), (0, 0)); |
|
| 11159 | - | assert!(r.retired.kept > 0, "{r:?}"); |
|
| 11160 | - | write(root, "f9.txt", b"9\n"); |
|
| 11161 | - | heads.push(repo.commit("c9", &[]).unwrap()); |
|
| 11162 | - | crate::store::reader_tests::age_retired( |
|
| 11163 | - | repo.store(), |
|
| 11164 | - | crate::store::RETIRED_FOR + std::time::Duration::from_secs(60), |
|
| 11165 | - | ); |
|
| 11166 | - | let r = repo.gc(true).unwrap(); |
|
| 11167 | - | assert!(r.retired.packs >= 1 && r.retired.loose > 0, "{r:?}"); |
|
| 11168 | - | assert_eq!(r.retired.kept, 0, "{r:?}"); |
|
| 11169 | - | let r = repo.gc(true).unwrap(); |
|
| 11170 | - | assert_eq!((r.retired.packs, r.retired.loose), (0, 0)); |
|
| 11171 | - | assert!(r.retired.kept > 0, "what the last gc retired is young"); |
|
| 11172 | - | // The reader opened before all of it still reads everything, from |
|
| 11173 | - | // the packs it finds when what it loaded is gone. |
|
| 11174 | - | assert_eq!(reader.log().unwrap().len(), 5); |
|
| 11175 | - | for h in &heads[..4] { |
|
| 11176 | - | reader.store().get_change(h).unwrap(); |
|
| 11177 | - | } |
|
| 11178 | - | reader.refresh().unwrap(); |
|
| 11179 | - | assert_eq!(reader.log().unwrap().len(), 6); |
|
| 11180 | - | let fresh = Repo::open(root).unwrap(); |
|
| 11181 | - | assert_eq!( |
|
| 11182 | - | fresh.store().pack_count(), |
|
| 11183 | - | 1, |
|
| 11184 | - | "retired packs are not loaded" |
|
| 11185 | - | ); |
|
| 11186 | - | } |
|
| 11187 | - | ||
| 11188 | - | /// oxygenhub#249: a repository opens when its store's pack listing names |
|
| 11189 | - | /// an index that is not there (deleted between the listing and the read). |
|
| 11190 | - | #[test] |
|
| 11191 | - | fn a_repository_opens_when_a_listed_index_is_gone() { |
|
| 11192 | - | hermetic(); |
|
| 11193 | - | let backend = crate::store::reader_tests::racing(true); |
|
| 11194 | - | let dyn_backend: std::sync::Arc<crate::store::Backend> = backend.clone(); |
|
| 11195 | - | crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = Some(dyn_backend)); |
|
| 11196 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 11197 | - | let repo = Repo::init(dir.path()).unwrap(); |
|
| 11198 | - | write(dir.path(), "a.txt", b"a\n"); |
|
| 11199 | - | let h = repo.commit("a", &[]).unwrap(); |
|
| 11200 | - | repo.gc(true).unwrap(); |
|
| 11201 | - | let again = Repo::open(dir.path()).unwrap(); |
|
| 11202 | - | assert_eq!(again.store().pack_count(), 1); |
|
| 11203 | - | assert_eq!(again.bookmark_heads().unwrap(), vec![h]); |
|
| 11204 | - | // And when one is deleted between the listing and the read: a copy of |
|
| 11205 | - | // the live pack, whose index goes as the store opens. |
|
| 11206 | - | use crate::strata::Store as _; |
|
| 11207 | - | let idx = backend |
|
| 11208 | - | .list("pack-") |
|
| 11209 | - | .unwrap() |
|
| 11210 | - | .into_iter() |
|
| 11211 | - | .map(|e| e.key) |
|
| 11212 | - | .find(|k| k.ends_with(".idx") && k != "pack-0000.idx") |
|
| 11213 | - | .unwrap(); |
|
| 11214 | - | let stem = idx.trim_end_matches(".idx"); |
|
| 11215 | - | for ext in ["pack", "idx"] { |
|
| 11216 | - | let bytes = backend.get(&format!("{stem}.{ext}")).unwrap(); |
|
| 11217 | - | backend |
|
| 11218 | - | .put_new(&format!("pack-copy.{ext}"), &bytes) |
|
| 11219 | - | .unwrap(); |
|
| 11220 | - | } |
|
| 11221 | - | *backend.doomed.lock().unwrap() = Some("pack-copy.idx".into()); |
|
| 11222 | - | let again = Repo::open(dir.path()).unwrap(); |
|
| 11223 | - | assert_eq!(again.store().pack_count(), 1); |
|
| 11224 | - | assert_eq!(again.bookmark_heads().unwrap(), vec![h]); |
|
| 11225 | - | assert!(!backend.has("pack-copy.idx").unwrap()); |
|
| 11226 | - | crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = None); |
|
| 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 | 8631 | } |
removed crates/oxy-core/src/repo/transparency.rs +0 −623
| 1 | - | //! Checking a host's transparency log on fetch (spec §14.8, oxygenhub#238). |
|
| 2 | - | //! |
|
| 3 | - | //! A host that keeps a log sends its latest checkpoint with the refs, and |
|
| 4 | - | //! for each bookmark's attestation its entry and an inclusion proof. This |
|
| 5 | - | //! user remembers, per hub, the log's key and the last checkpoint seen |
|
| 6 | - | //! (`<oxy home>/tlog/<hub>`): the key on first sight, then never another. |
|
| 7 | - | //! Each new checkpoint must extend the last one, which oxy proves from the |
|
| 8 | - | //! hub's public tiles (C2SP tlog-tiles); one that does not means the hub |
|
| 9 | - | //! showed two histories, and the fetch fails. An attestation that is not |
|
| 10 | - | //! in the log yet, or whose proof does not verify, is only reported while |
|
| 11 | - | //! the log is new (oxygenhub docs/transparency.md, decision 3). |
|
| 12 | - | //! |
|
| 13 | - | //! The witnesses that cosign a hub's checkpoints are pinned the same way |
|
| 14 | - | //! (`<oxy home>/tlog/<hub>.witnesses`), and give each bookmark's |
|
| 15 | - | //! attestation an *integrated time* (oxygenhub#240): the earliest time a |
|
| 16 | - | //! trusted witness cosigned a checkpoint holding it. A fetch checks |
|
| 17 | - | //! delegations and certificates at that time when it has one. |
|
| 18 | - | ||
| 19 | - | use super::*; |
|
| 20 | - | use crate::credentials::hub_of; |
|
| 21 | - | use crate::protocol::http_get; |
|
| 22 | - | use crate::tlog::{self, Checkpoint, Entry, Hash, HashReader, Integrated, Note, Tile, TlogError}; |
|
| 23 | - | use std::cell::RefCell; |
|
| 24 | - | ||
| 25 | - | /// Tiles fetched, by level and number, with the width fetched. |
|
| 26 | - | type TileCache = RefCell<HashMap<(u32, u64), (Tile, Vec<u8>)>>; |
|
| 27 | - | ||
| 28 | - | /// A hub's log as its tiles serve it, for a tree of `size` entries. |
|
| 29 | - | struct Tiles<'a> { |
|
| 30 | - | hub: &'a str, |
|
| 31 | - | size: u64, |
|
| 32 | - | options: &'a RemoteOptions, |
|
| 33 | - | cache: TileCache, |
|
| 34 | - | } |
|
| 35 | - | ||
| 36 | - | impl HashReader for Tiles<'_> { |
|
| 37 | - | fn read_hashes(&self, indexes: &[u64]) -> std::result::Result<Vec<Hash>, TlogError> { |
|
| 38 | - | let mut out = Vec::with_capacity(indexes.len()); |
|
| 39 | - | for &i in indexes { |
|
| 40 | - | let (t, ..) = Tile::for_index(i); |
|
| 41 | - | let key = (t.level, t.n); |
|
| 42 | - | if !self.cache.borrow().contains_key(&key) { |
|
| 43 | - | let held = |
|
| 44 | - | (self.size >> (tlog::TILE_HEIGHT * t.level)) - (t.n << tlog::TILE_HEIGHT); |
|
| 45 | - | let tile = Tile { |
|
| 46 | - | width: held.min(tlog::TILE_WIDTH) as u32, |
|
| 47 | - | ..t |
|
| 48 | - | }; |
|
| 49 | - | let data = http_get(&format!("{}/log/{}", self.hub, tile.path()), self.options) |
|
| 50 | - | .map_err(|e| TlogError::Read(e.to_string()))?; |
|
| 51 | - | self.cache.borrow_mut().insert(key, (tile, data)); |
|
| 52 | - | } |
|
| 53 | - | let cache = self.cache.borrow(); |
|
| 54 | - | let (tile, data) = &cache[&key]; |
|
| 55 | - | out.push(tile.hash_at(data, i)?); |
|
| 56 | - | } |
|
| 57 | - | Ok(out) |
|
| 58 | - | } |
|
| 59 | - | } |
|
| 60 | - | ||
| 61 | - | /// What this user last saw of a hub's log. |
|
| 62 | - | struct Seen { |
|
| 63 | - | identity: Identity, |
|
| 64 | - | checkpoint: Checkpoint, |
|
| 65 | - | } |
|
| 66 | - | ||
| 67 | - | fn file_name(hub: &str) -> String { |
|
| 68 | - | hub.chars() |
|
| 69 | - | .map(|c| { |
|
| 70 | - | if c.is_ascii_alphanumeric() || c == '.' || c == '-' { |
|
| 71 | - | c |
|
| 72 | - | } else { |
|
| 73 | - | '_' |
|
| 74 | - | } |
|
| 75 | - | }) |
|
| 76 | - | .collect() |
|
| 77 | - | } |
|
| 78 | - | ||
| 79 | - | impl Repo { |
|
| 80 | - | fn tlog_file(&self, hub: &str) -> PathBuf { |
|
| 81 | - | self.user_config |
|
| 82 | - | .parent() |
|
| 83 | - | .map(Path::to_path_buf) |
|
| 84 | - | .unwrap_or_else(default_home) |
|
| 85 | - | .join("tlog") |
|
| 86 | - | .join(file_name(hub)) |
|
| 87 | - | } |
|
| 88 | - | ||
| 89 | - | fn seen_log(&self, hub: &str) -> Option<Seen> { |
|
| 90 | - | let text = fs::read_to_string(self.tlog_file(hub)).ok()?; |
|
| 91 | - | let (key, note) = text.split_once('\n')?; |
|
| 92 | - | let identity = Identity::decode(&crate::dsse::base64_decode(key)?).ok()?; |
|
| 93 | - | let note = Note::parse(note).ok()?; |
|
| 94 | - | Some(Seen { |
|
| 95 | - | checkpoint: Checkpoint::parse(¬e.text).ok()?, |
|
| 96 | - | identity, |
|
| 97 | - | }) |
|
| 98 | - | } |
|
| 99 | - | ||
| 100 | - | pub(crate) fn remember_log(&self, hub: &str, identity: &Identity, note: &Note) -> Result<()> { |
|
| 101 | - | let path = self.tlog_file(hub); |
|
| 102 | - | if let Some(dir) = path.parent() { |
|
| 103 | - | fs::create_dir_all(dir)?; |
|
| 104 | - | } |
|
| 105 | - | let text = format!( |
|
| 106 | - | "{}\n{}", |
|
| 107 | - | crate::dsse::base64(&identity.encode()), |
|
| 108 | - | note.encode() |
|
| 109 | - | ); |
|
| 110 | - | crate::durable::write_atomic(&path, text.as_bytes())?; |
|
| 111 | - | Ok(()) |
|
| 112 | - | } |
|
| 113 | - | ||
| 114 | - | /// Take a checkpoint note `text` from `hub` as the newest seen, once it |
|
| 115 | - | /// verifies with the hub's pinned log key and extends the last one |
|
| 116 | - | /// seen. `Err` when the hub showed two histories; `Ok(Err(why))` when |
|
| 117 | - | /// it could not be checked (the last one seen is kept). |
|
| 118 | - | pub(crate) fn accept_checkpoint( |
|
| 119 | - | &self, |
|
| 120 | - | hub: &str, |
|
| 121 | - | text: &str, |
|
| 122 | - | options: &RemoteOptions, |
|
| 123 | - | ) -> Result<std::result::Result<Accepted, String>> { |
|
| 124 | - | let unchecked = |why: String| Ok(Err(why)); |
|
| 125 | - | let note = match Note::parse(text) { |
|
| 126 | - | Ok(n) => n, |
|
| 127 | - | Err(e) => return unchecked(e.to_string()), |
|
| 128 | - | }; |
|
| 129 | - | let cp = match Checkpoint::parse(¬e.text) { |
|
| 130 | - | Ok(c) => c, |
|
| 131 | - | Err(e) => return unchecked(e.to_string()), |
|
| 132 | - | }; |
|
| 133 | - | let fetch_key = || { |
|
| 134 | - | http_get(&format!("{hub}/.well-known/oxyhub-key"), options) |
|
| 135 | - | .and_then(|b| Ok(Identity::decode(&b)?)) |
|
| 136 | - | }; |
|
| 137 | - | let mut notes = Vec::new(); |
|
| 138 | - | let mut seen = self.seen_log(hub); |
|
| 139 | - | let mut identity = match &seen { |
|
| 140 | - | Some(s) => s.identity.clone(), |
|
| 141 | - | None => match fetch_key() { |
|
| 142 | - | Ok(i) => i, |
|
| 143 | - | Err(e) => return unchecked(format!("its key could not be fetched ({e})")), |
|
| 144 | - | }, |
|
| 145 | - | }; |
|
| 146 | - | if let Err(e) = note.verify(&cp.origin, &identity) { |
|
| 147 | - | // Signed by another key than the one pinned: a hub given a new |
|
| 148 | - | // key, or one starting another log. Not proof of a fork, so a |
|
| 149 | - | // loud note while logs are new (decision 3), and the new key is |
|
| 150 | - | // pinned; a fork under one key is what fails. |
|
| 151 | - | let Some(old) = &seen else { |
|
| 152 | - | return unchecked(e.to_string()); |
|
| 153 | - | }; |
|
| 154 | - | match fetch_key() { |
|
| 155 | - | Ok(new) if note.verify(&cp.origin, &new).is_ok() => { |
|
| 156 | - | notes.push(format!( |
|
| 157 | - | "{hub}'s log is signed by another key than before ({} now, {} then): if its operator did not announce a new key, it may be showing others a different log", |
|
| 158 | - | new.hash().short(12), |
|
| 159 | - | old.identity.hash().short(12) |
|
| 160 | - | )); |
|
| 161 | - | identity = new; |
|
| 162 | - | seen = None; |
|
| 163 | - | } |
|
| 164 | - | _ => return unchecked(format!("its checkpoint does not verify ({e})")), |
|
| 165 | - | } |
|
| 166 | - | } |
|
| 167 | - | let two = |why: String| { |
|
| 168 | - | fail(format!( |
|
| 169 | - | "{hub} showed two histories: {why}. Do not trust what it serves; tell its operator. \ |
|
| 170 | - | (What this user saw of its log is in {}.)", |
|
| 171 | - | self.tlog_file(hub).display() |
|
| 172 | - | )) |
|
| 173 | - | }; |
|
| 174 | - | if let Some(s) = &seen { |
|
| 175 | - | let old = &s.checkpoint; |
|
| 176 | - | if old.origin != cp.origin { |
|
| 177 | - | // A hub moved to another name keeps its key: a new log as |
|
| 178 | - | // far as this user can tell, said so. |
|
| 179 | - | notes.push(format!( |
|
| 180 | - | "{hub}'s log is now {}, not {}: checking it afresh", |
|
| 181 | - | cp.origin, old.origin |
|
| 182 | - | )); |
|
| 183 | - | } else if cp.size < old.size { |
|
| 184 | - | return two(format!( |
|
| 185 | - | "its log has {} entries, fewer than the {} it showed before", |
|
| 186 | - | cp.size, old.size |
|
| 187 | - | )); |
|
| 188 | - | } else if cp.size == old.size && cp.root != old.root { |
|
| 189 | - | return two(format!("two different logs of {} entries", cp.size)); |
|
| 190 | - | } else if cp.size > old.size && old.size > 0 { |
|
| 191 | - | let tiles = Tiles { |
|
| 192 | - | hub, |
|
| 193 | - | size: cp.size, |
|
| 194 | - | options, |
|
| 195 | - | cache: RefCell::new(HashMap::new()), |
|
| 196 | - | }; |
|
| 197 | - | match tlog::prove_consistency(cp.size, old.size, &tiles).and_then(|p| { |
|
| 198 | - | tlog::check_consistency(&p, cp.size, &cp.root, old.size, &old.root) |
|
| 199 | - | }) { |
|
| 200 | - | Ok(()) => {} |
|
| 201 | - | Err(TlogError::ProofFailed) | Err(TlogError::BadIndex(_)) => { |
|
| 202 | - | return two(format!( |
|
| 203 | - | "its log of {} entries does not extend the one of {} it showed before", |
|
| 204 | - | cp.size, old.size |
|
| 205 | - | )) |
|
| 206 | - | } |
|
| 207 | - | // Keep the older checkpoint: the next fetch tries again. |
|
| 208 | - | Err(e) => return unchecked(format!("its tiles could not be read ({e})")), |
|
| 209 | - | } |
|
| 210 | - | } |
|
| 211 | - | } |
|
| 212 | - | self.remember_log(hub, &identity, ¬e)?; |
|
| 213 | - | Ok(Ok(Accepted { |
|
| 214 | - | checkpoint: cp, |
|
| 215 | - | identity, |
|
| 216 | - | note, |
|
| 217 | - | notes, |
|
| 218 | - | })) |
|
| 219 | - | } |
|
| 220 | - | ||
| 221 | - | fn witnesses_file(&self, hub: &str) -> PathBuf { |
|
| 222 | - | let mut p = self.tlog_file(hub); |
|
| 223 | - | p.set_file_name(format!("{}.witnesses", file_name(hub))); |
|
| 224 | - | p |
|
| 225 | - | } |
|
| 226 | - | ||
| 227 | - | /// The witnesses this user pinned for `hub`: each one's name in |
|
| 228 | - | /// cosignatures and its identity. |
|
| 229 | - | pub(crate) fn pinned_witnesses(&self, hub: &str) -> Vec<(String, Identity)> { |
|
| 230 | - | let Ok(text) = fs::read_to_string(self.witnesses_file(hub)) else { |
|
| 231 | - | return vec![]; |
|
| 232 | - | }; |
|
| 233 | - | text.lines() |
|
| 234 | - | .filter_map(|l| { |
|
| 235 | - | let (name, key) = l.split_once(' ')?; |
|
| 236 | - | let id = Identity::decode(&crate::dsse::base64_decode(key)?).ok()?; |
|
| 237 | - | Some((name.to_string(), id)) |
|
| 238 | - | }) |
|
| 239 | - | .collect() |
|
| 240 | - | } |
|
| 241 | - | ||
| 242 | - | /// Pin the witnesses `hub` lists (name and identity bytes), as its log |
|
| 243 | - | /// key is pinned: on first sight silently, later with a note for a |
|
| 244 | - | /// witness that is new or has another key than before. A witness the |
|
| 245 | - | /// hub no longer lists stays pinned. Returns every pinned witness. |
|
| 246 | - | pub(crate) fn pin_witnesses( |
|
| 247 | - | &self, |
|
| 248 | - | hub: &str, |
|
| 249 | - | listed: &[(String, Vec<u8>)], |
|
| 250 | - | notes: &mut Vec<String>, |
|
| 251 | - | ) -> Result<Vec<(String, Identity)>> { |
|
| 252 | - | let path = self.witnesses_file(hub); |
|
| 253 | - | let first = !path.exists(); |
|
| 254 | - | let mut pinned = self.pinned_witnesses(hub); |
|
| 255 | - | let mut changed = false; |
|
| 256 | - | for (name, bytes) in listed { |
|
| 257 | - | if name.is_empty() || name.contains(char::is_whitespace) { |
|
| 258 | - | notes.push(format!( |
|
| 259 | - | "{hub} lists a witness named {name:?}, which cannot name one" |
|
| 260 | - | )); |
|
| 261 | - | continue; |
|
| 262 | - | } |
|
| 263 | - | let Ok(id) = Identity::decode(bytes) else { |
|
| 264 | - | notes.push(format!( |
|
| 265 | - | "{hub} lists witness {name} with a key that does not decode" |
|
| 266 | - | )); |
|
| 267 | - | continue; |
|
| 268 | - | }; |
|
| 269 | - | match pinned.iter_mut().find(|(n, _)| n == name) { |
|
| 270 | - | Some((_, old)) if old.hash() == id.hash() => {} |
|
| 271 | - | Some((_, old)) => { |
|
| 272 | - | notes.push(format!( |
|
| 273 | - | "{hub}'s witness {name} has another key than before ({} now, {} then): if its operator did not announce one, {hub} may be naming a witness of its own", |
|
| 274 | - | id.hash().short(12), |
|
| 275 | - | old.hash().short(12) |
|
| 276 | - | )); |
|
| 277 | - | *old = id; |
|
| 278 | - | changed = true; |
|
| 279 | - | } |
|
| 280 | - | None => { |
|
| 281 | - | if !first { |
|
| 282 | - | notes.push(format!( |
|
| 283 | - | "{hub} is now cosigned by witness {name} (key {}), trusted from now on", |
|
| 284 | - | id.hash().short(12) |
|
| 285 | - | )); |
|
| 286 | - | } |
|
| 287 | - | pinned.push((name.clone(), id)); |
|
| 288 | - | changed = true; |
|
| 289 | - | } |
|
| 290 | - | } |
|
| 291 | - | } |
|
| 292 | - | if changed { |
|
| 293 | - | if let Some(dir) = path.parent() { |
|
| 294 | - | fs::create_dir_all(dir)?; |
|
| 295 | - | } |
|
| 296 | - | let text: String = pinned |
|
| 297 | - | .iter() |
|
| 298 | - | .map(|(n, id)| format!("{n} {}\n", crate::dsse::base64(&id.encode()))) |
|
| 299 | - | .collect(); |
|
| 300 | - | crate::durable::write_atomic(&path, text.as_bytes())?; |
|
| 301 | - | } |
|
| 302 | - | Ok(pinned) |
|
| 303 | - | } |
|
| 304 | - | ||
| 305 | - | /// Check what a refs answer from `url` says about its host's log, and |
|
| 306 | - | /// read each bookmark attestation's integrated time. Fails only when |
|
| 307 | - | /// the host showed two histories under one key; everything else is a |
|
| 308 | - | /// note. |
|
| 309 | - | pub(crate) fn check_log( |
|
| 310 | - | &self, |
|
| 311 | - | url: &str, |
|
| 312 | - | ca: Option<&Path>, |
|
| 313 | - | resp: &RefsResponse, |
|
| 314 | - | ) -> Result<LogCheck> { |
|
| 315 | - | let mut out = LogCheck::default(); |
|
| 316 | - | let (Some(text), Some(hub)) = (&resp.log, hub_of(url)) else { |
|
| 317 | - | return Ok(out); |
|
| 318 | - | }; |
|
| 319 | - | out.kept = true; |
|
| 320 | - | let options = RemoteOptions { |
|
| 321 | - | ca: ca.map(Path::to_path_buf), |
|
| 322 | - | ..RemoteOptions::default() |
|
| 323 | - | }; |
|
| 324 | - | let accepted = match self.accept_checkpoint(&hub, text, &options)? { |
|
| 325 | - | Ok(c) => c, |
|
| 326 | - | Err(why) => { |
|
| 327 | - | out.notes |
|
| 328 | - | .push(format!("{hub}'s transparency log was not checked: {why}")); |
|
| 329 | - | out.unchecked = Some(why); |
|
| 330 | - | return Ok(out); |
|
| 331 | - | } |
|
| 332 | - | }; |
|
| 333 | - | let cp = &accepted.checkpoint; |
|
| 334 | - | out.notes.extend(accepted.notes.iter().cloned()); |
|
| 335 | - | let witnesses = self.pin_witnesses(&hub, &resp.witnesses, &mut out.notes)?; |
|
| 336 | - | let mut missing = 0; |
|
| 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 { |
|
| 345 | - | missing += 1; |
|
| 346 | - | return Err("it is not in the log yet".into()); |
|
| 347 | - | }; |
|
| 348 | - | let fits = Entry::decode(&l.entry).is_ok_and(|e| { |
|
| 349 | - | e.kind == kind && e.object == a.attest.hash() && e.repo == a.attest.repo |
|
| 350 | - | }); |
|
| 351 | - | let included = fits |
|
| 352 | - | && tlog::check_inclusion( |
|
| 353 | - | &l.proof, |
|
| 354 | - | cp.size, |
|
| 355 | - | &cp.root, |
|
| 356 | - | l.index, |
|
| 357 | - | &tlog::leaf_hash(&l.entry), |
|
| 358 | - | ) |
|
| 359 | - | .is_ok(); |
|
| 360 | - | if !included { |
|
| 361 | - | out.notes.push(format!( |
|
| 362 | - | "{hub}'s log entry {} for {name} does not prove its attestation is in the log", |
|
| 363 | - | l.index |
|
| 364 | - | )); |
|
| 365 | - | } |
|
| 366 | - | if !fits { |
|
| 367 | - | Err("its log entry is not its attestation's".to_string()) |
|
| 368 | - | } else if witnesses.is_empty() { |
|
| 369 | - | Err(format!("{hub} names no witness")) |
|
| 370 | - | } else { |
|
| 371 | - | let cosigned: Vec<(&str, &[Hash])> = integration |
|
| 372 | - | .iter() |
|
| 373 | - | .map(|i| (i.note.as_str(), i.proof.as_slice())) |
|
| 374 | - | .collect(); |
|
| 375 | - | tlog::integrated_time( |
|
| 376 | - | &l.entry, |
|
| 377 | - | l.index, |
|
| 378 | - | &cosigned, |
|
| 379 | - | &cp.origin, |
|
| 380 | - | &accepted.identity, |
|
| 381 | - | &witnesses, |
|
| 382 | - | ) |
|
| 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); |
|
| 399 | - | } |
|
| 400 | - | out.times = times; |
|
| 401 | - | out.tag_times = tag_times; |
|
| 402 | - | if missing > 0 { |
|
| 403 | - | out.notes.push(format!( |
|
| 404 | - | "{} of {hub}'s attestations {} not in its transparency log yet (logged within seconds of a push; checked on a later fetch)", |
|
| 405 | - | missing, |
|
| 406 | - | if missing == 1 { "is" } else { "are" } |
|
| 407 | - | )); |
|
| 408 | - | } |
|
| 409 | - | Ok(out) |
|
| 410 | - | } |
|
| 411 | - | } |
|
| 412 | - | ||
| 413 | - | /// A checkpoint `accept_checkpoint` took: the checkpoint, the log key it |
|
| 414 | - | /// verified with, its note as served, and what accepting it noted. |
|
| 415 | - | pub(crate) struct Accepted { |
|
| 416 | - | pub checkpoint: Checkpoint, |
|
| 417 | - | pub identity: Identity, |
|
| 418 | - | pub note: Note, |
|
| 419 | - | pub notes: Vec<String>, |
|
| 420 | - | } |
|
| 421 | - | ||
| 422 | - | /// What a refs answer said about its host's log. |
|
| 423 | - | #[derive(Debug, Default)] |
|
| 424 | - | pub(crate) struct LogCheck { |
|
| 425 | - | /// Whether the host keeps a log (it sent a checkpoint). |
|
| 426 | - | pub kept: bool, |
|
| 427 | - | /// Why the log could not be checked, when it could not. |
|
| 428 | - | pub unchecked: Option<String>, |
|
| 429 | - | pub notes: Vec<String>, |
|
| 430 | - | /// Each attested bookmark's integrated time, or why it has none. |
|
| 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>>, |
|
| 434 | - | } |
|
| 435 | - | ||
| 436 | - | impl LogCheck { |
|
| 437 | - | /// Bookmark `name`'s integrated time, or why there is none. |
|
| 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> { |
|
| 452 | - | if let Some(why) = &self.unchecked { |
|
| 453 | - | return Err(format!("its host's log was not checked ({why})")); |
|
| 454 | - | } |
|
| 455 | - | if !self.kept { |
|
| 456 | - | return Err("its host keeps no transparency log".into()); |
|
| 457 | - | } |
|
| 458 | - | match times.get(name) { |
|
| 459 | - | Some(Ok(i)) => Ok(i), |
|
| 460 | - | Some(Err(why)) => Err(why.clone()), |
|
| 461 | - | None => Err("it has no log entry".into()), |
|
| 462 | - | } |
|
| 463 | - | } |
|
| 464 | - | } |
|
| 465 | - | ||
| 466 | - | /// How a check that depends on when an attestation was signed came out |
|
| 467 | - | /// (spec §14.8, *Integrated time*). |
|
| 468 | - | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 469 | - | pub(crate) enum Timed { |
|
| 470 | - | /// Passed at the integrated time. |
|
| 471 | - | Integrated, |
|
| 472 | - | /// No integrated time: passed by the earlier rule (the issuer's |
|
| 473 | - | /// acceptance, else the attestation's own time). |
|
| 474 | - | Fallback, |
|
| 475 | - | /// Failed at the integrated time but passed by the earlier rule: |
|
| 476 | - | /// accepted with a note while logs are new. |
|
| 477 | - | Disagrees, |
|
| 478 | - | /// Failed. |
|
| 479 | - | Refused, |
|
| 480 | - | } |
|
| 481 | - | ||
| 482 | - | /// Judge a check (`at`, made at a time in milliseconds) at the integrated |
|
| 483 | - | /// time when there is one, else by the earlier rule (`before`). |
|
| 484 | - | pub(crate) fn judge( |
|
| 485 | - | integrated: Option<u64>, |
|
| 486 | - | at: impl Fn(u64) -> bool, |
|
| 487 | - | before: impl FnOnce() -> bool, |
|
| 488 | - | ) -> Timed { |
|
| 489 | - | match integrated { |
|
| 490 | - | Some(t) if at(t) => Timed::Integrated, |
|
| 491 | - | Some(_) => { |
|
| 492 | - | if before() { |
|
| 493 | - | Timed::Disagrees |
|
| 494 | - | } else { |
|
| 495 | - | Timed::Refused |
|
| 496 | - | } |
|
| 497 | - | } |
|
| 498 | - | None => { |
|
| 499 | - | if before() { |
|
| 500 | - | Timed::Fallback |
|
| 501 | - | } else { |
|
| 502 | - | Timed::Refused |
|
| 503 | - | } |
|
| 504 | - | } |
|
| 505 | - | } |
|
| 506 | - | } |
|
| 507 | - | ||
| 508 | - | /// A note when an attestation's claimed time (`claimed`, its own clock) |
|
| 509 | - | /// is more than `tlog::TIME_TOLERANCE_MS` from its integrated time. |
|
| 510 | - | pub(crate) fn gap_note(claimed: u64, integrated: &Integrated) -> Option<String> { |
|
| 511 | - | let t = integrated.ms(); |
|
| 512 | - | let minutes = |d: u64| d.div_ceil(60_000); |
|
| 513 | - | if claimed.saturating_add(tlog::TIME_TOLERANCE_MS) < t { |
|
| 514 | - | Some(format!( |
|
| 515 | - | "claims to be from {} minutes before witness {} first saw it: backdated, held back before it was logged, or signed on a slow clock", |
|
| 516 | - | minutes(t - claimed), |
|
| 517 | - | integrated.witness |
|
| 518 | - | )) |
|
| 519 | - | } else if claimed > t.saturating_add(tlog::TIME_TOLERANCE_MS) { |
|
| 520 | - | Some(format!( |
|
| 521 | - | "claims to be from {} minutes after witness {} saw it: signed on a fast clock", |
|
| 522 | - | minutes(claimed - t), |
|
| 523 | - | integrated.witness |
|
| 524 | - | )) |
|
| 525 | - | } else { |
|
| 526 | - | None |
|
| 527 | - | } |
|
| 528 | - | } |
|
| 529 | - | ||
| 530 | - | /// A hub's whole log, checked (`oxy tlog verify`). |
|
| 531 | - | #[derive(Clone, Debug)] |
|
| 532 | - | pub struct LogAudit { |
|
| 533 | - | pub hub: String, |
|
| 534 | - | pub checkpoint: Checkpoint, |
|
| 535 | - | /// Every entry, numbered from 0. |
|
| 536 | - | pub entries: Vec<Entry>, |
|
| 537 | - | /// What accepting the checkpoint noted (a new log key, a new name). |
|
| 538 | - | pub notes: Vec<String>, |
|
| 539 | - | /// The trusted witnesses that cosigned the checkpoint, with their |
|
| 540 | - | /// times in seconds, earliest first. |
|
| 541 | - | pub witnessed: Vec<(String, u64)>, |
|
| 542 | - | } |
|
| 543 | - | ||
| 544 | - | impl Repo { |
|
| 545 | - | /// Fetch the log of `url`'s hub whole: its checkpoint, accepted as a |
|
| 546 | - | /// fetch accepts one, and every entry bundle, whose entries must make |
|
| 547 | - | /// the root the checkpoint signs. |
|
| 548 | - | pub fn audit_log(&self, url: &str, ca: Option<&Path>) -> Result<LogAudit> { |
|
| 549 | - | let hub = |
|
| 550 | - | hub_of(url).ok_or_else(|| RepoError::Msg(format!("{url} is not a hub's address")))?; |
|
| 551 | - | let options = RemoteOptions { |
|
| 552 | - | ca: ca.map(Path::to_path_buf), |
|
| 553 | - | ..RemoteOptions::default() |
|
| 554 | - | }; |
|
| 555 | - | let tl = |e: TlogError| RepoError::Msg(e.to_string()); |
|
| 556 | - | let text = String::from_utf8(http_get(&format!("{hub}/log/checkpoint"), &options)?) |
|
| 557 | - | .map_err(|_| RepoError::Msg(format!("{hub}'s checkpoint is not text")))?; |
|
| 558 | - | let accepted = match self.accept_checkpoint(&hub, &text, &options)? { |
|
| 559 | - | Ok(c) => c, |
|
| 560 | - | Err(why) => return fail(format!("{hub}'s log could not be checked: {why}")), |
|
| 561 | - | }; |
|
| 562 | - | let mut notes = accepted.notes; |
|
| 563 | - | // The witnesses it names (one `<name> <base64 identity>` line |
|
| 564 | - | // each), pinned as a fetch pins them; a hub that does not list |
|
| 565 | - | // them leaves those pinned before. |
|
| 566 | - | let listed: Vec<(String, Vec<u8>)> = http_get(&format!("{hub}/log/witnesses"), &options) |
|
| 567 | - | .ok() |
|
| 568 | - | .and_then(|b| String::from_utf8(b).ok()) |
|
| 569 | - | .map(|t| { |
|
| 570 | - | t.lines() |
|
| 571 | - | .filter_map(|l| { |
|
| 572 | - | let (n, k) = l.split_once(' ')?; |
|
| 573 | - | Some((n.to_string(), crate::dsse::base64_decode(k.trim())?)) |
|
| 574 | - | }) |
|
| 575 | - | .collect() |
|
| 576 | - | }) |
|
| 577 | - | .unwrap_or_default(); |
|
| 578 | - | let witnesses = self.pin_witnesses(&hub, &listed, &mut notes)?; |
|
| 579 | - | let mut witnessed = accepted.note.cosignatures(&witnesses); |
|
| 580 | - | witnessed.sort_by_key(|(_, t)| *t); |
|
| 581 | - | let cp = accepted.checkpoint; |
|
| 582 | - | let mut entries = Vec::with_capacity(cp.size as usize); |
|
| 583 | - | let mut stored: Vec<Hash> = Vec::new(); |
|
| 584 | - | let mut n = 0u64; |
|
| 585 | - | while n < cp.size { |
|
| 586 | - | let t = Tile { |
|
| 587 | - | level: 0, |
|
| 588 | - | n: n >> tlog::TILE_HEIGHT, |
|
| 589 | - | width: (cp.size - n).min(tlog::TILE_WIDTH) as u32, |
|
| 590 | - | }; |
|
| 591 | - | let bytes = http_get(&format!("{hub}/log/{}", t.entries_path()), &options)?; |
|
| 592 | - | let bundle = tlog::decode_bundle(&bytes).map_err(tl)?; |
|
| 593 | - | if bundle.len() != t.width as usize { |
|
| 594 | - | return fail(format!( |
|
| 595 | - | "{hub}'s entry bundle {} holds {} entries, not {}", |
|
| 596 | - | t.entries_path(), |
|
| 597 | - | bundle.len(), |
|
| 598 | - | t.width |
|
| 599 | - | )); |
|
| 600 | - | } |
|
| 601 | - | for b in bundle { |
|
| 602 | - | let add = |
|
| 603 | - | tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&b), &stored).map_err(tl)?; |
|
| 604 | - | stored.extend(add); |
|
| 605 | - | entries.push(Entry::decode(&b).map_err(tl)?); |
|
| 606 | - | n += 1; |
|
| 607 | - | } |
|
| 608 | - | } |
|
| 609 | - | if tlog::tree_hash(cp.size, &stored).map_err(tl)? != cp.root { |
|
| 610 | - | return fail(format!( |
|
| 611 | - | "{hub} serves entries that do not make the root it signed for {} entries: its log is not the one it attests", |
|
| 612 | - | cp.size |
|
| 613 | - | )); |
|
| 614 | - | } |
|
| 615 | - | Ok(LogAudit { |
|
| 616 | - | hub, |
|
| 617 | - | checkpoint: cp, |
|
| 618 | - | entries, |
|
| 619 | - | notes, |
|
| 620 | - | witnessed, |
|
| 621 | - | }) |
|
| 622 | - | } |
|
| 623 | - | } |
modified crates/oxy-core/src/repo/walk.rs +1 −3
| … | 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 | - | // `.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") { |
|
| 188 | + | if name == OXY_DIR { |
|
| 191 | 189 | continue; |
|
| 192 | 190 | } |
|
| 193 | 191 | let Ok(ft) = e.file_type() else { continue }; |
|
| … | 136 unchanged lines |
modified crates/oxy-core/src/repo/workspace.rs +2 −9
| … | 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 = crate::portable::canonical(dir)?; |
|
| 204 | + | let dir = dir.canonicalize()?; |
|
| 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 | - | // 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(), ""); |
|
| 213 | + | let wc = self.new_working_copy(heads.clone(), ""); |
|
| 218 | 214 | let wc_hash = self.store.put_change(&wc)?; |
|
| 219 | 215 | let (op_ref, op) = self.head_op()?; |
|
| 220 | 216 | let mut view = op.view.clone(); |
|
| … | 129 unchanged lines |
||
| 350 | 346 | std::time::Duration::from_secs(60), |
|
| 351 | 347 | )?; |
|
| 352 | 348 | HELD.with(|h| h.borrow_mut().insert(path.clone(), 1)); |
|
| 353 | - | // Whoever held the lock may have moved the operation head: what |
|
| 354 | - | // is done under it starts from the head as it is now (oxygenhub#249). |
|
| 355 | - | self.forget_head(); |
|
| 356 | 349 | Ok(WorkingCopyLock { |
|
| 357 | 350 | path, |
|
| 358 | 351 | _file: Some(file), |
|
| … | 3 unchanged lines |
modified crates/oxy-core/src/state.rs +14 −287
| … | 39 unchanged lines |
||
| 40 | 40 | pub kind: ChangeKind, |
|
| 41 | 41 | } |
|
| 42 | 42 | ||
| 43 | - | /// The log's order of changes: newest first by clock, then by hash, so |
|
| 44 | - | /// it is total. |
|
| 45 | - | pub fn log_order(a: (&ChangeHash, &Hlc), b: (&ChangeHash, &Hlc)) -> std::cmp::Ordering { |
|
| 46 | - | b.1.cmp(a.1).then(b.0.cmp(a.0)) |
|
| 47 | - | } |
|
| 48 | - | ||
| 49 | 43 | impl ChangeMeta { |
|
| 50 | 44 | /// In the flat layout of local indexes and caches ([`crate::flat`]). |
|
| 51 | 45 | pub(crate) fn write_flat(&self, w: &mut crate::flat::Writer) { |
|
| … | 65 unchanged lines |
||
| 117 | 111 | /// changes and tree), the file graphs not built yet, by node: `files` |
|
| 118 | 112 | /// holds only those loaded with [`State::load_graphs`]. |
|
| 119 | 113 | deferred: Option<crate::fasthash::FastMap<NodeId, Arc<Deferred>>>, |
|
| 120 | - | /// The tree as readers see it ([`State::view`]), once worked out for |
|
| 121 | - | /// a tree with name conflicts; cleared by every change added. |
|
| 122 | - | unified_view: std::sync::OnceLock<TreeView>, |
|
| 123 | 114 | } |
|
| 124 | 115 | ||
| 125 | 116 | use crate::objects::TextRef; |
|
| … | 207 unchanged lines |
||
| 333 | 324 | // [`State::read_index_changes`]). |
|
| 334 | 325 | let mut w = Writer::default(); |
|
| 335 | 326 | w.fixed(Self::INDEX_MAGIC); |
|
| 336 | - | // In log order, newest first (oxygen#94): the log takes them as |
|
| 337 | - | // they come, with no map and no sort. |
|
| 338 | 327 | let mut changes: Vec<(&ChangeHash, &Arc<ChangeMeta>)> = self.changes.iter().collect(); |
|
| 339 | - | changes.sort_unstable_by(|a, b| log_order((a.0, &a.1.hlc), (b.0, &b.1.hlc))); |
|
| 328 | + | changes.sort_by_key(|(h, _)| **h); |
|
| 340 | 329 | w.count(changes.len()); |
|
| 341 | 330 | for (h, m) in changes { |
|
| 342 | 331 | w.object(h); |
|
| … | 206 unchanged lines |
||
| 549 | 538 | } |
|
| 550 | 539 | ||
| 551 | 540 | /// The changes of a state index: its first block, which a reader can |
|
| 552 | - | /// take without the rest. |
|
| 541 | + | /// take without the rest (the log needs no more). |
|
| 553 | 542 | pub fn read_index_changes( |
|
| 554 | 543 | bytes: &[u8], |
|
| 555 | 544 | ) -> Result<crate::fasthash::FastMap<ChangeHash, Arc<ChangeMeta>>, ObjectError> { |
|
| 556 | - | Ok(Self::read_index_changes_listed(bytes)? |
|
| 557 | - | .into_iter() |
|
| 558 | - | .map(|(h, m)| (h, Arc::new(m))) |
|
| 559 | - | .collect()) |
|
| 560 | - | } |
|
| 561 | - | ||
| 562 | - | /// The same changes as a list in the order the index keeps them, which |
|
| 563 | - | /// a current index keeps in log order ([`log_order`]): what the log |
|
| 564 | - | /// needs (oxygen#94). |
|
| 565 | - | pub fn read_index_changes_listed( |
|
| 566 | - | bytes: &[u8], |
|
| 567 | - | ) -> Result<Vec<(ChangeHash, ChangeMeta)>, ObjectError> { |
|
| 568 | 545 | use crate::flat::Reader; |
|
| 569 | 546 | let mut r = Reader::new(bytes); |
|
| 570 | 547 | if r.fixed::<18>()?.as_slice() != Self::INDEX_MAGIC { |
|
| 571 | 548 | return Err(ObjectError::Schema("state index: not a state index".into())); |
|
| 572 | 549 | } |
|
| 573 | 550 | let n = r.count()?; |
|
| 574 | - | let mut changes = Vec::with_capacity(n); |
|
| 551 | + | let mut changes = crate::fasthash::FastMap::default(); |
|
| 552 | + | changes.reserve(n); |
|
| 575 | 553 | for _ in 0..n { |
|
| 576 | 554 | let h = r.object()?; |
|
| 577 | - | changes.push((h, ChangeMeta::read_flat(&mut r)?)); |
|
| 555 | + | changes.insert(h, Arc::new(ChangeMeta::read_flat(&mut r)?)); |
|
| 578 | 556 | } |
|
| 579 | 557 | if !r.at_end() { |
|
| 580 | 558 | return Err(ObjectError::Schema("state index: trailing bytes".into())); |
|
| … | 133 unchanged lines |
||
| 714 | 692 | closure: Default::default(), |
|
| 715 | 693 | levels: Default::default(), |
|
| 716 | 694 | deferred: self.deferred.clone(), |
|
| 717 | - | unified_view: self.unified_view.clone(), |
|
| 718 | 695 | } |
|
| 719 | 696 | } |
|
| 720 | 697 | } |
|
| … | 27 unchanged lines |
||
| 748 | 725 | FileContent::Dir => false, |
|
| 749 | 726 | } |
|
| 750 | 727 | } |
|
| 751 | - | } |
|
| 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 | 728 | } |
|
| 763 | 729 | ||
| 764 | 730 | /// See `State::conflict_regions`. |
|
| … | 544 unchanged lines |
||
| 1309 | 1275 | } |
|
| 1310 | 1276 | pub fn tree(&self) -> &TreeGraph { |
|
| 1311 | 1277 | &self.tree |
|
| 1312 | - | } |
|
| 1313 | - | ||
| 1314 | - | /// The tree as every reader sees it: the tree graph's view, with the |
|
| 1315 | - | /// same path added on several lines of history with identical content |
|
| 1316 | - | /// shown as one entry rather than a name conflict (spec §9.2). The |
|
| 1317 | - | /// text files among such candidates must be built in a history-only |
|
| 1318 | - | /// state ([`State::unify_candidates`]); until they are, they are shown |
|
| 1319 | - | /// as the conflict. |
|
| 1320 | - | pub fn view(&self) -> TreeView { |
|
| 1321 | - | if let Some(v) = self.unified_view.get() { |
|
| 1322 | - | return v.clone(); |
|
| 1323 | - | } |
|
| 1324 | - | let raw = self.tree.view(); |
|
| 1325 | - | if !raw |
|
| 1326 | - | .conflicts |
|
| 1327 | - | .iter() |
|
| 1328 | - | .any(|c| matches!(c, crate::tree::TreeConflict::Name { .. })) |
|
| 1329 | - | { |
|
| 1330 | - | return raw; |
|
| 1331 | - | } |
|
| 1332 | - | let (view, decided) = self.tree.unify(raw, |es| self.same_content(es)); |
|
| 1333 | - | if decided { |
|
| 1334 | - | let _ = self.unified_view.set(view.clone()); |
|
| 1335 | - | } |
|
| 1336 | - | view |
|
| 1337 | 1278 | } |
|
| 1338 | - | ||
| 1339 | - | /// The text files [`State::view`] must compare and a history-only |
|
| 1340 | - | /// state has not built: candidates of the tree's name conflicts. |
|
| 1341 | - | pub fn unify_candidates(&self) -> Vec<NodeId> { |
|
| 1342 | - | let Some(deferred) = &self.deferred else { |
|
| 1343 | - | return Vec::new(); |
|
| 1344 | - | }; |
|
| 1345 | - | if self.unified_view.get().is_some() { |
|
| 1346 | - | return Vec::new(); |
|
| 1347 | - | } |
|
| 1348 | - | let raw = self.tree.view(); |
|
| 1349 | - | let mut out = Vec::new(); |
|
| 1350 | - | for c in &raw.conflicts { |
|
| 1351 | - | if let crate::tree::TreeConflict::Name { nodes, .. } = c { |
|
| 1352 | - | out.extend(nodes.iter().filter(|n| deferred.contains_key(*n))); |
|
| 1353 | - | } |
|
| 1354 | - | } |
|
| 1355 | - | out |
|
| 1356 | - | } |
|
| 1357 | - | ||
| 1358 | - | /// Whether the entries' contents are identical, unconflicted: the same |
|
| 1359 | - | /// rendering for text files, the same single blob for opaque ones, |
|
| 1360 | - | /// symlinks and subrepos. `None` for a text file not built yet. |
|
| 1361 | - | fn same_content(&self, entries: &[&crate::tree::TreeEntry]) -> Option<bool> { |
|
| 1362 | - | let text = |e: &crate::tree::TreeEntry| e.kind == NodeKind::File && !e.mode.opaque; |
|
| 1363 | - | let mut first: Option<Vec<u8>> = None; |
|
| 1364 | - | let mut first_blob: Option<ObjectRef> = None; |
|
| 1365 | - | for e in entries { |
|
| 1366 | - | if text(e) { |
|
| 1367 | - | if self |
|
| 1368 | - | .deferred |
|
| 1369 | - | .as_ref() |
|
| 1370 | - | .is_some_and(|d| d.contains_key(&e.node)) |
|
| 1371 | - | { |
|
| 1372 | - | return None; |
|
| 1373 | - | } |
|
| 1374 | - | let (bytes, m) = self.render_file(&e.node); |
|
| 1375 | - | if m.is_conflicted() { |
|
| 1376 | - | return Some(false); |
|
| 1377 | - | } |
|
| 1378 | - | match &first { |
|
| 1379 | - | None => first = Some(bytes), |
|
| 1380 | - | Some(b) if *b == bytes => {} |
|
| 1381 | - | Some(_) => return Some(false), |
|
| 1382 | - | } |
|
| 1383 | - | } else { |
|
| 1384 | - | let [blob] = self.live_blobs(&e.node)[..] else { |
|
| 1385 | - | return Some(false); |
|
| 1386 | - | }; |
|
| 1387 | - | match first_blob { |
|
| 1388 | - | None => first_blob = Some(blob), |
|
| 1389 | - | Some(b) if b == blob => {} |
|
| 1390 | - | Some(_) => return Some(false), |
|
| 1391 | - | } |
|
| 1392 | - | } |
|
| 1393 | - | } |
|
| 1394 | - | Some(true) |
|
| 1395 | - | } |
|
| 1396 | 1279 | pub fn file(&self, node: &NodeId) -> Option<&FileGraph> { |
|
| 1397 | 1280 | self.graph(node) |
|
| 1398 | 1281 | } |
|
| … | 93 unchanged lines |
||
| 1492 | 1375 | return; |
|
| 1493 | 1376 | } |
|
| 1494 | 1377 | let meta = ChangeMeta::of(&change); |
|
| 1495 | - | self.unified_view = Default::default(); |
|
| 1496 | 1378 | self.apply_with(h, change, texts); |
|
| 1497 | 1379 | self.changes.insert(h, Arc::new(meta)); |
|
| 1498 | 1380 | { |
|
| … | 510 unchanged lines |
||
| 2009 | 1891 | .collect() |
|
| 2010 | 1892 | } |
|
| 2011 | 1893 | ||
| 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 | - | ||
| 2019 | 1894 | /// Live blobs of an opaque node: every `new` that no later `Replace` retired. |
|
| 2020 | 1895 | pub fn live_blobs(&self, node: &NodeId) -> Vec<ObjectRef> { |
|
| 2021 | 1896 | let mut live: Vec<ObjectRef> = self |
|
| … | 104 unchanged lines |
||
| 2126 | 2001 | out |
|
| 2127 | 2002 | } |
|
| 2128 | 2003 | ||
| 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 | - | ||
| 2222 | 2004 | pub fn render_file(&self, node: &NodeId) -> (Vec<u8>, Materialized) { |
|
| 2223 | 2005 | let Some(g) = self.graph(node) else { |
|
| 2224 | 2006 | return (Vec::new(), Materialized::default()); |
|
| 2225 | 2007 | }; |
|
| 2226 | - | let m = self.materialize_ordered(g); |
|
| 2008 | + | let m = g.materialize(self); |
|
| 2227 | 2009 | let bytes = g.render(&m, &|h| self.label(h)); |
|
| 2228 | 2010 | (bytes, m) |
|
| 2229 | 2011 | } |
|
| … | 3 unchanged lines |
||
| 2233 | 2015 | pub fn materialize(&self) -> Snapshot { |
|
| 2234 | 2016 | use rayon::prelude::*; |
|
| 2235 | 2017 | self.require_full("materializing a state"); |
|
| 2236 | - | let tree = self.view(); |
|
| 2018 | + | let tree = self.tree.view(); |
|
| 2237 | 2019 | let entries: Vec<(&String, &crate::tree::TreeEntry)> = tree.entries.iter().collect(); |
|
| 2238 | 2020 | let rendered: Vec<(String, FileState)> = entries |
|
| 2239 | 2021 | .par_iter() |
|
| … | 10 unchanged lines |
||
| 2250 | 2032 | /// Like [`State::materialize`], leaving the text files of nodes in |
|
| 2251 | 2033 | /// `skip` unrendered (listed in [`Snapshot::unrendered`]); every other |
|
| 2252 | 2034 | /// file's graph must be loaded. For a caller that already knows those |
|
| 2253 | - | /// files are unchanged and not conflicted. Its tree is the tree |
|
| 2254 | - | /// graph's own view, as a Git import builds against it: same-path |
|
| 2255 | - | /// additions are not unified ([`State::view`]), since the import's |
|
| 2256 | - | /// merge records Git's tree for them itself. |
|
| 2035 | + | /// files are unchanged and not conflicted. |
|
| 2257 | 2036 | pub fn materialize_except(&self, skip: &HashSet<NodeId>) -> Snapshot { |
|
| 2258 | - | self.materialize_leaving(self.tree.view(), |_, e| skip.contains(&e.node)) |
|
| 2037 | + | self.materialize_leaving(|_, e| skip.contains(&e.node)) |
|
| 2259 | 2038 | } |
|
| 2260 | 2039 | ||
| 2261 | 2040 | /// As [`State::materialize_except`], rendering only the text files at |
|
| 2262 | - | /// `render` (a few, where the caller knows the rest unchanged), with |
|
| 2263 | - | /// the tree as [`State::view`] has it. |
|
| 2041 | + | /// `render` (a few, where the caller knows the rest unchanged). |
|
| 2264 | 2042 | pub fn materialize_only(&self, render: &std::collections::HashSet<&str>) -> Snapshot { |
|
| 2265 | - | self.materialize_leaving(self.view(), |p, _| !render.contains(p)) |
|
| 2043 | + | self.materialize_leaving(|p, _| !render.contains(p)) |
|
| 2266 | 2044 | } |
|
| 2267 | 2045 | ||
| 2268 | - | /// As [`State::materialize_only`], rendering only the text files of |
|
| 2046 | + | /// As [`State::materialize_except`], rendering only the text files of |
|
| 2269 | 2047 | /// the nodes in `render` (a change's edited files), without a set of |
|
| 2270 | 2048 | /// every other file to leave out. |
|
| 2271 | 2049 | pub fn materialize_nodes(&self, render: &HashSet<NodeId>) -> Snapshot { |
|
| 2272 | - | self.materialize_leaving(self.view(), |_, e| !render.contains(&e.node)) |
|
| 2050 | + | self.materialize_leaving(|_, e| !render.contains(&e.node)) |
|
| 2273 | 2051 | } |
|
| 2274 | 2052 | ||
| 2275 | 2053 | fn materialize_leaving( |
|
| 2276 | 2054 | &self, |
|
| 2277 | - | tree: TreeView, |
|
| 2278 | 2055 | leave: impl Fn(&str, &crate::tree::TreeEntry) -> bool + Sync, |
|
| 2279 | 2056 | ) -> Snapshot { |
|
| 2280 | 2057 | use rayon::prelude::*; |
|
| 2058 | + | let tree = self.tree.view(); |
|
| 2281 | 2059 | let entries: Vec<(&String, &crate::tree::TreeEntry)> = tree.entries.iter().collect(); |
|
| 2282 | 2060 | let rendered: Vec<(String, FileState, bool)> = entries |
|
| 2283 | 2061 | .par_iter() |
|
| … | 110 unchanged lines |
||
| 2394 | 2172 | return false; |
|
| 2395 | 2173 | } |
|
| 2396 | 2174 | 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). |
|
| 2403 | 2175 | let mut stack: Vec<ChangeHash> = self |
|
| 2404 | 2176 | .changes |
|
| 2405 | 2177 | .get(a) |
|
| 2406 | 2178 | .map(|m| m.deps.clone()) |
|
| 2407 | 2179 | .unwrap_or_default(); |
|
| 2408 | - | if stack.contains(b) { |
|
| 2409 | - | return true; |
|
| 2410 | - | } |
|
| 2411 | 2180 | while let Some(c) = stack.pop() { |
|
| 2412 | 2181 | if c == *b { |
|
| 2413 | 2182 | return true; |
|
| … | 2 unchanged lines |
||
| 2416 | 2185 | continue; |
|
| 2417 | 2186 | } |
|
| 2418 | 2187 | if let Some(m) = self.changes.get(&c) { |
|
| 2419 | - | if m.deps.contains(b) { |
|
| 2420 | - | return true; |
|
| 2421 | - | } |
|
| 2422 | 2188 | stack.extend(m.deps.iter().copied()); |
|
| 2423 | 2189 | } |
|
| 2424 | 2190 | } |
|
| … | 4 unchanged lines |
||
| 2429 | 2195 | #[cfg(test)] |
|
| 2430 | 2196 | mod tests { |
|
| 2431 | 2197 | use super::*; |
|
| 2432 | - | ||
| 2433 | - | /// A changes block of thousands comes back whole and in order, and a |
|
| 2434 | - | /// truncated one is refused (oxygen#94). |
|
| 2435 | - | #[test] |
|
| 2436 | - | fn a_long_changes_block_decodes_in_order() { |
|
| 2437 | - | use crate::flat::Writer; |
|
| 2438 | - | let metas: Vec<(ChangeHash, ChangeMeta)> = (0..5000u32) |
|
| 2439 | - | .map(|i| { |
|
| 2440 | - | let mut h = [0u8; 32]; |
|
| 2441 | - | h[..4].copy_from_slice(&i.to_le_bytes()); |
|
| 2442 | - | let meta = ChangeMeta { |
|
| 2443 | - | change_id: crate::ids::ChangeId([i as u8; 16]), |
|
| 2444 | - | deps: (0..i % 3).map(|d| ObjectRef([d as u8; 32])).collect(), |
|
| 2445 | - | author: ObjectRef([7; 32]), |
|
| 2446 | - | hlc: Hlc { |
|
| 2447 | - | wall_ms: 1_000_000 - i as u64, |
|
| 2448 | - | logical: i, |
|
| 2449 | - | }, |
|
| 2450 | - | message: "x".repeat((i % 50) as usize), |
|
| 2451 | - | kind: ChangeKind::Normal, |
|
| 2452 | - | }; |
|
| 2453 | - | (ObjectRef(h), meta) |
|
| 2454 | - | }) |
|
| 2455 | - | .collect(); |
|
| 2456 | - | let mut w = Writer::default(); |
|
| 2457 | - | w.fixed(State::INDEX_MAGIC); |
|
| 2458 | - | w.count(metas.len()); |
|
| 2459 | - | for (h, m) in &metas { |
|
| 2460 | - | w.object(h); |
|
| 2461 | - | m.write_flat(&mut w); |
|
| 2462 | - | } |
|
| 2463 | - | assert_eq!(State::read_index_changes_listed(&w.0).unwrap(), metas); |
|
| 2464 | - | let mut cut = w.0.clone(); |
|
| 2465 | - | cut.pop(); |
|
| 2466 | - | assert!( |
|
| 2467 | - | State::read_index_changes_listed(&cut).is_err(), |
|
| 2468 | - | "a truncated block is refused" |
|
| 2469 | - | ); |
|
| 2470 | - | } |
|
| 2471 | 2198 | use crate::ids::ChangeId; |
|
| 2472 | 2199 | use crate::objects::{AtomRange, ChangeKind, Hlc, OpRef}; |
|
| 2473 | 2200 | ||
| … | 744 unchanged lines |
modified crates/oxy-core/src/store.rs +108 −2163
| … | 23 unchanged lines |
||
| 24 | 24 | //! `receipt/<hh>/<rest>/<attest>` index the seals covering a change and |
|
| 25 | 25 | //! the receipts on a state. Older stores hold these as one list per |
|
| 26 | 26 | //! object (`sigs/`, `seals/`, `receipts/`), which are still read. |
|
| 27 | - | //! `pack-retired` records the packs and loose objects a repack replaced, |
|
| 28 | - | //! which stay for readers until a collection an hour later (oxygenhub#249). |
|
| 29 | - | //! |
|
| 30 | - | //! A store that is not this disk's directory, where finding an object's |
|
| 31 | - | //! entries is a LIST request, also keeps every entry key in the |
|
| 32 | - | //! `pack-entries` journal and, once a collection folds them, in entry |
|
| 33 | - | //! lists (`pack-<id>.entries`), read once per open (spec §13.7, #116). |
|
| 34 | 27 | ||
| 35 | 28 | use crate::attest::Attest; |
|
| 36 | 29 | use crate::hash::{hash_object, header, ObjectRef, ObjectType}; |
|
| … | 28 unchanged lines |
||
| 65 | 58 | /// Largest object a pack entry may claim; anything bigger is corruption, |
|
| 66 | 59 | /// and refusing it keeps a bad index from driving huge allocations. |
|
| 67 | 60 | const MAX_OBJECT_LEN: u32 = 1 << 30; |
|
| 68 | - | /// What reading an entry of a pack whose dictionary is gone says. A pack |
|
| 69 | - | /// being deleted can lose its dictionary first, so a read that meets this |
|
| 70 | - | /// looks at the packs again before it is believed. |
|
| 71 | - | const NO_DICT: &str = "packed with a dictionary the pack lacks"; |
|
| 72 | - | ||
| 73 | - | /// The record of what repacks replaced (oxygenhub#249): packs and loose |
|
| 74 | - | /// objects whose contents a newer pack holds, kept for readers that |
|
| 75 | - | /// still use them until garbage collection deletes them, at least |
|
| 76 | - | /// [`RETIRED_FOR`] later. One line per key, `<retired at, wall ms> <key>`. |
|
| 77 | - | /// It is a reference record (updated by compare-and-swap, so concurrent |
|
| 78 | - | /// repacks and collections never lose each other's lines), named under |
|
| 79 | - | /// `pack-` so the pack listing every open makes shows whether it holds |
|
| 80 | - | /// anything. |
|
| 81 | - | const RETIRED_KEY: &str = "pack-retired"; |
|
| 82 | - | /// How long a retired pack or loose object stays in the store: a reader |
|
| 83 | - | /// that loaded its list of packs before a repack reads on from the old |
|
| 84 | - | /// packs this long, and after that finds the objects in the new pack by |
|
| 85 | - | /// looking again (see [`Store::get`]). |
|
| 86 | - | pub const RETIRED_FOR: std::time::Duration = std::time::Duration::from_secs(60 * 60); |
|
| 87 | - | /// Compare-and-swap rounds for the retired record before giving up. |
|
| 88 | - | const RETIRED_TRIES: usize = 32; |
|
| 89 | - | /// After a look at the pack listing found nothing new, how long a missed |
|
| 90 | - | /// [`Store::has`] answers without looking again: a writer probing for |
|
| 91 | - | /// thousands of objects it is about to store would otherwise list the |
|
| 92 | - | /// packs for each. |
|
| 93 | - | const HAS_RELIST_AFTER: std::time::Duration = std::time::Duration::from_secs(1); |
|
| 94 | - | ||
| 95 | - | /// The journal of signatures and index entries (spec §13.7): every entry |
|
| 96 | - | /// key a writer on a store that is not this disk's directory wrote since |
|
| 97 | - | /// the last fold, one line each, `<folded at, wall ms; 0 until folded> |
|
| 98 | - | /// <key>`, as the retired record's lines are. A reference record updated |
|
| 99 | - | /// by compare-and-swap, so concurrent writers never lose each other's |
|
| 100 | - | /// lines; read once per open, instead of a listing per lookup. |
|
| 101 | - | pub const JOURNAL_KEY: &str = "pack-entries"; |
|
| 102 | - | /// A complete entry list (spec §13.7): every entry a fold found. |
|
| 103 | - | const LIST_SUFFIX: &str = ".entries"; |
|
| 104 | - | /// A partial entry list: journal lines a writer folded out. |
|
| 105 | - | const PART_SUFFIX: &str = ".entries-part"; |
|
| 106 | - | /// The first line of an entry list. |
|
| 107 | - | const LIST_MAGIC: &str = "oxy-entries 1"; |
|
| 108 | - | /// Written once a collection has copied the older one-list keys into |
|
| 109 | - | /// per-object entries: readers stop looking for them. |
|
| 110 | - | const LEGACY_FOLDED_KEY: &str = "pack-legacy-folded"; |
|
| 111 | - | /// Unfolded journal lines a writer lets the journal hold before it folds |
|
| 112 | - | /// them into a partial list. |
|
| 113 | - | const JOURNAL_FOLD_AT: usize = 1000; |
|
| 114 | - | /// How long a handle answers lookups from the entries it read before it |
|
| 115 | - | /// reads the pack listing and the journal again (spec §13.7): the |
|
| 116 | - | /// interval a cloud client keeps the pack listing for, so another |
|
| 117 | - | /// writer's entry is seen as soon as its pack would be. |
|
| 118 | - | pub const ENTRIES_FRESH: std::time::Duration = std::time::Duration::from_secs(30); |
|
| 119 | - | /// The per-object entry indexes, each with the one-list name older |
|
| 120 | - | /// stores kept it under. |
|
| 121 | - | const ENTRY_INDEXES: [(&str, &str); 6] = [ |
|
| 122 | - | ("sig", "sigs"), |
|
| 123 | - | ("seal", "seals"), |
|
| 124 | - | ("receipt", "receipts"), |
|
| 125 | - | ("approval", "approvals"), |
|
| 126 | - | ("cert", "certs"), |
|
| 127 | - | ("accepted", "accepted-lists"), |
|
| 128 | - | ]; |
|
| 129 | - | ||
| 130 | - | /// A pack listing as compared: each key with its size and time. |
|
| 131 | - | type Listed = Vec<(String, u64, Option<u64>)>; |
|
| 132 | 61 | ||
| 133 | - | /// The retired record's lines: each key with the time it was retired. |
|
| 134 | - | fn parse_retired(bytes: &[u8]) -> std::collections::BTreeMap<String, u64> { |
|
| 135 | - | String::from_utf8_lossy(bytes) |
|
| 136 | - | .lines() |
|
| 137 | - | .filter_map(|l| { |
|
| 138 | - | let (t, k) = l.split_once(' ')?; |
|
| 139 | - | Some((k.to_string(), t.parse().ok()?)) |
|
| 140 | - | }) |
|
| 141 | - | .collect() |
|
| 142 | - | } |
|
| 143 | - | ||
| 144 | - | fn encode_retired(m: &std::collections::BTreeMap<String, u64>) -> Vec<u8> { |
|
| 145 | - | m.iter() |
|
| 146 | - | .map(|(k, t)| format!("{t} {k}\n")) |
|
| 147 | - | .collect::<String>() |
|
| 148 | - | .into_bytes() |
|
| 149 | - | } |
|
| 150 | - | ||
| 151 | - | /// What deleting retired packs and loose objects did. |
|
| 152 | - | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
|
| 153 | - | pub struct RetiredReport { |
|
| 154 | - | /// Retired packs deleted, each with its index and dictionary. |
|
| 155 | - | pub packs: usize, |
|
| 156 | - | /// Retired loose objects deleted. |
|
| 157 | - | pub loose: usize, |
|
| 158 | - | /// Retired entries younger than [`RETIRED_FOR`], left for the next |
|
| 159 | - | /// collection. |
|
| 160 | - | pub kept: usize, |
|
| 161 | - | } |
|
| 162 | - | ||
| 163 | 62 | /// An object read from the store: its type and body. |
|
| 164 | 63 | pub type Object = (ObjectType, Vec<u8>); |
|
| 165 | 64 | ||
| … | 60 unchanged lines |
||
| 226 | 125 | format!("{index}/{}/{}/", &h[..2], &h[2..]) |
|
| 227 | 126 | } |
|
| 228 | 127 | ||
| 229 | - | /// Whether a key names an entry list (spec §13.7), complete or partial. |
|
| 230 | - | fn is_entry_list(key: &str) -> bool { |
|
| 231 | - | key.starts_with("pack-") && (key.ends_with(LIST_SUFFIX) || key.ends_with(PART_SUFFIX)) |
|
| 232 | - | } |
|
| 233 | - | ||
| 234 | - | /// An entry list's bytes: zstd-compressed text, [`LIST_MAGIC`] and then |
|
| 235 | - | /// one key per line, sorted. |
|
| 236 | - | fn encode_entry_list<'a>(keys: impl Iterator<Item = &'a String>) -> Result<Vec<u8>> { |
|
| 237 | - | let mut text = String::from(LIST_MAGIC); |
|
| 238 | - | text.push('\n'); |
|
| 239 | - | for k in keys { |
|
| 240 | - | text.push_str(k); |
|
| 241 | - | text.push('\n'); |
|
| 242 | - | } |
|
| 243 | - | Ok(zstd::bulk::compress(text.as_bytes(), ZSTD_LEVEL)?) |
|
| 244 | - | } |
|
| 245 | - | ||
| 246 | - | /// The keys an entry list holds. |
|
| 247 | - | fn decode_entry_list(key: &str, bytes: &[u8]) -> Result<Vec<String>> { |
|
| 248 | - | let bad = |why: &str| StoreError::Io(io::Error::other(format!("{key}: {why}"))); |
|
| 249 | - | let text = zstd::stream::decode_all(bytes).map_err(|_| bad("not zstd"))?; |
|
| 250 | - | let text = String::from_utf8(text).map_err(|_| bad("not UTF-8"))?; |
|
| 251 | - | let mut lines = text.lines(); |
|
| 252 | - | if lines.next() != Some(LIST_MAGIC) { |
|
| 253 | - | return Err(bad("not an entry list")); |
|
| 254 | - | } |
|
| 255 | - | Ok(lines |
|
| 256 | - | .filter(|l| !l.is_empty()) |
|
| 257 | - | .map(str::to_string) |
|
| 258 | - | .collect()) |
|
| 259 | - | } |
|
| 260 | - | ||
| 261 | - | /// Drop journal lines folded at least [`RETIRED_FOR`] before `now`: |
|
| 262 | - | /// every reader that could need them has a list holding them by then. |
|
| 263 | - | fn expire_folded(m: &mut std::collections::BTreeMap<String, u64>, now: u64) { |
|
| 264 | - | let hour = RETIRED_FOR.as_millis() as u64; |
|
| 265 | - | m.retain(|_, t| *t == 0 || now.saturating_sub(*t) < hour); |
|
| 266 | - | } |
|
| 267 | - | ||
| 268 | 128 | /// `<index>/<hh>/<rest>`: the one-file-per-object layout of older stores. |
|
| 269 | 129 | fn legacy_key(index: &str, r: &ObjectRef) -> String { |
|
| 270 | 130 | let h = r.to_hex(); |
|
| … | 44 unchanged lines |
||
| 315 | 175 | dict: Option<&Arc<zstd::dict::DecoderDictionary<'static>>>, |
|
| 316 | 176 | ) -> Result<Object> { |
|
| 317 | 177 | let body = if e.flags & FLAG_DICT != 0 { |
|
| 318 | - | let dict = dict.ok_or_else(|| StoreError::Corrupt(*r, NO_DICT.into()))?; |
|
| 178 | + | let dict = dict.ok_or_else(|| { |
|
| 179 | + | StoreError::Corrupt(*r, "packed with a dictionary the pack lacks".into()) |
|
| 180 | + | })?; |
|
| 319 | 181 | zstd::bulk::Decompressor::with_prepared_dictionary(dict) |
|
| 320 | 182 | .and_then(|mut d| d.decompress(&stored, e.raw_len as usize)) |
|
| 321 | 183 | .map_err(|err| StoreError::Corrupt(*r, format!("zstd: {err}")))? |
|
| … | 13 unchanged lines |
||
| 335 | 197 | } |
|
| 336 | 198 | ||
| 337 | 199 | impl PackIndex { |
|
| 338 | - | /// Load a pack's index; `pack_len` is the pack's size, from the listing |
|
| 339 | - | /// that found it (a stat of it would be a request of its own). `None` |
|
| 340 | - | /// when the index is gone since the listing: the pack was deleted. |
|
| 341 | - | fn load( |
|
| 342 | - | backend: &Backend, |
|
| 343 | - | idx_key: &str, |
|
| 344 | - | pack_key: &str, |
|
| 345 | - | pack_len: u64, |
|
| 346 | - | ) -> Result<Option<PackIndex>> { |
|
| 347 | - | let data = match backend.get(idx_key) { |
|
| 348 | - | Ok(d) => d, |
|
| 349 | - | Err(BackendError::NotFound(_)) => return Ok(None), |
|
| 350 | - | Err(e) => return Err(backend_err(e)), |
|
| 351 | - | }; |
|
| 200 | + | fn load(backend: &Backend, idx_key: &str, pack_key: &str) -> Result<PackIndex> { |
|
| 201 | + | let data = backend.get(idx_key).map_err(backend_err)?; |
|
| 352 | 202 | let bad = |m: &str| StoreError::Corrupt(ObjectRef::ZERO, format!("{idx_key}: {m}")); |
|
| 353 | 203 | if data.len() < 12 || &data[..8] != IDX_MAGIC { |
|
| 354 | 204 | return Err(bad("bad index header")); |
|
| … | 2 unchanged lines |
||
| 357 | 207 | if data.len() != 12 + count * IDX_ENTRY { |
|
| 358 | 208 | return Err(bad("index length mismatch")); |
|
| 359 | 209 | } |
|
| 210 | + | let pack_len = backend |
|
| 211 | + | .stat(pack_key) |
|
| 212 | + | .map_err(backend_err)? |
|
| 213 | + | .ok_or_else(|| bad("the index names a pack that is not there"))? |
|
| 214 | + | .size; |
|
| 360 | 215 | let mut refs = Vec::with_capacity(count); |
|
| 361 | 216 | let mut entries = Vec::with_capacity(count); |
|
| 362 | 217 | for i in 0..count { |
|
| … | 32 unchanged lines |
||
| 395 | 250 | let dict_key = format!("{}.dict", pack_key.trim_end_matches(".pack")); |
|
| 396 | 251 | let dict = get_optional(backend, &dict_key)? |
|
| 397 | 252 | .map(|d| Arc::new(zstd::dict::DecoderDictionary::copy(&d))); |
|
| 398 | - | Ok(Some(PackIndex { |
|
| 253 | + | Ok(PackIndex { |
|
| 399 | 254 | key: pack_key.to_string(), |
|
| 400 | 255 | refs, |
|
| 401 | 256 | entries, |
|
| 402 | 257 | dict, |
|
| 403 | - | })) |
|
| 258 | + | }) |
|
| 404 | 259 | } |
|
| 405 | 260 | ||
| 406 | 261 | fn find(&self, r: &ObjectRef) -> Option<PackEntry> { |
|
| … | 65 unchanged lines |
||
| 472 | 327 | scratch: PathBuf, |
|
| 473 | 328 | /// The backend is that directory, so the old file paths still hold. |
|
| 474 | 329 | local: bool, |
|
| 475 | - | packs: RefCell<Vec<Arc<PackIndex>>>, |
|
| 476 | - | /// The pack listing `packs` was loaded from (key, size, time), to tell |
|
| 477 | - | /// whether a look at the store would find anything new. |
|
| 478 | - | packs_listed: RefCell<Listed>, |
|
| 479 | - | /// When a look at the pack listing for a missed [`Store::has`] last |
|
| 480 | - | /// found nothing new. |
|
| 481 | - | has_relisted: std::cell::Cell<Option<std::time::Instant>>, |
|
| 330 | + | packs: RefCell<Vec<PackIndex>>, |
|
| 482 | 331 | /// An open write batch (spec §13.4): objects go here instead of to loose |
|
| 483 | 332 | /// files, and are written out with one `fsync` when the batch finishes. |
|
| 484 | 333 | pending: RefCell<Option<Pending>>, |
|
| 485 | 334 | batch_depth: std::cell::Cell<u32>, |
|
| 486 | 335 | /// Change metadata already read, from the `metas` cache file. |
|
| 487 | 336 | metas: RefCell<Option<crate::fasthash::FastMap<ObjectRef, crate::state::ChangeMeta>>>, |
|
| 488 | - | /// The live entry lists (spec §13.7) the pack listing showed: listed |
|
| 489 | - | /// and not retired. |
|
| 490 | - | entry_lists: RefCell<Vec<String>>, |
|
| 491 | - | /// Whether the store is folded: a live entry list is complete. |
|
| 492 | - | folded: std::cell::Cell<bool>, |
|
| 493 | - | /// Whether the pack listing showed `pack-legacy-folded`. |
|
| 494 | - | legacy_folded: std::cell::Cell<bool>, |
|
| 495 | - | /// The entry keys of a folded store, read from its lists and journal |
|
| 496 | - | /// on the first lookup after the packs were loaded. |
|
| 497 | - | entries: RefCell<Option<std::collections::BTreeSet<String>>>, |
|
| 498 | - | /// When `entries` was read; past [`ENTRIES_FRESH`] it is read again. |
|
| 499 | - | entries_read: std::cell::Cell<Option<std::time::Instant>>, |
|
| 500 | - | /// Entry lists already read, by key: written once, so never read |
|
| 501 | - | /// twice; those no longer live are let go. |
|
| 502 | - | lists_read: RefCell<std::collections::HashMap<String, Arc<Vec<String>>>>, |
|
| 503 | - | /// Entry keys written in an open batch, added to the journal when the |
|
| 504 | - | /// batch is flushed. |
|
| 505 | - | journal_queue: RefCell<Vec<String>>, |
|
| 506 | 337 | } |
|
| 507 | 338 | ||
| 508 | 339 | impl std::fmt::Debug for Store { |
|
| … | 107 unchanged lines |
||
| 616 | 447 | return None; |
|
| 617 | 448 | } |
|
| 618 | 449 | static STORES: OnceLock<Mutex<HashMap<PathBuf, Arc<MemoryStore>>>> = OnceLock::new(); |
|
| 619 | - | let key = crate::portable::canonical(dir).unwrap_or_else(|_| dir.to_path_buf()); |
|
| 450 | + | let key = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); |
|
| 620 | 451 | let mut map = STORES |
|
| 621 | 452 | .get_or_init(Default::default) |
|
| 622 | 453 | .lock() |
|
| … | 42 unchanged lines |
||
| 665 | 496 | scratch, |
|
| 666 | 497 | local: false, |
|
| 667 | 498 | packs: RefCell::new(Vec::new()), |
|
| 668 | - | packs_listed: RefCell::new(Vec::new()), |
|
| 669 | - | has_relisted: std::cell::Cell::new(None), |
|
| 670 | 499 | pending: RefCell::new(None), |
|
| 671 | 500 | batch_depth: std::cell::Cell::new(0), |
|
| 672 | 501 | metas: RefCell::new(None), |
|
| 673 | - | entry_lists: RefCell::new(Vec::new()), |
|
| 674 | - | folded: std::cell::Cell::new(false), |
|
| 675 | - | legacy_folded: std::cell::Cell::new(false), |
|
| 676 | - | entries: RefCell::new(None), |
|
| 677 | - | entries_read: std::cell::Cell::new(None), |
|
| 678 | - | lists_read: RefCell::new(Default::default()), |
|
| 679 | - | journal_queue: RefCell::new(Vec::new()), |
|
| 680 | 502 | }; |
|
| 681 | 503 | store |
|
| 682 | 504 | .reload_packs() |
|
| … | 12 unchanged lines |
||
| 695 | 517 | self.local |
|
| 696 | 518 | } |
|
| 697 | 519 | ||
| 698 | - | /// Load the list of packs again, from a new listing: what other |
|
| 699 | - | /// writers packed since becomes readable, and packs since retired or |
|
| 700 | - | /// deleted are let go. Packs already loaded are kept, not read again. |
|
| 701 | - | pub fn refresh(&self) -> Result<()> { |
|
| 702 | - | *self.entries.borrow_mut() = None; |
|
| 703 | - | self.reload_packs() |
|
| 704 | - | } |
|
| 705 | - | ||
| 706 | - | /// The pack listing, with how `packs_listed` records it. |
|
| 707 | - | fn list_packs(&self) -> Result<(Vec<strata::Entry>, Listed)> { |
|
| 520 | + | fn reload_packs(&self) -> Result<()> { |
|
| 708 | 521 | let listed = self.backend.list("pack-").map_err(backend_err)?; |
|
| 709 | - | let sig = listed |
|
| 522 | + | let mut idx_keys: Vec<&str> = listed |
|
| 710 | 523 | .iter() |
|
| 711 | - | .map(|e| (e.key.clone(), e.size, e.modified_ms)) |
|
| 712 | - | .collect(); |
|
| 713 | - | Ok((listed, sig)) |
|
| 714 | - | } |
|
| 715 | - | ||
| 716 | - | /// Load every pack the listing names that is not retired. An index |
|
| 717 | - | /// that is gone by the time it is read (its pack deleted after the |
|
| 718 | - | /// listing) is passed over, and the store listed once more, since |
|
| 719 | - | /// what replaced it may be new since the first listing. |
|
| 720 | - | fn reload_packs(&self) -> Result<()> { |
|
| 721 | - | let (listed, sig) = self.list_packs()?; |
|
| 722 | - | self.load_listed(listed, sig) |
|
| 723 | - | } |
|
| 724 | - | ||
| 725 | - | fn load_listed(&self, mut listed: Vec<strata::Entry>, mut sig: Listed) -> Result<()> { |
|
| 726 | - | let had: std::collections::HashMap<String, Arc<PackIndex>> = self |
|
| 727 | - | .packs |
|
| 728 | - | .borrow() |
|
| 729 | - | .iter() |
|
| 730 | - | .map(|p| (p.key.clone(), p.clone())) |
|
| 524 | + | .map(|e| e.key.as_str()) |
|
| 525 | + | .filter(|k| k.ends_with(".idx")) |
|
| 731 | 526 | .collect(); |
|
| 732 | - | let mut loaded: std::collections::HashMap<String, Arc<PackIndex>> = Default::default(); |
|
| 733 | - | for attempt in 0..2 { |
|
| 734 | - | let retired = self.retired_in(&listed)?; |
|
| 735 | - | let mut idx_keys: Vec<&str> = listed |
|
| 736 | - | .iter() |
|
| 737 | - | .map(|e| e.key.as_str()) |
|
| 738 | - | .filter(|k| k.ends_with(".idx")) |
|
| 739 | - | .collect(); |
|
| 740 | - | idx_keys.sort(); |
|
| 741 | - | let mut packs = Vec::new(); |
|
| 742 | - | let mut vanished = false; |
|
| 743 | - | for idx in idx_keys { |
|
| 744 | - | let pack = format!("{}.pack", idx.trim_end_matches(".idx")); |
|
| 745 | - | if retired.contains_key(&pack) { |
|
| 746 | - | continue; |
|
| 747 | - | } |
|
| 748 | - | let Some(p) = listed.iter().find(|e| e.key == pack) else { |
|
| 749 | - | continue; |
|
| 750 | - | }; |
|
| 751 | - | if let Some(kept) = had.get(&pack).or_else(|| loaded.get(&pack)) { |
|
| 752 | - | packs.push(kept.clone()); |
|
| 753 | - | continue; |
|
| 754 | - | } |
|
| 755 | - | match PackIndex::load(self.backend.as_ref(), idx, &pack, p.size)? { |
|
| 756 | - | Some(index) => { |
|
| 757 | - | let index = Arc::new(index); |
|
| 758 | - | loaded.insert(pack, index.clone()); |
|
| 759 | - | packs.push(index); |
|
| 760 | - | } |
|
| 761 | - | None => vanished = true, |
|
| 762 | - | } |
|
| 763 | - | } |
|
| 764 | - | if vanished && attempt == 0 { |
|
| 765 | - | (listed, sig) = self.list_packs()?; |
|
| 766 | - | continue; |
|
| 767 | - | } |
|
| 768 | - | let lists: Vec<String> = listed |
|
| 769 | - | .iter() |
|
| 770 | - | .map(|e| e.key.as_str()) |
|
| 771 | - | .filter(|k| is_entry_list(k) && !retired.contains_key(*k)) |
|
| 772 | - | .map(str::to_string) |
|
| 773 | - | .collect(); |
|
| 774 | - | self.folded |
|
| 775 | - | .set(lists.iter().any(|k| k.ends_with(LIST_SUFFIX))); |
|
| 776 | - | self.legacy_folded |
|
| 777 | - | .set(listed.iter().any(|e| e.key == LEGACY_FOLDED_KEY)); |
|
| 778 | - | *self.entry_lists.borrow_mut() = lists; |
|
| 779 | - | if *self.packs_listed.borrow() != sig { |
|
| 780 | - | *self.entries.borrow_mut() = None; |
|
| 527 | + | idx_keys.sort(); |
|
| 528 | + | let mut packs = Vec::new(); |
|
| 529 | + | for idx in idx_keys { |
|
| 530 | + | let pack = format!("{}.pack", idx.trim_end_matches(".idx")); |
|
| 531 | + | if listed.iter().any(|e| e.key == pack) { |
|
| 532 | + | packs.push(PackIndex::load(self.backend.as_ref(), idx, &pack)?); |
|
| 781 | 533 | } |
|
| 782 | - | *self.packs.borrow_mut() = packs; |
|
| 783 | - | *self.packs_listed.borrow_mut() = sig; |
|
| 784 | - | return Ok(()); |
|
| 785 | - | } |
|
| 786 | - | unreachable!("the second round always returns") |
|
| 787 | - | } |
|
| 788 | - | ||
| 789 | - | /// Load the packs again when the pack listing differs from the one |
|
| 790 | - | /// they were loaded from; whether it did. What a miss calls before it |
|
| 791 | - | /// is believed (oxygenhub#249): another writer may have packed the |
|
| 792 | - | /// object, or a repack moved it, since this store loaded its packs. |
|
| 793 | - | fn reload_if_changed(&self) -> Result<bool> { |
|
| 794 | - | let (listed, sig) = self.list_packs()?; |
|
| 795 | - | if *self.packs_listed.borrow() == sig { |
|
| 796 | - | return Ok(false); |
|
| 797 | 534 | } |
|
| 798 | - | self.load_listed(listed, sig)?; |
|
| 799 | - | Ok(true) |
|
| 800 | - | } |
|
| 801 | - | ||
| 802 | - | /// The retired record, read when `listed` (a pack listing) shows it |
|
| 803 | - | /// holds anything. |
|
| 804 | - | fn retired_in( |
|
| 805 | - | &self, |
|
| 806 | - | listed: &[strata::Entry], |
|
| 807 | - | ) -> Result<std::collections::BTreeMap<String, u64>> { |
|
| 808 | - | match listed.iter().find(|e| e.key == RETIRED_KEY) { |
|
| 809 | - | Some(e) if e.size > 0 => Ok(self.read_retired()?.0), |
|
| 810 | - | _ => Ok(Default::default()), |
|
| 811 | - | } |
|
| 812 | - | } |
|
| 813 | - | ||
| 814 | - | /// The retired record and its version (`None`: never written). |
|
| 815 | - | fn read_retired( |
|
| 816 | - | &self, |
|
| 817 | - | ) -> Result<( |
|
| 818 | - | std::collections::BTreeMap<String, u64>, |
|
| 819 | - | Option<strata::Version>, |
|
| 820 | - | )> { |
|
| 821 | - | self.read_record(RETIRED_KEY) |
|
| 822 | - | } |
|
| 823 | - | ||
| 824 | - | /// A record of `<wall ms> <key>` lines (the retired record, the |
|
| 825 | - | /// journal) and its version (`None`: never written). |
|
| 826 | - | fn read_record( |
|
| 827 | - | &self, |
|
| 828 | - | key: &str, |
|
| 829 | - | ) -> Result<( |
|
| 830 | - | std::collections::BTreeMap<String, u64>, |
|
| 831 | - | Option<strata::Version>, |
|
| 832 | - | )> { |
|
| 833 | - | match self.backend.read_ref(key).map_err(backend_err)? { |
|
| 834 | - | Some((bytes, v)) => Ok((parse_retired(&bytes), Some(v))), |
|
| 835 | - | None => Ok((Default::default(), None)), |
|
| 836 | - | } |
|
| 837 | - | } |
|
| 838 | - | ||
| 839 | - | /// Change the retired record by compare-and-swap, reading it again |
|
| 840 | - | /// whenever another writer changed it first. `edit` must give the |
|
| 841 | - | /// same result however often it runs. |
|
| 842 | - | fn update_retired( |
|
| 843 | - | &self, |
|
| 844 | - | edit: impl FnMut(&mut std::collections::BTreeMap<String, u64>), |
|
| 845 | - | ) -> Result<()> { |
|
| 846 | - | self.update_record(RETIRED_KEY, "the record of retired packs", edit) |
|
| 847 | - | } |
|
| 848 | - | ||
| 849 | - | /// Change a record of `<wall ms> <key>` lines by compare-and-swap, as |
|
| 850 | - | /// [`Store::update_retired`] does; `what` names it in the error when |
|
| 851 | - | /// other writers kept winning. |
|
| 852 | - | fn update_record( |
|
| 853 | - | &self, |
|
| 854 | - | key: &str, |
|
| 855 | - | what: &str, |
|
| 856 | - | mut edit: impl FnMut(&mut std::collections::BTreeMap<String, u64>), |
|
| 857 | - | ) -> Result<()> { |
|
| 858 | - | for _ in 0..RETIRED_TRIES { |
|
| 859 | - | let (mut m, version) = self.read_record(key)?; |
|
| 860 | - | let before = m.clone(); |
|
| 861 | - | edit(&mut m); |
|
| 862 | - | // Unchanged; a record never written stays unwritten. |
|
| 863 | - | if m == before && (version.is_some() || m.is_empty()) { |
|
| 864 | - | return Ok(()); |
|
| 865 | - | } |
|
| 866 | - | match self |
|
| 867 | - | .backend |
|
| 868 | - | .write_ref(key, &encode_retired(&m), version.as_ref()) |
|
| 869 | - | { |
|
| 870 | - | Ok(_) => return Ok(()), |
|
| 871 | - | Err(BackendError::Conflict(_)) => continue, |
|
| 872 | - | Err(e) => return Err(backend_err(e)), |
|
| 873 | - | } |
|
| 874 | - | } |
|
| 875 | - | Err(StoreError::Io(io::Error::other(format!( |
|
| 876 | - | "other writers kept changing {what}; run the command again" |
|
| 877 | - | )))) |
|
| 878 | - | } |
|
| 879 | - | ||
| 880 | - | /// Record `keys` (packs, by their `.pack` key, and loose objects) as |
|
| 881 | - | /// retired now: kept for the readers still using them, and deleted by |
|
| 882 | - | /// a collection at least [`RETIRED_FOR`] later. A key already retired |
|
| 883 | - | /// keeps its first time. |
|
| 884 | - | fn retire(&self, keys: &[String]) -> Result<()> { |
|
| 885 | - | if keys.is_empty() { |
|
| 886 | - | return Ok(()); |
|
| 887 | - | } |
|
| 888 | - | let now = now_ms(); |
|
| 889 | - | self.update_retired(|m| { |
|
| 890 | - | for k in keys { |
|
| 891 | - | m.entry(k.clone()).or_insert(now); |
|
| 892 | - | } |
|
| 893 | - | }) |
|
| 894 | - | } |
|
| 895 | - | ||
| 896 | - | /// Delete what was retired at least `older_than` ago (a collection |
|
| 897 | - | /// does, with [`RETIRED_FOR`]): each pack with its index and |
|
| 898 | - | /// dictionary, and each loose object a pack now holds. A retired loose |
|
| 899 | - | /// object no pack holds any more (the pack that had it was pruned, and |
|
| 900 | - | /// the object stored again since) is kept, as an ordinary loose object. |
|
| 901 | - | pub fn expire_retired( |
|
| 902 | - | &self, |
|
| 903 | - | older_than: std::time::Duration, |
|
| 904 | - | progress: Progress, |
|
| 905 | - | ) -> Result<RetiredReport> { |
|
| 906 | - | let (listed, _) = self.list_packs()?; |
|
| 907 | - | let retired = self.retired_in(&listed)?; |
|
| 908 | - | let mut report = RetiredReport::default(); |
|
| 909 | - | if retired.is_empty() { |
|
| 910 | - | return Ok(report); |
|
| 911 | - | } |
|
| 912 | - | let now = now_ms(); |
|
| 913 | - | let older_than = older_than.as_millis() as u64; |
|
| 914 | - | let due: Vec<(String, u64)> = retired |
|
| 915 | - | .iter() |
|
| 916 | - | .filter(|(_, t)| now.saturating_sub(**t) >= older_than) |
|
| 917 | - | .map(|(k, t)| (k.clone(), *t)) |
|
| 918 | - | .collect(); |
|
| 919 | - | report.kept = retired.len() - due.len(); |
|
| 920 | - | if due.is_empty() { |
|
| 921 | - | return Ok(report); |
|
| 922 | - | } |
|
| 923 | - | progress(&format!("deleting {} retired files", due.len())); |
|
| 924 | - | // Which objects the live packs hold. |
|
| 925 | - | self.reload_packs()?; |
|
| 926 | - | for (key, _) in &due { |
|
| 927 | - | if key.ends_with(".pack") { |
|
| 928 | - | self.remove_pack(key)?; |
|
| 929 | - | report.packs += 1; |
|
| 930 | - | } else if is_entry_list(key) { |
|
| 931 | - | // An entry list a fold replaced (spec §13.7). |
|
| 932 | - | delete(self.backend.as_ref(), key)?; |
|
| 933 | - | } else if let Some(r) = loose_ref(key) { |
|
| 934 | - | if self.find_packed(&r).is_some() { |
|
| 935 | - | delete(self.backend.as_ref(), key)?; |
|
| 936 | - | report.loose += 1; |
|
| 937 | - | } |
|
| 938 | - | } |
|
| 939 | - | } |
|
| 940 | - | self.update_retired(|m| { |
|
| 941 | - | for (k, t) in &due { |
|
| 942 | - | if m.get(k) == Some(t) { |
|
| 943 | - | m.remove(k); |
|
| 944 | - | } |
|
| 945 | - | } |
|
| 946 | - | })?; |
|
| 947 | - | self.reload_packs()?; |
|
| 948 | - | Ok(report) |
|
| 949 | - | } |
|
| 950 | - | ||
| 951 | - | /// The loose objects retired and not yet deleted, by key. |
|
| 952 | - | fn retired_loose(&self) -> Result<std::collections::HashSet<String>> { |
|
| 953 | - | let (listed, _) = self.list_packs()?; |
|
| 954 | - | Ok(self |
|
| 955 | - | .retired_in(&listed)? |
|
| 956 | - | .into_keys() |
|
| 957 | - | .filter(|k| k.starts_with("loose/")) |
|
| 958 | - | .collect()) |
|
| 535 | + | *self.packs.borrow_mut() = packs; |
|
| 536 | + | Ok(()) |
|
| 959 | 537 | } |
|
| 960 | 538 | ||
| 961 | 539 | pub fn pack_count(&self) -> usize { |
|
| … | 8 unchanged lines |
||
| 970 | 548 | .find_map(|(i, p)| p.find(r).map(|e| (i, e))) |
|
| 971 | 549 | } |
|
| 972 | 550 | ||
| 973 | - | /// Loose objects, with their keys and ages; not those retired, whose |
|
| 974 | - | /// objects a pack holds. |
|
| 551 | + | /// Loose objects, with their keys and ages. |
|
| 975 | 552 | fn loose_objects(&self) -> Result<Vec<(ObjectRef, strata::Entry)>> { |
|
| 976 | - | let retired = self.retired_loose()?; |
|
| 977 | - | let mut out = self.loose_objects_all()?; |
|
| 978 | - | out.retain(|(_, e)| !retired.contains(&e.key)); |
|
| 979 | - | Ok(out) |
|
| 980 | - | } |
|
| 981 | - | ||
| 982 | - | /// Every loose object, retired or not. |
|
| 983 | - | fn loose_objects_all(&self) -> Result<Vec<(ObjectRef, strata::Entry)>> { |
|
| 984 | 553 | let mut out: Vec<(ObjectRef, strata::Entry)> = self |
|
| 985 | 554 | .backend |
|
| 986 | 555 | .list("loose/") |
|
| … | 34 unchanged lines |
||
| 1021 | 590 | /// [`Store::repack_all`], reporting progress. |
|
| 1022 | 591 | pub fn repack_all_with(&self, level: i32, progress: Progress) -> Result<(PackReport, usize)> { |
|
| 1023 | 592 | let loose = self.loose_objects()?; |
|
| 1024 | - | let (mut refs, keys, trained): (Vec<ObjectRef>, Vec<String>, bool) = { |
|
| 593 | + | let (mut refs, keys): (Vec<ObjectRef>, Vec<String>) = { |
|
| 1025 | 594 | let packs = self.packs.borrow(); |
|
| 1026 | 595 | ( |
|
| 1027 | 596 | packs.iter().flat_map(|p| p.refs.iter().copied()).collect(), |
|
| 1028 | 597 | packs.iter().map(|p| p.key.clone()).collect(), |
|
| 1029 | - | packs.first().is_some_and(|p| p.dict.is_some()), |
|
| 1030 | 598 | ) |
|
| 1031 | 599 | }; |
|
| 1032 | - | // Nothing loose and one pack already made by a full repack (only |
|
| 1033 | - | // such a pack has a trained dictionary): rewriting it would give |
|
| 1034 | - | // the same pack. A hub's nightly gc rewrote andymccall/oxygen's |
|
| 1035 | - | // 48 000 objects (37 s under its write lock) every time. |
|
| 1036 | - | if loose.is_empty() && keys.len() <= 1 && (level == ZSTD_LEVEL || trained) { |
|
| 600 | + | if loose.is_empty() && keys.len() <= 1 && level == ZSTD_LEVEL { |
|
| 1037 | 601 | return Ok((PackReport::default(), 0)); |
|
| 1038 | 602 | } |
|
| 1039 | 603 | refs.extend(loose.iter().map(|(r, _)| *r)); |
|
| … | 48 unchanged lines |
||
| 1088 | 652 | let old_enough = |
|
| 1089 | 653 | |modified: Option<u64>| modified.is_none_or(|t| now.saturating_sub(t) >= grace_ms); |
|
| 1090 | 654 | let mut report = PruneReport::default(); |
|
| 1091 | - | let mut gone: Vec<String> = Vec::new(); |
|
| 1092 | - | // Loose objects. A retired one (its object packed) whose object is |
|
| 1093 | - | // unreachable goes with it, uncounted: the pack's copy is counted. |
|
| 1094 | - | let retired = self.retired_loose()?; |
|
| 1095 | - | let mut unretired: Vec<String> = Vec::new(); |
|
| 1096 | - | for (r, entry) in self.loose_objects_all()? { |
|
| 655 | + | // Loose objects. |
|
| 656 | + | for (r, entry) in self.loose_objects()? { |
|
| 1097 | 657 | if keep.contains(&r) { |
|
| 1098 | - | continue; |
|
| 1099 | - | } |
|
| 1100 | - | if retired.contains(&entry.key) { |
|
| 1101 | - | if old_enough(entry.modified_ms) { |
|
| 1102 | - | delete(self.backend.as_ref(), &entry.key)?; |
|
| 1103 | - | unretired.push(entry.key); |
|
| 1104 | - | } |
|
| 1105 | 658 | continue; |
|
| 1106 | 659 | } |
|
| 1107 | 660 | if !old_enough(entry.modified_ms) { |
|
| … | 3 unchanged lines |
||
| 1111 | 664 | report.bytes += entry.size; |
|
| 1112 | 665 | report.objects += 1; |
|
| 1113 | 666 | delete(self.backend.as_ref(), &entry.key)?; |
|
| 1114 | - | self.remove_sidecars(&r, &mut gone)?; |
|
| 1115 | - | } |
|
| 1116 | - | if !unretired.is_empty() { |
|
| 1117 | - | self.update_retired(|m| { |
|
| 1118 | - | for k in &unretired { |
|
| 1119 | - | m.remove(k); |
|
| 1120 | - | } |
|
| 1121 | - | })?; |
|
| 667 | + | self.remove_sidecars(&r)?; |
|
| 1122 | 668 | } |
|
| 1123 | 669 | // Packs: rewrite those that hold unreachable objects. |
|
| 1124 | 670 | let mut keep_refs: Vec<ObjectRef> = Vec::new(); |
|
| … | 37 unchanged lines |
||
| 1162 | 708 | }; |
|
| 1163 | 709 | self.write_pack(&keep_refs, &[], &old_keys, ZSTD_LEVEL, progress)?; |
|
| 1164 | 710 | for r in dropped { |
|
| 1165 | - | self.remove_sidecars(&r, &mut gone)?; |
|
| 711 | + | self.remove_sidecars(&r)?; |
|
| 1166 | 712 | } |
|
| 1167 | 713 | } |
|
| 1168 | - | self.journal_forget(&gone)?; |
|
| 1169 | 714 | Ok(report) |
|
| 1170 | 715 | } |
|
| 1171 | 716 | ||
| 1172 | 717 | /// Everything kept beside an object: its signatures and the seal and |
|
| 1173 | 718 | /// receipt entries under it, in both layouts. |
|
| 1174 | - | /// The entry keys deleted are added to `gone`, for the journal. |
|
| 1175 | - | fn remove_sidecars(&self, r: &ObjectRef, gone: &mut Vec<String>) -> Result<()> { |
|
| 719 | + | fn remove_sidecars(&self, r: &ObjectRef) -> Result<()> { |
|
| 1176 | 720 | for index in ["sigs", "seals", "receipts"] { |
|
| 1177 | 721 | delete(self.backend.as_ref(), &legacy_key(index, r))?; |
|
| 1178 | 722 | } |
|
| … | 4 unchanged lines |
||
| 1183 | 727 | .map_err(backend_err)? |
|
| 1184 | 728 | { |
|
| 1185 | 729 | delete(self.backend.as_ref(), &e.key)?; |
|
| 1186 | - | gone.push(e.key); |
|
| 1187 | 730 | } |
|
| 1188 | 731 | } |
|
| 1189 | 732 | Ok(()) |
|
| 1190 | 733 | } |
|
| 1191 | 734 | ||
| 1192 | - | /// Take entry keys a collection deleted out of the journal, so the |
|
| 1193 | - | /// next complete fold leaves them out (spec §13.7). |
|
| 1194 | - | fn journal_forget(&self, gone: &[String]) -> Result<()> { |
|
| 1195 | - | if self.local || gone.is_empty() { |
|
| 1196 | - | return Ok(()); |
|
| 1197 | - | } |
|
| 1198 | - | if let Some(set) = self.entries.borrow_mut().as_mut() { |
|
| 1199 | - | for k in gone { |
|
| 1200 | - | set.remove(k); |
|
| 1201 | - | } |
|
| 1202 | - | } |
|
| 1203 | - | self.update_record(JOURNAL_KEY, "the journal of entries", |m| { |
|
| 1204 | - | for k in gone { |
|
| 1205 | - | m.remove(k); |
|
| 1206 | - | } |
|
| 1207 | - | }) |
|
| 1208 | - | } |
|
| 1209 | - | ||
| 1210 | 735 | /// Put a complete, synced temporary pack file and its index into the |
|
| 1211 | 736 | /// store; `index` must be sorted by ObjectRef. The pack goes first: an |
|
| 1212 | 737 | /// index never names a pack that is not there. |
|
| … | 27 unchanged lines |
||
| 1240 | 765 | self.reload_packs() |
|
| 1241 | 766 | } |
|
| 1242 | 767 | ||
| 1243 | - | /// Remove a pack with its index and dictionary: the index first, so |
|
| 1244 | - | /// a store opened meanwhile does not load a pack that is going. |
|
| 768 | + | /// Remove a pack with its index and dictionary. |
|
| 1245 | 769 | fn remove_pack(&self, pack_key: &str) -> Result<()> { |
|
| 1246 | 770 | let stem = pack_key.trim_end_matches(".pack"); |
|
| 1247 | - | delete(self.backend.as_ref(), &format!("{stem}.idx"))?; |
|
| 1248 | 771 | delete(self.backend.as_ref(), pack_key)?; |
|
| 772 | + | delete(self.backend.as_ref(), &format!("{stem}.idx"))?; |
|
| 1249 | 773 | delete(self.backend.as_ref(), &format!("{stem}.dict")) |
|
| 1250 | 774 | } |
|
| 1251 | 775 | ||
| 1252 | - | /// Write `refs` into a new pack, then retire `loose_keys` and |
|
| 1253 | - | /// `old_packs`: they stay in the store, unused by stores opened from |
|
| 1254 | - | /// now on, until a collection [`RETIRED_FOR`] later deletes them, so a |
|
| 1255 | - | /// reader that loaded its packs before goes on reading (oxygenhub#249). |
|
| 776 | + | /// Write `refs` into a new pack, then delete `loose_keys` and `old_packs`. |
|
| 1256 | 777 | fn write_pack( |
|
| 1257 | 778 | &self, |
|
| 1258 | 779 | refs: &[ObjectRef], |
|
| … | 3 unchanged lines |
||
| 1262 | 783 | progress: Progress, |
|
| 1263 | 784 | ) -> Result<PackReport> { |
|
| 1264 | 785 | if refs.is_empty() { |
|
| 1265 | - | self.retire(old_packs)?; |
|
| 786 | + | for p in old_packs { |
|
| 787 | + | self.remove_pack(p)?; |
|
| 788 | + | } |
|
| 1266 | 789 | self.reload_packs()?; |
|
| 1267 | 790 | return Ok(PackReport::default()); |
|
| 1268 | 791 | } |
|
| … | 115 unchanged lines |
||
| 1384 | 907 | } |
|
| 1385 | 908 | progress(&format!("writing the index of {} objects", index.len())); |
|
| 1386 | 909 | self.finish_pack_files(&id, &tmp_pack, &index)?; |
|
| 1387 | - | let mut retired = old_packs.to_vec(); |
|
| 1388 | - | retired.extend_from_slice(loose_keys); |
|
| 1389 | - | self.retire(&retired)?; |
|
| 910 | + | for p in old_packs { |
|
| 911 | + | self.remove_pack(p)?; |
|
| 912 | + | } |
|
| 1390 | 913 | self.reload_packs()?; |
|
| 914 | + | for key in loose_keys { |
|
| 915 | + | delete(self.backend.as_ref(), key)?; |
|
| 916 | + | } |
|
| 1391 | 917 | Ok(report) |
|
| 1392 | 918 | } |
|
| 1393 | 919 | ||
| … | 42 unchanged lines |
||
| 1436 | 962 | delete(self.backend.as_ref(), &loose_key(r)) |
|
| 1437 | 963 | } |
|
| 1438 | 964 | ||
| 1439 | - | /// Whether the store holds `r`. A miss looks at the pack listing |
|
| 1440 | - | /// again before it is believed, as [`Store::get`]'s does; a writer |
|
| 1441 | - | /// asking about many objects it is about to store looks at most once |
|
| 1442 | - | /// in [`HAS_RELIST_AFTER`] while nothing new turns up. |
|
| 1443 | 965 | pub fn has(&self, r: &ObjectRef) -> bool { |
|
| 1444 | - | if self.has_now(r) { |
|
| 1445 | - | return true; |
|
| 1446 | - | } |
|
| 1447 | - | let recent = self |
|
| 1448 | - | .has_relisted |
|
| 1449 | - | .get() |
|
| 1450 | - | .is_some_and(|t| t.elapsed() < HAS_RELIST_AFTER); |
|
| 1451 | - | if recent { |
|
| 1452 | - | return false; |
|
| 1453 | - | } |
|
| 1454 | - | match self.reload_if_changed() { |
|
| 1455 | - | Ok(true) => { |
|
| 1456 | - | self.has_relisted.set(None); |
|
| 1457 | - | self.has_now(r) |
|
| 1458 | - | } |
|
| 1459 | - | _ => { |
|
| 1460 | - | self.has_relisted.set(Some(std::time::Instant::now())); |
|
| 1461 | - | false |
|
| 1462 | - | } |
|
| 1463 | - | } |
|
| 1464 | - | } |
|
| 1465 | - | ||
| 1466 | - | /// [`Store::has`] against the packs as loaded. |
|
| 1467 | - | fn has_now(&self, r: &ObjectRef) -> bool { |
|
| 1468 | - | self.find_packed(r).is_some() |
|
| 966 | + | self.backend.has(&loose_key(r)).unwrap_or(false) |
|
| 967 | + | || self.find_packed(r).is_some() |
|
| 1469 | 968 | || self |
|
| 1470 | 969 | .pending |
|
| 1471 | 970 | .borrow() |
|
| 1472 | 971 | .as_ref() |
|
| 1473 | 972 | .is_some_and(|p| p.lookup.contains_key(r)) |
|
| 1474 | - | || self.backend.has(&loose_key(r)).unwrap_or(false) |
|
| 1475 | 973 | } |
|
| 1476 | 974 | ||
| 1477 | 975 | /// Start a write batch: until the matching `finish`, `put` collects |
|
| … | 28 unchanged lines |
||
| 1506 | 1004 | return Ok(()); |
|
| 1507 | 1005 | } |
|
| 1508 | 1006 | let Some(pending) = self.pending.borrow_mut().take() else { |
|
| 1509 | - | return self.flush_journal(); |
|
| 1007 | + | return Ok(()); |
|
| 1510 | 1008 | }; |
|
| 1511 | - | self.write_pending(pending)?; |
|
| 1512 | - | self.flush_journal() |
|
| 1009 | + | self.write_pending(pending) |
|
| 1513 | 1010 | } |
|
| 1514 | 1011 | ||
| 1515 | 1012 | /// Make every object written so far durable, even inside an open |
|
| … | 1 unchanged lines |
||
| 1517 | 1014 | /// is moved (spec §13.6): the operation head, a bookmark pointer. |
|
| 1518 | 1015 | pub fn flush(&self) -> Result<()> { |
|
| 1519 | 1016 | let Some(pending) = self.pending.borrow_mut().take() else { |
|
| 1520 | - | return self.flush_journal(); |
|
| 1017 | + | return Ok(()); |
|
| 1521 | 1018 | }; |
|
| 1522 | 1019 | self.write_pending(pending)?; |
|
| 1523 | - | self.flush_journal()?; |
|
| 1524 | 1020 | *self.pending.borrow_mut() = Some(Pending { |
|
| 1525 | 1021 | buffered: vec![], |
|
| 1526 | 1022 | buffered_bytes: 0, |
|
| … | 259 unchanged lines |
||
| 1786 | 1282 | self.backend.put_many_new(&items).map_err(backend_err) |
|
| 1787 | 1283 | } |
|
| 1788 | 1284 | ||
| 1789 | - | /// Read and verify an object, packed, loose, or pending in a batch. |
|
| 1790 | - | /// The packs' indexes are in memory, so they are asked first: asking |
|
| 1791 | - | /// for a loose copy of a packed object costs a request that finds |
|
| 1792 | - | /// nothing on a store in a bucket, every time (oxygenhub#228). |
|
| 1793 | - | /// |
|
| 1794 | - | /// A miss is not believed at once (oxygenhub#249): when the pack it |
|
| 1795 | - | /// was in is gone (deleted after a repack retired it) the packs are |
|
| 1796 | - | /// loaded again, and when the object is nowhere the pack listing is |
|
| 1797 | - | /// looked at, and the packs loaded again if it changed (another |
|
| 1798 | - | /// writer packed it, or a repack moved it); then it is read once more. |
|
| 1285 | + | /// Read and verify an object, loose, packed, or pending in a batch. |
|
| 1799 | 1286 | pub fn get(&self, r: &ObjectRef) -> Result<Object> { |
|
| 1800 | - | match self.get_now(r) { |
|
| 1801 | - | Err(e) if is_miss(&e) => { |
|
| 1802 | - | // Read from a pack this store loaded, which is gone. |
|
| 1803 | - | let packed_miss = |
|
| 1804 | - | !matches!(e, StoreError::NotFound(_)) || self.find_packed(r).is_some(); |
|
| 1805 | - | let changed = if packed_miss { |
|
| 1806 | - | self.reload_packs()?; |
|
| 1807 | - | true |
|
| 1808 | - | } else { |
|
| 1809 | - | self.reload_if_changed()? |
|
| 1810 | - | }; |
|
| 1811 | - | if changed { |
|
| 1812 | - | self.get_now(r) |
|
| 1813 | - | } else { |
|
| 1814 | - | Err(e) |
|
| 1815 | - | } |
|
| 1816 | - | } |
|
| 1817 | - | other => other, |
|
| 1818 | - | } |
|
| 1819 | - | } |
|
| 1820 | - | ||
| 1821 | - | /// [`Store::get`] against the packs as loaded. |
|
| 1822 | - | fn get_now(&self, r: &ObjectRef) -> Result<Object> { |
|
| 1823 | - | if let Some((i, e)) = self.find_packed(r) { |
|
| 1824 | - | let pack = self.packs.borrow()[i].clone(); |
|
| 1825 | - | return pack.fetch(self.backend.as_ref(), r, e); |
|
| 1826 | - | } |
|
| 1827 | 1287 | match get_optional(self.backend.as_ref(), &loose_key(r))? { |
|
| 1828 | 1288 | Some(bytes) => parse_loose(&bytes, r), |
|
| 1829 | - | None => self.pending_get(r).unwrap_or(Err(StoreError::NotFound(*r))), |
|
| 1289 | + | None => match self.find_packed(r) { |
|
| 1290 | + | Some((i, e)) => self.packs.borrow()[i].fetch(self.backend.as_ref(), r, e), |
|
| 1291 | + | None => self.pending_get(r).unwrap_or(Err(StoreError::NotFound(*r))), |
|
| 1292 | + | }, |
|
| 1830 | 1293 | } |
|
| 1831 | 1294 | } |
|
| 1832 | 1295 | ||
| 1833 | - | /// Many objects at once, read and verified in parallel: packed ones |
|
| 1834 | - | /// from their packs, the others loose (as [`Store::get`], packs first), |
|
| 1835 | - | /// then what is pending in a batch. Results are in the order of `refs`. |
|
| 1836 | - | /// |
|
| 1837 | - | /// Misses are looked for again as [`Store::get`]'s are, together. |
|
| 1296 | + | /// Many objects at once: loose ones are read and verified in parallel, |
|
| 1297 | + | /// then packed ones in parallel, then what is pending in a batch. |
|
| 1298 | + | /// Results are in the order of `refs`. |
|
| 1838 | 1299 | pub fn get_many(&self, refs: &[ObjectRef]) -> Vec<Result<Object>> { |
|
| 1839 | - | let mut out = self.get_many_now(refs); |
|
| 1840 | - | let missed: Vec<usize> = out |
|
| 1841 | - | .iter() |
|
| 1842 | - | .enumerate() |
|
| 1843 | - | .filter(|(_, g)| g.as_ref().is_err_and(is_miss)) |
|
| 1844 | - | .map(|(i, _)| i) |
|
| 1845 | - | .collect(); |
|
| 1846 | - | if missed.is_empty() { |
|
| 1847 | - | return out; |
|
| 1848 | - | } |
|
| 1849 | - | let packed_miss = missed.iter().any(|i| { |
|
| 1850 | - | !matches!(out[*i], Err(StoreError::NotFound(_))) |
|
| 1851 | - | || self.find_packed(&refs[*i]).is_some() |
|
| 1852 | - | }); |
|
| 1853 | - | let changed = if packed_miss { |
|
| 1854 | - | self.reload_packs().map(|_| true) |
|
| 1855 | - | } else { |
|
| 1856 | - | self.reload_if_changed() |
|
| 1857 | - | }; |
|
| 1858 | - | match changed { |
|
| 1859 | - | Ok(true) => { |
|
| 1860 | - | let again: Vec<ObjectRef> = missed.iter().map(|i| refs[*i]).collect(); |
|
| 1861 | - | for (i, g) in missed.into_iter().zip(self.get_many_now(&again)) { |
|
| 1862 | - | out[i] = g; |
|
| 1863 | - | } |
|
| 1864 | - | } |
|
| 1865 | - | Ok(false) => {} |
|
| 1866 | - | Err(e) => { |
|
| 1867 | - | let why = e.to_string(); |
|
| 1868 | - | for i in missed { |
|
| 1869 | - | out[i] = Err(StoreError::Io(io::Error::other(why.clone()))); |
|
| 1870 | - | } |
|
| 1871 | - | } |
|
| 1872 | - | } |
|
| 1873 | - | out |
|
| 1874 | - | } |
|
| 1875 | - | ||
| 1876 | - | /// [`Store::get_many`] against the packs as loaded. |
|
| 1877 | - | fn get_many_now(&self, refs: &[ObjectRef]) -> Vec<Result<Object>> { |
|
| 1878 | 1300 | use rayon::prelude::*; |
|
| 1879 | 1301 | let backend = self.backend.as_ref(); |
|
| 1302 | + | let mut found: Vec<Option<Result<Object>>> = refs |
|
| 1303 | + | .par_iter() |
|
| 1304 | + | .map(|r| match get_optional(backend, &loose_key(r)) { |
|
| 1305 | + | Ok(Some(bytes)) => Some(parse_loose(&bytes, r)), |
|
| 1306 | + | Ok(None) => None, |
|
| 1307 | + | Err(e) => Some(Err(e)), |
|
| 1308 | + | }) |
|
| 1309 | + | .collect(); |
|
| 1310 | + | // Packed ones: look them up in order, then read them in parallel. |
|
| 1880 | 1311 | let packs = self.packs.borrow(); |
|
| 1881 | - | let place: Vec<Option<(usize, PackEntry)>> = refs |
|
| 1312 | + | let packed: Vec<(usize, usize, PackEntry)> = found |
|
| 1882 | 1313 | .iter() |
|
| 1883 | - | .map(|r| { |
|
| 1314 | + | .enumerate() |
|
| 1315 | + | .filter(|(_, f)| f.is_none()) |
|
| 1316 | + | .filter_map(|(i, _)| { |
|
| 1884 | 1317 | packs |
|
| 1885 | 1318 | .iter() |
|
| 1886 | 1319 | .enumerate() |
|
| 1887 | - | .find_map(|(p, pack)| pack.find(r).map(|e| (p, e))) |
|
| 1320 | + | .find_map(|(p, pack)| pack.find(&refs[i]).map(|e| (i, p, e))) |
|
| 1888 | 1321 | }) |
|
| 1889 | 1322 | .collect(); |
|
| 1890 | - | let indexes: &[Arc<PackIndex>] = &packs; |
|
| 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 |
|
| 1323 | + | let indexes: &[PackIndex] = &packs; |
|
| 1324 | + | let read: Vec<(usize, Result<Object>)> = packed |
|
| 1896 | 1325 | .par_iter() |
|
| 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) |
|
| 1908 | - | }) |
|
| 1326 | + | .map(|(i, p, e)| (*i, indexes[*p].fetch(backend, &refs[*i], *e))) |
|
| 1909 | 1327 | .collect(); |
|
| 1910 | 1328 | 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; |
|
| 1329 | + | for (i, r) in read { |
|
| 1330 | + | found[i] = Some(r); |
|
| 1914 | 1331 | } |
|
| 1915 | 1332 | found |
|
| 1916 | 1333 | .into_iter() |
|
| 1917 | 1334 | .zip(refs) |
|
| 1918 | - | .map(|(f, r)| { |
|
| 1919 | - | f.unwrap_or_else(|| self.pending_get(r).unwrap_or(Err(StoreError::NotFound(*r)))) |
|
| 1335 | + | .map(|(f, r)| match f { |
|
| 1336 | + | Some(f) => f, |
|
| 1337 | + | None => self.get(r), |
|
| 1920 | 1338 | }) |
|
| 1921 | 1339 | .collect() |
|
| 1922 | 1340 | } |
|
| … | 244 unchanged lines |
||
| 2167 | 1585 | /// Record `value` under `key` in `index`: one write-once entry, so two |
|
| 2168 | 1586 | /// writers of a shared store never lose each other's. |
|
| 2169 | 1587 | fn index_add(&self, index: &str, key: &ObjectRef, value: &ObjectRef) -> Result<()> { |
|
| 2170 | - | debug_assert!(ENTRY_INDEXES.iter().any(|(i, _)| *i == index)); |
|
| 2171 | 1588 | let entry = format!("{}{}", fanout_prefix(index, key), value.to_hex()); |
|
| 2172 | - | put_new_or_existing(self.backend.as_ref(), &entry, b"")?; |
|
| 2173 | - | self.journal_add(entry) |
|
| 1589 | + | put_new_or_existing(self.backend.as_ref(), &entry, b"") |
|
| 2174 | 1590 | } |
|
| 2175 | 1591 | ||
| 2176 | 1592 | /// The values recorded under `key` in `index`, with those an older |
|
| 2177 | 1593 | /// store kept as one list in `legacy`, sorted. |
|
| 2178 | 1594 | fn index_get(&self, index: &str, legacy: &str, key: &ObjectRef) -> Result<Vec<ObjectRef>> { |
|
| 2179 | 1595 | let mut values: Vec<ObjectRef> = self |
|
| 2180 | - | .entry_keys(&fanout_prefix(index, key))? |
|
| 1596 | + | .backend |
|
| 1597 | + | .list(&fanout_prefix(index, key)) |
|
| 1598 | + | .map_err(backend_err)? |
|
| 2181 | 1599 | .into_iter() |
|
| 2182 | - | .filter_map(|k| k.rsplit('/').next().and_then(ObjectRef::from_hex)) |
|
| 1600 | + | .filter_map(|e| e.key.rsplit('/').next().and_then(ObjectRef::from_hex)) |
|
| 2183 | 1601 | .collect(); |
|
| 2184 | - | if !self.reads_legacy() { |
|
| 2185 | - | // Older lists folded into entries (spec §13.7). |
|
| 2186 | - | } else if let Some(bytes) = get_optional(self.backend.as_ref(), &legacy_key(legacy, key))? { |
|
| 1602 | + | if let Some(bytes) = get_optional(self.backend.as_ref(), &legacy_key(legacy, key))? { |
|
| 2187 | 1603 | values.extend( |
|
| 2188 | 1604 | String::from_utf8_lossy(&bytes) |
|
| 2189 | 1605 | .lines() |
|
| … | 16 unchanged lines |
||
| 2206 | 1622 | } |
|
| 2207 | 1623 | ||
| 2208 | 1624 | /// Record receipt attestation `attest` for `state` (spec §14.6). |
|
| 2209 | - | /// Keep a short-lived key's certificate (spec §14.7), with its issuer's |
|
| 2210 | - | /// signature, indexed by the key it certifies. |
|
| 2211 | - | pub fn put_certificate(&self, c: &crate::cert::SignedCertificate) -> Result<ObjectRef> { |
|
| 2212 | - | let r = self.put(ObjectType::Certificate, &c.cert.encode())?; |
|
| 2213 | - | self.put_signature(&r, &c.sig)?; |
|
| 2214 | - | self.index_add("cert", &c.cert.key_id(), &r)?; |
|
| 2215 | - | Ok(r) |
|
| 2216 | - | } |
|
| 2217 | - | ||
| 2218 | - | /// A kept certificate with its issuer's signature. |
|
| 2219 | - | pub fn get_certificate(&self, r: &ObjectRef) -> Result<crate::cert::SignedCertificate> { |
|
| 2220 | - | let cert = crate::cert::Certificate::decode(&self.get_typed(r, ObjectType::Certificate)?)?; |
|
| 2221 | - | let sig = self |
|
| 2222 | - | .signatures(r)? |
|
| 2223 | - | .into_iter() |
|
| 2224 | - | .find(|s| s.key == cert.issuer) |
|
| 2225 | - | .ok_or(StoreError::NotFound(*r))?; |
|
| 2226 | - | Ok(crate::cert::SignedCertificate { cert, sig }) |
|
| 2227 | - | } |
|
| 2228 | - | ||
| 2229 | - | /// The certificate kept for short-lived key `key`, if any. |
|
| 2230 | - | pub fn certificate_of(&self, key: &ObjectRef) -> Result<Option<ObjectRef>> { |
|
| 2231 | - | Ok(self |
|
| 2232 | - | .index_get("cert", "certs", key)? |
|
| 2233 | - | .into_iter() |
|
| 2234 | - | .find(|r| self.has(r))) |
|
| 2235 | - | } |
|
| 2236 | - | ||
| 2237 | - | /// Record `value` under `key` in index `index` (for repository code). |
|
| 2238 | - | pub(crate) fn index_add_pub( |
|
| 2239 | - | &self, |
|
| 2240 | - | index: &str, |
|
| 2241 | - | key: &ObjectRef, |
|
| 2242 | - | value: &ObjectRef, |
|
| 2243 | - | ) -> Result<()> { |
|
| 2244 | - | self.index_add(index, key, value) |
|
| 2245 | - | } |
|
| 2246 | - | ||
| 2247 | - | /// The values recorded under `key` in index `index` (which has no |
|
| 2248 | - | /// older one-list form: the legacy name only has to differ from it). |
|
| 2249 | - | pub(crate) fn index_get_pub(&self, index: &str, key: &ObjectRef) -> Result<Vec<ObjectRef>> { |
|
| 2250 | - | self.index_get(index, &format!("{index}-lists"), key) |
|
| 2251 | - | } |
|
| 2252 | - | ||
| 2253 | 1625 | pub fn index_receipt(&self, state: &ObjectRef, attest: &ObjectRef) -> Result<()> { |
|
| 2254 | 1626 | self.index_add("receipt", state, attest) |
|
| 2255 | 1627 | } |
|
| … | 1 unchanged lines |
||
| 2257 | 1629 | /// Receipt attestations recorded for `state`. |
|
| 2258 | 1630 | pub fn receipts_for(&self, state: &ObjectRef) -> Result<Vec<ObjectRef>> { |
|
| 2259 | 1631 | self.index_get("receipt", "receipts", state) |
|
| 2260 | - | } |
|
| 2261 | - | ||
| 2262 | - | pub fn index_approval(&self, state: &ObjectRef, attest: &ObjectRef) -> Result<()> { |
|
| 2263 | - | self.index_add("approval", state, attest) |
|
| 2264 | - | } |
|
| 2265 | - | ||
| 2266 | - | /// Approval attestations recorded for `state` (OxygenFlow approval |
|
| 2267 | - | /// gates, oxygenhub#235). |
|
| 2268 | - | pub fn approvals_for(&self, state: &ObjectRef) -> Result<Vec<ObjectRef>> { |
|
| 2269 | - | self.index_get("approval", "approvals", state) |
|
| 2270 | - | } |
|
| 2271 | - | ||
| 2272 | - | /// Every state with an approval, for garbage collection. |
|
| 2273 | - | pub fn approval_states(&self) -> Result<Vec<ObjectRef>> { |
|
| 2274 | - | self.indexed_states("approval", "approvals") |
|
| 2275 | 1632 | } |
|
| 2276 | 1633 | ||
| 2277 | 1634 | /// Every state with a receipt, for garbage collection. |
|
| 2278 | 1635 | pub fn receipt_states(&self) -> Result<Vec<ObjectRef>> { |
|
| 2279 | - | self.indexed_states("receipt", "receipts") |
|
| 2280 | - | } |
|
| 2281 | - | ||
| 2282 | - | /// The states an index (`one/<hh>/<rest>/<attest>` entries, and the |
|
| 2283 | - | /// packed `many/<hh>/<rest>` lists) holds attestations for. |
|
| 2284 | - | fn indexed_states(&self, one: &str, many: &str) -> Result<Vec<ObjectRef>> { |
|
| 2285 | 1636 | let mut out: Vec<ObjectRef> = Vec::new(); |
|
| 2286 | - | for key in self.entry_keys(&format!("{one}/"))? { |
|
| 1637 | + | for e in self.backend.list("receipt/").map_err(backend_err)? { |
|
| 2287 | 1638 | // receipt/<hh>/<rest>/<attest> |
|
| 2288 | - | let mut parts = key.splitn(4, '/'); |
|
| 1639 | + | let mut parts = e.key.splitn(4, '/'); |
|
| 2289 | 1640 | let (Some(_), Some(hh), Some(rest), Some(_)) = |
|
| 2290 | 1641 | (parts.next(), parts.next(), parts.next(), parts.next()) |
|
| 2291 | 1642 | else { |
|
| … | 1 unchanged lines |
||
| 2293 | 1644 | }; |
|
| 2294 | 1645 | out.extend(ObjectRef::from_hex(&format!("{hh}{rest}"))); |
|
| 2295 | 1646 | } |
|
| 2296 | - | let legacy = if self.reads_legacy() { |
|
| 2297 | - | self.backend |
|
| 2298 | - | .list(&format!("{many}/")) |
|
| 2299 | - | .map_err(backend_err)? |
|
| 2300 | - | } else { |
|
| 2301 | - | Vec::new() |
|
| 2302 | - | }; |
|
| 2303 | - | for e in legacy { |
|
| 1647 | + | for e in self.backend.list("receipts/").map_err(backend_err)? { |
|
| 2304 | 1648 | let mut parts = e.key.splitn(3, '/'); |
|
| 2305 | 1649 | let (Some(_), Some(hh), Some(rest)) = (parts.next(), parts.next(), parts.next()) else { |
|
| 2306 | 1650 | continue; |
|
| … | 17 unchanged lines |
||
| 2324 | 1668 | /// key, sorted by key. Empty if none. |
|
| 2325 | 1669 | pub fn signatures(&self, r: &ObjectRef) -> Result<Vec<Signature>> { |
|
| 2326 | 1670 | let mut sigs: Vec<Signature> = Vec::new(); |
|
| 2327 | - | for key in self.entry_keys(&fanout_prefix("sig", r))? { |
|
| 2328 | - | // Gone since it was listed or journalled: a collection deleted |
|
| 2329 | - | // it with its object, or a new signature by the same signer is |
|
| 2330 | - | // replacing it. |
|
| 2331 | - | let Some(bytes) = get_optional(self.backend.as_ref(), &key)? else { |
|
| 2332 | - | continue; |
|
| 2333 | - | }; |
|
| 1671 | + | for e in self |
|
| 1672 | + | .backend |
|
| 1673 | + | .list(&fanout_prefix("sig", r)) |
|
| 1674 | + | .map_err(backend_err)? |
|
| 1675 | + | { |
|
| 1676 | + | let bytes = self.backend.get(&e.key).map_err(backend_err)?; |
|
| 2334 | 1677 | sigs.extend(Signature::decode_list(&bytes)?); |
|
| 2335 | 1678 | } |
|
| 2336 | 1679 | // An older store's one list per object; a signer kept there and |
|
| 2337 | 1680 | // here counts once, as written here. |
|
| 2338 | - | let legacy = if self.reads_legacy() { |
|
| 2339 | - | get_optional(self.backend.as_ref(), &legacy_key("sigs", r))? |
|
| 2340 | - | } else { |
|
| 2341 | - | None |
|
| 2342 | - | }; |
|
| 2343 | - | if let Some(bytes) = legacy { |
|
| 1681 | + | if let Some(bytes) = get_optional(self.backend.as_ref(), &legacy_key("sigs", r))? { |
|
| 2344 | 1682 | for sig in Signature::decode_list(&bytes)? { |
|
| 2345 | 1683 | if !sigs.iter().any(|s| s.key == sig.key) { |
|
| 2346 | 1684 | sigs.push(sig); |
|
| … | 11 unchanged lines |
||
| 2358 | 1696 | pub fn put_signature(&self, r: &ObjectRef, sig: &Signature) -> Result<()> { |
|
| 2359 | 1697 | let key = format!("{}{}", fanout_prefix("sig", r), sig.key.to_hex()); |
|
| 2360 | 1698 | let bytes = Signature::encode_list(std::slice::from_ref(sig)); |
|
| 2361 | - | if get_optional(self.backend.as_ref(), &key)?.as_deref() != Some(bytes.as_slice()) { |
|
| 2362 | - | delete(self.backend.as_ref(), &key)?; |
|
| 2363 | - | put_new_or_existing(self.backend.as_ref(), &key, &bytes)?; |
|
| 2364 | - | } |
|
| 2365 | - | // Journalled even when the key was there: an older writer's |
|
| 2366 | - | // entry is not in the journal or any list. |
|
| 2367 | - | self.journal_add(key) |
|
| 2368 | - | } |
|
| 2369 | - | ||
| 2370 | - | // ---- the journal and entry lists (spec §13.7, #116) |
|
| 2371 | - | ||
| 2372 | - | /// Whether lookups still read the one-list keys of older stores: until |
|
| 2373 | - | /// a complete fold has copied them into per-object entries and the |
|
| 2374 | - | /// pack listing shows its marker (spec §13.7). |
|
| 2375 | - | fn reads_legacy(&self) -> bool { |
|
| 2376 | - | !self.legacy_folded.get() |
|
| 2377 | - | } |
|
| 2378 | - | ||
| 2379 | - | /// Copy every older one-list key's contents into per-object entries, |
|
| 2380 | - | /// keeping an entry already there, then write the marker that tells |
|
| 2381 | - | /// readers to stop reading those keys. The keys themselves are left |
|
| 2382 | - | /// for older readers. A list that cannot be read is passed over, and |
|
| 2383 | - | /// said. |
|
| 2384 | - | fn fold_legacy(&self, progress: Progress) -> Result<()> { |
|
| 2385 | - | let mut copied = 0usize; |
|
| 2386 | - | for (index, legacy) in ENTRY_INDEXES { |
|
| 2387 | - | for e in self |
|
| 2388 | - | .backend |
|
| 2389 | - | .list(&format!("{legacy}/")) |
|
| 2390 | - | .map_err(backend_err)? |
|
| 2391 | - | { |
|
| 2392 | - | // <legacy>/<hh>/<rest> |
|
| 2393 | - | let mut parts = e.key.splitn(3, '/'); |
|
| 2394 | - | let (Some(_), Some(hh), Some(rest)) = (parts.next(), parts.next(), parts.next()) |
|
| 2395 | - | else { |
|
| 2396 | - | continue; |
|
| 2397 | - | }; |
|
| 2398 | - | let Some(r) = ObjectRef::from_hex(&format!("{hh}{rest}")) else { |
|
| 2399 | - | continue; |
|
| 2400 | - | }; |
|
| 2401 | - | let Some(bytes) = get_optional(self.backend.as_ref(), &e.key)? else { |
|
| 2402 | - | continue; |
|
| 2403 | - | }; |
|
| 2404 | - | if index == "sig" { |
|
| 2405 | - | let Ok(sigs) = Signature::decode_list(&bytes) else { |
|
| 2406 | - | progress(&format!("{}: not a list of signatures, passed over", e.key)); |
|
| 2407 | - | continue; |
|
| 2408 | - | }; |
|
| 2409 | - | for sig in sigs { |
|
| 2410 | - | let key = format!("{}{}", fanout_prefix("sig", &r), sig.key.to_hex()); |
|
| 2411 | - | let one = Signature::encode_list(std::slice::from_ref(&sig)); |
|
| 2412 | - | put_new_or_existing(self.backend.as_ref(), &key, &one)?; |
|
| 2413 | - | copied += 1; |
|
| 2414 | - | } |
|
| 2415 | - | } else { |
|
| 2416 | - | for v in String::from_utf8_lossy(&bytes) |
|
| 2417 | - | .lines() |
|
| 2418 | - | .filter_map(ObjectRef::from_hex) |
|
| 2419 | - | { |
|
| 2420 | - | let key = format!("{}{}", fanout_prefix(index, &r), v.to_hex()); |
|
| 2421 | - | put_new_or_existing(self.backend.as_ref(), &key, b"")?; |
|
| 2422 | - | copied += 1; |
|
| 2423 | - | } |
|
| 2424 | - | } |
|
| 2425 | - | } |
|
| 2426 | - | } |
|
| 2427 | - | if copied > 0 { |
|
| 2428 | - | progress(&format!("{copied} entries copied out of older lists")); |
|
| 2429 | - | } |
|
| 2430 | - | put_new_or_existing( |
|
| 2431 | - | self.backend.as_ref(), |
|
| 2432 | - | LEGACY_FOLDED_KEY, |
|
| 2433 | - | format!("{}\n", now_ms()).as_bytes(), |
|
| 2434 | - | )?; |
|
| 2435 | - | self.legacy_folded.set(true); |
|
| 2436 | - | Ok(()) |
|
| 2437 | - | } |
|
| 2438 | - | ||
| 2439 | - | /// The entry keys under `prefix` (an index's `<index>/` or one |
|
| 2440 | - | /// object's `<index>/<hh>/<rest>/`), sorted: on a folded store from |
|
| 2441 | - | /// its lists and journal, with no request; otherwise by listing. |
|
| 2442 | - | fn entry_keys(&self, prefix: &str) -> Result<Vec<String>> { |
|
| 2443 | - | if let Some(keys) = self.with_entries(|set| { |
|
| 2444 | - | set.range(prefix.to_string()..) |
|
| 2445 | - | .take_while(|k| k.starts_with(prefix)) |
|
| 2446 | - | .cloned() |
|
| 2447 | - | .collect::<Vec<String>>() |
|
| 2448 | - | })? { |
|
| 2449 | - | return Ok(keys); |
|
| 2450 | - | } |
|
| 2451 | - | Ok(self |
|
| 2452 | - | .backend |
|
| 2453 | - | .list(prefix) |
|
| 2454 | - | .map_err(backend_err)? |
|
| 2455 | - | .into_iter() |
|
| 2456 | - | .map(|e| e.key) |
|
| 2457 | - | .collect()) |
|
| 2458 | - | } |
|
| 2459 | - | ||
| 2460 | - | /// `f` over a folded store's entry keys, read on first use; `None` |
|
| 2461 | - | /// when the store is not folded (or is this disk's directory), and |
|
| 2462 | - | /// lookups list. |
|
| 2463 | - | fn with_entries<T>( |
|
| 2464 | - | &self, |
|
| 2465 | - | f: impl FnOnce(&std::collections::BTreeSet<String>) -> T, |
|
| 2466 | - | ) -> Result<Option<T>> { |
|
| 2467 | - | if self.local || !self.folded.get() { |
|
| 2468 | - | return Ok(None); |
|
| 2469 | - | } |
|
| 2470 | - | // Read over the interval ago: list the packs again (a cloud |
|
| 2471 | - | // client keeps that listing as long), loading what changed, and |
|
| 2472 | - | // read the journal again, so another writer's entry is seen as |
|
| 2473 | - | // its pack would be. |
|
| 2474 | - | if self |
|
| 2475 | - | .entries_read |
|
| 2476 | - | .get() |
|
| 2477 | - | .is_some_and(|t| t.elapsed() >= ENTRIES_FRESH) |
|
| 2478 | - | { |
|
| 2479 | - | *self.entries.borrow_mut() = None; |
|
| 2480 | - | self.reload_if_changed()?; |
|
| 2481 | - | if !self.folded.get() { |
|
| 2482 | - | return Ok(None); |
|
| 2483 | - | } |
|
| 2484 | - | } |
|
| 2485 | - | if self.entries.borrow().is_none() { |
|
| 2486 | - | let Some(set) = self.read_entries()? else { |
|
| 2487 | - | return Ok(None); |
|
| 2488 | - | }; |
|
| 2489 | - | *self.entries.borrow_mut() = Some(set); |
|
| 2490 | - | } |
|
| 2491 | - | Ok(self.entries.borrow().as_ref().map(f)) |
|
| 2492 | - | } |
|
| 2493 | - | ||
| 2494 | - | /// Every live entry list's keys and every journal line's, folded or |
|
| 2495 | - | /// not, with what this handle has queued. A list deleted since the |
|
| 2496 | - | /// packs were listed (retired over an hour ago) has the packs listed |
|
| 2497 | - | /// again; `None` when one is still missing then, or the store is no |
|
| 2498 | - | /// longer folded: lookups list. |
|
| 2499 | - | fn read_entries(&self) -> Result<Option<std::collections::BTreeSet<String>>> { |
|
| 2500 | - | 'attempt: for attempt in 0..2 { |
|
| 2501 | - | if !self.folded.get() { |
|
| 2502 | - | return Ok(None); |
|
| 2503 | - | } |
|
| 2504 | - | let mut set = std::collections::BTreeSet::new(); |
|
| 2505 | - | let lists = self.entry_lists.borrow().clone(); |
|
| 2506 | - | self.lists_read |
|
| 2507 | - | .borrow_mut() |
|
| 2508 | - | .retain(|k, _| lists.contains(k)); |
|
| 2509 | - | for key in &lists { |
|
| 2510 | - | let known = self.lists_read.borrow().get(key).cloned(); |
|
| 2511 | - | let keys = match known { |
|
| 2512 | - | Some(keys) => keys, |
|
| 2513 | - | None => match get_optional(self.backend.as_ref(), key)? { |
|
| 2514 | - | Some(bytes) => { |
|
| 2515 | - | let keys = Arc::new(decode_entry_list(key, &bytes)?); |
|
| 2516 | - | self.lists_read |
|
| 2517 | - | .borrow_mut() |
|
| 2518 | - | .insert(key.clone(), keys.clone()); |
|
| 2519 | - | keys |
|
| 2520 | - | } |
|
| 2521 | - | None if attempt == 0 => { |
|
| 2522 | - | self.reload_packs()?; |
|
| 2523 | - | continue 'attempt; |
|
| 2524 | - | } |
|
| 2525 | - | None => return Ok(None), |
|
| 2526 | - | }, |
|
| 2527 | - | }; |
|
| 2528 | - | set.extend(keys.iter().cloned()); |
|
| 2529 | - | } |
|
| 2530 | - | set.extend(self.read_record(JOURNAL_KEY)?.0.into_keys()); |
|
| 2531 | - | set.extend(self.journal_queue.borrow().iter().cloned()); |
|
| 2532 | - | self.entries_read.set(Some(std::time::Instant::now())); |
|
| 2533 | - | return Ok(Some(set)); |
|
| 2534 | - | } |
|
| 2535 | - | Ok(None) |
|
| 2536 | - | } |
|
| 2537 | - | ||
| 2538 | - | /// Add an entry key this handle wrote to the journal: at once, or |
|
| 2539 | - | /// when the open batch is flushed. One the handle already knows from |
|
| 2540 | - | /// the lists or the journal is not added again. Nothing on this |
|
| 2541 | - | /// disk's directory. |
|
| 2542 | - | fn journal_add(&self, key: String) -> Result<()> { |
|
| 2543 | - | if self.local { |
|
| 2544 | - | return Ok(()); |
|
| 2545 | - | } |
|
| 2546 | - | if let Some(set) = self.entries.borrow_mut().as_mut() { |
|
| 2547 | - | if !set.insert(key.clone()) { |
|
| 2548 | - | return Ok(()); |
|
| 2549 | - | } |
|
| 2550 | - | } |
|
| 2551 | - | if self.batch_depth.get() > 0 { |
|
| 2552 | - | self.journal_queue.borrow_mut().push(key); |
|
| 2553 | - | return Ok(()); |
|
| 2554 | - | } |
|
| 2555 | - | self.journal_append(std::slice::from_ref(&key)) |
|
| 2556 | - | } |
|
| 2557 | - | ||
| 2558 | - | /// Add what the open batch queued to the journal. |
|
| 2559 | - | fn flush_journal(&self) -> Result<()> { |
|
| 2560 | - | let queued = std::mem::take(&mut *self.journal_queue.borrow_mut()); |
|
| 2561 | - | self.journal_append(&queued) |
|
| 2562 | - | } |
|
| 2563 | - | ||
| 2564 | - | /// Add `keys` to the journal, unfolded, in one compare-and-swap |
|
| 2565 | - | /// update, dropping lines folded over an hour ago; past |
|
| 2566 | - | /// [`JOURNAL_FOLD_AT`] unfolded lines, fold them into a partial list. |
|
| 2567 | - | fn journal_append(&self, keys: &[String]) -> Result<()> { |
|
| 2568 | - | if keys.is_empty() { |
|
| 1699 | + | if get_optional(self.backend.as_ref(), &key)?.as_deref() == Some(bytes.as_slice()) { |
|
| 2569 | 1700 | return Ok(()); |
|
| 2570 | 1701 | } |
|
| 2571 | - | let now = now_ms(); |
|
| 2572 | - | let mut unfolded = 0; |
|
| 2573 | - | self.update_record(JOURNAL_KEY, "the journal of entries", |m| { |
|
| 2574 | - | expire_folded(m, now); |
|
| 2575 | - | for k in keys { |
|
| 2576 | - | m.entry(k.clone()).or_insert(0); |
|
| 2577 | - | } |
|
| 2578 | - | unfolded = m.values().filter(|t| **t == 0).count(); |
|
| 2579 | - | })?; |
|
| 2580 | - | if unfolded > JOURNAL_FOLD_AT { |
|
| 2581 | - | self.fold_journal()?; |
|
| 2582 | - | } |
|
| 2583 | - | Ok(()) |
|
| 2584 | - | } |
|
| 2585 | - | ||
| 2586 | - | /// Fold the journal's unfolded lines into a partial entry list, and |
|
| 2587 | - | /// mark them folded. Lines added meanwhile stay unfolded. |
|
| 2588 | - | fn fold_journal(&self) -> Result<()> { |
|
| 2589 | - | let (journal, _) = self.read_record(JOURNAL_KEY)?; |
|
| 2590 | - | let keys: Vec<String> = journal |
|
| 2591 | - | .into_iter() |
|
| 2592 | - | .filter(|(_, t)| *t == 0) |
|
| 2593 | - | .map(|(k, _)| k) |
|
| 2594 | - | .collect(); |
|
| 2595 | - | if keys.is_empty() { |
|
| 2596 | - | return Ok(()); |
|
| 2597 | - | } |
|
| 2598 | - | self.put_entry_list(PART_SUFFIX, keys.iter())?; |
|
| 2599 | - | self.mark_folded(&keys) |
|
| 2600 | - | } |
|
| 2601 | - | ||
| 2602 | - | /// Mark these journal lines folded now, if they are not yet; drop |
|
| 2603 | - | /// those folded over an hour ago. |
|
| 2604 | - | fn mark_folded(&self, keys: &[String]) -> Result<()> { |
|
| 2605 | - | let now = now_ms(); |
|
| 2606 | - | self.update_record(JOURNAL_KEY, "the journal of entries", |m| { |
|
| 2607 | - | expire_folded(m, now); |
|
| 2608 | - | for k in keys { |
|
| 2609 | - | if let Some(t) = m.get_mut(k) { |
|
| 2610 | - | if *t == 0 { |
|
| 2611 | - | *t = now; |
|
| 2612 | - | } |
|
| 2613 | - | } |
|
| 2614 | - | } |
|
| 2615 | - | }) |
|
| 2616 | - | } |
|
| 2617 | - | ||
| 2618 | - | /// Write a new entry list of `keys` (sorted), complete or partial by |
|
| 2619 | - | /// `suffix`; its key. |
|
| 2620 | - | fn put_entry_list<'a>( |
|
| 2621 | - | &self, |
|
| 2622 | - | suffix: &str, |
|
| 2623 | - | keys: impl Iterator<Item = &'a String>, |
|
| 2624 | - | ) -> Result<String> { |
|
| 2625 | - | let key = format!("pack-{}{suffix}", fresh_id()); |
|
| 2626 | - | let bytes = encode_entry_list(keys)?; |
|
| 2627 | - | self.backend.put_new(&key, &bytes).map_err(backend_err)?; |
|
| 2628 | - | Ok(key) |
|
| 2629 | - | } |
|
| 2630 | - | ||
| 2631 | - | /// Fold every signature and index entry into one complete entry list |
|
| 2632 | - | /// (spec §13.7), what a collection does last: first the older |
|
| 2633 | - | /// one-list keys into per-object entries, once; then the journal's lines and |
|
| 2634 | - | /// a listing of every entry prefix, written as a new list, every list |
|
| 2635 | - | /// the packs' listing showed retired, and the journal lines read |
|
| 2636 | - | /// marked folded. Nothing when the store is this disk's directory, or |
|
| 2637 | - | /// when the one live list already holds exactly these entries and the |
|
| 2638 | - | /// journal has nothing unfolded. Whether a list was written. |
|
| 2639 | - | pub fn fold_entries(&self, progress: Progress) -> Result<bool> { |
|
| 2640 | - | if self.local { |
|
| 2641 | - | return Ok(false); |
|
| 2642 | - | } |
|
| 2643 | - | self.reload_packs()?; |
|
| 2644 | - | if !self.legacy_folded.get() { |
|
| 2645 | - | progress("folding older lists of signatures and index entries"); |
|
| 2646 | - | self.fold_legacy(progress)?; |
|
| 2647 | - | } |
|
| 2648 | - | let (journal, _) = self.read_record(JOURNAL_KEY)?; |
|
| 2649 | - | progress("folding signatures and index entries"); |
|
| 2650 | - | let mut all: std::collections::BTreeSet<String> = journal.keys().cloned().collect(); |
|
| 2651 | - | for (index, _) in ENTRY_INDEXES { |
|
| 2652 | - | for e in self |
|
| 2653 | - | .backend |
|
| 2654 | - | .list(&format!("{index}/")) |
|
| 2655 | - | .map_err(backend_err)? |
|
| 2656 | - | { |
|
| 2657 | - | all.insert(e.key); |
|
| 2658 | - | } |
|
| 2659 | - | } |
|
| 2660 | - | let lists = self.entry_lists.borrow().clone(); |
|
| 2661 | - | let unfolded: Vec<String> = journal |
|
| 2662 | - | .iter() |
|
| 2663 | - | .filter(|(_, t)| **t == 0) |
|
| 2664 | - | .map(|(k, _)| k.clone()) |
|
| 2665 | - | .collect(); |
|
| 2666 | - | if unfolded.is_empty() && lists.len() == 1 && lists[0].ends_with(LIST_SUFFIX) { |
|
| 2667 | - | if let Some(bytes) = get_optional(self.backend.as_ref(), &lists[0])? { |
|
| 2668 | - | let held: std::collections::BTreeSet<String> = |
|
| 2669 | - | decode_entry_list(&lists[0], &bytes)?.into_iter().collect(); |
|
| 2670 | - | if held == all { |
|
| 2671 | - | return Ok(false); |
|
| 2672 | - | } |
|
| 2673 | - | } |
|
| 2674 | - | } |
|
| 2675 | - | self.put_entry_list(LIST_SUFFIX, all.iter())?; |
|
| 2676 | - | self.retire(&lists)?; |
|
| 2677 | - | if !unfolded.is_empty() { |
|
| 2678 | - | self.mark_folded(&unfolded)?; |
|
| 2679 | - | } |
|
| 2680 | - | *self.entries.borrow_mut() = None; |
|
| 2681 | - | self.reload_packs()?; |
|
| 2682 | - | Ok(true) |
|
| 1702 | + | delete(self.backend.as_ref(), &key)?; |
|
| 1703 | + | put_new_or_existing(self.backend.as_ref(), &key, &bytes) |
|
| 2683 | 1704 | } |
|
| 2684 | 1705 | ||
| 2685 | 1706 | /// Finish packs a writer left unfinished: a batch writes its objects to |
|
| … | 118 unchanged lines |
||
| 2804 | 1825 | out.sort(); |
|
| 2805 | 1826 | out.dedup(); |
|
| 2806 | 1827 | Ok(out) |
|
| 2807 | - | } |
|
| 2808 | - | } |
|
| 2809 | - | ||
| 2810 | - | /// Whether a read failed in a way a newer list of packs may cure: the |
|
| 2811 | - | /// object was not found, or its pack's dictionary is gone. |
|
| 2812 | - | fn is_miss(e: &StoreError) -> bool { |
|
| 2813 | - | match e { |
|
| 2814 | - | StoreError::NotFound(_) => true, |
|
| 2815 | - | StoreError::Corrupt(_, why) => why == NO_DICT, |
|
| 2816 | - | _ => false, |
|
| 2817 | 1828 | } |
|
| 2818 | 1829 | } |
|
| 2819 | 1830 | ||
| 2820 | 1831 | /// 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 | - | ||
| 2832 | 1832 | fn parse_loose(data: &[u8], r: &ObjectRef) -> Result<Object> { |
|
| 2833 | 1833 | let nul = data |
|
| 2834 | 1834 | .iter() |
|
| … | 180 unchanged lines |
||
| 3015 | 2015 | for (r, b) in refs.iter().zip(&bodies) { |
|
| 3016 | 2016 | assert_eq!(&again.get(r).unwrap().1, b); |
|
| 3017 | 2017 | } |
|
| 3018 | - | let packs = |dir: &Path| { |
|
| 3019 | - | let mut v: Vec<String> = fs::read_dir(dir) |
|
| 3020 | - | .unwrap() |
|
| 3021 | - | .flatten() |
|
| 3022 | - | .map(|e| e.file_name().to_string_lossy().into_owned()) |
|
| 3023 | - | .filter(|n| n.starts_with("pack-")) |
|
| 3024 | - | .collect(); |
|
| 3025 | - | v.sort(); |
|
| 3026 | - | v |
|
| 3027 | - | }; |
|
| 3028 | - | // With nothing new, a repack leaves that pack as it is: it would |
|
| 3029 | - | // only write the same again. |
|
| 3030 | - | let before = packs(dir.path()); |
|
| 3031 | - | let (report, merged) = again.repack_all(REPACK_ZSTD_LEVEL).unwrap(); |
|
| 3032 | - | assert_eq!((report.objects, merged), (0, 0)); |
|
| 3033 | - | assert_eq!(packs(dir.path()), before); |
|
| 3034 | - | // One new object: a repack replaces the pack and its dictionary, |
|
| 3035 | - | // retiring the old ones, which a collection an hour on deletes. |
|
| 3036 | - | let extra = again.put(ObjectType::Chunk, b"one more\n").unwrap(); |
|
| 2018 | + | // A repack after that replaces the pack and its dictionary. |
|
| 3037 | 2019 | again.repack_all(REPACK_ZSTD_LEVEL).unwrap(); |
|
| 3038 | - | let files = packs(dir.path()); |
|
| 3039 | - | assert_eq!( |
|
| 3040 | - | files.len(), |
|
| 3041 | - | 7, |
|
| 3042 | - | "two of pack, index and dictionary, and the retired record: {files:?}" |
|
| 3043 | - | ); |
|
| 3044 | - | assert_eq!(again.pack_count(), 1, "the old pack is not read"); |
|
| 2020 | + | let files: Vec<String> = fs::read_dir(dir.path()) |
|
| 2021 | + | .unwrap() |
|
| 2022 | + | .flatten() |
|
| 2023 | + | .map(|e| e.file_name().to_string_lossy().into_owned()) |
|
| 2024 | + | .filter(|n| n.starts_with("pack-")) |
|
| 2025 | + | .collect(); |
|
| 2026 | + | assert_eq!(files.len(), 3, "one pack, index and dictionary: {files:?}"); |
|
| 3045 | 2027 | assert_eq!(&again.get(&refs[7]).unwrap().1, &bodies[7]); |
|
| 3046 | - | assert_eq!(again.get(&extra).unwrap().1, b"one more\n"); |
|
| 3047 | - | again |
|
| 3048 | - | .expire_retired(std::time::Duration::ZERO, &mut silent()) |
|
| 3049 | - | .unwrap(); |
|
| 3050 | - | let files = packs(dir.path()); |
|
| 3051 | - | assert_eq!(files.len(), 4, "one pack, index, dictionary: {files:?}"); |
|
| 3052 | - | assert!(before |
|
| 3053 | - | .iter() |
|
| 3054 | - | .filter(|f| *f != RETIRED_KEY) |
|
| 3055 | - | .all(|f| !files.contains(f))); |
|
| 3056 | - | assert_eq!(&again.get(&refs[8]).unwrap().1, &bodies[8]); |
|
| 3057 | 2028 | } |
|
| 3058 | 2029 | ||
| 3059 | 2030 | #[test] |
|
| … | 381 unchanged lines |
||
| 3441 | 2412 | } |
|
| 3442 | 2413 | ||
| 3443 | 2414 | #[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] |
|
| 3462 | 2415 | fn get_many_reads_loose_and_packed_objects_in_order() { |
|
| 3463 | 2416 | let dir = tempfile::tempdir().unwrap(); |
|
| 3464 | 2417 | each_store(dir.path(), |which, s| { |
|
| … | 33 unchanged lines |
||
| 3498 | 2451 | ); |
|
| 3499 | 2452 | } |
|
| 3500 | 2453 | assert!(matches!(got[40], Err(StoreError::NotFound(_)))); |
|
| 3501 | - | }); |
|
| 3502 | - | } |
|
| 3503 | - | ||
| 3504 | - | /// A packed object is read from its pack without first asking the |
|
| 3505 | - | /// backend for a loose copy (oxygenhub#228: on a bucket each such ask |
|
| 3506 | - | /// was a request that found nothing, every time). |
|
| 3507 | - | #[test] |
|
| 3508 | - | fn packed_objects_cost_no_loose_lookups() { |
|
| 3509 | - | use std::sync::atomic::{AtomicUsize, Ordering}; |
|
| 3510 | - | struct Counting(MemoryStore, AtomicUsize); |
|
| 3511 | - | impl crate::strata::Store for Counting { |
|
| 3512 | - | fn describe(&self) -> String { |
|
| 3513 | - | "counting".into() |
|
| 3514 | - | } |
|
| 3515 | - | fn has(&self, k: &str) -> crate::strata::Result<bool> { |
|
| 3516 | - | self.0.has(k) |
|
| 3517 | - | } |
|
| 3518 | - | fn get(&self, k: &str) -> crate::strata::Result<Vec<u8>> { |
|
| 3519 | - | if k.starts_with("loose/") { |
|
| 3520 | - | self.1.fetch_add(1, Ordering::Relaxed); |
|
| 3521 | - | } |
|
| 3522 | - | self.0.get(k) |
|
| 3523 | - | } |
|
| 3524 | - | fn get_range( |
|
| 3525 | - | &self, |
|
| 3526 | - | k: &str, |
|
| 3527 | - | r: std::ops::Range<u64>, |
|
| 3528 | - | ) -> crate::strata::Result<Vec<u8>> { |
|
| 3529 | - | self.0.get_range(k, r) |
|
| 3530 | - | } |
|
| 3531 | - | fn put_new(&self, k: &str, b: &[u8]) -> crate::strata::Result<()> { |
|
| 3532 | - | self.0.put_new(k, b) |
|
| 3533 | - | } |
|
| 3534 | - | fn list(&self, p: &str) -> crate::strata::Result<Vec<crate::strata::Entry>> { |
|
| 3535 | - | self.0.list(p) |
|
| 3536 | - | } |
|
| 3537 | - | fn delete(&self, k: &str) -> crate::strata::Result<()> { |
|
| 3538 | - | self.0.delete(k) |
|
| 3539 | - | } |
|
| 3540 | - | fn read_ref( |
|
| 3541 | - | &self, |
|
| 3542 | - | k: &str, |
|
| 3543 | - | ) -> crate::strata::Result<Option<(Vec<u8>, crate::strata::Version)>> { |
|
| 3544 | - | self.0.read_ref(k) |
|
| 3545 | - | } |
|
| 3546 | - | fn write_ref( |
|
| 3547 | - | &self, |
|
| 3548 | - | k: &str, |
|
| 3549 | - | b: &[u8], |
|
| 3550 | - | e: Option<&crate::strata::Version>, |
|
| 3551 | - | ) -> crate::strata::Result<crate::strata::Version> { |
|
| 3552 | - | self.0.write_ref(k, b, e) |
|
| 3553 | - | } |
|
| 3554 | - | } |
|
| 3555 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 3556 | - | let backend = Arc::new(Counting(MemoryStore::new(), AtomicUsize::new(0))); |
|
| 3557 | - | let s = Store::open_on(backend.clone(), dir.path().join("scratch")).unwrap(); |
|
| 3558 | - | let refs: Vec<ObjectRef> = (0..10) |
|
| 3559 | - | .map(|i| { |
|
| 3560 | - | s.put(ObjectType::Chunk, format!("p {i}").as_bytes()) |
|
| 3561 | - | .unwrap() |
|
| 3562 | - | }) |
|
| 3563 | - | .collect(); |
|
| 3564 | - | s.pack_loose().unwrap(); |
|
| 3565 | - | let s = Store::open_on(backend.clone(), dir.path().join("scratch")).unwrap(); |
|
| 3566 | - | backend.1.store(0, Ordering::Relaxed); |
|
| 3567 | - | for r in &refs { |
|
| 3568 | - | assert!(s.get(r).is_ok()); |
|
| 3569 | - | } |
|
| 3570 | - | assert!(s.get_many(&refs).iter().all(|g| g.is_ok())); |
|
| 3571 | - | assert_eq!(backend.1.load(Ordering::Relaxed), 0, "no loose lookups"); |
|
| 3572 | - | // An object no pack holds is still looked for loose. |
|
| 3573 | - | let missing = hash_object(ObjectType::Chunk, b"never stored"); |
|
| 3574 | - | assert!(matches!(s.get(&missing), Err(StoreError::NotFound(_)))); |
|
| 3575 | - | assert!(backend.1.load(Ordering::Relaxed) > 0); |
|
| 3576 | - | } |
|
| 3577 | - | } |
|
| 3578 | - | ||
| 3579 | - | /// Readers never wait (oxygenhub#249): a store keeps reading while other |
|
| 3580 | - | /// handles repack, collect and push into the store under it. |
|
| 3581 | - | #[cfg(test)] |
|
| 3582 | - | pub(crate) mod reader_tests { |
|
| 3583 | - | use super::*; |
|
| 3584 | - | ||
| 3585 | - | fn chunk(i: usize) -> Vec<u8> { |
|
| 3586 | - | format!("object {i} ").repeat(1 + i % 5).into_bytes() |
|
| 3587 | - | } |
|
| 3588 | - | ||
| 3589 | - | /// Two handles on one store: a writer, and a reader opened at once. |
|
| 3590 | - | fn each_pair(f: impl Fn(&str, Store, Store)) { |
|
| 3591 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 3592 | - | let s = Store::open(dir.path().join("objects")).unwrap(); |
|
| 3593 | - | let r = Store::open(dir.path().join("objects")).unwrap(); |
|
| 3594 | - | f("directory", s, r); |
|
| 3595 | - | let m: Arc<Backend> = Arc::new(MemoryStore::new()); |
|
| 3596 | - | let s = Store::open_on(m.clone(), dir.path().join("scratch")).unwrap(); |
|
| 3597 | - | let r = Store::open_on(m, dir.path().join("scratch-r")).unwrap(); |
|
| 3598 | - | f("memory", s, r); |
|
| 3599 | - | } |
|
| 3600 | - | ||
| 3601 | - | /// Set every retired entry's time back by `by`, as if retired then. |
|
| 3602 | - | pub(crate) fn age_retired(s: &Store, by: std::time::Duration) { |
|
| 3603 | - | s.update_retired(|m| { |
|
| 3604 | - | for t in m.values_mut() { |
|
| 3605 | - | *t = t.saturating_sub(by.as_millis() as u64); |
|
| 3606 | - | } |
|
| 3607 | - | }) |
|
| 3608 | - | .unwrap(); |
|
| 3609 | - | } |
|
| 3610 | - | ||
| 3611 | - | fn retired_keys(s: &Store) -> Vec<String> { |
|
| 3612 | - | s.read_retired().unwrap().0.into_keys().collect() |
|
| 3613 | - | } |
|
| 3614 | - | ||
| 3615 | - | #[test] |
|
| 3616 | - | fn a_reader_opened_before_a_repack_reads_every_object_after_it() { |
|
| 3617 | - | each_pair(|which, writer, _| { |
|
| 3618 | - | let mut refs = Vec::new(); |
|
| 3619 | - | for i in 0..40 { |
|
| 3620 | - | refs.push(writer.put(ObjectType::Chunk, &chunk(i)).unwrap()); |
|
| 3621 | - | } |
|
| 3622 | - | writer.pack_loose().unwrap(); |
|
| 3623 | - | for i in 40..80 { |
|
| 3624 | - | refs.push(writer.put(ObjectType::Chunk, &chunk(i)).unwrap()); |
|
| 3625 | - | } |
|
| 3626 | - | // Opened now: one pack and forty loose objects. |
|
| 3627 | - | let reader = Store::open_on(writer.backend().clone(), writer.dir()).unwrap(); |
|
| 3628 | - | assert_eq!(reader.pack_count(), 1, "{which}"); |
|
| 3629 | - | let (_, merged) = writer.repack_all(REPACK_ZSTD_LEVEL).unwrap(); |
|
| 3630 | - | assert_eq!(merged, 1); |
|
| 3631 | - | // The old pack and the loose objects are retired, not deleted. |
|
| 3632 | - | let retired = retired_keys(&writer); |
|
| 3633 | - | assert_eq!(retired.len(), 81, "{which}: {retired:?}"); |
|
| 3634 | - | assert_eq!(writer.loose_count().unwrap(), 0, "retired is not loose"); |
|
| 3635 | - | // The reader reads on from what it loaded. |
|
| 3636 | - | for (i, r) in refs.iter().enumerate() { |
|
| 3637 | - | assert_eq!(reader.get(r).unwrap().1, chunk(i), "{which}: {i}"); |
|
| 3638 | - | } |
|
| 3639 | - | assert_eq!(reader.pack_count(), 1); |
|
| 3640 | - | // A store opened now reads the new pack only. |
|
| 3641 | - | let fresh = Store::open_on(writer.backend().clone(), writer.dir()).unwrap(); |
|
| 3642 | - | assert_eq!(fresh.pack_count(), 1); |
|
| 3643 | - | assert_ne!(fresh.packs.borrow()[0].key, reader.packs.borrow()[0].key); |
|
| 3644 | - | // Once the retired files are deleted, the reader finds every |
|
| 3645 | - | // object in the new pack, by get, get_many and has alike. |
|
| 3646 | - | let report = writer |
|
| 3647 | - | .expire_retired(std::time::Duration::ZERO, &mut silent()) |
|
| 3648 | - | .unwrap(); |
|
| 3649 | - | assert_eq!((report.packs, report.loose), (1, 80), "{which}"); |
|
| 3650 | - | assert!(retired_keys(&writer).is_empty()); |
|
| 3651 | - | for (i, r) in refs.iter().enumerate() { |
|
| 3652 | - | assert_eq!(reader.get(r).unwrap().1, chunk(i), "{which}: {i}"); |
|
| 3653 | - | } |
|
| 3654 | - | let other = Store::open_on(writer.backend().clone(), writer.dir()).unwrap(); |
|
| 3655 | - | let before = other.packs.borrow()[0].key.clone(); |
|
| 3656 | - | writer.put(ObjectType::Chunk, b"new").unwrap(); |
|
| 3657 | - | writer.repack_all(REPACK_ZSTD_LEVEL).unwrap(); |
|
| 3658 | - | writer |
|
| 3659 | - | .expire_retired(std::time::Duration::ZERO, &mut silent()) |
|
| 3660 | - | .unwrap(); |
|
| 3661 | - | assert!(other.get_many(&refs).iter().all(|g| g.is_ok()), "{which}"); |
|
| 3662 | - | assert_ne!(other.packs.borrow()[0].key, before); |
|
| 3663 | - | let third = Store::open_on(writer.backend().clone(), writer.dir()).unwrap(); |
|
| 3664 | - | writer.put(ObjectType::Chunk, b"newer").unwrap(); |
|
| 3665 | - | writer.repack_all(REPACK_ZSTD_LEVEL).unwrap(); |
|
| 3666 | - | writer |
|
| 3667 | - | .expire_retired(std::time::Duration::ZERO, &mut silent()) |
|
| 3668 | - | .unwrap(); |
|
| 3669 | - | assert!(refs.iter().all(|r| third.has(r)), "{which}"); |
|
| 3670 | - | assert!(!third.has(&hash_object(ObjectType::Chunk, b"never"))); |
|
| 3671 | - | }); |
|
| 3672 | - | } |
|
| 3673 | - | ||
| 3674 | - | #[test] |
|
| 3675 | - | fn a_reader_opened_before_a_spilled_batch_reads_its_objects() { |
|
| 3676 | - | each_pair(|which, writer, reader| { |
|
| 3677 | - | assert_eq!(reader.pack_count(), 0); |
|
| 3678 | - | let mut refs = Vec::new(); |
|
| 3679 | - | { |
|
| 3680 | - | let batch = writer.begin_batch().unwrap(); |
|
| 3681 | - | for i in 0..(SPILL_OBJECTS + 50) { |
|
| 3682 | - | refs.push(writer.put(ObjectType::Chunk, &chunk(i)).unwrap()); |
|
| 3683 | - | } |
|
| 3684 | - | batch.finish().unwrap(); |
|
| 3685 | - | } |
|
| 3686 | - | assert_eq!(writer.pack_count(), 1, "{which}: the batch spilled"); |
|
| 3687 | - | assert_eq!(reader.get(&refs[0]).unwrap().1, chunk(0), "{which}"); |
|
| 3688 | - | assert_eq!(reader.pack_count(), 1, "the miss loaded the new pack"); |
|
| 3689 | - | let got = reader.get_many(&refs); |
|
| 3690 | - | assert!(got.iter().all(|g| g.is_ok()), "{which}"); |
|
| 3691 | - | // has, too, from another reader. |
|
| 3692 | - | let late = Store::open_on(writer.backend().clone(), writer.dir()).unwrap(); |
|
| 3693 | - | { |
|
| 3694 | - | let batch = writer.begin_batch().unwrap(); |
|
| 3695 | - | for i in 1000..(1000 + SPILL_OBJECTS + 1) { |
|
| 3696 | - | writer.put(ObjectType::Chunk, &chunk(i)).unwrap(); |
|
| 3697 | - | } |
|
| 3698 | - | batch.finish().unwrap(); |
|
| 3699 | - | } |
|
| 3700 | - | assert!(late.has(&hash_object(ObjectType::Chunk, &chunk(1000)))); |
|
| 3701 | - | }); |
|
| 3702 | - | } |
|
| 3703 | - | ||
| 3704 | - | #[test] |
|
| 3705 | - | fn retired_entries_expire_after_the_hour_and_are_never_loaded() { |
|
| 3706 | - | each_pair(|which, s, _| { |
|
| 3707 | - | for i in 0..10 { |
|
| 3708 | - | s.put(ObjectType::Chunk, &chunk(i)).unwrap(); |
|
| 3709 | - | } |
|
| 3710 | - | s.pack_loose().unwrap(); |
|
| 3711 | - | s.put(ObjectType::Chunk, &chunk(10)).unwrap(); |
|
| 3712 | - | s.repack_all(ZSTD_LEVEL).unwrap(); |
|
| 3713 | - | // Retired over an hour ago: the first pack and 10 loose objects. |
|
| 3714 | - | age_retired(&s, RETIRED_FOR + std::time::Duration::from_secs(60)); |
|
| 3715 | - | let old = retired_keys(&s); |
|
| 3716 | - | assert_eq!(old.len(), 12, "{which}: {old:?}"); |
|
| 3717 | - | // Retired just now: the second pack. |
|
| 3718 | - | s.put(ObjectType::Chunk, &chunk(11)).unwrap(); |
|
| 3719 | - | s.repack_all(ZSTD_LEVEL).unwrap(); |
|
| 3720 | - | assert_eq!(retired_keys(&s).len(), 14); |
|
| 3721 | - | let listed = s |
|
| 3722 | - | .backend() |
|
| 3723 | - | .list("pack-") |
|
| 3724 | - | .unwrap() |
|
| 3725 | - | .into_iter() |
|
| 3726 | - | .filter(|e| e.key.ends_with(".idx")) |
|
| 3727 | - | .count(); |
|
| 3728 | - | assert_eq!(listed, 3, "{which}"); |
|
| 3729 | - | let fresh = Store::open_on(s.backend().clone(), s.dir()).unwrap(); |
|
| 3730 | - | assert_eq!( |
|
| 3731 | - | fresh.pack_count(), |
|
| 3732 | - | 1, |
|
| 3733 | - | "{which}: retired packs are not loaded" |
|
| 3734 | - | ); |
|
| 3735 | - | let report = s.expire_retired(RETIRED_FOR, &mut silent()).unwrap(); |
|
| 3736 | - | assert_eq!( |
|
| 3737 | - | report, |
|
| 3738 | - | RetiredReport { |
|
| 3739 | - | packs: 1, |
|
| 3740 | - | loose: 11, |
|
| 3741 | - | kept: 2 |
|
| 3742 | - | }, |
|
| 3743 | - | "{which}" |
|
| 3744 | - | ); |
|
| 3745 | - | let young = retired_keys(&s); |
|
| 3746 | - | assert_eq!(young.len(), 2, "{which}: {young:?}"); |
|
| 3747 | - | assert!(young.iter().all(|k| !old.contains(k))); |
|
| 3748 | - | for k in old.iter().filter(|k| k.ends_with(".pack")) { |
|
| 3749 | - | assert!(!s.backend().has(k).unwrap(), "{which}: {k}"); |
|
| 3750 | - | } |
|
| 3751 | - | for k in &young { |
|
| 3752 | - | assert!(s.backend().has(k).unwrap(), "{which}: {k} kept"); |
|
| 3753 | - | } |
|
| 3754 | - | for i in 0..12 { |
|
| 3755 | - | let r = hash_object(ObjectType::Chunk, &chunk(i)); |
|
| 3756 | - | assert_eq!(fresh.get(&r).unwrap().1, chunk(i), "{which}"); |
|
| 3757 | - | } |
|
| 3758 | 2454 | }); |
|
| 3759 | - | } |
|
| 3760 | - | ||
| 3761 | - | /// A retired loose object whose pack copy was pruned since is not |
|
| 3762 | - | /// lost when the retired files go: it is loose again. |
|
| 3763 | - | #[test] |
|
| 3764 | - | fn a_retired_loose_object_no_pack_holds_is_kept() { |
|
| 3765 | - | each_pair(|which, s, _| { |
|
| 3766 | - | let a = s.put(ObjectType::Chunk, b"a").unwrap(); |
|
| 3767 | - | let b = s.put(ObjectType::Chunk, b"b").unwrap(); |
|
| 3768 | - | s.pack_loose().unwrap(); |
|
| 3769 | - | assert_eq!(s.loose_count().unwrap(), 0); |
|
| 3770 | - | // The pack rewritten without b, as pruning does. |
|
| 3771 | - | let first = s.packs.borrow()[0].key.clone(); |
|
| 3772 | - | s.write_pack(&[a], &[], &[first], ZSTD_LEVEL, &mut silent()) |
|
| 3773 | - | .unwrap(); |
|
| 3774 | - | let report = s |
|
| 3775 | - | .expire_retired(std::time::Duration::ZERO, &mut silent()) |
|
| 3776 | - | .unwrap(); |
|
| 3777 | - | assert_eq!((report.packs, report.loose), (1, 1), "{which}: a's copy"); |
|
| 3778 | - | assert!(retired_keys(&s).is_empty()); |
|
| 3779 | - | assert_eq!(s.get(&b).unwrap().1, b"b", "{which}"); |
|
| 3780 | - | assert_eq!(s.get(&a).unwrap().1, b"a"); |
|
| 3781 | - | assert_eq!(s.loose_count().unwrap(), 1, "b is loose again"); |
|
| 3782 | - | // And pruning it, unreachable, deletes it. |
|
| 3783 | - | let keep = [a].into_iter().collect(); |
|
| 3784 | - | let r = s.prune(&keep, std::time::Duration::ZERO).unwrap(); |
|
| 3785 | - | assert_eq!(r.objects, 1); |
|
| 3786 | - | assert!(!s.has(&b)); |
|
| 3787 | - | }); |
|
| 3788 | - | } |
|
| 3789 | - | ||
| 3790 | - | /// A backend whose first pack listing names an index that is deleted |
|
| 3791 | - | /// before it is read, as a collection running meanwhile would. |
|
| 3792 | - | pub(crate) struct Racing { |
|
| 3793 | - | inner: MemoryStore, |
|
| 3794 | - | lists: std::sync::atomic::AtomicUsize, |
|
| 3795 | - | /// Deleted right after the first listing returns. |
|
| 3796 | - | pub(crate) doomed: std::sync::Mutex<Option<String>>, |
|
| 3797 | - | /// Named in every listing, never stored. |
|
| 3798 | - | ghost: bool, |
|
| 3799 | - | } |
|
| 3800 | - | ||
| 3801 | - | impl crate::strata::Store for Racing { |
|
| 3802 | - | fn describe(&self) -> String { |
|
| 3803 | - | "racing".into() |
|
| 3804 | - | } |
|
| 3805 | - | fn has(&self, k: &str) -> crate::strata::Result<bool> { |
|
| 3806 | - | self.inner.has(k) |
|
| 3807 | - | } |
|
| 3808 | - | fn get(&self, k: &str) -> crate::strata::Result<Vec<u8>> { |
|
| 3809 | - | self.inner.get(k) |
|
| 3810 | - | } |
|
| 3811 | - | fn get_range(&self, k: &str, r: std::ops::Range<u64>) -> crate::strata::Result<Vec<u8>> { |
|
| 3812 | - | self.inner.get_range(k, r) |
|
| 3813 | - | } |
|
| 3814 | - | fn put_new(&self, k: &str, b: &[u8]) -> crate::strata::Result<()> { |
|
| 3815 | - | self.inner.put_new(k, b) |
|
| 3816 | - | } |
|
| 3817 | - | fn list(&self, p: &str) -> crate::strata::Result<Vec<crate::strata::Entry>> { |
|
| 3818 | - | let mut out = self.inner.list(p)?; |
|
| 3819 | - | if p == "pack-" { |
|
| 3820 | - | self.lists |
|
| 3821 | - | .fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
|
| 3822 | - | if let Some(k) = self.doomed.lock().unwrap().take() { |
|
| 3823 | - | self.inner.delete(&k)?; |
|
| 3824 | - | } |
|
| 3825 | - | if self.ghost { |
|
| 3826 | - | for k in ["pack-0000.idx", "pack-0000.pack"] { |
|
| 3827 | - | out.push(crate::strata::Entry { |
|
| 3828 | - | key: k.into(), |
|
| 3829 | - | size: 100, |
|
| 3830 | - | modified_ms: None, |
|
| 3831 | - | }); |
|
| 3832 | - | } |
|
| 3833 | - | out.sort_by(|a, b| a.key.cmp(&b.key)); |
|
| 3834 | - | } |
|
| 3835 | - | } |
|
| 3836 | - | Ok(out) |
|
| 3837 | - | } |
|
| 3838 | - | fn delete(&self, k: &str) -> crate::strata::Result<()> { |
|
| 3839 | - | self.inner.delete(k) |
|
| 3840 | - | } |
|
| 3841 | - | fn read_ref( |
|
| 3842 | - | &self, |
|
| 3843 | - | k: &str, |
|
| 3844 | - | ) -> crate::strata::Result<Option<(Vec<u8>, crate::strata::Version)>> { |
|
| 3845 | - | self.inner.read_ref(k) |
|
| 3846 | - | } |
|
| 3847 | - | fn write_ref( |
|
| 3848 | - | &self, |
|
| 3849 | - | k: &str, |
|
| 3850 | - | b: &[u8], |
|
| 3851 | - | e: Option<&crate::strata::Version>, |
|
| 3852 | - | ) -> crate::strata::Result<crate::strata::Version> { |
|
| 3853 | - | self.inner.write_ref(k, b, e) |
|
| 3854 | - | } |
|
| 3855 | - | } |
|
| 3856 | - | ||
| 3857 | - | pub(crate) fn racing(ghost: bool) -> Arc<Racing> { |
|
| 3858 | - | Arc::new(Racing { |
|
| 3859 | - | inner: MemoryStore::new(), |
|
| 3860 | - | lists: Default::default(), |
|
| 3861 | - | doomed: Default::default(), |
|
| 3862 | - | ghost, |
|
| 3863 | - | }) |
|
| 3864 | - | } |
|
| 3865 | - | ||
| 3866 | - | #[test] |
|
| 3867 | - | fn an_index_deleted_between_listing_and_loading_is_passed_over() { |
|
| 3868 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 3869 | - | let backend = racing(false); |
|
| 3870 | - | let s = Store::open_on(backend.clone(), dir.path()).unwrap(); |
|
| 3871 | - | let a = s.put(ObjectType::Chunk, b"a").unwrap(); |
|
| 3872 | - | s.pack_loose().unwrap(); |
|
| 3873 | - | let b = s.put(ObjectType::Chunk, b"b").unwrap(); |
|
| 3874 | - | s.pack_loose().unwrap(); |
|
| 3875 | - | let idx = { |
|
| 3876 | - | let packs = s.packs.borrow(); |
|
| 3877 | - | let (i, _) = s.find_packed(&a).unwrap(); |
|
| 3878 | - | format!("{}.idx", packs[i].key.trim_end_matches(".pack")) |
|
| 3879 | - | }; |
|
| 3880 | - | *backend.doomed.lock().unwrap() = Some(idx); |
|
| 3881 | - | let before = backend.lists.load(std::sync::atomic::Ordering::Relaxed); |
|
| 3882 | - | let again = Store::open_on(backend.clone(), dir.path()).unwrap(); |
|
| 3883 | - | assert_eq!(again.pack_count(), 1, "the deleted one is passed over"); |
|
| 3884 | - | assert_eq!( |
|
| 3885 | - | backend.lists.load(std::sync::atomic::Ordering::Relaxed) - before, |
|
| 3886 | - | 2, |
|
| 3887 | - | "and the store listed again" |
|
| 3888 | - | ); |
|
| 3889 | - | assert_eq!(again.get(&b).unwrap().1, b"b"); |
|
| 3890 | - | // A listing that names an index never there opens too. |
|
| 3891 | - | let ghosted = racing(true); |
|
| 3892 | - | let s = Store::open_on(ghosted.clone(), dir.path().join("g")).unwrap(); |
|
| 3893 | - | let c = s.put(ObjectType::Chunk, b"c").unwrap(); |
|
| 3894 | - | s.pack_loose().unwrap(); |
|
| 3895 | - | let again = Store::open_on(ghosted, dir.path().join("g")).unwrap(); |
|
| 3896 | - | assert_eq!(again.pack_count(), 1); |
|
| 3897 | - | assert_eq!(again.get(&c).unwrap().1, b"c"); |
|
| 3898 | 2455 | } |
|
| 3899 | 2456 | } |
|
| 3900 | 2457 | ||
| … | 190 unchanged lines |
||
| 4091 | 2648 | for (r, b) in refs.iter().zip(&objects) { |
|
| 4092 | 2649 | assert_eq!(again.get(r).unwrap().1, b.1); |
|
| 4093 | 2650 | } |
|
| 4094 | - | } |
|
| 4095 | - | } |
|
| 4096 | - | ||
| 4097 | - | /// Signatures and index entries on a store that is not this disk's |
|
| 4098 | - | /// directory: the journal and the entry lists (spec §13.7, #116). |
|
| 4099 | - | #[cfg(test)] |
|
| 4100 | - | mod entry_tests { |
|
| 4101 | - | use super::*; |
|
| 4102 | - | use crate::identity::{Domain, SecretKey}; |
|
| 4103 | - | use crate::strata::Store as _; |
|
| 4104 | - | use std::collections::HashMap; |
|
| 4105 | - | use std::sync::atomic::{AtomicBool, Ordering}; |
|
| 4106 | - | use std::sync::Mutex; |
|
| 4107 | - | ||
| 4108 | - | /// A store in memory that counts its requests by kind, as a bucket |
|
| 4109 | - | /// store's /health counters do; it can also slip a competing journal |
|
| 4110 | - | /// line in just before a write of the journal, as another process |
|
| 4111 | - | /// would. |
|
| 4112 | - | #[derive(Default)] |
|
| 4113 | - | struct Counted { |
|
| 4114 | - | inner: MemoryStore, |
|
| 4115 | - | counts: Mutex<HashMap<&'static str, usize>>, |
|
| 4116 | - | interfere: AtomicBool, |
|
| 4117 | - | } |
|
| 4118 | - | ||
| 4119 | - | impl Counted { |
|
| 4120 | - | fn count(&self, kind: &'static str) { |
|
| 4121 | - | *self.counts.lock().unwrap().entry(kind).or_default() += 1; |
|
| 4122 | - | } |
|
| 4123 | - | fn n(&self, kind: &str) -> usize { |
|
| 4124 | - | self.counts.lock().unwrap().get(kind).copied().unwrap_or(0) |
|
| 4125 | - | } |
|
| 4126 | - | fn reset(&self) { |
|
| 4127 | - | self.counts.lock().unwrap().clear(); |
|
| 4128 | - | } |
|
| 4129 | - | fn total(&self) -> usize { |
|
| 4130 | - | self.counts.lock().unwrap().values().sum() |
|
| 4131 | - | } |
|
| 4132 | - | fn summary(&self) -> String { |
|
| 4133 | - | let c = self.counts.lock().unwrap(); |
|
| 4134 | - | let mut v: Vec<String> = c.iter().map(|(k, n)| format!("{k} {n}")).collect(); |
|
| 4135 | - | v.sort(); |
|
| 4136 | - | v.join(", ") |
|
| 4137 | - | } |
|
| 4138 | - | } |
|
| 4139 | - | ||
| 4140 | - | impl crate::strata::Store for Counted { |
|
| 4141 | - | fn describe(&self) -> String { |
|
| 4142 | - | "counted".into() |
|
| 4143 | - | } |
|
| 4144 | - | fn has(&self, k: &str) -> crate::strata::Result<bool> { |
|
| 4145 | - | self.count("has"); |
|
| 4146 | - | self.inner.has(k) |
|
| 4147 | - | } |
|
| 4148 | - | fn get(&self, k: &str) -> crate::strata::Result<Vec<u8>> { |
|
| 4149 | - | self.count(if is_legacy(k) { "get_legacy" } else { "get" }); |
|
| 4150 | - | self.inner.get(k) |
|
| 4151 | - | } |
|
| 4152 | - | fn get_range(&self, k: &str, r: std::ops::Range<u64>) -> crate::strata::Result<Vec<u8>> { |
|
| 4153 | - | self.count("range"); |
|
| 4154 | - | self.inner.get_range(k, r) |
|
| 4155 | - | } |
|
| 4156 | - | fn put_new(&self, k: &str, b: &[u8]) -> crate::strata::Result<()> { |
|
| 4157 | - | self.count("put"); |
|
| 4158 | - | self.inner.put_new(k, b) |
|
| 4159 | - | } |
|
| 4160 | - | fn list(&self, p: &str) -> crate::strata::Result<Vec<crate::strata::Entry>> { |
|
| 4161 | - | self.count(if p == "pack-" { |
|
| 4162 | - | "list_packs" |
|
| 4163 | - | } else if is_legacy(p) { |
|
| 4164 | - | "list_legacy" |
|
| 4165 | - | } else { |
|
| 4166 | - | "list" |
|
| 4167 | - | }); |
|
| 4168 | - | self.inner.list(p) |
|
| 4169 | - | } |
|
| 4170 | - | fn delete(&self, k: &str) -> crate::strata::Result<()> { |
|
| 4171 | - | self.count("delete"); |
|
| 4172 | - | self.inner.delete(k) |
|
| 4173 | - | } |
|
| 4174 | - | fn read_ref( |
|
| 4175 | - | &self, |
|
| 4176 | - | k: &str, |
|
| 4177 | - | ) -> crate::strata::Result<Option<(Vec<u8>, crate::strata::Version)>> { |
|
| 4178 | - | self.count(if k == JOURNAL_KEY { |
|
| 4179 | - | "read_journal" |
|
| 4180 | - | } else { |
|
| 4181 | - | "read_ref" |
|
| 4182 | - | }); |
|
| 4183 | - | self.inner.read_ref(k) |
|
| 4184 | - | } |
|
| 4185 | - | fn write_ref( |
|
| 4186 | - | &self, |
|
| 4187 | - | k: &str, |
|
| 4188 | - | b: &[u8], |
|
| 4189 | - | e: Option<&crate::strata::Version>, |
|
| 4190 | - | ) -> crate::strata::Result<crate::strata::Version> { |
|
| 4191 | - | self.count("write_ref"); |
|
| 4192 | - | if k == JOURNAL_KEY && self.interfere.swap(false, Ordering::SeqCst) { |
|
| 4193 | - | let (mut m, v) = match self.inner.read_ref(k)? { |
|
| 4194 | - | Some((bytes, v)) => (parse_retired(&bytes), Some(v)), |
|
| 4195 | - | None => (Default::default(), None), |
|
| 4196 | - | }; |
|
| 4197 | - | m.insert( |
|
| 4198 | - | format!("seal/{}", "c".repeat(64)) + "/" + &"d".repeat(64), |
|
| 4199 | - | 0, |
|
| 4200 | - | ); |
|
| 4201 | - | self.inner.write_ref(k, &encode_retired(&m), v.as_ref())?; |
|
| 4202 | - | } |
|
| 4203 | - | self.inner.write_ref(k, b, e) |
|
| 4204 | - | } |
|
| 4205 | - | } |
|
| 4206 | - | ||
| 4207 | - | /// Whether a key or prefix is under an older one-list layout. |
|
| 4208 | - | fn is_legacy(k: &str) -> bool { |
|
| 4209 | - | ENTRY_INDEXES |
|
| 4210 | - | .iter() |
|
| 4211 | - | .any(|(_, legacy)| k.starts_with(&format!("{legacy}/"))) |
|
| 4212 | - | } |
|
| 4213 | - | ||
| 4214 | - | struct Fixture { |
|
| 4215 | - | backend: Arc<Counted>, |
|
| 4216 | - | dir: tempfile::TempDir, |
|
| 4217 | - | handles: std::cell::Cell<usize>, |
|
| 4218 | - | } |
|
| 4219 | - | ||
| 4220 | - | impl Fixture { |
|
| 4221 | - | fn new() -> Fixture { |
|
| 4222 | - | Fixture { |
|
| 4223 | - | backend: Arc::new(Counted::default()), |
|
| 4224 | - | dir: tempfile::tempdir().unwrap(), |
|
| 4225 | - | handles: Default::default(), |
|
| 4226 | - | } |
|
| 4227 | - | } |
|
| 4228 | - | /// Another handle on the store, as another process opens it. |
|
| 4229 | - | fn open(&self) -> Store { |
|
| 4230 | - | let n = self.handles.get() + 1; |
|
| 4231 | - | self.handles.set(n); |
|
| 4232 | - | Store::open_on(self.backend.clone(), self.dir.path().join(format!("s{n}"))).unwrap() |
|
| 4233 | - | } |
|
| 4234 | - | } |
|
| 4235 | - | ||
| 4236 | - | fn signer(n: u8) -> SecretKey { |
|
| 4237 | - | SecretKey { |
|
| 4238 | - | ed_seed: [n; 32], |
|
| 4239 | - | ml_seed: [n + 100; 32], |
|
| 4240 | - | } |
|
| 4241 | - | } |
|
| 4242 | - | ||
| 4243 | - | fn sign(s: &Store, r: &ObjectRef, n: u8) -> Signature { |
|
| 4244 | - | let sk = signer(n); |
|
| 4245 | - | let key = s.put_identity(&sk.identity("a", "b", 0)).unwrap(); |
|
| 4246 | - | let sig = sk.sign(Domain::Change, &key, r); |
|
| 4247 | - | s.put_signature(r, &sig).unwrap(); |
|
| 4248 | - | sig |
|
| 4249 | - | } |
|
| 4250 | - | ||
| 4251 | - | fn journal(s: &Store) -> std::collections::BTreeMap<String, u64> { |
|
| 4252 | - | s.read_record(JOURNAL_KEY).unwrap().0 |
|
| 4253 | - | } |
|
| 4254 | - | ||
| 4255 | - | fn fold(s: &Store) -> bool { |
|
| 4256 | - | s.fold_entries(&mut silent()).unwrap() |
|
| 4257 | - | } |
|
| 4258 | - | ||
| 4259 | - | /// What 0.4.6 writes for a signature: its own key, and nothing else. |
|
| 4260 | - | fn old_writer_signs(f: &Fixture, r: &ObjectRef, n: u8) -> Signature { |
|
| 4261 | - | let sk = signer(n); |
|
| 4262 | - | let sig = sk.sign(Domain::Change, &ObjectRef([n; 32]), r); |
|
| 4263 | - | f.backend |
|
| 4264 | - | .put_new( |
|
| 4265 | - | &format!("{}{}", fanout_prefix("sig", r), sig.key.to_hex()), |
|
| 4266 | - | &Signature::encode_list(std::slice::from_ref(&sig)), |
|
| 4267 | - | ) |
|
| 4268 | - | .unwrap(); |
|
| 4269 | - | sig |
|
| 4270 | - | } |
|
| 4271 | - | ||
| 4272 | - | /// After a fold, a signature or seal another handle writes is found |
|
| 4273 | - | /// by a store opened later through the journal, with no listing; a |
|
| 4274 | - | /// handle that already read the entries sees it once refreshed. |
|
| 4275 | - | #[test] |
|
| 4276 | - | fn an_entry_written_after_a_fold_is_found_through_the_journal() { |
|
| 4277 | - | let f = Fixture::new(); |
|
| 4278 | - | let w = f.open(); |
|
| 4279 | - | let r1 = w.put(ObjectType::Chunk, b"one").unwrap(); |
|
| 4280 | - | let s1 = sign(&w, &r1, 1); |
|
| 4281 | - | assert!(fold(&w)); |
|
| 4282 | - | assert!(!fold(&w), "nothing new: no second list"); |
|
| 4283 | - | ||
| 4284 | - | let early = f.open(); |
|
| 4285 | - | assert_eq!(early.signatures(&r1).unwrap(), vec![s1.clone()]); |
|
| 4286 | - | ||
| 4287 | - | let w2 = f.open(); |
|
| 4288 | - | let r2 = w2.put(ObjectType::Chunk, b"two").unwrap(); |
|
| 4289 | - | let s2 = sign(&w2, &r2, 2); |
|
| 4290 | - | w2.index_seal(&r2, &ObjectRef([9; 32])).unwrap(); |
|
| 4291 | - | w2.index_receipt(&r2, &ObjectRef([8; 32])).unwrap(); |
|
| 4292 | - | // Its own writes, at once. |
|
| 4293 | - | assert_eq!(w2.signatures(&r2).unwrap(), vec![s2.clone()]); |
|
| 4294 | - | ||
| 4295 | - | let later = f.open(); |
|
| 4296 | - | f.backend.reset(); |
|
| 4297 | - | assert_eq!(later.signatures(&r1).unwrap(), vec![s1]); |
|
| 4298 | - | assert_eq!(later.signatures(&r2).unwrap(), vec![s2.clone()]); |
|
| 4299 | - | assert_eq!(later.seals_for(&r2).unwrap(), vec![ObjectRef([9; 32])]); |
|
| 4300 | - | assert_eq!(later.receipts_for(&r2).unwrap(), vec![ObjectRef([8; 32])]); |
|
| 4301 | - | assert_eq!(later.receipt_states().unwrap(), vec![r2]); |
|
| 4302 | - | assert!(later.seals_for(&r1).unwrap().is_empty()); |
|
| 4303 | - | assert_eq!(f.backend.n("list"), 0, "{}", f.backend.summary()); |
|
| 4304 | - | assert_eq!(f.backend.n("read_journal"), 1, "{}", f.backend.summary()); |
|
| 4305 | - | ||
| 4306 | - | // The handle that read the entries before keeps its view until |
|
| 4307 | - | // it is refreshed, as it keeps its operation head. |
|
| 4308 | - | assert!(early.signatures(&r2).unwrap().is_empty()); |
|
| 4309 | - | early.refresh().unwrap(); |
|
| 4310 | - | assert_eq!(early.signatures(&r2).unwrap(), vec![s2]); |
|
| 4311 | - | } |
|
| 4312 | - | ||
| 4313 | - | /// A fold marks the journal's lines rather than dropping them, so a |
|
| 4314 | - | /// reader whose listing predates it still finds every entry; an hour |
|
| 4315 | - | /// on, the old list is deleted and the lines dropped, and readers |
|
| 4316 | - | /// find everything in the new list. |
|
| 4317 | - | #[test] |
|
| 4318 | - | fn a_fold_keeps_the_journal_for_the_hour_readers_may_need_it() { |
|
| 4319 | - | let f = Fixture::new(); |
|
| 4320 | - | let w = f.open(); |
|
| 4321 | - | let r1 = w.put(ObjectType::Chunk, b"one").unwrap(); |
|
| 4322 | - | let s1 = sign(&w, &r1, 1); |
|
| 4323 | - | assert!(fold(&w)); |
|
| 4324 | - | let r2 = w.put(ObjectType::Chunk, b"two").unwrap(); |
|
| 4325 | - | let s2 = sign(&w, &r2, 2); |
|
| 4326 | - | assert!(journal(&w).values().any(|t| *t == 0)); |
|
| 4327 | - | ||
| 4328 | - | // Opened before the second fold: lists the first list only. |
|
| 4329 | - | let stale = f.open(); |
|
| 4330 | - | let stale_after_expiry = f.open(); |
|
| 4331 | - | assert!(fold(&w)); |
|
| 4332 | - | assert!( |
|
| 4333 | - | journal(&w).values().all(|t| *t != 0), |
|
| 4334 | - | "every line is marked folded" |
|
| 4335 | - | ); |
|
| 4336 | - | assert!(journal(&w).len() >= 2, "and kept"); |
|
| 4337 | - | assert_eq!(stale.signatures(&r1).unwrap(), vec![s1.clone()]); |
|
| 4338 | - | assert_eq!(stale.signatures(&r2).unwrap(), vec![s2.clone()]); |
|
| 4339 | - | ||
| 4340 | - | // An hour later: the first list goes with the next collection, |
|
| 4341 | - | // and the next journal update drops the folded lines. |
|
| 4342 | - | reader_tests::age_retired(&w, RETIRED_FOR * 2); |
|
| 4343 | - | w.update_record(JOURNAL_KEY, "test", |m| { |
|
| 4344 | - | for t in m.values_mut() { |
|
| 4345 | - | *t = t.saturating_sub(2 * RETIRED_FOR.as_millis() as u64); |
|
| 4346 | - | } |
|
| 4347 | - | }) |
|
| 4348 | - | .unwrap(); |
|
| 4349 | - | w.expire_retired(RETIRED_FOR, &mut silent()).unwrap(); |
|
| 4350 | - | let lists: Vec<String> = f |
|
| 4351 | - | .backend |
|
| 4352 | - | .inner |
|
| 4353 | - | .list("pack-") |
|
| 4354 | - | .unwrap() |
|
| 4355 | - | .into_iter() |
|
| 4356 | - | .map(|e| e.key) |
|
| 4357 | - | .filter(|k| is_entry_list(k)) |
|
| 4358 | - | .collect(); |
|
| 4359 | - | assert_eq!(lists.len(), 1, "the replaced list is deleted: {lists:?}"); |
|
| 4360 | - | let r3 = w.put(ObjectType::Chunk, b"three").unwrap(); |
|
| 4361 | - | let s3 = sign(&w, &r3, 3); |
|
| 4362 | - | let j = journal(&w); |
|
| 4363 | - | assert_eq!(j.len(), 1, "only the new line is left: {j:?}"); |
|
| 4364 | - | assert!(j.keys().all(|k| k.starts_with(&fanout_prefix("sig", &r3)))); |
|
| 4365 | - | ||
| 4366 | - | // A handle whose listing named the deleted list lists again. |
|
| 4367 | - | assert_eq!( |
|
| 4368 | - | stale_after_expiry.signatures(&r1).unwrap(), |
|
| 4369 | - | vec![s1.clone()] |
|
| 4370 | - | ); |
|
| 4371 | - | assert_eq!( |
|
| 4372 | - | stale_after_expiry.signatures(&r3).unwrap(), |
|
| 4373 | - | vec![s3.clone()] |
|
| 4374 | - | ); |
|
| 4375 | - | let fresh = f.open(); |
|
| 4376 | - | f.backend.reset(); |
|
| 4377 | - | assert_eq!(fresh.signatures(&r1).unwrap(), vec![s1]); |
|
| 4378 | - | assert_eq!(fresh.signatures(&r2).unwrap(), vec![s2]); |
|
| 4379 | - | assert_eq!(fresh.signatures(&r3).unwrap(), vec![s3]); |
|
| 4380 | - | assert_eq!(f.backend.n("list"), 0); |
|
| 4381 | - | } |
|
| 4382 | - | ||
| 4383 | - | /// A store no fold has touched (as 0.4.6 left it: per-object keys and |
|
| 4384 | - | /// older one-list keys, no journal, no lists) reads by listing, and |
|
| 4385 | - | /// reads the same once folded. An older writer's entry after the |
|
| 4386 | - | /// fold is missed until the next fold: the trade-off of §13.7. |
|
| 4387 | - | #[test] |
|
| 4388 | - | fn an_old_layout_store_reads_before_and_after_its_first_fold() { |
|
| 4389 | - | let f = Fixture::new(); |
|
| 4390 | - | let w = f.open(); |
|
| 4391 | - | let r1 = w.put(ObjectType::Chunk, b"one").unwrap(); |
|
| 4392 | - | let r2 = w.put(ObjectType::Chunk, b"two").unwrap(); |
|
| 4393 | - | let s1 = old_writer_signs(&f, &r1, 1); |
|
| 4394 | - | let s2 = SecretKey { |
|
| 4395 | - | ed_seed: [2; 32], |
|
| 4396 | - | ml_seed: [3; 32], |
|
| 4397 | - | } |
|
| 4398 | - | .sign(Domain::Change, &ObjectRef([2; 32]), &r2); |
|
| 4399 | - | f.backend |
|
| 4400 | - | .put_new( |
|
| 4401 | - | &legacy_key("sigs", &r2), |
|
| 4402 | - | &Signature::encode_list(std::slice::from_ref(&s2)), |
|
| 4403 | - | ) |
|
| 4404 | - | .unwrap(); |
|
| 4405 | - | f.backend |
|
| 4406 | - | .put_new( |
|
| 4407 | - | &format!("{}{}", fanout_prefix("seal", &r1), "ab".repeat(32)), |
|
| 4408 | - | b"", |
|
| 4409 | - | ) |
|
| 4410 | - | .unwrap(); |
|
| 4411 | - | f.backend |
|
| 4412 | - | .put_new( |
|
| 4413 | - | &legacy_key("receipts", &r2), |
|
| 4414 | - | format!("{}\n", ObjectRef([7; 32]).to_hex()).as_bytes(), |
|
| 4415 | - | ) |
|
| 4416 | - | .unwrap(); |
|
| 4417 | - | let check = |s: &Store| { |
|
| 4418 | - | assert_eq!(s.signatures(&r1).unwrap(), vec![s1.clone()]); |
|
| 4419 | - | assert_eq!(s.signatures(&r2).unwrap(), vec![s2.clone()]); |
|
| 4420 | - | assert_eq!(s.seals_for(&r1).unwrap(), vec![ObjectRef([0xab; 32])]); |
|
| 4421 | - | assert_eq!(s.receipts_for(&r2).unwrap(), vec![ObjectRef([7; 32])]); |
|
| 4422 | - | assert_eq!(s.receipt_states().unwrap(), vec![r2]); |
|
| 4423 | - | }; |
|
| 4424 | - | let reader = f.open(); |
|
| 4425 | - | assert!(!reader.folded.get()); |
|
| 4426 | - | check(&reader); |
|
| 4427 | - | assert!(f.backend.inner.read_ref(JOURNAL_KEY).unwrap().is_none()); |
|
| 4428 | - | ||
| 4429 | - | assert!(fold(&w)); |
|
| 4430 | - | let reader = f.open(); |
|
| 4431 | - | assert!(reader.folded.get()); |
|
| 4432 | - | check(&reader); |
|
| 4433 | - | ||
| 4434 | - | // The older lists' entries are per-object entries now, and the |
|
| 4435 | - | // marker is there. |
|
| 4436 | - | assert!(f.backend.inner.has(LEGACY_FOLDED_KEY).unwrap()); |
|
| 4437 | - | assert!(f |
|
| 4438 | - | .backend |
|
| 4439 | - | .inner |
|
| 4440 | - | .has(&format!("{}{}", fanout_prefix("sig", &r2), s2.key.to_hex())) |
|
| 4441 | - | .unwrap()); |
|
| 4442 | - | assert!(f |
|
| 4443 | - | .backend |
|
| 4444 | - | .inner |
|
| 4445 | - | .has(&format!( |
|
| 4446 | - | "{}{}", |
|
| 4447 | - | fanout_prefix("receipt", &r2), |
|
| 4448 | - | ObjectRef([7; 32]).to_hex() |
|
| 4449 | - | )) |
|
| 4450 | - | .unwrap()); |
|
| 4451 | - | ||
| 4452 | - | // An older writer after the fold: missed until the next one. |
|
| 4453 | - | let r3 = w.put(ObjectType::Chunk, b"three").unwrap(); |
|
| 4454 | - | let s3 = old_writer_signs(&f, &r3, 3); |
|
| 4455 | - | assert!(f.open().signatures(&r3).unwrap().is_empty()); |
|
| 4456 | - | assert!(fold(&w)); |
|
| 4457 | - | assert_eq!(f.open().signatures(&r3).unwrap(), vec![s3]); |
|
| 4458 | - | } |
|
| 4459 | - | ||
| 4460 | - | /// Writers racing on the journal never lose a line: a lost |
|
| 4461 | - | /// compare-and-swap reads the journal again and retries. |
|
| 4462 | - | #[test] |
|
| 4463 | - | fn concurrent_journal_appends_lose_nothing() { |
|
| 4464 | - | let f = Fixture::new(); |
|
| 4465 | - | // One race, made to happen. |
|
| 4466 | - | let w = f.open(); |
|
| 4467 | - | f.backend.interfere.store(true, Ordering::SeqCst); |
|
| 4468 | - | w.index_seal(&ObjectRef([1; 32]), &ObjectRef([2; 32])) |
|
| 4469 | - | .unwrap(); |
|
| 4470 | - | let j = journal(&w); |
|
| 4471 | - | assert_eq!(j.len(), 2, "the competitor's line and ours: {j:?}"); |
|
| 4472 | - | // Many, from threads, each its own handle. |
|
| 4473 | - | let backend: Arc<Backend> = f.backend.clone(); |
|
| 4474 | - | std::thread::scope(|scope| { |
|
| 4475 | - | for t in 0..8u8 { |
|
| 4476 | - | let backend = backend.clone(); |
|
| 4477 | - | let dir = f.dir.path().join(format!("t{t}")); |
|
| 4478 | - | scope.spawn(move || { |
|
| 4479 | - | let s = Store::open_on(backend, dir).unwrap(); |
|
| 4480 | - | for i in 0..25u8 { |
|
| 4481 | - | s.index_seal(&ObjectRef([t + 10; 32]), &ObjectRef([i; 32])) |
|
| 4482 | - | .unwrap(); |
|
| 4483 | - | } |
|
| 4484 | - | }); |
|
| 4485 | - | } |
|
| 4486 | - | }); |
|
| 4487 | - | assert_eq!(journal(&w).len(), 2 + 8 * 25); |
|
| 4488 | - | assert!(fold(&w)); |
|
| 4489 | - | let r = f.open(); |
|
| 4490 | - | for t in 0..8u8 { |
|
| 4491 | - | assert_eq!(r.seals_for(&ObjectRef([t + 10; 32])).unwrap().len(), 25); |
|
| 4492 | - | } |
|
| 4493 | - | } |
|
| 4494 | - | ||
| 4495 | - | /// A batch adds its entries to the journal in one update when it is |
|
| 4496 | - | /// flushed; past the limit the writer folds them into a partial list. |
|
| 4497 | - | #[test] |
|
| 4498 | - | fn a_long_journal_is_folded_into_a_partial_list() { |
|
| 4499 | - | let f = Fixture::new(); |
|
| 4500 | - | let w = f.open(); |
|
| 4501 | - | assert!(fold(&w), "folded, with nothing yet"); |
|
| 4502 | - | let key = ObjectRef([5; 32]); |
|
| 4503 | - | f.backend.reset(); |
|
| 4504 | - | let batch = w.begin_batch().unwrap(); |
|
| 4505 | - | for i in 0..=JOURNAL_FOLD_AT as u32 { |
|
| 4506 | - | let mut v = [0u8; 32]; |
|
| 4507 | - | v[..4].copy_from_slice(&i.to_le_bytes()); |
|
| 4508 | - | w.index_seal(&key, &ObjectRef(v)).unwrap(); |
|
| 4509 | - | } |
|
| 4510 | - | assert_eq!(f.backend.n("write_ref"), 0, "nothing until the flush"); |
|
| 4511 | - | batch.finish().unwrap(); |
|
| 4512 | - | let parts = f |
|
| 4513 | - | .backend |
|
| 4514 | - | .inner |
|
| 4515 | - | .list("pack-") |
|
| 4516 | - | .unwrap() |
|
| 4517 | - | .into_iter() |
|
| 4518 | - | .filter(|e| e.key.ends_with(PART_SUFFIX)) |
|
| 4519 | - | .count(); |
|
| 4520 | - | assert_eq!(parts, 1); |
|
| 4521 | - | assert!(journal(&w).values().all(|t| *t != 0), "all folded"); |
|
| 4522 | - | let r = f.open(); |
|
| 4523 | - | assert_eq!(r.seals_for(&key).unwrap().len(), JOURNAL_FOLD_AT + 1); |
|
| 4524 | - | // The next complete fold takes the partial list in. |
|
| 4525 | - | assert!(fold(&w)); |
|
| 4526 | - | let lists: Vec<String> = f.open().entry_lists.borrow().clone(); |
|
| 4527 | - | assert_eq!(lists.len(), 1); |
|
| 4528 | - | assert!(lists[0].ends_with(LIST_SUFFIX)); |
|
| 4529 | - | assert_eq!(f.open().seals_for(&key).unwrap().len(), JOURNAL_FOLD_AT + 1); |
|
| 4530 | - | } |
|
| 4531 | - | ||
| 4532 | - | /// A collection that deletes an object takes its entries out of the |
|
| 4533 | - | /// journal too, so the next fold leaves them out. |
|
| 4534 | - | #[test] |
|
| 4535 | - | fn pruned_objects_leave_the_journal() { |
|
| 4536 | - | let f = Fixture::new(); |
|
| 4537 | - | let w = f.open(); |
|
| 4538 | - | let gone = w.put(ObjectType::Chunk, b"gone").unwrap(); |
|
| 4539 | - | let kept = w.put(ObjectType::Chunk, b"kept").unwrap(); |
|
| 4540 | - | sign(&w, &gone, 1); |
|
| 4541 | - | let s = sign(&w, &kept, 2); |
|
| 4542 | - | assert!(fold(&w)); |
|
| 4543 | - | let keep: std::collections::HashSet<ObjectRef> = w |
|
| 4544 | - | .list() |
|
| 4545 | - | .unwrap() |
|
| 4546 | - | .into_iter() |
|
| 4547 | - | .filter(|r| *r != gone) |
|
| 4548 | - | .collect(); |
|
| 4549 | - | w.prune(&keep, std::time::Duration::ZERO).unwrap(); |
|
| 4550 | - | assert!(journal(&w).keys().all(|k| !k.contains(&gone.to_hex()[2..]))); |
|
| 4551 | - | fold(&w); |
|
| 4552 | - | let r = f.open(); |
|
| 4553 | - | assert!(r.signatures(&gone).unwrap().is_empty()); |
|
| 4554 | - | assert_eq!(r.signatures(&kept).unwrap(), vec![s]); |
|
| 4555 | - | let all = r.with_entries(|e| e.clone()).unwrap().unwrap(); |
|
| 4556 | - | assert!( |
|
| 4557 | - | all.iter().all(|k| !k.contains(&gone.to_hex()[2..])), |
|
| 4558 | - | "{all:?}" |
|
| 4559 | - | ); |
|
| 4560 | - | } |
|
| 4561 | - | ||
| 4562 | - | /// Requests per lookup, before and after the fold (#116): twenty |
|
| 4563 | - | /// objects, five of them signed, each looked up for its signatures |
|
| 4564 | - | /// and its seals, as a fetch does. |
|
| 4565 | - | #[test] |
|
| 4566 | - | fn a_folded_store_looks_up_entries_without_listing() { |
|
| 4567 | - | let f = Fixture::new(); |
|
| 4568 | - | let w = f.open(); |
|
| 4569 | - | let refs: Vec<ObjectRef> = (0..20) |
|
| 4570 | - | .map(|i| { |
|
| 4571 | - | w.put(ObjectType::Chunk, format!("o {i}").as_bytes()) |
|
| 4572 | - | .unwrap() |
|
| 4573 | - | }) |
|
| 4574 | - | .collect(); |
|
| 4575 | - | for r in &refs[..5] { |
|
| 4576 | - | sign(&w, r, 1); |
|
| 4577 | - | } |
|
| 4578 | - | let lookups = |s: &Store| { |
|
| 4579 | - | for r in &refs { |
|
| 4580 | - | s.signatures(r).unwrap(); |
|
| 4581 | - | s.seals_for(r).unwrap(); |
|
| 4582 | - | } |
|
| 4583 | - | }; |
|
| 4584 | - | let before = f.open(); |
|
| 4585 | - | f.backend.reset(); |
|
| 4586 | - | lookups(&before); |
|
| 4587 | - | let (before_total, before_list) = (f.backend.total(), f.backend.n("list")); |
|
| 4588 | - | eprintln!("40 lookups before the fold: {}", f.backend.summary()); |
|
| 4589 | - | ||
| 4590 | - | assert!(fold(&w)); |
|
| 4591 | - | let after = f.open(); |
|
| 4592 | - | f.backend.reset(); |
|
| 4593 | - | lookups(&after); |
|
| 4594 | - | eprintln!("40 lookups after the fold: {}", f.backend.summary()); |
|
| 4595 | - | assert_eq!(before_list, 40); |
|
| 4596 | - | assert_eq!(f.backend.n("list"), 0); |
|
| 4597 | - | assert_eq!(f.backend.n("get_legacy"), 0, "the older lists are folded"); |
|
| 4598 | - | assert_eq!(f.backend.n("read_journal"), 1); |
|
| 4599 | - | assert!( |
|
| 4600 | - | f.backend.total() < before_total / 10, |
|
| 4601 | - | "{}", |
|
| 4602 | - | f.backend.summary() |
|
| 4603 | - | ); |
|
| 4604 | - | } |
|
| 4605 | - | ||
| 4606 | - | /// Once a fold has copied the older lists into per-object entries and |
|
| 4607 | - | /// written its marker, readers stop reading them, folded or not; the |
|
| 4608 | - | /// answers are the same. |
|
| 4609 | - | #[test] |
|
| 4610 | - | fn the_legacy_marker_stops_reads_of_the_older_lists() { |
|
| 4611 | - | let f = Fixture::new(); |
|
| 4612 | - | let w = f.open(); |
|
| 4613 | - | let r = w.put(ObjectType::Chunk, b"old").unwrap(); |
|
| 4614 | - | let sig = SecretKey { |
|
| 4615 | - | ed_seed: [4; 32], |
|
| 4616 | - | ml_seed: [5; 32], |
|
| 4617 | - | } |
|
| 4618 | - | .sign(Domain::Change, &ObjectRef([4; 32]), &r); |
|
| 4619 | - | f.backend |
|
| 4620 | - | .put_new( |
|
| 4621 | - | &legacy_key("sigs", &r), |
|
| 4622 | - | &Signature::encode_list(std::slice::from_ref(&sig)), |
|
| 4623 | - | ) |
|
| 4624 | - | .unwrap(); |
|
| 4625 | - | for (legacy, v) in [("seals", 9u8), ("certs", 8), ("accepted-lists", 6)] { |
|
| 4626 | - | f.backend |
|
| 4627 | - | .put_new( |
|
| 4628 | - | &legacy_key(legacy, &r), |
|
| 4629 | - | format!("{}\n", ObjectRef([v; 32]).to_hex()).as_bytes(), |
|
| 4630 | - | ) |
|
| 4631 | - | .unwrap(); |
|
| 4632 | - | } |
|
| 4633 | - | let check = |s: &Store| { |
|
| 4634 | - | assert_eq!(s.signatures(&r).unwrap(), vec![sig.clone()]); |
|
| 4635 | - | assert_eq!(s.seals_for(&r).unwrap(), vec![ObjectRef([9; 32])]); |
|
| 4636 | - | assert_eq!( |
|
| 4637 | - | s.index_get("cert", "certs", &r).unwrap(), |
|
| 4638 | - | vec![ObjectRef([8; 32])] |
|
| 4639 | - | ); |
|
| 4640 | - | assert_eq!( |
|
| 4641 | - | s.index_get_pub("accepted", &r).unwrap(), |
|
| 4642 | - | vec![ObjectRef([6; 32])] |
|
| 4643 | - | ); |
|
| 4644 | - | }; |
|
| 4645 | - | let before = f.open(); |
|
| 4646 | - | f.backend.reset(); |
|
| 4647 | - | check(&before); |
|
| 4648 | - | assert_eq!(f.backend.n("get_legacy"), 4, "{}", f.backend.summary()); |
|
| 4649 | - | ||
| 4650 | - | assert!(fold(&w)); |
|
| 4651 | - | let folded = f.open(); |
|
| 4652 | - | f.backend.reset(); |
|
| 4653 | - | check(&folded); |
|
| 4654 | - | assert_eq!(f.backend.n("get_legacy") + f.backend.n("list"), 0); |
|
| 4655 | - | ||
| 4656 | - | // Not folded (its lists gone), but marked: per-object listings, |
|
| 4657 | - | // still no older lists. |
|
| 4658 | - | for e in f.backend.inner.list("pack-").unwrap() { |
|
| 4659 | - | if is_entry_list(&e.key) { |
|
| 4660 | - | f.backend.inner.delete(&e.key).unwrap(); |
|
| 4661 | - | } |
|
| 4662 | - | } |
|
| 4663 | - | let marked = f.open(); |
|
| 4664 | - | assert!(!marked.folded.get() && marked.legacy_folded.get()); |
|
| 4665 | - | f.backend.reset(); |
|
| 4666 | - | check(&marked); |
|
| 4667 | - | assert_eq!(f.backend.n("get_legacy"), 0, "{}", f.backend.summary()); |
|
| 4668 | - | assert_eq!(f.backend.n("list"), 4); |
|
| 4669 | - | } |
|
| 4670 | - | ||
| 4671 | - | /// A handle that has read the entries finds another process's new |
|
| 4672 | - | /// signature once the pack listing's interval has passed, with no |
|
| 4673 | - | /// refresh, at the cost of one pack listing and one journal read. |
|
| 4674 | - | #[test] |
|
| 4675 | - | fn a_long_lived_handle_sees_new_entries_within_the_interval() { |
|
| 4676 | - | let f = Fixture::new(); |
|
| 4677 | - | let w = f.open(); |
|
| 4678 | - | let r1 = w.put(ObjectType::Chunk, b"one").unwrap(); |
|
| 4679 | - | let s1 = sign(&w, &r1, 1); |
|
| 4680 | - | assert!(fold(&w)); |
|
| 4681 | - | let a = f.open(); |
|
| 4682 | - | assert_eq!(a.signatures(&r1).unwrap(), vec![s1]); |
|
| 4683 | - | ||
| 4684 | - | let b = f.open(); |
|
| 4685 | - | let r2 = b.put(ObjectType::Chunk, b"two").unwrap(); |
|
| 4686 | - | let s2 = sign(&b, &r2, 2); |
|
| 4687 | - | b.index_approval(&r2, &ObjectRef([3; 32])).unwrap(); |
|
| 4688 | - | // Within the interval: answered from what was read. |
|
| 4689 | - | assert!(a.signatures(&r2).unwrap().is_empty()); |
|
| 4690 | - | ||
| 4691 | - | // The interval passes, as the pack listing's does. |
|
| 4692 | - | let t = a.entries_read.get().unwrap(); |
|
| 4693 | - | a.entries_read.set(t.checked_sub(ENTRIES_FRESH)); |
|
| 4694 | - | f.backend.reset(); |
|
| 4695 | - | assert_eq!(a.signatures(&r2).unwrap(), vec![s2]); |
|
| 4696 | - | assert_eq!(a.approvals_for(&r2).unwrap(), vec![ObjectRef([3; 32])]); |
|
| 4697 | - | assert_eq!(f.backend.n("read_journal"), 1, "{}", f.backend.summary()); |
|
| 4698 | - | assert_eq!(f.backend.n("list_packs"), 1, "{}", f.backend.summary()); |
|
| 4699 | - | assert_eq!(f.backend.n("list"), 0, "{}", f.backend.summary()); |
|
| 4700 | - | assert_eq!( |
|
| 4701 | - | f.backend.n("get"), |
|
| 4702 | - | 1, |
|
| 4703 | - | "the signature; the list already read is not read again: {}", |
|
| 4704 | - | f.backend.summary() |
|
| 4705 | - | ); |
|
| 4706 | 2651 | } |
|
| 4707 | 2652 | } |
modified crates/oxy-core/src/strata.rs +7 −163
| … | 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 | - | ||
| 337 | 297 | impl LocalStore { |
|
| 338 | 298 | /// Link `tmp` into `path`, create-only; the directory is synced and |
|
| 339 | 299 | /// `tmp` removed either way. `Exists` when the name is taken. |
|
| 340 | 300 | fn link_into_place(&self, key: &str, tmp: &Path, path: &Path) -> Result<()> { |
|
| 341 | - | let linked = place_new(tmp, path); |
|
| 301 | + | let linked = fs::hard_link(tmp, path); |
|
| 342 | 302 | let _ = fs::remove_file(tmp); |
|
| 343 | 303 | match linked { |
|
| 344 | 304 | Ok(()) => { |
|
| … | 78 unchanged lines |
||
| 423 | 383 | crate::durable::step("rename loose objects")?; |
|
| 424 | 384 | let mut dirs: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new(); |
|
| 425 | 385 | for (path, tmp, _) in &todo { |
|
| 426 | - | let linked = place_new(tmp, path); |
|
| 386 | + | let linked = fs::hard_link(tmp, path); |
|
| 427 | 387 | let _ = fs::remove_file(tmp); |
|
| 428 | 388 | match linked { |
|
| 429 | 389 | Ok(()) => { |
|
| … | 19 unchanged lines |
||
| 449 | 409 | return Err(Error::Exists(key.to_string())); |
|
| 450 | 410 | } |
|
| 451 | 411 | self.ensure_dir(&path)?; |
|
| 452 | - | match place_new(file, &path) { |
|
| 412 | + | match fs::hard_link(file, &path) { |
|
| 453 | 413 | Ok(()) => { |
|
| 454 | 414 | let _ = fs::remove_file(file); |
|
| 455 | 415 | if let Some(dir) = path.parent() { |
|
| … | 261 unchanged lines |
||
| 717 | 677 | used: Mutex<Option<u64>>, |
|
| 718 | 678 | } |
|
| 719 | 679 | ||
| 720 | - | /// Where a caching store keeps the ranges it read of objects too large to |
|
| 721 | - | /// keep whole, and those objects' sizes: apart from every object key. |
|
| 722 | - | const RANGES: &str = "cached-range"; |
|
| 723 | - | const SIZES: &str = "cached-size"; |
|
| 724 | - | ||
| 725 | 680 | /// How stale a cached object's use time may get before a read renews it. |
|
| 726 | 681 | const TOUCH_AFTER: Duration = Duration::from_secs(60); |
|
| 727 | 682 | ||
| … | 72 unchanged lines |
||
| 800 | 755 | } |
|
| 801 | 756 | *self.used.lock().unwrap_or_else(|p| p.into_inner()) = Some(left); |
|
| 802 | 757 | Ok(()) |
|
| 803 | - | } |
|
| 804 | - | ||
| 805 | - | /// An object's size: kept on disk once the remote has said it (objects |
|
| 806 | - | /// never change), so it is asked once. `None` when there is no object. |
|
| 807 | - | fn size_of(&self, key: &str) -> Result<Option<u64>> { |
|
| 808 | - | let skey = format!("{SIZES}/{key}"); |
|
| 809 | - | if let Some(n) = self |
|
| 810 | - | .cache |
|
| 811 | - | .get(&skey) |
|
| 812 | - | .ok() |
|
| 813 | - | .and_then(|b| String::from_utf8(b).ok()) |
|
| 814 | - | .and_then(|t| t.parse().ok()) |
|
| 815 | - | { |
|
| 816 | - | return Ok(Some(n)); |
|
| 817 | - | } |
|
| 818 | - | let size = self.remote.stat(key)?.map(|e| e.size); |
|
| 819 | - | if let Some(n) = size { |
|
| 820 | - | self.keep(&skey, n.to_string().as_bytes())?; |
|
| 821 | - | } |
|
| 822 | - | Ok(size) |
|
| 823 | 758 | } |
|
| 824 | 759 | ||
| 825 | 760 | /// Mark a cached object used now, at most once a minute. |
|
| … | 52 unchanged lines |
||
| 878 | 813 | Err(e) if self.cache.has(key)? => return Err(e), |
|
| 879 | 814 | Err(_) => {} |
|
| 880 | 815 | } |
|
| 881 | - | // A large object's ranges are kept as they are read (packs never |
|
| 882 | - | // change, oxygenhub#234): a server opens the store again for every |
|
| 883 | - | // request, so only what is on disk carries over. |
|
| 884 | - | let rkey = format!("{RANGES}/{key}/{}-{}", range.start, range.end); |
|
| 885 | - | if let Ok(bytes) = self.cache.get(&rkey) { |
|
| 886 | - | self.touch(&rkey); |
|
| 887 | - | return Ok(bytes); |
|
| 888 | - | } |
|
| 889 | 816 | // A small object is fetched whole and kept; a large one is read by |
|
| 890 | - | // range and the range kept. Its size is kept too, so the remote is |
|
| 891 | - | // asked it once. |
|
| 892 | - | match self.size_of(key)? { |
|
| 893 | - | Some(size) if self.cacheable(size) => { |
|
| 817 | + | // range, every time. |
|
| 818 | + | match self.remote.stat(key)? { |
|
| 819 | + | Some(e) if self.cacheable(e.size) => { |
|
| 894 | 820 | let bytes = self.get(key)?; |
|
| 895 | 821 | let len = bytes.len() as u64; |
|
| 896 | 822 | if range.end > len || range.start > range.end { |
|
| … | 3 unchanged lines |
||
| 900 | 826 | ))); |
|
| 901 | 827 | } |
|
| 902 | 828 | Ok(bytes[range.start as usize..range.end as usize].to_vec()) |
|
| 903 | - | } |
|
| 904 | - | Some(_) => { |
|
| 905 | - | let bytes = self.remote.get_range(key, range)?; |
|
| 906 | - | self.keep(&rkey, &bytes)?; |
|
| 907 | - | Ok(bytes) |
|
| 908 | 829 | } |
|
| 830 | + | Some(_) => self.remote.get_range(key, range), |
|
| 909 | 831 | None => Err(Error::NotFound(key.to_string())), |
|
| 910 | 832 | } |
|
| 911 | 833 | } |
|
| … | 35 unchanged lines |
||
| 947 | 869 | ||
| 948 | 870 | fn delete(&self, key: &str) -> Result<()> { |
|
| 949 | 871 | self.remote.delete(key)?; |
|
| 950 | - | // Its kept ranges and size go with it. |
|
| 951 | - | let mut extra = self.cache.list(&format!("{RANGES}/{key}/"))?; |
|
| 952 | - | extra.extend(self.cache.stat(&format!("{SIZES}/{key}"))?); |
|
| 953 | - | for e in extra { |
|
| 954 | - | self.cache.delete(&e.key)?; |
|
| 955 | - | let mut used = self.used.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 956 | - | if let Some(u) = used.as_mut() { |
|
| 957 | - | *u = u.saturating_sub(e.size); |
|
| 958 | - | } |
|
| 959 | - | } |
|
| 960 | 872 | let size = self.cache.stat(key)?.map(|e| e.size); |
|
| 961 | 873 | self.cache.delete(key)?; |
|
| 962 | 874 | if let Some(n) = size { |
|
| … | 164 unchanged lines |
||
| 1127 | 1039 | use super::*; |
|
| 1128 | 1040 | use std::sync::Arc; |
|
| 1129 | 1041 | ||
| 1130 | - | /// Where hard links are refused (Android, oxygen#141), objects are |
|
| 1131 | - | /// renamed into place, still never over a name that is taken. |
|
| 1132 | 1042 | #[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 | - | ||
| 1151 | - | #[test] |
|
| 1152 | 1043 | fn keys_are_plain_relative_paths() { |
|
| 1153 | 1044 | for ok in ["a", "packs/ab.pack", "a-b_c.d/e"] { |
|
| 1154 | 1045 | check_key(ok).unwrap(); |
|
| … | 45 unchanged lines |
||
| 1200 | 1091 | assert!(!store.cache.has("refs").unwrap()); |
|
| 1201 | 1092 | } |
|
| 1202 | 1093 | ||
| 1203 | - | /// A remote that answers nothing: what is read through it was cached. |
|
| 1204 | - | struct Unreachable; |
|
| 1205 | - | ||
| 1206 | - | impl Store for Unreachable { |
|
| 1207 | - | fn describe(&self) -> String { |
|
| 1208 | - | "unreachable".into() |
|
| 1209 | - | } |
|
| 1210 | - | fn has(&self, _: &str) -> Result<bool> { |
|
| 1211 | - | Err(Error::Backend("asked the remote".into())) |
|
| 1212 | - | } |
|
| 1213 | - | fn get(&self, _: &str) -> Result<Vec<u8>> { |
|
| 1214 | - | Err(Error::Backend("asked the remote".into())) |
|
| 1215 | - | } |
|
| 1216 | - | fn get_range(&self, _: &str, _: Range<u64>) -> Result<Vec<u8>> { |
|
| 1217 | - | Err(Error::Backend("asked the remote".into())) |
|
| 1218 | - | } |
|
| 1219 | - | fn put_new(&self, _: &str, _: &[u8]) -> Result<()> { |
|
| 1220 | - | Err(Error::Backend("asked the remote".into())) |
|
| 1221 | - | } |
|
| 1222 | - | fn list(&self, _: &str) -> Result<Vec<Entry>> { |
|
| 1223 | - | Err(Error::Backend("asked the remote".into())) |
|
| 1224 | - | } |
|
| 1225 | - | fn delete(&self, _: &str) -> Result<()> { |
|
| 1226 | - | Err(Error::Backend("asked the remote".into())) |
|
| 1227 | - | } |
|
| 1228 | - | fn stat(&self, _: &str) -> Result<Option<Entry>> { |
|
| 1229 | - | Err(Error::Backend("asked the remote".into())) |
|
| 1230 | - | } |
|
| 1231 | - | fn read_ref(&self, _: &str) -> Result<Option<(Vec<u8>, Version)>> { |
|
| 1232 | - | Err(Error::Backend("asked the remote".into())) |
|
| 1233 | - | } |
|
| 1234 | - | fn write_ref(&self, _: &str, _: &[u8], _: Option<&Version>) -> Result<Version> { |
|
| 1235 | - | Err(Error::Backend("asked the remote".into())) |
|
| 1236 | - | } |
|
| 1237 | - | } |
|
| 1238 | - | ||
| 1239 | 1094 | #[test] |
|
| 1240 | 1095 | fn a_caching_store_stays_within_its_limit() { |
|
| 1241 | 1096 | let dir = tempfile::tempdir().unwrap(); |
|
| … | 27 unchanged lines |
||
| 1269 | 1124 | assert_eq!(store.get_range("pack-big", 10..20).unwrap(), vec![7u8; 10]); |
|
| 1270 | 1125 | assert_eq!(store.get("pack-big").unwrap().len(), 2000); |
|
| 1271 | 1126 | assert!(!store.cache.has("pack-big").unwrap()); |
|
| 1272 | - | // Its ranges are kept as read, and its size, so a store opened |
|
| 1273 | - | // again (a server's next request) asks the remote nothing. |
|
| 1274 | - | let again = |
|
| 1275 | - | CachingStore::open(Arc::new(Unreachable), dir.path().join("cache"), 8000).unwrap(); |
|
| 1276 | - | assert_eq!(again.get_range("pack-big", 10..20).unwrap(), vec![7u8; 10]); |
|
| 1277 | 1127 | // Counting from scratch agrees with what was kept track of. |
|
| 1278 | 1128 | let fresh = CachingStore::open(remote, dir.path().join("cache"), 8000).unwrap(); |
|
| 1279 | 1129 | assert_eq!(fresh.cached_bytes().unwrap(), store.cached_bytes().unwrap()); |
|
| 1280 | - | // Deleting it takes its ranges and size too. |
|
| 1281 | - | store.delete("pack-big").unwrap(); |
|
| 1282 | - | assert!( |
|
| 1283 | - | store.cache.list("cached-range/").unwrap().is_empty() |
|
| 1284 | - | && store.cache.list("cached-size/").unwrap().is_empty() |
|
| 1285 | - | ); |
|
| 1286 | 1130 | } |
|
| 1287 | 1131 | ||
| 1288 | 1132 | #[test] |
|
| … | 109 unchanged lines |
modified crates/oxy-core/src/subrepo.rs +0 −30
| … | 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 | - | ||
| 59 | 29 | pub fn encode(&self) -> Vec<u8> { |
|
| 60 | 30 | let v = match self { |
|
| 61 | 31 | Pin::Oxy { |
|
| … | 278 unchanged lines |
removed crates/oxy-core/src/tlog.rs +0 −1646
| 1 | - | //! The transparency log (spec §14.8, oxygenhub#238, oxygenhub |
|
| 2 | - | //! docs/transparency.md): an append-only Merkle tree of what a hub |
|
| 3 | - | //! accepts, served as C2SP tlog-tiles with C2SP signed-note checkpoints, |
|
| 4 | - | //! so a hub cannot show one history to some and another to others. |
|
| 5 | - | //! |
|
| 6 | - | //! The tree is RFC 6962's, with BLAKE3 for its hashes, domain-separated |
|
| 7 | - | //! for leaves and nodes. Hashes are kept in *stored order*: every hash of |
|
| 8 | - | //! the tree, each written as soon as it is known, so the store is |
|
| 9 | - | //! append-only (the arithmetic is that of Go's `sumdb/tlog`). Tiles are |
|
| 10 | - | //! views of it: level L tile N holds the hashes of tree level 8·L for |
|
| 11 | - | //! leaves N·256 onwards. |
|
| 12 | - | //! |
|
| 13 | - | //! A checkpoint is a signed note: the origin, the tree size and the root, |
|
| 14 | - | //! signed with the hub's Ed25519 key (what note tools read) and with its |
|
| 15 | - | //! composite key in an extra line, so the log stays post-quantum. |
|
| 16 | - | ||
| 17 | - | use crate::cbor::{self, Value}; |
|
| 18 | - | use crate::hash::{KeyId, ObjectRef}; |
|
| 19 | - | use crate::identity::{verify, Domain, Identity, SecretKey, Signature}; |
|
| 20 | - | use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, ObjectError}; |
|
| 21 | - | use sha2::{Digest, Sha256}; |
|
| 22 | - | ||
| 23 | - | /// A hash in the tree. |
|
| 24 | - | pub type Hash = [u8; 32]; |
|
| 25 | - | ||
| 26 | - | /// Tile height: a tile holds up to 2⁸ hashes (C2SP tlog-tiles). |
|
| 27 | - | pub const TILE_HEIGHT: u32 = 8; |
|
| 28 | - | /// Hashes in a full tile. |
|
| 29 | - | pub const TILE_WIDTH: u64 = 1 << TILE_HEIGHT; |
|
| 30 | - | ||
| 31 | - | #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] |
|
| 32 | - | pub enum TlogError { |
|
| 33 | - | #[error("the proof does not verify")] |
|
| 34 | - | ProofFailed, |
|
| 35 | - | #[error("{0}")] |
|
| 36 | - | BadIndex(String), |
|
| 37 | - | #[error("reading the log's hashes: {0}")] |
|
| 38 | - | Read(String), |
|
| 39 | - | #[error("not a valid note: {0}")] |
|
| 40 | - | BadNote(String), |
|
| 41 | - | #[error("{0}")] |
|
| 42 | - | BadSignature(String), |
|
| 43 | - | #[error("{0}")] |
|
| 44 | - | Schema(String), |
|
| 45 | - | } |
|
| 46 | - | ||
| 47 | - | impl From<ObjectError> for TlogError { |
|
| 48 | - | fn from(e: ObjectError) -> Self { |
|
| 49 | - | TlogError::Schema(e.to_string()) |
|
| 50 | - | } |
|
| 51 | - | } |
|
| 52 | - | ||
| 53 | - | type Result<T> = std::result::Result<T, TlogError>; |
|
| 54 | - | ||
| 55 | - | // ------------------------------------------------------------- hashing |
|
| 56 | - | ||
| 57 | - | /// A leaf's hash: `BLAKE3("oxygen-tlog-leaf\0" || entry bytes)`. |
|
| 58 | - | pub fn leaf_hash(entry: &[u8]) -> Hash { |
|
| 59 | - | let mut h = blake3::Hasher::new(); |
|
| 60 | - | h.update(b"oxygen-tlog-leaf\0"); |
|
| 61 | - | h.update(entry); |
|
| 62 | - | *h.finalize().as_bytes() |
|
| 63 | - | } |
|
| 64 | - | ||
| 65 | - | /// An interior node's hash: `BLAKE3("oxygen-tlog-node\0" || left || right)`. |
|
| 66 | - | pub fn node_hash(left: &Hash, right: &Hash) -> Hash { |
|
| 67 | - | let mut h = blake3::Hasher::new(); |
|
| 68 | - | h.update(b"oxygen-tlog-node\0"); |
|
| 69 | - | h.update(left); |
|
| 70 | - | h.update(right); |
|
| 71 | - | *h.finalize().as_bytes() |
|
| 72 | - | } |
|
| 73 | - | ||
| 74 | - | /// The root of the empty tree: the hash of nothing, as RFC 6962 has it. |
|
| 75 | - | pub fn empty_root() -> Hash { |
|
| 76 | - | *blake3::hash(b"").as_bytes() |
|
| 77 | - | } |
|
| 78 | - | ||
| 79 | - | // --------------------------------------------------------------- entries |
|
| 80 | - | ||
| 81 | - | /// One accepted object (spec §14.8): small and fixed-shape, never the |
|
| 82 | - | /// object itself, so a private repository's entries reveal only ids and |
|
| 83 | - | /// hashes. |
|
| 84 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 85 | - | pub struct Entry { |
|
| 86 | - | /// `ref`, `seal`, `tag`, `rotate`, `revoke`, `receipt`, `approval`, |
|
| 87 | - | /// `policy`, `identity`, `certificate`, `accepted`. |
|
| 88 | - | pub kind: String, |
|
| 89 | - | /// The repository's id; zero for identities. |
|
| 90 | - | pub repo: [u8; 16], |
|
| 91 | - | /// The attestation, policy, identity or certificate. |
|
| 92 | - | pub object: ObjectRef, |
|
| 93 | - | /// The keys whose signatures were checked. |
|
| 94 | - | pub signers: Vec<KeyId>, |
|
| 95 | - | /// The hub's clock when it accepted it, in milliseconds (its claim). |
|
| 96 | - | pub received: u64, |
|
| 97 | - | } |
|
| 98 | - | ||
| 99 | - | impl Entry { |
|
| 100 | - | pub fn to_value(&self) -> Value { |
|
| 101 | - | Value::map([ |
|
| 102 | - | (0, Value::Uint(1)), |
|
| 103 | - | (1, Value::Text(self.kind.clone())), |
|
| 104 | - | (2, Value::Bytes(self.repo.to_vec())), |
|
| 105 | - | (3, Value::Bytes(self.object.0.to_vec())), |
|
| 106 | - | ( |
|
| 107 | - | 4, |
|
| 108 | - | Value::Array( |
|
| 109 | - | self.signers |
|
| 110 | - | .iter() |
|
| 111 | - | .map(|k| Value::Bytes(k.0.to_vec())) |
|
| 112 | - | .collect(), |
|
| 113 | - | ), |
|
| 114 | - | ), |
|
| 115 | - | (5, Value::Uint(self.received)), |
|
| 116 | - | ]) |
|
| 117 | - | } |
|
| 118 | - | ||
| 119 | - | pub fn encode(&self) -> Vec<u8> { |
|
| 120 | - | cbor::encode(&self.to_value()) |
|
| 121 | - | } |
|
| 122 | - | ||
| 123 | - | pub fn decode(b: &[u8]) -> Result<Entry> { |
|
| 124 | - | let v = cbor::decode(b).map_err(ObjectError::from)?; |
|
| 125 | - | let m = map(&v, "log entry")?; |
|
| 126 | - | check_version(m)?; |
|
| 127 | - | let repo = bytes(field(m, 2, "repo")?, "repo")?; |
|
| 128 | - | let repo: [u8; 16] = repo |
|
| 129 | - | .try_into() |
|
| 130 | - | .map_err(|_| TlogError::Schema("log entry: repo is not 16 bytes".into()))?; |
|
| 131 | - | let mut signers = Vec::new(); |
|
| 132 | - | for s in array(field(m, 4, "signers")?, "signers")? { |
|
| 133 | - | signers.push(oref(s, "signer")?); |
|
| 134 | - | } |
|
| 135 | - | Ok(Entry { |
|
| 136 | - | kind: text(field(m, 1, "kind")?, "kind")?.to_owned(), |
|
| 137 | - | repo, |
|
| 138 | - | object: oref(field(m, 3, "object")?, "object")?, |
|
| 139 | - | signers, |
|
| 140 | - | received: uint(field(m, 5, "received")?, "received")?, |
|
| 141 | - | }) |
|
| 142 | - | } |
|
| 143 | - | ||
| 144 | - | /// Its leaf hash in the tree. |
|
| 145 | - | pub fn leaf_hash(&self) -> Hash { |
|
| 146 | - | leaf_hash(&self.encode()) |
|
| 147 | - | } |
|
| 148 | - | } |
|
| 149 | - | ||
| 150 | - | /// An entry bundle (C2SP tlog-tiles): each entry as a big-endian u16 |
|
| 151 | - | /// length and its bytes. |
|
| 152 | - | pub fn encode_bundle(entries: &[Vec<u8>]) -> Result<Vec<u8>> { |
|
| 153 | - | let mut out = Vec::new(); |
|
| 154 | - | for e in entries { |
|
| 155 | - | let n = u16::try_from(e.len()) |
|
| 156 | - | .map_err(|_| TlogError::Schema("a log entry over 65535 bytes".into()))?; |
|
| 157 | - | out.extend_from_slice(&n.to_be_bytes()); |
|
| 158 | - | out.extend_from_slice(e); |
|
| 159 | - | } |
|
| 160 | - | Ok(out) |
|
| 161 | - | } |
|
| 162 | - | ||
| 163 | - | /// The entries of a bundle. |
|
| 164 | - | pub fn decode_bundle(mut b: &[u8]) -> Result<Vec<Vec<u8>>> { |
|
| 165 | - | let mut out = Vec::new(); |
|
| 166 | - | while !b.is_empty() { |
|
| 167 | - | if b.len() < 2 { |
|
| 168 | - | return Err(TlogError::Schema("entry bundle: a truncated length".into())); |
|
| 169 | - | } |
|
| 170 | - | let n = u16::from_be_bytes([b[0], b[1]]) as usize; |
|
| 171 | - | if b.len() < 2 + n { |
|
| 172 | - | return Err(TlogError::Schema("entry bundle: a truncated entry".into())); |
|
| 173 | - | } |
|
| 174 | - | out.push(b[2..2 + n].to_vec()); |
|
| 175 | - | b = &b[2 + n..]; |
|
| 176 | - | } |
|
| 177 | - | Ok(out) |
|
| 178 | - | } |
|
| 179 | - | ||
| 180 | - | // -------------------------------------------------- the tree's arithmetic |
|
| 181 | - | ||
| 182 | - | /// Reads stored hashes by their stored index. |
|
| 183 | - | pub trait HashReader { |
|
| 184 | - | /// The hashes at `indexes`, in that order. |
|
| 185 | - | fn read_hashes(&self, indexes: &[u64]) -> Result<Vec<Hash>>; |
|
| 186 | - | } |
|
| 187 | - | ||
| 188 | - | /// Every stored hash in order, as a log in memory holds them. |
|
| 189 | - | impl HashReader for [Hash] { |
|
| 190 | - | fn read_hashes(&self, indexes: &[u64]) -> Result<Vec<Hash>> { |
|
| 191 | - | indexes |
|
| 192 | - | .iter() |
|
| 193 | - | .map(|&i| { |
|
| 194 | - | self.get(i as usize) |
|
| 195 | - | .copied() |
|
| 196 | - | .ok_or_else(|| TlogError::Read(format!("no stored hash {i}"))) |
|
| 197 | - | }) |
|
| 198 | - | .collect() |
|
| 199 | - | } |
|
| 200 | - | } |
|
| 201 | - | ||
| 202 | - | impl HashReader for Vec<Hash> { |
|
| 203 | - | fn read_hashes(&self, indexes: &[u64]) -> Result<Vec<Hash>> { |
|
| 204 | - | self.as_slice().read_hashes(indexes) |
|
| 205 | - | } |
|
| 206 | - | } |
|
| 207 | - | ||
| 208 | - | /// The largest power of two below `n` (n ≥ 2), and its log. |
|
| 209 | - | fn maxpow2(n: u64) -> (u64, u32) { |
|
| 210 | - | let mut l = 0; |
|
| 211 | - | while (1u64 << (l + 1)) < n { |
|
| 212 | - | l += 1; |
|
| 213 | - | } |
|
| 214 | - | (1 << l, l) |
|
| 215 | - | } |
|
| 216 | - | ||
| 217 | - | /// Where the `n`th hash of tree level `level` is stored: level L's nth |
|
| 218 | - | /// hash is written right after level L+1's (2n+1)th, and level 0's nth |
|
| 219 | - | /// at n + n/2 + n/4 + …. |
|
| 220 | - | pub fn stored_hash_index(level: u32, mut n: u64) -> u64 { |
|
| 221 | - | for _ in 0..level { |
|
| 222 | - | n = 2 * n + 1; |
|
| 223 | - | } |
|
| 224 | - | let mut i = 0; |
|
| 225 | - | let mut m = n; |
|
| 226 | - | while m > 0 { |
|
| 227 | - | i += m; |
|
| 228 | - | m >>= 1; |
|
| 229 | - | } |
|
| 230 | - | i + level as u64 |
|
| 231 | - | } |
|
| 232 | - | ||
| 233 | - | /// The level and position of the hash at a stored index. |
|
| 234 | - | pub fn split_stored_hash_index(index: u64) -> (u32, u64) { |
|
| 235 | - | let mut n = index / 2; |
|
| 236 | - | let mut index_n = stored_hash_index(0, n); |
|
| 237 | - | debug_assert!(index_n <= index); |
|
| 238 | - | loop { |
|
| 239 | - | // Each new leaf n adds 1 + trailing_zeros(n+1) hashes. |
|
| 240 | - | let x = index_n + 1 + (n + 1).trailing_zeros() as u64; |
|
| 241 | - | if x > index { |
|
| 242 | - | break; |
|
| 243 | - | } |
|
| 244 | - | n += 1; |
|
| 245 | - | index_n = x; |
|
| 246 | - | } |
|
| 247 | - | let level = (index - index_n) as u32; |
|
| 248 | - | (level, n >> level) |
|
| 249 | - | } |
|
| 250 | - | ||
| 251 | - | /// How many hashes a tree of `n` leaves stores. |
|
| 252 | - | pub fn stored_hash_count(n: u64) -> u64 { |
|
| 253 | - | if n == 0 { |
|
| 254 | - | return 0; |
|
| 255 | - | } |
|
| 256 | - | let mut count = stored_hash_index(0, n - 1) + 1; |
|
| 257 | - | let mut i = n - 1; |
|
| 258 | - | while i & 1 != 0 { |
|
| 259 | - | count += 1; |
|
| 260 | - | i >>= 1; |
|
| 261 | - | } |
|
| 262 | - | count |
|
| 263 | - | } |
|
| 264 | - | ||
| 265 | - | /// The hashes to append when leaf `n` (with hash `leaf`) joins a tree of |
|
| 266 | - | /// `n` leaves: the leaf's, then those of the subtrees it completes. |
|
| 267 | - | pub fn stored_hashes_for_leaf( |
|
| 268 | - | n: u64, |
|
| 269 | - | leaf: Hash, |
|
| 270 | - | r: &(impl HashReader + ?Sized), |
|
| 271 | - | ) -> Result<Vec<Hash>> { |
|
| 272 | - | let m = (n + 1).trailing_zeros(); |
|
| 273 | - | let indexes: Vec<u64> = (0..m) |
|
| 274 | - | .rev() |
|
| 275 | - | .map(|i| stored_hash_index(i, (n >> i) - 1)) |
|
| 276 | - | .collect(); |
|
| 277 | - | let old = r.read_hashes(&indexes)?; |
|
| 278 | - | let mut h = leaf; |
|
| 279 | - | let mut out = vec![h]; |
|
| 280 | - | for i in 0..m as usize { |
|
| 281 | - | h = node_hash(&old[m as usize - 1 - i], &h); |
|
| 282 | - | out.push(h); |
|
| 283 | - | } |
|
| 284 | - | Ok(out) |
|
| 285 | - | } |
|
| 286 | - | ||
| 287 | - | fn sub_tree_index(mut lo: u64, hi: u64, need: &mut Vec<u64>) { |
|
| 288 | - | while lo < hi { |
|
| 289 | - | let (k, level) = maxpow2(hi - lo + 1); |
|
| 290 | - | debug_assert_eq!(lo & (k - 1), 0); |
|
| 291 | - | need.push(stored_hash_index(level, lo >> level)); |
|
| 292 | - | lo += k; |
|
| 293 | - | } |
|
| 294 | - | } |
|
| 295 | - | ||
| 296 | - | fn sub_tree_hash(mut lo: u64, hi: u64, hashes: &[Hash]) -> (Hash, &[Hash]) { |
|
| 297 | - | let mut count = 0; |
|
| 298 | - | while lo < hi { |
|
| 299 | - | let (k, _) = maxpow2(hi - lo + 1); |
|
| 300 | - | count += 1; |
|
| 301 | - | lo += k; |
|
| 302 | - | } |
|
| 303 | - | let mut h = hashes[count - 1]; |
|
| 304 | - | for i in (0..count - 1).rev() { |
|
| 305 | - | h = node_hash(&hashes[i], &h); |
|
| 306 | - | } |
|
| 307 | - | (h, &hashes[count..]) |
|
| 308 | - | } |
|
| 309 | - | ||
| 310 | - | /// The root of the first `n` leaves. |
|
| 311 | - | pub fn tree_hash(n: u64, r: &(impl HashReader + ?Sized)) -> Result<Hash> { |
|
| 312 | - | if n == 0 { |
|
| 313 | - | return Ok(empty_root()); |
|
| 314 | - | } |
|
| 315 | - | let mut need = Vec::new(); |
|
| 316 | - | sub_tree_index(0, n, &mut need); |
|
| 317 | - | let hashes = r.read_hashes(&need)?; |
|
| 318 | - | Ok(sub_tree_hash(0, n, &hashes).0) |
|
| 319 | - | } |
|
| 320 | - | ||
| 321 | - | fn leaf_proof_index(lo: u64, hi: u64, n: u64, need: &mut Vec<u64>) { |
|
| 322 | - | if lo + 1 == hi { |
|
| 323 | - | return; |
|
| 324 | - | } |
|
| 325 | - | let (k, _) = maxpow2(hi - lo); |
|
| 326 | - | if n < lo + k { |
|
| 327 | - | leaf_proof_index(lo, lo + k, n, need); |
|
| 328 | - | sub_tree_index(lo + k, hi, need); |
|
| 329 | - | } else { |
|
| 330 | - | sub_tree_index(lo, lo + k, need); |
|
| 331 | - | leaf_proof_index(lo + k, hi, n, need); |
|
| 332 | - | } |
|
| 333 | - | } |
|
| 334 | - | ||
| 335 | - | fn leaf_proof(lo: u64, hi: u64, n: u64, hashes: &[Hash]) -> (Vec<Hash>, &[Hash]) { |
|
| 336 | - | if lo + 1 == hi { |
|
| 337 | - | // The verifier has the leaf's hash. |
|
| 338 | - | return (Vec::new(), hashes); |
|
| 339 | - | } |
|
| 340 | - | let (k, _) = maxpow2(hi - lo); |
|
| 341 | - | let (mut p, th, rest); |
|
| 342 | - | if n < lo + k { |
|
| 343 | - | let (pp, h1) = leaf_proof(lo, lo + k, n, hashes); |
|
| 344 | - | let (t, h2) = sub_tree_hash(lo + k, hi, h1); |
|
| 345 | - | (p, th, rest) = (pp, t, h2); |
|
| 346 | - | } else { |
|
| 347 | - | let (t, h1) = sub_tree_hash(lo, lo + k, hashes); |
|
| 348 | - | let (pp, h2) = leaf_proof(lo + k, hi, n, h1); |
|
| 349 | - | (p, th, rest) = (pp, t, h2); |
|
| 350 | - | } |
|
| 351 | - | p.push(th); |
|
| 352 | - | (p, rest) |
|
| 353 | - | } |
|
| 354 | - | ||
| 355 | - | /// The inclusion proof of leaf `n` in the tree of the first `t` leaves. |
|
| 356 | - | pub fn prove_inclusion(t: u64, n: u64, r: &(impl HashReader + ?Sized)) -> Result<Vec<Hash>> { |
|
| 357 | - | if n >= t { |
|
| 358 | - | return Err(TlogError::BadIndex(format!( |
|
| 359 | - | "leaf {n} is not in a tree of {t}" |
|
| 360 | - | ))); |
|
| 361 | - | } |
|
| 362 | - | let mut need = Vec::new(); |
|
| 363 | - | leaf_proof_index(0, t, n, &mut need); |
|
| 364 | - | if need.is_empty() { |
|
| 365 | - | return Ok(Vec::new()); |
|
| 366 | - | } |
|
| 367 | - | let hashes = r.read_hashes(&need)?; |
|
| 368 | - | Ok(leaf_proof(0, t, n, &hashes).0) |
|
| 369 | - | } |
|
| 370 | - | ||
| 371 | - | fn run_inclusion(p: &[Hash], lo: u64, hi: u64, n: u64, leaf: Hash) -> Result<Hash> { |
|
| 372 | - | if lo + 1 == hi { |
|
| 373 | - | return if p.is_empty() { |
|
| 374 | - | Ok(leaf) |
|
| 375 | - | } else { |
|
| 376 | - | Err(TlogError::ProofFailed) |
|
| 377 | - | }; |
|
| 378 | - | } |
|
| 379 | - | let Some((last, rest)) = p.split_last() else { |
|
| 380 | - | return Err(TlogError::ProofFailed); |
|
| 381 | - | }; |
|
| 382 | - | let (k, _) = maxpow2(hi - lo); |
|
| 383 | - | if n < lo + k { |
|
| 384 | - | Ok(node_hash(&run_inclusion(rest, lo, lo + k, n, leaf)?, last)) |
|
| 385 | - | } else { |
|
| 386 | - | Ok(node_hash(last, &run_inclusion(rest, lo + k, hi, n, leaf)?)) |
|
| 387 | - | } |
|
| 388 | - | } |
|
| 389 | - | ||
| 390 | - | /// Check that leaf `n`, with hash `leaf`, is in the tree of `t` leaves |
|
| 391 | - | /// whose root is `root`. |
|
| 392 | - | pub fn check_inclusion(proof: &[Hash], t: u64, root: &Hash, n: u64, leaf: &Hash) -> Result<()> { |
|
| 393 | - | if n >= t { |
|
| 394 | - | return Err(TlogError::ProofFailed); |
|
| 395 | - | } |
|
| 396 | - | if run_inclusion(proof, 0, t, n, *leaf)? == *root { |
|
| 397 | - | Ok(()) |
|
| 398 | - | } else { |
|
| 399 | - | Err(TlogError::ProofFailed) |
|
| 400 | - | } |
|
| 401 | - | } |
|
| 402 | - | ||
| 403 | - | fn tree_proof_index(lo: u64, hi: u64, n: u64, need: &mut Vec<u64>) { |
|
| 404 | - | if n == hi { |
|
| 405 | - | if lo != 0 { |
|
| 406 | - | sub_tree_index(lo, hi, need); |
|
| 407 | - | } |
|
| 408 | - | return; |
|
| 409 | - | } |
|
| 410 | - | let (k, _) = maxpow2(hi - lo); |
|
| 411 | - | if n <= lo + k { |
|
| 412 | - | tree_proof_index(lo, lo + k, n, need); |
|
| 413 | - | sub_tree_index(lo + k, hi, need); |
|
| 414 | - | } else { |
|
| 415 | - | sub_tree_index(lo, lo + k, need); |
|
| 416 | - | tree_proof_index(lo + k, hi, n, need); |
|
| 417 | - | } |
|
| 418 | - | } |
|
| 419 | - | ||
| 420 | - | fn tree_proof(lo: u64, hi: u64, n: u64, hashes: &[Hash]) -> (Vec<Hash>, &[Hash]) { |
|
| 421 | - | if n == hi { |
|
| 422 | - | if lo == 0 { |
|
| 423 | - | // Exactly the old tree, whose root the verifier has. |
|
| 424 | - | return (Vec::new(), hashes); |
|
| 425 | - | } |
|
| 426 | - | let (th, rest) = sub_tree_hash(lo, hi, hashes); |
|
| 427 | - | return (vec![th], rest); |
|
| 428 | - | } |
|
| 429 | - | let (k, _) = maxpow2(hi - lo); |
|
| 430 | - | let (mut p, th, rest); |
|
| 431 | - | if n <= lo + k { |
|
| 432 | - | let (pp, h1) = tree_proof(lo, lo + k, n, hashes); |
|
| 433 | - | let (t, h2) = sub_tree_hash(lo + k, hi, h1); |
|
| 434 | - | (p, th, rest) = (pp, t, h2); |
|
| 435 | - | } else { |
|
| 436 | - | let (t, h1) = sub_tree_hash(lo, lo + k, hashes); |
|
| 437 | - | let (pp, h2) = tree_proof(lo + k, hi, n, h1); |
|
| 438 | - | (p, th, rest) = (pp, t, h2); |
|
| 439 | - | } |
|
| 440 | - | p.push(th); |
|
| 441 | - | (p, rest) |
|
| 442 | - | } |
|
| 443 | - | ||
| 444 | - | /// The consistency proof that the tree of `n` leaves is a prefix of the |
|
| 445 | - | /// tree of `t`. |
|
| 446 | - | pub fn prove_consistency(t: u64, n: u64, r: &(impl HashReader + ?Sized)) -> Result<Vec<Hash>> { |
|
| 447 | - | if n < 1 || n > t { |
|
| 448 | - | return Err(TlogError::BadIndex(format!( |
|
| 449 | - | "no consistency proof from {n} to {t}" |
|
| 450 | - | ))); |
|
| 451 | - | } |
|
| 452 | - | let mut need = Vec::new(); |
|
| 453 | - | tree_proof_index(0, t, n, &mut need); |
|
| 454 | - | if need.is_empty() { |
|
| 455 | - | return Ok(Vec::new()); |
|
| 456 | - | } |
|
| 457 | - | let hashes = r.read_hashes(&need)?; |
|
| 458 | - | Ok(tree_proof(0, t, n, &hashes).0) |
|
| 459 | - | } |
|
| 460 | - | ||
| 461 | - | fn run_consistency(p: &[Hash], lo: u64, hi: u64, n: u64, old: Hash) -> Result<(Hash, Hash)> { |
|
| 462 | - | if n == hi { |
|
| 463 | - | if lo == 0 { |
|
| 464 | - | return if p.is_empty() { |
|
| 465 | - | Ok((old, old)) |
|
| 466 | - | } else { |
|
| 467 | - | Err(TlogError::ProofFailed) |
|
| 468 | - | }; |
|
| 469 | - | } |
|
| 470 | - | return match p { |
|
| 471 | - | [h] => Ok((*h, *h)), |
|
| 472 | - | _ => Err(TlogError::ProofFailed), |
|
| 473 | - | }; |
|
| 474 | - | } |
|
| 475 | - | let Some((last, rest)) = p.split_last() else { |
|
| 476 | - | return Err(TlogError::ProofFailed); |
|
| 477 | - | }; |
|
| 478 | - | let (k, _) = maxpow2(hi - lo); |
|
| 479 | - | if n <= lo + k { |
|
| 480 | - | let (oh, th) = run_consistency(rest, lo, lo + k, n, old)?; |
|
| 481 | - | Ok((oh, node_hash(&th, last))) |
|
| 482 | - | } else { |
|
| 483 | - | let (oh, th) = run_consistency(rest, lo + k, hi, n, old)?; |
|
| 484 | - | Ok((node_hash(last, &oh), node_hash(last, &th))) |
|
| 485 | - | } |
|
| 486 | - | } |
|
| 487 | - | ||
| 488 | - | /// Check that the tree of `n` leaves with root `old_root` is a prefix of |
|
| 489 | - | /// the tree of `t` leaves with root `root`. |
|
| 490 | - | pub fn check_consistency( |
|
| 491 | - | proof: &[Hash], |
|
| 492 | - | t: u64, |
|
| 493 | - | root: &Hash, |
|
| 494 | - | n: u64, |
|
| 495 | - | old_root: &Hash, |
|
| 496 | - | ) -> Result<()> { |
|
| 497 | - | if n < 1 || n > t { |
|
| 498 | - | return Err(TlogError::ProofFailed); |
|
| 499 | - | } |
|
| 500 | - | let (oh, th) = run_consistency(proof, 0, t, n, *old_root)?; |
|
| 501 | - | if oh == *old_root && th == *root { |
|
| 502 | - | Ok(()) |
|
| 503 | - | } else { |
|
| 504 | - | Err(TlogError::ProofFailed) |
|
| 505 | - | } |
|
| 506 | - | } |
|
| 507 | - | ||
| 508 | - | // ----------------------------------------------------------------- tiles |
|
| 509 | - | ||
| 510 | - | /// A tile (C2SP tlog-tiles, height 8): level `level`'s hashes for leaves |
|
| 511 | - | /// from `n`·256, `width` of them (256 when full). |
|
| 512 | - | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 513 | - | pub struct Tile { |
|
| 514 | - | pub level: u32, |
|
| 515 | - | pub n: u64, |
|
| 516 | - | pub width: u32, |
|
| 517 | - | } |
|
| 518 | - | ||
| 519 | - | /// A tile index as a path: three-digit groups, all but the last with an |
|
| 520 | - | /// `x`, as `x001/x234/067` for 1234067. |
|
| 521 | - | pub fn index_path(n: u64) -> String { |
|
| 522 | - | let mut groups = Vec::new(); |
|
| 523 | - | let mut m = n; |
|
| 524 | - | loop { |
|
| 525 | - | groups.push(m % 1000); |
|
| 526 | - | m /= 1000; |
|
| 527 | - | if m == 0 { |
|
| 528 | - | break; |
|
| 529 | - | } |
|
| 530 | - | } |
|
| 531 | - | groups.reverse(); |
|
| 532 | - | let last = groups.len() - 1; |
|
| 533 | - | groups |
|
| 534 | - | .iter() |
|
| 535 | - | .enumerate() |
|
| 536 | - | .map(|(i, g)| { |
|
| 537 | - | if i < last { |
|
| 538 | - | format!("x{g:03}") |
|
| 539 | - | } else { |
|
| 540 | - | format!("{g:03}") |
|
| 541 | - | } |
|
| 542 | - | }) |
|
| 543 | - | .collect::<Vec<_>>() |
|
| 544 | - | .join("/") |
|
| 545 | - | } |
|
| 546 | - | ||
| 547 | - | /// The index a path names (the inverse of [`index_path`]). |
|
| 548 | - | pub fn parse_index_path(path: &str) -> Option<u64> { |
|
| 549 | - | let parts: Vec<&str> = path.split('/').collect(); |
|
| 550 | - | let mut n: u64 = 0; |
|
| 551 | - | for (i, p) in parts.iter().enumerate() { |
|
| 552 | - | let digits = if i + 1 < parts.len() { |
|
| 553 | - | p.strip_prefix('x')? |
|
| 554 | - | } else { |
|
| 555 | - | p |
|
| 556 | - | }; |
|
| 557 | - | if digits.len() != 3 || !digits.bytes().all(|c| c.is_ascii_digit()) { |
|
| 558 | - | return None; |
|
| 559 | - | } |
|
| 560 | - | n = n.checked_mul(1000)?.checked_add(digits.parse().ok()?)?; |
|
| 561 | - | } |
|
| 562 | - | Some(n) |
|
| 563 | - | } |
|
| 564 | - | ||
| 565 | - | impl Tile { |
|
| 566 | - | /// `tile/<L>/<N>[.p/<W>]`. |
|
| 567 | - | pub fn path(&self) -> String { |
|
| 568 | - | format!( |
|
| 569 | - | "tile/{}/{}{}", |
|
| 570 | - | self.level, |
|
| 571 | - | index_path(self.n), |
|
| 572 | - | self.partial() |
|
| 573 | - | ) |
|
| 574 | - | } |
|
| 575 | - | ||
| 576 | - | /// The entry bundle beside a level-0 tile: `tile/entries/<N>[.p/<W>]`. |
|
| 577 | - | pub fn entries_path(&self) -> String { |
|
| 578 | - | format!("tile/entries/{}{}", index_path(self.n), self.partial()) |
|
| 579 | - | } |
|
| 580 | - | ||
| 581 | - | fn partial(&self) -> String { |
|
| 582 | - | if (self.width as u64) < TILE_WIDTH { |
|
| 583 | - | format!(".p/{}", self.width) |
|
| 584 | - | } else { |
|
| 585 | - | String::new() |
|
| 586 | - | } |
|
| 587 | - | } |
|
| 588 | - | ||
| 589 | - | /// The tile a path names (`<L>/<N>[.p/<W>]` after `tile/`). |
|
| 590 | - | pub fn parse(path: &str) -> Option<Tile> { |
|
| 591 | - | let (level, rest) = path.split_once('/')?; |
|
| 592 | - | let level: u32 = level.parse().ok()?; |
|
| 593 | - | let (index, width) = match rest.split_once(".p/") { |
|
| 594 | - | Some((i, w)) => { |
|
| 595 | - | let w: u32 = w.parse().ok()?; |
|
| 596 | - | if w == 0 || w as u64 >= TILE_WIDTH { |
|
| 597 | - | return None; |
|
| 598 | - | } |
|
| 599 | - | (i, w) |
|
| 600 | - | } |
|
| 601 | - | None => (rest, TILE_WIDTH as u32), |
|
| 602 | - | }; |
|
| 603 | - | Some(Tile { |
|
| 604 | - | level, |
|
| 605 | - | n: parse_index_path(index)?, |
|
| 606 | - | width, |
|
| 607 | - | }) |
|
| 608 | - | } |
|
| 609 | - | ||
| 610 | - | /// The tile holding the stored hash at `index`, and the byte range its |
|
| 611 | - | /// hash comes from in the tile's data. |
|
| 612 | - | pub fn for_index(index: u64) -> (Tile, usize, usize) { |
|
| 613 | - | let (level, mut n) = split_stored_hash_index(index); |
|
| 614 | - | let tl = level / TILE_HEIGHT; |
|
| 615 | - | let within = level - tl * TILE_HEIGHT; |
|
| 616 | - | let tn = (n << within) >> TILE_HEIGHT; |
|
| 617 | - | n -= (tn << TILE_HEIGHT) >> within; |
|
| 618 | - | let width = ((n + 1) << within) as u32; |
|
| 619 | - | let tile = Tile { |
|
| 620 | - | level: tl, |
|
| 621 | - | n: tn, |
|
| 622 | - | width, |
|
| 623 | - | }; |
|
| 624 | - | ( |
|
| 625 | - | tile, |
|
| 626 | - | ((n << within) * 32) as usize, |
|
| 627 | - | (((n + 1) << within) * 32) as usize, |
|
| 628 | - | ) |
|
| 629 | - | } |
|
| 630 | - | ||
| 631 | - | /// The tile's data: its hashes, read from stored order. |
|
| 632 | - | pub fn read_data(&self, r: &(impl HashReader + ?Sized)) -> Result<Vec<u8>> { |
|
| 633 | - | let start = self.n << TILE_HEIGHT; |
|
| 634 | - | let indexes: Vec<u64> = (0..self.width as u64) |
|
| 635 | - | .map(|i| stored_hash_index(TILE_HEIGHT * self.level, start + i)) |
|
| 636 | - | .collect(); |
|
| 637 | - | Ok(r.read_hashes(&indexes)?.concat()) |
|
| 638 | - | } |
|
| 639 | - | ||
| 640 | - | /// The stored hash at `index` from this tile's data. |
|
| 641 | - | pub fn hash_at(&self, data: &[u8], index: u64) -> Result<Hash> { |
|
| 642 | - | if self.width == 0 |
|
| 643 | - | || self.width as u64 > TILE_WIDTH |
|
| 644 | - | || data.len() < self.width as usize * 32 |
|
| 645 | - | { |
|
| 646 | - | return Err(TlogError::Read(format!("tile {} is short", self.path()))); |
|
| 647 | - | } |
|
| 648 | - | let (t, start, end) = Tile::for_index(index); |
|
| 649 | - | if t.level != self.level || t.n != self.n || t.width > self.width { |
|
| 650 | - | return Err(TlogError::Read(format!( |
|
| 651 | - | "stored hash {index} is not in tile {}", |
|
| 652 | - | self.path() |
|
| 653 | - | ))); |
|
| 654 | - | } |
|
| 655 | - | Ok(tile_hash(&data[start..end])) |
|
| 656 | - | } |
|
| 657 | - | ||
| 658 | - | /// The tiles a tree that grows from `old` to `new` leaves writes, full |
|
| 659 | - | /// ones and the new partial ones. |
|
| 660 | - | pub fn new_tiles(old: u64, new: u64) -> Vec<Tile> { |
|
| 661 | - | let mut tiles = Vec::new(); |
|
| 662 | - | let mut level = 0u32; |
|
| 663 | - | while level < 8 && (new >> (TILE_HEIGHT * level)) > 0 { |
|
| 664 | - | let old_n = old >> (TILE_HEIGHT * level); |
|
| 665 | - | let new_n = new >> (TILE_HEIGHT * level); |
|
| 666 | - | if old_n != new_n { |
|
| 667 | - | for n in (old_n >> TILE_HEIGHT)..(new_n >> TILE_HEIGHT) { |
|
| 668 | - | tiles.push(Tile { |
|
| 669 | - | level, |
|
| 670 | - | n, |
|
| 671 | - | width: TILE_WIDTH as u32, |
|
| 672 | - | }); |
|
| 673 | - | } |
|
| 674 | - | let n = new_n >> TILE_HEIGHT; |
|
| 675 | - | let w = new_n - (n << TILE_HEIGHT); |
|
| 676 | - | if w > 0 { |
|
| 677 | - | tiles.push(Tile { |
|
| 678 | - | level, |
|
| 679 | - | n, |
|
| 680 | - | width: w as u32, |
|
| 681 | - | }); |
|
| 682 | - | } |
|
| 683 | - | } |
|
| 684 | - | level += 1; |
|
| 685 | - | } |
|
| 686 | - | tiles |
|
| 687 | - | } |
|
| 688 | - | } |
|
| 689 | - | ||
| 690 | - | fn tile_hash(data: &[u8]) -> Hash { |
|
| 691 | - | if data.len() == 32 { |
|
| 692 | - | let mut h = [0u8; 32]; |
|
| 693 | - | h.copy_from_slice(data); |
|
| 694 | - | return h; |
|
| 695 | - | } |
|
| 696 | - | let half = data.len() / 2; |
|
| 697 | - | node_hash(&tile_hash(&data[..half]), &tile_hash(&data[half..])) |
|
| 698 | - | } |
|
| 699 | - | ||
| 700 | - | // ----------------------------------------------------- notes, checkpoints |
|
| 701 | - | ||
| 702 | - | /// One signature line of a note: `— <name> <base64(key hash || sig)>`. |
|
| 703 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 704 | - | pub struct NoteSignature { |
|
| 705 | - | pub name: String, |
|
| 706 | - | pub key_hash: [u8; 4], |
|
| 707 | - | pub sig: Vec<u8>, |
|
| 708 | - | } |
|
| 709 | - | ||
| 710 | - | /// A C2SP signed note: its text and its signature lines. |
|
| 711 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 712 | - | pub struct Note { |
|
| 713 | - | /// The text, ending with a newline. |
|
| 714 | - | pub text: String, |
|
| 715 | - | pub sigs: Vec<NoteSignature>, |
|
| 716 | - | } |
|
| 717 | - | ||
| 718 | - | /// Signature types in a note's key hash (C2SP signed-note). |
|
| 719 | - | const SIG_ED25519: u8 = 0x01; |
|
| 720 | - | /// Oxygen's composite Ed25519 + ML-DSA-65 signature (spec §14.8). |
|
| 721 | - | const SIG_COMPOSITE: u8 = 0x4f; |
|
| 722 | - | ||
| 723 | - | fn key_hash(name: &str, sig_type: u8, key: &[u8]) -> [u8; 4] { |
|
| 724 | - | let mut h = Sha256::new(); |
|
| 725 | - | h.update(name.as_bytes()); |
|
| 726 | - | h.update(b"\n"); |
|
| 727 | - | h.update([sig_type]); |
|
| 728 | - | h.update(key); |
|
| 729 | - | let d = h.finalize(); |
|
| 730 | - | [d[0], d[1], d[2], d[3]] |
|
| 731 | - | } |
|
| 732 | - | ||
| 733 | - | /// What a composite signature of a note signs (domain `checkpoint`). |
|
| 734 | - | fn note_target(text: &str) -> ObjectRef { |
|
| 735 | - | let mut h = blake3::Hasher::new(); |
|
| 736 | - | h.update(b"oxygen-tlog-note\0"); |
|
| 737 | - | h.update(text.as_bytes()); |
|
| 738 | - | ObjectRef(*h.finalize().as_bytes()) |
|
| 739 | - | } |
|
| 740 | - | ||
| 741 | - | fn valid_name(name: &str) -> bool { |
|
| 742 | - | !name.is_empty() && !name.contains(|c: char| c.is_whitespace() || c == '+') |
|
| 743 | - | } |
|
| 744 | - | ||
| 745 | - | impl Note { |
|
| 746 | - | /// The note as served: the text, a blank line, the signature lines. |
|
| 747 | - | pub fn encode(&self) -> String { |
|
| 748 | - | let mut out = self.text.clone(); |
|
| 749 | - | out.push('\n'); |
|
| 750 | - | for s in &self.sigs { |
|
| 751 | - | let mut b = s.key_hash.to_vec(); |
|
| 752 | - | b.extend_from_slice(&s.sig); |
|
| 753 | - | out.push_str(&format!( |
|
| 754 | - | "\u{2014} {} {}\n", |
|
| 755 | - | s.name, |
|
| 756 | - | crate::dsse::base64(&b) |
|
| 757 | - | )); |
|
| 758 | - | } |
|
| 759 | - | out |
|
| 760 | - | } |
|
| 761 | - | ||
| 762 | - | pub fn parse(s: &str) -> Result<Note> { |
|
| 763 | - | let bad = |w: &str| TlogError::BadNote(w.to_string()); |
|
| 764 | - | let split = s |
|
| 765 | - | .rfind("\n\n") |
|
| 766 | - | .ok_or_else(|| bad("no blank line before the signatures"))?; |
|
| 767 | - | let text = &s[..split + 1]; |
|
| 768 | - | if text.chars().any(|c| c.is_control() && c != '\n') { |
|
| 769 | - | return Err(bad("control characters in the text")); |
|
| 770 | - | } |
|
| 771 | - | let mut sigs = Vec::new(); |
|
| 772 | - | for line in s[split + 2..].lines() { |
|
| 773 | - | let rest = line |
|
| 774 | - | .strip_prefix("\u{2014} ") |
|
| 775 | - | .ok_or_else(|| bad("a signature line without its dash"))?; |
|
| 776 | - | let (name, b64) = rest |
|
| 777 | - | .split_once(' ') |
|
| 778 | - | .ok_or_else(|| bad("a signature line without a key"))?; |
|
| 779 | - | let b = |
|
| 780 | - | crate::dsse::base64_decode(b64).ok_or_else(|| bad("a signature is not base64"))?; |
|
| 781 | - | if b.len() < 5 || !valid_name(name) { |
|
| 782 | - | return Err(bad("a malformed signature line")); |
|
| 783 | - | } |
|
| 784 | - | sigs.push(NoteSignature { |
|
| 785 | - | name: name.to_string(), |
|
| 786 | - | key_hash: [b[0], b[1], b[2], b[3]], |
|
| 787 | - | sig: b[4..].to_vec(), |
|
| 788 | - | }); |
|
| 789 | - | } |
|
| 790 | - | if sigs.is_empty() { |
|
| 791 | - | return Err(bad("no signatures")); |
|
| 792 | - | } |
|
| 793 | - | Ok(Note { |
|
| 794 | - | text: text.to_string(), |
|
| 795 | - | sigs, |
|
| 796 | - | }) |
|
| 797 | - | } |
|
| 798 | - | ||
| 799 | - | /// Sign as `name` with `identity`'s key: the Ed25519 line note tools |
|
| 800 | - | /// read, and the composite line that keeps it post-quantum. |
|
| 801 | - | pub fn sign(text: &str, name: &str, identity: &Identity, sk: &SecretKey) -> Result<Note> { |
|
| 802 | - | if !valid_name(name) { |
|
| 803 | - | return Err(TlogError::BadNote(format!( |
|
| 804 | - | "{name:?} cannot name a note's key" |
|
| 805 | - | ))); |
|
| 806 | - | } |
|
| 807 | - | if !text.ends_with('\n') || text.contains("\n\n") { |
|
| 808 | - | return Err(TlogError::BadNote( |
|
| 809 | - | "the text must end with one newline and hold no blank line".into(), |
|
| 810 | - | )); |
|
| 811 | - | } |
|
| 812 | - | let ed = NoteSignature { |
|
| 813 | - | name: name.to_string(), |
|
| 814 | - | key_hash: key_hash(name, SIG_ED25519, &identity.ed25519_pk), |
|
| 815 | - | sig: sk.sign_ed25519(text.as_bytes()).to_vec(), |
|
| 816 | - | }; |
|
| 817 | - | let composite = NoteSignature { |
|
| 818 | - | name: name.to_string(), |
|
| 819 | - | key_hash: key_hash(name, SIG_COMPOSITE, &identity.encode()), |
|
| 820 | - | sig: cbor::encode( |
|
| 821 | - | &sk.sign(Domain::Checkpoint, &identity.hash(), ¬e_target(text)) |
|
| 822 | - | .to_value(), |
|
| 823 | - | ), |
|
| 824 | - | }; |
|
| 825 | - | Ok(Note { |
|
| 826 | - | text: text.to_string(), |
|
| 827 | - | sigs: vec![ed, composite], |
|
| 828 | - | }) |
|
| 829 | - | } |
|
| 830 | - | ||
| 831 | - | /// Both of `name`'s signatures by `identity` verify (a note signed |
|
| 832 | - | /// with Ed25519 only is not enough). |
|
| 833 | - | pub fn verify(&self, name: &str, identity: &Identity) -> Result<()> { |
|
| 834 | - | let find = |t: u8, key: &[u8]| { |
|
| 835 | - | let h = key_hash(name, t, key); |
|
| 836 | - | self.sigs.iter().find(|s| s.name == name && s.key_hash == h) |
|
| 837 | - | }; |
|
| 838 | - | let ed = find(SIG_ED25519, &identity.ed25519_pk) |
|
| 839 | - | .ok_or_else(|| TlogError::BadSignature(format!("no Ed25519 signature by {name}")))?; |
|
| 840 | - | identity |
|
| 841 | - | .verify_ed25519(self.text.as_bytes(), &ed.sig) |
|
| 842 | - | .map_err(|_| { |
|
| 843 | - | TlogError::BadSignature(format!("{name}'s Ed25519 signature does not verify")) |
|
| 844 | - | })?; |
|
| 845 | - | let c = find(SIG_COMPOSITE, &identity.encode()) |
|
| 846 | - | .ok_or_else(|| TlogError::BadSignature(format!("no composite signature by {name}")))?; |
|
| 847 | - | let sig = cbor::decode(&c.sig) |
|
| 848 | - | .map_err(|e| e.to_string()) |
|
| 849 | - | .and_then(|v| Signature::from_value(&v).map_err(|e| e.to_string())) |
|
| 850 | - | .map_err(|e| TlogError::BadSignature(format!("{name}'s composite signature: {e}")))?; |
|
| 851 | - | verify(identity, &sig, Domain::Checkpoint, ¬e_target(&self.text)).map_err(|e| { |
|
| 852 | - | TlogError::BadSignature(format!("{name}'s composite signature does not verify: {e}")) |
|
| 853 | - | }) |
|
| 854 | - | } |
|
| 855 | - | } |
|
| 856 | - | ||
| 857 | - | /// A checkpoint (C2SP tlog-checkpoint): the log's origin, size and root. |
|
| 858 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 859 | - | pub struct Checkpoint { |
|
| 860 | - | /// The log's name: the hub's host and `/log`. |
|
| 861 | - | pub origin: String, |
|
| 862 | - | pub size: u64, |
|
| 863 | - | pub root: Hash, |
|
| 864 | - | /// Further lines, each without its newline. |
|
| 865 | - | pub extensions: Vec<String>, |
|
| 866 | - | } |
|
| 867 | - | ||
| 868 | - | impl Checkpoint { |
|
| 869 | - | pub fn text(&self) -> String { |
|
| 870 | - | let mut t = format!( |
|
| 871 | - | "{}\n{}\n{}\n", |
|
| 872 | - | self.origin, |
|
| 873 | - | self.size, |
|
| 874 | - | crate::dsse::base64(&self.root) |
|
| 875 | - | ); |
|
| 876 | - | for e in &self.extensions { |
|
| 877 | - | t.push_str(e); |
|
| 878 | - | t.push('\n'); |
|
| 879 | - | } |
|
| 880 | - | t |
|
| 881 | - | } |
|
| 882 | - | ||
| 883 | - | pub fn parse(text: &str) -> Result<Checkpoint> { |
|
| 884 | - | let bad = |w: &str| TlogError::BadNote(format!("checkpoint: {w}")); |
|
| 885 | - | if !text.ends_with('\n') { |
|
| 886 | - | return Err(bad("no final newline")); |
|
| 887 | - | } |
|
| 888 | - | let mut lines = text[..text.len() - 1].split('\n'); |
|
| 889 | - | let origin = lines |
|
| 890 | - | .next() |
|
| 891 | - | .filter(|o| !o.is_empty()) |
|
| 892 | - | .ok_or_else(|| bad("no origin"))?; |
|
| 893 | - | let size = lines |
|
| 894 | - | .next() |
|
| 895 | - | .and_then(|s| { |
|
| 896 | - | (s == "0" || !s.starts_with('0')) |
|
| 897 | - | .then(|| s.parse().ok()) |
|
| 898 | - | .flatten() |
|
| 899 | - | }) |
|
| 900 | - | .ok_or_else(|| bad("no tree size"))?; |
|
| 901 | - | let root = lines |
|
| 902 | - | .next() |
|
| 903 | - | .and_then(crate::dsse::base64_decode) |
|
| 904 | - | .and_then(|b| <Hash>::try_from(b).ok()) |
|
| 905 | - | .ok_or_else(|| bad("no root hash"))?; |
|
| 906 | - | Ok(Checkpoint { |
|
| 907 | - | origin: origin.to_string(), |
|
| 908 | - | size, |
|
| 909 | - | root, |
|
| 910 | - | extensions: lines.map(String::from).collect(), |
|
| 911 | - | }) |
|
| 912 | - | } |
|
| 913 | - | ||
| 914 | - | /// Signed by the log's key, named by its origin. |
|
| 915 | - | pub fn sign(&self, identity: &Identity, sk: &SecretKey) -> Result<Note> { |
|
| 916 | - | Note::sign(&self.text(), &self.origin, identity, sk) |
|
| 917 | - | } |
|
| 918 | - | ||
| 919 | - | /// The checkpoint a note carries, once the log's key (`identity`) |
|
| 920 | - | /// signed it for `origin`. |
|
| 921 | - | pub fn verify(note: &Note, origin: &str, identity: &Identity) -> Result<Checkpoint> { |
|
| 922 | - | note.verify(origin, identity)?; |
|
| 923 | - | let c = Checkpoint::parse(¬e.text)?; |
|
| 924 | - | if c.origin != origin { |
|
| 925 | - | return Err(TlogError::BadNote(format!( |
|
| 926 | - | "checkpoint of {}, not of {origin}", |
|
| 927 | - | c.origin |
|
| 928 | - | ))); |
|
| 929 | - | } |
|
| 930 | - | Ok(c) |
|
| 931 | - | } |
|
| 932 | - | } |
|
| 933 | - | ||
| 934 | - | // ------------------------------------------------------------- witnesses |
|
| 935 | - | ||
| 936 | - | /// C2SP tlog-cosignature: a witness's Ed25519 cosignature. |
|
| 937 | - | const SIG_COSIGNATURE: u8 = 0x04; |
|
| 938 | - | /// Oxygen's composite cosignature beside it (spec §14.8). |
|
| 939 | - | const SIG_COSIG_COMPOSITE: u8 = 0x4e; |
|
| 940 | - | ||
| 941 | - | /// What a cosignature signs: `cosignature/v1`, the witness's time in |
|
| 942 | - | /// seconds, then the checkpoint's text. |
|
| 943 | - | fn cosigned_message(text: &str, time: u64) -> String { |
|
| 944 | - | format!("cosignature/v1\ntime {time}\n{text}") |
|
| 945 | - | } |
|
| 946 | - | ||
| 947 | - | fn cosig_target(message: &str) -> ObjectRef { |
|
| 948 | - | let mut h = blake3::Hasher::new(); |
|
| 949 | - | h.update(b"oxygen-tlog-cosig\0"); |
|
| 950 | - | h.update(message.as_bytes()); |
|
| 951 | - | ObjectRef(*h.finalize().as_bytes()) |
|
| 952 | - | } |
|
| 953 | - | ||
| 954 | - | /// A witness's cosignature of a checkpoint's text at `time` (seconds): |
|
| 955 | - | /// the C2SP cosignature/v1 line, and a composite line that keeps it |
|
| 956 | - | /// post-quantum. Each carries the time before the signature. |
|
| 957 | - | pub fn cosign( |
|
| 958 | - | text: &str, |
|
| 959 | - | name: &str, |
|
| 960 | - | witness: &Identity, |
|
| 961 | - | sk: &SecretKey, |
|
| 962 | - | time: u64, |
|
| 963 | - | ) -> Result<Vec<NoteSignature>> { |
|
| 964 | - | if !valid_name(name) { |
|
| 965 | - | return Err(TlogError::BadNote(format!( |
|
| 966 | - | "{name:?} cannot name a witness" |
|
| 967 | - | ))); |
|
| 968 | - | } |
|
| 969 | - | let message = cosigned_message(text, time); |
|
| 970 | - | let mut ed = time.to_be_bytes().to_vec(); |
|
| 971 | - | ed.extend_from_slice(&sk.sign_ed25519(message.as_bytes())); |
|
| 972 | - | let mut composite = time.to_be_bytes().to_vec(); |
|
| 973 | - | composite.extend_from_slice(&cbor::encode( |
|
| 974 | - | &sk.sign(Domain::Checkpoint, &witness.hash(), &cosig_target(&message)) |
|
| 975 | - | .to_value(), |
|
| 976 | - | )); |
|
| 977 | - | Ok(vec![ |
|
| 978 | - | NoteSignature { |
|
| 979 | - | name: name.to_string(), |
|
| 980 | - | key_hash: key_hash(name, SIG_COSIGNATURE, &witness.ed25519_pk), |
|
| 981 | - | sig: ed, |
|
| 982 | - | }, |
|
| 983 | - | NoteSignature { |
|
| 984 | - | name: name.to_string(), |
|
| 985 | - | key_hash: key_hash(name, SIG_COSIG_COMPOSITE, &witness.encode()), |
|
| 986 | - | sig: composite, |
|
| 987 | - | }, |
|
| 988 | - | ]) |
|
| 989 | - | } |
|
| 990 | - | ||
| 991 | - | impl Note { |
|
| 992 | - | /// The time `witness` (named `name`) cosigned this note, once both its |
|
| 993 | - | /// lines verify and agree on the time. |
|
| 994 | - | pub fn cosigned_by(&self, name: &str, witness: &Identity) -> Result<u64> { |
|
| 995 | - | let find = |t: u8, key: &[u8]| { |
|
| 996 | - | let h = key_hash(name, t, key); |
|
| 997 | - | self.sigs.iter().find(|s| s.name == name && s.key_hash == h) |
|
| 998 | - | }; |
|
| 999 | - | let time_of = |sig: &[u8]| -> Result<(u64, Vec<u8>)> { |
|
| 1000 | - | if sig.len() < 9 { |
|
| 1001 | - | return Err(TlogError::BadSignature(format!( |
|
| 1002 | - | "{name}'s cosignature is short" |
|
| 1003 | - | ))); |
|
| 1004 | - | } |
|
| 1005 | - | let mut t = [0u8; 8]; |
|
| 1006 | - | t.copy_from_slice(&sig[..8]); |
|
| 1007 | - | Ok((u64::from_be_bytes(t), sig[8..].to_vec())) |
|
| 1008 | - | }; |
|
| 1009 | - | let ed = find(SIG_COSIGNATURE, &witness.ed25519_pk) |
|
| 1010 | - | .ok_or_else(|| TlogError::BadSignature(format!("no cosignature by {name}")))?; |
|
| 1011 | - | let (time, sig) = time_of(&ed.sig)?; |
|
| 1012 | - | let message = cosigned_message(&self.text, time); |
|
| 1013 | - | witness |
|
| 1014 | - | .verify_ed25519(message.as_bytes(), &sig) |
|
| 1015 | - | .map_err(|_| { |
|
| 1016 | - | TlogError::BadSignature(format!("{name}'s cosignature does not verify")) |
|
| 1017 | - | })?; |
|
| 1018 | - | let c = find(SIG_COSIG_COMPOSITE, &witness.encode()).ok_or_else(|| { |
|
| 1019 | - | TlogError::BadSignature(format!("no composite cosignature by {name}")) |
|
| 1020 | - | })?; |
|
| 1021 | - | let (ctime, csig) = time_of(&c.sig)?; |
|
| 1022 | - | if ctime != time { |
|
| 1023 | - | return Err(TlogError::BadSignature(format!( |
|
| 1024 | - | "{name}'s two cosignatures disagree on the time" |
|
| 1025 | - | ))); |
|
| 1026 | - | } |
|
| 1027 | - | let sig = cbor::decode(&csig) |
|
| 1028 | - | .map_err(|e| e.to_string()) |
|
| 1029 | - | .and_then(|v| Signature::from_value(&v).map_err(|e| e.to_string())) |
|
| 1030 | - | .map_err(|e| TlogError::BadSignature(format!("{name}'s composite cosignature: {e}")))?; |
|
| 1031 | - | verify(witness, &sig, Domain::Checkpoint, &cosig_target(&message)).map_err(|e| { |
|
| 1032 | - | TlogError::BadSignature(format!( |
|
| 1033 | - | "{name}'s composite cosignature does not verify: {e}" |
|
| 1034 | - | )) |
|
| 1035 | - | })?; |
|
| 1036 | - | Ok(time) |
|
| 1037 | - | } |
|
| 1038 | - | } |
|
| 1039 | - | ||
| 1040 | - | /// A C2SP tlog-witness `add-checkpoint` request: the size of the last |
|
| 1041 | - | /// checkpoint the witness cosigned for this log, the consistency proof |
|
| 1042 | - | /// from it, and the new checkpoint note. |
|
| 1043 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 1044 | - | pub struct AddCheckpoint { |
|
| 1045 | - | pub old_size: u64, |
|
| 1046 | - | pub proof: Vec<Hash>, |
|
| 1047 | - | pub note: String, |
|
| 1048 | - | } |
|
| 1049 | - | ||
| 1050 | - | impl AddCheckpoint { |
|
| 1051 | - | pub fn encode(&self) -> String { |
|
| 1052 | - | let mut out = format!("old {}\n", self.old_size); |
|
| 1053 | - | for h in &self.proof { |
|
| 1054 | - | out.push_str(&crate::dsse::base64(h)); |
|
| 1055 | - | out.push('\n'); |
|
| 1056 | - | } |
|
| 1057 | - | out.push('\n'); |
|
| 1058 | - | out.push_str(&self.note); |
|
| 1059 | - | out |
|
| 1060 | - | } |
|
| 1061 | - | ||
| 1062 | - | pub fn parse(body: &str) -> Result<AddCheckpoint> { |
|
| 1063 | - | let bad = |w: &str| TlogError::BadNote(format!("add-checkpoint: {w}")); |
|
| 1064 | - | let (head, note) = body |
|
| 1065 | - | .split_once("\n\n") |
|
| 1066 | - | .ok_or_else(|| bad("no blank line before the checkpoint"))?; |
|
| 1067 | - | let mut lines = head.split('\n'); |
|
| 1068 | - | let old_size = lines |
|
| 1069 | - | .next() |
|
| 1070 | - | .and_then(|l| l.strip_prefix("old ")) |
|
| 1071 | - | .and_then(|n| n.parse().ok()) |
|
| 1072 | - | .ok_or_else(|| bad("no old size"))?; |
|
| 1073 | - | let mut proof = Vec::new(); |
|
| 1074 | - | for l in lines { |
|
| 1075 | - | let h = crate::dsse::base64_decode(l) |
|
| 1076 | - | .and_then(|b| <Hash>::try_from(b).ok()) |
|
| 1077 | - | .ok_or_else(|| bad("a proof line is not a hash"))?; |
|
| 1078 | - | proof.push(h); |
|
| 1079 | - | } |
|
| 1080 | - | Ok(AddCheckpoint { |
|
| 1081 | - | old_size, |
|
| 1082 | - | proof, |
|
| 1083 | - | note: note.to_string(), |
|
| 1084 | - | }) |
|
| 1085 | - | } |
|
| 1086 | - | } |
|
| 1087 | - | ||
| 1088 | - | /// What a witness answers. |
|
| 1089 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 1090 | - | pub enum Witnessed { |
|
| 1091 | - | /// Cosigned: the checkpoint, and the cosignature lines to return. |
|
| 1092 | - | Cosigned { |
|
| 1093 | - | checkpoint: Checkpoint, |
|
| 1094 | - | lines: Vec<NoteSignature>, |
|
| 1095 | - | }, |
|
| 1096 | - | /// The request's old size is not the witness's: it answers with its own |
|
| 1097 | - | /// (409, so the log retries with a proof from there). |
|
| 1098 | - | Conflict(u64), |
|
| 1099 | - | } |
|
| 1100 | - | ||
| 1101 | - | /// Why a witness refused. |
|
| 1102 | - | #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] |
|
| 1103 | - | pub enum WitnessError { |
|
| 1104 | - | /// Not signed by the log's key (403). |
|
| 1105 | - | #[error("{0}")] |
|
| 1106 | - | Unsigned(String), |
|
| 1107 | - | /// The checkpoint does not extend what the witness cosigned (422). |
|
| 1108 | - | #[error("{0}")] |
|
| 1109 | - | Inconsistent(String), |
|
| 1110 | - | /// Malformed (400). |
|
| 1111 | - | #[error("{0}")] |
|
| 1112 | - | Bad(String), |
|
| 1113 | - | } |
|
| 1114 | - | ||
| 1115 | - | /// A witness: its name in cosignatures, and its key. |
|
| 1116 | - | pub struct WitnessKey<'a> { |
|
| 1117 | - | pub name: &'a str, |
|
| 1118 | - | pub identity: &'a Identity, |
|
| 1119 | - | pub sk: &'a SecretKey, |
|
| 1120 | - | } |
|
| 1121 | - | ||
| 1122 | - | /// A witness's decision (C2SP tlog-witness) on `req` for the log named |
|
| 1123 | - | /// `origin` with key `log_key`, whose last cosigned checkpoint was |
|
| 1124 | - | /// `latest` (size and root): cosign when the new checkpoint verifies and |
|
| 1125 | - | /// extends it, as `me` at `now` (seconds). |
|
| 1126 | - | pub fn witness( |
|
| 1127 | - | req: &AddCheckpoint, |
|
| 1128 | - | origin: &str, |
|
| 1129 | - | log_key: &Identity, |
|
| 1130 | - | latest: Option<(u64, Hash)>, |
|
| 1131 | - | me: &WitnessKey, |
|
| 1132 | - | now: u64, |
|
| 1133 | - | ) -> std::result::Result<Witnessed, WitnessError> { |
|
| 1134 | - | let note = Note::parse(&req.note).map_err(|e| WitnessError::Bad(e.to_string()))?; |
|
| 1135 | - | let cp = Checkpoint::verify(¬e, origin, log_key) |
|
| 1136 | - | .map_err(|e| WitnessError::Unsigned(e.to_string()))?; |
|
| 1137 | - | let (old, old_root) = latest.unwrap_or((0, empty_root())); |
|
| 1138 | - | if req.old_size != old { |
|
| 1139 | - | return Ok(Witnessed::Conflict(old)); |
|
| 1140 | - | } |
|
| 1141 | - | if cp.size < old { |
|
| 1142 | - | return Err(WitnessError::Bad(format!( |
|
| 1143 | - | "a checkpoint of {} entries is older than the {old} cosigned", |
|
| 1144 | - | cp.size |
|
| 1145 | - | ))); |
|
| 1146 | - | } |
|
| 1147 | - | let consistent = match (old, cp.size == old) { |
|
| 1148 | - | (0, _) => req.proof.is_empty(), |
|
| 1149 | - | (_, true) => req.proof.is_empty() && cp.root == old_root, |
|
| 1150 | - | _ => check_consistency(&req.proof, cp.size, &cp.root, old, &old_root).is_ok(), |
|
| 1151 | - | }; |
|
| 1152 | - | if !consistent { |
|
| 1153 | - | return Err(WitnessError::Inconsistent(format!( |
|
| 1154 | - | "the checkpoint of {} entries does not extend the one of {old} cosigned", |
|
| 1155 | - | cp.size |
|
| 1156 | - | ))); |
|
| 1157 | - | } |
|
| 1158 | - | let lines = cosign(¬e.text, me.name, me.identity, me.sk, now) |
|
| 1159 | - | .map_err(|e| WitnessError::Bad(e.to_string()))?; |
|
| 1160 | - | Ok(Witnessed::Cosigned { |
|
| 1161 | - | checkpoint: cp, |
|
| 1162 | - | lines, |
|
| 1163 | - | }) |
|
| 1164 | - | } |
|
| 1165 | - | ||
| 1166 | - | // -------------------------------------------------------- integrated time |
|
| 1167 | - | ||
| 1168 | - | /// How far a claimed time may stray from an integrated time before it is |
|
| 1169 | - | /// noted (spec §14.8, *Integrated time*): ten minutes, in milliseconds. |
|
| 1170 | - | pub const TIME_TOLERANCE_MS: u64 = 10 * 60 * 1000; |
|
| 1171 | - | ||
| 1172 | - | impl Note { |
|
| 1173 | - | /// The witnesses among `trusted` (name and identity) whose |
|
| 1174 | - | /// cosignatures of this note verify, with their times in seconds. |
|
| 1175 | - | /// Lines by anyone else are ignored. |
|
| 1176 | - | pub fn cosignatures(&self, trusted: &[(String, Identity)]) -> Vec<(String, u64)> { |
|
| 1177 | - | trusted |
|
| 1178 | - | .iter() |
|
| 1179 | - | .filter_map(|(n, id)| self.cosigned_by(n, id).ok().map(|t| (n.clone(), t))) |
|
| 1180 | - | .collect() |
|
| 1181 | - | } |
|
| 1182 | - | } |
|
| 1183 | - | ||
| 1184 | - | /// When witnesses first saw a log entry (spec §14.8): an upper bound on |
|
| 1185 | - | /// when what it logs was signed, by a clock neither its signer nor the |
|
| 1186 | - | /// log chose. |
|
| 1187 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 1188 | - | pub struct Integrated { |
|
| 1189 | - | /// The earliest trusted cosignature's time, in seconds. |
|
| 1190 | - | pub time: u64, |
|
| 1191 | - | /// The witness that made it. |
|
| 1192 | - | pub witness: String, |
|
| 1193 | - | /// The size of the checkpoint it cosigned. |
|
| 1194 | - | pub size: u64, |
|
| 1195 | - | } |
|
| 1196 | - | ||
| 1197 | - | impl Integrated { |
|
| 1198 | - | /// The time in milliseconds, as Oxygen's other times are. |
|
| 1199 | - | pub fn ms(&self) -> u64 { |
|
| 1200 | - | self.time.saturating_mul(1000) |
|
| 1201 | - | } |
|
| 1202 | - | } |
|
| 1203 | - | ||
| 1204 | - | /// Entry `index` (`entry`, its bytes)'s integrated time: the earliest time |
|
| 1205 | - | /// a witness in `trusted` cosigned one of `cosigned` (checkpoint notes, |
|
| 1206 | - | /// each with the entry's inclusion proof against it) that `log_key` |
|
| 1207 | - | /// signed for `origin` and that includes the entry. A note that does not |
|
| 1208 | - | /// verify, or does not include the entry, counts for nothing; `Err` says |
|
| 1209 | - | /// why no time could be read. |
|
| 1210 | - | pub fn integrated_time( |
|
| 1211 | - | entry: &[u8], |
|
| 1212 | - | index: u64, |
|
| 1213 | - | cosigned: &[(&str, &[Hash])], |
|
| 1214 | - | origin: &str, |
|
| 1215 | - | log_key: &Identity, |
|
| 1216 | - | trusted: &[(String, Identity)], |
|
| 1217 | - | ) -> std::result::Result<Integrated, String> { |
|
| 1218 | - | if cosigned.is_empty() { |
|
| 1219 | - | return Err("no witness has cosigned a checkpoint holding it yet".into()); |
|
| 1220 | - | } |
|
| 1221 | - | let leaf = leaf_hash(entry); |
|
| 1222 | - | let mut best: Option<Integrated> = None; |
|
| 1223 | - | let mut why = String::new(); |
|
| 1224 | - | for (text, proof) in cosigned { |
|
| 1225 | - | let checked = Note::parse(text) |
|
| 1226 | - | .and_then(|n| Checkpoint::verify(&n, origin, log_key).map(|c| (n, c))) |
|
| 1227 | - | .and_then(|(n, c)| { |
|
| 1228 | - | check_inclusion(proof, c.size, &c.root, index, &leaf)?; |
|
| 1229 | - | Ok((n, c)) |
|
| 1230 | - | }); |
|
| 1231 | - | let (note, cp) = match checked { |
|
| 1232 | - | Ok(x) => x, |
|
| 1233 | - | Err(e) => { |
|
| 1234 | - | why = format!("a cosigned checkpoint served for it does not hold it ({e})"); |
|
| 1235 | - | continue; |
|
| 1236 | - | } |
|
| 1237 | - | }; |
|
| 1238 | - | let times = note.cosignatures(trusted); |
|
| 1239 | - | if times.is_empty() { |
|
| 1240 | - | why = format!( |
|
| 1241 | - | "the checkpoint of {} entries holding it is cosigned by no witness this user trusts", |
|
| 1242 | - | cp.size |
|
| 1243 | - | ); |
|
| 1244 | - | continue; |
|
| 1245 | - | } |
|
| 1246 | - | for (witness, time) in times { |
|
| 1247 | - | if best.as_ref().is_none_or(|b| time < b.time) { |
|
| 1248 | - | best = Some(Integrated { |
|
| 1249 | - | time, |
|
| 1250 | - | witness, |
|
| 1251 | - | size: cp.size, |
|
| 1252 | - | }); |
|
| 1253 | - | } |
|
| 1254 | - | } |
|
| 1255 | - | } |
|
| 1256 | - | best.ok_or(why) |
|
| 1257 | - | } |
|
| 1258 | - | ||
| 1259 | - | #[cfg(test)] |
|
| 1260 | - | mod tests { |
|
| 1261 | - | use super::*; |
|
| 1262 | - | ||
| 1263 | - | /// A log in memory: its stored hashes, grown leaf by leaf. |
|
| 1264 | - | fn grow(leaves: u64) -> (Vec<Hash>, Vec<Hash>) { |
|
| 1265 | - | let mut stored: Vec<Hash> = Vec::new(); |
|
| 1266 | - | let mut leaf_hashes = Vec::new(); |
|
| 1267 | - | for n in 0..leaves { |
|
| 1268 | - | let h = leaf_hash(format!("entry {n}").as_bytes()); |
|
| 1269 | - | leaf_hashes.push(h); |
|
| 1270 | - | let add = stored_hashes_for_leaf(n, h, &stored).unwrap(); |
|
| 1271 | - | assert_eq!(stored.len() as u64, stored_hash_index(0, n)); |
|
| 1272 | - | stored.extend(add); |
|
| 1273 | - | assert_eq!(stored.len() as u64, stored_hash_count(n + 1)); |
|
| 1274 | - | } |
|
| 1275 | - | (stored, leaf_hashes) |
|
| 1276 | - | } |
|
| 1277 | - | ||
| 1278 | - | /// RFC 6962's MTH, straight from its definition. |
|
| 1279 | - | fn mth(leaves: &[Hash]) -> Hash { |
|
| 1280 | - | match leaves.len() { |
|
| 1281 | - | 0 => empty_root(), |
|
| 1282 | - | 1 => leaves[0], |
|
| 1283 | - | n => { |
|
| 1284 | - | let (k, _) = maxpow2(n as u64); |
|
| 1285 | - | node_hash(&mth(&leaves[..k as usize]), &mth(&leaves[k as usize..])) |
|
| 1286 | - | } |
|
| 1287 | - | } |
|
| 1288 | - | } |
|
| 1289 | - | ||
| 1290 | - | #[test] |
|
| 1291 | - | fn stored_indexes_round_trip() { |
|
| 1292 | - | for level in 0..6 { |
|
| 1293 | - | for n in 0..300 { |
|
| 1294 | - | let i = stored_hash_index(level, n); |
|
| 1295 | - | assert_eq!(split_stored_hash_index(i), (level, n)); |
|
| 1296 | - | } |
|
| 1297 | - | } |
|
| 1298 | - | } |
|
| 1299 | - | ||
| 1300 | - | #[test] |
|
| 1301 | - | fn roots_match_rfc_6962_and_every_proof_verifies() { |
|
| 1302 | - | let (stored, leaves) = grow(140); |
|
| 1303 | - | for t in 1..=140u64 { |
|
| 1304 | - | let root = tree_hash(t, &stored).unwrap(); |
|
| 1305 | - | assert_eq!(root, mth(&leaves[..t as usize]), "root of {t}"); |
|
| 1306 | - | for n in 0..t { |
|
| 1307 | - | let p = prove_inclusion(t, n, &stored).unwrap(); |
|
| 1308 | - | check_inclusion(&p, t, &root, n, &leaves[n as usize]).unwrap(); |
|
| 1309 | - | // Another leaf, another tree, a tampered path: refused. |
|
| 1310 | - | let other = (n + 1) % t; |
|
| 1311 | - | if other != n { |
|
| 1312 | - | assert!(check_inclusion(&p, t, &root, n, &leaves[other as usize]).is_err()); |
|
| 1313 | - | } |
|
| 1314 | - | if let Some(first) = p.first() { |
|
| 1315 | - | let mut bad = p.clone(); |
|
| 1316 | - | bad[0] = node_hash(first, first); |
|
| 1317 | - | assert!(check_inclusion(&bad, t, &root, n, &leaves[n as usize]).is_err()); |
|
| 1318 | - | } |
|
| 1319 | - | let old = n + 1; |
|
| 1320 | - | let old_root = tree_hash(old, &stored).unwrap(); |
|
| 1321 | - | let c = prove_consistency(t, old, &stored).unwrap(); |
|
| 1322 | - | check_consistency(&c, t, &root, old, &old_root).unwrap(); |
|
| 1323 | - | if old < t { |
|
| 1324 | - | let fork = node_hash(&old_root, &old_root); |
|
| 1325 | - | assert!( |
|
| 1326 | - | check_consistency(&c, t, &root, old, &fork).is_err(), |
|
| 1327 | - | "a forked history" |
|
| 1328 | - | ); |
|
| 1329 | - | } |
|
| 1330 | - | } |
|
| 1331 | - | } |
|
| 1332 | - | assert_eq!(tree_hash(0, &stored).unwrap(), empty_root()); |
|
| 1333 | - | } |
|
| 1334 | - | ||
| 1335 | - | #[test] |
|
| 1336 | - | fn tiles_hold_the_stored_hashes() { |
|
| 1337 | - | let n = 70_000u64; |
|
| 1338 | - | let (stored, _) = grow(n); |
|
| 1339 | - | let tiles = Tile::new_tiles(0, n); |
|
| 1340 | - | // 273 full tiles of level 0 and a partial one, one full and one |
|
| 1341 | - | // partial at level 1, a partial at level 2. |
|
| 1342 | - | assert_eq!(tiles.iter().filter(|t| t.level == 0).count(), 274); |
|
| 1343 | - | assert_eq!(tiles.iter().filter(|t| t.level == 2).count(), 1); |
|
| 1344 | - | let datas: Vec<(Tile, Vec<u8>)> = tiles |
|
| 1345 | - | .iter() |
|
| 1346 | - | .map(|t| (*t, t.read_data(&stored).unwrap())) |
|
| 1347 | - | .collect(); |
|
| 1348 | - | for (i, want) in stored.iter().enumerate().step_by(7) { |
|
| 1349 | - | let (t, ..) = Tile::for_index(i as u64); |
|
| 1350 | - | let (tile, data) = datas |
|
| 1351 | - | .iter() |
|
| 1352 | - | .find(|(d, _)| d.level == t.level && d.n == t.n && d.width >= t.width) |
|
| 1353 | - | .expect("the tile holding it was written"); |
|
| 1354 | - | assert_eq!( |
|
| 1355 | - | tile.hash_at(data, i as u64).unwrap(), |
|
| 1356 | - | *want, |
|
| 1357 | - | "stored hash {i}" |
|
| 1358 | - | ); |
|
| 1359 | - | } |
|
| 1360 | - | // Growing writes only what changed. |
|
| 1361 | - | let grown = Tile::new_tiles(n, n + 300); |
|
| 1362 | - | assert!(grown.iter().all(|t| t.level > 0 || t.n >= n / 256)); |
|
| 1363 | - | } |
|
| 1364 | - | ||
| 1365 | - | #[test] |
|
| 1366 | - | fn tile_paths() { |
|
| 1367 | - | assert_eq!(index_path(1_234_067), "x001/x234/067"); |
|
| 1368 | - | assert_eq!(index_path(5), "005"); |
|
| 1369 | - | assert_eq!(index_path(1000), "x001/000"); |
|
| 1370 | - | assert_eq!(parse_index_path("x001/x234/067"), Some(1_234_067)); |
|
| 1371 | - | assert_eq!(parse_index_path("x001/234/067"), None); |
|
| 1372 | - | let t = Tile { |
|
| 1373 | - | level: 1, |
|
| 1374 | - | n: 1234, |
|
| 1375 | - | width: 17, |
|
| 1376 | - | }; |
|
| 1377 | - | assert_eq!(t.path(), "tile/1/x001/234.p/17"); |
|
| 1378 | - | assert_eq!(Tile::parse("1/x001/234.p/17"), Some(t)); |
|
| 1379 | - | assert_eq!( |
|
| 1380 | - | Tile { |
|
| 1381 | - | level: 0, |
|
| 1382 | - | n: 3, |
|
| 1383 | - | width: 256 |
|
| 1384 | - | } |
|
| 1385 | - | .entries_path(), |
|
| 1386 | - | "tile/entries/003" |
|
| 1387 | - | ); |
|
| 1388 | - | assert_eq!(Tile::parse("0/003.p/256"), None, "a full tile has no .p"); |
|
| 1389 | - | } |
|
| 1390 | - | ||
| 1391 | - | #[test] |
|
| 1392 | - | fn entries_and_bundles_round_trip() { |
|
| 1393 | - | let e = Entry { |
|
| 1394 | - | kind: "ref".into(), |
|
| 1395 | - | repo: [7; 16], |
|
| 1396 | - | object: ObjectRef([1; 32]), |
|
| 1397 | - | signers: vec![ObjectRef([2; 32])], |
|
| 1398 | - | received: 1_792_000_000_000, |
|
| 1399 | - | }; |
|
| 1400 | - | assert_eq!(Entry::decode(&e.encode()).unwrap(), e); |
|
| 1401 | - | let b = encode_bundle(&[e.encode(), b"x".to_vec()]).unwrap(); |
|
| 1402 | - | assert_eq!(decode_bundle(&b).unwrap(), vec![e.encode(), b"x".to_vec()]); |
|
| 1403 | - | assert!(decode_bundle(&b[..b.len() - 1]).is_err()); |
|
| 1404 | - | assert_eq!(e.leaf_hash(), leaf_hash(&e.encode())); |
|
| 1405 | - | } |
|
| 1406 | - | ||
| 1407 | - | #[test] |
|
| 1408 | - | fn a_witness_cosigns_what_extends_what_it_saw_and_nothing_else() { |
|
| 1409 | - | let log_sk = SecretKey::generate(); |
|
| 1410 | - | let log = log_sk.identity("log", "[email protected]", 1); |
|
| 1411 | - | let w_sk = SecretKey::generate(); |
|
| 1412 | - | let w = w_sk.identity("witness", "[email protected]", 1); |
|
| 1413 | - | let origin = "hub.example/log"; |
|
| 1414 | - | let (stored, _) = grow(40); |
|
| 1415 | - | let note_of = |n: u64| { |
|
| 1416 | - | Checkpoint { |
|
| 1417 | - | origin: origin.into(), |
|
| 1418 | - | size: n, |
|
| 1419 | - | root: tree_hash(n, &stored).unwrap(), |
|
| 1420 | - | extensions: vec![], |
|
| 1421 | - | } |
|
| 1422 | - | .sign(&log, &log_sk) |
|
| 1423 | - | .unwrap() |
|
| 1424 | - | .encode() |
|
| 1425 | - | }; |
|
| 1426 | - | let ask = |old: u64, n: u64, latest: Option<(u64, Hash)>| { |
|
| 1427 | - | let proof = if old == 0 || old == n { |
|
| 1428 | - | vec![] |
|
| 1429 | - | } else { |
|
| 1430 | - | prove_consistency(n, old, &stored).unwrap() |
|
| 1431 | - | }; |
|
| 1432 | - | let req = AddCheckpoint { |
|
| 1433 | - | old_size: old, |
|
| 1434 | - | proof, |
|
| 1435 | - | note: note_of(n), |
|
| 1436 | - | }; |
|
| 1437 | - | let req = AddCheckpoint::parse(&req.encode()).unwrap(); |
|
| 1438 | - | let me = WitnessKey { |
|
| 1439 | - | name: "w.example", |
|
| 1440 | - | identity: &w, |
|
| 1441 | - | sk: &w_sk, |
|
| 1442 | - | }; |
|
| 1443 | - | witness(&req, origin, &log, latest, &me, 1_792_300_000) |
|
| 1444 | - | }; |
|
| 1445 | - | // First sight, then a later checkpoint with its proof. |
|
| 1446 | - | let Ok(Witnessed::Cosigned { checkpoint, lines }) = ask(0, 10, None) else { |
|
| 1447 | - | panic!() |
|
| 1448 | - | }; |
|
| 1449 | - | let mut note = Note::parse(¬e_of(10)).unwrap(); |
|
| 1450 | - | note.sigs.extend(lines); |
|
| 1451 | - | let back = Note::parse(¬e.encode()).unwrap(); |
|
| 1452 | - | assert_eq!(back.cosigned_by("w.example", &w).unwrap(), 1_792_300_000); |
|
| 1453 | - | assert!( |
|
| 1454 | - | back.cosigned_by("w.example", &log).is_err(), |
|
| 1455 | - | "another witness's key" |
|
| 1456 | - | ); |
|
| 1457 | - | let latest = Some((10, checkpoint.root)); |
|
| 1458 | - | assert!(matches!( |
|
| 1459 | - | ask(10, 40, latest), |
|
| 1460 | - | Ok(Witnessed::Cosigned { .. }) |
|
| 1461 | - | )); |
|
| 1462 | - | // The log's idea of what the witness saw is wrong: told the size. |
|
| 1463 | - | assert_eq!(ask(5, 40, latest), Ok(Witnessed::Conflict(10))); |
|
| 1464 | - | // A fork: what the witness saw is not a prefix. |
|
| 1465 | - | let forked = Some((10, [9; 32])); |
|
| 1466 | - | assert!(matches!( |
|
| 1467 | - | ask(10, 40, forked), |
|
| 1468 | - | Err(WitnessError::Inconsistent(_)) |
|
| 1469 | - | )); |
|
| 1470 | - | // Signed by someone else. |
|
| 1471 | - | let req = AddCheckpoint { |
|
| 1472 | - | old_size: 0, |
|
| 1473 | - | proof: vec![], |
|
| 1474 | - | note: note_of(10), |
|
| 1475 | - | }; |
|
| 1476 | - | assert!(matches!( |
|
| 1477 | - | witness( |
|
| 1478 | - | &req, |
|
| 1479 | - | origin, |
|
| 1480 | - | &w, |
|
| 1481 | - | None, |
|
| 1482 | - | &WitnessKey { |
|
| 1483 | - | name: "w.example", |
|
| 1484 | - | identity: &w, |
|
| 1485 | - | sk: &w_sk |
|
| 1486 | - | }, |
|
| 1487 | - | 1 |
|
| 1488 | - | ), |
|
| 1489 | - | Err(WitnessError::Unsigned(_)) |
|
| 1490 | - | )); |
|
| 1491 | - | } |
|
| 1492 | - | ||
| 1493 | - | #[test] |
|
| 1494 | - | fn checkpoints_are_signed_twice_and_checked() { |
|
| 1495 | - | let sk = SecretKey::generate(); |
|
| 1496 | - | let hub = sk.identity("oxygenhub.io", "[email protected]", 1); |
|
| 1497 | - | let (stored, _) = grow(10); |
|
| 1498 | - | let c = Checkpoint { |
|
| 1499 | - | origin: "oxygenhub.io/log".into(), |
|
| 1500 | - | size: 10, |
|
| 1501 | - | root: tree_hash(10, &stored).unwrap(), |
|
| 1502 | - | extensions: vec![], |
|
| 1503 | - | }; |
|
| 1504 | - | let note = c.sign(&hub, &sk).unwrap(); |
|
| 1505 | - | let served = note.encode(); |
|
| 1506 | - | assert!(served.starts_with("oxygenhub.io/log\n10\n")); |
|
| 1507 | - | assert_eq!(served.matches("\n\u{2014} oxygenhub.io/log ").count(), 2); |
|
| 1508 | - | let back = Note::parse(&served).unwrap(); |
|
| 1509 | - | assert_eq!( |
|
| 1510 | - | Checkpoint::verify(&back, "oxygenhub.io/log", &hub).unwrap(), |
|
| 1511 | - | c |
|
| 1512 | - | ); |
|
| 1513 | - | // Another key, another origin, a changed size, a lost line: refused. |
|
| 1514 | - | let other_sk = SecretKey::generate(); |
|
| 1515 | - | let other = other_sk.identity("x", "x@x", 1); |
|
| 1516 | - | assert!(Checkpoint::verify(&back, "oxygenhub.io/log", &other).is_err()); |
|
| 1517 | - | assert!(Checkpoint::verify(&back, "elsewhere/log", &hub).is_err()); |
|
| 1518 | - | let forged = Note { |
|
| 1519 | - | text: back.text.replace("\n10\n", "\n11\n"), |
|
| 1520 | - | ..back.clone() |
|
| 1521 | - | }; |
|
| 1522 | - | assert!(Checkpoint::verify(&forged, "oxygenhub.io/log", &hub).is_err()); |
|
| 1523 | - | let ed_only = Note { |
|
| 1524 | - | sigs: vec![back.sigs[0].clone()], |
|
| 1525 | - | ..back.clone() |
|
| 1526 | - | }; |
|
| 1527 | - | assert!( |
|
| 1528 | - | matches!(Checkpoint::verify(&ed_only, "oxygenhub.io/log", &hub), Err(TlogError::BadSignature(m)) if m.contains("composite")), |
|
| 1529 | - | "the post-quantum line is required" |
|
| 1530 | - | ); |
|
| 1531 | - | // The Ed25519 line is a standard note signature over the text. |
|
| 1532 | - | let h = key_hash("oxygenhub.io/log", SIG_ED25519, &hub.ed25519_pk); |
|
| 1533 | - | assert_eq!(back.sigs[0].key_hash, h); |
|
| 1534 | - | assert!(hub |
|
| 1535 | - | .verify_ed25519(back.text.as_bytes(), &back.sigs[0].sig) |
|
| 1536 | - | .is_ok()); |
|
| 1537 | - | } |
|
| 1538 | - | ||
| 1539 | - | #[test] |
|
| 1540 | - | fn an_entry_is_integrated_when_a_trusted_witness_first_cosigned_it() { |
|
| 1541 | - | let log_sk = SecretKey::generate(); |
|
| 1542 | - | let log = log_sk.identity("log", "[email protected]", 1); |
|
| 1543 | - | let origin = "hub.example/log"; |
|
| 1544 | - | let witness = |name: &str| { |
|
| 1545 | - | let sk = SecretKey::generate(); |
|
| 1546 | - | (name.to_string(), sk.identity(name, "[email protected]", 1), sk) |
|
| 1547 | - | }; |
|
| 1548 | - | let (a, b, c) = ( |
|
| 1549 | - | witness("a.example"), |
|
| 1550 | - | witness("b.example"), |
|
| 1551 | - | witness("c.example"), |
|
| 1552 | - | ); |
|
| 1553 | - | let trusted = vec![(a.0.clone(), a.1.clone()), (b.0.clone(), b.1.clone())]; |
|
| 1554 | - | // A log of 40 real entries, so `integrated_time` hashes the leaf. |
|
| 1555 | - | let leaf_bytes = |n: u64| { |
|
| 1556 | - | Entry { |
|
| 1557 | - | kind: "ref".into(), |
|
| 1558 | - | repo: [n as u8; 16], |
|
| 1559 | - | object: ObjectRef([n as u8; 32]), |
|
| 1560 | - | signers: vec![], |
|
| 1561 | - | received: n, |
|
| 1562 | - | } |
|
| 1563 | - | .encode() |
|
| 1564 | - | }; |
|
| 1565 | - | let mut stored: Vec<Hash> = Vec::new(); |
|
| 1566 | - | for n in 0..40 { |
|
| 1567 | - | let add = stored_hashes_for_leaf(n, leaf_hash(&leaf_bytes(n)), &stored).unwrap(); |
|
| 1568 | - | stored.extend(add); |
|
| 1569 | - | } |
|
| 1570 | - | // Checkpoint `n`, cosigned by each of `by` at its time. |
|
| 1571 | - | type W = (String, Identity, SecretKey); |
|
| 1572 | - | let cosigned = |n: u64, by: &[(&W, u64)]| { |
|
| 1573 | - | let cp = Checkpoint { |
|
| 1574 | - | origin: origin.into(), |
|
| 1575 | - | size: n, |
|
| 1576 | - | root: tree_hash(n, &stored).unwrap(), |
|
| 1577 | - | extensions: vec![], |
|
| 1578 | - | }; |
|
| 1579 | - | let mut note = cp.sign(&log, &log_sk).unwrap(); |
|
| 1580 | - | for ((name, id, sk), t) in by { |
|
| 1581 | - | note.sigs |
|
| 1582 | - | .extend(cosign(¬e.text, name, id, sk, *t).unwrap()); |
|
| 1583 | - | } |
|
| 1584 | - | note.encode() |
|
| 1585 | - | }; |
|
| 1586 | - | let proof = |n: u64| prove_inclusion(n, 7, &stored).unwrap(); |
|
| 1587 | - | let entry = leaf_bytes(7); |
|
| 1588 | - | let time = |served: &[(String, Vec<Hash>)]| { |
|
| 1589 | - | let s: Vec<(&str, &[Hash])> = served |
|
| 1590 | - | .iter() |
|
| 1591 | - | .map(|(n, p)| (n.as_str(), p.as_slice())) |
|
| 1592 | - | .collect(); |
|
| 1593 | - | integrated_time(&entry, 7, &s, origin, &log, &trusted) |
|
| 1594 | - | }; |
|
| 1595 | - | ||
| 1596 | - | // One checkpoint, two witnesses: the earlier cosignature. |
|
| 1597 | - | let one = cosigned(10, &[(&a, 1_792_300_050), (&b, 1_792_300_020)]); |
|
| 1598 | - | let got = time(&[(one.clone(), proof(10))]).unwrap(); |
|
| 1599 | - | assert_eq!( |
|
| 1600 | - | (got.time, got.witness.as_str(), got.size), |
|
| 1601 | - | (1_792_300_020, "b.example", 10) |
|
| 1602 | - | ); |
|
| 1603 | - | assert_eq!(got.ms(), 1_792_300_020_000); |
|
| 1604 | - | // Witnesses that first cosigned different checkpoints: the |
|
| 1605 | - | // earliest of all. |
|
| 1606 | - | let later = cosigned(20, &[(&a, 1_792_300_010)]); |
|
| 1607 | - | let got = time(&[(later.clone(), proof(20)), (one.clone(), proof(10))]).unwrap(); |
|
| 1608 | - | assert_eq!( |
|
| 1609 | - | (got.time, got.witness.as_str()), |
|
| 1610 | - | (1_792_300_010, "a.example") |
|
| 1611 | - | ); |
|
| 1612 | - | ||
| 1613 | - | // A witness this user does not trust is no witness, however early. |
|
| 1614 | - | let stranger = cosigned(10, &[(&c, 1)]); |
|
| 1615 | - | let err = time(&[(stranger, proof(10))]).unwrap_err(); |
|
| 1616 | - | assert!(err.contains("no witness this user trusts"), "{err}"); |
|
| 1617 | - | // A forged cosignature under a trusted name: c's key signed it. |
|
| 1618 | - | let forged = cosigned(10, &[(&(a.0.clone(), c.1.clone(), c.2.clone()), 1)]); |
|
| 1619 | - | assert!(time(&[(forged.clone(), proof(10))]).is_err()); |
|
| 1620 | - | // Beside a real one, it changes nothing. |
|
| 1621 | - | let got = time(&[(forged, proof(10)), (one.clone(), proof(10))]).unwrap(); |
|
| 1622 | - | assert_eq!(got.time, 1_792_300_020); |
|
| 1623 | - | // A time moved after cosigning no longer verifies. |
|
| 1624 | - | let mut moved = Note::parse(&one).unwrap(); |
|
| 1625 | - | for s in moved.sigs.iter_mut().filter(|s| s.name == "b.example") { |
|
| 1626 | - | s.sig[..8].copy_from_slice(&1u64.to_be_bytes()); |
|
| 1627 | - | } |
|
| 1628 | - | let got = time(&[(moved.encode(), proof(10))]).unwrap(); |
|
| 1629 | - | assert_eq!(got.witness, "a.example", "b's lines no longer verify"); |
|
| 1630 | - | ||
| 1631 | - | // A checkpoint that does not hold the entry, a proof for another |
|
| 1632 | - | // size, another log's key: no time. |
|
| 1633 | - | let early = cosigned(5, &[(&a, 1)]); |
|
| 1634 | - | assert!(time(&[(early, proof(10))]).is_err()); |
|
| 1635 | - | assert!(time(&[(one.clone(), proof(20))]).is_err()); |
|
| 1636 | - | let other_sk = SecretKey::generate(); |
|
| 1637 | - | let other = other_sk.identity("x", "x@x", 1); |
|
| 1638 | - | let s = [(one.as_str(), proof(10))]; |
|
| 1639 | - | let s: Vec<(&str, &[Hash])> = s.iter().map(|(n, p)| (*n, p.as_slice())).collect(); |
|
| 1640 | - | assert!(integrated_time(&entry, 7, &s, origin, &other, &trusted).is_err()); |
|
| 1641 | - | assert!(integrated_time(&entry, 7, &s, "elsewhere/log", &log, &trusted).is_err()); |
|
| 1642 | - | assert!(integrated_time(&leaf_bytes(8), 7, &s, origin, &log, &trusted).is_err()); |
|
| 1643 | - | // Nothing served. |
|
| 1644 | - | assert!(time(&[]).unwrap_err().contains("no witness has cosigned")); |
|
| 1645 | - | } |
|
| 1646 | - | } |
modified crates/oxy-core/src/tree.rs +0 −176
| … | 183 unchanged lines |
||
| 184 | 184 | /// cheap to clone. |
|
| 185 | 185 | pub entries: std::sync::Arc<BTreeMap<String, TreeEntry>>, |
|
| 186 | 186 | pub conflicts: Vec<TreeConflict>, |
|
| 187 | - | /// Nodes added at a place another node holds with identical content |
|
| 188 | - | /// (spec §9.2), each with the node the view shows there in its stead: |
|
| 189 | - | /// one file to every reader, and retired by the first change that |
|
| 190 | - | /// touches the node shown ([`TreeGraph::unify`]). |
|
| 191 | - | pub unified: BTreeMap<NodeId, NodeId>, |
|
| 192 | 187 | } |
|
| 193 | 188 | ||
| 194 | 189 | impl TreeView { |
|
| … | 588 unchanged lines |
||
| 783 | 778 | } |
|
| 784 | 779 | view.entries = std::sync::Arc::new(entries); |
|
| 785 | 780 | view |
|
| 786 | - | } |
|
| 787 | - | } |
|
| 788 | - | ||
| 789 | - | impl TreeGraph { |
|
| 790 | - | /// `view` (this graph's) with each name conflict between nodes added |
|
| 791 | - | /// with identical content made one entry (spec §9.2): the node whose |
|
| 792 | - | /// placement there has the least key is shown at the plain name, and |
|
| 793 | - | /// the others are listed in [`TreeView::unified`]. Only a group of |
|
| 794 | - | /// files, symlinks or subrepos of one kind and mode, none in another |
|
| 795 | - | /// conflict, is offered to `same`, one entry per node, which says |
|
| 796 | - | /// whether their contents are identical (`None`: it cannot tell yet). |
|
| 797 | - | /// The key order is the same for every peer, so all show one node. |
|
| 798 | - | /// Also returns whether `same` answered for every group. |
|
| 799 | - | pub fn unify( |
|
| 800 | - | &self, |
|
| 801 | - | mut view: TreeView, |
|
| 802 | - | same: impl Fn(&[&TreeEntry]) -> Option<bool>, |
|
| 803 | - | ) -> (TreeView, bool) { |
|
| 804 | - | let mut decided = true; |
|
| 805 | - | if !view |
|
| 806 | - | .conflicts |
|
| 807 | - | .iter() |
|
| 808 | - | .any(|c| matches!(c, TreeConflict::Name { .. })) |
|
| 809 | - | { |
|
| 810 | - | return (view, decided); |
|
| 811 | - | } |
|
| 812 | - | // Nodes in any other conflict stay as they are. |
|
| 813 | - | let mut troubled: HashSet<NodeId> = HashSet::new(); |
|
| 814 | - | for c in &view.conflicts { |
|
| 815 | - | match c { |
|
| 816 | - | TreeConflict::Name { .. } => {} |
|
| 817 | - | TreeConflict::MultiPlacement { node, .. } |
|
| 818 | - | | TreeConflict::Orphan { node, .. } |
|
| 819 | - | | TreeConflict::Cycle { node } |
|
| 820 | - | | TreeConflict::Mode { node, .. } => { |
|
| 821 | - | troubled.insert(*node); |
|
| 822 | - | } |
|
| 823 | - | } |
|
| 824 | - | } |
|
| 825 | - | let mut of_node: HashMap<NodeId, Vec<&TreeEntry>> = HashMap::new(); |
|
| 826 | - | for e in view.entries.values() { |
|
| 827 | - | of_node.entry(e.node).or_default().push(e); |
|
| 828 | - | } |
|
| 829 | - | // (paths to drop, entry to add, duplicates) per unified group. |
|
| 830 | - | let mut merged: Vec<(Vec<String>, TreeEntry, Vec<NodeId>)> = Vec::new(); |
|
| 831 | - | let mut gone: HashSet<usize> = HashSet::new(); |
|
| 832 | - | for (i, c) in view.conflicts.iter().enumerate() { |
|
| 833 | - | let TreeConflict::Name { |
|
| 834 | - | parent, |
|
| 835 | - | name, |
|
| 836 | - | nodes, |
|
| 837 | - | } = c |
|
| 838 | - | else { |
|
| 839 | - | continue; |
|
| 840 | - | }; |
|
| 841 | - | if nodes.iter().any(|n| troubled.contains(n)) { |
|
| 842 | - | continue; |
|
| 843 | - | } |
|
| 844 | - | // The group's entries: each node's placements at this place. |
|
| 845 | - | let mut group: Vec<&TreeEntry> = Vec::new(); |
|
| 846 | - | for n in nodes { |
|
| 847 | - | for e in of_node.get(n).into_iter().flatten() { |
|
| 848 | - | if self |
|
| 849 | - | .placement(&e.placement) |
|
| 850 | - | .is_some_and(|p| p.parent == *parent && p.name == *name) |
|
| 851 | - | { |
|
| 852 | - | group.push(e); |
|
| 853 | - | } |
|
| 854 | - | } |
|
| 855 | - | } |
|
| 856 | - | let Some(first) = group.first() else { |
|
| 857 | - | continue; |
|
| 858 | - | }; |
|
| 859 | - | if first.kind == NodeKind::Dir |
|
| 860 | - | || group |
|
| 861 | - | .iter() |
|
| 862 | - | .any(|e| e.kind != first.kind || e.mode != first.mode) |
|
| 863 | - | { |
|
| 864 | - | continue; |
|
| 865 | - | } |
|
| 866 | - | group.sort_by_key(|e| e.placement); |
|
| 867 | - | let mut reps: Vec<&TreeEntry> = Vec::new(); |
|
| 868 | - | for e in &group { |
|
| 869 | - | if !reps.iter().any(|r| r.node == e.node) { |
|
| 870 | - | reps.push(e); |
|
| 871 | - | } |
|
| 872 | - | } |
|
| 873 | - | if reps.len() < 2 { |
|
| 874 | - | continue; |
|
| 875 | - | } |
|
| 876 | - | match same(&reps) { |
|
| 877 | - | Some(true) => {} |
|
| 878 | - | Some(false) => continue, |
|
| 879 | - | None => { |
|
| 880 | - | decided = false; |
|
| 881 | - | continue; |
|
| 882 | - | } |
|
| 883 | - | } |
|
| 884 | - | let shown = reps[0]; |
|
| 885 | - | let path = match shown.path.rsplit_once('/') { |
|
| 886 | - | Some((dir, _)) => format!("{dir}/{name}"), |
|
| 887 | - | None => name.clone(), |
|
| 888 | - | }; |
|
| 889 | - | merged.push(( |
|
| 890 | - | group.iter().map(|e| e.path.clone()).collect(), |
|
| 891 | - | TreeEntry { |
|
| 892 | - | path, |
|
| 893 | - | ..shown.clone() |
|
| 894 | - | }, |
|
| 895 | - | reps[1..].iter().map(|e| e.node).collect(), |
|
| 896 | - | )); |
|
| 897 | - | gone.insert(i); |
|
| 898 | - | } |
|
| 899 | - | drop(of_node); |
|
| 900 | - | if merged.is_empty() { |
|
| 901 | - | return (view, decided); |
|
| 902 | - | } |
|
| 903 | - | let entries = std::sync::Arc::make_mut(&mut view.entries); |
|
| 904 | - | for (paths, shown, dups) in merged { |
|
| 905 | - | for p in paths { |
|
| 906 | - | entries.remove(&p); |
|
| 907 | - | } |
|
| 908 | - | for d in dups { |
|
| 909 | - | view.unified.insert(d, shown.node); |
|
| 910 | - | } |
|
| 911 | - | entries.insert(shown.path.clone(), shown); |
|
| 912 | - | } |
|
| 913 | - | let mut i = 0; |
|
| 914 | - | view.conflicts.retain(|_| { |
|
| 915 | - | i += 1; |
|
| 916 | - | !gone.contains(&(i - 1)) |
|
| 917 | - | }); |
|
| 918 | - | (view, decided) |
|
| 919 | 781 | } |
|
| 920 | 782 | } |
|
| 921 | 783 | ||
| … | 393 unchanged lines |
||
| 1315 | 1177 | Ok(Some(TreeView { |
|
| 1316 | 1178 | entries: std::sync::Arc::new(entries.into_iter().collect()), |
|
| 1317 | 1179 | conflicts: Vec::new(), |
|
| 1318 | - | unified: BTreeMap::new(), |
|
| 1319 | 1180 | })) |
|
| 1320 | 1181 | } |
|
| 1321 | 1182 | } |
|
| … | 189 unchanged lines |
||
| 1511 | 1372 | nodes: vec![nid(1), nid(2)] |
|
| 1512 | 1373 | }] |
|
| 1513 | 1374 | ); |
|
| 1514 | - | } |
|
| 1515 | - | ||
| 1516 | - | /// Identical additions at one place are one entry, the node with the |
|
| 1517 | - | /// least placement key shown; anything else stays a name conflict. |
|
| 1518 | - | #[test] |
|
| 1519 | - | fn identical_additions_unify() { |
|
| 1520 | - | let mut g = TreeGraph::new(); |
|
| 1521 | - | add(&mut g, 1, 0, 1, NodeKind::Dir, NodeId::ROOT, "d"); |
|
| 1522 | - | add(&mut g, 3, 0, 3, NodeKind::File, nid(1), "a.txt"); |
|
| 1523 | - | add(&mut g, 2, 0, 2, NodeKind::File, nid(1), "a.txt"); |
|
| 1524 | - | let raw = g.view(); |
|
| 1525 | - | let (v, decided) = g.unify(raw.clone(), |es| { |
|
| 1526 | - | assert_eq!(es.len(), 2, "one entry per node"); |
|
| 1527 | - | Some(true) |
|
| 1528 | - | }); |
|
| 1529 | - | assert!(decided); |
|
| 1530 | - | assert_eq!(paths(&v), ["d", "d/a.txt"]); |
|
| 1531 | - | assert!(v.conflicts.is_empty()); |
|
| 1532 | - | assert_eq!(v.entries["d/a.txt"].node, nid(2), "the least placement key"); |
|
| 1533 | - | assert_eq!(v.entries["d/a.txt"].placement, (h(2), 0)); |
|
| 1534 | - | assert_eq!(v.unified, [(nid(3), nid(2))].into_iter().collect()); |
|
| 1535 | - | // Different contents, or not known yet: the conflict as it was. |
|
| 1536 | - | let (v, decided) = g.unify(raw.clone(), |_| Some(false)); |
|
| 1537 | - | assert!(decided); |
|
| 1538 | - | assert_eq!(v, raw); |
|
| 1539 | - | let (v, decided) = g.unify(raw.clone(), |_| None); |
|
| 1540 | - | assert!(!decided); |
|
| 1541 | - | assert_eq!(v, raw); |
|
| 1542 | - | // Directories, and kinds that differ, are never offered. |
|
| 1543 | - | let mut g = TreeGraph::new(); |
|
| 1544 | - | add(&mut g, 1, 0, 1, NodeKind::Dir, NodeId::ROOT, "d"); |
|
| 1545 | - | add(&mut g, 2, 0, 2, NodeKind::Dir, NodeId::ROOT, "d"); |
|
| 1546 | - | add(&mut g, 3, 0, 3, NodeKind::File, NodeId::ROOT, "f"); |
|
| 1547 | - | add(&mut g, 4, 0, 4, NodeKind::Symlink, NodeId::ROOT, "f"); |
|
| 1548 | - | let raw = g.view(); |
|
| 1549 | - | let (v, _) = g.unify(raw.clone(), |_| panic!("not offered")); |
|
| 1550 | - | assert_eq!(v, raw); |
|
| 1551 | 1375 | } |
|
| 1552 | 1376 | ||
| 1553 | 1377 | #[test] |
|
| … | 220 unchanged lines |
modified crates/oxy-server/Cargo.toml +0 −1
| … | 14 unchanged lines |
||
| 15 | 15 | [dev-dependencies] |
|
| 16 | 16 | rcgen = "0.14" |
|
| 17 | 17 | tempfile = "3" |
|
| 18 | - | tower = { version = "0.5", features = ["util"] } |
modified crates/oxy-server/src/lib.rs +3 −145
| … | 10 unchanged lines |
||
| 11 | 11 | //! and authorization) and routing to [`Server::handle`]; `router` is the |
|
| 12 | 12 | //! standalone server built on [`DirBackend`]. |
|
| 13 | 13 | ||
| 14 | - | pub mod witness; |
|
| 15 | - | ||
| 16 | 14 | use axum::body::Bytes; |
|
| 17 | 15 | use axum::extract::{Path, State}; |
|
| 18 | 16 | use axum::http::{header, HeaderMap, Method, StatusCode}; |
|
| … | 46 unchanged lines |
||
| 65 | 63 | /// (accepted or refused), the states each accepted reference moved |
|
| 66 | 64 | /// between, and the changes stored, for logs and cache invalidation. |
|
| 67 | 65 | fn after_push(&self, _name: &str, _report: &oxy_core::repo::PushReport, _headers: &HeaderMap) {} |
|
| 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 | - | ||
| 84 | - | /// Vet a push by what it adds before anything of it is stored or any |
|
| 85 | - | /// reference moves (spec §16.1): `bundle` is the push as decoded, its |
|
| 86 | - | /// changes with their lines, `updates` the references it would move. |
|
| 87 | - | /// A host refuses the whole push by returning a [`Failure`], whose |
|
| 88 | - | /// text reaches the client as the push's error, as it is, several |
|
| 89 | - | /// lines included; it should answer neither 401, 403 nor 404, which a |
|
| 90 | - | /// client takes for a credential it should change. Called for every |
|
| 91 | - | /// part of a push sent in parts (each part's changes are its own), |
|
| 92 | - | /// outside the repository's push lock, so a slow vet holds up no |
|
| 93 | - | /// other push. The default accepts everything at no cost. |
|
| 94 | - | fn vet_push( |
|
| 95 | - | &self, |
|
| 96 | - | _name: &str, |
|
| 97 | - | _repo: &Repo, |
|
| 98 | - | _bundle: &oxy_core::protocol::Bundle, |
|
| 99 | - | _updates: &[oxy_core::protocol::RefUpdate], |
|
| 100 | - | _headers: &HeaderMap, |
|
| 101 | - | ) -> Result<(), Failure> { |
|
| 102 | - | Ok(()) |
|
| 103 | - | } |
|
| 104 | - | ||
| 105 | - | /// Add the host's transparency log to a refs answer (spec §14.8): its |
|
| 106 | - | /// checkpoint, and each attestation's entry and inclusion proof. A host |
|
| 107 | - | /// without a log leaves it as it is. |
|
| 108 | - | fn add_log(&self, _name: &str, _refs: &mut oxy_core::protocol::RefsResponse) {} |
|
| 109 | 66 | ||
| 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 | - | ||
| 135 | 67 | /// The bookmark of `name` that pushes may never delete (its default), |
|
| 136 | 68 | /// when the host keeps one apart from the repository's own config. |
|
| 137 | 69 | fn default_bookmark(&self, _name: &str) -> Option<String> { |
|
| … | 39 unchanged lines |
||
| 177 | 109 | Fetch, |
|
| 178 | 110 | Objects, |
|
| 179 | 111 | Checkpoint, |
|
| 180 | - | CheckpointIndex, |
|
| 181 | 112 | Shallow, |
|
| 182 | 113 | Push, |
|
| 183 | 114 | } |
|
| … | 7 unchanged lines |
||
| 191 | 122 | "fetch" => Endpoint::Fetch, |
|
| 192 | 123 | "objects" => Endpoint::Objects, |
|
| 193 | 124 | "checkpoint" => Endpoint::Checkpoint, |
|
| 194 | - | "checkpoint-index" => Endpoint::CheckpointIndex, |
|
| 195 | 125 | "shallow" => Endpoint::Shallow, |
|
| 196 | 126 | "push" => Endpoint::Push, |
|
| 197 | 127 | _ => return None, |
|
| … | 8 unchanged lines |
||
| 206 | 136 | Endpoint::Fetch => "fetch", |
|
| 207 | 137 | Endpoint::Objects => "objects", |
|
| 208 | 138 | Endpoint::Checkpoint => "checkpoint", |
|
| 209 | - | Endpoint::CheckpointIndex => "checkpoint-index", |
|
| 210 | 139 | Endpoint::Shallow => "shallow", |
|
| 211 | 140 | Endpoint::Push => "push", |
|
| 212 | 141 | } |
|
| … | 109 unchanged lines |
||
| 322 | 251 | if let Some(d) = self.backend.default_bookmark(name) { |
|
| 323 | 252 | refs.default_bookmark = Some(d); |
|
| 324 | 253 | } |
|
| 325 | - | self.backend.add_log(name, &mut refs); |
|
| 326 | - | self.backend.adjust_refs(name, &r, &mut refs, headers); |
|
| 327 | 254 | ok(wire::encode_refs(r.id()?, &refs)) |
|
| 328 | 255 | } |
|
| 329 | 256 | Endpoint::Negotiate => { |
|
| … | 18 unchanged lines |
||
| 348 | 275 | let heads = wire::decode_hashes(&body)?; |
|
| 349 | 276 | let lock = self.lock_for(name); |
|
| 350 | 277 | let _guard = lock.lock().unwrap(); |
|
| 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)?)) |
|
| 278 | + | ok(wire::encode_bundle(&r.checkpoint_bundle_for(&heads)?)) |
|
| 360 | 279 | } |
|
| 361 | 280 | Endpoint::Shallow => { |
|
| 362 | 281 | let (heads, depth) = wire::decode_shallow(&body)?; |
|
| … | 1 unchanged lines |
||
| 364 | 283 | } |
|
| 365 | 284 | Endpoint::Push => { |
|
| 366 | 285 | let (bundle, updates, policy, tags) = wire::decode_push(&body)?; |
|
| 367 | - | // The host vets what the push adds before anything is stored |
|
| 368 | - | // or bound: a refused push leaves the repository as it was. |
|
| 369 | - | self.backend |
|
| 370 | - | .vet_push(name, &r, &bundle, &updates, headers)?; |
|
| 371 | 286 | let lock = self.lock_for(name); |
|
| 372 | 287 | let _guard = lock.lock().unwrap(); |
|
| 373 | 288 | if r.is_unbound() { |
|
| … | 15 unchanged lines |
||
| 389 | 304 | let (refused, updates): (Vec<_>, Vec<_>) = updates.into_iter().partition(|u| { |
|
| 390 | 305 | u.new_state.is_zero() && protected.as_deref() == Some(u.name.as_str()) |
|
| 391 | 306 | }); |
|
| 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)?; |
|
| 307 | + | let mut report = r.receive_push_report(bundle, updates, policy, tags)?; |
|
| 404 | 308 | // The request named references, all refused here: it was no |
|
| 405 | 309 | // push of objects alone, whatever the repository saw. |
|
| 406 | - | if !refused.is_empty() || !withdrawals_refused.is_empty() { |
|
| 310 | + | if !refused.is_empty() { |
|
| 407 | 311 | report |
|
| 408 | 312 | .results |
|
| 409 | 313 | .retain(|r| r.name != oxy_core::repo::OBJECTS_RESULT); |
|
| … | 8 unchanged lines |
||
| 418 | 322 | ), |
|
| 419 | 323 | }); |
|
| 420 | 324 | } |
|
| 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 | - | } |
|
| 428 | 325 | for r in report.results.iter().filter(|r| !r.ok) { |
|
| 429 | 326 | eprintln!( |
|
| 430 | 327 | "oxy-server: push to {name}: {} refused: {}", |
|
| 431 | 328 | r.name, r.reason |
|
| 432 | 329 | ); |
|
| 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 | 330 | } |
|
| 447 | 331 | self.backend.after_push(name, &report, headers); |
|
| 448 | 332 | ok(wire::encode_push_response(&report.results)) |
|
| … | 49 unchanged lines |
||
| 498 | 382 | *byte = u8::from_str_radix(&h[2 * i..2 * i + 2], 16).ok()?; |
|
| 499 | 383 | } |
|
| 500 | 384 | Some(out) |
|
| 501 | - | } |
|
| 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 | 385 | } |
|
| 510 | 386 | ||
| 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 | - | ||
| 517 | 387 | /// The token of an `Authorization: Bearer` header, if any. |
|
| 518 | 388 | pub fn bearer_token(headers: &HeaderMap) -> Option<&str> { |
|
| 519 | 389 | headers |
|
| … | 15 unchanged lines |
||
| 535 | 405 | None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(), |
|
| 536 | 406 | } |
|
| 537 | 407 | } |
|
| 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 | 408 | ||
| 548 | 409 | /// The router of `oxy serve`: `/{repo}/oxy/v1/{endpoint}` over a [`DirBackend`]. |
|
| 549 | 410 | pub fn router(cfg: ServerConfig) -> Router { |
|
| … | 1 unchanged lines |
||
| 551 | 412 | root: cfg.root, |
|
| 552 | 413 | tokens: cfg.tokens, |
|
| 553 | 414 | }); |
|
| 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). |
|
| 556 | 415 | Router::new() |
|
| 557 | 416 | .route("/{repo}/oxy/v1/{endpoint}", any(dispatch)) |
|
| 558 | - | .layer(axum::extract::DefaultBodyLimit::max(BODY_LIMIT)) |
|
| 559 | 417 | .with_state(server) |
|
| 560 | 418 | } |
|
| 561 | 419 | ||
| … | 29 unchanged lines |
removed crates/oxy-server/src/witness.rs +0 −272
| 1 | - | //! `oxy witness serve` (Oxygen spec §14.8, oxygenhub docs/transparency.md): |
|
| 2 | - | //! a small transparency-log witness anyone can run. It cosigns the |
|
| 3 | - | //! checkpoints of the logs it is told about (C2SP tlog-witness), each only |
|
| 4 | - | //! when it extends the last one this witness cosigned, with its own key |
|
| 5 | - | //! and clock, so a log cannot show two histories without the witness |
|
| 6 | - | //! seeing it. |
|
| 7 | - | //! |
|
| 8 | - | //! State is one file per log in the state directory: the size and root of |
|
| 9 | - | //! the last checkpoint cosigned, replaced atomically. |
|
| 10 | - | ||
| 11 | - | use axum::extract::State; |
|
| 12 | - | use axum::http::{header, StatusCode}; |
|
| 13 | - | use axum::response::{IntoResponse, Response}; |
|
| 14 | - | use axum::routing::post; |
|
| 15 | - | use axum::Router; |
|
| 16 | - | use oxy_core::identity::{Identity, SecretKey}; |
|
| 17 | - | use oxy_core::tlog::{self, AddCheckpoint, Checkpoint, Note, WitnessError, WitnessKey, Witnessed}; |
|
| 18 | - | use std::path::PathBuf; |
|
| 19 | - | use std::sync::{Arc, Mutex}; |
|
| 20 | - | ||
| 21 | - | /// What a witness serves. |
|
| 22 | - | pub struct WitnessConfig { |
|
| 23 | - | /// Its name in cosignatures (a host name, no spaces). |
|
| 24 | - | pub name: String, |
|
| 25 | - | pub identity: Identity, |
|
| 26 | - | pub sk: SecretKey, |
|
| 27 | - | /// The logs it witnesses: their origins and keys. |
|
| 28 | - | pub logs: Vec<(String, Identity)>, |
|
| 29 | - | /// Where it keeps what it cosigned. |
|
| 30 | - | pub state: PathBuf, |
|
| 31 | - | pub tls: Option<crate::TlsFiles>, |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | struct Witness { |
|
| 35 | - | cfg: WitnessConfig, |
|
| 36 | - | /// One add-checkpoint at a time: each reads and moves its log's state. |
|
| 37 | - | lock: Mutex<()>, |
|
| 38 | - | } |
|
| 39 | - | ||
| 40 | - | fn state_file(w: &Witness, origin: &str) -> PathBuf { |
|
| 41 | - | let safe: String = origin |
|
| 42 | - | .chars() |
|
| 43 | - | .map(|c| { |
|
| 44 | - | if c.is_ascii_alphanumeric() || c == '.' || c == '-' { |
|
| 45 | - | c |
|
| 46 | - | } else { |
|
| 47 | - | '_' |
|
| 48 | - | } |
|
| 49 | - | }) |
|
| 50 | - | .collect(); |
|
| 51 | - | w.cfg.state.join(safe) |
|
| 52 | - | } |
|
| 53 | - | ||
| 54 | - | fn read_state(w: &Witness, origin: &str) -> Option<(u64, tlog::Hash)> { |
|
| 55 | - | let text = std::fs::read_to_string(state_file(w, origin)).ok()?; |
|
| 56 | - | let (size, root) = text.trim().split_once(' ')?; |
|
| 57 | - | let root = oxy_core::hash::ObjectRef::from_hex(root)?; |
|
| 58 | - | Some((size.parse().ok()?, root.0)) |
|
| 59 | - | } |
|
| 60 | - | ||
| 61 | - | fn plain(status: StatusCode, why: &str) -> Response { |
|
| 62 | - | ( |
|
| 63 | - | status, |
|
| 64 | - | [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], |
|
| 65 | - | format!("{why}\n"), |
|
| 66 | - | ) |
|
| 67 | - | .into_response() |
|
| 68 | - | } |
|
| 69 | - | ||
| 70 | - | async fn add_checkpoint(State(w): State<Arc<Witness>>, body: String) -> Response { |
|
| 71 | - | let Ok(req) = AddCheckpoint::parse(&body) else { |
|
| 72 | - | return plain(StatusCode::BAD_REQUEST, "not an add-checkpoint request"); |
|
| 73 | - | }; |
|
| 74 | - | let Some(origin) = Note::parse(&req.note) |
|
| 75 | - | .ok() |
|
| 76 | - | .and_then(|n| Checkpoint::parse(&n.text).ok()) |
|
| 77 | - | .map(|c| c.origin) |
|
| 78 | - | else { |
|
| 79 | - | return plain(StatusCode::BAD_REQUEST, "the checkpoint does not parse"); |
|
| 80 | - | }; |
|
| 81 | - | let Some(key) = w |
|
| 82 | - | .cfg |
|
| 83 | - | .logs |
|
| 84 | - | .iter() |
|
| 85 | - | .find(|(o, _)| *o == origin) |
|
| 86 | - | .map(|(_, k)| k.clone()) |
|
| 87 | - | else { |
|
| 88 | - | return plain( |
|
| 89 | - | StatusCode::NOT_FOUND, |
|
| 90 | - | &format!("this witness does not witness {origin}"), |
|
| 91 | - | ); |
|
| 92 | - | }; |
|
| 93 | - | let _held = w.lock.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 94 | - | let latest = read_state(&w, &origin); |
|
| 95 | - | let me = WitnessKey { |
|
| 96 | - | name: &w.cfg.name, |
|
| 97 | - | identity: &w.cfg.identity, |
|
| 98 | - | sk: &w.cfg.sk, |
|
| 99 | - | }; |
|
| 100 | - | let now = std::time::SystemTime::now() |
|
| 101 | - | .duration_since(std::time::UNIX_EPOCH) |
|
| 102 | - | .map(|d| d.as_secs()) |
|
| 103 | - | .unwrap_or(0); |
|
| 104 | - | let conflict = |n: u64| { |
|
| 105 | - | ( |
|
| 106 | - | StatusCode::CONFLICT, |
|
| 107 | - | [(header::CONTENT_TYPE, "text/x.tlog.size")], |
|
| 108 | - | format!("{n}\n"), |
|
| 109 | - | ) |
|
| 110 | - | .into_response() |
|
| 111 | - | }; |
|
| 112 | - | match tlog::witness(&req, &origin, &key, latest, &me, now) { |
|
| 113 | - | Ok(Witnessed::Cosigned { checkpoint, lines }) => { |
|
| 114 | - | let text = format!( |
|
| 115 | - | "{} {}\n", |
|
| 116 | - | checkpoint.size, |
|
| 117 | - | oxy_core::hash::ObjectRef(checkpoint.root).to_hex() |
|
| 118 | - | ); |
|
| 119 | - | if let Err(e) = std::fs::create_dir_all(&w.cfg.state).and_then(|_| { |
|
| 120 | - | oxy_core::durable::write_atomic(&state_file(&w, &origin), text.as_bytes()) |
|
| 121 | - | }) { |
|
| 122 | - | return plain( |
|
| 123 | - | StatusCode::INTERNAL_SERVER_ERROR, |
|
| 124 | - | &format!("keeping the witness's state: {e}"), |
|
| 125 | - | ); |
|
| 126 | - | } |
|
| 127 | - | let body = Note { |
|
| 128 | - | text: String::new(), |
|
| 129 | - | sigs: lines, |
|
| 130 | - | } |
|
| 131 | - | .encode(); |
|
| 132 | - | ( |
|
| 133 | - | [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], |
|
| 134 | - | body.trim_start_matches('\n').to_string(), |
|
| 135 | - | ) |
|
| 136 | - | .into_response() |
|
| 137 | - | } |
|
| 138 | - | Ok(Witnessed::Conflict(n)) => conflict(n), |
|
| 139 | - | Err(WitnessError::Unsigned(e)) => plain(StatusCode::FORBIDDEN, &e), |
|
| 140 | - | Err(WitnessError::Inconsistent(e)) => plain(StatusCode::UNPROCESSABLE_ENTITY, &e), |
|
| 141 | - | Err(WitnessError::Bad(e)) => plain(StatusCode::BAD_REQUEST, &e), |
|
| 142 | - | } |
|
| 143 | - | } |
|
| 144 | - | ||
| 145 | - | /// The witness's routes: `POST /add-checkpoint`. |
|
| 146 | - | pub fn router(cfg: WitnessConfig) -> Router { |
|
| 147 | - | Router::new() |
|
| 148 | - | .route("/add-checkpoint", post(add_checkpoint)) |
|
| 149 | - | .with_state(Arc::new(Witness { |
|
| 150 | - | cfg, |
|
| 151 | - | lock: Mutex::new(()), |
|
| 152 | - | })) |
|
| 153 | - | } |
|
| 154 | - | ||
| 155 | - | /// Serve a witness on `listener` until stopped. |
|
| 156 | - | pub fn run_blocking( |
|
| 157 | - | listener: std::net::TcpListener, |
|
| 158 | - | mut cfg: WitnessConfig, |
|
| 159 | - | ) -> std::io::Result<()> { |
|
| 160 | - | let rt = tokio::runtime::Builder::new_multi_thread() |
|
| 161 | - | .enable_all() |
|
| 162 | - | .build()?; |
|
| 163 | - | let tls = cfg.tls.take(); |
|
| 164 | - | rt.block_on(async move { |
|
| 165 | - | listener.set_nonblocking(true)?; |
|
| 166 | - | match tls { |
|
| 167 | - | Some(tls) => { |
|
| 168 | - | let _ = rustls::crypto::ring::default_provider().install_default(); |
|
| 169 | - | let rustls_cfg = |
|
| 170 | - | axum_server::tls_rustls::RustlsConfig::from_pem_file(&tls.cert, &tls.key) |
|
| 171 | - | .await |
|
| 172 | - | .map_err(|e| std::io::Error::other(format!("loading TLS files: {e}")))?; |
|
| 173 | - | axum_server::from_tcp_rustls(listener, rustls_cfg)? |
|
| 174 | - | .serve(router(cfg).into_make_service()) |
|
| 175 | - | .await |
|
| 176 | - | } |
|
| 177 | - | None => axum::serve(tokio::net::TcpListener::from_std(listener)?, router(cfg)).await, |
|
| 178 | - | } |
|
| 179 | - | }) |
|
| 180 | - | } |
|
| 181 | - | ||
| 182 | - | #[cfg(test)] |
|
| 183 | - | mod tests { |
|
| 184 | - | use super::*; |
|
| 185 | - | use axum::body::Body; |
|
| 186 | - | use axum::http::Request; |
|
| 187 | - | use tower::ServiceExt; |
|
| 188 | - | ||
| 189 | - | #[tokio::test] |
|
| 190 | - | async fn the_witness_cosigns_and_keeps_what_it_cosigned() { |
|
| 191 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 192 | - | let log_sk = SecretKey::generate(); |
|
| 193 | - | let log = log_sk.identity("log", "[email protected]", 1); |
|
| 194 | - | let w_sk = SecretKey::generate(); |
|
| 195 | - | let w = w_sk.identity("witness", "[email protected]", 1); |
|
| 196 | - | let cfg = || WitnessConfig { |
|
| 197 | - | name: "witness.example".into(), |
|
| 198 | - | identity: w.clone(), |
|
| 199 | - | sk: w_sk.clone(), |
|
| 200 | - | logs: vec![("hub.example/log".into(), log.clone())], |
|
| 201 | - | state: dir.path().join("state"), |
|
| 202 | - | tls: None, |
|
| 203 | - | }; |
|
| 204 | - | let mut stored: Vec<tlog::Hash> = Vec::new(); |
|
| 205 | - | for n in 0..9u64 { |
|
| 206 | - | stored.extend( |
|
| 207 | - | tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&[n as u8]), &stored).unwrap(), |
|
| 208 | - | ); |
|
| 209 | - | } |
|
| 210 | - | let note = |n: u64| { |
|
| 211 | - | Checkpoint { |
|
| 212 | - | origin: "hub.example/log".into(), |
|
| 213 | - | size: n, |
|
| 214 | - | root: tlog::tree_hash(n, &stored).unwrap(), |
|
| 215 | - | extensions: vec![], |
|
| 216 | - | } |
|
| 217 | - | .sign(&log, &log_sk) |
|
| 218 | - | .unwrap() |
|
| 219 | - | .encode() |
|
| 220 | - | }; |
|
| 221 | - | let post = |body: String| async { |
|
| 222 | - | let res = router(cfg()) |
|
| 223 | - | .oneshot( |
|
| 224 | - | Request::post("/add-checkpoint") |
|
| 225 | - | .body(Body::from(body)) |
|
| 226 | - | .unwrap(), |
|
| 227 | - | ) |
|
| 228 | - | .await |
|
| 229 | - | .unwrap(); |
|
| 230 | - | let st = res.status(); |
|
| 231 | - | let b = axum::body::to_bytes(res.into_body(), 1 << 20) |
|
| 232 | - | .await |
|
| 233 | - | .unwrap(); |
|
| 234 | - | (st, String::from_utf8(b.to_vec()).unwrap()) |
|
| 235 | - | }; |
|
| 236 | - | let (st, lines) = post( |
|
| 237 | - | AddCheckpoint { |
|
| 238 | - | old_size: 0, |
|
| 239 | - | proof: vec![], |
|
| 240 | - | note: note(4), |
|
| 241 | - | } |
|
| 242 | - | .encode(), |
|
| 243 | - | ) |
|
| 244 | - | .await; |
|
| 245 | - | assert_eq!(st, StatusCode::OK, "{lines}"); |
|
| 246 | - | let cosigned = |
|
| 247 | - | Note::parse(&format!("{}\n{lines}", Note::parse(¬e(4)).unwrap().text)).unwrap(); |
|
| 248 | - | assert!(cosigned.cosigned_by("witness.example", &w).is_ok()); |
|
| 249 | - | // Kept across a restart (a new router): a wrong old size is told. |
|
| 250 | - | let (st, size) = post( |
|
| 251 | - | AddCheckpoint { |
|
| 252 | - | old_size: 0, |
|
| 253 | - | proof: vec![], |
|
| 254 | - | note: note(9), |
|
| 255 | - | } |
|
| 256 | - | .encode(), |
|
| 257 | - | ) |
|
| 258 | - | .await; |
|
| 259 | - | assert_eq!((st, size.trim()), (StatusCode::CONFLICT, "4")); |
|
| 260 | - | let proof = tlog::prove_consistency(9, 4, &stored).unwrap(); |
|
| 261 | - | let (st, _) = post( |
|
| 262 | - | AddCheckpoint { |
|
| 263 | - | old_size: 4, |
|
| 264 | - | proof, |
|
| 265 | - | note: note(9), |
|
| 266 | - | } |
|
| 267 | - | .encode(), |
|
| 268 | - | ) |
|
| 269 | - | .await; |
|
| 270 | - | assert_eq!(st, StatusCode::OK); |
|
| 271 | - | } |
|
| 272 | - | } |
modified crates/oxy-server/tests/http.rs +0 −567
| … | 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. |
|
| 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 | 117 | #[test] |
|
| 147 | 118 | fn objects_endpoint_serves_lazy_blobs() { |
|
| 148 | 119 | hermetic(); |
|
| … | 568 unchanged lines |
||
| 717 | 688 | { |
|
| 718 | 689 | use std::os::unix::fs::PermissionsExt; |
|
| 719 | 690 | fs::set_permissions(&attest, fs::Permissions::from_mode(0o755)).unwrap(); |
|
| 720 | - | } |
|
| 721 | - | } |
|
| 722 | - | ||
| 723 | - | /// oxygen#112: a subrepo checkout whose stored login for the hub is stale |
|
| 724 | - | /// pushes with the token its parent checkout holds for the same hub, once |
|
| 725 | - | /// the stale one is refused; a token the subrepo sets itself is never |
|
| 726 | - | /// swapped for another. |
|
| 727 | - | #[test] |
|
| 728 | - | fn a_refused_stored_token_falls_back_to_the_parents() { |
|
| 729 | - | hermetic(); |
|
| 730 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 731 | - | let root = dir.path(); |
|
| 732 | - | let served = root.join("served"); |
|
| 733 | - | Repo::init(&served.join("parent")).unwrap(); |
|
| 734 | - | Repo::init(&served.join("child")).unwrap(); |
|
| 735 | - | let base = start(&served, vec!["good".into()]); |
|
| 736 | - | ||
| 737 | - | let work = root.join("work"); |
|
| 738 | - | let mut parent = Repo::clone(&format!("{base}/parent"), &work).unwrap(); |
|
| 739 | - | parent.config.set("remote.origin.token", "good").unwrap(); |
|
| 740 | - | parent.save_config().unwrap(); |
|
| 741 | - | // The subrepo's own home holds a stale login for this hub. |
|
| 742 | - | let home = root.join("home"); |
|
| 743 | - | oxy_core::credentials::Credentials::at(&home) |
|
| 744 | - | .save(&base, "stale", Some("andy"), None) |
|
| 745 | - | .unwrap(); |
|
| 746 | - | let nested = work.join("deps").join("child"); |
|
| 747 | - | let mut child = Repo::clone(&format!("{base}/child"), &nested) |
|
| 748 | - | .unwrap() |
|
| 749 | - | .with_home(&home); |
|
| 750 | - | let options = child.options_for_url(&format!("{base}/child")); |
|
| 751 | - | assert_eq!( |
|
| 752 | - | options.token.as_deref(), |
|
| 753 | - | Some("stale"), |
|
| 754 | - | "the stored login comes first" |
|
| 755 | - | ); |
|
| 756 | - | assert_eq!( |
|
| 757 | - | options.fallback_tokens, |
|
| 758 | - | vec!["good".to_string()], |
|
| 759 | - | "the parent's, to fall back on" |
|
| 760 | - | ); |
|
| 761 | - | assert!(!options.fallback_on_401_only); |
|
| 762 | - | write(&nested, "c.txt", b"child\n"); |
|
| 763 | - | child.commit("child work", &[]).unwrap(); |
|
| 764 | - | let pushed = child.push("origin", false).unwrap(); |
|
| 765 | - | assert!(pushed[0].ok, "{}", pushed[0].reason); |
|
| 766 | - | assert_eq!( |
|
| 767 | - | Repo::open(&served.join("child")) |
|
| 768 | - | .unwrap() |
|
| 769 | - | .bookmark_heads() |
|
| 770 | - | .unwrap() |
|
| 771 | - | .len(), |
|
| 772 | - | 1 |
|
| 773 | - | ); |
|
| 774 | - | ||
| 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. |
|
| 778 | - | child.config.set("remote.origin.token", "wrong").unwrap(); |
|
| 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 | - | ); |
|
| 790 | - | write(&nested, "d.txt", b"more\n"); |
|
| 791 | - | child.commit("more", &[]).unwrap(); |
|
| 792 | - | let err = child.push("origin", false).err().unwrap().to_string(); |
|
| 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); |
|
| 807 | - | } |
|
| 808 | - | ||
| 809 | - | /// A host refuses a push by what it adds (`Backend::vet_push`): the client |
|
| 810 | - | /// sees the host's reason, every line of it, and nothing is stored, bound |
|
| 811 | - | /// or moved; a push without that content then lands. |
|
| 812 | - | #[test] |
|
| 813 | - | fn a_host_refuses_a_push_by_its_content() { |
|
| 814 | - | use axum::extract::{Path as AxPath, State}; |
|
| 815 | - | use axum::http::{HeaderMap, Method, StatusCode}; |
|
| 816 | - | use axum::response::Response; |
|
| 817 | - | use oxy_core::objects::Edit; |
|
| 818 | - | use oxy_core::protocol::{Bundle, RefUpdate}; |
|
| 819 | - | use oxy_server::{Access, Backend, DirBackend, Endpoint, Failure, Server}; |
|
| 820 | - | use std::sync::Arc; |
|
| 821 | - | ||
| 822 | - | struct Vetting(DirBackend); |
|
| 823 | - | impl Backend for Vetting { |
|
| 824 | - | fn open(&self, name: &str) -> Result<Repo, Failure> { |
|
| 825 | - | self.0.open(name) |
|
| 826 | - | } |
|
| 827 | - | fn authorize( |
|
| 828 | - | &self, |
|
| 829 | - | name: &str, |
|
| 830 | - | access: Access, |
|
| 831 | - | headers: &HeaderMap, |
|
| 832 | - | ) -> Result<(), Failure> { |
|
| 833 | - | self.0.authorize(name, access, headers) |
|
| 834 | - | } |
|
| 835 | - | fn vet_push( |
|
| 836 | - | &self, |
|
| 837 | - | _name: &str, |
|
| 838 | - | _repo: &Repo, |
|
| 839 | - | bundle: &Bundle, |
|
| 840 | - | updates: &[RefUpdate], |
|
| 841 | - | _headers: &HeaderMap, |
|
| 842 | - | ) -> Result<(), Failure> { |
|
| 843 | - | let adds_it = bundle.changes.iter().any(|(c, _)| { |
|
| 844 | - | c.edits.iter().any(|e| match e { |
|
| 845 | - | Edit::Insert { lines, .. } => lines |
|
| 846 | - | .iter() |
|
| 847 | - | .any(|l| l.windows(11).any(|w| w == b"DO NOT PUSH")), |
|
| 848 | - | _ => false, |
|
| 849 | - | }) |
|
| 850 | - | }); |
|
| 851 | - | if adds_it { |
|
| 852 | - | return Err(Failure( |
|
| 853 | - | StatusCode::UNPROCESSABLE_ENTITY, |
|
| 854 | - | format!( |
|
| 855 | - | "refused: a change adds DO NOT PUSH\n a.txt:2\nremove it and push {} again", |
|
| 856 | - | updates.first().map(|u| u.name.as_str()).unwrap_or("") |
|
| 857 | - | ), |
|
| 858 | - | )); |
|
| 859 | - | } |
|
| 860 | - | Ok(()) |
|
| 861 | - | } |
|
| 862 | 691 | } |
|
| 863 | - | async fn route( |
|
| 864 | - | State(server): State<Arc<Server>>, |
|
| 865 | - | AxPath((repo, endpoint)): AxPath<(String, String)>, |
|
| 866 | - | method: Method, |
|
| 867 | - | headers: HeaderMap, |
|
| 868 | - | body: axum::body::Bytes, |
|
| 869 | - | ) -> Response { |
|
| 870 | - | let e = Endpoint::parse(&endpoint).unwrap(); |
|
| 871 | - | server.handle(repo, e, &method, &headers, body).await |
|
| 872 | - | } |
|
| 873 | - | ||
| 874 | - | hermetic(); |
|
| 875 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 876 | - | let root = dir.path(); |
|
| 877 | - | let served = root.join("served"); |
|
| 878 | - | let hosted = Repo::init_bare_unbound(&served.join("lib")).unwrap(); |
|
| 879 | - | let server = Server::new(Vetting(DirBackend { |
|
| 880 | - | root: served.clone(), |
|
| 881 | - | tokens: vec![], |
|
| 882 | - | })); |
|
| 883 | - | let listener = TcpListener::bind("127.0.0.1:0").unwrap(); |
|
| 884 | - | let port = listener.local_addr().unwrap().port(); |
|
| 885 | - | std::thread::spawn(move || { |
|
| 886 | - | let rt = tokio::runtime::Builder::new_multi_thread() |
|
| 887 | - | .enable_all() |
|
| 888 | - | .build() |
|
| 889 | - | .unwrap(); |
|
| 890 | - | rt.block_on(async move { |
|
| 891 | - | listener.set_nonblocking(true).unwrap(); |
|
| 892 | - | let app = axum::Router::new() |
|
| 893 | - | .route("/{repo}/oxy/v1/{endpoint}", axum::routing::any(route)) |
|
| 894 | - | .with_state(server); |
|
| 895 | - | axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app) |
|
| 896 | - | .await |
|
| 897 | - | .unwrap(); |
|
| 898 | - | }); |
|
| 899 | - | }); |
|
| 900 | - | std::thread::sleep(std::time::Duration::from_millis(50)); |
|
| 901 | - | let url = format!("http://127.0.0.1:{port}/lib"); |
|
| 902 | - | ||
| 903 | - | let work = root.join("work"); |
|
| 904 | - | let mut local = Repo::init(&work).unwrap(); |
|
| 905 | - | local.config.set("remote.origin.url", &url).unwrap(); |
|
| 906 | - | local.save_config().unwrap(); |
|
| 907 | - | write(&work, "a.txt", b"one\nDO NOT PUSH\n"); |
|
| 908 | - | local.snapshot("test").unwrap(); |
|
| 909 | - | let bad = local.commit("a", &[]).unwrap(); |
|
| 910 | - | let err = local.push("origin", false).err().unwrap().to_string(); |
|
| 911 | - | assert!( |
|
| 912 | - | err.contains("HTTP 422") |
|
| 913 | - | && err.contains( |
|
| 914 | - | "refused: a change adds DO NOT PUSH\n a.txt:2\nremove it and push main again" |
|
| 915 | - | ) |
|
| 916 | - | && !err.contains("hint:"), |
|
| 917 | - | "{err}" |
|
| 918 | - | ); |
|
| 919 | - | let hosted_now = Repo::open(&served.join("lib")).unwrap(); |
|
| 920 | - | assert!(hosted_now.is_unbound(), "a refused push binds nothing"); |
|
| 921 | - | assert!(hosted_now.bookmark_heads().unwrap().is_empty()); |
|
| 922 | - | assert!( |
|
| 923 | - | hosted_now.store().get_change(&bad).is_err(), |
|
| 924 | - | "nothing stored" |
|
| 925 | - | ); |
|
| 926 | - | drop(hosted); |
|
| 927 | - | ||
| 928 | - | // Without it, the push lands. |
|
| 929 | - | write(&work, "a.txt", b"one\n"); |
|
| 930 | - | local.snapshot("test").unwrap(); |
|
| 931 | - | local.amend(None).unwrap(); |
|
| 932 | - | let results = local.push("origin", false).unwrap(); |
|
| 933 | - | assert!(results.iter().all(|r| r.ok), "{results:?}"); |
|
| 934 | - | assert_eq!( |
|
| 935 | - | Repo::open(&served.join("lib")) |
|
| 936 | - | .unwrap() |
|
| 937 | - | .bookmark_heads() |
|
| 938 | - | .unwrap() |
|
| 939 | - | .len(), |
|
| 940 | - | 1 |
|
| 941 | - | ); |
|
| 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 | 692 | } |
modified crates/oxy-strata/Cargo.toml +1 −1
| … | 18 unchanged lines |
||
| 19 | 19 | http = "1" |
|
| 20 | 20 | http-body-util = "0.1" |
|
| 21 | 21 | bytes = "1" |
|
| 22 | - | tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "rt"] } |
|
| 22 | + | tokio = { version = "1", default-features = false, features = ["rt"] } |
|
| 23 | 23 | futures = "0.3" |
|
| 24 | 24 | serde_json = "1" |
|
| 25 | 25 | ||
| … | 2 unchanged lines |
modified crates/oxy-strata/src/lib.rs +28 −452
| … | 47 unchanged lines |
||
| 48 | 48 | } |
|
| 49 | 49 | let lookup = *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 50 | 50 | let given = lookup.and_then(|f| f(url)); |
|
| 51 | - | Some(shared(url, given).map(|s| Box::new(Shared(s)) as Box<dyn Store>)) |
|
| 52 | - | } |
|
| 53 | - | ||
| 54 | - | /// One client per store and credentials, kept for the life of the process |
|
| 55 | - | /// (oxygenhub#234): a server opens a repository for every request, and a |
|
| 56 | - | /// new client each time meant a new runtime, new connections and signing |
|
| 57 | - | /// in to the provider again before the first byte. |
|
| 58 | - | type OpenStores = std::collections::HashMap<(String, Option<Credentials>), Arc<CloudStore>>; |
|
| 59 | - | ||
| 60 | - | fn shared(url: &str, given: Option<Credentials>) -> Result<Arc<CloudStore>> { |
|
| 61 | - | static OPEN: Mutex<Option<OpenStores>> = Mutex::new(None); |
|
| 62 | - | let key = (url.to_string(), given); |
|
| 63 | - | let mut open = OPEN.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 64 | - | let map = open.get_or_insert_with(Default::default); |
|
| 65 | - | if let Some(s) = map.get(&key) { |
|
| 66 | - | return Ok(s.clone()); |
|
| 67 | - | } |
|
| 68 | - | let store = Arc::new(CloudStore::open_with(url, key.1.as_ref())?); |
|
| 69 | - | map.insert(key, store.clone()); |
|
| 70 | - | Ok(store) |
|
| 71 | - | } |
|
| 72 | - | ||
| 73 | - | /// A shared [`CloudStore`] as a [`Store`]. |
|
| 74 | - | struct Shared(Arc<CloudStore>); |
|
| 75 | - | ||
| 76 | - | impl Store for Shared { |
|
| 77 | - | fn describe(&self) -> String { |
|
| 78 | - | self.0.describe() |
|
| 79 | - | } |
|
| 80 | - | fn has(&self, key: &str) -> Result<bool> { |
|
| 81 | - | self.0.has(key) |
|
| 82 | - | } |
|
| 83 | - | fn get(&self, key: &str) -> Result<Vec<u8>> { |
|
| 84 | - | self.0.get(key) |
|
| 85 | - | } |
|
| 86 | - | fn get_range(&self, key: &str, range: Range<u64>) -> Result<Vec<u8>> { |
|
| 87 | - | self.0.get_range(key, range) |
|
| 88 | - | } |
|
| 89 | - | fn put_new(&self, key: &str, bytes: &[u8]) -> Result<()> { |
|
| 90 | - | self.0.put_new(key, bytes) |
|
| 91 | - | } |
|
| 92 | - | fn list(&self, prefix: &str) -> Result<Vec<Entry>> { |
|
| 93 | - | self.0.list(prefix) |
|
| 94 | - | } |
|
| 95 | - | fn stat(&self, key: &str) -> Result<Option<Entry>> { |
|
| 96 | - | self.0.stat(key) |
|
| 97 | - | } |
|
| 98 | - | fn delete(&self, key: &str) -> Result<()> { |
|
| 99 | - | self.0.delete(key) |
|
| 100 | - | } |
|
| 101 | - | fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> { |
|
| 102 | - | self.0.read_ref(key) |
|
| 103 | - | } |
|
| 104 | - | fn write_ref(&self, key: &str, bytes: &[u8], expected: Option<&Version>) -> Result<Version> { |
|
| 105 | - | self.0.write_ref(key, bytes, expected) |
|
| 106 | - | } |
|
| 107 | - | } |
|
| 108 | - | ||
| 109 | - | static OPENS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); |
|
| 110 | - | static CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); |
|
| 111 | - | static MICROS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); |
|
| 112 | - | static BY_OP: [std::sync::atomic::AtomicU64; Op::ALL.len()] = |
|
| 113 | - | [const { std::sync::atomic::AtomicU64::new(0) }; Op::ALL.len()]; |
|
| 114 | - | ||
| 115 | - | /// The kinds of request a cloud store makes, counted apart so a server can |
|
| 116 | - | /// tell which of them its time goes to. |
|
| 117 | - | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 118 | - | pub enum Op { |
|
| 119 | - | /// `has` that found the object. |
|
| 120 | - | Has, |
|
| 121 | - | /// `has` that did not: an absent object is never cached. |
|
| 122 | - | HasMissing, |
|
| 123 | - | Get, |
|
| 124 | - | Range, |
|
| 125 | - | Put, |
|
| 126 | - | List, |
|
| 127 | - | /// A listing of a repository's packs, sent. |
|
| 128 | - | ListPacks, |
|
| 129 | - | /// A listing answered from [`Listings`], not sent. |
|
| 130 | - | ListKept, |
|
| 131 | - | Delete, |
|
| 132 | - | ReadRef, |
|
| 133 | - | /// A read of a repository's journal of signatures and index entries |
|
| 134 | - | /// (`pack-entries`, spec §13.7): one per open of a folded store, in |
|
| 135 | - | /// place of a listing per lookup. |
|
| 136 | - | ReadJournal, |
|
| 137 | - | WriteRef, |
|
| 138 | - | } |
|
| 139 | - | ||
| 140 | - | impl Op { |
|
| 141 | - | pub const ALL: [Op; 12] = [ |
|
| 142 | - | Op::Has, |
|
| 143 | - | Op::HasMissing, |
|
| 144 | - | Op::Get, |
|
| 145 | - | Op::Range, |
|
| 146 | - | Op::Put, |
|
| 147 | - | Op::List, |
|
| 148 | - | Op::ListPacks, |
|
| 149 | - | Op::ListKept, |
|
| 150 | - | Op::Delete, |
|
| 151 | - | Op::ReadRef, |
|
| 152 | - | Op::ReadJournal, |
|
| 153 | - | Op::WriteRef, |
|
| 154 | - | ]; |
|
| 155 | - | ||
| 156 | - | pub fn name(self) -> &'static str { |
|
| 157 | - | match self { |
|
| 158 | - | Op::Has => "has", |
|
| 159 | - | Op::HasMissing => "has_missing", |
|
| 160 | - | Op::Get => "get", |
|
| 161 | - | Op::Range => "range", |
|
| 162 | - | Op::Put => "put", |
|
| 163 | - | Op::List => "list", |
|
| 164 | - | Op::ListPacks => "list_packs", |
|
| 165 | - | Op::ListKept => "list_kept", |
|
| 166 | - | Op::Delete => "delete", |
|
| 167 | - | Op::ReadRef => "read_ref", |
|
| 168 | - | Op::ReadJournal => "read_journal", |
|
| 169 | - | Op::WriteRef => "write_ref", |
|
| 170 | - | } |
|
| 171 | - | } |
|
| 172 | - | } |
|
| 173 | - | ||
| 174 | - | fn counted(op: Op) { |
|
| 175 | - | BY_OP[op as usize].fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
|
| 176 | - | } |
|
| 177 | - | ||
| 178 | - | /// What the cloud stores have done since the process started: clients |
|
| 179 | - | /// opened, requests made and the time spent in them, for a server to show |
|
| 180 | - | /// (a hub's /health). |
|
| 181 | - | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
|
| 182 | - | pub struct Stats { |
|
| 183 | - | pub opens: u64, |
|
| 184 | - | pub calls: u64, |
|
| 185 | - | pub millis: u64, |
|
| 186 | - | /// Requests by kind, in [`Op::ALL`]'s order. |
|
| 187 | - | pub by_op: [u64; Op::ALL.len()], |
|
| 188 | - | } |
|
| 189 | - | ||
| 190 | - | impl Stats { |
|
| 191 | - | /// Each kind of request with how many were made. |
|
| 192 | - | pub fn ops(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ { |
|
| 193 | - | Op::ALL |
|
| 194 | - | .iter() |
|
| 195 | - | .map(|op| (op.name(), self.by_op[*op as usize])) |
|
| 196 | - | } |
|
| 197 | - | } |
|
| 198 | - | ||
| 199 | - | pub fn stats() -> Stats { |
|
| 200 | - | use std::sync::atomic::Ordering::Relaxed; |
|
| 201 | - | Stats { |
|
| 202 | - | opens: OPENS.load(Relaxed), |
|
| 203 | - | calls: CALLS.load(Relaxed), |
|
| 204 | - | millis: MICROS.load(Relaxed) / 1000, |
|
| 205 | - | by_op: std::array::from_fn(|i| BY_OP[i].load(Relaxed)), |
|
| 206 | - | } |
|
| 51 | + | Some(CloudStore::open_with(url, given.as_ref()).map(|s| Box::new(s) as Box<dyn Store>)) |
|
| 207 | 52 | } |
|
| 208 | 53 | ||
| 209 | 54 | /// Credentials a program keeps itself and hands over, instead of the |
|
| 210 | 55 | /// provider's usual places: a hub keeps each repository's sealed. When a |
|
| 211 | 56 | /// store is opened with them, nothing from the environment or the |
|
| 212 | 57 | /// provider's files is used. |
|
| 213 | - | #[derive(Clone, PartialEq, Eq, Hash)] |
|
| 58 | + | #[derive(Clone, PartialEq, Eq)] |
|
| 214 | 59 | pub enum Credentials { |
|
| 215 | 60 | /// An S3 access key. |
|
| 216 | 61 | Aws { |
|
| … | 194 unchanged lines |
||
| 411 | 256 | prefix: String, |
|
| 412 | 257 | name: String, |
|
| 413 | 258 | hint: &'static str, |
|
| 414 | - | /// Always there until the store is dropped: two workers, so the |
|
| 415 | - | /// requests of a shared store run side by side. |
|
| 259 | + | /// Always there until the store is dropped. |
|
| 416 | 260 | runtime: Option<tokio::runtime::Runtime>, |
|
| 417 | - | /// Keys `has` found missing lately (see [`Misses`]). |
|
| 418 | - | misses: Misses, |
|
| 419 | - | /// Pack listings, kept a moment (see [`Listings`]). |
|
| 420 | - | listings: Listings, |
|
| 421 | - | } |
|
| 422 | - | ||
| 423 | - | /// How long a key found missing is taken as missing without asking again. |
|
| 424 | - | const MISS_FOR: std::time::Duration = std::time::Duration::from_secs(30); |
|
| 425 | - | ||
| 426 | - | /// Keys found missing in the last [`MISS_FOR`], so a server answering the |
|
| 427 | - | /// same question again and again does not send a request each time |
|
| 428 | - | /// (oxygenhub#228: a client's negotiate asked about ~190 objects the hub |
|
| 429 | - | /// lacked every 8 s, 45 ms a request). This process forgets a key the |
|
| 430 | - | /// moment it writes it, so its own writes are never hidden; another writer |
|
| 431 | - | /// to the same keys is seen within `MISS_FOR`. |
|
| 432 | - | #[derive(Default)] |
|
| 433 | - | struct Misses(Mutex<std::collections::HashMap<String, std::time::Instant>>); |
|
| 434 | - | ||
| 435 | - | impl Misses { |
|
| 436 | - | /// At most this many kept; past it the oldest half is let go. |
|
| 437 | - | const MAX: usize = 100_000; |
|
| 438 | - | ||
| 439 | - | fn known(&self, key: &str) -> bool { |
|
| 440 | - | let map = self.0.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 441 | - | map.get(key).is_some_and(|t| t.elapsed() < MISS_FOR) |
|
| 442 | - | } |
|
| 443 | - | ||
| 444 | - | fn missed(&self, key: &str) { |
|
| 445 | - | let mut map = self.0.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 446 | - | if map.len() >= Self::MAX { |
|
| 447 | - | map.retain(|_, t| t.elapsed() < MISS_FOR / 2); |
|
| 448 | - | if map.len() >= Self::MAX { |
|
| 449 | - | map.clear(); |
|
| 450 | - | } |
|
| 451 | - | } |
|
| 452 | - | map.insert(key.to_string(), std::time::Instant::now()); |
|
| 453 | - | } |
|
| 454 | - | ||
| 455 | - | fn written(&self, key: &str) { |
|
| 456 | - | self.0.lock().unwrap_or_else(|p| p.into_inner()).remove(key); |
|
| 457 | - | } |
|
| 458 | - | } |
|
| 459 | - | ||
| 460 | - | /// Listings kept for [`MISS_FOR`] (oxygen#116): the `pack-` keys, which |
|
| 461 | - | /// every open of a repository's store lists, so a forge serving a |
|
| 462 | - | /// repository from a bucket lists them once a while rather than once a |
|
| 463 | - | /// request. Like [`Misses`], this process forgets a listing the moment it |
|
| 464 | - | /// writes or deletes a key under it; another writer's pack is seen within |
|
| 465 | - | /// `MISS_FOR`, as a missing key is. |
|
| 466 | - | #[derive(Default)] |
|
| 467 | - | struct Listings(Mutex<std::collections::HashMap<String, (std::time::Instant, Vec<Entry>)>>); |
|
| 468 | - | ||
| 469 | - | impl Listings { |
|
| 470 | - | /// The prefixes kept: those every open lists. |
|
| 471 | - | fn kept(prefix: &str) -> bool { |
|
| 472 | - | prefix == "pack-" |
|
| 473 | - | } |
|
| 474 | - | ||
| 475 | - | fn get(&self, prefix: &str) -> Option<Vec<Entry>> { |
|
| 476 | - | if !Self::kept(prefix) { |
|
| 477 | - | return None; |
|
| 478 | - | } |
|
| 479 | - | let map = self.0.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 480 | - | map.get(prefix) |
|
| 481 | - | .filter(|(t, _)| t.elapsed() < MISS_FOR) |
|
| 482 | - | .map(|(_, e)| e.clone()) |
|
| 483 | - | } |
|
| 484 | - | ||
| 485 | - | fn put(&self, prefix: &str, entries: &[Entry]) { |
|
| 486 | - | if Self::kept(prefix) { |
|
| 487 | - | self.0.lock().unwrap_or_else(|p| p.into_inner()).insert( |
|
| 488 | - | prefix.to_string(), |
|
| 489 | - | (std::time::Instant::now(), entries.to_vec()), |
|
| 490 | - | ); |
|
| 491 | - | } |
|
| 492 | - | } |
|
| 493 | - | ||
| 494 | - | fn written(&self, key: &str) { |
|
| 495 | - | self.0 |
|
| 496 | - | .lock() |
|
| 497 | - | .unwrap_or_else(|p| p.into_inner()) |
|
| 498 | - | .retain(|prefix, _| !key.starts_with(prefix.as_str())); |
|
| 499 | - | } |
|
| 500 | - | } |
|
| 501 | - | ||
| 502 | - | /// Run a request that writes or deletes `key`, forgetting what the caches |
|
| 503 | - | /// hold for it both before it is sent and after it completes, whatever it |
|
| 504 | - | /// returns: a listing or a miss taken by another thread while the request |
|
| 505 | - | /// was in flight may predate it, and would otherwise be kept for |
|
| 506 | - | /// `MISS_FOR` (oxygenhub#249). |
|
| 507 | - | fn writing<T>(misses: &Misses, listings: &Listings, key: &str, request: impl FnOnce() -> T) -> T { |
|
| 508 | - | misses.written(key); |
|
| 509 | - | listings.written(key); |
|
| 510 | - | let out = request(); |
|
| 511 | - | misses.written(key); |
|
| 512 | - | listings.written(key); |
|
| 513 | - | out |
|
| 261 | + | /// One call at a time: the runtime runs on the calling thread. |
|
| 262 | + | turn: Mutex<()>, |
|
| 514 | 263 | } |
|
| 515 | 264 | ||
| 516 | 265 | impl Drop for CloudStore { |
|
| … | 53 unchanged lines |
||
| 570 | 319 | )), |
|
| 571 | 320 | _ => None, |
|
| 572 | 321 | }; |
|
| 573 | - | OPENS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
|
| 574 | - | let runtime = tokio::runtime::Builder::new_multi_thread() |
|
| 575 | - | .worker_threads(2) |
|
| 576 | - | .thread_name("oxy-strata") |
|
| 322 | + | let runtime = tokio::runtime::Builder::new_current_thread() |
|
| 577 | 323 | .enable_all() |
|
| 578 | 324 | .build() |
|
| 579 | 325 | .map_err(|e| Error::Backend(format!("starting the HTTP runtime: {e}")))?; |
|
| … | 25 unchanged lines |
||
| 605 | 351 | name: parsed.plain(), |
|
| 606 | 352 | hint, |
|
| 607 | 353 | runtime: Some(runtime), |
|
| 608 | - | misses: Misses::default(), |
|
| 609 | - | listings: Listings::default(), |
|
| 354 | + | turn: Mutex::new(()), |
|
| 610 | 355 | }) |
|
| 611 | 356 | } |
|
| 612 | 357 | ||
| … | 5 unchanged lines |
||
| 618 | 363 | F: Future + Send, |
|
| 619 | 364 | F::Output: Send, |
|
| 620 | 365 | { |
|
| 366 | + | let _turn = self.turn.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 621 | 367 | let runtime = self |
|
| 622 | 368 | .runtime |
|
| 623 | 369 | .as_ref() |
|
| 624 | 370 | .expect("the runtime lives as long as the store"); |
|
| 625 | - | let started = std::time::Instant::now(); |
|
| 626 | - | let out = if tokio::runtime::Handle::try_current().is_ok() { |
|
| 627 | - | std::thread::scope(|s| { |
|
| 371 | + | if tokio::runtime::Handle::try_current().is_ok() { |
|
| 372 | + | return std::thread::scope(|s| { |
|
| 628 | 373 | s.spawn(|| runtime.block_on(f)) |
|
| 629 | 374 | .join() |
|
| 630 | 375 | .unwrap_or_else(|p| std::panic::resume_unwind(p)) |
|
| 631 | - | }) |
|
| 632 | - | } else { |
|
| 633 | - | runtime.block_on(f) |
|
| 634 | - | }; |
|
| 635 | - | CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
|
| 636 | - | MICROS.fetch_add( |
|
| 637 | - | started.elapsed().as_micros() as u64, |
|
| 638 | - | std::sync::atomic::Ordering::Relaxed, |
|
| 639 | - | ); |
|
| 640 | - | out |
|
| 376 | + | }); |
|
| 377 | + | } |
|
| 378 | + | runtime.block_on(f) |
|
| 641 | 379 | } |
|
| 642 | 380 | ||
| 643 | 381 | fn path(&self, key: &str) -> Result<ObjectPath> { |
|
| … | 73 unchanged lines |
||
| 717 | 455 | ||
| 718 | 456 | fn has(&self, key: &str) -> Result<bool> { |
|
| 719 | 457 | let path = self.path(key)?; |
|
| 720 | - | if self.misses.known(key) { |
|
| 721 | - | return Ok(false); |
|
| 722 | - | } |
|
| 723 | 458 | match self.block(self.inner.head(&path)) { |
|
| 724 | - | Ok(_) => { |
|
| 725 | - | counted(Op::Has); |
|
| 726 | - | Ok(true) |
|
| 727 | - | } |
|
| 728 | - | Err(object_store::Error::NotFound { .. }) => { |
|
| 729 | - | counted(Op::HasMissing); |
|
| 730 | - | self.misses.missed(key); |
|
| 731 | - | Ok(false) |
|
| 732 | - | } |
|
| 459 | + | Ok(_) => Ok(true), |
|
| 460 | + | Err(object_store::Error::NotFound { .. }) => Ok(false), |
|
| 733 | 461 | Err(e) => Err(self.map(e, key)), |
|
| 734 | 462 | } |
|
| 735 | 463 | } |
|
| 736 | 464 | ||
| 737 | 465 | fn get(&self, key: &str) -> Result<Vec<u8>> { |
|
| 738 | 466 | let path = self.path(key)?; |
|
| 739 | - | counted(Op::Get); |
|
| 740 | 467 | self.block(async { |
|
| 741 | 468 | let r = self.inner.get(&path).await?; |
|
| 742 | 469 | r.bytes().await |
|
| … | 14 unchanged lines |
||
| 757 | 484 | return Ok(Vec::new()); |
|
| 758 | 485 | } |
|
| 759 | 486 | let want = (range.end - range.start) as usize; |
|
| 760 | - | counted(Op::Range); |
|
| 761 | 487 | let bytes = self |
|
| 762 | 488 | .block(self.inner.get_range(&path, range.clone())) |
|
| 763 | 489 | .map_err(|e| self.map(e, key))?; |
|
| … | 14 unchanged lines |
||
| 778 | 504 | mode: PutMode::Create, |
|
| 779 | 505 | ..Default::default() |
|
| 780 | 506 | }; |
|
| 781 | - | counted(Op::Put); |
|
| 782 | - | writing(&self.misses, &self.listings, key, || { |
|
| 783 | - | self.block( |
|
| 784 | - | self.inner |
|
| 785 | - | .put_opts(&path, PutPayload::from(bytes.to_vec()), opts), |
|
| 786 | - | ) |
|
| 787 | - | }) |
|
| 507 | + | self.block( |
|
| 508 | + | self.inner |
|
| 509 | + | .put_opts(&path, PutPayload::from(bytes.to_vec()), opts), |
|
| 510 | + | ) |
|
| 788 | 511 | .map(|_| ()) |
|
| 789 | 512 | .map_err(|e| self.map(e, key)) |
|
| 790 | 513 | } |
|
| 791 | 514 | ||
| 792 | 515 | fn list(&self, prefix: &str) -> Result<Vec<Entry>> { |
|
| 793 | - | if let Some(kept) = self.listings.get(prefix) { |
|
| 794 | - | counted(Op::ListKept); |
|
| 795 | - | return Ok(kept); |
|
| 796 | - | } |
|
| 797 | 516 | // Providers list by whole path segments; the asked prefix may end |
|
| 798 | 517 | // mid-segment, so list its directory and filter here. |
|
| 799 | 518 | let dir = prefix.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); |
|
| … | 6 unchanged lines |
||
| 806 | 525 | let full_dir = full_dir |
|
| 807 | 526 | .map(|d| ObjectPath::parse(&d).map_err(|e| Error::Backend(format!("{d}: {e}")))) |
|
| 808 | 527 | .transpose()?; |
|
| 809 | - | counted(if Listings::kept(prefix) { |
|
| 810 | - | Op::ListPacks |
|
| 811 | - | } else { |
|
| 812 | - | Op::List |
|
| 813 | - | }); |
|
| 814 | 528 | let metas: Vec<object_store::ObjectMeta> = self |
|
| 815 | 529 | .block(self.inner.list(full_dir.as_ref()).try_collect()) |
|
| 816 | 530 | .map_err(|e| self.map(e, prefix))?; |
|
| … | 15 unchanged lines |
||
| 832 | 546 | }) |
|
| 833 | 547 | .collect(); |
|
| 834 | 548 | out.sort_by(|a, b| a.key.cmp(&b.key)); |
|
| 835 | - | self.listings.put(prefix, &out); |
|
| 836 | 549 | Ok(out) |
|
| 837 | - | } |
|
| 838 | - | ||
| 839 | - | /// A HEAD of the key, not a listing of its directory (oxygen#116). |
|
| 840 | - | fn stat(&self, key: &str) -> Result<Option<Entry>> { |
|
| 841 | - | let path = self.path(key)?; |
|
| 842 | - | counted(Op::Has); |
|
| 843 | - | match self.block(self.inner.head(&path)) { |
|
| 844 | - | Ok(m) => Ok(Some(Entry { |
|
| 845 | - | key: key.to_string(), |
|
| 846 | - | size: m.size, |
|
| 847 | - | modified_ms: u64::try_from(m.last_modified.timestamp_millis()).ok(), |
|
| 848 | - | })), |
|
| 849 | - | Err(object_store::Error::NotFound { .. }) => Ok(None), |
|
| 850 | - | Err(e) => Err(self.map(e, key)), |
|
| 851 | - | } |
|
| 852 | 550 | } |
|
| 853 | 551 | ||
| 854 | 552 | fn delete(&self, key: &str) -> Result<()> { |
|
| 855 | 553 | let path = self.path(key)?; |
|
| 856 | - | counted(Op::Delete); |
|
| 857 | - | match writing(&self.misses, &self.listings, key, || { |
|
| 858 | - | self.block(self.inner.delete(&path)) |
|
| 859 | - | }) { |
|
| 554 | + | match self.block(self.inner.delete(&path)) { |
|
| 860 | 555 | Ok(()) | Err(object_store::Error::NotFound { .. }) => Ok(()), |
|
| 861 | 556 | Err(e) => Err(self.map(e, key)), |
|
| 862 | 557 | } |
|
| … | 1 unchanged lines |
||
| 864 | 559 | ||
| 865 | 560 | fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> { |
|
| 866 | 561 | let path = self.path(key)?; |
|
| 867 | - | counted(if key == oxy_core::store::JOURNAL_KEY { |
|
| 868 | - | Op::ReadJournal |
|
| 869 | - | } else { |
|
| 870 | - | Op::ReadRef |
|
| 871 | - | }); |
|
| 872 | 562 | let got = self.block(async { |
|
| 873 | 563 | let r = self.inner.get_opts(&path, GetOptions::default()).await?; |
|
| 874 | 564 | let meta = r.meta.clone(); |
|
| … | 20 unchanged lines |
||
| 895 | 585 | mode, |
|
| 896 | 586 | ..Default::default() |
|
| 897 | 587 | }; |
|
| 898 | - | counted(Op::WriteRef); |
|
| 899 | - | let put = writing(&self.misses, &self.listings, key, || { |
|
| 900 | - | self.block( |
|
| 588 | + | let put = self |
|
| 589 | + | .block( |
|
| 901 | 590 | self.inner |
|
| 902 | 591 | .put_opts(&path, PutPayload::from(bytes.to_vec()), opts), |
|
| 903 | 592 | ) |
|
| 904 | - | }) |
|
| 905 | - | .map_err(|e| match self.map(e, key) { |
|
| 906 | - | // A record that exists when none was expected is a lost |
|
| 907 | - | // race too, not a misuse of put_new. |
|
| 908 | - | Error::Exists(k) => Error::Conflict(k), |
|
| 909 | - | other => other, |
|
| 910 | - | })?; |
|
| 593 | + | .map_err(|e| match self.map(e, key) { |
|
| 594 | + | // A record that exists when none was expected is a lost |
|
| 595 | + | // race too, not a misuse of put_new. |
|
| 596 | + | Error::Exists(k) => Error::Conflict(k), |
|
| 597 | + | other => other, |
|
| 598 | + | })?; |
|
| 911 | 599 | Ok(Self::version_of(put.e_tag, put.version)) |
|
| 912 | 600 | } |
|
| 913 | 601 | } |
|
| 914 | 602 | ||
| 915 | 603 | #[cfg(test)] |
|
| 916 | 604 | mod tests { |
|
| 917 | - | ||
| 918 | - | /// A miss is remembered until the key is written, or for MISS_FOR. |
|
| 919 | - | #[test] |
|
| 920 | - | fn a_miss_is_remembered_until_written() { |
|
| 921 | - | let m = super::Misses::default(); |
|
| 922 | - | assert!(!m.known("loose/ab/cd")); |
|
| 923 | - | m.missed("loose/ab/cd"); |
|
| 924 | - | assert!(m.known("loose/ab/cd") && !m.known("loose/ab/ce")); |
|
| 925 | - | m.written("loose/ab/cd"); |
|
| 926 | - | assert!(!m.known("loose/ab/cd"), "a write forgets the miss"); |
|
| 927 | - | m.missed("old"); |
|
| 928 | - | m.0.lock() |
|
| 929 | - | .unwrap() |
|
| 930 | - | .insert("old".into(), std::time::Instant::now() - super::MISS_FOR); |
|
| 931 | - | assert!(!m.known("old"), "a miss expires"); |
|
| 932 | - | } |
|
| 933 | - | ||
| 934 | - | /// Every kind of request is reported, once and by its own name. |
|
| 935 | - | /// The pack listing is kept until a pack key is written or deleted |
|
| 936 | - | /// here, or for MISS_FOR; other prefixes are never kept (oxygen#116). |
|
| 937 | - | #[test] |
|
| 938 | - | fn the_pack_listing_is_kept_until_a_pack_is_written() { |
|
| 939 | - | let l = super::Listings::default(); |
|
| 940 | - | let e = |k: &str| oxy_core::strata::Entry { |
|
| 941 | - | key: k.into(), |
|
| 942 | - | size: 1, |
|
| 943 | - | modified_ms: None, |
|
| 944 | - | }; |
|
| 945 | - | l.put("pack-", &[e("pack-1.idx")]); |
|
| 946 | - | assert_eq!(l.get("pack-").map(|v| v.len()), Some(1)); |
|
| 947 | - | l.put("loose/", &[e("loose/ab")]); |
|
| 948 | - | assert!(l.get("loose/").is_none(), "only the pack listing is kept"); |
|
| 949 | - | l.written("loose/cd"); |
|
| 950 | - | assert!(l.get("pack-").is_some(), "a loose object leaves it"); |
|
| 951 | - | l.written("pack-2.pack"); |
|
| 952 | - | assert!(l.get("pack-").is_none(), "a new pack forgets it"); |
|
| 953 | - | l.put("pack-", &[e("pack-1.idx")]); |
|
| 954 | - | l.0.lock().unwrap().get_mut("pack-").unwrap().0 -= super::MISS_FOR; |
|
| 955 | - | assert!(l.get("pack-").is_none(), "and it ages out"); |
|
| 956 | - | } |
|
| 957 | - | ||
| 958 | - | /// A listing or miss taken by another thread while a write is in |
|
| 959 | - | /// flight is forgotten when the write completes, not kept stale for |
|
| 960 | - | /// MISS_FOR (oxygenhub#249), whether the write succeeded or not. |
|
| 961 | - | #[test] |
|
| 962 | - | fn a_listing_taken_during_a_write_is_forgotten_after_it() { |
|
| 963 | - | let (m, l) = (super::Misses::default(), super::Listings::default()); |
|
| 964 | - | let e = |k: &str| oxy_core::strata::Entry { |
|
| 965 | - | key: k.into(), |
|
| 966 | - | size: 1, |
|
| 967 | - | modified_ms: None, |
|
| 968 | - | }; |
|
| 969 | - | l.put("pack-", &[e("pack-1.idx")]); |
|
| 970 | - | let got = super::writing(&m, &l, "pack-2.idx", || { |
|
| 971 | - | assert!(l.get("pack-").is_none(), "forgotten before it is sent"); |
|
| 972 | - | // Another thread lists, and misses, mid-request. |
|
| 973 | - | l.put("pack-", &[e("pack-1.idx")]); |
|
| 974 | - | m.missed("pack-2.idx"); |
|
| 975 | - | 7 |
|
| 976 | - | }); |
|
| 977 | - | assert_eq!(got, 7); |
|
| 978 | - | assert!(l.get("pack-").is_none(), "the mid-request listing is gone"); |
|
| 979 | - | assert!(!m.known("pack-2.idx"), "and the miss"); |
|
| 980 | - | // A delete that fails is treated the same. |
|
| 981 | - | l.put("pack-", &[e("pack-1.idx")]); |
|
| 982 | - | let r: std::result::Result<(), &str> = super::writing(&m, &l, "pack-1.idx", || { |
|
| 983 | - | l.put("pack-", &[e("pack-1.idx")]); |
|
| 984 | - | Err("network") |
|
| 985 | - | }); |
|
| 986 | - | assert!(r.is_err() && l.get("pack-").is_none()); |
|
| 987 | - | // Other prefixes' writes leave the pack listing. |
|
| 988 | - | l.put("pack-", &[e("pack-1.idx")]); |
|
| 989 | - | super::writing(&m, &l, "loose/ab/cd", || ()); |
|
| 990 | - | assert!(l.get("pack-").is_some()); |
|
| 991 | - | } |
|
| 992 | - | ||
| 993 | - | #[test] |
|
| 994 | - | fn stats_name_every_kind_of_request() { |
|
| 995 | - | let names: Vec<&str> = super::stats().ops().map(|(n, _)| n).collect(); |
|
| 996 | - | assert_eq!(names.len(), super::Op::ALL.len()); |
|
| 997 | - | let mut unique = names.clone(); |
|
| 998 | - | unique.sort(); |
|
| 999 | - | unique.dedup(); |
|
| 1000 | - | assert_eq!(unique.len(), names.len()); |
|
| 1001 | - | assert!(names.contains(&"has_missing") && names.contains(&"range")); |
|
| 1002 | - | } |
|
| 1003 | - | ||
| 1004 | - | /// One client per store and credentials (oxygenhub#234): opening the |
|
| 1005 | - | /// same again shares it, and only a new one counts as opened. |
|
| 1006 | - | #[test] |
|
| 1007 | - | fn a_store_opened_again_shares_its_client() { |
|
| 1008 | - | let creds = |k: &str| Credentials::Aws { |
|
| 1009 | - | access_key_id: k.into(), |
|
| 1010 | - | secret_access_key: "s".into(), |
|
| 1011 | - | session_token: None, |
|
| 1012 | - | }; |
|
| 1013 | - | let url = "s3://oxy-shared-test/repos/a?region=eu-west-2"; |
|
| 1014 | - | let before = super::stats().opens; |
|
| 1015 | - | let a = super::shared(url, Some(creds("one"))).unwrap(); |
|
| 1016 | - | let b = super::shared(url, Some(creds("one"))).unwrap(); |
|
| 1017 | - | assert!(std::sync::Arc::ptr_eq(&a, &b)); |
|
| 1018 | - | let other_keys = super::shared(url, Some(creds("two"))).unwrap(); |
|
| 1019 | - | assert!( |
|
| 1020 | - | !std::sync::Arc::ptr_eq(&a, &other_keys), |
|
| 1021 | - | "other credentials, another client" |
|
| 1022 | - | ); |
|
| 1023 | - | assert!( |
|
| 1024 | - | super::stats().opens - before >= 2, |
|
| 1025 | - | "two clients opened (other tests may open more)" |
|
| 1026 | - | ); |
|
| 1027 | - | } |
|
| 1028 | - | ||
| 1029 | 605 | #[tokio::test] |
|
| 1030 | 606 | async fn a_store_can_be_used_from_inside_a_runtime() { |
|
| 1031 | 607 | // As a server's handler would: blocking on the store's own runtime |
|
| … | 83 unchanged lines |
removed docs/cylinder.md +0 −456
| 1 | - | # Cylinder: artifacts, the Oxygen way |
|
| 2 | - | ||
| 3 | - | Status: Draft 0.3 (proposal revision 3), 2026-10-12. Draft 0.2 added the |
|
| 4 | - | object model, deployment leases, inverted vulnerability indexing, |
|
| 5 | - | multi-party consensus, agent scopes and revset queries; 0.3 corrects the |
|
| 6 | - | MicroVM mount path, the reach of the file index, revocation, encryption, |
|
| 7 | - | leases and upstream quarantine, and keeps §7 open. C0 is built; the rest |
|
| 8 | - | is not. "Cylinder" is a working name (the artifact-repository idea parked |
|
| 9 | - | on 2026-10-02); nothing here is decided until Andy says so. |
|
| 10 | - | ||
| 11 | - | **Cylinder** keeps and moves everything a build produces: runner images, |
|
| 12 | - | container images, packages, binaries, SBOMs and reports. It would be a |
|
| 13 | - | service of its own, embedded in OxygenHub by default and shown through |
|
| 14 | - | the hub's UI, the same pattern as Jetstream. It is meant to be better |
|
| 15 | - | than what exists, not compatible with it; compatibility is an edge we add |
|
| 16 | - | for adoption, never the core. |
|
| 17 | - | ||
| 18 | - | ## 1. What exists today |
|
| 19 | - | ||
| 20 | - | | | Strengths | Weaknesses | |
|
| 21 | - | |---|---|---| |
|
| 22 | - | | **Docker Hub** | The default namespace; huge public catalogue; official images; scanning (Scout). | Pull limits since April 2025: 10 an hour anonymous, 100 an hour on a free account. SaaS only. Tags are mutable. Signatures and provenance are separate artifacts; referrers are still in beta. Nothing links an image to the source it came from. | |
|
| 23 | - | | **GitHub** (GHCR, Packages) | Next to the code and Actions; free container storage for now; the best Sigstore integration; artifact attestations. | Storage billing for private images deferred, not settled. One silo per package format. Auth by personal access tokens. Tags are mutable: the tj-actions attack (March 2025, CVE-2025-30066) moved every tag of a popular action to a malicious commit at once. No self-hosting outside Enterprise Server. | |
|
| 24 | - | | **GitLab** | A registry per project; many package formats; a dependency proxy; self-hostable, so it can be sovereign. | Cleanup policies delete tags only; layers stay until an administrator runs garbage collection. The registry is a separate service to run and scale. Signing means bringing cosign yourself. | |
|
| 25 | - | | **Bitbucket** (Packages) | GA since December 2025; permissions inherited from the repository; garbage collection runs by itself; Maven and npm since April 2026. | Young; Cloud only; few formats; no provenance story. | |
|
| 26 | - | | **Artifactory, Nexus** | Every format; proxies and caches; enterprise features. | Costly; heavy JVM operations; slow UIs. A second system beside the forge, with its own identities and permissions. Provenance and scanning are add-on products. | |
|
| 27 | - | | **Harbor, Quay** | Self-hosted OCI with signing (cosign, Notation), replication and scanning. | Many components to run. A central bottleneck: peer-to-peer means adding Dragonfly. | |
|
| 28 | - | | **ECR, Artifact Registry, ACR** | Cloud IAM; regional; scanning; lazy pulling on AWS (SOCI). | Lock-in; egress costs; not sovereign. | |
|
| 29 | - | ||
| 30 | - | And the bolt-ons each of them needs: |
|
| 31 | - | ||
| 32 | - | - **Distribution:** Dragonfly, Kraken or Spegel, run beside the registry. |
|
| 33 | - | - **Lazy pulling:** eStargz, SOCI, Nydus or zstd:chunked, each a format |
|
| 34 | - | plus a snapshotter. It matters: pulling took 76% of container start time |
|
| 35 | - | while only 6.4% of the pulled data was read (Slacker, FAST '16). |
|
| 36 | - | - **Signing and provenance:** cosign and Rekor, SLSA generators, |
|
| 37 | - | in-toto, Notation, attached afterwards through the OCI referrers API. |
|
| 38 | - | - **Retention:** cleanup policies plus a separate garbage collection. |
|
| 39 | - | ||
| 40 | - | ## 2. The pattern behind the weaknesses |
|
| 41 | - | ||
| 42 | - | 1. **The unit is the tarball layer.** One changed file re-pushes and |
|
| 43 | - | re-pulls a whole layer; layers share nothing below their own size; and a |
|
| 44 | - | layer has two digests (compressed and not), so tools disagree on what |
|
| 45 | - | it is called. |
|
| 46 | - | 2. **Names are mutable pointers.** A tag moves silently and leaves no |
|
| 47 | - | history. Pinning by digest is safe but unreadable, so most people |
|
| 48 | - | don't. |
|
| 49 | - | 3. **Trust is bolted on afterwards.** The registry stores bytes; a |
|
| 50 | - | separate tool signs them, a third says how they were built, and each |
|
| 51 | - | brings its own keys and identities. Nothing stops an unsigned image |
|
| 52 | - | from being pushed, and most pulls never verify anything. |
|
| 53 | - | 4. **Distribution is someone else's problem.** Every registry is a |
|
| 54 | - | central server; peer-to-peer and lazy loading are extra products. |
|
| 55 | - | 5. **The forge and the registry are strangers.** Permissions, identity, |
|
| 56 | - | retention and "which commit is this?" are kept twice and drift apart. |
|
| 57 | - | ||
| 58 | - | ## 3. What Oxygen already has |
|
| 59 | - | ||
| 60 | - | Every piece Cylinder needs exists in some form: |
|
| 61 | - | ||
| 62 | - | - **Content-defined chunks with BLAKE3** (FastCDC, 32 to 256 KiB), shared |
|
| 63 | - | by repositories, release assets, and runner images: deduplication below |
|
| 64 | - | file level, across everything. |
|
| 65 | - | - **Hybrid post-quantum signatures** (Ed25519 with ML-DSA-65), identities |
|
| 66 | - | with delegation chains, and rotation recorded in the repository. |
|
| 67 | - | - **Signed Flow receipts:** a runner signs what it built, from which |
|
| 68 | - | state, with which pipeline and environment, producing which outputs. |
|
| 69 | - | That is provenance by construction, not a statement added later. |
|
| 70 | - | - **flow-casfs:** a content-addressed tree mounted lazily over FUSE or |
|
| 71 | - | virtio-fs. Lazy pulling is native, not a format bolted on. |
|
| 72 | - | - **The Jetstream swarm:** peer-to-peer chunks, rarest first, TLS pinned |
|
| 73 | - | by the hub, tickets that only the hub signs. |
|
| 74 | - | - **Strata:** storage on disk, S3, GCS or Azure, with no server in between, |
|
| 75 | - | sovereign keys and client-side hash verification. |
|
| 76 | - | - **OxygenHub:** one identity and permission model, checks, proposals and |
|
| 77 | - | reports. |
|
| 78 | - | - **Scoped delegation:** key scopes (`changes`, `refs`, `seal`) allowing |
|
| 79 | - | principals and AI agents to act within strict cryptographic boundaries. |
|
| 80 | - | ||
| 81 | - | ## 4. The design |
|
| 82 | - | ||
| 83 | - | ### 4.1 One kind of thing: a tree of chunks |
|
| 84 | - | ||
| 85 | - | An **artifact** is an immutable, content-addressed object in the Oxygen |
|
| 86 | - | store naming a file tree: |
|
| 87 | - | ||
| 88 | - | ``` |
|
| 89 | - | Artifact { |
|
| 90 | - | version [0]: 1 |
|
| 91 | - | kind [1]: "container" | "runner-image" | "package" | "archive" | "report" |
|
| 92 | - | arch [2]: text | null // "x86_64", "riscv64", "aarch64" |
|
| 93 | - | os [3]: text | null // "linux", "darwin", "none" |
|
| 94 | - | tree [4]: ObjectRef // DirTree blob: paths, modes, blobs, sizes |
|
| 95 | - | entrypoint [5]: [text] | null // default exec argv |
|
| 96 | - | env [6]: map<text, text> | null // environment variables |
|
| 97 | - | labels [7]: map<text, text> | null // metadata / OCI labels |
|
| 98 | - | total_size [8]: u64 // uncompressed bytes |
|
| 99 | - | receipt [9]: ObjectRef | null // Receipt object that produced it |
|
| 100 | - | } |
|
| 101 | - | ``` |
|
| 102 | - | ||
| 103 | - | Files in the `DirTree` are ordinary Oxygen `blob` objects composed of |
|
| 104 | - | FastCDC BLAKE3 `chunk`s. So: |
|
| 105 | - | ||
| 106 | - | - An image rebuilt with one changed file moves only that file's changed |
|
| 107 | - | chunks: no layers. |
|
| 108 | - | - A release tarball, an npm package, and the container image containing |
|
| 109 | - | the same binary share its chunks verbatim. If a 100 MB model or library |
|
| 110 | - | is committed in the repo and then packaged, zero new chunk bytes are |
|
| 111 | - | written to Strata. |
|
| 112 | - | - A single file can be fetched independently: a container or job starts |
|
| 113 | - | after fetching only the files it reads. |
|
| 114 | - | - Every artifact has exactly one name for its content: the BLAKE3 of its |
|
| 115 | - | canonical CBOR encoding. |
|
| 116 | - | - **MicroVMs:** Flow's MicroVM tier runs on Firecracker, which has no |
|
| 117 | - | virtio-fs, so a microVM gets the artifact as a block device: an ext4 |
|
| 118 | - | image served on the fly from the chunk tree (a userspace block device, |
|
| 119 | - | ublk, or an NBD server), fetching blocks as the guest reads them and |
|
| 120 | - | duplicating no bytes. That removes copying whole images onto each |
|
| 121 | - | runner's disk without changing hypervisor. The syscall tier and |
|
| 122 | - | containers mount the tree directly over `flow-casfs`; mounting it with |
|
| 123 | - | virtio-fs would need Cloud Hypervisor or QEMU instead of Firecracker. |
|
| 124 | - | ||
| 125 | - | ### 4.2 Names are signed histories, not pointers |
|
| 126 | - | ||
| 127 | - | A **channel** (`app:stable`, `runner-image:rust`, `pkg:cargo/my-crate`) is an |
|
| 128 | - | append-only log of signed moves, recorded as an attestation of kind |
|
| 129 | - | `channel`: |
|
| 130 | - | ||
| 131 | - | ``` |
|
| 132 | - | Attest (kind = "channel") { |
|
| 133 | - | channel [0]: text // e.g. "app:stable" |
|
| 134 | - | old_art [1]: ObjectRef | null // previous artifact hash (CAS check) |
|
| 135 | - | new_art [2]: ObjectRef // target artifact hash |
|
| 136 | - | serial [3]: u64 // monotonically increasing |
|
| 137 | - | receipt [4]: ObjectRef | null // Receipt that built new_art |
|
| 138 | - | reason [5]: text // commit message or promotion note |
|
| 139 | - | } |
|
| 140 | - | ``` |
|
| 141 | - | ||
| 142 | - | A move needs policy: signed by an authorized key under the repository's |
|
| 143 | - | trust policy (`spec.md §14.5`), from an approved bookmark, after |
|
| 144 | - | required checks or human approvals pass. Clients follow a channel and |
|
| 145 | - | verify each step: |
|
| 146 | - | ||
| 147 | - | - A tag cannot move silently. The tj-actions attack needed one stolen |
|
| 148 | - | token; here it needs a move signed by an authorized key, and the move |
|
| 149 | - | is logged in the append-only history. |
|
| 150 | - | - Rolling back is a move to an earlier artifact, recorded and attested |
|
| 151 | - | like any other move. |
|
| 152 | - | - Pinning stays readable and tamper-evident: `app:stable@42` names the |
|
| 153 | - | 42nd entry of an audited log. |
|
| 154 | - | - On Strata stores, updates use compare-and-swap on the channel record |
|
| 155 | - | (`old_art` and `serial`), matching how bookmark `refs` operate. |
|
| 156 | - | ||
| 157 | - | ### 4.3 Provenance by construction and multi-party consensus |
|
| 158 | - | ||
| 159 | - | An artifact is published by a Flow job, and its record is the unbroken |
|
| 160 | - | chain: |
|
| 161 | - | ||
| 162 | - | > source state → pipeline → runner (identity, tier, image) → signed |
|
| 163 | - | > receipt → artifact |
|
| 164 | - | ||
| 165 | - | Each link is a hash or signature verifiable offline. Beyond what |
|
| 166 | - | attestations give today: |
|
| 167 | - | ||
| 168 | - | - **Assurance levels:** a MicroVM job on a registered runner is worth |
|
| 169 | - | more than a syscall-tier job, which is worth more than a signed local |
|
| 170 | - | build. Trust policies can enforce minimum assurance floors for |
|
| 171 | - | sensitive channels. |
|
| 172 | - | - **Multi-party consensus (`reproduced`):** when multiple independent |
|
| 173 | - | runners on disjoint machines or architectures build the same artifact |
|
| 174 | - | hash from the same state, each signs a receipt. A policy rule can |
|
| 175 | - | require threshold consensus: |
|
| 176 | - | `require: "reproduced(2)"` demands two independent runner receipts |
|
| 177 | - | before an artifact can be promoted to a production channel. |
|
| 178 | - | - **Upstream lineage:** an artifact built from other artifacts (a base |
|
| 179 | - | image, a package) names their hashes in its receipt inputs, so the chain |
|
| 180 | - | reaches back transitively to their sources. |
|
| 181 | - | - **An SBOM with every artifact:** the job that publishes an artifact also |
|
| 182 | - | records what it contains by package and version (an SBOM, itself an |
|
| 183 | - | artifact named in the receipt's outputs), so "which artifacts hold a |
|
| 184 | - | vulnerable version of this package" is a lookup, not a scan (§4.6). |
|
| 185 | - | ||
| 186 | - | ### 4.4 Distribution is built in |
|
| 187 | - | ||
| 188 | - | - **The store is the seed; the swarm delivers.** Jetstream's swarm, |
|
| 189 | - | generalized: any runner, cluster node or developer machine in a site |
|
| 190 | - | shares the chunks it has, over TLS pinned by the hub, admitted by |
|
| 191 | - | hub-signed tickets. |
|
| 192 | - | - **Lazy by default:** artifacts are mounted via `flow-casfs` and fetched |
|
| 193 | - | on demand. |
|
| 194 | - | - **Learned prefetch:** each run records which chunks were accessed |
|
| 195 | - | during its initial execution window; subsequent pulls request those |
|
| 196 | - | chunks first. |
|
| 197 | - | - **Sites and air gaps:** a site syncs chunks and channel logs from |
|
| 198 | - | another hub as a signed bundle, so a disconnected network gets |
|
| 199 | - | verified artifacts with no live connection. |
|
| 200 | - | ||
| 201 | - | ### 4.5 Sovereign storage, and our own transparency |
|
| 202 | - | ||
| 203 | - | - Bytes live on any Strata store: local disk, S3, GCS or Azure, in the |
|
| 204 | - | customer's own account and region, encrypted at rest by the store with |
|
| 205 | - | customer-managed keys (for example S3 with a KMS key). Chunks stay named |
|
| 206 | - | by the hash of their plain bytes: encrypting them on the client with |
|
| 207 | - | separate keys would stop them deduplicating and stop peers checking them |
|
| 208 | - | by hash. |
|
| 209 | - | - Channel moves and signatures are recorded in the hub's append-only |
|
| 210 | - | Merkle log. Auditing requires no dependence on public ledgers like |
|
| 211 | - | Rekor, though Cylinder can bridge to them when desired. |
|
| 212 | - | ||
| 213 | - | ### 4.6 Policy at point of use, and the inverted vulnerability index |
|
| 214 | - | ||
| 215 | - | Verification happens where the artifact is used, not only where it is |
|
| 216 | - | stored: |
|
| 217 | - | ||
| 218 | - | - A runner checks the job's image against policy before execution. |
|
| 219 | - | - A Kubernetes admission hook checks pod images against channel policies |
|
| 220 | - | and assurance levels. |
|
| 221 | - | - `oxy` and `flow` verify what they fetch. |
|
| 222 | - | ||
| 223 | - | **The inverted file index:** Because artifacts are trees of |
|
| 224 | - | content-addressed files rather than opaque tarballs, Cylinder keeps an |
|
| 225 | - | index from each file's hash to the artifacts that contain it: |
|
| 226 | - | ||
| 227 | - | $$\text{FileHash} \longrightarrow [\text{ArtifactHash}]$$ |
|
| 228 | - | ||
| 229 | - | - **Exact files, at once.** Given a known-bad file (a compromised binary, |
|
| 230 | - | a leaked key, the exact `libcrypto.so.3` an advisory names), one lookup |
|
| 231 | - | lists every container image, runner image and package across the fleet |
|
| 232 | - | holding it, with no layers to unpack. |
|
| 233 | - | - **Not versions.** A patched or rebuilt library is a different file with |
|
| 234 | - | a different hash, so this index does not answer "which images have a |
|
| 235 | - | vulnerable version of OpenSSL". That comes from package identity: the |
|
| 236 | - | SBOM each artifact carries (§4.3), indexed by package and version. The |
|
| 237 | - | two together answer both questions; the file index alone answers the |
|
| 238 | - | first. |
|
| 239 | - | - **Revocation without an outage.** When an artifact, base image or file |
|
| 240 | - | is revoked in policy, admission hooks refuse new pods that use it at |
|
| 241 | - | once. Pods already running are not touched by admission; a controller |
|
| 242 | - | marks them, then replaces or evicts them within a grace period the |
|
| 243 | - | policy sets, so one policy change cannot take production down. |
|
| 244 | - | ||
| 245 | - | ### 4.7 Retention by reachability and deployment leases |
|
| 246 | - | ||
| 247 | - | What a channel, a release, a pinned pipeline or an active deployment |
|
| 248 | - | reaches is kept; unreferenced chunks are collected by `oxy gc`. |
|
| 249 | - | ||
| 250 | - | **Deployment leases:** Container registries often suffer from garbage |
|
| 251 | - | collection deleting images still running on long-lived cluster pods. |
|
| 252 | - | To prevent this, nodes or Kubernetes operators register periodic |
|
| 253 | - | **deployment leases**: |
|
| 254 | - | ||
| 255 | - | ``` |
|
| 256 | - | Attest (kind = "lease") { |
|
| 257 | - | artifact [0]: ObjectRef // artifact hash currently running |
|
| 258 | - | scope [1]: text // e.g. "k8s:cluster-lon/ns/workload" |
|
| 259 | - | expires [2]: u64 // timestamp |
|
| 260 | - | } |
|
| 261 | - | ``` |
|
| 262 | - | ||
| 263 | - | Active leases act as garbage collection roots. If a workload terminates |
|
| 264 | - | or a cluster is decommissioned, the lease expires naturally, enabling |
|
| 265 | - | safe automated retention without operational risk. A lease is signed by |
|
| 266 | - | a key scoped to leases and to its `scope`, and has a maximum length (a |
|
| 267 | - | day, renewed while the workload runs), so a compromised node cannot hold |
|
| 268 | - | storage forever or pin artifacts outside its own scope. |
|
| 269 | - | ||
| 270 | - | ### 4.8 Compatibility at the edge |
|
| 271 | - | ||
| 272 | - | - **An OCI registry façade:** `docker pull` and Kubernetes work |
|
| 273 | - | unchanged. Manifests and layers are assembled on the fly from chunks. |
|
| 274 | - | `docker push` is accepted and chunked via FastCDC, applying |
|
| 275 | - | deduplication to legacy images. |
|
| 276 | - | - **A containerd snapshotter** speaking the native protocol, mounting |
|
| 277 | - | file trees via `flow-casfs` for instant sub-second container starts |
|
| 278 | - | and P2P delivery on Kubernetes. |
|
| 279 | - | - **Package façades (Cargo, npm, PyPI):** Served from the same chunk |
|
| 280 | - | store. Façade endpoints synthesize package metadata (Cargo sparse |
|
| 281 | - | indexes, npm packuments, PyPI simple indexes) dynamically from artifact |
|
| 282 | - | manifests. |
|
| 283 | - | - **Upstream dependency quarantine:** When Flow builds fetch third-party |
|
| 284 | - | packages from crates.io or npm, Cylinder proxies them into the |
|
| 285 | - | organization's Strata store, locking the hashes into internal mirror |
|
| 286 | - | channels. Upstream outages or deletions cannot break builds. Packages |
|
| 287 | - | arrive compressed (`.crate` and `.tgz` are gzip), which chunks poorly, |
|
| 288 | - | so the original archive is kept for clients that check its hash and its |
|
| 289 | - | unpacked files are chunked as an artifact tree, where they deduplicate |
|
| 290 | - | with everything else. |
|
| 291 | - | ||
| 292 | - | ### 4.9 AI-native agent integration |
|
| 293 | - | ||
| 294 | - | Extending Oxygen's scoped delegation model (`ai_native_agent_workflows.md §2`), |
|
| 295 | - | agent keys can hold artifact-specific scopes: |
|
| 296 | - | ||
| 297 | - | ``` |
|
| 298 | - | delegates [8]: [{ key: KeyId, scope: ["changes" | "refs" | "seal" | "artifacts" | "channels"], expires: u64? }] |
|
| 299 | - | ``` |
|
| 300 | - | ||
| 301 | - | - An AI agent (e.g. Aider, Claude Code) working autonomously can be |
|
| 302 | - | granted `--scope artifacts` and restricted channel rights: |
|
| 303 | - | - Permitted: building artifacts and moving `agents/NAME/**` channels. |
|
| 304 | - | - Forbidden: moving production channels (`app:stable`, `release/*`). |
|
| 305 | - | - Agents can test containerized workloads end-to-end in isolated |
|
| 306 | - | channels, while production promotion remains guarded by human landing |
|
| 307 | - | rules or multi-party consensus. |
|
| 308 | - | ||
| 309 | - | ### 4.10 Revset query integration |
|
| 310 | - | ||
| 311 | - | Oxygen's revset language (`revset_query_language.md`) bridges source |
|
| 312 | - | changes and deployed artifacts: |
|
| 313 | - | ||
| 314 | - | | Expression | Selects | |
|
| 315 | - | |---|---| |
|
| 316 | - | | `artifact(CHANNEL)` | Changes contained within the artifact currently at `CHANNEL` | |
|
| 317 | - | | `deployed(ENV)` | Changes currently active in environment `ENV` (via active leases) | |
|
| 318 | - | | `unshipped()` | Equivalent to `main ~ artifact(app:stable)` (changes on `main` that have not reached production) | |
|
| 319 | - | | `reproduced()` | Changes whose built artifacts have 2+ independent runner receipts | |
|
| 320 | - | | `vulnerable(CVE)` | Changes that touch or introduced files present in an artifact flagged with `CVE` | |
|
| 321 | - | ||
| 322 | - | Running `oxy log -r 'main ~ artifact(app:stable)'` immediately shows all |
|
| 323 | - | changes pending deployment. |
|
| 324 | - | ||
| 325 | - | ### 4.11 The UI (inside the hub) |
|
| 326 | - | ||
| 327 | - | - **Per repository, an Artifacts tab:** published artifacts, channel |
|
| 328 | - | histories, and the provenance graph from commit to run, artifact, and |
|
| 329 | - | deployment lease. |
|
| 330 | - | - **Site-wide:** chunk distribution across hubs and runners, active |
|
| 331 | - | swarm transfers, deduplication savings, and the global CVE inverted index. |
|
| 332 | - | - **Runner images** are listed as standard artifacts with their build |
|
| 333 | - | receipts and hardware architecture badges. |
|
| 334 | - | ||
| 335 | - | ```mermaid |
|
| 336 | - | flowchart LR |
|
| 337 | - | subgraph Build |
|
| 338 | - | S[Source state] --> P[Pipeline] |
|
| 339 | - | P --> R1[Runner 1: microvm] |
|
| 340 | - | P --> R2[Runner 2: disjoint host] |
|
| 341 | - | R1 -->|receipt 1| A[Artifact: tree of chunks] |
|
| 342 | - | R2 -->|receipt 2| A |
|
| 343 | - | end |
|
| 344 | - | A -->|consensus move| C[Channel log: app:stable@42] |
|
| 345 | - | A --> ST[(Strata store)] |
|
| 346 | - | ST -->|seed| SW{{Swarm}} |
|
| 347 | - | SW --> N1[Runner] |
|
| 348 | - | SW --> N2[K8s node: containerd snapshotter] |
|
| 349 | - | SW --> N3[Developer] |
|
| 350 | - | N2 -->|deployment lease| DL[Active Leases: GC Roots] |
|
| 351 | - | C -->|policy check| N2 |
|
| 352 | - | OCI[OCI façade] --- ST |
|
| 353 | - | PKG[Package façades: cargo/npm/pypi] --- ST |
|
| 354 | - | H[OxygenHub UI & CVE Index] --- C |
|
| 355 | - | H --- A |
|
| 356 | - | ``` |
|
| 357 | - | ||
| 358 | - | ### 4.12 Where it stands among the services |
|
| 359 | - | ||
| 360 | - | - **OxygenHub** owns repositories, proposals, checks, user identity, |
|
| 361 | - | and the UI. |
|
| 362 | - | - **Jetstream** owns the fleet: queue, placement, and runner swarms. It |
|
| 363 | - | fetches runner images from Cylinder by artifact hash. |
|
| 364 | - | - **Flow** executes pipelines and produces signed execution receipts. |
|
| 365 | - | - **Cylinder** owns artifacts: manifests, channel logs, provenance |
|
| 366 | - | chains, distribution, package façades, and deployment leases. |
|
| 367 | - | - **Strata** provides the underlying content-addressed chunk storage. |
|
| 368 | - | ||
| 369 | - | Each service runs embedded in OxygenHub by default, or standalone for |
|
| 370 | - | distributed scale. |
|
| 371 | - | ||
| 372 | - | ## 5. Better how, in one table |
|
| 373 | - | ||
| 374 | - | | | Today's best | Cylinder | |
|
| 375 | - | |---|---|---| |
|
| 376 | - | | Rebuild with one changed file | Re-push and re-pull the layer | Only that file's changed chunks | |
|
| 377 | - | | Dedupe | Whole identical layers | FastCDC chunks across repos, images and packages | |
|
| 378 | - | | Names | Mutable tags; digests nobody reads | Signed, append-only channels (`app:stable@42`) | |
|
| 379 | - | | Provenance | Attestations added later, if at all | The build's signed receipt is the record | |
|
| 380 | - | | Signing | A separate tool and keys | Native Ed25519 + ML-DSA-65 composite signatures | |
|
| 381 | - | | Consensus | Single-builder attestation | Multi-party consensus receipts (`reproduced(N)`) | |
|
| 382 | - | | Lazy start | Bolted-on snapshotter (SOCI, Nydus) | Native: file trees mounted via `flow-casfs` | |
|
| 383 | - | | Peer to peer | Separate mesh daemon (Dragonfly) | Built-in Jetstream swarm, TLS-pinned | |
|
| 384 | - | | Vulnerability search | Unpack and scan every layer | Exact files by one index lookup; versions by SBOM package index | |
|
| 385 | - | | Retention | Tag rules + manual GC | Reachability + active deployment leases | |
|
| 386 | - | | AI workflows | Uncontrolled service tokens | Scoped delegation (`artifacts`, `channels`) | |
|
| 387 | - | | Querying | Disconnected CI/CD dashboards | Unified revsets (`main ~ artifact(app:stable)`) | |
|
| 388 | - | | Sovereignty | Complex multi-tier server stack | Single binary; customer KMS and Strata bucket | |
|
| 389 | - | ||
| 390 | - | ## 6. How we could build it |
|
| 391 | - | ||
| 392 | - | - **C0. Images in the hub (built 2026-10-12, oxygenhub #146).** Admin › |
|
| 393 | - | Runners › Images: every runner image, its size and architecture, where |
|
| 394 | - | it is (the hub, each runner, swarm fetches in progress), the |
|
| 395 | - | repositories whose jobs used it, and an image held nowhere marked. |
|
| 396 | - | - **C1. The service boundary and artifact model.** Implement the |
|
| 397 | - | `Artifact` CBOR object type and FastCDC chunk engine in `cylinder`; |
|
| 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). |
|
| 410 | - | - **C2. Channels, provenance and consensus.** Flow publishes artifacts; |
|
| 411 | - | signed channel move attestations; multi-party `reproduced(N)` consensus; |
|
| 412 | - | revset predicates. |
|
| 413 | - | - **C3. The OCI façade and containerd snapshotter.** OCI pull/push |
|
| 414 | - | translation; `containerd` snapshotter over `flow-casfs`. |
|
| 415 | - | - **C4. Leases and the indexes.** Kubernetes deployment lease operator |
|
| 416 | - | for safe GC; the inverted file index and the SBOM package index; the |
|
| 417 | - | controller that replaces revoked running workloads within their grace |
|
| 418 | - | period. |
|
| 419 | - | - **C5. Reach and package façades.** Package façades for Cargo, npm, |
|
| 420 | - | and PyPI; upstream dependency quarantine mirroring. |
|
| 421 | - | ||
| 422 | - | ## 7. Open questions |
|
| 423 | - | ||
| 424 | - | These are Andy's decisions. Each carries the position proposed in review, |
|
| 425 | - | not an answer. |
|
| 426 | - | ||
| 427 | - | - **The name.** Proposed: keep **Cylinder**. It fits the elemental |
|
| 428 | - | progression: *Oxygen* (VCS) is carried in *Cylinders* (artifacts) by |
|
| 429 | - | *Jetstream* (the fleet), driven by *Flow* (pipelines). |
|
| 430 | - | - **Where it lives.** Proposed: first in `oxygenflow` as |
|
| 431 | - | `crates/oxygen-cylinder`, beside `jet` and `flow`, to share `flow-casfs` |
|
| 432 | - | and the chunking code; its own repository from C3. |
|
| 433 | - | - **Local builds.** Proposed: allowed, at the lowest assurance level |
|
| 434 | - | (`local`); production channels require more (`tier-microvm` and |
|
| 435 | - | `reproduced(2)`). |
|
| 436 | - | - **A public catalogue and federation.** Proposed: public hubs federate |
|
| 437 | - | by publishing signed Strata bundles of channels and artifacts, checked |
|
| 438 | - | offline with no central intermediary. |
|
| 439 | - | - **Its place in the queue.** Proposed: C1 alongside Jetstream's service |
|
| 440 | - | boundary, so Jetstream's image store is built on Cylinder from the |
|
| 441 | - | start rather than on a temporary mechanism. Against: the core work of |
|
| 442 | - | 2026-10-03 (Strata's remaining stores, agents, hub polish) waits |
|
| 443 | - | longer. |
|
| 444 | - | ||
| 445 | - | ## Sources |
|
| 446 | - | ||
| 447 | - | - Docker Hub pull limits: [docs.docker.com, pull usage and limits](https://docs.docker.com/docker-hub/usage/pulls/); [heise, "only 10 pulls per hour"](https://heise.de/-10329047) |
|
| 448 | - | - GHCR billing: [GitHub community discussion #183054](https://github.com/orgs/community/discussions/183054) |
|
| 449 | - | - GitLab cleanup policies and garbage collection: [GitLab docs, reduce container registry storage](https://docs.gitlab.com/user/packages/container_registry/reduce_container_registry_storage/) |
|
| 450 | - | - Bitbucket Packages: [Atlassian blog, Bitbucket Packages](https://www.atlassian.com/blog/bitbucket/bitbucket-packages-container-registry); [GA announcement](https://www.atlassian.com/blog/bitbucket/open-beta-introducing-packages-native-container-registry-in-bitbucket-cloud) |
|
| 451 | - | - Pull time versus data read: [SOCI paper (Amazon), citing Slacker](https://cdn.amazon.science/84/96/51ea2e0d40fca8cfaee9328278b9/soci-paper.pdf); [Grab engineering, lazy loading](https://engineering.grab.com/docker-lazy-loading) |
|
| 452 | - | - zstd:chunked: [Red Hat, faster container image pulls](https://www.redhat.com/sysadmin/faster-container-image-pulls) |
|
| 453 | - | - Peer-to-peer meshes: [Spegel vs Kraken vs Dragonfly](https://www.pistack.xyz/posts/2026-04-28-spegel-vs-kraken-vs-dragonfly-self-hosted-p2p-container-image-distribution-guide-2026/) |
|
| 454 | - | - Sigstore in registries: [Sigstore in image registries: adoption and reality](https://jacar.es/en/sigstore-in-image-registries-adoption-and-reality/); [cosign and the referrers API](https://oneuptime.com/blog/post/2026-08-11-cosign-signature-storage-oci-referrers-separate-repositories/markdown) |
|
| 455 | - | - tj-actions tag attack: [CVE-2025-30066](https://inventivehq.com/tools/security/cve-lookup/cve-2025-30066); [Legit Security, immutable tags](https://legitsecurity.com/blog/what-are-immutable-tags-and-can-they-protect-you-from-supply-chain-attacks) |
|
| 456 | - | - Artifactory and Nexus: [Codefresh, Artifactory alternatives](https://codefresh.io/learn/jfrog-artifactory/top-8-artifactory-alternatives-in-2025); [PeerSpot, Artifactory improvements](https://origin.peerspot.com/questions/what-needs-improvement-with-jfrog-artifactory) |
modified docs/guide.md +3 −92
| … | 149 unchanged lines |
||
| 150 | 150 | bookmark to resolve conflicts, `oxy bookmark set NAME CHANGE --upto` |
|
| 151 | 151 | keeps everything the bookmark held. |
|
| 152 | 152 | ||
| 153 | - | `--only` is exact, and that can surprise: other changes to the same files |
|
| 154 | - | are left out, so a file can come out partly edited (a pipeline file that |
|
| 155 | - | no longer parses, say). To put one change on top of a bookmark's work, |
|
| 156 | - | give that bookmark's heads with it, as `oxy bookmark show main` prints |
|
| 157 | - | them: `oxy bookmark set fix <main's heads> 745307b4`. |
|
| 158 | - | ||
| 159 | 153 | `oxy bookmark switch` says when the bookmark you switch to lacks changes |
|
| 160 | 154 | that the one you left has. |
|
| 161 | 155 | ||
| … | 162 unchanged lines |
||
| 324 | 318 | remove the markers. Tree conflicts, such as two files wanting the same |
|
| 325 | 319 | name, appear as `<name>.oxy-conflict-<n>` candidates: keep the one you |
|
| 326 | 320 | want under the right name and delete the rest. |
|
| 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 | - | ||
| 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 | 321 | ||
| 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 | 322 | ``` |
|
| 354 | 323 | oxy resolve # which conflicts your edits resolve, which still have markers |
|
| 355 | 324 | oxy commit -m "Resolve retry vs refactor" |
|
| … | 45 unchanged lines |
||
| 401 | 370 | ||
| 402 | 371 | A token is found in this order: the remote's own `remote.<name>.token`, then |
|
| 403 | 372 | `$OXY_TOKEN` (for CI), then the store, then a remote on the same hub in an |
|
| 404 | - | enclosing checkout (so a subrepo uses its parent's). When the token came |
|
| 405 | - | from the store or an enclosing checkout and the hub refuses it (a stale login, |
|
| 406 | - | or another account's), oxy tries the other tokens enclosing checkouts hold for |
|
| 407 | - | that hub before giving up, and says so once; a remote's own token and |
|
| 408 | - | `$OXY_TOKEN` are never swapped for another. oxy sends a token over |
|
| 373 | + | enclosing checkout (so a subrepo uses its parent's). oxy sends a token over |
|
| 409 | 374 | `https` only, or over plain `http` to this machine; it is never sent to |
|
| 410 | 375 | another host after a redirect, and a credentials file that others can read |
|
| 411 | 376 | is ignored. |
|
| … | 84 unchanged lines |
||
| 496 | 461 | carries you as author and its signature: `oxy log -r 'delegated()'` |
|
| 497 | 462 | lists them, `oxy verify` names the agent, and its own `oxy push` is |
|
| 498 | 463 | refused under any policy, so you land its work. |
|
| 499 | - | ||
| 500 | - | **Keyless agents** (on an OxygenHub) hold no key at all. You give the |
|
| 501 | - | agent a grant instead, signed by your key, and the hub certifies a fresh |
|
| 502 | - | key for it each time it signs: |
|
| 503 | 464 | ||
| 504 | - | ``` |
|
| 505 | - | oh agent grant claude --for 30d # your shell: prints the agent's token, once |
|
| 506 | - | oxy auth grant < token # the agent's shell (its own OXY_HOME) |
|
| 507 | - | oxy config --user author.key <YOUR KeyId> |
|
| 508 | - | oxy config --user author.signer keyless |
|
| 509 | - | oxy config --user author.for <YOUR ACCOUNT> # follow your newest identity |
|
| 510 | 465 | ``` |
|
| 511 | - | ||
| 512 | - | Each certificate lasts ten minutes and carries your grant, so the |
|
| 513 | - | agent's commits count as yours, and a push seals them with the fresh |
|
| 514 | - | key. A repository with a trust policy also names the agent: |
|
| 515 | - | `oxy policy grant-subject agent:claude@YOU --issuer hub.identity --scope |
|
| 516 | - | changes` (add `refs` to let it push), with the hub's identity from |
|
| 517 | - | `/.well-known/oxyhub-key`. `oh agent list` shows your grants and `oh |
|
| 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. |
|
| 523 | - | ||
| 524 | - | ``` |
|
| 525 | 466 | oxy identity revoke # retire your own key |
|
| 526 | 467 | oxy identity revoke 3f2a… --reason "laptop stolen" # any key, if you hold a root |
|
| 527 | 468 | ``` |
|
| … | 31 unchanged lines |
||
| 559 | 500 | oxy receipt export 3f2a9c build.receipt # hand it to someone |
|
| 560 | 501 | oxy receipt verify build.receipt # check one without keeping it |
|
| 561 | 502 | oxy receipt import build.receipt # verify and keep it |
|
| 562 | - | oxy receipt export 3f2a9c prov.json --format slsa # SLSA provenance, signed with your key |
|
| 563 | - | oxy identity export --pem me.pem # the key cosign checks it with |
|
| 564 | 503 | ``` |
|
| 565 | - | ||
| 566 | - | The SLSA form is for tools outside Oxygen: in-toto and SLSA verifiers, and |
|
| 567 | - | `cosign verify-blob-attestation --key me.pem --type slsaprovenance1 |
|
| 568 | - | --insecure-ignore-tlog=true --check-claims=false --signature prov.json`. Its subjects are named by |
|
| 569 | - | Oxygen hashes, which cosign cannot compute from a file, hence |
|
| 570 | - | `--check-claims=false`. |
|
| 571 | 504 | ||
| 572 | 505 | ## 11. Big repositories: partial, sparse, shallow |
|
| 573 | 506 | ||
| … | 94 unchanged lines |
||
| 668 | 601 | | Key | Meaning | |
|
| 669 | 602 | |---|---| |
|
| 670 | 603 | | `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) | |
|
| 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`) | |
|
| 673 | - | | `keyless.hub` | the hub that certifies keyless keys (default: the default remote's hub) | |
|
| 604 | + | | `author.signer` | the key that signs instead of `author.key`: an agent's own key, which your identity lists as a delegate (also `$OXY_SIGNER`) | |
|
| 674 | 605 | | `core.sign` | `change` to sign every commit; otherwise seals at push | |
|
| 675 | 606 | | `core.op-retention` | days for which old operations keep their states alive (90) | |
|
| 676 | 607 | | `core.gc-grace` | days an unreachable object survives (14) | |
|
| … | 17 unchanged lines |
||
| 694 | 625 | oxy gc # prune unreachable objects past the grace period, repack into one pack |
|
| 695 | 626 | oxy gc --now # ignore the grace period (only when nothing else is writing) |
|
| 696 | 627 | oxy fsck # check every object, the op log, references, signatures, checkpoints |
|
| 697 | - | oxy doctor # check identity, config, trust policy and remotes, with what to do about each problem |
|
| 628 | + | oxy doctor # check identity, config and remotes, with what to do about each problem |
|
| 698 | 629 | oxy checkpoint # serialise the bookmark's state so large histories load instantly |
|
| 699 | 630 | ``` |
|
| 700 | 631 | ||
| … | 34 unchanged lines |
||
| 735 | 666 | ``` |
|
| 736 | 667 | ||
| 737 | 668 | The oh-my-bash plugin in `contrib/oh-my-bash` loads the bash completions itself. |
|
| 738 | - | ||
| 739 | - | ## Transparency logs |
|
| 740 | - | ||
| 741 | - | A hub that keeps a transparency log (OxygenHub does, at `/log`) logs every signed object it accepts. `oxy fetch` and `oxy pull` check it as they go. They remember the log's key and its last checkpoint, and fail if the hub ever shows a log that does not extend what you saw before. That is the sign of a hub showing different histories to different people. To audit a hub's whole log and see what it holds for this repository: |
|
| 742 | - | ||
| 743 | - | ``` |
|
| 744 | - | oxy tlog verify # this checkout's hub |
|
| 745 | - | oxy tlog verify https://oxygenhub.io --all |
|
| 746 | - | oxy tlog verify --key <KeyId> # what a key signed |
|
| 747 | - | ``` |
|
| 748 | - | ||
| 749 | - | Anyone can **witness** a hub's log: a witness cosigns each checkpoint that extends the last one it cosigned, with its own key and clock, so a hub cannot show two histories without the witness noticing. The hub lists its witnesses in `hub.toml` (`[tlog] witnesses`), and its served checkpoint carries their cosignatures. |
|
| 750 | - | ||
| 751 | - | ``` |
|
| 752 | - | curl -fsSL https://oxygenhub.io/.well-known/oxyhub-key -o oxygenhub.identity |
|
| 753 | - | oxy witness serve --name witness.example.org --log oxygenhub.io/log=oxygenhub.identity \ |
|
| 754 | - | --bind 0.0.0.0:8891 --tls-cert cert.pem --tls-key key.pem |
|
| 755 | - | ``` |
|
| 756 | - | ||
| 757 | - | It signs with your oxy home's key and keeps what it cosigned in `--state` (default `witness-state`). |
removed docs/lazy-clone.md +0 −195
| 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. |
removed docs/mcp.md +0 −78
| 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 +6 −31
| … | 51 unchanged lines |
||
| 52 | 52 | when the server was started with tokens; a missing or wrong token is `401`. |
|
| 53 | 53 | - Errors are plain-text bodies with a 4xx or 5xx status; the client shows the |
|
| 54 | 54 | text. A push that is *refused* (policy, compare-and-swap) is a `200` with |
|
| 55 | - | `ok = false` in the push response, not an HTTP error; one a host |
|
| 56 | - | refuses by its content (`vet_push`, spec §16.4) is an HTTP error with |
|
| 57 | - | the host's reason as its text, and nothing of it is stored. |
|
| 55 | + | `ok = false` in the push response, not an HTTP error. |
|
| 58 | 56 | - TLS is ordinary HTTPS. The reference client trusts the system web roots |
|
| 59 | 57 | plus an optional PEM file (`remote.<name>.ca` or `OXY_CA`); servers with |
|
| 60 | 58 | self-signed certificates are expected to hand that file out. |
|
| … | 8 unchanged lines |
||
| 69 | 67 | with `400`. Clients send the header on every request when they have an |
|
| 70 | 68 | id; servers ignore it on bound repositories. |
|
| 71 | 69 | ||
| 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 | - | ||
| 92 | 70 | Embedding: `oxy_server::Server` answers any endpoint for a repository name |
|
| 93 | 71 | a host's `Backend` resolves and authorises (`open`, `authorize`, and an |
|
| 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 |
|
| 96 | - | what it adds before anything is stored: an HTTP error, `422` say, whose |
|
| 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 |
|
| 72 | + | `after_push` hook), so a hub with `owner/name` paths and its own accounts |
|
| 98 | 73 | routes `/{owner}/{name}/oxy/v1/{endpoint}` to `Server::handle`; `oxy serve` |
|
| 99 | 74 | is `DirBackend` on that same server. |
|
| 100 | 75 | ||
| … | 11 unchanged lines |
||
| 112 | 87 | | Signed attestation | `[attest bytes, [Signature]]` | |
|
| 113 | 88 | | Signed policy | `[policy bytes, [Signature]]` (also the policy proposal file format) | |
|
| 114 | 89 | | Capabilities | `{0: [protocol versions], 1: repo id bytes(16), 2: [feature names]}`; version `1`; features currently `["seal"]` | |
|
| 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 | |
|
| 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) | |
|
| 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 | |
|
| 90 | + | | Refs response | `{0: [ref], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}` | |
|
| 91 | + | | ref | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}`; the attestation is present only when it describes the current state; `last serial` is the highest attestation serial ever recorded for the name | |
|
| 92 | + | | tag | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` | |
|
| 118 | 93 | | Negotiate request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order (no change before its dependencies) | |
|
| 119 | 94 | | Fetch request | `{0: [hashes], 1: max blob size or null}`; response a Bundle whose blobs above the size (and their chunks) are omitted | |
|
| 120 | 95 | | Objects request | `{0: [refs]}`; response a Bundle carrying only objects; a blob is accompanied by its chunks | |
|
| 121 | 96 | | 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 | |
|
| 122 | 97 | | Shallow request | `{0: [heads], 1: depth}`; response `{0: [hashes]}`: the changes within `depth` dependency steps of the heads, dependencies first | |
|
| 123 | 98 | | Bundle | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}` | |
|
| 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 | |
|
| 99 | + | | Push request | `{0: Bundle, 1: [update], 2: signed policy or null, 3: [tag]}` | |
|
| 125 | 100 | | update | `{0: name, 1: old state ref, 2: new state ref, 3: force (bool), 4: signed attestation or null}` | |
|
| 126 | 101 | | Push response | `{0: [{0: name, 1: ok (bool), 2: reason (text)}]}`; a result named `policy` reports the policy update, `tags/<name>` each tag | |
|
| 127 | 102 | ||
| … | 101 unchanged lines |
modified docs/spec.md +30 −172
| … | 355 unchanged lines |
||
| 356 | 356 | ||
| 357 | 357 | ### 9.2 Tree conflicts |
|
| 358 | 358 | ||
| 359 | - | - **Name conflict.** Two live nodes share a placement. The same path added with identical content on several lines of history is not one, as Git unifies an addition both sides of a merge made: when every node at the placement is a file, symlink, or subrepo of one kind and one mode, none is in another tree conflict, and their contents are the same and unconflicted (one rendering for text files; one live blob for opaque files, symlink targets, and subrepo pins), the state shows a single entry, the node whose placement has the least `PlacementRef`, so every peer shows the same one. Nothing is resolved and no change is recorded. The first change that touches the node shown (an edit, `Move`, `RemoveNode`, or `SetMode`) also deletes the others' placements, or they would differ from it and the conflict would return; a working-copy snapshot adds those deletions with the touch and reports no change of its own for them. Directories are not unified. A Git import builds against the tree without this unification, so an import of a merge records Git's tree for such a path itself (§23). |
|
| 359 | + | - **Name conflict.** Two live nodes share a placement. |
|
| 360 | 360 | - **Multi-placement.** One node has two live placements (concurrent renames). |
|
| 361 | 361 | - **Orphan.** A live node's parent directory is not live (concurrent delete of a directory and modification within it). |
|
| 362 | 362 | - **Cycle.** A directory is placed inside its own descendant, which arises when two commuting changes each move a directory under the other. Both placements are live and the tree is conflicted. For materialization the placement whose introducing change has the greater `(hlc, ChangeHash)` is treated as absent; resolution deletes one of the two placements. |
|
| … | 3 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 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. |
|
| 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. |
|
| 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. 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. |
|
| 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. |
|
| 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 | - | ||
| 395 | 393 | ### 9.6 Resolution |
|
| 396 | 394 | ||
| 397 | 395 | 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 |
||
| 416 | 414 | ``` |
|
| 417 | 415 | bookmarks/<name> local bookmarks |
|
| 418 | 416 | remotes/<remote>/bookmarks/<name> last-seen remote bookmarks |
|
| 419 | - | tags/<name> tags (immutable once created; may be withdrawn) |
|
| 417 | + | tags/<name> tags (immutable once created) |
|
| 420 | 418 | policy the trust policy head (§14.5) |
|
| 421 | 419 | ``` |
|
| 422 | 420 | ||
| … | 5 unchanged lines |
||
| 428 | 426 | State { version [0]: 1, heads [1]: [ChangeHash] } // sorted, unique; may be empty |
|
| 429 | 427 | ``` |
|
| 430 | 428 | ||
| 431 | - | A repository holds any number of bookmarks. Each workspace has a **current bookmark** (`oxy bookmark switch NAME`, recorded per workspace) that its working copy sits on and that `commit`, `log`, `push`, and `pull` act on by default. Switching refuses uncommitted work unless told what to do with it: `--discard` drops it, and `--carry` takes it along when every path it touches is the same in NAME's state as in the working copy's base (so the same edits apply there), refusing and naming the paths otherwise. `oxy bookmark create feat && oxy bookmark switch feat --carry` moves work started on one bookmark onto a new one. `oxy bookmark create NAME [CHANGE..]` names a new state (default: the current working copy's base heads); given CHANGE.., it refuses when the current state has changes recorded before the latest of them that they do not contain, since a state holds its heads' dependency closure and not everything recorded before them, unless `--upto` (the current state's changes up to CHANGE.., as Git ancestry would have it) or `--only` (exactly CHANGE.. and their dependencies) says which is meant, and `bookmark switch` reports how many changes the new state lacks of the old; `set NAME CHANGE..` moves one, `delete NAME` removes it (its changes stay until unreachable, §21), `list` shows all with their heads and the remote-tracking difference, and `oxy merge NAME` unions the named bookmark's heads into the current one (NAME is a bookmark here or a remote's tracking bookmark, `origin/develop`, as last fetched: oxygen#117), which is the whole of merging in this model: no merge change, conflicts reified (§9); a working copy without changes follows the merge, whichever state it sat on. `oxy checkout NAME` materializes a bookmark without switching to it; with uncommitted work it refuses, naming each changed path and how it changed (a *removed* path is in the working copy's base but not on disk), unless `--discard` or `--carry` (as for switching) says what to do with the work. Bookmark names, then tag names, are resolved before change prefixes wherever a change is expected (a bookmark or tag names a change when its state has one head). For builds, `oxy describe` without `-m` names the working copy as `git describe` does: its base state's tag (`v1.2`), else the nearest tag whose state the base contains with the number of changes it adds and the newest head's ChangeId (`v1.2-3-8c5ea12a`), else the bookmark and that ChangeId (`develop-8c5ea12a`), with `-dirty` when the working copy has uncommitted changes; `--json` gives the parts. |
|
| 429 | + | A repository holds any number of bookmarks. Each workspace has a **current bookmark** (`oxy bookmark switch NAME`, recorded per workspace) that its working copy sits on and that `commit`, `log`, `push`, and `pull` act on by default. Switching refuses uncommitted work unless told what to do with it: `--discard` drops it, and `--carry` takes it along when every path it touches is the same in NAME's state as in the working copy's base (so the same edits apply there), refusing and naming the paths otherwise. `oxy bookmark create feat && oxy bookmark switch feat --carry` moves work started on one bookmark onto a new one. `oxy bookmark create NAME [CHANGE..]` names a new state (default: the current working copy's base heads); given CHANGE.., it refuses when the current state has changes recorded before the latest of them that they do not contain, since a state holds its heads' dependency closure and not everything recorded before them, unless `--upto` (the current state's changes up to CHANGE.., as Git ancestry would have it) or `--only` (exactly CHANGE.. and their dependencies) says which is meant, and `bookmark switch` reports how many changes the new state lacks of the old; `set NAME CHANGE..` moves one, `delete NAME` removes it (its changes stay until unreachable, §21), `list` shows all with their heads and the remote-tracking difference, and `oxy merge NAME` unions the named bookmark's heads into the current one, which is the whole of merging in this model: no merge change, conflicts reified (§9); a working copy without changes follows the merge, whichever state it sat on. `oxy checkout NAME` materializes a bookmark without switching to it; with uncommitted work it refuses, naming each changed path and how it changed (a *removed* path is in the working copy's base but not on disk), unless `--discard` or `--carry` (as for switching) says what to do with the work. Bookmark names, then tag names, are resolved before change prefixes wherever a change is expected (a bookmark or tag names a change when its state has one head). For builds, `oxy describe` without `-m` names the working copy as `git describe` does: its base state's tag (`v1.2`), else the nearest tag whose state the base contains with the number of changes it adds and the newest head's ChangeId (`v1.2-3-8c5ea12a`), else the bookmark and that ChangeId (`develop-8c5ea12a`), with `-dirty` when the working copy has uncommitted changes; `--json` gives the parts. |
|
| 432 | 430 | ||
| 433 | 431 | Every reference, attestation, view, and checkpoint therefore names a state by one ObjectRef however many heads it has. Multiple heads are normal in this model: every independent change is a head until something depends on it, so a busy bookmark may have dozens. That is not a conflict in the §9 sense; `oxy log` shows the heads as parallel, and a state object keeps the cost of naming them constant for everything that refers to the bookmark. The empty state, `State { heads: [] }`, is what a new bookmark points at. |
|
| 434 | 432 | ||
| 435 | 433 | ### 10.2 Reference attestations (G2, G3) |
|
| 436 | 434 | ||
| 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. |
|
| 438 | - | ||
| 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). |
|
| 435 | + | 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. |
|
| 440 | 436 | ||
| 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). |
|
| 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. |
|
| 442 | 438 | ||
| 443 | 439 | ## 11. Checkpoints |
|
| 444 | 440 | ||
| … | 40 unchanged lines |
||
| 485 | 481 | `view` is the complete state of all references and workspaces after the operation. The operation log is a DAG. The head (`.oxy/ops/head`) moves by compare-and-swap under a short lock (`.oxy/ops/lock`), held only while the pointer is written: a process that finds the head is no longer the parent it built on records a merge operation with both as parents, whose view is the three-way merge of the two views against that parent. A reference changed on one side takes that side's value; changed differently on both, a bookmark keeps both sides' heads (it then has several), a change beats a deletion, a tag keeps its first value, remote references and the policy take the later process's value, and a workspace changed by both is an error: the later operation is not recorded, since the files on disk match only one of the two working-copy changes. `oxy undo` refuses when the head moved while it ran. |
|
| 486 | 482 | ||
| 487 | 483 | `oxy undo` undoes **this workspace's** last command (oxygen#99): the newest operation recorded by this workspace (for operations older than the `workspace` field, one that moved only other workspaces' working copies is theirs), passing over working-copy snapshots, pushes (the remote keeps what it was sent) and commands an earlier undo reverted, and taking a command's own `(checkout)` with it. When no other workspace recorded an operation since, the head moves back to the command's parent. Otherwise the command is reverted in place as a new operation naming it in `undoes`: each reference it changed returns to its value before it; a bookmark another workspace moved since is put back head by head (the heads the command added go, those it replaced return, the other workspace's stay); and a reference that cannot be put back so (a later change builds on one the command added, or another workspace changed the same tag, remote reference, policy or working copy) refuses the undo, naming it. Undo reports the command it undid, what that changed, the other workspaces' operations it kept, and bookmarks whose undone changes a remote already holds. `oxy redo` and `oxy op log` expose the DAG. Operations are retained for `core.op-retention` (default 90 days) and then eligible for garbage collection. |
|
| 488 | - | ||
| 489 | - | A process reads the operation head once, when it first needs it, and answers every later read of references, bookmarks and the view from that operation (oxygenhub#249): one command, or one request a host serves, never mixes two heads, and never waits on another process's operation to read. Its own operations move it to the head each one recorded. It reads the head again only where it must see what others did meanwhile: once it holds the working-copy lock (whoever held the lock may have moved the head), when a host receives a push (the updates are checked against the head as it is now), in `oxy fsck`, and wherever the implementation asks for it explicitly (`Repo::refresh`). An operation recorded from an older head merges with the newer one as any concurrent operation does. |
|
| 490 | 484 | ||
| 491 | 485 | Because all mutation goes through the operation log and object writes are idempotent, no long-held repository lock is needed: the operation head's lock covers one pointer write. Each workspace has a **working-copy lock** (`.oxy/workspaces/<id>/lock`), held by every command that reads or writes the working copy and replaces its working-copy change (snapshot, checkout, bookmark switch, merge, commit, amend and rewrites, discard, pull), from reading the operation head until its operation is recorded. A snapshot therefore never sees a half-written checkout, and never records one as edits. The lock is re-entrant within a process thread; a waiting command gives up after a minute and says why; a lock whose process is gone is taken over, and where that cannot be told, one older than a minute. |
|
| 492 | 486 | ||
| … | 10 unchanged lines |
||
| 503 | 497 | - `oxy commit` (`-m MSG`, or `-F FILE` with `-` for standard input) sets the message, sets `kind = normal`, and starts a fresh working-copy change on top of it. The change is signed immediately if `core.sign = "change"`; with the default `core.sign = "seal"` it is left unsigned locally and covered by a seal at push time (§14.4). |
|
| 504 | 498 | - `oxy new`, `oxy edit <change>`, and `oxy abandon` move which change the workspace edits. |
|
| 505 | 499 | - There is no staging area and no stash. A partial commit is a split of the working copy: `oxy commit PATH..` records only the named paths (and every edit of theirs) and leaves the rest as uncommitted work; `oxy commit --interactive` offers each hunk (one `Insert` or `Delete` edit on one file, or one tree operation) for inclusion, like `git add -p`; `oxy split` does the same to an existing change. A hunk that another selected hunk refers to is selected with it, so the committed change is always well-formed (§8.3). |
|
| 506 | - | - A **resolution** (a working copy that resolves its base's conflicts, §9.6) may be committed by paths too, so other work need not be put aside (oxygen#118): the named paths must include every file it resolves, whole, or the commit is refused naming them; what is left stays uncommitted on top of the resolved state. `oxy amend PATH..` folds only those paths into the head (committed as a change on it, then squashed into it, §8.5), leaving the rest uncommitted; `-m` replaces the head's message as usual. |
|
| 507 | 500 | ||
| 508 | 501 | ### 12.4 Snapshot performance |
|
| 509 | 502 | ||
| … | 14 unchanged lines |
||
| 524 | 517 | objects/ |
|
| 525 | 518 | pack-<id>.pack append-only pack file |
|
| 526 | 519 | pack-<id>.idx sorted (ObjectRef → offset, length, type) index |
|
| 527 | - | pack-<id>.dict the pack's zstd dictionary, when a full repack trained one |
|
| 528 | - | pack-retired packs, loose objects and entry lists a repack or fold replaced, kept for readers (§13.4) |
|
| 529 | - | pack-entries journal of signatures and index entries written since the last fold (§13.7) |
|
| 530 | - | pack-<id>.entries an entry list: every signature and index entry, as a fold found them (§13.7) |
|
| 531 | - | pack-<id>.entries-part an entry list of journal lines a writer folded (§13.7) |
|
| 532 | - | pack-legacy-folded written once a collection has folded the older one-list keys (§13.7) |
|
| 533 | 520 | loose/ objects written since the last pack, as header || body |
|
| 534 | - | nocompress/ chunks packs keep raw (§13.3) |
|
| 535 | - | sig/ one signature per signer, keyed by the ObjectRef it covers (§5.4, §13.7) |
|
| 536 | - | seal/ receipt/ approval/ cert/ accepted/ |
|
| 537 | - | index entries, one key each, the value in the key (§13.7) |
|
| 521 | + | sigs/ signature lists keyed by the ObjectRef they cover (§5.4) |
|
| 538 | 522 | ops/ operation objects and op heads |
|
| 539 | 523 | refs/ current view cache (derived from ops; rebuildable) |
|
| 540 | 524 | workspaces/<id>/ snapshot cache, fsmonitor cookie |
|
| … | 26 unchanged lines |
||
| 567 | 551 | ||
| 568 | 552 | ### 13.4 Packing |
|
| 569 | 553 | ||
| 570 | - | An automatic trigger when the loose object count exceeds 4096 writes loose objects into a new pack and merges packs when there are more than 16; `oxy gc` repacks every surviving object into one pack, compressed harder (zstd level 9, against level 3 for everyday writes) and on every core, as `git gc` repacks. A store that is already one such pack (it has a dictionary) with nothing loose is left as it is: repacking it would write the same pack. Writers SHOULD batch: a snapshot, a fetched bundle, a Git import, or a checkpoint is one write batch, and a batch that grows beyond a small threshold (256 objects or 8 MiB) is written as a single pack with one `fsync` rather than as loose objects, each with its own; smaller batches stay loose so ordinary commits do not multiply packs. Objects of an open batch MUST be readable by the writer before the batch completes. Pack indexes MUST support lookup by ObjectRef prefix and MUST be memory-mappable. A repository with two million objects MUST be openable with under 64 MiB of resident index memory on the Full profile. |
|
| 571 | - | ||
| 572 | - | Readers never wait for writers (oxygenhub#249). A store loads its list of packs when it opens and keeps it; a read that misses is not believed at once. When the pack an object was found in is gone, or its dictionary is, the list is loaded again and the read tried once more; when the object is in no pack and not loose, the store lists its packs, and if the listing differs from the one it loaded from, loads the new packs and tries once more. Only then is the object missing (`has` answers false). A pack whose index is gone between the listing and the read, as when a collection deletes it meanwhile, is passed over and the store listed once more, so opening a repository never fails for it. A writer asking whether it already holds many objects lists the packs at most once a second while nothing new turns up. |
|
| 554 | + | An automatic trigger when the loose object count exceeds 4096 writes loose objects into a new pack and merges packs when there are more than 16; `oxy gc` repacks every surviving object into one pack, compressed harder (zstd level 9, against level 3 for everyday writes) and on every core, as `git gc` repacks. Writers SHOULD batch: a snapshot, a fetched bundle, a Git import, or a checkpoint is one write batch, and a batch that grows beyond a small threshold (256 objects or 8 MiB) is written as a single pack with one `fsync` rather than as loose objects, each with its own; smaller batches stay loose so ordinary commits do not multiply packs. Objects of an open batch MUST be readable by the writer before the batch completes. Pack indexes MUST support lookup by ObjectRef prefix and MUST be memory-mappable. A repository with two million objects MUST be openable with under 64 MiB of resident index memory on the Full profile. |
|
| 573 | 555 | ||
| 574 | - | A repack does not delete what it replaces. The packs it folded in and the loose objects it packed are **retired**: recorded, with the time, in `pack-retired`, a reference record of lines `<wall ms> <key>` updated by compare-and-swap, so concurrent repacks and collections never lose each other's lines. A store opening, or loading its packs again, does not load a retired pack, and retired loose objects are not counted or packed again as loose; a reader that loaded its packs before the repack reads on from the old files. Each `oxy gc`, and each automatic pack check after a commit, first deletes what was retired at least an hour ago: each such pack with its index and dictionary, and each such loose object a live pack holds. A retired loose object no live pack holds (its pack copy was pruned since) is kept as an ordinary loose object. Readers that loaded their packs before the files went find the objects in the new pack by the reload above. The hour is independent of `core.gc-grace` (§21): `oxy gc --now` waives the grace period for unreachable objects, not the hour readers have. |
|
| 575 | - | ||
| 576 | 556 | The pack and index formats are fixed so that tools can read them without the implementation: |
|
| 577 | 557 | ||
| 578 | 558 | ``` |
|
| … | 12 unchanged lines |
||
| 591 | 571 | ||
| 592 | 572 | Every update that must survive a crash replaces a file atomically: write a uniquely named temporary file in the same directory, `fsync` it, rename it over the target, and `fsync` the directory. Loose objects and packs are written the same way, a pack before its index. The operation head (`.oxy/ops/head`) is the commit point of every command: before it moves, every object the new operation names MUST be durable, including objects still held by an open write batch (§13.4), which is flushed first. A crash therefore leaves the repository at the operation before or after the interrupted command, never between; stray temporary files may remain and are ignored. The reference implementation's tests stop `commit` and `push` at each durable step and require both repositories to open, pass `oxy fsck` (§21.1) and accept the same command again. |
|
| 593 | 573 | ||
| 594 | - | ### 13.7 Signatures and index entries |
|
| 595 | - | ||
| 596 | - | Signatures (§5.4) and the indexes kept beside the objects are stored one key per entry, with the value in the key where there is no other. `sig/<hh>/<rest>/<signer>` holds one signer's signature over the object `<hh><rest>`; a new signature by the same signer replaces it. `seal/`, `receipt/`, `approval/`, `cert/` and `accepted/` hold empty `<hh>/<rest>/<value>` keys: the seal attestations covering a change, the receipts and approvals on a state, the certificate kept for a short-lived key, and a reference's accepted attestation. Each entry is its own write-once key, so writers sharing a store never lose each other's entries. Stores written before this layout kept one list per object (`sigs/`, `seals/`, `receipts/`, `approvals/`, `certs/`, `accepted-lists/`); readers still read these until a collection folds them (below). |
|
| 597 | - | ||
| 598 | - | To find an object's entries in this layout, a reader lists its prefix. On a directory that is one directory read. On a bucket it is one LIST request per lookup, and most find nothing, because most objects are unsigned (oxygen#116). So a store that is not this disk's directory also keeps its entries as lists that a reader reads once per open. Neither changes the per-object keys, which every writer still writes first, so older readers keep working. |
|
| 599 | - | ||
| 600 | - | - **The journal**, `pack-entries`: a reference record, updated by compare-and-swap as `pack-retired` is, of lines `<folded at, wall ms; 0 until folded> <entry key>`. After writing an entry's own key, a writer adds the key to the journal. A write batch (§13.4) adds all of its entries in one update when it is flushed, before the operation head moves. A writer that loses the race reads the record again and retries (up to 32 times), so concurrent writers never lose each other's lines. |
|
| 601 | - | - **Entry lists**: zstd-compressed UTF-8 text, written once: the line `oxy-entries 1`, then one entry key per line, sorted. `pack-<id>.entries` is **complete**: it holds every entry the store held when it was written. `pack-<id>.entries-part` is **partial**: it holds the journal lines a writer folded out. The lists are named under `pack-` so that the pack listing every open makes already finds them. They are not tied to a pack: a repack by an implementation that does not know them cannot hide them, and a collection with nothing to repack still folds. |
|
| 602 | - | - **Folding.** `oxy gc` (and a forge's collection) folds **completely**. It reads the journal, lists every entry prefix, writes a complete list of both, retires every list it read (in `pack-retired`, like a pack: a collection deletes it an hour later), and marks the journal lines it read as folded. When nothing changed since the last complete list, no list is written. A collection that deletes an object deletes its entry keys and their journal lines too, and the next complete list leaves them out. A writer whose update leaves more than 1000 unfolded lines folds them into a partial list and marks them. The next update after a folded line's hour (§13.4) drops it. Until then, a reader whose pack listing predates the fold still finds every entry, in the old lists or in the journal. Lines added while a fold runs are not marked, so they stay. |
|
| 603 | - | - **Reading.** A store is **folded** when its live lists (listed and not retired) include a complete one. On its first lookup, a handle on a folded store reads every live list and the journal, folded lines included, and answers every lookup from that set. It adds what it writes itself, and it never lists the per-object prefixes or reads the older lists. A signature's bytes are still read from its own key, which a cache may keep, since that key is written once. A handle reads them again when it loads its packs again (§13.4) or is refreshed, and on the first lookup more than 30 seconds after it last read them. At that point it lists its packs again, reads only the lists that are new, and reads the journal. 30 seconds is as long as a cloud client keeps the pack listing, so another writer's entry is seen within that interval, as another writer's pack is, even by a handle a server keeps open across requests. On a store that is not folded (no collection has folded it since writers kept a journal, or another implementation's collection retired its lists), a lookup lists the per-object prefix as before. |
|
| 604 | - | - **Older lists.** Before listing the entry prefixes, a complete fold copies every older one-list key's contents into per-object entries, keeping an entry already there. It then writes the marker `pack-legacy-folded` once. A reader whose pack listing shows the marker does not read the older lists. Nothing writes them any more. |
|
| 605 | - | ||
| 606 | - | **Older writers.** Writers that predate the journal (oxy 0.4.6 and before) write only the per-object keys. Readers of a folded store miss what such a writer adds until the next complete fold picks it up. A store becomes folded at its first complete fold. So the operator of a repository kept in a bucket first upgrades every writer to it (clients with `storage.url`, runners, the forge), then runs `oxy gc` there, or lets the forge's collection run. After that, a lookup costs no request apart from reading a signature's own key. A forge that collects nightly picks up an older writer's entries within a day. A repository's own directory (`.oxy/objects`) keeps no journal or lists: reading a directory costs no request, so its lookups list the per-object prefixes as before. |
|
| 607 | - | ||
| 608 | 574 | ## 14. Identity, signatures, and trust policy |
|
| 609 | 575 | ||
| 610 | 576 | ### 14.1 Algorithms |
|
| … | 32 unchanged lines |
||
| 643 | 609 | ||
| 644 | 610 | The two public keys live in one object, so a `KeyId` names the pair. `delegates` lists keys that may act for this identity, each within a **scope**: `changes` lets the key sign changes whose `author` is this identity, `seal` lets it seal them, and `refs` lets it attest references and tags as this identity would (§14.5). A proxy acting for a Thin client (§22) or a host that commits browser edits for a user gets `changes,refs`; an AI agent gets `changes` alone, so its signature authenticates the changes it makes but it can never move a reference, whatever its owner may do. A delegation with `expires` authenticates nothing whose `hlc.wall_ms` is later than that, so an agent can be let in for a session without changing the owner's own expiry. Delegation is one level deep and is not transitive. A bare `KeyId` in `delegates`, as identities carried before scopes existed, means every scope with no expiry. Rotation is a new identity with `previous` set, plus an attestation of kind `rotate` signed by the previous key. Revocation is an attestation of kind `revoke` signed by the revoked key, by the identity that delegated to it, or by a policy root key (§14.5); the delegator's revocation marks the changes the key already signed as valid but revoked, and the delegator then re-issues their identity without it. |
|
| 645 | 611 | ||
| 646 | - | **Lifecycle rules.** Because the `KeyId` is the hash of the whole object, any change to an identity, including its `delegates` or `expires`, is a rotation: a successor object with `previous` set. A successor MAY keep the same key pair (`oxy identity renew`, `oxy identity delegate add|remove`) or carry a fresh one (`oxy identity rotate`); either way earlier signatures remain verifiable under the earlier object, which stays stored. A rotation **supersedes** the previous KeyId: a policy that named it MUST be updated to name the successor (§14.5), and a rule key covers every predecessor in its `previous` chain that is not revoked, so changes signed before a rotation remain authorized. A tag's attestation (§10) signed by such a predecessor is authorized only when its `hlc.wall_ms` is earlier than the `created` of the identity that succeeded the signer, so tags made before a rotation stay valid and a superseded key makes no new ones. Revocation is for compromised or abandoned keys: a revoked key authorizes nothing, whatever its chain. `expires` bounds the changes a key can authenticate: a signature or seal over a change whose `hlc.wall_ms` is later than the identity's `expires` is invalid. A client records the revocations it makes or verifies under `.oxy/revoked/` and reports signatures by such keys as valid but revoked. |
|
| 612 | + | **Lifecycle rules.** Because the `KeyId` is the hash of the whole object, any change to an identity, including its `delegates` or `expires`, is a rotation: a successor object with `previous` set. A successor MAY keep the same key pair (`oxy identity renew`, `oxy identity delegate add|remove`) or carry a fresh one (`oxy identity rotate`); either way earlier signatures remain verifiable under the earlier object, which stays stored. A rotation **supersedes** the previous KeyId: a policy that named it MUST be updated to name the successor (§14.5), and a rule key covers every predecessor in its `previous` chain that is not revoked, so changes signed before a rotation remain authorized. Revocation is for compromised or abandoned keys: a revoked key authorizes nothing, whatever its chain. `expires` bounds the changes a key can authenticate: a signature or seal over a change whose `hlc.wall_ms` is later than the identity's `expires` is invalid. A client records the revocations it makes or verifies under `.oxy/revoked/` and reports signatures by such keys as valid but revoked. |
|
| 647 | 613 | ||
| 648 | 614 | ### 14.3 Composite signature (G2, G4) |
|
| 649 | 615 | ||
| … | 5 unchanged lines |
||
| 655 | 621 | ml [3]: bytes(3309) |
|
| 656 | 622 | } |
|
| 657 | 623 | message = "oxy1 sig " || domain || 0x00 || KeyId || ObjectRef-being-signed |
|
| 658 | - | domain = "change" | "attest" | "policy" | "certificate" | "grant" | "checkpoint" |
|
| 624 | + | domain = "change" | "attest" | "policy" |
|
| 659 | 625 | ``` |
|
| 660 | 626 | ||
| 661 | 627 | Both signatures are computed over the same `message`, which includes the `KeyId` of the composite pair. Verification MUST require both signatures to verify with the keys of the identity named by `key`. A signature with either half missing, empty, or failing is invalid. This is the same construction as the IETF LAMPS composite signature drafts and prevents stripping the post-quantum half. |
|
| … | 7 unchanged lines |
||
| 669 | 635 | ``` |
|
| 670 | 636 | Attest { |
|
| 671 | 637 | version [0]: 1 |
|
| 672 | - | kind [1]: "ref" | "tag" | "seal" | "rotate" | "revoke" | "lock" | "receipt" | "approval" | "accepted" |
|
| 638 | + | kind [1]: "ref" | "tag" | "seal" | "rotate" | "revoke" | "lock" | "receipt" |
|
| 673 | 639 | repo [2]: bytes(16) // repository ID, chosen at init |
|
| 674 | 640 | hlc [3]: HLC |
|
| 675 | 641 | body [4]: map // per kind |
|
| … | 5 unchanged lines |
||
| 681 | 647 | revoke: { key: KeyId, reason } |
|
| 682 | 648 | lock: { node: NodeId, path, acquire: bool, expires } |
|
| 683 | 649 | receipt: { receipt: ObjectRef, state: ObjectRef, run: text } // §14.6 |
|
| 684 | - | approval: { run: text, job: text, job_hash: ObjectRef, state: ObjectRef, |
|
| 685 | - | inputs: [ObjectRef], environment: text, approve: 0 | 1 } // sorted, unique inputs |
|
| 686 | - | accepted: { attest: ObjectRef } // §14.7 |
|
| 687 | 650 | ``` |
|
| 688 | 651 | ||
| 689 | - | An **approval** is a person's decision about one job of one OxygenFlow run (an approval gate, OxygenFlow spec §5.3.2): the job as hashed with its sandbox, on that state, taking exactly those input blobs (for a deploy, the reviewed plan), for that environment. It approves nothing else: another run, state, plan or environment needs another approval. It is handed to the hub as `[attest bytes, [Signature], identity bytes]`, signed in domain `attest` by the approver's own key (`oxy seal --flow URL --job NAME`, `--reject` to reject), so it verifies with nothing else; whether the signer may approve is the hub's rule (a key linked to an account with the gate's role; delegated keys only where the repository allows). The hub keeps it in the repository, so it can be verified without the hub. |
|
| 690 | - | ||
| 691 | 652 | A **seal** is one signature over a batch of change hashes. A composite signature is 3373 bytes, which is larger than a typical change, so signing every change individually roughly doubles the size of history in high-frequency workflows. A seal replaces that with one signature per push. |
|
| 692 | 653 | ||
| 693 | 654 | **Cadence.** With the default `core.sign = "seal"`, changes are left unsigned locally and `oxy push` creates one seal covering every change in the push that carries no individual signature, immediately before the reference attestation. Seals are created only at push: a seal made earlier would be invalidated by any amend, since rewriting produces a new hash. With `core.sign = "change"`, every change is signed at commit and no seal is needed. Policy (§14.5) decides which the receiving repository accepts. |
|
| 694 | 655 | ||
| 695 | - | **Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate with the `seal` scope, not expired at `C.hlc.wall_ms` (§14.2). The same author-or-delegate rule applies to individual change signatures, with the `changes` scope. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A change whose `author` is the zero KeyId names nobody (history imported from Git, §21, or made before an identity existed), so a seal from any key authenticates it: the sealer **adopts** it, and policy authorizes the sealer for the reference as usual. A push seals the pusher's own unauthenticated changes and adopts anonymous ones in the same seal; a pusher that signs for its configured author (`author.signer`: a delegate, or a short-lived key under the author's grant, §14.7) seals the author's as well, and the receiver checks it may. A seal's signer must additionally be authorized by policy for the reference being pushed. |
|
| 656 | + | **Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate with the `seal` scope, not expired at `C.hlc.wall_ms` (§14.2). The same author-or-delegate rule applies to individual change signatures, with the `changes` scope. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A change whose `author` is the zero KeyId names nobody (history imported from Git, §21, or made before an identity existed), so a seal from any key authenticates it: the sealer **adopts** it, and policy authorizes the sealer for the reference as usual. A push seals the pusher's own unauthenticated changes and adopts anonymous ones in the same seal. A seal's signer must additionally be authorized by policy for the reference being pushed. |
|
| 696 | 657 | ||
| 697 | 658 | **Handling.** A seal is stored and transported with the changes it covers: `fetch` (§16.1) streams the seals covering every change it returns, `push` (§16.4) MUST include a seal or signature for every change, and a seal covering any reachable change is itself reachable (§21). Seals from different keys MAY overlap; a change needs at least one valid authenticator, and policy thresholds count distinct authenticating keys. |
|
| 698 | 659 | ||
| … | 8 unchanged lines |
||
| 707 | 668 | serial [2]: u64 |
|
| 708 | 669 | roots [3]: { keys: [KeyId], threshold: u32 } |
|
| 709 | 670 | rules [4]: [ { pattern: glob over reference names, keys: [KeyId], threshold: u32, |
|
| 710 | - | require: "signed" | "sealed" | "any" /* default */, allow_conflicts: bool, |
|
| 711 | - | subjects [5]: [ { pattern: glob over subjects, issuer: KeyId, scope: [text] } ] /* only when any */ } ] |
|
| 671 | + | require: "signed" | "sealed" | "any" /* default */, allow_conflicts: bool } ] |
|
| 712 | 672 | identities[5]: [Identity] // embedded so verification needs no fetch |
|
| 713 | 673 | revoked [6]: [KeyId] |
|
| 714 | 674 | expires [7]: u64 |
|
| … | 2 unchanged lines |
||
| 717 | 677 | ||
| 718 | 678 | - 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. |
|
| 719 | 679 | - 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. |
|
| 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. |
|
| 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. |
|
| 680 | + | - 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). 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. |
|
| 722 | 681 | - 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`. |
|
| 723 | 682 | - 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. |
|
| 724 | 683 | - 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. |
|
| … | 21 unchanged lines |
||
| 746 | 705 | ||
| 747 | 706 | A receipt names no state: the same job on the same scope in the same environment gives the same receipt, which is what lets a runner reuse it for a later state whose scope is unchanged. An attestation of kind `receipt` (§14.4) binds a receipt to the `state` it ran on and names the `run` (usually its URL). The runner signs that attestation with its own identity (§14.2), an ordinary composite key, in domain `attest`. |
|
| 748 | 707 | ||
| 749 | - | **Signed receipt.** A runner hands a receipt over as `[receipt bytes, attest bytes, [Signature], identity bytes]`, so it verifies with nothing else: the attestation's `receipt` is the receipt's ObjectRef, and a signature by the included identity verifies over the attestation. Whether a runner is trusted is the reader's decision (a host lists its runners' keys); a revoked runner key is reported. A runner that signs with a short-lived key (§14.7) adds a fifth element, `[certificate bytes, issuer identity bytes]`: the receipt then verifies only if the certificate does (signed by the identity given, which its `issuer` names), certifies the key that signed, gives the `seal` scope and covers the attestation's time; a host checks the time it accepted the receipt as well, since the attestation's is the signer's claim. A repository keeps the certificate (type `certificate`, with its issuer's signature, indexed by the key it certifies) and the issuer's identity beside the receipt, and gc keeps them with it. |
|
| 708 | + | **Signed receipt.** A runner hands a receipt over as `[receipt bytes, attest bytes, [Signature], identity bytes]`, so it verifies with nothing else: the attestation's `receipt` is the receipt's ObjectRef, and a signature by the included identity verifies over the attestation. Whether a runner is trusted is the reader's decision (a host lists its runners' keys); a revoked runner key is reported. |
|
| 750 | 709 | ||
| 751 | 710 | **Storage.** A repository keeps receipts beside the state they name: the receipt, the attestation with its signatures, and the runner's identity, indexed by state ObjectRef. Receipts live as long as the changes of their state, and keep their outputs and inputs alive (§21). `oxy receipt list|show|export|verify|import` reads and exchanges them; a receipt for another repository is refused. |
|
| 752 | - | ||
| 753 | - | **SLSA provenance.** For tools outside Oxygen, `oxy receipt export --format slsa` writes what a valid receipt says as an in-toto Statement v1 with an SLSA provenance v1 predicate (`buildType` `https://oxygenhub.io/oxygenflow/job/v1`), in a DSSE envelope (payload type `application/vnd.in-toto+json`). Its subjects are the job's outputs, with the digest `oxygen-blob` (their ObjectRefs); a job with none has the state as its subject (`oxygen-state`). Resolved dependencies are the state, the scope (`oxygen-scope`) and the inputs; the builder is `urn:oxygen:runner:<runner KeyId>`; `startedOn` is the attestation's wall time less `duration_ms`, `finishedOn` that time. The signed receipt itself is a byproduct (`oxygen-receipt`, media type `application/vnd.oxygen.receipt+cbor`, base64 `content`), so an Oxygen verifier can still check the runner's signature. The envelope is signed by the **exporter**, with the Ed25519 half of its signing key over DSSE's pre-authentication encoding (which cannot be mistaken for an Oxygen signature message, §14.3); `keyid` is the KeyId in hex. DSSE verifiers know no ML-DSA, so this signature is classical: it vouches that the exporter checked the receipt, and the receipt inside keeps the runner's composite signature. `oxy identity export --pem` writes the signing key's Ed25519 public key as a PEM SubjectPublicKeyInfo, as cosign's `--key` takes it. A host does the same with its own key for the artifacts it keeps (hub spec). |
|
| 754 | - | ||
| 755 | - | ### 14.7 Certificates for short-lived keys |
|
| 756 | - | ||
| 757 | - | An issuer (a hub's key) may vouch for a fresh key for a few minutes, so a Flow job or an agent signs with a key that dies with it instead of one kept on disk (oxygenhub#241, oxygenhub docs/keyless.md): |
|
| 758 | - | ||
| 759 | - | ``` |
|
| 760 | - | Certificate { |
|
| 761 | - | version [0]: 1 |
|
| 762 | - | key [1]: bytes // the short-lived key's identity object (§14.2) |
|
| 763 | - | issuer [2]: KeyId // the key that signs this certificate |
|
| 764 | - | subject [3]: text // who proved itself: "repo:OWNER/NAME:bookmark:B", "agent:NAME@ACCOUNT" |
|
| 765 | - | claims [4]: [[text, text]] // what else the proof said, sorted by name |
|
| 766 | - | scope [5]: [text] // changes, refs, seal |
|
| 767 | - | not_before [6]: u64 // wall milliseconds |
|
| 768 | - | expires [7]: u64 |
|
| 769 | - | grant [8]: bytes // optional: a signed grant (below), only when present |
|
| 770 | - | } |
|
| 771 | - | ``` |
|
| 772 | - | ||
| 773 | - | A signed certificate is `[certificate bytes, Signature]`: the issuer's composite signature, in domain `certificate` (§14.3), over the certificate's ObjectRef (type `certificate`). It is valid when the signature verifies with the identity its `issuer` names, `expires` is after `not_before` and at most an hour later, and its scope is not empty. A signature by the short-lived key names the key's KeyId as any signature does; it counts only within the certificate's scope and window, and the time it is checked at must be one the signer did not choose (a host's record of when it accepted the signature, or the transparency log's), never the signed object's own clock. Which issuers a repository trusts, and for which subjects, its trust policy says (§14.5). |
|
| 774 | - | ||
| 775 | - | **Grants.** An author lets the keys an issuer certifies for a subject act for it, as a delegate does but without a key of their own (an agent's fresh keys, oxygenhub docs/keyless.md): |
|
| 776 | - | ||
| 777 | - | ``` |
|
| 778 | - | Grant { |
|
| 779 | - | version [0]: 1 |
|
| 780 | - | grantor [1]: KeyId // the author, whose key signs the grant |
|
| 781 | - | subject [2]: text // "agent:NAME@ACCOUNT" |
|
| 782 | - | issuer [3]: KeyId // whose certificates for the subject count |
|
| 783 | - | scope [4]: [text] // what certificates under it may give: changes, seal, refs |
|
| 784 | - | not_before [5]: u64 // wall milliseconds |
|
| 785 | - | expires [6]: u64 |
|
| 786 | - | } |
|
| 787 | - | ``` |
|
| 788 | - | ||
| 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. |
|
| 790 | - | ||
| 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'. |
|
| 792 | - | ||
| 793 | - | ### 14.8 The transparency log |
|
| 794 | - | ||
| 795 | - | A host keeps an append-only log of what it accepts, so it cannot show one history to some readers and another to others (oxygenhub#238, oxygenhub docs/transparency.md). Each accepted signed object (a reference or tag attestation, a seal, a rotation or revocation, a receipt, an approval, a policy, an identity, a certificate, an acceptance) becomes one entry, in the order the host accepted it: |
|
| 796 | 711 | ||
| 797 | - | ``` |
|
| 798 | - | Entry { |
|
| 799 | - | version [0]: 1 |
|
| 800 | - | kind [1]: text // "ref", "seal", "tag", "rotate", "revoke", "receipt", "approval", |
|
| 801 | - | // "policy", "identity", "certificate", "accepted" |
|
| 802 | - | repo [2]: bytes(16) // the repository's id; zero for identities |
|
| 803 | - | object [3]: ObjectRef // the attestation, policy, identity or certificate |
|
| 804 | - | signers [4]: [KeyId] // the keys whose signatures the host checked |
|
| 805 | - | received [5]: u64 // the host's clock, milliseconds (its claim) |
|
| 806 | - | } |
|
| 807 | - | ``` |
|
| 808 | - | ||
| 809 | - | **The tree** is RFC 6962's Merkle tree over the entries, with BLAKE3: a leaf's hash is `BLAKE3("oxygen-tlog-leaf\0" || cbor(Entry))`, an interior node's `BLAKE3("oxygen-tlog-node\0" || left || right)`, and the empty tree's root `BLAKE3("")`. A tree of n > 1 leaves splits at k, the largest power of two below n. Inclusion and consistency proofs are RFC 6962's (§2.1.1, §2.1.2), their hashes listed from the leaf upwards. |
|
| 810 | - | ||
| 811 | - | **Stored order.** Every hash of the tree is stored once, as soon as it is known: adding leaf n stores its hash and then the hash of each subtree it completes (one per trailing 1 bit of n). The hash of tree level L at position n is therefore at stored index `i(L, n)`, where n is first replaced L times by 2n+1, then i = n + ⌊n/2⌋ + ⌊n/4⌋ + … + L. The store is append-only. |
|
| 812 | - | ||
| 813 | - | **Tiles** (C2SP tlog-tiles, height 8). Level-L tile N holds the hashes of tree level 8L for leaves from N·256 on, 32 bytes each: 256 of them when full, fewer in a partial tile. Its path is `tile/<L>/<N>`, with `.p/<W>` appended for a partial tile of W hashes; N is written in groups of three digits, every group but the last prefixed `x` (1234067 is `x001/x234/067`). The entries of level-0 tile N are at `tile/entries/<N>[.p/<W>]`, each as a big-endian u16 length and its bytes. Full tiles never change. |
|
| 814 | - | ||
| 815 | - | **Checkpoints** (C2SP tlog-checkpoint) are C2SP signed notes. The text is the origin (the host's name and `/log`), the tree size in decimal and the root in base64, each line ending in a newline. It is signed twice under the origin's name: |
|
| 816 | - | - with the host key's Ed25519 half, as a standard note signature (type 0x01: key hash = SHA-256(name ‖ "\n" ‖ 0x01 ‖ public key)[:4], signature over the text), which note tools read; |
|
| 817 | - | - with the composite key, as type 0x4f (key hash over the identity object's bytes): the CBOR of a composite Signature in domain `checkpoint` over `BLAKE3("oxygen-tlog-note\0" || text)`. |
|
| 818 | - | ||
| 819 | - | A reader accepts a checkpoint only when both verify, so the log stays post-quantum. Which witnesses must cosign it is oxygenhub docs/transparency.md's. |
|
| 820 | - | ||
| 821 | - | **Witnesses** (C2SP tlog-witness) cosign a log's checkpoints with their own keys and clocks, so a log cannot show two histories without someone seeing both. A log sends a witness `POST add-checkpoint`: `old <size>` (of the last checkpoint that witness cosigned), the consistency proof's hashes in base64 one per line, a blank line, then the checkpoint note. The witness: |
|
| 822 | - | - answers 409 with its own size when `old` is not it; |
|
| 823 | - | - answers 403 when the note does not verify with the log's key; |
|
| 824 | - | - answers 422 when the checkpoint does not extend the one it cosigned; |
|
| 825 | - | - otherwise remembers the new size and root, and returns its cosignature lines, which the log adds to the checkpoint it serves. |
|
| 826 | - | ||
| 827 | - | A cosignature is two lines under the witness's name: |
|
| 828 | - | - C2SP cosignature/v1 (type 0x04): the witness's time in seconds as 8 big-endian bytes, then an Ed25519 signature over `cosignature/v1\ntime <seconds>\n` followed by the checkpoint's text; |
|
| 829 | - | - a composite line (type 0x4e): the same time, then the CBOR of a composite Signature in domain `checkpoint` over `BLAKE3("oxygen-tlog-cosig\0" || that message)`. |
|
| 830 | - | ||
| 831 | - | A reader counts a cosignature only when both lines verify and agree on the time. |
|
| 832 | - | ||
| 833 | - | **On fetch**, when a refs response carries a checkpoint (§16), a client: |
|
| 834 | - | - pins the host's log key on first sight (the hub's published identity), per user and hub, with the checkpoint; |
|
| 835 | - | - requires every later checkpoint to verify with that key, keep its origin, and extend the last one seen: a smaller tree, the same size with another root, or a consistency proof that fails (computed from the host's tiles) means the host showed two histories, and the fetch fails; |
|
| 836 | - | - checks each bookmark's attestation against its entry (kind `ref`, the attestation's ObjectRef and repository id) and inclusion proof, reporting one that is missing or does not verify. While logs are new this is a warning, not a refusal. |
|
| 837 | - | ||
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 843 | - | ||
| 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). |
|
| 845 | - | ||
| 846 | 712 | ## 15. Transport |
|
| 847 | 713 | ||
| 848 | 714 | Every transport carries the same wire protocol (§16). The transport provides the channel; integrity and authenticity of history come from the object model regardless of channel. |
|
| … | 51 unchanged lines |
||
| 900 | 766 | | Message | Encoding | |
|
| 901 | 767 | |---|---| |
|
| 902 | 768 | | Signed attestation | `[attest bytes, [Signature]]` | |
|
| 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 | |
|
| 769 | + | | `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; key 3 (optional) holds the policies between the receiver's serial and the one sent with a push, ascending | |
|
| 904 | 770 | | `capabilities` response | `{0: [versions], 1: repo id bytes(16), 2: [feature names]}` | |
|
| 905 | 771 | | Signed policy | `[policy bytes, [Signature]]` | |
|
| 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 | |
|
| 772 | + | | `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 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; a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` | |
|
| 907 | 773 | | `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order | |
|
| 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) | |
|
| 774 | + | | `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size | |
|
| 909 | 775 | | `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its 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` | |
|
| 776 | + | | `checkpoint` request | `{0: [heads]}`; response a `Bundle` of objects: the checkpoint of exactly those heads, its payload blob, and the blob's chunks | |
|
| 912 | 777 | | `shallow` request | `{0: [heads], 1: depth}`; response `{0: [hashes]}`, the changes within `depth` steps of the heads, dependencies first | |
|
| 913 | 778 | | `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 | |
|
| 914 | 779 | ||
| … | 3 unchanged lines |
||
| 918 | 783 | ||
| 919 | 784 | 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. |
|
| 920 | 785 | ||
| 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. |
|
| 786 | + | **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. |
|
| 922 | 787 | ||
| 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. |
|
| 924 | - | ||
| 925 | 788 | **Deleting a reference.** An update whose `new_state` is the zero ObjectRef deletes the bookmark (`oxy push REMOTE --delete NAME`). The bookmark MUST exist at `old_state`. A server MUST refuse to delete its default bookmark: in a bare repository that is the host's to name (the reference server's is the repository's configured bookmark, a forge's its default-bookmark setting), and in a non-bare repository it is the checked-out bookmark. The update is attested like any other: under a policy the attestation is required and its key MUST be authorized for the reference. The deletion's attestation stays the reference's latest, so an older attestation cannot be replayed to bring the bookmark back. The first attestation of a reference the remote does not list takes the client's clock in milliseconds as its serial, which is past the serial of any deleted namesake; later ones increment it. Changes of a deleted bookmark stay until unreachable (§21). |
|
| 926 | 789 | ||
| 927 | 790 | ## 17. Partial, sparse, and shallow repositories |
|
| … | 9 unchanged lines |
||
| 937 | 800 | ||
| 938 | 801 | - **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. |
|
| 939 | 802 | - **Lazy blobs** are fetched via `objects` on first read. `oxy status` and `oxy commit` MUST NOT need them. |
|
| 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. |
|
| 803 | + | - **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. |
|
| 941 | 804 | - Thin clients (§22) always use `depth = 0`: a checkpoint plus zero history. |
|
| 942 | 805 | ||
| 943 | 806 | Servers MUST honour `filter` on `negotiate`, `fetch`, and `checkpoint`. |
|
| … | 12 unchanged lines |
||
| 956 | 819 | | File | Purpose | |
|
| 957 | 820 | |---|---| |
|
| 958 | 821 | | `.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. | |
|
| 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. | |
|
| 822 | + | | `.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. | |
|
| 960 | 823 | | `.oxy/config` | Repository-local settings, TOML. | |
|
| 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. | |
|
| 824 | + | | `~/.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; also `$OXY_SIGNER`), `core.sign`, and `remote.<name>.token`; also `auth.helper`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. | |
|
| 962 | 825 | | `~/.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. | |
|
| 963 | 826 | ||
| 964 | 827 | 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. |
|
| … | 10 unchanged lines |
||
| 975 | 838 | ||
| 976 | 839 | ## 21. Garbage collection |
|
| 977 | 840 | ||
| 978 | - | An object is **reachable** if it is referenced, directly or transitively, from any bookmark, tag, policy, the current operation heads, any operation newer than `core.op-retention`, any workspace's current working-copy change, the working-copy changes of operations newer than `core.snapshot-retention` (default 7 days; `oxy gc --expire-snapshots` keeps none but the current ones, oxygen#98), or any checkpoint of a reachable state. A file that was only ever in a working copy, never committed, so goes once the snapshots that held it expire. `oxy gc` first deletes the packs and loose objects earlier repacks retired over an hour ago (§13.4), then deletes unreachable objects that are older than `core.gc-grace` (default 14 days, to protect concurrent writers) and repacks; what this repack replaces is retired in turn, for a later collection. On a store that is not this disk's directory it then folds the signatures and index entries completely (§13.7). Tombstone atoms are never collected independently of their change. Divergent changes are reachable if any of their variants is. A change's text blobs and their chunks are reachable with it (§7.4). A seal is reachable if any change it covers is reachable, and reference attestations are reachable while they are the latest for their reference or are within `core.op-retention`. |
|
| 841 | + | An object is **reachable** if it is referenced, directly or transitively, from any bookmark, tag, policy, the current operation heads, any operation newer than `core.op-retention`, any workspace's current working-copy change, the working-copy changes of operations newer than `core.snapshot-retention` (default 7 days; `oxy gc --expire-snapshots` keeps none but the current ones, oxygen#98), or any checkpoint of a reachable state. A file that was only ever in a working copy, never committed, so goes once the snapshots that held it expire. `oxy gc` deletes unreachable objects that are older than `core.gc-grace` (default 14 days, to protect concurrent writers) and repacks. Tombstone atoms are never collected independently of their change. Divergent changes are reachable if any of their variants is. A change's text blobs and their chunks are reachable with it (§7.4). A seal is reachable if any change it covers is reachable, and reference attestations are reachable while they are the latest for their reference or are within `core.op-retention`. |
|
| 979 | 842 | ||
| 980 | 843 | ### 21.1 Integrity check |
|
| 981 | 844 | ||
| … | 1 unchanged lines |
||
| 983 | 846 | ||
| 984 | 847 | ### 21.2 Doctor |
|
| 985 | 848 | ||
| 986 | - | `oxy doctor` checks what commonly stands between a user and a working repository and says, for each problem, what to do next: the operation log opens and no lock was left by a dead process; the signing identity exists, its secret key file is present, and it has not expired (a warning within 30 days); the author name and email are not the defaults; the trust policy (§14.5) loads and has not expired (a warning within 30 days), and its absence is a warning, since nothing fetched is then verified, with the steps by which the repository's owner sets one (oxygen#127); and for each remote, that plain `http://` is only used on loopback, a token is set, the CA file exists, and (unless `--offline`) the remote answers and is this repository or an unbound one. Failures of remote requests anywhere in `oxy` carry the same next step (an untrusted certificate, a rejected or insufficient token, no repository at the URL, nothing listening, an unknown host, a timeout). The exit status is 1 when a check fails; `--json` gives the checks. |
|
| 849 | + | `oxy doctor` checks what commonly stands between a user and a working repository and says, for each problem, what to do next: the operation log opens and no lock was left by a dead process; the signing identity exists, its secret key file is present, and it has not expired (a warning within 30 days); the author name and email are not the defaults; and for each remote, that plain `http://` is only used on loopback, a token is set, the CA file exists, and (unless `--offline`) the remote answers and is this repository or an unbound one. Failures of remote requests anywhere in `oxy` carry the same next step (an untrusted certificate, a rejected or insufficient token, no repository at the URL, nothing listening, an unknown host, a timeout). The exit status is 1 when a check fails; `--json` gives the checks. |
|
| 987 | 850 | ||
| 988 | 851 | ## 22. Client profiles and retro hardware |
|
| 989 | 852 | ||
| … | 23 unchanged lines |
||
| 1013 | 876 | - **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. |
|
| 1014 | 877 | - **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. |
|
| 1015 | 878 | - **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. |
|
| 1018 | 879 | - **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. |
|
| 1019 | 880 | ||
| 1020 | 881 | ## 24. Command-line interface |
|
| … | 4 unchanged lines |
||
| 1025 | 886 | oxy init [PATH] [--bare] oxy clone URL [--sparse PATH].. [--depth N] [--max-blob SIZE] |
|
| 1026 | 887 | oxy status [--json] oxy diff oxy prompt [--format F] |
|
| 1027 | 888 | oxy commit -m MSG [--depends-on CHANGE].. |
|
| 1028 | - | oxy log [-r REVSET] [-n N] [-v] [-L S,E:PATH] oxy show CHANGE | -r REVSET [--stat] |
|
| 1029 | - | oxy describe [CHANGE] [-m MSG] oxy amend [-m MSG] [--into CHANGE | PATH..] |
|
| 1030 | - | oxy split CHANGE [PATH].. oxy abandon CHANGE | -r REVSET [--yes] oxy resolve [--both [--reverse]] [PATH].. |
|
| 889 | + | oxy log [-r REVSET] [-n N] [-v] [-L S,E:PATH] oxy show CHANGE | -r REVSET |
|
| 890 | + | oxy describe [CHANGE] [-m MSG] oxy amend [-m MSG] [--into CHANGE] |
|
| 891 | + | oxy split CHANGE [PATH].. oxy abandon CHANGE | -r REVSET [--yes] oxy resolve |
|
| 1031 | 892 | oxy checkout [CHANGE].. [--discard|--carry] oxy undo oxy op log |
|
| 1032 | 893 | oxy tag create NAME [CHANGE].. [-m MSG] oxy tag list |
|
| 1033 | 894 | oxy bookmark create|set|delete|list|switch oxy merge BOOKMARK |
|
| … | 13 unchanged lines |
||
| 1047 | 908 | oxy doctor [--offline] [--json] check identity, config and remotes (§21.2) |
|
| 1048 | 909 | oxy completions SHELL shell completion script oxy man [--dir DIR] man pages |
|
| 1049 | 910 | oxy hunks oxy discard PATH.. | --hunks N,M |
|
| 1050 | - | oxy mcp [DIR] [--allow-destructive] serve the repository to an AI agent (MCP, stdio) |
|
| 1051 | 911 | ``` |
|
| 1052 | 912 | ||
| 1053 | 913 | **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/`. |
|
| 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 | 914 | ||
| 1057 | 915 | 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. |
|
| 1058 | 916 | ||
| … | 50 unchanged lines |
||
| 1109 | 967 | | HTTPS transport and endpoints (§16) | Implemented, HTTP only; Noise and ZCT (§15) reserved; negotiation is exact (no Merkle buckets); `refs` takes no `patterns` | |
|
| 1110 | 968 | | Partial, sparse, shallow (§17) | Implemented | |
|
| 1111 | 969 | | Locks (§18) | Local advisory locks only; server locks and the `lock` endpoint reserved | |
|
| 1112 | - | | Configuration, attributes, hooks (§19, §20) | Implemented (`.oxyattributes`: binary, text, eol, nocompress, lockable, merge=union) | |
|
| 970 | + | | Configuration, attributes, hooks (§19, §20) | Implemented (`.oxyattributes`: binary, text, eol, nocompress, lockable) | |
|
| 1113 | 971 | | Thin profile and proxy (§22) | Reserved | |
|
| 1114 | 972 | | Git interoperability (§23) | Implemented: import, export, fetch, push, rename detection | |
|
| 1115 | 973 | | 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 |
modified docs/strata.md +0 −27
| … | 140 unchanged lines |
||
| 141 | 141 | the store, so a commit takes about half a second to a second. |
|
| 142 | 142 | - **Choose at init:** set `storage.url` only when the repository is |
|
| 143 | 143 | created. Changing it later does not move the objects. |
|
| 144 | - | - **Signatures and seals:** each one is stored under a key of its own, |
|
| 145 | - | as in any repository. A bucket can only find those keys with a LIST |
|
| 146 | - | request, so the store also keeps a journal of them (`pack-entries`) |
|
| 147 | - | and lists of them (`pack-<id>.entries`). These are read when the |
|
| 148 | - | repository is opened and again every 30 seconds (spec §13.7). After |
|
| 149 | - | every client that writes to the bucket runs this version of oxy, run |
|
| 150 | - | `oxy gc` once. From then on, looking up a signature or a seal sends no |
|
| 151 | - | LIST request, and another client's new signature is seen within 30 |
|
| 152 | - | seconds. A client still on oxy 0.4.6 or older writes signatures that |
|
| 153 | - | the others miss until the next `oxy gc`, so upgrade every client |
|
| 154 | - | first. |
|
| 155 | - | ||
| 156 | - | A repository kept on a store holds: |
|
| 157 | - | ||
| 158 | - | ``` |
|
| 159 | - | PREFIX/ |
|
| 160 | - | pack-<id>.pack, .idx, .dict packs, their indexes and dictionaries |
|
| 161 | - | pack-retired what repacks and folds replaced, deleted an hour later |
|
| 162 | - | pack-entries journal: signatures and index entries since the last fold |
|
| 163 | - | pack-<id>.entries every signature and index entry, as the last gc found them |
|
| 164 | - | pack-<id>.entries-part journal lines folded out once there were more than 1000 |
|
| 165 | - | pack-legacy-folded the older one-list keys have been folded |
|
| 166 | - | loose/ nocompress/ objects not yet packed; chunks kept raw |
|
| 167 | - | sig/ seal/ receipt/ approval/ cert/ accepted/ |
|
| 168 | - | one key per signature or index entry |
|
| 169 | - | ops/head the operation head |
|
| 170 | - | ``` |
|
| 171 | 144 | ||
| 172 | 145 | ## What a store holds |
|
| 173 | 146 | ||
| … | 38 unchanged lines |
removed packaging/haikuports/README.md +0 −40
| 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. |
removed packaging/haikuports/dev-vcs/oh/oh-0.9.16.recipe +0 −1472
| 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 | - | } |
removed packaging/haikuports/dev-vcs/oxy/oxy-0.9.0.recipe +0 −990
| 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 | - | } |
removed packaging/haikuports/icons/oh-16.png +0 −0
No line changes.
removed packaging/haikuports/icons/oh-32.png +0 −0
No line changes.
removed packaging/haikuports/icons/oh-64.png +0 −0
No line changes.
removed packaging/haikuports/icons/oh.hvif +0 −0
No line changes.
removed packaging/haikuports/icons/oh.svg +0 −17
| 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> |
removed packaging/haikuports/icons/oxy-16.png +0 −0
No line changes.
removed packaging/haikuports/icons/oxy-32.png +0 −0
No line changes.
removed packaging/haikuports/icons/oxy-64.png +0 −0
No line changes.
removed packaging/haikuports/icons/oxy.hvif +0 −0
No line changes.
removed packaging/haikuports/icons/oxy.svg +0 −17
| 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> |
removed packaging/haikuports/make-icons.py +0 −163
| 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)) |
removed packaging/haikuports/make-recipe.py +0 −140
| 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)) |
removed scripts/changelog-check.sh +0 −27
| 1 | - | #!/bin/sh |
|
| 2 | - | # The changelog check (2026-10-19): a commit that changes what users get |
|
| 3 | - | # adds a line under "## Unreleased" in CHANGELOG.md, so a release's notes |
|
| 4 | - | # are written as the work is done and versions say what changed. |
|
| 5 | - | # |
|
| 6 | - | # scripts/changelog-check.sh [PATH...] |
|
| 7 | - | # |
|
| 8 | - | # PATHs are what the commit will take (as given to `oxy commit`); without |
|
| 9 | - | # any, the working copy's changes. Code is anything under src/, crates/, |
|
| 10 | - | # templates/, assets/, packaging/, images/ or scripts/, except tests. |
|
| 11 | - | # Docs, pipelines, pins (deps/) and lock files need no line. For a code |
|
| 12 | - | # change users won't notice (a refactor, a test helper), set NO_CHANGELOG=1. |
|
| 13 | - | set -eu |
|
| 14 | - | [ "${NO_CHANGELOG:-}" = 1 ] && exit 0 |
|
| 15 | - | cd "$(dirname "$0")/.." |
|
| 16 | - | if [ "$#" -gt 0 ]; then |
|
| 17 | - | paths=$(printf '%s\n' "$@") |
|
| 18 | - | else |
|
| 19 | - | paths=$(oxy --plain status --json | python3 -c 'import json,sys; print("\n".join(p["path"] for p in json.load(sys.stdin)["paths"]))') |
|
| 20 | - | fi |
|
| 21 | - | code=$(printf '%s\n' "$paths" | grep -E '^(src|crates|templates|assets|packaging|images|scripts)/' | grep -vE '(^|/)tests?/|_test\.rs$' || true) |
|
| 22 | - | [ -z "$code" ] && exit 0 |
|
| 23 | - | printf '%s\n' "$paths" | grep -qx 'CHANGELOG.md' && exit 0 |
|
| 24 | - | echo "changelog: these changes touch code but CHANGELOG.md has no new line under ## Unreleased:" >&2 |
|
| 25 | - | printf '%s\n' "$code" | sed 's/^/ /' >&2 |
|
| 26 | - | echo "add one (Added / Changed / Fixed / Security, with the issue number), or set NO_CHANGELOG=1 if users won't notice" >&2 |
|
| 27 | - | exit 1 |
modified scripts/dist.sh +0 −11
| … | 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 | - | ||
| 151 | 140 | rm -rf "$OUT/stage" |
|
| 152 | 141 | (cd "$OUT" && shopt -s nullglob && sha256sum -- *.tar.gz *.zip *.deb >SHA256SUMS) |
|
| 153 | 142 | # DIST_FLAT=DIR: the same files again under names without the version, for |
|
| … | 12 unchanged lines |
removed src/cli/approve.rs +0 −151
| 1 | - | //! `oxy seal --flow RUN_URL --job NAME [--reject]` (oxygenhub#235): sign an |
|
| 2 | - | //! approval of one job of one run with your key, so the hub can show, and |
|
| 3 | - | //! anyone can check, who released it. The hub says what the job is (its |
|
| 4 | - | //! hash with its sandbox, the state, the plan it takes, the environment); |
|
| 5 | - | //! the signature covers exactly that and nothing else. |
|
| 6 | - | ||
| 7 | - | use super::{open, ui}; |
|
| 8 | - | use anyhow::{anyhow, bail, Context, Result}; |
|
| 9 | - | use oxy_core::approval::{SignedApproval, Statement}; |
|
| 10 | - | use oxy_core::hash::ObjectRef; |
|
| 11 | - | use serde_json::Value; |
|
| 12 | - | ||
| 13 | - | /// `https://hub/OWNER/NAME/flow/N` to the hub's base, `OWNER/NAME`, N and |
|
| 14 | - | /// the repository's URL. |
|
| 15 | - | fn run_url(url: &str) -> Result<(String, String, i64, String)> { |
|
| 16 | - | let url = url.trim_end_matches('/'); |
|
| 17 | - | let (repo_url, n) = url |
|
| 18 | - | .rsplit_once("/flow/") |
|
| 19 | - | .ok_or_else(|| anyhow!("{url} is not a run URL (…/OWNER/NAME/flow/N)"))?; |
|
| 20 | - | let number: i64 = n |
|
| 21 | - | .parse() |
|
| 22 | - | .map_err(|_| anyhow!("{url} is not a run URL (…/OWNER/NAME/flow/N)"))?; |
|
| 23 | - | let (rest, name) = repo_url |
|
| 24 | - | .rsplit_once('/') |
|
| 25 | - | .ok_or_else(|| anyhow!("{url} is not a run URL"))?; |
|
| 26 | - | let (base, owner) = rest |
|
| 27 | - | .rsplit_once('/') |
|
| 28 | - | .ok_or_else(|| anyhow!("{url} is not a run URL"))?; |
|
| 29 | - | Ok(( |
|
| 30 | - | base.to_string(), |
|
| 31 | - | format!("{owner}/{name}"), |
|
| 32 | - | number, |
|
| 33 | - | repo_url.to_string(), |
|
| 34 | - | )) |
|
| 35 | - | } |
|
| 36 | - | ||
| 37 | - | fn to_hex(b: &[u8]) -> String { |
|
| 38 | - | b.iter().map(|x| format!("{x:02x}")).collect() |
|
| 39 | - | } |
|
| 40 | - | ||
| 41 | - | fn oref(v: &Value, key: &str) -> Result<ObjectRef> { |
|
| 42 | - | v.get(key) |
|
| 43 | - | .and_then(Value::as_str) |
|
| 44 | - | .and_then(ObjectRef::from_hex) |
|
| 45 | - | .ok_or_else(|| anyhow!("the hub's statement has no valid {key}")) |
|
| 46 | - | } |
|
| 47 | - | ||
| 48 | - | pub fn seal(url: &str, job: &str, reject: bool, as_json: bool) -> Result<i32> { |
|
| 49 | - | let repo = open()?; |
|
| 50 | - | let (base, slug, number, repo_url) = run_url(url)?; |
|
| 51 | - | let options = repo.options_for_url(&repo_url); |
|
| 52 | - | let api = format!("{base}/api/v1/repos/{slug}/flow/runs/{number}/jobs/{job}/approval"); |
|
| 53 | - | let body = oxy_core::protocol::http_get(&api, &options).map_err(|e| anyhow!("{e}"))?; |
|
| 54 | - | let v: Value = serde_json::from_slice(&body).context("the hub did not answer with JSON")?; |
|
| 55 | - | let repo_hex = v.get("repo").and_then(Value::as_str).unwrap_or(""); |
|
| 56 | - | let mine = to_hex(&repo.id()?); |
|
| 57 | - | if repo_hex != mine { |
|
| 58 | - | bail!("run #{number} belongs to repository {repo_hex}, not this checkout's ({mine}): run oxy seal --flow from a checkout of {slug}"); |
|
| 59 | - | } |
|
| 60 | - | let mut repo_id = [0u8; 16]; |
|
| 61 | - | for (i, b) in repo_id.iter_mut().enumerate() { |
|
| 62 | - | *b = u8::from_str_radix(&repo_hex[i * 2..i * 2 + 2], 16)?; |
|
| 63 | - | } |
|
| 64 | - | let inputs = v |
|
| 65 | - | .get("inputs") |
|
| 66 | - | .and_then(Value::as_array) |
|
| 67 | - | .map(|a| { |
|
| 68 | - | a.iter() |
|
| 69 | - | .filter_map(|x| x.as_str().and_then(ObjectRef::from_hex)) |
|
| 70 | - | .collect::<Vec<_>>() |
|
| 71 | - | }) |
|
| 72 | - | .unwrap_or_default(); |
|
| 73 | - | let statement = Statement { |
|
| 74 | - | repo: repo_id, |
|
| 75 | - | run: v |
|
| 76 | - | .get("run") |
|
| 77 | - | .and_then(Value::as_str) |
|
| 78 | - | .unwrap_or(url) |
|
| 79 | - | .to_string(), |
|
| 80 | - | job: job.to_string(), |
|
| 81 | - | job_hash: oref(&v, "job_hash")?, |
|
| 82 | - | state: oref(&v, "state")?, |
|
| 83 | - | inputs, |
|
| 84 | - | environment: v |
|
| 85 | - | .get("environment") |
|
| 86 | - | .and_then(Value::as_str) |
|
| 87 | - | .unwrap_or("") |
|
| 88 | - | .to_string(), |
|
| 89 | - | approve: !reject, |
|
| 90 | - | }; |
|
| 91 | - | let (key, sk) = repo |
|
| 92 | - | .secret_key()? |
|
| 93 | - | .ok_or_else(|| anyhow!("no signing key: create one with `oxy identity new`"))?; |
|
| 94 | - | let me = repo.identity(&key)?; |
|
| 95 | - | let signed = SignedApproval::sign(&statement, &me, &sk); |
|
| 96 | - | let post = format!("{base}/api/v1/repos/{slug}/flow/runs/{number}/jobs/{job}/seal"); |
|
| 97 | - | let payload = serde_json::json!({ "signed": to_hex(&signed.encode()) }).to_string(); |
|
| 98 | - | let (code, answer) = oxy_core::protocol::http_json("POST", &post, Some(&payload), &options) |
|
| 99 | - | .map_err(|e| anyhow!("{e}"))?; |
|
| 100 | - | if !(200..300).contains(&code) { |
|
| 101 | - | let why = serde_json::from_str::<Value>(&answer) |
|
| 102 | - | .ok() |
|
| 103 | - | .and_then(|v| v.get("error").and_then(Value::as_str).map(String::from)); |
|
| 104 | - | bail!( |
|
| 105 | - | "the hub refused the seal (HTTP {code}): {}", |
|
| 106 | - | why.unwrap_or(answer) |
|
| 107 | - | ); |
|
| 108 | - | } |
|
| 109 | - | if as_json { |
|
| 110 | - | println!("{answer}"); |
|
| 111 | - | } else { |
|
| 112 | - | let verb = if reject { "Rejected" } else { "Approved" }; |
|
| 113 | - | println!( |
|
| 114 | - | "{} {job} of run #{number} with key {}", |
|
| 115 | - | ui::done(verb), |
|
| 116 | - | &key.to_hex()[..12] |
|
| 117 | - | ); |
|
| 118 | - | println!( |
|
| 119 | - | " state {} · job {}", |
|
| 120 | - | &statement.state.to_hex()[..12], |
|
| 121 | - | &statement.job_hash.to_hex()[..12] |
|
| 122 | - | ); |
|
| 123 | - | for i in &statement.inputs { |
|
| 124 | - | println!(" takes {}", &i.to_hex()[..12]); |
|
| 125 | - | } |
|
| 126 | - | if !statement.environment.is_empty() { |
|
| 127 | - | println!(" for environment {}", statement.environment); |
|
| 128 | - | } |
|
| 129 | - | } |
|
| 130 | - | Ok(0) |
|
| 131 | - | } |
|
| 132 | - | ||
| 133 | - | #[cfg(test)] |
|
| 134 | - | mod tests { |
|
| 135 | - | #[test] |
|
| 136 | - | fn run_urls_are_read() { |
|
| 137 | - | let (base, slug, n, repo) = |
|
| 138 | - | super::run_url("https://oxygenhub.io/andymccall/oxygenhub-gcp/flow/8/").unwrap(); |
|
| 139 | - | assert_eq!( |
|
| 140 | - | (base.as_str(), slug.as_str(), n, repo.as_str()), |
|
| 141 | - | ( |
|
| 142 | - | "https://oxygenhub.io", |
|
| 143 | - | "andymccall/oxygenhub-gcp", |
|
| 144 | - | 8, |
|
| 145 | - | "https://oxygenhub.io/andymccall/oxygenhub-gcp" |
|
| 146 | - | ) |
|
| 147 | - | ); |
|
| 148 | - | assert!(super::run_url("https://oxygenhub.io/andymccall/x/proposal/3").is_err()); |
|
| 149 | - | assert!(super::run_url("https://oxygenhub.io/andymccall/x/flow/abc").is_err()); |
|
| 150 | - | } |
|
| 151 | - | } |
removed src/cli/artifact.rs +0 −180
| 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/auth.rs +6 −42
| … | 169 unchanged lines |
||
| 170 | 170 | let anonymous = RemoteOptions { |
|
| 171 | 171 | token: None, |
|
| 172 | 172 | ca: ca.clone(), |
|
| 173 | - | ..RemoteOptions::default() |
|
| 173 | + | repo_id: None, |
|
| 174 | + | cache_dir: None, |
|
| 174 | 175 | }; |
|
| 175 | 176 | let token = if with_token { |
|
| 176 | 177 | let mut line = String::new(); |
|
| … | 31 unchanged lines |
||
| 208 | 209 | " {} {}", |
|
| 209 | 210 | ui::note(), |
|
| 210 | 211 | ui::dim(format!("token in {}", where_stored(&stored))) |
|
| 211 | - | ); |
|
| 212 | - | Ok(0) |
|
| 213 | - | } |
|
| 214 | - | ||
| 215 | - | /// `oxy auth grant`: keep an agent's grant token (oxygenhub#241) for |
|
| 216 | - | /// keyless signing, read from standard input. |
|
| 217 | - | pub fn grant(hub: Option<&str>) -> Result<i32> { |
|
| 218 | - | use std::io::IsTerminal; |
|
| 219 | - | let hub = resolve_hub(hub)?; |
|
| 220 | - | if !token_allowed(&hub) { |
|
| 221 | - | bail!("{hub} is plain http: a token would cross the network unencrypted; use its https address"); |
|
| 222 | - | } |
|
| 223 | - | if std::io::stdin().is_terminal() { |
|
| 224 | - | eprint!("Agent grant token for {hub}: "); |
|
| 225 | - | } |
|
| 226 | - | let mut line = String::new(); |
|
| 227 | - | std::io::stdin() |
|
| 228 | - | .lock() |
|
| 229 | - | .read_line(&mut line) |
|
| 230 | - | .context("reading the token from standard input")?; |
|
| 231 | - | let token = line.trim(); |
|
| 232 | - | if !token.starts_with("oxyagent_") { |
|
| 233 | - | bail!("that is not an agent grant token (they start oxyagent_; `oh agent grant NAME` makes one)"); |
|
| 234 | - | } |
|
| 235 | - | let stored = Credentials::user() |
|
| 236 | - | .save_grant(&hub, token) |
|
| 237 | - | .map_err(|e| anyhow::anyhow!("{e}"))?; |
|
| 238 | - | println!( |
|
| 239 | - | "{} for {}", |
|
| 240 | - | ui::done("Kept the agent grant"), |
|
| 241 | - | ui::path(&hub) |
|
| 242 | - | ); |
|
| 243 | - | println!( |
|
| 244 | - | " {} {}", |
|
| 245 | - | ui::note(), |
|
| 246 | - | ui::dim(format!( |
|
| 247 | - | "token in {}; sign with it: oxy config author.signer keyless", |
|
| 248 | - | where_stored(&stored) |
|
| 249 | - | )) |
|
| 250 | 212 | ); |
|
| 251 | 213 | Ok(0) |
|
| 252 | 214 | } |
|
| … | 46 unchanged lines |
||
| 299 | 261 | let options = RemoteOptions { |
|
| 300 | 262 | token: Some(token.clone()), |
|
| 301 | 263 | ca: e.ca.clone(), |
|
| 302 | - | ..RemoteOptions::default() |
|
| 264 | + | repo_id: None, |
|
| 265 | + | cache_dir: None, |
|
| 303 | 266 | }; |
|
| 304 | 267 | let check = match whoami(&e.hub, &options) { |
|
| 305 | 268 | Ok(Ok(a)) => ui::ok(format!("valid, signed in as {a}")), |
|
| … | 46 unchanged lines |
||
| 352 | 315 | let options = RemoteOptions { |
|
| 353 | 316 | token: Some(t.clone()), |
|
| 354 | 317 | ca: entry.ca.clone(), |
|
| 355 | - | ..RemoteOptions::default() |
|
| 318 | + | repo_id: None, |
|
| 319 | + | cache_dir: None, |
|
| 356 | 320 | }; |
|
| 357 | 321 | matches!( |
|
| 358 | 322 | http_json( |
|
| … | 26 unchanged lines |
modified src/cli/format.rs +4 −29
| … | 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 | - | 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 | - | } |
|
| 45 | + | Ok(match repo.authenticate_change(h)? { |
|
| 54 | 46 | SignatureStatus::Unsigned => "unsigned".to_string(), |
|
| 55 | 47 | SignatureStatus::Valid { |
|
| 56 | 48 | key, |
|
| … | 65 unchanged lines |
||
| 122 | 114 | .identity(author) |
|
| 123 | 115 | .ok() |
|
| 124 | 116 | .and_then(|a| a.delegate(key).copied()); |
|
| 125 | - | // A short-lived key acting under the author's grant (§14.7) |
|
| 126 | - | // is no delegate, and not a revoked one either. |
|
| 127 | - | let certified = entry.is_none() && repo.certified_subject(key).is_some(); |
|
| 128 | 117 | Some(Via { |
|
| 129 | 118 | name: name.clone(), |
|
| 130 | 119 | email: email.clone(), |
|
| 131 | 120 | key: *key, |
|
| 132 | 121 | scope: entry.map(|d| d.scope.to_string()), |
|
| 133 | 122 | expires: entry.and_then(|d| d.expires), |
|
| 134 | - | revoked: *revoked || (entry.is_none() && !certified), |
|
| 123 | + | revoked: *revoked || entry.is_none(), |
|
| 135 | 124 | }) |
|
| 136 | 125 | } |
|
| 137 | 126 | _ => None, |
|
| 138 | 127 | } |
|
| 139 | 128 | } |
|
| 140 | 129 | ||
| 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 | - | ||
| 151 | 130 | /// A compact, styled authentication marker for log lines, from a status |
|
| 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 { |
|
| 131 | + | /// already in hand (the caller also reads who signed, see [`via`]). |
|
| 132 | + | pub fn signature_mark_of(status: &SignatureStatus) -> String { |
|
| 155 | 133 | match status { |
|
| 156 | - | SignatureStatus::Unsigned if awaiting => { |
|
| 157 | - | ui::dim(format!("{} awaiting seal", ui::glyph("◌", "o"))) |
|
| 158 | - | } |
|
| 159 | 134 | SignatureStatus::Unsigned => ui::dim(format!("{} unsigned", ui::glyph("·", "-"))), |
|
| 160 | 135 | SignatureStatus::Valid { |
|
| 161 | 136 | sealed, |
|
| … | 64 unchanged lines |
modified src/cli/identity.rs +8 −201
| … | 131 unchanged lines |
||
| 132 | 132 | ui::author(&r.identity.name), |
|
| 133 | 133 | r.identity.email |
|
| 134 | 134 | ); |
|
| 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 | - | ); |
|
| 135 | + | println!("{}", ui::kv("old key", ui::dim(r.old.to_hex()))); |
|
| 152 | 136 | println!("{}", ui::kv("new key", r.new.to_hex())); |
|
| 153 | 137 | print_delegates(repo, &r.identity.delegates); |
|
| 154 | 138 | if let Some(e) = r.identity.expires { |
|
| … | 16 unchanged lines |
||
| 171 | 155 | local: bool, |
|
| 172 | 156 | expires: Option<u64>, |
|
| 173 | 157 | ) -> Result<i32> { |
|
| 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 | - | }; |
|
| 158 | + | let mut repo = open()?; |
|
| 210 | 159 | let name = name.unwrap_or_else(|| repo.config.author_name.clone()); |
|
| 211 | 160 | let email = email.unwrap_or_else(|| repo.config.author_email.clone()); |
|
| 212 | 161 | let scope = if local { |
|
| … | 117 unchanged lines |
||
| 330 | 279 | pinned.short(12), |
|
| 331 | 280 | u.short(12), |
|
| 332 | 281 | pinned.to_hex() |
|
| 333 | - | )) |
|
| 334 | - | ); |
|
| 335 | - | } |
|
| 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 | 282 | )) |
|
| 364 | 283 | ); |
|
| 365 | - | break; |
|
| 366 | 284 | } |
|
| 367 | - | cur = repo.identity(&k).ok().and_then(|i| i.previous); |
|
| 368 | 285 | } |
|
| 369 | 286 | print_delegates(&repo, &id.delegates); |
|
| 370 | 287 | if repo.is_revoked(&key)? { |
|
| … | 117 unchanged lines |
||
| 488 | 405 | ); |
|
| 489 | 406 | } |
|
| 490 | 407 | Ok(if bad > 0 { 1 } else { 0 }) |
|
| 491 | - | } |
|
| 492 | - | ||
| 493 | - | /// Whether a configuration key holds a secret, never to be printed. |
|
| 494 | - | fn secret_key(key: &str) -> bool { |
|
| 495 | - | let k = key.to_ascii_lowercase(); |
|
| 496 | - | k.ends_with(".token") || k.contains("secret") || k.contains("password") |
|
| 497 | - | } |
|
| 498 | - | ||
| 499 | - | /// A secret as a confirmation may show it (oxygen#113): enough of its start |
|
| 500 | - | /// to tell which one it is, never the rest. |
|
| 501 | - | fn mask(value: &str) -> String { |
|
| 502 | - | let shown: String = value.chars().take(10).collect(); |
|
| 503 | - | let rest = value.chars().count().saturating_sub(10); |
|
| 504 | - | if rest == 0 { |
|
| 505 | - | "…".to_string() |
|
| 506 | - | } else { |
|
| 507 | - | format!("{shown}…({rest} more)") |
|
| 508 | - | } |
|
| 509 | 408 | } |
|
| 510 | 409 | ||
| 511 | 410 | pub fn config(key: &str, value: &str, user: bool, unset: bool) -> Result<i32> { |
|
| 512 | 411 | // An empty value removes the key, which is what --unset says plainly. |
|
| 513 | - | // `-` reads the value from standard input, so a token never sits in |
|
| 514 | - | // the shell's history (oxygen#113). |
|
| 515 | - | let read; |
|
| 516 | - | let value = if unset { |
|
| 517 | - | "" |
|
| 518 | - | } else if value == "-" { |
|
| 519 | - | let mut line = String::new(); |
|
| 520 | - | std::io::stdin().read_line(&mut line)?; |
|
| 521 | - | read = line.trim().to_string(); |
|
| 522 | - | read.as_str() |
|
| 523 | - | } else { |
|
| 524 | - | value |
|
| 525 | - | }; |
|
| 412 | + | let value = if unset { "" } else { value }; |
|
| 526 | 413 | let said = if unset { |
|
| 527 | 414 | format!("{} {key}", ui::done("Unset")) |
|
| 528 | - | } else if secret_key(key) { |
|
| 529 | - | format!("{} {key} = {}", ui::done("Set"), mask(value)) |
|
| 530 | 415 | } else { |
|
| 531 | 416 | format!("{} {key} = {value}", ui::done("Set")) |
|
| 532 | 417 | }; |
|
| … | 10 unchanged lines |
||
| 543 | 428 | Ok(0) |
|
| 544 | 429 | } |
|
| 545 | 430 | ||
| 546 | - | pub fn export(path: Option<PathBuf>, pem: bool) -> Result<i32> { |
|
| 431 | + | pub fn export(path: Option<PathBuf>) -> Result<i32> { |
|
| 547 | 432 | let repo = open()?; |
|
| 548 | - | // The PEM is for checking what this home signs, so it is the signing |
|
| 549 | - | // key's: a delegate's when `author.signer` names one. |
|
| 550 | - | let key = if pem { |
|
| 551 | - | repo.signing_key() |
|
| 552 | - | } else { |
|
| 553 | - | repo.config.author_key |
|
| 554 | - | }; |
|
| 555 | - | let Some(key) = key else { |
|
| 433 | + | let Some(key) = repo.config.author_key else { |
|
| 556 | 434 | anyhow::bail!("no identity configured; run `oxy identity create`"); |
|
| 557 | 435 | }; |
|
| 558 | 436 | let id = repo.identity(&key)?; |
|
| 559 | - | let ext = if pem { "pem" } else { "identity" }; |
|
| 560 | - | let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.{ext}", key.short(16)))); |
|
| 561 | - | if pem { |
|
| 562 | - | std::fs::write(&path, oxy_core::dsse::public_pem(&id))?; |
|
| 563 | - | } else { |
|
| 564 | - | std::fs::write(&path, id.encode())?; |
|
| 565 | - | } |
|
| 437 | + | let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.identity", key.short(16)))); |
|
| 438 | + | std::fs::write(&path, id.encode())?; |
|
| 566 | 439 | println!( |
|
| 567 | - | "{} {} {} <{}> ({}) to {}", |
|
| 440 | + | "{} identity {} <{}> ({}) to {}", |
|
| 568 | 441 | ui::done("Wrote"), |
|
| 569 | - | if pem { "the public key of" } else { "identity" }, |
|
| 570 | 442 | ui::author(&id.name), |
|
| 571 | 443 | id.email, |
|
| 572 | 444 | key.short(12), |
|
| … | 238 unchanged lines |
||
| 811 | 683 | ("threshold", json::n(rule.threshold)), |
|
| 812 | 684 | ("require", json::s(rule.require.name())), |
|
| 813 | 685 | ("allow_conflicts", json::b(rule.allow_conflicts)), |
|
| 814 | - | ( |
|
| 815 | - | "subjects", |
|
| 816 | - | json::arr(rule.subjects.iter().map(|g| { |
|
| 817 | - | json::obj([ |
|
| 818 | - | ("subject", json::s(&g.pattern)), |
|
| 819 | - | ("issuer", ident(&g.issuer)), |
|
| 820 | - | ("scope", json::s(&g.scope.to_string())), |
|
| 821 | - | ]) |
|
| 822 | - | })), |
|
| 823 | - | ), |
|
| 824 | 686 | ]) |
|
| 825 | 687 | })), |
|
| 826 | 688 | ), |
|
| … | 56 unchanged lines |
||
| 883 | 745 | ); |
|
| 884 | 746 | for k in &rule.keys { |
|
| 885 | 747 | println!(" {} {}", ui::hash(k.short(12)), ui::author(name(k))); |
|
| 886 | - | } |
|
| 887 | - | for g in &rule.subjects { |
|
| 888 | - | println!( |
|
| 889 | - | " {} {} {}", |
|
| 890 | - | ui::author(&g.pattern), |
|
| 891 | - | ui::dim(format!( |
|
| 892 | - | "certified by {} ({}),", |
|
| 893 | - | name(&g.issuer), |
|
| 894 | - | g.issuer.short(12) |
|
| 895 | - | )), |
|
| 896 | - | ui::dim(format!("scope {}", g.scope)) |
|
| 897 | - | ); |
|
| 898 | 748 | } |
|
| 899 | 749 | } |
|
| 900 | 750 | if !p.revoked.is_empty() { |
|
| … | 26 unchanged lines |
||
| 927 | 777 | id.email, |
|
| 928 | 778 | key.short(12), |
|
| 929 | 779 | ui::bookmark(pattern.unwrap_or("*")) |
|
| 930 | - | ); |
|
| 931 | - | report_update(&u, out)?; |
|
| 932 | - | Ok(0) |
|
| 933 | - | } |
|
| 934 | - | ||
| 935 | - | pub fn policy_grant_subject( |
|
| 936 | - | subject: &str, |
|
| 937 | - | issuer_file: &Path, |
|
| 938 | - | scope: &str, |
|
| 939 | - | pattern: Option<&str>, |
|
| 940 | - | out: Option<PathBuf>, |
|
| 941 | - | ) -> Result<i32> { |
|
| 942 | - | let repo = open()?; |
|
| 943 | - | let issuer = Identity::decode(&std::fs::read(issuer_file)?) |
|
| 944 | - | .map_err(|e| anyhow::anyhow!("{}: not an identity file: {e}", issuer_file.display()))?; |
|
| 945 | - | let scope = oxy_core::identity::Scope::parse(scope).map_err(|e| anyhow::anyhow!(e))?; |
|
| 946 | - | let key = issuer.hash(); |
|
| 947 | - | let u = repo.policy_grant_subject(subject, issuer.clone(), scope, pattern)?; |
|
| 948 | - | println!( |
|
| 949 | - | "{} {} certified by {} ({}) on {}, scope {}", |
|
| 950 | - | ui::done("Granted"), |
|
| 951 | - | ui::author(subject), |
|
| 952 | - | issuer.name, |
|
| 953 | - | key.short(12), |
|
| 954 | - | ui::bookmark(pattern.unwrap_or("*")), |
|
| 955 | - | scope |
|
| 956 | 780 | ); |
|
| 957 | 781 | report_update(&u, out)?; |
|
| 958 | 782 | Ok(0) |
|
| … | 106 unchanged lines |
||
| 1065 | 889 | ||
| 1066 | 890 | fn hex_string(b: &[u8]) -> String { |
|
| 1067 | 891 | b.iter().map(|x| format!("{x:02x}")).collect() |
|
| 1068 | - | } |
|
| 1069 | - | ||
| 1070 | - | #[cfg(test)] |
|
| 1071 | - | mod tests { |
|
| 1072 | - | #[test] |
|
| 1073 | - | fn secrets_are_never_printed_whole() { |
|
| 1074 | - | assert!( |
|
| 1075 | - | super::secret_key("remote.earth.token") |
|
| 1076 | - | && super::secret_key("auth.secret") |
|
| 1077 | - | && !super::secret_key("remote.earth.url") |
|
| 1078 | - | ); |
|
| 1079 | - | assert_eq!( |
|
| 1080 | - | super::mask("oxyhub_6b7dce13409b634d"), |
|
| 1081 | - | "oxyhub_6b7…(13 more)" |
|
| 1082 | - | ); |
|
| 1083 | - | assert_eq!(super::mask("short"), "…"); |
|
| 1084 | - | } |
|
| 1085 | 892 | } |
removed src/cli/mcp.rs +0 −543
| 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 +15 −335
| 1 | 1 | //! Command-line definitions and dispatch. |
|
| 2 | 2 | ||
| 3 | - | mod approve; |
|
| 4 | - | mod artifact; |
|
| 5 | 3 | mod auth; |
|
| 6 | 4 | mod format; |
|
| 7 | 5 | mod identity; |
|
| 8 | 6 | mod json; |
|
| 9 | - | mod mcp; |
|
| 10 | 7 | mod proposal; |
|
| 11 | 8 | mod receipt; |
|
| 12 | 9 | mod sync; |
|
| 13 | - | mod tlog; |
|
| 14 | 10 | pub mod ui; |
|
| 15 | 11 | mod workspace; |
|
| 16 | 12 | ||
| … | 191 unchanged lines |
||
| 208 | 204 | /// Machine-readable output: one JSON document on stdout |
|
| 209 | 205 | #[arg(long)] |
|
| 210 | 206 | json: bool, |
|
| 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 | 207 | }, |
|
| 220 | 208 | /// Print the version of oxy, its repository format, and protocol |
|
| 221 | 209 | Version { |
|
| … | 34 unchanged lines |
||
| 256 | 244 | #[arg(short, long)] |
|
| 257 | 245 | message: Option<String>, |
|
| 258 | 246 | /// Amend an older change; the edits must touch only lines it already knows |
|
| 259 | - | #[arg(long, conflicts_with = "paths")] |
|
| 247 | + | #[arg(long)] |
|
| 260 | 248 | into: Option<String>, |
|
| 261 | - | /// Fold in only these paths; the rest stays as uncommitted work |
|
| 262 | - | paths: Vec<String>, |
|
| 263 | 249 | }, |
|
| 264 | 250 | /// Split a head: the named paths become a separate first change |
|
| 265 | 251 | Split { |
|
| … | 22 unchanged lines |
||
| 288 | 274 | /// Show every change a revset selects |
|
| 289 | 275 | #[arg(short = 'r', long, value_name = "REVSET", conflicts_with = "change")] |
|
| 290 | 276 | revset: Option<String>, |
|
| 291 | - | /// The files it touched with lines added and removed, instead of the diff |
|
| 292 | - | #[arg(long)] |
|
| 293 | - | stat: bool, |
|
| 294 | 277 | /// Machine-readable output: one JSON document on stdout |
|
| 295 | 278 | #[arg(long)] |
|
| 296 | 279 | json: bool, |
|
| … | 10 unchanged lines |
||
| 307 | 290 | #[arg(long, conflicts_with = "discard")] |
|
| 308 | 291 | carry: bool, |
|
| 309 | 292 | }, |
|
| 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 |
|
| 293 | + | /// Report which conflicts of the working copy's base are resolved by the current files |
|
| 313 | 294 | 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, |
|
| 324 | 295 | /// Machine-readable output: one JSON document on stdout |
|
| 325 | 296 | #[arg(long)] |
|
| 326 | 297 | json: bool, |
|
| … | 66 unchanged lines |
||
| 393 | 364 | #[command(subcommand)] |
|
| 394 | 365 | cmd: BookmarkCmd, |
|
| 395 | 366 | }, |
|
| 396 | - | /// Union another bookmark's heads into the current one (no merge change): |
|
| 397 | - | /// a bookmark here, or a remote's (`origin/develop`) as last fetched |
|
| 367 | + | /// Union another bookmark's heads into the current one (no merge change) |
|
| 398 | 368 | Merge { |
|
| 399 | 369 | /// The bookmark whose heads to add |
|
| 400 | 370 | bookmark: String, |
|
| … | 7 unchanged lines |
||
| 408 | 378 | Submodule { |
|
| 409 | 379 | #[command(subcommand)] |
|
| 410 | 380 | cmd: SubmoduleCmd, |
|
| 411 | - | }, |
|
| 412 | - | /// Witness hubs' transparency logs: cosign each checkpoint that extends |
|
| 413 | - | /// the last one cosigned (spec §14.8) |
|
| 414 | - | Witness { |
|
| 415 | - | #[command(subcommand)] |
|
| 416 | - | cmd: WitnessCmd, |
|
| 417 | - | }, |
|
| 418 | - | /// Hubs' transparency logs: check that a hub shows everyone the same |
|
| 419 | - | /// history (spec §14.8) |
|
| 420 | - | Tlog { |
|
| 421 | - | #[command(subcommand)] |
|
| 422 | - | cmd: TlogCmd, |
|
| 423 | 381 | }, |
|
| 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 | - | }, |
|
| 429 | 382 | /// Execution receipts: signed records of what a runner ran on a state |
|
| 430 | 383 | Receipt { |
|
| 431 | 384 | #[command(subcommand)] |
|
| … | 36 unchanged lines |
||
| 468 | 421 | #[arg(short = 'b', long)] |
|
| 469 | 422 | bookmark: Option<String>, |
|
| 470 | 423 | /// Delete this bookmark on the remote (never its default bookmark); |
|
| 471 | - | /// the local bookmark is kept. `tags/NAME` withdraws a tag there |
|
| 472 | - | /// (and here, as `oxy tag delete` does) |
|
| 424 | + | /// the local bookmark is kept |
|
| 473 | 425 | #[arg(long, value_name = "NAME", conflicts_with_all = ["bookmark", "force"])] |
|
| 474 | 426 | delete: Option<String>, |
|
| 475 | 427 | /// Machine-readable output: one JSON document on stdout |
|
| … | 30 unchanged lines |
||
| 506 | 458 | /// Seal every change of a hub proposal and push the seal (your approval) |
|
| 507 | 459 | #[arg(long, conflicts_with = "changes")] |
|
| 508 | 460 | proposal: Option<String>, |
|
| 509 | - | /// Approve a job of a hub run that waits for a sealed approval: `--flow https://hub/OWNER/NAME/flow/N --job apply` |
|
| 510 | - | #[arg(long, conflicts_with_all = ["changes", "proposal"], requires = "job")] |
|
| 511 | - | flow: Option<String>, |
|
| 512 | - | /// The job to approve, with --flow |
|
| 513 | - | #[arg(long, requires = "flow")] |
|
| 514 | - | job: Option<String>, |
|
| 515 | - | /// Reject the job instead of approving it, with --flow |
|
| 516 | - | #[arg(long, requires = "flow")] |
|
| 517 | - | reject: bool, |
|
| 518 | 461 | /// Machine-readable output: one JSON document on stdout |
|
| 519 | 462 | #[arg(long)] |
|
| 520 | 463 | json: bool, |
|
| … | 24 unchanged lines |
||
| 545 | 488 | #[command(subcommand)] |
|
| 546 | 489 | cmd: PolicyCmd, |
|
| 547 | 490 | }, |
|
| 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) |
|
| 491 | + | /// 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) |
|
| 549 | 492 | Config { |
|
| 550 | 493 | /// The key, such as author.name or remote.origin.token |
|
| 551 | 494 | key: String, |
|
| 552 | - | /// The value to set (none with --unset); `-` reads it from standard input, out of the shell's history |
|
| 495 | + | /// The value to set (none with --unset) |
|
| 553 | 496 | #[arg(required_unless_present = "unset")] |
|
| 554 | 497 | value: Option<String>, |
|
| 555 | 498 | /// Write to the user file (~/.config/oxy/config.toml) instead of this repository |
|
| … | 100 unchanged lines |
||
| 656 | 599 | /// branch at a commit would hold |
|
| 657 | 600 | #[arg(long, conflicts_with = "only")] |
|
| 658 | 601 | upto: bool, |
|
| 659 | - | /// Just CHANGE... and what they depend on. Other changes to the |
|
| 660 | - | /// same files are left out, so a file can come out partly edited; |
|
| 661 | - | /// to add one change to a bookmark's work, give the bookmark's |
|
| 662 | - | /// heads with it instead: `bookmark set NAME <its heads> CHANGE` |
|
| 602 | + | /// Just CHANGE... and what they depend on |
|
| 663 | 603 | #[arg(long)] |
|
| 664 | 604 | only: bool, |
|
| 665 | 605 | }, |
|
| … | 9 unchanged lines |
||
| 675 | 615 | /// moved to a commit would hold |
|
| 676 | 616 | #[arg(long, conflicts_with = "only")] |
|
| 677 | 617 | upto: bool, |
|
| 678 | - | /// Just CHANGE... and what they depend on. Other changes to the |
|
| 679 | - | /// same files are left out, so a file can come out partly edited; |
|
| 680 | - | /// to add one change to a bookmark's work, give the bookmark's |
|
| 681 | - | /// heads with it instead: `bookmark set NAME <its heads> CHANGE` |
|
| 618 | + | /// Just CHANGE... and what they depend on |
|
| 682 | 619 | #[arg(long)] |
|
| 683 | 620 | only: bool, |
|
| 684 | 621 | }, |
|
| … | 39 unchanged lines |
||
| 724 | 661 | } |
|
| 725 | 662 | ||
| 726 | 663 | #[derive(Subcommand)] |
|
| 727 | - | enum WitnessCmd { |
|
| 728 | - | /// Serve a witness (C2SP tlog-witness: POST /add-checkpoint), signing |
|
| 729 | - | /// with this oxy home's key (author.key) |
|
| 730 | - | Serve { |
|
| 731 | - | /// The witness's name in cosignatures: a host name |
|
| 732 | - | #[arg(long)] |
|
| 733 | - | name: String, |
|
| 734 | - | /// A log to witness, as ORIGIN=FILE: its origin (HOST/log) and its |
|
| 735 | - | /// key's identity file (a hub's /.well-known/oxyhub-key); repeatable |
|
| 736 | - | #[arg(long = "log", required = true)] |
|
| 737 | - | logs: Vec<String>, |
|
| 738 | - | /// Where to keep what it cosigned |
|
| 739 | - | #[arg(long, default_value = "witness-state")] |
|
| 740 | - | state: PathBuf, |
|
| 741 | - | /// Address and port to listen on |
|
| 742 | - | #[arg(long, default_value = "127.0.0.1:8891")] |
|
| 743 | - | bind: String, |
|
| 744 | - | /// PEM certificate chain; enables HTTPS together with --tls-key |
|
| 745 | - | #[arg(long, requires = "tls_key")] |
|
| 746 | - | tls_cert: Option<PathBuf>, |
|
| 747 | - | /// PEM private key for --tls-cert |
|
| 748 | - | #[arg(long, requires = "tls_cert")] |
|
| 749 | - | tls_key: Option<PathBuf>, |
|
| 750 | - | }, |
|
| 751 | - | } |
|
| 752 | - | ||
| 753 | - | #[derive(Subcommand)] |
|
| 754 | - | enum TlogCmd { |
|
| 755 | - | /// Check a hub's whole log: its checkpoint against the log key and the |
|
| 756 | - | /// last checkpoint seen, and every entry against the root it signed; |
|
| 757 | - | /// then list this repository's entries (or a key's, or all) |
|
| 758 | - | Verify { |
|
| 759 | - | /// The hub, as https://oxygenhub.io (default: this checkout's remote) |
|
| 760 | - | hub: Option<String>, |
|
| 761 | - | /// List the entries a key signed (a KeyId) instead |
|
| 762 | - | #[arg(long, conflicts_with = "all")] |
|
| 763 | - | key: Option<String>, |
|
| 764 | - | /// List every entry |
|
| 765 | - | #[arg(long)] |
|
| 766 | - | all: bool, |
|
| 767 | - | /// Machine-readable output |
|
| 768 | - | #[arg(long)] |
|
| 769 | - | json: bool, |
|
| 770 | - | }, |
|
| 771 | - | } |
|
| 772 | - | ||
| 773 | - | #[derive(Subcommand)] |
|
| 774 | 664 | enum AuthCmd { |
|
| 775 | 665 | /// Sign in to HUB in the browser (device sign-in) and keep the token for |
|
| 776 | 666 | /// every repository there |
|
| … | 14 unchanged lines |
||
| 791 | 681 | /// The hub (default: this checkout's origin, or the only one) |
|
| 792 | 682 | hub: Option<String>, |
|
| 793 | 683 | }, |
|
| 794 | - | /// Keep an agent grant token (from `oh agent grant`) for keyless |
|
| 795 | - | /// signing: with `author.signer = keyless`, commits and pushes are |
|
| 796 | - | /// signed by fresh keys the hub certifies, in the grantor's name |
|
| 797 | - | /// (the token is read from standard input, so it stays out of the |
|
| 798 | - | /// shell's history) |
|
| 799 | - | Grant { |
|
| 800 | - | /// The hub (default: this checkout's origin) |
|
| 801 | - | hub: Option<String>, |
|
| 802 | - | }, |
|
| 803 | 684 | } |
|
| 804 | 685 | ||
| 805 | 686 | #[derive(Subcommand)] |
|
| … | 26 unchanged lines |
||
| 832 | 713 | Remove { |
|
| 833 | 714 | /// The subrepo to unregister |
|
| 834 | 715 | 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, |
|
| 841 | - | }, |
|
| 842 | - | } |
|
| 843 | - | ||
| 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 | 716 | }, |
|
| 898 | 717 | } |
|
| 899 | 718 | ||
| … | 27 unchanged lines |
||
| 927 | 746 | /// Change, bookmark or tag whose state holds it |
|
| 928 | 747 | #[arg(long)] |
|
| 929 | 748 | rev: Option<String>, |
|
| 930 | - | /// `oxygen`: the runner's signed receipt, as `verify` and `import` |
|
| 931 | - | /// read it. `slsa`: SLSA provenance in a DSSE envelope, signed with |
|
| 932 | - | /// your key, for in-toto and cosign (`oxy identity export --pem` |
|
| 933 | - | /// gives the key they check it with) |
|
| 934 | - | #[arg(long, default_value = "oxygen", value_parser = ["oxygen", "slsa"])] |
|
| 935 | - | format: String, |
|
| 936 | 749 | }, |
|
| 937 | 750 | /// Check a signed receipt in FILE without keeping it |
|
| 938 | 751 | Verify { |
|
| … | 21 unchanged lines |
||
| 960 | 773 | /// A message recorded with the tag |
|
| 961 | 774 | #[arg(short, long, default_value = "")] |
|
| 962 | 775 | message: String, |
|
| 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 | 776 | }, |
|
| 974 | 777 | /// List tags and the states they name |
|
| 975 | 778 | List { |
|
| … | 83 unchanged lines |
||
| 1059 | 862 | Show, |
|
| 1060 | 863 | /// Write your identity object to a file, to hand to a policy holder |
|
| 1061 | 864 | Export { |
|
| 1062 | - | /// File to write (default: <key>.identity, or <key>.pem with --pem, in the current directory) |
|
| 865 | + | /// File to write (default: <key>.identity in the current directory) |
|
| 1063 | 866 | path: Option<PathBuf>, |
|
| 1064 | - | /// Write the Ed25519 public key as PEM instead, as cosign's --key and |
|
| 1065 | - | /// other DSSE verifiers take it |
|
| 1066 | - | #[arg(long)] |
|
| 1067 | - | pem: bool, |
|
| 1068 | 867 | }, |
|
| 1069 | 868 | /// Sign a host's challenge (hex, at least 16 bytes) to prove you hold your key; prints the signature as hex |
|
| 1070 | 869 | SignChallenge { |
|
| … | 52 unchanged lines |
||
| 1123 | 922 | ||
| 1124 | 923 | #[derive(Subcommand)] |
|
| 1125 | 924 | 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 | - | }, |
|
| 1132 | 925 | /// Export the current bookmark to a Git repository (created bare if absent) |
|
| 1133 | 926 | Export { |
|
| 1134 | 927 | /// Git repository to write to (created bare if absent) |
|
| … | 66 unchanged lines |
||
| 1201 | 994 | Grant { |
|
| 1202 | 995 | /// The identity to grant, exported with `oxy identity export` |
|
| 1203 | 996 | identity_file: PathBuf, |
|
| 1204 | - | /// Reference pattern the grant applies to (default: every bookmark) |
|
| 1205 | - | #[arg(long)] |
|
| 1206 | - | pattern: Option<String>, |
|
| 1207 | - | /// Where to write the proposal when more root signatures are needed |
|
| 1208 | - | #[arg(long)] |
|
| 1209 | - | out: Option<PathBuf>, |
|
| 1210 | - | }, |
|
| 1211 | - | /// Allow holders of an issuer's certificates for SUBJECT to sign (keyless) |
|
| 1212 | - | GrantSubject { |
|
| 1213 | - | /// Who: `repo:OWNER/NAME:bookmark:main`, `agent:NAME@ACCOUNT`; `*` matches any run of characters |
|
| 1214 | - | subject: String, |
|
| 1215 | - | /// The issuer's identity file, e.g. a hub's /.well-known/oxyhub-key |
|
| 1216 | - | #[arg(long)] |
|
| 1217 | - | issuer: PathBuf, |
|
| 1218 | - | /// What its keys may sign: changes, refs, seal |
|
| 1219 | - | #[arg(long, default_value = "changes,refs")] |
|
| 1220 | - | scope: String, |
|
| 1221 | 997 | /// Reference pattern the grant applies to (default: every bookmark) |
|
| 1222 | 998 | #[arg(long)] |
|
| 1223 | 999 | pattern: Option<String>, |
|
| … | 52 unchanged lines |
||
| 1276 | 1052 | }, |
|
| 1277 | 1053 | } |
|
| 1278 | 1054 | ||
| 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 | - | ||
| 1301 | 1055 | /// Open the repository containing the current directory. |
|
| 1302 | 1056 | fn open() -> Result<Repo> { |
|
| 1303 | 1057 | let repo = Repo::open(&std::env::current_dir()?).context("opening repository")?; |
|
| … | 72 unchanged lines |
||
| 1376 | 1130 | } |
|
| 1377 | 1131 | Cmd::Cat { path, change } => workspace::cat(&path, change.as_deref()), |
|
| 1378 | 1132 | Cmd::Root { json } => workspace::root(json), |
|
| 1379 | - | Cmd::Mcp { |
|
| 1380 | - | dir, |
|
| 1381 | - | allow_destructive, |
|
| 1382 | - | } => mcp::serve(dir, allow_destructive), |
|
| 1383 | 1133 | Cmd::Version { json } => workspace::version(json), |
|
| 1384 | 1134 | Cmd::Discard { paths, hunks } => workspace::discard(&paths, &hunks), |
|
| 1385 | 1135 | Cmd::Hunks { json } => workspace::hunks(json), |
|
| … | 42 unchanged lines |
||
| 1428 | 1178 | } |
|
| 1429 | 1179 | None => workspace::describe_name(json), |
|
| 1430 | 1180 | }, |
|
| 1431 | - | Cmd::Amend { |
|
| 1432 | - | message, |
|
| 1433 | - | into, |
|
| 1434 | - | paths, |
|
| 1435 | - | } => workspace::amend(message.as_deref(), into.as_deref(), &paths), |
|
| 1181 | + | Cmd::Amend { message, into } => workspace::amend(message.as_deref(), into.as_deref()), |
|
| 1436 | 1182 | Cmd::Split { change, paths } => workspace::split(&change, &paths), |
|
| 1437 | 1183 | Cmd::Abandon { |
|
| 1438 | 1184 | change, |
|
| … | 3 unchanged lines |
||
| 1442 | 1188 | Cmd::Show { |
|
| 1443 | 1189 | change, |
|
| 1444 | 1190 | revset, |
|
| 1445 | - | stat, |
|
| 1446 | 1191 | json, |
|
| 1447 | - | } => workspace::show(change.as_deref(), revset.as_deref(), stat, json), |
|
| 1192 | + | } => workspace::show(change.as_deref(), revset.as_deref(), json), |
|
| 1448 | 1193 | Cmd::Checkout { |
|
| 1449 | 1194 | changes, |
|
| 1450 | 1195 | discard, |
|
| 1451 | 1196 | carry, |
|
| 1452 | 1197 | } => workspace::checkout(&changes, discard, carry), |
|
| 1453 | - | Cmd::Resolve { |
|
| 1454 | - | paths, |
|
| 1455 | - | both, |
|
| 1456 | - | reverse, |
|
| 1457 | - | json, |
|
| 1458 | - | } => workspace::resolve(&paths, both, reverse, json), |
|
| 1198 | + | Cmd::Resolve { json } => workspace::resolve(json), |
|
| 1459 | 1199 | Cmd::Undo => workspace::undo(), |
|
| 1460 | 1200 | Cmd::Gc { |
|
| 1461 | 1201 | now, |
|
| … | 61 unchanged lines |
||
| 1523 | 1263 | } => auth::login(hub.as_deref(), with_token, ca), |
|
| 1524 | 1264 | AuthCmd::Status => auth::status(), |
|
| 1525 | 1265 | AuthCmd::Logout { hub } => auth::logout(hub.as_deref()), |
|
| 1526 | - | AuthCmd::Grant { hub } => auth::grant(hub.as_deref()), |
|
| 1527 | 1266 | }, |
|
| 1528 | 1267 | Cmd::Submodule { cmd } => match cmd { |
|
| 1529 | 1268 | SubmoduleCmd::Add { |
|
| … | 3 unchanged lines |
||
| 1533 | 1272 | } => sync::submodule_add(&url, &path, bookmark.as_deref()), |
|
| 1534 | 1273 | SubmoduleCmd::Update { init, paths } => sync::submodule_update(init, &paths), |
|
| 1535 | 1274 | SubmoduleCmd::Status { json } => sync::submodule_status(json), |
|
| 1536 | - | SubmoduleCmd::Remove { |
|
| 1537 | - | path, |
|
| 1538 | - | delete, |
|
| 1539 | - | force, |
|
| 1540 | - | } => sync::submodule_remove(&path, delete, force), |
|
| 1541 | - | }, |
|
| 1542 | - | Cmd::Witness { |
|
| 1543 | - | cmd: |
|
| 1544 | - | WitnessCmd::Serve { |
|
| 1545 | - | name, |
|
| 1546 | - | logs, |
|
| 1547 | - | state, |
|
| 1548 | - | bind, |
|
| 1549 | - | tls_cert, |
|
| 1550 | - | tls_key, |
|
| 1551 | - | }, |
|
| 1552 | - | } => tlog::witness_serve(&name, &logs, state, &bind, tls_cert.zip(tls_key)), |
|
| 1553 | - | Cmd::Tlog { cmd } => match cmd { |
|
| 1554 | - | TlogCmd::Verify { |
|
| 1555 | - | hub, |
|
| 1556 | - | key, |
|
| 1557 | - | all, |
|
| 1558 | - | json, |
|
| 1559 | - | } => tlog::verify(hub.as_deref(), key.as_deref(), all, json), |
|
| 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), |
|
| 1275 | + | SubmoduleCmd::Remove { path } => sync::submodule_remove(&path), |
|
| 1576 | 1276 | }, |
|
| 1577 | 1277 | Cmd::Receipt { cmd } => match cmd { |
|
| 1578 | 1278 | ReceiptCmd::List { rev, json } => receipt::list(rev.as_deref(), json), |
|
| … | 6 unchanged lines |
||
| 1585 | 1285 | receipt: r, |
|
| 1586 | 1286 | file, |
|
| 1587 | 1287 | rev, |
|
| 1588 | - | format, |
|
| 1589 | - | } => receipt::export(&r, &file, rev.as_deref(), &format), |
|
| 1288 | + | } => receipt::export(&r, &file, rev.as_deref()), |
|
| 1590 | 1289 | ReceiptCmd::Verify { file, json } => receipt::verify(&file, json), |
|
| 1591 | 1290 | ReceiptCmd::Import { file } => receipt::import(&file), |
|
| 1592 | 1291 | }, |
|
| … | 3 unchanged lines |
||
| 1596 | 1295 | changes, |
|
| 1597 | 1296 | message, |
|
| 1598 | 1297 | } => workspace::tag_create(&name, &changes, &message), |
|
| 1599 | - | TagCmd::Delete { name, reason } => workspace::tag_delete(&name, &reason), |
|
| 1600 | 1298 | TagCmd::List { json } => workspace::tag_list(json), |
|
| 1601 | 1299 | }, |
|
| 1602 | 1300 | Cmd::Remote { cmd } => match cmd { |
|
| … | 45 unchanged lines |
||
| 1648 | 1346 | expires, |
|
| 1649 | 1347 | } => identity::create(name, email, local, expires), |
|
| 1650 | 1348 | IdentityCmd::Show => identity::show(), |
|
| 1651 | - | IdentityCmd::Export { path, pem } => identity::export(path, pem), |
|
| 1349 | + | IdentityCmd::Export { path } => identity::export(path), |
|
| 1652 | 1350 | IdentityCmd::SignChallenge { challenge, json } => { |
|
| 1653 | 1351 | identity::sign_challenge(&challenge, json) |
|
| 1654 | 1352 | } |
|
| … | 10 unchanged lines |
||
| 1665 | 1363 | DelegateCmd::List => identity::delegate_list(), |
|
| 1666 | 1364 | }, |
|
| 1667 | 1365 | }, |
|
| 1668 | - | Cmd::Git { |
|
| 1669 | - | cmd: GitCmd::Colocate { off }, |
|
| 1670 | - | } => sync::git_colocate(off), |
|
| 1671 | 1366 | Cmd::Git { |
|
| 1672 | 1367 | cmd: GitCmd::Export { git_dir, branch }, |
|
| 1673 | 1368 | } => sync::git_export(&git_dir, branch.as_deref()), |
|
| … | 32 unchanged lines |
||
| 1706 | 1401 | pattern, |
|
| 1707 | 1402 | out, |
|
| 1708 | 1403 | } => identity::policy_grant(&identity_file, pattern.as_deref(), out), |
|
| 1709 | - | PolicyCmd::GrantSubject { |
|
| 1710 | - | subject, |
|
| 1711 | - | issuer, |
|
| 1712 | - | scope, |
|
| 1713 | - | pattern, |
|
| 1714 | - | out, |
|
| 1715 | - | } => identity::policy_grant_subject(&subject, &issuer, &scope, pattern.as_deref(), out), |
|
| 1716 | 1404 | PolicyCmd::Revoke { key, out } => identity::policy_revoke(&key, out), |
|
| 1717 | 1405 | PolicyCmd::Root { cmd } => match cmd { |
|
| 1718 | 1406 | RootCmd::Add { identity_file, out } => { |
|
| … | 4 unchanged lines |
||
| 1723 | 1411 | }, |
|
| 1724 | 1412 | PolicyCmd::Sign { proposal, out } => identity::policy_sign(&proposal, out), |
|
| 1725 | 1413 | }, |
|
| 1726 | - | Cmd::Seal { |
|
| 1727 | - | flow: Some(url), |
|
| 1728 | - | job: Some(job), |
|
| 1729 | - | reject, |
|
| 1730 | - | json, |
|
| 1731 | - | .. |
|
| 1732 | - | } => approve::seal(&url, &job, reject, json), |
|
| 1733 | 1414 | Cmd::Seal { |
|
| 1734 | 1415 | changes, |
|
| 1735 | 1416 | proposal: Some(url), |
|
| 1736 | 1417 | json, |
|
| 1737 | - | .. |
|
| 1738 | 1418 | } if changes.is_empty() => proposal::seal(&url, json), |
|
| 1739 | 1419 | Cmd::Seal { changes, json, .. } => identity::seal(&changes, json), |
|
| 1740 | 1420 | Cmd::Proposal { cmd } => match cmd { |
|
| … | 54 unchanged lines |
modified src/cli/receipt.rs +2 −108
| … | 172 unchanged lines |
||
| 173 | 173 | Ok(if i.problem.is_some() { 1 } else { 0 }) |
|
| 174 | 174 | } |
|
| 175 | 175 | ||
| 176 | - | /// `oxy receipt export HASH FILE [--format slsa]`: the signed receipt, to |
|
| 177 | - | /// hand to anyone, or SLSA provenance of it for tools outside Oxygen. |
|
| 178 | - | pub fn export(prefix: &str, file: &str, rev: Option<&str>, format: &str) -> Result<i32> { |
|
| 176 | + | /// `oxy receipt export HASH FILE`: the signed receipt, to hand to anyone. |
|
| 177 | + | pub fn export(prefix: &str, file: &str, rev: Option<&str>) -> Result<i32> { |
|
| 179 | 178 | let repo = open()?; |
|
| 180 | 179 | let i = find(&repo, prefix, rev)?; |
|
| 181 | - | if format == "slsa" { |
|
| 182 | - | return export_slsa(&repo, &i, file); |
|
| 183 | - | } |
|
| 184 | 180 | std::fs::write(file, i.signed.encode())?; |
|
| 185 | 181 | println!( |
|
| 186 | 182 | "{} {} {}", |
|
| 187 | 183 | ui::done("Exported receipt"), |
|
| 188 | 184 | ui::hash(format!("#{}", i.attest.short(12))), |
|
| 189 | 185 | ui::path(file) |
|
| 190 | - | ); |
|
| 191 | - | Ok(0) |
|
| 192 | - | } |
|
| 193 | - | ||
| 194 | - | /// What SLSA calls the build type of a Flow job. |
|
| 195 | - | pub const BUILD_TYPE: &str = "https://oxygenhub.io/oxygenflow/job/v1"; |
|
| 196 | - | ||
| 197 | - | /// The in-toto Statement v1, with an SLSA provenance v1 predicate, of what |
|
| 198 | - | /// a receipt says (spec §14.6). Its subjects are the job's outputs (the |
|
| 199 | - | /// state it ran on when it made none), named by their Oxygen object hashes; |
|
| 200 | - | /// the signed receipt itself rides along as a byproduct, so an Oxygen |
|
| 201 | - | /// verifier can still check the runner's own signature. |
|
| 202 | - | pub fn statement(sr: &SignedReceipt) -> serde_json::Value { |
|
| 203 | - | use serde_json::json; |
|
| 204 | - | let r = &sr.receipt; |
|
| 205 | - | let subject: Vec<_> = if r.outputs.is_empty() { |
|
| 206 | - | vec![json!({ "name": "state", "digest": { "oxygen-state": sr.state().to_hex() } })] |
|
| 207 | - | } else { |
|
| 208 | - | r.outputs |
|
| 209 | - | .iter() |
|
| 210 | - | .map(|o| json!({ "name": o.to_hex(), "digest": { "oxygen-blob": o.to_hex() } })) |
|
| 211 | - | .collect() |
|
| 212 | - | }; |
|
| 213 | - | let rfc3339 = |ms: u64| super::format::when(ms).replacen(' ', "T", 1); |
|
| 214 | - | let made = sr.attest.hlc.wall_ms; |
|
| 215 | - | let mut deps = vec![ |
|
| 216 | - | json!({ "name": "state", "digest": { "oxygen-state": sr.state().to_hex() } }), |
|
| 217 | - | json!({ "name": "scope", "digest": { "oxygen-scope": r.scope_tree_hash.to_hex() } }), |
|
| 218 | - | ]; |
|
| 219 | - | deps.extend( |
|
| 220 | - | r.inputs |
|
| 221 | - | .iter() |
|
| 222 | - | .map(|h| json!({ "name": h.to_hex(), "digest": { "oxygen-blob": h.to_hex() } })), |
|
| 223 | - | ); |
|
| 224 | - | json!({ |
|
| 225 | - | "_type": "https://in-toto.io/Statement/v1", |
|
| 226 | - | "subject": subject, |
|
| 227 | - | "predicateType": "https://slsa.dev/provenance/v1", |
|
| 228 | - | "predicate": { |
|
| 229 | - | "buildDefinition": { |
|
| 230 | - | "buildType": BUILD_TYPE, |
|
| 231 | - | "externalParameters": { |
|
| 232 | - | "job": r.job_name, |
|
| 233 | - | "pipeline": r.pipeline_hash.to_hex(), |
|
| 234 | - | }, |
|
| 235 | - | "internalParameters": { |
|
| 236 | - | "environment": r.environment.to_hex(), |
|
| 237 | - | }, |
|
| 238 | - | "resolvedDependencies": deps, |
|
| 239 | - | }, |
|
| 240 | - | "runDetails": { |
|
| 241 | - | "builder": { |
|
| 242 | - | "id": format!("urn:oxygen:runner:{}", sr.runner.hash().to_hex()), |
|
| 243 | - | "version": { "name": sr.runner.name }, |
|
| 244 | - | }, |
|
| 245 | - | "metadata": { |
|
| 246 | - | "invocationId": sr.run(), |
|
| 247 | - | "startedOn": rfc3339(made.saturating_sub(r.duration_ms)), |
|
| 248 | - | "finishedOn": rfc3339(made), |
|
| 249 | - | }, |
|
| 250 | - | "byproducts": [{ |
|
| 251 | - | "name": "oxygen-receipt", |
|
| 252 | - | "mediaType": "application/vnd.oxygen.receipt+cbor", |
|
| 253 | - | "content": oxy_core::dsse::base64(&sr.encode()), |
|
| 254 | - | "annotations": { |
|
| 255 | - | "exitCode": r.exit_code, |
|
| 256 | - | "durationMs": r.duration_ms, |
|
| 257 | - | "runner": sr.runner.name, |
|
| 258 | - | }, |
|
| 259 | - | }], |
|
| 260 | - | }, |
|
| 261 | - | }, |
|
| 262 | - | }) |
|
| 263 | - | } |
|
| 264 | - | ||
| 265 | - | fn export_slsa(repo: &oxy_core::repo::Repo, i: &ReceiptInfo, file: &str) -> Result<i32> { |
|
| 266 | - | if let Some(p) = &i.problem { |
|
| 267 | - | bail!( |
|
| 268 | - | "receipt #{} does not verify ({p}); provenance is only made of a valid one", |
|
| 269 | - | i.attest.short(12) |
|
| 270 | - | ); |
|
| 271 | - | } |
|
| 272 | - | let Some((key, sk)) = repo.secret_key()? else { |
|
| 273 | - | bail!("SLSA provenance is signed with your key, and none is set up here; run `oxy identity create`"); |
|
| 274 | - | }; |
|
| 275 | - | let payload = serde_json::to_vec(&statement(&i.signed))?; |
|
| 276 | - | std::fs::write( |
|
| 277 | - | file, |
|
| 278 | - | oxy_core::dsse::envelope(oxy_core::dsse::IN_TOTO, &payload, &key.to_hex(), &sk), |
|
| 279 | - | )?; |
|
| 280 | - | println!( |
|
| 281 | - | "{} {} {} {}", |
|
| 282 | - | ui::done("Exported SLSA provenance of receipt"), |
|
| 283 | - | ui::hash(format!("#{}", i.attest.short(12))), |
|
| 284 | - | ui::path(file), |
|
| 285 | - | ui::dim(format!("signed by {}", key.short(12))) |
|
| 286 | - | ); |
|
| 287 | - | println!( |
|
| 288 | - | "{}", |
|
| 289 | - | ui::dim(format!( |
|
| 290 | - | " check it: oxy identity export --pem key.pem, then cosign verify-blob-attestation --key key.pem --type slsaprovenance1 --insecure-ignore-tlog=true --check-claims=false --signature {file}" |
|
| 291 | - | )) |
|
| 292 | 186 | ); |
|
| 293 | 187 | Ok(0) |
|
| 294 | 188 | } |
|
| … | 33 unchanged lines |
modified src/cli/sync.rs +14 −164
| … | 25 unchanged lines |
||
| 26 | 26 | }); |
|
| 27 | 27 | let max_blob = max_blob.map(oxy_core::repo::parse_size).transpose()?; |
|
| 28 | 28 | let sp = ui::spinner(&format!("Cloning {url}…")); |
|
| 29 | - | let (repo, skipped) = Repo::clone_reporting(url, &dest, max_blob, sparse, depth)?; |
|
| 29 | + | let repo = match depth { |
|
| 30 | + | Some(d) => Repo::clone_shallow(url, &dest, d, max_blob, sparse)?, |
|
| 31 | + | None => Repo::clone_with(url, &dest, max_blob, sparse)?, |
|
| 32 | + | }; |
|
| 30 | 33 | let heads = repo.bookmark_heads()?; |
|
| 31 | 34 | sp.done(&format!( |
|
| 32 | 35 | "{} into {} {}", |
|
| … | 5 unchanged lines |
||
| 38 | 41 | plural(heads.len(), "head") |
|
| 39 | 42 | )) |
|
| 40 | 43 | )); |
|
| 41 | - | warn_skipped(&skipped); |
|
| 42 | 44 | let others = repo.remote_only_bookmarks()?.len(); |
|
| 43 | 45 | if others > 0 { |
|
| 44 | 46 | println!( |
|
| … | 327 unchanged lines |
||
| 372 | 374 | let repo = open()?; |
|
| 373 | 375 | let remote = remote_name(&repo, remote); |
|
| 374 | 376 | let sp = ui::spinner(&format!("Fetching from {remote}…")); |
|
| 375 | - | let report = noted(&repo, &remote, repo.fetch(&remote))?; |
|
| 377 | + | let report = repo.fetch(&remote)?; |
|
| 376 | 378 | sp.clear(); |
|
| 377 | 379 | if as_json { |
|
| 378 | 380 | println!( |
|
| … | 20 unchanged lines |
||
| 399 | 401 | })) |
|
| 400 | 402 | ), |
|
| 401 | 403 | ("new_tags", json::n(report.new_tags)), |
|
| 402 | - | ( |
|
| 403 | - | "withdrawn_tags", |
|
| 404 | - | json::arr(report.withdrawn_tags.iter().map(|n| json::s(n))), |
|
| 405 | - | ), |
|
| 406 | 404 | ("unverified", json::b(report.unverified)), |
|
| 407 | - | ( |
|
| 408 | - | "log_notes", |
|
| 409 | - | json::arr(report.log_notes.iter().map(|n| json::s(n))), |
|
| 410 | - | ), |
|
| 411 | - | ("skipped", skipped_json(&report.skipped)), |
|
| 412 | 405 | ]) |
|
| 413 | 406 | ); |
|
| 414 | 407 | return Ok(0); |
|
| … | 50 unchanged lines |
||
| 465 | 458 | ui::dim(format!("{} adopted", plural(report.new_tags, "tag"))) |
|
| 466 | 459 | ); |
|
| 467 | 460 | } |
|
| 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 | - | } |
|
| 475 | 461 | if report.unverified { |
|
| 476 | 462 | eprintln!( |
|
| 477 | 463 | "{} {remote} has no trust policy; nothing fetched from it was verified", |
|
| 478 | 464 | ui::caution() |
|
| 479 | 465 | ); |
|
| 480 | - | } |
|
| 481 | - | for n in &report.log_notes { |
|
| 482 | - | eprintln!("{} {n}", ui::caution()); |
|
| 483 | 466 | } |
|
| 484 | - | warn_skipped(&report.skipped); |
|
| 485 | 467 | Ok(0) |
|
| 486 | - | } |
|
| 487 | - | ||
| 488 | - | /// Bookmarks a fetch left as they were, their attestation unverifiable |
|
| 489 | - | /// (oxygenhub#266): one warning each, naming it and why. |
|
| 490 | - | fn warn_skipped(skipped: &[(String, String)]) { |
|
| 491 | - | for (name, why) in skipped { |
|
| 492 | - | eprintln!( |
|
| 493 | - | "{} {} {}", |
|
| 494 | - | ui::caution(), |
|
| 495 | - | ui::warn(format!("{name} not updated:")), |
|
| 496 | - | ui::dim(why) |
|
| 497 | - | ); |
|
| 498 | - | } |
|
| 499 | 468 | } |
|
| 500 | 469 | ||
| 501 | - | fn skipped_json(skipped: &[(String, String)]) -> String { |
|
| 502 | - | json::arr( |
|
| 503 | - | skipped |
|
| 504 | - | .iter() |
|
| 505 | - | .map(|(name, why)| json::obj([("name", json::s(name)), ("reason", json::s(why))])), |
|
| 506 | - | ) |
|
| 507 | - | } |
|
| 508 | - | ||
| 509 | 470 | /// Pull acts on the current bookmark only: name the others the fetch |
|
| 510 | 471 | /// found behind their remote, which is easy to take for the current one |
|
| 511 | 472 | /// (oxygen#29). |
|
| … | 27 unchanged lines |
||
| 539 | 500 | let repo = open()?; |
|
| 540 | 501 | let remote = remote_name(&repo, remote); |
|
| 541 | 502 | let sp = ui::spinner(&format!("Pulling from {remote}…")); |
|
| 542 | - | let r = noted(&repo, &remote, repo.pull(&remote))?; |
|
| 503 | + | let r = repo.pull(&remote)?; |
|
| 543 | 504 | if as_json { |
|
| 544 | 505 | sp.clear(); |
|
| 545 | 506 | let conflicted = repo |
|
| … | 9 unchanged lines |
||
| 555 | 516 | ("changed", json::b(r.changed)), |
|
| 556 | 517 | ("checked_out", json::b(r.checked_out)), |
|
| 557 | 518 | ("conflicted", json::b(conflicted)), |
|
| 558 | - | ("others", json::arr(r.others.iter().map(|o| json::s(o)))), |
|
| 559 | - | ("skipped", skipped_json(&r.skipped)) |
|
| 519 | + | ("others", json::arr(r.others.iter().map(|o| json::s(o)))) |
|
| 560 | 520 | ]) |
|
| 561 | 521 | ); |
|
| 562 | 522 | return Ok(0); |
|
| … | 14 unchanged lines |
||
| 577 | 537 | )); |
|
| 578 | 538 | others_pulled(&r.others); |
|
| 579 | 539 | others_behind(&repo, &remote); |
|
| 580 | - | warn_skipped(&r.skipped); |
|
| 581 | 540 | if r.checked_out { |
|
| 582 | 541 | super::workspace::where_now(&repo); |
|
| 583 | 542 | } |
|
| … | 20 unchanged lines |
||
| 604 | 563 | } |
|
| 605 | 564 | others_pulled(&r.others); |
|
| 606 | 565 | others_behind(&repo, &remote); |
|
| 607 | - | warn_skipped(&r.skipped); |
|
| 608 | 566 | subrepos_behind(&repo); |
|
| 609 | 567 | super::workspace::where_now(&repo); |
|
| 610 | 568 | Ok(0) |
|
| … | 42 unchanged lines |
||
| 653 | 611 | let mut code = 0; |
|
| 654 | 612 | for r in results { |
|
| 655 | 613 | if r.ok { |
|
| 656 | - | let done = if r.name.starts_with("tags/") { |
|
| 657 | - | "Withdrew" |
|
| 658 | - | } else { |
|
| 659 | - | "Deleted" |
|
| 660 | - | }; |
|
| 661 | 614 | println!( |
|
| 662 | 615 | "{} {}", |
|
| 663 | - | ui::done(done), |
|
| 616 | + | ui::done("Deleted"), |
|
| 664 | 617 | ui::id(format!("{remote}/{}", r.name)) |
|
| 665 | 618 | ); |
|
| 666 | 619 | } else { |
|
| … | 22 unchanged lines |
||
| 689 | 642 | let sp = ui::spinner(&format!("Pushing to {remote}…")); |
|
| 690 | 643 | let bookmark_name = bookmark.map_or_else(|| repo.config.bookmark.clone(), String::from); |
|
| 691 | 644 | // A long push goes in parts: the spinner says which. |
|
| 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 | - | )?; |
|
| 645 | + | let results = repo.push_bookmark_with(&remote, &bookmark_name, force, &mut |m| { |
|
| 646 | + | sp.set(&format!("Pushing to {remote}… {m}")) |
|
| 647 | + | })?; |
|
| 699 | 648 | sp.clear(); |
|
| 700 | 649 | if as_json { |
|
| 701 | 650 | // The state each accepted bookmark now holds on the remote (its local |
|
| … | 55 unchanged lines |
||
| 757 | 706 | tls: Option<(PathBuf, PathBuf)>, |
|
| 758 | 707 | ) -> Result<i32> { |
|
| 759 | 708 | let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding {bind}"))?; |
|
| 760 | - | let root = oxy_core::portable::canonical(&root)?; |
|
| 709 | + | let root = root.canonicalize()?; |
|
| 761 | 710 | let tls = tls.map(|(cert, key)| oxy_server::TlsFiles { cert, key }); |
|
| 762 | 711 | println!( |
|
| 763 | 712 | "Serving repositories under {} on {}://{bind}/<repo>/oxy/v1/ ({})", |
|
| … | 13 unchanged lines |
||
| 777 | 726 | eprintln!("warning: serving plain HTTP on a non-loopback address; put a TLS terminator in front or pass --tls-cert/--tls-key"); |
|
| 778 | 727 | } |
|
| 779 | 728 | oxy_server::run_blocking(listener, oxy_server::ServerConfig { root, tokens, tls })?; |
|
| 780 | - | Ok(0) |
|
| 781 | - | } |
|
| 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 | 729 | Ok(0) |
|
| 810 | 730 | } |
|
| 811 | 731 | ||
| … | 424 unchanged lines |
||
| 1236 | 1156 | Ok(0) |
|
| 1237 | 1157 | } |
|
| 1238 | 1158 | ||
| 1239 | - | pub fn submodule_remove(path: &str, delete: bool, force: bool) -> Result<i32> { |
|
| 1159 | + | pub fn submodule_remove(path: &str) -> Result<i32> { |
|
| 1240 | 1160 | 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 | - | } |
|
| 1254 | 1161 | 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 | - | } |
|
| 1274 | 1162 | println!( |
|
| 1275 | 1163 | "{} subrepo {} {}", |
|
| 1276 | 1164 | ui::done("Removed"), |
|
| 1277 | 1165 | ui::path(path), |
|
| 1278 | - | ui::dim("and its directory (commit to record the removal)") |
|
| 1166 | + | ui::dim("(directory left in place; commit to record the removal)") |
|
| 1279 | 1167 | ); |
|
| 1280 | 1168 | Ok(0) |
|
| 1281 | - | } |
|
| 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 | 1169 | } |
|
| 1308 | 1170 | ||
| 1309 | 1171 | pub fn deepen(remote: &str, depth: u32) -> Result<i32> { |
|
| … | 18 unchanged lines |
||
| 1328 | 1190 | } |
|
| 1329 | 1191 | )); |
|
| 1330 | 1192 | Ok(0) |
|
| 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 | 1193 | } |
removed src/cli/tlog.rs +0 −205
| 1 | - | //! `oxy tlog verify` (spec §14.8, oxygenhub#238): audit a hub's whole |
|
| 2 | - | //! transparency log and list the entries that concern you. |
|
| 3 | - | ||
| 4 | - | use super::json; |
|
| 5 | - | use super::open; |
|
| 6 | - | use super::ui; |
|
| 7 | - | use anyhow::{anyhow, Result}; |
|
| 8 | - | use oxy_core::hash::KeyId; |
|
| 9 | - | use std::path::{Path, PathBuf}; |
|
| 10 | - | ||
| 11 | - | pub fn verify(hub: Option<&str>, key: Option<&str>, all: bool, as_json: bool) -> Result<i32> { |
|
| 12 | - | let repo = open()?; |
|
| 13 | - | let remote = repo.default_remote(); |
|
| 14 | - | let url = match hub { |
|
| 15 | - | Some(h) if h.contains("://") => h.to_string(), |
|
| 16 | - | Some(h) => format!("https://{h}"), |
|
| 17 | - | None => repo.config.remotes.get(&remote).cloned().ok_or_else(|| { |
|
| 18 | - | anyhow!("name the hub to check (this checkout has no remote {remote})") |
|
| 19 | - | })?, |
|
| 20 | - | }; |
|
| 21 | - | let ca = repo |
|
| 22 | - | .config |
|
| 23 | - | .remote_cas |
|
| 24 | - | .get(&remote) |
|
| 25 | - | .map(Path::new) |
|
| 26 | - | .filter(|_| hub.is_none()); |
|
| 27 | - | let audit = repo.audit_log(&url, ca).map_err(|e| anyhow!("{e}"))?; |
|
| 28 | - | let key = match key { |
|
| 29 | - | Some(k) => Some( |
|
| 30 | - | KeyId::from_hex(k) |
|
| 31 | - | .ok_or_else(|| anyhow!("--key takes a full KeyId (64 hex digits)"))?, |
|
| 32 | - | ), |
|
| 33 | - | None => None, |
|
| 34 | - | }; |
|
| 35 | - | let rid = repo.id().ok(); |
|
| 36 | - | let listed: Vec<(usize, &oxy_core::tlog::Entry)> = audit |
|
| 37 | - | .entries |
|
| 38 | - | .iter() |
|
| 39 | - | .enumerate() |
|
| 40 | - | .filter(|(_, e)| match (&key, all) { |
|
| 41 | - | (_, true) => true, |
|
| 42 | - | (Some(k), _) => e.signers.contains(k), |
|
| 43 | - | (None, _) => Some(e.repo) == rid, |
|
| 44 | - | }) |
|
| 45 | - | .collect(); |
|
| 46 | - | let what = match (&key, all) { |
|
| 47 | - | (_, true) => "every entry".to_string(), |
|
| 48 | - | (Some(k), _) => format!("entries signed by {}", k.short(12)), |
|
| 49 | - | (None, _) => "this repository's entries".to_string(), |
|
| 50 | - | }; |
|
| 51 | - | if as_json { |
|
| 52 | - | println!( |
|
| 53 | - | "{}", |
|
| 54 | - | json::obj([ |
|
| 55 | - | ("hub", json::s(&audit.hub)), |
|
| 56 | - | ("origin", json::s(&audit.checkpoint.origin)), |
|
| 57 | - | ("size", json::n(audit.checkpoint.size)), |
|
| 58 | - | ( |
|
| 59 | - | "root", |
|
| 60 | - | json::s(&oxy_core::hash::ObjectRef(audit.checkpoint.root).to_hex()) |
|
| 61 | - | ), |
|
| 62 | - | ( |
|
| 63 | - | "witnesses", |
|
| 64 | - | json::arr(audit.witnessed.iter().map(|(name, t)| { |
|
| 65 | - | json::obj([ |
|
| 66 | - | ("name", json::s(name)), |
|
| 67 | - | ("time_ms", json::n(t.saturating_mul(1000))), |
|
| 68 | - | ]) |
|
| 69 | - | })), |
|
| 70 | - | ), |
|
| 71 | - | ( |
|
| 72 | - | "entries", |
|
| 73 | - | json::arr(listed.iter().map(|(i, e)| { |
|
| 74 | - | json::obj([ |
|
| 75 | - | ("index", json::n(*i)), |
|
| 76 | - | ("kind", json::s(&e.kind)), |
|
| 77 | - | ( |
|
| 78 | - | "repo", |
|
| 79 | - | json::s( |
|
| 80 | - | &e.repo |
|
| 81 | - | .iter() |
|
| 82 | - | .map(|b| format!("{b:02x}")) |
|
| 83 | - | .collect::<String>(), |
|
| 84 | - | ), |
|
| 85 | - | ), |
|
| 86 | - | ("object", json::s(&e.object.to_hex())), |
|
| 87 | - | ( |
|
| 88 | - | "signers", |
|
| 89 | - | json::arr(e.signers.iter().map(|k| json::s(&k.to_hex()))), |
|
| 90 | - | ), |
|
| 91 | - | ("received_ms", json::n(e.received)), |
|
| 92 | - | ]) |
|
| 93 | - | })), |
|
| 94 | - | ), |
|
| 95 | - | ]) |
|
| 96 | - | ); |
|
| 97 | - | return Ok(0); |
|
| 98 | - | } |
|
| 99 | - | for n in &audit.notes { |
|
| 100 | - | eprintln!("{} {n}", ui::caution()); |
|
| 101 | - | } |
|
| 102 | - | println!( |
|
| 103 | - | "{} {}: {} entries make the root its log key signed, and extend what you saw before", |
|
| 104 | - | ui::done("Verified"), |
|
| 105 | - | ui::path(&audit.checkpoint.origin), |
|
| 106 | - | audit.checkpoint.size |
|
| 107 | - | ); |
|
| 108 | - | // Its cosignatures: an upper bound on when every entry in it was made |
|
| 109 | - | // (spec §14.8, *Integrated time*). |
|
| 110 | - | if audit.witnessed.is_empty() { |
|
| 111 | - | println!( |
|
| 112 | - | " {} {}", |
|
| 113 | - | ui::note(), |
|
| 114 | - | ui::dim("cosigned by no witness you trust") |
|
| 115 | - | ); |
|
| 116 | - | } else { |
|
| 117 | - | let by: Vec<String> = audit |
|
| 118 | - | .witnessed |
|
| 119 | - | .iter() |
|
| 120 | - | .map(|(name, t)| format!("{name} {}", ui::ago(t.saturating_mul(1000)))) |
|
| 121 | - | .collect(); |
|
| 122 | - | println!( |
|
| 123 | - | " {} {}", |
|
| 124 | - | ui::note(), |
|
| 125 | - | ui::dim(format!("cosigned by {}", by.join(", "))) |
|
| 126 | - | ); |
|
| 127 | - | } |
|
| 128 | - | if listed.is_empty() { |
|
| 129 | - | println!(" {} {}", ui::note(), ui::dim(format!("no {what} in it"))); |
|
| 130 | - | return Ok(0); |
|
| 131 | - | } |
|
| 132 | - | println!( |
|
| 133 | - | " {} {}", |
|
| 134 | - | ui::note(), |
|
| 135 | - | ui::dim(format!("{} of {what}:", listed.len())) |
|
| 136 | - | ); |
|
| 137 | - | for (i, e) in listed { |
|
| 138 | - | let signers: Vec<String> = e.signers.iter().map(|k| k.short(12)).collect(); |
|
| 139 | - | println!( |
|
| 140 | - | " #{i:<6} {:<11} {} {} {}", |
|
| 141 | - | e.kind, |
|
| 142 | - | ui::hash(e.object.short(12)), |
|
| 143 | - | ui::dim(if signers.is_empty() { |
|
| 144 | - | "unsigned".to_string() |
|
| 145 | - | } else { |
|
| 146 | - | format!("by {}", signers.join(", ")) |
|
| 147 | - | }), |
|
| 148 | - | ui::dim(ui::ago(e.received)) |
|
| 149 | - | ); |
|
| 150 | - | } |
|
| 151 | - | Ok(0) |
|
| 152 | - | } |
|
| 153 | - | ||
| 154 | - | /// `oxy witness serve`: cosign the named logs' checkpoints. |
|
| 155 | - | pub fn witness_serve( |
|
| 156 | - | name: &str, |
|
| 157 | - | logs: &[String], |
|
| 158 | - | state: PathBuf, |
|
| 159 | - | bind: &str, |
|
| 160 | - | tls: Option<(PathBuf, PathBuf)>, |
|
| 161 | - | ) -> Result<i32> { |
|
| 162 | - | let home = oxy_core::repo::default_home(); |
|
| 163 | - | let (identity, sk) = oxy_core::repo::home_key(&home) |
|
| 164 | - | .map_err(|e| anyhow!("{e}"))? |
|
| 165 | - | .ok_or_else(|| anyhow!("{} names no key (author.key) with its secret: a witness signs with it; oxy identity create", home.display()))?; |
|
| 166 | - | let mut parsed = Vec::new(); |
|
| 167 | - | for l in logs { |
|
| 168 | - | let (origin, file) = l |
|
| 169 | - | .split_once('=') |
|
| 170 | - | .ok_or_else(|| anyhow!("--log takes ORIGIN=FILE, as oxygenhub.io/log=hub.identity"))?; |
|
| 171 | - | let bytes = std::fs::read(file).map_err(|e| anyhow!("{file}: {e}"))?; |
|
| 172 | - | let key = |
|
| 173 | - | oxy_core::identity::Identity::decode(&bytes).map_err(|e| anyhow!("{file}: {e}"))?; |
|
| 174 | - | parsed.push((origin.to_string(), key)); |
|
| 175 | - | } |
|
| 176 | - | let listener = std::net::TcpListener::bind(bind).map_err(|e| anyhow!("binding {bind}: {e}"))?; |
|
| 177 | - | println!( |
|
| 178 | - | "{} {} logs as {} with key {} on {}://{bind}/add-checkpoint", |
|
| 179 | - | ui::done("Witnessing"), |
|
| 180 | - | parsed.len(), |
|
| 181 | - | ui::path(name), |
|
| 182 | - | identity.hash().short(12), |
|
| 183 | - | if tls.is_some() { "https" } else { "http" } |
|
| 184 | - | ); |
|
| 185 | - | for (o, k) in &parsed { |
|
| 186 | - | println!( |
|
| 187 | - | " {} {} {}", |
|
| 188 | - | ui::note(), |
|
| 189 | - | o, |
|
| 190 | - | ui::dim(format!("signed by {}", k.hash().short(12))) |
|
| 191 | - | ); |
|
| 192 | - | } |
|
| 193 | - | oxy_server::witness::run_blocking( |
|
| 194 | - | listener, |
|
| 195 | - | oxy_server::witness::WitnessConfig { |
|
| 196 | - | name: name.to_string(), |
|
| 197 | - | identity, |
|
| 198 | - | sk, |
|
| 199 | - | logs: parsed, |
|
| 200 | - | state, |
|
| 201 | - | tls: tls.map(|(cert, key)| oxy_server::TlsFiles { cert, key }), |
|
| 202 | - | }, |
|
| 203 | - | )?; |
|
| 204 | - | Ok(0) |
|
| 205 | - | } |
modified src/cli/workspace.rs +20 −346
| 1 | 1 | //! Commands that act on the local repository and working copy. |
|
| 2 | 2 | ||
| 3 | 3 | use super::format::{ |
|
| 4 | - | author_name, awaits_seal, deps_line, deps_of, plural, short_id, signature_line, |
|
| 5 | - | signature_mark_of, subject_of, via, when, |
|
| 4 | + | author_name, deps_line, deps_of, plural, short_id, signature_line, signature_mark_of, |
|
| 5 | + | subject_of, via, when, |
|
| 6 | 6 | }; |
|
| 7 | 7 | use super::json; |
|
| 8 | 8 | use super::open; |
|
| … | 87 unchanged lines |
||
| 96 | 96 | "to leave them out quietly, list them in .oxyignore; to track one, \ |
|
| 97 | 97 | raise snapshot.max-new-file-size (oxy config snapshot.max-new-file-size 2G)" |
|
| 98 | 98 | ) |
|
| 99 | - | ); |
|
| 100 | - | } |
|
| 101 | - | ||
| 102 | - | /// Name what a checkout removed from the working copy (oxygen#102): a |
|
| 103 | - | /// switch to a bookmark lacking files takes them off disk, which an |
|
| 104 | - | /// editor open on the tree notices before a person reading a count does. |
|
| 105 | - | fn print_removed(r: &oxy_core::repo::CheckoutReport) { |
|
| 106 | - | if r.removed == 0 { |
|
| 107 | - | return; |
|
| 108 | - | } |
|
| 109 | - | let more = r.removed.saturating_sub(r.removed_paths.len()); |
|
| 110 | - | let names = r.removed_paths.join(", "); |
|
| 111 | - | let tail = if more > 0 { |
|
| 112 | - | format!(" and {more} more") |
|
| 113 | - | } else { |
|
| 114 | - | String::new() |
|
| 115 | - | }; |
|
| 116 | - | let lead = if r.removed >= 20 { |
|
| 117 | - | ui::caution() |
|
| 118 | - | } else { |
|
| 119 | - | ui::note() |
|
| 120 | - | }; |
|
| 121 | - | println!( |
|
| 122 | - | " {} {}", |
|
| 123 | - | lead, |
|
| 124 | - | ui::dim(format!( |
|
| 125 | - | "removed from disk (the bookmark does not have them): {names}{tail}" |
|
| 126 | - | )) |
|
| 127 | - | ); |
|
| 128 | - | } |
|
| 129 | - | ||
| 130 | - | /// Name the work a switch carried (oxygen#120): it is still on disk, as it |
|
| 131 | - | /// was, on the new bookmark. |
|
| 132 | - | fn print_carried(paths: &[String]) { |
|
| 133 | - | if paths.is_empty() { |
|
| 134 | - | return; |
|
| 135 | - | } |
|
| 136 | - | let shown: Vec<&str> = paths.iter().take(10).map(String::as_str).collect(); |
|
| 137 | - | let more = paths.len().saturating_sub(shown.len()); |
|
| 138 | - | println!( |
|
| 139 | - | " {} {}", |
|
| 140 | - | ui::note(), |
|
| 141 | - | ui::dim(format!( |
|
| 142 | - | "carried, as they were: {}{}", |
|
| 143 | - | shown.join(", "), |
|
| 144 | - | if more > 0 { |
|
| 145 | - | format!(" and {more} more") |
|
| 146 | - | } else { |
|
| 147 | - | String::new() |
|
| 148 | - | } |
|
| 149 | - | )) |
|
| 150 | 99 | ); |
|
| 151 | 100 | } |
|
| 152 | 101 | ||
| … | 91 unchanged lines |
||
| 244 | 193 | ); |
|
| 245 | 194 | print_behind(&repo); |
|
| 246 | 195 | print_statuses(&statuses); |
|
| 247 | - | print_pin_moves(&pin_moves(&repo, None)); |
|
| 248 | 196 | print_untracked_nested(&repo, &repo.untracked_nested()); |
|
| 249 | 197 | print_skipped_large(&repo); |
|
| 250 | 198 | return Ok(0); |
|
| … | 25 unchanged lines |
||
| 276 | 224 | ); |
|
| 277 | 225 | print_behind(&repo); |
|
| 278 | 226 | print_statuses(&snap.statuses); |
|
| 279 | - | print_pin_moves(&pin_moves(&repo, None)); |
|
| 280 | 227 | print_untracked_nested(&repo, &snap.untracked_nested); |
|
| 281 | 228 | print_skipped_large(&repo); |
|
| 282 | 229 | if !snap.base_snapshot.is_conflicted() { |
|
| … | 25 unchanged lines |
||
| 308 | 255 | _ => ui::err(format!("{} unresolved", ui::glyph("✱", "!"))), |
|
| 309 | 256 | }; |
|
| 310 | 257 | 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 | - | } |
|
| 333 | 258 | } |
|
| 334 | 259 | } |
|
| 335 | 260 | Ok(2) |
|
| … | 87 unchanged lines |
||
| 423 | 348 | } else { |
|
| 424 | 349 | oxy_core::repo::Selection::Paths(paths.to_vec()) |
|
| 425 | 350 | }; |
|
| 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 | - | }; |
|
| 433 | 351 | let sp = ui::spinner("Committing…"); |
|
| 434 | 352 | let (h, partial) = repo.commit_selection_partial(message, &deps, &selection)?; |
|
| 435 | 353 | // The metadata only: a first commit's change holds the whole tree. |
|
| … | 5 unchanged lines |
||
| 441 | 359 | ui::hash(format!("#{}", h.short(12))), |
|
| 442 | 360 | ui::subject(subject_of(message)) |
|
| 443 | 361 | )); |
|
| 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 | - | } |
|
| 461 | 362 | print_untracked_nested(&repo, &repo.untracked_nested()); |
|
| 462 | 363 | print_skipped_large(&repo); |
|
| 463 | 364 | // Only a commit that took part of the working copy leaves anything. |
|
| … | 14 unchanged lines |
||
| 478 | 379 | Ok(0) |
|
| 479 | 380 | } |
|
| 480 | 381 | ||
| 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 | - | ||
| 556 | 382 | pub fn blame(path: &str, json: bool) -> Result<i32> { |
|
| 557 | 383 | let repo = open()?; |
|
| 558 | 384 | let lines = repo.blame(path)?; |
|
| … | 368 unchanged lines |
||
| 927 | 753 | if let Some(agent) = via(&repo, &status, &c.author) { |
|
| 928 | 754 | head.push(ui::dim(format!("via {}", agent.name))); |
|
| 929 | 755 | } |
|
| 930 | - | head.push(signature_mark_of( |
|
| 931 | - | &status, |
|
| 932 | - | awaits_seal(&repo, &status, &c.author), |
|
| 933 | - | )); |
|
| 756 | + | head.push(signature_mark_of(&status)); |
|
| 934 | 757 | } |
|
| 935 | 758 | println!("{}{}", node_line(row, &bullet), head.join(" ")); |
|
| 936 | 759 | let subject = if e.is_working_copy && c.message.is_empty() { |
|
| … | 268 unchanged lines |
||
| 1205 | 1028 | print!("{}", render_change(&c, &base, &snapshot)); |
|
| 1206 | 1029 | } |
|
| 1207 | 1030 | None => { |
|
| 1208 | - | // Nothing changed: nothing to show, and no base to load. |
|
| 1209 | - | let Some((wc, base, snapshot)) = repo.working_copy_with_base_unless_unchanged()? else { |
|
| 1210 | - | return Ok(0); |
|
| 1211 | - | }; |
|
| 1031 | + | let (wc, base, snapshot) = repo.working_copy_with_base()?; |
|
| 1212 | 1032 | let text = { |
|
| 1213 | 1033 | let _t = oxy_core::timing::phase("diff: render the change"); |
|
| 1214 | 1034 | render_change(&wc, &base, &snapshot) |
|
| … | 141 unchanged lines |
||
| 1356 | 1176 | Ok(0) |
|
| 1357 | 1177 | } |
|
| 1358 | 1178 | ||
| 1359 | - | pub fn show(change: Option<&str>, revset: Option<&str>, stat: bool, as_json: bool) -> Result<i32> { |
|
| 1179 | + | pub fn show(change: Option<&str>, revset: Option<&str>, as_json: bool) -> Result<i32> { |
|
| 1360 | 1180 | let repo = open()?; |
|
| 1361 | 1181 | repo.snapshot_status("oxy show")?; |
|
| 1362 | 1182 | let Some(r) = revset else { |
|
| 1363 | 1183 | let h = repo.resolve_change(change.unwrap_or("@"))?; |
|
| 1364 | - | return show_one(&repo, h, as_json, false, stat); |
|
| 1184 | + | return show_one(&repo, h, as_json, false); |
|
| 1365 | 1185 | }; |
|
| 1366 | 1186 | // Several changes: a JSON array, or the changes one after another. |
|
| 1367 | 1187 | let hashes = repo.revset(r)?.hashes; |
|
| … | 3 unchanged lines |
||
| 1371 | 1191 | if i > 0 { |
|
| 1372 | 1192 | print!(","); |
|
| 1373 | 1193 | } |
|
| 1374 | - | show_one(&repo, *h, true, true, stat)?; |
|
| 1194 | + | show_one(&repo, *h, true, true)?; |
|
| 1375 | 1195 | } |
|
| 1376 | 1196 | println!("]"); |
|
| 1377 | 1197 | return Ok(0); |
|
| … | 6 unchanged lines |
||
| 1384 | 1204 | if i > 0 { |
|
| 1385 | 1205 | println!(); |
|
| 1386 | 1206 | } |
|
| 1387 | - | show_one(&repo, *h, false, false, stat)?; |
|
| 1207 | + | show_one(&repo, *h, false, false)?; |
|
| 1388 | 1208 | } |
|
| 1389 | 1209 | Ok(0) |
|
| 1390 | - | } |
|
| 1391 | - | ||
| 1392 | - | /// Lines added and removed in one file's diff. |
|
| 1393 | - | fn counts(f: &oxy_core::repo::FileDiff) -> (usize, usize) { |
|
| 1394 | - | f.hunks |
|
| 1395 | - | .iter() |
|
| 1396 | - | .fold((0, 0), |(a, r), h| (a + h.added.len(), r + h.removed.len())) |
|
| 1397 | - | } |
|
| 1398 | - | ||
| 1399 | - | /// What `--stat` prints (oxygen#119): each file the change touched, its |
|
| 1400 | - | /// lines added and removed, then the total. |
|
| 1401 | - | fn print_stat(files: &[oxy_core::repo::FileDiff]) { |
|
| 1402 | - | let width = files |
|
| 1403 | - | .iter() |
|
| 1404 | - | .map(|f| f.path.chars().count()) |
|
| 1405 | - | .max() |
|
| 1406 | - | .unwrap_or(0) |
|
| 1407 | - | .min(60); |
|
| 1408 | - | let (mut added, mut removed) = (0, 0); |
|
| 1409 | - | for f in files { |
|
| 1410 | - | let (a, r) = counts(f); |
|
| 1411 | - | added += a; |
|
| 1412 | - | removed += r; |
|
| 1413 | - | let path = match &f.from { |
|
| 1414 | - | Some(from) => format!("{from} → {}", f.path), |
|
| 1415 | - | None => f.path.clone(), |
|
| 1416 | - | }; |
|
| 1417 | - | println!( |
|
| 1418 | - | " {path:<width$} {} {} {}", |
|
| 1419 | - | ui::ok(format!("{:>6}", format!("+{a}"))), |
|
| 1420 | - | ui::err(format!("{:>6}", format!("-{r}"))), |
|
| 1421 | - | ui::dim(&f.kind) |
|
| 1422 | - | ); |
|
| 1423 | - | } |
|
| 1424 | - | println!( |
|
| 1425 | - | " {}", |
|
| 1426 | - | ui::dim(format!( |
|
| 1427 | - | "{} changed, {added} added, {removed} removed", |
|
| 1428 | - | plural(files.len(), "file") |
|
| 1429 | - | )) |
|
| 1430 | - | ); |
|
| 1431 | 1210 | } |
|
| 1432 | 1211 | ||
| 1433 | 1212 | /// One change, as `oxy show` prints it; `inline` JSON goes without its |
|
| 1434 | - | /// newline, for an array. `stat` lists the files instead of the diff. |
|
| 1435 | - | fn show_one(repo: &Repo, h: ChangeHash, as_json: bool, inline: bool, stat: bool) -> Result<i32> { |
|
| 1213 | + | /// newline, for an array. |
|
| 1214 | + | fn show_one(repo: &Repo, h: ChangeHash, as_json: bool, inline: bool) -> Result<i32> { |
|
| 1436 | 1215 | let c = repo.store().get_change(&h)?; |
|
| 1437 | 1216 | if as_json { |
|
| 1438 | 1217 | let files = repo.diff_files(Some(&h))?; |
|
| … | 9 unchanged lines |
||
| 1448 | 1227 | body.push_str(&format!( |
|
| 1449 | 1228 | ",{}:{}}}", |
|
| 1450 | 1229 | json::s("files"), |
|
| 1451 | - | json::arr(files.iter().map(|f| { |
|
| 1452 | - | let (a, r) = counts(f); |
|
| 1453 | - | json::obj([ |
|
| 1454 | - | ("path", json::s(&f.path)), |
|
| 1455 | - | ("kind", json::s(&f.kind)), |
|
| 1456 | - | ("from", json::opt(f.from.as_deref(), json::s)), |
|
| 1457 | - | ("added", a.to_string()), |
|
| 1458 | - | ("removed", r.to_string()), |
|
| 1459 | - | ]) |
|
| 1460 | - | })) |
|
| 1230 | + | json::arr(files.iter().map(|f| json::obj([ |
|
| 1231 | + | ("path", json::s(&f.path)), |
|
| 1232 | + | ("kind", json::s(&f.kind)), |
|
| 1233 | + | ("from", json::opt(f.from.as_deref(), json::s)) |
|
| 1234 | + | ]))) |
|
| 1461 | 1235 | )); |
|
| 1462 | 1236 | if inline { |
|
| 1463 | 1237 | print!("{body}"); |
|
| … | 51 unchanged lines |
||
| 1515 | 1289 | println!(" {line}"); |
|
| 1516 | 1290 | } |
|
| 1517 | 1291 | println!(); |
|
| 1518 | - | if stat { |
|
| 1519 | - | print_stat(&repo.diff_files(Some(&h))?); |
|
| 1520 | - | } else { |
|
| 1521 | - | print!("{}", repo.describe_change(&h)?); |
|
| 1522 | - | } |
|
| 1292 | + | print!("{}", repo.describe_change(&h)?); |
|
| 1523 | 1293 | Ok(0) |
|
| 1524 | 1294 | } |
|
| 1525 | 1295 | ||
| … | 41 unchanged lines |
||
| 1567 | 1337 | format!("; carried {}", plural(carried.len(), "changed path")) |
|
| 1568 | 1338 | } |
|
| 1569 | 1339 | )); |
|
| 1570 | - | print_removed(&r); |
|
| 1571 | 1340 | for n in &r.notes { |
|
| 1572 | 1341 | println!(" {} {n}", ui::note()); |
|
| 1573 | 1342 | } |
|
| 1574 | - | print_kept_both(&r); |
|
| 1575 | 1343 | if r.conflicted { |
|
| 1576 | 1344 | println!( |
|
| 1577 | 1345 | " {} {}", |
|
| … | 5 unchanged lines |
||
| 1583 | 1351 | Ok(0) |
|
| 1584 | 1352 | } |
|
| 1585 | 1353 | ||
| 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> { |
|
| 1354 | + | pub fn resolve(as_json: bool) -> Result<i32> { |
|
| 1605 | 1355 | let repo = open()?; |
|
| 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 | - | } |
|
| 1356 | + | let snap = repo.snapshot("oxy resolve")?; |
|
| 1632 | 1357 | if as_json { |
|
| 1633 | 1358 | println!("{}", repo.status_json(&snap)?); |
|
| 1634 | 1359 | return Ok(if snap.base_snapshot.is_conflicted() { |
|
| … | 320 unchanged lines |
||
| 1955 | 1680 | repo.config.snapshot_retention_days = 0; |
|
| 1956 | 1681 | } |
|
| 1957 | 1682 | let sp = ui::spinner("Packing and pruning…"); |
|
| 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}…")))?; |
|
| 1683 | + | let r = repo.gc_with(now, &mut |phase| sp.set(&format!("{phase}…")))?; |
|
| 1961 | 1684 | sp.clear(); |
|
| 1962 | 1685 | if r.packed.objects > 0 { |
|
| 1963 | 1686 | println!( |
|
| … | 24 unchanged lines |
||
| 1988 | 1711 | }, |
|
| 1989 | 1712 | plural(repo.store().pack_count(), "pack") |
|
| 1990 | 1713 | ); |
|
| 1991 | - | // What earlier repacks replaced stays an hour for readers still using |
|
| 1992 | - | // it (oxygenhub#249); a collection after that deletes it. |
|
| 1993 | - | let kept = if r.retired.kept > 0 { |
|
| 1994 | - | format!( |
|
| 1995 | - | "{} retired less than an hour ago kept for readers", |
|
| 1996 | - | plural(r.retired.kept, "file") |
|
| 1997 | - | ) |
|
| 1998 | - | } else { |
|
| 1999 | - | String::new() |
|
| 2000 | - | }; |
|
| 2001 | - | if r.retired.packs + r.retired.loose > 0 { |
|
| 2002 | - | println!( |
|
| 2003 | - | "Deleted {} and {} retired by earlier repacks{}.", |
|
| 2004 | - | plural(r.retired.packs, "pack"), |
|
| 2005 | - | plural(r.retired.loose, "loose object"), |
|
| 2006 | - | if kept.is_empty() { |
|
| 2007 | - | String::new() |
|
| 2008 | - | } else { |
|
| 2009 | - | format!("; {kept}") |
|
| 2010 | - | } |
|
| 2011 | - | ); |
|
| 2012 | - | } else if !kept.is_empty() { |
|
| 2013 | - | println!("{kept}."); |
|
| 2014 | - | } |
|
| 2015 | 1714 | Ok(0) |
|
| 2016 | 1715 | } |
|
| 2017 | 1716 | ||
| … | 261 unchanged lines |
||
| 2279 | 1978 | Ok(0) |
|
| 2280 | 1979 | } |
|
| 2281 | 1980 | ||
| 2282 | - | pub fn amend(message: Option<&str>, into: Option<&str>, paths: &[String]) -> Result<i32> { |
|
| 1981 | + | pub fn amend(message: Option<&str>, into: Option<&str>) -> Result<i32> { |
|
| 2283 | 1982 | let repo = open()?; |
|
| 2284 | 1983 | let before = repo.view()?; |
|
| 2285 | 1984 | let new = match into { |
|
| … | 1 unchanged lines |
||
| 2287 | 1986 | let target = repo.resolve_change(spec)?; |
|
| 2288 | 1987 | repo.amend_into(&target, message)? |
|
| 2289 | 1988 | } |
|
| 2290 | - | None if !paths.is_empty() => repo.amend_paths(message, paths)?, |
|
| 2291 | 1989 | None => repo.amend(message)?, |
|
| 2292 | 1990 | }; |
|
| 2293 | 1991 | let c = repo.store().get_change_meta(&new)?; |
|
| … | 202 unchanged lines |
||
| 2496 | 2194 | Ok(0) |
|
| 2497 | 2195 | } |
|
| 2498 | 2196 | ||
| 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 | - | ||
| 2520 | 2197 | pub fn tag_list(as_json: bool) -> Result<i32> { |
|
| 2521 | 2198 | let repo = open()?; |
|
| 2522 | 2199 | let tags = repo.tags()?; |
|
| … | 581 unchanged lines |
||
| 3104 | 2781 | format!("; carried {}", plural(carried.len(), "changed path")) |
|
| 3105 | 2782 | } |
|
| 3106 | 2783 | )); |
|
| 3107 | - | print_removed(&r); |
|
| 3108 | - | print_carried(&carried); |
|
| 3109 | - | print_kept_both(&r); |
|
| 3110 | 2784 | if r.conflicted { |
|
| 3111 | 2785 | println!( |
|
| 3112 | 2786 | " {} {}", |
|
| … | 166 unchanged lines |
modified src/main.rs +0 −3
| … | 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 | - | } |
|
| 41 | 38 | drop(command); |
|
| 42 | 39 | oxy_core::timing::memory_summary(); |
|
| 43 | 40 | match result { |
|
| … | 8 unchanged lines |
removed tests/cli_colocate.rs +0 −110
| 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 +0 −207
| … | 163 unchanged lines |
||
| 164 | 164 | assert_eq!(by_path["changes"].as_array().unwrap().len(), 2, "{by_path}"); |
|
| 165 | 165 | let show = json(r, &home, &["show", "--json", &format!("#{}", &hash[..12])]); |
|
| 166 | 166 | assert_eq!(show["files"][0]["path"], "b.txt"); |
|
| 167 | - | // oxygen#119: lines added and removed per file, and --stat. |
|
| 168 | - | assert!(show["files"][0]["added"].as_u64().unwrap() >= 1, "{show}"); |
|
| 169 | - | let (code, out, _) = oxy(r, &home, &["show", "--stat", &format!("#{}", &hash[..12])]); |
|
| 170 | - | let out = String::from_utf8_lossy(&out); |
|
| 171 | - | assert!( |
|
| 172 | - | code == 0 && out.contains("b.txt") && out.contains("1 file changed"), |
|
| 173 | - | "{out}" |
|
| 174 | - | ); |
|
| 175 | - | assert!(!out.contains("@@"), "the files, not the diff: {out}"); |
|
| 176 | 167 | let verify = json(r, &home, &["verify", "--json"]); |
|
| 177 | 168 | assert!(verify |
|
| 178 | 169 | .as_array() |
|
| … | 35 unchanged lines |
||
| 214 | 205 | assert_eq!(oxy(r, &home, &["policy", "init"]).0, 0); |
|
| 215 | 206 | let policy = json(r, &home, &["policy", "show", "--json"]); |
|
| 216 | 207 | assert_eq!(policy["roots"][0]["name"], "Ada"); |
|
| 217 | - | // Keyless (oxygenhub#241): a hub's certified subject may sign too. |
|
| 218 | - | let hub = |
|
| 219 | - | oxy_core::identity::SecretKey::generate().identity("oxygenhub.io", "[email protected]", 1); |
|
| 220 | - | let hub_file = r.join("hub.identity"); |
|
| 221 | - | std::fs::write(&hub_file, hub.encode()).unwrap(); |
|
| 222 | - | let grant = [ |
|
| 223 | - | "policy", |
|
| 224 | - | "grant-subject", |
|
| 225 | - | "repo:ada/demo:bookmark:main", |
|
| 226 | - | "--issuer", |
|
| 227 | - | hub_file.to_str().unwrap(), |
|
| 228 | - | "--scope", |
|
| 229 | - | "refs", |
|
| 230 | - | ]; |
|
| 231 | - | assert_eq!(oxy(r, &home, &grant).0, 0); |
|
| 232 | - | let policy = json(r, &home, &["policy", "show", "--json"]); |
|
| 233 | - | let subjects = &policy["rules"][0]["subjects"][0]; |
|
| 234 | 208 | assert_eq!( |
|
| 235 | - | ( |
|
| 236 | - | subjects["subject"].as_str(), |
|
| 237 | - | subjects["issuer"]["name"].as_str(), |
|
| 238 | - | subjects["scope"].as_str() |
|
| 239 | - | ), |
|
| 240 | - | ( |
|
| 241 | - | Some("repo:ada/demo:bookmark:main"), |
|
| 242 | - | Some("oxygenhub.io"), |
|
| 243 | - | Some("refs") |
|
| 244 | - | ) |
|
| 245 | - | ); |
|
| 246 | - | std::fs::remove_file(&hub_file).unwrap(); |
|
| 247 | - | assert_eq!( |
|
| 248 | 209 | oxy(r, &home, &["tag", "create", "v1", "-m", "first tag"]).0, |
|
| 249 | 210 | 0 |
|
| 250 | 211 | ); |
|
| … | 11 unchanged lines |
||
| 262 | 223 | ||
| 263 | 224 | /// `oxy commit -F`, `oxy describe` naming and tags as changes (feedback |
|
| 264 | 225 | /// 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 | 226 | #[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 | - | ||
| 383 | - | #[test] |
|
| 384 | 227 | fn commit_from_a_file_and_describe_for_builds() { |
|
| 385 | 228 | let tmp = tempfile::tempdir().unwrap(); |
|
| 386 | 229 | let home = tmp.path().join("home"); |
|
| … | 140 unchanged lines |
||
| 527 | 370 | 0 |
|
| 528 | 371 | ); |
|
| 529 | 372 | assert_eq!(std::fs::read(&out).unwrap(), sr.encode()); |
|
| 530 | - | ||
| 531 | - | // SLSA provenance is signed with the exporter's key: none yet, so it is |
|
| 532 | - | // refused and says what to do. |
|
| 533 | - | let prov = tmp.path().join("prov.json"); |
|
| 534 | - | let p = prov.to_str().unwrap(); |
|
| 535 | - | let slsa = ["receipt", "export", &attest[..10], p, "--format", "slsa"]; |
|
| 536 | - | let (code, _, err) = oxy(&r, &home, &slsa); |
|
| 537 | - | assert_ne!(code, 0); |
|
| 538 | - | assert!(err.contains("oxy identity create"), "{err}"); |
|
| 539 | - | let ident = [ |
|
| 540 | - | "identity", |
|
| 541 | - | "create", |
|
| 542 | - | "--name", |
|
| 543 | - | "Ada", |
|
| 544 | - | "--email", |
|
| 545 | - | "[email protected]", |
|
| 546 | - | ]; |
|
| 547 | - | assert_eq!(oxy(&r, &home, &ident).0, 0); |
|
| 548 | - | assert_eq!(oxy(&r, &home, &slsa).0, 0); |
|
| 549 | - | let env: serde_json::Value = serde_json::from_slice(&std::fs::read(&prov).unwrap()).unwrap(); |
|
| 550 | - | assert_eq!(env["payloadType"], oxy_core::dsse::IN_TOTO); |
|
| 551 | - | let payload = oxy_core::dsse::base64_decode(env["payload"].as_str().unwrap()).unwrap(); |
|
| 552 | - | let st: serde_json::Value = serde_json::from_slice(&payload).unwrap(); |
|
| 553 | - | assert_eq!(st["predicateType"], "https://slsa.dev/provenance/v1"); |
|
| 554 | - | assert_eq!(st["subject"][0]["digest"]["oxygen-state"], state.to_hex()); |
|
| 555 | - | let run = &st["predicate"]["runDetails"]; |
|
| 556 | - | assert_eq!(run["metadata"]["invocationId"], "demo#7"); |
|
| 557 | - | let kept = oxy_core::dsse::base64_decode(run["byproducts"][0]["content"].as_str().unwrap()); |
|
| 558 | - | assert_eq!( |
|
| 559 | - | kept.unwrap(), |
|
| 560 | - | sr.encode(), |
|
| 561 | - | "the runner's receipt rides along" |
|
| 562 | - | ); |
|
| 563 | - | // It verifies with the exporter's key and its PEM holds that key. |
|
| 564 | - | let repo = oxy_core::repo::Repo::open(&r) |
|
| 565 | - | .unwrap() |
|
| 566 | - | .with_home(home.clone()); |
|
| 567 | - | let key = repo.signing_key().unwrap(); |
|
| 568 | - | assert_eq!(env["signatures"][0]["keyid"], key.to_hex()); |
|
| 569 | - | let me = repo.identity(&key).unwrap(); |
|
| 570 | - | let sig = oxy_core::dsse::base64_decode(env["signatures"][0]["sig"].as_str().unwrap()).unwrap(); |
|
| 571 | - | assert!(oxy_core::dsse::verify(&me, oxy_core::dsse::IN_TOTO, &payload, &sig).is_ok()); |
|
| 572 | - | assert!(oxy_core::dsse::verify(&runner, oxy_core::dsse::IN_TOTO, &payload, &sig).is_err()); |
|
| 573 | - | let pem = tmp.path().join("key.pem"); |
|
| 574 | - | let pem_args = ["identity", "export", "--pem", pem.to_str().unwrap()]; |
|
| 575 | - | assert_eq!(oxy(&r, &home, &pem_args).0, 0); |
|
| 576 | - | assert_eq!( |
|
| 577 | - | std::fs::read_to_string(&pem).unwrap(), |
|
| 578 | - | oxy_core::dsse::public_pem(&me) |
|
| 579 | - | ); |
|
| 580 | 373 | ||
| 581 | 374 | // A receipt whose result was changed after signing does not verify. |
|
| 582 | 375 | let mut forged = sr.clone(); |
|
| … | 303 unchanged lines |
removed tests/cli_mcp.rs +0 −231
| 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 | - | } |
removed tests/cli_subrepo.rs +0 −94
| 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 | - | } |