Many changes

This commit is contained in:
2026-05-31 20:17:49 -05:00
parent 9e398957a9
commit 99d46ecd2a
28 changed files with 3729 additions and 594 deletions
+114 -24
View File
@@ -1,7 +1,10 @@
package main
import (
"bytes"
"fmt"
"reflect"
"strings"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
@@ -22,32 +25,11 @@ func newGenerateConfigCmd() *cobra.Command {
}
func writeDefaultConfig(cmd *cobra.Command) error {
cfg := config.Config{
Server: config.ServerConfig{
ListenAddr: ":8080",
},
Limits: config.LimitsConfig{
MaxUploadSize: "100MB",
DefaultTTL: "1h",
},
Storage: config.StorageConfig{
DataDir: "./data",
CleanupInterval: "1m",
},
Security: config.SecurityConfig{
AllowedIPs: []string{},
TrustProxyHeaders: false,
RateLimit: config.RateLimitConfig{
Enabled: true,
RequestsPerMinute: 30,
Burst: 10,
},
},
}
cfg := config.Default()
b, err := yaml.Marshal(cfg)
b, err := marshalYAMLWithComments(cfg)
if err != nil {
return fmt.Errorf("marshal default config: %w", err)
return fmt.Errorf("marshal default config with comments: %w", err)
}
if _, err := cmd.OutOrStdout().Write(b); err != nil {
@@ -56,3 +38,111 @@ func writeDefaultConfig(cmd *cobra.Command) error {
return nil
}
func marshalYAMLWithComments(v any) ([]byte, error) {
root, err := nodeFromValue(reflect.ValueOf(v))
if err != nil {
return nil, err
}
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(4)
if err := enc.Encode(root); err != nil {
return nil, fmt.Errorf("encode yaml: %w", err)
}
if err := enc.Close(); err != nil {
return nil, fmt.Errorf("close yaml encoder: %w", err)
}
return buf.Bytes(), nil
}
func nodeFromValue(value reflect.Value) (*yaml.Node, error) {
if !value.IsValid() {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
}
for value.Kind() == reflect.Ptr {
if value.IsNil() {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
}
value = value.Elem()
}
if value.Kind() == reflect.Struct {
return nodeFromStruct(value)
}
return scalarOrCollectionNode(value.Interface())
}
func nodeFromStruct(value reflect.Value) (*yaml.Node, error) {
typ := value.Type()
mapping := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if !field.IsExported() {
continue
}
key, skip := yamlFieldName(field)
if skip {
continue
}
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: key,
}
if comment := strings.TrimSpace(field.Tag.Get("comment")); comment != "" {
keyNode.HeadComment = comment
}
valueNode, err := nodeFromValue(value.Field(i))
if err != nil {
return nil, fmt.Errorf("field %s: %w", field.Name, err)
}
mapping.Content = append(mapping.Content, keyNode, valueNode)
}
return mapping, nil
}
func scalarOrCollectionNode(value any) (*yaml.Node, error) {
b, err := yaml.Marshal(value)
if err != nil {
return nil, fmt.Errorf("marshal value node: %w", err)
}
var doc yaml.Node
if err := yaml.Unmarshal(b, &doc); err != nil {
return nil, fmt.Errorf("unmarshal value node: %w", err)
}
if len(doc.Content) == 0 {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
}
return doc.Content[0], nil
}
func yamlFieldName(field reflect.StructField) (string, bool) {
tag := strings.TrimSpace(field.Tag.Get("yaml"))
if tag == "-" {
return "", true
}
if tag == "" {
return strings.ToLower(field.Name), false
}
parts := strings.Split(tag, ",")
key := strings.TrimSpace(parts[0])
if key == "-" {
return "", true
}
if key == "" {
return strings.ToLower(field.Name), false
}
return key, false
}
+14 -53
View File
@@ -16,10 +16,10 @@ import (
"testing"
"time"
"gopkg.in/yaml.v3"
"scratchbox/internal/config"
httpapi "scratchbox/internal/http"
"scratchbox/internal/storage"
"gopkg.in/yaml.v3"
)
func repoRoot(t *testing.T) string {
@@ -125,44 +125,6 @@ security:
}
}
func TestMainProcessHTTPInitFails(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.yaml")
cfg := `
server:
listen_addr: "127.0.0.1:0"
limits:
max_upload_size: "1MB"
default_ttl: "1h"
storage:
data_dir: "` + filepath.Join(tmp, "data") + `"
cleanup_interval: "10s"
security:
allowed_ips: []
trust_proxy_headers: false
rate_limit:
enabled: true
requests_per_minute: 10
burst: 1
`
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainHelperProcess")
cmd.Dir = repoRoot(t)
cmd.Env = append(os.Environ(),
"GO_WANT_MAIN_HELPER=1",
"MAIN_HELPER_MODE=http-fail",
"MAIN_HELPER_CONFIG="+cfgPath,
)
if err := cmd.Run(); err == nil {
t.Fatalf("expected helper process failure for http init")
}
}
func TestMainHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_MAIN_HELPER") != "1" {
return
@@ -193,14 +155,6 @@ func TestMainHelperProcess(t *testing.T) {
os.Args = []string{"scratchbox", "server", "--config", cfgPath}
main()
os.Exit(0)
case "http-fail":
cfgPath := os.Getenv("MAIN_HELPER_CONFIG")
if err := os.Chdir(t.TempDir()); err != nil {
os.Exit(2)
}
os.Args = []string{"scratchbox", "server", "--config", cfgPath}
main()
os.Exit(0)
default:
os.Exit(2)
}
@@ -222,17 +176,24 @@ func TestGenerateConfigCommandWritesDefaultYAML(t *testing.T) {
if out.Len() == 0 {
t.Fatalf("generate-config output is empty")
}
output := out.String()
if !strings.Contains(output, "# HTTP listener settings.") {
t.Fatalf("generate-config output missing expected section comment")
}
if !strings.Contains(output, "# Maximum upload request body size. Supports B, KB, MB, GB and KiB, MiB, GiB (case-insensitive). Defaults to bytes when unit is omitted (example: 1048576).") {
t.Fatalf("generate-config output missing expected field comment")
}
var cfg config.Config
if err := yaml.Unmarshal(out.Bytes(), &cfg); err != nil {
if err := yaml.Unmarshal([]byte(output), &cfg); err != nil {
t.Fatalf("yaml.Unmarshal() error = %v", err)
}
if cfg.Server.ListenAddr != ":8080" {
t.Fatalf("server.listen_addr = %q, want %q", cfg.Server.ListenAddr, ":8080")
}
if cfg.Limits.MaxUploadSize != "100MB" {
t.Fatalf("limits.max_upload_size = %q, want %q", cfg.Limits.MaxUploadSize, "100MB")
if cfg.Limits.MaxUploadSize != "100MiB" {
t.Fatalf("limits.max_upload_size = %q, want %q", cfg.Limits.MaxUploadSize, "100MiB")
}
if cfg.Storage.DataDir != "./data" {
t.Fatalf("storage.data_dir = %q, want %q", cfg.Storage.DataDir, "./data")
@@ -349,7 +310,7 @@ security:
t.Fatalf("create payload missing id")
}
rawResp, err := client.Get(baseURL + "/raw/" + id)
rawResp, err := client.Get(baseURL + "/api/raw/" + id)
if err != nil {
t.Fatalf("client.Get(raw) error = %v", err)
}
@@ -377,7 +338,7 @@ security:
if err != nil {
t.Fatalf("ReadAll(view) error = %v", err)
}
if !strings.Contains(string(viewBody), "Scratch "+id) {
t.Fatalf("view body missing scratch id")
if !strings.Contains(string(viewBody), `id="app"`) {
t.Fatalf("view body missing SPA mount point")
}
}