diff --git a/cmd/proxyctl/main.go b/cmd/proxyctl/main.go new file mode 100644 index 0000000..7e53743 --- /dev/null +++ b/cmd/proxyctl/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "io" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "proxyctl/internal/app" + "proxyctl/internal/profile" + "proxyctl/internal/tui" +) + +func main() { + if err := run(os.Args[1:], os.Stdin, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "proxyctl:", err) + os.Exit(1) + } +} + +func run(args []string, input io.Reader, output io.Writer) error { + if len(args) != 0 { + return fmt.Errorf("this build supports no command-line arguments") + } + path, err := profile.ConfigPath() + if err != nil { + return err + } + application, err := app.New(profile.NewFileStore(path)) + if err != nil { + return err + } + program := tea.NewProgram(tui.NewModel(application), tea.WithInput(input), tea.WithOutput(output)) + _, err = program.Run() + return err +} diff --git a/cmd/proxyctl/main_test.go b/cmd/proxyctl/main_test.go new file mode 100644 index 0000000..ca4adfb --- /dev/null +++ b/cmd/proxyctl/main_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDefaultInvocationStartsTUIWithNoProfileState(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var output bytes.Buffer + if err := run(nil, strings.NewReader("q"), &output); err != nil { + t.Fatalf("run proxyctl: %v", err) + } + if !strings.Contains(output.String(), "No Active Profile") { + t.Fatalf("default TUI output missing no-Profile state: %q", output.String()) + } +} + +func TestDefaultInvocationLoadsPersistedActiveProfile(t *testing.T) { + xdg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdg) + writeConfig(t, filepath.Join(xdg, "proxyctl", "profiles.json"), `{ + "profiles": [{"name":"office","mode":"single","singleUrl":"http://proxy.example:8080","httpUrl":"http://proxy.example:8080","httpsUrl":"http://proxy.example:8080","allUrl":"http://proxy.example:8080"}], + "activeProfile": "office" +}`) + var output bytes.Buffer + if err := run(nil, strings.NewReader("q"), &output); err != nil { + t.Fatalf("run proxyctl: %v", err) + } + if !strings.Contains(output.String(), "Active Profile: office") { + t.Fatalf("default TUI output missing Active Profile: %q", output.String()) + } +} + +func writeConfig(t *testing.T, path, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ac8dd42 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module proxyctl + +go 1.24.0 + +require github.com/charmbracelet/bubbletea v1.3.10 + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4789639 --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..8df8445 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,164 @@ +package app + +import ( + "errors" + "fmt" + + "proxyctl/internal/profile" +) + +type IntentKind int + +const ( + CreateProfile IntentKind = iota + 1 + UpdateProfile + DeleteProfile + ActivateProfile +) + +type Intent struct { + Kind IntentKind + Name string + Profile profile.Profile + Confirm bool +} + +type Snapshot struct { + Profiles []profile.Profile + ActiveProfile string +} + +type Result struct { + Snapshot Snapshot + Notice string + Err error +} + +type Module interface { + Snapshot() Snapshot + Dispatch(Intent) Result +} + +type Application struct { + store profile.Store + state profile.State +} + +func New(store profile.Store) (*Application, error) { + state, err := store.Load() + if err != nil { + return nil, err + } + return &Application{store: store, state: state}, nil +} + +func (a *Application) Snapshot() Snapshot { + profiles := append([]profile.Profile(nil), a.state.Profiles...) + return Snapshot{Profiles: profiles, ActiveProfile: a.state.ActiveProfile} +} + +func (a *Application) Dispatch(intent Intent) Result { + var notice string + var err error + switch intent.Kind { + case CreateProfile: + notice, err = a.create(intent.Profile) + case ActivateProfile: + notice, err = a.activate(intent.Name) + case UpdateProfile: + notice, err = a.update(intent.Name, intent.Profile) + case DeleteProfile: + notice, err = a.delete(intent.Name, intent.Confirm) + default: + err = errors.New("unsupported Profile action") + } + return Result{Snapshot: a.Snapshot(), Notice: notice, Err: err} +} + +func (a *Application) create(input profile.Profile) (string, error) { + p, err := profile.ValidateAndNormalize(input) + if err != nil { + return "", err + } + if a.index(p.Name) >= 0 { + return "", fmt.Errorf("a Profile named %q already exists", p.Name) + } + next := cloneState(a.state) + next.Profiles = append(next.Profiles, p) + if err := a.store.Save(next); err != nil { + return "", err + } + a.state = next + return fmt.Sprintf("Created Profile %q", p.Name), nil +} + +func (a *Application) activate(name string) (string, error) { + if a.index(name) < 0 { + return "", fmt.Errorf("Profile %q does not exist", name) + } + next := cloneState(a.state) + next.ActiveProfile = name + if err := a.store.Save(next); err != nil { + return "", err + } + a.state = next + return fmt.Sprintf("Activated Profile %q", name), nil +} + +func (a *Application) update(originalName string, input profile.Profile) (string, error) { + index := a.index(originalName) + if index < 0 { + return "", fmt.Errorf("Profile %q does not exist", originalName) + } + p, err := profile.ValidateAndNormalize(input) + if err != nil { + return "", err + } + if other := a.index(p.Name); other >= 0 && other != index { + return "", fmt.Errorf("a Profile named %q already exists", p.Name) + } + next := cloneState(a.state) + next.Profiles[index] = p + if next.ActiveProfile == originalName { + next.ActiveProfile = p.Name + } + if err := a.store.Save(next); err != nil { + return "", err + } + a.state = next + return fmt.Sprintf("Updated Profile %q", p.Name), nil +} + +func (a *Application) delete(name string, confirm bool) (string, error) { + index := a.index(name) + if index < 0 { + return "", fmt.Errorf("Profile %q does not exist", name) + } + if len(a.state.Profiles) == 1 && !confirm { + return "", errors.New("deleting the final Profile requires confirmation") + } + next := cloneState(a.state) + next.Profiles = append(next.Profiles[:index], next.Profiles[index+1:]...) + if next.ActiveProfile == name { + next.ActiveProfile = "" + } + if err := a.store.Save(next); err != nil { + return "", err + } + a.state = next + return fmt.Sprintf("Deleted Profile %q", name), nil +} + +func (a *Application) index(name string) int { + for i := range a.state.Profiles { + if a.state.Profiles[i].Name == name { + return i + } + } + return -1 +} + +func cloneState(state profile.State) profile.State { + state.Profiles = append([]profile.Profile(nil), state.Profiles...) + return state +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..3f5a6d4 --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,69 @@ +package app_test + +import ( + "path/filepath" + "testing" + + "proxyctl/internal/app" + "proxyctl/internal/profile" +) + +func TestUserCanCreateAndActivatePersistentSingleProxyProfile(t *testing.T) { + configFile := filepath.Join(t.TempDir(), "proxyctl", "profiles.json") + application, err := app.New(profile.NewFileStore(configFile)) + if err != nil { + t.Fatalf("start application: %v", err) + } + + result := application.Dispatch(app.Intent{ + Kind: app.CreateProfile, + Profile: profile.Profile{ + Name: "office", + Mode: profile.SingleMode, + SingleURL: "http://proxy.example:8080", + Bypass: []string{"LOCALHOST", ".Example.com", "localhost"}, + }, + }) + if result.Err != nil { + t.Fatalf("create Profile: %v", result.Err) + } + + result = application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"}) + if result.Err != nil { + t.Fatalf("activate Profile: %v", result.Err) + } + if result.Snapshot.ActiveProfile != "office" { + t.Fatalf("Active Profile = %q, want office", result.Snapshot.ActiveProfile) + } + got := result.Snapshot.Profiles[0] + if got.SingleURL != "http://proxy.example:8080" { + t.Fatalf("single proxy value = %q", got.SingleURL) + } + if want := []string{"localhost", ".example.com"}; !equalStrings(got.Bypass, want) { + t.Fatalf("normalized Bypass = %#v, want %#v", got.Bypass, want) + } + if got.HTTPURL != "http://proxy.example:8080" || got.HTTPSURL != "http://proxy.example:8080" || got.AllURL != "http://proxy.example:8080" { + t.Fatalf("Single proxy was not expanded: %#v", got) + } + + restarted, err := app.New(profile.NewFileStore(configFile)) + if err != nil { + t.Fatalf("restart application: %v", err) + } + persisted := restarted.Snapshot() + if persisted.ActiveProfile != "office" || len(persisted.Profiles) != 1 { + t.Fatalf("persisted snapshot = %#v", persisted) + } +} + +func equalStrings(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} diff --git a/internal/app/profile_management_test.go b/internal/app/profile_management_test.go new file mode 100644 index 0000000..145ac6d --- /dev/null +++ b/internal/app/profile_management_test.go @@ -0,0 +1,75 @@ +package app_test + +import ( + "path/filepath" + "strings" + "testing" + + "proxyctl/internal/app" + "proxyctl/internal/profile" +) + +func TestUserCanOpenSingleProfileInAdvancedModeWithoutLosingItsValue(t *testing.T) { + application, err := app.New(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json"))) + if err != nil { + t.Fatal(err) + } + created := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{ + Name: "home", Mode: profile.SingleMode, SingleURL: "socks5h://127.0.0.1:1080", + }}) + if created.Err != nil { + t.Fatal(created.Err) + } + + edited := application.Dispatch(app.Intent{Kind: app.UpdateProfile, Name: "home", Profile: profile.Profile{ + Name: "home", Mode: profile.AdvancedMode, SingleURL: "socks5h://127.0.0.1:1080", + }}) + if edited.Err != nil { + t.Fatalf("open advanced editing: %v", edited.Err) + } + got := edited.Snapshot.Profiles[0] + if got.HTTPURL != got.SingleURL || got.HTTPSURL != got.SingleURL || got.AllURL != got.SingleURL { + t.Fatalf("advanced values = %q, %q, %q; want preserved %q", got.HTTPURL, got.HTTPSURL, got.AllURL, got.SingleURL) + } +} + +func TestUserCanViewEditActivateAndDeleteProfiles(t *testing.T) { + application, err := app.New(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json"))) + if err != nil { + t.Fatal(err) + } + for _, p := range []profile.Profile{ + {Name: "home", Mode: profile.SingleMode, SingleURL: "http://localhost:8080"}, + {Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:3128", HTTPSURL: "https://proxy.example:4443", AllURL: "socks5://proxy.example:1080"}, + } { + if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: p}); result.Err != nil { + t.Fatalf("create %s: %v", p.Name, result.Err) + } + } + if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: "home", Mode: profile.SingleMode, SingleURL: "http://localhost:9000"}}); result.Err == nil || !strings.Contains(result.Err.Error(), "already exists") { + t.Fatalf("duplicate name error = %v", result.Err) + } + if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"}); result.Err != nil { + t.Fatal(result.Err) + } + updated := application.Dispatch(app.Intent{Kind: app.UpdateProfile, Name: "office", Profile: profile.Profile{ + Name: "work", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:3128", AllURL: "socks5h://proxy.example:1080", + }}) + if updated.Err != nil { + t.Fatal(updated.Err) + } + if updated.Snapshot.ActiveProfile != "work" { + t.Fatalf("renamed Active Profile = %q", updated.Snapshot.ActiveProfile) + } + deleted := application.Dispatch(app.Intent{Kind: app.DeleteProfile, Name: "home", Confirm: true}) + if deleted.Err != nil || len(deleted.Snapshot.Profiles) != 1 { + t.Fatalf("delete Profile result = %#v", deleted) + } + if unconfirmed := application.Dispatch(app.Intent{Kind: app.DeleteProfile, Name: "work"}); unconfirmed.Err == nil { + t.Fatal("deleting the final Profile did not require confirmation") + } + confirmed := application.Dispatch(app.Intent{Kind: app.DeleteProfile, Name: "work", Confirm: true}) + if confirmed.Err != nil || confirmed.Snapshot.ActiveProfile != "" || len(confirmed.Snapshot.Profiles) != 0 { + t.Fatalf("delete final Profile result = %#v", confirmed) + } +} diff --git a/internal/app/storage_test.go b/internal/app/storage_test.go new file mode 100644 index 0000000..5d903fd --- /dev/null +++ b/internal/app/storage_test.go @@ -0,0 +1,82 @@ +package app_test + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "proxyctl/internal/app" + "proxyctl/internal/profile" +) + +func TestProfileConfigurationUsesIsolatedXDGPathOwnerOnlyAndAtomicReplacement(t *testing.T) { + xdg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdg) + path, err := profile.ConfigPath() + if err != nil { + t.Fatalf("resolve XDG configuration: %v", err) + } + wantPath := filepath.Join(xdg, "proxyctl", "profiles.json") + if path != wantPath { + t.Fatalf("configuration path = %q, want %q", path, wantPath) + } + application, err := app.New(profile.NewFileStore(path)) + if err != nil { + t.Fatal(err) + } + created := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{ + Name: "private", Mode: profile.SingleMode, SingleURL: "http://alice:secret@proxy.example:8080", + }}) + if created.Err != nil { + t.Fatal(created.Err) + } + assertMode(t, filepath.Dir(path), 0o700) + assertMode(t, path, 0o600) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "\n \"profiles\"") { + t.Fatalf("Profile configuration is not human-readable JSON:\n%s", data) + } + + oldFile, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer oldFile.Close() + updated := application.Dispatch(app.Intent{Kind: app.UpdateProfile, Name: "private", Profile: profile.Profile{ + Name: "renamed", Mode: profile.SingleMode, SingleURL: "http://alice:new-secret@proxy.example:8080", + }}) + if updated.Err != nil { + t.Fatal(updated.Err) + } + oldData, err := io.ReadAll(oldFile) + if err != nil { + t.Fatal(err) + } + newData, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(oldData), `"name": "private"`) || !strings.Contains(string(newData), `"name": "renamed"`) { + t.Fatalf("configuration was not atomically replaced; old=%s new=%s", oldData, newData) + } + matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".profiles-*")) + if err != nil || len(matches) != 0 { + t.Fatalf("temporary Profile files left behind: %v (%v)", matches, err) + } +} + +func assertMode(t *testing.T, path string, want os.FileMode) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != want { + t.Fatalf("mode for %s = %#o, want %#o", path, got, want) + } +} diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go new file mode 100644 index 0000000..9bb658e --- /dev/null +++ b/internal/app/validation_test.go @@ -0,0 +1,72 @@ +package app_test + +import ( + "path/filepath" + "strings" + "testing" + + "proxyctl/internal/app" + "proxyctl/internal/profile" +) + +func TestProfileValidationAcceptsSupportedURLsAndNormalizesEveryBypassKind(t *testing.T) { + application, err := app.New(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json"))) + if err != nil { + t.Fatal(err) + } + result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{ + Name: "mixed", + Mode: profile.AdvancedMode, + HTTPURL: "http://proxy.example:80", + HTTPSURL: "https://user:secret@proxy.example:443", + AllURL: "socks5h://[2001:db8::5]:1080", + Bypass: []string{ + "LOCALHOST, Example.COM.", + ".Internal.Example", + "192.0.2.1", + "2001:0db8::1", + "10.2.3.4/8", + "2001:db8:abcd::5/48", + "localhost", + }, + }}) + if result.Err != nil { + t.Fatalf("create validated Profile: %v", result.Err) + } + want := []string{"localhost", "example.com", ".internal.example", "192.0.2.1", "2001:db8::1", "10.0.0.0/8", "2001:db8:abcd::/48"} + if got := result.Snapshot.Profiles[0].Bypass; !equalStrings(got, want) { + t.Fatalf("normalized Bypass = %#v, want %#v", got, want) + } +} + +func TestProfileValidationRejectsMalformedURLsAndBypassWithActionableErrors(t *testing.T) { + tests := []struct { + name string + profile profile.Profile + want string + }{ + {"unsupported scheme", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "ftp://user:secret@proxy.example:21"}, "http, https, socks5, or socks5h"}, + {"missing host", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://:8080"}, "host"}, + {"missing port", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example"}, "port"}, + {"invalid port", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example:70000"}, "between 1 and 65535"}, + {"path", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080/path"}, "path"}, + {"invalid IPv4 host", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://999.1.2.3:8080"}, "invalid host"}, + {"invalid bypass", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080", Bypass: []string{"*"}}, "hostname, domain suffix, IP address, or CIDR"}, + {"invalid IPv4 bypass", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080", Bypass: []string{"999.1.2.3"}}, "hostname, domain suffix, IP address, or CIDR"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + application, err := app.New(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json"))) + if err != nil { + t.Fatal(err) + } + result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: tt.profile}) + if result.Err == nil || !strings.Contains(result.Err.Error(), tt.want) { + t.Fatalf("error = %v, want actionable text containing %q", result.Err, tt.want) + } + if strings.Contains(result.Err.Error(), "secret") { + t.Fatalf("validation error exposed proxy password: %v", result.Err) + } + }) + } +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go new file mode 100644 index 0000000..44d85dc --- /dev/null +++ b/internal/profile/profile.go @@ -0,0 +1,220 @@ +package profile + +import ( + "errors" + "fmt" + "net" + "net/url" + "regexp" + "strconv" + "strings" +) + +type Mode string + +const ( + SingleMode Mode = "single" + AdvancedMode Mode = "advanced" +) + +type Profile struct { + Name string `json:"name"` + Mode Mode `json:"mode"` + SingleURL string `json:"singleUrl,omitempty"` + HTTPURL string `json:"httpUrl,omitempty"` + HTTPSURL string `json:"httpsUrl,omitempty"` + AllURL string `json:"allUrl,omitempty"` + Bypass []string `json:"bypass,omitempty"` +} + +type State struct { + Profiles []Profile `json:"profiles"` + ActiveProfile string `json:"activeProfile,omitempty"` +} + +type Store interface { + Load() (State, error) + Save(State) error +} + +func ValidateAndNormalize(input Profile) (Profile, error) { + p := input + p.Name = strings.TrimSpace(p.Name) + if p.Name == "" { + return Profile{}, errors.New("Profile name is required") + } + if p.Mode == "" { + p.Mode = SingleMode + } + switch p.Mode { + case SingleMode: + value, err := validateURL("Single proxy URL", p.SingleURL) + if err != nil { + return Profile{}, err + } + p.SingleURL = value + p.HTTPURL, p.HTTPSURL, p.AllURL = value, value, value + case AdvancedMode: + if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" && strings.TrimSpace(p.SingleURL) != "" { + value, err := validateURL("Single proxy URL", p.SingleURL) + if err != nil { + return Profile{}, err + } + p.SingleURL = value + p.HTTPURL, p.HTTPSURL, p.AllURL = value, value, value + } + var err error + if p.HTTPURL, err = validateOptionalURL("HTTP proxy URL", p.HTTPURL); err != nil { + return Profile{}, err + } + if p.HTTPSURL, err = validateOptionalURL("HTTPS proxy URL", p.HTTPSURL); err != nil { + return Profile{}, err + } + if p.AllURL, err = validateOptionalURL("ALL/SOCKS proxy URL", p.AllURL); err != nil { + return Profile{}, err + } + if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" { + return Profile{}, errors.New("at least one advanced proxy URL is required") + } + default: + return Profile{}, errors.New("proxy mode must be single or advanced") + } + + bypass, err := normalizeBypass(p.Bypass) + if err != nil { + return Profile{}, err + } + p.Bypass = bypass + return p, nil +} + +func validateOptionalURL(field, value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "", nil + } + return validateURL(field, value) +} + +func validateURL(field, value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("%s is required", field) + } + u, err := url.Parse(value) + if err != nil { + return "", fmt.Errorf("%s is malformed", field) + } + switch strings.ToLower(u.Scheme) { + case "http", "https", "socks5", "socks5h": + default: + return "", fmt.Errorf("%s must use http, https, socks5, or socks5h", field) + } + if u.Hostname() == "" { + return "", fmt.Errorf("%s must include a host", field) + } + if u.Path != "" || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("%s must not include a path, query, or fragment", field) + } + port := u.Port() + if port == "" { + return "", fmt.Errorf("%s must include a port", field) + } + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return "", fmt.Errorf("%s port must be between 1 and 65535", field) + } + if net.ParseIP(u.Hostname()) == nil && (looksLikeIPv4(u.Hostname()) || !validHostname(u.Hostname())) { + return "", fmt.Errorf("%s contains an invalid host", field) + } + u.Scheme = strings.ToLower(u.Scheme) + return u.String(), nil +} + +func normalizeBypass(values []string) ([]string, error) { + result := make([]string, 0, len(values)) + seen := make(map[string]bool) + for _, raw := range values { + for _, item := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' }) { + value, err := normalizeBypassEntry(item) + if err != nil { + return nil, err + } + if !seen[value] { + seen[value] = true + result = append(result, value) + } + } + } + return result, nil +} + +func normalizeBypassEntry(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", errors.New("Bypass entries cannot be empty") + } + if _, network, err := net.ParseCIDR(value); err == nil { + return network.String(), nil + } + if ip := net.ParseIP(value); ip != nil { + return ip.String(), nil + } + suffix := strings.HasPrefix(value, ".") + host := strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(value, "."), ".")) + if looksLikeIPv4(host) || !validHostname(host) { + return "", errors.New("Bypass entry must be a hostname, domain suffix, IP address, or CIDR range") + } + if suffix { + return "." + host, nil + } + return host, nil +} + +var credentialPattern = regexp.MustCompile(`(?i)(https?|socks5h?)://([^/@\s:]+):([^@\s]*)@`) + +func ObscureURL(value string) string { + u, err := url.Parse(value) + if err != nil || u.User == nil { + return credentialPattern.ReplaceAllString(value, `${1}://${2}:REDACTED@`) + } + if _, present := u.User.Password(); !present { + return value + } + u.User = url.UserPassword(u.User.Username(), "REDACTED") + return u.String() +} + +func looksLikeIPv4(host string) bool { + parts := strings.Split(host, ".") + if len(parts) != 4 { + return false + } + for _, part := range parts { + if part == "" { + return false + } + for _, r := range part { + if r < '0' || r > '9' { + return false + } + } + } + return true +} + +func validHostname(host string) bool { + if len(host) == 0 || len(host) > 253 || strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") { + return false + } + for _, label := range strings.Split(host, ".") { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, r := range label { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-') { + return false + } + } + } + return true +} diff --git a/internal/profile/store.go b/internal/profile/store.go new file mode 100644 index 0000000..80efd9c --- /dev/null +++ b/internal/profile/store.go @@ -0,0 +1,91 @@ +package profile + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +type FileStore struct { + path string +} + +func NewFileStore(path string) *FileStore { + return &FileStore{path: path} +} + +func ConfigPath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("locate user configuration directory: %w", err) + } + return filepath.Join(configDir, "proxyctl", "profiles.json"), nil +} + +func (s *FileStore) Load() (State, error) { + data, err := os.ReadFile(s.path) + if errors.Is(err, fs.ErrNotExist) { + return State{}, nil + } + if err != nil { + return State{}, fmt.Errorf("read Profile configuration: %w", err) + } + var state State + if err := json.Unmarshal(data, &state); err != nil { + return State{}, fmt.Errorf("parse Profile configuration: %w", err) + } + return state, nil +} + +func (s *FileStore) Save(state State) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return fmt.Errorf("create Profile configuration directory: %w", err) + } + if err := os.Chmod(filepath.Dir(s.path), 0o700); err != nil { + return fmt.Errorf("secure Profile configuration directory: %w", err) + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("encode Profile configuration: %w", err) + } + data = append(data, '\n') + temp, err := os.CreateTemp(filepath.Dir(s.path), ".profiles-*") + if err != nil { + return fmt.Errorf("create temporary Profile configuration: %w", err) + } + tempName := temp.Name() + defer os.Remove(tempName) + if err := temp.Chmod(0o600); err != nil { + temp.Close() + return fmt.Errorf("secure temporary Profile configuration: %w", err) + } + if _, err := temp.Write(data); err != nil { + temp.Close() + return fmt.Errorf("write temporary Profile configuration: %w", err) + } + if err := temp.Sync(); err != nil { + temp.Close() + return fmt.Errorf("sync temporary Profile configuration: %w", err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("close temporary Profile configuration: %w", err) + } + if err := os.Rename(tempName, s.path); err != nil { + return fmt.Errorf("replace Profile configuration: %w", err) + } + if err := os.Chmod(s.path, 0o600); err != nil { + return fmt.Errorf("secure Profile configuration: %w", err) + } + dir, err := os.Open(filepath.Dir(s.path)) + if err == nil { + err = dir.Sync() + _ = dir.Close() + } + if err != nil { + return fmt.Errorf("sync Profile configuration directory: %w", err) + } + return nil +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..7512af7 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,347 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "proxyctl/internal/app" + "proxyctl/internal/profile" +) + +type page int + +const ( + mainPage page = iota + profilesPage + formPage +) + +type formState struct { + originalName string + profile profile.Profile + field int +} + +type Model struct { + application app.Module + snapshot app.Snapshot + page page + selected int + form formState + confirm bool + status string +} + +func NewModel(application app.Module) Model { + return Model{application: application, snapshot: application.Snapshot()} +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { + key, ok := message.(tea.KeyMsg) + if !ok { + return m, nil + } + switch m.page { + case mainPage: + return m.updateMain(key) + case profilesPage: + return m.updateProfiles(key) + case formPage: + return m.updateForm(key) + default: + return m, nil + } +} + +func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { + switch key.String() { + case "p": + m.page = profilesPage + m.status = "" + case "q", "esc", "ctrl+c": + return m, tea.Quit + } + return m, nil +} + +func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.confirm { + switch key.String() { + case "y": + if selected, ok := m.selectedProfile(); ok { + m.apply(app.Intent{Kind: app.DeleteProfile, Name: selected.Name, Confirm: true}) + } + m.confirm = false + case "n", "esc", "q": + m.confirm = false + } + return m, nil + } + + switch key.String() { + case "q", "esc": + m.page = mainPage + m.status = "" + case "up", "k": + if m.selected > 0 { + m.selected-- + } + case "down", "j": + if m.selected+1 < len(m.snapshot.Profiles) { + m.selected++ + } + case "n": + m.form = formState{profile: profile.Profile{Mode: profile.SingleMode}} + m.page = formPage + m.status = "" + case "e": + if selected, ok := m.selectedProfile(); ok { + m.form = formState{originalName: selected.Name, profile: selected} + m.page = formPage + m.status = "" + } + case "a", "enter": + if selected, ok := m.selectedProfile(); ok { + m.apply(app.Intent{Kind: app.ActivateProfile, Name: selected.Name}) + } + case "d": + if _, ok := m.selectedProfile(); ok { + m.confirm = true + } + } + return m, nil +} + +func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) { + switch key.String() { + case "esc": + m.page = profilesPage + m.status = "" + return m, nil + case "ctrl+s": + kind := app.CreateProfile + if m.form.originalName != "" { + kind = app.UpdateProfile + } + intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: m.form.profile} + result := m.application.Dispatch(intent) + m.snapshot = result.Snapshot + if result.Err != nil { + m.status = "Error: " + result.Err.Error() + return m, nil + } + m.status = result.Notice + m.page = profilesPage + m.selectName(intent.Profile.Name) + return m, nil + case "ctrl+t": + if m.form.profile.Mode == profile.SingleMode { + m.form.profile.Mode = profile.AdvancedMode + if m.form.profile.HTTPURL == "" && m.form.profile.HTTPSURL == "" && m.form.profile.AllURL == "" { + m.form.profile.HTTPURL = m.form.profile.SingleURL + m.form.profile.HTTPSURL = m.form.profile.SingleURL + m.form.profile.AllURL = m.form.profile.SingleURL + } + } else { + m.form.profile.Mode = profile.SingleMode + } + m.form.field = 0 + return m, nil + case "tab", "down": + m.form.field = (m.form.field + 1) % m.form.fieldCount() + return m, nil + case "shift+tab", "up": + m.form.field = (m.form.field - 1 + m.form.fieldCount()) % m.form.fieldCount() + return m, nil + case "backspace": + value := []rune(m.form.value()) + if len(value) > 0 { + m.form.setValue(string(value[:len(value)-1])) + } + return m, nil + } + if key.Type == tea.KeyRunes { + m.form.setValue(m.form.value() + string(key.Runes)) + } + return m, nil +} + +func (m *Model) apply(intent app.Intent) { + result := m.application.Dispatch(intent) + m.snapshot = result.Snapshot + if m.selected >= len(m.snapshot.Profiles) && m.selected > 0 { + m.selected-- + } + if result.Err != nil { + m.status = "Error: " + result.Err.Error() + } else { + m.status = result.Notice + } +} + +func (m Model) selectedProfile() (profile.Profile, bool) { + if m.selected < 0 || m.selected >= len(m.snapshot.Profiles) { + return profile.Profile{}, false + } + return m.snapshot.Profiles[m.selected], true +} + +func (m *Model) selectName(name string) { + for i := range m.snapshot.Profiles { + if m.snapshot.Profiles[i].Name == name { + m.selected = i + return + } + } +} + +func (m Model) View() string { + switch m.page { + case profilesPage: + return m.profilesView() + case formPage: + return m.formView() + default: + return m.mainView() + } +} + +func (m Model) mainView() string { + var b strings.Builder + b.WriteString("proxyctl — Target panel\n\n") + if m.snapshot.ActiveProfile == "" { + b.WriteString("No Active Profile\n") + b.WriteString("Create and activate a Proxy Profile before enabling Targets.\n") + } else { + fmt.Fprintf(&b, "Active Profile: %s\n", m.snapshot.ActiveProfile) + } + b.WriteString("\nNo Targets are available in this build.\n") + b.WriteString("\np: manage Proxy Profiles q/Esc: quit\n") + return b.String() +} + +func (m Model) profilesView() string { + var b strings.Builder + b.WriteString("Proxy Profiles\n\n") + if len(m.snapshot.Profiles) == 0 { + b.WriteString("No Proxy Profiles. Press n to create one.\n") + } + for i, item := range m.snapshot.Profiles { + cursor, active := " ", " " + if i == m.selected { + cursor = "> " + } + if item.Name == m.snapshot.ActiveProfile { + active = "*" + } + fmt.Fprintf(&b, "%s%s %s\n", cursor, active, item.Name) + } + if selected, ok := m.selectedProfile(); ok { + b.WriteString("\nSelected Profile\n") + fmt.Fprintf(&b, "Mode: %s\n", selected.Mode) + if selected.Mode == profile.SingleMode { + fmt.Fprintf(&b, "Proxy: %s\n", profile.ObscureURL(selected.SingleURL)) + } else { + fmt.Fprintf(&b, "HTTP: %s\nHTTPS: %s\nALL/SOCKS: %s\n", + profile.ObscureURL(selected.HTTPURL), profile.ObscureURL(selected.HTTPSURL), profile.ObscureURL(selected.AllURL)) + } + fmt.Fprintf(&b, "Bypass: %s\n", strings.Join(selected.Bypass, ", ")) + } + if m.confirm { + b.WriteString("\nDelete Profile? y: confirm n/Esc: cancel\n") + } else { + b.WriteString("\nn: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back\n") + } + if m.status != "" { + fmt.Fprintf(&b, "\n%s\n", m.status) + } + return b.String() +} + +func (m Model) formView() string { + var b strings.Builder + action := "Create" + if m.form.originalName != "" { + action = "Edit" + } + fmt.Fprintf(&b, "%s Proxy Profile — %s mode\n\n", action, m.form.profile.Mode) + labels := m.form.labels() + for i, label := range labels { + cursor := " " + if i == m.form.field { + cursor = "> " + } + value := m.form.valueAt(i) + if label != "Name" && label != "Bypass" { + value = profile.ObscureURL(value) + } + fmt.Fprintf(&b, "%s%s: %s\n", cursor, label, value) + } + b.WriteString("\nTab/↑/↓: move Ctrl+T: toggle Single/Advanced\nCtrl+S: save Esc: cancel\n") + if m.status != "" { + fmt.Fprintf(&b, "\n%s\n", m.status) + } + return b.String() +} + +func (f formState) labels() []string { + if f.profile.Mode == profile.AdvancedMode { + return []string{"Name", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL", "Bypass"} + } + return []string{"Name", "Single proxy URL", "Bypass"} +} + +func (f formState) fieldCount() int { return len(f.labels()) } + +func (f formState) value() string { return f.valueAt(f.field) } + +func (f formState) valueAt(index int) string { + if index == 0 { + return f.profile.Name + } + if f.profile.Mode == profile.AdvancedMode { + switch index { + case 1: + return f.profile.HTTPURL + case 2: + return f.profile.HTTPSURL + case 3: + return f.profile.AllURL + default: + return strings.Join(f.profile.Bypass, ", ") + } + } + if index == 1 { + return f.profile.SingleURL + } + return strings.Join(f.profile.Bypass, ", ") +} + +func (f *formState) setValue(value string) { + if f.field == 0 { + f.profile.Name = value + return + } + if f.profile.Mode == profile.AdvancedMode { + switch f.field { + case 1: + f.profile.HTTPURL = value + case 2: + f.profile.HTTPSURL = value + case 3: + f.profile.AllURL = value + default: + f.profile.Bypass = []string{value} + } + return + } + if f.field == 1 { + f.profile.SingleURL = value + } else { + f.profile.Bypass = []string{value} + } +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..a55cd70 --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,118 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "proxyctl/internal/app" + "proxyctl/internal/profile" +) + +type recordingModule struct { + snapshot app.Snapshot + intents []app.Intent +} + +func (m *recordingModule) Snapshot() app.Snapshot { return m.snapshot } +func (m *recordingModule) Dispatch(intent app.Intent) app.Result { + m.intents = append(m.intents, intent) + return app.Result{Snapshot: m.snapshot, Notice: "done"} +} + +func TestMainAndProfilePagesMapKeysAndObscurePasswords(t *testing.T) { + module := &recordingModule{snapshot: app.Snapshot{ + ActiveProfile: "office", + Profiles: []profile.Profile{{ + Name: "office", Mode: profile.SingleMode, + SingleURL: "https://alice:very-secret@proxy.example:8443", + }}, + }} + model := NewModel(module) + if view := model.View(); !strings.Contains(view, "Active Profile: office") { + t.Fatalf("main view missing Active Profile:\n%s", view) + } + + model = updateKey(model, "p") + view := model.View() + if !strings.Contains(view, "Proxy Profiles") || !strings.Contains(view, "alice:") { + t.Fatalf("Profile page did not render selected Profile:\n%s", view) + } + if strings.Contains(view, "very-secret") { + t.Fatalf("Profile page exposed proxy password:\n%s", view) + } + + model = updateKey(model, "a") + if len(module.intents) != 1 || module.intents[0].Kind != app.ActivateProfile || module.intents[0].Name != "office" { + t.Fatalf("activate key dispatched %#v", module.intents) + } + model = updateKey(model, "d") + if !strings.Contains(model.View(), "Delete Profile") { + t.Fatalf("delete key did not request confirmation:\n%s", model.View()) + } + model = updateKey(model, "y") + if len(module.intents) != 2 || module.intents[1].Kind != app.DeleteProfile || !module.intents[1].Confirm { + t.Fatalf("confirmed delete dispatched %#v", module.intents) + } +} + +func TestProfileFormMapsKeyboardInputToCreateIntent(t *testing.T) { + module := &recordingModule{} + model := NewModel(module) + model = updateKey(model, "p") + model = updateKey(model, "n") + model = updateRunes(model, "home") + model = updateKey(model, "tab") + model = updateRunes(model, "socks5://localhost:1080") + model = updateKey(model, "tab") + model = updateRunes(model, "localhost, .example.com") + model = updateKey(model, "ctrl+s") + + if len(module.intents) != 1 { + t.Fatalf("create form dispatched %d intents", len(module.intents)) + } + intent := module.intents[0] + if intent.Kind != app.CreateProfile || intent.Profile.Name != "home" || intent.Profile.SingleURL != "socks5://localhost:1080" { + t.Fatalf("create form dispatched %#v", intent) + } + if got := intent.Profile.Bypass; len(got) != 1 || got[0] != "localhost, .example.com" { + t.Fatalf("create form Bypass = %#v", got) + } +} + +func TestRenderedMalformedProxyURLStillObscuresPassword(t *testing.T) { + module := &recordingModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{ + Name: "manual", Mode: profile.SingleMode, SingleURL: "http://alice:do-not-show@proxy.example:bad", + }}}} + model := NewModel(module) + model = updateKey(model, "p") + if view := model.View(); strings.Contains(view, "do-not-show") { + t.Fatalf("malformed proxy URL exposed password:\n%s", view) + } +} + +func TestNoProfileStateStaysOnAccessibleMainPage(t *testing.T) { + model := NewModel(&recordingModule{}) + view := model.View() + if !strings.Contains(view, "No Active Profile") || !strings.Contains(view, "p: manage Proxy Profiles") { + t.Fatalf("no-Profile main view is not actionable:\n%s", view) + } +} + +func updateKey(model Model, key string) Model { + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} + switch key { + case "tab": + msg = tea.KeyMsg{Type: tea.KeyTab} + case "ctrl+s": + msg = tea.KeyMsg{Type: tea.KeyCtrlS} + } + updated, _ := model.Update(msg) + return updated.(Model) +} + +func updateRunes(model Model, value string) Model { + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)}) + return updated.(Model) +}