imapflow 1.6.4 → 1.6.5
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 +7 -0
- package/lib/commands/compress.js +29 -18
- package/lib/handler/imap-compiler.js +91 -60
- package/lib/handler/imap-parser.js +7 -0
- package/lib/handler/imap-stream.js +22 -10
- package/lib/imap-flow.js +78 -33
- package/lib/search-compiler.js +24 -16
- package/package.json +1 -1
- package/test/commands-integration-test.js +97 -5
- package/test/idle-polling-test.js +81 -0
- package/test/imap-compiler-test.js +74 -4
- package/test/imap-flow-internals-test.js +134 -0
- package/test/imap-flow-secure-test.js +133 -116
- package/test/imap-flow-server-test.js +126 -0
- package/test/imap-parser-test.js +25 -0
- package/test/imap-stream-edge-cases-test.js +27 -3
- package/test/integration/rev2-live-test.js +30 -0
- package/test/search-compiler-test.js +90 -3
package/lib/search-compiler.js
CHANGED
|
@@ -30,6 +30,17 @@ let setBoolOpt = (attributes, term, value) => {
|
|
|
30
30
|
attributes.push({ type: 'ATOM', value: term.toUpperCase() });
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Normalizes a user-supplied sequence set (string, number, bigint, or an array of
|
|
35
|
+
* them) into the single string value of a SEQUENCE token. An array is one
|
|
36
|
+
* comma-joined set: separate tokens would be parsed by the server as extra
|
|
37
|
+
* sequence-number search keys ANDed to the query, not as part of the set.
|
|
38
|
+
*
|
|
39
|
+
* @param {*} value - The sequence set value(s)
|
|
40
|
+
* @returns {string} The joined sequence set string
|
|
41
|
+
*/
|
|
42
|
+
let toSequenceValue = value => [].concat(value).join(',');
|
|
43
|
+
|
|
33
44
|
/**
|
|
34
45
|
* Adds a search option with its value(s) to the attributes array.
|
|
35
46
|
* Handles NOT operations and array values.
|
|
@@ -37,23 +48,20 @@ let setBoolOpt = (attributes, term, value) => {
|
|
|
37
48
|
* @param {Array} attributes - Array to append the attribute to
|
|
38
49
|
* @param {string} term - The search term (e.g., 'FROM', 'SUBJECT')
|
|
39
50
|
* @param {*} value - The value for the search term (string, array, or falsy for NOT)
|
|
40
|
-
* @param {string} [type='ATOM'] - The attribute type
|
|
41
51
|
*/
|
|
42
|
-
let setOpt = (attributes, term, value
|
|
43
|
-
type = type || 'ATOM';
|
|
44
|
-
|
|
52
|
+
let setOpt = (attributes, term, value) => {
|
|
45
53
|
// Handle NOT operations for false or null values
|
|
46
54
|
if (value === false || value === null) {
|
|
47
|
-
attributes.push({ type, value: 'NOT' });
|
|
55
|
+
attributes.push({ type: 'ATOM', value: 'NOT' });
|
|
48
56
|
}
|
|
49
57
|
|
|
50
|
-
attributes.push({ type, value: term.toUpperCase() });
|
|
58
|
+
attributes.push({ type: 'ATOM', value: term.toUpperCase() });
|
|
51
59
|
|
|
52
|
-
// Handle array values (e.g
|
|
60
|
+
// Handle array values (e.g. HEADER name/value pairs)
|
|
53
61
|
if (Array.isArray(value)) {
|
|
54
|
-
value.forEach(entry => attributes.push({ type, value: (entry || '').toString() }));
|
|
62
|
+
value.forEach(entry => attributes.push({ type: 'ATOM', value: (entry || '').toString() }));
|
|
55
63
|
} else {
|
|
56
|
-
attributes.push({ type, value: value.toString() });
|
|
64
|
+
attributes.push({ type: 'ATOM', value: value.toString() });
|
|
57
65
|
}
|
|
58
66
|
};
|
|
59
67
|
|
|
@@ -158,12 +166,12 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
158
166
|
// Custom sequence range support (non-standard)
|
|
159
167
|
case 'SEQ':
|
|
160
168
|
{
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (
|
|
169
|
+
// Passed through as a SEQUENCE token: the compiler validates the
|
|
170
|
+
// set grammar and throws a coded error. An invalid value used to
|
|
171
|
+
// be dropped silently here, which turned a bad filter into an
|
|
172
|
+
// unrestricted search that matched every message.
|
|
173
|
+
let value = params[term] || params[term] === 0 ? toSequenceValue(params[term]) : '';
|
|
174
|
+
if (value) {
|
|
167
175
|
attributes.push({ type: 'SEQUENCE', value });
|
|
168
176
|
}
|
|
169
177
|
}
|
|
@@ -239,7 +247,7 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
239
247
|
case 'UID':
|
|
240
248
|
if (params[term]) {
|
|
241
249
|
attributes.push({ type: 'ATOM', value: 'UID' });
|
|
242
|
-
|
|
250
|
+
attributes.push({ type: 'SEQUENCE', value: toSequenceValue(params[term]) });
|
|
243
251
|
}
|
|
244
252
|
break;
|
|
245
253
|
|
package/package.json
CHANGED
|
@@ -2910,6 +2910,40 @@ module.exports['Commands: compress handles error'] = async test => {
|
|
|
2910
2910
|
test.done();
|
|
2911
2911
|
};
|
|
2912
2912
|
|
|
2913
|
+
module.exports['Commands: compress fails the connection on trailing data'] = async test => {
|
|
2914
|
+
// Per RFC 4978 the server switches to DEFLATE at its tagged OK, so data already
|
|
2915
|
+
// buffered behind the OK was consumed as cleartext and the deflate stream is
|
|
2916
|
+
// truncated. Declining the upgrade is not a protocol option at that point - the
|
|
2917
|
+
// session is unrecoverable in both directions and must fail closed.
|
|
2918
|
+
let closeAfterCalled = false;
|
|
2919
|
+
let nextCalled = false;
|
|
2920
|
+
const connection = createMockConnection({
|
|
2921
|
+
capabilities: new Map([['COMPRESS=DEFLATE', true]]),
|
|
2922
|
+
closeAfter: () => {
|
|
2923
|
+
closeAfterCalled = true;
|
|
2924
|
+
},
|
|
2925
|
+
exec: async () => ({
|
|
2926
|
+
hasTrailingData: true,
|
|
2927
|
+
next: () => {
|
|
2928
|
+
nextCalled = true;
|
|
2929
|
+
test.ok(closeAfterCalled, 'teardown must be scheduled before parser backpressure is released');
|
|
2930
|
+
}
|
|
2931
|
+
})
|
|
2932
|
+
});
|
|
2933
|
+
|
|
2934
|
+
let err = null;
|
|
2935
|
+
try {
|
|
2936
|
+
await compressCommand(connection);
|
|
2937
|
+
} catch (e) {
|
|
2938
|
+
err = e;
|
|
2939
|
+
}
|
|
2940
|
+
test.ok(err, 'compress must throw');
|
|
2941
|
+
test.equal(err && err.code, 'COMPRESS_TRAILING_DATA');
|
|
2942
|
+
test.ok(closeAfterCalled, 'the connection must be closed');
|
|
2943
|
+
test.ok(nextCalled, 'parser backpressure must still be released');
|
|
2944
|
+
test.done();
|
|
2945
|
+
};
|
|
2946
|
+
|
|
2913
2947
|
// ============================================
|
|
2914
2948
|
// STARTTLS Command Tests
|
|
2915
2949
|
// ============================================
|
|
@@ -3895,6 +3929,52 @@ module.exports['Commands: list runs separate INBOX query when using namespace']
|
|
|
3895
3929
|
test.done();
|
|
3896
3930
|
};
|
|
3897
3931
|
|
|
3932
|
+
module.exports['Commands: list INBOX fixup propagates non-reducible failures'] = async test => {
|
|
3933
|
+
// Both sides of the fixup's retry guard: a NO on the extended call is an operational
|
|
3934
|
+
// failure (not a RETURN options rejection), and a BAD on a call that already ran plain
|
|
3935
|
+
// has no options left to reduce - neither may trigger the plain retry
|
|
3936
|
+
for (let { capabilities, status } of [
|
|
3937
|
+
{
|
|
3938
|
+
capabilities: [
|
|
3939
|
+
['IMAP4rev1', true],
|
|
3940
|
+
['LIST-EXTENDED', true]
|
|
3941
|
+
],
|
|
3942
|
+
status: 'NO'
|
|
3943
|
+
},
|
|
3944
|
+
{ capabilities: [['IMAP4rev1', true]], status: 'BAD' }
|
|
3945
|
+
]) {
|
|
3946
|
+
let listCalls = 0;
|
|
3947
|
+
let lsubCalls = 0;
|
|
3948
|
+
const connection = createMockConnection({
|
|
3949
|
+
state: 3,
|
|
3950
|
+
capabilities: new Map(capabilities),
|
|
3951
|
+
exec: async (cmd, attrs) => {
|
|
3952
|
+
if (cmd === 'LSUB') {
|
|
3953
|
+
lsubCalls++;
|
|
3954
|
+
}
|
|
3955
|
+
if (cmd === 'LIST') {
|
|
3956
|
+
listCalls++;
|
|
3957
|
+
if (attrs[1] === 'INBOX') {
|
|
3958
|
+
throw commandError('Command failed', status);
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
return { next: () => {} };
|
|
3962
|
+
}
|
|
3963
|
+
});
|
|
3964
|
+
|
|
3965
|
+
try {
|
|
3966
|
+
await listCommand(connection, 'Mail/', '*');
|
|
3967
|
+
test.ok(false, 'Should have thrown');
|
|
3968
|
+
} catch (err) {
|
|
3969
|
+
test.equal(err.responseStatus, status);
|
|
3970
|
+
}
|
|
3971
|
+
// Main listing and the failed INBOX call - no plain retry, no LSUB merge
|
|
3972
|
+
test.equal(listCalls, 2);
|
|
3973
|
+
test.equal(lsubCalls, 0);
|
|
3974
|
+
}
|
|
3975
|
+
test.done();
|
|
3976
|
+
};
|
|
3977
|
+
|
|
3898
3978
|
module.exports['Commands: list handles LSUB merging'] = async test => {
|
|
3899
3979
|
const connection = createMockConnection({
|
|
3900
3980
|
state: 3,
|
|
@@ -4851,18 +4931,28 @@ module.exports['Commands: list discards partial results from a rejected INBOX fi
|
|
|
4851
4931
|
state: 3,
|
|
4852
4932
|
capabilities: new Map([
|
|
4853
4933
|
['IMAP4rev1', true],
|
|
4854
|
-
['LIST-EXTENDED', true]
|
|
4934
|
+
['LIST-EXTENDED', true],
|
|
4935
|
+
['SPECIAL-USE', true]
|
|
4855
4936
|
]),
|
|
4856
4937
|
exec: async (cmd, attrs, opts) => {
|
|
4857
4938
|
if (cmd === 'LIST') {
|
|
4858
4939
|
listAttempts.push(JSON.stringify(attrs));
|
|
4859
4940
|
if (listAttempts.length === 1) {
|
|
4860
4941
|
await opts.untagged.LIST({
|
|
4861
|
-
attributes: [
|
|
4942
|
+
attributes: [
|
|
4943
|
+
[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }, { value: '\\Sent' }],
|
|
4944
|
+
{ value: '.' },
|
|
4945
|
+
{ value: 'Prefix.Folder1' }
|
|
4946
|
+
]
|
|
4862
4947
|
});
|
|
4863
4948
|
} else if (listAttempts.length === 2) {
|
|
4864
|
-
// The fixup attempt streams
|
|
4865
|
-
//
|
|
4949
|
+
// The fixup attempt streams untagged lines and THEN gets the tagged
|
|
4950
|
+
// BAD - the partial lines must not survive the retry. "Aliased" also
|
|
4951
|
+
// claims \Sent and sorts ahead of the main run's Prefix.Folder1, so
|
|
4952
|
+
// it would steal the special-use slot if it were not rolled back
|
|
4953
|
+
await opts.untagged.LIST({
|
|
4954
|
+
attributes: [[{ value: '\\Sent' }], { value: '.' }, { value: 'Aliased' }]
|
|
4955
|
+
});
|
|
4866
4956
|
await opts.untagged.LIST({
|
|
4867
4957
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '.' }, { value: 'INBOX' }]
|
|
4868
4958
|
});
|
|
@@ -4881,7 +4971,9 @@ module.exports['Commands: list discards partial results from a rejected INBOX fi
|
|
|
4881
4971
|
test.equal(listAttempts.length, 3);
|
|
4882
4972
|
// Exactly one INBOX entry - the rejected attempt's partial line was discarded
|
|
4883
4973
|
test.equal(result.filter(e => e.path === 'INBOX').length, 1);
|
|
4884
|
-
test.ok(result.
|
|
4974
|
+
test.ok(!result.some(e => e.path === 'Aliased'), 'the rejected attempt entry is gone');
|
|
4975
|
+
// The main run's special-use match survived the rollback of the rejected attempt
|
|
4976
|
+
test.equal(result.find(e => e.path === 'Prefix.Folder1').specialUse, '\\Sent');
|
|
4885
4977
|
test.done();
|
|
4886
4978
|
};
|
|
4887
4979
|
|
|
@@ -347,3 +347,84 @@ module.exports['Polling: no polling session without a saved select command'] = a
|
|
|
347
347
|
test.equal(connection.idling, false, 'idling untouched');
|
|
348
348
|
test.done();
|
|
349
349
|
};
|
|
350
|
+
|
|
351
|
+
module.exports['Polling: a falsy STATUS result stops the loop as PollFailed'] = async test => {
|
|
352
|
+
await withFakeTimers(async timers => {
|
|
353
|
+
let warnings = [];
|
|
354
|
+
let connection = createConnection({
|
|
355
|
+
missingIdleCommand: 'STATUS',
|
|
356
|
+
// Only STATUS is ever polled; a failure without a NO status makes the
|
|
357
|
+
// real STATUS implementation swallow the error and return false
|
|
358
|
+
respond: async () => {
|
|
359
|
+
throw new Error('Command failed');
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
connection.log.warn = entry => warnings.push(entry);
|
|
363
|
+
|
|
364
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
365
|
+
await timers.drain();
|
|
366
|
+
await idlePromise;
|
|
367
|
+
|
|
368
|
+
test.ok(
|
|
369
|
+
warnings.some(entry => entry && entry.err && entry.err.code === 'PollFailed'),
|
|
370
|
+
'the falsy STATUS result surfaced as a PollFailed error'
|
|
371
|
+
);
|
|
372
|
+
test.equal(connection.idling, false, 'idling reset after the failed poll');
|
|
373
|
+
test.equal(connection.preCheck, false, 'preCheck released');
|
|
374
|
+
test.equal(timers.count(), 0, 'polling stopped');
|
|
375
|
+
test.done();
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
module.exports['Polling: a repeated break call is a no-op'] = async test => {
|
|
380
|
+
await withFakeTimers(async timers => {
|
|
381
|
+
let connection = createConnection();
|
|
382
|
+
|
|
383
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
384
|
+
await timers.drain();
|
|
385
|
+
test.deepEqual(connection.commands, ['NOOP'], 'the immediate first poll ran');
|
|
386
|
+
|
|
387
|
+
// A caller may hold on to the break function and invoke it more than once
|
|
388
|
+
let preCheck = connection.preCheck;
|
|
389
|
+
await preCheck();
|
|
390
|
+
await preCheck();
|
|
391
|
+
await idlePromise;
|
|
392
|
+
|
|
393
|
+
test.equal(connection.idling, false, 'idling reset');
|
|
394
|
+
test.equal(connection.preCheck, false, 'preCheck released');
|
|
395
|
+
test.equal(timers.count(), 0, 'no timer left armed');
|
|
396
|
+
|
|
397
|
+
await timers.fire();
|
|
398
|
+
test.deepEqual(connection.commands, ['NOOP'], 'no further poll after the duplicate break');
|
|
399
|
+
test.done();
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
module.exports['Polling: a break in the initiation tick prevents the first poll'] = async test => {
|
|
404
|
+
await withFakeTimers(async timers => {
|
|
405
|
+
let connection = createConnection();
|
|
406
|
+
// Simulate a break request (e.g. a command being queued) landing in the same
|
|
407
|
+
// tick the loop is initiated: trap the loop installing its own preCheck and
|
|
408
|
+
// break through it immediately, before the first poll has started
|
|
409
|
+
let installedPreCheck = connection.preCheck;
|
|
410
|
+
Object.defineProperty(connection, 'preCheck', {
|
|
411
|
+
get: () => installedPreCheck,
|
|
412
|
+
set: value => {
|
|
413
|
+
installedPreCheck = value;
|
|
414
|
+
if (typeof value === 'function') {
|
|
415
|
+
value();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
421
|
+
await timers.drain();
|
|
422
|
+
await idlePromise;
|
|
423
|
+
|
|
424
|
+
test.deepEqual(connection.commands, [], 'the already-cancelled session never polled');
|
|
425
|
+
test.equal(connection.idling, false, 'idling reset');
|
|
426
|
+
test.equal(connection.preCheck, false, 'preCheck released');
|
|
427
|
+
test.equal(timers.count(), 0, 'no timer left armed');
|
|
428
|
+
test.done();
|
|
429
|
+
});
|
|
430
|
+
};
|
|
@@ -642,10 +642,11 @@ module.exports['IMAP Compiler: partial range in SECTION'] = test =>
|
|
|
642
642
|
|
|
643
643
|
// Returns the error a compile attempt raised, or undefined when it succeeded.
|
|
644
644
|
// Several cases below assert that a token can never reach the wire, and the
|
|
645
|
-
// try/catch is the only interesting part of each.
|
|
646
|
-
|
|
645
|
+
// try/catch is the only interesting part of each. Takes either an attributes
|
|
646
|
+
// array (compiled under a default tag/command) or a full response object.
|
|
647
|
+
const compileError = async input => {
|
|
647
648
|
try {
|
|
648
|
-
await compiler({ tag: 'A', command: 'CMD', attributes });
|
|
649
|
+
await compiler(Array.isArray(input) ? { tag: 'A', command: 'CMD', attributes: input } : input);
|
|
649
650
|
} catch (err) {
|
|
650
651
|
return err;
|
|
651
652
|
}
|
|
@@ -663,10 +664,50 @@ module.exports['IMAP Compiler: SEQUENCE rejects values that are not sequence set
|
|
|
663
664
|
|
|
664
665
|
module.exports['IMAP Compiler: SEQUENCE accepts every valid sequence-set form'] = test =>
|
|
665
666
|
asyncWrapper(test, async test => {
|
|
666
|
-
|
|
667
|
+
// '$' is the RFC 5182 SEARCHRES saved-result marker, valid as the entire set
|
|
668
|
+
for (let value of ['1', '*', '1:*', '*:1', '1,3,5', '1:3,7,9:*', '4294967295', '$']) {
|
|
667
669
|
const compiled = (await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'SEQUENCE', value }] })).toString();
|
|
668
670
|
test.equal(compiled, `A FETCH ${value}`);
|
|
669
671
|
}
|
|
672
|
+
|
|
673
|
+
// '$' only stands for the whole set, never an element of one
|
|
674
|
+
for (let value of ['$,1', '1,$', '$:2']) {
|
|
675
|
+
let err = await compileError([{ type: 'SEQUENCE', value }]);
|
|
676
|
+
test.equal(err && err.code, 'InvalidSequenceSet', `${JSON.stringify(value)} must be rejected`);
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
module.exports['IMAP Compiler: SEQUENCE validation stays linear on huge valid sets'] = test =>
|
|
681
|
+
asyncWrapper(test, async test => {
|
|
682
|
+
// The regex this replaced overflowed the engine's backtrack stack with an
|
|
683
|
+
// uncoded RangeError at roughly a million comma-separated elements - a set
|
|
684
|
+
// a fetch over a large mailbox can legitimately produce
|
|
685
|
+
const huge = Array.from({ length: 1500000 }, (_, i) => i + 1).join(',');
|
|
686
|
+
const compiled = await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'SEQUENCE', value: huge }] });
|
|
687
|
+
test.ok(compiled.toString().endsWith(huge), 'the huge but valid set must compile');
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
module.exports['IMAP Compiler: SEQUENCE validation is skipped when logging'] = test =>
|
|
691
|
+
asyncWrapper(test, async test => {
|
|
692
|
+
// The incoming token parser accepts sequence-shaped tokens the strict grammar
|
|
693
|
+
// rejects (an ESEARCH set like "1:2:3", a folder name like "12:30:00"). Every
|
|
694
|
+
// parsed server response is re-compiled for the log, so a throw here would let
|
|
695
|
+
// one quirky server line kill the whole connection.
|
|
696
|
+
for (let value of ['1:2:3', '12:30:00', '1,2:3:4']) {
|
|
697
|
+
const compiled = (await compiler({ tag: '*', command: 'OK', attributes: [{ type: 'SEQUENCE', value }] }, { isLogging: true })).toString();
|
|
698
|
+
test.equal(compiled, `* OK ${value}`, 'logging re-compile must pass the token through');
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
module.exports['IMAP Compiler: tag and command cannot carry a line terminator'] = test =>
|
|
703
|
+
asyncWrapper(test, async test => {
|
|
704
|
+
// The tag/command preamble is written verbatim ahead of any token, so it goes
|
|
705
|
+
// through the same choke point as everything else
|
|
706
|
+
let err = await compileError({ tag: 'A\r\nZZ LOGOUT', command: 'NOOP' });
|
|
707
|
+
test.equal(err && err.code, 'InvalidTokenValue', 'CRLF in the tag must be rejected');
|
|
708
|
+
|
|
709
|
+
err = await compileError({ tag: 'A', command: 'NOOP\r\nZZ LOGOUT' });
|
|
710
|
+
test.equal(err && err.code, 'InvalidTokenValue', 'CRLF in the command must be rejected');
|
|
670
711
|
});
|
|
671
712
|
|
|
672
713
|
module.exports['IMAP Compiler: quoted strings use IMAP escaping, not JSON escaping'] = test =>
|
|
@@ -707,6 +748,23 @@ module.exports['IMAP Compiler: NUMBER coerces its value instead of writing it th
|
|
|
707
748
|
test.equal((await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value: '42' }] })).toString(), 'A CMD 42');
|
|
708
749
|
});
|
|
709
750
|
|
|
751
|
+
module.exports['IMAP Compiler: numeric tokens clamp to bounded non-negative integers'] = test =>
|
|
752
|
+
asyncWrapper(test, async test => {
|
|
753
|
+
// Infinity, negatives and unsafe magnitudes would otherwise emit bytes outside
|
|
754
|
+
// the number alphabet ("Infinity", "-5", "1e+300") and get a server BAD blamed
|
|
755
|
+
// on the server instead of the caller
|
|
756
|
+
for (let [value, expected] of [
|
|
757
|
+
[Infinity, '0'],
|
|
758
|
+
['Infinity', '0'],
|
|
759
|
+
[-5, '0'],
|
|
760
|
+
['2e21', '0'],
|
|
761
|
+
[4.6, '5']
|
|
762
|
+
]) {
|
|
763
|
+
const compiled = (await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value }] })).toString();
|
|
764
|
+
test.equal(compiled, `A CMD ${expected}`, `${JSON.stringify(value)} must clamp to ${expected}`);
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
|
|
710
768
|
module.exports['IMAP Compiler: partial range coerces its elements'] = test =>
|
|
711
769
|
asyncWrapper(test, async test => {
|
|
712
770
|
// The partial range is the last token component written verbatim, so it is
|
|
@@ -719,6 +777,18 @@ module.exports['IMAP Compiler: partial range coerces its elements'] = test =>
|
|
|
719
777
|
await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: [0, 1024] }] })
|
|
720
778
|
).toString();
|
|
721
779
|
test.equal(normal, 'A FETCH BODY.PEEK[]<0.1024>');
|
|
780
|
+
|
|
781
|
+
// Floats and unbounded numerics clamp the same way NUMBER tokens do, so no
|
|
782
|
+
// non-digit byte ("512.5", "Infinity") can appear inside the partial range
|
|
783
|
+
const float = (
|
|
784
|
+
await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: [0, 512.5] }] })
|
|
785
|
+
).toString();
|
|
786
|
+
test.equal(float, 'A FETCH BODY.PEEK[]<0.513>');
|
|
787
|
+
|
|
788
|
+
const unbounded = (
|
|
789
|
+
await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: ['Infinity', '2e21'] }] })
|
|
790
|
+
).toString();
|
|
791
|
+
test.equal(unbounded, 'A FETCH BODY.PEEK[]<0.0>');
|
|
722
792
|
});
|
|
723
793
|
|
|
724
794
|
module.exports['IMAP Compiler: logging output never throws on unsendable values'] = test =>
|
|
@@ -471,3 +471,137 @@ module.exports['Internals: untaggedFetch includes modseq and flagColor'] = async
|
|
|
471
471
|
test.equal(evt.flagColor, 'red');
|
|
472
472
|
test.done();
|
|
473
473
|
};
|
|
474
|
+
|
|
475
|
+
// ============================================================================
|
|
476
|
+
// runInternal dispatch guards
|
|
477
|
+
// ============================================================================
|
|
478
|
+
|
|
479
|
+
module.exports['Internals: runInternal returns false for unknown command'] = async test => {
|
|
480
|
+
let client = makeClient();
|
|
481
|
+
client.socket = { destroyed: false };
|
|
482
|
+
let res = await client.runInternal('NOT_A_COMMAND');
|
|
483
|
+
test.equal(res, false);
|
|
484
|
+
test.done();
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
module.exports['Internals: runInternal throws NoConnection when the socket is destroyed'] = async test => {
|
|
488
|
+
let client = makeClient();
|
|
489
|
+
client.socket = { destroyed: true };
|
|
490
|
+
let err = null;
|
|
491
|
+
try {
|
|
492
|
+
await client.runInternal('NOOP');
|
|
493
|
+
} catch (e) {
|
|
494
|
+
err = e;
|
|
495
|
+
}
|
|
496
|
+
test.ok(err);
|
|
497
|
+
test.equal(err.code, 'NoConnection');
|
|
498
|
+
test.done();
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// ============================================================================
|
|
502
|
+
// send() onSend error containment
|
|
503
|
+
// ============================================================================
|
|
504
|
+
|
|
505
|
+
module.exports['Internals: send contains a throwing onSend callback'] = async test => {
|
|
506
|
+
let client = makeClient();
|
|
507
|
+
let written = [];
|
|
508
|
+
let warnings = [];
|
|
509
|
+
client.socket = { destroyed: false };
|
|
510
|
+
client.writeSocket = { destroyed: false, write: chunk => written.push(chunk) };
|
|
511
|
+
client.state = client.states.AUTHENTICATED;
|
|
512
|
+
client.log.warn = entry => warnings.push(entry);
|
|
513
|
+
client.currentRequest = { tag: 'A1', sent: false };
|
|
514
|
+
|
|
515
|
+
// The command is already on the wire when onSend runs, so a throwing callback
|
|
516
|
+
// must be logged and swallowed instead of rejecting the request
|
|
517
|
+
await client.send({
|
|
518
|
+
tag: 'A1',
|
|
519
|
+
command: 'NOOP',
|
|
520
|
+
attributes: [],
|
|
521
|
+
options: {
|
|
522
|
+
onSend: () => {
|
|
523
|
+
throw new Error('onSend boom');
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test.equal(written.length, 1, 'the command was written to the socket');
|
|
529
|
+
test.equal(written[0].toString(), 'A1 NOOP\r\n', 'the actual wire bytes went out before onSend ran');
|
|
530
|
+
test.equal(client.currentRequest.sent, true, 'the request is marked as sent');
|
|
531
|
+
test.ok(
|
|
532
|
+
warnings.some(entry => entry && entry.err && entry.err.message === 'onSend boom'),
|
|
533
|
+
'the callback error was logged'
|
|
534
|
+
);
|
|
535
|
+
test.done();
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// ============================================================================
|
|
539
|
+
// countUnknownTag teardown guard
|
|
540
|
+
// ============================================================================
|
|
541
|
+
|
|
542
|
+
module.exports['Internals: countUnknownTag ignores tags on a closed connection'] = test => {
|
|
543
|
+
let client = makeClient();
|
|
544
|
+
client.isClosed = true;
|
|
545
|
+
client.countUnknownTag('A1');
|
|
546
|
+
test.equal(client._unknownTagCount, 0, 'teardown crossover is not counted');
|
|
547
|
+
test.done();
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// ============================================================================
|
|
551
|
+
// rejectUnparsedCompletion
|
|
552
|
+
// ============================================================================
|
|
553
|
+
|
|
554
|
+
// Installs a command that is current and on the wire, returning a holder for
|
|
555
|
+
// the rejection the reader loop would deliver to the caller
|
|
556
|
+
const armInFlight = (client, tag) => {
|
|
557
|
+
let captured = { err: null };
|
|
558
|
+
client.currentRequest = { tag, sent: true };
|
|
559
|
+
client.requestTagMap.set(tag, {
|
|
560
|
+
tag,
|
|
561
|
+
reject: err => {
|
|
562
|
+
captured.err = err;
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
return captured;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
module.exports['Internals: rejectUnparsedCompletion ignores lines without an in-flight command'] = test => {
|
|
569
|
+
let client = makeClient();
|
|
570
|
+
|
|
571
|
+
client.currentRequest = false;
|
|
572
|
+
client.rejectUnparsedCompletion(Buffer.from('A1 OK done'), new Error('parse fail'));
|
|
573
|
+
test.equal(client.currentRequest, false, 'no request to settle');
|
|
574
|
+
|
|
575
|
+
// A command that is current but not yet on the wire must not be settled either
|
|
576
|
+
client.currentRequest = { tag: 'A1', sent: false };
|
|
577
|
+
client.rejectUnparsedCompletion(Buffer.from('A1 OK done'), new Error('parse fail'));
|
|
578
|
+
test.ok(client.currentRequest, 'the unsent request is untouched');
|
|
579
|
+
test.done();
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
module.exports['Internals: rejectUnparsedCompletion recovers the tag from NUL-padded raw bytes'] = async test => {
|
|
583
|
+
let client = makeClient();
|
|
584
|
+
let captured = armInFlight(client, 'A1');
|
|
585
|
+
|
|
586
|
+
// The parser died before extracting a tag - the raw line carries the buggy-server
|
|
587
|
+
// NUL padding, so the fallback must skip it and stop at the first non-tag byte
|
|
588
|
+
let parserError = new Error('parse fail');
|
|
589
|
+
client.rejectUnparsedCompletion(Buffer.from('\x00\x00A1 \x07garbage'), parserError);
|
|
590
|
+
|
|
591
|
+
test.ok(captured.err, 'the in-flight command was failed');
|
|
592
|
+
test.equal(captured.err.code, 'ParserError');
|
|
593
|
+
test.equal(captured.err.parserError, parserError);
|
|
594
|
+
test.equal(client.currentRequest, false, 'the request slot was cleared');
|
|
595
|
+
test.done();
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
module.exports['Internals: rejectUnparsedCompletion ignores a mismatched raw tag'] = test => {
|
|
599
|
+
let client = makeClient();
|
|
600
|
+
let captured = armInFlight(client, 'A1');
|
|
601
|
+
|
|
602
|
+
client.rejectUnparsedCompletion(Buffer.from('A2 NO other'), new Error('parse fail'));
|
|
603
|
+
|
|
604
|
+
test.equal(captured.err, null, 'a line for another tag settles nothing');
|
|
605
|
+
test.ok(client.currentRequest, 'the in-flight request stays current');
|
|
606
|
+
test.done();
|
|
607
|
+
};
|