diff --git a/internal/exec/exec.go b/internal/exec/exec.go new file mode 100644 index 0000000..3a4f1d9 --- /dev/null +++ b/internal/exec/exec.go @@ -0,0 +1,155 @@ +// Package exec plans and performs the final process handoff. +package exec + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + "syscall" + "time" + + "github.com/KofTwentyTwo/limen/internal/config" + "github.com/KofTwentyTwo/limen/internal/probe" + "github.com/KofTwentyTwo/limen/internal/ssh" + "github.com/KofTwentyTwo/limen/internal/state" +) + +const maxSessionNameLength = 64 + +// ErrEmptyArgv reports an invalid handoff plan. +var ErrEmptyArgv = errors.New("empty argv") + +// Plan is the final command vector and the host it targets. +type Plan struct { + HostName string + Argv []string +} + +// SyscallExec is the injectable boundary around syscall.Exec. +type SyscallExec func(path string, argv []string, env []string) error + +// Options supplies dependencies for Handoff. +type Options struct { + StatePath string + Now func() time.Time + Environ func() []string + Stderr io.Writer + LoadState func(string) state.State + SaveState func(string, state.State) error + Exec SyscallExec +} + +// LocalAttach plans `tmux attach -t ` for localhost. +func LocalAttach(session string) Plan { + return Plan{ + HostName: probe.LocalhostName, + Argv: ssh.LocalTmuxArgv("attach", "-t", session), + } +} + +// LocalNew plans `tmux new`, optionally with `-s `. +func LocalNew(name string) (Plan, error) { + args, err := newSessionArgs(name) + if err != nil { + return Plan{}, err + } + return Plan{ + HostName: probe.LocalhostName, + Argv: ssh.LocalTmuxArgv(args...), + }, nil +} + +// RemoteAttach plans `ssh -t tmux attach -t `. +func RemoteAttach(host config.Host, session string) Plan { + return Plan{ + HostName: host.Name, + Argv: ssh.SessionArgv(host, "attach", "-t", session), + } +} + +// RemoteNew plans `ssh -t tmux new`, optionally with `-s `. +func RemoteNew(host config.Host, name string) (Plan, error) { + args, err := newSessionArgs(name) + if err != nil { + return Plan{}, err + } + return Plan{ + HostName: host.Name, + Argv: ssh.SessionArgv(host, args...), + }, nil +} + +// NormalizeSessionName trims and validates a name for `tmux new -s`. +// Empty input is valid and means tmux should create an unnamed session. +func NormalizeSessionName(name string) (string, bool, error) { + normalized := strings.TrimSpace(name) + if normalized == "" { + return "", false, nil + } + if strings.ContainsAny(normalized, ":.") { + return "", false, errors.New("name cannot contain : or .") + } + if len([]rune(normalized)) > maxSessionNameLength { + return "", false, fmt.Errorf("name cannot exceed %d characters", maxSessionNameLength) + } + return normalized, true, nil +} + +// Handoff records the destination host, then replaces the current process. +// State is advisory: a write failure must not strand the user before exec. +func Handoff(plan Plan, opts Options) int { + deps := opts.withDefaults() + if len(plan.Argv) == 0 { + fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", ErrEmptyArgv) + return 127 + } + + st := deps.LoadState(deps.StatePath) + if st.LastAttached == nil { + st.LastAttached = map[string]time.Time{} + } + st.LastAttached[plan.HostName] = deps.Now().UTC() + _ = deps.SaveState(deps.StatePath, st) + + if err := deps.Exec(plan.Argv[0], plan.Argv, deps.Environ()); err != nil { + fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", err) + return 127 + } + + return 0 +} + +func newSessionArgs(name string) ([]string, error) { + normalized, named, err := NormalizeSessionName(name) + if err != nil { + return nil, err + } + if !named { + return []string{"new"}, nil + } + return []string{"new", "-s", normalized}, nil +} + +func (opts Options) withDefaults() Options { + if opts.Now == nil { + opts.Now = time.Now + } + if opts.Environ == nil { + opts.Environ = os.Environ + } + if opts.Stderr == nil { + opts.Stderr = io.Discard + } + if opts.LoadState == nil { + opts.LoadState = state.Load + } + if opts.SaveState == nil { + opts.SaveState = state.Save + } + if opts.Exec == nil { + opts.Exec = syscall.Exec + } + return opts +} diff --git a/internal/exec/exec_test.go b/internal/exec/exec_test.go new file mode 100644 index 0000000..233d69d --- /dev/null +++ b/internal/exec/exec_test.go @@ -0,0 +1,238 @@ +package exec + +import ( + "bytes" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/KofTwentyTwo/limen/internal/config" + "github.com/KofTwentyTwo/limen/internal/probe" + "github.com/KofTwentyTwo/limen/internal/state" +) + +func TestLocalAttachPlansTmuxAttach(t *testing.T) { + got := LocalAttach("api") + want := Plan{ + HostName: probe.LocalhostName, + Argv: []string{"tmux", "attach", "-t", "api"}, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("LocalAttach() = %#v, want %#v", got, want) + } +} + +func TestLocalNewPlansUnnamedAndNamedSessions(t *testing.T) { + tests := []struct { + name string + input string + want Plan + }{ + { + name: "unnamed", + input: " ", + want: Plan{HostName: probe.LocalhostName, Argv: []string{"tmux", "new"}}, + }, + { + name: "named", + input: " build ", + want: Plan{HostName: probe.LocalhostName, Argv: []string{"tmux", "new", "-s", "build"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := LocalNew(tt.input) + if err != nil { + t.Fatalf("LocalNew returned error: %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("LocalNew() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestRemoteAttachPlansSSHAttach(t *testing.T) { + host := config.Host{Name: "prod", Hostname: "prod.example.com", User: "deploy", Port: 2222} + + got := RemoteAttach(host, "api") + want := Plan{ + HostName: "prod", + Argv: []string{ + "ssh", "-t", "-p", "2222", "deploy@prod.example.com", + "tmux", "attach", "-t", "api", + }, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("RemoteAttach() = %#v, want %#v", got, want) + } +} + +func TestRemoteNewPlansSSHNew(t *testing.T) { + host := config.Host{Name: "dev", Hostname: "dev.lan", User: "james"} + + got, err := RemoteNew(host, " work ") + if err != nil { + t.Fatalf("RemoteNew returned error: %v", err) + } + want := Plan{ + HostName: "dev", + Argv: []string{"ssh", "-t", "james@dev.lan", "tmux", "new", "-s", "work"}, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("RemoteNew() = %#v, want %#v", got, want) + } +} + +func TestNewSessionPlannersReturnValidationErrors(t *testing.T) { + if _, err := LocalNew("bad:name"); err == nil || err.Error() != "name cannot contain : or ." { + t.Fatalf("LocalNew error = %v, want invalid name", err) + } + + host := config.Host{Name: "dev", Hostname: "dev.lan"} + if _, err := RemoteNew(host, "bad.name"); err == nil || err.Error() != "name cannot contain : or ." { + t.Fatalf("RemoteNew error = %v, want invalid name", err) + } +} + +func TestNormalizeSessionName(t *testing.T) { + tests := []struct { + name string + input string + wantName string + wantNamed bool + wantErr string + }{ + {name: "empty creates unnamed", input: " \t ", wantNamed: false}, + {name: "trims whitespace", input: " Build ", wantName: "Build", wantNamed: true}, + {name: "rejects colon", input: "bad:name", wantErr: "name cannot contain : or ."}, + {name: "rejects dot", input: "bad.name", wantErr: "name cannot contain : or ."}, + {name: "rejects over cap", input: repeatRune('a', 65), wantErr: "name cannot exceed 64 characters"}, + {name: "accepts cap", input: repeatRune('a', 64), wantName: repeatRune('a', 64), wantNamed: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotName, gotNamed, err := NormalizeSessionName(tt.input) + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("NormalizeSessionName error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("NormalizeSessionName returned error: %v", err) + } + if gotName != tt.wantName || gotNamed != tt.wantNamed { + t.Fatalf("NormalizeSessionName() = (%q, %v), want (%q, %v)", gotName, gotNamed, tt.wantName, tt.wantNamed) + } + }) + } +} + +func TestHandoffUpdatesStateBeforeExec(t *testing.T) { + now := time.Date(2026, 5, 26, 13, 45, 0, 0, time.FixedZone("test", -5*60*60)) + var calls []string + var saved state.State + var execPath string + var execArgv []string + var execEnv []string + var stderr bytes.Buffer + + code := Handoff(Plan{HostName: "prod", Argv: []string{"ssh", "-t", "prod", "tmux", "new"}}, Options{ + StatePath: "/tmp/state.json", + Now: func() time.Time { return now }, + Environ: func() []string { return []string{"TERM=xterm-256color"} }, + Stderr: &stderr, + LoadState: func(path string) state.State { + calls = append(calls, "load:"+path) + return state.State{LastAttached: map[string]time.Time{"localhost": now.Add(-time.Hour)}} + }, + SaveState: func(path string, st state.State) error { + calls = append(calls, "save:"+path) + saved = st + return nil + }, + Exec: func(path string, argv []string, env []string) error { + calls = append(calls, "exec") + execPath = path + execArgv = append([]string(nil), argv...) + execEnv = append([]string(nil), env...) + return errors.New("exec unavailable") + }, + }) + + if code != 127 { + t.Fatalf("Handoff exit code = %d, want 127", code) + } + wantCalls := []string{"load:/tmp/state.json", "save:/tmp/state.json", "exec"} + if !reflect.DeepEqual(calls, wantCalls) { + t.Fatalf("calls = %#v, want %#v", calls, wantCalls) + } + if got := saved.LastAttached["prod"]; !got.Equal(now.UTC()) { + t.Fatalf("saved prod timestamp = %s, want %s", got, now.UTC()) + } + if _, ok := saved.LastAttached["localhost"]; !ok { + t.Fatal("existing localhost timestamp was not preserved") + } + if execPath != "ssh" || !reflect.DeepEqual(execArgv, []string{"ssh", "-t", "prod", "tmux", "new"}) { + t.Fatalf("exec saw %q %#v", execPath, execArgv) + } + if !reflect.DeepEqual(execEnv, []string{"TERM=xterm-256color"}) { + t.Fatalf("exec env = %#v", execEnv) + } + if got := stderr.String(); got != "limen: exec failed: exec unavailable\n" { + t.Fatalf("stderr = %q", got) + } +} + +func TestHandoffTreatsStateSaveFailureAsAdvisory(t *testing.T) { + var calledExec bool + + code := Handoff(Plan{HostName: "prod", Argv: []string{"ssh"}}, Options{ + LoadState: func(path string) state.State { + return state.State{} + }, + SaveState: func(path string, st state.State) error { + return errors.New("disk full") + }, + Exec: func(path string, argv []string, env []string) error { + calledExec = true + return nil + }, + }) + + if code != 0 { + t.Fatalf("Handoff exit code = %d, want 0", code) + } + if !calledExec { + t.Fatal("exec was not called after advisory state save failure") + } +} + +func TestHandoffReportsEmptyArgvAsExecFailure(t *testing.T) { + var stderr bytes.Buffer + + code := Handoff(Plan{HostName: "localhost"}, Options{Stderr: &stderr}) + + if code != 127 { + t.Fatalf("Handoff exit code = %d, want 127", code) + } + if got := stderr.String(); got != "limen: exec failed: empty argv\n" { + t.Fatalf("stderr = %q", got) + } +} + +func repeatRune(r rune, count int) string { + var builder strings.Builder + for i := 0; i < count; i++ { + builder.WriteRune(r) + } + return builder.String() +}