mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 05:20:05 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1424447257 | ||
|
|
99ef7b6035 | ||
|
|
c805e35f40 |
No files matched your search
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.7.1"
|
||||
".": "0.8.0"
|
||||
}
|
||||
+167
@@ -1,5 +1,172 @@
|
||||
# Changelog
|
||||
|
||||
## [0.8.0](https://github.com/Twigpine/node/compare/v0.7.1...v0.8.0) (2026-11-08)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **node:** the GraphQL `repos` field now returns an error instead of the full list when more than MAX_VISIBLE_REPO_PAGE_SIZE (200) repositories are visible; larger lists must use `reposPage` with `limit`/`after`. Repository names are additionally limited to 100 bytes at creation, fork, and mirror-registration time.
|
||||
* **node:** gl cert show <id> --verify now requires --expect-node <did>; the node's self-reported DID is no longer trusted as a cert anchor.
|
||||
* **node:** `git-receive-pack` now rejects a push whose authenticated DID is not the repo owner, returning 403 before any ref update is applied. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so an agent pushing under its own DID cannot push while this is on. Set `GITLAWB_ENFORCE_OWNER_PUSH=false` during a rolling upgrade, or have automation push as the repo owner, until scoped collaborator / UCAN-delegated push rights land.
|
||||
|
||||
### Features
|
||||
|
||||
* **core:** carry the resumed candidate's identity in the scan token ([2656718](https://github.com/Twigpine/node/commit/2656718916ab9c68dd7fccb8f6fba8bba6b43e82))
|
||||
* **node,git:** cap concurrent served git ops with a 503 load-shed ([#62](https://github.com/Twigpine/node/issues/62)) ([5981257](https://github.com/Twigpine/node/commit/5981257be582fe1d39980bb2b1b3ff5c98448e84))
|
||||
* **node:** persist upstream mirror configuration and transition state ([#538](https://github.com/Twigpine/node/issues/538)) ([0e5a160](https://github.com/Twigpine/node/commit/0e5a1608e21f4859b2919307287747263f9fa8d0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api,sync:** Delimit positional arguments in git clone mirror invocations ([#539](https://github.com/Twigpine/node/issues/539)) ([0cdb4bc](https://github.com/Twigpine/node/commit/0cdb4bcc7c4f7b0eb997fc5ea26981b1e0e26e3c))
|
||||
* **attest:** bound did:key method-id length before base58 decode ([#360](https://github.com/Twigpine/node/issues/360)) ([#535](https://github.com/Twigpine/node/issues/535)) ([9b915b0](https://github.com/Twigpine/node/commit/9b915b09a7c727af15c5c520107403b9dc93d35a))
|
||||
* **attest:** make verify_all lenient under RequireAll ([#359](https://github.com/Twigpine/node/issues/359)) ([#532](https://github.com/Twigpine/node/issues/532)) ([db98b39](https://github.com/Twigpine/node/commit/db98b3957a15eff9e9f286f8a2d084d8e731fcdb))
|
||||
* **attest:** use strict Ed25519 verification with regression test ([1fcbbbd](https://github.com/Twigpine/node/commit/1fcbbbd89c027ae7d1d903f705631d94f8dfbadc))
|
||||
* **ci:** add tested release tag resolver ([1b65f70](https://github.com/Twigpine/node/commit/1b65f70403ab47ced8c2828745c1c79c89a185e4))
|
||||
* **ci:** recognize namespaced test attributes in pr-triage's needs-tests check ([1989fcc](https://github.com/Twigpine/node/commit/1989fcca112d0efb56978efe09d9a836afa7cb59))
|
||||
* **ci:** reject unsafe release dispatch tags ([7b365b0](https://github.com/Twigpine/node/commit/7b365b0dc3ef3b5e3d14fd62bccc434e57200612))
|
||||
* **ci:** reject unsafe release dispatch tags ([0b990d6](https://github.com/Twigpine/node/commit/0b990d687f0eb379b62eb600ede2a6233017e419))
|
||||
* **core:** cap the blinded recipient scan in open_blob ([#419](https://github.com/Twigpine/node/issues/419)) ([#440](https://github.com/Twigpine/node/issues/440)) ([2450bbd](https://github.com/Twigpine/node/commit/2450bbd9e962f47b374b07932a1ff3fc969b9427))
|
||||
* **core:** compute created-skew in checked arithmetic ([#358](https://github.com/Twigpine/node/issues/358)) ([#545](https://github.com/Twigpine/node/issues/545)) ([61d8a4f](https://github.com/Twigpine/node/commit/61d8a4f8f1503ca0b088a039802a8999d305642d))
|
||||
* **core:** count distinct signer DIDs in certificate threshold check ([3993fd1](https://github.com/Twigpine/node/commit/3993fd1f5daacbebb8f8e2c62af8856c493ec87a))
|
||||
* **core:** Count distinct signer DIDs in certificate threshold check ([50d3cbb](https://github.com/Twigpine/node/commit/50d3cbbe97f77b3cab9fab220add9f3a0d0dbc2f))
|
||||
* **core:** enforce strict RFC 8032 Ed25519 signature verification ([425ebf4](https://github.com/Twigpine/node/commit/425ebf40e7737f44c5fe4e9dc8995c8aeb074ce1))
|
||||
* **core:** put the redirect predicate behind a feature so core stays lean ([c82a70a](https://github.com/Twigpine/node/commit/c82a70ab21a21192b2579693b7d7c13aeb8a4d20))
|
||||
* **core:** refuse a redirect that rewrites the signed request-target ([5c0d6f3](https://github.com/Twigpine/node/commit/5c0d6f32522adea8f167f8e445a26377b6fe3963))
|
||||
* **core:** reject small-order Ed25519 keys at DID resolution and before X25519 ([#314](https://github.com/Twigpine/node/issues/314)) ([86c544a](https://github.com/Twigpine/node/commit/86c544a577f28efb2341a53d4faa6b54d1c8b919))
|
||||
* **core:** reject zero threshold, canonicalize did:key, document replay ([#509](https://github.com/Twigpine/node/issues/509)) ([#540](https://github.com/Twigpine/node/issues/540)) ([b4a3122](https://github.com/Twigpine/node/commit/b4a3122500e87d0b84aa3b116171de070188a6bd))
|
||||
* **core:** share the origin-scoped redirect rule with the git helper ([ba44a01](https://github.com/Twigpine/node/commit/ba44a018ec8945b447977d89fc6a10d98fc1bb7b))
|
||||
* **core:** use strict Ed25519 signature verification ([a6c13be](https://github.com/Twigpine/node/commit/a6c13be15b1421f3791e49a8219ea739f1e3ec6e))
|
||||
* **deps:** bump aws-smithy-json to 0.62.7 (GHSA-8ffr-xgwf-xj56) ([#516](https://github.com/Twigpine/node/issues/516)) ([151d688](https://github.com/Twigpine/node/commit/151d68838743a96198879b3154cfbaedca04c3cd))
|
||||
* **deps:** bump follow-redirects and override ws in scripts/ ([#525](https://github.com/Twigpine/node/issues/525)) ([a23c81d](https://github.com/Twigpine/node/commit/a23c81d75ae0a7567905ca9ad093bc9ad7c5adcf))
|
||||
* **deps:** bump libp2p to pick up hickory 0.26 and serde_with 3.22 ([#518](https://github.com/Twigpine/node/issues/518)) ([623b600](https://github.com/Twigpine/node/commit/623b600835d717fcfdab36af116dd4b9a92a92cd)), closes [#76](https://github.com/Twigpine/node/issues/76)
|
||||
* **deps:** resolve cargo audit failures on main ([#514](https://github.com/Twigpine/node/issues/514)) ([4d024e0](https://github.com/Twigpine/node/commit/4d024e01bcf25c93a3b878ac50e6435575dcc5f4))
|
||||
* **git-remote:** abort fetch on EOF without POSTing; harden the real-git test harness ([#192](https://github.com/Twigpine/node/issues/192)) ([1cb930c](https://github.com/Twigpine/node/commit/1cb930cbd57ea5d232edddc8e50dc94fab4362a5))
|
||||
* **git-remote:** drive multi-round fetch as a v0 stateless-RPC client loop ([#117](https://github.com/Twigpine/node/issues/117)) ([5fdf155](https://github.com/Twigpine/node/commit/5fdf155c7fd7398f61c200c256cb7327729f378a))
|
||||
* **git-remote:** drive multi-round fetch as a v0 stateless-RPC client loop ([#117](https://github.com/Twigpine/node/issues/117)) ([a64b141](https://github.com/Twigpine/node/commit/a64b14150dfb7e369c16e5fab98375ba0ce38404))
|
||||
* **git-remote:** normalize the shim's accepted socket to blocking ([10e0840](https://github.com/Twigpine/node/commit/10e0840dd95204d2b8faa1c594808e2ad57bc9c4))
|
||||
* **git-remote:** normalize the shim's accepted socket to blocking ([#192](https://github.com/Twigpine/node/issues/192)) ([8d7f95f](https://github.com/Twigpine/node/commit/8d7f95fd63769ef30540f216190e1bb52ccd4f69))
|
||||
* **git-remote:** reject malformed pkt-line length headers instead of flushing ([8c04043](https://github.com/Twigpine/node/commit/8c04043fb56b5030a0aa6b328c91f20173352aa6))
|
||||
* **git-remote:** skip content-free flush rounds in the fetch loop ([#117](https://github.com/Twigpine/node/issues/117)) ([68ba492](https://github.com/Twigpine/node/commit/68ba49267c8d65169ab9adf0552761f87baba4c1))
|
||||
* **git-remote:** stop waiting on EOF for a delete-only push ([#442](https://github.com/Twigpine/node/issues/442)) ([c2e175d](https://github.com/Twigpine/node/commit/c2e175d2222975eccb4776447268e8678fd48ba4))
|
||||
* **gl:** accept the resume token the incomplete-scan message tells you to pass ([181f617](https://github.com/Twigpine/node/commit/181f6178a0e0f5b9506093fa7579ad6ef8dd2ff5))
|
||||
* **gl:** assert 404 verdict and harden transport error handling in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([a329174](https://github.com/Twigpine/node/commit/a3291741757038307e61d3952e6c20aa4901c8a3))
|
||||
* **gl:** assert whoami 404 verdict and preserve transport error cause ([#220](https://github.com/Twigpine/node/issues/220)) ([b4007a5](https://github.com/Twigpine/node/commit/b4007a5e005c63aa0c083b969cd8b5f65b918adb))
|
||||
* **gl:** close two completeness-gate escapes; surface the node's error key ([6b2c53b](https://github.com/Twigpine/node/commit/6b2c53bbb86bcb35a54ef2edd2078e8f208263a6))
|
||||
* **gl:** derive View: URL from node instead of hardcoding gitlawb.com ([#370](https://github.com/Twigpine/node/issues/370)) ([#549](https://github.com/Twigpine/node/issues/549)) ([d84c445](https://github.com/Twigpine/node/commit/d84c445f94c05e43994c8199bb8e4cd432554fbc))
|
||||
* **gl:** follow scan continuations in gl ipfs get, bounded three ways ([9a8d190](https://github.com/Twigpine/node/commit/9a8d190492315cf767807dd1a98470debff4bd25))
|
||||
* **gl:** make doctor exit non-zero on Fail-class checks ([#357](https://github.com/Twigpine/node/issues/357)) ([#547](https://github.com/Twigpine/node/issues/547)) ([42cc893](https://github.com/Twigpine/node/commit/42cc893fb3af17468767cd8c6722894cd4ef7e44))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([21c74c6](https://github.com/Twigpine/node/commit/21c74c65d985200221489fa342d7419e8626362f))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([fd78cfa](https://github.com/Twigpine/node/commit/fd78cfa3ad0e7997b284371a55b7fb52753236d1))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([3cc15cf](https://github.com/Twigpine/node/commit/3cc15cf7247396ab1efcd279d3dd51dd8deaf014))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([16cc3b3](https://github.com/Twigpine/node/commit/16cc3b37e8268d048f3145ab16978dae297df5d4))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([3f41df5](https://github.com/Twigpine/node/commit/3f41df5868838544876473c2560c3d6be20e679c))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([b0e5cc0](https://github.com/Twigpine/node/commit/b0e5cc0ee0e91ebe35689bb1e073fa7e48ab37ee))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([12bc759](https://github.com/Twigpine/node/commit/12bc759ea6b20ab97c9dfeb4e00d7c6a6ca4fcfe))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([10e3492](https://github.com/Twigpine/node/commit/10e34928254205fafa8e96513a3df7899bfb10b1))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([c10f2ca](https://github.com/Twigpine/node/commit/c10f2ca255d63331f6acf3a29629e2a1c11ff355))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([3add535](https://github.com/Twigpine/node/commit/3add5356e36f0c2f89a0617fcb8018209bf722ff))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([fdd1103](https://github.com/Twigpine/node/commit/fdd11030dc55a68142441ecf05f0bf33a8d38695))
|
||||
* **gl:** pass the mirror temp path to git as an OS string ([#434](https://github.com/Twigpine/node/issues/434)) ([df87b15](https://github.com/Twigpine/node/commit/df87b150fdb00e4b70e359996a41b2eebf7bb426))
|
||||
* **gl:** read whoami error bodies through CappedBody.text ([bfc44f9](https://github.com/Twigpine/node/commit/bfc44f926d08c0bf774e2c05dd76b245871294f1))
|
||||
* **gl:** read whoami error bodies through CappedBody.text ([4725146](https://github.com/Twigpine/node/commit/4725146db2c13cd3b8f998260d9df3a4b5f70c9e))
|
||||
* **gl:** report why the node-DID comparison was skipped in cert show ([6b2c53b](https://github.com/Twigpine/node/commit/6b2c53bbb86bcb35a54ef2edd2078e8f208263a6))
|
||||
* **gl:** require non-empty error message and use ephemeral port in whoami tests ([#220](https://github.com/Twigpine/node/issues/220)) ([a27a267](https://github.com/Twigpine/node/commit/a27a2678712cc512b316f143d8c053dbdf981a4b))
|
||||
* **gl:** sanitize full transport error chain in whoami, remove raw error attachment ([#220](https://github.com/Twigpine/node/issues/220)) ([39ce655](https://github.com/Twigpine/node/commit/39ce655904f516094cbbef6f2ee585906ef77e05))
|
||||
* **gl:** status-check the client read/write surfaces so a node denial surfaces as an error, not a fake result ([#123](https://github.com/Twigpine/node/issues/123)) ([#186](https://github.com/Twigpine/node/issues/186)) ([6b2c53b](https://github.com/Twigpine/node/commit/6b2c53bbb86bcb35a54ef2edd2078e8f208263a6))
|
||||
* **gl:** stop carrying request signatures to a host the node redirected us to ([de3f230](https://github.com/Twigpine/node/commit/de3f230940f21d88da48a972bddeb877420d2165))
|
||||
* **icaptcha-client:** parse GITLAWB_ICAPTCHA_INSECURE as truthy only ([#227](https://github.com/Twigpine/node/issues/227)) ([#533](https://github.com/Twigpine/node/issues/533)) ([77ac103](https://github.com/Twigpine/node/commit/77ac1037cbf893bf0e2e861753cc4bc78cc9053a))
|
||||
* **icaptcha:** return unsolved instead of blocking or overflowing ([#444](https://github.com/Twigpine/node/issues/444)) ([5479fd5](https://github.com/Twigpine/node/commit/5479fd59b848f13e7c0f0f1054b0d41cf50cbb73))
|
||||
* **infra:** serialize the upgrade and write compose through a unique temp path ([888fab6](https://github.com/Twigpine/node/commit/888fab602828dc8b514cfa5fbb4d027a3a2db019))
|
||||
* **ipfs:** page the legacy CID scan instead of materializing every repo ([4985e38](https://github.com/Twigpine/node/commit/4985e38af2bd629fa81ec70aec63937ed9894b73))
|
||||
* **node,infra,macos:** make the owner-push opt-out reach every launch path ([1f0d9ff](https://github.com/Twigpine/node/commit/1f0d9ff368c0169048aeec219de4114c2630dff6))
|
||||
* **node:** ANS-104 codec and recovery policy types ([#26](https://github.com/Twigpine/node/issues/26) split 2/4) ([#524](https://github.com/Twigpine/node/issues/524)) ([2c341f7](https://github.com/Twigpine/node/commit/2c341f71ac66c0011a09ff5864e8734fd0348c09))
|
||||
* **node:** authenticate gossip ref-update events before writing them ([#325](https://github.com/Twigpine/node/issues/325)) ([9bda2e1](https://github.com/Twigpine/node/commit/9bda2e103c9ae25a5a798343164b4fa02219735b))
|
||||
* **node:** bind a peer row to its DID so only its keyholder can repoint it ([#273](https://github.com/Twigpine/node/issues/273)) ([8d38043](https://github.com/Twigpine/node/commit/8d38043a4ca7d0df3c9a4c4bdbb171d7adcdec4f))
|
||||
* **node:** bound federated repository aggregation ([#546](https://github.com/Twigpine/node/issues/546)) ([0189990](https://github.com/Twigpine/node/commit/0189990ac9e72bed3c42bc589347eacec04be740))
|
||||
* **node:** bound GraphQL query cost ([#552](https://github.com/Twigpine/node/issues/552)) ([c805e35](https://github.com/Twigpine/node/commit/c805e35f40873a120c81bf00d9ddb50da956fd97))
|
||||
* **node:** bound Identify-derived Kademlia addresses ([#544](https://github.com/Twigpine/node/issues/544)) ([b33562f](https://github.com/Twigpine/node/commit/b33562f1a913cbc1b294f00577f4ab1f7ad67984))
|
||||
* **node:** bound legacy readiness fallback ([5af14c2](https://github.com/Twigpine/node/commit/5af14c203d07fdd2d0b5689fcb12e189237dbbb2))
|
||||
* **node:** bound peer failure tracking ([74f7554](https://github.com/Twigpine/node/commit/74f75548941712573d344aa058d162f40189b4df))
|
||||
* **node:** bound replay of authenticated gossip ref-update events ([#334](https://github.com/Twigpine/node/issues/334)) ([206e082](https://github.com/Twigpine/node/commit/206e0827ad6beb051cbb0fb5ef459a63a102c454))
|
||||
* **node:** bound the fruitless reads one sweep run will spend ([e4aaae8](https://github.com/Twigpine/node/commit/e4aaae8c5229355010ca710abf22c181210201c3))
|
||||
* **node:** bound the legacy CID scan and let the caller carry its position ([22f4db9](https://github.com/Twigpine/node/commit/22f4db94b2fb3b3c7c43c5bd34608ea77ce5d197))
|
||||
* **node:** bound the legacy scan's query by the remaining row budget ([21e6873](https://github.com/Twigpine/node/commit/21e687368336fd219d82308ee20b3d6bb018a3dc))
|
||||
* **node:** bound the legacy-repair read by the pin batch deadline ([ea2219b](https://github.com/Twigpine/node/commit/ea2219b2cb635da61f120f65434db4c427494f6c))
|
||||
* **node:** bound the pin loops' DB work by the batch deadline, both lanes ([9984e08](https://github.com/Twigpine/node/commit/9984e082121f3e8d25750dd54c0d60c85cef0ac7))
|
||||
* **node:** bound the pre-walk CID resolve on its own short budget ([0d7711c](https://github.com/Twigpine/node/commit/0d7711c08917a70f982391249439485ddce382b0))
|
||||
* **node:** bound the request body the signature middleware buffers before it verifies ([#262](https://github.com/Twigpine/node/issues/262)) ([288ad92](https://github.com/Twigpine/node/commit/288ad92fe83526a717fa1a6ac37b1f3cf1f97961))
|
||||
* **node:** bring the Pinata pin path back into lockstep with the Kubo one ([4681f54](https://github.com/Twigpine/node/commit/4681f54327b58b6ebdd569a1cec17a3c2643ad13))
|
||||
* **node:** budget provenance and fallback walks separately ([ae96ffa](https://github.com/Twigpine/node/commit/ae96ffa46706a6d088a412aa02dbf2f715ceaa37))
|
||||
* **node:** canonicalize and 404-on-no-match in remove_label ([#344](https://github.com/Twigpine/node/issues/344)) ([#530](https://github.com/Twigpine/node/issues/530)) ([8c9e4ad](https://github.com/Twigpine/node/commit/8c9e4adc2d5ae3560c1bd3ba76c1d0ddb4de2af6))
|
||||
* **node:** carry discovery onto the per-(oid, repo) incompleteness marker ([bd0bef1](https://github.com/Twigpine/node/commit/bd0bef1476f90e99d9a697e2520c32b954f3ba6f))
|
||||
* **node:** charge discovery's probes against the per-run read budget ([bc20236](https://github.com/Twigpine/node/commit/bc202368e13202504fe77534273b508f7559a84a))
|
||||
* **node:** clamp every admission-held DB await in the CID resolver ([bad82b3](https://github.com/Twigpine/node/commit/bad82b3cd9fa970ebf67817395bfbd98591879b1))
|
||||
* **node:** close review findings on the owner-push default ([2f0174c](https://github.com/Twigpine/node/commit/2f0174cc041fb6e9eb3c904172b37b5b9a6c094b))
|
||||
* **node:** close three host-class gaps in the public-URL gate ([#346](https://github.com/Twigpine/node/issues/346)) ([76ca04f](https://github.com/Twigpine/node/commit/76ca04f3552aa8c17305fca73fa1c3469a29903c))
|
||||
* **node:** count a completed fetch once per fetch, not per stateless-RPC POST ([1c690c1](https://github.com/Twigpine/node/commit/1c690c125c16ad2ed38a5885d0e16b96789b715e))
|
||||
* **node:** count fetch completion from the response outcome, not the request ([5a5d4e1](https://github.com/Twigpine/node/commit/5a5d4e1b43a5b37fcc57731ec504b221a58931f1))
|
||||
* **node:** drop withheld-subtree trees from the replication pin set ([#382](https://github.com/Twigpine/node/issues/382)) ([19a7ce2](https://github.com/Twigpine/node/commit/19a7ce2bbdad9dd96bdc35ed6230d14f772a5969))
|
||||
* **node:** enforce owner-only push by default ([cdbda19](https://github.com/Twigpine/node/commit/cdbda19c29db576d2ea37ae74578c899b4eb3bb9))
|
||||
* **node:** Escape LIKE metacharacters in certificate prefix search ([eeafa1f](https://github.com/Twigpine/node/commit/eeafa1fa5350fb2d15ba30ac6cd6ad8247b4b7ec))
|
||||
* **node:** Escape LIKE wildcards in certificate prefix search ([96d8123](https://github.com/Twigpine/node/commit/96d8123e0f84e85a7b0234ad84f5e603a5a303aa))
|
||||
* **node:** fail pin_git_object when the add response carries no Hash ([#528](https://github.com/Twigpine/node/issues/528)) ([47c6e6c](https://github.com/Twigpine/node/commit/47c6e6ce2200dd196cb531bc6a6ed946cf587409))
|
||||
* **node:** fence a repo publish on the write attempt that owns it ([#285](https://github.com/Twigpine/node/issues/285)) ([ed76adf](https://github.com/Twigpine/node/commit/ed76adfe29d793229fbc63eeb86d24cac5d23d13))
|
||||
* **node:** gate /ipfs/pins and /arweave/anchors behind authentication ([#121](https://github.com/Twigpine/node/issues/121)) ([#543](https://github.com/Twigpine/node/issues/543)) ([d3845ce](https://github.com/Twigpine/node/commit/d3845ced69597b3e73e3c8cb5f452f6367897e5b))
|
||||
* **node:** gate bounty mutations on repo read before status checks ([#529](https://github.com/Twigpine/node/issues/529)) ([8eedb93](https://github.com/Twigpine/node/commit/8eedb935c3525a91ac311e273c3f22d3b4eb4615))
|
||||
* **node:** gate GET /ipfs/{cid} tree objects so a withheld subtree's structure can't leak ([#135](https://github.com/Twigpine/node/issues/135)) ([21f8cb6](https://github.com/Twigpine/node/commit/21f8cb61350b2355edd2b2ed527d9fb9c4305bd9))
|
||||
* **node:** gate replica register/unregister on repo read visibility ([#445](https://github.com/Twigpine/node/issues/445)) ([0303222](https://github.com/Twigpine/node/commit/0303222eae486c6d519be1441d4b3b11115a05ab))
|
||||
* **node:** gate two Unix-only tests so the crate compiles on Windows ([5a7a77b](https://github.com/Twigpine/node/commit/5a7a77b8dc1723cde44b0439327e517e23a048ab)), closes [#228](https://github.com/Twigpine/node/issues/228)
|
||||
* **node:** give each discovery row its own slice of the pass budget ([22560b0](https://github.com/Twigpine/node/commit/22560b0872bd16dd66100681b1050faa677fc568))
|
||||
* **node:** harden peer readiness state ([6bd8f41](https://github.com/Twigpine/node/commit/6bd8f4121235ec6c4b7062aadcf3a631f174510c))
|
||||
* **node:** hold fork to the same repo-name rules as every other creation route ([#412](https://github.com/Twigpine/node/issues/412)) ([fc87586](https://github.com/Twigpine/node/commit/fc8758641147ca5f38561fc71b13b1e41a78a5ed))
|
||||
* **node:** honor pre-assigned task assignee on claim ([#550](https://github.com/Twigpine/node/issues/550)) ([a4026e8](https://github.com/Twigpine/node/commit/a4026e84ffe82f48b41817c8ba687fbc1aac8f9b))
|
||||
* **node:** make metrics::init race-safe with Once ([c45ec96](https://github.com/Twigpine/node/commit/c45ec9605d55171f7b8f1b0b542478097ddf43a3))
|
||||
* **node:** make the pin-source incompleteness marker per (object, repo) ([5575da4](https://github.com/Twigpine/node/commit/5575da4b65ff19da78a6b218da07f854706a811c))
|
||||
* **node:** map ipfs/arweave DB outages to 503 via bare ? ([#251](https://github.com/Twigpine/node/issues/251)) ([0e2328b](https://github.com/Twigpine/node/commit/0e2328bd8044ce6e8923cc6bd42219068d3f6b9d))
|
||||
* **node:** map ipfs/arweave DB outages to 503 via bare ? ([#251](https://github.com/Twigpine/node/issues/251)) ([c29a91a](https://github.com/Twigpine/node/commit/c29a91af6b7eb444e202f4724e3f677d2ef25466))
|
||||
* **node:** mint a continuation when a ceiling stops the /ipfs scan mid-page ([69cfbd4](https://github.com/Twigpine/node/commit/69cfbd4b3ccde243ff669392820f0fce86381663))
|
||||
* **node:** never hand back a continuation that made no progress ([76f6300](https://github.com/Twigpine/node/commit/76f630008f0088c952755b643126affdf962d12b))
|
||||
* **node:** opaque AppError::Internal and AppError::Db HTTP bodies ([#226](https://github.com/Twigpine/node/issues/226)) ([241b366](https://github.com/Twigpine/node/commit/241b36633bca7e0ead33bf670b7c1531c3bd9ab5))
|
||||
* **node:** opaque AppError::Internal and AppError::Db HTTP bodies ([#226](https://github.com/Twigpine/node/issues/226)) ([d192028](https://github.com/Twigpine/node/commit/d192028ee1487a3638c545d6a39e58a4575df1f1))
|
||||
* **node:** opaque GraphQL DB error messages ([#250](https://github.com/Twigpine/node/issues/250)) ([c10ccf1](https://github.com/Twigpine/node/commit/c10ccf1f059eae18fcb0b7058a35bb134838b790))
|
||||
* **node:** order the CID candidate list so it cannot depend on heap order ([366b599](https://github.com/Twigpine/node/commit/366b59933112b345113501a65f0f30e76518ff61))
|
||||
* **node:** owner-gate create_task repo_id against hosted repos ([#541](https://github.com/Twigpine/node/issues/541)) ([bac49f1](https://github.com/Twigpine/node/commit/bac49f150a2b99d7b14fc9d53e231b7e743a753c))
|
||||
* **node:** persist the libp2p identity instead of deriving it from the node DID ([#324](https://github.com/Twigpine/node/issues/324)) ([c301a68](https://github.com/Twigpine/node/commit/c301a681619e890a081c385d7cbab5b8a8d4efbb))
|
||||
* **node:** port discovery's candidate load onto the paged repo query ([4c665fb](https://github.com/Twigpine/node/commit/4c665fbca8641f7dfe6770d1ee5384a0af3d215c))
|
||||
* **node:** preserve legacy peer compatibility ([547f9ae](https://github.com/Twigpine/node/commit/547f9aed10706bc617d0e1e2e5346cf52d19a901))
|
||||
* **node:** reconcile public-read messaging with per-repo enforcement ([#439](https://github.com/Twigpine/node/issues/439)) ([0366f18](https://github.com/Twigpine/node/commit/0366f18458dd996ea18057ce60abf88673e16ed4))
|
||||
* **node:** record a discovered source and its marker in one commit ([9babb5e](https://github.com/Twigpine/node/commit/9babb5ece053608c522f49b4528de23d5b61b749))
|
||||
* **node:** reject revision syntax in issue ids and drop service passthrough ([#521](https://github.com/Twigpine/node/issues/521)) ([421f03a](https://github.com/Twigpine/node/commit/421f03ac46592c57bc0df5a8fa79061c74d32fe6))
|
||||
* **node:** repair source-less legacy pins by bounded additive discovery ([d88c0c7](https://github.com/Twigpine/node/commit/d88c0c726c8630e104e0161366d1815d69e96428))
|
||||
* **node:** require Content-Digest on a signed request ([#306](https://github.com/Twigpine/node/issues/306)) ([189aad9](https://github.com/Twigpine/node/commit/189aad96e5c831f94cef654048d2d9797a5f574d))
|
||||
* **node:** resolve issue ids from git refnames, not request input ([#526](https://github.com/Twigpine/node/issues/526)) ([3063719](https://github.com/Twigpine/node/commit/30637193a626da800984363b6192fc50131b9741))
|
||||
* **node:** resume the /ipfs scan per candidate so a starved one is not skipped ([d0635d9](https://github.com/Twigpine/node/commit/d0635d95b7d601490474419c6bdb99b0ce402e71))
|
||||
* **node:** rewind the pin repair sweep cursor on every completed walk ([1b44b12](https://github.com/Twigpine/node/commit/1b44b12b468c265f946390a57cd815d67d205166))
|
||||
* **node:** rotate discovery candidates across traversals ([4fdfa1c](https://github.com/Twigpine/node/commit/4fdfa1cedb3dcb6d7d6cc4004e7277a9fa95de94))
|
||||
* **node:** scope bounty aggregates to anonymously-listable repos ([#477](https://github.com/Twigpine/node/issues/477)) ([#527](https://github.com/Twigpine/node/issues/527)) ([317a155](https://github.com/Twigpine/node/commit/317a1550c2810932118429b9987ea437a1465cf5))
|
||||
* **node:** scope the refs listing by the canonical repo slug ([#388](https://github.com/Twigpine/node/issues/388)) ([131bb95](https://github.com/Twigpine/node/commit/131bb95c686698d0f6a90dabd3976509df86b595))
|
||||
* **node:** size the ipfs work floor for provenance walks plus one full search ([dc5daa1](https://github.com/Twigpine/node/commit/dc5daa14b4d0816d8a14fabdf057259c1d177a46))
|
||||
* **node:** stop reporting a Kubo pin whose DB record never landed ([f839fb9](https://github.com/Twigpine/node/commit/f839fb924653f1b012c2b34d51260ea3faba7f62))
|
||||
* **node:** stop the /ipfs size probe reporting a fault as a missing object ([ed6355e](https://github.com/Twigpine/node/commit/ed6355ed2c42bed09dec35d46904805e97de806c))
|
||||
* **node:** use readiness for peer liveness ([225644a](https://github.com/Twigpine/node/commit/225644a3ea0302ae91959016928df1040ddd9cb5))
|
||||
* **node:** use readiness for peer liveness ([1a841a9](https://github.com/Twigpine/node/commit/1a841a99e2d40129a33f1898d3ce28d05eb04632))
|
||||
* **node:** validate git branch refs to close option injection ([#379](https://github.com/Twigpine/node/issues/379)) ([6233bd9](https://github.com/Twigpine/node/commit/6233bd9d850b60fdd2fb05afaa3d996d9f37f59d))
|
||||
* **node:** validate ref-update SHAs are hex, not just length 40 ([#433](https://github.com/Twigpine/node/issues/433)) ([911be4d](https://github.com/Twigpine/node/commit/911be4d9fef309e56117440d21e5dad7718be666))
|
||||
* **node:** version ref_certificates for forward compat ([#26](https://github.com/Twigpine/node/issues/26) split 3/4) ([#548](https://github.com/Twigpine/node/issues/548)) ([38bab41](https://github.com/Twigpine/node/commit/38bab4191baa5e8778f3412dc1f013d1f654bb15))
|
||||
* **release:** bump icaptcha-client with the rest of the workspace ([6668525](https://github.com/Twigpine/node/commit/6668525f41936ed2e1f8a1334b0dd17eaea866f0))
|
||||
* **repo_store:** use SHA-256 for stable advisory-lock key ([#210](https://github.com/Twigpine/node/issues/210)) ([c380f9c](https://github.com/Twigpine/node/commit/c380f9cee5139a2cab6fc52000d43844b86bb143))
|
||||
* **repo_store:** use SHA-256 for stable advisory-lock key ([#210](https://github.com/Twigpine/node/issues/210)) ([89e10d0](https://github.com/Twigpine/node/commit/89e10d0ff12dcccae3bd4490548d906f77d76636))
|
||||
* **review:** assert the read-timeout postcondition and drop the socket clone ([4d9bd5a](https://github.com/Twigpine/node/commit/4d9bd5a18a0692baa67f94aac8e8663d8e69bdac))
|
||||
* **review:** bound the probe's channel waits and stop overstating the handshake ([d4e5ecf](https://github.com/Twigpine/node/commit/d4e5ecf299afaabf05eee22db462b9314b5a6998))
|
||||
* **review:** bound token key, rule bytes, and the discovery load ([e6cfb58](https://github.com/Twigpine/node/commit/e6cfb588c2a1600d59f2af88471d248a99532f03))
|
||||
* **review:** keep the resume token on every terminal that still holds one ([8c39cfd](https://github.com/Twigpine/node/commit/8c39cfdcb347ca824cbec7d6b56720df9517a472))
|
||||
* **review:** make the coupled sites findable and pin two more properties ([a7e8e35](https://github.com/Twigpine/node/commit/a7e8e35030cae4e1126cda6c161bc516e3b8ffff))
|
||||
* **review:** make the LIKE escape independent of the SQL parser mode ([1d3987b](https://github.com/Twigpine/node/commit/1d3987bba6619a64a2cef15023224cbaa6cc8d1b)), closes [#319](https://github.com/Twigpine/node/issues/319)
|
||||
* **review:** mint a continuation at every ceiling and widen the token ([342dd48](https://github.com/Twigpine/node/commit/342dd48d2426a8870423cd537781b52d03e8f175))
|
||||
* **test:** match SHA-256 advisory-lock key in receive-pack deadline test ([c68e1b0](https://github.com/Twigpine/node/commit/c68e1b07f8db752cff786bdc9c226b49889a40b1))
|
||||
* **whoami:** update registered status handling on 404 response ([75b0df8](https://github.com/Twigpine/node/commit/75b0df8f1c7bda6a02444ce17e11088b839a51b6))
|
||||
|
||||
## [0.7.1](https://github.com/Gitlawb/node/compare/v0.7.0...v0.7.1) (2026-08-03)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ time once updated.
|
||||
|
||||
**Requirements:**
|
||||
- Rust stable (≥ 1.91) — install via [rustup](https://rustup.rs)
|
||||
- PostgreSQL — required for the node. Use the bundled `docker-compose.yml` for local dev.
|
||||
- PostgreSQL 16+ — required for the node (it uses `pg_input_is_valid`, which exists only on PostgreSQL 16 and later). Use the bundled `docker-compose.yml` for local dev.
|
||||
- Docker (optional, for full-stack local testing)
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
Generated
+6
-6
@@ -3470,7 +3470,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "git-remote-gitlawb"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
@@ -3485,7 +3485,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-attest"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek 2.2.0",
|
||||
@@ -3502,7 +3502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-core"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
@@ -3530,7 +3530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-node"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -3589,7 +3589,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gl"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -4056,7 +4056,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icaptcha-client"
|
||||
version = "0.4.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
|
||||
@@ -53,6 +53,7 @@ Good today:
|
||||
|
||||
- Local or Docker node startup.
|
||||
- Postgres-backed repo metadata.
|
||||
- Bounded GraphQL queries with [repository pagination](docs/graphql-pagination.md).
|
||||
- Bare git repository storage.
|
||||
- Git smart-HTTP clone/fetch/push.
|
||||
- RFC 9421-signed writes.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "git-remote-gitlawb"
|
||||
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
version = "0.8.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-attest"
|
||||
description = "External Attestation v1: pluggable provenance attachments for Twigpine ref-update certs"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
version = "0.8.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-core"
|
||||
description = "Core cryptographic primitives for the Twigpine network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
version = "0.8.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-node"
|
||||
description = "The Twigpine node daemon — git hosting over HTTP with DID auth"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
version = "0.8.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -11,7 +11,8 @@ use crate::state::AppState;
|
||||
|
||||
/// Hard ceiling on rows any ref-update feed returns in one request. Shared by the
|
||||
/// shared collector's clamp and the per-handler request caps so they can't drift.
|
||||
const MAX_VISIBLE_REF_UPDATES: i64 = 200;
|
||||
/// `pub(crate)` so the GraphQL complexity meter can price against the same bound.
|
||||
pub(crate) const MAX_VISIBLE_REF_UPDATES: i64 = 200;
|
||||
|
||||
/// Collect up to `limit` ref-update rows visible to `caller`, newest first,
|
||||
/// paging past rows the feed gate drops. Filtering after a plain SQL `LIMIT`
|
||||
|
||||
@@ -8367,6 +8367,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn get_by_cid_per_source_cap_sheds_same_source_admits_other() {
|
||||
let mut state = crate::test_support::test_state_lazy();
|
||||
state.db.pool().close().await;
|
||||
// Global pool has room; the per-source cap is 1.
|
||||
state.git_ipfs_walk_semaphore = Arc::new(Semaphore::new(8));
|
||||
state.git_ipfs_walk_per_caller = crate::rate_limit::PerCallerConcurrency::new(1, 100);
|
||||
@@ -8393,16 +8394,23 @@ mod tests {
|
||||
"a source at its per-source /ipfs walk cap must shed 503 with global capacity free"
|
||||
);
|
||||
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(body["error"], "overloaded");
|
||||
|
||||
// A DIFFERENT source is NOT shed by the per-source cap: it clears admission and
|
||||
// proceeds (then errors on the lazy DB, which is not a 503).
|
||||
// proceeds to the closed DB, which has a distinct db_unavailable error code.
|
||||
let resp = ipfs_router(state)
|
||||
.oneshot(get_cid(&cid, Some(other)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"a different source must not be shed by the per-source cap"
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
body["error"],
|
||||
crate::error::DB_UNAVAILABLE_CODE,
|
||||
"a different source must clear admission and reach the closed database"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -247,6 +247,40 @@ pub struct InfoRefsQuery {
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Validate a repository name at the create/fork write paths. The charset is
|
||||
/// restricted, and the length is bounded so every cursor the server emits
|
||||
/// parses back: `repo_cursor` embeds the name in a base64(JSON) cursor that
|
||||
/// `parse_repo_cursor` rejects over 4096 bytes (#465 4th round).
|
||||
///
|
||||
/// This is the API-side validator, and it deliberately differs from the
|
||||
/// stricter disk-side `validate_repo_name` in `git/repo_store.rs`: this one
|
||||
/// admits names the store rejects (empty, leading `-`, non-ASCII
|
||||
/// alphanumeric) and rejects `.`, which the store allows. The divergence
|
||||
/// predates this bound (#412 owns the fork half), so a store-rejected name
|
||||
/// still fails later at `repo_store.init` after the proof is spent. Do not
|
||||
/// "fix" the divergence here; the length bound below is the one that keeps
|
||||
/// emitted cursors parseable, and it is shared via `MAX_REPO_NAME_LEN` so the
|
||||
/// two validators cannot drift apart on length.
|
||||
fn validate_repo_name(name: &str) -> Result<()> {
|
||||
// Sanitize name: alphanumeric, hyphens, underscores only
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"repo name must contain only alphanumeric characters, hyphens, and underscores".into(),
|
||||
));
|
||||
}
|
||||
// Byte length, not char count: the cursor budget is measured in bytes.
|
||||
if name.len() > crate::db::MAX_REPO_NAME_LEN {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"repo name must be at most {} bytes",
|
||||
crate::db::MAX_REPO_NAME_LEN
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST /api/v1/repos
|
||||
/// Create a new repository. Requires HTTP Signature auth.
|
||||
pub async fn create_repo(
|
||||
@@ -261,16 +295,7 @@ pub async fn create_repo(
|
||||
// rejected request (bad name, already exists) never burns a valid proof.
|
||||
let proof = crate::icaptcha::verify_request(&headers, &auth.0)?;
|
||||
|
||||
// Sanitize name: alphanumeric, hyphens, underscores only
|
||||
if !req
|
||||
.name
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"repo name must contain only alphanumeric characters, hyphens, and underscores".into(),
|
||||
));
|
||||
}
|
||||
validate_repo_name(&req.name)?;
|
||||
|
||||
// default_branch is caller-supplied and becomes a PR's target_branch when the
|
||||
// PR omits one, which is interpolated into a git revision argument. Validate
|
||||
@@ -3774,15 +3799,7 @@ pub async fn fork_repo(
|
||||
let fork_name = req.name.unwrap_or_else(|| source.name.clone());
|
||||
let forker_did = auth.0;
|
||||
|
||||
// Validate fork name
|
||||
if !fork_name
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"repo name must contain only alphanumeric characters, hyphens, and underscores".into(),
|
||||
));
|
||||
}
|
||||
validate_repo_name(&fork_name)?;
|
||||
|
||||
// Same barrier every other repo-creation route goes through. The character
|
||||
// allowlist above is vacuously true on an empty name, and `repo_disk_path`
|
||||
@@ -5049,6 +5066,50 @@ mod tests {
|
||||
peer_mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_name_validation_rejects_bad_charset_and_overlong_names() {
|
||||
assert!(validate_repo_name("ok-name_123").is_ok());
|
||||
assert!(validate_repo_name(&"a".repeat(crate::db::MAX_REPO_NAME_LEN)).is_ok());
|
||||
// The bound is in bytes: the cursor budget is measured in bytes.
|
||||
let overlong = "a".repeat(crate::db::MAX_REPO_NAME_LEN + 1);
|
||||
let err = validate_repo_name(&overlong).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AppError::BadRequest(_)),
|
||||
"overlong name must be a 400, got: {err:?}"
|
||||
);
|
||||
assert!(validate_repo_name("bad name!").is_err());
|
||||
// Empty names were already admissible before the length bound; the
|
||||
// bound does not change that.
|
||||
assert!(validate_repo_name("").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_repo_rejects_overlong_name_through_handler() {
|
||||
// Pins the wiring: `create_repo` must call `validate_repo_name`
|
||||
// before any database access. The lazy state never connects, so
|
||||
// deleting the call site would let the handler proceed to `get_repo`
|
||||
// and fail with a connection error instead of BadRequest.
|
||||
let state = crate::test_support::test_state_lazy();
|
||||
let overlong = "a".repeat(crate::db::MAX_REPO_NAME_LEN + 1);
|
||||
let err = create_repo(
|
||||
State(state),
|
||||
Extension(AuthenticatedDid(OWNER_DID.to_owned())),
|
||||
axum::http::HeaderMap::new(),
|
||||
Json(CreateRepoRequest {
|
||||
name: overlong,
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".into(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AppError::BadRequest(ref msg) if msg.contains("at most")),
|
||||
"overlong name through create_repo must be a 400, got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_pack_request_finalizes_only_with_done_pktline() {
|
||||
let want = "0032want 1111111111111111111111111111111111111111\n";
|
||||
@@ -9179,6 +9240,7 @@ mod tests {
|
||||
/// A pkt-line receive-pack body carrying one branch-create ref update, so the
|
||||
/// handler's post-receive tail resolves a non-empty new-tip set (the delta
|
||||
/// scan's git stages run).
|
||||
#[cfg(unix)]
|
||||
fn ref_update_body(new_sha: &str) -> axum::body::Bytes {
|
||||
let line = format!("{ZERO_SHA} {new_sha} refs/heads/main");
|
||||
axum::body::Bytes::from(format!("{:04x}{}0000", line.len() + 4, line))
|
||||
@@ -11636,6 +11698,7 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn f2a_log(log: &std::path::Path) -> String {
|
||||
std::fs::read_to_string(log).unwrap_or_default()
|
||||
}
|
||||
@@ -11643,6 +11706,7 @@ mod tests {
|
||||
/// Withheld-walk children run so far. `ls-tree` is the walk's signature child
|
||||
/// (`blob_paths` lists every reachable commit's tree); the delta scan and the
|
||||
/// full-scan fallback use `rev-list` / `cat-file` instead.
|
||||
#[cfg(unix)]
|
||||
fn f2a_walks(log: &std::path::Path) -> usize {
|
||||
f2a_log(log)
|
||||
.lines()
|
||||
@@ -11654,6 +11718,7 @@ mod tests {
|
||||
/// the withheld walk actually runs rather than taking the no-rule shortcut).
|
||||
/// The repo's on-disk path is passed to the tail directly, so no repo_store or
|
||||
/// receive-pack plumbing is involved.
|
||||
#[cfg(unix)]
|
||||
async fn f2a_state(
|
||||
pool: sqlx::PgPool,
|
||||
git_bin: &str,
|
||||
@@ -11685,6 +11750,7 @@ mod tests {
|
||||
(state, rec)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn f2a_update(ref_name: &str, new_sha: &str) -> Vec<RefUpdate> {
|
||||
vec![RefUpdate {
|
||||
old_sha: ZERO_SHA.to_string(),
|
||||
@@ -11693,6 +11759,7 @@ mod tests {
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
const F2A_PUSHER: &str = "did:key:z6MkF2aPusherAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
/// When publish durability is fenced, the tail must not run walks or take
|
||||
@@ -11805,6 +11872,7 @@ mod tests {
|
||||
}
|
||||
/// Poll `cond` until it holds, with a bound so a regression fails the test
|
||||
/// rather than hanging the suite.
|
||||
#[cfg(unix)]
|
||||
async fn f2a_wait_for(mut cond: impl FnMut() -> bool, what: &str) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
while !cond() {
|
||||
@@ -11819,6 +11887,7 @@ mod tests {
|
||||
/// A `rev-list --objects` line names the tips a DELTA scan was asked to resolve,
|
||||
/// so it attributes that scan to one push's tips. The withheld walk's own
|
||||
/// `rev-list --all` / `ls-tree` lines never carry a tip as an argument this way.
|
||||
#[cfg(unix)]
|
||||
fn f2a_delta_scanned(log: &std::path::Path, tip: &str) -> bool {
|
||||
f2a_log(log)
|
||||
.lines()
|
||||
@@ -11967,6 +12036,7 @@ mod tests {
|
||||
|
||||
/// Mount a Pinata upload endpoint that assigns every object the same CID, and
|
||||
/// point the state at it. Returns the server (kept alive by the caller) and CID.
|
||||
#[cfg(unix)]
|
||||
async fn f2a_pinata(state: &mut AppState) -> (mockito::ServerGuard, String) {
|
||||
let cid = "bafyf2acoalescedmapping".to_string();
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
@@ -11986,6 +12056,7 @@ mod tests {
|
||||
|
||||
/// Poll the branch to CID table until the push's mapping lands (the Pinata
|
||||
/// worker is detached), bounded so a regression fails rather than hangs.
|
||||
#[cfg(unix)]
|
||||
async fn f2a_wait_for_branch_cid(
|
||||
db: &crate::db::Db,
|
||||
slug: &str,
|
||||
@@ -12005,6 +12076,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn f2a_slug(rec: &crate::db::RepoRecord) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
@@ -12139,6 +12211,7 @@ mod tests {
|
||||
// and `z6p2fail`), and owner-only push is on by default, so the identity has to
|
||||
// follow the repo each push targets rather than being fixed for both.
|
||||
|
||||
#[cfg(unix)]
|
||||
fn p2_push(
|
||||
state: &AppState,
|
||||
owner: &str,
|
||||
@@ -12157,6 +12230,7 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn p2_logged(log: &std::path::Path, prefix: &str) -> bool {
|
||||
f2a_log(log).lines().any(|l| l.starts_with(prefix))
|
||||
}
|
||||
@@ -13152,6 +13226,7 @@ exit 0
|
||||
/// runs exactly one `rev-list --all`, so this counts the walks that were attempted
|
||||
/// (the `ls-tree` counter above cannot: a walk whose enumeration fails never gets
|
||||
/// to `ls-tree`).
|
||||
#[cfg(unix)]
|
||||
fn f2b_walk_attempts(log: &std::path::Path) -> usize {
|
||||
f2a_log(log)
|
||||
.lines()
|
||||
|
||||
@@ -6,6 +6,20 @@ use std::time::Duration;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Maximum visible repositories per page, plus one internal look-ahead row.
|
||||
pub(crate) const MAX_VISIBLE_REPO_PAGE_SIZE: usize = 200;
|
||||
|
||||
/// Maximum repository name length in bytes. `repo_cursor` embeds the name in
|
||||
/// a base64(JSON) cursor that `parse_repo_cursor` rejects over 4096 bytes, so
|
||||
/// every write path must bound the name: a cursor the server emits must
|
||||
/// always parse back (#465 4th round). 100 bytes keeps the longest possible
|
||||
/// cursor an order of magnitude under the parse limit.
|
||||
pub(crate) const MAX_REPO_NAME_LEN: usize = 100;
|
||||
|
||||
/// Maximum agent tasks returned per query. Shared by the GraphQL `tasks`
|
||||
/// complexity meter and the resolver clamp so they can't drift.
|
||||
pub(crate) const MAX_VISIBLE_TASK_PAGE_SIZE: i64 = 200;
|
||||
|
||||
// ── Public data types ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -405,6 +419,17 @@ impl Db {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Test-only: access the underlying Postgres connection pool.
|
||||
///
|
||||
/// The production accessor was removed on main (ed76adf) once every
|
||||
/// production caller had migrated to typed `Db` methods; this restores
|
||||
/// it for tests only, for the raw-SQL fixtures and closed-pool cases in
|
||||
/// this change that have no typed equivalent.
|
||||
#[cfg(test)]
|
||||
pub fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
/// Test-only: apply the full schema to a fresh pool. `#[sqlx::test]`
|
||||
/// provisions an empty per-test database, so DB-backed tests must run this
|
||||
/// before seeding. Reuses the production `migrate()` path (the advisory lock
|
||||
@@ -1594,6 +1619,16 @@ impl Db {
|
||||
machine_id: Option<&str>,
|
||||
quarantined: bool,
|
||||
) -> Result<()> {
|
||||
// Peer-supplied names are bound like the local write paths: an
|
||||
// unbounded name would make reposPage emit a cursor longer than the
|
||||
// 4096-byte parse limit, dead-ending pagination for every caller past
|
||||
// that row (#465 4th round). The sync caller drops the error and skips
|
||||
// the mirror.
|
||||
if name.len() > MAX_REPO_NAME_LEN {
|
||||
anyhow::bail!(
|
||||
"mirror repo name exceeds {MAX_REPO_NAME_LEN} bytes: refusing to register"
|
||||
);
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let id = format!("{owner_short}/{name}");
|
||||
// `quarantined` is set only on first insert (the admission decision).
|
||||
@@ -1885,6 +1920,73 @@ impl Db {
|
||||
Ok(rows.into_iter().map(row_to_repo).collect())
|
||||
}
|
||||
|
||||
/// A bounded, mirror-deduplicated page ordered by owner key and repository
|
||||
/// name. Apply root visibility before LIMIT: private rows must not consume
|
||||
/// page slots or influence continuation metadata. The root-rule predicate
|
||||
/// mirrors `visibility::listable_at_root`; a differential test pins it.
|
||||
/// Malformed reader JSON denies that repo to non-owners without failing the page.
|
||||
/// Cursors only select a position and never confer read authority.
|
||||
///
|
||||
/// Performance note: the page orders and keyset-filters on
|
||||
/// `(owner_key, name)` under `COLLATE "C"`, but the existing
|
||||
/// `idx_repos_owner_key_name` index is built on the database default
|
||||
/// collation and cannot serve that ordering, so each page sorts the full
|
||||
/// deduped repo set before the keyset filter applies: O(total repos) work
|
||||
/// per page. This is accepted (pages are capped at
|
||||
/// `MAX_VISIBLE_REPO_PAGE_SIZE` rows over a narrow projection). A
|
||||
/// C-collation expression index was deliberately not added in a migration:
|
||||
/// versions 27-35 are claimed by other in-flight branches (Gitlawb/node#384
|
||||
/// claims 27-35, #464 claims 27-28) and the runner keys applied migrations
|
||||
/// on the version integer alone, so a colliding entry would be silently
|
||||
/// skipped on whichever side merges second. Revisit once that range lands
|
||||
/// or clears.
|
||||
pub async fn list_visible_repos_page(
|
||||
&self,
|
||||
caller: Option<&str>,
|
||||
after: Option<(&str, &str)>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RepoRecord>> {
|
||||
let sql = format!(
|
||||
"{}
|
||||
SELECT d.id, d.name, d.owner_did, d.description, d.is_public,
|
||||
d.default_branch, d.created_at, d.updated_at, d.disk_path,
|
||||
d.forked_from, d.machine_id
|
||||
FROM deduped d
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT reader_dids FROM visibility_rules v
|
||||
WHERE v.repo_id = d.id AND v.path_glob ~ '^/*([*][*])*$'
|
||||
ORDER BY v.path_glob DESC LIMIT 1
|
||||
) root_rule ON TRUE
|
||||
WHERE (
|
||||
($2::text IS NOT NULL AND ({key}) = $2)
|
||||
OR CASE WHEN root_rule.reader_dids IS NULL THEN d.is_public
|
||||
WHEN NOT pg_input_is_valid(root_rule.reader_dids, 'jsonb') THEN FALSE
|
||||
WHEN jsonb_typeof(root_rule.reader_dids::jsonb) = 'array' THEN
|
||||
COALESCE(root_rule.reader_dids::jsonb ? $3::text, FALSE)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(root_rule.reader_dids::jsonb) reader
|
||||
WHERE jsonb_typeof(reader) <> 'string'
|
||||
)
|
||||
ELSE FALSE END
|
||||
)
|
||||
AND ($4::text IS NULL OR (({key}) COLLATE \"C\", d.name COLLATE \"C\") > ($4, $5::text))
|
||||
ORDER BY ({key}) COLLATE \"C\", d.name COLLATE \"C\"
|
||||
LIMIT $6",
|
||||
Self::dedup_cte(),
|
||||
key = OWNER_KEY_CASE_SQL,
|
||||
);
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind(None::<&str>)
|
||||
.bind(caller.map(normalize_owner_key))
|
||||
.bind(caller)
|
||||
.bind(after.map(|(owner, _)| normalize_owner_key(owner)))
|
||||
.bind(after.map(|(_, name)| name))
|
||||
.bind(limit.min(MAX_VISIBLE_REPO_PAGE_SIZE + 1) as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_repo).collect())
|
||||
}
|
||||
|
||||
/// Repos currently quarantined (admitted as mirrors but withheld from every
|
||||
/// listing surface). `list_all_repos_deduped` excludes these (its `DEDUP_CTE`
|
||||
/// filters `quarantined = FALSE`), so a gate that resolves a slug against the
|
||||
|
||||
@@ -118,7 +118,7 @@ impl RepoStore {
|
||||
/// Test-only: every guard from this store parks in `release` right before the
|
||||
/// `pg_advisory_unlock` await, until `gate` is notified. Dropping the future
|
||||
/// while it is parked reproduces a client disconnect inside `release`.
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
pub fn with_pre_unlock_gate(mut self, gate: Arc<tokio::sync::Notify>) -> Self {
|
||||
self.pre_unlock_gate = Some(gate);
|
||||
self
|
||||
@@ -126,7 +126,7 @@ impl RepoStore {
|
||||
|
||||
/// Test-only: how many write guards from this store have reached the Tigris upload
|
||||
/// site. See [`RepoWriteGuard::release`].
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
pub fn tigris_upload_site_reached(&self) -> usize {
|
||||
self.upload_site_reached
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
@@ -1717,8 +1717,11 @@ fn validate_repo_name(repo_name: &str) -> Result<()> {
|
||||
if repo_name.is_empty() {
|
||||
anyhow::bail!("repo_name is empty");
|
||||
}
|
||||
if repo_name.len() > 100 {
|
||||
anyhow::bail!("repo_name exceeds 100 chars");
|
||||
// Byte bound shared with the API-side validator via `MAX_REPO_NAME_LEN`:
|
||||
// the two validators differ on charset by design, but must not drift on
|
||||
// length, or an API-admitted name fails here after the proof is spent.
|
||||
if repo_name.len() > crate::db::MAX_REPO_NAME_LEN {
|
||||
anyhow::bail!("repo_name exceeds {} chars", crate::db::MAX_REPO_NAME_LEN);
|
||||
}
|
||||
// Repo names are `[A-Za-z0-9._-]+` minus path-traversal traps.
|
||||
if repo_name.contains("..") {
|
||||
|
||||
@@ -3,7 +3,7 @@ pub mod query;
|
||||
pub mod subscription;
|
||||
pub mod types;
|
||||
|
||||
use async_graphql::Schema;
|
||||
use async_graphql::{Schema, SchemaBuilder};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::Db;
|
||||
@@ -14,6 +14,22 @@ use subscription::SubscriptionRoot;
|
||||
|
||||
pub type GitlawbSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
|
||||
|
||||
// Keep ordinary schema discovery and application queries usable while
|
||||
// bounding validation work and the number of resolver selections per request.
|
||||
const GRAPHQL_MAX_COMPLEXITY: usize = 400;
|
||||
// The current public schema is shallow; this leaves headroom for composed
|
||||
// clients and the canonical getIntrospectionQuery without allowing
|
||||
// recursively nested documents to grow unchecked.
|
||||
const GRAPHQL_MAX_DEPTH: usize = 14;
|
||||
|
||||
fn apply_query_limits<Query, Mutation, Subscription>(
|
||||
builder: SchemaBuilder<Query, Mutation, Subscription>,
|
||||
) -> SchemaBuilder<Query, Mutation, Subscription> {
|
||||
builder
|
||||
.limit_complexity(GRAPHQL_MAX_COMPLEXITY)
|
||||
.limit_depth(GRAPHQL_MAX_DEPTH)
|
||||
}
|
||||
|
||||
/// Client-facing message for GraphQL resolver failures that wrap a real
|
||||
/// `sqlx::Error`. The real error is logged server-side; never put sqlx/Postgres
|
||||
/// detail in the GraphQL `errors` array (#250).
|
||||
@@ -81,7 +97,7 @@ pub fn build_schema(
|
||||
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
|
||||
task_event_tx: tokio::sync::broadcast::Sender<TaskEventBroadcast>,
|
||||
) -> GitlawbSchema {
|
||||
Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
|
||||
apply_query_limits(Schema::build(QueryRoot, MutationRoot, SubscriptionRoot))
|
||||
.data(db)
|
||||
.data(ref_update_tx)
|
||||
.data(task_event_tx)
|
||||
@@ -91,6 +107,10 @@ pub fn build_schema(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::events::MAX_VISIBLE_REF_UPDATES;
|
||||
use crate::db::{MAX_VISIBLE_REPO_PAGE_SIZE, MAX_VISIBLE_TASK_PAGE_SIZE};
|
||||
use async_graphql::{EmptyMutation, EmptySubscription, Object, Value};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn graphql_db_err_opaques_sqlx_chain() {
|
||||
@@ -160,6 +180,668 @@ mod tests {
|
||||
assert!(!err.message.contains("failed to open"));
|
||||
}
|
||||
|
||||
fn production_test_schema() -> GitlawbSchema {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
production_test_schema_with_db(Arc::new(Db::for_testing(pool)))
|
||||
}
|
||||
|
||||
fn production_test_schema_with_db(db: Arc<Db>) -> GitlawbSchema {
|
||||
build_schema(
|
||||
db,
|
||||
tokio::sync::broadcast::channel(1).0,
|
||||
tokio::sync::broadcast::channel(1).0,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_update_subscription_alias_budget() {
|
||||
assert_subscription_alias_budget("refUpdates { repo }").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_event_subscription_alias_budget() {
|
||||
assert_subscription_alias_budget("taskEvents { taskId }").await;
|
||||
}
|
||||
|
||||
async fn assert_subscription_alias_budget(field: &str) {
|
||||
use futures::StreamExt;
|
||||
use std::time::Duration;
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let (task_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let schema = build_schema(
|
||||
Arc::new(Db::for_testing(pool)),
|
||||
ref_tx.clone(),
|
||||
task_tx.clone(),
|
||||
);
|
||||
for count in [7, 8] {
|
||||
let fields = (0..count)
|
||||
.map(|n| format!("r{n}: {field}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let query = format!("subscription {{ {fields} }}");
|
||||
let mut responses = Box::pin(schema.execute_stream(query));
|
||||
if count == 8 {
|
||||
let response = tokio::time::timeout(Duration::from_secs(1), responses.next())
|
||||
.await
|
||||
.expect("over-budget subscriptions must reject before waiting for events")
|
||||
.expect("the validation error must be returned");
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
assert_eq!(ref_tx.receiver_count() + task_tx.receiver_count(), 0);
|
||||
continue;
|
||||
}
|
||||
// Poll the accepted operation far enough to register its receivers.
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(30), responses.next())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(ref_tx.receiver_count() + task_tx.receiver_count(), 7);
|
||||
let _ = ref_tx.send(RefUpdateBroadcast {
|
||||
repo: "owner/repo".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".repeat(40),
|
||||
new_sha: "1".repeat(40),
|
||||
pusher_did: "did:key:test".into(),
|
||||
node_did: "did:key:node".into(),
|
||||
timestamp: "2026-01-01T00:00:00Z".into(),
|
||||
owner_did: "did:key:test".into(),
|
||||
});
|
||||
let _ = task_tx.send(TaskEventBroadcast {
|
||||
task_id: "task".into(),
|
||||
old_status: "pending".into(),
|
||||
new_status: "claimed".into(),
|
||||
by_did: "did:key:test".into(),
|
||||
at: "2026-01-01T00:00:00Z".into(),
|
||||
});
|
||||
let response = tokio::time::timeout(Duration::from_secs(1), responses.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_ne!(response.data, Value::Null);
|
||||
drop(responses);
|
||||
assert_eq!(ref_tx.receiver_count() + task_tx.receiver_count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn production_mutation_budget_accepts_seven_and_rejects_eight(pool: sqlx::PgPool) {
|
||||
let db = Arc::new(Db::for_testing(pool));
|
||||
db.run_migrations().await.unwrap();
|
||||
let caller = "did:key:reviewer";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
for index in 0..8 {
|
||||
db.create_task(&crate::db::AgentTask {
|
||||
id: format!("task-{index}"),
|
||||
repo_id: None,
|
||||
kind: "test".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: caller.into(),
|
||||
assignee_did: None,
|
||||
capability: "test".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
deadline: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let schema = production_test_schema_with_db(db.clone());
|
||||
for count in [8, 7] {
|
||||
let fields = (0..count)
|
||||
.map(|n| {
|
||||
format!("r{n}: claimTask(id: \"task-{n}\", assigneeDid: \"{caller}\") {{ id }}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema
|
||||
.execute(
|
||||
async_graphql::Request::new(format!("mutation {{ {fields} }}"))
|
||||
.data(crate::auth::AuthenticatedDid(caller.into())),
|
||||
)
|
||||
.await;
|
||||
if count == 8 {
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
assert_eq!(
|
||||
db.list_tasks(Some("pending"), None, 8).await.unwrap().len(),
|
||||
8
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_eq!(
|
||||
response
|
||||
.data
|
||||
.into_json()
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.len(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
db.list_tasks(Some("claimed"), Some(caller), 8)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_task("task-7").await.unwrap().unwrap().status,
|
||||
"pending"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn production_list_cost_scales_with_the_same_alias_count(pool: sqlx::PgPool) {
|
||||
let db = Arc::new(Db::for_testing(pool));
|
||||
db.run_migrations().await.unwrap();
|
||||
let schema = production_test_schema_with_db(db);
|
||||
// Bounds are per-root named constants so the cases move with the
|
||||
// documented maximums instead of going stale as literals.
|
||||
for (field, selection, max) in [
|
||||
("refUpdates", "repo", MAX_VISIBLE_REF_UPDATES),
|
||||
("tasks", "id", MAX_VISIBLE_TASK_PAGE_SIZE),
|
||||
(
|
||||
"reposPage",
|
||||
"nodes { name }",
|
||||
MAX_VISIBLE_REPO_PAGE_SIZE as i64,
|
||||
),
|
||||
] {
|
||||
// Two aliases at the maximum always exceed the budget.
|
||||
let fields = (0..2)
|
||||
.map(|n| format!("r{n}: {field}(limit: {max}) {{ {selection} }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
|
||||
// At limit 1 two aliases fit for the row-priced roots; reposPage
|
||||
// prices the full corpus scan, so a single alias is the most that
|
||||
// fits under the budget.
|
||||
let aliases = if field == "reposPage" { 1 } else { 2 };
|
||||
let fields = (0..aliases)
|
||||
.map(|n| format!("r{n}: {field}(limit: 1) {{ {selection} }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert!(response.errors.is_empty(), "{field}: {:?}", response.errors);
|
||||
assert_eq!(
|
||||
response
|
||||
.data
|
||||
.into_json()
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.len(),
|
||||
aliases
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_limits_reject_mutation_aliases_and_large_lists() {
|
||||
let schema = production_test_schema();
|
||||
// Limits are derived from the MAX_* constants so the boundaries move
|
||||
// with the documented maximums instead of going stale as literals.
|
||||
for (prefix, count, field) in [
|
||||
("mutation", 8, "claimTask(id: \"missing\", assigneeDid: \"did:key:test\") { id }".to_owned()),
|
||||
("mutation", 8, "createTask(delegatorDid: \"did:key:test\", input: { kind: \"test\", capability: \"test\" }) { id }".to_owned()),
|
||||
("mutation", 8, "completeTask(id: \"missing\", byDid: \"did:key:test\", input: {}) { id }".to_owned()),
|
||||
("mutation", 8, "failTask(id: \"missing\", byDid: \"did:key:test\", input: {}) { id }".to_owned()),
|
||||
("query", 8, "task(id: \"missing\") { id }".to_owned()),
|
||||
("query", 2, format!("refUpdates(limit: {MAX_VISIBLE_REF_UPDATES}) {{ repo }}")),
|
||||
("query", 2, format!("tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ id }}")),
|
||||
("query", 2, format!("reposPage(limit: {MAX_VISIBLE_REPO_PAGE_SIZE}) {{ nodes {{ name }} }}")),
|
||||
("query", 8, "reposPage(limit: 1) { nodes { name } }".to_owned()),
|
||||
] {
|
||||
let fields = (0..count)
|
||||
.map(|n| format!("r{n}: {field}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{prefix} {{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn production_repos_page_serves_the_documented_maximum(pool: sqlx::PgPool) {
|
||||
let db = Arc::new(Db::for_testing(pool));
|
||||
db.run_migrations().await.unwrap();
|
||||
let schema = production_test_schema_with_db(db);
|
||||
for selection in [
|
||||
"nodes { name }",
|
||||
"nodes { name ownerDid } hasNextPage endCursor",
|
||||
] {
|
||||
let response = schema
|
||||
.execute(format!(
|
||||
"{{ reposPage(limit: {MAX_VISIBLE_REPO_PAGE_SIZE}) {{ {selection} }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_eq!(
|
||||
response.data.into_json().unwrap()["reposPage"]["nodes"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seven_root_aliases_are_accepted() {
|
||||
struct Root(Arc<AtomicUsize>);
|
||||
#[Object]
|
||||
impl Root {
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn repos(&self) -> Vec<Nested> {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
vec![Nested]
|
||||
}
|
||||
}
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let schema = apply_query_limits(Schema::build(
|
||||
Root(calls.clone()),
|
||||
EmptyMutation,
|
||||
EmptySubscription,
|
||||
))
|
||||
.finish();
|
||||
let fields = (0..7)
|
||||
.map(|n| format!("r{n}: repos {{ value }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expensive_root_aliases_are_rejected_before_database_access() {
|
||||
// Use the production builder with a lazy pool: rejection must precede DB access.
|
||||
let schema = production_test_schema();
|
||||
let fields = (0..8)
|
||||
.map(|n| format!("r{n}: repos {{ name }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
|
||||
assert_eq!(response.data, Value::Null);
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_schema_introspection_remains_available() {
|
||||
let schema = production_test_schema();
|
||||
let canonical_introspection = r#"
|
||||
query IntrospectionQuery {
|
||||
__schema {
|
||||
queryType { name }
|
||||
mutationType { name }
|
||||
subscriptionType { name }
|
||||
types {
|
||||
...FullType
|
||||
}
|
||||
directives {
|
||||
name
|
||||
description
|
||||
locations
|
||||
args {
|
||||
...InputValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment FullType on __Type {
|
||||
kind
|
||||
name
|
||||
description
|
||||
fields(includeDeprecated: true) {
|
||||
name
|
||||
description
|
||||
args {
|
||||
...InputValue
|
||||
}
|
||||
type {
|
||||
...TypeRef
|
||||
}
|
||||
isDeprecated
|
||||
deprecationReason
|
||||
}
|
||||
inputFields {
|
||||
...InputValue
|
||||
}
|
||||
interfaces {
|
||||
...TypeRef
|
||||
}
|
||||
enumValues(includeDeprecated: true) {
|
||||
name
|
||||
description
|
||||
isDeprecated
|
||||
deprecationReason
|
||||
}
|
||||
possibleTypes {
|
||||
...TypeRef
|
||||
}
|
||||
}
|
||||
|
||||
fragment InputValue on __InputValue {
|
||||
name
|
||||
description
|
||||
type { ...TypeRef }
|
||||
defaultValue
|
||||
}
|
||||
|
||||
fragment TypeRef on __Type {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let response = schema.execute(canonical_introspection).await;
|
||||
assert!(
|
||||
response.errors.is_empty(),
|
||||
"canonical introspection query failed against production schema: {:?}",
|
||||
response.errors
|
||||
);
|
||||
assert_ne!(response.data, Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_updates_and_tasks_single_alias_max_limit_complexity_contract() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
pool.close().await;
|
||||
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let (task_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let schema = build_schema(Arc::new(Db::for_testing(pool)), ref_tx, task_tx);
|
||||
|
||||
// refUpdates(limit: MAX_VISIBLE_REF_UPDATES):
|
||||
// Single-field selection has child_complexity 1 -> cost 100 + 200 + 1 = 301 <= 400.
|
||||
// It passes validation and reaches the resolver (which yields db error on lazy pool).
|
||||
let single_ref = schema
|
||||
.execute(format!(
|
||||
"{{ refUpdates(limit: {MAX_VISIBLE_REF_UPDATES}) {{ repo }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!single_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"single-field refUpdates at max limit must pass complexity validation: {:?}",
|
||||
single_ref.errors
|
||||
);
|
||||
assert!(
|
||||
single_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"single-field refUpdates at max limit must reach the resolver: {:?}",
|
||||
single_ref.errors
|
||||
);
|
||||
|
||||
// Two-field selection has child_complexity 2 -> cost 100 + 200 + 2 = 302 <= 400.
|
||||
// The documented max-200 request stays reachable with multiple fields
|
||||
// (the old multiplicative price rejected it at 450); it passes
|
||||
// validation and reaches the resolver like the single-field case.
|
||||
let multi_ref = schema
|
||||
.execute(format!(
|
||||
"{{ refUpdates(limit: {MAX_VISIBLE_REF_UPDATES}) {{ repo refName }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!multi_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"two-field refUpdates at max limit must pass complexity validation: {:?}",
|
||||
multi_ref.errors
|
||||
);
|
||||
assert!(
|
||||
multi_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"two-field refUpdates at max limit must reach the resolver: {:?}",
|
||||
multi_ref.errors
|
||||
);
|
||||
|
||||
// tasks(limit: MAX_VISIBLE_TASK_PAGE_SIZE):
|
||||
// Single-field selection has child_complexity 1, so the cost is
|
||||
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 1 <= 400.
|
||||
// It passes validation and reaches the resolver.
|
||||
let single_task = schema
|
||||
.execute(format!(
|
||||
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ id }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!single_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"single-field tasks at the maximum page size must pass complexity validation: {:?}",
|
||||
single_task.errors
|
||||
);
|
||||
assert!(
|
||||
single_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"single-field tasks at the maximum page size must reach the resolver: {:?}",
|
||||
single_task.errors
|
||||
);
|
||||
|
||||
// Two-field selection has child_complexity 2, so the cost is
|
||||
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 2 <= 400.
|
||||
// A request at the documented maximum stays reachable with multiple
|
||||
// fields (the old multiplicative price rejected it at 450); it passes
|
||||
// validation and reaches the resolver like the single-field case.
|
||||
let multi_task = schema
|
||||
.execute(format!(
|
||||
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ id status }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!multi_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"two-field tasks at the maximum page size must pass complexity validation: {:?}",
|
||||
multi_task.errors
|
||||
);
|
||||
assert!(
|
||||
multi_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"two-field tasks at the maximum page size must reach the resolver: {:?}",
|
||||
multi_task.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_repos_aliases_exceed_the_budget() {
|
||||
// `repos` is priced as the full page it scans
|
||||
// (50 + MAX_VISIBLE_REPO_PAGE_SIZE + child), so two `repos { name }`
|
||||
// aliases cost 2 * 251 = 502 > 400 and the document is rejected before
|
||||
// any resolver runs. If the price reverts to `50 + child_complexity`
|
||||
// the aliases cost 2 * 51 = 102, validation passes, and the failure
|
||||
// mode changes (resolver DB errors on the lazy pool): this test goes
|
||||
// red on revert.
|
||||
let schema = production_test_schema();
|
||||
let response = schema
|
||||
.execute("{ r0: repos { name } r1: repos { name } }")
|
||||
.await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seven_repos_page_aliases_exceed_the_budget() {
|
||||
// `reposPage` is priced as the full corpus scan
|
||||
// (50 + MAX_VISIBLE_REPO_PAGE_SIZE + child_complexity), so seven
|
||||
// `reposPage(limit: 1) { nodes { name } }` aliases cost
|
||||
// 7 * (50 + 200 + 2) = 1764 > 400 and the document is rejected
|
||||
// before any resolver runs. Under the previous row-priced formula
|
||||
// the same aliases cost 7 * (50 + 1 + 2) = 371, validation passes,
|
||||
// and the failure mode changes (resolver DB errors on the lazy
|
||||
// pool): this test goes red on revert. Eight aliases are rejected
|
||||
// under both formulas, so seven is the load-bearing boundary.
|
||||
let schema = production_test_schema();
|
||||
let fields = (0..7)
|
||||
.map(|n| format!("r{n}: reposPage(limit: 1) {{ nodes {{ name }} }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_updates_fixed_base_is_charged() {
|
||||
// The 100 base in the refUpdates complexity formula charges for the
|
||||
// collector's fixed work (loading the deduped repo set, the
|
||||
// quarantined set, and the visibility rules, then walking up to 16
|
||||
// keyset pages) even at limit: 1. Four aliases at limit: 1 cost
|
||||
// 4 * (100 + 1 + 1) = 408 > 400 and must be rejected. If the base
|
||||
// disappears, the cost drops to 4 * (1 + 1) = 8 and this test fails.
|
||||
let schema = production_test_schema();
|
||||
let fields = (0..4)
|
||||
.map(|n| format!("r{n}: refUpdates(limit: 1) {{ repo }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Nested;
|
||||
|
||||
#[Object]
|
||||
impl Nested {
|
||||
async fn child(&self) -> Nested {
|
||||
Nested
|
||||
}
|
||||
|
||||
async fn value(&self) -> i32 {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
struct CountingQuery(Arc<AtomicUsize>);
|
||||
|
||||
#[Object]
|
||||
impl CountingQuery {
|
||||
async fn nested(&self) -> Nested {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
Nested
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_depth_limit_accepts_fourteen_and_rejects_fifteen() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let schema = apply_query_limits(Schema::build(
|
||||
CountingQuery(Arc::clone(&calls)),
|
||||
EmptyMutation,
|
||||
EmptySubscription,
|
||||
))
|
||||
.finish();
|
||||
let query_at_depth = |depth: usize| {
|
||||
let selection = (2..depth).fold("value".to_string(), |selection, _| {
|
||||
format!("child {{ {selection} }}")
|
||||
});
|
||||
format!("{{ nested {{ {selection} }} }}")
|
||||
};
|
||||
|
||||
let accepted = schema.execute(query_at_depth(GRAPHQL_MAX_DEPTH)).await;
|
||||
|
||||
assert!(
|
||||
accepted.errors.is_empty(),
|
||||
"depth {GRAPHQL_MAX_DEPTH} should be accepted: {:?}",
|
||||
accepted.errors
|
||||
);
|
||||
assert_eq!(calls.swap(0, Ordering::Relaxed), 1);
|
||||
|
||||
let rejected = schema.execute(query_at_depth(GRAPHQL_MAX_DEPTH + 1)).await;
|
||||
|
||||
assert_eq!(rejected.data, Value::Null);
|
||||
assert_eq!(rejected.errors.len(), 1);
|
||||
assert_eq!(rejected.errors[0].message, "Query is nested too deep.");
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_schema_enforces_depth_limit() {
|
||||
let schema = production_test_schema();
|
||||
// Depth 14 is accepted through the production build_schema path.
|
||||
let of_types_14 = (0..9).fold("name".to_string(), |acc, _| format!("ofType {{ {acc} }}"));
|
||||
let query_14 =
|
||||
format!("{{ __schema {{ types {{ fields {{ type {{ {of_types_14} }} }} }} }} }}");
|
||||
let accepted = schema.execute(&query_14).await;
|
||||
assert!(accepted.errors.is_empty(), "{:?}", accepted.errors);
|
||||
|
||||
// Construct an introspection query of depth 15 using __schema.
|
||||
// 1: __schema
|
||||
// 2: types
|
||||
// 3: fields
|
||||
// 4: type
|
||||
// 5..14: ofType (10 times)
|
||||
// 15: name
|
||||
// Total depth = 15 > GRAPHQL_MAX_DEPTH (14).
|
||||
let of_types = (0..10).fold("name".to_string(), |acc, _| format!("ofType {{ {acc} }}"));
|
||||
let query = format!("{{ __schema {{ types {{ fields {{ type {{ {of_types} }} }} }} }} }}");
|
||||
|
||||
let rejected = schema.execute(&query).await;
|
||||
assert_eq!(rejected.data, async_graphql::Value::Null);
|
||||
assert_eq!(rejected.errors.len(), 1);
|
||||
assert_eq!(rejected.errors[0].message, "Query is nested too deep.");
|
||||
}
|
||||
|
||||
/// Every `.map_err(` in the GraphQL query/mutation resolvers must route
|
||||
/// through the opaque helpers, or discard the error (`|_|`). Same source-
|
||||
/// scrape pattern as `api::authz_guard` (#255 review).
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct MutationRoot;
|
||||
|
||||
#[Object]
|
||||
impl MutationRoot {
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn create_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -89,6 +90,7 @@ impl MutationRoot {
|
||||
Ok(AgentTaskType::from(stored))
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn claim_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -126,6 +128,7 @@ impl MutationRoot {
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn complete_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -177,6 +180,7 @@ impl MutationRoot {
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn fail_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
|
||||
@@ -1,57 +1,154 @@
|
||||
use async_graphql::{Context, Object, Result};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::api::events::MAX_VISIBLE_REF_UPDATES;
|
||||
use crate::db::{Db, RepoRecord, MAX_VISIBLE_REPO_PAGE_SIZE, MAX_VISIBLE_TASK_PAGE_SIZE};
|
||||
|
||||
use super::types::{AgentTaskType, RefUpdateType, RepoType};
|
||||
use super::types::{AgentTaskType, RefUpdateType, RepoPageType, RepoType};
|
||||
|
||||
fn repo_type(repo: RepoRecord) -> RepoType {
|
||||
RepoType {
|
||||
name: repo.name,
|
||||
owner_did: repo.owner_did,
|
||||
description: repo.description,
|
||||
default_branch: repo.default_branch,
|
||||
created_at: repo.created_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_cursor(repo: &RepoRecord) -> String {
|
||||
URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&(&repo.owner_did, &repo.name))
|
||||
.expect("a pair of strings is JSON serializable"),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_repo_cursor(cursor: &str) -> Result<(String, String)> {
|
||||
// A position contains only public response fields, not a database id or
|
||||
// authority. Every page re-evaluates visibility for the current caller.
|
||||
if cursor.len() > 4096 {
|
||||
return Err(async_graphql::Error::new("invalid repository cursor"));
|
||||
}
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| async_graphql::Error::new("invalid repository cursor"))?;
|
||||
let position: (String, String) = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| async_graphql::Error::new("invalid repository cursor"))?;
|
||||
// PostgreSQL text cannot contain NUL. Reject it as malformed input before
|
||||
// binding either cursor component, rather than reporting a database error.
|
||||
if position.0.contains('\0') || position.1.contains('\0') {
|
||||
return Err(async_graphql::Error::new("invalid repository cursor"));
|
||||
}
|
||||
Ok(position)
|
||||
}
|
||||
|
||||
pub struct QueryRoot;
|
||||
|
||||
#[Object]
|
||||
impl QueryRoot {
|
||||
// DB-backed roots carry a base cost so aliases consume the request budget
|
||||
// even when each alias selects only one inexpensive response field.
|
||||
// `repos` performs the same full corpus scan as a max-size `reposPage`
|
||||
// (list_visible_repos_page with MAX_VISIBLE_REPO_PAGE_SIZE + 1), so it
|
||||
// is priced as the full page it is, not as a cheap alias.
|
||||
#[graphql(complexity = "50 + MAX_VISIBLE_REPO_PAGE_SIZE + child_complexity")]
|
||||
/// Complete visible repository list, up to the documented maximum number
|
||||
/// of entries. Larger lists must use reposPage; this field returns an
|
||||
/// error instead of truncating silently.
|
||||
async fn repos(&self, ctx: &Context<'_>) -> Result<Vec<RepoType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let repos = db
|
||||
.list_all_repos_deduped()
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
|
||||
// Apply the same "/" visibility gate the REST/per-repo endpoints use so
|
||||
// this surface does not enumerate private repos (#97). The caller DID is
|
||||
// threaded onto the context by optional_signature; absent = anonymous.
|
||||
let caller = ctx
|
||||
.data::<crate::auth::AuthenticatedDid>()
|
||||
.ok()
|
||||
.map(|d| d.0.as_str());
|
||||
let ids: Vec<String> = repos.iter().map(|r| r.id.clone()).collect();
|
||||
let rules_by_repo = db
|
||||
.list_visibility_rules_for_repos(&ids)
|
||||
let mut repos = db
|
||||
.list_visible_repos_page(caller, None, MAX_VISIBLE_REPO_PAGE_SIZE + 1)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
|
||||
Ok(repos
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, caller)
|
||||
})
|
||||
.map(|r| RepoType {
|
||||
name: r.name,
|
||||
owner_did: r.owner_did,
|
||||
description: r.description,
|
||||
default_branch: r.default_branch,
|
||||
created_at: r.created_at.to_rfc3339(),
|
||||
})
|
||||
.collect())
|
||||
if repos.len() > MAX_VISIBLE_REPO_PAGE_SIZE {
|
||||
return Err(async_graphql::Error::new(format!(
|
||||
"repository list exceeds {} entries; use reposPage with limit and after",
|
||||
MAX_VISIBLE_REPO_PAGE_SIZE
|
||||
)));
|
||||
}
|
||||
// Preserve the legacy activity ordering for complete, small lists.
|
||||
repos.sort_by_key(|repo| std::cmp::Reverse(repo.updated_at));
|
||||
Ok(repos.into_iter().map(repo_type).collect())
|
||||
}
|
||||
|
||||
/// Bounded visible repositories ordered by owner and name. Continue with
|
||||
/// endCursor while hasNextPage is true. Pages are not a database snapshot.
|
||||
// Every page sorts the full deduplicated corpus under COLLATE "C"
|
||||
// (list_visible_repos_page documents the accepted O(total repos) bound),
|
||||
// so the price charges the scan, not the emitted rows: a limit:1 page
|
||||
// costs the same as a max-size page, the way `repos` is priced.
|
||||
#[graphql(complexity = "50 + MAX_VISIBLE_REPO_PAGE_SIZE + child_complexity")]
|
||||
async fn repos_page(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
#[graphql(
|
||||
default = 50,
|
||||
desc = "Page size from 1 to the documented maximum; other values are rejected."
|
||||
)]
|
||||
limit: i64,
|
||||
after: Option<String>,
|
||||
) -> Result<RepoPageType> {
|
||||
if !(1..=MAX_VISIBLE_REPO_PAGE_SIZE as i64).contains(&limit) {
|
||||
return Err(async_graphql::Error::new(format!(
|
||||
"limit must be between 1 and {}",
|
||||
MAX_VISIBLE_REPO_PAGE_SIZE
|
||||
)));
|
||||
}
|
||||
let after = after.as_deref().map(parse_repo_cursor).transpose()?;
|
||||
let caller = ctx
|
||||
.data::<crate::auth::AuthenticatedDid>()
|
||||
.ok()
|
||||
.map(|d| d.0.as_str());
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let mut repos = db
|
||||
.list_visible_repos_page(
|
||||
caller,
|
||||
after
|
||||
.as_ref()
|
||||
.map(|(owner, name)| (owner.as_str(), name.as_str())),
|
||||
limit as usize + 1,
|
||||
)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
let has_next_page = repos.len() > limit as usize;
|
||||
repos.truncate(limit as usize);
|
||||
let end_cursor = repos.last().map(repo_cursor);
|
||||
Ok(RepoPageType {
|
||||
nodes: repos.into_iter().map(repo_type).collect(),
|
||||
has_next_page,
|
||||
end_cursor,
|
||||
})
|
||||
}
|
||||
|
||||
// Complexity is additive with a base reflecting the collector's fixed scan:
|
||||
// every request loads the full deduped repo set, the quarantined set, and
|
||||
// the visibility rules, then walks up to max(limit, 2048) event rows past
|
||||
// withheld ones, regardless of `limit`. A multiplicative price both
|
||||
// undercharged limit:1 (51 for that fixed work) and made a request at the
|
||||
// documented maximum unreachable (two fields at max limit cost 450 > 400).
|
||||
//
|
||||
// Decision on the collector floor (review #465, 3rd round): the up-to-2048
|
||||
// walk is bounded constant overhead that does not scale with `limit`, so
|
||||
// it is priced in the fixed base, not the limit term; the limit term
|
||||
// prices only the emitted rows. Pricing the walk at face value in the base
|
||||
// would put every refUpdates query over the 400 budget and make the field
|
||||
// unusable, so the base prices that fixed scan as a lump sum instead.
|
||||
#[graphql(
|
||||
complexity = "100 + (limit.clamp(0, MAX_VISIBLE_REF_UPDATES) as usize) + child_complexity"
|
||||
)]
|
||||
async fn ref_updates(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
repo: Option<String>,
|
||||
#[graphql(
|
||||
default = 20,
|
||||
desc = "Max 200; larger requests return the newest 200 rows (no continuation cursor)."
|
||||
desc = "Requests above the documented maximum return the newest rows up to that maximum (no continuation cursor)."
|
||||
)]
|
||||
limit: i64,
|
||||
) -> Result<Vec<RefUpdateType>> {
|
||||
@@ -105,6 +202,14 @@ impl QueryRoot {
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
// Complexity is additive: list_tasks is a single LIMIT-bounded query whose
|
||||
// cost does not scale with the selected fields, so the price is the base
|
||||
// plus the clamped limit plus the child fields. A multiplicative price
|
||||
// made a request at the documented maximum unreachable (two fields at max
|
||||
// limit cost 450 > 400).
|
||||
#[graphql(
|
||||
complexity = "50 + (limit.clamp(0, MAX_VISIBLE_TASK_PAGE_SIZE) as usize) + child_complexity"
|
||||
)]
|
||||
async fn tasks(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -112,7 +217,7 @@ impl QueryRoot {
|
||||
assignee_did: Option<String>,
|
||||
#[graphql(
|
||||
default = 50,
|
||||
desc = "Max 200; larger requests are clamped to 200 (no error). Negative values clamp to 0."
|
||||
desc = "Requests above the documented maximum are clamped to it (no error). Negative values clamp to 0."
|
||||
)]
|
||||
limit: i64,
|
||||
) -> Result<Vec<AgentTaskType>> {
|
||||
@@ -120,7 +225,7 @@ impl QueryRoot {
|
||||
// Clamp before SQL: a negative LIMIT is a client fault that Postgres
|
||||
// rejects with 2201W, which would otherwise trip the opaque DB path
|
||||
// and write an error-level log on every probe (#250 review).
|
||||
let limit = limit.clamp(0, 200);
|
||||
let limit = limit.clamp(0, MAX_VISIBLE_TASK_PAGE_SIZE);
|
||||
let tasks = db
|
||||
.list_tasks(status.as_deref(), assignee_did.as_deref(), limit)
|
||||
.await
|
||||
@@ -128,6 +233,7 @@ impl QueryRoot {
|
||||
Ok(tasks.into_iter().map(AgentTaskType::from).collect())
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn task(&self, ctx: &Context<'_>, id: String) -> Result<Option<AgentTaskType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let t = db
|
||||
@@ -141,12 +247,642 @@ impl QueryRoot {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::db::{Db, ReceivedRefUpdate, RepoRecord};
|
||||
use base64::Engine;
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
const OWNER: &str = "did:key:z6MkOwner";
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_legacy_rejects_overflow_and_pages_reach_every_visible_repo(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let total = crate::db::MAX_VISIBLE_REPO_PAGE_SIZE + 5;
|
||||
for index in 0..total {
|
||||
let name = format!("repo-{index:03}");
|
||||
db.create_repo(&repo(&name, OWNER, &name, true))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// The SQL helper itself must bound materialization, even for a caller
|
||||
// accidentally requesting an unlimited page.
|
||||
assert_eq!(
|
||||
db.list_visible_repos_page(None, None, usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
crate::db::MAX_VISIBLE_REPO_PAGE_SIZE + 1
|
||||
);
|
||||
let schema = schema(db);
|
||||
let legacy = anon(&schema, "{ repos { name } }").await;
|
||||
assert_eq!(legacy.errors.len(), 1);
|
||||
assert!(legacy.errors[0].message.contains("use reposPage"));
|
||||
assert_eq!(legacy.data, async_graphql::Value::Null);
|
||||
|
||||
let mut cursor = None;
|
||||
let mut names = Vec::new();
|
||||
loop {
|
||||
let response = schema.execute(
|
||||
async_graphql::Request::new(
|
||||
"query($after: String) { reposPage(limit: 50, after: $after) { nodes { name } hasNextPage endCursor } }",
|
||||
).variables(async_graphql::Variables::from_json(serde_json::json!({"after": cursor}))),
|
||||
).await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
let json = response.data.into_json().unwrap();
|
||||
let page = &json["reposPage"];
|
||||
let rows = page["nodes"].as_array().unwrap();
|
||||
assert!(rows.len() <= 50);
|
||||
names.extend(
|
||||
rows.iter()
|
||||
.map(|row| row["name"].as_str().unwrap().to_owned()),
|
||||
);
|
||||
if !page["hasNextPage"].as_bool().unwrap() {
|
||||
break;
|
||||
}
|
||||
let next = page["endCursor"].as_str().unwrap().to_owned();
|
||||
assert_ne!(cursor.as_ref(), Some(&next));
|
||||
cursor = Some(next);
|
||||
assert!(names.len() <= crate::db::MAX_VISIBLE_REPO_PAGE_SIZE);
|
||||
}
|
||||
assert_eq!(
|
||||
names,
|
||||
(0..total)
|
||||
.map(|index| format!("repo-{index:03}"))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_legacy_accepts_exactly_the_visible_bound(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let base_time = Utc::now();
|
||||
let total = crate::db::MAX_VISIBLE_REPO_PAGE_SIZE;
|
||||
for index in 0..total {
|
||||
let name = format!("repo-{index:03}");
|
||||
let mut r = repo(&name, OWNER, &name, true);
|
||||
// Anti-correlate updated_at with name, creation order, and index
|
||||
// so only ordering by updated_at DESC can satisfy the expectation.
|
||||
let offset = (index * 37) % total;
|
||||
r.updated_at = base_time + chrono::Duration::seconds(offset as i64);
|
||||
db.create_repo(&r).await.unwrap();
|
||||
}
|
||||
db.create_repo(&repo("hidden", OWNER, "hidden", false))
|
||||
.await
|
||||
.unwrap();
|
||||
let response = anon(&schema(db), "{ repos { name } }").await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
let repos = response.data.into_json().unwrap()["repos"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(repos.len(), total);
|
||||
let names: Vec<_> = repos.iter().filter_map(|r| r["name"].as_str()).collect();
|
||||
assert!(
|
||||
!names.contains(&"hidden"),
|
||||
"hidden repo must be excluded from legacy repos response"
|
||||
);
|
||||
let mut expected_indices = (0..total).collect::<Vec<_>>();
|
||||
expected_indices.sort_by_key(|&idx| std::cmp::Reverse((idx * 37) % total));
|
||||
let expected_names: Vec<String> = expected_indices
|
||||
.into_iter()
|
||||
.map(|index| format!("repo-{index:03}"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
expected_names
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
"legacy repos must retain activity ordering (most recently updated first)"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_page_pagination_crosses_owner_boundary(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let owner1 = "did:key:z6MkaOwner1";
|
||||
let owner2 = "did:key:z6MkbOwner2";
|
||||
let earlier_owner = "did:web:example.com";
|
||||
// This owner sorts first only after did:key normalization. Its name
|
||||
// sorts past the first cursor, so an unqualified OR name predicate
|
||||
// would incorrectly serve it again on the next page.
|
||||
//
|
||||
// Rows are inserted out of order and not in reverse asserted order.
|
||||
// Assigned IDs do not track name order, and owner2 contains both
|
||||
// "B-repo" and "a-repo" whose byte order (B < a) differs from their
|
||||
// case-folded order (a < b), ensuring lower(d.name) cannot satisfy
|
||||
// the keyset query. Sized to 6 entries so the last page is full-sized
|
||||
// (limit 2), proving hasNextPage distinguishes a full terminal page.
|
||||
db.create_repo(&repo("r5", owner2, "c-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r9", earlier_owner, "zz-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r3", owner2, "B-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r8", owner1, "z-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r2", owner1, "b-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r1", owner2, "a-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let schema = schema(db);
|
||||
let page1_resp = anon(
|
||||
&schema,
|
||||
"{ reposPage(limit: 2) { nodes { name ownerDid } hasNextPage endCursor } }",
|
||||
)
|
||||
.await;
|
||||
assert!(page1_resp.errors.is_empty(), "{:?}", page1_resp.errors);
|
||||
let p1 = page1_resp.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p1["hasNextPage"], true);
|
||||
assert_eq!(
|
||||
p1["nodes"],
|
||||
serde_json::json!([
|
||||
{"name": "zz-repo", "ownerDid": earlier_owner},
|
||||
{"name": "b-repo", "ownerDid": owner1},
|
||||
])
|
||||
);
|
||||
let cursor = p1["endCursor"].as_str().unwrap();
|
||||
|
||||
let page2_query = format!(
|
||||
"{{ reposPage(limit: 2, after: \"{cursor}\") {{ nodes {{ name ownerDid }} hasNextPage endCursor }} }}"
|
||||
);
|
||||
let page2_resp = anon(&schema, &page2_query).await;
|
||||
assert!(page2_resp.errors.is_empty(), "{:?}", page2_resp.errors);
|
||||
let p2 = page2_resp.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p2["hasNextPage"], true);
|
||||
assert_eq!(
|
||||
p2["nodes"],
|
||||
serde_json::json!([
|
||||
{"name": "z-repo", "ownerDid": owner1},
|
||||
{"name": "B-repo", "ownerDid": owner2},
|
||||
])
|
||||
);
|
||||
let cursor = p2["endCursor"].as_str().unwrap();
|
||||
let page3_query = format!(
|
||||
"{{ reposPage(limit: 2, after: \"{cursor}\") {{ nodes {{ name ownerDid }} hasNextPage endCursor }} }}"
|
||||
);
|
||||
let page3_resp = anon(&schema, &page3_query).await;
|
||||
assert!(page3_resp.errors.is_empty(), "{:?}", page3_resp.errors);
|
||||
let p3 = page3_resp.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p3["hasNextPage"], false);
|
||||
assert_eq!(
|
||||
p3["nodes"],
|
||||
serde_json::json!([
|
||||
{"name": "a-repo", "ownerDid": owner2},
|
||||
{"name": "c-repo", "ownerDid": owner2},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_page_at_documented_maximum_distinguishes_terminal_page(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let total = crate::db::MAX_VISIBLE_REPO_PAGE_SIZE;
|
||||
for index in 0..total {
|
||||
let name = format!("repo-{index:03}");
|
||||
db.create_repo(&repo(&name, OWNER, &name, true))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let schema = schema(db.clone());
|
||||
let query =
|
||||
format!("{{ reposPage(limit: {total}) {{ nodes {{ name }} hasNextPage endCursor }} }}");
|
||||
let resp200 = anon(&schema, &query).await;
|
||||
assert!(resp200.errors.is_empty(), "{:?}", resp200.errors);
|
||||
let p200 = resp200.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p200["hasNextPage"], false);
|
||||
let nodes200 = p200["nodes"].as_array().unwrap().clone();
|
||||
assert_eq!(nodes200.len(), total);
|
||||
|
||||
// Adding row 201 keeps page 1 content identical while flipping hasNextPage to true.
|
||||
let name201 = format!("repo-{total:03}");
|
||||
db.create_repo(&repo(&name201, OWNER, &name201, true))
|
||||
.await
|
||||
.unwrap();
|
||||
let resp201 = anon(&schema, &query).await;
|
||||
assert!(resp201.errors.is_empty(), "{:?}", resp201.errors);
|
||||
let p201 = resp201.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p201["hasNextPage"], true);
|
||||
assert_eq!(p201["nodes"], p200["nodes"]);
|
||||
assert_eq!(p201["endCursor"], p200["endCursor"]);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_page_visibility_matches_the_shared_gate(pool: PgPool) {
|
||||
use crate::db::VisibilityMode;
|
||||
let db = db(pool).await;
|
||||
let reader = "did:key:zReader";
|
||||
for (id, public) in [
|
||||
("open", true),
|
||||
("private", false),
|
||||
("root-deny", true),
|
||||
("root-reader", false),
|
||||
("subtree", true),
|
||||
("root-tie", true),
|
||||
("odd-star", true),
|
||||
("malformed-readers", true),
|
||||
("non-array-readers", true),
|
||||
("non-string-reader", true),
|
||||
("quarantined", true),
|
||||
] {
|
||||
db.create_repo(&repo(id, OWNER, id, public)).await.unwrap();
|
||||
}
|
||||
// Canonical and mirror copies must still collapse before pagination.
|
||||
db.create_repo(&repo("z6MkOwner/open", "z6MkOwner", "open", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo(
|
||||
"other-method",
|
||||
"did:web:z6MkOwner",
|
||||
"other-method",
|
||||
false,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
db.set_repo_quarantine("quarantined", true).await.unwrap();
|
||||
for (id, glob, readers) in [
|
||||
("root-deny", "/", vec![]),
|
||||
("root-reader", "/**", vec![reader.to_owned()]),
|
||||
("subtree", "/secret/**", vec![]),
|
||||
("root-tie", "/", vec![reader.to_owned()]),
|
||||
("root-tie", "/**", vec![]),
|
||||
("odd-star", "/*", vec![]),
|
||||
("malformed-readers", "/", vec![]),
|
||||
("non-array-readers", "/", vec![]),
|
||||
("non-string-reader", "/", vec![]),
|
||||
] {
|
||||
db.set_visibility_rule(id, glob, VisibilityMode::B, &readers, OWNER)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// The typed setter cannot create malformed JSON, but existing TEXT rows
|
||||
// can contain it. Such a rule must deny this repo, not abort the page.
|
||||
// All three deny arms of the reader_dids predicate are pinned here:
|
||||
// (a) invalid JSON, (b) valid JSON that is not an array, (c) an array
|
||||
// containing a non-string member. The Rust gate parses reader_dids as
|
||||
// Vec<String> with unwrap_or_default(), so (b) and (c) also fail to
|
||||
// parse and deny; the SQL CASE must agree on every shape.
|
||||
let rows = sqlx::query("UPDATE visibility_rules SET reader_dids = $1 WHERE repo_id = $2")
|
||||
.bind("not JSON")
|
||||
.bind("malformed-readers")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.rows_affected(), 1);
|
||||
let rows = sqlx::query("UPDATE visibility_rules SET reader_dids = $1 WHERE repo_id = $2")
|
||||
.bind("\"just-a-string\"")
|
||||
.bind("non-array-readers")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.rows_affected(), 1);
|
||||
// The named reader is listed, but the non-string member poisons the
|
||||
// whole list: both sides must still deny them.
|
||||
let rows = sqlx::query("UPDATE visibility_rules SET reader_dids = $1 WHERE repo_id = $2")
|
||||
.bind("[\"did:key:zReader\", 42]")
|
||||
.bind("non-string-reader")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.rows_affected(), 1);
|
||||
let all = db.list_all_repos_deduped().await.unwrap();
|
||||
for caller in [
|
||||
None,
|
||||
Some(OWNER),
|
||||
Some("z6MkOwner"),
|
||||
Some(reader),
|
||||
Some("zReader"),
|
||||
Some("did:web:z6MkOwner"),
|
||||
] {
|
||||
let mut expected = Vec::new();
|
||||
for record in &all {
|
||||
let rules = db.list_visibility_rules(&record.id).await.unwrap();
|
||||
if crate::visibility::listable_at_root(
|
||||
&rules,
|
||||
record.is_public,
|
||||
&record.owner_did,
|
||||
caller,
|
||||
) {
|
||||
expected.push(record.id.clone());
|
||||
}
|
||||
}
|
||||
expected.sort();
|
||||
let mut actual = db
|
||||
.list_visible_repos_page(caller, None, usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|record| record.id)
|
||||
.collect::<Vec<_>>();
|
||||
actual.sort();
|
||||
assert_eq!(actual, expected, "caller {caller:?}");
|
||||
}
|
||||
let schema = schema(db);
|
||||
for caller in [None, Some("did:key:zUnauthorized")] {
|
||||
let query = "{ repos { name } }";
|
||||
let response = match caller {
|
||||
None => anon(&schema, query).await,
|
||||
Some(caller) => authed(&schema, query, caller).await,
|
||||
};
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
let mut names: Vec<String> = response.data.into_json().unwrap()["repos"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|repo| repo["name"].as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["odd-star", "open", "subtree"]);
|
||||
}
|
||||
let query = "{ reposPage(limit: 1) { nodes { name ownerDid } hasNextPage endCursor } }";
|
||||
let response = anon(&schema, query).await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
let json = response.data.into_json().unwrap();
|
||||
let page = &json["reposPage"];
|
||||
assert_eq!(
|
||||
page["nodes"],
|
||||
serde_json::json!([{"name": "odd-star", "ownerDid": OWNER}])
|
||||
);
|
||||
assert_eq!(page["hasNextPage"], true);
|
||||
let cursor = super::parse_repo_cursor(page["endCursor"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(cursor, (OWNER.to_owned(), "odd-star".to_owned()));
|
||||
assert!(!json.to_string().contains("private"));
|
||||
assert!(!json.to_string().contains("quarantined"));
|
||||
|
||||
// Route-level check with unauthorized authenticated caller:
|
||||
// Excludes private, quarantined, and root-deny repos, matching anonymous behavior.
|
||||
let all_query = "{ reposPage(limit: 50) { nodes { name } hasNextPage } }";
|
||||
let unauth_response = authed(&schema, all_query, "did:key:zUnauthorized").await;
|
||||
assert!(
|
||||
unauth_response.errors.is_empty(),
|
||||
"{:?}",
|
||||
unauth_response.errors
|
||||
);
|
||||
let unauth_json = unauth_response.data.into_json().unwrap();
|
||||
let unauth_names: Vec<&str> = unauth_json["reposPage"]["nodes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|n| n["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(unauth_names, vec!["odd-star", "open", "subtree"]);
|
||||
let unauth_str = unauth_json.to_string();
|
||||
assert!(!unauth_str.contains("private"));
|
||||
assert!(!unauth_str.contains("quarantined"));
|
||||
assert!(!unauth_str.contains("root-deny"));
|
||||
assert!(!unauth_str.contains("root-reader"));
|
||||
assert!(!unauth_str.contains("root-tie"));
|
||||
|
||||
// Reader caller gets root-reader and root-tie, but still excludes private, quarantined, root-deny.
|
||||
let reader_response = authed(&schema, all_query, reader).await;
|
||||
assert!(
|
||||
reader_response.errors.is_empty(),
|
||||
"{:?}",
|
||||
reader_response.errors
|
||||
);
|
||||
let reader_json = reader_response.data.into_json().unwrap();
|
||||
let reader_names: Vec<&str> = reader_json["reposPage"]["nodes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|n| n["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
reader_names,
|
||||
vec!["odd-star", "open", "root-reader", "subtree"]
|
||||
);
|
||||
let reader_str = reader_json.to_string();
|
||||
assert!(!reader_str.contains("private"));
|
||||
assert!(!reader_str.contains("quarantined"));
|
||||
assert!(!reader_str.contains("root-deny"));
|
||||
assert!(!reader_str.contains("root-tie"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_page_rechecks_cursor_authority_and_exact_boundary(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let visible = repo("visible", OWNER, "a-visible", true);
|
||||
db.create_repo(&visible).await.unwrap();
|
||||
db.create_repo(&repo("hidden", OWNER, "z-private", false))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let first = anon(
|
||||
&schema,
|
||||
"{ reposPage(limit: 1) { nodes { name } hasNextPage endCursor } }",
|
||||
)
|
||||
.await;
|
||||
assert!(first.errors.is_empty());
|
||||
let first = first.data.into_json().unwrap();
|
||||
assert_eq!(first["reposPage"]["hasNextPage"], false);
|
||||
let query = format!(
|
||||
"{{ reposPage(limit: 1, after: \"{}\") {{ nodes {{ name }} hasNextPage endCursor }} }}",
|
||||
super::repo_cursor(&visible)
|
||||
);
|
||||
let owner = authed(&schema, &query, OWNER).await;
|
||||
assert!(owner.errors.is_empty());
|
||||
assert_eq!(
|
||||
owner.data.into_json().unwrap()["reposPage"]["nodes"][0]["name"],
|
||||
"z-private"
|
||||
);
|
||||
let anon = anon(&schema, &query).await;
|
||||
assert!(anon.errors.is_empty());
|
||||
assert_eq!(
|
||||
anon.data.into_json().unwrap()["reposPage"],
|
||||
serde_json::json!({
|
||||
"nodes": [], "hasNextPage": false, "endCursor": null
|
||||
})
|
||||
);
|
||||
let unauth = authed(&schema, &query, "did:key:zUnauthorized").await;
|
||||
assert!(unauth.errors.is_empty());
|
||||
assert_eq!(
|
||||
unauth.data.into_json().unwrap()["reposPage"],
|
||||
serde_json::json!({
|
||||
"nodes": [], "hasNextPage": false, "endCursor": null
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repos_page_rejects_nul_cursors_before_database_access() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let schema = schema(Arc::new(Db::for_testing(pool)));
|
||||
for position in [("did:key:zOwner\0", "repo"), ("did:key:zOwner", "repo\0")] {
|
||||
let cursor = super::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&position).unwrap());
|
||||
let query = format!("{{ reposPage(after: \"{cursor}\") {{ hasNextPage }} }}");
|
||||
let response =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), anon(&schema, &query))
|
||||
.await
|
||||
.expect("invalid cursors must be rejected without accessing the database");
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "invalid repository cursor");
|
||||
assert_eq!(response.data, async_graphql::Value::Null);
|
||||
}
|
||||
let position = ("did:key:zOwner", "repo");
|
||||
let cursor = super::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&position).unwrap());
|
||||
assert_eq!(
|
||||
super::parse_repo_cursor(&cursor).unwrap(),
|
||||
(position.0.to_owned(), position.1.to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repos_page_rejects_invalid_inputs_before_database_access() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let schema = schema(Arc::new(Db::for_testing(pool)));
|
||||
let over_max = crate::db::MAX_VISIBLE_REPO_PAGE_SIZE + 1;
|
||||
let expected_limit_message = format!(
|
||||
"limit must be between 1 and {}",
|
||||
crate::db::MAX_VISIBLE_REPO_PAGE_SIZE
|
||||
);
|
||||
// Each input pins its own rejection message: the loop must not accept
|
||||
// either message for every query, or a misattributed message (e.g. the
|
||||
// limit arm returning "invalid repository cursor") stays invisible.
|
||||
for (query, expected_message) in [
|
||||
(
|
||||
"{ reposPage(limit: 0) { hasNextPage } }".to_owned(),
|
||||
expected_limit_message.clone(),
|
||||
),
|
||||
(
|
||||
"{ reposPage(limit: -1) { hasNextPage } }".to_owned(),
|
||||
expected_limit_message.clone(),
|
||||
),
|
||||
(
|
||||
format!("{{ reposPage(limit: {over_max}) {{ hasNextPage }} }}"),
|
||||
expected_limit_message.clone(),
|
||||
),
|
||||
(
|
||||
"{ reposPage(after: \"invalid!\") { hasNextPage } }".to_owned(),
|
||||
"invalid repository cursor".to_owned(),
|
||||
),
|
||||
] {
|
||||
let response =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), anon(&schema, &query))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(
|
||||
response.errors[0].message, expected_message,
|
||||
"wrong rejection message for query: {query}"
|
||||
);
|
||||
}
|
||||
// Valid base64 and valid cursor JSON: only the length guard rejects it.
|
||||
let oversized = super::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&("did:key:reader", "a".repeat(3100))).unwrap());
|
||||
assert!(oversized.len() > 4096);
|
||||
assert!(serde_json::from_slice::<(String, String)>(
|
||||
&super::URL_SAFE_NO_PAD.decode(&oversized).unwrap()
|
||||
)
|
||||
.is_ok());
|
||||
assert!(super::parse_repo_cursor(&oversized).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parse_repo_cursor_rejects_wrong_shape_json() {
|
||||
// Valid base64 and valid JSON, but not a (String, String) pair: the
|
||||
// wrong-shape arm must report "invalid repository cursor", exactly,
|
||||
// before any database access.
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let schema = schema(Arc::new(Db::for_testing(pool)));
|
||||
for payload in [
|
||||
serde_json::json!(["only-one"]),
|
||||
serde_json::json!(["a", "b", "c"]),
|
||||
serde_json::json!({"owner": "x", "name": "y"}),
|
||||
serde_json::json!(42),
|
||||
serde_json::json!(["a", 1]),
|
||||
] {
|
||||
let cursor = super::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
|
||||
let err = super::parse_repo_cursor(&cursor).unwrap_err();
|
||||
assert_eq!(err.message, "invalid repository cursor");
|
||||
let query = format!("{{ reposPage(after: \"{cursor}\") {{ hasNextPage }} }}");
|
||||
let response =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), anon(&schema, &query))
|
||||
.await
|
||||
.expect("wrong-shape cursors must be rejected without accessing the database");
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "invalid repository cursor");
|
||||
assert_eq!(response.data, async_graphql::Value::Null);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn longest_repo_name_cursor_round_trips() {
|
||||
// #465 4th round: a cursor the server emits must always parse back.
|
||||
// Repo names are bounded at the create/fork/mirror write paths, so the
|
||||
// longest admissible name must produce a cursor under the 4096-byte
|
||||
// parse limit.
|
||||
let name = "a".repeat(crate::db::MAX_REPO_NAME_LEN);
|
||||
let record = repo("long", OWNER, &name, true);
|
||||
let cursor = super::repo_cursor(&record);
|
||||
assert!(
|
||||
cursor.len() <= 4096,
|
||||
"emitted cursor must stay under the parse limit: {}",
|
||||
cursor.len()
|
||||
);
|
||||
assert_eq!(
|
||||
super::parse_repo_cursor(&cursor).unwrap(),
|
||||
(OWNER.to_owned(), name)
|
||||
);
|
||||
}
|
||||
|
||||
/// A negative refUpdates(limit) must be accepted and clamped to zero rows,
|
||||
/// like tasks(limit: -1): the complexity formula's `limit.clamp(0, ...)`
|
||||
/// prices it at the floor and the shared collector returns before any
|
||||
/// database access. Mirrors tasks_negative_limit_clamped.
|
||||
#[tokio::test]
|
||||
async fn ref_updates_negative_limit_clamped() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let schema = schema(Arc::new(Db::for_testing(pool)));
|
||||
let resp = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
anon(&schema, "{ refUpdates(limit: -1) { repo } }"),
|
||||
)
|
||||
.await
|
||||
.expect("negative limit must clamp without accessing the database");
|
||||
assert!(
|
||||
resp.errors.is_empty(),
|
||||
"negative limit must clamp, not fail: {:?}",
|
||||
resp.errors
|
||||
);
|
||||
assert_eq!(count(&resp), 0);
|
||||
}
|
||||
|
||||
/// The mirror write path binds peer-supplied names verbatim, so it must
|
||||
/// enforce the same bound as the local write paths: an overlong peer name
|
||||
/// would otherwise make reposPage emit an unparseable cursor. The
|
||||
/// rejection happens before any database access.
|
||||
#[tokio::test]
|
||||
async fn mirror_repo_rejects_overlong_name() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let db = Db::for_testing(pool);
|
||||
let name = "b".repeat(crate::db::MAX_REPO_NAME_LEN + 1);
|
||||
let err = db
|
||||
.upsert_mirror_repo("z6MkLong", &name, "/tmp/long", None, false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("exceeds"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
async fn db(pool: PgPool) -> Arc<Db> {
|
||||
let db = Db::for_testing(pool);
|
||||
db.run_migrations().await.unwrap();
|
||||
@@ -475,12 +1211,13 @@ mod tests {
|
||||
assert_eq!(count_tasks(&resp), 0);
|
||||
}
|
||||
|
||||
/// #255: ceiling of the tasks(limit) clamp must be held (schema promises Max 200).
|
||||
/// #255: ceiling of the tasks(limit) clamp must be held (schema promises the
|
||||
/// documented maximum).
|
||||
#[sqlx::test]
|
||||
async fn tasks_limit_ceiling_clamped_to_200(pool: PgPool) {
|
||||
async fn tasks_limit_ceiling_clamped_to_max(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for i in 0..201 {
|
||||
for i in 0..crate::db::MAX_VISIBLE_TASK_PAGE_SIZE + 1 {
|
||||
db.create_task(&crate::db::AgentTask {
|
||||
id: format!("task-ceil-{i}"),
|
||||
repo_id: None,
|
||||
@@ -501,7 +1238,49 @@ mod tests {
|
||||
}
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ tasks(limit: 5000) { id } }").await;
|
||||
assert_eq!(count_tasks(&resp), 200, "limit above 200 must clamp to 200");
|
||||
assert_eq!(
|
||||
count_tasks(&resp),
|
||||
crate::db::MAX_VISIBLE_TASK_PAGE_SIZE as usize,
|
||||
"limit above the maximum must clamp to MAX_VISIBLE_TASK_PAGE_SIZE"
|
||||
);
|
||||
}
|
||||
|
||||
/// A limit above MAX_VISIBLE_REF_UPDATES must reach the resolver clamped,
|
||||
/// not be rejected: the complexity formula clamps the priced limit (so the
|
||||
/// document passes validation) and the shared collector clamps the rows
|
||||
/// (so at most MAX_VISIBLE_REF_UPDATES come back). Removing the formula
|
||||
/// clamp prices the raw limit and rejects the document; removing the
|
||||
/// collector clamp returns unbounded rows. Mirrors
|
||||
/// tasks_limit_ceiling_clamped_to_max.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_limit_ceiling_clamped_to_max(pool: PgPool) {
|
||||
use crate::api::events::MAX_VISIBLE_REF_UPDATES;
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("r1", OWNER, "widget", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let total = MAX_VISIBLE_REF_UPDATES as usize + 1;
|
||||
for i in 0..total {
|
||||
let mut row = ref_row(&format!("ceil{i}"), "z6MkOwner/widget");
|
||||
row.timestamp = format!("2026-07-01T10:{:02}:{:02}+00:00", i / 60, i % 60);
|
||||
db.insert_ref_update(&row).await.unwrap();
|
||||
}
|
||||
let schema = schema(db);
|
||||
let query = format!(
|
||||
"{{ refUpdates(limit: {}) {{ repo }} }}",
|
||||
MAX_VISIBLE_REF_UPDATES + 4800
|
||||
);
|
||||
let resp = anon(&schema, &query).await;
|
||||
assert!(
|
||||
resp.errors.is_empty(),
|
||||
"limit above the maximum must clamp, not fail: {:?}",
|
||||
resp.errors
|
||||
);
|
||||
assert_eq!(
|
||||
count(&resp),
|
||||
MAX_VISIBLE_REF_UPDATES as usize,
|
||||
"limit above the maximum must clamp to MAX_VISIBLE_REF_UPDATES"
|
||||
);
|
||||
}
|
||||
|
||||
fn count_tasks(resp: &async_graphql::Response) -> usize {
|
||||
|
||||
@@ -20,6 +20,7 @@ impl SubscriptionRoot {
|
||||
/// `if announce` block, `api/repos.rs`). This is a single-point invariant —
|
||||
/// any new sender to `ref_update_tx` MUST be `announce`-gated, or private-repo
|
||||
/// ref metadata leaks here to unauthenticated subscribers (#112/#114 class).
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn ref_updates(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -46,6 +47,7 @@ impl SubscriptionRoot {
|
||||
})
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn task_events(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
|
||||
@@ -11,6 +11,14 @@ pub struct RepoType {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(SimpleObject)]
|
||||
pub struct RepoPageType {
|
||||
pub nodes: Vec<RepoType>,
|
||||
pub has_next_page: bool,
|
||||
/// Position after the last returned visible repository; null for an empty page.
|
||||
pub end_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(SimpleObject, Clone)]
|
||||
pub struct AgentTaskType {
|
||||
pub id: String,
|
||||
|
||||
@@ -2638,6 +2638,7 @@ mod tests {
|
||||
/// type probe carries its oid on stdin rather than in argv, so an oid appears in
|
||||
/// the log only once an object has already got past its probe, and a healthy
|
||||
/// object costs two invocations to a faulting one's one.
|
||||
#[cfg(unix)]
|
||||
fn objects_attempted(log: &std::path::Path) -> usize {
|
||||
std::fs::read_to_string(log)
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -912,6 +912,7 @@ mod tests {
|
||||
/// probe carries its oid on stdin rather than in argv, so an oid appears in the log only
|
||||
/// once an object has already got past its probe, and a healthy object costs two
|
||||
/// invocations to a faulting one's one.
|
||||
#[cfg(unix)]
|
||||
fn objects_attempted(log: &std::path::Path) -> usize {
|
||||
std::fs::read_to_string(log)
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -1330,6 +1330,18 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Use a rooted path without a Windows drive prefix in the remote fixture.
|
||||
/// The destination still resolves on the temp directory's current drive,
|
||||
/// while the remote's relative path contains no illegal colon component.
|
||||
fn absolute_slug_path(path: &Path) -> String {
|
||||
assert!(path.is_absolute());
|
||||
path.components()
|
||||
.filter(|part| !matches!(part, std::path::Component::Prefix(_)))
|
||||
.collect::<std::path::PathBuf>()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn process_batch_rejects_slug_escaping_repos_dir(pool: PgPool) {
|
||||
// The verified escape from #272: `PathBuf::join` discards everything
|
||||
@@ -1345,12 +1357,13 @@ mod tests {
|
||||
// fails on but leaves the parent it created, so assert on both.
|
||||
let outside = TempDir::new().unwrap();
|
||||
let escape_dir = outside.path().join("nest");
|
||||
let slug = format!("a/{}/escape", escape_dir.display());
|
||||
let escape_path = absolute_slug_path(&escape_dir);
|
||||
let slug = format!("a/{escape_path}/escape");
|
||||
let escape_target = escape_dir.join("escape.git");
|
||||
|
||||
// Serve the composed URL for real, so a run without the guard genuinely
|
||||
// clones outside the root rather than merely failing at git.
|
||||
let rel = format!("a{}/escape", escape_dir.display());
|
||||
let rel = format!("a{escape_path}/escape");
|
||||
let (_remote, peer_url) = rooted_remote(&[&rel]);
|
||||
|
||||
let did = "did:key:z6MkAttacker";
|
||||
@@ -1470,6 +1483,7 @@ mod tests {
|
||||
// ── canonical containment before the git call (issue #272) ───────────────
|
||||
|
||||
/// Every ref in `repo`, as one string, for a before/after comparison.
|
||||
#[cfg(unix)]
|
||||
fn refs_of(repo: &Path) -> String {
|
||||
let out = Command::new("git")
|
||||
.args(["-C", repo.to_str().unwrap(), "for-each-ref"])
|
||||
@@ -1802,6 +1816,7 @@ mod tests {
|
||||
/// starvation tests is fixed rather than dependent on how fast the loop
|
||||
/// runs. Two rows enqueued in the same microsecond would otherwise order
|
||||
/// arbitrarily.
|
||||
#[cfg(unix)]
|
||||
async fn enqueue_at(db: &Db, pool: &PgPool, repo: &str, did: &str, enqueued_at: &str) {
|
||||
enqueue(db, repo, did).await;
|
||||
sqlx::query("UPDATE sync_queue SET enqueued_at = $1 WHERE repo = $2")
|
||||
@@ -1995,7 +2010,8 @@ mod tests {
|
||||
|
||||
let outside = TempDir::new().unwrap();
|
||||
let escape_dir = outside.path().join("nest");
|
||||
let slug = format!("a/{}/gitlawb-probe", escape_dir.display());
|
||||
let escape_path = absolute_slug_path(&escape_dir);
|
||||
let slug = format!("a/{escape_path}/gitlawb-probe");
|
||||
let escape_target = escape_dir.join("gitlawb-probe.git");
|
||||
|
||||
// Two things this fixture must get right or the test is green for the
|
||||
@@ -2006,7 +2022,7 @@ mod tests {
|
||||
// run without the guard genuinely clones outside the root instead of
|
||||
// just failing at git. Db::upsert_peer cannot seed this row: it gates on
|
||||
// is_public_http_url, which rejects file://.
|
||||
let rel = format!("a{}/gitlawb-probe", escape_dir.display());
|
||||
let rel = format!("a{escape_path}/gitlawb-probe");
|
||||
let (_remote, peer_url) = rooted_remote(&[&rel]);
|
||||
let did = "did:key:z6MkAttacker";
|
||||
seed_local_peer(&pool, did, &peer_url).await;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Native Git fixtures shared by timing tests on Unix and Windows.
|
||||
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The fixture delays or hangs in its own process, so the production watchdog
|
||||
/// can terminate it without depending on a shell or descendant process support.
|
||||
pub(super) enum Behavior<'a> {
|
||||
Delay(u64),
|
||||
Hang,
|
||||
HangOid(&'a str),
|
||||
HangRepo(&'a str),
|
||||
LogTypes(&'a Path),
|
||||
}
|
||||
|
||||
pub(super) struct GitShim {
|
||||
_directory: tempfile::TempDir,
|
||||
executable: PathBuf,
|
||||
}
|
||||
|
||||
impl Deref for GitShim {
|
||||
type Target = Path;
|
||||
|
||||
fn deref(&self) -> &Path {
|
||||
&self.executable
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn create(name: &str, behavior: Behavior<'_>) -> GitShim {
|
||||
static COMPILED: OnceLock<tempfile::TempDir> = OnceLock::new();
|
||||
let compiled = COMPILED.get_or_init(|| {
|
||||
let directory = tempfile::tempdir().expect("native Git fixture directory");
|
||||
let source = directory.path().join("git_shim.rs");
|
||||
std::fs::write(&source, include_str!("../tests/fixtures/git_shim.rs"))
|
||||
.expect("write native Git fixture source");
|
||||
let executable = directory
|
||||
.path()
|
||||
.join(format!("git-shim{}", std::env::consts::EXE_SUFFIX));
|
||||
let output =
|
||||
std::process::Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()))
|
||||
.arg("--edition=2021")
|
||||
.arg("-Dwarnings")
|
||||
.arg(&source)
|
||||
.arg("-o")
|
||||
.arg(executable)
|
||||
.output()
|
||||
.expect("compile native Git fixture with the installed Rust toolchain");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
directory
|
||||
});
|
||||
let directory = tempfile::Builder::new()
|
||||
.prefix(name)
|
||||
.tempdir()
|
||||
.expect("Git fixture instance");
|
||||
let file_name = format!("git-shim{}", std::env::consts::EXE_SUFFIX);
|
||||
let executable = directory.path().join(&file_name);
|
||||
std::fs::copy(compiled.path().join(file_name), &executable).expect("copy native Git fixture");
|
||||
let config = match behavior {
|
||||
Behavior::Delay(milliseconds) => format!("delay\n{milliseconds}"),
|
||||
Behavior::Hang => "hang\n".to_owned(),
|
||||
Behavior::HangOid(oid) => format!("hang-oid\n{oid}"),
|
||||
Behavior::HangRepo(repo) => format!("hang-repo\n{repo}"),
|
||||
Behavior::LogTypes(path) => format!("log-types\n{}", path.display()),
|
||||
};
|
||||
std::fs::write(directory.path().join("config"), config).expect("configure native Git fixture");
|
||||
GitShim {
|
||||
_directory: directory,
|
||||
executable,
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ use gitlawb_core::identity::Keypair;
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[path = "test_git_shim.rs"]
|
||||
mod git_shim;
|
||||
|
||||
/// Build an [`AppState`] over a real, migrated Postgres pool (from `#[sqlx::test]`).
|
||||
/// Runs the schema migrations first, because the per-test database starts empty.
|
||||
///
|
||||
@@ -559,11 +562,12 @@ mod tests {
|
||||
/// #174 (SC1, load-bearing): a saturated READ pool must NOT shed an
|
||||
/// authenticated push — the write pool is a separate budget. Read pool at zero,
|
||||
/// write pool with capacity: the push proceeds PAST admission (it then errors on
|
||||
/// the placeholder DB, but crucially it is not a 503). Route git-receive-pack
|
||||
/// the closed DB, with db_unavailable rather than overloaded). Route git-receive-pack
|
||||
/// back to the read pool and this goes red — that is the isolation proof.
|
||||
#[tokio::test]
|
||||
async fn git_receive_pack_not_shed_by_exhausted_read_pool() {
|
||||
let mut state = test_state_lazy();
|
||||
state.db.pool().close().await;
|
||||
// Read pool exhausted as if a flood of anonymous clones held every slot.
|
||||
state.git_read_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
// Write pool keeps its default capacity from test_state_lazy.
|
||||
@@ -585,10 +589,13 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"an exhausted READ pool must not shed a push — the write pool is a separate budget (#174)"
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
body["error"],
|
||||
crate::error::DB_UNAVAILABLE_CODE,
|
||||
"the push must clear admission and reach the deliberately closed database"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11195,14 +11202,10 @@ mod tests {
|
||||
seed_legacy_pin(&pool, &bare, &fx.public_oid, Some(&repo.id)).await;
|
||||
|
||||
// A git that takes 300ms per invocation (the read makes two: type, then content).
|
||||
let slow_git = std::env::temp_dir().join(format!("gl-slow-git-{short}"));
|
||||
std::fs::write(&slow_git, "#!/bin/sh\nsleep 0.3\nexec git \"$@\"\n").expect("write shim");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&slow_git, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod shim");
|
||||
}
|
||||
let slow_git = git_shim::create(
|
||||
&format!("gl-slow-git-{short}"),
|
||||
git_shim::Behavior::Delay(300),
|
||||
);
|
||||
|
||||
let ticks = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let ticker = {
|
||||
@@ -12149,19 +12152,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Write an executable `git` stand-in and return its path.
|
||||
fn write_git_shim(name: &str, script: &str) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(name);
|
||||
std::fs::write(&path, script).expect("write the git shim");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod the git shim");
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// F6 scenario 1 (#173 round 13): one hung candidate must not starve the rows behind
|
||||
/// it in the same pass. `DiscoveryCtx` is loaded once per pass, so before the per-row
|
||||
/// slice every source-less row in a pass shared ONE deadline: the first row's wedged
|
||||
@@ -12201,23 +12191,9 @@ mod tests {
|
||||
|
||||
// The type stage feeds the oid on STDIN (`cat-file --batch-check`) and the
|
||||
// content stage puts it in argv, so the stand-in has to look in both places.
|
||||
let git_bin = write_git_shim(
|
||||
let git_bin = git_shim::create(
|
||||
&format!("gl-hung-git-{short}"),
|
||||
&format!(
|
||||
"#!/bin/sh\n\
|
||||
if [ \"$2\" = \"--batch-check\" ]; then\n\
|
||||
\x20 oid=$(cat)\n\
|
||||
\x20 case \"$oid\" in\n\
|
||||
\x20 {hung_oid}) sleep 30; exit 1 ;;\n\
|
||||
\x20 esac\n\
|
||||
\x20 printf '%s\\n' \"$oid\" | git \"$@\"\n\
|
||||
\x20 exit $?\n\
|
||||
fi\n\
|
||||
case \"$*\" in\n\
|
||||
\x20 *{hung_oid}*) sleep 30; exit 1 ;;\n\
|
||||
esac\n\
|
||||
exec git \"$@\"\n"
|
||||
),
|
||||
git_shim::Behavior::HangOid(&hung_oid),
|
||||
);
|
||||
|
||||
let stats = tokio::time::timeout(
|
||||
@@ -12302,10 +12278,7 @@ mod tests {
|
||||
|
||||
// Wedges on every invocation, so no row can ever be repaired and the only
|
||||
// question left is what each one COSTS.
|
||||
let git_bin = write_git_shim(
|
||||
&format!("gl-spent-git-{short}"),
|
||||
"#!/bin/sh\nsleep 30\nexit 1\n",
|
||||
);
|
||||
let git_bin = git_shim::create(&format!("gl-spent-git-{short}"), git_shim::Behavior::Hang);
|
||||
|
||||
let stats = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(60),
|
||||
@@ -12909,10 +12882,7 @@ mod tests {
|
||||
seed_legacy_pin(&pool, &src, oid, None).await;
|
||||
}
|
||||
|
||||
let git_bin = write_git_shim(
|
||||
&format!("gl-starve-git-{short}"),
|
||||
"#!/bin/sh\nsleep 30\nexit 1\n",
|
||||
);
|
||||
let git_bin = git_shim::create(&format!("gl-starve-git-{short}"), git_shim::Behavior::Hang);
|
||||
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(120),
|
||||
@@ -12980,9 +12950,9 @@ mod tests {
|
||||
let (raw_cid, provider_cid) = seed_legacy_pin(&pool, &src, &fx.public_oid, None).await;
|
||||
|
||||
// Wedges only inside the position-nine repo, which the sweep enters by cwd.
|
||||
let git_bin = write_git_shim(
|
||||
let git_bin = git_shim::create(
|
||||
&format!("gl-mid-git-{short}"),
|
||||
"#!/bin/sh\ncase \"$(pwd)\" in\n */midcand9.git) sleep 30; exit 1 ;;\nesac\nexec git \"$@\"\n",
|
||||
git_shim::Behavior::HangRepo("midcand9.git"),
|
||||
);
|
||||
|
||||
let first = tokio::time::timeout(
|
||||
@@ -13144,19 +13114,9 @@ mod tests {
|
||||
|
||||
let log = std::env::temp_dir().join(format!("gl-ali-log-{short}"));
|
||||
let _ = std::fs::remove_file(&log);
|
||||
let git_bin = write_git_shim(
|
||||
let git_bin = git_shim::create(
|
||||
&format!("gl-ali-git-{short}"),
|
||||
&format!(
|
||||
"#!/bin/sh\n\
|
||||
if [ \"$2\" = \"--batch-check\" ]; then\n\
|
||||
\x20 oid=$(cat)\n\
|
||||
\x20 printf '%s %s\\n' \"$oid\" \"$(basename $(pwd))\" >> {log}\n\
|
||||
\x20 printf '%s\\n' \"$oid\" | git \"$@\"\n\
|
||||
\x20 exit $?\n\
|
||||
fi\n\
|
||||
exec git \"$@\"\n",
|
||||
log = log.display()
|
||||
),
|
||||
git_shim::Behavior::LogTypes(&log),
|
||||
);
|
||||
|
||||
let mut traversal = crate::ipfs_pin::DiscoveryTraversalState::default();
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
let executable = std::env::current_exe()?;
|
||||
let config = std::fs::read_to_string(executable.parent().unwrap().join("config"))?;
|
||||
let (mode, parameter) = config.split_once('\n').unwrap_or((&config, ""));
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if mode == "delay" {
|
||||
std::thread::sleep(Duration::from_millis(parameter.parse().unwrap()));
|
||||
}
|
||||
let batch = args.get(1).is_some_and(|arg| arg == "--batch-check");
|
||||
let input = if batch {
|
||||
let mut bytes = Vec::new();
|
||||
std::io::stdin().read_to_end(&mut bytes)?;
|
||||
Some(bytes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cwd = std::env::current_dir()?;
|
||||
let hang = mode == "hang"
|
||||
|| (mode == "hang-repo" && cwd.file_name().is_some_and(|name| name == parameter))
|
||||
|| (mode == "hang-oid"
|
||||
&& (args.iter().any(|arg| arg == parameter)
|
||||
|| input
|
||||
.as_ref()
|
||||
.is_some_and(|bytes| String::from_utf8_lossy(bytes).trim() == parameter)));
|
||||
if hang {
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
std::process::exit(1);
|
||||
}
|
||||
if mode == "log-types" && batch {
|
||||
let mut log = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(parameter)?;
|
||||
writeln!(
|
||||
log,
|
||||
"{} {}",
|
||||
String::from_utf8_lossy(input.as_ref().unwrap()).trim(),
|
||||
cwd.file_name().unwrap().to_string_lossy()
|
||||
)?;
|
||||
}
|
||||
let mut child = Command::new("git")
|
||||
.args(&args)
|
||||
.stdin(if input.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::inherit()
|
||||
})
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()?;
|
||||
if let Some(bytes) = input {
|
||||
child.stdin.take().unwrap().write_all(&bytes)?;
|
||||
}
|
||||
std::process::exit(child.wait()?.code().unwrap_or(1));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gl"
|
||||
description = "The Twigpine CLI — identity management, node control, MCP server"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
version = "0.8.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "icaptcha-client"
|
||||
version = "0.4.0" # x-release-please-version
|
||||
version = "0.8.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -8,6 +8,7 @@ Step-by-step guide to staking $GITLAWB, registering your node on-chain, and earn
|
||||
|
||||
- A wallet with at least **10,000 $GITLAWB** (minimum stake) plus a small amount of ETH on Base for gas
|
||||
- Docker or Rust 1.91+ (for running the node process)
|
||||
- PostgreSQL 16+ — the node uses `pg_input_is_valid`, which exists only on PostgreSQL 16 and later. The bundled `docker-compose.yml` pins `postgres:16-alpine`; if you supply your own `DATABASE_URL`, the server must be 16+.
|
||||
- A public HTTP URL (your-host.com) — can be a VPS, Fly.io app, or anything reachable. A Fly.io config is provided at `infra/fly/fly.toml` (deploy from the repo root with `fly deploy -c infra/fly/fly.toml`)
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# GraphQL repository pagination
|
||||
|
||||
`repos` returns the complete visible repository list when it contains at most
|
||||
200 entries. Larger lists return a GraphQL error directing the caller to
|
||||
`reposPage`; they are never silently truncated.
|
||||
|
||||
Use `reposPage` for larger lists:
|
||||
|
||||
```graphql
|
||||
query Repositories($after: String) {
|
||||
reposPage(limit: 50, after: $after) {
|
||||
nodes {
|
||||
name
|
||||
ownerDid
|
||||
}
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Start with `after: null`. When `hasNextPage` is true, pass the returned
|
||||
`endCursor` as `after` on the next request. Stop when `hasNextPage` is false.
|
||||
An empty page has a null `endCursor`. The default limit is 50 and the maximum
|
||||
page size is 200; limits below 1 or above 200 are rejected (see Query budgets
|
||||
below). Malformed cursors, including decoded owner or name strings containing
|
||||
NUL, return `invalid repository cursor` before database access.
|
||||
|
||||
Pages are ordered by normalized owner DID and repository name. Visibility and
|
||||
mirror deduplication are applied in the database before limiting the page;
|
||||
hidden and quarantined repositories do not occupy page slots or create a
|
||||
continuation signal. Cursors contain a position from the last returned visible
|
||||
repository, not a permission grant. Each request checks the current caller's
|
||||
visibility independently. Keep the same caller while traversing a list.
|
||||
Malformed reader lists in a repository's root visibility rule deny access to
|
||||
non-owners for that repository while other visible repositories remain listable.
|
||||
|
||||
Pagination is not a snapshot: concurrent renames, ownership changes, or visibility
|
||||
changes can alter subsequent pages. Treat cursors as opaque and restart from the
|
||||
first page when a fresh complete traversal is needed. Small legacy `repos`
|
||||
queries retain their activity ordering.
|
||||
|
||||
## Query budgets
|
||||
|
||||
Every GraphQL document is validated against two budgets before any resolver
|
||||
runs. Both apply per document (per request), not per client: `/graphql` has
|
||||
no per-caller rate limiting, so a rejected document can always be retried
|
||||
smaller. The constants live in `crates/gitlawb-node/src/graphql/mod.rs`.
|
||||
|
||||
- **Complexity budget: 400 per document** (`GRAPHQL_MAX_COMPLEXITY`). Each
|
||||
root field carries a base cost plus the clamped page size plus the selected
|
||||
child fields:
|
||||
- `repos`: 50 + `MAX_VISIBLE_REPO_PAGE_SIZE` + child fields. `repos`
|
||||
always scans the full visible corpus, so it is priced as the full page
|
||||
it is.
|
||||
- `reposPage`: 50 + `MAX_VISIBLE_REPO_PAGE_SIZE` + child fields. Every
|
||||
page request sorts the full deduplicated corpus, so the price does not
|
||||
depend on `limit`: seven `reposPage(limit: 1)` aliases cost the same as
|
||||
seven max-size pages.
|
||||
- `refUpdates`: 100 + clamped `limit` (`MAX_VISIBLE_REF_UPDATES`) + child
|
||||
fields. The 100 base covers the collector's fixed scan (the deduplicated
|
||||
repo set, the quarantined set, the visibility rules, and the bounded
|
||||
up-to-2048-row walk past withheld rows); the limit term prices only the
|
||||
rows returned.
|
||||
- `tasks`: 50 + clamped `limit` (`MAX_VISIBLE_TASK_PAGE_SIZE`) + child
|
||||
fields.
|
||||
- `task`, mutations, subscriptions: 50 + child fields.
|
||||
|
||||
A document whose total exceeds 400 is rejected with
|
||||
`Query is too complex.` before any resolver runs, so an over-budget
|
||||
document needs fewer aliases or fewer child fields. A smaller `limit` does
|
||||
not help `reposPage` or `repos`: both are priced at the full
|
||||
`MAX_VISIBLE_REPO_PAGE_SIZE` scan regardless of `limit`.
|
||||
|
||||
- **Depth cap: 14 per document** (`GRAPHQL_MAX_DEPTH`). Documents nested
|
||||
deeper than 14 selections are rejected with `Query is nested too deep.`
|
||||
Reference in new issue
Block a user