package hst

import (
	"errors"
	"strconv"
	"strings"

	"hakurei.app/check"
	"hakurei.app/ext"
)

// Config configures an application container.
type Config struct {
	// Reverse-DNS style configured arbitrary identifier string.
	//
	// This value is passed as is to Wayland security-context-v1 and used as
	// part of defaults in D-Bus session proxy. The zero value causes a default
	// value to be derived from the container instance.
	ID string `json:"id,omitempty"`

	// System services to make available in the container.
	Enablements *Enablements `json:"enablements,omitempty"`

	// Session D-Bus proxy configuration.
	//
	// Has no effect if [EDBus] but is not set in Enablements. The zero value
	// assumes built-in defaults derived from ID.
	SessionBus *BusConfig `json:"session_bus,omitempty"`
	// System D-Bus proxy configuration.
	//
	// Has no effect if [EDBus] but is not set in Enablements. The zero value
	// disables system bus proxy.
	SystemBus *BusConfig `json:"system_bus,omitempty"`

	// Direct access to Wayland socket, no attempt is made to attach
	// security-context-v1 and the bare socket is made available to the
	// container.
	//
	// This option is unsupported and will most likely enable full control over
	// the Wayland session from within the container. Do not set this to true
	// unless you are sure you know what you are doing.
	DirectWayland bool `json:"direct_wayland,omitempty"`

	// Direct access to the PipeWire socket established via SecurityContext::Create,
	// no attempt is made to start the pipewire-pulse server.
	//
	// The SecurityContext machinery is fatally flawed, it unconditionally sets
	// read and execute bits on all objects for clients with the lowest achievable
	// privilege level (by setting PW_KEY_ACCESS to "restricted" or by satisfying
	// all conditions of [the /.flatpak-info hack]). This enables them to call
	// any method targeting any object, and since Registry::Destroy checks for
	// the read and execute bit, allows the destruction of any object other than
	// PW_ID_CORE as well.
	//
	// This behaviour is implemented separately in media-session and wireplumber,
	// with the wireplumber implementation in Lua via an embedded Lua vm. In all
	// known setups, wireplumber is in use, and in that case, no option for
	// configuring this behaviour exists, without replacing the Lua script.
	// Also, since PipeWire relies on these permissions to work, reducing them
	// was never possible in the first place.
	//
	// Currently, the only other sandboxed use case is flatpak, which is not
	// aware of PipeWire and blindly exposes the bare PulseAudio socket to the
	// container (behaves like DirectPulse). This socket is backed by the
	// pipewire-pulse compatibility daemon, which obtains client pid via the
	// SO_PEERCRED option. The PipeWire daemon, pipewire-pulse daemon and the
	// session manager daemon then separately performs [the /.flatpak-info hack].
	// Under such use case, since the client has no direct access to PipeWire,
	// insecure parts of the protocol are obscured by the absence of an
	// equivalent API in PulseAudio, or pipewire-pulse simply not implementing
	// them.
	//
	// Hakurei does not rely on [the /.flatpak-info hack]. Instead, a socket is
	// sets up via SecurityContext. A pipewire-pulse server connected through it
	// achieves the same permissions as flatpak does via [the /.flatpak-info hack]
	// and is maintained for the life of the container.
	//
	// This option is unsupported and enables a denial-of-service attack as the
	// sandboxed client is able to destroy any client object and thus
	// disconnecting them from PipeWire, or destroy the SecurityContext object,
	// preventing any further container creation.
	//
	// Do not set this to true, it is insecure under any configuration.
	//
	// [the /.flatpak-info hack]: https://git.gensokyo.uk/rosa/hakurei/issues/21
	DirectPipeWire bool `json:"direct_pipewire,omitempty"`

	// Direct access to PulseAudio socket, no attempt is made to establish
	// pipewire-pulse server via a PipeWire socket with a SecurityContext
	// attached, and the bare socket is made available to the container.
	//
	// This option is unsupported and enables arbitrary code execution as the
	// PulseAudio server.
	//
	// Do not set this to true, it is insecure under any configuration.
	DirectPulse bool `json:"direct_pulse,omitempty"`

	// Extra acl updates to perform before setuid.
	ExtraPerms []ExtraPermConfig `json:"extra_perms,omitempty"`

	// Numerical application id, passed to hsu, used to derive init user
	// namespace credentials.
	Identity int `json:"identity"`
	// Init user namespace supplementary groups inherited by all container processes.
	Groups []string `json:"groups"`

	// Scheduling policy to set for the container.
	//
	// The zero value retains the current scheduling policy.
	SchedPolicy ext.SchedPolicy `json:"sched_policy,omitempty"`
	// Scheduling priority to set for the container.
	//
	// The zero value implies the minimum priority of the current SchedPolicy.
	// Has no effect if SchedPolicy is zero.
	SchedPriority ext.Int `json:"sched_priority,omitempty"`

	// High level configuration applied to the underlying [container].
	Container *ContainerConfig `json:"container"`
}

var (
	// ErrConfigNull is returned by [Config.Validate] for an invalid configuration
	// that contains a null value for any field that must not be null.
	ErrConfigNull = errors.New("unexpected null in config")

	// ErrIdentityBounds is returned by [Config.Validate] for an out of bounds
	// [Config.Identity] value.
	ErrIdentityBounds = errors.New("identity out of bounds")

	// ErrSchedPolicyBounds is returned by [Config.Validate] for an out of bounds
	// [Config.SchedPolicy] value.
	ErrSchedPolicyBounds = errors.New("scheduling policy out of bounds")

	// ErrEnviron is returned by [Config.Validate] if an environment variable
	// name contains '=' or NUL.
	ErrEnviron = errors.New("invalid environment variable name")

	// ErrInsecure is returned by [Config.Validate] if the configuration is
	// considered insecure.
	ErrInsecure = errors.New("configuration is insecure")
)

const (
	// VAllowInsecure allows use of compatibility options considered insecure
	// under any configuration, to work around ecosystem-wide flaws.
	VAllowInsecure = 1 << iota
)

// Validate checks [Config] and returns [AppError] if an invalid value is encountered.
func (config *Config) Validate(flags int) error {
	const step = "validate configuration"

	if config == nil {
		return &AppError{Step: step, Err: ErrConfigNull,
			Msg: "invalid configuration"}
	}

	// this is checked again in hsu
	if config.Identity < IdentityStart || config.Identity > IdentityEnd {
		return &AppError{Step: step, Err: ErrIdentityBounds,
			Msg: "identity " + strconv.Itoa(config.Identity) + " out of range"}
	}

	if config.SchedPolicy < 0 || config.SchedPolicy > ext.SCHED_LAST {
		return &AppError{Step: step, Err: ErrSchedPolicyBounds,
			Msg: "scheduling policy " +
				strconv.Itoa(int(config.SchedPolicy)) +
				" out of range"}
	}

	if err := config.SessionBus.CheckInterfaces("session"); err != nil {
		return err
	}
	if err := config.SystemBus.CheckInterfaces("system"); err != nil {
		return err
	}

	if config.Container == nil {
		return &AppError{Step: step, Err: ErrConfigNull,
			Msg: "configuration missing container state"}
	}
	if config.Container.Home == nil {
		return &AppError{Step: step, Err: ErrConfigNull,
			Msg: "container configuration missing path to home directory"}
	}
	if config.Container.Shell == nil {
		return &AppError{Step: step, Err: ErrConfigNull,
			Msg: "container configuration missing path to shell"}
	}
	if config.Container.Path == nil {
		return &AppError{Step: step, Err: ErrConfigNull,
			Msg: "container configuration missing path to initial program"}
	}

	for key := range config.Container.Env {
		if strings.IndexByte(key, '=') != -1 || strings.IndexByte(key, 0) != -1 {
			return &AppError{Step: step, Err: ErrEnviron,
				Msg: "invalid environment variable " + strconv.Quote(key)}
		}
	}

	et := config.Enablements.Unwrap()
	if !config.DirectPulse && et&EPulse != 0 {
		return &AppError{Step: step, Err: ErrInsecure,
			Msg: "enablement PulseAudio is insecure and no longer supported"}
	}

	if flags&VAllowInsecure == 0 {
		switch {
		case et&EWayland != 0 && config.DirectWayland:
			return &AppError{Step: step, Err: ErrInsecure,
				Msg: "direct_wayland is insecure and no longer supported"}

		case et&EPipeWire != 0 && config.DirectPipeWire:
			return &AppError{Step: step, Err: ErrInsecure,
				Msg: "direct_pipewire is insecure and no longer supported"}

		case et&EPulse != 0 && config.DirectPulse:
			return &AppError{Step: step, Err: ErrInsecure,
				Msg: "direct_pulse is insecure and no longer supported"}
		}
	}

	return nil
}

// ExtraPermConfig describes an acl update to perform before setuid.
type ExtraPermConfig struct {
	// Whether to create Path as a directory if it does not exist.
	Ensure bool `json:"ensure,omitempty"`
	// Pathname to act on.
	Path *check.Absolute `json:"path"`
	// Whether to set ACL_READ for the target user.
	Read bool `json:"r,omitempty"`
	// Whether to set ACL_WRITE for the target user.
	Write bool `json:"w,omitempty"`
	// Whether to set ACL_EXECUTE for the target user.
	Execute bool `json:"x,omitempty"`
}

// String returns a checked string representation of [ExtraPermConfig].
func (e *ExtraPermConfig) String() string {
	if e == nil || e.Path == nil {
		return "<invalid>"
	}
	buf := make([]byte, 0, 5+len(e.Path.String()))
	buf = append(buf, '-', '-', '-')
	if e.Ensure {
		buf = append(buf, '+')
	}
	buf = append(buf, ':')
	buf = append(buf, []byte(e.Path.String())...)
	if e.Read {
		buf[0] = 'r'
	}
	if e.Write {
		buf[1] = 'w'
	}
	if e.Execute {
		buf[2] = 'x'
	}
	return string(buf)
}
