// Copyright (c) 2020 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License-AGPL.txt in the project root for license information.

package iws

import (
	"bufio"
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/sirupsen/logrus"
	"golang.org/x/xerrors"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/encoding/protojson"

	"github.com/gitpod-io/gitpod/common-go/log"
	"github.com/gitpod-io/gitpod/ws-daemon/api"
	"github.com/gitpod-io/gitpod/ws-daemon/pkg/container"
)

// Uidmapper provides UID mapping services for creating Linux user namespaces
// from within a workspace.
type Uidmapper struct {
	Config  UidmapperConfig
	Runtime container.Runtime
}

// UidmapperConfig configures the UID mapper
type UidmapperConfig struct {
	// ProcLocation is the location of the node's proc filesystem
	ProcLocation string `json:"procLocation"`
	// RootRange is the range to which one can map the root (uid 0) user/group to
	RootRange UIDRange `json:"rootUIDRange"`
	// UserRange is the range to which any other user can be mapped to
	UserRange []UIDRange `json:"userUIDRange"`
}

// UIDRange represents a range of UID/GID's
type UIDRange struct {
	Start uint32 `json:"start"`
	Size  uint32 `json:"size"`
}

// Contains returns true if the other range is contained by this one
func (r UIDRange) Contains(start, size uint32) bool {
	if start < r.Start {
		return false
	}
	if size > r.Size {
		return false
	}
	return true
}

// HandleUIDMappingRequest performs a UID mapping request
func (m *Uidmapper) HandleUIDMappingRequest(ctx context.Context, req *api.WriteIDMappingRequest, containerID container.ID, instanceID string) (err error) {
	var reqjson []byte
	reqjson, err = protojson.Marshal(req)
	if err != nil {
		return err
	}

	fields := logrus.Fields{"req": string(reqjson), "containerID": containerID, "instanceId": instanceID}
	log.WithFields(fields).Info("received UID mapping request")

	err = m.validateMapping(req.Mapping)
	if err != nil {
		return err
	}

	containerPID, err := m.Runtime.ContainerPID(ctx, containerID)
	if err != nil {
		log.WithError(err).WithFields(fields).Error("handleUIDMappingRequest: cannot get containerPID")
		return status.Error(codes.Internal, "cannot establish mapping")
	}
	fields["containerPID"] = containerPID

	hostPID, err := m.findHostPID(uint64(containerPID), uint64(req.Pid))
	if err != nil {
		log.WithError(err).WithFields(fields).Error("handleUIDMappingRequest: cannot find PID on host")
		return status.Error(codes.InvalidArgument, "cannot find PID")
	}
	fields["hostPID"] = hostPID

	err = WriteMapping(hostPID, req.Gid, req.Mapping)
	if err != nil {
		log.WithError(err).WithFields(fields).Error("handleUIDMappingRequest: cannot write mapping")
		return status.Error(codes.FailedPrecondition, "cannot write mapping")
	}

	log.WithFields(fields).Info("established UID/GID mapping")

	return nil
}

func (m *Uidmapper) validateMapping(mapping []*api.WriteIDMappingRequest_Mapping) error {
	for _, mp := range mapping {
		if mp.ContainerId == 0 && !m.Config.RootRange.Contains(mp.HostId, mp.Size) {
			return status.Error(codes.InvalidArgument, "mapping for UID 0 is out of range")
		}
		if mp.ContainerId > 0 {
			var found bool
			for _, r := range m.Config.UserRange {
				if r.Contains(mp.HostId, mp.Size) {
					found = true
					break
				}
			}
			if !found {
				return status.Errorf(codes.InvalidArgument, "mapping for UID %d is out of range", mp.ContainerId)
			}
		}
	}
	return nil
}

// WriteMapping writes uid_map and gid_map
func WriteMapping(hostPID uint64, gid bool, mapping []*api.WriteIDMappingRequest_Mapping) (err error) {
	// Note: unlike shadow's newuidmap/newgidmap we do not set /proc/PID/setgroups to deny because:
	//    - we're writing from a privileged process, hence don't trip that restriction introduced in Linux 3.39
	//    - denying setgroups would prevent any meaningfull use of the NS mapped "root" user (e.g. breaks apt-get)

	var fc string
	for _, m := range mapping {
		fc += fmt.Sprintf("%d %d %d\n", m.ContainerId, m.HostId, m.Size)
	}

	var fn string
	if gid {
		fn = "gid_map"
	} else {
		fn = "uid_map"
	}

	pth := fmt.Sprintf("/proc/%d/%s", hostPID, fn)
	log.WithField("path", pth).WithField("fc", fc).Debug("attempting to write UID mapping")

	err = os.WriteFile(pth, []byte(fc), 0644)
	if err != nil {
		return xerrors.Errorf("cannot write UID/GID mapping: %w", err)
	}

	return nil
}

// findHosPID translates an in-container PID to the root PID namespace.
func (m *Uidmapper) findHostPID(containerPID, inContainerPID uint64) (uint64, error) {
	paths := []string{fmt.Sprint(containerPID)}
	seen := make(map[string]struct{})

	for {
		if len(paths) == 0 {
			return 0, fmt.Errorf("cannot find in-container PID %d on the node", inContainerPID)
		}

		p := paths[0]
		paths = paths[1:]

		if _, ok := seen[p]; ok {
			continue
		}
		seen[p] = struct{}{}

		p = filepath.Join(m.Config.ProcLocation, p)

		pid, nspid, err := readStatusFile(filepath.Join(p, "status"))
		if err != nil {
			log.WithField("file", filepath.Join(p, "status")).WithError(err).Error("findHostPID: cannot read PID file")
			continue
		}
		for _, nsp := range nspid {
			if nsp == inContainerPID {
				return pid, nil
			}
		}

		taskfn := filepath.Join(p, "task")
		tasks, err := os.ReadDir(taskfn)
		if err != nil {
			continue
		}
		for _, task := range tasks {
			cldrn, err := os.ReadFile(filepath.Join(taskfn, task.Name(), "children"))
			if err != nil {
				continue
			}
			paths = append(paths, strings.Fields(string(cldrn))...)
		}
	}
}

func readStatusFile(fn string) (pid uint64, nspid []uint64, err error) {
	f, err := os.Open(fn)
	if err != nil {
		return
	}
	defer f.Close()

	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		line := scanner.Text()
		if strings.HasPrefix(line, "Pid:") {
			pid, err = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "Pid:")), 10, 64)
			if err != nil {
				err = xerrors.Errorf("cannot parse pid in %s: %w", fn, err)
				return
			}
		}
		if strings.HasPrefix(line, "NSpid:") {
			fields := strings.Fields(strings.TrimSpace(strings.TrimPrefix(line, "NSpid:")))
			for _, fld := range fields {
				var npid uint64
				npid, err = strconv.ParseUint(fld, 10, 64)
				if err != nil {
					err = xerrors.Errorf("cannot parse NSpid %v in %s: %w", fld, fn, err)
					return
				}

				nspid = append(nspid, npid)
			}
		}
	}
	if err = scanner.Err(); err != nil {
		return
	}

	return
}
