From 353d5a0251c4a75729790d53b2c6fc0b57c3f747 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 12 Nov 2026 21:57:55 -0300 Subject: [PATCH 1/2] feat(typegen): build supabase-typegen from source at the pinned supabase-swift commit supabase gen types --lang swift now builds supabase-typegen from the supabase-swift commit in the app's nearest Package.resolved, with the generator's committed dependency versions and the user's toolchain, instead of downloading the prebuilt release binary. The binary is cached per commit. Linux builds link the Swift runtime statically, and the build steps remove git's repository variables so a git hook cannot point them at the user's repository. Fixes SDK-3043 --- packages/typegen/CLAUDE.md | 21 +- packages/typegen/README.md | 49 ++- packages/typegen/src/contract.ts | 5 +- packages/typegen/src/errors.ts | 5 +- packages/typegen/src/languages/external.ts | 3 +- packages/typegen/src/languages/swift.ts | 363 ++++++++++----- packages/typegen/test/swift.test.ts | 487 ++++++++++++++------- 7 files changed, 646 insertions(+), 287 deletions(-) diff --git a/packages/typegen/CLAUDE.md b/packages/typegen/CLAUDE.md index b95eac8..783b679 100644 --- a/packages/typegen/CLAUDE.md +++ b/packages/typegen/CLAUDE.md @@ -39,13 +39,15 @@ lives in supabase-js. - `src/languages/dart.ts` -- the first out-of-process entry: `dart run supabase_typegen --output -`. No `--schema` flag: the document on stdin already holds the schemas the consumer introspected. -- `src/languages/swift.ts` -- `supabase-typegen --access-control `, a - prebuilt binary from supabase-swift's releases (SDK-2199). It downloads the - release the nearest `Package.resolved` pins into the user cache, then falls - back to PATH; `SUPABASE_TYPEGEN` overrides both. The only entry that touches - the network or the file system; `createSwift` takes `fetch` and the platform - so tests stay offline. `swift-access-control` maps to `--access-control` and - accepts only `internal` and `public`. Exit `65` is a rejected document. +- `src/languages/swift.ts` -- `supabase-typegen --access-control `, built + from source (SDK-3043) at the supabase-swift commit the nearest + `Package.resolved` pins, with the user's `git` and `swift`, and cached per + commit; then falls back to PATH; `SUPABASE_TYPEGEN` overrides both. The build + steps run through `host.spawn` with a temporary folder as `cwd`. The only + entry that touches the network or the file system; `createSwift` takes the + platform, the temporary directory and the progress `log` so tests stay + hermetic. `swift-access-control` maps to `--access-control` and accepts only + `internal` and `public`. Exit `65` is a rejected document. - `src/languages/index.ts` -- the three in-process entries and the `languages` list. `typescript` has no user options; `detect-one-to-one-relationships` (default true; the CLI's deprecated `--postgrest-v9-compat` sets it false), @@ -107,5 +109,6 @@ with `parseGeneratorMetadata`. `test/in-process.test.ts` asserts each bundled language's output equals the direct generator call on sorted input (the byte-identity acceptance criterion, without Docker). `test/dart.test.ts` and `test/swift.test.ts` cover the handoff and every error classification with the -fake host from `test/helpers.ts`; `test/node-host.test.ts` runs a fake `dart` -shell script on PATH. +fake host from `test/helpers.ts`. `test/swift.test.ts` also runs the source +build through `createNodeHost` against fake `git` and `swift` shell scripts on +PATH; `test/node-host.test.ts` runs a fake `dart` shell script on PATH. diff --git a/packages/typegen/README.md b/packages/typegen/README.md index 942f3f2..81889e5 100644 --- a/packages/typegen/README.md +++ b/packages/typegen/README.md @@ -62,7 +62,7 @@ not. | `typescript` | in-process | `generateTypescript` from `@supabase/postgrest-typegen` | consumer: `detect-one-to-one-relationships`, `postgrest-version`, `default-schema` | | `go` | in-process | `generateGo` from `@supabase/postgrest-typegen` | | | `python` | in-process | `generatePython` from `@supabase/postgrest-typegen` | | -| `swift` | out-of-process | `supabase-typegen --access-control `, fetched | `--swift-access-control internal\|public` | +| `swift` | out-of-process | `supabase-typegen --access-control `, built | `--swift-access-control internal\|public` | | `dart` | out-of-process | `dart run supabase_typegen --output -` in the project | | The three in-process entries are a transition: as each generator relocates to @@ -70,19 +70,46 @@ its SDK repository (SDK-1641), its entry here changes to an out-of-process command, or for TypeScript to an import of the new npm package, and consumers notice nothing but a dependency bump. -Swift moved first. `supabase-typegen` is a prebuilt executable from the -supabase-swift releases, not a package dependency: as a SwiftPM product its +Swift moved first. `supabase-typegen` is an executable in supabase-swift's +`tools/supabase-typegen`, not a package dependency: as a SwiftPM product its swift-format dependency would pin every app's swift-syntax, and an Xcode project without a `Package.swift` has nothing to `swift run` from. -The output targets one version's `@Table` macro, so the binary must match the -project's supabase-swift. The entry reads that version from the nearest -`Package.resolved` (in `cwd` or a parent; SwiftPM's, or Xcode's inside the -`.xcodeproj` or `.xcworkspace`), downloads that release's asset once into -`$XDG_CACHE_HOME/supabase/typegen//` (default `~/Library/Caches` on -macOS, `~/.cache` elsewhere) and runs it. Without a version pin, or on a -platform with no asset (anything but macOS and Linux x86_64), it runs -`supabase-typegen` from PATH. `SUPABASE_TYPEGEN=` overrides both. +The output targets one commit's `@Table` macro, so the generator must match the +project's supabase-swift. The entry reads the supabase-swift pin from the +nearest `Package.resolved` (in `cwd` or a parent; SwiftPM's, or Xcode's inside +the `.xcodeproj` or `.xcworkspace`) and builds the generator from source at the +pin's `revision`, so version, branch and revision pins all work: + +1. The revision must be a 40-character lowercase hex SHA; nothing else reaches + `git`. +2. A binary already in `$XDG_CACHE_HOME/supabase/typegen//` (default + `~/Library/Caches` on macOS, `~/.cache` elsewhere; an empty or relative + `XDG_CACHE_HOME` is ignored) runs with no build. +3. Otherwise it checks that `git` and `swift` (Swift 6.2 / Xcode 26 or newer) + are on PATH, fetches the single commit into a temporary folder, checks that + `HEAD` is the revision, and runs + `swift build -c release --package-path tools/supabase-typegen --disable-automatic-resolution`. + The flag builds the generator's committed `Package.resolved` exactly and + fails rather than resolve other dependency versions. Linux adds + `--static-swift-stdlib`, so the cached binary keeps working after the + toolchain that built it is removed or upgraded. Git's repository variables + (`GIT_DIR`, `GIT_INDEX_FILE` and the rest of + `git rev-parse --local-env-vars`) are removed from these steps, so running + inside a git hook cannot touch the user's repository. +4. For a version pin, `supabase-typegen --version` must print that version. +5. The binary is copied beside its cache path and renamed into place, so + concurrent runs are safe. The temporary folder is removed in every case. + +The first build prints a line to stderr and takes a few minutes, mostly +compiling swift-syntax: about 5.5 minutes on an Apple M3 with Swift 6.4, and +about the same on Linux aarch64 with Swift 6.2 in Docker on that machine. It +trusts nothing the app does not already run: the supabase-swift source at the +pinned commit, the generator's pinned dependencies and the user's toolchain. +supabase-swift 2.x has no `tools/supabase-typegen` and fails with a request to +update to 3.0.0-alpha.1 or newer. Without a supabase-swift pin, or on Windows, +the entry runs `supabase-typegen` from PATH. `SUPABASE_TYPEGEN=` overrides +both. `--swift-access-control` lost `private` and `package`, which the new generator does not accept. diff --git a/packages/typegen/src/contract.ts b/packages/typegen/src/contract.ts index 4cd8eb5..c82c625 100644 --- a/packages/typegen/src/contract.ts +++ b/packages/typegen/src/contract.ts @@ -8,7 +8,10 @@ export interface SpawnRequest { /** Executable name, resolved against `PATH` by the host (for example `dart`). */ readonly command: string; readonly args: readonly string[]; - /** Directory to run in; always the host's `cwd`, the user's project. */ + /** + * Directory to run in: the host's `cwd`, the user's project, for a + * generator, or a directory the language entry chose for a setup step. + */ readonly cwd: string; readonly env: Readonly>; /** Complete document to write to the child's stdin before closing it. */ diff --git a/packages/typegen/src/errors.ts b/packages/typegen/src/errors.ts index a1cd673..06f6ea0 100644 --- a/packages/typegen/src/errors.ts +++ b/packages/typegen/src/errors.ts @@ -61,7 +61,10 @@ export class SpawnUnavailableError extends TypegenError { } } -/** An out-of-process generator exited unsuccessfully. */ +/** + * An out-of-process generator, or a step that builds one, exited + * unsuccessfully. + */ export class ToolFailedError extends TypegenError { readonly command: readonly string[]; readonly exitCode: number | null; diff --git a/packages/typegen/src/languages/external.ts b/packages/typegen/src/languages/external.ts index 961b395..4c8a3f9 100644 --- a/packages/typegen/src/languages/external.ts +++ b/packages/typegen/src/languages/external.ts @@ -44,7 +44,8 @@ export interface ExternalTool { classify?(result: SpawnResult): ToolFailure | undefined; } -const isCommandNotFound = (error: unknown): boolean => +/** Whether `Host.spawn` rejected because the command is not on PATH. */ +export const isCommandNotFound = (error: unknown): boolean => typeof error === "object" && error !== null && "code" in error && diff --git a/packages/typegen/src/languages/swift.ts b/packages/typegen/src/languages/swift.ts index 4d556b6..7adde94 100644 --- a/packages/typegen/src/languages/swift.ts +++ b/packages/typegen/src/languages/swift.ts @@ -1,23 +1,40 @@ import { existsSync } from "node:fs"; import { chmod, + copyFile, mkdir, + mkdtemp, readdir, readFile, rename, - writeFile, + rm, } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { gunzipSync } from "node:zlib"; -import type { ChoiceOptionSpec, Host, TypegenLanguage } from "../contract.ts"; -import { ToolNotInstalledError, TypegenError } from "../errors.ts"; +import { homedir, tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import type { + ChoiceOptionSpec, + Host, + SpawnRequest, + SpawnResult, + TypegenLanguage, +} from "../contract.ts"; +import { + ToolFailedError, + ToolNotInstalledError, + TypegenError, +} from "../errors.ts"; import { resolveOptions } from "../options.ts"; -import { externalLanguage } from "./external.ts"; +import { externalLanguage, isCommandNotFound } from "./external.ts"; const SWIFT_ACCESS_CONTROL = "swift-access-control"; const BINARY = "supabase-typegen"; -const RELEASES = "https://github.com/supabase/supabase-swift/releases"; +const SOURCE = "https://github.com/supabase/supabase-swift"; const REPOSITORY = /github\.com[/:]supabase\/supabase-swift(\.git)?\/?$/i; +const PACKAGE_PATH = "tools/supabase-typegen"; +const TOOLCHAIN = "Swift 6.2 / Xcode 26"; +const FIRST_RELEASE = "3.0.0-alpha.1"; +/** The only revision form ever passed to git, so a pin cannot smuggle in an option. */ +const COMMIT = /^[0-9a-f]{40}$/; /** * The flag `supabase gen types` already exposes, narrowed to the two levels @@ -33,19 +50,15 @@ const swiftAccessControl = { help: "Access control for Swift generated types.", } satisfies ChoiceOptionSpec; -const installHint = `Set SUPABASE_TYPEGEN to a \`${BINARY}\` binary, or put one on PATH. Download it from the supabase-swift release your project depends on (${RELEASES}).`; - -/** Release asset names, keyed by `process.platform` and `process.arch`. */ -const assets: Readonly> = { - "darwin-arm64": "supabase-typegen-macos-universal.tar.gz", - "darwin-x64": "supabase-typegen-macos-universal.tar.gz", - "linux-x64": "supabase-typegen-linux-x86_64.tar.gz", -}; +const installHint = `Install git and ${TOOLCHAIN} or newer so \`${BINARY}\` can be built from the supabase-swift commit your project pins, or set SUPABASE_TYPEGEN to a \`${BINARY}\` binary, or put one on PATH.`; export interface SwiftDependencies { + /** `process.platform`. Windows skips the source build. */ readonly platform: string; - readonly arch: string; - readonly fetch: (url: string, init?: RequestInit) => Promise; + /** Where the source build makes its scratch checkout. */ + readonly temporaryDirectory: () => string; + /** Progress the user should see while the generator builds. */ + readonly log: (message: string) => void; } /** @@ -53,24 +66,28 @@ export interface SwiftDependencies { * on stdin and writes one file of `@Table` structs to stdout. * * The generated code targets the `@Table` macro of one supabase-swift - * version, so the binary must come from the same release as the project's - * dependency. The entry reads that version from the nearest - * `Package.resolved` (SwiftPM, or Xcode's copy inside the `.xcodeproj` or - * `.xcworkspace`), downloads the release's binary once into the user's cache - * and runs it. Without a version pin, or on a platform with no release asset, - * it runs `supabase-typegen` from PATH. `SUPABASE_TYPEGEN` overrides both. + * commit, so the generator must come from the same commit as the project's + * dependency. The entry reads that commit from the nearest `Package.resolved` + * (SwiftPM, or Xcode's copy inside the `.xcodeproj` or `.xcworkspace`), + * builds the generator there from source once with the user's own `git` and + * `swift`, caches the binary per commit and runs it. Without a supabase-swift + * pin, or on Windows, it runs `supabase-typegen` from PATH. + * `SUPABASE_TYPEGEN` overrides both. * - * It is a binary, not a package dependency, because its swift-format - * dependency would pin every app's swift-syntax, and because an Xcode project - * without a `Package.swift` has nothing to `swift run` from. No `--schema` is - * passed: the document already holds exactly the schemas the consumer - * introspected, and the tool generates all of them. + * A source build trusts nothing beyond what the app already runs: the + * supabase-swift source at the pinned commit, the generator's committed + * `Package.resolved`, and the user's toolchain. It is a separate executable, + * not a package dependency, because its swift-format dependency would pin + * every app's swift-syntax, and because an Xcode project without a + * `Package.swift` has nothing to `swift run` from. No `--schema` is passed: + * the document already holds exactly the schemas the consumer introspected, + * and the tool generates all of them. */ export function createSwift( dependencies: SwiftDependencies = { platform: process.platform, - arch: process.arch, - fetch: (url, init) => fetch(url, init), + temporaryDirectory: tmpdir, + log: (message) => process.stderr.write(`${message}\n`), }, ): TypegenLanguage { const options = [swiftAccessControl]; @@ -99,77 +116,246 @@ export const swift = createSwift(); async function resolveBinary( host: Host, - { platform, arch, fetch }: SwiftDependencies, + dependencies: SwiftDependencies, ): Promise { const override = host.env.SUPABASE_TYPEGEN; if (override) { return override; } - const asset = assets[`${platform}-${arch}`]; - const version = asset ? await pinnedVersion(host.cwd) : undefined; - if (!asset || !version) { + const { spawn } = host; + // Without spawn, externalLanguage reports SpawnUnavailableError for the generator itself. + if (dependencies.platform === "win32" || spawn === undefined) { return BINARY; } - const path = join(cacheDirectory(host.env, platform), version, BINARY); - if (existsSync(path)) { - return path; - } - const url = `${RELEASES}/download/v${version}/${asset}`; - const response = await fetch(url, host.signal ? { signal: host.signal } : {}); - if (response.status === 404) { - throw new ToolNotInstalledError({ - language: "swift", - tool: BINARY, - installHint, - message: `supabase-swift ${version}, which ${host.cwd} depends on, has no \`${BINARY}\` release asset (${url}). Update supabase-swift to a release that ships it, or: ${installHint}`, - }); + const pin = await pinnedState(host.cwd); + if (pin?.revision === undefined) { + return BINARY; } - if (!response.ok) { + const { revision, version } = pin; + if (!COMMIT.test(revision)) { throw new TypegenError({ language: "swift", - message: `Downloading ${url} failed with HTTP ${response.status}. ${installHint}`, + message: `The supabase-swift pin in ${host.cwd}'s Package.resolved has revision "${revision}", which is not a 40-character lowercase hex commit SHA. Resolve the package again, or: ${installHint}`, }); } - const binary = extract( - gunzipSync(new Uint8Array(await response.arrayBuffer())), + const path = join( + cacheDirectory(host.env, dependencies.platform), + revision, BINARY, ); - if (!binary) { - throw new TypegenError({ - language: "swift", - message: `${url} does not contain \`${BINARY}\`. ${installHint}`, - }); + if (!existsSync(path)) { + await build({ host, spawn, dependencies, revision, version, path }); } - // Written beside the target and renamed, so a concurrent run never sees a partial file. - await mkdir(dirname(path), { recursive: true }); - const partial = `${path}.${process.pid}.partial`; - await writeFile(partial, binary); - await chmod(partial, 0o755); - await rename(partial, path); return path; } +interface Build { + readonly host: Host; + readonly spawn: NonNullable; + readonly dependencies: SwiftDependencies; + readonly revision: string; + readonly version: string | undefined; + /** Where the finished binary goes in the cache. */ + readonly path: string; +} + +/** + * Checks out `revision` into a scratch folder, builds the generator with its + * committed dependency versions and moves the binary to `path`. The scratch + * folder is removed whatever happens, and the cache only ever receives a + * binary that built and, for a version pin, reports that version. + */ +async function build({ + host, + spawn, + dependencies, + revision, + version, + path, +}: Build): Promise { + const label = version ?? revision; + const run = async ( + command: string, + args: readonly string[], + cwd: string, + ): Promise => { + const request: SpawnRequest = { + command, + args, + cwd, + env: buildEnvironment(host.env), + stdin: "", + signal: host.signal, + }; + let result: SpawnResult; + try { + result = await spawn(request); + } catch (error) { + if (isCommandNotFound(error)) { + throw new ToolNotInstalledError({ + language: "swift", + tool: command, + installHint, + message: `Building \`${BINARY}\` ${label} from source needs \`${command}\` and ${TOOLCHAIN} or newer, and \`${command}\` was not found on PATH. ${installHint}`, + cause: error, + }); + } + throw error; + } + if (result.exitCode !== 0) { + const commandLine = [command, ...args]; + const output = [result.stdout.trim(), result.stderr.trim()] + .filter(Boolean) + .join("\n"); + throw new ToolFailedError({ + language: "swift", + command: commandLine, + exitCode: result.exitCode, + stderr: result.stderr.trim(), + message: `Building \`${BINARY}\` ${label} from source failed: \`${commandLine.join(" ")}\` ${ + result.exitCode === null + ? "was terminated by a signal" + : `exited with code ${result.exitCode}` + }.${output ? `\n${output}` : ""}`, + }); + } + return result; + }; + + await run("git", ["--version"], host.cwd); + await run("swift", ["--version"], host.cwd); + dependencies.log( + `Building ${BINARY} ${label} from source (supabase-swift ${revision}). This takes a few minutes; later runs use the cached build.`, + ); + const scratch = await mkdtemp( + join(dependencies.temporaryDirectory(), `${BINARY}-`), + ); + // Named after the scratch folder, which is unique, so concurrent runs never share one. + const partial = `${path}.${process.pid}.${basename(scratch)}.partial`; + try { + await run("git", ["init"], scratch); + await run("git", ["fetch", "--depth", "1", SOURCE, revision], scratch); + await run("git", ["checkout", "--detach", "FETCH_HEAD"], scratch); + const head = (await run("git", ["rev-parse", "HEAD"], scratch)).stdout; + if (head.trim() !== revision) { + throw new TypegenError({ + language: "swift", + message: `Fetching supabase-swift ${revision} checked out ${head.trim()} instead.`, + }); + } + if (!existsSync(join(scratch, PACKAGE_PATH, "Package.swift"))) { + throw new ToolNotInstalledError({ + language: "swift", + tool: BINARY, + installHint: `Update supabase-swift to ${FIRST_RELEASE} or newer.`, + message: `supabase-swift ${label} has no ${BINARY}; update to ${FIRST_RELEASE} or newer.`, + }); + } + // --disable-automatic-resolution builds the committed Package.resolved exactly, or fails. + // On Linux the binary would otherwise load the Swift runtime from the toolchain that + // built it, and break for good once that toolchain is removed or upgraded. + await run( + "swift", + [ + "build", + "-c", + "release", + "--package-path", + PACKAGE_PATH, + "--disable-automatic-resolution", + ...(dependencies.platform === "linux" ? ["--static-swift-stdlib"] : []), + ], + scratch, + ); + const built = join(scratch, PACKAGE_PATH, ".build", "release", BINARY); + if (version !== undefined) { + const reported = (await run(built, ["--version"], scratch)).stdout.trim(); + if (reported !== version) { + throw new TypegenError({ + language: "swift", + message: `The \`${BINARY}\` built from supabase-swift ${revision} reports version ${reported}, but Package.resolved pins ${version}.`, + }); + } + } + // Copied beside the target and renamed, so a concurrent run never sees a partial file. + await mkdir(dirname(path), { recursive: true }); + await copyFile(built, partial); + await chmod(partial, 0o755); + await rename(partial, path); + } finally { + await rm(partial, { force: true }); + await rm(scratch, { recursive: true, force: true }); + } +} + +/** + * What git exports to hooks, from `git rev-parse --local-env-vars`. Inherited, + * they would point the scratch checkout, and SwiftPM's own git calls, at the + * user's repository. `undefined` removes a variable even from a host that + * merges `process.env` in, since Node and Bun skip undefined values. + */ +const GIT_REPOSITORY_VARIABLES = [ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CONFIG", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_PARAMETERS", + "GIT_DIR", + "GIT_GRAFT_FILE", + "GIT_IMPLICIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_NO_REPLACE_OBJECTS", + "GIT_OBJECT_DIRECTORY", + "GIT_PREFIX", + "GIT_REPLACE_REF_BASE", + "GIT_SHALLOW_FILE", + "GIT_WORK_TREE", +]; + +function buildEnvironment( + env: Readonly>, +): Record { + return { + ...env, + ...Object.fromEntries( + GIT_REPOSITORY_VARIABLES.map((name) => [name, undefined]), + ), + // A credential prompt would hang the CLI; the repository is public. + GIT_TERMINAL_PROMPT: "0", + }; +} + +/** Relative or empty values are ignored, as the XDG base directory spec asks. */ function cacheDirectory( env: Readonly>, platform: string, ): string { + const absolute = (path: string | undefined) => + path && isAbsolute(path) ? path : undefined; const base = - env.XDG_CACHE_HOME ?? - join(env.HOME ?? "", platform === "darwin" ? "Library/Caches" : ".cache"); + absolute(env.XDG_CACHE_HOME) ?? + join( + absolute(env.HOME) ?? homedir(), + platform === "darwin" ? "Library/Caches" : ".cache", + ); return join(base, "supabase", "typegen"); } +interface PinState { + readonly version?: string; + readonly revision?: string; +} + /** - * The supabase-swift version pinned by the nearest `Package.resolved`, looking - * in `cwd` and then each parent. Returns `undefined` when nothing pins it to a - * version, for example a branch or revision pin. + * The supabase-swift pin of the nearest `Package.resolved` that has one, + * looking in `cwd` and then each parent. */ -async function pinnedVersion(cwd: string): Promise { +async function pinnedState(cwd: string): Promise { for (let directory = cwd; ; directory = dirname(directory)) { for (const file of await resolvedFiles(directory)) { - const version = versionIn(await readFile(file, "utf8")); - if (version) { - return version; + const state = stateIn(await readFile(file, "utf8")); + if (state) { + return state; } } if (dirname(directory) === directory) { @@ -202,11 +388,11 @@ async function resolvedFiles(directory: string): Promise { interface Pin { readonly location?: string; readonly repositoryURL?: string; - readonly state?: { readonly version?: string }; + readonly state?: PinState; } /** Reads every `Package.resolved` format: version 1 nests the pins under `object`. */ -function versionIn(contents: string): string | undefined { +function stateIn(contents: string): PinState | undefined { let parsed: { pins?: Pin[]; object?: { pins?: Pin[] } }; try { parsed = JSON.parse(contents); @@ -216,34 +402,5 @@ function versionIn(contents: string): string | undefined { const pins = parsed.pins ?? parsed.object?.pins ?? []; return pins.find((pin) => REPOSITORY.test(pin.location ?? pin.repositoryURL ?? ""), - )?.state?.version; -} - -/** - * The regular file called `name` in an uncompressed tar archive. Enough of the - * format for the release assets: 512-byte headers, the name at offset 0, the - * size in octal at 124, the type at 156, data padded to 512 bytes. - */ -function extract(archive: Uint8Array, name: string): Uint8Array | undefined { - const text = new TextDecoder(); - for (let offset = 0; offset + 512 <= archive.length;) { - const header = archive.subarray(offset, offset + 512); - const entry = text.decode(header.subarray(0, 100)).replace(/\0.*$/s, ""); - if (entry === "") { - return undefined; - } - const size = Number.parseInt( - text.decode(header.subarray(124, 136)).replace(/\0.*$/s, "").trim() || - "0", - 8, - ); - const type = header[156]; - const start = offset + 512; - // Type "0" or NUL is a regular file; pax ("x", "g") and others are skipped. - if ((type === 0x30 || type === 0) && entry.replace(/^\.\//, "") === name) { - return archive.slice(start, start + size); - } - offset = start + Math.ceil(size / 512) * 512; - } - return undefined; + )?.state; } diff --git a/packages/typegen/test/swift.test.ts b/packages/typegen/test/swift.test.ts index 09ba2ac..f0a1eac 100644 --- a/packages/typegen/test/swift.test.ts +++ b/packages/typegen/test/swift.test.ts @@ -1,9 +1,12 @@ -import { beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { execFileSync } from "node:child_process"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { + existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, + realpathSync, + rmSync, statSync, writeFileSync, } from "node:fs"; @@ -14,6 +17,7 @@ import { sortGeneratorMetadata, } from "@supabase/postgrest-typegen"; import { + createNodeHost, InvalidOptionError, MetadataRejectedError, swift, @@ -76,9 +80,7 @@ describe("swift", () => { expect(error).toBeInstanceOf(ToolNotInstalledError); const notInstalled = error as ToolNotInstalledError; expect(notInstalled.tool).toBe("supabase-typegen"); - expect(notInstalled.installHint).toContain( - "https://github.com/supabase/supabase-swift/releases", - ); + expect(notInstalled.installHint).toContain("SUPABASE_TYPEGEN"); }); test("reports exit 65 as a rejected document", async () => { @@ -109,105 +111,207 @@ describe("swift", () => { }); }); -describe("swift binary resolution", () => { - const binary = "#!/bin/sh\necho generated\n"; - let tarball: Uint8Array; +const REVISION = "0123456789abcdef0123456789abcdef01234567"; + +/** + * Stand-ins for `git` and `swift`, put alone on PATH. Each appends its command + * line to `$FAKE_LOG`. `git` fakes a fetch of the revision it is given, and + * checking it out creates `tools/supabase-typegen` unless `FAKE_NO_TYPEGEN` is + * set. `swift build` writes a `supabase-typegen` that prints + * `$FAKE_TYPEGEN_VERSION` for `--version` and `generated` otherwise. The + * `FAKE_*_FAIL` variables make a step fail the way the real tool does. Both + * fail if a git repository variable reaches them. They + * call coreutils by absolute path because PATH holds only the fakes. + */ +const FAKE_GIT = `#!/bin/sh +echo "git $*" >> "$FAKE_LOG" +if [ -n "$GIT_DIR$GIT_WORK_TREE$GIT_INDEX_FILE$GIT_OBJECT_DIRECTORY" ]; then + echo "a repository variable leaked in" >&2 + exit 3 +fi +case "$1" in + --version) echo "git version 2.50.0" ;; + init) /bin/mkdir .git ;; + fetch) + if [ -n "$FAKE_FETCH_FAIL" ]; then + echo "fatal: remote error: upload-pack: not our ref $5" >&2 + exit 128 + fi + for argument; do revision=$argument; done + echo "$revision" > .git/FAKE_FETCH_HEAD ;; + checkout) + /bin/cp .git/FAKE_FETCH_HEAD .git/FAKE_HEAD + if [ -z "$FAKE_NO_TYPEGEN" ]; then + /bin/mkdir -p tools/supabase-typegen + echo "// swift-tools-version:6.2" > tools/supabase-typegen/Package.swift + fi ;; + rev-parse) if [ -n "$FAKE_HEAD" ]; then echo "$FAKE_HEAD"; else /bin/cat .git/FAKE_HEAD; fi ;; + *) echo "unexpected git $*" >&2; exit 2 ;; +esac +`; + +const FAKE_SWIFT = `#!/bin/sh +echo "swift $*" >> "$FAKE_LOG" +if [ -n "$GIT_DIR$GIT_WORK_TREE$GIT_INDEX_FILE$GIT_OBJECT_DIRECTORY" ]; then + echo "a repository variable leaked in" >&2 + exit 3 +fi +case "$1" in + --version) echo "Swift version 6.2" ;; + build) + if [ -n "$FAKE_BUILD_FAIL" ]; then + echo "Sources/SupabaseTypegen/Typegen.swift:1:1: error: cannot find 'x' in scope" + echo "error: fatalError" >&2 + exit 1 + fi + /bin/mkdir -p tools/supabase-typegen/.build/release + binary=tools/supabase-typegen/.build/release/supabase-typegen + printf '#!/bin/sh\\necho "typegen $*" >> "$FAKE_LOG"\\nif [ "$1" = --version ]; then echo %s; exit 0; fi\\n/bin/cat > /dev/null\\necho generated\\n' "\${FAKE_TYPEGEN_VERSION:-3.0.0-alpha.1}" > "$binary" + /bin/chmod 755 "$binary" ;; + *) echo "unexpected swift $*" >&2; exit 2 ;; +esac +`; + +describe("swift source build", () => { let root: string; + let bin: string; + let scratch: string; + let logFile: string; + let messages: string[]; - beforeAll(() => { - const source = mkdtempSync(join(tmpdir(), "typegen-asset-")); - writeFileSync(join(source, "supabase-typegen"), binary, { mode: 0o755 }); - // The system tar, as the supabase-swift release job uses: bsdtar on macOS adds pax headers. - execFileSync( - "tar", - ["-czf", "asset.tar.gz", "-C", source, "supabase-typegen"], - { - cwd: source, - }, - ); - tarball = new Uint8Array(readFileSync(join(source, "asset.tar.gz"))); + beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), "typegen-swift-"))); + bin = join(root, "bin"); + scratch = join(root, "tmp"); + logFile = join(root, "commands.log"); + messages = []; + mkdirSync(bin); + mkdirSync(scratch); + writeFileSync(logFile, ""); + writeFileSync(join(bin, "git"), FAKE_GIT, { mode: 0o755 }); + writeFileSync(join(bin, "swift"), FAKE_SWIFT, { mode: 0o755 }); }); - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "typegen-swift-")); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); }); - const resolvedV2 = (version: string) => + const pin = (state: Record) => JSON.stringify({ pins: [ { identity: "swift-crypto", location: "https://github.com/apple/swift-crypto.git", - state: { version: "3.0.0" }, + state: { version: "3.0.0", revision: "f".repeat(40) }, }, { identity: "supabase-swift", location: "https://github.com/supabase/supabase-swift.git", - state: { version, revision: "abc" }, + state, }, ], version: 2, }); + const versionPin = (version = "3.0.0-alpha.1") => + pin({ version, revision: REVISION }); + const project = (files: Record) => { const cwd = join(root, "app"); + mkdirSync(cwd, { recursive: true }); for (const [path, contents] of Object.entries(files)) { mkdirSync(dirname(join(cwd, path)), { recursive: true }); writeFileSync(join(cwd, path), contents); } - mkdirSync(cwd, { recursive: true }); return cwd; }; - const setup = ( + const make = (platform = "darwin") => + createSwift({ + platform, + temporaryDirectory: () => scratch, + log: (message) => messages.push(message), + }); + + const run = ( cwd: string, - respond: (url: string) => Response = () => - new Response(tarball, { status: 200 }), env: Record = {}, - ) => { - const urls: string[] = []; - const language = createSwift({ - platform: "darwin", - arch: "arm64", - fetch: async (url) => { - urls.push(String(url)); - return respond(String(url)); - }, - }); - const host = createFakeHost(() => success("code\n"), { - cwd, - env: { HOME: join(root, "home"), ...env }, - }); - return { language, host, urls }; - }; + platform = "darwin", + ) => + make(platform).generate( + unsortedMetadata, + {}, + createNodeHost({ + cwd, + env: { + PATH: bin, + HOME: join(root, "home"), + FAKE_LOG: logFile, + ...env, + }, + }), + ); + + const ran = (prefix: string) => + commands().some((line) => line.startsWith(prefix)); + const commands = () => readFileSync(logFile, "utf8").split("\n").slice(0, -1); + const cached = (cache = "home/Library/Caches") => + join(root, cache, "supabase/typegen", REVISION, "supabase-typegen"); + const leftovers = () => [ + ...readdirSync(scratch), + ...(existsSync(dirname(cached())) ? readdirSync(dirname(cached())) : []), + ]; - test("downloads the release the project's Package.resolved pins and runs it", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host, urls } = setup(cwd); + test("builds the pinned commit from source, caches the binary and runs it", async () => { + const cwd = project({ "Package.resolved": versionPin() }); - expect(await language.generate(unsortedMetadata, {}, host)).toBe("code\n"); + expect(await run(cwd)).toBe("generated\n"); - expect(urls).toEqual([ - "https://github.com/supabase/supabase-swift/releases/download/v2.56.0/supabase-typegen-macos-universal.tar.gz", + expect(commands()).toEqual([ + "git --version", + "swift --version", + "git init", + `git fetch --depth 1 https://github.com/supabase/supabase-swift ${REVISION}`, + "git checkout --detach FETCH_HEAD", + "git rev-parse HEAD", + "swift build -c release --package-path tools/supabase-typegen --disable-automatic-resolution", + "typegen --version", + "typegen --access-control internal", ]); - const command = host.requests[0]!.command; - expect(command).toBe( - join( - root, - "home/Library/Caches/supabase/typegen/2.56.0/supabase-typegen", - ), + expect(statSync(cached()).mode & 0o777).toBe(0o755); + expect(readdirSync(dirname(cached()))).toEqual(["supabase-typegen"]); + expect(readdirSync(scratch)).toEqual([]); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain( + "Building supabase-typegen 3.0.0-alpha.1 from source", ); - expect(readFileSync(command, "utf8")).toBe(binary); - expect(statSync(command).mode & 0o777).toBe(0o755); + expect(messages[0]).toContain("a few minutes"); }); - test("reuses the cached binary", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host, urls } = setup(cwd); - await language.generate(unsortedMetadata, {}, host); - await language.generate(unsortedMetadata, {}, host); - expect(urls).toHaveLength(1); - expect(host.requests).toHaveLength(2); + test("runs the cached binary without git or swift", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + await run(cwd); + writeFileSync(logFile, ""); + messages = []; + + expect(await run(cwd)).toBe("generated\n"); + + expect(commands()).toEqual(["typegen --access-control internal"]); + expect(messages).toEqual([]); + }); + + test("builds a branch pin at its revision without a version check", async () => { + const cwd = project({ + "Package.resolved": pin({ branch: "main", revision: REVISION }), + }); + + expect(await run(cwd)).toBe("generated\n"); + + expect(commands()).toContain( + `git fetch --depth 1 https://github.com/supabase/supabase-swift ${REVISION}`, + ); + expect(commands()).not.toContain("typegen --version"); + expect(existsSync(cached())).toBe(true); }); test("reads an Xcode project's Package.resolved in the version 1 format", async () => { @@ -219,135 +323,196 @@ describe("swift binary resolution", () => { { package: "Supabase", repositoryURL: "https://github.com/supabase/supabase-swift", - state: { version: "2.40.0" }, + state: { version: "3.0.0-alpha.1", revision: REVISION }, }, ], }, version: 1, }), }); - const { language, host, urls } = setup(cwd); - await language.generate(unsortedMetadata, {}, host); - expect(urls[0]).toContain("/download/v2.40.0/"); + await run(cwd); + expect(existsSync(cached())).toBe(true); }); test("finds Package.resolved in a parent directory", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); + const cwd = project({ "Package.resolved": versionPin() }); mkdirSync(join(cwd, "Sources/App"), { recursive: true }); - const { language, host, urls } = setup(join(cwd, "Sources/App")); - await language.generate(unsortedMetadata, {}, host); - expect(urls[0]).toContain("/download/v2.56.0/"); + await run(join(cwd, "Sources/App")); + expect(existsSync(cached())).toBe(true); }); - test("picks the Linux asset on Linux x86_64", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const urls: string[] = []; - const language = createSwift({ - platform: "linux", - arch: "x64", - fetch: async (url) => { - urls.push(String(url)); - return new Response(tarball); - }, - }); - const host = createFakeHost(() => success(""), { - cwd, - env: { HOME: join(root, "home"), XDG_CACHE_HOME: join(root, "xdg") }, - }); - await language.generate(unsortedMetadata, {}, host); - expect(urls[0]).toEndWith("/supabase-typegen-linux-x86_64.tar.gz"); - expect(host.requests[0]!.command).toBe( - join(root, "xdg/supabase/typegen/2.56.0/supabase-typegen"), + test("links the Swift runtime statically on Linux", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + await run(cwd, {}, "linux"); + expect(commands()).toContain( + "swift build -c release --package-path tools/supabase-typegen --disable-automatic-resolution --static-swift-stdlib", ); }); - test("falls back to PATH without a version pin", async () => { - const branchPin = JSON.stringify({ - pins: [ - { - identity: "supabase-swift", - location: "https://github.com/supabase/supabase-swift", - state: { branch: "main", revision: "abc" }, - }, - ], - version: 3, - }); - for (const cwd of [ - project({}), - project({ "Package.resolved": branchPin }), - ]) { - const { language, host, urls } = setup(cwd); - await language.generate(unsortedMetadata, {}, host); - expect(urls).toEqual([]); - expect(host.requests.at(-1)!.command).toBe("supabase-typegen"); + test("uses XDG_CACHE_HOME and ~/.cache outside macOS", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + await run(cwd, {}, "linux"); + expect(existsSync(cached("home/.cache"))).toBe(true); + await run(cwd, { XDG_CACHE_HOME: join(root, "xdg") }, "linux"); + expect(existsSync(cached("xdg"))).toBe(true); + }); + + test("ignores an empty or relative XDG_CACHE_HOME", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + for (const XDG_CACHE_HOME of ["", "relative/cache"]) { + await run(cwd, { XDG_CACHE_HOME }, "linux"); + expect(existsSync(cached("home/.cache"))).toBe(true); } }); - test("falls back to PATH on a platform without a release asset", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const language = createSwift({ - platform: "win32", - arch: "x64", - fetch: async () => fail("must not download"), - }); + test("keeps the user's git repository variables away from the build", async () => { + // Git exports these to hooks, for example when gen types runs in pre-commit. + const cwd = project({ "Package.resolved": versionPin() }); + const repository = join(root, "user-repo/.git"); + expect( + await run(cwd, { + GIT_DIR: repository, + GIT_WORK_TREE: dirname(repository), + GIT_INDEX_FILE: join(repository, "index"), + GIT_OBJECT_DIRECTORY: join(repository, "objects"), + }), + ).toBe("generated\n"); + }); + + test("two concurrent first runs both succeed", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + expect(await Promise.all([run(cwd), run(cwd)])).toEqual([ + "generated\n", + "generated\n", + ]); + expect(readdirSync(dirname(cached()))).toEqual(["supabase-typegen"]); + expect(readdirSync(scratch)).toEqual([]); + }); + + test("falls back to PATH without a supabase-swift pin", async () => { + const cwd = project({}); + writeFileSync( + join(bin, "supabase-typegen"), + '#!/bin/sh\necho "typegen $*" >> "$FAKE_LOG"\necho from-path\n', + { mode: 0o755 }, + ); + expect(await run(cwd)).toBe("from-path\n"); + expect(commands()).toEqual(["typegen --access-control internal"]); + }); + + test("falls back to PATH on Windows", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const language = make("win32"); const host = createFakeHost(() => success(""), { cwd }); await language.generate(unsortedMetadata, {}, host); - expect(host.requests[0]!.command).toBe("supabase-typegen"); + expect(host.requests.map((request) => request.command)).toEqual([ + "supabase-typegen", + ]); }); - test("SUPABASE_TYPEGEN overrides the download", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host, urls } = setup(cwd, undefined, { - SUPABASE_TYPEGEN: "/opt/dev/supabase-typegen", + test("SUPABASE_TYPEGEN overrides the source build", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const host = createFakeHost(() => success(""), { + cwd, + env: { SUPABASE_TYPEGEN: "/opt/dev/supabase-typegen" }, }); - await language.generate(unsortedMetadata, {}, host); - expect(urls).toEqual([]); - expect(host.requests[0]!.command).toBe("/opt/dev/supabase-typegen"); + await make().generate(unsortedMetadata, {}, host); + expect(host.requests.map((request) => request.command)).toEqual([ + "/opt/dev/supabase-typegen", + ]); }); - test("reports a release without the binary as not installed", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.10.0") }); - const { language, host } = setup( - cwd, - () => new Response("Not Found", { status: 404 }), - ); - const error = await rejection( - language.generate(unsortedMetadata, {}, host), - ); + test("refuses a revision that is not a full commit SHA without running git", async () => { + for (const revision of ["abc", "--upload-pack=touch", "A".repeat(40)]) { + const cwd = project({ + "Package.resolved": pin({ version: "3.0.0-alpha.1", revision }), + }); + const error = await rejection(run(cwd)); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain("40-character"); + expect(commands()).toEqual([]); + } + }); + + for (const tool of ["git", "swift"]) { + test(`names a missing ${tool} and the minimum toolchain before fetching`, async () => { + rmSync(join(bin, tool)); + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection(run(cwd)); + expect(error).toBeInstanceOf(ToolNotInstalledError); + expect((error as ToolNotInstalledError).tool).toBe(tool); + expect((error as ToolNotInstalledError).message).toContain( + "Swift 6.2 / Xcode 26", + ); + expect(ran("git fetch")).toBe(false); + expect(leftovers()).toEqual([]); + }); + } + + test("reports a supabase-swift without supabase-typegen as not installed", async () => { + const cwd = project({ "Package.resolved": versionPin("2.56.0") }); + const error = await rejection(run(cwd, { FAKE_NO_TYPEGEN: "1" })); expect(error).toBeInstanceOf(ToolNotInstalledError); - expect((error as ToolNotInstalledError).message).toContain("2.10.0"); - expect(host.requests).toHaveLength(0); + expect((error as ToolNotInstalledError).message).toContain( + "supabase-swift 2.56.0 has no supabase-typegen; update to 3.0.0-alpha.1 or newer", + ); + expect(ran("swift build")).toBe(false); + expect(leftovers()).toEqual([]); }); - test("reports any other download failure", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host } = setup( - cwd, - () => new Response("", { status: 503 }), + test("reports a failed fetch with git's error", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection(run(cwd, { FAKE_FETCH_FAIL: "1" })); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain( + "upload-pack: not our ref", ); + expect(leftovers()).toEqual([]); + }); + + test("refuses a checkout whose HEAD is not the pinned revision", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection(run(cwd, { FAKE_HEAD: "e".repeat(40) })); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain(REVISION); + expect(ran("swift build")).toBe(false); + expect(leftovers()).toEqual([]); + }); + + test("forwards the swift build errors", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection(run(cwd, { FAKE_BUILD_FAIL: "1" })); + expect(error).toBeInstanceOf(ToolFailedError); + const { message, exitCode, stderr } = error as ToolFailedError; + expect(exitCode).toBe(1); + expect(stderr).toBe("error: fatalError"); + expect(message).toContain("swift build"); + expect(message).toContain("error: cannot find 'x' in scope"); + expect(message).toContain("error: fatalError"); + expect(leftovers()).toEqual([]); + }); + + test("refuses a binary whose --version differs from the pin, and does not cache it", async () => { + const cwd = project({ "Package.resolved": versionPin() }); const error = await rejection( - language.generate(unsortedMetadata, {}, host), + run(cwd, { FAKE_TYPEGEN_VERSION: "3.0.0-alpha.2" }), ); expect(error).toBeInstanceOf(TypegenError); - expect((error as TypegenError).message).toContain("503"); + expect((error as TypegenError).message).toContain("3.0.0-alpha.2"); + expect(commands()).not.toContain("typegen --access-control internal"); + expect(leftovers()).toEqual([]); }); - test("validates options before downloading", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host, urls } = setup(cwd); - expect( - await rejection( - language.generate( - unsortedMetadata, - { "swift-access-control": "open" }, - host, - ), + test("validates options before building", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection( + make().generate( + unsortedMetadata, + { "swift-access-control": "open" }, + createNodeHost({ cwd, env: { PATH: bin, FAKE_LOG: logFile } }), ), - ).toBeInstanceOf(InvalidOptionError); - expect(urls).toEqual([]); + ); + expect(error).toBeInstanceOf(InvalidOptionError); + expect(commands()).toEqual([]); }); }); - -const fail = (message: string): never => { - throw new Error(message); -}; From af40e09516af2415d9a9c71732558ce53a8d4d51 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 12 Nov 2026 22:38:58 -0300 Subject: [PATCH 2/2] fix(typegen): build only commits that are upstream's GitHub serves every commit of the fork network through the upstream URL, pull request heads included, so fetching the pinned SHA alone let a Package.resolved get a fork's code built and run. A version pin's tag must now point at the revision, and any other pin's revision must be on its upstream branch, checked in a commits-only clone before the fetch. The Package.resolved lookup also skips files the user does not own and non-regular files, treats malformed JSON as no pin, and rejects a version or branch that is not a plain value before git or the terminal sees it. Refs SDK-3043 --- packages/typegen/CLAUDE.md | 4 +- packages/typegen/README.md | 17 +- packages/typegen/src/languages/swift.ts | 221 +++++++++++++++++++----- packages/typegen/test/swift.test.ts | 123 ++++++++++++- 4 files changed, 310 insertions(+), 55 deletions(-) diff --git a/packages/typegen/CLAUDE.md b/packages/typegen/CLAUDE.md index 783b679..5584c27 100644 --- a/packages/typegen/CLAUDE.md +++ b/packages/typegen/CLAUDE.md @@ -42,7 +42,9 @@ lives in supabase-js. - `src/languages/swift.ts` -- `supabase-typegen --access-control `, built from source (SDK-3043) at the supabase-swift commit the nearest `Package.resolved` pins, with the user's `git` and `swift`, and cached per - commit; then falls back to PATH; `SUPABASE_TYPEGEN` overrides both. The build + commit; then falls back to PATH; `SUPABASE_TYPEGEN` overrides both. Before + fetching it proves the commit is upstream's (tag for a version pin, ancestry + of the branch otherwise), since GitHub serves fork commits by SHA. The build steps run through `host.spawn` with a temporary folder as `cwd`. The only entry that touches the network or the file system; `createSwift` takes the platform, the temporary directory and the progress `log` so tests stay diff --git a/packages/typegen/README.md b/packages/typegen/README.md index 81889e5..7b68a46 100644 --- a/packages/typegen/README.md +++ b/packages/typegen/README.md @@ -81,14 +81,23 @@ nearest `Package.resolved` (in `cwd` or a parent; SwiftPM's, or Xcode's inside the `.xcodeproj` or `.xcworkspace`) and builds the generator from source at the pin's `revision`, so version, branch and revision pins all work: -1. The revision must be a 40-character lowercase hex SHA; nothing else reaches - `git`. +1. The revision must be a 40-character lowercase hex SHA, a version must be a + semantic version and a branch a plain branch name; nothing else reaches + `git`. Only regular files the user owns count as `Package.resolved`, so + another user's file in a shared parent such as `/tmp` cannot pick the commit, + and a malformed one counts as no pin. 2. A binary already in `$XDG_CACHE_HOME/supabase/typegen//` (default `~/Library/Caches` on macOS, `~/.cache` elsewhere; an empty or relative `XDG_CACHE_HOME` is ignored) runs with no build. 3. Otherwise it checks that `git` and `swift` (Swift 6.2 / Xcode 26 or newer) - are on PATH, fetches the single commit into a temporary folder, checks that - `HEAD` is the revision, and runs + are on PATH and that the commit is upstream's. GitHub serves every commit of + the fork network through the upstream URL, pull request heads included, so a + version pin's tag (`` or `v`, per `git ls-remote`) must + point at the revision, and a branch pin's revision must be an ancestor of + that upstream branch (a bare revision pin: the default branch), checked in a + commits-only clone (`--filter=tree:0`, about 1.5 MB and a second). It then + fetches the single commit into a temporary folder, checks that `HEAD` is the + revision, and runs `swift build -c release --package-path tools/supabase-typegen --disable-automatic-resolution`. The flag builds the generator's committed `Package.resolved` exactly and fails rather than resolve other dependency versions. Linux adds diff --git a/packages/typegen/src/languages/swift.ts b/packages/typegen/src/languages/swift.ts index 7adde94..dcef7c2 100644 --- a/packages/typegen/src/languages/swift.ts +++ b/packages/typegen/src/languages/swift.ts @@ -8,6 +8,7 @@ import { readFile, rename, rm, + stat, } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join } from "node:path"; @@ -35,6 +36,10 @@ const TOOLCHAIN = "Swift 6.2 / Xcode 26"; const FIRST_RELEASE = "3.0.0-alpha.1"; /** The only revision form ever passed to git, so a pin cannot smuggle in an option. */ const COMMIT = /^[0-9a-f]{40}$/; +/** Semantic versions only: a pinned version reaches git as a tag name and the terminal as text. */ +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +/** Branch names git accepts, minus anything that could read as an option or a refspec. */ +const BRANCH = /^(?!-)(?!.*\.\.)[A-Za-z0-9._/-]+$/; /** * The flag `supabase gen types` already exposes, narrowed to the two levels @@ -59,6 +64,12 @@ export interface SwiftDependencies { readonly temporaryDirectory: () => string; /** Progress the user should see while the generator builds. */ readonly log: (message: string) => void; + /** + * `process.getuid()`. A `Package.resolved` owned by anyone else is ignored, + * so another user's file in a shared parent such as `/tmp` cannot pick the + * commit that gets built. `undefined` skips the check. + */ + readonly userId: () => number | undefined; } /** @@ -76,7 +87,10 @@ export interface SwiftDependencies { * * A source build trusts nothing beyond what the app already runs: the * supabase-swift source at the pinned commit, the generator's committed - * `Package.resolved`, and the user's toolchain. It is a separate executable, + * `Package.resolved`, and the user's toolchain. GitHub serves every commit of + * the fork network, pull request heads included, through the upstream URL, so + * the commit must first be shown to be upstream's: a version pin's tag must + * point at it, and any other pin must be on its upstream branch. It is a separate executable, * not a package dependency, because its swift-format dependency would pin * every app's swift-syntax, and because an Xcode project without a * `Package.swift` has nothing to `swift run` from. No `--schema` is passed: @@ -88,6 +102,7 @@ export function createSwift( platform: process.platform, temporaryDirectory: tmpdir, log: (message) => process.stderr.write(`${message}\n`), + userId: () => process.getuid?.(), }, ): TypegenLanguage { const options = [swiftAccessControl]; @@ -127,16 +142,28 @@ async function resolveBinary( if (dependencies.platform === "win32" || spawn === undefined) { return BINARY; } - const pin = await pinnedState(host.cwd); + const pin = await pinnedState(host.cwd, dependencies.userId()); if (pin?.revision === undefined) { return BINARY; } - const { revision, version } = pin; - if (!COMMIT.test(revision)) { - throw new TypegenError({ + const { revision, version, branch } = pin; + const invalid = (field: string, value: string, expected: string) => + new TypegenError({ language: "swift", - message: `The supabase-swift pin in ${host.cwd}'s Package.resolved has revision "${revision}", which is not a 40-character lowercase hex commit SHA. Resolve the package again, or: ${installHint}`, + message: `The supabase-swift pin in ${host.cwd}'s Package.resolved has ${field} ${JSON.stringify(value)}, which is not ${expected}. Resolve the package again, or: ${installHint}`, }); + if (!COMMIT.test(revision)) { + throw invalid( + "revision", + revision, + "a 40-character lowercase hex commit SHA", + ); + } + if (version !== undefined && !VERSION.test(version)) { + throw invalid("version", version, "a semantic version"); + } + if (version === undefined && branch !== undefined && !BRANCH.test(branch)) { + throw invalid("branch", branch, "a plain branch name"); } const path = join( cacheDirectory(host.env, dependencies.platform), @@ -144,7 +171,13 @@ async function resolveBinary( BINARY, ); if (!existsSync(path)) { - await build({ host, spawn, dependencies, revision, version, path }); + await build({ + host, + spawn, + dependencies, + pin: { revision, version, branch }, + path, + }); } return path; } @@ -153,28 +186,29 @@ interface Build { readonly host: Host; readonly spawn: NonNullable; readonly dependencies: SwiftDependencies; - readonly revision: string; - readonly version: string | undefined; + /** A validated pin: the revision is a SHA, the version a semantic version, the branch plain. */ + readonly pin: { readonly revision: string } & PinState; /** Where the finished binary goes in the cache. */ readonly path: string; } /** - * Checks out `revision` into a scratch folder, builds the generator with its - * committed dependency versions and moves the binary to `path`. The scratch - * folder is removed whatever happens, and the cache only ever receives a - * binary that built and, for a version pin, reports that version. + * Checks that `pin.revision` is upstream's, checks it out into a scratch + * folder, builds the generator with its committed dependency versions and + * moves the binary to `path`. The scratch folder is removed whatever happens, + * and the cache only ever receives a binary that built and, for a version pin, + * reports that version. */ async function build({ host, spawn, dependencies, - revision, - version, + pin, path, }: Build): Promise { + const { revision, version, branch } = pin; const label = version ?? revision; - const run = async ( + const exec = async ( command: string, args: readonly string[], cwd: string, @@ -187,9 +221,8 @@ async function build({ stdin: "", signal: host.signal, }; - let result: SpawnResult; try { - result = await spawn(request); + return await spawn(request); } catch (error) { if (isCommandNotFound(error)) { throw new ToolNotInstalledError({ @@ -202,6 +235,13 @@ async function build({ } throw error; } + }; + const run = async ( + command: string, + args: readonly string[], + cwd: string, + ): Promise => { + const result = await exec(command, args, cwd); if (result.exitCode !== 0) { const commandLine = [command, ...args]; const output = [result.stdout.trim(), result.stderr.trim()] @@ -232,18 +272,73 @@ async function build({ ); // Named after the scratch folder, which is unique, so concurrent runs never share one. const partial = `${path}.${process.pid}.${basename(scratch)}.partial`; + const checkout = join(scratch, "src"); try { - await run("git", ["init"], scratch); - await run("git", ["fetch", "--depth", "1", SOURCE, revision], scratch); - await run("git", ["checkout", "--detach", "FETCH_HEAD"], scratch); - const head = (await run("git", ["rev-parse", "HEAD"], scratch)).stdout; + if (version !== undefined) { + // A tag can only come from upstream's maintainers; annotated tags list the commit as `^{}`. + const tags = [version, `v${version}`].map((name) => `refs/tags/${name}`); + const listed = await run( + "git", + ["ls-remote", SOURCE, ...tags.flatMap((tag) => [tag, `${tag}^{}`])], + scratch, + ); + const commits = new Map( + listed.stdout + .split("\n") + .map((line) => line.split("\t")) + .map(([sha, ref]) => [ref?.trim(), sha?.trim()]), + ); + if ( + !tags.some( + (tag) => (commits.get(`${tag}^{}`) ?? commits.get(tag)) === revision, + ) + ) { + throw new TypegenError({ + language: "swift", + message: `supabase-swift's ${version} tag does not point at ${revision}, the revision Package.resolved pins. Resolve the package again; a revision from a fork or a pull request is not built.`, + }); + } + } else { + // Only the commits of one branch, about 1.5 MB, to show the revision is on it. + const branchArgs = branch === undefined ? [] : ["--branch", branch]; + await run( + "git", + [ + "clone", + "--bare", + "--filter=tree:0", + "--no-tags", + "--single-branch", + ...branchArgs, + SOURCE, + "history.git", + ], + scratch, + ); + const ancestry = await exec( + "git", + ["merge-base", "--is-ancestor", revision, "HEAD"], + join(scratch, "history.git"), + ); + if (ancestry.exitCode !== 0) { + throw new TypegenError({ + language: "swift", + message: `${revision} is not on supabase-swift's ${branch ?? "default"} branch. Resolve the package again; a revision from a fork or a pull request is not built.`, + }); + } + } + await mkdir(checkout); + await run("git", ["init"], checkout); + await run("git", ["fetch", "--depth", "1", SOURCE, revision], checkout); + await run("git", ["checkout", "--detach", "FETCH_HEAD"], checkout); + const head = (await run("git", ["rev-parse", "HEAD"], checkout)).stdout; if (head.trim() !== revision) { throw new TypegenError({ language: "swift", message: `Fetching supabase-swift ${revision} checked out ${head.trim()} instead.`, }); } - if (!existsSync(join(scratch, PACKAGE_PATH, "Package.swift"))) { + if (!existsSync(join(checkout, PACKAGE_PATH, "Package.swift"))) { throw new ToolNotInstalledError({ language: "swift", tool: BINARY, @@ -265,11 +360,13 @@ async function build({ "--disable-automatic-resolution", ...(dependencies.platform === "linux" ? ["--static-swift-stdlib"] : []), ], - scratch, + checkout, ); - const built = join(scratch, PACKAGE_PATH, ".build", "release", BINARY); + const built = join(checkout, PACKAGE_PATH, ".build", "release", BINARY); if (version !== undefined) { - const reported = (await run(built, ["--version"], scratch)).stdout.trim(); + const reported = ( + await run(built, ["--version"], checkout) + ).stdout.trim(); if (reported !== version) { throw new TypegenError({ language: "swift", @@ -343,16 +440,21 @@ function cacheDirectory( interface PinState { readonly version?: string; + readonly branch?: string; readonly revision?: string; } /** * The supabase-swift pin of the nearest `Package.resolved` that has one, - * looking in `cwd` and then each parent. + * looking in `cwd` and then each parent. Only regular files that `userId` + * owns count. */ -async function pinnedState(cwd: string): Promise { +async function pinnedState( + cwd: string, + userId: number | undefined, +): Promise { for (let directory = cwd; ; directory = dirname(directory)) { - for (const file of await resolvedFiles(directory)) { + for (const file of await resolvedFiles(directory, userId)) { const state = stateIn(await readFile(file, "utf8")); if (state) { return state; @@ -364,9 +466,12 @@ async function pinnedState(cwd: string): Promise { } } -async function resolvedFiles(directory: string): Promise { +async function resolvedFiles( + directory: string, + userId: number | undefined, +): Promise { const entries = await readdir(directory).catch(() => []); - return [ + const candidates = [ join(directory, "Package.resolved"), ...entries .filter((entry) => entry.endsWith(".xcworkspace")) @@ -382,25 +487,55 @@ async function resolvedFiles(directory: string): Promise { "project.xcworkspace/xcshareddata/swiftpm/Package.resolved", ), ), - ].filter((file) => existsSync(file)); + ]; + const usable = await Promise.all( + candidates.map(async (file) => { + const status = await stat(file).catch(() => undefined); + // A FIFO would block the read forever; a file of another user is not the project's. + return ( + status?.isFile() === true && + (userId === undefined || status.uid === userId) + ); + }), + ); + return candidates.filter((_file, index) => usable[index]); } -interface Pin { - readonly location?: string; - readonly repositoryURL?: string; - readonly state?: PinState; -} +const record = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; + +const text = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; -/** Reads every `Package.resolved` format: version 1 nests the pins under `object`. */ +/** + * Reads every `Package.resolved` format (version 1 nests the pins under + * `object`). Anything that is not the expected shape counts as no pin. + */ function stateIn(contents: string): PinState | undefined { - let parsed: { pins?: Pin[]; object?: { pins?: Pin[] } }; + let parsed: unknown; try { parsed = JSON.parse(contents); } catch { return undefined; } - const pins = parsed.pins ?? parsed.object?.pins ?? []; - return pins.find((pin) => - REPOSITORY.test(pin.location ?? pin.repositoryURL ?? ""), - )?.state; + const root = record(parsed); + const pins = root?.pins ?? record(root?.object)?.pins; + if (!Array.isArray(pins)) { + return undefined; + } + for (const entry of pins) { + const pin = record(entry); + const location = text(pin?.location) ?? text(pin?.repositoryURL) ?? ""; + if (REPOSITORY.test(location)) { + const state = record(pin?.state); + return { + version: text(state?.version), + branch: text(state?.branch), + revision: text(state?.revision), + }; + } + } + return undefined; } diff --git a/packages/typegen/test/swift.test.ts b/packages/typegen/test/swift.test.ts index f0a1eac..948e44b 100644 --- a/packages/typegen/test/swift.test.ts +++ b/packages/typegen/test/swift.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, @@ -131,6 +132,13 @@ if [ -n "$GIT_DIR$GIT_WORK_TREE$GIT_INDEX_FILE$GIT_OBJECT_DIRECTORY" ]; then fi case "$1" in --version) echo "git version 2.50.0" ;; + ls-remote) + if [ -n "$FAKE_TAGS" ]; then printf '%b' "$FAKE_TAGS" + else printf '%s\\trefs/tags/v3.0.0-alpha.1\\n' ${REVISION}; fi ;; + clone) + for argument; do directory=$argument; done + /bin/mkdir "$directory" ;; + merge-base) if [ -n "$FAKE_NOT_ON_BRANCH" ]; then exit 1; fi ;; init) /bin/mkdir .git ;; fetch) if [ -n "$FAKE_FETCH_FAIL" ]; then @@ -226,11 +234,12 @@ describe("swift source build", () => { return cwd; }; - const make = (platform = "darwin") => + const make = (platform = "darwin", userId = process.getuid?.()) => createSwift({ platform, temporaryDirectory: () => scratch, log: (message) => messages.push(message), + userId: () => userId, }); const run = ( @@ -270,6 +279,7 @@ describe("swift source build", () => { expect(commands()).toEqual([ "git --version", "swift --version", + "git ls-remote https://github.com/supabase/supabase-swift refs/tags/3.0.0-alpha.1 refs/tags/3.0.0-alpha.1^{} refs/tags/v3.0.0-alpha.1 refs/tags/v3.0.0-alpha.1^{}", "git init", `git fetch --depth 1 https://github.com/supabase/supabase-swift ${REVISION}`, "git checkout --detach FETCH_HEAD", @@ -300,20 +310,114 @@ describe("swift source build", () => { expect(messages).toEqual([]); }); - test("builds a branch pin at its revision without a version check", async () => { + test("builds a branch pin once its revision is on that upstream branch", async () => { const cwd = project({ - "Package.resolved": pin({ branch: "main", revision: REVISION }), + "Package.resolved": pin({ branch: "release/v3", revision: REVISION }), }); expect(await run(cwd)).toBe("generated\n"); - expect(commands()).toContain( - `git fetch --depth 1 https://github.com/supabase/supabase-swift ${REVISION}`, - ); + expect(commands().slice(2, 5)).toEqual([ + "git clone --bare --filter=tree:0 --no-tags --single-branch --branch release/v3 https://github.com/supabase/supabase-swift history.git", + `git merge-base --is-ancestor ${REVISION} HEAD`, + "git init", + ]); + expect(ran("git ls-remote")).toBe(false); expect(commands()).not.toContain("typegen --version"); expect(existsSync(cached())).toBe(true); }); + test("checks a bare revision pin against the default branch", async () => { + const cwd = project({ "Package.resolved": pin({ revision: REVISION }) }); + await run(cwd); + expect(commands()).toContain( + "git clone --bare --filter=tree:0 --no-tags --single-branch https://github.com/supabase/supabase-swift history.git", + ); + }); + + test("refuses a revision that is not on its upstream branch, such as a fork's commit", async () => { + const cwd = project({ + "Package.resolved": pin({ branch: "main", revision: REVISION }), + }); + const error = await rejection(run(cwd, { FAKE_NOT_ON_BRANCH: "1" })); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain( + `${REVISION} is not on supabase-swift's main branch`, + ); + expect(ran("git fetch")).toBe(false); + expect(leftovers()).toEqual([]); + }); + + test("accepts an annotated tag through its peeled commit", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const tag = "f".repeat(40); + expect( + await run(cwd, { + FAKE_TAGS: `${tag}\\trefs/tags/3.0.0-alpha.1\\n${REVISION}\\trefs/tags/3.0.0-alpha.1^{}\\n`, + }), + ).toBe("generated\n"); + }); + + test("refuses a version pin whose tag points at another commit", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + for (const tags of [ + `${"e".repeat(40)}\\trefs/tags/v3.0.0-alpha.1\\n`, + "", + ]) { + writeFileSync(logFile, ""); + const error = await rejection(run(cwd, { FAKE_TAGS: tags || "\\c" })); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain( + `supabase-swift's 3.0.0-alpha.1 tag does not point at ${REVISION}`, + ); + expect(ran("git fetch")).toBe(false); + expect(leftovers()).toEqual([]); + } + }); + + test("refuses a malformed version or branch before running git", async () => { + const states: Record[] = [ + { version: "3.0.0\u001b]0;x\u0007", revision: REVISION }, + { version: "--upload-pack=x", revision: REVISION }, + { branch: "--upload-pack=x", revision: REVISION }, + { branch: "main:refs/heads/x", revision: REVISION }, + ]; + for (const state of states) { + const cwd = project({ "Package.resolved": pin(state) }); + const error = await rejection(run(cwd)); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).not.toContain("\u001b"); + expect(commands()).toEqual([]); + } + }); + + test("skips a Package.resolved owned by another user", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection( + make("darwin", (process.getuid?.() ?? 0) + 1).generate( + unsortedMetadata, + {}, + createNodeHost({ cwd, env: { PATH: bin, FAKE_LOG: logFile } }), + ), + ); + expect(error).toBeInstanceOf(ToolNotInstalledError); + expect((error as ToolNotInstalledError).tool).toBe("supabase-typegen"); + expect(ran("git")).toBe(false); + }); + + test("skips malformed and non-regular Package.resolved files", async () => { + for (const contents of ["null", '{"pins":{}}', '{"pins":[null]}', "[1]"]) { + const cwd = project({ "Package.resolved": contents }); + const error = await rejection(run(cwd)); + expect(error).toBeInstanceOf(ToolNotInstalledError); + } + const cwd = project({}); + rmSync(join(cwd, "Package.resolved"), { force: true }); + execFileSync("/usr/bin/mkfifo", [join(cwd, "Package.resolved")]); + expect(await rejection(run(cwd))).toBeInstanceOf(ToolNotInstalledError); + expect(commands()).toEqual([]); + }); + test("reads an Xcode project's Package.resolved in the version 1 format", async () => { const cwd = project({ "App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved": @@ -451,7 +555,12 @@ describe("swift source build", () => { test("reports a supabase-swift without supabase-typegen as not installed", async () => { const cwd = project({ "Package.resolved": versionPin("2.56.0") }); - const error = await rejection(run(cwd, { FAKE_NO_TYPEGEN: "1" })); + const error = await rejection( + run(cwd, { + FAKE_NO_TYPEGEN: "1", + FAKE_TAGS: `${REVISION}\\trefs/tags/2.56.0\\n`, + }), + ); expect(error).toBeInstanceOf(ToolNotInstalledError); expect((error as ToolNotInstalledError).message).toContain( "supabase-swift 2.56.0 has no supabase-typegen; update to 3.0.0-alpha.1 or newer",