fix: fall back to env path resolution when root has no passwd entry

Running as uid 0 without a resolvable root passwd entry (scratch
containers, minimal chroots) panicked at startup on every command,
because the euid-based path resolution had no fallback. Fall back to
env-derived resolution there: without a passwd database there is no
user switching, so the cross-user poisoning that branch prevents
cannot occur.

Also restore the underlying cause in the generic event-log init error
(minimal output hid it after the usefulerror change), and document
that root's per-user data lives under /root regardless of a preserved
HOME.
This commit is contained in:
Sahilb315
2026-07-14 04:49:07 +05:30
parent ad16d8c162
commit 297f516242
4 changed files with 45 additions and 10 deletions
+19 -8
View File
@@ -658,6 +658,12 @@ func rootCacheDir() (string, error) {
return filepath.Join(home, ".cache"), 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, // realUserHomeDir returns the current user's home from the passwd database,
// ignoring HOME and XDG_* env vars that may be leaked from another account. // ignoring HOME and XDG_* env vars that may be leaked from another account.
// Overridable in tests. // Overridable in tests.
@@ -707,11 +713,15 @@ func configDir() (string, error) {
} }
if configGeteuid() == 0 { if configGeteuid() == 0 {
base, err := rootConfigDir() if base, err := rootConfigDirResolver(); err == nil {
if err != nil {
return "", err
}
return filepath.Join(base, pmgDefaultHomeRelativePath), 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() userConfigDir, err := os.UserConfigDir()
@@ -835,11 +845,12 @@ func cacheDir() (string, error) {
return filepath.Join(baseDir, pmgDefaultHomeRelativePath), nil return filepath.Join(baseDir, pmgDefaultHomeRelativePath), nil
case "darwin", "linux": case "darwin", "linux":
if configGeteuid() == 0 { if configGeteuid() == 0 {
base, err := rootCacheDir() if base, err := rootCacheDirResolver(); err == nil {
if err != nil {
return "", err
}
return filepath.Join(base, pmgDefaultHomeRelativePath), 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() userCacheDir, err := os.UserCacheDir()
+20
View File
@@ -78,3 +78,23 @@ func TestCacheDirAsNonRootUsesEnvHome(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, dir, "/home/victim") assert.Contains(t, dir, "/home/victim")
} }
func TestRootDirsFallBackToEnvWhenPasswdUnavailable(t *testing.T) {
poisonUserEnv(t)
withEuid(t, 0)
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")
}
+5 -1
View File
@@ -6,7 +6,9 @@ Use system install when one machine or image should protect every user account:
sudo pmg setup install --system sudo pmg setup install --system
``` ```
Requires Linux and root. Because every user's shims execute the PMG binary by its absolute path, `--system` validates it first: the binary must be **root-owned**, world-executable, and not writable by group or others, and it must sit in a **root-owned directory** that is not world-writable. Install PMG as root into a standard path such as `/usr/local/bin`; a user-local build (e.g. `~/go/bin/pmg`) is rejected. **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, and located in a **root-owned directory** that isn't world-writable.
Per-user `pmg setup install` remains available and does not conflict with a system install. Per-user `pmg setup install` remains available and does not conflict with a system install.
@@ -113,6 +115,8 @@ Shared policy lives under `/etc/safedep/pmg`. Runtime data stays per user:
You can relocate these with `PMG_CONFIG_DIR` and `PMG_CACHE_DIR`. You can relocate these with `PMG_CONFIG_DIR` and `PMG_CACHE_DIR`.
When pmg runs as root (including via sudo), its per-user data goes under `/root`, regardless of any `HOME` preserved by sudo. Root never writes into another user's home.
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). 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/<user>/.config/safedep` as root during the build. Either fix ownership for the runtime user, or set `PMG_CONFIG_DIR` to a writable location. In Docker images, avoid creating `/home/<user>/.config/safedep` as root during the build. Either fix ownership for the runtime user, or set `PMG_CONFIG_DIR` to a writable location.
+1 -1
View File
@@ -235,7 +235,7 @@ func eventlogInitError(err error) error {
} }
return usefulerror.NewUsefulError(). return usefulerror.NewUsefulError().
WithCode(errcodes.Lifecycle). WithCode(errcodes.Lifecycle).
WithHumanError("failed to initialize event logging"). WithHumanError(fmt.Sprintf("failed to initialize event logging: %v", err)).
Wrap(err) Wrap(err)
} }