Rules-based browser launcher for TUI + GNOME. switchyard.aly.codes
tui gome bowser go
0

Configure Feed

Select the types of activity you want to include in your feed.

extract routing logic into internal/routing

Aly Raffauf (Jul 5, 2026, 11:22 AM EDT) 33cdde0b 61b5b623

+1014 -1140
+7 -3
.github/workflows/ci.yml
··· 29 29 run: | 30 30 scripts/generate-flatpak-go-modules.py 31 31 git diff --exit-code -- flatpak/go-modules.json 32 - - name: Run tests with coverage 33 - run: just test-coverage 32 + - name: Test routing package with coverage 33 + run: just test-routing-coverage 34 + - name: Test command config compatibility 35 + run: just test-config 36 + - name: Test command sanitizer 37 + run: just test-sanitizer 34 38 - name: Display coverage summary 35 39 run: | 36 40 echo "## Test Coverage Summary" >> $GITHUB_STEP_SUMMARY ··· 73 77 name: "Build Flatpak" 74 78 runs-on: ubuntu-latest 75 79 container: 76 - image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-49 80 + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50 77 81 options: --privileged 78 82 steps: 79 83 - uses: actions/checkout@v7
+6 -80
cmd/switchyard/config.go
··· 9 9 "path/filepath" 10 10 "strings" 11 11 12 + "github.com/alyraffauf/switchyard/internal/routing" 12 13 "github.com/pelletier/go-toml/v2" 13 14 ) 14 15 15 - type Config struct { 16 - PromptOnClick bool `toml:"prompt_on_click"` 17 - FavoriteBrowser string `toml:"favorite_browser"` 18 - HiddenBrowsers []string `toml:"hidden_browsers"` 19 - CheckDefaultBrowser bool `toml:"check_default_browser"` 20 - ShowAppNames bool `toml:"show_app_names"` 21 - ForceDarkMode bool `toml:"force_dark_mode"` 22 - StayAlive bool `toml:"stay_alive"` 23 - RemoveTrackingParameters bool `toml:"remove_tracking_parameters"` 24 - Redirections []Redirection `toml:"redirections,omitempty"` 25 - Rules []Rule `toml:"rules"` 26 - } 27 - 28 - type Redirection struct { 29 - Name string `toml:"name,omitempty"` 30 - Type string `toml:"type,omitempty"` // "domain", "wildcard", or "regex", defaults to "domain" 31 - Find string `toml:"find"` 32 - Replace string `toml:"replace"` 33 - } 34 - 35 - type Condition struct { 36 - Type string `toml:"type"` // "domain", "keyword", "glob", "regex" 37 - Pattern string `toml:"pattern"` 38 - Negate bool `toml:"negate,omitempty"` 39 - } 40 - 41 - type Rule struct { 42 - Name string `toml:"name"` 43 - Conditions []Condition `toml:"conditions"` 44 - Logic string `toml:"logic,omitempty"` // "all" or "any" 45 - Browser string `toml:"browser"` 46 - AlwaysAsk bool `toml:"always_ask"` 47 - } 16 + type Config = routing.Config 17 + type Redirection = routing.Redirection 18 + type Condition = routing.Condition 19 + type Rule = routing.Rule 48 20 49 21 func configDir() string { 50 22 if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { ··· 59 31 } 60 32 61 33 func newDefaultConfig() *Config { 62 - return &Config{ 63 - PromptOnClick: true, 64 - CheckDefaultBrowser: true, 65 - ShowAppNames: false, // Default: hide app names, show tooltips 66 - ForceDarkMode: true, // Default: force dark mode 67 - StayAlive: true, 68 - RemoveTrackingParameters: false, 69 - Rules: []Rule{}, 70 - } 34 + return routing.NewDefaultConfig() 71 35 } 72 36 73 37 func loadConfig() *Config { ··· 112 76 } 113 77 114 78 return os.WriteFile(configPath(), data, 0644) 115 - } 116 - 117 - func (cfg *Config) matchRule(url string) (browserID string, alwaysAsk bool, matched bool) { 118 - for _, rule := range cfg.Rules { 119 - if rule.matchesConditions(url) { 120 - return rule.Browser, rule.AlwaysAsk, true 121 - } 122 - } 123 - return "", false, false 124 - } 125 - 126 - func (r *Rule) matchesConditions(url string) bool { 127 - if len(r.Conditions) == 0 { 128 - return false 129 - } 130 - 131 - logic := r.Logic 132 - if logic == "" { 133 - logic = "all" // Default to AND logic 134 - } 135 - 136 - if logic == "all" { 137 - // AND: All conditions must match 138 - for _, cond := range r.Conditions { 139 - if !matchesPattern(url, cond.Pattern, cond.Type, cond.Negate) { 140 - return false 141 - } 142 - } 143 - return true 144 - } else { 145 - // OR: Any condition must match 146 - for _, cond := range r.Conditions { 147 - if matchesPattern(url, cond.Pattern, cond.Type, cond.Negate) { 148 - return true 149 - } 150 - } 151 - return false 152 - } 153 79 } 154 80 155 81 // hostCommand runs commands on the host when Switchyard is sandboxed by Flatpak.
+12 -16
cmd/switchyard/config_test.go internal/routing/config_test.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package routing 4 4 5 5 import ( 6 6 "testing" 7 7 ) 8 8 9 - // TestRuleMatchesConditions_AND tests AND logic (all conditions must match) 10 9 func TestRuleMatchesConditions_AND(t *testing.T) { 11 10 tests := []struct { 12 11 name string ··· 146 145 147 146 for _, tt := range tests { 148 147 t.Run(tt.name, func(t *testing.T) { 149 - result := tt.rule.matchesConditions(tt.url) 148 + result := tt.rule.MatchesConditions(tt.url) 150 149 if result != tt.want { 151 - t.Errorf("Rule.matchesConditions(%q) = %v, want %v", tt.url, result, tt.want) 150 + t.Errorf("Rule.MatchesConditions(%q) = %v, want %v", tt.url, result, tt.want) 152 151 } 153 152 }) 154 153 } 155 154 } 156 155 157 - // TestRuleMatchesConditions_OR tests OR logic (any condition can match) 158 156 func TestRuleMatchesConditions_OR(t *testing.T) { 159 157 tests := []struct { 160 158 name string ··· 264 262 265 263 for _, tt := range tests { 266 264 t.Run(tt.name, func(t *testing.T) { 267 - result := tt.rule.matchesConditions(tt.url) 265 + result := tt.rule.MatchesConditions(tt.url) 268 266 if result != tt.want { 269 - t.Errorf("Rule.matchesConditions(%q) = %v, want %v", tt.url, result, tt.want) 267 + t.Errorf("Rule.MatchesConditions(%q) = %v, want %v", tt.url, result, tt.want) 270 268 } 271 269 }) 272 270 } 273 271 } 274 272 275 - // TestConfigMatchRule tests the full rule matching from Config 276 273 func TestConfigMatchRule(t *testing.T) { 277 274 tests := []struct { 278 275 name string ··· 458 455 459 456 for _, tt := range tests { 460 457 t.Run(tt.name, func(t *testing.T) { 461 - browserID, alwaysAsk, matched := tt.config.matchRule(tt.url) 458 + browserID, alwaysAsk, matched := tt.config.MatchRule(tt.url) 462 459 if browserID != tt.wantBrowserID { 463 - t.Errorf("Config.matchRule(%q) browserID = %q, want %q", tt.url, browserID, tt.wantBrowserID) 460 + t.Errorf("Config.MatchRule(%q) browserID = %q, want %q", tt.url, browserID, tt.wantBrowserID) 464 461 } 465 462 if alwaysAsk != tt.wantAlwaysAsk { 466 - t.Errorf("Config.matchRule(%q) alwaysAsk = %v, want %v", tt.url, alwaysAsk, tt.wantAlwaysAsk) 463 + t.Errorf("Config.MatchRule(%q) alwaysAsk = %v, want %v", tt.url, alwaysAsk, tt.wantAlwaysAsk) 467 464 } 468 465 if matched != tt.wantMatched { 469 - t.Errorf("Config.matchRule(%q) matched = %v, want %v", tt.url, matched, tt.wantMatched) 466 + t.Errorf("Config.MatchRule(%q) matched = %v, want %v", tt.url, matched, tt.wantMatched) 470 467 } 471 468 }) 472 469 } 473 470 } 474 471 475 - // TestConfigMatchRule_RuleOrdering tests that rules are matched in order (first match wins) 476 472 func TestConfigMatchRule_RuleOrdering(t *testing.T) { 477 473 tests := []struct { 478 474 name string ··· 675 671 676 672 for _, tt := range tests { 677 673 t.Run(tt.name, func(t *testing.T) { 678 - browserID, _, matched := tt.config.matchRule(tt.url) 674 + browserID, _, matched := tt.config.MatchRule(tt.url) 679 675 if browserID != tt.wantBrowserID { 680 - t.Errorf("Config.matchRule(%q) browserID = %q, want %q", 676 + t.Errorf("Config.MatchRule(%q) browserID = %q, want %q", 681 677 tt.url, browserID, tt.wantBrowserID) 682 678 } 683 679 if matched != tt.wantMatched { 684 - t.Errorf("Config.matchRule(%q) matched = %v, want %v", 680 + t.Errorf("Config.MatchRule(%q) matched = %v, want %v", 685 681 tt.url, matched, tt.wantMatched) 686 682 } 687 683 })
+11 -16
cmd/switchyard/dialog_helpers.go
··· 17 17 cancelBtn.ConnectClicked(func() { onCancel() }) 18 18 header.PackStart(cancelBtn) 19 19 20 - actionBtn := gtk.NewButton() 21 - actionBtn.SetLabel(actionLabel) 22 - actionBtn.AddCSSClass("suggested-action") 20 + actionButton := gtk.NewButton() 21 + actionButton.SetLabel(actionLabel) 22 + actionButton.AddCSSClass("suggested-action") 23 23 if onAction != nil { 24 - actionBtn.ConnectClicked(func() { onAction() }) 24 + actionButton.ConnectClicked(func() { onAction() }) 25 25 } 26 - header.PackEnd(actionBtn) 26 + header.PackEnd(actionButton) 27 27 28 - return header, actionBtn 28 + return header, actionButton 29 29 } 30 30 31 - // dialogWithToolbar creates a dialog with a standard toolbar and scrolled content area. 32 - // Returns the dialog and a content box where widgets can be added. 33 31 func dialogWithToolbar(title string, width, height int, header *adw.HeaderBar) (*adw.Dialog, *gtk.Box, *gtk.ScrolledWindow) { 34 32 dialog := adw.NewDialog() 35 33 dialog.SetTitle(title) ··· 55 53 return dialog, content, scrolledWindow 56 54 } 57 55 58 - // simpleDialogWithToolbar creates a dialog without a scrolled window (for simple, small dialogs). 59 56 func simpleDialogWithToolbar(title string, width, height int, header *adw.HeaderBar) (*adw.Dialog, *gtk.Box) { 60 57 dialog := adw.NewDialog() 61 58 dialog.SetTitle(title) ··· 78 75 return dialog, content 79 76 } 80 77 81 - // converts condition type string to combo row index. 82 - func conditionTypeToIndex(condType string) uint { 83 - switch condType { 78 + func conditionTypeToIndex(conditionType string) uint { 79 + switch conditionType { 84 80 case "domain": 85 81 return 0 86 82 case "keyword": ··· 94 90 } 95 91 } 96 92 97 - // convert a combo row index to condition type string. 98 93 func indexToConditionType(index uint) string { 99 94 switch index { 100 95 case 0: ··· 110 105 } 111 106 } 112 107 113 - func redirectionTypeToIndex(rwType string) uint { 114 - switch rwType { 108 + func redirectionTypeToIndex(redirectionType string) uint { 109 + switch redirectionType { 115 110 case "wildcard": 116 111 return 1 117 112 case "regex": 118 113 return 2 119 114 default: 120 - return 0 // "domain" is default 115 + return 0 121 116 } 122 117 } 123 118
+7 -8
cmd/switchyard/dialog_redirection.go
··· 3 3 package main 4 4 5 5 import ( 6 + "github.com/alyraffauf/switchyard/internal/routing" 6 7 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 7 8 "github.com/diamondburned/gotk4/pkg/gtk/v4" 8 9 ) ··· 31 32 header, saveBtn := dialogHeader("Cancel", actionLabel, func() { dialog.Close() }, nil) 32 33 dialog, content, _ := dialogWithToolbar(title, 450, 450, header) 33 34 34 - // Name section 35 35 nameGroup := adw.NewPreferencesGroup() 36 36 nameGroup.SetTitle("Name") 37 37 nameGroup.SetDescription("Give this redirection a descriptive name (optional)") ··· 44 44 nameGroup.Add(nameRow) 45 45 content.Append(nameGroup) 46 46 47 - // Redirection section 48 47 group := adw.NewPreferencesGroup() 49 48 group.SetTitle("Redirection") 50 49 group.SetDescription("Define how links are modified") ··· 75 74 76 75 validateInputs := func() { 77 76 find := findRow.Text() 78 - rwType := indexToRedirectionType(typeRow.Selected()) 79 - r := Redirection{Type: rwType, Find: find, Replace: replaceRow.Text()} 80 - err := validateRedirection(r) 77 + redirectionType := indexToRedirectionType(typeRow.Selected()) 78 + redirection := Redirection{Type: redirectionType, Find: find, Replace: replaceRow.Text()} 79 + err := routing.ValidateRedirection(redirection) 81 80 82 81 if find != "" && err != nil { 83 82 findRow.AddCSSClass("error") ··· 96 95 saveBtn.ConnectClicked(func() { 97 96 name := nameRow.Text() 98 97 find := findRow.Text() 99 - rwType := indexToRedirectionType(typeRow.Selected()) 98 + redirectionType := indexToRedirectionType(typeRow.Selected()) 100 99 101 100 if isNew { 102 101 cfg.Redirections = append(cfg.Redirections, Redirection{ 103 102 Name: name, 104 - Type: rwType, 103 + Type: redirectionType, 105 104 Find: find, 106 105 Replace: replaceRow.Text(), 107 106 }) 108 107 } else { 109 108 redirection.Name = name 110 - redirection.Type = rwType 109 + redirection.Type = redirectionType 111 110 redirection.Find = find 112 111 redirection.Replace = replaceRow.Text() 113 112 }
+2 -1
cmd/switchyard/dialog_rule_add.go
··· 3 3 package main 4 4 5 5 import ( 6 + "github.com/alyraffauf/switchyard/internal/routing" 6 7 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 7 8 "github.com/diamondburned/gotk4/pkg/gtk/v4" 8 9 ) ··· 23 24 browserIdx := browserRow.Selected() 24 25 25 26 if len(*conditions) > 0 && int(browserIdx) < len(browsers) { 26 - if !validateConditions(*conditions) { 27 + if !routing.AreAllConditionsValid(*conditions) { 27 28 return 28 29 } 29 30
+40 -60
cmd/switchyard/dialog_rule_common.go
··· 3 3 package main 4 4 5 5 import ( 6 + "github.com/alyraffauf/switchyard/internal/routing" 6 7 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 7 8 "github.com/diamondburned/gotk4/pkg/gtk/v4" 8 9 ) 9 10 10 - // buildRuleDialogContent creates the shared content for add/edit rule dialogs 11 11 func buildRuleDialogContent( 12 12 initialRule *Rule, 13 13 browsers []*Browser, 14 - actionBtn *gtk.Button, 14 + actionButton *gtk.Button, 15 15 ) ( 16 16 nameEntry *adw.EntryRow, 17 17 conditions *[]Condition, ··· 26 26 content.SetMarginTop(18) 27 27 content.SetMarginBottom(18) 28 28 29 - // Name section 30 29 nameGroup := adw.NewPreferencesGroup() 31 30 nameGroup.SetTitle("Rule Name") 32 31 nameGroup.SetDescription("Give this rule a descriptive name (optional)") ··· 39 38 nameGroup.Add(nameEntry) 40 39 content.Append(nameGroup) 41 40 42 - // Initialize conditions 43 41 var conditionsSlice []Condition 44 42 if initialRule != nil && len(initialRule.Conditions) > 0 { 45 43 conditionsSlice = make([]Condition, len(initialRule.Conditions)) ··· 49 47 } 50 48 conditions = &conditionsSlice 51 49 52 - // Conditions section 53 50 conditionsGroup := adw.NewPreferencesGroup() 54 51 conditionsGroup.SetTitle("Conditions") 55 52 conditionsGroup.SetDescription("Define conditions to match URLs") 56 53 57 54 conditionsListBox := createBoxedListBox() 58 55 59 - // Logic selector row 60 56 logicRow = adw.NewComboRow() 61 57 logicRow.SetTitle("Match Logic") 62 58 logicRow.SetModel(gtk.NewStringList([]string{"All conditions", "Any condition"})) ··· 72 68 var rebuildConditions func() 73 69 74 70 rebuildConditions = func() { 75 - // Clear existing condition rows 76 71 for _, row := range conditionRows { 77 72 conditionsListBox.Remove(row) 78 73 } 79 74 conditionRows = nil 80 75 81 - // Clear the "Add Condition" button if it exists 82 - // Avoids losing track and having multiple buttons unexpectedly 83 76 if addConditionRow != nil { 84 77 conditionsListBox.Remove(addConditionRow) 85 78 } 86 79 87 - // Build condition rows 88 80 for i := range *conditions { 89 - condIdx := i 81 + conditionIndex := i 90 82 row := createConditionRow( 91 83 conditions, 92 - condIdx, 93 - actionBtn, 84 + conditionIndex, 85 + actionButton, 94 86 rebuildConditions, 95 87 ) 96 88 conditionsListBox.Append(row) 97 89 conditionRows = append(conditionRows, row) 98 90 } 99 91 100 - // Add "Add Condition" row at the end of the list 101 92 addConditionRow = adw.NewActionRow() 102 93 addConditionRow.SetTitle("Add Condition") 103 94 addConditionRow.AddPrefix(gtk.NewImageFromIconName("list-add-symbolic")) ··· 108 99 }) 109 100 conditionsListBox.Append(addConditionRow) 110 101 111 - // Update action button state 112 - actionBtn.SetSensitive(areAllConditionsValid(*conditions)) 102 + actionButton.SetSensitive(routing.AreAllConditionsValid(*conditions)) 113 103 } 114 104 115 105 rebuildConditions() 116 106 conditionsGroup.Add(conditionsListBox) 117 107 content.Append(conditionsGroup) 118 108 119 - // Action section 120 109 actionGroup := adw.NewPreferencesGroup() 121 110 actionGroup.SetTitle("Browser Action") 122 111 actionGroup.SetDescription("Select which browser opens matching URLs") ··· 129 118 } 130 119 actionGroup.Add(alwaysAskRow) 131 120 132 - // Browser dropdown 133 121 browserNames := make([]string, len(browsers)) 134 - selectedIdx := uint(0) 135 - for i, b := range browsers { 136 - browserNames[i] = b.Name 137 - if initialRule != nil && b.ID == initialRule.Browser { 138 - selectedIdx = uint(i) 122 + selectedIndex := uint(0) 123 + for i, browser := range browsers { 124 + browserNames[i] = browser.Name 125 + if initialRule != nil && browser.ID == initialRule.Browser { 126 + selectedIndex = uint(i) 139 127 } 140 128 } 141 129 142 130 browserRow = adw.NewComboRow() 143 131 browserRow.SetTitle("Browser") 144 132 browserRow.SetModel(gtk.NewStringList(browserNames)) 145 - browserRow.SetSelected(selectedIdx) 133 + browserRow.SetSelected(selectedIndex) 146 134 if initialRule != nil { 147 135 browserRow.SetSensitive(!initialRule.AlwaysAsk) 148 136 } 149 137 actionGroup.Add(browserRow) 150 138 151 - // Link always ask toggle to browser row sensitivity 152 139 alwaysAskRow.Connect("notify::active", func() { 153 140 browserRow.SetSensitive(!alwaysAskRow.Active()) 154 141 }) ··· 158 145 return 159 146 } 160 147 161 - // createConditionRow creates a single condition editing row with all controls 162 148 func createConditionRow( 163 149 conditions *[]Condition, 164 - condIdx int, 165 - actionBtn *gtk.Button, 150 + conditionIndex int, 151 + actionButton *gtk.Button, 166 152 rebuildConditions func(), 167 153 ) *gtk.ListBoxRow { 168 154 conditionRow := gtk.NewListBoxRow() ··· 175 161 conditionContainer.SetMarginStart(12) 176 162 conditionContainer.SetMarginEnd(12) 177 163 178 - // Match type dropdown 179 164 typeDropdown := gtk.NewDropDown( 180 165 gtk.NewStringList([]string{"Exact Domain", "URL Contains", "Wildcard", "Regex"}), 181 166 nil, 182 167 ) 183 - typeDropdown.SetSelected(conditionTypeToIndex((*conditions)[condIdx].Type)) 168 + typeDropdown.SetSelected(conditionTypeToIndex((*conditions)[conditionIndex].Type)) 184 169 typeDropdown.SetVAlign(gtk.AlignCenter) 185 - typeDropdown.SetSizeRequest(150, -1) // Fixed width for consistent alignment 170 + typeDropdown.SetSizeRequest(150, -1) 186 171 conditionContainer.Append(typeDropdown) 187 172 188 - // Negate dropdown (is / is not) 189 173 negateDropdown := gtk.NewDropDown( 190 174 gtk.NewStringList([]string{"is", "is not"}), 191 175 nil, 192 176 ) 193 - if (*conditions)[condIdx].Negate { 177 + if (*conditions)[conditionIndex].Negate { 194 178 negateDropdown.SetSelected(1) 195 179 } else { 196 180 negateDropdown.SetSelected(0) 197 181 } 198 182 negateDropdown.SetVAlign(gtk.AlignCenter) 199 183 negateDropdown.Connect("notify::selected", func() { 200 - (*conditions)[condIdx].Negate = negateDropdown.Selected() == 1 184 + (*conditions)[conditionIndex].Negate = negateDropdown.Selected() == 1 201 185 }) 202 186 conditionContainer.Append(negateDropdown) 203 187 204 - // Pattern entry 205 188 patternEntry := gtk.NewEntry() 206 - patternEntry.SetText((*conditions)[condIdx].Pattern) 189 + patternEntry.SetText((*conditions)[conditionIndex].Pattern) 207 190 patternEntry.SetHExpand(true) 208 191 patternEntry.SetPlaceholderText("Pattern") 209 192 conditionContainer.Append(patternEntry) 210 193 211 - // Connect handlers 212 194 typeDropdown.Connect("notify::selected", func() { 213 - (*conditions)[condIdx].Type = indexToConditionType(typeDropdown.Selected()) 214 - validateConditionEntry(conditions, condIdx, typeDropdown, patternEntry, actionBtn) 195 + (*conditions)[conditionIndex].Type = indexToConditionType(typeDropdown.Selected()) 196 + validateConditionEntry(conditions, conditionIndex, typeDropdown, patternEntry, actionButton) 215 197 }) 216 198 217 199 patternEntry.Connect("changed", func() { 218 - (*conditions)[condIdx].Pattern = patternEntry.Text() 219 - validateConditionEntry(conditions, condIdx, typeDropdown, patternEntry, actionBtn) 200 + (*conditions)[conditionIndex].Pattern = patternEntry.Text() 201 + validateConditionEntry(conditions, conditionIndex, typeDropdown, patternEntry, actionButton) 220 202 }) 221 203 222 - // Delete button 223 - deleteBtn := gtk.NewButton() 224 - deleteBtn.SetIconName("edit-delete-symbolic") 225 - deleteBtn.SetTooltipText("Delete this condition") 226 - deleteBtn.AddCSSClass("flat") 227 - deleteBtn.AddCSSClass("circular") 228 - deleteBtn.AddCSSClass("destructive-action") 229 - deleteBtn.SetVAlign(gtk.AlignCenter) 230 - deleteBtn.SetSensitive(len(*conditions) > 1) 231 - deleteBtn.ConnectClicked(func() { 232 - if len(*conditions) > 1 && condIdx < len(*conditions) { 233 - *conditions = append((*conditions)[:condIdx], (*conditions)[condIdx+1:]...) 204 + deleteButton := gtk.NewButton() 205 + deleteButton.SetIconName("edit-delete-symbolic") 206 + deleteButton.SetTooltipText("Delete this condition") 207 + deleteButton.AddCSSClass("flat") 208 + deleteButton.AddCSSClass("circular") 209 + deleteButton.AddCSSClass("destructive-action") 210 + deleteButton.SetVAlign(gtk.AlignCenter) 211 + deleteButton.SetSensitive(len(*conditions) > 1) 212 + deleteButton.ConnectClicked(func() { 213 + if len(*conditions) > 1 && conditionIndex < len(*conditions) { 214 + *conditions = append((*conditions)[:conditionIndex], (*conditions)[conditionIndex+1:]...) 234 215 rebuildConditions() 235 216 } 236 217 }) 237 - conditionContainer.Append(deleteBtn) 218 + conditionContainer.Append(deleteButton) 238 219 239 220 conditionRow.SetChild(conditionContainer) 240 221 return conditionRow 241 222 } 242 223 243 - // validateConditionEntry validates a pattern and updates UI accordingly 244 224 func validateConditionEntry( 245 225 conditions *[]Condition, 246 - condIdx int, 226 + conditionIndex int, 247 227 typeDropdown *gtk.DropDown, 248 228 patternEntry *gtk.Entry, 249 - actionBtn *gtk.Button, 229 + actionButton *gtk.Button, 250 230 ) { 251 231 pattern := patternEntry.Text() 252 - condType := indexToConditionType(typeDropdown.Selected()) 232 + conditionType := indexToConditionType(typeDropdown.Selected()) 253 233 254 - err := validateConditionPattern(condType, pattern) 234 + err := routing.ValidateConditionPattern(conditionType, pattern) 255 235 if err != nil { 256 236 patternEntry.AddCSSClass("error") 257 237 } else { 258 238 patternEntry.RemoveCSSClass("error") 259 239 } 260 240 261 - actionBtn.SetSensitive(areAllConditionsValid(*conditions)) 241 + actionButton.SetSensitive(routing.AreAllConditionsValid(*conditions)) 262 242 }
+2 -3
cmd/switchyard/dialog_rule_edit.go
··· 3 3 package main 4 4 5 5 import ( 6 + "github.com/alyraffauf/switchyard/internal/routing" 6 7 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 7 8 "github.com/diamondburned/gotk4/pkg/gtk/v4" 8 9 ) 9 10 10 11 func showEditRuleDialog(parent *adw.Window, cfg *Config, rule *Rule, browsers []*Browser, rebuildRulesList func()) { 11 - // Ensure rules have at least one condition 12 12 if len(rule.Conditions) == 0 { 13 13 rule.Conditions = []Condition{{ 14 14 Type: "domain", ··· 30 30 browserIdx := browserRow.Selected() 31 31 32 32 if len(*conditions) > 0 && int(browserIdx) < len(browsers) { 33 - if !validateConditions(*conditions) { 33 + if !routing.AreAllConditionsValid(*conditions) { 34 34 return 35 35 } 36 36 37 - // Update rule 38 37 rule.Name = nameEntry.Text() 39 38 rule.Conditions = *conditions 40 39 rule.Logic = getLogicFromComboRow(logicRow)
+5 -4
cmd/switchyard/main.go
··· 8 8 "os" 9 9 "strings" 10 10 11 + "github.com/alyraffauf/switchyard/internal/routing" 11 12 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 12 13 "github.com/diamondburned/gotk4/pkg/gdk/v4" 13 14 "github.com/diamondburned/gotk4/pkg/gio/v2" ··· 98 99 99 100 // handleSwitchyardURL processes switchyard:// URLs with browser preferences 100 101 func handleSwitchyardURL(app *adw.Application, browsers []*Browser, cfg *Config, rawURL string) { 101 - targetURL, browserPrefs, err := parseSwitchyardURL(rawURL) 102 + targetURL, browserPrefs, err := routing.ParseSwitchyardURL(rawURL) 102 103 if err != nil { 103 104 // Invalid switchyard URL - ignore 104 105 return ··· 135 136 } 136 137 137 138 func prepareURLForRouting(rawURL string, cfg *Config) string { 138 - sanitized := sanitizeURL(rawURL) 139 + sanitized := routing.SanitizeURL(rawURL) 139 140 if sanitized == "" { 140 141 return "" 141 142 } ··· 145 146 } 146 147 147 148 if len(cfg.Redirections) > 0 { 148 - sanitized = applyRedirections(sanitized, cfg.Redirections) 149 + sanitized = routing.ApplyRedirections(sanitized, cfg.Redirections) 149 150 } 150 151 151 152 return sanitized ··· 154 155 // handleURL routes a URL to the appropriate browser based on rules 155 156 func handleURL(app *adw.Application, browsers []*Browser, cfg *Config, urlStr string) { 156 157 // Try to match a rule 157 - browserID, alwaysAsk, matched := cfg.matchRule(urlStr) 158 + browserID, alwaysAsk, matched := cfg.MatchRule(urlStr) 158 159 if matched { 159 160 // Check if rule has AlwaysAsk enabled 160 161 if alwaysAsk {
+3 -3
cmd/switchyard/pattern.go internal/routing/pattern.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package routing 4 4 5 5 import ( 6 6 "regexp" ··· 25 25 var result bool 26 26 switch patternType { 27 27 case "domain": 28 - domain := extractDomain(url) 28 + domain := ExtractDomain(url) 29 29 result = strings.EqualFold(domain, pattern) 30 30 case "keyword": 31 31 result = strings.Contains(strings.ToLower(url), strings.ToLower(pattern)) ··· 49 49 50 50 // matchGlob performs glob-style pattern matching against a URL's domain or full URL 51 51 func matchGlob(url, pattern string) bool { 52 - domain := extractDomain(url) 52 + domain := ExtractDomain(url) 53 53 54 54 // Convert glob to regex: escape dots, convert * to .* 55 55 regexPattern := "^" + strings.ReplaceAll(strings.ReplaceAll(pattern, ".", "\\."), "*", ".*") + "$"
+1 -1
cmd/switchyard/pattern_test.go internal/routing/pattern_test.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package routing 4 4 5 5 import ( 6 6 "testing"
-72
cmd/switchyard/redirection.go
··· 1 - // SPDX-License-Identifier: GPL-3.0-or-later 2 - 3 - package main 4 - 5 - import ( 6 - "net/url" 7 - "regexp" 8 - "strings" 9 - ) 10 - 11 - func applyRedirections(rawURL string, redirections []Redirection) string { 12 - for _, r := range redirections { 13 - rawURL = applyRedirection(rawURL, r) 14 - } 15 - return rawURL 16 - } 17 - 18 - func applyRedirection(rawURL string, r Redirection) string { 19 - rwType := r.Type 20 - if rwType == "" { 21 - rwType = "domain" 22 - } 23 - 24 - switch rwType { 25 - case "domain": 26 - return applyDomainRedirection(rawURL, r) 27 - case "wildcard": 28 - return applyWildcardRedirection(rawURL, r) 29 - case "regex": 30 - return applyRegexRedirection(rawURL, r) 31 - default: 32 - return rawURL 33 - } 34 - } 35 - 36 - func applyDomainRedirection(rawURL string, r Redirection) string { 37 - u, err := url.Parse(rawURL) 38 - if err != nil { 39 - return rawURL 40 - } 41 - 42 - if !strings.EqualFold(u.Hostname(), r.Find) { 43 - return rawURL 44 - } 45 - 46 - u.Host = strings.Replace(u.Host, u.Hostname(), r.Replace, 1) 47 - return u.String() 48 - } 49 - 50 - func applyWildcardRedirection(rawURL string, r Redirection) string { 51 - pattern := wildcardToRegex(r.Find) 52 - re, ok := getCompiledRegex("(?i)" + pattern) // case-insensitive 53 - if !ok { 54 - return rawURL 55 - } 56 - return re.ReplaceAllString(rawURL, r.Replace) 57 - } 58 - 59 - func applyRegexRedirection(rawURL string, r Redirection) string { 60 - re, ok := getCompiledRegex(r.Find) 61 - if !ok { 62 - return rawURL 63 - } 64 - return re.ReplaceAllString(rawURL, r.Replace) 65 - } 66 - 67 - func wildcardToRegex(pattern string) string { 68 - // Escape regex special chars except * 69 - escaped := regexp.QuoteMeta(pattern) 70 - // Convert \* back to .* 71 - return strings.ReplaceAll(escaped, `\*`, `.*`) 72 - }
+7 -8
cmd/switchyard/redirection_test.go internal/routing/redirection_test.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package routing 4 4 5 5 import ( 6 6 "testing" ··· 481 481 482 482 for _, tt := range tests { 483 483 t.Run(tt.name, func(t *testing.T) { 484 - got := applyRedirections(tt.url, tt.redirections) 484 + got := ApplyRedirections(tt.url, tt.redirections) 485 485 if got != tt.want { 486 - t.Errorf("applyRedirections() = %q, want %q", got, tt.want) 486 + t.Errorf("ApplyRedirections() = %q, want %q", got, tt.want) 487 487 } 488 488 }) 489 489 } ··· 579 579 580 580 for _, tt := range tests { 581 581 t.Run(tt.name, func(t *testing.T) { 582 - err := validateRedirection(tt.redirection) 582 + err := ValidateRedirection(tt.redirection) 583 583 if (err != nil) != tt.wantErr { 584 - t.Errorf("validateRedirection() error = %v, wantErr %v", err, tt.wantErr) 584 + t.Errorf("ValidateRedirection() error = %v, wantErr %v", err, tt.wantErr) 585 585 } 586 586 }) 587 587 } 588 588 } 589 589 590 - // TestRealWorldRedirections tests common real-world URL transformations 591 590 func TestRealWorldRedirections(t *testing.T) { 592 591 tests := []struct { 593 592 name string ··· 701 700 702 701 for _, tt := range tests { 703 702 t.Run(tt.name, func(t *testing.T) { 704 - got := applyRedirections(tt.url, tt.redirections) 703 + got := ApplyRedirections(tt.url, tt.redirections) 705 704 if got != tt.want { 706 - t.Errorf("applyRedirections() = %q, want %q", got, tt.want) 705 + t.Errorf("ApplyRedirections() = %q, want %q", got, tt.want) 707 706 } 708 707 }) 709 708 }
+28 -33
cmd/switchyard/settings_redirections.go
··· 10 10 "github.com/diamondburned/gotk4/pkg/gtk/v4" 11 11 ) 12 12 13 - func formatRedirectionSubtitle(r *Redirection) string { 14 - rwType := r.Type 15 - if rwType == "" { 16 - rwType = "domain" 13 + func formatRedirectionSubtitle(redirection *Redirection) string { 14 + redirectionType := redirection.Type 15 + if redirectionType == "" { 16 + redirectionType = "domain" 17 17 } 18 18 19 19 var typeLabel string 20 - switch rwType { 20 + switch redirectionType { 21 21 case "domain": 22 22 typeLabel = "Domain" 23 23 case "wildcard": ··· 28 28 typeLabel = "Domain" 29 29 } 30 30 31 - if r.Replace == "" { 31 + if redirection.Replace == "" { 32 32 return fmt.Sprintf("%s · Removes match", typeLabel) 33 33 } 34 - return fmt.Sprintf("%s · Replaces with %s", typeLabel, html.EscapeString(r.Replace)) 34 + return fmt.Sprintf("%s · Replaces with %s", typeLabel, html.EscapeString(redirection.Replace)) 35 35 } 36 36 37 37 func createRedirectionsPage(win *adw.Window, cfg *Config) gtk.Widgetter { ··· 43 43 titleLabel.AddCSSClass("title") 44 44 header.SetTitleWidget(titleLabel) 45 45 46 - // Add button in header 47 46 addButton := gtk.NewButton() 48 47 addButton.SetIconName("list-add-symbolic") 49 48 addButton.SetTooltipText("Add Redirection") ··· 60 59 content.SetMarginTop(12) 61 60 content.SetMarginBottom(12) 62 61 63 - // Info banner 64 62 infoLabel := gtk.NewLabel("Redirections modify links before rules are applied.") 65 63 infoLabel.SetWrap(true) 66 64 infoLabel.SetXAlign(0) ··· 95 93 reorderBox := gtk.NewBox(gtk.OrientationHorizontal, 0) 96 94 reorderBox.SetVAlign(gtk.AlignCenter) 97 95 98 - upBtn := gtk.NewButton() 99 - upBtn.SetIconName("go-up-symbolic") 100 - upBtn.AddCSSClass("flat") 101 - upBtn.SetSensitive(redirectionIndex > 0) 102 - upBtn.SetTooltipText("Move up") 103 - upBtn.ConnectClicked(func() { 96 + upButton := gtk.NewButton() 97 + upButton.SetIconName("go-up-symbolic") 98 + upButton.AddCSSClass("flat") 99 + upButton.SetSensitive(redirectionIndex > 0) 100 + upButton.SetTooltipText("Move up") 101 + upButton.ConnectClicked(func() { 104 102 if redirectionIndex > 0 { 105 103 cfg.Redirections[redirectionIndex], cfg.Redirections[redirectionIndex-1] = cfg.Redirections[redirectionIndex-1], cfg.Redirections[redirectionIndex] 106 104 saveConfig(cfg) 107 105 rebuildRedirectionsList() 108 106 } 109 107 }) 110 - reorderBox.Append(upBtn) 108 + reorderBox.Append(upButton) 111 109 112 - downBtn := gtk.NewButton() 113 - downBtn.SetIconName("go-down-symbolic") 114 - downBtn.AddCSSClass("flat") 115 - downBtn.SetSensitive(redirectionIndex < len(cfg.Redirections)-1) 116 - downBtn.SetTooltipText("Move down") 117 - downBtn.ConnectClicked(func() { 110 + downButton := gtk.NewButton() 111 + downButton.SetIconName("go-down-symbolic") 112 + downButton.AddCSSClass("flat") 113 + downButton.SetSensitive(redirectionIndex < len(cfg.Redirections)-1) 114 + downButton.SetTooltipText("Move down") 115 + downButton.ConnectClicked(func() { 118 116 if redirectionIndex < len(cfg.Redirections)-1 { 119 117 cfg.Redirections[redirectionIndex], cfg.Redirections[redirectionIndex+1] = cfg.Redirections[redirectionIndex+1], cfg.Redirections[redirectionIndex] 120 118 saveConfig(cfg) 121 119 rebuildRedirectionsList() 122 120 } 123 121 }) 124 - reorderBox.Append(downBtn) 122 + reorderBox.Append(downButton) 125 123 126 124 row.AddSuffix(reorderBox) 127 125 128 - // Delete button 129 - deleteBtn := gtk.NewButton() 130 - deleteBtn.SetIconName("edit-delete-symbolic") 131 - deleteBtn.AddCSSClass("flat") 132 - deleteBtn.AddCSSClass("destructive-action") 133 - deleteBtn.SetTooltipText("Remove") 134 - deleteBtn.ConnectClicked(func() { 126 + deleteButton := gtk.NewButton() 127 + deleteButton.SetIconName("edit-delete-symbolic") 128 + deleteButton.AddCSSClass("flat") 129 + deleteButton.AddCSSClass("destructive-action") 130 + deleteButton.SetTooltipText("Remove") 131 + deleteButton.ConnectClicked(func() { 135 132 cfg.Redirections = append(cfg.Redirections[:redirectionIndex], cfg.Redirections[redirectionIndex+1:]...) 136 133 saveConfig(cfg) 137 134 rebuildRedirectionsList() 138 135 }) 139 - row.AddSuffix(deleteBtn) 136 + row.AddSuffix(deleteButton) 140 137 141 - // Edit on click 142 138 row.ConnectActivated(func() { 143 139 showEditRedirectionDialog(win, cfg, redirection, rebuildRedirectionsList) 144 140 }) ··· 149 145 rebuildRedirectionsList = func() { 150 146 clearListBox(redirectionsListBox) 151 147 152 - // handle empty state 153 148 if len(cfg.Redirections) == 0 { 154 149 infoLabel.SetVisible(false) 155 150 redirectionsListBox.SetVisible(false)
-78
cmd/switchyard/url.go
··· 1 - // SPDX-License-Identifier: GPL-3.0-or-later 2 - 3 - package main 4 - 5 - import ( 6 - "fmt" 7 - "net/url" 8 - "strings" 9 - ) 10 - 11 - func extractDomain(rawURL string) string { 12 - // Add scheme if missing so url.Parse works correctly 13 - if !strings.Contains(rawURL, "://") { 14 - rawURL = "https://" + rawURL 15 - } 16 - u, err := url.Parse(rawURL) 17 - if err != nil { 18 - return "" 19 - } 20 - return u.Hostname() 21 - } 22 - 23 - func sanitizeURL(rawURL string) string { 24 - // Remove newlines and carriage returns (common when URLs are copied from formatted text) 25 - rawURL = strings.ReplaceAll(rawURL, "\n", "") 26 - rawURL = strings.ReplaceAll(rawURL, "\r", "") 27 - rawURL = strings.TrimSpace(rawURL) 28 - if rawURL == "" { 29 - return "" 30 - } 31 - 32 - // Reject local file paths 33 - if strings.HasPrefix(rawURL, "/") || strings.HasPrefix(rawURL, ".") { 34 - return "" 35 - } 36 - 37 - u, err := url.Parse(rawURL) 38 - if err != nil { 39 - return "" 40 - } 41 - 42 - // Add https scheme if missing 43 - if u.Scheme == "" { 44 - rawURL = "https://" + rawURL 45 - u, _ = url.Parse(rawURL) 46 - } 47 - 48 - // Only allow browser-routable schemes 49 - switch u.Scheme { 50 - case "http", "https", "file", "ftp": 51 - return u.String() 52 - default: 53 - return "" 54 - } 55 - } 56 - 57 - func parseSwitchyardURL(rawURL string) (targetURL string, browserPrefs []string, err error) { 58 - u, err := url.Parse(rawURL) 59 - if err != nil { 60 - return "", nil, err 61 - } 62 - 63 - if u.Scheme != "switchyard" || u.Host != "open" { 64 - return "", nil, fmt.Errorf("invalid switchyard URL") 65 - } 66 - 67 - query := u.Query() 68 - targetURL = query.Get("url") 69 - if targetURL == "" { 70 - return "", nil, fmt.Errorf("missing url parameter") 71 - } 72 - 73 - if browser := query.Get("browser"); browser != "" { 74 - browserPrefs = strings.Split(browser, ",") 75 - } 76 - 77 - return targetURL, browserPrefs, nil 78 - }
+10 -10
cmd/switchyard/url_test.go internal/routing/url_test.go
··· 1 1 // SPDX-License-Identifier: GPL-3.0-or-later 2 2 3 - package main 3 + package routing 4 4 5 5 import ( 6 6 "testing" ··· 42 42 43 43 for _, tt := range tests { 44 44 t.Run(tt.name, func(t *testing.T) { 45 - result := sanitizeURL(tt.input) 45 + result := SanitizeURL(tt.input) 46 46 if result != tt.expected { 47 - t.Errorf("sanitizeURL(%q) = %q, want %q", tt.input, result, tt.expected) 47 + t.Errorf("SanitizeURL(%q) = %q, want %q", tt.input, result, tt.expected) 48 48 } 49 49 }) 50 50 } ··· 82 82 83 83 for _, tt := range tests { 84 84 t.Run(tt.name, func(t *testing.T) { 85 - result := extractDomain(tt.input) 85 + result := ExtractDomain(tt.input) 86 86 if result != tt.expected { 87 - t.Errorf("extractDomain(%q) = %q, want %q", tt.input, result, tt.expected) 87 + t.Errorf("ExtractDomain(%q) = %q, want %q", tt.input, result, tt.expected) 88 88 } 89 89 }) 90 90 } ··· 144 144 145 145 for _, tt := range tests { 146 146 t.Run(tt.name, func(t *testing.T) { 147 - gotURL, gotBrowsers, err := parseSwitchyardURL(tt.input) 147 + gotURL, gotBrowsers, err := ParseSwitchyardURL(tt.input) 148 148 if (err != nil) != tt.wantErr { 149 - t.Errorf("parseSwitchyardURL() error = %v, wantErr %v", err, tt.wantErr) 149 + t.Errorf("ParseSwitchyardURL() error = %v, wantErr %v", err, tt.wantErr) 150 150 return 151 151 } 152 152 if tt.wantErr { 153 153 return 154 154 } 155 155 if gotURL != tt.wantURL { 156 - t.Errorf("parseSwitchyardURL() url = %q, want %q", gotURL, tt.wantURL) 156 + t.Errorf("ParseSwitchyardURL() url = %q, want %q", gotURL, tt.wantURL) 157 157 } 158 158 if len(gotBrowsers) != len(tt.wantBrowsers) { 159 - t.Errorf("parseSwitchyardURL() browsers = %v, want %v", gotBrowsers, tt.wantBrowsers) 159 + t.Errorf("ParseSwitchyardURL() browsers = %v, want %v", gotBrowsers, tt.wantBrowsers) 160 160 return 161 161 } 162 162 for i, b := range gotBrowsers { 163 163 if b != tt.wantBrowsers[i] { 164 - t.Errorf("parseSwitchyardURL() browsers[%d] = %q, want %q", i, b, tt.wantBrowsers[i]) 164 + t.Errorf("ParseSwitchyardURL() browsers[%d] = %q, want %q", i, b, tt.wantBrowsers[i]) 165 165 } 166 166 } 167 167 })
-190
cmd/switchyard/validation.go
··· 1 - // SPDX-License-Identifier: GPL-3.0-or-later 2 - 3 - package main 4 - 5 - import ( 6 - "fmt" 7 - "regexp" 8 - "strings" 9 - ) 10 - 11 - func validateConditions(conditions []Condition) bool { 12 - validTypes := map[string]bool{ 13 - "domain": true, 14 - "keyword": true, 15 - "glob": true, 16 - "regex": true, 17 - } 18 - 19 - for _, c := range conditions { 20 - if c.Pattern == "" { 21 - return false 22 - } 23 - if !validTypes[c.Type] { 24 - return false 25 - } 26 - // For regex type, validate the pattern compiles 27 - if c.Type == "regex" { 28 - if _, err := regexp.Compile(c.Pattern); err != nil { 29 - return false 30 - } 31 - } 32 - } 33 - return true 34 - } 35 - 36 - // Domain patterns should be exact hostnames like "example.com" or "api.github.com". 37 - func validateDomainPattern(pattern string) error { 38 - if pattern == "" { 39 - return fmt.Errorf("Domain cannot be empty") 40 - } 41 - 42 - // Check for wildcard characters (not allowed in domain type) 43 - if strings.Contains(pattern, "*") || strings.Contains(pattern, "?") { 44 - return fmt.Errorf("Wildcards not allowed in domain patterns (use Wildcard type instead)") 45 - } 46 - 47 - // Check for spaces 48 - if strings.Contains(pattern, " ") { 49 - return fmt.Errorf("Domain cannot contain spaces") 50 - } 51 - 52 - // Check for invalid starting/ending characters 53 - if strings.HasPrefix(pattern, ".") || strings.HasSuffix(pattern, ".") { 54 - return fmt.Errorf("Domain cannot start or end with a dot") 55 - } 56 - if strings.HasPrefix(pattern, "-") || strings.HasSuffix(pattern, "-") { 57 - return fmt.Errorf("Domain cannot start or end with a hyphen") 58 - } 59 - 60 - // Validate characters: only alphanumeric, dots, hyphens, underscores 61 - for _, ch := range pattern { 62 - if !((ch >= 'a' && ch <= 'z') || 63 - (ch >= 'A' && ch <= 'Z') || 64 - (ch >= '0' && ch <= '9') || 65 - ch == '.' || ch == '-' || ch == '_') { 66 - return fmt.Errorf("Domain contains invalid character: %c", ch) 67 - } 68 - } 69 - 70 - return nil 71 - } 72 - 73 - // Glob patterns can contain * wildcards for matching multiple characters. 74 - func validateGlobPattern(pattern string) error { 75 - if pattern == "" { 76 - return fmt.Errorf("Wildcard pattern cannot be empty") 77 - } 78 - 79 - // Check for spaces 80 - if strings.Contains(pattern, " ") { 81 - return fmt.Errorf("Wildcard pattern cannot contain spaces") 82 - } 83 - 84 - // Remove wildcards temporarily for validation 85 - temp := strings.ReplaceAll(pattern, "*", "X") 86 - 87 - // Check if remaining pattern (without wildcards) has valid characters 88 - for _, ch := range temp { 89 - if !((ch >= 'a' && ch <= 'z') || 90 - (ch >= 'A' && ch <= 'Z') || 91 - (ch >= '0' && ch <= '9') || 92 - ch == '.' || ch == '-' || ch == '_') { 93 - return fmt.Errorf("Wildcard pattern contains invalid character: %c", ch) 94 - } 95 - } 96 - 97 - // Check for problematic dot placement (unless adjacent to wildcard) 98 - if strings.HasPrefix(pattern, ".") && !strings.HasPrefix(pattern, ".*") { 99 - return fmt.Errorf("Wildcard pattern cannot start with a dot") 100 - } 101 - if strings.HasSuffix(pattern, ".") && !strings.HasSuffix(pattern, "*.") { 102 - return fmt.Errorf("Wildcard pattern cannot end with a dot") 103 - } 104 - 105 - return nil 106 - } 107 - 108 - // Returns an error with a descriptive message if invalid, nil if valid. 109 - func validateConditionPattern(condType, pattern string) error { 110 - if pattern == "" { 111 - return fmt.Errorf("Pattern cannot be empty") 112 - } 113 - 114 - switch condType { 115 - case "domain": 116 - return validateDomainPattern(pattern) 117 - case "glob": 118 - return validateGlobPattern(pattern) 119 - case "regex": 120 - if _, err := regexp.Compile(pattern); err != nil { 121 - return fmt.Errorf("Invalid regex: %w", err) 122 - } 123 - case "keyword": 124 - // Keywords can be any non-empty string 125 - return nil 126 - default: 127 - return fmt.Errorf("Invalid condition type: %s", condType) 128 - } 129 - 130 - return nil 131 - } 132 - 133 - // isConditionValid checks if a single condition is valid. 134 - func isConditionValid(c Condition) bool { 135 - return validateConditionPattern(c.Type, c.Pattern) == nil 136 - } 137 - 138 - // areAllConditionsValid checks if all conditions in a slice are valid. 139 - func areAllConditionsValid(conditions []Condition) bool { 140 - if len(conditions) == 0 { 141 - return false 142 - } 143 - for _, c := range conditions { 144 - if !isConditionValid(c) { 145 - return false 146 - } 147 - } 148 - return true 149 - } 150 - 151 - func validateRedirection(r Redirection) error { 152 - if r.Find == "" { 153 - return fmt.Errorf("Find pattern cannot be empty") 154 - } 155 - 156 - rwType := r.Type 157 - if rwType == "" { 158 - rwType = "domain" 159 - } 160 - 161 - switch rwType { 162 - case "domain": 163 - return validateDomainPattern(r.Find) 164 - case "wildcard": 165 - pattern := wildcardToRegex(r.Find) 166 - if _, err := regexp.Compile("(?i)" + pattern); err != nil { 167 - return fmt.Errorf("Invalid pattern: %w", err) 168 - } 169 - case "regex": 170 - if _, err := regexp.Compile(r.Find); err != nil { 171 - return fmt.Errorf("Invalid regex: %w", err) 172 - } 173 - default: 174 - return fmt.Errorf("Invalid redirection type: %s", rwType) 175 - } 176 - return nil 177 - } 178 - 179 - func isRedirectionValid(r Redirection) bool { 180 - return validateRedirection(r) == nil 181 - } 182 - 183 - func areAllRedirectionsValid(redirections []Redirection) bool { 184 - for _, r := range redirections { 185 - if !isRedirectionValid(r) { 186 - return false 187 - } 188 - } 189 - return true 190 - }
-547
cmd/switchyard/validation_test.go
··· 1 - // SPDX-License-Identifier: GPL-3.0-or-later 2 - 3 - package main 4 - 5 - import ( 6 - "testing" 7 - ) 8 - 9 - // TestValidateDomainPattern tests domain pattern validation 10 - func TestValidateDomainPattern(t *testing.T) { 11 - tests := []struct { 12 - name string 13 - pattern string 14 - wantErr bool 15 - errMsg string 16 - }{ 17 - { 18 - name: "valid simple domain", 19 - pattern: "example.com", 20 - wantErr: false, 21 - }, 22 - { 23 - name: "valid subdomain", 24 - pattern: "api.example.com", 25 - wantErr: false, 26 - }, 27 - { 28 - name: "valid multiple subdomains", 29 - pattern: "deep.api.example.com", 30 - wantErr: false, 31 - }, 32 - { 33 - name: "valid domain with hyphen", 34 - pattern: "my-site.example.com", 35 - wantErr: false, 36 - }, 37 - { 38 - name: "valid domain with numbers", 39 - pattern: "site123.example.com", 40 - wantErr: false, 41 - }, 42 - { 43 - name: "valid domain with underscore", 44 - pattern: "my_site.example.com", 45 - wantErr: false, 46 - }, 47 - { 48 - name: "empty domain", 49 - pattern: "", 50 - wantErr: true, 51 - errMsg: "Domain cannot be empty", 52 - }, 53 - { 54 - name: "domain with wildcard asterisk", 55 - pattern: "*.example.com", 56 - wantErr: true, 57 - errMsg: "Wildcards not allowed in domain patterns (use Wildcard type instead)", 58 - }, 59 - { 60 - name: "domain with wildcard question mark", 61 - pattern: "example?.com", 62 - wantErr: true, 63 - errMsg: "Wildcards not allowed in domain patterns (use Wildcard type instead)", 64 - }, 65 - { 66 - name: "domain with space", 67 - pattern: "example .com", 68 - wantErr: true, 69 - errMsg: "Domain cannot contain spaces", 70 - }, 71 - { 72 - name: "domain starting with dot", 73 - pattern: ".example.com", 74 - wantErr: true, 75 - errMsg: "Domain cannot start or end with a dot", 76 - }, 77 - { 78 - name: "domain ending with dot", 79 - pattern: "example.com.", 80 - wantErr: true, 81 - errMsg: "Domain cannot start or end with a dot", 82 - }, 83 - { 84 - name: "domain starting with hyphen", 85 - pattern: "-example.com", 86 - wantErr: true, 87 - errMsg: "Domain cannot start or end with a hyphen", 88 - }, 89 - { 90 - name: "domain ending with hyphen", 91 - pattern: "example.com-", 92 - wantErr: true, 93 - errMsg: "Domain cannot start or end with a hyphen", 94 - }, 95 - { 96 - name: "domain with slash", 97 - pattern: "example.com/path", 98 - wantErr: true, 99 - errMsg: "Domain contains invalid character: /", 100 - }, 101 - { 102 - name: "domain with special character", 103 - pattern: "example@test.com", 104 - wantErr: true, 105 - errMsg: "Domain contains invalid character: @", 106 - }, 107 - } 108 - 109 - for _, tt := range tests { 110 - t.Run(tt.name, func(t *testing.T) { 111 - err := validateDomainPattern(tt.pattern) 112 - if tt.wantErr { 113 - if err == nil { 114 - t.Errorf("validateDomainPattern(%q) expected error but got nil", tt.pattern) 115 - } else if tt.errMsg != "" && err.Error() != tt.errMsg { 116 - t.Errorf("validateDomainPattern(%q) error = %q, want %q", tt.pattern, err.Error(), tt.errMsg) 117 - } 118 - } else { 119 - if err != nil { 120 - t.Errorf("validateDomainPattern(%q) unexpected error: %v", tt.pattern, err) 121 - } 122 - } 123 - }) 124 - } 125 - } 126 - 127 - // TestValidateGlobPattern tests wildcard pattern validation 128 - func TestValidateGlobPattern(t *testing.T) { 129 - tests := []struct { 130 - name string 131 - pattern string 132 - wantErr bool 133 - errMsg string 134 - }{ 135 - { 136 - name: "valid wildcard at start", 137 - pattern: "*.example.com", 138 - wantErr: false, 139 - }, 140 - { 141 - name: "valid wildcard at end", 142 - pattern: "example.*", 143 - wantErr: false, 144 - }, 145 - { 146 - name: "valid wildcard in middle", 147 - pattern: "api.*.example.com", 148 - wantErr: false, 149 - }, 150 - { 151 - name: "valid multiple wildcards", 152 - pattern: "*.*.example.com", 153 - wantErr: false, 154 - }, 155 - { 156 - name: "valid domain with hyphen and wildcard", 157 - pattern: "my-*.example.com", 158 - wantErr: false, 159 - }, 160 - { 161 - name: "valid domain with numbers and wildcard", 162 - pattern: "site*.example.com", 163 - wantErr: false, 164 - }, 165 - { 166 - name: "empty pattern", 167 - pattern: "", 168 - wantErr: true, 169 - errMsg: "Wildcard pattern cannot be empty", 170 - }, 171 - { 172 - name: "pattern with space", 173 - pattern: "* .example.com", 174 - wantErr: true, 175 - errMsg: "Wildcard pattern cannot contain spaces", 176 - }, 177 - { 178 - name: "pattern starting with dot (not wildcard)", 179 - pattern: ".example.com", 180 - wantErr: true, 181 - errMsg: "Wildcard pattern cannot start with a dot", 182 - }, 183 - { 184 - name: "pattern ending with dot (not wildcard)", 185 - pattern: "example.com.", 186 - wantErr: true, 187 - errMsg: "Wildcard pattern cannot end with a dot", 188 - }, 189 - { 190 - name: "pattern with special character", 191 - pattern: "example@*.com", 192 - wantErr: true, 193 - errMsg: "Wildcard pattern contains invalid character: @", 194 - }, 195 - { 196 - name: "pattern with slash", 197 - pattern: "example.com/*", 198 - wantErr: true, 199 - errMsg: "Wildcard pattern contains invalid character: /", 200 - }, 201 - } 202 - 203 - for _, tt := range tests { 204 - t.Run(tt.name, func(t *testing.T) { 205 - err := validateGlobPattern(tt.pattern) 206 - if tt.wantErr { 207 - if err == nil { 208 - t.Errorf("validateGlobPattern(%q) expected error but got nil", tt.pattern) 209 - } else if tt.errMsg != "" && err.Error() != tt.errMsg { 210 - t.Errorf("validateGlobPattern(%q) error = %q, want %q", tt.pattern, err.Error(), tt.errMsg) 211 - } 212 - } else { 213 - if err != nil { 214 - t.Errorf("validateGlobPattern(%q) unexpected error: %v", tt.pattern, err) 215 - } 216 - } 217 - }) 218 - } 219 - } 220 - 221 - // TestValidateConditionPattern tests the main condition pattern validator 222 - func TestValidateConditionPattern(t *testing.T) { 223 - tests := []struct { 224 - name string 225 - condType string 226 - pattern string 227 - wantErr bool 228 - }{ 229 - // Domain type 230 - { 231 - name: "valid domain type", 232 - condType: "domain", 233 - pattern: "example.com", 234 - wantErr: false, 235 - }, 236 - { 237 - name: "invalid domain type - wildcard", 238 - condType: "domain", 239 - pattern: "*.example.com", 240 - wantErr: true, 241 - }, 242 - // Keyword type 243 - { 244 - name: "valid keyword type", 245 - condType: "keyword", 246 - pattern: "github", 247 - wantErr: false, 248 - }, 249 - { 250 - name: "valid keyword with special chars", 251 - condType: "keyword", 252 - pattern: "/api/v2/", 253 - wantErr: false, 254 - }, 255 - { 256 - name: "empty keyword", 257 - condType: "keyword", 258 - pattern: "", 259 - wantErr: true, 260 - }, 261 - // Glob type 262 - { 263 - name: "valid glob type", 264 - condType: "glob", 265 - pattern: "*.example.com", 266 - wantErr: false, 267 - }, 268 - { 269 - name: "invalid glob type - spaces", 270 - condType: "glob", 271 - pattern: "* .example.com", 272 - wantErr: true, 273 - }, 274 - // Regex type 275 - { 276 - name: "valid regex type", 277 - condType: "regex", 278 - pattern: "^https://.*\\.example\\.com", 279 - wantErr: false, 280 - }, 281 - { 282 - name: "invalid regex type - bad syntax", 283 - condType: "regex", 284 - pattern: "[invalid", 285 - wantErr: true, 286 - }, 287 - { 288 - name: "empty regex", 289 - condType: "regex", 290 - pattern: "", 291 - wantErr: true, 292 - }, 293 - // Empty pattern universal check 294 - { 295 - name: "empty pattern for domain", 296 - condType: "domain", 297 - pattern: "", 298 - wantErr: true, 299 - }, 300 - { 301 - name: "empty pattern for glob", 302 - condType: "glob", 303 - pattern: "", 304 - wantErr: true, 305 - }, 306 - { 307 - name: "invalid condition type", 308 - condType: "invalid_type", 309 - pattern: "example.com", 310 - wantErr: true, 311 - }, 312 - } 313 - 314 - for _, tt := range tests { 315 - t.Run(tt.name, func(t *testing.T) { 316 - err := validateConditionPattern(tt.condType, tt.pattern) 317 - if tt.wantErr && err == nil { 318 - t.Errorf("validateConditionPattern(%q, %q) expected error but got nil", tt.condType, tt.pattern) 319 - } 320 - if !tt.wantErr && err != nil { 321 - t.Errorf("validateConditionPattern(%q, %q) unexpected error: %v", tt.condType, tt.pattern, err) 322 - } 323 - }) 324 - } 325 - } 326 - 327 - // TestIsConditionValid tests single condition validation 328 - func TestIsConditionValid(t *testing.T) { 329 - tests := []struct { 330 - name string 331 - condition Condition 332 - want bool 333 - }{ 334 - { 335 - name: "valid domain condition", 336 - condition: Condition{ 337 - Type: "domain", 338 - Pattern: "example.com", 339 - }, 340 - want: true, 341 - }, 342 - { 343 - name: "valid keyword condition", 344 - condition: Condition{ 345 - Type: "keyword", 346 - Pattern: "github", 347 - }, 348 - want: true, 349 - }, 350 - { 351 - name: "valid glob condition", 352 - condition: Condition{ 353 - Type: "glob", 354 - Pattern: "*.example.com", 355 - }, 356 - want: true, 357 - }, 358 - { 359 - name: "valid regex condition", 360 - condition: Condition{ 361 - Type: "regex", 362 - Pattern: "^https://.*", 363 - }, 364 - want: true, 365 - }, 366 - { 367 - name: "invalid domain condition - wildcard", 368 - condition: Condition{ 369 - Type: "domain", 370 - Pattern: "*.example.com", 371 - }, 372 - want: false, 373 - }, 374 - { 375 - name: "invalid condition - empty pattern", 376 - condition: Condition{ 377 - Type: "domain", 378 - Pattern: "", 379 - }, 380 - want: false, 381 - }, 382 - { 383 - name: "invalid regex condition", 384 - condition: Condition{ 385 - Type: "regex", 386 - Pattern: "[invalid", 387 - }, 388 - want: false, 389 - }, 390 - { 391 - name: "invalid condition type", 392 - condition: Condition{ 393 - Type: "invalid_type", 394 - Pattern: "example.com", 395 - }, 396 - want: false, 397 - }, 398 - } 399 - 400 - for _, tt := range tests { 401 - t.Run(tt.name, func(t *testing.T) { 402 - got := isConditionValid(tt.condition) 403 - if got != tt.want { 404 - t.Errorf("isConditionValid(%v) = %v, want %v", tt.condition, got, tt.want) 405 - } 406 - }) 407 - } 408 - } 409 - 410 - // TestAreAllConditionsValid tests validation of condition slices 411 - func TestAreAllConditionsValid(t *testing.T) { 412 - tests := []struct { 413 - name string 414 - conditions []Condition 415 - want bool 416 - }{ 417 - { 418 - name: "all valid conditions", 419 - conditions: []Condition{ 420 - {Type: "domain", Pattern: "example.com"}, 421 - {Type: "keyword", Pattern: "github"}, 422 - }, 423 - want: true, 424 - }, 425 - { 426 - name: "one invalid condition", 427 - conditions: []Condition{ 428 - {Type: "domain", Pattern: "example.com"}, 429 - {Type: "domain", Pattern: "*.invalid.com"}, 430 - }, 431 - want: false, 432 - }, 433 - { 434 - name: "empty pattern in list", 435 - conditions: []Condition{ 436 - {Type: "domain", Pattern: "example.com"}, 437 - {Type: "keyword", Pattern: ""}, 438 - }, 439 - want: false, 440 - }, 441 - { 442 - name: "empty conditions list", 443 - conditions: []Condition{}, 444 - want: false, 445 - }, 446 - { 447 - name: "nil conditions list", 448 - conditions: nil, 449 - want: false, 450 - }, 451 - { 452 - name: "single valid condition", 453 - conditions: []Condition{ 454 - {Type: "domain", Pattern: "example.com"}, 455 - }, 456 - want: true, 457 - }, 458 - { 459 - name: "invalid regex in list", 460 - conditions: []Condition{ 461 - {Type: "domain", Pattern: "example.com"}, 462 - {Type: "regex", Pattern: "[invalid"}, 463 - }, 464 - want: false, 465 - }, 466 - { 467 - name: "invalid condition type in list", 468 - conditions: []Condition{ 469 - {Type: "domain", Pattern: "example.com"}, 470 - {Type: "invalid_type", Pattern: "example.com"}, 471 - }, 472 - want: false, 473 - }, 474 - } 475 - 476 - for _, tt := range tests { 477 - t.Run(tt.name, func(t *testing.T) { 478 - got := areAllConditionsValid(tt.conditions) 479 - if got != tt.want { 480 - t.Errorf("areAllConditionsValid(%v) = %v, want %v", tt.conditions, got, tt.want) 481 - } 482 - }) 483 - } 484 - } 485 - 486 - // TestValidateConditions tests the main validateConditions function 487 - func TestValidateConditions(t *testing.T) { 488 - tests := []struct { 489 - name string 490 - conditions []Condition 491 - want bool 492 - }{ 493 - { 494 - name: "valid conditions with all types", 495 - conditions: []Condition{ 496 - {Type: "domain", Pattern: "example.com"}, 497 - {Type: "keyword", Pattern: "test"}, 498 - {Type: "glob", Pattern: "*.example.com"}, 499 - {Type: "regex", Pattern: "^https://.*"}, 500 - }, 501 - want: true, 502 - }, 503 - { 504 - name: "invalid type", 505 - conditions: []Condition{ 506 - {Type: "invalid_type", Pattern: "example.com"}, 507 - }, 508 - want: false, 509 - }, 510 - { 511 - name: "empty pattern", 512 - conditions: []Condition{ 513 - {Type: "domain", Pattern: ""}, 514 - }, 515 - want: false, 516 - }, 517 - { 518 - name: "invalid regex pattern", 519 - conditions: []Condition{ 520 - {Type: "regex", Pattern: "[unclosed"}, 521 - }, 522 - want: false, 523 - }, 524 - { 525 - name: "empty list", 526 - conditions: []Condition{}, 527 - want: true, 528 - }, 529 - { 530 - name: "mixed valid and invalid", 531 - conditions: []Condition{ 532 - {Type: "domain", Pattern: "valid.com"}, 533 - {Type: "domain", Pattern: ""}, 534 - }, 535 - want: false, 536 - }, 537 - } 538 - 539 - for _, tt := range tests { 540 - t.Run(tt.name, func(t *testing.T) { 541 - got := validateConditions(tt.conditions) 542 - if got != tt.want { 543 - t.Errorf("validateConditions(%v) = %v, want %v", tt.conditions, got, tt.want) 544 - } 545 - }) 546 - } 547 - }
+85
internal/routing/config.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package routing 4 + 5 + type Config struct { 6 + PromptOnClick bool `toml:"prompt_on_click"` 7 + FavoriteBrowser string `toml:"favorite_browser"` 8 + HiddenBrowsers []string `toml:"hidden_browsers"` 9 + CheckDefaultBrowser bool `toml:"check_default_browser"` 10 + ShowAppNames bool `toml:"show_app_names"` 11 + ForceDarkMode bool `toml:"force_dark_mode"` 12 + StayAlive bool `toml:"stay_alive"` 13 + RemoveTrackingParameters bool `toml:"remove_tracking_parameters"` 14 + Redirections []Redirection `toml:"redirections,omitempty"` 15 + Rules []Rule `toml:"rules"` 16 + } 17 + 18 + type Redirection struct { 19 + Name string `toml:"name,omitempty"` 20 + Type string `toml:"type,omitempty"` // "domain", "wildcard", or "regex", defaults to "domain" 21 + Find string `toml:"find"` 22 + Replace string `toml:"replace"` 23 + } 24 + 25 + type Condition struct { 26 + Type string `toml:"type"` // "domain", "keyword", "glob", "regex" 27 + Pattern string `toml:"pattern"` 28 + Negate bool `toml:"negate,omitempty"` 29 + } 30 + 31 + type Rule struct { 32 + Name string `toml:"name"` 33 + Conditions []Condition `toml:"conditions"` 34 + Logic string `toml:"logic,omitempty"` // "all" or "any" 35 + Browser string `toml:"browser"` 36 + AlwaysAsk bool `toml:"always_ask"` 37 + } 38 + 39 + func NewDefaultConfig() *Config { 40 + return &Config{ 41 + PromptOnClick: true, 42 + CheckDefaultBrowser: true, 43 + ShowAppNames: false, 44 + ForceDarkMode: true, 45 + StayAlive: true, 46 + RemoveTrackingParameters: false, 47 + Rules: []Rule{}, 48 + } 49 + } 50 + 51 + func (cfg *Config) MatchRule(url string) (browserID string, alwaysAsk bool, matched bool) { 52 + for _, rule := range cfg.Rules { 53 + if rule.MatchesConditions(url) { 54 + return rule.Browser, rule.AlwaysAsk, true 55 + } 56 + } 57 + return "", false, false 58 + } 59 + 60 + func (rule *Rule) MatchesConditions(url string) bool { 61 + if len(rule.Conditions) == 0 { 62 + return false 63 + } 64 + 65 + logic := rule.Logic 66 + if logic == "" { 67 + logic = "all" 68 + } 69 + 70 + if logic == "all" { 71 + for _, condition := range rule.Conditions { 72 + if !matchesPattern(url, condition.Pattern, condition.Type, condition.Negate) { 73 + return false 74 + } 75 + } 76 + return true 77 + } 78 + 79 + for _, condition := range rule.Conditions { 80 + if matchesPattern(url, condition.Pattern, condition.Type, condition.Negate) { 81 + return true 82 + } 83 + } 84 + return false 85 + }
+70
internal/routing/redirection.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package routing 4 + 5 + import ( 6 + "net/url" 7 + "regexp" 8 + "strings" 9 + ) 10 + 11 + func ApplyRedirections(rawURL string, redirections []Redirection) string { 12 + for _, redirection := range redirections { 13 + rawURL = applyRedirection(rawURL, redirection) 14 + } 15 + return rawURL 16 + } 17 + 18 + func applyRedirection(rawURL string, redirection Redirection) string { 19 + redirectionType := redirection.Type 20 + if redirectionType == "" { 21 + redirectionType = "domain" 22 + } 23 + 24 + switch redirectionType { 25 + case "domain": 26 + return applyDomainRedirection(rawURL, redirection) 27 + case "wildcard": 28 + return applyWildcardRedirection(rawURL, redirection) 29 + case "regex": 30 + return applyRegexRedirection(rawURL, redirection) 31 + default: 32 + return rawURL 33 + } 34 + } 35 + 36 + func applyDomainRedirection(rawURL string, redirection Redirection) string { 37 + parsedURL, err := url.Parse(rawURL) 38 + if err != nil { 39 + return rawURL 40 + } 41 + 42 + if !strings.EqualFold(parsedURL.Hostname(), redirection.Find) { 43 + return rawURL 44 + } 45 + 46 + parsedURL.Host = strings.Replace(parsedURL.Host, parsedURL.Hostname(), redirection.Replace, 1) 47 + return parsedURL.String() 48 + } 49 + 50 + func applyWildcardRedirection(rawURL string, redirection Redirection) string { 51 + pattern := wildcardToRegex(redirection.Find) 52 + compiledRegex, ok := getCompiledRegex("(?i)" + pattern) 53 + if !ok { 54 + return rawURL 55 + } 56 + return compiledRegex.ReplaceAllString(rawURL, redirection.Replace) 57 + } 58 + 59 + func applyRegexRedirection(rawURL string, redirection Redirection) string { 60 + compiledRegex, ok := getCompiledRegex(redirection.Find) 61 + if !ok { 62 + return rawURL 63 + } 64 + return compiledRegex.ReplaceAllString(rawURL, redirection.Replace) 65 + } 66 + 67 + func wildcardToRegex(pattern string) string { 68 + escaped := regexp.QuoteMeta(pattern) 69 + return strings.ReplaceAll(escaped, `\*`, `.*`) 70 + }
+76
internal/routing/url.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package routing 4 + 5 + import ( 6 + "fmt" 7 + "net/url" 8 + "strings" 9 + ) 10 + 11 + func ExtractDomain(rawURL string) string { 12 + if !strings.Contains(rawURL, "://") { 13 + rawURL = "https://" + rawURL 14 + } 15 + parsedURL, err := url.Parse(rawURL) 16 + if err != nil { 17 + return "" 18 + } 19 + return parsedURL.Hostname() 20 + } 21 + 22 + func SanitizeURL(rawURL string) string { 23 + rawURL = strings.ReplaceAll(rawURL, "\n", "") 24 + rawURL = strings.ReplaceAll(rawURL, "\r", "") 25 + rawURL = strings.TrimSpace(rawURL) 26 + if rawURL == "" { 27 + return "" 28 + } 29 + 30 + if strings.HasPrefix(rawURL, "/") || strings.HasPrefix(rawURL, ".") { 31 + return "" 32 + } 33 + 34 + parsedURL, err := url.Parse(rawURL) 35 + if err != nil { 36 + return "" 37 + } 38 + 39 + if parsedURL.Scheme == "" { 40 + rawURL = "https://" + rawURL 41 + parsedURL, err = url.Parse(rawURL) 42 + if err != nil { 43 + return "" 44 + } 45 + } 46 + 47 + switch parsedURL.Scheme { 48 + case "http", "https", "file", "ftp": 49 + return parsedURL.String() 50 + default: 51 + return "" 52 + } 53 + } 54 + 55 + func ParseSwitchyardURL(rawURL string) (targetURL string, browserPrefs []string, err error) { 56 + parsedURL, err := url.Parse(rawURL) 57 + if err != nil { 58 + return "", nil, err 59 + } 60 + 61 + if parsedURL.Scheme != "switchyard" || parsedURL.Host != "open" { 62 + return "", nil, fmt.Errorf("invalid switchyard URL") 63 + } 64 + 65 + query := parsedURL.Query() 66 + targetURL = query.Get("url") 67 + if targetURL == "" { 68 + return "", nil, fmt.Errorf("missing url parameter") 69 + } 70 + 71 + if browser := query.Get("browser"); browser != "" { 72 + browserPrefs = strings.Split(browser, ",") 73 + } 74 + 75 + return targetURL, browserPrefs, nil 76 + }
+151
internal/routing/validation.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package routing 4 + 5 + import ( 6 + "fmt" 7 + "regexp" 8 + "strings" 9 + ) 10 + 11 + func validateDomainPattern(pattern string) error { 12 + if pattern == "" { 13 + return fmt.Errorf("Domain cannot be empty") 14 + } 15 + 16 + if strings.Contains(pattern, "*") || strings.Contains(pattern, "?") { 17 + return fmt.Errorf("Wildcards not allowed in domain patterns (use Wildcard type instead)") 18 + } 19 + 20 + if strings.Contains(pattern, " ") { 21 + return fmt.Errorf("Domain cannot contain spaces") 22 + } 23 + 24 + if strings.HasPrefix(pattern, ".") || strings.HasSuffix(pattern, ".") { 25 + return fmt.Errorf("Domain cannot start or end with a dot") 26 + } 27 + if strings.HasPrefix(pattern, "-") || strings.HasSuffix(pattern, "-") { 28 + return fmt.Errorf("Domain cannot start or end with a hyphen") 29 + } 30 + 31 + for _, character := range pattern { 32 + if !((character >= 'a' && character <= 'z') || 33 + (character >= 'A' && character <= 'Z') || 34 + (character >= '0' && character <= '9') || 35 + character == '.' || character == '-' || character == '_') { 36 + return fmt.Errorf("Domain contains invalid character: %c", character) 37 + } 38 + } 39 + 40 + return nil 41 + } 42 + 43 + func validateGlobPattern(pattern string) error { 44 + if pattern == "" { 45 + return fmt.Errorf("Wildcard pattern cannot be empty") 46 + } 47 + 48 + if strings.Contains(pattern, " ") { 49 + return fmt.Errorf("Wildcard pattern cannot contain spaces") 50 + } 51 + 52 + patternWithoutWildcards := strings.ReplaceAll(pattern, "*", "X") 53 + 54 + for _, character := range patternWithoutWildcards { 55 + if !((character >= 'a' && character <= 'z') || 56 + (character >= 'A' && character <= 'Z') || 57 + (character >= '0' && character <= '9') || 58 + character == '.' || character == '-' || character == '_') { 59 + return fmt.Errorf("Wildcard pattern contains invalid character: %c", character) 60 + } 61 + } 62 + 63 + if strings.HasPrefix(pattern, ".") && !strings.HasPrefix(pattern, ".*") { 64 + return fmt.Errorf("Wildcard pattern cannot start with a dot") 65 + } 66 + if strings.HasSuffix(pattern, ".") && !strings.HasSuffix(pattern, "*.") { 67 + return fmt.Errorf("Wildcard pattern cannot end with a dot") 68 + } 69 + 70 + return nil 71 + } 72 + 73 + func ValidateConditionPattern(conditionType, pattern string) error { 74 + if pattern == "" { 75 + return fmt.Errorf("Pattern cannot be empty") 76 + } 77 + 78 + switch conditionType { 79 + case "domain": 80 + return validateDomainPattern(pattern) 81 + case "glob": 82 + return validateGlobPattern(pattern) 83 + case "regex": 84 + if _, err := regexp.Compile(pattern); err != nil { 85 + return fmt.Errorf("Invalid regex: %w", err) 86 + } 87 + case "keyword": 88 + return nil 89 + default: 90 + return fmt.Errorf("Invalid condition type: %s", conditionType) 91 + } 92 + 93 + return nil 94 + } 95 + 96 + func isConditionValid(condition Condition) bool { 97 + return ValidateConditionPattern(condition.Type, condition.Pattern) == nil 98 + } 99 + 100 + func AreAllConditionsValid(conditions []Condition) bool { 101 + if len(conditions) == 0 { 102 + return false 103 + } 104 + for _, condition := range conditions { 105 + if !isConditionValid(condition) { 106 + return false 107 + } 108 + } 109 + return true 110 + } 111 + 112 + func ValidateRedirection(redirection Redirection) error { 113 + if redirection.Find == "" { 114 + return fmt.Errorf("Find pattern cannot be empty") 115 + } 116 + 117 + redirectionType := redirection.Type 118 + if redirectionType == "" { 119 + redirectionType = "domain" 120 + } 121 + 122 + switch redirectionType { 123 + case "domain": 124 + return validateDomainPattern(redirection.Find) 125 + case "wildcard": 126 + pattern := wildcardToRegex(redirection.Find) 127 + if _, err := regexp.Compile("(?i)" + pattern); err != nil { 128 + return fmt.Errorf("Invalid pattern: %w", err) 129 + } 130 + case "regex": 131 + if _, err := regexp.Compile(redirection.Find); err != nil { 132 + return fmt.Errorf("Invalid regex: %w", err) 133 + } 134 + default: 135 + return fmt.Errorf("Invalid redirection type: %s", redirectionType) 136 + } 137 + return nil 138 + } 139 + 140 + func isRedirectionValid(redirection Redirection) bool { 141 + return ValidateRedirection(redirection) == nil 142 + } 143 + 144 + func AreAllRedirectionsValid(redirections []Redirection) bool { 145 + for _, redirection := range redirections { 146 + if !isRedirectionValid(redirection) { 147 + return false 148 + } 149 + } 150 + return true 151 + }
+474
internal/routing/validation_test.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package routing 4 + 5 + import ( 6 + "testing" 7 + ) 8 + 9 + func TestValidateDomainPattern(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + pattern string 13 + wantErr bool 14 + errorMessage string 15 + }{ 16 + { 17 + name: "valid simple domain", 18 + pattern: "example.com", 19 + wantErr: false, 20 + }, 21 + { 22 + name: "valid subdomain", 23 + pattern: "api.example.com", 24 + wantErr: false, 25 + }, 26 + { 27 + name: "valid multiple subdomains", 28 + pattern: "deep.api.example.com", 29 + wantErr: false, 30 + }, 31 + { 32 + name: "valid domain with hyphen", 33 + pattern: "my-site.example.com", 34 + wantErr: false, 35 + }, 36 + { 37 + name: "valid domain with numbers", 38 + pattern: "site123.example.com", 39 + wantErr: false, 40 + }, 41 + { 42 + name: "valid domain with underscore", 43 + pattern: "my_site.example.com", 44 + wantErr: false, 45 + }, 46 + { 47 + name: "empty domain", 48 + pattern: "", 49 + wantErr: true, 50 + errorMessage: "Domain cannot be empty", 51 + }, 52 + { 53 + name: "domain with wildcard asterisk", 54 + pattern: "*.example.com", 55 + wantErr: true, 56 + errorMessage: "Wildcards not allowed in domain patterns (use Wildcard type instead)", 57 + }, 58 + { 59 + name: "domain with wildcard question mark", 60 + pattern: "example?.com", 61 + wantErr: true, 62 + errorMessage: "Wildcards not allowed in domain patterns (use Wildcard type instead)", 63 + }, 64 + { 65 + name: "domain with space", 66 + pattern: "example .com", 67 + wantErr: true, 68 + errorMessage: "Domain cannot contain spaces", 69 + }, 70 + { 71 + name: "domain starting with dot", 72 + pattern: ".example.com", 73 + wantErr: true, 74 + errorMessage: "Domain cannot start or end with a dot", 75 + }, 76 + { 77 + name: "domain ending with dot", 78 + pattern: "example.com.", 79 + wantErr: true, 80 + errorMessage: "Domain cannot start or end with a dot", 81 + }, 82 + { 83 + name: "domain starting with hyphen", 84 + pattern: "-example.com", 85 + wantErr: true, 86 + errorMessage: "Domain cannot start or end with a hyphen", 87 + }, 88 + { 89 + name: "domain ending with hyphen", 90 + pattern: "example.com-", 91 + wantErr: true, 92 + errorMessage: "Domain cannot start or end with a hyphen", 93 + }, 94 + { 95 + name: "domain with slash", 96 + pattern: "example.com/path", 97 + wantErr: true, 98 + errorMessage: "Domain contains invalid character: /", 99 + }, 100 + { 101 + name: "domain with special character", 102 + pattern: "example@test.com", 103 + wantErr: true, 104 + errorMessage: "Domain contains invalid character: @", 105 + }, 106 + } 107 + 108 + for _, tt := range tests { 109 + t.Run(tt.name, func(t *testing.T) { 110 + err := validateDomainPattern(tt.pattern) 111 + if tt.wantErr { 112 + if err == nil { 113 + t.Errorf("validateDomainPattern(%q) expected error but got nil", tt.pattern) 114 + } else if tt.errorMessage != "" && err.Error() != tt.errorMessage { 115 + t.Errorf("validateDomainPattern(%q) error = %q, want %q", tt.pattern, err.Error(), tt.errorMessage) 116 + } 117 + } else { 118 + if err != nil { 119 + t.Errorf("validateDomainPattern(%q) unexpected error: %v", tt.pattern, err) 120 + } 121 + } 122 + }) 123 + } 124 + } 125 + 126 + func TestValidateGlobPattern(t *testing.T) { 127 + tests := []struct { 128 + name string 129 + pattern string 130 + wantErr bool 131 + errorMessage string 132 + }{ 133 + { 134 + name: "valid wildcard at start", 135 + pattern: "*.example.com", 136 + wantErr: false, 137 + }, 138 + { 139 + name: "valid wildcard at end", 140 + pattern: "example.*", 141 + wantErr: false, 142 + }, 143 + { 144 + name: "valid wildcard in middle", 145 + pattern: "api.*.example.com", 146 + wantErr: false, 147 + }, 148 + { 149 + name: "valid multiple wildcards", 150 + pattern: "*.*.example.com", 151 + wantErr: false, 152 + }, 153 + { 154 + name: "valid domain with hyphen and wildcard", 155 + pattern: "my-*.example.com", 156 + wantErr: false, 157 + }, 158 + { 159 + name: "valid domain with numbers and wildcard", 160 + pattern: "site*.example.com", 161 + wantErr: false, 162 + }, 163 + { 164 + name: "empty pattern", 165 + pattern: "", 166 + wantErr: true, 167 + errorMessage: "Wildcard pattern cannot be empty", 168 + }, 169 + { 170 + name: "pattern with space", 171 + pattern: "* .example.com", 172 + wantErr: true, 173 + errorMessage: "Wildcard pattern cannot contain spaces", 174 + }, 175 + { 176 + name: "pattern starting with dot (not wildcard)", 177 + pattern: ".example.com", 178 + wantErr: true, 179 + errorMessage: "Wildcard pattern cannot start with a dot", 180 + }, 181 + { 182 + name: "pattern ending with dot (not wildcard)", 183 + pattern: "example.com.", 184 + wantErr: true, 185 + errorMessage: "Wildcard pattern cannot end with a dot", 186 + }, 187 + { 188 + name: "pattern with special character", 189 + pattern: "example@*.com", 190 + wantErr: true, 191 + errorMessage: "Wildcard pattern contains invalid character: @", 192 + }, 193 + { 194 + name: "pattern with slash", 195 + pattern: "example.com/*", 196 + wantErr: true, 197 + errorMessage: "Wildcard pattern contains invalid character: /", 198 + }, 199 + } 200 + 201 + for _, tt := range tests { 202 + t.Run(tt.name, func(t *testing.T) { 203 + err := validateGlobPattern(tt.pattern) 204 + if tt.wantErr { 205 + if err == nil { 206 + t.Errorf("validateGlobPattern(%q) expected error but got nil", tt.pattern) 207 + } else if tt.errorMessage != "" && err.Error() != tt.errorMessage { 208 + t.Errorf("validateGlobPattern(%q) error = %q, want %q", tt.pattern, err.Error(), tt.errorMessage) 209 + } 210 + } else { 211 + if err != nil { 212 + t.Errorf("validateGlobPattern(%q) unexpected error: %v", tt.pattern, err) 213 + } 214 + } 215 + }) 216 + } 217 + } 218 + 219 + func TestValidateConditionPattern(t *testing.T) { 220 + tests := []struct { 221 + name string 222 + conditionType string 223 + pattern string 224 + wantErr bool 225 + }{ 226 + { 227 + name: "valid domain type", 228 + conditionType: "domain", 229 + pattern: "example.com", 230 + wantErr: false, 231 + }, 232 + { 233 + name: "invalid domain type - wildcard", 234 + conditionType: "domain", 235 + pattern: "*.example.com", 236 + wantErr: true, 237 + }, 238 + { 239 + name: "valid keyword type", 240 + conditionType: "keyword", 241 + pattern: "github", 242 + wantErr: false, 243 + }, 244 + { 245 + name: "valid keyword with special chars", 246 + conditionType: "keyword", 247 + pattern: "/api/v2/", 248 + wantErr: false, 249 + }, 250 + { 251 + name: "empty keyword", 252 + conditionType: "keyword", 253 + pattern: "", 254 + wantErr: true, 255 + }, 256 + { 257 + name: "valid glob type", 258 + conditionType: "glob", 259 + pattern: "*.example.com", 260 + wantErr: false, 261 + }, 262 + { 263 + name: "invalid glob type - spaces", 264 + conditionType: "glob", 265 + pattern: "* .example.com", 266 + wantErr: true, 267 + }, 268 + { 269 + name: "valid regex type", 270 + conditionType: "regex", 271 + pattern: "^https://.*\\.example\\.com", 272 + wantErr: false, 273 + }, 274 + { 275 + name: "invalid regex type - bad syntax", 276 + conditionType: "regex", 277 + pattern: "[invalid", 278 + wantErr: true, 279 + }, 280 + { 281 + name: "empty regex", 282 + conditionType: "regex", 283 + pattern: "", 284 + wantErr: true, 285 + }, 286 + { 287 + name: "empty pattern for domain", 288 + conditionType: "domain", 289 + pattern: "", 290 + wantErr: true, 291 + }, 292 + { 293 + name: "empty pattern for glob", 294 + conditionType: "glob", 295 + pattern: "", 296 + wantErr: true, 297 + }, 298 + { 299 + name: "invalid condition type", 300 + conditionType: "invalid_type", 301 + pattern: "example.com", 302 + wantErr: true, 303 + }, 304 + } 305 + 306 + for _, tt := range tests { 307 + t.Run(tt.name, func(t *testing.T) { 308 + err := ValidateConditionPattern(tt.conditionType, tt.pattern) 309 + if tt.wantErr && err == nil { 310 + t.Errorf("ValidateConditionPattern(%q, %q) expected error but got nil", tt.conditionType, tt.pattern) 311 + } 312 + if !tt.wantErr && err != nil { 313 + t.Errorf("ValidateConditionPattern(%q, %q) unexpected error: %v", tt.conditionType, tt.pattern, err) 314 + } 315 + }) 316 + } 317 + } 318 + 319 + func TestIsConditionValid(t *testing.T) { 320 + tests := []struct { 321 + name string 322 + condition Condition 323 + want bool 324 + }{ 325 + { 326 + name: "valid domain condition", 327 + condition: Condition{ 328 + Type: "domain", 329 + Pattern: "example.com", 330 + }, 331 + want: true, 332 + }, 333 + { 334 + name: "valid keyword condition", 335 + condition: Condition{ 336 + Type: "keyword", 337 + Pattern: "github", 338 + }, 339 + want: true, 340 + }, 341 + { 342 + name: "valid glob condition", 343 + condition: Condition{ 344 + Type: "glob", 345 + Pattern: "*.example.com", 346 + }, 347 + want: true, 348 + }, 349 + { 350 + name: "valid regex condition", 351 + condition: Condition{ 352 + Type: "regex", 353 + Pattern: "^https://.*", 354 + }, 355 + want: true, 356 + }, 357 + { 358 + name: "invalid domain condition - wildcard", 359 + condition: Condition{ 360 + Type: "domain", 361 + Pattern: "*.example.com", 362 + }, 363 + want: false, 364 + }, 365 + { 366 + name: "invalid condition - empty pattern", 367 + condition: Condition{ 368 + Type: "domain", 369 + Pattern: "", 370 + }, 371 + want: false, 372 + }, 373 + { 374 + name: "invalid regex condition", 375 + condition: Condition{ 376 + Type: "regex", 377 + Pattern: "[invalid", 378 + }, 379 + want: false, 380 + }, 381 + { 382 + name: "invalid condition type", 383 + condition: Condition{ 384 + Type: "invalid_type", 385 + Pattern: "example.com", 386 + }, 387 + want: false, 388 + }, 389 + } 390 + 391 + for _, tt := range tests { 392 + t.Run(tt.name, func(t *testing.T) { 393 + got := isConditionValid(tt.condition) 394 + if got != tt.want { 395 + t.Errorf("isConditionValid(%v) = %v, want %v", tt.condition, got, tt.want) 396 + } 397 + }) 398 + } 399 + } 400 + 401 + func TestAreAllConditionsValid(t *testing.T) { 402 + tests := []struct { 403 + name string 404 + conditions []Condition 405 + want bool 406 + }{ 407 + { 408 + name: "all valid conditions", 409 + conditions: []Condition{ 410 + {Type: "domain", Pattern: "example.com"}, 411 + {Type: "keyword", Pattern: "github"}, 412 + }, 413 + want: true, 414 + }, 415 + { 416 + name: "one invalid condition", 417 + conditions: []Condition{ 418 + {Type: "domain", Pattern: "example.com"}, 419 + {Type: "domain", Pattern: "*.invalid.com"}, 420 + }, 421 + want: false, 422 + }, 423 + { 424 + name: "empty pattern in list", 425 + conditions: []Condition{ 426 + {Type: "domain", Pattern: "example.com"}, 427 + {Type: "keyword", Pattern: ""}, 428 + }, 429 + want: false, 430 + }, 431 + { 432 + name: "empty conditions list", 433 + conditions: []Condition{}, 434 + want: false, 435 + }, 436 + { 437 + name: "nil conditions list", 438 + conditions: nil, 439 + want: false, 440 + }, 441 + { 442 + name: "single valid condition", 443 + conditions: []Condition{ 444 + {Type: "domain", Pattern: "example.com"}, 445 + }, 446 + want: true, 447 + }, 448 + { 449 + name: "invalid regex in list", 450 + conditions: []Condition{ 451 + {Type: "domain", Pattern: "example.com"}, 452 + {Type: "regex", Pattern: "[invalid"}, 453 + }, 454 + want: false, 455 + }, 456 + { 457 + name: "invalid condition type in list", 458 + conditions: []Condition{ 459 + {Type: "domain", Pattern: "example.com"}, 460 + {Type: "invalid_type", Pattern: "example.com"}, 461 + }, 462 + want: false, 463 + }, 464 + } 465 + 466 + for _, tt := range tests { 467 + t.Run(tt.name, func(t *testing.T) { 468 + got := AreAllConditionsValid(tt.conditions) 469 + if got != tt.want { 470 + t.Errorf("AreAllConditionsValid(%v) = %v, want %v", tt.conditions, got, tt.want) 471 + } 472 + }) 473 + } 474 + }
+17 -7
justfile
··· 55 55 scripts/generate-flatpak-go-modules.py 56 56 57 57 # Run unit tests 58 - test: 59 - @echo "Running unit tests..." 60 - go test -v ./cmd/switchyard/config_test.go ./cmd/switchyard/config_compat_test.go ./cmd/switchyard/url_test.go ./cmd/switchyard/pattern_test.go ./cmd/switchyard/validation_test.go ./cmd/switchyard/redirection_test.go ./cmd/switchyard/app.go ./cmd/switchyard/config.go ./cmd/switchyard/url.go ./cmd/switchyard/pattern.go ./cmd/switchyard/validation.go ./cmd/switchyard/redirection.go 58 + test-routing: 59 + go test -v ./internal/routing 60 + 61 + test-config: 62 + go test -v ./cmd/switchyard/config_compat_test.go ./cmd/switchyard/app.go ./cmd/switchyard/config.go 63 + 64 + test-sanitizer: 65 + go test -v ./cmd/switchyard/sanitizer_test.go ./cmd/switchyard/sanitizer.go 66 + 67 + test: test-routing test-config test-sanitizer 61 68 62 69 # Run tests with coverage report 63 - test-coverage: 70 + test-routing-coverage: 64 71 @echo "Running tests with coverage..." 65 - go test -coverprofile=coverage.out ./cmd/switchyard/config_test.go ./cmd/switchyard/config_compat_test.go ./cmd/switchyard/url_test.go ./cmd/switchyard/pattern_test.go ./cmd/switchyard/validation_test.go ./cmd/switchyard/redirection_test.go ./cmd/switchyard/app.go ./cmd/switchyard/config.go ./cmd/switchyard/url.go ./cmd/switchyard/pattern.go ./cmd/switchyard/validation.go ./cmd/switchyard/redirection.go 72 + go test -v -coverprofile=coverage.out ./internal/routing 66 73 go tool cover -func=coverage.out 67 74 @echo "" 68 75 @echo "To view HTML coverage report, run: go tool cover -html=coverage.out" 76 + 77 + test-coverage: test-config test-sanitizer test-routing-coverage 69 78 70 79 # Build and install Flatpak (development version) 71 80 flatpak: ··· 136 145 exit 1 137 146 fi 138 147 139 - # Allow cmd/switchyard/app.go, metainfo, extension manifests, and package.json to be dirty; nothing else. 140 148 dirty="$(git status --porcelain -- ':!cmd/switchyard/app.go' ":!${metainfo}" ":!${manifest}" ":!${firefox_manifest}" ":!${pkgjson}")" 141 149 if [ -n "$dirty" ]; then 142 - echo "error: working tree has changes outside cmd/switchyard/app.go, ${metainfo}, ${manifest}, ${firefox_manifest}:" >&2 150 + echo "error: working tree has changes outside cmd/switchyard/app.go, ${metainfo}, ${manifest}, ${firefox_manifest}, ${pkgjson}:" >&2 143 151 echo "$dirty" >&2 144 152 exit 1 145 153 fi ··· 154 162 echo " add a release entry with notes before running 'just release'" >&2 155 163 exit 1 156 164 fi 165 + 166 + just test 157 167 158 168 sed -i -E "s/^(\s*Version\s*=\s*)\"[^\"]+\"/\1\"${version}\"/" cmd/switchyard/app.go 159 169 if ! grep -qE "Version\s*=\s*\"${version}\"" cmd/switchyard/app.go; then