@remcp/runtime 0.2.154 → 0.2.156
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/package.json +1 -1
- package/src/extended/browser-actions.mjs +311 -0
- package/src/extended/browser-endpoint.mjs +469 -0
- package/src/extended/browser-read.mjs +288 -0
- package/src/extended/browser-session.mjs +413 -0
- package/src/extended/browser.mjs +4 -1440
- package/src/extended/desktop-computer-actions.mjs +346 -0
- package/src/extended/desktop-computer-snapshot.mjs +245 -0
- package/src/extended/desktop-core.mjs +455 -0
- package/src/extended/desktop-linux-displays.mjs +366 -0
- package/src/extended/desktop-linux-input.mjs +485 -0
- package/src/extended/desktop-linux-screen.mjs +105 -0
- package/src/extended/desktop-linux-ui.mjs +407 -0
- package/src/extended/desktop-linux-window-actions.mjs +237 -0
- package/src/extended/desktop-linux-windows.mjs +321 -0
- package/src/extended/desktop-linux.mjs +6 -1791
- package/src/extended/desktop-ui-actions.mjs +402 -0
- package/src/extended/desktop-vision.mjs +209 -0
- package/src/extended/desktop.mjs +6 -1583
- package/src/tools/files-archive-actions.mjs +166 -0
- package/src/tools/files-archive-safety.mjs +395 -0
- package/src/tools/files-bulk-operations.mjs +415 -0
- package/src/tools/files-read-traversal.mjs +347 -0
- package/src/tools/files-screenshots.mjs +167 -0
- package/src/tools/files-secure-paths.mjs +408 -0
- package/src/tools/files-write-edit.mjs +336 -0
- package/src/tools/files.mjs +8 -2111
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { constants, createReadStream } from 'node:fs';
|
|
6
|
+
import { access, chmod, chown, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import { liveConfig, runtimeConfig } from '../config.mjs';
|
|
9
|
+
import { documentKind, readDocxText, readPdfText } from '../documents.mjs';
|
|
10
|
+
import { diffStats, unifiedDiff } from '../diff.mjs';
|
|
11
|
+
import { describeFilesystemFailure } from '../permissions.mjs';
|
|
12
|
+
import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
|
|
13
|
+
import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
|
|
14
|
+
import { countEvent, recordEvent } from '../telemetry.mjs';
|
|
15
|
+
import { clampInteger, decodeText, displayPath, fail, fitsOutput, globToRegExp, image, isInsideRoot, looksBinary, multi, pageLines, resolveSafePath, splitLines, text, throwIfCancelled } from '../util.mjs';
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
import { ensureDirectoryPath } from './files-secure-paths.mjs';
|
|
19
|
+
import { MAX_ARCHIVE_EXPANDED_BYTES, archiveExpandedSize, archiveTarExpandedSize, archiveTool, assertArchiveExpandedSize, assertExtractedTree, copyTreeContents, installExtractedTree, moveArchiveFile, recoverExtractionArtifacts, safeArchiveEntry, snapshotRegularFile } from './files-archive-safety.mjs';
|
|
20
|
+
|
|
21
|
+
export async function createArchiveTool(args, extra = {}) {
|
|
22
|
+
const tools = archiveTool();
|
|
23
|
+
const sources = Array.isArray(args.paths) ? args.paths : [args.paths].filter(Boolean);
|
|
24
|
+
if (!sources.length) fail('paths must list at least one file or directory');
|
|
25
|
+
const resolved = [];
|
|
26
|
+
for (const entry of sources) {
|
|
27
|
+
throwIfCancelled(extra.signal);
|
|
28
|
+
resolved.push(await resolveSafePath(entry, 'paths[]'));
|
|
29
|
+
}
|
|
30
|
+
const destination = await resolveSafePath(args.destination, 'destination');
|
|
31
|
+
const existingDestination = await lstat(destination).catch(() => null);
|
|
32
|
+
if (existingDestination?.isSymbolicLink()) fail('Archive destination cannot be a symbolic link');
|
|
33
|
+
const format = String(args.format || (destination.endsWith('.zip') ? 'zip' : 'tar.gz')).toLowerCase();
|
|
34
|
+
const baseDir = path.dirname(resolved[0]);
|
|
35
|
+
const names = resolved.map(entry => path.relative(baseDir, entry));
|
|
36
|
+
if (names.some(name => !name || name.startsWith('..') || path.isAbsolute(name))) fail('Archive sources must share one parent directory');
|
|
37
|
+
const archiveNames = names.map(name => {
|
|
38
|
+
const portable = name.split(path.sep).join('/');
|
|
39
|
+
return portable.startsWith('-') ? `./${portable}` : portable;
|
|
40
|
+
});
|
|
41
|
+
const suffix = format === 'zip' ? '.zip' : format === 'tar' ? '.tar' : '.tar.gz';
|
|
42
|
+
const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-archive-'));
|
|
43
|
+
if (resolved.some(entry => !path.relative(entry, stagingDirectory).startsWith('..') && !path.isAbsolute(path.relative(entry, stagingDirectory)))) {
|
|
44
|
+
await rm(stagingDirectory, { recursive: true, force: true });
|
|
45
|
+
fail('Archive source must not contain the system temporary directory');
|
|
46
|
+
}
|
|
47
|
+
const staging = path.join(stagingDirectory, `payload${suffix}`);
|
|
48
|
+
const snapshotRoot = path.join(stagingDirectory, 'source');
|
|
49
|
+
try {
|
|
50
|
+
await ensureDirectoryPath(snapshotRoot, { allowOutside:true });
|
|
51
|
+
|
|
52
|
+
const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES, signal: extra.signal };
|
|
53
|
+
for (let index = 0; index < resolved.length; index += 1) {
|
|
54
|
+
throwIfCancelled(extra.signal);
|
|
55
|
+
const snapshotPath = path.join(snapshotRoot, names[index]);
|
|
56
|
+
await ensureDirectoryPath(path.dirname(snapshotPath), { allowOutside:true });
|
|
57
|
+
|
|
58
|
+
await copyTreeContents(resolved[index], snapshotPath, snapshotState);
|
|
59
|
+
}
|
|
60
|
+
if (format === 'zip') {
|
|
61
|
+
if (process.platform === 'win32') {
|
|
62
|
+
if (!tools.tar) fail('Windows archive backend is unavailable: tar.exe is required');
|
|
63
|
+
const result = spawnSync(tools.tar, ['-a', '-c', '-f', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
64
|
+
if (result.status !== 0) fail(`tar.exe ZIP creation failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
65
|
+
} else {
|
|
66
|
+
if (!tools.zip) fail('zip is not installed on this device; use format "tar.gz"');
|
|
67
|
+
const result = spawnSync(tools.zip, ['-r', '-q', '-y', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
68
|
+
if (result.status !== 0) fail(`zip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
69
|
+
}
|
|
70
|
+
} else if (format === 'tar' || format === 'tar.gz' || format === 'tgz') {
|
|
71
|
+
if (!tools.tar) fail('tar is not installed on this device');
|
|
72
|
+
const flags = format === 'tar' ? '-cf' : '-czf';
|
|
73
|
+
const result = spawnSync(tools.tar, [flags, staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
74
|
+
if (result.status !== 0) fail(`tar failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
75
|
+
} else {
|
|
76
|
+
fail('format must be tar, tar.gz, or zip');
|
|
77
|
+
}
|
|
78
|
+
const stagedInfo = await stat(staging);
|
|
79
|
+
assertArchiveExpandedSize(stagedInfo.size, 'archive output');
|
|
80
|
+
|
|
81
|
+
await moveArchiveFile(staging, destination);
|
|
82
|
+
|
|
83
|
+
const info = await stat(destination).catch(() => null);
|
|
84
|
+
const note = ' (built outside the tree in temporary staging and committed atomically)';
|
|
85
|
+
|
|
86
|
+
return text(`Created ${displayPath(destination)} (${format}, ${info?.size ?? 0} bytes) from ${resolved.length} path(s)${note}.`);
|
|
87
|
+
} finally {
|
|
88
|
+
if (stagingDirectory) await rm(stagingDirectory, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function archiveFormatForPath(value) {
|
|
93
|
+
if (/\.zip$/i.test(value)) return 'zip';
|
|
94
|
+
if (/\.(tar\.gz|tgz)$/i.test(value)) return 'tar.gz';
|
|
95
|
+
if (/\.(tar\.bz2|tbz2?)$/i.test(value)) return 'tar.bz2';
|
|
96
|
+
if (/\.tar\.xz$/i.test(value)) return 'tar.xz';
|
|
97
|
+
return 'tar';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function archiveSnapshotSuffix(format) {
|
|
101
|
+
if (format === 'zip') return '.zip';
|
|
102
|
+
if (format === 'tar.bz2') return '.tar.bz2';
|
|
103
|
+
if (format === 'tar.xz') return '.tar.xz';
|
|
104
|
+
if (format === 'tar.gz') return '.tar.gz';
|
|
105
|
+
return '.tar';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function extractArchiveTool(args) {
|
|
109
|
+
const tools = archiveTool();
|
|
110
|
+
const archive = await resolveSafePath(args.archive, 'archive');
|
|
111
|
+
const destination = await resolveSafePath(args.destination || path.dirname(archive), 'destination');
|
|
112
|
+
if (destination === path.parse(destination).root) fail('Archive destination cannot be a filesystem root');
|
|
113
|
+
const parent = path.dirname(destination);
|
|
114
|
+
const backupPrefix = `.remcp-extract-backup-${createHash('sha256').update(destination).digest('hex').slice(0, 16)}-`;
|
|
115
|
+
const format = archiveFormatForPath(archive);
|
|
116
|
+
const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-extract-'));
|
|
117
|
+
const archiveSnapshot = path.join(stagingDirectory, `source${archiveSnapshotSuffix(format)}`);
|
|
118
|
+
const staging = path.join(stagingDirectory, 'tree');
|
|
119
|
+
try {
|
|
120
|
+
await mkdir(staging, { mode: 0o700 });
|
|
121
|
+
await snapshotRegularFile(archive, archiveSnapshot, MAX_ARCHIVE_EXPANDED_BYTES);
|
|
122
|
+
await recoverExtractionArtifacts(parent, destination, backupPrefix);
|
|
123
|
+
const existingDestination = await lstat(destination).catch(() => null);
|
|
124
|
+
if (existingDestination?.isSymbolicLink() || (existingDestination && !existingDestination.isDirectory())) fail('Archive destination is not a safe directory');
|
|
125
|
+
if (format === 'zip' && process.platform !== 'win32') {
|
|
126
|
+
if (!tools.unzip) fail('unzip is not installed on this device');
|
|
127
|
+
const listing = spawnSync(tools.unzip, ['-Z', '-1', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
128
|
+
if (listing.error || listing.status !== 0) fail(`Could not inspect ZIP archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
|
|
129
|
+
const names = String(listing.stdout || '').split(/\r?\n/).filter(Boolean);
|
|
130
|
+
if (names.length > 100_000 || names.some(name => !safeArchiveEntry(name))) fail('ZIP archive contains an unsafe path or too many entries');
|
|
131
|
+
const details = spawnSync(tools.unzip, ['-Z', '-v', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
132
|
+
if (details.error || details.status !== 0) fail(`Could not inspect ZIP sizes: ${(details.stderr || details.error?.message || `exit ${details.status}`).trim()}`);
|
|
133
|
+
const detailText = String(details.stdout || '');
|
|
134
|
+
if (/symbolic link|Unix file attributes \([^)]*\b12\d{4}/i.test(detailText)) fail('ZIP archive contains a symbolic link');
|
|
135
|
+
const expandedBytes = archiveExpandedSize(detailText.split(/\r?\n/), /uncompressed size:\s*(\d+)\s*bytes/i, 'ZIP archive', names.length);
|
|
136
|
+
assertArchiveExpandedSize(expandedBytes, 'ZIP archive');
|
|
137
|
+
const result = spawnSync(tools.unzip, ['-o', '-q', archiveSnapshot, '-d', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
138
|
+
if (result.status !== 0) fail(`unzip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
139
|
+
} else {
|
|
140
|
+
if (!tools.tar) fail(process.platform === 'win32' ? 'Windows archive backend is unavailable: tar.exe is required' : 'tar is not installed on this device');
|
|
141
|
+
const listFlags = format === 'tar.gz' ? '-tzf' : format === 'tar.bz2' ? '-tjf' : format === 'tar.xz' ? '-tJf' : '-tf';
|
|
142
|
+
const listing = spawnSync(tools.tar, [listFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
143
|
+
if (listing.error || listing.status !== 0) fail(`Could not inspect archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
|
|
144
|
+
const names = String(listing.stdout || '').split(/\r?\n/).filter(Boolean);
|
|
145
|
+
if (names.length > 100_000 || names.some(name => !safeArchiveEntry(name))) fail('Archive contains an unsafe path or too many entries');
|
|
146
|
+
const verboseFlags = format === 'tar.gz' ? '-tvzf' : format === 'tar.bz2' ? '-tvjf' : format === 'tar.xz' ? '-tvJf' : '-tvf';
|
|
147
|
+
const details = spawnSync(tools.tar, [verboseFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
148
|
+
if (details.error || details.status !== 0) fail(`Could not inspect archive sizes: ${(details.stderr || details.error?.message || `exit ${details.status}`).trim()}`);
|
|
149
|
+
const detailLines = String(details.stdout || '').split(/\r?\n/);
|
|
150
|
+
if (detailLines.some(line => /^[bclph]/.test(line))) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
151
|
+
const expandedBytes = archiveTarExpandedSize(detailLines, 'archive', names.length);
|
|
152
|
+
assertArchiveExpandedSize(expandedBytes, 'archive');
|
|
153
|
+
const flags = format === 'tar.gz' ? '-xzf' : format === 'tar.bz2' ? '-xjf' : format === 'tar.xz' ? '-xJf' : '-xf';
|
|
154
|
+
const result = spawnSync(tools.tar, [flags, archiveSnapshot, '-C', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
155
|
+
if (result.status !== 0) fail(`tar failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
156
|
+
}
|
|
157
|
+
await assertExtractedTree(staging);
|
|
158
|
+
await installExtractedTree(staging, destination);
|
|
159
|
+
const entries = await readdir(destination).catch(() => []);
|
|
160
|
+
return text(`Extracted ${displayPath(archive)} into ${displayPath(destination)} (${entries.length} top-level entries).`);
|
|
161
|
+
} finally {
|
|
162
|
+
await rm(stagingDirectory, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- screenshots ----------------------------------------------------------------------
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { constants, createReadStream } from 'node:fs';
|
|
6
|
+
import { access, chmod, chown, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import { liveConfig, runtimeConfig } from '../config.mjs';
|
|
9
|
+
import { documentKind, readDocxText, readPdfText } from '../documents.mjs';
|
|
10
|
+
import { diffStats, unifiedDiff } from '../diff.mjs';
|
|
11
|
+
import { describeFilesystemFailure } from '../permissions.mjs';
|
|
12
|
+
import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
|
|
13
|
+
import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
|
|
14
|
+
import { countEvent, recordEvent } from '../telemetry.mjs';
|
|
15
|
+
import { clampInteger, decodeText, displayPath, fail, fitsOutput, globToRegExp, image, isInsideRoot, looksBinary, multi, pageLines, resolveSafePath, splitLines, text, throwIfCancelled } from '../util.mjs';
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
import { READ_NOFOLLOW_NONBLOCK, WRITE_CREATE_NOFOLLOW, ensureDirectoryPath, openDirectoryPath, openRegularFile } from './files-secure-paths.mjs';
|
|
19
|
+
|
|
20
|
+
export const MAX_ARCHIVE_EXPANDED_BYTES = Math.min(
|
|
21
|
+
4 * 1024 * 1024 * 1024,
|
|
22
|
+
Math.max(64 * 1024 * 1024, Number(process.env.REMCP_MAX_ARCHIVE_EXPANDED_BYTES) || 512 * 1024 * 1024),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export function safeArchiveEntry(name) {
|
|
26
|
+
const normalized = String(name || '').replace(/\\/g, '/');
|
|
27
|
+
return Boolean(normalized) && !normalized.startsWith('/') && !/^[A-Za-z]:\//.test(normalized) && !normalized.split('/').includes('..');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function archiveExpandedSize(lines, pattern, label, expectedEntries = null) {
|
|
31
|
+
let total = 0;
|
|
32
|
+
let matched = 0;
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
const match = line.match(pattern);
|
|
35
|
+
if (!match) continue;
|
|
36
|
+
total += Number(match[1]);
|
|
37
|
+
matched += 1;
|
|
38
|
+
if (!Number.isSafeInteger(total) || total > MAX_ARCHIVE_EXPANDED_BYTES) fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
|
|
39
|
+
}
|
|
40
|
+
if ((!matched && expectedEntries !== 0) || (expectedEntries !== null && matched < expectedEntries)) fail(`Could not determine ${label} size safely`);
|
|
41
|
+
return total;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function tarEntrySize(line) {
|
|
45
|
+
const tokens = String(line || '').trim().split(/\s+/);
|
|
46
|
+
for (const index of [2, 3, 4]) {
|
|
47
|
+
const size = Number(tokens[index]);
|
|
48
|
+
if (!Number.isSafeInteger(size) || size < 0) continue;
|
|
49
|
+
const tail = tokens.slice(index + 1);
|
|
50
|
+
const gnuDate = /^\d{4}-\d{2}-\d{2}$/.test(tail[0] || '');
|
|
51
|
+
const bsdMonthDay = /^[A-Z][a-z]{2}$/.test(tail[0] || '') && /^\d{1,2}$/.test(tail[1] || '');
|
|
52
|
+
const bsdTime = /^\d{2}:\d{2}(?::\d{2})?$/.test(tail[2] || '');
|
|
53
|
+
const bsdDate = bsdMonthDay && (bsdTime || /^\d{4}$/.test(tail[2] || ''));
|
|
54
|
+
if (gnuDate || bsdDate) return size;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function archiveTarExpandedSize(lines, label, expectedEntries) {
|
|
60
|
+
let total = 0;
|
|
61
|
+
let matched = 0;
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const size = tarEntrySize(line);
|
|
64
|
+
if (size === null) continue;
|
|
65
|
+
total += size;
|
|
66
|
+
matched += 1;
|
|
67
|
+
if (!Number.isSafeInteger(total) || total > MAX_ARCHIVE_EXPANDED_BYTES) fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
|
|
68
|
+
}
|
|
69
|
+
if (matched < expectedEntries) fail(`Could not determine ${label} size safely`);
|
|
70
|
+
return total;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function assertArchiveExpandedSize(bytes, label) {
|
|
74
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > MAX_ARCHIVE_EXPANDED_BYTES) {
|
|
75
|
+
fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function assertExtractedTree(root, state = { entries: 0, bytes: 0 }) {
|
|
80
|
+
const info = await lstat(root);
|
|
81
|
+
if (info.isSymbolicLink()) fail('Archive extraction produced a symbolic link');
|
|
82
|
+
if (!info.isDirectory() && !info.isFile()) fail('Archive extraction produced a special file');
|
|
83
|
+
state.entries += 1;
|
|
84
|
+
state.bytes += info.isFile() ? info.size : 0;
|
|
85
|
+
if (state.entries > 100_000 || state.bytes > MAX_ARCHIVE_EXPANDED_BYTES) fail('Archive extraction exceeded the safety limit');
|
|
86
|
+
if (!info.isDirectory()) return;
|
|
87
|
+
for (const entry of await readdir(root, { withFileTypes: true })) await assertExtractedTree(path.join(root, entry.name), state);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function openArchiveSource(absolute) {
|
|
91
|
+
return openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function snapshotRegularFile(source, destination, maxBytes) {
|
|
95
|
+
const sourceInfo = await lstat(source);
|
|
96
|
+
if (sourceInfo.isSymbolicLink()) fail(`Archive source is a symbolic link: ${displayPath(source)}`);
|
|
97
|
+
if (!sourceInfo.isFile()) fail(`Archive source is not a regular file: ${displayPath(source)}`);
|
|
98
|
+
const opened = await openArchiveSource(source);
|
|
99
|
+
const { handle, info } = opened;
|
|
100
|
+
let output;
|
|
101
|
+
try {
|
|
102
|
+
if (info.size > maxBytes) fail(`Archive is ${info.size} bytes, above the ${maxBytes}-byte snapshot limit`);
|
|
103
|
+
output = await open(destination, WRITE_CREATE_NOFOLLOW, 0o400);
|
|
104
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
105
|
+
let offset = 0;
|
|
106
|
+
for (;;) {
|
|
107
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
|
108
|
+
if (!bytesRead) break;
|
|
109
|
+
if (!Number.isSafeInteger(offset + bytesRead) || offset + bytesRead > maxBytes) {
|
|
110
|
+
fail(`Archive grew beyond the ${maxBytes}-byte snapshot limit`);
|
|
111
|
+
}
|
|
112
|
+
let written = 0;
|
|
113
|
+
while (written < bytesRead) {
|
|
114
|
+
const result = await output.write(buffer, written, bytesRead - written, offset + written);
|
|
115
|
+
if (!result.bytesWritten) throw new Error('Could not finish writing the archive snapshot');
|
|
116
|
+
written += result.bytesWritten;
|
|
117
|
+
}
|
|
118
|
+
offset += bytesRead;
|
|
119
|
+
}
|
|
120
|
+
await output.truncate(offset);
|
|
121
|
+
await output.sync();
|
|
122
|
+
await output.chmod(0o400);
|
|
123
|
+
return offset;
|
|
124
|
+
} finally {
|
|
125
|
+
if (output) await output.close().catch(() => {});
|
|
126
|
+
await opened.close().catch(() => {});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, state) {
|
|
131
|
+
const mode = sourceInfo.mode & 0o777;
|
|
132
|
+
const currentBytes = state.bytes || 0;
|
|
133
|
+
if (state.maxBytes !== undefined && sourceInfo.size > state.maxBytes - currentBytes) {
|
|
134
|
+
fail(`Archive contents exceed the ${state.maxBytes}-byte extraction limit`);
|
|
135
|
+
}
|
|
136
|
+
// Never validate a pathname and then truncate it: another local process could replace that entry
|
|
137
|
+
// between the check and open. Build a brand-new inode beside the destination and commit it with
|
|
138
|
+
// rename(), which replaces the directory entry atomically without following a raced symlink or
|
|
139
|
+
// truncating a hard-linked file.
|
|
140
|
+
const temporary = path.join(
|
|
141
|
+
path.dirname(destinationPath),
|
|
142
|
+
`.${path.basename(destinationPath)}.remcp-${randomUUID()}.tmp`,
|
|
143
|
+
);
|
|
144
|
+
let output;
|
|
145
|
+
try {
|
|
146
|
+
output = await open(temporary, WRITE_CREATE_NOFOLLOW, mode);
|
|
147
|
+
const outputInfo = await output.stat();
|
|
148
|
+
if (!outputInfo.isFile()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
149
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
150
|
+
let offset = 0;
|
|
151
|
+
for (;;) {
|
|
152
|
+
const { bytesRead } = await sourceHandle.read(buffer, 0, buffer.length, offset);
|
|
153
|
+
if (!bytesRead) break;
|
|
154
|
+
if (!Number.isSafeInteger(offset + bytesRead) || (state.maxBytes !== undefined && currentBytes + offset + bytesRead > state.maxBytes)) {
|
|
155
|
+
fail(`Archive contents exceed the ${state.maxBytes}-byte extraction limit`);
|
|
156
|
+
}
|
|
157
|
+
let written = 0;
|
|
158
|
+
while (written < bytesRead) {
|
|
159
|
+
const result = await output.write(buffer, written, bytesRead - written, offset + written);
|
|
160
|
+
if (!result.bytesWritten) throw new Error('Could not finish copying archive contents');
|
|
161
|
+
written += result.bytesWritten;
|
|
162
|
+
}
|
|
163
|
+
offset += bytesRead;
|
|
164
|
+
}
|
|
165
|
+
await output.sync();
|
|
166
|
+
await output.chmod(mode);
|
|
167
|
+
await output.close();
|
|
168
|
+
output = null;
|
|
169
|
+
await rename(temporary, destinationPath);
|
|
170
|
+
state.bytes = currentBytes + offset;
|
|
171
|
+
} finally {
|
|
172
|
+
if (output) await output.close().catch(() => {});
|
|
173
|
+
await unlink(temporary).catch(() => {});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function copyTreeContents(source, destination, state = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES }) {
|
|
178
|
+
throwIfCancelled(state.signal);
|
|
179
|
+
const initial = await lstat(source);
|
|
180
|
+
if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains symbolic links or special files; archive destination is unsafe');
|
|
181
|
+
state.entries += 1;
|
|
182
|
+
if (state.entries > 100_000) fail('Archive contains too many entries');
|
|
183
|
+
if (initial.isFile()) {
|
|
184
|
+
const opened = source.startsWith('/proc/self/fd/')
|
|
185
|
+
? await openRegularFile(source, READ_NOFOLLOW_NONBLOCK)
|
|
186
|
+
: await openArchiveSource(source);
|
|
187
|
+
try { await copyOpenRegularFile(opened.handle, opened.info, destination, state); }
|
|
188
|
+
finally { if (opened.close) await opened.close(); else await opened.handle.close(); }
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let directoryHandle = null;
|
|
192
|
+
let sourcePath = source;
|
|
193
|
+
let info = initial;
|
|
194
|
+
try {
|
|
195
|
+
try {
|
|
196
|
+
directoryHandle = await openDirectoryPath(source, { allowOutside:state.allowOutsideSource === true });
|
|
197
|
+
info = await directoryHandle.handle.stat();
|
|
198
|
+
if (!info.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
199
|
+
sourcePath = directoryHandle.anchor;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (['ELOOP', 'ENOTDIR'].includes(error?.code)) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
const mode = info.mode & 0o777;
|
|
205
|
+
const existing = await lstat(destination).catch(() => null);
|
|
206
|
+
if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
207
|
+
try { await ensureDirectoryPath(destination, { allowOutside:true }); }
|
|
208
|
+
catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
209
|
+
const destinationInfo = await lstat(destination);
|
|
210
|
+
if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
211
|
+
let destinationHandle;
|
|
212
|
+
try {
|
|
213
|
+
destinationHandle = await openDirectoryPath(destination, { allowOutside:true });
|
|
214
|
+
const openedDestination = await destinationHandle.handle.stat();
|
|
215
|
+
if (!openedDestination.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
216
|
+
await destinationHandle.handle.chmod(mode | 0o700);
|
|
217
|
+
const destinationAnchor = destinationHandle.anchor;
|
|
218
|
+
for (const name of await readdir(sourcePath)) {
|
|
219
|
+
await copyTreeContents(path.join(sourcePath, name), path.join(destinationAnchor, name), state);
|
|
220
|
+
}
|
|
221
|
+
await destinationHandle.handle.chmod(mode);
|
|
222
|
+
} finally {
|
|
223
|
+
if (destinationHandle) await destinationHandle.close().catch(() => {});
|
|
224
|
+
}
|
|
225
|
+
} finally {
|
|
226
|
+
if (directoryHandle) await directoryHandle.close().catch(() => {});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function removeExtractionArtifact(target) {
|
|
231
|
+
const info = await lstat(target).catch(() => null);
|
|
232
|
+
if (!info) return;
|
|
233
|
+
if (info.isDirectory() && !info.isSymbolicLink()) await rm(target, { recursive: true, force: true });
|
|
234
|
+
else await unlink(target).catch(error => { if (error?.code !== 'ENOENT') throw error; });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function recoverExtractionArtifacts(parent, destination, prefix) {
|
|
238
|
+
const parentDirectory = await openDirectoryPath(parent, { create:true });
|
|
239
|
+
const anchor = parentDirectory.anchor;
|
|
240
|
+
const destinationPath = path.join(anchor, path.basename(destination));
|
|
241
|
+
try {
|
|
242
|
+
const entries = await readdir(anchor, { withFileTypes: true });
|
|
243
|
+
const staleBefore = Date.now() - 60 * 60 * 1000;
|
|
244
|
+
for (const entry of entries) {
|
|
245
|
+
if (!entry.name.startsWith('.remcp-extract-txn-')) continue;
|
|
246
|
+
const candidate = path.join(anchor, entry.name);
|
|
247
|
+
const info = await lstat(candidate).catch(() => null);
|
|
248
|
+
if (info && info.mtimeMs < staleBefore) await removeExtractionArtifact(candidate);
|
|
249
|
+
}
|
|
250
|
+
const backups = entries.filter(entry => entry.name.startsWith(prefix)).map(entry => path.join(anchor, entry.name));
|
|
251
|
+
for (const backup of backups) {
|
|
252
|
+
const destinationInfo = await lstat(destinationPath).catch(() => null);
|
|
253
|
+
if (destinationInfo) {
|
|
254
|
+
await removeExtractionArtifact(backup);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const backupInfo = await lstat(backup).catch(() => null);
|
|
258
|
+
if (!backupInfo) continue;
|
|
259
|
+
if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) {
|
|
260
|
+
await removeExtractionArtifact(backup);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
await rename(backup, destinationPath);
|
|
265
|
+
} catch {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
await parentDirectory.close().catch(() => {});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function restoreExtractionBackup(backup, destination) {
|
|
275
|
+
if (await lstat(destination).catch(() => null)) return false;
|
|
276
|
+
const info = await lstat(backup).catch(() => null);
|
|
277
|
+
if (!info) return false;
|
|
278
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
279
|
+
await removeExtractionArtifact(backup);
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
await rename(backup, destination);
|
|
284
|
+
return true;
|
|
285
|
+
} catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function installExtractedTree(source, destination) {
|
|
291
|
+
const parent = path.dirname(destination);
|
|
292
|
+
if (destination === path.parse(destination).root) fail('Archive destination cannot be a filesystem root');
|
|
293
|
+
const backupPrefix = `.remcp-extract-backup-${createHash('sha256').update(destination).digest('hex').slice(0, 16)}-`;
|
|
294
|
+
await recoverExtractionArtifacts(parent, destination, backupPrefix);
|
|
295
|
+
const parentDirectory = await openDirectoryPath(parent, { create:true });
|
|
296
|
+
const parentAnchor = parentDirectory.anchor;
|
|
297
|
+
const destinationPath = path.join(parentAnchor, path.basename(destination));
|
|
298
|
+
const existing = await lstat(destinationPath).catch(() => null);
|
|
299
|
+
const destinationMode = existing?.isDirectory() ? existing.mode & 0o777 : 0o700;
|
|
300
|
+
let transaction = '';
|
|
301
|
+
let backup = '';
|
|
302
|
+
let backupOwned = false;
|
|
303
|
+
let preserveBackup = false;
|
|
304
|
+
try {
|
|
305
|
+
transaction = await mkdtemp(path.join(parentAnchor, '.remcp-extract-txn-'));
|
|
306
|
+
if (existing) {
|
|
307
|
+
backup = path.join(parentAnchor, `${backupPrefix}${randomUUID()}`);
|
|
308
|
+
try {
|
|
309
|
+
await rename(destinationPath, backup);
|
|
310
|
+
backupOwned = true;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
313
|
+
backup = '';
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (backupOwned) {
|
|
317
|
+
const backupInfo = await lstat(backup);
|
|
318
|
+
if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) fail('Archive destination is not a safe directory');
|
|
319
|
+
await copyTreeContents(backup, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
|
|
320
|
+
}
|
|
321
|
+
await copyTreeContents(source, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
|
|
322
|
+
await chmod(transaction, destinationMode);
|
|
323
|
+
await rename(transaction, destinationPath);
|
|
324
|
+
if (backupOwned) {
|
|
325
|
+
await removeExtractionArtifact(backup);
|
|
326
|
+
backupOwned = false;
|
|
327
|
+
}
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (backupOwned) {
|
|
330
|
+
try {
|
|
331
|
+
await restoreExtractionBackup(backup, destinationPath);
|
|
332
|
+
if (!await lstat(backup).catch(() => null)) backupOwned = false;
|
|
333
|
+
else preserveBackup = true;
|
|
334
|
+
} catch (restoreError) {
|
|
335
|
+
preserveBackup = true;
|
|
336
|
+
throw new AggregateError([error, restoreError], 'Could not restore archive destination after extraction failure');
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
throw error;
|
|
340
|
+
} finally {
|
|
341
|
+
if (transaction) await rm(transaction, { recursive: true, force: true });
|
|
342
|
+
if (backupOwned && !preserveBackup) await removeExtractionArtifact(backup);
|
|
343
|
+
await parentDirectory.close().catch(() => {});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function moveArchiveFile(source, destination) {
|
|
348
|
+
const parent = path.dirname(destination);
|
|
349
|
+
const parentDirectory = await openDirectoryPath(parent, { create:true });
|
|
350
|
+
const destinationPath = path.join(parentDirectory.anchor, path.basename(destination));
|
|
351
|
+
try {
|
|
352
|
+
try {
|
|
353
|
+
await rename(source, destinationPath);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (!['EXDEV', 'EPERM'].includes(error?.code)) throw error;
|
|
356
|
+
const temporary = path.join(parentDirectory.anchor, `.${path.basename(destination)}.remcp-${randomUUID()}.tmp`);
|
|
357
|
+
try {
|
|
358
|
+
await copyFile(source, temporary, constants.COPYFILE_EXCL);
|
|
359
|
+
await rename(temporary, destinationPath);
|
|
360
|
+
} finally {
|
|
361
|
+
await unlink(temporary).catch(() => {});
|
|
362
|
+
}
|
|
363
|
+
await unlink(source);
|
|
364
|
+
}
|
|
365
|
+
} finally {
|
|
366
|
+
await parentDirectory.close().catch(() => {});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function archiveCommandProbe(name, versionArgs = ['--version']) {
|
|
371
|
+
const result = spawnSync(name, versionArgs, { encoding: 'utf8' });
|
|
372
|
+
return !result.error && result.status === 0 ? name : null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function archiveTool({ platform = process.platform, probe = archiveCommandProbe } = {}) {
|
|
376
|
+
const tarName = platform === 'win32' ? 'tar.exe' : 'tar';
|
|
377
|
+
// Windows ships libarchive/bsdtar as tar.exe. It can create/read ZIP as well as tar-family
|
|
378
|
+
// archives, so keeping one backend avoids introducing a second extraction/safety path.
|
|
379
|
+
if (platform === 'win32') return { tar: probe(tarName), zip:null, unzip:null };
|
|
380
|
+
// Info-ZIP unzip (the default on Debian/Ubuntu) treats --version as an invalid combination and
|
|
381
|
+
// exits 10 even though the binary is healthy. Its portable version probe is -v.
|
|
382
|
+
return { tar: probe(tarName), zip: probe('zip'), unzip: probe('unzip', ['-v']) };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function archiveBackendAvailable({ platform = process.platform, probe = archiveCommandProbe } = {}) {
|
|
386
|
+
if (platform === 'win32') return Boolean(archiveTool({ platform, probe }).tar);
|
|
387
|
+
return platform === 'linux' || platform === 'darwin';
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function zipArchiveBackendAvailable({ platform = process.platform, probe = archiveCommandProbe } = {}) {
|
|
391
|
+
const tools = archiveTool({ platform, probe });
|
|
392
|
+
if (platform === 'win32') return Boolean(tools.tar);
|
|
393
|
+
if (platform === 'linux' || platform === 'darwin') return Boolean(tools.zip && tools.unzip);
|
|
394
|
+
return false;
|
|
395
|
+
}
|