Ctrl K
ComunidadGuías…

codersdk/usersecretsimport.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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. EnvName is set only when the key passes
// env-name validation (best-effort; keys like MY-TOKEN or PATH get an empty
// EnvName so they are still imported without env injection).
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 {
		req := CreateUserSecretRequest{Name: e.key, Value: e.value}
		// env_name uses a partial unique index (WHERE env_name != ''),
		// so multiple empty env_names are allowed.
		if UserSecretEnvNameValid(e.key) == nil {
			req.EnvName = e.key
		} else {
			// Keys that cannot be env-injected (reserved names, invalid
			// identifiers) are imported without an injection target, so
			// they must be disabled: an enabled secret always has at
			// least one of env_name or file_path set. The user can add
			// a target and re-enable the secret afterwards.
			disabled := false
			req.Enabled = &disabled
		}
		reqs = append(reqs, req)
	}
	return reqs, nil
}

// Duplicate keys are rejected up front (citing the line for env files)
// instead of surfacing as a per-row uniqueness violation later.
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 '\'':
		return singleQuotedInner(v, lineNum)
	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
}

// singleQuotedInner returns the content between the opening single
// quote at index 0 and the first closing single quote. Single quotes
// have no escape sequences, so the first quote after the opener always
// closes the value. Only whitespace may follow the closing quote.
func singleQuotedInner(v string, lineNum int) (string, error) {
	for i := 1; i < len(v); i++ {
		if v[i] != '\'' {
			continue
		}
		if strings.Trim(v[i+1:], " \t") != "" {
			return "", xerrors.Errorf("line %d: unexpected data after closing single quote", lineNum)
		}
		return v[1:i], nil
	}
	return "", xerrors.Errorf("line %d: missing closing single quote", lineNum)
}

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
}