diff --git a/.circleci/config.yml b/.circleci/config.yml index 7c1f40c..451f1df 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -28,22 +28,22 @@ commands: steps: - run: mkdir ~/goroot && curl https://dl.google.com/go/go<< parameters.GOVERSION >>.windows-amd64.zip --output ~/go<< parameters.GOVERSION >>.windows-amd64.zip - run: unzip ~/go<< parameters.GOVERSION >>.windows-amd64.zip -d ~/goroot - go-test: + go-tests: parameters: - FOLDER: - type: string GO: type: string default: go steps: - - run: cd << parameters.FOLDER >> && << parameters.GO >> env && << parameters.GO >> test . - + - run: << parameters.GO >> test ./... + jobs: test-linux: executor: golang working_directory: /go/src/github.com/hashicorp/go-cty-funcs steps: - checkout + - go-tests + test-darwin: executor: darwin working_directory: ~/go/src/github.com/hashicorp/go-cty-funcs @@ -51,6 +51,8 @@ jobs: - checkout - install-go-unix: GOOS: darwin + - go-tests: + GO: ~/goroot/go/bin/go test-windows: executor: name: win/vs2019 @@ -59,6 +61,8 @@ jobs: steps: - checkout - install-go-windows + - go-tests: + GO: ~/goroot/go/bin/go check-fmt: executor: golang steps: diff --git a/.test.sh b/.test.sh new file mode 100755 index 0000000..93dbd04 --- /dev/null +++ b/.test.sh @@ -0,0 +1,13 @@ +set -e +clear +go fmt ./... +goimports -w */*.go +for f in */ +do + pushd $f > /dev/null + go mod tidy + go test . + popd > /dev/null +done + +git diff --exit-code --ignore-space-change --ignore-space-at-eol diff --git a/cidr/host.go b/cidr/host.go new file mode 100644 index 0000000..fb7a9bc --- /dev/null +++ b/cidr/host.go @@ -0,0 +1,49 @@ +package cidr + +import ( + "fmt" + "net" + + "github.com/apparentlymart/go-cidr/cidr" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" + "github.com/zclconf/go-cty/cty/gocty" +) + +// HostFunc is a function that calculates a full host IP address within a given +// IP network address prefix. +var HostFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "prefix", + Type: cty.String, + }, + { + Name: "hostnum", + Type: cty.Number, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + var hostNum int + if err := gocty.FromCtyValue(args[1], &hostNum); err != nil { + return cty.UnknownVal(cty.String), err + } + _, network, err := net.ParseCIDR(args[0].AsString()) + if err != nil { + return cty.UnknownVal(cty.String), fmt.Errorf("invalid CIDR expression: %s", err) + } + + ip, err := cidr.Host(network, hostNum) + if err != nil { + return cty.UnknownVal(cty.String), err + } + + return cty.StringVal(ip.String()), nil + }, +}) + +// Host calculates a full host IP address within a given IP network address prefix. +func Host(prefix, hostnum cty.Value) (cty.Value, error) { + return HostFunc.Call([]cty.Value{prefix, hostnum}) +} diff --git a/cidr/host_test.go b/cidr/host_test.go new file mode 100644 index 0000000..57d60e9 --- /dev/null +++ b/cidr/host_test.go @@ -0,0 +1,79 @@ +package cidr + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestHost(t *testing.T) { + tests := []struct { + Prefix cty.Value + Hostnum cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("192.168.1.0/24"), + cty.NumberIntVal(5), + cty.StringVal("192.168.1.5"), + false, + }, + { + cty.StringVal("192.168.1.0/24"), + cty.NumberIntVal(-5), + cty.StringVal("192.168.1.251"), + false, + }, + { + cty.StringVal("192.168.1.0/24"), + cty.NumberIntVal(-256), + cty.StringVal("192.168.1.0"), + false, + }, + { + cty.StringVal("192.168.1.0/30"), + cty.NumberIntVal(255), + cty.UnknownVal(cty.String), + true, // 255 doesn't fit in two bits + }, + { + cty.StringVal("192.168.1.0/30"), + cty.NumberIntVal(-255), + cty.UnknownVal(cty.String), + true, // 255 doesn't fit in two bits + }, + { + cty.StringVal("not-a-cidr"), + cty.NumberIntVal(6), + cty.UnknownVal(cty.String), + true, // not a valid CIDR mask + }, + { + cty.StringVal("10.256.0.0/8"), + cty.NumberIntVal(6), + cty.UnknownVal(cty.String), + true, // can't have an octet >255 + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("cidrhost(%#v, %#v)", test.Prefix, test.Hostnum), func(t *testing.T) { + got, err := Host(test.Prefix, test.Hostnum) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} diff --git a/cidr/netmask.go b/cidr/netmask.go new file mode 100644 index 0000000..918814d --- /dev/null +++ b/cidr/netmask.go @@ -0,0 +1,34 @@ +package cidr + +import ( + "fmt" + "net" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +// NetmaskFunc is a function that converts an IPv4 address prefix given in CIDR +// notation into a subnet mask address. +var NetmaskFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "prefix", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + _, network, err := net.ParseCIDR(args[0].AsString()) + if err != nil { + return cty.UnknownVal(cty.String), fmt.Errorf("invalid CIDR expression: %s", err) + } + + return cty.StringVal(net.IP(network.Mask).String()), nil + }, +}) + +// Netmask converts an IPv4 address prefix given in CIDR notation into a subnet mask address. +func Netmask(prefix cty.Value) (cty.Value, error) { + return NetmaskFunc.Call([]cty.Value{prefix}) +} diff --git a/cidr/netmask_test.go b/cidr/netmask_test.go new file mode 100644 index 0000000..07f6756 --- /dev/null +++ b/cidr/netmask_test.go @@ -0,0 +1,66 @@ +package cidr + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestNetmask(t *testing.T) { + tests := []struct { + Prefix cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("192.168.1.0/24"), + cty.StringVal("255.255.255.0"), + false, + }, + { + cty.StringVal("192.168.1.0/32"), + cty.StringVal("255.255.255.255"), + false, + }, + { + cty.StringVal("0.0.0.0/0"), + cty.StringVal("0.0.0.0"), + false, + }, + { + cty.StringVal("1::/64"), + cty.StringVal("ffff:ffff:ffff:ffff::"), + false, + }, + { + cty.StringVal("not-a-cidr"), + cty.UnknownVal(cty.String), + true, // not a valid CIDR mask + }, + { + cty.StringVal("110.256.0.0/8"), + cty.UnknownVal(cty.String), + true, // can't have an octet >255 + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("cidrnetmask(%#v)", test.Prefix), func(t *testing.T) { + got, err := Netmask(test.Prefix) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} diff --git a/cidr/subnet.go b/cidr/subnet.go new file mode 100644 index 0000000..ffcb24d --- /dev/null +++ b/cidr/subnet.go @@ -0,0 +1,66 @@ +package cidr + +import ( + "fmt" + "net" + + "github.com/apparentlymart/go-cidr/cidr" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" + "github.com/zclconf/go-cty/cty/gocty" +) + +// SubnetFunc is a function that calculates a subnet address within a given +// IP network address prefix. +var SubnetFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "prefix", + Type: cty.String, + }, + { + Name: "newbits", + Type: cty.Number, + }, + { + Name: "netnum", + Type: cty.Number, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + var newbits int + if err := gocty.FromCtyValue(args[1], &newbits); err != nil { + return cty.UnknownVal(cty.String), err + } + var netnum int + if err := gocty.FromCtyValue(args[2], &netnum); err != nil { + return cty.UnknownVal(cty.String), err + } + + _, network, err := net.ParseCIDR(args[0].AsString()) + if err != nil { + return cty.UnknownVal(cty.String), fmt.Errorf("invalid CIDR expression: %s", err) + } + + // For portability with 32-bit systems where the subnet number will be + // a 32-bit int, we only allow extension of 32 bits in one call even if + // we're running on a 64-bit machine. (Of course, this is significant + // only for IPv6.) + if newbits > 32 { + return cty.UnknownVal(cty.String), fmt.Errorf("may not extend prefix by more than 32 bits") + } + + newNetwork, err := cidr.Subnet(network, newbits, netnum) + if err != nil { + return cty.UnknownVal(cty.String), err + } + + return cty.StringVal(newNetwork.String()), nil + }, +}) + +// Subnet calculates a subnet address within a given IP network address prefix. +func Subnet(prefix, newbits, netnum cty.Value) (cty.Value, error) { + return SubnetFunc.Call([]cty.Value{prefix, newbits, netnum}) +} diff --git a/cidr/subnet_test.go b/cidr/subnet_test.go new file mode 100644 index 0000000..1435bdb --- /dev/null +++ b/cidr/subnet_test.go @@ -0,0 +1,87 @@ +package cidr + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestSubnet(t *testing.T) { + tests := []struct { + Prefix cty.Value + Newbits cty.Value + Netnum cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("192.168.2.0/20"), + cty.NumberIntVal(4), + cty.NumberIntVal(6), + cty.StringVal("192.168.6.0/24"), + false, + }, + { + cty.StringVal("fe80::/48"), + cty.NumberIntVal(16), + cty.NumberIntVal(6), + cty.StringVal("fe80:0:0:6::/64"), + false, + }, + { // IPv4 address encoded in IPv6 syntax gets normalized + cty.StringVal("::ffff:192.168.0.0/112"), + cty.NumberIntVal(8), + cty.NumberIntVal(6), + cty.StringVal("192.168.6.0/24"), + false, + }, + { // not enough bits left + cty.StringVal("192.168.0.0/30"), + cty.NumberIntVal(4), + cty.NumberIntVal(6), + cty.UnknownVal(cty.String), + true, + }, + { // can't encode 16 in 2 bits + cty.StringVal("192.168.0.0/168"), + cty.NumberIntVal(2), + cty.NumberIntVal(16), + cty.StringVal("fe80:0:0:6::/64"), + true, + }, + { // not a valid CIDR mask + cty.StringVal("not-a-cidr"), + cty.NumberIntVal(4), + cty.NumberIntVal(6), + cty.StringVal("fe80:0:0:6::/64"), + true, + }, + { // can't have an octet >255 + cty.StringVal("10.256.0.0/8"), + cty.NumberIntVal(4), + cty.NumberIntVal(6), + cty.StringVal("fe80:0:0:6::/64"), + true, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("cidrsubnet(%#v, %#v, %#v)", test.Prefix, test.Newbits, test.Netnum), func(t *testing.T) { + got, err := Subnet(test.Prefix, test.Newbits, test.Netnum) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} diff --git a/cidr/subnets.go b/cidr/subnets.go new file mode 100644 index 0000000..e70e015 --- /dev/null +++ b/cidr/subnets.go @@ -0,0 +1,99 @@ +package cidr + +import ( + "net" + + "github.com/apparentlymart/go-cidr/cidr" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" + "github.com/zclconf/go-cty/cty/gocty" +) + +// SubnetsFunc is similar to SubnetFunc but calculates many consecutive subnet +// addresses at once, rather than just a single subnet extension. +var SubnetsFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "prefix", + Type: cty.String, + }, + }, + VarParam: &function.Parameter{ + Name: "newbits", + Type: cty.Number, + }, + Type: function.StaticReturnType(cty.List(cty.String)), + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + _, network, err := net.ParseCIDR(args[0].AsString()) + if err != nil { + return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "invalid CIDR expression: %s", err) + } + startPrefixLen, _ := network.Mask.Size() + + prefixLengthArgs := args[1:] + if len(prefixLengthArgs) == 0 { + return cty.ListValEmpty(cty.String), nil + } + + var firstLength int + if err := gocty.FromCtyValue(prefixLengthArgs[0], &firstLength); err != nil { + return cty.UnknownVal(cty.String), function.NewArgError(1, err) + } + firstLength += startPrefixLen + + retVals := make([]cty.Value, len(prefixLengthArgs)) + + current, _ := cidr.PreviousSubnet(network, firstLength) + for i, lengthArg := range prefixLengthArgs { + var length int + if err := gocty.FromCtyValue(lengthArg, &length); err != nil { + return cty.UnknownVal(cty.String), function.NewArgError(i+1, err) + } + + if length < 1 { + return cty.UnknownVal(cty.String), function.NewArgErrorf(i+1, "must extend prefix by at least one bit") + } + // For portability with 32-bit systems where the subnet number + // will be a 32-bit int, we only allow extension of 32 bits in + // one call even if we're running on a 64-bit machine. + // (Of course, this is significant only for IPv6.) + if length > 32 { + return cty.UnknownVal(cty.String), function.NewArgErrorf(i+1, "may not extend prefix by more than 32 bits") + } + length += startPrefixLen + if length > (len(network.IP) * 8) { + protocol := "IP" + switch len(network.IP) * 8 { + case 32: + protocol = "IPv4" + case 128: + protocol = "IPv6" + } + return cty.UnknownVal(cty.String), function.NewArgErrorf(i+1, "would extend prefix to %d bits, which is too long for an %s address", length, protocol) + } + + next, rollover := cidr.NextSubnet(current, length) + if rollover || !network.Contains(next.IP) { + // If we run out of suffix bits in the base CIDR prefix then + // NextSubnet will start incrementing the prefix bits, which + // we don't allow because it would then allocate addresses + // outside of the caller's given prefix. + return cty.UnknownVal(cty.String), function.NewArgErrorf(i+1, "not enough remaining address space for a subnet with a prefix of %d bits after %s", length, current.String()) + } + + current = next + retVals[i] = cty.StringVal(current.String()) + } + + return cty.ListVal(retVals), nil + }, +}) + +// Subnets calculates a sequence of consecutive subnet prefixes that may be of +// different prefix lengths under a common base prefix. +func Subnets(prefix cty.Value, newbits ...cty.Value) (cty.Value, error) { + args := make([]cty.Value, len(newbits)+1) + args[0] = prefix + copy(args[1:], newbits) + return SubnetsFunc.Call(args) +} diff --git a/cidr/subnets_test.go b/cidr/subnets_test.go new file mode 100644 index 0000000..7a29b57 --- /dev/null +++ b/cidr/subnets_test.go @@ -0,0 +1,113 @@ +package cidr + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestSubnets(t *testing.T) { + tests := []struct { + Prefix cty.Value + Newbits []cty.Value + Want cty.Value + Err string + }{ + { + cty.StringVal("10.0.0.0/21"), + []cty.Value{ + cty.NumberIntVal(3), + cty.NumberIntVal(3), + cty.NumberIntVal(3), + cty.NumberIntVal(4), + cty.NumberIntVal(4), + cty.NumberIntVal(4), + cty.NumberIntVal(7), + cty.NumberIntVal(7), + cty.NumberIntVal(7), + }, + cty.ListVal([]cty.Value{ + cty.StringVal("10.0.0.0/24"), + cty.StringVal("10.0.1.0/24"), + cty.StringVal("10.0.2.0/24"), + cty.StringVal("10.0.3.0/25"), + cty.StringVal("10.0.3.128/25"), + cty.StringVal("10.0.4.0/25"), + cty.StringVal("10.0.4.128/28"), + cty.StringVal("10.0.4.144/28"), + cty.StringVal("10.0.4.160/28"), + }), + ``, + }, + { + cty.StringVal("10.0.0.0/30"), + []cty.Value{ + cty.NumberIntVal(1), + cty.NumberIntVal(3), + }, + cty.UnknownVal(cty.List(cty.String)), + `would extend prefix to 33 bits, which is too long for an IPv4 address`, + }, + { + cty.StringVal("10.0.0.0/8"), + []cty.Value{ + cty.NumberIntVal(1), + cty.NumberIntVal(1), + cty.NumberIntVal(1), + }, + cty.UnknownVal(cty.List(cty.String)), + `not enough remaining address space for a subnet with a prefix of 9 bits after 10.128.0.0/9`, + }, + { + cty.StringVal("10.0.0.0/8"), + []cty.Value{ + cty.NumberIntVal(1), + cty.NumberIntVal(0), + }, + cty.UnknownVal(cty.List(cty.String)), + `must extend prefix by at least one bit`, + }, + { + cty.StringVal("10.0.0.0/8"), + []cty.Value{ + cty.NumberIntVal(1), + cty.NumberIntVal(-1), + }, + cty.UnknownVal(cty.List(cty.String)), + `must extend prefix by at least one bit`, + }, + { + cty.StringVal("fe80::/48"), + []cty.Value{ + cty.NumberIntVal(1), + cty.NumberIntVal(33), + }, + cty.UnknownVal(cty.List(cty.String)), + `may not extend prefix by more than 32 bits`, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("cidrsubnets(%#v, %#v)", test.Prefix, test.Newbits), func(t *testing.T) { + got, err := Subnets(test.Prefix, test.Newbits...) + wantErr := test.Err != "" + + if wantErr { + if err == nil { + t.Fatal("succeeded; want error") + } + if err.Error() != test.Err { + t.Fatalf("wrong error\ngot: %s\nwant: %s", err.Error(), test.Err) + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} diff --git a/crypto/bcrypt.go b/crypto/bcrypt.go new file mode 100644 index 0000000..4f70df5 --- /dev/null +++ b/crypto/bcrypt.go @@ -0,0 +1,59 @@ +package crypto + +import ( + "fmt" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" + "github.com/zclconf/go-cty/cty/gocty" + "golang.org/x/crypto/bcrypt" +) + +// BcryptFunc is a function that computes a hash of the given string using the +// Blowfish cipher. +var BcryptFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "str", + Type: cty.String, + }, + }, + VarParam: &function.Parameter{ + Name: "cost", + Type: cty.Number, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + defaultCost := 10 + + if len(args) > 1 { + var val int + if err := gocty.FromCtyValue(args[1], &val); err != nil { + return cty.UnknownVal(cty.String), err + } + defaultCost = val + } + + if len(args) > 2 { + return cty.UnknownVal(cty.String), fmt.Errorf("bcrypt() takes no more than two arguments") + } + + input := args[0].AsString() + out, err := bcrypt.GenerateFromPassword([]byte(input), defaultCost) + if err != nil { + return cty.UnknownVal(cty.String), fmt.Errorf("error occured generating password %s", err.Error()) + } + + return cty.StringVal(string(out)), nil + }, +}) + +// Bcrypt computes a hash of the given string using the Blowfish cipher, +// returning a string in the Modular Crypt Format usually expected in the +// shadow password file on many Unix systems. +func Bcrypt(str cty.Value, cost ...cty.Value) (cty.Value, error) { + args := make([]cty.Value, len(cost)+1) + args[0] = str + copy(args[1:], cost) + return BcryptFunc.Call(args) +} diff --git a/crypto/bcrypt_test.go b/crypto/bcrypt_test.go new file mode 100644 index 0000000..e651794 --- /dev/null +++ b/crypto/bcrypt_test.go @@ -0,0 +1,38 @@ +package crypto + +import ( + "testing" + + "github.com/zclconf/go-cty/cty" + "golang.org/x/crypto/bcrypt" +) + +func TestBcrypt(t *testing.T) { + // single variable test + p, err := Bcrypt(cty.StringVal("test")) + if err != nil { + t.Fatalf("err: %s", err) + } + + err = bcrypt.CompareHashAndPassword([]byte(p.AsString()), []byte("test")) + if err != nil { + t.Fatalf("Error comparing hash and password: %s", err) + } + + // testing with two parameters + p, err = Bcrypt(cty.StringVal("test"), cty.NumberIntVal(5)) + if err != nil { + t.Fatalf("err: %s", err) + } + + err = bcrypt.CompareHashAndPassword([]byte(p.AsString()), []byte("test")) + if err != nil { + t.Fatalf("Error comparing hash and password: %s", err) + } + + // Negative test for more than two parameters + _, err = Bcrypt(cty.StringVal("test"), cty.NumberIntVal(10), cty.NumberIntVal(11)) + if err == nil { + t.Fatal("succeeded; want error") + } +} diff --git a/crypto/hash.go b/crypto/hash.go new file mode 100644 index 0000000..232d210 --- /dev/null +++ b/crypto/hash.go @@ -0,0 +1,27 @@ +package crypto + +import ( + "hash" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +func makeStringHashFunction(hf func() hash.Hash, enc func([]byte) string) function.Function { + return function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "str", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + s := args[0].AsString() + h := hf() + h.Write([]byte(s)) + rv := enc(h.Sum(nil)) + return cty.StringVal(rv), nil + }, + }) +} diff --git a/crypto/hash_test.go b/crypto/hash_test.go new file mode 100644 index 0000000..7a00d18 --- /dev/null +++ b/crypto/hash_test.go @@ -0,0 +1,63 @@ +package crypto + +const ( + CipherBase64 = "eczGaDhXDbOFRZGhjx2etVzWbRqWDlmq0bvNt284JHVbwCgObiuyX9uV0LSAMY707IEgMkExJqXmsB4OWKxvB7epRB9G/3+F+pcrQpODlDuL9oDUAsa65zEpYF0Wbn7Oh7nrMQncyUPpyr9WUlALl0gRWytOA23S+y5joa4M34KFpawFgoqTu/2EEH4Xl1zo+0fy73fEto+nfkUY+meuyGZ1nUx/+DljP7ZqxHBFSlLODmtuTMdswUbHbXbWneW51D7Jm7xB8nSdiA2JQNK5+Sg5x8aNfgvFTt/m2w2+qpsyFa5Wjeu6fZmXSl840CA07aXbk9vN4I81WmJyblD/ZA==" + PrivateKey = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAgUElV5mwqkloIrM8ZNZ72gSCcnSJt7+/Usa5G+D15YQUAdf9 +c1zEekTfHgDP+04nw/uFNFaE5v1RbHaPxhZYVg5ZErNCa/hzn+x10xzcepeS3KPV +Xcxae4MR0BEegvqZqJzN9loXsNL/c3H/B+2Gle3hTxjlWFb3F5qLgR+4Mf4ruhER +1v6eHQa/nchi03MBpT4UeJ7MrL92hTJYLdpSyCqmr8yjxkKJDVC2uRrr+sTSxfh7 +r6v24u/vp/QTmBIAlNPgadVAZw17iNNb7vjV7Gwl/5gHXonCUKURaV++dBNLrHIZ +pqcAM8wHRph8mD1EfL9hsz77pHewxolBATV+7QIDAQABAoIBAC1rK+kFW3vrAYm3 ++8/fQnQQw5nec4o6+crng6JVQXLeH32qXShNf8kLLG/Jj0vaYcTPPDZw9JCKkTMQ +0mKj9XR/5DLbBMsV6eNXXuvJJ3x4iKW5eD9WkLD4FKlNarBRyO7j8sfPTqXW7uat +NxWdFH7YsSRvNh/9pyQHLWA5OituidMrYbc3EUx8B1GPNyJ9W8Q8znNYLfwYOjU4 +Wv1SLE6qGQQH9Q0WzA2WUf8jklCYyMYTIywAjGb8kbAJlKhmj2t2Igjmqtwt1PYc +pGlqbtQBDUiWXt5S4YX/1maIQ/49yeNUajjpbJiH3DbhJbHwFTzP3pZ9P9GHOzlG +kYR+wSECgYEAw/Xida8kSv8n86V3qSY/I+fYQ5V+jDtXIE+JhRnS8xzbOzz3v0WS +Oo5H+o4nJx5eL3Ghb3Gcm0Jn46dHrxinHbm+3RjXv/X6tlbxIYjRSQfHOTSMCTvd +qcliF5vC6RCLXuc7R+IWR1Ky6eDEZGtrvt3DyeYABsp9fRUFR/6NluUCgYEAqNsw +1aSl7WJa27F0DoJdlU9LWerpXcazlJcIdOz/S9QDmSK3RDQTdqfTxRmrxiYI9LEs +mkOkvzlnnOBMpnZ3ZOU5qIRfprecRIi37KDAOHWGnlC0EWGgl46YLb7/jXiWf0AG +Y+DfJJNd9i6TbIDWu8254/erAS6bKMhW/3q7f2kCgYAZ7Id/BiKJAWRpqTRBXlvw +BhXoKvjI2HjYP21z/EyZ+PFPzur/lNaZhIUlMnUfibbwE9pFggQzzf8scM7c7Sf+ +mLoVSdoQ/Rujz7CqvQzi2nKSsM7t0curUIb3lJWee5/UeEaxZcmIufoNUrzohAWH +BJOIPDM4ssUTLRq7wYM9uQKBgHCBau5OP8gE6mjKuXsZXWUoahpFLKwwwmJUp2vQ +pOFPJ/6WZOlqkTVT6QPAcPUbTohKrF80hsZqZyDdSfT3peFx4ZLocBrS56m6NmHR +UYHMvJ8rQm76T1fryHVidz85g3zRmfBeWg8yqT5oFg4LYgfLsPm1gRjOhs8LfPvI +OLlRAoGBAIZ5Uv4Z3s8O7WKXXUe/lq6j7vfiVkR1NW/Z/WLKXZpnmvJ7FgxN4e56 +RXT7GwNQHIY8eDjDnsHxzrxd+raOxOZeKcMHj3XyjCX3NHfTscnsBPAGYpY/Wxzh +T8UYnFu6RzkixElTf2rseEav7rkdKkI3LAeIZy7B0HulKKsmqVQ7 +-----END RSA PRIVATE KEY----- +` + WrongPrivateKey = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAlrCgnEVgmNKCq7KPc+zUU5IrxPu1ClMNJS7RTsTPEkbwe5SB +p+6V6WtCbD/X/lDRRGbOENChh1Phulb7lViqgrdpHydgsrKoS5ah3DfSIxLFLE00 +9Yo4TCYwgw6+s59j16ZAFVinaQ9l6Kmrb2ll136hMrz8QKh+qw+onOLd38WFgm+W +ZtUqSXf2LANzfzzy4OWFNyFqKaCAolSkPdTS9Nz+svtScvp002DQp8OdP1AgPO+l +o5N3M38Fftapwg0pCtJ5Zq0NRWIXEonXiTEMA6zy3gEZVOmDxoIFUWnmrqlMJLFy +5S6LDrHSdqJhCxDK6WRZj43X9j8spktk3eGhMwIDAQABAoIBAAem8ID/BOi9x+Tw +LFi2rhGQWqimH4tmrEQ3HGnjlKBY+d1MrUjZ1MMFr1nP5CgF8pqGnfA8p/c3Sz8r +K5tp5T6+EZiDZ2WrrOApxg5ox0MAsQKO6SGO40z6o3wEQ6rbbTaGOrraxaWQIpyu +AQanU4Sd6ZGqByVBaS1GnklZO+shCHqw73b7g1cpLEmFzcYnKHYHlUUIsstMe8E1 +BaCY0CH7JbWBjcbiTnBVwIRZuu+EjGiQuhTilYL2OWqoMVg1WU0L2IFpR8lkf/2W +SBx5J6xhwbBGASOpM+qidiN580GdPzGhWYSqKGroHEzBm6xPSmV1tadNA26WFG4p +pthLiAECgYEA5BsPRpNYJAQLu5B0N7mj9eEp0HABVEgL/MpwiImjaKdAwp78HM64 +IuPvJxs7r+xESiIz4JyjR8zrQjYOCKJsARYkmNlEuAz0SkHabCw1BdEBwUhjUGVB +efoERK6GxfAoNqmSDwsOvHFOtsmDIlbHmg7G2rUxNVpeou415BSB0B8CgYEAqR4J +YHKk2Ibr9rU+rBU33TcdTGw0aAkFNAVeqM9j0haWuFXmV3RArgoy09lH+2Ha6z/g +fTX2xSDAWV7QUlLOlBRIhurPAo2jO2yCrGHPZcWiugstrR2hTTInigaSnCmK3i7F +6sYmL3S7K01IcVNxSlWvGijtClT92Cl2WUCTfG0CgYAiEjyk4QtQTd5mxLvnOu5X +oqs5PBGmwiAwQRiv/EcRMbJFn7Oupd3xMDSflbzDmTnWDOfMy/jDl8MoH6TW+1PA +kcsjnYhbKWwvz0hN0giVdtOZSDO1ZXpzOrn6fEsbM7T9/TQY1SD9WrtUKCNTNL0Z +sM1ZC6lu+7GZCpW4HKwLJwKBgQCRT0yxQXBg1/UxwuO5ynV4rx2Oh76z0WRWIXMH +S0MyxdP1SWGkrS/SGtM3cg/GcHtA/V6vV0nUcWK0p6IJyjrTw2XZ/zGluPuTWJYi +9dvVT26Vunshrz7kbH7KuwEICy3V4IyQQHeY+QzFlR70uMS0IVFWAepCoWqHbIDT +CYhwNQKBgGPcLXmjpGtkZvggl0aZr9LsvCTckllSCFSI861kivL/rijdNoCHGxZv +dfDkLTLcz9Gk41rD9Gxn/3sqodnTAc3Z2PxFnzg1Q/u3+x6YAgBwI/g/jE2xutGW +H7CurtMwALQ/n/6LUKFmjRZjqbKX9SO2QSaC3grd6sY9Tu+bZjLe +-----END RSA PRIVATE KEY----- +` +) diff --git a/crypto/md5.go b/crypto/md5.go new file mode 100644 index 0000000..79f54c6 --- /dev/null +++ b/crypto/md5.go @@ -0,0 +1,18 @@ +package crypto + +import ( + "crypto/md5" + "encoding/hex" + + "github.com/zclconf/go-cty/cty" +) + +// Md5Func is a function that computes the MD5 hash of a given string and +// encodes it with hexadecimal digits. +var Md5Func = makeStringHashFunction(md5.New, hex.EncodeToString) + +// Md5 computes the MD5 hash of a given string and encodes it with hexadecimal +// digits. +func Md5(str cty.Value) (cty.Value, error) { + return Md5Func.Call([]cty.Value{str}) +} diff --git a/crypto/md5_test.go b/crypto/md5_test.go new file mode 100644 index 0000000..8ab6f38 --- /dev/null +++ b/crypto/md5_test.go @@ -0,0 +1,51 @@ +package crypto + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestMd5(t *testing.T) { + tests := []struct { + String cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("tada"), + cty.StringVal("ce47d07243bb6eaf5e1322c81baf9bbf"), + false, + }, + { // Confirm that we're not trimming any whitespaces + cty.StringVal(" tada "), + cty.StringVal("aadf191a583e53062de2d02c008141c4"), + false, + }, + { // We accept empty string too + cty.StringVal(""), + cty.StringVal("d41d8cd98f00b204e9800998ecf8427e"), + false, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("md5(%#v)", test.String), func(t *testing.T) { + got, err := Md5(test.String) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} diff --git a/crypto/rsa.go b/crypto/rsa.go new file mode 100644 index 0000000..8a89c44 --- /dev/null +++ b/crypto/rsa.go @@ -0,0 +1,64 @@ +package crypto + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +// RsaDecryptFunc is a function that decrypts an RSA-encrypted ciphertext. +var RsaDecryptFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "ciphertext", + Type: cty.String, + }, + { + Name: "privatekey", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + s := args[0].AsString() + key := args[1].AsString() + + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return cty.UnknownVal(cty.String), fmt.Errorf("failed to decode input %q: cipher text must be base64-encoded", s) + } + + block, _ := pem.Decode([]byte(key)) + if block == nil { + return cty.UnknownVal(cty.String), fmt.Errorf("failed to parse key: no key found") + } + if block.Headers["Proc-Type"] == "4,ENCRYPTED" { + return cty.UnknownVal(cty.String), fmt.Errorf( + "failed to parse key: password protected keys are not supported. Please decrypt the key prior to use", + ) + } + + x509Key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return cty.UnknownVal(cty.String), err + } + + out, err := rsa.DecryptPKCS1v15(nil, x509Key, b) + if err != nil { + return cty.UnknownVal(cty.String), err + } + + return cty.StringVal(string(out)), nil + }, +}) + +// RsaDecrypt decrypts an RSA-encrypted ciphertext, returning the corresponding +// cleartext. +func RsaDecrypt(ciphertext, privatekey cty.Value) (cty.Value, error) { + return RsaDecryptFunc.Call([]cty.Value{ciphertext, privatekey}) +} diff --git a/crypto/rsa_test.go b/crypto/rsa_test.go new file mode 100644 index 0000000..25c7ffb --- /dev/null +++ b/crypto/rsa_test.go @@ -0,0 +1,78 @@ +package crypto + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestRsaDecrypt(t *testing.T) { + tests := []struct { + Ciphertext cty.Value + Privatekey cty.Value + Want cty.Value + Err bool + }{ + // Base-64 encoded cipher decrypts correctly + { + cty.StringVal(CipherBase64), + cty.StringVal(PrivateKey), + cty.StringVal("message"), + false, + }, + // Wrong key + { + cty.StringVal(CipherBase64), + cty.StringVal(WrongPrivateKey), + cty.UnknownVal(cty.String), + true, + }, + // Bad key + { + cty.StringVal(CipherBase64), + cty.StringVal("bad key"), + cty.UnknownVal(cty.String), + true, + }, + // Empty key + { + cty.StringVal(CipherBase64), + cty.StringVal(""), + cty.UnknownVal(cty.String), + true, + }, + // Bad cipher + { + cty.StringVal("bad cipher"), + cty.StringVal(PrivateKey), + cty.UnknownVal(cty.String), + true, + }, + // Empty cipher + { + cty.StringVal(""), + cty.StringVal(PrivateKey), + cty.UnknownVal(cty.String), + true, + }, + } + for _, test := range tests { + t.Run(fmt.Sprintf("RsaDecrypt(%#v, %#v)", test.Ciphertext, test.Privatekey), func(t *testing.T) { + got, err := RsaDecrypt(test.Ciphertext, test.Privatekey) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} diff --git a/crypto/sha.go b/crypto/sha.go new file mode 100644 index 0000000..5e1cb4a --- /dev/null +++ b/crypto/sha.go @@ -0,0 +1,40 @@ +package crypto + +import ( + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + + "github.com/zclconf/go-cty/cty" +) + +// Sha1Func is a function that computes the SHA1 hash of a given string and +// encodes it with hexadecimal digits. +var Sha1Func = makeStringHashFunction(sha1.New, hex.EncodeToString) + +// Sha256Func is a function that computes the SHA256 hash of a given string and +// encodes it with hexadecimal digits. +var Sha256Func = makeStringHashFunction(sha256.New, hex.EncodeToString) + +// Sha512Func is a function that computes the SHA512 hash of a given string and +// encodes it with hexadecimal digits. +var Sha512Func = makeStringHashFunction(sha512.New, hex.EncodeToString) + +// Sha1 computes the SHA1 hash of a given string and encodes it with +// hexadecimal digits. +func Sha1(str cty.Value) (cty.Value, error) { + return Sha1Func.Call([]cty.Value{str}) +} + +// Sha256 computes the SHA256 hash of a given string and encodes it with +// hexadecimal digits. +func Sha256(str cty.Value) (cty.Value, error) { + return Sha256Func.Call([]cty.Value{str}) +} + +// Sha512 computes the SHA512 hash of a given string and encodes it with +// hexadecimal digits. +func Sha512(str cty.Value) (cty.Value, error) { + return Sha512Func.Call([]cty.Value{str}) +} diff --git a/crypto/sha_test.go b/crypto/sha_test.go new file mode 100644 index 0000000..aefdb9d --- /dev/null +++ b/crypto/sha_test.go @@ -0,0 +1,107 @@ +package crypto + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestSha1(t *testing.T) { + tests := []struct { + String cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("test"), + cty.StringVal("a94a8fe5ccb19ba61c4c0873d391e987982fbbd3"), + false, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("sha1(%#v)", test.String), func(t *testing.T) { + got, err := Sha1(test.String) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} + +func TestSha256(t *testing.T) { + tests := []struct { + String cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("test"), + cty.StringVal("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"), + false, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("sha256(%#v)", test.String), func(t *testing.T) { + got, err := Sha256(test.String) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} + +func TestSha512(t *testing.T) { + tests := []struct { + String cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("test"), + cty.StringVal("ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff"), + false, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("sha512(%#v)", test.String), func(t *testing.T) { + got, err := Sha512(test.String) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} diff --git a/crypto/testdata/hello.txt b/crypto/testdata/hello.txt new file mode 100644 index 0000000..5e1c309 --- /dev/null +++ b/crypto/testdata/hello.txt @@ -0,0 +1 @@ +Hello World \ No newline at end of file diff --git a/crypto/testdata/icon.png b/crypto/testdata/icon.png new file mode 100644 index 0000000..a474f14 Binary files /dev/null and b/crypto/testdata/icon.png differ diff --git a/encoding/base64.go b/encoding/base64.go new file mode 100644 index 0000000..e1e1b13 --- /dev/null +++ b/encoding/base64.go @@ -0,0 +1,71 @@ +package encoding + +import ( + "encoding/base64" + "fmt" + "log" + "unicode/utf8" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +// Base64DecodeFunc is a function that decodes a string containing a base64 sequence. +var Base64DecodeFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "str", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + s := args[0].AsString() + sDec, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return cty.UnknownVal(cty.String), fmt.Errorf("failed to decode base64 data '%s'", s) + } + if !utf8.Valid([]byte(sDec)) { + log.Printf("[DEBUG] the result of decoding the the provided string is not valid UTF-8: %s", sDec) + return cty.UnknownVal(cty.String), fmt.Errorf("the result of decoding the the provided string is not valid UTF-8") + } + return cty.StringVal(string(sDec)), nil + }, +}) + +// Base64EncodeFunc is a function that encodes a string to a base64 sequence. +var Base64EncodeFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "str", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return cty.StringVal(base64.StdEncoding.EncodeToString([]byte(args[0].AsString()))), nil + }, +}) + +// Base64Decode decodes a string containing a base64 sequence. +// +// Terraform uses the "standard" Base64 alphabet as defined in RFC 4648 section 4. +// +// Strings in the Terraform language are sequences of unicode characters rather +// than bytes, so this function will also interpret the resulting bytes as +// UTF-8. If the bytes after Base64 decoding are _not_ valid UTF-8, this function +// produces an error. +func Base64Decode(str cty.Value) (cty.Value, error) { + return Base64DecodeFunc.Call([]cty.Value{str}) +} + +// Base64Encode applies Base64 encoding to a string. +// +// Terraform uses the "standard" Base64 alphabet as defined in RFC 4648 section 4. +// +// Strings in the Terraform language are sequences of unicode characters rather +// than bytes, so this function will first encode the characters from the string +// as UTF-8, and then apply Base64 encoding to the result. +func Base64Encode(str cty.Value) (cty.Value, error) { + return Base64EncodeFunc.Call([]cty.Value{str}) +} diff --git a/encoding/base64_test.go b/encoding/base64_test.go new file mode 100644 index 0000000..f940679 --- /dev/null +++ b/encoding/base64_test.go @@ -0,0 +1,84 @@ +package encoding + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestBase64Decode(t *testing.T) { + tests := []struct { + String cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("YWJjMTIzIT8kKiYoKSctPUB+"), + cty.StringVal("abc123!?$*&()'-=@~"), + false, + }, + { // Invalid base64 data decoding + cty.StringVal("this-is-an-invalid-base64-data"), + cty.UnknownVal(cty.String), + true, + }, + { // Invalid utf-8 + cty.StringVal("\xc3\x28"), + cty.UnknownVal(cty.String), + true, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("base64decode(%#v)", test.String), func(t *testing.T) { + got, err := Base64Decode(test.String) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} + +func TestBase64Encode(t *testing.T) { + tests := []struct { + String cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("abc123!?$*&()'-=@~"), + cty.StringVal("YWJjMTIzIT8kKiYoKSctPUB+"), + false, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("base64encode(%#v)", test.String), func(t *testing.T) { + got, err := Base64Encode(test.String) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} diff --git a/encoding/url.go b/encoding/url.go new file mode 100644 index 0000000..a39eee0 --- /dev/null +++ b/encoding/url.go @@ -0,0 +1,34 @@ +package encoding + +import ( + "net/url" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +// URLEncodeFunc is a function that applies URL encoding to a given string. +var URLEncodeFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "str", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return cty.StringVal(url.QueryEscape(args[0].AsString())), nil + }, +}) + +// URLEncode applies URL encoding to a given string. +// +// This function identifies characters in the given string that would have a +// special meaning when included as a query string argument in a URL and +// escapes them using RFC 3986 "percent encoding". +// +// If the given string contains non-ASCII characters, these are first encoded as +// UTF-8 and then percent encoding is applied separately to each UTF-8 byte. +func URLEncode(str cty.Value) (cty.Value, error) { + return URLEncodeFunc.Call([]cty.Value{str}) +} diff --git a/encoding/url_test.go b/encoding/url_test.go new file mode 100644 index 0000000..0ccc4b8 --- /dev/null +++ b/encoding/url_test.go @@ -0,0 +1,56 @@ +package encoding + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestURLEncode(t *testing.T) { + tests := []struct { + String cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("abc123-_"), + cty.StringVal("abc123-_"), + false, + }, + { + cty.StringVal("foo:bar@localhost?foo=bar&bar=baz"), + cty.StringVal("foo%3Abar%40localhost%3Ffoo%3Dbar%26bar%3Dbaz"), + false, + }, + { + cty.StringVal("mailto:email?subject=this+is+my+subject"), + cty.StringVal("mailto%3Aemail%3Fsubject%3Dthis%2Bis%2Bmy%2Bsubject"), + false, + }, + { + cty.StringVal("foo/bar"), + cty.StringVal("foo%2Fbar"), + false, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("urlencode(%#v)", test.String), func(t *testing.T) { + got, err := URLEncode(test.String) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} diff --git a/filesystem/filesystem.go b/filesystem/filesystem.go new file mode 100644 index 0000000..dc8d272 --- /dev/null +++ b/filesystem/filesystem.go @@ -0,0 +1,312 @@ +package filesystem + +import ( + "encoding/base64" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "unicode/utf8" + + "github.com/bmatcuk/doublestar" + homedir "github.com/mitchellh/go-homedir" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +// MakeFileFunc constructs a function that takes a file path and returns the +// contents of that file, either directly as a string (where valid UTF-8 is +// required) or as a string containing base64 bytes. +func MakeFileFunc(baseDir string, encBase64 bool) function.Function { + return function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "path", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + path := args[0].AsString() + src, err := readFileBytes(baseDir, path) + if err != nil { + return cty.UnknownVal(cty.String), err + } + + switch { + case encBase64: + enc := base64.StdEncoding.EncodeToString(src) + return cty.StringVal(enc), nil + default: + if !utf8.Valid(src) { + return cty.UnknownVal(cty.String), fmt.Errorf("contents of %s are not valid UTF-8; use the filebase64 function to obtain the Base64 encoded contents or the other file functions (e.g. filemd5, filesha256) to obtain file hashing results instead", path) + } + return cty.StringVal(string(src)), nil + } + }, + }) +} + +// MakeFileExistsFunc is a function that takes a path and determines whether a +// file exists at that path. +// +// MakeFileExistsFunc will try to expand a path starting with a '~' to the home +// folder using github.com/mitchellh/go-homedir +func MakeFileExistsFunc(baseDir string) function.Function { + return function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "path", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.Bool), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + path := args[0].AsString() + path, err := homedir.Expand(path) + if err != nil { + return cty.UnknownVal(cty.Bool), fmt.Errorf("failed to expand ~: %s", err) + } + + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + + // Ensure that the path is canonical for the host OS + path = filepath.Clean(path) + + fi, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return cty.False, nil + } + return cty.UnknownVal(cty.Bool), fmt.Errorf("failed to stat %s", path) + } + + if fi.Mode().IsRegular() { + return cty.True, nil + } + + return cty.False, fmt.Errorf("%s is not a regular file, but %q", + path, fi.Mode().String()) + }, + }) +} + +// MakeFileSetFunc is a function that takes a glob pattern +// and enumerates a file set from that pattern +func MakeFileSetFunc(baseDir string) function.Function { + return function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "path", + Type: cty.String, + }, + { + Name: "pattern", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.Set(cty.String)), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + path := args[0].AsString() + pattern := args[1].AsString() + + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + + // Join the path to the glob pattern, while ensuring the full + // pattern is canonical for the host OS. The joined path is + // automatically cleaned during this operation. + pattern = filepath.Join(path, pattern) + + matches, err := doublestar.Glob(pattern) + if err != nil { + return cty.UnknownVal(cty.Set(cty.String)), fmt.Errorf("failed to glob pattern (%s): %s", pattern, err) + } + + var matchVals []cty.Value + for _, match := range matches { + fi, err := os.Stat(match) + + if err != nil { + return cty.UnknownVal(cty.Set(cty.String)), fmt.Errorf("failed to stat (%s): %s", match, err) + } + + if !fi.Mode().IsRegular() { + continue + } + + // Remove the path and file separator from matches. + match, err = filepath.Rel(path, match) + + if err != nil { + return cty.UnknownVal(cty.Set(cty.String)), fmt.Errorf("failed to trim path of match (%s): %s", match, err) + } + + // Replace any remaining file separators with forward slash (/) + // separators for cross-system compatibility. + match = filepath.ToSlash(match) + + matchVals = append(matchVals, cty.StringVal(match)) + } + + if len(matchVals) == 0 { + return cty.SetValEmpty(cty.String), nil + } + + return cty.SetVal(matchVals), nil + }, + }) +} + +// BasenameFunc is a function that takes a string containing a filesystem path +// and removes all except the last portion from it. +var BasenameFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "path", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return cty.StringVal(filepath.Base(args[0].AsString())), nil + }, +}) + +// DirnameFunc is a function that takes a string containing a filesystem path +// and removes the last portion from it. +var DirnameFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "path", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return cty.StringVal(filepath.Dir(args[0].AsString())), nil + }, +}) + +// AbsPathFunc is a function that converts a filesystem path to an absolute path +var AbsPathFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "path", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + absPath, err := filepath.Abs(args[0].AsString()) + return cty.StringVal(filepath.ToSlash(absPath)), err + }, +}) + +// PathExpandFunc is a function that expands a leading ~ character to the current user's home directory. +var PathExpandFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "path", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + + homePath, err := homedir.Expand(args[0].AsString()) + return cty.StringVal(homePath), err + }, +}) + +func readFileBytes(baseDir, path string) ([]byte, error) { + path, err := homedir.Expand(path) + if err != nil { + return nil, fmt.Errorf("failed to expand ~: %s", err) + } + + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + + // Ensure that the path is canonical for the host OS + path = filepath.Clean(path) + + src, err := ioutil.ReadFile(path) + if err != nil { + // ReadFile does not return Terraform-user-friendly error + // messages, so we'll provide our own. + if os.IsNotExist(err) { + return nil, fmt.Errorf("no file exists at %s", path) + } + return nil, fmt.Errorf("failed to read %s", path) + } + + return src, nil +} + +// File reads the contents of the file at the given path. +// +// The file must contain valid UTF-8 bytes, or this function will return an error. +// +// The underlying function implementation works relative to a particular base +// directory, so this wrapper takes a base directory string and uses it to +// construct the underlying function before calling it. +func File(baseDir string, path cty.Value) (cty.Value, error) { + fn := MakeFileFunc(baseDir, false) + return fn.Call([]cty.Value{path}) +} + +// FileExists determines whether a file exists at the given path. +// +// The underlying function implementation works relative to a particular base +// directory, so this wrapper takes a base directory string and uses it to +// construct the underlying function before calling it. +func FileExists(baseDir string, path cty.Value) (cty.Value, error) { + fn := MakeFileExistsFunc(baseDir) + return fn.Call([]cty.Value{path}) +} + +// FileSet enumerates a set of files given a glob pattern +// +// The underlying function implementation works relative to a particular base +// directory, so this wrapper takes a base directory string and uses it to +// construct the underlying function before calling it. +func FileSet(baseDir string, path, pattern cty.Value) (cty.Value, error) { + fn := MakeFileSetFunc(baseDir) + return fn.Call([]cty.Value{path, pattern}) +} + +// Basename takes a string containing a filesystem path and removes all except the last portion from it. +// +// The underlying function implementation works only with the path string and does not access the filesystem itself. +// It is therefore unable to take into account filesystem features such as symlinks. +// +// If the path is empty then the result is ".", representing the current working directory. +func Basename(path cty.Value) (cty.Value, error) { + return BasenameFunc.Call([]cty.Value{path}) +} + +// Dirname takes a string containing a filesystem path and removes the last portion from it. +// +// The underlying function implementation works only with the path string and does not access the filesystem itself. +// It is therefore unable to take into account filesystem features such as symlinks. +// +// If the path is empty then the result is ".", representing the current working directory. +func Dirname(path cty.Value) (cty.Value, error) { + return DirnameFunc.Call([]cty.Value{path}) +} + +// Pathexpand takes a string that might begin with a `~` segment, and if so it replaces that segment with +// the current user's home directory path. +// +// The underlying function implementation works only with the path string and does not access the filesystem itself. +// It is therefore unable to take into account filesystem features such as symlinks. +// +// If the leading segment in the path is not `~` then the given path is returned unmodified. +func Pathexpand(path cty.Value) (cty.Value, error) { + return PathExpandFunc.Call([]cty.Value{path}) +} diff --git a/filesystem/filesystem_test.go b/filesystem/filesystem_test.go new file mode 100644 index 0000000..14f841d --- /dev/null +++ b/filesystem/filesystem_test.go @@ -0,0 +1,445 @@ +package filesystem + +import ( + "fmt" + "path/filepath" + "runtime" + "testing" + + homedir "github.com/mitchellh/go-homedir" + "github.com/zclconf/go-cty/cty" +) + +func TestFileExists(t *testing.T) { + tests := []struct { + Path cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("testdata/hello.txt"), + cty.BoolVal(true), + false, + }, + { + cty.StringVal(""), // empty path + cty.BoolVal(false), + true, + }, + { + cty.StringVal("testdata/missing"), + cty.BoolVal(false), + false, // no file exists + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("FileExists(\".\", %#v)", test.Path), func(t *testing.T) { + got, err := FileExists(".", test.Path) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} + +func TestFileSet(t *testing.T) { + type testCase struct { + Path cty.Value + Pattern cty.Value + Want cty.Value + Err bool + } + tests := []testCase{ + { + cty.StringVal("."), + cty.StringVal("testdata*"), + cty.SetValEmpty(cty.String), + false, + }, + { + cty.StringVal("."), + cty.StringVal("testdata"), + cty.SetValEmpty(cty.String), + false, + }, + { + cty.StringVal("."), + cty.StringVal("{testdata,missing}"), + cty.SetValEmpty(cty.String), + false, + }, + { + cty.StringVal("."), + cty.StringVal("testdata/missing"), + cty.SetValEmpty(cty.String), + false, + }, + { + cty.StringVal("."), + cty.StringVal("testdata/missing*"), + cty.SetValEmpty(cty.String), + false, + }, + { + cty.StringVal("."), + cty.StringVal("*/missing"), + cty.SetValEmpty(cty.String), + false, + }, + { + cty.StringVal("."), + cty.StringVal("**/missing"), + cty.SetValEmpty(cty.String), + false, + }, + { + cty.StringVal("."), + cty.StringVal("testdata/*.txt"), + cty.SetVal([]cty.Value{ + cty.StringVal("testdata/hello.txt"), + }), + false, + }, + { + cty.StringVal("."), + cty.StringVal("testdata/hello.txt"), + cty.SetVal([]cty.Value{ + cty.StringVal("testdata/hello.txt"), + }), + false, + }, + { + cty.StringVal("."), + cty.StringVal("testdata/hello.???"), + cty.SetVal([]cty.Value{ + cty.StringVal("testdata/hello.txt"), + }), + false, + }, + { + cty.StringVal("."), + cty.StringVal("testdata/hello*"), + cty.SetVal([]cty.Value{ + cty.StringVal("testdata/hello.tmpl"), + cty.StringVal("testdata/hello.txt"), + }), + false, + }, + { + cty.StringVal("."), + cty.StringVal("testdata/hello.{tmpl,txt}"), + cty.SetVal([]cty.Value{ + cty.StringVal("testdata/hello.tmpl"), + cty.StringVal("testdata/hello.txt"), + }), + false, + }, + { + cty.StringVal("."), + cty.StringVal("*/hello.txt"), + cty.SetVal([]cty.Value{ + cty.StringVal("testdata/hello.txt"), + }), + false, + }, + { + cty.StringVal("."), + cty.StringVal("*/*.txt"), + cty.SetVal([]cty.Value{ + cty.StringVal("testdata/hello.txt"), + }), + false, + }, + { + cty.StringVal("."), + cty.StringVal("*/hello*"), + cty.SetVal([]cty.Value{ + cty.StringVal("testdata/hello.tmpl"), + cty.StringVal("testdata/hello.txt"), + }), + false, + }, + { + cty.StringVal("."), + cty.StringVal("**/hello*"), + cty.SetVal([]cty.Value{ + cty.StringVal("testdata/hello.tmpl"), + cty.StringVal("testdata/hello.txt"), + }), + false, + }, + { + cty.StringVal("."), + cty.StringVal("**/hello.{tmpl,txt}"), + cty.SetVal([]cty.Value{ + cty.StringVal("testdata/hello.tmpl"), + cty.StringVal("testdata/hello.txt"), + }), + false, + }, + { + cty.StringVal("."), + cty.StringVal("["), + cty.SetValEmpty(cty.String), + true, + }, + { + cty.StringVal("testdata"), + cty.StringVal("missing"), + cty.SetValEmpty(cty.String), + false, + }, + { + cty.StringVal("testdata"), + cty.StringVal("missing*"), + cty.SetValEmpty(cty.String), + false, + }, + { + cty.StringVal("testdata"), + cty.StringVal("*.txt"), + cty.SetVal([]cty.Value{ + cty.StringVal("hello.txt"), + }), + false, + }, + { + cty.StringVal("testdata"), + cty.StringVal("hello.txt"), + cty.SetVal([]cty.Value{ + cty.StringVal("hello.txt"), + }), + false, + }, + { + cty.StringVal("testdata"), + cty.StringVal("hello.???"), + cty.SetVal([]cty.Value{ + cty.StringVal("hello.txt"), + }), + false, + }, + { + cty.StringVal("testdata"), + cty.StringVal("hello*"), + cty.SetVal([]cty.Value{ + cty.StringVal("hello.tmpl"), + cty.StringVal("hello.txt"), + }), + false, + }, + } + switch runtime.GOOS { + case "windows": + tests = append(tests, []testCase{ + { + cty.StringVal("."), + cty.StringVal("//"), + cty.SetValEmpty(cty.String), + false, + }, + }..., + ) + default: + tests = append(tests, []testCase{ + { + cty.StringVal("."), + cty.StringVal("\\"), + cty.SetValEmpty(cty.String), + true, + }, + }..., + ) + } + + for _, test := range tests { + t.Run(fmt.Sprintf("FileSet(\".\", %#v, %#v)", test.Path, test.Pattern), func(t *testing.T) { + got, err := FileSet(".", test.Path, test.Pattern) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} + +func TestBasename(t *testing.T) { + tests := []struct { + Path cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("testdata/hello.txt"), + cty.StringVal("hello.txt"), + false, + }, + { + cty.StringVal("hello.txt"), + cty.StringVal("hello.txt"), + false, + }, + { + cty.StringVal(""), + cty.StringVal("."), + false, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("Basename(%#v)", test.Path), func(t *testing.T) { + got, err := Basename(test.Path) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} + +func TestDirname(t *testing.T) { + type testCase struct { + Path cty.Value + Want cty.Value + Err bool + } + tests := []testCase{ + { + cty.StringVal("testdata/hello.txt"), + cty.StringVal("testdata"), + false, + }, + + { + cty.StringVal("hello.txt"), + cty.StringVal("."), + false, + }, + { + cty.StringVal(""), + cty.StringVal("."), + false, + }, + } + switch runtime.GOOS { + case "windows": + tests = append(tests, []testCase{ + { + cty.StringVal("testdata/foo/hello.txt"), + cty.StringVal("testdata\\foo"), + false, + }, + }..., + ) + default: + tests = append(tests, []testCase{ + { + cty.StringVal("testdata/foo/hello.txt"), + cty.StringVal("testdata/foo"), + false, + }, + }..., + ) + } + + for _, test := range tests { + t.Run(fmt.Sprintf("Dirname(%#v)", test.Path), func(t *testing.T) { + got, err := Dirname(test.Path) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} + +func TestPathExpand(t *testing.T) { + homePath, err := homedir.Dir() + if err != nil { + t.Fatalf("Error getting home directory: %v", err) + } + + tests := []struct { + Path cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("~/test-file"), + cty.StringVal(filepath.Join(homePath, "test-file")), + false, + }, + { + cty.StringVal("~/another/test/file"), + cty.StringVal(filepath.Join(homePath, "another/test/file")), + false, + }, + { + cty.StringVal("/root/file"), + cty.StringVal("/root/file"), + false, + }, + { + cty.StringVal("/"), + cty.StringVal("/"), + false, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("Dirname(%#v)", test.Path), func(t *testing.T) { + got, err := Pathexpand(test.Path) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} diff --git a/filesystem/testdata/hello.tmpl b/filesystem/testdata/hello.tmpl new file mode 100644 index 0000000..e69de29 diff --git a/filesystem/testdata/hello.txt b/filesystem/testdata/hello.txt new file mode 100644 index 0000000..5e1c309 --- /dev/null +++ b/filesystem/testdata/hello.txt @@ -0,0 +1 @@ +Hello World \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8f39cba --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/hashicorp/go-cty-funcs + +go 1.14 + +require ( + github.com/apparentlymart/go-cidr v1.0.1 + github.com/bmatcuk/doublestar v1.1.5 + github.com/google/uuid v1.1.1 + github.com/mitchellh/go-homedir v1.1.0 + github.com/zclconf/go-cty v1.4.0 + golang.org/x/crypto v0.0.0-20200422194213-44a606286825 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5763b6c --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +github.com/apparentlymart/go-cidr v1.0.1 h1:NmIwLZ/KdsjIUlhf+/Np40atNXm/+lZ5txfTJ/SpF+U= +github.com/apparentlymart/go-cidr v1.0.1/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= +github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= +github.com/bmatcuk/doublestar v1.1.5 h1:2bNwBOmhyFEFcoB3tGvTD5xanq+4kyOZlB8wFYbMjkk= +github.com/bmatcuk/doublestar v1.1.5/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/zclconf/go-cty v1.4.0 h1:+q+tmgyUB94HIdH/uVTIi/+kt3pt4sHwEZAcTyLoGsQ= +github.com/zclconf/go-cty v1.4.0/go.mod h1:nHzOclRkoj++EU9ZjSrZvRG0BXIWt8c7loYc0qXAFGQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200422194213-44a606286825 h1:dSChiwOTvzwbHFTMq2l6uRardHH7/E6SqEkqccinS/o= +golang.org/x/crypto v0.0.0-20200422194213-44a606286825/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/uuid/uuid_v4.go b/uuid/uuid_v4.go new file mode 100644 index 0000000..1fb5567 --- /dev/null +++ b/uuid/uuid_v4.go @@ -0,0 +1,28 @@ +package uuid + +import ( + "github.com/google/uuid" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +var V4Func = function.New(&function.Spec{ + Params: []function.Parameter{}, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + uuid, err := uuid.NewRandom() + if err != nil { + return cty.UnknownVal(cty.String), err + } + return cty.StringVal(uuid.String()), nil + }, +}) + +// V4 generates and returns a Type-4 UUID in the standard hexadecimal string +// format. +// +// This is not a "pure" function: it will generate a different result for each +// call. +func V4() (cty.Value, error) { + return V4Func.Call(nil) +} diff --git a/uuid/uuid_v4_test.go b/uuid/uuid_v4_test.go new file mode 100644 index 0000000..ba981b2 --- /dev/null +++ b/uuid/uuid_v4_test.go @@ -0,0 +1,17 @@ +package uuid + +import ( + "testing" +) + +func TestV4(t *testing.T) { + result, err := V4() + if err != nil { + t.Fatal(err) + } + + resultStr := result.AsString() + if got, want := len(resultStr), 36; got != want { + t.Errorf("wrong result length %d; want %d", got, want) + } +} diff --git a/uuid/uuid_v5.go b/uuid/uuid_v5.go new file mode 100644 index 0000000..3cc068b --- /dev/null +++ b/uuid/uuid_v5.go @@ -0,0 +1,51 @@ +package uuid + +import ( + "fmt" + + uuidv5 "github.com/google/uuid" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +var V5Func = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "namespace", + Type: cty.String, + }, + { + Name: "name", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + var namespace uuidv5.UUID + switch { + case args[0].AsString() == "dns": + namespace = uuidv5.NameSpaceDNS + case args[0].AsString() == "url": + namespace = uuidv5.NameSpaceURL + case args[0].AsString() == "oid": + namespace = uuidv5.NameSpaceOID + case args[0].AsString() == "x500": + namespace = uuidv5.NameSpaceX500 + default: + if namespace, err = uuidv5.Parse(args[0].AsString()); err != nil { + return cty.UnknownVal(cty.String), fmt.Errorf("uuidv5() doesn't support namespace %s (%v)", args[0].AsString(), err) + } + } + val := args[1].AsString() + return cty.StringVal(uuidv5.NewSHA1(namespace, []byte(val)).String()), nil + }, +}) + +// V5 generates and returns a Type-5 UUID in the standard hexadecimal +// string format. +// +// This is not a "pure" function: it will generate a different result for each +// call. +func V5(namespace cty.Value, name cty.Value) (cty.Value, error) { + return V5Func.Call([]cty.Value{namespace, name}) +} diff --git a/uuid/uuid_v5_test.go b/uuid/uuid_v5_test.go new file mode 100644 index 0000000..f5dfe46 --- /dev/null +++ b/uuid/uuid_v5_test.go @@ -0,0 +1,73 @@ +package uuid + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestV5(t *testing.T) { + tests := []struct { + Namespace cty.Value + Name cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("dns"), + cty.StringVal("tada"), + cty.StringVal("faa898db-9b9d-5b75-86a9-149e7bb8e3b8"), + false, + }, + { + cty.StringVal("url"), + cty.StringVal("tada"), + cty.StringVal("2c1ff6b4-211f-577e-94de-d978b0caa16e"), + false, + }, + { + cty.StringVal("oid"), + cty.StringVal("tada"), + cty.StringVal("61eeea26-5176-5288-87fc-232d6ed30d2f"), + false, + }, + { + cty.StringVal("x500"), + cty.StringVal("tada"), + cty.StringVal("7e12415e-f7c9-57c3-9e43-52dc9950d264"), + false, + }, + { + cty.StringVal("6ba7b810-9dad-11d1-80b4-00c04fd430c8"), + cty.StringVal("tada"), + cty.StringVal("faa898db-9b9d-5b75-86a9-149e7bb8e3b8"), + false, + }, + { + cty.StringVal("tada"), + cty.StringVal("tada"), + cty.UnknownVal(cty.String), + true, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("uuidv5(%#v, %#v)", test.Namespace, test.Name), func(t *testing.T) { + got, err := V5(test.Namespace, test.Name) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +}