// Mgmt
// Copyright (C) 2013-2024+ James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.

package resources

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"
	"unsafe"

	"github.com/purpleidea/mgmt/engine"
	"github.com/purpleidea/mgmt/engine/traits"
	engineUtil "github.com/purpleidea/mgmt/engine/util"
	"github.com/purpleidea/mgmt/util"
	"github.com/purpleidea/mgmt/util/errwrap"
	"github.com/purpleidea/mgmt/util/recwatch"

	sdbus "github.com/coreos/go-systemd/v22/dbus"
	"github.com/coreos/go-systemd/v22/unit"
	systemdUtil "github.com/coreos/go-systemd/v22/util"
	fstab "github.com/deniswernert/go-fstab"
	"github.com/godbus/dbus/v5"
	"golang.org/x/sys/unix"
)

func init() {
	engine.RegisterResource("mount", func() engine.Res { return &MountRes{} })
}

const (
	// procFilesystems is a file that lists all the valid filesystem types.
	procFilesystems = "/proc/filesystems"
	// procPath is the path to /proc/mounts which contains all active mounts.
	procPath = "/proc/mounts"
	// fstabPath is the path to the fstab file which defines mounts.
	fstabPath = "/etc/fstab"
	// fstabUmask is the umask (permissions) used to edit /etc/fstab.
	fstabUmask = 0644

	// getStatus64 is an ioctl command to get the status of file backed
	// loopback devices (i.e. iso file mounts.)
	getStatus64 = 0x4C05
	// loopFileUmask is the umask (permissions) used to read the loop file.
	loopFileUmask = 0660

	// devDisk is the path where disks and partitions can be found, organized
	// by uuid/label/path.
	devDisk = "/dev/disk/"
	// diskByUUID is the location of symlinks for devices by UUID.
	diskByUUID = devDisk + "by-uuid/"
	// diskByLabel is the location of symlinks for devices by label.
	diskByLabel = devDisk + "by-label/"
	// diskByUUID is the location of symlinks for partitions by UUID.
	diskByPartUUID = devDisk + "by-partuuid/"
	// diskByLabel is the location of symlinks for partitions by label.
	diskByPartLabel = devDisk + "by-partlabel/"

	// dbusSystemdService is the service to connect to systemd itself.
	dbusSystemd1Service = "org.freedesktop.systemd1"
	// dbusSystemd1Interface is the base systemd1 path.
	dbusSystemd1Path = "/org/freedesktop/systemd1"
	// dbusUnitPath is the dbus path where mount unit files are found.
	dbusUnitPath = dbusSystemd1Path + "/unit/"
	// dbusSystemd1Interface is the base systemd1 interface.
	dbusSystemd1Interface = "org.freedesktop.systemd1"
	// dbusMountInterface is used as an argument to filter dbus messages.
	dbusMountInterface = dbusSystemd1Interface + ".Mount"
	// dbusManagerInterface is the systemd manager interface used for
	// interfacing with systemd units.
	dbusManagerInterface = dbusSystemd1Interface + ".Manager"
	// dbusRestartUnit is the dbus method for restarting systemd units.
	dbusRestartUnit = dbusManagerInterface + ".RestartUnit"
	// dbusReloadSystemd is the dbus method for reloading systemd settings.
	// (i.e. systemctl daemon-reload)
	dbusReloadSystemd = dbusManagerInterface + ".Reload"
	// restartTimeout is the delay before restartUnit is assumed to have
	// failed.
	dbusRestartCtxTimeout = 10
	// dbusSignalJobRemoved is the name of the dbus signal that produces a
	// message when a dbus job is done (or has errored.)
	dbusSignalJobRemoved = "JobRemoved"
)

// MountRes is a systemd mount resource that adds/removes entries from
// /etc/fstab, and makes sure the defined device is mounted or unmounted
// accordingly. The mount point is set according to the resource's name.
type MountRes struct {
	traits.Base

	init *engine.Init

	// State must be exists or absent. If absent, remaining fields are
	// ignored.
	State string `lang:"state" yaml:"state"`

	// Device is the location of the device or image.
	Device string `lang:"device" yaml:"device"`

	// Type of the filesystem.
	Type string `lang:"type" yaml:"type"`

	// Options are mount options.
	Options map[string]string `lang:"options" yaml:"options"`

	// Freq is the dump frequency.
	Freq int `lang:"freq" yaml:"freq"`

	// PassNo is the verification order.
	PassNo int `lang:"passno" yaml:"passno"`

	mount *fstab.Mount // struct representing the mount
}

// Default returns some sensible defaults for this resource.
func (obj *MountRes) Default() engine.Res {
	return &MountRes{
		Options: defaultMntOps(),
	}
}

// Validate if the params passed in are valid data.
func (obj *MountRes) Validate() error {
	var err error

	// validate state
	if obj.State != "exists" && obj.State != "absent" {
		return fmt.Errorf("state must be 'exists', or 'absent'")
	}

	// validate type
	fs, err := os.ReadFile(procFilesystems)
	if err != nil {
		return errwrap.Wrapf(err, "error reading %s", procFilesystems)
	}
	fsSlice := strings.Fields(string(fs))
	for i, x := range fsSlice {
		if x == "nodev" {
			fsSlice = append(fsSlice[:i], fsSlice[i+1:]...)
		}
	}
	if obj.State != "absent" && !util.StrInList(obj.Type, fsSlice) {
		return fmt.Errorf("type must be a valid filesystem type (see /proc/filesystems)")
	}

	// validate mountpoint
	if strings.Contains(obj.Name(), "//") {
		return fmt.Errorf("double slashes are not allowed in resource name")
	}
	if err := unix.Access(obj.Name(), unix.R_OK); err != nil {
		return errwrap.Wrapf(err, "error validating mount point: %s", obj.Name())
	}

	// validate device
	device, err := evalSpec(obj.Device) // eval symlink
	if err != nil {
		return errwrap.Wrapf(err, "error evaluating spec: %s", obj.Device)
	}
	if err := unix.Access(device, unix.R_OK); err != nil {
		return errwrap.Wrapf(err, "error validating device: %s", device)
	}
	return nil
}

// Init runs some startup code for this resource.
func (obj *MountRes) Init(init *engine.Init) error {
	obj.init = init //save for later

	obj.mount = &fstab.Mount{
		Spec:    obj.Device,
		File:    obj.Name(),
		VfsType: obj.Type,
		MntOps:  obj.Options,
		Freq:    obj.Freq,
		PassNo:  obj.PassNo,
	}
	return nil
}

// Cleanup is run by the engine to clean up after the resource is done.
func (obj *MountRes) Cleanup() error {
	return nil
}

// Watch listens for signals from the mount unit associated with the resource.
// It also watch for changes to /etc/fstab, where mounts are defined.
func (obj *MountRes) Watch(ctx context.Context) error {
	// make sure systemd is running
	if !systemdUtil.IsRunningSystemd() {
		return fmt.Errorf("systemd is not running")
	}

	// establish a godbus connection
	conn, err := util.SystemBusPrivateUsable()
	if err != nil {
		return errwrap.Wrapf(err, "error establishing dbus connection")
	}
	defer conn.Close()

	// add a dbus rule to watch signals from the mount unit.
	args := fmt.Sprintf("type='signal', path='%s', arg0='%s'",
		dbusUnitPath+sdbus.PathBusEscape(unit.UnitNamePathEscape((obj.Name()+".mount"))),
		dbusMountInterface,
	)
	if call := conn.BusObject().Call(engineUtil.DBusAddMatch, 0, args); call.Err != nil {
		return errwrap.Wrapf(call.Err, "error creating dbus call")
	}
	defer conn.BusObject().Call(engineUtil.DBusRemoveMatch, 0, args) // ignore the error

	ch := make(chan *dbus.Signal)
	defer close(ch)

	conn.Signal(ch)
	defer conn.RemoveSignal(ch)

	// watch the fstab file
	recWatcher, err := recwatch.NewRecWatcher(fstabPath, false)
	if err != nil {
		return err
	}
	// close the recwatcher when we're done
	defer recWatcher.Close()

	obj.init.Running() // when started, notify engine that we're running

	var send bool
	var done bool
	for {
		select {
		case event, ok := <-recWatcher.Events():
			if !ok {
				if done {
					return nil
				}
				done = true
				continue
			}
			if err := event.Error; err != nil {
				return errwrap.Wrapf(err, "unknown recwatcher error")
			}
			if obj.init.Debug {
				obj.init.Logf("event(%s): %v", event.Body.Name, event.Body.Op)
			}

			send = true

		case event, ok := <-ch:
			if !ok {
				if done {
					return nil
				}
				done = true
				continue
			}
			if obj.init.Debug {
				obj.init.Logf("event: %+v", event)
			}

			send = true

		case <-ctx.Done(): // closed by the engine to signal shutdown
			return nil
		}

		// do all our event sending all together to avoid duplicate msgs
		if send {
			send = false
			obj.init.Event() // notify engine of an event (this can block)
		}
	}
}

// fstabCheckApply checks /etc/fstab for entries corresponding to the resource
// definition, and adds or deletes the entry as needed.
func (obj *MountRes) fstabCheckApply(ctx context.Context, apply bool) (bool, error) {
	exists, err := fstabEntryExists(fstabPath, obj.mount)
	if err != nil {
		return false, errwrap.Wrapf(err, "error checking if fstab entry exists")
	}

	// if everything is as it should be, we're done
	if (exists && obj.State == "exists") || (!exists && obj.State == "absent") {
		return true, nil
	}

	if !apply {
		return false, nil
	}
	obj.init.Logf("fstabCheckApply(%t)", apply)

	if obj.State == "exists" {
		if err := obj.fstabEntryAdd(fstabPath, obj.mount); err != nil {
			return false, errwrap.Wrapf(err, "error adding fstab entry: %+v", obj.mount)
		}
		return false, nil
	}
	if err := obj.fstabEntryRemove(fstabPath, obj.mount); err != nil {
		return false, errwrap.Wrapf(err, "error removing fstab entry: %+v", obj.mount)
	}
	return false, nil
}

// mountCheckApply checks if the defined resource is mounted, and mounts or
// unmounts it according to the defined state.
func (obj *MountRes) mountCheckApply(ctx context.Context, apply bool) (bool, error) {
	exists, err := mountExists(procPath, obj.mount)
	if err != nil {
		return false, errwrap.Wrapf(err, "error checking if mount exists")
	}

	// if everything is as it should be, we're done
	if (exists && obj.State == "exists") || (!exists && obj.State == "absent") {
		return true, nil
	}

	if !apply {
		return false, nil
	}
	obj.init.Logf("mountCheckApply(%t)", apply)

	if obj.State == "exists" {
		// Reload mounts from /etc/fstab by performing a `daemon-reload` and
		// restarting `local-fs.target` and `remote-fs.target` units.
		if err := mountReload(ctx); err != nil {
			return false, errwrap.Wrapf(err, "error reloading /etc/fstab")
		}
		return false, nil // we're done
	}
	// unmount the device
	if err := unix.Unmount(obj.Name(), 0); err != nil { // 0 means no flags
		return false, errwrap.Wrapf(err, "error unmounting %s", obj.Name())
	}
	return false, nil
}

// CheckApply is run to check the state and, if apply is true, to apply the
// necessary changes to reach the desired state. This is run before Watch and
// again if Watch finds a change occurring to the state.
func (obj *MountRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
	checkOK := true

	if c, err := obj.fstabCheckApply(ctx, apply); err != nil {
		return false, err
	} else if !c {
		checkOK = false
	}

	if c, err := obj.mountCheckApply(ctx, apply); err != nil {
		return false, err
	} else if !c {
		checkOK = false
	}

	return checkOK, nil
}

// Cmp compares two resources and return if they are equivalent.
func (obj *MountRes) Cmp(r engine.Res) error {
	// we can only compare MountRes to others of the same resource kind
	res, ok := r.(*MountRes)
	if !ok {
		return fmt.Errorf("not a %s", obj.Kind())
	}
	if obj.State != res.State {
		return fmt.Errorf("the State differs")
	}
	if obj.Type != res.Type {
		return fmt.Errorf("the Type differs")
	}
	if !strMapEq(obj.Options, res.Options) {
		return fmt.Errorf("the Options differ")
	}
	if obj.Freq != res.Freq {
		return fmt.Errorf("the Type differs")
	}
	if obj.PassNo != res.PassNo {
		return fmt.Errorf("the PassNo differs")
	}

	return nil
}

// MountUID is a unique resource identifier.
type MountUID struct {
	engine.BaseUID
	name string
}

// IFF aka if and only if they are equivalent, return true. If not, false.
func (obj *MountUID) IFF(uid engine.ResUID) bool {
	res, ok := uid.(*MountUID)
	if !ok {
		return false
	}
	return obj.name == res.name
}

// UIDs includes all params to make a unique identification of this object. Most
// resources only return one although some resources can return multiple.
func (obj *MountRes) UIDs() []engine.ResUID {
	x := &MountUID{
		BaseUID: engine.BaseUID{Name: obj.Name(), Kind: obj.Kind()},
		name:    obj.Name(),
	}
	return []engine.ResUID{x}
}

// UnmarshalYAML is the custom unmarshal handler for this struct. It is
// primarily useful for setting the defaults.
func (obj *MountRes) UnmarshalYAML(unmarshal func(interface{}) error) error {
	type rawRes MountRes // indirection to avoid infinite recursion

	def := obj.Default()       // get the default
	res, ok := def.(*MountRes) // put in the right format
	if !ok {
		return fmt.Errorf("could not convert to MountRes")
	}
	raw := rawRes(*res) // convert; the defaults go here

	if err := unmarshal(&raw); err != nil {
		return err
	}

	*obj = MountRes(raw) // restore from indirection with type conversion!
	return nil
}

// defaultMntOps returns a map that sets the default mount options for fstab
// mounts.
func defaultMntOps() map[string]string {
	return map[string]string{"defaults": ""}
}

// strMapEq returns true, if and only if the two provided maps are identical.
func strMapEq(x, y map[string]string) bool {
	if len(x) != len(y) {
		return false
	}
	for k, v := range x {
		if val, ok := x[k]; !ok || v != val {
			return false
		}
	}
	return true
}

// fstabEntryExists checks whether or not a given mount exists in the provided
// fstab file.
func fstabEntryExists(file string, mount *fstab.Mount) (bool, error) {
	mounts, err := fstab.ParseFile(file)
	if err != nil {
		return false, errwrap.Wrapf(err, "error parsing file: %s", file)
	}
	for _, m := range mounts {
		if m.Equals(mount) {
			return true, nil
		}
	}
	return false, nil
}

// fstabEntryAdd adds the given mount to the provided fstab file.
func (obj *MountRes) fstabEntryAdd(file string, mount *fstab.Mount) error {
	mounts, err := fstab.ParseFile(file)
	if err != nil {
		return errwrap.Wrapf(err, "error parsing file: %s", file)
	}
	for _, m := range mounts {
		// if the entry exists, we're done
		if m.Equals(mount) {
			return nil
		}
	}
	// mount does not exist so we need to add it
	mounts = append(mounts, mount)
	return obj.fstabWrite(file, mounts)
}

// fstabEntryRemove removes the given mount from the provided fstab file.
func (obj *MountRes) fstabEntryRemove(file string, mount *fstab.Mount) error {
	mounts, err := fstab.ParseFile(file)
	if err != nil {
		return errwrap.Wrapf(err, "error parsing file: %s", file)
	}
	for i, m := range mounts {
		// remove any entry with the defined mountpoint
		if m.File == mount.File {
			mounts = append(mounts[:i], mounts[i+1:]...)
		}
	}
	return obj.fstabWrite(file, mounts)
}

// fstabWrite generates an fstab file with the given mounts, and writes them to
// the provided fstab file.
func (obj *MountRes) fstabWrite(file string, mounts fstab.Mounts) error {
	// build the file contents
	contents := fmt.Sprintf("# Generated by %s at %d", obj.init.Program, time.Now().UnixNano()) + "\n"
	contents = contents + mounts.String() + "\n"
	// write the file
	if err := os.WriteFile(file, []byte(contents), fstabUmask); err != nil {
		return errwrap.Wrapf(err, "error writing fstab file: %s", file)
	}
	return nil
}

// mountExists returns true, if a given mount exists in the given file
// (typically /proc/mounts.)
func mountExists(file string, mount *fstab.Mount) (bool, error) {
	var err error
	m := *mount // make a copy so we don't change the definition

	// resolve the device's symlink if there is one
	if m.Spec, err = evalSpec(mount.Spec); err != nil {
		return false, errwrap.Wrapf(err, "error evaluating spec: %s", mount.Spec)
	}

	// get all mounts
	mounts, err := fstab.ParseFile(file)
	if err != nil {
		return false, errwrap.Wrapf(err, "error parsing file: %s", file)
	}
	// check for the defined mount
	for _, p := range mounts {
		found, err := mountCompare(&m, p)
		if err != nil {
			return false, errwrap.Wrapf(err, "mounts could not be compared: %s and %s", mount.String(), p.String())
		}
		if found {
			return true, nil
		}
	}
	return false, nil
}

// mountCompare compares two mounts. It is assumed that the first comes from a
// resource definition, and the second comes from /proc/mounts. It compares the
// two after resolving the loopback device's file path (if necessary,) and
// ignores freq and passno, as they may differ between the definition and
// /proc/mounts.
func mountCompare(def, proc *fstab.Mount) (bool, error) {
	if def.Equals(proc) {
		return true, nil
	}
	if def.File != proc.File {
		return false, nil
	}
	if def.Spec != "" {
		procSpec, err := loopFilePath(proc.Spec)
		if err != nil {
			return false, err
		}
		if def.Spec != procSpec {
			return false, nil
		}
	}
	if !strMapEq(def.MntOps, defaultMntOps()) && !strMapEq(def.MntOps, proc.MntOps) {
		return false, nil
	}
	if def.VfsType != "" && def.VfsType != proc.VfsType {
		return false, nil
	}
	return true, nil
}

// mountReload performs a daemon-reload and restarts fs-local.target and
// fs-remote.target, to let systemd mount any new entries in /etc/fstab.
func mountReload(ctx context.Context) error {
	// establish a godbus connection
	conn, err := util.SystemBusPrivateUsable()
	if err != nil {
		return errwrap.Wrapf(err, "error establishing dbus connection")
	}
	defer conn.Close()
	// systemctl daemon-reload
	call := conn.Object(dbusSystemd1Service, dbusSystemd1Path).Call(dbusReloadSystemd, 0)
	if call.Err != nil {
		return errwrap.Wrapf(call.Err, "error reloading systemd")
	}

	// systemctl restart local-fs.target
	if err := restartUnit(ctx, conn, "local-fs.target"); err != nil {
		return errwrap.Wrapf(err, "error restarting unit")
	}

	// systemctl restart remote-fs.target
	if err := restartUnit(ctx, conn, "remote-fs.target"); err != nil {
		return errwrap.Wrapf(err, "error restarting unit")
	}

	return nil
}

// restartUnit restarts the given dbus unit and waits for it to finish starting
// up. If restartTimeout is exceeded, it will return an error.
func restartUnit(ctx context.Context, conn *dbus.Conn, unit string) error {
	// timeout if we don't get the JobRemoved event
	ctx, cancel := context.WithTimeout(ctx, dbusRestartCtxTimeout*time.Second)
	defer cancel()

	// Add a dbus rule to watch the systemd1 JobRemoved signal used to wait
	// until the restart job completes.
	args := fmt.Sprintf("type='signal', path='%s', interface='%s', member='%s', arg2='%s'",
		dbusSystemd1Path,
		dbusManagerInterface,
		dbusSignalJobRemoved,
		unit,
	)
	if call := conn.BusObject().Call(engineUtil.DBusAddMatch, 0, args); call.Err != nil {
		return errwrap.Wrapf(call.Err, "error creating dbus call")
	}
	defer conn.BusObject().Call(engineUtil.DBusRemoveMatch, 0, args) // ignore the error

	// channel for godbus connection
	ch := make(chan *dbus.Signal)
	defer close(ch)

	conn.Signal(ch)
	defer conn.RemoveSignal(ch)

	// restart the unit
	sd1 := conn.Object(dbusSystemd1Service, dbus.ObjectPath(dbusSystemd1Path))
	if call := sd1.Call(dbusRestartUnit, 0, unit, "fail"); call.Err != nil {
		return errwrap.Wrapf(call.Err, "error restarting unit: %s", unit)
	}

	// wait for the job to be removed, indicating completion
	select {
	case event, ok := <-ch:
		if !ok {
			return fmt.Errorf("channel closed unexpectedly")
		}
		if event.Body[3] != "done" {
			return fmt.Errorf("unexpected job status: %s", event.Body[3])
		}
	case <-ctx.Done():
		return fmt.Errorf("restarting %s failed due to context timeout", unit)
	}
	return nil
}

// evalSpec resolves the device from the supplied spec, i.e. it follows the
// symlink, if any, from the provided uuid, label, or path.
func evalSpec(spec string) (string, error) {
	var path string
	m := &fstab.Mount{}
	m.Spec = spec

	switch m.SpecType() {
	case fstab.UUID:
		path = diskByUUID + m.SpecValue()
	case fstab.Label:
		path = diskByLabel + m.SpecValue()
	case fstab.PartUUID:
		path = diskByPartUUID + m.SpecValue()
	case fstab.PartLabel:
		path = diskByPartLabel + m.SpecValue()
	case fstab.Path:
		path = m.SpecValue()
	default:
		return "", fmt.Errorf("unexpected spec type: %v", m.SpecType())
	}

	return filepath.EvalSymlinks(path)
}

// loopFilePath returns the file path of the mounted filesystem image, backing
// the given loopback device.
func loopFilePath(spec string) (string, error) {
	// if it's not a loopback device, return the input
	if !strings.Contains(spec, "/dev/loop") {
		return spec, nil
	}
	info, err := getLoopInfo(spec)
	if err != nil {
		return "", errwrap.Wrapf(err, "error getting loop info")
	}
	// trim the extra null chars off the end of the filename
	return string(bytes.Trim(info.FileName[:], "\x00")), nil
}

// loopInfo is a datastructure that holds relevant information about a file
// backed loopback device. Code is based on freddierice/go-losetup.
type loopInfo struct {
	Device         uint64
	INode          uint64
	RDevice        uint64
	Offset         uint64
	SizeLimit      uint64
	Number         uint32
	EncryptType    uint32
	EncryptKeySize uint32
	Flags          uint32
	FileName       [64]byte
	CryptName      [64]byte
	EncryptKey     [32]byte
	Init           [2]uint64
}

// getLoopInfo returns a loopInfo struct containing information about the
// provided file backed loopback device.
func getLoopInfo(loop string) (*loopInfo, error) {
	// open the loop file
	f, err := os.OpenFile(loop, 0, loopFileUmask)
	if err != nil {
		return nil, fmt.Errorf("error opening %s: %s", loop, err)
	}
	defer f.Close()

	// deserialize the contents
	retInfo := &loopInfo{}
	_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), getStatus64, uintptr(unsafe.Pointer(retInfo)))
	if errno == unix.ENXIO {
		return nil, fmt.Errorf("device not backed by a file")
	} else if errno != 0 {
		return nil, fmt.Errorf("error getting info about %s (errno: %d)", loop, errno)
	}

	return retInfo, nil
}
