diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index c58830b30a7f..1cab32a591ab 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -5,7 +5,9 @@ import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/co import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -18,12 +20,20 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; const textDecoder = new TextDecoder(); const textEncoder = new TextEncoder(); +const catalogPathFor = (path: Path.Path, stateDir: string) => + path.join(stateDir, "connection-catalog.json"); +const stateDirFor = (path: Path.Path, baseDir: string) => path.join(baseDir, "userdata"); const decodeConnectionCatalog = Schema.decodeEffect( Schema.fromJsonString(ConnectionCatalogDocument), ); -function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref | null = null) { +function makeSafeStorageLayer( + available: + | boolean + | Effect.Effect, + failDecrypt: Ref.Ref | null = null, +) { return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { - isEncryptionAvailable: Effect.succeed(available), + isEncryptionAvailable: typeof available === "boolean" ? Effect.succeed(available) : available, encryptString: (value) => Effect.succeed(textEncoder.encode(`encrypted:${value}`)), decryptString: (value) => { return Effect.gen(function* () { @@ -45,7 +55,9 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref function makeLayer( baseDir: string, - encryptionAvailable = true, + encryptionAvailable: + | boolean + | Effect.Effect = true, failDecrypt: Ref.Ref | null = null, fileSystemLayer: Layer.Layer = NodeServices.layer, ) { @@ -223,31 +235,110 @@ describe("DesktopConnectionCatalogStore", () => { ), ); - it.effect("surfaces malformed catalog documents without deleting them", () => + it.effect("quarantines malformed catalog documents and treats them as missing", () => withStore( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const catalogPath = `${environment.stateDir}/connection-catalog.json`; + const catalogPath = catalogPathFor(path, environment.stateDir); yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(catalogPath, "{not-json"); - const error = yield* store.get.pipe(Effect.flip); - assert.instanceOf( - error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDocumentDecodeError, + const corruptDocuments = ["\0".repeat(64), '{"version":1}'] as const; + yield* fileSystem.writeFileString(catalogPath, corruptDocuments[0]); + assert.deepStrictEqual(yield* store.get, Option.none()); + yield* fileSystem.writeFileString(catalogPath, corruptDocuments[1]); + assert.deepStrictEqual(yield* store.get, Option.none()); + + const quarantineNames = (yield* fileSystem.readDirectory(environment.stateDir)).filter( + (name) => name.startsWith("connection-catalog.json.corrupt."), ); - assert.equal(error.catalogPath, catalogPath); - assert.exists(error.cause); - assert.equal(yield* fileSystem.readFileString(catalogPath), "{not-json"); + assert.lengthOf(quarantineNames, 2); + assert.notEqual(quarantineNames[0], quarantineNames[1]); + quarantineNames.forEach((name) => + assert.match(name, /^connection-catalog\.json\.corrupt\.\d+\.[a-f0-9]{32}$/), + ); + const quarantinedContents = yield* Effect.all( + quarantineNames.map((name) => + fileSystem.readFileString(path.join(environment.stateDir, name)), + ), + ); + assert.deepEqual(quarantinedContents.sort(), [...corruptDocuments].sort()); + assert.isFalse(yield* fileSystem.exists(catalogPath)); }), ), ); + it.effect("continues when a malformed catalog cannot be quarantined", () => + Effect.gen(function* () { + const baseFileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-connection-catalog-test-", + }); + const catalogPath = catalogPathFor(path, stateDirFor(path, baseDir)); + const quarantineError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + pathOrDescriptor: catalogPath, + }); + yield* baseFileSystem.makeDirectory(stateDirFor(path, baseDir), { recursive: true }); + yield* baseFileSystem.writeFileString(catalogPath, "{not-json"); + const fileSystemLayer = Layer.succeed(FileSystem.FileSystem, { + ...baseFileSystem, + rename: (oldPath, newPath) => + String(oldPath) === catalogPath && String(newPath).startsWith(`${catalogPath}.corrupt.`) + ? Effect.fail(quarantineError) + : baseFileSystem.rename(oldPath, newPath), + } satisfies FileSystem.FileSystem); + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( + Effect.provide(makeLayer(baseDir, true, null, fileSystemLayer)), + ); + const logMessages: Array = []; + const logger = Logger.make(({ message }) => { + logMessages.push(message); + }); + + assert.deepStrictEqual( + yield* store.get.pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))), + Option.none(), + ); + assert.equal(yield* baseFileSystem.readFileString(catalogPath), "{not-json"); + assert.lengthOf(logMessages, 1); + const logMessage = logMessages[0]; + if (!Array.isArray(logMessage)) { + return assert.fail("expected structured warning arguments"); + } + assert.equal( + logMessage[0], + "Could not quarantine a corrupt desktop connection catalog; continuing without it.", + ); + const logDetails = logMessage[1]; + if (logDetails === null || typeof logDetails !== "object") { + return assert.fail("expected structured warning metadata"); + } + assert.sameMembers(Object.keys(logDetails), [ + "catalogPath", + "decodeError", + "quarantineError", + ]); + assert.equal("catalogPath" in logDetails ? logDetails.catalogPath : undefined, catalogPath); + const decodeError = "decodeError" in logDetails ? logDetails.decodeError : undefined; + const loggedQuarantineError = + "quarantineError" in logDetails ? logDetails.quarantineError : undefined; + assert.isString(decodeError); + assert.isString(loggedQuarantineError); + assert.notInclude(decodeError, "{not-json"); + assert.notInclude(loggedQuarantineError, "{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; + const path = yield* Path.Path; const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-connection-catalog-test-", }); @@ -255,7 +346,7 @@ describe("DesktopConnectionCatalogStore", () => { _tag: "PermissionDenied", module: "FileSystem", method: "readFileString", - pathOrDescriptor: `${baseDir}/userdata/connection-catalog.json`, + pathOrDescriptor: catalogPathFor(path, stateDirFor(path, baseDir)), }); const fileSystemLayer = Layer.succeed( FileSystem.FileSystem, @@ -272,11 +363,11 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, ); - assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); + assert.equal(error.catalogPath, catalogPathFor(path, stateDirFor(path, baseDir))); assert.strictEqual(error.cause, permissionError); assert.equal( error.message, - `Failed to read the desktop connection catalog at ${baseDir}/userdata/connection-catalog.json.`, + `Failed to read the desktop connection catalog at ${catalogPathFor(path, stateDirFor(path, baseDir))}.`, ); assert.notEqual(error.message, permissionError.message); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), @@ -285,6 +376,7 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("reports the failed catalog write operation and path", () => Effect.gen(function* () { const baseFileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-connection-catalog-test-", }); @@ -292,7 +384,7 @@ describe("DesktopConnectionCatalogStore", () => { _tag: "PermissionDenied", module: "FileSystem", method: "makeDirectory", - pathOrDescriptor: `${baseDir}/userdata`, + pathOrDescriptor: stateDirFor(path, baseDir), }); const fileSystemLayer = Layer.succeed( FileSystem.FileSystem, @@ -310,11 +402,11 @@ describe("DesktopConnectionCatalogStore", () => { DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreWriteError, ); assert.equal(error.operation, "create-directory"); - assert.equal(error.path, `${baseDir}/userdata`); + assert.equal(error.path, stateDirFor(path, baseDir)); assert.strictEqual(error.cause, permissionError); assert.equal( error.message, - `Desktop connection catalog write failed during create-directory at ${baseDir}/userdata.`, + `Desktop connection catalog write failed during create-directory at ${stateDirFor(path, baseDir)}.`, ); assert.notEqual(error.message, permissionError.message); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), @@ -325,6 +417,7 @@ describe("DesktopConnectionCatalogStore", () => { Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); @@ -335,7 +428,7 @@ describe("DesktopConnectionCatalogStore", () => { DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreMigrationError, ); assert.equal(error.operation, "read-legacy-registry"); - assert.equal(error.catalogPath, `${environment.stateDir}/connection-catalog.json`); + assert.equal(error.catalogPath, catalogPathFor(path, environment.stateDir)); assert.instanceOf( error.cause, DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, @@ -345,7 +438,7 @@ describe("DesktopConnectionCatalogStore", () => { assert.exists(registryError.cause); assert.equal( error.message, - `Legacy desktop saved-environment migration failed during read-legacy-registry into ${environment.stateDir}/connection-catalog.json.`, + `Legacy desktop saved-environment migration failed during read-legacy-registry into ${catalogPathFor(path, environment.stateDir)}.`, ); assert.notEqual(error.message, registryError.message); }), @@ -357,10 +450,12 @@ describe("DesktopConnectionCatalogStore", () => { Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const catalogPath = `${environment.stateDir}/connection-catalog.json`; + const catalogPath = catalogPathFor(path, environment.stateDir); yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(catalogPath, '{"version":1,"encryptedCatalog":"%%%"}\n'); + const document = '{"version":1,"encryptedCatalog":"%%%"}\n'; + yield* fileSystem.writeFileString(catalogPath, document); const error = yield* store.get.pipe(Effect.flip); assert.instanceOf( @@ -375,13 +470,58 @@ describe("DesktopConnectionCatalogStore", () => { `Failed to decode encryptedCatalog for the desktop connection catalog at ${catalogPath}.`, ); assert.notInclude(error.message, "%%%"); + assert.equal(yield* fileSystem.readFileString(catalogPath), document); + assert.lengthOf( + (yield* fileSystem.readDirectory(environment.stateDir)).filter((name) => + name.startsWith("connection-catalog.json.corrupt."), + ), + 0, + ); }), ), ); + it.effect("surfaces secure-storage availability failures without quarantining the catalog", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-connection-catalog-test-", + }); + const availabilityError = new ElectronSafeStorage.ElectronSafeStorageAvailabilityError({ + cause: new Error("safe storage unavailable"), + }); + const layer = makeLayer(baseDir, Effect.fail(availabilityError)); + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( + Effect.provide(layer), + ); + const stateDir = stateDirFor(path, baseDir); + const catalogPath = catalogPathFor(path, stateDir); + const document = '{"version":1,"encryptedCatalog":"ZW5jcnlwdGVkOnt9"}\n'; + yield* fileSystem.makeDirectory(stateDir, { recursive: true }); + yield* fileSystem.writeFileString(catalogPath, document); + + const error = yield* store.get.pipe(Effect.flip); + assert.instanceOf( + error, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError, + ); + assert.equal(error.operation, "check-encryption-availability"); + assert.strictEqual(error.cause, availabilityError); + assert.equal(yield* fileSystem.readFileString(catalogPath), document); + assert.lengthOf( + (yield* fileSystem.readDirectory(stateDir)).filter((name) => + name.startsWith("connection-catalog.json.corrupt."), + ), + 0, + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-connection-catalog-test-", }); @@ -399,14 +539,14 @@ describe("DesktopConnectionCatalogStore", () => { DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError, ); assert.equal(error.operation, "decrypt-catalog"); - assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); + assert.equal(error.catalogPath, catalogPathFor(path, stateDirFor(path, baseDir))); assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageDecryptError); const decryptError = error.cause as ElectronSafeStorage.ElectronSafeStorageDecryptError; assert.instanceOf(decryptError.cause, Error); assert.equal(decryptError.cause.message, "invalid encrypted catalog"); assert.equal( error.message, - `Desktop connection catalog protection failed during decrypt-catalog at ${baseDir}/userdata/connection-catalog.json.`, + `Desktop connection catalog protection failed during decrypt-catalog at ${catalogPathFor(path, stateDirFor(path, baseDir))}.`, ); assert.notEqual(error.message, decryptError.message); yield* Ref.set(failDecrypt, false); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 5ec2edb595ff..e702c31084d9 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -469,9 +469,38 @@ export const make = Effect.gen(function* () { return Option.some(encoded); }); + const readRecoverableDocument = readDocument(fileSystem, catalogPath).pipe( + Effect.catchTag("DesktopConnectionCatalogStoreDocumentDecodeError", (decodeError) => + Effect.gen(function* () { + yield* Effect.gen(function* () { + const suffix = (yield* crypto.randomUUIDv4).replace(/-/g, ""); + const quarantinePath = `${catalogPath}.corrupt.${process.pid}.${suffix}`; + yield* fileSystem.rename(catalogPath, quarantinePath); + yield* Effect.logWarning("Quarantined a corrupt desktop connection catalog.", { + catalogPath, + quarantinePath, + decodeError: decodeError.message, + }); + }).pipe( + Effect.catch((quarantineError) => + Effect.logWarning( + "Could not quarantine a corrupt desktop connection catalog; continuing without it.", + { + catalogPath, + decodeError: decodeError.message, + quarantineError: String(quarantineError), + }, + ), + ), + ); + return Option.none(); + }), + ), + ); + return DesktopConnectionCatalogStore.of({ get: Effect.gen(function* () { - const document = yield* readDocument(fileSystem, catalogPath); + const document = yield* readRecoverableDocument; if (Option.isNone(document)) { return yield* migrateLegacyCatalog; }