mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1dba3c7b2 | ||
|
|
c898347637 | ||
|
|
7b5f4df028 | ||
|
|
14da4dce9c | ||
|
|
df64b9be2b |
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Копировать текущий путь',
|
||||
|
||||
@@ -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': '复制当前路径',
|
||||
|
||||
@@ -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': '複製目前路徑',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user