diff --git a/internal/privilege/sudo.go b/internal/privilege/sudo.go new file mode 100644 index 0000000..2d5ec67 --- /dev/null +++ b/internal/privilege/sudo.go @@ -0,0 +1,77 @@ +// Package privilege manages sudo timestamp acquisition and keepalive so +// a long-running installer only prompts once. +package privilege + +import ( + "context" + "fmt" + "os/exec" + "time" +) + +// Keepalive holds the lifetime of a background sudo-refresh goroutine. +type Keepalive struct { + cancel context.CancelFunc + done chan struct{} +} + +// Stop terminates the keepalive goroutine. Safe to call multiple times. +func (k *Keepalive) Stop() { + if k == nil || k.cancel == nil { + return + } + k.cancel() + <-k.done + k.cancel = nil +} + +// Acquirer abstracts the `sudo` binary so tests can inject a fake. +type Acquirer interface { + Validate(ctx context.Context) error // sudo -v (may prompt) + ValidateNonInteractive(ctx context.Context) error // sudo -n -v +} + +// ExecSudo is the production Acquirer. +type ExecSudo struct{} + +func (ExecSudo) Validate(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "sudo", "-v") + cmd.Stdin = nil // inherit terminal stdin + return cmd.Run() +} + +func (ExecSudo) ValidateNonInteractive(ctx context.Context) error { + return exec.CommandContext(ctx, "sudo", "-n", "-v").Run() +} + +// Acquire runs `sudo -v` once (prompting if needed) and returns a Keepalive +// that refreshes the timestamp every `interval` using ValidateNonInteractive. +// If the initial Validate fails, no goroutine is started and the error is returned. +func Acquire(ctx context.Context, a Acquirer, interval time.Duration) (*Keepalive, error) { + if a == nil { + a = ExecSudo{} + } + if interval <= 0 { + interval = 50 * time.Second + } + if err := a.Validate(ctx); err != nil { + return nil, fmt.Errorf("sudo -v failed: %w", err) + } + kCtx, cancel := context.WithCancel(ctx) + k := &Keepalive{cancel: cancel, done: make(chan struct{})} + + go func() { + defer close(k.done) + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-kCtx.Done(): + return + case <-t.C: + _ = a.ValidateNonInteractive(kCtx) // best-effort + } + } + }() + return k, nil +} diff --git a/internal/privilege/sudo_test.go b/internal/privilege/sudo_test.go new file mode 100644 index 0000000..45a346d --- /dev/null +++ b/internal/privilege/sudo_test.go @@ -0,0 +1,65 @@ +package privilege + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" +) + +type fakeAcquirer struct { + validateErr error + validates int32 + refreshes int32 +} + +func (f *fakeAcquirer) Validate(ctx context.Context) error { + atomic.AddInt32(&f.validates, 1) + return f.validateErr +} +func (f *fakeAcquirer) ValidateNonInteractive(ctx context.Context) error { + atomic.AddInt32(&f.refreshes, 1) + return nil +} + +func TestAcquireFailsIfValidateFails(t *testing.T) { + a := &fakeAcquirer{validateErr: errors.New("wrong password")} + _, err := Acquire(context.Background(), a, 10*time.Millisecond) + if err == nil { + t.Fatal("expected error") + } + if atomic.LoadInt32(&a.refreshes) != 0 { + t.Fatal("keepalive ran despite Validate failure") + } +} + +func TestAcquireStartsKeepaliveAndStops(t *testing.T) { + a := &fakeAcquirer{} + k, err := Acquire(context.Background(), a, 5*time.Millisecond) + if err != nil { + t.Fatal(err) + } + time.Sleep(25 * time.Millisecond) + k.Stop() + got := atomic.LoadInt32(&a.refreshes) + if got < 2 { + t.Fatalf("refreshes = %d, want >= 2", got) + } + + // After Stop, no additional refreshes should occur. + snap := atomic.LoadInt32(&a.refreshes) + time.Sleep(20 * time.Millisecond) + if after := atomic.LoadInt32(&a.refreshes); after != snap { + t.Fatalf("refreshes continued after Stop: %d → %d", snap, after) + } +} + +func TestStopIsIdempotent(t *testing.T) { + k, err := Acquire(context.Background(), &fakeAcquirer{}, 5*time.Millisecond) + if err != nil { + t.Fatal(err) + } + k.Stop() + k.Stop() // must not panic or hang +}