@yolk-sdk/conformance 0.1.0-canary.100

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.
@@ -0,0 +1,385 @@
1
+ import { Effect, Option, Predicate, Result } from 'effect'
2
+ import * as Base64 from 'effect/encoding/Base64'
3
+ import * as Schema from 'effect/Schema'
4
+ import type { HttpClientRequest } from 'effect/http'
5
+
6
+ // Header names that carry credentials or session state. Fixtures must never
7
+ // contain them and replay ledgers redact them.
8
+ const credentialHeaderNames: ReadonlySet<string> = new Set([
9
+ 'authorization',
10
+ 'proxy-authorization',
11
+ 'cookie',
12
+ 'set-cookie',
13
+ 'x-api-key',
14
+ 'api-key',
15
+ 'x-goog-api-key',
16
+ 'x-auth-token',
17
+ 'x-access-token',
18
+ 'x-amz-security-token',
19
+ 'x-vercel-oidc-token',
20
+ 'x-csrf-token'
21
+ ])
22
+
23
+ const credentialHeaderPattern = /(api[-_]?key|secret|password|cookie|authorization)/i
24
+
25
+ // A `-`/`_`-separated name segment that is exactly `token`, `key`, or `auth`
26
+ // (`x-auth-token`, `private-token`, `x-figma-token`, `x-*-key`). Plural
27
+ // segments such as `x-ratelimit-remaining-tokens` do not match.
28
+ const credentialHeaderSegmentPattern = /(^|[-_])(token|key|auth)([-_]|$)/i
29
+
30
+ /**
31
+ * True for header names that carry credentials or session state. Used by the
32
+ * fixture secret scan, recorder drop rules, and ledger redaction.
33
+ */
34
+ export const isCredentialHeaderName = (name: string): boolean => {
35
+ const lower = name.toLowerCase()
36
+
37
+ return (
38
+ credentialHeaderNames.has(lower) ||
39
+ credentialHeaderPattern.test(lower) ||
40
+ credentialHeaderSegmentPattern.test(lower)
41
+ )
42
+ }
43
+
44
+ export const redactedHeaderValue = '<redacted>'
45
+
46
+ // Credential text patterns. The fixture secret scan (`scanFixtureForSecrets`) tests them and the
47
+ // runner's report sanitizer redacts with them, so both share this one definition.
48
+
49
+ /** `Bearer <token>` with a token-shaped value. */
50
+ export const bearerPattern = /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/i
51
+
52
+ /** PEM private key header. */
53
+ const privateKeyPattern = /-----BEGIN [A-Z ]*PRIVATE KEY-----/
54
+
55
+ /** Common API-key prefixes and JSON Web Tokens. */
56
+ const tokenPrefixPatterns: ReadonlyArray<RegExp> = [
57
+ // OpenAI/Anthropic/DeepSeek-style secret keys (sk-..., sk-ant-..., sk-proj-...)
58
+ /\bsk-[A-Za-z0-9_-]{16,}/,
59
+ /\b[sr]k_(live|test)_[A-Za-z0-9]{16,}/,
60
+ /\bxai-[A-Za-z0-9]{20,}/,
61
+ /\bvck_[A-Za-z0-9]{16,}/,
62
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/,
63
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/,
64
+ /\bAKIA[0-9A-Z]{16}\b/,
65
+ /\bAIza[0-9A-Za-z_-]{35}/,
66
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/,
67
+ // Afloat API keys (`afloat_` and a long alphanumeric remainder)
68
+ /\bafloat_[A-Za-z0-9]{16,}/,
69
+ // JSON Web Tokens (OIDC/OAuth access tokens)
70
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/
71
+ ]
72
+
73
+ /** Common API-key prefixes, JSON Web Tokens, and PEM private keys. */
74
+ export const apiKeyPatterns: ReadonlyArray<RegExp> = [...tokenPrefixPatterns, privateKeyPattern]
75
+
76
+ const credentialParamNames =
77
+ 'api[_-]?key|key|token|access[_-]?token|refresh[_-]?token|id[_-]?token|auth|secret|password|client[_-]?secret|x-amz-signature|x-amz-credential|x-amz-security-token'
78
+
79
+ /**
80
+ * Query-string or form-encoded credential parameter, anchored at the start of
81
+ * the text or after `?`/`&` (URLs and `application/x-www-form-urlencoded` bodies).
82
+ */
83
+ export const credentialParamPattern = new RegExp(`(?:^|[?&])(${credentialParamNames})=[^&#]+`, 'i')
84
+
85
+ // Every `name=` of a credential parameter, found on its own: the pattern stops at `=` and never
86
+ // consumes the value, so a later `?name=` or `&name=` is always found, whatever the value holds.
87
+ const credentialParamNamesAt = new RegExp(`(?:^|[?&])(${credentialParamNames})=`, 'gi')
88
+
89
+ // What ends a raw parameter value: a character that cannot appear raw inside a URL query value
90
+ // (RFC 3986): `&`, `#`, whitespace, `"`, `<`, `>`. `?` and `'` are NOT boundaries (both are legal
91
+ // raw inside a query value), and neither is any percent-encoded delimiter.
92
+ const valueBoundary = /[&#\s"<>]/
93
+
94
+ /**
95
+ * The exact synthetic credential values a `PortFixture` may carry, keyed by lower-case parameter
96
+ * name: SigV4 presigned-URL placeholders for S3-compatible ports (the R2 conformance fixtures sign
97
+ * with them). `scanPortFixtureForSecrets` exempts a credential parameter only when its name is a
98
+ * key here and its whole raw value (up to `&`, `#`, whitespace, `"`, `<`, `>`, or the end),
99
+ * percent-decoded, equals that key's value exactly; anything else inside the value (a raw `?`, an
100
+ * encoded delimiter, any suffix), another parameter name, or another scope is flagged.
101
+ * `scanFixtureForSecrets` exempts nothing. Never build a placeholder by prefixing or suffixing a
102
+ * real value.
103
+ */
104
+ export const syntheticPortCredentialParams = Object.freeze({
105
+ 'x-amz-signature': 'yolk-synthetic-signature',
106
+ 'x-amz-credential': 'yolk-synthetic-access-key-id/20260930/auto/s3/aws4_request'
107
+ })
108
+
109
+ const exactPlaceholders = new Map<string, string>(Object.entries(syntheticPortCredentialParams))
110
+
111
+ /**
112
+ * `text` with `%XX` escapes decoded, repeatedly (at most three layers, then left as it is). The R2
113
+ * guard (`findR2PortFixtureSecrets` in `@yolk-sdk/connectors`) applies at most the same three
114
+ * percent-decoding rounds (plus its escape rounds): keep the percent depth in step.
115
+ */
116
+ const percentDecodedLayers = (text: string): string => {
117
+ let current = text
118
+
119
+ for (let round = 0; round < 3 && /%[0-9A-Fa-f]{2}/.test(current); round++) {
120
+ current = current.replace(/%([0-9A-Fa-f]{2})/g, (_match, hex: string) =>
121
+ String.fromCharCode(Number.parseInt(hex, 16))
122
+ )
123
+ }
124
+
125
+ return current
126
+ }
127
+
128
+ /** The raw, undecoded value starting at `start`: up to the first structural boundary. */
129
+ const rawParamValueAt = (text: string, start: number): string => {
130
+ const rest = text.slice(start)
131
+ const end = rest.search(valueBoundary)
132
+
133
+ return end === -1 ? rest : rest.slice(0, end)
134
+ }
135
+
136
+ /**
137
+ * True when `text` carries a credential query or form parameter that is not an exact synthetic
138
+ * placeholder (`syntheticPortCredentialParams`). Every `name=` is found and judged on its own: its
139
+ * raw value runs to the next structural boundary (see `rawParamValueAt`), is percent-decoded
140
+ * (`percentDecodedLayers`), and is exempt only when it equals that name's placeholder exactly. It
141
+ * flags at least whatever `credentialParamPattern` flags (a `name=` followed by any character but
142
+ * `&` or `#`), except the exact placeholders.
143
+ */
144
+ export const hasLiveCredentialParam = (text: string): boolean =>
145
+ [...text.matchAll(credentialParamNamesAt)].some(match => {
146
+ const name = (match[1] ?? '').toLowerCase()
147
+ const start = match.index + match[0].length
148
+ const value = rawParamValueAt(text, start)
149
+ const next = text.charAt(start)
150
+ const present = value.length > 0 || (next !== '' && next !== '&' && next !== '#')
151
+
152
+ return present && exactPlaceholders.get(name) !== percentDecodedLayers(value)
153
+ })
154
+
155
+ // Singular credential field names (snake, kebab, or camel case), including the AWS-style
156
+ // `accessKeyId` / `secretAccessKey` / `sessionToken` of S3-compatible signing inputs. Plural usage
157
+ // counters such as `max_tokens` or `prompt_tokens` never match.
158
+ const credentialFieldNames =
159
+ '(?:access|refresh|id|auth|api|session|private|bearer|oauth)[_-]?token|token|client[_-]?secret|secret(?:[_-]?key)?|private[_-]?key|password|passwd|api[_-]?key|access[_-]?key[_-]?id|secret[_-]?access[_-]?key|authorization'
160
+
161
+ /** A JSON object key (or similar field name) that holds a credential. */
162
+ export const credentialFieldPattern = new RegExp(`^(${credentialFieldNames})$`, 'i')
163
+
164
+ const globally = (pattern: RegExp): RegExp =>
165
+ new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`)
166
+
167
+ // Redaction forms of the patterns above. They are deliberately broader than the scan (any bearer
168
+ // value, parameters after whitespace or punctuation, `name: value` field pairs) because
169
+ // over-redacting a report message is harmless.
170
+ const bearerRedaction = /\bbearer\s+\S+/gi
171
+
172
+ const apiKeyRedactions: ReadonlyArray<RegExp> = [
173
+ ...tokenPrefixPatterns.map(globally),
174
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g
175
+ ]
176
+
177
+ const credentialParamRedaction = new RegExp(
178
+ `(^|[?&\\s;,(])(${credentialParamNames})=[^&#\\s;,)]+`,
179
+ 'gi'
180
+ )
181
+
182
+ // `name: value` / `"name": "value"` / `name=value`. An unquoted value may carry an auth scheme
183
+ // (`Authorization: Basic <token>`); a value already redacted as `Bearer <redacted>` is kept.
184
+ const credentialFieldRedaction = new RegExp(
185
+ `(^|[^A-Za-z0-9_-])(["']?)(${credentialFieldNames})\\2(\\s*[:=]\\s*)(?!bearer <redacted>)(?:"(?:[^"\\\\]|\\\\.)*"?|'[^']*'?|(?:(?:basic|bearer|digest|negotiate|token)\\s+)?[^\\s,;&}\\]]+)`,
186
+ 'gi'
187
+ )
188
+
189
+ // Candidate unquoted `Name:` header-like names inside one line (colon only). Quoted keys such
190
+ // as `"x-api-key": "..."` are handled by `quotedCredentialHeaderRedaction`; `name=value` pairs
191
+ // are left to the quote-aware field and parameter passes.
192
+ const headerLikeNamePattern = /(?:^|[^A-Za-z0-9_-])([A-Za-z][A-Za-z0-9_-]*)\s*:/g
193
+
194
+ /**
195
+ * Redact the rest of a line after the first header-like `Name:` that `isCredentialHeaderName`
196
+ * accepts (`X-Api-Key: ...`, `Proxy-Authorization: Basic ...`, `Cookie: ...`), so every header the
197
+ * fixture scan flags is also redacted in report text. Runs on the raw text before any other pass,
198
+ * so a partially matched value (for example a key-shaped cookie name) never shields the rest of
199
+ * the line.
200
+ */
201
+ const redactCredentialHeaderLines = (text: string): string =>
202
+ text
203
+ .split(/(\r\n|\r|\n)/)
204
+ .map(line => {
205
+ headerLikeNamePattern.lastIndex = 0
206
+
207
+ for (let match = headerLikeNamePattern.exec(line); match !== null;) {
208
+ const name = match[1]
209
+
210
+ if (name !== undefined && isCredentialHeaderName(name)) {
211
+ return `${line.slice(0, match.index + match[0].length)} ${redactedHeaderValue}`
212
+ }
213
+
214
+ match = headerLikeNamePattern.exec(line)
215
+ }
216
+
217
+ return line
218
+ })
219
+ .join('')
220
+
221
+ // A quoted key that `isCredentialHeaderName` accepts (`"x-api-key": "..."`,
222
+ // `'Proxy-Authorization': '...'`): only the value is redacted, so JSON stays balanced.
223
+ const quotedCredentialHeaderRedaction =
224
+ /(["'])([A-Za-z][A-Za-z0-9_-]*)\1(\s*:\s*)("(?:[^"\\]|\\.)*"?|'[^']*'?|[^\s,;&{}[\]]+)/g
225
+
226
+ const redactQuotedCredentialHeaders = (text: string): string =>
227
+ text.replace(
228
+ quotedCredentialHeaderRedaction,
229
+ (match, quote: string, name: string, separator: string) =>
230
+ isCredentialHeaderName(name)
231
+ ? `${quote}${name}${quote}${separator}${redactedHeaderValue}`
232
+ : match
233
+ )
234
+
235
+ /**
236
+ * Redact every credential pattern shared with the fixture secret scan: credential header lines
237
+ * (first, to the end of the line), quoted credential header keys (value only), bearer tokens,
238
+ * API-key prefixes, JWTs, private keys, credential field pairs (`"api_key": "..."`,
239
+ * `password="..."`), and credential query/form parameters. Best effort; used on report messages.
240
+ * Known gap: escaped JSON inside a quoted string value (`"body":"{\\"x-api-key\\":...}"`) is not
241
+ * parsed.
242
+ * Quote-aware field redaction runs before parameter redaction so a quoted value with spaces is
243
+ * removed whole.
244
+ */
245
+ export const redactCredentialText = (text: string): string => {
246
+ let result = redactCredentialHeaderLines(text).replace(
247
+ bearerRedaction,
248
+ `Bearer ${redactedHeaderValue}`
249
+ )
250
+
251
+ for (const pattern of apiKeyRedactions) {
252
+ result = result.replace(pattern, redactedHeaderValue)
253
+ }
254
+
255
+ return redactQuotedCredentialHeaders(result)
256
+ .replace(
257
+ credentialFieldRedaction,
258
+ (_match, prefix: string, quote: string, name: string, separator: string) =>
259
+ `${prefix}${quote}${name}${quote}${separator}${redactedHeaderValue}`
260
+ )
261
+ .replace(
262
+ credentialParamRedaction,
263
+ (_match, prefix: string, name: string) => `${prefix}${name}=${redactedHeaderValue}`
264
+ )
265
+ }
266
+
267
+ export const redactHeaders = (headers: Readonly<Record<string, string>>) => {
268
+ const redacted: Record<string, string> = {}
269
+
270
+ for (const [name, value] of Object.entries(headers)) {
271
+ redacted[name] = isCredentialHeaderName(name) ? redactedHeaderValue : value
272
+ }
273
+
274
+ return redacted
275
+ }
276
+
277
+ const compareStrings = (left: string, right: string): number => {
278
+ if (left < right) {
279
+ return -1
280
+ }
281
+
282
+ return left > right ? 1 : 0
283
+ }
284
+
285
+ /**
286
+ * Canonical absolute URL used for matching: hash removed and query parameters
287
+ * sorted by name, then value. Unparseable input is returned unchanged.
288
+ */
289
+ export const normalizeWireUrl = (input: string): string => {
290
+ if (!URL.canParse(input)) {
291
+ return input
292
+ }
293
+
294
+ const url = new URL(input)
295
+ url.hash = ''
296
+
297
+ const params = [...url.searchParams.entries()].sort(
298
+ ([leftName, leftValue], [rightName, rightValue]) =>
299
+ leftName === rightName
300
+ ? compareStrings(leftValue, rightValue)
301
+ : compareStrings(leftName, rightName)
302
+ )
303
+
304
+ url.search = ''
305
+
306
+ for (const [name, value] of params) {
307
+ url.searchParams.append(name, value)
308
+ }
309
+
310
+ return url.toString()
311
+ }
312
+
313
+ /**
314
+ * Match an optional URL filter. A pattern ending in `*` is a prefix match on
315
+ * the normalized URL; otherwise the normalized URLs must be equal.
316
+ */
317
+ export const urlMatchesPattern = (pattern: string, normalizedUrl: string): boolean =>
318
+ pattern.endsWith('*')
319
+ ? normalizedUrl.startsWith(pattern.slice(0, -1))
320
+ : normalizeWireUrl(pattern) === normalizedUrl
321
+
322
+ /** Request body as UTF-8 text when it is an in-memory body; otherwise undefined. */
323
+ export const requestBodyText = (
324
+ request: HttpClientRequest.HttpClientRequest
325
+ ): string | undefined => {
326
+ const body = request.body
327
+
328
+ if (Predicate.isTagged(body, 'Uint8Array')) {
329
+ return new TextDecoder().decode(body.body)
330
+ }
331
+
332
+ if (Predicate.isTagged(body, 'Raw') && Predicate.isString(body.body)) {
333
+ return body.body
334
+ }
335
+
336
+ return undefined
337
+ }
338
+
339
+ /** Exact bytes of standard base64 text; `None` when the text is not valid base64. */
340
+ export const decodeBase64Bytes = (text: string): Option.Option<Uint8Array> =>
341
+ Result.getSuccess(Base64.decode(text))
342
+
343
+ /**
344
+ * Decode bytes as UTF-8 only when they are valid UTF-8 on their own. A leading
345
+ * byte-order mark is kept so that re-encoding yields the same bytes.
346
+ */
347
+ export const decodeUtf8Strict = (bytes: Uint8Array): Option.Option<string> =>
348
+ Option.liftThrowable(() =>
349
+ new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)
350
+ )()
351
+
352
+ /** Lossless recording of bytes: readable text when valid UTF-8, otherwise base64. */
353
+ export const recordBytes = (
354
+ bytes: Uint8Array
355
+ ): { readonly text: string } | { readonly base64: string } =>
356
+ Option.match(decodeUtf8Strict(bytes), {
357
+ onNone: () => ({ base64: Base64.encode(bytes) }),
358
+ onSome: text => ({ text })
359
+ })
360
+
361
+ const decodeJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json))
362
+
363
+ /** Parse text as JSON; `None` when it is not valid JSON. */
364
+ export const parseJsonText = (text: string): Effect.Effect<Option.Option<Schema.Json>> =>
365
+ decodeJsonText(text).pipe(Effect.option)
366
+
367
+ export const headerRecord = (headers: Readonly<Record<string, string | undefined>>) => {
368
+ const record: Record<string, string> = {}
369
+
370
+ for (const [name, value] of Object.entries(headers)) {
371
+ if (value !== undefined) {
372
+ record[name.toLowerCase()] = value
373
+ }
374
+ }
375
+
376
+ return record
377
+ }
378
+
379
+ export const mediaType = (contentType: string | undefined): string | undefined =>
380
+ contentType?.split(';', 1)[0]?.trim().toLowerCase()
381
+
382
+ // Web `Response` rejects a body for these statuses.
383
+ const nullBodyStatuses: ReadonlySet<number> = new Set([101, 103, 204, 205, 304])
384
+
385
+ export const isNullBodyStatus = (status: number): boolean => nullBodyStatuses.has(status)