Skip to content

Commit b4fc5ef

Browse files
authored
refactor(http-recorder): tighten cassette safety, fix WS leaks + docs (anomalyco#26730)
1 parent f6c8e35 commit b4fc5ef

9 files changed

Lines changed: 177 additions & 95 deletions

File tree

packages/http-recorder/README.md

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,11 @@ import { Effect } from "effect"
166166

167167
const audit = Effect.gen(function* () {
168168
const cassettes = yield* HttpRecorder.Cassette.Service
169-
const entries = yield* cassettes.list()
170-
const issues = yield* Effect.forEach(entries, (entry) =>
169+
const names = yield* cassettes.list()
170+
const issues = yield* Effect.forEach(names, (name) =>
171171
cassettes
172-
.read(entry.name)
173-
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
172+
.read(name)
173+
.pipe(Effect.map((interactions) => ({ name, findings: HttpRecorder.secretFindings(interactions) }))),
174174
)
175175
return issues.filter((i) => i.findings.length > 0)
176176
})
@@ -198,12 +198,11 @@ type RecordReplayOptions = {
198198
199199
| File | Purpose |
200200
| -------------- | -------------------------------------------------------------------------------- |
201-
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
202-
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
203-
| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. |
204-
| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. |
205-
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
206-
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
207-
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
208-
| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. |
209-
| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. |
201+
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
202+
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
203+
| `cassette.ts` | `Cassette.Service``fileSystem` / `memory` adapters, error types. |
204+
| `recorder.ts` | Shared transport plumbing: `resolveAutoMode`, `ReplayState`. |
205+
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
206+
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
207+
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
208+
| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. |

packages/http-recorder/src/cassette.ts

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
22
import * as fs from "node:fs"
33
import * as path from "node:path"
4-
import { secretFindings, type SecretFinding } from "./redaction"
4+
import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction"
55
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
66

77
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
@@ -14,13 +14,24 @@ export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFo
1414
}
1515
}
1616

17-
export interface AppendResult {
18-
readonly findings: ReadonlyArray<SecretFinding>
17+
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
18+
cassetteName: Schema.String,
19+
findings: Schema.Array(SecretFindingSchema),
20+
}) {
21+
override get message() {
22+
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
23+
.map((finding) => `${finding.path} (${finding.reason})`)
24+
.join(", ")}`
25+
}
1926
}
2027

2128
export interface Interface {
2229
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
23-
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult>
30+
readonly append: (
31+
name: string,
32+
interaction: Interaction,
33+
metadata?: CassetteMetadata,
34+
) => Effect.Effect<void, UnsafeCassetteError>
2435
readonly exists: (name: string) => Effect.Effect<boolean>
2536
readonly list: () => Effect.Effect<ReadonlyArray<string>>
2637
}
@@ -44,6 +55,9 @@ const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(
4455

4556
const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
4657

58+
const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
59+
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
60+
4761
export const fileSystem = (
4862
options: { readonly directory?: string } = {},
4963
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
@@ -92,11 +106,9 @@ export const fileSystem = (
92106
entry.findings.push(...secretFindings(interaction))
93107
const cassette = buildCassette(name, entry.interactions, metadata)
94108
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
95-
if (findings.length === 0) {
96-
yield* ensureDirectory(name)
97-
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
98-
}
99-
return { findings }
109+
yield* failIfUnsafe(name, findings)
110+
yield* ensureDirectory(name)
111+
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
100112
}),
101113
exists: (name) =>
102114
fs.access(cassettePath(name)).pipe(
@@ -108,12 +120,7 @@ export const fileSystem = (
108120
Effect.map((files) =>
109121
files
110122
.filter((file) => file.endsWith(".json"))
111-
.map((file) =>
112-
path
113-
.relative(directory, file)
114-
.replace(/\\/g, "/")
115-
.replace(/\.json$/, ""),
116-
)
123+
.map((file) => path.relative(directory, file).replace(/\\/g, "/").replace(/\.json$/, ""))
117124
.toSorted((a, b) => a.localeCompare(b)),
118125
),
119126
),
@@ -133,17 +140,17 @@ export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {})
133140
stored.has(name)
134141
? Effect.succeed(stored.get(name) ?? [])
135142
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
136-
append: (name, interaction, metadata) =>
137-
Effect.sync(() => {
138-
const existing = stored.get(name)
139-
if (existing) existing.push(interaction)
140-
else stored.set(name, [interaction])
141-
const findings = accumulatedFindings.get(name)
142-
if (findings) findings.push(...secretFindings(interaction))
143-
else accumulatedFindings.set(name, [...secretFindings(interaction)])
144-
if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
145-
return { findings: accumulatedFindings.get(name) ?? [] }
146-
}),
143+
append: (name, interaction, metadata) => {
144+
const existing = stored.get(name)
145+
if (existing) existing.push(interaction)
146+
else stored.set(name, [interaction])
147+
const existingFindings = accumulatedFindings.get(name)
148+
const findings = existingFindings ?? []
149+
if (!existingFindings) accumulatedFindings.set(name, findings)
150+
findings.push(...secretFindings(interaction))
151+
if (metadata) findings.push(...secretFindings({ name, ...metadata }))
152+
return failIfUnsafe(name, findings)
153+
},
147154
exists: (name) => Effect.sync(() => stored.has(name)),
148155
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
149156
})

packages/http-recorder/src/effect.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from "effect/unstable/http"
1313
import * as CassetteService from "./cassette"
1414
import { defaultMatcher, selectSequential, type RequestMatcher } from "./matching"
15-
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
15+
import { makeReplayState, resolveAutoMode } from "./recorder"
1616
import { defaults, type Redactor } from "./redactor"
1717
import { redactUrl } from "./redaction"
1818
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
@@ -100,9 +100,9 @@ export const recordingLayer = (
100100
...captured,
101101
}),
102102
}
103-
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe(
104-
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
105-
)
103+
yield* cassetteService
104+
.append(name, interaction, options.metadata)
105+
.pipe(Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))))
106106
return HttpClientResponse.fromWeb(
107107
request,
108108
new Response(decodeResponseBody(interaction.response), interaction.response),

packages/http-recorder/src/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@ export type {
77
WebSocketFrame,
88
WebSocketInteraction,
99
} from "./schema"
10-
export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
10+
export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette"
1111
export { defaultMatcher, type RequestMatcher } from "./matching"
1212
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
13-
export { UnsafeCassetteError } from "./recorder"
1413
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
1514
export {
1615
makeWebSocketExecutor,

packages/http-recorder/src/matching.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
3636
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
3737
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
3838

39-
const safeText = (value: unknown) => {
39+
export const safeText = (value: unknown) => {
4040
if (value === undefined) return "undefined"
4141
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
4242
const text = JSON.stringify(value)

packages/http-recorder/src/recorder.ts

Lines changed: 6 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,22 @@
1-
import { Effect, Ref, Schema, Scope } from "effect"
1+
import { Effect, Ref, Scope } from "effect"
22
import type * as CassetteService from "./cassette"
33
import type { CassetteNotFoundError } from "./cassette"
4-
import { SecretFindingSchema } from "./redaction"
5-
import type { CassetteMetadata, Interaction } from "./schema"
6-
7-
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
8-
cassetteName: Schema.String,
9-
findings: Schema.Array(SecretFindingSchema),
10-
}) {
11-
override get message() {
12-
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
13-
.map((finding) => `${finding.path} (${finding.reason})`)
14-
.join(", ")}`
15-
}
16-
}
17-
18-
export type ResolvedMode = "record" | "replay" | "passthrough"
4+
import type { Interaction } from "./schema"
195

206
const isCI = () => {
217
const value = process.env.CI
228
return value !== undefined && value !== "" && value !== "false" && value !== "0"
239
}
2410

25-
export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect<ResolvedMode> =>
11+
export const resolveAutoMode = (
12+
cassette: CassetteService.Interface,
13+
name: string,
14+
): Effect.Effect<"record" | "replay" | "passthrough"> =>
2615
Effect.gen(function* () {
2716
if (isCI()) return "replay"
2817
return (yield* cassette.exists(name)) ? "replay" : "record"
2918
})
3019

31-
export const appendOrFail = (
32-
cassette: CassetteService.Interface,
33-
name: string,
34-
interaction: Interaction,
35-
metadata: CassetteMetadata | undefined,
36-
): Effect.Effect<void, UnsafeCassetteError> =>
37-
cassette
38-
.append(name, interaction, metadata)
39-
.pipe(
40-
Effect.flatMap(({ findings }) =>
41-
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })),
42-
),
43-
)
44-
4520
export interface ReplayState<T> {
4621
readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
4722
readonly cursor: Effect.Effect<number>

packages/http-recorder/src/redaction.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { Schema } from "effect"
2+
13
export const REDACTED = "[REDACTED]"
24

35
const DEFAULT_REDACT_HEADERS = [
@@ -95,8 +97,6 @@ export const redactHeaders = (
9597
)
9698
}
9799

98-
import { Schema } from "effect"
99-
100100
export const SecretFindingSchema = Schema.Struct({
101101
path: Schema.String,
102102
reason: Schema.String,

packages/http-recorder/src/websocket.ts

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Effect, Option, Ref, Scope, Stream } from "effect"
22
import type { Headers } from "effect/unstable/http"
33
import * as CassetteService from "./cassette"
4-
import { canonicalizeJson, decodeJson } from "./matching"
5-
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
4+
import { canonicalizeJson, decodeJson, safeText } from "./matching"
5+
import { makeReplayState, resolveAutoMode } from "./recorder"
66
import type { RecordReplayMode } from "./effect"
7+
import { redactUrl } from "./redaction"
78
import { defaults, type Redactor } from "./redactor"
89
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
910

@@ -53,15 +54,15 @@ const decodeFrameText = (frame: WebSocketFrame) =>
5354
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
5455
Effect.sync(() => {
5556
if (JSON.stringify(actual) === JSON.stringify(expected)) return
56-
throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
57+
throw new Error(`${message}: expected ${safeText(expected)}, received ${safeText(actual)}`)
5758
})
5859

5960
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
6061

6162
const compareClientMessage = (actual: string, expected: WebSocketFrame | undefined, index: number, asJson: boolean) => {
6263
if (!expected)
6364
return Effect.sync(() => {
64-
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
65+
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${safeText(actual)}`)
6566
})
6667
const expectedText = decodeFrameText(expected)
6768
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
@@ -98,23 +99,21 @@ export const makeWebSocketExecutor = <E>(
9899
const closeOnce = Effect.gen(function* () {
99100
if (yield* Ref.getAndSet(closed, true)) return
100101
yield* connection.close
101-
yield* appendOrFail(
102-
options.cassette,
103-
options.name,
104-
{ transport: "websocket", open: openSnapshot(request), client, server },
105-
options.metadata,
106-
).pipe(Effect.orDie)
102+
yield* options.cassette
103+
.append(
104+
options.name,
105+
{ transport: "websocket", open: openSnapshot(request), client, server },
106+
options.metadata,
107+
)
108+
.pipe(Effect.orDie)
107109
})
108110
return {
109111
sendText: (message) =>
110112
connection
111113
.sendText(message)
112114
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
113115
messages: connection.messages.pipe(
114-
Stream.map((message) => {
115-
server.push(encodeFrame(message))
116-
return message
117-
}),
116+
Stream.tap((message) => Effect.sync(() => server.push(encodeFrame(message)))),
118117
),
119118
close: closeOnce,
120119
}
@@ -130,20 +129,22 @@ export const makeWebSocketExecutor = <E>(
130129
const interactions = yield* replay.load.pipe(Effect.orDie)
131130
const index = yield* replay.cursor
132131
const interaction = interactions[index]
133-
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
132+
if (!interaction)
133+
return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${redactUrl(request.url)}`))
134134
yield* replay.advance
135135
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
136136
const messageIndex = yield* Ref.make(0)
137137
return {
138138
sendText: (message) =>
139139
Effect.gen(function* () {
140-
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
140+
const current = yield* Ref.get(messageIndex)
141141
yield* compareClientMessage(
142142
message,
143143
interaction.client[current],
144144
current,
145145
options.compareClientMessagesAsJson === true,
146146
)
147+
yield* Ref.update(messageIndex, (value) => value + 1)
147148
}),
148149
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)),
149150
close: Effect.gen(function* () {

0 commit comments

Comments
 (0)