mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 00:50:06 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e550e9783 | ||
|
|
1662fb6922 | ||
|
|
73ff25a314 | ||
|
|
4abe993250 | ||
|
|
b98dce721d |
No files matched your search
@@ -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"]);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user