Files
Benjamin Holmes eb36e3c3e4 plugin-getter: install plugins from non-GitHub HTTP sources (#13691)
Packer can only install plugins from github.com, with
releases.hashicorp.com consulted first for HashiCorp-published plugins.
This has been a long-standing gap for air-gapped and policy-restricted
environments (#11164): the source address parser already accepts any
hostname, but both existing getters reject non-github.com sources at
install time.

Add a remote plugin getter that installs plugins from the host named in
a required_plugins source address. The host serves the directory
structure of releases.hashicorp.com under the source's path: an
index.json listing versions, and per version a SHA256SUMS file, the
zips it lists, and - when the zip names carry no plugin protocol
version - the version's manifest.json. A plugin published on
releases.hashicorp.com is therefore mirrored as a verbatim copy of its
tree, with every checksum file and signature upstream-authored. A
plugin published as GitHub release assets is mirrored by copying each
release's assets into a version directory, renaming their SHA256SUMS
file to the unprefixed convention with content unchanged, and writing
an index.json listing the versions. Both kinds of content can be
served side by side by one host.

Getter selection happens per source address: github.com sources keep
the release and github getters unchanged, while any other host is
served by the remote getter over HTTPS. Sources with three or more
components are supported, up to the existing 16-component limit, so
nested artifact-repository paths and hosts that embed the upstream
origin in their path all resolve.

Version discovery, constraint solving, checksum verification, and the
binary naming rules match the existing getters. The installed filename
is rebuilt from validated checksum-file fields and never taken from the
server's response, checksum entries matching neither known naming shape
are rejected rather than guessed at, and nothing the remote metadata
supplies is used to fetch from another origin or path. index.json
parsing is covered by fixtures captured from the live releases API, so
a format change there fails tests rather than user installs.

Closes #11164
2026-08-26 14:13:48 +05:30

574 lines
16 KiB
Go

// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: BUSL-1.1
package remote
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"runtime"
"strings"
"sync"
"testing"
"github.com/hashicorp/go-version"
"github.com/hashicorp/packer/hcl2template/addrs"
plugingetter "github.com/hashicorp/packer/packer/plugin-getter"
"github.com/stretchr/testify/assert"
)
func TestParseIndex(t *testing.T) {
golden, err := os.ReadFile("testdata/releases_hashicorp_com_index.json")
if err != nil {
t.Fatal(err)
}
t.Run("parses a verbatim releases.hashicorp.com index", func(t *testing.T) {
idx, err := parseIndex(golden, "packer-plugin-docker")
if err != nil {
t.Fatal(err)
}
if len(idx.versions) != 23 {
t.Fatalf("expected 23 versions, got %d", len(idx.versions))
}
if _, ok := idx.versions["1.1.4"]; !ok {
t.Fatal("expected version 1.1.4 in the index")
}
})
t.Run("parses a minimal mirror index", func(t *testing.T) {
idx, err := parseIndex([]byte(`{"versions": {"0.6.6": {}, "0.6.7": {}}}`), "packer-plugin-git")
if err != nil {
t.Fatal(err)
}
if len(idx.versions) != 2 {
t.Fatalf("expected 2 versions, got %d", len(idx.versions))
}
})
rejected := []struct {
name string
index string
}{
{"no versions", `{"versions": {}}`},
{"malformed json", `{"versions": `},
{"only unparseable versions", `{"versions": {"not-a-version": {}}}`},
}
for _, tt := range rejected {
t.Run("rejects "+tt.name, func(t *testing.T) {
if _, err := parseIndex([]byte(tt.index), "packer-plugin-docker"); err == nil {
t.Fatal("expected an error, got none")
}
})
}
}
func TestInit(t *testing.T) {
tests := []struct {
name string
entry *plugingetter.ChecksumFileEntry
binVersion string
protocolVersion string
os string
arch string
wantErr bool
}{
{
name: "github release asset shape parses",
entry: &plugingetter.ChecksumFileEntry{
Filename: "packer-plugin-v0.2.12_x5.0_freebsd_amd64.zip",
},
binVersion: "v0.2.12",
protocolVersion: "x5.0",
os: "freebsd",
arch: "amd64",
},
{
name: "releases.hashicorp.com shape parses with the version normalized",
entry: &plugingetter.ChecksumFileEntry{
Filename: "packer-plugin-1.1.4_darwin_arm64.zip",
},
binVersion: "v1.1.4",
protocolVersion: "",
os: "darwin",
arch: "arm64",
},
{
name: "three fields with a v prefix is neither shape",
entry: &plugingetter.ChecksumFileEntry{
Filename: "packer-plugin-v0.2.12_freebsd_amd64.zip",
},
wantErr: true,
},
{
name: "four fields without a protocol version is neither shape",
entry: &plugingetter.ChecksumFileEntry{
Filename: "packer-plugin-v0.2.12_x5.0.zip",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &plugingetter.Requirement{}
getter := &Getter{}
err := getter.Init(req, tt.entry)
if err != nil && !tt.wantErr {
t.Fatalf("unexpected error: %s", err)
}
if err == nil && tt.wantErr {
t.Fatal("expected error but got nil")
}
if !tt.wantErr && (tt.entry.BinVersion != tt.binVersion || tt.entry.ProtVersion != tt.protocolVersion || tt.entry.Os != tt.os || tt.entry.Arch != tt.arch) {
t.Fatalf("unexpected parsed values: %+v", tt.entry)
}
})
}
}
func TestValidate(t *testing.T) {
tests := []struct {
name string
installOpts plugingetter.BinaryInstallationOptions
entry *plugingetter.ChecksumFileEntry
version string
wantErr bool
}{
{
name: "valid entry",
installOpts: plugingetter.BinaryInstallationOptions{
OS: "linux",
ARCH: "amd64",
},
entry: &plugingetter.ChecksumFileEntry{
BinVersion: "v1.2.3",
Os: "linux",
Arch: "amd64",
ProtVersion: "x5.0",
},
version: "1.2.3",
},
{
name: "wrong version",
installOpts: plugingetter.BinaryInstallationOptions{
OS: "linux",
ARCH: "amd64",
},
entry: &plugingetter.ChecksumFileEntry{
BinVersion: "v1.2.3",
Os: "linux",
Arch: "amd64",
ProtVersion: "x5.0",
},
version: "1.2.4",
wantErr: true,
},
{
name: "wrong system",
installOpts: plugingetter.BinaryInstallationOptions{
OS: "linux",
ARCH: "amd64",
},
entry: &plugingetter.ChecksumFileEntry{
BinVersion: "v1.2.3",
Os: "darwin",
Arch: "arm64",
ProtVersion: "x5.0",
},
version: "1.2.3",
wantErr: true,
},
{
name: "incompatible protocol version",
installOpts: plugingetter.BinaryInstallationOptions{
OS: "linux", ARCH: "amd64",
APIVersionMajor: "5", APIVersionMinor: "0",
},
entry: &plugingetter.ChecksumFileEntry{
BinVersion: "v1.2.3",
Os: "linux",
Arch: "amd64",
ProtVersion: "x6.0",
},
version: "1.2.3",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
getter := &Getter{}
err := getter.Validate(plugingetter.GetOptions{}, tt.version, tt.installOpts, tt.entry)
if (err != nil) != tt.wantErr {
t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestExpectedFileName(t *testing.T) {
getter := &Getter{}
pr := &plugingetter.Requirement{
Identifier: &addrs.Plugin{
Source: "plugins.example.com/mirror/hashicorp/comment",
},
}
entry := &plugingetter.ChecksumFileEntry{
BinVersion: "v1.2.3",
ProtVersion: "x5.0",
Os: "linux",
Arch: "amd64",
Ext: ".zip",
}
fileName := getter.ExpectedFileName(pr, "1.2.3", entry, "packer-plugin-comment_v1.2.3_x5.0_linux_amd64.zip")
assert.Equal(t, "packer-plugin-comment_v1.2.3_x5.0_linux_amd64.zip", fileName)
// A malicious zipFileName with path traversal must not affect the output.
maliciousFileName := getter.ExpectedFileName(pr, "1.2.3", entry,
`packer-plugin-comment_v1.2.3_x5.0_linux_amd64_/../../tmp/PWNED.zip`)
assert.Equal(t, "packer-plugin-comment_v1.2.3_x5.0_linux_amd64.zip", maliciousFileName)
}
// fakeRelease describes one plugin version served by the test server. All
// versions live in the releases.hashicorp.com directory structure; official
// releases carry release-site zip names and a manifest.json, retrofitted
// GitHub releases carry the author's asset names and need no manifest.
type fakeRelease struct {
version string // "1.1.0"
platforms []string // "darwin_amd64"
official bool
corruptChecksum bool
omitManifest bool // official only: do not serve manifest.json
omitSums bool // do not serve the SHA256SUMS file
}
// pluginBasePath derives the URL path the getter must request for a source,
// e.g. "example.com/mirror/hashicorp/comment" -> "/mirror/hashicorp/packer-plugin-comment".
func pluginBasePath(source string) string {
parts := strings.Split(source, "/")
return "/" + strings.Join(append(parts[1:len(parts)-1], "packer-plugin-"+parts[len(parts)-1]), "/")
}
// buildPluginFiles renders the served file tree for one plugin below
// pluginBasePath(source).
func buildPluginFiles(t *testing.T, source string, releases []fakeRelease) map[string][]byte {
t.Helper()
files := map[string][]byte{}
base := pluginBasePath(source)
parts := strings.Split(source, "/")
plugin := "packer-plugin-" + parts[len(parts)-1]
indexVersions := map[string]interface{}{}
for _, rel := range releases {
indexVersions[rel.version] = map[string]string{}
sums := &strings.Builder{}
for _, platform := range rel.platforms {
binName := fmt.Sprintf("%s_v%s_x5.0_%s", plugin, rel.version, platform)
zipName := binName + ".zip"
if rel.official {
zipName = fmt.Sprintf("%s_%s_%s.zip", plugin, rel.version, platform)
}
zipBuf := &bytes.Buffer{}
zw := zip.NewWriter(zipBuf)
fw, err := zw.Create(binName)
if err != nil {
t.Fatal(err)
}
_, _ = fmt.Fprintf(fw, "#!/bin/sh\necho '{\"version\":\"v%s\",\"api_version\":\"x5.0\"}'\n", rel.version)
if err := zw.Close(); err != nil {
t.Fatal(err)
}
checksum := sha256.Sum256(zipBuf.Bytes())
if rel.corruptChecksum {
checksum[0] ^= 0xff
}
_, _ = fmt.Fprintf(sums, "%x %s\n", checksum, zipName)
files[base+"/"+rel.version+"/"+zipName] = zipBuf.Bytes()
}
if rel.official {
manifestName := fmt.Sprintf("%s_%s_manifest.json", plugin, rel.version)
manifest := []byte(`{"version": "1", "metadata": {"protocol_version": "5.0"}}`)
checksum := sha256.Sum256(manifest)
_, _ = fmt.Fprintf(sums, "%x %s\n", checksum, manifestName)
if !rel.omitManifest {
files[base+"/"+rel.version+"/"+manifestName] = manifest
}
}
if !rel.omitSums {
sumsName := fmt.Sprintf("%s_%s_SHA256SUMS", plugin, rel.version)
files[base+"/"+rel.version+"/"+sumsName] = []byte(sums.String())
}
}
if releases != nil {
indexBytes, err := json.Marshal(map[string]interface{}{"versions": indexVersions})
if err != nil {
t.Fatal(err)
}
files[base+"/index.json"] = indexBytes
}
return files
}
// newTestServer serves one or more plugin file trees, recording every
// requested path.
func newTestServer(t *testing.T, trees []map[string][]byte, requested *[]string, mu *sync.Mutex) *httptest.Server {
t.Helper()
files := map[string][]byte{}
for _, tree := range trees {
for k, v := range tree {
files[k] = v
}
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
*requested = append(*requested, r.URL.Path)
mu.Unlock()
body, ok := files[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
_, _ = w.Write(body)
}))
}
// installFromServer runs InstallLatest for source against the server, into a
// fresh plugin directory.
func installFromServer(t *testing.T, server *httptest.Server, source, constraints string) (*plugingetter.Installation, error) {
t.Helper()
identifier, err := addrs.ParsePluginSourceString(source)
if err != nil {
t.Fatal(err)
}
cts, err := version.NewConstraint(constraints)
if err != nil {
t.Fatal(err)
}
pr := &plugingetter.Requirement{
Identifier: identifier,
VersionConstraints: cts,
}
return pr.InstallLatest(plugingetter.InstallOptions{
Getters: []plugingetter.Getter{
&Getter{
BaseURL: server.URL,
HttpClient: server.Client(),
Name: strings.Split(source, "/")[0],
},
},
PluginDirectory: t.TempDir(),
BinaryInstallationOptions: plugingetter.BinaryInstallationOptions{
APIVersionMajor: "5", APIVersionMinor: "0",
OS: "darwin", ARCH: "amd64",
Checksummers: []plugingetter.Checksummer{
{Type: "sha256", Hash: sha256.New()},
},
},
})
}
func TestInstallFromRemote(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test plugins are shell scripts, which cannot run on Windows")
}
tests := []struct {
name string
source string // defaults to a 4-part source
releases []fakeRelease
constraints string
wantVersion string
wantErr string // substring of the expected error, "" for success
}{
{
name: "resolves highest version matching constraint",
releases: []fakeRelease{
{version: "0.9.0", platforms: []string{"darwin_amd64"}},
{version: "1.0.5", platforms: []string{"darwin_amd64"}},
{version: "1.1.0", platforms: []string{"darwin_amd64"}},
{version: "2.0.0", platforms: []string{"darwin_amd64"}},
},
constraints: ">= 1.0, < 2.0",
wantVersion: "v1.1.0",
},
{
name: "bad checksum fails the install",
releases: []fakeRelease{
{version: "1.1.0", platforms: []string{"darwin_amd64"}, corruptChecksum: true},
},
constraints: ">= 1.0",
wantErr: "checksum",
},
{
name: "falls back to a lower version when the platform is missing",
releases: []fakeRelease{
{version: "1.0.5", platforms: []string{"darwin_amd64"}},
{version: "1.1.0", platforms: []string{"linux_arm64"}},
},
constraints: ">= 1.0",
wantVersion: "v1.0.5",
},
{
name: "missing index.json fails with the URL",
releases: nil,
constraints: ">= 1.0",
wantErr: "index.json",
},
{
name: "3-part source installs from the docroot",
source: "ghes.example.com/ethanmdavidson/comment",
releases: []fakeRelease{
{version: "1.0.5", platforms: []string{"darwin_amd64"}},
},
constraints: ">= 1.0",
wantVersion: "v1.0.5",
},
{
name: "5-part source installs from a nested repository path",
source: "artifactory.example.com/artifactory/hashi-tools/hashicorp/comment",
releases: []fakeRelease{
{version: "1.0.5", platforms: []string{"darwin_amd64"}},
},
constraints: ">= 1.0",
wantVersion: "v1.0.5",
},
{
name: "verbatim releases.hashicorp.com content installs",
releases: []fakeRelease{
{version: "1.1.3", platforms: []string{"darwin_amd64"}, official: true},
{version: "1.1.4", platforms: []string{"darwin_amd64"}, official: true},
},
constraints: ">= 1.1.0, < 1.1.4",
wantVersion: "v1.1.3",
},
{
name: "official content without its manifest fails",
releases: []fakeRelease{
{version: "1.1.4", platforms: []string{"darwin_amd64"}, official: true, omitManifest: true},
},
constraints: ">= 1.1.0",
wantErr: "manifest.json",
},
{
name: "missing checksum file fails with its name",
releases: []fakeRelease{
{version: "1.1.4", platforms: []string{"darwin_amd64"}, official: true, omitSums: true},
},
constraints: ">= 1.1.0",
wantErr: "packer-plugin-comment_1.1.4_SHA256SUMS",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
source := tt.source
if source == "" {
source = "example.com/mirror/hashicorp/comment"
}
base := pluginBasePath(source)
var requested []string
var mu sync.Mutex
server := newTestServer(t, []map[string][]byte{buildPluginFiles(t, source, tt.releases)}, &requested, &mu)
defer server.Close()
got, err := installFromServer(t, server, source, tt.constraints)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected an error containing %q, got none", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error to contain %q, got: %s", tt.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("InstallLatest() error: %s", err)
}
if got == nil || got.Version != tt.wantVersion {
t.Fatalf("expected version %q installed, got %+v", tt.wantVersion, got)
}
if _, err := os.Stat(got.BinaryPath); err != nil {
t.Fatalf("installed binary missing: %s", err)
}
if !strings.Contains(got.BinaryPath, source) {
t.Fatalf("binary installed outside the source hierarchy: %s", got.BinaryPath)
}
mu.Lock()
defer mu.Unlock()
assert.Contains(t, requested, base+"/index.json",
"discovery must hit the full multi-part source path")
for _, p := range requested {
if strings.Contains(p, "/v"+strings.TrimPrefix(tt.wantVersion, "v")+"/") {
t.Fatalf("requested a v-prefixed version directory: %s", p)
}
}
})
}
}
// TestInstallSideBySide proves one host can serve a verbatim
// releases.hashicorp.com mirror and a retrofitted GitHub-release mirror next
// to each other.
func TestInstallSideBySide(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test plugins are shell scripts, which cannot run on Windows")
}
communitySource := "plugins.example.com/mirror/community/comment"
hashicorpSource := "plugins.example.com/mirror/hashicorp/docker"
var requested []string
var mu sync.Mutex
server := newTestServer(t, []map[string][]byte{
buildPluginFiles(t, communitySource, []fakeRelease{
{version: "0.6.7", platforms: []string{"darwin_amd64"}},
}),
buildPluginFiles(t, hashicorpSource, []fakeRelease{
{version: "1.1.4", platforms: []string{"darwin_amd64"}, official: true},
}),
}, &requested, &mu)
defer server.Close()
community, err := installFromServer(t, server, communitySource, ">= 0.6.0")
if err != nil {
t.Fatalf("community plugin install failed: %s", err)
}
if community.Version != "v0.6.7" {
t.Fatalf("expected community v0.6.7, got %+v", community)
}
hashicorp, err := installFromServer(t, server, hashicorpSource, ">= 1.1.0")
if err != nil {
t.Fatalf("hashicorp plugin install failed: %s", err)
}
if hashicorp.Version != "v1.1.4" {
t.Fatalf("expected hashicorp v1.1.4, got %+v", hashicorp)
}
}