1
0
mirror of synced 2026-05-03 02:03:31 +00:00
Files
flux2/internal/plugin/install_test.go
Stefan Prodan 9a4b93056b Add support for plugin binary artifacts
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
2026-04-14 00:46:23 +03:00

691 lines
18 KiB
Go

/*
Copyright 2026 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package plugin
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"crypto/sha256"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// createTestTarGz creates a tar.gz archive containing a single file.
func createTestTarGz(name string, content []byte) ([]byte, error) {
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
hdr := &tar.Header{
Name: name,
Mode: 0o755,
Size: int64(len(content)),
}
if err := tw.WriteHeader(hdr); err != nil {
return nil, err
}
if _, err := tw.Write(content); err != nil {
return nil, err
}
tw.Close()
gw.Close()
return buf.Bytes(), nil
}
// createTestTar creates an uncompressed tar archive containing a single file.
func createTestTar(name string, content []byte) ([]byte, error) {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
hdr := &tar.Header{
Name: name,
Mode: 0o755,
Size: int64(len(content)),
}
if err := tw.WriteHeader(hdr); err != nil {
return nil, err
}
if _, err := tw.Write(content); err != nil {
return nil, err
}
tw.Close()
return buf.Bytes(), nil
}
// tarEntry describes a single entry for createTestTarGzMulti.
type tarEntry struct {
header tar.Header
content []byte
}
// createTestTarGzMulti creates a tar.gz archive with arbitrary entries.
// Used to test rejection of unsafe or non-regular entries.
func createTestTarGzMulti(entries []tarEntry) ([]byte, error) {
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
for _, e := range entries {
hdr := e.header
hdr.Size = int64(len(e.content))
if err := tw.WriteHeader(&hdr); err != nil {
return nil, err
}
if len(e.content) > 0 {
if _, err := tw.Write(e.content); err != nil {
return nil, err
}
}
}
tw.Close()
gw.Close()
return buf.Bytes(), nil
}
// zipEntry describes a single entry for createTestZip.
type zipEntry struct {
name string
mode fs.FileMode
content []byte
}
// createTestZip creates a zip archive with arbitrary entries. Entries may
// carry Unix mode bits (e.g. os.ModeSymlink) to exercise non-regular files.
func createTestZip(entries []zipEntry) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, e := range entries {
hdr := &zip.FileHeader{
Name: e.name,
Method: zip.Deflate,
}
mode := e.mode
if mode == 0 {
mode = 0o755
}
hdr.SetMode(mode)
w, err := zw.CreateHeader(hdr)
if err != nil {
return nil, err
}
if _, err := w.Write(e.content); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func TestInstall(t *testing.T) {
binaryContent := []byte("#!/bin/sh\necho hello")
archive, err := createTestTarGz("flux-operator", binaryContent)
if err != nil {
t.Fatalf("failed to create test archive: %v", err)
}
checksum := fmt.Sprintf("sha256:%x", sha256.Sum256(archive))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(archive)
}))
defer server.Close()
pluginDir := t.TempDir()
manifest := &PluginManifest{
Name: "operator",
Bin: "flux-operator",
}
pv := &PluginVersion{Version: "0.45.0"}
plat := &PluginPlatform{
OS: "linux",
Arch: "amd64",
URL: server.URL + "/flux-operator_0.45.0_linux_amd64.tar.gz",
Checksum: checksum,
}
installer := &Installer{HTTPClient: server.Client()}
if err := installer.Install(pluginDir, manifest, pv, plat); err != nil {
t.Fatalf("install failed: %v", err)
}
// Verify binary was written.
binPath := filepath.Join(pluginDir, "flux-operator")
data, err := os.ReadFile(binPath)
if err != nil {
t.Fatalf("binary not found: %v", err)
}
if string(data) != string(binaryContent) {
t.Errorf("binary content mismatch")
}
// Verify receipt was written.
receipt := ReadReceipt(pluginDir, "operator")
if receipt == nil {
t.Fatal("receipt not found")
}
if receipt.Version != "0.45.0" {
t.Errorf("expected version '0.45.0', got %q", receipt.Version)
}
if receipt.Name != "operator" {
t.Errorf("expected name 'operator', got %q", receipt.Name)
}
}
func TestInstallChecksumMismatch(t *testing.T) {
binaryContent := []byte("#!/bin/sh\necho hello")
archive, err := createTestTarGz("flux-operator", binaryContent)
if err != nil {
t.Fatalf("failed to create test archive: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(archive)
}))
defer server.Close()
pluginDir := t.TempDir()
manifest := &PluginManifest{Name: "operator", Bin: "flux-operator"}
pv := &PluginVersion{Version: "0.45.0"}
plat := &PluginPlatform{
OS: "linux",
Arch: "amd64",
URL: server.URL + "/archive.tar.gz",
Checksum: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
}
installer := &Installer{HTTPClient: server.Client()}
err = installer.Install(pluginDir, manifest, pv, plat)
if err == nil {
t.Fatal("expected checksum error, got nil")
}
if !bytes.Contains([]byte(err.Error()), []byte("checksum verification failed")) {
t.Errorf("expected checksum error, got: %v", err)
}
}
func TestInstallBinaryNotInArchive(t *testing.T) {
// Archive contains "wrong-name" instead of "flux-operator".
archive, err := createTestTarGz("wrong-name", []byte("content"))
if err != nil {
t.Fatalf("failed to create test archive: %v", err)
}
checksum := fmt.Sprintf("sha256:%x", sha256.Sum256(archive))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(archive)
}))
defer server.Close()
pluginDir := t.TempDir()
manifest := &PluginManifest{Name: "operator", Bin: "flux-operator"}
pv := &PluginVersion{Version: "0.45.0"}
plat := &PluginPlatform{
OS: "linux",
Arch: "amd64",
URL: server.URL + "/archive.tar.gz",
Checksum: checksum,
}
installer := &Installer{HTTPClient: server.Client()}
err = installer.Install(pluginDir, manifest, pv, plat)
if err == nil {
t.Fatal("expected error for missing binary, got nil")
}
if !bytes.Contains([]byte(err.Error()), []byte("not found in archive")) {
t.Errorf("expected 'not found in archive' error, got: %v", err)
}
}
func TestUninstall(t *testing.T) {
pluginDir := t.TempDir()
// Create fake binary and receipt.
binPath := filepath.Join(pluginDir, "flux-testplugin")
os.WriteFile(binPath, []byte("binary"), 0o755)
receiptPath := filepath.Join(pluginDir, "flux-testplugin.yaml")
os.WriteFile(receiptPath, []byte("name: testplugin"), 0o644)
if err := Uninstall(pluginDir, "testplugin"); err != nil {
t.Fatalf("uninstall failed: %v", err)
}
if _, err := os.Stat(binPath); !os.IsNotExist(err) {
t.Error("binary was not removed")
}
if _, err := os.Stat(receiptPath); !os.IsNotExist(err) {
t.Error("receipt was not removed")
}
}
func TestUninstallNonExistent(t *testing.T) {
pluginDir := t.TempDir()
err := Uninstall(pluginDir, "nonexistent")
if err == nil {
t.Fatal("expected error for non-existent plugin, got nil")
}
if !strings.Contains(err.Error(), "is not installed") {
t.Errorf("expected 'is not installed' error, got: %v", err)
}
}
func TestUninstallSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require elevated privileges on Windows")
}
pluginDir := t.TempDir()
// Create a real binary and symlink it into the plugin dir.
realBin := filepath.Join(t.TempDir(), "flux-operator")
os.WriteFile(realBin, []byte("real binary"), 0o755)
linkPath := filepath.Join(pluginDir, "flux-linked")
os.Symlink(realBin, linkPath)
if err := Uninstall(pluginDir, "linked"); err != nil {
t.Fatalf("uninstall symlink failed: %v", err)
}
// Symlink should be removed.
if _, err := os.Lstat(linkPath); !os.IsNotExist(err) {
t.Error("symlink was not removed")
}
// Original binary should still exist.
if _, err := os.Stat(realBin); err != nil {
t.Error("original binary was removed — symlink removal should not affect target")
}
}
func TestUninstallManualBinary(t *testing.T) {
pluginDir := t.TempDir()
// Manually copied binary with no receipt.
binPath := filepath.Join(pluginDir, "flux-manual")
os.WriteFile(binPath, []byte("binary"), 0o755)
if err := Uninstall(pluginDir, "manual"); err != nil {
t.Fatalf("uninstall manual binary failed: %v", err)
}
if _, err := os.Stat(binPath); !os.IsNotExist(err) {
t.Error("binary was not removed")
}
}
func TestReadReceipt(t *testing.T) {
pluginDir := t.TempDir()
t.Run("exists", func(t *testing.T) {
receiptData := `name: operator
version: "0.45.0"
installedAt: "2026-03-28T20:05:00Z"
platform:
os: darwin
arch: arm64
url: https://example.com/archive.tar.gz
checksum: sha256:abc123
`
os.WriteFile(filepath.Join(pluginDir, "flux-operator.yaml"), []byte(receiptData), 0o644)
receipt := ReadReceipt(pluginDir, "operator")
if receipt == nil {
t.Fatal("expected receipt, got nil")
}
if receipt.Version != "0.45.0" {
t.Errorf("expected version '0.45.0', got %q", receipt.Version)
}
if receipt.Platform.OS != "darwin" {
t.Errorf("expected OS 'darwin', got %q", receipt.Platform.OS)
}
})
t.Run("not exists", func(t *testing.T) {
receipt := ReadReceipt(pluginDir, "nonexistent")
if receipt != nil {
t.Error("expected nil receipt")
}
})
}
func TestInstallRawBinary(t *testing.T) {
// Bytes that don't match zip/gzip/tar magic — treated as a raw binary.
binaryContent := []byte("#!/bin/sh\necho raw plugin")
checksum := fmt.Sprintf("sha256:%x", sha256.Sum256(binaryContent))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(binaryContent)
}))
defer server.Close()
pluginDir := t.TempDir()
manifest := &PluginManifest{
Name: "validate",
Bin: "flux-validate",
}
pv := &PluginVersion{Version: "1.2.3"}
plat := &PluginPlatform{
OS: runtime.GOOS,
Arch: runtime.GOARCH,
// URL filename deliberately differs from manifest.Bin — mimics a
// typical GitHub release asset that includes platform/version in
// the name. The installer must rename to manifest.Bin.
URL: server.URL + "/download/flux-validate-" + runtime.GOARCH + "-v1.2.3",
Checksum: checksum,
}
installer := &Installer{HTTPClient: server.Client()}
if err := installer.Install(pluginDir, manifest, pv, plat); err != nil {
t.Fatalf("install failed: %v", err)
}
// The installed file must be named exactly manifest.Bin (+ .exe on Windows),
// regardless of what the URL path looked like.
wantName := "flux-validate"
if runtime.GOOS == "windows" {
wantName += ".exe"
}
binPath := filepath.Join(pluginDir, wantName)
data, err := os.ReadFile(binPath)
if err != nil {
t.Fatalf("binary not found at %s: %v", binPath, err)
}
if !bytes.Equal(data, binaryContent) {
t.Errorf("binary content mismatch: got %q, want %q", data, binaryContent)
}
// Nothing should have been written under the URL-derived name.
urlDerived := filepath.Join(pluginDir, "flux-validate-"+runtime.GOARCH+"-v1.2.3")
if _, err := os.Stat(urlDerived); !os.IsNotExist(err) {
t.Errorf("unexpected file at URL-derived path %s", urlDerived)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(binPath)
if err != nil {
t.Fatalf("stat: %v", err)
}
if info.Mode()&0o111 == 0 {
t.Errorf("binary is not executable: mode %v", info.Mode())
}
}
// Raw binary install must still produce a receipt.
if receipt := ReadReceipt(pluginDir, "validate"); receipt == nil {
t.Fatal("receipt not found")
}
}
func TestDetectArchiveFormat(t *testing.T) {
tarGz, err := createTestTarGz("bin", []byte("content"))
if err != nil {
t.Fatalf("createTestTarGz: %v", err)
}
plainTar, err := createTestTar("bin", []byte("content"))
if err != nil {
t.Fatalf("createTestTar: %v", err)
}
tests := []struct {
name string
url string
content []byte
want archiveFormat
}{
// Extension-based detection takes precedence over content.
{"zip extension", "https://example.com/plugin.zip", []byte("ignored"), formatZip},
{"tar.gz extension", "https://example.com/plugin.tar.gz", []byte("ignored"), formatTarGz},
{"tgz extension", "https://example.com/plugin.tgz", []byte("ignored"), formatTarGz},
{"tar extension", "https://example.com/plugin.tar", []byte("ignored"), formatTar},
{"uppercase extension", "https://example.com/PLUGIN.ZIP", []byte("ignored"), formatZip},
// Magic-byte detection when extension is absent or unrecognized.
{"zip magic no extension", "https://example.com/download", []byte{'P', 'K', 0x03, 0x04, 0, 0, 0, 0}, formatZip},
{"gzip magic no extension", "https://example.com/download", tarGz, formatTarGz},
{"tar magic no extension", "https://example.com/download", plainTar, formatTar},
// Fallback to raw binary.
{"unknown content", "https://example.com/download", []byte("#!/bin/sh\necho hi"), formatBinary},
{"short file", "https://example.com/download", []byte("ab"), formatBinary},
{"empty file", "https://example.com/download", []byte{}, formatBinary},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "artifact")
if err := os.WriteFile(tmp, tc.content, 0o644); err != nil {
t.Fatal(err)
}
got, err := detectArchiveFormat(tmp, tc.url)
if err != nil {
t.Fatalf("detectArchiveFormat: %v", err)
}
if got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
func TestExtractFromTarGz(t *testing.T) {
content := []byte("test binary content")
archive, err := createTestTarGz("flux-operator", content)
if err != nil {
t.Fatalf("failed to create archive: %v", err)
}
tmpFile := filepath.Join(t.TempDir(), "test.tar.gz")
os.WriteFile(tmpFile, archive, 0o644)
destPath := filepath.Join(t.TempDir(), "flux-operator")
if err := extractFromTarGz(tmpFile, "flux-operator", destPath); err != nil {
t.Fatalf("extract failed: %v", err)
}
data, err := os.ReadFile(destPath)
if err != nil {
t.Fatalf("failed to read extracted file: %v", err)
}
if string(data) != string(content) {
t.Errorf("content mismatch: got %q, want %q", string(data), string(content))
}
}
func TestExtractFromTarGzRejectsUnsafeEntries(t *testing.T) {
content := []byte("legit content")
// Archive contains, in order:
// 1. A symlink whose basename matches the target (must be skipped).
// 2. A regular entry with ".." in the path (must be skipped).
// 3. An absolute-path entry (must be skipped).
// 4. A legitimate regular file that must be extracted.
entries := []tarEntry{
{
header: tar.Header{
Name: "flux-operator",
Typeflag: tar.TypeSymlink,
Linkname: "/etc/passwd",
Mode: 0o777,
},
},
{
header: tar.Header{
Name: "../flux-operator",
Typeflag: tar.TypeReg,
Mode: 0o755,
},
content: []byte("malicious"),
},
{
header: tar.Header{
Name: "/absolute/flux-operator",
Typeflag: tar.TypeReg,
Mode: 0o755,
},
content: []byte("malicious"),
},
{
header: tar.Header{
Name: "bin/flux-operator",
Typeflag: tar.TypeReg,
Mode: 0o755,
},
content: content,
},
}
archive, err := createTestTarGzMulti(entries)
if err != nil {
t.Fatalf("createTestTarGzMulti: %v", err)
}
tmpFile := filepath.Join(t.TempDir(), "test.tar.gz")
os.WriteFile(tmpFile, archive, 0o644)
destPath := filepath.Join(t.TempDir(), "flux-operator")
if err := extractFromTarGz(tmpFile, "flux-operator", destPath); err != nil {
t.Fatalf("extract failed: %v", err)
}
data, err := os.ReadFile(destPath)
if err != nil {
t.Fatalf("failed to read extracted file: %v", err)
}
if !bytes.Equal(data, content) {
t.Errorf("extracted content mismatch: got %q, want %q", data, content)
}
}
func TestExtractFromZip(t *testing.T) {
content := []byte("test binary content")
archive, err := createTestZip([]zipEntry{
{name: "flux-operator", content: content},
})
if err != nil {
t.Fatalf("createTestZip: %v", err)
}
tmpFile := filepath.Join(t.TempDir(), "test.zip")
os.WriteFile(tmpFile, archive, 0o644)
destPath := filepath.Join(t.TempDir(), "flux-operator")
if err := extractFromZip(tmpFile, "flux-operator", destPath); err != nil {
t.Fatalf("extract failed: %v", err)
}
data, err := os.ReadFile(destPath)
if err != nil {
t.Fatalf("failed to read extracted file: %v", err)
}
if !bytes.Equal(data, content) {
t.Errorf("content mismatch: got %q, want %q", data, content)
}
}
func TestExtractFromZipRejectsUnsafeEntries(t *testing.T) {
content := []byte("legit content")
// Archive contains, in order:
// 1. A symlink whose basename matches the target (must be skipped).
// 2. An entry with ".." in the path (must be skipped).
// 3. An absolute-path entry (must be skipped).
// 4. A directory entry whose basename matches (must be skipped).
// 5. A legitimate regular file that must be extracted.
entries := []zipEntry{
{
name: "flux-operator",
mode: fs.ModeSymlink | 0o777,
content: []byte("/etc/passwd"),
},
{
name: "../flux-operator",
content: []byte("malicious"),
},
{
name: "/absolute/flux-operator",
content: []byte("malicious"),
},
{
name: "flux-operator/",
mode: fs.ModeDir | 0o755,
},
{
name: "bin/flux-operator",
content: content,
},
}
archive, err := createTestZip(entries)
if err != nil {
t.Fatalf("createTestZip: %v", err)
}
tmpFile := filepath.Join(t.TempDir(), "test.zip")
os.WriteFile(tmpFile, archive, 0o644)
destPath := filepath.Join(t.TempDir(), "flux-operator")
if err := extractFromZip(tmpFile, "flux-operator", destPath); err != nil {
t.Fatalf("extract failed: %v", err)
}
data, err := os.ReadFile(destPath)
if err != nil {
t.Fatalf("failed to read extracted file: %v", err)
}
if !bytes.Equal(data, content) {
t.Errorf("extracted content mismatch: got %q, want %q", data, content)
}
}
func TestExtractFromTarGzNotFound(t *testing.T) {
archive, err := createTestTarGz("other-binary", []byte("content"))
if err != nil {
t.Fatalf("failed to create archive: %v", err)
}
tmpFile := filepath.Join(t.TempDir(), "test.tar.gz")
os.WriteFile(tmpFile, archive, 0o644)
destPath := filepath.Join(t.TempDir(), "flux-operator")
err = extractFromTarGz(tmpFile, "flux-operator", destPath)
if err == nil {
t.Fatal("expected error, got nil")
}
}