Skip to content

Commit 307f5c0

Browse files
rgarciaclaude
andcommitted
profile download: extract zstd tar to directory + handle 202
The download endpoint now returns a zstd-compressed tar of the full user-data directory, plus 202 when the profile has not yet captured state. Update the CLI subcommand to require --to <dir>, stream the archive into that directory, and surface the 202 case as a friendly info message instead of writing an empty/invalid file. Also bump kernel-go-sdk to v0.52.0 and add klauspost/compress for zstd decoding. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 7e6329b commit 307f5c0

4 files changed

Lines changed: 151 additions & 94 deletions

File tree

cmd/profiles.go

Lines changed: 81 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
package cmd
22

33
import (
4-
"bytes"
4+
"archive/tar"
55
"context"
6-
"encoding/json"
6+
"errors"
77
"fmt"
88
"io"
99
"net/http"
1010
"os"
11+
"path/filepath"
12+
"strings"
1113

1214
"github.com/kernel/cli/pkg/util"
1315
"github.com/kernel/kernel-go-sdk"
1416
"github.com/kernel/kernel-go-sdk/option"
1517
"github.com/kernel/kernel-go-sdk/packages/pagination"
18+
"github.com/klauspost/compress/zstd"
1619
"github.com/pterm/pterm"
1720
"github.com/samber/lo"
1821
"github.com/spf13/cobra"
@@ -51,8 +54,7 @@ type ProfilesDeleteInput struct {
5154

5255
type ProfilesDownloadInput struct {
5356
Identifier string
54-
Output string
55-
Pretty bool
57+
To string
5658
}
5759

5860
// ProfilesCmd handles profile operations independent of cobra.
@@ -246,48 +248,91 @@ func (p ProfilesCmd) Delete(ctx context.Context, in ProfilesDeleteInput) error {
246248
}
247249

248250
func (p ProfilesCmd) Download(ctx context.Context, in ProfilesDownloadInput) error {
251+
if in.To == "" {
252+
return fmt.Errorf("missing required --to <path> for extraction directory")
253+
}
254+
249255
res, err := p.profiles.Download(ctx, in.Identifier)
250256
if err != nil {
251257
return util.CleanedUpSdkError{Err: err}
252258
}
253259
defer res.Body.Close()
254260

255-
if in.Output == "" {
256-
pterm.Error.Println("Missing --to output file path")
261+
if res.StatusCode == http.StatusAccepted {
257262
_, _ = io.Copy(io.Discard, res.Body)
263+
pterm.Info.Printf("Profile '%s' has no saved data yet. Use it in a browser session first to capture state.\n", in.Identifier)
258264
return nil
259265
}
260266

261-
f, err := os.Create(in.Output)
267+
if res.StatusCode != http.StatusOK {
268+
body, _ := io.ReadAll(res.Body)
269+
return fmt.Errorf("unexpected status %d from profile download: %s", res.StatusCode, strings.TrimSpace(string(body)))
270+
}
271+
272+
if err := extractProfileArchive(res.Body, in.To); err != nil {
273+
return fmt.Errorf("extract profile archive: %w", err)
274+
}
275+
276+
pterm.Success.Printf("Extracted profile '%s' to %s\n", in.Identifier, in.To)
277+
return nil
278+
}
279+
280+
// extractProfileArchive streams a zstd-compressed tar archive into destDir.
281+
// Files and directories are created relative to destDir; symlinks and other
282+
// special entry types are skipped. Path-traversal entries are rejected.
283+
func extractProfileArchive(r io.Reader, destDir string) error {
284+
if err := os.MkdirAll(destDir, 0o755); err != nil {
285+
return fmt.Errorf("create destination: %w", err)
286+
}
287+
288+
cleanedDest, err := filepath.Abs(destDir)
262289
if err != nil {
263-
pterm.Error.Printf("Failed to create file: %v\n", err)
264-
return nil
290+
return fmt.Errorf("resolve destination: %w", err)
265291
}
266-
defer f.Close()
267-
if in.Pretty {
268-
var buf bytes.Buffer
269-
body, _ := io.ReadAll(res.Body)
270-
if len(body) == 0 {
271-
pterm.Error.Println("Empty response body")
272-
return nil
292+
293+
decoder, err := zstd.NewReader(r)
294+
if err != nil {
295+
return fmt.Errorf("zstd init: %w", err)
296+
}
297+
defer decoder.Close()
298+
299+
tr := tar.NewReader(decoder)
300+
for {
301+
header, err := tr.Next()
302+
if errors.Is(err, io.EOF) {
303+
break
273304
}
274-
if err := json.Indent(&buf, body, "", " "); err != nil {
275-
pterm.Error.Printf("Failed to pretty-print JSON: %v\n", err)
276-
return nil
305+
if err != nil {
306+
return fmt.Errorf("tar read: %w", err)
277307
}
278-
if _, err := io.Copy(f, &buf); err != nil {
279-
pterm.Error.Printf("Failed to write pretty-printed JSON: %v\n", err)
280-
return nil
308+
309+
destPath := filepath.Join(cleanedDest, header.Name)
310+
if !strings.HasPrefix(destPath, cleanedDest+string(os.PathSeparator)) && destPath != cleanedDest {
311+
return fmt.Errorf("illegal entry path: %s", header.Name)
281312
}
282-
return nil
283-
} else {
284-
if _, err := io.Copy(f, res.Body); err != nil {
285-
pterm.Error.Printf("Failed to write file: %v\n", err)
286-
return nil
313+
314+
switch header.Typeflag {
315+
case tar.TypeDir:
316+
if err := os.MkdirAll(destPath, 0o755); err != nil {
317+
return fmt.Errorf("mkdir %s: %w", destPath, err)
318+
}
319+
case tar.TypeReg:
320+
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
321+
return fmt.Errorf("mkdir parent of %s: %w", destPath, err)
322+
}
323+
f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(header.Mode)&0o777)
324+
if err != nil {
325+
return fmt.Errorf("create %s: %w", destPath, err)
326+
}
327+
if _, err := io.Copy(f, tr); err != nil {
328+
f.Close()
329+
return fmt.Errorf("write %s: %w", destPath, err)
330+
}
331+
if err := f.Close(); err != nil {
332+
return fmt.Errorf("close %s: %w", destPath, err)
333+
}
287334
}
288335
}
289-
290-
pterm.Success.Printf("Saved profile to %s\n", in.Output)
291336
return nil
292337
}
293338

@@ -329,8 +374,9 @@ var profilesDeleteCmd = &cobra.Command{
329374
}
330375

331376
var profilesDownloadCmd = &cobra.Command{
332-
Use: "download <id-or-name>",
333-
Short: "Download a profile as a ZIP archive",
377+
Use: "download <id-or-name> --to <dir>",
378+
Short: "Download a profile and extract it to a directory",
379+
Long: "Download a profile and extract its zstd-compressed user-data tar archive into the directory given by --to. The directory is created if it does not exist.",
334380
Args: cobra.ExactArgs(1),
335381
RunE: runProfilesDownload,
336382
}
@@ -350,8 +396,8 @@ func init() {
350396
profilesCreateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response")
351397
profilesCreateCmd.Flags().String("name", "", "Optional unique profile name")
352398
profilesDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
353-
profilesDownloadCmd.Flags().String("to", "", "Output zip file path")
354-
profilesDownloadCmd.Flags().Bool("pretty", false, "Pretty-print JSON to file")
399+
profilesDownloadCmd.Flags().String("to", "", "Directory to extract the profile into (required)")
400+
_ = profilesDownloadCmd.MarkFlagRequired("to")
355401
}
356402

357403
func runProfilesList(cmd *cobra.Command, args []string) error {
@@ -398,9 +444,8 @@ func runProfilesDelete(cmd *cobra.Command, args []string) error {
398444

399445
func runProfilesDownload(cmd *cobra.Command, args []string) error {
400446
client := getKernelClient(cmd)
401-
out, _ := cmd.Flags().GetString("to")
402-
pretty, _ := cmd.Flags().GetBool("pretty")
447+
to, _ := cmd.Flags().GetString("to")
403448
svc := client.Profiles
404449
p := ProfilesCmd{profiles: &svc}
405-
return p.Download(cmd.Context(), ProfilesDownloadInput{Identifier: args[0], Output: out, Pretty: pretty})
450+
return p.Download(cmd.Context(), ProfilesDownloadInput{Identifier: args[0], To: to})
406451
}

cmd/profiles_test.go

Lines changed: 63 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
package cmd
22

33
import (
4+
"archive/tar"
45
"bytes"
56
"context"
67
"errors"
78
"fmt"
89
"io"
910
"net/http"
1011
"os"
12+
"path/filepath"
1113
"strings"
1214
"testing"
1315
"time"
1416

1517
"github.com/kernel/kernel-go-sdk"
1618
"github.com/kernel/kernel-go-sdk/option"
1719
"github.com/kernel/kernel-go-sdk/packages/pagination"
20+
"github.com/klauspost/compress/zstd"
1821
"github.com/pterm/pterm"
1922
"github.com/stretchr/testify/assert"
2023
)
@@ -224,86 +227,92 @@ func TestProfilesDelete_SkipConfirm(t *testing.T) {
224227
assert.Contains(t, buf.String(), "Deleted profile: a")
225228
}
226229

227-
func TestProfilesDownload_MissingOutput(t *testing.T) {
228-
buf := captureProfilesOutput(t)
229-
fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) {
230-
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("content")), Header: http.Header{}}, nil
231-
}}
230+
// makeProfileArchive builds a zstd-compressed tar archive from a map of file
231+
// paths to contents, for use in download tests.
232+
func makeProfileArchive(t *testing.T, files map[string]string) []byte {
233+
t.Helper()
234+
var buf bytes.Buffer
235+
zw, err := zstd.NewWriter(&buf)
236+
assert.NoError(t, err)
237+
tw := tar.NewWriter(zw)
238+
for name, content := range files {
239+
hdr := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(content)), Typeflag: tar.TypeReg}
240+
assert.NoError(t, tw.WriteHeader(hdr))
241+
_, err := tw.Write([]byte(content))
242+
assert.NoError(t, err)
243+
}
244+
assert.NoError(t, tw.Close())
245+
assert.NoError(t, zw.Close())
246+
return buf.Bytes()
247+
}
248+
249+
func TestProfilesDownload_MissingTo(t *testing.T) {
250+
fake := &FakeProfilesService{}
232251
p := ProfilesCmd{profiles: fake}
233-
_ = p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", Output: "", Pretty: false})
234-
assert.Contains(t, buf.String(), "Missing --to output file path")
252+
err := p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", To: ""})
253+
assert.Error(t, err)
254+
assert.Contains(t, err.Error(), "missing required --to")
235255
}
236256

237-
func TestProfilesDownload_RawSuccess(t *testing.T) {
257+
func TestProfilesDownload_ExtractSuccess(t *testing.T) {
238258
buf := captureProfilesOutput(t)
239-
f, err := os.CreateTemp("", "profile-*.zip")
259+
dir, err := os.MkdirTemp("", "profile-*")
240260
assert.NoError(t, err)
241-
name := f.Name()
242-
_ = f.Close()
243-
defer os.Remove(name)
261+
defer os.RemoveAll(dir)
244262

245-
content := "hello"
263+
archive := makeProfileArchive(t, map[string]string{
264+
"Default/Preferences": "{\"k\":1}",
265+
"Local State": "local",
266+
})
246267
fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) {
247-
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(content)), Header: http.Header{}}, nil
268+
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(archive)), Header: http.Header{}}, nil
248269
}}
249270
p := ProfilesCmd{profiles: fake}
250-
_ = p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", Output: name, Pretty: false})
251-
252-
b, readErr := os.ReadFile(name)
253-
assert.NoError(t, readErr)
254-
assert.Equal(t, content, string(b))
255-
assert.Contains(t, buf.String(), "Saved profile to "+name)
256-
}
257-
258-
func TestProfilesDownload_PrettySuccess(t *testing.T) {
259-
f, err := os.CreateTemp("", "profile-*.json")
271+
err = p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", To: dir})
260272
assert.NoError(t, err)
261-
name := f.Name()
262-
_ = f.Close()
263-
defer os.Remove(name)
264273

265-
jsonBody := "{\"a\":1}"
266-
fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) {
267-
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(jsonBody)), Header: http.Header{}}, nil
268-
}}
269-
p := ProfilesCmd{profiles: fake}
270-
_ = p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", Output: name, Pretty: true})
274+
b, readErr := os.ReadFile(filepath.Join(dir, "Default", "Preferences"))
275+
assert.NoError(t, readErr)
276+
assert.Equal(t, "{\"k\":1}", string(b))
271277

272-
b, readErr := os.ReadFile(name)
278+
b2, readErr := os.ReadFile(filepath.Join(dir, "Local State"))
273279
assert.NoError(t, readErr)
274-
out := string(b)
275-
assert.Contains(t, out, "\n")
276-
assert.Contains(t, out, "\"a\": 1")
280+
assert.Equal(t, "local", string(b2))
281+
282+
assert.Contains(t, buf.String(), "Extracted profile 'p1' to "+dir)
277283
}
278284

279-
func TestProfilesDownload_PrettyEmptyBody(t *testing.T) {
285+
func TestProfilesDownload_202NoData(t *testing.T) {
280286
buf := captureProfilesOutput(t)
281-
f, err := os.CreateTemp("", "profile-*.json")
287+
dir, err := os.MkdirTemp("", "profile-*")
282288
assert.NoError(t, err)
283-
name := f.Name()
284-
_ = f.Close()
285-
defer os.Remove(name)
289+
defer os.RemoveAll(dir)
286290

287291
fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) {
288-
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("")), Header: http.Header{}}, nil
292+
return &http.Response{StatusCode: http.StatusAccepted, Body: io.NopCloser(strings.NewReader("")), Header: http.Header{}}, nil
289293
}}
290294
p := ProfilesCmd{profiles: fake}
291-
_ = p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", Output: name, Pretty: true})
292-
assert.Contains(t, buf.String(), "Empty response body")
295+
err = p.Download(context.Background(), ProfilesDownloadInput{Identifier: "fresh", To: dir})
296+
assert.NoError(t, err)
297+
assert.Contains(t, buf.String(), "no saved data yet")
298+
299+
entries, _ := os.ReadDir(dir)
300+
assert.Empty(t, entries)
293301
}
294302

295-
func TestProfilesDownload_PrettyInvalidJSON(t *testing.T) {
296-
buf := captureProfilesOutput(t)
297-
f, err := os.CreateTemp("", "profile-*.json")
303+
func TestProfilesDownload_PathTraversalRejected(t *testing.T) {
304+
dir, err := os.MkdirTemp("", "profile-*")
298305
assert.NoError(t, err)
299-
name := f.Name()
300-
_ = f.Close()
301-
defer os.Remove(name)
306+
defer os.RemoveAll(dir)
302307

308+
archive := makeProfileArchive(t, map[string]string{
309+
"../escape": "nope",
310+
})
303311
fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) {
304-
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("not json")), Header: http.Header{}}, nil
312+
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(archive)), Header: http.Header{}}, nil
305313
}}
306314
p := ProfilesCmd{profiles: fake}
307-
_ = p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", Output: name, Pretty: true})
308-
assert.Contains(t, buf.String(), "Failed to pretty-print JSON")
315+
err = p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", To: dir})
316+
assert.Error(t, err)
317+
assert.Contains(t, err.Error(), "illegal entry path")
309318
}

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ require (
99
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
1010
github.com/golang-jwt/jwt/v5 v5.2.2
1111
github.com/joho/godotenv v1.5.1
12-
github.com/kernel/kernel-go-sdk v0.48.0
12+
github.com/kernel/kernel-go-sdk v0.52.0
13+
github.com/klauspost/compress v1.18.5
1314
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
1415
github.com/pterm/pterm v0.12.80
1516
github.com/samber/lo v1.51.0
@@ -20,6 +21,7 @@ require (
2021
golang.org/x/crypto v0.47.0
2122
golang.org/x/oauth2 v0.30.0
2223
golang.org/x/sync v0.19.0
24+
golang.org/x/term v0.39.0
2325
)
2426

2527
require (
@@ -55,7 +57,6 @@ require (
5557
github.com/tidwall/sjson v1.2.5 // indirect
5658
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
5759
golang.org/x/sys v0.40.0 // indirect
58-
golang.org/x/term v0.39.0 // indirect
5960
golang.org/x/text v0.33.0 // indirect
6061
gopkg.in/yaml.v3 v3.0.1 // indirect
6162
)

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,10 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
6464
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
6565
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
6666
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
67-
github.com/kernel/kernel-go-sdk v0.48.0 h1:XX1VVs8D5q+rBMkZovXmKAQa94w+6oEJzxBLikfPaxw=
68-
github.com/kernel/kernel-go-sdk v0.48.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ=
67+
github.com/kernel/kernel-go-sdk v0.52.0 h1:ChRAMo6oMAEmazC610FtcqKFO/cqHzU9v1ECF0MiR8E=
68+
github.com/kernel/kernel-go-sdk v0.52.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ=
69+
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
70+
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
6971
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
7072
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
7173
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=

0 commit comments

Comments
 (0)