diff --git a/.scratch/proxyctl/issues/02-shell-target.md b/.scratch/proxyctl/issues/02-shell-target.md index f08a3fe..23923b2 100644 --- a/.scratch/proxyctl/issues/02-shell-target.md +++ b/.scratch/proxyctl/issues/02-shell-target.md @@ -4,7 +4,7 @@ **Blocked by:** 01 — Create and activate persistent Proxy Profiles. -**Status:** in-progress +**Status:** done - [ ] A shared Target Interface exposes description, inspection, and desired-state operations without leaking platform paths or commands to the TUI. - [ ] The Shell Target is Available only for detected Bash or Zsh environments; unsupported Shells are explained outside the main Target list. diff --git a/cmd/proxyctl/main.go b/cmd/proxyctl/main.go index 9f1b517..2066530 100644 --- a/cmd/proxyctl/main.go +++ b/cmd/proxyctl/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "io" "os" @@ -9,6 +10,8 @@ import ( "proxyctl/internal/app" "proxyctl/internal/profile" + "proxyctl/internal/shell" + "proxyctl/internal/target" "proxyctl/internal/tui" ) @@ -20,14 +23,58 @@ func main() { } 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") + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("locate proxyctl executable: %w", err) } - configuration, err := profile.ResolveConfigurationForCurrentProcess() + return runWithEnvironment(args, input, output, processEnvironment{}, shell.OSFileSystem{}, executable) +} + +type processEnvironment struct{} + +func (processEnvironment) CurrentUser() (profile.UserIdentity, error) { + return profile.SystemCurrentUser() +} +func (processEnvironment) LookupUser(name string) (profile.UserIdentity, error) { + return profile.SystemLookupUser(name) +} +func (processEnvironment) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) } + +func runWithEnvironment(args []string, input io.Reader, output io.Writer, environment profile.IdentityEnvironment, files shell.FileSystem, executable string) error { + profileConfiguration, err := profile.ResolveConfiguration(environment) if err != nil { return err } - application, err := app.New(profile.NewOwnedFileStoreAt(configuration.Path, configuration.Owner, configuration.TrustedRoot)) + shellConfiguration, err := shell.ResolveConfiguration(environment) + if err != nil { + return err + } + shellTarget := shell.New(shellConfiguration, files, environment) + + if len(args) != 0 { + if len(args) != 2 || args[0] != "shell" || (args[1] != "install" && args[1] != "uninstall") { + return fmt.Errorf("usage: proxyctl [shell install|shell uninstall]") + } + availability := shellTarget.Availability(context.Background()) + if !availability.Available { + return fmt.Errorf("Shell Target unavailable: %s", availability.Evidence) + } + if args[1] == "install" { + if err := shellTarget.Install(context.Background(), executable); err != nil { + return err + } + fmt.Fprintln(output, "Shell integration installed; restart or source your Shell startup file once.") + return nil + } + if err := shellTarget.Uninstall(context.Background()); err != nil { + return err + } + fmt.Fprintln(output, "Shell integration uninstalled.") + return nil + } + + store := profile.NewOwnedFileStoreAt(profileConfiguration.Path, profileConfiguration.Owner, profileConfiguration.TrustedRoot) + application, err := app.NewWithTargets(store, []target.Target{shellTarget}) if err != nil { return err } diff --git a/cmd/proxyctl/shell_test.go b/cmd/proxyctl/shell_test.go new file mode 100644 index 0000000..d55adfd --- /dev/null +++ b/cmd/proxyctl/shell_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "proxyctl/internal/profile" + "proxyctl/internal/shell" +) + +type commandEnvironment struct { + identity profile.UserIdentity + env map[string]string +} + +func (e commandEnvironment) CurrentUser() (profile.UserIdentity, error) { return e.identity, nil } +func (e commandEnvironment) LookupUser(string) (profile.UserIdentity, error) { + return profile.UserIdentity{}, os.ErrNotExist +} +func (e commandEnvironment) LookupEnv(name string) (string, bool) { + value, ok := e.env[name] + return value, ok +} + +func TestShellInstallAndUninstallCommandsUseInjectedUserRoots(t *testing.T) { + home := t.TempDir() + environment := commandEnvironment{identity: profile.UserIdentity{Username: "tester", HomeDir: home, UID: os.Getuid(), GID: os.Getgid()}, env: map[string]string{"SHELL": "/bin/bash", "XDG_CONFIG_HOME": filepath.Join(home, "config"), "XDG_STATE_HOME": filepath.Join(home, "state")}} + var output bytes.Buffer + if err := runWithEnvironment([]string{"shell", "install"}, strings.NewReader(""), &output, environment, shell.OSFileSystem{}, "/test/proxyctl"); err != nil { + t.Fatal(err) + } + startup := filepath.Join(home, ".bashrc") + data, err := os.ReadFile(startup) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "command '/test/proxyctl'") || !strings.Contains(output.String(), "installed") { + t.Fatalf("install output/startup = %q / %q", output.String(), data) + } + output.Reset() + if err := runWithEnvironment([]string{"shell", "uninstall"}, strings.NewReader(""), &output, environment, shell.OSFileSystem{}, "/test/proxyctl"); err != nil { + t.Fatal(err) + } + data, err = os.ReadFile(startup) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "proxyctl shell integration") || !strings.Contains(output.String(), "uninstalled") { + t.Fatalf("uninstall output/startup = %q / %q", output.String(), data) + } +} + +func TestUnsupportedShellMaintenanceCommandExplainsAvailability(t *testing.T) { + home := t.TempDir() + environment := commandEnvironment{identity: profile.UserIdentity{Username: "tester", HomeDir: home, UID: os.Getuid(), GID: os.Getgid()}, env: map[string]string{"SHELL": "/bin/fish"}} + err := runWithEnvironment([]string{"shell", "install"}, strings.NewReader(""), &bytes.Buffer{}, environment, shell.OSFileSystem{}, "/test/proxyctl") + if err == nil || !strings.Contains(err.Error(), "Bash or Zsh") { + t.Fatalf("unsupported Shell error = %v", err) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index be73baf..006010e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,10 +1,12 @@ package app import ( + "context" "errors" "fmt" "proxyctl/internal/profile" + "proxyctl/internal/target" ) type IntentKind int @@ -14,18 +16,31 @@ const ( UpdateProfile DeleteProfile ActivateProfile + SetTargetDesired ) type Intent struct { - Kind IntentKind - Name string - Profile profile.Profile - Confirm bool + Kind IntentKind + Name string + Profile profile.Profile + Confirm bool + TargetID string + Enabled bool +} + +type TargetSnapshot struct { + ID string + Description target.Description + Available bool + AvailabilityEvidence string + State target.State + Evidence string } type Snapshot struct { Profiles []profile.Profile ActiveProfile string + Targets []TargetSnapshot } type Result struct { @@ -40,24 +55,41 @@ type Module interface { } type Application struct { - store profile.Store - state profile.State + store profile.Store + state profile.State + targets []target.Target + inspections map[string]target.Inspection } func New(store profile.Store) (*Application, error) { + return NewWithTargets(store, nil) +} + +func NewWithTargets(store profile.Store, targets []target.Target) (*Application, error) { state, err := store.Load() if err != nil { return nil, err } - return &Application{store: store, state: state}, nil + a := &Application{store: store, state: state, targets: append([]target.Target(nil), targets...), inspections: make(map[string]target.Inspection)} + a.refreshTargets() + return a, nil } func (a *Application) Snapshot() Snapshot { profiles := append([]profile.Profile(nil), a.state.Profiles...) - return Snapshot{Profiles: profiles, ActiveProfile: a.state.ActiveProfile} + snapshot := Snapshot{Profiles: profiles, ActiveProfile: a.state.ActiveProfile} + for _, adapter := range a.targets { + availability := adapter.Availability(context.Background()) + inspection := a.inspections[adapter.ID()] + snapshot.Targets = append(snapshot.Targets, TargetSnapshot{ID: adapter.ID(), Description: adapter.Description(), Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence}) + } + return snapshot } func (a *Application) Dispatch(intent Intent) Result { + if intent.Kind == SetTargetDesired { + return a.setTargetDesired(intent) + } var notice string next, err := a.store.Mutate(func(current profile.State) (profile.State, error) { var mutationErr error @@ -77,6 +109,7 @@ func (a *Application) Dispatch(intent Intent) Result { }) if err == nil { a.state = next + a.refreshTargets() } else { var committed *profile.CommitError if errors.As(err, &committed) { @@ -94,6 +127,47 @@ func (a *Application) Dispatch(intent Intent) Result { return Result{Snapshot: a.Snapshot(), Notice: notice, Err: err} } +func (a *Application) activeProfile() *profile.Profile { + for i := range a.state.Profiles { + if a.state.Profiles[i].Name == a.state.ActiveProfile { + value := a.state.Profiles[i] + return &value + } + } + return nil +} + +func (a *Application) refreshTargets() { + active := a.activeProfile() + for _, adapter := range a.targets { + a.inspections[adapter.ID()] = adapter.Inspect(context.Background(), active) + } +} + +func (a *Application) setTargetDesired(intent Intent) Result { + var selected target.Target + for _, adapter := range a.targets { + if adapter.ID() == intent.TargetID { + selected = adapter + break + } + } + if selected == nil { + return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Target %q does not exist", intent.TargetID)} + } + active := a.activeProfile() + if intent.Enabled && active == nil { + return Result{Snapshot: a.Snapshot(), Err: errors.New("no Active Profile; open Profile management before enabling a Target")} + } + result := selected.SetDesired(context.Background(), intent.Enabled, active) + a.inspections[selected.ID()] = result.Inspection + notice := "" + if result.Err == nil { + notice = fmt.Sprintf("%s Target is now %s", selected.Description().Name, result.Inspection.State) + } + return Result{Snapshot: a.Snapshot(), Notice: notice, Err: result.Err} +} + func create(state profile.State, input profile.Profile) (profile.State, string, error) { p, err := profile.ValidateAndNormalize(input) if err != nil { diff --git a/internal/app/target_test.go b/internal/app/target_test.go new file mode 100644 index 0000000..d4434e6 --- /dev/null +++ b/internal/app/target_test.go @@ -0,0 +1,94 @@ +package app_test + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "proxyctl/internal/app" + "proxyctl/internal/profile" + "proxyctl/internal/target" +) + +type fakeTarget struct { + available bool + state target.State + setErr error + calls []bool +} + +func (f *fakeTarget) ID() string { return "shell" } +func (f *fakeTarget) Description() target.Description { + return target.Description{Name: "Shell", Summary: "Shell summary", Details: "Shell details"} +} +func (f *fakeTarget) Availability(context.Context) target.Availability { + return target.Availability{Available: f.available, Evidence: "fake detection"} +} +func (f *fakeTarget) Inspect(context.Context, *profile.Profile) target.Inspection { + return target.Inspection{State: f.state, Evidence: "actual state"} +} +func (f *fakeTarget) SetDesired(_ context.Context, enabled bool, _ *profile.Profile) target.OperationResult { + f.calls = append(f.calls, enabled) + if f.setErr == nil { + if enabled { + f.state = target.Enabled + } else { + f.state = target.Disabled + } + } + return target.OperationResult{Inspection: target.Inspection{State: f.state, Evidence: "re-inspected actual state"}, Err: f.setErr} +} + +func TestApplicationTargetSeamRequiresActiveProfileAndReturnsTruthfulResults(t *testing.T) { + adapter := &fakeTarget{available: true, state: target.External} + application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter}) + if err != nil { + t.Fatal(err) + } + initial := application.Snapshot() + if len(initial.Targets) != 1 || initial.Targets[0].State != target.External || initial.Targets[0].Description.Summary != "Shell summary" { + t.Fatalf("initial Target snapshot = %#v", initial.Targets) + } + + result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true}) + if result.Err == nil || !strings.Contains(result.Err.Error(), "Profile management") || len(adapter.calls) != 0 || result.Snapshot.Targets[0].State != target.External { + t.Fatalf("enable without Active Profile = %#v, calls=%v", result, adapter.calls) + } + + createAndActivate(t, application) + result = application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true}) + if result.Err != nil || result.Snapshot.Targets[0].State != target.Enabled || len(adapter.calls) != 1 { + t.Fatalf("enable result = %#v", result) + } + + adapter.setErr = errors.New("permission denied") + adapter.state = target.Drifted + result = application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: false}) + if result.Err == nil || result.Snapshot.Targets[0].State != target.Drifted || result.Snapshot.Targets[0].Evidence != "re-inspected actual state" { + t.Fatalf("failed operation not truthful: %#v", result) + } +} + +func TestApplicationHidesUnavailableTargetButKeepsExplanation(t *testing.T) { + adapter := &fakeTarget{available: false, state: target.Disabled} + application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter}) + if err != nil { + t.Fatal(err) + } + got := application.Snapshot().Targets[0] + if got.Available || got.AvailabilityEvidence != "fake detection" { + t.Fatalf("unavailable Target = %#v", got) + } +} + +func createAndActivate(t *testing.T, application *app.Application) { + t.Helper() + if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: "office", SingleURL: "http://proxy.example:8080"}}); result.Err != nil { + t.Fatal(result.Err) + } + if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"}); result.Err != nil { + t.Fatal(result.Err) + } +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 5af630c..9de627f 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -172,10 +172,16 @@ func normalizeBypassEntry(raw string) (string, error) { var credentialPattern = regexp.MustCompile(`(?i)(https?|socks5h?)://([^/@\s:]+):([^@\s]*)@`) +// RedactText removes URL passwords from diagnostic text without requiring the +// surrounding text to be a valid URL. +func RedactText(value string) string { + return credentialPattern.ReplaceAllString(value, `${1}://${2}:REDACTED@`) +} + func ObscureURL(value string) string { u, err := url.Parse(value) if err != nil || u.User == nil { - return credentialPattern.ReplaceAllString(value, `${1}://${2}:REDACTED@`) + return RedactText(value) } if _, present := u.User.Password(); !present { return value diff --git a/internal/profile/store.go b/internal/profile/store.go index 578624e..e283e26 100644 --- a/internal/profile/store.go +++ b/internal/profile/store.go @@ -112,6 +112,16 @@ func (systemIdentityEnvironment) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) } +// SystemCurrentUser and SystemLookupUser expose the same injectable identity model +// to user-scoped Target configuration without duplicating OS account parsing. +func SystemCurrentUser() (UserIdentity, error) { + return systemIdentityEnvironment{}.CurrentUser() +} + +func SystemLookupUser(name string) (UserIdentity, error) { + return systemIdentityEnvironment{}.LookupUser(name) +} + func ConfigPath() (string, error) { configuration, err := ResolveConfigurationForCurrentProcess() return configuration.Path, err diff --git a/internal/shell/configuration_test.go b/internal/shell/configuration_test.go new file mode 100644 index 0000000..7acd9df --- /dev/null +++ b/internal/shell/configuration_test.go @@ -0,0 +1,51 @@ +package shell_test + +import ( + "path/filepath" + "testing" + + "proxyctl/internal/profile" + "proxyctl/internal/shell" +) + +type identityEnvironment struct { + current profile.UserIdentity + users map[string]profile.UserIdentity + env map[string]string +} + +func (e identityEnvironment) CurrentUser() (profile.UserIdentity, error) { return e.current, nil } +func (e identityEnvironment) LookupUser(name string) (profile.UserIdentity, error) { + return e.users[name], nil +} +func (e identityEnvironment) LookupEnv(name string) (string, bool) { + value, ok := e.env[name] + return value, ok +} + +func TestShellConfigurationUsesSudoInvokingUserAndRejectsRootXDGOverrides(t *testing.T) { + owner := profile.UserIdentity{Username: "alice", HomeDir: "/home/alice", UID: 1000, GID: 1000} + got, err := shell.ResolveConfiguration(identityEnvironment{ + current: profile.UserIdentity{Username: "root", HomeDir: "/root", UID: 0, GID: 0}, users: map[string]profile.UserIdentity{"alice": owner}, + env: map[string]string{"SUDO_USER": "alice", "SHELL": "/bin/zsh", "XDG_CONFIG_HOME": "/root/config", "XDG_STATE_HOME": "/root/state"}, + }) + if err != nil { + t.Fatal(err) + } + if got.Shell != "/bin/zsh" || got.StartupFile != "/home/alice/.zshrc" || got.EnvironmentFile != "/home/alice/.config/proxyctl/shell.env" || got.RecoveryFile != "/home/alice/.local/state/proxyctl/shell.json" || got.UID != 1000 || !got.OwnFiles { + t.Fatalf("sudo invoking-user configuration = %#v", got) + } +} + +func TestShellConfigurationUsesDirectRootIdentityAndAbsoluteXDG(t *testing.T) { + got, err := shell.ResolveConfiguration(identityEnvironment{ + current: profile.UserIdentity{Username: "root", HomeDir: "/root", UID: 0, GID: 0}, + env: map[string]string{"SHELL": "/bin/bash", "XDG_CONFIG_HOME": "/var/root-config", "XDG_STATE_HOME": "/var/root-state"}, + }) + if err != nil { + t.Fatal(err) + } + if got.StartupFile != "/root/.bashrc" || got.EnvironmentFile != filepath.Join("/var/root-config", "proxyctl", "shell.env") || got.RecoveryFile != filepath.Join("/var/root-state", "proxyctl", "shell.json") || got.UID != 0 { + t.Fatalf("direct-root configuration = %#v", got) + } +} diff --git a/internal/shell/shell.go b/internal/shell/shell.go new file mode 100644 index 0000000..787ebcc --- /dev/null +++ b/internal/shell/shell.go @@ -0,0 +1,523 @@ +// Package shell implements the Bash and Zsh Shell Target. +package shell + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/url" + "os" + "path/filepath" + "strings" + + "proxyctl/internal/profile" + "proxyctl/internal/target" +) + +const ( + integrationStart = "# >>> proxyctl shell integration >>>" + integrationEnd = "# <<< proxyctl shell integration <<<" +) + +var proxyVariables = []string{ + "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", + "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy", +} + +type Configuration struct { + Shell string + StartupFile string + EnvironmentFile string + RecoveryFile string + OwnFiles bool + UID int + GID int +} + +// ResolveConfiguration keeps user Shell effects with the invoking user under sudo. +func ResolveConfiguration(environment profile.IdentityEnvironment) (Configuration, error) { + profileConfiguration, err := profile.ResolveConfiguration(environment) + if err != nil { + return Configuration{}, err + } + owner := profileConfiguration.Owner + current, err := environment.CurrentUser() + if err != nil { + return Configuration{}, fmt.Errorf("identify effective user: %w", err) + } + invokingUser := current.Username != owner.Username + configDirectory := filepath.Dir(profileConfiguration.Path) + stateDirectory := "" + if value, ok := environment.LookupEnv("XDG_STATE_HOME"); ok && filepath.IsAbs(value) { + if !invokingUser || pathWithin(value, owner.HomeDir) { + stateDirectory = value + } + } + if stateDirectory == "" { + stateDirectory = filepath.Join(owner.HomeDir, ".local", "state") + } + shellPath, _ := environment.LookupEnv("SHELL") + startupFile := "" + switch filepath.Base(shellPath) { + case "bash": + startupFile = filepath.Join(owner.HomeDir, ".bashrc") + case "zsh": + startupFile = filepath.Join(owner.HomeDir, ".zshrc") + } + return Configuration{ + Shell: shellPath, StartupFile: startupFile, + EnvironmentFile: filepath.Join(configDirectory, "shell.env"), + RecoveryFile: filepath.Join(stateDirectory, "proxyctl", "shell.json"), + OwnFiles: true, UID: owner.UID, GID: owner.GID, + }, nil +} + +func ResolveConfigurationForCurrentProcess() (Configuration, error) { + return ResolveConfiguration(systemEnvironment{}) +} + +type systemEnvironment struct{} + +func (systemEnvironment) CurrentUser() (profile.UserIdentity, error) { + return profile.SystemCurrentUser() +} +func (systemEnvironment) LookupUser(name string) (profile.UserIdentity, error) { + return profile.SystemLookupUser(name) +} +func (systemEnvironment) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) } + +func pathWithin(path, parent string) bool { + relative, err := filepath.Rel(parent, path) + return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} + +type Environment interface{ LookupEnv(string) (string, bool) } +type MapEnvironment map[string]string + +func (m MapEnvironment) LookupEnv(name string) (string, bool) { value, ok := m[name]; return value, ok } + +type Ownership struct{ UID, GID int } + +type FileSystem interface { + ReadFile(string) ([]byte, error) + FileMode(string) (os.FileMode, error) + AtomicWrite(string, []byte, os.FileMode, *Ownership) error + Remove(string) error +} + +type OSFileSystem struct{} + +func (OSFileSystem) ReadFile(path string) ([]byte, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("%s is not a regular file", path) + } + return os.ReadFile(path) +} +func (OSFileSystem) Remove(path string) error { return os.Remove(path) } +func (OSFileSystem) FileMode(path string) (os.FileMode, error) { + info, err := os.Lstat(path) + if err != nil { + return 0, err + } + if !info.Mode().IsRegular() { + return 0, fmt.Errorf("%s is not a regular file", path) + } + return info.Mode().Perm(), nil +} +func (OSFileSystem) AtomicWrite(path string, data []byte, mode os.FileMode, owner *Ownership) error { + if !filepath.IsAbs(path) { + return errors.New("Shell Target path must be absolute") + } + directory := filepath.Dir(path) + var missing []string + for current := directory; ; current = filepath.Dir(current) { + if _, err := os.Stat(current); err == nil { + break + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + missing = append(missing, current) + if filepath.Dir(current) == current { + break + } + } + if err := os.MkdirAll(directory, 0o700); err != nil { + return err + } + for i := len(missing) - 1; i >= 0; i-- { + if owner != nil && (owner.UID != os.Geteuid() || owner.GID != os.Getegid()) { + if err := os.Chown(missing[i], owner.UID, owner.GID); err != nil { + return err + } + } + if err := os.Chmod(missing[i], 0o700); err != nil { + return err + } + } + var temporary string + for range 100 { + bytes := make([]byte, 8) + if _, err := rand.Read(bytes); err != nil { + return err + } + temporary = filepath.Join(directory, ".proxyctl-"+hex.EncodeToString(bytes)) + file, err := os.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if errors.Is(err, fs.ErrExist) { + continue + } + if err != nil { + return err + } + ok := false + defer func() { + if !ok { + _ = os.Remove(temporary) + } + }() + if owner != nil && (owner.UID != os.Geteuid() || owner.GID != os.Getegid()) { + if err = file.Chown(owner.UID, owner.GID); err != nil { + _ = file.Close() + return err + } + } + if _, err = file.Write(data); err == nil { + err = file.Sync() + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + if err = os.Rename(temporary, path); err != nil { + return err + } + ok = true + dir, err := os.Open(directory) + if err != nil { + return err + } + err = dir.Sync() + closeErr := dir.Close() + if err != nil { + return err + } + return closeErr + } + return errors.New("allocate temporary Shell Target file") +} + +type recovery struct { + Original map[string]string `json:"original,omitempty"` + OriginalScript *string `json:"originalScript,omitempty"` + Fingerprint string `json:"fingerprint"` +} + +type Adapter struct { + configuration Configuration + files FileSystem + environment Environment +} + +func New(configuration Configuration, files FileSystem, environment Environment) *Adapter { + if files == nil { + files = OSFileSystem{} + } + if environment == nil { + environment = MapEnvironment{} + } + return &Adapter{configuration: configuration, files: files, environment: environment} +} + +func (a *Adapter) ID() string { return "shell" } +func (a *Adapter) Description() target.Description { + return target.Description{Name: "Shell", Summary: "Exports proxy variables for Bash and Zsh commands.", Details: "Affects commands started by the integrated Shell; it does not configure system services. Installation updates the user's Shell startup file and requires no privilege escalation."} +} +func (a *Adapter) Availability(context.Context) target.Availability { + switch filepath.Base(a.configuration.Shell) { + case "bash": + return target.Availability{Available: true, Evidence: "Bash detected"} + case "zsh": + return target.Availability{Available: true, Evidence: "Zsh detected"} + default: + return target.Availability{Evidence: "Shell Target requires Bash or Zsh; neither was detected"} + } +} +func (a *Adapter) EnvironmentPath() string { return a.configuration.EnvironmentFile } +func (a *Adapter) RecoveryPath() string { return a.configuration.RecoveryFile } +func (a *Adapter) StartupPath() string { return a.configuration.StartupFile } + +func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection { + metadata, managed, metadataErr := a.readRecovery() + generated, generatedErr := a.files.ReadFile(a.configuration.EnvironmentFile) + if managed || (metadataErr != nil && !errors.Is(metadataErr, fs.ErrNotExist)) { + if metadataErr != nil { + return target.Inspection{State: target.Drifted, Evidence: "managed Shell recovery metadata cannot be read"} + } + if generatedErr != nil { + return target.Inspection{State: target.Drifted, Evidence: "managed Shell environment is missing or unreadable"} + } + if active != nil && fingerprint(generated) == metadata.Fingerprint && fingerprint(renderEnabled(*active)) == metadata.Fingerprint { + return target.Inspection{State: target.Enabled, Evidence: "managed Shell environment matches the Active Profile"} + } + return target.Inspection{State: target.Drifted, Evidence: "managed Shell environment does not match the Active Profile"} + } + if generatedErr == nil && recognizesProxy(generated) { + return target.Inspection{State: target.External, Evidence: "recognized proxy assignments exist in the Shell environment file"} + } + for _, name := range proxyVariables { + if value, ok := a.environment.LookupEnv(name); ok && value != "" { + return target.Inspection{State: target.External, Evidence: "recognized proxy variable " + name + " exists"} + } + } + return target.Inspection{State: target.Disabled, Evidence: "no recognized Shell proxy configuration detected"} +} + +func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.Profile) target.OperationResult { + if !a.Availability(ctx).Available { + return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("Shell Target is unavailable: Bash or Zsh was not detected")} + } + if enabled && active == nil { + return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("no Active Profile; open Profile management to create and activate one")} + } + var err error + if enabled { + err = a.enable(*active) + } else { + err = a.disable() + } + inspection := a.Inspect(ctx, active) + if err != nil { + err = redactError(err, active) + } + return target.OperationResult{Inspection: inspection, Err: err} +} + +func (a *Adapter) enable(active profile.Profile) error { + normalized, err := profile.ValidateAndNormalize(active) + if err != nil { + return err + } + metadata, managed, err := a.readRecovery() + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("read Shell recovery metadata: %w", err) + } + if !managed { + if existing, readErr := a.files.ReadFile(a.configuration.EnvironmentFile); readErr == nil && recognizesProxy(existing) { + original := string(existing) + metadata.OriginalScript = &original + } else { + metadata.Original = make(map[string]string) + for _, name := range proxyVariables { + if value, ok := a.environment.LookupEnv(name); ok { + metadata.Original[name] = value + } + } + } + } + generated := renderEnabled(normalized) + metadata.Fingerprint = fingerprint(generated) + if err := a.writeOwned(a.configuration.EnvironmentFile, generated); err != nil { + return fmt.Errorf("write Shell environment: %w", err) + } + data, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return err + } + if err := a.writeOwned(a.configuration.RecoveryFile, append(data, '\n')); err != nil { + return fmt.Errorf("write Shell recovery metadata after environment changed: %w", err) + } + return nil +} + +func (a *Adapter) disable() error { + metadata, managed, err := a.readRecovery() + if errors.Is(err, fs.ErrNotExist) || !managed { + return nil + } + if err != nil { + return fmt.Errorf("read Shell recovery metadata: %w", err) + } + restored := renderRestored(metadata.Original) + if metadata.OriginalScript != nil { + restored = []byte(*metadata.OriginalScript) + } + if err := a.writeOwned(a.configuration.EnvironmentFile, restored); err != nil { + return fmt.Errorf("restore Shell environment: %w", err) + } + if err := a.files.Remove(a.configuration.RecoveryFile); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove Shell recovery metadata after environment changed: %w", err) + } + return nil +} + +func (a *Adapter) readRecovery() (recovery, bool, error) { + data, err := a.files.ReadFile(a.configuration.RecoveryFile) + if err != nil { + return recovery{}, false, err + } + var value recovery + if err := json.Unmarshal(data, &value); err != nil { + return recovery{}, true, err + } + return value, true, nil +} +func (a *Adapter) ownership() *Ownership { + if !a.configuration.OwnFiles { + return nil + } + return &Ownership{UID: a.configuration.UID, GID: a.configuration.GID} +} +func (a *Adapter) writeOwned(path string, data []byte) error { + return a.files.AtomicWrite(path, data, 0o600, a.ownership()) +} + +func renderEnabled(p profile.Profile) []byte { + values := map[string]string{"HTTP_PROXY": p.HTTPURL, "http_proxy": p.HTTPURL, "HTTPS_PROXY": p.HTTPSURL, "https_proxy": p.HTTPSURL, "ALL_PROXY": p.AllURL, "all_proxy": p.AllURL, "NO_PROXY": strings.Join(p.Bypass, ","), "no_proxy": strings.Join(p.Bypass, ",")} + var output strings.Builder + output.WriteString("# Generated by proxyctl. Do not edit.\n") + for _, name := range proxyVariables { + if value := values[name]; value != "" { + fmt.Fprintf(&output, "export %s=%s\n", name, shellQuote(value)) + } else { + fmt.Fprintf(&output, "unset %s\n", name) + } + } + return []byte(output.String()) +} +func renderRestored(original map[string]string) []byte { + var output strings.Builder + output.WriteString("# Generated by proxyctl. Restores the environment present before enable.\n") + var unset []string + for _, name := range proxyVariables { + if value, ok := original[name]; ok { + fmt.Fprintf(&output, "export %s=%s\n", name, shellQuote(value)) + } else { + unset = append(unset, name) + } + } + if len(unset) > 0 { + fmt.Fprintf(&output, "unset %s\n", strings.Join(unset, " ")) + } + return []byte(output.String()) +} +func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } +func fingerprint(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) } +func recognizesProxy(data []byte) bool { + text := string(data) + for _, name := range proxyVariables { + if strings.Contains(text, "export "+name+"=") || strings.Contains(text, name+"=") { + return true + } + } + return false +} + +type redactedError struct { + message string + cause error +} + +func (e *redactedError) Error() string { return e.message } +func (e *redactedError) Unwrap() error { return e.cause } + +func redactError(err error, active *profile.Profile) error { + if err == nil { + return nil + } + message := profile.RedactText(err.Error()) + if active == nil { + return &redactedError{message: message, cause: err} + } + for _, raw := range []string{active.SingleURL, active.HTTPURL, active.HTTPSURL, active.AllURL} { + if raw == "" { + continue + } + message = strings.ReplaceAll(message, raw, profile.ObscureURL(raw)) + if parsed, parseErr := url.Parse(raw); parseErr == nil && parsed.User != nil { + if password, ok := parsed.User.Password(); ok && password != "" { + message = strings.ReplaceAll(message, password, "REDACTED") + } + } + } + return &redactedError{message: message, cause: err} +} + +func (a *Adapter) Install(_ context.Context, executable string) error { + contents, err := a.files.ReadFile(a.configuration.StartupFile) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("read Shell startup file: %w", err) + } + cleaned := removeIntegration(string(contents)) + if strings.Contains(cleaned, integrationStart) || strings.Contains(cleaned, integrationEnd) { + return errors.New("Shell startup file contains malformed proxyctl integration markers; no changes made") + } + if cleaned != "" && !strings.HasSuffix(cleaned, "\n") { + cleaned += "\n" + } + block := integrationStart + "\n" + + "proxyctl() {\n" + + " command " + shellQuote(executable) + " \"$@\"\n" + + " proxyctl_status=$?\n" + + " [ -r " + shellQuote(a.configuration.EnvironmentFile) + " ] && . " + shellQuote(a.configuration.EnvironmentFile) + "\n" + + " return $proxyctl_status\n" + + "}\n" + integrationEnd + "\n" + mode := os.FileMode(0o644) + if existingMode, modeErr := a.files.FileMode(a.configuration.StartupFile); modeErr == nil { + mode = existingMode + } + if err := a.files.AtomicWrite(a.configuration.StartupFile, []byte(cleaned+block), mode, a.ownership()); err != nil { + return fmt.Errorf("install Shell integration: %w", err) + } + return nil +} +func (a *Adapter) Uninstall(_ context.Context) error { + contents, err := a.files.ReadFile(a.configuration.StartupFile) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("read Shell startup file: %w", err) + } + cleaned := removeIntegration(string(contents)) + if strings.Contains(cleaned, integrationStart) || strings.Contains(cleaned, integrationEnd) { + return errors.New("Shell startup file contains malformed proxyctl integration markers; no changes made") + } + if cleaned == string(contents) { + return nil + } + mode := os.FileMode(0o644) + if existingMode, modeErr := a.files.FileMode(a.configuration.StartupFile); modeErr == nil { + mode = existingMode + } + if err := a.files.AtomicWrite(a.configuration.StartupFile, []byte(cleaned), mode, a.ownership()); err != nil { + return fmt.Errorf("uninstall Shell integration: %w", err) + } + return nil +} +func removeIntegration(contents string) string { + for { + start := strings.Index(contents, integrationStart) + if start < 0 { + return contents + } + endRelative := strings.Index(contents[start:], integrationEnd) + if endRelative < 0 { + return contents + } + end := start + endRelative + len(integrationEnd) + if end < len(contents) && contents[end] == '\n' { + end++ + } + contents = contents[:start] + contents[end:] + } +} diff --git a/internal/shell/shell_test.go b/internal/shell/shell_test.go new file mode 100644 index 0000000..0b6354d --- /dev/null +++ b/internal/shell/shell_test.go @@ -0,0 +1,361 @@ +package shell_test + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "proxyctl/internal/profile" + "proxyctl/internal/shell" + "proxyctl/internal/target" + "proxyctl/internal/target/targettest" +) + +func TestShellAvailabilitySupportsOnlyBashAndZsh(t *testing.T) { + for _, tc := range []struct { + shell string + available bool + }{{"/bin/bash", true}, {"/usr/bin/zsh", true}, {"/usr/bin/fish", false}, {"", false}} { + t.Run(tc.shell, func(t *testing.T) { + a := newAdapter(t, tc.shell, nil) + got := a.Availability(context.Background()) + if got.Available != tc.available || (!tc.available && !strings.Contains(got.Evidence, "Bash or Zsh")) { + t.Fatalf("availability = %#v", got) + } + }) + } +} + +func TestShellAdapterRunsReusableTargetContract(t *testing.T) { + for _, shellName := range []string{"/bin/bash", "/usr/bin/zsh"} { + shellName := shellName + t.Run(shellName, func(t *testing.T) { + targettest.Run(t, func(t *testing.T) targettest.Fixture { + adapter := newAdapter(t, shellName, nil) + return targettest.Fixture{ + Target: adapter, Active: activeProfile(), Secret: "secret", + MakeExternal: func(t *testing.T) { + if err := os.MkdirAll(filepath.Dir(adapter.EnvironmentPath()), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(adapter.EnvironmentPath(), []byte("export HTTP_PROXY='http://external.example:8080'\n"), 0o600); err != nil { + t.Fatal(err) + } + }, + MakeDrifted: func(t *testing.T) { + if err := os.WriteFile(adapter.EnvironmentPath(), []byte("export HTTP_PROXY='http://changed.example:8080'\n"), 0o600); err != nil { + t.Fatal(err) + } + }, + PermissionFailure: func(t *testing.T) target.OperationResult { + failed := newAdapterWithFS(t, shellName, nil, &failingFS{FileSystem: shell.OSFileSystem{}, permission: true}) + return failed.SetDesired(context.Background(), true, activeProfile()) + }, + PartialFailure: func(t *testing.T) target.OperationResult { + failed := newAdapterWithFS(t, shellName, nil, &failingFS{FileSystem: shell.OSFileSystem{}, failState: true}) + return failed.SetDesired(context.Background(), true, activeProfile()) + }, + RedactionFailure: func(t *testing.T) target.OperationResult { + failed := newAdapterWithFS(t, shellName, nil, &failingFS{FileSystem: shell.OSFileSystem{}, failWrite: true}) + return failed.SetDesired(context.Background(), true, activeProfile()) + }, + } + }) + }) + } +} + +func TestShellTargetContract(t *testing.T) { + contract := func(t *testing.T, shellName string) { + t.Helper() + t.Run(shellName, func(t *testing.T) { + t.Run("disabled-enable-idempotent-disable", func(t *testing.T) { + a := newAdapter(t, shellName, map[string]string{"HTTP_PROXY": "http://prior.example:3128"}) + assertState(t, a, activeProfile(), target.External) + result := a.SetDesired(context.Background(), true, activeProfile()) + if result.Err != nil || result.Inspection.State != target.Enabled { + t.Fatalf("enable = %#v", result) + } + env := readFile(t, a.EnvironmentPath()) + for _, name := range []string{"HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"} { + if !strings.Contains(env, "export "+name+"=") { + t.Errorf("generated environment missing %s:\n%s", name, env) + } + } + if second := a.SetDesired(context.Background(), true, activeProfile()); second.Err != nil || second.Inspection.State != target.Enabled { + t.Fatalf("second enable = %#v", second) + } + result = a.SetDesired(context.Background(), false, nil) + if result.Err != nil || result.Inspection.State != target.External { + t.Fatalf("disable = %#v", result) + } + restored := readFile(t, a.EnvironmentPath()) + if !strings.Contains(restored, "export HTTP_PROXY='http://prior.example:3128'") || !strings.Contains(restored, "unset http_proxy HTTPS_PROXY https_proxy ALL_PROXY all_proxy NO_PROXY no_proxy") { + t.Fatalf("prior state not restored with required unsets:\n%s", restored) + } + if second := a.SetDesired(context.Background(), false, nil); second.Err != nil || second.Inspection.State != target.External { + t.Fatalf("idempotent disable = %#v", second) + } + }) + + t.Run("all-four-inspection-states", func(t *testing.T) { + a := newAdapter(t, shellName, nil) + assertState(t, a, activeProfile(), target.Disabled) + if err := os.MkdirAll(filepath.Dir(a.EnvironmentPath()), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(a.EnvironmentPath(), []byte("export HTTP_PROXY='http://external.example:8080'\n"), 0o600); err != nil { + t.Fatal(err) + } + assertState(t, a, activeProfile(), target.External) + if result := a.SetDesired(context.Background(), true, activeProfile()); result.Err != nil { + t.Fatal(result.Err) + } + assertState(t, a, activeProfile(), target.Enabled) + if err := os.WriteFile(a.EnvironmentPath(), []byte("export HTTP_PROXY='http://changed.example:8080'\n"), 0o600); err != nil { + t.Fatal(err) + } + assertState(t, a, activeProfile(), target.Drifted) + }) + + t.Run("validation-and-redaction", func(t *testing.T) { + fs := &failingFS{FileSystem: shell.OSFileSystem{}, failWrite: true} + a := newAdapterWithFS(t, shellName, nil, fs) + if got := a.SetDesired(context.Background(), true, nil); got.Err == nil || !strings.Contains(got.Err.Error(), "Profile management") { + t.Fatalf("no Active Profile result = %#v", got) + } + got := a.SetDesired(context.Background(), true, activeProfile()) + if got.Err == nil || strings.Contains(got.Err.Error(), "secret") || got.Inspection.State != target.Disabled { + t.Fatalf("failure was not truthful/redacted: %#v", got) + } + + restoreFS := &failingFS{FileSystem: shell.OSFileSystem{}} + restore := newAdapterWithFS(t, shellName, map[string]string{"HTTP_PROXY": "http://alice:restore-secret@prior.example:8080"}, restoreFS) + if enabled := restore.SetDesired(context.Background(), true, activeProfile()); enabled.Err != nil { + t.Fatal(enabled.Err) + } + restoreFS.failRestore = true + disabled := restore.SetDesired(context.Background(), false, nil) + if disabled.Err == nil || strings.Contains(disabled.Err.Error(), "restore-secret") { + t.Fatalf("disable error exposed restored secret: %#v", disabled) + } + }) + + t.Run("permission-and-partial-failure-reinspect", func(t *testing.T) { + fs := &failingFS{FileSystem: shell.OSFileSystem{}, failState: true} + a := newAdapterWithFS(t, shellName, nil, fs) + got := a.SetDesired(context.Background(), true, activeProfile()) + if got.Err == nil || !errors.Is(got.Err, os.ErrPermission) { + t.Fatalf("permission error = %#v", got) + } + if got.Inspection.State != target.External { + t.Fatalf("partial write state = %s, want External", got.Inspection.State) + } + }) + }) + } + contract(t, "/bin/bash") + contract(t, "/usr/bin/zsh") +} + +func TestIntegrationInstallUninstallPreservesStartupFileAndLoadsAfterCommand(t *testing.T) { + a := newAdapter(t, "/bin/bash", nil) + startup := a.StartupPath() + if err := os.MkdirAll(filepath.Dir(startup), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(startup, []byte("# user alias\nalias ll='ls -l'\n"), 0o640); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Dir(startup), 0o751); err != nil { + t.Fatal(err) + } + if err := a.Install(context.Background(), "/opt/bin/proxyctl"); err != nil { + t.Fatal(err) + } + if err := a.Install(context.Background(), "/opt/bin/proxyctl"); err != nil { + t.Fatal(err) + } + installed := readFile(t, startup) + if strings.Count(installed, "# >>> proxyctl shell integration >>>") != 1 || !strings.Contains(installed, "command '/opt/bin/proxyctl'") || !strings.Contains(installed, a.EnvironmentPath()) || !strings.Contains(installed, "alias ll=") { + t.Fatalf("installed startup file:\n%s", installed) + } + startupInfo, _ := os.Stat(startup) + homeInfo, _ := os.Stat(filepath.Dir(startup)) + if startupInfo.Mode().Perm() != 0o640 || homeInfo.Mode().Perm() != 0o751 { + t.Fatalf("install changed unrelated permissions: file=%o directory=%o", startupInfo.Mode().Perm(), homeInfo.Mode().Perm()) + } + if err := a.Uninstall(context.Background()); err != nil { + t.Fatal(err) + } + if err := a.Uninstall(context.Background()); err != nil { + t.Fatal(err) + } + if got := readFile(t, startup); got != "# user alias\nalias ll='ls -l'\n" { + t.Fatalf("uninstall changed unrelated content: %q", got) + } +} + +func TestIntegrationRefusesStartupSymlinkWithoutChangingVictim(t *testing.T) { + a := newAdapter(t, "/bin/bash", nil) + if err := os.MkdirAll(filepath.Dir(a.StartupPath()), 0o700); err != nil { + t.Fatal(err) + } + victim := filepath.Join(t.TempDir(), "victim") + if err := os.WriteFile(victim, []byte("unchanged\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(victim, a.StartupPath()); err != nil { + t.Fatal(err) + } + if err := a.Install(context.Background(), "/bin/proxyctl"); err == nil { + t.Fatal("install followed startup symlink") + } + if got := readFile(t, victim); got != "unchanged\n" { + t.Fatalf("symlink victim changed to %q", got) + } +} + +func TestMalformedIntegrationMarkersNeverCauseStartupContentDeletion(t *testing.T) { + a := newAdapter(t, "/bin/bash", nil) + if err := os.MkdirAll(filepath.Dir(a.StartupPath()), 0o700); err != nil { + t.Fatal(err) + } + original := "# >>> proxyctl shell integration >>>\n# user content without closing marker\n" + if err := os.WriteFile(a.StartupPath(), []byte(original), 0o644); err != nil { + t.Fatal(err) + } + if err := a.Install(context.Background(), "/bin/proxyctl"); err == nil { + t.Fatal("install accepted malformed integration markers") + } + if got := readFile(t, a.StartupPath()); got != original { + t.Fatalf("malformed startup changed to %q", got) + } +} + +func TestInstalledBashWrapperReloadsParentEnvironmentAfterProxyctlExits(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash is not installed") + } + a := newAdapter(t, "/bin/bash", nil) + executable := filepath.Join(t.TempDir(), "fake-proxyctl") + if err := os.MkdirAll(filepath.Dir(a.EnvironmentPath()), 0o700); err != nil { + t.Fatal(err) + } + script := "#!/bin/sh\nprintf '%s\\n' \"export HTTP_PROXY='http://reloaded.example:8080'\" > " + shellPathQuote(a.EnvironmentPath()) + "\nexit 7\n" + if err := os.WriteFile(executable, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + if err := a.Install(context.Background(), executable); err != nil { + t.Fatal(err) + } + command := exec.Command("bash", "--noprofile", "--rcfile", a.StartupPath(), "-i", "-c", `proxyctl >/dev/null || test $? -eq 7; printf %s "$HTTP_PROXY"`) + output, err := command.Output() + if err != nil { + t.Fatalf("run integrated Bash: %v", err) + } + if got := string(output); got != "http://reloaded.example:8080" { + t.Fatalf("parent Shell proxy = %q", got) + } +} + +func shellPathQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } + +func TestOwnedWritesCarryInvokingUserIdentityIntoAtomicReplacement(t *testing.T) { + root := t.TempDir() + files := &ownershipFS{FileSystem: shell.OSFileSystem{}} + a := shell.New(shell.Configuration{Shell: "/bin/bash", StartupFile: filepath.Join(root, ".bashrc"), EnvironmentFile: filepath.Join(root, "config", "shell.env"), RecoveryFile: filepath.Join(root, "state", "shell.json"), OwnFiles: true, UID: 1234, GID: 1235}, files, shell.MapEnvironment{}) + if result := a.SetDesired(context.Background(), true, activeProfile()); result.Err != nil { + t.Fatal(result.Err) + } + if len(files.owners) != 2 { + t.Fatalf("owned atomic writes = %#v", files.owners) + } + for _, owner := range files.owners { + if owner == nil || owner.UID != 1234 || owner.GID != 1235 { + t.Fatalf("atomic owner = %#v", owner) + } + } +} + +type ownershipFS struct { + shell.FileSystem + owners []*shell.Ownership +} + +func (f *ownershipFS) AtomicWrite(path string, data []byte, mode os.FileMode, owner *shell.Ownership) error { + f.owners = append(f.owners, owner) + return f.FileSystem.AtomicWrite(path, data, mode, nil) +} + +func TestRecoveryAndGeneratedFilesAreAtomicOwnerOnly(t *testing.T) { + a := newAdapter(t, "/usr/bin/zsh", nil) + if result := a.SetDesired(context.Background(), true, activeProfile()); result.Err != nil { + t.Fatal(result.Err) + } + for _, path := range []string{a.EnvironmentPath(), a.RecoveryPath()} { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("%s mode = %o", path, info.Mode().Perm()) + } + } + matches, _ := filepath.Glob(filepath.Join(filepath.Dir(a.RecoveryPath()), ".proxyctl-*")) + if len(matches) != 0 { + t.Fatalf("atomic temporary files remain: %v", matches) + } +} + +func activeProfile() *profile.Profile { + return &profile.Profile{Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://alice:secret@proxy.example:8080", HTTPSURL: "https://proxy.example:8443", AllURL: "socks5h://proxy.example:1080", Bypass: []string{"localhost", ".example.com", "10.0.0.0/8"}} +} + +func newAdapter(t *testing.T, shellName string, env map[string]string) *shell.Adapter { + return newAdapterWithFS(t, shellName, env, shell.OSFileSystem{}) +} +func newAdapterWithFS(t *testing.T, shellName string, env map[string]string, fs shell.FileSystem) *shell.Adapter { + t.Helper() + root := t.TempDir() + return shell.New(shell.Configuration{Shell: shellName, StartupFile: filepath.Join(root, "home", ".bashrc"), EnvironmentFile: filepath.Join(root, "config", "shell.env"), RecoveryFile: filepath.Join(root, "state", "shell.json")}, fs, shell.MapEnvironment(env)) +} +func assertState(t *testing.T, a *shell.Adapter, p *profile.Profile, want target.State) { + t.Helper() + if got := a.Inspect(context.Background(), p); got.State != want { + t.Fatalf("state = %s (%s), want %s", got.State, got.Evidence, want) + } +} +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +type failingFS struct { + shell.FileSystem + failWrite, failState, failRestore, permission bool +} + +func (f *failingFS) AtomicWrite(path string, data []byte, mode os.FileMode, owner *shell.Ownership) error { + if f.permission { + return os.ErrPermission + } + if f.failWrite { + return errors.New("write http://alice:secret@proxy.example:8080 failed") + } + if f.failRestore && strings.HasSuffix(path, "shell.env") { + return errors.New("restore http://alice:restore-secret@prior.example:8080 failed") + } + if f.failState && strings.HasSuffix(path, "shell.json") { + return os.ErrPermission + } + return f.FileSystem.AtomicWrite(path, data, mode, owner) +} diff --git a/internal/target/target.go b/internal/target/target.go new file mode 100644 index 0000000..ef717fe --- /dev/null +++ b/internal/target/target.go @@ -0,0 +1,48 @@ +// Package target defines the application-facing seam implemented by configuration Targets. +package target + +import ( + "context" + + "proxyctl/internal/profile" +) + +// State is the detected state of a Target's actual configuration. +type State string + +const ( + Enabled State = "Enabled" + Disabled State = "Disabled" + External State = "External" + Drifted State = "Drifted" +) + +type Description struct { + Name string + Summary string + Details string +} + +type Availability struct { + Available bool + Evidence string +} + +type Inspection struct { + State State + Evidence string +} + +type OperationResult struct { + Inspection Inspection + Err error +} + +// Target hides platform paths and commands from the application and TUI. +type Target interface { + ID() string + Description() Description + Availability(context.Context) Availability + Inspect(context.Context, *profile.Profile) Inspection + SetDesired(context.Context, bool, *profile.Profile) OperationResult +} diff --git a/internal/target/target_test.go b/internal/target/target_test.go new file mode 100644 index 0000000..9fb8fe2 --- /dev/null +++ b/internal/target/target_test.go @@ -0,0 +1,32 @@ +package target_test + +import ( + "context" + "testing" + + "proxyctl/internal/profile" + "proxyctl/internal/target" +) + +type seamTarget struct{} + +func (seamTarget) ID() string { return "seam" } +func (seamTarget) Description() target.Description { + return target.Description{Name: "Seam", Summary: "summary", Details: "details"} +} +func (seamTarget) Availability(context.Context) target.Availability { + return target.Availability{Available: true, Evidence: "detected"} +} +func (seamTarget) Inspect(context.Context, *profile.Profile) target.Inspection { + return target.Inspection{State: target.Disabled, Evidence: "absent"} +} +func (seamTarget) SetDesired(context.Context, bool, *profile.Profile) target.OperationResult { + return target.OperationResult{Inspection: target.Inspection{State: target.Enabled}} +} + +func TestSharedTargetInterfaceDoesNotExposePlatformDetails(t *testing.T) { + var adapter target.Target = seamTarget{} + if adapter.ID() != "seam" || !adapter.Availability(context.Background()).Available { + t.Fatal("Target seam did not expose expected domain behavior") + } +} diff --git a/internal/target/targettest/contract.go b/internal/target/targettest/contract.go new file mode 100644 index 0000000..0ed783b --- /dev/null +++ b/internal/target/targettest/contract.go @@ -0,0 +1,95 @@ +// Package targettest provides the common behavioral contract for real Target adapters. +package targettest + +import ( + "context" + "errors" + "os" + "strings" + "testing" + + "proxyctl/internal/profile" + "proxyctl/internal/target" +) + +// Fixture supplies adapter-specific host setup while assertions stay at the Target seam. +type Fixture struct { + Target target.Target + Active *profile.Profile + MakeExternal func(*testing.T) + MakeDrifted func(*testing.T) + PermissionFailure func(*testing.T) target.OperationResult + PartialFailure func(*testing.T) target.OperationResult + RedactionFailure func(*testing.T) target.OperationResult + Secret string +} + +type Factory func(*testing.T) Fixture + +// Run verifies the state and desired-state behavior shared by every adapter. +// Adapter suites add injected permission and partial-effect failures because their +// failure injection mechanisms necessarily differ. +func Run(t *testing.T, factory Factory) { + t.Helper() + t.Run("common Target contract", func(t *testing.T) { + fixture := factory(t) + ctx := context.Background() + if !fixture.Target.Availability(ctx).Available { + t.Fatal("Target fixture must be available") + } + if got := fixture.Target.Inspect(ctx, fixture.Active).State; got != target.Disabled { + t.Fatalf("initial state = %s, want Disabled", got) + } + if got := fixture.Target.SetDesired(ctx, true, nil); got.Err == nil || !strings.Contains(got.Err.Error(), "Profile management") { + t.Fatalf("nil Active Profile = %#v", got) + } + invalid := &profile.Profile{Name: "invalid", Mode: profile.SingleMode, SingleURL: "ftp://proxy.example:21"} + if got := fixture.Target.SetDesired(ctx, true, invalid); got.Err == nil || got.Inspection.State != target.Disabled { + t.Fatalf("validation failure = %#v", got) + } + if got := fixture.Target.SetDesired(ctx, true, fixture.Active); got.Err != nil || got.Inspection.State != target.Enabled { + t.Fatalf("enable = %#v", got) + } + if got := fixture.Target.SetDesired(ctx, true, fixture.Active); got.Err != nil || got.Inspection.State != target.Enabled { + t.Fatalf("idempotent enable = %#v", got) + } + fixture.MakeDrifted(t) + if got := fixture.Target.Inspect(ctx, fixture.Active).State; got != target.Drifted { + t.Fatalf("drift state = %s", got) + } + if got := fixture.Target.SetDesired(ctx, true, fixture.Active); got.Err != nil || got.Inspection.State != target.Enabled { + t.Fatalf("repair = %#v", got) + } + if got := fixture.Target.SetDesired(ctx, false, nil); got.Err != nil { + t.Fatalf("disable = %#v", got) + } + if got := fixture.Target.SetDesired(ctx, false, nil); got.Err != nil { + t.Fatalf("idempotent disable = %#v", got) + } + + external := factory(t) + external.MakeExternal(t) + if got := external.Target.Inspect(ctx, external.Active).State; got != target.External { + t.Fatalf("external state = %s", got) + } + if got := external.Target.SetDesired(ctx, true, external.Active); got.Err != nil { + t.Fatalf("enable over External = %#v", got) + } + if got := external.Target.SetDesired(ctx, false, nil); got.Err != nil || got.Inspection.State != target.External { + t.Fatalf("restore External = %#v", got) + } + + permission := fixture.PermissionFailure(t) + if permission.Err == nil || !errors.Is(permission.Err, os.ErrPermission) || permission.Inspection.State != target.Disabled { + t.Fatalf("permission failure = %#v", permission) + } + partial := fixture.PartialFailure(t) + if partial.Err == nil || partial.Inspection.State != target.External { + t.Fatalf("partial failure = %#v", partial) + } + redacted := fixture.RedactionFailure(t) + if redacted.Err == nil || fixture.Secret == "" || strings.Contains(redacted.Err.Error(), fixture.Secret) { + t.Fatalf("secret was not redacted: %#v", redacted) + } + }) +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 856fb09..21fd080 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -8,6 +8,7 @@ import ( "proxyctl/internal/app" "proxyctl/internal/profile" + "proxyctl/internal/target" ) type page int @@ -32,6 +33,11 @@ type Model struct { form formState confirm bool status string + applying bool +} + +type targetOperationDone struct { + result app.Result } func NewModel(application app.Module) Model { @@ -41,6 +47,16 @@ func NewModel(application app.Module) Model { func (m Model) Init() tea.Cmd { return nil } func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { + if completed, ok := message.(targetOperationDone); ok { + m.applying = false + m.snapshot = completed.result.Snapshot + if completed.result.Err != nil { + m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m" + } else { + m.status = completed.result.Notice + } + return m, nil + } key, ok := message.(tea.KeyMsg) if !ok { return m, nil @@ -58,10 +74,29 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { + targets := m.availableTargets() switch key.String() { case "p": m.page = profilesPage m.status = "" + case "up", "k": + if !m.applying && m.selected > 0 { + m.selected-- + } + case "down", "j": + if !m.applying && m.selected+1 < len(targets) { + m.selected++ + } + case " ": + if !m.applying && m.selected >= 0 && m.selected < len(targets) { + selected := targets[m.selected] + desired := selected.State != target.Enabled + m.applying = true + m.status = "" + return m, func() tea.Msg { + return targetOperationDone{result: m.application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired})} + } + } case "q", "esc", "ctrl+c": return m, tea.Quit } @@ -208,6 +243,16 @@ func (m Model) View() string { } } +func (m Model) availableTargets() []app.TargetSnapshot { + var result []app.TargetSnapshot + for _, item := range m.snapshot.Targets { + if item.Available { + result = append(result, item) + } + } + return result +} + func (m Model) mainView() string { var b strings.Builder b.WriteString("proxyctl — Target panel\n\n") @@ -217,8 +262,31 @@ func (m Model) mainView() string { } 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") + targets := m.availableTargets() + if len(targets) == 0 { + b.WriteString("\nNo Targets are available in this build.\n") + } else { + b.WriteString("\nTargets\n") + for i, item := range targets { + cursor := " " + if i == m.selected { + cursor = "> " + } + state := string(item.State) + if m.applying && i == m.selected { + state = "Applying…" + } + fmt.Fprintf(&b, "%s[%-9s] %s\n", cursor, state, item.Description.Name) + } + if m.selected >= 0 && m.selected < len(targets) { + selected := targets[m.selected] + fmt.Fprintf(&b, "\n%s\n%s\n", selected.Description.Summary, selected.Evidence) + } + } + b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles q/Esc: quit\n") + if m.status != "" { + fmt.Fprintf(&b, "\n%s\n", m.status) + } return b.String() } diff --git a/internal/tui/target_test.go b/internal/tui/target_test.go new file mode 100644 index 0000000..83a187b --- /dev/null +++ b/internal/tui/target_test.go @@ -0,0 +1,64 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "proxyctl/internal/app" + "proxyctl/internal/target" +) + +type targetModule struct { + snapshot app.Snapshot + result app.Result + intents []app.Intent +} + +func (m *targetModule) Snapshot() app.Snapshot { return m.snapshot } +func (m *targetModule) Dispatch(intent app.Intent) app.Result { + m.intents = append(m.intents, intent) + m.snapshot = m.result.Snapshot + return m.result +} + +func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing.T) { + module := &targetModule{snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{ + {ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.External, Evidence: "existing proxy"}, + {ID: "apt", Description: target.Description{Name: "APT"}, Available: false}, + }}} + module.result = app.Result{Snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.Enabled, Evidence: "matches"}}}, Notice: "Shell Target is now Enabled"} + model := NewModel(module) + view := model.View() + if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Exports for Bash/Zsh") { + t.Fatalf("Target panel:\n%s", view) + } + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updated.(Model) + if command == nil || !strings.Contains(model.View(), "Applying") || len(module.intents) != 0 { + t.Fatalf("operation did not expose applying state before effect:\n%s", model.View()) + } + message := command() + updated, _ = model.Update(message) + model = updated.(Model) + if len(module.intents) != 1 || module.intents[0].Kind != app.SetTargetDesired || !module.intents[0].Enabled || !strings.Contains(model.View(), "Enabled") { + t.Fatalf("completed toggle = %#v\n%s", module.intents, model.View()) + } +} + +func TestTargetFailurePreservesTruthfulStateAndRendersRedactedError(t *testing.T) { + initial := app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}} + module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Err: errors.New("permission denied for alice:REDACTED@proxy")}} + model := NewModel(module) + updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updated.(Model) + updated, _ = model.Update(command()) + model = updated.(Model) + view := model.View() + if !strings.Contains(view, "Enabled") || !strings.Contains(view, "\x1b[31mError:") || !strings.Contains(view, "REDACTED") { + t.Fatalf("failed operation rendering:\n%s", view) + } +}