Skip to content

Commit ebfca8e

Browse files
committed
fix(isolation): preserve copy boundary parity
1 parent d0cd523 commit ebfca8e

4 files changed

Lines changed: 171 additions & 117 deletions

File tree

src/copy-containment.js

Lines changed: 28 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -66,21 +66,14 @@ function pinRoot(rootPath, label, expectedRoot) {
6666
}
6767

6868
function assertPinnedRoot(root, label, relativePath) {
69-
let canonicalPath;
7069
let identity;
7170
try {
72-
canonicalPath = fs.realpathSync.native(root.canonicalPath);
73-
identity = statIdentity(canonicalPath);
71+
identity = statIdentity(root.canonicalPath);
7472
} catch (err) {
7573
throw containmentError(relativePath, `${label} root can no longer be resolved`, err);
7674
}
7775

78-
if (
79-
canonicalPath !== root.canonicalPath ||
80-
identity.device !== root.device ||
81-
identity.inode !== root.inode ||
82-
!identity.directory
83-
) {
76+
if (identity.device !== root.device || identity.inode !== root.inode || !identity.directory) {
8477
throw containmentError(relativePath, `${label} root changed after it was pinned`);
8578
}
8679
}
@@ -92,15 +85,11 @@ function validateRelativePath(relativePath) {
9285
if (relativePath.includes('\0')) {
9386
throw containmentError(relativePath, 'path contains a null byte');
9487
}
95-
if (
96-
path.posix.isAbsolute(relativePath) ||
97-
path.win32.isAbsolute(relativePath) ||
98-
path.win32.parse(relativePath).root
99-
) {
88+
if (path.isAbsolute(relativePath) || path.parse(relativePath).root) {
10089
throw containmentError(relativePath, 'absolute paths are not allowed');
10190
}
10291

103-
const components = relativePath.split(/[\\/]/);
92+
const components = relativePath.split(path.sep);
10493
if (components.some((component) => component === '' || component === '.' || component === '..')) {
10594
throw containmentError(
10695
relativePath,
@@ -147,21 +136,17 @@ function resolveDestinationPath(boundary, relativePath) {
147136
throw containmentError(relativePath, 'destination path escapes its pinned root');
148137
}
149138

150-
let existingPath = candidatePath;
151-
while (true) {
152-
try {
153-
fs.lstatSync(existingPath);
154-
break;
155-
} catch (err) {
156-
if (err.code !== 'ENOENT') {
157-
throw err;
158-
}
159-
const parentPath = path.dirname(existingPath);
160-
if (parentPath === existingPath) {
161-
throw containmentError(relativePath, 'destination has no resolvable ancestor', err);
162-
}
163-
existingPath = parentPath;
139+
let existingPath;
140+
try {
141+
fs.lstatSync(candidatePath);
142+
existingPath = candidatePath;
143+
} catch (err) {
144+
if (err.code !== 'ENOENT') {
145+
throw err;
164146
}
147+
// The copy pipeline creates directories parent-first in phase two, so the
148+
// immediate parent must exist before any mkdir/copy effect is attempted.
149+
existingPath = path.dirname(candidatePath);
165150
}
166151

167152
let canonicalExistingPath;
@@ -203,9 +188,23 @@ function isCopyContainmentError(error) {
203188
return error?.code === CONTAINMENT_ERROR_CODE;
204189
}
205190

191+
function copyErrorFromPayload(payload) {
192+
const error =
193+
payload.code === CONTAINMENT_ERROR_CODE
194+
? new CopyContainmentError(payload.relativePath, 'worker rejected an unsafe path')
195+
: new Error(payload.message);
196+
error.name = payload.name || error.name;
197+
error.message = payload.message;
198+
if (payload.code) {
199+
error.code = payload.code;
200+
}
201+
return error;
202+
}
203+
206204
module.exports = {
207205
CONTAINMENT_ERROR_CODE,
208206
CopyContainmentError,
207+
copyErrorFromPayload,
209208
createCopyBoundary,
210209
isCopyContainmentError,
211210
resolveCopyPath,

src/copy-worker.js

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
const { parentPort, workerData } = require('worker_threads');
99
const fs = require('fs');
10-
const path = require('path');
1110
const {
1211
createCopyBoundary,
1312
isCopyContainmentError,
@@ -23,16 +22,8 @@ let error = null;
2322

2423
for (const relativePath of files) {
2524
try {
26-
// Ensure parent directory exists
27-
const parentRelativePath = path.dirname(relativePath);
28-
if (parentRelativePath !== '.') {
29-
const { destinationPath: destDir } = resolveCopyPath(copyBoundary, parentRelativePath);
30-
if (!fs.existsSync(destDir)) {
31-
fs.mkdirSync(destDir, { recursive: true });
32-
}
33-
}
34-
35-
// Copy the file
25+
// Phase two creates every parent directory. Re-resolve the source and
26+
// destination immediately before the only worker filesystem effect.
3627
const { sourcePath, destinationPath } = resolveCopyPath(copyBoundary, relativePath);
3728
fs.copyFileSync(sourcePath, destinationPath);
3829
copied++;
@@ -50,6 +41,7 @@ for (const relativePath of files) {
5041
name: err.name,
5142
code: err.code,
5243
message: err.message,
44+
relativePath: err.relativePath || relativePath,
5345
};
5446
break;
5547
}

src/isolation-manager.js

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ const { readRepoSettings } = require('../lib/repo-settings');
3535
const { provisionClaudeCredentials } = require('./claude-credentials');
3636
const {
3737
CopyContainmentError,
38+
copyErrorFromPayload,
3839
createCopyBoundary,
3940
resolveCopyPath,
4041
resolveSourcePath,
42+
validateRelativePath,
4143
} = require('./copy-containment');
4244

4345
const DEFAULT_WORKTREE_SETUP_TIMEOUT_MS = 15 * 60 * 1000;
@@ -1323,12 +1325,9 @@ class IsolationManager {
13231325

13241326
function handleEntry(entry, srcPath, relPath, relativePath, ancestorDirectories) {
13251327
if (entry.isSymbolicLink()) {
1326-
const targetStats = fs.statSync(srcPath);
1328+
const resolvedSourcePath = resolveSourcePath(copyBoundary, relPath);
1329+
const targetStats = fs.statSync(resolvedSourcePath);
13271330
if (targetStats.isDirectory()) {
1328-
const resolvedSourcePath = resolveSourcePath(copyBoundary, relPath);
1329-
if (ancestorDirectories.has(resolvedSourcePath)) {
1330-
throw new CopyContainmentError(relPath, 'source directory symlink creates a cycle');
1331-
}
13321331
directories.add(relPath);
13331332
collectFiles(relPath, ancestorDirectories);
13341333
return;
@@ -1365,8 +1364,9 @@ class IsolationManager {
13651364
continue;
13661365
}
13671366

1368-
const srcPath = path.join(currentSrc, entry.name);
1369-
const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
1367+
const entryName = validateRelativePath(entry.name);
1368+
const srcPath = path.join(currentSrc, entryName);
1369+
const relPath = relativePath ? path.join(relativePath, entryName) : entryName;
13701370

13711371
try {
13721372
handleEntry(entry, srcPath, relPath, relativePath, childAncestors);
@@ -1428,6 +1428,7 @@ class IsolationManager {
14281428
}
14291429

14301430
// Spawn workers and wait for completion
1431+
const workers = [];
14311432
const workerPromises = chunks.map((chunk) => {
14321433
return new Promise((resolve, reject) => {
14331434
const worker = new Worker(workerPath, {
@@ -1438,9 +1439,11 @@ class IsolationManager {
14381439
expectedBoundary: copyBoundary,
14391440
},
14401441
});
1442+
workers.push(worker);
1443+
let result = null;
14411444

1442-
worker.on('message', (result) => {
1443-
resolve(result);
1445+
worker.on('message', (workerResult) => {
1446+
result = workerResult;
14441447
});
14451448

14461449
worker.on('error', (err) => {
@@ -1450,6 +1453,12 @@ class IsolationManager {
14501453
worker.on('exit', (code) => {
14511454
if (code !== 0) {
14521455
reject(new Error(`Worker exited with code ${code}`));
1456+
} else if (!result) {
1457+
reject(new Error('Copy worker exited without reporting a result'));
1458+
} else if (result.error) {
1459+
reject(copyErrorFromPayload(result.error));
1460+
} else {
1461+
resolve(result);
14531462
}
14541463
});
14551464
});
@@ -1458,15 +1467,11 @@ class IsolationManager {
14581467
// Wait for all workers to complete (proper async/await - no busy-wait!)
14591468
// FIX: Previous version used busy-wait which blocked the event loop,
14601469
// preventing worker thread messages from being processed (timeout bug)
1461-
const workerResults = await Promise.all(workerPromises);
1462-
const failedResult = workerResults.find((result) => result.error);
1463-
if (failedResult) {
1464-
const error = new Error(failedResult.error.message);
1465-
error.name = failedResult.error.name || 'Error';
1466-
if (failedResult.error.code) {
1467-
error.code = failedResult.error.code;
1468-
}
1469-
throw error;
1470+
try {
1471+
await Promise.all(workerPromises);
1472+
} catch (err) {
1473+
await Promise.all(workers.map((worker) => worker.terminate().catch(() => undefined)));
1474+
throw err;
14701475
}
14711476
}
14721477

0 commit comments

Comments
 (0)