Add explicit secrets directory bootstrap
This commit is contained in:
371
secrets.go
Normal file
371
secrets.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package configkit
|
||||
|
||||
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.
|
||||
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))
|
||||
}
|
||||
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 len(entries) > secretsEntryLimit {
|
||||
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}}}
|
||||
}
|
||||
Reference in New Issue
Block a user