fix(process): preserve symlink target bytes

This commit is contained in:
morluto
2026-10-27 12:23:54 +08:00
parent ff6b3fb9a1
commit 2e7a206b20
7 changed files with 244 additions and 15 deletions
+8
View File
@@ -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.
+33 -7
View File
@@ -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"]),
+23
View File
@@ -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<FileState, { type: "symlink" }>,
): 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;
};
@@ -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",
);
},
);
+13 -6
View File
@@ -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<Result<string, EntryOmission>> => {
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}`,
@@ -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,
@@ -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");
},
);