mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 16:41:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fe69f6101 | ||
|
|
127f32822f | ||
|
|
927feb9c79 | ||
|
|
b8b180440e | ||
|
|
af37e01b8c | ||
|
|
9b7c16263c |
No files matched your search
@@ -11,6 +11,24 @@ test("isMissingStatError accepts only true path absence codes", () => {
|
||||
assert.equal(isMissingStatError(new Error("ENOENT")), true);
|
||||
});
|
||||
|
||||
test("isMissingStatError treats Electron-wrapped SFTP absence as missing", () => {
|
||||
// ipcRenderer.invoke strips custom `code` and wraps the ssh2 message.
|
||||
// New-file uploads lstat the destination first; this is the toast users see.
|
||||
assert.equal(
|
||||
isMissingStatError(
|
||||
new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: No such file"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(isMissingStatError(new Error("No such file")), true);
|
||||
assert.equal(isMissingStatError(new Error("No such file or directory")), true);
|
||||
assert.equal(isMissingStatError(new Error("No such file: /tmp/tool.sh")), true);
|
||||
assert.equal(
|
||||
isMissingStatError(new Error("ENOENT: no such file or directory, lstat '/tmp/tool.sh'")),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("isMissingStatError rejects unsupported LSTAT and other failures", () => {
|
||||
const enotsup = new Error("Remote server does not support LSTAT") as Error & {
|
||||
code: string;
|
||||
@@ -24,4 +42,25 @@ test("isMissingStatError rejects unsupported LSTAT and other failures", () => {
|
||||
eperm.code = "EPERM";
|
||||
assert.equal(isMissingStatError(eperm), false);
|
||||
assert.equal(isMissingStatError(new Error("channel closed")), false);
|
||||
assert.equal(
|
||||
isMissingStatError(
|
||||
new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: Permission denied"),
|
||||
),
|
||||
false,
|
||||
);
|
||||
// Path names are user-controlled; do not treat a substring hit as absence.
|
||||
assert.equal(
|
||||
isMissingStatError(
|
||||
new Error("EACCES: permission denied, lstat '/private/enoent/report.txt'"),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isMissingStatError(
|
||||
new Error(
|
||||
"Error invoking remote method 'netcatty:sftp:lstat': Error: EACCES: permission denied, lstat '/private/enoent/report.txt'",
|
||||
),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -18,12 +18,4 @@ export const isSessionError = (err: unknown): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
/** True absence only — ENOTSUP / unknown inspection must not map to "no conflict". */
|
||||
export const isMissingStatError = (error: unknown): boolean => {
|
||||
const code = (error as { code?: string | number } | null)?.code;
|
||||
return code === 2
|
||||
|| code === "ENOENT"
|
||||
|| code === "NO_SUCH_FILE"
|
||||
|| code === "SSH_FX_NO_SUCH_FILE"
|
||||
|| String((error as { message?: string } | null)?.message || "").trim() === "ENOENT";
|
||||
};
|
||||
export { isMissingStatError } from "../../../domain/sftpStatError";
|
||||
@@ -659,6 +659,39 @@ test("ENOENT from lstat still means absent destination for conflict check", asyn
|
||||
assert.equal(results[0].success, true);
|
||||
});
|
||||
|
||||
test("Electron-wrapped lstat absence still means absent destination", async () => {
|
||||
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
||||
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/usr/local/bin",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
lstatSftp: async () => {
|
||||
throw new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: No such file");
|
||||
},
|
||||
startStreamTransfer: async ({ targetPath: path }) => {
|
||||
uploadedPaths.push(path);
|
||||
return { transferId: "upload-1" };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => {
|
||||
throw new Error("absent destination must not prompt for conflict");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].success, true);
|
||||
});
|
||||
|
||||
test("compressed-upload LSTAT ENOTSUP is not treated as an absent destination", async (t) => {
|
||||
let compressedStarts = 0;
|
||||
installCompressedUploadBridge(t, {
|
||||
|
||||
@@ -161,6 +161,16 @@ export const SettingsSection: React.FC<{
|
||||
</section>
|
||||
);
|
||||
|
||||
/** Footer note under a SettingCard. Keep a gap so it does not kiss the card. */
|
||||
export const settingHintClassName = "mt-3 text-xs text-muted-foreground";
|
||||
|
||||
export const SettingHint: React.FC<{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className }) => (
|
||||
<p className={cn(settingHintClassName, className)}>{children}</p>
|
||||
);
|
||||
|
||||
export const settingCardClassName = "rounded-lg border bg-card";
|
||||
|
||||
interface SettingCardProps {
|
||||
|
||||
@@ -2,6 +2,20 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
test("system setting hints sit below their cards with a shared top gap", () => {
|
||||
const uiSource = readFileSync(new URL("./settings-ui.tsx", import.meta.url), "utf8");
|
||||
const systemSource = readFileSync(new URL("./tabs/SettingsSystemTab.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(uiSource, /export const settingHintClassName = "mt-3 text-xs text-muted-foreground"/);
|
||||
assert.match(uiSource, /export const SettingHint/);
|
||||
assert.match(systemSource, /SettingHint/);
|
||||
assert.match(systemSource, /settings\.system\.crashLogs\.hint/);
|
||||
assert.doesNotMatch(
|
||||
systemSource,
|
||||
/<p className="text-xs text-muted-foreground">\s*\{t\("settings\.system\.crashLogs\.hint"\)\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("settings search focus scrolls the content pane, not the window viewport", () => {
|
||||
const source = readFileSync(new URL("./settingsFocus.ts", import.meta.url), "utf8");
|
||||
const uiSource = readFileSync(new URL("./settings-ui.tsx", import.meta.url), "utf8");
|
||||
|
||||
@@ -11,7 +11,7 @@ import { SessionLogFormat, keyEventToString } from "../../../domain/models";
|
||||
import type { HttpNetworkProxyMode, HttpNetworkProxySettings } from "../../../domain/httpNetworkProxy";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
import { Toggle, Select, SettingRow, SectionHeader, SettingCard, SettingsAnchor, SettingsTabContent } from "../settings-ui";
|
||||
import { Toggle, Select, SettingRow, SectionHeader, SettingCard, SettingHint, SettingsAnchor, SettingsTabContent } from "../settings-ui";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
interface CrashLogFile {
|
||||
@@ -569,7 +569,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingHint>
|
||||
{updateState.lastCheckedAt && (
|
||||
<span>
|
||||
{t('settings.update.lastCheckedPrefix')}
|
||||
@@ -578,7 +578,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</span>
|
||||
)}
|
||||
{t('settings.update.hint')}
|
||||
</p>
|
||||
</SettingHint>
|
||||
|
||||
<SectionHeader title={t("settings.system.networkProxy.title")} />
|
||||
<SettingCard className="space-y-4 py-4">
|
||||
@@ -639,9 +639,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</>
|
||||
)}
|
||||
</SettingCard>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingHint>
|
||||
{t("settings.system.networkProxy.hint")}
|
||||
</p>
|
||||
</SettingHint>
|
||||
|
||||
<SectionHeader title={t("settings.system.credentials.title")} />
|
||||
<SettingsAnchor anchorId="system-credentials">
|
||||
@@ -832,9 +832,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
)}
|
||||
</SettingCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingHint>
|
||||
{t("settings.system.crashLogs.hint")}
|
||||
</p>
|
||||
</SettingHint>
|
||||
</SettingsAnchor>
|
||||
|
||||
<SectionHeader title={t("settings.system.tempDirectory")} />
|
||||
@@ -915,9 +915,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
)}
|
||||
</SettingCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingHint>
|
||||
{t("settings.system.tempDirectoryHint")}
|
||||
</p>
|
||||
</SettingHint>
|
||||
</SettingsAnchor>
|
||||
|
||||
<SectionHeader title={t("settings.sessionRestore.title")} />
|
||||
@@ -1068,9 +1068,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingHint>
|
||||
{t("settings.sessionLogs.hint")}
|
||||
</p>
|
||||
</SettingHint>
|
||||
|
||||
<SectionHeader title={t('settings.sshDeepLink.title')} />
|
||||
<SettingCard>
|
||||
@@ -1183,9 +1183,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingHint>
|
||||
{t("settings.sshDebugLogs.hint")}
|
||||
</p>
|
||||
</SettingHint>
|
||||
|
||||
<SectionHeader title={t("settings.globalHotkey.title")} />
|
||||
<SettingCard className="space-y-4 py-4">
|
||||
@@ -1258,9 +1258,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingHint>
|
||||
{t("settings.globalHotkey.hint")}
|
||||
</p>
|
||||
</SettingHint>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isMissingStatError } from "./sftpStatError";
|
||||
|
||||
test("isMissingStatError recognizes ssh2 and Electron-wrapped absence", () => {
|
||||
assert.equal(isMissingStatError(new Error("No such file")), true);
|
||||
assert.equal(
|
||||
isMissingStatError(
|
||||
new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: No such file"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("isMissingStatError does not treat path substrings as absence", () => {
|
||||
assert.equal(
|
||||
isMissingStatError(
|
||||
new Error("EACCES: permission denied, lstat '/private/enoent/report.txt'"),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
const ELECTRON_INVOKE_PREFIX = /^error invoking remote method '[^']+': error:\s*/i;
|
||||
|
||||
/** ssh2 / Node / SCP absence kinds. Must not search the whole message: paths are user-controlled. */
|
||||
const ABSENCE_KIND = /^(?:enoent|no such file(?: or directory)?|no_such_file|ssh_fx_no_such_file)(?:\b|:|$)/;
|
||||
|
||||
const unwrapStatErrorMessage = (message: string): string =>
|
||||
message.replace(ELECTRON_INVOKE_PREFIX, "").trim();
|
||||
|
||||
/** True absence only — ENOTSUP / unknown inspection must not map to "no conflict". */
|
||||
export const isMissingStatError = (error: unknown): boolean => {
|
||||
const code = (error as { code?: string | number } | null)?.code;
|
||||
if (
|
||||
code === 2
|
||||
|| code === "ENOENT"
|
||||
|| code === "NO_SUCH_FILE"
|
||||
|| code === "SSH_FX_NO_SUCH_FILE"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = String((error as { message?: string } | null)?.message || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!message) return false;
|
||||
|
||||
// ssh2 StatusCodeError is "No such file". ipcRenderer.invoke strips `code`
|
||||
// and wraps it as:
|
||||
// Error invoking remote method 'netcatty:sftp:lstat': Error: No such file
|
||||
return ABSENCE_KIND.test(unwrapStatErrorMessage(message));
|
||||
};
|
||||
@@ -27,6 +27,15 @@ function createSftpReadTimeoutError(timeoutMs) {
|
||||
return error;
|
||||
}
|
||||
|
||||
/** ssh2 / SCP absence — classify here so IPC cannot strip `code`. */
|
||||
function isMissingRemotePathError(error) {
|
||||
const code = error?.code;
|
||||
return code === 2
|
||||
|| code === "ENOENT"
|
||||
|| code === "NO_SUCH_FILE"
|
||||
|| code === "SSH_FX_NO_SUCH_FILE";
|
||||
}
|
||||
|
||||
async function runBoundedSftpMemoryRead(payload, operation) {
|
||||
const requestedTimeout = Number(payload?.timeoutMs);
|
||||
const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0
|
||||
@@ -923,15 +932,20 @@ function createFileOpsApi(ctx) {
|
||||
if (isScpModeClient(client)) {
|
||||
// SCP shell stat already reports the link node (no follow).
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const st = await getScpBackendForClient(client).stat(payload.path, {
|
||||
encoding,
|
||||
signal: payload?.abortSignal || null,
|
||||
});
|
||||
return formatSftpStatResult(
|
||||
payload.path,
|
||||
st,
|
||||
st.mode ? (st.mode & 0o777).toString(8) : st.permissions,
|
||||
);
|
||||
try {
|
||||
const st = await getScpBackendForClient(client).stat(payload.path, {
|
||||
encoding,
|
||||
signal: payload?.abortSignal || null,
|
||||
});
|
||||
return formatSftpStatResult(
|
||||
payload.path,
|
||||
st,
|
||||
st.mode ? (st.mode & 0o777).toString(8) : st.permissions,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isMissingRemotePathError(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const sftp = await requireSftpChannel(client);
|
||||
@@ -949,6 +963,9 @@ function createFileOpsApi(ctx) {
|
||||
try {
|
||||
attrs = await lstatAsync(sftp, encodedPath);
|
||||
} catch (error) {
|
||||
// Return null before throw so ipcRenderer.invoke cannot strip code 2
|
||||
// and leave a localized "File not found" as a hard upload error.
|
||||
if (isMissingRemotePathError(error)) return null;
|
||||
const code = error?.code;
|
||||
const lstatUnsupported = code === 8
|
||||
|| code === "ENOTSUP"
|
||||
|
||||
@@ -212,6 +212,51 @@ test("lstatSftp refuses followed STAT when LSTAT is unsupported", async () => {
|
||||
assert.equal(statCalls, 0, "must not classify via followed STAT");
|
||||
});
|
||||
|
||||
test("lstatSftp returns null for SSH_FX_NO_SUCH_FILE even with a localized message", async () => {
|
||||
const channel = { lstat() {} };
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
|
||||
requireSftpChannel: async () => channel,
|
||||
resolveEncodingForRequest: () => "utf-8",
|
||||
encodePath: (remotePath) => remotePath,
|
||||
lstatAsync: async () => {
|
||||
const error = new Error("File not found");
|
||||
error.code = 2;
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.lstatSftp(null, {
|
||||
sftpId: "sftp-1",
|
||||
path: "/usr/local/bin/new-file.sh",
|
||||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("lstatSftp still throws permission errors on an existing path", async () => {
|
||||
const channel = { lstat() {} };
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
|
||||
requireSftpChannel: async () => channel,
|
||||
resolveEncodingForRequest: () => "utf-8",
|
||||
encodePath: (remotePath) => remotePath,
|
||||
lstatAsync: async () => {
|
||||
const error = new Error("Permission denied");
|
||||
error.code = "EACCES";
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => api.lstatSftp(null, { sftpId: "sftp-1", path: "/root/secret" }),
|
||||
(error) => {
|
||||
assert.equal(error.code, "EACCES");
|
||||
assert.match(String(error.message), /Permission denied/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("lstatSftp refuses a channel without native LSTAT", async () => {
|
||||
const channel = { stat() {} };
|
||||
let lstatCalls = 0;
|
||||
|
||||
+1
-10
@@ -15,6 +15,7 @@ import {
|
||||
describeSftpIncomingKind,
|
||||
getSftpConflictTypeKey,
|
||||
} from "../domain/sftpConflict";
|
||||
import { isMissingStatError } from "../domain/sftpStatError";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -37,16 +38,6 @@ import type { UploadBridge, UploadCallbacks, UploadConfig, UploadResult } from "
|
||||
const formatUploadError = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
/** Only true absence may map to "no conflict"; ENOTSUP/etc. must not. */
|
||||
const isMissingStatError = (error: unknown): boolean => {
|
||||
const code = (error as { code?: string | number } | null)?.code;
|
||||
return code === 2
|
||||
|| code === "ENOENT"
|
||||
|| code === "NO_SUCH_FILE"
|
||||
|| code === "SSH_FX_NO_SUCH_FILE"
|
||||
|| String((error as { message?: string } | null)?.message || "").trim() === "ENOENT";
|
||||
};
|
||||
|
||||
const getDropEntrySize = (entry: DropEntry): number => (
|
||||
entry.isDirectory ? 0 : entry.file?.size ?? entry.size ?? 0
|
||||
);
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@ declare global {
|
||||
): Promise<void>;
|
||||
renameSftp?(sftpId: string, oldPath: string, newPath: string, encoding?: SftpFilenameEncoding): Promise<void>;
|
||||
statSftp?(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<SftpStatResult>;
|
||||
/** No-follow remote metadata for conflict detection (symlink vs target). */
|
||||
lstatSftp?(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<SftpStatResult>;
|
||||
/** 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>;
|
||||
getSftpHomeDir?(sftpId: string, encoding?: SftpFilenameEncoding): Promise<{ success: boolean; homeDir?: string; error?: string }>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user