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";
1414import * as BrowserSession from "../BrowserSession.ts";
1515import * as BrowserImport from "./BrowserImport.ts";
1616import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts";
17import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1718
1819const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
1920
......@@ -105,28 +106,30 @@ describe("BrowserImport.importCookies", () => {
105106 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
106107 );
107108
108 it.effect("refuses to import while the source browser holds its profile", () =>
109 Effect.gen(function* () {
110 const fileSystem = yield* FileSystem.FileSystem;
111 const { importer, root } = yield* withImporter();
112 // The lock Chromium leaves while it is running, dangling target and
113 // all. This must stop the import before it ever asks the keychain.
114 yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`);
115
116 const error = yield* importer
117 .importCookies({
118 input: {
119 sourceId: "helium",
120 sourceProfileDirectory: "Default",
121 targetProfileId: "default",
122 },
123 scope: "persist:t3code-preview-test",
124 persistent: true,
125 })
126 .pipe(Effect.flip);
109 it.effect.skipIf(!symlinksSupported)(
110 "refuses to import while the source browser holds its profile",
111 () =>
112 Effect.gen(function* () {
113 const fileSystem = yield* FileSystem.FileSystem;
114 const { importer, root } = yield* withImporter();
115 // The lock Chromium leaves while it is running, dangling target and
116 // all. This must stop the import before it ever asks the keychain.
117 yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`);
118
119 const error = yield* importer
120 .importCookies({
121 input: {
122 sourceId: "helium",
123 sourceProfileDirectory: "Default",
124 targetProfileId: "default",
125 },
126 scope: "persist:t3code-preview-test",
127 persistent: true,
128 })
129 .pipe(Effect.flip);
127130
128 assert.equal(error.reason, "browserRunning");
129 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
131 assert.equal(error.reason, "browserRunning");
132 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
130133 );
131134});
132135
apps/desktop/src/preview/BrowserImport/Sources.test.ts+104-93
......@@ -32,6 +32,7 @@ import {
3232 sourcePathContext,
3333 windowsChromiumCookiesAreHeld,
3434} from "./Sources.ts";
35import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
3536
3637const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
3738
......@@ -125,7 +126,7 @@ const writeFirefoxCookieDatabase = (
125126 });
126127
127128describe("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", () =>
129130 run(
130131 Effect.gen(function* () {
131132 const fileSystem = yield* FileSystem.FileSystem;
......@@ -221,48 +222,52 @@ describe("isSourceRunning", () => {
221222 ),
222223 );
223224
224 it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () =>
225 run(
226 Effect.gen(function* () {
227 const fileSystem = yield* FileSystem.FileSystem;
228 const context = yield* withSourceHome();
229 assert.isFalse(yield* isSourceRunning(helium, context));
230
231 // Chromium points the lock at `<host>-<pid>`, a target that never
232 // exists on disk. A check that follows the link reports a running
233 // browser as closed, letting an import read a live, mid-write database.
234 yield* fileSystem.symlink(
235 "host-that-does-not-exist-1234",
236 `${userDataDirectory(context)}/SingletonLock`,
237 );
225 it.effect.skipIf(!symlinksSupported)(
226 "reads Chromium's dangling SingletonLock symlink as a running browser",
227 () =>
228 run(
229 Effect.gen(function* () {
230 const fileSystem = yield* FileSystem.FileSystem;
231 const context = yield* withSourceHome();
232 assert.isFalse(yield* isSourceRunning(helium, context));
233
234 // Chromium points the lock at `<host>-<pid>`, a target that never
235 // exists on disk. A check that follows the link reports a running
236 // browser as closed, letting an import read a live, mid-write database.
237 yield* fileSystem.symlink(
238 "host-that-does-not-exist-1234",
239 `${userDataDirectory(context)}/SingletonLock`,
240 );
238241
239 assert.isTrue(yield* isSourceRunning(helium, context));
240 }),
241 ),
242 assert.isTrue(yield* isSourceRunning(helium, context));
243 }),
244 ),
242245 );
243246
244 it.effect("uses the provided hostname to classify Chromium locks", () =>
245 run(
246 Effect.gen(function* () {
247 const fileSystem = yield* FileSystem.FileSystem;
248 const paths = yield* withSourceHome();
249 yield* fileSystem.symlink(
250 "lock-owner-99999999",
251 `${helium.userDataDirectory(paths)}/SingletonLock`,
252 );
247 it.effect.skipIf(!symlinksSupported)(
248 "uses the provided hostname to classify Chromium locks",
249 () =>
250 run(
251 Effect.gen(function* () {
252 const fileSystem = yield* FileSystem.FileSystem;
253 const paths = yield* withSourceHome();
254 yield* fileSystem.symlink(
255 "lock-owner-99999999",
256 `${helium.userDataDirectory(paths)}/SingletonLock`,
257 );
253258
254 assert.isTrue(
255 yield* isSourceRunning(helium, paths).pipe(
256 Effect.provideService(HostProcessHostname, "another-host"),
257 ),
258 );
259 assert.isFalse(
260 yield* isSourceRunning(helium, paths).pipe(
261 Effect.provideService(HostProcessHostname, "lock-owner"),
262 ),
263 );
264 }),
265 ),
259 assert.isTrue(
260 yield* isSourceRunning(helium, paths).pipe(
261 Effect.provideService(HostProcessHostname, "another-host"),
262 ),
263 );
264 assert.isFalse(
265 yield* isSourceRunning(helium, paths).pipe(
266 Effect.provideService(HostProcessHostname, "lock-owner"),
267 ),
268 );
269 }),
270 ),
266271 );
267272});
268273
......@@ -385,25 +390,27 @@ describe("isSourceInstalled", () => {
385390 ),
386391 );
387392
388 it.effect("follows cookie database symlinks when detecting profiles", () =>
389 run(
390 Effect.gen(function* () {
391 const fileSystem = yield* FileSystem.FileSystem;
392 const context = yield* withSourceHome();
393 const root = userDataDirectory(context);
394 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
395 yield* fileSystem.symlink("missing-cookies", `${root}/Default/Cookies`);
393 it.effect.skipIf(!symlinksSupported)(
394 "follows cookie database symlinks when detecting profiles",
395 () =>
396 run(
397 Effect.gen(function* () {
398 const fileSystem = yield* FileSystem.FileSystem;
399 const context = yield* withSourceHome();
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), []);
398 assert.isFalse(yield* isSourceInstalled(helium, context));
404 assert.deepEqual(yield* listSourceProfiles(helium, context), []);
405 assert.isFalse(yield* isSourceInstalled(helium, context));
399406
400 yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db");
401 assert.deepEqual(yield* listSourceProfiles(helium, context), [
402 { directory: "Default", name: "Default" },
403 ]);
404 assert.isTrue(yield* isSourceInstalled(helium, context));
405 }),
406 ),
407 yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db");
408 assert.deepEqual(yield* listSourceProfiles(helium, context), [
409 { directory: "Default", name: "Default" },
410 ]);
411 assert.isTrue(yield* isSourceInstalled(helium, context));
412 }),
413 ),
407414 );
408415});
409416
......@@ -660,44 +667,48 @@ describe("cookieDatabaseCandidatePaths", () => {
660667const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!;
661668
662669describe("Firefox Snap profiles", () => {
663 it.effect("finds Snap profiles with or without profiles.ini and checks their locks", () =>
664 run(
665 Effect.gen(function* () {
666 const fileSystem = yield* FileSystem.FileSystem;
667 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-snap-" });
668 const context = yield* sourcePathContext.pipe(
669 Effect.provideService(HostProcessEnvironment, { HOME: home }),
670 Effect.provideService(HostProcessPlatform, "linux"),
671 );
672 const root = `${home}/snap/firefox/common/.mozilla/firefox`;
673 const directory = `${root}/abcd.default`;
674 yield* fileSystem.makeDirectory(directory, { recursive: true });
675 yield* writeFirefoxCookieDatabase(`${directory}/cookies.sqlite`, 2, 1);
676 yield* fileSystem.writeFileString(
677 `${root}/profiles.ini`,
678 "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n",
679 );
670 it.effect.skipIf(!symlinksSupported)(
671 "finds Snap profiles with or without profiles.ini and checks their locks",
672 () =>
673 run(
674 Effect.gen(function* () {
675 const fileSystem = yield* FileSystem.FileSystem;
676 const home = yield* fileSystem.makeTempDirectoryScoped({
677 prefix: "t3code-firefox-snap-",
678 });
679 const context = yield* sourcePathContext.pipe(
680 Effect.provideService(HostProcessEnvironment, { HOME: home }),
681 Effect.provideService(HostProcessPlatform, "linux"),
682 );
683 const root = `${home}/snap/firefox/common/.mozilla/firefox`;
684 const directory = `${root}/abcd.default`;
685 yield* fileSystem.makeDirectory(directory, { recursive: true });
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));
682 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
683 { directory, name: "Personal", cookieCount: 2 },
684 ]);
685 assert.equal(
686 yield* resolveCookieDatabase(firefox, context, directory),
687 `${directory}/cookies.sqlite`,
688 );
689 assert.isFalse(yield* isSourceRunning(firefox, context));
690 yield* fileSystem.symlink("foreign-host:+4242", `${directory}/lock`);
691 assert.isTrue(yield* isSourceRunning(firefox, context));
692 yield* fileSystem.remove(`${directory}/lock`);
693 assert.isFalse(yield* isSourceRunning(firefox, context));
692 assert.isTrue(yield* isSourceInstalled(firefox, context));
693 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
694 { directory, name: "Personal", cookieCount: 2 },
695 ]);
696 assert.equal(
697 yield* resolveCookieDatabase(firefox, context, directory),
698 `${directory}/cookies.sqlite`,
699 );
700 assert.isFalse(yield* isSourceRunning(firefox, context));
701 yield* fileSystem.symlink("foreign-host:+4242", `${directory}/lock`);
702 assert.isTrue(yield* isSourceRunning(firefox, context));
703 yield* fileSystem.remove(`${directory}/lock`);
704 assert.isFalse(yield* isSourceRunning(firefox, context));
694705
695 yield* fileSystem.remove(`${root}/profiles.ini`);
696 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
697 { directory, name: "abcd.default", cookieCount: 2 },
698 ]);
699 }),
700 ),
706 yield* fileSystem.remove(`${root}/profiles.ini`);
707 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
708 { directory, name: "abcd.default", cookieCount: 2 },
709 ]);
710 }),
711 ),
701712 );
702713
703714 it.effect("keeps matching profile names in native and Snap installs distinct", () =>
......@@ -862,7 +873,7 @@ describe("listSourceProfiles Firefox fallback", () => {
862873});
863874
864875describe("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", () =>
866877 run(
867878 Effect.gen(function* () {
868879 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";
77
88import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
99import { runSqliteState } from "./t3-sqlite-state.ts";
10import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1011
1112const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(function* (
1213 baseDir: string,
......@@ -73,47 +74,49 @@ it.layer(NodeServices.layer)("t3-sqlite-state", (it) => {
7374 }),
7475 );
7576
76 it.effect("backs up isolated state before writes and refuses the shared home", () =>
77 Effect.gen(function* () {
78 const fs = yield* FileSystem.FileSystem;
79 const path = yield* Path.Path;
80 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-exec-" });
81 yield* createFixtureDatabase(baseDir);
77 it.effect.skipIf(!symlinksSupported)(
78 "backs up isolated state before writes and refuses the shared home",
79 () =>
80 Effect.gen(function* () {
81 const fs = yield* FileSystem.FileSystem;
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({
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 {
86 const mutation = yield* runSqliteState({
9587 operation: "exec",
9688 baseDir,
97 sql: "DELETE FROM fixtures",
98 },
99 { sharedHome: baseDir },
100 ).pipe(Effect.flip);
101 assert.equal(error._tag, "SqliteStateSharedHomeMutationError");
89 sql: "INSERT INTO fixtures (id, label) VALUES (2, 'seeded')",
90 });
91 assert.equal(mutation.operation, "exec");
92 if (mutation.operation === "exec") {
93 assert.equal((yield* fs.stat(mutation.backup)).mode & 0o777, 0o600);
94 }
10295
103 const aliasParent = yield* fs.makeTempDirectoryScoped({
104 prefix: "t3-sqlite-state-alias-",
105 });
106 const aliasBaseDir = path.join(aliasParent, "shared-home-alias");
107 yield* fs.symlink(baseDir, aliasBaseDir);
108 const aliasError = yield* runSqliteState(
109 {
110 operation: "exec",
111 baseDir: aliasBaseDir,
112 sql: "DELETE FROM fixtures",
113 },
114 { sharedHome: baseDir },
115 ).pipe(Effect.flip);
116 assert.equal(aliasError._tag, "SqliteStateSharedHomeMutationError");
117 }),
96 const error = yield* runSqliteState(
97 {
98 operation: "exec",
99 baseDir,
100 sql: "DELETE FROM fixtures",
101 },
102 { sharedHome: baseDir },
103 ).pipe(Effect.flip);
104 assert.equal(error._tag, "SqliteStateSharedHomeMutationError");
105
106 const aliasParent = yield* fs.makeTempDirectoryScoped({
107 prefix: "t3-sqlite-state-alias-",
108 });
109 const aliasBaseDir = path.join(aliasParent, "shared-home-alias");
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 }),
118121 );
119122});
apps/server/src/assets/AssetAccess.test.ts+224-198
......@@ -24,6 +24,7 @@ import { assetFileResponse } from "../http.ts";
2424import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
2525import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
2626import { openMediaFile } from "./MediaFile.ts";
27import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
2728
2829vi.mock("node:fs/promises", async (importOriginal) => {
2930 const actual = await importOriginal<typeof NodeFSP>();
......@@ -108,216 +109,241 @@ describe("AssetAccess", () => {
108109 }).pipe(Effect.provide(testLayer)),
109110 );
110111
111 it.effect("rejects non-previewable files, disguised targets, and directories", () =>
112 Effect.gen(function* () {
113 const fs = yield* FileSystem.FileSystem;
114 const path = yield* Path.Path;
115 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" });
116 for (const name of ["report.md", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) {
117 const filePath = path.join(root, name);
118 yield* fs.writeFileString(filePath, "not media");
119 const error = yield* issueAssetUrl({
120 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
112 it.effect.skipIf(!symlinksSupported)(
113 "rejects non-previewable files, disguised targets, and directories",
114 () =>
115 Effect.gen(function* () {
116 const fs = yield* FileSystem.FileSystem;
117 const path = yield* Path.Path;
118 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" });
119 for (const name of ["report.md", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) {
120 const filePath = path.join(root, name);
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 },
121135 }).pipe(Effect.flip);
122 expect(error).toBeInstanceOf(AssetPreviewTypeValidationError);
123 }
124 const disguisedPath = path.join(root, "disguised.png");
125 yield* fs.symlink(path.join(root, "secret.txt"), disguisedPath);
126 const disguisedError = yield* issueAssetUrl({
127 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath },
128 }).pipe(Effect.flip);
129 expect(disguisedError).toBeInstanceOf(AssetPreviewTypeValidationError);
130 const directoryPath = path.join(root, "directory.png");
131 yield* fs.makeDirectory(directoryPath);
132 const directoryError = yield* issueAssetUrl({
133 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: directoryPath },
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)),
136 expect(disguisedError).toBeInstanceOf(AssetPreviewTypeValidationError);
137 const directoryPath = path.join(root, "directory.png");
138 yield* fs.makeDirectory(directoryPath);
139 const directoryError = yield* issueAssetUrl({
140 resource: {
141 _tag: "media-file",
142 threadId: ThreadId.make("thread-1"),
143 path: directoryPath,
144 },
145 }).pipe(Effect.flip);
146 expect(directoryError._tag).toBe("AssetWorkspaceAssetNotFoundError");
147 }).pipe(Effect.provide(testLayer)),
167148 );
168149
169 it.effect("keeps full and partial responses bound to the file opened during resolution", () =>
170 Effect.gen(function* () {
171 const fs = yield* FileSystem.FileSystem;
172 const path = yield* Path.Path;
173 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-file-" });
174 const filePath = path.join(root, "recording.mp4");
175 const savedPath = path.join(root, "saved.mp4");
176 const secretPath = path.join(root, "secret.txt");
177 yield* fs.writeFileString(filePath, "0123456789");
178 yield* fs.writeFileString(secretPath, "private information");
179 const result = yield* issueAssetUrl({
180 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
181 });
182 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
183 const separator = suffix.indexOf("/");
184 for (const [range, expected, status] of [
185 [undefined, "0123456789", 200],
186 ["bytes=2-5", "2345", 206],
187 ] as const) {
188 const asset = yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1));
189 if (!asset) throw new Error("Expected a resolved media file");
190
191 yield* fs.rename(filePath, savedPath);
192 yield* fs.symlink(secretPath, filePath);
193 const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, range));
194 expect(response.status).toBe(status);
195 expect(response.headers.get("content-length")).toBe(String(expected.length));
196 expect(yield* Effect.promise(() => response.text())).toBe(expected);
150 it.effect.skipIf(!symlinksSupported)(
151 "binds media URLs to the canonical target and rejects symlink substitution",
152 () =>
153 Effect.gen(function* () {
154 const fs = yield* FileSystem.FileSystem;
155 const path = yield* Path.Path;
156 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-symlink-" });
157 const filePath = path.join(root, "actual.svg");
158 const aliasPath = path.join(root, "alias.png");
159 const replacementPath = path.join(root, "other.svg");
160 yield* fs.writeFileString(filePath, "<svg/>");
161 yield* fs.writeFileString(replacementPath, "<svg>private</svg>");
162 yield* fs.symlink(filePath, aliasPath);
163 const canonicalFile = yield* fs.realPath(filePath);
164 const result = yield* issueAssetUrl({
165 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: aliasPath },
166 });
167 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
168 const separator = suffix.indexOf("/");
169 const token = suffix.slice(0, separator);
170 const name = suffix.slice(separator + 1);
171 const expected = { kind: "file", path: canonicalFile, mimeType: "image/svg+xml" };
172 expect(yield* resolveAsset(token, name)).toMatchObject(expected);
173 yield* fs.remove(aliasPath);
174 yield* fs.symlink(replacementPath, aliasPath);
175 expect(yield* resolveAsset(token, name)).toMatchObject(expected);
197176 yield* fs.remove(filePath);
198 yield* fs.rename(savedPath, filePath);
199 }
200 }).pipe(Effect.provide(testLayer)),
177 yield* fs.symlink(replacementPath, filePath);
178 expect(yield* resolveAsset(token, name)).toBeNull();
179 }).pipe(Effect.provide(testLayer)),
201180 );
202181
203 it.effect("rejects a symlink swapped in after canonical validation but before open", () =>
204 Effect.gen(function* () {
205 const fs = yield* FileSystem.FileSystem;
206 const path = yield* Path.Path;
207 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-race-" });
208 const filePath = path.join(root, "recording.mp4");
209 const secretPath = path.join(root, "secret.txt");
210 yield* fs.writeFileString(filePath, "video");
211 yield* fs.writeFileString(secretPath, "secret");
212 const canonicalPath = yield* fs.realPath(filePath);
213 const result = yield* issueAssetUrl({
214 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
215 });
216 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
217 const separator = suffix.indexOf("/");
218 const swappingFileSystem = FileSystem.FileSystem.of({
219 ...fs,
220 stat: Effect.fn(function* (requestedPath) {
221 const info = yield* fs.stat(requestedPath);
222 if (requestedPath === canonicalPath) {
223 yield* fs.remove(filePath);
224 yield* fs.symlink(secretPath, filePath);
225 }
226 return info;
227 }),
228 });
229 expect(
230 yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe(
231 Effect.provideService(FileSystem.FileSystem, swappingFileSystem),
232 ),
233 ).toBeNull();
234 }).pipe(Effect.provide(testLayer)),
182 it.effect.skipIf(!symlinksSupported)(
183 "keeps full and partial responses bound to the file opened during resolution",
184 () =>
185 Effect.gen(function* () {
186 const fs = yield* FileSystem.FileSystem;
187 const path = yield* Path.Path;
188 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-file-" });
189 const filePath = path.join(root, "recording.mp4");
190 const savedPath = path.join(root, "saved.mp4");
191 const secretPath = path.join(root, "secret.txt");
192 yield* fs.writeFileString(filePath, "0123456789");
193 yield* fs.writeFileString(secretPath, "private information");
194 const result = yield* issueAssetUrl({
195 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
196 });
197 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
198 const separator = suffix.indexOf("/");
199 for (const [range, expected, status] of [
200 [undefined, "0123456789", 200],
201 ["bytes=2-5", "2345", 206],
202 ] as const) {
203 const asset = yield* resolveAsset(
204 suffix.slice(0, separator),
205 suffix.slice(separator + 1),
206 );
207 if (!asset) throw new Error("Expected a resolved media file");
208
209 yield* fs.rename(filePath, savedPath);
210 yield* fs.symlink(secretPath, filePath);
211 const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, range));
212 expect(response.status).toBe(status);
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)),
235219 );
236220
237 it.effect("closes a descriptor rejected when its path changes during open", () =>
238 Effect.gen(function* () {
239 const fs = yield* FileSystem.FileSystem;
240 const path = yield* Path.Path;
241 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-rejected-" });
242 const filePath = path.join(root, "recording.mp4");
243 const secretPath = path.join(root, "secret.txt");
244 yield* fs.writeFileString(filePath, "video");
245 yield* fs.writeFileString(secretPath, "secret");
246 const canonicalPath = yield* fs.realPath(filePath);
247 const originalOpen = (yield* Effect.promise(() =>
248 vi.importActual<typeof NodeFSP>("node:fs/promises"),
249 )).open;
250 let opened: NodeFSP.FileHandle | undefined;
251 const openSpy = vi.mocked(NodeFSP.open).mockImplementation(async (target, flags, mode) => {
252 const handle = await originalOpen(target, flags, mode);
253 if (target === canonicalPath) {
254 opened = handle;
255 await NodeFSP.unlink(filePath);
256 await NodeFSP.symlink(secretPath, filePath);
257 }
258 return handle;
259 });
260 yield* Effect.addFinalizer(() => Effect.sync(() => openSpy.mockImplementation(originalOpen)));
261 expect(yield* openMediaFile(canonicalPath)).toBeNull();
262 expect(opened).toBeDefined();
263 expect(opened?.fd).toBe(-1);
264 }).pipe(Effect.provide(testLayer)),
221 it.effect.skipIf(!symlinksSupported)(
222 "rejects a symlink swapped in after canonical validation but before open",
223 () =>
224 Effect.gen(function* () {
225 const fs = yield* FileSystem.FileSystem;
226 const path = yield* Path.Path;
227 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-race-" });
228 const filePath = path.join(root, "recording.mp4");
229 const secretPath = path.join(root, "secret.txt");
230 yield* fs.writeFileString(filePath, "video");
231 yield* fs.writeFileString(secretPath, "secret");
232 const canonicalPath = yield* fs.realPath(filePath);
233 const result = yield* issueAssetUrl({
234 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
235 });
236 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
237 const separator = suffix.indexOf("/");
238 const swappingFileSystem = FileSystem.FileSystem.of({
239 ...fs,
240 stat: Effect.fn(function* (requestedPath) {
241 const info = yield* fs.stat(requestedPath);
242 if (requestedPath === canonicalPath) {
243 yield* fs.remove(filePath);
244 yield* fs.symlink(secretPath, filePath);
245 }
246 return info;
247 }),
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)),
265255 );
266256
267 it.effect("rejects an ancestor symlink race even when canonical path rechecks would pass", () =>
268 Effect.gen(function* () {
269 const fs = yield* FileSystem.FileSystem;
270 const path = yield* Path.Path;
271 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-parent-race-" });
272 const publicDirectory = path.join(root, "public");
273 const privateDirectory = path.join(root, "private");
274 yield* fs.makeDirectory(publicDirectory);
275 yield* fs.makeDirectory(privateDirectory);
276 const filePath = path.join(publicDirectory, "recording.mp4");
277 yield* fs.writeFileString(filePath, "public video");
278 yield* fs.writeFileString(path.join(privateDirectory, "recording.mp4"), "private video");
279 const canonicalPath = yield* fs.realPath(filePath);
280 const result = yield* issueAssetUrl({
281 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath },
282 });
283 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
284 const separator = suffix.indexOf("/");
285 const native = yield* Effect.promise(() =>
286 vi.importActual<typeof NodeFSP>("node:fs/promises"),
287 );
288 const savedDirectory = path.join(root, "saved");
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 );
257 it.effect.skipIf(!symlinksSupported)(
258 "closes a descriptor rejected when its path changes during open",
259 () =>
260 Effect.gen(function* () {
261 const fs = yield* FileSystem.FileSystem;
262 const path = yield* Path.Path;
263 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-rejected-" });
264 const filePath = path.join(root, "recording.mp4");
265 const secretPath = path.join(root, "secret.txt");
266 yield* fs.writeFileString(filePath, "video");
267 yield* fs.writeFileString(secretPath, "secret");
268 const canonicalPath = yield* fs.realPath(filePath);
269 const originalOpen = (yield* Effect.promise(() =>
270 vi.importActual<typeof NodeFSP>("node:fs/promises"),
271 )).open;
272 let opened: NodeFSP.FileHandle | undefined;
273 const openSpy = vi.mocked(NodeFSP.open).mockImplementation(async (target, flags, mode) => {
274 const handle = await originalOpen(target, flags, mode);
275 if (target === canonicalPath) {
276 opened = handle;
277 await NodeFSP.unlink(filePath);
278 await NodeFSP.symlink(secretPath, filePath);
311279 }
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");
312323 return canonical;
313 }),
314 });
315 expect(
316 yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe(
317 Effect.provideService(FileSystem.FileSystem, swappingFileSystem),
318 ),
319 ).toBeNull();
320 }).pipe(Effect.provide(testLayer)),
324 });
325 yield* Effect.addFinalizer(() =>
326 Effect.sync(() => realpathSpy.mockReset().mockImplementation(native.realpath)),
327 );
328 const swappingFileSystem = FileSystem.FileSystem.of({
329 ...fs,
330 realPath: Effect.fn(function* (requestedPath) {
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)),
321347 );
322348
323349 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";
1313import { Command } from "effect/unstable/cli";
1414
1515import { cli } from "../bin.ts";
16import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1617
1718const runCli = (args: ReadonlyArray<string>) =>
1819 Command.runWith(cli, { version: "0.0.0" })(args).pipe(
......@@ -185,27 +186,29 @@ describe("t3 theme", () => {
185186
186187 // A symlink is a normal way to hand this command a theme -- desktop hooks
187188 // symlink the current palette -- so the source is resolved, not refused.
188 it.effect("publishes a theme file through a symlinked source path", () =>
189 Effect.gen(function* () {
190 const baseDir = makeBaseDir();
191 const realFile = NodePath.join(baseDir, "real-nightfall.json");
192 NodeFS.writeFileSync(realFile, NIGHTFALL_THEME_JSON);
193 const linkPath = NodePath.join(baseDir, "nightfall.json");
194 NodeFS.symlinkSync(realFile, linkPath);
195
196 yield* runCli(["theme", "set", linkPath, "--base-dir", baseDir]);
197
198 assert.equal(
199 NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")),
200 true,
201 );
202 assert.equal(readSettings(baseDir).defaultTheme, "nightfall");
203 }),
189 it.effect.skipIf(!symlinksSupported)(
190 "publishes a theme file through a symlinked source path",
191 () =>
192 Effect.gen(function* () {
193 const baseDir = makeBaseDir();
194 const realFile = NodePath.join(baseDir, "real-nightfall.json");
195 NodeFS.writeFileSync(realFile, NIGHTFALL_THEME_JSON);
196 const linkPath = NodePath.join(baseDir, "nightfall.json");
197 NodeFS.symlinkSync(realFile, linkPath);
198
199 yield* runCli(["theme", "set", linkPath, "--base-dir", baseDir]);
200
201 assert.equal(
202 NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")),
203 true,
204 );
205 assert.equal(readSettings(baseDir).defaultTheme, "nightfall");
206 }),
204207 );
205208
206209 // The staging entry is created fresh with O_EXCL, so a symlink planted at
207210 // 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", () =>
209212 Effect.gen(function* () {
210213 const baseDir = makeBaseDir();
211214 const themesDir = NodePath.join(baseDir, "userdata", "themes");
......@@ -226,28 +229,30 @@ describe("t3 theme", () => {
226229 // Rollback moves the previous directory entry aside and back, so even an
227230 // entry the watcher would never publish -- here a symlink -- comes back
228231 // exactly as it was when the set fails.
229 it.effect("restores a non-theme destination entry when the set fails", () =>
230 Effect.gen(function* () {
231 const baseDir = makeBaseDir();
232 writeSettings(baseDir, {});
233 const userdataDir = NodePath.dirname(settingsPathFor(baseDir));
234 const themesDir = NodePath.join(userdataDir, "themes");
235 NodeFS.mkdirSync(themesDir, { recursive: true });
236 const outside = NodePath.join(baseDir, "outside.json");
237 NodeFS.writeFileSync(outside, NIGHTFALL_THEME_JSON);
238 const destination = NodePath.join(themesDir, "nightfall.json");
239 NodeFS.symlinkSync(outside, destination);
240 const themeFile = NodePath.join(baseDir, "nightfall.json");
241 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
242
243 NodeFS.chmodSync(userdataDir, 0o555);
244 try {
245 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip);
246 assert.equal(NodeFS.lstatSync(destination).isSymbolicLink(), true);
247 } finally {
248 NodeFS.chmodSync(userdataDir, 0o755);
249 }
250 }),
232 it.effect.skipIf(!symlinksSupported)(
233 "restores a non-theme destination entry when the set fails",
234 () =>
235 Effect.gen(function* () {
236 const baseDir = makeBaseDir();
237 writeSettings(baseDir, {});
238 const userdataDir = NodePath.dirname(settingsPathFor(baseDir));
239 const themesDir = NodePath.join(userdataDir, "themes");
240 NodeFS.mkdirSync(themesDir, { recursive: true });
241 const outside = NodePath.join(baseDir, "outside.json");
242 NodeFS.writeFileSync(outside, NIGHTFALL_THEME_JSON);
243 const destination = NodePath.join(themesDir, "nightfall.json");
244 NodeFS.symlinkSync(outside, destination);
245 const themeFile = NodePath.join(baseDir, "nightfall.json");
246 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
247
248 NodeFS.chmodSync(userdataDir, 0o555);
249 try {
250 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip);
251 assert.equal(NodeFS.lstatSync(destination).isSymbolicLink(), true);
252 } finally {
253 NodeFS.chmodSync(userdataDir, 0o755);
254 }
255 }),
251256 );
252257
253258 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";
77import { describe, expect, it } from "vite-plus/test";
88
99import { isEntrypoint } from "./entrypoint.ts";
10import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1011
1112const makeTempDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-entrypoint-test-"));
1213
......@@ -44,21 +45,24 @@ describe("isEntrypoint", () => {
4445 ).toBe(true);
4546 });
4647
47 it("matches through a symlinked entrypoint, as npm and npx install it", () => {
48 const dir = makeTempDir();
49 const real = NodePath.join(dir, "bin.mjs");
50 const link = NodePath.join(dir, "t3");
51 NodeFS.writeFileSync(real, "");
52 NodeFS.symlinkSync(real, link);
48 it.skipIf(!symlinksSupported)(
49 "matches through a symlinked entrypoint, as npm and npx install it",
50 () => {
51 const dir = makeTempDir();
52 const real = NodePath.join(dir, "bin.mjs");
53 const link = NodePath.join(dir, "t3");
54 NodeFS.writeFileSync(real, "");
55 NodeFS.symlinkSync(real, link);
5356
54 expect(
55 isEntrypoint({
56 moduleUrl: NodeURL.pathToFileURL(real).href,
57 entryPath: link,
58 runtimeMain: undefined,
59 }),
60 ).toBe(true);
61 });
57 expect(
58 isEntrypoint({
59 moduleUrl: NodeURL.pathToFileURL(real).href,
60 entryPath: link,
61 runtimeMain: undefined,
62 }),
63 ).toBe(true);
64 },
65 );
6266
6367 it("stays false for an imported module that is not the entrypoint", () => {
6468 // 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";
1313
1414import * as ServerConfig from "./config.ts";
1515import * as EnvironmentTheme from "./environmentTheme.ts";
16import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1617
1718const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile));
1819
......@@ -188,7 +189,7 @@ it.layer(NodeServices.layer)("environment theme", (it) => {
188189
189190 // A symlinked themes directory stays usable, but a symlinked file inside it
190191 // 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", () =>
192193 withEnvironmentThemes(
193194 {},
194195 Effect.gen(function* () {
apps/server/src/orchestration/workflowScriptQuery.test.ts+27-28
......@@ -5,6 +5,7 @@ import * as NodePath from "node:path";
55import { it as effectIt } from "@effect/vitest";
66import * as Effect from "effect/Effect";
77import { afterAll, assert, describe } from "vite-plus/test";
8import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
89import { readWorkflowScript } from "./workflowScriptQuery.ts";
910
1011const root = NodePath.join(NodeOS.homedir(), ".claude", "projects", "__wf_script_test__");
......@@ -14,19 +15,15 @@ NodeFS.writeFileSync(scriptPath, "export const meta = {};\n");
1415const outside = NodePath.join(NodeOS.tmpdir(), "wf-outside.js");
1516NodeFS.writeFileSync(outside, "evil\n");
1617const 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 });
1822 NodeFS.symlinkSync(outside, link);
19} catch (error) {
20 // Tolerate only "already exists" from a prior run — any other failure
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;
23 if (!NodeFS.lstatSync(link).isSymbolicLink()) {
24 throw new Error("test setup: sneaky.js must be a symlink");
2525 }
2626}
27if (!NodeFS.lstatSync(link).isSymbolicLink()) {
28 throw new Error("test setup: sneaky.js must be a symlink");
29}
3027
3128afterAll(() => {
3229 NodeFS.rmSync(root, { recursive: true, force: true });
......@@ -53,23 +50,25 @@ describe("readWorkflowScript containment", () => {
5350 }),
5451 );
5552
56 effectIt.effect("rejects paths outside the root and symlink escapes", () =>
57 Effect.gen(function* () {
58 const escaped = yield* Effect.exit(readWorkflowScript({ scriptPath: outside }));
59 assert.equal(escaped._tag, "Failure");
60 // A symlink INSIDE the root pointing outside must fail specifically on
61 // realpath re-containment — a "not-found" would mean the link was
62 // never exercised and the assertion proves nothing.
63 const sneaky = yield* Effect.exit(
64 readWorkflowScript({ scriptPath: link }).pipe(
65 Effect.flip,
66 Effect.map((error) => error.reason),
67 ),
68 );
69 assert.equal(sneaky._tag, "Success");
70 if (sneaky._tag === "Success") {
71 assert.equal(sneaky.value, "outside-root");
72 }
73 }),
53 effectIt.effect.skipIf(!symlinksSupported)(
54 "rejects paths outside the root and symlink escapes",
55 () =>
56 Effect.gen(function* () {
57 const escaped = yield* Effect.exit(readWorkflowScript({ scriptPath: outside }));
58 assert.equal(escaped._tag, "Failure");
59 // A symlink INSIDE the root pointing outside must fail specifically on
60 // realpath re-containment — a "not-found" would mean the link was
61 // never exercised and the assertion proves nothing.
62 const sneaky = yield* Effect.exit(
63 readWorkflowScript({ scriptPath: link }).pipe(
64 Effect.flip,
65 Effect.map((error) => error.reason),
66 ),
67 );
68 assert.equal(sneaky._tag, "Success");
69 if (sneaky._tag === "Success") {
70 assert.equal(sneaky.value, "outside-root");
71 }
72 }),
7473 );
7574});
apps/server/src/provider/Drivers/AntigravitySkills.test.ts+23-20
......@@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem";
55import * as Path from "effect/Path";
66
77import { discoverAntigravitySkills } from "./AntigravitySkills.ts";
8import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
89
910const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) {
1011 const fileSystem = yield* FileSystem.FileSystem;
......@@ -239,27 +240,29 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => {
239240 }),
240241 );
241242
242 it.effect("follows directory symlinks used to install shared skills", () =>
243 Effect.gen(function* () {
244 const fileSystem = yield* FileSystem.FileSystem;
245 const path = yield* Path.Path;
246 const input = yield* makeWorkspace();
247 const sourceDirectory = path.join(input.profileDirectory, "shared-review");
248 yield* writeSkill(sourceDirectory, "---\nname: review\n---\n");
249 const root = path.join(input.cwd, ".agents", "skills");
250 const linkedDirectory = path.join(root, "review");
251 yield* fileSystem.makeDirectory(root, { recursive: true });
252 yield* fileSystem.symlink(sourceDirectory, linkedDirectory);
243 it.effect.skipIf(!symlinksSupported)(
244 "follows directory symlinks used to install shared skills",
245 () =>
246 Effect.gen(function* () {
247 const fileSystem = yield* FileSystem.FileSystem;
248 const path = yield* Path.Path;
249 const input = yield* makeWorkspace();
250 const sourceDirectory = path.join(input.profileDirectory, "shared-review");
251 yield* writeSkill(sourceDirectory, "---\nname: review\n---\n");
252 const root = path.join(input.cwd, ".agents", "skills");
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), [
255 {
256 name: "review",
257 path: path.join(linkedDirectory, "SKILL.md"),
258 scope: "project",
259 enabled: true,
260 },
261 ]);
262 }),
257 assert.deepEqual(yield* discoverAntigravitySkills(input), [
258 {
259 name: "review",
260 path: path.join(linkedDirectory, "SKILL.md"),
261 scope: "project",
262 enabled: true,
263 },
264 ]);
265 }),
263266 );
264267
265268 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 {
1313 materializeCodexShadowHome,
1414 resolveCodexHomeLayout,
1515} from "./CodexHomeLayout.ts";
16import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1617const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings);
1718
1819const decodeCodexSettings = (input: {
......@@ -84,130 +85,139 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
8485 });
8586
8687 describe("materializeCodexShadowHome", () => {
87 it.effect("materializes a shadow home with shared state links and private auth", () =>
88 Effect.gen(function* () {
89 const fileSystem = yield* FileSystem.FileSystem;
90 const path = yield* Path.Path;
91 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
92 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
93 const shadowHome = path.join(shadowRoot, "shadow");
94
95 yield* fileSystem.makeDirectory(path.join(sharedHome, "sessions"));
96 yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');
97 yield* writeTextFile(path.join(sharedHome, "models_cache.json"), '{"models":["shared"]}\n');
98 yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n');
99 yield* fileSystem.makeDirectory(shadowHome, { recursive: true });
100 yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n');
101 yield* fileSystem.symlink(
102 path.join(sharedHome, "models_cache.json"),
103 path.join(shadowHome, "models_cache.json"),
104 );
105
106 const layout = yield* resolveCodexHomeLayout(
107 decodeCodexSettings({
108 homePath: sharedHome,
109 shadowHomePath: shadowHome,
110 }),
111 );
112
113 yield* materializeCodexShadowHome(layout);
114
115 const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions"));
116 const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml"));
117 const mcpOauthLocksTarget = yield* fileSystem.readLink(
118 path.join(shadowHome, "mcp-oauth-locks"),
119 );
120 const modelsCacheExists = yield* fileSystem.exists(
121 path.join(shadowHome, "models_cache.json"),
122 );
123 const authLinkResult = yield* fileSystem
124 .readLink(path.join(shadowHome, "auth.json"))
125 .pipe(Effect.result);
126 const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json"));
127
128 expect(sessionsTarget).toBe(path.join(sharedHome, "sessions"));
129 expect(configTarget).toBe(path.join(sharedHome, "config.toml"));
130 expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks"));
131 expect(modelsCacheExists).toBe(false);
132 expect(authLinkResult._tag).toBe("Failure");
133 expect(authContents).toContain("shadow");
134 }),
88 it.effect.skipIf(!symlinksSupported)(
89 "materializes a shadow home with shared state links and private auth",
90 () =>
91 Effect.gen(function* () {
92 const fileSystem = yield* FileSystem.FileSystem;
93 const path = yield* Path.Path;
94 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
95 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
96 const shadowHome = path.join(shadowRoot, "shadow");
97
98 yield* fileSystem.makeDirectory(path.join(sharedHome, "sessions"));
99 yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');
100 yield* writeTextFile(
101 path.join(sharedHome, "models_cache.json"),
102 '{"models":["shared"]}\n',
103 );
104 yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n');
105 yield* fileSystem.makeDirectory(shadowHome, { recursive: true });
106 yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n');
107 yield* fileSystem.symlink(
108 path.join(sharedHome, "models_cache.json"),
109 path.join(shadowHome, "models_cache.json"),
110 );
111
112 const layout = yield* resolveCodexHomeLayout(
113 decodeCodexSettings({
114 homePath: sharedHome,
115 shadowHomePath: shadowHome,
116 }),
117 );
118
119 yield* materializeCodexShadowHome(layout);
120
121 const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions"));
122 const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml"));
123 const mcpOauthLocksTarget = yield* fileSystem.readLink(
124 path.join(shadowHome, "mcp-oauth-locks"),
125 );
126 const modelsCacheExists = yield* fileSystem.exists(
127 path.join(shadowHome, "models_cache.json"),
128 );
129 const authLinkResult = yield* fileSystem
130 .readLink(path.join(shadowHome, "auth.json"))
131 .pipe(Effect.result);
132 const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json"));
133
134 expect(sessionsTarget).toBe(path.join(sharedHome, "sessions"));
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 }),
135141 );
136142
137 it.effect("replaces Codex-created local MCP OAuth locks with the shared lock directory", () =>
138 Effect.gen(function* () {
139 const fileSystem = yield* FileSystem.FileSystem;
140 const path = yield* Path.Path;
141 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
142 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
143 const shadowHome = path.join(shadowRoot, "shadow");
144 const sharedLocks = path.join(sharedHome, "mcp-oauth-locks");
145 const shadowLocks = path.join(shadowHome, "mcp-oauth-locks");
146
147 yield* writeTextFile(path.join(sharedLocks, "file-store.lock"), "");
148 yield* writeTextFile(path.join(shadowLocks, "file-store.lock"), "");
149
150 const layout = yield* resolveCodexHomeLayout(
151 decodeCodexSettings({
152 homePath: sharedHome,
153 shadowHomePath: shadowHome,
154 }),
155 );
156
157 yield* materializeCodexShadowHome(layout);
158
159 const locksTarget = yield* fileSystem.readLink(shadowLocks);
160 const sharedLockExists = yield* fileSystem.exists(
161 path.join(sharedLocks, "file-store.lock"),
162 );
163
164 expect(locksTarget).toBe(sharedLocks);
165 expect(sharedLockExists).toBe(true);
166 }),
143 it.effect.skipIf(!symlinksSupported)(
144 "replaces Codex-created local MCP OAuth locks with the shared lock directory",
145 () =>
146 Effect.gen(function* () {
147 const fileSystem = yield* FileSystem.FileSystem;
148 const path = yield* Path.Path;
149 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
150 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
151 const shadowHome = path.join(shadowRoot, "shadow");
152 const sharedLocks = path.join(sharedHome, "mcp-oauth-locks");
153 const shadowLocks = path.join(shadowHome, "mcp-oauth-locks");
154
155 yield* writeTextFile(path.join(sharedLocks, "file-store.lock"), "");
156 yield* writeTextFile(path.join(shadowLocks, "file-store.lock"), "");
157
158 const layout = yield* resolveCodexHomeLayout(
159 decodeCodexSettings({
160 homePath: sharedHome,
161 shadowHomePath: shadowHome,
162 }),
163 );
164
165 yield* materializeCodexShadowHome(layout);
166
167 const locksTarget = yield* fileSystem.readLink(shadowLocks);
168 const sharedLockExists = yield* fileSystem.exists(
169 path.join(sharedLocks, "file-store.lock"),
170 );
171
172 expect(locksTarget).toBe(sharedLocks);
173 expect(sharedLockExists).toBe(true);
174 }),
167175 );
168176
169 it.effect("accepts Codex-created shadow-local runtime directories", () =>
170 Effect.gen(function* () {
171 const fileSystem = yield* FileSystem.FileSystem;
172 const path = yield* Path.Path;
173 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
174 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
175 const shadowHome = path.join(shadowRoot, "shadow");
176
177 yield* fileSystem.makeDirectory(path.join(sharedHome, "log"));
178 yield* fileSystem.makeDirectory(path.join(sharedHome, "memories"));
179 yield* fileSystem.makeDirectory(path.join(sharedHome, "tmp"));
180 yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');
181 yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n');
182 yield* fileSystem.makeDirectory(path.join(shadowHome, "log"), { recursive: true });
183 yield* fileSystem.makeDirectory(path.join(shadowHome, "memories"), { recursive: true });
184 yield* fileSystem.makeDirectory(path.join(shadowHome, "tmp"), { recursive: true });
185
186 const layout = yield* resolveCodexHomeLayout(
187 decodeCodexSettings({
188 homePath: sharedHome,
189 shadowHomePath: shadowHome,
190 }),
191 );
192
193 yield* materializeCodexShadowHome(layout);
194
195 const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml"));
196 const logLinkResult = yield* fileSystem
197 .readLink(path.join(shadowHome, "log"))
198 .pipe(Effect.result);
199 const memoriesLinkResult = yield* fileSystem
200 .readLink(path.join(shadowHome, "memories"))
201 .pipe(Effect.result);
202 const tmpLinkResult = yield* fileSystem
203 .readLink(path.join(shadowHome, "tmp"))
204 .pipe(Effect.result);
205
206 expect(configTarget).toBe(path.join(sharedHome, "config.toml"));
207 expect(logLinkResult._tag).toBe("Failure");
208 expect(memoriesLinkResult._tag).toBe("Failure");
209 expect(tmpLinkResult._tag).toBe("Failure");
210 }),
177 it.effect.skipIf(!symlinksSupported)(
178 "accepts Codex-created shadow-local runtime directories",
179 () =>
180 Effect.gen(function* () {
181 const fileSystem = yield* FileSystem.FileSystem;
182 const path = yield* Path.Path;
183 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
184 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
185 const shadowHome = path.join(shadowRoot, "shadow");
186
187 yield* fileSystem.makeDirectory(path.join(sharedHome, "log"));
188 yield* fileSystem.makeDirectory(path.join(sharedHome, "memories"));
189 yield* fileSystem.makeDirectory(path.join(sharedHome, "tmp"));
190 yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');
191 yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n');
192 yield* fileSystem.makeDirectory(path.join(shadowHome, "log"), { recursive: true });
193 yield* fileSystem.makeDirectory(path.join(shadowHome, "memories"), { recursive: true });
194 yield* fileSystem.makeDirectory(path.join(shadowHome, "tmp"), { recursive: true });
195
196 const layout = yield* resolveCodexHomeLayout(
197 decodeCodexSettings({
198 homePath: sharedHome,
199 shadowHomePath: shadowHome,
200 }),
201 );
202
203 yield* materializeCodexShadowHome(layout);
204
205 const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml"));
206 const logLinkResult = yield* fileSystem
207 .readLink(path.join(shadowHome, "log"))
208 .pipe(Effect.result);
209 const memoriesLinkResult = yield* fileSystem
210 .readLink(path.join(shadowHome, "memories"))
211 .pipe(Effect.result);
212 const tmpLinkResult = yield* fileSystem
213 .readLink(path.join(shadowHome, "tmp"))
214 .pipe(Effect.result);
215
216 expect(configTarget).toBe(path.join(sharedHome, "config.toml"));
217 expect(logLinkResult._tag).toBe("Failure");
218 expect(memoriesLinkResult._tag).toBe("Failure");
219 expect(tmpLinkResult._tag).toBe("Failure");
220 }),
211221 );
212222
213223 it.effect("rejects shadow homes that point at the shared home", () =>
......@@ -233,36 +243,38 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
233243 }),
234244 );
235245
236 it.effect("rejects shared entries that already exist in the shadow home as real files", () =>
237 Effect.gen(function* () {
238 const path = yield* Path.Path;
239 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
240 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
241 const shadowHome = path.join(shadowRoot, "shadow");
242 yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');
243 yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n');
244
245 const layout = yield* resolveCodexHomeLayout(
246 decodeCodexSettings({
247 homePath: sharedHome,
248 shadowHomePath: shadowHome,
249 }),
250 );
251
252 const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip);
253
254 expect(error).toBeInstanceOf(CodexShadowHomeEntryConflictError);
255 expect(error).toMatchObject({
256 sharedHomePath: sharedHome,
257 effectiveHomePath: shadowHome,
258 entryName: "config.toml",
259 linkPath: path.join(shadowHome, "config.toml"),
260 targetPath: path.join(sharedHome, "config.toml"),
261 });
262 expect(error.message).toBe(
263 `Cannot create Codex shadow home entry 'config.toml' because '${path.join(shadowHome, "config.toml")}' already exists and is not a symlink.`,
264 );
265 }),
246 it.effect.skipIf(!symlinksSupported)(
247 "rejects shared entries that already exist in the shadow home as real files",
248 () =>
249 Effect.gen(function* () {
250 const path = yield* Path.Path;
251 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
252 const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
253 const shadowHome = path.join(shadowRoot, "shadow");
254 yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');
255 yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n');
256
257 const layout = yield* resolveCodexHomeLayout(
258 decodeCodexSettings({
259 homePath: sharedHome,
260 shadowHomePath: shadowHome,
261 }),
262 );
263
264 const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip);
265
266 expect(error).toBeInstanceOf(CodexShadowHomeEntryConflictError);
267 expect(error).toMatchObject({
268 sharedHomePath: sharedHome,
269 effectiveHomePath: shadowHome,
270 entryName: "config.toml",
271 linkPath: path.join(shadowHome, "config.toml"),
272 targetPath: path.join(sharedHome, "config.toml"),
273 });
274 expect(error.message).toBe(
275 `Cannot create Codex shadow home entry 'config.toml' because '${path.join(shadowHome, "config.toml")}' already exists and is not a symlink.`,
276 );
277 }),
266278 );
267279
268280 it.effect("preserves filesystem operation, paths, and cause", () =>
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+27-24
......@@ -49,6 +49,7 @@ import {
4949 makeOpenCodeAdapter,
5050 mergeOpenCodeAssistantText,
5151} from "./OpenCodeAdapter.ts";
52import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
5253
5354// Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`.
5455class OpenCodeAdapter extends Context.Service<OpenCodeAdapter, OpenCodeAdapterShape>()(
......@@ -6401,30 +6402,32 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
64016402 }),
64026403 );
64036404
6404 it.effect("treats lexically or physically identical directories as the same", () =>
6405 Effect.gen(function* () {
6406 const fileSystem = yield* FileSystem.FileSystem;
6407 const path = yield* Path.Path;
6408 const sameDirectory = (left: string, right: string) =>
6409 isSameOpenCodeDirectory(fileSystem, path, left, right);
6410
6411 // Lexical-only differences (trailing slash, dot segments) short-circuit
6412 // without touching the filesystem — the paths need not exist.
6413 NodeAssert.equal(yield* sameDirectory("/repo/project/", "/repo/project"), true);
6414 NodeAssert.equal(yield* sameDirectory("/repo/nested/../project", "/repo/project"), true);
6415 // Nonexistent paths degrade to the lexical comparison instead of failing.
6416 NodeAssert.equal(yield* sameDirectory("/repo/project", "/repo/other"), false);
6417
6418 // A symlinked cwd (the macOS `/tmp` → `/private/tmp` shape) resolves to
6419 // the directory it points at, so the two spellings compare equal.
6420 const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode-dir-" });
6421 const real = path.join(base, "real");
6422 const link = path.join(base, "link");
6423 yield* fileSystem.makeDirectory(real);
6424 yield* fileSystem.symlink(real, link);
6425 NodeAssert.equal(yield* sameDirectory(link, real), true);
6426 NodeAssert.equal(yield* sameDirectory(link, path.join(base, "other")), false);
6427 }).pipe(Effect.scoped),
6405 it.effect.skipIf(!symlinksSupported)(
6406 "treats lexically or physically identical directories as the same",
6407 () =>
6408 Effect.gen(function* () {
6409 const fileSystem = yield* FileSystem.FileSystem;
6410 const path = yield* Path.Path;
6411 const sameDirectory = (left: string, right: string) =>
6412 isSameOpenCodeDirectory(fileSystem, path, left, right);
6413
6414 // Lexical-only differences (trailing slash, dot segments) short-circuit
6415 // without touching the filesystem — the paths need not exist.
6416 NodeAssert.equal(yield* sameDirectory("/repo/project/", "/repo/project"), true);
6417 NodeAssert.equal(yield* sameDirectory("/repo/nested/../project", "/repo/project"), true);
6418 // Nonexistent paths degrade to the lexical comparison instead of failing.
6419 NodeAssert.equal(yield* sameDirectory("/repo/project", "/repo/other"), false);
6420
6421 // A symlinked cwd (the macOS `/tmp` → `/private/tmp` shape) resolves to
6422 // the directory it points at, so the two spellings compare equal.
6423 const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode-dir-" });
6424 const real = path.join(base, "real");
6425 const link = path.join(base, "link");
6426 yield* fileSystem.makeDirectory(real);
6427 yield* fileSystem.symlink(real, link);
6428 NodeAssert.equal(yield* sameDirectory(link, real), true);
6429 NodeAssert.equal(yield* sameDirectory(link, path.join(base, "other")), false);
6430 }).pipe(Effect.scoped),
64286431 );
64296432
64306433 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 {
2020 resolveLatestProviderVersion,
2121 resolveProviderMaintenanceCapabilitiesEffect,
2222} from "./providerMaintenance.ts";
23import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
2324
2425const driver = (value: string) => ProviderDriverKind.make(value);
2526// These write `#!/bin/sh` stubs and resolve them through a darwin-mocked
......@@ -456,100 +457,110 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
456457 });
457458 });
458459
459 it.effect("keeps npm updates for binaries symlinked into npm's global node_modules tree", () =>
460 Effect.gen(function* () {
461 const tempDir = yield* makeTempDir("t3-npm-capabilities");
462 const binDir = NodePath.join(tempDir, "bin");
463 const packageBinDir = NodePath.join(
464 tempDir,
465 "lib",
466 "node_modules",
467 "@example",
468 "package-tool",
469 "bin",
470 );
471 NodeFS.mkdirSync(binDir, { recursive: true });
472 NodeFS.mkdirSync(packageBinDir, { recursive: true });
473 const packageBinPath = NodePath.join(packageBinDir, "package-tool.js");
474 const symlinkPath = NodePath.join(binDir, "package-tool");
475 NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n");
476 NodeFS.chmodSync(packageBinPath, 0o755);
477 NodeFS.symlinkSync(packageBinPath, symlinkPath);
478
479 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
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 ],
460 it.effect.skipIf(!symlinksSupported)(
461 "keeps npm updates for binaries symlinked into npm's global node_modules tree",
462 () =>
463 Effect.gen(function* () {
464 const tempDir = yield* makeTempDir("t3-npm-capabilities");
465 const binDir = NodePath.join(tempDir, "bin");
466 const packageBinDir = NodePath.join(
467 tempDir,
468 "lib",
469 "node_modules",
470 "@example",
471 "package-tool",
472 "bin",
473 );
474 NodeFS.mkdirSync(binDir, { recursive: true });
475 NodeFS.mkdirSync(packageBinDir, { recursive: true });
476 const packageBinPath = NodePath.join(packageBinDir, "package-tool.js");
477 const symlinkPath = NodePath.join(binDir, "package-tool");
478 NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n");
479 NodeFS.chmodSync(packageBinPath, 0o755);
480 NodeFS.symlinkSync(packageBinPath, symlinkPath);
501481
502 lockKey: "npm-global",
503 },
504 });
505 }),
482 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
483 packageToolUpdate,
484 {
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 }),
506512 );
507513
508 it.effect("uses Effect FileSystem realPath when detecting pnpm global symlinks", () =>
509 Effect.gen(function* () {
510 const tempDir = yield* makeTempDir("t3-pnpm-realpath-capabilities");
511 const binDir = NodePath.join(tempDir, "bin");
512 const packageBinDir = NodePath.join(
513 tempDir,
514 ".local",
515 "share",
516 "pnpm",
517 "global",
518 "5",
519 "node_modules",
520 "@example",
521 "package-tool",
522 "bin",
523 );
524 NodeFS.mkdirSync(binDir, { recursive: true });
525 NodeFS.mkdirSync(packageBinDir, { recursive: true });
526 const packageBinPath = NodePath.join(packageBinDir, "package-tool.js");
527 const symlinkPath = NodePath.join(binDir, "package-tool");
528 NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n");
529 NodeFS.chmodSync(packageBinPath, 0o755);
530 NodeFS.symlinkSync(packageBinPath, symlinkPath);
531
532 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
533 binaryPath: symlinkPath,
534 env: {
535 PATH: "",
536 },
537 });
514 it.effect.skipIf(!symlinksSupported)(
515 "uses Effect FileSystem realPath when detecting pnpm global symlinks",
516 () =>
517 Effect.gen(function* () {
518 const tempDir = yield* makeTempDir("t3-pnpm-realpath-capabilities");
519 const binDir = NodePath.join(tempDir, "bin");
520 const packageBinDir = NodePath.join(
521 tempDir,
522 ".local",
523 "share",
524 "pnpm",
525 "global",
526 "5",
527 "node_modules",
528 "@example",
529 "package-tool",
530 "bin",
531 );
532 NodeFS.mkdirSync(binDir, { recursive: true });
533 NodeFS.mkdirSync(packageBinDir, { recursive: true });
534 const packageBinPath = NodePath.join(packageBinDir, "package-tool.js");
535 const symlinkPath = NodePath.join(binDir, "package-tool");
536 NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n");
537 NodeFS.chmodSync(packageBinPath, 0o755);
538 NodeFS.symlinkSync(packageBinPath, symlinkPath);
538539
539 expect(capabilities).toEqual({
540 provider: driver("packageTool"),
541 packageName: "@example/package-tool",
542 update: {
543 command: "pnpm add -g @example/package-tool@latest",
540 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
541 packageToolUpdate,
542 {
543 binaryPath: symlinkPath,
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",
550 },
551 });
552 }),
558 args: ["add", "-g", "@example/package-tool@latest"],
559
560 lockKey: "pnpm-global",
561 },
562 });
563 }),
553564 );
554565
555566 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 {
199199 type TransferBudgetRun,
200200 transferBudgetViolations,
201201} from "../integration/TransferBudgetReport.integration.ts";
202import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
202203
203204const defaultProjectId = ProjectId.make("project-default");
204205const defaultThreadId = ThreadId.make("thread-default");
......@@ -6311,7 +6312,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
63116312 }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
63126313 );
63136314
6314 it.effect("preserves structured workspace rpc failures", () =>
6315 it.effect.skipIf(!symlinksSupported)("preserves structured workspace rpc failures", () =>
63156316 Effect.gen(function* () {
63166317 const fs = yield* FileSystem.FileSystem;
63176318 const path = yield* Path.Path;
apps/server/src/sourceControl/PrTemplateDetection.test.ts+80-73
......@@ -10,6 +10,7 @@ import { ServerConfig } from "../config.ts";
1010import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
1111import * as VcsProcess from "../vcs/VcsProcess.ts";
1212import { detectPrTemplate } from "./PrTemplateDetection.ts";
13import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1314
1415const SINGLE_TEMPLATE_PATHS = [
1516 ".github/pull_request_template.md",
......@@ -139,27 +140,29 @@ it.effect.each(TEMPLATE_DIRECTORIES)("recognizes the $0 directory", (relativeDir
139140 ),
140141);
141142
142it.effect("skips unusable directory entries and uses the one valid template", () =>
143 runWithTempDirectory((cwd) =>
144 Effect.gen(function* () {
145 const fileSystem = yield* FileSystem.FileSystem;
146 const path = yield* Path.Path;
147 const templateDirectory = path.join(cwd, ".github", "PULL_REQUEST_TEMPLATE");
148 yield* fileSystem.makeDirectory(path.join(templateDirectory, "b-directory.md"), {
149 recursive: true,
150 });
151 yield* fileSystem.writeFileString(path.join(templateDirectory, "a-empty.md"), " \n");
152 yield* fileSystem.symlink(
153 path.join(templateDirectory, "missing.md"),
154 path.join(templateDirectory, "c-broken.md"),
155 );
156 yield* fileSystem.writeFileString(path.join(templateDirectory, "z-valid.md"), "valid");
157 yield* commitTemplates(cwd);
158
159 const template = yield* detectTemplate(cwd);
160 assert.strictEqual(Option.getOrUndefined(template), "valid");
161 }),
162 ),
143it.effect.skipIf(!symlinksSupported)(
144 "skips unusable directory entries and uses the one valid template",
145 () =>
146 runWithTempDirectory((cwd) =>
147 Effect.gen(function* () {
148 const fileSystem = yield* FileSystem.FileSystem;
149 const path = yield* Path.Path;
150 const templateDirectory = path.join(cwd, ".github", "PULL_REQUEST_TEMPLATE");
151 yield* fileSystem.makeDirectory(path.join(templateDirectory, "b-directory.md"), {
152 recursive: true,
153 });
154 yield* fileSystem.writeFileString(path.join(templateDirectory, "a-empty.md"), " \n");
155 yield* fileSystem.symlink(
156 path.join(templateDirectory, "missing.md"),
157 path.join(templateDirectory, "c-broken.md"),
158 );
159 yield* fileSystem.writeFileString(path.join(templateDirectory, "z-valid.md"), "valid");
160 yield* commitTemplates(cwd);
161
162 const template = yield* detectTemplate(cwd);
163 assert.strictEqual(Option.getOrUndefined(template), "valid");
164 }),
165 ),
163166);
164167
165168it.effect("does not guess between multiple directory templates", () =>
......@@ -176,60 +179,64 @@ it.effect("does not guess between multiple directory templates", () =>
176179 ),
177180);
178181
179it.effect("rejects a committed template symlink escaping the repository", () =>
180 runWithTempDirectory((cwd) =>
181 Effect.gen(function* () {
182 const fileSystem = yield* FileSystem.FileSystem;
183 const path = yield* Path.Path;
184 const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({
185 prefix: "t3-pr-template-outside-",
186 });
187 const outsideTemplate = path.join(outsideDirectory, "secret.md");
188 yield* fileSystem.writeFileString(outsideTemplate, "LOCAL_SECRET_SENTINEL");
189 const escapedTemplatePath = path.join(cwd, ".github", "pull_request_template.md");
190 yield* fileSystem.makeDirectory(path.dirname(escapedTemplatePath), { recursive: true });
191 yield* fileSystem.symlink(outsideTemplate, escapedTemplatePath);
192 yield* writeTemplate(cwd, "pull_request_template.md", "safe template");
193 yield* commitTemplates(cwd);
194
195 const template = yield* detectTemplate(cwd);
196 assert.strictEqual(Option.getOrUndefined(template), "safe template");
197 assert.notInclude(
198 Option.getOrElse(template, () => ""),
199 "LOCAL_SECRET_SENTINEL",
200 );
201 }),
202 ),
182it.effect.skipIf(!symlinksSupported)(
183 "rejects a committed template symlink escaping the repository",
184 () =>
185 runWithTempDirectory((cwd) =>
186 Effect.gen(function* () {
187 const fileSystem = yield* FileSystem.FileSystem;
188 const path = yield* Path.Path;
189 const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({
190 prefix: "t3-pr-template-outside-",
191 });
192 const outsideTemplate = path.join(outsideDirectory, "secret.md");
193 yield* fileSystem.writeFileString(outsideTemplate, "LOCAL_SECRET_SENTINEL");
194 const escapedTemplatePath = path.join(cwd, ".github", "pull_request_template.md");
195 yield* fileSystem.makeDirectory(path.dirname(escapedTemplatePath), { recursive: true });
196 yield* fileSystem.symlink(outsideTemplate, escapedTemplatePath);
197 yield* writeTemplate(cwd, "pull_request_template.md", "safe template");
198 yield* commitTemplates(cwd);
199
200 const template = yield* detectTemplate(cwd);
201 assert.strictEqual(Option.getOrUndefined(template), "safe template");
202 assert.notInclude(
203 Option.getOrElse(template, () => ""),
204 "LOCAL_SECRET_SENTINEL",
205 );
206 }),
207 ),
203208);
204209
205it.effect("reads the committed template when a worktree parent is replaced", () =>
206 runWithTempDirectory((cwd) =>
207 Effect.gen(function* () {
208 const fileSystem = yield* FileSystem.FileSystem;
209 const path = yield* Path.Path;
210 const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({
211 prefix: "t3-pr-template-outside-",
212 });
213 const templatePath = yield* writeTemplate(
214 cwd,
215 ".github/pull_request_template.md",
216 "committed template",
217 );
218 yield* commitTemplates(cwd);
219 yield* writeTemplate(outsideDirectory, "pull_request_template.md", "LOCAL_SECRET_SENTINEL");
220
221 const templateDirectory = path.dirname(templatePath);
222 yield* fileSystem.rename(templateDirectory, path.join(cwd, ".github-original"));
223 yield* fileSystem.symlink(outsideDirectory, templateDirectory);
224
225 const template = yield* detectTemplate(cwd);
226 assert.strictEqual(Option.getOrUndefined(template), "committed template");
227 assert.notInclude(
228 Option.getOrElse(template, () => ""),
229 "LOCAL_SECRET_SENTINEL",
230 );
231 }),
232 ),
210it.effect.skipIf(!symlinksSupported)(
211 "reads the committed template when a worktree parent is replaced",
212 () =>
213 runWithTempDirectory((cwd) =>
214 Effect.gen(function* () {
215 const fileSystem = yield* FileSystem.FileSystem;
216 const path = yield* Path.Path;
217 const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({
218 prefix: "t3-pr-template-outside-",
219 });
220 const templatePath = yield* writeTemplate(
221 cwd,
222 ".github/pull_request_template.md",
223 "committed template",
224 );
225 yield* commitTemplates(cwd);
226 yield* writeTemplate(outsideDirectory, "pull_request_template.md", "LOCAL_SECRET_SENTINEL");
227
228 const templateDirectory = path.dirname(templatePath);
229 yield* fileSystem.rename(templateDirectory, path.join(cwd, ".github-original"));
230 yield* fileSystem.symlink(outsideDirectory, templateDirectory);
231
232 const template = yield* detectTemplate(cwd);
233 assert.strictEqual(Option.getOrUndefined(template), "committed template");
234 assert.notInclude(
235 Option.getOrElse(template, () => ""),
236 "LOCAL_SECRET_SENTINEL",
237 );
238 }),
239 ),
233240);
234241
235242it.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";
2727import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts";
2828import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
2929import * as GitWorkflowService from "../git/GitWorkflowService.ts";
30import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
3031
3132const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
3233
......@@ -144,7 +145,7 @@ function makeBackgroundPolicyLayer(shouldRunScopeWork: (scope: BackgroundScope)
144145}
145146
146147describe("VcsStatusBroadcaster", () => {
147 it.effect(
148 it.effect.skipIf(!symlinksSupported)(
148149 "automatically pulls an enabled clean default branch when status detects it is behind",
149150 () => {
150151 let remoteStatus: VcsStatusRemoteResult = { ...baseRemoteStatus, behindCount: 2 };
......@@ -522,67 +523,70 @@ describe("VcsStatusBroadcaster", () => {
522523 }).pipe(Effect.provide(makeTestLayer(state)));
523524 });
524525
525 it.effect("normalizes symlinked CWDs before cache lookup and workflow calls", () => {
526 const seenCwds: string[] = [];
527 const state = {
528 currentLocalStatus: baseLocalStatus,
529 currentRemoteStatus: baseRemoteStatus,
530 localStatusCalls: 0,
531 remoteStatusCalls: 0,
532 localInvalidationCalls: 0,
533 remoteInvalidationCalls: 0,
534 };
535 const testLayer = VcsStatusBroadcaster.layer.pipe(
536 Layer.provideMerge(NodeServices.layer),
537 Layer.provide(makeBackgroundPolicyLayer(() => true)),
538 Layer.provide(
539 Layer.mock(GitWorkflowService.GitWorkflowService)({
540 localStatus: (input) =>
541 Effect.sync(() => {
542 seenCwds.push(input.cwd);
543 state.localStatusCalls += 1;
544 return state.currentLocalStatus;
545 }),
546 remoteStatus: (input) =>
547 Effect.sync(() => {
548 seenCwds.push(input.cwd);
549 state.remoteStatusCalls += 1;
550 return state.currentRemoteStatus;
551 }),
552 invalidateLocalStatus: () =>
553 Effect.sync(() => {
554 state.localInvalidationCalls += 1;
555 }),
556 invalidateRemoteStatus: () =>
557 Effect.sync(() => {
558 state.remoteInvalidationCalls += 1;
559 }),
560 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
561 ),
562 );
526 it.effect.skipIf(!symlinksSupported)(
527 "normalizes symlinked CWDs before cache lookup and workflow calls",
528 () => {
529 const seenCwds: string[] = [];
530 const state = {
531 currentLocalStatus: baseLocalStatus,
532 currentRemoteStatus: baseRemoteStatus,
533 localStatusCalls: 0,
534 remoteStatusCalls: 0,
535 localInvalidationCalls: 0,
536 remoteInvalidationCalls: 0,
537 };
538 const testLayer = VcsStatusBroadcaster.layer.pipe(
539 Layer.provideMerge(NodeServices.layer),
540 Layer.provide(makeBackgroundPolicyLayer(() => true)),
541 Layer.provide(
542 Layer.mock(GitWorkflowService.GitWorkflowService)({
543 localStatus: (input) =>
544 Effect.sync(() => {
545 seenCwds.push(input.cwd);
546 state.localStatusCalls += 1;
547 return state.currentLocalStatus;
548 }),
549 remoteStatus: (input) =>
550 Effect.sync(() => {
551 seenCwds.push(input.cwd);
552 state.remoteStatusCalls += 1;
553 return state.currentRemoteStatus;
554 }),
555 invalidateLocalStatus: () =>
556 Effect.sync(() => {
557 state.localInvalidationCalls += 1;
558 }),
559 invalidateRemoteStatus: () =>
560 Effect.sync(() => {
561 state.remoteInvalidationCalls += 1;
562 }),
563 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
564 ),
565 );
563566
564 return Effect.gen(function* () {
565 const fileSystem = yield* FileSystem.FileSystem;
566 const path = yield* Path.Path;
567 const realDir = yield* fileSystem.makeTempDirectoryScoped({
568 prefix: "t3-vcs-status-real-",
569 });
570 const linkParent = yield* fileSystem.makeTempDirectoryScoped({
571 prefix: "t3-vcs-status-link-",
572 });
573 const linkDir = path.join(linkParent, "repo-link");
574 yield* fileSystem.symlink(realDir, linkDir);
575 const realPath = yield* fileSystem.realPath(realDir);
567 return Effect.gen(function* () {
568 const fileSystem = yield* FileSystem.FileSystem;
569 const path = yield* Path.Path;
570 const realDir = yield* fileSystem.makeTempDirectoryScoped({
571 prefix: "t3-vcs-status-real-",
572 });
573 const linkParent = yield* fileSystem.makeTempDirectoryScoped({
574 prefix: "t3-vcs-status-link-",
575 });
576 const linkDir = path.join(linkParent, "repo-link");
577 yield* fileSystem.symlink(realDir, linkDir);
578 const realPath = yield* fileSystem.realPath(realDir);
576579
577 const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
578 yield* broadcaster.getStatus({ cwd: linkDir });
579 yield* broadcaster.getStatus({ cwd: realDir });
580 const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
581 yield* broadcaster.getStatus({ cwd: linkDir });
582 yield* broadcaster.getStatus({ cwd: realDir });
580583
581 assert.deepStrictEqual(seenCwds, [realPath, realPath]);
582 assert.equal(state.localStatusCalls, 1);
583 assert.equal(state.remoteStatusCalls, 1);
584 }).pipe(Effect.provide(testLayer));
585 });
584 assert.deepStrictEqual(seenCwds, [realPath, realPath]);
585 assert.equal(state.localStatusCalls, 1);
586 assert.equal(state.remoteStatusCalls, 1);
587 }).pipe(Effect.provide(testLayer));
588 },
589 );
586590
587591 it.effect("streams a local snapshot first and remote updates later", () => {
588592 const state = {
apps/server/src/workspace/WorkspaceFileSystem.test.ts+31-28
......@@ -14,6 +14,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";
1414import * as WorkspaceEntries from "./WorkspaceEntries.ts";
1515import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts";
1616import * as WorkspacePaths from "./WorkspacePaths.ts";
17import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1718
1819const ProjectLayer = WorkspaceFileSystem.layer.pipe(
1920 Layer.provide(WorkspacePaths.layer),
......@@ -138,34 +139,36 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
138139 }),
139140 );
140141
141 it.effect("rejects symlinks that resolve outside the workspace root", () =>
142 Effect.gen(function* () {
143 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
144 const fileSystem = yield* FileSystem.FileSystem;
145 const path = yield* Path.Path;
146 const cwd = yield* makeTempDir;
147 const outsideDir = yield* makeTempDir;
148 yield* writeTextFile(outsideDir, "secret.txt", "outside\n");
149 yield* fileSystem.symlink(
150 path.join(outsideDir, "secret.txt"),
151 path.join(cwd, "linked-secret.txt"),
152 );
153
154 const error = yield* workspaceFileSystem
155 .readFile({ cwd, relativePath: "linked-secret.txt" })
156 .pipe(Effect.flip);
157 const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd);
158 const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt"));
159
160 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError);
161 expect(error).toMatchObject({
162 workspaceRoot: cwd,
163 relativePath: "linked-secret.txt",
164 resolvedWorkspaceRoot,
165 resolvedPath,
166 });
167 expect("cause" in error).toBe(false);
168 }),
142 it.effect.skipIf(!symlinksSupported)(
143 "rejects symlinks that resolve outside the workspace root",
144 () =>
145 Effect.gen(function* () {
146 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
147 const fileSystem = yield* FileSystem.FileSystem;
148 const path = yield* Path.Path;
149 const cwd = yield* makeTempDir;
150 const outsideDir = yield* makeTempDir;
151 yield* writeTextFile(outsideDir, "secret.txt", "outside\n");
152 yield* fileSystem.symlink(
153 path.join(outsideDir, "secret.txt"),
154 path.join(cwd, "linked-secret.txt"),
155 );
156
157 const error = yield* workspaceFileSystem
158 .readFile({ cwd, relativePath: "linked-secret.txt" })
159 .pipe(Effect.flip);
160 const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd);
161 const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt"));
162
163 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError);
164 expect(error).toMatchObject({
165 workspaceRoot: cwd,
166 relativePath: "linked-secret.txt",
167 resolvedWorkspaceRoot,
168 resolvedPath,
169 });
170 expect("cause" in error).toBe(false);
171 }),
169172 );
170173
171174 it.effect("rejects directories without manufacturing an I/O cause", () =>
packages/shared/package.json+4
......@@ -223,6 +223,10 @@
223223 "types": "./src/hostProcess.ts",
224224 "import": "./src/hostProcess.ts"
225225 },
226 "./testing/symlinks": {
227 "types": "./src/testing/symlinks.ts",
228 "import": "./src/testing/symlinks.ts"
229 },
226230 "./httpReadiness": {
227231 "types": "./src/httpReadiness.ts",
228232 "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 {
8787} from "./build-desktop-artifact.ts";
8888import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
8989import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
90import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
9091
9192// A minimal stand-in for the staged sidecar roots packed into the WSL archive.
9293const stageWslRuntimeTreeFixture = Effect.fn("stageWslRuntimeTreeFixture")(function* (
......@@ -2191,7 +2192,7 @@ it("keeps the prefix of a UNC path instead of going relative", () => {
21912192 assert.deepStrictEqual(paths[0], "\\\\server\\share\\tmp\\node_modules");
21922193});
21932194
2194it.effect("rebases packaged links into the isolated tree", () =>
2195it.effect.skipIf(!symlinksSupported)("rebases packaged links into the isolated tree", () =>
21952196 Effect.gen(function* () {
21962197 const fs = yield* FileSystem.FileSystem;
21972198 const path = yield* Path.Path;
scripts/mock-update-server.test.ts+30-27
......@@ -7,6 +7,7 @@ import * as Path from "effect/Path";
77import { HttpClient, HttpRouter } from "effect/unstable/http";
88
99import { makeMockUpdateRouteLayer } from "./mock-update-server.ts";
10import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1011
1112const withMockUpdateServer = <A, E, R>(rootRealPath: string, effect: Effect.Effect<A, E, R>) =>
1213 effect.pipe(
......@@ -72,35 +73,37 @@ it.layer(NodeServices.layer)("mock-update-server", (it) => {
7273 }),
7374 );
7475
75 it.effect("rejects symlinked files that escape the configured root", () =>
76 Effect.gen(function* () {
77 const fileSystem = yield* FileSystem.FileSystem;
78 const path = yield* Path.Path;
79 const root = yield* fileSystem.makeTempDirectoryScoped({
80 prefix: "mock-update-server-root-",
81 });
82 const outside = yield* fileSystem.makeTempDirectoryScoped({
83 prefix: "mock-update-server-outside-",
84 });
85 const rootRealPath = yield* fileSystem.realPath(root);
86 const outsideFile = path.join(outside, "outside.yml");
87 const linksDir = path.join(root, "links");
88 const symlinkPath = path.join(linksDir, "outside.yml");
76 it.effect.skipIf(!symlinksSupported)(
77 "rejects symlinked files that escape the configured root",
78 () =>
79 Effect.gen(function* () {
80 const fileSystem = yield* FileSystem.FileSystem;
81 const path = yield* Path.Path;
82 const root = yield* fileSystem.makeTempDirectoryScoped({
83 prefix: "mock-update-server-root-",
84 });
85 const outside = yield* fileSystem.makeTempDirectoryScoped({
86 prefix: "mock-update-server-outside-",
87 });
88 const rootRealPath = yield* fileSystem.realPath(root);
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");
91 yield* fileSystem.makeDirectory(linksDir, { recursive: true });
92 yield* fileSystem.symlink(outsideFile, symlinkPath);
93 yield* fileSystem.writeFileString(outsideFile, "version: outside\n");
94 yield* fileSystem.makeDirectory(linksDir, { recursive: true });
95 yield* fileSystem.symlink(outsideFile, symlinkPath);
9396
94 yield* withMockUpdateServer(
95 rootRealPath,
96 Effect.gen(function* () {
97 const client = yield* HttpClient.HttpClient;
98 const response = yield* client.get("/links/outside.yml");
97 yield* withMockUpdateServer(
98 rootRealPath,
99 Effect.gen(function* () {
100 const client = yield* HttpClient.HttpClient;
101 const response = yield* client.get("/links/outside.yml");
99102
100 assert.equal(response.status, 404);
101 assert.equal(yield* response.text, "Not Found");
102 }),
103 );
104 }),
103 assert.equal(response.status, 404);
104 assert.equal(yield* response.text, "Not Found");
105 }),
106 );
107 }),
105108 );
106109});