Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 65 additions & 18 deletions apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,39 +405,86 @@ describe("DesktopConnectionCatalogStore", () => {
),
);

it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () =>
it.effect("preserves an undecryptable catalog and allows a fresh encrypted catalog", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const fileSystem = yield* FileSystem.FileSystem;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-connection-catalog-test-",
});
const failDecrypt = yield* Ref.make(false);
const layer = makeLayer(baseDir, true, failDecrypt);
const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe(
Effect.provide(layer),
Effect.provide(makeLayer(baseDir, true, failDecrypt)),
);
const stateDir = path.join(baseDir, "userdata");
const catalogPath = path.join(stateDir, "connection-catalog.json");
const catalog = '{"schemaVersion":1,"targets":[]}';
assert.isTrue(yield* store.set(catalog));
const original = yield* fileSystem.readFileString(catalogPath);
yield* Ref.set(failDecrypt, true);

const results = yield* Effect.all([store.get, store.get], { concurrency: "unbounded" });
assert.deepStrictEqual(results, [Option.none(), Option.none()]);
const backups = (yield* fileSystem.readDirectory(stateDir)).filter((name) =>
name.endsWith(".undecryptable"),
);
assert.equal(backups.length, 1);
const backupPath = path.join(stateDir, backups[0]!);
assert.equal(yield* fileSystem.readFileString(backupPath), original);
assert.isFalse(yield* fileSystem.exists(catalogPath));

yield* Ref.set(failDecrypt, false);
assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[],"profiles":[]}'));
assert.deepStrictEqual(
yield* store.get,
Option.some('{"schemaVersion":1,"targets":[],"profiles":[]}'),
);
assert.equal(yield* fileSystem.readFileString(backupPath), original);
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
);

assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[]}'));
it.effect("does not discard an undecryptable catalog when preserving it fails", () =>
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 failDecrypt = yield* Ref.make(false);
const normalStore = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe(
Effect.provide(makeLayer(baseDir, true, failDecrypt)),
);
yield* normalStore.set("{}");
const catalogPath = path.join(baseDir, "userdata", "connection-catalog.json");
const original = yield* fileSystem.readFileString(catalogPath);
const permissionError = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "rename",
pathOrDescriptor: catalogPath,
});
const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe(
Effect.provide(
makeLayer(
baseDir,
true,
failDecrypt,
Layer.succeed(FileSystem.FileSystem, {
...fileSystem,
rename: () => Effect.fail(permissionError),
}),
),
),
);
yield* Ref.set(failDecrypt, true);
const error = yield* store.get.pipe(Effect.flip);
assert.instanceOf(
error,
DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError,
);
assert.equal(error.operation, "decrypt-catalog");
assert.equal(error.catalogPath, path.join(baseDir, "userdata", "connection-catalog.json"));
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 ${path.join(baseDir, "userdata", "connection-catalog.json")}.`,
DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreWriteError,
);
assert.notEqual(error.message, decryptError.message);
yield* Ref.set(failDecrypt, false);
assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}'));
assert.equal(error.operation, "preserve-undecryptable-catalog");
assert.strictEqual(error.cause, permissionError);
assert.equal(yield* fileSystem.readFileString(catalogPath), original);
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
);
});
43 changes: 40 additions & 3 deletions apps/desktop/src/app/DesktopConnectionCatalogStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
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";
Expand Down Expand Up @@ -52,6 +53,7 @@ const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([
"create-directory",
"write-temporary-file",
"replace-catalog-file",
"preserve-undecryptable-catalog",
]);

const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([
Expand Down Expand Up @@ -151,6 +153,7 @@ export class DesktopConnectionCatalogStore extends Context.Service<
{
readonly get: Effect.Effect<
Option.Option<string>,
| DesktopConnectionCatalogStoreWriteError
| DesktopConnectionCatalogStoreReadError
| DesktopConnectionCatalogStoreDocumentDecodeError
| DesktopConnectionCatalogStoreDecodeError
Expand Down Expand Up @@ -385,6 +388,7 @@ export const make = Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
const catalogPath = path.join(environment.stateDir, "connection-catalog.json");
const mutex = yield* Semaphore.make(1);
const encryptionAvailable = safeStorage.isEncryptionAvailable.pipe(
Effect.mapError(
(cause) =>
Expand Down Expand Up @@ -495,21 +499,54 @@ export const make = Effect.gen(function* () {
),
);
return Option.some(decrypted);
}).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")),
set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) {
}).pipe(
Effect.catchTag(
"DesktopConnectionCatalogStoreProtectionError",
(
error,
): Effect.Effect<
Option.Option<string>,
DesktopConnectionCatalogStoreProtectionError | DesktopConnectionCatalogStoreWriteError
> =>
error.operation !== "decrypt-catalog"
? Effect.fail(error)
: Effect.gen(function* () {
const backupPath = `${catalogPath}.${yield* crypto.randomUUIDv4}.undecryptable`;
yield* fileSystem.rename(catalogPath, backupPath);
yield* Effect.logWarning(
"Saved connections could not be decrypted. Preserved the catalog; reconnect saved remote environments.",
{ catalogPath, backupPath },
);
return Option.none<string>();
}).pipe(
Effect.mapError(
(cause) =>
new DesktopConnectionCatalogStoreWriteError({
operation: "preserve-undecryptable-catalog",
path: catalogPath,
cause,
}),
),
),
),
mutex.withPermits(1),
Effect.withSpan("desktop.connectionCatalogStore.get"),
),
set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog: string) {
if (!(yield* encryptionAvailable)) {
return false;
}
yield* writeCatalog(catalog);
return true;
}),
}, mutex.withPermits(1)),
clear: fileSystem.remove(catalogPath, { force: true }).pipe(
Effect.catch((error) =>
Effect.logWarning("Could not clear the desktop connection catalog.", {
catalogPath,
error,
}),
),
mutex.withPermits(1),
Effect.withSpan("desktop.connectionCatalogStore.clear"),
),
});
Expand Down
Loading