music-metadata 12.1.0 → 12.1.1

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/README.md CHANGED
@@ -611,6 +611,17 @@ Returns a list of supported MIME-types. This may include some MIME-types which a
611
611
  Files, buffers, and Blobs support chapters whether `moov` appears before or after `mdat`.
612
612
  For forward-only streams, `moov` must appear before the chapter data.
613
613
 
614
+ - `maxMemoryAllocation`: `number` (default: `67108864`, 64 MiB)
615
+
616
+ Maximum bytes buffered for a single metadata payload, accumulated Ogg packet, or tokenizer stream lookahead.
617
+ Must be a positive safe integer. Parsing rejects payloads above this limit before allocating them.
618
+ The parser configures this limit once on its `strtok3` tokenizers, including tokenizers for embedded tags.
619
+ Oversized tokenizer allocations or stream lookahead reject with `strtok3.BufferSizeError`.
620
+ When using `parseFromTokenizer()`, also configure `maxBufferSize` on the supplied tokenizer at creation.
621
+ Increase it for files with unusually large metadata or cover art, or lower it for constrained environments.
622
+ Audio/video data that is skipped does not count toward this limit. This does not cap total process memory:
623
+ decoded metadata, multiple retained tags, and temporary copies can require additional memory.
624
+
614
625
  - `mkvUseIndex`: `boolean` (default: `false`)
615
626
 
616
627
  When `true`, the parser uses the SeekHead index in Matroska (MKV) files to skip segment and cluster elements.
@@ -5,6 +5,7 @@ import { parse as mimeTypeParse } from 'media-typer';
5
5
  import { aiffParserLoader } from './aiff/AiffLoader.js';
6
6
  import { apeParserLoader } from './apev2/Apev2Loader.js';
7
7
  import { asfParserLoader } from './asf/AsfLoader.js';
8
+ import { getMaxMemoryAllocation } from './common/Allocation.js';
8
9
  import { MetadataCollector } from './common/MetadataCollector.js';
9
10
  import { createAudioTrackInfo } from './common/TrackInfo.js';
10
11
  import { scanAppendingHeaders } from './core.js';
@@ -56,6 +57,7 @@ export class ParserFactory {
56
57
  this.parsers.push(parser);
57
58
  }
58
59
  async parse(tokenizer, parserLoader, opts = {}) {
60
+ getMaxMemoryAllocation(opts);
59
61
  if (tokenizer.supportsRandomAccess()) {
60
62
  debug('tokenizer supports random-access, scanning for appending headers');
61
63
  await scanAppendingHeaders(tokenizer, opts);
@@ -90,7 +92,7 @@ export class ParserFactory {
90
92
  debug(`Loading ${parserLoader.parserType} parser...`);
91
93
  const metadata = new MetadataCollector(opts);
92
94
  const ParserImpl = await parserLoader.load();
93
- const parser = new ParserImpl(metadata, tokenizer, opts ?? {});
95
+ const parser = new ParserImpl(metadata, tokenizer, opts);
94
96
  debug(`Parser ${parserLoader.parserType} loaded`);
95
97
  await parser.parse();
96
98
  // Audio-only parsers describe their single stream through the format fields.
@@ -1,6 +1,7 @@
1
1
  import initDebug from 'debug';
2
2
  import * as strtok3 from 'strtok3';
3
3
  import * as Token from 'token-types';
4
+ import { getTokenizerOptions } from '../common/Allocation.js';
4
5
  import { BasicParser } from '../common/BasicParser.js';
5
6
  import { FourCcToken } from '../common/FourCC.js';
6
7
  import { ID3v2Parser } from '../id3v2/ID3v2Parser.js';
@@ -18,11 +19,11 @@ const debug = initDebug('music-metadata:parser:aiff');
18
19
  export class AIFFParser extends BasicParser {
19
20
  isCompressed = null;
20
21
  async parse() {
21
- const header = await this.tokenizer.readToken(iff.Header);
22
+ const header = await this.readToken(iff.Header);
22
23
  if (header.chunkID !== 'FORM') {
23
24
  throw new AiffContentError("Invalid Chunk-ID, expected 'FORM'"); // Not AIFF format
24
25
  }
25
- const type = await this.tokenizer.readToken(FourCcToken);
26
+ const type = await this.readToken(FourCcToken);
26
27
  switch (type) {
27
28
  case 'AIFF':
28
29
  this.metadata.setFormat('container', type);
@@ -41,7 +42,7 @@ export class AIFFParser extends BasicParser {
41
42
  while (!this.tokenizer.fileInfo.size ||
42
43
  this.tokenizer.fileInfo.size - this.tokenizer.position >= iff.Header.len) {
43
44
  debug(`Reading AIFF chunk at offset=${this.tokenizer.position}`);
44
- const chunkHeader = await this.tokenizer.readToken(iff.Header);
45
+ const chunkHeader = await this.readToken(iff.Header);
45
46
  const nextChunk = 2 * Math.round(chunkHeader.chunkSize / 2);
46
47
  const bytesRead = await this.readData(chunkHeader);
47
48
  await this.tokenizer.ignore(nextChunk - bytesRead);
@@ -63,7 +64,7 @@ export class AIFFParser extends BasicParser {
63
64
  if (this.isCompressed === null) {
64
65
  throw new AiffContentError('Failed to parse AIFF.COMM chunk when compression type is unknown');
65
66
  }
66
- const common = await this.tokenizer.readToken(new AiffToken.Common(header, this.isCompressed));
67
+ const common = await this.readToken(new AiffToken.Common(header, this.isCompressed));
67
68
  this.metadata.setFormat('bitsPerSample', common.sampleSize);
68
69
  this.metadata.setFormat('sampleRate', common.sampleRate);
69
70
  this.metadata.setFormat('numberOfChannels', common.numChannels);
@@ -76,8 +77,8 @@ export class AIFFParser extends BasicParser {
76
77
  }
77
78
  case 'ID3 ': {
78
79
  // ID3-meta-data
79
- const id3_data = await this.tokenizer.readToken(new Token.Uint8ArrayType(header.chunkSize));
80
- const rst = strtok3.fromBuffer(id3_data);
80
+ const id3_data = await this.readToken(new Token.Uint8ArrayType(header.chunkSize));
81
+ const rst = strtok3.fromBuffer(id3_data, getTokenizerOptions(this.options));
81
82
  await new ID3v2Parser().parse(this.metadata, rst, this.options);
82
83
  return header.chunkSize;
83
84
  }
@@ -97,7 +98,7 @@ export class AIFFParser extends BasicParser {
97
98
  }
98
99
  }
99
100
  async readTextChunk(header) {
100
- const value = await this.tokenizer.readToken(new Token.StringType(header.chunkSize, 'ascii'));
101
+ const value = await this.readToken(new Token.StringType(header.chunkSize, 'ascii'));
101
102
  const values = value
102
103
  .split('\0')
103
104
  .map(v => v.trim())
@@ -2,6 +2,7 @@ import { textDecode } from '@borewit/text-codec';
2
2
  import initDebug from 'debug';
3
3
  import * as strtok3 from 'strtok3';
4
4
  import { StringType } from 'token-types';
5
+ import { getTokenizerOptions, readBuffer } from '../common/Allocation.js';
5
6
  import { BasicParser } from '../common/BasicParser.js';
6
7
  import * as util from '../common/Util.js';
7
8
  import { makeUnexpectedFileContentError } from '../ParseError.js';
@@ -60,8 +61,7 @@ export class APEv2Parser extends BasicParser {
60
61
  if (footer.ID !== preamble) {
61
62
  throw new ApeContentError('Unexpected APEv2 Footer ID preamble value');
62
63
  }
63
- strtok3.fromBuffer(buffer);
64
- const apeParser = new APEv2Parser(metadata, strtok3.fromBuffer(buffer), options);
64
+ const apeParser = new APEv2Parser(metadata, strtok3.fromBuffer(buffer, getTokenizerOptions(options)), options);
65
65
  return apeParser.parseTags(footer);
66
66
  }
67
67
  ape = {};
@@ -82,8 +82,7 @@ export class APEv2Parser extends BasicParser {
82
82
  if (this.tokenizer.fileInfo.size) {
83
83
  // Try to read the APEv2 header using just the footer-header
84
84
  const remaining = this.tokenizer.fileInfo.size - this.tokenizer.position; // ToDo: take ID3v1 into account
85
- const buffer = new Uint8Array(remaining);
86
- await this.tokenizer.readBuffer(buffer);
85
+ const buffer = await this.readTagValue(remaining);
87
86
  return APEv2Parser.parseTagFooter(this.metadata, buffer, this.options);
88
87
  }
89
88
  }
@@ -167,28 +166,7 @@ export class APEv2Parser extends BasicParser {
167
166
  }
168
167
  }
169
168
  async readTagValue(size) {
170
- const chunkSize = 64 * 1024;
171
- if ((!this.tokenizer.supportsRandomAccess() || this.tokenizer.fileInfo.size === undefined) && size > chunkSize) {
172
- // Stream sizes may be unknown or untrustworthy. Only allocate the full
173
- // value after all of its bytes have actually been read.
174
- const chunks = [];
175
- for (let remaining = size; remaining > 0;) {
176
- const chunk = new Uint8Array(Math.min(remaining, chunkSize));
177
- await this.tokenizer.readBuffer(chunk);
178
- chunks.push(chunk);
179
- remaining -= chunk.length;
180
- }
181
- const value = new Uint8Array(size);
182
- let offset = 0;
183
- for (const chunk of chunks) {
184
- value.set(chunk, offset);
185
- offset += chunk.length;
186
- }
187
- return value;
188
- }
189
- const value = new Uint8Array(size);
190
- await this.tokenizer.readBuffer(value);
191
- return value;
169
+ return readBuffer(this.tokenizer, size, this.options, undefined, message => new ApeContentError(message));
192
170
  }
193
171
  async parseDescriptorExpansion(lenExp) {
194
172
  await this.tokenizer.ignore(lenExp);
@@ -1,6 +1,7 @@
1
1
  import initDebug from 'debug';
2
2
  import { EndOfStreamError } from 'strtok3';
3
3
  import * as Token from 'token-types';
4
+ import { validateAllocation } from '../common/Allocation.js';
4
5
  import { BasicParser } from '../common/BasicParser.js';
5
6
  import { TrackType } from '../type.js';
6
7
  import AsfGuid from './AsfGuid.js';
@@ -8,7 +9,6 @@ import * as AsfObject from './AsfObject.js';
8
9
  import { AsfContentParseError } from './AsfObject.js';
9
10
  const debug = initDebug('music-metadata:parser:ASF');
10
11
  const headerType = 'asf';
11
- const maxAsfMetadataObjectSize = 16 * 1024 * 1024;
12
12
  /**
13
13
  * Windows Media Metadata Usage Guidelines
14
14
  * - Ref: https://msdn.microsoft.com/en-us/library/ms867702.aspx
@@ -24,7 +24,7 @@ export class AsfParser extends BasicParser {
24
24
  codecs = [];
25
25
  streamBitrates = new Map();
26
26
  async parse() {
27
- const header = await this.tokenizer.readToken(AsfObject.TopLevelHeaderObjectToken);
27
+ const header = await this.readToken(AsfObject.TopLevelHeaderObjectToken);
28
28
  if (!header.objectId.equals(AsfGuid.HeaderObject)) {
29
29
  throw new AsfContentParseError(`expected asf header; but was not found; got: ${header.objectId.str}`);
30
30
  }
@@ -89,7 +89,7 @@ export class AsfParser extends BasicParser {
89
89
  case AsfObject.FilePropertiesObject.guid.str: {
90
90
  // 3.2
91
91
  this.validateAllocationSize(payloadSize, 'File Properties Object');
92
- const fpo = await this.tokenizer.readToken(new AsfObject.FilePropertiesObject(header));
92
+ const fpo = await this.readToken(new AsfObject.FilePropertiesObject(header));
93
93
  this.metadata.setFormat('duration', Number(fpo.playDuration / BigInt(1000)) / 10000 - Number(fpo.preroll) / 1000);
94
94
  this.metadata.setFormat('bitrate', fpo.maximumBitrate);
95
95
  break;
@@ -97,7 +97,7 @@ export class AsfParser extends BasicParser {
97
97
  case AsfObject.StreamPropertiesObject.guid.str: {
98
98
  // 3.3
99
99
  this.validateAllocationSize(payloadSize, 'Stream Properties Object');
100
- const spo = await this.tokenizer.readToken(new AsfObject.StreamPropertiesObject(header));
100
+ const spo = await this.readToken(new AsfObject.StreamPropertiesObject(header));
101
101
  this.metadata.setFormat('container', `ASF/${spo.streamType}`);
102
102
  if (spo.streamType === 'audio') {
103
103
  this.metadata.setFormat('hasAudio', true);
@@ -114,7 +114,7 @@ export class AsfParser extends BasicParser {
114
114
  if (payloadSize < extensionHeaderToken.len) {
115
115
  throw new AsfContentParseError(`ASF Header Extension Object payload is too small: ${payloadSize} bytes`);
116
116
  }
117
- const extHeader = await this.tokenizer.readToken(extensionHeaderToken);
117
+ const extHeader = await this.readToken(extensionHeaderToken);
118
118
  const expectedExtensionSize = payloadSize - extensionHeaderToken.len;
119
119
  if (extHeader.extensionDataSize !== expectedExtensionSize) {
120
120
  throw new AsfContentParseError(`ASF extension data size ${extHeader.extensionDataSize} does not match enclosing payload size ${expectedExtensionSize}`);
@@ -124,12 +124,12 @@ export class AsfParser extends BasicParser {
124
124
  }
125
125
  case AsfObject.ContentDescriptionObjectState.guid.str: // 3.10
126
126
  this.validateAllocationSize(payloadSize, 'Content Description Object');
127
- tags = await this.tokenizer.readToken(new AsfObject.ContentDescriptionObjectState(header));
127
+ tags = await this.readToken(new AsfObject.ContentDescriptionObjectState(header));
128
128
  await this.addTags(tags);
129
129
  break;
130
130
  case AsfObject.ExtendedContentDescriptionObjectState.guid.str: // 3.11
131
131
  this.validateAllocationSize(payloadSize, 'Extended Content Description Object');
132
- tags = await this.tokenizer.readToken(new AsfObject.ExtendedContentDescriptionObjectState(header));
132
+ tags = await this.readToken(new AsfObject.ExtendedContentDescriptionObjectState(header));
133
133
  await this.addTags(tags);
134
134
  break;
135
135
  case AsfGuid.CodecListObject.str: {
@@ -154,13 +154,13 @@ export class AsfParser extends BasicParser {
154
154
  if (payloadSize < 2) {
155
155
  throw new AsfContentParseError('Truncated Stream Bitrate Properties Object');
156
156
  }
157
- const count = await this.tokenizer.readToken(Token.UINT16_LE);
157
+ const count = await this.readToken(Token.UINT16_LE);
158
158
  if (count * 6 > payloadSize - 2) {
159
159
  throw new AsfContentParseError('ASF stream bitrate records exceed object payload');
160
160
  }
161
161
  for (let index = 0; index < count; ++index) {
162
- const streamNumber = (await this.tokenizer.readToken(Token.UINT16_LE)) & 0x7f;
163
- this.streamBitrates.set(streamNumber, await this.tokenizer.readToken(Token.UINT32_LE));
162
+ const streamNumber = (await this.readToken(Token.UINT16_LE)) & 0x7f;
163
+ this.streamBitrates.set(streamNumber, await this.readToken(Token.UINT32_LE));
164
164
  }
165
165
  await this.ignorePayload(payloadSize - 2 - count * 6, 'Stream Bitrate Properties Object');
166
166
  break;
@@ -188,7 +188,7 @@ export class AsfParser extends BasicParser {
188
188
  if (remainingSize < AsfObject.HeaderObjectToken.len) {
189
189
  throw new AsfContentParseError(`Insufficient ASF ${containerType} data for an object header: ${remainingSize} bytes`);
190
190
  }
191
- const header = await this.tokenizer.readToken(AsfObject.HeaderObjectToken);
191
+ const header = await this.readToken(AsfObject.HeaderObjectToken);
192
192
  if (header.objectSize > remainingSize) {
193
193
  throw new AsfContentParseError(`ASF ${containerType} object size ${header.objectSize} exceeds remaining ${containerType} payload size ${remainingSize}`);
194
194
  }
@@ -209,9 +209,7 @@ export class AsfParser extends BasicParser {
209
209
  return payloadSize;
210
210
  }
211
211
  validateAllocationSize(payloadSize, objectType) {
212
- if (payloadSize > maxAsfMetadataObjectSize) {
213
- throw new AsfContentParseError(`ASF ${objectType} payload size ${payloadSize} exceeds allocation limit ${maxAsfMetadataObjectSize}`);
214
- }
212
+ validateAllocation(payloadSize, this.options, message => new AsfContentParseError(`ASF ${objectType}: ${message}`));
215
213
  }
216
214
  async ignorePayload(payloadSize, objectType) {
217
215
  let ignored;
@@ -236,7 +234,7 @@ export class AsfParser extends BasicParser {
236
234
  case AsfObject.ExtendedStreamPropertiesObjectState.guid.str: {
237
235
  // 4.1
238
236
  this.validateAllocationSize(payloadSize, 'Extended Stream Properties Object');
239
- const extended = await this.tokenizer.readToken(new AsfObject.ExtendedStreamPropertiesObjectState(header));
237
+ const extended = await this.readToken(new AsfObject.ExtendedStreamPropertiesObjectState(header));
240
238
  if (extended.streamPropertiesObject) {
241
239
  const stream = extended.streamPropertiesObject;
242
240
  this.streams.set(stream.streamNumber, stream);
@@ -247,14 +245,14 @@ export class AsfParser extends BasicParser {
247
245
  case AsfObject.MetadataObjectState.guid.str: {
248
246
  // 4.7
249
247
  this.validateAllocationSize(payloadSize, 'Metadata Object');
250
- const moTags = await this.tokenizer.readToken(new AsfObject.MetadataObjectState(header));
248
+ const moTags = await this.readToken(new AsfObject.MetadataObjectState(header));
251
249
  await this.addTags(moTags);
252
250
  break;
253
251
  }
254
252
  case AsfObject.MetadataLibraryObjectState.guid.str: {
255
253
  // 4.8
256
254
  this.validateAllocationSize(payloadSize, 'Metadata Library Object');
257
- const mlTags = await this.tokenizer.readToken(new AsfObject.MetadataLibraryObjectState(header));
255
+ const mlTags = await this.readToken(new AsfObject.MetadataLibraryObjectState(header));
258
256
  await this.addTags(mlTags);
259
257
  break;
260
258
  }
@@ -0,0 +1,14 @@
1
+ import type { IGetToken } from '@tokenizer/token';
2
+ import { type IFileInfo, type ITokenizer, type ITokenizerOptions } from 'strtok3';
3
+ import type { IOptions } from '../type.js';
4
+ type ErrorFactory = (message: string) => Error;
5
+ export declare function getMaxMemoryAllocation(options: IOptions): number;
6
+ /** Configure the tokenizer safety net once, using the same limit as parser-owned buffers. */
7
+ export declare function getTokenizerOptions(options: IOptions, fileInfo?: IFileInfo): ITokenizerOptions;
8
+ /** Check untrusted lengths before allocating, including accumulated stream payloads. */
9
+ export declare function validateAllocation(length: number, options: IOptions, makeError?: ErrorFactory): void;
10
+ /** Read only bounded chunks from streams; an advertised stream size does not prove bytes exist. */
11
+ export declare function readBuffer(tokenizer: ITokenizer, length: number, options: IOptions, position?: number, makeError?: ErrorFactory): Promise<Uint8Array>;
12
+ /** Bound custom tokenizer reads and buffer large stream payloads incrementally. */
13
+ export declare function readToken<T>(tokenizer: ITokenizer, token: IGetToken<T>, options: IOptions, position?: number, makeError?: ErrorFactory): Promise<T>;
14
+ export {};
@@ -0,0 +1,70 @@
1
+ import { EndOfStreamError } from 'strtok3';
2
+ import { UnexpectedFileContentError } from '../ParseError.js';
3
+ const defaultMaxMemoryAllocation = 64 * 1024 * 1024;
4
+ const readChunkSize = 64 * 1024;
5
+ const allocationError = message => new UnexpectedFileContentError('metadata', message);
6
+ export function getMaxMemoryAllocation(options) {
7
+ const limit = options.maxMemoryAllocation ?? defaultMaxMemoryAllocation;
8
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
9
+ throw new RangeError('maxMemoryAllocation must be a positive safe integer');
10
+ }
11
+ return limit;
12
+ }
13
+ /** Configure the tokenizer safety net once, using the same limit as parser-owned buffers. */
14
+ export function getTokenizerOptions(options, fileInfo) {
15
+ return { maxBufferSize: getMaxMemoryAllocation(options), fileInfo };
16
+ }
17
+ /** Check untrusted lengths before allocating, including accumulated stream payloads. */
18
+ export function validateAllocation(length, options, makeError = allocationError) {
19
+ if (!Number.isSafeInteger(length) || length < 0) {
20
+ throw makeError(`Invalid allocation size: ${length}`);
21
+ }
22
+ const limit = getMaxMemoryAllocation(options);
23
+ if (length > limit) {
24
+ throw makeError(`Metadata size ${length} exceeds allocation limit ${limit} (buffering limit)`);
25
+ }
26
+ }
27
+ function validateAvailableBytes(tokenizer, length, position) {
28
+ if (tokenizer.fileInfo.size !== undefined && length > tokenizer.fileInfo.size - position) {
29
+ throw new EndOfStreamError();
30
+ }
31
+ }
32
+ /** Read only bounded chunks from streams; an advertised stream size does not prove bytes exist. */
33
+ export async function readBuffer(tokenizer, length, options, position = tokenizer.position, makeError = allocationError) {
34
+ validateAllocation(length, options, makeError);
35
+ validateAvailableBytes(tokenizer, length, position);
36
+ if ((!tokenizer.supportsRandomAccess() || tokenizer.fileInfo.size === undefined) && length > readChunkSize) {
37
+ const chunks = [];
38
+ for (let remaining = length; remaining > 0;) {
39
+ const chunk = new Uint8Array(Math.min(remaining, readChunkSize));
40
+ const bytesRead = await tokenizer.readBuffer(chunk, { position });
41
+ if (bytesRead !== chunk.length) {
42
+ throw new EndOfStreamError();
43
+ }
44
+ chunks.push(chunk);
45
+ remaining -= chunk.length;
46
+ position += chunk.length;
47
+ }
48
+ const buffer = new Uint8Array(length);
49
+ let offset = 0;
50
+ for (const chunk of chunks) {
51
+ buffer.set(chunk, offset);
52
+ offset += chunk.length;
53
+ }
54
+ return buffer;
55
+ }
56
+ const buffer = new Uint8Array(length);
57
+ if ((await tokenizer.readBuffer(buffer, { position })) !== length) {
58
+ throw new EndOfStreamError();
59
+ }
60
+ return buffer;
61
+ }
62
+ /** Bound custom tokenizer reads and buffer large stream payloads incrementally. */
63
+ export async function readToken(tokenizer, token, options, position = tokenizer.position, makeError = allocationError) {
64
+ validateAllocation(token.len, options, makeError);
65
+ validateAvailableBytes(tokenizer, token.len, position);
66
+ if (token.len <= readChunkSize) {
67
+ return tokenizer.readToken(token, position);
68
+ }
69
+ return token.get(await readBuffer(tokenizer, token.len, options, position, makeError), 0);
70
+ }
@@ -1,3 +1,4 @@
1
+ import type { IGetToken } from '@tokenizer/token';
1
2
  import type { ITokenizer } from 'strtok3';
2
3
  import type { ITokenParser } from '../ParserFactory.js';
3
4
  import type { IOptions } from '../type.js';
@@ -13,5 +14,6 @@ export declare abstract class BasicParser implements ITokenParser {
13
14
  * @param {IOptions} options Parsing options
14
15
  */
15
16
  constructor(metadata: INativeMetadataCollector, tokenizer: ITokenizer, options: IOptions);
17
+ protected readToken<T>(token: IGetToken<T>, position?: number): Promise<T>;
16
18
  abstract parse(): Promise<void>;
17
19
  }
@@ -1,3 +1,4 @@
1
+ import { getMaxMemoryAllocation, readToken } from './Allocation.js';
1
2
  export class BasicParser {
2
3
  metadata;
3
4
  tokenizer;
@@ -9,8 +10,12 @@ export class BasicParser {
9
10
  * @param {IOptions} options Parsing options
10
11
  */
11
12
  constructor(metadata, tokenizer, options) {
13
+ getMaxMemoryAllocation(options);
12
14
  this.metadata = metadata;
13
15
  this.tokenizer = tokenizer;
14
16
  this.options = options;
15
17
  }
18
+ readToken(token, position) {
19
+ return readToken(this.tokenizer, token, this.options, position);
20
+ }
16
21
  }
package/lib/core.js CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { fromBlob, fromBuffer, fromWebStream } from 'strtok3';
5
5
  import { APEv2Parser } from './apev2/APEv2Parser.js';
6
+ import { getTokenizerOptions } from './common/Allocation.js';
6
7
  import { hasID3v1Header } from './id3v1/ID3v1Parser.js';
7
8
  import { getLyricsHeaderLength } from './lyrics3/Lyrics3.js';
8
9
  import { ParserFactory } from './ParserFactory.js';
@@ -17,7 +18,7 @@ export { LyricsContentType, TimestampFormat, TrackType } from './type.js';
17
18
  * @returns Metadata
18
19
  */
19
20
  export async function parseBlob(blob, options = {}) {
20
- const tokenizer = fromBlob(blob);
21
+ const tokenizer = fromBlob(blob, getTokenizerOptions(options));
21
22
  try {
22
23
  return await parseFromTokenizer(tokenizer, options);
23
24
  }
@@ -33,9 +34,7 @@ export async function parseBlob(blob, options = {}) {
33
34
  * @returns Metadata
34
35
  */
35
36
  export async function parseWebStream(webStream, fileInfo, options = {}) {
36
- const tokenizer = fromWebStream(webStream, {
37
- fileInfo: typeof fileInfo === 'string' ? { mimeType: fileInfo } : fileInfo
38
- });
37
+ const tokenizer = fromWebStream(webStream, getTokenizerOptions(options, typeof fileInfo === 'string' ? { mimeType: fileInfo } : fileInfo));
39
38
  try {
40
39
  return await parseFromTokenizer(tokenizer, options);
41
40
  }
@@ -52,9 +51,7 @@ export async function parseWebStream(webStream, fileInfo, options = {}) {
52
51
  * Ref: https://github.com/Borewit/strtok3/blob/e6938c81ff685074d5eb3064a11c0b03ca934c1d/src/index.ts#L15
53
52
  */
54
53
  export async function parseBuffer(uint8Array, fileInfo, options = {}) {
55
- const tokenizer = fromBuffer(uint8Array, {
56
- fileInfo: typeof fileInfo === 'string' ? { mimeType: fileInfo } : fileInfo
57
- });
54
+ const tokenizer = fromBuffer(uint8Array, getTokenizerOptions(options, typeof fileInfo === 'string' ? { mimeType: fileInfo } : fileInfo));
58
55
  return parseFromTokenizer(tokenizer, options);
59
56
  }
60
57
  /**
@@ -22,6 +22,9 @@ export declare class DsdiffContentParseError extends DsdiffContentParseError_bas
22
22
  */
23
23
  export declare class DsdiffParser extends BasicParser {
24
24
  parse(): Promise<void>;
25
+ private validateChunkSize;
26
+ private requireChunkSize;
27
+ private ignoreBytes;
25
28
  private readFmt8Chunks;
26
29
  private readData;
27
30
  private handleSoundPropertyChunks;
@@ -1,12 +1,13 @@
1
1
  import initDebug from 'debug';
2
2
  import * as strtok3 from 'strtok3';
3
3
  import * as Token from 'token-types';
4
+ import { getTokenizerOptions } from '../common/Allocation.js';
4
5
  import { BasicParser } from '../common/BasicParser.js';
5
6
  import { FourCcToken } from '../common/FourCC.js';
6
7
  import { ID3v2Parser } from '../id3v2/ID3v2Parser.js';
7
8
  import { makeUnexpectedFileContentError } from '../ParseError.js';
8
9
  import { ChunkHeader64 } from './DsdiffToken.js';
9
- const debug = initDebug('music-metadata:parser:aiff');
10
+ const debug = initDebug('music-metadata:parser:dsdiff');
10
11
  export class DsdiffContentParseError extends makeUnexpectedFileContentError('DSDIFF') {
11
12
  }
12
13
  /**
@@ -17,12 +18,14 @@ export class DsdiffContentParseError extends makeUnexpectedFileContentError('DSD
17
18
  */
18
19
  export class DsdiffParser extends BasicParser {
19
20
  async parse() {
20
- const header = await this.tokenizer.readToken(ChunkHeader64);
21
+ const header = await this.readToken(ChunkHeader64);
21
22
  if (header.chunkID !== 'FRM8') {
22
23
  throw new DsdiffContentParseError('Unexpected chunk-ID');
23
24
  }
25
+ this.validateChunkSize(header);
26
+ this.requireChunkSize(header, FourCcToken.len);
24
27
  this.metadata.setAudioOnly();
25
- const type = (await this.tokenizer.readToken(FourCcToken)).trim();
28
+ const type = (await this.readToken(FourCcToken)).trim();
26
29
  switch (type) {
27
30
  case 'DSD':
28
31
  this.metadata.setFormat('container', `DSDIFF/${type}`);
@@ -32,13 +35,40 @@ export class DsdiffParser extends BasicParser {
32
35
  throw new DsdiffContentParseError(`Unsupported DSDIFF type: ${type}`);
33
36
  }
34
37
  }
38
+ validateChunkSize(header, remainingSize) {
39
+ const size = header.chunkSize;
40
+ if (size < 0n || size > BigInt(Number.MAX_SAFE_INTEGER)) {
41
+ throw new DsdiffContentParseError(`Invalid chunk size: ${size}`);
42
+ }
43
+ const paddedSize = size + (size & 1n);
44
+ if (remainingSize !== undefined && paddedSize > remainingSize) {
45
+ throw new DsdiffContentParseError(`Chunk ${header.chunkID} size ${size} exceeds its enclosing chunk`);
46
+ }
47
+ const fileSize = this.tokenizer.fileInfo.size;
48
+ if (fileSize !== undefined && paddedSize > BigInt(fileSize - this.tokenizer.position)) {
49
+ throw new DsdiffContentParseError(`Chunk ${header.chunkID} size ${size} exceeds remaining file size`);
50
+ }
51
+ }
52
+ requireChunkSize(header, minimum) {
53
+ if (header.chunkSize < BigInt(minimum)) {
54
+ throw new DsdiffContentParseError(`Chunk ${header.chunkID} is too small: ${header.chunkSize}`);
55
+ }
56
+ }
57
+ async ignoreBytes(length) {
58
+ if ((await this.tokenizer.ignore(Number(length))) !== Number(length)) {
59
+ throw new DsdiffContentParseError('Unexpected end of chunk');
60
+ }
61
+ }
35
62
  async readFmt8Chunks(remainingSize) {
36
63
  while (remainingSize >= ChunkHeader64.len) {
37
- const chunkHeader = await this.tokenizer.readToken(ChunkHeader64);
64
+ const chunkHeader = await this.readToken(ChunkHeader64);
65
+ this.validateChunkSize(chunkHeader, remainingSize - BigInt(ChunkHeader64.len));
38
66
  // If the data is an odd number of bytes in length, a pad byte must be added at the end
39
67
  debug(`Chunk id=${chunkHeader.chunkID}`);
40
68
  await this.readData(chunkHeader);
41
- remainingSize -= BigInt(ChunkHeader64.len) + chunkHeader.chunkSize;
69
+ const padding = chunkHeader.chunkSize & 1n;
70
+ await this.ignoreBytes(padding);
71
+ remainingSize -= BigInt(ChunkHeader64.len) + chunkHeader.chunkSize + padding;
42
72
  }
43
73
  }
44
74
  async readData(header) {
@@ -46,14 +76,16 @@ export class DsdiffParser extends BasicParser {
46
76
  const p0 = this.tokenizer.position;
47
77
  switch (header.chunkID.trim()) {
48
78
  case 'FVER': {
79
+ this.requireChunkSize(header, Token.UINT32_LE.len);
49
80
  // 3.1 FORMAT VERSION CHUNK
50
- const version = await this.tokenizer.readToken(Token.UINT32_LE);
81
+ const version = await this.readToken(Token.UINT32_LE);
51
82
  debug(`DSDIFF version=${version}`);
52
83
  break;
53
84
  }
54
85
  case 'PROP': {
86
+ this.requireChunkSize(header, FourCcToken.len);
55
87
  // 3.2 PROPERTY CHUNK
56
- const propType = await this.tokenizer.readToken(FourCcToken);
88
+ const propType = await this.readToken(FourCcToken);
57
89
  if (propType !== 'SND ') {
58
90
  throw new DsdiffContentParseError('Unexpected PROP-chunk ID');
59
91
  }
@@ -62,8 +94,8 @@ export class DsdiffParser extends BasicParser {
62
94
  }
63
95
  case 'ID3': {
64
96
  // Unofficial ID3 tag support
65
- const id3_data = await this.tokenizer.readToken(new Token.Uint8ArrayType(Number(header.chunkSize)));
66
- const rst = strtok3.fromBuffer(id3_data);
97
+ const id3_data = await this.readToken(new Token.Uint8ArrayType(Number(header.chunkSize)));
98
+ const rst = strtok3.fromBuffer(id3_data, getTokenizerOptions(this.options));
67
99
  await new ID3v2Parser().parse(this.metadata, rst, this.options);
68
100
  break;
69
101
  }
@@ -82,34 +114,39 @@ export class DsdiffParser extends BasicParser {
82
114
  const remaining = header.chunkSize - BigInt(this.tokenizer.position - p0);
83
115
  if (remaining > 0) {
84
116
  debug(`After Parsing chunk, remaining ${remaining} bytes`);
85
- await this.tokenizer.ignore(Number(remaining));
117
+ await this.ignoreBytes(remaining);
86
118
  }
87
119
  }
88
120
  async handleSoundPropertyChunks(remainingSize) {
89
121
  debug(`Parsing sound-property-chunks, remainingSize=${remainingSize}`);
90
- while (remainingSize > 0) {
91
- const sndPropHeader = await this.tokenizer.readToken(ChunkHeader64);
122
+ while (remainingSize >= BigInt(ChunkHeader64.len)) {
123
+ const sndPropHeader = await this.readToken(ChunkHeader64);
124
+ this.validateChunkSize(sndPropHeader, remainingSize - BigInt(ChunkHeader64.len));
92
125
  debug(`Sound-property-chunk[ID=${sndPropHeader.chunkID}, size=${sndPropHeader.chunkSize}]`);
93
126
  const p0 = this.tokenizer.position;
94
127
  switch (sndPropHeader.chunkID.trim()) {
95
128
  case 'FS': {
129
+ this.requireChunkSize(sndPropHeader, Token.UINT32_BE.len);
96
130
  // 3.2.1 Sample Rate Chunk
97
- const sampleRate = await this.tokenizer.readToken(Token.UINT32_BE);
131
+ const sampleRate = await this.readToken(Token.UINT32_BE);
98
132
  this.metadata.setFormat('sampleRate', sampleRate);
99
133
  break;
100
134
  }
101
135
  case 'CHNL': {
136
+ this.requireChunkSize(sndPropHeader, Token.UINT16_BE.len);
102
137
  // 3.2.2 Channels Chunk
103
- const numChannels = await this.tokenizer.readToken(Token.UINT16_BE);
138
+ const numChannels = await this.readToken(Token.UINT16_BE);
104
139
  this.metadata.setFormat('numberOfChannels', numChannels);
105
140
  await this.handleChannelChunks(sndPropHeader.chunkSize - BigInt(Token.UINT16_BE.len));
106
141
  break;
107
142
  }
108
143
  case 'CMPR': {
144
+ this.requireChunkSize(sndPropHeader, FourCcToken.len + Token.UINT8.len);
109
145
  // 3.2.3 Compression Type Chunk
110
- const compressionIdCode = (await this.tokenizer.readToken(FourCcToken)).trim();
111
- const count = await this.tokenizer.readToken(Token.UINT8);
112
- const compressionName = await this.tokenizer.readToken(new Token.StringType(count, 'ascii'));
146
+ const compressionIdCode = (await this.readToken(FourCcToken)).trim();
147
+ const count = await this.readToken(Token.UINT8);
148
+ this.requireChunkSize(sndPropHeader, FourCcToken.len + Token.UINT8.len + count);
149
+ const compressionName = await this.readToken(new Token.StringType(count, 'ascii'));
113
150
  if (compressionIdCode === 'DSD') {
114
151
  this.metadata.setFormat('lossless', true);
115
152
  this.metadata.setFormat('bitsPerSample', 1);
@@ -118,32 +155,39 @@ export class DsdiffParser extends BasicParser {
118
155
  break;
119
156
  }
120
157
  case 'ABSS': {
158
+ this.requireChunkSize(sndPropHeader, 8);
121
159
  // 3.2.4 Absolute Start Time Chunk
122
- const hours = await this.tokenizer.readToken(Token.UINT16_BE);
123
- const minutes = await this.tokenizer.readToken(Token.UINT8);
124
- const seconds = await this.tokenizer.readToken(Token.UINT8);
125
- const samples = await this.tokenizer.readToken(Token.UINT32_BE);
160
+ const hours = await this.readToken(Token.UINT16_BE);
161
+ const minutes = await this.readToken(Token.UINT8);
162
+ const seconds = await this.readToken(Token.UINT8);
163
+ const samples = await this.readToken(Token.UINT32_BE);
126
164
  debug(`ABSS ${hours}:${minutes}:${seconds}.${samples}`);
127
165
  break;
128
166
  }
129
167
  case 'LSCO': {
168
+ this.requireChunkSize(sndPropHeader, Token.UINT16_BE.len);
130
169
  // 3.2.5 Loudspeaker Configuration Chunk
131
- const lsConfig = await this.tokenizer.readToken(Token.UINT16_BE);
170
+ const lsConfig = await this.readToken(Token.UINT16_BE);
132
171
  debug(`LSCO lsConfig=${lsConfig}`);
133
172
  break;
134
173
  }
135
174
  default:
136
175
  debug(`Unknown sound-property-chunk[ID=${sndPropHeader.chunkID}, size=${sndPropHeader.chunkSize}]`);
137
- await this.tokenizer.ignore(Number(sndPropHeader.chunkSize));
176
+ await this.ignoreBytes(sndPropHeader.chunkSize);
138
177
  }
139
178
  const remaining = sndPropHeader.chunkSize - BigInt(this.tokenizer.position - p0);
140
179
  if (remaining > 0) {
141
180
  debug(`After Parsing sound-property-chunk ${sndPropHeader.chunkSize}, remaining ${remaining} bytes`);
142
- await this.tokenizer.ignore(Number(remaining));
181
+ await this.ignoreBytes(remaining);
143
182
  }
144
- remainingSize -= BigInt(ChunkHeader64.len) + sndPropHeader.chunkSize;
183
+ const padding = sndPropHeader.chunkSize & 1n;
184
+ await this.ignoreBytes(padding);
185
+ remainingSize -= BigInt(ChunkHeader64.len) + sndPropHeader.chunkSize + padding;
145
186
  debug(`Parsing sound-property-chunks, remainingSize=${remainingSize}`);
146
187
  }
188
+ if (remainingSize !== 0n) {
189
+ throw new DsdiffContentParseError('Incomplete sound-property chunk header');
190
+ }
147
191
  if (this.metadata.format.lossless &&
148
192
  this.metadata.format.sampleRate &&
149
193
  this.metadata.format.numberOfChannels &&
@@ -154,14 +198,10 @@ export class DsdiffParser extends BasicParser {
154
198
  }
155
199
  async handleChannelChunks(remainingSize) {
156
200
  debug(`Parsing channel-chunks, remainingSize=${remainingSize}`);
157
- const channels = [];
158
201
  while (remainingSize >= FourCcToken.len) {
159
- const channelId = await this.tokenizer.readToken(FourCcToken);
202
+ const channelId = await this.readToken(FourCcToken);
160
203
  debug(`Channel[ID=${channelId}]`);
161
- channels.push(channelId);
162
204
  remainingSize -= BigInt(FourCcToken.len);
163
205
  }
164
- debug(`Channels: ${channels.join(', ')}`);
165
- return channels;
166
206
  }
167
207
  }
@@ -1,4 +1,5 @@
1
1
  import { type ITokenizer } from 'strtok3';
2
+ import type { IOptions } from '../type.js';
2
3
  import { type IElementType, type ITree, type ValueType } from './types.js';
3
4
  declare const EbmlContentError_base: {
4
5
  new (message: string): {
@@ -45,6 +46,7 @@ export type IElementListener = {
45
46
  * WEBM VP8 AUDIO FILE
46
47
  */
47
48
  export declare class EbmlIterator {
49
+ private readonly options;
48
50
  private parserMap;
49
51
  private ebmlMaxIDLength;
50
52
  private ebmlMaxSizeLength;
@@ -53,9 +55,10 @@ export declare class EbmlIterator {
53
55
  * @param {ITokenizer} tokenizer Input
54
56
  * @param tokenizer
55
57
  */
56
- constructor(tokenizer: ITokenizer);
58
+ constructor(tokenizer: ITokenizer, options?: IOptions);
57
59
  iterate(dtdElement: IElementType, posDone: number, listener: IElementListener): Promise<ITree>;
58
60
  private parseContainer;
61
+ private ignore;
59
62
  private readVintData;
60
63
  private readElement;
61
64
  private readFloat;
@@ -2,6 +2,7 @@ import initDebug from 'debug';
2
2
  import { EndOfStreamError } from 'strtok3';
3
3
  import * as Token from 'token-types';
4
4
  import { Float32_BE, Float64_BE, StringType, UINT8 } from 'token-types';
5
+ import { readBuffer } from '../common/Allocation.js';
5
6
  import { makeUnexpectedFileContentError } from '../ParseError.js';
6
7
  import { DataType } from './types.js';
7
8
  const debug = initDebug('music-metadata:parser:ebml');
@@ -22,6 +23,7 @@ export const ParseAction = {
22
23
  * WEBM VP8 AUDIO FILE
23
24
  */
24
25
  export class EbmlIterator {
26
+ options;
25
27
  parserMap = new Map();
26
28
  ebmlMaxIDLength = 4;
27
29
  ebmlMaxSizeLength = 8;
@@ -30,7 +32,8 @@ export class EbmlIterator {
30
32
  * @param {ITokenizer} tokenizer Input
31
33
  * @param tokenizer
32
34
  */
33
- constructor(tokenizer) {
35
+ constructor(tokenizer, options = {}) {
36
+ this.options = options;
34
37
  this.tokenizer = tokenizer;
35
38
  this.parserMap.set(DataType.uint, e => this.readUint(e));
36
39
  this.parserMap.set(DataType.string, e => this.readString(e));
@@ -104,11 +107,11 @@ export class EbmlIterator {
104
107
  break;
105
108
  case ParseAction.IgnoreElement:
106
109
  debug(`Ignore element: name=${getElementPath(child)}, element.id=0x${element.id}, container=${!!child.container} at position=${elementPosition}`);
107
- await this.tokenizer.ignore(element.len);
110
+ await this.ignore(element.len, posDone);
108
111
  break;
109
112
  case ParseAction.SkipSiblings:
110
113
  debug(`Ignore remaining container, at: name=${getElementPath(child)}, element.id=0x${element.id}, container=${!!child.container} at position=${elementPosition}`);
111
- await this.tokenizer.ignore(posDone - this.tokenizer.position);
114
+ await this.ignore(posDone - this.tokenizer.position, posDone);
112
115
  break;
113
116
  case ParseAction.TerminateParsing:
114
117
  debug(`Terminate parsing at element: name=${getElementPath(child)}, element.id=0x${element.id}, container=${!!child.container} at position=${elementPosition}`);
@@ -118,16 +121,26 @@ export class EbmlIterator {
118
121
  else {
119
122
  switch (element.id) {
120
123
  case 0xec: // void
121
- await this.tokenizer.ignore(element.len);
124
+ await this.ignore(element.len, posDone);
122
125
  break;
123
126
  default:
124
127
  debug(`parseEbml: parent=${getElementPath(dtdElement)}, unknown child: id=${element.id.toString(16)} at position=${elementPosition}`);
125
- await this.tokenizer.ignore(element.len);
128
+ await this.ignore(element.len, posDone);
126
129
  }
127
130
  }
128
131
  }
129
132
  return tree;
130
133
  }
134
+ async ignore(length, posDone) {
135
+ // Unknown-size EBML containers may advertise lengths above the safe integer range.
136
+ // Bound skips by the enclosing container and known input before passing them to the tokenizer.
137
+ const end = Math.min(posDone, this.tokenizer.fileInfo.size ?? Number.POSITIVE_INFINITY);
138
+ const boundedLength = Math.min(length, end - this.tokenizer.position);
139
+ if (!Number.isSafeInteger(boundedLength) || boundedLength < 0) {
140
+ throw new EbmlContentError(`Invalid element length: ${length}`);
141
+ }
142
+ await this.tokenizer.ignore(boundedLength);
143
+ }
131
144
  async readVintData(maxLength) {
132
145
  const msb = await this.tokenizer.peekNumber(UINT8);
133
146
  let mask = 0x80;
@@ -180,28 +193,7 @@ export class EbmlIterator {
180
193
  return rawString.replace(/\x00.*$/g, '');
181
194
  }
182
195
  async readBuffer(e) {
183
- const chunkSize = 64 * 1024;
184
- if ((!this.tokenizer.supportsRandomAccess() || this.tokenizer.fileInfo.size === undefined) && e.len > chunkSize) {
185
- // Neither a declared container size nor an advertised stream size proves these bytes exist.
186
- // Allocate incrementally, and only assemble the leaf after reading its payload.
187
- const chunks = [];
188
- for (let remaining = e.len; remaining > 0;) {
189
- const chunk = new Uint8Array(Math.min(remaining, chunkSize));
190
- await this.tokenizer.readBuffer(chunk);
191
- chunks.push(chunk);
192
- remaining -= chunk.length;
193
- }
194
- const buf = new Uint8Array(e.len);
195
- let offset = 0;
196
- for (const chunk of chunks) {
197
- buf.set(chunk, offset);
198
- offset += chunk.length;
199
- }
200
- return buf;
201
- }
202
- const buf = new Uint8Array(e.len);
203
- await this.tokenizer.readBuffer(buf);
204
- return buf;
196
+ return readBuffer(this.tokenizer, e.len, this.options, undefined, message => new EbmlContentError(message));
205
197
  }
206
198
  }
207
199
  function readUIntBE(buf, len) {
@@ -13,14 +13,14 @@ class FlacContentError extends makeUnexpectedFileContentError('FLAC') {
13
13
  export class FlacParser extends AbstractID3Parser {
14
14
  vorbisParser = new VorbisStream(this.metadata, this.options);
15
15
  async postId3v2Parse() {
16
- const fourCC = await this.tokenizer.readToken(FourCcToken);
16
+ const fourCC = await this.readToken(FourCcToken);
17
17
  if (fourCC.toString() !== 'fLaC') {
18
18
  throw new FlacContentError('Invalid FLAC preamble');
19
19
  }
20
20
  let blockHeader;
21
21
  do {
22
22
  // Read block header
23
- blockHeader = await this.tokenizer.readToken(Flac.BlockHeader);
23
+ blockHeader = await this.readToken(Flac.BlockHeader);
24
24
  // Parse block data
25
25
  await this.parseDataBlock(blockHeader);
26
26
  } while (!blockHeader.lastBlock);
@@ -60,7 +60,7 @@ export class FlacParser extends AbstractID3Parser {
60
60
  if (dataLen !== Flac.BlockStreamInfo.len) {
61
61
  throw new FlacContentError('Unexpected block-stream-info length');
62
62
  }
63
- const streamInfo = await this.tokenizer.readToken(Flac.BlockStreamInfo);
63
+ const streamInfo = await this.readToken(Flac.BlockStreamInfo);
64
64
  this.metadata.setFormat('container', 'FLAC');
65
65
  this.processsStreamInfo(streamInfo);
66
66
  }
@@ -83,7 +83,7 @@ export class FlacParser extends AbstractID3Parser {
83
83
  * Ref: https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-640004.2.3
84
84
  */
85
85
  async readComment(dataLen) {
86
- const data = await this.tokenizer.readToken(new Uint8ArrayType(dataLen));
86
+ const data = await this.readToken(new Uint8ArrayType(dataLen));
87
87
  return this.parseComment(data);
88
88
  }
89
89
  /**
@@ -97,9 +97,12 @@ export class FlacParser extends AbstractID3Parser {
97
97
  this.metadata.setFormat('tool', vendor);
98
98
  }
99
99
  const commentListLength = decoder.readInt32();
100
- const tags = new Array(commentListLength);
100
+ if (commentListLength > Math.floor(decoder.remaining / 4)) {
101
+ throw new FlacContentError(`Vorbis comment count ${commentListLength} exceeds remaining data`);
102
+ }
103
+ const tags = [];
101
104
  for (let i = 0; i < commentListLength; i++) {
102
- tags[i] = decoder.parseUserComment();
105
+ tags.push(decoder.parseUserComment());
103
106
  }
104
107
  await Promise.all(tags.map(tag => {
105
108
  if (tag.key === 'ENCODER') {
@@ -112,7 +115,7 @@ export class FlacParser extends AbstractID3Parser {
112
115
  if (this.options.skipCovers) {
113
116
  return this.tokenizer.ignore(dataLen);
114
117
  }
115
- return this.addPictureTag(await this.tokenizer.readToken(new VorbisPictureToken(dataLen)));
118
+ return this.addPictureTag(await this.readToken(new VorbisPictureToken(dataLen)));
116
119
  }
117
120
  addPictureTag(picture) {
118
121
  return this.addTag('METADATA_BLOCK_PICTURE', picture);
@@ -1,4 +1,5 @@
1
1
  import * as Token from 'token-types';
2
+ import { readToken } from '../common/Allocation.js';
2
3
  import { getFrameHeaderLength, readFrameHeader } from './FrameHeader.js';
3
4
  import { FrameParser, Id3v2ContentError } from './FrameParser.js';
4
5
  import { ExtendedHeader, ID3v2Header } from './ID3v2Token.js';
@@ -84,7 +85,7 @@ export class ID3v2Parser {
84
85
  return this.parseId3Data(this.id3Header.size - extendedHeaderSize);
85
86
  }
86
87
  async parseId3Data(dataLen) {
87
- const uint8Array = await this.tokenizer.readToken(new Token.Uint8ArrayType(dataLen));
88
+ const uint8Array = await readToken(this.tokenizer, new Token.Uint8ArrayType(dataLen), this.options, undefined, message => new Id3v2ContentError(message));
88
89
  for (const tag of this.parseMetadata(uint8Array)) {
89
90
  switch (tag.id) {
90
91
  case 'TXXX':
package/lib/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import initDebug from 'debug';
5
5
  import { fromFile, fromStream } from 'strtok3';
6
+ import { getTokenizerOptions } from './common/Allocation.js';
6
7
  import { CouldNotDetermineFileTypeError, parseFromTokenizer, UnsupportedFileTypeError } from './core.js';
7
8
  import { ParserFactory } from './ParserFactory.js';
8
9
  export * from './core.js';
@@ -15,9 +16,7 @@ const debug = initDebug('music-metadata:parser');
15
16
  * @returns Metadata
16
17
  */
17
18
  export async function parseStream(stream, fileInfo, options = {}) {
18
- const tokenizer = await fromStream(stream, {
19
- fileInfo: typeof fileInfo === 'string' ? { mimeType: fileInfo } : fileInfo
20
- });
19
+ const tokenizer = await fromStream(stream, getTokenizerOptions(options, typeof fileInfo === 'string' ? { mimeType: fileInfo } : fileInfo));
21
20
  try {
22
21
  return await parseFromTokenizer(tokenizer, options);
23
22
  }
@@ -33,7 +32,7 @@ export async function parseStream(stream, fileInfo, options = {}) {
33
32
  */
34
33
  export async function parseFile(filePath, options = {}) {
35
34
  debug(`parseFile: ${filePath}`);
36
- const fileTokenizer = await fromFile(filePath);
35
+ const fileTokenizer = await fromFile(filePath, getTokenizerOptions(options));
37
36
  const parserFactory = new ParserFactory();
38
37
  try {
39
38
  const parserLoader = parserFactory.findLoaderForExtension(filePath);
@@ -21,7 +21,7 @@ export class MatroskaParser extends BasicParser {
21
21
  flagUseIndexToSkipClusters = this.options.mkvUseIndex ?? false;
22
22
  async parse() {
23
23
  const containerSize = this.tokenizer.fileInfo.size ?? Number.MAX_SAFE_INTEGER;
24
- const matroskaIterator = new EbmlIterator(this.tokenizer);
24
+ const matroskaIterator = new EbmlIterator(this.tokenizer, this.options);
25
25
  debug('Initializing DTD end MatroskaIterator');
26
26
  await matroskaIterator.iterate(matroskaDtd, containerSize, {
27
27
  startNext: element => {
@@ -1,9 +1,10 @@
1
+ import type { IGetToken } from '@tokenizer/token';
1
2
  import { BasicParser } from '../common/BasicParser.js';
2
3
  import { Atom } from './Atom.js';
3
4
  export declare class MP4Parser extends BasicParser {
4
5
  private static read_BE_Integer;
5
6
  private validatePayloadLength;
6
- private readToken;
7
+ protected readToken<T>(token: IGetToken<T>, position?: number): Promise<T>;
7
8
  private tracks;
8
9
  private mediaDataRanges;
9
10
  private streamedChapters;
@@ -2,6 +2,7 @@ import { textDecode } from '@borewit/text-codec';
2
2
  import initDebug from 'debug';
3
3
  import * as Token from 'token-types';
4
4
  import { uint8ArrayToHex } from 'uint8array-extras';
5
+ import { readToken, validateAllocation } from '../common/Allocation.js';
5
6
  import { BasicParser } from '../common/BasicParser.js';
6
7
  import { Genres } from '../id3v1/ID3v1Parser.js';
7
8
  import { TrackType } from '../type.js';
@@ -123,13 +124,10 @@ export class MP4Parser extends BasicParser {
123
124
  // Metadata and sample tables are buffered; media data is skipped separately.
124
125
  // A bound is also necessary for streams whose total size is unknown.
125
126
  validatePayloadLength(length) {
126
- if (!Number.isSafeInteger(length) || length < 0 || length > 64 * 1024 * 1024) {
127
- throw new Mp4ContentError(`Atom payload exceeds the 64 MiB buffering limit: ${length}`);
128
- }
127
+ validateAllocation(length, this.options, message => new Mp4ContentError(message));
129
128
  }
130
129
  async readToken(token, position) {
131
- this.validatePayloadLength(token.len);
132
- return this.tokenizer.readToken(token, position);
130
+ return readToken(this.tokenizer, token, this.options, position, message => new Mp4ContentError(message));
133
131
  }
134
132
  tracks = new Map();
135
133
  mediaDataRanges = [];
@@ -1,6 +1,7 @@
1
1
  import initDebug from 'debug';
2
2
  import { EndOfStreamError } from 'strtok3';
3
3
  import * as Token from 'token-types';
4
+ import { readToken } from '../common/Allocation.js';
4
5
  import { BasicParser } from '../common/BasicParser.js';
5
6
  import { createAudioTrackInfo } from '../common/TrackInfo.js';
6
7
  import { makeUnexpectedFileContentError } from '../ParseError.js';
@@ -34,7 +35,7 @@ class OggStream {
34
35
  debug('serial=%s page#=%s, Ogg.id=%s', header.streamSerialNumber, header.pageSequenceNo, header.capturePattern);
35
36
  const segmentTable = await tokenizer.readToken(new SegmentTable(header));
36
37
  debug('totalPageSize=%s', segmentTable.totalPageSize);
37
- const pageData = await tokenizer.readToken(new Token.Uint8ArrayType(segmentTable.totalPageSize));
38
+ const pageData = await readToken(tokenizer, new Token.Uint8ArrayType(segmentTable.totalPageSize), this.options);
38
39
  this.payloadBytes += pageData.length;
39
40
  debug('firstPage=%s, lastPage=%s, continued=%s', header.headerType.firstPage, header.headerType.lastPage, header.headerType.continued);
40
41
  if (header.headerType.firstPage) {
@@ -2,6 +2,7 @@ export declare class VorbisDecoder {
2
2
  private readonly data;
3
3
  private offset;
4
4
  constructor(data: Uint8Array, offset: number);
5
+ get remaining(): number;
5
6
  readInt32(): number;
6
7
  readStringUtf8(): string;
7
8
  parseUserComment(): {
@@ -1,5 +1,8 @@
1
1
  import { textDecode } from '@borewit/text-codec';
2
2
  import * as Token from 'token-types';
3
+ import { makeUnexpectedFileContentError } from '../../ParseError.js';
4
+ class VorbisCommentError extends makeUnexpectedFileContentError('Vorbis') {
5
+ }
3
6
  export class VorbisDecoder {
4
7
  data;
5
8
  offset;
@@ -7,13 +10,22 @@ export class VorbisDecoder {
7
10
  this.data = data;
8
11
  this.offset = offset;
9
12
  }
13
+ get remaining() {
14
+ return this.data.length - this.offset;
15
+ }
10
16
  readInt32() {
17
+ if (this.remaining < 4) {
18
+ throw new VorbisCommentError('Truncated Vorbis comment length');
19
+ }
11
20
  const value = Token.UINT32_LE.get(this.data, this.offset);
12
21
  this.offset += 4;
13
22
  return value;
14
23
  }
15
24
  readStringUtf8() {
16
25
  const len = this.readInt32();
26
+ if (len > this.remaining) {
27
+ throw new VorbisCommentError(`Vorbis comment length ${len} exceeds remaining data ${this.remaining}`);
28
+ }
17
29
  const value = textDecode(this.data.subarray(this.offset, this.offset + len), 'utf-8');
18
30
  this.offset += len;
19
31
  return value;
@@ -23,6 +23,7 @@ export declare class VorbisContentError extends VorbisContentError_base {
23
23
  */
24
24
  export declare class VorbisStream implements IPageConsumer {
25
25
  private pageSegments;
26
+ private bufferedPageLength;
26
27
  protected metadata: INativeMetadataCollector;
27
28
  protected options: IOptions;
28
29
  protected lastPageHeader?: IPageHeader;
@@ -35,7 +36,7 @@ export declare class VorbisStream implements IPageConsumer {
35
36
  * @param pageData Page data
36
37
  */
37
38
  parsePage(header: IPageHeader, pageData: Uint8Array): Promise<void>;
38
- private static mergeUint8Arrays;
39
+ private mergeUint8Arrays;
39
40
  flush(): Promise<void>;
40
41
  parseUserComment(pageData: Uint8Array, offset: number): Promise<number>;
41
42
  addTag(id: string, value: string | IVorbisPicture): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import debugInit from 'debug';
2
2
  import * as Token from 'token-types';
3
+ import { validateAllocation } from '../../common/Allocation.js';
3
4
  import { makeUnexpectedFileContentError } from '../../ParseError.js';
4
5
  import { CommonHeader, IdentificationHeader, VorbisPictureToken } from './Vorbis.js';
5
6
  import { VorbisDecoder } from './VorbisDecoder.js';
@@ -12,6 +13,7 @@ export class VorbisContentError extends makeUnexpectedFileContentError('Vorbis')
12
13
  */
13
14
  export class VorbisStream {
14
15
  pageSegments = [];
16
+ bufferedPageLength = 0;
15
17
  metadata;
16
18
  options;
17
19
  lastPageHeader;
@@ -36,30 +38,36 @@ export class VorbisStream {
36
38
  if (this.pageSegments.length === 0) {
37
39
  throw new VorbisContentError('Cannot continue on previous page');
38
40
  }
41
+ validateAllocation(this.bufferedPageLength + pageData.length, this.options);
39
42
  this.pageSegments.push(pageData);
43
+ this.bufferedPageLength += pageData.length;
40
44
  }
41
45
  if (header.headerType.lastPage || !header.headerType.continued) {
42
46
  // Flush page segments
43
47
  if (this.pageSegments.length > 0) {
44
- const fullPage = VorbisStream.mergeUint8Arrays(this.pageSegments);
48
+ const fullPage = this.mergeUint8Arrays(this.pageSegments);
45
49
  await this.parseFullPage(fullPage);
46
50
  }
47
51
  // Reset page segments
52
+ this.bufferedPageLength = header.headerType.lastPage ? 0 : pageData.length;
53
+ validateAllocation(this.bufferedPageLength, this.options);
48
54
  this.pageSegments = header.headerType.lastPage ? [] : [pageData];
49
55
  }
50
56
  }
51
57
  }
52
- static mergeUint8Arrays(arrays) {
58
+ mergeUint8Arrays(arrays) {
53
59
  const totalSize = arrays.reduce((acc, e) => acc + e.length, 0);
60
+ validateAllocation(totalSize, this.options);
54
61
  const merged = new Uint8Array(totalSize);
55
- arrays.forEach((array, i, _arrays) => {
56
- const offset = _arrays.slice(0, i).reduce((acc, e) => acc + e.length, 0);
62
+ let offset = 0;
63
+ for (const array of arrays) {
57
64
  merged.set(array, offset);
58
- });
65
+ offset += array.length;
66
+ }
59
67
  return merged;
60
68
  }
61
69
  async flush() {
62
- await this.parseFullPage(VorbisStream.mergeUint8Arrays(this.pageSegments));
70
+ await this.parseFullPage(this.mergeUint8Arrays(this.pageSegments));
63
71
  }
64
72
  async parseUserComment(pageData, offset) {
65
73
  const decoder = new VorbisDecoder(pageData, offset);
package/lib/type.d.ts CHANGED
@@ -596,6 +596,12 @@ export interface IAudioMetadata extends INativeAudioMetadata {
596
596
  */
597
597
  export type ParserType = 'mpeg' | 'apev2' | 'mp4' | 'asf' | 'flac' | 'ogg' | 'aiff' | 'wavpack' | 'riff' | 'musepack' | 'dsf' | 'dsdiff' | 'adts' | 'matroska';
598
598
  export interface IOptions {
599
+ /**
600
+ * Maximum bytes buffered for a single metadata payload, accumulated Ogg packet, or tokenizer stream lookahead.
601
+ * Defaults to 64 MiB (67108864 bytes). Must be a positive safe integer.
602
+ * This is not a limit on total process memory or the size of skipped audio/video data.
603
+ */
604
+ maxMemoryAllocation?: number;
599
605
  /**
600
606
  * default: `false`, if set to `true`, it will parse the whole media file if required to determine the duration.
601
607
  */
@@ -1,6 +1,7 @@
1
1
  import initDebug from 'debug';
2
2
  import * as strtok3 from 'strtok3';
3
3
  import * as Token from 'token-types';
4
+ import { getTokenizerOptions } from '../common/Allocation.js';
4
5
  import { BasicParser } from '../common/BasicParser.js';
5
6
  import { FourCcToken } from '../common/FourCC.js';
6
7
  import { ID3v2Parser } from '../id3v2/ID3v2Parser.js';
@@ -29,7 +30,7 @@ export class WaveParser extends BasicParser {
29
30
  avgBytesPerSec = 0;
30
31
  header;
31
32
  async parse() {
32
- const riffHeader = await this.tokenizer.readToken(riff.Header);
33
+ const riffHeader = await this.readToken(riff.Header);
33
34
  debug(`pos=${this.tokenizer.position}, parse: chunkID=${riffHeader.chunkID}`);
34
35
  if (riffHeader.chunkID !== 'RIFF') {
35
36
  return; // Not RIFF format
@@ -43,7 +44,7 @@ export class WaveParser extends BasicParser {
43
44
  await this.flushInfoTags();
44
45
  }
45
46
  async parseRiffChunk(chunkSize) {
46
- const type = await this.tokenizer.readToken(FourCcToken);
47
+ const type = await this.readToken(FourCcToken);
47
48
  this.metadata.setFormat('container', type);
48
49
  switch (type) {
49
50
  case 'WAVE':
@@ -54,7 +55,7 @@ export class WaveParser extends BasicParser {
54
55
  }
55
56
  async readWaveChunk(remaining) {
56
57
  while (remaining >= riff.Header.len) {
57
- const header = await this.tokenizer.readToken(riff.Header);
58
+ const header = await this.readToken(riff.Header);
58
59
  remaining -= riff.Header.len + header.chunkSize;
59
60
  if (header.chunkSize > remaining) {
60
61
  this.metadata.addWarning('Data chunk size exceeds file size');
@@ -66,7 +67,7 @@ export class WaveParser extends BasicParser {
66
67
  if (header.chunkSize < 8) {
67
68
  throw new WaveContentError('CSET chunk must contain at least 8 bytes');
68
69
  }
69
- const cset = await this.tokenizer.readToken(new Token.Uint8ArrayType(header.chunkSize));
70
+ const cset = await this.readToken(new Token.Uint8ArrayType(header.chunkSize));
70
71
  this.codePage = Token.UINT16_LE.get(cset, 0);
71
72
  if (!this.getInfoEncoding()) {
72
73
  this.metadata.addWarning(`Unsupported RIFF CSET code page: ${this.codePage}; LIST/INFO tags will be omitted`);
@@ -79,11 +80,11 @@ export class WaveParser extends BasicParser {
79
80
  break;
80
81
  case 'fact': // extended Format chunk,
81
82
  this.metadata.setFormat('lossless', false);
82
- this.fact = await this.tokenizer.readToken(new WaveChunk.FactChunk(header));
83
+ this.fact = await this.readToken(new WaveChunk.FactChunk(header));
83
84
  break;
84
85
  case 'fmt ': {
85
86
  // The Util Chunk, non-PCM Formats
86
- const fmt = await this.tokenizer.readToken(new WaveChunk.Format(header));
87
+ const fmt = await this.readToken(new WaveChunk.Format(header));
87
88
  let subFormat = WaveChunk.WaveFormatNameMap[fmt.wFormatTag];
88
89
  if (!subFormat) {
89
90
  debug(`WAVE/non-PCM format=${fmt.wFormatTag}`);
@@ -100,8 +101,8 @@ export class WaveParser extends BasicParser {
100
101
  case 'id3 ': // The way Picard, FooBar currently stores, ID3 meta-data
101
102
  case 'ID3 ': {
102
103
  // The way Mp3Tags stores ID3 meta-data
103
- const id3_data = await this.tokenizer.readToken(new Token.Uint8ArrayType(header.chunkSize));
104
- const rst = strtok3.fromBuffer(id3_data);
104
+ const id3_data = await this.readToken(new Token.Uint8ArrayType(header.chunkSize));
105
+ const rst = strtok3.fromBuffer(id3_data, getTokenizerOptions(this.options));
105
106
  await new ID3v2Parser().parse(this.metadata, rst, this.options);
106
107
  break;
107
108
  }
@@ -140,7 +141,7 @@ export class WaveParser extends BasicParser {
140
141
  }
141
142
  case 'bext': {
142
143
  // Broadcast Audio Extension chunk https://tech.ebu.ch/docs/tech/tech3285.pdf
143
- const bext = await this.tokenizer.readToken(BroadcastAudioExtensionChunk);
144
+ const bext = await this.readToken(BroadcastAudioExtensionChunk);
144
145
  for (const key of Object.keys(bext)) {
145
146
  await this.metadata.addTag('exif', `bext.${key}`, bext[key]);
146
147
  }
@@ -165,7 +166,7 @@ export class WaveParser extends BasicParser {
165
166
  }
166
167
  }
167
168
  async parseListTag(listHeader) {
168
- const listType = await this.tokenizer.readToken(new Token.StringType(4, 'latin1'));
169
+ const listType = await this.readToken(new Token.StringType(4, 'latin1'));
169
170
  debug('pos=%s, parseListTag: chunkID=RIFF/WAVE/LIST/%s', this.tokenizer.position, listType);
170
171
  switch (listType) {
171
172
  case 'INFO':
@@ -178,9 +179,9 @@ export class WaveParser extends BasicParser {
178
179
  }
179
180
  async parseRiffInfoTags(chunkSize) {
180
181
  while (chunkSize >= 8) {
181
- const header = await this.tokenizer.readToken(riff.Header);
182
+ const header = await this.readToken(riff.Header);
182
183
  const valueToken = new riff.ListInfoTagValue(header);
183
- const bytes = await this.tokenizer.readToken(new Token.Uint8ArrayType(valueToken.len));
184
+ const bytes = await this.readToken(new Token.Uint8ArrayType(valueToken.len));
184
185
  if (this.codePage === undefined) {
185
186
  this.metadata.registerTagType('exif');
186
187
  this.pendingInfoTags.push({ header, bytes });
@@ -70,8 +70,11 @@ export class WavPackParser extends BasicParser {
70
70
  while (remaining > MetadataIdToken.len) {
71
71
  const id = await this.tokenizer.readToken(MetadataIdToken);
72
72
  const dataSizeInWords = await this.tokenizer.readNumber(id.largeBlock ? Token.UINT24_LE : Token.UINT8);
73
- const data = new Uint8Array(dataSizeInWords * 2 - (id.isOddSize ? 1 : 0));
74
- await this.tokenizer.readBuffer(data);
73
+ const headerSize = MetadataIdToken.len + (id.largeBlock ? Token.UINT24_LE.len : Token.UINT8.len);
74
+ if (dataSizeInWords * 2 > remaining - headerSize) {
75
+ throw new WavPackContentError('Metadata sub-block exceeds its enclosing block');
76
+ }
77
+ const data = await this.readToken(new Token.Uint8ArrayType(dataSizeInWords * 2 - (id.isOddSize ? 1 : 0)));
75
78
  debug(`Metadata Sub-Blocks functionId=0x${id.functionId.toString(16)}, id.largeBlock=${id.largeBlock},data-size=${data.length}`);
76
79
  switch (id.functionId) {
77
80
  case 0x0: // ID_DUMMY: could be used to pad WavPack blocks
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "music-metadata",
3
3
  "description": "Music metadata parser for Node.js, supporting virtual any audio and tag format.",
4
- "version": "12.1.0",
4
+ "version": "12.1.1",
5
5
  "author": {
6
6
  "name": "Borewit",
7
7
  "url": "https://github.com/Borewit"
@@ -124,7 +124,7 @@
124
124
  "debug": "^4.4.3",
125
125
  "file-type": "^22.1.1",
126
126
  "media-typer": "^2.0.0",
127
- "strtok3": "^10.3.5",
127
+ "strtok3": "^10.4.0",
128
128
  "token-types": "^6.1.2",
129
129
  "uint8array-extras": "^1.6.0",
130
130
  "win-guid": "^0.2.1"