feat: add user secrets file parser and shared validator (PLAT-240) (#26723)

Part of the **PLAT-240** bulk secret import stack: this PR adds the
`codersdk` parser and shared create-secret validator used by the
follow-up batch endpoint and UI PRs.

`ParseSecretsFile` parses `.env`, `.json`, and `.yaml` files into
`CreateUserSecretRequest` entries in source order, with size, count,
duplicate-key, structure, and malformed-input checks.
`ValidateCreateUserSecretRequest` now backs the single-create handler
too, so create validation has one SDK-level implementation.

Part of https://linear.app/codercom/issue/PLAT-240

> This PR was generated by Coder Agents on behalf of
@dylanhuff-at-coder.
This commit is contained in:
dylanhuff-at-coder
2026-07-22 06:23:38 -07:00
committed by GitHub
parent ff71bb3850
commit 99e740bdb7
6 changed files with 856 additions and 31 deletions
+10 -31
View File
@@ -20,11 +20,6 @@ import (
)
const (
userSecretNameField = "name"
userSecretValueField = "value"
userSecretEnvNameField = "env_name"
userSecretFilePathField = "file_path"
// These names are raised by the enforce_user_secrets_per_user_limits
// trigger with USING CONSTRAINT. They are not table CHECK
// constraints, so dbgen does not emit them in check_constraint.go.
@@ -62,7 +57,7 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
return
}
if validations := createUserSecretValidationErrors(req); len(validations) > 0 {
if validations := codersdk.ValidateCreateUserSecretRequest(req); len(validations) > 0 {
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations)
return
}
@@ -133,7 +128,7 @@ func (api *API) getUserSecrets(rw http.ResponseWriter, r *http.Request) { //noli
func (api *API) getUserSecret(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route.
ctx := r.Context()
user := httpmw.UserParam(r)
name := chi.URLParam(r, userSecretNameField)
name := chi.URLParam(r, codersdk.UserSecretNameField)
secret, err := api.Database.GetUserSecretByUserIDAndName(ctx, database.GetUserSecretByUserIDAndNameParams{
UserID: user.ID,
@@ -169,7 +164,7 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
name = chi.URLParam(r, userSecretNameField)
name = chi.URLParam(r, codersdk.UserSecretNameField)
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.UserSecret](rw, &audit.RequestParams{
Audit: *auditor,
@@ -284,7 +279,7 @@ func (api *API) deleteUserSecret(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
name = chi.URLParam(r, userSecretNameField)
name = chi.URLParam(r, codersdk.UserSecretNameField)
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.UserSecret](rw, &audit.RequestParams{
Audit: *auditor,
@@ -322,32 +317,16 @@ func writeUserSecretValidationErrors(ctx context.Context, rw http.ResponseWriter
})
}
func createUserSecretValidationErrors(req codersdk.CreateUserSecretRequest) []codersdk.ValidationError {
var validations []codersdk.ValidationError
validations = appendUserSecretValidationError(validations, userSecretNameField, codersdk.UserSecretNameValid(req.Name))
if req.Value == "" {
validations = append(validations, codersdk.ValidationError{
Field: userSecretValueField,
Detail: "Value is required.",
})
} else {
validations = appendUserSecretValidationError(validations, userSecretValueField, codersdk.UserSecretValueValid(req.Value))
}
validations = appendUserSecretValidationError(validations, userSecretEnvNameField, codersdk.UserSecretEnvNameValid(req.EnvName))
validations = appendUserSecretValidationError(validations, userSecretFilePathField, codersdk.UserSecretFilePathValid(req.FilePath))
return validations
}
func updateUserSecretValidationErrors(req codersdk.UpdateUserSecretRequest) []codersdk.ValidationError {
var validations []codersdk.ValidationError
if req.Value != nil {
validations = appendUserSecretValidationError(validations, userSecretValueField, codersdk.UserSecretValueValid(*req.Value))
validations = appendUserSecretValidationError(validations, codersdk.UserSecretValueField, codersdk.UserSecretValueValid(*req.Value))
}
if req.EnvName != nil {
validations = appendUserSecretValidationError(validations, userSecretEnvNameField, codersdk.UserSecretEnvNameValid(*req.EnvName))
validations = appendUserSecretValidationError(validations, codersdk.UserSecretEnvNameField, codersdk.UserSecretEnvNameValid(*req.EnvName))
}
if req.FilePath != nil {
validations = appendUserSecretValidationError(validations, userSecretFilePathField, codersdk.UserSecretFilePathValid(*req.FilePath))
validations = appendUserSecretValidationError(validations, codersdk.UserSecretFilePathField, codersdk.UserSecretFilePathValid(*req.FilePath))
}
return validations
}
@@ -404,17 +383,17 @@ func userSecretConflictValidationErrors(err error) []codersdk.ValidationError {
switch {
case database.IsUniqueViolation(err, database.UniqueUserSecretsUserNameIndex):
return []codersdk.ValidationError{{
Field: userSecretNameField,
Field: codersdk.UserSecretNameField,
Detail: "name already in use",
}}
case database.IsUniqueViolation(err, database.UniqueUserSecretsUserEnvNameIndex):
return []codersdk.ValidationError{{
Field: userSecretEnvNameField,
Field: codersdk.UserSecretEnvNameField,
Detail: "environment variable already in use",
}}
case database.IsUniqueViolation(err, database.UniqueUserSecretsUserFilePathIndex):
return []codersdk.ValidationError{{
Field: userSecretFilePathField,
Field: codersdk.UserSecretFilePathField,
Detail: "file path already in use",
}}
default:
+349
View File
@@ -0,0 +1,349 @@
package codersdk
import (
"encoding/json"
"errors"
"io"
"strings"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
// SecretsFileFormat identifies the on-disk format of a secrets file.
type SecretsFileFormat string
const (
// SecretsFileFormatEnv is a dotenv-style file of KEY=VALUE lines.
SecretsFileFormatEnv SecretsFileFormat = "env"
// SecretsFileFormatJSON is a flat JSON object of string values.
SecretsFileFormatJSON SecretsFileFormat = "json"
// SecretsFileFormatYAML is a flat YAML mapping of string values.
SecretsFileFormatYAML SecretsFileFormat = "yaml"
)
// MaxSecretsFileBytes bounds the raw size of a secrets file before parsing.
const MaxSecretsFileBytes = 1 << 20 // 1 MiB
type secretEntry struct {
key string
value string
line int
}
// ParseSecretsFile parses a secrets file into CreateUserSecretRequests.
// It checks structure and duplicate keys; per-entry validation is left to
// ValidateCreateUserSecretRequest.
func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSecretRequest, error) {
if len(content) > MaxSecretsFileBytes {
return nil, xerrors.Errorf("secrets file exceeds the maximum allowed size of %d bytes", MaxSecretsFileBytes)
}
switch format {
case SecretsFileFormatEnv, SecretsFileFormatJSON, SecretsFileFormatYAML:
case "":
return nil, xerrors.New("a secrets file format is required")
default:
return nil, xerrors.Errorf("unknown secrets file format %q", format)
}
if strings.TrimSpace(content) == "" {
return nil, xerrors.New("no secrets found in file")
}
var (
entries []secretEntry
err error
)
switch format {
case SecretsFileFormatEnv:
entries, err = parseEnvSecrets(content)
case SecretsFileFormatJSON:
entries, err = parseJSONSecrets(content)
case SecretsFileFormatYAML:
entries, err = parseYAMLSecrets(content)
}
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, xerrors.New("no secrets found in file")
}
if len(entries) > MaxUserSecretsPerUserCount {
return nil, xerrors.Errorf("secrets file contains %d secrets, which exceeds the maximum of %d secrets per user", len(entries), MaxUserSecretsPerUserCount)
}
if err := detectDuplicateKeys(entries); err != nil {
return nil, err
}
reqs := make([]CreateUserSecretRequest, 0, len(entries))
for _, e := range entries {
reqs = append(reqs, CreateUserSecretRequest{
Name: e.key,
EnvName: e.key,
Value: e.value,
})
}
return reqs, nil
}
func detectDuplicateKeys(entries []secretEntry) error {
seen := make(map[string]struct{}, len(entries))
for _, e := range entries {
if _, ok := seen[e.key]; ok {
if e.line > 0 {
return xerrors.Errorf("duplicate key %q on line %d", e.key, e.line)
}
return xerrors.Errorf("duplicate key %q", e.key)
}
seen[e.key] = struct{}{}
}
return nil
}
// parseEnvSecrets parses a dotenv-style file into ordered entries. It supports a
// deliberately small subset of dotenv:
// - KEY=VALUE lines, an optional "export " prefix, and full-line "#" comments.
// - Single-quoted values are literal; double-quoted values support \n, \t,
// \r, \\, and \" escapes and keep unknown escapes literal.
//
// It intentionally does NOT:
// - expand $VAR or ${VAR}. Secrets frequently contain "$", and expansion would
// silently corrupt them.
// - strip inline comments, so PASS=abc#123 keeps the trailing #123.
// - support multiline values. Use the JSON or YAML format for PEM keys or
// certs.
//
// Duplicate keys are an error (see detectDuplicateKeys); a silent last-wins
// would drop a secret.
//
// This is hand-rolled rather than using joho/godotenv or hashicorp/go-envparse
// because those expand variables and/or strip inline comments (silent secret
// corruption) and return unordered maps, so we lose source order, line numbers,
// and duplicate detection. They would also add a dependency to the public
// codersdk package.
func parseEnvSecrets(content string) ([]secretEntry, error) {
content = strings.ReplaceAll(content, "\r\n", "\n")
content = strings.TrimPrefix(content, "\ufeff")
var entries []secretEntry
for i, raw := range strings.Split(content, "\n") {
lineNum := i + 1
if t := strings.TrimSpace(raw); t == "" || strings.HasPrefix(t, "#") {
continue
}
work := stripExportPrefix(strings.TrimLeft(raw, " \t"))
eq := strings.IndexByte(work, '=')
if eq < 0 {
return nil, xerrors.Errorf("line %d: expected KEY=VALUE but found no '='", lineNum)
}
key := strings.TrimSpace(work[:eq])
if key == "" {
return nil, xerrors.Errorf("line %d: missing key before '='", lineNum)
}
value, err := parseEnvValue(work[eq+1:], lineNum)
if err != nil {
return nil, err
}
entries = append(entries, secretEntry{key: key, value: value, line: lineNum})
}
return entries, nil
}
// stripExportPrefix removes a leading "export " (the word export
// followed by whitespace). A line like "export=foo" is left untouched
// so the key becomes "export".
func stripExportPrefix(s string) string {
const kw = "export"
if !strings.HasPrefix(s, kw) {
return s
}
rest := s[len(kw):]
if rest == "" || (rest[0] != ' ' && rest[0] != '\t') {
return s
}
return strings.TrimLeft(rest, " \t")
}
func parseEnvValue(rhs string, lineNum int) (string, error) {
v := strings.TrimLeft(rhs, " \t")
if v == "" {
return "", nil
}
switch v[0] {
case '"':
inner, err := doubleQuotedInner(v, lineNum)
if err != nil {
return "", err
}
return unescapeDoubleQuoted(inner), nil
case '\'':
inner, ok := quotedInner(v, '\'')
if !ok {
return "", xerrors.Errorf("line %d: missing closing single quote", lineNum)
}
return inner, nil
default:
return strings.TrimSpace(v), nil
}
}
func doubleQuotedInner(v string, lineNum int) (string, error) {
for i := 1; i < len(v); i++ {
if v[i] != '"' || hasOddBackslashRun(v, i) {
continue
}
if strings.Trim(v[i+1:], " \t") != "" {
return "", xerrors.Errorf("line %d: unexpected data after closing double quote", lineNum)
}
return v[1:i], nil
}
return "", xerrors.Errorf("line %d: missing closing double quote", lineNum)
}
func hasOddBackslashRun(s string, before int) bool {
count := 0
for i := before - 1; i >= 0 && s[i] == '\\'; i-- {
count++
}
return count%2 == 1
}
func quotedInner(v string, quote byte) (string, bool) {
trimmed := strings.TrimRight(v, " \t")
if len(trimmed) < 2 || trimmed[len(trimmed)-1] != quote {
return "", false
}
return trimmed[1 : len(trimmed)-1], true
}
func unescapeDoubleQuoted(s string) string {
if !strings.Contains(s, "\\") {
return s
}
buf := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c != '\\' || i == len(s)-1 {
buf = append(buf, c)
continue
}
switch next := s[i+1]; next {
case 'n':
buf = append(buf, '\n')
case 't':
buf = append(buf, '\t')
case 'r':
buf = append(buf, '\r')
case '\\':
buf = append(buf, '\\')
case '"':
buf = append(buf, '"')
default:
buf = append(buf, '\\', next)
}
i++
}
return string(buf)
}
func parseJSONSecrets(content string) ([]secretEntry, error) {
dec := json.NewDecoder(strings.NewReader(content))
tok, err := dec.Token()
if err != nil {
return nil, xerrors.Errorf("invalid JSON: %w", err)
}
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
return nil, xerrors.New("JSON content must be an object mapping secret names to string values")
}
var entries []secretEntry
for dec.More() {
keyTok, err := dec.Token()
if err != nil {
return nil, xerrors.Errorf("invalid JSON: %w", err)
}
key, ok := keyTok.(string)
if !ok {
return nil, xerrors.New("invalid JSON object key")
}
valTok, err := dec.Token()
if err != nil {
return nil, xerrors.Errorf("invalid JSON: %w", err)
}
switch val := valTok.(type) {
case string:
entries = append(entries, secretEntry{key: key, value: val})
case json.Delim:
return nil, xerrors.Errorf("value for key %q must be a string, not a nested object or array", key)
default:
return nil, xerrors.Errorf("value for key %q must be a string", key)
}
}
if _, err := dec.Token(); err != nil {
return nil, xerrors.Errorf("invalid JSON: %w", err)
}
var extra any
if err := dec.Decode(&extra); errors.Is(err, io.EOF) {
return entries, nil
} else if err != nil {
return nil, xerrors.Errorf("invalid JSON: %w", err)
}
return nil, xerrors.New("unexpected trailing data after JSON object")
}
func parseYAMLSecrets(content string) ([]secretEntry, error) {
dec := yaml.NewDecoder(strings.NewReader(content))
var root yaml.Node
if err := dec.Decode(&root); err != nil {
if errors.Is(err, io.EOF) {
return nil, nil
}
return nil, xerrors.Errorf("invalid YAML: %w", err)
}
var extra yaml.Node
if err := dec.Decode(&extra); err == nil {
return nil, xerrors.New("YAML content must be a single document mapping secret names to string values")
} else if !errors.Is(err, io.EOF) {
return nil, xerrors.Errorf("invalid YAML: %w", err)
}
if root.Kind == 0 || len(root.Content) == 0 {
return nil, nil
}
doc := root.Content[0]
if doc.Kind != yaml.MappingNode {
return nil, xerrors.New("YAML content must be a mapping of secret names to string values")
}
entries := make([]secretEntry, 0, len(doc.Content)/2)
for i := 0; i+1 < len(doc.Content); i += 2 {
keyNode := doc.Content[i]
valNode := doc.Content[i+1]
if keyNode.Kind != yaml.ScalarNode || (keyNode.Tag != "" && keyNode.Tag != "!!str") {
return nil, xerrors.New("YAML keys must be strings")
}
if valNode.Kind != yaml.ScalarNode {
return nil, xerrors.Errorf("value for key %q must be a string, not a nested mapping or sequence", keyNode.Value)
}
if valNode.Tag != "" && valNode.Tag != "!!str" {
return nil, xerrors.Errorf("value for key %q must be a string (quote the value if it is numeric or boolean)", keyNode.Value)
}
entries = append(entries, secretEntry{key: keyNode.Value, value: valNode.Value, line: keyNode.Line})
}
return entries, nil
}
+372
View File
@@ -0,0 +1,372 @@
package codersdk_test
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
)
func TestParseSecretsFileEnv(t *testing.T) {
t.Parallel()
content := strings.Join([]string{
"# full-line comment",
" # indented full-line comment",
"",
" ",
"export EXPORTED=exported-value",
"PLAIN=plain-value",
"WITH_SPACES= trimmed ",
`DQUOTED="double quoted"`,
`DQ_ESCAPES="a\nb\tc\\d\"e"`,
`DQ_ESCAPED_QUOTE="a\"b"`,
`DQ_EVEN_BACKSLASH_CLOSE="two backslashes\\"`,
`DQ_CARRIAGE="a\rb"`,
`DQ_UNKNOWN="x\zy"`,
`SQUOTED='literal \n no escape'`,
"EQ_IN_VALUE=a=b=c",
"HASH=value # kept literal",
"UNICODE=héllo 世界 café",
"exportFOO=literal-key",
"export=literal-export-key",
"EQ_ONLY_VALUE==",
"EMPTY_VAL=",
"TABBED=\t tab trimmed \t",
"export\tTAB_EXPORT=via-tab",
}, "\n")
reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, content)
require.NoError(t, err)
want := []codersdk.CreateUserSecretRequest{
{Name: "EXPORTED", EnvName: "EXPORTED", Value: "exported-value"},
{Name: "PLAIN", EnvName: "PLAIN", Value: "plain-value"},
{Name: "WITH_SPACES", EnvName: "WITH_SPACES", Value: "trimmed"},
{Name: "DQUOTED", EnvName: "DQUOTED", Value: "double quoted"},
{Name: "DQ_ESCAPES", EnvName: "DQ_ESCAPES", Value: "a\nb\tc\\d\"e"},
{Name: "DQ_ESCAPED_QUOTE", EnvName: "DQ_ESCAPED_QUOTE", Value: `a"b`},
{Name: "DQ_EVEN_BACKSLASH_CLOSE", EnvName: "DQ_EVEN_BACKSLASH_CLOSE", Value: `two backslashes\`},
{Name: "DQ_CARRIAGE", EnvName: "DQ_CARRIAGE", Value: "a\rb"},
{Name: "DQ_UNKNOWN", EnvName: "DQ_UNKNOWN", Value: `x\zy`},
{Name: "SQUOTED", EnvName: "SQUOTED", Value: `literal \n no escape`},
{Name: "EQ_IN_VALUE", EnvName: "EQ_IN_VALUE", Value: "a=b=c"},
{Name: "HASH", EnvName: "HASH", Value: "value # kept literal"},
{Name: "UNICODE", EnvName: "UNICODE", Value: "héllo 世界 café"},
{Name: "exportFOO", EnvName: "exportFOO", Value: "literal-key"},
{Name: "export", EnvName: "export", Value: "literal-export-key"},
{Name: "EQ_ONLY_VALUE", EnvName: "EQ_ONLY_VALUE", Value: "="},
{Name: "EMPTY_VAL", EnvName: "EMPTY_VAL", Value: ""},
{Name: "TABBED", EnvName: "TABBED", Value: "tab trimmed"},
{Name: "TAB_EXPORT", EnvName: "TAB_EXPORT", Value: "via-tab"},
}
require.Equal(t, want, reqs)
}
func TestParseSecretsFileEnvCRLFAndBOM(t *testing.T) {
t.Parallel()
content := "\ufeffKEY1=val1\r\nKEY2=val2\r\n"
reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, content)
require.NoError(t, err)
require.Equal(t, []codersdk.CreateUserSecretRequest{
{Name: "KEY1", EnvName: "KEY1", Value: "val1"},
{Name: "KEY2", EnvName: "KEY2", Value: "val2"},
}, reqs)
}
func TestParseSecretsFileEnvErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
errMsgs []string
}{
{name: "NoEquals", content: "OK=value\nNOEQUALS\n", errMsgs: []string{"no '='", "line 2"}},
{name: "MissingKey", content: "=value", errMsgs: []string{"missing key"}},
{name: "UnterminatedDouble", content: `KEY="oops`, errMsgs: []string{"missing closing double quote"}},
{name: "EscapedDoubleQuoteNotClosing", content: `KEY="oops\"`, errMsgs: []string{"missing closing double quote"}},
{name: "DoubleQuoteTrailingData", content: `KEY="ok" # comment`, errMsgs: []string{"unexpected data after closing double quote"}},
{name: "UnterminatedSingle", content: `KEY='oops`, errMsgs: []string{"missing closing single quote"}},
{name: "DuplicateKey", content: "DUP=a\nDUP=b", errMsgs: []string{"duplicate key", "line 2"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, tt.content)
require.Error(t, err)
for _, msg := range tt.errMsgs {
assert.Contains(t, err.Error(), msg)
}
})
}
}
func TestParseSecretsFileJSON(t *testing.T) {
t.Parallel()
reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatJSON, `{"A":"1","B":"two","C":"a=b#c"}`)
require.NoError(t, err)
require.Equal(t, []codersdk.CreateUserSecretRequest{
{Name: "A", EnvName: "A", Value: "1"},
{Name: "B", EnvName: "B", Value: "two"},
{Name: "C", EnvName: "C", Value: "a=b#c"},
}, reqs)
}
func TestParseSecretsFileJSONErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
errMsg string
}{
{name: "Malformed", content: `{"A":`, errMsg: "invalid JSON"},
{name: "NonObjectArray", content: `["a","b"]`, errMsg: "must be an object"},
{name: "NonObjectScalar", content: `"just a string"`, errMsg: "must be an object"},
{name: "NumberValue", content: `{"A":1}`, errMsg: "must be a string"},
{name: "BoolValue", content: `{"A":true}`, errMsg: "must be a string"},
{name: "NullValue", content: `{"A":null}`, errMsg: "must be a string"},
{name: "NestedObject", content: `{"A":{"x":"y"}}`, errMsg: "nested object or array"},
{name: "NestedArray", content: `{"A":["x"]}`, errMsg: "nested object or array"},
{name: "DuplicateKey", content: `{"DUP":"a","DUP":"b"}`, errMsg: "duplicate key"},
{name: "TrailingData", content: `{"A":"1"} {"B":"2"}`, errMsg: "trailing data"},
{name: "InvalidTrailingJSON", content: `{"A":"1"} {`, errMsg: "invalid JSON"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatJSON, tt.content)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
})
}
}
func TestParseSecretsFileYAML(t *testing.T) {
t.Parallel()
content := strings.Join([]string{
"# a comment",
"A: one",
`B: "two"`,
"C: 'a=b#c'",
}, "\n")
reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, content)
require.NoError(t, err)
require.Equal(t, []codersdk.CreateUserSecretRequest{
{Name: "A", EnvName: "A", Value: "one"},
{Name: "B", EnvName: "B", Value: "two"},
{Name: "C", EnvName: "C", Value: "a=b#c"},
}, reqs)
}
func TestParseSecretsFileYAMLErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
errMsg string
}{
{name: "Malformed", content: "A: [unclosed", errMsg: "invalid YAML"},
{name: "NonMappingScalar", content: "just a scalar", errMsg: "must be a mapping"},
{name: "NonMappingSequence", content: "- a\n- b", errMsg: "must be a mapping"},
{name: "NestedMapping", content: "OUTER:\n inner: x", errMsg: "nested mapping or sequence"},
{name: "SequenceValue", content: "LIST:\n - a\n - b", errMsg: "nested mapping or sequence"},
{name: "IntValue", content: "PORT: 8080", errMsg: "must be a string"},
{name: "BoolValue", content: "FLAG: true", errMsg: "must be a string"},
{name: "NullValue", content: "KEY: null", errMsg: "must be a string"},
{name: "BoolKey", content: "true: value", errMsg: "keys must be strings"},
{name: "IntKey", content: "1: value", errMsg: "keys must be strings"},
{name: "SequenceKey", content: "? [a, b]\n: value", errMsg: "keys must be strings"},
{name: "DuplicateKey", content: "DUP: a\nDUP: b", errMsg: "duplicate key"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, tt.content)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
})
}
}
func TestParseSecretsFileYAMLAlias(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "a: &a \"x\"\nb: *a\n")
require.Error(t, err)
assert.Contains(t, err.Error(), "must be a string")
}
func TestParseSecretsFileYAMLMultiDocument(t *testing.T) {
t.Parallel()
t.Run("SecondMappingRejected", func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\nB: \"2\"\n")
require.Error(t, err)
assert.Contains(t, err.Error(), "single document")
})
t.Run("SecondScalarRejected", func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\nplain\n")
require.Error(t, err)
assert.Contains(t, err.Error(), "single document")
})
t.Run("TrailingSeparatorRejected", func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\n")
require.Error(t, err)
assert.Contains(t, err.Error(), "single document")
})
}
// FuzzParseSecretsFile checks two invariants: (1) the parser never panics
// regardless of input (the fuzz engine catches panics automatically); (2) on
// success the result is well-formed: at least one entry, at most
// MaxUserSecretsPerUserCount entries, EnvName == Name for every entry,
// and all keys unique. On error the returned slice must be nil/empty.
func FuzzParseSecretsFile(f *testing.F) {
// env - valid
f.Add("env", "KEY=value")
f.Add("env", "export EXPORTED=val\nPLAIN=plain")
f.Add("env", "\ufeffKEY1=val1\r\nKEY2=val2\r\n")
f.Add("env", "EMPTY=\nKEY=val")
f.Add("env", "EQ=a=b=c")
f.Add("env", `DQUOTED="double quoted"`)
f.Add("env", `SQUOTED='single quoted'`)
f.Add("env", "# comment\nKEY=val")
// env - malformed / tricky quoting
f.Add("env", `KEY="unterminated`)
f.Add("env", `KEY='unterminated`)
f.Add("env", `KEY="escaped\"`)
f.Add("env", `KEY="two backslashes\\"`)
f.Add("env", "NOEQUALS")
f.Add("env", "=value")
f.Add("env", `KEY="ok" # trailing`)
f.Add("env", "DUP=a\nDUP=b")
// json - valid
f.Add("json", `{"A":"1","B":"two"}`)
// json - malformed
f.Add("json", `{"A":`)
f.Add("json", `["a","b"]`)
f.Add("json", `"just a string"`)
f.Add("json", `{"A":1}`)
f.Add("json", `{"A":true}`)
f.Add("json", `{"A":null}`)
f.Add("json", `{"A":{"x":"y"}}`)
f.Add("json", `{"A":["x"]}`)
f.Add("json", `{"DUP":"a","DUP":"b"}`)
f.Add("json", `{"A":"1"} {"B":"2"}`)
// yaml - valid
f.Add("yaml", "A: one\nB: \"two\"\n")
// yaml - malformed
f.Add("yaml", "A: [unclosed")
f.Add("yaml", "- a\n- b\n")
f.Add("yaml", "OUTER:\n inner: x\n")
f.Add("yaml", "PORT: 8080\n")
f.Add("yaml", "FLAG: true\n")
f.Add("yaml", "a: &a \"x\"\nb: *a\n")
f.Add("yaml", "A: \"1\"\n---\nB: \"2\"\n")
// unknown / empty format
f.Add("", "KEY=value")
f.Add("toml", "KEY=value")
f.Fuzz(func(t *testing.T, format string, content string) {
reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormat(format), content)
if err != nil {
require.Empty(t, reqs)
return
}
// On success: at least one entry, within the per-user cap.
require.NotEmpty(t, reqs)
require.LessOrEqual(t, len(reqs), codersdk.MaxUserSecretsPerUserCount)
seen := make(map[string]struct{}, len(reqs))
for _, req := range reqs {
require.Equal(t, req.Name, req.EnvName)
_, dup := seen[req.Name]
require.False(t, dup, "duplicate key %q in result", req.Name)
seen[req.Name] = struct{}{}
}
})
}
func TestParseSecretsFileGeneralErrors(t *testing.T) {
t.Parallel()
t.Run("UnknownFormat", func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile("toml", "A=1")
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown secrets file format")
})
t.Run("EmptyFormat", func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile("", "A=1")
require.Error(t, err)
assert.Contains(t, err.Error(), "format is required")
})
t.Run("MaxBytesBoundary", func(t *testing.T) {
t.Parallel()
value := strings.Repeat("a", codersdk.MaxSecretsFileBytes-len("KEY="))
reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, "KEY="+value)
require.NoError(t, err)
require.Equal(t, []codersdk.CreateUserSecretRequest{
{Name: "KEY", EnvName: "KEY", Value: value},
}, reqs)
})
t.Run("Oversized", func(t *testing.T) {
t.Parallel()
content := strings.Repeat("a", codersdk.MaxSecretsFileBytes+1)
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, content)
require.Error(t, err)
assert.Contains(t, err.Error(), "maximum allowed size")
})
t.Run("TooManySecrets", func(t *testing.T) {
t.Parallel()
lines := make([]string, 0, codersdk.MaxUserSecretsPerUserCount+1)
for i := 0; i < codersdk.MaxUserSecretsPerUserCount+1; i++ {
lines = append(lines, fmt.Sprintf("KEY_%d=value", i))
}
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, strings.Join(lines, "\n"))
require.Error(t, err)
assert.Contains(t, err.Error(), "exceeds the maximum")
})
emptyCases := []struct {
name string
format codersdk.SecretsFileFormat
content string
}{
{name: "EnvEmpty", format: codersdk.SecretsFileFormatEnv, content: ""},
{name: "EnvWhitespace", format: codersdk.SecretsFileFormatEnv, content: " \n\t\n"},
{name: "EnvAllComments", format: codersdk.SecretsFileFormatEnv, content: "# one\n# two\n"},
{name: "JSONEmptyObject", format: codersdk.SecretsFileFormatJSON, content: "{}"},
{name: "YAMLEmpty", format: codersdk.SecretsFileFormatYAML, content: ""},
{name: "YAMLCommentsOnly", format: codersdk.SecretsFileFormatYAML, content: "# nothing here\n"},
}
for _, tt := range emptyCases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(tt.format, tt.content)
require.Error(t, err)
assert.Contains(t, err.Error(), "no secrets found")
})
}
}
+30
View File
@@ -209,6 +209,36 @@ var (
}
)
// UserSecret*Field constants are the canonical ValidationError.Field values
// for user secret fields. UserSecretNameField is also the chi URL parameter
// name used in coderd route segments.
const (
UserSecretNameField = "name"
UserSecretValueField = "value"
UserSecretEnvNameField = "env_name"
UserSecretFilePathField = "file_path"
)
// ValidateCreateUserSecretRequest validates a single create-secret request.
func ValidateCreateUserSecretRequest(req CreateUserSecretRequest) []ValidationError {
var validations []ValidationError
if err := UserSecretNameValid(req.Name); err != nil {
validations = append(validations, ValidationError{Field: UserSecretNameField, Detail: err.Error()})
}
if req.Value == "" {
validations = append(validations, ValidationError{Field: UserSecretValueField, Detail: "Value is required."})
} else if err := UserSecretValueValid(req.Value); err != nil {
validations = append(validations, ValidationError{Field: UserSecretValueField, Detail: err.Error()})
}
if err := UserSecretEnvNameValid(req.EnvName); err != nil {
validations = append(validations, ValidationError{Field: UserSecretEnvNameField, Detail: err.Error()})
}
if err := UserSecretFilePathValid(req.FilePath); err != nil {
validations = append(validations, ValidationError{Field: UserSecretFilePathField, Detail: err.Error()})
}
return validations
}
// UserSecretNameValid validates a user secret name. Names are used in
// API route path segments, so they must not include route separators.
func UserSecretNameValid(s string) error {
+52
View File
@@ -9,6 +9,58 @@ import (
"github.com/coder/coder/v2/codersdk"
)
func TestValidateCreateUserSecretRequest(t *testing.T) {
t.Parallel()
tests := []struct {
name string
req codersdk.CreateUserSecretRequest
want []codersdk.ValidationError
}{
{
name: "Valid",
req: codersdk.CreateUserSecretRequest{
Name: "github-token",
Value: "ghp_xxxxxxxxxxxx",
EnvName: "GITHUB_TOKEN",
FilePath: "~/.github-token",
},
},
{
name: "MissingValue",
req: codersdk.CreateUserSecretRequest{
Name: "missing-value-secret",
},
want: []codersdk.ValidationError{{
Field: "value",
Detail: "Value is required.",
}},
},
{
name: "MultiInvalid",
req: codersdk.CreateUserSecretRequest{
EnvName: "1TOKEN",
FilePath: "relative/path",
},
want: []codersdk.ValidationError{
{Field: "name", Detail: "Name is required."},
{Field: "value", Detail: "Value is required."},
{Field: "env_name", Detail: "must start with a letter or underscore, followed by letters, digits, or underscores"},
{Field: "file_path", Detail: "file path must start with ~/ or /"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := codersdk.ValidateCreateUserSecretRequest(tt.req)
assert.Equal(t, tt.want, got)
})
}
}
func TestUserSecretNameValid(t *testing.T) {
t.Parallel()
+43
View File
@@ -5772,6 +5772,12 @@ export const MaxChatFileIDs = 50;
*/
export const MaxChatFileSizeBytes = 10485760;
// From codersdk/usersecretsimport.go
/**
* MaxSecretsFileBytes bounds the raw size of a secrets file before parsing.
*/
export const MaxSecretsFileBytes = 1048576; // 1 MiB
// From codersdk/usersecretvalidation.go
/**
* MaxUserSecretEnvNameLength caps the length of an env_name when one
@@ -7892,6 +7898,11 @@ export interface STUNReport {
readonly Error: string | null;
}
// From codersdk/usersecretsimport.go
export type SecretsFileFormat = "env" | "json" | "yaml";
export const SecretsFileFormats: SecretsFileFormat[] = ["env", "json", "yaml"];
// From serpent/serpent.go
/**
* Annotations is an arbitrary key-mapping used to extend the Option and Command types.
@@ -10189,6 +10200,38 @@ export interface UserSecret {
readonly updated_at: string;
}
// From codersdk/usersecretvalidation.go
/**
* UserSecret*Field constants are the canonical ValidationError.Field values
* for user secret fields. UserSecretNameField is also the chi URL parameter
* name used in coderd route segments.
*/
export const UserSecretEnvNameField = "env_name";
// From codersdk/usersecretvalidation.go
/**
* UserSecret*Field constants are the canonical ValidationError.Field values
* for user secret fields. UserSecretNameField is also the chi URL parameter
* name used in coderd route segments.
*/
export const UserSecretFilePathField = "file_path";
// From codersdk/usersecretvalidation.go
/**
* UserSecret*Field constants are the canonical ValidationError.Field values
* for user secret fields. UserSecretNameField is also the chi URL parameter
* name used in coderd route segments.
*/
export const UserSecretNameField = "name";
// From codersdk/usersecretvalidation.go
/**
* UserSecret*Field constants are the canonical ValidationError.Field values
* for user secret fields. UserSecretNameField is also the chi URL parameter
* name used in coderd route segments.
*/
export const UserSecretValueField = "value";
// From codersdk/userskills.go
/**
* UserSkill represents a user skill with its raw Markdown content.