file-type 21.3.3 → 21.3.4

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 (4) hide show
  1. package/core.js +157 -50
  2. package/index.js +55 -18
  3. package/package.json +1 -1
  4. package/readme.md +1 -1
package/core.js CHANGED
@@ -24,6 +24,7 @@ const maximumUnknownSizePayloadProbeSizeInBytes = maximumZipEntrySizeInBytes;
24
24
  const maximumZipTextEntrySizeInBytes = maximumZipEntrySizeInBytes;
25
25
  const maximumNestedGzipDetectionSizeInBytes = maximumUntrustedSkipSizeInBytes;
26
26
  const maximumNestedGzipProbeDepth = 1;
27
+ const unknownSizeGzipProbeTimeoutInMilliseconds = 100;
27
28
  const maximumId3HeaderSizeInBytes = maximumUntrustedSkipSizeInBytes;
28
29
  const maximumEbmlDocumentTypeSizeInBytes = 64;
29
30
  const maximumEbmlElementPayloadSizeInBytes = maximumUnknownSizePayloadProbeSizeInBytes;
@@ -47,6 +48,7 @@ const recoverableZipErrorMessagePrefixes = [
47
48
  'Unsupported ZIP compression method:',
48
49
  'ZIP entry compressed data exceeds ',
49
50
  'ZIP entry decompressed data exceeds ',
51
+ 'Expected data-descriptor-signature at position ',
50
52
  ];
51
53
  const recoverableZipErrorCodes = new Set([
52
54
  'Z_BUF_ERROR',
@@ -56,6 +58,27 @@ const recoverableZipErrorCodes = new Set([
56
58
 
57
59
  class ParserHardLimitError extends Error {}
58
60
 
61
+ function patchWebByobTokenizerClose(tokenizer) {
62
+ const streamReader = tokenizer?.streamReader;
63
+ if (streamReader?.constructor?.name !== 'WebStreamByobReader') {
64
+ return tokenizer;
65
+ }
66
+
67
+ const {reader} = streamReader;
68
+ const cancelAndRelease = async () => {
69
+ await reader.cancel();
70
+ reader.releaseLock();
71
+ };
72
+
73
+ streamReader.close = cancelAndRelease;
74
+ streamReader.abort = async () => {
75
+ streamReader.interrupted = true;
76
+ await cancelAndRelease();
77
+ };
78
+
79
+ return tokenizer;
80
+ }
81
+
59
82
  function getSafeBound(value, maximum, reason) {
60
83
  if (
61
84
  !Number.isFinite(value)
@@ -533,7 +556,8 @@ function _check(buffer, headers, options) {
533
556
  }
534
557
 
535
558
  export function normalizeSampleSize(sampleSize) {
536
- // Accept odd caller input, but preserve valid caller-requested probe depth.
559
+ // `sampleSize` is an explicit caller-controlled tuning knob, not untrusted file input.
560
+ // Preserve valid caller-requested probe depth here; applications must bound attacker-derived option values themselves.
537
561
  if (!Number.isFinite(sampleSize)) {
538
562
  return reasonableDetectionSizeInBytes;
539
563
  }
@@ -541,6 +565,45 @@ export function normalizeSampleSize(sampleSize) {
541
565
  return Math.max(1, Math.trunc(sampleSize));
542
566
  }
543
567
 
568
+ function readByobReaderWithSignal(reader, buffer, signal) {
569
+ if (signal === undefined) {
570
+ return reader.read(buffer);
571
+ }
572
+
573
+ signal.throwIfAborted();
574
+
575
+ return new Promise((resolve, reject) => {
576
+ const cleanup = () => {
577
+ signal.removeEventListener('abort', onAbort);
578
+ };
579
+
580
+ const onAbort = () => {
581
+ const abortReason = signal.reason;
582
+ cleanup();
583
+
584
+ (async () => {
585
+ try {
586
+ await reader.cancel(abortReason);
587
+ } catch {}
588
+ })();
589
+
590
+ reject(abortReason);
591
+ };
592
+
593
+ signal.addEventListener('abort', onAbort, {once: true});
594
+ (async () => {
595
+ try {
596
+ const result = await reader.read(buffer);
597
+ cleanup();
598
+ resolve(result);
599
+ } catch (error) {
600
+ cleanup();
601
+ reject(error);
602
+ }
603
+ })();
604
+ });
605
+ }
606
+
544
607
  function normalizeMpegOffsetTolerance(mpegOffsetTolerance) {
545
608
  // This value controls scan depth and therefore worst-case CPU work.
546
609
  if (!Number.isFinite(mpegOffsetTolerance)) {
@@ -752,7 +815,11 @@ export class FileTypeParser {
752
815
  };
753
816
  }
754
817
 
755
- async fromTokenizer(tokenizer, detectionReentryCount = 0) {
818
+ createTokenizerFromWebStream(stream) {
819
+ return patchWebByobTokenizerClose(strtok3.fromWebStream(stream, this.getTokenizerOptions()));
820
+ }
821
+
822
+ async parseTokenizer(tokenizer, detectionReentryCount = 0) {
756
823
  this.detectionReentryCount = detectionReentryCount;
757
824
  const initialPosition = tokenizer.position;
758
825
  // Iterate through all file-type detectors
@@ -782,6 +849,14 @@ export class FileTypeParser {
782
849
  }
783
850
  }
784
851
 
852
+ async fromTokenizer(tokenizer) {
853
+ try {
854
+ return await this.parseTokenizer(tokenizer);
855
+ } finally {
856
+ await tokenizer.close();
857
+ }
858
+ }
859
+
785
860
  async fromBuffer(input) {
786
861
  if (!(input instanceof Uint8Array || input instanceof ArrayBuffer)) {
787
862
  throw new TypeError(`Expected the \`input\` argument to be of type \`Uint8Array\` or \`ArrayBuffer\`, got \`${typeof input}\``);
@@ -797,21 +872,15 @@ export class FileTypeParser {
797
872
  }
798
873
 
799
874
  async fromBlob(blob) {
875
+ this.options.signal?.throwIfAborted();
800
876
  const tokenizer = strtok3.fromBlob(blob, this.getTokenizerOptions());
801
- try {
802
- return await this.fromTokenizer(tokenizer);
803
- } finally {
804
- await tokenizer.close();
805
- }
877
+ return this.fromTokenizer(tokenizer);
806
878
  }
807
879
 
808
880
  async fromStream(stream) {
809
- const tokenizer = strtok3.fromWebStream(stream, this.getTokenizerOptions());
810
- try {
811
- return await this.fromTokenizer(tokenizer);
812
- } finally {
813
- await tokenizer.close();
814
- }
881
+ this.options.signal?.throwIfAborted();
882
+ const tokenizer = this.createTokenizerFromWebStream(stream);
883
+ return this.fromTokenizer(tokenizer);
815
884
  }
816
885
 
817
886
  async toDetectionStream(stream, options) {
@@ -822,7 +891,7 @@ export class FileTypeParser {
822
891
  const reader = stream.getReader({mode: 'byob'});
823
892
  try {
824
893
  // Read the first chunk from the stream
825
- const {value: chunk, done} = await reader.read(new Uint8Array(sampleSize));
894
+ const {value: chunk, done} = await readByobReaderWithSignal(reader, new Uint8Array(sampleSize), this.options.signal);
826
895
  firstChunk = chunk;
827
896
  if (!done && chunk) {
828
897
  try {
@@ -859,6 +928,71 @@ export class FileTypeParser {
859
928
  return newStream;
860
929
  }
861
930
 
931
+ async detectGzip(tokenizer) {
932
+ if (this.gzipProbeDepth >= maximumNestedGzipProbeDepth) {
933
+ return {
934
+ ext: 'gz',
935
+ mime: 'application/gzip',
936
+ };
937
+ }
938
+
939
+ const gzipHandler = new GzipHandler(tokenizer);
940
+ const limitedInflatedStream = createByteLimitedReadableStream(gzipHandler.inflate(), maximumNestedGzipDetectionSizeInBytes);
941
+ const hasUnknownSize = hasUnknownFileSize(tokenizer);
942
+ let timeout;
943
+ let probeSignal;
944
+ let probeParser;
945
+ let compressedFileType;
946
+
947
+ if (hasUnknownSize) {
948
+ const timeoutController = new AbortController();
949
+ timeout = setTimeout(() => {
950
+ timeoutController.abort(new DOMException(`Operation timed out after ${unknownSizeGzipProbeTimeoutInMilliseconds} ms`, 'TimeoutError'));
951
+ }, unknownSizeGzipProbeTimeoutInMilliseconds);
952
+ probeSignal = this.options.signal === undefined
953
+ ? timeoutController.signal
954
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
955
+ : AbortSignal.any([this.options.signal, timeoutController.signal]);
956
+ probeParser = new FileTypeParser({
957
+ ...this.options,
958
+ signal: probeSignal,
959
+ });
960
+ probeParser.gzipProbeDepth = this.gzipProbeDepth + 1;
961
+ } else {
962
+ this.gzipProbeDepth++;
963
+ }
964
+
965
+ try {
966
+ compressedFileType = await (probeParser ?? this).fromStream(limitedInflatedStream);
967
+ } catch (error) {
968
+ if (
969
+ error?.name === 'AbortError'
970
+ && probeSignal?.reason?.name !== 'TimeoutError'
971
+ ) {
972
+ throw error;
973
+ }
974
+
975
+ // Timeout, decompression, or inner-detection failures are expected for non-tar gzip files.
976
+ } finally {
977
+ clearTimeout(timeout);
978
+ if (!hasUnknownSize) {
979
+ this.gzipProbeDepth--;
980
+ }
981
+ }
982
+
983
+ if (compressedFileType?.ext === 'tar') {
984
+ return {
985
+ ext: 'tar.gz',
986
+ mime: 'application/gzip',
987
+ };
988
+ }
989
+
990
+ return {
991
+ ext: 'gz',
992
+ mime: 'application/gzip',
993
+ };
994
+ }
995
+
862
996
  check(header, options) {
863
997
  return _check(this.buffer, header, options);
864
998
  }
@@ -878,6 +1012,13 @@ export class FileTypeParser {
878
1012
 
879
1013
  this.tokenizer = tokenizer;
880
1014
 
1015
+ if (hasUnknownFileSize(tokenizer)) {
1016
+ await tokenizer.peekBuffer(this.buffer, {length: 3, mayBeLess: true});
1017
+ if (this.check([0x1F, 0x8B, 0x8])) {
1018
+ return this.detectGzip(tokenizer);
1019
+ }
1020
+ }
1021
+
881
1022
  await tokenizer.peekBuffer(this.buffer, {length: 32, mayBeLess: true});
882
1023
 
883
1024
  // -- 2-byte signatures --
@@ -981,41 +1122,7 @@ export class FileTypeParser {
981
1122
  }
982
1123
 
983
1124
  if (this.check([0x1F, 0x8B, 0x8])) {
984
- if (this.gzipProbeDepth >= maximumNestedGzipProbeDepth) {
985
- return {
986
- ext: 'gz',
987
- mime: 'application/gzip',
988
- };
989
- }
990
-
991
- const gzipHandler = new GzipHandler(tokenizer);
992
- const limitedInflatedStream = createByteLimitedReadableStream(gzipHandler.inflate(), maximumNestedGzipDetectionSizeInBytes);
993
- let compressedFileType;
994
- try {
995
- this.gzipProbeDepth++;
996
- compressedFileType = await this.fromStream(limitedInflatedStream);
997
- } catch (error) {
998
- if (error?.name === 'AbortError') {
999
- throw error;
1000
- }
1001
-
1002
- // Decompression or inner-detection failures are expected for non-tar gzip files.
1003
- } finally {
1004
- this.gzipProbeDepth--;
1005
- }
1006
-
1007
- // We only need enough inflated bytes to confidently decide whether this is tar.gz.
1008
- if (compressedFileType?.ext === 'tar') {
1009
- return {
1010
- ext: 'tar.gz',
1011
- mime: 'application/gzip',
1012
- };
1013
- }
1014
-
1015
- return {
1016
- ext: 'gz',
1017
- mime: 'application/gzip',
1018
- };
1125
+ return this.detectGzip(tokenizer);
1019
1126
  }
1020
1127
 
1021
1128
  if (this.check([0x42, 0x5A, 0x68])) {
@@ -1076,7 +1183,7 @@ export class FileTypeParser {
1076
1183
  }
1077
1184
 
1078
1185
  this.detectionReentryCount++;
1079
- return this.fromTokenizer(tokenizer, this.detectionReentryCount); // Skip ID3 header, recursion
1186
+ return this.parseTokenizer(tokenizer, this.detectionReentryCount); // Skip ID3 header, recursion
1080
1187
  }
1081
1188
 
1082
1189
  // Musepack, SV7
package/index.js CHANGED
@@ -29,7 +29,8 @@ function isTokenizerStreamBoundsError(error) {
29
29
 
30
30
  export class FileTypeParser extends DefaultFileTypeParser {
31
31
  async fromStream(stream) {
32
- const tokenizer = await (stream instanceof WebReadableStream ? strtok3.fromWebStream(stream, this.getTokenizerOptions()) : strtok3.fromStream(stream, this.getTokenizerOptions()));
32
+ this.options.signal?.throwIfAborted();
33
+ const tokenizer = await (stream instanceof WebReadableStream ? this.createTokenizerFromWebStream(stream) : strtok3.fromStream(stream, this.getTokenizerOptions()));
33
34
  try {
34
35
  return await super.fromTokenizer(tokenizer);
35
36
  } catch (error) {
@@ -39,11 +40,18 @@ export class FileTypeParser extends DefaultFileTypeParser {
39
40
 
40
41
  throw error;
41
42
  } finally {
42
- await tokenizer.close();
43
+ // TODO: Remove this when `strtok3.fromStream()` closes the underlying Readable instead of only aborting tokenizer reads.
44
+ if (
45
+ stream instanceof Readable
46
+ && !stream.destroyed
47
+ ) {
48
+ stream.destroy();
49
+ }
43
50
  }
44
51
  }
45
52
 
46
53
  async fromFile(path) {
54
+ this.options.signal?.throwIfAborted();
47
55
  // TODO: Remove this when `strtok3.fromFile()` safely rejects non-regular filesystem objects without a pathname race.
48
56
  const fileHandle = await fs.open(path, fileSystemConstants.O_RDONLY | fileSystemConstants.O_NONBLOCK);
49
57
  const fileStat = await fileHandle.stat();
@@ -59,11 +67,7 @@ export class FileTypeParser extends DefaultFileTypeParser {
59
67
  size: fileStat.size,
60
68
  },
61
69
  });
62
- try {
63
- return await super.fromTokenizer(tokenizer);
64
- } finally {
65
- await tokenizer.close();
66
- }
70
+ return super.fromTokenizer(tokenizer);
67
71
  }
68
72
 
69
73
  async toDetectionStream(readableStream, options = {}) {
@@ -71,36 +75,69 @@ export class FileTypeParser extends DefaultFileTypeParser {
71
75
  return super.toDetectionStream(readableStream, options);
72
76
  }
73
77
 
74
- const sampleSize = normalizeSampleSize(options.sampleSize ?? reasonableDetectionSizeInBytes);
78
+ const {sampleSize = reasonableDetectionSizeInBytes} = options;
79
+ const {signal} = this.options;
80
+ const normalizedSampleSize = normalizeSampleSize(sampleSize);
81
+
82
+ signal?.throwIfAborted();
75
83
 
76
84
  return new Promise((resolve, reject) => {
77
- readableStream.on('error', reject);
85
+ let isSettled = false;
86
+
87
+ const cleanup = () => {
88
+ readableStream.off('error', onError);
89
+ readableStream.off('readable', onReadable);
90
+ signal?.removeEventListener('abort', onAbort);
91
+ };
78
92
 
79
- readableStream.once('readable', () => {
93
+ const settle = (callback, value) => {
94
+ if (isSettled) {
95
+ return;
96
+ }
97
+
98
+ isSettled = true;
99
+ cleanup();
100
+ callback(value);
101
+ };
102
+
103
+ const onError = error => {
104
+ settle(reject, error);
105
+ };
106
+
107
+ const onAbort = () => {
108
+ if (!readableStream.destroyed) {
109
+ readableStream.destroy();
110
+ }
111
+
112
+ settle(reject, signal.reason);
113
+ };
114
+
115
+ const onReadable = () => {
80
116
  (async () => {
81
117
  try {
82
- // Set up output stream
83
118
  const pass = new PassThrough();
84
119
  const outputStream = pipeline ? pipeline(readableStream, pass, () => {}) : readableStream.pipe(pass);
85
-
86
- // Read the input stream and detect the filetype
87
- const chunk = readableStream.read(sampleSize) ?? readableStream.read() ?? new Uint8Array(0);
120
+ const chunk = readableStream.read(normalizedSampleSize) ?? readableStream.read() ?? new Uint8Array(0);
88
121
  try {
89
122
  pass.fileType = await this.fromBuffer(chunk);
90
123
  } catch (error) {
91
124
  if (error instanceof strtok3.EndOfStreamError) {
92
125
  pass.fileType = undefined;
93
126
  } else {
94
- reject(error);
127
+ settle(reject, error);
95
128
  }
96
129
  }
97
130
 
98
- resolve(outputStream);
131
+ settle(resolve, outputStream);
99
132
  } catch (error) {
100
- reject(error);
133
+ settle(reject, error);
101
134
  }
102
135
  })();
103
- });
136
+ };
137
+
138
+ readableStream.on('error', onError);
139
+ readableStream.once('readable', onReadable);
140
+ signal?.addEventListener('abort', onAbort, {once: true});
104
141
  });
105
142
  }
106
143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "file-type",
3
- "version": "21.3.3",
3
+ "version": "21.3.4",
4
4
  "description": "Detect the file type of a file, stream, or data",
5
5
  "license": "MIT",
6
6
  "repository": "sindresorhus/file-type",
package/readme.md CHANGED
@@ -441,7 +441,7 @@ import {FileTypeParser} from 'file-type';
441
441
 
442
442
  const abortController = new AbortController()
443
443
 
444
- const parser = new FileTypeParser({abortSignal: abortController.signal});
444
+ const parser = new FileTypeParser({signal: abortController.signal});
445
445
 
446
446
  const promise = parser.fromStream(blob.stream());
447
447