Compare commits

...
Author SHA1 Message Date
bincxzandCursor e1dba3c7b2 fix(sftp): restore the previous file if Windows extract replace fails
Rename the destination aside before publishing the staging file, instead of deleting it first.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:51:57 +08:00
bincxzandCursor c898347637 fix(sftp): refuse single-file extract onto an existing directory
Remote mv would otherwise drop the staging file inside that directory and still report success.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:45:07 +08:00
bincxzandCursor 7b5f4df028 fix(sftp): quote extract cleanup trap and use unique staging files
Remote gzip/bzip2/xz nested a quoted path inside trap, so names with spaces failed before decode. Staging now exclusively creates a unique sibling, and local .gz uses Node zlib.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:38:46 +08:00
bincxzandCursor 14da4dce9c fix(sftp): keep extract from clobbering files or dying on unzip output
Write gzip/bzip2/xz results to a staging path and replace the destination only after a zero exit. Drain unzip progress instead of treating routine stdout as a hard failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:29:14 +08:00
bincxzandCursor df64b9be2b feat(sftp): add right-click extract for archives
Let SFTP list and tree views unpack zip/tar/gz archives in place via remote SSH exec (or local tar/unzip), so users do not need to switch to another file manager.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:21:47 +08:00
25 changed files with 920 additions and 5 deletions

No files matched your search

+4
View File
@@ -229,6 +229,10 @@ export const enVaultMessages: Messages = {
'sftp.moveTo.confirm': 'Move',
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
'sftp.context.download': 'Download',
'sftp.context.extract': 'Extract here',
'sftp.extract.extracting': 'Extracting {fileName}...',
'sftp.extract.success': 'Extracted {fileName}',
'sftp.extract.error': 'Failed to extract {fileName}: {error}',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
'sftp.copyCurrentPath': 'Copy current path',
+4
View File
@@ -229,6 +229,10 @@ export const esVaultMessages: Messages = {
'sftp.moveTo.confirm': 'Mover',
'sftp.moveTo.pathNotFound': 'Directorio no encontrado o inaccesible',
'sftp.context.download': 'Descargar',
'sftp.context.extract': 'Extraer aquí',
'sftp.extract.extracting': 'Extrayendo {fileName}...',
'sftp.extract.success': 'Se extrajo {fileName}',
'sftp.extract.error': 'No se pudo extraer {fileName}: {error}',
'sftp.context.copyToOtherPane': 'Copiar al otro panel',
'sftp.copyToOtherPane.unavailable': 'Abre la vista SFTP de dos paneles y conecta el panel de destino antes de copiar archivos.',
'sftp.copyCurrentPath': 'Copiar ruta actual',
+4
View File
@@ -266,6 +266,10 @@ export const ruVaultMessages: Messages = {
'sftp.moveTo.confirm': 'Переместить',
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
'sftp.context.download': 'Скачать',
'sftp.context.extract': 'Распаковать здесь',
'sftp.extract.extracting': 'Распаковка {fileName}...',
'sftp.extract.success': 'Распакован {fileName}',
'sftp.extract.error': 'Не удалось распаковать {fileName}: {error}',
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
'sftp.copyCurrentPath': 'Копировать текущий путь',
+4
View File
@@ -833,6 +833,10 @@ export const zhCNCoreMessages: Messages = {
'sftp.moveTo.confirm': '移动',
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
'sftp.context.download': '下载',
'sftp.context.extract': '解压到当前目录',
'sftp.extract.extracting': '正在解压 {fileName}...',
'sftp.extract.success': '已解压 {fileName}',
'sftp.extract.error': '解压 {fileName} 失败:{error}',
'sftp.context.copyToOtherPane': '复制到另一侧',
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
'sftp.copyCurrentPath': '复制当前路径',
+4
View File
@@ -833,6 +833,10 @@ export const zhTWCoreMessages: Messages = {
'sftp.moveTo.confirm': '移動',
'sftp.moveTo.pathNotFound': '目錄不存在或無法存取',
'sftp.context.download': '下載',
'sftp.context.extract': '解壓到目前目錄',
'sftp.extract.extracting': '正在解壓 {fileName}...',
'sftp.extract.success': '已解壓 {fileName}',
'sftp.extract.error': '解壓 {fileName} 失敗:{error}',
'sftp.context.copyToOtherPane': '複製到另一側',
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
'sftp.copyCurrentPath': '複製目前路徑',
+1
View File
@@ -59,6 +59,7 @@ export interface SftpPaneCallbacks {
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
// External file upload (supports folders via DataTransfer)
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
// External file upload from <input type="file" multiple> picker (FileList).
+11 -1
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppWindow, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
import { AppWindow, Archive, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
import { Button } from "../ui/button";
import {
ContextMenu,
@@ -17,6 +17,7 @@ import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
import type { UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting";
import { buildSftpColumnTemplate, isNavigableDirectory, isSftpColumnMenuKey } from "./utils";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { isExtractableArchive } from "../../domain/sftpArchive";
import { SftpFileRow } from "./SftpFileRow";
import { SftpColumnMenuItems } from "./SftpColumnMenuItems";
import { getSftpVirtualListScrollTop } from "../../domain/sftpVirtualList";
@@ -65,6 +66,7 @@ interface SftpPaneFileListProps {
onEditFile?: (entry: SftpFileEntry) => void;
onDownloadFile?: (entry: SftpFileEntry) => void;
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
onExtractArchive?: (entry: SftpFileEntry) => void;
onEditPermissions?: (entry: SftpFileEntry) => void;
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void> | void;
onUploadExternalFolder?: (targetPath?: string) => Promise<void> | void;
@@ -153,6 +155,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
onEditFile,
onDownloadFile,
onDownloadFiles,
onExtractArchive,
onEditPermissions,
onUploadExternalFileList,
onUploadExternalFolder,
@@ -347,6 +350,12 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
{t("sftp.context.download")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onExtractArchive && isExtractableArchive(entry.name) && (
<ContextMenuItem onClick={() => onExtractArchive(entry)}>
<Archive size={14} className="mr-2" />{" "}
{t("sftp.context.extract")}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
@@ -472,6 +481,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
onMoveEntriesToPath,
onDownloadFile,
onDownloadFiles,
onExtractArchive,
onDragEnd,
onEditFile,
onEditPermissions,
+4
View File
@@ -44,6 +44,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
onOpenFileWith,
onEditFile,
onDownloadFile,
onExtractArchive,
onEditPermissions,
draggedFiles,
openNewFolderDialog,
@@ -197,6 +198,8 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
onEditFileRef.current = onEditFile;
const onDownloadFileRef = useRef(onDownloadFile);
onDownloadFileRef.current = onDownloadFile;
const onExtractArchiveRef = useRef(onExtractArchive);
onExtractArchiveRef.current = onExtractArchive;
const onEditPermissionsRef = useRef(onEditPermissions);
onEditPermissionsRef.current = onEditPermissions;
const openRenameDialogRef = useRef(openRenameDialog);
@@ -917,6 +920,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
onOpenFileWithRef,
onEditFileRef,
onDownloadFileRef,
onExtractArchiveRef,
onEditPermissionsRef,
openDeleteConfirmRef,
openRenameDialogRef,
@@ -23,6 +23,7 @@ export interface SftpPaneTreeViewProps {
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void;
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onEditPermissions?: (entry: SftpFileEntry, fullPath?: string) => void;
draggedFiles: (SftpTransferSource & { side: 'left' | 'right' })[] | null;
openNewFolderDialog: (targetPath: string) => void;
+11 -1
View File
@@ -19,7 +19,7 @@ import {
useSftpWritableHosts,
} from "./SftpContext";
import type { SftpPane } from "../../application/state/sftp/types";
import { joinPath } from "../../application/state/sftp/utils";
import { joinPath, getParentPath } from "../../application/state/sftp/utils";
import type { Host } from "../../domain/models";
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
@@ -328,6 +328,14 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
}
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
const handleExtractArchive = useCallback((entry: Parameters<NonNullable<typeof callbacks.onExtractArchive>>[0], fullPath?: string) => {
const archivePath = fullPath ?? joinPath(pane.connection?.currentPath ?? "", entry.name);
void Promise.resolve(callbacks.onExtractArchive?.(entry, fullPath)).then(() => {
const parentPath = getParentPath(archivePath);
if (parentPath) requestNestedTreeReload([parentPath]);
});
}, [callbacks, pane.connection?.currentPath, requestNestedTreeReload]);
const handleMoveEntriesToPath = useCallback(async (sourcePaths: string[], targetPath: string) => {
await callbacks.onMoveEntriesToPath(sourcePaths, targetPath);
}, [callbacks]);
@@ -602,6 +610,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onOpenFileWith={callbacks.onOpenFileWith}
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
onEditPermissions={callbacks.onEditPermissions}
draggedFiles={draggedFiles}
openNewFolderDialog={openNewFolderDialogAtPath}
@@ -653,6 +662,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
onDownloadFiles={callbacks.onDownloadFiles}
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
onEditPermissions={callbacks.onEditPermissions}
onUploadExternalFileList={handleUploadExternalFileList}
onUploadExternalFolder={handleUploadExternalFolder}
@@ -8,6 +8,7 @@ import {
} from "../../../application/state/sftp/transferConcurrency";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { isNavigableDirectory } from "../utils";
import { reportSftpUploadResults } from "../reportSftpUploadResults";
@@ -535,6 +536,52 @@ export const useSftpViewFileOps = ({
[handleDownloadFileForSide],
);
const handleExtractArchiveForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const resolvedPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
toast.info(t("sftp.extract.extracting", { fileName: file.name }), "SFTP");
try {
const bridge = netcattyBridge.get();
if (pane.connection.isLocal) {
if (!bridge?.extractLocalArchive) {
throw new Error("Local extract unavailable");
}
await bridge.extractLocalArchive(resolvedPath);
} else {
const sftpId = getSftpIdForConnection?.(pane.connection.id);
if (!sftpId || !bridge?.extractSftpArchive) {
throw new Error("SFTP session not found");
}
await bridge.extractSftpArchive(sftpId, resolvedPath, pane.filenameEncoding);
}
await sftpRef.current.refresh(side);
toast.success(t("sftp.extract.success", { fileName: file.name }), "SFTP");
} catch (e) {
logger.error("[SftpView] Failed to extract archive:", e);
toast.error(
t("sftp.extract.error", {
fileName: file.name,
error: e instanceof Error ? e.message : String(e),
}),
"SFTP",
);
}
},
[getSftpIdForConnection, sftpRef, t],
);
const onExtractArchiveLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleExtractArchiveForSide("left", file, fullPath),
[handleExtractArchiveForSide],
);
const onExtractArchiveRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleExtractArchiveForSide("right", file, fullPath),
[handleExtractArchiveForSide],
);
// Multi-file download. For local panes, each file auto-downloads as a blob
// (no prompt). For remote panes, prompts for a target directory once and
// streams all selected entries into it — avoids the per-file save dialog
@@ -738,6 +785,8 @@ export const useSftpViewFileOps = ({
onOpenFileWithRight,
onDownloadFileLeft,
onDownloadFileRight,
onExtractArchiveLeft,
onExtractArchiveRight,
onDownloadFilesLeft,
onDownloadFilesRight,
onUploadExternalFilesLeft,
@@ -81,6 +81,8 @@ export interface UseSftpViewFileOpsResult {
onOpenFileWithRight: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onExtractArchiveLeft: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onExtractArchiveRight: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onDownloadFilesLeft: (files: SftpFileEntry[]) => void;
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
@@ -154,6 +154,7 @@ export const useSftpViewPaneCallbacks = ({
onOpenFileWith: fileOps.onOpenFileWithLeft,
onDownloadFile: fileOps.onDownloadFileLeft,
onDownloadFiles: fileOps.onDownloadFilesLeft,
onExtractArchive: fileOps.onExtractArchiveLeft,
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
@@ -196,6 +197,7 @@ export const useSftpViewPaneCallbacks = ({
onOpenFileWith: fileOps.onOpenFileWithRight,
onDownloadFile: fileOps.onDownloadFileRight,
onDownloadFiles: fileOps.onDownloadFilesRight,
onExtractArchive: fileOps.onExtractArchiveRight,
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { AppWindow, ArrowRight, ArrowUp, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
import { AppWindow, Archive, ArrowRight, ArrowUp, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../ui/context-menu';
import { getParentPath } from '../../application/state/sftp/utils';
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
import { isExtractableArchive } from '../../domain/sftpArchive';
import { isNavigableDirectory } from './utils';
import { getSftpTreeUploadFilesTargetPath, getSftpUploadFilesLabelKey, getSftpUploadFolderLabelKey } from './sftpUploadMenu';
@@ -15,7 +16,7 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
executeMoveAction, triggerUploadPicker, onUploadExternalFolder, uploadEnabled, folderUploadEnabled,
setMoveTargetPaths, setMoveToPath, setMoveToError, setMoveToSuggestions, setMoveToSuggestionIndex,
setIsMoving, setShowMoveToDialog, tRef, onCopyToOtherPaneRef, onNavigateToRef, onOpenFileWithSystemDefaultRef, onOpenFileWithRef,
onEditFileRef, onDownloadFileRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
onEditFileRef, onDownloadFileRef, onExtractArchiveRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
openNewFolderDialogRef, openNewFileDialogRef,
} = props;
@@ -82,6 +83,11 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
<Download size={14} className="mr-2" />{tRef.current('sftp.context.download')}
</ContextMenuItem>
)}
{!isDir && onExtractArchiveRef.current && isExtractableArchive(entry.name) && (
<ContextMenuItem onClick={() => onExtractArchiveRef.current?.(entry, entryPath)}>
<Archive size={14} className="mr-2" />{tRef.current('sftp.context.extract')}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={handleCopyToOtherPane}>
<Copy size={14} className="mr-2" />{tRef.current('sftp.context.copyToOtherPane')}
@@ -175,6 +181,7 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
onUploadExternalFolder,
onCopyToOtherPaneRef,
onDownloadFileRef,
onExtractArchiveRef,
onEditFileRef,
onEditPermissionsRef,
onNavigateToRef,
+15
View File
@@ -0,0 +1,15 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getSftpArchiveKind, isExtractableArchive } from "./sftpArchive.ts";
test("SFTP extract menu matches compound archive suffixes", () => {
assert.equal(getSftpArchiveKind("backup.tar.gz"), "tar.gz");
assert.equal(getSftpArchiveKind("logs.tgz"), "tar.gz");
assert.equal(getSftpArchiveKind("src.tar.bz2"), "tar.bz2");
assert.equal(getSftpArchiveKind("app.zip"), "zip");
assert.equal(getSftpArchiveKind("notes.txt.gz"), "gz");
assert.equal(isExtractableArchive("/var/tmp/payload.tar.xz"), true);
assert.equal(isExtractableArchive("readme.md"), false);
assert.equal(isExtractableArchive(".zip"), false);
});
+32
View File
@@ -0,0 +1,32 @@
const ARCHIVE_SUFFIXES: Array<{ kind: string; suffixes: string[] }> = [
{ kind: "tar.gz", suffixes: [".tar.gz", ".tgz"] },
{ kind: "tar.bz2", suffixes: [".tar.bz2", ".tbz2", ".tar.bzip2"] },
{ kind: "tar.xz", suffixes: [".tar.xz", ".txz"] },
{ kind: "tar.zst", suffixes: [".tar.zst", ".tzst"] },
{ kind: "tar", suffixes: [".tar"] },
{ kind: "zip", suffixes: [".zip"] },
{ kind: "gz", suffixes: [".gz"] },
{ kind: "bz2", suffixes: [".bz2"] },
{ kind: "xz", suffixes: [".xz"] },
];
function archiveBaseName(fileName: string): string {
const normalized = String(fileName || "").replace(/\\/g, "/");
const parts = normalized.split("/");
return parts[parts.length - 1] || "";
}
export function getSftpArchiveKind(fileName: string): string | null {
const base = archiveBaseName(fileName).toLowerCase();
if (!base) return null;
for (const entry of ARCHIVE_SUFFIXES) {
if (entry.suffixes.some((suffix) => base.endsWith(suffix) && base.length > suffix.length)) {
return entry.kind;
}
}
return null;
}
export function isExtractableArchive(fileName: string): boolean {
return getSftpArchiveKind(fileName) != null;
}
+7
View File
@@ -617,6 +617,11 @@ async function listDrives() {
return letters.filter((_, idx) => results[idx].status === "fulfilled").map((letter) => letter + ":");
}
async function extractLocalArchive(_event, payload) {
const { extractLocalArchiveFile } = require("./sftpBridge/archiveExtract.cjs");
return extractLocalArchiveFile(payload?.path);
}
/**
* Register IPC handlers for local filesystem operations
*/
@@ -626,6 +631,7 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:local:write", writeLocalFile);
ipcMain.handle("netcatty:local:delete", deleteLocalFile);
ipcMain.handle("netcatty:local:rename", renameLocalFile);
ipcMain.handle("netcatty:local:extract", extractLocalArchive);
ipcMain.handle("netcatty:local:mkdir", mkdirLocal);
ipcMain.handle("netcatty:local:stat", statLocal);
ipcMain.handle("netcatty:local:lstat", lstatLocal);
@@ -644,6 +650,7 @@ module.exports = {
writeLocalFile,
deleteLocalFile,
renameLocalFile,
extractLocalArchive,
mkdirLocal,
statLocal,
lstatLocal,
+7 -1
View File
@@ -526,6 +526,7 @@ async function execRemoteShellCommand(sshClient, command, optionsOrSignal = null
1,
Number(options.maxOutputBytes) || REMOTE_DELETE_EXEC_MAX_OUTPUT_BYTES,
);
const discardStdout = options.discardStdout === true;
return await new Promise((resolve, reject) => {
let settled = false;
let streamRef = null;
@@ -575,6 +576,7 @@ async function execRemoteShellCommand(sshClient, command, optionsOrSignal = null
const appendOutput = (target, chunk) => {
if (settled) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
if (target === "stdout" && discardStdout) return;
outputBytes += buffer.length;
if (outputBytes > maxOutputBytes) {
finish(new Error(`Remote command output exceeded ${maxOutputBytes} bytes`));
@@ -2374,7 +2376,7 @@ const fileOpsApi = createFileOpsApi({
requireSftpChannel, resolveEncodingForRequest, updateResolvedEncoding, encodePath, decodeName,
detectEncodingFromList, statResultFromAttrs, normalizeRemotePathString, collectReadable, writeToWritable,
throwIfAborted, pipeStreams, ensureRemoteDirForSession, removeRemotePathInternal, removeRemoteDirectory,
tryFastShellDirectoryDelete, renameRemotePath,
tryFastShellDirectoryDelete, execRemoteShellCommand, renameRemotePath,
buildStagedRemotePath, buildBackupRemotePath,
realpathAsync, statAsync, lstatAsync, readdirAsync, mkdirAsync, rmdirAsync, unlinkAsync, openFileAsync,
writeFileChunkAsync, closeFileAsync, createAbortError, copySftpEncodingState, clearSftpEncodingState,
@@ -2395,6 +2397,7 @@ const {
statSftp,
lstatSftp,
chmodSftp,
extractSftpArchive,
getSftpHomeDir,
} = fileOpsApi;
@@ -2590,6 +2593,7 @@ function registerHandlers(ipcMain, options = {}) {
"netcatty:sftp:stat",
"netcatty:sftp:lstat",
"netcatty:sftp:chmod",
"netcatty:sftp:extract",
"netcatty:sftp:homeDir",
].forEach((channel) => registerWorkerHandle(ipcMain, terminalWorkerManager, channel, ownership));
return;
@@ -2612,6 +2616,7 @@ function registerHandlers(ipcMain, options = {}) {
["netcatty:sftp:stat", statSftp],
["netcatty:sftp:lstat", lstatSftp],
["netcatty:sftp:chmod", chmodSftp],
["netcatty:sftp:extract", extractSftpArchive],
["netcatty:sftp:homeDir", getSftpHomeDir],
].forEach(([channel, handler]) => registerActivityHandle(ipcMain, channel, handler, ownership));
}
@@ -2659,6 +2664,7 @@ module.exports = {
statSftp,
lstatSftp,
chmodSftp,
extractSftpArchive,
getSftpHomeDir,
resolveEncodingForRequest,
_execRemoteShellCommandForTests: execRemoteShellCommand,
@@ -138,3 +138,21 @@ test("remote delete exec rejects before exposing a character cut by its byte lim
));
assert.ok(stream.closeCalls + stream.destroyCalls > 0);
});
test("remote extract exec drains stdout without treating progress as a hard limit", async () => {
const stream = createRemoteStream();
const sshClient = {
exec(_command, next) { next(null, stream); },
};
const running = execRemoteShellCommand(sshClient, "unzip -qo archive.zip", {
openingTimeoutMs: 100,
runTimeoutMs: 100,
maxOutputBytes: 8,
discardStdout: true,
});
stream.emit("data", Buffer.alloc(64, "x"));
stream.stderr.emit("data", Buffer.from("ok"));
stream.emit("close", 0);
assert.deepEqual(await running, { stdout: "", stderr: "ok", code: 0 });
});
@@ -0,0 +1,417 @@
/**
* Detect archive types and build extract commands for remote SSH exec / local spawn.
* Pure helpers plus local process I/O — no SFTP session access.
*/
"use strict";
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { pipeline } = require("node:stream/promises");
const zlib = require("node:zlib");
const ARCHIVE_KINDS = [
{ kind: "tar.gz", suffixes: [".tar.gz", ".tgz"] },
{ kind: "tar.bz2", suffixes: [".tar.bz2", ".tbz2", ".tar.bzip2"] },
{ kind: "tar.xz", suffixes: [".tar.xz", ".txz"] },
{ kind: "tar.zst", suffixes: [".tar.zst", ".tzst"] },
{ kind: "tar", suffixes: [".tar"] },
{ kind: "zip", suffixes: [".zip"] },
{ kind: "gz", suffixes: [".gz"] },
{ kind: "bz2", suffixes: [".bz2"] },
{ kind: "xz", suffixes: [".xz"] },
];
const EXTRACT_OPEN_TIMEOUT_MS = 15_000;
const EXTRACT_BASE_TIMEOUT_MS = 60_000;
const EXTRACT_MAX_TIMEOUT_MS = 10 * 60_000;
const EXTRACT_MAX_OUTPUT_BYTES = 64 * 1024;
function getArchiveBaseName(filePath) {
const normalized = String(filePath || "").replace(/\\/g, "/");
const parts = normalized.split("/");
return parts[parts.length - 1] || "";
}
function getArchiveKind(filePath) {
const base = getArchiveBaseName(filePath).toLowerCase();
if (!base) return null;
for (const entry of ARCHIVE_KINDS) {
if (entry.suffixes.some((suffix) => base.endsWith(suffix) && base.length > suffix.length)) {
return entry.kind;
}
}
return null;
}
function isExtractableArchive(filePath) {
return getArchiveKind(filePath) != null;
}
function posixParentDir(remotePath) {
const normalized = String(remotePath || "").replace(/\\/g, "/");
if (!normalized || normalized === "/" || normalized === ".") {
throw new Error("Archive path has no parent directory");
}
const trimmed = normalized.replace(/\/+$/, "") || "/";
if (trimmed === "/") {
throw new Error("Archive path has no parent directory");
}
const idx = trimmed.lastIndexOf("/");
if (idx < 0) return ".";
if (idx === 0) return "/";
return trimmed.slice(0, idx);
}
function stripCompressionSuffix(filePath, kind) {
const suffix = kind === "gz" ? ".gz" : kind === "bz2" ? ".bz2" : kind === "xz" ? ".xz" : "";
if (!suffix) {
throw new Error(`Cannot strip suffix for archive kind: ${kind}`);
}
const base = getArchiveBaseName(filePath);
if (base.length <= suffix.length || !base.toLowerCase().endsWith(suffix)) {
throw new Error(`Archive name does not match kind ${kind}`);
}
const parent = posixParentDir(filePath);
const stem = base.slice(0, base.length - suffix.length);
if (parent === "/") return `/${stem}`;
if (parent === ".") return stem;
return `${parent}/${stem}`;
}
function computeExtractTimeoutMs(archiveSize) {
const size = Number(archiveSize);
if (!Number.isFinite(size) || size <= 0) return EXTRACT_MAX_TIMEOUT_MS;
const extra = Math.ceil(size / (10 * 1024 * 1024)) * 30_000;
return Math.min(EXTRACT_MAX_TIMEOUT_MS, Math.max(EXTRACT_BASE_TIMEOUT_MS, EXTRACT_BASE_TIMEOUT_MS + extra));
}
function buildExtractCommand(archivePath, { encoding = "utf-8" } = {}) {
const kind = getArchiveKind(archivePath);
if (!kind) {
throw new Error(`Unsupported archive type: ${getArchiveBaseName(archivePath) || archivePath}`);
}
const { assertSafeRemotePath, shellQuotePath } = require("./scpShell.cjs");
const remotePath = assertSafeRemotePath(archivePath);
const parent = posixParentDir(remotePath);
const qArchive = shellQuotePath(remotePath, encoding);
const qParent = shellQuotePath(parent, encoding);
if (kind === "tar") return `tar -xf ${qArchive} -C ${qParent}`;
if (kind === "tar.gz") return `tar -xzf ${qArchive} -C ${qParent}`;
if (kind === "tar.bz2") return `tar -xjf ${qArchive} -C ${qParent}`;
if (kind === "tar.xz") return `tar -xJf ${qArchive} -C ${qParent}`;
if (kind === "tar.zst") return `tar --zstd -xf ${qArchive} -C ${qParent}`;
if (kind === "zip") {
return [
`if command -v unzip >/dev/null 2>&1; then`,
` unzip -qo ${qArchive} -d ${qParent} >/dev/null`,
`elif tar -tf ${qArchive} >/dev/null 2>&1; then`,
` tar -xf ${qArchive} -C ${qParent}`,
`else`,
` echo 'unzip is not installed on the remote host' >&2`,
` exit 127`,
`fi`,
].join("\n");
}
const outputPath = stripCompressionSuffix(remotePath, kind);
const decoder = kind === "gz" ? "gzip" : kind === "bz2" ? "bzip2" : kind === "xz" ? "xz" : null;
if (!decoder) throw new Error(`Unsupported archive type: ${kind}`);
return buildSingleFileExtractCommand(decoder, qArchive, outputPath, encoding);
}
function buildSingleFileExtractCommand(decoder, qArchive, outputPath, encoding) {
const { shellQuotePath } = require("./scpShell.cjs");
const qOut = shellQuotePath(outputPath, encoding);
return [
"set -e",
`out=${qOut}`,
`archive=${qArchive}`,
"n=0",
"stage=",
"while [ \"$n\" -lt 32 ]; do",
" candidate=\"$out.netcatty-extract.$$.$n\"",
" if (umask 077; set -C; : > \"$candidate\") 2>/dev/null; then",
" stage=\"$candidate\"",
" break",
" fi",
" n=$((n + 1))",
"done",
"if [ -z \"$stage\" ]; then",
" echo 'could not allocate extraction staging file' >&2",
" exit 1",
"fi",
"trap 'rm -f -- \"$stage\"' EXIT",
`${decoder} -dc -- "$archive" > "$stage"`,
"if [ -d \"$out\" ]; then",
" echo 'extraction target is a directory' >&2",
" exit 1",
"fi",
"mv -f -- \"$stage\" \"$out\"",
"trap - EXIT",
].join("\n");
}
function tarArgs(flag, archivePath, parentDir) {
return flag ? [flag, archivePath, "-C", parentDir] : ["-xf", archivePath, "-C", parentDir];
}
function buildLocalExtractPlan(archivePath, platform = process.platform) {
const kind = getArchiveKind(archivePath);
if (!kind) {
throw new Error(`Unsupported archive type: ${path.basename(archivePath) || archivePath}`);
}
const parentDir = path.dirname(archivePath);
if (kind === "tar") return { command: "tar", args: tarArgs("-xf", archivePath, parentDir) };
if (kind === "tar.gz") return { command: "tar", args: tarArgs("-xzf", archivePath, parentDir) };
if (kind === "tar.bz2") return { command: "tar", args: tarArgs("-xjf", archivePath, parentDir) };
if (kind === "tar.xz") return { command: "tar", args: tarArgs("-xJf", archivePath, parentDir) };
if (kind === "tar.zst") return { command: "tar", args: ["--zstd", "-xf", archivePath, "-C", parentDir] };
if (kind === "zip") {
if (platform === "win32") {
return { command: "tar", args: ["-xf", archivePath, "-C", parentDir] };
}
return {
command: "unzip",
args: ["-qo", archivePath, "-d", parentDir],
fallback: { command: "tar", args: ["-xf", archivePath, "-C", parentDir] },
};
}
const outputPath = path.join(parentDir, path.basename(stripCompressionSuffix(archivePath.replace(/\\/g, "/"), kind)));
if (kind === "gz") return { builtin: "gunzip", archivePath, stdoutFile: outputPath };
if (kind === "bz2") return { command: "bzip2", args: ["-dc", archivePath], stdoutFile: outputPath };
if (kind === "xz") return { command: "xz", args: ["-dc", archivePath], stdoutFile: outputPath };
throw new Error(`Unsupported archive type: ${kind}`);
}
function isMissingBinaryError(error) {
const code = error?.code;
return code === "ENOENT" || code === 127 || code === "127";
}
async function allocateLocalStagingFile(destFile) {
for (let i = 0; i < 32; i += 1) {
const stagingFile = `${destFile}.netcatty-extract.${crypto.randomBytes(6).toString("hex")}`;
try {
const handle = await fs.promises.open(stagingFile, "wx");
await handle.close();
return stagingFile;
} catch (error) {
if (error?.code !== "EEXIST") throw error;
}
}
throw new Error("Could not allocate extraction staging file");
}
async function replaceLocalFile(stagingFile, destFile) {
try {
const destStat = await fs.promises.lstat(destFile);
if (destStat.isDirectory()) {
throw new Error("Extraction target is a directory");
}
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
try {
await fs.promises.rename(stagingFile, destFile);
} catch (error) {
if (error?.code === "EEXIST" || error?.code === "EPERM") {
const backupFile = `${destFile}.netcatty-backup.${crypto.randomBytes(6).toString("hex")}`;
await fs.promises.rename(destFile, backupFile);
try {
await fs.promises.rename(stagingFile, destFile);
} catch (publishError) {
try {
await fs.promises.rename(backupFile, destFile);
} catch {
/* keep the backup file if restore also fails */
}
throw publishError;
}
await fs.promises.unlink(backupFile).catch(() => {});
return;
}
try {
await fs.promises.copyFile(stagingFile, destFile);
} finally {
await fs.promises.unlink(stagingFile).catch(() => {});
}
}
}
function collectBoundedStderr(stream) {
let stderr = "";
stream.on("data", (chunk) => {
if (stderr.length >= EXTRACT_MAX_OUTPUT_BYTES) return;
stderr += String(chunk);
if (stderr.length > EXTRACT_MAX_OUTPUT_BYTES) {
stderr = stderr.slice(0, EXTRACT_MAX_OUTPUT_BYTES);
}
});
return () => stderr;
}
async function gunzipLocalFile(archivePath, stdoutFile, timeoutMs) {
const stagingFile = await allocateLocalStagingFile(stdoutFile);
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
timer.unref?.();
try {
await pipeline(
fs.createReadStream(archivePath),
zlib.createGunzip(),
fs.createWriteStream(stagingFile),
{ signal: ac.signal },
);
await replaceLocalFile(stagingFile, stdoutFile);
} catch (error) {
await fs.promises.unlink(stagingFile).catch(() => {});
if (error?.name === "AbortError") {
throw new Error(`Local extraction timed out after ${timeoutMs} ms`);
}
const detail = error?.message ? `: ${error.message}` : "";
throw new Error(`Local extraction failed${detail}`);
} finally {
clearTimeout(timer);
}
}
async function pipeCommandToFile(command, args, stdoutFile, timeoutMs) {
const stagingFile = await allocateLocalStagingFile(stdoutFile);
await new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
const out = fs.createWriteStream(stagingFile);
const readStderr = collectBoundedStderr(child.stderr);
let settled = false;
const timer = setTimeout(() => {
finish(new Error(`Local extraction timed out after ${timeoutMs} ms`));
try { child.kill("SIGKILL"); } catch { /* ignore */ }
}, timeoutMs);
timer.unref?.();
const finish = (error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try { out.destroy(); } catch { /* ignore */ }
if (error) {
fs.promises.unlink(stagingFile).catch(() => {});
reject(error);
return;
}
resolve();
};
child.stdout.pipe(out);
out.on("error", (error) => finish(error));
child.on("error", (error) => finish(error));
child.on("close", (code) => {
if (code === 0) {
out.end(() => finish(null));
return;
}
const stderr = readStderr();
finish(new Error(
`Local extraction failed (code ${code})${stderr ? `: ${stderr.trim()}` : ""}`,
));
});
});
try {
await replaceLocalFile(stagingFile, stdoutFile);
} catch (error) {
await fs.promises.unlink(stagingFile).catch(() => {});
throw error;
}
}
function runSpawnedExtract(command, args, timeoutMs) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
const readStderr = collectBoundedStderr(child.stderr);
let settled = false;
const timer = setTimeout(() => {
finish(new Error(`Local extraction timed out after ${timeoutMs} ms`));
try { child.kill("SIGKILL"); } catch { /* ignore */ }
}, timeoutMs);
timer.unref?.();
const finish = (error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (error) reject(error);
else resolve();
};
child.stdout.on("data", () => {});
child.on("error", (error) => finish(error));
child.on("close", (code) => {
if (code === 0) {
finish(null);
return;
}
const stderr = readStderr();
finish(new Error(
`Local extraction failed (code ${code})${stderr ? `: ${stderr.trim()}` : ""}`,
));
});
});
}
async function runLocalExtractCommand(command, args, stdoutFile, timeoutMs) {
if (stdoutFile) {
await pipeCommandToFile(command, args, stdoutFile, timeoutMs);
return;
}
await runSpawnedExtract(command, args, timeoutMs);
}
async function executeLocalExtractPlan(plan, { timeoutMs = EXTRACT_MAX_TIMEOUT_MS } = {}) {
if (plan.builtin === "gunzip") {
await gunzipLocalFile(plan.archivePath, plan.stdoutFile, timeoutMs);
return;
}
try {
await runLocalExtractCommand(plan.command, plan.args, plan.stdoutFile, timeoutMs);
} catch (error) {
if (plan.fallback && isMissingBinaryError(error)) {
await runLocalExtractCommand(
plan.fallback.command,
plan.fallback.args,
plan.fallback.stdoutFile,
timeoutMs,
);
return;
}
throw error;
}
}
async function extractLocalArchiveFile(archivePath) {
if (typeof archivePath !== "string" || !archivePath) {
throw new Error("Archive path is required");
}
const stat = await fs.promises.stat(archivePath);
if (stat.isDirectory()) {
throw new Error("Cannot extract a directory");
}
const plan = buildLocalExtractPlan(archivePath);
await executeLocalExtractPlan(plan, { timeoutMs: computeExtractTimeoutMs(stat.size) });
return { success: true };
}
module.exports = {
ARCHIVE_KINDS,
EXTRACT_OPEN_TIMEOUT_MS,
EXTRACT_MAX_TIMEOUT_MS,
EXTRACT_MAX_OUTPUT_BYTES,
getArchiveKind,
isExtractableArchive,
posixParentDir,
stripCompressionSuffix,
computeExtractTimeoutMs,
buildExtractCommand,
buildLocalExtractPlan,
executeLocalExtractPlan,
extractLocalArchiveFile,
};
@@ -0,0 +1,188 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const {
getArchiveKind,
isExtractableArchive,
posixParentDir,
stripCompressionSuffix,
computeExtractTimeoutMs,
buildExtractCommand,
buildLocalExtractPlan,
EXTRACT_MAX_TIMEOUT_MS,
} = require("./archiveExtract.cjs");
test("detects compound archive suffixes before single-file compression", () => {
assert.equal(getArchiveKind("backup.tar.gz"), "tar.gz");
assert.equal(getArchiveKind("/var/a.tgz"), "tar.gz");
assert.equal(getArchiveKind("logs.tar.bz2"), "tar.bz2");
assert.equal(getArchiveKind("src.tar.xz"), "tar.xz");
assert.equal(getArchiveKind("app.tar"), "tar");
assert.equal(getArchiveKind("payload.zip"), "zip");
assert.equal(getArchiveKind("notes.txt.gz"), "gz");
assert.equal(getArchiveKind("notes.txt"), null);
assert.equal(getArchiveKind(".gz"), null);
assert.equal(isExtractableArchive("bundle.tgz"), true);
assert.equal(isExtractableArchive("readme.md"), false);
});
test("posix parent and gzip output stay in the archive directory", () => {
assert.equal(posixParentDir("/home/app/a.tar.gz"), "/home/app");
assert.equal(posixParentDir("/a.zip"), "/");
assert.equal(stripCompressionSuffix("/home/app/notes.txt.gz", "gz"), "/home/app/notes.txt");
assert.equal(stripCompressionSuffix("/notes.txt.gz", "gz"), "/notes.txt");
});
test("extract commands quote spaces and single quotes", () => {
const tarCmd = buildExtractCommand("/tmp/my files/app's.tgz");
assert.match(tarCmd, /tar -xzf '/);
assert.match(tarCmd, /'\/tmp\/my files\/app'\\''s\.tgz'/);
assert.match(tarCmd, /-C '\/tmp\/my files'/);
const zipCmd = buildExtractCommand("/opt/build/out.zip");
assert.match(zipCmd, /unzip -qo '\/opt\/build\/out\.zip' -d '\/opt\/build' >\/dev\/null/);
assert.match(zipCmd, /tar -xf '\/opt\/build\/out\.zip' -C '\/opt\/build'/);
const gzCmd = buildExtractCommand("/var/log/syslog.gz");
assert.match(gzCmd, /out='\/var\/log\/syslog'/);
assert.match(gzCmd, /archive='\/var\/log\/syslog\.gz'/);
assert.match(gzCmd, /trap 'rm -f -- "\$stage"' EXIT/);
assert.match(gzCmd, /gzip -dc -- "\$archive" > "\$stage"/);
assert.match(gzCmd, /if \[ -d "\$out" \]/);
assert.match(gzCmd, /mv -f -- "\$stage" "\$out"/);
const spacedGz = buildExtractCommand("/tmp/my file.txt.gz");
assert.match(spacedGz, /out='\/tmp\/my file\.txt'/);
assert.match(spacedGz, /archive='\/tmp\/my file\.txt\.gz'/);
assert.doesNotMatch(spacedGz, /trap 'rm -f -- '/);
});
test("generated gzip extract script can install its EXIT trap with spaces in the path", () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-trap-"));
const archive = path.join(dir, "my file.txt.gz");
fs.writeFileSync(archive, "x");
const cmd = buildExtractCommand(archive);
const prefix = [];
for (const line of cmd.split("\n")) {
prefix.push(line);
if (line.startsWith("trap ")) break;
}
prefix.push("trap - EXIT");
prefix.push("rm -f -- \"$stage\"");
const ran = spawnSync("sh", ["-c", prefix.join("\n")], { encoding: "utf8" });
assert.equal(ran.status, 0, ran.stderr);
fs.rmSync(dir, { recursive: true, force: true });
});
test("extract command rejects newlines and unknown types", () => {
assert.throws(() => buildExtractCommand("/tmp/bad\n.tar.gz"), /NUL or newlines/);
assert.throws(() => buildExtractCommand("/tmp/notes.txt"), /Unsupported archive type/);
});
test("local extract plan uses unzip with tar fallback off Windows", () => {
const unixZip = buildLocalExtractPlan("/tmp/a.zip", "linux");
assert.deepEqual(unixZip.command, "unzip");
assert.deepEqual(unixZip.args, ["-qo", "/tmp/a.zip", "-d", "/tmp"]);
assert.deepEqual(unixZip.fallback, { command: "tar", args: ["-xf", "/tmp/a.zip", "-C", "/tmp"] });
const winZip = buildLocalExtractPlan("C:\\tmp\\a.zip", "win32");
assert.equal(winZip.command, "tar");
assert.ok(winZip.args.includes("-xf"));
const gz = buildLocalExtractPlan("/tmp/notes.txt.gz", "win32");
assert.equal(gz.builtin, "gunzip");
assert.equal(gz.archivePath, "/tmp/notes.txt.gz");
assert.equal(gz.stdoutFile, "/tmp/notes.txt");
});
test("unknown archive size uses the maximum extract timeout", () => {
assert.equal(computeExtractTimeoutMs(undefined), EXTRACT_MAX_TIMEOUT_MS);
assert.ok(computeExtractTimeoutMs(1024) < EXTRACT_MAX_TIMEOUT_MS);
});
test("extractLocalArchiveFile unpacks a tar.gz next to the archive", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const { extractLocalArchiveFile } = require("./archiveExtract.cjs");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-extract-"));
const source = path.join(dir, "hello.txt");
const archive = path.join(dir, "hello.tgz");
fs.writeFileSync(source, "hello-extract");
const packed = spawnSync("tar", ["-czf", archive, "-C", dir, "hello.txt"], { encoding: "utf8" });
assert.equal(packed.status, 0, packed.stderr);
fs.unlinkSync(source);
await extractLocalArchiveFile(archive);
assert.equal(fs.readFileSync(source, "utf8"), "hello-extract");
fs.rmSync(dir, { recursive: true, force: true });
});
test("failed gzip extract leaves an existing sibling file intact", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { extractLocalArchiveFile } = require("./archiveExtract.cjs");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-extract-keep-"));
const dest = path.join(dir, "notes.txt");
const archive = path.join(dir, "notes.txt.gz");
fs.writeFileSync(dest, "keep-me");
fs.writeFileSync(archive, "this-is-not-gzip");
await assert.rejects(() => extractLocalArchiveFile(archive), /Local extraction failed/);
assert.equal(fs.readFileSync(dest, "utf8"), "keep-me");
assert.deepEqual(
fs.readdirSync(dir).filter((name) => name.includes(".netcatty-extract")),
[],
);
fs.rmSync(dir, { recursive: true, force: true });
});
test("extractLocalArchiveFile gunzips with Node zlib and keeps a sibling staging name", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const zlib = require("node:zlib");
const { extractLocalArchiveFile } = require("./archiveExtract.cjs");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-extract-gz-"));
const dest = path.join(dir, "notes.txt");
const archive = path.join(dir, "notes.txt.gz");
const sibling = `${dest}.netcatty-extract`;
fs.writeFileSync(sibling, "do-not-touch");
fs.writeFileSync(archive, zlib.gzipSync("hello-gzip"));
await extractLocalArchiveFile(archive);
assert.equal(fs.readFileSync(dest, "utf8"), "hello-gzip");
assert.equal(fs.readFileSync(sibling, "utf8"), "do-not-touch");
fs.rmSync(dir, { recursive: true, force: true });
});
test("failed gzip extract leaves a sibling directory target intact", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const zlib = require("node:zlib");
const { extractLocalArchiveFile } = require("./archiveExtract.cjs");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-extract-dir-"));
const dest = path.join(dir, "notes.txt");
const archive = path.join(dir, "notes.txt.gz");
fs.mkdirSync(dest);
fs.writeFileSync(path.join(dest, "keep.txt"), "inside");
fs.writeFileSync(archive, zlib.gzipSync("hello-gzip"));
await assert.rejects(() => extractLocalArchiveFile(archive), /directory/);
assert.equal(fs.readFileSync(path.join(dest, "keep.txt"), "utf8"), "inside");
fs.rmSync(dir, { recursive: true, force: true });
});
+59
View File
@@ -1015,6 +1015,64 @@ function createFileOpsApi(ctx) {
await client.chmod(encodedPath, parseInt(payload.mode, 8));
return true;
}
/**
* Extract a remote archive into its parent directory via SSH exec.
*/
async function extractSftpArchive(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const archivePath = payload.path;
if (typeof archivePath !== "string" || !archivePath) {
throw new Error("Archive path is required");
}
const {
buildExtractCommand,
computeExtractTimeoutMs,
EXTRACT_OPEN_TIMEOUT_MS,
EXTRACT_MAX_OUTPUT_BYTES,
getArchiveKind,
} = require("./archiveExtract.cjs");
if (!getArchiveKind(archivePath)) {
throw new Error(`Unsupported archive type: ${archivePath}`);
}
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const command = buildExtractCommand(archivePath, { encoding });
const signal = payload?.abortSignal || null;
throwIfAborted(signal);
const sshClient = client.client;
if (!sshClient || typeof sshClient.exec !== "function") {
throw new Error("SSH exec unavailable");
}
if (typeof execRemoteShellCommand !== "function") {
throw new Error("SSH exec unavailable");
}
let archiveSize = 0;
try {
if (!isScpModeClient(client) && typeof lstatAsync === "function") {
const sftp = await requireSftpChannel(client, { signal });
const encodedPath = encodePath(archivePath, encoding);
const attrs = await lstatAsync(sftp, encodedPath);
archiveSize = Number(attrs?.size) || 0;
}
} catch {
archiveSize = 0;
}
await execRemoteShellCommand(sshClient, command, {
signal,
openingTimeoutMs: EXTRACT_OPEN_TIMEOUT_MS,
runTimeoutMs: computeExtractTimeoutMs(archiveSize),
maxOutputBytes: EXTRACT_MAX_OUTPUT_BYTES,
discardStdout: true,
});
return { success: true };
}
/**
* Resolve the remote user's home directory.
@@ -1147,6 +1205,7 @@ function createFileOpsApi(ctx) {
statSftp,
lstatSftp,
chmodSftp,
extractSftpArchive,
getSftpHomeDir,
};
}
@@ -0,0 +1,58 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { createFileOpsApi } = require("./fileOps.cjs");
function createExtractApi({ execImpl, clients } = {}) {
const commands = [];
const optionsLog = [];
const api = createFileOpsApi({
sftpClients: clients || new Map([
["sftp-1", { client: { exec() {} } }],
]),
resolveEncodingForRequest: () => "utf-8",
throwIfAborted: () => {},
encodePath: (value) => value,
requireSftpChannel: async () => ({}),
lstatAsync: async () => ({ size: 1024 }),
isScpModeClient: () => false,
execRemoteShellCommand: async (_ssh, command, options) => {
commands.push(command);
optionsLog.push(options);
if (typeof execImpl === "function") return execImpl(command);
return { stdout: "", stderr: "", code: 0 };
},
});
return { api, commands, optionsLog };
}
test("extractSftpArchive runs a quoted remote tar command", async () => {
const { api, commands, optionsLog } = createExtractApi();
const result = await api.extractSftpArchive(null, {
sftpId: "sftp-1",
path: "/home/app/backup.tar.gz",
});
assert.deepEqual(result, { success: true });
assert.equal(commands.length, 1);
assert.match(commands[0], /tar -xzf '\/home\/app\/backup\.tar\.gz' -C '\/home\/app'/);
assert.equal(optionsLog[0].discardStdout, true);
});
test("extractSftpArchive rejects unsupported files", async () => {
const { api, commands } = createExtractApi();
await assert.rejects(
() => api.extractSftpArchive(null, { sftpId: "sftp-1", path: "/home/app/notes.txt" }),
/Unsupported archive type/,
);
assert.equal(commands.length, 0);
});
test("extractSftpArchive requires an open SFTP session", async () => {
const { api } = createExtractApi({ clients: new Map() });
await assert.rejects(
() => api.extractSftpArchive(null, { sftpId: "missing", path: "/tmp/a.zip" }),
/SFTP session not found/,
);
});
+6
View File
@@ -865,6 +865,9 @@ function createPreloadApi(ctx) {
chmodSftp: async (sftpId, path, mode, encoding) => {
return ipcRenderer.invoke("netcatty:sftp:chmod", { sftpId, path, mode, encoding });
},
extractSftpArchive: async (sftpId, path, encoding) => {
return ipcRenderer.invoke("netcatty:sftp:extract", { sftpId, path, encoding });
},
getSftpHomeDir: async (sftpId, encoding) => {
return ipcRenderer.invoke("netcatty:sftp:homeDir", { sftpId, encoding });
},
@@ -887,6 +890,9 @@ function createPreloadApi(ctx) {
renameLocalFile: async (oldPath, newPath) => {
return ipcRenderer.invoke("netcatty:local:rename", { oldPath, newPath });
},
extractLocalArchive: async (path) => {
return ipcRenderer.invoke("netcatty:local:extract", { path });
},
mkdirLocal: async (path) => {
return ipcRenderer.invoke("netcatty:local:mkdir", { path });
},
+3
View File
@@ -26,6 +26,8 @@ declare global {
/** No-follow remote metadata for conflict detection (symlink vs target). Missing path → null. */
lstatSftp?(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<SftpStatResult | null>;
chmodSftp?(sftpId: string, path: string, mode: string, encoding?: SftpFilenameEncoding): Promise<void>;
/** Extract a remote archive into its parent directory via SSH exec. */
extractSftpArchive?(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<{ success: boolean }>;
getSftpHomeDir?(sftpId: string, encoding?: SftpFilenameEncoding): Promise<{ success: boolean; homeDir?: string; error?: string }>;
// Transfer with progress
@@ -148,6 +150,7 @@ declare global {
writeLocalFile?(path: string, content: ArrayBuffer): Promise<void>;
deleteLocalFile?(path: string, expectedType?: SftpStatResult["type"]): Promise<void>;
renameLocalFile?(oldPath: string, newPath: string): Promise<void>;
extractLocalArchive?(path: string): Promise<{ success: boolean }>;
mkdirLocal?(path: string): Promise<void>;
statLocal?(path: string): Promise<SftpStatResult>;
/** No-follow local metadata for conflict detection (symlink vs target). */