package app import ( "fmt" "log/slog" "os" "path/filepath" "regexp" "strings" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/fileops" ) var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) var readSecretsDirectoryEntries = os.ReadDir // APIKeyFileByteLimit bounds one credential value read from the configured // secrets directory. API keys are expected to be short single-line values. const APIKeyFileByteLimit int64 = 8 * 1024 const secretDirectoryPrivateMode os.FileMode = 0o700 const secretFilePrivateMode os.FileMode = 0o600 type secretsLoadStats struct { Dir string Loaded int PreservedExisting int Skipped int } func loadSecretsFromConfig(cfg *config.Config, logger *slog.Logger) (*secretsLoadStats, error) { if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Secrets == nil { return nil, nil } rawDir := strings.TrimSpace(cfg.Pipeline.Secrets.EnvDir) if rawDir == "" { return nil, nil } resolvedDir := rawDir if !filepath.IsAbs(resolvedDir) { cwd, err := os.Getwd() if err != nil { return nil, fmt.Errorf("resolve secrets env_dir %q from current working directory: %w", rawDir, err) } resolvedDir = filepath.Join(cwd, resolvedDir) } resolvedDir = filepath.Clean(resolvedDir) if err := fileops.ValidateConfinedDirectory(resolvedDir, validateSecretDirectory); err != nil { return nil, fmt.Errorf("API-key loader: validate secrets env_dir: %w", err) } entries, err := readSecretsDirectoryEntries(resolvedDir) if err != nil { return nil, fmt.Errorf("API-key loader: read secrets env_dir %q: %w", resolvedDir, err) } stats := &secretsLoadStats{Dir: resolvedDir} for _, entry := range entries { name := entry.Name() if !envVarNamePattern.MatchString(name) { stats.Skipped++ continue } if _, exists := os.LookupEnv(name); exists { stats.PreservedExisting++ continue } value, err := readAPIKeyFile(resolvedDir, name) if err != nil { return nil, err } if err := os.Setenv(name, value); err != nil { return nil, fmt.Errorf("API-key loader: set environment variable %q: %w", name, err) } stats.Loaded++ } if logger != nil { logger.Info( "loaded secret environment variables from filesystem", "secrets_env_dir", stats.Dir, "loaded", stats.Loaded, "preserved_existing", stats.PreservedExisting, "skipped", stats.Skipped, ) } return stats, nil } func validateSecretDirectory(info os.FileInfo) error { if !info.IsDir() { return fmt.Errorf("secrets env_dir is not a directory") } return validateSecretDirectoryPrivacy(info) } func validateSecretFile(info os.FileInfo) error { if !info.Mode().IsRegular() { return fmt.Errorf("secret entry is not a regular file") } return validateSecretFilePrivacy(info) } func readAPIKeyFile(directory, name string) (string, error) { content, err := fileops.ReadRegularFileUnderRoot( directory, name, APIKeyFileByteLimit, validateSecretDirectory, validateSecretFile, ) if err != nil { return "", fmt.Errorf("API-key loader: read secret file %q with %d-byte limit: %w", name, APIKeyFileByteLimit, err) } return strings.TrimRight(string(content), "\r\n"), nil }