authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 13:33:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 20:33:20+00:00
log30f128fababb1fea46041d228b6ea52733d2ee09
tree366bba3ef46b2d6100c566b36139ec32cde2ce47
parent4701041eef6339bbda0d9d9b00a929c4e9e18662
signature Signed by PGP key B5690EEEBB952194

test: skip symlink fixtures where the host cannot create symlinks (#9566)

Co-authored-by: Claude Code <noreply@anthropic.com>

20 files changed, 1035 insertions(+), 902 deletions(-)

apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts+24-21
...@@ -14,6 +14,7 @@ import * as Ref from "effect/Ref";...@@ -14,6 +14,7 @@ import * as Ref from "effect/Ref";
14import * as BrowserSession from "../BrowserSession.ts";14import * as BrowserSession from "../BrowserSession.ts";
15import * as BrowserImport from "./BrowserImport.ts";15import * as BrowserImport from "./BrowserImport.ts";
16import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts";16import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts";
17import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1718
18const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;19const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
1920
...@@ -105,28 +106,30 @@ describe("BrowserImport.importCookies", () => {...@@ -105,28 +106,30 @@ describe("BrowserImport.importCookies", () => {
105 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),106 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
106 );107 );
107108
108 it.effect("refuses to import while the source browser holds its profile", () =>109 it.effect.skipIf(!symlinksSupported)(
109 Effect.gen(function* () {110 "refuses to import while the source browser holds its profile",
110 const fileSystem = yield* FileSystem.FileSystem;111 () =>
111 const { importer, root } = yield* withImporter();112 Effect.gen(function* () {
112 // The lock Chromium leaves while it is running, dangling target and113 const fileSystem = yield* FileSystem.FileSystem;
113 // all. This must stop the import before it ever asks the keychain.114 const { importer, root } = yield* withImporter();
114 yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`);115 // The lock Chromium leaves while it is running, dangling target and
115116 // all. This must stop the import before it ever asks the keychain.
116 const error = yield* importer117 yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`);
117 .importCookies({118
118 input: {119 const error = yield* importer
119 sourceId: "helium",120 .importCookies({
120 sourceProfileDirectory: "Default",121 input: {
121 targetProfileId: "default",122 sourceId: "helium",
122 },123 sourceProfileDirectory: "Default",
123 scope: "persist:t3code-preview-test",124 targetProfileId: "default",
124 persistent: true,125 },
125 })126 scope: "persist:t3code-preview-test",
126 .pipe(Effect.flip);127 persistent: true,
128 })
129 .pipe(Effect.flip);
127130
128 assert.equal(error.reason, "browserRunning");131 assert.equal(error.reason, "browserRunning");
129 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),132 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
130 );133 );
131});134});
132135
apps/desktop/src/preview/BrowserImport/Sources.test.ts+104-93
...@@ -32,6 +32,7 @@ import {...@@ -32,6 +32,7 @@ import {
32 sourcePathContext,32 sourcePathContext,
33 windowsChromiumCookiesAreHeld,33 windowsChromiumCookiesAreHeld,
34} from "./Sources.ts";34} from "./Sources.ts";
35import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
3536
36const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;37const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
3738
...@@ -125,7 +126,7 @@ const writeFirefoxCookieDatabase = (...@@ -125,7 +126,7 @@ const writeFirefoxCookieDatabase = (
125 });126 });
126127
127describe("Helium on Linux", () => {128describe("Helium on Linux", () => {
128 it.effect("discovers its profiles and checks the user-data lock", () =>129 it.effect.skipIf(!symlinksSupported)("discovers its profiles and checks the user-data lock", () =>
129 run(130 run(
130 Effect.gen(function* () {131 Effect.gen(function* () {
131 const fileSystem = yield* FileSystem.FileSystem;132 const fileSystem = yield* FileSystem.FileSystem;
...@@ -221,48 +222,52 @@ describe("isSourceRunning", () => {...@@ -221,48 +222,52 @@ describe("isSourceRunning", () => {
221 ),222 ),
222 );223 );
223224
224 it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () =>225 it.effect.skipIf(!symlinksSupported)(
225 run(226 "reads Chromium's dangling SingletonLock symlink as a running browser",
226 Effect.gen(function* () {227 () =>
227 const fileSystem = yield* FileSystem.FileSystem;228 run(
228 const context = yield* withSourceHome();229 Effect.gen(function* () {
229 assert.isFalse(yield* isSourceRunning(helium, context));230 const fileSystem = yield* FileSystem.FileSystem;
230231 const context = yield* withSourceHome();
231 // Chromium points the lock at `<host>-<pid>`, a target that never232 assert.isFalse(yield* isSourceRunning(helium, context));
232 // exists on disk. A check that follows the link reports a running233
233 // browser as closed, letting an import read a live, mid-write database.234 // Chromium points the lock at `<host>-<pid>`, a target that never
234 yield* fileSystem.symlink(235 // exists on disk. A check that follows the link reports a running
235 "host-that-does-not-exist-1234",236 // browser as closed, letting an import read a live, mid-write database.
236 `${userDataDirectory(context)}/SingletonLock`,237 yield* fileSystem.symlink(
237 );238 "host-that-does-not-exist-1234",
239 `${userDataDirectory(context)}/SingletonLock`,
240 );
238241
239 assert.isTrue(yield* isSourceRunning(helium, context));242 assert.isTrue(yield* isSourceRunning(helium, context));
240 }),243 }),
241 ),244 ),
242 );245 );
243246
244 it.effect("uses the provided hostname to classify Chromium locks", () =>247 it.effect.skipIf(!symlinksSupported)(
245 run(248 "uses the provided hostname to classify Chromium locks",
246 Effect.gen(function* () {249 () =>
247 const fileSystem = yield* FileSystem.FileSystem;250 run(
248 const paths = yield* withSourceHome();251 Effect.gen(function* () {
249 yield* fileSystem.symlink(252 const fileSystem = yield* FileSystem.FileSystem;
250 "lock-owner-99999999",253 const paths = yield* withSourceHome();
251 `${helium.userDataDirectory(paths)}/SingletonLock`,254 yield* fileSystem.symlink(
252 );255 "lock-owner-99999999",
256 `${helium.userDataDirectory(paths)}/SingletonLock`,
257 );
253258
254 assert.isTrue(259 assert.isTrue(
255 yield* isSourceRunning(helium, paths).pipe(260 yield* isSourceRunning(helium, paths).pipe(
256 Effect.provideService(HostProcessHostname, "another-host"),261 Effect.provideService(HostProcessHostname, "another-host"),
257 ),262 ),
258 );263 );
259 assert.isFalse(264 assert.isFalse(
260 yield* isSourceRunning(helium, paths).pipe(265 yield* isSourceRunning(helium, paths).pipe(
261 Effect.provideService(HostProcessHostname, "lock-owner"),266 Effect.provideService(HostProcessHostname, "lock-owner"),
262 ),267 ),
263 );268 );
264 }),269 }),
265 ),270 ),
266 );271 );
267});272});
268273
...@@ -385,25 +390,27 @@ describe("isSourceInstalled", () => {...@@ -385,25 +390,27 @@ describe("isSourceInstalled", () => {
385 ),390 ),
386 );391 );
387392
388 it.effect("follows cookie database symlinks when detecting profiles", () =>393 it.effect.skipIf(!symlinksSupported)(
389 run(394 "follows cookie database symlinks when detecting profiles",
390 Effect.gen(function* () {395 () =>
391 const fileSystem = yield* FileSystem.FileSystem;396 run(
392 const context = yield* withSourceHome();397 Effect.gen(function* () {
393 const root = userDataDirectory(context);398 const fileSystem = yield* FileSystem.FileSystem;
394 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });399 const context = yield* withSourceHome();
395 yield* fileSystem.symlink("missing-cookies", `${root}/Default/Cookies`);400 const root = userDataDirectory(context);
401 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
402 yield* fileSystem.symlink("missing-cookies", `${root}/Default/Cookies`);
396403
397 assert.deepEqual(yield* listSourceProfiles(helium, context), []);404 assert.deepEqual(yield* listSourceProfiles(helium, context), []);
398 assert.isFalse(yield* isSourceInstalled(helium, context));405 assert.isFalse(yield* isSourceInstalled(helium, context));
399406
400 yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db");407 yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db");
401 assert.deepEqual(yield* listSourceProfiles(helium, context), [408 assert.deepEqual(yield* listSourceProfiles(helium, context), [
402 { directory: "Default", name: "Default" },409 { directory: "Default", name: "Default" },
403 ]);410 ]);
404 assert.isTrue(yield* isSourceInstalled(helium, context));411 assert.isTrue(yield* isSourceInstalled(helium, context));
405 }),412 }),
406 ),413 ),
407 );414 );
408});415});
409416
...@@ -660,44 +667,48 @@ describe("cookieDatabaseCandidatePaths", () => {...@@ -660,44 +667,48 @@ describe("cookieDatabaseCandidatePaths", () => {
660const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!;667const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!;
661668
662describe("Firefox Snap profiles", () => {669describe("Firefox Snap profiles", () => {
663 it.effect("finds Snap profiles with or without profiles.ini and checks their locks", () =>670 it.effect.skipIf(!symlinksSupported)(
664 run(671 "finds Snap profiles with or without profiles.ini and checks their locks",
665 Effect.gen(function* () {672 () =>
666 const fileSystem = yield* FileSystem.FileSystem;673 run(
667 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-snap-" });674 Effect.gen(function* () {
668 const context = yield* sourcePathContext.pipe(675 const fileSystem = yield* FileSystem.FileSystem;
669 Effect.provideService(HostProcessEnvironment, { HOME: home }),676 const home = yield* fileSystem.makeTempDirectoryScoped({
670 Effect.provideService(HostProcessPlatform, "linux"),677 prefix: "t3code-firefox-snap-",
671 );678 });
672 const root = `${home}/snap/firefox/common/.mozilla/firefox`;679 const context = yield* sourcePathContext.pipe(
673 const directory = `${root}/abcd.default`;680 Effect.provideService(HostProcessEnvironment, { HOME: home }),
674 yield* fileSystem.makeDirectory(directory, { recursive: true });681 Effect.provideService(HostProcessPlatform, "linux"),
675 yield* writeFirefoxCookieDatabase(`${directory}/cookies.sqlite`, 2, 1);682 );
676 yield* fileSystem.writeFileString(683 const root = `${home}/snap/firefox/common/.mozilla/firefox`;
677 `${root}/profiles.ini`,684 const directory = `${root}/abcd.default`;
678 "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n",685 yield* fileSystem.makeDirectory(directory, { recursive: true });
679 );686 yield* writeFirefoxCookieDatabase(`${directory}/cookies.sqlite`, 2, 1);
687 yield* fileSystem.writeFileString(
688 `${root}/profiles.ini`,
689 "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n",
690 );
680691
681 assert.isTrue(yield* isSourceInstalled(firefox, context));692 assert.isTrue(yield* isSourceInstalled(firefox, context));
682 assert.deepEqual(yield* listSourceProfiles(firefox, context), [693 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
683 { directory, name: "Personal", cookieCount: 2 },694 { directory, name: "Personal", cookieCount: 2 },
684 ]);695 ]);
685 assert.equal(696 assert.equal(
686 yield* resolveCookieDatabase(firefox, context, directory),697 yield* resolveCookieDatabase(firefox, context, directory),
687 `${directory}/cookies.sqlite`,698 `${directory}/cookies.sqlite`,
688 );699 );
689 assert.isFalse(yield* isSourceRunning(firefox, context));700 assert.isFalse(yield* isSourceRunning(firefox, context));
690 yield* fileSystem.symlink("foreign-host:+4242", `${directory}/lock`);701 yield* fileSystem.symlink("foreign-host:+4242", `${directory}/lock`);
691 assert.isTrue(yield* isSourceRunning(firefox, context));702 assert.isTrue(yield* isSourceRunning(firefox, context));
692 yield* fileSystem.remove(`${directory}/lock`);703 yield* fileSystem.remove(`${directory}/lock`);
693 assert.isFalse(yield* isSourceRunning(firefox, context));704 assert.isFalse(yield* isSourceRunning(firefox, context));
694705
695 yield* fileSystem.remove(`${root}/profiles.ini`);706 yield* fileSystem.remove(`${root}/profiles.ini`);
696 assert.deepEqual(yield* listSourceProfiles(firefox, context), [707 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
697 { directory, name: "abcd.default", cookieCount: 2 },708 { directory, name: "abcd.default", cookieCount: 2 },
698 ]);709 ]);
699 }),710 }),
700 ),711 ),
701 );712 );
702713
703 it.effect("keeps matching profile names in native and Snap installs distinct", () =>714 it.effect("keeps matching profile names in native and Snap installs distinct", () =>
...@@ -862,7 +873,7 @@ describe("listSourceProfiles Firefox fallback", () => {...@@ -862,7 +873,7 @@ describe("listSourceProfiles Firefox fallback", () => {
862});873});
863874
864describe("isSourceRunning for Firefox", () => {875describe("isSourceRunning for Firefox", () => {
865 it.effect("finds the lock inside the profile, not at the root", () =>876 it.effect.skipIf(!symlinksSupported)("finds the lock inside the profile, not at the root", () =>
866 run(877 run(
867 Effect.gen(function* () {878 Effect.gen(function* () {
868 const fileSystem = yield* FileSystem.FileSystem;879 const fileSystem = yield* FileSystem.FileSystem;
apps/server/scripts/t3-sqlite-state.test.ts+41-38
...@@ -7,6 +7,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";...@@ -7,6 +7,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
77
8import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";8import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
9import { runSqliteState } from "./t3-sqlite-state.ts";9import { runSqliteState } from "./t3-sqlite-state.ts";
10import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1011
11const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(function* (12const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(function* (
12 baseDir: string,13 baseDir: string,
...@@ -73,47 +74,49 @@ it.layer(NodeServices.layer)("t3-sqlite-state", (it) => {...@@ -73,47 +74,49 @@ it.layer(NodeServices.layer)("t3-sqlite-state", (it) => {
73 }),74 }),
74 );75 );
7576
76 it.effect("backs up isolated state before writes and refuses the shared home", () =>77 it.effect.skipIf(!symlinksSupported)(
77 Effect.gen(function* () {78 "backs up isolated state before writes and refuses the shared home",
78 const fs = yield* FileSystem.FileSystem;79 () =>
79 const path = yield* Path.Path;80 Effect.gen(function* () {
80 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-exec-" });81 const fs = yield* FileSystem.FileSystem;
81 yield* createFixtureDatabase(baseDir);82 const path = yield* Path.Path;
83 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-exec-" });
84 yield* createFixtureDatabase(baseDir);
8285
83 const mutation = yield* runSqliteState({86 const mutation = yield* runSqliteState({
84 operation: "exec",
85 baseDir,
86 sql: "INSERT INTO fixtures (id, label) VALUES (2, 'seeded')",
87 });
88 assert.equal(mutation.operation, "exec");
89 if (mutation.operation === "exec") {
90 assert.equal((yield* fs.stat(mutation.backup)).mode & 0o777, 0o600);
91 }
92
93 const error = yield* runSqliteState(
94 {
95 operation: "exec",87 operation: "exec",
96 baseDir,88 baseDir,
97 sql: "DELETE FROM fixtures",89 sql: "INSERT INTO fixtures (id, label) VALUES (2, 'seeded')",
98 },90 });
99 { sharedHome: baseDir },91 assert.equal(mutation.operation, "exec");
100 ).pipe(Effect.flip);92 if (mutation.operation === "exec") {
101 assert.equal(error._tag, "SqliteStateSharedHomeMutationError");93 assert.equal((yield* fs.stat(mutation.backup)).mode & 0o777, 0o600);
94 }
10295
103 const aliasParent = yield* fs.makeTempDirectoryScoped({96 const error = yield* runSqliteState(
104 prefix: "t3-sqlite-state-alias-",97 {
105 });98 operation: "exec",
106 const aliasBaseDir = path.join(aliasParent, "shared-home-alias");99 baseDir,
107 yield* fs.symlink(baseDir, aliasBaseDir);100 sql: "DELETE FROM fixtures",
108 const aliasError = yield* runSqliteState(101 },
109 {102 { sharedHome: baseDir },
110 operation: "exec",103 ).pipe(Effect.flip);
111 baseDir: aliasBaseDir,104 assert.equal(error._tag, "SqliteStateSharedHomeMutationError");
112 sql: "DELETE FROM fixtures",105
113 },106 const aliasParent = yield* fs.makeTempDirectoryScoped({
114 { sharedHome: baseDir },107 prefix: "t3-sqlite-state-alias-",
115 ).pipe(Effect.flip);108 });
116 assert.equal(aliasError._tag, "SqliteStateSharedHomeMutationError");109 const aliasBaseDir = path.join(aliasParent, "shared-home-alias");
117 }),110 yield* fs.symlink(baseDir, aliasBaseDir);
111 const aliasError = yield* runSqliteState(
112 {
113 operation: "exec",
114 baseDir: aliasBaseDir,
115 sql: "DELETE FROM fixtures",
116 },
117 { sharedHome: baseDir },
118 ).pipe(Effect.flip);
119 assert.equal(aliasError._tag, "SqliteStateSharedHomeMutationError");
120 }),
118 );121 );
119});122});
apps/server/src/assets/AssetAccess.test.ts+224-198
...@@ -24,6 +24,7 @@ import { assetFileResponse } from "../http.ts";...@@ -24,6 +24,7 @@ import { assetFileResponse } from "../http.ts";
24import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";24import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
25import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";25import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
26import { openMediaFile } from "./MediaFile.ts";26import { openMediaFile } from "./MediaFile.ts";
27import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
2728
28vi.mock("node:fs/promises", async (importOriginal) => {29vi.mock("node:fs/promises", async (importOriginal) => {
29 const actual = await importOriginal<typeof NodeFSP>();30 const actual = await importOriginal<typeof NodeFSP>();
...@@ -108,216 +109,241 @@ describe("AssetAccess", () => {...@@ -108,216 +109,241 @@ describe("AssetAccess", () => {
108 }).pipe(Effect.provide(testLayer)),109 }).pipe(Effect.provide(testLayer)),
109 );110 );
110111
111 it.effect("rejects non-previewable files, disguised targets, and directories", () =>112 it.effect.skipIf(!symlinksSupported)(
112 Effect.gen(function* () {113 "rejects non-previewable files, disguised targets, and directories",
113 const fs = yield* FileSystem.FileSystem;114 () =>
114 const path = yield* Path.Path;115 Effect.gen(function* () {
115 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" });116 const fs = yield* FileSystem.FileSystem;
116 for (const name of ["report.md", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) {117 const path = yield* Path.Path;
117 const filePath = path.join(root, name);118 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" });
118 yield* fs.writeFileString(filePath, "not media");119 for (const name of ["report.md", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) {
119 const error = yield* issueAssetUrl({120 const filePath = path.join(root, name);
120 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },121 yield* fs.writeFileString(filePath, "not media");
122 const error = yield* issueAssetUrl({
123 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
124 }).pipe(Effect.flip);
125 expect(error).toBeInstanceOf(AssetPreviewTypeValidationError);
126 }
127 const disguisedPath = path.join(root, "disguised.png");
128 yield* fs.symlink(path.join(root, "secret.txt"), disguisedPath);
129 const disguisedError = yield* issueAssetUrl({
130 resource: {
131 _tag: "media-file",
132 threadId: ThreadId.make("thread-1"),
133 path: disguisedPath,
134 },
121 }).pipe(Effect.flip);135 }).pipe(Effect.flip);
122 expect(error).toBeInstanceOf(AssetPreviewTypeValidationError);136 expect(disguisedError).toBeInstanceOf(AssetPreviewTypeValidationError);
123 }137 const directoryPath = path.join(root, "directory.png");
124 const disguisedPath = path.join(root, "disguised.png");138 yield* fs.makeDirectory(directoryPath);
125 yield* fs.symlink(path.join(root, "secret.txt"), disguisedPath);139 const directoryError = yield* issueAssetUrl({
126 const disguisedError = yield* issueAssetUrl({140 resource: {
127 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath },141 _tag: "media-file",
128 }).pipe(Effect.flip);142 threadId: ThreadId.make("thread-1"),
129 expect(disguisedError).toBeInstanceOf(AssetPreviewTypeValidationError);143 path: directoryPath,
130 const directoryPath = path.join(root, "directory.png");144 },
131 yield* fs.makeDirectory(directoryPath);145 }).pipe(Effect.flip);
132 const directoryError = yield* issueAssetUrl({146 expect(directoryError._tag).toBe("AssetWorkspaceAssetNotFoundError");
133 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: directoryPath },147 }).pipe(Effect.provide(testLayer)),
134 }).pipe(Effect.flip);
135 expect(directoryError._tag).toBe("AssetWorkspaceAssetNotFoundError");
136 }).pipe(Effect.provide(testLayer)),
137 );
138
139 it.effect("binds media URLs to the canonical target and rejects symlink substitution", () =>
140 Effect.gen(function* () {
141 const fs = yield* FileSystem.FileSystem;
142 const path = yield* Path.Path;
143 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-symlink-" });
144 const filePath = path.join(root, "actual.svg");
145 const aliasPath = path.join(root, "alias.png");
146 const replacementPath = path.join(root, "other.svg");
147 yield* fs.writeFileString(filePath, "<svg/>");
148 yield* fs.writeFileString(replacementPath, "<svg>private</svg>");
149 yield* fs.symlink(filePath, aliasPath);
150 const canonicalFile = yield* fs.realPath(filePath);
151 const result = yield* issueAssetUrl({
152 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: aliasPath },
153 });
154 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
155 const separator = suffix.indexOf("/");
156 const token = suffix.slice(0, separator);
157 const name = suffix.slice(separator + 1);
158 const expected = { kind: "file", path: canonicalFile, mimeType: "image/svg+xml" };
159 expect(yield* resolveAsset(token, name)).toMatchObject(expected);
160 yield* fs.remove(aliasPath);
161 yield* fs.symlink(replacementPath, aliasPath);
162 expect(yield* resolveAsset(token, name)).toMatchObject(expected);
163 yield* fs.remove(filePath);
164 yield* fs.symlink(replacementPath, filePath);
165 expect(yield* resolveAsset(token, name)).toBeNull();
166 }).pipe(Effect.provide(testLayer)),
167 );148 );
168149
169 it.effect("keeps full and partial responses bound to the file opened during resolution", () =>150 it.effect.skipIf(!symlinksSupported)(
170 Effect.gen(function* () {151 "binds media URLs to the canonical target and rejects symlink substitution",
171 const fs = yield* FileSystem.FileSystem;152 () =>
172 const path = yield* Path.Path;153 Effect.gen(function* () {
173 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-file-" });154 const fs = yield* FileSystem.FileSystem;
174 const filePath = path.join(root, "recording.mp4");155 const path = yield* Path.Path;
175 const savedPath = path.join(root, "saved.mp4");156 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-symlink-" });
176 const secretPath = path.join(root, "secret.txt");157 const filePath = path.join(root, "actual.svg");
177 yield* fs.writeFileString(filePath, "0123456789");158 const aliasPath = path.join(root, "alias.png");
178 yield* fs.writeFileString(secretPath, "private information");159 const replacementPath = path.join(root, "other.svg");
179 const result = yield* issueAssetUrl({160 yield* fs.writeFileString(filePath, "<svg/>");
180 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },161 yield* fs.writeFileString(replacementPath, "<svg>private</svg>");
181 });162 yield* fs.symlink(filePath, aliasPath);
182 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);163 const canonicalFile = yield* fs.realPath(filePath);
183 const separator = suffix.indexOf("/");164 const result = yield* issueAssetUrl({
184 for (const [range, expected, status] of [165 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: aliasPath },
185 [undefined, "0123456789", 200],166 });
186 ["bytes=2-5", "2345", 206],167 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
187 ] as const) {168 const separator = suffix.indexOf("/");
188 const asset = yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1));169 const token = suffix.slice(0, separator);
189 if (!asset) throw new Error("Expected a resolved media file");170 const name = suffix.slice(separator + 1);
190171 const expected = { kind: "file", path: canonicalFile, mimeType: "image/svg+xml" };
191 yield* fs.rename(filePath, savedPath);172 expect(yield* resolveAsset(token, name)).toMatchObject(expected);
192 yield* fs.symlink(secretPath, filePath);173 yield* fs.remove(aliasPath);
193 const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, range));174 yield* fs.symlink(replacementPath, aliasPath);
194 expect(response.status).toBe(status);175 expect(yield* resolveAsset(token, name)).toMatchObject(expected);
195 expect(response.headers.get("content-length")).toBe(String(expected.length));
196 expect(yield* Effect.promise(() => response.text())).toBe(expected);
197 yield* fs.remove(filePath);176 yield* fs.remove(filePath);
198 yield* fs.rename(savedPath, filePath);177 yield* fs.symlink(replacementPath, filePath);
199 }178 expect(yield* resolveAsset(token, name)).toBeNull();
200 }).pipe(Effect.provide(testLayer)),179 }).pipe(Effect.provide(testLayer)),
201 );180 );
202181
203 it.effect("rejects a symlink swapped in after canonical validation but before open", () =>182 it.effect.skipIf(!symlinksSupported)(
204 Effect.gen(function* () {183 "keeps full and partial responses bound to the file opened during resolution",
205 const fs = yield* FileSystem.FileSystem;184 () =>
206 const path = yield* Path.Path;185 Effect.gen(function* () {
207 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-race-" });186 const fs = yield* FileSystem.FileSystem;
208 const filePath = path.join(root, "recording.mp4");187 const path = yield* Path.Path;
209 const secretPath = path.join(root, "secret.txt");188 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-file-" });
210 yield* fs.writeFileString(filePath, "video");189 const filePath = path.join(root, "recording.mp4");
211 yield* fs.writeFileString(secretPath, "secret");190 const savedPath = path.join(root, "saved.mp4");
212 const canonicalPath = yield* fs.realPath(filePath);191 const secretPath = path.join(root, "secret.txt");
213 const result = yield* issueAssetUrl({192 yield* fs.writeFileString(filePath, "0123456789");
214 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },193 yield* fs.writeFileString(secretPath, "private information");
215 });194 const result = yield* issueAssetUrl({
216 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);195 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
217 const separator = suffix.indexOf("/");196 });
218 const swappingFileSystem = FileSystem.FileSystem.of({197 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
219 ...fs,198 const separator = suffix.indexOf("/");
220 stat: Effect.fn(function* (requestedPath) {199 for (const [range, expected, status] of [
221 const info = yield* fs.stat(requestedPath);200 [undefined, "0123456789", 200],
222 if (requestedPath === canonicalPath) {201 ["bytes=2-5", "2345", 206],
223 yield* fs.remove(filePath);202 ] as const) {
224 yield* fs.symlink(secretPath, filePath);203 const asset = yield* resolveAsset(
225 }204 suffix.slice(0, separator),
226 return info;205 suffix.slice(separator + 1),
227 }),206 );
228 });207 if (!asset) throw new Error("Expected a resolved media file");
229 expect(208
230 yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe(209 yield* fs.rename(filePath, savedPath);
231 Effect.provideService(FileSystem.FileSystem, swappingFileSystem),210 yield* fs.symlink(secretPath, filePath);
232 ),211 const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, range));
233 ).toBeNull();212 expect(response.status).toBe(status);
234 }).pipe(Effect.provide(testLayer)),213 expect(response.headers.get("content-length")).toBe(String(expected.length));
214 expect(yield* Effect.promise(() => response.text())).toBe(expected);
215 yield* fs.remove(filePath);
216 yield* fs.rename(savedPath, filePath);
217 }
218 }).pipe(Effect.provide(testLayer)),
235 );219 );
236220
237 it.effect("closes a descriptor rejected when its path changes during open", () =>221 it.effect.skipIf(!symlinksSupported)(
238 Effect.gen(function* () {222 "rejects a symlink swapped in after canonical validation but before open",
239 const fs = yield* FileSystem.FileSystem;223 () =>
240 const path = yield* Path.Path;224 Effect.gen(function* () {
241 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-rejected-" });225 const fs = yield* FileSystem.FileSystem;
242 const filePath = path.join(root, "recording.mp4");226 const path = yield* Path.Path;
243 const secretPath = path.join(root, "secret.txt");227 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-race-" });
244 yield* fs.writeFileString(filePath, "video");228 const filePath = path.join(root, "recording.mp4");
245 yield* fs.writeFileString(secretPath, "secret");229 const secretPath = path.join(root, "secret.txt");
246 const canonicalPath = yield* fs.realPath(filePath);230 yield* fs.writeFileString(filePath, "video");
247 const originalOpen = (yield* Effect.promise(() =>231 yield* fs.writeFileString(secretPath, "secret");
248 vi.importActual<typeof NodeFSP>("node:fs/promises"),232 const canonicalPath = yield* fs.realPath(filePath);
249 )).open;233 const result = yield* issueAssetUrl({
250 let opened: NodeFSP.FileHandle | undefined;234 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
251 const openSpy = vi.mocked(NodeFSP.open).mockImplementation(async (target, flags, mode) => {235 });
252 const handle = await originalOpen(target, flags, mode);236 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
253 if (target === canonicalPath) {237 const separator = suffix.indexOf("/");
254 opened = handle;238 const swappingFileSystem = FileSystem.FileSystem.of({
255 await NodeFSP.unlink(filePath);239 ...fs,
256 await NodeFSP.symlink(secretPath, filePath);240 stat: Effect.fn(function* (requestedPath) {
257 }241 const info = yield* fs.stat(requestedPath);
258 return handle;242 if (requestedPath === canonicalPath) {
259 });243 yield* fs.remove(filePath);
260 yield* Effect.addFinalizer(() => Effect.sync(() => openSpy.mockImplementation(originalOpen)));244 yield* fs.symlink(secretPath, filePath);
261 expect(yield* openMediaFile(canonicalPath)).toBeNull();245 }
262 expect(opened).toBeDefined();246 return info;
263 expect(opened?.fd).toBe(-1);247 }),
264 }).pipe(Effect.provide(testLayer)),248 });
249 expect(
250 yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe(
251 Effect.provideService(FileSystem.FileSystem, swappingFileSystem),
252 ),
253 ).toBeNull();
254 }).pipe(Effect.provide(testLayer)),
265 );255 );
266256
267 it.effect("rejects an ancestor symlink race even when canonical path rechecks would pass", () =>257 it.effect.skipIf(!symlinksSupported)(
268 Effect.gen(function* () {258 "closes a descriptor rejected when its path changes during open",
269 const fs = yield* FileSystem.FileSystem;259 () =>
270 const path = yield* Path.Path;260 Effect.gen(function* () {
271 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-parent-race-" });261 const fs = yield* FileSystem.FileSystem;
272 const publicDirectory = path.join(root, "public");262 const path = yield* Path.Path;
273 const privateDirectory = path.join(root, "private");263 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-rejected-" });
274 yield* fs.makeDirectory(publicDirectory);264 const filePath = path.join(root, "recording.mp4");
275 yield* fs.makeDirectory(privateDirectory);265 const secretPath = path.join(root, "secret.txt");
276 const filePath = path.join(publicDirectory, "recording.mp4");266 yield* fs.writeFileString(filePath, "video");
277 yield* fs.writeFileString(filePath, "public video");267 yield* fs.writeFileString(secretPath, "secret");
278 yield* fs.writeFileString(path.join(privateDirectory, "recording.mp4"), "private video");268 const canonicalPath = yield* fs.realPath(filePath);
279 const canonicalPath = yield* fs.realPath(filePath);269 const originalOpen = (yield* Effect.promise(() =>
280 const result = yield* issueAssetUrl({270 vi.importActual<typeof NodeFSP>("node:fs/promises"),
281 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },271 )).open;
282 });272 let opened: NodeFSP.FileHandle | undefined;
283 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);273 const openSpy = vi.mocked(NodeFSP.open).mockImplementation(async (target, flags, mode) => {
284 const separator = suffix.indexOf("/");274 const handle = await originalOpen(target, flags, mode);
285 const native = yield* Effect.promise(() =>275 if (target === canonicalPath) {
286 vi.importActual<typeof NodeFSP>("node:fs/promises"),276 opened = handle;
287 );277 await NodeFSP.unlink(filePath);
288 const savedDirectory = path.join(root, "saved");278 await NodeFSP.symlink(secretPath, filePath);
289 const realpathSpy = vi.mocked(NodeFSP.realpath).mockImplementationOnce(async () => {
290 // A pathname-only guard can see the original parents during realpath,
291 // but the private file during both lstat calls and open.
292 await native.unlink(publicDirectory);
293 await native.rename(savedDirectory, publicDirectory);
294 const canonical = await native.realpath(canonicalPath);
295 await native.rename(publicDirectory, savedDirectory);
296 await native.symlink(privateDirectory, publicDirectory, "junction");
297 return canonical;
298 });
299 yield* Effect.addFinalizer(() =>
300 Effect.sync(() => realpathSpy.mockReset().mockImplementation(native.realpath)),
301 );
302 const swappingFileSystem = FileSystem.FileSystem.of({
303 ...fs,
304 realPath: Effect.fn(function* (requestedPath) {
305 const canonical = yield* fs.realPath(requestedPath);
306 if (requestedPath === canonicalPath) {
307 yield* fs.rename(publicDirectory, savedDirectory);
308 yield* Effect.promise(() =>
309 NodeFSP.symlink(privateDirectory, publicDirectory, "junction"),
310 );
311 }279 }
280 return handle;
281 });
282 yield* Effect.addFinalizer(() =>
283 Effect.sync(() => openSpy.mockImplementation(originalOpen)),
284 );
285 expect(yield* openMediaFile(canonicalPath)).toBeNull();
286 expect(opened).toBeDefined();
287 expect(opened?.fd).toBe(-1);
288 }).pipe(Effect.provide(testLayer)),
289 );
290
291 it.effect.skipIf(!symlinksSupported)(
292 "rejects an ancestor symlink race even when canonical path rechecks would pass",
293 () =>
294 Effect.gen(function* () {
295 const fs = yield* FileSystem.FileSystem;
296 const path = yield* Path.Path;
297 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-parent-race-" });
298 const publicDirectory = path.join(root, "public");
299 const privateDirectory = path.join(root, "private");
300 yield* fs.makeDirectory(publicDirectory);
301 yield* fs.makeDirectory(privateDirectory);
302 const filePath = path.join(publicDirectory, "recording.mp4");
303 yield* fs.writeFileString(filePath, "public video");
304 yield* fs.writeFileString(path.join(privateDirectory, "recording.mp4"), "private video");
305 const canonicalPath = yield* fs.realPath(filePath);
306 const result = yield* issueAssetUrl({
307 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
308 });
309 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
310 const separator = suffix.indexOf("/");
311 const native = yield* Effect.promise(() =>
312 vi.importActual<typeof NodeFSP>("node:fs/promises"),
313 );
314 const savedDirectory = path.join(root, "saved");
315 const realpathSpy = vi.mocked(NodeFSP.realpath).mockImplementationOnce(async () => {
316 // A pathname-only guard can see the original parents during realpath,
317 // but the private file during both lstat calls and open.
318 await native.unlink(publicDirectory);
319 await native.rename(savedDirectory, publicDirectory);
320 const canonical = await native.realpath(canonicalPath);
321 await native.rename(publicDirectory, savedDirectory);
322 await native.symlink(privateDirectory, publicDirectory, "junction");
312 return canonical;323 return canonical;
313 }),324 });
314 });325 yield* Effect.addFinalizer(() =>
315 expect(326 Effect.sync(() => realpathSpy.mockReset().mockImplementation(native.realpath)),
316 yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe(327 );
317 Effect.provideService(FileSystem.FileSystem, swappingFileSystem),328 const swappingFileSystem = FileSystem.FileSystem.of({
318 ),329 ...fs,
319 ).toBeNull();330 realPath: Effect.fn(function* (requestedPath) {
320 }).pipe(Effect.provide(testLayer)),331 const canonical = yield* fs.realPath(requestedPath);
332 if (requestedPath === canonicalPath) {
333 yield* fs.rename(publicDirectory, savedDirectory);
334 yield* Effect.promise(() =>
335 NodeFSP.symlink(privateDirectory, publicDirectory, "junction"),
336 );
337 }
338 return canonical;
339 }),
340 });
341 expect(
342 yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe(
343 Effect.provideService(FileSystem.FileSystem, swappingFileSystem),
344 ),
345 ).toBeNull();
346 }).pipe(Effect.provide(testLayer)),
321 );347 );
322348
323 it.effect("keeps in-place edits readable but requires a new URL after atomic replacement", () =>349 it.effect("keeps in-place edits readable but requires a new URL after atomic replacement", () =>
apps/server/src/cli/theme.test.ts+44-39
...@@ -13,6 +13,7 @@ import * as TestConsole from "effect/testing/TestConsole";...@@ -13,6 +13,7 @@ import * as TestConsole from "effect/testing/TestConsole";
13import { Command } from "effect/unstable/cli";13import { Command } from "effect/unstable/cli";
1414
15import { cli } from "../bin.ts";15import { cli } from "../bin.ts";
16import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1617
17const runCli = (args: ReadonlyArray<string>) =>18const runCli = (args: ReadonlyArray<string>) =>
18 Command.runWith(cli, { version: "0.0.0" })(args).pipe(19 Command.runWith(cli, { version: "0.0.0" })(args).pipe(
...@@ -185,27 +186,29 @@ describe("t3 theme", () => {...@@ -185,27 +186,29 @@ describe("t3 theme", () => {
185186
186 // A symlink is a normal way to hand this command a theme -- desktop hooks187 // A symlink is a normal way to hand this command a theme -- desktop hooks
187 // symlink the current palette -- so the source is resolved, not refused.188 // symlink the current palette -- so the source is resolved, not refused.
188 it.effect("publishes a theme file through a symlinked source path", () =>189 it.effect.skipIf(!symlinksSupported)(
189 Effect.gen(function* () {190 "publishes a theme file through a symlinked source path",
190 const baseDir = makeBaseDir();191 () =>
191 const realFile = NodePath.join(baseDir, "real-nightfall.json");192 Effect.gen(function* () {
192 NodeFS.writeFileSync(realFile, NIGHTFALL_THEME_JSON);193 const baseDir = makeBaseDir();
193 const linkPath = NodePath.join(baseDir, "nightfall.json");194 const realFile = NodePath.join(baseDir, "real-nightfall.json");
194 NodeFS.symlinkSync(realFile, linkPath);195 NodeFS.writeFileSync(realFile, NIGHTFALL_THEME_JSON);
195196 const linkPath = NodePath.join(baseDir, "nightfall.json");
196 yield* runCli(["theme", "set", linkPath, "--base-dir", baseDir]);197 NodeFS.symlinkSync(realFile, linkPath);
197198
198 assert.equal(199 yield* runCli(["theme", "set", linkPath, "--base-dir", baseDir]);
199 NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")),200
200 true,201 assert.equal(
201 );202 NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")),
202 assert.equal(readSettings(baseDir).defaultTheme, "nightfall");203 true,
203 }),204 );
205 assert.equal(readSettings(baseDir).defaultTheme, "nightfall");
206 }),
204 );207 );
205208
206 // The staging entry is created fresh with O_EXCL, so a symlink planted at209 // The staging entry is created fresh with O_EXCL, so a symlink planted at
207 // its predictable name is cleared, never followed and written through.210 // its predictable name is cleared, never followed and written through.
208 it.effect("never writes through a symlink at the staging path", () =>211 it.effect.skipIf(!symlinksSupported)("never writes through a symlink at the staging path", () =>
209 Effect.gen(function* () {212 Effect.gen(function* () {
210 const baseDir = makeBaseDir();213 const baseDir = makeBaseDir();
211 const themesDir = NodePath.join(baseDir, "userdata", "themes");214 const themesDir = NodePath.join(baseDir, "userdata", "themes");
...@@ -226,28 +229,30 @@ describe("t3 theme", () => {...@@ -226,28 +229,30 @@ describe("t3 theme", () => {
226 // Rollback moves the previous directory entry aside and back, so even an229 // Rollback moves the previous directory entry aside and back, so even an
227 // entry the watcher would never publish -- here a symlink -- comes back230 // entry the watcher would never publish -- here a symlink -- comes back
228 // exactly as it was when the set fails.231 // exactly as it was when the set fails.
229 it.effect("restores a non-theme destination entry when the set fails", () =>232 it.effect.skipIf(!symlinksSupported)(
230 Effect.gen(function* () {233 "restores a non-theme destination entry when the set fails",
231 const baseDir = makeBaseDir();234 () =>
232 writeSettings(baseDir, {});235 Effect.gen(function* () {
233 const userdataDir = NodePath.dirname(settingsPathFor(baseDir));236 const baseDir = makeBaseDir();
234 const themesDir = NodePath.join(userdataDir, "themes");237 writeSettings(baseDir, {});
235 NodeFS.mkdirSync(themesDir, { recursive: true });238 const userdataDir = NodePath.dirname(settingsPathFor(baseDir));
236 const outside = NodePath.join(baseDir, "outside.json");239 const themesDir = NodePath.join(userdataDir, "themes");
237 NodeFS.writeFileSync(outside, NIGHTFALL_THEME_JSON);240 NodeFS.mkdirSync(themesDir, { recursive: true });
238 const destination = NodePath.join(themesDir, "nightfall.json");241 const outside = NodePath.join(baseDir, "outside.json");
239 NodeFS.symlinkSync(outside, destination);242 NodeFS.writeFileSync(outside, NIGHTFALL_THEME_JSON);
240 const themeFile = NodePath.join(baseDir, "nightfall.json");243 const destination = NodePath.join(themesDir, "nightfall.json");
241 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);244 NodeFS.symlinkSync(outside, destination);
242245 const themeFile = NodePath.join(baseDir, "nightfall.json");
243 NodeFS.chmodSync(userdataDir, 0o555);246 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
244 try {247
245 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip);248 NodeFS.chmodSync(userdataDir, 0o555);
246 assert.equal(NodeFS.lstatSync(destination).isSymbolicLink(), true);249 try {
247 } finally {250 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip);
248 NodeFS.chmodSync(userdataDir, 0o755);251 assert.equal(NodeFS.lstatSync(destination).isSymbolicLink(), true);
249 }252 } finally {
250 }),253 NodeFS.chmodSync(userdataDir, 0o755);
254 }
255 }),
251 );256 );
252257
253 it.effect("restores the previous theme when a re-publish fails to set", () =>258 it.effect("restores the previous theme when a re-publish fails to set", () =>
apps/server/src/entrypoint.test.ts+18-14
...@@ -7,6 +7,7 @@ import * as NodeURL from "node:url";...@@ -7,6 +7,7 @@ import * as NodeURL from "node:url";
7import { describe, expect, it } from "vite-plus/test";7import { describe, expect, it } from "vite-plus/test";
88
9import { isEntrypoint } from "./entrypoint.ts";9import { isEntrypoint } from "./entrypoint.ts";
10import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1011
11const makeTempDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-entrypoint-test-"));12const makeTempDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-entrypoint-test-"));
1213
...@@ -44,21 +45,24 @@ describe("isEntrypoint", () => {...@@ -44,21 +45,24 @@ describe("isEntrypoint", () => {
44 ).toBe(true);45 ).toBe(true);
45 });46 });
4647
47 it("matches through a symlinked entrypoint, as npm and npx install it", () => {48 it.skipIf(!symlinksSupported)(
48 const dir = makeTempDir();49 "matches through a symlinked entrypoint, as npm and npx install it",
49 const real = NodePath.join(dir, "bin.mjs");50 () => {
50 const link = NodePath.join(dir, "t3");51 const dir = makeTempDir();
51 NodeFS.writeFileSync(real, "");52 const real = NodePath.join(dir, "bin.mjs");
52 NodeFS.symlinkSync(real, link);53 const link = NodePath.join(dir, "t3");
54 NodeFS.writeFileSync(real, "");
55 NodeFS.symlinkSync(real, link);
5356
54 expect(57 expect(
55 isEntrypoint({58 isEntrypoint({
56 moduleUrl: NodeURL.pathToFileURL(real).href,59 moduleUrl: NodeURL.pathToFileURL(real).href,
57 entryPath: link,60 entryPath: link,
58 runtimeMain: undefined,61 runtimeMain: undefined,
59 }),62 }),
60 ).toBe(true);63 ).toBe(true);
61 });64 },
65 );
6266
63 it("stays false for an imported module that is not the entrypoint", () => {67 it("stays false for an imported module that is not the entrypoint", () => {
64 // This is what keeps `bin.test.ts` from launching the CLI on import.68 // This is what keeps `bin.test.ts` from launching the CLI on import.
apps/server/src/environmentTheme.test.ts+2-1
...@@ -13,6 +13,7 @@ import * as Stream from "effect/Stream";...@@ -13,6 +13,7 @@ import * as Stream from "effect/Stream";
1313
14import * as ServerConfig from "./config.ts";14import * as ServerConfig from "./config.ts";
15import * as EnvironmentTheme from "./environmentTheme.ts";15import * as EnvironmentTheme from "./environmentTheme.ts";
16import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1617
17const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile));18const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile));
1819
...@@ -188,7 +189,7 @@ it.layer(NodeServices.layer)("environment theme", (it) => {...@@ -188,7 +189,7 @@ it.layer(NodeServices.layer)("environment theme", (it) => {
188189
189 // A symlinked themes directory stays usable, but a symlinked file inside it190 // A symlinked themes directory stays usable, but a symlinked file inside it
190 // must not publish whatever it points at.191 // must not publish whatever it points at.
191 it.effect("ignores a symlinked theme file", () =>192 it.effect.skipIf(!symlinksSupported)("ignores a symlinked theme file", () =>
192 withEnvironmentThemes(193 withEnvironmentThemes(
193 {},194 {},
194 Effect.gen(function* () {195 Effect.gen(function* () {
apps/server/src/orchestration/workflowScriptQuery.test.ts+27-28
...@@ -5,6 +5,7 @@ import * as NodePath from "node:path";...@@ -5,6 +5,7 @@ import * as NodePath from "node:path";
5import { it as effectIt } from "@effect/vitest";5import { it as effectIt } from "@effect/vitest";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import { afterAll, assert, describe } from "vite-plus/test";7import { afterAll, assert, describe } from "vite-plus/test";
8import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
8import { readWorkflowScript } from "./workflowScriptQuery.ts";9import { readWorkflowScript } from "./workflowScriptQuery.ts";
910
10const root = NodePath.join(NodeOS.homedir(), ".claude", "projects", "__wf_script_test__");11const root = NodePath.join(NodeOS.homedir(), ".claude", "projects", "__wf_script_test__");
...@@ -14,19 +15,15 @@ NodeFS.writeFileSync(scriptPath, "export const meta = {};\n");...@@ -14,19 +15,15 @@ NodeFS.writeFileSync(scriptPath, "export const meta = {};\n");
14const outside = NodePath.join(NodeOS.tmpdir(), "wf-outside.js");15const outside = NodePath.join(NodeOS.tmpdir(), "wf-outside.js");
15NodeFS.writeFileSync(outside, "evil\n");16NodeFS.writeFileSync(outside, "evil\n");
16const link = NodePath.join(root, "sneaky.js");17const link = NodePath.join(root, "sneaky.js");
17try {18// Planted only where the host allows it; the escape test is skipped
19// otherwise rather than passing vacuously on "not-found".
20if (symlinksSupported) {
21 NodeFS.rmSync(link, { force: true });
18 NodeFS.symlinkSync(outside, link);22 NodeFS.symlinkSync(outside, link);
19} catch (error) {23 if (!NodeFS.lstatSync(link).isSymbolicLink()) {
20 // Tolerate only "already exists" from a prior run — any other failure24 throw new Error("test setup: sneaky.js must be a symlink");
21 // (EPERM etc.) must fail setup, or the escape test below would pass
22 // vacuously on "not-found" without testing containment.
23 if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
24 throw error;
25 }25 }
26}26}
27if (!NodeFS.lstatSync(link).isSymbolicLink()) {
28 throw new Error("test setup: sneaky.js must be a symlink");
29}
3027
31afterAll(() => {28afterAll(() => {
32 NodeFS.rmSync(root, { recursive: true, force: true });29 NodeFS.rmSync(root, { recursive: true, force: true });
...@@ -53,23 +50,25 @@ describe("readWorkflowScript containment", () => {...@@ -53,23 +50,25 @@ describe("readWorkflowScript containment", () => {
53 }),50 }),
54 );51 );
5552
56 effectIt.effect("rejects paths outside the root and symlink escapes", () =>53 effectIt.effect.skipIf(!symlinksSupported)(
57 Effect.gen(function* () {54 "rejects paths outside the root and symlink escapes",
58 const escaped = yield* Effect.exit(readWorkflowScript({ scriptPath: outside }));55 () =>
59 assert.equal(escaped._tag, "Failure");56 Effect.gen(function* () {
60 // A symlink INSIDE the root pointing outside must fail specifically on57 const escaped = yield* Effect.exit(readWorkflowScript({ scriptPath: outside }));
61 // realpath re-containment — a "not-found" would mean the link was58 assert.equal(escaped._tag, "Failure");
62 // never exercised and the assertion proves nothing.59 // A symlink INSIDE the root pointing outside must fail specifically on
63 const sneaky = yield* Effect.exit(60 // realpath re-containment — a "not-found" would mean the link was
64 readWorkflowScript({ scriptPath: link }).pipe(61 // never exercised and the assertion proves nothing.
65 Effect.flip,62 const sneaky = yield* Effect.exit(
66 Effect.map((error) => error.reason),63 readWorkflowScript({ scriptPath: link }).pipe(
67 ),64 Effect.flip,
68 );65 Effect.map((error) => error.reason),
69 assert.equal(sneaky._tag, "Success");66 ),
70 if (sneaky._tag === "Success") {67 );
71 assert.equal(sneaky.value, "outside-root");68 assert.equal(sneaky._tag, "Success");
72 }69 if (sneaky._tag === "Success") {
73 }),70 assert.equal(sneaky.value, "outside-root");
71 }
72 }),
74 );73 );
75});74});
apps/server/src/provider/Drivers/AntigravitySkills.test.ts+23-20
...@@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";5import * as Path from "effect/Path";
66
7import { discoverAntigravitySkills } from "./AntigravitySkills.ts";7import { discoverAntigravitySkills } from "./AntigravitySkills.ts";
8import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
89
9const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) {10const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) {
10 const fileSystem = yield* FileSystem.FileSystem;11 const fileSystem = yield* FileSystem.FileSystem;
...@@ -239,27 +240,29 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => {...@@ -239,27 +240,29 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => {
239 }),240 }),
240 );241 );
241242
242 it.effect("follows directory symlinks used to install shared skills", () =>243 it.effect.skipIf(!symlinksSupported)(
243 Effect.gen(function* () {244 "follows directory symlinks used to install shared skills",
244 const fileSystem = yield* FileSystem.FileSystem;245 () =>
245 const path = yield* Path.Path;246 Effect.gen(function* () {
246 const input = yield* makeWorkspace();247 const fileSystem = yield* FileSystem.FileSystem;
247 const sourceDirectory = path.join(input.profileDirectory, "shared-review");248 const path = yield* Path.Path;
248 yield* writeSkill(sourceDirectory, "---\nname: review\n---\n");249 const input = yield* makeWorkspace();
249 const root = path.join(input.cwd, ".agents", "skills");250 const sourceDirectory = path.join(input.profileDirectory, "shared-review");
250 const linkedDirectory = path.join(root, "review");251 yield* writeSkill(sourceDirectory, "---\nname: review\n---\n");
251 yield* fileSystem.makeDirectory(root, { recursive: true });252 const root = path.join(input.cwd, ".agents", "skills");
252 yield* fileSystem.symlink(sourceDirectory, linkedDirectory);253 const linkedDirectory = path.join(root, "review");
254 yield* fileSystem.makeDirectory(root, { recursive: true });
255 yield* fileSystem.symlink(sourceDirectory, linkedDirectory);
253256
254 assert.deepEqual(yield* discoverAntigravitySkills(input), [257 assert.deepEqual(yield* discoverAntigravitySkills(input), [
255 {258 {
256 name: "review",259 name: "review",
257 path: path.join(linkedDirectory, "SKILL.md"),260 path: path.join(linkedDirectory, "SKILL.md"),
258 scope: "project",261 scope: "project",
259 enabled: true,262 enabled: true,
260 },263 },
261 ]);264 ]);
262 }),265 }),
263 );266 );
264267
265 it.effect("rejects an oversized skill instead of returning an incomplete catalog", () =>268 it.effect("rejects an oversized skill instead of returning an incomplete catalog", () =>
apps/server/src/provider/Drivers/CodexHomeLayout.test.ts+162-150
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 materializeCodexShadowHome,13 materializeCodexShadowHome,
14 resolveCodexHomeLayout,14 resolveCodexHomeLayout,
15} from "./CodexHomeLayout.ts";15} from "./CodexHomeLayout.ts";
16import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
16const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings);17const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings);
1718
18const decodeCodexSettings = (input: {19const decodeCodexSettings = (input: {
...@@ -84,130 +85,139 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {...@@ -84,130 +85,139 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
84 });85 });
8586
86 describe("materializeCodexShadowHome", () => {87 describe("materializeCodexShadowHome", () => {
87 it.effect("materializes a shadow home with shared state links and private auth", () =>88 it.effect.skipIf(!symlinksSupported)(
88 Effect.gen(function* () {89 "materializes a shadow home with shared state links and private auth",
89 const fileSystem = yield* FileSystem.FileSystem;90 () =>
90 const path = yield* Path.Path;91 Effect.gen(function* () {
91 const sharedHome = yield* makeTempDir("t3code-codex-shared-");92 const fileSystem = yield* FileSystem.FileSystem;
92 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");93 const path = yield* Path.Path;
93 const shadowHome = path.join(shadowRoot, "shadow");94 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
9495 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
95 yield* fileSystem.makeDirectory(path.join(sharedHome, "sessions"));96 const shadowHome = path.join(shadowRoot, "shadow");
96 yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');97
97 yield* writeTextFile(path.join(sharedHome, "models_cache.json"), '{"models":["shared"]}\n');98 yield* fileSystem.makeDirectory(path.join(sharedHome, "sessions"));
98 yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n');99 yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');
99 yield* fileSystem.makeDirectory(shadowHome, { recursive: true });100 yield* writeTextFile(
100 yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n');101 path.join(sharedHome, "models_cache.json"),
101 yield* fileSystem.symlink(102 '{"models":["shared"]}\n',
102 path.join(sharedHome, "models_cache.json"),103 );
103 path.join(shadowHome, "models_cache.json"),104 yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n');
104 );105 yield* fileSystem.makeDirectory(shadowHome, { recursive: true });
105106 yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n');
106 const layout = yield* resolveCodexHomeLayout(107 yield* fileSystem.symlink(
107 decodeCodexSettings({108 path.join(sharedHome, "models_cache.json"),
108 homePath: sharedHome,109 path.join(shadowHome, "models_cache.json"),
109 shadowHomePath: shadowHome,110 );
110 }),111
111 );112 const layout = yield* resolveCodexHomeLayout(
112113 decodeCodexSettings({
113 yield* materializeCodexShadowHome(layout);114 homePath: sharedHome,
114115 shadowHomePath: shadowHome,
115 const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions"));116 }),
116 const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml"));117 );
117 const mcpOauthLocksTarget = yield* fileSystem.readLink(118
118 path.join(shadowHome, "mcp-oauth-locks"),119 yield* materializeCodexShadowHome(layout);
119 );120
120 const modelsCacheExists = yield* fileSystem.exists(121 const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions"));
121 path.join(shadowHome, "models_cache.json"),122 const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml"));
122 );123 const mcpOauthLocksTarget = yield* fileSystem.readLink(
123 const authLinkResult = yield* fileSystem124 path.join(shadowHome, "mcp-oauth-locks"),
124 .readLink(path.join(shadowHome, "auth.json"))125 );
125 .pipe(Effect.result);126 const modelsCacheExists = yield* fileSystem.exists(
126 const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json"));127 path.join(shadowHome, "models_cache.json"),
127128 );
128 expect(sessionsTarget).toBe(path.join(sharedHome, "sessions"));129 const authLinkResult = yield* fileSystem
129 expect(configTarget).toBe(path.join(sharedHome, "config.toml"));130 .readLink(path.join(shadowHome, "auth.json"))
130 expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks"));131 .pipe(Effect.result);
131 expect(modelsCacheExists).toBe(false);132 const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json"));
132 expect(authLinkResult._tag).toBe("Failure");133
133 expect(authContents).toContain("shadow");134 expect(sessionsTarget).toBe(path.join(sharedHome, "sessions"));
134 }),135 expect(configTarget).toBe(path.join(sharedHome, "config.toml"));
136 expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks"));
137 expect(modelsCacheExists).toBe(false);
138 expect(authLinkResult._tag).toBe("Failure");
139 expect(authContents).toContain("shadow");
140 }),
135 );141 );
136142
137 it.effect("replaces Codex-created local MCP OAuth locks with the shared lock directory", () =>143 it.effect.skipIf(!symlinksSupported)(
138 Effect.gen(function* () {144 "replaces Codex-created local MCP OAuth locks with the shared lock directory",
139 const fileSystem = yield* FileSystem.FileSystem;145 () =>
140 const path = yield* Path.Path;146 Effect.gen(function* () {
141 const sharedHome = yield* makeTempDir("t3code-codex-shared-");147 const fileSystem = yield* FileSystem.FileSystem;
142 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");148 const path = yield* Path.Path;
143 const shadowHome = path.join(shadowRoot, "shadow");149 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
144 const sharedLocks = path.join(sharedHome, "mcp-oauth-locks");150 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
145 const shadowLocks = path.join(shadowHome, "mcp-oauth-locks");151 const shadowHome = path.join(shadowRoot, "shadow");
146152 const sharedLocks = path.join(sharedHome, "mcp-oauth-locks");
147 yield* writeTextFile(path.join(sharedLocks, "file-store.lock"), "");153 const shadowLocks = path.join(shadowHome, "mcp-oauth-locks");
148 yield* writeTextFile(path.join(shadowLocks, "file-store.lock"), "");154
149155 yield* writeTextFile(path.join(sharedLocks, "file-store.lock"), "");
150 const layout = yield* resolveCodexHomeLayout(156 yield* writeTextFile(path.join(shadowLocks, "file-store.lock"), "");
151 decodeCodexSettings({157
152 homePath: sharedHome,158 const layout = yield* resolveCodexHomeLayout(
153 shadowHomePath: shadowHome,159 decodeCodexSettings({
154 }),160 homePath: sharedHome,
155 );161 shadowHomePath: shadowHome,
156162 }),
157 yield* materializeCodexShadowHome(layout);163 );
158164
159 const locksTarget = yield* fileSystem.readLink(shadowLocks);165 yield* materializeCodexShadowHome(layout);
160 const sharedLockExists = yield* fileSystem.exists(166
161 path.join(sharedLocks, "file-store.lock"),167 const locksTarget = yield* fileSystem.readLink(shadowLocks);
162 );168 const sharedLockExists = yield* fileSystem.exists(
163169 path.join(sharedLocks, "file-store.lock"),
164 expect(locksTarget).toBe(sharedLocks);170 );
165 expect(sharedLockExists).toBe(true);171
166 }),172 expect(locksTarget).toBe(sharedLocks);
173 expect(sharedLockExists).toBe(true);
174 }),
167 );175 );
168176
169 it.effect("accepts Codex-created shadow-local runtime directories", () =>177 it.effect.skipIf(!symlinksSupported)(
170 Effect.gen(function* () {178 "accepts Codex-created shadow-local runtime directories",
171 const fileSystem = yield* FileSystem.FileSystem;179 () =>
172 const path = yield* Path.Path;180 Effect.gen(function* () {
173 const sharedHome = yield* makeTempDir("t3code-codex-shared-");181 const fileSystem = yield* FileSystem.FileSystem;
174 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");182 const path = yield* Path.Path;
175 const shadowHome = path.join(shadowRoot, "shadow");183 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
176184 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
177 yield* fileSystem.makeDirectory(path.join(sharedHome, "log"));185 const shadowHome = path.join(shadowRoot, "shadow");
178 yield* fileSystem.makeDirectory(path.join(sharedHome, "memories"));186
179 yield* fileSystem.makeDirectory(path.join(sharedHome, "tmp"));187 yield* fileSystem.makeDirectory(path.join(sharedHome, "log"));
180 yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');188 yield* fileSystem.makeDirectory(path.join(sharedHome, "memories"));
181 yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n');189 yield* fileSystem.makeDirectory(path.join(sharedHome, "tmp"));
182 yield* fileSystem.makeDirectory(path.join(shadowHome, "log"), { recursive: true });190 yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');
183 yield* fileSystem.makeDirectory(path.join(shadowHome, "memories"), { recursive: true });191 yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n');
184 yield* fileSystem.makeDirectory(path.join(shadowHome, "tmp"), { recursive: true });192 yield* fileSystem.makeDirectory(path.join(shadowHome, "log"), { recursive: true });
185193 yield* fileSystem.makeDirectory(path.join(shadowHome, "memories"), { recursive: true });
186 const layout = yield* resolveCodexHomeLayout(194 yield* fileSystem.makeDirectory(path.join(shadowHome, "tmp"), { recursive: true });
187 decodeCodexSettings({195
188 homePath: sharedHome,196 const layout = yield* resolveCodexHomeLayout(
189 shadowHomePath: shadowHome,197 decodeCodexSettings({
190 }),198 homePath: sharedHome,
191 );199 shadowHomePath: shadowHome,
192200 }),
193 yield* materializeCodexShadowHome(layout);201 );
194202
195 const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml"));203 yield* materializeCodexShadowHome(layout);
196 const logLinkResult = yield* fileSystem204
197 .readLink(path.join(shadowHome, "log"))205 const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml"));
198 .pipe(Effect.result);206 const logLinkResult = yield* fileSystem
199 const memoriesLinkResult = yield* fileSystem207 .readLink(path.join(shadowHome, "log"))
200 .readLink(path.join(shadowHome, "memories"))208 .pipe(Effect.result);
201 .pipe(Effect.result);209 const memoriesLinkResult = yield* fileSystem
202 const tmpLinkResult = yield* fileSystem210 .readLink(path.join(shadowHome, "memories"))
203 .readLink(path.join(shadowHome, "tmp"))211 .pipe(Effect.result);
204 .pipe(Effect.result);212 const tmpLinkResult = yield* fileSystem
205213 .readLink(path.join(shadowHome, "tmp"))
206 expect(configTarget).toBe(path.join(sharedHome, "config.toml"));214 .pipe(Effect.result);
207 expect(logLinkResult._tag).toBe("Failure");215
208 expect(memoriesLinkResult._tag).toBe("Failure");216 expect(configTarget).toBe(path.join(sharedHome, "config.toml"));
209 expect(tmpLinkResult._tag).toBe("Failure");217 expect(logLinkResult._tag).toBe("Failure");
210 }),218 expect(memoriesLinkResult._tag).toBe("Failure");
219 expect(tmpLinkResult._tag).toBe("Failure");
220 }),
211 );221 );
212222
213 it.effect("rejects shadow homes that point at the shared home", () =>223 it.effect("rejects shadow homes that point at the shared home", () =>
...@@ -233,36 +243,38 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {...@@ -233,36 +243,38 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
233 }),243 }),
234 );244 );
235245
236 it.effect("rejects shared entries that already exist in the shadow home as real files", () =>246 it.effect.skipIf(!symlinksSupported)(
237 Effect.gen(function* () {247 "rejects shared entries that already exist in the shadow home as real files",
238 const path = yield* Path.Path;248 () =>
239 const sharedHome = yield* makeTempDir("t3code-codex-shared-");249 Effect.gen(function* () {
240 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");250 const path = yield* Path.Path;
241 const shadowHome = path.join(shadowRoot, "shadow");251 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
242 yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');252 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
243 yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n');253 const shadowHome = path.join(shadowRoot, "shadow");
244254 yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');
245 const layout = yield* resolveCodexHomeLayout(255 yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n');
246 decodeCodexSettings({256
247 homePath: sharedHome,257 const layout = yield* resolveCodexHomeLayout(
248 shadowHomePath: shadowHome,258 decodeCodexSettings({
249 }),259 homePath: sharedHome,
250 );260 shadowHomePath: shadowHome,
251261 }),
252 const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip);262 );
253263
254 expect(error).toBeInstanceOf(CodexShadowHomeEntryConflictError);264 const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip);
255 expect(error).toMatchObject({265
256 sharedHomePath: sharedHome,266 expect(error).toBeInstanceOf(CodexShadowHomeEntryConflictError);
257 effectiveHomePath: shadowHome,267 expect(error).toMatchObject({
258 entryName: "config.toml",268 sharedHomePath: sharedHome,
259 linkPath: path.join(shadowHome, "config.toml"),269 effectiveHomePath: shadowHome,
260 targetPath: path.join(sharedHome, "config.toml"),270 entryName: "config.toml",
261 });271 linkPath: path.join(shadowHome, "config.toml"),
262 expect(error.message).toBe(272 targetPath: path.join(sharedHome, "config.toml"),
263 `Cannot create Codex shadow home entry 'config.toml' because '${path.join(shadowHome, "config.toml")}' already exists and is not a symlink.`,273 });
264 );274 expect(error.message).toBe(
265 }),275 `Cannot create Codex shadow home entry 'config.toml' because '${path.join(shadowHome, "config.toml")}' already exists and is not a symlink.`,
276 );
277 }),
266 );278 );
267279
268 it.effect("preserves filesystem operation, paths, and cause", () =>280 it.effect("preserves filesystem operation, paths, and cause", () =>
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+27-24
...@@ -49,6 +49,7 @@ import {...@@ -49,6 +49,7 @@ import {
49 makeOpenCodeAdapter,49 makeOpenCodeAdapter,
50 mergeOpenCodeAssistantText,50 mergeOpenCodeAssistantText,
51} from "./OpenCodeAdapter.ts";51} from "./OpenCodeAdapter.ts";
52import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
5253
53// Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`.54// Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`.
54class OpenCodeAdapter extends Context.Service<OpenCodeAdapter, OpenCodeAdapterShape>()(55class OpenCodeAdapter extends Context.Service<OpenCodeAdapter, OpenCodeAdapterShape>()(
...@@ -6401,30 +6402,32 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -6401,30 +6402,32 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
6401 }),6402 }),
6402 );6403 );
64036404
6404 it.effect("treats lexically or physically identical directories as the same", () =>6405 it.effect.skipIf(!symlinksSupported)(
6405 Effect.gen(function* () {6406 "treats lexically or physically identical directories as the same",
6406 const fileSystem = yield* FileSystem.FileSystem;6407 () =>
6407 const path = yield* Path.Path;6408 Effect.gen(function* () {
6408 const sameDirectory = (left: string, right: string) =>6409 const fileSystem = yield* FileSystem.FileSystem;
6409 isSameOpenCodeDirectory(fileSystem, path, left, right);6410 const path = yield* Path.Path;
64106411 const sameDirectory = (left: string, right: string) =>
6411 // Lexical-only differences (trailing slash, dot segments) short-circuit6412 isSameOpenCodeDirectory(fileSystem, path, left, right);
6412 // without touching the filesystem — the paths need not exist.6413
6413 NodeAssert.equal(yield* sameDirectory("/repo/project/", "/repo/project"), true);6414 // Lexical-only differences (trailing slash, dot segments) short-circuit
6414 NodeAssert.equal(yield* sameDirectory("/repo/nested/../project", "/repo/project"), true);6415 // without touching the filesystem — the paths need not exist.
6415 // Nonexistent paths degrade to the lexical comparison instead of failing.6416 NodeAssert.equal(yield* sameDirectory("/repo/project/", "/repo/project"), true);
6416 NodeAssert.equal(yield* sameDirectory("/repo/project", "/repo/other"), false);6417 NodeAssert.equal(yield* sameDirectory("/repo/nested/../project", "/repo/project"), true);
64176418 // Nonexistent paths degrade to the lexical comparison instead of failing.
6418 // A symlinked cwd (the macOS `/tmp` → `/private/tmp` shape) resolves to6419 NodeAssert.equal(yield* sameDirectory("/repo/project", "/repo/other"), false);
6419 // the directory it points at, so the two spellings compare equal.6420
6420 const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode-dir-" });6421 // A symlinked cwd (the macOS `/tmp` → `/private/tmp` shape) resolves to
6421 const real = path.join(base, "real");6422 // the directory it points at, so the two spellings compare equal.
6422 const link = path.join(base, "link");6423 const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode-dir-" });
6423 yield* fileSystem.makeDirectory(real);6424 const real = path.join(base, "real");
6424 yield* fileSystem.symlink(real, link);6425 const link = path.join(base, "link");
6425 NodeAssert.equal(yield* sameDirectory(link, real), true);6426 yield* fileSystem.makeDirectory(real);
6426 NodeAssert.equal(yield* sameDirectory(link, path.join(base, "other")), false);6427 yield* fileSystem.symlink(real, link);
6427 }).pipe(Effect.scoped),6428 NodeAssert.equal(yield* sameDirectory(link, real), true);
6429 NodeAssert.equal(yield* sameDirectory(link, path.join(base, "other")), false);
6430 }).pipe(Effect.scoped),
6428 );6431 );
64296432
6430 it.effect("appends raw assistant text deltas and reconciles part update snapshots", () =>6433 it.effect("appends raw assistant text deltas and reconciles part update snapshots", () =>
apps/server/src/provider/providerMaintenance.test.ts+98-87
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 resolveLatestProviderVersion,20 resolveLatestProviderVersion,
21 resolveProviderMaintenanceCapabilitiesEffect,21 resolveProviderMaintenanceCapabilitiesEffect,
22} from "./providerMaintenance.ts";22} from "./providerMaintenance.ts";
23import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
2324
24const driver = (value: string) => ProviderDriverKind.make(value);25const driver = (value: string) => ProviderDriverKind.make(value);
25// These write `#!/bin/sh` stubs and resolve them through a darwin-mocked26// These write `#!/bin/sh` stubs and resolve them through a darwin-mocked
...@@ -456,100 +457,110 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {...@@ -456,100 +457,110 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
456 });457 });
457 });458 });
458459
459 it.effect("keeps npm updates for binaries symlinked into npm's global node_modules tree", () =>460 it.effect.skipIf(!symlinksSupported)(
460 Effect.gen(function* () {461 "keeps npm updates for binaries symlinked into npm's global node_modules tree",
461 const tempDir = yield* makeTempDir("t3-npm-capabilities");462 () =>
462 const binDir = NodePath.join(tempDir, "bin");463 Effect.gen(function* () {
463 const packageBinDir = NodePath.join(464 const tempDir = yield* makeTempDir("t3-npm-capabilities");
464 tempDir,465 const binDir = NodePath.join(tempDir, "bin");
465 "lib",466 const packageBinDir = NodePath.join(
466 "node_modules",467 tempDir,
467 "@example",468 "lib",
468 "package-tool",469 "node_modules",
469 "bin",470 "@example",
470 );471 "package-tool",
471 NodeFS.mkdirSync(binDir, { recursive: true });472 "bin",
472 NodeFS.mkdirSync(packageBinDir, { recursive: true });473 );
473 const packageBinPath = NodePath.join(packageBinDir, "package-tool.js");474 NodeFS.mkdirSync(binDir, { recursive: true });
474 const symlinkPath = NodePath.join(binDir, "package-tool");475 NodeFS.mkdirSync(packageBinDir, { recursive: true });
475 NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n");476 const packageBinPath = NodePath.join(packageBinDir, "package-tool.js");
476 NodeFS.chmodSync(packageBinPath, 0o755);477 const symlinkPath = NodePath.join(binDir, "package-tool");
477 NodeFS.symlinkSync(packageBinPath, symlinkPath);478 NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n");
478479 NodeFS.chmodSync(packageBinPath, 0o755);
479 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {480 NodeFS.symlinkSync(packageBinPath, symlinkPath);
480 binaryPath: symlinkPath,
481 env: {
482 PATH: "",
483 },
484 });
485
486 expect(capabilities).toEqual({
487 provider: driver("packageTool"),
488 packageName: "@example/package-tool",
489 update: {
490 command:
491 "npm install -g --allow-scripts=@example/package-tool @example/package-tool@latest",
492
493 executable: "npm",
494
495 args: [
496 "install",
497 "-g",
498 "--allow-scripts=@example/package-tool",
499 "@example/package-tool@latest",
500 ],
501481
502 lockKey: "npm-global",482 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
503 },483 packageToolUpdate,
504 });484 {
505 }),485 binaryPath: symlinkPath,
486 env: {
487 PATH: "",
488 },
489 },
490 );
491
492 expect(capabilities).toEqual({
493 provider: driver("packageTool"),
494 packageName: "@example/package-tool",
495 update: {
496 command:
497 "npm install -g --allow-scripts=@example/package-tool @example/package-tool@latest",
498
499 executable: "npm",
500
501 args: [
502 "install",
503 "-g",
504 "--allow-scripts=@example/package-tool",
505 "@example/package-tool@latest",
506 ],
507
508 lockKey: "npm-global",
509 },
510 });
511 }),
506 );512 );
507513
508 it.effect("uses Effect FileSystem realPath when detecting pnpm global symlinks", () =>514 it.effect.skipIf(!symlinksSupported)(
509 Effect.gen(function* () {515 "uses Effect FileSystem realPath when detecting pnpm global symlinks",
510 const tempDir = yield* makeTempDir("t3-pnpm-realpath-capabilities");516 () =>
511 const binDir = NodePath.join(tempDir, "bin");517 Effect.gen(function* () {
512 const packageBinDir = NodePath.join(518 const tempDir = yield* makeTempDir("t3-pnpm-realpath-capabilities");
513 tempDir,519 const binDir = NodePath.join(tempDir, "bin");
514 ".local",520 const packageBinDir = NodePath.join(
515 "share",521 tempDir,
516 "pnpm",522 ".local",
517 "global",523 "share",
518 "5",524 "pnpm",
519 "node_modules",525 "global",
520 "@example",526 "5",
521 "package-tool",527 "node_modules",
522 "bin",528 "@example",
523 );529 "package-tool",
524 NodeFS.mkdirSync(binDir, { recursive: true });530 "bin",
525 NodeFS.mkdirSync(packageBinDir, { recursive: true });531 );
526 const packageBinPath = NodePath.join(packageBinDir, "package-tool.js");532 NodeFS.mkdirSync(binDir, { recursive: true });
527 const symlinkPath = NodePath.join(binDir, "package-tool");533 NodeFS.mkdirSync(packageBinDir, { recursive: true });
528 NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n");534 const packageBinPath = NodePath.join(packageBinDir, "package-tool.js");
529 NodeFS.chmodSync(packageBinPath, 0o755);535 const symlinkPath = NodePath.join(binDir, "package-tool");
530 NodeFS.symlinkSync(packageBinPath, symlinkPath);536 NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n");
531537 NodeFS.chmodSync(packageBinPath, 0o755);
532 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {538 NodeFS.symlinkSync(packageBinPath, symlinkPath);
533 binaryPath: symlinkPath,
534 env: {
535 PATH: "",
536 },
537 });
538539
539 expect(capabilities).toEqual({540 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
540 provider: driver("packageTool"),541 packageToolUpdate,
541 packageName: "@example/package-tool",542 {
542 update: {543 binaryPath: symlinkPath,
543 command: "pnpm add -g @example/package-tool@latest",544 env: {
545 PATH: "",
546 },
547 },
548 );
544549
545 executable: "pnpm",550 expect(capabilities).toEqual({
551 provider: driver("packageTool"),
552 packageName: "@example/package-tool",
553 update: {
554 command: "pnpm add -g @example/package-tool@latest",
546555
547 args: ["add", "-g", "@example/package-tool@latest"],556 executable: "pnpm",
548557
549 lockKey: "pnpm-global",558 args: ["add", "-g", "@example/package-tool@latest"],
550 },559
551 });560 lockKey: "pnpm-global",
552 }),561 },
562 });
563 }),
553 );564 );
554565
555 it("allows the package's own install scripts in npm global updates", () => {566 it("allows the package's own install scripts in npm global updates", () => {
apps/server/src/server.test.ts+2-1
...@@ -199,6 +199,7 @@ import {...@@ -199,6 +199,7 @@ import {
199 type TransferBudgetRun,199 type TransferBudgetRun,
200 transferBudgetViolations,200 transferBudgetViolations,
201} from "../integration/TransferBudgetReport.integration.ts";201} from "../integration/TransferBudgetReport.integration.ts";
202import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
202203
203const defaultProjectId = ProjectId.make("project-default");204const defaultProjectId = ProjectId.make("project-default");
204const defaultThreadId = ThreadId.make("thread-default");205const defaultThreadId = ThreadId.make("thread-default");
...@@ -6311,7 +6312,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -6311,7 +6312,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
6311 }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),6312 }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
6312 );6313 );
63136314
6314 it.effect("preserves structured workspace rpc failures", () =>6315 it.effect.skipIf(!symlinksSupported)("preserves structured workspace rpc failures", () =>
6315 Effect.gen(function* () {6316 Effect.gen(function* () {
6316 const fs = yield* FileSystem.FileSystem;6317 const fs = yield* FileSystem.FileSystem;
6317 const path = yield* Path.Path;6318 const path = yield* Path.Path;
apps/server/src/sourceControl/PrTemplateDetection.test.ts+80-73
...@@ -10,6 +10,7 @@ import { ServerConfig } from "../config.ts";...@@ -10,6 +10,7 @@ import { ServerConfig } from "../config.ts";
10import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";10import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
11import * as VcsProcess from "../vcs/VcsProcess.ts";11import * as VcsProcess from "../vcs/VcsProcess.ts";
12import { detectPrTemplate } from "./PrTemplateDetection.ts";12import { detectPrTemplate } from "./PrTemplateDetection.ts";
13import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1314
14const SINGLE_TEMPLATE_PATHS = [15const SINGLE_TEMPLATE_PATHS = [
15 ".github/pull_request_template.md",16 ".github/pull_request_template.md",
...@@ -139,27 +140,29 @@ it.effect.each(TEMPLATE_DIRECTORIES)("recognizes the $0 directory", (relativeDir...@@ -139,27 +140,29 @@ it.effect.each(TEMPLATE_DIRECTORIES)("recognizes the $0 directory", (relativeDir
139 ),140 ),
140);141);
141142
142it.effect("skips unusable directory entries and uses the one valid template", () =>143it.effect.skipIf(!symlinksSupported)(
143 runWithTempDirectory((cwd) =>144 "skips unusable directory entries and uses the one valid template",
144 Effect.gen(function* () {145 () =>
145 const fileSystem = yield* FileSystem.FileSystem;146 runWithTempDirectory((cwd) =>
146 const path = yield* Path.Path;147 Effect.gen(function* () {
147 const templateDirectory = path.join(cwd, ".github", "PULL_REQUEST_TEMPLATE");148 const fileSystem = yield* FileSystem.FileSystem;
148 yield* fileSystem.makeDirectory(path.join(templateDirectory, "b-directory.md"), {149 const path = yield* Path.Path;
149 recursive: true,150 const templateDirectory = path.join(cwd, ".github", "PULL_REQUEST_TEMPLATE");
150 });151 yield* fileSystem.makeDirectory(path.join(templateDirectory, "b-directory.md"), {
151 yield* fileSystem.writeFileString(path.join(templateDirectory, "a-empty.md"), " \n");152 recursive: true,
152 yield* fileSystem.symlink(153 });
153 path.join(templateDirectory, "missing.md"),154 yield* fileSystem.writeFileString(path.join(templateDirectory, "a-empty.md"), " \n");
154 path.join(templateDirectory, "c-broken.md"),155 yield* fileSystem.symlink(
155 );156 path.join(templateDirectory, "missing.md"),
156 yield* fileSystem.writeFileString(path.join(templateDirectory, "z-valid.md"), "valid");157 path.join(templateDirectory, "c-broken.md"),
157 yield* commitTemplates(cwd);158 );
158159 yield* fileSystem.writeFileString(path.join(templateDirectory, "z-valid.md"), "valid");
159 const template = yield* detectTemplate(cwd);160 yield* commitTemplates(cwd);
160 assert.strictEqual(Option.getOrUndefined(template), "valid");161
161 }),162 const template = yield* detectTemplate(cwd);
162 ),163 assert.strictEqual(Option.getOrUndefined(template), "valid");
164 }),
165 ),
163);166);
164167
165it.effect("does not guess between multiple directory templates", () =>168it.effect("does not guess between multiple directory templates", () =>
...@@ -176,60 +179,64 @@ it.effect("does not guess between multiple directory templates", () =>...@@ -176,60 +179,64 @@ it.effect("does not guess between multiple directory templates", () =>
176 ),179 ),
177);180);
178181
179it.effect("rejects a committed template symlink escaping the repository", () =>182it.effect.skipIf(!symlinksSupported)(
180 runWithTempDirectory((cwd) =>183 "rejects a committed template symlink escaping the repository",
181 Effect.gen(function* () {184 () =>
182 const fileSystem = yield* FileSystem.FileSystem;185 runWithTempDirectory((cwd) =>
183 const path = yield* Path.Path;186 Effect.gen(function* () {
184 const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({187 const fileSystem = yield* FileSystem.FileSystem;
185 prefix: "t3-pr-template-outside-",188 const path = yield* Path.Path;
186 });189 const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({
187 const outsideTemplate = path.join(outsideDirectory, "secret.md");190 prefix: "t3-pr-template-outside-",
188 yield* fileSystem.writeFileString(outsideTemplate, "LOCAL_SECRET_SENTINEL");191 });
189 const escapedTemplatePath = path.join(cwd, ".github", "pull_request_template.md");192 const outsideTemplate = path.join(outsideDirectory, "secret.md");
190 yield* fileSystem.makeDirectory(path.dirname(escapedTemplatePath), { recursive: true });193 yield* fileSystem.writeFileString(outsideTemplate, "LOCAL_SECRET_SENTINEL");
191 yield* fileSystem.symlink(outsideTemplate, escapedTemplatePath);194 const escapedTemplatePath = path.join(cwd, ".github", "pull_request_template.md");
192 yield* writeTemplate(cwd, "pull_request_template.md", "safe template");195 yield* fileSystem.makeDirectory(path.dirname(escapedTemplatePath), { recursive: true });
193 yield* commitTemplates(cwd);196 yield* fileSystem.symlink(outsideTemplate, escapedTemplatePath);
194197 yield* writeTemplate(cwd, "pull_request_template.md", "safe template");
195 const template = yield* detectTemplate(cwd);198 yield* commitTemplates(cwd);
196 assert.strictEqual(Option.getOrUndefined(template), "safe template");199
197 assert.notInclude(200 const template = yield* detectTemplate(cwd);
198 Option.getOrElse(template, () => ""),201 assert.strictEqual(Option.getOrUndefined(template), "safe template");
199 "LOCAL_SECRET_SENTINEL",202 assert.notInclude(
200 );203 Option.getOrElse(template, () => ""),
201 }),204 "LOCAL_SECRET_SENTINEL",
202 ),205 );
206 }),
207 ),
203);208);
204209
205it.effect("reads the committed template when a worktree parent is replaced", () =>210it.effect.skipIf(!symlinksSupported)(
206 runWithTempDirectory((cwd) =>211 "reads the committed template when a worktree parent is replaced",
207 Effect.gen(function* () {212 () =>
208 const fileSystem = yield* FileSystem.FileSystem;213 runWithTempDirectory((cwd) =>
209 const path = yield* Path.Path;214 Effect.gen(function* () {
210 const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({215 const fileSystem = yield* FileSystem.FileSystem;
211 prefix: "t3-pr-template-outside-",216 const path = yield* Path.Path;
212 });217 const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({
213 const templatePath = yield* writeTemplate(218 prefix: "t3-pr-template-outside-",
214 cwd,219 });
215 ".github/pull_request_template.md",220 const templatePath = yield* writeTemplate(
216 "committed template",221 cwd,
217 );222 ".github/pull_request_template.md",
218 yield* commitTemplates(cwd);223 "committed template",
219 yield* writeTemplate(outsideDirectory, "pull_request_template.md", "LOCAL_SECRET_SENTINEL");224 );
220225 yield* commitTemplates(cwd);
221 const templateDirectory = path.dirname(templatePath);226 yield* writeTemplate(outsideDirectory, "pull_request_template.md", "LOCAL_SECRET_SENTINEL");
222 yield* fileSystem.rename(templateDirectory, path.join(cwd, ".github-original"));227
223 yield* fileSystem.symlink(outsideDirectory, templateDirectory);228 const templateDirectory = path.dirname(templatePath);
224229 yield* fileSystem.rename(templateDirectory, path.join(cwd, ".github-original"));
225 const template = yield* detectTemplate(cwd);230 yield* fileSystem.symlink(outsideDirectory, templateDirectory);
226 assert.strictEqual(Option.getOrUndefined(template), "committed template");231
227 assert.notInclude(232 const template = yield* detectTemplate(cwd);
228 Option.getOrElse(template, () => ""),233 assert.strictEqual(Option.getOrUndefined(template), "committed template");
229 "LOCAL_SECRET_SENTINEL",234 assert.notInclude(
230 );235 Option.getOrElse(template, () => ""),
231 }),236 "LOCAL_SECRET_SENTINEL",
232 ),237 );
238 }),
239 ),
233);240);
234241
235it.effect("bounds template reads and marks truncated content", () =>242it.effect("bounds template reads and marks truncated content", () =>
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+63-59
...@@ -27,6 +27,7 @@ import { GitManagerError } from "@t3tools/contracts";...@@ -27,6 +27,7 @@ import { GitManagerError } from "@t3tools/contracts";
27import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts";27import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts";
28import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";28import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
29import * as GitWorkflowService from "../git/GitWorkflowService.ts";29import * as GitWorkflowService from "../git/GitWorkflowService.ts";
30import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
3031
31const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");32const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
3233
...@@ -144,7 +145,7 @@ function makeBackgroundPolicyLayer(shouldRunScopeWork: (scope: BackgroundScope)...@@ -144,7 +145,7 @@ function makeBackgroundPolicyLayer(shouldRunScopeWork: (scope: BackgroundScope)
144}145}
145146
146describe("VcsStatusBroadcaster", () => {147describe("VcsStatusBroadcaster", () => {
147 it.effect(148 it.effect.skipIf(!symlinksSupported)(
148 "automatically pulls an enabled clean default branch when status detects it is behind",149 "automatically pulls an enabled clean default branch when status detects it is behind",
149 () => {150 () => {
150 let remoteStatus: VcsStatusRemoteResult = { ...baseRemoteStatus, behindCount: 2 };151 let remoteStatus: VcsStatusRemoteResult = { ...baseRemoteStatus, behindCount: 2 };
...@@ -522,67 +523,70 @@ describe("VcsStatusBroadcaster", () => {...@@ -522,67 +523,70 @@ describe("VcsStatusBroadcaster", () => {
522 }).pipe(Effect.provide(makeTestLayer(state)));523 }).pipe(Effect.provide(makeTestLayer(state)));
523 });524 });
524525
525 it.effect("normalizes symlinked CWDs before cache lookup and workflow calls", () => {526 it.effect.skipIf(!symlinksSupported)(
526 const seenCwds: string[] = [];527 "normalizes symlinked CWDs before cache lookup and workflow calls",
527 const state = {528 () => {
528 currentLocalStatus: baseLocalStatus,529 const seenCwds: string[] = [];
529 currentRemoteStatus: baseRemoteStatus,530 const state = {
530 localStatusCalls: 0,531 currentLocalStatus: baseLocalStatus,
531 remoteStatusCalls: 0,532 currentRemoteStatus: baseRemoteStatus,
532 localInvalidationCalls: 0,533 localStatusCalls: 0,
533 remoteInvalidationCalls: 0,534 remoteStatusCalls: 0,
534 };535 localInvalidationCalls: 0,
535 const testLayer = VcsStatusBroadcaster.layer.pipe(536 remoteInvalidationCalls: 0,
536 Layer.provideMerge(NodeServices.layer),537 };
537 Layer.provide(makeBackgroundPolicyLayer(() => true)),538 const testLayer = VcsStatusBroadcaster.layer.pipe(
538 Layer.provide(539 Layer.provideMerge(NodeServices.layer),
539 Layer.mock(GitWorkflowService.GitWorkflowService)({540 Layer.provide(makeBackgroundPolicyLayer(() => true)),
540 localStatus: (input) =>541 Layer.provide(
541 Effect.sync(() => {542 Layer.mock(GitWorkflowService.GitWorkflowService)({
542 seenCwds.push(input.cwd);543 localStatus: (input) =>
543 state.localStatusCalls += 1;544 Effect.sync(() => {
544 return state.currentLocalStatus;545 seenCwds.push(input.cwd);
545 }),546 state.localStatusCalls += 1;
546 remoteStatus: (input) =>547 return state.currentLocalStatus;
547 Effect.sync(() => {548 }),
548 seenCwds.push(input.cwd);549 remoteStatus: (input) =>
549 state.remoteStatusCalls += 1;550 Effect.sync(() => {
550 return state.currentRemoteStatus;551 seenCwds.push(input.cwd);
551 }),552 state.remoteStatusCalls += 1;
552 invalidateLocalStatus: () =>553 return state.currentRemoteStatus;
553 Effect.sync(() => {554 }),
554 state.localInvalidationCalls += 1;555 invalidateLocalStatus: () =>
555 }),556 Effect.sync(() => {
556 invalidateRemoteStatus: () =>557 state.localInvalidationCalls += 1;
557 Effect.sync(() => {558 }),
558 state.remoteInvalidationCalls += 1;559 invalidateRemoteStatus: () =>
559 }),560 Effect.sync(() => {
560 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),561 state.remoteInvalidationCalls += 1;
561 ),562 }),
562 );563 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
564 ),
565 );
563566
564 return Effect.gen(function* () {567 return Effect.gen(function* () {
565 const fileSystem = yield* FileSystem.FileSystem;568 const fileSystem = yield* FileSystem.FileSystem;
566 const path = yield* Path.Path;569 const path = yield* Path.Path;
567 const realDir = yield* fileSystem.makeTempDirectoryScoped({570 const realDir = yield* fileSystem.makeTempDirectoryScoped({
568 prefix: "t3-vcs-status-real-",571 prefix: "t3-vcs-status-real-",
569 });572 });
570 const linkParent = yield* fileSystem.makeTempDirectoryScoped({573 const linkParent = yield* fileSystem.makeTempDirectoryScoped({
571 prefix: "t3-vcs-status-link-",574 prefix: "t3-vcs-status-link-",
572 });575 });
573 const linkDir = path.join(linkParent, "repo-link");576 const linkDir = path.join(linkParent, "repo-link");
574 yield* fileSystem.symlink(realDir, linkDir);577 yield* fileSystem.symlink(realDir, linkDir);
575 const realPath = yield* fileSystem.realPath(realDir);578 const realPath = yield* fileSystem.realPath(realDir);
576579
577 const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;580 const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
578 yield* broadcaster.getStatus({ cwd: linkDir });581 yield* broadcaster.getStatus({ cwd: linkDir });
579 yield* broadcaster.getStatus({ cwd: realDir });582 yield* broadcaster.getStatus({ cwd: realDir });
580583
581 assert.deepStrictEqual(seenCwds, [realPath, realPath]);584 assert.deepStrictEqual(seenCwds, [realPath, realPath]);
582 assert.equal(state.localStatusCalls, 1);585 assert.equal(state.localStatusCalls, 1);
583 assert.equal(state.remoteStatusCalls, 1);586 assert.equal(state.remoteStatusCalls, 1);
584 }).pipe(Effect.provide(testLayer));587 }).pipe(Effect.provide(testLayer));
585 });588 },
589 );
586590
587 it.effect("streams a local snapshot first and remote updates later", () => {591 it.effect("streams a local snapshot first and remote updates later", () => {
588 const state = {592 const state = {
apps/server/src/workspace/WorkspaceFileSystem.test.ts+31-28
...@@ -14,6 +14,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";...@@ -14,6 +14,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";
14import * as WorkspaceEntries from "./WorkspaceEntries.ts";14import * as WorkspaceEntries from "./WorkspaceEntries.ts";
15import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts";15import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts";
16import * as WorkspacePaths from "./WorkspacePaths.ts";16import * as WorkspacePaths from "./WorkspacePaths.ts";
17import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1718
18const ProjectLayer = WorkspaceFileSystem.layer.pipe(19const ProjectLayer = WorkspaceFileSystem.layer.pipe(
19 Layer.provide(WorkspacePaths.layer),20 Layer.provide(WorkspacePaths.layer),
...@@ -138,34 +139,36 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i...@@ -138,34 +139,36 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
138 }),139 }),
139 );140 );
140141
141 it.effect("rejects symlinks that resolve outside the workspace root", () =>142 it.effect.skipIf(!symlinksSupported)(
142 Effect.gen(function* () {143 "rejects symlinks that resolve outside the workspace root",
143 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;144 () =>
144 const fileSystem = yield* FileSystem.FileSystem;145 Effect.gen(function* () {
145 const path = yield* Path.Path;146 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
146 const cwd = yield* makeTempDir;147 const fileSystem = yield* FileSystem.FileSystem;
147 const outsideDir = yield* makeTempDir;148 const path = yield* Path.Path;
148 yield* writeTextFile(outsideDir, "secret.txt", "outside\n");149 const cwd = yield* makeTempDir;
149 yield* fileSystem.symlink(150 const outsideDir = yield* makeTempDir;
150 path.join(outsideDir, "secret.txt"),151 yield* writeTextFile(outsideDir, "secret.txt", "outside\n");
151 path.join(cwd, "linked-secret.txt"),152 yield* fileSystem.symlink(
152 );153 path.join(outsideDir, "secret.txt"),
153154 path.join(cwd, "linked-secret.txt"),
154 const error = yield* workspaceFileSystem155 );
155 .readFile({ cwd, relativePath: "linked-secret.txt" })156
156 .pipe(Effect.flip);157 const error = yield* workspaceFileSystem
157 const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd);158 .readFile({ cwd, relativePath: "linked-secret.txt" })
158 const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt"));159 .pipe(Effect.flip);
159160 const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd);
160 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError);161 const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt"));
161 expect(error).toMatchObject({162
162 workspaceRoot: cwd,163 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError);
163 relativePath: "linked-secret.txt",164 expect(error).toMatchObject({
164 resolvedWorkspaceRoot,165 workspaceRoot: cwd,
165 resolvedPath,166 relativePath: "linked-secret.txt",
166 });167 resolvedWorkspaceRoot,
167 expect("cause" in error).toBe(false);168 resolvedPath,
168 }),169 });
170 expect("cause" in error).toBe(false);
171 }),
169 );172 );
170173
171 it.effect("rejects directories without manufacturing an I/O cause", () =>174 it.effect("rejects directories without manufacturing an I/O cause", () =>
packages/shared/package.json+4
...@@ -223,6 +223,10 @@...@@ -223,6 +223,10 @@
223 "types": "./src/hostProcess.ts",223 "types": "./src/hostProcess.ts",
224 "import": "./src/hostProcess.ts"224 "import": "./src/hostProcess.ts"
225 },225 },
226 "./testing/symlinks": {
227 "types": "./src/testing/symlinks.ts",
228 "import": "./src/testing/symlinks.ts"
229 },
226 "./httpReadiness": {230 "./httpReadiness": {
227 "types": "./src/httpReadiness.ts",231 "types": "./src/httpReadiness.ts",
228 "import": "./src/httpReadiness.ts"232 "import": "./src/httpReadiness.ts"
packages/shared/src/testing/symlinks.ts created+29
...@@ -0,0 +1,29 @@
1// @effect-diagnostics nodeBuiltinImport:off - one synchronous probe at module load, outside any Effect runtime.
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
6/**
7 * Whether this host lets an unprivileged process create symlinks. Windows
8 * refuses without Developer Mode or elevation, so tests that plant a symlink
9 * to prove containment or escape detection should `skipIf(!symlinksSupported)`
10 * rather than fail at setup. Probed once per process.
11 */
12export const symlinksSupported: boolean = (() => {
13 let directory: string | undefined;
14 try {
15 directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-symlink-probe-"));
16 NodeFS.symlinkSync(NodePath.join(directory, "target"), NodePath.join(directory, "link"));
17 return true;
18 } catch {
19 return false;
20 } finally {
21 // Cleanup must not decide the answer: a transient EPERM/EBUSY on the
22 // probe directory would otherwise throw out of module initialisation.
23 try {
24 if (directory !== undefined) NodeFS.rmSync(directory, { recursive: true, force: true });
25 } catch {
26 // Leave the probe directory behind rather than fail every importer.
27 }
28 }
29})();
scripts/build-desktop-artifact.test.ts+2-1
...@@ -87,6 +87,7 @@ import {...@@ -87,6 +87,7 @@ import {
87} from "./build-desktop-artifact.ts";87} from "./build-desktop-artifact.ts";
88import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";88import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
89import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";89import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
90import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
9091
91// A minimal stand-in for the staged sidecar roots packed into the WSL archive.92// A minimal stand-in for the staged sidecar roots packed into the WSL archive.
92const stageWslRuntimeTreeFixture = Effect.fn("stageWslRuntimeTreeFixture")(function* (93const stageWslRuntimeTreeFixture = Effect.fn("stageWslRuntimeTreeFixture")(function* (
...@@ -2191,7 +2192,7 @@ it("keeps the prefix of a UNC path instead of going relative", () => {...@@ -2191,7 +2192,7 @@ it("keeps the prefix of a UNC path instead of going relative", () => {
2191 assert.deepStrictEqual(paths[0], "\\\\server\\share\\tmp\\node_modules");2192 assert.deepStrictEqual(paths[0], "\\\\server\\share\\tmp\\node_modules");
2192});2193});
21932194
2194it.effect("rebases packaged links into the isolated tree", () =>2195it.effect.skipIf(!symlinksSupported)("rebases packaged links into the isolated tree", () =>
2195 Effect.gen(function* () {2196 Effect.gen(function* () {
2196 const fs = yield* FileSystem.FileSystem;2197 const fs = yield* FileSystem.FileSystem;
2197 const path = yield* Path.Path;2198 const path = yield* Path.Path;
scripts/mock-update-server.test.ts+30-27
...@@ -7,6 +7,7 @@ import * as Path from "effect/Path";...@@ -7,6 +7,7 @@ import * as Path from "effect/Path";
7import { HttpClient, HttpRouter } from "effect/unstable/http";7import { HttpClient, HttpRouter } from "effect/unstable/http";
88
9import { makeMockUpdateRouteLayer } from "./mock-update-server.ts";9import { makeMockUpdateRouteLayer } from "./mock-update-server.ts";
10import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1011
11const withMockUpdateServer = <A, E, R>(rootRealPath: string, effect: Effect.Effect<A, E, R>) =>12const withMockUpdateServer = <A, E, R>(rootRealPath: string, effect: Effect.Effect<A, E, R>) =>
12 effect.pipe(13 effect.pipe(
...@@ -72,35 +73,37 @@ it.layer(NodeServices.layer)("mock-update-server", (it) => {...@@ -72,35 +73,37 @@ it.layer(NodeServices.layer)("mock-update-server", (it) => {
72 }),73 }),
73 );74 );
7475
75 it.effect("rejects symlinked files that escape the configured root", () =>76 it.effect.skipIf(!symlinksSupported)(
76 Effect.gen(function* () {77 "rejects symlinked files that escape the configured root",
77 const fileSystem = yield* FileSystem.FileSystem;78 () =>
78 const path = yield* Path.Path;79 Effect.gen(function* () {
79 const root = yield* fileSystem.makeTempDirectoryScoped({80 const fileSystem = yield* FileSystem.FileSystem;
80 prefix: "mock-update-server-root-",81 const path = yield* Path.Path;
81 });82 const root = yield* fileSystem.makeTempDirectoryScoped({
82 const outside = yield* fileSystem.makeTempDirectoryScoped({83 prefix: "mock-update-server-root-",
83 prefix: "mock-update-server-outside-",84 });
84 });85 const outside = yield* fileSystem.makeTempDirectoryScoped({
85 const rootRealPath = yield* fileSystem.realPath(root);86 prefix: "mock-update-server-outside-",
86 const outsideFile = path.join(outside, "outside.yml");87 });
87 const linksDir = path.join(root, "links");88 const rootRealPath = yield* fileSystem.realPath(root);
88 const symlinkPath = path.join(linksDir, "outside.yml");89 const outsideFile = path.join(outside, "outside.yml");
90 const linksDir = path.join(root, "links");
91 const symlinkPath = path.join(linksDir, "outside.yml");
8992
90 yield* fileSystem.writeFileString(outsideFile, "version: outside\n");93 yield* fileSystem.writeFileString(outsideFile, "version: outside\n");
91 yield* fileSystem.makeDirectory(linksDir, { recursive: true });94 yield* fileSystem.makeDirectory(linksDir, { recursive: true });
92 yield* fileSystem.symlink(outsideFile, symlinkPath);95 yield* fileSystem.symlink(outsideFile, symlinkPath);
9396
94 yield* withMockUpdateServer(97 yield* withMockUpdateServer(
95 rootRealPath,98 rootRealPath,
96 Effect.gen(function* () {99 Effect.gen(function* () {
97 const client = yield* HttpClient.HttpClient;100 const client = yield* HttpClient.HttpClient;
98 const response = yield* client.get("/links/outside.yml");101 const response = yield* client.get("/links/outside.yml");
99102
100 assert.equal(response.status, 404);103 assert.equal(response.status, 404);
101 assert.equal(yield* response.text, "Not Found");104 assert.equal(yield* response.text, "Not Found");
102 }),105 }),
103 );106 );
104 }),107 }),
105 );108 );
106});109});