Skip to content

Commit 84a7876

Browse files
wip
1 parent 5cd3de3 commit 84a7876

File tree

10 files changed

+909
-5
lines changed

10 files changed

+909
-5
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/google/uuid v1.6.0
1212
github.com/muesli/termenv v0.15.2
1313
github.com/oapi-codegen/runtime v1.1.1
14+
github.com/puzpuzpuz/xsync v1.5.2
1415
github.com/rs/zerolog v1.34.0
1516
github.com/snyk/code-client-go v1.21.3
1617
github.com/snyk/error-catalog-golang-public v0.0.0-20250625135845-2d6f9a31f318
@@ -81,7 +82,6 @@ require (
8182
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
8283
github.com/pkg/errors v0.9.1 // indirect
8384
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
84-
github.com/puzpuzpuz/xsync v1.5.2 // indirect
8585
github.com/rivo/uniseg v0.4.7 // indirect
8686
github.com/rogpeppe/go-internal v1.14.1 // indirect
8787
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect

internal/fileupload/client.go

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
package fileupload
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"os"
9+
"path/filepath"
10+
"runtime"
11+
12+
"github.com/google/uuid"
13+
"github.com/puzpuzpuz/xsync"
14+
15+
listsources "github.com/snyk/cli-extension-os-flows/internal/files"
16+
"github.com/snyk/cli-extension-os-flows/internal/fileupload/lowlevel"
17+
)
18+
19+
// Config contains configuration for the file upload client.
20+
type Config struct {
21+
BaseURL string
22+
OrgID uuid.UUID
23+
}
24+
25+
// Client provides high-level file upload functionality.
26+
type Client struct {
27+
httpClient *http.Client
28+
lowlevel lowlevel.SealableClient
29+
cfg Config
30+
filters Filters
31+
}
32+
33+
// NewClient creates a new high-level file upload client.
34+
// For production use, consumers only need to provide Config.
35+
// For testing, use WithLowLevelClient option to inject a mock.
36+
func NewClient(httpClient *http.Client, cfg Config, opts ...Option) *Client {
37+
client := &Client{
38+
httpClient: httpClient,
39+
cfg: cfg,
40+
filters: Filters{
41+
supportedExtensions: xsync.NewMapOf[bool](),
42+
supportedConfigFiles: xsync.NewMapOf[bool](),
43+
},
44+
}
45+
46+
for _, opt := range opts {
47+
opt(client)
48+
}
49+
50+
if client.lowlevel == nil {
51+
client.lowlevel = lowlevel.NewClient(lowlevel.Config{
52+
BaseURL: cfg.BaseURL,
53+
}, lowlevel.WithHTTPClient(httpClient))
54+
}
55+
56+
return client
57+
}
58+
59+
func (c *Client) getDeeproxyFilters(ctx context.Context) (FiltersResponse, error) {
60+
var filtersResp FiltersResponse
61+
62+
url := fmt.Sprintf("%s/hidden/orgs/%s/code/filters", c.cfg.BaseURL, c.cfg.OrgID)
63+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
64+
if err != nil {
65+
return filtersResp, fmt.Errorf("failed to create deeproxy filters request: %w", err)
66+
}
67+
68+
req.Header.Set("snyk-org-name", c.cfg.OrgID.String())
69+
70+
resp, err := c.httpClient.Do(req)
71+
if err != nil {
72+
return filtersResp, fmt.Errorf("error making deeproxy filters request: %w", err)
73+
}
74+
75+
if resp.StatusCode < 200 || resp.StatusCode > 299 {
76+
return filtersResp, fmt.Errorf("unexpected response code: %s (%s)", resp.Status, resp.Body)
77+
}
78+
79+
if err := json.NewDecoder(resp.Body).Decode(&filtersResp); err != nil {
80+
return filtersResp, fmt.Errorf("failed to decode deeproxy filters response: %w", err)
81+
}
82+
83+
return filtersResp, nil
84+
}
85+
86+
func (c *Client) loadFilters(ctx context.Context) error {
87+
c.filters.once.Do(func() {
88+
filtersResp, err := c.getDeeproxyFilters(ctx)
89+
if err != nil {
90+
c.filters.initErr = err
91+
return
92+
}
93+
94+
for _, ext := range filtersResp.Extensions {
95+
c.filters.supportedExtensions.Store(ext, true)
96+
}
97+
for _, configFile := range filtersResp.ConfigFiles {
98+
// .gitignore and .dcignore should not be uploaded
99+
// (https://github.com/snyk/code-client/blob/d6f6a2ce4c14cb4b05aa03fb9f03533d8cf6ca4a/src/files.ts#L138)
100+
if configFile == ".gitignore" || configFile == ".dcignore" {
101+
continue
102+
}
103+
c.filters.supportedConfigFiles.Store(configFile, true)
104+
}
105+
})
106+
return c.filters.initErr
107+
}
108+
109+
// createFileFilter creates a filter function based on the current filtering configuration.
110+
// Returns nil if filtering should be skipped, otherwise returns a function that evaluates files.
111+
func (c *Client) createFileFilter(ctx context.Context, opts UploadOptions) (func(string) bool, error) {
112+
if opts.SkipFiltering {
113+
return nil, nil
114+
}
115+
116+
if err := c.loadFilters(ctx); err != nil {
117+
return nil, fmt.Errorf("failed to load deeproxy filters: %w", err)
118+
}
119+
120+
return func(path string) bool {
121+
fileExt := filepath.Ext(path)
122+
fileName := filepath.Base(path)
123+
_, isSupportedExtension := c.filters.supportedExtensions.Load(fileExt)
124+
_, isSupportedConfigFile := c.filters.supportedConfigFiles.Load(fileName)
125+
return isSupportedExtension || isSupportedConfigFile
126+
}, nil
127+
}
128+
129+
func (c *Client) uploadPaths(ctx context.Context, revID RevisionID, rootPath string, paths []string) error {
130+
files := make([]lowlevel.UploadFile, 0, c.lowlevel.GetLimits().FileCountLimit)
131+
defer func() {
132+
for _, file := range files {
133+
file.File.Close()
134+
}
135+
}()
136+
137+
for _, pth := range paths {
138+
relPth, err := filepath.Rel(rootPath, pth)
139+
if err != nil {
140+
return err
141+
}
142+
143+
f, err := os.Open(pth)
144+
if err != nil {
145+
return err
146+
}
147+
148+
files = append(files, lowlevel.UploadFile{
149+
Path: relPth,
150+
File: f,
151+
})
152+
}
153+
154+
err := c.lowlevel.UploadFiles(ctx, c.cfg.OrgID, revID, files)
155+
if err != nil {
156+
return err
157+
}
158+
159+
return nil
160+
}
161+
162+
// addPathsToRevision adds multiple file paths to an existing revision.
163+
func (c *Client) addPathsToRevision(ctx context.Context, revisionID RevisionID, rootPath string, pathsChan <-chan string, opts UploadOptions) error {
164+
filter, err := c.createFileFilter(ctx, opts)
165+
if err != nil {
166+
return err
167+
}
168+
169+
var chunks <-chan []string
170+
if filter != nil {
171+
chunks = chunkChanFiltered(pathsChan, c.lowlevel.GetLimits().FileCountLimit, filter)
172+
} else {
173+
chunks = chunkChan(pathsChan, c.lowlevel.GetLimits().FileCountLimit)
174+
}
175+
176+
for chunk := range chunks {
177+
err := c.uploadPaths(ctx, revisionID, rootPath, chunk)
178+
if err != nil {
179+
return err
180+
}
181+
}
182+
183+
return nil
184+
}
185+
186+
// CreateRevision creates a new revision and returns its ID.
187+
func (c *Client) CreateRevision(ctx context.Context) (RevisionID, error) {
188+
revision, err := c.lowlevel.CreateRevision(ctx, c.cfg.OrgID)
189+
if err != nil {
190+
return uuid.Nil, fmt.Errorf("failed to create revision: %w", err)
191+
}
192+
return revision.Data.ID, nil
193+
}
194+
195+
// AddFileToRevision adds a single file to an existing revision.
196+
func (c *Client) AddFileToRevision(ctx context.Context, revisionID RevisionID, filePath string, opts UploadOptions) error {
197+
writableChan := make(chan string, 1)
198+
writableChan <- filePath
199+
close(writableChan)
200+
201+
return c.addPathsToRevision(ctx, revisionID, filepath.Dir(filePath), writableChan, opts)
202+
}
203+
204+
// AddDirToRevision adds a directory and all its contents to an existing revision.
205+
func (c *Client) AddDirToRevision(ctx context.Context, revisionID RevisionID, dirPath string, opts UploadOptions) error {
206+
sources, err := listsources.ForPath(dirPath, nil, runtime.NumCPU())
207+
if err != nil {
208+
return err
209+
}
210+
211+
return c.addPathsToRevision(ctx, revisionID, dirPath, sources, opts)
212+
}
213+
214+
// SealRevision seals a revision, making it immutable.
215+
func (c *Client) SealRevision(ctx context.Context, revisionID RevisionID) error {
216+
_, err := c.lowlevel.SealRevision(ctx, c.cfg.OrgID, revisionID)
217+
if err != nil {
218+
return fmt.Errorf("failed to seal revision: %w", err)
219+
}
220+
return nil
221+
}
222+
223+
// CreateRevisionFromPaths uploads multiple paths (files or directories), returning a revision ID.
224+
// This is a convenience method that creates, uploads, and seals a revision.
225+
func (c *Client) CreateRevisionFromPaths(ctx context.Context, paths []string, opts UploadOptions) (RevisionID, error) {
226+
revisionID, err := c.CreateRevision(ctx)
227+
if err != nil {
228+
return uuid.Nil, err
229+
}
230+
231+
for _, pth := range paths {
232+
info, err := os.Stat(pth)
233+
if err != nil {
234+
return uuid.Nil, fmt.Errorf("failed to stat path %s: %w", pth, err)
235+
}
236+
237+
if info.IsDir() {
238+
if err := c.AddDirToRevision(ctx, revisionID, pth, opts); err != nil {
239+
return uuid.Nil, fmt.Errorf("failed to add directory %s: %w", pth, err)
240+
}
241+
} else {
242+
if err := c.AddFileToRevision(ctx, revisionID, pth, opts); err != nil {
243+
return uuid.Nil, fmt.Errorf("failed to add file %s: %w", pth, err)
244+
}
245+
}
246+
}
247+
248+
if err := c.SealRevision(ctx, revisionID); err != nil {
249+
return uuid.Nil, err
250+
}
251+
252+
return revisionID, nil
253+
}
254+
255+
// CreateRevisionFromDir uploads a directory and all its contents, returning a revision ID.
256+
// This is a convenience method equivalent to CreateRevisionFromPaths with a single directory.
257+
func (c *Client) CreateRevisionFromDir(ctx context.Context, dirPath string, opts UploadOptions) (RevisionID, error) {
258+
return c.CreateRevisionFromPaths(ctx, []string{dirPath}, opts)
259+
}
260+
261+
// CreateRevisionFromFile uploads a single file, returning a revision ID.
262+
// This is a convenience method equivalent to CreateRevisionFromPaths with a single file.
263+
func (c *Client) CreateRevisionFromFile(ctx context.Context, filePath string, opts UploadOptions) (RevisionID, error) {
264+
return c.CreateRevisionFromPaths(ctx, []string{filePath}, opts)
265+
}

0 commit comments

Comments
 (0)