• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

supabase / cli / 19088750195

05 Nov 2025 01:56AM UTC coverage: 54.482% (-0.2%) from 54.699%
19088750195

Pull #4381

github

web-flow
Merge b235fb22d into 8f3bf1cde
Pull Request #4381: feat: `functions download foo --use-api`

43 of 126 new or added lines in 2 files covered. (34.13%)

5 existing lines in 1 file now uncovered.

6430 of 11802 relevant lines covered (54.48%)

6.14 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

39.15
/internal/functions/download/download.go
1
package download
2

3
import (
4
        "bufio"
5
        "bytes"
6
        "context"
7
        "fmt"
8
        "io"
9
        "mime"
10
        "mime/multipart"
11
        "net/http"
12
        "os"
13
        "os/exec"
14
        "path"
15
        "path/filepath"
16
        "strings"
17

18
        "github.com/andybalholm/brotli"
19
        "github.com/docker/docker/api/types/container"
20
        "github.com/docker/docker/api/types/network"
21
        "github.com/go-errors/errors"
22
        "github.com/spf13/afero"
23
        "github.com/spf13/viper"
24
        "github.com/supabase/cli/internal/utils"
25
        "github.com/supabase/cli/internal/utils/flags"
26
        "github.com/supabase/cli/pkg/api"
27
)
28

29
var (
30
        legacyEntrypointPath = "file:///src/index.ts"
31
        legacyImportMapPath  = "file:///src/import_map.json"
32
)
33

34
func RunLegacy(ctx context.Context, slug string, projectRef string, fsys afero.Fs) error {
5✔
35
        // 1. Sanity checks.
5✔
36
        {
10✔
37
                if err := utils.ValidateFunctionSlug(slug); err != nil {
6✔
38
                        return err
1✔
39
                }
1✔
40
        }
41
        if err := utils.InstallOrUpgradeDeno(ctx, fsys); err != nil {
5✔
42
                return err
1✔
43
        }
1✔
44

45
        scriptDir, err := utils.CopyDenoScripts(ctx, fsys)
3✔
46
        if err != nil {
4✔
47
                return err
1✔
48
        }
1✔
49

50
        // 2. Download Function.
51
        if err := downloadFunction(ctx, projectRef, slug, scriptDir.ExtractPath); err != nil {
3✔
52
                return err
1✔
53
        }
1✔
54

55
        fmt.Println("Downloaded Function " + utils.Aqua(slug) + " from project " + utils.Aqua(projectRef) + ".")
1✔
56
        return nil
1✔
57
}
58

59
func getFunctionMetadata(ctx context.Context, projectRef, slug string) (*api.FunctionSlugResponse, error) {
8✔
60
        resp, err := utils.GetSupabase().V1GetAFunctionWithResponse(ctx, projectRef, slug)
8✔
61
        if err != nil {
9✔
62
                return nil, errors.Errorf("failed to get function metadata: %w", err)
1✔
63
        }
1✔
64

65
        switch resp.StatusCode() {
7✔
66
        case http.StatusNotFound:
1✔
67
                return nil, errors.Errorf("Function %s does not exist on the Supabase project.", utils.Aqua(slug))
1✔
68
        case http.StatusOK:
5✔
69
                break
5✔
70
        default:
1✔
71
                return nil, errors.Errorf("Failed to download Function %s on the Supabase project: %s", utils.Aqua(slug), string(resp.Body))
1✔
72
        }
73

74
        if resp.JSON200.EntrypointPath == nil {
10✔
75
                resp.JSON200.EntrypointPath = &legacyEntrypointPath
5✔
76
        }
5✔
77
        if resp.JSON200.ImportMapPath == nil {
10✔
78
                resp.JSON200.ImportMapPath = &legacyImportMapPath
5✔
79
        }
5✔
80
        return resp.JSON200, nil
5✔
81
}
82

83
func downloadFunction(ctx context.Context, projectRef, slug, extractScriptPath string) error {
5✔
84
        fmt.Println("Downloading " + utils.Bold(slug))
5✔
85
        denoPath, err := utils.GetDenoPath()
5✔
86
        if err != nil {
5✔
87
                return err
×
88
        }
×
89

90
        meta, err := getFunctionMetadata(ctx, projectRef, slug)
5✔
91
        if err != nil {
6✔
92
                return err
1✔
93
        }
1✔
94

95
        resp, err := utils.GetSupabase().V1GetAFunctionBodyWithResponse(ctx, projectRef, slug)
4✔
96
        if err != nil {
5✔
97
                return errors.Errorf("failed to get function body: %w", err)
1✔
98
        }
1✔
99
        if resp.StatusCode() != http.StatusOK {
4✔
100
                return errors.New("Unexpected error downloading Function: " + string(resp.Body))
1✔
101
        }
1✔
102

103
        resBuf := bytes.NewReader(resp.Body)
2✔
104
        funcDir := filepath.Join(utils.FunctionsDir, slug)
2✔
105
        args := []string{"run", "-A", extractScriptPath, funcDir, *meta.EntrypointPath}
2✔
106
        cmd := exec.CommandContext(ctx, denoPath, args...)
2✔
107
        var errBuf bytes.Buffer
2✔
108
        cmd.Stdin = resBuf
2✔
109
        cmd.Stdout = os.Stdout
2✔
110
        cmd.Stderr = &errBuf
2✔
111
        if err := cmd.Run(); err != nil {
3✔
112
                return errors.Errorf("Error downloading function: %w\n%v", err, errBuf.String())
1✔
113
        }
1✔
114
        return nil
1✔
115
}
116

117
func Run(ctx context.Context, slug, projectRef string, useLegacyBundle, useDocker bool, fsys afero.Fs) error {
5✔
118
        // Sanity check
5✔
119
        if err := flags.LoadConfig(fsys); err != nil {
5✔
NEW
120
                return err
×
NEW
121
        }
×
122

123
        if useLegacyBundle {
10✔
124
                return RunLegacy(ctx, slug, projectRef, fsys)
5✔
125
        }
5✔
126

NEW
127
        if useDocker {
×
NEW
128
                if utils.IsDockerRunning(ctx) {
×
NEW
129
                        // download eszip file for client-side unbundling with edge-runtime
×
NEW
130
                        return downloadWithDockerUnbundle(ctx, slug, projectRef, fsys)
×
NEW
131
                } else {
×
NEW
132
                        fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "Docker is not running")
×
NEW
133
                }
×
134
        }
135

136
        // Use server-side unbundling with multipart/form-data
NEW
137
        return downloadWithServerSideUnbundle(ctx, slug, projectRef, fsys)
×
138
}
139

NEW
140
func downloadWithDockerUnbundle(ctx context.Context, slug string, projectRef string, fsys afero.Fs) error {
×
141
        eszipPath, err := downloadOne(ctx, slug, projectRef, fsys)
×
142
        if err != nil {
×
143
                return err
×
144
        }
×
145
        if !viper.GetBool("DEBUG") {
×
146
                defer func() {
×
147
                        if err := fsys.Remove(eszipPath); err != nil {
×
148
                                fmt.Fprintln(os.Stderr, err)
×
149
                        }
×
150
                }()
151
        }
152
        // Extract eszip to functions directory
153
        err = extractOne(ctx, slug, eszipPath)
×
154
        if err != nil {
×
155
                utils.CmdSuggestion += suggestLegacyBundle(slug)
×
156
        }
×
157
        return err
×
158
}
159

160
func downloadOne(ctx context.Context, slug, projectRef string, fsys afero.Fs) (string, error) {
×
161
        fmt.Println("Downloading " + utils.Bold(slug))
×
162
        resp, err := utils.GetSupabase().V1GetAFunctionBody(ctx, projectRef, slug)
×
163
        if err != nil {
×
164
                return "", errors.Errorf("failed to get function body: %w", err)
×
165
        }
×
166
        defer resp.Body.Close()
×
167
        if resp.StatusCode != http.StatusOK {
×
168
                body, err := io.ReadAll(resp.Body)
×
169
                if err != nil {
×
170
                        return "", errors.Errorf("Error status %d: unexpected error downloading Function", resp.StatusCode)
×
171
                }
×
172
                return "", errors.Errorf("Error status %d: %s", resp.StatusCode, string(body))
×
173
        }
174
        r := io.Reader(resp.Body)
×
175
        if strings.EqualFold(resp.Header.Get("Content-Encoding"), "br") {
×
176
                r = brotli.NewReader(resp.Body)
×
177
        }
×
178
        // Create temp file to store downloaded eszip
179
        eszipPath := filepath.Join(utils.TempDir, fmt.Sprintf("output_%s.eszip", slug))
×
180
        if err := utils.MkdirIfNotExistFS(fsys, utils.TempDir); err != nil {
×
181
                return "", err
×
182
        }
×
183
        if err := afero.WriteReader(fsys, eszipPath, r); err != nil {
×
184
                return "", errors.Errorf("failed to download file: %w", err)
×
185
        }
×
186
        return eszipPath, nil
×
187
}
188

189
func extractOne(ctx context.Context, slug, eszipPath string) error {
×
190
        hostFuncDirPath, err := filepath.Abs(filepath.Join(utils.FunctionsDir, slug))
×
191
        if err != nil {
×
192
                return errors.Errorf("failed to resolve absolute path: %w", err)
×
193
        }
×
194

195
        hostEszipPath, err := filepath.Abs(eszipPath)
×
196
        if err != nil {
×
197
                return errors.Errorf("failed to resolve eszip path: %w", err)
×
198
        }
×
199
        dockerEszipPath := path.Join(utils.DockerEszipDir, filepath.Base(hostEszipPath))
×
200

×
201
        binds := []string{
×
202
                // Reuse deno cache directory, ie. DENO_DIR, between container restarts
×
203
                // https://denolib.gitbook.io/guide/advanced/deno_dir-code-fetch-and-cache
×
204
                utils.EdgeRuntimeId + ":/root/.cache/deno:rw",
×
205
                hostEszipPath + ":" + dockerEszipPath + ":ro",
×
206
                hostFuncDirPath + ":" + utils.DockerDenoDir + ":rw",
×
207
        }
×
208

×
209
        return utils.DockerRunOnceWithConfig(
×
210
                ctx,
×
211
                container.Config{
×
212
                        Image: utils.Config.EdgeRuntime.Image,
×
213
                        Cmd:   []string{"unbundle", "--eszip", dockerEszipPath, "--output", utils.DockerDenoDir},
×
214
                },
×
215
                container.HostConfig{
×
216
                        Binds: binds,
×
217
                },
×
218
                network.NetworkingConfig{},
×
219
                "",
×
220
                os.Stdout,
×
221
                getErrorLogger(),
×
222
        )
×
223
}
224

225
func getErrorLogger() io.Writer {
×
226
        if utils.Config.EdgeRuntime.DenoVersion > 1 {
×
227
                return os.Stderr
×
228
        }
×
229
        // Additional error handling for deno v1
230
        r, w := io.Pipe()
×
231
        go func() {
×
232
                logs := bufio.NewScanner(r)
×
233
                for logs.Scan() {
×
234
                        line := logs.Text()
×
235
                        fmt.Fprintln(os.Stderr, line)
×
236
                        if strings.EqualFold(line, "invalid eszip v2") {
×
237
                                utils.CmdSuggestion = suggestDenoV2()
×
238
                        }
×
239
                }
240
                if err := logs.Err(); err != nil {
×
241
                        fmt.Fprintln(os.Stderr, err)
×
242
                }
×
243
        }()
244
        return w
×
245
}
246

247
func suggestDenoV2() string {
×
248
        return fmt.Sprintf(`Please use deno v2 in %s to download this Function:
×
249

×
250
[edge_runtime]
×
251
deno_version = 2
×
252
`, utils.Bold(utils.ConfigPath))
×
253
}
×
254

255
func suggestLegacyBundle(slug string) string {
×
256
        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))
×
257
}
×
258

NEW
259
func downloadWithServerSideUnbundle(ctx context.Context, slug, projectRef string, fsys afero.Fs) error {
×
NEW
260
        fmt.Fprintln(os.Stderr, "Downloading "+utils.Bold(slug))
×
NEW
261

×
NEW
262
        // Request multipart/form-data response using RequestEditorFn
×
NEW
263
        resp, err := utils.GetSupabase().V1GetAFunctionBody(ctx, projectRef, slug, func(ctx context.Context, req *http.Request) error {
×
NEW
264
                req.Header.Set("Accept", "multipart/form-data")
×
NEW
265
                return nil
×
NEW
266
        })
×
NEW
267
        if err != nil {
×
NEW
268
                return errors.Errorf("failed to download function: %w", err)
×
NEW
269
        }
×
NEW
270
        defer resp.Body.Close()
×
NEW
271

×
NEW
272
        if resp.StatusCode != http.StatusOK {
×
NEW
273
                body, err := io.ReadAll(resp.Body)
×
NEW
274
                if err != nil {
×
NEW
275
                        return errors.Errorf("Error status %d: %w", resp.StatusCode, err)
×
NEW
276
                }
×
NEW
277
                return errors.Errorf("Error status %d: %s", resp.StatusCode, string(body))
×
278
        }
279

280
        // Parse the multipart response
NEW
281
        mediaType, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
×
NEW
282
        if err != nil {
×
NEW
283
                return errors.Errorf("failed to parse content type: %w", err)
×
NEW
284
        }
×
285

NEW
286
        if !strings.HasPrefix(mediaType, "multipart/") {
×
NEW
287
                return errors.Errorf("expected multipart response, got %s", mediaType)
×
NEW
288
        }
×
289

290
        // Create function directory
NEW
291
        funcDir := filepath.Join(utils.FunctionsDir, slug)
×
NEW
292

×
NEW
293
        if err := utils.MkdirIfNotExistFS(fsys, funcDir); err != nil {
×
NEW
294
                return err
×
NEW
295
        }
×
296

297
        // Parse multipart form
NEW
298
        mr := multipart.NewReader(resp.Body, params["boundary"])
×
NEW
299
        for {
×
NEW
300
                part, err := mr.NextPart()
×
NEW
301
                if errors.Is(err, io.EOF) {
×
NEW
302
                        break
×
303
                }
NEW
304
                if err != nil {
×
NEW
305
                        return errors.Errorf("failed to read multipart: %w", err)
×
NEW
306
                }
×
307

308
                // Determine the relative path from headers to preserve directory structure.
NEW
309
                relPath, err := resolvedPartPath(slug, part) // always starts with :slug
×
NEW
310
                if err != nil {
×
NEW
311
                        return err
×
NEW
312
                }
×
313

314
                // result of invalid or missing filename but we're letting it slide
NEW
315
                if relPath == "" {
×
NEW
316
                        fmt.Fprintln(utils.GetDebugLogger(), "Skipping part without filename")
×
NEW
317
                        continue
×
318
                }
319

NEW
320
                filePath, err := joinWithinDir(funcDir, relPath)
×
NEW
321
                if err != nil {
×
NEW
322
                        return err
×
NEW
323
                }
×
324

NEW
325
                if err := afero.WriteReader(fsys, filePath, part); err != nil {
×
NEW
326
                        return errors.Errorf("failed to write file: %w", err)
×
NEW
327
                }
×
328
        }
329

NEW
330
        fmt.Println("Downloaded Function " + utils.Aqua(slug) + " from project " + utils.Aqua(projectRef) + ".")
×
NEW
331
        return nil
×
332
}
333

334
// parse multipart part headers to read and sanitize relative file path for writing
335
func resolvedPartPath(slug string, part *multipart.Part) (string, error) {
6✔
336
        // dedicated header to specify relative path, not expected to be used
6✔
337
        if relPath := part.Header.Get("Supabase-Path"); relPath != "" {
7✔
338
                return normalizeRelativePath(slug, relPath), nil
1✔
339
        }
1✔
340

341
        // part.FileName() does not allow us to handle relative paths, so we parse Content-Disposition manually
342
        cd := part.Header.Get("Content-Disposition")
5✔
343
        if cd == "" {
5✔
NEW
344
                return "", nil
×
NEW
345
        }
×
346

347
        _, params, err := mime.ParseMediaType(cd)
5✔
348
        if err != nil {
6✔
349
                return "", errors.Errorf("failed to parse content disposition: %w", err)
1✔
350
        }
1✔
351

352
        if filename := params["filename"]; filename != "" {
7✔
353
                return normalizeRelativePath(slug, filename), nil
3✔
354
        }
3✔
355
        return "", nil
1✔
356
}
357

358
// remove leading source/ or :slug/
359
func normalizeRelativePath(slug, raw string) string {
8✔
360
        cleaned := path.Clean(raw)
8✔
361
        if after, ok := strings.CutPrefix(cleaned, "source/"); ok {
10✔
362
                cleaned = after
2✔
363
        } else if after, ok := strings.CutPrefix(cleaned, slug+"/"); ok {
11✔
364
                cleaned = after
3✔
365
        } else if cleaned == slug {
7✔
366
                // If the path is exactly :slug, skip it
1✔
367
                cleaned = ""
1✔
368
        }
1✔
369
        return cleaned
8✔
370
}
371

372
// joinWithinDir safely joins base and rel ensuring the result stays within base directory
373
func joinWithinDir(base, rel string) (string, error) {
6✔
374
        cleanRel := filepath.Clean(rel)
6✔
375
        // Be forgiving: treat a rooted path as relative to base (e.g. "/foo" -> "foo")
6✔
376
        if filepath.IsAbs(cleanRel) {
8✔
377
                cleanRel = strings.TrimLeft(cleanRel, "/\\")
2✔
378
        }
2✔
379
        if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") {
8✔
380
                return "", errors.Errorf("invalid file path outside function directory: %s", rel)
2✔
381
        }
2✔
382
        joined := filepath.Join(base, cleanRel)
4✔
383
        cleanJoined := filepath.Clean(joined)
4✔
384
        cleanBase := filepath.Clean(base)
4✔
385
        if cleanJoined != cleanBase && !strings.HasPrefix(cleanJoined, cleanBase+"/") {
4✔
NEW
386
                return "", errors.Errorf("refusing to write outside function directory: %s", rel)
×
NEW
387
        }
×
388
        return joined, nil
4✔
389
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc