diff --git a/cmd/netsynth/main.go b/cmd/netsynth/main.go index 07f9be2..341b439 100644 --- a/cmd/netsynth/main.go +++ b/cmd/netsynth/main.go @@ -84,10 +84,11 @@ func run(cmd *cobra.Command, args []string) error { } // Load config (CFG-01 through CFG-05, D-11: fail fast before capture) - freqCfgs, err := config.Load(configPath) + loadResult, err := config.Load(configPath) if err != nil { return err } + freqCfgs := loadResult.FreqCfgs // Resolve output path if outputPath == "" { diff --git a/config/config.go b/config/config.go index 44ac055..5f6b800 100644 --- a/config/config.go +++ b/config/config.go @@ -5,6 +5,7 @@ package config import ( "errors" "fmt" + "hash/fnv" "io/fs" "os" "path/filepath" @@ -23,9 +24,27 @@ type SoundOverride struct { Waveform *string `toml:"waveform"` } +// RawRule holds a user-defined classification rule as decoded from TOML. +// Port is a pointer so we can distinguish "not set" (nil, matches any port) from port=0. +type RawRule struct { + Port *uint16 `toml:"port"` + Protocol string `toml:"protocol"` + Class string `toml:"class"` +} + // rawConfig is the top-level TOML decode target. type rawConfig struct { Sounds map[string]SoundOverride `toml:"sounds"` + Rules []RawRule `toml:"rules"` +} + +// LoadResult is the return type from Load(). It carries the merged FreqConfig map, +// the user-defined classification rules (to be prepended before DefaultRules by the caller), +// and the resolved config file path (empty string if no config was found). +type LoadResult struct { + FreqCfgs map[classify.TrafficClass]synth.FreqConfig + UserRules []classify.Rule + ConfigPath string } // validWaveforms maps TOML waveform strings to WaveformType constants. @@ -39,33 +58,43 @@ var validWaveforms = map[string]synth.WaveformType{ // Load finds, parses, validates, and merges a TOML config file. // // configPath is the --config flag value; empty string triggers auto-discovery. -// Returns the merged FreqConfig map (defaults + overrides) ready for synth.NewBank. +// Returns a LoadResult with the merged FreqConfig map, user-defined rules, and resolved path. // Returns an error on: explicit file not found, parse errors, unknown keys, -// type mismatches, or invalid waveform values. Returns no error (uses defaults) -// when no config is found during auto-discovery. -func Load(configPath string) (map[classify.TrafficClass]synth.FreqConfig, error) { +// type mismatches, invalid waveform values, or invalid rule definitions. +// Returns no error (uses defaults) when no config is found during auto-discovery. +func Load(configPath string) (LoadResult, error) { path, explicit, err := resolvePath(configPath) if err != nil { - return nil, err + return LoadResult{}, err } if path == "" { // No config found during auto-discovery — use defaults silently (CFG-02) - return copyDefaults(), nil + return LoadResult{FreqCfgs: copyDefaults(), UserRules: []classify.Rule{}, ConfigPath: ""}, nil } raw, err := parseFile(path) if err != nil { if explicit && errors.Is(err, fs.ErrNotExist) { - return nil, fmt.Errorf("config file not found: %s", configPath) + return LoadResult{}, fmt.Errorf("config file not found: %s", configPath) } - return nil, err + return LoadResult{}, err } if err := validate(raw); err != nil { - return nil, err + return LoadResult{}, err } - return merge(copyDefaults(), raw.Sounds), nil + userRules := convertRules(raw.Rules) + freqCfgs := copyDefaults() + // Add auto-freq entries BEFORE merge so that [sounds.X] overrides for user classes apply. + addAutoFreqEntries(freqCfgs, userRules) + merge(freqCfgs, raw.Sounds) + + return LoadResult{ + FreqCfgs: freqCfgs, + UserRules: userRules, + ConfigPath: path, + }, nil } // resolvePath resolves the config path from an explicit flag value or auto-discovery. @@ -119,7 +148,7 @@ func parseFile(path string) (rawConfig, error) { return raw, nil } -// validate checks waveform strings before merge so we fail fast at startup (D-11). +// validate checks waveform strings and rules before merge so we fail fast at startup (D-11). func validate(raw rawConfig) error { for _, override := range raw.Sounds { if override.Waveform != nil { @@ -128,9 +157,73 @@ func validate(raw rawConfig) error { } } } + return validateRules(raw.Rules) +} + +// validateRules checks that each rule has a valid protocol and a non-empty class. +func validateRules(rules []RawRule) error { + validProtocols := map[string]bool{"tcp": true, "udp": true, "icmp": true} + for i, r := range rules { + if r.Protocol == "" { + return fmt.Errorf("config: rules[%d]: protocol is required", i) + } + if !validProtocols[r.Protocol] { + return fmt.Errorf("config: rules[%d]: invalid protocol %q -- valid: tcp, udp, icmp", i, r.Protocol) + } + if r.Class == "" { + return fmt.Errorf("config: rules[%d]: class is required", i) + } + } return nil } +// convertRules converts a slice of RawRule (from TOML) into classify.Rule slice. +func convertRules(raw []RawRule) []classify.Rule { + result := make([]classify.Rule, len(raw)) + for i, r := range raw { + var port uint16 + if r.Port != nil { + port = *r.Port + } + result[i] = classify.Rule{ + Protocol: r.Protocol, + DstPort: port, + Class: classify.TrafficClass(r.Class), + } + } + return result +} + +// autoAssignFreq computes a deterministic frequency in [1200, 2350] Hz for a class name +// using FNV-32a hashing. Same input always produces the same output. +func autoAssignFreq(className string) float64 { + h := fnv.New32a() + h.Write([]byte(className)) + const ( + baseHz = 1200.0 + stepHz = 50.0 + numSteps = uint32(24) + ) + return baseHz + float64(h.Sum32()%numSteps)*stepHz +} + +// addAutoFreqEntries adds a FreqConfig entry for each user-defined class that doesn't +// already have one in the map. Built-in classes that appear in user rules are skipped. +// Must be called AFTER merge() so that [sounds.X] overrides are already applied. +func addAutoFreqEntries(cfgs map[classify.TrafficClass]synth.FreqConfig, userRules []classify.Rule) { + for _, rule := range userRules { + if _, exists := cfgs[rule.Class]; !exists { + baseHz := autoAssignFreq(string(rule.Class)) + cfgs[rule.Class] = synth.FreqConfig{ + BaseHz: baseHz, + WaveformType: synth.WaveformSine, + Harmonics: synth.WaveformPresetHarmonics(synth.WaveformSine, baseHz, synth.SampleRate), + Pan: 0.0, + } + } + } +} + // parseWaveform converts a TOML waveform string to a WaveformType. func parseWaveform(s string) (synth.WaveformType, error) { if wt, ok := validWaveforms[s]; ok { diff --git a/config/config_test.go b/config/config_test.go index 1a3e078..a2a4330 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -31,10 +31,11 @@ func writeTOML(t *testing.T, content string) string { func TestLoadPartialOverrideFrequency(t *testing.T) { path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\n") - cfgs, err := config.Load(path) + result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } + cfgs := result.FreqCfgs if cfgs[classify.ClassICMP].BaseHz != 100.0 { t.Errorf("ICMP BaseHz: got %v, want 100.0", cfgs[classify.ClassICMP].BaseHz) @@ -54,10 +55,11 @@ func TestLoadPartialOverrideFrequency(t *testing.T) { func TestLoadPartialOverrideWaveform(t *testing.T) { path := writeTOML(t, "[sounds.ICMP]\nwaveform = \"square\"\n") - cfgs, err := config.Load(path) + result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } + cfgs := result.FreqCfgs if cfgs[classify.ClassICMP].WaveformType != synth.WaveformSquare { t.Errorf("ICMP WaveformType: got %v, want WaveformSquare", cfgs[classify.ClassICMP].WaveformType) @@ -76,10 +78,11 @@ func TestLoadPartialOverrideWaveform(t *testing.T) { func TestLoadBothOverrides(t *testing.T) { path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\nwaveform = \"square\"\n") - cfgs, err := config.Load(path) + result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } + cfgs := result.FreqCfgs if cfgs[classify.ClassICMP].BaseHz != 100.0 { t.Errorf("ICMP BaseHz: got %v, want 100.0", cfgs[classify.ClassICMP].BaseHz) @@ -107,10 +110,11 @@ func TestLoadNoConfig(t *testing.T) { // Chdir to a temp dir that has no netsynth.toml t.Chdir(t.TempDir()) - cfgs, err := config.Load("") + result, err := config.Load("") if err != nil { t.Fatalf("Load with no config: %v", err) } + cfgs := result.FreqCfgs if len(cfgs) != 14 { t.Errorf("result map size: got %d, want 14", len(cfgs)) } @@ -135,10 +139,11 @@ func TestLoadExplicitMissing(t *testing.T) { func TestLoadUnknownClass(t *testing.T) { path := writeTOML(t, "[sounds.BOGUS]\nfrequency = 100.0\n") - cfgs, err := config.Load(path) + result, err := config.Load(path) if err != nil { t.Fatalf("Load with unknown class: %v", err) } + cfgs := result.FreqCfgs if len(cfgs) != 14 { t.Errorf("result map size: got %d, want 14 (BOGUS should not appear)", len(cfgs)) } @@ -165,10 +170,11 @@ func TestLoadInvalidWaveform(t *testing.T) { func TestLoadAllDefaultsPresent(t *testing.T) { path := writeTOML(t, "[sounds.ICMP]\nfrequency = 200.0\n") - cfgs, err := config.Load(path) + result, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } + cfgs := result.FreqCfgs if len(cfgs) != 14 { t.Errorf("result map size: got %d, want 14", len(cfgs)) } @@ -178,3 +184,274 @@ func TestLoadAllDefaultsPresent(t *testing.T) { } } } + +// --- New tests for Phase 7 Plan 01 --- + +// TestLoadCustomRules: TOML with [[rules]] block (port=8080, protocol="tcp", class="MyApp") +// plus [sounds.MyApp] (frequency=300.0) parses successfully. +func TestLoadCustomRules(t *testing.T) { + toml := ` +[[rules]] +port = 8080 +protocol = "tcp" +class = "MyApp" + +[sounds.MyApp] +frequency = 300.0 +` + path := writeTOML(t, toml) + + result, err := config.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(result.UserRules) != 1 { + t.Fatalf("UserRules len: got %d, want 1", len(result.UserRules)) + } + rule := result.UserRules[0] + if rule.Protocol != "tcp" { + t.Errorf("UserRules[0].Protocol: got %q, want %q", rule.Protocol, "tcp") + } + if rule.DstPort != 8080 { + t.Errorf("UserRules[0].DstPort: got %d, want 8080", rule.DstPort) + } + if rule.Class != "MyApp" { + t.Errorf("UserRules[0].Class: got %q, want %q", rule.Class, "MyApp") + } + if result.FreqCfgs["MyApp"].BaseHz != 300.0 { + t.Errorf("FreqCfgs[MyApp].BaseHz: got %v, want 300.0", result.FreqCfgs["MyApp"].BaseHz) + } +} + +// TestLoadCustomRuleNoPort: TOML with [[rules]] (protocol="udp", class="AllUDP", no port field) +// parses; UserRules[0].DstPort == 0. +func TestLoadCustomRuleNoPort(t *testing.T) { + toml := ` +[[rules]] +protocol = "udp" +class = "AllUDP" +` + path := writeTOML(t, toml) + + result, err := config.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(result.UserRules) != 1 { + t.Fatalf("UserRules len: got %d, want 1", len(result.UserRules)) + } + if result.UserRules[0].DstPort != 0 { + t.Errorf("UserRules[0].DstPort: got %d, want 0", result.UserRules[0].DstPort) + } +} + +// TestLoadCustomRuleMissingProtocol: [[rules]] with class="X" but no protocol -> error containing "protocol is required". +func TestLoadCustomRuleMissingProtocol(t *testing.T) { + toml := ` +[[rules]] +class = "X" +` + path := writeTOML(t, toml) + + _, err := config.Load(path) + if err == nil { + t.Fatal("expected error for missing protocol, got nil") + } + if !strings.Contains(err.Error(), "protocol is required") { + t.Errorf("error should contain 'protocol is required', got: %v", err) + } +} + +// TestLoadCustomRuleMissingClass: [[rules]] with protocol="tcp" but no class -> error containing "class is required". +func TestLoadCustomRuleMissingClass(t *testing.T) { + toml := ` +[[rules]] +protocol = "tcp" +` + path := writeTOML(t, toml) + + _, err := config.Load(path) + if err == nil { + t.Fatal("expected error for missing class, got nil") + } + if !strings.Contains(err.Error(), "class is required") { + t.Errorf("error should contain 'class is required', got: %v", err) + } +} + +// TestLoadCustomRuleInvalidProtocol: [[rules]] with protocol="ftp" -> error containing "invalid protocol". +func TestLoadCustomRuleInvalidProtocol(t *testing.T) { + toml := ` +[[rules]] +protocol = "ftp" +class = "FTPTraffic" +` + path := writeTOML(t, toml) + + _, err := config.Load(path) + if err == nil { + t.Fatal("expected error for invalid protocol 'ftp', got nil") + } + if !strings.Contains(err.Error(), "invalid protocol") { + t.Errorf("error should contain 'invalid protocol', got: %v", err) + } +} + +// TestLoadCustomRuleUnknownField: [[rules]] with typo_field="bad" -> error containing "typo_field". +func TestLoadCustomRuleUnknownField(t *testing.T) { + toml := ` +[[rules]] +protocol = "tcp" +class = "SomeClass" +typo_field = "bad" +` + path := writeTOML(t, toml) + + _, err := config.Load(path) + if err == nil { + t.Fatal("expected error for unknown field 'typo_field', got nil") + } + if !strings.Contains(err.Error(), "typo_field") { + t.Errorf("error should contain 'typo_field', got: %v", err) + } +} + +// TestUserRulesPrepend: Load returns UserRules separately from FreqCfgs; +// caller can do append(result.UserRules, classify.DefaultRules...) to get user rules first. +func TestUserRulesPrepend(t *testing.T) { + toml := ` +[[rules]] +protocol = "tcp" +port = 9000 +class = "MyService" +` + path := writeTOML(t, toml) + + result, err := config.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + combined := append(result.UserRules, classify.DefaultRules...) + if len(combined) != len(classify.DefaultRules)+1 { + t.Errorf("combined rules len: got %d, want %d", len(combined), len(classify.DefaultRules)+1) + } + // User rule should be first + if combined[0].Class != "MyService" { + t.Errorf("first rule should be user rule 'MyService', got %q", combined[0].Class) + } +} + +// TestAutoFreqAssignment: TOML with [[rules]] (class="GameServer", protocol="tcp") and +// NO [sounds.GameServer] -> FreqCfgs contains "GameServer" entry with BaseHz in [1200, 2350] +// and WaveformType == WaveformSine. +func TestAutoFreqAssignment(t *testing.T) { + toml := ` +[[rules]] +protocol = "tcp" +class = "GameServer" +` + path := writeTOML(t, toml) + + result, err := config.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + cfg, ok := result.FreqCfgs["GameServer"] + if !ok { + t.Fatal("FreqCfgs should contain 'GameServer' entry from auto-freq assignment") + } + if cfg.BaseHz < 1200.0 || cfg.BaseHz > 2350.0 { + t.Errorf("GameServer BaseHz: got %v, want in [1200, 2350]", cfg.BaseHz) + } + if cfg.WaveformType != synth.WaveformSine { + t.Errorf("GameServer WaveformType: got %v, want WaveformSine", cfg.WaveformType) + } +} + +// TestAutoFreqDeterministic: Two Load() calls with same class name produce same BaseHz. +func TestAutoFreqDeterministic(t *testing.T) { + toml := ` +[[rules]] +protocol = "tcp" +class = "MyDeterministicClass" +` + path := writeTOML(t, toml) + + result1, err := config.Load(path) + if err != nil { + t.Fatalf("Load (1): %v", err) + } + result2, err := config.Load(path) + if err != nil { + t.Fatalf("Load (2): %v", err) + } + hz1 := result1.FreqCfgs["MyDeterministicClass"].BaseHz + hz2 := result2.FreqCfgs["MyDeterministicClass"].BaseHz + if hz1 != hz2 { + t.Errorf("auto-freq not deterministic: first=%v, second=%v", hz1, hz2) + } +} + +// TestAutoFreqSkipsBuiltins: TOML with [[rules]] (class="HTTPS", protocol="tcp", port=443) +// -> FreqCfgs["HTTPS"].BaseHz == 175.0 (the default), NOT an auto-assigned value. +func TestAutoFreqSkipsBuiltins(t *testing.T) { + toml := ` +[[rules]] +protocol = "tcp" +port = 443 +class = "HTTPS" +` + path := writeTOML(t, toml) + + result, err := config.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if result.FreqCfgs[classify.ClassHTTPS].BaseHz != 175.0 { + t.Errorf("HTTPS BaseHz: got %v, want 175.0 (default, not auto-assigned)", result.FreqCfgs[classify.ClassHTTPS].BaseHz) + } +} + +// TestLoadResultConfigPath: Load(explicit_path) -> LoadResult.ConfigPath == explicit_path; +// Load("") with no file -> LoadResult.ConfigPath == "". +func TestLoadResultConfigPath(t *testing.T) { + // explicit path + path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\n") + result, err := config.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if result.ConfigPath != path { + t.Errorf("ConfigPath: got %q, want %q", result.ConfigPath, path) + } + + // empty path with no config file + t.Chdir(t.TempDir()) + result2, err := config.Load("") + if err != nil { + t.Fatalf("Load with no config: %v", err) + } + if result2.ConfigPath != "" { + t.Errorf("ConfigPath for no-config: got %q, want %q", result2.ConfigPath, "") + } +} + +// TestLoadNoConfigReturnsLoadResult: Load("") in empty dir returns LoadResult with +// len(FreqCfgs)==14, len(UserRules)==0, ConfigPath=="". +func TestLoadNoConfigReturnsLoadResult(t *testing.T) { + t.Chdir(t.TempDir()) + + result, err := config.Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(result.FreqCfgs) != 14 { + t.Errorf("FreqCfgs len: got %d, want 14", len(result.FreqCfgs)) + } + if len(result.UserRules) != 0 { + t.Errorf("UserRules len: got %d, want 0", len(result.UserRules)) + } + if result.ConfigPath != "" { + t.Errorf("ConfigPath: got %q, want %q", result.ConfigPath, "") + } +}