fit-file-parser 6.2.0 → 6.2.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Change Log
2
2
 
3
+ ## 6.2.2 - 2026-10-11
4
+
5
+ ### Fixed
6
+
7
+ - Restore ordinary decoding of existing Bryton and Magene FIT files whose
8
+ normal record headers set reserved bit 4. Their activity data was readable
9
+ before 5.2.0 and is recovered without modifying the original bytes.
10
+ - Require complete definitions and native/developer payloads for messages
11
+ using reserved-header compatibility, including ordinary data records that
12
+ reference a definition with the reserved bit set.
13
+
14
+ ### Compatibility
15
+
16
+ - Strict parsing, full raw-message retention, and the standalone raw reader
17
+ continue rejecting reserved header bits. Bit 5 in data headers remains
18
+ invalid in all modes. Checksum behavior still follows `force`.
19
+ - No new options or parsed output fields. Existing successful imports need no
20
+ migration; retry previously rejected originals with the updated parser.
21
+
22
+ ## 6.2.1 - 2026-10-10
23
+
24
+ ### Added
25
+
26
+ - Add the opt-in `strict` parser option to require complete files and records,
27
+ valid definitions, and no trailing bytes in every parsing API. Checksum
28
+ validation remains controlled by `force`.
29
+
30
+ ### Fixed
31
+
32
+ - Apply the same recovery policy to `parseFilesAsync()`, `parseAsync()`, and
33
+ callback parsing. The all-file API now accepts missing CRC bytes with
34
+ `force: true` when `strict` is disabled, and ignores a non-FIT suffix.
35
+ - Keep existing decoder behavior, recovery, and output when `strict` is not
36
+ requested, including handling of malformed records and definitions.
37
+ - Apply additional definition and record bounds checks only in strict mode.
38
+
39
+ ### Compatibility
40
+
41
+ - `force` still defaults to `true`; `strict` defaults to `false`.
42
+ - `strict: true` requires exactly one file for `parseAsync()` and callback
43
+ parsing, or complete concatenated files for `parseFilesAsync()`.
44
+ - Temperature mappings and unit conversions are unchanged.
45
+
3
46
  ## 6.2.0 - 2026-10-10
4
47
 
5
48
  ### Added
package/README.md CHANGED
@@ -77,12 +77,23 @@ for (const file of files) {
77
77
 
78
78
  Each result has independent message definitions, developer descriptions,
79
79
  timestamps, and activity collections. Results are never merged. A single FIT
80
- file returns a one-element array. Empty input, incomplete files (including
81
- missing file CRC bytes), and trailing non-FIT bytes reject the entire call,
82
- even with `force: true`. Records and definitions extending beyond the declared
83
- data section are also rejected. CRC validation follows the `force` option for every
84
- file. Failures reject with an `Error`. The existing `parseAsync()` and callback
85
- APIs continue to parse the first FIT file only.
80
+ file returns a one-element array. All parsing APIs share the same `force` and
81
+ `strict` options. By default, complete fields in a partial final record can be
82
+ recovered, and a non-FIT suffix after parsed files is ignored. With `force: true`,
83
+ missing CRC bytes are also accepted, including when the next FIT header starts
84
+ at the declared data boundary or after one CRC byte. The parser never scans
85
+ record payloads for guessed file boundaries.
86
+
87
+ Use `{ force: false, strict: true }` to require complete files and records,
88
+ valid checksums, and no trailing bytes. `{ force: true, strict: true }` enforces
89
+ the same structure while tolerating known checksum failures. Any file that
90
+ cannot be parsed rejects the whole call with an `Error`. Empty input, invalid
91
+ headers, and declared data beyond the available input
92
+ remain errors in every mode.
93
+
94
+ The existing `parseAsync()` and callback APIs return the first FIT file only.
95
+ With `strict: true`, they require exactly one file; use `parseFilesAsync()` for
96
+ strict validation of concatenated input.
86
97
 
87
98
  ### Callback API
88
99
 
@@ -120,6 +131,7 @@ All options are optional.
120
131
  | --------------------------- | --------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
121
132
  | `mode` | `list`, `cascade`, `both` | `list` | Controls whether primary activity collections are returned as root lists, nested data, or both. |
122
133
  | `force` | `true`, `false` | `true` | Skips header and file CRC validation and enables supported best-effort field recovery. Structural header and data bounds are always validated. |
134
+ | `strict` | `true`, `false` | `false` | Requires CRC bytes, complete records, valid definitions, and no trailing bytes. CRC validation remains controlled by `force`. |
123
135
  | `speedUnit` | `m/s`, `km/h`, `mph` | `m/s` | Converts speed-related fields. |
124
136
  | `lengthUnit` | `m`, `km`, `mi` | `m` | Converts distance, altitude, and other length-related fields. |
125
137
  | `temperatureUnit` | `celsius`, `°C`, `kelvin`, `fahrenheit` | `celsius` | Converts temperature fields. `°C` remains available as a legacy alias. |
@@ -133,6 +145,31 @@ All options are optional.
133
145
  too short, have an invalid header size or signature, or declare data beyond the
134
146
  available bytes are rejected in both modes.
135
147
 
148
+ `strict: false` keeps the existing decoder behavior, including its handling of
149
+ malformed records and definitions. `parseAsync()` and callback callers keep
150
+ their current defaults, recovery, and output. `parseFilesAsync()` uses that
151
+ same decoder independently for each file.
152
+
153
+ Ordinary decoding tolerates the reserved bit 4 in normal record headers used
154
+ by some existing device files. Messages using this compatibility behavior must
155
+ have complete definitions and payloads inside the declared data section. Bit 5
156
+ in data headers remains invalid. Strict mode, full raw-message retention, and
157
+ the standalone raw reader continue rejecting reserved header bits. Original
158
+ bytes and the caller's checksum policy are unchanged.
159
+
160
+ `strict: true` adds definition and record bounds checks so CRC bytes cannot be
161
+ decoded as measurements. Full raw-message retention (`includeRawMessages`)
162
+ continues to require complete records as it did before this option was added.
163
+
164
+ Choose options according to the input you accept:
165
+
166
+ | Options | Policy |
167
+ | -------------------------------- | --------------------------------------------------------- |
168
+ | Default | Best effort, including missing or invalid CRCs. |
169
+ | `{ force: false }` | Validate CRCs while keeping existing field recovery. |
170
+ | `{ force: true, strict: true }` | Require complete structure while tolerating invalid CRCs. |
171
+ | `{ force: false, strict: true }` | Require complete structure and valid CRCs. |
172
+
136
173
  ### Lossless developer fields
137
174
 
138
175
  By default, developer fields retain the existing decoded, name-keyed output.
package/dist/binary.d.ts CHANGED
@@ -8,6 +8,8 @@ export interface MessageTypeDefinition {
8
8
  fieldDefs: FieldDefinition[];
9
9
  developerFieldDefs?: DeveloperFieldDefinition[];
10
10
  rawData?: any[];
11
+ completeDefinition?: boolean;
12
+ reservedHeaderBit?: boolean;
11
13
  }
12
14
  export interface DeveloperFieldDefinition {
13
15
  developerDataIndex: number;
package/dist/binary.js CHANGED
@@ -417,6 +417,8 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
417
417
  const recordHeader = blob[startIndex];
418
418
  let localMessageType = recordHeader & 15;
419
419
  const isCompressedTimestamp = (recordHeader & CompressedHeaderMask) === CompressedHeaderMask;
420
+ const reservedHeaderBit = !isCompressedTimestamp && (recordHeader & 0x10) !== 0;
421
+ const strictHeaders = options.strict || retainsRawMessages(options);
420
422
  if (isCompressedTimestamp) {
421
423
  // compressed timestamp
422
424
  localMessageType = (recordHeader & CompressedLocalMsgNumMask) >> 5;
@@ -424,15 +426,16 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
424
426
  else if ((recordHeader & 64) === 64) {
425
427
  // is definition message
426
428
  // startIndex + 1 is reserved
427
- if (retainsRawMessages(options) && startIndex + 6 > dataEnd) {
429
+ const requireCompleteDefinition = strictHeaders || reservedHeaderBit;
430
+ if (requireCompleteDefinition && startIndex + 6 > dataEnd) {
428
431
  throw new Error('Invalid FIT definition bounds');
429
432
  }
430
433
  const hasDeveloperData = (recordHeader & 32) === 32;
431
434
  const lEnd = blob[startIndex + 2] === 0;
432
435
  const numberOfFields = blob[startIndex + 5];
433
436
  const nativeDefinitionsEnd = startIndex + 6 + numberOfFields * 3;
434
- if (retainsRawMessages(options)
435
- && ((recordHeader & 16) !== 0
437
+ if (requireCompleteDefinition
438
+ && ((strictHeaders && reservedHeaderBit)
436
439
  || blob[startIndex + 1] !== 0
437
440
  || blob[startIndex + 2] > 1
438
441
  || nativeDefinitionsEnd > dataEnd
@@ -444,7 +447,7 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
444
447
  : 0;
445
448
  const definitionEnd = nativeDefinitionsEnd
446
449
  + (hasDeveloperData ? 1 + numberOfDeveloperDataFields * 3 : 0);
447
- if (retainsRawMessages(options) && definitionEnd > dataEnd) {
450
+ if (requireCompleteDefinition && definitionEnd > dataEnd) {
448
451
  throw new Error('Invalid FIT developer definition bounds');
449
452
  }
450
453
  const mTypeDef = {
@@ -457,6 +460,13 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
457
460
  fieldDefs: [],
458
461
  developerFieldDefs: [],
459
462
  rawData: [],
463
+ // A later data header may use the reserved bit with an ordinary
464
+ // definition. Do not extend legacy partial-record recovery to that case.
465
+ completeDefinition: startIndex + 6 <= dataEnd
466
+ && blob[startIndex + 1] === 0
467
+ && blob[startIndex + 2] <= 1
468
+ && definitionEnd <= dataEnd,
469
+ reservedHeaderBit,
460
470
  };
461
471
  const message = getFitMessage(mTypeDef.globalMessageNumber);
462
472
  for (let i = 0; i < numberOfFields; i++) {
@@ -509,13 +519,20 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
509
519
  nextIndex: hasDeveloperData ? nextIndexWithDeveloperData : nextIndex,
510
520
  };
511
521
  }
512
- if (!isCompressedTimestamp && (recordHeader & 0x30) !== 0) {
522
+ // Some existing producer files set bit 4 without changing the record layout.
523
+ // Recover only that bit in ordinary decoding; bit 5 and strict/raw parsing
524
+ // retain their previous rejection policy. Never rewrite the source bytes.
525
+ if (!isCompressedTimestamp && ((recordHeader & 0x20) !== 0 || (strictHeaders && reservedHeaderBit))) {
513
526
  throw new Error('Invalid FIT data record header');
514
527
  }
515
528
  const messageType = messageTypes[localMessageType];
516
529
  if (!messageType) {
517
530
  throw new Error('FIT data record has no local definition');
518
531
  }
532
+ const reservedHeaderCompatibility = reservedHeaderBit || messageType.reservedHeaderBit;
533
+ if (reservedHeaderCompatibility && !messageType.completeDefinition) {
534
+ throw new Error('Invalid FIT definition for reserved header compatibility');
535
+ }
519
536
  let messageSize = 0;
520
537
  let readDataFromIndex = startIndex + 1;
521
538
  const fields = {};
@@ -530,7 +547,7 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
530
547
  const rawDeveloperFields = includeRawDeveloperFields || includeRawMessage ? [] : undefined;
531
548
  const unmappedFields = options.includeUnmappedMessages ? [] : undefined;
532
549
  const unmappedDeveloperFields = options.includeUnmappedMessages ? [] : undefined;
533
- if (retainsRawMessages(options)) {
550
+ if (strictHeaders || reservedHeaderCompatibility) {
534
551
  const nativeSize = messageType.fieldDefs.reduce((total, field, index) => (total + (isCompressedTimestamp && index === 0 && field.fDefNo === 253 ? 0 : field.size)), 0);
535
552
  const developerSize = developerFieldDefs.reduce((total, field) => total + field.size, 0);
536
553
  if (startIndex + 1 + nativeSize + developerSize > dataEnd) {
@@ -665,7 +682,7 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
665
682
  if (isCompressedTimestamp) {
666
683
  const previousTimestamp = decoderState.lastTimestamp;
667
684
  if (previousTimestamp === undefined) {
668
- if (!options.force) {
685
+ if (!options.force || options.strict) {
669
686
  throw new Error('Compressed timestamp requires a previous timestamp');
670
687
  }
671
688
  }
@@ -8,6 +8,8 @@ export interface MessageTypeDefinition {
8
8
  fieldDefs: FieldDefinition[];
9
9
  developerFieldDefs?: DeveloperFieldDefinition[];
10
10
  rawData?: any[];
11
+ completeDefinition?: boolean;
12
+ reservedHeaderBit?: boolean;
11
13
  }
12
14
  export interface DeveloperFieldDefinition {
13
15
  developerDataIndex: number;
@@ -423,6 +423,8 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
423
423
  const recordHeader = blob[startIndex];
424
424
  let localMessageType = recordHeader & 15;
425
425
  const isCompressedTimestamp = (recordHeader & CompressedHeaderMask) === CompressedHeaderMask;
426
+ const reservedHeaderBit = !isCompressedTimestamp && (recordHeader & 0x10) !== 0;
427
+ const strictHeaders = options.strict || retainsRawMessages(options);
426
428
  if (isCompressedTimestamp) {
427
429
  // compressed timestamp
428
430
  localMessageType = (recordHeader & CompressedLocalMsgNumMask) >> 5;
@@ -430,15 +432,16 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
430
432
  else if ((recordHeader & 64) === 64) {
431
433
  // is definition message
432
434
  // startIndex + 1 is reserved
433
- if (retainsRawMessages(options) && startIndex + 6 > dataEnd) {
435
+ const requireCompleteDefinition = strictHeaders || reservedHeaderBit;
436
+ if (requireCompleteDefinition && startIndex + 6 > dataEnd) {
434
437
  throw new Error('Invalid FIT definition bounds');
435
438
  }
436
439
  const hasDeveloperData = (recordHeader & 32) === 32;
437
440
  const lEnd = blob[startIndex + 2] === 0;
438
441
  const numberOfFields = blob[startIndex + 5];
439
442
  const nativeDefinitionsEnd = startIndex + 6 + numberOfFields * 3;
440
- if (retainsRawMessages(options)
441
- && ((recordHeader & 16) !== 0
443
+ if (requireCompleteDefinition
444
+ && ((strictHeaders && reservedHeaderBit)
442
445
  || blob[startIndex + 1] !== 0
443
446
  || blob[startIndex + 2] > 1
444
447
  || nativeDefinitionsEnd > dataEnd
@@ -450,7 +453,7 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
450
453
  : 0;
451
454
  const definitionEnd = nativeDefinitionsEnd
452
455
  + (hasDeveloperData ? 1 + numberOfDeveloperDataFields * 3 : 0);
453
- if (retainsRawMessages(options) && definitionEnd > dataEnd) {
456
+ if (requireCompleteDefinition && definitionEnd > dataEnd) {
454
457
  throw new Error('Invalid FIT developer definition bounds');
455
458
  }
456
459
  const mTypeDef = {
@@ -463,6 +466,13 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
463
466
  fieldDefs: [],
464
467
  developerFieldDefs: [],
465
468
  rawData: [],
469
+ // A later data header may use the reserved bit with an ordinary
470
+ // definition. Do not extend legacy partial-record recovery to that case.
471
+ completeDefinition: startIndex + 6 <= dataEnd
472
+ && blob[startIndex + 1] === 0
473
+ && blob[startIndex + 2] <= 1
474
+ && definitionEnd <= dataEnd,
475
+ reservedHeaderBit,
466
476
  };
467
477
  const message = (0, messages_js_1.getFitMessage)(mTypeDef.globalMessageNumber);
468
478
  for (let i = 0; i < numberOfFields; i++) {
@@ -515,13 +525,20 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
515
525
  nextIndex: hasDeveloperData ? nextIndexWithDeveloperData : nextIndex,
516
526
  };
517
527
  }
518
- if (!isCompressedTimestamp && (recordHeader & 0x30) !== 0) {
528
+ // Some existing producer files set bit 4 without changing the record layout.
529
+ // Recover only that bit in ordinary decoding; bit 5 and strict/raw parsing
530
+ // retain their previous rejection policy. Never rewrite the source bytes.
531
+ if (!isCompressedTimestamp && ((recordHeader & 0x20) !== 0 || (strictHeaders && reservedHeaderBit))) {
519
532
  throw new Error('Invalid FIT data record header');
520
533
  }
521
534
  const messageType = messageTypes[localMessageType];
522
535
  if (!messageType) {
523
536
  throw new Error('FIT data record has no local definition');
524
537
  }
538
+ const reservedHeaderCompatibility = reservedHeaderBit || messageType.reservedHeaderBit;
539
+ if (reservedHeaderCompatibility && !messageType.completeDefinition) {
540
+ throw new Error('Invalid FIT definition for reserved header compatibility');
541
+ }
525
542
  let messageSize = 0;
526
543
  let readDataFromIndex = startIndex + 1;
527
544
  const fields = {};
@@ -536,7 +553,7 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
536
553
  const rawDeveloperFields = includeRawDeveloperFields || includeRawMessage ? [] : undefined;
537
554
  const unmappedFields = options.includeUnmappedMessages ? [] : undefined;
538
555
  const unmappedDeveloperFields = options.includeUnmappedMessages ? [] : undefined;
539
- if (retainsRawMessages(options)) {
556
+ if (strictHeaders || reservedHeaderCompatibility) {
540
557
  const nativeSize = messageType.fieldDefs.reduce((total, field, index) => (total + (isCompressedTimestamp && index === 0 && field.fDefNo === 253 ? 0 : field.size)), 0);
541
558
  const developerSize = developerFieldDefs.reduce((total, field) => total + field.size, 0);
542
559
  if (startIndex + 1 + nativeSize + developerSize > dataEnd) {
@@ -671,7 +688,7 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
671
688
  if (isCompressedTimestamp) {
672
689
  const previousTimestamp = decoderState.lastTimestamp;
673
690
  if (previousTimestamp === undefined) {
674
- if (!options.force) {
691
+ if (!options.force || options.strict) {
675
692
  throw new Error('Compressed timestamp requires a previous timestamp');
676
693
  }
677
694
  }
@@ -7,7 +7,10 @@ export { getFitCoursePointId, getFitGarminProductDisplayName, getFitGarminProduc
7
7
  export { FitMessageReaderError, fitTimestampToUnixMilliseconds, getFitBaseTypeId, readFitMessages, readFitStringField, readFitUnsignedField, } from './raw-message-reader.js';
8
8
  export type { FitMessageReaderErrorCode, FitMessageReaderIssue, FitMessageReaderIssueCode, FitMessageReaderOptions, FitMessageReaderResult, FitRawDeveloperField, FitRawField, FitRawMessage, } from './raw-message-reader.js';
9
9
  export interface FitParserOptions {
10
+ /** Skips CRC validation and enables supported best-effort recovery. Defaults to true. */
10
11
  force?: boolean;
12
+ /** Requires complete files and records, and rejects trailing bytes. Defaults to false. */
13
+ strict?: boolean;
11
14
  speedUnit?: string;
12
15
  lengthUnit?: string;
13
16
  temperatureUnit?: string;
@@ -29,9 +32,9 @@ export default class FitParser {
29
32
  constructor(options?: FitParserOptions);
30
33
  parseAsync(content: ArrayBuffer | Buffer<ArrayBuffer>): Promise<ParsedFit>;
31
34
  /**
32
- * Parses every complete FIT file in an input, preserving file order and
33
- * independent decoder state. Rejects incomplete files and trailing bytes;
34
- * each file uses this parser's options, including its CRC policy.
35
+ * Parses FIT files in input order with independent decoder state. Uses the
36
+ * same force and strict policies as parseAsync; strict mode rejects trailing
37
+ * bytes, while the default ignores a non-FIT suffix after parsed files.
35
38
  * Rejects with an Error when any file cannot be parsed.
36
39
  */
37
40
  parseFilesAsync(content: ArrayBuffer | Buffer<ArrayBuffer>): Promise<ParsedFit[]>;
@@ -31,21 +31,31 @@ Object.defineProperty(exports, "getFitBaseTypeId", { enumerable: true, get: func
31
31
  Object.defineProperty(exports, "readFitMessages", { enumerable: true, get: function () { return raw_message_reader_js_1.readFitMessages; } });
32
32
  Object.defineProperty(exports, "readFitStringField", { enumerable: true, get: function () { return raw_message_reader_js_1.readFitStringField; } });
33
33
  Object.defineProperty(exports, "readFitUnsignedField", { enumerable: true, get: function () { return raw_message_reader_js_1.readFitUnsignedField; } });
34
+ function hasFitHeader(blob, start) {
35
+ const headerLength = blob[start];
36
+ return blob.length - start >= headerLength
37
+ && (headerLength === 12 || headerLength === 14)
38
+ && blob[start + 8] === 0x2E
39
+ && blob[start + 9] === 0x46
40
+ && blob[start + 10] === 0x49
41
+ && blob[start + 11] === 0x54;
42
+ }
34
43
  class FitParser {
35
44
  constructor(options = {}) {
36
- var _a, _b, _c, _d;
45
+ var _a, _b, _c, _d, _e;
37
46
  this.options = {
38
47
  force: options.force != null ? options.force : true,
48
+ strict: (_a = options.strict) !== null && _a !== void 0 ? _a : false,
39
49
  speedUnit: options.speedUnit || 'm/s',
40
50
  lengthUnit: options.lengthUnit || 'm',
41
51
  temperatureUnit: options.temperatureUnit || 'celsius',
42
52
  elapsedRecordField: options.elapsedRecordField || false,
43
53
  pressureUnit: options.pressureUnit || 'bar',
44
54
  mode: options.mode || 'list',
45
- includeRawDeveloperFields: (_a = options.includeRawDeveloperFields) !== null && _a !== void 0 ? _a : false,
46
- includeRawMessages: (_b = options.includeRawMessages) !== null && _b !== void 0 ? _b : false,
47
- includeUnmappedMessages: (_c = options.includeUnmappedMessages) !== null && _c !== void 0 ? _c : false,
48
- rawMessagesOnly: (_d = options.rawMessagesOnly) !== null && _d !== void 0 ? _d : false,
55
+ includeRawDeveloperFields: (_b = options.includeRawDeveloperFields) !== null && _b !== void 0 ? _b : false,
56
+ includeRawMessages: (_c = options.includeRawMessages) !== null && _c !== void 0 ? _c : false,
57
+ includeUnmappedMessages: (_d = options.includeUnmappedMessages) !== null && _d !== void 0 ? _d : false,
58
+ rawMessagesOnly: (_e = options.rawMessagesOnly) !== null && _e !== void 0 ? _e : false,
49
59
  };
50
60
  }
51
61
  parseAsync(content) {
@@ -61,9 +71,9 @@ class FitParser {
61
71
  });
62
72
  }
63
73
  /**
64
- * Parses every complete FIT file in an input, preserving file order and
65
- * independent decoder state. Rejects incomplete files and trailing bytes;
66
- * each file uses this parser's options, including its CRC policy.
74
+ * Parses FIT files in input order with independent decoder state. Uses the
75
+ * same force and strict policies as parseAsync; strict mode rejects trailing
76
+ * bytes, while the default ignores a non-FIT suffix after parsed files.
67
77
  * Rejects with an Error when any file cannot be parsed.
68
78
  */
69
79
  parseFilesAsync(content) {
@@ -75,10 +85,13 @@ class FitParser {
75
85
  let start = 0;
76
86
  do {
77
87
  const remaining = blob.length - start;
88
+ const headerLength = blob[start];
89
+ if (results.length > 0 && !this.options.strict && !hasFitHeader(blob, start)) {
90
+ break;
91
+ }
78
92
  if (remaining < 12) {
79
93
  throw new Error('File to small to be a FIT file');
80
94
  }
81
- const headerLength = blob[start];
82
95
  if (headerLength !== 12 && headerLength !== 14) {
83
96
  throw new Error('Incorrect header size');
84
97
  }
@@ -94,13 +107,20 @@ class FitParser {
94
107
  const dataLength = new DataView(blob.buffer, blob.byteOffset + start, remaining)
95
108
  .getUint32(4, true);
96
109
  const crcStart = start + headerLength + dataLength;
97
- if (crcStart > blob.length) {
98
- throw new Error('File data exceeds input length');
99
- }
100
- if (crcStart + 2 > blob.length) {
101
- throw new Error('File CRC missing');
110
+ // File completeness and CRC checks belong to the shared parser below,
111
+ // preserving the same validation policy and error order in every API.
112
+ let end = Math.min(crcStart + 2, blob.length);
113
+ // Missing CRC bytes can also precede the next file. Check only the
114
+ // declared boundary and its one-byte CRC variant; never scan activity
115
+ // payloads for a guessed file boundary.
116
+ if (this.options.force && !this.options.strict) {
117
+ if (hasFitHeader(blob, crcStart)) {
118
+ end = crcStart;
119
+ }
120
+ else if (hasFitHeader(blob, crcStart + 1)) {
121
+ end = crcStart + 1;
122
+ }
102
123
  }
103
- const end = crcStart + 2;
104
124
  try {
105
125
  results.push(yield new Promise((resolve, reject) => {
106
126
  this.parseFile(blob.slice(start, end).buffer, (error, data) => {
@@ -110,7 +130,7 @@ class FitParser {
110
130
  else if (data) {
111
131
  resolve(data);
112
132
  }
113
- }, true);
133
+ });
114
134
  }));
115
135
  }
116
136
  catch (error) {
@@ -124,7 +144,7 @@ class FitParser {
124
144
  parse(content, callback) {
125
145
  this.parseFile(content, callback);
126
146
  }
127
- parseFile(content, callback, validateRecordBounds = false) {
147
+ parseFile(content, callback) {
128
148
  var _a, _b, _c;
129
149
  const blob = content instanceof ArrayBuffer
130
150
  ? new Uint8Array(content)
@@ -167,10 +187,14 @@ class FitParser {
167
187
  callback('File data exceeds input length', undefined);
168
188
  return;
169
189
  }
170
- if (!this.options.force && crcStart + 2 > blob.length) {
190
+ if ((!this.options.force || this.options.strict) && crcStart + 2 > blob.length) {
171
191
  callback('File CRC missing', undefined);
172
192
  return;
173
193
  }
194
+ if (this.options.strict && crcStart + 2 < blob.length) {
195
+ callback('Unexpected bytes after FIT file', undefined);
196
+ return;
197
+ }
174
198
  if (!this.options.force) {
175
199
  const crcFile = blob[crcStart] + (blob[crcStart + 1] << 8);
176
200
  const crcFileCalc = (0, binary_js_1.calculateCRC)(blob, 0, crcStart);
@@ -234,9 +258,7 @@ class FitParser {
234
258
  let pausedTime = 0;
235
259
  while (loopIndex < crcStart) {
236
260
  const { globalMessageNumber, littleEndian, message, messageType, nextIndex, compressedTimestamp, rawFields: recordRawFields, rawDeveloperFields: recordRawDeveloperFields, unmappedFields: recordUnmappedFields, unmappedDeveloperFields: recordUnmappedDeveloperFields, } = (0, binary_js_1.readRecord)(blob, messageTypes, developerFields, loopIndex, this.options, startDate, pausedTime, dataView, decoderState, crcStart);
237
- // Complete-file parsing also validates bounds without requiring raw
238
- // retention. The single-file API keeps its existing recovery behavior.
239
- if (validateRecordBounds
261
+ if (this.options.strict
240
262
  && (!Number.isInteger(nextIndex) || nextIndex <= loopIndex || nextIndex > crcStart)) {
241
263
  throw new Error('Invalid FIT record bounds');
242
264
  }
@@ -7,7 +7,10 @@ export { getFitCoursePointId, getFitGarminProductDisplayName, getFitGarminProduc
7
7
  export { FitMessageReaderError, fitTimestampToUnixMilliseconds, getFitBaseTypeId, readFitMessages, readFitStringField, readFitUnsignedField, } from './raw-message-reader.js';
8
8
  export type { FitMessageReaderErrorCode, FitMessageReaderIssue, FitMessageReaderIssueCode, FitMessageReaderOptions, FitMessageReaderResult, FitRawDeveloperField, FitRawField, FitRawMessage, } from './raw-message-reader.js';
9
9
  export interface FitParserOptions {
10
+ /** Skips CRC validation and enables supported best-effort recovery. Defaults to true. */
10
11
  force?: boolean;
12
+ /** Requires complete files and records, and rejects trailing bytes. Defaults to false. */
13
+ strict?: boolean;
11
14
  speedUnit?: string;
12
15
  lengthUnit?: string;
13
16
  temperatureUnit?: string;
@@ -29,9 +32,9 @@ export default class FitParser {
29
32
  constructor(options?: FitParserOptions);
30
33
  parseAsync(content: ArrayBuffer | Buffer<ArrayBuffer>): Promise<ParsedFit>;
31
34
  /**
32
- * Parses every complete FIT file in an input, preserving file order and
33
- * independent decoder state. Rejects incomplete files and trailing bytes;
34
- * each file uses this parser's options, including its CRC policy.
35
+ * Parses FIT files in input order with independent decoder state. Uses the
36
+ * same force and strict policies as parseAsync; strict mode rejects trailing
37
+ * bytes, while the default ignores a non-FIT suffix after parsed files.
35
38
  * Rejects with an Error when any file cannot be parsed.
36
39
  */
37
40
  parseFilesAsync(content: ArrayBuffer | Buffer<ArrayBuffer>): Promise<ParsedFit[]>;
@@ -12,21 +12,31 @@ import { mapDataIntoLap, mapDataIntoSession } from './helper.js';
12
12
  export { FitBaseType, FitEncoder } from './fit-encoder.js';
13
13
  export { getFitCoursePointId, getFitGarminProductDisplayName, getFitGarminProductName, getFitManufacturerName, getFitSportId, getFitSportName, getFitSubSportId, getFitSubSportName, } from './profile-lookup.js';
14
14
  export { FitMessageReaderError, fitTimestampToUnixMilliseconds, getFitBaseTypeId, readFitMessages, readFitStringField, readFitUnsignedField, } from './raw-message-reader.js';
15
+ function hasFitHeader(blob, start) {
16
+ const headerLength = blob[start];
17
+ return blob.length - start >= headerLength
18
+ && (headerLength === 12 || headerLength === 14)
19
+ && blob[start + 8] === 0x2E
20
+ && blob[start + 9] === 0x46
21
+ && blob[start + 10] === 0x49
22
+ && blob[start + 11] === 0x54;
23
+ }
15
24
  export default class FitParser {
16
25
  constructor(options = {}) {
17
- var _a, _b, _c, _d;
26
+ var _a, _b, _c, _d, _e;
18
27
  this.options = {
19
28
  force: options.force != null ? options.force : true,
29
+ strict: (_a = options.strict) !== null && _a !== void 0 ? _a : false,
20
30
  speedUnit: options.speedUnit || 'm/s',
21
31
  lengthUnit: options.lengthUnit || 'm',
22
32
  temperatureUnit: options.temperatureUnit || 'celsius',
23
33
  elapsedRecordField: options.elapsedRecordField || false,
24
34
  pressureUnit: options.pressureUnit || 'bar',
25
35
  mode: options.mode || 'list',
26
- includeRawDeveloperFields: (_a = options.includeRawDeveloperFields) !== null && _a !== void 0 ? _a : false,
27
- includeRawMessages: (_b = options.includeRawMessages) !== null && _b !== void 0 ? _b : false,
28
- includeUnmappedMessages: (_c = options.includeUnmappedMessages) !== null && _c !== void 0 ? _c : false,
29
- rawMessagesOnly: (_d = options.rawMessagesOnly) !== null && _d !== void 0 ? _d : false,
36
+ includeRawDeveloperFields: (_b = options.includeRawDeveloperFields) !== null && _b !== void 0 ? _b : false,
37
+ includeRawMessages: (_c = options.includeRawMessages) !== null && _c !== void 0 ? _c : false,
38
+ includeUnmappedMessages: (_d = options.includeUnmappedMessages) !== null && _d !== void 0 ? _d : false,
39
+ rawMessagesOnly: (_e = options.rawMessagesOnly) !== null && _e !== void 0 ? _e : false,
30
40
  };
31
41
  }
32
42
  parseAsync(content) {
@@ -42,9 +52,9 @@ export default class FitParser {
42
52
  });
43
53
  }
44
54
  /**
45
- * Parses every complete FIT file in an input, preserving file order and
46
- * independent decoder state. Rejects incomplete files and trailing bytes;
47
- * each file uses this parser's options, including its CRC policy.
55
+ * Parses FIT files in input order with independent decoder state. Uses the
56
+ * same force and strict policies as parseAsync; strict mode rejects trailing
57
+ * bytes, while the default ignores a non-FIT suffix after parsed files.
48
58
  * Rejects with an Error when any file cannot be parsed.
49
59
  */
50
60
  parseFilesAsync(content) {
@@ -56,10 +66,13 @@ export default class FitParser {
56
66
  let start = 0;
57
67
  do {
58
68
  const remaining = blob.length - start;
69
+ const headerLength = blob[start];
70
+ if (results.length > 0 && !this.options.strict && !hasFitHeader(blob, start)) {
71
+ break;
72
+ }
59
73
  if (remaining < 12) {
60
74
  throw new Error('File to small to be a FIT file');
61
75
  }
62
- const headerLength = blob[start];
63
76
  if (headerLength !== 12 && headerLength !== 14) {
64
77
  throw new Error('Incorrect header size');
65
78
  }
@@ -75,13 +88,20 @@ export default class FitParser {
75
88
  const dataLength = new DataView(blob.buffer, blob.byteOffset + start, remaining)
76
89
  .getUint32(4, true);
77
90
  const crcStart = start + headerLength + dataLength;
78
- if (crcStart > blob.length) {
79
- throw new Error('File data exceeds input length');
80
- }
81
- if (crcStart + 2 > blob.length) {
82
- throw new Error('File CRC missing');
91
+ // File completeness and CRC checks belong to the shared parser below,
92
+ // preserving the same validation policy and error order in every API.
93
+ let end = Math.min(crcStart + 2, blob.length);
94
+ // Missing CRC bytes can also precede the next file. Check only the
95
+ // declared boundary and its one-byte CRC variant; never scan activity
96
+ // payloads for a guessed file boundary.
97
+ if (this.options.force && !this.options.strict) {
98
+ if (hasFitHeader(blob, crcStart)) {
99
+ end = crcStart;
100
+ }
101
+ else if (hasFitHeader(blob, crcStart + 1)) {
102
+ end = crcStart + 1;
103
+ }
83
104
  }
84
- const end = crcStart + 2;
85
105
  try {
86
106
  results.push(yield new Promise((resolve, reject) => {
87
107
  this.parseFile(blob.slice(start, end).buffer, (error, data) => {
@@ -91,7 +111,7 @@ export default class FitParser {
91
111
  else if (data) {
92
112
  resolve(data);
93
113
  }
94
- }, true);
114
+ });
95
115
  }));
96
116
  }
97
117
  catch (error) {
@@ -105,7 +125,7 @@ export default class FitParser {
105
125
  parse(content, callback) {
106
126
  this.parseFile(content, callback);
107
127
  }
108
- parseFile(content, callback, validateRecordBounds = false) {
128
+ parseFile(content, callback) {
109
129
  var _a, _b, _c;
110
130
  const blob = content instanceof ArrayBuffer
111
131
  ? new Uint8Array(content)
@@ -148,10 +168,14 @@ export default class FitParser {
148
168
  callback('File data exceeds input length', undefined);
149
169
  return;
150
170
  }
151
- if (!this.options.force && crcStart + 2 > blob.length) {
171
+ if ((!this.options.force || this.options.strict) && crcStart + 2 > blob.length) {
152
172
  callback('File CRC missing', undefined);
153
173
  return;
154
174
  }
175
+ if (this.options.strict && crcStart + 2 < blob.length) {
176
+ callback('Unexpected bytes after FIT file', undefined);
177
+ return;
178
+ }
155
179
  if (!this.options.force) {
156
180
  const crcFile = blob[crcStart] + (blob[crcStart + 1] << 8);
157
181
  const crcFileCalc = calculateCRC(blob, 0, crcStart);
@@ -215,9 +239,7 @@ export default class FitParser {
215
239
  let pausedTime = 0;
216
240
  while (loopIndex < crcStart) {
217
241
  const { globalMessageNumber, littleEndian, message, messageType, nextIndex, compressedTimestamp, rawFields: recordRawFields, rawDeveloperFields: recordRawDeveloperFields, unmappedFields: recordUnmappedFields, unmappedDeveloperFields: recordUnmappedDeveloperFields, } = readRecord(blob, messageTypes, developerFields, loopIndex, this.options, startDate, pausedTime, dataView, decoderState, crcStart);
218
- // Complete-file parsing also validates bounds without requiring raw
219
- // retention. The single-file API keeps its existing recovery behavior.
220
- if (validateRecordBounds
242
+ if (this.options.strict
221
243
  && (!Number.isInteger(nextIndex) || nextIndex <= loopIndex || nextIndex > crcStart)) {
222
244
  throw new Error('Invalid FIT record bounds');
223
245
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fit-file-parser",
3
3
  "type": "module",
4
- "version": "6.2.0",
4
+ "version": "6.2.2",
5
5
  "private": false,
6
6
  "description": "JavaScript and TypeScript library for parsing and encoding FIT files, with developer-field support, raw message access, and ESM/CommonJS builds.",
7
7
  "author": {