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,
},
]),
);