@@ -77,30 +77,18 @@ pub fn reconcile_sudo_environment() {
7777
7878#[ cfg( unix) ]
7979mod unix {
80- use super :: sudo_parse_passwd_home;
80+ use super :: { should_reconcile_for , sudo_parse_passwd_home} ;
8181 use std:: path:: Path ;
8282 use std:: process:: Command ;
8383
8484 pub ( super ) fn reconcile_sudo_home ( ) {
85- // EUID gate: only act when we are *actually* running with root
86- // privileges. A process running as a normal user might have
87- // SUDO_USER exported (inherited from a shell init, set in
88- // user env, or via `sudo -E`) — without the EUID check we'd
89- // happily rewrite HOME to another user's dir and redirect
90- // every subsequent data_dir / cert path there. `geteuid()` is
91- // the cheap, reliable discriminator.
92- //
9385 // SAFETY: geteuid() is async-signal-safe and cannot fail.
9486 let euid = unsafe { libc:: geteuid ( ) } ;
95- if euid != 0 {
96- return ;
97- }
98- let Ok ( sudo_user) = std:: env:: var ( "SUDO_USER" ) else {
87+ let sudo_user_raw = std:: env:: var ( "SUDO_USER" ) . ok ( ) ;
88+ let Some ( sudo_user) = should_reconcile_for ( euid, sudo_user_raw. as_deref ( ) ) else {
9989 return ;
10090 } ;
101- if sudo_user. is_empty ( ) || sudo_user == "root" {
102- return ;
103- }
91+ let sudo_user = sudo_user. to_string ( ) ;
10492 match resolve_home ( & sudo_user) {
10593 Some ( home) => {
10694 tracing:: info!(
@@ -155,6 +143,34 @@ mod unix {
155143 }
156144}
157145
146+ /// Decide whether to re-root HOME for a sudo-style invocation, given a
147+ /// process's effective UID and the value of the `SUDO_USER` env var.
148+ /// Returns `Some(user)` if and only if we should re-root HOME to that
149+ /// user's dir; `None` in every other case (normal user, real root
150+ /// login without sudo, SUDO_USER missing / empty / literally "root").
151+ ///
152+ /// Extracted as a pure function so every branch — including the
153+ /// load-bearing `euid == 0 && SUDO_USER unset` path that must leave
154+ /// HOME as root's own /root — can be asserted with unit tests.
155+ /// Always compiled so the tests run on every host.
156+ fn should_reconcile_for < ' a > ( euid : u32 , sudo_user : Option < & ' a str > ) -> Option < & ' a str > {
157+ // EUID gate: if we're not actually root, `SUDO_USER` could be
158+ // anything (inherited from a shell init, explicitly exported,
159+ // set via `sudo -E`) and rewriting HOME based on it would let a
160+ // normal-user process redirect cert paths to someone else's files.
161+ if euid != 0 {
162+ return None ;
163+ }
164+ // Real root login (no sudo) — SUDO_USER is simply unset. Do NOT
165+ // re-root: root's own /root is the correct HOME for that process.
166+ let user = sudo_user?;
167+ // Empty string or literal "root" also mean "nothing to reconcile".
168+ if user. is_empty ( ) || user == "root" {
169+ return None ;
170+ }
171+ Some ( user)
172+ }
173+
158174/// Pure parser for a single-line `getent passwd` entry.
159175/// Always compiled so unit tests can run on every host.
160176fn sudo_parse_passwd_home ( content : & str ) -> Option < String > {
@@ -1035,6 +1051,21 @@ fn contains_our_block(existing: &str) -> bool {
10351051 false
10361052}
10371053
1054+ /// True iff `existing` has our exact pref line but NOT inside our
1055+ /// marker+pref block — i.e. an orphan `security.enterprise_roots.enabled
1056+ /// = true` whose provenance we can't prove. Used by
1057+ /// `disable_firefox_enterprise_roots` to surface a one-line hint on
1058+ /// uninstall so users upgrading from pre-v1.2.13 installs know their
1059+ /// Firefox user.js still has a cosmetic orphan pref from the old app
1060+ /// (not broken, just left in place because we can't distinguish it
1061+ /// from a user-authored line).
1062+ fn has_bare_enterprise_roots ( existing : & str ) -> bool {
1063+ if contains_our_block ( existing) {
1064+ return false ;
1065+ }
1066+ existing. lines ( ) . any ( |l| l. trim ( ) == FX_PREF )
1067+ }
1068+
10381069fn has_nss_certutil ( ) -> bool {
10391070 // We want NSS's `certutil` (from libnss3-tools), not Windows's
10401071 // built-in `certutil.exe` which shares the binary name but has
@@ -1310,6 +1341,24 @@ fn disable_firefox_enterprise_roots() {
13101341 } ;
13111342 if let Some ( new) = strip_enterprise_roots_block ( & existing) {
13121343 let _ = std:: fs:: write ( & user_js, new) ;
1344+ continue ;
1345+ }
1346+ // No marker block to strip, but an orphan pref is present.
1347+ // Surface it so the user isn't left wondering why user.js
1348+ // still has an enterprise_roots line after --remove-cert.
1349+ // The orphan is harmless (Firefox falls back to its built-in
1350+ // root store once the CA leaves the OS store), but silent
1351+ // leftovers feel like half-done removals.
1352+ if has_bare_enterprise_roots ( & existing) {
1353+ tracing:: info!(
1354+ "Firefox profile {}: `security.enterprise_roots.enabled` pref \
1355+ present without our marker — left in place. If it was written \
1356+ by a pre-v1.2.13 install it's a cosmetic orphan (harmless, \
1357+ Firefox falls back to its built-in root store); remove it \
1358+ manually from user.js if it bothers you. If you set it \
1359+ yourself, leave it.",
1360+ profile. display( )
1361+ ) ;
13131362 }
13141363 }
13151364}
@@ -1560,6 +1609,70 @@ ID_LIKE=debian
15601609 assert_eq ! ( stripped, "" ) ;
15611610 }
15621611
1612+ // ── has_bare_enterprise_roots ──
1613+
1614+ #[ test]
1615+ fn bare_enterprise_roots_detected_when_no_marker_present ( ) {
1616+ let content = "user_pref(\" security.enterprise_roots.enabled\" , true);\n " ;
1617+ assert ! ( has_bare_enterprise_roots( content) ) ;
1618+ }
1619+
1620+ #[ test]
1621+ fn bare_enterprise_roots_not_detected_when_marker_block_present ( ) {
1622+ // Our marker+pref block — strip handles this; has_bare_ must
1623+ // return false so we don't double-warn about a line we own.
1624+ let content = format ! ( "{}\n {}\n " , FX_MARKER , FX_PREF ) ;
1625+ assert ! ( !has_bare_enterprise_roots( & content) ) ;
1626+ }
1627+
1628+ #[ test]
1629+ fn bare_enterprise_roots_not_detected_when_pref_absent ( ) {
1630+ let content = "user_pref(\" other\" , 1);\n " ;
1631+ assert ! ( !has_bare_enterprise_roots( content) ) ;
1632+ }
1633+
1634+ #[ test]
1635+ fn bare_enterprise_roots_ignores_false_variant ( ) {
1636+ // User explicitly set enterprise_roots = false — not our line
1637+ // and not the pre-marker legacy write (which only ever wrote
1638+ // true). No orphan to warn about.
1639+ let content = "user_pref(\" security.enterprise_roots.enabled\" , false);\n " ;
1640+ assert ! ( !has_bare_enterprise_roots( content) ) ;
1641+ }
1642+
1643+ // ── should_reconcile_for ──
1644+
1645+ #[ test]
1646+ fn reconcile_skipped_for_normal_user ( ) {
1647+ // euid != 0 — even with SUDO_USER set we must NOT re-root HOME.
1648+ // A non-root process that happened to inherit SUDO_USER (or
1649+ // used `sudo -E`) shouldn't get to redirect cert paths.
1650+ assert_eq ! ( should_reconcile_for( 1000 , Some ( "alice" ) ) , None ) ;
1651+ assert_eq ! ( should_reconcile_for( 1000 , None ) , None ) ;
1652+ }
1653+
1654+ #[ test]
1655+ fn reconcile_skipped_for_real_root_login_without_sudo ( ) {
1656+ // Load-bearing case the maintainer asked to pin: euid == 0
1657+ // AND no SUDO_USER means the process is a real root login,
1658+ // not a sudo elevation. HOME should stay as /root; we must
1659+ // not try to resolve some other user's home.
1660+ assert_eq ! ( should_reconcile_for( 0 , None ) , None ) ;
1661+ }
1662+
1663+ #[ test]
1664+ fn reconcile_skipped_when_sudo_user_is_empty_or_root ( ) {
1665+ assert_eq ! ( should_reconcile_for( 0 , Some ( "" ) ) , None ) ;
1666+ assert_eq ! ( should_reconcile_for( 0 , Some ( "root" ) ) , None ) ;
1667+ }
1668+
1669+ #[ test]
1670+ fn reconcile_triggers_for_real_sudo_invocation ( ) {
1671+ // euid == 0 AND SUDO_USER points to a non-root user — this is
1672+ // the sudo case we do want to reconcile.
1673+ assert_eq ! ( should_reconcile_for( 0 , Some ( "alice" ) ) , Some ( "alice" ) ) ;
1674+ }
1675+
15631676 // ── sudo_parse_passwd_home ──
15641677
15651678 #[ test]
0 commit comments