diff --git a/src/artifacts/NtfsMftArtifactReader.test.ts b/src/artifacts/NtfsMftArtifactReader.test.ts index 9280691840..4f53a31e0c 100644 --- a/src/artifacts/NtfsMftArtifactReader.test.ts +++ b/src/artifacts/NtfsMftArtifactReader.test.ts @@ -78,3 +78,23 @@ it("names a failed fixup in the coverage limitations", async () => { expect.stringMatching(/sector trailer that is not the update sequence/u), ); }); + +it("reports incomplete coverage when FILE extents contradict the record slots", async () => { + const usedTooLarge = recordBytes({}); + usedTooLarge.writeUInt32LE(4096, 24); + const totalTooSmall = recordBytes({}); + totalTooSmall.writeUInt32LE(512, 28); + + const inventory = await scanCanonicalArtifactInventory( + await writeMft("$MFT", Buffer.concat([usedTooLarge, totalTooSmall])), + ); + expect(inventory.coverage.status).toBe("unknown"); + expect(inventory.limitations).toContainEqual( + "2 FILE record(s) declare inconsistent used or total byte counts, beginning at offset 0", + ); + expect(inventory.occurrences[0]?.format_metadata).toMatchObject({ + record_count: 2, + fixup_mismatch_count: 0, + fixup_unverified_record_count: 0, + }); +}); diff --git a/src/domain/windows/ntfsMft.ts b/src/domain/windows/ntfsMft.ts index 74ce98a9ec..a1c9232b8c 100644 --- a/src/domain/windows/ntfsMft.ts +++ b/src/domain/windows/ntfsMft.ts @@ -96,9 +96,10 @@ export interface MftRecord { readonly flags: { readonly raw: number; readonly names: readonly string[] }; readonly inUse: boolean; readonly directory: boolean; - /** Bytes the record claims it uses, and the record size it was parsed at. */ + /** Used and allocated byte counts as declared by the FILE record. */ readonly usedBytes: number; readonly totalBytes: number; + readonly extentValid: boolean; /** The base record this is an extension of, or `null` for a base record. */ readonly baseRecordReference: MftFileReference | null; /** The record number the header declares; `null` when the header predates the field. */ @@ -199,6 +200,8 @@ export const parseMftImage = ( const records: MftRecord[] = []; let invalidSlots = 0; + let invalidExtents = 0; + let firstInvalidExtentOffset: number | undefined; let firstInvalidOffset: number | undefined; for ( let offset = 0; @@ -215,13 +218,23 @@ export const parseMftImage = ( if (!isRecordSize(declaredRecordBytes)) break; continue; } - records.push(readRecord(bytes, offset, offset / recordBytes, recordBytes)); + const record = readRecord(bytes, offset, offset / recordBytes, recordBytes); + records.push(record); + if (!record.extentValid) { + invalidExtents += 1; + firstInvalidExtentOffset ??= offset; + } } if (firstInvalidOffset !== undefined) limitations.push( `record at ${String(firstInvalidOffset)} does not begin with the FILE signature; ${String(invalidSlots)} slot(s) without a FILE signature were found`, ); + if (firstInvalidExtentOffset !== undefined) + limitations.push( + `${String(invalidExtents)} FILE record(s) declare inconsistent used or total byte counts, beginning at offset ${String(firstInvalidExtentOffset)}`, + ); + const truncatedTailBytes = bytes.length % recordBytes; if (truncatedTailBytes !== 0) limitations.push( @@ -284,6 +297,8 @@ const readRecord = ( const usaOffset = view.getUint16(RECORD_HEADER.updateSequenceOffset, true); const fixups = verifyFixups(record, view, usaOffset); + const usedBytes = view.getUint32(RECORD_HEADER.usedBytes, true); + const totalBytes = view.getUint32(RECORD_HEADER.totalBytes, true); return { index, @@ -293,8 +308,12 @@ const readRecord = ( flags: { raw: flags, names: namesOf(flags) }, inUse: (flags & FILE_RECORD_IN_USE) !== 0, directory: (flags & FILE_RECORD_IS_DIRECTORY) !== 0, - usedBytes: view.getUint32(RECORD_HEADER.usedBytes, true), - totalBytes: view.getUint32(RECORD_HEADER.totalBytes, true), + usedBytes, + totalBytes, + extentValid: + totalBytes === recordBytes && + usedBytes >= NTFS_MFT_MIN_RECORD_BYTES && + usedBytes <= totalBytes, baseRecordReference: baseRecordReference === 0n ? null