imapflow 1.6.4 → 1.6.6

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.
Files changed (46) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/lib/commands/append.js +26 -4
  4. package/lib/commands/compress.js +29 -18
  5. package/lib/commands/copyuid-parser.js +4 -2
  6. package/lib/commands/expunge.js +5 -2
  7. package/lib/commands/fetch.js +9 -3
  8. package/lib/commands/list.js +18 -38
  9. package/lib/commands/namespace.js +2 -2
  10. package/lib/commands/quota.js +10 -2
  11. package/lib/commands/search.js +54 -14
  12. package/lib/commands/select.js +81 -70
  13. package/lib/commands/status-fields.js +68 -0
  14. package/lib/commands/status.js +23 -61
  15. package/lib/handler/imap-compiler.js +91 -60
  16. package/lib/handler/imap-parser.js +7 -0
  17. package/lib/handler/imap-stream.js +78 -12
  18. package/lib/handler/limits.js +16 -4
  19. package/lib/imap-flow.d.ts +22 -2
  20. package/lib/imap-flow.js +209 -94
  21. package/lib/jp-decoder.js +30 -5
  22. package/lib/limited-passthrough.js +19 -1
  23. package/lib/search-compiler.js +24 -16
  24. package/lib/tools.js +190 -39
  25. package/package.json +4 -4
  26. package/test/commands-branches-test.js +4 -0
  27. package/test/commands-integration-test.js +780 -5
  28. package/test/copyuid-parser-test.js +20 -0
  29. package/test/idle-polling-test.js +81 -0
  30. package/test/imap-compiler-test.js +74 -4
  31. package/test/imap-flow-coverage-test.js +4 -2
  32. package/test/imap-flow-fetch-download-test.js +26 -0
  33. package/test/imap-flow-internals-test.js +134 -0
  34. package/test/imap-flow-methods-test.js +92 -0
  35. package/test/imap-flow-secure-test.js +133 -116
  36. package/test/imap-flow-server-test.js +126 -0
  37. package/test/imap-parser-test.js +25 -0
  38. package/test/imap-stream-edge-cases-test.js +163 -3
  39. package/test/integration/rev2-live-test.js +30 -0
  40. package/test/jp-decoder-test.js +57 -0
  41. package/test/limited-passthrough-test.js +24 -0
  42. package/test/parser-limits-test.js +18 -0
  43. package/test/reliability-improvements-test.js +3 -3
  44. package/test/search-compiler-test.js +90 -3
  45. package/test/timer-policy-test.js +27 -1
  46. package/test/tools-test.js +151 -2
@@ -75,37 +75,48 @@ const lineReader = (sock, onLine) => {
75
75
 
76
76
  const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
77
77
 
78
- // ---------------------------------------------------------------------------
79
- // STARTTLS happy path
80
- // ---------------------------------------------------------------------------
81
-
82
- module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
83
- let server = net.createServer(rawSocket => {
78
+ // Builds a STARTTLS mock server. Defaults give the happy path; options tweak a
79
+ // phase without another copy of the upgrade scaffold:
80
+ // - preTlsCaps / postTlsCaps: capability list advertised before / after the upgrade
81
+ // - startTlsOk: tag => string, overrides the response line to the STARTTLS command
82
+ // - onTlsLine: (tlsSocket, line) => boolean, intercepts post-upgrade lines; return
83
+ // true when the line was handled
84
+ const createStartTlsServer = (opts = {}) =>
85
+ net.createServer(rawSocket => {
84
86
  rawSocket.on('error', () => {});
85
87
 
88
+ let preTlsCaps = opts.preTlsCaps || `${CAPS} STARTTLS`;
89
+ let postTlsCaps = opts.postTlsCaps || CAPS;
90
+
86
91
  let detachPlain;
87
92
  detachPlain = lineReader(rawSocket, line => {
88
- handleLine(
89
- rawSocket,
90
- line,
91
- () => {
92
- // Upgrade: stop reading plaintext, wrap the socket in TLS
93
- detachPlain();
94
- let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
95
- tlsSocket.on('error', () => {});
96
- tlsSocket.on('secure', () => {});
97
- // post-TLS phase advertises a different capability set
98
- lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, `${CAPS} POSTTLS-ONLY`));
99
- },
100
- `${CAPS} STARTTLS PRETLS-ONLY`
101
- );
93
+ let parts = line.split(' ');
94
+ let tag = parts[0];
95
+ let cmd = (parts[1] || '').toUpperCase();
96
+
97
+ if (cmd === 'STARTTLS') {
98
+ rawSocket.write(opts.startTlsOk ? opts.startTlsOk(tag) : `${tag} OK Begin TLS\r\n`);
99
+ detachPlain();
100
+ let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
101
+ tlsSocket.on('error', () => {});
102
+ lineReader(tlsSocket, l => {
103
+ if (opts.onTlsLine && opts.onTlsLine(tlsSocket, l)) {
104
+ return;
105
+ }
106
+ handleLine(tlsSocket, l, null, postTlsCaps);
107
+ });
108
+ return;
109
+ }
110
+
111
+ handleLine(rawSocket, line, null, preTlsCaps);
102
112
  });
103
113
 
104
- rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS PRETLS-ONLY] ready\r\n`);
114
+ rawSocket.write(`* OK [CAPABILITY ${preTlsCaps}] ready\r\n`);
105
115
  });
106
116
 
107
- let port = await listen(server);
108
- let client = new ImapFlow({
117
+ // The client options shared by every STARTTLS test.
118
+ const makeStartTlsClient = (port, overrides = {}) =>
119
+ new ImapFlow({
109
120
  host: '127.0.0.1',
110
121
  port,
111
122
  secure: false,
@@ -115,8 +126,30 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
115
126
  disableAutoIdle: true,
116
127
  disableCompression: true,
117
128
  logger: false,
118
- auth: { user: 'test', pass: 'secret' }
129
+ auth: { user: 'test', pass: 'secret' },
130
+ ...overrides
119
131
  });
132
+
133
+ // Every terminal upgrade path must leave no upgrade state behind
134
+ const assertUpgradeSettled = (test, client) => {
135
+ test.equal(client.upgrading, false, 'upgrading flag cleared');
136
+ test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
137
+ test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
138
+ };
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // STARTTLS happy path
142
+ // ---------------------------------------------------------------------------
143
+
144
+ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
145
+ // post-TLS phase advertises a different capability set
146
+ let server = createStartTlsServer({
147
+ preTlsCaps: `${CAPS} STARTTLS PRETLS-ONLY`,
148
+ postTlsCaps: `${CAPS} POSTTLS-ONLY`
149
+ });
150
+
151
+ let port = await listen(server);
152
+ let client = makeStartTlsClient(port);
120
153
  client.on('error', () => {});
121
154
 
122
155
  await client.connect();
@@ -141,43 +174,15 @@ module.exports['Secure: STARTTLS discards capabilities even when the OK carries
141
174
  // a discard conditioned on "an update is still pending" would keep exactly the
142
175
  // pre-TLS list an attacker controls - the list that then chooses the AUTH
143
176
  // mechanism and answers LOGINDISABLED. RFC 9051 6.2.1 makes the discard mandatory.
144
- let server = net.createServer(rawSocket => {
145
- rawSocket.on('error', () => {});
146
-
147
- let detachPlain;
148
- detachPlain = lineReader(rawSocket, line => {
149
- let parts = line.split(' ');
150
- let tag = parts[0];
151
- let cmd = (parts[1] || '').toUpperCase();
152
-
153
- if (cmd === 'STARTTLS') {
154
- rawSocket.write(`${tag} OK [CAPABILITY ${CAPS} PRETLS-ONLY] Begin TLS\r\n`);
155
- detachPlain();
156
- let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
157
- tlsSocket.on('error', () => {});
158
- lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, `${CAPS} POSTTLS-ONLY`));
159
- return;
160
- }
161
-
162
- handleLine(rawSocket, line, null, `${CAPS} STARTTLS PRETLS-ONLY`);
163
- });
164
-
165
- rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS PRETLS-ONLY] ready\r\n`);
177
+ let server = createStartTlsServer({
178
+ preTlsCaps: `${CAPS} STARTTLS PRETLS-ONLY`,
179
+ postTlsCaps: `${CAPS} POSTTLS-ONLY`,
180
+ // the OK itself carries a (pre-TLS, attacker-rewritable) CAPABILITY code
181
+ startTlsOk: tag => `${tag} OK [CAPABILITY ${CAPS} PRETLS-ONLY] Begin TLS\r\n`
166
182
  });
167
183
 
168
184
  let port = await listen(server);
169
- let client = new ImapFlow({
170
- host: '127.0.0.1',
171
- port,
172
- secure: false,
173
- doSTARTTLS: true,
174
- servername: 'localhost',
175
- tls: { rejectUnauthorized: false },
176
- disableAutoIdle: true,
177
- disableCompression: true,
178
- logger: false,
179
- auth: { user: 'test', pass: 'secret' }
180
- });
185
+ let client = makeStartTlsClient(port);
181
186
  client.on('error', () => {});
182
187
 
183
188
  await client.connect();
@@ -191,23 +196,40 @@ module.exports['Secure: STARTTLS discards capabilities even when the OK carries
191
196
  test.done();
192
197
  };
193
198
 
194
- // Builds the STARTTLS happy-path server used by the watchdog and cleanup tests below.
195
- const createStartTlsServer = () =>
196
- net.createServer(rawSocket => {
197
- rawSocket.on('error', () => {});
199
+ module.exports['Secure: pre-TLS rawCapabilities do not survive a failed re-fetch'] = async test => {
200
+ // capabilities/authCapabilities are discarded at the upgrade, but rawCapabilities
201
+ // is public surface external consumers read. If the post-TLS CAPABILITY re-fetch
202
+ // fails, the pre-TLS list - the one an active attacker can rewrite - must not
203
+ // linger there either.
204
+ let server = createStartTlsServer({
205
+ preTlsCaps: `${CAPS} STARTTLS PRETLS-ONLY`,
206
+ postTlsCaps: `${CAPS} POSTTLS-ONLY`,
207
+ onTlsLine: (tlsSocket, line) => {
208
+ let parts = line.split(' ');
209
+ if ((parts[1] || '').toUpperCase() === 'CAPABILITY') {
210
+ // the re-fetch over TLS fails
211
+ tlsSocket.write(`${parts[0]} NO CAPABILITY not available\r\n`);
212
+ return true;
213
+ }
214
+ return false;
215
+ }
216
+ });
198
217
 
199
- let detachPlain;
200
- detachPlain = lineReader(rawSocket, line => {
201
- handleLine(rawSocket, line, () => {
202
- detachPlain();
203
- let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
204
- tlsSocket.on('error', () => {});
205
- lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, CAPS));
206
- });
207
- });
218
+ let port = await listen(server);
219
+ let client = makeStartTlsClient(port);
220
+ client.on('error', () => {});
208
221
 
209
- rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
210
- });
222
+ await client.connect();
223
+ test.ok(client.secureConnection, 'connection upgraded to TLS');
224
+ test.ok(!client.capabilities.has('PRETLS-ONLY'), 'pre-TLS capabilities were discarded');
225
+ let raw = [].concat(client.rawCapabilities || []);
226
+ test.ok(!raw.some(entry => entry && /PRETLS-ONLY/.test(entry.value || entry)), 'pre-TLS rawCapabilities were discarded');
227
+
228
+ await client.logout();
229
+ client.close();
230
+ server.close();
231
+ test.done();
232
+ };
211
233
 
212
234
  // Records every socket that went through configureSocket(), which is the single place the
213
235
  // transport options (keepalive + inactivity watchdog) are applied.
@@ -227,19 +249,7 @@ module.exports['Secure: STARTTLS session keeps the inactivity watchdog'] = async
227
249
  // and a dead connection was never noticed.
228
250
  let server = createStartTlsServer();
229
251
  let port = await listen(server);
230
- let client = new ImapFlow({
231
- host: '127.0.0.1',
232
- port,
233
- secure: false,
234
- doSTARTTLS: true,
235
- servername: 'localhost',
236
- tls: { rejectUnauthorized: false },
237
- socketTimeout: 200,
238
- disableAutoIdle: true,
239
- disableCompression: true,
240
- logger: false,
241
- auth: { user: 'test', pass: 'secret' }
242
- });
252
+ let client = makeStartTlsClient(port, { socketTimeout: 200 });
243
253
 
244
254
  let errors = [];
245
255
  client.on('error', err => errors.push(err));
@@ -271,25 +281,12 @@ module.exports['Secure: STARTTLS leaves no upgrade state behind on success'] = a
271
281
  // a successful handshake no timer, rejector, flag or temporary handler survives.
272
282
  let server = createStartTlsServer();
273
283
  let port = await listen(server);
274
- let client = new ImapFlow({
275
- host: '127.0.0.1',
276
- port,
277
- secure: false,
278
- doSTARTTLS: true,
279
- servername: 'localhost',
280
- tls: { rejectUnauthorized: false },
281
- disableAutoIdle: true,
282
- disableCompression: true,
283
- logger: false,
284
- auth: { user: 'test', pass: 'secret' }
285
- });
284
+ let client = makeStartTlsClient(port);
286
285
  client.on('error', () => {});
287
286
 
288
287
  await client.connect();
289
288
 
290
- test.equal(client.upgrading, false, 'upgrading flag cleared');
291
- test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
292
- test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
289
+ assertUpgradeSettled(test, client);
293
290
  // The handshake-only handler is gone, leaving the generic socket error handler as the single
294
291
  // error path (during the handshake it is the other way round, which is what prevents a
295
292
  // handshake error from firing two handlers at once).
@@ -314,18 +311,7 @@ module.exports['Secure: close() during a STARTTLS upgrade settles the upgrade']
314
311
  rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
315
312
  });
316
313
  let port = await listen(server);
317
- let client = new ImapFlow({
318
- host: '127.0.0.1',
319
- port,
320
- secure: false,
321
- doSTARTTLS: true,
322
- servername: 'localhost',
323
- tls: { rejectUnauthorized: false },
324
- disableAutoIdle: true,
325
- disableCompression: true,
326
- logger: false,
327
- auth: { user: 'test', pass: 'secret' }
328
- });
314
+ let client = makeStartTlsClient(port);
329
315
  client.on('error', () => {});
330
316
 
331
317
  let connectResult = client.connect().then(
@@ -337,15 +323,46 @@ module.exports['Secure: close() during a STARTTLS upgrade settles the upgrade']
337
323
  while (!client.upgrading) {
338
324
  await new Promise(resolve => setTimeout(resolve, 10));
339
325
  }
326
+ // A late socket event would invoke the same settle helper the rejector exposes -
327
+ // capture it before close() consumes it
328
+ let settle = client._upgradeReject;
340
329
  client.close();
341
330
 
342
331
  let err = await connectResult;
343
332
  test.ok(err, 'connect rejected rather than hanging on the abandoned upgrade');
344
333
  test.ok(['ClosedAfterConnectText', 'ClosedAfterConnectTLS', 'NoConnection'].includes(err.code), `connect rejected with ${err.code}`);
345
- test.equal(client.upgrading, false, 'upgrading flag cleared');
346
- test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
347
- test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
334
+ assertUpgradeSettled(test, client);
335
+
336
+ // The late event lands after the upgrade already settled - it must be a no-op:
337
+ // a settled upgrade neither claims the error as a TLS failure nor re-arms any state
338
+ let lateErr = new Error('late socket error');
339
+ settle(lateErr);
340
+ test.ok(!lateErr.tlsFailed, 'the late error was not marked as a TLS upgrade failure');
341
+ assertUpgradeSettled(test, client);
342
+
343
+ server.close();
344
+ test.done();
345
+ };
348
346
 
347
+ module.exports['Secure: STARTTLS handshake failure rejects connect'] = async test => {
348
+ let server = createStartTlsServer();
349
+ let port = await listen(server);
350
+ // The tls option is erased entirely, not merely relaxed: certificate validation
351
+ // stays on, so the mock server's self-signed certificate must fail the handshake,
352
+ // and the upgrade builds its TLS options from the no-options fallback
353
+ let client = makeStartTlsClient(port, { tls: undefined });
354
+ client.on('error', () => {});
355
+
356
+ let err = await client.connect().then(
357
+ () => null,
358
+ connectErr => connectErr
359
+ );
360
+
361
+ test.ok(err, 'connect rejected on the failed handshake');
362
+ test.ok(err.tlsFailed, 'the error is marked as a TLS upgrade failure');
363
+ assertUpgradeSettled(test, client);
364
+
365
+ client.close();
349
366
  server.close();
350
367
  test.done();
351
368
  };
@@ -1346,3 +1346,129 @@ module.exports['Server: an unparseable tagged completion fails the command inste
1346
1346
  server.close();
1347
1347
  test.done();
1348
1348
  };
1349
+
1350
+ module.exports['Server: a NUL-padded unparseable completion still fails the command'] = async test => {
1351
+ // Buggy servers pad lines with leading NUL bytes; the parser strips them before
1352
+ // reading the tag. The unparsed-completion recovery has to see the same tag the
1353
+ // parser saw, or the command hangs for exactly the server class the NUL
1354
+ // workaround exists for.
1355
+ let server = createServer({
1356
+ handlers: {
1357
+ SELECT(ctx) {
1358
+ ctx.write(`\x00\x00${ctx.tag} OK [\x01BAD-CODE] SELECT completed\r\n`);
1359
+ }
1360
+ }
1361
+ });
1362
+ let port = await listen(server);
1363
+ let client = makeClient(port);
1364
+ client.on('error', () => {});
1365
+
1366
+ await client.connect();
1367
+
1368
+ let selectErr = null;
1369
+ try {
1370
+ await client.mailboxOpen('INBOX');
1371
+ } catch (err) {
1372
+ selectErr = err;
1373
+ }
1374
+ test.ok(selectErr, 'the command whose completion could not be parsed must reject');
1375
+
1376
+ let folders = await client.list();
1377
+ test.ok(Array.isArray(folders) && folders.length, 'later commands still run');
1378
+
1379
+ await client.logout();
1380
+ client.close();
1381
+ server.close();
1382
+ test.done();
1383
+ };
1384
+
1385
+ module.exports['Server: a sequence-shaped token in a server response does not fail the connection'] = async test => {
1386
+ // The incoming token parser accepts sequence-shaped tokens the strict outgoing
1387
+ // grammar rejects ("1:2:3"). Every parsed response is re-compiled for the log, so
1388
+ // that pass must skip the validation - one quirky but parseable server line would
1389
+ // otherwise tear down the whole connection.
1390
+ let server = createServer({
1391
+ handlers: {
1392
+ NOOP(ctx) {
1393
+ ctx.write(`${ctx.tag} OK [XDATA 1:2:3] NOOP completed\r\n`);
1394
+ }
1395
+ }
1396
+ });
1397
+ let port = await listen(server);
1398
+ let client = makeClient(port);
1399
+ client.on('error', () => {});
1400
+
1401
+ await client.connect();
1402
+ await client.noop();
1403
+ test.ok(client.usable, 'connection must stay usable');
1404
+
1405
+ let folders = await client.list();
1406
+ test.ok(Array.isArray(folders) && folders.length, 'later commands still run');
1407
+
1408
+ await client.logout();
1409
+ client.close();
1410
+ server.close();
1411
+ test.done();
1412
+ };
1413
+
1414
+ module.exports['Server: an invalid range rejects the command without wedging the queue'] = async test => {
1415
+ // The compiler refuses invalid sequence sets before anything reaches the wire.
1416
+ // The dispatch layer has to treat that as the command's own failure: the request
1417
+ // must reject (even when queued behind an in-flight command) and the queue must
1418
+ // keep moving instead of waiting forever on a response that can never arrive.
1419
+ let server = createServer();
1420
+ let port = await listen(server);
1421
+ let client = makeClient(port);
1422
+ client.on('error', () => {});
1423
+
1424
+ await client.connect();
1425
+ await client.mailboxOpen('INBOX');
1426
+
1427
+ let err = null;
1428
+ try {
1429
+ await client.fetchOne('1;2', { uid: true });
1430
+ } catch (e) {
1431
+ err = e;
1432
+ }
1433
+ test.ok(err, 'the invalid range must reject');
1434
+ test.equal(err && err.code, 'InvalidSequenceSet');
1435
+
1436
+ // Queued variant: the invalid command sits behind an in-flight one; every promise
1437
+ // must settle and the command behind the invalid one must still run
1438
+ let results = await Promise.allSettled([client.noop(), client.fetchOne('3;4', { uid: true }), client.noop()]);
1439
+ test.equal(results[0].status, 'fulfilled', 'command before the invalid one succeeds');
1440
+ test.equal(results[1].status, 'rejected', 'queued invalid command must reject, not hang');
1441
+ test.equal(results[2].status, 'fulfilled', 'command after the invalid one still runs');
1442
+
1443
+ await client.logout();
1444
+ client.close();
1445
+ server.close();
1446
+ test.done();
1447
+ };
1448
+
1449
+ module.exports['Server: a throwing response listener does not fail the command'] = async test => {
1450
+ // 'response' is emitted after a tagged completion parses successfully. A listener
1451
+ // throwing synchronously used to be caught by the parse-failure path, which
1452
+ // rejected the in-flight command with a bogus ParserError even though the server
1453
+ // had executed it.
1454
+ let server = createServer();
1455
+ let port = await listen(server);
1456
+ let client = makeClient(port);
1457
+ client.on('error', () => {});
1458
+
1459
+ await client.connect();
1460
+ client.on('response', payload => {
1461
+ if (payload.response === 'OK') {
1462
+ throw new Error('listener bug');
1463
+ }
1464
+ });
1465
+
1466
+ await client.noop();
1467
+ let mailbox = await client.mailboxOpen('INBOX');
1468
+ test.ok(mailbox, 'commands succeed despite the throwing listener');
1469
+
1470
+ await client.logout();
1471
+ client.close();
1472
+ server.close();
1473
+ test.done();
1474
+ };
@@ -1447,3 +1447,28 @@ module.exports['IMAP Parser: balanced brackets inside a response code are still
1447
1447
  test.equal(text, 'Flags permitted.');
1448
1448
  test.equal(parsed.attributes[0].section[0].value, 'PERMANENTFLAGS');
1449
1449
  });
1450
+
1451
+ module.exports['IMAP Parser: a parse failure after the tag exposes the parsed tag'] = test =>
1452
+ asyncWrapper(test, async test => {
1453
+ // The connection settles the in-flight command from an unparseable tagged
1454
+ // completion using this tag - re-deriving it from the raw bytes instead would
1455
+ // bypass the leading-NUL workaround and strand the command
1456
+ let err = null;
1457
+ try {
1458
+ await parser(Buffer.from('A5 OK [\x01BAD-CODE] done', 'binary'));
1459
+ } catch (e) {
1460
+ err = e;
1461
+ }
1462
+ test.ok(err, 'the line must fail to parse');
1463
+ test.equal(err && err.parsedTag, 'A5');
1464
+
1465
+ // The NUL-padding workaround is inherited: the exposed tag is the stripped one
1466
+ err = null;
1467
+ try {
1468
+ await parser(Buffer.from('\x00\x00A6 OK [\x01BAD-CODE] done', 'binary'));
1469
+ } catch (e) {
1470
+ err = e;
1471
+ }
1472
+ test.ok(err, 'the padded line must fail to parse');
1473
+ test.equal(err && err.parsedTag, 'A6');
1474
+ });
@@ -475,15 +475,39 @@ module.exports['Literal marker scan stays linear on a long digit run'] = test =>
475
475
  test.done();
476
476
  };
477
477
 
478
- module.exports['Literal marker rejects an oversized digit run without parsing it'] = test => {
478
+ module.exports['Literal marker fails the stream on an oversized digit run'] = test => {
479
+ // An impossible size is still a syntactically valid marker. Treating it as an
480
+ // ordinary line instead would feed the announced literal body to the line parser
481
+ // and desynchronize the session, so the stream must end with LiteralTooLarge.
479
482
  const stream = new ImapStream({ cid: 'test' });
480
- stream.on('error', () => {});
483
+ let streamErr = null;
484
+ stream.on('error', err => {
485
+ streamErr = err;
486
+ });
481
487
  stream.resume();
482
488
 
483
- // Longer than any number64 can be, so it cannot be a valid marker
484
489
  const line = Buffer.concat([Buffer.from('* OK {'), Buffer.from('1'.repeat(40)), Buffer.from('}\r\n')]);
485
490
 
486
491
  test.equal(stream.checkLiteralMarker(line), false, 'an impossible size must not start literal mode');
492
+ test.ok(stream.destroyed, 'the stream must fail closed instead of continuing as if the marker were text');
493
+ setImmediate(() => {
494
+ test.ok(streamErr && streamErr.code === 'LiteralTooLarge', 'must fail with LiteralTooLarge');
495
+ test.done();
496
+ });
497
+ };
498
+
499
+ module.exports['Literal marker accepts a zero-padded size'] = test => {
500
+ // The RFC "number" production is 1*DIGIT, so leading zeros are legal - a long
501
+ // digit run can still denote a small size and must be consumed as a literal
502
+ const stream = new ImapStream({ cid: 'test' });
503
+ stream.on('error', () => {});
504
+ stream.resume();
505
+
506
+ const line = Buffer.from(`* 1 FETCH (BODY[] {${'0'.repeat(21)}123}\r\n`);
507
+
508
+ test.equal(stream.checkLiteralMarker(line), true, 'a zero-padded marker is a valid literal marker');
509
+ test.equal(stream.literalWaiting, 123, 'the padded size must parse to its numeric value');
510
+ stream.destroy();
487
511
  test.done();
488
512
  };
489
513
 
@@ -504,3 +528,139 @@ module.exports['Literal marker still accepts sizes at the digit-length bound'] =
504
528
  test.equal(ok.literalWaiting, 1024);
505
529
  test.done();
506
530
  };
531
+
532
+ module.exports['Response assembly enforces the cumulative size cap'] = test => {
533
+ // The per-line and per-literal caps alone cannot stop a response spread across many
534
+ // tokens: under a 40-byte response budget, a 25-byte marker line plus a declared
535
+ // 10-byte literal fits (35), but the next marker line (16 bytes + 10 declared) must
536
+ // trip the cap - before the second literal's bytes are even read
537
+ const stream = new ImapStream({ cid: 'test', maxResponseSize: 40 });
538
+ let streamErr = null;
539
+ stream.on('error', err => {
540
+ streamErr = err;
541
+ });
542
+ stream.resume();
543
+
544
+ stream.write(Buffer.from('* 1 FETCH (BODY[1] {10}\r\n'));
545
+ stream.write(Buffer.from('0123456789'));
546
+ stream.write(Buffer.from(' BODY[2] {10}\r\n0123456789)\r\n'));
547
+
548
+ setTimeout(() => {
549
+ test.ok(streamErr, 'an oversized cumulative response must fail the stream');
550
+ test.equal(streamErr && streamErr.code, 'ResponseTooLarge');
551
+ test.ok(stream.destroyed, 'the stream must fail closed instead of parsing the rejected payload');
552
+ test.done();
553
+ }, 100);
554
+ };
555
+
556
+ // Resolves once the stream has settled - on its first 'error', or on 'end' when it is
557
+ // consumed to completion. Waiting for the real signal keeps these tests off fixed sleeps.
558
+ const settle = stream =>
559
+ new Promise(resolve => {
560
+ stream.once('error', err => resolve(err));
561
+ stream.once('end', () => resolve(null));
562
+ });
563
+
564
+ module.exports['Response size budget resets between responses'] = async test => {
565
+ // The counter tracks a single response, not the whole session. Each response here fits
566
+ // the 40-byte budget on its own but the two together do not, so a counter that failed to
567
+ // reset would trip the cap on the second one - which is what makes this test detect the
568
+ // regression rather than merely pass alongside it.
569
+ const line = '* OK ' + 'a'.repeat(23) + '\r\n'; // 30 bytes, two of them exceed the budget
570
+ const stream = new ImapStream({ cid: 'test', maxResponseSize: 40 });
571
+ let streamErr = null;
572
+ let count = 0;
573
+ stream.on('error', err => {
574
+ streamErr = err;
575
+ });
576
+ stream.on('data', cmd => {
577
+ count++;
578
+ cmd.next();
579
+ });
580
+
581
+ test.ok(line.length <= 40 && line.length * 2 > 40, 'each response fits the budget, the pair does not');
582
+
583
+ stream.write(Buffer.from(line + line));
584
+ stream.end();
585
+
586
+ await settle(stream);
587
+ test.ifError(streamErr);
588
+ test.equal(count, 2);
589
+ test.done();
590
+ };
591
+
592
+ module.exports['Response size cap defaults above the literal cap'] = test => {
593
+ // The response total also carries the literal marker line and the rest of the framing, so
594
+ // a default equal to the literal cap would make a literal of exactly the maximum permitted
595
+ // size impossible to receive
596
+ const stream = new ImapStream({ cid: 'test' });
597
+ test.equal(stream.maxResponseSize, 2 * 1024 * 1024 * 1024);
598
+ test.ok(stream.maxResponseSize > stream.maxLiteralSize, 'the response cap must leave headroom above the literal cap');
599
+ test.done();
600
+ };
601
+
602
+ module.exports['A literal of exactly maxLiteralSize is accepted when the response cap leaves headroom'] = async test => {
603
+ const stream = new ImapStream({ cid: 'test', maxLiteralSize: 100, maxResponseSize: 200 });
604
+ let streamErr = null;
605
+ let received = null;
606
+ stream.on('error', err => {
607
+ streamErr = err;
608
+ });
609
+ stream.on('data', cmd => {
610
+ received = cmd;
611
+ cmd.next();
612
+ });
613
+
614
+ stream.write(Buffer.from('* 1 FETCH (BODY[] {100}\r\n'));
615
+ stream.write(Buffer.from('x'.repeat(100)));
616
+ stream.write(Buffer.from(')\r\n'));
617
+ stream.end();
618
+
619
+ await settle(stream);
620
+ test.ifError(streamErr);
621
+ test.ok(received, 'a literal at exactly the configured maximum must be delivered');
622
+ test.equal(received.literals.length, 1);
623
+ test.equal(received.literals[0].length, 100);
624
+ test.done();
625
+ };
626
+
627
+ module.exports['An unterminated line is bounded by the response budget'] = async test => {
628
+ // maxResponseSize is only committed when a line completes, so an in-progress line has to
629
+ // be measured against the remaining budget separately - otherwise a response cap lowered
630
+ // to bound parser memory buys nothing while a server streams a line that never ends
631
+ const stream = new ImapStream({ cid: 'test', maxResponseSize: 64 });
632
+ stream.resume();
633
+
634
+ stream.write(Buffer.from('x'.repeat(1024))); // no line terminator anywhere
635
+
636
+ let err = await settle(stream);
637
+ test.ok(err, 'an unterminated line beyond the response budget must fail the stream');
638
+ test.equal(err.code, 'ResponseTooLarge');
639
+ test.ok(stream.lineBytes <= 64, 'no more than the budget may stay buffered');
640
+ test.done();
641
+ };
642
+
643
+ module.exports['Infinity disables a parser size cap'] = test => {
644
+ // A cap that cannot be disabled forces a caller who knows their server onto the default
645
+ const stream = new ImapStream({ cid: 'test', maxResponseSize: Infinity, maxLiteralSize: Infinity, maxLineLength: Infinity });
646
+ test.equal(stream.maxResponseSize, Infinity);
647
+ test.equal(stream.maxLiteralSize, Infinity);
648
+ test.equal(stream.maxLineLength, Infinity);
649
+ test.done();
650
+ };
651
+
652
+ module.exports['A marker line that fits the budget can still be refused for its literal'] = async test => {
653
+ // The line is measured against the budget as it is assembled, but the declared literal is
654
+ // only charged once the marker line completes - so the cap has to be enforced in both places
655
+ const stream = new ImapStream({ cid: 'test', maxResponseSize: 40 });
656
+ stream.resume();
657
+
658
+ // 25-byte marker line fits on its own; the 30 declared literal bytes push the total past 40
659
+ stream.write(Buffer.from('* 1 FETCH (BODY[1] {30}\r\n'));
660
+
661
+ let err = await settle(stream);
662
+ test.ok(err, 'the declared literal must be charged before its bytes arrive');
663
+ test.equal(err.code, 'ResponseTooLarge');
664
+ test.equal(err.responseSize, 55);
665
+ test.done();
666
+ };