diff --git a/pkg/secrets/pushtofile/secret_group.go b/pkg/secrets/pushtofile/secret_group.go index 0351dfb4b..bd69aca85 100644 --- a/pkg/secrets/pushtofile/secret_group.go +++ b/pkg/secrets/pushtofile/secret_group.go @@ -15,21 +15,27 @@ const secretGroupFileFormatPrefix = "conjur.org/secret-file-format." const defaultFilePermissions os.FileMode = 0664 +// Secret represents an application secret that has been retrieved from +// Conjur. type Secret struct { Alias string Value string } +// SecretGroup incorporates all of the information about a secret group +// that has been parsed from that secret group's Annotations. type SecretGroup struct { - Name string - FilePath string - FileTemplate string - FileFormat string - PolicyPathPrefix string - FilePermissions os.FileMode - SecretSpecs []SecretSpec + Name string + FilePath string + FileTemplate string + FileFormat string + PolicyPathPrefix string + FilePermissions os.FileMode + SecretSpecs []SecretSpec } +// ResolvedSecretSpecs resolves all of the secret paths for a secret +// group by prepending each path with that group's policy path prefix. func (s *SecretGroup) ResolvedSecretSpecs() []SecretSpec { if len(s.PolicyPathPrefix) == 0 { return s.SecretSpecs @@ -161,49 +167,15 @@ func NewSecretGroups(annotations map[string]string) ([]*SecretGroup, []error) { var sgs []*SecretGroup var errors []error - for k, v := range annotations { - if strings.HasPrefix(k, secretGroupPrefix) { - groupName := strings.TrimPrefix(k, secretGroupPrefix) - secretSpecs, err := NewSecretSpecs([]byte(v)) - if err != nil { - // Accumulate errors - err = fmt.Errorf( - `cannot create secret specs from annotation "%s": %s`, - k, - err, - ) - errors = append(errors, err) + for key := range annotations { + if strings.HasPrefix(key, secretGroupPrefix) { + groupName := strings.TrimPrefix(key, secretGroupPrefix) + sg, errs := newSecretGroup(annotations, groupName) + if errs != nil { + errors = append(errors, errs...) continue } - - fileTemplate := annotations[secretGroupFileTemplatePrefix+groupName] - filePath := annotations[secretGroupFilePathPrefix+groupName] - fileFormat := annotations[secretGroupFileFormatPrefix+groupName] - policyPathPrefix := annotations[secretGroupPolicyPathPrefix+groupName] - - if len(fileFormat) > 0 { - _, err := FileTemplateForFormat(fileFormat, secretSpecs) - if err != nil { - // Accumulate errors - err = fmt.Errorf( - `unable to process file format annotation %q for group: %s`, - fileFormat, - err, - ) - errors = append(errors, err) - continue - } - } - - sgs = append(sgs, &SecretGroup{ - Name: groupName, - FilePath: filePath, - FileTemplate: fileTemplate, - FileFormat: fileFormat, - FilePermissions: defaultFilePermissions, - PolicyPathPrefix: policyPathPrefix, - SecretSpecs: secretSpecs, - }) + sgs = append(sgs, sg) } } @@ -218,3 +190,47 @@ func NewSecretGroups(annotations map[string]string) ([]*SecretGroup, []error) { return sgs, nil } + +func newSecretGroup(annotations map[string]string, groupName string) (*SecretGroup, []error) { + groupSecrets := annotations[secretGroupPrefix+groupName] + secretSpecs, err := NewSecretSpecs([]byte(groupSecrets)) + if err != nil { + err = fmt.Errorf( + `cannot create secret specs from annotation "%s": %s`, + secretGroupPrefix+groupName, + err, + ) + return nil, []error{err} + + } + if errors := validateSecretPaths(secretSpecs, groupName); err != nil { + return nil, errors + } + + fileTemplate := annotations[secretGroupFileTemplatePrefix+groupName] + filePath := annotations[secretGroupFilePathPrefix+groupName] + fileFormat := annotations[secretGroupFileFormatPrefix+groupName] + policyPathPrefix := annotations[secretGroupPolicyPathPrefix+groupName] + + if len(fileFormat) > 0 { + _, err := FileTemplateForFormat(fileFormat, secretSpecs) + if err != nil { + err = fmt.Errorf( + `unable to process file format annotation %q for group: %s`, + fileFormat, + err, + ) + return nil, []error{err} + } + } + + return &SecretGroup{ + Name: groupName, + FilePath: filePath, + FileTemplate: fileTemplate, + FileFormat: fileFormat, + FilePermissions: defaultFilePermissions, + PolicyPathPrefix: policyPathPrefix, + SecretSpecs: secretSpecs, + }, nil +} diff --git a/pkg/secrets/pushtofile/secret_spec.go b/pkg/secrets/pushtofile/secret_spec.go index 40aa5efb5..14e0fd144 100644 --- a/pkg/secrets/pushtofile/secret_spec.go +++ b/pkg/secrets/pushtofile/secret_spec.go @@ -7,11 +7,19 @@ import ( "gopkg.in/yaml.v3" ) +const ( + maxConjurVarNameLen = 126 +) + +// SecretSpec specifies a secret to be retrieved from Conjur by defining +// its alias (i.e. the name of the secret from an application's perspective) +// and its variable path in Conjur. type SecretSpec struct { Alias string Path string } +// MarshalYAML is a custom marshaller for SecretSpec. func (t SecretSpec) MarshalYAML() (interface{}, error) { return map[string]string{t.Alias: t.Path}, nil } @@ -68,6 +76,8 @@ func (t *SecretSpec) unmarshalFromMap(node *yaml.Node) error { return nil } +// NewSecretSpecs creates a slice of SecretSpec structs by unmarshalling +// a YAML representation of secret specifications. func NewSecretSpecs(raw []byte) ([]SecretSpec, error) { var secretSpecs []SecretSpec err := yaml.Unmarshal(raw, &secretSpecs) @@ -77,3 +87,36 @@ func NewSecretSpecs(raw []byte) ([]SecretSpec, error) { return secretSpecs, nil } + +func validateSecretPaths(secretSpecs []SecretSpec, groupName string) []error { + var errors []error + for _, secretSpec := range secretSpecs { + if err := validateSecretPath(secretSpec.Path, groupName); err != nil { + errors = append(errors, err) + } + } + return errors +} + +func validateSecretPath(path, groupName string) error { + // The Conjur variable path must not be null + if path == "" { + return fmt.Errorf("Secret group %s: null Conjur variable path", groupName) + } + + // The Conjur variable path must not end with slash character + varName := path[strings.LastIndex(path, "/")+1:] + if varName == "" { + return fmt.Errorf("Secret group %s: the Conjur variable path '%s' has a trailing '/'", + groupName, path) + } + + // The Conjur variable name (the last word in the Conjur variable path) + // must be no longer than 126 characters. + if len(varName) > maxConjurVarNameLen { + return fmt.Errorf("Secret group %s: the Conjur variable name '%s' is longer than %d characters", + groupName, varName, maxConjurVarNameLen) + } + + return nil +} diff --git a/pkg/secrets/pushtofile/secret_spec_test.go b/pkg/secrets/pushtofile/secret_spec_test.go index 1e9701d66..dec1c2242 100644 --- a/pkg/secrets/pushtofile/secret_spec_test.go +++ b/pkg/secrets/pushtofile/secret_spec_test.go @@ -1,11 +1,18 @@ package pushtofile import ( + "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" ) +const ( + validConjurPath1 = "valid/conjur/variable/path" + validConjurPath2 = "another/valid/conjur/variable/path" +) + type secretsSpecTestCase struct { description string contents string @@ -19,7 +26,7 @@ func (tc secretsSpecTestCase) Run(t *testing.T) { }) } -func assertGoodSecretSpecs(expectedResult []SecretSpec) func (*testing.T, []SecretSpec, error) { +func assertGoodSecretSpecs(expectedResult []SecretSpec) func(*testing.T, []SecretSpec, error) { return func(t *testing.T, result []SecretSpec, err error) { if !assert.NoError(t, err) { return @@ -43,12 +50,12 @@ var secretsSpecTestCases = []secretsSpecTestCase{ assert: assertGoodSecretSpecs( []SecretSpec{ { - Path: "dev/openshift/api-url", Alias: "api-url", + Path: "dev/openshift/api-url", }, { - Path: "dev/openshift/password", Alias: "admin-password", + Path: "dev/openshift/password", }, }, ), @@ -107,3 +114,88 @@ func TestNewSecretSpecs(t *testing.T) { tc.Run(t) } } + +func generateLongName(len int, char byte) string { + var b strings.Builder + b.Grow(len) + for i := 0; i < len; i++ { + b.WriteByte(byte(char)) + } + return b.String() +} + +func TestValidateSecretSpecPaths(t *testing.T) { + repeatChar := []byte("a")[0] + maxLenConjurVarName := generateLongName(maxConjurVarNameLen, repeatChar) + + type assertFunc func(*testing.T, []error, string) + + assertNoErrors := func() assertFunc { + return func(t *testing.T, errors []error, desc string) { + assert.Len(t, errors, 0, desc) + } + } + + assertErrorsContain := func(expErrStrs ...string) assertFunc { + return func(t *testing.T, errors []error, desc string) { + assert.Len(t, errors, len(expErrStrs), desc) + for i, expErrStr := range expErrStrs { + assert.Contains(t, errors[i].Error(), expErrStr, desc) + } + } + } + + testCases := []struct { + description string + path1 string + path2 string + assert assertFunc + }{ + { + "valid Conjur paths", + validConjurPath1, + validConjurPath2, + assertNoErrors(), + }, { + "null Conjur path and valid Conjur path", + "", + validConjurPath2, + assertErrorsContain("null Conjur variable path"), + }, { + "Conjur path with trailing '/' and valid Conjur path", + validConjurPath1 + "/", + validConjurPath2, + assertErrorsContain("has a trailing '/'"), + }, { + "Conjur path with max len var name and valid Conjur path", + validConjurPath1 + "/" + maxLenConjurVarName, + validConjurPath2, + assertNoErrors(), + }, { + "Conjur path with oversized var name and valid Conjur path", + validConjurPath1 + "/" + maxLenConjurVarName + "a", + validConjurPath2, + assertErrorsContain(fmt.Sprintf( + "is longer than %d characters", maxConjurVarNameLen)), + }, { + "Two Conjur paths with trailing '/'", + validConjurPath1 + "/", + validConjurPath2 + "/", + assertErrorsContain("has a trailing '/'", "has a trailing '/'"), + }, + } + + for _, tc := range testCases { + // Set up test case + secretSpecs := []SecretSpec{ + {Alias: "foo", Path: tc.path1}, + {Alias: "bar", Path: tc.path2}, + } + + // Run test case + err := validateSecretPaths(secretSpecs, "some-group-name") + + // Check result + tc.assert(t, err, tc.description) + } +} diff --git a/pkg/secrets/pushtofile/standard_templates.go b/pkg/secrets/pushtofile/standard_templates.go index 5f5d6977b..e0938e87c 100644 --- a/pkg/secrets/pushtofile/standard_templates.go +++ b/pkg/secrets/pushtofile/standard_templates.go @@ -17,12 +17,10 @@ func (s standardTemplate) ValidateAlias(alias string) error { } var standardTemplates = map[string]standardTemplate{ - "yaml": {template: yamlTemplate, validateAlias: func(alias string) error { - return nil - }}, - "json": {template: jsonTemplate}, - "dotenv": {template: dotenvTemplate}, - "bash": {template: bashTemplate}, + "yaml": {template: yamlTemplate, validateAlias: validateYAMLKey}, + "json": {template: jsonTemplate, validateAlias: validateJSONKey}, + "dotenv": {template: dotenvTemplate, validateAlias: validateBashVarName}, + "bash": {template: bashTemplate, validateAlias: validateBashVarName}, } // FileTemplateForFormat returns the template for a file format, after ensuring the diff --git a/pkg/secrets/pushtofile/standard_templates_test.go b/pkg/secrets/pushtofile/standard_templates_test.go index 7840044e2..d89a0fdb4 100644 --- a/pkg/secrets/pushtofile/standard_templates_test.go +++ b/pkg/secrets/pushtofile/standard_templates_test.go @@ -1,47 +1,74 @@ package pushtofile import ( + "fmt" "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + invalidYAMLChar = "invalid YAML character" + invalidJSONChar = "invalid JSON character" + yamlAliasTooLong = "too long for YAML" + jsonAliasTooLong = "too long for JSON" + invalidBashVarName = "Must be alphanumerics and underscores" + validConjurPath = "valid/conjur/variable/path" ) +type assertErrorFunc func(*testing.T, error, string) + +func assertNoError() assertErrorFunc { + return func(t *testing.T, err error, desc string) { + assert.NoError(t, err, desc) + } +} + +func assertErrorContains(expErrStr string) assertErrorFunc { + return func(t *testing.T, err error, desc string) { + assert.Error(t, err, desc) + assert.Contains(t, err.Error(), expErrStr, desc) + } +} + var standardTemplateTestCases = []pushToWriterTestCase{ { description: "json", - template: standardTemplates["json"].template, + template: standardTemplates["json"].template, secrets: []*Secret{ {Alias: "alias 1", Value: "secret value 1"}, {"alias 2", "secret value 2"}, }, - assert: assertGoodOutput(`{"alias 1":"secret value 1","alias 2":"secret value 2"}`), + assert: assertGoodOutput(`{"alias 1":"secret value 1","alias 2":"secret value 2"}`), }, { description: "yaml", - template: standardTemplates["yaml"].template, + template: standardTemplates["yaml"].template, secrets: []*Secret{ {Alias: "alias 1", Value: "secret value 1"}, {"alias 2", "secret value 2"}, }, - assert: assertGoodOutput(`"alias 1": "secret value 1" + assert: assertGoodOutput(`"alias 1": "secret value 1" "alias 2": "secret value 2"`), }, { description: "dotenv", - template: standardTemplates["dotenv"].template, + template: standardTemplates["dotenv"].template, secrets: []*Secret{ {Alias: "alias1", Value: "secret value 1"}, {"alias2", "secret value 2"}, }, - assert: assertGoodOutput(`alias1="secret value 1" + assert: assertGoodOutput(`alias1="secret value 1" alias2="secret value 2"`), }, { description: "bash", - template: standardTemplates["bash"].template, + template: standardTemplates["bash"].template, secrets: []*Secret{ {Alias: "alias1", Value: "secret value 1"}, {"alias2", "secret value 2"}, }, - assert: assertGoodOutput(`export alias1="secret value 1" + assert: assertGoodOutput(`export alias1="secret value 1" export alias2="secret value 2"`), }, } @@ -51,3 +78,195 @@ func Test_standardTemplates(t *testing.T) { tc.Run(t) } } + +type aliasCharTestCase struct { + description string + testChar rune + assert assertErrorFunc +} + +func (tc *aliasCharTestCase) Run(t *testing.T, fileFormat string) { + t.Run(tc.description, func(t *testing.T) { + // Set up test case + desc := fmt.Sprintf("%s file format, key containing %s character", + fileFormat, tc.description) + alias := "key_containing_" + string(tc.testChar) + "_character" + secretSpecs := []SecretSpec{{Alias: alias, Path: validConjurPath}} + + // Run test case + _, err := FileTemplateForFormat(fileFormat, secretSpecs) + + // Check result + tc.assert(t, err, desc) + }) +} + +type aliasLenTestCase struct { + description string + alias string + assert assertErrorFunc +} + +func (tc *aliasLenTestCase) Run(t *testing.T, fileFormat string) { + t.Run(tc.description, func(t *testing.T) { + // Set up test case + desc := fmt.Sprintf("%s file format, %s", fileFormat, tc.description) + secretSpecs := []SecretSpec{{Alias: tc.alias, Path: validConjurPath}} + + // Run test case + _, err := FileTemplateForFormat(fileFormat, secretSpecs) + + // Check result + tc.assert(t, err, desc) + }) +} + +func TestValidateAliasForYAML(t *testing.T) { + testValidateAliasCharForYAML(t) + testValidateAliasLenForYAML(t) +} + +func testValidateAliasCharForYAML(t *testing.T) { + testCases := []aliasCharTestCase{ + // YAML file format, 8-bit characters + {"printable ASCII", '\u003F', assertNoError()}, + {"heart emoji", '💙', assertNoError()}, + {"dog emoji", '🐶', assertNoError()}, + {"ASCII NULL", '\u0000', assertErrorContains(invalidYAMLChar)}, + {"ASCII BS", '\u0008', assertErrorContains(invalidYAMLChar)}, + {"ASCII tab", '\u0009', assertNoError()}, + {"ASCII LF", '\u000A', assertNoError()}, + {"ASCII VT", '\u000B', assertErrorContains(invalidYAMLChar)}, + {"ASCII CR", '\u000D', assertNoError()}, + {"ASCII space", '\u0020', assertNoError()}, + {"ASCII tilde", '\u007E', assertNoError()}, + {"ASCII DEL", '\u007F', assertErrorContains(invalidYAMLChar)}, + // YAML file format, 16-bit Unicode + {"Unicode NEL", '\u0085', assertNoError()}, + {"Unicode 0x86", '\u0086', assertErrorContains(invalidYAMLChar)}, + {"Unicode 0x9F", '\u009F', assertErrorContains(invalidYAMLChar)}, + {"Unicode 0xA0", '\u00A0', assertNoError()}, + {"Unicode 0xD7FF", '\uD7FF', assertNoError()}, + {"Unicode 0xE000", '\uE000', assertNoError()}, + {"Unicode 0xFFFD", '\uFFFD', assertNoError()}, + {"Unicode 0xFFFE", '\uFFFE', assertErrorContains(invalidYAMLChar)}, + // YAML file format, 32-bit Unicode + {"Unicode 0x10000", '\U00010000', assertNoError()}, + {"Unicode 0x10FFFF", '\U0010FFFF', assertNoError()}, + } + + for _, tc := range testCases { + tc.Run(t, "yaml") + } +} + +func testValidateAliasLenForYAML(t *testing.T) { + repeatChar := []byte("a")[0] + maxLenAlias := generateLongName(maxYAMLKeyLen, repeatChar) + + testCases := []aliasLenTestCase{ + {"single char alias", "a", assertNoError()}, + {"maximum length alias", maxLenAlias, assertNoError()}, + {"oversized alias", maxLenAlias + "a", assertErrorContains(yamlAliasTooLong)}, + } + + for _, tc := range testCases { + tc.Run(t, "yaml") + } +} + +// JSON file format +//{"json", "single-char alias", "a", ""}, +//{"json", "maximum length alias", maxLenJSONAlias, ""}, +//{"json", "oversized alias", maxLenJSONAlias + "a", jsonAliasTooLong}, + +func TestValidateAliasForJSON(t *testing.T) { + testValidateAliasCharForJSON(t) + testValidateAliasLenForJSON(t) +} + +func testValidateAliasCharForJSON(t *testing.T) { + testCases := []aliasCharTestCase{ + // JSON file format, valid characters + {"ASCII space", '\u0020', assertNoError()}, + {"ASCII tilde", '~', assertNoError()}, + {"heart emoji", '💙', assertNoError()}, + {"dog emoji", '🐶', assertNoError()}, + {"Unicode 0x10000", '\U00010000', assertNoError()}, + {"Unicode 0x10FFFF", '\U0010FFFF', assertNoError()}, + // JSON file format, invalid characters + {"ASCII NUL", '\u0000', assertErrorContains(invalidJSONChar)}, + {"ASCII 0x1F", '\u001F', assertErrorContains(invalidJSONChar)}, + {"ASCII NULL", '\u0000', assertErrorContains(invalidJSONChar)}, + {"ASCII BS", '\u0008', assertErrorContains(invalidJSONChar)}, + {"ASCII tab", '\u0009', assertErrorContains(invalidJSONChar)}, + {"ASCII LF", '\u000A', assertErrorContains(invalidJSONChar)}, + {"ASCII VT", '\u000B', assertErrorContains(invalidJSONChar)}, + {"ASCII DEL", '\u007F', assertErrorContains(invalidJSONChar)}, + {"ASCII quote", '"', assertErrorContains(invalidJSONChar)}, + {"ASCII backslash", '\\', assertErrorContains(invalidJSONChar)}, + } + + for _, tc := range testCases { + tc.Run(t, "json") + } +} + +func testValidateAliasLenForJSON(t *testing.T) { + repeatChar := []byte("a")[0] + maxLenAlias := generateLongName(maxJSONKeyLen, repeatChar) + + testCases := []aliasLenTestCase{ + {"single-char alias", "a", assertNoError()}, + {"maximum length alias", maxLenAlias, assertNoError()}, + {"oversized alias", maxLenAlias + "a", assertErrorContains(jsonAliasTooLong)}, + } + + for _, tc := range testCases { + tc.Run(t, "json") + } +} + +func TestValidateAliasForBash(t *testing.T) { + testValidateAliasForBashOrDotenv(t, "bash") +} + +func TestValidateAliasForDotenv(t *testing.T) { + testValidateAliasForBashOrDotenv(t, "dotenv") +} + +func testValidateAliasForBashOrDotenv(t *testing.T, fileFormat string) { + testCases := []struct { + description string + alias string + assert assertErrorFunc + }{ + // Bash file format, valid aliases + {"all lower case chars", "foobar", assertNoError()}, + {"all upper case chars", "FOOBAR", assertNoError()}, + {"upper case, lower case, and underscores", "_Foo_Bar_", assertNoError()}, + {"leading underscore with digits", "_12345", assertNoError()}, + {"upper case, lower case, underscores, digits", "_Foo_Bar_1234", assertNoError()}, + + // Bash file format, invalid aliases + {"leading digit", "7th_Heaven", assertErrorContains(invalidBashVarName)}, + {"spaces", "FOO BAR", assertErrorContains(invalidBashVarName)}, + {"dashes", "FOO-BAR", assertErrorContains(invalidBashVarName)}, + {"single quotes", "FOO_'BAR'", assertErrorContains(invalidBashVarName)}, + {"dog emoji", "FOO_'🐶'_BAR", assertErrorContains(invalidBashVarName)}, + {"trailing space", "FOO_BAR ", assertErrorContains(invalidBashVarName)}, + } + + for _, tc := range testCases { + // Set up test case + desc := fmt.Sprintf("%s file format, alias with %s", + fileFormat, tc.description) + secretSpecs := []SecretSpec{{Alias: tc.alias, Path: validConjurPath}} + + // Run test case + _, err := FileTemplateForFormat(fileFormat, secretSpecs) + + // Check result + tc.assert(t, err, desc) + } +} diff --git a/pkg/secrets/pushtofile/standard_templates_validate.go b/pkg/secrets/pushtofile/standard_templates_validate.go new file mode 100644 index 000000000..ff468e3c3 --- /dev/null +++ b/pkg/secrets/pushtofile/standard_templates_validate.go @@ -0,0 +1,90 @@ +package pushtofile + +import ( + "fmt" + "regexp" +) + +const ( + maxYAMLKeyLen = 1024 + maxJSONKeyLen = 2097152 +) + +func validateYAMLKey(key string) error { + if len(key) > maxYAMLKeyLen { + return fmt.Errorf("the key '%s' is too long for YAML", key) + } + for _, c := range key { + if !isValidYAMLChar(c) { + return fmt.Errorf("invalid YAML character: '%c'", c) + } + } + return nil +} + +func isValidYAMLChar(c rune) bool { + // Checks whether a character is in the YAML valid character set as + // defined here: https://yaml.org/spec/1.2.2/#51-character-set + switch { + case c == '\u0009': + return true // tab + case c == '\u000A': + return true // LF + case c == '\u000D': + return true // CR + case c >= '\u0020' && c <= '\u007E': + return true // Printable ASCII + case c == '\u0085': + return true // Next Line (NEL) + case c >= '\u00A0' && c <= '\uD7FF': + return true // Basic Multilingual Plane (BMP) + case c >= '\uE000' && c <= '\uFFFD': + return true // Additional Unicode Areas + case c >= '\U00010000' && c <= '\U0010FFFF': + return true // 32 bit + default: + return false + } +} + +func validateJSONKey(key string) error { + if len(key) > maxJSONKeyLen { + return fmt.Errorf("the key '%s' is too long for JSON", key) + } + for _, c := range key { + if !isValidJSONChar(c) { + return fmt.Errorf("invalid JSON character: '%c'", c) + } + } + return nil +} + +func isValidJSONChar(c rune) bool { + // Checks whether a character is in the JSON valid character set as + // defined here: https://www.json.org/json-en.html + // This document specifies that any characters are valid except: + // - Control characters (0x00-0x1F and 0x7f [DEL]) + // - Double quote (") + // - Backslash (\) + switch { + case c >= '\u0000' && c <= '\u001F': + return false // Control characters other than DEL + case c == '\u007F': + return false // DEL + case c == rune('"'): + return false // Double quote + case c == rune('\\'): + return false // Backslash + default: + return true + } +} + +func validateBashVarName(name string) error { + r := regexp.MustCompile("^[a-zA-Z_][a-zA-Z0-9_]*$") + if !r.MatchString(name) { + explanation := "Must be alphanumerics and underscores, with first char being a non-digit" + return fmt.Errorf("invalid alias '%s': %s", name, explanation) + } + return nil +}