github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/addrs/module_source_test.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package addrs
     5  
     6  import (
     7  	"testing"
     8  
     9  	"github.com/google/go-cmp/cmp"
    10  	svchost "github.com/hashicorp/terraform-svchost"
    11  )
    12  
    13  func TestParseModuleSource(t *testing.T) {
    14  	tests := map[string]struct {
    15  		input   string
    16  		want    ModuleSource
    17  		wantErr string
    18  	}{
    19  		// Local paths
    20  		"local in subdirectory": {
    21  			input: "./child",
    22  			want:  ModuleSourceLocal("./child"),
    23  		},
    24  		"local in subdirectory non-normalized": {
    25  			input: "./nope/../child",
    26  			want:  ModuleSourceLocal("./child"),
    27  		},
    28  		"local in sibling directory": {
    29  			input: "../sibling",
    30  			want:  ModuleSourceLocal("../sibling"),
    31  		},
    32  		"local in sibling directory non-normalized": {
    33  			input: "./nope/../../sibling",
    34  			want:  ModuleSourceLocal("../sibling"),
    35  		},
    36  		"Windows-style local in subdirectory": {
    37  			input: `.\child`,
    38  			want:  ModuleSourceLocal("./child"),
    39  		},
    40  		"Windows-style local in subdirectory non-normalized": {
    41  			input: `.\nope\..\child`,
    42  			want:  ModuleSourceLocal("./child"),
    43  		},
    44  		"Windows-style local in sibling directory": {
    45  			input: `..\sibling`,
    46  			want:  ModuleSourceLocal("../sibling"),
    47  		},
    48  		"Windows-style local in sibling directory non-normalized": {
    49  			input: `.\nope\..\..\sibling`,
    50  			want:  ModuleSourceLocal("../sibling"),
    51  		},
    52  		"an abominable mix of different slashes": {
    53  			input: `./nope\nope/why\./please\don't`,
    54  			want:  ModuleSourceLocal("./nope/nope/why/please/don't"),
    55  		},
    56  
    57  		// Registry addresses
    58  		// (NOTE: There is another test function TestParseModuleSourceRegistry
    59  		// which tests this situation more exhaustively, so this is just a
    60  		// token set of cases to see that we are indeed calling into the
    61  		// registry address parser when appropriate.)
    62  		"main registry implied": {
    63  			input: "hashicorp/subnets/cidr",
    64  			want: ModuleSourceRegistry{
    65  				Package: ModuleRegistryPackage{
    66  					Host:         svchost.Hostname("registry.terraform.io"),
    67  					Namespace:    "hashicorp",
    68  					Name:         "subnets",
    69  					TargetSystem: "cidr",
    70  				},
    71  				Subdir: "",
    72  			},
    73  		},
    74  		"main registry implied, subdir": {
    75  			input: "hashicorp/subnets/cidr//examples/foo",
    76  			want: ModuleSourceRegistry{
    77  				Package: ModuleRegistryPackage{
    78  					Host:         svchost.Hostname("registry.terraform.io"),
    79  					Namespace:    "hashicorp",
    80  					Name:         "subnets",
    81  					TargetSystem: "cidr",
    82  				},
    83  				Subdir: "examples/foo",
    84  			},
    85  		},
    86  		"main registry implied, escaping subdir": {
    87  			input: "hashicorp/subnets/cidr//../nope",
    88  			// NOTE: This error is actually being caught by the _remote package_
    89  			// address parser, because any registry parsing failure falls back
    90  			// to that but both of them have the same subdir validation. This
    91  			// case is here to make sure that stays true, so we keep reporting
    92  			// a suitable error when the user writes a registry-looking thing.
    93  			wantErr: `subdirectory path "../nope" leads outside of the module package`,
    94  		},
    95  		"custom registry": {
    96  			input: "example.com/awesomecorp/network/happycloud",
    97  			want: ModuleSourceRegistry{
    98  				Package: ModuleRegistryPackage{
    99  					Host:         svchost.Hostname("example.com"),
   100  					Namespace:    "awesomecorp",
   101  					Name:         "network",
   102  					TargetSystem: "happycloud",
   103  				},
   104  				Subdir: "",
   105  			},
   106  		},
   107  		"custom registry, subdir": {
   108  			input: "example.com/awesomecorp/network/happycloud//examples/foo",
   109  			want: ModuleSourceRegistry{
   110  				Package: ModuleRegistryPackage{
   111  					Host:         svchost.Hostname("example.com"),
   112  					Namespace:    "awesomecorp",
   113  					Name:         "network",
   114  					TargetSystem: "happycloud",
   115  				},
   116  				Subdir: "examples/foo",
   117  			},
   118  		},
   119  
   120  		// Remote package addresses
   121  		"github.com shorthand": {
   122  			input: "github.com/hashicorp/terraform-cidr-subnets",
   123  			want: ModuleSourceRemote{
   124  				Package: ModulePackage("git::https://github.com/hashicorp/terraform-cidr-subnets.git"),
   125  			},
   126  		},
   127  		"github.com shorthand, subdir": {
   128  			input: "github.com/hashicorp/terraform-cidr-subnets//example/foo",
   129  			want: ModuleSourceRemote{
   130  				Package: ModulePackage("git::https://github.com/hashicorp/terraform-cidr-subnets.git"),
   131  				Subdir:  "example/foo",
   132  			},
   133  		},
   134  		"git protocol, URL-style": {
   135  			input: "git://example.com/code/baz.git",
   136  			want: ModuleSourceRemote{
   137  				Package: ModulePackage("git://example.com/code/baz.git"),
   138  			},
   139  		},
   140  		"git protocol, URL-style, subdir": {
   141  			input: "git://example.com/code/baz.git//bleep/bloop",
   142  			want: ModuleSourceRemote{
   143  				Package: ModulePackage("git://example.com/code/baz.git"),
   144  				Subdir:  "bleep/bloop",
   145  			},
   146  		},
   147  		"git over HTTPS, URL-style": {
   148  			input: "git::https://example.com/code/baz.git",
   149  			want: ModuleSourceRemote{
   150  				Package: ModulePackage("git::https://example.com/code/baz.git"),
   151  			},
   152  		},
   153  		"git over HTTPS, URL-style, subdir": {
   154  			input: "git::https://example.com/code/baz.git//bleep/bloop",
   155  			want: ModuleSourceRemote{
   156  				Package: ModulePackage("git::https://example.com/code/baz.git"),
   157  				Subdir:  "bleep/bloop",
   158  			},
   159  		},
   160  		"git over HTTPS, URL-style, subdir, query parameters": {
   161  			input: "git::https://example.com/code/baz.git//bleep/bloop?otherthing=blah",
   162  			want: ModuleSourceRemote{
   163  				Package: ModulePackage("git::https://example.com/code/baz.git?otherthing=blah"),
   164  				Subdir:  "bleep/bloop",
   165  			},
   166  		},
   167  		"git over SSH, URL-style": {
   168  			input: "git::ssh://git@example.com/code/baz.git",
   169  			want: ModuleSourceRemote{
   170  				Package: ModulePackage("git::ssh://git@example.com/code/baz.git"),
   171  			},
   172  		},
   173  		"git over SSH, URL-style, subdir": {
   174  			input: "git::ssh://git@example.com/code/baz.git//bleep/bloop",
   175  			want: ModuleSourceRemote{
   176  				Package: ModulePackage("git::ssh://git@example.com/code/baz.git"),
   177  				Subdir:  "bleep/bloop",
   178  			},
   179  		},
   180  		"git over SSH, scp-style": {
   181  			input: "git::git@example.com:code/baz.git",
   182  			want: ModuleSourceRemote{
   183  				// Normalized to URL-style
   184  				Package: ModulePackage("git::ssh://git@example.com/code/baz.git"),
   185  			},
   186  		},
   187  		"git over SSH, scp-style, subdir": {
   188  			input: "git::git@example.com:code/baz.git//bleep/bloop",
   189  			want: ModuleSourceRemote{
   190  				// Normalized to URL-style
   191  				Package: ModulePackage("git::ssh://git@example.com/code/baz.git"),
   192  				Subdir:  "bleep/bloop",
   193  			},
   194  		},
   195  
   196  		// NOTE: We intentionally don't test the bitbucket.org shorthands
   197  		// here, because that detector makes direct HTTP tequests to the
   198  		// Bitbucket API and thus isn't appropriate for unit testing.
   199  
   200  		"Google Cloud Storage bucket implied, path prefix": {
   201  			input: "www.googleapis.com/storage/v1/BUCKET_NAME/PATH_TO_MODULE",
   202  			want: ModuleSourceRemote{
   203  				Package: ModulePackage("gcs::https://www.googleapis.com/storage/v1/BUCKET_NAME/PATH_TO_MODULE"),
   204  			},
   205  		},
   206  		"Google Cloud Storage bucket, path prefix": {
   207  			input: "gcs::https://www.googleapis.com/storage/v1/BUCKET_NAME/PATH_TO_MODULE",
   208  			want: ModuleSourceRemote{
   209  				Package: ModulePackage("gcs::https://www.googleapis.com/storage/v1/BUCKET_NAME/PATH_TO_MODULE"),
   210  			},
   211  		},
   212  		"Google Cloud Storage bucket implied, archive object": {
   213  			input: "www.googleapis.com/storage/v1/BUCKET_NAME/PATH/TO/module.zip",
   214  			want: ModuleSourceRemote{
   215  				Package: ModulePackage("gcs::https://www.googleapis.com/storage/v1/BUCKET_NAME/PATH/TO/module.zip"),
   216  			},
   217  		},
   218  		"Google Cloud Storage bucket, archive object": {
   219  			input: "gcs::https://www.googleapis.com/storage/v1/BUCKET_NAME/PATH/TO/module.zip",
   220  			want: ModuleSourceRemote{
   221  				Package: ModulePackage("gcs::https://www.googleapis.com/storage/v1/BUCKET_NAME/PATH/TO/module.zip"),
   222  			},
   223  		},
   224  
   225  		"Amazon S3 bucket implied, archive object": {
   226  			input: "s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip",
   227  			want: ModuleSourceRemote{
   228  				Package: ModulePackage("s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip"),
   229  			},
   230  		},
   231  		"Amazon S3 bucket, archive object": {
   232  			input: "s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip",
   233  			want: ModuleSourceRemote{
   234  				Package: ModulePackage("s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip"),
   235  			},
   236  		},
   237  
   238  		"HTTP URL": {
   239  			input: "http://example.com/module",
   240  			want: ModuleSourceRemote{
   241  				Package: ModulePackage("http://example.com/module"),
   242  			},
   243  		},
   244  		"HTTPS URL": {
   245  			input: "https://example.com/module",
   246  			want: ModuleSourceRemote{
   247  				Package: ModulePackage("https://example.com/module"),
   248  			},
   249  		},
   250  		"HTTPS URL, archive file": {
   251  			input: "https://example.com/module.zip",
   252  			want: ModuleSourceRemote{
   253  				Package: ModulePackage("https://example.com/module.zip"),
   254  			},
   255  		},
   256  		"HTTPS URL, forced archive file": {
   257  			input: "https://example.com/module?archive=tar",
   258  			want: ModuleSourceRemote{
   259  				Package: ModulePackage("https://example.com/module?archive=tar"),
   260  			},
   261  		},
   262  		"HTTPS URL, forced archive file and checksum": {
   263  			input: "https://example.com/module?archive=tar&checksum=blah",
   264  			want: ModuleSourceRemote{
   265  				// The query string only actually gets processed when we finally
   266  				// do the get, so "checksum=blah" is accepted as valid up
   267  				// at this parsing layer.
   268  				Package: ModulePackage("https://example.com/module?archive=tar&checksum=blah"),
   269  			},
   270  		},
   271  
   272  		"absolute filesystem path": {
   273  			// Although a local directory isn't really "remote", we do
   274  			// treat it as such because we still need to do all of the same
   275  			// high-level steps to work with these, even though "downloading"
   276  			// is replaced by a deep filesystem copy instead.
   277  			input: "/tmp/foo/example",
   278  			want: ModuleSourceRemote{
   279  				Package: ModulePackage("file:///tmp/foo/example"),
   280  			},
   281  		},
   282  		"absolute filesystem path, subdir": {
   283  			// This is a funny situation where the user wants to use a
   284  			// directory elsewhere on their system as a package containing
   285  			// multiple modules, but the entry point is not at the root
   286  			// of that subtree, and so they can use the usual subdir
   287  			// syntax to move the package root higher in the real filesystem.
   288  			input: "/tmp/foo//example",
   289  			want: ModuleSourceRemote{
   290  				Package: ModulePackage("file:///tmp/foo"),
   291  				Subdir:  "example",
   292  			},
   293  		},
   294  
   295  		"subdir escaping out of package": {
   296  			// This is general logic for all subdir regardless of installation
   297  			// protocol, but we're using a filesystem path here just as an
   298  			// easy placeholder/
   299  			input:   "/tmp/foo//example/../../invalid",
   300  			wantErr: `subdirectory path "../invalid" leads outside of the module package`,
   301  		},
   302  
   303  		"relative path without the needed prefix": {
   304  			input: "boop/bloop",
   305  			// For this case we return a generic error message from the addrs
   306  			// layer, but using a specialized error type which our module
   307  			// installer checks for and produces an extra hint for users who
   308  			// were intending to write a local path which then got
   309  			// misinterpreted as a remote source due to the missing prefix.
   310  			// However, the main message is generic here because this is really
   311  			// just a general "this string doesn't match any of our source
   312  			// address patterns" situation, not _necessarily_ about relative
   313  			// local paths.
   314  			wantErr: `Terraform cannot detect a supported external module source type for boop/bloop`,
   315  		},
   316  
   317  		"go-getter will accept all sorts of garbage": {
   318  			input: "dfgdfgsd:dgfhdfghdfghdfg/dfghdfghdfg",
   319  			want: ModuleSourceRemote{
   320  				// Unfortunately go-getter doesn't actually reject a totally
   321  				// invalid address like this until getting time, as long as
   322  				// it looks somewhat like a URL.
   323  				Package: ModulePackage("dfgdfgsd:dgfhdfghdfghdfg/dfghdfghdfg"),
   324  			},
   325  		},
   326  	}
   327  
   328  	for name, test := range tests {
   329  		t.Run(name, func(t *testing.T) {
   330  			addr, err := ParseModuleSource(test.input)
   331  
   332  			if test.wantErr != "" {
   333  				switch {
   334  				case err == nil:
   335  					t.Errorf("unexpected success\nwant error: %s", test.wantErr)
   336  				case err.Error() != test.wantErr:
   337  					t.Errorf("wrong error messages\ngot:  %s\nwant: %s", err.Error(), test.wantErr)
   338  				}
   339  				return
   340  			}
   341  
   342  			if err != nil {
   343  				t.Fatalf("unexpected error: %s", err.Error())
   344  			}
   345  
   346  			if diff := cmp.Diff(addr, test.want); diff != "" {
   347  				t.Errorf("wrong result\n%s", diff)
   348  			}
   349  		})
   350  	}
   351  
   352  }
   353  
   354  func TestModuleSourceRemoteFromRegistry(t *testing.T) {
   355  	t.Run("both have subdir", func(t *testing.T) {
   356  		remote := ModuleSourceRemote{
   357  			Package: ModulePackage("boop"),
   358  			Subdir:  "foo",
   359  		}
   360  		registry := ModuleSourceRegistry{
   361  			Subdir: "bar",
   362  		}
   363  		gotAddr := remote.FromRegistry(registry)
   364  		if remote.Subdir != "foo" {
   365  			t.Errorf("FromRegistry modified the reciever; should be pure function")
   366  		}
   367  		if registry.Subdir != "bar" {
   368  			t.Errorf("FromRegistry modified the given address; should be pure function")
   369  		}
   370  		if got, want := gotAddr.Subdir, "foo/bar"; got != want {
   371  			t.Errorf("wrong resolved subdir\ngot:  %s\nwant: %s", got, want)
   372  		}
   373  	})
   374  	t.Run("only remote has subdir", func(t *testing.T) {
   375  		remote := ModuleSourceRemote{
   376  			Package: ModulePackage("boop"),
   377  			Subdir:  "foo",
   378  		}
   379  		registry := ModuleSourceRegistry{
   380  			Subdir: "",
   381  		}
   382  		gotAddr := remote.FromRegistry(registry)
   383  		if remote.Subdir != "foo" {
   384  			t.Errorf("FromRegistry modified the reciever; should be pure function")
   385  		}
   386  		if registry.Subdir != "" {
   387  			t.Errorf("FromRegistry modified the given address; should be pure function")
   388  		}
   389  		if got, want := gotAddr.Subdir, "foo"; got != want {
   390  			t.Errorf("wrong resolved subdir\ngot:  %s\nwant: %s", got, want)
   391  		}
   392  	})
   393  	t.Run("only registry has subdir", func(t *testing.T) {
   394  		remote := ModuleSourceRemote{
   395  			Package: ModulePackage("boop"),
   396  			Subdir:  "",
   397  		}
   398  		registry := ModuleSourceRegistry{
   399  			Subdir: "bar",
   400  		}
   401  		gotAddr := remote.FromRegistry(registry)
   402  		if remote.Subdir != "" {
   403  			t.Errorf("FromRegistry modified the reciever; should be pure function")
   404  		}
   405  		if registry.Subdir != "bar" {
   406  			t.Errorf("FromRegistry modified the given address; should be pure function")
   407  		}
   408  		if got, want := gotAddr.Subdir, "bar"; got != want {
   409  			t.Errorf("wrong resolved subdir\ngot:  %s\nwant: %s", got, want)
   410  		}
   411  	})
   412  }
   413  
   414  func TestParseModuleSourceRemote(t *testing.T) {
   415  
   416  	tests := map[string]struct {
   417  		input          string
   418  		wantString     string
   419  		wantForDisplay string
   420  		wantErr        string
   421  	}{
   422  		"git over HTTPS, URL-style, query parameters": {
   423  			// Query parameters should be correctly appended after the Package
   424  			input:          `git::https://example.com/code/baz.git?otherthing=blah`,
   425  			wantString:     `git::https://example.com/code/baz.git?otherthing=blah`,
   426  			wantForDisplay: `git::https://example.com/code/baz.git?otherthing=blah`,
   427  		},
   428  		"git over HTTPS, URL-style, subdir, query parameters": {
   429  			// Query parameters should be correctly appended after the Package and Subdir
   430  			input:          `git::https://example.com/code/baz.git//bleep/bloop?otherthing=blah`,
   431  			wantString:     `git::https://example.com/code/baz.git//bleep/bloop?otherthing=blah`,
   432  			wantForDisplay: `git::https://example.com/code/baz.git//bleep/bloop?otherthing=blah`,
   433  		},
   434  	}
   435  
   436  	for name, test := range tests {
   437  		t.Run(name, func(t *testing.T) {
   438  			remote, err := parseModuleSourceRemote(test.input)
   439  
   440  			if test.wantErr != "" {
   441  				switch {
   442  				case err == nil:
   443  					t.Errorf("unexpected success\nwant error: %s", test.wantErr)
   444  				case err.Error() != test.wantErr:
   445  					t.Errorf("wrong error messages\ngot:  %s\nwant: %s", err.Error(), test.wantErr)
   446  				}
   447  				return
   448  			}
   449  
   450  			if err != nil {
   451  				t.Fatalf("unexpected error: %s", err.Error())
   452  			}
   453  
   454  			if got, want := remote.String(), test.wantString; got != want {
   455  				t.Errorf("wrong String() result\ngot:  %s\nwant: %s", got, want)
   456  			}
   457  			if got, want := remote.ForDisplay(), test.wantForDisplay; got != want {
   458  				t.Errorf("wrong ForDisplay() result\ngot:  %s\nwant: %s", got, want)
   459  			}
   460  		})
   461  	}
   462  }
   463  
   464  func TestParseModuleSourceRegistry(t *testing.T) {
   465  	// We test parseModuleSourceRegistry alone here, in addition to testing
   466  	// it indirectly as part of TestParseModuleSource, because general
   467  	// module parsing unfortunately eats all of the error situations from
   468  	// registry passing by falling back to trying for a direct remote package
   469  	// address.
   470  
   471  	// Historical note: These test cases were originally derived from the
   472  	// ones in the old internal/registry/regsrc package that the
   473  	// ModuleSourceRegistry type is replacing. That package had the notion
   474  	// of "normalized" addresses as separate from the original user input,
   475  	// but this new implementation doesn't try to preserve the original
   476  	// user input at all, and so the main string output is always normalized.
   477  	//
   478  	// That package also had some behaviors to turn the namespace, name, and
   479  	// remote system portions into lowercase, but apparently we didn't
   480  	// actually make use of that in the end and were preserving the case
   481  	// the user provided in the input, and so for backward compatibility
   482  	// we're continuing to do that here, at the expense of now making the
   483  	// "ForDisplay" output case-preserving where its predecessor in the
   484  	// old package wasn't. The main Terraform Registry at registry.terraform.io
   485  	// is itself case-insensitive anyway, so our case-preserving here is
   486  	// entirely for the benefit of existing third-party registry
   487  	// implementations that might be case-sensitive, which we must remain
   488  	// compatible with now.
   489  
   490  	tests := map[string]struct {
   491  		input           string
   492  		wantString      string
   493  		wantForDisplay  string
   494  		wantForProtocol string
   495  		wantErr         string
   496  	}{
   497  		"public registry": {
   498  			input:           `hashicorp/consul/aws`,
   499  			wantString:      `registry.terraform.io/hashicorp/consul/aws`,
   500  			wantForDisplay:  `hashicorp/consul/aws`,
   501  			wantForProtocol: `hashicorp/consul/aws`,
   502  		},
   503  		"public registry with subdir": {
   504  			input:           `hashicorp/consul/aws//foo`,
   505  			wantString:      `registry.terraform.io/hashicorp/consul/aws//foo`,
   506  			wantForDisplay:  `hashicorp/consul/aws//foo`,
   507  			wantForProtocol: `hashicorp/consul/aws`,
   508  		},
   509  		"public registry using explicit hostname": {
   510  			input:           `registry.terraform.io/hashicorp/consul/aws`,
   511  			wantString:      `registry.terraform.io/hashicorp/consul/aws`,
   512  			wantForDisplay:  `hashicorp/consul/aws`,
   513  			wantForProtocol: `hashicorp/consul/aws`,
   514  		},
   515  		"public registry with mixed case names": {
   516  			input:           `HashiCorp/Consul/aws`,
   517  			wantString:      `registry.terraform.io/HashiCorp/Consul/aws`,
   518  			wantForDisplay:  `HashiCorp/Consul/aws`,
   519  			wantForProtocol: `HashiCorp/Consul/aws`,
   520  		},
   521  		"private registry with non-standard port": {
   522  			input:           `Example.com:1234/HashiCorp/Consul/aws`,
   523  			wantString:      `example.com:1234/HashiCorp/Consul/aws`,
   524  			wantForDisplay:  `example.com:1234/HashiCorp/Consul/aws`,
   525  			wantForProtocol: `HashiCorp/Consul/aws`,
   526  		},
   527  		"private registry with IDN hostname": {
   528  			input:           `Испытание.com/HashiCorp/Consul/aws`,
   529  			wantString:      `испытание.com/HashiCorp/Consul/aws`,
   530  			wantForDisplay:  `испытание.com/HashiCorp/Consul/aws`,
   531  			wantForProtocol: `HashiCorp/Consul/aws`,
   532  		},
   533  		"private registry with IDN hostname and non-standard port": {
   534  			input:           `Испытание.com:1234/HashiCorp/Consul/aws//Foo`,
   535  			wantString:      `испытание.com:1234/HashiCorp/Consul/aws//Foo`,
   536  			wantForDisplay:  `испытание.com:1234/HashiCorp/Consul/aws//Foo`,
   537  			wantForProtocol: `HashiCorp/Consul/aws`,
   538  		},
   539  		"invalid hostname": {
   540  			input:   `---.com/HashiCorp/Consul/aws`,
   541  			wantErr: `invalid module registry hostname "---.com"; internationalized domain names must be given as direct unicode characters, not in punycode`,
   542  		},
   543  		"hostname with only one label": {
   544  			// This was historically forbidden in our initial implementation,
   545  			// so we keep it forbidden to avoid newly interpreting such
   546  			// addresses as registry addresses rather than remote source
   547  			// addresses.
   548  			input:   `foo/var/baz/qux`,
   549  			wantErr: `invalid module registry hostname: must contain at least one dot`,
   550  		},
   551  		"invalid target system characters": {
   552  			input:   `foo/var/no-no-no`,
   553  			wantErr: `invalid target system "no-no-no": must be between one and 64 ASCII letters or digits`,
   554  		},
   555  		"invalid target system length": {
   556  			input:   `foo/var/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaah`,
   557  			wantErr: `invalid target system "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaah": must be between one and 64 ASCII letters or digits`,
   558  		},
   559  		"invalid namespace": {
   560  			input:   `boop!/var/baz`,
   561  			wantErr: `invalid namespace "boop!": must be between one and 64 characters, including ASCII letters, digits, dashes, and underscores, where dashes and underscores may not be the prefix or suffix`,
   562  		},
   563  		"missing part with explicit hostname": {
   564  			input:   `foo.com/var/baz`,
   565  			wantErr: `source address must have three more components after the hostname: the namespace, the name, and the target system`,
   566  		},
   567  		"errant query string": {
   568  			input:   `foo/var/baz?otherthing`,
   569  			wantErr: `module registry addresses may not include a query string portion`,
   570  		},
   571  		"github.com": {
   572  			// We don't allow using github.com like a module registry because
   573  			// that conflicts with the historically-supported shorthand for
   574  			// installing directly from GitHub-hosted git repositories.
   575  			input:   `github.com/HashiCorp/Consul/aws`,
   576  			wantErr: `can't use "github.com" as a module registry host, because it's reserved for installing directly from version control repositories`,
   577  		},
   578  		"bitbucket.org": {
   579  			// We don't allow using bitbucket.org like a module registry because
   580  			// that conflicts with the historically-supported shorthand for
   581  			// installing directly from BitBucket-hosted git repositories.
   582  			input:   `bitbucket.org/HashiCorp/Consul/aws`,
   583  			wantErr: `can't use "bitbucket.org" as a module registry host, because it's reserved for installing directly from version control repositories`,
   584  		},
   585  		"local path from current dir": {
   586  			// Can't use a local path when we're specifically trying to parse
   587  			// a _registry_ source address.
   588  			input:   `./boop`,
   589  			wantErr: `can't use local directory "./boop" as a module registry address`,
   590  		},
   591  		"local path from parent dir": {
   592  			// Can't use a local path when we're specifically trying to parse
   593  			// a _registry_ source address.
   594  			input:   `../boop`,
   595  			wantErr: `can't use local directory "../boop" as a module registry address`,
   596  		},
   597  	}
   598  
   599  	for name, test := range tests {
   600  		t.Run(name, func(t *testing.T) {
   601  			addrI, err := ParseModuleSourceRegistry(test.input)
   602  
   603  			if test.wantErr != "" {
   604  				switch {
   605  				case err == nil:
   606  					t.Errorf("unexpected success\nwant error: %s", test.wantErr)
   607  				case err.Error() != test.wantErr:
   608  					t.Errorf("wrong error messages\ngot:  %s\nwant: %s", err.Error(), test.wantErr)
   609  				}
   610  				return
   611  			}
   612  
   613  			if err != nil {
   614  				t.Fatalf("unexpected error: %s", err.Error())
   615  			}
   616  
   617  			addr, ok := addrI.(ModuleSourceRegistry)
   618  			if !ok {
   619  				t.Fatalf("wrong address type %T; want %T", addrI, addr)
   620  			}
   621  
   622  			if got, want := addr.String(), test.wantString; got != want {
   623  				t.Errorf("wrong String() result\ngot:  %s\nwant: %s", got, want)
   624  			}
   625  			if got, want := addr.ForDisplay(), test.wantForDisplay; got != want {
   626  				t.Errorf("wrong ForDisplay() result\ngot:  %s\nwant: %s", got, want)
   627  			}
   628  			if got, want := addr.Package.ForRegistryProtocol(), test.wantForProtocol; got != want {
   629  				t.Errorf("wrong ForRegistryProtocol() result\ngot:  %s\nwant: %s", got, want)
   630  			}
   631  		})
   632  	}
   633  }