mcp-fs-shell-windows 0.2.7 → 0.2.19
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 +41 -12
- package/dist/append_files/handler.js +2 -2
- package/dist/checksum_files/handler.js +2 -2
- package/dist/checksum_files_verif/handler.js +2 -2
- package/dist/convert_encoding/handler.js +40 -0
- package/dist/convert_encoding/schema.js +19 -0
- package/dist/copy_files/handler.js +2 -2
- package/dist/count_lines/handler.js +3 -3
- package/dist/create_directories/handler.js +2 -2
- package/dist/create_link/handler.js +90 -0
- package/dist/create_link/schema.js +25 -0
- package/dist/delete_files/handler.js +205 -14
- package/dist/delete_files/schema.js +4 -0
- package/dist/delete_files_by_pattern/handler.js +7 -7
- package/dist/dir_diff/handler.js +155 -0
- package/dist/dir_diff/schema.js +7 -0
- package/dist/directory_tree/helpers.js +5 -5
- package/dist/edit_files/handler.js +2 -2
- package/dist/file_diff/handler.js +2 -2
- package/dist/file_info/handler.js +39 -3
- package/dist/fuzzy_find_files/handler.js +2 -2
- package/dist/helpers/encoding.js +154 -0
- package/dist/helpers/path.js +133 -6
- package/dist/launch_file/handler.js +6 -4
- package/dist/list_directory/handler.js +16 -9
- package/dist/move_files/handler.js +2 -2
- package/dist/patch_files/handler.js +2 -2
- package/dist/patch_files/helpers.js +24 -4
- package/dist/read_bytes/handler.js +43 -0
- package/dist/read_bytes/schema.js +6 -0
- package/dist/read_files/handler.js +18 -9
- package/dist/read_files/schema.js +37 -3
- package/dist/replace_regex/handler.js +105 -0
- package/dist/replace_regex/schema.js +23 -0
- package/dist/search_files/handler.js +2 -2
- package/dist/search_glob/handler.js +2 -2
- package/dist/search_regex/handler.js +63 -40
- package/dist/search_regex/schema.js +1 -0
- package/dist/server.js +183 -9
- package/dist/wait_for_file/handler.js +91 -0
- package/dist/wait_for_file/schema.js +7 -0
- package/dist/write_bytes/handler.js +49 -0
- package/dist/write_bytes/schema.js +7 -0
- package/dist/write_new_files/handler.js +6 -3
- package/dist/write_new_files/schema.js +1 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { validatePath } from "../helpers/path.js";
|
|
3
|
+
import { validatePath, stripLongPath, stripLongPathAll } from "../helpers/path.js";
|
|
4
4
|
export async function handleDeleteFilesByPattern(directory, pattern, includeDirectories, allowedDirectories) {
|
|
5
5
|
const validPath = await validatePath(directory, allowedDirectories);
|
|
6
6
|
let regex;
|
|
@@ -21,23 +21,23 @@ export async function handleDeleteFilesByPattern(directory, pattern, includeDire
|
|
|
21
21
|
if (entry.isDirectory()) {
|
|
22
22
|
if (includeDirectories) {
|
|
23
23
|
await fs.rm(fullPath, { recursive: true, force: true });
|
|
24
|
-
deleted.push(`${fullPath} (directory)`);
|
|
24
|
+
deleted.push(`${stripLongPath(fullPath)} (directory)`);
|
|
25
25
|
}
|
|
26
26
|
else {
|
|
27
|
-
skipped.push(`${fullPath} (directory; set includeDirectories: true to delete)`);
|
|
27
|
+
skipped.push(`${stripLongPath(fullPath)} (directory; set includeDirectories: true to delete)`);
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
else {
|
|
31
31
|
await fs.unlink(fullPath);
|
|
32
|
-
deleted.push(fullPath);
|
|
32
|
+
deleted.push(stripLongPath(fullPath));
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
catch (error) {
|
|
36
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
37
|
-
skipped.push(`${fullPath} (${errorMessage})`);
|
|
36
|
+
const errorMessage = stripLongPathAll(error instanceof Error ? error.message : String(error));
|
|
37
|
+
skipped.push(`${stripLongPath(fullPath)} (${errorMessage})`);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
let output = `Pattern "${pattern}" in ${validPath}:\n`;
|
|
40
|
+
let output = `Pattern "${pattern}" in ${stripLongPath(validPath)}:\n`;
|
|
41
41
|
output += `- ${deleted.length} deleted\n`;
|
|
42
42
|
if (skipped.length > 0) {
|
|
43
43
|
output += `- ${skipped.length} skipped:\n` + skipped.map((s) => ` ${s}`).join("\n");
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { createReadStream } from "fs";
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { validatePath } from "../helpers/path.js";
|
|
6
|
+
import { minimatch } from "minimatch";
|
|
7
|
+
import { createUnifiedDiff } from "../helpers/diff.js";
|
|
8
|
+
const TEXT_SIZE_LIMIT = 1024 * 1024; // 1 MiB
|
|
9
|
+
const NUL_SCAN_LIMIT = 8192; // 8 KiB
|
|
10
|
+
// Same minimatch machinery/options as list_directory's ignore param, but
|
|
11
|
+
// matched against the file's relative path (forward slashes).
|
|
12
|
+
function isIgnored(relPath, ignore) {
|
|
13
|
+
if (ignore.length === 0)
|
|
14
|
+
return false;
|
|
15
|
+
return ignore.some((pattern) => minimatch(relPath, pattern, { dot: true }));
|
|
16
|
+
}
|
|
17
|
+
async function walkTree(root, ignore) {
|
|
18
|
+
const files = new Map();
|
|
19
|
+
async function visit(abs, rel) {
|
|
20
|
+
let entries;
|
|
21
|
+
try {
|
|
22
|
+
entries = await fs.readdir(abs, { withFileTypes: true });
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const code = error.code;
|
|
26
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
27
|
+
return;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
// Deterministic traversal order
|
|
31
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
const relPath = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
34
|
+
if (isIgnored(relPath, ignore))
|
|
35
|
+
continue;
|
|
36
|
+
const absPath = path.join(abs, entry.name);
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
await visit(absPath, relPath);
|
|
39
|
+
}
|
|
40
|
+
else if (entry.isFile()) {
|
|
41
|
+
const stats = await fs.stat(absPath);
|
|
42
|
+
files.set(relPath, { abs: absPath, size: stats.size });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
await visit(root, "");
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
49
|
+
function sha256File(filePath) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const hash = createHash("sha256");
|
|
52
|
+
const stream = createReadStream(filePath);
|
|
53
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
54
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
55
|
+
stream.on("error", reject);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async function looksLikeText(filePath, size) {
|
|
59
|
+
if (size > TEXT_SIZE_LIMIT)
|
|
60
|
+
return false;
|
|
61
|
+
if (size === 0)
|
|
62
|
+
return true;
|
|
63
|
+
const scanLength = Math.min(size, NUL_SCAN_LIMIT);
|
|
64
|
+
const buffer = Buffer.alloc(scanLength);
|
|
65
|
+
const handle = await fs.open(filePath, "r");
|
|
66
|
+
try {
|
|
67
|
+
await handle.read(buffer, 0, scanLength, 0);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
await handle.close();
|
|
71
|
+
}
|
|
72
|
+
return buffer.indexOf(0) === -1;
|
|
73
|
+
}
|
|
74
|
+
export async function handleDirDiff(dirA, dirB, ignore, maxFiles, allowedDirectories) {
|
|
75
|
+
const validA = await validatePath(dirA, allowedDirectories);
|
|
76
|
+
const validB = await validatePath(dirB, allowedDirectories);
|
|
77
|
+
for (const [original, valid] of [
|
|
78
|
+
[dirA, validA],
|
|
79
|
+
[dirB, validB],
|
|
80
|
+
]) {
|
|
81
|
+
let stats;
|
|
82
|
+
try {
|
|
83
|
+
stats = await fs.stat(valid);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const code = error.code;
|
|
87
|
+
if (code === "ENOENT") {
|
|
88
|
+
throw new Error(`Directory does not exist: ${original}`);
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
if (!stats.isDirectory()) {
|
|
93
|
+
throw new Error(`Not a directory: ${original}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const filesA = await walkTree(validA, ignore);
|
|
97
|
+
const filesB = await walkTree(validB, ignore);
|
|
98
|
+
const union = Array.from(new Set([...filesA.keys(), ...filesB.keys()])).sort();
|
|
99
|
+
let truncated = false;
|
|
100
|
+
let comparePaths = union;
|
|
101
|
+
if (maxFiles !== undefined && union.length > maxFiles) {
|
|
102
|
+
comparePaths = union.slice(0, maxFiles);
|
|
103
|
+
truncated = true;
|
|
104
|
+
}
|
|
105
|
+
const added = [];
|
|
106
|
+
const removed = [];
|
|
107
|
+
const changed = [];
|
|
108
|
+
let identical = 0;
|
|
109
|
+
for (const relPath of comparePaths) {
|
|
110
|
+
const a = filesA.get(relPath);
|
|
111
|
+
const b = filesB.get(relPath);
|
|
112
|
+
if (!a || !b) {
|
|
113
|
+
if (a && !b) {
|
|
114
|
+
removed.push({ path: relPath, size: a.size });
|
|
115
|
+
}
|
|
116
|
+
else if (b && !a) {
|
|
117
|
+
added.push({ path: relPath, size: b.size });
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Present in both: decide by size first, then sha256.
|
|
122
|
+
const sizeA = a.size;
|
|
123
|
+
const sizeB = b.size;
|
|
124
|
+
const shaA = await sha256File(a.abs);
|
|
125
|
+
const shaB = await sha256File(b.abs);
|
|
126
|
+
if (sizeA === sizeB && shaA === shaB) {
|
|
127
|
+
identical++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const entry = { path: relPath, sizeA, sizeB, shaA, shaB };
|
|
131
|
+
// Attach a unified diff when BOTH sides look like text.
|
|
132
|
+
if (sizeA <= TEXT_SIZE_LIMIT &&
|
|
133
|
+
sizeB <= TEXT_SIZE_LIMIT &&
|
|
134
|
+
(await looksLikeText(a.abs, sizeA)) &&
|
|
135
|
+
(await looksLikeText(b.abs, sizeB))) {
|
|
136
|
+
const textA = await fs.readFile(a.abs, "utf8");
|
|
137
|
+
const textB = await fs.readFile(b.abs, "utf8");
|
|
138
|
+
entry.diff = createUnifiedDiff(textA, textB, relPath, relPath);
|
|
139
|
+
}
|
|
140
|
+
changed.push(entry);
|
|
141
|
+
}
|
|
142
|
+
return JSON.stringify({
|
|
143
|
+
added,
|
|
144
|
+
removed,
|
|
145
|
+
changed,
|
|
146
|
+
counts: {
|
|
147
|
+
added: added.length,
|
|
148
|
+
removed: removed.length,
|
|
149
|
+
changed: changed.length,
|
|
150
|
+
identical,
|
|
151
|
+
compared: comparePaths.length,
|
|
152
|
+
},
|
|
153
|
+
truncated,
|
|
154
|
+
}, null, 2);
|
|
155
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const DirDiffArgsSchema = z.object({
|
|
3
|
+
dirA: z.string().describe("First directory (baseline). Files present only here are reported as removed."),
|
|
4
|
+
dirB: z.string().describe("Second directory (comparison target). Files present only here are reported as added."),
|
|
5
|
+
ignore: z.array(z.string()).optional().default([]).describe("Glob patterns (minimatch, same semantics as list_directory's ignore param) matched against each file's relative path; ignored paths are excluded from all lists and counts."),
|
|
6
|
+
maxFiles: z.number().int().min(1).optional().describe("Cap on total files compared (union of both trees, after ignore). If the union exceeds it, the comparison stops and truncated=true; counts reflect only what was compared."),
|
|
7
|
+
});
|
|
@@ -30,16 +30,16 @@ export async function buildDirectoryTree(currentPath, rootPath, excludePatterns
|
|
|
30
30
|
if (shouldExclude) {
|
|
31
31
|
continue;
|
|
32
32
|
}
|
|
33
|
+
// Links (junctions/symlinks) are reported as type 'link' and never
|
|
34
|
+
// recursed into, so their targets are never traversed.
|
|
33
35
|
const entryData = {
|
|
34
36
|
name: entry.name,
|
|
35
|
-
type: entry.isDirectory() ? 'directory' : 'file'
|
|
37
|
+
type: entry.isSymbolicLink() ? 'link' : entry.isDirectory() ? 'directory' : 'file'
|
|
36
38
|
};
|
|
37
39
|
if (entry.isDirectory()) {
|
|
40
|
+
// Empty directories stay in the tree (children: []) - only EXCLUDED
|
|
41
|
+
// entries are skipped.
|
|
38
42
|
entryData.children = await buildDirectoryTree(entryPath, rootPath, excludePatterns, allowedDirectories);
|
|
39
|
-
// Skip empty directories after exclusion
|
|
40
|
-
if (entryData.children.length === 0) {
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
43
|
}
|
|
44
44
|
result.push(entryData);
|
|
45
45
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
|
-
import { validatePath } from "../helpers/path.js";
|
|
2
|
+
import { validatePath, stripLongPathAll } from "../helpers/path.js";
|
|
3
3
|
// Detect the dominant line ending of a file so it can be preserved on write.
|
|
4
4
|
function detectEol(text) {
|
|
5
5
|
if (text.includes("\r\n"))
|
|
@@ -42,7 +42,7 @@ export async function handleEditFiles(files, allowedDirectories) {
|
|
|
42
42
|
results.push(`File: ${file.path}\n${editLog.join("\n")}`);
|
|
43
43
|
}
|
|
44
44
|
catch (error) {
|
|
45
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
45
|
+
const errorMessage = stripLongPathAll(error instanceof Error ? error.message : String(error));
|
|
46
46
|
errors.push(`Failed to edit ${file.path}: ${errorMessage}`);
|
|
47
47
|
}
|
|
48
48
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
|
-
import { validatePath } from "../helpers/path.js";
|
|
2
|
+
import { validatePath, stripLongPathAll } from "../helpers/path.js";
|
|
3
3
|
import { createUnifiedDiff } from "../helpers/diff.js";
|
|
4
4
|
export async function handleFileDiff(file1Path, file2Path, allowedDirectories) {
|
|
5
5
|
const validFile1Path = await validatePath(file1Path, allowedDirectories);
|
|
@@ -22,7 +22,7 @@ export async function handleFileDiff(file1Path, file2Path, allowedDirectories) {
|
|
|
22
22
|
return formattedDiff;
|
|
23
23
|
}
|
|
24
24
|
catch (error) {
|
|
25
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
25
|
+
const errorMessage = stripLongPathAll(error instanceof Error ? error.message : String(error));
|
|
26
26
|
throw new Error(`Error comparing files: ${errorMessage}`);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
|
-
import {
|
|
2
|
+
import { execFile } from "child_process";
|
|
3
|
+
import { validatePathPreservingLinks } from "../helpers/path.js";
|
|
4
|
+
// Best-effort NTFS attribute list (e.g. "Archive", "ReadOnly, Hidden") via a
|
|
5
|
+
// single encoded PowerShell call. The command string is built in JS with the
|
|
6
|
+
// path single-quote-escaped and then encoded as UTF-16LE Base64 for
|
|
7
|
+
// -EncodedCommand, which avoids all argv quoting/Unicode issues. Resolves
|
|
8
|
+
// null on any failure (missing powershell, timeout, bad path, ...).
|
|
9
|
+
function getWindowsAttributes(filePath) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
let b64;
|
|
12
|
+
try {
|
|
13
|
+
const escaped = filePath.replace(/'/g, "''");
|
|
14
|
+
const command = `(Get-Item -LiteralPath '${escaped}' -Force).Attributes.ToString()`;
|
|
15
|
+
b64 = Buffer.from(command, "utf16le").toString("base64");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
resolve(null);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
execFile("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", b64], { timeout: 10000, windowsHide: true }, (error, stdout) => {
|
|
22
|
+
if (error) {
|
|
23
|
+
resolve(null);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const text = stdout.trim();
|
|
27
|
+
resolve(text.length > 0 ? text : null);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
3
31
|
export async function getFileStats(filePath) {
|
|
4
32
|
const stats = await fs.stat(filePath);
|
|
5
33
|
return {
|
|
@@ -13,8 +41,9 @@ export async function getFileStats(filePath) {
|
|
|
13
41
|
};
|
|
14
42
|
}
|
|
15
43
|
export async function handleGetFileInfo(filePath, allowedDirectories) {
|
|
16
|
-
const validPath = await
|
|
44
|
+
const validPath = await validatePathPreservingLinks(filePath, allowedDirectories);
|
|
17
45
|
const stats = await fs.stat(validPath);
|
|
46
|
+
const linkStats = await fs.lstat(validPath);
|
|
18
47
|
const formattedInfo = {
|
|
19
48
|
path: filePath,
|
|
20
49
|
size: `${stats.size} bytes`,
|
|
@@ -22,8 +51,15 @@ export async function handleGetFileInfo(filePath, allowedDirectories) {
|
|
|
22
51
|
created: stats.birthtime.toISOString(),
|
|
23
52
|
modified: stats.mtime.toISOString(),
|
|
24
53
|
accessed: stats.atime.toISOString(),
|
|
25
|
-
permissions: stats.mode.toString(8).slice(-3)
|
|
54
|
+
permissions: stats.mode.toString(8).slice(-3),
|
|
55
|
+
isReparsePoint: String(linkStats.isSymbolicLink())
|
|
26
56
|
};
|
|
57
|
+
if (process.platform === "win32") {
|
|
58
|
+
const attributes = await getWindowsAttributes(validPath);
|
|
59
|
+
if (attributes !== null) {
|
|
60
|
+
formattedInfo.attributes = attributes;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
27
63
|
return Object.entries(formattedInfo)
|
|
28
64
|
.map(([key, value]) => `${key}: ${value}`)
|
|
29
65
|
.join("\n");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { validatePath } from "../helpers/path.js";
|
|
3
|
+
import { validatePath, stripLongPath } from "../helpers/path.js";
|
|
4
4
|
function levenshtein(a, b) {
|
|
5
5
|
if (a === b)
|
|
6
6
|
return 0;
|
|
@@ -68,7 +68,7 @@ export async function handleFuzzyFindFiles(query, searchPath, recursive, maxResu
|
|
|
68
68
|
}
|
|
69
69
|
let output = `Top ${top.length} fuzzy matches for "${query}" (scanned ${scanned} entries${truncated ? ", stopped at scan limit" : ""}):\n`;
|
|
70
70
|
top.forEach((c, i) => {
|
|
71
|
-
output += `${i + 1}. (distance ${c.distance}) ${c.path}\n`;
|
|
71
|
+
output += `${i + 1}. (distance ${c.distance}) ${stripLongPath(c.path)}\n`;
|
|
72
72
|
});
|
|
73
73
|
return output;
|
|
74
74
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Text-encoding support for the fork's text tools: detection, decode, and
|
|
2
|
+
// encode for utf8, utf16le, utf16be, and cp1252.
|
|
3
|
+
export const TEXT_ENCODINGS = [
|
|
4
|
+
"utf8",
|
|
5
|
+
"utf16le",
|
|
6
|
+
"utf16be",
|
|
7
|
+
"cp1252",
|
|
8
|
+
];
|
|
9
|
+
const BOM_UTF8 = [0xef, 0xbb, 0xbf];
|
|
10
|
+
const BOM_UTF16LE = [0xff, 0xfe];
|
|
11
|
+
const BOM_UTF16BE = [0xfe, 0xff];
|
|
12
|
+
function startsWithBytes(buf, sig) {
|
|
13
|
+
if (buf.length < sig.length)
|
|
14
|
+
return false;
|
|
15
|
+
for (let i = 0; i < sig.length; i++) {
|
|
16
|
+
if (buf[i] !== sig[i])
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Detect the text encoding of a raw buffer:
|
|
23
|
+
* BOM FF FE -> utf16le
|
|
24
|
+
* BOM FE FF -> utf16be
|
|
25
|
+
* BOM EF BB BF -> utf8 (the BOM is stripped at decode time)
|
|
26
|
+
* otherwise, if the buffer decodes as STRICT UTF-8 (no invalid sequences
|
|
27
|
+
* and no U+FFFD replacement characters) -> utf8
|
|
28
|
+
* otherwise -> cp1252 (cp1252 accepts any byte value, so it is the fallback)
|
|
29
|
+
*/
|
|
30
|
+
export function detectEncoding(buf) {
|
|
31
|
+
if (startsWithBytes(buf, BOM_UTF16LE))
|
|
32
|
+
return "utf16le";
|
|
33
|
+
if (startsWithBytes(buf, BOM_UTF16BE))
|
|
34
|
+
return "utf16be";
|
|
35
|
+
if (startsWithBytes(buf, BOM_UTF8))
|
|
36
|
+
return "utf8";
|
|
37
|
+
try {
|
|
38
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
|
39
|
+
if (text.includes("\uFFFD"))
|
|
40
|
+
return "cp1252";
|
|
41
|
+
return "utf8";
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return "cp1252";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Decode a raw buffer with the given encoding, stripping a BOM matching that
|
|
49
|
+
* encoding when present (EF BB BF for utf8, FF FE for utf16le, FE FF for utf16be).
|
|
50
|
+
*/
|
|
51
|
+
export function decodeText(buf, encoding) {
|
|
52
|
+
let b = buf;
|
|
53
|
+
if (encoding === "utf8") {
|
|
54
|
+
if (startsWithBytes(b, BOM_UTF8))
|
|
55
|
+
b = b.subarray(3);
|
|
56
|
+
return new TextDecoder("utf-8").decode(b);
|
|
57
|
+
}
|
|
58
|
+
if (encoding === "utf16le") {
|
|
59
|
+
if (startsWithBytes(b, BOM_UTF16LE))
|
|
60
|
+
b = b.subarray(2);
|
|
61
|
+
return new TextDecoder("utf-16le").decode(b);
|
|
62
|
+
}
|
|
63
|
+
if (encoding === "utf16be") {
|
|
64
|
+
if (startsWithBytes(b, BOM_UTF16BE))
|
|
65
|
+
b = b.subarray(2);
|
|
66
|
+
// Decode by swapping byte order to little-endian (drop a trailing odd byte).
|
|
67
|
+
const usable = b.length - (b.length % 2);
|
|
68
|
+
const swapped = Buffer.alloc(usable);
|
|
69
|
+
for (let i = 0; i + 1 < usable; i += 2) {
|
|
70
|
+
swapped[i] = b[i + 1];
|
|
71
|
+
swapped[i + 1] = b[i];
|
|
72
|
+
}
|
|
73
|
+
return new TextDecoder("utf-16le").decode(swapped);
|
|
74
|
+
}
|
|
75
|
+
// cp1252: accepts any byte value (WHATWG maps the 5 undefined bytes to U+FFFD).
|
|
76
|
+
return new TextDecoder("cp1252").decode(b);
|
|
77
|
+
}
|
|
78
|
+
// Unicode code points that map into cp1252's 0x80-0x9F range. Everything else
|
|
79
|
+
// encodable is an identity mapping: U+0000-U+007F -> 0x00-0x7F and
|
|
80
|
+
// U+00A0-U+00FF -> 0xA0-0xFF.
|
|
81
|
+
const CP1252_SPECIAL = new Map([
|
|
82
|
+
[0x20ac, 0x80], // EURO SIGN
|
|
83
|
+
[0x201a, 0x82], // SINGLE LOW-9 QUOTATION MARK
|
|
84
|
+
[0x0192, 0x83], // LATIN SMALL LETTER F WITH HOOK
|
|
85
|
+
[0x201e, 0x84], // DOUBLE LOW-9 QUOTATION MARK
|
|
86
|
+
[0x2026, 0x85], // HORIZONTAL ELLIPSIS
|
|
87
|
+
[0x2020, 0x86], // DAGGER
|
|
88
|
+
[0x2021, 0x87], // DOUBLE DAGGER
|
|
89
|
+
[0x02c6, 0x88], // MODIFIER LETTER CIRCUMFLEX ACCENT
|
|
90
|
+
[0x2030, 0x89], // PER MILLE SIGN
|
|
91
|
+
[0x0160, 0x8a], // LATIN CAPITAL LETTER S WITH CARON
|
|
92
|
+
[0x2039, 0x8b], // SINGLE LEFT-POINTING ANGLE QUOTATION MARK
|
|
93
|
+
[0x0152, 0x8c], // LATIN CAPITAL LIGATURE OE
|
|
94
|
+
[0x017d, 0x8e], // LATIN CAPITAL LETTER Z WITH CARON
|
|
95
|
+
[0x2018, 0x91], // LEFT SINGLE QUOTATION MARK
|
|
96
|
+
[0x2019, 0x92], // RIGHT SINGLE QUOTATION MARK
|
|
97
|
+
[0x201c, 0x93], // LEFT DOUBLE QUOTATION MARK
|
|
98
|
+
[0x201d, 0x94], // RIGHT DOUBLE QUOTATION MARK
|
|
99
|
+
[0x2022, 0x95], // BULLET
|
|
100
|
+
[0x2013, 0x96], // EN DASH
|
|
101
|
+
[0x2014, 0x97], // EM DASH
|
|
102
|
+
[0x02dc, 0x98], // SMALL TILDE
|
|
103
|
+
[0x2122, 0x99], // TRADE MARK SIGN
|
|
104
|
+
[0x0161, 0x9a], // LATIN SMALL LETTER S WITH CARON
|
|
105
|
+
[0x203a, 0x9b], // SINGLE RIGHT-POINTING ANGLE QUOTATION MARK
|
|
106
|
+
[0x0153, 0x9c], // LATIN SMALL LIGATURE OE
|
|
107
|
+
[0x017e, 0x9e], // LATIN SMALL LETTER Z WITH CARON
|
|
108
|
+
[0x0178, 0x9f], // LATIN CAPITAL LETTER Y WITH DIAERESIS
|
|
109
|
+
]);
|
|
110
|
+
function cp1252CodePointToByte(cp) {
|
|
111
|
+
if (cp <= 0x7f)
|
|
112
|
+
return cp;
|
|
113
|
+
if (cp >= 0xa0 && cp <= 0xff)
|
|
114
|
+
return cp;
|
|
115
|
+
return CP1252_SPECIAL.get(cp) ?? null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Encode a string to the given encoding. utf16le/utf16be output is prefixed
|
|
119
|
+
* with its BOM (FF FE / FE FF); utf8/cp1252 output is not. Throws for code
|
|
120
|
+
* points with no cp1252 mapping (no silent replacement).
|
|
121
|
+
*/
|
|
122
|
+
export function encodeText(text, encoding) {
|
|
123
|
+
if (encoding === "utf8") {
|
|
124
|
+
return Buffer.from(text, "utf-8");
|
|
125
|
+
}
|
|
126
|
+
if (encoding === "utf16le") {
|
|
127
|
+
return Buffer.concat([
|
|
128
|
+
Buffer.from([0xff, 0xfe]),
|
|
129
|
+
Buffer.from(text, "utf-16le"),
|
|
130
|
+
]);
|
|
131
|
+
}
|
|
132
|
+
if (encoding === "utf16be") {
|
|
133
|
+
const le = Buffer.from(text, "utf-16le");
|
|
134
|
+
const be = Buffer.alloc(le.length);
|
|
135
|
+
for (let i = 0; i + 1 < le.length; i += 2) {
|
|
136
|
+
be[i] = le[i + 1];
|
|
137
|
+
be[i + 1] = le[i];
|
|
138
|
+
}
|
|
139
|
+
return Buffer.concat([Buffer.from([0xfe, 0xff]), be]);
|
|
140
|
+
}
|
|
141
|
+
// cp1252: one byte per code point. text.length (UTF-16 code units) is a safe
|
|
142
|
+
// upper bound on the byte count (surrogate pairs shrink, never grow).
|
|
143
|
+
const out = Buffer.alloc(text.length);
|
|
144
|
+
let n = 0;
|
|
145
|
+
for (const ch of text) {
|
|
146
|
+
const cp = ch.codePointAt(0);
|
|
147
|
+
const byte = cp1252CodePointToByte(cp);
|
|
148
|
+
if (byte === null) {
|
|
149
|
+
throw new Error(`Cannot encode character U+${cp.toString(16).toUpperCase()} to cp1252: no mapping`);
|
|
150
|
+
}
|
|
151
|
+
out[n++] = byte;
|
|
152
|
+
}
|
|
153
|
+
return out.subarray(0, n);
|
|
154
|
+
}
|
package/dist/helpers/path.js
CHANGED
|
@@ -11,12 +11,75 @@ export function expandHome(filepath) {
|
|
|
11
11
|
}
|
|
12
12
|
return filepath;
|
|
13
13
|
}
|
|
14
|
+
// --- Windows long-path (\\?\) support -------------------------------------
|
|
15
|
+
//
|
|
16
|
+
// Windows limits plain API paths to 260 characters (MAX_PATH) unless the
|
|
17
|
+
// path is given in extended-length form: \\?\C:\... for drive paths and
|
|
18
|
+
// \\?\UNC\server\share\... for UNC paths. When the machine-level long-path
|
|
19
|
+
// policy (LongPathsEnabled) is off - or for maximum portability - file
|
|
20
|
+
// operations on longer plain paths fail with ENOENT. Converting validated
|
|
21
|
+
// paths to extended-length form removes the 260-char ceiling.
|
|
22
|
+
//
|
|
23
|
+
// toLongPath() is applied at the END of path validation (validatePath /
|
|
24
|
+
// validatePathPreservingLinks) so every handler inherits it through that
|
|
25
|
+
// single choke point. stripLongPath() is the display inverse: user-facing
|
|
26
|
+
// output must never contain the \\?\ prefix.
|
|
27
|
+
const LONG_PREFIX = "\\\\?\\"; // \\?\
|
|
28
|
+
const LONG_UNC_PREFIX = "\\\\?\\UNC\\"; // \\?\UNC\
|
|
29
|
+
// Convert an absolute path to Windows extended-length form. No-op guard:
|
|
30
|
+
// win32 only. Returns unchanged when already extended-length. Drive paths
|
|
31
|
+
// are prefixed only when longer than 255 chars; UNC paths are always
|
|
32
|
+
// prefixed (the \\?\UNC\ form is equivalent at any length).
|
|
33
|
+
export function toLongPath(absPath) {
|
|
34
|
+
if (process.platform !== "win32")
|
|
35
|
+
return absPath;
|
|
36
|
+
if (absPath.startsWith(LONG_PREFIX) || absPath.startsWith("//?/"))
|
|
37
|
+
return absPath;
|
|
38
|
+
if (/^[A-Za-z]:\\/.test(absPath) && absPath.length > 255) {
|
|
39
|
+
return LONG_PREFIX + absPath;
|
|
40
|
+
}
|
|
41
|
+
if (absPath.startsWith("\\\\")) {
|
|
42
|
+
return LONG_UNC_PREFIX + absPath.slice(2);
|
|
43
|
+
}
|
|
44
|
+
return absPath;
|
|
45
|
+
}
|
|
46
|
+
// Display inverse of toLongPath: strip a leading extended-length prefix
|
|
47
|
+
// (the UNC form comes back with a normal double leading backslash).
|
|
48
|
+
export function stripLongPath(p) {
|
|
49
|
+
if (p.startsWith(LONG_UNC_PREFIX))
|
|
50
|
+
return "\\\\" + p.slice(LONG_UNC_PREFIX.length);
|
|
51
|
+
if (p.startsWith(LONG_PREFIX))
|
|
52
|
+
return p.slice(LONG_PREFIX.length);
|
|
53
|
+
if (p.startsWith("//?/UNC/"))
|
|
54
|
+
return "//" + p.slice(7);
|
|
55
|
+
if (p.startsWith("//?/"))
|
|
56
|
+
return p.slice(4);
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
// Sanitize a string (e.g. an error message) that may embed extended-length
|
|
60
|
+
// paths: replace every occurrence of a prefix with its plain equivalent.
|
|
61
|
+
export function stripLongPathAll(s) {
|
|
62
|
+
return s
|
|
63
|
+
.split(LONG_UNC_PREFIX)
|
|
64
|
+
.join("\\\\")
|
|
65
|
+
.split(LONG_PREFIX)
|
|
66
|
+
.join("")
|
|
67
|
+
.split("//?/UNC/")
|
|
68
|
+
.join("//")
|
|
69
|
+
.split("//?/")
|
|
70
|
+
.join("");
|
|
71
|
+
}
|
|
14
72
|
// Security utilities
|
|
15
73
|
export async function validatePath(requestedPath, allowedDirectories) {
|
|
16
74
|
const expandedPath = expandHome(requestedPath);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
75
|
+
// Extended-length (\\?\) inputs are normalized to their plain form first,
|
|
76
|
+
// so resolution and allow-root checks run against the original shape;
|
|
77
|
+
// the prefix is (re-)applied at the end of validation. The allow-roots
|
|
78
|
+
// check below therefore still runs on the ORIGINAL (unprefixed) path.
|
|
79
|
+
const plain = stripLongPath(expandedPath);
|
|
80
|
+
const absolute = path.isAbsolute(plain)
|
|
81
|
+
? path.resolve(plain)
|
|
82
|
+
: path.resolve(process.cwd(), plain);
|
|
20
83
|
const normalizedRequested = normalizePath(absolute);
|
|
21
84
|
// Check if path is within allowed directories
|
|
22
85
|
const isAllowed = allowedDirectories.some(dir => normalizedRequested.startsWith(dir));
|
|
@@ -25,13 +88,13 @@ export async function validatePath(requestedPath, allowedDirectories) {
|
|
|
25
88
|
}
|
|
26
89
|
// Handle symlinks by checking their real path
|
|
27
90
|
try {
|
|
28
|
-
const realPath = await fs.realpath(absolute);
|
|
91
|
+
const realPath = stripLongPath(await fs.realpath(absolute));
|
|
29
92
|
const normalizedReal = normalizePath(realPath);
|
|
30
93
|
const isRealPathAllowed = allowedDirectories.some(dir => normalizedReal.startsWith(dir));
|
|
31
94
|
if (!isRealPathAllowed) {
|
|
32
95
|
throw new Error("Access denied - symlink target outside allowed directories");
|
|
33
96
|
}
|
|
34
|
-
return realPath;
|
|
97
|
+
return toLongPath(realPath);
|
|
35
98
|
}
|
|
36
99
|
catch (error) {
|
|
37
100
|
// Target doesn't exist yet: walk up to the nearest existing ancestor
|
|
@@ -59,8 +122,72 @@ export async function validatePath(requestedPath, allowedDirectories) {
|
|
|
59
122
|
if (!isAncestorAllowed) {
|
|
60
123
|
throw new Error("Access denied - parent directory outside allowed directories");
|
|
61
124
|
}
|
|
62
|
-
return absolute;
|
|
125
|
+
return toLongPath(absolute);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Like validatePath, but for tools that must classify/operate on the path
|
|
129
|
+
// ITSELF without following reparse points (junctions/symlinks): verifies the
|
|
130
|
+
// literal path is within an allowed directory, keeps the symlink-escape guard
|
|
131
|
+
// (a path that resolves OUTSIDE the allowed directories is rejected), but
|
|
132
|
+
// returns the UN-resolved path. Dangling links and not-yet-existing paths are
|
|
133
|
+
// validated via the nearest existing ancestor, exactly like validatePath.
|
|
134
|
+
export async function validatePathPreservingLinks(requestedPath, allowedDirectories) {
|
|
135
|
+
const expandedPath = expandHome(requestedPath);
|
|
136
|
+
// Same as validatePath: normalize extended-length (\\?\) inputs to their
|
|
137
|
+
// plain form first, then re-apply the prefix at the end.
|
|
138
|
+
const plain = stripLongPath(expandedPath);
|
|
139
|
+
const absolute = path.isAbsolute(plain)
|
|
140
|
+
? path.resolve(plain)
|
|
141
|
+
: path.resolve(process.cwd(), plain);
|
|
142
|
+
const normalizedRequested = normalizePath(absolute);
|
|
143
|
+
// Check if path is within allowed directories
|
|
144
|
+
const isAllowed = allowedDirectories.some(dir => normalizedRequested.startsWith(dir));
|
|
145
|
+
if (!isAllowed) {
|
|
146
|
+
throw new Error(`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`);
|
|
147
|
+
}
|
|
148
|
+
// Symlink-escape guard: if the path resolves outside the allowed
|
|
149
|
+
// directories, reject (same policy as validatePath).
|
|
150
|
+
try {
|
|
151
|
+
const realPath = stripLongPath(await fs.realpath(absolute));
|
|
152
|
+
const normalizedReal = normalizePath(realPath);
|
|
153
|
+
const isRealPathAllowed = allowedDirectories.some(dir => normalizedReal.startsWith(dir));
|
|
154
|
+
if (!isRealPathAllowed) {
|
|
155
|
+
throw new Error("Access denied - symlink target outside allowed directories");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error instanceof Error && error.message.startsWith("Access denied")) {
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
// Target doesn't exist yet (or is a dangling link): walk up to the
|
|
163
|
+
// nearest existing ancestor and verify it is within an allowed directory
|
|
164
|
+
let ancestor = path.dirname(absolute);
|
|
165
|
+
let realAncestor = null;
|
|
166
|
+
while (true) {
|
|
167
|
+
try {
|
|
168
|
+
realAncestor = await fs.realpath(ancestor);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
const up = path.dirname(ancestor);
|
|
173
|
+
if (up === ancestor) {
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
ancestor = up;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (realAncestor === null) {
|
|
180
|
+
throw new Error(`Parent directory does not exist: ${path.dirname(absolute)}`);
|
|
181
|
+
}
|
|
182
|
+
const normalizedAncestor = normalizePath(realAncestor);
|
|
183
|
+
const isAncestorAllowed = allowedDirectories.some(dir => normalizedAncestor.startsWith(dir));
|
|
184
|
+
if (!isAncestorAllowed) {
|
|
185
|
+
throw new Error("Access denied - parent directory outside allowed directories");
|
|
186
|
+
}
|
|
63
187
|
}
|
|
188
|
+
// Return the literal (normalized) path - reparse points NOT followed.
|
|
189
|
+
// Extended-length form is applied last, exactly like validatePath.
|
|
190
|
+
return toLongPath(normalizedRequested);
|
|
64
191
|
}
|
|
65
192
|
// Create a directory (and parents) if needed. Tolerates volume roots,
|
|
66
193
|
// which Node's recursive mkdir can reject with EPERM on Windows.
|