| // Copyright (c) HashiCorp, Inc. |
| // SPDX-License-Identifier: BUSL-1.1 |
| |
| package pluginshared |
| |
| import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| "fmt" |
| "log" |
| "os" |
| "path" |
| "path/filepath" |
| "strings" |
| "time" |
| |
| "github.com/hashicorp/go-getter" |
| svchost "github.com/hashicorp/terraform-svchost" |
| "github.com/hashicorp/terraform/internal/releaseauth" |
| ) |
| |
| // BinaryManager downloads, caches, and returns information about the |
| // plugin binary downloaded from the specified backend. |
| type BinaryManager struct { |
| signingKey string |
| binaryName string |
| pluginName string |
| pluginDataDir string |
| overridePath string |
| host svchost.Hostname |
| client *BasePluginClient |
| goos string |
| arch string |
| ctx context.Context |
| } |
| |
| // Binary is a struct containing the path to an authenticated binary corresponding to |
| // a backend service. |
| type Binary struct { |
| Path string |
| ProductVersion string |
| ResolvedFromCache bool |
| ResolvedFromDevOverride bool |
| } |
| |
| const ( |
| KB = 1000 |
| MB = 1000 * KB |
| ) |
| |
| func (v BinaryManager) binaryLocation() string { |
| return path.Join(v.pluginDataDir, "bin", fmt.Sprintf("%s_%s", v.goos, v.arch)) |
| } |
| |
| func (v BinaryManager) cachedVersion(version string) *string { |
| binaryPath := path.Join(v.binaryLocation(), v.binaryName) |
| |
| if _, err := os.Stat(binaryPath); err != nil { |
| return nil |
| } |
| |
| // The version from the manifest must match the contents of ".version" |
| versionData, err := os.ReadFile(path.Join(v.binaryLocation(), ".version")) |
| if err != nil || strings.Trim(string(versionData), " \n\r\t") != version { |
| return nil |
| } |
| |
| return &binaryPath |
| } |
| |
| // Resolve fetches, authenticates, and caches a plugin binary matching the specifications |
| // and returns its location and version. |
| func (v BinaryManager) Resolve() (*Binary, error) { |
| if v.overridePath != "" { |
| log.Printf("[TRACE] Using dev override for %s binary", v.pluginName) |
| return v.resolveDev() |
| } |
| return v.resolveRelease() |
| } |
| |
| func (v BinaryManager) resolveDev() (*Binary, error) { |
| return &Binary{ |
| Path: v.overridePath, |
| ProductVersion: "dev", |
| ResolvedFromDevOverride: true, |
| }, nil |
| } |
| |
| func (v BinaryManager) resolveRelease() (*Binary, error) { |
| manifest, err := v.latestManifest(v.ctx) |
| if err != nil { |
| return nil, fmt.Errorf("could not resolve %s version for host %q: %w", v.pluginName, v.host.ForDisplay(), err) |
| } |
| |
| buildInfo, err := manifest.Select(v.pluginName, v.goos, v.arch) |
| if err != nil { |
| return nil, err |
| } |
| |
| // Check if there's a cached binary |
| if cachedBinary := v.cachedVersion(manifest.Version); cachedBinary != nil { |
| return &Binary{ |
| Path: *cachedBinary, |
| ProductVersion: manifest.Version, |
| ResolvedFromCache: true, |
| }, nil |
| } |
| |
| // Download the archive |
| t, err := os.CreateTemp(os.TempDir(), v.binaryName) |
| if err != nil { |
| return nil, fmt.Errorf("failed to create temp file for download: %w", err) |
| } |
| defer os.Remove(t.Name()) |
| |
| err = v.client.DownloadFile(buildInfo.URL, t) |
| if err != nil { |
| return nil, err |
| } |
| t.Close() // Close only returns an error if it's already been called |
| |
| // Authenticate the archive |
| err = v.verifyPlugin(manifest, buildInfo, t.Name()) |
| if err != nil { |
| return nil, fmt.Errorf("could not resolve %s version %q: %w", v.pluginName, manifest.Version, err) |
| } |
| |
| // Unarchive |
| unzip := getter.ZipDecompressor{ |
| FilesLimit: 3, // plugin binary, .version file, and LICENSE.txt |
| FileSizeLimit: 500 * MB, |
| } |
| targetPath := v.binaryLocation() |
| log.Printf("[TRACE] decompressing %q to %q", t.Name(), targetPath) |
| |
| err = unzip.Decompress(targetPath, t.Name(), true, 0000) |
| if err != nil { |
| return nil, fmt.Errorf("failed to decompress %s: %w", v.pluginName, err) |
| } |
| |
| err = os.WriteFile(path.Join(targetPath, ".version"), []byte(manifest.Version), 0644) |
| if err != nil { |
| log.Printf("[ERROR] failed to write .version file to %q: %s", targetPath, err) |
| } |
| |
| return &Binary{ |
| Path: path.Join(targetPath, v.binaryName), |
| ProductVersion: manifest.Version, |
| ResolvedFromCache: false, |
| }, nil |
| } |
| |
| // Useful for small files that can be decoded all at once |
| func (v BinaryManager) downloadFileBuffer(pathOrURL string) ([]byte, error) { |
| buffer := bytes.Buffer{} |
| err := v.client.DownloadFile(pathOrURL, &buffer) |
| if err != nil { |
| return nil, err |
| } |
| |
| return buffer.Bytes(), err |
| } |
| |
| // verifyPlugin authenticates the downloaded release archive |
| func (v BinaryManager) verifyPlugin(archiveManifest *Release, info *BuildArtifact, archiveLocation string) error { |
| signature, err := v.downloadFileBuffer(archiveManifest.URLSHASumsSignatures[0]) |
| if err != nil { |
| return fmt.Errorf("failed to download %s SHA256SUMS signature file: %w", v.pluginName, err) |
| } |
| sums, err := v.downloadFileBuffer(archiveManifest.URLSHASums) |
| if err != nil { |
| return fmt.Errorf("failed to download %s SHA256SUMS file: %w", v.pluginName, err) |
| } |
| |
| checksums, err := releaseauth.ParseChecksums(sums) |
| if err != nil { |
| return fmt.Errorf("failed to parse %s SHA256SUMS file: %w", v.pluginName, err) |
| } |
| |
| filename := path.Base(info.URL) |
| reportedSHA, ok := checksums[filename] |
| if !ok { |
| return fmt.Errorf("could not find checksum for file %q", filename) |
| } |
| |
| sigAuth := releaseauth.NewSignatureAuthentication(signature, sums) |
| if len(v.signingKey) > 0 { |
| sigAuth.PublicKey = v.signingKey |
| } |
| |
| all := releaseauth.AllAuthenticators( |
| releaseauth.NewChecksumAuthentication(reportedSHA, archiveLocation), |
| sigAuth, |
| ) |
| |
| return all.Authenticate() |
| } |
| |
| func (v BinaryManager) latestManifest(ctx context.Context) (*Release, error) { |
| manifestCacheLocation := path.Join(v.pluginDataDir, v.host.String(), "manifest.json") |
| |
| // Find the manifest cache for the hostname. |
| data, err := os.ReadFile(manifestCacheLocation) |
| modTime := time.Time{} |
| var localManifest *Release |
| if err != nil { |
| log.Printf("[TRACE] no %s manifest cache found for host %q", v.pluginName, v.host) |
| } else { |
| log.Printf("[TRACE] %s manifest cache found for host %q", v.pluginName, v.host) |
| |
| localManifest, err = decodeManifest(bytes.NewBuffer(data)) |
| modTime = localManifest.TimestampUpdated |
| if err != nil { |
| log.Printf("[WARN] failed to decode %s manifest cache %q: %s", v.pluginName, manifestCacheLocation, err) |
| } |
| } |
| |
| // Even though we may have a local manifest, always see if there is a newer remote manifest |
| result, err := v.client.FetchManifest(modTime) |
| // FetchManifest can return nil, nil (see below) |
| if err != nil { |
| return nil, fmt.Errorf("failed to fetch %s manifest: %w", v.pluginName, err) |
| } |
| |
| // No error and no remoteManifest means the existing manifest is not modified |
| // and it's safe to use the local manifest |
| if result == nil && localManifest != nil { |
| result = localManifest |
| } else { |
| data, err := json.Marshal(result) |
| if err != nil { |
| return nil, fmt.Errorf("failed to dump %s manifest to JSON: %w", v.pluginName, err) |
| } |
| |
| // Ensure target directory exists |
| if err := os.MkdirAll(filepath.Dir(manifestCacheLocation), 0755); err != nil { |
| return nil, fmt.Errorf("failed to create %s manifest cache directory: %w", v.pluginName, err) |
| } |
| |
| output, err := os.Create(manifestCacheLocation) |
| if err != nil { |
| return nil, fmt.Errorf("failed to create %s manifest cache: %w", v.pluginName, err) |
| } |
| |
| _, err = output.Write(data) |
| if err != nil { |
| return nil, fmt.Errorf("failed to write %s manifest cache: %w", v.pluginName, err) |
| } |
| log.Printf("[TRACE] wrote %s manifest cache to %q", v.pluginName, manifestCacheLocation) |
| } |
| |
| return result, nil |
| } |