imapflow 1.6.5 → 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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -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/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 +22 -2
- package/lib/imap-flow.js +131 -61
- 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/commands-branches-test.js +4 -0
- package/test/commands-integration-test.js +683 -0
- package/test/copyuid-parser-test.js +20 -0
- package/test/imap-flow-coverage-test.js +4 -2
- package/test/imap-flow-fetch-download-test.js +26 -0
- 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 +27 -1
- package/test/tools-test.js +151 -2
package/lib/imap-flow.js
CHANGED
|
@@ -12,7 +12,7 @@ const logger = require('./logger');
|
|
|
12
12
|
const libmime = require('libmime');
|
|
13
13
|
const zlib = require('zlib');
|
|
14
14
|
const { Headers } = require('@zone-eu/mailsplit');
|
|
15
|
-
const { LimitedPassthrough } = require('./limited-passthrough');
|
|
15
|
+
const { LimitedPassthrough, normalizeByteLimit } = require('./limited-passthrough');
|
|
16
16
|
|
|
17
17
|
const { ImapStream } = require('./handler/imap-stream');
|
|
18
18
|
const { parser, compiler } = require('./handler/imap-handler');
|
|
@@ -38,7 +38,11 @@ const {
|
|
|
38
38
|
AuthenticationFailure,
|
|
39
39
|
getColorFlags,
|
|
40
40
|
hasCapability,
|
|
41
|
-
unrefTimer
|
|
41
|
+
unrefTimer,
|
|
42
|
+
parseUintValue,
|
|
43
|
+
isUnsafeKey,
|
|
44
|
+
getStringList,
|
|
45
|
+
MAX_UINT32_DIGITS
|
|
42
46
|
} = require('./tools');
|
|
43
47
|
|
|
44
48
|
const imapCommands = require('./imap-commands.js');
|
|
@@ -50,6 +54,10 @@ const UPGRADE_TIMEOUT = 10 * 1000;
|
|
|
50
54
|
|
|
51
55
|
const SOCKET_TIMEOUT = 5 * 60 * 1000;
|
|
52
56
|
|
|
57
|
+
// Ceiling for any throttle back-off wait. Both the connection-level back-off and the per-command
|
|
58
|
+
// retries derive their delay from server-supplied hints, which are unbounded.
|
|
59
|
+
const MAX_THROTTLE_DELAY = 5 * 60 * 1000;
|
|
60
|
+
|
|
53
61
|
// Default threshold for warning that a mailbox lock has been held for a long
|
|
54
62
|
// time. Intended to catch forgotten release() calls, not legitimate long ops
|
|
55
63
|
// (e.g. fetching hundreds of thousands of messages). Configurable via the
|
|
@@ -323,17 +331,18 @@ class ImapFlow extends EventEmitter {
|
|
|
323
331
|
logRaw: this.logRaw,
|
|
324
332
|
secureConnection: this.secureConnection,
|
|
325
333
|
maxLineLength: this.options.maxLineLength,
|
|
326
|
-
maxLiteralSize: this.options.maxLiteralSize
|
|
334
|
+
maxLiteralSize: this.options.maxLiteralSize,
|
|
335
|
+
maxResponseSize: this.options.maxResponseSize
|
|
327
336
|
});
|
|
328
337
|
|
|
329
338
|
this.reading = false;
|
|
330
339
|
this.socket = false;
|
|
331
340
|
this.writeSocket = false;
|
|
332
341
|
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
this.
|
|
342
|
+
// In-flight throttle back-offs (see throttleWait()). Tracked as a set because more than
|
|
343
|
+
// one can be pending at a time: the reader's connection-level back-off and a command
|
|
344
|
+
// retrying its own throttled request. close() clears them all.
|
|
345
|
+
this._throttleWaits = new Set();
|
|
337
346
|
|
|
338
347
|
// Pending rejector of the in-flight STARTTLS upgrade promise (see upgradeToSTARTTLS()).
|
|
339
348
|
// Stored so emitError() can route a streamer-originated error into the upgrade's single
|
|
@@ -816,6 +825,32 @@ class ImapFlow extends EventEmitter {
|
|
|
816
825
|
}
|
|
817
826
|
}
|
|
818
827
|
|
|
828
|
+
/**
|
|
829
|
+
* Waits out a throttle back-off.
|
|
830
|
+
*
|
|
831
|
+
* The delay is capped at MAX_THROTTLE_DELAY because it can come straight from a server hint
|
|
832
|
+
* (a Microsoft 365 "Suggested Backoff Time", say) and an uncapped hint would park the caller
|
|
833
|
+
* for weeks. The timer is unref'd and tracked so it can never outlive the client: a bare
|
|
834
|
+
* setTimeout here keeps a short-lived process alive for the full delay after close(), and
|
|
835
|
+
* leaves the caller waiting on a connection that is already gone.
|
|
836
|
+
*
|
|
837
|
+
* @param {Number} delay - Requested delay in milliseconds.
|
|
838
|
+
* @returns {Promise<Boolean>} True if close() aborted the wait, false on normal expiry.
|
|
839
|
+
*/
|
|
840
|
+
async throttleWait(delay) {
|
|
841
|
+
delay = Math.min(Math.max(Number(delay) || 0, 0), MAX_THROTTLE_DELAY);
|
|
842
|
+
|
|
843
|
+
return await new Promise(resolve => {
|
|
844
|
+
let entry = { resolve };
|
|
845
|
+
entry.timer = setTimeout(() => {
|
|
846
|
+
this._throttleWaits.delete(entry);
|
|
847
|
+
resolve(false);
|
|
848
|
+
}, delay);
|
|
849
|
+
unrefTimer(entry.timer);
|
|
850
|
+
this._throttleWaits.add(entry);
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
819
854
|
async reader() {
|
|
820
855
|
let data;
|
|
821
856
|
let processedCount = 0;
|
|
@@ -901,8 +936,10 @@ class ImapFlow extends EventEmitter {
|
|
|
901
936
|
try {
|
|
902
937
|
parsed = await parser(data.payload, { literals: data.literals });
|
|
903
938
|
} catch (err) {
|
|
904
|
-
// can not make sense of this
|
|
905
|
-
|
|
939
|
+
// can not make sense of this. The payload can be up to the configured line
|
|
940
|
+
// cap (1GB by default), so log only a bounded prefix: a server looping
|
|
941
|
+
// unparseable garbage would otherwise turn this error log into a disk filler.
|
|
942
|
+
this.log.error({ src: 's', msg: data.payload.toString('latin1', 0, 1024), payloadBytes: data.payload.length, err, cid: this.id });
|
|
906
943
|
// An unparseable untagged line is junk that can be skipped, but the line may
|
|
907
944
|
// have been the in-flight command's tagged completion. Dropping that one
|
|
908
945
|
// silently strands the command: currentRequest is never cleared, so trySend()
|
|
@@ -974,7 +1011,9 @@ class ImapFlow extends EventEmitter {
|
|
|
974
1011
|
}
|
|
975
1012
|
|
|
976
1013
|
let section = parsed.attributes && parsed.attributes.length && parsed.attributes[0] && !parsed.attributes[0].value && parsed.attributes[0].section;
|
|
977
|
-
|
|
1014
|
+
// section[0] can be a parsed NIL (null), e.g. from a "[NIL]" response code - the
|
|
1015
|
+
// dereference must be guarded or one such line tears down the whole connection
|
|
1016
|
+
if (section && section.length && section[0] && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
978
1017
|
let sectionHandler = this.getSectionHandler(section[0].value.toUpperCase().trim());
|
|
979
1018
|
if (sectionHandler) {
|
|
980
1019
|
try {
|
|
@@ -1124,27 +1163,12 @@ class ImapFlow extends EventEmitter {
|
|
|
1124
1163
|
err.code = 'ETHROTTLE';
|
|
1125
1164
|
err.throttleReset = throttleDelay;
|
|
1126
1165
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
// Cap wait at 5 minutes to avoid hanging connections indefinitely.
|
|
1130
|
-
// The server-suggested delay can be very large.
|
|
1131
|
-
delayResponse = 5 * 60 * 1000;
|
|
1132
|
-
}
|
|
1166
|
+
// The server-suggested delay can be very large, so throttleWait() caps it
|
|
1167
|
+
let delayResponse = Math.min(throttleDelay, MAX_THROTTLE_DELAY);
|
|
1133
1168
|
|
|
1134
1169
|
this.log.warn({ msg: 'Throttling detected', cid: this.id, throttleDelay, delayResponse, err });
|
|
1135
1170
|
|
|
1136
|
-
|
|
1137
|
-
// the back-off never keeps the event loop alive, and storing the resolve
|
|
1138
|
-
// lets close() abort the wait promptly (aborted=true) instead of blocking
|
|
1139
|
-
// the reader for up to 5 minutes and rejecting long after the connection
|
|
1140
|
-
// is gone. Normal expiry resolves with aborted=false.
|
|
1141
|
-
let aborted = await new Promise(resolve => {
|
|
1142
|
-
this._throttleAbort = resolve;
|
|
1143
|
-
this._throttleTimer = setTimeout(() => resolve(false), delayResponse);
|
|
1144
|
-
unrefTimer(this._throttleTimer);
|
|
1145
|
-
});
|
|
1146
|
-
this._throttleTimer = null;
|
|
1147
|
-
this._throttleAbort = null;
|
|
1171
|
+
let aborted = await this.throttleWait(delayResponse);
|
|
1148
1172
|
|
|
1149
1173
|
if (aborted) {
|
|
1150
1174
|
// Connection closed during back-off: reject promptly with a
|
|
@@ -1571,6 +1595,12 @@ class ImapFlow extends EventEmitter {
|
|
|
1571
1595
|
let opts = Object.assign(
|
|
1572
1596
|
{
|
|
1573
1597
|
socket: this.socket,
|
|
1598
|
+
// host is required even though the socket is already connected: without
|
|
1599
|
+
// it, a connection made to an IP literal (servername=false) has its
|
|
1600
|
+
// certificate verified against Node's fallback name "localhost" instead
|
|
1601
|
+
// of the IP - accepting any "localhost" certificate for any IP-hosted
|
|
1602
|
+
// server, and rejecting legitimate IP-SAN certificates.
|
|
1603
|
+
host: this.host,
|
|
1574
1604
|
servername: this.servername,
|
|
1575
1605
|
port: this.port
|
|
1576
1606
|
},
|
|
@@ -1894,11 +1924,18 @@ class ImapFlow extends EventEmitter {
|
|
|
1894
1924
|
return;
|
|
1895
1925
|
}
|
|
1896
1926
|
|
|
1897
|
-
if (!untagged
|
|
1927
|
+
if (!untagged) {
|
|
1898
1928
|
return;
|
|
1899
1929
|
}
|
|
1900
1930
|
|
|
1901
|
-
|
|
1931
|
+
// Not a usable count: anything but a bounded digit run. A digit run long enough
|
|
1932
|
+
// coerces to Infinity, which would corrupt mailbox state (resolveRange('*') would
|
|
1933
|
+
// compile to the literal "Infinity" and every range-based command would fail until
|
|
1934
|
+
// the next SELECT)
|
|
1935
|
+
let count = parseUintValue(untagged.command, MAX_UINT32_DIGITS);
|
|
1936
|
+
if (count === false) {
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1902
1939
|
if (count === this.mailbox.exists) {
|
|
1903
1940
|
// nothing changed?
|
|
1904
1941
|
return;
|
|
@@ -1920,11 +1957,12 @@ class ImapFlow extends EventEmitter {
|
|
|
1920
1957
|
return;
|
|
1921
1958
|
}
|
|
1922
1959
|
|
|
1923
|
-
if (!untagged
|
|
1960
|
+
if (!untagged) {
|
|
1924
1961
|
return;
|
|
1925
1962
|
}
|
|
1926
1963
|
|
|
1927
|
-
|
|
1964
|
+
// Same bound untaggedExists() applies: only a bounded decimal run is a usable sequence number
|
|
1965
|
+
let seq = parseUintValue(untagged.command, MAX_UINT32_DIGITS);
|
|
1928
1966
|
if (seq && seq <= this.mailbox.exists) {
|
|
1929
1967
|
this.mailbox.exists--;
|
|
1930
1968
|
let payload = {
|
|
@@ -1955,8 +1993,14 @@ class ImapFlow extends EventEmitter {
|
|
|
1955
1993
|
let tags = [];
|
|
1956
1994
|
let uids = false;
|
|
1957
1995
|
|
|
1996
|
+
// A malformed VANISHED can carry no attributes at all, and one carrying only the
|
|
1997
|
+
// (EARLIER) tag leaves `uids` false - expandRange() handles that and yields nothing
|
|
1998
|
+
if (!untagged.attributes || !untagged.attributes.length) {
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
1958
2002
|
if (untagged.attributes.length > 1 && Array.isArray(untagged.attributes[0])) {
|
|
1959
|
-
tags = untagged.attributes[0].map(
|
|
2003
|
+
tags = getStringList(untagged.attributes[0]).map(value => value.toUpperCase());
|
|
1960
2004
|
untagged.attributes.shift();
|
|
1961
2005
|
}
|
|
1962
2006
|
|
|
@@ -2320,14 +2364,13 @@ class ImapFlow extends EventEmitter {
|
|
|
2320
2364
|
clearTimeout(this.connectTimeout);
|
|
2321
2365
|
clearTimeout(this.greetingTimeout);
|
|
2322
2366
|
|
|
2323
|
-
// Abort
|
|
2324
|
-
//
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
this._throttleAbort(true);
|
|
2329
|
-
this._throttleAbort = null;
|
|
2367
|
+
// Abort every in-flight throttle back-off so each waiter unblocks and its request is
|
|
2368
|
+
// settled promptly rather than after the full delay.
|
|
2369
|
+
for (let entry of this._throttleWaits) {
|
|
2370
|
+
clearTimeout(entry.timer);
|
|
2371
|
+
entry.resolve(true);
|
|
2330
2372
|
}
|
|
2373
|
+
this._throttleWaits.clear();
|
|
2331
2374
|
|
|
2332
2375
|
this.usable = false;
|
|
2333
2376
|
// close() takes over ownership of the idling state: dropping the session token means a
|
|
@@ -3593,7 +3636,8 @@ class ImapFlow extends EventEmitter {
|
|
|
3593
3636
|
let processed = 0;
|
|
3594
3637
|
|
|
3595
3638
|
let chunkSize = Number(options.chunkSize) || 64 * 1024;
|
|
3596
|
-
|
|
3639
|
+
// Normalized once here so every bounded stage of the pipeline below agrees on the budget
|
|
3640
|
+
let maxBytes = normalizeByteLimit(options.maxBytes);
|
|
3597
3641
|
|
|
3598
3642
|
let uid = false;
|
|
3599
3643
|
|
|
@@ -3783,24 +3827,43 @@ class ImapFlow extends EventEmitter {
|
|
|
3783
3827
|
output = stream = new PassThrough();
|
|
3784
3828
|
}
|
|
3785
3829
|
|
|
3830
|
+
// Every byte-bounded stage of the pipeline. The fetch loop below stops as soon as any of
|
|
3831
|
+
// them has taken all it will accept. The limiter at the tail is not enough on its own: a
|
|
3832
|
+
// transform in the middle that buffers its whole input before emitting anything (the
|
|
3833
|
+
// format=flowed decoder, the Japanese charset decoder) leaves the tail limiter reporting
|
|
3834
|
+
// `limited === false` however much the server sends, so a download with a small maxBytes
|
|
3835
|
+
// would still pull the entire part off the wire.
|
|
3836
|
+
let limiters = [];
|
|
3837
|
+
let isLimited = () => limiters.some(entry => entry.limited);
|
|
3838
|
+
|
|
3839
|
+
// Appending a stage means forwarding the current tail's errors to it before piping, so a
|
|
3840
|
+
// failure anywhere reaches the stream the caller is reading
|
|
3841
|
+
let pipeStage = stage => {
|
|
3842
|
+
output.on('error', err => {
|
|
3843
|
+
stage.emit('error', err);
|
|
3844
|
+
});
|
|
3845
|
+
output = output.pipe(stage);
|
|
3846
|
+
return stage;
|
|
3847
|
+
};
|
|
3848
|
+
|
|
3786
3849
|
let isTextNode = ['text/html', 'text/plain', 'text/x-amp-html'].includes(meta.contentType) || (part === '1' && !meta.contentType);
|
|
3787
3850
|
if ((!meta.disposition || meta.disposition === 'inline') && isTextNode) {
|
|
3788
3851
|
// RFC 3676 format=flowed text: unwrap soft line breaks
|
|
3789
3852
|
if (meta.flowed) {
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3853
|
+
// FlowedDecoder buffers its whole input before emitting, and being third party it
|
|
3854
|
+
// carries no bound of its own, so bound what it can ever be handed. Unwrapping only
|
|
3855
|
+
// removes bytes, so capping its input at maxBytes cannot push the delivered output
|
|
3856
|
+
// above the cap either.
|
|
3857
|
+
limiters.push(pipeStage(new LimitedPassthrough({ maxBytes })));
|
|
3858
|
+
|
|
3859
|
+
pipeStage(new FlowedDecoder({ delSp: meta.delSp }));
|
|
3797
3860
|
}
|
|
3798
3861
|
|
|
3799
3862
|
// Convert non-UTF-8 charsets to UTF-8 via a streaming decoder.
|
|
3800
3863
|
// ASCII and UTF-8 need no conversion. Unknown charsets are left as-is.
|
|
3801
3864
|
if (meta.charset && !['ascii', 'usascii', 'utf8'].includes(meta.charset.toLowerCase().replace(/[^a-z0-9]+/g, ''))) {
|
|
3802
3865
|
try {
|
|
3803
|
-
let decoder = getDecoder(meta.charset);
|
|
3866
|
+
let decoder = getDecoder(meta.charset, maxBytes);
|
|
3804
3867
|
// Safety listener attached first so the decoder always has at least
|
|
3805
3868
|
// one 'error' listener. Prevents Node.js from throwing
|
|
3806
3869
|
// ERR_UNHANDLED_ERROR if a later pipe setup step throws and leaves
|
|
@@ -3810,10 +3873,10 @@ class ImapFlow extends EventEmitter {
|
|
|
3810
3873
|
decoder.on('error', err => {
|
|
3811
3874
|
this.log.warn({ err, charset: meta.charset, cid: this.id });
|
|
3812
3875
|
});
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3876
|
+
// The Japanese decoder buffers its whole input as well, and reports the same
|
|
3877
|
+
// `limited` flag the limiters do so the fetch loop can stop once it is full.
|
|
3878
|
+
// A streaming decoder has no such flag, which reads as false and is correct.
|
|
3879
|
+
limiters.push(pipeStage(decoder));
|
|
3817
3880
|
// force to utf-8 for output
|
|
3818
3881
|
meta.charset = 'utf-8';
|
|
3819
3882
|
} catch {
|
|
@@ -3822,11 +3885,8 @@ class ImapFlow extends EventEmitter {
|
|
|
3822
3885
|
}
|
|
3823
3886
|
}
|
|
3824
3887
|
|
|
3825
|
-
let limiter = new LimitedPassthrough({ maxBytes });
|
|
3826
|
-
|
|
3827
|
-
limiter.emit('error', err);
|
|
3828
|
-
});
|
|
3829
|
-
output = output.pipe(limiter);
|
|
3888
|
+
let limiter = pipeStage(new LimitedPassthrough({ maxBytes }));
|
|
3889
|
+
limiters.push(limiter);
|
|
3830
3890
|
|
|
3831
3891
|
// Cleanup function
|
|
3832
3892
|
const cleanup = () => {
|
|
@@ -3841,7 +3901,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3841
3901
|
output.once('close', cleanup);
|
|
3842
3902
|
|
|
3843
3903
|
let writeChunk = chunk => {
|
|
3844
|
-
if (
|
|
3904
|
+
if (isLimited() || fetchAborted || stream.destroyed) {
|
|
3845
3905
|
return true;
|
|
3846
3906
|
}
|
|
3847
3907
|
return stream.write(chunk);
|
|
@@ -3851,7 +3911,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3851
3911
|
// Stops when the server returns a short chunk (< chunkSize), the byte
|
|
3852
3912
|
// limiter is satisfied, or the consumer destroys the output stream.
|
|
3853
3913
|
let fetchAllParts = async () => {
|
|
3854
|
-
while (hasMore && !
|
|
3914
|
+
while (hasMore && !isLimited() && !fetchAborted) {
|
|
3855
3915
|
let { chunk } = await getNextPart();
|
|
3856
3916
|
if (!chunk || fetchAborted) {
|
|
3857
3917
|
break;
|
|
@@ -4012,6 +4072,12 @@ class ImapFlow extends EventEmitter {
|
|
|
4012
4072
|
|
|
4013
4073
|
for (let [part, content] of response.bodyParts) {
|
|
4014
4074
|
let keyParts = part.split('.mime');
|
|
4075
|
+
// The server chooses the BODY[...] keys it answers with: never let one be a
|
|
4076
|
+
// prototype-chain name, or the assignments below write onto Object.prototype
|
|
4077
|
+
// (process-wide pollution) instead of the result object.
|
|
4078
|
+
if (isUnsafeKey(keyParts[0])) {
|
|
4079
|
+
continue;
|
|
4080
|
+
}
|
|
4015
4081
|
if (keyParts.length === 1) {
|
|
4016
4082
|
// content
|
|
4017
4083
|
let key = keyParts[0];
|
|
@@ -4080,7 +4146,11 @@ class ImapFlow extends EventEmitter {
|
|
|
4080
4146
|
}
|
|
4081
4147
|
|
|
4082
4148
|
for (let part of Object.keys(data)) {
|
|
4083
|
-
|
|
4149
|
+
// `meta` is only built from the companion BODY[<part>.MIME] item. A server may
|
|
4150
|
+
// legally answer with fewer items than were requested, and one part arriving
|
|
4151
|
+
// without its MIME headers must not cost the caller the whole download.
|
|
4152
|
+
let meta = data[part].meta || {};
|
|
4153
|
+
data[part].meta = meta;
|
|
4084
4154
|
|
|
4085
4155
|
// parts that arrived via FETCH BINARY (response.binaryParts) are already
|
|
4086
4156
|
// decoded by the server - decoding again would corrupt the data
|
package/lib/jp-decoder.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { Transform } = require('stream');
|
|
4
4
|
const encodingJapanese = require('encoding-japanese');
|
|
5
|
+
const { normalizeByteLimit } = require('./limited-passthrough.js');
|
|
5
6
|
|
|
6
7
|
// A Transform stream for decoding Japanese character sets (Shift_JIS, EUC-JP, ISO-2022-JP).
|
|
7
8
|
// Unlike iconv-lite which can decode incrementally, encoding-japanese requires the complete
|
|
@@ -9,23 +10,47 @@ const encodingJapanese = require('encoding-japanese');
|
|
|
9
10
|
// which uses escape sequences to switch between ASCII and multi-byte modes). Therefore,
|
|
10
11
|
// this stream buffers all input during _transform and performs the actual decoding in _flush.
|
|
11
12
|
class JPDecoder extends Transform {
|
|
12
|
-
constructor(charset) {
|
|
13
|
+
constructor(charset, maxBytes) {
|
|
13
14
|
super();
|
|
14
15
|
|
|
15
16
|
this.charset = charset;
|
|
16
17
|
this.chunks = [];
|
|
17
18
|
this.chunklen = 0;
|
|
19
|
+
|
|
20
|
+
// Upper bound for the buffered bytes, normalized the same way LimitedPassthrough
|
|
21
|
+
// normalizes its own. The whole-input buffering defeats a downstream maxBytes limiter
|
|
22
|
+
// (nothing is emitted until _flush), so without an internal bound a server could force
|
|
23
|
+
// unbounded memory use through a caller that asked for a limited download. Excess input
|
|
24
|
+
// is truncated, mirroring the truncation a maxBytes download applies anyway.
|
|
25
|
+
this.maxBytes = normalizeByteLimit(maxBytes);
|
|
26
|
+
|
|
27
|
+
// Also mirroring LimitedPassthrough: true once the bound is reached and every further
|
|
28
|
+
// chunk is being discarded. The download loop reads this to stop pulling from the
|
|
29
|
+
// server, which the limiter at the tail of the pipeline cannot tell it, because nothing
|
|
30
|
+
// is emitted from here until _flush().
|
|
31
|
+
this.limited = false;
|
|
18
32
|
}
|
|
19
33
|
|
|
20
|
-
// Buffer all incoming chunks; no decoding happens here because
|
|
21
|
-
// require the complete input for accurate conversion.
|
|
34
|
+
// Buffer all incoming chunks (up to maxBytes); no decoding happens here because
|
|
35
|
+
// Japanese charsets require the complete input for accurate conversion.
|
|
22
36
|
_transform(chunk, encoding, done) {
|
|
23
37
|
if (typeof chunk === 'string') {
|
|
24
38
|
chunk = Buffer.from(chunk, encoding);
|
|
25
39
|
}
|
|
26
40
|
|
|
27
|
-
this.
|
|
28
|
-
|
|
41
|
+
if (this.chunklen + chunk.length > this.maxBytes) {
|
|
42
|
+
chunk = chunk.slice(0, Math.max(0, this.maxBytes - this.chunklen));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (chunk.length) {
|
|
46
|
+
this.chunks.push(chunk);
|
|
47
|
+
this.chunklen += chunk.length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (this.chunklen >= this.maxBytes) {
|
|
51
|
+
this.limited = true;
|
|
52
|
+
}
|
|
53
|
+
|
|
29
54
|
done();
|
|
30
55
|
}
|
|
31
56
|
|
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
const { Transform } = require('stream');
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes a byte budget for the download pipeline. Any finite positive number is honored and
|
|
7
|
+
* floored, because byte counts are integers: with a fractional bound a counter can only ever
|
|
8
|
+
* reach its floor, so a stage would never report itself full and a loop polling that flag would
|
|
9
|
+
* keep pulling forever. Anything else - 0, NaN, a non-numeric value - means "no limit".
|
|
10
|
+
*
|
|
11
|
+
* Lives here rather than in tools.js because tools.js requires jp-decoder.js, which needs this.
|
|
12
|
+
*
|
|
13
|
+
* @param {*} value - The configured budget.
|
|
14
|
+
* @returns {Number} The normalized budget, or Infinity when unbounded.
|
|
15
|
+
*/
|
|
16
|
+
const normalizeByteLimit = value => {
|
|
17
|
+
let bytes = Number(value);
|
|
18
|
+
// Math.max keeps a sub-1 budget from flooring to 0, which would read back as "no limit"
|
|
19
|
+
return Number.isFinite(bytes) && bytes > 0 ? Math.max(Math.floor(bytes), 1) : Infinity;
|
|
20
|
+
};
|
|
21
|
+
|
|
5
22
|
// A Transform stream that passes through data up to a maximum byte limit,
|
|
6
23
|
// then silently discards all subsequent chunks. Used to enforce download
|
|
7
24
|
// size limits when fetching message content from the IMAP server.
|
|
@@ -9,7 +26,7 @@ class LimitedPassthrough extends Transform {
|
|
|
9
26
|
constructor(options) {
|
|
10
27
|
super();
|
|
11
28
|
this.options = options || {};
|
|
12
|
-
this.maxBytes = this.options.maxBytes
|
|
29
|
+
this.maxBytes = normalizeByteLimit(this.options.maxBytes);
|
|
13
30
|
this.processed = 0;
|
|
14
31
|
// Once set to true, all subsequent chunks are dropped without error
|
|
15
32
|
this.limited = false;
|
|
@@ -42,3 +59,4 @@ class LimitedPassthrough extends Transform {
|
|
|
42
59
|
}
|
|
43
60
|
|
|
44
61
|
module.exports.LimitedPassthrough = LimitedPassthrough;
|
|
62
|
+
module.exports.normalizeByteLimit = normalizeByteLimit;
|