Skip to content

Commit b2ca2e4

Browse files
authored
Merge pull request #7170 from cloudflare/jasnell/streams-test-consolidation-4
Add digest streams suite; migrate existing DigestStream tests
2 parents cd90354 + 914bd6b commit b2ca2e4

33 files changed

Lines changed: 1640 additions & 1112 deletions

‎src/tests/streams/AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# src/tests/streams/
22

33
Streams test suite, organized WPT-style: one subdirectory per functional area
4-
(`identity/`, `encoding/`, `compression/`, eventually `readable/`,
5-
`writable/`, `piping/`, ...). Every
4+
(`identity/`, `encoding/`, `compression/`, `digest/`, eventually
5+
`readable/`, `writable/`, `piping/`, ...). Every
66
test here runs against **both** streams implementations — the legacy C++ one
77
(`src/workerd/api/streams/`) and the TypeScript one
88
(`src/per_isolate/webstreams/`) — to prove parity. A test that only makes

‎src/tests/streams/digest/AGENTS.md‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# crypto.DigestStream
2+
3+
An informal specification of the Cloudflare-specific DigestStream (a
4+
WritableStream subclass computing a hash digest) as implemented in workerd,
5+
derived from — and kept in lockstep with — the test suite in this
6+
directory. **The tests are the normative artifact.** Both the C++
7+
implementation (`src/workerd/api/crypto/crypto.{h,c++}`) and the TypeScript
8+
implementation (`src/per_isolate/crypto/digest-stream.ts`, behind
9+
`typescript_implemented_streams`) are covered. Both drive the SAME native
10+
digest context (`utils.createDigestContext` → CRC/OpenSSL contexts and the
11+
WTF-8/toWellFormed string encoder), so hashing, string encoding, and byte
12+
counting are parity by construction; divergences live in the stream
13+
wrapper and promise plumbing. DigestStream is non-standard: no WPT exists.
14+
15+
## Interface
16+
17+
```webidl
18+
[Exposed=Worker]
19+
interface DigestStream : WritableStream {
20+
constructor((DOMString or HashAlgorithm) algorithm,
21+
optional DigestStreamOptions options = {});
22+
readonly attribute Promise<ArrayBuffer> digest; // memoized identity
23+
readonly attribute bigint bytesWritten;
24+
};
25+
dictionary DigestStreamOptions { boolean toWellFormed = false; };
26+
```
27+
28+
- Algorithms (case-insensitive over the whole name, string or `{name}`
29+
object): `md5`, `SHA-1`, `SHA-256`, `SHA-384`, `SHA-512`, `crc32`,
30+
`crc32c`, `crc64nvme`. Unknown names throw `NotSupportedError`
31+
synchronously; the options bag is type-checked BEFORE the algorithm
32+
lookup (primitive bags → TypeError "constructor parameter 2 is not of
33+
type 'Options'."); `toWellFormed` is ToBoolean-coerced.
34+
- **Inheritance divergence (ledger #1):** instances are
35+
`instanceof WritableStream` and the instance prototype chain is wired in
36+
both, but only TypeScript's genuine `class extends` links the
37+
CONSTRUCTOR's prototype to the WritableStream function; the C++ jsg
38+
static chain does not. Subclassing via `class X extends
39+
crypto.DigestStream` works in both.
40+
- `digest` and `bytesWritten` are brand-checked prototype accessors
41+
(`digest` moves to a per-instance own property in the unflagged legacy
42+
era); `Symbol.dispose` is brand-checked; `[object DigestStream]`
43+
branding under `set_tostring_tag`.
44+
45+
## Core semantics
46+
47+
- **Write settlement:** the hash update runs inside write() (chunks are
48+
consumed before the write settles — post-write mutation or detach cannot
49+
change the digest; metadata comes from internal slots). Chunks:
50+
ArrayBuffer, any view (offsets honored), strings; bare
51+
SharedArrayBuffers reject (views onto them are accepted); everything
52+
else rejects TypeError "DigestStream is a byte stream but received an
53+
object of non-ArrayBuffer/ArrayBufferView/string type on its writable
54+
side." Zero-length writes are no-ops.
55+
- **String encoding:** WTF-8 per chunk by default (lone surrogate = ED A0
56+
80 — deliberately NOT TextEncoder's substitution; pinned for existing
57+
digests). `toWellFormed: true` substitutes U+FFFD with
58+
TextEncoderStream-style stateful pairing across chunks and a close-time
59+
flush of a dangling lead. `bytesWritten` counts UTF-8 bytes (bigint in
60+
every era).
61+
- **Digest promise:** memoized identity (same object every access, before
62+
and after settling); resolves on close() with the digest ArrayBuffer;
63+
rejects on abort(reason) with the reason; never settled by abandonment
64+
or GC. close() is single-use (second close rejects, digest unaffected);
65+
writes after close reject without disturbing the digest.
66+
- **`Symbol.dispose`:** errors the digest ("The DigestStream was
67+
disposed.") without touching stream state — the stream stays unlocked,
68+
zero-length writes still resolve, non-empty writes reject; idempotent;
69+
a no-op after close. **Rejection reporting diverges (ledger #2):** the
70+
TypeScript implementation marks the digest promise handled, so an
71+
abandoned rejected digest produces NO unhandledrejection report; C++
72+
reports it. A derived promise (`.then()` with no catch) still reports in
73+
both.
74+
- **Pipes:** a valid pipeTo destination from user streams, transforms, and
75+
Response bodies (brand checks pass in both implementations).
76+
- **Writer accounting:** desiredSize counts the in-flight chunk against the
77+
default HWM of 1 and recovers on settlement in BOTH implementations
78+
(contrast the compression suite's inert C++ desiredSize); ready stays
79+
settled. Write resolutions carry undefined (no thenable check); the
80+
digest ArrayBuffer gets exactly one thenable check per ledger #3. The
81+
constructor's options-bag getter may re-enter the API safely.
82+
83+
## Compatibility flags
84+
85+
| Flag (enable date) | Selects | Unflagged behavior tested by |
86+
| --- | --- | --- |
87+
| `workers_api_getters_setters_on_prototype` (2022-01-31) | `digest` as prototype accessor | `legacyDigestIsOwnInstanceProperty` |
88+
| `set_tostring_tag` (2024-09-26) | `[object DigestStream]` branding | `legacyToStringTag` |
89+
| `streams_enable_constructors` + `transformstream_enable_standard_constructor` (2022-11-30) | pipe tests build standard sources ||
90+
91+
Unlike identity/compression, DigestStream's sink converts exceptions to
92+
rejected promises directly, so `capture_async_api_throws` has no effect and
93+
is not pinned.
94+
95+
`digest-cpp-pedantic.wd-test` runs the full module set with the dateless
96+
opt-in `pedantic_wpt` added, pinning the ABSENCE of pedantic effects
97+
(the implementation consults the flag nowhere; the reachable
98+
standard-streams machinery changes nothing the suite pins).
99+
100+
## Divergence ledger (C++ vs TypeScript)
101+
102+
| # | Area | C++ | TypeScript | Pinned in |
103+
| --- | --- | --- | --- | --- |
104+
| 1 | Constructor static inheritance | `getPrototypeOf(crypto.DigestStream) !== WritableStream` | `=== WritableStream` | `isRealWritableStreamSubclass` |
105+
| 2 | Abandoned digest rejection reporting | reported | marked handled, not reported | `abandonedDigestReporting` |
106+
| 3 | Thenable-check stage for the digest ArrayBuffer (fires exactly once in both) | when the digest promise is awaited | inside close(), as the deferred resolves | `thenInterceptionDuringDigestResolution` |
107+
108+
## Assertion catalogue
109+
110+
| Module | Asserts |
111+
| --- | --- |
112+
| `api-surface.js` | WritableStream subclassing incl. ledger #1; branding; brand-checked accessors + dispose; subclassable |
113+
| `construction.js` | case-insensitive algorithm matching (digest-compared); CRC exact spelling; unknown/missing algorithms throw synchronously; `{name}` object form; all 8 algorithms with sizes; option-bag Web IDL rules with exact TypeError; type-check-before-lookup ordering |
114+
| `digest-vectors.js` | pinned md5/SHA-256/crc32 outputs for bytes, strings, non-Uint8 views, offset subarrays; AWS SDK checksum vectors; mixed-type write accumulation with bytesWritten; shared digestOf helper |
115+
| `string-encoding.js` | WTF-8-not-TextEncoder default; toWellFormed matches TextEncoder for every lone-surrogate shape; inert for valid input and byte chunks; identical bytesWritten; every falsy spelling defaults off; ToBoolean coercion; stateful pair-joining across chunks; dangling-lead flush; per-chunk default encoding |
116+
| `chunk-types.js` | non-byte chunk rejection with exact message; bare-SAB rejection + SAB-view acceptance; DataView offset honored |
117+
| `digest-promise.js` | promise identity stability across settling; bytesWritten bigint semantics incl. zero-length and UTF-8 counting |
118+
| `lifecycle.js` | close resolves; abort rejects (incl. after writes); write-after-close rejects without disturbing the digest; double close safe; abandoned stream/writer safe; unused stream safe |
119+
| `dispose.js` | dispose errors digest + non-empty writes while stream state untouched; zero-length writes still resolve; idempotent; no-op after close |
120+
| `unhandled-rejection.js` | ledger #2 reporting matrix + derived-promise reporting |
121+
| `buffer-lifecycle.js` | consume-at-write: post-write mutation/detach invisible; lying metadata getters never consulted |
122+
| `pipe-integration.js` | pipeTo from user streams; TransformStream chain; Response body |
123+
| `large-payload.js` | 1MB+ chunk digesting |
124+
| `gc-interplay.js` | GC never settles an abandoned digest; writer remains operable across GC |
125+
| `reentrancy.js` | staged thenable-check matrix (ledger #3); write issued from a write continuation preserves accumulation order; options-bag getter re-entering the constructor is safe and its value is honored |
126+
| `backpressure.js` | desiredSize counts and recovers (parity); ready settled |
127+
| `legacy-shape.js` | unflagged era: `[object Object]`; digest as own instance property (no prototype accessor); bytesWritten stays a bigint prototype accessor; flow unchanged |
128+
| `which-impl.js` | implementation detection |
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
load("//:build/wd_test.bzl", "wd_test")
2+
3+
# The digest streams suite runs the same test modules under two configs:
4+
# digest-cpp against the C++ implementation, digest-ts against the
5+
# TypeScript implementation.
6+
7+
digest_suite_srcs = glob(["*.js"]) + ["digest-modules.capnp"]
8+
9+
wd_test(
10+
src = "digest-cpp.wd-test",
11+
data = digest_suite_srcs,
12+
)
13+
14+
wd_test(
15+
src = "digest-ts.wd-test",
16+
args = ["--experimental"],
17+
data = digest_suite_srcs,
18+
)
19+
20+
# The main C++ cell plus the dateless opt-in pedantic_wpt flag, enforcing
21+
# that pedantic mode changes nothing on this suite's surface.
22+
wd_test(
23+
src = "digest-cpp-pedantic.wd-test",
24+
data = digest_suite_srcs,
25+
)
26+
27+
# Legacy (pre-flag) regression guard, C++ implementation only. The
28+
# @all-compat-flags variant is disabled: at the 2999-12-31 date the flags
29+
# the cell deliberately omits would all turn on.
30+
wd_test(
31+
src = "digest-cpp-legacy.wd-test",
32+
data = digest_suite_srcs,
33+
generate_all_compat_flags_variant = False,
34+
)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 Cloudflare, Inc.
2+
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3+
// https://opensource.org/licenses/Apache-2.0
4+
5+
// Object shape of crypto.DigestStream: a subclass of the global
6+
// WritableStream in both implementations, with brand-checked accessors.
7+
// Divergence: TypeScript uses a genuine `class extends`, so the
8+
// CONSTRUCTOR's prototype is the WritableStream function itself; the C++
9+
// jsg inheritance wires the instance prototype chain but not the static
10+
// constructor chain.
11+
12+
import { strictEqual, notStrictEqual, ok, throws } from 'node:assert';
13+
import { usingTsImpl } from 'which-impl';
14+
15+
export const isRealWritableStreamSubclass = {
16+
test() {
17+
const stream = new crypto.DigestStream('md5');
18+
ok(stream instanceof WritableStream);
19+
strictEqual(
20+
Object.getPrototypeOf(crypto.DigestStream.prototype),
21+
WritableStream.prototype
22+
);
23+
if (usingTsImpl) {
24+
strictEqual(Object.getPrototypeOf(crypto.DigestStream), WritableStream);
25+
} else {
26+
notStrictEqual(
27+
Object.getPrototypeOf(crypto.DigestStream),
28+
WritableStream
29+
);
30+
}
31+
// Inherited members must be reachable, not shadowed.
32+
strictEqual(typeof stream.getWriter, 'function');
33+
strictEqual(stream.locked, false);
34+
},
35+
};
36+
37+
export const toStringTagBranding = {
38+
test() {
39+
strictEqual(
40+
Object.prototype.toString.call(new crypto.DigestStream('md5')),
41+
'[object DigestStream]'
42+
);
43+
},
44+
};
45+
46+
export const accessorsAreBrandChecked = {
47+
test() {
48+
// Brand checks, not instanceof checks: a plain object with the right
49+
// prototype is still rejected.
50+
const fake = Object.create(crypto.DigestStream.prototype);
51+
throws(() => fake.digest, { name: 'TypeError' });
52+
throws(() => fake.bytesWritten, { name: 'TypeError' });
53+
throws(() => fake[Symbol.dispose](), { name: 'TypeError' });
54+
55+
const desc = Object.getOwnPropertyDescriptor(
56+
crypto.DigestStream.prototype,
57+
'digest'
58+
);
59+
strictEqual(typeof desc.get, 'function');
60+
strictEqual(desc.set, undefined);
61+
},
62+
};
63+
64+
export const constructorIsSubclassable = {
65+
async test() {
66+
class MyDigest extends crypto.DigestStream {
67+
constructor() {
68+
super('md5');
69+
this.tag = 'mine';
70+
}
71+
}
72+
const stream = new MyDigest();
73+
strictEqual(stream.tag, 'mine');
74+
ok(stream instanceof crypto.DigestStream);
75+
ok(stream instanceof WritableStream);
76+
const writer = stream.getWriter();
77+
await writer.write('hello');
78+
await writer.close();
79+
strictEqual((await stream.digest).byteLength, 16);
80+
},
81+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) 2026 Cloudflare, Inc.
2+
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3+
// https://opensource.org/licenses/Apache-2.0
4+
5+
// Writer accounting under the synchronous hash sink: desiredSize counts
6+
// the in-flight chunk against the default HWM of 1 and recovers once the
7+
// write settles, in BOTH implementations (contrast the compression
8+
// suite, where the C++ writer's desiredSize is inert). ready stays
9+
// settled — the eager sink never sustains backpressure.
10+
11+
import { strictEqual } from 'node:assert';
12+
13+
export const desiredSizeCountsAndRecovers = {
14+
async test() {
15+
const stream = new crypto.DigestStream('md5');
16+
const writer = stream.getWriter();
17+
strictEqual(writer.desiredSize, 1);
18+
const writePromise = writer.write(new Uint8Array(100_000));
19+
strictEqual(writer.desiredSize, 0);
20+
await writePromise;
21+
strictEqual(writer.desiredSize, 1);
22+
await writer.ready;
23+
await writer.close();
24+
},
25+
};
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright (c) 2026 Cloudflare, Inc.
2+
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3+
// https://opensource.org/licenses/Apache-2.0
4+
5+
// Input buffer lifecycle: the hash update runs inside write(), so the
6+
// chunk's bytes are consumed before the write settles — mutation after the
7+
// write cannot change the digest, and the chunk is never retained.
8+
9+
import { deepStrictEqual } from 'node:assert';
10+
import { digestOf } from 'digest-vectors';
11+
12+
export const mutationAfterWriteIsInvisible = {
13+
async test() {
14+
const buf = new Uint8Array([1, 2, 3, 4]);
15+
const stream = new crypto.DigestStream('crc32');
16+
const writer = stream.getWriter();
17+
await writer.write(buf);
18+
buf.fill(0xff);
19+
await writer.close();
20+
deepStrictEqual(
21+
new Uint8Array(await stream.digest),
22+
await digestOf('crc32', new Uint8Array([1, 2, 3, 4]))
23+
);
24+
},
25+
};
26+
27+
export const detachAfterWriteIsInvisible = {
28+
async test() {
29+
const buf = new Uint8Array([1, 2, 3, 4]);
30+
const stream = new crypto.DigestStream('crc32');
31+
const writer = stream.getWriter();
32+
await writer.write(buf);
33+
buf.buffer.transfer();
34+
await writer.close();
35+
deepStrictEqual(
36+
new Uint8Array(await stream.digest),
37+
await digestOf('crc32', new Uint8Array([1, 2, 3, 4]))
38+
);
39+
},
40+
};
41+
42+
export const lyingMetadataNeverConsulted = {
43+
async test() {
44+
// Buffer metadata comes from internal slots; shadowing own getters are
45+
// never invoked.
46+
const view = new TextEncoder().encode('real');
47+
for (const key of ['byteLength', 'byteOffset', 'buffer']) {
48+
Object.defineProperty(view, key, {
49+
get() {
50+
throw new Error(`${key} getter must not be called`);
51+
},
52+
});
53+
}
54+
const stream = new crypto.DigestStream('md5');
55+
const writer = stream.getWriter();
56+
await writer.write(view);
57+
await writer.close();
58+
deepStrictEqual(
59+
new Uint8Array(await stream.digest),
60+
await digestOf('md5', 'real')
61+
);
62+
},
63+
};

0 commit comments

Comments
 (0)