fix: actionable remedy for root-created per-user config dir

A pmg run as root with a preserved HOME (GitHub runners, sudo -E, su
without -) creates the invoking user's ~/.config/safedep as root-owned,
and event-log init then fail-closes every later non-root command.
Make that state self-solvable:

- event-log init permission errors exit with a usefulerror naming the
  likely cause and the chown fix instead of a bare fatal
- pmg setup doctor probes event-log dir writability and reports the
  same fix via a new per-result Fix override
- document the mechanism and remedy in system-install.md, along with
  the binary ownership requirements for --system
- consolidate this branch's doctor tests into doctor_test.go
This commit is contained in:
Sahilb315
2026-07-14 02:40:08 +05:30
parent 1b17eb295a
commit 479f546ebc
5 changed files with 139 additions and 29 deletions
+51 -23
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/alias"
"github.com/safedep/pmg/internal/doctor"
@@ -94,29 +95,7 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
Name: checkEventLogDir,
Category: "Configuration",
Run: func() doctor.CheckResult {
if cfg.Config.SkipEventLogging {
return doctor.CheckResult{
Status: doctor.StatusWarn,
Message: "Event logging is disabled",
}
}
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())
},
},
{
@@ -278,6 +257,52 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
return doctor.RunChecks(checks)
}
// 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 common cause is a root or
// sudo run having created the per-user directory as root, hence the chown fix.
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 {
return doctor.CheckResult{
Status: doctor.StatusFail,
Message: "Event log directory not writable",
Fix: fmt.Sprintf("sudo chown -R $(id -un) %s", configDir),
}
}
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
@@ -520,6 +545,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),
@@ -1,11 +1,14 @@
package setup
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/safedep/pmg/internal/doctor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPathContainsDir(t *testing.T) {
@@ -102,3 +105,48 @@ func TestClassifyPackageManagerResolutionsAcceptsEitherShimDir(t *testing.T) {
assert.ElementsMatch(t, []string{"npm", "pip"}, under)
assert.Equal(t, []string{"yarn"}, shadowed)
}
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 chown fix", 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)
assert.Contains(t, result.Fix, "sudo chown -R")
assert.Contains(t, result.Fix, configDir)
})
}