// This file is part of the adequate Debian-native package, and is available
// under the Expat license. For the full terms please see debian/copyright.

package main

import (
	"encoding/xml"
	"fmt"
	"log"
	"os"
	osUser "os/user"
	"path"
	"path/filepath"
	"sort"
	"strings"
)

const (
	INVALID_SYSTEMD_USER_GROUP_TAG   = "invalid-systemd-user-or-group"
	INVALID_DBUS_USER_GROUP_TAG      = "invalid-dbus-user-or-group"
	INVALID_SYSV_INIT_USER_GROUP_TAG = "invalid-sysvinit-user-or-group"
)

var systemIDTags = []string{
	INVALID_SYSTEMD_USER_GROUP_TAG,
	INVALID_DBUS_USER_GROUP_TAG,
	INVALID_SYSV_INIT_USER_GROUP_TAG,
}

// systemID holds a unix user or group name.
type systemID struct {
	value string
	kind  idType
}

func (s systemID) isValid() bool {
	if s.kind == unsetIDType || s.value == "" {
		log.Fatalf("Unexpected systemID value: %#v", s)
	}

	switch s.kind {
	case userType:
		_, err := osUser.Lookup(s.value)
		return err == nil
	case groupType:
		_, err := osUser.LookupGroup(s.value)
		return err == nil
	}
	return true // should never get here
}

type idType int

const (
	unsetIDType idType = iota
	userType
	groupType
)

func (id idType) String() string {
	switch id {
	case userType:
		return "user"
	case groupType:
		return "group"
	}
	return ""
}

type invalidIDErr struct {
	pkg  string
	tag  string
	path string
	id   systemID
}

func (c invalidIDErr) Error() string {
	if c == (invalidIDErr{}) {
		return ""
	}
	return fmt.Sprintf("%s: %s %s %s=%s", c.pkg, c.tag, c.path, c.id.kind, c.id.value)
}

type systemIDChecker struct {
	tagsToEmit         map[string]bool
	doNotEmitAnyTag    bool
	extractSystemdIDs  runCommandType
	extractDbusIDs     runCommandType
	extractSysvinitIDs runCommandType
}

func newSystemIDChecker(tags tagFilter) systemIDChecker {
	emit := make(map[string]bool)
	var emitAny bool
	for _, t := range systemIDTags {
		emit[t] = tags.shouldEmit(t)
		if tags.shouldEmit(t) {
			emitAny = true
		}
	}

	return systemIDChecker{
		tagsToEmit:         emit,
		doNotEmitAnyTag:    !emitAny,
		extractSystemdIDs:  runCommand,
		extractDbusIDs:     runCommand,
		extractSysvinitIDs: runCommand,
	}
}

func (cc systemIDChecker) check(pkg2files map[string][]string) []invalidIDErr {
	if cc.doNotEmitAnyTag {
		return nil
	}

	// build map from file to pkg
	filePkg := make(map[string]string)
	for pkg, files := range pkg2files {
		for _, f := range files {
			filePkg[f] = pkg
		}
	}

	var errs []invalidIDErr
	for _, systemType := range []struct {
		tag    string
		loader func(map[string]string) []fileIDPair
	}{
		{INVALID_SYSTEMD_USER_GROUP_TAG, loadSystemdIDs},
		{INVALID_DBUS_USER_GROUP_TAG, loadDbusIDs},
		{INVALID_SYSV_INIT_USER_GROUP_TAG, loadSysvinitIDs},
	} {
		if !cc.tagsToEmit[systemType.tag] {
			continue
		}
		for _, pair := range systemType.loader(filePkg) {
			if pair.id.isValid() {
				continue
			}
			pkg, ok := filePkg[pair.path]
			if !ok {
				// Likely due to an uninstalled, non-purged package.
				continue
			}
			errs = append(errs, invalidIDErr{
				pkg:  pkg,
				tag:  systemType.tag,
				path: pair.path,
				id:   pair.id,
			})
		}
	}
	sort.Slice(errs, func(i, j int) bool {
		return (errs[i].pkg < errs[j].pkg &&
			errs[i].tag < errs[j].tag &&
			errs[i].id.kind < errs[j].id.kind)
	})
	return errs
}

type fileIDPair struct {
	path string
	id   systemID
}

// systemd

func loadSystemdIDs(fileAcceptList map[string]string) []fileIDPair {
	var res []fileIDPair
	files := glob("/usr/lib/systemd/system/*.service")
	for _, f := range files {
		if _, ok := fileAcceptList[f]; !ok {
			// Skip, unless a file belongs to a package we've been
			// asked to check.
			continue
		}
		buf, err := os.ReadFile(f)
		if err != nil {
			continue
		}
		res = append(res, extractSystemdIDsFromFile(f, buf)...)
	}
	return res
}

func extractSystemdIDsFromFile(filename string, contents []byte) []fileIDPair {
	var res []fileIDPair
	var inServiceSection bool
	for _, line := range strings.Split(string(contents), "\n") {
		if strings.TrimSpace(line) == "[Service]" {
			inServiceSection = true
		} else if strings.HasPrefix(line, "[") {
			inServiceSection = false
		}
		if !inServiceSection {
			continue
		}

		// systemd.syntax(7): [..]  configuration entries in the style
		// key=value. Whitespace immediately before or after the "=" is
		// ignored. Empty lines and lines starting with "#" or ";" are
		// ignored [..]
		var candidate string
		var kind idType
		if strings.HasPrefix(line, "User=") {
			u := strings.Split(line, "=")
			if len(u) >= 2 {
				candidate, kind = u[1], userType
			}
		} else if strings.HasPrefix(line, "Group=") {
			g := strings.Split(line, "=")
			if len(g) >= 2 {
				candidate, kind = g[1], groupType
			}
		} else {
			continue
		}

		// Strip leading and trailing space, single and double
		// quotes, and do not attempt to validate values with %
		// specifiers (see systemd.unit(5)) or with quoted characters.
		if v := strings.TrimSpace(strings.Trim(candidate, `"'`)); len(v) > 0 &&
			!strings.Contains(v, "%") && !strings.Contains(v, `\`) {

			res = append(res, fileIDPair{filename, systemID{value: candidate, kind: kind}})
		}
	}
	return res
}

// dbus

func loadDbusIDs(fileAcceptList map[string]string) []fileIDPair {
	var res []fileIDPair
	files := glob("/etc/dbus-*/*/*.conf")
	for _, f := range files {
		if _, ok := fileAcceptList[f]; !ok {
			// Skip, unless a file belongs to a package we've been
			// asked to check.
			continue
		}
		buf, err := os.ReadFile(f)
		if err != nil {
			continue
		}
		cfg := &dbusConfig{}
		if err := xml.Unmarshal(buf, cfg); err != nil {
			continue
		}
		for _, p := range cfg.Policy {
			if p.User != "" {
				res = append(res, fileIDPair{f, systemID{value: p.User, kind: userType}})
			}
			if p.Group != "" {
				res = append(res, fileIDPair{f, systemID{value: p.Group, kind: groupType}})
			}
		}
	}
	return res
}

type dbusConfig struct {
	Policy []Policy `xml:"policy"`
}

type Policy struct {
	User  string `xml:"user,attr"`
	Group string `xml:"group,attr"`
}

// sysvinit

func loadSysvinitIDs(fileAcceptList map[string]string) []fileIDPair {
	var res []fileIDPair
	files := glob("/etc/init.d/*")
	for _, f := range files {
		if _, ok := fileAcceptList[f]; !ok {
			// Skip, unless a file belongs to a package we've been
			// asked to check.
			continue
		}
		buf, err := os.ReadFile(f)
		if err != nil {
			continue
		}
		res = append(res, extractSysvinitIDsFromFile(f, buf)...)
	}
	return res
}

func extractSysvinitIDsFromFile(filename string, contents []byte) []fileIDPair {
	var res []fileIDPair
	for _, line := range strings.Split(string(contents), "\n") {
		if i := strings.Index(line, "#"); i != -1 {
			line = line[:i]
		}
		tokens := strings.Split(line, "=")
		if len(tokens) != 2 {
			continue
		}
		var kind idType
		switch k := strings.TrimSpace(tokens[0]); {
		// Assume that variables with a USER/GROUP suffix are used to
		// specify the user/group under which a service should run.
		case strings.HasSuffix(k, "USER"):
			kind = userType
		case strings.HasSuffix(k, "GROUP"):
			kind = groupType
		default:
			continue
		}
		v := strings.Trim(strings.TrimSpace(tokens[1]), `"'`)
		if strings.Contains(v, "$") {
			continue // let's not bother with evaluating shell ...
		}
		res = append(res, fileIDPair{filename, systemID{value: v, kind: kind}})
	}
	return res
}

// shared funcs

func glob(pat string) []string {
	files, err := filepath.Glob(pat)
	if err == path.ErrBadPattern {
		log.Fatalf("Bad glob pattern %q; please file a bug", pat)
	}
	return files
}
