//go:build !windows

// Package core — runas.go provides the spawn-as-different-Unix-user primitive
// used when a project sets `run_as_user` in config.toml.
//
// # Mechanism
//
// We intentionally spawn via:
//
//	sudo -n -iu <target-user> -- <command> [args...]
//
// The flags are load-bearing and should NOT be "simplified":
//
//   - -n (non-interactive): never prompt for a password. If passwordless
//     sudo to the target user is not configured, fail loudly instead of
//     hanging on a prompt that nobody will ever see.
//
//   - -i (simulate initial login): run the target user's full login shell,
//     loading their ~/.profile / ~/.bashrc, setting HOME to their home
//     directory, and clearing the supervisor's environment. This is what
//     makes the spawned process a "real session as that user" — their
//     ~/.claude/settings.json, their PGSSL certs, their plugin state.
//
//   - -u <target-user>: the target uid. Must be a specific username; the
//     sudoers rule that allows this should be scoped to this user only,
//     not ALL.
//
//   - -- : end of sudo options. Everything after this is the command to run
//     as the target user. Prevents an argv element that starts with "-"
//     from being reinterpreted as a sudo flag.
//
// Alternatives that are NOT used, with reasons:
//
//   - setuid(): loses the target user's shell profile entirely. No
//     ~/.bashrc, no ~/.profile, no login env. Also has to be done before
//     exec, which means the supervisor process needs CAP_SETUID or to be
//     running as root — strictly worse than sudo on both fronts.
//
//   - su - <target>: interactive-only on many distros (no -c equivalent
//     for a non-shell argv), and it consults PAM differently from sudo,
//     making the "passwordless" surface harder to reason about.
//
//   - sudo -u <target> (without -i): preserves the supervisor's cwd and
//     most of its environment. This leaks the supervisor's HOME and any
//     unset-by-default env vars, which defeats the isolation story.
//
// # Environment handling
//
// When RunAsUser is set, the supervisor's environment is NOT forwarded to
// the target user. Only variables on the explicit allowlist are passed
// through via `sudo --preserve-env=VAR1,VAR2`. The default allowlist is
// intentionally minimal; anything else should live in the target user's
// own shell profile or ~/.claude/settings.json.
package core

import (
	"context"
	"errors"
	"fmt"
	"os/exec"
	"os/user"
	"sort"
	"strings"
	"sync"
	"time"
)

// currentUsername returns the current Unix login name, or "" if it can't
// be determined. Used by runas_check.go when building example sudoers
// snippets in error messages.
func currentUsername() string {
	u, err := user.Current()
	if err != nil {
		return ""
	}
	return u.Username
}

// RunAsChdirEnv carries the intended working directory across the sudo -i
// boundary. sudo -i simulates an initial login and runs the command from the
// target user's HOME, which silently overrides the supervisor's cmd.Dir. The
// spawned command therefore re-establishes the intended cwd by chdir-ing to
// this value before exec-ing the real binary. It travels via the environment
// (not argv) so directories containing spaces or non-ASCII survive sudo's
// command re-quoting. Callers set it on cmd.Env when SpawnOptions.WorkDir is
// non-empty; BuildSpawnCommand adds it to the --preserve-env allowlist.
const RunAsChdirEnv = "CC_RUNAS_CHDIR"

// DefaultEnvAllowlist is the minimal env preserved across the sudo
// boundary. Deliberately excluded:
//   - HOME / USER / LOGNAME / SHELL — sudo -i overrides them
//   - PWD — sudo -i overrides cmd.Dir, so the intended cwd is re-applied
//     inside the spawn via RunAsChdirEnv (see BuildSpawnCommand)
//   - PATH — sudo -i builds it from the target user's /etc/profile +
//     ~/.profile. Preserving the supervisor's PATH would (a) leak
//     supervisor work directories into the isolated session and
//     (b) defeat the whole point of -i by overriding the login shell's
//     PATH. If the target user needs specific binaries on PATH, put
//     them in the system PATH (e.g. /usr/local/bin symlinks) or in
//     the target user's own shell profile.
//   - anything secret
var DefaultEnvAllowlist = []string{
	"LANG",
	"LC_ALL",
	"LC_CTYPE",
	"LC_MESSAGES",
	"TERM",
}

// SpawnOptions controls how a command is spawned. Zero value = legacy
// supervisor-user spawn. Non-empty RunAsUser triggers sudo wrapping.
type SpawnOptions struct {
	RunAsUser    string
	EnvAllowlist []string // extends DefaultEnvAllowlist, not a replacement
	// WorkDir is the directory the spawned command should run in. Under
	// sudo -i (IsolationMode) cmd.Dir is ignored because -i chdirs to the
	// target user's HOME, so when WorkDir is set BuildSpawnCommand wraps the
	// command to chdir into it via RunAsChdirEnv. Empty = leave cwd to -i.
	WorkDir string
}

func (o SpawnOptions) IsolationMode() bool {
	return o.RunAsUser != ""
}

func (o SpawnOptions) mergedAllowlist() []string {
	seen := make(map[string]struct{}, len(DefaultEnvAllowlist)+len(o.EnvAllowlist)+1)
	for _, v := range DefaultEnvAllowlist {
		seen[v] = struct{}{}
	}
	if o.WorkDir != "" {
		// The re-chdir wrapper needs this env var to survive --preserve-env.
		seen[RunAsChdirEnv] = struct{}{}
	}
	for _, v := range o.EnvAllowlist {
		if v == "" {
			continue
		}
		seen[v] = struct{}{}
	}
	out := make([]string, 0, len(seen))
	for v := range seen {
		out = append(out, v)
	}
	sort.Strings(out)
	return out
}

// BuildSpawnCommand returns an *exec.Cmd that either invokes name/args
// directly (legacy) or wraps them in `sudo -n -iu <user> --preserve-env=... -- name args...`.
//
// Does NOT run the per-spawn re-check — callers should invoke
// VerifyRunAsUserCheap immediately before Start() so a sudoers edit
// between startup preflight and spawn is caught.
func BuildSpawnCommand(ctx context.Context, opts SpawnOptions, name string, args ...string) *exec.Cmd {
	if !opts.IsolationMode() {
		return exec.CommandContext(ctx, name, args...)
	}
	sudoArgs := []string{
		"-n",
		"-iu", opts.RunAsUser,
		"--preserve-env=" + strings.Join(opts.mergedAllowlist(), ","),
		"--",
	}
	if opts.WorkDir != "" {
		// sudo -i runs the command from the target user's HOME, overriding
		// cmd.Dir, so the agent would otherwise start in the wrong directory
		// (e.g. ignoring a multi-workspace binding). Re-establish the intended
		// cwd inside the spawn. The path travels in $CC_RUNAS_CHDIR (a
		// preserved env var, set by the caller) rather than argv so paths with
		// spaces or non-ASCII survive sudo's command re-quoting.
		sudoArgs = append(sudoArgs,
			"/bin/sh", "-c", "cd \"$"+RunAsChdirEnv+"\" || exit 1; exec \"$@\"", "sh", name)
	} else {
		sudoArgs = append(sudoArgs, name)
	}
	sudoArgs = append(sudoArgs, args...)
	return exec.CommandContext(ctx, "sudo", sudoArgs...)
}

// FilterEnvForSpawn strips env down to the merged allowlist when
// opts.IsolationMode() is true. Belt-and-braces with sudo's own
// --preserve-env, but having cc-connect's spawn argv be the single
// source of truth keeps test assertions clean.
func FilterEnvForSpawn(env []string, opts SpawnOptions) []string {
	if !opts.IsolationMode() {
		return env
	}
	allow := opts.mergedAllowlist()
	allowSet := make(map[string]struct{}, len(allow))
	for _, v := range allow {
		allowSet[v] = struct{}{}
	}
	out := make([]string, 0, len(env))
	for _, e := range env {
		eq := strings.IndexByte(e, '=')
		if eq <= 0 {
			continue
		}
		if _, ok := allowSet[e[:eq]]; ok {
			out = append(out, e)
		}
	}
	return out
}

// SudoRunner runs `sudo <args...>` and returns combined output. Tests
// inject a stub; production uses ExecSudoRunner.
type SudoRunner interface {
	Run(ctx context.Context, args ...string) ([]byte, error)
}

type ExecSudoRunner struct{}

func (ExecSudoRunner) Run(ctx context.Context, args ...string) ([]byte, error) {
	return exec.CommandContext(ctx, "sudo", args...).CombinedOutput()
}

// VerifyRunAsUserCheap runs the two cheap preflight checks that must pass
// before every spawn, not just at startup:
//
//  1. `sudo -n -iu <user> -- /usr/bin/true` must succeed — the supervisor still
//     has passwordless sudo to the target user.
//  2. `sudo -n -iu <user> -- sudo -n /usr/bin/true` must FAIL — the target user
//     cannot non-interactively escalate.
//
// Returns nil if both checks behave as expected. Results are cached for
// verifyCacheTTL keyed by runAsUser so rapid-fire messages don't pay the
// ~100ms cost per spawn. A failure evicts the cache immediately so the
// next spawn re-verifies fresh.
//
// The expensive checks (work_dir access, isolation probe) live in the
// preflight and audit packages and only run at startup / via `cc-connect
// doctor user-isolation`.
func VerifyRunAsUserCheap(ctx context.Context, runner SudoRunner, runAsUser string) error {
	if runAsUser == "" {
		return errors.New("VerifyRunAsUserCheap: runAsUser is empty")
	}
	if verifyCacheHit(runAsUser) {
		return nil
	}
	if out, err := runner.Run(ctx, "-n", "-iu", runAsUser, "--", "/usr/bin/true"); err != nil {
		verifyCacheEvict(runAsUser)
		return fmt.Errorf("passwordless sudo to user %q failed (check that your sudoers rule is present and scoped to this user): %w: %s", runAsUser, err, strings.TrimSpace(string(out)))
	}
	out, err := runner.Run(ctx, "-n", "-iu", runAsUser, "--", "sudo", "-n", "/usr/bin/true")
	if err == nil {
		verifyCacheEvict(runAsUser)
		return fmt.Errorf("target user %q can run passwordless sudo; isolation is meaningless. Remove NOPASSWD sudo for this user. Output: %s", runAsUser, strings.TrimSpace(string(out)))
	}
	verifyCacheStore(runAsUser)
	return nil
}

// verifyCacheTTL is short by design. It absorbs a burst of messages
// (one Slack user typing rapidly) while still re-verifying often enough
// that a sudoers edit during a long idle gap is caught on the next spawn.
const verifyCacheTTL = 30 * time.Second

var (
	verifyCacheMu sync.Mutex
	verifyCache   = map[string]time.Time{}
)

func verifyCacheHit(user string) bool {
	verifyCacheMu.Lock()
	defer verifyCacheMu.Unlock()
	expires, ok := verifyCache[user]
	if !ok {
		return false
	}
	if time.Now().After(expires) {
		delete(verifyCache, user)
		return false
	}
	return true
}

func verifyCacheStore(user string) {
	verifyCacheMu.Lock()
	defer verifyCacheMu.Unlock()
	verifyCache[user] = time.Now().Add(verifyCacheTTL)
}

func verifyCacheEvict(user string) {
	verifyCacheMu.Lock()
	defer verifyCacheMu.Unlock()
	delete(verifyCache, user)
}

// ResetVerifyCache clears all cached positive verification results. Used
// by tests and available for any caller that wants to force a re-check
// on the next spawn (e.g. after reloading sudoers).
func ResetVerifyCache() {
	verifyCacheMu.Lock()
	defer verifyCacheMu.Unlock()
	verifyCache = map[string]time.Time{}
}
