small pixel drawing of a pufferfish zoa

env/env.go

package env

import (
	"bufio"
	"errors"
	"fmt"
	"log"
	"os"
	"os/exec"
	"strings"

	"mvdan.cc/sh/v3/expand"
)

func GenerateEnv() (expand.Environ, error) {
	var env expand.Environ

	// $PATH is annoyingly non-standard, so we hardcode the var
	// to the binary paths described in the fhs standard.
	// in most cases, this will "just work" for people, but special
	// cases should be evaluated - this may need some adjustment in the future.
	// i am resistant to making it over-rideable.
	//
	// users can always call their special binaries with their full paths
	// if they are resistant to moving them for some reason.
	path := envString("PATH", "/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin")

	uname, err := getUname()
	if err != nil {
		log.Fatal(err)
	}
	unameOS := envString("OS", uname.Sysname)
	unameRelease := envString("RELEASE", uname.Release)
	unameArch := envString("ARCH", uname.Arch)
	unameNodename := envString("NODENAME", uname.Nodename)

	// jes rant: standards are extremely annoying about hostnames.
	//
	// "Note that there is no standard that
	// says that the hostname set by sethostname(2)
	// is the same string as the nodename field of
	// the struct returned by uname() (indeed, some
	// systems allow a 256-byte hostname and an 8-byte
	// nodename), but this is true on Linux. The same
	// holds for setdomainname(2) and the domainname field."
	//
	// in practice, there's usually not a difference between hostname
	// and NODENAME, so i've chosen to expose the NODENAME variable for the
	// sake of simplicity (most users expect this).
	//
	// if this becomes an issue, i'll revisit it. i doubt it though.
	// tldr: i'm ignoring that golang's os.Hostname() implementation
	// isn't standards-compliant by the letter of the law.
	// in actual practice, the hostname and nodename
	// are always identical in every case i've observed.
	//
	// and i'm exposing only 1 because otherwise things get annoying.
	// shrug.

	// !OS_RELEASE_* VARS ARE NOT STANDARDS-BACKED!
	// OS_* vars may or may not exist depending on the distro in question, so
	// they're not reliable _at all_. they're scraped from /etc/os-release
	// and are useful for identifying specific Linux distros, or their versions.
	//
	// if you rely on these variables, I highly suggest checking for their
	// existence with test -z before utilizing them. there be no standards here.

	os_release, err := getOSRelease()
	if err != nil {
		return env, err
	}
	osReleaseID := envString("OS_RELEASE_ID", os_release.ID)
	osReleaseVersionID := envString("OS_RELEASE_VERSION_ID", os_release.VersionID)

	env = expand.ListEnviron(path,
		unameOS, unameNodename, unameRelease, unameArch,
		osReleaseID, osReleaseVersionID)

	return env, nil
}

func envString(key string, value string) string {
	return fmt.Sprintf("%s=%s", key, value)
}

// uname_os, uname_hostname, uname_release, uname_arch
type Uname struct {
	Sysname  string
	Nodename string
	Release  string
	Arch     string
}

func getUname() (Uname, error) {
	var uname Uname
	out, err := exec.Command("uname", "-m").Output()
	if err != nil {
		return uname, err
	}
	uname.Arch = strings.TrimSuffix(string(out), "\n")

	out, err = exec.Command("uname", "-n").Output()
	if err != nil {
		return uname, err
	}
	uname.Nodename = strings.TrimSuffix(string(out), "\n")

	out, err = exec.Command("uname", "-r").Output()
	if err != nil {
		return uname, err
	}
	uname.Release = strings.TrimSuffix(string(out), "\n")

	out, err = exec.Command("uname", "-s").Output()
	if err != nil {
		return uname, err
	}
	uname.Sysname = strings.TrimSuffix(string(out), "\n")

	return uname, nil
}

// I want to keep this list as small as possible, since
// this struct is unreliable.
type OSRelease struct {
	ID        string // distro name - "arch"
	VersionID string // for debian distros, this is set to "22.04"
}

// getOsRelease parses /etc/os-release data
// into a struct
func getOSRelease() (OSRelease, error) {
	var osr = OSRelease{}
	f, err := os.Open("/etc/os-release")
	if errors.Is(err, os.ErrNotExist) {
		return osr, nil
	}
	if err != nil {
		return osr, err
	}
	defer f.Close()

	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		key, value, _ := strings.Cut(scanner.Text(), "=")
		value = strings.Trim(value, `"`)
		switch key {
		case "ID":
			osr.ID = value
		case "VERSION_ID":
			osr.VersionID = value
		}
	}

	return osr, nil
}