You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
packer/hcl2template/addrs/plugin.go

215 lines
7.7 KiB

// Copyright IBM Corp. 2013, 2025
// SPDX-License-Identifier: BUSL-1.1
package addrs
import (
"fmt"
"net/url"
"path"
"strings"
"golang.org/x/net/idna"
)
// Plugin encapsulates a single plugin type.
type Plugin struct {
Source string
}
// Parts returns the list of components of the source URL, starting with the
// host, and ending with the name of the plugin.
//
// This will correspond more or less to the filesystem hierarchy where
// the plugin is installed.
func (p Plugin) Parts() []string {
return strings.FieldsFunc(p.Source, func(r rune) bool {
return r == '/'
})
}
// Name returns the raw name of the plugin from its source
//
// Exemples:
// - "github.com/hashicorp/amazon" -> "amazon"
func (p Plugin) Name() string {
parts := p.Parts()
return parts[len(parts)-1]
}
func (p Plugin) String() string {
return p.Source
}
// ParsePluginPart processes an addrs.Plugin namespace or type string
// provided by an end-user, producing a normalized version if possible or
// an error if the string contains invalid characters.
//
// A plugin part is processed in the same way as an individual label in a DNS
// domain name: it is transformed to lowercase per the usual DNS case mapping
// and normalization rules and may contain only letters, digits, and dashes.
// Additionally, dashes may not appear at the start or end of the string.
//
// These restrictions are intended to allow these names to appear in fussy
// contexts such as directory/file names on case-insensitive filesystems,
// repository names on GitHub, etc. We're using the DNS rules in particular,
// rather than some similar rules defined locally, because the hostname part
// of an addrs.Plugin is already a hostname and it's ideal to use exactly
// the same case folding and normalization rules for all of the parts.
//
// It's valid to pass the result of this function as the argument to a
// subsequent call, in which case the result will be identical.
func ParsePluginPart(given string) (string, error) {
if len(given) == 0 {
return "", fmt.Errorf("must have at least one character")
}
// We're going to process the given name using the same "IDNA" library we
// use for the hostname portion, since it already implements the case
// folding rules we want.
//
// The idna library doesn't expose individual label parsing directly, but
// once we've verified it doesn't contain any dots we can just treat it
// like a top-level domain for this library's purposes.
if strings.ContainsRune(given, '.') {
return "", fmt.Errorf("dots are not allowed")
}
// We don't allow names containing multiple consecutive dashes, just as
// a matter of preference: they look confusing, or incorrect.
// This also, as a side-effect, prevents the use of the "punycode"
// indicator prefix "xn--" that would cause the IDNA library to interpret
// the given name as punycode, because that would be weird and unexpected.
if strings.Contains(given, "--") {
return "", fmt.Errorf("cannot use multiple consecutive dashes")
}
result, err := idna.Lookup.ToUnicode(given)
if err != nil {
return "", fmt.Errorf("must contain only letters, digits, and dashes, and may not use leading or trailing dashes: %w", err)
}
return result, nil
}
// IsPluginPartNormalized compares a given string to the result of ParsePluginPart(string)
func IsPluginPartNormalized(str string) (bool, error) {
normalized, err := ParsePluginPart(str)
if err != nil {
return false, err
}
if str == normalized {
return true, nil
}
return false, nil
}
// ParsePluginSourceString parses the source attribute and returns a plugin.
// This is intended primarily to parse the FQN-like strings
//
// The following are valid source string formats:
//
// namespace/name
// hostname/namespace/name
func ParsePluginSourceString(str string) (*Plugin, error) {
var errs []string
if strings.HasPrefix(str, "/") {
errs = append(errs, "A source URL must not start with a '/' character.")
}
if strings.HasSuffix(str, "/") {
errs = append(errs, "A source URL must not end with a '/' character.")
}
if strings.Count(str, "/") < 2 {
errs = append(errs, "A source URL must at least contain a host and a path with 2 components")
}
url, err := url.Parse(str)
if err != nil {
errs = append(errs, fmt.Sprintf("Failed to parse source URL: %s", err))
}
if url != nil && url.Scheme != "" {
errs = append(errs, "A source URL must not contain a scheme (e.g. https://).")
}
if url != nil && url.RawQuery != "" {
errs = append(errs, "A source URL must not contain a query (e.g. ?var=val)")
}
if url != nil && url.Fragment != "" {
errs = append(errs, "A source URL must not contain a fragment (e.g. #anchor).")
}
if errs != nil {
errsMsg := &strings.Builder{}
for _, err := range errs {
fmt.Fprintf(errsMsg, "* %s\n", err)
}
return nil, fmt.Errorf("The provided source URL is invalid.\nThe following errors have been discovered:\n%s\nA valid source looks like \"github.com/hashicorp/happycloud\"", errsMsg)
}
// check the 'name' portion, which is always the last part
_, givenName := path.Split(str)
_, err = ParsePluginPart(givenName)
if err != nil {
return nil, fmt.Errorf(`Invalid plugin type %q in source: %s"`, givenName, err)
}
// Due to how plugin executables are named and plugin git repositories
// are conventionally named, it's a reasonable and
// apparently-somewhat-common user error to incorrectly use the
// "packer-plugin-" prefix in a plugin source address. There is
// no good reason for a plugin to have the prefix "packer-" anyway,
// so we've made that invalid from the start both so we can give feedback
// to plugin developers about the packer- prefix being redundant
// and give specialized feedback to folks who incorrectly use the full
// packer-plugin- prefix to help them self-correct.
const redundantPrefix = "packer-"
const userErrorPrefix = "packer-plugin-"
if strings.HasPrefix(givenName, redundantPrefix) {
if strings.HasPrefix(givenName, userErrorPrefix) {
// Likely user error. We only return this specialized error if
// whatever is after the prefix would otherwise be a
// syntactically-valid plugin type, so we don't end up advising
// the user to try something that would be invalid for another
// reason anyway.
// (This is mainly just for robustness, because the validation
// we already did above should've rejected most/all ways for
// the suggestedType to end up invalid here.)
suggestedType := strings.Replace(givenName, userErrorPrefix, "", -1)
if _, err := ParsePluginPart(suggestedType); err == nil {
return nil, fmt.Errorf("Plugin source has a type with the prefix %q, which isn't valid.\n"+
"Although that prefix is often used in the names of version control repositories "+
"for Packer plugins, plugin source strings should not include it.\n"+
"\nDid you mean %q?", userErrorPrefix, suggestedType)
}
}
// Otherwise, probably instead an incorrectly-named plugin, perhaps
// arising from a similar instinct to what causes there to be
// thousands of Python packages on PyPI with "python-"-prefixed
// names.
return nil, fmt.Errorf("Plugin source has a type with the %q prefix, which isn't valid.\n"+
"If you are the author of this plugin, rename it to not include the prefix.\n"+
"Ex: %q",
redundantPrefix,
strings.Replace(givenName, redundantPrefix, "", 1))
}
plug := &Plugin{
Source: str,
}
if len(plug.Parts()) > 16 {
return nil, fmt.Errorf("The source URL must have at most 16 components, and the one provided has %d.\n"+
"This is unsupported by Packer, please consider using a source that has less components to it.\n"+
"If this is a blocking issue for you, please open an issue to ask for supporting more "+
"components to the source URI.",
len(plug.Parts()))
}
return plug, nil
}