//go:build linux

package sandbox

import (
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"syscall"
	"unsafe"

	"github.com/landlock-lsm/go-landlock/landlock"
	"golang.org/x/sys/unix"
)

var ErrUnsupportedPlatform = errors.New("no sandbox backend available")

// LandlockBackend uses the Linux Landlock LSM via a re-exec pattern.
// landlock_restrict_self only affects the calling OS thread, so we re-exec
// the binary as "sandbox-exec" which runs single-threaded before exec'ing git.
type LandlockBackend struct {
	selfExe string
	lookup  LookupUID
}

func (l *LandlockBackend) Wrap(repoPath string, cmd *exec.Cmd) (*exec.Cmd, error) {
	return l.WrapMulti([]string{repoPath}, cmd)
}

func (l *LandlockBackend) WrapMulti(paths []string, cmd *exec.Cmd) (*exec.Cmd, error) {
	if len(paths) == 0 {
		return cmd, nil
	}

	// resolve the executable to an absolute path now, while $PATH is still
	// intact; the re-exec'd sandbox-exec subprocess inherits the env we pass
	// via cmd.Env, which may not include the wrappers that set up $PATH.
	args := cmd.Args
	if len(args) > 0 {
		if abs, err := exec.LookPath(args[0]); err == nil {
			args = append([]string{abs}, args[1:]...)
		}
	}

	var sandboxArgs []string
	sandboxArgs = append(sandboxArgs, "sandbox-exec")
	for _, p := range paths {
		sandboxArgs = append(sandboxArgs, "--repo-path="+p)
	}
	sandboxArgs = append(sandboxArgs, "--")
	sandboxArgs = append(sandboxArgs, args...)

	wrapped := exec.Command(l.selfExe, sandboxArgs...)
	wrapped.Env = cmd.Env
	wrapped.Dir = paths[0] // kernel chdir's here after setuid, before execve
	wrapped.Stdin = cmd.Stdin
	wrapped.Stdout = cmd.Stdout
	wrapped.Stderr = cmd.Stderr

	// drop to the virtual UID if we can resolve one. the kernel handles
	// fork -> setgroups -> setresgid -> setresuid -> chdir -> execve;
	// requires CAP_SETUID/CAP_SETGID on the caller.
	//
	// the primary GID is intentionally set to the virtual UID, NOT the
	// repo's group ownership. repo dirs are owned by virtualUID:gitGroup
	// with mode 0770 so the knot service (in gitGroup) can read them, but
	// sandbox subprocesses must not inherit gitGroup or they would gain
	// group access to every other repo and lose cross-owner isolation.
	//
	// Groups is an empty (non-nil) slice and NoSetGroups is false so the
	// kernel calls setgroups(0, NULL) and clears supplementary groups.
	// NoSetGroups: true would skip setgroups entirely and the subprocess
	// would inherit the parent's supplementary groups (including gitGroup).
	if l.lookup != nil {
		if uid, _, err := l.lookup(paths[0]); err == nil && uid > 0 {
			wrapped.SysProcAttr = &syscall.SysProcAttr{
				Credential: &syscall.Credential{
					Uid:    uid,
					Gid:    uid,
					Groups: []uint32{},
				},
			}
		}
	}

	return wrapped, nil
}

func (l *LandlockBackend) Name() string { return "landlock" }

// RuleSpec describes the paths a sandbox should grant access to, grouped by
// access tier. It is the input to the Landlock ruleset construction and is
// exposed so the path-derivation logic can be tested independently of any
// actual kernel-level enforcement.
type RuleSpec struct {
	// SystemRO is the set of system directories granted read+execute.
	SystemRO []string
	// GitConfigRO is the global git config file, granted read-only access
	// at file granularity. Empty when $HOME is not set.
	GitConfigRO string
	// DevRW is the set of device-file directories granted read/write +
	// ioctl access (needed so /dev/null works under Landlock V5+).
	DevRW []string
	// TmpRW is the set of directories granted read/write for temporary
	// patch and object files.
	TmpRW []string
	// RepoRW is the set of repository directories granted read/write
	// access including the REFER right (for cross-directory rename in
	// receive-pack's quarantine migration).
	RepoRW []string
}

// BuildRuleSpec derives the set of paths the sandbox should grant to each
// access tier given the repository paths the subprocess operates on.
func BuildRuleSpec(repoPaths []string) RuleSpec {
	return buildRuleSpec(repoPaths, os.Getenv("HOME"))
}

// buildRuleSpec is the testable variant of BuildRuleSpec that takes $HOME
// explicitly instead of reading it from the environment.
func buildRuleSpec(repoPaths []string, home string) RuleSpec {
	var gitConfig string
	if home != "" {
		// the only thing the sandboxed git subprocess needs from $HOME is the
		// global config file. granting just that one file (not the whole
		// .config tree) keeps everything else under $HOME outside the ruleset.
		gitConfig = filepath.Join(home, ".config", "git", "config")
	}

	return RuleSpec{
		SystemRO:    []string{"/usr", "/bin", "/lib", "/lib64", "/nix", "/etc"},
		GitConfigRO: gitConfig,
		DevRW:       []string{"/dev"},
		TmpRW:       []string{"/tmp"},
		RepoRW:      append([]string(nil), repoPaths...),
	}
}

// ApplyLandlock applies a Landlock ruleset to the current process then
// exec's into gitArgs. Called from the hidden "sandbox-exec" subcommand.
func ApplyLandlock(repoPaths []string, gitArgs []string) error {
	if len(gitArgs) == 0 {
		return fmt.Errorf("sandbox-exec: no command specified")
	}

	spec := BuildRuleSpec(repoPaths)

	rules := []landlock.Rule{
		landlock.RODirs(spec.SystemRO...).IgnoreIfMissing(),
		landlock.RWFiles(spec.DevRW...).WithIoctlDev().IgnoreIfMissing(),
		landlock.RWDirs(spec.TmpRW...).IgnoreIfMissing(),
	}
	if spec.GitConfigRO != "" {
		rules = append(rules, landlock.ROFiles(spec.GitConfigRO).IgnoreIfMissing())
	}
	for _, p := range spec.RepoRW {
		rules = append(rules, landlock.RWDirs(p).WithRefer())
	}

	// V8.BestEffort enforces the strongest ruleset the running kernel supports,
	// up to V8. RestrictPaths also sets PR_SET_NO_NEW_PRIVS automatically.
	if err := landlock.V8.BestEffort().RestrictPaths(rules...); err != nil {
		return fmt.Errorf("sandbox-exec: restrict paths: %w", err)
	}

	gitBin := gitArgs[0]
	if !filepath.IsAbs(gitBin) {
		return fmt.Errorf("sandbox-exec: expected absolute path, got %q", gitBin)
	}

	return unix.Exec(gitBin, gitArgs, os.Environ())
}

func probeLandlock() bool {
	_, err := landlockCreateRuleset(nil, unix.LANDLOCK_CREATE_RULESET_VERSION)
	// EOPNOTSUPP and ENOSYS mean the kernel doesn't support landlock.
	// Any other result (including EINVAL for the nil attr) means it's available.
	return !errors.Is(err, unix.EOPNOTSUPP) && !errors.Is(err, unix.ENOSYS)
}

func platformNew(lookup LookupUID) (Backend, string) {
	if probeLandlock() {
		selfExe, err := os.Readlink("/proc/self/exe")
		if err != nil {
			selfExe = "/proc/self/exe"
		}
		return &LandlockBackend{selfExe: selfExe, lookup: lookup}, ""
	}

	return &NoopBackend{}, "landlock unavailable (kernel < 5.13); git subprocesses run unsandboxed"
}

func platformProbe() string {
	if probeLandlock() {
		return "landlock available (kernel >= 5.13)"
	}
	return "no sandbox backend available (kernel < 5.13)"
}

// landlockCreateRuleset wraps the landlock_create_ruleset(2) syscall.
// Pass attr=nil and flags=LANDLOCK_CREATE_RULESET_VERSION to query ABI version.
// Used only for the non-destructive probe in probeLandlock; all ruleset
// construction is handled by go-landlock.
func landlockCreateRuleset(attr *unix.LandlockRulesetAttr, flags uint) (int, error) {
	var attrPtr unsafe.Pointer
	var attrSize uintptr
	if attr != nil {
		attrPtr = unsafe.Pointer(attr)
		attrSize = unsafe.Sizeof(*attr)
	}
	fd, _, errno := unix.Syscall(
		unix.SYS_LANDLOCK_CREATE_RULESET,
		uintptr(attrPtr),
		attrSize,
		uintptr(flags),
	)
	if errno != 0 {
		return 0, errno
	}
	return int(fd), nil
}
