github.com/opentofu/opentofu@v1.7.1/internal/addrs/module_source_test.go (about)

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