github.com/hashicorp/packer@v1.14.3/hcl2template/addrs/plugin.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: BUSL-1.1
     3  
     4  package addrs
     5  
     6  import (
     7  	"fmt"
     8  	"net/url"
     9  	"path"
    10  	"strings"
    11  
    12  	"golang.org/x/net/idna"
    13  )
    14  
    15  // Plugin encapsulates a single plugin type.
    16  type Plugin struct {
    17  	Source string
    18  }
    19  
    20  // Parts returns the list of components of the source URL, starting with the
    21  // host, and ending with the name of the plugin.
    22  //
    23  // This will correspond more or less to the filesystem hierarchy where
    24  // the plugin is installed.
    25  func (p Plugin) Parts() []string {
    26  	return strings.FieldsFunc(p.Source, func(r rune) bool {
    27  		return r == '/'
    28  	})
    29  }
    30  
    31  // Name returns the raw name of the plugin from its source
    32  //
    33  // Exemples:
    34  //   - "github.com/hashicorp/amazon" -> "amazon"
    35  func (p Plugin) Name() string {
    36  	parts := p.Parts()
    37  	return parts[len(parts)-1]
    38  }
    39  
    40  func (p Plugin) String() string {
    41  	return p.Source
    42  }
    43  
    44  // ParsePluginPart processes an addrs.Plugin namespace or type string
    45  // provided by an end-user, producing a normalized version if possible or
    46  // an error if the string contains invalid characters.
    47  //
    48  // A plugin part is processed in the same way as an individual label in a DNS
    49  // domain name: it is transformed to lowercase per the usual DNS case mapping
    50  // and normalization rules and may contain only letters, digits, and dashes.
    51  // Additionally, dashes may not appear at the start or end of the string.
    52  //
    53  // These restrictions are intended to allow these names to appear in fussy
    54  // contexts such as directory/file names on case-insensitive filesystems,
    55  // repository names on GitHub, etc. We're using the DNS rules in particular,
    56  // rather than some similar rules defined locally, because the hostname part
    57  // of an addrs.Plugin is already a hostname and it's ideal to use exactly
    58  // the same case folding and normalization rules for all of the parts.
    59  //
    60  // It's valid to pass the result of this function as the argument to a
    61  // subsequent call, in which case the result will be identical.
    62  func ParsePluginPart(given string) (string, error) {
    63  	if len(given) == 0 {
    64  		return "", fmt.Errorf("must have at least one character")
    65  	}
    66  
    67  	// We're going to process the given name using the same "IDNA" library we
    68  	// use for the hostname portion, since it already implements the case
    69  	// folding rules we want.
    70  	//
    71  	// The idna library doesn't expose individual label parsing directly, but
    72  	// once we've verified it doesn't contain any dots we can just treat it
    73  	// like a top-level domain for this library's purposes.
    74  	if strings.ContainsRune(given, '.') {
    75  		return "", fmt.Errorf("dots are not allowed")
    76  	}
    77  
    78  	// We don't allow names containing multiple consecutive dashes, just as
    79  	// a matter of preference: they look confusing, or incorrect.
    80  	// This also, as a side-effect, prevents the use of the "punycode"
    81  	// indicator prefix "xn--" that would cause the IDNA library to interpret
    82  	// the given name as punycode, because that would be weird and unexpected.
    83  	if strings.Contains(given, "--") {
    84  		return "", fmt.Errorf("cannot use multiple consecutive dashes")
    85  	}
    86  
    87  	result, err := idna.Lookup.ToUnicode(given)
    88  	if err != nil {
    89  		return "", fmt.Errorf("must contain only letters, digits, and dashes, and may not use leading or trailing dashes: %w", err)
    90  	}
    91  
    92  	return result, nil
    93  }
    94  
    95  // IsPluginPartNormalized compares a given string to the result of ParsePluginPart(string)
    96  func IsPluginPartNormalized(str string) (bool, error) {
    97  	normalized, err := ParsePluginPart(str)
    98  	if err != nil {
    99  		return false, err
   100  	}
   101  	if str == normalized {
   102  		return true, nil
   103  	}
   104  	return false, nil
   105  }
   106  
   107  // ParsePluginSourceString parses the source attribute and returns a plugin.
   108  // This is intended primarily to parse the FQN-like strings
   109  //
   110  // The following are valid source string formats:
   111  //
   112  //	namespace/name
   113  //	hostname/namespace/name
   114  func ParsePluginSourceString(str string) (*Plugin, error) {
   115  	var errs []string
   116  
   117  	if strings.HasPrefix(str, "/") {
   118  		errs = append(errs, "A source URL must not start with a '/' character.")
   119  	}
   120  
   121  	if strings.HasSuffix(str, "/") {
   122  		errs = append(errs, "A source URL must not end with a '/' character.")
   123  	}
   124  
   125  	if strings.Count(str, "/") < 2 {
   126  		errs = append(errs, "A source URL must at least contain a host and a path with 2 components")
   127  	}
   128  
   129  	url, err := url.Parse(str)
   130  	if err != nil {
   131  		errs = append(errs, fmt.Sprintf("Failed to parse source URL: %s", err))
   132  	}
   133  
   134  	if url != nil && url.Scheme != "" {
   135  		errs = append(errs, "A source URL must not contain a scheme (e.g. https://).")
   136  	}
   137  
   138  	if url != nil && url.RawQuery != "" {
   139  		errs = append(errs, "A source URL must not contain a query (e.g. ?var=val)")
   140  	}
   141  
   142  	if url != nil && url.Fragment != "" {
   143  		errs = append(errs, "A source URL must not contain a fragment (e.g. #anchor).")
   144  	}
   145  
   146  	if errs != nil {
   147  		errsMsg := &strings.Builder{}
   148  		for _, err := range errs {
   149  			fmt.Fprintf(errsMsg, "* %s\n", err)
   150  		}
   151  
   152  		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)
   153  	}
   154  
   155  	// check the 'name' portion, which is always the last part
   156  	_, givenName := path.Split(str)
   157  	_, err = ParsePluginPart(givenName)
   158  	if err != nil {
   159  		return nil, fmt.Errorf(`Invalid plugin type %q in source: %s"`, givenName, err)
   160  	}
   161  
   162  	// Due to how plugin executables are named and plugin git repositories
   163  	// are conventionally named, it's a reasonable and
   164  	// apparently-somewhat-common user error to incorrectly use the
   165  	// "packer-plugin-" prefix in a plugin source address. There is
   166  	// no good reason for a plugin to have the prefix "packer-" anyway,
   167  	// so we've made that invalid from the start both so we can give feedback
   168  	// to plugin developers about the packer- prefix being redundant
   169  	// and give specialized feedback to folks who incorrectly use the full
   170  	// packer-plugin- prefix to help them self-correct.
   171  	const redundantPrefix = "packer-"
   172  	const userErrorPrefix = "packer-plugin-"
   173  	if strings.HasPrefix(givenName, redundantPrefix) {
   174  		if strings.HasPrefix(givenName, userErrorPrefix) {
   175  			// Likely user error. We only return this specialized error if
   176  			// whatever is after the prefix would otherwise be a
   177  			// syntactically-valid plugin type, so we don't end up advising
   178  			// the user to try something that would be invalid for another
   179  			// reason anyway.
   180  			// (This is mainly just for robustness, because the validation
   181  			// we already did above should've rejected most/all ways for
   182  			// the suggestedType to end up invalid here.)
   183  			suggestedType := strings.Replace(givenName, userErrorPrefix, "", -1)
   184  			if _, err := ParsePluginPart(suggestedType); err == nil {
   185  				return nil, fmt.Errorf("Plugin source has a type with the prefix %q, which isn't valid.\n"+
   186  					"Although that prefix is often used in the names of version control repositories "+
   187  					"for Packer plugins, plugin source strings should not include it.\n"+
   188  					"\nDid you mean %q?", userErrorPrefix, suggestedType)
   189  			}
   190  		}
   191  		// Otherwise, probably instead an incorrectly-named plugin, perhaps
   192  		// arising from a similar instinct to what causes there to be
   193  		// thousands of Python packages on PyPI with "python-"-prefixed
   194  		// names.
   195  		return nil, fmt.Errorf("Plugin source has a type with the %q prefix, which isn't valid.\n"+
   196  			"If you are the author of this plugin, rename it to not include the prefix.\n"+
   197  			"Ex: %q",
   198  			redundantPrefix,
   199  			strings.Replace(givenName, redundantPrefix, "", 1))
   200  	}
   201  
   202  	plug := &Plugin{
   203  		Source: str,
   204  	}
   205  	if len(plug.Parts()) > 16 {
   206  		return nil, fmt.Errorf("The source URL must have at most 16 components, and the one provided has %d.\n"+
   207  			"This is unsupported by Packer, please consider using a source that has less components to it.\n"+
   208  			"If this is a blocking issue for you, please open an issue to ask for supporting more "+
   209  			"components to the source URI.",
   210  			len(plug.Parts()))
   211  	}
   212  
   213  	return plug, nil
   214  }