1/**
2 * Request-body gunzip (DESIGN §4, §7).
3 *
4 * git's remote-curl **gzips POST bodies** (`Content-Encoding: gzip`) whenever
5 * the buffered body is under `http.postBuffer` (default 1 MiB) — i.e.
6 * essentially every fetch negotiation request. Workers do not auto-decompress
7 * request bodies, so the server must branch on `Content-Encoding: gzip` and
8 * pipe through `DecompressionStream("gzip")`, sniffing the `1f 8b` magic
9 * defensively (a raw pkt-line body always starts with an ASCII hex digit, so
10 * the sniff can never false-positive).
11 *
12 * This module is the only place `DecompressionStream` is used; the pack-entry
13 * zlib boundary lives in `Zlib.ts`.
14 */
15import * as Effect from "effect/Effect";
16import * as Pull from "effect/Pull";
17import * as Schema from "effect/Schema";
18import * as Stream from "effect/Stream";
19import { concatBytes } from "./ObjectCodec.ts";
20
21/**
22 * Error raised when a request body declared (or sniffed) as gzip fails to
23 * decompress, or when the upstream body stream fails while being piped
24 * through the decompressor.
25 */
26export class GzipBodyError extends Schema.TaggedError<GzipBodyError>()(
27 "GzipBodyError",
28 { reason: Schema.String },
29) {}
30
31/**
32 * Returns `true` when the bytes start with the gzip magic `1f 8b`.
33 */
34export const sniffGzip = (bytes: Uint8Array): boolean =>
35 bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
36
37/**
38 * Returns `true` when a `Content-Encoding` header value declares gzip
39 * (handles `gzip`, `x-gzip`, and comma-separated lists).
40 */
41export const isGzipContentEncoding = (value: string | undefined): boolean =>
42 value !== undefined &&
43 value.split(",").some((token) => {
44 const t = token.trim().toLowerCase();
45 return t === "gzip" || t === "x-gzip";
46 });
47
48const errorReason = (error: unknown): string =>
49 error instanceof Error ? error.message : String(error);
50
51/**
52 * Pipes a byte stream through `DecompressionStream("gzip")`.
53 *
54 * Upstream failures propagate through the web-stream pipe and surface as
55 * {@link GzipBodyError} (the typed upstream error is flattened — acceptable
56 * because the caller treats any body failure the same way).
57 */
58export const gunzipStream = <E>(
59 input: Stream.Stream<Uint8Array, E>,
60): Stream.Stream<Uint8Array, GzipBodyError> =>
61 Stream.fromReadableStream({
62 evaluate: () =>
63 Stream.toReadableStream(input).pipeThrough(
64 // dom.d.ts types DecompressionStream's writable as BufferSource; the
65 // runtime accepts Uint8Array chunks — bridge the variance mismatch.
66 new DecompressionStream("gzip") as unknown as ReadableWritablePair<
67 Uint8Array,
68 Uint8Array
69 >,
70 ) as ReadableStream<Uint8Array>,
71 onError: (error) =>
72 new GzipBodyError({ reason: `gunzip failed: ${errorReason(error)}` }),
73 });
74
75/**
76 * One-shot gunzip of a fully buffered body.
77 */
78export const gunzipBuffer = (
79 data: Uint8Array,
80): Effect.Effect<Uint8Array, GzipBodyError> =>
81 gunzipStream(Stream.succeed(data)).pipe(
82 Stream.runCollect,
83 Effect.map((chunks) => concatBytes(chunks)),
84 );
85
86/**
87 * Decodes a fully buffered git request body: gunzips when `Content-Encoding`
88 * says gzip **or** the body carries the `1f 8b` magic (defensive sniff —
89 * pkt-line bodies always start with an ASCII hex digit, so this can never
90 * misfire); otherwise returns the bytes unchanged.
91 */
92export const decodeGitBodyBuffer = (
93 data: Uint8Array,
94 contentEncoding: string | undefined,
95): Effect.Effect<Uint8Array, GzipBodyError> =>
96 isGzipContentEncoding(contentEncoding) || sniffGzip(data)
97 ? gunzipBuffer(data)
98 : Effect.succeed(data);
99
100/**
101 * Decodes a streaming git request body: when `Content-Encoding` declares
102 * gzip the whole stream pipes through the decompressor; otherwise the first
103 * bytes are peeked and the `1f 8b` sniff decides. Pass-through preserves the
104 * upstream error type; the gunzip path surfaces failures as
105 * {@link GzipBodyError}.
106 */
107export const decodeGitBodyStream = <E>(
108 input: Stream.Stream<Uint8Array, E>,
109 contentEncoding: string | undefined,
110): Stream.Stream<Uint8Array, E | GzipBodyError> => {
111 if (isGzipContentEncoding(contentEncoding)) {
112 return gunzipStream(input);
113 }
114 return Stream.unwrap(
115 Effect.gen(function* () {
116 const pull = yield* Stream.toPull(input);
117 const head: Array<Uint8Array> = [];
118 let length = 0;
119 let ended = false;
120 while (length < 2 && !ended) {
121 yield* pull.pipe(
122 Effect.map((chunks) => {
123 for (const chunk of chunks) {
124 head.push(chunk);
125 length += chunk.length;
126 }
127 }),
128 Pull.catchDone(() =>
129 Effect.sync(() => {
130 ended = true;
131 }),
132 ),
133 );
134 }
135 const headBytes = concatBytes(head, length);
136 const remainder: Stream.Stream<Uint8Array, E> = ended
137 ? Stream.empty
138 : // `toPull`'s error channel is already Done-free, so ExcludeDone<E> = E;
139 // TS cannot reduce the conditional for a generic E, hence the cast.
140 (Stream.fromPull(Effect.succeed(pull)) as Stream.Stream<
141 Uint8Array,
142 E
143 >);
144 const full: Stream.Stream<Uint8Array, E> =
145 length === 0
146 ? remainder
147 : Stream.concat(Stream.succeed(headBytes), remainder);
148 return sniffGzip(headBytes)
149 ? (gunzipStream(full) as Stream.Stream<Uint8Array, E | GzipBodyError>)
150 : full;
151 }),
152 );
153};