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 }