mirror of
https://github.com/morluto/rea
synced 2026-10-27 22:45:19 -04:00
fix(process): preserve symlink target bytes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user