Compare commits

...
Author SHA1 Message Date
陈大猫 8e550e9783 ci: retrigger after unrelated plugin manager UI flake 2026-10-29 20:21:46 +08:00
陈大猫 1662fb6922 ci: retrigger lint-and-test after stuck runner 2026-10-29 20:08:03 +08:00
陈大猫 73ff25a314 fix(sftp): resolve trusted local tar before cwd switch
Use the Windows System32 tar.exe for local availability checks and compression so an upload sibling tar.exe cannot hijack execution. Keep Unix tar lookup unchanged and update command assertions.

Residual P2: exclude patterns still interpret glob metacharacters in archive paths; glob escaping is intentionally deferred.
2026-10-29 19:46:53 +08:00
陈大猫 4abe993250 fix(sftp): exclude in-tree tar archive under GNU tar stdout 2026-10-29 19:42:59 +08:00
noon.li b98dce721d fix(sftp): keep non-ASCII local paths intact in Windows compressed upload
Windows tar.exe converts argv to the ANSI code page, turning non-ASCII
characters into '?'. Run tar with cwd instead of -C and stream the
archive through stdout instead of -f, so local paths never pass through
argv. Folders whose own name is non-ASCII fall back to per-file upload
on Windows.
2026-10-29 17:56:33 +08:00
4 changed files with 236 additions and 11 deletions

No files matched your search

+37 -11
View File
@@ -395,12 +395,21 @@ function init(deps) {
transferBridge = deps.transferBridge;
}
/**
* Resolve Windows tar before switching to an upload-controlled directory.
*/
function resolveLocalTarCommand() {
return process.platform === 'win32'
? path.join(process.env.SystemRoot || process.env.windir || 'C:\\Windows', 'System32', 'tar.exe')
: 'tar';
}
/**
* Check if tar command is available on the system
*/
async function checkTarAvailable(signal) {
return new Promise((resolve) => {
const tar = spawn('tar', ['--version'], { stdio: 'ignore' });
const tar = spawn(resolveLocalTarCommand(), ['--version'], { stdio: 'ignore' });
let settled = false;
let childClosed = false;
let forceKillTimer = null;
@@ -484,24 +493,41 @@ async function compressFolder(folderPath, outputPath, compressionId, sendProgres
}
// Use tar with gzip compression, excluding macOS resource fork files
// -czf: create, gzip, file
// -C: change to directory (so we don't include the full path in archive)
// -czf -: create, gzip, write to stdout (the opened output file)
// cwd: run in the parent directory (so we don't include the full path in archive)
// --exclude='._*': exclude macOS resource fork files
// --exclude='.DS_Store': exclude macOS folder metadata files
// Windows tar.exe converts argv to the ANSI code page, so local paths are
// passed via cwd and a file descriptor instead of -C and -f. Explicitly
// exclude the archive when it lies inside the source tree.
const folderName = path.basename(folderPath);
const parentDir = path.dirname(folderPath);
const tar = spawn('tar', [
'-czf', outputPath,
'-C', parentDir,
let outputFd;
try {
outputFd = fs.openSync(outputPath, 'w');
} catch (err) {
reject(new Error(`Failed to create tar archive: ${err.message}`));
return;
}
const tarArgs = [
'-czf', '-',
'--exclude=._*',
'--exclude=.DS_Store',
'--exclude=.Spotlight-V100',
'--exclude=.Trashes',
folderName
], {
stdio: ['ignore', 'pipe', 'pipe']
'--exclude=.Trashes'
];
const relativeOutputPath = path.relative(folderPath, outputPath);
if (relativeOutputPath !== '..' && !relativeOutputPath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeOutputPath)) {
tarArgs.push(`--exclude=${path.relative(parentDir, outputPath).split(path.sep).join('/')}`);
}
tarArgs.push(folderName);
const tar = spawn(resolveLocalTarCommand(), tarArgs, {
cwd: parentDir,
stdio: ['ignore', outputFd, 'pipe']
});
// tar holds its own handle; close ours so the archive can be removed later.
fs.closeSync(outputFd);
compression.process = tar;
const stderr = createBoundedUtf8Collector(MAX_LOCAL_TAR_STDERR_BYTES);
@@ -0,0 +1,131 @@
"use strict";
const assert = require("node:assert/strict");
const childProcess = require("node:child_process");
const fs = require("node:fs");
const Module = require("node:module");
const os = require("node:os");
const path = require("node:path");
const { EventEmitter } = require("node:events");
const test = require("node:test");
const localTarCommand = process.platform === "win32"
? path.join(process.env.SystemRoot || process.env.windir || "C:\\Windows", "System32", "tar.exe")
: "tar";
// The bridge destructures spawn and getTempFilePath at load time, so record
// tar calls and redirect temp archives before requiring it.
let tempDir = null;
const spawnCalls = [];
const realSpawn = childProcess.spawn;
const originalLoad = Module._load;
childProcess.spawn = (command, args, options) => {
spawnCalls.push({ command, args, options });
return realSpawn(command, args, options);
};
Module._load = function load(request, parent, isMain) {
if (request === "./tempDirBridge.cjs") {
return { getTempFilePath: (fileName) => path.join(tempDir, `1_1_${fileName}`) };
}
return originalLoad.call(this, request, parent, isMain);
};
const compressUploadBridge = require("./compressUploadBridge.cjs");
childProcess.spawn = realSpawn;
Module._load = originalLoad;
function createFolder(t, parentName) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-compress-folder-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const folderPath = path.join(root, parentName, "folder");
fs.mkdirSync(folderPath, { recursive: true });
fs.writeFileSync(path.join(folderPath, "file.txt"), "payload");
return { root, folderPath };
}
async function compressAndList(folderPath) {
let archiveEntries = null;
const handlers = new Map();
compressUploadBridge._resetCompressionSupportCacheForTests();
compressUploadBridge.init({
sftpClients: new Map([["sftp-folder", {
client: {
writable: true,
exec(command, callback) {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
stream.end = () => {};
stream.destroy = () => {};
callback(null, stream);
process.nextTick(() => {
if (command === "tar --version") stream.emit("data", Buffer.from("tar"));
stream.emit("close", 0);
});
},
},
__netcattyEndpointKey: "endpoint:folder",
}]]),
transferBridge: {
acquireTransferSessionLeases(_leaseId, payload) { return [payload.targetSftpId]; },
releaseTransferSessionLeases() {},
broadcastGlobalTransferEvent() {},
async startInternalTransfer(_event, payload) {
// Read the archive through stdin so its path never reaches tar argv.
archiveEntries = childProcess.execFileSync("tar", ["-tzf", "-"], {
input: fs.readFileSync(payload.sourcePath),
encoding: "utf8",
})
.split(/\r?\n/)
.filter(Boolean)
.map((entry) => entry.replace(/\/$/, ""))
.sort();
return { success: true };
},
async cancelTransfer() { return { success: true }; },
},
});
compressUploadBridge.registerHandlers({ handle(channel, handler) { handlers.set(channel, handler); } });
spawnCalls.length = 0;
const result = await handlers.get("netcatty:compress:start")({ sender: { id: 1, send() {} } }, {
compressionId: "compress-folder",
folderPath,
targetPath: "/srv",
sftpId: "sftp-folder",
folderName: "folder",
totalBytes: 7,
});
const tarCall = spawnCalls.find((call) => call.command === localTarCommand && call.args.includes("-czf"));
return { result, archiveEntries, tarCall };
}
test("local tar gets its paths through cwd and stdout instead of argv", async (t) => {
// Non-ASCII parent and temp directories, as with a Chinese Windows user name.
const { root, folderPath } = createFolder(t, "下载目录");
tempDir = path.dirname(folderPath);
const { result, archiveEntries, tarCall } = await compressAndList(folderPath);
assert.equal(result.success, true);
assert.equal(spawnCalls.find((call) => call.args.includes("--version"))?.command, localTarCommand);
assert.ok(tarCall, "local tar compression must be spawned");
assert.equal(tarCall.options.cwd, path.dirname(folderPath));
assert.equal(tarCall.args.includes("-C"), false, "parent directory must not be passed through argv");
assert.deepEqual(tarCall.args.slice(0, 2), ["-czf", "-"]);
assert.equal(typeof tarCall.options.stdio[1], "number", "the archive file must be tar's stdout");
assert.equal(tarCall.args.some((arg) => arg.includes(root)), false, "no local path may reach tar argv");
assert.equal(tarCall.args.at(-1), "folder");
assert.deepEqual(archiveEntries, ["folder", "folder/file.txt"]);
});
test("an archive written inside the source folder is excluded from itself", async (t) => {
const { folderPath } = createFolder(t, "parent");
tempDir = path.join(folderPath, "tmp");
fs.mkdirSync(tempDir);
const { result, archiveEntries, tarCall } = await compressAndList(folderPath);
assert.equal(result.success, true);
assert.ok(tarCall.args.includes("--exclude=folder/tmp/1_1_folder.tar.gz"));
assert.deepEqual(archiveEntries, ["folder", "folder/file.txt", "folder/tmp"]);
});
+58
View File
@@ -93,3 +93,61 @@ test("compressed folder totals exclude directory entries", async () => {
}
}
});
test("non-ASCII folder names fall back to per-file upload on Windows", async () => {
const previousWindow = globalThis.window;
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator");
let supportChecks = 0;
const createdTasks: string[] = [];
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { platform: "Win32" },
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
checkCompressedUploadSupport: async () => {
supportChecks += 1;
return { supported: true, localTar: true, remoteTar: true };
},
startCompressedUpload: async (options: { compressionId: string }) => (
{ compressionId: options.compressionId, success: true }
),
},
},
});
try {
const results = await uploadFoldersCompressed(
[
["前端", [{ file: null, localPath: "C:\\Users\\me\\前端\\a.txt", relativePath: "前端/a.txt", isDirectory: false, size: 1 }]],
["mpr", [{ file: null, localPath: "C:\\Users\\me\\等10个文件\\mpr\\a.txt", relativePath: "mpr/a.txt", isDirectory: false, size: 1 }]],
],
"/remote",
"sftp-1",
{ onTaskCreated: (task) => createdTasks.push(task.fileName) },
);
assert.deepEqual(results, [
{ fileName: "前端", success: false, error: "Compressed upload not supported - fallback needed" },
{ fileName: "mpr", success: true },
]);
assert.equal(supportChecks, 1, "the non-ASCII folder must not probe compressed upload support");
assert.deepEqual(createdTasks, ["mpr"], "no compressed task is created for the fallback folder");
} finally {
if (previousNavigator) {
Object.defineProperty(globalThis, "navigator", previousNavigator);
} else {
Reflect.deleteProperty(globalThis, "navigator");
}
if (previousWindow === undefined) {
Reflect.deleteProperty(globalThis, "window");
} else {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: previousWindow,
});
}
}
});
+10
View File
@@ -8,6 +8,9 @@ import type { UploadController } from "./uploadController";
const formatUploadError = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const isWindowsPlatform = (): boolean =>
typeof navigator !== "undefined" && /Win/i.test(navigator.platform);
export async function uploadFoldersCompressed(
folderEntries: Array<[string, DropEntry[]]>,
targetPath: string,
@@ -95,6 +98,13 @@ export async function uploadFoldersCompressed(
}
}
// Windows tar.exe converts argv to the ANSI code page, so a non-ASCII
// folder name would be mangled. Upload such folders file by file instead.
if (isWindowsPlatform() && /[\u0080-\uffff]/.test(folderName)) {
results.push({ fileName: folderName, success: false, error: "Compressed upload not supported - fallback needed" });
continue;
}
let taskId: string | null = null; // Declare taskId outside try block for error handling
try {