diff --git a/src/components/terminal/terminal.js b/src/components/terminal/terminal.js index 6b7504140..2662da827 100644 --- a/src/components/terminal/terminal.js +++ b/src/components/terminal/terminal.js @@ -19,6 +19,7 @@ import { import confirm from "dialogs/confirm"; import fonts from "lib/fonts"; import appSettings from "lib/settings"; +import { quotePosixShellArg } from "utils/shell"; import LigaturesAddon from "./ligatures"; import { DEFAULT_TERMINAL_SETTINGS, @@ -67,6 +68,9 @@ export default class TerminalComponent { this.pid = null; this.isConnected = false; this.serverMode = options.serverMode !== false; // Default true + this.remoteSsh = options.remoteSsh || null; + this.remoteShellId = null; + this.remoteInputDisposable = null; this.touchSelection = null; this.touchScrolling = null; this.parsedAppKeybindings = []; @@ -801,6 +805,9 @@ export default class TerminalComponent { "Terminal is in local mode, cannot connect to server session", ); } + if (this.remoteSsh) { + return this.connectToRemoteShell(); + } if (!pid) { pid = await this.createSession(); @@ -915,6 +922,102 @@ export default class TerminalComponent { }); } + /** + * Connect xterm to an interactive Maverick SSH shell. + */ + connectToRemoteShell() { + const profile = this.remoteSsh; + if (!profile) throw new Error("SSH profile is required"); + + return new Promise((resolve, reject) => { + let settled = false; + const finishConnecting = (event) => { + this.remoteInputDisposable = this.terminal.onData((data) => { + if (!this.isConnected || !this.remoteShellId) return; + sftp.writeShell( + this.remoteShellId, + data, + () => {}, + (error) => this.onError?.(error), + ); + }); + this.terminal.unicode.activeVersion = "11"; + this.terminal.focus(); + void this.fitAndResizeTerminal(true); + this.onConnect?.(); + settled = true; + resolve(event.sessionId); + }; + const onEvent = (event) => { + switch (event?.type) { + case "ready": + this.remoteShellId = event.sessionId; + this.pid = `ssh:${event.sessionId}`; + this.isConnected = true; + if (profile.initialDirectory && profile.initialDirectory !== "/") { + try { + sftp.writeShell( + event.sessionId, + `cd ${quotePosixShellArg(profile.initialDirectory)}\n`, + () => finishConnecting(event), + onFailure, + ); + } catch (error) { + onFailure(error?.message); + } + break; + } + finishConnecting(event); + break; + + case "data": { + const binary = atob(event.data || ""); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + this.terminal.write(bytes); + break; + } + + case "exit": + this.isConnected = false; + this.processExited = true; + if (!this.intentionalClose) { + this.onProcessExit?.({ exit_code: event.exitCode }); + } + break; + + case "error": { + const error = new Error(event.message || "SSH shell error"); + this.isConnected = false; + if (!settled) reject(error); + else if (!this.intentionalClose) this.onError?.(error); + break; + } + } + }; + + const onFailure = (message) => { + const error = new Error( + typeof message === "string" ? message : "Failed to open SSH shell", + ); + this.isConnected = false; + if (!settled) reject(error); + else if (!this.intentionalClose) this.onError?.(error); + }; + + const openShell = () => { + sftp.openShellUsingProfile( + profile.profileId, + this.terminal.cols, + this.terminal.rows, + onEvent, + onFailure, + ); + }; + + openShell(); + }); + } + /** * Resize terminal * @param {number} cols - Number of columns @@ -926,6 +1029,20 @@ export default class TerminalComponent { const resizeKey = `${cols}x${rows}`; if (!force && this.lastRequestedServerSize === resizeKey) return; this.lastRequestedServerSize = resizeKey; + if (this.remoteSsh) { + if (!this.remoteShellId) return; + sftp.resizeShell( + this.remoteShellId, + cols, + rows, + () => {}, + (error) => { + this.lastRequestedServerSize = null; + this.onError?.(error); + }, + ); + return; + } try { await new Promise((resolve, reject) => { @@ -987,6 +1104,15 @@ export default class TerminalComponent { * @param {string} data - Data to write */ write(data) { + if (this.remoteSsh && this.isConnected && this.remoteShellId) { + sftp.writeShell( + this.remoteShellId, + data, + () => {}, + (error) => this.onError?.(error), + ); + return; + } if ( this.serverMode && this.isConnected && @@ -1285,6 +1411,18 @@ export default class TerminalComponent { */ async terminate() { this.intentionalClose = true; + this.remoteInputDisposable?.dispose?.(); + this.remoteInputDisposable = null; + + if (this.remoteShellId) { + const shellID = this.remoteShellId; + this.remoteShellId = null; + this.isConnected = false; + await new Promise((resolve) => { + sftp.closeShell(shellID, resolve, resolve); + }); + return; + } if (this.websocket) { try { diff --git a/src/components/terminal/terminalManager.js b/src/components/terminal/terminalManager.js index 632626658..63f05d44a 100644 --- a/src/components/terminal/terminalManager.js +++ b/src/components/terminal/terminalManager.js @@ -13,6 +13,7 @@ import openFile from "lib/openFile"; import openFolder from "lib/openFolder"; import appSettings from "lib/settings"; import helpers from "utils/helpers"; +import Url from "utils/Url"; import TerminalComponent from "./terminal"; import TerminalTouchSelection from "./terminalTouchSelection"; @@ -276,6 +277,7 @@ class TerminalManager { const shouldRender = render !== false; const isServerMode = serverMode !== false; const isReconnecting = reconnecting === true; + const isRemoteSsh = !!terminalOptions.remoteSsh; const terminalId = `terminal_${++this.terminalCounter}`; const providedName = @@ -289,7 +291,7 @@ class TerminalManager { : terminalName; // Check if terminal is installed before proceeding - if (isServerMode) { + if (isServerMode && !isRemoteSsh) { const installationResult = await this.checkAndInstallTerminal(); if (!installationResult.success) { throw new Error(installationResult.error); @@ -369,7 +371,11 @@ class TerminalManager { this.terminals.set(uniqueId, instance); - if (terminalComponent.serverMode && terminalComponent.pid) { + if ( + terminalComponent.serverMode && + !terminalComponent.remoteSsh && + terminalComponent.pid + ) { await this.persistTerminalSession( terminalComponent.pid, terminalName, @@ -399,7 +405,7 @@ class TerminalManager { } // Show alert for terminal creation failure - if (!isReconnecting) { + if (!isReconnecting && !error?.reported) { const errorMessage = error?.message || "Unknown error"; alert( strings["error"], @@ -831,7 +837,11 @@ class TerminalManager { const formattedTitle = `${titlePrefix} - ${title}`; terminalFile.filename = formattedTitle; - if (terminalComponent.serverMode && terminalComponent.pid) { + if ( + terminalComponent.serverMode && + !terminalComponent.remoteSsh && + terminalComponent.pid + ) { await this.persistTerminalSession( terminalComponent.pid, formattedTitle, @@ -870,6 +880,9 @@ class TerminalManager { // Handle acode CLI open commands (OSC 7777) terminalComponent.onOscOpen = async (type, path) => { + // OSC 7777 is an Acode-local CLI protocol. Remote hosts must not use it + // to request access to paths on the Android device. + if (terminalComponent.remoteSsh) return; if (!path) return; // Convert proot path @@ -899,6 +912,10 @@ class TerminalManager { // Set up custom title function for terminal const getTerminalTitle = () => { + if (terminalComponent.remoteSsh) { + const { username, hostname, displayName } = terminalComponent.remoteSsh; + return displayName || `${username}@${hostname}`; + } if (terminalComponent.pid) { return `PID: ${terminalComponent.pid}`; } @@ -924,7 +941,11 @@ class TerminalManager { terminal.component.intentionalClose = true; } - if (terminal.component.serverMode && terminal.component.pid) { + if ( + terminal.component.serverMode && + !terminal.component.remoteSsh && + terminal.component.pid + ) { this.removePersistedSession(terminal.component.pid); } @@ -1091,6 +1112,40 @@ class TerminalManager { }); } + /** + * Create an SSH terminal using credentials from an SFTP storage URL. + * @param {string|{url: string, name?: string}} storage - SFTP storage + * @param {object} options - Terminal options + * @returns {Promise} Terminal instance + */ + async createRemoteTerminal(storage, options = {}) { + const url = typeof storage === "string" ? storage : storage?.url; + const storageName = typeof storage === "object" ? storage?.name : null; + + if (!url || !/^sftp:/.test(url)) { + throw new Error("A valid SFTP storage is required"); + } + + const { hostname, pathname } = Url.decodeUrl(url); + const profileId = hostname?.startsWith("profile-") ? hostname : null; + if (!profileId) { + throw new Error( + "The SFTP storage has not been migrated to a native profile", + ); + } + + return this.createTerminal({ + ...options, + name: options.name || `SSH - ${storageName || hostname}`, + serverMode: true, + remoteSsh: { + profileId, + displayName: storageName || "SSH", + initialDirectory: pathname || "/", + }, + }); + } + /** * Handle keyboard resize events for all terminals * This is called when the virtual keyboard opens/closes on mobile diff --git a/src/dialogs/multiPrompt.js b/src/dialogs/multiPrompt.js index 1ddba8f64..538702e6c 100644 --- a/src/dialogs/multiPrompt.js +++ b/src/dialogs/multiPrompt.js @@ -22,6 +22,7 @@ import alert from "./alert"; * @property {boolean} [readOnly] Is read only * @property {boolean} [autofocus] Is autofocus * @property {boolean} [hidden] Is hidden + * @property {boolean} [sensitive] Clear the input when the prompt closes */ /** @@ -66,8 +67,9 @@ export default function multiPrompt(message, inputs, help) { return; } } + const values = getValue(); hide(); - resolve(getValue()); + resolve(values); }, }); const cancelBtn = tag("button", { @@ -88,7 +90,9 @@ export default function multiPrompt(message, inputs, help) { onsubmit: (e) => { e.preventDefault(); if (!okBtn.disabled) { - resolve(getValue()); + const values = getValue(); + hide(); + resolve(values); } }, children: [ @@ -136,6 +140,7 @@ export default function multiPrompt(message, inputs, help) { if ($focusEl) $focusEl.focus(); function hidePrompt() { + clearSensitiveInputs(); $promptDiv.classList.add("hide"); restoreTheme(); setTimeout(() => { @@ -158,10 +163,19 @@ export default function multiPrompt(message, inputs, help) { values[$input.id] = $input.checked; else values[$input.id] = $input.value; }); + clearSensitiveInputs(inputAr); return values; } + function clearSensitiveInputs(inputs = [...$body.getAll("input")]) { + for (const $input of inputs) { + if ($input.type === "password" || $input.isSensitive) { + $input.value = ""; + } + } + } + /** * Creates a group of inputs * @param {Array} inputs Array of inputs @@ -211,6 +225,7 @@ export default function multiPrompt(message, inputs, help) { readOnly, autofocus, hidden, + sensitive, } = input; const inputType = type === "textarea" ? "textarea" : "input"; @@ -277,6 +292,9 @@ export default function multiPrompt(message, inputs, help) { Object.defineProperty($input, "prompt", { value: { $body, hide }, }); + Object.defineProperty($input, "isSensitive", { + value: Boolean(sensitive), + }); Object.defineProperty($input, "setError", { value(message) { diff --git a/src/fileSystem/sftp.js b/src/fileSystem/sftp.js index 7c4b72980..41bff24af 100644 --- a/src/fileSystem/sftp.js +++ b/src/fileSystem/sftp.js @@ -6,49 +6,33 @@ import Path from "utils/Path"; import Url from "utils/Url"; import internalFs from "./internalFs"; +let pendingConnection = null; +let pendingConnectionID = null; + class SftpClient { #MAX_TRY = 3; - #hostname; - #port; - #username; - #authenticationType; - #password; - #keyFile; - #passPhrase; + #profileID; #base; #connectionID; #path; #stat; - #retry = 0; /** * * @param {String} hostname * @param {Number} port * @param {String} username - * @param {{password?: String, passPhrase?: String, keyFile?: String}} authentication + * @param {{profileID: String}} authentication */ constructor(hostname, port = 22, username, authentication) { - this.#hostname = hostname; - this.#port = port; - this.#username = username; - this.#authenticationType = !!authentication.keyFile ? "key" : "password"; - this.#keyFile = authentication.keyFile; - this.#passPhrase = authentication.passPhrase; - this.#password = authentication.password; - this.#base = Url.formate({ - protocol: "sftp:", - hostname: this.#hostname, - port: this.#port, - username: this.#username, - password: this.#password, - query: { - passPhrase: this.#passPhrase, - keyFile: this.#keyFile, - }, - }); + authentication ||= {}; + this.#profileID = authentication.profileID; + if (!this.#profileID) { + throw new Error("A native SFTP profile is required"); + } + this.#base = Url.formate({ protocol: "sftp:", hostname: this.#profileID }); - this.#connectionID = `${this.#username}@${this.#hostname}`; + this.#connectionID = this.#profileID; } setPath(path) { @@ -398,41 +382,56 @@ class SftpClient { } async connect() { - await new Promise((resolve, reject) => { - const retry = (err) => { - if (settings.value.retryRemoteFsAfterFail) { - if (++this.#retry > this.#MAX_TRY) { - this.#retry = 0; - reject(err); - } else { - this.connect().then(resolve).catch(reject); - } - } else { - reject(err); - } - }; - - if (this.#authenticationType === "key") { - sftp.connectUsingKeyFile( - this.#hostname, - this.#port, - this.#username, - this.#keyFile, - this.#passPhrase, - resolve, - retry, - ); - return; + if (pendingConnection) { + if (pendingConnectionID === this.#connectionID) { + return pendingConnection; } + try { + await pendingConnection; + } catch { + // The next profile should still get its own connection attempt. + } + return this.connect(); + } + + pendingConnectionID = this.#connectionID; + pendingConnection = this.#connectWithRetry(); - sftp.connectUsingPassword( - this.#hostname, - this.#port, - this.#username, - this.#password, - resolve, - retry, - ); + try { + return await pendingConnection; + } finally { + if (pendingConnectionID === this.#connectionID) { + pendingConnection = null; + pendingConnectionID = null; + } + } + } + + async #connectWithRetry() { + const attempts = settings.value.retryRemoteFsAfterFail + ? this.#MAX_TRY + 1 + : 1; + let lastError; + + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return await this.#connectWithHostVerification(); + } catch (error) { + if (error?.nonRetryable) throw error; + lastError = error; + } + } + + throw lastError; + } + + async #connectWithHostVerification() { + return this.#connectOnce(); + } + + #connectOnce() { + return new Promise((resolve, reject) => { + sftp.connectUsingProfile(this.#profileID, resolve, reject); }); } @@ -581,25 +580,22 @@ class SftpClient { * @param {String} host * @param {Number} port * @param {String} username - * @param {{password?: String, passPhrase?: String, keyFile?: String}} authentication + * @param {{profileID: String}} authentication */ function Sftp(host, port, username, authentication) { return new SftpClient(host, port, username, authentication); } Sftp.fromUrl = (url) => { - const { username, password, hostname, pathname, port, query } = - Url.decodeUrl(url); - const { keyFile, passPhrase } = query; - - const sftp = new SftpClient(hostname, port || 22, username, { - password, - keyFile, - passPhrase, - }); - - sftp.setPath(pathname); - return createFs(sftp); + const { hostname, pathname } = Url.decodeUrl(url); + if (hostname?.startsWith("profile-")) { + const sftp = new SftpClient(null, 22, null, { profileID: hostname }); + sftp.setPath(pathname); + return createFs(sftp); + } + throw new Error( + "Legacy SFTP credentials must be migrated to a native profile", + ); }; Sftp.test = (url) => /^sftp:/.test(url); diff --git a/src/lib/fileList.js b/src/lib/fileList.js index a83e9bde4..694c19b9f 100644 --- a/src/lib/fileList.js +++ b/src/lib/fileList.js @@ -15,6 +15,9 @@ import settings from "./settings"; const filesTree = {}; const pendingScans = new Set(); +const activeChildUrls = new WeakMap(); +const FALLBACK_SCAN_BATCH_SIZE = 64; +let fallbackScanTail = Promise.resolve(); const events = { "add-file": [], "push-file": [], @@ -164,7 +167,7 @@ export function rename(oldUrl, newUrl) { * @returns {Tree[]} */ export default function files(dir) { - const listedDirs = []; + const listedDirs = new Set(); let transform = (item) => item; if (typeof dir === "string") { for (const item of Object.values(filesTree)) { @@ -178,7 +181,7 @@ export default function files(dir) { const allFiles = []; Object.values(filesTree).forEach((item) => { - allFiles.push(...flattenTree(item, transform, listedDirs)); + flattenTree(item, transform, listedDirs, allFiles); }); return allFiles; } @@ -215,13 +218,14 @@ files.off = function (event, callback) { */ function getTree(treeList, dir) { if (!treeList) return; - let tree = treeList.find(({ url }) => url === dir); - if (tree) return tree; - for (const item of treeList) { - tree = getTree(item.children, dir); - if (tree) return tree; + const pending = [...treeList]; + while (pending.length) { + const tree = pending.pop(); + if (tree.url === dir) return tree; + if (tree.children?.length) { + for (const child of tree.children) pending.push(child); + } } - return null; } @@ -235,15 +239,13 @@ function getTree(treeList, dir) { * @param {Tree} tree - Files tree */ function getFile(path, tree) { - const { children } = tree; - let { url } = tree; - if (url === path) return tree; - if (!children) return null; - const len = children.length; - for (let i = 0; i < len; i++) { - const item = children[i]; - const result = getFile(path, item); - if (result) return result; + const pending = [tree]; + while (pending.length) { + const item = pending.pop(); + if (item.url === path) return item; + if (item.children?.length) { + for (const child of item.children) pending.push(child); + } } return null; } @@ -253,21 +255,20 @@ function getFile(path, tree) { * @param {Tree} tree * @param {(item:Tree)=>object} transform */ -function flattenTree(tree, transform, listedDirs) { - const list = []; - const { children } = tree; - if (!children) { - return [transform(tree)]; +function flattenTree(tree, transform, listedDirs, list = []) { + const pending = [tree]; + while (pending.length) { + const item = pending.pop(); + if (!item.children) { + list.push(transform(item)); + continue; + } + if (listedDirs.has(item.url)) continue; + listedDirs.add(item.url); + for (let i = item.children.length - 1; i >= 0; i -= 1) { + pending.push(item.children[i]); + } } - - if (listedDirs.includes(tree.url)) return list; - - listedDirs.push(tree.url); - - children.forEach((item) => { - if (item.children) list.push(...flattenTree(item, transform, listedDirs)); - else list.push(transform(item)); - }); return list; } @@ -316,35 +317,105 @@ function onRemoveFolder({ url }) { * @param {Tree} [root] - Root path */ async function getAllFiles(parent, root, options = {}) { - root = root || parent.root; - if (!parent.children || !root.isConnected) return; + const previousScan = fallbackScanTail; + let releaseScan; + fallbackScanTail = new Promise((resolve) => { + releaseScan = resolve; + }); + await previousScan.catch(() => {}); try { - const entries = await fsOperation(parent.url).lsDir(); - const promises = []; + return await scanAllFiles(parent, root, options); + } finally { + releaseScan(); + } +} + +async function scanAllFiles(parent, root, options = {}) { + root = root || parent.root; + if (!parent.children || !isFallbackScanActive(root)) return; + + // Compatibility providers such as FTP and SFTP are indexed in JavaScript. + // Keep one directory request in flight and periodically yield so a large + // remote workspace cannot monopolize the WebView event loop. + const directories = [parent]; + const queuedDirectories = new Set([parent.url]); + let directoryIndex = 0; + let processedSinceYield = 0; + + while (directoryIndex < directories.length && isFallbackScanActive(root)) { + const directory = directories[directoryIndex++]; + const entries = await listDirectoryWithRetry(directory, root); + if (!entries) continue; + const knownChildren = new Set(directory.children.map((child) => child.url)); + activeChildUrls.set(directory, knownChildren); + + try { + for (const item of entries) { + if (!isFallbackScanActive(root)) return; + const child = await createChildTree(directory, item, root, { + ...options, + deferDirectories: true, + knownChildren, + }); + if (child?.children && !queuedDirectories.has(child.url)) { + queuedDirectories.add(child.url); + directories.push(child); + } - for (const item of entries) { - promises.push(createChildTree(parent, item, root)); + processedSinceYield += 1; + if (processedSinceYield >= FALLBACK_SCAN_BATCH_SIZE) { + processedSinceYield = 0; + await yieldToMainThread(); + } + } + } finally { + if (activeChildUrls.get(directory) === knownChildren) { + activeChildUrls.delete(directory); + } } - await Promise.all(promises); - } catch (error) { - // retry after 3s - parent.retriedCount += 1; - if (parent.retriedCount > settings.value.maxRetryCount) return; - if (settings.value.showRetryToast) { - toast(`retrying: ${parent.path}`); + await yieldToMainThread(); + } +} + +async function listDirectoryWithRetry(parent, root) { + while (isFallbackScanActive(root)) { + try { + const entries = await fsOperation(parent.url).lsDir(); + parent.retriedCount = 0; + return entries || []; + } catch (error) { + parent.retriedCount += 1; + if (parent.retriedCount > settings.value.maxRetryCount) return null; + if (settings.value.showRetryToast) { + toast(`retrying: ${parent.path}`); + } + await waitForRetry(root, 3000); } + } + return null; +} - setTimeout(() => { - // why not outside? because parent may be removed - if (!root.isConnected) return; - parent.children.length = 0; - getAllFiles(parent, root, options); - }, 3000); +async function waitForRetry(root, duration) { + const deadline = Date.now() + duration; + while (isFallbackScanActive(root) && Date.now() < deadline) { + await delay(Math.min(250, deadline - Date.now())); } } +function isFallbackScanActive(root) { + return root.isConnected && filesTree[root.url] === root; +} + +function delay(duration) { + return new Promise((resolve) => setTimeout(resolve, duration)); +} + +function yieldToMainThread() { + return delay(0); +} + /** * Emit an event * @param {string} event @@ -369,11 +440,16 @@ function trackScan(scan) { * @param {File} item * @param {Tree} root */ -async function createChildTree(parent, item, root) { - if (!root.isConnected) return; - const { name, url, isDirectory, mime, type, size, modifiedDate } = item; - const exists = parent.children.findIndex((child) => child.url === url); - if (exists > -1) { +async function createChildTree(parent, item, root, options = {}) { + if (!isFallbackScanActive(root)) return; + const { name, url, isDirectory, isLink, mime, type, size, modifiedDate } = + item; + const knownChildren = + options.knownChildren || activeChildUrls.get(parent) || null; + const exists = knownChildren + ? knownChildren.has(url) + : parent.children.some((child) => child.url === url); + if (exists) { return; } @@ -385,18 +461,23 @@ async function createChildTree(parent, item, root) { size, modifiedDate, ); - if (!root.isConnected) return; + if (!isFallbackScanActive(root)) return; - const existingTree = getTree(Object.values(filesTree), file.url); + const existingTree = filesTree[file.url]; if (existingTree) { file.children = existingTree.children; parent.children.push(file); + knownChildren?.add(file.url); return; } parent.children.push(file); + knownChildren?.add(file.url); if (isDirectory) { + // Keep links visible in the tree, but do not recursively index them. Remote + // links can point back to an ancestor and otherwise create an endless scan. + if (isLink) return; const ignore = picomatch.isMatch( Url.join(file.path, ""), settings.value.excludeFolders, @@ -404,12 +485,15 @@ async function createChildTree(parent, item, root) { ); if (ignore) return; - await getAllFiles(file, root); - return; + if (!options.deferDirectories) { + await getAllFiles(file, root, options); + } + return file; } emit("push-file", file); emit("add-file", file); + return file; } export class Tree { diff --git a/src/lib/openFolder.js b/src/lib/openFolder.js index 741b6f583..927606a92 100644 --- a/src/lib/openFolder.js +++ b/src/lib/openFolder.js @@ -335,6 +335,11 @@ async function handleContextmenu(type, url, name, $target) { strings["install as plugin"] || "Install as Plugin", "extension", ]; + const OPEN_SSH_TERMINAL = [ + "open-ssh-terminal", + strings["open ssh terminal"] || "Open SSH Terminal", + "terminal", + ]; let options; @@ -364,6 +369,8 @@ async function handleContextmenu(type, url, name, $target) { "terminal", ]; options.push(OPEN_IN_TERMINAL); + } else if (/^sftp:/.test(url)) { + options.push(OPEN_SSH_TERMINAL); } } else if (type === "root") { options = []; @@ -381,6 +388,8 @@ async function handleContextmenu(type, url, name, $target) { "terminal", ]; options.push(OPEN_IN_TERMINAL); + } else if (/^sftp:/.test(url)) { + options.push(OPEN_SSH_TERMINAL); } options.push(CLOSE_FOLDER); @@ -401,7 +410,7 @@ async function handleContextmenu(type, url, name, $target) { /** * @param {"dir"|"file"|"root"} type - * @param {"copy"|"cut"|"delete"|"rename"|"paste"|"new file"|"new folder"|"cancel"|"open-folder"|"install-plugin"} action + * @param {"copy"|"cut"|"delete"|"rename"|"paste"|"new file"|"new folder"|"cancel"|"open-folder"|"install-plugin"|"open-in-terminal"|"open-ssh-terminal"|"copy-relative-path"} action * @param {string} url target url * @param {HTMLElement} $target target element * @param {string} name Name of file or folder @@ -447,6 +456,9 @@ function execOperation(type, action, url, $target, name) { case "open-in-terminal": return openInTerminal(); + case "open-ssh-terminal": + return openSshTerminal(); + case "copy-relative-path": return copyRelativePath(); } @@ -557,6 +569,19 @@ function execOperation(type, action, url, $target, name) { } } + async function openSshTerminal() { + try { + const { TerminalManager } = await import( + /* webpackChunkName: "terminal" */ "components/terminal" + ); + await TerminalManager.createRemoteTerminal({ url, name }); + Sidebar.hide(); + } catch (error) { + console.error("Failed to open SSH terminal:", error); + toast(`Failed to open SSH terminal: ${error.message || "Unknown error"}`); + } + } + async function deleteFile() { const msg = strings["delete entry"].replace("{name}", name); const confirmation = await confirm(strings.warning, msg); @@ -1192,9 +1217,10 @@ openFolder.removeItem = (url) => { openFolder.removeFolders = (url) => { ({ url } = Url.parse(url)); - const regex = new RegExp("^" + escapeStringRegexp(url)); - addedFolder.forEach((folder) => { - if (regex.test(folder.url)) { + // remove() mutates addedFolder, so iterate over a snapshot to avoid skipping + // adjacent folders that belong to the same remote storage. + [...addedFolder].forEach((folder) => { + if (Url.isSameOrDescendant(folder.url, url)) { folder.remove(); } }); diff --git a/src/lib/recents.js b/src/lib/recents.js index 816a374f5..1f583a8a0 100644 --- a/src/lib/recents.js +++ b/src/lib/recents.js @@ -55,7 +55,7 @@ const recents = { removeFolder(url) { ({ url } = Url.parse(url)); this.folders = this.folders.filter((folder) => { - return !new RegExp("^" + escapeStringRegexp(folder.url)).test(url); + return !Url.isSameOrDescendant(folder.url, url); }); }, diff --git a/src/lib/remoteStorage.js b/src/lib/remoteStorage.js index 5185de0ce..83c8077b7 100644 --- a/src/lib/remoteStorage.js +++ b/src/lib/remoteStorage.js @@ -1,4 +1,3 @@ -import fsOperation from "fileSystem"; import Ftp from "fileSystem/ftp"; import Sftp from "fileSystem/sftp"; import loader from "dialogs/loader"; @@ -6,6 +5,12 @@ import multiPrompt from "dialogs/multiPrompt"; import URLParse from "url-parse"; import helpers from "utils/helpers"; import Url from "utils/Url"; +import { + createSftpProfileUrl, + editSftpProfile, + getSftpProfileId, + getSftpProfileInfo, +} from "./sftpProfiles"; import { interstitialAd } from "./startAd"; export default { @@ -161,23 +166,80 @@ export default { ]); } }, - /** - * @param {...any} args [hostname, username, keyFile, password, passphrase, port, name] - */ - async addSftp(...args) { + /** Persist credentials natively and retain only an opaque profile URL. */ + async addSftp({ + hostname = "", + username = "", + port = 22, + alias: initialAlias = "", + authType = "password", + existingProfile = null, + } = {}) { let stopConnection = false; + if (existingProfile?.profileId) { + try { + const saved = await getSftpProfileInfo(existingProfile.profileId); + hostname = saved.hostname; + username = saved.username; + port = saved.port; + authType = saved.authType; + } catch (error) { + await helpers.error(error); + return null; + } + } - const { - hostname, - username, - keyFile, - password, - passPhrase, - port, - alias, - usePassword, - } = await prompt(...args); - const authType = usePassword ? "password" : "keyFile"; + let values; + try { + values = await prompt({ + hostname, + username, + port, + alias: initialAlias || existingProfile?.name || "", + authType: authType === "keyFile" ? "key" : authType, + hasSavedKey: existingProfile?.profileId && authType === "key", + }); + } catch { + return null; + } + + const retryDetails = { + hostname: values.hostname, + username: values.username, + port: values.port, + alias: values.alias, + authType: values.usePassword ? "password" : "key", + existingProfile, + }; + let profile; + let saveError; + try { + profile = await editSftpProfile({ + profileId: existingProfile?.profileId, + hostname: values.hostname, + username: values.username, + port: values.port, + authType: values.usePassword ? "password" : "key", + password: values.password, + keyFile: values.keyFile, + passPhrase: values.passPhrase, + }); + } catch (error) { + saveError = error; + } finally { + // Drop all WebView references as soon as the native store has consumed them. + values.password = ""; + values.keyFile = ""; + values.passPhrase = ""; + } + if (saveError) { + values = null; + await helpers.error(saveError); + return this.addSftp(retryDetails); + } + const alias = values.alias; + values = null; + const url = createSftpProfileUrl(profile.profileId); loader.create(strings["add sftp"], strings["connecting..."], { timeout: 10000, @@ -185,10 +247,8 @@ export default { stopConnection = true; }, }); - const connection = Sftp(hostname, Number.parseInt(port), username, { - password, - keyFile, - passPhrase, + const connection = Sftp(null, 22, null, { + profileID: profile.profileId, }); try { @@ -199,37 +259,6 @@ export default { return; } - let localKeyFile = ""; - if (keyFile) { - let fs = fsOperation(keyFile); - const text = await fs.readFile("utf8"); - - //Original key file sometimes gives permission error - //To solve permission error - const filename = keyFile.hashCode(); - localKeyFile = Url.join(DATA_STORAGE, filename); - fs = fsOperation(localKeyFile); - const exists = await fs.exists(); - if (exists) { - await fs.writeFile(text); - } else { - let fs = fsOperation(DATA_STORAGE); - await fs.createFile(filename, text); - } - } - - const url = Url.formate({ - protocol: "sftp:", - hostname, - username, - password, - port, - path: "/", - query: { - keyFile: localKeyFile, - passPhrase, - }, - }); loader.destroy(); await helpers.showInterstitialIfReady(); return { @@ -246,52 +275,51 @@ export default { } loader.destroy(); - await helpers.error(err); - return await this.addSftp( - hostname, - username, - keyFile, - password, - passPhrase, - port, + if (!err?.reported) await helpers.error(err); + return await this.addSftp({ + hostname: profile.hostname, + username: profile.username, + port: profile.port, alias, - authType, - ); + authType: profile.authType, + existingProfile: { + ...profile, + url, + home: existingProfile?.home, + }, + }); } - function prompt( + function prompt({ hostname, username, - keyFile, - password, - passPhrase, port, alias, - authType = "password", - ) { - port = port || 22; - - const MODE_PASS = authType === "password"; - const inputs = [ + authType, + hasSavedKey, + }) { + const usePassword = authType !== "key"; + return multiPrompt(strings["add sftp"], [ { id: "alias", placeholder: strings.name, type: "text", - value: alias ? alias : "", + value: alias, required: true, }, { id: "username", - placeholder: `${strings.username} (${strings.optional})`, + placeholder: strings.username, type: "text", value: username, + required: true, }, { id: "hostname", placeholder: strings.hostname, type: "text", - required: true, value: hostname, + required: true, }, [ "Authentication type: ", @@ -300,13 +328,12 @@ export default { placeholder: strings.password, name: "authType", type: "radio", - value: MODE_PASS, + value: usePassword, onchange() { - if (!!this.value) { - this.prompt.$body.get("#password").hidden = false; - this.prompt.$body.get("#keyFile").hidden = true; - this.prompt.$body.get("#passPhrase").hidden = true; - } + if (!this.checked) return; + this.prompt.$body.get("#password").hidden = false; + this.prompt.$body.get("#keyFile").hidden = true; + this.prompt.$body.get("#passPhrase").hidden = true; }, }, { @@ -314,59 +341,75 @@ export default { placeholder: strings["key file"], name: "authType", type: "radio", - value: !MODE_PASS, + value: !usePassword, onchange() { - if (!!this.value) { - const $password = this.prompt.$body.get("#password"); - $password.hidden = true; - $password.value = ""; - this.prompt.$body.get("#keyFile").hidden = false; - this.prompt.$body.get("#passPhrase").hidden = false; - } + if (!this.checked) return; + const password = this.prompt.$body.get("#password"); + password.hidden = true; + password.value = ""; + this.prompt.$body.get("#keyFile").hidden = false; + this.prompt.$body.get("#passPhrase").hidden = false; }, }, ], { id: "password", - placeholder: strings.password, - name: "password", + placeholder: existingProfile?.profileId + ? `${strings.password} (leave blank to keep saved)` + : strings.password, type: "password", - value: password, - hidden: !MODE_PASS, + hidden: !usePassword, }, { id: "keyFile", - placeholder: strings["select key file"], - name: "keyFile", - hidden: MODE_PASS, - value: keyFile, + placeholder: hasSavedKey + ? `${strings["select key file"]} (leave blank to keep saved)` + : strings["select key file"], type: "text", + readOnly: true, + sensitive: true, + hidden: usePassword, onclick() { - sdcard.openDocumentFile((res) => { - this.value = res.uri; + sdcard.openDocumentFile((result) => { + this.value = result.uri; }); }, }, { id: "passPhrase", placeholder: `${strings.passphrase} (${strings.optional})`, - name: "passPhrase", type: "password", - hidden: MODE_PASS, - value: passPhrase, + hidden: usePassword, }, { id: "port", - placeholder: `${strings.port} (${strings.optional})`, + placeholder: strings.port, type: "number", - value: port, + value: port || 22, + required: true, }, - ]; - - return multiPrompt(strings["add sftp"], inputs); + ]); } }, - edit({ name, storageType, url }) { + async edit({ name, storageType, url, home }) { + const profileId = getSftpProfileId(url); + if (storageType === "sftp" && profileId) { + return this.addSftp({ + alias: name, + existingProfile: { profileId, url, home, name }, + }); + } + if (storageType === "sftp") { + const { username, hostname, port, query } = URLParse(url, true); + return this.addSftp({ + hostname, + username: username ? decodeURIComponent(username) : "", + port: port || 22, + alias: name, + authType: query?.keyFile ? "key" : "password", + }); + } + let { username, password, hostname, port, query } = URLParse(url, true); if (username) { @@ -398,28 +441,6 @@ export default { ); } - if (storageType === "sftp") { - let { passPhrase, keyFile } = query; - if (passPhrase) { - passPhrase = decodeURIComponent(passPhrase); - } - - if (keyFile) { - keyFile = decodeURIComponent(keyFile); - } - - return this.addSftp( - hostname, - username, - keyFile, - password, - passPhrase, - port, - name, - password ? "password" : "key", - ); - } - return null; }, }; diff --git a/src/lib/sftpProfiles.js b/src/lib/sftpProfiles.js new file mode 100644 index 000000000..6d5aeb0b2 --- /dev/null +++ b/src/lib/sftpProfiles.js @@ -0,0 +1,213 @@ +import fsOperation from "fileSystem"; +import Url from "utils/Url"; + +const PROFILE_PREFIX = "profile-"; +const MIGRATION_MARKER = "sftpNativeProfileMigration"; +const MIGRATION_VERSION = "1"; +const MIGRATED_STORAGE_KEYS = [ + "storageList", + "folders", + "files", + "recentFiles", + "recentFolders", + "fileBrowserState", +]; + +export function getSftpProfileId(url) { + if (!/^sftp:/.test(url || "")) return null; + const { hostname } = Url.decodeUrl(url); + return hostname?.startsWith(PROFILE_PREFIX) ? hostname : null; +} + +export function createSftpProfileUrl(profileId, pathname = "/") { + return Url.formate({ + protocol: "sftp:", + hostname: profileId, + path: pathname || "/", + }); +} + +function saveSftpProfile({ + profileId = null, + hostname, + port = 22, + username, + authType, + password = "", + keyFile = "", + passPhrase = "", +}) { + return new Promise((resolve, reject) => { + sftp.saveProfile( + profileId, + hostname, + Number.parseInt(port, 10) || 22, + username, + authType, + password, + keyFile, + passPhrase, + resolve, + reject, + ); + }); +} + +export function editSftpProfile({ + profileId = null, + hostname = "", + port = 22, + username = "", + authType = "password", + password = "", + keyFile = "", + passPhrase = "", +} = {}) { + return new Promise((resolve, reject) => { + sftp.editProfile( + profileId, + hostname, + Number.parseInt(port, 10) || 22, + username, + authType, + password, + keyFile, + passPhrase, + resolve, + reject, + ); + }); +} + +export function getSftpProfileInfo(profileId) { + return new Promise((resolve, reject) => { + sftp.getProfileInfo(profileId, resolve, reject); + }); +} + +export function deleteSftpProfile(profileId) { + return new Promise((resolve) => { + sftp.deleteProfile(profileId, resolve, resolve); + }); +} + +/** + * Moves legacy credential-bearing SFTP URLs into encrypted native profiles. + * Migration fails closed before third-party plugins load if encryption is unavailable. + */ +export async function migrateLegacySftpProfiles() { + if (localStorage.getItem(MIGRATION_MARKER) === MIGRATION_VERSION) return; + + const profileCache = new Map(); + const copiedKeys = new Set(); + let migrationError = null; + + for (const storageKey of MIGRATED_STORAGE_KEYS) { + const raw = localStorage.getItem(storageKey); + if (!raw) continue; + let value; + try { + value = JSON.parse(raw); + } catch { + continue; + } + + const migrated = await migrateValue(value); + if (migrated.changed) { + localStorage.setItem(storageKey, JSON.stringify(migrated.value)); + } + } + + for (const keyFile of copiedKeys) { + if (!keyFile.startsWith(globalThis.DATA_STORAGE || "\0")) continue; + try { + await fsOperation(keyFile).delete(); + } catch (error) { + console.warn("Could not remove migrated SFTP key copy", error); + } + } + + if (migrationError) { + throw new Error( + "SFTP credentials could not be moved to encrypted native storage", + { cause: migrationError }, + ); + } + + localStorage.setItem(MIGRATION_MARKER, MIGRATION_VERSION); + + async function migrateValue(value) { + if (typeof value === "string") return migrateUrl(value); + if (Array.isArray(value)) { + let changed = false; + const next = []; + for (const item of value) { + const migrated = await migrateValue(item); + changed ||= migrated.changed; + next.push(migrated.value); + } + return { value: next, changed }; + } + if (value && typeof value === "object") { + let changed = false; + const next = {}; + for (const [key, item] of Object.entries(value)) { + const migrated = await migrateValue(item); + changed ||= migrated.changed; + next[key] = migrated.value; + } + return { value: next, changed }; + } + return { value, changed: false }; + } + + async function migrateUrl(value) { + if (!/^sftp:/.test(value) || getSftpProfileId(value)) { + return { value, changed: false }; + } + + try { + const { username, password, hostname, pathname, port, query } = + Url.decodeUrl(value); + if (!hostname || !username) return { value, changed: false }; + const keyFile = normalizeLegacyValue(query?.keyFile); + const passPhrase = normalizeLegacyValue(query?.passPhrase); + const authType = keyFile ? "key" : "password"; + const signature = JSON.stringify({ + hostname, + port: port || 22, + username, + password: password || "", + keyFile, + passPhrase, + }); + + let profileId = profileCache.get(signature); + if (!profileId) { + profileId = await saveSftpProfile({ + hostname, + port: port || 22, + username, + authType, + password: password || "", + keyFile, + passPhrase, + }); + profileCache.set(signature, profileId); + if (keyFile) copiedKeys.add(keyFile); + } + return { + value: createSftpProfileUrl(profileId, pathname || "/"), + changed: true, + }; + } catch (error) { + console.warn("Could not migrate legacy SFTP URL", error); + migrationError ||= error; + return { value, changed: false }; + } + } +} + +function normalizeLegacyValue(value) { + return value && value !== "undefined" && value !== "null" ? value : ""; +} diff --git a/src/main.js b/src/main.js index c29eee871..071757803 100644 --- a/src/main.js +++ b/src/main.js @@ -53,6 +53,7 @@ import openFolder, { addedFolder } from "lib/openFolder"; import { registerPrettierFormatter } from "lib/registerPrettierFormatter"; import restoreFiles from "lib/restoreFiles"; import settings from "lib/settings"; +import { migrateLegacySftpProfiles } from "lib/sftpProfiles"; import startAd, { BANNER_SUPPRESSION_REASON, setBannerSuppressed, @@ -266,6 +267,9 @@ async function onDeviceReady() { acode.setLoadingMessage("Loading language..."); await lang.set(settings.value.lang); + acode.setLoadingMessage("Securing SFTP profiles..."); + await migrateLegacySftpProfiles(); + if (settings.value.developerMode) { try { const devTools = (await import("lib/devTools")).default; diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index e977eb59d..d7be18203 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -21,6 +21,7 @@ import projects from "lib/projects"; import recents from "lib/recents"; import remoteStorage from "lib/remoteStorage"; import appSettings from "lib/settings"; +import { deleteSftpProfile, getSftpProfileId } from "lib/sftpProfiles"; import mimeTypes from "mime-types"; import mustache from "mustache"; import filesSettings from "settings/filesSettings"; @@ -478,6 +479,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { case "addFtp": case "addSftp": { const storage = await remoteStorage[action](); + if (!storage) break; updateStorage(storage); break; } @@ -1066,6 +1068,14 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { options.push(["edit", strings.edit, "edit"]); } + if (storageType === "sftp" && uuid) { + options.push([ + "ssh_terminal", + strings["open ssh terminal"] || "Open SSH Terminal", + "terminal", + ]); + } + if (helpers.isFile(type)) { options.push(["info", strings.info, "info"]); options.push(["open_with", strings["open with"], "open_in_browser"]); @@ -1087,7 +1097,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { const confirmation = await confirm(strings.warning, message); if (!confirmation) break; - deleteFunction(); + await deleteFunction(); break; } @@ -1114,6 +1124,15 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { break; } + case "ssh_terminal": { + const { TerminalManager } = await import( + /* webpackChunkName: "terminal" */ "components/terminal" + ); + await TerminalManager.createRemoteTerminal({ url, name }); + $page.hide(); + break; + } + case "info": acode.exec("file-info", url); break; @@ -1219,28 +1238,61 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { } } - function removeStorage() { - if (url) { - recents.removeFolder(url); - recents.removeFile(url); + async function removeStorage() { + const removedStorage = storageList.find( + (storage) => storage.uuid === uuid, + ); + const storageUrl = removedStorage?.url || url; + + if (storageUrl) { + recents.removeFolder(storageUrl); + recents.removeFile(storageUrl); + openFolder.removeFolders(storageUrl); + helpers.updateUriOfAllActiveFiles(storageUrl, null); + } + if ( + storageUrl && + removedStorage && + (removedStorage.storageType === "sftp" || + removedStorage.type === "sftp") + ) { + const profileId = getSftpProfileId(storageUrl); + const { username, hostname, port = 22 } = Url.decodeUrl(storageUrl); + const connectionID = profileId || `${username}@${hostname}:${port}`; + await new Promise((resolve) => { + sftp.isConnected((activeConnectionID) => { + if (activeConnectionID !== connectionID) { + resolve(); + return; + } + sftp.close(resolve, resolve); + }, resolve); + }); + const profileStillUsed = storageList.some( + (storage) => + storage.uuid !== uuid && + getSftpProfileId(storage.url) === profileId, + ); + if (profileId && !profileStillUsed) { + await deleteSftpProfile(profileId); + } } storageList = storageList.filter((storage) => { if (storage.uuid !== uuid) { return true; } - if (storage.url) { + if (storage.url && !getSftpProfileId(storage.url)) { const parsedUrl = URLParse(storage.url, true); const keyFile = decodeURIComponent( parsedUrl.query["keyFile"] || "", ); - if (keyFile) { - fsOperation(keyFile).delete(); - } + if (keyFile) fsOperation(keyFile).delete().catch(console.warn); } return false; }); localStorage.storageList = JSON.stringify(storageList); + acode.exec("save-state"); reload(); } diff --git a/src/plugins/auth/src/android/EncryptedPreferenceManager.java b/src/plugins/auth/src/android/EncryptedPreferenceManager.java index f14a4cd55..51a32a19f 100644 --- a/src/plugins/auth/src/android/EncryptedPreferenceManager.java +++ b/src/plugins/auth/src/android/EncryptedPreferenceManager.java @@ -8,18 +8,33 @@ import java.security.GeneralSecurityException; public class EncryptedPreferenceManager { - private SharedPreferences sharedPreferences; + private final SharedPreferences sharedPreferences; /** * @param context The Android Context * @param prefName The custom name for your preference file (e.g., "user_session") */ public EncryptedPreferenceManager(Context context, String prefName) { + this(context, prefName, true); + } + + /** + * @param context The Android Context + * @param prefName The custom name for your preference file + * @param allowPlaintextFallback Whether storage may fall back to ordinary + * SharedPreferences when encryption is unavailable + */ + public EncryptedPreferenceManager( + Context context, + String prefName, + boolean allowPlaintextFallback + ) { + SharedPreferences preferences; try { String masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC); // EncryptedSharedPreferences handles encryption of both Keys and Values - sharedPreferences = EncryptedSharedPreferences.create( + preferences = EncryptedSharedPreferences.create( prefName, masterKeyAlias, context, @@ -27,9 +42,14 @@ public EncryptedPreferenceManager(Context context, String prefName) { EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ); } catch (GeneralSecurityException | IOException e) { - // Fallback to standard private preferences if hardware-backed encryption fails - sharedPreferences = context.getSharedPreferences(prefName, Context.MODE_PRIVATE); + if (!allowPlaintextFallback) { + throw new IllegalStateException("Encrypted preferences are unavailable", e); + } + + // Preserve the existing behavior for legacy callers unless strict mode is requested. + preferences = context.getSharedPreferences(prefName, Context.MODE_PRIVATE); } + sharedPreferences = preferences; } // --- Reusable Methods --- @@ -38,6 +58,10 @@ public void setString(String key, String value) { sharedPreferences.edit().putString(key, value).apply(); } + public boolean setStringSync(String key, String value) { + return sharedPreferences.edit().putString(key, value).commit(); + } + public String getString(String key, String defaultValue) { return sharedPreferences.getString(key, defaultValue); } @@ -62,6 +86,10 @@ public void remove(String key) { sharedPreferences.edit().remove(key).apply(); } + public boolean removeSync(String key) { + return sharedPreferences.edit().remove(key).commit(); + } + public boolean exists(String key) { return sharedPreferences.contains(key); } @@ -69,4 +97,4 @@ public boolean exists(String key) { public void clear() { sharedPreferences.edit().clear().apply(); } -} \ No newline at end of file +} diff --git a/src/plugins/sftp/index.d.ts b/src/plugins/sftp/index.d.ts index 62d0afeed..dc75b3536 100644 --- a/src/plugins/sftp/index.d.ts +++ b/src/plugins/sftp/index.d.ts @@ -12,10 +12,25 @@ interface Stats { uri: string; } -interface ExecResult{ +interface ExecResult{ code: Number; result: String; -} +} + +interface ShellEvent { + type: "ready" | "data" | "exit" | "error"; + sessionId?: string; + data?: string; + exitCode?: number; + message?: string; +} + +interface SftpProfileInfo { + hostname: string; + port: number; + username: string; + authType: "password" | "key"; +} interface Sftp { /** @@ -25,28 +40,12 @@ interface Sftp { * @param onFail */ exec(command: String, onSucess: (res: ExecResult)=>void, onFail: (err: any) => void): void; - /** - * Connects to SFTP server - * @param host Hostname of the server - * @param port port numer - * @param username Username - * @param password Password or private key file to authenticate the server - * @param onSuccess Callback function on success returns url of copied file/dir - * @param onFail Callback function on error returns error object - */ - connectUsingPassoword(host: String, port: Number, username: String, password: String, onSuccess: () => void, onFail: (err: any) => void): void; - - /** - * Connects to SFTP server - * @param host Hostname of the server - * @param port port numer - * @param username Username - * @param keyFile Password or private key file to authenticate the server - * @param passphrase Passphrase for keyfile - * @param onSuccess Callback function on success returns url of copied file/dir - * @param onFail Callback function on error returns error object - */ - connectUsingKeyFile(host: String, port: Number, username: String, keyFile: String, passphrase: String, onSuccess: () => void, onFail: (err: any) => void): void; + /** Connects using credentials held by the native profile store. */ + connectUsingProfile(profileId: String, onSuccess: () => void, onFail: (err: any) => void): void; + saveProfile(profileId: String | null, host: String, port: Number, username: String, authType: String, password: String, keyFile: String, passphrase: String, onSuccess: (profileId: String) => void, onFail: (err: any) => void): void; + editProfile(profileId: String | null, host: String, port: Number, username: String, authType: String, password: String, keyFile: String, passphrase: String, onSuccess: (profile: SftpProfileInfo & {profileId: string}) => void, onFail: (err: any) => void): void; + getProfileInfo(profileId: String, onSuccess: (profile: SftpProfileInfo & {profileId: string}) => void, onFail: (err: any) => void): void; + deleteProfile(profileId: String, onSuccess: () => void, onFail: (err: any) => void): void; /** * Gets file from the server. @@ -78,7 +77,11 @@ interface Sftp { * @param onSuccess * @param onFail */ - isConnected(onSuccess: (connectionId: String) => void, onFail: (err: any) => void): void; -} + isConnected(onSuccess: (connectionId: String) => void, onFail: (err: any) => void): void; + openShellUsingProfile(profileId: String, cols: Number, rows: Number, onEvent: (event: ShellEvent) => void, onFail: (err: any) => void): void; + writeShell(sessionId: String, data: String, onSuccess: () => void, onFail: (err: any) => void): void; + resizeShell(sessionId: String, cols: Number, rows: Number, onSuccess: () => void, onFail: (err: any) => void): void; + closeShell(sessionId: String, onSuccess: () => void, onFail: (err: any) => void): void; +} -declare var sftp: Sftp; \ No newline at end of file +declare var sftp: Sftp; diff --git a/src/plugins/sftp/plugin.xml b/src/plugins/sftp/plugin.xml index ed1e75ccc..527d729b3 100644 --- a/src/plugins/sftp/plugin.xml +++ b/src/plugins/sftp/plugin.xml @@ -21,7 +21,8 @@ - + + diff --git a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java index 25b373742..b676c1f05 100644 --- a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java +++ b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java @@ -1,26 +1,38 @@ package com.foxdebug.sftp; import android.app.Activity; +import android.app.AlertDialog; import android.content.ContentResolver; import android.content.Context; import android.net.Uri; +import android.util.Base64; import android.util.Log; import androidx.documentfile.provider.DocumentFile; import com.sshtools.client.SshClient; import com.sshtools.client.SshClient.SshClientBuilder; +import com.sshtools.client.SshClientContext; +import com.sshtools.client.SessionChannelNG; import com.sshtools.client.sftp.SftpClient; import com.sshtools.client.sftp.SftpClient.SftpClientBuilder; import com.sshtools.client.sftp.SftpFile; import com.sshtools.client.sftp.TransferCancelledException; +import com.sshtools.common.knownhosts.HostKeyVerification; import com.sshtools.common.permissions.PermissionDeniedException; +import com.sshtools.common.policy.FileSystemPolicy; import com.sshtools.common.publickey.InvalidPassphraseException; import com.sshtools.common.publickey.SshKeyUtils; import com.sshtools.common.sftp.SftpFileAttributes; import com.sshtools.common.sftp.SftpStatusException; import com.sshtools.common.ssh.SshException; +import com.sshtools.common.ssh.Channel; +import com.sshtools.common.ssh.ChannelEventListener; +import com.sshtools.common.ssh.RequestFuture; import com.sshtools.common.ssh.components.SshKeyPair; +import com.sshtools.common.ssh.components.SshPublicKey; import com.sshtools.common.ssh.components.jce.JCEProvider; -import com.sshtools.common.util.FileUtils; +import com.sshtools.common.util.UnsignedInteger32; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -31,241 +43,894 @@ import java.net.URISyntaxException; import java.net.URLDecoder; import java.net.URLEncoder; +import java.nio.ByteBuffer; import java.nio.channels.UnresolvedAddressException; import java.nio.charset.StandardCharsets; import java.security.Security; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.cordova.CallbackContext; import org.apache.cordova.CordovaInterface; import org.apache.cordova.CordovaPlugin; import org.apache.cordova.CordovaWebView; +import org.apache.cordova.PluginResult; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; -import java.util.Arrays; import org.bouncycastle.jce.provider.BouncyCastleProvider; public class Sftp extends CordovaPlugin { private static final String TAG = "SFTP"; + // Maverick's 16 MB default is allocated in full for every SFTP subsystem. + // A smaller mobile window prevents connection bursts from exhausting the heap. + private static final long SFTP_MAX_WINDOW_SIZE = 1024L * 1024L; + private static final long SFTP_MIN_WINDOW_SIZE = 128L * 1024L; + private static boolean cryptoProviderConfigured; + private final Object connectionLock = new Object(); + private final Map remoteShells = new ConcurrentHashMap<>(); private SshClient ssh; private SftpClient sftp; private Context context; private Activity activity; private String connectionID; + private SftpSecurityStore securityStore; + + private final class ConnectionSecurity { + + private final String hostname; + private final int port; + private volatile JSONObject failure; + + private ConnectionSecurity(String hostname, int port) { + this.hostname = hostname; + this.port = port; + } + + private void configure(SshClientContext sshContext) { + configureClient(sshContext); + sshContext.setHostKeyVerification( + new HostKeyVerification() { + @Override + public boolean verifyHost(String host, SshPublicKey publicKey) + throws SshException { + try { + String fingerprint = publicKey.getFingerprint(); + String algorithm = publicKey.getAlgorithm(); + String encodedKey = Base64.encodeToString( + publicKey.getEncoded(), + Base64.NO_WRAP + ); + String endpoint = hostname + ":" + port; + JSONObject trusted = securityStore.getKnownHost(endpoint); + if (trusted == null) { + if ( + confirmUnknownHost(endpoint, algorithm, fingerprint) + ) { + securityStore.trustHost( + endpoint, + algorithm, + fingerprint, + encodedKey + ); + return true; + } + failure = hostKeyFailure( + "HOST_KEY_REJECTED", + endpoint, + fingerprint, + null + ); + return false; + } + + String expected = trusted.optString("fingerprint"); + String expectedKey = trusted.optString("publicKey"); + if ( + (!expectedKey.isEmpty() && !encodedKey.equals(expectedKey)) || + (expectedKey.isEmpty() && !fingerprint.equals(expected)) + ) { + failure = hostKeyFailure( + "HOST_KEY_CHANGED", + endpoint, + fingerprint, + expected + ); + showChangedHostKey(endpoint, expected, fingerprint); + return false; + } + return true; + } catch (JSONException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new SshException( + "Could not verify the SSH host key", + SshException.HOST_KEY_ERROR, + e + ); + } + } + } + ); + } + + private boolean report(CallbackContext callback) { + JSONObject error = failure; + failure = null; + if (error == null) return false; + callback.error(error); + return true; + } + } + + private final class RemoteShell { + + private final String id; + private final SshClient client; + private final SessionChannelNG channel; + private final CallbackContext streamCallback; + private final AtomicBoolean finished = new AtomicBoolean(false); + private final ExecutorService inputWriter = Executors.newSingleThreadExecutor(); + + private RemoteShell( + String id, + SshClient client, + SessionChannelNG channel, + CallbackContext streamCallback + ) { + this.id = id; + this.client = client; + this.channel = channel; + this.streamCallback = streamCallback; + } + + private void sendData(ByteBuffer source) { + if (finished.get() || source == null || !source.hasRemaining()) return; + + ByteBuffer data = source.asReadOnlyBuffer(); + byte[] bytes = new byte[data.remaining()]; + data.get(bytes); + try { + JSONObject event = new JSONObject(); + event.put("type", "data"); + event.put("data", Base64.encodeToString(bytes, Base64.NO_WRAP)); + sendShellEvent(streamCallback, event, true); + } catch (JSONException e) { + finish(null, e.getMessage()); + } + } + + private void finish(Integer exitCode, String error) { + if (!finished.compareAndSet(false, true)) return; + remoteShells.remove(id, this); + inputWriter.shutdownNow(); + + try { + channel.close(); + } catch (Exception e) { + Log.w(TAG, "Failed to close SSH shell channel " + id, e); + } + try { + client.close(); + } catch (IOException e) { + Log.w(TAG, "Failed to close SSH shell connection " + id, e); + } + + try { + JSONObject event = new JSONObject(); + event.put("type", error == null ? "exit" : "error"); + if (exitCode != null) event.put("exitCode", exitCode); + if (error != null) event.put("message", error); + sendShellEvent(streamCallback, event, false); + } catch (JSONException e) { + streamCallback.error(errMessage(e)); + } + } + + private void write(String input, CallbackContext callback) { + if (finished.get()) { + callback.error("SSH shell is not connected"); + return; + } + + try { + inputWriter.execute( + new Runnable() { + @Override + public void run() { + try { + byte[] data = input.getBytes(StandardCharsets.UTF_8); + channel.getOutputStream().write(data); + channel.getOutputStream().flush(); + callback.success(); + } catch (IOException e) { + finish(null, errMessage(e)); + callback.error(errMessage(e)); + } + } + } + ); + } catch (RejectedExecutionException e) { + callback.error("SSH shell is not connected"); + } + } + } public void initialize(CordovaInterface cordova, CordovaWebView webView) { super.initialize(cordova, webView); context = cordova.getContext(); activity = cordova.getActivity(); + securityStore = new SftpSecurityStore(context); System.setProperty("maverick.log.nothread", "true"); + configureCryptoProvider(); } - public boolean execute( - String action, - JSONArray args, - CallbackContext callback + private static synchronized void configureCryptoProvider() { + if (cryptoProviderConfigured) return; + + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME); + Security.insertProviderAt(new BouncyCastleProvider(), 1); + JCEProvider.enableBouncyCastle(true); + cryptoProviderConfigured = true; + } + + private static void configureClient(SshClientContext sshContext) { + FileSystemPolicy policy = sshContext.getPolicy(FileSystemPolicy.class); + policy.setSftpMaxWindowSize( + new UnsignedInteger32(SFTP_MAX_WINDOW_SIZE) + ); + policy.setSftpMinWindowSize( + new UnsignedInteger32(SFTP_MIN_WINDOW_SIZE) + ); + policy.setMaximumNumberofAsyncSFTPRequests(4); + } + + private void closeConnectionQuietly() { + SftpClient previousSftp = sftp; + SshClient previousSsh = ssh; + sftp = null; + ssh = null; + connectionID = null; + + if (previousSftp != null) { + try { + previousSftp.quit(); + } catch (Exception e) { + Log.w(TAG, "Failed to close the SFTP subsystem", e); + } + } + if (previousSsh != null) { + try { + previousSsh.close(); + } catch (Exception e) { + Log.w(TAG, "Failed to close the SSH connection", e); + } + } + } + + private boolean establishConnection( + SshClientBuilder builder, + String newConnectionID, + ConnectionSecurity security + ) throws IOException, SshException, PermissionDeniedException { + synchronized (connectionLock) { + closeConnectionQuietly(); + ssh = builder.onConfigure(security::configure).build(); + if (!ssh.isConnected()) { + closeConnectionQuietly(); + return false; + } + + connectionID = newConnectionID; + try { + sftp = SftpClientBuilder.create().withClient(ssh).build(); + } catch (IOException | SshException | PermissionDeniedException e) { + closeConnectionQuietly(); + throw e; + } + + try { + sftp.getSubsystemChannel().setCharsetEncoding("UTF-8"); + } catch (UnsupportedEncodingException | SshException e) { + Log.w(TAG, "Failed to set UTF-8 encoding, using the default", e); + } + return true; + } + } + + private static void sendShellEvent( + CallbackContext callback, + JSONObject event, + boolean keepCallback + ) { + PluginResult result = new PluginResult(PluginResult.Status.OK, event); + result.setKeepCallback(keepCallback); + callback.sendPluginResult(result); + } + + private static JSONObject hostKeyFailure( + String code, + String host, + String fingerprint, + String expectedFingerprint + ) throws JSONException { + JSONObject error = new JSONObject(); + error.put("code", code); + error.put("host", host); + error.put("fingerprint", fingerprint); + if (expectedFingerprint != null) { + error.put("expectedFingerprint", expectedFingerprint); + } + return error; + } + + private boolean confirmUnknownHost( + String endpoint, + String algorithm, + String fingerprint + ) throws InterruptedException { + if (activity == null || activity.isFinishing()) return false; + + CountDownLatch decision = new CountDownLatch(1); + AtomicBoolean trusted = new AtomicBoolean(false); + activity.runOnUiThread( + () -> { + AlertDialog dialog = new AlertDialog.Builder(activity) + .setTitle("Unknown SSH host") + .setMessage( + "This is the first connection to " + + endpoint + + ".\n\nKey type: " + + algorithm + + "\nFingerprint: " + + fingerprint + + "\n\nVerify this fingerprint before trusting the host." + ) + .setNegativeButton("Cancel", (ignored, which) -> decision.countDown()) + .setPositiveButton( + "Trust and connect", + (ignored, which) -> { + trusted.set(true); + decision.countDown(); + } + ) + .create(); + dialog.setOnCancelListener(ignored -> decision.countDown()); + dialog.show(); + } + ); + decision.await(); + return trusted.get(); + } + + private void showChangedHostKey( + String endpoint, + String expectedFingerprint, + String receivedFingerprint ) { + if (activity == null || activity.isFinishing()) return; + activity.runOnUiThread( + () -> + new AlertDialog.Builder(activity) + .setTitle("SSH host key changed") + .setMessage( + "The identity of " + + endpoint + + " has changed. The connection was blocked.\n\nExpected: " + + expectedFingerprint + + "\nReceived: " + + receivedFingerprint + ) + .setPositiveButton("Close", null) + .show() + ); + } + + private void closeRemoteShells() { + for (RemoteShell shell : remoteShells.values()) { + shell.finish(null, null); + } + remoteShells.clear(); + } + + @Override + public void onReset() { + closeRemoteShells(); + super.onReset(); + } + + @Override + public void onDestroy() { + closeRemoteShells(); + super.onDestroy(); + } + + private SshClientBuilder buildProfileBuilder(JSONObject profile) + throws IOException, InvalidPassphraseException, JSONException { + SshClientBuilder builder = SshClientBuilder.create() + .withHostname(profile.getString("hostname")) + .withPort(profile.optInt("port", 22)) + .withUsername(profile.getString("username")); + + if ("key".equals(profile.optString("authType"))) { + byte[] privateKey = Base64.decode( + profile.getString("privateKey"), + Base64.NO_WRAP + ); + SshKeyPair keyPair = SshKeyUtils.getPrivateKey( + new ByteArrayInputStream(privateKey), + profile.optString("passphrase") + ); + builder.withIdentities(keyPair); + } else { + builder.withPassword(profile.optString("password")); + } + return builder; + } + + private byte[] readUri(String uriString) throws IOException { + if (uriString == null || uriString.isEmpty()) { + throw new IOException("Private key file is required"); + } + try ( + InputStream input = context + .getContentResolver() + .openInputStream(Uri.parse(uriString)); + ByteArrayOutputStream output = new ByteArrayOutputStream() + ) { + if (input == null) throw new IOException("Could not open key file"); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + return output.toByteArray(); + } + } + + private void openRemoteShell( + SshClient shellClient, + int columns, + int rows, + CallbackContext callback + ) throws SshException, JSONException, IOException { + if (!shellClient.isConnected()) { + shellClient.close(); + throw new IOException("Failed to establish SSH connection"); + } + + String shellID = UUID.randomUUID().toString(); + SessionChannelNG channel; try { - Method method = getClass() - .getDeclaredMethod(action, JSONArray.class, CallbackContext.class); + channel = shellClient.openSessionChannel(true); + } catch (SshException e) { + shellClient.close(); + throw e; + } + RemoteShell shell = new RemoteShell(shellID, shellClient, channel, callback); + remoteShells.put(shellID, shell); - if (method != null) { - method.invoke(this, args, callback); - return true; + channel.addEventListener( + new ChannelEventListener() { + @Override + public void onChannelDataIn(Channel source, ByteBuffer data) { + shell.sendData(data); + } + + @Override + public void onChannelExtendedData( + Channel source, + ByteBuffer data, + int type + ) { + shell.sendData(data); + } + + @Override + public void onChannelClose(Channel source) { + int exitCode = channel.getExitCode(); + shell.finish( + exitCode == SessionChannelNG.EXITCODE_NOT_RECEIVED ? null : exitCode, + null + ); + } + + @Override + public void onChannelError(Channel source, Throwable error) { + shell.finish(null, error == null ? "SSH shell error" : error.toString()); + } } - } catch (NoSuchMethodException e) { - callback.error("Method not found: " + action); - return false; - } catch (SecurityException e) { - callback.error("Security exception: " + e.getMessage()); - return false; - } catch (Exception e) { - callback.error("Exception: " + e.getMessage()); - return false; + ); + + RequestFuture pty = channel + .allocatePseudoTerminal("xterm-256color", columns, rows) + .waitFor(30000L); + if (!pty.isSuccess()) { + shell.finish(null, "Remote server rejected PTY allocation"); + return; } + if (shell.finished.get()) return; - return false; + RequestFuture start = channel.startShell().waitFor(30000L); + if (!start.isSuccess()) { + shell.finish(null, "Remote server rejected the interactive shell"); + return; + } + if (shell.finished.get()) return; + + JSONObject ready = new JSONObject(); + ready.put("type", "ready"); + ready.put("sessionId", shellID); + sendShellEvent(callback, ready, true); } - public void connectUsingPassword(JSONArray args, CallbackContext callback) { + public void openShellUsingProfile(JSONArray args, CallbackContext callback) { cordova .getThreadPool() .execute( new Runnable() { public void run() { + ConnectionSecurity security = null; try { - String host = args.optString(0); - int port = args.optInt(1); - String username = args.optString(2); - String password = args.optString(3); - JCEProvider.enableBouncyCastle(true); - Log.d( - TAG, - "Connecting to " + host + ":" + port + " as " + username + String profileID = args.optString(0); + int columns = Math.max(1, args.optInt(1, 80)); + int rows = Math.max(1, args.optInt(2, 24)); + JSONObject profile = securityStore.getProfile(profileID); + ConnectionSecurity profileSecurity = new ConnectionSecurity( + profile.getString("hostname"), + profile.optInt("port", 22) ); - ssh = SshClientBuilder.create() - .withHostname(host) - .withPort(port) - .withUsername(username) - .withPassword(password) - .build(); + security = profileSecurity; + openRemoteShell( + buildProfileBuilder(profile) + .onConfigure(profileSecurity::configure) + .build(), + columns, + rows, + callback + ); + } catch (InvalidPassphraseException e) { + callback.error("Invalid passphrase for stored key"); + } catch (Exception e) { + if (security != null && security.report(callback)) return; + callback.error("Failed to open SSH shell: " + errMessage(e)); + Log.e(TAG, "Failed to open SSH shell from profile", e); + } + } + } + ); + } - if (ssh.isConnected()) { - connectionID = username + "@" + host; + public void saveProfile(JSONArray args, CallbackContext callback) { + cordova + .getThreadPool() + .execute( + new Runnable() { + public void run() { + try { + String requestedID = args.optString(0, null); + if (requestedID != null && !requestedID.isEmpty()) { + callback.error("Legacy profile import cannot replace a saved profile"); + return; + } + String authType = args.optString(4, "password"); + JSONObject profile = new JSONObject(); + profile.put("hostname", args.getString(1)); + profile.put("port", args.optInt(2, 22)); + profile.put("username", args.getString(3)); + profile.put("authType", authType); + if ("key".equals(authType)) { + profile.put( + "privateKey", + Base64.encodeToString(readUri(args.optString(6)), Base64.NO_WRAP) + ); + profile.put("passphrase", args.optString(7)); + } else { + profile.put("password", args.optString(5)); + } + callback.success(securityStore.saveProfile(requestedID, profile)); + } catch (Exception e) { + callback.error("Could not securely save SFTP profile: " + errMessage(e)); + Log.e(TAG, "Could not save SFTP profile", e); + } + } + } + ); + } - try { - sftp = SftpClientBuilder.create().withClient(ssh).build(); - } catch (IOException | SshException e) { - ssh.close(); - callback.error( - "Failed to initialize SFTP subsystem: " + errMessage(e) - ); - Log.e(TAG, "Failed to initialize SFTP subsystem", e); - return; - } + public void editProfile(JSONArray args, CallbackContext callback) { + cordova + .getThreadPool() + .execute( + new Runnable() { + public void run() { + try { + String requestedID = nullableProfileID(args); + JSONObject existing = requestedID == null + ? null + : securityStore.getProfile(requestedID); + String hostname = args.getString(1).trim(); + int port = args.optInt(2, 22); + String username = args.getString(3).trim(); + String authType = args.optString(4, "password"); + if (hostname.isEmpty()) { + throw new IllegalArgumentException("Hostname is required"); + } + if (username.isEmpty()) { + throw new IllegalArgumentException("Username is required"); + } + if (port < 1 || port > 65535) { + throw new IllegalArgumentException( + "Port must be between 1 and 65535" + ); + } - try { - sftp.getSubsystemChannel().setCharsetEncoding("UTF-8"); - } catch (UnsupportedEncodingException | SshException e) { - // Fallback to default encoding if UTF-8 fails - Log.w( - TAG, - "Failed to set UTF-8 encoding, falling back to default", - e + JSONObject profile = new JSONObject(); + profile.put("hostname", hostname); + profile.put("port", port); + profile.put("username", username); + profile.put("authType", authType); + if ("key".equals(authType)) { + String keyFile = args.optString(6); + if (!keyFile.isEmpty()) { + profile.put( + "privateKey", + Base64.encodeToString(readUri(keyFile), Base64.NO_WRAP) + ); + profile.put("passphrase", args.optString(7)); + } else if ( + existing != null && + "key".equals(existing.optString("authType")) + ) { + profile.put("privateKey", existing.getString("privateKey")); + profile.put("passphrase", existing.optString("passphrase")); + } else { + throw new IllegalArgumentException( + "Select a private key file" ); } - callback.success(); - Log.d(TAG, "Connected successfully to " + connectionID); - return; + } else { + String password = args.optString(5); + if ( + password.isEmpty() && + existing != null && + "password".equals(existing.optString("authType")) + ) { + password = existing.optString("password"); + } + profile.put("password", password); } - callback.error("Failed to establish SSH connection"); - } catch (UnresolvedAddressException e) { - callback.error("Cannot resolve host address"); - Log.e(TAG, "Cannot resolve host address", e); - } catch (PermissionDeniedException e) { - callback.error("Authentication failed: " + e.getMessage()); - Log.e(TAG, "Authentication failed", e); - } catch (SshException e) { - callback.error("SSH error: " + errMessage(e)); - Log.e(TAG, "SSH error", e); - } catch (IOException e) { - callback.error("I/O error: " + errMessage(e)); - Log.e(TAG, "I/O error", e); + String profileID = securityStore.saveProfile( + requestedID, + profile + ); + callback.success(profileInfo(profileID, profile)); } catch (Exception e) { - callback.error("Unexpected error: " + errMessage(e)); - Log.e(TAG, "Unexpected error", e); + callback.error( + "Could not securely save SFTP profile: " + errMessage(e) + ); + Log.e(TAG, "Could not save SFTP profile", e); } } } ); } - public void connectUsingKeyFile(JSONArray args, CallbackContext callback) { + public void getProfileInfo(JSONArray args, CallbackContext callback) { cordova .getThreadPool() .execute( new Runnable() { public void run() { try { - String host = args.optString(0); - int port = args.optInt(1); - String username = args.optString(2); - String keyFile = args.optString(3); - String passphrase = args.optString(4); - DocumentFile file = DocumentFile.fromSingleUri( - context, - Uri.parse(keyFile) + String profileID = args.getString(0); + callback.success( + profileInfo(profileID, securityStore.getProfile(profileID)) ); - Uri uri = file.getUri(); - ContentResolver contentResolver = context.getContentResolver(); - InputStream in = contentResolver.openInputStream(uri); - -// for `appDataDirectory`, Ref: https://developer.android.com/reference/android/content/Context#getExternalFilesDir(java.lang.String) -// the absolute path to application-specific directory. May return *null* if shared storage is not currently available. - File appDataDirectory = context.getExternalFilesDir(null); - if (appDataDirectory != null) { - com.sshtools.common.logger.Log.getDefaultContext().enableFile(com.sshtools.common.logger.Log.Level.DEBUG, new File(appDataDirectory,"synergy.log")); - } -// JCEProvider.enableBouncyCastle(false); + } catch (Exception e) { + callback.error("Could not read SFTP profile: " + errMessage(e)); + } + } + } + ); + } - Log.i(TAG, "All Available Security Providers (Security.getProviders() : " + Arrays.toString(Security.getProviders())); - Log.i(TAG, "All Available Security Providers for ED25519 (Security.getProviders(\"KeyPairGenerator.Ed25519\"\") : " + Arrays.toString(Security.getProviders("KeyPairGenerator.Ed25519"))); - Log.i(TAG, "BC Security Provider Name (`Security.getProvider(BouncyCastleProvider.PROVIDER_NAME)`) : " + Security.getProvider(BouncyCastleProvider.PROVIDER_NAME)); - Security.removeProvider("BC"); - Security.insertProviderAt(new BouncyCastleProvider(), 1); + private static JSONObject profileInfo(String profileID, JSONObject profile) + throws JSONException { + JSONObject info = new JSONObject(); + info.put("profileId", profileID); + info.put("hostname", profile.getString("hostname")); + info.put("port", profile.optInt("port", 22)); + info.put("username", profile.getString("username")); + info.put("authType", profile.optString("authType", "password")); + return info; + } - Log.i(TAG, "(After Inserting BC) All Available Security Providers (Security.getProviders() : " + Arrays.toString(Security.getProviders())); - Log.i(TAG, "(After Inserting BC) All Available Security Providers for ED25519 (Security.getProviders(\"KeyPairGenerator.Ed25519\"\") : " + Arrays.toString(Security.getProviders("KeyPairGenerator.Ed25519"))); - Log.i(TAG, "(After Inserting BC) BC Security Provider Name (`Security.getProvider(BouncyCastleProvider.PROVIDER_NAME)`) : " + Security.getProvider(BouncyCastleProvider.PROVIDER_NAME)); + private static String nullableProfileID(JSONArray args) { + if (args.length() == 0 || args.isNull(0)) return null; + String value = args.optString(0, null); + if (value == null) return null; + value = value.trim(); + if ( + value.isEmpty() || + "null".equalsIgnoreCase(value) || + "undefined".equalsIgnoreCase(value) + ) return null; + return value; + } - SshKeyPair keyPair = null; - try { - keyPair = SshKeyUtils.getPrivateKey(in, passphrase); - } catch (InvalidPassphraseException e) { - callback.error("Invalid passphrase for key file"); - Log.e(TAG, "Invalid passphrase for key file", e); - return; - } catch (IOException e) { - callback.error("Could not read key file: " + errMessage(e)); - Log.e(TAG, "Could not read key file", e); - return; + public void deleteProfile(JSONArray args, CallbackContext callback) { + String profileID = args.optString(0); + try { + JSONObject profile = securityStore.getProfile(profileID); + String label = profile.optString("username") + "@" + profile.optString("hostname"); + activity.runOnUiThread( + () -> { + AlertDialog confirmation = new AlertDialog.Builder(activity) + .setTitle("Delete saved SSH credentials?") + .setMessage( + "Remove the encrypted SFTP/SSH profile for " + label + "?" + ) + .setNegativeButton( + "Cancel", + (ignored, which) -> callback.error("Profile deletion was cancelled") + ) + .setPositiveButton( + "Delete", + (ignored, which) -> { + securityStore.deleteProfile(profileID); + callback.success(); } + ) + .create(); + confirmation.setOnCancelListener( + ignored -> callback.error("Profile deletion was cancelled") + ); + confirmation.show(); + } + ); + } catch (Exception e) { + callback.error("Could not delete SFTP profile: " + errMessage(e)); + } + } - ssh = SshClientBuilder.create() - .withHostname(host) - .withPort(port) - .withUsername(username) - .withIdentities(keyPair) - .build(); + public void writeShell(JSONArray args, CallbackContext callback) { + RemoteShell shell = remoteShells.get(args.optString(0)); + if (shell == null || shell.finished.get()) { + callback.error("SSH shell is not connected"); + return; + } + shell.write(args.optString(1), callback); + } - if (ssh.isConnected()) { - connectionID = username + "@" + host; - try { - sftp = SftpClientBuilder.create().withClient(ssh).build(); - } catch (IOException | SshException e) { - ssh.close(); - callback.error( - "Failed to initialize SFTP subsystem: " + errMessage(e) - ); - Log.e(TAG, "Failed to initialize SFTP subsystem", e); - return; - } + public void resizeShell(JSONArray args, CallbackContext callback) { + String shellID = args.optString(0); + RemoteShell shell = remoteShells.get(shellID); + if (shell == null || shell.finished.get()) { + callback.error("SSH shell is not connected"); + return; + } + shell.channel.changeTerminalDimensions( + Math.max(1, args.optInt(1, 80)), + Math.max(1, args.optInt(2, 24)), + 0, + 0 + ); + callback.success(); + } - try { - sftp.getSubsystemChannel().setCharsetEncoding("UTF-8"); - } catch (UnsupportedEncodingException | SshException e) { - // Fallback to default encoding if UTF-8 fails - Log.w( - TAG, - "Failed to set UTF-8 encoding, falling back to default", - e - ); - } + public void closeShell(JSONArray args, CallbackContext callback) { + RemoteShell shell = remoteShells.get(args.optString(0)); + if (shell != null) shell.finish(null, null); + callback.success(); + } + + public boolean execute( + String action, + JSONArray args, + CallbackContext callback + ) { + if (!isAllowedAction(action)) { + callback.error("SFTP action is not available: " + action); + return false; + } + try { + Method method = getClass() + .getDeclaredMethod(action, JSONArray.class, CallbackContext.class); + + if (method != null) { + method.invoke(this, args, callback); + return true; + } + } catch (NoSuchMethodException e) { + callback.error("Method not found: " + action); + return false; + } catch (SecurityException e) { + callback.error("Security exception: " + e.getMessage()); + return false; + } catch (Exception e) { + callback.error("Exception: " + e.getMessage()); + return false; + } + + return false; + } + + private static boolean isAllowedAction(String action) { + switch (action) { + case "exec": + case "connectUsingProfile": + case "saveProfile": + case "editProfile": + case "getProfileInfo": + case "deleteProfile": + case "getFile": + case "putFile": + case "lsDir": + case "stat": + case "mkdir": + case "rm": + case "createFile": + case "rename": + case "pwd": + case "close": + case "isConnected": + case "openShellUsingProfile": + case "writeShell": + case "resizeShell": + case "closeShell": + return true; + default: + return false; + } + } + + public void connectUsingProfile(JSONArray args, CallbackContext callback) { + cordova + .getThreadPool() + .execute( + new Runnable() { + public void run() { + ConnectionSecurity security = null; + String profileID = args.optString(0); + try { + JSONObject profile = securityStore.getProfile(profileID); + ConnectionSecurity profileSecurity = new ConnectionSecurity( + profile.getString("hostname"), + profile.optInt("port", 22) + ); + security = profileSecurity; + if ( + establishConnection( + buildProfileBuilder(profile), + profileID, + profileSecurity + ) + ) { callback.success(); - Log.d(TAG, "Connected successfully to " + connectionID); return; } - + if (security.report(callback)) return; callback.error("Failed to establish SSH connection"); - } catch (UnresolvedAddressException e) { - callback.error("Cannot resolve host address"); - Log.e(TAG, "Cannot resolve host address", e); - } catch (PermissionDeniedException e) { - callback.error("Authentication failed: " + e.getMessage()); - Log.e(TAG, "Authentication failed", e); - } catch (SshException e) { - callback.error("SSH error: " + errMessage(e)); - Log.e(TAG, "SSH error", e); - } catch (IOException e) { - callback.error("I/O error: " + errMessage(e)); - Log.e(TAG, "I/O error", e); - } catch (SecurityException e) { - callback.error("Security error: " + errMessage(e)); - Log.e(TAG, "Security error", e); + } catch (InvalidPassphraseException e) { + callback.error("Invalid passphrase for stored key"); } catch (Exception e) { - callback.error("Unexpected error: " + errMessage(e)); - Log.e(TAG, "Unexpected error", e); + if (security != null && security.report(callback)) return; + callback.error("Failed to connect SFTP profile: " + errMessage(e)); + Log.e(TAG, "Failed to connect SFTP profile", e); + } catch (OutOfMemoryError e) { + synchronized (connectionLock) { + closeConnectionQuietly(); + } + callback.error("Not enough memory to initialize SFTP"); } } } @@ -412,11 +1077,22 @@ public void lsDir(JSONArray args, CallbackContext callback) { .execute( new Runnable() { public void run() { + SftpClient activeSftp = null; try { String path = args.optString(0); - if (ssh != null && sftp != null) { + synchronized (connectionLock) { + activeSftp = sftp; + if ( + ssh == null || + !ssh.isConnected() || + activeSftp == null || + activeSftp.isClosed() + ) { + callback.error("Not connected"); + return; + } JSONArray files = new JSONArray(); - for (SftpFile file : sftp.ls(path)) { + for (SftpFile file : activeSftp.ls(path)) { String filename = file.getFilename(); if (filename.equals(".") || filename.equals("..")) { continue; @@ -443,11 +1119,11 @@ public void run() { if (permissions.charAt(0) == 'l') { fileInfo.put("isLink", true); try { - String linkTarget = sftp.getSymbolicLinkTarget( + String linkTarget = activeSftp.getSymbolicLinkTarget( file.getAbsolutePath() ); fileInfo.put("linkTarget", linkTarget); - SftpFileAttributes linkAttributes = sftp.stat( + SftpFileAttributes linkAttributes = activeSftp.stat( linkTarget ); fileInfo.put("isFile", linkAttributes.isFile()); @@ -473,15 +1149,30 @@ public void run() { callback.success(files); return; } - callback.error("Not connected"); - } catch (SftpStatusException | JSONException | SshException e) { + } catch (SftpStatusException | JSONException e) { callback.error(errMessage(e)); + } catch (SshException | RuntimeException e) { + invalidateSftpConnection(activeSftp, e); + callback.error( + "SFTP connection was interrupted. Reconnect and try again." + ); } } } ); } + private void invalidateSftpConnection( + SftpClient failedSftp, + Exception failure + ) { + synchronized (connectionLock) { + if (failedSftp == null || sftp != failedSftp) return; + Log.w(TAG, "Invalidating failed SFTP connection", failure); + closeConnectionQuietly(); + } + } + public void stat(JSONArray args, CallbackContext callback) { cordova .getThreadPool() @@ -728,16 +1419,13 @@ public void close(JSONArray args, CallbackContext callback) { .execute( new Runnable() { public void run() { - try { - if (ssh != null) { - ssh.close(); - sftp.quit(); + synchronized (connectionLock) { + if (ssh != null || sftp != null) { + closeConnectionQuietly(); callback.success(); return; } - callback.error("Not connected"); - } catch (IOException | SshException e) { - callback.error(errMessage(e)); + callback.success(); } } } @@ -750,17 +1438,19 @@ public void isConnected(JSONArray args, CallbackContext callback) { .execute( new Runnable() { public void run() { - if ( - ssh != null && - ssh.isConnected() && - sftp != null && - !sftp.isClosed() - ) { - callback.success(connectionID); - return; - } + synchronized (connectionLock) { + if ( + ssh != null && + ssh.isConnected() && + sftp != null && + !sftp.isClosed() + ) { + callback.success(connectionID); + return; + } - callback.success(0); + callback.success(0); + } } } ); diff --git a/src/plugins/sftp/src/com/foxdebug/sftp/SftpSecurityStore.java b/src/plugins/sftp/src/com/foxdebug/sftp/SftpSecurityStore.java new file mode 100644 index 000000000..9951566fc --- /dev/null +++ b/src/plugins/sftp/src/com/foxdebug/sftp/SftpSecurityStore.java @@ -0,0 +1,75 @@ +package com.foxdebug.sftp; + +import android.content.Context; +import com.foxdebug.acode.rk.auth.EncryptedPreferenceManager; +import java.security.GeneralSecurityException; +import java.util.UUID; +import org.json.JSONException; +import org.json.JSONObject; + +final class SftpSecurityStore { + + static final String PROFILE_PREFIX = "profile-"; + private static final String PROFILE_PREFS = "acode_sftp_profiles_v1"; + private static final String HOST_PREFS = "acode_ssh_known_hosts_v1"; + + private final EncryptedPreferenceManager profiles; + private final EncryptedPreferenceManager knownHosts; + + SftpSecurityStore(Context context) { + // SSH credentials and trusted-host records must never fall back to plaintext. + profiles = new EncryptedPreferenceManager(context, PROFILE_PREFS, false); + knownHosts = new EncryptedPreferenceManager(context, HOST_PREFS, false); + } + + static boolean isProfileID(String value) { + return value != null && value.startsWith(PROFILE_PREFIX); + } + + synchronized String saveProfile(String requestedID, JSONObject profile) + throws GeneralSecurityException, JSONException { + String profileID = isProfileID(requestedID) + ? requestedID + : PROFILE_PREFIX + UUID.randomUUID(); + if (!profiles.setStringSync(profileID, profile.toString())) { + throw new GeneralSecurityException("Could not persist SFTP profile"); + } + return profileID; + } + + synchronized JSONObject getProfile(String profileID) + throws GeneralSecurityException, JSONException { + if (!isProfileID(profileID)) throw new GeneralSecurityException( + "Invalid SFTP profile ID" + ); + String storedProfile = profiles.getString(profileID, null); + if (storedProfile == null) throw new GeneralSecurityException( + "SFTP profile was not found" + ); + return new JSONObject(storedProfile); + } + + synchronized void deleteProfile(String profileID) { + if (isProfileID(profileID)) profiles.removeSync(profileID); + } + + synchronized JSONObject getKnownHost(String host) throws JSONException { + String value = knownHosts.getString(host, null); + return value == null ? null : new JSONObject(value); + } + + synchronized void trustHost( + String host, + String algorithm, + String fingerprint, + String publicKey + ) throws JSONException { + JSONObject record = new JSONObject(); + record.put("algorithm", algorithm); + record.put("fingerprint", fingerprint); + record.put("publicKey", publicKey); + if (!knownHosts.setStringSync(host, record.toString())) { + throw new JSONException("Could not persist trusted SSH host"); + } + } +} diff --git a/src/plugins/sftp/www/sftp.js b/src/plugins/sftp/www/sftp.js index cd1859ea6..1fbbb76e3 100644 --- a/src/plugins/sftp/www/sftp.js +++ b/src/plugins/sftp/www/sftp.js @@ -2,22 +2,21 @@ module.exports = { exec: function (command, onSuccess, onFail) { cordova.exec(onSuccess, onFail, 'Sftp', 'exec', [command]); }, - connectUsingPassword: function (host, port, username, password, onSuccess, onFail) { - if (typeof port != 'number') { - throw new Error('Port must be number'); - } - - port = Number.parseInt(port); - cordova.exec(onSuccess, onFail, 'Sftp', 'connectUsingPassword', [host, port, username, password]); - }, - connectUsingKeyFile: function (host, port, username, keyFile, passphrase, onSuccess, onFail) { - if (typeof port != 'number') { - throw new Error('Port must be number'); - } - - port = Number.parseInt(port); - cordova.exec(onSuccess, onFail, 'Sftp', 'connectUsingKeyFile', [host, port, username, keyFile, passphrase]); - }, + connectUsingProfile: function (profileId, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'connectUsingProfile', [profileId]); + }, + saveProfile: function (profileId, host, port, username, authType, password, keyFile, passphrase, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'saveProfile', [profileId, host, port, username, authType, password, keyFile, passphrase]); + }, + editProfile: function (profileId, host, port, username, authType, password, keyFile, passphrase, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'editProfile', [profileId, host, port, username, authType, password, keyFile, passphrase]); + }, + getProfileInfo: function (profileId, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'getProfileInfo', [profileId]); + }, + deleteProfile: function (profileId, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'deleteProfile', [profileId]); + }, getFile: function (filename, localFilename, onSuccess, onFail) { cordova.exec(onSuccess, onFail, 'Sftp', 'getFile', [filename, localFilename]); }, @@ -48,7 +47,19 @@ module.exports = { close: function (onSuccess, onFail) { cordova.exec(onSuccess, onFail, 'Sftp', 'close', []); }, - isConnected: function (onSuccess, onFail) { - cordova.exec(onSuccess, onFail, 'Sftp', 'isConnected', []); - } -}; + isConnected: function (onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'isConnected', []); + }, + openShellUsingProfile: function (profileId, cols, rows, onEvent, onFail) { + cordova.exec(onEvent, onFail, 'Sftp', 'openShellUsingProfile', [profileId, cols, rows]); + }, + writeShell: function (sessionId, data, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'writeShell', [sessionId, data]); + }, + resizeShell: function (sessionId, cols, rows, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'resizeShell', [sessionId, cols, rows]); + }, + closeShell: function (sessionId, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'closeShell', [sessionId]); + } +}; diff --git a/src/utils/Url.js b/src/utils/Url.js index 948503129..f79e69be0 100644 --- a/src/utils/Url.js +++ b/src/utils/Url.js @@ -43,6 +43,24 @@ export default { }); }, + /** + * Checks whether a URL is the same as, or nested below, a parent URL. + * Query parameters are ignored and path segment boundaries are preserved. + * @param {string} candidate + * @param {string} parent + * @returns {boolean} + */ + isSameOrDescendant(candidate, parent) { + const normalize = (value) => { + value = this.parse(value).url; + return value.endsWith("/") ? value.slice(0, -1) : value; + }; + candidate = normalize(candidate); + parent = normalize(parent); + if (candidate === parent) return true; + return candidate.startsWith(`${parent}/`); + }, + /** * * @param {String} url diff --git a/src/utils/shell.js b/src/utils/shell.js new file mode 100644 index 000000000..434ad922c --- /dev/null +++ b/src/utils/shell.js @@ -0,0 +1,13 @@ +/** + * Quote one value for use as a literal argument in a POSIX shell command. + * Control characters are rejected because terminal input is line-oriented. + * @param {unknown} value + * @returns {string} + */ +export function quotePosixShellArg(value) { + const argument = String(value); + if (/\0|\r|\n/.test(argument)) { + throw new Error("Shell arguments cannot contain control characters"); + } + return `'${argument.replaceAll("'", `'"'"'`)}'`; +} diff --git a/tests/unit/sftpProfiles.test.js b/tests/unit/sftpProfiles.test.js new file mode 100644 index 000000000..eb160a629 --- /dev/null +++ b/tests/unit/sftpProfiles.test.js @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { deleteMock } = vi.hoisted(() => ({ deleteMock: vi.fn() })); + +vi.mock("fileSystem", () => ({ + default: () => ({ delete: deleteMock }), +})); + +import { + createSftpProfileUrl, + editSftpProfile, + getSftpProfileId, + getSftpProfileInfo, + migrateLegacySftpProfiles, +} from "lib/sftpProfiles"; + +describe("SFTP secure profiles", () => { + beforeEach(() => { + const values = new Map(); + globalThis.localStorage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + removeItem: (key) => values.delete(key), + }; + globalThis.DATA_STORAGE = "file:///data/"; + deleteMock.mockReset(); + }); + + it("creates and recognizes opaque profile URLs", () => { + const url = createSftpProfileUrl("profile-123", "/project/file.js"); + expect(url).toBe("sftp://profile-123/project/file.js"); + expect(getSftpProfileId(url)).toBe("profile-123"); + expect(getSftpProfileId("sftp://user:secret@example.com/project")).toBeNull(); + }); + + it("passes transient form credentials to the encrypted native profile store", async () => { + const editProfile = vi.fn((...args) => { + args.at(-2)({ profileId: "profile-native" }); + }); + globalThis.sftp = { editProfile }; + + await editSftpProfile({ + hostname: "example.com", + port: 2222, + username: "user", + authType: "key", + keyFile: "content://private-key", + passPhrase: "key secret", + }); + + expect(editProfile).toHaveBeenCalledWith( + null, + "example.com", + 2222, + "user", + "key", + "", + "content://private-key", + "key secret", + expect.any(Function), + expect.any(Function), + ); + }); + + it("reads only non-secret profile metadata from native storage", async () => { + const getProfileInfo = vi.fn((profileId, resolve) => { + resolve({ + profileId, + hostname: "example.com", + port: 22, + username: "user", + authType: "password", + }); + }); + globalThis.sftp = { getProfileInfo }; + + const profile = await getSftpProfileInfo("profile-native"); + + expect(profile).not.toHaveProperty("password"); + expect(profile).not.toHaveProperty("privateKey"); + }); + + it("migrates repeated credential URLs once and removes credentials", async () => { + const saveProfile = vi.fn((...args) => { + const onSuccess = args.at(-2); + onSuccess("profile-abcd"); + }); + globalThis.sftp = { saveProfile }; + const root = "sftp://user:p%40ss@example.com:2222/"; + const file = "sftp://user:p%40ss@example.com:2222/project/app.js"; + localStorage.setItem( + "storageList", + JSON.stringify([{ storageType: "sftp", url: root }]), + ); + localStorage.setItem("recentFiles", JSON.stringify([file])); + + await migrateLegacySftpProfiles(); + + expect(saveProfile).toHaveBeenCalledTimes(1); + expect(JSON.parse(localStorage.getItem("storageList"))[0].url).toBe( + "sftp://profile-abcd/", + ); + expect(JSON.parse(localStorage.getItem("recentFiles"))[0]).toBe( + "sftp://profile-abcd/project/app.js", + ); + expect(localStorage.getItem("storageList")).not.toContain("p%40ss"); + expect(localStorage.getItem("sftpNativeProfileMigration")).toBe("1"); + + const restoredLegacy = "sftp://other:secret@example.net/"; + localStorage.setItem("recentFolders", JSON.stringify([restoredLegacy])); + await migrateLegacySftpProfiles(); + expect(saveProfile).toHaveBeenCalledTimes(1); + expect(JSON.parse(localStorage.getItem("recentFolders"))[0]).toBe( + restoredLegacy, + ); + }); + + it("fails closed when a legacy URL cannot be encrypted", async () => { + globalThis.sftp = { + saveProfile: (...args) => args.at(-1)("Keystore unavailable"), + }; + const legacy = "sftp://user:secret@example.com/"; + localStorage.setItem( + "storageList", + JSON.stringify([{ storageType: "sftp", url: legacy }]), + ); + + await expect(migrateLegacySftpProfiles()).rejects.toThrow( + "SFTP credentials could not be moved to encrypted native storage", + ); + + expect(JSON.parse(localStorage.getItem("storageList"))[0].url).toBe(legacy); + expect(localStorage.getItem("sftpNativeProfileMigration")).toBeNull(); + }); +}); diff --git a/tests/unit/shell.test.js b/tests/unit/shell.test.js new file mode 100644 index 000000000..6a7f5ef6d --- /dev/null +++ b/tests/unit/shell.test.js @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { quotePosixShellArg } from "utils/shell"; + +describe("quotePosixShellArg", () => { + it("quotes spaces and shell metacharacters as literal text", () => { + expect(quotePosixShellArg("/srv/project $(touch bad)")).toBe( + "'/srv/project $(touch bad)'", + ); + }); + + it("escapes embedded single quotes", () => { + expect(quotePosixShellArg("/srv/user's project")).toBe( + `'/srv/user'"'"'s project'`, + ); + }); + + it("rejects line-oriented control characters", () => { + expect(() => quotePosixShellArg("/srv/project\ncommand")).toThrow( + "control characters", + ); + }); +}); diff --git a/tests/unit/url.test.js b/tests/unit/url.test.js index f2304b659..0334ab0a0 100644 --- a/tests/unit/url.test.js +++ b/tests/unit/url.test.js @@ -85,6 +85,32 @@ describe("Url.safe", () => { }); }); +describe("Url.isSameOrDescendant", () => { + it("matches a remote root and its descendants", () => { + expect( + Url.isSameOrDescendant( + "sftp://user@host:22/project/src?keyFile=secret", + "sftp://user@host:22/project", + ), + ).toBe(true); + expect( + Url.isSameOrDescendant( + "sftp://user@host:22/project/", + "sftp://user@host:22/project", + ), + ).toBe(true); + }); + + it("preserves path segment boundaries", () => { + expect( + Url.isSameOrDescendant( + "sftp://user@host:22/project-copy", + "sftp://user@host:22/project", + ), + ).toBe(false); + }); +}); + describe("Url.formate", () => { it("builds a url from its parts", () => { expect(