From 2e7a206b20f8788bd458271b5bbc295803ca7280 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:23:54 +0800 Subject: [PATCH] fix(process): preserve symlink target bytes --- docs/process-capture.md | 8 ++ src/domain/process/processCapture.ts | 40 +++++-- src/domain/process/symlinkTarget.ts | 23 ++++ .../capture/FilesystemCoverage.test.ts | 104 ++++++++++++++++++ src/process/capture/FilesystemSnapshot.ts | 19 +++- .../capture/ProcessFilesystemEffects.ts | 35 +++++- .../processFilesystemCoverageAdapters.test.ts | 30 +++++ 7 files changed, 244 insertions(+), 15 deletions(-) create mode 100644 src/domain/process/symlinkTarget.ts diff --git a/docs/process-capture.md b/docs/process-capture.md index 88156c3ab3..5a7c33533c 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -663,3 +663,11 @@ REA then preserves ownership uncertainty and can report `cleanup_incomplete`; it does not disable verification to continue. A sandbox that explicitly allows this read-only process inspector is a separate prerequisite from the compiler and cache settings. + +Symlink observations preserve their stored target bytes in `symlink_target_hex` +(lowercase hexadecimal). `symlink_target` retains valid UTF-8 text and is `null` +when the target bytes cannot be decoded as UTF-8. Filesystem effects compare +these exact bytes without resolving the link. Older captures without the hex +field still parse; a legacy target containing U+FFFD has ambiguous byte identity, +so matching target text alone produces an `unknown` effect. Symlink descendants +remain outside filesystem observation regardless of target encoding. diff --git a/src/domain/process/processCapture.ts b/src/domain/process/processCapture.ts index c859a7456e..ece9d5667a 100644 --- a/src/domain/process/processCapture.ts +++ b/src/domain/process/processCapture.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { observedSymlinkTarget } from "./symlinkTarget.js"; import { utcDateTimeSchema } from "../offsetDateTime.js"; import { jsonValueSchema } from "../jsonValue.js"; @@ -80,7 +81,9 @@ export type FileState = FileStateIdentity & | { readonly type: "symlink"; readonly sha256: null; - readonly symlink_target: string; + readonly symlink_target: string | null; + /** Exact stored target bytes; omitted by legacy captures. */ + readonly symlink_target_hex?: string | undefined; } | { readonly type: "directory" | "other"; @@ -346,12 +349,35 @@ const fileStateSchema = z.discriminatedUnion("type", [ .nullable(), symlink_target: z.null(), }), - z.object({ - ...fileStateShape, - type: z.literal("symlink"), - sha256: z.null(), - symlink_target: z.string(), - }), + z + .object({ + ...fileStateShape, + type: z.literal("symlink"), + sha256: z.null(), + symlink_target: z.string().nullable(), + symlink_target_hex: z + .string() + .regex(/^(?:[a-f0-9]{2})+$/u) + .optional(), + }) + .superRefine((file, context) => { + if (file.symlink_target_hex === undefined) { + if (file.symlink_target === null) + context.addIssue({ + code: "custom", + message: "An unknown symlink target requires its exact bytes", + }); + return; + } + const { text } = observedSymlinkTarget( + Buffer.from(file.symlink_target_hex, "hex"), + ); + if (file.symlink_target !== text) + context.addIssue({ + code: "custom", + message: "Symlink target text contradicts its exact bytes", + }); + }), z.object({ ...fileStateShape, type: z.enum(["directory", "other"]), diff --git a/src/domain/process/symlinkTarget.ts b/src/domain/process/symlinkTarget.ts new file mode 100644 index 0000000000..2f13596fea --- /dev/null +++ b/src/domain/process/symlinkTarget.ts @@ -0,0 +1,23 @@ +import type { FileState } from "./processCapture.js"; + +/** Preserve stored bytes; undecodable targets have no invented text spelling. */ +export const observedSymlinkTarget = ( + raw: Buffer, +): { text: string | null; hex: string } => { + const decoded = raw.toString("utf8"); + return { + text: Buffer.from(decoded, "utf8").equals(raw) ? decoded : null, + hex: raw.toString("hex"), + }; +}; + +/** Old replacement characters cannot establish the target's original bytes. */ +export const symlinkTargetIdentity = ( + file: Extract, +): string | null => { + if (file.symlink_target_hex !== undefined) return file.symlink_target_hex; + const text = file.symlink_target; + if (text === null || text.includes("\ufffd")) return null; + const raw = Buffer.from(text, "utf8"); + return raw.toString("utf8") === text ? raw.toString("hex") : null; +}; diff --git a/src/process/capture/FilesystemCoverage.test.ts b/src/process/capture/FilesystemCoverage.test.ts index 46e6ea4a3e..2d5a3d891b 100644 --- a/src/process/capture/FilesystemCoverage.test.ts +++ b/src/process/capture/FilesystemCoverage.test.ts @@ -632,3 +632,107 @@ it.skipIf(process.platform === "win32")( expect(snapshot.coverage.enumeration_truncated).toBe(false); }, ); + +it.skipIf(process.platform === "win32")( + "preserves non-UTF-8 symlink target bytes through snapshot effects and schema parsing", + async () => { + const { processFilesystemSnapshotSchema } = + await import("../../domain/process/processCapture.js"); + const root = await createTestTempDirectory("rea-fs-symlink-bytes-"); + const path = join(root, "link"); + const first = Buffer.from([0x61, 0xff]); + const second = Buffer.from([0x61, 0xfe]); + await symlink(first, path); + const scenario = processScenarioSchema.parse({ + executable: process.execPath, + filesystem_observation_paths: [root], + }); + const before = processFilesystemSnapshotSchema.parse( + await snapshotRoots(scenario), + ); + await rm(path); + await symlink(second, path); + const after = processFilesystemSnapshotSchema.parse( + await snapshotRoots(scenario), + ); + const link = before.files.find((file) => file.type === "symlink"); + expect(link).toMatchObject({ + symlink_target: null, + symlink_target_hex: "61ff", + }); + expect( + classifyFilesystemEffects(before, after).find( + ({ path }) => path === "root_0:link", + )?.status, + ).toBe("modified"); + expect( + classifyFilesystemEffects(before, before).find( + ({ path }) => path === "root_0:link", + )?.status, + ).toBe("unchanged"); + expect( + processFilesystemSnapshotSchema.safeParse({ + ...before, + files: [{ ...link, symlink_target: "a�" }], + }).success, + ).toBe(false); + expect( + processFilesystemSnapshotSchema.safeParse({ + ...before, + files: [{ ...link, symlink_target_hex: "f" }], + }).success, + ).toBe(false); + expect( + processFilesystemSnapshotSchema.safeParse({ + ...before, + files: [{ ...link, symlink_target_hex: undefined }], + }).success, + ).toBe(false); + const legacy = processFilesystemSnapshotSchema.parse({ + ...before, + files: [{ ...link, symlink_target: "a�", symlink_target_hex: undefined }], + }); + expect(classifyFilesystemEffects(legacy, legacy)[0]?.status).toBe( + "unknown", + ); + const differentLegacy = processFilesystemSnapshotSchema.parse({ + ...legacy, + files: legacy.files.map((file) => ({ ...file, symlink_target: "b�" })), + }); + expect(classifyFilesystemEffects(legacy, differentLegacy)[0]?.status).toBe( + "modified", + ); + const valid = processFilesystemSnapshotSchema.parse({ + ...before, + files: [ + { + ...link, + symlink_target: "a�", + symlink_target_hex: Buffer.from("a�").toString("hex"), + }, + ], + }); + expect(classifyFilesystemEffects(valid, valid)[0]?.status).toBe( + "unchanged", + ); + const oldValid = processFilesystemSnapshotSchema.parse({ + ...before, + files: [ + { ...link, symlink_target: "target", symlink_target_hex: undefined }, + ], + }); + const newValid = processFilesystemSnapshotSchema.parse({ + ...before, + files: [ + { + ...link, + symlink_target: "target", + symlink_target_hex: Buffer.from("target").toString("hex"), + }, + ], + }); + expect(classifyFilesystemEffects(oldValid, newValid)[0]?.status).toBe( + "unchanged", + ); + }, +); diff --git a/src/process/capture/FilesystemSnapshot.ts b/src/process/capture/FilesystemSnapshot.ts index a2084963f9..8ca0bf0087 100644 --- a/src/process/capture/FilesystemSnapshot.ts +++ b/src/process/capture/FilesystemSnapshot.ts @@ -1,3 +1,4 @@ +import { observedSymlinkTarget } from "../../domain/process/symlinkTarget.js"; import { createHash } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import { lstat, open, opendir, readlink, realpath } from "node:fs/promises"; @@ -95,14 +96,19 @@ const observeSymlink = async ( path: string, before: Stats, signal?: AbortSignal, -): Promise> => { - const target = await readEntryObservation(() => readlink(path), signal); +): Promise< + Result<{ readonly text: string | null; readonly hex: string }, EntryOmission> +> => { + const target = await readEntryObservation( + () => readlink(path, { encoding: "buffer" }), + signal, + ); if (!target.ok) return target; const after = await readEntryObservation(() => lstat(path), signal); if (!after.ok) return after; - return hasSameIdentity(before, after.value, "symlink") - ? target - : err({ reason: "identity_changed", system_code: null }); + if (!hasSameIdentity(before, after.value, "symlink")) + return err({ reason: "identity_changed", system_code: null }); + return ok(observedSymlinkTarget(target.value)); }; type PathLookup = @@ -501,7 +507,8 @@ export const snapshotRoots = async ( mode: stats.mode, size: stats.size, sha256: null, - symlink_target: target.value, + symlink_target: target.value.text, + symlink_target_hex: target.value.hex, }); unobservedSubtrees.push({ path: `${rootAlias}:${relativePath}`, diff --git a/src/process/capture/ProcessFilesystemEffects.ts b/src/process/capture/ProcessFilesystemEffects.ts index e515e49820..7c6de35520 100644 --- a/src/process/capture/ProcessFilesystemEffects.ts +++ b/src/process/capture/ProcessFilesystemEffects.ts @@ -1,3 +1,4 @@ +import { symlinkTargetIdentity } from "../../domain/process/symlinkTarget.js"; import type { FilesystemCheckpoint, ProcessFilesystemSnapshot, @@ -73,8 +74,35 @@ export const classifyFilesystemEffects = ( const metadataChanged = beforeFile.type !== afterFile.type || beforeFile.mode !== afterFile.mode || - beforeFile.size !== afterFile.size || + beforeFile.size !== afterFile.size; + const beforeTarget = + beforeFile.type === "symlink" + ? symlinkTargetIdentity(beforeFile) + : null; + const afterTarget = + afterFile.type === "symlink" ? symlinkTargetIdentity(afterFile) : null; + // Different decoded spellings prove different bytes even when an old + // capture cannot recover either target's complete byte identity. + const targetTextChanged = + beforeFile.type === "symlink" && + afterFile.type === "symlink" && + beforeFile.symlink_target !== null && + afterFile.symlink_target !== null && beforeFile.symlink_target !== afterFile.symlink_target; + if ( + !metadataChanged && + !targetTextChanged && + beforeFile.type === "symlink" && + afterFile.type === "symlink" && + (beforeTarget === null || afterTarget === null) + ) + return { + path, + status: "unknown", + before: beforeFile, + after: afterFile, + reason: "A legacy symlink target has no unambiguous byte identity.", + } as const; if ( !metadataChanged && beforeFile.type === "file" && @@ -92,7 +120,10 @@ export const classifyFilesystemEffects = ( return { path, status: - metadataChanged || beforeFile.sha256 !== afterFile.sha256 + metadataChanged || + targetTextChanged || + beforeTarget !== afterTarget || + beforeFile.sha256 !== afterFile.sha256 ? "modified" : "unchanged", before: beforeFile, diff --git a/tests/boundary/process/processFilesystemCoverageAdapters.test.ts b/tests/boundary/process/processFilesystemCoverageAdapters.test.ts index 1772af643c..d3166d4bc1 100644 --- a/tests/boundary/process/processFilesystemCoverageAdapters.test.ts +++ b/tests/boundary/process/processFilesystemCoverageAdapters.test.ts @@ -629,3 +629,33 @@ it }, 20_000, ); + +itWithCaptureCapability( + "retains raw symlink target mutations through the real capture CLI", + { timeout: 20_000 }, + async () => { + const root = await createTestTempDirectory("rea-symlink-byte-cli-"); + await symlink(Buffer.from([0x61, 0xff]), join(root, "link")); + const evidence = await captureViaCli({ + executable: process.execPath, + arguments: [ + "-e", + "const fs=require('node:fs');fs.unlinkSync('link');fs.symlinkSync(Buffer.from([0x61,0xfe]),'link')", + ], + working_directory: root, + filesystem_observation_paths: [root], + timeout_ms: 5000, + }); + const capture = parseProcessCapture(evidence.normalized_result); + expect( + capture.files_before.find(({ path }) => path === "root_0:link"), + ).toMatchObject({ symlink_target: null, symlink_target_hex: "61ff" }); + expect( + capture.files_after.find(({ path }) => path === "root_0:link"), + ).toMatchObject({ symlink_target: null, symlink_target_hex: "61fe" }); + expect( + capture.filesystem_effects.find(({ path }) => path === "root_0:link") + ?.status, + ).toBe("modified"); + }, +);