feat(ntfs): expose source file names and lossless metadata

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 13:13:09 +08:00
1 parent 81c09773be
commit 411804afbf
5 files changed
+106 -15

No files matched your search

+4 -2
View File
@@ -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
+17
View File
@@ -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,
+41
View File
@@ -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 })),
+22 -11
View File
@@ -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;
+22 -2
View File
@@ -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);