Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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/
// <slug>/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<string> }> = [];
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({
Expand Down
70 changes: 54 additions & 16 deletions apps/cli/src/shared/functions/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
dirs: ReadonlyArray<string>,
): Promise<ReadonlyArray<string>> {
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));
Expand Down Expand Up @@ -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<string>();

Expand All @@ -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);
Expand Down Expand Up @@ -1057,7 +1092,7 @@ async function writeSourceDeployForm(
await walkImportPaths(
importMap,
config.entrypoint,
[realSourceRoot],
assetAllowedRoots,
workdir,
uploadAsset,
async (message) => {
Expand Down Expand Up @@ -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))],
);
Comment on lines +1213 to +1214

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include symlink roots when binding static files

When a single function directory is symlinked outside the git root and the function declares static_files, this widened root list is only used for moduleRoots. The later static-file loop still goes through appendProjectBind, which checks only [realSourceRoot], so real paths under the symlink target are silently omitted from the Docker -v binds while the bundler still receives --static for the symlinked path. That leaves --use-docker deploys and serve/start unable to read those assets even though Go follows the symlink; use the same widened asset roots for static-file binds too.

AGENTS.md reference: apps/cli/AGENTS.md:L247-L257

Useful? React with 👍 / 👎.

const importMapAllowedRoots = await resolveImportMapAllowedRoots(sourceRoot, config.importMap);
const binds = [`${hostFunctionsDir}:${toDockerPath(hostFunctionsDir)}:ro`];
if (process.env["BITBUCKET_CLONE_DIR"] === undefined) {
Expand Down