// Copyright (c) HashiCorp, Inc. // 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 }