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)
This commit is contained in:
Sahilb315
2026-07-14 00:44:44 +05:30
parent b1aa217011
commit 1276a1ebaa
9 changed files with 150 additions and 47 deletions
+41 -14
View File
@@ -103,7 +103,7 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
info, err := os.Stat(cfg.EventLogDir())
if err != nil {
return doctor.CheckResult{
Status: doctor.StatusWarn,
Status: doctor.StatusFail,
Message: "Event log directory not found",
}
}
@@ -304,14 +304,18 @@ func pathIsUnderDir(path, dir string) bool {
return strings.HasPrefix(cleanPath, prefix)
}
func classifyPackageManagerResolutions(packageManagers []string, shimDir string, lookPath func(string) (string, error)) (underShim, shadowed []string) {
// 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 pathIsUnderDir(resolved, shimDir) {
if resolvesUnderAny(resolved, shimDirs) {
underShim = append(underShim, pm)
continue
}
@@ -320,10 +324,30 @@ func classifyPackageManagerResolutions(packageManagers []string, shimDir string,
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
}
func checkShimDirResolution(shimDir, pathLabel string, pathEntries []string) doctor.CheckResult {
underShim, shadowed := classifyPackageManagerResolutions(
alias.DefaultConfig().PackageManagers,
shimDir,
shimDirs(),
exec.LookPath,
)
@@ -362,18 +386,21 @@ func checkShimDirResolution(shimDir, pathLabel string, pathEntries []string) doc
func checkShimInPathResult() doctor.CheckResult {
pathEntries := filepath.SplitList(os.Getenv("PATH"))
if shim.SystemShimsInstalled() {
return checkShimDirResolution(shim.SystemBinDir(), "System shim directory", pathEntries)
// 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"
}
userDir, err := shim.UserBinDir()
if err != nil {
return doctor.CheckResult{
Status: doctor.StatusWarn,
Message: fmt.Sprintf("Could not resolve shim directory: %v", err),
}
}
return checkShimDirResolution(userDir, "Shim directory", pathEntries)
return checkShimDirResolution(shimDir, pathLabel, pathEntries)
}
func runProtectionChecks(coreResults []doctor.CheckResult) []doctor.CheckResult {
+24 -1
View File
@@ -73,9 +73,32 @@ func TestClassifyPackageManagerResolutions(t *testing.T) {
under, shadowed := classifyPackageManagerResolutions(
[]string{"npm", "pip", "uv"},
shimDir,
[]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)
}
+24 -12
View File
@@ -152,15 +152,21 @@ func remove(system, removeConfig bool) error {
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 {
return err
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
}
@@ -168,21 +174,20 @@ func remove(system, removeConfig bool) error {
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)
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 {
return fmt.Errorf("failed to create shim manager: %w", err)
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 err := shimMgr.Remove(); err != nil {
return 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")
@@ -199,14 +204,21 @@ func removeSystem(removeConfig bool) error {
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 {
return fmt.Errorf("failed to remove system shims: %w", err)
errs = append(errs, fmt.Errorf("failed to remove system shims: %w", err))
}
if removeConfig {
if err := config.RemoveSystemConfigFile(); err != nil {
return err
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
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestErrIfSystemInstallAllowed(t *testing.T) {
func TestRequireSystemInstallSupported(t *testing.T) {
orig := setupGeteuid
t.Cleanup(func() { setupGeteuid = orig })