Skip to content

Commit 7b51a42

Browse files
committed
Preserve file permissions when editing through symlinks
Both LocalRuntime and SSHRuntime now preserve the original file permissions when editing through symlinks. If the target file exists, its permissions are read before writing and restored after. For new files, default permissions apply (600 for SSH, system defaults for local). Added integration test verifying permissions are preserved across edits.
1 parent e36f7c6 commit 7b51a42

File tree

3 files changed

+59
-4
lines changed

3 files changed

+59
-4
lines changed

src/runtime/LocalRuntime.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,15 +242,20 @@ export class LocalRuntime implements Runtime {
242242
let tempPath: string;
243243
let writer: WritableStreamDefaultWriter<Uint8Array>;
244244
let resolvedPath: string;
245+
let originalMode: number | undefined;
245246

246247
return new WritableStream<Uint8Array>({
247248
async start() {
248249
// Resolve symlinks to write through them (preserves the symlink)
249250
try {
250251
resolvedPath = await fsPromises.realpath(filePath);
252+
// Save original permissions to restore after write
253+
const stat = await fsPromises.stat(resolvedPath);
254+
originalMode = stat.mode;
251255
} catch {
252-
// If file doesn't exist, use the original path
256+
// If file doesn't exist, use the original path and default permissions
253257
resolvedPath = filePath;
258+
originalMode = undefined;
254259
}
255260

256261
// Create parent directories if they don't exist
@@ -270,6 +275,10 @@ export class LocalRuntime implements Runtime {
270275
// Close the writer and rename to final location
271276
await writer.close();
272277
try {
278+
// If we have original permissions, apply them to temp file before rename
279+
if (originalMode !== undefined) {
280+
await fsPromises.chmod(tempPath, originalMode);
281+
}
273282
await fsPromises.rename(tempPath, resolvedPath);
274283
} catch (err) {
275284
throw new RuntimeErrorClass(

src/runtime/SSHRuntime.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,15 +263,16 @@ export class SSHRuntime implements Runtime {
263263

264264
/**
265265
* Write file contents over SSH atomically from a stream
266-
* Preserves symlinks by resolving them first, then writing to the target
266+
* Preserves symlinks and file permissions by resolving and copying metadata
267267
*/
268268
writeFile(path: string): WritableStream<Uint8Array> {
269269
const tempPath = `${path}.tmp.${Date.now()}`;
270270
// Resolve symlinks to get the actual target path, preserving the symlink itself
271-
// If path doesn't exist, readlink fails and we use the original path
271+
// If target exists, save its permissions to restore after write
272+
// If path doesn't exist, use 600 as default
272273
// Then write atomically using mv (all-or-nothing for readers)
273274
// Use shescape.quote for safe path escaping
274-
const writeCommand = `RESOLVED=$(readlink -f ${shescape.quote(path)} 2>/dev/null || echo ${shescape.quote(path)}) && mkdir -p $(dirname "$RESOLVED") && cat > ${shescape.quote(tempPath)} && chmod 600 ${shescape.quote(tempPath)} && mv ${shescape.quote(tempPath)} "$RESOLVED"`;
275+
const writeCommand = `RESOLVED=$(readlink -f ${shescape.quote(path)} 2>/dev/null || echo ${shescape.quote(path)}) && PERMS=$(stat -c '%a' "$RESOLVED" 2>/dev/null || echo 600) && mkdir -p $(dirname "$RESOLVED") && cat > ${shescape.quote(tempPath)} && chmod "$PERMS" ${shescape.quote(tempPath)} && mv ${shescape.quote(tempPath)} "$RESOLVED"`;
275276

276277
// Need to get the exec stream in async callbacks
277278
let execPromise: Promise<ExecStream> | null = null;

tests/runtime/runtime.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,51 @@ describeIntegration("Runtime integration tests", () => {
372372
const targetContent = await readFileString(runtime, targetPath);
373373
expect(targetContent).toBe("new content");
374374
});
375+
376+
test.concurrent("preserves file permissions when editing through symlink", async () => {
377+
const runtime = createRuntime();
378+
await using workspace = await TestWorkspace.create(runtime, type);
379+
380+
// Create a target file with specific permissions (755)
381+
const targetPath = `${workspace.path}/target.txt`;
382+
await writeFileString(runtime, targetPath, "original content");
383+
384+
// Set permissions to 755
385+
const chmodResult = await execBuffered(runtime, "chmod 755 target.txt", {
386+
cwd: workspace.path,
387+
timeout: 30,
388+
});
389+
expect(chmodResult.exitCode).toBe(0);
390+
391+
// Verify initial permissions
392+
const statBefore = await execBuffered(runtime, "stat -c '%a' target.txt", {
393+
cwd: workspace.path,
394+
timeout: 30,
395+
});
396+
expect(statBefore.stdout.trim()).toBe("755");
397+
398+
// Create a symlink to the target
399+
const linkPath = `${workspace.path}/link.txt`;
400+
const lnResult = await execBuffered(runtime, "ln -s target.txt link.txt", {
401+
cwd: workspace.path,
402+
timeout: 30,
403+
});
404+
expect(lnResult.exitCode).toBe(0);
405+
406+
// Edit the file via the symlink
407+
await writeFileString(runtime, linkPath, "new content");
408+
409+
// Verify permissions are preserved
410+
const statAfter = await execBuffered(runtime, "stat -c '%a' target.txt", {
411+
cwd: workspace.path,
412+
timeout: 30,
413+
});
414+
expect(statAfter.stdout.trim()).toBe("755");
415+
416+
// Verify content was updated
417+
const content = await readFileString(runtime, targetPath);
418+
expect(content).toBe("new content");
419+
});
375420
});
376421

377422
describe("stat() - File metadata", () => {

0 commit comments

Comments
 (0)