Compare commits

..
Author SHA1 Message Date
bincxz 8fe69f6101 fix(sftp): return null from lstat when the remote path is absent
ipcRenderer.invoke strips ssh2's status code. A server that says
"File not found" instead of "No such file" still uses code 2, so
classify absence in the main-process handler and return null.
2026-08-14 20:29:48 +08:00
bincxz 127f32822f fix(sftp): match absence-shaped lstat messages only
Substring search could treat a permission error as missing if the
path contained "enoent". Unwrap the Electron invoke prefix and match
the known ssh2/Node absence kind at the start of that message.
2026-08-14 20:23:24 +08:00
bincxz 927feb9c79 fix(sftp): treat IPC-wrapped lstat absence as no conflict
After #2955, upload conflict checks lstat the destination first.
New files throw ssh2's "No such file"; Electron invoke strips `code`
and wraps the message, so isMissingStatError treated absence as a
hard error and every new upload toasted.
2026-08-14 20:20:04 +08:00
陈大猫 b8b180440e Merge pull request #2990 from binaricat/fix/settings-system-hint-spacing
fix(settings): give system footer hints space under cards
2026-08-14 20:13:46 +08:00
bincxz af37e01b8c fix(settings): give system footer hints space under cards
Crash-log and other system notes sat flush against the card when they
were wrapped with the card in SettingsAnchor. Use a shared SettingHint
with a top gap for every system-page footer note.
2026-08-14 20:12:57 +08:00
陈大猫 9b7c16263c Merge pull request #2989 from binaricat/fix/connection-progress-rewind
fix(terminal): keep connection progress from rewinding
2026-08-14 20:08:25 +08:00
12 changed files with 238 additions and 45 deletions

No files matched your search

+39
View File
@@ -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,
);
});
+1 -9
View File
@@ -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";
+33
View File
@@ -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, {
+10
View File
@@ -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 {
+14
View File
@@ -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");
+15 -15
View File
@@ -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>
);
};
+22
View File
@@ -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,
);
});
+30
View File
@@ -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));
};
+26 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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 }>;