Files
pmg/internal/shim/shim.go
T
Sahilb315 1276a1ebaa fix: address system-install review findings
- shim: make system executable resolution injectable so tests pass under
  umask 002; skip the root-owner test when running as root
- doctor: treat resolution into either the system or per-user shim dir as
  intercepted, and collapse the shim-in-PATH check to a single call site
- setup: make remove (both --system and per-user) best-effort with
  errors.Join so one failed step no longer strands the other artifact
- shim: allow a group-writable install parent dir (Debian/Ubuntu ship
  /usr/local/bin as root:staff 2775) while still rejecting world-writable
  and non-root-owned parents
- audit: attribute cloud events to SUDO_USER when running under sudo
- docs: drop the soft-fail event-logging claim (hard-fail is retained)
2026-07-14 00:44:44 +05:30

264 lines
6.4 KiB
Go

package shim
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/internal/alias"
)
const (
shimMarker = "PMG shims"
shimScriptMarker = "# PMG shim - do not edit, managed by pmg setup"
)
type ShimConfig struct {
BinDir string
HomeDir string
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 {
config ShimConfig
}
func NewShimManager(config ShimConfig) *ShimManager {
return &ShimManager{config: config}
}
func NewDefaultShimManager() (*ShimManager, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
aliasCfg := alias.DefaultConfig()
pmgBin, err := currentExecutable()
if err != nil {
return nil, err
}
binDir, err := UserBinDir()
if err != nil {
return nil, err
}
return &ShimManager{config: ShimConfig{
BinDir: binDir,
HomeDir: homeDir,
PMGBin: pmgBin,
PackageManagers: aliasCfg.PackageManagers,
Shells: aliasCfg.Shells,
}}, nil
}
func (m *ShimManager) Install() error {
if m.config.PMGBin == "" {
pmgBin, err := currentExecutable()
if err != nil {
return err
}
m.config.PMGBin = pmgBin
}
if err := os.MkdirAll(m.config.BinDir, 0o755); err != nil {
return fmt.Errorf("failed to create shim directory %s: %w", m.config.BinDir, 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)
}
return nil
}
func (m *ShimManager) Remove() error {
// 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 m.config.ManageProfile {
if err := removeSystemProfile(); err != nil {
errs = append(errs, fmt.Errorf("failed to remove system profile: %w", err))
}
}
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) {
for _, shell := range m.config.Shells {
for _, configPath := range shell.CandidateRcFiles(m.config.HomeDir) {
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
continue
}
log.Warnf("Warning: could not read %s (%s)", configPath, err)
continue
}
if strings.Contains(string(data), shimMarker) {
return true, nil
}
}
}
return false, nil
}
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
%s
PMG_BIN=%s
if [ ! -x "$PMG_BIN" ]; then
echo "[pmg] error: PMG binary not found or not executable: $PMG_BIN" >&2
echo "[pmg] error: run 'pmg setup install' again or remove shims with 'pmg setup remove'" >&2
exit 127
fi
PMG_SHIM_PATH=$(cd -- "$(dirname -- "$0")" && pwd)/$(basename -- "$0")
export PMG_SHIM_PATH
exec "$PMG_BIN" %s "$@"
`, shimScriptMarker, pmgBin, pm)
return os.WriteFile(shimPath, []byte(content), 0o755)
}
func currentExecutable() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("failed to resolve pmg executable: %w", err)
}
return filepath.Abs(exe)
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
func (m *ShimManager) addPathToShells() error {
primary := alias.PrimaryShellName()
for _, shell := range m.config.Shells {
files, err := shell.InstallRcFiles(m.config.HomeDir, shell.Name() == primary)
if err != nil {
log.Warnf("Warning: skipping %s (%s)", shell.Name(), err)
continue
}
for _, configPath := range files {
m.addPathToFile(configPath, shell)
}
}
return nil
}
// addPathToFile appends the shell's PATH export to a single config file unless
// it is already present. A missing file is a no-op.
func (m *ShimManager) addPathToFile(configPath string, shell alias.Shell) {
data, err := os.ReadFile(configPath)
if err != nil {
if !os.IsNotExist(err) {
log.Warnf("Warning: skipping %s (%s)", configPath, err)
}
return
}
if strings.Contains(string(data), shimMarker) {
return
}
f, err := os.OpenFile(configPath, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
log.Warnf("Warning: skipping %s (%s)", configPath, err)
return
}
_, err = fmt.Fprintf(f, "\n%s", shell.PathExport(m.config.BinDir))
if closeErr := f.Close(); closeErr != nil {
log.Warnf("Warning: failed to close %s: %s", configPath, closeErr)
}
if err != nil {
log.Warnf("Warning: failed to write PATH export to %s: %s", configPath, err)
}
}
func (m *ShimManager) removePathFromShells() error {
drop := func(line string) bool {
return strings.Contains(line, shimMarker)
}
for _, shell := range m.config.Shells {
for _, configPath := range shell.CandidateRcFiles(m.config.HomeDir) {
if err := alias.RewriteFileDroppingLines(configPath, drop); err != nil {
log.Warnf("Warning: failed to update %s: %s", configPath, err)
}
}
}
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)
}