Lots more changes

This commit is contained in:
2026-06-01 01:29:47 -05:00
parent 8bec7f0dda
commit 050ec138f3
20 changed files with 1773 additions and 689 deletions
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"sync"
"scratchbox/internal/config"
)
func newAccessLogWriter(cfg config.Config) (io.Writer, func() error, error) {
if !cfg.Server.AccessLog.Enabled {
return io.Discard, func() error { return nil }, nil
}
if cfg.Server.AccessLog.FilePath == "" {
return os.Stdout, func() error { return nil }, nil
}
writer, err := newRollingFileWriter(
cfg.Server.AccessLog.FilePath,
cfg.Server.AccessLog.MaxSizeBytes,
cfg.Server.AccessLog.MaxBackups,
)
if err != nil {
return nil, nil, err
}
return io.MultiWriter(os.Stdout, writer), writer.Close, nil
}
type rollingFileWriter struct {
mu sync.Mutex
path string
maxSize int64
maxBackups int
file *os.File
size int64
}
func newRollingFileWriter(path string, maxSize int64, maxBackups int) (*rollingFileWriter, error) {
if maxSize <= 0 {
return nil, fmt.Errorf("max size must be > 0")
}
if maxBackups <= 0 {
return nil, fmt.Errorf("max backups must be > 0")
}
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create log directory: %w", err)
}
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return nil, fmt.Errorf("open log file: %w", err)
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, fmt.Errorf("stat log file: %w", err)
}
return &rollingFileWriter{
path: path,
maxSize: maxSize,
maxBackups: maxBackups,
file: f,
size: info.Size(),
}, nil
}
func (w *rollingFileWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.size > 0 && w.size+int64(len(p)) > w.maxSize {
if err := w.rotateLocked(); err != nil {
return 0, err
}
}
n, err := w.file.Write(p)
w.size += int64(n)
return n, err
}
func (w *rollingFileWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
func (w *rollingFileWriter) rotateLocked() error {
if err := w.file.Close(); err != nil {
return fmt.Errorf("close before rotate: %w", err)
}
for i := w.maxBackups - 1; i >= 1; i-- {
from := rotatedLogPath(w.path, i)
to := rotatedLogPath(w.path, i+1)
if _, err := os.Stat(from); err != nil {
if os.IsNotExist(err) {
continue
}
return fmt.Errorf("stat rotated file %s: %w", from, err)
}
_ = os.Remove(to)
if err := os.Rename(from, to); err != nil {
return fmt.Errorf("rotate backup %s -> %s: %w", from, to, err)
}
}
firstBackup := rotatedLogPath(w.path, 1)
_ = os.Remove(firstBackup)
if _, err := os.Stat(w.path); err == nil {
if err := os.Rename(w.path, firstBackup); err != nil {
return fmt.Errorf("rotate current log to %s: %w", firstBackup, err)
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("stat current log file: %w", err)
}
f, err := os.OpenFile(w.path, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open rotated log file: %w", err)
}
w.file = f
w.size = 0
return nil
}
func rotatedLogPath(path string, index int) string {
return fmt.Sprintf("%s.%d", path, index)
}
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"bytes"
"os"
"path/filepath"
"testing"
"scratchbox/internal/config"
)
func TestNewAccessLogWriterDisabled(t *testing.T) {
t.Parallel()
cfg := config.Default()
cfg.Server.AccessLog.Enabled = false
writer, closeFn, err := newAccessLogWriter(cfg)
if err != nil {
t.Fatalf("newAccessLogWriter() error = %v", err)
}
if writer == nil {
t.Fatalf("newAccessLogWriter() writer = nil")
}
if err := closeFn(); err != nil {
t.Fatalf("closeFn() error = %v", err)
}
}
func TestRollingFileWriterRotatesAndKeepsBackups(t *testing.T) {
t.Parallel()
logPath := filepath.Join(t.TempDir(), "access.log")
w, err := newRollingFileWriter(logPath, 10, 2)
if err != nil {
t.Fatalf("newRollingFileWriter() error = %v", err)
}
defer w.Close()
_, _ = w.Write([]byte("12345"))
_, _ = w.Write([]byte("67890"))
_, _ = w.Write([]byte("abc"))
_, _ = w.Write([]byte("defghijkl"))
if err := w.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
current, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("ReadFile(current) error = %v", err)
}
backup1, err := os.ReadFile(logPath + ".1")
if err != nil {
t.Fatalf("ReadFile(backup1) error = %v", err)
}
backup2, err := os.ReadFile(logPath + ".2")
if err != nil {
t.Fatalf("ReadFile(backup2) error = %v", err)
}
if !bytes.Equal(current, []byte("defghijkl")) {
t.Fatalf("current log = %q, want %q", string(current), "defghijkl")
}
if !bytes.Equal(backup1, []byte("abc")) {
t.Fatalf("backup1 log = %q, want %q", string(backup1), "abc")
}
if !bytes.Equal(backup2, []byte("1234567890")) {
t.Fatalf("backup2 log = %q, want %q", string(backup2), "1234567890")
}
}
+32
View File
@@ -0,0 +1,32 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"scratchbox/internal/config"
)
func newGenerateKeyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "generate-key",
Short: "Print a random base64 encryption key to stdout",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return writeEncryptionKey(cmd)
},
}
return cmd
}
func writeEncryptionKey(cmd *cobra.Command) error {
key, err := config.GenerateEncryptionKey()
if err != nil {
return fmt.Errorf("generate encryption key: %w", err)
}
if _, err := fmt.Fprintln(cmd.OutOrStdout(), key); err != nil {
return fmt.Errorf("write encryption key: %w", err)
}
return nil
}
+1
View File
@@ -27,5 +27,6 @@ func newRootCmd() *cobra.Command {
}
cmd.AddCommand(newServerCmd())
cmd.AddCommand(newGenerateConfigCmd())
cmd.AddCommand(newGenerateKeyCmd())
return cmd
}
+104 -7
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"log"
@@ -31,6 +32,18 @@ func repoRoot(t *testing.T) string {
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
}
func writeConfigKeyFile(t *testing.T, configPath string) {
t.Helper()
keyPath := filepath.Join(filepath.Dir(configPath), "config.key")
key, err := config.GenerateEncryptionKey()
if err != nil {
t.Fatalf("GenerateEncryptionKey() error = %v", err)
}
if err := os.WriteFile(keyPath, []byte(key+"\n"), 0o600); err != nil {
t.Fatalf("WriteFile(config.key) error = %v", err)
}
}
func TestMainProcessSuccess(t *testing.T) {
t.Parallel()
@@ -48,7 +61,15 @@ storage:
security:
allowed_ips: []
trust_proxy_headers: false
rate_limit:
rate_limit_ui:
enabled: true
requests_per_minute: 10
burst: 1
rate_limit_api_read:
enabled: true
requests_per_minute: 10
burst: 1
rate_limit_api_write:
enabled: true
requests_per_minute: 10
burst: 1
@@ -56,6 +77,7 @@ security:
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
writeConfigKeyFile(t, cfgPath)
cmd := exec.Command(os.Args[0], "-test.run=TestMainHelperProcess")
cmd.Dir = repoRoot(t)
@@ -104,7 +126,15 @@ storage:
security:
allowed_ips: []
trust_proxy_headers: false
rate_limit:
rate_limit_ui:
enabled: true
requests_per_minute: 10
burst: 1
rate_limit_api_read:
enabled: true
requests_per_minute: 10
burst: 1
rate_limit_api_write:
enabled: true
requests_per_minute: 10
burst: 1
@@ -112,6 +142,7 @@ security:
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
writeConfigKeyFile(t, cfgPath)
cmd := exec.Command(os.Args[0], "-test.run=TestMainHelperProcess")
cmd.Dir = repoRoot(t)
@@ -198,8 +229,26 @@ func TestGenerateConfigCommandWritesDefaultYAML(t *testing.T) {
if cfg.Storage.DataDir != "./data" {
t.Fatalf("storage.data_dir = %q, want %q", cfg.Storage.DataDir, "./data")
}
if cfg.Security.RateLimit.RequestsPerMinute != 30 {
t.Fatalf("security.rate_limit.requests_per_minute = %d, want %d", cfg.Security.RateLimit.RequestsPerMinute, 30)
if cfg.Storage.EncryptionKeyFile != "config.key" {
t.Fatalf("storage.encryption_key_file = %q, want %q", cfg.Storage.EncryptionKeyFile, "config.key")
}
if cfg.Security.RateLimitUI.RequestsPerMinute != 360 {
t.Fatalf("security.rate_limit_ui.requests_per_minute = %d, want %d", cfg.Security.RateLimitUI.RequestsPerMinute, 360)
}
if cfg.Security.RateLimitUI.Enabled {
t.Fatalf("security.rate_limit_ui.enabled = %v, want false", cfg.Security.RateLimitUI.Enabled)
}
if cfg.Security.RateLimitAPIRead.RequestsPerMinute != 300 {
t.Fatalf("security.rate_limit_api_read.requests_per_minute = %d, want %d", cfg.Security.RateLimitAPIRead.RequestsPerMinute, 300)
}
if cfg.Security.RateLimitAPIRead.Enabled {
t.Fatalf("security.rate_limit_api_read.enabled = %v, want false", cfg.Security.RateLimitAPIRead.Enabled)
}
if cfg.Security.RateLimitAPIWrite.RequestsPerMinute != 30 {
t.Fatalf("security.rate_limit_api_write.requests_per_minute = %d, want %d", cfg.Security.RateLimitAPIWrite.RequestsPerMinute, 30)
}
if !cfg.Security.RateLimitAPIWrite.Enabled {
t.Fatalf("security.rate_limit_api_write.enabled = %v, want true", cfg.Security.RateLimitAPIWrite.Enabled)
}
}
@@ -216,6 +265,45 @@ func TestGenerateConfigCommandRejectsUnexpectedArgs(t *testing.T) {
}
}
func TestGenerateKeyCommandWritesKey(t *testing.T) {
t.Parallel()
cmd := newRootCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"generate-key"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
raw := strings.TrimSpace(out.String())
if raw == "" {
t.Fatalf("generate-key output is empty")
}
keyBytes, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
t.Fatalf("generate-key output is not valid base64: %v", err)
}
if len(keyBytes) != 32 {
t.Fatalf("decoded key length = %d, want 32", len(keyBytes))
}
}
func TestGenerateKeyCommandRejectsUnexpectedArgs(t *testing.T) {
t.Parallel()
cmd := newRootCmd()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"generate-key", "extra"})
if err := cmd.Execute(); err == nil {
t.Fatalf("expected error for unexpected args")
}
}
func TestLiveServerEndToEndHTTP(t *testing.T) {
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.yaml")
@@ -231,7 +319,15 @@ storage:
security:
allowed_ips: []
trust_proxy_headers: false
rate_limit:
rate_limit_ui:
enabled: true
requests_per_minute: 30
burst: 10
rate_limit_api_read:
enabled: true
requests_per_minute: 30
burst: 10
rate_limit_api_write:
enabled: true
requests_per_minute: 30
burst: 10
@@ -239,13 +335,14 @@ security:
if err := os.WriteFile(cfgPath, []byte(cfgRaw), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
writeConfigKeyFile(t, cfgPath)
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
logger := log.New(io.Discard, "", 0)
store, err := storage.NewFilesystemStore(cfg.Storage.DataDir)
store, err := storage.NewFilesystemStore(cfg.Storage.DataDir, cfg.Storage.EncryptionKeyBytes)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -261,7 +358,7 @@ security:
_ = os.Chdir(wd)
}()
srv, err := httpapi.NewServer(cfg, store, logger)
srv, err := httpapi.NewServer(cfg, store, logger, logger)
if err != nil {
t.Fatalf("httpapi.NewServer() error = %v", err)
}
+19 -2
View File
@@ -40,13 +40,30 @@ func run(configPath string) error {
}
logger := log.New(os.Stdout, "[scratchbox] ", log.LstdFlags|log.LUTC)
accessWriter, closeAccessWriter, err := newAccessLogWriter(cfg)
if err != nil {
return fmt.Errorf("access log init failed: %w", err)
}
defer func() {
if closeErr := closeAccessWriter(); closeErr != nil {
logger.Printf("access log writer close failed: %v", closeErr)
}
}()
var accessLogger *log.Logger
if cfg.Server.AccessLog.Enabled {
accessLogger = log.New(accessWriter, "[access] ", log.LstdFlags|log.LUTC)
}
store, err := storage.NewFilesystemStore(cfg.Storage.DataDir)
store, err := storage.NewFilesystemStoreWithDefaultTTL(
cfg.Storage.DataDir,
cfg.Limits.DefaultTTLDuration,
cfg.Storage.EncryptionKeyBytes,
)
if err != nil {
return fmt.Errorf("storage init failed: %w", err)
}
server, err := httpapi.NewServer(cfg, store, logger)
server, err := httpapi.NewServer(cfg, store, logger, accessLogger)
if err != nil {
return fmt.Errorf("http init failed: %w", err)
}