From 6146a3b4158ee9a6cb3c5309d9effced889fcb9d Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:54:17 +0100 Subject: [PATCH] fix(desktop): recover corrupt connection catalogs --- .../app/DesktopConnectionCatalogStore.test.ts | 168 ++++++++++++++- .../src/app/DesktopConnectionCatalogStore.ts | 201 ++++++++++++++---- 2 files changed, 321 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 7c7818994f6..e8c2d0623bd 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -2,8 +2,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; @@ -92,6 +94,29 @@ const withStore = ( return yield* effect.pipe(Effect.provide(makeLayer(baseDir, encryptionAvailable))); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); +const expectCorruptCatalogQuarantine = (corruptCatalog: string) => + withStore( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + const catalogPath = `${environment.stateDir}/connection-catalog.json`; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(catalogPath, corruptCatalog); + + assert.deepStrictEqual(yield* store.get, Option.none()); + assert.isFalse(yield* fileSystem.exists(catalogPath)); + const quarantined = (yield* fileSystem.readDirectory(environment.stateDir)).filter((entry) => + /^connection-catalog\.json\.corrupt-\d+$/.test(entry), + ); + assert.deepStrictEqual(quarantined.length, 1); + assert.equal( + yield* fileSystem.readFileString(`${environment.stateDir}/${quarantined[0]}`), + corruptCatalog, + ); + }), + ); + describe("DesktopConnectionCatalogStore", () => { it.effect("persists, reads, and clears an encrypted connection catalog", () => withStore( @@ -119,6 +144,27 @@ describe("DesktopConnectionCatalogStore", () => { ), ); + it.effect("preserves an encrypted catalog when secure storage is unavailable", () => + withStore( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + const catalogPath = `${environment.stateDir}/connection-catalog.json`; + const encryptedDocument = '{"version":1,"encryptedCatalog":"ZW5jcnlwdGVkOnt9"}\n'; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(catalogPath, encryptedDocument); + + assert.deepStrictEqual(yield* store.get, Option.none()); + assert.equal(yield* fileSystem.readFileString(catalogPath), encryptedDocument); + assert.deepStrictEqual(yield* fileSystem.readDirectory(environment.stateDir), [ + "connection-catalog.json", + ]); + }), + false, + ), + ); + it.effect("migrates legacy relay, SSH, bearer profile, and credential data", () => withStore( Effect.gen(function* () { @@ -222,28 +268,140 @@ describe("DesktopConnectionCatalogStore", () => { ), ); - it.effect("surfaces malformed catalog documents without deleting them", () => + it.effect("quarantines an all-NUL catalog and starts empty", () => + expectCorruptCatalogQuarantine("\0".repeat(912)), + ); + + it.effect("quarantines a truncated catalog document and starts empty", () => + expectCorruptCatalogQuarantine('{"version":1,"encryptedCatalog":'), + ); + + it.effect("quarantines a schema-invalid outer catalog document and starts empty", () => + expectCorruptCatalogQuarantine('{"version":1,"encryptedCatalog":42}'), + ); + + it.effect("preserves a well-formed catalog from a newer application version", () => withStore( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; const catalogPath = `${environment.stateDir}/connection-catalog.json`; + const futureCatalog = '{"version":2,"payload":{"ciphertext":"future-format"}}\n'; yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(catalogPath, "{not-json"); + yield* fileSystem.writeFileString(catalogPath, futureCatalog); const error = yield* store.get.pipe(Effect.flip); assert.instanceOf( error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDocumentDecodeError, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreUnsupportedVersionError, ); assert.equal(error.catalogPath, catalogPath); - assert.exists(error.cause); - assert.equal(yield* fileSystem.readFileString(catalogPath), "{not-json"); + assert.equal(error.version, 2); + assert.equal(yield* fileSystem.readFileString(catalogPath), futureCatalog); + assert.deepStrictEqual(yield* fileSystem.readDirectory(environment.stateDir), [ + "connection-catalog.json", + ]); + }), + ), + ); + + it.effect("attempts legacy migration after quarantining a malformed catalog", () => + withStore( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + const catalogPath = `${environment.stateDir}/connection-catalog.json`; + yield* savedEnvironments.setRegistry([ + { + environmentId: EnvironmentId.make("relay-environment"), + label: "Relay", + httpBaseUrl: "https://relay.example.com/", + wsBaseUrl: "wss://relay.example.com/", + createdAt: "2026-06-01T00:00:00.000Z", + lastConnectedAt: null, + relayManaged: { relayUrl: "https://relay-control.example.com/" }, + }, + ]); + yield* fileSystem.writeFileString(catalogPath, "{not-json"); + + const recovered = yield* store.get; + assert.isTrue(Option.isSome(recovered)); + if (Option.isNone(recovered)) { + return; + } + const catalog = yield* decodeConnectionCatalog(recovered.value); + assert.deepInclude(catalog.targets[0], { + _tag: "RelayConnectionTarget", + environmentId: EnvironmentId.make("relay-environment"), + label: "Relay", + }); + const quarantined = (yield* fileSystem.readDirectory(environment.stateDir)).filter( + (entry) => /^connection-catalog\.json\.corrupt-\d+$/.test(entry), + ); + assert.deepStrictEqual(quarantined.length, 1); + assert.equal( + yield* fileSystem.readFileString(`${environment.stateDir}/${quarantined[0]}`), + "{not-json", + ); + assert.isTrue(yield* fileSystem.exists(catalogPath)); }), ), ); + it.effect("does not quarantine a valid catalog written during recovery", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-connection-catalog-test-", + }); + const catalogPath = `${baseDir}/userdata/connection-catalog.json`; + const quarantineStarted = yield* Deferred.make(); + const allowQuarantine = yield* Deferred.make(); + const setCompleted = yield* Deferred.make(); + const fileSystemLayer = Layer.succeed(FileSystem.FileSystem, { + ...fileSystem, + rename: (fromPath, toPath) => + toPath.includes("connection-catalog.json.corrupt-") + ? Deferred.succeed(quarantineStarted, undefined).pipe( + Effect.andThen(Deferred.await(allowQuarantine)), + Effect.andThen(fileSystem.rename(fromPath, toPath)), + ) + : fileSystem.rename(fromPath, toPath), + }); + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( + Effect.provide(makeLayer(baseDir, true, null, fileSystemLayer)), + ); + yield* fileSystem.makeDirectory(`${baseDir}/userdata`, { recursive: true }); + yield* fileSystem.writeFileString(catalogPath, "{not-json"); + + const getFiber = yield* store.get.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(quarantineStarted); + const replacement = '{"schemaVersion":1,"targets":[]}'; + const setFiber = yield* store.set(replacement).pipe( + Effect.tap(() => Deferred.succeed(setCompleted, undefined)), + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + assert.isFalse(yield* Deferred.isDone(setCompleted)); + + yield* Deferred.succeed(allowQuarantine, undefined); + assert.deepStrictEqual(yield* Fiber.join(getFiber), Option.none()); + assert.isTrue(yield* Fiber.join(setFiber)); + assert.deepStrictEqual(yield* store.get, Option.some(replacement)); + const quarantined = (yield* fileSystem.readDirectory(`${baseDir}/userdata`)).filter((entry) => + /^connection-catalog\.json\.corrupt-\d+$/.test(entry), + ); + assert.deepStrictEqual(quarantined.length, 1); + assert.equal( + yield* fileSystem.readFileString(`${baseDir}/userdata/${quarantined[0]}`), + "{not-json", + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + it.effect("surfaces catalog filesystem failures instead of treating them as missing", () => Effect.gen(function* () { const baseFileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 5ec2edb595f..bfc3ec29e31 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -12,6 +12,7 @@ import { } from "@t3tools/client-runtime/platform"; import type { PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -20,7 +21,9 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import * as DesktopSavedEnvironments from "../settings/DesktopSavedEnvironments.ts"; @@ -32,10 +35,17 @@ const EncryptedConnectionCatalogDocument = Schema.Struct({ }); type EncryptedConnectionCatalogDocument = typeof EncryptedConnectionCatalogDocument.Type; +const ConnectionCatalogVersionEnvelope = Schema.Struct({ + version: Schema.Number, +}); + const EncryptedConnectionCatalogDocumentJson = fromLenientJson(EncryptedConnectionCatalogDocument); const decodeEncryptedConnectionCatalogDocumentJson = Schema.decodeEffect( EncryptedConnectionCatalogDocumentJson, ); +const decodeConnectionCatalogVersionEnvelopeJson = Schema.decodeEffect( + fromLenientJson(ConnectionCatalogVersionEnvelope), +); const encodeEncryptedConnectionCatalogDocumentJson = Schema.encodeEffect( EncryptedConnectionCatalogDocumentJson, ); @@ -51,7 +61,9 @@ const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([ "encode-document", "create-directory", "write-temporary-file", + "sync-temporary-file", "replace-catalog-file", + "quarantine-corrupt-catalog", ]); const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([ @@ -117,6 +129,18 @@ export class DesktopConnectionCatalogStoreDocumentDecodeError extends Schema.Tag } } +export class DesktopConnectionCatalogStoreUnsupportedVersionError extends Schema.TaggedErrorClass()( + "DesktopConnectionCatalogStoreUnsupportedVersionError", + { + catalogPath: Schema.String, + version: Schema.Number, + }, +) { + override get message(): string { + return `Desktop connection catalog version ${this.version} at ${this.catalogPath} is newer than this application supports.`; + } +} + export class DesktopConnectionCatalogStoreMigrationError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreMigrationError", { @@ -152,7 +176,8 @@ export class DesktopConnectionCatalogStore extends Context.Service< readonly get: Effect.Effect< Option.Option, | DesktopConnectionCatalogStoreReadError - | DesktopConnectionCatalogStoreDocumentDecodeError + | DesktopConnectionCatalogStoreUnsupportedVersionError + | DesktopConnectionCatalogStoreWriteError | DesktopConnectionCatalogStoreDecodeError | DesktopConnectionCatalogStoreMigrationError | DesktopConnectionCatalogStoreProtectionError @@ -188,7 +213,9 @@ const readDocument = ( catalogPath: string, ): Effect.Effect< Option.Option, - DesktopConnectionCatalogStoreReadError | DesktopConnectionCatalogStoreDocumentDecodeError + | DesktopConnectionCatalogStoreReadError + | DesktopConnectionCatalogStoreDocumentDecodeError + | DesktopConnectionCatalogStoreUnsupportedVersionError > => fileSystem.readFileString(catalogPath).pipe( Effect.catch((error) => @@ -206,12 +233,26 @@ const readDocument = ( ? Effect.succeed(Option.none()) : decodeEncryptedConnectionCatalogDocumentJson(raw).pipe( Effect.map(Option.some), - Effect.mapError( - (cause) => - new DesktopConnectionCatalogStoreDocumentDecodeError({ + Effect.catch((cause) => + Effect.gen(function* () { + const envelopeResult = yield* Effect.result( + decodeConnectionCatalogVersionEnvelopeJson(raw), + ); + if ( + Result.isSuccess(envelopeResult) && + Number.isSafeInteger(envelopeResult.success.version) && + envelopeResult.success.version > 1 + ) { + return yield* new DesktopConnectionCatalogStoreUnsupportedVersionError({ + catalogPath, + version: envelopeResult.success.version, + }); + } + return yield* new DesktopConnectionCatalogStoreDocumentDecodeError({ catalogPath, cause, - }), + }); + }), ), ), ), @@ -257,6 +298,19 @@ const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")( }), ), ); + yield* Effect.scoped( + input.fileSystem.open(tempPath, { flag: "r+" }).pipe( + Effect.flatMap((file) => file.sync), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "sync-temporary-file", + path: tempPath, + cause, + }), + ), + ), + ); yield* input.fileSystem.rename(tempPath, input.catalogPath).pipe( Effect.mapError( (cause) => @@ -382,6 +436,7 @@ export const make = Effect.gen(function* () { const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; const crypto = yield* Crypto.Crypto; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + const lock = yield* Semaphore.make(1); const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); const encryptionAvailable = safeStorage.isEncryptionAvailable.pipe( Effect.mapError( @@ -469,47 +524,107 @@ export const make = Effect.gen(function* () { return Option.some(encoded); }); + const quarantineCorruptCatalog = Effect.fn( + "desktop.connectionCatalogStore.quarantineCorruptCatalog", + )(function* (error: DesktopConnectionCatalogStoreDocumentDecodeError) { + let quarantineTimestamp = yield* Clock.currentTimeMillis; + let quarantinePath = `${catalogPath}.corrupt-${quarantineTimestamp}`; + while ( + yield* fileSystem.exists(quarantinePath).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "quarantine-corrupt-catalog", + path: quarantinePath, + cause, + }), + ), + ) + ) { + quarantineTimestamp += 1; + quarantinePath = `${catalogPath}.corrupt-${quarantineTimestamp}`; + } + yield* fileSystem.rename(catalogPath, quarantinePath).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "quarantine-corrupt-catalog", + path: quarantinePath, + cause, + }), + ), + ); + yield* Effect.logWarning( + "Recovered a corrupt desktop connection catalog; the original was preserved for recovery.", + { + catalogPath, + quarantinePath, + error: error.message, + }, + ); + }); + return DesktopConnectionCatalogStore.of({ - get: Effect.gen(function* () { - const document = yield* readDocument(fileSystem, catalogPath); - if (Option.isNone(document)) { - return yield* migrateLegacyCatalog; - } - if (!(yield* encryptionAvailable)) { - return Option.none(); - } - const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe( - Effect.flatMap((encryptedCatalog) => - safeStorage.decryptString(encryptedCatalog).pipe( - Effect.mapError( - (cause) => - new DesktopConnectionCatalogStoreProtectionError({ - operation: "decrypt-catalog", - catalogPath, - cause, - }), + get: lock + .withPermits(1)( + Effect.gen(function* () { + const document = yield* readDocument(fileSystem, catalogPath).pipe( + Effect.catchTag("DesktopConnectionCatalogStoreDocumentDecodeError", (error) => + quarantineCorruptCatalog(error).pipe( + Effect.as(Option.none()), + ), ), - ), - ), - ); - return Option.some(decrypted); - }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), + ); + if (Option.isNone(document)) { + return yield* migrateLegacyCatalog; + } + if (!(yield* encryptionAvailable)) { + return Option.none(); + } + const decrypted = yield* decodeSecretBytes( + catalogPath, + document.value.encryptedCatalog, + ).pipe( + Effect.flatMap((encryptedCatalog) => + safeStorage.decryptString(encryptedCatalog).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "decrypt-catalog", + catalogPath, + cause, + }), + ), + ), + ), + ); + return Option.some(decrypted); + }), + ) + .pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { - if (!(yield* encryptionAvailable)) { - return false; - } - yield* writeCatalog(catalog); - return true; - }), - clear: fileSystem.remove(catalogPath, { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("Could not clear the desktop connection catalog.", { - catalogPath, - error, + return yield* lock.withPermits(1)( + Effect.gen(function* () { + if (!(yield* encryptionAvailable)) { + return false; + } + yield* writeCatalog(catalog); + return true; }), - ), - Effect.withSpan("desktop.connectionCatalogStore.clear"), - ), + ); + }), + clear: lock + .withPermits(1)( + fileSystem.remove(catalogPath, { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear the desktop connection catalog.", { + catalogPath, + error, + }), + ), + ), + ) + .pipe(Effect.withSpan("desktop.connectionCatalogStore.clear")), }); });