package installer import ( "bytes" "context" "fmt" "os" "os/exec" "runtime" "strings" "sync" "time" "gitgud.io/mike/mpv-manager/internal/process" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/keyring" "gitgud.io/mike/mpv-manager/pkg/log" ) // CommandRunner implements CommandExecutor interface var _ CommandExecutor = (*CommandRunner)(nil) type CommandRunner struct { outputChan chan<- string errorChan chan<- error ctx context.Context // Context for cancellation support allowInteractiveSudo bool commitGuard func() error commitMu sync.Mutex commitStarted bool } // SetCommitGuard binds the irreversible boundary to the owning UI job. Staging // code must not invoke the guard until it is about to mutate the live install. func (cr *CommandRunner) SetCommitGuard(guard func() error) { cr.commitGuard = guard } func (cr *CommandRunner) BeginCommit() error { cr.commitMu.Lock() defer cr.commitMu.Unlock() if cr.commitStarted { return nil } if err := cr.ctx.Err(); err != nil { return err } if cr.commitGuard != nil { if err := cr.commitGuard(); err != nil { return err } } cr.commitStarted = true return nil } // NewCommandRunner creates a new command runner with output and error channels func NewCommandRunner(outputChan chan<- string, errorChan chan<- error) *CommandRunner { return &CommandRunner{ outputChan: outputChan, errorChan: errorChan, ctx: context.Background(), // Default to background context allowInteractiveSudo: true, } } // NewCommandRunnerWithContext creates a new command runner with a specific context for cancellation func NewCommandRunnerWithContext(ctx context.Context, outputChan chan<- string, errorChan chan<- error) *CommandRunner { return &CommandRunner{ outputChan: outputChan, errorChan: errorChan, ctx: ctx, allowInteractiveSudo: true, } } // SetInteractiveSudoAllowed controls whether sudo may prompt on the runner's // inherited terminal when no keyring credential is available. Interactive // terminal UIs disable this because a child prompt would compete with the // application's raw terminal ownership. func (cr *CommandRunner) SetInteractiveSudoAllowed(allowed bool) { cr.allowInteractiveSudo = allowed } // InteractiveSudoAllowed reports whether sudo may prompt on stdin. func (cr *CommandRunner) InteractiveSudoAllowed() bool { return cr.allowInteractiveSudo } // Context returns the command runner's context func (cr *CommandRunner) Context() context.Context { return cr.ctx } // SetContext sets the context for the command runner func (cr *CommandRunner) SetContext(ctx context.Context) { cr.ctx = ctx } func (cr *CommandRunner) RunCommand(name string, args ...string) error { cmd := exec.CommandContext(cr.ctx, name, args...) return cr.RunCommandWithOutput(cmd) } // RunCommandOutput runs a command and returns its captured standard output, // mirroring exec.Command(name, args...).Output() under the runner's context. func (cr *CommandRunner) RunCommandOutput(name string, args ...string) ([]byte, error) { return process.Output(cr.ctx, exec.CommandContext(cr.ctx, name, args...)) } func (cr *CommandRunner) RunCommandWithOutput(cmd *exec.Cmd) error { cmdStr := strings.Join(append([]string{cmd.Path}, cmd.Args[1:]...), " ") log.Command(cmdStr) if cr.outputChan != nil { for _, line := range []string{fmt.Sprintf("Running: %s", cmdStr), strings.Repeat("─", 50)} { select { case cr.outputChan <- line: case <-cr.ctx.Done(): return cr.ctx.Err() } } } // Stream each process pipe once. The previous multiwriter fed both stdout // and stderr into both buffers and then logged their concatenation, which // duplicated every byte and doubled memory use. stdoutCapture := &outputCapture{outputChan: cr.outputChan, ctx: cr.ctx} stderrCapture := &outputCapture{outputChan: cr.outputChan, isError: true, ctx: cr.ctx} cmd.Stdout = stdoutCapture cmd.Stderr = stderrCapture if cmd.Stdin == nil && cr.allowInteractiveSudo { cmd.Stdin = os.Stdin } err := process.Run(cr.ctx, cmd) stdoutCapture.Flush() stderrCapture.Flush() if err != nil { log.Error(fmt.Sprintf("Command failed: %s - Error: %s", cmdStr, err.Error())) if cr.errorChan != nil { select { case cr.errorChan <- err: case <-cr.ctx.Done(): } } if cr.outputChan != nil { select { case cr.outputChan <- fmt.Sprintf("Error: %s", err.Error()): case <-cr.ctx.Done(): } } return err } log.Info("Command completed successfully") return nil } type outputCapture struct { outputChan chan<- string isError bool mu sync.Mutex pending []byte ctx context.Context } func (oc *outputCapture) Write(p []byte) (n int, err error) { oc.mu.Lock() defer oc.mu.Unlock() n = len(p) const continuation = " [continued]" const limit = constants.MaxOutputLineBytes - len(continuation) for len(p) > 0 { end := bytes.IndexAny(p, "\r\n") if end < 0 { end = len(p) } take := min(end, limit-len(oc.pending)) oc.pending = append(oc.pending, p[:take]...) p = p[take:] if take == end && len(p) > 0 { oc.emit(string(oc.pending)) oc.pending = oc.pending[:0] p = p[1:] } else if len(oc.pending) == limit { oc.emit(string(oc.pending) + continuation) oc.pending = oc.pending[:0] } } return n, nil } // Flush publishes a final unterminated line after the child exits. Write only // emits complete lines so arbitrary pipe read boundaries cannot fragment or // merge user-visible command records. func (oc *outputCapture) Flush() { oc.mu.Lock() defer oc.mu.Unlock() if len(oc.pending) == 0 { return } oc.emit(string(oc.pending)) oc.pending = oc.pending[:0] } func (oc *outputCapture) emit(line string) { if line == "" { return } if oc.isError { log.ErrorOutput(line) } else { log.Output(line) } if oc.outputChan != nil { ctx := oc.ctx if ctx == nil { ctx = context.Background() } select { case oc.outputChan <- line: case <-ctx.Done(): } } } func (cr *CommandRunner) RunShellCommand(script string) error { return cr.RunCommandWithOutput(newShellCommand(cr.ctx, script)) } func (cr *CommandRunner) RunCommands(commands []string) error { for i, cmdStr := range commands { // Check for cancellation between commands select { case <-cr.ctx.Done(): return fmt.Errorf("command execution cancelled: %w", cr.ctx.Err()) default: } log.Separator(fmt.Sprintf("Command %d", i+1)) if err := cr.RunShellCommand(cmdStr); err != nil { return err } } return nil } // IsCancelled returns true if the context has been cancelled func (cr *CommandRunner) IsCancelled() bool { select { case <-cr.ctx.Done(): return true default: return false } } // CheckCancelled returns an error if the context has been cancelled func (cr *CommandRunner) CheckCancelled() error { select { case <-cr.ctx.Done(): return fmt.Errorf("operation cancelled: %w", cr.ctx.Err()) default: return nil } } // CanElevateWithoutPassword checks the current OS authorization without opening // a prompt or reading a credential. Actual commands still use sudo -n, so an // expired timestamp or a narrower command policy fails without prompting. func CanElevateWithoutPassword(parent context.Context) bool { if runtime.GOOS != "linux" { return false } if os.Geteuid() == 0 { return true } ctx, cancel := context.WithTimeout(parent, 3*time.Second) defer cancel() // sudo-rs can require authentication for -v even with NOPASSWD: ALL. // Probe an actual harmless command without trusting PATH for its target. cmd := exec.CommandContext(ctx, "sudo", "-n", "/usr/bin/true") cmd.WaitDelay = 500 * time.Millisecond return cmd.Run() == nil } // RunSudoCommand runs a command with sudo, using existing OS authorization or a // keyring password when necessary. func (cr *CommandRunner) RunSudoCommand(name string, args ...string) error { // Already root - no sudo needed if os.Geteuid() == 0 { log.Debug("RunSudoCommand: Already running as root, no sudo needed") return cr.RunCommand(name, args...) } if CanElevateWithoutPassword(cr.ctx) { return cr.RunCommand("sudo", append([]string{"-n", name}, args...)...) } // Try to get password from keyring kr, err := keyring.Open() if err != nil { if !cr.allowInteractiveSudo { return fmt.Errorf("sudo authentication requires a stored keyring credential or an already elevated process: %w", err) } log.Debug(fmt.Sprintf("RunSudoCommand: Keyring not available: %v, falling back to regular sudo", err)) // No keyring available - fall back to regular sudo return cr.RunCommand("sudo", append([]string{name}, args...)...) } password, err := kr.GetPasswordContext(cr.ctx) if err != nil { if !cr.allowInteractiveSudo { return fmt.Errorf("sudo authentication requires a stored keyring credential or an already elevated process: %w", err) } log.Debug(fmt.Sprintf("RunSudoCommand: Stored password unavailable after bounded keyring read: %v; falling back to regular sudo", err)) // No password stored - fall back to regular sudo return cr.RunCommand("sudo", append([]string{name}, args...)...) } log.Debug("RunSudoCommand: Using password from keyring") // Validate and cache sudo credentials if err := cr.cacheSudoCredentials(password); err != nil { log.Error(fmt.Sprintf("RunSudoCommand: Sudo authentication failed: %v", err)) return fmt.Errorf("sudo authentication failed: %w", err) } log.Debug("RunSudoCommand: Sudo credentials cached, running command") // Run command with cached sudo return cr.RunCommand("sudo", append([]string{"-n", name}, args...)...) } // cacheSudoCredentials validates password and refreshes sudo timestamp func (cr *CommandRunner) cacheSudoCredentials(password string) error { log.Debug("cacheSudoCredentials: Validating sudo password") ctx, cancel := context.WithTimeout(cr.ctx, 15*time.Second) defer cancel() // Force validation of this exact stored password instead of accepting an // unrelated cached sudo timestamp. cmd := exec.CommandContext(ctx, "sudo", "-k", "-S", "-v") cmd.Stdin = bytes.NewBufferString(password + "\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := process.Run(ctx, cmd); err != nil { if ctx.Err() != nil { return fmt.Errorf("sudo credential validation timed out or was cancelled: %w", ctx.Err()) } log.Debug(fmt.Sprintf("cacheSudoCredentials: Validation failed - stderr: %s", stderr.String())) return fmt.Errorf("invalid password: %s", stderr.String()) } log.Debug("cacheSudoCredentials: Password validated, sudo timestamp refreshed") return nil }