From 2e34aa0433fa4fdb2af394e08af87e7bb47cde96 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:47:33 +0000 Subject: [PATCH] feat(ntfs): seal paired journal acquisitions as Evidence Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- src/application/ntfs/NtfsJournalService.ts | 186 +++++++++++++++++++++ src/domain/windows/ntfsJournalQuery.ts | 87 ++++++++++ 2 files changed, 273 insertions(+) create mode 100644 src/application/ntfs/NtfsJournalService.ts create mode 100644 src/domain/windows/ntfsJournalQuery.ts diff --git a/src/application/ntfs/NtfsJournalService.ts b/src/application/ntfs/NtfsJournalService.ts new file mode 100644 index 0000000000..f8867dbd9e --- /dev/null +++ b/src/application/ntfs/NtfsJournalService.ts @@ -0,0 +1,186 @@ +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { + artifactFileCancellationCause, + artifactFilePrimaryFacts, +} from "../../artifacts/ArtifactFileOperation.js"; +import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + projectStableArtifactFailure, + stableArtifactInputRecovery, +} from "../../artifacts/StableArtifactFailure.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { + NTFS_JOURNAL_FILE_BYTES, + NTFS_JOURNAL_OPERATION as operation, + NTFS_JOURNAL_PROVIDER, + ntfsJournalInputSchema, + ntfsJournalResultSchema, +} from "../../domain/windows/ntfsJournalQuery.js"; +import { composeNtfsJournalTimeline } from "../../domain/windows/ntfsJournalTimeline.js"; +import { parseMftImage } from "../../domain/windows/ntfsMft.js"; +import { parseAnalysisInput } from "../AnalysisInput.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; + +type PathParameter = "mft_path" | "journal_path"; +const inputFailure = ( + parameter: PathParameter, + message: string, + cause?: unknown, +) => + new AnalysisInputError( + operation, + { + cause, + ...(cause instanceof ArtifactReaderFailure + ? stableArtifactInputRecovery(cause) + : {}), + }, + [{ path: [parameter], reason: "invalid_format", message }], + ); + +const readFailure = ( + failure: unknown, + path: string, + parameter: PathParameter, +): AnalysisError => { + const { cause } = artifactFilePrimaryFacts(failure); + if (cause instanceof ArtifactReaderFailure) { + if (cause.reason === "cancelled") + return new AnalysisCancelledError(operation, { + cause: artifactFileCancellationCause(cause), + }); + if (cause.reason === "integrity") + return new AnalysisArtifactChangedError(operation, path, cause.message, { + cause, + }); + if (cause.reason === "limit") + return new AnalysisResourceConstraintError( + operation, + "file-size", + cause.message, + { + maximum_file_bytes: NTFS_JOURNAL_FILE_BYTES, + }, + { cause, retryable: false }, + ); + } + return inputFailure( + parameter, + `Could not read the selected acquisition at ${path}`, + cause, + ); +}; + +const readAcquisition = async ( + path: string, + parameter: PathParameter, + resources: ArtifactResourceScope, + signal?: AbortSignal, +) => { + try { + return ok( + await resources.run(() => + readStableArtifact( + path, + NTFS_JOURNAL_FILE_BYTES, + signal, + undefined, + resources, + ), + ), + ); + } catch (cause) { + return err( + projectStableArtifactFailure( + cause, + readFailure(cause, path, parameter), + path, + NTFS_JOURNAL_PROVIDER.id, + operation, + ), + ); + } +}; + +export const inspectNtfsJournalEvidence = async ( + rawInput: unknown, + resources: ArtifactResourceScope, + signal?: AbortSignal, +): Promise> => { + const admitted = parseAnalysisInput( + operation, + rawInput, + ntfsJournalInputSchema, + signal, + ); + if (!admitted.ok) return admitted; + const input = admitted.value; + const mft = await readAcquisition( + input.mft_path, + "mft_path", + resources, + signal, + ); + if (!mft.ok) return mft; + const parsed = parseMftImage(mft.value.bytes); + if (!parsed.ok) return err(inputFailure("mft_path", parsed.error.message)); + + const journal = await readAcquisition( + input.journal_path, + "journal_path", + resources, + signal, + ); + if (!journal.ok) return journal; + const result = ntfsJournalResultSchema.parse({ + mft: { + path: input.mft_path, + bytes: mft.value.bytes.length, + sha256: mft.value.sha256, + }, + journal: { + path: input.journal_path, + bytes: journal.value.bytes.length, + sha256: journal.value.sha256, + }, + ...composeNtfsJournalTimeline(parsed.value, journal.value.bytes, { + startFileOffset: input.start_file_offset, + maxRows: input.max_rows, + }), + }); + return completeProviderEvidence( + createEvidence( + { + path: input.journal_path, + format: "file", + sha256: journal.value.sha256, + }, + NTFS_JOURNAL_PROVIDER, + { + operation, + predicateType: "rea.ntfs-journal", + parameters: input, + result, + confidence: "derived", + limitations: [ + ...result.mftLimitations, + "Journal bytes alone do not establish the acquisition's historical completeness.", + ], + locations: [ + { kind: "artifact-path", path: input.mft_path }, + { kind: "artifact-path", path: input.journal_path }, + ], + }, + ), + signal, + ); +}; diff --git a/src/domain/windows/ntfsJournalQuery.ts b/src/domain/windows/ntfsJournalQuery.ts new file mode 100644 index 0000000000..aff365774f --- /dev/null +++ b/src/domain/windows/ntfsJournalQuery.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; + +import { digestSchema } from "../digests.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; + +export const NTFS_JOURNAL_OPERATION = "inspect_ntfs_journal" as const; +export const NTFS_JOURNAL_PROVIDER = { + id: "rea-ntfs-journal", + name: "REA offline NTFS change journal", + version: "1", +} as const; + +/** Working-set allowance for each in-memory acquisition, independent of page size. */ +export const NTFS_JOURNAL_FILE_BYTES = 64 * 1024 * 1024; +const offset = z.int().nonnegative(); +export const ntfsJournalRowLimitSchema = z.int().min(1).max(200).default(50); +export const ntfsJournalInputSchema = z.strictObject({ + mft_path: absoluteLocalPathStringSchema, + journal_path: absoluteLocalPathStringSchema, + start_file_offset: offset.default(0), + max_rows: ntfsJournalRowLimitSchema, +}); + +const sourceSchema = z.strictObject({ + path: absoluteLocalPathStringSchema, + bytes: offset, + sha256: digestSchema, +}); +const matchSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("matched"), + record: z.strictObject({ + index: offset, + sequenceNumber: offset, + fileOffset: offset, + }), + }), + z.strictObject({ kind: z.enum(["unresolved", "ambiguous"]) }), +]); +const issueSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("padding"), + fileOffset: offset, + bytes: offset, + }), + z.strictObject({ + kind: z.enum(["malformed", "truncated"]), + fileOffset: offset, + }), + z.strictObject({ + kind: z.literal("unsupported"), + fileOffset: offset, + major: offset, + minor: offset, + }), +]); +const unsignedDecimal = z.string().regex(/^(?:0|[1-9][0-9]*)$/u); +const signedDecimal = z.string().regex(/^(?:0|-?[1-9][0-9]*)$/u); + +export const ntfsJournalResultSchema = z.strictObject({ + mft: sourceSchema, + journal: sourceSchema, + rows: z.array( + z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("event"), + event: z.strictObject({ + fileOffset: offset, + recordBytes: offset, + fileReference: unsignedDecimal, + parentReference: unsignedDecimal, + usn: signedDecimal, + timestampFileTime: unsignedDecimal, + reasonFlags: offset, + sourceFlags: offset, + securityId: offset, + fileAttributes: offset, + name: z.string(), + mft: z.strictObject({ file: matchSchema, parent: matchSchema }), + }), + }), + z.strictObject({ kind: z.literal("coverage"), issue: issueSchema }), + ]), + ), + nextFileOffset: offset.nullable(), + mftLimitations: z.array(z.string()), +});