Skip to content

Commit 66ddc9b

Browse files
committed
Add digest streams suite; migrate existing DigestStream tests
1 parent cd90354 commit 66ddc9b

25 files changed

Lines changed: 1254 additions & 1110 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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+
)
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: 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+
};
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
// Chunk validation: ArrayBuffer, any ArrayBufferView (offsets honored),
6+
// and strings are accepted; everything else — including a bare
7+
// SharedArrayBuffer — rejects with the same TypeError in both
8+
// implementations. Views onto SharedArrayBuffers are accepted.
9+
10+
import { strictEqual, deepStrictEqual, rejects } from 'node:assert';
11+
12+
const badChunkMsg =
13+
'DigestStream is a byte stream but received an object ' +
14+
'of non-ArrayBuffer/ArrayBufferView/string type on its writable side.';
15+
16+
export const rejectsNonByteChunks = {
17+
async test() {
18+
for (const bad of [123, null, undefined, {}, [], true, Symbol.iterator]) {
19+
const stream = new crypto.DigestStream('md5');
20+
const writer = stream.getWriter();
21+
await rejects(writer.write(bad), { message: badChunkMsg });
22+
}
23+
},
24+
};
25+
26+
export const sharedArrayBufferHandling = {
27+
async test() {
28+
{
29+
const stream = new crypto.DigestStream('md5');
30+
const writer = stream.getWriter();
31+
await rejects(writer.write(new SharedArrayBuffer(8)), {
32+
message: badChunkMsg,
33+
});
34+
}
35+
{
36+
const stream = new crypto.DigestStream('md5');
37+
const writer = stream.getWriter();
38+
await writer.write(new Uint8Array(new SharedArrayBuffer(4)));
39+
await writer.close();
40+
await stream.digest;
41+
strictEqual(stream.bytesWritten, 4n);
42+
}
43+
},
44+
};
45+
46+
export const dataViewRespectsOffset = {
47+
async test() {
48+
const backing = new Uint8Array([9, 9, 1, 2, 3, 4, 9, 9]);
49+
const stream = new crypto.DigestStream('crc32');
50+
const writer = stream.getWriter();
51+
await writer.write(new DataView(backing.buffer, 2, 4));
52+
await writer.close();
53+
54+
const reference = new crypto.DigestStream('crc32');
55+
const refWriter = reference.getWriter();
56+
await refWriter.write(new Uint8Array([1, 2, 3, 4]));
57+
await refWriter.close();
58+
59+
deepStrictEqual(
60+
new Uint8Array(await stream.digest),
61+
new Uint8Array(await reference.digest)
62+
);
63+
strictEqual(stream.bytesWritten, 4n);
64+
},
65+
};
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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+
// Constructor arguments: algorithm names (string or {name} object, matched
6+
// case-insensitively over the whole name) and the options bag.
7+
8+
import { strictEqual, deepStrictEqual, throws } from 'node:assert';
9+
import { digestOf } from 'digest-vectors';
10+
11+
export const algorithmNamesAreCaseInsensitive = {
12+
async test() {
13+
const variants = {
14+
crc32: ['crc32', 'CRC32', 'Crc32', 'cRc32'],
15+
crc32c: ['crc32c', 'CRC32C', 'Crc32c', 'crc32C', 'cRc32C'],
16+
crc64nvme: ['crc64nvme', 'CRC64NVME', 'Crc64Nvme', 'crc64NVME'],
17+
md5: ['md5', 'MD5', 'Md5'],
18+
'sha-256': ['sha-256', 'SHA-256', 'Sha-256'],
19+
};
20+
for (const [canonical, names] of Object.entries(variants)) {
21+
const expected = await digestOf(canonical, 'hello');
22+
for (const name of names) {
23+
deepStrictEqual(
24+
await digestOf(name, 'hello'),
25+
expected,
26+
`${name} should select the same algorithm as ${canonical}`
27+
);
28+
}
29+
}
30+
},
31+
};
32+
33+
export const crcNamesStillRequireAnExactSpelling = {
34+
test() {
35+
// Case-insensitivity must not turn a nonsense name into a match: the
36+
// comparison is over the whole name.
37+
for (const name of [
38+
'crc',
39+
'crc3',
40+
'crc322',
41+
'crc32d',
42+
' crc32',
43+
'crc32 ',
44+
'crc-32',
45+
'crc64',
46+
'crc64nvm',
47+
'crc64nvmex',
48+
'nvme',
49+
]) {
50+
throws(
51+
() => new crypto.DigestStream(name),
52+
{ name: 'NotSupportedError' },
53+
`${JSON.stringify(name)} should not be a valid algorithm`
54+
);
55+
}
56+
},
57+
};
58+
59+
export const unknownAlgorithmThrowsSynchronously = {
60+
test() {
61+
throws(() => new crypto.DigestStream('foo'));
62+
throws(() => new crypto.DigestStream(''));
63+
// A missing name coerces to "undefined" and then fails the lookup.
64+
throws(() => new crypto.DigestStream({}));
65+
throws(() => new crypto.DigestStream(null));
66+
throws(() => new crypto.DigestStream(undefined));
67+
},
68+
};
69+
70+
export const algorithmAsObject = {
71+
async test() {
72+
const check = new Uint8Array([
73+
93, 65, 64, 42, 188, 75, 42, 118, 185, 113, 157, 145, 16, 23, 197, 146,
74+
]);
75+
deepStrictEqual(await digestOf({ name: 'md5' }, 'hello'), check);
76+
},
77+
};
78+
79+
export const allAlgorithms = {
80+
async test() {
81+
const sizes = {
82+
md5: 16,
83+
'SHA-1': 20,
84+
'SHA-256': 32,
85+
'SHA-384': 48,
86+
'SHA-512': 64,
87+
crc32: 4,
88+
crc32c: 4,
89+
crc64nvme: 8,
90+
};
91+
for (const [name, size] of Object.entries(sizes)) {
92+
strictEqual(
93+
(await digestOf(name, 'abc')).byteLength,
94+
size,
95+
`${name} digest size`
96+
);
97+
}
98+
},
99+
};
100+
101+
// The option bag follows Web IDL dictionary rules: undefined and null are an
102+
// empty bag, any object is read for its fields, and a primitive is a
103+
// TypeError. Arrays and functions count as objects.
104+
export const optionBagAcceptsObjectsAndRejectsPrimitives = {
105+
test() {
106+
for (const options of [undefined, null, {}, [], () => {}, new Date()]) {
107+
const stream = new crypto.DigestStream('md5', options);
108+
stream[Symbol.dispose]();
109+
}
110+
for (const options of [0, 1, '', 'x', true, false, 1n, Symbol.iterator]) {
111+
throws(
112+
() => new crypto.DigestStream('md5', options),
113+
{
114+
name: 'TypeError',
115+
message:
116+
"Failed to construct 'DigestStream': constructor parameter 2 is " +
117+
"not of type 'Options'.",
118+
},
119+
`should reject primitive option bag: ${String(options)}`
120+
);
121+
}
122+
},
123+
};
124+
125+
// Argument type-checking happens before the algorithm name is looked up.
126+
export const argumentTypesAreCheckedBeforeAlgorithmLookup = {
127+
test() {
128+
throws(() => new crypto.DigestStream('foo', 0), {
129+
name: 'TypeError',
130+
message:
131+
"Failed to construct 'DigestStream': constructor parameter 2 is not " +
132+
"of type 'Options'.",
133+
});
134+
throws(() => new crypto.DigestStream('foo', { toWellFormed: true }), {
135+
name: 'NotSupportedError',
136+
});
137+
},
138+
};

0 commit comments

Comments
 (0)