377 lines
11 KiB
Go
377 lines
11 KiB
Go
package engine
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
secretsEntryLimit = 1024
|
|
secretFileByteLimit = int64(1 << 20)
|
|
secretsByteLimit = int64(8 << 20)
|
|
)
|
|
|
|
// SecretFileStatus describes the outcome for one file considered by
|
|
// LoadSecretsDir.
|
|
type SecretFileStatus string
|
|
|
|
const (
|
|
SecretFileLoaded SecretFileStatus = "loaded"
|
|
SecretFileSkippedExisting SecretFileStatus = "skipped_existing"
|
|
SecretFileNotApplied SecretFileStatus = "not_applied"
|
|
SecretFileFailed SecretFileStatus = "failed"
|
|
)
|
|
|
|
// SecretFileResult is the safe, value-free bootstrap result for one directory
|
|
// entry. ExistingEmpty is meaningful when Status is SecretFileSkippedExisting.
|
|
type SecretFileResult struct {
|
|
Name string
|
|
Path string
|
|
Status SecretFileStatus
|
|
ExistingEmpty bool
|
|
}
|
|
|
|
// SecretsReport is a detached snapshot of an explicit secrets-directory
|
|
// bootstrap. It does not retain file contents or environment values.
|
|
type SecretsReport struct {
|
|
entries []SecretFileResult
|
|
}
|
|
|
|
// Entries returns a copy of the report's filename-ordered entries.
|
|
func (r *SecretsReport) Entries() []SecretFileResult {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return slices.Clone(r.entries)
|
|
}
|
|
|
|
// WriteTo renders one safe, deterministic line per entry. Names and paths are
|
|
// quoted so control characters cannot affect terminal output.
|
|
func (r *SecretsReport) WriteTo(output io.Writer) (int64, error) {
|
|
if output == nil {
|
|
return 0, errors.New("configkit: secrets report output is nil")
|
|
}
|
|
if r == nil {
|
|
return 0, nil
|
|
}
|
|
var total int64
|
|
for _, entry := range r.entries {
|
|
line := "status=" + strconv.Quote(string(entry.Status)) +
|
|
" name=" + strconv.Quote(entry.Name) +
|
|
" path=" + strconv.Quote(entry.Path) +
|
|
" existing_empty=" + strconv.FormatBool(entry.ExistingEmpty) + "\n"
|
|
written, err := io.WriteString(output, line)
|
|
total += int64(written)
|
|
if err != nil {
|
|
return total, err
|
|
}
|
|
if written != len(line) {
|
|
return total, io.ErrShortWrite
|
|
}
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
// SecretsError reports safe structured diagnostics for a failed explicit
|
|
// secrets-directory bootstrap. It intentionally does not unwrap filesystem or
|
|
// environment errors.
|
|
type SecretsError struct {
|
|
diagnostics []Diagnostic
|
|
}
|
|
|
|
// Error implements error without rendering filenames, paths, or file contents.
|
|
func (e *SecretsError) Error() string {
|
|
if e == nil {
|
|
return "configkit: secrets bootstrap failed"
|
|
}
|
|
count := 0
|
|
for _, diagnostic := range e.diagnostics {
|
|
if diagnostic.Severity == SeverityError {
|
|
count++
|
|
}
|
|
}
|
|
if count == 1 {
|
|
return "configkit: secrets bootstrap failed with 1 error"
|
|
}
|
|
return fmt.Sprintf("configkit: secrets bootstrap failed with %d errors", count)
|
|
}
|
|
|
|
// Diagnostics returns a deep copy of the bootstrap diagnostics.
|
|
func (e *SecretsError) Diagnostics() []Diagnostic {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return cloneDiagnostics(e.diagnostics)
|
|
}
|
|
|
|
type secretsEnvironment struct {
|
|
lookup func(string) (string, bool)
|
|
set func(string, string) error
|
|
}
|
|
|
|
type secretsAccess struct {
|
|
openDirectory func(string) (*os.File, error)
|
|
lstat func(string) (fs.FileInfo, error)
|
|
openFile func(string) (*os.File, error)
|
|
}
|
|
|
|
type stagedSecret struct {
|
|
entry int
|
|
value string
|
|
}
|
|
|
|
// LoadSecretsDir explicitly loads immediate regular files from directory into
|
|
// previously unset process environment variables. Applications should call it
|
|
// once during startup, before concurrent work; Load never calls it.
|
|
// Dot-prefixed entries are ignored and omitted from the report.
|
|
func LoadSecretsDir(directory string) (*SecretsReport, error) {
|
|
return loadSecretsDir(directory, secretsEnvironment{lookup: os.LookupEnv, set: os.Setenv}, secretsAccess{
|
|
openDirectory: os.Open,
|
|
lstat: os.Lstat,
|
|
openFile: os.Open,
|
|
})
|
|
}
|
|
|
|
func loadSecretsDir(directory string, environment secretsEnvironment, access secretsAccess) (*SecretsReport, error) {
|
|
report := &SecretsReport{}
|
|
if strings.TrimSpace(directory) == "" {
|
|
return report, secretsFailure(CodeSecretsInvalidPath, "secrets directory path is required")
|
|
}
|
|
|
|
canonical, err := canonicalExistingPath(directory)
|
|
if err != nil {
|
|
return report, secretsFailure(classifySecretsFilesystemError(err), "secrets directory could not be canonicalized: "+strconv.Quote(cleanAbsolutePath(directory)))
|
|
}
|
|
info, err := os.Stat(canonical)
|
|
if err != nil {
|
|
return report, secretsFailure(classifySecretsFilesystemError(err), "secrets directory could not be inspected: "+strconv.Quote(canonical))
|
|
}
|
|
if !info.IsDir() {
|
|
return report, secretsFailure(CodeSecretsInvalidPath, "secrets directory is not a directory: "+strconv.Quote(canonical))
|
|
}
|
|
|
|
directoryFile, err := access.openDirectory(canonical)
|
|
if err != nil {
|
|
return report, secretsFailure(classifySecretsFilesystemError(err), "secrets directory could not be opened: "+strconv.Quote(canonical))
|
|
}
|
|
entries, readErr := readSecretsEntries(directoryFile)
|
|
closeErr := directoryFile.Close()
|
|
if readErr != nil {
|
|
return report, secretsFailure(classifySecretsFilesystemError(readErr), "secrets directory could not be enumerated: "+strconv.Quote(canonical))
|
|
}
|
|
if closeErr != nil {
|
|
return report, secretsFailure(CodeSecretsRead, "secrets directory could not be closed: "+strconv.Quote(canonical))
|
|
}
|
|
entryLimitExceeded := len(entries) > secretsEntryLimit
|
|
entries = slices.DeleteFunc(entries, func(entry fs.DirEntry) bool {
|
|
return strings.HasPrefix(entry.Name(), ".")
|
|
})
|
|
slices.SortFunc(entries, func(left, right fs.DirEntry) int { return strings.Compare(left.Name(), right.Name()) })
|
|
report.entries = make([]SecretFileResult, len(entries))
|
|
for index, entry := range entries {
|
|
name := entry.Name()
|
|
report.entries[index] = SecretFileResult{Name: name, Path: filepath.Join(canonical, name), Status: SecretFileNotApplied}
|
|
}
|
|
if entryLimitExceeded {
|
|
return report, secretsFailure(CodeSecretsLimitExceeded, "secrets directory exceeds the 1024-entry limit: "+strconv.Quote(canonical))
|
|
}
|
|
|
|
for index := range report.entries {
|
|
entry := &report.entries[index]
|
|
if !validEnvironmentName(entry.Name) {
|
|
entry.Status = SecretFileFailed
|
|
return report, secretsFailure(CodeSecretsInvalidName, "secret filename is not a valid environment name: "+strconv.Quote(entry.Path))
|
|
}
|
|
info, err := access.lstat(entry.Path)
|
|
if err != nil {
|
|
entry.Status = SecretFileFailed
|
|
return report, secretsFailure(classifySecretsFilesystemError(err), "secret file could not be inspected: "+strconv.Quote(entry.Path))
|
|
}
|
|
if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
entry.Status = SecretFileFailed
|
|
return report, secretsFailure(CodeSecretsNotRegular, "secret entry is not a regular file: "+strconv.Quote(entry.Path))
|
|
}
|
|
}
|
|
|
|
staged := make([]stagedSecret, 0, len(report.entries))
|
|
var consumed int64
|
|
for index := range report.entries {
|
|
entry := &report.entries[index]
|
|
if existing, present := environment.lookup(entry.Name); present {
|
|
entry.Status = SecretFileSkippedExisting
|
|
entry.ExistingEmpty = existing == ""
|
|
continue
|
|
}
|
|
remaining := secretsByteLimit - consumed
|
|
value, used, err := readSecretValue(entry.Path, remaining, access)
|
|
if err != nil {
|
|
entry.Status = SecretFileFailed
|
|
return report, secretsFailure(secretReadCode(err), secretReadMessage(err)+": "+strconv.Quote(entry.Path))
|
|
}
|
|
consumed += used
|
|
staged = append(staged, stagedSecret{entry: index, value: value})
|
|
}
|
|
|
|
for _, secret := range staged {
|
|
entry := &report.entries[secret.entry]
|
|
if existing, present := environment.lookup(entry.Name); present {
|
|
entry.Status = SecretFileSkippedExisting
|
|
entry.ExistingEmpty = existing == ""
|
|
continue
|
|
}
|
|
if err := environment.set(entry.Name, secret.value); err != nil {
|
|
entry.Status = SecretFileFailed
|
|
return report, secretsFailure(CodeSecretsApplyFailed, "secret environment variable could not be set for file: "+strconv.Quote(entry.Path))
|
|
}
|
|
entry.Status = SecretFileLoaded
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
func readSecretsEntries(directory *os.File) ([]fs.DirEntry, error) {
|
|
entries := make([]fs.DirEntry, 0)
|
|
for len(entries) <= secretsEntryLimit {
|
|
batch, err := directory.ReadDir(secretsEntryLimit + 1 - len(entries))
|
|
entries = append(entries, batch...)
|
|
if err == io.EOF {
|
|
return entries, nil
|
|
}
|
|
if err != nil {
|
|
return entries, err
|
|
}
|
|
if len(entries) > secretsEntryLimit {
|
|
return entries, nil
|
|
}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
type secretReadFailure int
|
|
|
|
const (
|
|
secretReadFilesystem secretReadFailure = iota
|
|
secretReadFileLimit
|
|
secretReadTotalLimit
|
|
secretReadNUL
|
|
)
|
|
|
|
type secretReadError struct {
|
|
kind secretReadFailure
|
|
err error
|
|
}
|
|
|
|
func (e *secretReadError) Error() string { return "secret read failed" }
|
|
|
|
func readSecretValue(path string, remaining int64, access secretsAccess) (string, int64, error) {
|
|
if remaining < 0 {
|
|
return "", 0, &secretReadError{kind: secretReadTotalLimit}
|
|
}
|
|
limit := min(secretFileByteLimit, remaining)
|
|
file, err := access.openFile(path)
|
|
if err != nil {
|
|
return "", 0, &secretReadError{kind: secretReadFilesystem, err: err}
|
|
}
|
|
data, readErr := io.ReadAll(io.LimitReader(file, limit+1))
|
|
closeErr := file.Close()
|
|
if readErr != nil {
|
|
return "", 0, &secretReadError{kind: secretReadFilesystem, err: readErr}
|
|
}
|
|
if closeErr != nil {
|
|
return "", 0, &secretReadError{kind: secretReadFilesystem, err: closeErr}
|
|
}
|
|
if int64(len(data)) > limit {
|
|
kind := secretReadFileLimit
|
|
if remaining < secretFileByteLimit {
|
|
kind = secretReadTotalLimit
|
|
}
|
|
return "", 0, &secretReadError{kind: kind}
|
|
}
|
|
if slices.Contains(data, byte(0)) {
|
|
return "", 0, &secretReadError{kind: secretReadNUL}
|
|
}
|
|
value := string(data)
|
|
if strings.HasSuffix(value, "\r\n") {
|
|
value = strings.TrimSuffix(value, "\r\n")
|
|
} else {
|
|
value = strings.TrimSuffix(value, "\n")
|
|
}
|
|
return value, int64(len(data)), nil
|
|
}
|
|
|
|
func validEnvironmentName(name string) bool {
|
|
if name == "" || !asciiEnvironmentFirst(name[0]) {
|
|
return false
|
|
}
|
|
for index := 1; index < len(name); index++ {
|
|
character := name[index]
|
|
if !asciiEnvironmentFirst(character) && (character < '0' || character > '9') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func asciiEnvironmentFirst(character byte) bool {
|
|
return character == '_' || character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z'
|
|
}
|
|
|
|
func cleanAbsolutePath(path string) string {
|
|
absolute, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return filepath.Clean(path)
|
|
}
|
|
return filepath.Clean(absolute)
|
|
}
|
|
|
|
func classifySecretsFilesystemError(err error) Code {
|
|
switch {
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
return CodeSecretsMissing
|
|
case errors.Is(err, fs.ErrPermission):
|
|
return CodeSecretsPermission
|
|
default:
|
|
return CodeSecretsRead
|
|
}
|
|
}
|
|
|
|
func secretReadCode(err error) Code {
|
|
var failure *secretReadError
|
|
if errors.As(err, &failure) {
|
|
switch failure.kind {
|
|
case secretReadFileLimit, secretReadTotalLimit:
|
|
return CodeSecretsLimitExceeded
|
|
case secretReadNUL:
|
|
return CodeSecretsInvalidValue
|
|
case secretReadFilesystem:
|
|
return classifySecretsFilesystemError(failure.err)
|
|
}
|
|
}
|
|
return CodeSecretsRead
|
|
}
|
|
|
|
func secretReadMessage(err error) string {
|
|
var failure *secretReadError
|
|
if errors.As(err, &failure) {
|
|
switch failure.kind {
|
|
case secretReadFileLimit:
|
|
return "secret file exceeds the 1 MiB limit"
|
|
case secretReadTotalLimit:
|
|
return "eligible secret files exceed the 8 MiB total limit"
|
|
case secretReadNUL:
|
|
return "secret file contains a NUL byte"
|
|
}
|
|
}
|
|
return "secret file could not be read"
|
|
}
|
|
|
|
func secretsFailure(code Code, message string) *SecretsError {
|
|
return &SecretsError{diagnostics: []Diagnostic{{Code: code, Severity: SeverityError, Message: message}}}
|
|
}
|