Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/runtime/LocalRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,15 +241,29 @@ export class LocalRuntime implements Runtime {
writeFile(filePath: string): WritableStream<Uint8Array> {
let tempPath: string;
let writer: WritableStreamDefaultWriter<Uint8Array>;
let resolvedPath: string;
let originalMode: number | undefined;

return new WritableStream<Uint8Array>({
async start() {
// Resolve symlinks to write through them (preserves the symlink)
try {
resolvedPath = await fsPromises.realpath(filePath);
// Save original permissions to restore after write
const stat = await fsPromises.stat(resolvedPath);
originalMode = stat.mode;
} catch {
// If file doesn't exist, use the original path and default permissions
resolvedPath = filePath;
originalMode = undefined;
}

// Create parent directories if they don't exist
const parentDir = path.dirname(filePath);
const parentDir = path.dirname(resolvedPath);
await fsPromises.mkdir(parentDir, { recursive: true });

// Create temp file for atomic write
tempPath = `${filePath}.tmp.${Date.now()}`;
tempPath = `${resolvedPath}.tmp.${Date.now()}`;
const nodeStream = fs.createWriteStream(tempPath);
const webStream = Writable.toWeb(nodeStream) as WritableStream<Uint8Array>;
writer = webStream.getWriter();
Expand All @@ -261,7 +275,11 @@ export class LocalRuntime implements Runtime {
// Close the writer and rename to final location
await writer.close();
try {
await fsPromises.rename(tempPath, filePath);
// If we have original permissions, apply them to temp file before rename
if (originalMode !== undefined) {
await fsPromises.chmod(tempPath, originalMode);
}
await fsPromises.rename(tempPath, resolvedPath);
} catch (err) {
throw new RuntimeErrorClass(
`Failed to write file ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
Expand Down
8 changes: 6 additions & 2 deletions src/runtime/SSHRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,12 +263,16 @@ export class SSHRuntime implements Runtime {

/**
* Write file contents over SSH atomically from a stream
* Preserves symlinks and file permissions by resolving and copying metadata
*/
writeFile(path: string): WritableStream<Uint8Array> {
const tempPath = `${path}.tmp.${Date.now()}`;
// Create parent directory if needed, then write file atomically
// Resolve symlinks to get the actual target path, preserving the symlink itself
// If target exists, save its permissions to restore after write
// If path doesn't exist, use 600 as default
// Then write atomically using mv (all-or-nothing for readers)
// Use shescape.quote for safe path escaping
const writeCommand = `mkdir -p $(dirname ${shescape.quote(path)}) && cat > ${shescape.quote(tempPath)} && chmod 600 ${shescape.quote(tempPath)} && mv ${shescape.quote(tempPath)} ${shescape.quote(path)}`;
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"`;

// Need to get the exec stream in async callbacks
let execPromise: Promise<ExecStream> | null = null;
Expand Down
88 changes: 88 additions & 0 deletions tests/runtime/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,94 @@ describeIntegration("Runtime integration tests", () => {
const content = await readFileString(runtime, `${workspace.path}/special.txt`);
expect(content).toBe(specialContent);
});

test.concurrent("preserves symlinks when editing target file", async () => {
const runtime = createRuntime();
await using workspace = await TestWorkspace.create(runtime, type);

// Create a target file
const targetPath = `${workspace.path}/target.txt`;
await writeFileString(runtime, targetPath, "original content");

// Create a symlink to the target
const linkPath = `${workspace.path}/link.txt`;
const result = await execBuffered(runtime, `ln -s target.txt link.txt`, {
cwd: workspace.path,
timeout: 30,
});
expect(result.exitCode).toBe(0);

// Verify symlink was created
const lsResult = await execBuffered(runtime, "ls -la link.txt", {
cwd: workspace.path,
timeout: 30,
});
expect(lsResult.stdout).toContain("->");
expect(lsResult.stdout).toContain("target.txt");

// Edit the file via the symlink
await writeFileString(runtime, linkPath, "new content");

// Verify the symlink is still a symlink (not replaced with a file)
const lsAfter = await execBuffered(runtime, "ls -la link.txt", {
cwd: workspace.path,
timeout: 30,
});
expect(lsAfter.stdout).toContain("->");
expect(lsAfter.stdout).toContain("target.txt");

// Verify both the symlink and target have the new content
const linkContent = await readFileString(runtime, linkPath);
expect(linkContent).toBe("new content");

const targetContent = await readFileString(runtime, targetPath);
expect(targetContent).toBe("new content");
});

test.concurrent("preserves file permissions when editing through symlink", async () => {
const runtime = createRuntime();
await using workspace = await TestWorkspace.create(runtime, type);

// Create a target file with specific permissions (755)
const targetPath = `${workspace.path}/target.txt`;
await writeFileString(runtime, targetPath, "original content");

// Set permissions to 755
const chmodResult = await execBuffered(runtime, "chmod 755 target.txt", {
cwd: workspace.path,
timeout: 30,
});
expect(chmodResult.exitCode).toBe(0);

// Verify initial permissions
const statBefore = await execBuffered(runtime, "stat -c '%a' target.txt", {
cwd: workspace.path,
timeout: 30,
});
expect(statBefore.stdout.trim()).toBe("755");

// Create a symlink to the target
const linkPath = `${workspace.path}/link.txt`;
const lnResult = await execBuffered(runtime, "ln -s target.txt link.txt", {
cwd: workspace.path,
timeout: 30,
});
expect(lnResult.exitCode).toBe(0);

// Edit the file via the symlink
await writeFileString(runtime, linkPath, "new content");

// Verify permissions are preserved
const statAfter = await execBuffered(runtime, "stat -c '%a' target.txt", {
cwd: workspace.path,
timeout: 30,
});
expect(statAfter.stdout.trim()).toBe("755");

// Verify content was updated
const content = await readFileString(runtime, targetPath);
expect(content).toBe("new content");
});
});

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