blob: d3a9b2c04f960fcb80a9b4b4f1645e5bd54b80f6 [file] [log] [blame]
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package s3
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"regexp"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/arn"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
)
const (
multiRegionKeyIdPattern = `mrk-[a-f0-9]{32}`
uuidRegexPattern = `[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[ab89][a-f0-9]{3}-[a-f0-9]{12}`
aliasRegexPattern = `alias/[a-zA-Z0-9/_-]+`
)
func validateKMSKey(path cty.Path, s string) (diags tfdiags.Diagnostics) {
if arn.IsARN(s) {
return validateKMSKeyARN(path, s)
}
return validateKMSKeyID(path, s)
}
func validateKMSKeyID(path cty.Path, s string) (diags tfdiags.Diagnostics) {
keyIdRegex := regexp.MustCompile(`^` + uuidRegexPattern + `|` + multiRegionKeyIdPattern + `|` + aliasRegexPattern + `$`)
if !keyIdRegex.MatchString(s) {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid KMS Key ID",
fmt.Sprintf("Value must be a valid KMS Key ID, got %q", s),
path,
))
return diags
}
return diags
}
func validateKMSKeyARN(path cty.Path, s string) (diags tfdiags.Diagnostics) {
parsedARN, err := arn.Parse(s)
if err != nil {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid KMS Key ARN",
fmt.Sprintf("Value must be a valid KMS Key ARN, got %q", s),
path,
))
return diags
}
if !isKeyARN(parsedARN) {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid KMS Key ARN",
fmt.Sprintf("Value must be a valid KMS Key ARN, got %q", s),
path,
))
return diags
}
return diags
}
func isKeyARN(arn arn.ARN) bool {
return keyIdFromARNResource(arn.Resource) != "" || aliasIdFromARNResource(arn.Resource) != ""
}
func keyIdFromARNResource(s string) string {
keyIdResourceRegex := regexp.MustCompile(`^key/(` + uuidRegexPattern + `|` + multiRegionKeyIdPattern + `)$`)
matches := keyIdResourceRegex.FindStringSubmatch(s)
if matches == nil || len(matches) != 2 {
return ""
}
return matches[1]
}
func aliasIdFromARNResource(s string) string {
aliasIdResourceRegex := regexp.MustCompile(`^(` + aliasRegexPattern + `)$`)
matches := aliasIdResourceRegex.FindStringSubmatch(s)
if matches == nil || len(matches) != 2 {
return ""
}
return matches[1]
}
type stringValidator func(val string, path cty.Path, diags *tfdiags.Diagnostics)
func validateStringNotEmpty(val string, path cty.Path, diags *tfdiags.Diagnostics) {
val = strings.TrimSpace(val)
if len(val) == 0 {
*diags = diags.Append(attributeErrDiag(
"Invalid Value",
"The value cannot be empty or all whitespace",
path,
))
}
}
func validateStringLenBetween(min, max int) stringValidator {
return func(val string, path cty.Path, diags *tfdiags.Diagnostics) {
if l := len(val); l < min || l > max {
*diags = diags.Append(attributeErrDiag(
"Invalid Value Length",
fmt.Sprintf("Length must be between %d and %d, had %d", min, max, l),
path,
))
}
}
}
func validateStringMatches(re *regexp.Regexp, description string) stringValidator {
return func(val string, path cty.Path, diags *tfdiags.Diagnostics) {
if !re.MatchString(val) {
*diags = diags.Append(attributeErrDiag(
"Invalid Value",
description,
path,
))
}
}
}
func validateStringDoesNotContain(s string) stringValidator {
return func(val string, path cty.Path, diags *tfdiags.Diagnostics) {
if strings.Contains(val, s) {
*diags = diags.Append(attributeErrDiag(
"Invalid Value",
fmt.Sprintf(`Value must not contain "%s"`, s),
path,
))
}
}
}
func validateStringInSlice(sl []string) stringValidator {
return func(val string, path cty.Path, diags *tfdiags.Diagnostics) {
match := false
for _, s := range sl {
if val == s {
match = true
}
}
if !match {
*diags = diags.Append(attributeErrDiag(
"Invalid Value",
fmt.Sprintf("Value must be one of [%s]", strings.Join(sl, ", ")),
path,
))
}
}
}
// validateStringRetryMode ensures the provided value in a valid AWS retry mode
func validateStringRetryMode(val string, path cty.Path, diags *tfdiags.Diagnostics) {
_, err := aws.ParseRetryMode(val)
if err != nil {
*diags = diags.Append(attributeErrDiag(
"Invalid Value",
err.Error(),
path,
))
}
}
// S3 will strip leading slashes from an object, so while this will
// technically be accepted by S3, it will break our workspace hierarchy.
// S3 will recognize objects with a trailing slash as a directory
// so they should not be valid keys
func validateStringS3Path(val string, path cty.Path, diags *tfdiags.Diagnostics) {
if strings.HasPrefix(val, "/") || strings.HasSuffix(val, "/") {
*diags = diags.Append(attributeErrDiag(
"Invalid Value",
`The value must not start or end with "/"`,
path,
))
}
}
func validateARN(validators ...arnValidator) stringValidator {
return func(val string, path cty.Path, diags *tfdiags.Diagnostics) {
parsedARN, err := arn.Parse(val)
if err != nil {
*diags = diags.Append(attributeErrDiag(
"Invalid ARN",
fmt.Sprintf("The value %q cannot be parsed as an ARN: %s", val, err),
path,
))
return
}
for _, validator := range validators {
validator(parsedARN, path, diags)
}
}
}
// Copied from `ValidIAMPolicyJSON` (https://github.com/hashicorp/terraform-provider-aws/blob/ffd1c8a006dcd5a6b58a643df9cc147acb5b7a53/internal/verify/validate.go#L154)
func validateIAMPolicyDocument(val string, path cty.Path, diags *tfdiags.Diagnostics) {
// IAM Policy documents need to be valid JSON, and pass legacy parsing
val = strings.TrimSpace(val)
if first := val[:1]; first != "{" {
switch val[:1] {
case `"`:
// There are some common mistakes that lead to strings appearing
// here instead of objects, so we'll try some heuristics to
// check for those so we might give more actionable feedback in
// these situations.
var content string
var innerContent any
if err := json.Unmarshal([]byte(val), &content); err == nil {
if strings.HasSuffix(content, ".json") {
*diags = diags.Append(attributeErrDiag(
"Invalid IAM Policy Document",
fmt.Sprintf(`Expected a JSON object describing the policy, had a JSON-encoded string.
The string %q looks like a filename, please pass the contents of the file instead of the filename.`,
content,
),
path,
))
return
} else if err := json.Unmarshal([]byte(content), &innerContent); err == nil {
// hint = " (have you double-encoded your JSON data?)"
*diags = diags.Append(attributeErrDiag(
"Invalid IAM Policy Document",
`Expected a JSON object describing the policy, had a JSON-encoded string.
The string content was valid JSON, your policy document may have been double-encoded.`,
path,
))
return
}
}
*diags = diags.Append(attributeErrDiag(
"Invalid IAM Policy Document",
"Expected a JSON object describing the policy, had a JSON-encoded string.",
path,
))
default:
// Generic error for if we didn't find something more specific to say.
*diags = diags.Append(attributeErrDiag(
"Invalid IAM Policy Document",
"Expected a JSON object describing the policy",
path,
))
}
} else {
var j any
if err := json.Unmarshal([]byte(val), &j); err != nil {
errStr := err.Error()
var jsonErr *json.SyntaxError
if errors.As(err, &jsonErr) {
errStr += fmt.Sprintf(", at byte offset %d", jsonErr.Offset)
}
*diags = diags.Append(attributeErrDiag(
"Invalid JSON Document",
fmt.Sprintf("The JSON document contains an error: %s", errStr),
path,
))
}
}
}
func validateStringKMSKey(val string, path cty.Path, diags *tfdiags.Diagnostics) {
ds := validateKMSKey(path, val)
*diags = diags.Append(ds)
}
// validateStringLegacyURL validates that a string can be parsed generally as a URL, but does
// not ensure that the URL is valid.
func validateStringLegacyURL(val string, path cty.Path, diags *tfdiags.Diagnostics) {
u, err := url.Parse(val)
if err != nil {
*diags = diags.Append(attributeErrDiag(
"Invalid Value",
fmt.Sprintf("The value %q cannot be parsed as a URL: %s", val, err),
path,
))
return
}
if u.Scheme == "" || u.Host == "" {
*diags = diags.Append(legacyIncompleteURLDiag(val, path))
return
}
}
func legacyIncompleteURLDiag(val string, path cty.Path) tfdiags.Diagnostic {
return attributeWarningDiag(
"Complete URL Expected",
fmt.Sprintf(`The value should be a valid URL containing at least a scheme and hostname. Had %q.
Using an incomplete URL, such as a hostname only, may work, but may have unexpected behavior.`, val),
path,
)
}
// validateStringValidURL validates that a URL is a valid URL, inclding a scheme and host
func validateStringValidURL(val string, path cty.Path, diags *tfdiags.Diagnostics) {
u, err := url.Parse(val)
if err != nil {
*diags = diags.Append(attributeErrDiag(
"Invalid Value",
fmt.Sprintf("The value %q cannot be parsed as a URL: %s", val, err),
path,
))
return
}
if u.Scheme == "" || u.Host == "" {
*diags = diags.Append(invalidURLDiag(val, path))
return
}
}
func invalidURLDiag(val string, path cty.Path) tfdiags.Diagnostic {
return attributeErrDiag(
"Invalid Value",
fmt.Sprintf("The value must be a valid URL containing at least a scheme and hostname. Had %q", val),
path,
)
}
// Using a val of `cty.ValueSet` would be better here, but we can't get an ElementIterator from a ValueSet
type setValidator func(val cty.Value, path cty.Path, diags *tfdiags.Diagnostics)
func validateSetStringElements(validators ...stringValidator) setValidator {
return func(val cty.Value, path cty.Path, diags *tfdiags.Diagnostics) {
typ := val.Type()
if eltTyp := typ.ElementType(); eltTyp != cty.String {
*diags = diags.Append(attributeErrDiag(
"Internal Error",
fmt.Sprintf(`Expected type to be %s, got: %s`, cty.Set(cty.String).FriendlyName(), val.Type().FriendlyName()),
path,
))
return
}
eltPath := make(cty.Path, len(path)+1)
copy(eltPath, path)
idxIdx := len(path)
iter := val.ElementIterator()
for iter.Next() {
idx, elt := iter.Element()
eltPath[idxIdx] = cty.IndexStep{Key: idx}
for _, validator := range validators {
validator(elt.AsString(), eltPath, diags)
}
}
}
}
type arnValidator func(val arn.ARN, path cty.Path, diags *tfdiags.Diagnostics)
func validateIAMRoleARN(val arn.ARN, path cty.Path, diags *tfdiags.Diagnostics) {
if !strings.HasPrefix(val.Resource, "role/") {
*diags = diags.Append(attributeErrDiag(
"Invalid IAM Role ARN",
fmt.Sprintf("Value must be a valid IAM Role ARN, got %q", val),
path,
))
}
}
func validateIAMPolicyARN(val arn.ARN, path cty.Path, diags *tfdiags.Diagnostics) {
if !strings.HasPrefix(val.Resource, "policy/") {
*diags = diags.Append(attributeErrDiag(
"Invalid IAM Policy ARN",
fmt.Sprintf("Value must be a valid IAM Policy ARN, got %q", val),
path,
))
}
}
func validateDuration(validators ...durationValidator) stringValidator {
return func(val string, path cty.Path, diags *tfdiags.Diagnostics) {
duration, err := time.ParseDuration(val)
if err != nil {
*diags = diags.Append(attributeErrDiag(
"Invalid Duration",
fmt.Sprintf("The value %q cannot be parsed as a duration: %s", val, err),
path,
))
return
}
for _, validator := range validators {
validator(duration, path, diags)
}
}
}
type durationValidator func(val time.Duration, path cty.Path, diags *tfdiags.Diagnostics)
func validateDurationBetween(min, max time.Duration) durationValidator {
return func(val time.Duration, path cty.Path, diags *tfdiags.Diagnostics) {
if val < min || val > max {
*diags = diags.Append(attributeErrDiag(
"Invalid Duration",
fmt.Sprintf("Duration must be between %s and %s, had %s", min, max, val),
path,
))
}
}
}
type objectValidator func(obj cty.Value, objPath cty.Path, diags *tfdiags.Diagnostics)
func validateAttributesConflict(paths ...cty.Path) objectValidator {
return func(obj cty.Value, objPath cty.Path, diags *tfdiags.Diagnostics) {
found := false
for _, path := range paths {
val, err := path.Apply(obj)
if err != nil {
*diags = diags.Append(attributeErrDiag(
"Invalid Path for Schema",
"The S3 Backend unexpectedly provided a path that does not match the schema. "+
"Please report this to the developers.\n\n"+
"Path: "+pathString(path)+"\n\n"+
"Error:"+err.Error(),
objPath,
))
continue
}
if !val.IsNull() {
if found {
pathStrs := make([]string, len(paths))
for i, path := range paths {
pathStrs[i] = pathString(path)
}
*diags = diags.Append(invalidAttributeCombinationDiag(objPath, paths))
} else {
found = true
}
}
}
}
}
func validateExactlyOneOfAttributes(paths ...cty.Path) objectValidator {
return func(obj cty.Value, objPath cty.Path, diags *tfdiags.Diagnostics) {
var localDiags tfdiags.Diagnostics
found := make(map[string]cty.Path, len(paths))
for _, path := range paths {
val, err := path.Apply(obj)
if err != nil {
localDiags = localDiags.Append(attributeErrDiag(
"Invalid Path for Schema",
"The S3 Backend unexpectedly provided a path that does not match the schema. "+
"Please report this to the developers.\n\n"+
"Path: "+pathString(path)+"\n\n"+
"Error:"+err.Error(),
objPath,
))
continue
}
if !val.IsNull() {
found[pathString(path)] = path
}
}
*diags = diags.Append(localDiags)
if len(found) > 1 {
*diags = diags.Append(invalidAttributeCombinationDiag(objPath, paths))
return
}
if len(found) == 0 && !localDiags.HasErrors() {
pathStrs := make([]string, len(paths))
for i, path := range paths {
pathStrs[i] = pathString(path)
}
*diags = diags.Append(attributeErrDiag(
"Missing Required Value",
fmt.Sprintf(`Exactly one of %s must be set.`, strings.Join(pathStrs, ", ")),
objPath,
))
}
}
}
func invalidAttributeCombinationDiag(objPath cty.Path, paths []cty.Path) tfdiags.Diagnostic {
pathStrs := make([]string, len(paths))
for i, path := range paths {
pathStrs[i] = pathString(path)
}
return attributeErrDiag(
"Invalid Attribute Combination",
fmt.Sprintf(`Only one of %s can be set.`, strings.Join(pathStrs, ", ")),
objPath,
)
}
func attributeErrDiag(summary, detail string, attrPath cty.Path) tfdiags.Diagnostic {
return tfdiags.AttributeValue(tfdiags.Error, summary, detail, attrPath.Copy())
}
func attributeWarningDiag(summary, detail string, attrPath cty.Path) tfdiags.Diagnostic {
return tfdiags.AttributeValue(tfdiags.Warning, summary, detail, attrPath.Copy())
}
func wholeBodyErrDiag(summary, detail string) tfdiags.Diagnostic {
return tfdiags.WholeContainingBody(tfdiags.Error, summary, detail)
}
func wholeBodyWarningDiag(summary, detail string) tfdiags.Diagnostic {
return tfdiags.WholeContainingBody(tfdiags.Warning, summary, detail)
}
var assumeRoleNameValidator = []stringValidator{
validateStringLenBetween(2, 64),
validateStringMatches(
regexp.MustCompile(`^[\w+=,.@\-]*$`),
`Value can only contain letters, numbers, or the following characters: =,.@-`,
),
}