Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit f35db41

Browse files
authoredDec 13, 2024··
chore: implement signing of extensions (#79)
* chore: begin work implementing vsix signatures, manifest matching Based on https://github.com/filiptronicek/node-ovsx-sign
1 parent 2c12582 commit f35db41

27 files changed

+716
-147
lines changed
 

‎.github/workflows/build.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
- uses: actions/checkout@v4
1717
- uses: actions/setup-go@v5
1818
with:
19-
go-version: "~1.19"
19+
go-version: "~1.22"
2020

2121
- name: Get Go cache paths
2222
id: go-cache-paths

‎.github/workflows/lint.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ jobs:
2929
- uses: actions/checkout@v4
3030
- uses: actions/setup-go@v5
3131
with:
32-
go-version: "~1.19"
32+
go-version: "~1.22"
3333
- name: golangci-lint
3434
uses: golangci/golangci-lint-action@v6.1.1
3535
with:
36-
version: v1.48.0
36+
version: v1.58.0

‎.github/workflows/test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535
- uses: actions/checkout@v4
3636
- uses: actions/setup-go@v5
3737
with:
38-
go-version: "~1.19"
38+
go-version: "~1.22"
3939

4040
- name: Echo Go Cache Paths
4141
id: go-cache-paths

‎.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
bin
33
coverage
44
extensions
5-
.idea
5+
.idea

‎api/api.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func New(options *Options) *API {
112112
r.Post("/api/extensionquery", api.extensionQuery)
113113

114114
// Endpoint for getting an extension's files or the extension zip.
115-
r.Mount("/files", http.StripPrefix("/files", options.Storage.FileServer()))
115+
r.Mount("/files", http.StripPrefix("/files", storage.HTTPFileServer(options.Storage)))
116116

117117
// VS Code can use the files in the response to get file paths but it will
118118
// sometimes ignore that and use requests to /assets with hardcoded types to

‎api/api_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ func TestAPI(t *testing.T) {
171171
Response: "foobar",
172172
},
173173
{
174-
Name: "FileAPI",
174+
Name: "FileAPINotExists",
175175
Path: "/files/nonexistent",
176176
Status: http.StatusNotFound,
177177
},

‎cli/add.go

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,12 @@ import (
1010
"github.com/spf13/cobra"
1111
"golang.org/x/xerrors"
1212

13-
"cdr.dev/slog"
14-
"cdr.dev/slog/sloggers/sloghuman"
15-
1613
"github.com/coder/code-marketplace/storage"
1714
"github.com/coder/code-marketplace/util"
1815
)
1916

2017
func add() *cobra.Command {
21-
var (
22-
artifactory string
23-
extdir string
24-
repo string
25-
)
26-
18+
addFlags, opts := serverFlags()
2719
cmd := &cobra.Command{
2820
Use: "add <source>",
2921
Short: "Add an extension to the marketplace",
@@ -37,21 +29,7 @@ func add() *cobra.Command {
3729
ctx, cancel := context.WithCancel(cmd.Context())
3830
defer cancel()
3931

40-
verbose, err := cmd.Flags().GetBool("verbose")
41-
if err != nil {
42-
return err
43-
}
44-
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
45-
if verbose {
46-
logger = logger.Leveled(slog.LevelDebug)
47-
}
48-
49-
store, err := storage.NewStorage(ctx, &storage.Options{
50-
Artifactory: artifactory,
51-
ExtDir: extdir,
52-
Logger: logger,
53-
Repo: repo,
54-
})
32+
store, err := storage.NewStorage(ctx, opts)
5533
if err != nil {
5634
return err
5735
}
@@ -98,10 +76,7 @@ func add() *cobra.Command {
9876
return nil
9977
},
10078
}
101-
102-
cmd.Flags().StringVar(&extdir, "extensions-dir", "", "The path to extensions.")
103-
cmd.Flags().StringVar(&artifactory, "artifactory", "", "Artifactory server URL.")
104-
cmd.Flags().StringVar(&repo, "repo", "", "Artifactory repository.")
79+
addFlags(cmd)
10580

10681
return cmd
10782
}

‎cli/remove.go

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,15 @@ import (
1010
"github.com/spf13/cobra"
1111
"golang.org/x/xerrors"
1212

13-
"cdr.dev/slog"
14-
"cdr.dev/slog/sloggers/sloghuman"
15-
1613
"github.com/coder/code-marketplace/storage"
1714
"github.com/coder/code-marketplace/util"
1815
)
1916

2017
func remove() *cobra.Command {
2118
var (
22-
all bool
23-
artifactory string
24-
extdir string
25-
repo string
19+
all bool
2620
)
21+
addFlags, opts := serverFlags()
2722

2823
cmd := &cobra.Command{
2924
Use: "remove <id>",
@@ -37,21 +32,7 @@ func remove() *cobra.Command {
3732
ctx, cancel := context.WithCancel(cmd.Context())
3833
defer cancel()
3934

40-
verbose, err := cmd.Flags().GetBool("verbose")
41-
if err != nil {
42-
return err
43-
}
44-
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
45-
if verbose {
46-
logger = logger.Leveled(slog.LevelDebug)
47-
}
48-
49-
store, err := storage.NewStorage(ctx, &storage.Options{
50-
Artifactory: artifactory,
51-
ExtDir: extdir,
52-
Logger: logger,
53-
Repo: repo,
54-
})
35+
store, err := storage.NewStorage(ctx, opts)
5536
if err != nil {
5637
return err
5738
}
@@ -120,9 +101,7 @@ func remove() *cobra.Command {
120101
}
121102

122103
cmd.Flags().BoolVar(&all, "all", false, "Whether to delete all versions of the extension.")
123-
cmd.Flags().StringVar(&extdir, "extensions-dir", "", "The path to extensions.")
124-
cmd.Flags().StringVar(&artifactory, "artifactory", "", "Artifactory server URL.")
125-
cmd.Flags().StringVar(&repo, "repo", "", "Artifactory repository.")
104+
addFlags(cmd)
126105

127106
return cmd
128107
}

‎cli/root.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
package cli
22

33
import (
4-
"github.com/spf13/cobra"
54
"strings"
5+
6+
"github.com/spf13/cobra"
67
)
78

89
func Root() *cobra.Command {
@@ -16,7 +17,7 @@ func Root() *cobra.Command {
1617
}, "\n"),
1718
}
1819

19-
cmd.AddCommand(add(), remove(), server(), version())
20+
cmd.AddCommand(add(), remove(), server(), version(), signature())
2021

2122
cmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose output")
2223

‎cli/server.go

Lines changed: 56 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,68 @@ import (
1515

1616
"cdr.dev/slog"
1717
"cdr.dev/slog/sloggers/sloghuman"
18+
"github.com/coder/code-marketplace/extensionsign"
1819

1920
"github.com/coder/code-marketplace/api"
2021
"github.com/coder/code-marketplace/database"
2122
"github.com/coder/code-marketplace/storage"
2223
)
2324

25+
func serverFlags() (addFlags func(cmd *cobra.Command), opts *storage.Options) {
26+
opts = &storage.Options{}
27+
var sign bool
28+
return func(cmd *cobra.Command) {
29+
cmd.Flags().StringVar(&opts.ExtDir, "extensions-dir", "", "The path to extensions.")
30+
cmd.Flags().StringVar(&opts.Artifactory, "artifactory", "", "Artifactory server URL.")
31+
cmd.Flags().StringVar(&opts.Repo, "repo", "", "Artifactory repository.")
32+
cmd.Flags().BoolVar(&sign, "sign", false, "Sign extensions.")
33+
_ = cmd.Flags().MarkHidden("sign") // This flag needs to import a key, not just be a bool
34+
35+
if cmd.Use == "server" {
36+
// Server only flags
37+
cmd.Flags().DurationVar(&opts.ListCacheDuration, "list-cache-duration", time.Minute, "The duration of the extension cache.")
38+
}
39+
40+
var before func(cmd *cobra.Command, args []string) error
41+
if cmd.PreRunE != nil {
42+
before = cmd.PreRunE
43+
}
44+
if cmd.PreRun != nil {
45+
beforeNoE := cmd.PreRun
46+
before = func(cmd *cobra.Command, args []string) error {
47+
beforeNoE(cmd, args)
48+
return nil
49+
}
50+
}
51+
52+
cmd.PreRunE = func(cmd *cobra.Command, args []string) error {
53+
opts.Logger = cmdLogger(cmd)
54+
if before != nil {
55+
return before(cmd, args)
56+
}
57+
if sign { // TODO: Remove this for an actual key import
58+
opts.Signer, _ = extensionsign.GenerateKey()
59+
}
60+
return nil
61+
}
62+
}, opts
63+
}
64+
65+
func cmdLogger(cmd *cobra.Command) slog.Logger {
66+
verbose, _ := cmd.Flags().GetBool("verbose")
67+
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
68+
if verbose {
69+
logger = logger.Leveled(slog.LevelDebug)
70+
}
71+
return logger
72+
}
73+
2474
func server() *cobra.Command {
2575
var (
26-
address string
27-
artifactory string
28-
extdir string
29-
repo string
30-
listcacheduration time.Duration
31-
maxpagesize int
76+
address string
77+
maxpagesize int
3278
)
79+
addFlags, opts := serverFlags()
3380

3481
cmd := &cobra.Command{
3582
Use: "server",
@@ -41,26 +88,12 @@ func server() *cobra.Command {
4188
RunE: func(cmd *cobra.Command, args []string) error {
4289
ctx, cancel := context.WithCancel(cmd.Context())
4390
defer cancel()
91+
logger := opts.Logger
4492

4593
notifyCtx, notifyStop := signal.NotifyContext(ctx, interruptSignals...)
4694
defer notifyStop()
4795

48-
verbose, err := cmd.Flags().GetBool("verbose")
49-
if err != nil {
50-
return err
51-
}
52-
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
53-
if verbose {
54-
logger = logger.Leveled(slog.LevelDebug)
55-
}
56-
57-
store, err := storage.NewStorage(ctx, &storage.Options{
58-
Artifactory: artifactory,
59-
ExtDir: extdir,
60-
Logger: logger,
61-
Repo: repo,
62-
ListCacheDuration: listcacheduration,
63-
})
96+
store, err := storage.NewStorage(ctx, opts)
6497
if err != nil {
6598
return err
6699
}
@@ -137,12 +170,9 @@ func server() *cobra.Command {
137170
},
138171
}
139172

140-
cmd.Flags().StringVar(&extdir, "extensions-dir", "", "The path to extensions.")
141173
cmd.Flags().IntVar(&maxpagesize, "max-page-size", api.MaxPageSizeDefault, "The maximum number of pages to request")
142-
cmd.Flags().StringVar(&artifactory, "artifactory", "", "Artifactory server URL.")
143-
cmd.Flags().StringVar(&repo, "repo", "", "Artifactory repository.")
144174
cmd.Flags().StringVar(&address, "address", "127.0.0.1:3001", "The address on which to serve the marketplace API.")
145-
cmd.Flags().DurationVar(&listcacheduration, "list-cache-duration", time.Minute, "The duration of the extension cache.")
175+
addFlags(cmd)
146176

147177
return cmd
148178
}

‎cli/signature.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/spf13/cobra"
8+
"golang.org/x/xerrors"
9+
10+
"github.com/coder/code-marketplace/extensionsign"
11+
)
12+
13+
func signature() *cobra.Command {
14+
cmd := &cobra.Command{
15+
Use: "signature",
16+
Short: "Commands for debugging and working with signatures.",
17+
Hidden: true, // Debugging tools
18+
Aliases: []string{"sig", "sigs", "signatures"},
19+
}
20+
cmd.AddCommand(compareSignatureSigZips())
21+
return cmd
22+
}
23+
24+
func compareSignatureSigZips() *cobra.Command {
25+
cmd := &cobra.Command{
26+
Use: "compare",
27+
Args: cobra.ExactArgs(2),
28+
RunE: func(cmd *cobra.Command, args []string) error {
29+
decode := func(path string) (extensionsign.SignatureManifest, error) {
30+
data, err := os.ReadFile(path)
31+
if err != nil {
32+
return extensionsign.SignatureManifest{}, xerrors.Errorf("read %q: %w", args[0], err)
33+
}
34+
35+
sig, err := extensionsign.ExtractSignatureManifest(data)
36+
if err != nil {
37+
return extensionsign.SignatureManifest{}, xerrors.Errorf("unmarshal %q: %w", path, err)
38+
}
39+
return sig, nil
40+
}
41+
42+
a, err := decode(args[0])
43+
if err != nil {
44+
return err
45+
}
46+
b, err := decode(args[1])
47+
if err != nil {
48+
return err
49+
}
50+
51+
_, _ = fmt.Fprintf(os.Stdout, "Signature A:%s\n", a)
52+
_, _ = fmt.Fprintf(os.Stdout, "Signature B:%s\n", b)
53+
err = a.Equal(b)
54+
if err != nil {
55+
return err
56+
}
57+
58+
_, _ = fmt.Fprintf(os.Stdout, "Signatures are equal\n")
59+
return nil
60+
},
61+
}
62+
return cmd
63+
}

‎extensionsign/doc.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Package extensionsign is a Go implementation of https://github.com/filiptronicek/node-ovsx-sign
2+
package extensionsign

‎extensionsign/key.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package extensionsign
2+
3+
import (
4+
"crypto/ed25519"
5+
"crypto/rand"
6+
)
7+
8+
func GenerateKey() (ed25519.PrivateKey, error) {
9+
_, private, err := ed25519.GenerateKey(rand.Reader)
10+
if err != nil {
11+
return nil, err
12+
}
13+
return private, nil
14+
}

‎extensionsign/sigmanifest.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package extensionsign
2+
3+
import (
4+
"bytes"
5+
"crypto/sha256"
6+
"encoding/base64"
7+
"errors"
8+
"fmt"
9+
"io"
10+
11+
"golang.org/x/xerrors"
12+
13+
"github.com/coder/code-marketplace/storage/easyzip"
14+
)
15+
16+
// SignatureManifest should be serialized to JSON before being signed.
17+
type SignatureManifest struct {
18+
Package File
19+
// Entries is base64(filepath) -> File
20+
Entries map[string]File
21+
}
22+
23+
func (a SignatureManifest) String() string {
24+
return fmt.Sprintf("Package %q with Entries: %d", a.Package.Digests.SHA256, len(a.Entries))
25+
}
26+
27+
// Equal is helpful for debugging to know if two manifests are equal.
28+
// They can change if any file is removed/added/edited to an extension.
29+
func (a SignatureManifest) Equal(b SignatureManifest) error {
30+
var errs []error
31+
if err := a.Package.Equal(b.Package); err != nil {
32+
errs = append(errs, xerrors.Errorf("package: %w", err))
33+
}
34+
35+
if len(a.Entries) != len(b.Entries) {
36+
errs = append(errs, xerrors.Errorf("entry count mismatch: %d != %d", len(a.Entries), len(b.Entries)))
37+
}
38+
39+
for k, v := range a.Entries {
40+
if _, ok := b.Entries[k]; !ok {
41+
errs = append(errs, xerrors.Errorf("entry %q not found in second set", k))
42+
continue
43+
}
44+
if err := v.Equal(b.Entries[k]); err != nil {
45+
errs = append(errs, xerrors.Errorf("entry %q: %w", k, err))
46+
}
47+
}
48+
return errors.Join(errs...)
49+
}
50+
51+
type File struct {
52+
Size int64 `json:"size"`
53+
Digests Digests `json:"digests"`
54+
}
55+
56+
func (f File) Equal(b File) error {
57+
if f.Size != b.Size {
58+
return xerrors.Errorf("size mismatch: %d != %d", f.Size, b.Size)
59+
}
60+
if f.Digests.SHA256 != b.Digests.SHA256 {
61+
return xerrors.Errorf("sha256 mismatch: %s != %s", f.Digests.SHA256, b.Digests.SHA256)
62+
}
63+
return nil
64+
}
65+
66+
func FileManifest(file io.Reader) (File, error) {
67+
hash := sha256.New()
68+
69+
n, err := io.Copy(hash, file)
70+
if err != nil {
71+
return File{}, xerrors.Errorf("hash file: %w", err)
72+
}
73+
74+
return File{
75+
Size: n,
76+
Digests: Digests{
77+
SHA256: base64.StdEncoding.EncodeToString(hash.Sum(nil)),
78+
},
79+
}, nil
80+
}
81+
82+
type Digests struct {
83+
SHA256 string `json:"sha256"`
84+
}
85+
86+
// GenerateSignatureManifest generates a signature manifest for a VSIX file.
87+
// It does not sign the manifest. The manifest is the base64 encoded file path
88+
// followed by the sha256 hash of the file, and it's size.
89+
func GenerateSignatureManifest(vsixFile []byte) (SignatureManifest, error) {
90+
pkgManifest, err := FileManifest(bytes.NewReader(vsixFile))
91+
if err != nil {
92+
return SignatureManifest{}, xerrors.Errorf("package manifest: %w", err)
93+
}
94+
95+
manifest := SignatureManifest{
96+
Package: pkgManifest,
97+
Entries: make(map[string]File),
98+
}
99+
100+
err = easyzip.ExtractZip(vsixFile, func(name string, reader io.Reader) error {
101+
fm, err := FileManifest(reader)
102+
if err != nil {
103+
return xerrors.Errorf("file %q: %w", name, err)
104+
}
105+
manifest.Entries[base64.StdEncoding.EncodeToString([]byte(name))] = fm
106+
return nil
107+
})
108+
109+
if err != nil {
110+
return SignatureManifest{}, err
111+
}
112+
113+
return manifest, nil
114+
}

‎extensionsign/sigzip.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package extensionsign
2+
3+
import (
4+
"archive/zip"
5+
"bytes"
6+
"crypto"
7+
"crypto/rand"
8+
"encoding/json"
9+
10+
"golang.org/x/xerrors"
11+
12+
"github.com/coder/code-marketplace/storage/easyzip"
13+
)
14+
15+
func ExtractSignatureManifest(zip []byte) (SignatureManifest, error) {
16+
r, err := easyzip.GetZipFileReader(zip, ".signature.manifest")
17+
if err != nil {
18+
return SignatureManifest{}, xerrors.Errorf("get manifest: %w", err)
19+
}
20+
21+
defer r.Close()
22+
var manifest SignatureManifest
23+
err = json.NewDecoder(r).Decode(&manifest)
24+
if err != nil {
25+
return SignatureManifest{}, xerrors.Errorf("decode manifest: %w", err)
26+
}
27+
return manifest, nil
28+
}
29+
30+
// SignAndZipManifest signs a manifest and zips it up
31+
func SignAndZipManifest(secret crypto.Signer, manifest json.RawMessage) ([]byte, error) {
32+
var buf bytes.Buffer
33+
w := zip.NewWriter(&buf)
34+
35+
manFile, err := w.Create(".signature.manifest")
36+
if err != nil {
37+
return nil, xerrors.Errorf("create manifest: %w", err)
38+
}
39+
40+
_, err = manFile.Write(manifest)
41+
if err != nil {
42+
return nil, xerrors.Errorf("write manifest: %w", err)
43+
}
44+
45+
// Empty file
46+
_, err = w.Create(".signature.p7s")
47+
if err != nil {
48+
return nil, xerrors.Errorf("create empty p7s signature: %w", err)
49+
}
50+
51+
// Actual sig
52+
sigFile, err := w.Create(".signature.sig")
53+
if err != nil {
54+
return nil, xerrors.Errorf("create signature: %w", err)
55+
}
56+
57+
signature, err := secret.Sign(rand.Reader, manifest, crypto.Hash(0))
58+
if err != nil {
59+
return nil, xerrors.Errorf("sign: %w", err)
60+
}
61+
62+
_, err = sigFile.Write(signature)
63+
if err != nil {
64+
return nil, xerrors.Errorf("write signature: %w", err)
65+
}
66+
67+
err = w.Close()
68+
if err != nil {
69+
return nil, xerrors.Errorf("close zip: %w", err)
70+
}
71+
72+
return buf.Bytes(), nil
73+
}

‎go.mod

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/coder/code-marketplace
22

3-
go 1.19
3+
go 1.22.8
44

55
require (
66
cdr.dev/slog v1.6.1
@@ -9,6 +9,7 @@ require (
99
github.com/go-chi/httprate v0.14.1
1010
github.com/google/uuid v1.6.0
1111
github.com/lithammer/fuzzysearch v1.1.8
12+
github.com/spf13/afero v1.11.0
1213
github.com/spf13/cobra v1.8.1
1314
github.com/stretchr/testify v1.9.0
1415
golang.org/x/mod v0.19.0
@@ -32,9 +33,11 @@ require (
3233
github.com/spf13/pflag v1.0.5 // indirect
3334
go.opentelemetry.io/otel v1.16.0 // indirect
3435
go.opentelemetry.io/otel/trace v1.16.0 // indirect
35-
golang.org/x/crypto v0.17.0 // indirect
36-
golang.org/x/sys v0.15.0 // indirect
37-
golang.org/x/term v0.15.0 // indirect
36+
golang.org/x/crypto v0.19.0 // indirect
37+
golang.org/x/net v0.20.0 // indirect
38+
golang.org/x/sys v0.17.0 // indirect
39+
golang.org/x/term v0.17.0 // indirect
3840
golang.org/x/text v0.14.0 // indirect
41+
google.golang.org/protobuf v1.32.0 // indirect
3942
gopkg.in/yaml.v3 v3.0.1 // indirect
4043
)

‎go.sum

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
cdr.dev/slog v1.6.1 h1:IQjWZD0x6//sfv5n+qEhbu3wBkmtBQY5DILXNvMaIv4=
22
cdr.dev/slog v1.6.1/go.mod h1:eHEYQLaZvxnIAXC+XdTSNLb/kgA/X2RVSF72v5wsxEI=
3-
cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
3+
cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y=
4+
cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk=
5+
cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI=
46
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
7+
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
58
cloud.google.com/go/logging v1.7.0 h1:CJYxlNNNNAMkHp9em/YEXcfJg+rPDg7YfwoRpMU+t5I=
9+
cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M=
610
cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
11+
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
712
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
813
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
914
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -20,9 +25,13 @@ github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vz
2025
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs=
2126
github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0=
2227
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
28+
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
2329
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
30+
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
2431
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
32+
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
2533
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
34+
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
2635
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
2736
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
2837
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -47,6 +56,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
4756
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
4857
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
4958
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
59+
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
60+
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
5061
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
5162
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
5263
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -57,13 +68,15 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
5768
go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s=
5869
go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4=
5970
go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo=
71+
go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4=
6072
go.opentelemetry.io/otel/sdk v1.16.0 h1:Z1Ok1YsijYL0CSJpHt4cS3wDDh7p572grzNrBMiMWgE=
73+
go.opentelemetry.io/otel/sdk v1.16.0/go.mod h1:tMsIuKXuuIWPBAOrH+eHtvhTL+SntFtXF9QD68aP6p4=
6174
go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs=
6275
go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0=
6376
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
6477
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
65-
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
66-
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
78+
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
79+
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
6780
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
6881
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
6982
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
@@ -72,7 +85,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
7285
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
7386
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
7487
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
75-
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
88+
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
89+
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
7690
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
7791
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
7892
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -85,13 +99,13 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
8599
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
86100
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
87101
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
88-
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
89-
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
102+
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
103+
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
90104
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
91105
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
92106
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
93-
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
94-
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
107+
golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
108+
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
95109
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
96110
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
97111
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -106,11 +120,16 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
106120
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
107121
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
108122
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
109-
google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e h1:xIXmWJ303kJCuogpj0bHq+dcjcZHU+XFyc1I0Yl9cRg=
110-
google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 h1:XVeBY8d/FaK4848myy41HBqnDwvxeV3zMZhwN1TvAMU=
111-
google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 h1:2FZP5XuJY9zQyGM5N0rtovnoXjiMUEIUMvw0m9wlpLc=
112-
google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw=
113-
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
123+
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
124+
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY=
125+
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo=
126+
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4=
127+
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I=
128+
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc=
129+
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
130+
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
131+
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
132+
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
114133
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
115134
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
116135
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

‎storage/artifactory.go

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"errors"
88
"fmt"
99
"io"
10+
"io/fs"
1011
"net/http"
1112
"os"
1213
"path"
@@ -15,10 +16,12 @@ import (
1516
"sync"
1617
"time"
1718

19+
"github.com/spf13/afero/mem"
1820
"golang.org/x/sync/errgroup"
1921
"golang.org/x/xerrors"
2022

2123
"cdr.dev/slog"
24+
"github.com/coder/code-marketplace/storage/easyzip"
2225

2326
"github.com/coder/code-marketplace/util"
2427
)
@@ -41,6 +44,8 @@ type ArtifactoryList struct {
4144
Files []ArtifactoryFile `json:"files"`
4245
}
4346

47+
var _ Storage = (*Artifactory)(nil)
48+
4449
// Artifactory implements Storage. It stores extensions remotely through
4550
// Artifactory by both copying the VSIX and extracting said VSIX to a tree
4651
// structure in the form of publisher/extension/version to easily serve
@@ -213,7 +218,7 @@ func (s *Artifactory) upload(ctx context.Context, endpoint string, r io.Reader)
213218
return code, nil
214219
}
215220

216-
func (s *Artifactory) AddExtension(ctx context.Context, manifest *VSIXManifest, vsix []byte) (string, error) {
221+
func (s *Artifactory) AddExtension(ctx context.Context, manifest *VSIXManifest, vsix []byte, extra ...File) (string, error) {
217222
// Extract the zip to the correct path.
218223
identity := manifest.Metadata.Identity
219224
dir := path.Join(identity.Publisher, identity.ID, Version{
@@ -244,7 +249,7 @@ func (s *Artifactory) AddExtension(ctx context.Context, manifest *VSIXManifest,
244249
}
245250
}
246251

247-
err := ExtractZip(vsix, func(name string, r io.Reader) error {
252+
err := easyzip.ExtractZip(vsix, func(name string, r io.Reader) error {
248253
if util.Contains(assets, name) || (browser != "" && strings.HasPrefix(name, browser)) {
249254
_, err := s.upload(ctx, path.Join(dir, name), r)
250255
return err
@@ -262,28 +267,47 @@ func (s *Artifactory) AddExtension(ctx context.Context, manifest *VSIXManifest,
262267
return "", err
263268
}
264269

270+
for _, file := range extra {
271+
_, err := s.upload(ctx, path.Join(dir, file.RelativePath), bytes.NewReader(file.Content))
272+
if err != nil {
273+
return "", err
274+
}
275+
}
276+
265277
return s.uri + dir, nil
266278
}
267279

268-
func (s *Artifactory) FileServer() http.Handler {
269-
// TODO: Since we only extract a subset of files perhaps if the file does not
270-
// exist we should download the vsix and extract the requested file as a
271-
// fallback. Obviously this seems like quite a bit of overhead so we would
272-
// then emit a warning so we can notice that VS Code has added new asset types
273-
// that we should be extracting to avoid that overhead. Other solutions could
274-
// be implemented though like extracting the VSIX to disk locally and only
275-
// going to Artifactory for the VSIX when it is missing on disk (basically
276-
// using the disk as a cache).
277-
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
278-
reader, code, err := s.read(r.Context(), r.URL.Path)
279-
if err != nil {
280-
http.Error(rw, err.Error(), code)
281-
return
280+
// Open returns a file from Artifactory.
281+
// TODO: Since we only extract a subset of files perhaps if the file does not
282+
// exist we should download the vsix and extract the requested file as a
283+
// fallback. Obviously this seems like quite a bit of overhead so we would
284+
// then emit a warning so we can notice that VS Code has added new asset types
285+
// that we should be extracting to avoid that overhead. Other solutions could
286+
// be implemented though like extracting the VSIX to disk locally and only
287+
// going to Artifactory for the VSIX when it is missing on disk (basically
288+
// using the disk as a cache).
289+
func (s *Artifactory) Open(ctx context.Context, fp string) (fs.File, error) {
290+
resp, code, err := s.read(ctx, fp)
291+
if code != http.StatusOK || err != nil {
292+
switch code {
293+
case http.StatusNotFound:
294+
return nil, fs.ErrNotExist
295+
case http.StatusForbidden:
296+
return nil, fs.ErrPermission
297+
default:
298+
return nil, err
282299
}
283-
defer reader.Close()
284-
rw.WriteHeader(http.StatusOK)
285-
_, _ = io.Copy(rw, reader)
286-
})
300+
}
301+
302+
// TODO: Do no copy the bytes into memory, stream them rather than
303+
// storing the entire file into memory.
304+
f := mem.NewFileHandle(mem.CreateFile(fp))
305+
_, err = io.Copy(f, resp)
306+
if err != nil {
307+
return nil, err
308+
}
309+
310+
return f, nil
287311
}
288312

289313
func (s *Artifactory) Manifest(ctx context.Context, publisher, name string, version Version) (*VSIXManifest, error) {

‎storage/zip.go renamed to ‎storage/easyzip/zip.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package storage
1+
package easyzip
22

33
import (
44
"archive/zip"

‎storage/zip_test.go renamed to ‎storage/easyzip/zip_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package storage
1+
package easyzip
22

33
import (
44
"archive/zip"

‎storage/local.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,22 @@ import (
44
"context"
55
"fmt"
66
"io"
7+
"io/fs"
78
"net/http"
89
"os"
910
"path/filepath"
1011
"sort"
1112
"sync"
1213
"time"
1314

15+
"golang.org/x/xerrors"
16+
1417
"cdr.dev/slog"
18+
"github.com/coder/code-marketplace/storage/easyzip"
1519
)
1620

21+
var _ Storage = (*Local)(nil)
22+
1723
// Local implements Storage. It stores extensions locally on disk by both
1824
// copying the VSIX and extracting said VSIX to a tree structure in the form of
1925
// publisher/extension/version to easily serve individual assets via HTTP.
@@ -89,14 +95,14 @@ func (s *Local) list(ctx context.Context) []extension {
8995
return list
9096
}
9197

92-
func (s *Local) AddExtension(ctx context.Context, manifest *VSIXManifest, vsix []byte) (string, error) {
98+
func (s *Local) AddExtension(ctx context.Context, manifest *VSIXManifest, vsix []byte, extra ...File) (string, error) {
9399
// Extract the zip to the correct path.
94100
identity := manifest.Metadata.Identity
95101
dir := filepath.Join(s.extdir, identity.Publisher, identity.ID, Version{
96102
Version: identity.Version,
97103
TargetPlatform: identity.TargetPlatform,
98104
}.String())
99-
err := ExtractZip(vsix, func(name string, r io.Reader) error {
105+
err := easyzip.ExtractZip(vsix, func(name string, r io.Reader) error {
100106
path := filepath.Join(dir, name)
101107
err := os.MkdirAll(filepath.Dir(path), 0o755)
102108
if err != nil {
@@ -121,11 +127,23 @@ func (s *Local) AddExtension(ctx context.Context, manifest *VSIXManifest, vsix [
121127
return "", err
122128
}
123129

130+
for _, file := range extra {
131+
path := filepath.Join(dir, file.RelativePath)
132+
err := os.MkdirAll(filepath.Dir(path), 0o755)
133+
if err != nil {
134+
return "", err
135+
}
136+
err = os.WriteFile(path, file.Content, 0o644)
137+
if err != nil {
138+
return dir, xerrors.Errorf("write extra file %q: %w", path, err)
139+
}
140+
}
141+
124142
return dir, nil
125143
}
126144

127-
func (s *Local) FileServer() http.Handler {
128-
return http.FileServer(http.Dir(s.extdir))
145+
func (s *Local) Open(_ context.Context, fp string) (fs.File, error) {
146+
return http.Dir(s.extdir).Open(fp)
129147
}
130148

131149
func (s *Local) Manifest(ctx context.Context, publisher, name string, version Version) (*VSIXManifest, error) {

‎storage/signature.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package storage
2+
3+
import (
4+
"context"
5+
"crypto"
6+
"encoding/json"
7+
"io"
8+
"io/fs"
9+
"path/filepath"
10+
11+
"github.com/spf13/afero/mem"
12+
"golang.org/x/xerrors"
13+
14+
"github.com/coder/code-marketplace/extensionsign"
15+
)
16+
17+
var _ Storage = (*Signature)(nil)
18+
19+
const (
20+
SigzipFilename = "extension.sigzip"
21+
sigManifestName = ".signature.manifest"
22+
)
23+
24+
// Signature is a storage wrapper that can sign extensions on demand.
25+
type Signature struct {
26+
// Signer if provided, will be used to sign extensions. If not provided,
27+
// no extensions will be signed.
28+
Signer crypto.Signer
29+
Storage
30+
}
31+
32+
func NewSignatureStorage(signer crypto.Signer, s Storage) *Signature {
33+
return &Signature{
34+
Signer: signer,
35+
Storage: s,
36+
}
37+
}
38+
39+
func (s *Signature) SigningEnabled() bool {
40+
return s.Signer != nil
41+
}
42+
43+
// AddExtension includes the signature manifest of the vsix. Signing happens on
44+
// demand, so leave the manifest unsigned. This is safe to do even if
45+
// 'signExtensions' is disabled, as these files lay dormant until signed.
46+
func (s *Signature) AddExtension(ctx context.Context, manifest *VSIXManifest, vsix []byte, extra ...File) (string, error) {
47+
sigManifest, err := extensionsign.GenerateSignatureManifest(vsix)
48+
if err != nil {
49+
return "", xerrors.Errorf("generate signature manifest: %w", err)
50+
}
51+
52+
data, err := json.Marshal(sigManifest)
53+
if err != nil {
54+
return "", xerrors.Errorf("encode signature manifest: %w", err)
55+
}
56+
57+
return s.Storage.AddExtension(ctx, manifest, vsix, append(extra, File{
58+
RelativePath: sigManifestName,
59+
Content: data,
60+
})...)
61+
}
62+
63+
func (s *Signature) Manifest(ctx context.Context, publisher, name string, version Version) (*VSIXManifest, error) {
64+
manifest, err := s.Storage.Manifest(ctx, publisher, name, version)
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
if s.SigningEnabled() {
70+
for _, asset := range manifest.Assets.Asset {
71+
if asset.Path == SigzipFilename {
72+
// Already signed
73+
return manifest, nil
74+
}
75+
}
76+
manifest.Assets.Asset = append(manifest.Assets.Asset, VSIXAsset{
77+
Type: VSIXSignatureType,
78+
Path: SigzipFilename,
79+
Addressable: "true",
80+
})
81+
return manifest, nil
82+
}
83+
return manifest, nil
84+
}
85+
86+
// Open will intercept requests for signed extensions payload.
87+
// It does this by looking for 'SigzipFilename' or p7s.sig.
88+
//
89+
// The signed payload and signing process is taken from:
90+
// https://github.com/filiptronicek/node-ovsx-sign
91+
//
92+
// Some notes:
93+
//
94+
// - VSCodium requires a signature to exist, but it does appear to actually read
95+
// the signature. Meaning the signature could be empty, incorrect, or a
96+
// picture of cat and it would work. There is no signature verification.
97+
//
98+
// - VSCode requires a signature payload to exist, but the context appear
99+
// to be somewhat optional.
100+
// Following another open source implementation, it appears the '.signature.p7s'
101+
// file must exist, but it can be empty.
102+
// The signature is stored in a '.signature.sig' file, although it is unclear
103+
// is VSCode ever reads this file.
104+
// TODO: Properly implement the p7s file, and diverge from the other open
105+
// source implementation. Ideally this marketplace would match Microsoft's
106+
// marketplace API.
107+
func (s *Signature) Open(ctx context.Context, fp string) (fs.File, error) {
108+
if s.SigningEnabled() && filepath.Base(fp) == SigzipFilename {
109+
// hijack this request, sign the sig manifest
110+
manifest, err := s.Storage.Open(ctx, filepath.Join(filepath.Dir(fp), sigManifestName))
111+
if err != nil {
112+
// If this file is missing, it means the extension was added before
113+
// signatures were handled by the marketplace.
114+
// TODO: Generate the sig manifest payload and insert it?
115+
return nil, xerrors.Errorf("open signature manifest: %w", err)
116+
}
117+
defer manifest.Close()
118+
119+
manifestData, err := io.ReadAll(manifest)
120+
if err != nil {
121+
return nil, xerrors.Errorf("read signature manifest: %w", err)
122+
}
123+
124+
signed, err := extensionsign.SignAndZipManifest(s.Signer, manifestData)
125+
if err != nil {
126+
return nil, xerrors.Errorf("sign and zip manifest: %w", err)
127+
}
128+
129+
f := mem.NewFileHandle(mem.CreateFile(SigzipFilename))
130+
_, err = f.Write(signed)
131+
return f, err
132+
}
133+
134+
return s.Storage.Open(ctx, fp)
135+
}

‎storage/signature_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package storage_test
2+
3+
import (
4+
"crypto"
5+
"testing"
6+
7+
"github.com/coder/code-marketplace/extensionsign"
8+
"github.com/coder/code-marketplace/storage"
9+
)
10+
11+
func expectSignature(manifest *storage.VSIXManifest) {
12+
manifest.Assets.Asset = append(manifest.Assets.Asset, storage.VSIXAsset{
13+
Type: storage.VSIXSignatureType,
14+
Path: storage.SigzipFilename,
15+
Addressable: "true",
16+
})
17+
}
18+
19+
//nolint:revive // test control flag
20+
func signed(signer bool, factory func(t *testing.T) testStorage) func(t *testing.T) testStorage {
21+
return func(t *testing.T) testStorage {
22+
st := factory(t)
23+
var key crypto.Signer
24+
var exp func(*storage.VSIXManifest)
25+
if signer {
26+
key, _ = extensionsign.GenerateKey()
27+
exp = expectSignature
28+
}
29+
30+
return testStorage{
31+
storage: storage.NewSignatureStorage(key, st.storage),
32+
write: st.write,
33+
exists: st.exists,
34+
expectedManifest: exp,
35+
}
36+
}
37+
}

‎storage/storage.go

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,24 @@ package storage
22

33
import (
44
"context"
5+
"crypto"
56
"encoding/json"
67
"encoding/xml"
78
"fmt"
89
"io"
10+
"io/fs"
911
"net/http"
1012
"os"
1113
"regexp"
1214
"strings"
1315
"time"
1416

1517
"golang.org/x/mod/semver"
18+
1619
"golang.org/x/xerrors"
1720

1821
"cdr.dev/slog"
22+
"github.com/coder/code-marketplace/storage/easyzip"
1923
)
2024

2125
// VSIXManifest implement XMLManifest.PackageManifest.
@@ -112,6 +116,7 @@ type AssetType string
112116
const (
113117
ManifestAssetType AssetType = "Microsoft.VisualStudio.Code.Manifest" // This is the package.json.
114118
VSIXAssetType AssetType = "Microsoft.VisualStudio.Services.VSIXPackage"
119+
VSIXSignatureType AssetType = "Microsoft.VisualStudio.Services.VsixSignature"
115120
)
116121

117122
// VSIXAsset implements XMLManifest.PackageManifest.Assets.Asset.
@@ -123,6 +128,7 @@ type VSIXAsset struct {
123128
}
124129

125130
type Options struct {
131+
Signer crypto.Signer
126132
Artifactory string
127133
ExtDir string
128134
Repo string
@@ -203,11 +209,13 @@ func (vs ByVersion) Less(i, j int) bool {
203209

204210
type Storage interface {
205211
// AddExtension adds the provided VSIX into storage and returns the location
206-
// for verification purposes.
207-
AddExtension(ctx context.Context, manifest *VSIXManifest, vsix []byte) (string, error)
208-
// FileServer provides a handler for fetching extension repository files from
209-
// a client.
210-
FileServer() http.Handler
212+
// for verification purposes. Extra files can be included, but not required.
213+
// All extra files will be placed relative to the manifest outside the vsix.
214+
AddExtension(ctx context.Context, manifest *VSIXManifest, vsix []byte, extra ...File) (string, error)
215+
// Open mirrors the fs.FS interface of Open, except with a context.
216+
// The Open should return files from the extension storage, and used for
217+
// serving extensions.
218+
Open(ctx context.Context, name string) (fs.File, error)
211219
// Manifest returns the manifest bytes for the provided extension. The
212220
// extension asset itself (the VSIX) will be included on the manifest even if
213221
// it does not exist on the manifest on disk.
@@ -230,6 +238,22 @@ type Storage interface {
230238
WalkExtensions(ctx context.Context, fn func(manifest *VSIXManifest, versions []Version) error) error
231239
}
232240

241+
// HTTPFileServer creates an http.Handler that serves files from the provided
242+
// storage.
243+
func HTTPFileServer(s Storage) http.Handler {
244+
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
245+
http.FileServerFS(&contextFs{
246+
ctx: r.Context(),
247+
open: s.Open,
248+
}).ServeHTTP(rw, r)
249+
})
250+
}
251+
252+
type File struct {
253+
RelativePath string
254+
Content []byte
255+
}
256+
233257
const ArtifactoryTokenEnvKey = "ARTIFACTORY_TOKEN"
234258

235259
// NewStorage returns a storage instance based on the provided extension
@@ -240,31 +264,42 @@ func NewStorage(ctx context.Context, options *Options) (Storage, error) {
240264
return nil, xerrors.Errorf("cannot use both Artifactory and extension directory")
241265
} else if options.Artifactory != "" && options.Repo == "" {
242266
return nil, xerrors.Errorf("must provide repository")
243-
} else if options.Artifactory != "" {
267+
}
268+
269+
var store Storage
270+
var err error
271+
switch {
272+
case options.Artifactory != "":
244273
token := os.Getenv(ArtifactoryTokenEnvKey)
245274
if token == "" {
246275
return nil, xerrors.Errorf("the %s environment variable must be set", ArtifactoryTokenEnvKey)
247276
}
248-
return NewArtifactoryStorage(ctx, &ArtifactoryOptions{
277+
store, err = NewArtifactoryStorage(ctx, &ArtifactoryOptions{
249278
ListCacheDuration: options.ListCacheDuration,
250279
Logger: options.Logger,
251280
Repo: options.Repo,
252281
Token: token,
253282
URI: options.Artifactory,
254283
})
255-
} else if options.ExtDir != "" {
256-
return NewLocalStorage(&LocalOptions{
284+
case options.ExtDir != "":
285+
store, err = NewLocalStorage(&LocalOptions{
257286
ListCacheDuration: options.ListCacheDuration,
258287
ExtDir: options.ExtDir,
259288
}, options.Logger)
289+
default:
290+
return nil, xerrors.Errorf("must provide an Artifactory repository or local directory")
260291
}
261-
return nil, xerrors.Errorf("must provide an Artifactory repository or local directory")
292+
if err != nil {
293+
return nil, err
294+
}
295+
296+
return NewSignatureStorage(options.Signer, store), nil
262297
}
263298

264299
// ReadVSIXManifest reads and parses an extension manifest from a vsix file. If
265300
// the manifest is invalid it will be returned along with the validation error.
266301
func ReadVSIXManifest(vsix []byte) (*VSIXManifest, error) {
267-
vmr, err := GetZipFileReader(vsix, "extension.vsixmanifest")
302+
vmr, err := easyzip.GetZipFileReader(vsix, "extension.vsixmanifest")
268303
if err != nil {
269304
return nil, err
270305
}
@@ -322,7 +357,7 @@ type VSIXPackageJSON struct {
322357
// ReadVSIXPackageJSON reads and parses an extension's package.json from a vsix
323358
// file.
324359
func ReadVSIXPackageJSON(vsix []byte, packageJsonPath string) (*VSIXPackageJSON, error) {
325-
vpjr, err := GetZipFileReader(vsix, packageJsonPath)
360+
vpjr, err := easyzip.GetZipFileReader(vsix, packageJsonPath)
326361
if err != nil {
327362
return nil, err
328363
}
@@ -406,3 +441,12 @@ func ParseExtensionID(id string) (string, string, string, error) {
406441
}
407442
return match[0][1], match[0][2], match[0][3], nil
408443
}
444+
445+
type contextFs struct {
446+
ctx context.Context
447+
open func(ctx context.Context, name string) (fs.File, error)
448+
}
449+
450+
func (c *contextFs) Open(name string) (fs.File, error) {
451+
return c.open(c.ctx, name)
452+
}

‎storage/storage_test.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ type testStorage struct {
2525
storage storage.Storage
2626
write func(content []byte, elem ...string)
2727
exists func(elem ...string) bool
28+
29+
expectedManifest func(man *storage.VSIXManifest)
2830
}
2931
type storageFactory = func(t *testing.T) testStorage
3032

@@ -104,11 +106,13 @@ func TestNewStorage(t *testing.T) {
104106
require.Error(t, err)
105107
require.Regexp(t, test.error, err.Error())
106108
} else if test.local {
107-
_, ok := s.(*storage.Local)
109+
under := s.(*storage.Signature)
110+
_, ok := under.Storage.(*storage.Local)
108111
require.True(t, ok)
109112
require.NoError(t, err)
110113
} else {
111-
_, ok := s.(*storage.Artifactory)
114+
under := s.(*storage.Signature)
115+
_, ok := under.Storage.(*storage.Artifactory)
112116
require.True(t, ok)
113117
require.NoError(t, err)
114118
}
@@ -130,6 +134,14 @@ func TestStorage(t *testing.T) {
130134
name: "Artifactory",
131135
factory: artifactoryFactory,
132136
},
137+
{
138+
name: "SignedLocal",
139+
factory: signed(true, localFactory),
140+
},
141+
{
142+
name: "SignedArtifactory",
143+
factory: signed(true, artifactoryFactory),
144+
},
133145
}
134146
for _, sf := range factories {
135147
t.Run(sf.name, func(t *testing.T) {
@@ -189,7 +201,7 @@ func testFileServer(t *testing.T, factory storageFactory) {
189201
req := httptest.NewRequest("GET", test.path, nil)
190202
rec := httptest.NewRecorder()
191203

192-
server := f.storage.FileServer()
204+
server := storage.HTTPFileServer(f.storage)
193205
server.ServeHTTP(rec, req)
194206

195207
resp := rec.Result()
@@ -322,6 +334,9 @@ func testManifest(t *testing.T, factory storageFactory) {
322334
Path: fmt.Sprintf("%s.%s-%s.vsix", test.extension.Publisher, test.extension.Name, test.version),
323335
Addressable: "true",
324336
})
337+
if f.expectedManifest != nil {
338+
f.expectedManifest(test.expected)
339+
}
325340
require.Equal(t, test.expected, manifest)
326341
}
327342
})

‎testutil/extensions.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ type Extension struct {
2626
Pack []string
2727
}
2828

29+
func (e Extension) Copy() Extension {
30+
var n Extension
31+
data, _ := json.Marshal(e)
32+
_ = json.Unmarshal(data, &n)
33+
return n
34+
}
35+
2936
var Extensions = []Extension{
3037
{
3138
Publisher: "foo",
@@ -113,6 +120,7 @@ var Extensions = []Extension{
113120
}
114121

115122
func ConvertExtensionToManifest(ext Extension, version storage.Version) *storage.VSIXManifest {
123+
ext = ext.Copy()
116124
return &storage.VSIXManifest{
117125
Metadata: storage.VSIXMetadata{
118126
Identity: storage.VSIXIdentity{

‎testutil/mockstorage.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,38 @@ package testutil
33
import (
44
"context"
55
"errors"
6+
"io/fs"
67
"net/http"
78
"os"
9+
"path/filepath"
810
"sort"
911

12+
"github.com/spf13/afero/mem"
13+
1014
"github.com/coder/code-marketplace/storage"
1115
)
1216

17+
var _ storage.Storage = (*MockStorage)(nil)
18+
1319
// MockStorage implements storage.Storage for tests.
1420
type MockStorage struct{}
1521

1622
func NewMockStorage() *MockStorage {
1723
return &MockStorage{}
1824
}
1925

20-
func (s *MockStorage) AddExtension(ctx context.Context, manifest *storage.VSIXManifest, vsix []byte) (string, error) {
26+
func (s *MockStorage) AddExtension(ctx context.Context, manifest *storage.VSIXManifest, vsix []byte, extra ...storage.File) (string, error) {
2127
return "", errors.New("not implemented")
2228
}
29+
func (s *MockStorage) Open(ctx context.Context, path string) (fs.File, error) {
30+
if filepath.Base(path) == "nonexistent" {
31+
return nil, fs.ErrNotExist
32+
}
33+
34+
f := mem.NewFileHandle(mem.CreateFile(path))
35+
_, _ = f.Write([]byte("foobar"))
36+
return f, nil
37+
}
2338

2439
func (s *MockStorage) FileServer() http.Handler {
2540
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)
Please sign in to comment.