Skip to content
Merged
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
28 changes: 28 additions & 0 deletions apps/desktop/src/settings/DesktopAppSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,34 @@ describe("DesktopSettings", () => {
),
);

it.effect("saves through a symlinked settings file without replacing the link", () =>
withSettings(
Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const settings = yield* DesktopAppSettings.DesktopAppSettings;
const dotfiles = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-settings-dotfiles-",
});
const linkedSettingsPath = `${dotfiles}/desktop-settings.json`;
yield* fileSystem.writeFileString(linkedSettingsPath, "{}\n");
yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
yield* fileSystem.symlink(linkedSettingsPath, environment.desktopSettingsPath);

yield* settings.setServerExposureMode("network-accessible");

assert.equal(
yield* fileSystem.readLink(environment.desktopSettingsPath),
linkedSettingsPath,
);
const persisted = yield* decodeDesktopSettingsPatch(
yield* fileSystem.readFileString(linkedSettingsPath),
);
assert.equal(persisted.serverExposureMode, "network-accessible");
}),
),
);

it.effect("migrates legacy implicit update channels to the runtime default", () =>
withSettings(
Effect.gen(function* () {
Expand Down
20 changes: 17 additions & 3 deletions apps/desktop/src/settings/DesktopAppSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type DesktopUpdateChannel,
} from "@t3tools/contracts";
import { fromLenientJson } from "@t3tools/shared/schemaJson";
import { resolveSymlinkTarget } from "@t3tools/shared/symlink";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -130,6 +131,7 @@ const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSet

const DesktopSettingsWriteOperation = Schema.Literals([
"create-temporary-file-name",
"resolve-symlink",
"encode-document",
"create-directory",
"write-temporary-file",
Expand Down Expand Up @@ -421,8 +423,20 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp
readonly defaultSettings: DesktopSettings;
readonly suffix: string;
}): Effect.fn.Return<void, DesktopSettingsWriteError> {
const directory = input.path.dirname(input.settingsPath);
const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;
const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe(
Effect.provideService(FileSystem.FileSystem, input.fileSystem),
Effect.provideService(Path.Path, input.path),
Effect.mapError(
(cause) =>
new DesktopSettingsWriteError({
operation: "resolve-symlink",
path: input.settingsPath,
cause,
}),
),
);
const directory = input.path.dirname(targetPath);
const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`;
const encoded = yield* encodeDesktopSettingsJson(
toDesktopSettingsDocument(input.settings, input.defaultSettings),
).pipe(
Expand Down Expand Up @@ -455,7 +469,7 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp
}),
),
);
yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe(
yield* input.fileSystem.rename(tempPath, targetPath).pipe(
Effect.mapError(
(cause) =>
new DesktopSettingsWriteError({
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,34 @@ describe("DesktopClientSettings", () => {
),
);

it.effect("saves through a symlinked client settings file without replacing the link", () =>
withClientSettings(
Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const settings = yield* DesktopClientSettings.DesktopClientSettings;
const dotfiles = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-client-settings-dotfiles-",
});
const linkedSettingsPath = `${dotfiles}/client-settings.json`;
yield* fileSystem.writeFileString(linkedSettingsPath, "{}\n");
yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
yield* fileSystem.symlink(linkedSettingsPath, environment.clientSettingsPath);

yield* settings.set(clientSettings);

assert.equal(
yield* fileSystem.readLink(environment.clientSettingsPath),
linkedSettingsPath,
);
assert.deepEqual(
yield* decodeClientSettingsJson(yield* fileSystem.readFileString(linkedSettingsPath)),
clientSettings,
);
}),
),
);

it.effect.each([
{ label: "permission", reason: "PermissionDenied" },
{ label: "I/O", reason: "Unknown" },
Expand Down
20 changes: 17 additions & 3 deletions apps/desktop/src/settings/DesktopClientSettings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts";
import { fromLenientJson } from "@t3tools/shared/schemaJson";
import { resolveSymlinkTarget } from "@t3tools/shared/symlink";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -41,6 +42,7 @@ export class DesktopClientSettingsReadError extends Schema.TaggedError<DesktopCl

const DesktopClientSettingsWriteOperation = Schema.Literals([
"create-temporary-file-name",
"resolve-symlink",
"encode-document",
"create-directory",
"write-temporary-file",
Expand Down Expand Up @@ -126,8 +128,20 @@ const writeClientSettings = Effect.fnUntraced(function* (input: {
readonly settings: ClientSettings;
readonly suffix: string;
}): Effect.fn.Return<void, DesktopClientSettingsWriteError> {
const directory = input.path.dirname(input.settingsPath);
const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;
const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe(
Effect.provideService(FileSystem.FileSystem, input.fileSystem),
Effect.provideService(Path.Path, input.path),
Effect.mapError(
(cause) =>
new DesktopClientSettingsWriteError({
operation: "resolve-symlink",
path: input.settingsPath,
cause,
}),
),
);
const directory = input.path.dirname(targetPath);
const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`;
const encoded = yield* encodeClientSettingsJson(input.settings).pipe(
Effect.mapError(
(cause) =>
Expand Down Expand Up @@ -158,7 +172,7 @@ const writeClientSettings = Effect.fnUntraced(function* (input: {
}),
),
);
yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe(
yield* input.fileSystem.rename(tempPath, targetPath).pipe(
Effect.mapError(
(cause) =>
new DesktopClientSettingsWriteError({
Expand Down
139 changes: 139 additions & 0 deletions apps/server/src/atomicWrite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";

import { writeFileStringAtomically } from "./atomicWrite.ts";

it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => {
it.effect("keeps a symlinked file linked and rewrites its destination", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
const destination = path.join(root, "dotfiles", "settings.json");
const link = path.join(root, "home", "settings.json");
yield* fs.makeDirectory(path.dirname(destination), { recursive: true });
yield* fs.makeDirectory(path.dirname(link), { recursive: true });
yield* fs.writeFileString(destination, "before");
yield* fs.symlink(destination, link);

yield* writeFileStringAtomically({ filePath: link, contents: "after" });

assert.strictEqual(yield* fs.readLink(link), destination);
assert.strictEqual(yield* fs.readFileString(destination), "after");
}),
);

it.effect("keeps a dangling symlink linked and creates its destination", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
const destination = path.join(root, "dotfiles", "settings.json");
const link = path.join(root, "home", "settings.json");
yield* fs.makeDirectory(path.dirname(link), { recursive: true });
yield* fs.symlink(destination, link);

yield* writeFileStringAtomically({ filePath: link, contents: "fresh" });

assert.strictEqual(yield* fs.readLink(link), destination);
assert.strictEqual(yield* fs.readFileString(destination), "fresh");
}),
);

it.effect("fails on a symlink cycle without replacing either link", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
const first = path.join(root, "first.json");
const second = path.join(root, "second.json");
yield* fs.symlink(second, first);
yield* fs.symlink(first, second);

const result = yield* Effect.exit(
writeFileStringAtomically({ filePath: first, contents: "after" }),
);

assert.isTrue(Exit.isFailure(result));
assert.strictEqual(yield* fs.readLink(first), second);
assert.strictEqual(yield* fs.readLink(second), first);
}),
);

it.effect("resolves a relative link through a symlinked parent directory", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
const destination = path.join(root, "dotfiles", "config", "settings.json");
const linkedState = path.join(root, "dotfiles", "state");
const home = path.join(root, "home");
const link = path.join(home, "state", "settings.json");
yield* fs.makeDirectory(path.dirname(destination), { recursive: true });
yield* fs.makeDirectory(linkedState, { recursive: true });
yield* fs.makeDirectory(home, { recursive: true });
yield* fs.symlink(linkedState, path.join(home, "state"));
yield* fs.writeFileString(destination, "before");
yield* fs.symlink("../config/settings.json", link);

yield* writeFileStringAtomically({ filePath: link, contents: "after" });

assert.strictEqual(yield* fs.readLink(link), "../config/settings.json");
assert.strictEqual(yield* fs.readFileString(destination), "after");
}),
);

it.effect("creates a missing file and its directory", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
const filePath = path.join(root, "nested", "settings.json");

yield* writeFileStringAtomically({ filePath, contents: "fresh" });

assert.strictEqual(yield* fs.readFileString(filePath), "fresh");
}),
);
});

it.effect("surfaces an unreadable link instead of writing over it", () =>
Effect.gen(function* () {
const readLinkFailure = PlatformError.systemError({
_tag: "Unknown",
module: "FileSystem",
method: "readLink",
pathOrDescriptor: "/home/settings.json",
});

const result = yield* Effect.exit(
writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }),
);

assert.deepStrictEqual(result, Exit.fail(readLinkFailure));
}).pipe(
Effect.provide(
Layer.mergeAll(
Path.layer,
FileSystem.layerNoop({
readLink: () =>
Effect.fail(
PlatformError.systemError({
_tag: "Unknown",
module: "FileSystem",
method: "readLink",
pathOrDescriptor: "/home/settings.json",
}),
),
rename: () => Effect.die("an unreadable link must not be replaced"),
}),
),
),
),
);
13 changes: 10 additions & 3 deletions apps/server/src/atomicWrite.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import { resolveSymlinkTarget } from "@t3tools/shared/symlink";

/**
* Replaces a file's contents via a sibling temp file and rename. A symlinked
* target is resolved first so the link survives and its destination is
* rewritten, since renaming over the link itself would swap it for a regular file.
*/
export const writeFileStringAtomically = (input: {
readonly filePath: string;
readonly contents: string;
Expand All @@ -10,16 +16,17 @@ export const writeFileStringAtomically = (input: {
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const targetDirectory = path.dirname(input.filePath);
const targetPath = yield* resolveSymlinkTarget(input.filePath);
const targetDirectory = path.dirname(targetPath);

yield* fs.makeDirectory(targetDirectory, { recursive: true });
const tempDirectory = yield* fs.makeTempDirectoryScoped({
directory: targetDirectory,
prefix: `${path.basename(input.filePath)}.`,
prefix: `${path.basename(targetPath)}.`,
});
const tempPath = path.join(tempDirectory, "contents.tmp");

yield* fs.writeFileString(tempPath, input.contents);
yield* fs.rename(tempPath, input.filePath);
yield* fs.rename(tempPath, targetPath);
}),
);
Loading
Loading