diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index 35a9401ad6..2f374f814c 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -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" @@ -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 } @@ -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 +} diff --git a/pkg/util/pamparse/pamparse.go b/pkg/util/pamparse/pamparse.go new file mode 100644 index 0000000000..950b6dbe30 --- /dev/null +++ b/pkg/util/pamparse/pamparse.go @@ -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] +} diff --git a/pkg/util/pamparse/pamparse_test.go b/pkg/util/pamparse/pamparse_test.go new file mode 100644 index 0000000000..223e5bdae3 --- /dev/null +++ b/pkg/util/pamparse/pamparse_test.go @@ -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]) + } + } +}