diff --git a/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md index 88402efeed..39654d411a 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md @@ -87,7 +87,12 @@ Legacy `--output` / `-o` does not change deploy output, matching the Go command. the nearest git root still upload, with `../`-relative names. The git-root containment boundary is a TS-only safeguard with no Go equivalent — Go uploads any reachable import unbounded; #5755 widened the TS boundary from the workdir to the - git root. + git root. The boundary additionally admits the real (symlink-resolved) directories + of `supabase/functions` and each function's entrypoint, so function sources whose + symlink targets lie outside the git root still upload with their workdir-anchored + names, matching Go's symlink-following walker (INC-699 follow-up: skipping them + produced deploys with no file parts, rejected by the API with 400 "Entrypoint path + does not exist"). - Requires a linked project unless `--project-ref` is provided. - Uses API/server-side bundling by default; `--use-docker` and `--legacy-bundle` select local bundling. - `--use-api`, `--use-docker`, and `--legacy-bundle` are mutually exclusive deploy modes. diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts index 67725800fc..01956e6dc3 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Effect, Exit, Layer, Option, Stdio } from "effect"; @@ -504,6 +504,103 @@ describe("legacy functions deploy", () => { ); }); + it.live("uploads sources through a functions dir symlinked outside the git root", () => { + // INC-699 follow-up (Slack 2026-08-05): when `supabase/functions` (or a + // single function dir) is a symlink whose target lies outside the git + // root, the realpath containment boundary silently skipped the entrypoint + // ("WARN: Skipping import path outside source root") and the deploy went + // out with metadata only — no file parts — which the API rejects with + // 400 "Entrypoint path does not exist - .../source/supabase/functions/ + // /index.ts". Go follows symlinks unconditionally + // (`pkg/function/deno.go:125`), so the sources must upload, named at the + // workdir like any other deploy. + const repoRoot = join(tempRoot.current, "repo"); + const externalFunctionsDir = join(tempRoot.current, "external", "functions"); + const multiparts: Array<{ metadata?: string; fileNames: ReadonlyArray }> = []; + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => { + if (request.body._tag === "FormData") { + const metadata = request.body.formData.get("metadata"); + multiparts.push({ + metadata: typeof metadata === "string" ? metadata : undefined, + fileNames: request.body.formData + .getAll("file") + .flatMap((part) => (part instanceof File ? [part.name] : [])), + }); + } + if (request.method === "GET") { + return Effect.succeed(legacyJsonResponse(request, 200, [])); + } + return Effect.succeed( + legacyJsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 2, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: true, + entrypoint_path: "supabase/functions/hello-world/index.ts", + import_map_path: "supabase/functions/hello-world/deno.json", + }), + ); + }, + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: repoRoot }), + runtimeInfo: mockRuntimeInfo({ cwd: repoRoot }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(repoRoot, ".git"), { recursive: true })); + yield* Effect.tryPromise(() => writeProjectConfig(repoRoot)); + yield* Effect.tryPromise(async () => { + await mkdir(join(externalFunctionsDir, "hello-world"), { recursive: true }); + await mkdir(join(externalFunctionsDir, "_shared"), { recursive: true }); + await writeFile( + join(externalFunctionsDir, "hello-world", "index.ts"), + 'import { shared } from "../_shared/mod.ts"\nDeno.serve(() => new Response(shared))\n', + ); + await writeFile( + join(externalFunctionsDir, "_shared", "mod.ts"), + 'export const shared = "ok"\n', + ); + await symlink(externalFunctionsDir, join(repoRoot, "supabase", "functions")); + }); + + yield* legacyFunctionsDeploy(baseFlags); + + expect(out.stderrText).not.toContain("Skipping import path outside source root"); + expect(multiparts).toHaveLength(1); + expect(multiparts[0]?.fileNames).toEqual([ + "supabase/functions/hello-world/index.ts", + "supabase/functions/_shared/mod.ts", + ]); + expect(JSON.parse(multiparts[0]?.metadata ?? "{}")).toMatchObject({ + entrypoint_path: "supabase/functions/hello-world/index.ts", + }); + expect(stripSgr(out.stdoutText)).toContain( + "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", + ); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }); + it.live("deploys config-declared custom entrypoints when deploying all functions", () => { const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi({ diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 1043a3a149..eccc17c9b9 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -357,6 +357,37 @@ function hasParentPathSegment(relativePath: string) { .some((segment) => segment === ".."); } +/** + * Widens containment roots with the real paths of function source + * directories. A symlinked functions dir (or function dir) resolves outside + * the git-root boundary (`resolveFunctionsSourceRoot`) even though its + * unresolved paths — and therefore the uploaded file names and the + * server-recorded metadata paths — stay anchored inside the workdir. Go + * follows symlinks unconditionally (`apps/cli-go/pkg/function/deno.go:125`, + * "Assume no file is symlinked"), so skipping those files was a TS-only + * regression: the deploy request went out without its entrypoint file and the + * API rejected it with 400 "Entrypoint path does not exist" (INC-699 + * follow-up). Mirrors `resolveImportMapAllowedRoots`, which already admits an + * out-of-root import map's real directory. + */ +async function withRealSourceDirs( + roots: ReadonlyArray, + dirs: ReadonlyArray, +): Promise> { + const widened = [...roots]; + for (const dir of dirs) { + try { + const real = await realpath(dir); + if (!isContainedInAnyPath(widened, real)) { + widened.push(real); + } + } catch { + // Missing directory — the walker's ENOENT handling covers it (Go-parity warn). + } + } + return widened; +} + async function realpathIfExists(pathname: string) { try { return await realpath(resolve(pathname)); @@ -943,6 +974,10 @@ async function writeSourceDeployForm( const form = new FormData(); form.append("metadata", JSON.stringify(metadata)); const realSourceRoot = await realpath(sourceRoot); + const assetAllowedRoots = await withRealSourceDirs( + [realSourceRoot], + [join(workdir, SUPABASE_FUNCTIONS_DIR), dirname(config.entrypoint)], + ); const importMapAllowedRoots = await resolveImportMapAllowedRoots(sourceRoot, config.importMap); const uploadedAssets = new Set(); @@ -964,7 +999,7 @@ async function writeSourceDeployForm( const uploadAsset = async (pathname: string, contents: Uint8Array) => { const realPathname = await realpath(pathname); - if (!isContainedPath(realSourceRoot, realPathname)) { + if (!isContainedInAnyPath(assetAllowedRoots, realPathname)) { throw new Error(`refusing to upload asset outside source root: ${pathname}`); } await appendAsset(pathname, contents, realPathname); @@ -1057,7 +1092,7 @@ async function writeSourceDeployForm( await walkImportPaths( importMap, config.entrypoint, - [realSourceRoot], + assetAllowedRoots, workdir, uploadAsset, async (message) => { @@ -1160,20 +1195,23 @@ export async function buildDockerBinds( const projectRoot = resolve(functionsDir, "..", ".."); const sourceRoot = await resolveFunctionsSourceRoot(projectRoot); const realSourceRoot = await realpath(sourceRoot); - const moduleRoots = [ - realSourceRoot, - ...( - await Promise.all( - (options.additionalModuleRoots ?? []).map(async (root) => { - try { - return await realpath(root); - } catch { - return undefined; - } - }), - ) - ).flatMap((root) => (root === undefined ? [] : [root])), - ]; + const moduleRoots = await withRealSourceDirs( + [ + realSourceRoot, + ...( + await Promise.all( + (options.additionalModuleRoots ?? []).map(async (root) => { + try { + return await realpath(root); + } catch { + return undefined; + } + }), + ) + ).flatMap((root) => (root === undefined ? [] : [root])), + ], + [hostFunctionsDir, dirname(resolve(config.entrypoint))], + ); const importMapAllowedRoots = await resolveImportMapAllowedRoots(sourceRoot, config.importMap); const binds = [`${hostFunctionsDir}:${toDockerPath(hostFunctionsDir)}:ro`]; if (process.env["BITBUCKET_CLONE_DIR"] === undefined) {