diff --git a/docs/specs/native/ntfs-mft/spec.md b/docs/specs/native/ntfs-mft/spec.md index f7200178aa..1f88f42886 100644 --- a/docs/specs/native/ntfs-mft/spec.md +++ b/docs/specs/native/ntfs-mft/spec.md @@ -2,7 +2,7 @@ This specification records an implementation increment and its original problem statement. For current behavior and limitations, see the artifact inventory -guide. +workflow below. ## Problem and outcome @@ -36,7 +36,9 @@ its own. scanning attributes. The domain decoder retains resident `$FILE_NAME` names, namespaces, parent references and source offsets. Report incomplete coverage when the walk cannot finish or an attribute list may refer to - extensions. The inventory does not yet expose the individual names. + extensions. The first 32 record samples expose individual names, parent + references, namespaces, FILETIME values, sizes and attribute bits. Keep each + name attribute separate, including multiple names for one record. ## Acceptance diff --git a/src/domain/artifactFormatMetadata.ts b/src/domain/artifactFormatMetadata.ts index ae7b273429..6fcf7258a2 100644 --- a/src/domain/artifactFormatMetadata.ts +++ b/src/domain/artifactFormatMetadata.ts @@ -304,6 +304,21 @@ const ntfsFileReferenceSchema = z.strictObject({ recordNumber: offset, sequenceNumber: offset, }); +const unsignedDecimalSchema = z.string().regex(/^(?:0|[1-9][0-9]*)$/u); +export const ntfsFileNameSchema = z.strictObject({ + fileOffset: offset, + parent: ntfsFileReferenceSchema, + namespace: offset, + name: z.string(), + createdFileTime: unsignedDecimalSchema, + modifiedFileTime: unsignedDecimalSchema, + mftChangedFileTime: unsignedDecimalSchema, + accessedFileTime: unsignedDecimalSchema, + allocatedBytes: unsignedDecimalSchema, + dataBytes: unsignedDecimalSchema, + fileAttributes: offset, + reparseOrEa: offset, +}); const ntfsRecordHeaderSchema = z.strictObject({ index: offset, fileOffset: offset, @@ -325,6 +340,8 @@ const ntfsRecordHeaderSchema = z.strictObject({ logFileSequenceNumber: z.string().regex(/^(?:0|[1-9][0-9]*)$/u), firstAttributeOffset: offset, nextAttributeId: offset, + fileNames: z.array(ntfsFileNameSchema).exactOptional(), + fileNameCoverage: z.enum(["complete", "incomplete"]).exactOptional(), fixups: z.strictObject({ sequenceNumber: offset, entries: offset, diff --git a/src/domain/windows/ntfsMft.test.ts b/src/domain/windows/ntfsMft.test.ts index 6a599367d5..be16f5bd49 100644 --- a/src/domain/windows/ntfsMft.test.ts +++ b/src/domain/windows/ntfsMft.test.ts @@ -269,6 +269,47 @@ describe("MFT fixup coverage", () => { expect(bytes).toEqual(original); }); + it("preserves multiple hard-link names with independent parents and source metadata", () => { + const bytes = recordOf({ hardLinkCount: 2 }); + const view = new DataView(bytes.buffer); + for (const [index, name] of ["first", "second"].entries()) { + const attribute = 56 + index * 104; + const value = attribute + 24; + view.setUint32(attribute, 0x30, true); + view.setUint32(attribute + 4, 104, true); + view.setUint32(attribute + 16, 66 + name.length * 2, true); + view.setUint16(attribute + 20, 24, true); + view.setBigUint64(value, (3n << 48n) | BigInt(5 + index), true); + view.setBigUint64(value + 8, 133000000000000001n, true); + view.setBigUint64(value + 48, 9007199254740993n, true); + bytes.set([name.length, index + 1], value + 64); + bytes.set(Buffer.from(name, "utf16le"), value + 66); + } + view.setUint32(264, 0xffffffff, true); + view.setUint32(24, 272, true); + const parsed = parseMftImage(bytes); + if (!parsed.ok) throw new Error(parsed.error.message); + expect(parsed.value.metadata.record_headers[0]).toMatchObject({ + fileNameCoverage: "complete", + fileNames: [ + { + name: "first", + parent: { recordNumber: 5 }, + fileOffset: 80, + createdFileTime: "133000000000000001", + dataBytes: "9007199254740993", + }, + { + name: "second", + parent: { recordNumber: 6 }, + fileOffset: 184, + namespace: 2, + }, + ], + }); + expect(parsed.value.metadata.file_name_count).toBe(2); + }); + it("keeps a record whose sector trailer is not the sequence number, and reports it", () => { const parsed = parseMftImage( streamOf(recordOf(), recordOf({ sequenceNumber: 9, corruptSector: 1 })), diff --git a/src/domain/windows/ntfsMft.ts b/src/domain/windows/ntfsMft.ts index ac598b7e53..92e2eba175 100644 --- a/src/domain/windows/ntfsMft.ts +++ b/src/domain/windows/ntfsMft.ts @@ -24,8 +24,8 @@ const USA_MIN_ENTRIES = 2; /** NTFS fixes up one 512-byte sector at a time. */ export const FIXUP_SECTOR_BYTES = 512; /** - * Fixed whole-file ceiling. Records are inventoried from their headers only; - * attributes and their payloads are not decoded, so this is deliberately + * Fixed whole-file ceiling. Records and supported resident metadata are inventoried; no volume data + * is fetched, so this is deliberately * smaller than a volume's MFT can be. */ export const MFT_MAX_FILE_BYTES = 512 * 1024 * 1024; @@ -87,6 +87,15 @@ export interface MftFileName { readonly parent: MftFileReference; readonly namespace: number; readonly name: string; + /** Raw FILETIME values and sizes remain lossless decimal strings. */ + readonly createdFileTime: string; + readonly modifiedFileTime: string; + readonly mftChangedFileTime: string; + readonly accessedFileTime: string; + readonly allocatedBytes: string; + readonly dataBytes: string; + readonly fileAttributes: number; + readonly reparseOrEa: number; } export interface MftRecord { @@ -117,7 +126,7 @@ export interface MftRecord { readonly logFileSequenceNumber: string; /** Declared offset of the first attribute. */ readonly firstAttributeOffset: number; - /** Declared id of the next attribute; no attribute decoding follows yet. */ + /** Declared id of the next attribute. */ readonly nextAttributeId: number; readonly fixups: MftRecordFixups; readonly fileNames: readonly MftFileName[]; @@ -146,10 +155,7 @@ export interface MftMetadata { readonly fixup_mismatch_count: number; readonly fixup_unverified_record_count: number; readonly truncated_tail_bytes: number; - readonly record_headers: readonly Omit< - MftRecord, - "fileNames" | "fileNameCoverage" - >[]; + readonly record_headers: readonly MftRecord[]; readonly omitted_record_headers: number; readonly file_name_count: number; readonly file_name_incomplete_record_count: number; @@ -358,10 +364,7 @@ const scanMft = ( fixup_mismatch_count: mismatchCount, fixup_unverified_record_count: unverifiedCount, truncated_tail_bytes: truncatedTailBytes, - record_headers: recordHeaders.map( - ({ fileNames: _fileNames, fileNameCoverage: _coverage, ...header }) => - header, - ), + record_headers: recordHeaders, omitted_record_headers: recordCount - recordHeaders.length, file_name_count: fileNameCount, file_name_incomplete_record_count: incompleteNames, @@ -523,6 +526,14 @@ const readFileNames = ( parent: decodeMftFileReference(restored.readBigUInt64LE(value)), namespace: restored.readUInt8(value + 65), name: restored.toString("utf16le", nameStart, nameStart + nameBytes), + createdFileTime: restored.readBigUInt64LE(value + 8).toString(), + modifiedFileTime: restored.readBigUInt64LE(value + 16).toString(), + mftChangedFileTime: restored.readBigUInt64LE(value + 24).toString(), + accessedFileTime: restored.readBigUInt64LE(value + 32).toString(), + allocatedBytes: restored.readBigUInt64LE(value + 40).toString(), + dataBytes: restored.readBigUInt64LE(value + 48).toString(), + fileAttributes: restored.readUInt32LE(value + 56), + reparseOrEa: restored.readUInt32LE(value + 60), }); } offset += length; diff --git a/tests/boundary/mcp/ntfsMftAdmission.test.ts b/tests/boundary/mcp/ntfsMftAdmission.test.ts index ebe108a3bc..d6b6b447d0 100644 --- a/tests/boundary/mcp/ntfsMftAdmission.test.ts +++ b/tests/boundary/mcp/ntfsMftAdmission.test.ts @@ -31,6 +31,16 @@ const expectMftInventory = (value: unknown): void => { sequenceNumber: 1, declaredRecordNumber: null, fixups: { verifiedSectors: 2 }, + fileNameCoverage: "complete", + fileNames: [ + { + name: "name", + parent: { recordNumber: 5, sequenceNumber: 3 }, + fileOffset: 80, + createdFileTime: "133000000000000001", + dataBytes: "9007199254740993", + }, + ], }, ], omitted_record_headers: 0, @@ -48,8 +58,18 @@ mcpTest( record.writeUInt16LE(3, 6); record.writeUInt16LE(1, 16); record.writeUInt16LE(56, 20); - record.writeUInt32LE(64, 24); - record.writeUInt32LE(0xffff_ffff, 56); + record.writeUInt32LE(168, 24); + record.writeUInt32LE(0x30, 56); + record.writeUInt32LE(104, 60); + record.writeUInt32LE(74, 72); + record.writeUInt16LE(24, 76); + record.writeBigUInt64LE(0x0003_0000_0000_0005n, 80); + record.writeBigUInt64LE(133000000000000001n, 88); + record.writeBigUInt64LE(9007199254740993n, 128); + record.writeUInt8(4, 144); + record.writeUInt8(1, 145); + record.write("name", 146, "utf16le"); + record.writeUInt32LE(0xffff_ffff, 160); record.writeUInt32LE(1024, 28); record.writeUInt16LE(5, 42); record.writeUInt16LE(5, 510);