diff --git a/go.mod b/go.mod index 1419bc1..991736d 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/thoas/go-funk v0.9.3 golang.org/x/exp v0.0.0-20260611194520-c48552f49976 google.golang.org/grpc v1.83.0 - google.golang.org/protobuf v1.36.11 + google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index b692bd9..4151b6d 100644 --- a/go.sum +++ b/go.sum @@ -173,8 +173,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/managedplugin/download.go b/managedplugin/download.go index 3ca05b6..13c535a 100644 --- a/managedplugin/download.go +++ b/managedplugin/download.go @@ -141,7 +141,7 @@ func DownloadPluginFromHub(ctx context.Context, logger zerolog.Logger, c *cloudq return AssetSourceRemote, doDownloadPluginFromHub(ctx, logger, c, ops, dops) } -func doDownloadPluginFromHub(ctx context.Context, logger zerolog.Logger, c *cloudquery_api.ClientWithResponses, ops HubDownloadOptions, dops DownloaderOptions) error { +func doDownloadPluginFromHub(ctx context.Context, logger zerolog.Logger, c *cloudquery_api.ClientWithResponses, ops HubDownloadOptions, dops DownloaderOptions) (err error) { downloadDir := filepath.Dir(ops.LocalPath) if err := os.MkdirAll(downloadDir, 0755); err != nil { return fmt.Errorf("failed to create plugin directory %s: %w", downloadDir, err) @@ -216,6 +216,15 @@ func doDownloadPluginFromHub(ctx context.Context, logger zerolog.Logger, c *clou if err != nil { return fmt.Errorf("failed to create file %s: %w", ops.LocalPath, err) } + // The next run reports any file at this path as a cached plugin and never + // re-downloads, so a partial binary has to go. + defer func() { + if err != nil { + out.Close() + os.Remove(ops.LocalPath) + } + }() + _, err = io.Copy(out, fileInArchive) if err != nil { return fmt.Errorf("failed to copy body to file: %w", err) diff --git a/managedplugin/extract_test.go b/managedplugin/extract_test.go new file mode 100644 index 0000000..1d4418f --- /dev/null +++ b/managedplugin/extract_test.go @@ -0,0 +1,85 @@ +package managedplugin + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + cloudquery_api "github.com/cloudquery/cloudquery-api-go" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +// corruptZip builds a valid zip whose entry has a damaged deflate stream, so +// archive.Open succeeds and io.Copy fails part way through the entry. +func corruptZip(t *testing.T, entry string) []byte { + t.Helper() + + var buf bytes.Buffer + w := zip.NewWriter(&buf) + entryWriter, err := w.Create(entry) + require.NoError(t, err) + _, err = entryWriter.Write(bytes.Repeat([]byte("cloudquery-plugin-payload"), 4096)) + require.NoError(t, err) + require.NoError(t, w.Close()) + + raw := buf.Bytes() + // Damage the middle of the compressed stream, well past the local file header. + for i := len(raw) / 2; i < len(raw)/2+64; i++ { + raw[i] ^= 0xff + } + return raw +} + +// TestDownloadPluginFromHubRemovesPartialBinary covers the caching trap: the hub +// path never re-downloads once a file exists at LocalPath, so an extraction that +// fails after the file is created would poison every later run. The download +// checksum cannot catch this - it is verified before extraction begins, and is +// skipped entirely when the hub returns no checksum. +func TestDownloadPluginFromHubRemovesPartialBinary(t *testing.T) { + const ( + pluginName = "envzero" + pluginVersion = "v2.1.0" + ) + archive := corruptZip(t, fmt.Sprintf("plugin-%s-%s-%s-%s", pluginName, pluginVersion, runtime.GOOS, runtime.GOARCH)) + + mux := http.NewServeMux() + mux.HandleFunc("/asset.zip", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(archive) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + // An empty checksum is only warned about, so the archive reaches + // extraction unverified - the realistic way a bad zip gets that far. + _ = json.NewEncoder(w).Encode(cloudquery_api.PluginAsset{ + Location: server.URL + "/asset.zip", + }) + }) + + apiClient, err := cloudquery_api.NewClientWithResponses(server.URL) + require.NoError(t, err) + + localPath := filepath.Join(t.TempDir(), "plugin") + err = doDownloadPluginFromHub(context.Background(), zerolog.Nop(), apiClient, HubDownloadOptions{ + LocalPath: localPath, + PluginTeam: "cloudquery", + PluginKind: PluginSource.String(), + PluginName: pluginName, + PluginVersion: pluginVersion, + }, DownloaderOptions{NoProgress: true}) + + require.Error(t, err) + _, statErr := os.Stat(localPath) + require.ErrorIs(t, statErr, os.ErrNotExist, "a partial binary would be served as a cached plugin forever") +}