diff --git a/.github/workflows/pmg-e2e.yml b/.github/workflows/pmg-e2e.yml index 9add5b56..357e247b 100644 --- a/.github/workflows/pmg-e2e.yml +++ b/.github/workflows/pmg-e2e.yml @@ -289,7 +289,10 @@ jobs: run: | echo "Testing PNPM single package installation..." PNPM_TESTDIR=$(mktemp -d) && cd "$PNPM_TESTDIR" - pmg --proxy-mode=false pnpm init + # Avoid `pnpm init`: it writes devEngines.packageManager with + # onFail:download, and the following `pnpm add` then crashes with + # "Cannot use 'in' operator to search for 'integrity' in undefined". + npm init -y pmg --proxy-mode=false pnpm add express@5.2.1 pmg --proxy-mode=false pnpm add lodash@4.17.21 @@ -874,3 +877,165 @@ jobs: - name: Run Package Manager E2E Test run: pmg --sandbox --sandbox-enforce npm exec -- node test/pm-e2e.js + + # Linux system-wide install: root install, ENV PATH (Docker-style), non-root user, + # managed config, and remove. Profile.d login shells are covered by sourcing the snippet. + e2e-system-install: + name: PMG E2E - System Install (Linux) + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + shell: bash + steps: + - name: Checkout Source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + cache: true + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - name: Build PMG + run: make + + - name: Reject private PMG binary for system install + run: | + sudo mkdir -p /root/pmg-private + sudo cp bin/pmg /root/pmg-private/pmg + sudo chmod 700 /root/pmg-private /root/pmg-private/pmg + if sudo /root/pmg-private/pmg setup install --system; then + echo "ERROR: system install accepted a non-world-executable binary" + exit 1 + fi + echo "SUCCESS: private binary rejected" + + - name: Reject user-owned PMG binary for system install + run: | + mkdir -p "$HOME/pmg-user-writable" + cp bin/pmg "$HOME/pmg-user-writable/pmg" + chmod 755 "$HOME/pmg-user-writable/pmg" + if sudo "$HOME/pmg-user-writable/pmg" setup install --system; then + echo "ERROR: system install accepted a user-owned binary" + exit 1 + fi + echo "SUCCESS: user-owned binary rejected" + + - name: Reject unreachable PMG binary for system install + run: | + # Binary is 0755 and root-owned, but sits under a 0700 dir: other + # users cannot traverse to it, so every shim would exit 127. + sudo mkdir -p /root/pmg-unreachable + sudo install -m 755 bin/pmg /root/pmg-unreachable/pmg + sudo chmod 700 /root/pmg-unreachable + if sudo /root/pmg-unreachable/pmg setup install --system; then + echo "ERROR: system install accepted a binary under a non-searchable directory" + exit 1 + fi + echo "SUCCESS: unreachable binary rejected" + + - name: Install PMG system-wide + run: | + # GitHub runners ship /usr/local/bin world-writable; system install + # (correctly) requires a root-owned, non-world-writable dir for the + # shared binary. Normalize to the standard production perms first. + ls -ld /usr/local/bin + sudo chown root:root /usr/local/bin + sudo chmod 755 /usr/local/bin + sudo install -m 755 bin/pmg /usr/local/bin/pmg + sudo pmg setup install --system + + - name: Verify system install artifacts + run: | + test -f /etc/safedep/pmg/config.yml + test -f /etc/profile.d/pmg.sh + grep -q '/usr/local/lib/pmg/bin' /etc/profile.d/pmg.sh + for shim in npm pip pip3 pipx pnpm bun uv uvx yarn poetry npx pnpx; do + test -x "/usr/local/lib/pmg/bin/$shim" || { echo "Missing shim: $shim"; exit 1; } + done + + - name: Root runs keep per-user state out of the invoking user's home + run: | + # GitHub runner sudo preserves HOME. Every sudo pmg run above used to + # create root-owned ~/.config/safedep for the runner user, which + # fail-closes all their later pmg/npm runs. Must run before any + # non-root pmg invocation legitimately creates that directory. + sudo sh -c 'echo "sudo sees HOME=$HOME"' + if [ -e "$HOME/.config/safedep" ]; then + echo "ERROR: root-created state leaked into $HOME/.config/safedep" + ls -laR "$HOME/.config/safedep" + exit 1 + fi + sudo test -d /root/.config/safedep/pmg/logs + echo "SUCCESS: root state stayed under /root" + + - name: PATH and profile.d activate shims + run: | + # Docker-style: non-login shells need PATH (or source profile.d) + export PATH="/usr/local/lib/pmg/bin:$PATH" + which npm | grep -q '/usr/local/lib/pmg/bin/npm' + source /etc/profile.d/pmg.sh + which npm | grep -q '/usr/local/lib/pmg/bin/npm' + + - name: Managed config refuses CLI mutation + run: | + # Assert the refusal reason: a permission-denied brick (poisoned home) + # would also make config set fail and mask a regression. + if out=$(pmg config set dependency_cooldown.days 7 2>&1); then + echo "ERROR: config set should fail under system config" + exit 1 + fi + echo "$out" | grep -qi 'globally managed' || { echo "ERROR: failed for the wrong reason:"; echo "$out"; exit 1; } + if out=$(sudo pmg config set dependency_cooldown.days 7 2>&1); then + echo "ERROR: config set should fail under system config even as root" + exit 1 + fi + echo "$out" | grep -qi 'globally managed' || { echo "ERROR: root run failed for the wrong reason:"; echo "$out"; exit 1; } + echo "SUCCESS: managed config is locked" + + - name: Doctor reports system install state + run: | + export PATH="/usr/local/lib/pmg/bin:$PATH" + out=$(pmg setup doctor 2>&1 || true) + echo "$out" + echo "$out" | grep -q 'No aliases (system install)' + echo "$out" | grep -Eq 'Package managers resolve to System shim directory|System shim directory is in PATH' + + - name: Non-root user interception via system shims + run: | + sudo useradd -m pmgtest || true + # Pass runner PATH so setup-node's npm remains visible after FilterPMGFromPath. + sudo -u pmgtest env "PATH=/usr/local/lib/pmg/bin:$PATH" HOME=/home/pmgtest bash -lc ' + set -euo pipefail + # GH runners export XDG_CONFIG_HOME=/home/runner/.config and it + # leaks through sudo -u, so pmg would resolve the runner user + # config dir and fail on its runner-owned log file. + export XDG_CONFIG_HOME="$HOME/.config" + echo "HOME=$HOME XDG_CONFIG_HOME=$XDG_CONFIG_HOME" + which npm | grep -q /usr/local/lib/pmg/bin/npm + mkdir -p "$HOME/sys-e2e" && cd "$HOME/sys-e2e" + npm init -y + if npm install --no-cache --prefer-online safedep-test-pkg@0.1.3; then + echo "ERROR: safedep-test-pkg was not blocked for non-root user" + exit 1 + fi + if [ -d node_modules/safedep-test-pkg ]; then + echo "ERROR: safedep-test-pkg present in node_modules" + exit 1 + fi + echo "SUCCESS: non-root user blocked malicious package via system shims" + ' + + - name: Remove system install + run: | + sudo pmg setup remove --system --config-file + test ! -e /etc/profile.d/pmg.sh + test ! -e /etc/safedep/pmg/config.yml + test ! -d /usr/local/lib/pmg/bin + echo "SUCCESS: system install removed" diff --git a/README.md b/README.md index e54ec0bd..1868c75d 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ pmg setup install ``` > **Tip:** Re-run `pmg setup install` after upgrading PMG to pick up new configuration options. +> +> Linux all-users / golden images: `sudo pmg setup install --system` — see [docs/system-install.md](./docs/system-install.md). Validate your installation and verify protection is working: diff --git a/cmd/setup/doctor.go b/cmd/setup/doctor.go index e1154405..73b3a3db 100644 --- a/cmd/setup/doctor.go +++ b/cmd/setup/doctor.go @@ -3,9 +3,11 @@ package setup import ( "fmt" "os" + "os/exec" "path/filepath" - "slices" + "strings" + "github.com/safedep/dry/log" "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/alias" "github.com/safedep/pmg/internal/doctor" @@ -31,6 +33,9 @@ const ( checkProtectionNpm = "protection-npm" checkProtectionPip = "protection-pip" checkCA = "ca-cert" + checkSystemBinary = "system-binary" + + aliasesInstalledMessage = "Shell aliases installed" ) func NewDoctorCommand() *cobra.Command { @@ -91,23 +96,7 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult { Name: checkEventLogDir, Category: "Configuration", Run: func() doctor.CheckResult { - info, err := os.Stat(cfg.EventLogDir()) - if err != nil { - return doctor.CheckResult{ - Status: doctor.StatusFail, - Message: "Event log directory not found", - } - } - if !info.IsDir() { - return doctor.CheckResult{ - Status: doctor.StatusFail, - Message: "Event log path is not a directory", - } - } - return doctor.CheckResult{ - Status: doctor.StatusPass, - Message: "Event log directory found", - } + return checkEventLogDirResult(cfg.Config.SkipEventLogging, cfg.EventLogDir(), cfg.ConfigDir()) }, }, { @@ -130,15 +119,22 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult { Message: fmt.Sprintf("Could not determine alias status: %v", err), } } - if !installed { + if installed { return doctor.CheckResult{ - Status: doctor.StatusFail, - Message: "Aliases not installed", + Status: doctor.StatusPass, + Message: aliasesInstalledMessage, + ImpliesInterception: true, + } + } + if shim.SystemShimsInstalled() { + return doctor.CheckResult{ + Status: doctor.StatusPass, + Message: "No aliases (system install)", } } return doctor.CheckResult{ - Status: doctor.StatusPass, - Message: "Shell aliases installed", + Status: doctor.StatusFail, + Message: "Aliases not installed", } }, }, @@ -146,6 +142,12 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult { Name: checkShimDirectory, Category: "Shell Integration", Run: func() doctor.CheckResult { + if shim.SystemShimsInstalled() { + return doctor.CheckResult{ + Status: doctor.StatusPass, + Message: fmt.Sprintf("System shim directory found (%s)", shim.SystemBinDir()), + } + } sm, err := shim.NewDefaultShimManager() if err != nil { return doctor.CheckResult{ @@ -170,26 +172,7 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult { { Name: checkShimInPath, Category: "Shell Integration", - Run: func() doctor.CheckResult { - sm, err := shim.NewDefaultShimManager() - if err != nil { - return doctor.CheckResult{ - Status: doctor.StatusWarn, - Message: fmt.Sprintf("Could not check shims: %v", err), - } - } - shimDir := sm.GetBinDir() - if slices.Contains(filepath.SplitList(os.Getenv("PATH")), shimDir) { - return doctor.CheckResult{ - Status: doctor.StatusPass, - Message: "Shim directory is in PATH", - } - } - return doctor.CheckResult{ - Status: doctor.StatusFail, - Message: "Shim directory not in PATH", - } - }, + Run: checkShimInPathResult, }, { Name: checkProxyMode, @@ -272,9 +255,218 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult { }, }, } + + // System-only: the binary every user's shim execs must stay root-owned and + // non-writable. Validation runs at install; re-check it here to catch later + // permission/ownership drift (redeploy, chmod, image rebuild). + if shim.SystemShimsInstalled() { + checks = append(checks, doctor.Check{ + Name: checkSystemBinary, + Category: "Security", + Run: checkSystemBinaryResult, + }) + } + return doctor.RunChecks(checks) } +func checkSystemBinaryResult() doctor.CheckResult { + path, ok := shim.SystemShimBinary() + if !ok { + return doctor.CheckResult{ + Status: doctor.StatusWarn, + Message: "Could not determine system shim binary", + } + } + if err := shim.ValidateSystemBinary(path); err != nil { + return doctor.CheckResult{ + Status: doctor.StatusFail, + Message: fmt.Sprintf("System binary unsafe: %v", err), + Fix: "Reinstall with pmg setup install --system, or restore root ownership/permissions", + } + } + return doctor.CheckResult{ + Status: doctor.StatusPass, + Message: fmt.Sprintf("System binary is root-owned and safe (%s)", path), + } +} + +// checkEventLogDirResult is the testable core of the event-log dir check. +// Event logging is mandatory (init failure is fatal), so an unwritable dir +// fail-closes every pmg command for this user. The remedy is triaged: chown +// when another account created files in this user's home, an environment fix +// when a leaked HOME/XDG_CONFIG_HOME points at another user's home. +func checkEventLogDirResult(skipEventLogging bool, logDir, configDir string) doctor.CheckResult { + if skipEventLogging { + return doctor.CheckResult{ + Status: doctor.StatusWarn, + Message: "Event logging is disabled", + } + } + info, err := os.Stat(logDir) + if err != nil { + return doctor.CheckResult{ + Status: doctor.StatusFail, + Message: "Event log directory not found", + } + } + if !info.IsDir() { + return doctor.CheckResult{ + Status: doctor.StatusFail, + Message: "Event log path is not a directory", + } + } + + probe, err := os.CreateTemp(logDir, ".pmg-doctor-*") + if err != nil { + _, fix := config.UnwritableConfigDirRemedy(configDir) + return doctor.CheckResult{ + Status: doctor.StatusFail, + Message: "Event log directory not writable", + Fix: fix, + } + } + if err := probe.Close(); err != nil { + log.Warnf("failed to close doctor probe file: %v", err) + } + if err := os.Remove(probe.Name()); err != nil { + log.Warnf("failed to remove doctor probe file: %v", err) + } + + return doctor.CheckResult{ + Status: doctor.StatusPass, + Message: "Event log directory found", + } +} + +func pathContainsDir(pathEntries []string, dir string) bool { + if dir == "" { + return false + } + cleanDir := filepath.Clean(dir) + for _, entry := range pathEntries { + if filepath.Clean(entry) == cleanDir { + return true + } + } + return false +} + +func pathIsUnderDir(path, dir string) bool { + if path == "" || dir == "" { + return false + } + cleanPath := filepath.Clean(path) + cleanDir := filepath.Clean(dir) + if cleanPath == cleanDir { + return true + } + prefix := cleanDir + string(os.PathSeparator) + return strings.HasPrefix(cleanPath, prefix) +} + +// classifyPackageManagerResolutions splits package managers by where they +// resolve on PATH: underShim means the command runs through a pmg shim (so it +// is intercepted), shadowed means a real npm/pip sits ahead of the shims (so +// interception is bypassed and the user should be warned). +func classifyPackageManagerResolutions(packageManagers []string, shimDirs []string, lookPath func(string) (string, error)) (underShim, shadowed []string) { + for _, pm := range packageManagers { + resolved, err := lookPath(pm) + if err != nil { + continue + } + + if resolvesUnderAny(resolved, shimDirs) { + underShim = append(underShim, pm) + continue + } + shadowed = append(shadowed, pm) + } + return underShim, shadowed +} + +func resolvesUnderAny(path string, dirs []string) bool { + for _, dir := range dirs { + if pathIsUnderDir(path, dir) { + return true + } + } + return false +} + +// shimDirs lists every directory a package manager may legitimately resolve +// into. System and per-user installs are supported side by side, so resolution +// into either shim directory counts as intercepted rather than shadowed. +func shimDirs() []string { + dirs := []string{shim.SystemBinDir()} + if userDir, err := shim.UserBinDir(); err == nil { + dirs = append(dirs, userDir) + } + return dirs +} + +// checkShimDirResolution classifies interception against all shim dirs via +// shimDirs(); shimDir/pathLabel only select the PATH-membership fallback and +// the display label, not which directories count as intercepting. +func checkShimDirResolution(shimDir, pathLabel string, pathEntries []string) doctor.CheckResult { + underShim, shadowed := classifyPackageManagerResolutions( + alias.DefaultConfig().PackageManagers, + shimDirs(), + exec.LookPath, + ) + + if len(shadowed) > 0 { + if pathContainsDir(pathEntries, shimDir) || len(underShim) > 0 { + return doctor.CheckResult{ + Status: doctor.StatusWarn, + Message: fmt.Sprintf("%s resolved outside %s", strings.Join(shadowed, ", "), pathLabel), + } + } + return doctor.CheckResult{ + Status: doctor.StatusFail, + Message: fmt.Sprintf("%s not in PATH", pathLabel), + } + } + if len(underShim) > 0 { + return doctor.CheckResult{ + Status: doctor.StatusPass, + Message: fmt.Sprintf("Package managers resolve to %s", pathLabel), + ImpliesInterception: true, + } + } + if pathContainsDir(pathEntries, shimDir) { + return doctor.CheckResult{ + Status: doctor.StatusPass, + Message: fmt.Sprintf("%s is in PATH", pathLabel), + ImpliesInterception: true, + } + } + return doctor.CheckResult{ + Status: doctor.StatusFail, + Message: fmt.Sprintf("%s not in PATH", pathLabel), + } +} + +func checkShimInPathResult() doctor.CheckResult { + pathEntries := filepath.SplitList(os.Getenv("PATH")) + + // The primary directory only picks the label and PATH-membership target; + // classification accepts resolution into either shim dir regardless. + shimDir, pathLabel := shim.SystemBinDir(), "System shim directory" + if !shim.SystemShimsInstalled() { + userDir, err := shim.UserBinDir() + if err != nil { + return doctor.CheckResult{ + Status: doctor.StatusWarn, + Message: fmt.Sprintf("Could not resolve shim directory: %v", err), + } + } + shimDir, pathLabel = userDir, "Shim directory" + } + + return checkShimDirResolution(shimDir, pathLabel, pathEntries) +} + func runProtectionChecks(coreResults []doctor.CheckResult) []doctor.CheckResult { if !isInterceptionActive(coreResults) { var results []doctor.CheckResult @@ -306,10 +498,7 @@ func runProtectionChecks(coreResults []doctor.CheckResult) []doctor.CheckResult func isInterceptionActive(coreResults []doctor.CheckResult) bool { for _, r := range coreResults { - if r.Name == checkShellAliases && r.Status == doctor.StatusPass { - return true - } - if r.Name == checkShimInPath && r.Status == doctor.StatusPass { + if r.ImpliesInterception { return true } } @@ -329,6 +518,7 @@ var checkDisplayNames = map[string]string{ checkProtectionNpm: "npm protection", checkProtectionPip: "pip protection", checkCA: "MITM CA", + checkSystemBinary: "System binary", } var checkFixes = map[string]string{ @@ -336,7 +526,7 @@ var checkFixes = map[string]string{ checkEventLogDir: "pmg setup install", checkShellAliases: "pmg setup install", checkShimDirectory: "pmg setup install", - checkShimInPath: "Restart shell or source config", + checkShimInPath: "Restart shell or source profile", checkProxyMode: "Set proxy.enabled: true in config", checkSandbox: "Set sandbox.enabled: true in config", checkDependencyCooldown: "Set dependency_cooldown.enabled: true in config", @@ -395,6 +585,9 @@ func printResults(results []doctor.CheckResult) { fix := ui.Colors.Dim("—") if r.Status != doctor.StatusPass { fix = fixHint(r.Name) + if r.Fix != "" { + fix = r.Fix + } } rows = append(rows, []string{ statusBadge(r.Status), diff --git a/cmd/setup/doctor_test.go b/cmd/setup/doctor_test.go new file mode 100644 index 00000000..71a94e89 --- /dev/null +++ b/cmd/setup/doctor_test.go @@ -0,0 +1,164 @@ +package setup + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/safedep/pmg/config" + "github.com/safedep/pmg/internal/doctor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPathContainsDir(t *testing.T) { + assert.True(t, pathContainsDir([]string{"/usr/local/lib/pmg/bin/"}, "/usr/local/lib/pmg/bin")) + assert.False(t, pathContainsDir([]string{"/usr/local/bin"}, "/usr/local/lib/pmg/bin")) + assert.False(t, pathContainsDir([]string{"/usr/bin"}, "")) +} + +func TestPathIsUnderDir(t *testing.T) { + assert.True(t, pathIsUnderDir("/usr/local/lib/pmg/bin/npm", "/usr/local/lib/pmg/bin")) + assert.False(t, pathIsUnderDir("/usr/local/bin/npm", "/usr/local/lib/pmg/bin")) + assert.False(t, pathIsUnderDir("/usr/local/lib/pmg/bin-extra/npm", "/usr/local/lib/pmg/bin")) +} + +func TestSystemInstallAliasesPassDoesNotActivateInterception(t *testing.T) { + results := []doctor.CheckResult{ + {Name: checkShellAliases, Status: doctor.StatusPass, Message: "No aliases (system install)"}, + {Name: checkShimInPath, Status: doctor.StatusFail}, + } + + assert.False(t, isInterceptionActive(results)) +} + +func TestAliasesInstalledActivatesInterception(t *testing.T) { + results := []doctor.CheckResult{ + { + Name: checkShellAliases, + Status: doctor.StatusPass, + Message: aliasesInstalledMessage, + ImpliesInterception: true, + }, + {Name: checkShimInPath, Status: doctor.StatusFail}, + } + + assert.True(t, isInterceptionActive(results)) +} + +func TestShimInPathImpliesInterception(t *testing.T) { + results := []doctor.CheckResult{ + { + Name: checkShimInPath, + Status: doctor.StatusPass, + Message: "Package managers resolve to System shim directory", + ImpliesInterception: true, + }, + } + + assert.True(t, isInterceptionActive(results)) +} + +func TestClassifyPackageManagerResolutions(t *testing.T) { + shimDir := "/usr/local/lib/pmg/bin" + lookPath := func(name string) (string, error) { + switch name { + case "npm": + return shimDir + "/npm", nil + case "pip": + return "/usr/bin/pip", nil + case "uv": + return "", exec.ErrNotFound + default: + return "", exec.ErrNotFound + } + } + + under, shadowed := classifyPackageManagerResolutions( + []string{"npm", "pip", "uv"}, + []string{shimDir}, + lookPath, + ) + assert.Equal(t, []string{"npm"}, under) + assert.Equal(t, []string{"pip"}, shadowed) +} + +func TestClassifyPackageManagerResolutionsAcceptsEitherShimDir(t *testing.T) { + systemDir := "/usr/local/lib/pmg/bin" + userDir := "/home/dev/.pmg/bin" + lookPath := func(name string) (string, error) { + switch name { + case "npm": + return systemDir + "/npm", nil + case "pip": + return userDir + "/pip", nil + default: + return "/usr/bin/" + name, nil + } + } + + under, shadowed := classifyPackageManagerResolutions( + []string{"npm", "pip", "yarn"}, + []string{systemDir, userDir}, + lookPath, + ) + assert.ElementsMatch(t, []string{"npm", "pip"}, under) + assert.Equal(t, []string{"yarn"}, shadowed) +} + +func TestCheckSystemBinaryResult(t *testing.T) { + // No system shims installed -> could not determine binary (Warn). + result := checkSystemBinaryResult() + // On a dev machine with no /usr/local/lib/pmg/bin shims, SystemShimBinary + // returns !ok, so we get a Warn rather than a spurious Fail. + assert.Contains(t, []doctor.CheckStatus{doctor.StatusWarn, doctor.StatusPass, doctor.StatusFail}, result.Status) + if result.Status == doctor.StatusWarn { + assert.Equal(t, "Could not determine system shim binary", result.Message) + } +} + +func TestCheckEventLogDirResult(t *testing.T) { + configDir := "/home/dev/.config/safedep/pmg" + + t.Run("skipped when event logging disabled", func(t *testing.T) { + result := checkEventLogDirResult(true, t.TempDir(), configDir) + assert.Equal(t, doctor.StatusWarn, result.Status) + }) + + t.Run("missing directory fails", func(t *testing.T) { + result := checkEventLogDirResult(false, filepath.Join(t.TempDir(), "absent"), configDir) + assert.Equal(t, doctor.StatusFail, result.Status) + assert.Equal(t, "Event log directory not found", result.Message) + }) + + t.Run("file instead of directory fails", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "logs") + require.NoError(t, os.WriteFile(path, []byte("x"), 0o644)) + + result := checkEventLogDirResult(false, path, configDir) + assert.Equal(t, doctor.StatusFail, result.Status) + }) + + t.Run("writable directory passes", func(t *testing.T) { + result := checkEventLogDirResult(false, t.TempDir(), configDir) + assert.Equal(t, doctor.StatusPass, result.Status) + }) + + t.Run("unwritable directory fails with triaged remedy", func(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root: directory permissions are not enforced") + } + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0o555)) + t.Cleanup(func() { + require.NoError(t, os.Chmod(dir, 0o755)) + }) + + result := checkEventLogDirResult(false, dir, configDir) + assert.Equal(t, doctor.StatusFail, result.Status) + assert.Equal(t, "Event log directory not writable", result.Message) + _, expectedFix := config.UnwritableConfigDirRemedy(configDir) + assert.Equal(t, expectedFix, result.Fix) + }) +} diff --git a/cmd/setup/info.go b/cmd/setup/info.go index 74fe913e..bee13dfb 100644 --- a/cmd/setup/info.go +++ b/cmd/setup/info.go @@ -13,6 +13,7 @@ import ( "github.com/safedep/pmg/internal/alias" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/audit" + "github.com/safedep/pmg/internal/shim" "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/internal/version" "github.com/safedep/pmg/proxy/certmanager" @@ -77,7 +78,13 @@ func executeSetupInfo() error { } shellEntries["Detected Shell"] = shell - shellEntries["Alias Installed"] = strconv.FormatBool(isInstalled) + shellEntries["Aliases"] = installedState(isInstalled, aliasManager.GetRcPath()) + userBinDir, err := shim.UserBinDir() + if err != nil { + userBinDir = "" + } + shellEntries["User Shims"] = installedState(shim.UserShimsInstalled(), userBinDir) + shellEntries["System Shims"] = installedState(shim.SystemShimsInstalled(), shim.SystemBinDir()) ui.PrintInfoSection("Shell Integration", shellEntries) // Security section @@ -181,6 +188,18 @@ func executeSetupInfo() error { return nil } +// installedState renders installation-state rows consistently: the location +// when installed, "not installed" otherwise. +func installedState(installed bool, location string) string { + if !installed { + return "not installed" + } + if location == "" { + return "installed" + } + return fmt.Sprintf("installed (%s)", location) +} + func resolveSandboxDriverName() string { sb, err := platform.NewSandbox() if err != nil { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 4e00d182..d677ae6b 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -1,10 +1,14 @@ package setup import ( + "errors" "fmt" + "os" "runtime" + "github.com/safedep/dry/usefulerror" "github.com/safedep/pmg/config" + "github.com/safedep/pmg/errcodes" "github.com/safedep/pmg/internal/alias" "github.com/safedep/pmg/internal/shim" "github.com/safedep/pmg/internal/ui" @@ -12,7 +16,7 @@ import ( "github.com/spf13/cobra" ) -var setupRemoveConfigFile = false +var setupGeteuid = os.Geteuid func NewSetupCommand() *cobra.Command { setupCmd := &cobra.Command{ @@ -35,18 +39,30 @@ func NewSetupCommand() *cobra.Command { } func NewInstallCommand() *cobra.Command { - return &cobra.Command{ + var system bool + cmd := &cobra.Command{ Use: "install", Short: "Setup PMG config, aliases, and shims for package managers (npm, pnpm, pip, and more)", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { fmt.Print(ui.GeneratePMGBanner(version.Version, version.Commit)) - return install() + return install(system) }, } + cmd.Flags().BoolVar(&system, "system", false, "Install system-wide for all users (Linux, requires root)") + return cmd } -func install() error { +func install(system bool) error { + if system { + return installSystem() + } + + if setupGeteuid() == 0 { + fmt.Printf("%s %s\n", ui.Colors.Yellow("⚠"), + "Running as root without --system does not protect other users. Use `pmg setup install --system` so all users are covered.") + } + if err := config.WriteTemplateConfig(); err != nil { return fmt.Errorf("failed to write template config: %w", err) } @@ -88,52 +104,140 @@ func install() error { return nil } +func installSystem() error { + if err := requireSystemInstallSupported(); err != nil { + return err + } + + shimMgr, err := shim.NewSystemShimManager() + if err != nil { + return fmt.Errorf("failed to create system shim manager: %w", err) + } + + // Shims/profile first so a failed config write does not leave a managed + // config active without interception. + if err := shimMgr.Install(); err != nil { + return fmt.Errorf("failed to install system shims: %w", err) + } + if err := config.WriteSystemTemplateConfig(); err != nil { + return fmt.Errorf("failed to write system config: %w", err) + } + + ui.PrintSetupSystemInstallCmdInfo(shimMgr.GetBinDir(), config.SystemConfigDir(), shim.SystemProfilePath()) + return nil +} + func NewRemoveCommand() *cobra.Command { + var ( + removeConfig bool + system bool + ) cmd := &cobra.Command{ Use: "remove", Short: "Removes pmg aliases and shims from the user's shell config.", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { fmt.Print(ui.GeneratePMGBanner(version.Version, version.Commit)) - - if setupRemoveConfigFile { - // Only ever remove the per-user file; the globally managed - // config is not ours to delete from a per-user uninstall. - if err := config.RemoveUserConfigFile(); err != nil { - return err - } - } - - if runtime.GOOS == "windows" { - fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG config removed. No aliases or shims to clean up on Windows.") - return nil - } - - cfg := alias.DefaultConfig() - rcFileManager, err := alias.NewDefaultRcFileManager(cfg.RcFileName) - if err != nil { - return err - } - - aliasManager := alias.New(cfg, rcFileManager) - if err := aliasManager.Remove(); err != nil { - return fmt.Errorf("failed to remove aliases: %w", err) - } - - shimMgr, err := shim.NewDefaultShimManager() - if err != nil { - return fmt.Errorf("failed to create shim manager: %w", err) - } - - if err := shimMgr.Remove(); err != nil { - return fmt.Errorf("failed to remove shims: %w", err) - } - - fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG aliases and shims removed. Restart your terminal for changes to take effect") - return nil + return remove(system, removeConfig) }, } - cmd.Flags().BoolVar(&setupRemoveConfigFile, "config-file", false, "Remove the config file") + cmd.Flags().BoolVar(&removeConfig, "config-file", false, "Remove the config file") + cmd.Flags().BoolVar(&system, "system", false, "Remove system-wide install (Linux, requires root)") return cmd } + +func remove(system, removeConfig bool) error { + if system { + return removeSystem(removeConfig) + } + + // Best-effort: attempt every cleanup step so one failure does not strand the + // other artifacts and force a rerun. + var errs []error + if removeConfig { + // Only ever remove the per-user file; the globally managed + // config is not ours to delete from a per-user uninstall. + if err := config.RemoveUserConfigFile(); err != nil { + errs = append(errs, err) + } + } + + if runtime.GOOS == "windows" { + if len(errs) > 0 { + return errors.Join(errs...) + } + fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG config removed. No aliases or shims to clean up on Windows.") + return nil + } + + cfg := alias.DefaultConfig() + rcFileManager, err := alias.NewDefaultRcFileManager(cfg.RcFileName) + if err != nil { + errs = append(errs, err) + } else if err := alias.New(cfg, rcFileManager).Remove(); err != nil { + errs = append(errs, fmt.Errorf("failed to remove aliases: %w", err)) + } + + shimMgr, err := shim.NewDefaultShimManager() + if err != nil { + errs = append(errs, fmt.Errorf("failed to create shim manager: %w", err)) + } else if err := shimMgr.Remove(); err != nil { + errs = append(errs, fmt.Errorf("failed to remove shims: %w", err)) + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG aliases and shims removed. Restart your terminal for changes to take effect") + return nil +} + +func removeSystem(removeConfig bool) error { + if err := requireSystemInstallSupported(); err != nil { + return err + } + + shimMgr, err := shim.NewSystemShimManagerForRemove() + if err != nil { + return fmt.Errorf("failed to create system shim manager: %w", err) + } + + // Best-effort: attempt config removal even if shim removal fails, so one + // failed step does not strand the other artifact and force a rerun. Shims + // are removed first so interception stops before the managed config goes. + var errs []error + if err := shimMgr.Remove(); err != nil { + errs = append(errs, fmt.Errorf("failed to remove system shims: %w", err)) + } + if removeConfig { + if err := config.RemoveSystemConfigFile(); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + + fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG system install removed") + return nil +} + +func requireSystemInstallSupported() error { + if runtime.GOOS != "linux" { + return usefulerror.NewUsefulError(). + WithCode(errcodes.UnsupportedPlatform). + WithHumanError("system install is only supported on Linux"). + WithHelp("Use `pmg setup install` without --system for per-user setup, or run on Linux"). + Wrap(errors.New("unsupported platform for --system")) + } + if setupGeteuid() != 0 { + return usefulerror.NewUsefulError(). + WithCode(errcodes.PermissionDenied). + WithHumanError("system install requires root"). + WithHelp("Re-run as root, e.g. `sudo pmg setup install --system`"). + Wrap(errors.New("not root")) + } + return nil +} diff --git a/cmd/setup/setup_test.go b/cmd/setup/setup_test.go new file mode 100644 index 00000000..38aa3df9 --- /dev/null +++ b/cmd/setup/setup_test.go @@ -0,0 +1,55 @@ +package setup + +import ( + "runtime" + "testing" + + "github.com/safedep/dry/usefulerror" + "github.com/safedep/pmg/errcodes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRequireSystemInstallSupported(t *testing.T) { + orig := setupGeteuid + t.Cleanup(func() { setupGeteuid = orig }) + + setupGeteuid = func() int { return 0 } + err := requireSystemInstallSupported() + if runtime.GOOS == "linux" { + assert.NoError(t, err) + } else { + require.Error(t, err) + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, errcodes.UnsupportedPlatform, usefulErr.Code()) + } + + setupGeteuid = func() int { return 1000 } + err = requireSystemInstallSupported() + require.Error(t, err) + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + if runtime.GOOS == "linux" { + assert.Equal(t, errcodes.PermissionDenied, usefulErr.Code()) + } else { + assert.Equal(t, errcodes.UnsupportedPlatform, usefulErr.Code()) + } +} + +func TestInstallSystemRequiresRoot(t *testing.T) { + orig := setupGeteuid + t.Cleanup(func() { setupGeteuid = orig }) + + setupGeteuid = func() int { return 1000 } + + err := install(true) + require.Error(t, err) + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + if runtime.GOOS == "linux" { + assert.Equal(t, errcodes.PermissionDenied, usefulErr.Code()) + } else { + assert.Equal(t, errcodes.UnsupportedPlatform, usefulErr.Code()) + } +} diff --git a/config/config.go b/config/config.go index 20121eb2..6b023607 100644 --- a/config/config.go +++ b/config/config.go @@ -4,8 +4,10 @@ import ( "errors" "fmt" "os" + "os/user" "path/filepath" "runtime" + "strings" "time" _ "embed" @@ -610,6 +612,112 @@ func loadConfig() { } } +// configGeteuid is overridable in tests to exercise root path resolution +// without running as root. +var configGeteuid = os.Geteuid + +// rootHomeDir returns root's home from the passwd database. Path resolution +// for root must not consult HOME or XDG_*: sudo and su can preserve the +// invoking user's environment (GitHub runners, sudo -E, su without -), which +// would make root create root-owned state inside that user's home and +// fail-close every later non-root pmg run for them. +func rootHomeDir() (string, error) { + u, err := user.LookupId("0") + if err != nil { + return "", fmt.Errorf("failed to resolve root home directory: %w", err) + } + if u.HomeDir == "" { + return "", fmt.Errorf("root user has no home directory") + } + return u.HomeDir, nil +} + +// rootConfigDir mirrors os.UserConfigDir platform conventions for root's +// passwd home. +func rootConfigDir() (string, error) { + home, err := rootHomeDir() + if err != nil { + return "", err + } + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Application Support"), nil + } + return filepath.Join(home, ".config"), nil +} + +// rootCacheDir mirrors os.UserCacheDir platform conventions for root's +// passwd home. +func rootCacheDir() (string, error) { + home, err := rootHomeDir() + if err != nil { + return "", err + } + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Caches"), nil + } + return filepath.Join(home, ".cache"), nil +} + +// Overridable in tests to exercise the passwd-unavailable fallback. +var ( + rootConfigDirResolver = rootConfigDir + rootCacheDirResolver = rootCacheDir +) + +// realUserHomeDir returns the current user's home from the passwd database, +// ignoring HOME and XDG_* env vars that may be leaked from another account. +// Overridable in tests. +var realUserHomeDir = func() (string, error) { + u, err := user.Current() + if err != nil { + return "", err + } + return u.HomeDir, nil +} + +// UnwritableConfigDirRemedy returns actionable help for a per-user config or +// event-log directory the current user cannot write: help is the full +// explanation for fatal CLI errors, fix the terse variant for the doctor +// table. Prescribing the wrong remedy is harmful: chown-ing a directory that +// belongs to another account steals it and bricks that account instead, so +// its is only suggested when the directory is inside the current user's +// real (passwd) home, which a leaked environment cannot influence. +func UnwritableConfigDirRemedy(dir string) (help, fix string) { + if os.Getenv(pmgConfigDirEnvKey) != "" { + return fmt.Sprintf("PMG_CONFIG_DIR points at %s; make it writable by your user", dir), + "Make PMG_CONFIG_DIR writable" + } + + home, err := realUserHomeDir() + if err == nil && home != "" && !pathWithinDir(dir, home) { + return fmt.Sprintf( + "pmg resolved its config directory to %s, outside your home (%s): HOME or XDG_CONFIG_HOME leaked from another account (e.g. sudo -u). Fix the environment, e.g. export XDG_CONFIG_HOME=\"$HOME/.config\"", + dir, home), + `Fix leaked env: export XDG_CONFIG_HOME="$HOME/.config"` + } + + chown := fmt.Sprintf("sudo chown -R $(id -un) %s", dir) + return fmt.Sprintf("If a root or sudo run created it, restore ownership: %s", chown), chown +} + +func pathWithinDir(path, dir string) bool { + cleanPath, cleanDir := filepath.Clean(path), filepath.Clean(dir) + return cleanPath == cleanDir || strings.HasPrefix(cleanPath, cleanDir+string(os.PathSeparator)) +} + +// isSudoElevation reports whether pmg is running as root via sudo, i.e. a +// non-root user elevated and sudo may have preserved that user's HOME/XDG_*. +// Only then do per-user paths divert to root's own home, so root does not +// create state inside the invoking user's home. Running genuinely as root +// (no sudo) keeps honoring HOME/XDG_*, which is legitimate and intended (e.g. +// golden Docker images that set HOME/XDG_CONFIG_HOME on purpose). This mirrors +// the SUDO_USER guard used elsewhere (cmd/setup/cert.go). su without sudo does +// not set SUDO_USER and is not covered; the unwritable-dir remedy still guides +// the user if such a run poisons a directory. +func isSudoElevation() bool { + return configGeteuid() == 0 && os.Getenv("SUDO_USER") != "" +} + // configDir computes the path to the config directory. func configDir() (string, error) { dir := os.Getenv(pmgConfigDirEnvKey) @@ -617,6 +725,18 @@ func configDir() (string, error) { return dir, nil } + if isSudoElevation() { + if base, err := rootConfigDirResolver(); err == nil { + return filepath.Join(base, pmgDefaultHomeRelativePath), nil + } else { + // No resolvable root passwd entry (e.g. scratch containers, + // minimal chroots). Fall back to env-based resolution: without a + // passwd database there is no user switching, so the cross-user + // poisoning this branch prevents cannot occur. + log.Warnf("failed to resolve root home for config dir, using environment: %v", err) + } + } + userConfigDir, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("failed to retrieve user config directory: %w", err) @@ -737,6 +857,15 @@ func cacheDir() (string, error) { } return filepath.Join(baseDir, pmgDefaultHomeRelativePath), nil case "darwin", "linux": + if isSudoElevation() { + if base, err := rootCacheDirResolver(); err == nil { + return filepath.Join(base, pmgDefaultHomeRelativePath), nil + } else { + // Same fallback rationale as configDir. + log.Warnf("failed to resolve root home for cache dir, using environment: %v", err) + } + } + userCacheDir, err := os.UserCacheDir() if err != nil { return "", fmt.Errorf("failed to retrieve user cache directory: %w", err) @@ -821,20 +950,59 @@ func WriteTemplateConfig() error { return nil } - configDir, err := configDir() + configFilePath, err := userConfigFilePath() if err != nil { - return fmt.Errorf("failed to get config directory: %w", err) + return fmt.Errorf("failed to get config file path: %w", err) } - if err := os.MkdirAll(configDir, 0o755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } + return writeTemplateConfigFile(configFilePath) +} - configFilePath, err := userConfigFilePath() +// RemoveUserConfigFile deletes the per-user config file. It never touches the +// globally managed file. A missing file is not an error. +func RemoveUserConfigFile() error { + path, err := userConfigFilePath() if err != nil { return fmt.Errorf("failed to get config file path: %w", err) } + return removeFileIfExists(path) +} + +// WriteSystemTemplateConfig writes the template configuration to the OS-level +// managed config path (e.g. /etc/safedep/pmg/config.yml on Linux). Used by +// `pmg setup install --system`. +func WriteSystemTemplateConfig() error { + path := globalConfigFilePath() + if path == "" { + return fmt.Errorf("system config is not supported on %s", runtime.GOOS) + } + + return writeTemplateConfigFile(path) +} + +// RemoveSystemConfigFile deletes the globally managed config file. A missing +// file is not an error. Returns an error when the platform has no system path. +func RemoveSystemConfigFile() error { + path := globalConfigFilePath() + if path == "" { + return fmt.Errorf("system config is not supported on %s", runtime.GOOS) + } + + return removeFileIfExists(path) +} + +// SystemConfigDir returns the OS-level managed config directory, or "" when +// unsupported. +func SystemConfigDir() string { + return globalConfigDir() +} + +func writeTemplateConfigFile(configFilePath string) error { + if err := os.MkdirAll(filepath.Dir(configFilePath), 0o755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + existingConfig, err := os.ReadFile(configFilePath) if os.IsNotExist(err) { return os.WriteFile(configFilePath, []byte(templateConfig), 0o644) @@ -855,14 +1023,7 @@ func WriteTemplateConfig() error { return nil } -// RemoveUserConfigFile deletes the per-user config file. It never touches the -// globally managed file. A missing file is not an error. -func RemoveUserConfigFile() error { - path, err := userConfigFilePath() - if err != nil { - return fmt.Errorf("failed to get config file path: %w", err) - } - +func removeFileIfExists(path string) error { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove config file %q: %w", path, err) } diff --git a/config/managed_config_test.go b/config/managed_config_test.go index caacc0bd..3404d898 100644 --- a/config/managed_config_test.go +++ b/config/managed_config_test.go @@ -188,3 +188,19 @@ func TestRemoveUserConfigFileNeverTouchesGlobal(t *testing.T) { assert.NoFileExists(t, userFile, "per-user file should be removed") assert.FileExists(t, globalFile, "globally managed file must be left intact") } + +func TestWriteAndRemoveSystemTemplateConfig(t *testing.T) { + globalDir := t.TempDir() + useManagedConfigDir(t, globalDir) + + require.NoError(t, WriteSystemTemplateConfig()) + assert.FileExists(t, filepath.Join(globalDir, "config.yml")) + assert.Equal(t, globalDir, SystemConfigDir()) + assert.Equal(t, filepath.Join(globalDir, "config.yml"), globalConfigFilePath()) + + require.NoError(t, WriteSystemTemplateConfig()) + + require.NoError(t, RemoveSystemConfigFile()) + assert.NoFileExists(t, filepath.Join(globalDir, "config.yml")) + require.NoError(t, RemoveSystemConfigFile()) +} diff --git a/config/remedy_test.go b/config/remedy_test.go new file mode 100644 index 00000000..0742e974 --- /dev/null +++ b/config/remedy_test.go @@ -0,0 +1,56 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func withRealUserHome(t *testing.T, home string) { + t.Helper() + orig := realUserHomeDir + realUserHomeDir = func() (string, error) { return home, nil } + t.Cleanup(func() { realUserHomeDir = orig }) +} + +func TestUnwritableConfigDirRemedy(t *testing.T) { + t.Run("dir inside real home suggests chown", func(t *testing.T) { + t.Setenv("PMG_CONFIG_DIR", "") + withRealUserHome(t, "/home/alice") + + help, fix := UnwritableConfigDirRemedy("/home/alice/.config/safedep/pmg") + assert.Contains(t, help, "sudo chown -R") + assert.Contains(t, help, "/home/alice/.config/safedep/pmg") + assert.Contains(t, fix, "sudo chown -R") + }) + + t.Run("dir outside real home blames leaked env, never suggests chown", func(t *testing.T) { + t.Setenv("PMG_CONFIG_DIR", "") + withRealUserHome(t, "/home/pmgtest") + + help, fix := UnwritableConfigDirRemedy("/home/runner/.config/safedep/pmg") + assert.Contains(t, help, "XDG_CONFIG_HOME") + assert.NotContains(t, help, "chown") + assert.Contains(t, fix, "XDG_CONFIG_HOME") + assert.NotContains(t, fix, "chown") + }) + + t.Run("explicit PMG_CONFIG_DIR gets its own remedy", func(t *testing.T) { + t.Setenv("PMG_CONFIG_DIR", "/srv/pmg") + withRealUserHome(t, "/home/alice") + + help, fix := UnwritableConfigDirRemedy("/srv/pmg") + assert.Contains(t, help, "PMG_CONFIG_DIR") + assert.NotContains(t, help, "chown") + assert.Contains(t, fix, "PMG_CONFIG_DIR") + assert.NotContains(t, fix, "chown") + }) + + t.Run("sibling dir with home prefix is outside home", func(t *testing.T) { + t.Setenv("PMG_CONFIG_DIR", "") + withRealUserHome(t, "/home/alice") + + help, _ := UnwritableConfigDirRemedy("/home/alice-evil/.config/safedep/pmg") + assert.NotContains(t, help, "chown") + }) +} diff --git a/config/rootdir_test.go b/config/rootdir_test.go new file mode 100644 index 00000000..9bc5f5bb --- /dev/null +++ b/config/rootdir_test.go @@ -0,0 +1,126 @@ +package config + +import ( + "os/user" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func withEuid(t *testing.T, euid int) { + t.Helper() + orig := configGeteuid + configGeteuid = func() int { return euid } + t.Cleanup(func() { configGeteuid = orig }) +} + +func poisonUserEnv(t *testing.T) { + t.Helper() + t.Setenv("PMG_CONFIG_DIR", "") + t.Setenv("PMG_CACHE_DIR", "") + t.Setenv("HOME", "/home/victim") + t.Setenv("XDG_CONFIG_HOME", "/home/victim/.config") + t.Setenv("XDG_CACHE_HOME", "/home/victim/.cache") +} + +func TestConfigDirUnderSudoIgnoresPreservedHome(t *testing.T) { + poisonUserEnv(t) + withEuid(t, 0) + t.Setenv("SUDO_USER", "victim") + + dir, err := configDir() + require.NoError(t, err) + + rootUser, err := user.LookupId("0") + require.NoError(t, err) + assert.True(t, strings.HasPrefix(dir, rootUser.HomeDir), "expected %s under root home %s", dir, rootUser.HomeDir) + assert.NotContains(t, dir, "/home/victim") +} + +func TestConfigDirGenuineRootHonorsEnv(t *testing.T) { + // Root without sudo (SUDO_USER unset) is the intended user, e.g. a golden + // Docker image that deliberately sets XDG_CONFIG_HOME. It must not divert. + poisonUserEnv(t) + withEuid(t, 0) + t.Setenv("SUDO_USER", "") + + dir, err := configDir() + require.NoError(t, err) + assert.Contains(t, dir, "/home/victim") +} + +func TestConfigDirAsNonRootUsesEnvHome(t *testing.T) { + poisonUserEnv(t) + withEuid(t, 1000) + + dir, err := configDir() + require.NoError(t, err) + assert.Contains(t, dir, "/home/victim") +} + +func TestConfigDirEnvOverrideWinsUnderSudo(t *testing.T) { + poisonUserEnv(t) + t.Setenv("PMG_CONFIG_DIR", "/custom/pmg") + withEuid(t, 0) + t.Setenv("SUDO_USER", "victim") + + dir, err := configDir() + require.NoError(t, err) + assert.Equal(t, "/custom/pmg", dir) +} + +func TestCacheDirUnderSudoIgnoresPreservedHome(t *testing.T) { + poisonUserEnv(t) + withEuid(t, 0) + t.Setenv("SUDO_USER", "victim") + + dir, err := cacheDir() + require.NoError(t, err) + + rootUser, err := user.LookupId("0") + require.NoError(t, err) + assert.True(t, strings.HasPrefix(dir, rootUser.HomeDir), "expected %s under root home %s", dir, rootUser.HomeDir) + assert.NotContains(t, dir, "/home/victim") +} + +func TestCacheDirGenuineRootHonorsEnv(t *testing.T) { + poisonUserEnv(t) + withEuid(t, 0) + t.Setenv("SUDO_USER", "") + + dir, err := cacheDir() + require.NoError(t, err) + assert.Contains(t, dir, "/home/victim") +} + +func TestCacheDirAsNonRootUsesEnvHome(t *testing.T) { + poisonUserEnv(t) + withEuid(t, 1000) + + dir, err := cacheDir() + require.NoError(t, err) + assert.Contains(t, dir, "/home/victim") +} + +func TestRootDirsFallBackToEnvWhenPasswdUnavailable(t *testing.T) { + poisonUserEnv(t) + withEuid(t, 0) + t.Setenv("SUDO_USER", "victim") + + origConfig, origCache := rootConfigDirResolver, rootCacheDirResolver + rootConfigDirResolver = func() (string, error) { return "", assert.AnError } + rootCacheDirResolver = func() (string, error) { return "", assert.AnError } + t.Cleanup(func() { + rootConfigDirResolver, rootCacheDirResolver = origConfig, origCache + }) + + dir, err := configDir() + require.NoError(t, err) + assert.Contains(t, dir, "/home/victim") + + dir, err = cacheDir() + require.NoError(t, err) + assert.Contains(t, dir, "/home/victim") +} diff --git a/docs/config.md b/docs/config.md index e23594e0..42242951 100644 --- a/docs/config.md +++ b/docs/config.md @@ -113,6 +113,10 @@ Whenever a global config file is present: By default a user can still override the global config's values at runtime through `PMG_*` environment variables and CLI flags. Enable lockdown to forbid that. +## System Install (Linux) + +See [system-install.md](./system-install.md) for `pmg setup install --system`: artifacts, Docker `ENV PATH`, what works vs what differs (aliases, config edit, logs, cloud, CA, sandbox), and permissions. + ### Lockdown Add `global_lockdown: true` to the global config to enforce it: diff --git a/docs/development.md b/docs/development.md index f887bd1e..2c968e60 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,3 +2,4 @@ - [User Interface](./ui.md) - [Steps to introduce a new Package Manager](./package-manager.md) +- [System install (Linux)](./system-install.md) diff --git a/docs/persistent-proxy.md b/docs/persistent-proxy.md index f9dd978f..0a0b682b 100644 --- a/docs/persistent-proxy.md +++ b/docs/persistent-proxy.md @@ -179,8 +179,8 @@ proxy regardless of how the package manager reported the failure. same state file is refused while one is running. - **System-level trust enforcement is out of scope.** The server relies on env var propagation. Enforcing interception for `sudo`-scrubbed environments (e.g. - via `iptables`) and system-wide install (`pmg setup install --system`) are - tracked separately. + via `iptables`) is tracked separately. For system-wide shell shims on Linux, + see [system-install.md](./system-install.md). ## References diff --git a/docs/system-install.md b/docs/system-install.md new file mode 100644 index 00000000..a14f7cdf --- /dev/null +++ b/docs/system-install.md @@ -0,0 +1,145 @@ +# System Install (Linux) + +Use system install when one machine or image should protect every user account: shared VMs, golden Docker images, and similar setups. + +```bash +sudo pmg setup install --system +``` + +**Requires Linux and root.** Install PMG as root into a standard system path such as `/usr/local/bin`. A user-local build (e.g. `~/go/bin/pmg`) is rejected. + +`--system` enforces this because every user's shims run the PMG binary by absolute path. Before installing, it checks that the binary is **root-owned**, world-executable, not group- or other-writable, located in a **root-owned directory** that isn't world-writable, and reachable through world-searchable directories (a binary under `/root`, mode 0700, is rejected because other users could never execute it). + +Per-user `pmg setup install` remains available and does not conflict with a system install. + +To uninstall: + +```bash +sudo pmg setup remove --system +sudo pmg setup remove --system --config-file # also remove the system config file +``` + +## Files created + + +| Item | Path | +| --------------------- | ----------------------------- | +| Configuration | `/etc/safedep/pmg/config.yml` | +| Package-manager shims | `/usr/local/lib/pmg/bin` | +| Shell PATH snippet | `/etc/profile.d/pmg.sh` | + + +## Making shims visible on PATH + +System install writes shims to `/usr/local/lib/pmg/bin`. Processes only use them when that directory is on `PATH` ahead of the real `npm`, `pip`, and other package managers. + +### Linux VMs and login shells + +`pmg setup install --system` installs `/etc/profile.d/pmg.sh`, which prepends the shim directory for login shells. + +```bash +sudo pmg setup install --system +``` + +New login sessions pick this up automatically. For an already open shell, start a new login session or run: + +```bash +source /etc/profile.d/pmg.sh +``` + +Confirm with: + +```bash +which npm # should resolve under /usr/local/lib/pmg/bin +pmg setup doctor +``` + + +### Docker and container images + +Docker `RUN` does not load `/etc/profile.d`. After system install you **must** set `ENV PATH` so build steps and the runtime container see the shims: + +```dockerfile +FROM node:22-bookworm + +RUN curl -fsSL https://raw.githubusercontent.com/safedep/pmg/main/install.sh | sh \ + && pmg setup install --system + +# Required: profile.d is not sourced during docker build +ENV PATH="/usr/local/lib/pmg/bin:$PATH" + +# Optional: switch user; PATH from ENV still applies +RUN mkdir -p /app && chown node:node /app +WORKDIR /app +USER node +COPY --chown=node:node package*.json ./ +RUN npm ci +``` + +Derived images inherit that `ENV`. Later `RUN npm install` / `RUN pip install` go through PMG for any `USER`. + +If a child Dockerfile sets `ENV PATH=...` again, keep `/usr/local/lib/pmg/bin` ahead of the real `npm`/`pip` directories. Leaving it out (or behind those toolchains) drops interception. + +PMG running on the Docker host cannot inspect package installations inside `docker build`. PMG must be installed in the image as shown above. + +## Configuration + +The system config file is authoritative for every user. A per-user `config.yml` is ignored while `/etc/safedep/pmg/config.yml` exists. + +`pmg config set` and `pmg config edit` fail under a system config. Update the file as root, or redeploy it through your image or configuration management. + +Optional lockdown (`global_lockdown: true`) is documented in [config.md](./config.md). + +## Limitations + +- **Virtualenv.** After `source .venv/bin/activate`, bare `pip` uses the venv binary and skips PMG shims. Call `pmg pip …` explicitly. +- **Version managers.** Tools like nvm, pyenv, volta, and asdf often prepend their own bin directories from shell rc files that run after `/etc/profile.d`. That can put real `npm`/`pip` ahead of PMG shims even when the shim directory is on `PATH`. Prefer putting `/usr/local/lib/pmg/bin` first in a durable `ENV PATH` / login PATH, or call `pmg npm` / `pmg pip` explicitly. `pmg setup doctor` warns for any supported package manager that resolves outside the shim directory. +- **No shell aliases.** System install only installs PATH shims. There is no `~/.pmg.rc` alias layer. +- **Config changes.** `pmg config set` and `pmg config edit` are unavailable while the system config is active. Edit `/etc/safedep/pmg/config.yml` as root, or redeploy the file. +- **Custom sandbox `policy_templates`.** Relative paths in the system config resolve under each user's config directory, not `/etc/safedep/pmg`. Prefer absolute paths. +- **`pmg sandbox allow`.** Blocked when the system config sets `global_lockdown: true`. +- **Group-writable install directory.** The binary must be root-owned and non-writable, but if its directory is group-writable without the sticky bit (Debian/Ubuntu ship `/usr/local/bin` as `root:staff` mode `2775`), a group member can delete the root-owned binary and replace it, bypassing the check. `staff` is empty by default, so default exposure is nil; on a multi-user host where the group is not trusted, run `sudo chmod g-w /usr/local/bin` or install into a `root:root` directory. +- **Elevation only, not impersonation.** Root's per-user data is diverted to `/root` only for `sudo` to root (detected via `SUDO_USER`). `su` without `-` becomes root with no marker, so it can still create root-owned files in the caller's home; the caller sees a clear error and chown fix on their next `pmg` run. `sudo -u ` runs with only that user's rights, so it cannot poison another account at all, it just fails. Prefer `sudo` or `su -`, or set `PMG_CONFIG_DIR`. + + +## User data directories + +Shared policy lives under `/etc/safedep/pmg`. Runtime data stays per user: + + +| Data | Default location | +| --------------------- | ------------------------------------------------- | +| Event logs | `~/.config/safedep/pmg/logs/` | +| Cloud sync state | `~/.config/safedep/pmg/cloud-sync.db` | +| Cache | `~/.cache/safedep/pmg/` | +| Sandbox overlays | `~/.config/safedep/pmg/sandbox/overlays/` | +| Persistent CA keypair | `~/.config/safedep/pmg/ca-cert.pem`, `ca-key.pem` | + +You can relocate these with `PMG_CONFIG_DIR` and `PMG_CACHE_DIR`. + +When pmg runs under `sudo` (a non-root user elevated to root), its per-user data resolves under root's own home (`/root`), not the invoking user's, even if sudo preserved their `HOME`. Running directly as root honors `HOME`/`XDG_CONFIG_HOME` as usual, so golden images that set those on purpose keep working. This detection relies on sudo's `SUDO_USER` marker: `su` without `-` leaks the caller's environment but leaves no marker, so a root shell obtained that way can still write into the caller's home. Prefer `su -` or `sudo`. + +The invoking user must be able to write their config directory. PMG records an event log there on each run and fails the command if it cannot (unless event logging is disabled in config). + +In Docker images, avoid creating `/home//.config/safedep` as root during the build. Either fix ownership for the runtime user, or set `PMG_CONFIG_DIR` to a writable location. + +If every `pmg` command fails with `permission denied` on the event log, check where the reported path points: + +- **Inside your own home**: a root run created it as root (`su` without `-`, images that set `ENV HOME` before dropping root, or an older pmg under `sudo`). Restore ownership: `sudo chown -R $(id -un) ~/.config/safedep` +- **Inside another user's home**: your environment leaked that user's `HOME` or `XDG_CONFIG_HOME` (e.g. `sudo -u ` on GitHub-hosted runners). Fix the environment (`export XDG_CONFIG_HOME="$HOME/.config"`). Do not chown another user's directory; that bricks their pmg instead. + +The error message and `pmg setup doctor` print the fix matching your case. + +For cloud sync, enable cloud in the system config and provide credentials (`SAFEDEP_API_KEY` and `SAFEDEP_TENANT_ID`, or a keychain login on developer machines). + +## Certificates + +System install does not set up a MITM certificate authority. For npm and pip on Linux, PMG's default ephemeral CA and environment-variable injection are enough. + +To install a persistent CA into the OS trust store, use a separate command: + +```bash +pmg setup cert install --system +``` + +Run that as your normal user. Details are in [cert.md](./cert.md). diff --git a/go.mod b/go.mod index 42c00b69..8653de2f 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.1 require ( buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260528074646-b9e182189444.1 - buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260620084912-77c7bb923ddb.1 + buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260713161921-716fa3011a21.1 github.com/Masterminds/semver v1.5.0 github.com/elazarl/goproxy v1.8.1 github.com/fatih/color v1.18.0 diff --git a/go.sum b/go.sum index 59e1a341..9191d012 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260528074646-b9e182189444.1 h1:zpj buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260528074646-b9e182189444.1/go.mod h1:8pVZh4owzo4YXcKvFvdWEYGr4k/1VHGR0h39XHsuHD4= buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260620084912-77c7bb923ddb.1 h1:AYEqYqmDeF99lbHGJYjyzACLhJhwF9cJVcNCdl3vwYQ= buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260620084912-77c7bb923ddb.1/go.mod h1:I8E+sZXJNqzWBtSlRGCoiEorLSRiix50h2R/66aBzME= +buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260713161921-716fa3011a21.1 h1:k3kSCmCfcA8kJcI76f0Tj7JD9bx0U4EJuWvBrNO6JXY= +buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260713161921-716fa3011a21.1/go.mod h1:I8E+sZXJNqzWBtSlRGCoiEorLSRiix50h2R/66aBzME= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= diff --git a/go.work.sum b/go.work.sum index 92b2b74a..9fe44cb9 100644 --- a/go.work.sum +++ b/go.work.sum @@ -263,6 +263,7 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs= gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/plugin/opentelemetry v0.1.14/go.mod h1:ZAp4v5vU1CCcK9Oo8/va5rl6NStrzpSU+a70evd+W/g= gorm.io/plugin/prometheus v0.1.0/go.mod h1:5nrc/JrWCUNoDXCY4eOae/FK/J5WjQ0axXuFusCzdTc= diff --git a/internal/audit/cloud_sink.go b/internal/audit/cloud_sink.go index 29bbf685..44565d06 100644 --- a/internal/audit/cloud_sink.go +++ b/internal/audit/cloud_sink.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "os/user" "strings" controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1" @@ -17,7 +18,7 @@ import ( type cloudSink struct { *SyncClientBundle invocationID string - ciResolver CloudSinkCIResolver + ciResolver CloudSinkCIResolver command string workingDir string } @@ -94,6 +95,11 @@ func (s *cloudSink) buildInvocationContext() *controltowerv1.EndpointInvocationC ctx.SetCommand(s.command) ctx.SetWorkingDirectory(s.workingDir) + if u := invokingUser(); u != nil { + ctx.SetUsername(u.Username) + ctx.SetUsernameUid(u.Uid) + } + if s.ciResolver != nil { ci := &controltowerv1.EndpointCIContext{} ci.SetProvider(s.ciResolver.Provider()) @@ -112,6 +118,29 @@ func (s *cloudSink) buildInvocationContext() *controltowerv1.EndpointInvocationC return ctx } +var auditGeteuid = os.Geteuid + +// invokingUser resolves the human behind the command. SUDO_USER is honored +// only when the process is actually elevated (euid 0); otherwise any user +// could set SUDO_USER to spoof cloud-audit attribution to another account. +func invokingUser() *user.User { + if auditGeteuid() == 0 { + if name := os.Getenv("SUDO_USER"); name != "" { + if u, err := user.Lookup(name); err == nil { + return u + } + // No passwd entry for the sudo user (minimal containers): keep + // the attribution sudo recorded rather than reporting root. + return &user.User{Username: name, Uid: os.Getenv("SUDO_UID")} + } + } + u, err := user.Current() + if err != nil { + return nil + } + return u +} + func buildCommand(packageManager string, args []string) string { if packageManager == "" { return "" diff --git a/internal/audit/cloud_sink_test.go b/internal/audit/cloud_sink_test.go index 25fd94e9..ffc0bcc1 100644 --- a/internal/audit/cloud_sink_test.go +++ b/internal/audit/cloud_sink_test.go @@ -2,6 +2,7 @@ package audit import ( "context" + "os/user" "testing" "time" @@ -154,4 +155,42 @@ func TestCloudSinkSetsInvocationContextOnSessionComplete(t *testing.T) { require.NotNil(t, invCtx, "session complete event must have invocation context") assert.Contains(t, invCtx.GetCommand(), "npm") assert.NotEmpty(t, invCtx.GetWorkingDirectory()) + assert.NotEmpty(t, invCtx.GetUsername()) + assert.NotEmpty(t, invCtx.GetUsernameUid()) +} + +func TestInvokingUserIgnoresSudoUserWhenNotElevated(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + orig := auditGeteuid + t.Cleanup(func() { auditGeteuid = orig }) + + // Non-root process: SUDO_USER must be ignored, else attribution is spoofable. + auditGeteuid = func() int { return 1000 } + t.Setenv("SUDO_USER", "root") + got := invokingUser() + require.NotNil(t, got) + assert.Equal(t, current.Username, got.Username, "SUDO_USER must not override attribution when not elevated") + + // Elevated (euid 0): SUDO_USER is trusted and used. + auditGeteuid = func() int { return 0 } + t.Setenv("SUDO_USER", current.Username) + got = invokingUser() + require.NotNil(t, got) + assert.Equal(t, current.Username, got.Username) +} + +func TestInvokingUserKeepsSudoAttributionWithoutPasswdEntry(t *testing.T) { + orig := auditGeteuid + t.Cleanup(func() { auditGeteuid = orig }) + + auditGeteuid = func() int { return 0 } + t.Setenv("SUDO_USER", "no-such-user-xyz") + t.Setenv("SUDO_UID", "4242") + + got := invokingUser() + require.NotNil(t, got) + assert.Equal(t, "no-such-user-xyz", got.Username) + assert.Equal(t, "4242", got.Uid) } diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 82b009b4..2ee8fb45 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -9,10 +9,13 @@ const ( ) type CheckResult struct { - Name string - Category string - Status CheckStatus - Message string + Name string + Category string + Status CheckStatus + Message string + ImpliesInterception bool + // Fix overrides the check's static fix hint for this specific result. + Fix string } type Check struct { diff --git a/internal/shim/file_owner_unix.go b/internal/shim/file_owner_unix.go new file mode 100644 index 00000000..d5ceeb85 --- /dev/null +++ b/internal/shim/file_owner_unix.go @@ -0,0 +1,16 @@ +//go:build unix + +package shim + +import ( + "os" + "syscall" +) + +func fileOwnerUID(info os.FileInfo) (uint32, bool) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, false + } + return uint32(stat.Uid), true +} diff --git a/internal/shim/file_owner_windows.go b/internal/shim/file_owner_windows.go new file mode 100644 index 00000000..f79d04c1 --- /dev/null +++ b/internal/shim/file_owner_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package shim + +import "os" + +func fileOwnerUID(info os.FileInfo) (uint32, bool) { + return 0, false +} diff --git a/internal/shim/path.go b/internal/shim/path.go index 62fd5f79..2ec173ab 100644 --- a/internal/shim/path.go +++ b/internal/shim/path.go @@ -50,6 +50,9 @@ func FilterPMGFromPath(pathEnv string) string { if strings.HasSuffix(entry, pmgBinSuffix) { continue } + if filepath.Clean(entry) == filepath.Clean(SystemBinDir()) { + continue + } if shimDir != "" && filepath.Clean(entry) == shimDir { continue } diff --git a/internal/shim/path_test.go b/internal/shim/path_test.go index f57449fc..27298e9e 100644 --- a/internal/shim/path_test.go +++ b/internal/shim/path_test.go @@ -58,6 +58,11 @@ func TestFilterPMGFromPath(t *testing.T) { shimEnv: "/usr/local/lib/pmg/bin/npm", expected: "/usr/local/bin:/usr/bin", }, + { + name: "system shim dir is stripped without env var", + path: "/usr/local/lib/pmg/bin:/usr/local/bin:/usr/bin", + expected: "/usr/local/bin:/usr/bin", + }, { name: "env var strips arbitrary shim dir", path: "/shims:/usr/local/bin:/usr/bin", diff --git a/internal/shim/shim.go b/internal/shim/shim.go index beda28e1..e385328f 100644 --- a/internal/shim/shim.go +++ b/internal/shim/shim.go @@ -1,6 +1,7 @@ package shim import ( + "errors" "fmt" "os" "path/filepath" @@ -10,7 +11,10 @@ import ( "github.com/safedep/pmg/internal/alias" ) -const shimMarker = "PMG shims" +const ( + shimMarker = "PMG shims" + shimScriptMarker = "# PMG shim - do not edit, managed by pmg setup" +) type ShimConfig struct { BinDir string @@ -18,6 +22,11 @@ type ShimConfig struct { PMGBin string PackageManagers []string Shells []alias.Shell + // SkipShellRc skips per-user shell rc PATH edits. Used by system install, + // which relies on /etc/profile.d or ENV PATH instead. + SkipShellRc bool + // ManageProfile writes and removes /etc/profile.d/pmg.sh with Install/Remove. + ManageProfile bool } type ShimManager struct { @@ -40,8 +49,13 @@ func NewDefaultShimManager() (*ShimManager, error) { return nil, err } + binDir, err := UserBinDir() + if err != nil { + return nil, err + } + return &ShimManager{config: ShimConfig{ - BinDir: filepath.Join(homeDir, ".pmg", "bin"), + BinDir: binDir, HomeDir: homeDir, PMGBin: pmgBin, PackageManagers: aliasCfg.PackageManagers, @@ -62,12 +76,30 @@ func (m *ShimManager) Install() error { return fmt.Errorf("failed to create shim directory %s: %w", m.config.BinDir, err) } + if m.config.ManageProfile { + for _, dir := range []string{filepath.Dir(m.config.BinDir), m.config.BinDir} { + if err := secureSystemDir(dir); err != nil { + return err + } + } + } + for _, pm := range m.config.PackageManagers { if err := m.writeShimScript(pm); err != nil { return fmt.Errorf("failed to write shim for %s: %w", pm, err) } } + if m.config.ManageProfile { + if err := writeSystemProfile(m.config.BinDir); err != nil { + return fmt.Errorf("failed to write system profile: %w", err) + } + } + + if m.config.SkipShellRc { + return nil + } + if err := m.addPathToShells(); err != nil { return fmt.Errorf("failed to update shell configs: %w", err) } @@ -76,15 +108,26 @@ func (m *ShimManager) Install() error { } func (m *ShimManager) Remove() error { - if err := os.RemoveAll(m.config.BinDir); err != nil && !os.IsNotExist(err) { - log.Warnf("Warning: failed to remove shim directory: %v", err) + // Best-effort: a failure removing the shim directory must not skip profile + // and rc cleanup, otherwise a rerun is needed to fully uninstall. + var errs []error + if err := os.RemoveAll(m.config.BinDir); err != nil { + errs = append(errs, fmt.Errorf("failed to remove shim directory %s: %w", m.config.BinDir, err)) } - if err := m.removePathFromShells(); err != nil { - return fmt.Errorf("failed to clean shell configs: %w", err) + if m.config.ManageProfile { + if err := removeSystemProfile(); err != nil { + errs = append(errs, fmt.Errorf("failed to remove system profile: %w", err)) + } } - return nil + if !m.config.SkipShellRc { + if err := m.removePathFromShells(); err != nil { + errs = append(errs, fmt.Errorf("failed to clean shell configs: %w", err)) + } + } + + return errors.Join(errs...) } func (m *ShimManager) IsInstalled() (bool, error) { @@ -112,12 +155,21 @@ func (m *ShimManager) GetBinDir() string { return m.config.BinDir } +// UserBinDir returns the per-user PMG shim directory (~/.pmg/bin). +func UserBinDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(homeDir, ".pmg", "bin"), nil +} + func (m *ShimManager) writeShimScript(pm string) error { shimPath := filepath.Join(m.config.BinDir, pm) pmgBin := shellQuote(m.config.PMGBin) content := fmt.Sprintf(`#!/bin/sh -# PMG shim - do not edit, managed by pmg setup +%s PMG_BIN=%s if [ ! -x "$PMG_BIN" ]; then echo "[pmg] error: PMG binary not found or not executable: $PMG_BIN" >&2 @@ -127,7 +179,7 @@ fi PMG_SHIM_PATH=$(cd -- "$(dirname -- "$0")" && pwd)/$(basename -- "$0") export PMG_SHIM_PATH exec "$PMG_BIN" %s "$@" -`, pmgBin, pm) +`, shimScriptMarker, pmgBin, pm) return os.WriteFile(shimPath, []byte(content), 0o755) } @@ -207,3 +259,13 @@ func (m *ShimManager) removePathFromShells() error { return nil } + +// UserShimsInstalled reports whether the per-user shim directory contains at +// least one shim script. +func UserShimsInstalled() bool { + binDir, err := UserBinDir() + if err != nil { + return false + } + return shimsPresent(binDir) +} diff --git a/internal/shim/shim_test.go b/internal/shim/shim_test.go index 2dcf3cbf..532ace62 100644 --- a/internal/shim/shim_test.go +++ b/internal/shim/shim_test.go @@ -68,6 +68,18 @@ func TestShimManagerInstall(t *testing.T) { assert.Contains(t, string(fishContent), ".pmg/bin") } +func TestShimManagerRemoveReturnsDirectoryError(t *testing.T) { + root := t.TempDir() + blocker := filepath.Join(root, "blocker") + require.NoError(t, os.WriteFile(blocker, []byte("not a directory"), 0o644)) + + mgr := NewShimManager(ShimConfig{ + BinDir: filepath.Join(blocker, "bin"), + }) + + assert.Error(t, mgr.Remove()) +} + func TestShimManagerInstallIdempotent(t *testing.T) { homeDir := t.TempDir() binDir := filepath.Join(homeDir, ".pmg", "bin") diff --git a/internal/shim/system.go b/internal/shim/system.go new file mode 100644 index 00000000..196f8be9 --- /dev/null +++ b/internal/shim/system.go @@ -0,0 +1,332 @@ +package shim + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/safedep/pmg/internal/alias" +) + +const ( + defaultSystemBinDir = "/usr/local/lib/pmg/bin" + defaultSystemProfilePath = "/etc/profile.d/pmg.sh" + systemProfileMarker = "PMG system shims" +) + +// These overrides replace OS-level system install paths in tests. There is +// intentionally no env var or flag for them. +var ( + systemBinDirOverride string + systemProfilePathOverride string + // systemExecutableOwnershipCheck requires root ownership of the binary and + // its parent directory. Disabled in tests that cannot create root-owned files. + systemExecutableOwnershipCheck = true + // resolveExecutable resolves the running pmg binary for system install. + // Overridable in tests so validation does not run against the go-build test + // binary, which is group-writable under a 002 umask. + resolveExecutable = currentExecutable +) + +// SystemBinDir returns the directory for system-wide PMG shims. +func SystemBinDir() string { + if systemBinDirOverride != "" { + return systemBinDirOverride + } + return defaultSystemBinDir +} + +// SystemProfilePath returns the path of the system profile.d snippet. +func SystemProfilePath() string { + if systemProfilePathOverride != "" { + return systemProfilePathOverride + } + return defaultSystemProfilePath +} + +// NewSystemShimManager creates a shim manager for system-wide install: shims +// under SystemBinDir, no per-user rc edits, and /etc/profile.d management. +// The current executable is validated for multi-user use. +func NewSystemShimManager() (*ShimManager, error) { + return newSystemShimManager(true) +} + +// NewSystemShimManagerForRemove creates a system shim manager without +// validating the current executable. Uninstall must work even when the binary +// that originally installed the shims is no longer suitable for install. +func NewSystemShimManagerForRemove() (*ShimManager, error) { + return newSystemShimManager(false) +} + +func newSystemShimManager(validateExecutable bool) (*ShimManager, error) { + aliasCfg := alias.DefaultConfig() + pmgBin, err := resolveExecutable() + if err != nil { + return nil, err + } + + if validateExecutable { + if err := validateSystemExecutable(pmgBin); err != nil { + return nil, err + } + } + + return &ShimManager{ + config: ShimConfig{ + BinDir: SystemBinDir(), + PMGBin: pmgBin, + PackageManagers: aliasCfg.PackageManagers, + SkipShellRc: true, + ManageProfile: true, + }, + }, nil +} + +// validateSystemExecutable rejects binaries unsafe for system-wide shims. +// Shims hard-code this path, so the binary must be executable by all users, +// not writable by group/others, and owned by root in a root-owned, non-world- +// writable parent. +func validateSystemExecutable(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("failed to inspect pmg executable %s: %w", path, err) + } + + perm := info.Mode().Perm() + + // Other users must be able to exec the hard-coded pmg path from system shims. + otherExecute := os.FileMode(0o001) + // Group/other write would let another account replace the binary. + groupOrOtherWrite := os.FileMode(0o022) + + if perm&otherExecute == 0 { + return fmt.Errorf("pmg executable %s is not executable by all users", path) + } + if perm&groupOrOtherWrite != 0 { + return fmt.Errorf("pmg executable %s is writable by group or others", path) + } + + if systemExecutableOwnershipCheck { + // Root ownership of the binary and its parent blocks non-root replacement. + if err := requireRootOwnedPath(path, info); err != nil { + return err + } + if err := requireSafeParentDir(filepath.Dir(path)); err != nil { + return err + } + if err := requirePathSearchableByAll(path); err != nil { + return err + } + } + return nil +} + +// requirePathSearchableByAll walks every directory from the binary's parent up +// to the filesystem root and requires the execute (search) bit for others. The +// shims exec the binary as arbitrary users, so a single non-searchable +// ancestor (e.g. /root, mode 0700) makes the path unreachable and every shim +// fail with exit 127 for non-root users, even when the binary itself is 0755. +func requirePathSearchableByAll(path string) error { + for dir := filepath.Dir(path); ; dir = filepath.Dir(dir) { + info, err := os.Stat(dir) + if err != nil { + return fmt.Errorf("failed to inspect directory %s: %w", dir, err) + } + if info.Mode().Perm()&0o001 == 0 { + return fmt.Errorf("directory %s is not searchable by all users, so pmg at %s would be unreachable from other accounts", dir, path) + } + if dir == filepath.Dir(dir) { + return nil + } + } +} + +func requireRootOwnedPath(path string, info os.FileInfo) error { + uid, ok := fileOwnerUID(info) + if !ok { + return fmt.Errorf("cannot determine owner of %s", path) + } + if uid != 0 { + return fmt.Errorf("pmg executable %s must be owned by root", path) + } + return nil +} + +// requireSafeParentDir requires a root-owned, non-world-writable immediate +// parent. Group-writable is allowed on purpose so Debian/Ubuntu's default +// /usr/local/bin (root:staff 2775) is not rejected; the resulting bypass on +// group-writable non-sticky dirs is covered in docs/system-install.md +// Limitations. +func requireSafeParentDir(dir string) error { + info, err := os.Stat(dir) + if err != nil { + return fmt.Errorf("failed to inspect directory %s: %w", dir, err) + } + + if info.Mode().Perm()&os.FileMode(0o002) != 0 { + return fmt.Errorf("directory %s containing pmg executable is writable by others", dir) + } + + uid, ok := fileOwnerUID(info) + if !ok { + return fmt.Errorf("cannot determine owner of directory %s", dir) + } + + if uid != 0 { + return fmt.Errorf("directory %s containing pmg executable must be owned by root", dir) + } + + return nil +} + +// SystemShimsInstalled reports whether the system shim directory contains at +// least one shim script. +func SystemShimsInstalled() bool { + return shimsPresent(SystemBinDir()) +} + +// SystemShimBinary returns the pmg binary path that installed system shims +// execute (hard-coded as PMG_BIN in every shim). ok is false when no system +// shim with a resolvable PMG_BIN is present. This is the binary every user's +// shim runs, so it is the one whose integrity matters after install. All shims +// are written from the same template in one pass, so reading one suffices. +func SystemShimBinary() (string, bool) { + content, ok := firstShimContent(SystemBinDir()) + if !ok { + return "", false + } + return parseShimPMGBin(content) +} + +// parseShimPMGBin extracts the PMG_BIN value from a shim script, reversing the +// shellQuote used by writeShimScript. +func parseShimPMGBin(content string) (string, bool) { + for line := range strings.SplitSeq(content, "\n") { + if rest, ok := strings.CutPrefix(line, "PMG_BIN="); ok { + return shellUnquote(rest), true + } + } + return "", false +} + +// shellUnquote reverses shellQuote for the single-quoted form it emits. +func shellUnquote(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "'") + s = strings.TrimSuffix(s, "'") + return strings.ReplaceAll(s, `'\''`, `'`) +} + +// ValidateSystemBinary re-runs the system-install safety checks against path. +// Used by `pmg setup doctor` to detect ownership/permission drift of the +// installed binary after setup (validation otherwise runs only at install). +func ValidateSystemBinary(path string) error { + return validateSystemExecutable(path) +} + +// secureSystemDir forces root ownership and 0755 on a directory pmg manages +// system-wide. MkdirAll leaves pre-existing directories untouched, so a dir +// pre-created with weaker ownership (possible under Debian's group-writable +// /usr/local/lib) would let a non-root user replace shims; this closes that +// hole. No-op when not running as root (unit tests, dry contexts). +func secureSystemDir(path string) error { + if os.Geteuid() != 0 { + return nil + } + if err := os.Chown(path, 0, 0); err != nil { + return fmt.Errorf("failed to set root ownership on %s: %w", path, err) + } + if err := os.Chmod(path, 0o755); err != nil { + return fmt.Errorf("failed to set permissions on %s: %w", path, err) + } + return nil +} + +func shimsPresent(dir string) bool { + _, ok := firstShimContent(dir) + return ok +} + +// firstShimContent returns the content of the first managed shim script in dir. +func firstShimContent(dir string) (string, bool) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", false + } + for _, e := range entries { + if e.IsDir() { + continue + } + content, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err == nil && strings.Contains(string(content), shimScriptMarker) { + return string(content), true + } + } + return "", false +} + +// SystemProfileInstalled reports whether the system profile snippet exists and +// contains the PMG marker. +func SystemProfileInstalled() bool { + data, err := os.ReadFile(SystemProfilePath()) + if err != nil { + return false + } + return strings.Contains(string(data), systemProfileMarker) +} + +func writeSystemProfile(binDir string) error { + path := SystemProfilePath() + + // Do not chown/chmod /etc/profile.d itself: it is a shared system directory + // pmg does not own, and other packages drop snippets there. We only secure + // the file we write, below. + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("failed to create profile.d directory: %w", err) + } + + content := fmt.Sprintf(`# %s - managed by pmg setup install --system +# remove by running: pmg setup remove --system +export PATH="%s:$PATH" +`, systemProfileMarker, binDir) + + data, err := os.ReadFile(path) + if err == nil && string(data) == content { + return secureSystemFile(path) + } + + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read system profile %s: %w", path, err) + } + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write system profile %s: %w", path, err) + } + return secureSystemFile(path) +} + +// secureSystemFile forces root ownership and world-readable 0644 on a file pmg +// writes system-wide. This keeps the snippet readable by every user's login +// shell regardless of root's umask, and repairs a pre-existing file's owner +// without touching the shared directory it lives in. +func secureSystemFile(path string) error { + if os.Geteuid() != 0 { + return nil + } + if err := os.Chown(path, 0, 0); err != nil { + return fmt.Errorf("failed to set root ownership on %s: %w", path, err) + } + if err := os.Chmod(path, 0o644); err != nil { + return fmt.Errorf("failed to set permissions on %s: %w", path, err) + } + return nil +} + +func removeSystemProfile() error { + path := SystemProfilePath() + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove system profile %s: %w", path, err) + } + return nil +} diff --git a/internal/shim/system_test.go b/internal/shim/system_test.go new file mode 100644 index 00000000..0c61c568 --- /dev/null +++ b/internal/shim/system_test.go @@ -0,0 +1,238 @@ +package shim + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func useSystemPaths(t *testing.T, dir string) { + t.Helper() + systemBinDirOverride = filepath.Join(dir, "bin") + systemProfilePathOverride = filepath.Join(dir, "profile.d", "pmg.sh") + systemExecutableOwnershipCheck = false + + // The go-build test binary is group-writable under a 002 umask, which the + // executable validation rightly rejects. Point resolution at a crafted + // 0755 binary so the manager validates a realistic path, not the harness. + exe := filepath.Join(dir, "pmg") + require.NoError(t, os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755)) + resolveExecutable = func() (string, error) { return exe, nil } + + t.Cleanup(func() { + systemBinDirOverride = "" + systemProfilePathOverride = "" + systemExecutableOwnershipCheck = true + resolveExecutable = currentExecutable + }) +} + +func TestSystemShimManagerInstallAndRemove(t *testing.T) { + root := t.TempDir() + useSystemPaths(t, root) + + mgr, err := NewSystemShimManager() + require.NoError(t, err) + assert.True(t, mgr.config.SkipShellRc) + assert.True(t, mgr.config.ManageProfile) + assert.Equal(t, SystemBinDir(), mgr.GetBinDir()) + + require.NoError(t, mgr.Install()) + assert.True(t, SystemShimsInstalled()) + assert.True(t, SystemProfileInstalled()) + + npmShim := filepath.Join(SystemBinDir(), "npm") + content, err := os.ReadFile(npmShim) + require.NoError(t, err) + assert.Contains(t, string(content), "export PMG_SHIM_PATH") + assert.Contains(t, string(content), "pmg setup install") + assert.Contains(t, string(content), "pmg setup remove") + + profile, err := os.ReadFile(SystemProfilePath()) + require.NoError(t, err) + assert.Contains(t, string(profile), mgr.GetBinDir()) + assert.Contains(t, string(profile), systemProfileMarker) + + require.NoError(t, mgr.Install()) + profile2, err := os.ReadFile(SystemProfilePath()) + require.NoError(t, err) + assert.Equal(t, string(profile), string(profile2)) + + require.NoError(t, mgr.Remove()) + assert.False(t, SystemShimsInstalled()) + assert.False(t, SystemProfileInstalled()) + require.NoError(t, mgr.Remove()) +} + +func TestSystemShimManagerDoesNotTouchUserRc(t *testing.T) { + root := t.TempDir() + useSystemPaths(t, root) + + home := t.TempDir() + bashrc := filepath.Join(home, ".bashrc") + require.NoError(t, os.WriteFile(bashrc, []byte("# user bashrc\n"), 0o644)) + + mgr, err := NewSystemShimManager() + require.NoError(t, err) + mgr.config.HomeDir = home + require.NoError(t, mgr.Install()) + + content, err := os.ReadFile(bashrc) + require.NoError(t, err) + assert.Equal(t, "# user bashrc\n", string(content)) +} + +func TestSystemShimsInstalledIgnoresUnmanagedFiles(t *testing.T) { + root := t.TempDir() + useSystemPaths(t, root) + require.NoError(t, os.MkdirAll(SystemBinDir(), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(SystemBinDir(), "README"), []byte("not a shim"), 0o644)) + + assert.False(t, SystemShimsInstalled()) + + require.NoError(t, os.WriteFile( + filepath.Join(SystemBinDir(), "npm"), + []byte("#!/bin/sh\n# PMG shim - do not edit, managed by pmg setup\n"), + 0o755, + )) + assert.True(t, SystemShimsInstalled()) +} + +func TestWriteSystemProfileRepairsStalePath(t *testing.T) { + root := t.TempDir() + useSystemPaths(t, root) + require.NoError(t, os.MkdirAll(filepath.Dir(SystemProfilePath()), 0o755)) + require.NoError(t, os.WriteFile( + SystemProfilePath(), + []byte("# PMG system shims\nexport PATH=\"/stale/path:$PATH\"\n"), + 0o644, + )) + + binDir := filepath.Join(root, "custom-bin") + require.NoError(t, writeSystemProfile(binDir)) + + content, err := os.ReadFile(SystemProfilePath()) + require.NoError(t, err) + assert.Contains(t, string(content), binDir) + assert.NotContains(t, string(content), "/stale/path") + assert.NotContains(t, string(content), SystemBinDir()) +} + +func TestValidateSystemExecutableRejectsPrivateBinary(t *testing.T) { + systemExecutableOwnershipCheck = false + t.Cleanup(func() { systemExecutableOwnershipCheck = true }) + + privateDir := t.TempDir() + privateExecutable := filepath.Join(privateDir, "pmg") + require.NoError(t, os.WriteFile(privateExecutable, []byte("binary"), 0o700)) + + err := validateSystemExecutable(privateExecutable) + + require.Error(t, err) + assert.Contains(t, err.Error(), "not executable by all users") +} + +func TestValidateSystemExecutableRejectsGroupWritable(t *testing.T) { + systemExecutableOwnershipCheck = false + t.Cleanup(func() { systemExecutableOwnershipCheck = true }) + + dir := t.TempDir() + path := filepath.Join(dir, "pmg") + require.NoError(t, os.WriteFile(path, []byte("binary"), 0o755)) + require.NoError(t, os.Chmod(path, 0o775)) + + err := validateSystemExecutable(path) + + require.Error(t, err) + assert.Contains(t, err.Error(), "writable by group or others") +} + +func TestValidateSystemExecutableRejectsNonRootOwner(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file ownership is not resolvable on Windows") + } + if os.Geteuid() == 0 { + t.Skip("running as root: temp file is root-owned, so the owner check passes") + } + + dir := t.TempDir() + path := filepath.Join(dir, "pmg") + require.NoError(t, os.WriteFile(path, []byte("binary"), 0o755)) + + err := validateSystemExecutable(path) + + require.Error(t, err) + assert.Contains(t, err.Error(), "must be owned by root") +} + +func TestSystemShimBinaryResolvesInstalledPath(t *testing.T) { + root := t.TempDir() + useSystemPaths(t, root) + + exe := filepath.Join(root, "pmg") + require.NoError(t, os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755)) + resolveExecutable = func() (string, error) { return exe, nil } + + mgr, err := NewSystemShimManager() + require.NoError(t, err) + require.NoError(t, mgr.Install()) + + got, ok := SystemShimBinary() + require.True(t, ok) + assert.Equal(t, exe, got) +} + +func TestSystemShimBinaryFalseWhenNoShims(t *testing.T) { + root := t.TempDir() + useSystemPaths(t, root) + require.NoError(t, os.MkdirAll(SystemBinDir(), 0o755)) + + _, ok := SystemShimBinary() + assert.False(t, ok) +} + +func TestParseShimPMGBinRoundTripsShellQuote(t *testing.T) { + for _, path := range []string{"/usr/local/bin/pmg", "/opt/pmg dir/pmg", "/weird/o'brien/pmg"} { + content := "#!/bin/sh\n" + shimScriptMarker + "\nPMG_BIN=" + shellQuote(path) + "\n" + got, ok := parseShimPMGBin(content) + require.True(t, ok, path) + assert.Equal(t, path, got) + } +} + +func TestRequirePathSearchableByAll(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix permission semantics") + } + + t.Run("standard system path passes", func(t *testing.T) { + // Only directories are inspected, so the file itself need not exist. + assert.NoError(t, requirePathSearchableByAll("/usr/bin/pmg-does-not-exist")) + }) + + t.Run("non-searchable ancestor rejects", func(t *testing.T) { + base := t.TempDir() + require.NoError(t, os.Chmod(base, 0o700)) + sub := filepath.Join(base, "sub") + require.NoError(t, os.MkdirAll(sub, 0o755)) + + err := requirePathSearchableByAll(filepath.Join(sub, "pmg")) + require.Error(t, err) + assert.Contains(t, err.Error(), "not searchable by all users") + }) +} + +func TestNewSystemShimManagerForRemoveSkipsValidation(t *testing.T) { + root := t.TempDir() + useSystemPaths(t, root) + systemExecutableOwnershipCheck = true + + mgr, err := NewSystemShimManagerForRemove() + require.NoError(t, err) + require.NoError(t, mgr.Install()) + require.NoError(t, mgr.Remove()) +} diff --git a/internal/ui/info.go b/internal/ui/info.go index d4b2ee74..02614a70 100644 --- a/internal/ui/info.go +++ b/internal/ui/info.go @@ -32,3 +32,14 @@ func PrintSetupInstallCmdInfo(aliasPath, shimBinDir, configPath string) { fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Config: %s", configPath))) fmt.Printf(" %s\n", Colors.Dim("Restart your terminal for changes to take effect")) } + +func PrintSetupSystemInstallCmdInfo(shimBinDir, configDir, profilePath string) { + fmt.Printf("%s %s\n", Colors.Green("✓"), "PMG system install completed") + fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Shims: %s", shimBinDir))) + fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Config: %s", configDir))) + fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Profile: %s", profilePath))) + fmt.Printf(" %s\n", Colors.Dim("Per-user config files are now ignored.")) + fmt.Printf("\n%s For Docker builds (RUN does not source profile.d), add:\n", Colors.Dim("ℹ")) + fmt.Printf(" %s\n", Colors.Bold(fmt.Sprintf(`ENV PATH="%s:$PATH"`, shimBinDir))) + fmt.Printf("%s Login shells pick up PATH from profile.d. After venv activate, use `pmg pip`.\n", Colors.Dim("ℹ")) +} diff --git a/main.go b/main.go index 3d42efb9..89cbf608 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,15 @@ package main import ( + "errors" "fmt" + "io/fs" "os" "runtime" "strings" "github.com/safedep/dry/log" + "github.com/safedep/dry/usefulerror" "github.com/safedep/pmg/cmd/cloud" configCmd "github.com/safedep/pmg/cmd/config" "github.com/safedep/pmg/cmd/executors" @@ -19,6 +22,7 @@ import ( "github.com/safedep/pmg/cmd/setup" "github.com/safedep/pmg/cmd/version" "github.com/safedep/pmg/config" + "github.com/safedep/pmg/errcodes" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/audit" "github.com/safedep/pmg/internal/eventlog" @@ -103,7 +107,7 @@ func main() { } if eventlogErr != nil { - ui.Fatalf("failed to initialize event logging: %v", eventlogErr) + ui.ErrorExit(eventlogInitError(eventlogErr)) } if err := audit.Initialize(config.Get()); err != nil { @@ -215,6 +219,26 @@ func main() { } } +// eventlogInitError classifies event-log init failures. Event logging is +// mandatory, so init failure stays fatal; the permission case gets an +// actionable remedy because the common cause is a root or sudo run having +// created the per-user directory as root (some environments preserve HOME +// under sudo). +func eventlogInitError(err error) error { + if errors.Is(err, fs.ErrPermission) { + help, _ := config.UnwritableConfigDirRemedy(config.Get().ConfigDir()) + return usefulerror.NewUsefulError(). + WithCode(errcodes.PermissionDenied). + WithHumanError("event logging is required but its directory is not writable"). + WithHelp(help). + Wrap(err) + } + return usefulerror.NewUsefulError(). + WithCode(errcodes.Lifecycle). + WithHumanError(fmt.Sprintf("failed to initialize event logging: %v", err)). + Wrap(err) +} + func logDebugContext() { cfg := config.Get()