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