@yolk-sdk/emulators 0.1.0-canary.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +2135 -0
- package/dist/anthropic.d.mts +269 -0
- package/dist/anthropic.d.mts.map +1 -0
- package/dist/anthropic.mjs +177 -0
- package/dist/anthropic.mjs.map +1 -0
- package/dist/chat-completions.d.mts +288 -0
- package/dist/chat-completions.d.mts.map +1 -0
- package/dist/chat-completions.mjs +451 -0
- package/dist/chat-completions.mjs.map +1 -0
- package/dist/codex.d.mts +265 -0
- package/dist/codex.d.mts.map +1 -0
- package/dist/codex.mjs +199 -0
- package/dist/codex.mjs.map +1 -0
- package/dist/dropbox/api.d.mts +63 -0
- package/dist/dropbox/api.d.mts.map +1 -0
- package/dist/dropbox/api.mjs +565 -0
- package/dist/dropbox/api.mjs.map +1 -0
- package/dist/dropbox/state.d.mts +136 -0
- package/dist/dropbox/state.d.mts.map +1 -0
- package/dist/dropbox/state.mjs +209 -0
- package/dist/dropbox/state.mjs.map +1 -0
- package/dist/dropbox.d.mts +79 -0
- package/dist/dropbox.d.mts.map +1 -0
- package/dist/dropbox.mjs +124 -0
- package/dist/dropbox.mjs.map +1 -0
- package/dist/email-fixtures.d.mts +36 -0
- package/dist/email-fixtures.d.mts.map +1 -0
- package/dist/email-fixtures.mjs +1080 -0
- package/dist/email-fixtures.mjs.map +1 -0
- package/dist/email.d.mts +160 -0
- package/dist/email.d.mts.map +1 -0
- package/dist/email.mjs +608 -0
- package/dist/email.mjs.map +1 -0
- package/dist/emulator-compose.d.mts +40 -0
- package/dist/emulator-compose.d.mts.map +1 -0
- package/dist/emulator-compose.mjs +91 -0
- package/dist/emulator-compose.mjs.map +1 -0
- package/dist/emulator-http.d.mts +53 -0
- package/dist/emulator-http.d.mts.map +1 -0
- package/dist/emulator-http.mjs +117 -0
- package/dist/emulator-http.mjs.map +1 -0
- package/dist/emulator-kernel.d.mts +176 -0
- package/dist/emulator-kernel.d.mts.map +1 -0
- package/dist/emulator-kernel.mjs +413 -0
- package/dist/emulator-kernel.mjs.map +1 -0
- package/dist/fixture-route.d.mts +129 -0
- package/dist/fixture-route.d.mts.map +1 -0
- package/dist/fixture-route.mjs +257 -0
- package/dist/fixture-route.mjs.map +1 -0
- package/dist/fortnox/api.d.mts +92 -0
- package/dist/fortnox/api.d.mts.map +1 -0
- package/dist/fortnox/api.mjs +750 -0
- package/dist/fortnox/api.mjs.map +1 -0
- package/dist/fortnox/state.d.mts +533 -0
- package/dist/fortnox/state.d.mts.map +1 -0
- package/dist/fortnox/state.mjs +612 -0
- package/dist/fortnox/state.mjs.map +1 -0
- package/dist/fortnox.d.mts +128 -0
- package/dist/fortnox.d.mts.map +1 -0
- package/dist/fortnox.mjs +403 -0
- package/dist/fortnox.mjs.map +1 -0
- package/dist/gateway-evaluate-recordings.d.mts +12 -0
- package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
- package/dist/gateway-evaluate-recordings.mjs +128 -0
- package/dist/gateway-evaluate-recordings.mjs.map +1 -0
- package/dist/gateway.d.mts +272 -0
- package/dist/gateway.d.mts.map +1 -0
- package/dist/gateway.mjs +400 -0
- package/dist/gateway.mjs.map +1 -0
- package/dist/github/api.d.mts +63 -0
- package/dist/github/api.d.mts.map +1 -0
- package/dist/github/api.mjs +527 -0
- package/dist/github/api.mjs.map +1 -0
- package/dist/github/state.d.mts +272 -0
- package/dist/github/state.d.mts.map +1 -0
- package/dist/github/state.mjs +303 -0
- package/dist/github/state.mjs.map +1 -0
- package/dist/github.d.mts +77 -0
- package/dist/github.d.mts.map +1 -0
- package/dist/github.mjs +180 -0
- package/dist/github.mjs.map +1 -0
- package/dist/google/calendar.d.mts +10 -0
- package/dist/google/calendar.d.mts.map +1 -0
- package/dist/google/calendar.mjs +252 -0
- package/dist/google/calendar.mjs.map +1 -0
- package/dist/google/drive.d.mts +8 -0
- package/dist/google/drive.d.mts.map +1 -0
- package/dist/google/drive.mjs +252 -0
- package/dist/google/drive.mjs.map +1 -0
- package/dist/google/gmail.d.mts +10 -0
- package/dist/google/gmail.d.mts.map +1 -0
- package/dist/google/gmail.mjs +591 -0
- package/dist/google/gmail.mjs.map +1 -0
- package/dist/google/shared.d.mts +107 -0
- package/dist/google/shared.d.mts.map +1 -0
- package/dist/google/shared.mjs +146 -0
- package/dist/google/shared.mjs.map +1 -0
- package/dist/google/state.d.mts +385 -0
- package/dist/google/state.d.mts.map +1 -0
- package/dist/google/state.mjs +496 -0
- package/dist/google/state.mjs.map +1 -0
- package/dist/google.d.mts +75 -0
- package/dist/google.d.mts.map +1 -0
- package/dist/google.mjs +210 -0
- package/dist/google.mjs.map +1 -0
- package/dist/linkedin-search/api.d.mts +45 -0
- package/dist/linkedin-search/api.d.mts.map +1 -0
- package/dist/linkedin-search/api.mjs +181 -0
- package/dist/linkedin-search/api.mjs.map +1 -0
- package/dist/linkedin-search/state.d.mts +123 -0
- package/dist/linkedin-search/state.d.mts.map +1 -0
- package/dist/linkedin-search/state.mjs +261 -0
- package/dist/linkedin-search/state.mjs.map +1 -0
- package/dist/linkedin-search.d.mts +69 -0
- package/dist/linkedin-search.d.mts.map +1 -0
- package/dist/linkedin-search.mjs +158 -0
- package/dist/linkedin-search.mjs.map +1 -0
- package/dist/mcp/api.d.mts +44 -0
- package/dist/mcp/api.d.mts.map +1 -0
- package/dist/mcp/api.mjs +557 -0
- package/dist/mcp/api.mjs.map +1 -0
- package/dist/mcp/recordings.d.mts +40 -0
- package/dist/mcp/recordings.d.mts.map +1 -0
- package/dist/mcp/recordings.mjs +2390 -0
- package/dist/mcp/recordings.mjs.map +1 -0
- package/dist/mcp/state.d.mts +71 -0
- package/dist/mcp/state.d.mts.map +1 -0
- package/dist/mcp/state.mjs +83 -0
- package/dist/mcp/state.mjs.map +1 -0
- package/dist/mcp.d.mts +84 -0
- package/dist/mcp.d.mts.map +1 -0
- package/dist/mcp.mjs +241 -0
- package/dist/mcp.mjs.map +1 -0
- package/dist/messages.d.mts +183 -0
- package/dist/messages.d.mts.map +1 -0
- package/dist/messages.mjs +532 -0
- package/dist/messages.mjs.map +1 -0
- package/dist/microsoft/api.d.mts +47 -0
- package/dist/microsoft/api.d.mts.map +1 -0
- package/dist/microsoft/api.mjs +178 -0
- package/dist/microsoft/api.mjs.map +1 -0
- package/dist/microsoft/calendar.d.mts +30 -0
- package/dist/microsoft/calendar.d.mts.map +1 -0
- package/dist/microsoft/calendar.mjs +271 -0
- package/dist/microsoft/calendar.mjs.map +1 -0
- package/dist/microsoft/drive.d.mts +36 -0
- package/dist/microsoft/drive.d.mts.map +1 -0
- package/dist/microsoft/drive.mjs +298 -0
- package/dist/microsoft/drive.mjs.map +1 -0
- package/dist/microsoft/graph.d.mts +198 -0
- package/dist/microsoft/graph.d.mts.map +1 -0
- package/dist/microsoft/graph.mjs +264 -0
- package/dist/microsoft/graph.mjs.map +1 -0
- package/dist/microsoft/mail.d.mts +48 -0
- package/dist/microsoft/mail.d.mts.map +1 -0
- package/dist/microsoft/mail.mjs +488 -0
- package/dist/microsoft/mail.mjs.map +1 -0
- package/dist/microsoft/state.d.mts +480 -0
- package/dist/microsoft/state.d.mts.map +1 -0
- package/dist/microsoft/state.mjs +625 -0
- package/dist/microsoft/state.mjs.map +1 -0
- package/dist/microsoft.d.mts +168 -0
- package/dist/microsoft.d.mts.map +1 -0
- package/dist/microsoft.mjs +483 -0
- package/dist/microsoft.mjs.map +1 -0
- package/dist/node.d.mts +47 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +178 -0
- package/dist/node.mjs.map +1 -0
- package/dist/notion/api.d.mts +51 -0
- package/dist/notion/api.d.mts.map +1 -0
- package/dist/notion/api.mjs +507 -0
- package/dist/notion/api.mjs.map +1 -0
- package/dist/notion/state.d.mts +329 -0
- package/dist/notion/state.d.mts.map +1 -0
- package/dist/notion/state.mjs +432 -0
- package/dist/notion/state.mjs.map +1 -0
- package/dist/notion.d.mts +72 -0
- package/dist/notion.d.mts.map +1 -0
- package/dist/notion.mjs +127 -0
- package/dist/notion.mjs.map +1 -0
- package/dist/openai.d.mts +165 -0
- package/dist/openai.d.mts.map +1 -0
- package/dist/openai.mjs +141 -0
- package/dist/openai.mjs.map +1 -0
- package/dist/opencode-recordings.d.mts +7 -0
- package/dist/opencode-recordings.d.mts.map +1 -0
- package/dist/opencode-recordings.mjs +222 -0
- package/dist/opencode-recordings.mjs.map +1 -0
- package/dist/opencode.d.mts +70 -0
- package/dist/opencode.d.mts.map +1 -0
- package/dist/opencode.mjs +217 -0
- package/dist/opencode.mjs.map +1 -0
- package/dist/r2-fixtures.d.mts +36 -0
- package/dist/r2-fixtures.d.mts.map +1 -0
- package/dist/r2-fixtures.mjs +245 -0
- package/dist/r2-fixtures.mjs.map +1 -0
- package/dist/r2-guard.d.mts +43 -0
- package/dist/r2-guard.d.mts.map +1 -0
- package/dist/r2-guard.mjs +215 -0
- package/dist/r2-guard.mjs.map +1 -0
- package/dist/r2.d.mts +166 -0
- package/dist/r2.d.mts.map +1 -0
- package/dist/r2.mjs +517 -0
- package/dist/r2.mjs.map +1 -0
- package/dist/responses.d.mts +231 -0
- package/dist/responses.d.mts.map +1 -0
- package/dist/responses.mjs +556 -0
- package/dist/responses.mjs.map +1 -0
- package/dist/route-evidence.d.mts +45 -0
- package/dist/route-evidence.d.mts.map +1 -0
- package/dist/route-evidence.mjs +56 -0
- package/dist/route-evidence.mjs.map +1 -0
- package/dist/router.d.mts +93 -0
- package/dist/router.d.mts.map +1 -0
- package/dist/router.mjs +259 -0
- package/dist/router.mjs.map +1 -0
- package/dist/stateful-core.d.mts +17 -0
- package/dist/stateful-core.d.mts.map +1 -0
- package/dist/stateful-core.mjs +41 -0
- package/dist/stateful-core.mjs.map +1 -0
- package/dist/stateful-emulator.d.mts +565 -0
- package/dist/stateful-emulator.d.mts.map +1 -0
- package/dist/stateful-emulator.mjs +1228 -0
- package/dist/stateful-emulator.mjs.map +1 -0
- package/dist/stateful-secrets.d.mts +84 -0
- package/dist/stateful-secrets.d.mts.map +1 -0
- package/dist/stateful-secrets.mjs +216 -0
- package/dist/stateful-secrets.mjs.map +1 -0
- package/dist/subscription-usage-recordings.d.mts +9 -0
- package/dist/subscription-usage-recordings.d.mts.map +1 -0
- package/dist/subscription-usage-recordings.mjs +53 -0
- package/dist/subscription-usage-recordings.mjs.map +1 -0
- package/dist/subscription-usage.d.mts +53 -0
- package/dist/subscription-usage.d.mts.map +1 -0
- package/dist/subscription-usage.mjs +21 -0
- package/dist/subscription-usage.mjs.map +1 -0
- package/dist/telegram/api.d.mts +34 -0
- package/dist/telegram/api.d.mts.map +1 -0
- package/dist/telegram/api.mjs +257 -0
- package/dist/telegram/api.mjs.map +1 -0
- package/dist/telegram/state.d.mts +128 -0
- package/dist/telegram/state.d.mts.map +1 -0
- package/dist/telegram/state.mjs +154 -0
- package/dist/telegram/state.mjs.map +1 -0
- package/dist/telegram.d.mts +62 -0
- package/dist/telegram.d.mts.map +1 -0
- package/dist/telegram.mjs +127 -0
- package/dist/telegram.mjs.map +1 -0
- package/dist/todoist/api.d.mts +55 -0
- package/dist/todoist/api.d.mts.map +1 -0
- package/dist/todoist/api.mjs +415 -0
- package/dist/todoist/api.mjs.map +1 -0
- package/dist/todoist/state.d.mts +275 -0
- package/dist/todoist/state.d.mts.map +1 -0
- package/dist/todoist/state.mjs +345 -0
- package/dist/todoist/state.mjs.map +1 -0
- package/dist/todoist.d.mts +68 -0
- package/dist/todoist.d.mts.map +1 -0
- package/dist/todoist.mjs +181 -0
- package/dist/todoist.mjs.map +1 -0
- package/dist/xai.d.mts +267 -0
- package/dist/xai.d.mts.map +1 -0
- package/dist/xai.mjs +252 -0
- package/dist/xai.mjs.map +1 -0
- package/package.json +157 -0
- package/src/anthropic.ts +289 -0
- package/src/chat-completions.ts +924 -0
- package/src/codex.ts +296 -0
- package/src/dropbox/api.ts +1084 -0
- package/src/dropbox/state.ts +364 -0
- package/src/dropbox.ts +203 -0
- package/src/email-fixtures.ts +1297 -0
- package/src/email.ts +1108 -0
- package/src/emulator-compose.ts +147 -0
- package/src/emulator-http.ts +184 -0
- package/src/emulator-kernel.ts +844 -0
- package/src/fixture-route.ts +561 -0
- package/src/fortnox/api.ts +1352 -0
- package/src/fortnox/state.ts +798 -0
- package/src/fortnox.ts +801 -0
- package/src/gateway-evaluate-recordings.ts +153 -0
- package/src/gateway.ts +530 -0
- package/src/github/api.ts +986 -0
- package/src/github/state.ts +439 -0
- package/src/github.ts +271 -0
- package/src/google/calendar.ts +486 -0
- package/src/google/drive.ts +471 -0
- package/src/google/gmail.ts +1011 -0
- package/src/google/shared.ts +321 -0
- package/src/google/state.ts +686 -0
- package/src/google.ts +298 -0
- package/src/linkedin-search/api.ts +363 -0
- package/src/linkedin-search/state.ts +373 -0
- package/src/linkedin-search.ts +241 -0
- package/src/mcp/api.ts +995 -0
- package/src/mcp/recordings.ts +2665 -0
- package/src/mcp/state.ts +125 -0
- package/src/mcp.ts +319 -0
- package/src/messages.ts +844 -0
- package/src/microsoft/api.ts +426 -0
- package/src/microsoft/calendar.ts +491 -0
- package/src/microsoft/drive.ts +541 -0
- package/src/microsoft/graph.ts +550 -0
- package/src/microsoft/mail.ts +833 -0
- package/src/microsoft/state.ts +822 -0
- package/src/microsoft.ts +982 -0
- package/src/node.ts +293 -0
- package/src/notion/api.ts +976 -0
- package/src/notion/state.ts +512 -0
- package/src/notion.ts +210 -0
- package/src/openai.ts +198 -0
- package/src/opencode-recordings.ts +257 -0
- package/src/opencode.ts +271 -0
- package/src/r2-fixtures.ts +295 -0
- package/src/r2-guard.ts +323 -0
- package/src/r2.ts +890 -0
- package/src/responses.ts +901 -0
- package/src/route-evidence.ts +90 -0
- package/src/router.ts +490 -0
- package/src/stateful-core.ts +70 -0
- package/src/stateful-emulator.ts +2571 -0
- package/src/stateful-secrets.ts +299 -0
- package/src/subscription-usage-recordings.ts +77 -0
- package/src/subscription-usage.ts +68 -0
- package/src/telegram/api.ts +437 -0
- package/src/telegram/state.ts +229 -0
- package/src/telegram.ts +227 -0
- package/src/todoist/api.ts +736 -0
- package/src/todoist/state.ts +456 -0
- package/src/todoist.ts +316 -0
- package/src/xai.ts +341 -0
package/src/r2.ts
ADDED
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R2 emulator: a fixture-driven, in-memory fake backend for the host R2 ports `R2Presigner` and
|
|
3
|
+
* `R2ObjectClient`.
|
|
4
|
+
*
|
|
5
|
+
* The R2 connector never talks to R2 itself: hosts implement `R2Presigner` (local SigV4 signing of
|
|
6
|
+
* a PUT URL) and `R2ObjectClient` (conditional get and put). This emulator stands in for such a
|
|
7
|
+
* host at the port level: `call(port, method, request)` takes one port call as plain JSON (the
|
|
8
|
+
* credential-free request; bytes as base64) and answers `{ response }`, `{ failure }`, or a
|
|
9
|
+
* fail-closed `{ notEmulated }`. There is no S3, SigV4 signer, socket, `node:` builtin, or SDK
|
|
10
|
+
* import here: it is a plain structural object, and `r2PortsLayerFromBackend` in
|
|
11
|
+
* `@yolk-sdk/connectors/r2-storage/conformance` turns it into both port layers.
|
|
12
|
+
*
|
|
13
|
+
* Response behaviour comes ONLY from the R2 conformance fixtures (copied as data in
|
|
14
|
+
* `r2-fixtures.ts`). The emulator keeps an in-memory bucket (objects with their etag and bytes)
|
|
15
|
+
* seeded with the object the fixtures describe; the state only selects which fixture answers (the
|
|
16
|
+
* first matching fixture consistent with the bucket) and is updated with what that fixture says
|
|
17
|
+
* happened (an object created or replaced under the etag the fixture names). It never invents an
|
|
18
|
+
* etag, a byte, a failure, or a response. Presigning writes nothing.
|
|
19
|
+
*
|
|
20
|
+
* Credentials: every credential field (the shared port scan's `isPortCredentialKey` names, copied
|
|
21
|
+
* in `r2-guard.ts`) is dropped at any depth before anything is compared or recorded. Every refusal
|
|
22
|
+
* (`unknown-method`, `invalid-request`, `uncheckable-body`, `credential-in-request`,
|
|
23
|
+
* `uncheckable-request`, `no-matching-fixture`, `state-conflict`) is ledgered with constant text
|
|
24
|
+
* only (request `<redacted>`; port and method `<unrecognised>` for `unknown-method` and
|
|
25
|
+
* `uncheckable-request`), uses no fault, and changes no state. Only a request equal to a fixture
|
|
26
|
+
* request (minus credential fields) is recorded, so the ledger holds fixture text, with every
|
|
27
|
+
* `bodyBase64` as `<redacted>` plus `bodyBytes` (the decoded length). An own `__proto__` key at any
|
|
28
|
+
* depth is `invalid-request`. Every `bodyBase64` must be canonical standard base64 of UTF-8 text
|
|
29
|
+
* (else `uncheckable-body`); its decoded text is checked with the request's own texts. A request is
|
|
30
|
+
* `credential-in-request` when any key, string value, number (as printed and, for an integer, as
|
|
31
|
+
* its digit string), or decoded body repeats a guarded value, or holds, once the exact canonical
|
|
32
|
+
* placeholders are blanked out, a SigV4 credential name, a credential query parameter, or a token
|
|
33
|
+
* the shared scan flags (a bearer token, an API-key prefix, a JSON Web Token, a PEM private key).
|
|
34
|
+
* Guarded values are every non-empty key, string, and number under a credential field (a numeric
|
|
35
|
+
* string also in its number forms), with no minimum length, unlike the shared helpers. Both checks
|
|
36
|
+
* run raw, in R2's own decodings (`decodedVariants`: up to three percent and three escape rounds,
|
|
37
|
+
* with `\uXXXX`, `\xXX`, and numeric HTML references), and through the shared fail-closed closure
|
|
38
|
+
* (`textClosureOutcome` of `stateful-secrets.ts`, imported, never copied; a cap refuses). A request
|
|
39
|
+
* the checks cannot walk (cyclic, or nested too deeply) is `uncheckable-request`: `call` never
|
|
40
|
+
* throws. The copied credential key, parameter, and token lists live in `r2-guard.ts`;
|
|
41
|
+
* `test/r2.test.ts` holds one sample per entry of each, asserts each sample count equals its list's
|
|
42
|
+
* length, and checks every sample against the shared scan (`isPortCredentialKey`,
|
|
43
|
+
* `scanPortFixtureForSecrets`) and the emulator, so an entry added here without a sample fails. A
|
|
44
|
+
* name or pattern the shared scan adds later is not enforced until it is copied here with a sample.
|
|
45
|
+
*
|
|
46
|
+
* Request-shape latitude (the only one): credential fields are never compared or recorded, and JSON
|
|
47
|
+
* key order is not compared. Everything else (the endpoint, bucket, key, content type, `maxBytes`,
|
|
48
|
+
* `expectedEtag`, the put `condition`, `bodyBase64`, and `maxUploadBytes`) must equal a fixture
|
|
49
|
+
* request exactly, so only the fixtures' `run-synthetic` run id is emulated. A `bodyBase64` must be
|
|
50
|
+
* canonical standard base64 of UTF-8 text (else `uncheckable-body`); it is compared as sent but
|
|
51
|
+
* recorded only as its decoded length. No object may have an own `__proto__` key (else
|
|
52
|
+
* `invalid-request`), and no key or value, the decoded body included, may carry a credential or
|
|
53
|
+
* repeat a dropped credential value of any length (else `credential-in-request`). Every refusal is
|
|
54
|
+
* ledgered with constant text only (request `<redacted>`). Anything that does not match (an unknown
|
|
55
|
+
* port or method, no matching fixture, or no fixture consistent with the bucket) fails closed with
|
|
56
|
+
* a ledgered `notEmulated` answer, the port analogue of HTTP 400.
|
|
57
|
+
*
|
|
58
|
+
* @experimental
|
|
59
|
+
*/
|
|
60
|
+
import { Data, Equal, Predicate, Result } from 'effect'
|
|
61
|
+
import * as Schema from 'effect/Schema'
|
|
62
|
+
import {
|
|
63
|
+
r2EmulatorFixtures,
|
|
64
|
+
type R2EmulatorFailure,
|
|
65
|
+
type R2EmulatorFixture
|
|
66
|
+
} from './r2-fixtures.ts'
|
|
67
|
+
import {
|
|
68
|
+
bindRouteHandlers,
|
|
69
|
+
emulatorRouteKey,
|
|
70
|
+
type EmulatorEvidence,
|
|
71
|
+
type EmulatorRouteEvidence
|
|
72
|
+
} from './route-evidence.ts'
|
|
73
|
+
import {
|
|
74
|
+
guardedValues,
|
|
75
|
+
hasOwnProtoKey,
|
|
76
|
+
textCarriesCredential,
|
|
77
|
+
textsOf,
|
|
78
|
+
withoutCredentials
|
|
79
|
+
} from './r2-guard.ts'
|
|
80
|
+
|
|
81
|
+
export type { EmulatorEvidence, EmulatorRouteEvidence } from './route-evidence.ts'
|
|
82
|
+
|
|
83
|
+
export { r2EmulatorFixtures, type R2EmulatorFailure, type R2EmulatorFixture }
|
|
84
|
+
|
|
85
|
+
/** The presigner port the presign fixture and manifest route name. */
|
|
86
|
+
export const r2EmulatorPresignerPort = 'R2Presigner'
|
|
87
|
+
|
|
88
|
+
/** The object-client port the get and put fixtures and manifest routes name. */
|
|
89
|
+
export const r2EmulatorObjectClientPort = 'R2ObjectClient'
|
|
90
|
+
|
|
91
|
+
/** Manifest routes of port emulators use this pseudo method; `path` is `<Port>.<method>`. */
|
|
92
|
+
export const r2EmulatorRouteMethod = 'PORT'
|
|
93
|
+
|
|
94
|
+
const portRoute = (
|
|
95
|
+
port: string,
|
|
96
|
+
method: string,
|
|
97
|
+
write: boolean,
|
|
98
|
+
caseIds: ReadonlyArray<string>
|
|
99
|
+
): EmulatorRouteEvidence => ({
|
|
100
|
+
method: r2EmulatorRouteMethod,
|
|
101
|
+
path: `${port}.${method}`,
|
|
102
|
+
kind: 'connector',
|
|
103
|
+
write,
|
|
104
|
+
caseIds,
|
|
105
|
+
evidence: 'unverified',
|
|
106
|
+
observedAt: undefined
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const presignUploadUrl = 'r2.presign.put-upload-url'
|
|
110
|
+
|
|
111
|
+
const getMaxBytes = 'r2.objects.get-max-bytes'
|
|
112
|
+
|
|
113
|
+
const getExpectedEtag = 'r2.objects.get-expected-etag'
|
|
114
|
+
|
|
115
|
+
const getMissing = 'r2.objects.get-missing-not-found'
|
|
116
|
+
|
|
117
|
+
const createIfAbsent = 'r2.objects.create-if-absent'
|
|
118
|
+
|
|
119
|
+
const updateIfMatch = 'r2.objects.update-if-match'
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Route evidence manifest: every emulated port method (`PORT <Port>.<method>`) and the R2
|
|
123
|
+
* conformance cases whose (synthetic, unverified) fixtures it follows. `R2ObjectClient.put` is an
|
|
124
|
+
* unverified connector write: it needs a pending entry in the repo's evidence check until an
|
|
125
|
+
* owner-approved live run against a practice bucket verifies it. Presigning is local signing and
|
|
126
|
+
* `get` reads, so neither is a write.
|
|
127
|
+
*/
|
|
128
|
+
export const r2EmulatorRoutes: ReadonlyArray<EmulatorRouteEvidence> = [
|
|
129
|
+
portRoute(r2EmulatorPresignerPort, 'presignPutObject', false, [presignUploadUrl]),
|
|
130
|
+
portRoute(r2EmulatorObjectClientPort, 'get', false, [
|
|
131
|
+
getMaxBytes,
|
|
132
|
+
getExpectedEtag,
|
|
133
|
+
getMissing,
|
|
134
|
+
createIfAbsent,
|
|
135
|
+
updateIfMatch
|
|
136
|
+
]),
|
|
137
|
+
portRoute(r2EmulatorObjectClientPort, 'put', true, [createIfAbsent, updateIfMatch])
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
const NonEmpty = Schema.String.check(Schema.isNonEmpty())
|
|
141
|
+
|
|
142
|
+
/** Bytes of canonical standard base64, or `undefined` when it does not decode canonically. */
|
|
143
|
+
const base64Bytes = (text: string): Uint8Array | undefined => {
|
|
144
|
+
let binary: string
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
binary = atob(text)
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return btoa(binary) === text
|
|
153
|
+
? Uint8Array.from(binary, character => character.charCodeAt(0))
|
|
154
|
+
: undefined
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const Base64 = Schema.String.check(
|
|
158
|
+
Schema.makeFilter((text: string) => base64Bytes(text) !== undefined, {
|
|
159
|
+
identifier: 'R2EmulatorBase64',
|
|
160
|
+
description: 'canonical standard base64'
|
|
161
|
+
})
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
/** One object in the emulated bucket: its key, its etag (quotes kept), and its bytes as base64. */
|
|
165
|
+
export const R2EmulatorObject = Schema.Struct({
|
|
166
|
+
key: NonEmpty,
|
|
167
|
+
etag: NonEmpty,
|
|
168
|
+
bodyBase64: Base64
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
export type R2EmulatorObject = typeof R2EmulatorObject.Type
|
|
172
|
+
|
|
173
|
+
export const R2EmulatorBucket = Schema.Struct({
|
|
174
|
+
name: NonEmpty,
|
|
175
|
+
objects: Schema.Array(R2EmulatorObject)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
export type R2EmulatorBucket = typeof R2EmulatorBucket.Type
|
|
179
|
+
|
|
180
|
+
/** The emulated buckets: plain JSON, used both as the seed and as the `state()` snapshot. */
|
|
181
|
+
export const R2EmulatorSeed = Schema.Struct({
|
|
182
|
+
buckets: Schema.Array(R2EmulatorBucket)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
export type R2EmulatorSeed = typeof R2EmulatorSeed.Type
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The synthetic practice bucket the R2 fixtures describe (the default seed): the seeded object the
|
|
189
|
+
* get fixtures read, with the etag and bytes they answer.
|
|
190
|
+
*/
|
|
191
|
+
export const r2EmulatorDefaultSeed: R2EmulatorSeed = {
|
|
192
|
+
buckets: [
|
|
193
|
+
{
|
|
194
|
+
name: 'yolk-synthetic-bucket',
|
|
195
|
+
objects: [
|
|
196
|
+
{
|
|
197
|
+
key: 'fixtures/synthetic-object.txt',
|
|
198
|
+
etag: '"a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5"',
|
|
199
|
+
bodyBase64: 'U3ludGhldGljIGNvbmZvcm1hbmNlIG9iamVjdDogc2FmZSB0byByZWFkLgo='
|
|
200
|
+
}
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const Failure = Schema.Struct({
|
|
207
|
+
kind: Schema.Literals(['expected', 'error']),
|
|
208
|
+
code: NonEmpty,
|
|
209
|
+
message: Schema.String,
|
|
210
|
+
status: Schema.optionalKey(Schema.Int)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A fault: answer the next `count` calls (every one when omitted) of `port` and `method` whose
|
|
215
|
+
* credential-free request contains `match` (a deep subset: objects by key, arrays element by
|
|
216
|
+
* element, other values exactly) with `failure` instead of a fixture. Faults apply only to a call a
|
|
217
|
+
* fixture would answer, and change no bucket state.
|
|
218
|
+
*/
|
|
219
|
+
export const R2EmulatorFault = Schema.Struct({
|
|
220
|
+
kind: Schema.Literal('failure'),
|
|
221
|
+
port: NonEmpty,
|
|
222
|
+
method: NonEmpty,
|
|
223
|
+
match: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
|
|
224
|
+
count: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
|
225
|
+
failure: Failure
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
export type R2EmulatorFault = typeof R2EmulatorFault.Type
|
|
229
|
+
|
|
230
|
+
export type R2EmulatorFaultState = {
|
|
231
|
+
readonly id: number
|
|
232
|
+
readonly fault: R2EmulatorFault
|
|
233
|
+
/** Remaining matching calls; `undefined` for an unlimited fault. */
|
|
234
|
+
readonly remaining: number | undefined
|
|
235
|
+
readonly applied: number
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Thrown by `makeR2Emulator` (invalid seed) and `faults.add` (invalid fault). */
|
|
239
|
+
export class R2EmulatorInputInvalid extends Data.TaggedError('R2EmulatorInputInvalid')<{
|
|
240
|
+
readonly input: 'seed' | 'fault'
|
|
241
|
+
readonly reason: string
|
|
242
|
+
}> {
|
|
243
|
+
override get message(): string {
|
|
244
|
+
return `Invalid R2 emulator ${this.input}: ${this.reason}`
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Why a call failed closed. */
|
|
249
|
+
export type R2EmulatorNotEmulatedReason =
|
|
250
|
+
| 'unknown-method'
|
|
251
|
+
| 'invalid-request'
|
|
252
|
+
| 'uncheckable-body'
|
|
253
|
+
| 'credential-in-request'
|
|
254
|
+
| 'uncheckable-request'
|
|
255
|
+
| 'no-matching-fixture'
|
|
256
|
+
| 'state-conflict'
|
|
257
|
+
|
|
258
|
+
const notEmulatedText: Record<R2EmulatorNotEmulatedReason, string> = {
|
|
259
|
+
'unknown-method': 'unknown-method: the port and method have no emulated route',
|
|
260
|
+
'invalid-request': 'invalid-request: the request is not a JSON object',
|
|
261
|
+
'uncheckable-body': 'uncheckable-body: a bodyBase64 is not canonical base64 of UTF-8 text',
|
|
262
|
+
'credential-in-request':
|
|
263
|
+
'credential-in-request: the request carries a credential outside its credential fields',
|
|
264
|
+
'uncheckable-request': 'uncheckable-request: the request could not be checked',
|
|
265
|
+
'no-matching-fixture': 'no-matching-fixture: no fixture matches this request',
|
|
266
|
+
'state-conflict': 'state-conflict: no matching fixture is consistent with the emulated bucket'
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The constant port, method, and request of a ledger entry that records nothing it received. */
|
|
270
|
+
export const r2EmulatorRedacted = '<redacted>'
|
|
271
|
+
|
|
272
|
+
export const r2EmulatorUnrecognised = '<unrecognised>'
|
|
273
|
+
|
|
274
|
+
/** One emulator answer; structurally the reply of a plain-JSON `R2Backend`. */
|
|
275
|
+
export type R2EmulatorReply =
|
|
276
|
+
| { readonly response: Schema.Json }
|
|
277
|
+
| { readonly failure: R2EmulatorFailure }
|
|
278
|
+
| { readonly notEmulated: { readonly reason: string } }
|
|
279
|
+
|
|
280
|
+
export type R2EmulatorLedgerEntry = {
|
|
281
|
+
readonly seq: number
|
|
282
|
+
/** The port, or `<unrecognised>` for an `unknown-method` or `uncheckable-request` refusal. */
|
|
283
|
+
readonly port: string
|
|
284
|
+
/** The method, or `<unrecognised>` for an `unknown-method` or `uncheckable-request` refusal. */
|
|
285
|
+
readonly method: string
|
|
286
|
+
/**
|
|
287
|
+
* For an answered or faulted call, the request without credential fields (a fixture request) with
|
|
288
|
+
* every `bodyBase64` value replaced by `<redacted>`; `<redacted>` whole for every refusal.
|
|
289
|
+
*/
|
|
290
|
+
readonly request: Schema.Json
|
|
291
|
+
/** The decoded byte length of a recorded request's top-level `bodyBase64`. */
|
|
292
|
+
readonly bodyBytes?: number
|
|
293
|
+
readonly outcome: 'answered' | 'fault' | 'not-emulated'
|
|
294
|
+
/** `unknown-method` for a call outside the manifest. */
|
|
295
|
+
readonly evidence: EmulatorEvidence | 'unknown-method'
|
|
296
|
+
readonly fixtureId?: string
|
|
297
|
+
readonly faultId?: number
|
|
298
|
+
readonly reason?: R2EmulatorNotEmulatedReason
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export type R2EmulatorRouteCoverage = EmulatorRouteEvidence & {
|
|
302
|
+
/** Ledger calls to this route since the last ledger clear or reset. */
|
|
303
|
+
readonly calls: number
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export type R2EmulatorCoverage = {
|
|
307
|
+
readonly routes: ReadonlyArray<R2EmulatorRouteCoverage>
|
|
308
|
+
/** Ledger calls that failed closed. */
|
|
309
|
+
readonly notEmulatedCalls: number
|
|
310
|
+
/** Fixtures that have not answered a call since the emulator was made or last reset. */
|
|
311
|
+
readonly unusedFixtureIds: ReadonlyArray<string>
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export type R2EmulatorOptions = {
|
|
315
|
+
/** The initial buckets. Defaults to `r2EmulatorDefaultSeed`. */
|
|
316
|
+
readonly seed?: R2EmulatorSeed
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export type R2Emulator = {
|
|
320
|
+
/** Answer one R2 port call; never throws. This makes the emulator an `R2Backend`. */
|
|
321
|
+
readonly call: (port: string, method: string, request: Schema.Json) => R2EmulatorReply
|
|
322
|
+
/** The current buckets (a copy). */
|
|
323
|
+
readonly state: () => R2EmulatorSeed
|
|
324
|
+
/** The seed the emulator started from (a copy). */
|
|
325
|
+
readonly seed: () => R2EmulatorSeed
|
|
326
|
+
readonly ledger: {
|
|
327
|
+
readonly entries: () => ReadonlyArray<R2EmulatorLedgerEntry>
|
|
328
|
+
readonly clear: () => void
|
|
329
|
+
}
|
|
330
|
+
/** Clear the ledger and faults, forget fixture use, and restore the seeded buckets. */
|
|
331
|
+
readonly reset: () => void
|
|
332
|
+
readonly faults: {
|
|
333
|
+
readonly add: (fault: R2EmulatorFault) => R2EmulatorFaultState
|
|
334
|
+
readonly list: () => ReadonlyArray<R2EmulatorFaultState>
|
|
335
|
+
readonly clear: () => void
|
|
336
|
+
}
|
|
337
|
+
readonly coverage: () => R2EmulatorCoverage
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// JSON helpers.
|
|
341
|
+
|
|
342
|
+
type JsonObject = Schema.JsonObject
|
|
343
|
+
|
|
344
|
+
const isJsonObject = (value: Schema.Json | undefined): value is JsonObject =>
|
|
345
|
+
value !== undefined && value !== null && Predicate.isObject(value) && !Array.isArray(value)
|
|
346
|
+
|
|
347
|
+
const objectField = (value: Schema.Json | undefined, key: string): JsonObject | undefined => {
|
|
348
|
+
const field = isJsonObject(value) ? value[key] : undefined
|
|
349
|
+
|
|
350
|
+
return isJsonObject(field) ? field : undefined
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const stringField = (value: Schema.Json | undefined, key: string): string | undefined => {
|
|
354
|
+
const field = isJsonObject(value) ? value[key] : undefined
|
|
355
|
+
|
|
356
|
+
return Predicate.isString(field) ? field : undefined
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const numberField = (value: Schema.Json | undefined, key: string): number | undefined => {
|
|
360
|
+
const field = isJsonObject(value) ? value[key] : undefined
|
|
361
|
+
|
|
362
|
+
return Predicate.isNumber(field) ? field : undefined
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The field that carries put bytes as base64; checked and recorded only as decoded text. */
|
|
366
|
+
const bodyField = 'bodyBase64'
|
|
367
|
+
|
|
368
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true })
|
|
369
|
+
|
|
370
|
+
/** The UTF-8 text of canonical standard base64, or `undefined` when it is anything else. */
|
|
371
|
+
const bodyText = (value: Schema.Json): string | undefined => {
|
|
372
|
+
const bytes = Predicate.isString(value) ? base64Bytes(value) : undefined
|
|
373
|
+
|
|
374
|
+
if (bytes === undefined) return undefined
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
return utf8.decode(bytes)
|
|
378
|
+
} catch {
|
|
379
|
+
return undefined
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Every `bodyBase64` value of `value`, at any depth. */
|
|
384
|
+
const bodyValues = (value: Schema.Json): ReadonlyArray<Schema.Json> => {
|
|
385
|
+
if (Array.isArray(value)) return value.flatMap(bodyValues)
|
|
386
|
+
|
|
387
|
+
if (!isJsonObject(value)) return []
|
|
388
|
+
|
|
389
|
+
return Object.entries(value).flatMap(([key, item]) =>
|
|
390
|
+
key === bodyField ? [item] : bodyValues(item)
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** A copy with every `bodyBase64` value replaced by `<redacted>`, at any depth. */
|
|
395
|
+
const withoutBodies = (value: Schema.Json): Schema.Json => {
|
|
396
|
+
if (Array.isArray(value)) return value.map(withoutBodies)
|
|
397
|
+
|
|
398
|
+
if (!isJsonObject(value)) return value
|
|
399
|
+
|
|
400
|
+
return Object.fromEntries(
|
|
401
|
+
Object.entries(value).map(([key, item]) => [
|
|
402
|
+
key,
|
|
403
|
+
key === bodyField ? r2EmulatorRedacted : withoutBodies(item)
|
|
404
|
+
])
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Deep subset: objects by key, arrays element by element (same length), other values exactly. */
|
|
409
|
+
const containsSubset = (value: Schema.Json | undefined, pattern: Schema.Json): boolean => {
|
|
410
|
+
if (value === undefined) return false
|
|
411
|
+
|
|
412
|
+
if (Array.isArray(pattern)) {
|
|
413
|
+
return (
|
|
414
|
+
Array.isArray(value) &&
|
|
415
|
+
value.length === pattern.length &&
|
|
416
|
+
pattern.every((item, index) => containsSubset(value[index], item))
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (isJsonObject(pattern)) {
|
|
421
|
+
return (
|
|
422
|
+
isJsonObject(value) &&
|
|
423
|
+
Object.entries(pattern).every(
|
|
424
|
+
([key, item]) => Object.hasOwn(value, key) && containsSubset(value[key], item)
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return Equal.equals(value, pattern)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Bucket state.
|
|
433
|
+
|
|
434
|
+
type ObjectState = { readonly key: string; etag: string; bodyBase64: string }
|
|
435
|
+
|
|
436
|
+
type BucketState = { readonly name: string; objects: Array<ObjectState> }
|
|
437
|
+
|
|
438
|
+
type BucketsState = { readonly buckets: Array<BucketState> }
|
|
439
|
+
|
|
440
|
+
const bucketsFrom = (seed: R2EmulatorSeed): BucketsState => ({
|
|
441
|
+
buckets: seed.buckets.map(bucket => ({
|
|
442
|
+
name: bucket.name,
|
|
443
|
+
objects: bucket.objects.map(object => ({ ...object }))
|
|
444
|
+
}))
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
const snapshotOf = (state: BucketsState): R2EmulatorSeed => ({
|
|
448
|
+
buckets: state.buckets.map(bucket => ({
|
|
449
|
+
name: bucket.name,
|
|
450
|
+
objects: bucket.objects.map(object => ({ ...object }))
|
|
451
|
+
}))
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
const bucketOf = (state: BucketsState, request: JsonObject): BucketState | undefined => {
|
|
455
|
+
const name = stringField(request, 'bucket')
|
|
456
|
+
|
|
457
|
+
return state.buckets.find(bucket => bucket.name === name)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const objectIn = (bucket: BucketState, request: JsonObject): ObjectState | undefined => {
|
|
461
|
+
const key = stringField(request, 'key')
|
|
462
|
+
|
|
463
|
+
return bucket.objects.find(object => object.key === key)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const byteLength = (bodyBase64: string | undefined): number | undefined =>
|
|
467
|
+
bodyBase64 === undefined ? undefined : base64Bytes(bodyBase64)?.byteLength
|
|
468
|
+
|
|
469
|
+
/** A state model for one route: whether a fixture is consistent, and what it changes. */
|
|
470
|
+
type RouteModel = {
|
|
471
|
+
readonly consistent: (
|
|
472
|
+
state: BucketsState,
|
|
473
|
+
request: JsonObject,
|
|
474
|
+
fixture: R2EmulatorFixture
|
|
475
|
+
) => boolean
|
|
476
|
+
readonly apply: (state: BucketsState, request: JsonObject, fixture: R2EmulatorFixture) => void
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Presigning is local signing: every matching fixture is consistent, and nothing changes. */
|
|
480
|
+
const presignModel: RouteModel = {
|
|
481
|
+
consistent: () => true,
|
|
482
|
+
apply: () => undefined
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const getModel: RouteModel = {
|
|
486
|
+
consistent: (state, request, fixture) => {
|
|
487
|
+
const bucket = bucketOf(state, request)
|
|
488
|
+
|
|
489
|
+
if (bucket === undefined) return false
|
|
490
|
+
|
|
491
|
+
const object = objectIn(bucket, request)
|
|
492
|
+
const expected = stringField(request, 'expectedEtag')
|
|
493
|
+
const maxBytes = numberField(request, 'maxBytes')
|
|
494
|
+
const size = byteLength(object?.bodyBase64)
|
|
495
|
+
const etagHolds = object !== undefined && (expected === undefined || expected === object.etag)
|
|
496
|
+
|
|
497
|
+
if (fixture.failure !== undefined) {
|
|
498
|
+
switch (fixture.failure.code) {
|
|
499
|
+
case 'not_found':
|
|
500
|
+
return object === undefined
|
|
501
|
+
case 'conflict':
|
|
502
|
+
return object !== undefined && expected !== undefined && expected !== object.etag
|
|
503
|
+
case 'response_too_large':
|
|
504
|
+
return etagHolds && size !== undefined && maxBytes !== undefined && size > maxBytes
|
|
505
|
+
default:
|
|
506
|
+
// No other get failure is recorded with a state it follows from.
|
|
507
|
+
return false
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return (
|
|
512
|
+
object !== undefined &&
|
|
513
|
+
etagHolds &&
|
|
514
|
+
size !== undefined &&
|
|
515
|
+
maxBytes !== undefined &&
|
|
516
|
+
size <= maxBytes &&
|
|
517
|
+
Equal.equals(fixture.response, {
|
|
518
|
+
etag: object.etag,
|
|
519
|
+
size,
|
|
520
|
+
bodyBase64: object.bodyBase64
|
|
521
|
+
})
|
|
522
|
+
)
|
|
523
|
+
},
|
|
524
|
+
apply: () => undefined
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const putModel: RouteModel = {
|
|
528
|
+
consistent: (state, request, fixture) => {
|
|
529
|
+
const bucket = bucketOf(state, request)
|
|
530
|
+
|
|
531
|
+
if (bucket === undefined) return false
|
|
532
|
+
|
|
533
|
+
const object = objectIn(bucket, request)
|
|
534
|
+
const condition = objectField(request, 'condition')
|
|
535
|
+
const kind = stringField(condition, 'kind')
|
|
536
|
+
const conditionEtag = stringField(condition, 'etag')
|
|
537
|
+
|
|
538
|
+
if (fixture.failure !== undefined) {
|
|
539
|
+
if (fixture.failure.code !== 'conflict') return false
|
|
540
|
+
|
|
541
|
+
return kind === 'absent'
|
|
542
|
+
? object !== undefined
|
|
543
|
+
: kind === 'etag' && object !== undefined && object.etag !== conditionEtag
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const size = byteLength(stringField(request, 'bodyBase64'))
|
|
547
|
+
const maxUploadBytes = numberField(request, 'maxUploadBytes')
|
|
548
|
+
const etag = stringField(fixture.response, 'etag')
|
|
549
|
+
|
|
550
|
+
const conditionHolds =
|
|
551
|
+
kind === 'absent'
|
|
552
|
+
? object === undefined
|
|
553
|
+
: kind === 'etag' &&
|
|
554
|
+
object !== undefined &&
|
|
555
|
+
object.etag === conditionEtag &&
|
|
556
|
+
etag !== object.etag
|
|
557
|
+
|
|
558
|
+
return (
|
|
559
|
+
conditionHolds &&
|
|
560
|
+
etag !== undefined &&
|
|
561
|
+
size !== undefined &&
|
|
562
|
+
maxUploadBytes !== undefined &&
|
|
563
|
+
size <= maxUploadBytes &&
|
|
564
|
+
numberField(fixture.response, 'size') === size
|
|
565
|
+
)
|
|
566
|
+
},
|
|
567
|
+
apply: (state, request, fixture) => {
|
|
568
|
+
const bucket = bucketOf(state, request)
|
|
569
|
+
const key = stringField(request, 'key')
|
|
570
|
+
const bodyBase64 = stringField(request, 'bodyBase64')
|
|
571
|
+
const etag = stringField(fixture.response, 'etag')
|
|
572
|
+
|
|
573
|
+
if (
|
|
574
|
+
fixture.failure !== undefined ||
|
|
575
|
+
bucket === undefined ||
|
|
576
|
+
key === undefined ||
|
|
577
|
+
bodyBase64 === undefined ||
|
|
578
|
+
etag === undefined
|
|
579
|
+
) {
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const object = objectIn(bucket, request)
|
|
584
|
+
|
|
585
|
+
if (object === undefined) {
|
|
586
|
+
bucket.objects.push({ key, etag, bodyBase64 })
|
|
587
|
+
} else {
|
|
588
|
+
object.etag = etag
|
|
589
|
+
object.bodyBase64 = bodyBase64
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const routePath = (port: string, method: string) => `${port}.${method}`
|
|
595
|
+
|
|
596
|
+
const routeModels: ReadonlyMap<string, RouteModel> = new Map([
|
|
597
|
+
[
|
|
598
|
+
emulatorRouteKey(r2EmulatorRouteMethod, routePath(r2EmulatorPresignerPort, 'presignPutObject')),
|
|
599
|
+
presignModel
|
|
600
|
+
],
|
|
601
|
+
[emulatorRouteKey(r2EmulatorRouteMethod, routePath(r2EmulatorObjectClientPort, 'get')), getModel],
|
|
602
|
+
[emulatorRouteKey(r2EmulatorRouteMethod, routePath(r2EmulatorObjectClientPort, 'put')), putModel]
|
|
603
|
+
])
|
|
604
|
+
|
|
605
|
+
const strict = { onExcessProperty: 'error' } as const
|
|
606
|
+
|
|
607
|
+
const decodeSeed = Schema.decodeUnknownResult(R2EmulatorSeed, strict)
|
|
608
|
+
|
|
609
|
+
const decodeFault = Schema.decodeUnknownResult(R2EmulatorFault, strict)
|
|
610
|
+
|
|
611
|
+
const issueText = (error: Schema.SchemaError): string => error.message
|
|
612
|
+
|
|
613
|
+
/** Bucket names, and object keys within a bucket, must be unique so fixtures address one object. */
|
|
614
|
+
const seedProblem = (seed: R2EmulatorSeed): string | undefined => {
|
|
615
|
+
const names = seed.buckets.map(bucket => bucket.name)
|
|
616
|
+
|
|
617
|
+
if (new Set(names).size !== names.length) {
|
|
618
|
+
return 'bucket names must be unique'
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
for (const bucket of seed.buckets) {
|
|
622
|
+
const keys = bucket.objects.map(object => object.key)
|
|
623
|
+
|
|
624
|
+
if (new Set(keys).size !== keys.length) {
|
|
625
|
+
return `object keys in ${bucket.name} must be unique`
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return undefined
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Create an R2 emulator. Each call has its own buckets, ledger, faults, and fixture use.
|
|
634
|
+
*
|
|
635
|
+
* `call(port, method, request)` answers in this order: a port and method outside the manifest
|
|
636
|
+
* (`unknown-method`), a non-object request or one with an own `__proto__` key (`invalid-request`),
|
|
637
|
+
* a `bodyBase64` that is not canonical base64 of UTF-8 text (`uncheckable-body`), or a credential
|
|
638
|
+
* outside the credential fields (`credential-in-request`) fails closed; otherwise the fixtures
|
|
639
|
+
* whose port, method, and credential-free request match are checked against the buckets, and the
|
|
640
|
+
* first consistent one (preferring one not used since the last reset) is chosen; no match
|
|
641
|
+
* (`no-matching-fixture`) or no consistent match (`state-conflict`) fails closed. Only then does
|
|
642
|
+
* the first active matching fault answer its failure (changing nothing); otherwise the chosen
|
|
643
|
+
* fixture answers and updates the buckets. Every refusal is ledgered with constant text only; a
|
|
644
|
+
* request the checks cannot walk is refused as `uncheckable-request`, so `call` never throws.
|
|
645
|
+
*
|
|
646
|
+
* Throws `R2EmulatorInputInvalid` for an invalid seed.
|
|
647
|
+
*/
|
|
648
|
+
export const makeR2Emulator = (options: R2EmulatorOptions = {}): R2Emulator => {
|
|
649
|
+
const seedResult = decodeSeed(options.seed ?? r2EmulatorDefaultSeed)
|
|
650
|
+
|
|
651
|
+
if (Result.isFailure(seedResult)) {
|
|
652
|
+
throw new R2EmulatorInputInvalid({ input: 'seed', reason: issueText(seedResult.failure) })
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const seed = seedResult.success
|
|
656
|
+
const problem = seedProblem(seed)
|
|
657
|
+
|
|
658
|
+
if (problem !== undefined) {
|
|
659
|
+
throw new R2EmulatorInputInvalid({ input: 'seed', reason: problem })
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const bound = new Map(
|
|
663
|
+
bindRouteHandlers(r2EmulatorRoutes, routeModels).map(({ route, handler }) => [
|
|
664
|
+
route.path,
|
|
665
|
+
{ route, model: handler }
|
|
666
|
+
])
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
let state = bucketsFrom(seed)
|
|
670
|
+
let entries: Array<R2EmulatorLedgerEntry> = []
|
|
671
|
+
|
|
672
|
+
let faults: Array<{
|
|
673
|
+
id: number
|
|
674
|
+
fault: R2EmulatorFault
|
|
675
|
+
remaining: number | undefined
|
|
676
|
+
applied: number
|
|
677
|
+
}> = []
|
|
678
|
+
|
|
679
|
+
let nextFaultId = 1
|
|
680
|
+
let nextSeq = 1
|
|
681
|
+
const used = new Set<string>()
|
|
682
|
+
|
|
683
|
+
const record = (entry: Omit<R2EmulatorLedgerEntry, 'seq'>) => {
|
|
684
|
+
entries.push({ seq: nextSeq, ...entry })
|
|
685
|
+
nextSeq += 1
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const refuse = (
|
|
689
|
+
entry: Pick<R2EmulatorLedgerEntry, 'port' | 'method' | 'request' | 'evidence'>,
|
|
690
|
+
reason: R2EmulatorNotEmulatedReason
|
|
691
|
+
): R2EmulatorReply => {
|
|
692
|
+
record({ ...entry, outcome: 'not-emulated', reason })
|
|
693
|
+
|
|
694
|
+
return { notEmulated: { reason: notEmulatedText[reason] } }
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const answer = (port: string, method: string, rawRequest: Schema.Json): R2EmulatorReply => {
|
|
698
|
+
const target = bound.get(routePath(port, method))
|
|
699
|
+
|
|
700
|
+
if (target === undefined) {
|
|
701
|
+
// Nothing from an unrecognised call is recorded: its port, method, or request may hold
|
|
702
|
+
// anything.
|
|
703
|
+
return refuse(
|
|
704
|
+
{
|
|
705
|
+
port: r2EmulatorUnrecognised,
|
|
706
|
+
method: r2EmulatorUnrecognised,
|
|
707
|
+
request: r2EmulatorRedacted,
|
|
708
|
+
evidence: 'unknown-method'
|
|
709
|
+
},
|
|
710
|
+
'unknown-method'
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const evidence = target.route.evidence
|
|
715
|
+
|
|
716
|
+
// Every refusal below records constant text only: no request text reaches the ledger unless
|
|
717
|
+
// the request equals a fixture request (minus credential fields).
|
|
718
|
+
const constant = { port, method, request: r2EmulatorRedacted, evidence }
|
|
719
|
+
|
|
720
|
+
if (!isJsonObject(rawRequest) || hasOwnProtoKey(rawRequest)) {
|
|
721
|
+
return refuse(constant, 'invalid-request')
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const request = withoutCredentials(rawRequest)
|
|
725
|
+
|
|
726
|
+
// Bytes are checked as the text they decode to; anything else cannot be checked.
|
|
727
|
+
const bodies = bodyValues(request).map(bodyText)
|
|
728
|
+
const decodedBodies = bodies.flatMap(body => (body === undefined ? [] : [body]))
|
|
729
|
+
|
|
730
|
+
if (decodedBodies.length !== bodies.length) {
|
|
731
|
+
return refuse(constant, 'uncheckable-body')
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const guarded = guardedValues(rawRequest)
|
|
735
|
+
|
|
736
|
+
if (
|
|
737
|
+
[...textsOf(request), ...decodedBodies].some(text => textCarriesCredential(text, guarded))
|
|
738
|
+
) {
|
|
739
|
+
return refuse(constant, 'credential-in-request')
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const matching = r2EmulatorFixtures.filter(
|
|
743
|
+
fixture =>
|
|
744
|
+
fixture.port === port &&
|
|
745
|
+
fixture.method === method &&
|
|
746
|
+
isJsonObject(fixture.request) &&
|
|
747
|
+
Equal.equals(withoutCredentials(fixture.request), request)
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
if (matching.length === 0) {
|
|
751
|
+
return refuse(constant, 'no-matching-fixture')
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const consistent = matching.filter(fixture => target.model.consistent(state, request, fixture))
|
|
755
|
+
const chosen = consistent.find(fixture => !used.has(fixture.id)) ?? consistent[0]
|
|
756
|
+
|
|
757
|
+
if (chosen === undefined) {
|
|
758
|
+
return refuse(constant, 'state-conflict')
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// From here on the request equals a fixture request: what is recorded is fixture text, with
|
|
762
|
+
// the body only as its decoded length.
|
|
763
|
+
const bodyBytes = byteLength(stringField(request, bodyField))
|
|
764
|
+
const ledgerRequest = withoutBodies(request)
|
|
765
|
+
|
|
766
|
+
const recorded = (entry: Pick<R2EmulatorLedgerEntry, 'outcome' | 'fixtureId' | 'faultId'>) => {
|
|
767
|
+
const base = { port, method, request: ledgerRequest, evidence, ...entry }
|
|
768
|
+
|
|
769
|
+
record(bodyBytes === undefined ? base : { ...base, bodyBytes })
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Faults apply only to a call a fixture would answer: a refused call above leaves every fault
|
|
773
|
+
// untouched. A fault changes no bucket state.
|
|
774
|
+
const fault = faults.find(
|
|
775
|
+
candidate =>
|
|
776
|
+
candidate.fault.port === port &&
|
|
777
|
+
candidate.fault.method === method &&
|
|
778
|
+
(candidate.remaining === undefined || candidate.remaining > 0) &&
|
|
779
|
+
(candidate.fault.match === undefined || containsSubset(request, candidate.fault.match))
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
if (fault !== undefined) {
|
|
783
|
+
fault.applied += 1
|
|
784
|
+
|
|
785
|
+
if (fault.remaining !== undefined) {
|
|
786
|
+
fault.remaining -= 1
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
recorded({ outcome: 'fault', faultId: fault.id })
|
|
790
|
+
|
|
791
|
+
return { failure: fault.fault.failure }
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
target.model.apply(state, request, chosen)
|
|
795
|
+
used.add(chosen.id)
|
|
796
|
+
recorded({ outcome: 'answered', fixtureId: chosen.id })
|
|
797
|
+
|
|
798
|
+
return chosen.failure === undefined
|
|
799
|
+
? { response: chosen.response }
|
|
800
|
+
: { failure: chosen.failure }
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* `answer`, never throwing: a request the checks cannot walk (a cyclic or too deeply nested
|
|
805
|
+
* value) is ledgered with constant text and answered not-emulated. Every check runs before any
|
|
806
|
+
* fault is used or any state changes, so a throw leaves both untouched.
|
|
807
|
+
*/
|
|
808
|
+
const call = (port: string, method: string, rawRequest: Schema.Json): R2EmulatorReply => {
|
|
809
|
+
try {
|
|
810
|
+
return answer(port, method, rawRequest)
|
|
811
|
+
} catch {
|
|
812
|
+
return refuse(
|
|
813
|
+
{
|
|
814
|
+
port: r2EmulatorUnrecognised,
|
|
815
|
+
method: r2EmulatorUnrecognised,
|
|
816
|
+
request: r2EmulatorRedacted,
|
|
817
|
+
evidence: 'unknown-method'
|
|
818
|
+
},
|
|
819
|
+
'uncheckable-request'
|
|
820
|
+
)
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const faultState = (fault: (typeof faults)[number]): R2EmulatorFaultState => ({
|
|
825
|
+
id: fault.id,
|
|
826
|
+
fault: fault.fault,
|
|
827
|
+
remaining: fault.remaining,
|
|
828
|
+
applied: fault.applied
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
return {
|
|
832
|
+
call,
|
|
833
|
+
state: () => snapshotOf(state),
|
|
834
|
+
seed: () => snapshotOf(bucketsFrom(seed)),
|
|
835
|
+
ledger: {
|
|
836
|
+
entries: () => entries.map(entry => ({ ...entry })),
|
|
837
|
+
clear: () => {
|
|
838
|
+
entries = []
|
|
839
|
+
}
|
|
840
|
+
},
|
|
841
|
+
reset: () => {
|
|
842
|
+
state = bucketsFrom(seed)
|
|
843
|
+
entries = []
|
|
844
|
+
faults = []
|
|
845
|
+
used.clear()
|
|
846
|
+
},
|
|
847
|
+
faults: {
|
|
848
|
+
add: input => {
|
|
849
|
+
const decoded = decodeFault(input)
|
|
850
|
+
|
|
851
|
+
if (Result.isFailure(decoded)) {
|
|
852
|
+
throw new R2EmulatorInputInvalid({ input: 'fault', reason: issueText(decoded.failure) })
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (!bound.has(routePath(decoded.success.port, decoded.success.method))) {
|
|
856
|
+
throw new R2EmulatorInputInvalid({
|
|
857
|
+
input: 'fault',
|
|
858
|
+
reason: 'the port and method have no emulated route'
|
|
859
|
+
})
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const added = {
|
|
863
|
+
id: nextFaultId,
|
|
864
|
+
fault: decoded.success,
|
|
865
|
+
remaining: decoded.success.count,
|
|
866
|
+
applied: 0
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
nextFaultId += 1
|
|
870
|
+
faults.push(added)
|
|
871
|
+
|
|
872
|
+
return faultState(added)
|
|
873
|
+
},
|
|
874
|
+
list: () => faults.map(faultState),
|
|
875
|
+
clear: () => {
|
|
876
|
+
faults = []
|
|
877
|
+
}
|
|
878
|
+
},
|
|
879
|
+
coverage: () => ({
|
|
880
|
+
routes: r2EmulatorRoutes.map(route => ({
|
|
881
|
+
...route,
|
|
882
|
+
calls: entries.filter(entry => routePath(entry.port, entry.method) === route.path).length
|
|
883
|
+
})),
|
|
884
|
+
notEmulatedCalls: entries.filter(entry => entry.outcome === 'not-emulated').length,
|
|
885
|
+
unusedFixtureIds: r2EmulatorFixtures.flatMap(fixture =>
|
|
886
|
+
used.has(fixture.id) ? [] : [fixture.id]
|
|
887
|
+
)
|
|
888
|
+
})
|
|
889
|
+
}
|
|
890
|
+
}
|