Skip to content

Commit fb7041d

Browse files
committed
feat: enable serverside unbundling
1 parent df557dc commit fb7041d

File tree

7 files changed

+310
-31
lines changed

7 files changed

+310
-31
lines changed

cmd/functions.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ var (
4949
Long: "Download the source code for a Function from the linked Supabase project.",
5050
Args: cobra.ExactArgs(1),
5151
RunE: func(cmd *cobra.Command, args []string) error {
52-
return download.Run(cmd.Context(), args[0], flags.ProjectRef, useLegacyBundle, afero.NewOsFs())
52+
return download.Run(cmd.Context(), args[0], flags.ProjectRef, useLegacyBundle, useApi, useDocker, afero.NewOsFs())
5353
},
5454
}
5555

@@ -154,6 +154,9 @@ func init() {
154154
cobra.CheckErr(functionsServeCmd.Flags().MarkHidden("all"))
155155
functionsDownloadCmd.Flags().StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
156156
functionsDownloadCmd.Flags().BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.")
157+
functionsDownloadCmd.Flags().BoolVar(&useApi, "use-api", false, "Use Management API to unbundle functions server-side.")
158+
functionsDownloadCmd.Flags().BoolVar(&useDocker, "use-docker", false, "Use Docker to unbundle functions client-side.")
159+
functionsDownloadCmd.MarkFlagsMutuallyExclusive("use-api", "use-docker", "legacy-bundle")
157160
functionsCmd.AddCommand(functionsListCmd)
158161
functionsCmd.AddCommand(functionsDeleteCmd)
159162
functionsCmd.AddCommand(functionsDeployCmd)

internal/functions/download/download.go

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"context"
77
"fmt"
88
"io"
9+
"mime"
10+
"mime/multipart"
911
"net/http"
1012
"os"
1113
"os/exec"
@@ -112,15 +114,35 @@ func downloadFunction(ctx context.Context, projectRef, slug, extractScriptPath s
112114
return nil
113115
}
114116

115-
func Run(ctx context.Context, slug string, projectRef string, useLegacyBundle bool, fsys afero.Fs) error {
117+
func Run(ctx context.Context, slug string, projectRef string, useLegacyBundle bool, useApi bool, useDocker bool, fsys afero.Fs) error {
118+
// Sanity check
119+
if err := flags.LoadConfig(fsys); err != nil {
120+
return err
121+
}
122+
116123
if useLegacyBundle {
117124
return RunLegacy(ctx, slug, projectRef, fsys)
118125
}
119-
// 1. Sanity check
120-
if err := flags.LoadConfig(fsys); err != nil {
121-
return err
126+
127+
if useApi {
128+
// Use server-side unbundling with multipart/form-data
129+
return downloadWithServerSideUnbundle(ctx, slug, projectRef, fsys)
122130
}
123-
// 2. Download eszip to temp file
131+
132+
if useDocker {
133+
// download eszip file for client-side unbundling with edge-runtime
134+
return downloadWithDockerUnbundle(ctx, slug, projectRef, fsys)
135+
}
136+
137+
// Default: Try Docker first, fallback to server-side unbundling if Docker is not available
138+
if utils.IsDockerRunning(ctx) {
139+
return downloadWithDockerUnbundle(ctx, slug, projectRef, fsys)
140+
}
141+
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "Docker is not running, falling back to server-side unbundling")
142+
return downloadWithServerSideUnbundle(ctx, slug, projectRef, fsys)
143+
}
144+
145+
func downloadWithDockerUnbundle(ctx context.Context, slug string, projectRef string, fsys afero.Fs) error {
124146
eszipPath, err := downloadOne(ctx, slug, projectRef, fsys)
125147
if err != nil {
126148
return err
@@ -238,3 +260,138 @@ deno_version = 2
238260
func suggestLegacyBundle(slug string) string {
239261
return fmt.Sprintf("\nIf your function is deployed using CLI < 1.120.0, trying running %s instead.", utils.Aqua("supabase functions download --legacy-bundle "+slug))
240262
}
263+
264+
func downloadWithServerSideUnbundle(ctx context.Context, slug, projectRef string, fsys afero.Fs) error {
265+
fmt.Fprintln(os.Stderr, "Downloading "+utils.Bold(slug))
266+
267+
// Request multipart/form-data response using RequestEditorFn
268+
resp, err := utils.GetSupabase().V1GetAFunctionBody(ctx, projectRef, slug, func(ctx context.Context, req *http.Request) error {
269+
req.Header.Set("Accept", "multipart/form-data")
270+
return nil
271+
})
272+
if err != nil {
273+
return errors.Errorf("failed to download function: %w", err)
274+
}
275+
defer resp.Body.Close()
276+
277+
if resp.StatusCode != http.StatusOK {
278+
body, err := io.ReadAll(resp.Body)
279+
if err != nil {
280+
return errors.Errorf("Error status %d: %w", resp.StatusCode, err)
281+
}
282+
return errors.Errorf("Error status %d: %s", resp.StatusCode, string(body))
283+
}
284+
285+
// Parse the multipart response
286+
mediaType, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
287+
if err != nil {
288+
return errors.Errorf("failed to parse content type: %w", err)
289+
}
290+
291+
if !strings.HasPrefix(mediaType, "multipart/") {
292+
return errors.Errorf("expected multipart response, got %s", mediaType)
293+
}
294+
295+
// Create function directory
296+
funcDir := filepath.Join(utils.FunctionsDir, slug)
297+
298+
if err := utils.MkdirIfNotExistFS(fsys, funcDir); err != nil {
299+
return err
300+
}
301+
302+
// Parse multipart form
303+
mr := multipart.NewReader(resp.Body, params["boundary"])
304+
for {
305+
part, err := mr.NextPart()
306+
if errors.Is(err, io.EOF) {
307+
break
308+
}
309+
if err != nil {
310+
return errors.Errorf("failed to read multipart: %w", err)
311+
}
312+
313+
// Determine the relative path from headers to preserve directory structure.
314+
relPath, err := resolvedPartPath(slug, part) // always starts with :slug
315+
if err != nil {
316+
return err
317+
}
318+
319+
// result of invalid or missing filename but we're letting it slide
320+
if relPath == "" {
321+
fmt.Fprintln(utils.GetDebugLogger(), "Skipping part without filename")
322+
continue
323+
}
324+
325+
filePath, err := joinWithinDir(funcDir, relPath)
326+
if err != nil {
327+
return err
328+
}
329+
330+
if err := utils.MkdirIfNotExistFS(fsys, filepath.Dir(filePath)); err != nil {
331+
return err
332+
}
333+
if err := afero.WriteReader(fsys, filePath, part); err != nil {
334+
return errors.Errorf("failed to write file: %w", err)
335+
}
336+
}
337+
338+
fmt.Println("Downloaded Function " + utils.Aqua(slug) + " from project " + utils.Aqua(projectRef) + ".")
339+
return nil
340+
}
341+
342+
// parse multipart part headers to read and sanitize relative file path for writing
343+
func resolvedPartPath(slug string, part *multipart.Part) (string, error) {
344+
// dedicated header to specify relative path, not expected to be used
345+
if relPath := part.Header.Get("Supabase-Path"); relPath != "" {
346+
return normalizeRelativePath(slug, relPath), nil
347+
}
348+
349+
// part.FileName() does not allow us to handle relative paths, so we parse Content-Disposition manually
350+
cd := part.Header.Get("Content-Disposition")
351+
if cd == "" {
352+
return "", nil
353+
}
354+
355+
_, params, err := mime.ParseMediaType(cd)
356+
if err != nil {
357+
return "", errors.Errorf("failed to parse content disposition: %w", err)
358+
}
359+
360+
if filename := params["filename"]; filename != "" {
361+
return normalizeRelativePath(slug, filename), nil
362+
}
363+
return "", nil
364+
}
365+
366+
// remove leading source/ or :slug/
367+
func normalizeRelativePath(slug, raw string) string {
368+
cleaned := path.Clean(raw)
369+
if after, ok := strings.CutPrefix(cleaned, "source/"); ok {
370+
cleaned = after
371+
} else if after, ok := strings.CutPrefix(cleaned, slug+"/"); ok {
372+
cleaned = after
373+
} else if cleaned == slug {
374+
// If the path is exactly :slug, skip it
375+
cleaned = ""
376+
}
377+
return cleaned
378+
}
379+
380+
// joinWithinDir safely joins base and rel ensuring the result stays within base directory
381+
func joinWithinDir(base, rel string) (string, error) {
382+
cleanRel := filepath.Clean(rel)
383+
// Be forgiving: treat a rooted path as relative to base (e.g. "/foo" -> "foo")
384+
if filepath.IsAbs(cleanRel) {
385+
cleanRel = strings.TrimLeft(cleanRel, "/\\")
386+
}
387+
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") {
388+
return "", errors.Errorf("invalid file path outside function directory: %s", rel)
389+
}
390+
joined := filepath.Join(base, cleanRel)
391+
cleanJoined := filepath.Clean(joined)
392+
cleanBase := filepath.Clean(base)
393+
if cleanJoined != cleanBase && !strings.HasPrefix(cleanJoined, cleanBase+"/") {
394+
return "", errors.Errorf("refusing to write outside function directory: %s", rel)
395+
}
396+
return joined, nil
397+
}

internal/functions/download/download_test.go

Lines changed: 138 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ import (
55
"errors"
66
"fmt"
77
"log"
8+
"mime/multipart"
89
"net/http"
10+
"net/textproto"
911
"os"
12+
"path/filepath"
13+
"strings"
1014
"testing"
1115

1216
"github.com/h2non/gock"
@@ -61,7 +65,7 @@ func TestDownloadCommand(t *testing.T) {
6165
Get("/v1/projects/" + project + "/functions/" + slug + "/body").
6266
Reply(http.StatusOK)
6367
// Run test
64-
err = Run(context.Background(), slug, project, true, fsys)
68+
err = Run(context.Background(), slug, project, true, false, false, fsys)
6569
// Check error
6670
assert.NoError(t, err)
6771
assert.Empty(t, apitest.ListUnmatchedRequests())
@@ -73,7 +77,7 @@ func TestDownloadCommand(t *testing.T) {
7377
// Setup valid project ref
7478
project := apitest.RandomProjectRef()
7579
// Run test
76-
err := Run(context.Background(), "@", project, true, fsys)
80+
err := Run(context.Background(), "@", project, true, false, false, fsys)
7781
// Check error
7882
assert.ErrorContains(t, err, "Invalid Function name.")
7983
})
@@ -84,7 +88,7 @@ func TestDownloadCommand(t *testing.T) {
8488
// Setup valid project ref
8589
project := apitest.RandomProjectRef()
8690
// Run test
87-
err := Run(context.Background(), slug, project, true, fsys)
91+
err := Run(context.Background(), slug, project, true, false, false, fsys)
8892
// Check error
8993
assert.ErrorContains(t, err, "operation not permitted")
9094
})
@@ -98,7 +102,7 @@ func TestDownloadCommand(t *testing.T) {
98102
_, err := fsys.Create(utils.DenoPathOverride)
99103
require.NoError(t, err)
100104
// Run test
101-
err = Run(context.Background(), slug, project, true, afero.NewReadOnlyFs(fsys))
105+
err = Run(context.Background(), slug, project, true, false, false, afero.NewReadOnlyFs(fsys))
102106
// Check error
103107
assert.ErrorContains(t, err, "operation not permitted")
104108
})
@@ -121,7 +125,7 @@ func TestDownloadCommand(t *testing.T) {
121125
Reply(http.StatusNotFound).
122126
JSON(map[string]string{"message": "Function not found"})
123127
// Run test
124-
err = Run(context.Background(), slug, project, true, fsys)
128+
err = Run(context.Background(), slug, project, true, false, false, fsys)
125129
// Check error
126130
assert.ErrorContains(t, err, "Function test-func does not exist on the Supabase project.")
127131
})
@@ -235,3 +239,132 @@ func TestGetMetadata(t *testing.T) {
235239
assert.Nil(t, meta)
236240
})
237241
}
242+
243+
func TestNormalizeRelativePath(t *testing.T) {
244+
t.Parallel()
245+
246+
t.Run("returns cleaned relative path", func(t *testing.T) {
247+
got := normalizeRelativePath("test-func", "src/index.ts")
248+
assert.Equal(t, filepath.Join("src", "index.ts"), got)
249+
})
250+
251+
t.Run("strips slug prefix", func(t *testing.T) {
252+
got := normalizeRelativePath("test-func", "test-func/index.ts")
253+
assert.Equal(t, "index.ts", got)
254+
})
255+
256+
t.Run("strips source prefix", func(t *testing.T) {
257+
got := normalizeRelativePath("test-func", "source/index.ts")
258+
assert.Equal(t, "index.ts", got)
259+
})
260+
261+
t.Run("skips slug directory itself", func(t *testing.T) {
262+
got := normalizeRelativePath("test-func", "test-func")
263+
assert.Equal(t, "", got)
264+
})
265+
}
266+
267+
func TestResolvedPartPath(t *testing.T) {
268+
t.Parallel()
269+
270+
newPart := func(headers map[string]string) *multipart.Part {
271+
mh := make(textproto.MIMEHeader, len(headers))
272+
for k, v := range headers {
273+
mh.Set(k, v)
274+
}
275+
return &multipart.Part{Header: mh}
276+
}
277+
278+
t.Run("returns path from Supabase header", func(t *testing.T) {
279+
part := newPart(map[string]string{
280+
"Supabase-Path": "dir/file.ts",
281+
})
282+
got, err := resolvedPartPath("test-func", part)
283+
require.NoError(t, err)
284+
assert.Equal(t, filepath.Join("dir", "file.ts"), got)
285+
})
286+
287+
t.Run("returns filename from content disposition", func(t *testing.T) {
288+
part := newPart(map[string]string{
289+
"Content-Disposition": `form-data; name="file"; filename="test-func/index.ts"`,
290+
})
291+
got, err := resolvedPartPath("test-func", part)
292+
require.NoError(t, err)
293+
assert.Equal(t, "index.ts", got)
294+
})
295+
296+
t.Run("returns filename from editor-originated content disposition", func(t *testing.T) {
297+
part := newPart(map[string]string{
298+
"Content-Disposition": `form-data; name="file"; filename="source/index.ts"`,
299+
})
300+
got, err := resolvedPartPath("test-func", part)
301+
require.NoError(t, err)
302+
assert.Equal(t, "index.ts", got)
303+
})
304+
305+
t.Run("writes file of arbitrary depth to slug directory", func(t *testing.T) {
306+
part := newPart(map[string]string{
307+
"Content-Disposition": `form-data; name="file"; filename="test-func/dir/subdir/file.ts"`,
308+
})
309+
got, err := resolvedPartPath("test-func", part)
310+
require.NoError(t, err)
311+
assert.Equal(t, filepath.Join("dir", "subdir", "file.ts"), got)
312+
})
313+
314+
t.Run("returns empty when no filename provided", func(t *testing.T) {
315+
part := newPart(map[string]string{
316+
"Content-Disposition": `form-data; name="file"`,
317+
})
318+
got, err := resolvedPartPath("test-func", part)
319+
require.NoError(t, err)
320+
assert.Equal(t, "", got)
321+
})
322+
323+
t.Run("returns error on invalid content disposition", func(t *testing.T) {
324+
part := newPart(map[string]string{
325+
"Content-Disposition": `form-data; filename="unterminated`,
326+
})
327+
got, err := resolvedPartPath("test-func", part)
328+
require.ErrorContains(t, err, "failed to parse content disposition")
329+
assert.Equal(t, "", got)
330+
})
331+
}
332+
333+
func TestJoinWithinDir(t *testing.T) {
334+
t.Parallel()
335+
336+
base := filepath.Join(os.TempDir(), "base-dir")
337+
338+
t.Run("joins path within base directory", func(t *testing.T) {
339+
got, err := joinWithinDir(base, filepath.Join("sub", "file.ts"))
340+
require.NoError(t, err)
341+
assert.True(t, strings.HasPrefix(filepath.Clean(got), filepath.Clean(base)+"/") || filepath.Clean(got) == filepath.Clean(base))
342+
})
343+
344+
t.Run("treats leading slash as relative to base", func(t *testing.T) {
345+
got, err := joinWithinDir(base, "/foo/bar.ts")
346+
require.NoError(t, err)
347+
assert.True(t, strings.HasPrefix(filepath.Clean(got), filepath.Clean(base)+"/"))
348+
assert.Equal(t, filepath.Join(filepath.Clean(base), "foo", "bar.ts"), filepath.Clean(got))
349+
})
350+
351+
t.Run("rejects absolute path", func(t *testing.T) {
352+
abs := "/" + filepath.Join("etc", "passwd")
353+
got, err := joinWithinDir(base, abs)
354+
require.NoError(t, err)
355+
assert.True(t, strings.HasPrefix(filepath.Clean(got), filepath.Clean(base)+"/"))
356+
})
357+
358+
t.Run("rejects parent directory traversal", func(t *testing.T) {
359+
got, err := joinWithinDir(base, filepath.Join("..", "escape"))
360+
require.Error(t, err)
361+
assert.Equal(t, "", got)
362+
})
363+
364+
t.Run("accepts traversal within base directory", func(t *testing.T) {
365+
base = os.TempDir()
366+
got, err := joinWithinDir(base, filepath.Join("some", "..", "file.ts"))
367+
require.NoError(t, err)
368+
assert.Equal(t, filepath.Join(base, "file.ts"), got)
369+
})
370+
}

0 commit comments

Comments
 (0)