lang: Redact sensitive values from function errors
Some function errors include values derived from arguments. This commit is the result of a manual audit of these errors, which resulted in: - Adding a helper function to redact sensitive values; - Applying that helper function where errors include values derived from possibly-sensitive arguments; - Cleaning up other errors which need not include those values, or were otherwise incorrect.
This commit is contained in:
parent
ba6a64eb35
commit
5d7cb81c0c
|
@ -311,8 +311,8 @@ var LookupFunc = function.New(&function.Spec{
|
|||
return defaultVal.WithMarks(markses...), nil
|
||||
}
|
||||
|
||||
return cty.UnknownVal(cty.DynamicPseudoType).WithMarks(markses...), fmt.Errorf(
|
||||
"lookup failed to find '%s'", lookupKey)
|
||||
return cty.UnknownVal(cty.DynamicPseudoType), fmt.Errorf(
|
||||
"lookup failed to find key %s", redactIfSensitive(lookupKey, keyMarks))
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/lang/marks"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
|
@ -899,6 +900,46 @@ func TestLookup(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLookup_error(t *testing.T) {
|
||||
simpleMap := cty.MapVal(map[string]cty.Value{
|
||||
"foo": cty.StringVal("bar"),
|
||||
})
|
||||
|
||||
tests := map[string]struct {
|
||||
Values []cty.Value
|
||||
WantErr string
|
||||
}{
|
||||
"failed to find non-sensitive key": {
|
||||
[]cty.Value{
|
||||
simpleMap,
|
||||
cty.StringVal("boop"),
|
||||
},
|
||||
`lookup failed to find key "boop"`,
|
||||
},
|
||||
"failed to find sensitive key": {
|
||||
[]cty.Value{
|
||||
simpleMap,
|
||||
cty.StringVal("boop").Mark(marks.Sensitive),
|
||||
},
|
||||
"lookup failed to find key (sensitive value)",
|
||||
},
|
||||
}
|
||||
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := Lookup(test.Values...)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("succeeded; want error")
|
||||
}
|
||||
|
||||
if err.Error() != test.WantErr {
|
||||
t.Errorf("wrong error\ngot: %#v\nwant: %#v", err, test.WantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchkeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
Keys cty.Value
|
||||
|
|
|
@ -20,20 +20,22 @@ var Base64DecodeFunc = function.New(&function.Spec{
|
|||
{
|
||||
Name: "str",
|
||||
Type: cty.String,
|
||||
AllowMarked: true,
|
||||
},
|
||||
},
|
||||
Type: function.StaticReturnType(cty.String),
|
||||
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
|
||||
s := args[0].AsString()
|
||||
str, strMarks := args[0].Unmark()
|
||||
s := str.AsString()
|
||||
sDec, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return cty.UnknownVal(cty.String), fmt.Errorf("failed to decode base64 data '%s'", s)
|
||||
return cty.UnknownVal(cty.String), fmt.Errorf("failed to decode base64 data %s", redactIfSensitive(s, strMarks))
|
||||
}
|
||||
if !utf8.Valid([]byte(sDec)) {
|
||||
log.Printf("[DEBUG] the result of decoding the provided string is not valid UTF-8: %s", sDec)
|
||||
log.Printf("[DEBUG] the result of decoding the provided string is not valid UTF-8: %s", redactIfSensitive(sDec, strMarks))
|
||||
return cty.UnknownVal(cty.String), fmt.Errorf("the result of decoding the provided string is not valid UTF-8")
|
||||
}
|
||||
return cty.StringVal(string(sDec)), nil
|
||||
return cty.StringVal(string(sDec)).WithMarks(strMarks), nil
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -125,7 +127,7 @@ var TextDecodeBase64Func = function.New(&function.Spec{
|
|||
case base64.CorruptInputError:
|
||||
return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "the given value is has an invalid base64 symbol at offset %d", int(err))
|
||||
default:
|
||||
return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "invalid source string: %T", err)
|
||||
return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "invalid source string: %w", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -156,13 +158,13 @@ var Base64GzipFunc = function.New(&function.Spec{
|
|||
var b bytes.Buffer
|
||||
gz := gzip.NewWriter(&b)
|
||||
if _, err := gz.Write([]byte(s)); err != nil {
|
||||
return cty.UnknownVal(cty.String), fmt.Errorf("failed to write gzip raw data: '%s'", s)
|
||||
return cty.UnknownVal(cty.String), fmt.Errorf("failed to write gzip raw data: %w", err)
|
||||
}
|
||||
if err := gz.Flush(); err != nil {
|
||||
return cty.UnknownVal(cty.String), fmt.Errorf("failed to flush gzip writer: '%s'", s)
|
||||
return cty.UnknownVal(cty.String), fmt.Errorf("failed to flush gzip writer: %w", err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return cty.UnknownVal(cty.String), fmt.Errorf("failed to close gzip writer: '%s'", s)
|
||||
return cty.UnknownVal(cty.String), fmt.Errorf("failed to close gzip writer: %w", err)
|
||||
}
|
||||
return cty.StringVal(base64.StdEncoding.EncodeToString(b.Bytes())), nil
|
||||
},
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/lang/marks"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
|
@ -18,6 +19,11 @@ func TestBase64Decode(t *testing.T) {
|
|||
cty.StringVal("abc123!?$*&()'-=@~"),
|
||||
false,
|
||||
},
|
||||
{
|
||||
cty.StringVal("YWJjMTIzIT8kKiYoKSctPUB+").Mark(marks.Sensitive),
|
||||
cty.StringVal("abc123!?$*&()'-=@~").Mark(marks.Sensitive),
|
||||
false,
|
||||
},
|
||||
{ // Invalid base64 data decoding
|
||||
cty.StringVal("this-is-an-invalid-base64-data"),
|
||||
cty.UnknownVal(cty.String),
|
||||
|
@ -50,6 +56,40 @@ func TestBase64Decode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBase64Decode_error(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
String cty.Value
|
||||
WantErr string
|
||||
}{
|
||||
"invalid base64": {
|
||||
cty.StringVal("dfg"),
|
||||
`failed to decode base64 data "dfg"`,
|
||||
},
|
||||
"sensitive invalid base64": {
|
||||
cty.StringVal("dfg").Mark(marks.Sensitive),
|
||||
`failed to decode base64 data (sensitive value)`,
|
||||
},
|
||||
"invalid utf-8": {
|
||||
cty.StringVal("whee"),
|
||||
"the result of decoding the provided string is not valid UTF-8",
|
||||
},
|
||||
}
|
||||
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := Base64Decode(test.String)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("succeeded; want error")
|
||||
}
|
||||
|
||||
if err.Error() != test.WantErr {
|
||||
t.Errorf("wrong error result\ngot: %#v\nwant: %#v", err.Error(), test.WantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBase64Encode(t *testing.T) {
|
||||
tests := []struct {
|
||||
String cty.Value
|
||||
|
|
|
@ -25,12 +25,14 @@ func MakeFileFunc(baseDir string, encBase64 bool) function.Function {
|
|||
{
|
||||
Name: "path",
|
||||
Type: cty.String,
|
||||
AllowMarked: true,
|
||||
},
|
||||
},
|
||||
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)
|
||||
pathArg, pathMarks := args[0].Unmark()
|
||||
path := pathArg.AsString()
|
||||
src, err := readFileBytes(baseDir, path, pathMarks)
|
||||
if err != nil {
|
||||
err = function.NewArgError(0, err)
|
||||
return cty.UnknownVal(cty.String), err
|
||||
|
@ -39,12 +41,12 @@ func MakeFileFunc(baseDir string, encBase64 bool) function.Function {
|
|||
switch {
|
||||
case encBase64:
|
||||
enc := base64.StdEncoding.EncodeToString(src)
|
||||
return cty.StringVal(enc), nil
|
||||
return cty.StringVal(enc).WithMarks(pathMarks), 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.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", redactIfSensitive(path, pathMarks))
|
||||
}
|
||||
return cty.StringVal(string(src)), nil
|
||||
return cty.StringVal(string(src)).WithMarks(pathMarks), nil
|
||||
}
|
||||
},
|
||||
})
|
||||
|
@ -69,6 +71,7 @@ func MakeTemplateFileFunc(baseDir string, funcsCb func() map[string]function.Fun
|
|||
{
|
||||
Name: "path",
|
||||
Type: cty.String,
|
||||
AllowMarked: true,
|
||||
},
|
||||
{
|
||||
Name: "vars",
|
||||
|
@ -76,10 +79,10 @@ func MakeTemplateFileFunc(baseDir string, funcsCb func() map[string]function.Fun
|
|||
},
|
||||
}
|
||||
|
||||
loadTmpl := func(fn string) (hcl.Expression, error) {
|
||||
loadTmpl := func(fn string, marks cty.ValueMarks) (hcl.Expression, error) {
|
||||
// We re-use File here to ensure the same filename interpretation
|
||||
// as it does, along with its other safety checks.
|
||||
tmplVal, err := File(baseDir, cty.StringVal(fn))
|
||||
tmplVal, err := File(baseDir, cty.StringVal(fn).WithMarks(marks))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -159,7 +162,9 @@ func MakeTemplateFileFunc(baseDir string, funcsCb func() map[string]function.Fun
|
|||
// We'll render our template now to see what result type it produces.
|
||||
// A template consisting only of a single interpolation an potentially
|
||||
// return any type.
|
||||
expr, err := loadTmpl(args[0].AsString())
|
||||
|
||||
pathArg, pathMarks := args[0].Unmark()
|
||||
expr, err := loadTmpl(pathArg.AsString(), pathMarks)
|
||||
if err != nil {
|
||||
return cty.DynamicPseudoType, err
|
||||
}
|
||||
|
@ -170,11 +175,13 @@ func MakeTemplateFileFunc(baseDir string, funcsCb func() map[string]function.Fun
|
|||
return val.Type(), err
|
||||
},
|
||||
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
|
||||
expr, err := loadTmpl(args[0].AsString())
|
||||
pathArg, pathMarks := args[0].Unmark()
|
||||
expr, err := loadTmpl(pathArg.AsString(), pathMarks)
|
||||
if err != nil {
|
||||
return cty.DynamicVal, err
|
||||
}
|
||||
return renderTmpl(expr, args[1])
|
||||
result, err := renderTmpl(expr, args[1])
|
||||
return result.WithMarks(pathMarks), err
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -188,14 +195,16 @@ func MakeFileExistsFunc(baseDir string) function.Function {
|
|||
{
|
||||
Name: "path",
|
||||
Type: cty.String,
|
||||
AllowMarked: true,
|
||||
},
|
||||
},
|
||||
Type: function.StaticReturnType(cty.Bool),
|
||||
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
|
||||
path := args[0].AsString()
|
||||
pathArg, pathMarks := args[0].Unmark()
|
||||
path := pathArg.AsString()
|
||||
path, err := homedir.Expand(path)
|
||||
if err != nil {
|
||||
return cty.UnknownVal(cty.Bool), fmt.Errorf("failed to expand ~: %s", err)
|
||||
return cty.UnknownVal(cty.Bool), fmt.Errorf("failed to expand ~: %w", err)
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
|
@ -208,17 +217,17 @@ func MakeFileExistsFunc(baseDir string) function.Function {
|
|||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cty.False, nil
|
||||
return cty.False.WithMarks(pathMarks), nil
|
||||
}
|
||||
return cty.UnknownVal(cty.Bool), fmt.Errorf("failed to stat %s", path)
|
||||
return cty.UnknownVal(cty.Bool), fmt.Errorf("failed to stat %s", redactIfSensitive(path, pathMarks))
|
||||
}
|
||||
|
||||
if fi.Mode().IsRegular() {
|
||||
return cty.True, nil
|
||||
return cty.True.WithMarks(pathMarks), nil
|
||||
}
|
||||
|
||||
return cty.False, fmt.Errorf("%s is not a regular file, but %q",
|
||||
path, fi.Mode().String())
|
||||
redactIfSensitive(path, pathMarks), fi.Mode().String())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -231,16 +240,22 @@ func MakeFileSetFunc(baseDir string) function.Function {
|
|||
{
|
||||
Name: "path",
|
||||
Type: cty.String,
|
||||
AllowMarked: true,
|
||||
},
|
||||
{
|
||||
Name: "pattern",
|
||||
Type: cty.String,
|
||||
AllowMarked: true,
|
||||
},
|
||||
},
|
||||
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()
|
||||
pathArg, pathMarks := args[0].Unmark()
|
||||
path := pathArg.AsString()
|
||||
patternArg, patternMarks := args[1].Unmark()
|
||||
pattern := patternArg.AsString()
|
||||
|
||||
marks := []cty.ValueMarks{pathMarks, patternMarks}
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(baseDir, path)
|
||||
|
@ -253,7 +268,7 @@ func MakeFileSetFunc(baseDir string) function.Function {
|
|||
|
||||
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)
|
||||
return cty.UnknownVal(cty.Set(cty.String)), fmt.Errorf("failed to glob pattern %s: %w", redactIfSensitive(pattern, marks...), err)
|
||||
}
|
||||
|
||||
var matchVals []cty.Value
|
||||
|
@ -261,7 +276,7 @@ func MakeFileSetFunc(baseDir string) function.Function {
|
|||
fi, err := os.Stat(match)
|
||||
|
||||
if err != nil {
|
||||
return cty.UnknownVal(cty.Set(cty.String)), fmt.Errorf("failed to stat (%s): %s", match, err)
|
||||
return cty.UnknownVal(cty.Set(cty.String)), fmt.Errorf("failed to stat %s: %w", redactIfSensitive(match, marks...), err)
|
||||
}
|
||||
|
||||
if !fi.Mode().IsRegular() {
|
||||
|
@ -272,7 +287,7 @@ func MakeFileSetFunc(baseDir string) function.Function {
|
|||
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)
|
||||
return cty.UnknownVal(cty.Set(cty.String)), fmt.Errorf("failed to trim path of match %s: %w", redactIfSensitive(match, marks...), err)
|
||||
}
|
||||
|
||||
// Replace any remaining file separators with forward slash (/)
|
||||
|
@ -283,10 +298,10 @@ func MakeFileSetFunc(baseDir string) function.Function {
|
|||
}
|
||||
|
||||
if len(matchVals) == 0 {
|
||||
return cty.SetValEmpty(cty.String), nil
|
||||
return cty.SetValEmpty(cty.String).WithMarks(marks...), nil
|
||||
}
|
||||
|
||||
return cty.SetVal(matchVals), nil
|
||||
return cty.SetVal(matchVals).WithMarks(marks...), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -355,7 +370,7 @@ var PathExpandFunc = function.New(&function.Spec{
|
|||
func openFile(baseDir, path string) (*os.File, error) {
|
||||
path, err := homedir.Expand(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to expand ~: %s", err)
|
||||
return nil, fmt.Errorf("failed to expand ~: %w", err)
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
|
@ -368,12 +383,12 @@ func openFile(baseDir, path string) (*os.File, error) {
|
|||
return os.Open(path)
|
||||
}
|
||||
|
||||
func readFileBytes(baseDir, path string) ([]byte, error) {
|
||||
func readFileBytes(baseDir, path string, marks cty.ValueMarks) ([]byte, error) {
|
||||
f, err := openFile(baseDir, path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// An extra Terraform-specific hint for this situation
|
||||
return nil, fmt.Errorf("no file exists at %s; this function works only with files that are distributed as part of the configuration source code, so if this file will be created by a resource in this configuration you must instead obtain this result from an attribute of that resource", path)
|
||||
return nil, fmt.Errorf("no file exists at %s; this function works only with files that are distributed as part of the configuration source code, so if this file will be created by a resource in this configuration you must instead obtain this result from an attribute of that resource", redactIfSensitive(path, marks))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
@ -381,7 +396,7 @@ func readFileBytes(baseDir, path string) ([]byte, error) {
|
|||
|
||||
src, err := ioutil.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read %s", path)
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
return src, nil
|
||||
|
|
|
@ -2,9 +2,11 @@ package funcs
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/lang/marks"
|
||||
homedir "github.com/mitchellh/go-homedir"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
"github.com/zclconf/go-cty/cty/function"
|
||||
|
@ -15,22 +17,32 @@ func TestFile(t *testing.T) {
|
|||
tests := []struct {
|
||||
Path cty.Value
|
||||
Want cty.Value
|
||||
Err bool
|
||||
Err string
|
||||
}{
|
||||
{
|
||||
cty.StringVal("testdata/hello.txt"),
|
||||
cty.StringVal("Hello World"),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("testdata/icon.png"),
|
||||
cty.NilVal,
|
||||
true, // Not valid UTF-8
|
||||
`contents of "testdata/icon.png" 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`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("testdata/icon.png").Mark(marks.Sensitive),
|
||||
cty.NilVal,
|
||||
`contents of (sensitive value) 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`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("testdata/missing"),
|
||||
cty.NilVal,
|
||||
true, // no file exists
|
||||
`no file exists at "testdata/missing"; this function works only with files that are distributed as part of the configuration source code, so if this file will be created by a resource in this configuration you must instead obtain this result from an attribute of that resource`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("testdata/missing").Mark(marks.Sensitive),
|
||||
cty.NilVal,
|
||||
`no file exists at (sensitive value); this function works only with files that are distributed as part of the configuration source code, so if this file will be created by a resource in this configuration you must instead obtain this result from an attribute of that resource`,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -38,10 +50,13 @@ func TestFile(t *testing.T) {
|
|||
t.Run(fmt.Sprintf("File(\".\", %#v)", test.Path), func(t *testing.T) {
|
||||
got, err := File(".", test.Path)
|
||||
|
||||
if test.Err {
|
||||
if test.Err != "" {
|
||||
if err == nil {
|
||||
t.Fatal("succeeded; want error")
|
||||
}
|
||||
if got, want := err.Error(), test.Err; got != want {
|
||||
t.Errorf("wrong error\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
return
|
||||
} else if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
|
@ -71,13 +86,19 @@ func TestTemplateFile(t *testing.T) {
|
|||
cty.StringVal("testdata/icon.png"),
|
||||
cty.EmptyObjectVal,
|
||||
cty.NilVal,
|
||||
`contents of testdata/icon.png 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`,
|
||||
`contents of "testdata/icon.png" 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`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("testdata/missing"),
|
||||
cty.EmptyObjectVal,
|
||||
cty.NilVal,
|
||||
`no file exists at testdata/missing; this function works only with files that are distributed as part of the configuration source code, so if this file will be created by a resource in this configuration you must instead obtain this result from an attribute of that resource`,
|
||||
`no file exists at "testdata/missing"; this function works only with files that are distributed as part of the configuration source code, so if this file will be created by a resource in this configuration you must instead obtain this result from an attribute of that resource`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("testdata/secrets.txt").Mark(marks.Sensitive),
|
||||
cty.EmptyObjectVal,
|
||||
cty.NilVal,
|
||||
`no file exists at (sensitive value); this function works only with files that are distributed as part of the configuration source code, so if this file will be created by a resource in this configuration you must instead obtain this result from an attribute of that resource`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("testdata/hello.tmpl"),
|
||||
|
@ -197,33 +218,61 @@ func TestFileExists(t *testing.T) {
|
|||
tests := []struct {
|
||||
Path cty.Value
|
||||
Want cty.Value
|
||||
Err bool
|
||||
Err string
|
||||
}{
|
||||
{
|
||||
cty.StringVal("testdata/hello.txt"),
|
||||
cty.BoolVal(true),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal(""), // empty path
|
||||
cty.StringVal(""),
|
||||
cty.BoolVal(false),
|
||||
true,
|
||||
`"." is not a regular file, but "drwxr-xr-x"`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("testdata").Mark(marks.Sensitive),
|
||||
cty.BoolVal(false),
|
||||
`(sensitive value) is not a regular file, but "drwxr-xr-x"`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("testdata/missing"),
|
||||
cty.BoolVal(false),
|
||||
false, // no file exists
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("testdata/unreadable/foobar"),
|
||||
cty.BoolVal(false),
|
||||
`failed to stat "testdata/unreadable/foobar"`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("testdata/unreadable/foobar").Mark(marks.Sensitive),
|
||||
cty.BoolVal(false),
|
||||
`failed to stat (sensitive value)`,
|
||||
},
|
||||
}
|
||||
|
||||
// Ensure "unreadable" directory cannot be listed during the test run
|
||||
fi, err := os.Lstat("testdata/unreadable")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Chmod("testdata/unreadable", 0000)
|
||||
defer func(mode os.FileMode) {
|
||||
os.Chmod("testdata/unreadable", mode)
|
||||
}(fi.Mode())
|
||||
|
||||
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 test.Err != "" {
|
||||
if err == nil {
|
||||
t.Fatal("succeeded; want error")
|
||||
}
|
||||
if got, want := err.Error(), test.Err; got != want {
|
||||
t.Errorf("wrong error\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
return
|
||||
} else if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
|
@ -241,49 +290,49 @@ func TestFileSet(t *testing.T) {
|
|||
Path cty.Value
|
||||
Pattern cty.Value
|
||||
Want cty.Value
|
||||
Err bool
|
||||
Err string
|
||||
}{
|
||||
{
|
||||
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("."),
|
||||
|
@ -291,7 +340,7 @@ func TestFileSet(t *testing.T) {
|
|||
cty.SetVal([]cty.Value{
|
||||
cty.StringVal("testdata/hello.txt"),
|
||||
}),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("."),
|
||||
|
@ -299,7 +348,7 @@ func TestFileSet(t *testing.T) {
|
|||
cty.SetVal([]cty.Value{
|
||||
cty.StringVal("testdata/hello.txt"),
|
||||
}),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("."),
|
||||
|
@ -307,7 +356,7 @@ func TestFileSet(t *testing.T) {
|
|||
cty.SetVal([]cty.Value{
|
||||
cty.StringVal("testdata/hello.txt"),
|
||||
}),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("."),
|
||||
|
@ -316,7 +365,7 @@ func TestFileSet(t *testing.T) {
|
|||
cty.StringVal("testdata/hello.tmpl"),
|
||||
cty.StringVal("testdata/hello.txt"),
|
||||
}),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("."),
|
||||
|
@ -325,7 +374,7 @@ func TestFileSet(t *testing.T) {
|
|||
cty.StringVal("testdata/hello.tmpl"),
|
||||
cty.StringVal("testdata/hello.txt"),
|
||||
}),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("."),
|
||||
|
@ -333,7 +382,7 @@ func TestFileSet(t *testing.T) {
|
|||
cty.SetVal([]cty.Value{
|
||||
cty.StringVal("testdata/hello.txt"),
|
||||
}),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("."),
|
||||
|
@ -341,7 +390,7 @@ func TestFileSet(t *testing.T) {
|
|||
cty.SetVal([]cty.Value{
|
||||
cty.StringVal("testdata/hello.txt"),
|
||||
}),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("."),
|
||||
|
@ -350,7 +399,7 @@ func TestFileSet(t *testing.T) {
|
|||
cty.StringVal("testdata/hello.tmpl"),
|
||||
cty.StringVal("testdata/hello.txt"),
|
||||
}),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("."),
|
||||
|
@ -359,7 +408,7 @@ func TestFileSet(t *testing.T) {
|
|||
cty.StringVal("testdata/hello.tmpl"),
|
||||
cty.StringVal("testdata/hello.txt"),
|
||||
}),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("."),
|
||||
|
@ -368,31 +417,37 @@ func TestFileSet(t *testing.T) {
|
|||
cty.StringVal("testdata/hello.tmpl"),
|
||||
cty.StringVal("testdata/hello.txt"),
|
||||
}),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("."),
|
||||
cty.StringVal("["),
|
||||
cty.SetValEmpty(cty.String),
|
||||
true,
|
||||
`failed to glob pattern "[": syntax error in pattern`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("."),
|
||||
cty.StringVal("[").Mark(marks.Sensitive),
|
||||
cty.SetValEmpty(cty.String),
|
||||
`failed to glob pattern (sensitive value): syntax error in pattern`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("."),
|
||||
cty.StringVal("\\"),
|
||||
cty.SetValEmpty(cty.String),
|
||||
true,
|
||||
`failed to glob pattern "\\": syntax error in pattern`,
|
||||
},
|
||||
{
|
||||
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"),
|
||||
|
@ -400,7 +455,7 @@ func TestFileSet(t *testing.T) {
|
|||
cty.SetVal([]cty.Value{
|
||||
cty.StringVal("hello.txt"),
|
||||
}),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("testdata"),
|
||||
|
@ -408,7 +463,7 @@ func TestFileSet(t *testing.T) {
|
|||
cty.SetVal([]cty.Value{
|
||||
cty.StringVal("hello.txt"),
|
||||
}),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("testdata"),
|
||||
|
@ -416,7 +471,7 @@ func TestFileSet(t *testing.T) {
|
|||
cty.SetVal([]cty.Value{
|
||||
cty.StringVal("hello.txt"),
|
||||
}),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("testdata"),
|
||||
|
@ -425,7 +480,7 @@ func TestFileSet(t *testing.T) {
|
|||
cty.StringVal("hello.tmpl"),
|
||||
cty.StringVal("hello.txt"),
|
||||
}),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -433,10 +488,13 @@ func TestFileSet(t *testing.T) {
|
|||
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 test.Err != "" {
|
||||
if err == nil {
|
||||
t.Fatal("succeeded; want error")
|
||||
}
|
||||
if got, want := err.Error(), test.Err; got != want {
|
||||
t.Errorf("wrong error\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
return
|
||||
} else if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
|
|
|
@ -97,10 +97,12 @@ var ParseIntFunc = function.New(&function.Spec{
|
|||
{
|
||||
Name: "number",
|
||||
Type: cty.DynamicPseudoType,
|
||||
AllowMarked: true,
|
||||
},
|
||||
{
|
||||
Name: "base",
|
||||
Type: cty.Number,
|
||||
AllowMarked: true,
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -116,11 +118,13 @@ var ParseIntFunc = function.New(&function.Spec{
|
|||
var base int
|
||||
var err error
|
||||
|
||||
if err = gocty.FromCtyValue(args[0], &numstr); err != nil {
|
||||
numArg, numMarks := args[0].Unmark()
|
||||
if err = gocty.FromCtyValue(numArg, &numstr); err != nil {
|
||||
return cty.UnknownVal(cty.String), function.NewArgError(0, err)
|
||||
}
|
||||
|
||||
if err = gocty.FromCtyValue(args[1], &base); err != nil {
|
||||
baseArg, baseMarks := args[1].Unmark()
|
||||
if err = gocty.FromCtyValue(baseArg, &base); err != nil {
|
||||
return cty.UnknownVal(cty.Number), function.NewArgError(1, err)
|
||||
}
|
||||
|
||||
|
@ -135,13 +139,13 @@ var ParseIntFunc = function.New(&function.Spec{
|
|||
if !ok {
|
||||
return cty.UnknownVal(cty.Number), function.NewArgErrorf(
|
||||
0,
|
||||
"cannot parse %q as a base %d integer",
|
||||
numstr,
|
||||
base,
|
||||
"cannot parse %s as a base %s integer",
|
||||
redactIfSensitive(numstr, numMarks),
|
||||
redactIfSensitive(base, baseMarks),
|
||||
)
|
||||
}
|
||||
|
||||
parsedNum := cty.NumberVal((&big.Float{}).SetInt(num))
|
||||
parsedNum := cty.NumberVal((&big.Float{}).SetInt(num)).WithMarks(numMarks, baseMarks)
|
||||
|
||||
return parsedNum, nil
|
||||
},
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/lang/marks"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
|
@ -187,139 +188,175 @@ func TestParseInt(t *testing.T) {
|
|||
Num cty.Value
|
||||
Base cty.Value
|
||||
Want cty.Value
|
||||
Err bool
|
||||
Err string
|
||||
}{
|
||||
{
|
||||
cty.StringVal("128"),
|
||||
cty.NumberIntVal(10),
|
||||
cty.NumberIntVal(128),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("128").Mark(marks.Sensitive),
|
||||
cty.NumberIntVal(10),
|
||||
cty.NumberIntVal(128).Mark(marks.Sensitive),
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("128"),
|
||||
cty.NumberIntVal(10).Mark(marks.Sensitive),
|
||||
cty.NumberIntVal(128).Mark(marks.Sensitive),
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("128").Mark(marks.Sensitive),
|
||||
cty.NumberIntVal(10).Mark(marks.Sensitive),
|
||||
cty.NumberIntVal(128).Mark(marks.Sensitive),
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("128").Mark(marks.Raw),
|
||||
cty.NumberIntVal(10).Mark(marks.Sensitive),
|
||||
cty.NumberIntVal(128).WithMarks(cty.NewValueMarks(marks.Raw, marks.Sensitive)),
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("-128"),
|
||||
cty.NumberIntVal(10),
|
||||
cty.NumberIntVal(-128),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("00128"),
|
||||
cty.NumberIntVal(10),
|
||||
cty.NumberIntVal(128),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("-00128"),
|
||||
cty.NumberIntVal(10),
|
||||
cty.NumberIntVal(-128),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("FF00"),
|
||||
cty.NumberIntVal(16),
|
||||
cty.NumberIntVal(65280),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("ff00"),
|
||||
cty.NumberIntVal(16),
|
||||
cty.NumberIntVal(65280),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("-FF00"),
|
||||
cty.NumberIntVal(16),
|
||||
cty.NumberIntVal(-65280),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("00FF00"),
|
||||
cty.NumberIntVal(16),
|
||||
cty.NumberIntVal(65280),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("-00FF00"),
|
||||
cty.NumberIntVal(16),
|
||||
cty.NumberIntVal(-65280),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("1011111011101111"),
|
||||
cty.NumberIntVal(2),
|
||||
cty.NumberIntVal(48879),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("aA"),
|
||||
cty.NumberIntVal(62),
|
||||
cty.NumberIntVal(656),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("Aa"),
|
||||
cty.NumberIntVal(62),
|
||||
cty.NumberIntVal(2242),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("999999999999999999999999999999999999999999999999999999999999"),
|
||||
cty.NumberIntVal(10),
|
||||
cty.MustParseNumberVal("999999999999999999999999999999999999999999999999999999999999"),
|
||||
false,
|
||||
``,
|
||||
},
|
||||
{
|
||||
cty.StringVal("FF"),
|
||||
cty.NumberIntVal(10),
|
||||
cty.UnknownVal(cty.Number),
|
||||
true,
|
||||
`cannot parse "FF" as a base 10 integer`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("FF").Mark(marks.Sensitive),
|
||||
cty.NumberIntVal(10),
|
||||
cty.UnknownVal(cty.Number),
|
||||
`cannot parse (sensitive value) as a base 10 integer`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("FF").Mark(marks.Sensitive),
|
||||
cty.NumberIntVal(10).Mark(marks.Sensitive),
|
||||
cty.UnknownVal(cty.Number),
|
||||
`cannot parse (sensitive value) as a base (sensitive value) integer`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("00FF"),
|
||||
cty.NumberIntVal(10),
|
||||
cty.UnknownVal(cty.Number),
|
||||
true,
|
||||
`cannot parse "00FF" as a base 10 integer`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("-00FF"),
|
||||
cty.NumberIntVal(10),
|
||||
cty.UnknownVal(cty.Number),
|
||||
true,
|
||||
`cannot parse "-00FF" as a base 10 integer`,
|
||||
},
|
||||
{
|
||||
cty.NumberIntVal(2),
|
||||
cty.NumberIntVal(10),
|
||||
cty.UnknownVal(cty.Number),
|
||||
true,
|
||||
`first argument must be a string, not number`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("1"),
|
||||
cty.NumberIntVal(63),
|
||||
cty.UnknownVal(cty.Number),
|
||||
true,
|
||||
`base must be a whole number between 2 and 62 inclusive`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("1"),
|
||||
cty.NumberIntVal(-1),
|
||||
cty.UnknownVal(cty.Number),
|
||||
true,
|
||||
`base must be a whole number between 2 and 62 inclusive`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("1"),
|
||||
cty.NumberIntVal(1),
|
||||
cty.UnknownVal(cty.Number),
|
||||
true,
|
||||
`base must be a whole number between 2 and 62 inclusive`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("1"),
|
||||
cty.NumberIntVal(0),
|
||||
cty.UnknownVal(cty.Number),
|
||||
true,
|
||||
`base must be a whole number between 2 and 62 inclusive`,
|
||||
},
|
||||
{
|
||||
cty.StringVal("1.2"),
|
||||
cty.NumberIntVal(10),
|
||||
cty.UnknownVal(cty.Number),
|
||||
true,
|
||||
`cannot parse "1.2" as a base 10 integer`,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -327,10 +364,13 @@ func TestParseInt(t *testing.T) {
|
|||
t.Run(fmt.Sprintf("parseint(%#v, %#v)", test.Num, test.Base), func(t *testing.T) {
|
||||
got, err := ParseInt(test.Num, test.Base)
|
||||
|
||||
if test.Err {
|
||||
if test.Err != "" {
|
||||
if err == nil {
|
||||
t.Fatal("succeeded; want error")
|
||||
}
|
||||
if got, want := err.Error(), test.Err; got != want {
|
||||
t.Errorf("wrong error\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
return
|
||||
} else if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
package funcs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/lang/marks"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
func redactIfSensitive(value interface{}, markses ...cty.ValueMarks) string {
|
||||
if marks.Has(cty.DynamicVal.WithMarks(markses...), marks.Sensitive) {
|
||||
return "(sensitive value)"
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return fmt.Sprintf("%q", v)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
package funcs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/lang/marks"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
func TestRedactIfSensitive(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
value interface{}
|
||||
marks []cty.ValueMarks
|
||||
want string
|
||||
}{
|
||||
"sensitive string": {
|
||||
value: "foo",
|
||||
marks: []cty.ValueMarks{cty.NewValueMarks(marks.Sensitive)},
|
||||
want: "(sensitive value)",
|
||||
},
|
||||
"raw non-sensitive string": {
|
||||
value: "foo",
|
||||
marks: []cty.ValueMarks{cty.NewValueMarks(marks.Raw)},
|
||||
want: `"foo"`,
|
||||
},
|
||||
"raw sensitive string": {
|
||||
value: "foo",
|
||||
marks: []cty.ValueMarks{cty.NewValueMarks(marks.Raw), cty.NewValueMarks(marks.Sensitive)},
|
||||
want: "(sensitive value)",
|
||||
},
|
||||
"sensitive number": {
|
||||
value: 12345,
|
||||
marks: []cty.ValueMarks{cty.NewValueMarks(marks.Sensitive)},
|
||||
want: "(sensitive value)",
|
||||
},
|
||||
"non-sensitive number": {
|
||||
value: 12345,
|
||||
marks: []cty.ValueMarks{},
|
||||
want: "12345",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := redactIfSensitive(tc.value, tc.marks...)
|
||||
if got != tc.want {
|
||||
t.Errorf("wrong result, got %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue