@btoon/js 0.1.1 → 0.1.2

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.
package/dist/esm/web.d.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import type { CompressionAlgorithm } from './options.js';
2
2
  /** The Compression Streams format name for `alg`, if this runtime supports it. */
3
3
  export declare function webFormat(alg: CompressionAlgorithm, kind: 'CompressionStream' | 'DecompressionStream'): string | undefined;
4
+ /**
5
+ * The DecompressionStream format that decodeAsync may use for `alg` when no other
6
+ * decompressor is available, or undefined: never for zstd, and only once the runtime has
7
+ * shown that it rejects trailing bytes.
8
+ */
9
+ export declare function trustedDecompressionFormat(alg: CompressionAlgorithm): Promise<string | undefined>;
10
+ /** Whether decodeAsync might decompress `alg` with a DecompressionStream here (for error messages). */
11
+ export declare function mayDecompressAsync(alg: CompressionAlgorithm): boolean;
4
12
  export declare function webCompress(format: string, data: Uint8Array): Promise<Uint8Array>;
5
13
  /**
6
- * Decompresses with a DecompressionStream, stopping as soon as the output exceeds
7
- * `limit` (the declared size). For zlib it also checks that the payload ends with the
8
- * Adler-32 of the output: older runtimes silently ignore bytes after the end of the
9
- * stream, which a strict decoder must reject.
14
+ * Decompresses with a DecompressionStream, stopping as soon as the output exceeds `limit`
15
+ * (the declared size). Only for a format that trustedDecompressionFormat() returned, which
16
+ * rejects bytes after the end of the stream.
10
17
  */
11
18
  export declare function webDecompress(alg: CompressionAlgorithm, format: string, data: Uint8Array, limit: number): Promise<Uint8Array>;
12
19
  type Subtle = {
package/dist/esm/web.js CHANGED
@@ -1,8 +1,10 @@
1
1
  // Asynchronous implementations on top of web platform APIs: Compression Streams and
2
2
  // WebCrypto. Used by encodeAsync/decodeAsync wherever the runtime provides them.
3
3
  import { BtoonError, fail } from './errors.js';
4
- import { adler32 } from './inflate.js';
5
4
  const FORMATS = { zlib: 'deflate', brotli: 'brotli', zstd: 'zstd' };
5
+ // DecompressionStream has no way to bound a Zstandard window (SPEC §6), so it is never used
6
+ // for zstd.
7
+ const DECOMPRESSION_FORMATS = { zlib: 'deflate', brotli: 'brotli' };
6
8
  const CHUNK = 64 * 1024;
7
9
  const supported = new Map();
8
10
  function streamCtor(kind) {
@@ -40,6 +42,76 @@ function concat(chunks, total) {
40
42
  }
41
43
  return out;
42
44
  }
45
+ function fromHex(h) {
46
+ const out = new Uint8Array(h.length / 2);
47
+ for (let i = 0; i < out.length; i++)
48
+ out[i] = parseInt(h.slice(2 * i, 2 * i + 2), 16);
49
+ return out;
50
+ }
51
+ // Streams of the five bytes "BTOON" (zlib level 9, Brotli quality 11) for the trust probe.
52
+ const PROBE_TEXT = 'BTOON';
53
+ const PROBE_STREAMS = { deflate: '78da730af1f7f7030004780183', brotli: '0b028042544f4f4e03' };
54
+ const trust = new Map();
55
+ /**
56
+ * Whether this runtime's DecompressionStream can be trusted with `format`: it must decode a
57
+ * valid stream and reject one followed by a byte (in the same write or in the next), or by a
58
+ * second stream. Node.js 18 to 23 (some later 22.x releases excepted) ignore bytes after a
59
+ * zlib stream, and Node.js 22 and 24 after a Brotli stream, while SPEC §6 requires
60
+ * `invalid_envelope`. Checked once per format.
61
+ */
62
+ function trusted(format) {
63
+ let p = trust.get(format);
64
+ if (p === undefined) {
65
+ p = probe(format);
66
+ trust.set(format, p);
67
+ }
68
+ return p;
69
+ }
70
+ async function probe(format) {
71
+ const hex = PROBE_STREAMS[format];
72
+ if (hex === undefined)
73
+ return false;
74
+ const valid = fromHex(hex);
75
+ const next = (extra) => {
76
+ const out = new Uint8Array(valid.length + extra.length);
77
+ out.set(valid);
78
+ out.set(extra, valid.length);
79
+ return out;
80
+ };
81
+ try {
82
+ const out = await decompressChunks(format, [valid], 64, format);
83
+ if (String.fromCharCode(...out) !== PROBE_TEXT)
84
+ return false;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ for (const chunks of [[next(Uint8Array.of(0))], [valid, Uint8Array.of(0)], [next(valid)]]) {
90
+ try {
91
+ await decompressChunks(format, chunks, 64, format);
92
+ return false; // trailing bytes accepted
93
+ }
94
+ catch {
95
+ // rejected, as it should be
96
+ }
97
+ }
98
+ return true;
99
+ }
100
+ /**
101
+ * The DecompressionStream format that decodeAsync may use for `alg` when no other
102
+ * decompressor is available, or undefined: never for zstd, and only once the runtime has
103
+ * shown that it rejects trailing bytes.
104
+ */
105
+ export async function trustedDecompressionFormat(alg) {
106
+ const format = DECOMPRESSION_FORMATS[alg];
107
+ if (format === undefined || webFormat(alg, 'DecompressionStream') === undefined)
108
+ return undefined;
109
+ return (await trusted(format)) ? format : undefined;
110
+ }
111
+ /** Whether decodeAsync might decompress `alg` with a DecompressionStream here (for error messages). */
112
+ export function mayDecompressAsync(alg) {
113
+ return DECOMPRESSION_FORMATS[alg] !== undefined && webFormat(alg, 'DecompressionStream') !== undefined;
114
+ }
43
115
  async function readAll(reader, limit, alg) {
44
116
  const chunks = [];
45
117
  let total = 0;
@@ -56,11 +128,18 @@ async function readAll(reader, limit, alg) {
56
128
  }
57
129
  return concat(chunks, total);
58
130
  }
59
- /** Feeds `data` in chunks, honouring backpressure, then closes the writer. */
60
- async function pump(writer, data) {
61
- for (let i = 0; i < data.length; i += CHUNK) {
131
+ /** Splits `data` into the chunks written to a stream. */
132
+ function chunked(data) {
133
+ const chunks = [];
134
+ for (let i = 0; i < data.length; i += CHUNK)
135
+ chunks.push(data.subarray(i, Math.min(i + CHUNK, data.length)));
136
+ return chunks;
137
+ }
138
+ /** Writes `chunks`, honouring backpressure, then closes the writer. */
139
+ async function pump(writer, chunks) {
140
+ for (const chunk of chunks) {
62
141
  await writer.ready;
63
- await writer.write(data.subarray(i, Math.min(i + CHUNK, data.length)));
142
+ await writer.write(chunk);
64
143
  }
65
144
  await writer.close();
66
145
  }
@@ -70,41 +149,45 @@ function messageOf(e) {
70
149
  export async function webCompress(format, data) {
71
150
  const stream = new (streamCtor('CompressionStream'))(format);
72
151
  const writer = stream.writable.getWriter();
73
- const writing = pump(writer, data);
152
+ const writing = pump(writer, chunked(data));
74
153
  writing.catch(() => undefined);
75
154
  const out = await readAll(stream.readable.getReader(), Infinity, format);
76
155
  await writing;
77
156
  return out;
78
157
  }
79
158
  /**
80
- * Decompresses with a DecompressionStream, stopping as soon as the output exceeds
81
- * `limit` (the declared size). For zlib it also checks that the payload ends with the
82
- * Adler-32 of the output: older runtimes silently ignore bytes after the end of the
83
- * stream, which a strict decoder must reject.
159
+ * Runs `chunks` through a DecompressionStream. Output beyond `limit` bytes fails with
160
+ * `invalid_envelope` ("<label> payload decompresses to more than the declared size").
84
161
  */
85
- export async function webDecompress(alg, format, data, limit) {
162
+ async function decompressChunks(format, chunks, limit, label) {
86
163
  const stream = new (streamCtor('DecompressionStream'))(format);
87
164
  const writer = stream.writable.getWriter();
88
- const writing = pump(writer, data);
165
+ const writing = pump(writer, chunks);
89
166
  writing.catch(() => undefined);
90
- let out;
91
167
  try {
92
- out = await readAll(stream.readable.getReader(), limit, alg);
168
+ const out = await readAll(stream.readable.getReader(), limit, label);
93
169
  await writing;
170
+ return out;
94
171
  }
95
172
  catch (e) {
96
173
  writer.abort(e).catch(() => undefined);
174
+ throw e;
175
+ }
176
+ }
177
+ /**
178
+ * Decompresses with a DecompressionStream, stopping as soon as the output exceeds `limit`
179
+ * (the declared size). Only for a format that trustedDecompressionFormat() returned, which
180
+ * rejects bytes after the end of the stream.
181
+ */
182
+ export async function webDecompress(alg, format, data, limit) {
183
+ try {
184
+ return await decompressChunks(format, chunked(data), limit, alg);
185
+ }
186
+ catch (e) {
97
187
  if (e instanceof BtoonError)
98
188
  throw e;
99
189
  throw fail('invalid_envelope', `${alg} payload is corrupt: ${messageOf(e)}`, e);
100
190
  }
101
- if (alg === 'zlib') {
102
- const n = data.length;
103
- const trailer = n >= 4 ? ((data[n - 4] << 24) | (data[n - 3] << 16) | (data[n - 2] << 8) | data[n - 1]) >>> 0 : -1;
104
- if (trailer !== adler32(out))
105
- throw fail('invalid_envelope', 'zlib payload has trailing bytes');
106
- }
107
- return out;
108
191
  }
109
192
  /** WebCrypto's SubtleCrypto, if available (absent in Node 18 and on insecure origins). */
110
193
  export function subtle() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btoon/js",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "BTOON (Binary TOON) for JavaScript and TypeScript: canonical, hardened MessagePack with a columnar encoding for tables. Pure TypeScript, zero dependencies; runs in browsers, Node.js, Deno, Bun and edge runtimes.",
5
5
  "keywords": [
6
6
  "btoon",