From 73a528e4abdae18dcfc081585a4d69f39c6cb706 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:41:08 +0530 Subject: [PATCH 1/4] fix(sftp): recover from failed profile migration --- src/lib/sftpProfiles.js | 165 ++++++++++++++++++++++++-------- src/main.js | 44 ++++++++- tests/unit/sftpProfiles.test.js | 93 ++++++++++++++++-- 3 files changed, 253 insertions(+), 49 deletions(-) diff --git a/src/lib/sftpProfiles.js b/src/lib/sftpProfiles.js index 6d5aeb0b2..52b4ab05b 100644 --- a/src/lib/sftpProfiles.js +++ b/src/lib/sftpProfiles.js @@ -3,7 +3,7 @@ import Url from "utils/Url"; const PROFILE_PREFIX = "profile-"; const MIGRATION_MARKER = "sftpNativeProfileMigration"; -const MIGRATION_VERSION = "1"; +const MIGRATION_VERSION = "2"; const MIGRATED_STORAGE_KEYS = [ "storageList", "folders", @@ -12,11 +12,20 @@ const MIGRATED_STORAGE_KEYS = [ "recentFolders", "fileBrowserState", ]; +const REMOVE_VALUE = Symbol("remove-value"); export function getSftpProfileId(url) { - if (!/^sftp:/.test(url || "")) return null; - const { hostname } = Url.decodeUrl(url); - return hostname?.startsWith(PROFILE_PREFIX) ? hostname : null; + if (!/^sftp:/i.test(url || "")) return null; + try { + const { hostname, username, password, query } = Url.decodeUrl(url); + const hasLegacyCredentials = + username || password || query?.keyFile || query?.passPhrase; + return hostname?.startsWith(PROFILE_PREFIX) && !hasLegacyCredentials + ? hostname + : null; + } catch { + return null; + } } export function createSftpProfileUrl(profileId, pathname = "/") { @@ -93,28 +102,42 @@ export function deleteSftpProfile(profileId) { /** * Moves legacy credential-bearing SFTP URLs into encrypted native profiles. - * Migration fails closed before third-party plugins load if encryption is unavailable. + * Profiles that cannot be encrypted are removed before third-party plugins load. + * Unsaved editor tabs keep their cache and are restored as local recovery tabs. */ export async function migrateLegacySftpProfiles() { - if (localStorage.getItem(MIGRATION_MARKER) === MIGRATION_VERSION) return; + if (localStorage.getItem(MIGRATION_MARKER) === MIGRATION_VERSION) { + return { failures: [], removedReferences: 0, recoveredFiles: 0 }; + } const profileCache = new Map(); const copiedKeys = new Set(); - let migrationError = null; + const failures = []; + let removedReferences = 0; + let recoveredFiles = 0; for (const storageKey of MIGRATED_STORAGE_KEYS) { const raw = localStorage.getItem(storageKey); - if (!raw) continue; + if (!raw || !/sftp:/i.test(raw)) continue; let value; + let isJson = true; try { value = JSON.parse(raw); } catch { - continue; + value = raw; + isJson = false; } - const migrated = await migrateValue(value); + const migrated = await migrateValue(value, storageKey); if (migrated.changed) { - localStorage.setItem(storageKey, JSON.stringify(migrated.value)); + if (migrated.value === REMOVE_VALUE) { + localStorage.removeItem(storageKey); + } else { + localStorage.setItem( + storageKey, + isJson ? JSON.stringify(migrated.value) : migrated.value, + ); + } } } @@ -127,51 +150,68 @@ export async function migrateLegacySftpProfiles() { } } - if (migrationError) { - throw new Error( - "SFTP credentials could not be moved to encrypted native storage", - { cause: migrationError }, - ); - } - localStorage.setItem(MIGRATION_MARKER, MIGRATION_VERSION); + return { failures, removedReferences, recoveredFiles }; - async function migrateValue(value) { + async function migrateValue(value, storageKey) { 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); + const migrated = await migrateValue(item, storageKey); changed ||= migrated.changed; - next.push(migrated.value); + if (migrated.value !== REMOVE_VALUE) next.push(migrated.value); } return { value: next, changed }; } if (value && typeof value === "object") { let changed = false; const next = {}; + let recoverFile = false; for (const [key, item] of Object.entries(value)) { - const migrated = await migrateValue(item); + const migrated = await migrateValue(item, storageKey); changed ||= migrated.changed; + if (migrated.value === REMOVE_VALUE) { + if (key === "uri" && storageKey === "files" && value.isUnsaved) { + recoverFile = true; + continue; + } + if (key === "url" || key === "uri") { + return { value: REMOVE_VALUE, changed: true }; + } + continue; + } next[key] = migrated.value; } + if (recoverFile) { + next.uri = null; + next.filename = `Recovered ${value.filename || "remote file"}`; + next.isUnsaved = true; + next.deletedFile = true; + recoveredFiles++; + } return { value: next, changed }; } return { value, changed: false }; } async function migrateUrl(value) { - if (!/^sftp:/.test(value) || getSftpProfileId(value)) { + if (!/^sftp:/i.test(value) || getSftpProfileId(value)) { return { value, changed: false }; } + let credentials; try { + credentials = Url.decodeUrl(value); const { username, password, hostname, pathname, port, query } = - Url.decodeUrl(value); - if (!hostname || !username) return { value, changed: false }; + credentials; + if (!hostname || !username) { + throw new Error("The saved SFTP address is incomplete"); + } const keyFile = normalizeLegacyValue(query?.keyFile); const passPhrase = normalizeLegacyValue(query?.passPhrase); + if (keyFile) copiedKeys.add(keyFile); const authType = keyFile ? "key" : "password"; const signature = JSON.stringify({ hostname, @@ -182,30 +222,73 @@ export async function migrateLegacySftpProfiles() { 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); + let cachedProfile = profileCache.get(signature); + if (!cachedProfile) { + try { + const profileId = await saveSftpProfile({ + hostname, + port: port || 22, + username, + authType, + password: password || "", + keyFile, + passPhrase, + }); + cachedProfile = { profileId }; + } catch (error) { + cachedProfile = { + error, + failure: createFailure(error, credentials), + }; + failures.push(cachedProfile.failure); + } + profileCache.set(signature, cachedProfile); + } + if (cachedProfile.error) { + removedReferences++; + return { value: REMOVE_VALUE, changed: true }; } return { - value: createSftpProfileUrl(profileId, pathname || "/"), + value: createSftpProfileUrl(cachedProfile.profileId, pathname || "/"), changed: true, }; } catch (error) { console.warn("Could not migrate legacy SFTP URL", error); - migrationError ||= error; - return { value, changed: false }; + const failure = createFailure(error, credentials); + failures.push(failure); + removedReferences++; + return { value: REMOVE_VALUE, changed: true }; + } + } +} + +function createFailure(error, credentials = {}) { + credentials ||= {}; + const sensitiveValues = [ + credentials.password, + credentials.query?.passPhrase, + credentials.query?.keyFile, + ].filter(Boolean); + return { + hostname: credentials.hostname || "unknown host", + username: credentials.username || "unknown user", + message: sanitizeError(error, sensitiveValues), + }; +} + +function sanitizeError(error, sensitiveValues) { + let message = error?.message || String(error || "Unknown migration error"); + if (error?.cause) { + const cause = error.cause?.message || String(error.cause); + if (cause && !message.includes(cause)) message += `: ${cause}`; + } + message = message.replace(/sftp:\/\/[^\s"'<>]+/gi, "sftp://[redacted]"); + for (const value of sensitiveValues) { + for (const secret of [String(value), encodeURIComponent(String(value))]) { + if (secret) message = message.split(secret).join("[redacted]"); } } + return message; } function normalizeLegacyValue(value) { diff --git a/src/main.js b/src/main.js index 381a48e1f..87b6f9a44 100644 --- a/src/main.js +++ b/src/main.js @@ -28,6 +28,7 @@ import Contextmenu from "components/contextmenu"; import Sidebar from "components/sidebar"; import tile from "components/tile"; import toast from "components/toast"; +import alert from "dialogs/alert"; import confirm from "dialogs/confirm"; import intentHandler, { processPendingIntents } from "handlers/intent"; import keyboardHandler, { keydownState } from "handlers/keyboard"; @@ -322,7 +323,15 @@ async function onDeviceReady() { await lang.set(settings.value.lang); acode.setLoadingMessage("Securing SFTP profiles..."); - await migrateLegacySftpProfiles(); + const sftpMigration = await migrateLegacySftpProfiles(); + if (sftpMigration.failures.length) { + for (const failure of sftpMigration.failures) { + logger.log( + "error", + `SFTP profile migration failed for ${failure.username}@${failure.hostname}: ${failure.message}`, + ); + } + } if (settings.value.developerMode) { try { @@ -335,6 +344,9 @@ async function onDeviceReady() { try { await loadApp(); + if (sftpMigration.failures.length) { + showSftpMigrationReport(sftpMigration); + } } catch (error) { window.log("error", error); toast(`Error: ${error.message}`); @@ -481,6 +493,36 @@ async function onDeviceReady() { .catch(console.error); } +function showSftpMigrationReport({ + failures, + removedReferences, + recoveredFiles, +}) { + const details = failures + .map( + ({ username, hostname, message }) => + `${escapeHtml(username)}@${escapeHtml(hostname)}: ${escapeHtml(message)}`, + ) + .join("
"); + const recoveryMessage = recoveredFiles + ? `

${recoveredFiles} unsaved remote file${recoveredFiles === 1 ? " was" : "s were"} kept as a recovery tab.` + : ""; + + alert( + "Some SFTP connections were removed", + `Acode could not move ${failures.length} saved SFTP connection${failures.length === 1 ? "" : "s"} into encrypted storage. The affected connection data and ${removedReferences} saved reference${removedReferences === 1 ? " were" : "s were"} removed so Acode could start safely. Please add the connection${failures.length === 1 ? "" : "s"} again.

${details}${recoveryMessage}`, + ); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + async function onLogin() { try { const user = await auth.getLoggedInUser(); diff --git a/tests/unit/sftpProfiles.test.js b/tests/unit/sftpProfiles.test.js index eb160a629..f704c6a38 100644 --- a/tests/unit/sftpProfiles.test.js +++ b/tests/unit/sftpProfiles.test.js @@ -31,6 +31,9 @@ describe("SFTP secure profiles", () => { expect(url).toBe("sftp://profile-123/project/file.js"); expect(getSftpProfileId(url)).toBe("profile-123"); expect(getSftpProfileId("sftp://user:secret@example.com/project")).toBeNull(); + expect( + getSftpProfileId("sftp://user:secret@profile-server/project"), + ).toBeNull(); }); it("passes transient form credentials to the encrypted native profile store", async () => { @@ -104,7 +107,7 @@ describe("SFTP secure profiles", () => { "sftp://profile-abcd/project/app.js", ); expect(localStorage.getItem("storageList")).not.toContain("p%40ss"); - expect(localStorage.getItem("sftpNativeProfileMigration")).toBe("1"); + expect(localStorage.getItem("sftpNativeProfileMigration")).toBe("2"); const restoredLegacy = "sftp://other:secret@example.net/"; localStorage.setItem("recentFolders", JSON.stringify([restoredLegacy])); @@ -115,21 +118,97 @@ describe("SFTP secure profiles", () => { ); }); - it("fails closed when a legacy URL cannot be encrypted", async () => { + it("removes failed connections and recovers unsaved remote files", async () => { globalThis.sftp = { - saveProfile: (...args) => args.at(-1)("Keystore unavailable"), + saveProfile: (...args) => + args.at(-1)(new Error("Keystore unavailable for secret")), }; const legacy = "sftp://user:secret@example.com/"; localStorage.setItem( "storageList", JSON.stringify([{ storageType: "sftp", url: legacy }]), ); + localStorage.setItem("recentFiles", JSON.stringify([`${legacy}app.js`])); + localStorage.setItem( + "files", + JSON.stringify([ + { + id: "cached-unsaved-file", + uri: `${legacy}draft.js`, + filename: "draft.js", + isUnsaved: true, + }, + { + id: "clean-remote-file", + uri: `${legacy}saved.js`, + filename: "saved.js", + isUnsaved: false, + }, + ]), + ); + + const result = await migrateLegacySftpProfiles(); + const files = JSON.parse(localStorage.getItem("files")); + + expect(JSON.parse(localStorage.getItem("storageList"))).toEqual([]); + expect(JSON.parse(localStorage.getItem("recentFiles"))).toEqual([]); + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + id: "cached-unsaved-file", + uri: null, + filename: "Recovered draft.js", + isUnsaved: true, + deletedFile: true, + }); + expect(JSON.stringify(files)).not.toContain("secret"); + expect(result).toMatchObject({ + removedReferences: 4, + recoveredFiles: 1, + }); + expect(result.failures).toEqual([ + { + hostname: "example.com", + username: "user", + message: "Keystore unavailable for [redacted]", + }, + ]); + expect(localStorage.getItem("sftpNativeProfileMigration")).toBe("2"); + }); + + it("scrubs malformed legacy SFTP values instead of marking them migrated", async () => { + globalThis.sftp = { saveProfile: vi.fn() }; + localStorage.setItem( + "recentFiles", + JSON.stringify([ + "sftp:///missing-host", + "sftp://user:%E0%A4%A@example.com/bad-encoding", + ]), + ); - await expect(migrateLegacySftpProfiles()).rejects.toThrow( - "SFTP credentials could not be moved to encrypted native storage", + const result = await migrateLegacySftpProfiles(); + + expect(JSON.parse(localStorage.getItem("recentFiles"))).toEqual([]); + expect(result.failures[0].message).toBe( + "The saved SFTP address is incomplete", ); + expect(result.failures[1].message).toBe("URI malformed"); + expect(localStorage.getItem("sftpNativeProfileMigration")).toBe("2"); + }); + + it("deletes an app-owned legacy key copy when its profile cannot migrate", async () => { + globalThis.sftp = { + saveProfile: (...args) => args.at(-1)("Private key is unreadable"), + }; + const legacy = + "sftp://user@example.com/?keyFile=file%3A%2F%2F%2Fdata%2Fid_rsa&passPhrase=key-secret"; + localStorage.setItem( + "storageList", + JSON.stringify([{ storageType: "sftp", url: legacy }]), + ); + + await migrateLegacySftpProfiles(); - expect(JSON.parse(localStorage.getItem("storageList"))[0].url).toBe(legacy); - expect(localStorage.getItem("sftpNativeProfileMigration")).toBeNull(); + expect(JSON.parse(localStorage.getItem("storageList"))).toEqual([]); + expect(deleteMock).toHaveBeenCalledTimes(1); }); }); From b03fd4b463c1af35ecbfdaf87658bfd725ba56a7 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:49:35 +0530 Subject: [PATCH 2/4] fix(sftp): migrate folder-state URL keys --- src/lib/sftpProfiles.js | 6 +++- tests/unit/sftpProfiles.test.js | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/lib/sftpProfiles.js b/src/lib/sftpProfiles.js index 52b4ab05b..141573166 100644 --- a/src/lib/sftpProfiles.js +++ b/src/lib/sftpProfiles.js @@ -170,6 +170,10 @@ export async function migrateLegacySftpProfiles() { const next = {}; let recoverFile = false; for (const [key, item] of Object.entries(value)) { + const migratedKey = await migrateUrl(key); + changed ||= migratedKey.changed; + if (migratedKey.value === REMOVE_VALUE) continue; + const migrated = await migrateValue(item, storageKey); changed ||= migrated.changed; if (migrated.value === REMOVE_VALUE) { @@ -182,7 +186,7 @@ export async function migrateLegacySftpProfiles() { } continue; } - next[key] = migrated.value; + next[migratedKey.value] = migrated.value; } if (recoverFile) { next.uri = null; diff --git a/tests/unit/sftpProfiles.test.js b/tests/unit/sftpProfiles.test.js index f704c6a38..3e0e99411 100644 --- a/tests/unit/sftpProfiles.test.js +++ b/tests/unit/sftpProfiles.test.js @@ -118,6 +118,63 @@ describe("SFTP secure profiles", () => { ); }); + it("migrates SFTP URLs used as folder expansion-state keys", async () => { + globalThis.sftp = { + saveProfile: (...args) => args.at(-2)("profile-folder"), + }; + const expandedFolder = + "sftp://user:secret@example.com/project/src/components"; + localStorage.setItem( + "folders", + JSON.stringify([ + { + url: "sftp://profile-existing/project", + opts: { listState: { [expandedFolder]: true } }, + }, + ]), + ); + + await migrateLegacySftpProfiles(); + + const folders = JSON.parse(localStorage.getItem("folders")); + expect(folders[0].opts.listState).toEqual({ + "sftp://profile-folder/project/src/components": true, + }); + expect(localStorage.getItem("folders")).not.toContain("secret"); + }); + + it("drops folder expansion-state keys when their profile cannot migrate", async () => { + globalThis.sftp = { + saveProfile: (...args) => args.at(-1)("Keystore unavailable"), + }; + const expandedFolder = "sftp://user:secret@example.com/project/src"; + localStorage.setItem( + "folders", + JSON.stringify([ + { + url: "sftp://profile-existing/project", + opts: { listState: { [expandedFolder]: true } }, + }, + ]), + ); + + const result = await migrateLegacySftpProfiles(); + + const folders = JSON.parse(localStorage.getItem("folders")); + expect(folders[0].opts.listState).toEqual({}); + expect(localStorage.getItem("folders")).not.toContain("secret"); + expect(result).toMatchObject({ + removedReferences: 1, + failures: [ + { + hostname: "example.com", + username: "user", + message: "Keystore unavailable", + }, + ], + }); + }); + it("removes failed connections and recovers unsaved remote files", async () => { globalThis.sftp = { saveProfile: (...args) => From 15ae4bf96fe53d4b771e435165e9179548981fa6 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:55:32 +0530 Subject: [PATCH 3/4] fix --- src/lib/sftpProfiles.js | 24 +++++++++++++---- tests/unit/sftpProfiles.test.js | 47 ++++++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/lib/sftpProfiles.js b/src/lib/sftpProfiles.js index 141573166..b9e7fc156 100644 --- a/src/lib/sftpProfiles.js +++ b/src/lib/sftpProfiles.js @@ -153,13 +153,13 @@ export async function migrateLegacySftpProfiles() { localStorage.setItem(MIGRATION_MARKER, MIGRATION_VERSION); return { failures, removedReferences, recoveredFiles }; - async function migrateValue(value, storageKey) { + async function migrateValue(value, storageKey, folderProfileId = null) { 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, storageKey); + const migrated = await migrateValue(item, storageKey, folderProfileId); changed ||= migrated.changed; if (migrated.value !== REMOVE_VALUE) next.push(migrated.value); } @@ -169,12 +169,20 @@ export async function migrateLegacySftpProfiles() { let changed = false; const next = {}; let recoverFile = false; + const nestedFolderProfileId = + storageKey === "folders" + ? getSftpProfileId(value.url) || folderProfileId + : null; for (const [key, item] of Object.entries(value)) { - const migratedKey = await migrateUrl(key); + const migratedKey = await migrateUrl(key, folderProfileId); changed ||= migratedKey.changed; if (migratedKey.value === REMOVE_VALUE) continue; - const migrated = await migrateValue(item, storageKey); + const migrated = await migrateValue( + item, + storageKey, + nestedFolderProfileId, + ); changed ||= migrated.changed; if (migrated.value === REMOVE_VALUE) { if (key === "uri" && storageKey === "files" && value.isUnsaved) { @@ -200,7 +208,7 @@ export async function migrateLegacySftpProfiles() { return { value, changed: false }; } - async function migrateUrl(value) { + async function migrateUrl(value, preferredProfileId = null) { if (!/^sftp:/i.test(value) || getSftpProfileId(value)) { return { value, changed: false }; } @@ -216,6 +224,12 @@ export async function migrateLegacySftpProfiles() { const keyFile = normalizeLegacyValue(query?.keyFile); const passPhrase = normalizeLegacyValue(query?.passPhrase); if (keyFile) copiedKeys.add(keyFile); + if (preferredProfileId) { + return { + value: createSftpProfileUrl(preferredProfileId, pathname || "/"), + changed: true, + }; + } const authType = keyFile ? "key" : "password"; const signature = JSON.stringify({ hostname, diff --git a/tests/unit/sftpProfiles.test.js b/tests/unit/sftpProfiles.test.js index 3e0e99411..cd7464cb5 100644 --- a/tests/unit/sftpProfiles.test.js +++ b/tests/unit/sftpProfiles.test.js @@ -118,9 +118,10 @@ describe("SFTP secure profiles", () => { ); }); - it("migrates SFTP URLs used as folder expansion-state keys", async () => { + it("reuses the folder profile for legacy expansion-state keys", async () => { + const saveProfile = vi.fn(); globalThis.sftp = { - saveProfile: (...args) => args.at(-2)("profile-folder"), + saveProfile, }; const expandedFolder = "sftp://user:secret@example.com/project/src/components"; @@ -138,31 +139,53 @@ describe("SFTP secure profiles", () => { const folders = JSON.parse(localStorage.getItem("folders")); expect(folders[0].opts.listState).toEqual({ - "sftp://profile-folder/project/src/components": true, + "sftp://profile-existing/project/src/components": true, }); + expect(saveProfile).not.toHaveBeenCalled(); expect(localStorage.getItem("folders")).not.toContain("secret"); }); - it("drops folder expansion-state keys when their profile cannot migrate", async () => { - globalThis.sftp = { - saveProfile: (...args) => args.at(-1)("Keystore unavailable"), - }; - const expandedFolder = "sftp://user:secret@example.com/project/src"; + it("uses one new profile for a legacy folder and its expansion state", async () => { + const saveProfile = vi.fn((...args) => args.at(-2)("profile-folder")); + globalThis.sftp = { saveProfile }; + const root = "sftp://user:secret@example.com/project"; + const expandedFolder = `${root}/src`; localStorage.setItem( "folders", JSON.stringify([ { - url: "sftp://profile-existing/project", + url: root, opts: { listState: { [expandedFolder]: true } }, }, ]), ); - const result = await migrateLegacySftpProfiles(); + await migrateLegacySftpProfiles(); const folders = JSON.parse(localStorage.getItem("folders")); - expect(folders[0].opts.listState).toEqual({}); - expect(localStorage.getItem("folders")).not.toContain("secret"); + expect(folders[0]).toMatchObject({ + url: "sftp://profile-folder/project", + opts: { + listState: { "sftp://profile-folder/project/src": true }, + }, + }); + expect(saveProfile).toHaveBeenCalledTimes(1); + }); + + it("drops URL-shaped object keys when their profile cannot migrate", async () => { + globalThis.sftp = { + saveProfile: (...args) => args.at(-1)("Keystore unavailable"), + }; + const legacyKey = "sftp://user:secret@example.com/project/src"; + localStorage.setItem( + "fileBrowserState", + JSON.stringify([{ [legacyKey]: true }]), + ); + + const result = await migrateLegacySftpProfiles(); + + expect(JSON.parse(localStorage.getItem("fileBrowserState"))).toEqual([{}]); + expect(localStorage.getItem("fileBrowserState")).not.toContain("secret"); expect(result).toMatchObject({ removedReferences: 1, failures: [ From 43ec06395de167e9db5660444092c5a21d6a3a02 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:01:52 +0530 Subject: [PATCH 4/4] fix --- src/lib/sftpProfiles.js | 13 +++++++++++-- tests/unit/sftpProfiles.test.js | 5 +++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/lib/sftpProfiles.js b/src/lib/sftpProfiles.js index b9e7fc156..2a8675323 100644 --- a/src/lib/sftpProfiles.js +++ b/src/lib/sftpProfiles.js @@ -169,11 +169,16 @@ export async function migrateLegacySftpProfiles() { let changed = false; const next = {}; let recoverFile = false; - const nestedFolderProfileId = + let nestedFolderProfileId = storageKey === "folders" ? getSftpProfileId(value.url) || folderProfileId : null; - for (const [key, item] of Object.entries(value)) { + const entries = Object.entries(value); + if (storageKey === "folders") { + const urlIndex = entries.findIndex(([key]) => key === "url"); + if (urlIndex > 0) entries.unshift(entries.splice(urlIndex, 1)[0]); + } + for (const [key, item] of entries) { const migratedKey = await migrateUrl(key, folderProfileId); changed ||= migratedKey.changed; if (migratedKey.value === REMOVE_VALUE) continue; @@ -194,6 +199,10 @@ export async function migrateLegacySftpProfiles() { } continue; } + if (storageKey === "folders" && key === "url") { + nestedFolderProfileId = + getSftpProfileId(migrated.value) || nestedFolderProfileId; + } next[migratedKey.value] = migrated.value; } if (recoverFile) { diff --git a/tests/unit/sftpProfiles.test.js b/tests/unit/sftpProfiles.test.js index cd7464cb5..b66dab3d9 100644 --- a/tests/unit/sftpProfiles.test.js +++ b/tests/unit/sftpProfiles.test.js @@ -149,13 +149,14 @@ describe("SFTP secure profiles", () => { const saveProfile = vi.fn((...args) => args.at(-2)("profile-folder")); globalThis.sftp = { saveProfile }; const root = "sftp://user:secret@example.com/project"; - const expandedFolder = `${root}/src`; + const expandedFolder = + "sftp://user:stale-secret@example.com/project/src"; localStorage.setItem( "folders", JSON.stringify([ { - url: root, opts: { listState: { [expandedFolder]: true } }, + url: root, }, ]), );