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 }