Skip to content
Merged
61 changes: 61 additions & 0 deletions pkg/shellexec/shellexec.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/wavetermdev/waveterm/pkg/panichandler"
"github.com/wavetermdev/waveterm/pkg/remote"
"github.com/wavetermdev/waveterm/pkg/remote/conncontroller"
"github.com/wavetermdev/waveterm/pkg/util/pamparse"
"github.com/wavetermdev/waveterm/pkg/util/shellutil"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
"github.com/wavetermdev/waveterm/pkg/wavebase"
Expand Down Expand Up @@ -452,6 +453,30 @@ func StartShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOpt
ecmd = exec.Command(shellPath, shellOpts...)
ecmd.Env = os.Environ()
}

/*
For Snap installations, we need to correct the XDG environment variables as Snap
overrides them to point to snap directories. We will get the correct values, if
set, from the PAM environment. If the XDG variables are set in profile or in an
RC file, it will be overridden when the shell initializes.
*/
if os.Getenv("SNAP") != "" {
varsToReplace := map[string]string{"XDG_CONFIG_HOME": "", "XDG_DATA_HOME": "", "XDG_CACHE_HOME": "", "XDG_RUNTIME_DIR": "", "XDG_CONFIG_DIRS": "", "XDG_DATA_DIRS": ""}
pamEnvs := tryGetPamEnvVars()
log.Printf("PAM environment: %v", pamEnvs)
if len(pamEnvs) > 0 {
// We only want to set the XDG variables from the PAM environment, all others should already be correct or may have been overridden by something else out of our control
for k := range pamEnvs {
if _, ok := varsToReplace[k]; ok {
log.Printf("Setting %s to %s", k, pamEnvs[k])
varsToReplace[k] = pamEnvs[k]
}
}
}
log.Printf("Replacing XDG environment variables: %v", varsToReplace)
shellutil.UpdateCmdEnv(ecmd, varsToReplace)
}

if cmdOpts.Cwd != "" {
ecmd.Dir = cmdOpts.Cwd
}
Expand Down Expand Up @@ -512,3 +537,39 @@ func RunSimpleCmdInPty(ecmd *exec.Cmd, termSize waveobj.TermSize) ([]byte, error
<-ioDone
return outputBuf.Bytes(), nil
}

const etcEnvironmentPath = "/etc/environment"
const etcSecurityPath = "/etc/security/pam_env.conf"
const userEnvironmentPath = "~/.pam_environment"

/*
tryGetPamEnvVars tries to get the environment variables from /etc/environment,
/etc/security/pam_env.conf, and ~/.pam_environment.

It then returns a map of the environment variables, overriding duplicates with
the following order of precedence:
1. /etc/environment
2. /etc/security/pam_env.conf
3. ~/.pam_environment
*/
func tryGetPamEnvVars() map[string]string {
envVars, err := pamparse.ParseEnvironmentFile(etcEnvironmentPath)
if err != nil {
log.Printf("error parsing %s: %v", etcEnvironmentPath, err)
}
envVars2, err := pamparse.ParseEnvironmentConfFile(etcSecurityPath)
if err != nil {
log.Printf("error parsing %s: %v", etcSecurityPath, err)
}
envVars3, err := pamparse.ParseEnvironmentConfFile(wavebase.ExpandHomeDirSafe(userEnvironmentPath))
if err != nil {
log.Printf("error parsing %s: %v", userEnvironmentPath, err)
}
for k, v := range envVars2 {
envVars[k] = v
}
for k, v := range envVars3 {
envVars[k] = v
}
return envVars
}
147 changes: 147 additions & 0 deletions pkg/util/pamparse/pamparse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

// Package pamparse provides functions for parsing environment files in the format of /etc/environment, /etc/security/pam_env.conf, and ~/.pam_environment.
package pamparse

import (
"bufio"
"fmt"
"os"
"regexp"
"strings"
)

// Parses a file in the format of /etc/environment. Accepts a path to the file and returns a map of environment variables.
func ParseEnvironmentFile(path string) (map[string]string, error) {
rtn := make(map[string]string)
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
key, val := parseEnvironmentLine(line)
if key == "" {
continue
}
rtn[key] = val
}
return rtn, nil
}

// Parses a file in the format of /etc/security/pam_env.conf or ~/.pam_environment. Accepts a path to the file and returns a map of environment variables.
func ParseEnvironmentConfFile(path string) (map[string]string, error) {
rtn := make(map[string]string)
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
home, shell, err := parsePasswd()
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
key, val := parseEnvironmentConfLine(line)

// Fall back to ParseEnvironmentLine if ParseEnvironmentConfLine fails
if key == "" {
key, val = parseEnvironmentLine(line)
if key == "" {
continue
}
}
rtn[key] = replaceHomeAndShell(val, home, shell)
}
return rtn, nil
}

// Gets the home directory and shell from /etc/passwd for the current user.
func parsePasswd() (string, string, error) {
file, err := os.Open("/etc/passwd")
if err != nil {
return "", "", err
}
defer file.Close()
userPrefix := fmt.Sprintf("%s:", os.Getenv("USER"))
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, userPrefix) {
parts := strings.Split(line, ":")
if len(parts) < 7 {
return "", "", fmt.Errorf("invalid passwd entry: insufficient fields")
}
return parts[5], parts[6], nil
}
}
if err := scanner.Err(); err != nil {
return "", "", fmt.Errorf("error reading passwd file: %w", err)
}
return "", "", nil
}

// Replaces @{HOME} and @{SHELL} placeholders in a string with the provided values. Follows guidance from https://wiki.archlinux.org/title/Environment_variables#Using_pam_env
func replaceHomeAndShell(val string, home string, shell string) string {
val = strings.ReplaceAll(val, "@{HOME}", home)
val = strings.ReplaceAll(val, "@{SHELL}", shell)
return val
}

// Regex to parse a line from /etc/environment. Follows the guidance from https://wiki.archlinux.org/title/Environment_variables#Using_pam_env
var envFileLineRe = regexp.MustCompile(`^(?:export\s+)?([A-Z0-9_]+[A-Za-z0-9]*)=(.*)$`)

func parseEnvironmentLine(line string) (string, string) {
m := envFileLineRe.FindStringSubmatch(line)
if m == nil {
return "", ""
}
return m[1], sanitizeEnvVarValue(m[2])
}

// Regex to parse a line from /etc/security/pam_env.conf or ~/.pam_environment. Follows the guidance from https://wiki.archlinux.org/title/Environment_variables#Using_pam_env
var confFileLineRe = regexp.MustCompile(`^([A-Z0-9_]+[A-Za-z0-9]*)\s+(?:(?:DEFAULT=)([^\s]+(?: \w+)*))\s*(?:(?:OVERRIDE=)([^\s]+(?: \w+)*))?\s*$`)

func parseEnvironmentConfLine(line string) (string, string) {
m := confFileLineRe.FindStringSubmatch(line)
if m == nil {
return "", ""
}
var vals []string
if len(m) > 3 && m[3] != "" {
vals = []string{sanitizeEnvVarValue(m[3]), sanitizeEnvVarValue(m[2])}
} else {
vals = []string{sanitizeEnvVarValue(m[2])}
}
return m[1], strings.Join(vals, ":")
}

// Sanitizes an environment variable value by stripping comments and trimming quotes.
func sanitizeEnvVarValue(val string) string {
return stripComments(trimQuotes(val))
}

// Trims quotes as defined by https://unix.stackexchange.com/questions/748790/where-is-the-syntax-for-etc-environment-documented
func trimQuotes(val string) string {
if strings.HasPrefix(val, "\"") || strings.HasPrefix(val, "'") {
val = val[1:]
if strings.HasSuffix(val, "\"") || strings.HasSuffix(val, "'") {
val = val[0 : len(val)-1]
}
}
return val
}

// Strips comments as defined by https://unix.stackexchange.com/questions/748790/where-is-the-syntax-for-etc-environment-documented
func stripComments(val string) string {
commentIdx := strings.Index(val, "#")
if commentIdx == -1 {
return val
}
return val[0:commentIdx]
}
102 changes: 102 additions & 0 deletions pkg/util/pamparse/pamparse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

package pamparse_test

import (
"os"
"path/filepath"
"testing"

"github.com/wavetermdev/waveterm/pkg/util/pamparse"
)

// Tests influenced by https://unix.stackexchange.com/questions/748790/where-is-the-syntax-for-etc-environment-documented
func TestParseEnvironmentFile(t *testing.T) {
const fileContent = `
FOO1=bar
FOO2="bar"
FOO3="bar
FOO4=bar"
FOO5='bar'
FOO6='bar"
export FOO7=bar
FOO8=bar bar bar
#FOO9=bar
FOO10=$PATH
FOO11="foo#bar"
`

// create a temporary file with the content
tempFile := filepath.Join(t.TempDir(), "pam_env")
if err := os.WriteFile(tempFile, []byte(fileContent), 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}

// parse the file
got, err := pamparse.ParseEnvironmentFile(tempFile)
if err != nil {
t.Fatalf("failed to parse pam environment file: %v", err)
}

want := map[string]string{
"FOO1": "bar",
"FOO2": "bar",
"FOO3": "bar",
"FOO4": "bar\"",
"FOO5": "bar",
"FOO6": "bar",
"FOO7": "bar",
"FOO8": "bar bar bar",
"FOO10": "$PATH",
"FOO11": "foo",
}

if len(got) != len(want) {
t.Fatalf("expected %d environment variables, got %d", len(want), len(got))
}
for k, v := range want {
if got[k] != v {
t.Errorf("expected %q to be %q, got %q", k, v, got[k])
}
}
}

func TestParseEnvironmentConfFile(t *testing.T) {
const fileContent = `
TEST DEFAULT=@{HOME}/.config\ state OVERRIDE=./config\ s
FOO DEFAULT=@{HOME}/.config\ s
STRING DEFAULT="string"
STRINGOVERRIDE DEFAULT="string" OVERRIDE="string2"
FOO11="foo#bar"
`

// create a temporary file with the content
tempFile := filepath.Join(t.TempDir(), "pam_env_conf")
if err := os.WriteFile(tempFile, []byte(fileContent), 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}

// parse the file
got, err := pamparse.ParseEnvironmentConfFile(tempFile)
if err != nil {
t.Fatalf("failed to parse pam environment conf file: %v", err)
}

want := map[string]string{
"TEST": "./config\\ s:@{HOME}/.config\\ state",
"FOO": "@{HOME}/.config\\ s",
"STRING": "string",
"STRINGOVERRIDE": "string2:string",
"FOO11": "foo",
}

if len(got) != len(want) {
t.Fatalf("expected %d environment variables, got %d", len(want), len(got))
}
for k, v := range want {
if got[k] != v {
t.Errorf("expected %q to be %q, got %q", k, v, got[k])
}
}
}
Loading