@the-open-engine/zeroshot 6.39.0 → 6.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/cli/index.js +60 -17
- package/lib/detached-startup.d.ts +1 -0
- package/lib/detached-startup.js +2 -1
- package/lib/start-cluster-environment.d.ts +1 -0
- package/lib/start-cluster-run-options.d.ts +1 -0
- package/lib/start-cluster-run-options.js +1 -0
- package/npm-shrinkwrap.json +6 -8
- package/package.json +3 -3
- package/scripts/rust-distribution.js +13 -31
- package/src/agents/git-pusher-template.js +17 -34
- package/src/copy-containment.js +191 -0
- package/src/copy-containment.ts +259 -0
- package/src/copy-worker.js +29 -30
- package/src/copy-worker.ts +43 -30
- package/src/isolation-manager.js +59 -20
- package/src/legacy-lib/detached-startup.ts +3 -1
- package/src/legacy-lib/start-cluster-environment.ts +1 -0
- package/src/legacy-lib/start-cluster-run-options.ts +2 -0
- package/src/orchestrator.js +13 -3
- package/src/pr-body-template.js +71 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CopyContainmentError = exports.CONTAINMENT_ERROR_CODE = void 0;
|
|
4
|
+
exports.isCopyRecord = isCopyRecord;
|
|
5
|
+
exports.copyErrorCode = copyErrorCode;
|
|
6
|
+
exports.validateRelativePath = validateRelativePath;
|
|
7
|
+
exports.resolveSourcePath = resolveSourcePath;
|
|
8
|
+
exports.createCopyBoundary = createCopyBoundary;
|
|
9
|
+
exports.resolveCopyPath = resolveCopyPath;
|
|
10
|
+
exports.isCopyContainmentError = isCopyContainmentError;
|
|
11
|
+
exports.copyErrorFromPayload = copyErrorFromPayload;
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
exports.CONTAINMENT_ERROR_CODE = 'ERR_COPY_CONTAINMENT';
|
|
15
|
+
class CopyContainmentError extends Error {
|
|
16
|
+
code = exports.CONTAINMENT_ERROR_CODE;
|
|
17
|
+
relativePath;
|
|
18
|
+
constructor(relativePath, reason) {
|
|
19
|
+
super(`Copy containment violation for ${JSON.stringify(relativePath)}: ${reason}`);
|
|
20
|
+
this.name = 'CopyContainmentError';
|
|
21
|
+
this.relativePath = relativePath;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
exports.CopyContainmentError = CopyContainmentError;
|
|
25
|
+
function isContained(rootPath, targetPath) {
|
|
26
|
+
const relative = path.relative(rootPath, targetPath);
|
|
27
|
+
return (relative === '' ||
|
|
28
|
+
(!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)));
|
|
29
|
+
}
|
|
30
|
+
function containmentError(relativePath, reason, cause) {
|
|
31
|
+
const error = new CopyContainmentError(relativePath, reason);
|
|
32
|
+
if (cause !== undefined) {
|
|
33
|
+
error.cause = cause;
|
|
34
|
+
}
|
|
35
|
+
return error;
|
|
36
|
+
}
|
|
37
|
+
function isCopyRecord(value) {
|
|
38
|
+
return typeof value === 'object' && value !== null;
|
|
39
|
+
}
|
|
40
|
+
function copyErrorCode(error) {
|
|
41
|
+
if (isCopyRecord(error) && 'code' in error) {
|
|
42
|
+
return typeof error.code === 'string' ? error.code : null;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function statIdentity(targetPath) {
|
|
47
|
+
const stats = fs.statSync(targetPath, { bigint: true });
|
|
48
|
+
return {
|
|
49
|
+
device: stats.dev.toString(),
|
|
50
|
+
inode: stats.ino.toString(),
|
|
51
|
+
directory: stats.isDirectory(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function pinRoot(rootPath, label, expectedRoot) {
|
|
55
|
+
const requestedPath = path.resolve(rootPath);
|
|
56
|
+
const canonicalPath = fs.realpathSync.native(requestedPath);
|
|
57
|
+
const identity = statIdentity(canonicalPath);
|
|
58
|
+
if (!identity.directory) {
|
|
59
|
+
throw containmentError('', `${label} root is not a directory`);
|
|
60
|
+
}
|
|
61
|
+
const pinnedRoot = {
|
|
62
|
+
requestedPath,
|
|
63
|
+
canonicalPath,
|
|
64
|
+
device: identity.device,
|
|
65
|
+
inode: identity.inode,
|
|
66
|
+
};
|
|
67
|
+
if (expectedRoot &&
|
|
68
|
+
(expectedRoot.canonicalPath !== pinnedRoot.canonicalPath ||
|
|
69
|
+
expectedRoot.device !== pinnedRoot.device ||
|
|
70
|
+
expectedRoot.inode !== pinnedRoot.inode)) {
|
|
71
|
+
throw containmentError('', `${label} root changed after it was pinned`);
|
|
72
|
+
}
|
|
73
|
+
return pinnedRoot;
|
|
74
|
+
}
|
|
75
|
+
function assertPinnedRoot(root, label, relativePath) {
|
|
76
|
+
let identity;
|
|
77
|
+
try {
|
|
78
|
+
identity = statIdentity(root.canonicalPath);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
throw containmentError(relativePath, `${label} root can no longer be resolved`, error);
|
|
82
|
+
}
|
|
83
|
+
if (identity.device !== root.device || identity.inode !== root.inode || !identity.directory) {
|
|
84
|
+
throw containmentError(relativePath, `${label} root changed after it was pinned`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function validateRelativePath(relativePath, pathApi = path) {
|
|
88
|
+
if (typeof relativePath !== 'string' || relativePath.length === 0) {
|
|
89
|
+
throw containmentError(relativePath, 'path must be a non-empty relative string');
|
|
90
|
+
}
|
|
91
|
+
if (relativePath.includes('\0')) {
|
|
92
|
+
throw containmentError(relativePath, 'path contains a null byte');
|
|
93
|
+
}
|
|
94
|
+
if (pathApi.isAbsolute(relativePath) || pathApi.parse(relativePath).root) {
|
|
95
|
+
throw containmentError(relativePath, 'absolute paths are not allowed');
|
|
96
|
+
}
|
|
97
|
+
const components = pathApi.sep === '\\' ? relativePath.split(/[\\/]/) : relativePath.split(pathApi.sep);
|
|
98
|
+
if (components.some((component) => component === '' || component === '.' || component === '..')) {
|
|
99
|
+
throw containmentError(relativePath, 'empty, current-directory, and traversal components are not allowed');
|
|
100
|
+
}
|
|
101
|
+
return pathApi.normalize(relativePath);
|
|
102
|
+
}
|
|
103
|
+
function resolveSourcePath(boundary, relativePath) {
|
|
104
|
+
const normalizedPath = validateRelativePath(relativePath);
|
|
105
|
+
const root = boundary.sourceRoot;
|
|
106
|
+
assertPinnedRoot(root, 'source', relativePath);
|
|
107
|
+
const candidatePath = path.resolve(root.canonicalPath, normalizedPath);
|
|
108
|
+
if (!isContained(root.canonicalPath, candidatePath)) {
|
|
109
|
+
throw containmentError(relativePath, 'source path escapes its pinned root');
|
|
110
|
+
}
|
|
111
|
+
let canonicalPath;
|
|
112
|
+
try {
|
|
113
|
+
canonicalPath = fs.realpathSync.native(candidatePath);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (copyErrorCode(error) === 'ELOOP') {
|
|
117
|
+
throw containmentError(relativePath, 'source path contains a symlink cycle', error);
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
if (!isContained(root.canonicalPath, canonicalPath)) {
|
|
122
|
+
throw containmentError(relativePath, 'resolved source path escapes its pinned root');
|
|
123
|
+
}
|
|
124
|
+
return canonicalPath;
|
|
125
|
+
}
|
|
126
|
+
function resolveDestinationPath(boundary, relativePath) {
|
|
127
|
+
const normalizedPath = validateRelativePath(relativePath);
|
|
128
|
+
const root = boundary.destinationRoot;
|
|
129
|
+
assertPinnedRoot(root, 'destination', relativePath);
|
|
130
|
+
const candidatePath = path.resolve(root.canonicalPath, normalizedPath);
|
|
131
|
+
if (!isContained(root.canonicalPath, candidatePath)) {
|
|
132
|
+
throw containmentError(relativePath, 'destination path escapes its pinned root');
|
|
133
|
+
}
|
|
134
|
+
let existingPath;
|
|
135
|
+
try {
|
|
136
|
+
fs.lstatSync(candidatePath);
|
|
137
|
+
existingPath = candidatePath;
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (copyErrorCode(error) !== 'ENOENT') {
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
// The copy pipeline creates directories parent-first in phase two, so the
|
|
144
|
+
// immediate parent must exist before any mkdir/copy effect is attempted.
|
|
145
|
+
existingPath = path.dirname(candidatePath);
|
|
146
|
+
}
|
|
147
|
+
let canonicalExistingPath;
|
|
148
|
+
try {
|
|
149
|
+
canonicalExistingPath = fs.realpathSync.native(existingPath);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
throw containmentError(relativePath, 'destination contains an unresolved symlink', error);
|
|
153
|
+
}
|
|
154
|
+
if (!isContained(root.canonicalPath, canonicalExistingPath)) {
|
|
155
|
+
throw containmentError(relativePath, 'resolved destination path escapes its pinned root');
|
|
156
|
+
}
|
|
157
|
+
const unresolvedSuffix = path.relative(existingPath, candidatePath);
|
|
158
|
+
const resolvedPath = unresolvedSuffix
|
|
159
|
+
? path.join(canonicalExistingPath, unresolvedSuffix)
|
|
160
|
+
: canonicalExistingPath;
|
|
161
|
+
if (!isContained(root.canonicalPath, resolvedPath)) {
|
|
162
|
+
throw containmentError(relativePath, 'resolved destination path escapes its pinned root');
|
|
163
|
+
}
|
|
164
|
+
return resolvedPath;
|
|
165
|
+
}
|
|
166
|
+
function createCopyBoundary(sourceBase, destinationBase, expectedBoundary) {
|
|
167
|
+
return {
|
|
168
|
+
sourceRoot: pinRoot(sourceBase, 'source', expectedBoundary?.sourceRoot),
|
|
169
|
+
destinationRoot: pinRoot(destinationBase, 'destination', expectedBoundary?.destinationRoot),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function resolveCopyPath(boundary, relativePath) {
|
|
173
|
+
return {
|
|
174
|
+
sourcePath: resolveSourcePath(boundary, relativePath),
|
|
175
|
+
destinationPath: resolveDestinationPath(boundary, relativePath),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function isCopyContainmentError(error) {
|
|
179
|
+
return isCopyRecord(error) && error.code === exports.CONTAINMENT_ERROR_CODE;
|
|
180
|
+
}
|
|
181
|
+
function copyErrorFromPayload(payload) {
|
|
182
|
+
const error = payload.code === exports.CONTAINMENT_ERROR_CODE
|
|
183
|
+
? new CopyContainmentError(payload.relativePath, 'worker rejected an unsafe path')
|
|
184
|
+
: new Error(payload.message);
|
|
185
|
+
error.name = payload.name || error.name;
|
|
186
|
+
error.message = payload.message;
|
|
187
|
+
if (payload.code && !(error instanceof CopyContainmentError)) {
|
|
188
|
+
Object.assign(error, { code: payload.code });
|
|
189
|
+
}
|
|
190
|
+
return error;
|
|
191
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import fs = require('fs');
|
|
2
|
+
import path = require('path');
|
|
3
|
+
|
|
4
|
+
export const CONTAINMENT_ERROR_CODE = 'ERR_COPY_CONTAINMENT';
|
|
5
|
+
|
|
6
|
+
export interface PinnedCopyRoot {
|
|
7
|
+
requestedPath: string;
|
|
8
|
+
canonicalPath: string;
|
|
9
|
+
device: string;
|
|
10
|
+
inode: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CopyBoundary {
|
|
14
|
+
sourceRoot: PinnedCopyRoot;
|
|
15
|
+
destinationRoot: PinnedCopyRoot;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PathIdentity {
|
|
19
|
+
device: string;
|
|
20
|
+
inode: string;
|
|
21
|
+
directory: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CopyPathApi {
|
|
25
|
+
isAbsolute(targetPath: string): boolean;
|
|
26
|
+
normalize(targetPath: string): string;
|
|
27
|
+
parse(targetPath: string): { root: string };
|
|
28
|
+
readonly sep: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class CopyContainmentError extends Error {
|
|
32
|
+
readonly code = CONTAINMENT_ERROR_CODE;
|
|
33
|
+
readonly relativePath: unknown;
|
|
34
|
+
|
|
35
|
+
constructor(relativePath: unknown, reason: string) {
|
|
36
|
+
super(`Copy containment violation for ${JSON.stringify(relativePath)}: ${reason}`);
|
|
37
|
+
this.name = 'CopyContainmentError';
|
|
38
|
+
this.relativePath = relativePath;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isContained(rootPath: string, targetPath: string): boolean {
|
|
43
|
+
const relative = path.relative(rootPath, targetPath);
|
|
44
|
+
return (
|
|
45
|
+
relative === '' ||
|
|
46
|
+
(!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function containmentError(
|
|
51
|
+
relativePath: unknown,
|
|
52
|
+
reason: string,
|
|
53
|
+
cause?: unknown
|
|
54
|
+
): CopyContainmentError {
|
|
55
|
+
const error = new CopyContainmentError(relativePath, reason);
|
|
56
|
+
if (cause !== undefined) {
|
|
57
|
+
error.cause = cause;
|
|
58
|
+
}
|
|
59
|
+
return error;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isCopyRecord(value: unknown): value is Record<PropertyKey, unknown> {
|
|
63
|
+
return typeof value === 'object' && value !== null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function copyErrorCode(error: unknown): string | null {
|
|
67
|
+
if (isCopyRecord(error) && 'code' in error) {
|
|
68
|
+
return typeof error.code === 'string' ? error.code : null;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function statIdentity(targetPath: string): PathIdentity {
|
|
74
|
+
const stats = fs.statSync(targetPath, { bigint: true });
|
|
75
|
+
return {
|
|
76
|
+
device: stats.dev.toString(),
|
|
77
|
+
inode: stats.ino.toString(),
|
|
78
|
+
directory: stats.isDirectory(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pinRoot(rootPath: string, label: string, expectedRoot?: PinnedCopyRoot): PinnedCopyRoot {
|
|
83
|
+
const requestedPath = path.resolve(rootPath);
|
|
84
|
+
const canonicalPath = fs.realpathSync.native(requestedPath);
|
|
85
|
+
const identity = statIdentity(canonicalPath);
|
|
86
|
+
|
|
87
|
+
if (!identity.directory) {
|
|
88
|
+
throw containmentError('', `${label} root is not a directory`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const pinnedRoot = {
|
|
92
|
+
requestedPath,
|
|
93
|
+
canonicalPath,
|
|
94
|
+
device: identity.device,
|
|
95
|
+
inode: identity.inode,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (
|
|
99
|
+
expectedRoot &&
|
|
100
|
+
(expectedRoot.canonicalPath !== pinnedRoot.canonicalPath ||
|
|
101
|
+
expectedRoot.device !== pinnedRoot.device ||
|
|
102
|
+
expectedRoot.inode !== pinnedRoot.inode)
|
|
103
|
+
) {
|
|
104
|
+
throw containmentError('', `${label} root changed after it was pinned`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return pinnedRoot;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function assertPinnedRoot(root: PinnedCopyRoot, label: string, relativePath: string): void {
|
|
111
|
+
let identity: PathIdentity;
|
|
112
|
+
try {
|
|
113
|
+
identity = statIdentity(root.canonicalPath);
|
|
114
|
+
} catch (error: unknown) {
|
|
115
|
+
throw containmentError(relativePath, `${label} root can no longer be resolved`, error);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (identity.device !== root.device || identity.inode !== root.inode || !identity.directory) {
|
|
119
|
+
throw containmentError(relativePath, `${label} root changed after it was pinned`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function validateRelativePath(relativePath: unknown, pathApi: CopyPathApi = path): string {
|
|
124
|
+
if (typeof relativePath !== 'string' || relativePath.length === 0) {
|
|
125
|
+
throw containmentError(relativePath, 'path must be a non-empty relative string');
|
|
126
|
+
}
|
|
127
|
+
if (relativePath.includes('\0')) {
|
|
128
|
+
throw containmentError(relativePath, 'path contains a null byte');
|
|
129
|
+
}
|
|
130
|
+
if (pathApi.isAbsolute(relativePath) || pathApi.parse(relativePath).root) {
|
|
131
|
+
throw containmentError(relativePath, 'absolute paths are not allowed');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const components =
|
|
135
|
+
pathApi.sep === '\\' ? relativePath.split(/[\\/]/) : relativePath.split(pathApi.sep);
|
|
136
|
+
if (components.some((component) => component === '' || component === '.' || component === '..')) {
|
|
137
|
+
throw containmentError(
|
|
138
|
+
relativePath,
|
|
139
|
+
'empty, current-directory, and traversal components are not allowed'
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return pathApi.normalize(relativePath);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function resolveSourcePath(boundary: CopyBoundary, relativePath: string): string {
|
|
147
|
+
const normalizedPath = validateRelativePath(relativePath);
|
|
148
|
+
const root = boundary.sourceRoot;
|
|
149
|
+
assertPinnedRoot(root, 'source', relativePath);
|
|
150
|
+
|
|
151
|
+
const candidatePath = path.resolve(root.canonicalPath, normalizedPath);
|
|
152
|
+
if (!isContained(root.canonicalPath, candidatePath)) {
|
|
153
|
+
throw containmentError(relativePath, 'source path escapes its pinned root');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let canonicalPath: string;
|
|
157
|
+
try {
|
|
158
|
+
canonicalPath = fs.realpathSync.native(candidatePath);
|
|
159
|
+
} catch (error: unknown) {
|
|
160
|
+
if (copyErrorCode(error) === 'ELOOP') {
|
|
161
|
+
throw containmentError(relativePath, 'source path contains a symlink cycle', error);
|
|
162
|
+
}
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!isContained(root.canonicalPath, canonicalPath)) {
|
|
167
|
+
throw containmentError(relativePath, 'resolved source path escapes its pinned root');
|
|
168
|
+
}
|
|
169
|
+
return canonicalPath;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resolveDestinationPath(boundary: CopyBoundary, relativePath: string): string {
|
|
173
|
+
const normalizedPath = validateRelativePath(relativePath);
|
|
174
|
+
const root = boundary.destinationRoot;
|
|
175
|
+
assertPinnedRoot(root, 'destination', relativePath);
|
|
176
|
+
|
|
177
|
+
const candidatePath = path.resolve(root.canonicalPath, normalizedPath);
|
|
178
|
+
if (!isContained(root.canonicalPath, candidatePath)) {
|
|
179
|
+
throw containmentError(relativePath, 'destination path escapes its pinned root');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let existingPath: string;
|
|
183
|
+
try {
|
|
184
|
+
fs.lstatSync(candidatePath);
|
|
185
|
+
existingPath = candidatePath;
|
|
186
|
+
} catch (error: unknown) {
|
|
187
|
+
if (copyErrorCode(error) !== 'ENOENT') {
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
// The copy pipeline creates directories parent-first in phase two, so the
|
|
191
|
+
// immediate parent must exist before any mkdir/copy effect is attempted.
|
|
192
|
+
existingPath = path.dirname(candidatePath);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let canonicalExistingPath: string;
|
|
196
|
+
try {
|
|
197
|
+
canonicalExistingPath = fs.realpathSync.native(existingPath);
|
|
198
|
+
} catch (error: unknown) {
|
|
199
|
+
throw containmentError(relativePath, 'destination contains an unresolved symlink', error);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!isContained(root.canonicalPath, canonicalExistingPath)) {
|
|
203
|
+
throw containmentError(relativePath, 'resolved destination path escapes its pinned root');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const unresolvedSuffix = path.relative(existingPath, candidatePath);
|
|
207
|
+
const resolvedPath = unresolvedSuffix
|
|
208
|
+
? path.join(canonicalExistingPath, unresolvedSuffix)
|
|
209
|
+
: canonicalExistingPath;
|
|
210
|
+
if (!isContained(root.canonicalPath, resolvedPath)) {
|
|
211
|
+
throw containmentError(relativePath, 'resolved destination path escapes its pinned root');
|
|
212
|
+
}
|
|
213
|
+
return resolvedPath;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function createCopyBoundary(
|
|
217
|
+
sourceBase: string,
|
|
218
|
+
destinationBase: string,
|
|
219
|
+
expectedBoundary?: CopyBoundary
|
|
220
|
+
): CopyBoundary {
|
|
221
|
+
return {
|
|
222
|
+
sourceRoot: pinRoot(sourceBase, 'source', expectedBoundary?.sourceRoot),
|
|
223
|
+
destinationRoot: pinRoot(destinationBase, 'destination', expectedBoundary?.destinationRoot),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function resolveCopyPath(
|
|
228
|
+
boundary: CopyBoundary,
|
|
229
|
+
relativePath: string
|
|
230
|
+
): { sourcePath: string; destinationPath: string } {
|
|
231
|
+
return {
|
|
232
|
+
sourcePath: resolveSourcePath(boundary, relativePath),
|
|
233
|
+
destinationPath: resolveDestinationPath(boundary, relativePath),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function isCopyContainmentError(error: unknown): boolean {
|
|
238
|
+
return isCopyRecord(error) && error.code === CONTAINMENT_ERROR_CODE;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
interface CopyErrorPayload {
|
|
242
|
+
code?: string | null;
|
|
243
|
+
relativePath?: unknown;
|
|
244
|
+
name?: string;
|
|
245
|
+
message: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function copyErrorFromPayload(payload: CopyErrorPayload): Error {
|
|
249
|
+
const error =
|
|
250
|
+
payload.code === CONTAINMENT_ERROR_CODE
|
|
251
|
+
? new CopyContainmentError(payload.relativePath, 'worker rejected an unsafe path')
|
|
252
|
+
: new Error(payload.message);
|
|
253
|
+
error.name = payload.name || error.name;
|
|
254
|
+
error.message = payload.message;
|
|
255
|
+
if (payload.code && !(error instanceof CopyContainmentError)) {
|
|
256
|
+
Object.assign(error, { code: payload.code });
|
|
257
|
+
}
|
|
258
|
+
return error;
|
|
259
|
+
}
|
package/src/copy-worker.js
CHANGED
|
@@ -7,24 +7,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
7
7
|
* Used by IsolationManager._copyDirExcluding() for parallel copying.
|
|
8
8
|
*/
|
|
9
9
|
const fs = require("fs");
|
|
10
|
-
const path = require("path");
|
|
11
10
|
const worker_threads_1 = require("worker_threads");
|
|
11
|
+
const copy_containment_1 = require("./copy-containment");
|
|
12
12
|
function isCopyWorkerData(value) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
if (!(0, copy_containment_1.isCopyRecord)(value)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return (Array.isArray(value.files) &&
|
|
17
17
|
value.files.every((entry) => typeof entry === 'string') &&
|
|
18
|
-
'sourceBase' in value &&
|
|
19
18
|
typeof value.sourceBase === 'string' &&
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
function errorCode(error) {
|
|
24
|
-
if (typeof error === 'object' && error !== null && 'code' in error) {
|
|
25
|
-
return typeof error.code === 'string' ? error.code : null;
|
|
26
|
-
}
|
|
27
|
-
return null;
|
|
19
|
+
typeof value.destBase === 'string' &&
|
|
20
|
+
(0, copy_containment_1.isCopyRecord)(value.expectedBoundary));
|
|
28
21
|
}
|
|
29
22
|
function errorMessage(error) {
|
|
30
23
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -33,35 +26,41 @@ const rawWorkerData = worker_threads_1.workerData;
|
|
|
33
26
|
if (!isCopyWorkerData(rawWorkerData)) {
|
|
34
27
|
throw new TypeError('copy worker requires files, sourceBase, and destBase');
|
|
35
28
|
}
|
|
36
|
-
const { files, sourceBase, destBase } = rawWorkerData;
|
|
29
|
+
const { files, sourceBase, destBase, expectedBoundary } = rawWorkerData;
|
|
30
|
+
const copyBoundary = (0, copy_containment_1.createCopyBoundary)(sourceBase, destBase, expectedBoundary);
|
|
37
31
|
let copied = 0;
|
|
38
32
|
let skipped = 0;
|
|
39
|
-
|
|
33
|
+
let error = null;
|
|
40
34
|
for (const relativePath of files) {
|
|
41
|
-
const srcPath = path.join(sourceBase, relativePath);
|
|
42
|
-
const destPath = path.join(destBase, relativePath);
|
|
43
35
|
try {
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
// Copy the file
|
|
50
|
-
fs.copyFileSync(srcPath, destPath);
|
|
36
|
+
// Phase two creates every parent directory. Re-resolve the source and
|
|
37
|
+
// destination immediately before the only worker filesystem effect.
|
|
38
|
+
const { sourcePath, destinationPath } = (0, copy_containment_1.resolveCopyPath)(copyBoundary, relativePath);
|
|
39
|
+
fs.copyFileSync(sourcePath, destinationPath);
|
|
51
40
|
copied++;
|
|
52
41
|
}
|
|
53
|
-
catch (
|
|
42
|
+
catch (caughtError) {
|
|
54
43
|
// Skip files we can't copy (permission denied, broken symlinks, etc.)
|
|
55
|
-
const code =
|
|
56
|
-
if (
|
|
44
|
+
const code = (0, copy_containment_1.copyErrorCode)(caughtError);
|
|
45
|
+
if (!(0, copy_containment_1.isCopyContainmentError)(caughtError) &&
|
|
46
|
+
(code === 'EACCES' || code === 'EPERM' || code === 'ENOENT')) {
|
|
57
47
|
skipped++;
|
|
58
48
|
continue;
|
|
59
49
|
}
|
|
60
|
-
|
|
50
|
+
error = {
|
|
51
|
+
file: relativePath,
|
|
52
|
+
name: caughtError instanceof Error ? caughtError.name : 'Error',
|
|
53
|
+
code,
|
|
54
|
+
message: errorMessage(caughtError),
|
|
55
|
+
relativePath: (0, copy_containment_1.isCopyRecord)(caughtError) && 'relativePath' in caughtError
|
|
56
|
+
? caughtError.relativePath
|
|
57
|
+
: relativePath,
|
|
58
|
+
};
|
|
59
|
+
break;
|
|
61
60
|
}
|
|
62
61
|
}
|
|
63
62
|
// Report results back to main thread
|
|
64
63
|
if (!worker_threads_1.parentPort) {
|
|
65
64
|
throw new Error('copy worker requires a parent port');
|
|
66
65
|
}
|
|
67
|
-
worker_threads_1.parentPort.postMessage({ copied, skipped,
|
|
66
|
+
worker_threads_1.parentPort.postMessage({ copied, skipped, error });
|
package/src/copy-worker.ts
CHANGED
|
@@ -5,36 +5,40 @@
|
|
|
5
5
|
* Used by IsolationManager._copyDirExcluding() for parallel copying.
|
|
6
6
|
*/
|
|
7
7
|
import fs = require('fs');
|
|
8
|
-
import path = require('path');
|
|
9
8
|
import { parentPort, workerData } from 'worker_threads';
|
|
9
|
+
import {
|
|
10
|
+
copyErrorCode,
|
|
11
|
+
createCopyBoundary,
|
|
12
|
+
isCopyContainmentError,
|
|
13
|
+
isCopyRecord,
|
|
14
|
+
resolveCopyPath,
|
|
15
|
+
} from './copy-containment';
|
|
16
|
+
import type { CopyBoundary } from './copy-containment';
|
|
10
17
|
interface CopyWorkerData {
|
|
11
18
|
files: string[];
|
|
12
19
|
sourceBase: string;
|
|
13
20
|
destBase: string;
|
|
21
|
+
expectedBoundary: CopyBoundary;
|
|
14
22
|
}
|
|
15
23
|
interface CopyError {
|
|
16
24
|
file: string;
|
|
17
|
-
|
|
25
|
+
name: string;
|
|
26
|
+
code: string | null;
|
|
27
|
+
message: string;
|
|
28
|
+
relativePath: unknown;
|
|
18
29
|
}
|
|
19
30
|
function isCopyWorkerData(value: unknown): value is CopyWorkerData {
|
|
31
|
+
if (!isCopyRecord(value)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
20
34
|
return (
|
|
21
|
-
typeof value === 'object' &&
|
|
22
|
-
value !== null &&
|
|
23
|
-
'files' in value &&
|
|
24
35
|
Array.isArray(value.files) &&
|
|
25
36
|
value.files.every((entry: unknown) => typeof entry === 'string') &&
|
|
26
|
-
'sourceBase' in value &&
|
|
27
37
|
typeof value.sourceBase === 'string' &&
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
typeof value.destBase === 'string' &&
|
|
39
|
+
isCopyRecord(value.expectedBoundary)
|
|
30
40
|
);
|
|
31
41
|
}
|
|
32
|
-
function errorCode(error: unknown): string | null {
|
|
33
|
-
if (typeof error === 'object' && error !== null && 'code' in error) {
|
|
34
|
-
return typeof error.code === 'string' ? error.code : null;
|
|
35
|
-
}
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
42
|
function errorMessage(error: unknown): string {
|
|
39
43
|
return error instanceof Error ? error.message : String(error);
|
|
40
44
|
}
|
|
@@ -42,34 +46,43 @@ const rawWorkerData: unknown = workerData;
|
|
|
42
46
|
if (!isCopyWorkerData(rawWorkerData)) {
|
|
43
47
|
throw new TypeError('copy worker requires files, sourceBase, and destBase');
|
|
44
48
|
}
|
|
45
|
-
const { files, sourceBase, destBase } = rawWorkerData;
|
|
49
|
+
const { files, sourceBase, destBase, expectedBoundary } = rawWorkerData;
|
|
50
|
+
const copyBoundary = createCopyBoundary(sourceBase, destBase, expectedBoundary);
|
|
46
51
|
let copied = 0;
|
|
47
52
|
let skipped = 0;
|
|
48
|
-
|
|
53
|
+
let error: CopyError | null = null;
|
|
49
54
|
for (const relativePath of files) {
|
|
50
|
-
const srcPath = path.join(sourceBase, relativePath);
|
|
51
|
-
const destPath = path.join(destBase, relativePath);
|
|
52
55
|
try {
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
// Copy the file
|
|
59
|
-
fs.copyFileSync(srcPath, destPath);
|
|
56
|
+
// Phase two creates every parent directory. Re-resolve the source and
|
|
57
|
+
// destination immediately before the only worker filesystem effect.
|
|
58
|
+
const { sourcePath, destinationPath } = resolveCopyPath(copyBoundary, relativePath);
|
|
59
|
+
fs.copyFileSync(sourcePath, destinationPath);
|
|
60
60
|
copied++;
|
|
61
|
-
} catch (
|
|
61
|
+
} catch (caughtError: unknown) {
|
|
62
62
|
// Skip files we can't copy (permission denied, broken symlinks, etc.)
|
|
63
|
-
const code =
|
|
64
|
-
if (
|
|
63
|
+
const code = copyErrorCode(caughtError);
|
|
64
|
+
if (
|
|
65
|
+
!isCopyContainmentError(caughtError) &&
|
|
66
|
+
(code === 'EACCES' || code === 'EPERM' || code === 'ENOENT')
|
|
67
|
+
) {
|
|
65
68
|
skipped++;
|
|
66
69
|
continue;
|
|
67
70
|
}
|
|
68
|
-
|
|
71
|
+
error = {
|
|
72
|
+
file: relativePath,
|
|
73
|
+
name: caughtError instanceof Error ? caughtError.name : 'Error',
|
|
74
|
+
code,
|
|
75
|
+
message: errorMessage(caughtError),
|
|
76
|
+
relativePath:
|
|
77
|
+
isCopyRecord(caughtError) && 'relativePath' in caughtError
|
|
78
|
+
? caughtError.relativePath
|
|
79
|
+
: relativePath,
|
|
80
|
+
};
|
|
81
|
+
break;
|
|
69
82
|
}
|
|
70
83
|
}
|
|
71
84
|
// Report results back to main thread
|
|
72
85
|
if (!parentPort) {
|
|
73
86
|
throw new Error('copy worker requires a parent port');
|
|
74
87
|
}
|
|
75
|
-
parentPort.postMessage({ copied, skipped,
|
|
88
|
+
parentPort.postMessage({ copied, skipped, error });
|