Skip to content

Commit b419639

Browse files
committed
fix: Extract the plugin binary atomically
1 parent 18f1b65 commit b419639

2 files changed

Lines changed: 97 additions & 33 deletions

File tree

managedplugin/download.go

Lines changed: 31 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ func doDownloadPluginFromHub(ctx context.Context, logger zerolog.Logger, c *clou
190190
return errors.New("failed to get plugin metadata from hub: empty location from response")
191191
}
192192
pluginZipPath := ops.LocalPath + ".zip"
193+
defer os.Remove(pluginZipPath)
194+
193195
writtenChecksum, err := downloadFile(ctx, pluginZipPath, location, dops)
194196
if err != nil {
195197
return fmt.Errorf("failed to download plugin: %w", err)
@@ -201,29 +203,46 @@ func doDownloadPluginFromHub(ctx context.Context, logger zerolog.Logger, c *clou
201203
return fmt.Errorf("checksum mismatch: expected %s, got %s", pluginAsset.Checksum, writtenChecksum)
202204
}
203205

204-
archive, err := zip.OpenReader(pluginZipPath)
206+
pathInArchive := fmt.Sprintf("plugin-%s-%s-%s-%s", ops.PluginName, ops.PluginVersion, runtime.GOOS, runtime.GOARCH)
207+
return extractPluginBinary(pluginZipPath, pathInArchive, ops.LocalPath)
208+
}
209+
210+
// extractPluginBinary writes the binary to a temporary file and renames it into
211+
// place, so a failure part way through never leaves a truncated binary that the
212+
// next run treats as a cached plugin.
213+
func extractPluginBinary(archivePath, pathInArchive, localPath string) error {
214+
archive, err := zip.OpenReader(archivePath)
205215
if err != nil {
206216
return fmt.Errorf("failed to open plugin archive: %w", err)
207217
}
208218
defer archive.Close()
209219

210-
fileInArchive, err := archive.Open(fmt.Sprintf("plugin-%s-%s-%s-%s", ops.PluginName, ops.PluginVersion, runtime.GOOS, runtime.GOARCH))
220+
fileInArchive, err := archive.Open(pathInArchive)
211221
if err != nil {
212-
return fmt.Errorf("failed to open plugin archive: %w", err)
222+
return fmt.Errorf("failed to open plugin archive %s: %w", pathInArchive, err)
213223
}
224+
defer fileInArchive.Close()
214225

215-
out, err := os.OpenFile(ops.LocalPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0744)
226+
out, err := os.CreateTemp(filepath.Dir(localPath), filepath.Base(localPath)+".tmp")
216227
if err != nil {
217-
return fmt.Errorf("failed to create file %s: %w", ops.LocalPath, err)
228+
return fmt.Errorf("failed to create file %s: %w", localPath, err)
218229
}
219-
_, err = io.Copy(out, fileInArchive)
220-
if err != nil {
230+
tmpPath := out.Name()
231+
defer os.Remove(tmpPath)
232+
233+
if _, err := io.Copy(out, fileInArchive); err != nil {
234+
out.Close()
221235
return fmt.Errorf("failed to copy body to file: %w", err)
222236
}
223-
err = out.Close()
224-
if err != nil {
237+
if err := out.Close(); err != nil {
225238
return fmt.Errorf("failed to close file: %w", err)
226239
}
240+
if err := os.Chmod(tmpPath, 0744); err != nil {
241+
return fmt.Errorf("failed to set permissions on %s: %w", localPath, err)
242+
}
243+
if err := os.Rename(tmpPath, localPath); err != nil {
244+
return fmt.Errorf("failed to move plugin binary to %s: %w", localPath, err)
245+
}
227246
return nil
228247
}
229248

@@ -284,16 +303,12 @@ func doDownloadPluginFromGithub(ctx context.Context, logger zerolog.Logger, loca
284303
return fmt.Errorf("failed to get plugin url: %w", err)
285304
}
286305
logger.Debug().Msg(fmt.Sprintf("Downloading %s", downloadURL))
306+
defer os.Remove(pluginZipPath)
307+
287308
if _, err := downloadFile(ctx, pluginZipPath, downloadURL, dops); err != nil {
288309
return fmt.Errorf("failed to download plugin: %w", err)
289310
}
290311

291-
archive, err := zip.OpenReader(pluginZipPath)
292-
if err != nil {
293-
return fmt.Errorf("failed to open plugin archive: %w", err)
294-
}
295-
defer archive.Close()
296-
297312
var pathInArchive string
298313
switch {
299314
case strings.HasPrefix(downloadURL, "https://github.com/cloudquery/cloudquery/releases/download/plugins-plugin"):
@@ -312,24 +327,7 @@ func doDownloadPluginFromGithub(ctx context.Context, logger zerolog.Logger, loca
312327
return fmt.Errorf("unknown GitHub %s", downloadURL)
313328
}
314329

315-
pathInArchive = WithBinarySuffix(pathInArchive)
316-
fileInArchive, err := archive.Open(pathInArchive)
317-
if err != nil {
318-
return fmt.Errorf("failed to open plugin archive plugins/source/%s: %w", name, err)
319-
}
320-
out, err := os.OpenFile(localPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0744)
321-
if err != nil {
322-
return fmt.Errorf("failed to create file %s: %w", localPath, err)
323-
}
324-
_, err = io.Copy(out, fileInArchive)
325-
if err != nil {
326-
return fmt.Errorf("failed to copy body to file: %w", err)
327-
}
328-
err = out.Close()
329-
if err != nil {
330-
return fmt.Errorf("failed to close file: %w", err)
331-
}
332-
return nil
330+
return extractPluginBinary(pluginZipPath, WithBinarySuffix(pathInArchive), localPath)
333331
}
334332

335333
func downloadFile(ctx context.Context, localPath string, downloadURL string, dops DownloaderOptions) (string, error) {

managedplugin/extract_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package managedplugin
2+
3+
import (
4+
"archive/zip"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func writeTestArchive(t *testing.T, dir, entry string, contents []byte) string {
13+
t.Helper()
14+
15+
archivePath := filepath.Join(dir, "plugin.zip")
16+
f, err := os.Create(archivePath)
17+
require.NoError(t, err)
18+
19+
w := zip.NewWriter(f)
20+
entryWriter, err := w.Create(entry)
21+
require.NoError(t, err)
22+
_, err = entryWriter.Write(contents)
23+
require.NoError(t, err)
24+
require.NoError(t, w.Close())
25+
require.NoError(t, f.Close())
26+
27+
return archivePath
28+
}
29+
30+
func TestExtractPluginBinary(t *testing.T) {
31+
dir := t.TempDir()
32+
binary := []byte("plugin-binary")
33+
archivePath := writeTestArchive(t, dir, "plugin-aws-v1.0.0-linux-amd64", binary)
34+
localPath := filepath.Join(dir, "aws")
35+
36+
require.NoError(t, extractPluginBinary(archivePath, "plugin-aws-v1.0.0-linux-amd64", localPath))
37+
38+
got, err := os.ReadFile(localPath)
39+
require.NoError(t, err)
40+
require.Equal(t, binary, got)
41+
42+
info, err := os.Stat(localPath)
43+
require.NoError(t, err)
44+
require.Equal(t, os.FileMode(0744), info.Mode().Perm())
45+
}
46+
47+
// TestExtractPluginBinaryLeavesNoPartialFile guards the caching path: a failed
48+
// extraction that left bytes at localPath would make the next run skip the
49+
// download and exec a truncated binary.
50+
func TestExtractPluginBinaryLeavesNoPartialFile(t *testing.T) {
51+
dir := t.TempDir()
52+
archivePath := writeTestArchive(t, dir, "plugin-aws-v1.0.0-linux-amd64", []byte("plugin-binary"))
53+
localPath := filepath.Join(dir, "aws")
54+
55+
err := extractPluginBinary(archivePath, "plugin-aws-v9.9.9-linux-amd64", localPath)
56+
require.Error(t, err)
57+
58+
_, statErr := os.Stat(localPath)
59+
require.ErrorIs(t, statErr, os.ErrNotExist)
60+
61+
entries, err := os.ReadDir(dir)
62+
require.NoError(t, err)
63+
for _, entry := range entries {
64+
require.NotContains(t, entry.Name(), ".tmp", "the temporary file must not survive a failed extraction")
65+
}
66+
}

0 commit comments

Comments
 (0)