imapflow 1.6.5 → 1.7.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +20 -0
- package/lib/commands/append.js +26 -4
- package/lib/commands/copyuid-parser.js +4 -2
- package/lib/commands/expunge.js +5 -2
- package/lib/commands/fetch.js +9 -3
- package/lib/commands/idle.js +26 -5
- package/lib/commands/list.js +18 -38
- package/lib/commands/namespace.js +2 -2
- package/lib/commands/quota.js +10 -2
- package/lib/commands/search.js +54 -14
- package/lib/commands/select.js +81 -70
- package/lib/commands/status-fields.js +68 -0
- package/lib/commands/status.js +23 -61
- package/lib/handler/imap-stream.js +56 -2
- package/lib/handler/limits.js +16 -4
- package/lib/imap-flow.d.ts +33 -2
- package/lib/imap-flow.js +307 -97
- package/lib/jp-decoder.js +30 -5
- package/lib/limited-passthrough.js +19 -1
- package/lib/tools.js +190 -39
- package/package.json +4 -4
- package/test/auto-idle-test.js +470 -0
- package/test/commands-branches-test.js +4 -0
- package/test/commands-integration-test.js +683 -0
- package/test/connection-edge-cases-test.js +3 -1
- package/test/copyuid-parser-test.js +20 -0
- package/test/fixtures/test-client.js +57 -0
- package/test/idle-polling-test.js +88 -0
- package/test/imap-flow-coverage-test.js +8 -12
- package/test/imap-flow-fetch-download-test.js +29 -10
- package/test/imap-flow-internals-test.js +14 -32
- package/test/imap-flow-methods-test.js +92 -0
- package/test/imap-stream-edge-cases-test.js +136 -0
- package/test/jp-decoder-test.js +57 -0
- package/test/limited-passthrough-test.js +24 -0
- package/test/parser-limits-test.js +18 -0
- package/test/reliability-improvements-test.js +3 -3
- package/test/timer-policy-test.js +31 -18
- package/test/tools-test.js +151 -2
|
@@ -254,7 +254,9 @@ module.exports['Connection Edge: Socket timeout during IDLE'] = test => {
|
|
|
254
254
|
// Give async operations time to complete
|
|
255
255
|
setTimeout(() => {
|
|
256
256
|
test.ok(noopCalled, 'NOOP should be called to recover from IDLE timeout');
|
|
257
|
-
|
|
257
|
+
// Returning to IDLE is autoidle()'s decision once the NOOP settles (run() re-arms it);
|
|
258
|
+
// the watchdog handler itself must not bypass the busy guard by calling idle() directly
|
|
259
|
+
test.equal(idleCalled, false, 'the timeout handler does not restart IDLE by itself');
|
|
258
260
|
test.done();
|
|
259
261
|
}, 100);
|
|
260
262
|
};
|
|
@@ -151,3 +151,23 @@ module.exports['CopyUID Parser: uidValidity zero is stored as BigInt(0)'] = test
|
|
|
151
151
|
test.equal(map.uidMap.get(1), 100);
|
|
152
152
|
test.done();
|
|
153
153
|
};
|
|
154
|
+
|
|
155
|
+
// ============================================
|
|
156
|
+
// Hostile server input
|
|
157
|
+
// ============================================
|
|
158
|
+
|
|
159
|
+
module.exports['CopyUID Parser: non-decimal uidValidity is ignored, uidMap still parsed'] = test => {
|
|
160
|
+
// isNaN() passed values like "1e5" and "Infinity" through, and BigInt() then threw,
|
|
161
|
+
// losing the whole COPYUID result
|
|
162
|
+
let map = {};
|
|
163
|
+
parseCopyUid(makeResponse([{ value: 'COPYUID' }, { value: '1e5' }, { value: '1' }, { value: '100' }]), map);
|
|
164
|
+
test.equal(map.uidValidity, undefined);
|
|
165
|
+
test.ok(map.uidMap instanceof Map);
|
|
166
|
+
test.equal(map.uidMap.get(1), 100);
|
|
167
|
+
|
|
168
|
+
let map2 = {};
|
|
169
|
+
parseCopyUid(makeResponse([{ value: 'COPYUID' }, { value: 'Infinity' }, { value: '1' }, { value: '100' }]), map2);
|
|
170
|
+
test.equal(map2.uidValidity, undefined);
|
|
171
|
+
test.equal(map2.uidMap.get(1), 100);
|
|
172
|
+
test.done();
|
|
173
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared ImapFlow construction for unit tests that poke at internals without a live server.
|
|
4
|
+
// One definition instead of a per-file copy, so a change to what the constructor needs lands
|
|
5
|
+
// in one place and cannot drift between suites.
|
|
6
|
+
|
|
7
|
+
const { ImapFlow } = require('../../lib/imap-flow');
|
|
8
|
+
|
|
9
|
+
// A socket stub complete enough for setSocketHandlers(), clearSocketHandlers() and close() to
|
|
10
|
+
// run against. removeListener/removeAllListeners matter most: close() removes the handlers it
|
|
11
|
+
// installed, and a stub without them makes close() throw half-way through teardown with the
|
|
12
|
+
// client still holding its socket and streamer references.
|
|
13
|
+
const makeSocketStub = () => ({
|
|
14
|
+
destroyed: false,
|
|
15
|
+
destroy: () => {},
|
|
16
|
+
on: () => {},
|
|
17
|
+
once: () => {},
|
|
18
|
+
end: () => {},
|
|
19
|
+
unpipe: () => {},
|
|
20
|
+
removeListener: () => {},
|
|
21
|
+
removeAllListeners: () => {},
|
|
22
|
+
setKeepAlive: () => {},
|
|
23
|
+
setTimeout: () => {}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Bare construction: no socket, no state overrides. Tests that need a transport use
|
|
27
|
+
// makeSocketStub() or makeIdleReadyClient() instead of reaching for their own object literals.
|
|
28
|
+
const makeClient = (overrides = {}) =>
|
|
29
|
+
new ImapFlow({
|
|
30
|
+
host: '127.0.0.1',
|
|
31
|
+
port: 993,
|
|
32
|
+
logger: false,
|
|
33
|
+
auth: { user: 'test', pass: 'secret' },
|
|
34
|
+
...overrides
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// A client parked in SELECTED with a stubbed socket and a no-op idle(), ready for autoidle(),
|
|
38
|
+
// lock handling and watchdog behavior to be exercised directly
|
|
39
|
+
const makeIdleReadyClient = (overrides = {}) => {
|
|
40
|
+
let client = makeClient({ maxLockHoldTime: 0, ...overrides });
|
|
41
|
+
client.socket = makeSocketStub();
|
|
42
|
+
client.usable = true;
|
|
43
|
+
client.state = client.states.SELECTED;
|
|
44
|
+
client.mailbox = { path: 'INBOX', readOnly: false };
|
|
45
|
+
client.idle = async () => {};
|
|
46
|
+
return client;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// A fetchOne stand-in that serves `body` back in download()'s chunk-query shape
|
|
50
|
+
// ({source: {start, maxLength}}), so download tests do not each restate that contract
|
|
51
|
+
const chunkedFetchOne = body => async (range, query) => {
|
|
52
|
+
let start = query.source.start;
|
|
53
|
+
let maxLength = query.source.maxLength;
|
|
54
|
+
return { uid: 1, size: body.length, source: body.subarray(start, start + maxLength) };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
module.exports = { makeClient, makeIdleReadyClient, makeSocketStub, chunkedFetchOne };
|
|
@@ -428,3 +428,91 @@ module.exports['Polling: a break in the initiation tick prevents the first poll'
|
|
|
428
428
|
test.done();
|
|
429
429
|
});
|
|
430
430
|
};
|
|
431
|
+
|
|
432
|
+
module.exports['Polling: a restarted session resumes the schedule instead of polling again'] = async test => {
|
|
433
|
+
await withFakeTimers(async timers => {
|
|
434
|
+
let connection = createConnection();
|
|
435
|
+
|
|
436
|
+
let first = idleCommand(connection, 60000);
|
|
437
|
+
await timers.drain();
|
|
438
|
+
test.deepEqual(connection.commands, ['NOOP'], 'the first session polls immediately');
|
|
439
|
+
|
|
440
|
+
await connection.preCheck();
|
|
441
|
+
await first;
|
|
442
|
+
|
|
443
|
+
// Auto-IDLE restarts the loop after every caller command. An unconditional first poll here
|
|
444
|
+
// would tie the poll rate to how often the caller runs commands instead of to the poll
|
|
445
|
+
// interval - with a short autoIdleDelay, a command every few seconds means a poll every
|
|
446
|
+
// few seconds.
|
|
447
|
+
let second = idleCommand(connection, 60000);
|
|
448
|
+
await timers.drain();
|
|
449
|
+
test.deepEqual(connection.commands, ['NOOP'], 'the restarted session does not poll again');
|
|
450
|
+
test.equal(timers.count(), 1, 'it waits out the remainder of the interval instead');
|
|
451
|
+
test.ok(timers.pending()[0].delay <= 60000, 'and never longer than a full interval');
|
|
452
|
+
|
|
453
|
+
await connection.preCheck();
|
|
454
|
+
await second;
|
|
455
|
+
|
|
456
|
+
// Once a full interval has elapsed, a fresh session polls at once again
|
|
457
|
+
connection._lastPollAt = Date.now() - 61000;
|
|
458
|
+
let third = idleCommand(connection, 60000);
|
|
459
|
+
await timers.drain();
|
|
460
|
+
test.deepEqual(connection.commands, ['NOOP', 'NOOP'], 'a session starting after the interval polls right away');
|
|
461
|
+
|
|
462
|
+
await connection.preCheck();
|
|
463
|
+
await third;
|
|
464
|
+
test.done();
|
|
465
|
+
});
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
module.exports['Polling: a failed poll does not defer the next session'] = async test => {
|
|
469
|
+
await withFakeTimers(async timers => {
|
|
470
|
+
let failNext = true;
|
|
471
|
+
let connection = createConnection({
|
|
472
|
+
exec: async command => {
|
|
473
|
+
connection.commands.push(command);
|
|
474
|
+
if (failNext) {
|
|
475
|
+
failNext = false;
|
|
476
|
+
throw new Error('poll failed');
|
|
477
|
+
}
|
|
478
|
+
return { next: () => {} };
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// The failing poll cancels its own session. The attempt checked nothing, so it must not
|
|
483
|
+
// count as a poll: only a completed poll moves the schedule stamp forward.
|
|
484
|
+
let first = idleCommand(connection, 60000);
|
|
485
|
+
await timers.drain();
|
|
486
|
+
await first;
|
|
487
|
+
test.deepEqual(connection.commands, ['NOOP'], 'the first poll ran and failed');
|
|
488
|
+
|
|
489
|
+
let second = idleCommand(connection, 60000);
|
|
490
|
+
await timers.drain();
|
|
491
|
+
test.deepEqual(connection.commands, ['NOOP', 'NOOP'], 'the next session retries immediately instead of waiting out an interval');
|
|
492
|
+
|
|
493
|
+
await connection.preCheck();
|
|
494
|
+
await second;
|
|
495
|
+
test.done();
|
|
496
|
+
});
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
module.exports['Polling: a backward clock step never defers the next poll past one interval'] = async test => {
|
|
500
|
+
await withFakeTimers(async timers => {
|
|
501
|
+
let connection = createConnection();
|
|
502
|
+
|
|
503
|
+
// A last-poll stamp in the future is what an NTP step or a VM clock sync leaves behind.
|
|
504
|
+
// Without the clamp the remainder math would schedule the next poll a full clock jump
|
|
505
|
+
// plus one interval away.
|
|
506
|
+
connection._lastPollAt = Date.now() + 60 * 60 * 1000;
|
|
507
|
+
|
|
508
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
509
|
+
await timers.drain();
|
|
510
|
+
test.deepEqual(connection.commands, [], 'no immediate poll - the schedule is resumed');
|
|
511
|
+
test.equal(timers.count(), 1, 'a poll timer is armed');
|
|
512
|
+
test.ok(timers.pending()[0].delay <= 60000, 'and it is never more than one interval away');
|
|
513
|
+
|
|
514
|
+
await connection.preCheck();
|
|
515
|
+
await idlePromise;
|
|
516
|
+
test.done();
|
|
517
|
+
});
|
|
518
|
+
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// reader/handler error branches.
|
|
6
6
|
|
|
7
7
|
const { ImapFlow } = require('../lib/imap-flow');
|
|
8
|
+
const { makeSocketStub } = require('./fixtures/test-client');
|
|
8
9
|
|
|
9
10
|
const makeClient = (overrides = {}) => {
|
|
10
11
|
let client = new ImapFlow({
|
|
@@ -293,15 +294,6 @@ module.exports['Coverage: authenticate throws when run yields falsy auth result'
|
|
|
293
294
|
// Socket event handlers (built by setSocketHandlers)
|
|
294
295
|
// ============================================================================
|
|
295
296
|
|
|
296
|
-
// Minimal socket stub that records listeners so handlers can be invoked directly.
|
|
297
|
-
const makeSocketStub = () => ({
|
|
298
|
-
destroyed: false,
|
|
299
|
-
once() {},
|
|
300
|
-
on() {},
|
|
301
|
-
removeListener() {},
|
|
302
|
-
destroy() {}
|
|
303
|
-
});
|
|
304
|
-
|
|
305
297
|
module.exports['Coverage: setSocketHandlers removes a lingering connect error handler'] = test => {
|
|
306
298
|
let client = makeClient();
|
|
307
299
|
let removed = null;
|
|
@@ -389,7 +381,9 @@ module.exports['Coverage: _socketTimeout recovers an IDLE connection with NOOP']
|
|
|
389
381
|
await drain();
|
|
390
382
|
await drain();
|
|
391
383
|
test.ok(noopRun, 'NOOP issued to recover IDLE');
|
|
392
|
-
|
|
384
|
+
// Restarting IDLE is autoidle()'s decision once the NOOP settles (run() re-arms it);
|
|
385
|
+
// the watchdog handler itself must not bypass the busy guard by calling idle() directly.
|
|
386
|
+
test.equal(idleResumed, false, 'the handler does not restart IDLE by itself');
|
|
393
387
|
test.done();
|
|
394
388
|
};
|
|
395
389
|
|
|
@@ -591,9 +585,11 @@ module.exports['Coverage: reader caps very large throttle backoff'] = async test
|
|
|
591
585
|
let readerPromise = runReaderWith(client, ['A1 BAD Request is throttled. Suggested Backoff Time: 999999999 milliseconds']);
|
|
592
586
|
// give the reader a tick to register the throttle timer, then abort it
|
|
593
587
|
await drain();
|
|
594
|
-
|
|
595
|
-
|
|
588
|
+
for (let entry of client._throttleWaits) {
|
|
589
|
+
clearTimeout(entry.timer);
|
|
590
|
+
entry.resolve(true);
|
|
596
591
|
}
|
|
592
|
+
client._throttleWaits.clear();
|
|
597
593
|
await readerPromise;
|
|
598
594
|
test.ok(rejected);
|
|
599
595
|
test.done();
|
|
@@ -9,6 +9,7 @@ const libbase64 = require('libbase64');
|
|
|
9
9
|
const libqp = require('libqp');
|
|
10
10
|
const libmime = require('libmime');
|
|
11
11
|
const { Writable, finished } = require('stream');
|
|
12
|
+
const { chunkedFetchOne } = require('./fixtures/test-client');
|
|
12
13
|
|
|
13
14
|
const makeClient = (overrides = {}) => {
|
|
14
15
|
let client = new ImapFlow({
|
|
@@ -217,11 +218,7 @@ module.exports['Download: returns empty object without mailbox'] = async test =>
|
|
|
217
218
|
module.exports['Download: full message in multiple chunks'] = async test => {
|
|
218
219
|
let client = makeClient();
|
|
219
220
|
let body = Buffer.from('A'.repeat(10));
|
|
220
|
-
client.fetchOne =
|
|
221
|
-
let start = query.source.start;
|
|
222
|
-
let maxLength = query.source.maxLength;
|
|
223
|
-
return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) };
|
|
224
|
-
};
|
|
221
|
+
client.fetchOne = chunkedFetchOne(body);
|
|
225
222
|
let { meta, content } = await client.download('1', false, { chunkSize: 4 });
|
|
226
223
|
test.equal(meta.contentType, 'message/rfc822');
|
|
227
224
|
test.equal(meta.expectedSize, 10);
|
|
@@ -233,11 +230,7 @@ module.exports['Download: full message in multiple chunks'] = async test => {
|
|
|
233
230
|
module.exports['Download: falls back to default chunkSize/maxBytes when zero'] = async test => {
|
|
234
231
|
let client = makeClient();
|
|
235
232
|
let body = Buffer.from('tiny');
|
|
236
|
-
client.fetchOne =
|
|
237
|
-
let start = query.source.start;
|
|
238
|
-
let maxLength = query.source.maxLength;
|
|
239
|
-
return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) };
|
|
240
|
-
};
|
|
233
|
+
client.fetchOne = chunkedFetchOne(body);
|
|
241
234
|
// zero values are falsy -> the (|| default) fallbacks kick in
|
|
242
235
|
let { content } = await client.download('1', false, { chunkSize: 0, maxBytes: 0 });
|
|
243
236
|
let data = await collect(content);
|
|
@@ -852,3 +845,29 @@ module.exports['DownloadMany: decodes base64 and quoted-printable parts'] = asyn
|
|
|
852
845
|
test.equal(res['3'].meta.disposition, 'attachment');
|
|
853
846
|
test.done();
|
|
854
847
|
};
|
|
848
|
+
|
|
849
|
+
module.exports['Download: a buffering transform cannot defeat maxBytes'] = async test => {
|
|
850
|
+
// format=flowed unwrapping and the Japanese charset decoder both hold their whole input
|
|
851
|
+
// before emitting anything, so the limiter at the tail of the pipeline reports
|
|
852
|
+
// `limited === false` however much the server sends. Without a bound of their own the fetch
|
|
853
|
+
// loop would keep pulling the entire part.
|
|
854
|
+
for (let contentType of ['text/plain; format=flowed', 'text/plain; charset=iso-2022-jp']) {
|
|
855
|
+
let client = makeClient();
|
|
856
|
+
let mime = Buffer.from(`Content-Type: ${contentType}\r\nContent-Disposition: inline\r\n\r\n`);
|
|
857
|
+
let calls = 0;
|
|
858
|
+
client.fetchOne = async () => {
|
|
859
|
+
calls++;
|
|
860
|
+
let bodyParts = new Map();
|
|
861
|
+
bodyParts.set('2.mime', mime);
|
|
862
|
+
// full-size chunks keep hasMore true; the loop must stop on its own, not run dry
|
|
863
|
+
bodyParts.set('2', calls <= 40 ? Buffer.alloc(1024, 0x61) : Buffer.alloc(1));
|
|
864
|
+
return { uid: 1, size: 1024 * 1024, bodyParts };
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
let { content } = await client.download('1', '2', { chunkSize: 1024, maxBytes: 512 });
|
|
868
|
+
let data = await collect(content);
|
|
869
|
+
test.ok(data.length <= 512, `${contentType}: delivered ${data.length} bytes, expected no more than 512`);
|
|
870
|
+
test.ok(calls <= 3, `${contentType}: the fetch loop must stop at the bound, made ${calls} calls`);
|
|
871
|
+
}
|
|
872
|
+
test.done();
|
|
873
|
+
};
|
|
@@ -6,17 +6,8 @@
|
|
|
6
6
|
// and the untaggedFetch flag/modseq branches.
|
|
7
7
|
|
|
8
8
|
const { ImapFlow } = require('../lib/imap-flow');
|
|
9
|
-
|
|
10
|
-
const makeClient
|
|
11
|
-
let client = new ImapFlow({
|
|
12
|
-
host: 'imap.example.com',
|
|
13
|
-
port: 993,
|
|
14
|
-
auth: { user: 'test', pass: 'test' },
|
|
15
|
-
logger: false,
|
|
16
|
-
...overrides
|
|
17
|
-
});
|
|
18
|
-
return client;
|
|
19
|
-
};
|
|
9
|
+
const { withFakeTimers } = require('./fixtures/fake-timers');
|
|
10
|
+
const { makeClient, makeIdleReadyClient } = require('./fixtures/test-client');
|
|
20
11
|
|
|
21
12
|
// ============================================================================
|
|
22
13
|
// emitError
|
|
@@ -275,29 +266,20 @@ module.exports['Internals: autoidle does nothing when not selected'] = test => {
|
|
|
275
266
|
test.done();
|
|
276
267
|
};
|
|
277
268
|
|
|
278
|
-
module.exports['Internals: autoidle schedules idle when selected'] = test => {
|
|
279
|
-
|
|
280
|
-
|
|
269
|
+
module.exports['Internals: autoidle schedules idle when selected'] = async test => {
|
|
270
|
+
await withFakeTimers(async timers => {
|
|
271
|
+
let client = makeIdleReadyClient();
|
|
272
|
+
|
|
273
|
+
let idleCalled = false;
|
|
274
|
+
client.idle = async () => {
|
|
275
|
+
idleCalled = true;
|
|
276
|
+
};
|
|
281
277
|
|
|
282
|
-
let realSetTimeout = global.setTimeout;
|
|
283
|
-
let idleCalled = false;
|
|
284
|
-
client.idle = async () => {
|
|
285
|
-
idleCalled = true;
|
|
286
|
-
};
|
|
287
|
-
// Intercept the 15s idle timer and fire it synchronously
|
|
288
|
-
global.setTimeout = (fn, ms) => {
|
|
289
|
-
if (ms === 15 * 1000) {
|
|
290
|
-
fn();
|
|
291
|
-
return { unref() {} };
|
|
292
|
-
}
|
|
293
|
-
return realSetTimeout(fn, ms);
|
|
294
|
-
};
|
|
295
|
-
try {
|
|
296
278
|
client.autoidle();
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
279
|
+
await timers.fire();
|
|
280
|
+
|
|
281
|
+
test.ok(idleCalled);
|
|
282
|
+
});
|
|
301
283
|
test.done();
|
|
302
284
|
};
|
|
303
285
|
|
|
@@ -795,3 +795,95 @@ module.exports['Methods: autoEnable does not retry when disableIMAP4rev2 already
|
|
|
795
795
|
test.equal(calls.length, 1);
|
|
796
796
|
test.done();
|
|
797
797
|
};
|
|
798
|
+
|
|
799
|
+
module.exports['Methods: untaggedExpunge refuses an unusable sequence number'] = async test => {
|
|
800
|
+
// Same bound untaggedExists() applies: an overflowing or non-decimal value must not
|
|
801
|
+
// decrement the live message count
|
|
802
|
+
let client = makeClient();
|
|
803
|
+
client.mailbox.exists = 10;
|
|
804
|
+
let events = [];
|
|
805
|
+
client.on('expunge', e => events.push(e));
|
|
806
|
+
|
|
807
|
+
await client.untaggedExpunge(false);
|
|
808
|
+
for (let bad of ['9'.repeat(400), '1e5', '0', 'abc', '']) {
|
|
809
|
+
await client.untaggedExpunge({ command: bad });
|
|
810
|
+
}
|
|
811
|
+
test.equal(client.mailbox.exists, 10, 'the live message count must be left alone');
|
|
812
|
+
test.equal(events.length, 0);
|
|
813
|
+
|
|
814
|
+
await client.untaggedExpunge({ command: '3' });
|
|
815
|
+
test.equal(client.mailbox.exists, 9, 'a usable sequence number is still applied');
|
|
816
|
+
test.equal(events.length, 1);
|
|
817
|
+
test.equal(events[0].seq, 3);
|
|
818
|
+
test.done();
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
module.exports['Methods: untaggedVanished survives a malformed response'] = async test => {
|
|
822
|
+
// "* VANISHED (EARLIER)" leaves no sequence set at all. Throwing here would abort the
|
|
823
|
+
// handler for the whole response, and handleResponse only warns - so every expunge the
|
|
824
|
+
// response did carry would be dropped and the client's mailbox view would go stale.
|
|
825
|
+
let client = makeClient();
|
|
826
|
+
let events = [];
|
|
827
|
+
client.on('expunge', e => events.push(e));
|
|
828
|
+
|
|
829
|
+
await client.untaggedVanished({ attributes: [[{ value: 'EARLIER' }]] });
|
|
830
|
+
await client.untaggedVanished({ attributes: [] });
|
|
831
|
+
await client.untaggedVanished({});
|
|
832
|
+
await client.untaggedVanished({ attributes: [[{ value: 'EARLIER' }], null] });
|
|
833
|
+
|
|
834
|
+
test.equal(events.length, 0, 'nothing to report, and nothing thrown');
|
|
835
|
+
|
|
836
|
+
// a well-formed response still works after the malformed ones
|
|
837
|
+
await client.untaggedVanished({ attributes: [[{ value: 'EARLIER' }], { value: '5:6' }] });
|
|
838
|
+
test.equal(events.length, 2);
|
|
839
|
+
test.equal(events[0].uid, 5);
|
|
840
|
+
test.equal(events[0].earlier, true);
|
|
841
|
+
test.done();
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
module.exports['Methods: untaggedExists refuses an overflowing count'] = async test => {
|
|
845
|
+
// Number('9'.repeat(400)) is Infinity: resolveRange('*') would then compile the literal
|
|
846
|
+
// string "Infinity" and every range-based command would fail until the next SELECT
|
|
847
|
+
let client = makeClient();
|
|
848
|
+
client.mailbox = { path: 'INBOX', exists: 5 };
|
|
849
|
+
let events = [];
|
|
850
|
+
client.on('exists', e => events.push(e));
|
|
851
|
+
|
|
852
|
+
await client.untaggedExists(false);
|
|
853
|
+
await client.untaggedExists({});
|
|
854
|
+
for (let bad of ['9'.repeat(400), '1e5', '-1', 'abc', '1.5', '']) {
|
|
855
|
+
await client.untaggedExists({ command: bad });
|
|
856
|
+
}
|
|
857
|
+
test.equal(client.mailbox.exists, 5, 'the live message count must be left alone');
|
|
858
|
+
test.equal(events.length, 0);
|
|
859
|
+
|
|
860
|
+
await client.untaggedExists({ command: '7' });
|
|
861
|
+
test.equal(client.mailbox.exists, 7, 'a usable count is still applied');
|
|
862
|
+
test.equal(events.length, 1);
|
|
863
|
+
test.done();
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
module.exports['Methods: throttleWait caps the delay and close() aborts it'] = async test => {
|
|
867
|
+
let client = makeClient();
|
|
868
|
+
|
|
869
|
+
// A server hint is unbounded; the wait may never outlive the client either
|
|
870
|
+
let started = Date.now();
|
|
871
|
+
let pending = client.throttleWait(7 * 24 * 3600 * 1000);
|
|
872
|
+
test.equal(client._throttleWaits.size, 1, 'the wait is tracked so close() can reach it');
|
|
873
|
+
|
|
874
|
+
client.close();
|
|
875
|
+
test.equal(await pending, true, 'an aborted wait reports true');
|
|
876
|
+
test.equal(client._throttleWaits.size, 0, 'the tracked wait is released');
|
|
877
|
+
test.ok(Date.now() - started < 5000, 'close() must not wait out the delay');
|
|
878
|
+
|
|
879
|
+
// normal expiry reports false and releases the entry. The back-off timer is deliberately
|
|
880
|
+
// unref'd, so the test has to hold the event loop open itself - in a real session the
|
|
881
|
+
// socket does that.
|
|
882
|
+
let client2 = makeClient();
|
|
883
|
+
let keepAlive = setTimeout(() => {}, 1000);
|
|
884
|
+
test.equal(await client2.throttleWait(1), false);
|
|
885
|
+
clearTimeout(keepAlive);
|
|
886
|
+
test.equal(client2._throttleWaits.size, 0);
|
|
887
|
+
client2.close();
|
|
888
|
+
test.done();
|
|
889
|
+
};
|
|
@@ -528,3 +528,139 @@ module.exports['Literal marker still accepts sizes at the digit-length bound'] =
|
|
|
528
528
|
test.equal(ok.literalWaiting, 1024);
|
|
529
529
|
test.done();
|
|
530
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
|
+
};
|
package/test/jp-decoder-test.js
CHANGED
|
@@ -245,3 +245,60 @@ module.exports['JPDecoder: handles EUC-JP charset'] = async test => {
|
|
|
245
245
|
test.ok(result.length > 0);
|
|
246
246
|
test.done();
|
|
247
247
|
};
|
|
248
|
+
|
|
249
|
+
// ============================================
|
|
250
|
+
// maxBytes buffering bound
|
|
251
|
+
// ============================================
|
|
252
|
+
|
|
253
|
+
module.exports['JPDecoder: buffers at most maxBytes'] = test => {
|
|
254
|
+
// The whole-input buffering must honor the caller's download limit: without the
|
|
255
|
+
// bound a server could defeat maxBytes just by labelling a part with a Japanese
|
|
256
|
+
// charset, since nothing is emitted (and limited) until _flush
|
|
257
|
+
let decoder = new JPDecoder('iso-2022-jp', 8);
|
|
258
|
+
decoder.resume();
|
|
259
|
+
|
|
260
|
+
decoder.write(Buffer.from('hello '));
|
|
261
|
+
decoder.write(Buffer.from('world'));
|
|
262
|
+
|
|
263
|
+
test.equal(decoder.chunklen, 8);
|
|
264
|
+
test.equal(Buffer.concat(decoder.chunks).toString(), 'hello wo');
|
|
265
|
+
decoder.end();
|
|
266
|
+
test.done();
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
module.exports['JPDecoder: a fractional maxBytes still bounds the decoder'] = test => {
|
|
270
|
+
// A computed limit (a percentage of a budget, say) is not an integer, and silently turning
|
|
271
|
+
// that into "no limit at all" is exactly the exposure the bound exists to close
|
|
272
|
+
const decoder = new JPDecoder('sjis', 512.5);
|
|
273
|
+
test.equal(decoder.maxBytes, 512, 'a fractional bound is floored to a whole byte count');
|
|
274
|
+
decoder.resume();
|
|
275
|
+
decoder.write(Buffer.alloc(1024, 0x61));
|
|
276
|
+
test.equal(decoder.chunklen, 512, 'nothing beyond the bound is buffered');
|
|
277
|
+
test.equal(decoder.limited, true, 'the decoder reports that it is full');
|
|
278
|
+
decoder.end();
|
|
279
|
+
test.done();
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
module.exports['JPDecoder: a non-numeric maxBytes leaves the decoder unbounded'] = test => {
|
|
283
|
+
test.equal(new JPDecoder('sjis').maxBytes, Infinity);
|
|
284
|
+
test.equal(new JPDecoder('sjis', Infinity).maxBytes, Infinity);
|
|
285
|
+
test.equal(new JPDecoder('sjis', 'lots').maxBytes, Infinity);
|
|
286
|
+
test.equal(new JPDecoder('sjis', -1).maxBytes, Infinity);
|
|
287
|
+
test.equal(new JPDecoder('sjis').limited, false);
|
|
288
|
+
test.done();
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
module.exports['JPDecoder: reports limited once the bound is reached'] = test => {
|
|
292
|
+
// The decoder emits nothing until _flush, so a limiter downstream cannot tell that it is
|
|
293
|
+
// full - the download loop reads this flag instead to stop pulling from the server
|
|
294
|
+
const decoder = new JPDecoder('sjis', 10);
|
|
295
|
+
decoder.resume();
|
|
296
|
+
test.equal(decoder.limited, false);
|
|
297
|
+
decoder.write(Buffer.alloc(4, 0x61));
|
|
298
|
+
test.equal(decoder.limited, false, 'not full yet');
|
|
299
|
+
decoder.write(Buffer.alloc(6, 0x61));
|
|
300
|
+
test.equal(decoder.limited, true);
|
|
301
|
+
test.equal(decoder.chunklen, 10);
|
|
302
|
+
decoder.end();
|
|
303
|
+
test.done();
|
|
304
|
+
};
|