mhtml-stream 2.0.2 → 3.0.0

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/README.md CHANGED
@@ -5,10 +5,10 @@ MHTML Stream
5
5
  [![npm](https://img.shields.io/npm/v/mhtml-stream)](https://www.npmjs.com/package/mhtml-stream)
6
6
  [![license](https://img.shields.io/github/license/erikbrinkman/mhtml-stream)](LICENSE)
7
7
 
8
- Zero-dependency library for parsing MHTML data as streams using modern WHATWG
9
- streams and async iterators. Because it relies on modern cross javascript
10
- standards it works out-of-the-box in all javascript environments, with only a
11
- little tweaking necessary for module definitions.
8
+ Library for parsing MHTML data as streams using modern WHATWG streams and async
9
+ iterators. Because it relies on modern cross javascript standards it works
10
+ out-of-the-box in all javascript environments, with only a little tweaking
11
+ necessary for module definitions.
12
12
 
13
13
  Usage
14
14
  -----
@@ -39,3 +39,7 @@ Notes
39
39
  comes to whether whitespace should be added when unfolding. This currently
40
40
  uses the first whitespace character to indicate folding, and preservers any
41
41
  others.
42
+ - Decoded part content preserves the original MIME line endings. The parser
43
+ splits the stream on `\r\n`, and the quoted-printable and 7bit/8bit decoders
44
+ re-insert that `\r\n` between lines, so extraction is lossless. Both decoders
45
+ accept a `newLine` argument if you'd rather normalize to `\n`.
package/dist/headers.d.ts CHANGED
@@ -56,7 +56,7 @@ export declare class Headers implements MhtmlHeaders {
56
56
  get(key: string, delim?: string): string | null;
57
57
  getAll(key: string): string[];
58
58
  has(key: string): boolean;
59
- keys(): Iterable<string>;
59
+ keys(): IterableIterator<string>;
60
60
  values(delim?: string): IterableIterator<string>;
61
61
  valuesAll(): IterableIterator<string>;
62
62
  }
package/dist/headers.js CHANGED
@@ -1,55 +1,59 @@
1
1
  export class Headers {
2
+ // keyed by lowercased name; the original name casing (first seen) is
3
+ // preserved for iteration while lookups are case-insensitive, per RFC 5322
2
4
  #raw = new Map();
3
5
  [Symbol.iterator]() {
4
6
  return this.entries();
5
7
  }
6
8
  append(key, value) {
7
- const list = this.#raw.get(key);
8
- if (list === undefined) {
9
- this.#raw.set(key, [value]);
9
+ const entry = this.#raw.get(key.toLowerCase());
10
+ if (entry === undefined) {
11
+ this.#raw.set(key.toLowerCase(), { key, values: [value] });
10
12
  }
11
13
  else {
12
- list.push(value);
14
+ entry.values.push(value);
13
15
  }
14
16
  }
15
17
  *entries(delim = ", ") {
16
- for (const [key, values] of this.#raw.entries()) {
18
+ for (const { key, values } of this.#raw.values()) {
17
19
  yield [key, values.join(delim)];
18
20
  }
19
21
  }
20
22
  *entriesAll() {
21
- for (const [key, values] of this.#raw.entries()) {
23
+ for (const { key, values } of this.#raw.values()) {
22
24
  for (const val of values) {
23
25
  yield [key, val];
24
26
  }
25
27
  }
26
28
  }
27
29
  get(key, delim = ", ") {
28
- const vals = this.#raw.get(key);
29
- if (vals === undefined) {
30
+ const entry = this.#raw.get(key.toLowerCase());
31
+ if (entry === undefined) {
30
32
  return null;
31
33
  }
32
34
  else {
33
- return vals.join(delim);
35
+ return entry.values.join(delim);
34
36
  }
35
37
  }
36
38
  getAll(key) {
37
- return this.#raw.get(key) ?? [];
39
+ return this.#raw.get(key.toLowerCase())?.values ?? [];
38
40
  }
39
41
  has(key) {
40
- return this.#raw.has(key);
42
+ return this.#raw.has(key.toLowerCase());
41
43
  }
42
- keys() {
43
- return this.#raw.keys();
44
+ *keys() {
45
+ for (const { key } of this.#raw.values()) {
46
+ yield key;
47
+ }
44
48
  }
45
49
  *values(delim = ", ") {
46
- for (const vals of this.#raw.values()) {
47
- yield vals.join(delim);
50
+ for (const { values } of this.#raw.values()) {
51
+ yield values.join(delim);
48
52
  }
49
53
  }
50
54
  *valuesAll() {
51
- for (const vals of this.#raw.values()) {
52
- yield* vals;
55
+ for (const { values } of this.#raw.values()) {
56
+ yield* values;
53
57
  }
54
58
  }
55
59
  }
package/dist/index.d.ts CHANGED
@@ -15,7 +15,28 @@ export interface MhtmlFile {
15
15
  export type Decoder = (lines: AsyncIterable<Uint8Array>) => AsyncIterable<Uint8Array>;
16
16
  /** options for mhtml parsing */
17
17
  export interface ParseOptions {
18
- /** custom decoders for other Content-Transfer-Encodings */
18
+ /**
19
+ * custom decoders keyed by (lowercased) Content-Transfer-Encoding
20
+ *
21
+ * Use these to handle an encoding the defaults don't, or to override one. A
22
+ * {@link Decoder} turns the CRLF-split lines of a part into its decoded
23
+ * bytes; the parser strips the CRLFs when splitting, so a passthrough decoder
24
+ * re-emits them between lines:
25
+ *
26
+ * ```ts
27
+ * const crlf = new Uint8Array([13, 10]);
28
+ * const decoderOverrides = new Map([
29
+ * ["binary", async function* (lines) {
30
+ * let first = true;
31
+ * for await (const line of lines) {
32
+ * if (!first) yield crlf;
33
+ * first = false;
34
+ * yield line;
35
+ * }
36
+ * }],
37
+ * ]);
38
+ * ```
39
+ */
19
40
  decoderOverrides?: Map<string, Decoder>;
20
41
  }
21
42
  /**
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { toByteArray } from "base64-js";
2
2
  import { Headers } from "./headers";
3
- import { bytesEqual, collect, decodeBase64, decodeBinary, decodeIdentity, decodeQuotedPrintable, splitStream, } from "./utils";
3
+ import { bytesEqual, collect, decodeBase64, decodeBinary, decodeIdentity, decodeQuotedPrintable, isHexDigit, splitStream, } from "./utils";
4
4
  // NOTE we use this for ascii because it's faster than manual, but may cause
5
5
  // unexpected errors if the assumption is violated
6
6
  const encoder = new TextEncoder();
@@ -21,9 +21,12 @@ function decodeQEncoding(text) {
21
21
  }
22
22
  else if (code === 61) {
23
23
  // encoded character
24
- val = parseInt(text[++ind], 16);
25
- val *= 16;
26
- val += parseInt(text[++ind], 16);
24
+ const high = text.charCodeAt(++ind);
25
+ const low = text.charCodeAt(++ind);
26
+ if (!isHexDigit(high) || !isHexDigit(low)) {
27
+ throw new Error(`got invalid hex escape when decoding q-quoted word: "${text}"`);
28
+ }
29
+ val = parseInt(String.fromCharCode(high, low), 16);
27
30
  }
28
31
  else {
29
32
  // residual ascii
@@ -81,12 +84,12 @@ async function parseHeaders(iter) {
81
84
  headers.append(key, val);
82
85
  }
83
86
  if (line) {
84
- const delim = line.indexOf(": ");
87
+ const delim = line.indexOf(":");
85
88
  if (delim === -1) {
86
89
  throw new Error(`header line didn't have key-value delimiter: "${line}"`);
87
90
  }
88
91
  key = line.slice(0, delim);
89
- val = decodeLine(line.slice(delim + 2));
92
+ val = decodeLine(line.slice(delim + 1).replace(/^\s+/, ""));
90
93
  }
91
94
  else {
92
95
  return headers;
@@ -116,10 +119,13 @@ function getBoundary(headers) {
116
119
  let bound;
117
120
  let multipart = false;
118
121
  for (const field of contentType.split(/;\s*/)) {
119
- if (field.startsWith("multipart/")) {
122
+ // the media type and the boundary parameter name are case-insensitive, but
123
+ // the boundary value itself is not, so match on a lowercased copy
124
+ const lower = field.toLowerCase();
125
+ if (lower.startsWith("multipart/")) {
120
126
  multipart = true;
121
127
  }
122
- else if (field.startsWith("boundary=")) {
128
+ else if (lower.startsWith("boundary=")) {
123
129
  bound = field.slice(9);
124
130
  // TODO handling of quoted fields is not great
125
131
  if (bound.startsWith('"') && bound.endsWith('"')) {
@@ -155,7 +161,7 @@ export async function* parseMhtml(stream, { decoderOverrides = new Map() } = {})
155
161
  // parse out headers and get encoding for content
156
162
  const headers = await parseHeaders(lines);
157
163
  const [boundary, terminus] = bound ?? (bound = getBoundary(headers));
158
- const encoding = headers.get("Content-Transfer-Encoding") ?? "7bit";
164
+ const encoding = (headers.get("Content-Transfer-Encoding") ?? "7bit").toLowerCase();
159
165
  const decode = decoders.get(encoding);
160
166
  if (decode === undefined) {
161
167
  throw new Error(`unhandled encoding type: ${encoding}`);
@@ -140,6 +140,86 @@ Subject: =?iso-8859-1?Q?=A1Hola,\xffse=F1or!?=
140
140
  // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
141
141
  await expect(consume(parser)).rejects.toThrow("got non-ascii character when decoding q-quoted word");
142
142
  });
143
+ test("decodes base64 wrapped at a non-multiple-of-4 column", async () => {
144
+ // "hello world hello world" wrapped at column 23 (not a multiple of 4)
145
+ const content = `MIME-Version: 1.0
146
+ Content-Type: multipart/mixed; boundary=frontier
147
+
148
+ --frontier
149
+ Content-Type: application/octet-stream
150
+ Content-Transfer-Encoding: base64
151
+
152
+ aGVsbG8gd29ybGQgaGVsbG8
153
+ gd29ybGQ=
154
+ --frontier--
155
+ `;
156
+ const files = [];
157
+ for await (const file of parseMhtml(stringToStream(content))) {
158
+ files.push(file);
159
+ }
160
+ expect(decoder.decode(files[1].content)).toStrictEqual("hello world hello world");
161
+ });
162
+ test("matches header names and encodings case-insensitively", async () => {
163
+ const content = `mime-version: 1.0
164
+ content-type: MULTIPART/mixed; BOUNDARY=frontier
165
+
166
+ --frontier
167
+ content-type: application/octet-stream
168
+ content-transfer-encoding: Base64
169
+
170
+ aGVsbG8gd29ybGQ=
171
+ --frontier--
172
+ `;
173
+ const files = [];
174
+ for await (const file of parseMhtml(stringToStream(content))) {
175
+ files.push(file);
176
+ }
177
+ const headers = files.map(({ headers }) => Object.fromEntries(headers));
178
+ expect(headers).toEqual([
179
+ {
180
+ "mime-version": "1.0",
181
+ "content-type": "MULTIPART/mixed; BOUNDARY=frontier",
182
+ },
183
+ {
184
+ "content-type": "application/octet-stream",
185
+ "content-transfer-encoding": "Base64",
186
+ },
187
+ ]);
188
+ expect(decoder.decode(files[1].content)).toStrictEqual("hello world");
189
+ });
190
+ test("accepts headers without a space after the colon", async () => {
191
+ const content = `MIME-Version:1.0
192
+ Content-Type:multipart/mixed; boundary=frontier
193
+ X-Empty:
194
+
195
+ This is a message with multiple parts in MIME format.
196
+ --frontier--
197
+ `;
198
+ const files = [];
199
+ for await (const file of parseMhtml(stringToStream(content))) {
200
+ files.push(file);
201
+ }
202
+ const headers = files.map(({ headers }) => Object.fromEntries(headers));
203
+ expect(headers).toEqual([
204
+ {
205
+ "MIME-Version": "1.0",
206
+ "Content-Type": "multipart/mixed; boundary=frontier",
207
+ "X-Empty": "",
208
+ },
209
+ ]);
210
+ const text = files.map(({ content }) => decoder.decode(content));
211
+ expect(text).toEqual([
212
+ "This is a message with multiple parts in MIME format.",
213
+ ]);
214
+ });
215
+ test("fails on invalid hex in q-encoding", async () => {
216
+ const content = `MIME-Version: 1.0
217
+ Subject: =?utf-8?Q?a=ZZb?=
218
+ `;
219
+ const parser = parseMhtml(stringToStream(content));
220
+ // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
221
+ await expect(consume(parser)).rejects.toThrow("got invalid hex escape when decoding q-quoted word");
222
+ });
143
223
  test("fails without empty header delimiter", async () => {
144
224
  const content = `MIME-Version: 1.0`;
145
225
  const parser = parseMhtml(stringToStream(content));
@@ -1 +1 @@
1
- var D=L;var g=[],Y=[],f=typeof Uint8Array<"u"?Uint8Array:Array,P="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";for(Z=0,S=P.length;Z<S;++Z)g[Z]=P[Z],Y[P.charCodeAt(Z)]=Z;var Z,S;Y[45]=62;Y[95]=63;function m(G){var K=G.length;if(K%4>0)throw Error("Invalid string. Length must be a multiple of 4");var J=G.indexOf("=");if(J===-1)J=K;var M=J===K?0:4-J%4;return[J,M]}function v(G,K,J){return(K+J)*3/4-J}function L(G){var K,J=m(G),M=J[0],R=J[1],U=new f(v(G,M,R)),V=0,W=R>0?M-4:M,X;for(X=0;X<W;X+=4)K=Y[G.charCodeAt(X)]<<18|Y[G.charCodeAt(X+1)]<<12|Y[G.charCodeAt(X+2)]<<6|Y[G.charCodeAt(X+3)],U[V++]=K>>16&255,U[V++]=K>>8&255,U[V++]=K&255;if(R===2)K=Y[G.charCodeAt(X)]<<2|Y[G.charCodeAt(X+1)]>>4,U[V++]=K&255;if(R===1)K=Y[G.charCodeAt(X)]<<10|Y[G.charCodeAt(X+1)]<<4|Y[G.charCodeAt(X+2)]>>2,U[V++]=K>>8&255,U[V++]=K&255;return U}class _{#G=new Map;[Symbol.iterator](){return this.entries()}append(G,K){let J=this.#G.get(G);if(J===void 0)this.#G.set(G,[K]);else J.push(K)}*entries(G=", "){for(let[K,J]of this.#G.entries())yield[K,J.join(G)]}*entriesAll(){for(let[G,K]of this.#G.entries())for(let J of K)yield[G,J]}get(G,K=", "){let J=this.#G.get(G);if(J===void 0)return null;else return J.join(K)}getAll(G){return this.#G.get(G)??[]}has(G){return this.#G.has(G)}keys(){return this.#G.keys()}*values(G=", "){for(let K of this.#G.values())yield K.join(G)}*valuesAll(){for(let G of this.#G.values())yield*G}}function j(G,K){if(G.length!==K.length)return!1;else if(G.byteOffset%4===K.byteOffset%4){let J=(4-G.byteOffset%4)%4,M=Math.floor((G.byteLength-J)/4),R=J+M*4,U=new Uint32Array(G.buffer,G.byteOffset+J,M),V=new Uint32Array(K.buffer,K.byteOffset+J,M);for(let W=0;W<J;++W)if(G[W]!==K[W])return!1;for(let W=0;W<M;++W)if(U[W]!==V[W])return!1;for(let W=R;W<G.length;++W)if(G[W]!==K[W])return!1;return!0}else{for(let J of G.keys())if(G[J]!==K[J])return!1;return!0}}function h(G,K){return G.findIndex((J,M)=>{if(J!==K[0]||M+K.length>G.length)return!1;else{for(let R=1;R<K.length;++R)if(G[M+R]!==K[R])return!1;return!0}})}async function*Q(G,K){let J=new Uint8Array(0);for await(let M of G){J=J.length?E([J,M]):M;let R;while((R=h(J,K))!==-1)yield J.subarray(0,R),J=J.subarray(R+K.length)}yield J}function E(G){let K=G.reduce((R,U)=>R+U.length,0),J=new Uint8Array(K),M=0;for(let R of G)J.set(R,M),M+=R.length;return J}async function O(G){let K=[];for await(let J of G)K.push(J);return E(K)}var k=new Uint8Array([10]);async function*T(G,K=k){for await(let J of G){let M=new Uint8Array(J.length+K.length),R=!1,U=0;for(let V=0;V<J.length;++V){let W=J[V];if(W>=128)throw Error(`got non-ascii character when decoding quoted printable: ${W}`);if(W!==61)M[U++]=W;else{let X=J[++V];if(X===void 0)R=!0;else{let z=J[++V];if(z===void 0)throw Error("quoted printable escape (=) was not followed by two bytes");let $=parseInt(String.fromCharCode(X),16);$*=16,$+=parseInt(String.fromCharCode(z),16),M[U++]=$}}}if(!R)M.set(K,U),U+=K.length;yield M.subarray(0,U)}}var y=new TextDecoder;async function*C(G){for await(let K of G)yield D(y.decode(K))}async function*q(G){for await(let K of G)yield K}function A(){throw Error("binary transfer-encoding is explicitly not supported and trying to add an implementation will likely result in unexpected results, but if you want to ignore anyway, set binary to `decode8bit`")}var I=new TextEncoder,B=new TextDecoder;function b(G){let K=new Uint8Array(G.length),J=0;for(let M=0;M<G.length;++M){let R=G.charCodeAt(M),U;if(R>=128)throw Error(`got non-ascii character when decoding q-quoted word: "${G}"`);else if(R===95)U=32;else if(R===61)U=parseInt(G[++M],16),U*=16,U+=parseInt(G[++M],16);else U=R;K[J++]=U}return K.subarray(0,J)}var p=/^=\?([^?\s]+)\?([BQ])\?([^?\s]+)\?=$/;function H(G){let K=p.exec(G);if(K){let[,J,M,R]=K,U;if(M==="Q")U=b(R);else U=D(R);return new TextDecoder(J).decode(U)}else return G}async function c(G){let K=new _,J="",M="";for(;;){let{done:R,value:U}=await G.next();if(R)throw Error("didn't find an empty line to signify the end of header parsing");let V=B.decode(U);if(/^\s/.test(V))M+=H(V.substring(1));else{if(J)K.append(J,M);if(V){let W=V.indexOf(": ");if(W===-1)throw Error(`header line didn't have key-value delimiter: "${V}"`);J=V.slice(0,W),M=H(V.slice(W+2))}else return K}}}var u=new Map([["7bit",q],["base64",C],["quoted-printable",T],["8bit",q],["binary",A]]);function s(G){let K=G.get("Content-Type");if(K===null)throw Error(`first headers didn't contain a content type: ${JSON.stringify(Object.fromEntries(G))}`);let J,M=!1;for(let V of K.split(/;\s*/))if(V.startsWith("multipart/"))M=!0;else if(V.startsWith("boundary=")){if(J=V.slice(9),J.startsWith('"')&&J.endsWith('"'))J=J.slice(1,-1)}if(!M||J===void 0)throw Error("first content type header didn't contain 'multipart/...' and a boundary string");let R=I.encode(`--${J}`),U=I.encode(`--${J}--`);return[R,U]}async function*t(G,{decoderOverrides:K=new Map}={}){let J=new Map([...u.entries(),...K.entries()]),M=new Uint8Array([13,10]),R=Q(G,M),U=null,V=!0;while(V){let W=await c(R),[X,z]=U??(U=s(W)),$=W.get("Content-Transfer-Encoding")??"7bit",F=J.get($);if(F===void 0)throw Error(`unhandled encoding type: ${$}`);let w=await O(F({[Symbol.asyncIterator](){return{async next(){let{done:x,value:N}=await R.next();if(x)throw Error(`stream didn't end with the appropriate termination boundary: ${B.decode(z)}`);else if(j(N,X))return{done:!0,value:void 0};else if(j(N,z))return V=!1,{done:!0,value:void 0};else return{value:N}}}}}));yield{headers:W,content:w}}}export{t as parseMhtml};
1
+ var R=k;var m=[],Z=[],f=typeof Uint8Array<"u"?Uint8Array:Array,j="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";for($=0,q=j.length;$<q;++$)m[$]=j[$],Z[j.charCodeAt($)]=$;var $,q;Z[45]=62;Z[95]=63;function L(G){var J=G.length;if(J%4>0)throw Error("Invalid string. Length must be a multiple of 4");var K=G.indexOf("=");if(K===-1)K=J;var M=K===J?0:4-K%4;return[K,M]}function h(G,J,K){return(J+K)*3/4-K}function k(G){var J,K=L(G),M=K[0],U=K[1],W=new f(h(G,M,U)),X=0,V=U>0?M-4:M,Y;for(Y=0;Y<V;Y+=4)J=Z[G.charCodeAt(Y)]<<18|Z[G.charCodeAt(Y+1)]<<12|Z[G.charCodeAt(Y+2)]<<6|Z[G.charCodeAt(Y+3)],W[X++]=J>>16&255,W[X++]=J>>8&255,W[X++]=J&255;if(U===2)J=Z[G.charCodeAt(Y)]<<2|Z[G.charCodeAt(Y+1)]>>4,W[X++]=J&255;if(U===1)J=Z[G.charCodeAt(Y)]<<10|Z[G.charCodeAt(Y+1)]<<4|Z[G.charCodeAt(Y+2)]>>2,W[X++]=J>>8&255,W[X++]=J&255;return W}class Q{#G=new Map;[Symbol.iterator](){return this.entries()}append(G,J){let K=this.#G.get(G.toLowerCase());if(K===void 0)this.#G.set(G.toLowerCase(),{key:G,values:[J]});else K.values.push(J)}*entries(G=", "){for(let{key:J,values:K}of this.#G.values())yield[J,K.join(G)]}*entriesAll(){for(let{key:G,values:J}of this.#G.values())for(let K of J)yield[G,K]}get(G,J=", "){let K=this.#G.get(G.toLowerCase());if(K===void 0)return null;else return K.values.join(J)}getAll(G){return this.#G.get(G.toLowerCase())?.values??[]}has(G){return this.#G.has(G.toLowerCase())}*keys(){for(let{key:G}of this.#G.values())yield G}*values(G=", "){for(let{values:J}of this.#G.values())yield J.join(G)}*valuesAll(){for(let{values:G}of this.#G.values())yield*G}}function E(G,J){if(G.length!==J.length)return!1;else if(G.byteOffset%4===J.byteOffset%4){let K=(4-G.byteOffset%4)%4,M=Math.floor((G.byteLength-K)/4),U=K+M*4,W=new Uint32Array(G.buffer,G.byteOffset+K,M),X=new Uint32Array(J.buffer,J.byteOffset+K,M);for(let V=0;V<K;++V)if(G[V]!==J[V])return!1;for(let V=0;V<M;++V)if(W[V]!==X[V])return!1;for(let V=U;V<G.length;++V)if(G[V]!==J[V])return!1;return!0}else{for(let K of G.keys())if(G[K]!==J[K])return!1;return!0}}function b(G,J){return G.findIndex((K,M)=>{if(K!==J[0]||M+J.length>G.length)return!1;else{for(let U=1;U<J.length;++U)if(G[M+U]!==J[U])return!1;return!0}})}async function*O(G,J){let K=new Uint8Array(0);for await(let M of G){K=K.length?T([K,M]):M;let U;while((U=b(K,J))!==-1)yield K.subarray(0,U),K=K.subarray(U+J.length)}yield K}function T(G){let J=G.reduce((U,W)=>U+W.length,0),K=new Uint8Array(J),M=0;for(let U of G)K.set(U,M),M+=U.length;return K}async function C(G){let J=[];for await(let K of G)J.push(K);return T(J)}var D=new Uint8Array([13,10]);function S(G){return G>=48&&G<=57||G>=65&&G<=70||G>=97&&G<=102}async function*A(G,J=D){let K=!1;for await(let M of G){let U=new Uint8Array(M.length+J.length),W=0;if(K)U.set(J,W),W+=J.length,K=!1;let X=!1;for(let V=0;V<M.length;++V){let Y=M[V];if(Y>=128)throw Error(`got non-ascii character when decoding quoted printable: ${Y}`);if(Y!==61)U[W++]=Y;else{let z=M[++V];if(z===void 0)X=!0;else{let P=M[++V];if(P===void 0)throw Error("quoted printable escape (=) was not followed by two bytes");else if(!S(z)||!S(P))throw Error(`quoted printable escape (=) was not followed by two hex digits: "=${String.fromCharCode(z,P)}"`);U[W++]=parseInt(String.fromCharCode(z,P),16)}}}if(!X)K=!0;yield U.subarray(0,W)}}var y=new TextDecoder;async function*I(G){let J="";for await(let K of G){J+=y.decode(K).replace(/\s/g,"");let M=J.length-J.length%4;if(M>0)yield R(J.slice(0,M)),J=J.slice(M)}if(J.length>0)yield R(J)}async function*F(G,J=D){let K=!0;for await(let M of G){if(K)K=!1;else yield J;yield M}}function H(){throw Error("binary transfer-encoding is explicitly not supported and trying to add an implementation will likely result in unexpected results, but if you want to handle it anyway, override `binary` in `decoderOverrides`")}var B=new TextEncoder,x=new TextDecoder;function p(G){let J=new Uint8Array(G.length),K=0;for(let M=0;M<G.length;++M){let U=G.charCodeAt(M),W;if(U>=128)throw Error(`got non-ascii character when decoding q-quoted word: "${G}"`);else if(U===95)W=32;else if(U===61){let X=G.charCodeAt(++M),V=G.charCodeAt(++M);if(!S(X)||!S(V))throw Error(`got invalid hex escape when decoding q-quoted word: "${G}"`);W=parseInt(String.fromCharCode(X,V),16)}else W=U;J[K++]=W}return J.subarray(0,K)}var c=/^=\?([^?\s]+)\?([BQ])\?([^?\s]+)\?=$/;function w(G){let J=c.exec(G);if(J){let[,K,M,U]=J,W;if(M==="Q")W=p(U);else W=R(U);return new TextDecoder(K).decode(W)}else return G}async function u(G){let J=new Q,K="",M="";for(;;){let{done:U,value:W}=await G.next();if(U)throw Error("didn't find an empty line to signify the end of header parsing");let X=x.decode(W);if(/^\s/.test(X))M+=w(X.substring(1));else{if(K)J.append(K,M);if(X){let V=X.indexOf(":");if(V===-1)throw Error(`header line didn't have key-value delimiter: "${X}"`);K=X.slice(0,V),M=w(X.slice(V+1).replace(/^\s+/,""))}else return J}}}var a=new Map([["7bit",F],["base64",I],["quoted-printable",A],["8bit",F],["binary",H]]);function s(G){let J=G.get("Content-Type");if(J===null)throw Error(`first headers didn't contain a content type: ${JSON.stringify(Object.fromEntries(G))}`);let K,M=!1;for(let X of J.split(/;\s*/)){let V=X.toLowerCase();if(V.startsWith("multipart/"))M=!0;else if(V.startsWith("boundary=")){if(K=X.slice(9),K.startsWith('"')&&K.endsWith('"'))K=K.slice(1,-1)}}if(!M||K===void 0)throw Error("first content type header didn't contain 'multipart/...' and a boundary string");let U=B.encode(`--${K}`),W=B.encode(`--${K}--`);return[U,W]}async function*e(G,{decoderOverrides:J=new Map}={}){let K=new Map([...a.entries(),...J.entries()]),M=new Uint8Array([13,10]),U=O(G,M),W=null,X=!0;while(X){let V=await u(U),[Y,z]=W??(W=s(V)),P=(V.get("Content-Transfer-Encoding")??"7bit").toLowerCase(),N=K.get(P);if(N===void 0)throw Error(`unhandled encoding type: ${P}`);let g=await C(N({[Symbol.asyncIterator](){return{async next(){let{done:v,value:_}=await U.next();if(v)throw Error(`stream didn't end with the appropriate termination boundary: ${x.decode(z)}`);else if(E(_,Y))return{done:!0,value:void 0};else if(E(_,z))return X=!1,{done:!0,value:void 0};else return{value:_}}}}}));yield{headers:V,content:g}}}export{e as parseMhtml};
package/dist/utils.d.ts CHANGED
@@ -18,21 +18,38 @@ export declare function splitStream(iter: AsyncIterable<Uint8Array>, split: Uint
18
18
  * collect an async iterable of buffers into one
19
19
  */
20
20
  export declare function collect(stream: AsyncIterable<Uint8Array>): Promise<Uint8Array>;
21
+ /** whether a character code is a hex digit (0-9, A-F, a-f) */
22
+ export declare function isHexDigit(code: number): boolean;
21
23
  /**
22
24
  * decoder for quoted printable
23
25
  *
24
26
  * If quoted printable "lines" aren't escaped with an "=" then a new line needs
25
- * to be inserted. We use `newLine` which defaults to a single "\n".
27
+ * to be inserted. We use `newLine`, which defaults to CRLF to match the
28
+ * canonical MIME form; pass a custom separator (e.g. a single "\n") to
29
+ * normalize instead. The separator goes between lines; the CRLF before the MIME
30
+ * boundary belongs to the delimiter, not the body.
26
31
  */
27
32
  export declare function decodeQuotedPrintable(lines: AsyncIterable<Uint8Array>, newLine?: Uint8Array): AsyncIterableIterator<Uint8Array>;
28
33
  /**
29
34
  * decoder for base64
35
+ *
36
+ * RFC 2045 requires decoders to ignore line breaks and decode the concatenated
37
+ * stream, so producers may wrap at any column. We strip whitespace and buffer
38
+ * characters that don't yet form a complete four-character quantum, flushing
39
+ * the remainder at the end of the part.
30
40
  */
31
41
  export declare function decodeBase64(lines: AsyncIterable<Uint8Array>): AsyncIterableIterator<Uint8Array>;
32
42
  /**
33
43
  * decoder for 7bit and 8bit
34
- */
35
- export declare function decodeIdentity(lines: AsyncIterable<Uint8Array>): AsyncIterableIterator<Uint8Array>;
44
+ *
45
+ * 7bit/8bit apply no transfer transformation, so the content bytes are the
46
+ * payload as-is. parseMhtml splits the stream on CRLF to find part boundaries,
47
+ * so we re-insert `newLine` (defaulting to CRLF) between lines to restore the
48
+ * original bytes exactly. Pass `newLine` (e.g. a single "\n") to normalize line
49
+ * endings instead. The separator goes between lines; the CRLF before the
50
+ * boundary belongs to the delimiter, not the body.
51
+ */
52
+ export declare function decodeIdentity(lines: AsyncIterable<Uint8Array>, newLine?: Uint8Array): AsyncIterableIterator<Uint8Array>;
36
53
  /**
37
54
  * decoder for binary
38
55
  *
package/dist/utils.js CHANGED
@@ -111,19 +111,33 @@ export async function collect(stream) {
111
111
  }
112
112
  return concat(chunks);
113
113
  }
114
- // default new line character for quoted printable decoding
115
- const defaultNewLine = new Uint8Array([10]);
114
+ const crlf = new Uint8Array([13, 10]);
115
+ /** whether a character code is a hex digit (0-9, A-F, a-f) */
116
+ export function isHexDigit(code) {
117
+ return ((code >= 48 && code <= 57) ||
118
+ (code >= 65 && code <= 70) ||
119
+ (code >= 97 && code <= 102));
120
+ }
116
121
  /**
117
122
  * decoder for quoted printable
118
123
  *
119
124
  * If quoted printable "lines" aren't escaped with an "=" then a new line needs
120
- * to be inserted. We use `newLine` which defaults to a single "\n".
125
+ * to be inserted. We use `newLine`, which defaults to CRLF to match the
126
+ * canonical MIME form; pass a custom separator (e.g. a single "\n") to
127
+ * normalize instead. The separator goes between lines; the CRLF before the MIME
128
+ * boundary belongs to the delimiter, not the body.
121
129
  */
122
- export async function* decodeQuotedPrintable(lines, newLine = defaultNewLine) {
130
+ export async function* decodeQuotedPrintable(lines, newLine = crlf) {
131
+ let pendingNewLine = false; // a hard line break from the previous line
123
132
  for await (const bytes of lines) {
124
133
  const res = new Uint8Array(bytes.length + newLine.length);
125
- let softLine = false; // if newline wasn't escaped so we need to add
126
134
  let destInd = 0;
135
+ if (pendingNewLine) {
136
+ res.set(newLine, destInd);
137
+ destInd += newLine.length;
138
+ pendingNewLine = false;
139
+ }
140
+ let softLine = false; // if newline was escaped, so we shouldn't add one
127
141
  for (let ind = 0; ind < bytes.length; ++ind) {
128
142
  const code = bytes[ind];
129
143
  if (code >= 128) {
@@ -144,16 +158,15 @@ export async function* decodeQuotedPrintable(lines, newLine = defaultNewLine) {
144
158
  if (second === undefined) {
145
159
  throw new Error("quoted printable escape (=) was not followed by two bytes");
146
160
  }
147
- let val = parseInt(String.fromCharCode(first), 16);
148
- val *= 16;
149
- val += parseInt(String.fromCharCode(second), 16);
150
- res[destInd++] = val;
161
+ else if (!isHexDigit(first) || !isHexDigit(second)) {
162
+ throw new Error(`quoted printable escape (=) was not followed by two hex digits: "=${String.fromCharCode(first, second)}"`);
163
+ }
164
+ res[destInd++] = parseInt(String.fromCharCode(first, second), 16);
151
165
  }
152
166
  }
153
167
  }
154
168
  if (!softLine) {
155
- res.set(newLine, destInd);
156
- destInd += newLine.length;
169
+ pendingNewLine = true;
157
170
  }
158
171
  yield res.subarray(0, destInd);
159
172
  }
@@ -161,17 +174,45 @@ export async function* decodeQuotedPrintable(lines, newLine = defaultNewLine) {
161
174
  const decoder = new TextDecoder();
162
175
  /**
163
176
  * decoder for base64
177
+ *
178
+ * RFC 2045 requires decoders to ignore line breaks and decode the concatenated
179
+ * stream, so producers may wrap at any column. We strip whitespace and buffer
180
+ * characters that don't yet form a complete four-character quantum, flushing
181
+ * the remainder at the end of the part.
164
182
  */
165
183
  export async function* decodeBase64(lines) {
184
+ let residual = "";
166
185
  for await (const bytes of lines) {
167
- yield toByteArray(decoder.decode(bytes));
186
+ residual += decoder.decode(bytes).replace(/\s/g, "");
187
+ const usable = residual.length - (residual.length % 4);
188
+ if (usable > 0) {
189
+ yield toByteArray(residual.slice(0, usable));
190
+ residual = residual.slice(usable);
191
+ }
192
+ }
193
+ if (residual.length > 0) {
194
+ yield toByteArray(residual);
168
195
  }
169
196
  }
170
197
  /**
171
198
  * decoder for 7bit and 8bit
199
+ *
200
+ * 7bit/8bit apply no transfer transformation, so the content bytes are the
201
+ * payload as-is. parseMhtml splits the stream on CRLF to find part boundaries,
202
+ * so we re-insert `newLine` (defaulting to CRLF) between lines to restore the
203
+ * original bytes exactly. Pass `newLine` (e.g. a single "\n") to normalize line
204
+ * endings instead. The separator goes between lines; the CRLF before the
205
+ * boundary belongs to the delimiter, not the body.
172
206
  */
173
- export async function* decodeIdentity(lines) {
207
+ export async function* decodeIdentity(lines, newLine = crlf) {
208
+ let first = true;
174
209
  for await (const bytes of lines) {
210
+ if (first) {
211
+ first = false;
212
+ }
213
+ else {
214
+ yield newLine;
215
+ }
175
216
  yield bytes;
176
217
  }
177
218
  }
@@ -182,5 +223,5 @@ export async function* decodeIdentity(lines) {
182
223
  * error.
183
224
  */
184
225
  export function decodeBinary() {
185
- throw new Error("binary transfer-encoding is explicitly not supported and trying to add an implementation will likely result in unexpected results, but if you want to ignore anyway, set binary to `decode8bit`");
226
+ throw new Error("binary transfer-encoding is explicitly not supported and trying to add an implementation will likely result in unexpected results, but if you want to handle it anyway, override `binary` in `decoderOverrides`");
186
227
  }
@@ -115,7 +115,13 @@ describe("decodeQuotedPrintable()", () => {
115
115
  // eslint-disable-next-line spellcheck/spell-checker
116
116
  const input = ["key=3Dvalue", "this line continues =", "on the next line"];
117
117
  const res = decoder.decode(await collect(decodeQuotedPrintable(toAsyncIterable(input.map((l) => encoder.encode(l))))));
118
- expect(res).toStrictEqual("key=value\nthis line continues on the next line\n");
118
+ expect(res).toStrictEqual("key=value\r\nthis line continues on the next line");
119
+ });
120
+ test("normalizes to a custom separator when given one", async () => {
121
+ // eslint-disable-next-line spellcheck/spell-checker
122
+ const input = ["key=3Dvalue", "this line continues =", "on the next line"];
123
+ const res = decoder.decode(await collect(decodeQuotedPrintable(toAsyncIterable(input.map((l) => encoder.encode(l))), new Uint8Array([10]))));
124
+ expect(res).toStrictEqual("key=value\nthis line continues on the next line");
119
125
  });
120
126
  test("ascii failure", async () => {
121
127
  const input = ["\xff"];
@@ -137,11 +143,33 @@ describe("decodeQuotedPrintable()", () => {
137
143
  }
138
144
  })()).rejects.toThrow("quoted printable escape");
139
145
  });
146
+ test("non-hex escape failure", async () => {
147
+ const input = ["a=ZZb"];
148
+ const decoded = decodeQuotedPrintable(toAsyncIterable(input.map((l) => encoder.encode(l))));
149
+ // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
150
+ await expect((async () => {
151
+ for await (const _ of decoded) {
152
+ //
153
+ }
154
+ })()).rejects.toThrow("two hex digits");
155
+ });
140
156
  });
141
- test("decodeIdentity()", async () => {
142
- const input = ["line ", "not ascii \xff"];
143
- const res = decoder.decode(await collect(decodeIdentity(toAsyncIterable(input.map((l) => encoder.encode(l))))));
144
- expect(res).toStrictEqual("line not ascii \xff");
157
+ describe("decodeIdentity()", () => {
158
+ test("passes bytes through", async () => {
159
+ const input = ["line ", "not ascii \xff"];
160
+ const res = decoder.decode(await collect(decodeIdentity(toAsyncIterable(input.map((l) => encoder.encode(l))))));
161
+ expect(res).toStrictEqual("line \r\nnot ascii \xff");
162
+ });
163
+ test("reinserts CRLF between lines", async () => {
164
+ const input = ["line one", "line two", "line three"];
165
+ const res = decoder.decode(await collect(decodeIdentity(toAsyncIterable(input.map((l) => encoder.encode(l))))));
166
+ expect(res).toStrictEqual("line one\r\nline two\r\nline three");
167
+ });
168
+ test("normalizes to a custom separator when given one", async () => {
169
+ const input = ["line one", "line two"];
170
+ const res = decoder.decode(await collect(decodeIdentity(toAsyncIterable(input.map((l) => encoder.encode(l))), new Uint8Array([10]))));
171
+ expect(res).toStrictEqual("line one\nline two");
172
+ });
145
173
  });
146
174
  test("decodeBinary()", () => {
147
175
  expect(decodeBinary).toThrow("explicitly");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mhtml-stream",
3
- "version": "2.0.2",
4
- "description": "Zero dependency stream MHTML parser",
3
+ "version": "3.0.0",
4
+ "description": "Streaming MHTML parser",
5
5
  "keywords": [
6
6
  "mhtml",
7
7
  "stream"
@@ -10,6 +10,7 @@
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/erikbrinkman/mhtml-stream.git"
12
12
  },
13
+ "homepage": "https://erikbrinkman.github.io/mhtml-stream/",
13
14
  "author": "Erik Brinkman <erik.brinkman@gmail.com>",
14
15
  "license": "MIT",
15
16
  "type": "module",