fix(artifacts): retain only the bounded MFT header sample

Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
N0zoM1z0andgensokyo-dev authored and gensokyo-dev committed 2026-11-11 10:03:35 +08:00
1 parent 1a446cdade
commit 9505c73824
3 files changed
+60 -31

No files matched your search

+5 -2
View File
@@ -5,7 +5,10 @@ import {
type ArtifactFormatMetadata,
} from "../domain/artifactFormatMetadata.js";
import type { RegionDecodeFailure } from "../domain/firmware/regionDecoder.js";
import { createMftDecoder, type MftImage } from "../domain/windows/ntfsMft.js";
import {
createMftDecoder,
type MftInventory,
} from "../domain/windows/ntfsMft.js";
import {
ArtifactReaderFailure,
type ArtifactEntry,
@@ -128,7 +131,7 @@ export class NtfsMftArtifactReader implements ArtifactReader {
this.#limitations = image.limitations;
}
async #decode(signal?: AbortSignal): Promise<MftImage> {
async #decode(signal?: AbortSignal): Promise<MftInventory> {
const { handle, initial } = await this.#source.read(signal);
const decoder = createMftDecoder(initial.size);
if (!decoder.ok) throw this.#failure(decoder.error);
+13
View File
@@ -374,6 +374,19 @@ describe("parseMftImage refusals and limits", () => {
});
});
it("returns the same inventory without retaining all the decoded records", () => {
const bytes = streamOf(...Array.from({ length: 33 }, () => recordOf()));
const decoder = createMftDecoder(bytes.length);
const parsed = parseMftImage(bytes);
if (!decoder.ok || !parsed.ok) throw new Error("invalid fixture");
expect(decoder.value.write(bytes).ok).toBe(true);
const inventory = decoder.value.finish();
if (!inventory.ok) throw new Error(inventory.error.message);
expect(inventory.value).not.toHaveProperty("records");
expect(inventory.value.metadata).toEqual(parsed.value.metadata);
expect(inventory.value.limitations).toEqual(parsed.value.limitations);
});
it("decodes the same stream through the chunked decoder", () => {
const stream = streamOf(recordOf(), recordOf({ flags: 0x0003 }));
const decoder = createMftDecoder(stream.length);
+42 -29
View File
@@ -110,14 +110,17 @@ export interface MftRecord {
readonly fixups: MftRecordFixups;
}
export interface MftImage {
export interface MftInventory {
readonly metadata: MftMetadata;
/** The record size the stream declared, or the fixed default when it did not. */
readonly recordBytes: number;
readonly records: readonly MftRecord[];
readonly limitations: readonly string[];
}
export interface MftImage extends MftInventory {
readonly records: readonly MftRecord[];
}
export interface MftMetadata {
readonly kind: "ntfs-mft";
readonly record_bytes: number;
@@ -210,6 +213,15 @@ export const hasMftFileRecordHeader = (prefix: Uint8Array): boolean => {
export const parseMftImage = (
bytes: Uint8Array,
): Result<MftImage, RegionDecodeFailure> => {
const records: MftRecord[] = [];
const inventory = scanMft(bytes, records);
return inventory.ok ? ok({ ...inventory.value, records }) : inventory;
};
const scanMft = (
bytes: Uint8Array,
retainedRecords?: MftRecord[],
): Result<MftInventory, RegionDecodeFailure> => {
if (bytes.length < NTFS_MFT_MIN_RECORD_BYTES)
return err(
failure(
@@ -230,7 +242,13 @@ export const parseMftImage = (
`record 0 declares a record size of ${String(declaredRecordBytes)} bytes, which is not a supported power of two from ${String(MFT_MIN_RECORD_BYTES)} to ${String(MFT_MAX_RECORD_BYTES)}; records were read at the ${String(MFT_DEFAULT_RECORD_BYTES)}-byte default`,
);
const records: MftRecord[] = [];
const recordHeaders: MftRecord[] = [];
let recordCount = 0;
let inUseCount = 0;
let directoryCount = 0;
let extensionCount = 0;
let mismatchCount = 0;
let unverifiedCount = 0;
let invalidSlots = 0;
let invalidExtents = 0;
let firstInvalidExtentOffset: number | undefined;
@@ -251,7 +269,16 @@ export const parseMftImage = (
continue;
}
const record = readRecord(bytes, offset, offset / recordBytes, recordBytes);
records.push(record);
retainedRecords?.push(record);
if (recordHeaders.length < MFT_INLINE_HEADER_LIMIT)
recordHeaders.push(record);
recordCount += 1;
if (record.inUse) inUseCount += 1;
if (record.directory) directoryCount += 1;
if (record.baseRecordReference !== null) extensionCount += 1;
if (record.fixups.mismatchSector !== null) mismatchCount += 1;
else if (record.fixups.verifiedSectors < recordBytes / FIXUP_SECTOR_BYTES)
unverifiedCount += 1;
if (!record.extentValid) {
invalidExtents += 1;
firstInvalidExtentOffset ??= offset;
@@ -273,19 +300,10 @@ export const parseMftImage = (
`${String(truncatedTailBytes)} trailing byte(s) do not fill a ${String(recordBytes)}-byte record and were not parsed`,
);
const mismatchCount = records.filter(
(record) => record.fixups.mismatchSector !== null,
).length;
if (mismatchCount !== 0)
limitations.push(
`${String(mismatchCount)} record(s) hold a sector trailer that is not the update sequence number; their attributes were not read and no later step should trust them`,
);
const unverifiedCount = records.filter(
(record) =>
record.fixups.mismatchSector === null &&
(record.fixups.malformed ||
record.fixups.coveredSectors < recordBytes / FIXUP_SECTOR_BYTES),
).length;
if (unverifiedCount !== 0)
limitations.push(
`${String(unverifiedCount)} record(s) carry an update sequence array that does not cover every sector; those trailers were neither restored nor verified`,
@@ -294,23 +312,18 @@ export const parseMftImage = (
const metadata: MftMetadata = {
kind: "ntfs-mft",
record_bytes: recordBytes,
record_count: records.length,
in_use_record_count: records.filter((record) => record.inUse).length,
directory_record_count: records.filter((record) => record.directory).length,
extension_record_count: records.filter(
(record) => record.baseRecordReference !== null,
).length,
record_count: recordCount,
in_use_record_count: inUseCount,
directory_record_count: directoryCount,
extension_record_count: extensionCount,
fixup_mismatch_count: mismatchCount,
fixup_unverified_record_count: unverifiedCount,
truncated_tail_bytes: truncatedTailBytes,
record_headers: records.slice(0, MFT_INLINE_HEADER_LIMIT),
omitted_record_headers: Math.max(
0,
records.length - MFT_INLINE_HEADER_LIMIT,
),
record_headers: recordHeaders,
omitted_record_headers: recordCount - recordHeaders.length,
};
return ok({ metadata, recordBytes, records, limitations });
return ok({ metadata, recordBytes, limitations });
};
const readRecord = (
@@ -422,7 +435,7 @@ const verifyFixups = (
};
/** Buffer the admitted input once while accepting arbitrary chunk boundaries. */
class StreamingMftDecoder implements RegionDecoder<MftImage> {
class StreamingMftDecoder implements RegionDecoder<MftInventory> {
readonly #bytes: Uint8Array;
#written = 0;
@@ -438,16 +451,16 @@ class StreamingMftDecoder implements RegionDecoder<MftImage> {
return ok(undefined);
}
finish(): Result<MftImage, RegionDecodeFailure> {
finish(): Result<MftInventory, RegionDecodeFailure> {
if (this.#written !== this.#bytes.length)
return err(failure("stream ended before the admitted file length"));
return parseMftImage(this.#bytes);
return scanMft(this.#bytes);
}
}
export const createMftDecoder = (
fileBytes: number,
): Result<RegionDecoder<MftImage>, RegionDecodeFailure> =>
): Result<RegionDecoder<MftInventory>, RegionDecodeFailure> =>
Number.isSafeInteger(fileBytes) &&
fileBytes >= 0 &&
fileBytes <= MFT_MAX_FILE_BYTES