diff --git a/internal/config/config.go b/internal/config/config.go index c44bda0..f2d781f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "bytes" "errors" "fmt" + "io" "net" "net/url" "os" @@ -186,9 +187,19 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("read config %q: %w", path, err) } - if err := yaml.Unmarshal(data, cfg); err != nil { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(cfg); err != nil && !errors.Is(err, io.EOF) { return nil, fmt.Errorf("parse config %q: %w", path, err) } + var trailing yaml.Node + if err := decoder.Decode(&trailing); err != nil { + if !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("parse config %q: %w", path, err) + } + } else { + return nil, fmt.Errorf("parse config %q: multiple YAML documents are not supported", path) + } // Keep existing clip_url-only configurations usable after the safer // loopback default was introduced. An explicit clip_listen, including diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9e960aa..20422d5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -239,6 +239,79 @@ func TestLoad_PartialYAML_MergesWithDefaults(t *testing.T) { } } +func TestLoad_RejectsUnknownTopLevelKey(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cfg.yaml") + content := "remote: dev@workstation\nauto_discoverd: true\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + _, err := Load(path) + if err == nil { + t.Fatal("Load() accepted an unknown top-level key") + } + if !strings.Contains(err.Error(), "auto_discoverd") { + t.Fatalf("Load() error = %v, want unknown key name", err) + } + if !strings.Contains(err.Error(), path) { + t.Fatalf("Load() error = %v, want config path", err) + } +} + +func TestLoad_EmptyYAMLKeepsDefaults(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty.yaml") + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v, want empty YAML to keep defaults", err) + } + if cfg.LogLevel != Default().LogLevel || cfg.SSHPort != Default().SSHPort { + t.Fatalf("Load() defaults = log level %q, SSH port %d; want %q, %d", cfg.LogLevel, cfg.SSHPort, Default().LogLevel, Default().SSHPort) + } +} + +func TestLoad_RejectsTrailingYAMLDocument(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "multi.yaml") + content := "remote: dev@workstation\n---\nauto_discoverd: true\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + _, err := Load(path) + if err == nil { + t.Fatal("Load() accepted multiple YAML documents") + } + if !strings.Contains(err.Error(), "multiple YAML documents") { + t.Fatalf("Load() error = %v, want multiple-document error", err) + } + if !strings.Contains(err.Error(), path) { + t.Fatalf("Load() error = %v, want config path", err) + } +} + +func TestLoad_RejectsMalformedTrailingYAMLDocument(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "multi-invalid.yaml") + content := "remote: dev@workstation\n---\nnot: [valid\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + _, err := Load(path) + if err == nil { + t.Fatal("Load() accepted a malformed trailing YAML document") + } + if !strings.Contains(err.Error(), path) { + t.Fatalf("Load() error = %v, want config path", err) + } +} + func TestLoad_InvalidYAML(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "bad.yaml")