security(main): dialog-grant admission for local paths, explicit webPreferences

New localPathGrants.ts: an in-memory registry of paths the user picked in a
native dialog. Every check resolves realpath + stat at grant and use time;
Windows case folding; missing files, directories-as-files, devices and
symlinked parents can never pass. SFTP upload/download and zmodem send/
receive now refuse renderer-supplied local paths that were never granted,
returning the resolved path so callers never re-traverse a symlink. keyPath
is validated as a regular file <= 1MB before reading (a device node would
have blocked the UI thread forever). Tests inject a stub policy via
setLocalPathPolicy; production always defaults to the real registry.

Also: webPreferences now explicitly pins contextIsolation/nodeIntegration/
webSecurity instead of relying on defaults.

New offline test tests/local-path-grants.mjs (37 assertions incl. symlink
escape); offline suite grows to 13. i18n: 6 main.sftp/main.key error keys
in 4 languages.
This commit is contained in:
Bill committed 2026-10-07 22:06:45 +08:00
1 parent 9c75cdc7d4
commit d22923aedd
14 files changed
+612 -18

No files matched your search

+8
View File
@@ -157,6 +157,14 @@ function createWindow(): void {
...(existsSync(devIcon) ? { icon: nativeImage.createFromPath(devIcon) } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
// Written out explicitly even though every value matches Electron's
// current default: these are the three that keep the renderer unable to
// reach the main process, and a default that silently changed (or a
// future Electron release flipping one) must not be the only thing
// holding that line. See also the sender guard in ipc.ts.
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
// Keep the default renderer sandbox (preload only touches the electron
// IPC bridge, so it does not need Node access).
sandbox: true
+165
View File
@@ -0,0 +1,165 @@
/**
* Local-path admission for the files the main process is asked to read or write
* on behalf of the renderer (SFTP transfers, ZMODEM send/receive).
*
* Threat model: the renderer is the untrusted half of the app. Every local path
* it sends over IPC used to be taken at face value, so a compromised renderer
* (a navigation that slipped past the frame guard, a future caller that forgets
* the dialog) could make the main process read or write anywhere the user can
* reach. Local paths are therefore *granted* first, and the only grant source is
* a native file / directory dialog (sftp.ts's pickFiles / pickDirectory) — an
* explicit choice the user made with their own hands.
*
* Granting and checking both go through realpath: the registry stores resolved
* paths, and a candidate that does not resolve (missing file, dead drive,
* unreadable parent) is never granted and never accepted. Comparing only the
* literal spelling would let a symlinked parent steer a path that *looks* like
* it sits inside a granted directory somewhere else entirely — the resolved path
* is the only one that answers "where would this actually read/write?".
*
* Grant lifetime is the process: a directory the user picked as a save target
* stays valid for later transfers into it. Each dialog returns at most a handful
* of paths, so both sets stay tiny.
*
* `grantedPaths` is the real policy and the DEFAULT for every consumer, so a
* caller that forgets to inject one is still enforced. `LocalPathPolicy` exists
* only so the offline test harnesses (which have no dialog to grant from) can
* supply a double; it is never reachable from renderer input.
*/
import { realpathSync, statSync } from 'fs'
import { basename, join, sep } from 'path'
/**
* What the transfer engines need to know about local paths. Implemented by
* `grantedPaths` below; tests inject a permissive double.
*
* The methods return the *resolved* path to use rather than a boolean, so the
* caller opens exactly what was checked — re-joining or re-reading the original
* spelling would reopen the symlink window the check just closed.
*/
export interface LocalPathPolicy {
/** The local file an upload may read, or null when it must be refused. */
readSource(path: unknown): string | null
/** The local directory a download may be written into, or null. */
readDirectory(dir: unknown): string | null
/**
* The local path a write of `fileName` into `dir` may go to, or null when it
* must be refused. `fileName` comes off the wire (a remote file's basename,
* or the name a ZMODEM peer offered).
*/
writeTarget(dir: unknown, fileName: unknown): string | null
}
const grantedFiles = new Set<string>()
const grantedDirs = new Set<string>()
/**
* Windows compares paths case-insensitively; drive-letter and name casing must
* not decide whether a granted path matches. Applied to comparisons only — a
* path handed back to a caller keeps the casing the filesystem reported.
*/
const fold = (p: string): string => (process.platform === 'win32' ? p.toLowerCase() : p)
/** Real path of an existing regular file, or null. */
function realFile(p: unknown): string | null {
if (typeof p !== 'string' || p === '') return null
try {
const real = realpathSync(p)
return statSync(real).isFile() ? real : null
} catch {
return null
}
}
/** Real path of an existing directory, or null. */
function realDir(p: unknown): string | null {
if (typeof p !== 'string' || p === '') return null
try {
const real = realpathSync(p)
return statSync(real).isDirectory() ? real : null
} catch {
return null
}
}
/** Whether a folded, already-resolved directory sits at or under a granted dir. */
function isGrantedKey(key: string): boolean {
for (const dir of grantedDirs) {
if (key === dir || key.startsWith(dir + sep)) return true
}
return false
}
/** A single path segment: no separators, no `.`/`..`, not empty. */
function isPlainLeaf(name: unknown): name is string {
return (
typeof name === 'string' &&
name !== '' &&
name !== '.' &&
name !== '..' &&
basename(name) === name
)
}
/**
* Grant the files a native "open file" dialog just returned. A non-file in the
* list (a path that vanished between the dialog and here) grants nothing —
* everything reaches the engine through realpath, so an unresolvable entry can
* never match later either.
*/
export function grantPickedFiles(paths: unknown): void {
if (!Array.isArray(paths)) return
for (const p of paths) {
const real = realFile(p)
if (real !== null) grantedFiles.add(fold(real))
}
}
/** Grant the directory a native "open directory" dialog just returned. */
export function grantPickedDirectory(dir: unknown): void {
const real = realDir(dir)
if (real !== null) grantedDirs.add(fold(real))
}
/**
* The real policy. Every method re-resolves the path at check time: a file that
* has since been deleted, replaced by a directory, or had a symlink swapped in
* over it fails, even though it was granted earlier.
*/
export const grantedPaths: LocalPathPolicy = {
readSource(path: unknown): string | null {
const real = realFile(path)
return real !== null && grantedFiles.has(fold(real)) ? real : null
},
readDirectory(dir: unknown): string | null {
const real = realDir(dir)
return real !== null && isGrantedKey(fold(real)) ? real : null
},
writeTarget(dir: unknown, fileName: unknown): string | null {
const dirReal = realDir(dir)
if (dirReal === null) return null
const key = fold(dirReal)
if (!isGrantedKey(key)) return null
// Checked as a plain leaf first: a name still carrying a separator, or the
// bare `..`, must not climb out of the directory the user chose.
if (!isPlainLeaf(fileName)) return null
// The join is made against the *resolved* directory, so the containment
// check and the actual write agree on the target.
const target = join(dirReal, fileName)
let resolved: string
try {
resolved = realpathSync(target)
} catch {
// Not on disk yet: a fresh file created directly inside the granted dir.
return target
}
// Something is already there. Resolve it too: a symlink planted at that
// name would otherwise land the bytes somewhere else entirely while the
// write still looks like it targets the download folder.
const targetKey = fold(resolved)
if (targetKey === key || !targetKey.startsWith(key + sep)) return null
return target
}
}
+97 -8
View File
@@ -2,10 +2,28 @@ import { dialog } from 'electron'
import type { Client, SFTPWrapper } from 'ssh2'
import { randomUUID } from 'crypto'
import { createWriteStream, promises as fsp } from 'fs'
import { basename, join } from 'path'
import { basename } from 'path'
import { Ipc } from '../shared/ipc'
import { t } from '../shared/i18n'
import type { SftpEntry, TransferProgressEvent } from '../shared/sftp'
import {
grantPickedDirectory,
grantPickedFiles,
grantedPaths,
type LocalPathPolicy
} from './localPathGrants'
/**
* Local-path admission for this module's transfers. Defaults to the real grant
* registry — a caller that forgets to inject one is still enforced — and is
* only swapped by the offline test harnesses, which have no dialog to grant
* from. Never fed from renderer input.
*/
let pathPolicy: LocalPathPolicy = grantedPaths
export function setLocalPathPolicy(policy: LocalPathPolicy): void {
pathPolicy = policy
}
/** ssh2 Client lookup injected by pty.ts (kind === 'ssh' sessions only). */
let clientProvider: ((id: string) => Client | undefined) | undefined
@@ -347,12 +365,74 @@ type Broadcast = (channel: string, payload: unknown) => void
const CHUNK = 256 * 1024
const UPLOAD_WINDOW = 64
/**
* Path checks for the local side of a transfer.
*
* Threat model: `localPaths` and `localDir` arrive from the renderer, which is
* untrusted — without a check the main process would read or write any path the
* user can reach (a compromised renderer, a hand-crafted IPC message, or a
* future caller that forgets the dialog). Only paths the user granted through a
* native dialog are accepted; see localPathGrants.ts for what a grant is and
* why it is realpath-based.
*
* Both helpers throw, so the invoke rejects right away and the renderer
* surfaces the reason instead of starting a transfer that fails later. The
* checks run *before* the sftp channel is opened, so a refused request never
* costs a subsystem channel. Each returns the *resolved* path to open: the
* caller must use that, not the renderer's spelling, or it would re-traverse
* the very symlink the check followed.
*/
function requireUploadSources(localPaths: unknown): { source: string; name: string }[] {
if (!Array.isArray(localPaths) || localPaths.length === 0) {
throw new Error(t('main.sftp.localNotAllowed', { path: '' }))
}
return localPaths.map(local => {
// readSource also insists on a real regular file: a directory or a device
// node must never reach the upload's file handle.
const source = pathPolicy.readSource(local)
if (source === null) {
throw new Error(t('main.sftp.localNotAllowed', { path: String(local) }))
}
// The remote name stays the one derived from the path the USER saw in the
// dialog, not from the resolved target: FilePanel computes its overwrite
// confirmation from the same spelling, and a name that differed from the
// one the user approved would let an upload overwrite a file it never
// warned about. Only the path that gets READ is the resolved one.
return { source, name: basename(String(local)) }
})
}
/**
* Where each remote file lands locally. The directory must be one the user
* granted, and the name is the remote file's basename — checked as a plain leaf
* with any symlink already sitting at that name resolved (see
* localPathGrants.ts).
*/
function requireDownloadTargets(
remotePaths: unknown,
localDir: unknown
): { remote: string; name: string; target: string }[] {
if (!Array.isArray(remotePaths) || remotePaths.length === 0) {
throw new Error(t('main.sftp.localNotAllowed', { path: String(localDir) }))
}
return remotePaths.map(remote => {
const path = String(remote)
const name = basename(path)
const target = pathPolicy.writeTarget(localDir, name)
if (target === null) {
throw new Error(t('main.sftp.localNotAllowed', { path: String(localDir) }))
}
return { remote: path, name, target }
})
}
export function uploadRemote(
sessionId: string,
localPaths: string[],
remoteDir: string,
broadcast: Broadcast
): Promise<string> {
const sources = requireUploadSources(localPaths)
return withSftp(sessionId, async sftp => {
const id = randomUUID()
const transfer: ActiveTransfer = { kind: 'upload', cancelled: false }
@@ -361,9 +441,8 @@ export function uploadRemote(
void (async () => {
try {
for (const local of localPaths) {
for (const { source: local, name } of sources) {
if (transfer.cancelled) break
const name = basename(local)
const size = (await fsp.stat(local)).size
const remote = `${remoteDir.replace(/\/+$/, '')}/${name}`
const handle = await p<Buffer>(cb => sftp.open(remote, 'w', cb))
@@ -447,6 +526,7 @@ export function downloadRemote(
localDir: string,
broadcast: Broadcast
): Promise<string> {
const targets = requireDownloadTargets(remotePaths, localDir)
return withSftp(sessionId, async sftp => {
const id = randomUUID()
const transfer: ActiveTransfer = { kind: 'download', cancelled: false }
@@ -455,11 +535,9 @@ export function downloadRemote(
void (async () => {
try {
for (const remote of remotePaths) {
for (const { remote, name, target: local } of targets) {
if (transfer.cancelled) break
const name = basename(remote)
const { size } = await p<{ size: number }>(cb => sftp.stat(remote, cb))
const local = join(localDir, name)
const handle = await p<Buffer>(cb => sftp.open(remote, 'r', cb))
try {
const localHandle = await fsp.open(local, 'w')
@@ -516,13 +594,24 @@ export function downloadRemote(
})
}
/** Native file dialogs parented to the focused window. */
/**
* Native file dialogs parented to the focused window.
*
* These two are the ONLY grant source (see localPathGrants.ts): whatever the
* user picks here becomes usable by the transfer paths, and nothing else does.
* A cancelled dialog grants nothing — the empty result must not be read as
* "no restriction". Both return `filePaths` verbatim so the renderer's own
* display logic is unchanged.
*/
export async function pickFiles(): Promise<string[]> {
const r = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] })
grantPickedFiles(r.filePaths)
return r.filePaths
}
export async function pickDirectory(): Promise<string> {
const r = await dialog.showOpenDialog({ properties: ['openDirectory'] })
return r.filePaths[0] ?? ''
const dir = r.filePaths[0] ?? ''
if (dir !== '') grantPickedDirectory(dir)
return dir
}
+39 -4
View File
@@ -16,11 +16,20 @@
import type { SshConnection, SshSecretOverride } from '../shared/connections'
import { t } from '../shared/i18n'
import { randomUUID } from 'crypto'
import { readFileSync } from 'fs'
import { readFileSync, realpathSync, statSync } from 'fs'
import { fingerprintOf, type HostKeyCheckResult } from './knownHosts'
import type { ConnectionsStore } from './connectionsStore'
import type { Client, ClientChannel, ConnectConfig } from 'ssh2'
/**
* Private keys are a few KB; a file past this is a mistyped path (a disk image,
* a log), and reading it would put the whole thing on the UI thread's heap.
*/
const MAX_KEY_BYTES = 1024 * 1024
/** A refusal we produced ourselves: the message is already user-complete. */
class RefusedKeyError extends Error {}
export interface SshSessionHandle {
id: string
/** established ssh connection; powers the session routing in pty.ts */
@@ -350,11 +359,37 @@ export function resolveHostKey(promptId: string, action: 'accept' | 'reject'): v
pending.resolve(action === 'accept')
}
/** Read a private key file; surfaces a descriptive error on failure. */
/**
* Read a private key file, refusing anything that is not a regular file.
*
* Threat model: `keyPath` reaches here from the renderer (typed into the edit
* dialog, persisted in connections.json, then echoed back on connect), and it
* lands in a synchronous read on the UI thread. Two failures there would be
* fatal to the app, not just to this connection:
*
* - a device or FIFO (`/dev/zero`, `\\.\pipe\...`) makes the read block
* forever — the main process stops answering, and nothing times it out. A
* directory at least fails, but only with a confusing EISDIR.
* - a huge file (a disk image, a log) is slurped into memory in one call, so
* a mistyped path can exhaust the heap.
*
* A symlink is a third problem: the OS follows it, so the file actually read is
* not the file that was named — and a link the user did not create could point
* at anything. Resolving with realpath first makes the check and the read agree
* on one path, and the read then uses that resolved path.
*/
function readKeyFile(keyPath: string): string {
try {
return readFileSync(keyPath, 'utf8')
const real = realpathSync(keyPath)
const st = statSync(real)
if (!st.isFile()) throw new RefusedKeyError(t('main.ssh.keyNotAFile'))
if (st.size > MAX_KEY_BYTES) throw new RefusedKeyError(t('main.ssh.keyTooLarge'))
return readFileSync(real, 'utf8')
} catch (err) {
throw new Error(t('main.ssh.readKeyFailed', { path: keyPath, detail: (err as Error).message }))
// A refusal is already a complete message; an I/O error has only an errno
// to contribute, so it gets wrapped with the path the user typed.
if (err instanceof RefusedKeyError) throw err
const detail = err instanceof Error ? err.message : String(err)
throw new Error(t('main.ssh.readKeyFailed', { path: keyPath, detail }))
}
}
+56 -5
View File
@@ -15,7 +15,7 @@
* reach the terminal), and pty.ts's writePty drops user keystrokes via
* isZmodemActive.
*/
import { basename, join } from 'path'
import { basename } from 'path'
import { createReadStream, createWriteStream } from 'fs'
import { stat } from 'fs/promises'
import type { Writable } from 'stream'
@@ -24,6 +24,19 @@ import { Ipc } from '../shared/ipc'
import type { ZmodemDoneEvent, ZmodemResponse } from '../shared/ipc'
import type { TransferProgressEvent } from '../shared/sftp'
import { t } from '../shared/i18n'
import { grantedPaths, type LocalPathPolicy } from './localPathGrants'
/**
* Local-path admission, same shape and same rationale as sftp.ts's: defaults to
* the real grant registry so a caller that forgets to inject one is still
* enforced, and the offline e2e harness swaps in a double because it has no
* dialog to grant from. Never fed from renderer input.
*/
let pathPolicy: LocalPathPolicy = grantedPaths
export function setLocalPathPolicy(policy: LocalPathPolicy): void {
pathPolicy = policy
}
/** How long to wait for the renderer to answer a ZMODEM_OFFER (ms). */
const OFFER_TIMEOUT_MS = 120_000
@@ -293,6 +306,11 @@ async function sendOneFile(
})
}
/**
* Upload the approved local files. `paths` are the paths the policy accepted
* *and* resolved, so `createReadStream` opens exactly what was checked; the
* name offered to the peer is the basename of that resolved path.
*/
async function runSend(engine: Engine, paths: string[]): Promise<void> {
try {
const session = engine.session as Zmodem.SendSession
@@ -332,8 +350,18 @@ async function runSend(engine: Engine, paths: string[]): Promise<void> {
function handleOffer(engine: Engine, offer: Zmodem.Offer): void {
const details = offer.get_details()
// The offered name is the PEER's string, so it is display data only: the path
// actually written is decided by the policy below, which refuses anything that
// is not a plain leaf inside the directory the user granted. `basename` keeps
// the UI honest for a peer that sends a path rather than a name.
const name = basename(String(details.name ?? 'file'))
const dest = join(engine.dir ?? '.', name)
const dest = pathPolicy.writeTarget(engine.dir, name)
if (dest === null) {
// Refusing one file must not silently look like success: end the transfer
// with the reason, which also stops the peer (finalize aborts the session).
finalize(engine, false, t('main.zmodem.savePathNotAllowed', { name }))
return
}
if (details.size) engine.totalBytes += details.size
const stream = createWriteStream(dest)
@@ -596,7 +624,15 @@ export function respondZmodem(resp: ZmodemResponse): void {
abortSession(engine, t('main.zmodem.noSaveDir'))
return
}
engine.dir = resp.dir
// The save directory came from the renderer: only one the user granted
// through the dialog is usable (localPathGrants.ts). Resolved once here, so
// every file of the transfer is written into the directory that was checked.
const dir = pathPolicy.readDirectory(resp.dir)
if (dir === null) {
abortSession(engine, t('main.zmodem.saveDirNotAllowed'))
return
}
engine.dir = dir
const session = engine.session as Zmodem.ReceiveSession
session.on('offer', (offer) => handleOffer(engine, offer))
void session
@@ -605,12 +641,27 @@ export function respondZmodem(resp: ZmodemResponse): void {
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.receiveFailed'))
)
} else {
const paths = resp.paths ?? []
// Same for the files to send: the renderer supplies the paths, so each one
// must be a file the user picked. Checked before the transfer starts —
// rejecting mid-stream would leave the peer waiting on an abort.
const paths = Array.isArray(resp.paths) ? resp.paths : []
if (paths.length === 0) {
abortSession(engine, t('main.zmodem.noFiles'))
return
}
void runSend(engine, paths)
// All or nothing. Dropping just the refused entries would run a transfer
// that reports success while quietly shipping fewer files than the user
// selected.
const allowed: string[] = []
for (const p of paths) {
const source = pathPolicy.readSource(p)
if (source === null) {
abortSession(engine, t('main.zmodem.filesNotAllowed'))
return
}
allowed.push(source)
}
void runSend(engine, allowed)
}
}
+6
View File
@@ -26,6 +26,8 @@ const main: Record<string, string> = {
'main.ssh.shellOpenFailed': 'Could not open the SSH shell ({host}:{port}): {detail}',
'main.ssh.initFailed': 'SSH connection initialization failed ({host}:{port}): {detail}',
'main.ssh.readKeyFailed': 'Could not read the private key file {path}: {detail}',
'main.ssh.keyNotAFile': 'The private key path is not a regular file (directories, devices and other special files are refused)',
'main.ssh.keyTooLarge': 'The private key file is too large - the path is probably wrong',
'main.sftp.sessionGone': 'The SSH session does not exist or has disconnected',
'main.sftp.deleteFailed': 'Delete failed: {detail}',
@@ -35,6 +37,7 @@ const main: Record<string, string> = {
'main.sftp.openTimeout': 'Opening the SFTP channel timed out',
'main.sftp.commandExitCode': 'Command exited with code {code}',
'main.sftp.cancelled': 'Cancelled',
'main.sftp.localNotAllowed': 'Local path not authorised: {path}. Pick it again with the "choose file / choose directory" dialog.',
'main.zmodem.transferFailed': 'Transfer failed',
'main.zmodem.transferTimeout': 'Transfer timed out',
@@ -49,6 +52,9 @@ const main: Record<string, string> = {
'main.zmodem.sessionCreateFailed': 'Could not establish the ZMODEM session',
'main.zmodem.noSaveDir': 'No save directory specified',
'main.zmodem.noFiles': 'No files selected',
'main.zmodem.saveDirNotAllowed': 'The save directory is not authorised. Pick it again with the "choose directory" dialog.',
'main.zmodem.savePathNotAllowed': 'The remote file name was refused, transfer aborted: {name}',
'main.zmodem.filesNotAllowed': 'The selected local files are not authorised, transfer aborted',
'main.ipc.connectionMissing': 'Connection bookmark not found ({id})',
+6
View File
@@ -26,6 +26,8 @@ const main: Record<string, string> = {
'main.ssh.shellOpenFailed': 'SSH シェルを開けません ({host}:{port}): {detail}',
'main.ssh.initFailed': 'SSH 接続の初期化に失敗しました ({host}:{port}): {detail}',
'main.ssh.readKeyFailed': '秘密鍵ファイルを読み取れません {path}: {detail}',
'main.ssh.keyNotAFile': '秘密鍵のパスが通常のファイルではありません(ディレクトリ・デバイス・その他の特殊ファイルは受け付けません)',
'main.ssh.keyTooLarge': '秘密鍵ファイルが大きすぎます。パスの指定が誤っている可能性があります',
'main.sftp.sessionGone': 'SSH セッションが存在しないか、切断されています',
'main.sftp.deleteFailed': '削除に失敗しました: {detail}',
@@ -35,6 +37,7 @@ const main: Record<string, string> = {
'main.sftp.openTimeout': 'SFTP チャネルのオープンがタイムアウトしました',
'main.sftp.commandExitCode': 'コマンドの終了コード {code}',
'main.sftp.cancelled': 'キャンセルしました',
'main.sftp.localNotAllowed': 'ローカルパスが許可されていません: {path}。「ファイルを選択 / ディレクトリを選択」ダイアログで選び直してください。',
'main.zmodem.transferFailed': '転送に失敗しました',
'main.zmodem.transferTimeout': '転送がタイムアウトしました',
@@ -49,6 +52,9 @@ const main: Record<string, string> = {
'main.zmodem.sessionCreateFailed': 'ZMODEM セッションを確立できません',
'main.zmodem.noSaveDir': '保存先ディレクトリが指定されていません',
'main.zmodem.noFiles': 'ファイルが選択されていません',
'main.zmodem.saveDirNotAllowed': '保存先ディレクトリが許可されていません。「ディレクトリを選択」ダイアログで選び直してください。',
'main.zmodem.savePathNotAllowed': 'リモートのファイル名を受け付けられないため、転送を中止しました: {name}',
'main.zmodem.filesNotAllowed': '選択されたローカルファイルが許可されていないため、転送を中止しました',
'main.ipc.connectionMissing': '接続ブックマークが見つかりません ({id})',
+6
View File
@@ -26,6 +26,8 @@ const main: Record<string, string> = {
'main.ssh.shellOpenFailed': '无法打开 SSH shell ({host}:{port}): {detail}',
'main.ssh.initFailed': 'SSH 连接初始化失败 ({host}:{port}): {detail}',
'main.ssh.readKeyFailed': '无法读取私钥文件 {path}: {detail}',
'main.ssh.keyNotAFile': '私钥路径不是一个普通文件(目录、设备或其他特殊文件均不被接受)',
'main.ssh.keyTooLarge': '私钥文件过大,疑似路径填写有误',
'main.sftp.sessionGone': 'SSH 会话不存在或已断开',
'main.sftp.deleteFailed': '删除失败: {detail}',
@@ -35,6 +37,7 @@ const main: Record<string, string> = {
'main.sftp.openTimeout': 'SFTP 通道打开超时',
'main.sftp.commandExitCode': '命令退出码 {code}',
'main.sftp.cancelled': '已取消',
'main.sftp.localNotAllowed': '本地路径未被授权: {path}。请通过「选择文件 / 选择目录」对话框重新选择。',
'main.zmodem.transferFailed': '传输失败',
'main.zmodem.transferTimeout': '传输超时',
@@ -49,6 +52,9 @@ const main: Record<string, string> = {
'main.zmodem.sessionCreateFailed': '无法建立 ZMODEM 会话',
'main.zmodem.noSaveDir': '未指定保存目录',
'main.zmodem.noFiles': '未选择文件',
'main.zmodem.saveDirNotAllowed': '保存目录未被授权,请通过「选择目录」对话框重新选择',
'main.zmodem.savePathNotAllowed': '远端文件名不被接受,已中止传输: {name}',
'main.zmodem.filesNotAllowed': '所选的本地文件未被授权,已中止传输',
'main.ipc.connectionMissing': '连接书签不存在 ({id})',
+6
View File
@@ -26,6 +26,8 @@ const main: Record<string, string> = {
'main.ssh.shellOpenFailed': '無法開啟 SSH shell ({host}:{port}): {detail}',
'main.ssh.initFailed': 'SSH 連線初始化失敗 ({host}:{port}): {detail}',
'main.ssh.readKeyFailed': '無法讀取私密金鑰檔案 {path}: {detail}',
'main.ssh.keyNotAFile': '私密金鑰路徑不是一般檔案(目錄、裝置或其他特殊檔案均不接受)',
'main.ssh.keyTooLarge': '私密金鑰檔案過大,路徑可能填寫有誤',
'main.sftp.sessionGone': 'SSH 連線不存在或已中斷',
'main.sftp.deleteFailed': '刪除失敗: {detail}',
@@ -35,6 +37,7 @@ const main: Record<string, string> = {
'main.sftp.openTimeout': 'SFTP 通道開啟逾時',
'main.sftp.commandExitCode': '指令結束碼 {code}',
'main.sftp.cancelled': '已取消',
'main.sftp.localNotAllowed': '本機路徑未獲授權: {path}。請改用「選擇檔案 / 選擇目錄」對話框重新選擇。',
'main.zmodem.transferFailed': '傳輸失敗',
'main.zmodem.transferTimeout': '傳輸逾時',
@@ -49,6 +52,9 @@ const main: Record<string, string> = {
'main.zmodem.sessionCreateFailed': '無法建立 ZMODEM 連線',
'main.zmodem.noSaveDir': '未指定儲存目錄',
'main.zmodem.noFiles': '未選擇檔案',
'main.zmodem.saveDirNotAllowed': '儲存目錄未獲授權,請改用「選擇目錄」對話框重新選擇',
'main.zmodem.savePathNotAllowed': '遠端檔案名稱不被接受,已中止傳輸: {name}',
'main.zmodem.filesNotAllowed': '所選的本機檔案未獲授權,已中止傳輸',
'main.ipc.connectionMissing': '連線書籤不存在 ({id})',