Skip to content

Commit 665c19a

Browse files
fix: reject apply targets that are symlinks (#118)
Phase-2 rename replaced symlink paths with regular files. Detect symlinks via lstat before read/write and report a conflict instead.
1 parent 5ee5f2e commit 665c19a

3 files changed

Lines changed: 57 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stainless-code/codemap": patch
3+
---
4+
5+
Reject apply targets that are symlinks so phase-2 rename cannot replace a link with a regular file.

src/application/apply-engine.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { describe, expect, it } from "bun:test";
22
import {
33
chmodSync,
4+
lstatSync,
45
mkdirSync,
56
mkdtempSync,
67
readFileSync,
78
readdirSync,
9+
symlinkSync,
810
writeFileSync,
911
} from "node:fs";
1012
import { tmpdir } from "node:os";
@@ -597,6 +599,38 @@ describe("applyDiffPayload", () => {
597599

598600
expect(result.conflicts[0]?.reason).toBe("path escapes project root");
599601
});
602+
603+
it("rejects symlinked file_path and leaves the symlink intact", () => {
604+
const root = tmpProject();
605+
writeSource(root, "real.ts", "const foo = 1;\n");
606+
symlinkSync(join(root, "real.ts"), join(root, "link.ts"));
607+
608+
const result = applyDiffPayload({
609+
rows: [
610+
{
611+
file_path: "link.ts",
612+
line_start: 1,
613+
before_pattern: "foo",
614+
after_pattern: "bar",
615+
},
616+
],
617+
projectRoot: root,
618+
dryRun: false,
619+
});
620+
621+
expect(result.applied).toBe(false);
622+
expect(result.conflicts).toEqual([
623+
{
624+
file_path: "link.ts",
625+
line_start: 1,
626+
before_pattern: "foo",
627+
actual_at_line: "",
628+
reason: "path is a symlink",
629+
},
630+
]);
631+
expect(lstatSync(join(root, "link.ts")).isSymbolicLink()).toBe(true);
632+
expect(readSource(root, "real.ts")).toBe("const foo = 1;\n");
633+
});
600634
});
601635

602636
describe("overlap detection (F2 — triangulated review 2026-05-06)", () => {

src/application/apply-engine.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
*/
3131

3232
import { randomBytes } from "node:crypto";
33-
import { readFileSync, renameSync, writeFileSync } from "node:fs";
33+
import { lstatSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3434
import { isAbsolute, resolve } from "node:path";
3535

3636
import {
@@ -82,6 +82,7 @@ export type ConflictReason =
8282
| "line out of range"
8383
| "line content drifted"
8484
| "path escapes project root"
85+
| "path is a symlink"
8586
| "duplicate edit on same line";
8687

8788
/** Q5 envelope shape — single shape across `dry-run` and `apply` modes. */
@@ -172,8 +173,23 @@ export function applyDiffPayload(opts: ApplyDiffPayloadOpts): ApplyJsonPayload {
172173

173174
let source = sourceCache.get(canonicalPath);
174175
if (source === undefined) {
176+
const absPath = resolve(resolvedRoot, canonicalPath);
175177
try {
176-
source = readFileSync(resolve(resolvedRoot, canonicalPath), "utf8");
178+
if (lstatSync(absPath).isSymbolicLink()) {
179+
conflicts.push({
180+
file_path: canonicalPath,
181+
line_start: lineStart,
182+
before_pattern: before,
183+
actual_at_line: "",
184+
reason: "path is a symlink",
185+
});
186+
continue;
187+
}
188+
} catch {
189+
// Missing path — readFileSync below reports "file missing".
190+
}
191+
try {
192+
source = readFileSync(absPath, "utf8");
177193
} catch {
178194
conflicts.push({
179195
file_path: canonicalPath,

0 commit comments

Comments
 (0)