mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+10
-31
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Generated
+43
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user