@myagentroam/marctl 0.9.120
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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/bin/marctl.mjs +6 -0
- package/bin/marnodes.mjs +6 -0
- package/bin/marscp.mjs +6 -0
- package/bin/marssh.mjs +6 -0
- package/package.json +32 -0
- package/src/cli.mjs +219 -0
- package/src/client.mjs +220 -0
- package/src/config.mjs +164 -0
- package/src/direct.mjs +791 -0
- package/src/i18n/en-US.mjs +220 -0
- package/src/i18n/index.mjs +20 -0
- package/src/i18n/zh-CN.mjs +212 -0
- package/src/scp-worker.cjs +254 -0
- package/src/scp.mjs +321 -0
- package/src/terminal.mjs +661 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
const { createHash, randomUUID } = require('node:crypto');
|
|
2
|
+
const { constants } = require('node:fs');
|
|
3
|
+
const { link, lstat, open, rename, unlink } = require('node:fs/promises');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const CHUNK = 32 * 1024;
|
|
7
|
+
const [mode, encodedPath, sizeArgument, encodedMetadata, preserveArgument, encodedSourceBasename] =
|
|
8
|
+
process.argv.slice(1);
|
|
9
|
+
const target = Buffer.from(encodedPath ?? '', 'base64url').toString('utf8');
|
|
10
|
+
const sourceBasename = Buffer.from(encodedSourceBasename ?? '', 'base64url').toString('utf8');
|
|
11
|
+
const input = process.stdin[Symbol.asyncIterator]();
|
|
12
|
+
let pending = Buffer.alloc(0);
|
|
13
|
+
|
|
14
|
+
async function readExact(length) {
|
|
15
|
+
while (pending.length < length) {
|
|
16
|
+
const next = await input.next();
|
|
17
|
+
if (next.done) throw new Error('SCP_STREAM_TRUNCATED');
|
|
18
|
+
pending = Buffer.concat([pending, next.value]);
|
|
19
|
+
if (pending.length > CHUNK * 4) throw new Error('SCP_INPUT_OVERFLOW');
|
|
20
|
+
}
|
|
21
|
+
const data = pending.subarray(0, length);
|
|
22
|
+
pending = pending.subarray(length);
|
|
23
|
+
return data;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function send(data) {
|
|
27
|
+
await new Promise((resolve, reject) => {
|
|
28
|
+
process.stdout.write(data, (error) => (error ? reject(error) : resolve()));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function frameLength(size) {
|
|
33
|
+
const buffer = Buffer.alloc(4);
|
|
34
|
+
buffer.writeUInt32BE(size);
|
|
35
|
+
return buffer;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fileMetadata(stat) {
|
|
39
|
+
const bytes = Buffer.alloc(20);
|
|
40
|
+
bytes.writeUInt32BE(stat.mode & 0o777, 0);
|
|
41
|
+
bytes.writeDoubleBE(stat.atimeMs, 4);
|
|
42
|
+
bytes.writeDoubleBE(stat.mtimeMs, 12);
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function appendFilename(directory, filename) {
|
|
47
|
+
const separator =
|
|
48
|
+
directory.endsWith(path.sep) || (process.platform === 'win32' && directory.endsWith('/'))
|
|
49
|
+
? ''
|
|
50
|
+
: path.sep;
|
|
51
|
+
return `${directory}${separator}${filename}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function destinationPath() {
|
|
55
|
+
const targetPath = process.platform === 'win32' ? path.win32 : path.posix;
|
|
56
|
+
const directoryIntent = (
|
|
57
|
+
process.platform === 'win32' ? /(?:^|[\\/])\.{0,2}$/u : /(?:^|\/)\.{0,2}$/u
|
|
58
|
+
).test(target);
|
|
59
|
+
// A trailing slash or dot must not disguise the final directory symlink.
|
|
60
|
+
let inspected = target;
|
|
61
|
+
const trailingDirectorySyntax = process.platform === 'win32' ? /[\\/]+\.?$/u : /\/+\.?$/u;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const trimmed = inspected.replace(trailingDirectorySyntax, '');
|
|
64
|
+
if (trimmed === inspected || trimmed.length < targetPath.parse(inspected).root.length) break;
|
|
65
|
+
inspected = trimmed;
|
|
66
|
+
}
|
|
67
|
+
let previous;
|
|
68
|
+
try {
|
|
69
|
+
previous = await lstat(inspected);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error.code !== 'ENOENT') throw error;
|
|
72
|
+
if (directoryIntent) throw new Error('SCP_TARGET_INVALID');
|
|
73
|
+
return target;
|
|
74
|
+
}
|
|
75
|
+
if (previous.isDirectory() && !previous.isSymbolicLink()) {
|
|
76
|
+
if (
|
|
77
|
+
!sourceBasename ||
|
|
78
|
+
sourceBasename === '.' ||
|
|
79
|
+
sourceBasename === '..' ||
|
|
80
|
+
sourceBasename.includes('\0') ||
|
|
81
|
+
targetPath.basename(sourceBasename) !== sourceBasename ||
|
|
82
|
+
(process.platform === 'win32' &&
|
|
83
|
+
(/[<>:"/\\|?*\x00-\x1f]/u.test(sourceBasename) ||
|
|
84
|
+
/[. ]$/u.test(sourceBasename) ||
|
|
85
|
+
/^(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\.|$)/iu.test(sourceBasename)))
|
|
86
|
+
)
|
|
87
|
+
throw new Error('SCP_SOURCE_FILENAME_INVALID');
|
|
88
|
+
return appendFilename(target, sourceBasename);
|
|
89
|
+
}
|
|
90
|
+
if (directoryIntent) throw new Error('SCP_TARGET_INVALID');
|
|
91
|
+
return target;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function source() {
|
|
95
|
+
const before = await lstat(target);
|
|
96
|
+
if (!before.isFile() || before.isSymbolicLink() || !Number.isSafeInteger(before.size))
|
|
97
|
+
throw new Error('SCP_SOURCE_INVALID');
|
|
98
|
+
const handle = await open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
99
|
+
try {
|
|
100
|
+
const current = await handle.stat();
|
|
101
|
+
if (
|
|
102
|
+
(before.dev !== current.dev && !(process.platform === 'win32' && before.dev === 0)) ||
|
|
103
|
+
before.ino !== current.ino ||
|
|
104
|
+
before.size !== current.size
|
|
105
|
+
)
|
|
106
|
+
throw new Error('SCP_SOURCE_CHANGED');
|
|
107
|
+
const size = Buffer.alloc(8);
|
|
108
|
+
size.writeBigUInt64BE(BigInt(current.size));
|
|
109
|
+
await send(size);
|
|
110
|
+
await send(fileMetadata(current));
|
|
111
|
+
const hash = createHash('sha256');
|
|
112
|
+
const buffer = Buffer.alloc(CHUNK);
|
|
113
|
+
let offset = 0;
|
|
114
|
+
while (offset < current.size) {
|
|
115
|
+
const { bytesRead } = await handle.read(
|
|
116
|
+
buffer,
|
|
117
|
+
0,
|
|
118
|
+
Math.min(CHUNK, current.size - offset),
|
|
119
|
+
offset
|
|
120
|
+
);
|
|
121
|
+
if (bytesRead === 0) throw new Error('SCP_SOURCE_CHANGED');
|
|
122
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
123
|
+
await send(Buffer.concat([frameLength(bytesRead), buffer.subarray(0, bytesRead)]));
|
|
124
|
+
if ((await readExact(1))[0] !== 1) throw new Error('SCP_ACK_INVALID');
|
|
125
|
+
offset += bytesRead;
|
|
126
|
+
}
|
|
127
|
+
const after = await handle.stat();
|
|
128
|
+
if (
|
|
129
|
+
after.size !== current.size ||
|
|
130
|
+
after.mtimeMs !== current.mtimeMs ||
|
|
131
|
+
after.ctimeMs !== current.ctimeMs ||
|
|
132
|
+
after.ino !== current.ino
|
|
133
|
+
)
|
|
134
|
+
throw new Error('SCP_SOURCE_CHANGED');
|
|
135
|
+
await send(Buffer.concat([frameLength(0), hash.digest()]));
|
|
136
|
+
} finally {
|
|
137
|
+
await handle.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function destination() {
|
|
142
|
+
const destinationTarget = await destinationPath();
|
|
143
|
+
const size = Number(sizeArgument);
|
|
144
|
+
if (!Number.isSafeInteger(size) || size < 0) throw new Error('SCP_SIZE_INVALID');
|
|
145
|
+
const metadata = Buffer.from(encodedMetadata ?? '', 'base64url');
|
|
146
|
+
if (metadata.length !== 20) throw new Error('SCP_METADATA_INVALID');
|
|
147
|
+
const sourceMode = metadata.readUInt32BE(0);
|
|
148
|
+
const atimeMs = metadata.readDoubleBE(4);
|
|
149
|
+
const mtimeMs = metadata.readDoubleBE(12);
|
|
150
|
+
if (sourceMode > 0o777 || !Number.isFinite(atimeMs) || !Number.isFinite(mtimeMs))
|
|
151
|
+
throw new Error('SCP_METADATA_INVALID');
|
|
152
|
+
const filename = path.basename(destinationTarget);
|
|
153
|
+
const temporary = appendFilename(
|
|
154
|
+
path.dirname(destinationTarget),
|
|
155
|
+
`.${filename}.marctl-${randomUUID()}.tmp`
|
|
156
|
+
);
|
|
157
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
158
|
+
let published = false;
|
|
159
|
+
try {
|
|
160
|
+
let previous;
|
|
161
|
+
try {
|
|
162
|
+
previous = await lstat(destinationTarget);
|
|
163
|
+
if (!previous.isFile() || previous.isSymbolicLink()) throw new Error('SCP_TARGET_INVALID');
|
|
164
|
+
const existing = await open(
|
|
165
|
+
destinationTarget,
|
|
166
|
+
constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0)
|
|
167
|
+
);
|
|
168
|
+
try {
|
|
169
|
+
const accessible = await existing.stat();
|
|
170
|
+
if (
|
|
171
|
+
(!(process.platform === 'win32' && previous.dev === 0) &&
|
|
172
|
+
previous.dev !== accessible.dev) ||
|
|
173
|
+
previous.ino !== accessible.ino
|
|
174
|
+
)
|
|
175
|
+
throw new Error('SCP_TARGET_CHANGED');
|
|
176
|
+
} finally {
|
|
177
|
+
await existing.close();
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (error.code !== 'ENOENT') throw error;
|
|
181
|
+
}
|
|
182
|
+
await send(Buffer.from([1]));
|
|
183
|
+
const hash = createHash('sha256');
|
|
184
|
+
let received = 0;
|
|
185
|
+
for (;;) {
|
|
186
|
+
const length = (await readExact(4)).readUInt32BE();
|
|
187
|
+
if (length === 0) break;
|
|
188
|
+
if (length > CHUNK || received + length > size) throw new Error('SCP_SIZE_INVALID');
|
|
189
|
+
const bytes = await readExact(length);
|
|
190
|
+
hash.update(bytes);
|
|
191
|
+
let written = 0;
|
|
192
|
+
while (written < bytes.length) {
|
|
193
|
+
const result = await handle.write(
|
|
194
|
+
bytes,
|
|
195
|
+
written,
|
|
196
|
+
bytes.length - written,
|
|
197
|
+
received + written
|
|
198
|
+
);
|
|
199
|
+
written += result.bytesWritten;
|
|
200
|
+
}
|
|
201
|
+
received += length;
|
|
202
|
+
await send(Buffer.from([1]));
|
|
203
|
+
}
|
|
204
|
+
const expectedHash = await readExact(32);
|
|
205
|
+
if (received !== size || !hash.digest().equals(expectedHash))
|
|
206
|
+
throw new Error('SCP_CHECKSUM_MISMATCH');
|
|
207
|
+
if (preserveArgument === '1') await handle.utimes(new Date(atimeMs), new Date(mtimeMs));
|
|
208
|
+
await handle.chmod(
|
|
209
|
+
preserveArgument === '1'
|
|
210
|
+
? sourceMode
|
|
211
|
+
: previous === undefined
|
|
212
|
+
? sourceMode & ~process.umask()
|
|
213
|
+
: previous.mode & 0o777
|
|
214
|
+
);
|
|
215
|
+
await handle.sync();
|
|
216
|
+
await handle.close();
|
|
217
|
+
if (previous === undefined) {
|
|
218
|
+
await link(temporary, destinationTarget);
|
|
219
|
+
} else {
|
|
220
|
+
const current = await lstat(destinationTarget);
|
|
221
|
+
if (
|
|
222
|
+
!current.isFile() ||
|
|
223
|
+
current.isSymbolicLink() ||
|
|
224
|
+
previous.dev !== current.dev ||
|
|
225
|
+
previous.ino !== current.ino ||
|
|
226
|
+
previous.size !== current.size ||
|
|
227
|
+
previous.mtimeMs !== current.mtimeMs ||
|
|
228
|
+
previous.ctimeMs !== current.ctimeMs
|
|
229
|
+
)
|
|
230
|
+
throw new Error('SCP_TARGET_CHANGED');
|
|
231
|
+
await rename(temporary, destinationTarget);
|
|
232
|
+
}
|
|
233
|
+
published = true;
|
|
234
|
+
await send(Buffer.from([2]));
|
|
235
|
+
} finally {
|
|
236
|
+
await handle.close().catch(() => undefined);
|
|
237
|
+
await unlink(temporary).catch(() => undefined);
|
|
238
|
+
if (!published) process.exitCode = 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function main() {
|
|
243
|
+
if (!path.isAbsolute(target) || target.includes('\0')) throw new Error('SCP_PATH_INVALID');
|
|
244
|
+
if (mode === 'read') await source();
|
|
245
|
+
else if (mode === 'write') await destination();
|
|
246
|
+
else throw new Error('SCP_MODE_INVALID');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
void main()
|
|
250
|
+
.catch((error) => {
|
|
251
|
+
process.stderr.write(`${error instanceof Error ? error.message : 'SCP_FAILED'}\n`);
|
|
252
|
+
process.exitCode = 1;
|
|
253
|
+
})
|
|
254
|
+
.finally(() => process.stdin.destroy());
|
package/src/scp.mjs
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { gzipSync } from 'node:zlib';
|
|
5
|
+
import { MarctlError } from './config.mjs';
|
|
6
|
+
import { parseFrame, runTerminalSession } from './terminal.mjs';
|
|
7
|
+
|
|
8
|
+
const CHUNK = 32 * 1024;
|
|
9
|
+
const script = gzipSync(readFileSync(new URL('./scp-worker.cjs', import.meta.url))).toString(
|
|
10
|
+
'base64'
|
|
11
|
+
);
|
|
12
|
+
const expression = `eval(require('zlib').gunzipSync(Buffer.from('${script}','base64')).toString())`;
|
|
13
|
+
|
|
14
|
+
export function parseScpEndpoint(value) {
|
|
15
|
+
if (typeof value !== 'string' || !value || value.includes('\0'))
|
|
16
|
+
throw new MarctlError('Invalid copy endpoint.');
|
|
17
|
+
const explicit = value.indexOf('://');
|
|
18
|
+
const separator = explicit >= 0 ? explicit : value.indexOf(':');
|
|
19
|
+
if (separator < 0 || (separator === 1 && /^[A-Za-z]:[\\/]/u.test(value))) {
|
|
20
|
+
// Let the filesystem resolve dot components and symlink parents.
|
|
21
|
+
return { path: path.isAbsolute(value) ? value : `${process.cwd()}${path.sep}${value}` };
|
|
22
|
+
}
|
|
23
|
+
const host = value.slice(0, separator);
|
|
24
|
+
const file = value.slice(separator + (explicit >= 0 ? 3 : 1));
|
|
25
|
+
if (!host || !file || (!file.startsWith('/') && !/^[A-Za-z]:[\\/]/u.test(file)))
|
|
26
|
+
throw new MarctlError('Invalid Node file path.');
|
|
27
|
+
return { host, path: file };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function scpSourceBasename(value) {
|
|
31
|
+
const windowsPath = /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('\\\\');
|
|
32
|
+
if (value.endsWith('/') || (windowsPath && value.endsWith('\\')))
|
|
33
|
+
throw new MarctlError('Invalid source filename.');
|
|
34
|
+
const sourcePath = windowsPath ? path.win32 : path.posix;
|
|
35
|
+
const basename = sourcePath.basename(value);
|
|
36
|
+
if (!basename || basename === '.' || basename === '..')
|
|
37
|
+
throw new MarctlError('Invalid source filename.');
|
|
38
|
+
return basename;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function workerArguments(mode, file, size, metadata, preserve, sourceBasename) {
|
|
42
|
+
const encoded = Buffer.from(file, 'utf8').toString('base64url');
|
|
43
|
+
return [
|
|
44
|
+
mode,
|
|
45
|
+
encoded,
|
|
46
|
+
...(size === undefined
|
|
47
|
+
? []
|
|
48
|
+
: [
|
|
49
|
+
String(size),
|
|
50
|
+
metadata.toString('base64url'),
|
|
51
|
+
preserve ? '1' : '0',
|
|
52
|
+
Buffer.from(sourceBasename, 'utf8').toString('base64url')
|
|
53
|
+
])
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function workerCommand(mode, file, size, metadata, preserve, sourceBasename) {
|
|
58
|
+
return `node -e "${expression}" ${workerArguments(mode, file, size, metadata, preserve, sourceBasename).join(' ')}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class ByteChannel {
|
|
62
|
+
#buffer = Buffer.alloc(0);
|
|
63
|
+
#waiting = [];
|
|
64
|
+
#ended;
|
|
65
|
+
#failure;
|
|
66
|
+
|
|
67
|
+
constructor(send, signal) {
|
|
68
|
+
this.send = (bytes) => {
|
|
69
|
+
if (this.#failure) throw this.#failure;
|
|
70
|
+
return send(bytes);
|
|
71
|
+
};
|
|
72
|
+
if (signal) {
|
|
73
|
+
if (signal.aborted) this.fail(new MarctlError('Copy was interrupted.'));
|
|
74
|
+
else
|
|
75
|
+
signal.addEventListener(
|
|
76
|
+
'abort',
|
|
77
|
+
() => this.fail(new MarctlError('Copy was interrupted.')),
|
|
78
|
+
{
|
|
79
|
+
once: true
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
feed(bytes) {
|
|
86
|
+
if (this.#failure) return;
|
|
87
|
+
this.#buffer = Buffer.concat([this.#buffer, bytes]);
|
|
88
|
+
if (this.#buffer.length > CHUNK * 4)
|
|
89
|
+
return this.fail(new MarctlError('Copy output exceeded the bounded window.'));
|
|
90
|
+
this.#wake();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
finish(code) {
|
|
94
|
+
this.#ended = code;
|
|
95
|
+
this.#wake();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
fail(error) {
|
|
99
|
+
this.#failure ??= error;
|
|
100
|
+
this.#wake();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#wake() {
|
|
104
|
+
for (const resolve of this.#waiting.splice(0)) resolve();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async read(length) {
|
|
108
|
+
if (this.#failure) throw this.#failure;
|
|
109
|
+
while (this.#buffer.length < length) {
|
|
110
|
+
if (this.#failure) throw this.#failure;
|
|
111
|
+
if (this.#ended !== undefined) throw new MarctlError('Copy stream ended unexpectedly.');
|
|
112
|
+
await new Promise((resolve) => this.#waiting.push(resolve));
|
|
113
|
+
}
|
|
114
|
+
const bytes = this.#buffer.subarray(0, length);
|
|
115
|
+
this.#buffer = this.#buffer.subarray(length);
|
|
116
|
+
return bytes;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async done() {
|
|
120
|
+
if (this.#failure) throw this.#failure;
|
|
121
|
+
while (this.#ended === undefined && this.#failure === undefined)
|
|
122
|
+
await new Promise((resolve) => this.#waiting.push(resolve));
|
|
123
|
+
if (this.#failure) throw this.#failure;
|
|
124
|
+
if (this.#ended !== 0) throw new MarctlError('Remote copy command failed.');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function copyBytes(source, destination) {
|
|
129
|
+
const sizeBytes = await source.read(8);
|
|
130
|
+
const size = sizeBytes.readBigUInt64BE();
|
|
131
|
+
if (size > BigInt(Number.MAX_SAFE_INTEGER)) throw new MarctlError('Copy source is too large.');
|
|
132
|
+
const metadata = await source.read(20);
|
|
133
|
+
await destination.open(Number(size), metadata, async (target) => {
|
|
134
|
+
if ((await target.read(1))[0] !== 1)
|
|
135
|
+
throw new MarctlError('Copy target did not accept the file.');
|
|
136
|
+
let remaining = Number(size);
|
|
137
|
+
for (;;) {
|
|
138
|
+
const header = await source.read(4);
|
|
139
|
+
const length = header.readUInt32BE();
|
|
140
|
+
if (length > CHUNK || length > remaining)
|
|
141
|
+
throw new MarctlError('Copy source sent an invalid chunk.');
|
|
142
|
+
await target.send(header);
|
|
143
|
+
if (length === 0) {
|
|
144
|
+
await target.send(await source.read(32));
|
|
145
|
+
if (remaining !== 0 || (await target.read(1))[0] !== 2)
|
|
146
|
+
throw new MarctlError('Copy target did not verify the file.');
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
await target.send(await source.read(length));
|
|
150
|
+
if ((await target.read(1))[0] !== 1) throw new MarctlError('Copy target rejected the chunk.');
|
|
151
|
+
await source.send(Buffer.from([1]));
|
|
152
|
+
remaining -= length;
|
|
153
|
+
}
|
|
154
|
+
await target.done();
|
|
155
|
+
});
|
|
156
|
+
await source.done();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function withLocalWorker(mode, file, size, metadata, preserve, sourceBasename, use, signal) {
|
|
160
|
+
const child = spawn(
|
|
161
|
+
process.execPath,
|
|
162
|
+
['-e', expression, ...workerArguments(mode, file, size, metadata, preserve, sourceBasename)],
|
|
163
|
+
{
|
|
164
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
165
|
+
windowsHide: true
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
child.stdin.on('error', () => undefined);
|
|
169
|
+
child.stderr.resume();
|
|
170
|
+
const channel = new ByteChannel((bytes) => child.stdin.write(bytes), signal);
|
|
171
|
+
child.stdout.on('data', (bytes) => channel.feed(bytes));
|
|
172
|
+
child.once('error', (error) => channel.fail(error));
|
|
173
|
+
child.once('close', (code) => channel.finish(code ?? 1));
|
|
174
|
+
try {
|
|
175
|
+
return await use(channel);
|
|
176
|
+
} finally {
|
|
177
|
+
if (child.exitCode === null) child.kill();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function withRemoteWorker(
|
|
182
|
+
{
|
|
183
|
+
client,
|
|
184
|
+
WebSocketClass,
|
|
185
|
+
nodeId,
|
|
186
|
+
file,
|
|
187
|
+
mode,
|
|
188
|
+
size,
|
|
189
|
+
metadata,
|
|
190
|
+
preserve,
|
|
191
|
+
sourceBasename,
|
|
192
|
+
translate,
|
|
193
|
+
signal
|
|
194
|
+
},
|
|
195
|
+
use
|
|
196
|
+
) {
|
|
197
|
+
return runTerminalSession({
|
|
198
|
+
client,
|
|
199
|
+
WebSocketClass,
|
|
200
|
+
nodeId,
|
|
201
|
+
command: workerCommand(mode, file, size, metadata, preserve, sourceBasename),
|
|
202
|
+
binary: true,
|
|
203
|
+
raceConnections: true,
|
|
204
|
+
translate,
|
|
205
|
+
attached: async ({ transport, terminalId }) => {
|
|
206
|
+
const channel = new ByteChannel(
|
|
207
|
+
(bytes) =>
|
|
208
|
+
transport.send(
|
|
209
|
+
JSON.stringify({ type: 'input', terminalId, data: bytes.toString('base64') })
|
|
210
|
+
),
|
|
211
|
+
signal
|
|
212
|
+
);
|
|
213
|
+
let sequence = 0;
|
|
214
|
+
const onMessage = (raw, isBinary) => {
|
|
215
|
+
try {
|
|
216
|
+
const frame = parseFrame(raw, isBinary, terminalId, translate);
|
|
217
|
+
if (frame.type === 'output') {
|
|
218
|
+
if (frame.sequence !== sequence + 1)
|
|
219
|
+
throw new MarctlError('Copy output sequence is invalid.');
|
|
220
|
+
sequence = frame.sequence;
|
|
221
|
+
const bytes = Buffer.from(frame.data, 'base64');
|
|
222
|
+
if (bytes.toString('base64') !== frame.data)
|
|
223
|
+
throw new MarctlError('Copy output is not valid base64.');
|
|
224
|
+
channel.feed(bytes);
|
|
225
|
+
} else if (frame.type === 'exit') channel.finish(frame.exitCode ?? 1);
|
|
226
|
+
else if (frame.type === 'error')
|
|
227
|
+
channel.fail(new MarctlError(translate('errors.terminalRemoteError')));
|
|
228
|
+
else throw new MarctlError(translate('errors.terminalUnexpectedFrame'));
|
|
229
|
+
} catch (error) {
|
|
230
|
+
channel.fail(error);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
const onError = () =>
|
|
234
|
+
channel.fail(new MarctlError(translate('errors.terminalConnectionFailed')));
|
|
235
|
+
const onClose = () =>
|
|
236
|
+
channel.fail(new MarctlError(translate('errors.terminalConnectionLost')));
|
|
237
|
+
transport.on('message', onMessage);
|
|
238
|
+
transport.on('error', onError);
|
|
239
|
+
transport.on('close', onClose);
|
|
240
|
+
try {
|
|
241
|
+
transport.activate?.();
|
|
242
|
+
return await use(channel);
|
|
243
|
+
} finally {
|
|
244
|
+
transport.removeListener?.('message', onMessage);
|
|
245
|
+
transport.removeListener?.('error', onError);
|
|
246
|
+
transport.removeListener?.('close', onClose);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function withWorker(endpoint, mode, size, metadata, context, use) {
|
|
253
|
+
if (endpoint.nodeId === undefined)
|
|
254
|
+
return withLocalWorker(
|
|
255
|
+
mode,
|
|
256
|
+
endpoint.path,
|
|
257
|
+
size,
|
|
258
|
+
metadata,
|
|
259
|
+
context.preserve,
|
|
260
|
+
context.sourceBasename,
|
|
261
|
+
use,
|
|
262
|
+
context.signal
|
|
263
|
+
);
|
|
264
|
+
return withRemoteWorker(
|
|
265
|
+
{
|
|
266
|
+
...context,
|
|
267
|
+
nodeId: endpoint.nodeId,
|
|
268
|
+
file: endpoint.path,
|
|
269
|
+
mode,
|
|
270
|
+
size,
|
|
271
|
+
metadata
|
|
272
|
+
},
|
|
273
|
+
use
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function runScp({
|
|
278
|
+
source,
|
|
279
|
+
destination,
|
|
280
|
+
preserve = false,
|
|
281
|
+
client,
|
|
282
|
+
WebSocketClass,
|
|
283
|
+
resolveNodeId,
|
|
284
|
+
translate,
|
|
285
|
+
signals = process
|
|
286
|
+
}) {
|
|
287
|
+
const from = parseScpEndpoint(source);
|
|
288
|
+
const to = parseScpEndpoint(destination);
|
|
289
|
+
const sourceBasename = scpSourceBasename(from.path);
|
|
290
|
+
if (!from.host && !to.host) throw new MarctlError('At least one copy endpoint must be a Node.');
|
|
291
|
+
const references = [...new Set([from.host, to.host].filter(Boolean))];
|
|
292
|
+
const ids = await Promise.all(references.map((host) => resolveNodeId(host)));
|
|
293
|
+
const resolved = (endpoint) => ({
|
|
294
|
+
path: endpoint.path,
|
|
295
|
+
...(endpoint.host ? { nodeId: ids[references.indexOf(endpoint.host)] } : {})
|
|
296
|
+
});
|
|
297
|
+
const controller = new AbortController();
|
|
298
|
+
const interrupt = () => controller.abort();
|
|
299
|
+
signals.on?.('SIGINT', interrupt);
|
|
300
|
+
signals.on?.('SIGTERM', interrupt);
|
|
301
|
+
try {
|
|
302
|
+
const context = {
|
|
303
|
+
client,
|
|
304
|
+
WebSocketClass,
|
|
305
|
+
translate,
|
|
306
|
+
signal: controller.signal,
|
|
307
|
+
preserve,
|
|
308
|
+
sourceBasename
|
|
309
|
+
};
|
|
310
|
+
await withWorker(resolved(from), 'read', undefined, undefined, context, (channel) =>
|
|
311
|
+
copyBytes(channel, {
|
|
312
|
+
open: (size, metadata, use) =>
|
|
313
|
+
withWorker(resolved(to), 'write', size, metadata, context, use)
|
|
314
|
+
})
|
|
315
|
+
);
|
|
316
|
+
return 0;
|
|
317
|
+
} finally {
|
|
318
|
+
signals.removeListener?.('SIGINT', interrupt);
|
|
319
|
+
signals.removeListener?.('SIGTERM', interrupt);
|
|
320
|
+
}
|
|
321
|
+
}
|