@@ -220,15 +220,14 @@ pub fn build_linux_sandbox_command(
220220 return None ;
221221 }
222222
223- let mut args = vec ! [
224- "--user" . to_string( ) ,
225- "--map-root-user" . to_string( ) ,
226- "--mount" . to_string( ) ,
227- "--ipc" . to_string( ) ,
228- "--pid" . to_string( ) ,
229- "--uts" . to_string( ) ,
230- "--fork" . to_string( ) ,
231- ] ;
223+ let mut args: Vec < String > = working_unshare_mapping ( )
224+ . unwrap_or ( UNSHARE_MAPPING_CANDIDATES [ 0 ] )
225+ . iter ( )
226+ . map ( |arg| arg. to_string ( ) )
227+ . collect ( ) ;
228+ // The candidates already carry the namespace flags, so the probe and the
229+ // launcher share a single argument shape; only the opt-in `--net` is
230+ // added here.
232231 if status. network_active {
233232 args. push ( "--net" . to_string ( ) ) ;
234233 }
@@ -282,6 +281,82 @@ fn command_exists(command: &str) -> bool {
282281 . is_some_and ( |paths| env:: split_paths ( & paths) . any ( |path| path. join ( command) . exists ( ) ) )
283282}
284283
284+ /// Candidate `unshare` user-namespace mapping options, in preference order.
285+ ///
286+ /// Most systems accept `--map-root-user` alone. Some hardened containers and
287+ /// seccomp profiles block unprivileged writes to `/proc/self/uid_map`; there,
288+ /// util-linux delegates to the setuid `newuidmap`/`newgidmap` helpers when
289+ /// `--map-auto` is also present.
290+ ///
291+ /// That fallback therefore depends on the setuid helpers (the `uidmap`
292+ /// package on Debian/Ubuntu) and on the current user having a range in
293+ /// `/etc/subuid` and `/etc/subgid`. When either is missing, `--map-auto`
294+ /// fails and the startup probe rejects the candidate, keeping the plain form.
295+ ///
296+ /// Each candidate is the **complete** static argument shape the launcher
297+ /// uses (see `build_linux_sandbox_command`): mapping flags followed by the
298+ /// namespace flags `--mount --ipc --pid --uts --fork`. The startup probe
299+ /// runs each candidate verbatim (plus a trivial program), so probe success
300+ /// implies launch success: on systems where the mapping works but the
301+ /// namespace flags are denied (e.g. AppArmor-restricted CI runners that
302+ /// block mount propagation in user namespaces), the probe fails and the
303+ /// sandbox stays disabled instead of activating a launcher that always
304+ /// errors.
305+ ///
306+ /// `--net` is intentionally absent: it is appended only when network
307+ /// isolation is active (the non-default path), and probing with it would
308+ /// disable the sandbox on hosts that block network-namespace creation (e.g.
309+ /// Docker's default seccomp profile) even when network isolation is never
310+ /// requested.
311+ const UNSHARE_MAPPING_CANDIDATES : & [ & [ & str ] ] = & [
312+ & [
313+ "--user" ,
314+ "--map-root-user" ,
315+ "--mount" ,
316+ "--ipc" ,
317+ "--pid" ,
318+ "--uts" ,
319+ "--fork" ,
320+ ] ,
321+ & [
322+ "--user" ,
323+ "--map-root-user" ,
324+ "--map-auto" ,
325+ "--mount" ,
326+ "--ipc" ,
327+ "--pid" ,
328+ "--uts" ,
329+ "--fork" ,
330+ ] ,
331+ ] ;
332+
333+ /// Probe a candidate `unshare` mapping invocation with a trivial program.
334+ fn unshare_probe ( args : & [ & str ] ) -> bool {
335+ std:: process:: Command :: new ( "unshare" )
336+ . args ( args)
337+ . arg ( "true" )
338+ . stdin ( std:: process:: Stdio :: null ( ) )
339+ . stdout ( std:: process:: Stdio :: null ( ) )
340+ . stderr ( std:: process:: Stdio :: null ( ) )
341+ . status ( )
342+ . is_ok_and ( |status| status. success ( ) )
343+ }
344+
345+ /// The first mapping option set that works on this machine, if any.
346+ ///
347+ /// Probes are cached for the process lifetime; a missing `unshare` binary or a
348+ /// kernel that refuses every mapping yields `None`.
349+ fn working_unshare_mapping ( ) -> Option < & ' static [ & ' static str ] > {
350+ use std:: sync:: OnceLock ;
351+ static MAPPING : OnceLock < Option < & ' static [ & ' static str ] > > = OnceLock :: new ( ) ;
352+ * MAPPING . get_or_init ( || {
353+ UNSHARE_MAPPING_CANDIDATES
354+ . iter ( )
355+ . copied ( )
356+ . find ( |args| unshare_probe ( args) )
357+ } )
358+ }
359+
285360/// Check whether `unshare --user` actually works on this system.
286361/// On some CI environments (e.g. GitHub Actions), the binary exists but
287362/// user namespaces are restricted, causing silent failures.
@@ -292,13 +367,7 @@ fn unshare_user_namespace_works() -> bool {
292367 if !command_exists ( "unshare" ) {
293368 return false ;
294369 }
295- std:: process:: Command :: new ( "unshare" )
296- . args ( [ "--user" , "--map-root-user" , "true" ] )
297- . stdin ( std:: process:: Stdio :: null ( ) )
298- . stdout ( std:: process:: Stdio :: null ( ) )
299- . stderr ( std:: process:: Stdio :: null ( ) )
300- . status ( )
301- . is_ok_and ( |status| status. success ( ) )
370+ working_unshare_mapping ( ) . is_some ( )
302371 } )
303372}
304373
@@ -359,6 +428,52 @@ mod tests {
359428 assert_eq ! ( request. allowed_mounts, vec![ "tmp" ] ) ;
360429 }
361430
431+ #[ test]
432+ fn mapping_candidates_prefer_plain_root_mapping ( ) {
433+ assert ! ( !super :: UNSHARE_MAPPING_CANDIDATES . is_empty( ) ) ;
434+ for candidate in super :: UNSHARE_MAPPING_CANDIDATES {
435+ // Mapping flags.
436+ assert ! ( candidate. contains( & "--user" ) ) ;
437+ assert ! ( candidate. contains( & "--map-root-user" ) ) ;
438+ // Namespace flags the real launcher appends — the probe must
439+ // exercise the full invocation shape, not just mapping flags.
440+ assert ! ( candidate. contains( & "--mount" ) ) ;
441+ assert ! ( candidate. contains( & "--ipc" ) ) ;
442+ assert ! ( candidate. contains( & "--pid" ) ) ;
443+ assert ! ( candidate. contains( & "--uts" ) ) ;
444+ assert ! ( candidate. contains( & "--fork" ) ) ;
445+ }
446+ // The plain form must be tried first; `--map-auto` is only a fallback
447+ // for kernels/containers that block unprivileged uid_map writes.
448+ assert_eq ! (
449+ super :: UNSHARE_MAPPING_CANDIDATES [ 0 ] ,
450+ & [
451+ "--user" ,
452+ "--map-root-user" ,
453+ "--mount" ,
454+ "--ipc" ,
455+ "--pid" ,
456+ "--uts" ,
457+ "--fork" ,
458+ ]
459+ ) ;
460+ // The second candidate inserts `--map-auto` in the position util-linux
461+ // expects (after `--map-root-user`, before the namespace flags).
462+ assert_eq ! (
463+ super :: UNSHARE_MAPPING_CANDIDATES [ 1 ] ,
464+ & [
465+ "--user" ,
466+ "--map-root-user" ,
467+ "--map-auto" ,
468+ "--mount" ,
469+ "--ipc" ,
470+ "--pid" ,
471+ "--uts" ,
472+ "--fork" ,
473+ ]
474+ ) ;
475+ }
476+
362477 #[ test]
363478 fn builds_linux_launcher_with_network_flag_when_requested ( ) {
364479 let config = SandboxConfig :: default ( ) ;
0 commit comments