mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 17:28:08 +00:00
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:
1 parent
81c09773be
commit
411804afbf
5 files changed
+106
-15
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user