@@ -19,17 +19,63 @@ use tracing::debug;
1919/// `/proc/{pid}/cmdline` because `argv[0]` is trivially spoofable by any
2020/// process and must not be used as a trusted identity source.
2121///
22- /// If this fails, ensure the proxy process has permission to read
23- /// `/proc/<pid>/exe` (e.g. same user, or `CAP_SYS_PTRACE`).
22+ /// ### Unlinked binaries (`(deleted)` suffix)
23+ ///
24+ /// When a running binary is unlinked from its filesystem path — the common
25+ /// case is a `docker cp` hot-swap of `/opt/openshell/bin/openshell-sandbox`
26+ /// during a `cluster-deploy-fast` dev upgrade — the kernel appends the
27+ /// literal string `" (deleted)"` to the `/proc/<pid>/exe` readlink target.
28+ /// The raw tainted path (e.g. `"/opt/openshell/bin/openshell-sandbox (deleted)"`)
29+ /// is not a real filesystem path: any downstream `stat()` fails with `ENOENT`.
30+ ///
31+ /// We strip the suffix so callers see a clean, grep-friendly path suitable
32+ /// for cache keys and log messages. The strip is guarded: we only strip when
33+ /// `stat()` on the raw readlink target reports `NotFound`, so a live executable
34+ /// whose basename literally ends with `" (deleted)"` is returned unchanged.
35+ /// The comparison is done on raw bytes via `OsStrExt`, so filenames that are
36+ /// not valid UTF-8 are still handled correctly. Exactly one kernel-added
37+ /// suffix is stripped.
38+ ///
39+ /// This does NOT claim the file at the stripped path is the same binary that
40+ /// the process is executing — the on-disk inode may now be arbitrary. Callers
41+ /// that need to verify the running binary's *contents* (for integrity
42+ /// checking) should read the magic `/proc/<pid>/exe` symlink directly via
43+ /// `File::open`, which procfs resolves to the live in-memory executable even
44+ /// when the original inode has been unlinked.
45+ ///
46+ /// If the readlink itself fails, ensure the proxy process has permission
47+ /// to read `/proc/<pid>/exe` (e.g. same user, or `CAP_SYS_PTRACE`).
2448#[ cfg( target_os = "linux" ) ]
2549pub fn binary_path ( pid : i32 ) -> Result < PathBuf > {
26- std:: fs:: read_link ( format ! ( "/proc/{pid}/exe" ) ) . map_err ( |e| {
50+ use std:: ffi:: OsString ;
51+ use std:: io:: ErrorKind ;
52+ use std:: os:: unix:: ffi:: { OsStrExt , OsStringExt } ;
53+
54+ const DELETED_SUFFIX : & [ u8 ] = b" (deleted)" ;
55+
56+ let link = format ! ( "/proc/{pid}/exe" ) ;
57+ let target = std:: fs:: read_link ( & link) . map_err ( |e| {
2758 miette:: miette!(
2859 "Failed to read /proc/{pid}/exe: {e}. \
2960 Cannot determine binary identity — denying request. \
3061 Hint: the proxy may need CAP_SYS_PTRACE or to run as the same user."
3162 )
32- } )
63+ } ) ?;
64+
65+ // Only strip when the raw readlink target cannot be stat'd and its bytes
66+ // end with the kernel-added suffix. This preserves live executables whose
67+ // basename legitimately ends with " (deleted)" and handles non-UTF-8
68+ // filenames correctly.
69+ let raw_target_missing =
70+ matches ! ( std:: fs:: metadata( & target) , Err ( err) if err. kind( ) == ErrorKind :: NotFound ) ;
71+
72+ let bytes = target. as_os_str ( ) . as_bytes ( ) ;
73+ if raw_target_missing && bytes. ends_with ( DELETED_SUFFIX ) {
74+ let stripped = bytes[ ..bytes. len ( ) - DELETED_SUFFIX . len ( ) ] . to_vec ( ) ;
75+ return Ok ( PathBuf :: from ( OsString :: from_vec ( stripped) ) ) ;
76+ }
77+
78+ Ok ( target)
3379}
3480
3581/// Resolve the binary path of the TCP peer inside a sandbox network namespace.
@@ -391,6 +437,167 @@ mod tests {
391437 assert ! ( path. exists( ) ) ;
392438 }
393439
440+ /// Verify that an unlinked binary's path is returned without the
441+ /// kernel's " (deleted)" suffix. This is the common case during a
442+ /// `docker cp` hot-swap of the supervisor binary — before this strip,
443+ /// callers that `stat()` the returned path get `ENOENT` and the
444+ /// ancestor integrity check in the CONNECT proxy denies every request.
445+ #[ cfg( target_os = "linux" ) ]
446+ #[ test]
447+ fn binary_path_strips_deleted_suffix ( ) {
448+ use std:: os:: unix:: fs:: PermissionsExt ;
449+
450+ // Copy /bin/sleep to a temp path we control so we can unlink it.
451+ let tmp = tempfile:: TempDir :: new ( ) . unwrap ( ) ;
452+ let exe_path = tmp. path ( ) . join ( "deleted-sleep" ) ;
453+ std:: fs:: copy ( "/bin/sleep" , & exe_path) . unwrap ( ) ;
454+ std:: fs:: set_permissions ( & exe_path, std:: fs:: Permissions :: from_mode ( 0o755 ) ) . unwrap ( ) ;
455+
456+ // Spawn a child from the temp binary, then unlink it while the
457+ // child is still running. The child keeps the exec mapping via
458+ // `/proc/<pid>/exe`, but readlink will now return the tainted
459+ // "<path> (deleted)" string.
460+ let mut child = std:: process:: Command :: new ( & exe_path)
461+ . arg ( "5" )
462+ . spawn ( )
463+ . unwrap ( ) ;
464+ let pid: i32 = child. id ( ) . cast_signed ( ) ;
465+ std:: fs:: remove_file ( & exe_path) . unwrap ( ) ;
466+
467+ // Sanity check: the raw readlink should contain " (deleted)".
468+ let raw = std:: fs:: read_link ( format ! ( "/proc/{pid}/exe" ) )
469+ . unwrap ( )
470+ . to_string_lossy ( )
471+ . into_owned ( ) ;
472+ assert ! (
473+ raw. ends_with( " (deleted)" ) ,
474+ "kernel should append ' (deleted)' to unlinked exe readlink; got {raw:?}"
475+ ) ;
476+
477+ // The public API should return the stripped path, not the tainted one.
478+ let resolved = binary_path ( pid) . expect ( "binary_path should succeed for deleted binary" ) ;
479+ assert_eq ! (
480+ resolved, exe_path,
481+ "binary_path should strip the ' (deleted)' suffix"
482+ ) ;
483+ let resolved_str = resolved. to_string_lossy ( ) ;
484+ assert ! (
485+ !resolved_str. contains( "(deleted)" ) ,
486+ "stripped path must not contain '(deleted)'; got {resolved_str:?}"
487+ ) ;
488+
489+ let _ = child. kill ( ) ;
490+ let _ = child. wait ( ) ;
491+ }
492+
493+ /// A live executable whose basename literally ends with `" (deleted)"`
494+ /// must be returned unchanged — we only strip when `stat()` reports
495+ /// the raw readlink target missing. This guards against the trusted
496+ /// identity source misattributing a running binary to a truncated
497+ /// sibling path.
498+ #[ cfg( target_os = "linux" ) ]
499+ #[ test]
500+ fn binary_path_preserves_live_deleted_basename ( ) {
501+ use std:: os:: unix:: fs:: PermissionsExt ;
502+
503+ let tmp = tempfile:: TempDir :: new ( ) . unwrap ( ) ;
504+ // Basename literally ends with " (deleted)" while the file is still
505+ // on disk — a pathological but legal filename.
506+ let exe_path = tmp. path ( ) . join ( "sleepy (deleted)" ) ;
507+ std:: fs:: copy ( "/bin/sleep" , & exe_path) . unwrap ( ) ;
508+ std:: fs:: set_permissions ( & exe_path, std:: fs:: Permissions :: from_mode ( 0o755 ) ) . unwrap ( ) ;
509+
510+ let mut child = std:: process:: Command :: new ( & exe_path)
511+ . arg ( "5" )
512+ . spawn ( )
513+ . unwrap ( ) ;
514+ let pid: i32 = child. id ( ) . cast_signed ( ) ;
515+
516+ // File is still linked — binary_path must return the path unchanged,
517+ // suffix and all.
518+ let resolved = binary_path ( pid) . expect ( "binary_path should succeed for live binary" ) ;
519+ assert_eq ! (
520+ resolved, exe_path,
521+ "binary_path must NOT strip ' (deleted)' from a live executable's basename"
522+ ) ;
523+ assert ! (
524+ resolved. to_string_lossy( ) . ends_with( " (deleted)" ) ,
525+ "stripped path unexpectedly trimmed a real filename: {resolved:?}"
526+ ) ;
527+
528+ let _ = child. kill ( ) ;
529+ let _ = child. wait ( ) ;
530+ }
531+
532+ /// An unlinked executable whose filename contains non-UTF-8 bytes must
533+ /// still strip exactly one kernel-added `" (deleted)"` suffix. We operate
534+ /// on raw bytes via `OsStrExt`, so invalid UTF-8 is not a reason to skip
535+ /// the strip and return a path that downstream `stat()` calls will reject.
536+ #[ cfg( target_os = "linux" ) ]
537+ #[ test]
538+ fn binary_path_strips_suffix_for_non_utf8_filename ( ) {
539+ use std:: ffi:: OsString ;
540+ use std:: io:: Write ;
541+ use std:: os:: unix:: ffi:: { OsStrExt , OsStringExt } ;
542+ use std:: os:: unix:: fs:: { OpenOptionsExt , PermissionsExt } ;
543+
544+ let tmp = tempfile:: TempDir :: new ( ) . unwrap ( ) ;
545+ // 0xFF is not valid UTF-8. Build the filename on raw bytes.
546+ let mut raw_name: Vec < u8 > = b"badname-" . to_vec ( ) ;
547+ raw_name. push ( 0xFF ) ;
548+ raw_name. extend_from_slice ( b".bin" ) ;
549+ let exe_path = tmp. path ( ) . join ( OsString :: from_vec ( raw_name) ) ;
550+
551+ // Write bytes explicitly (instead of `std::fs::copy`) with an
552+ // explicit `sync_all()` + scope drop so the write fd is fully closed
553+ // before we `exec()` the file. Otherwise concurrent tests can race
554+ // the kernel into returning ETXTBSY on spawn.
555+ let bytes = std:: fs:: read ( "/bin/sleep" ) . expect ( "read /bin/sleep" ) ;
556+ {
557+ let mut f = std:: fs:: OpenOptions :: new ( )
558+ . write ( true )
559+ . create_new ( true )
560+ . mode ( 0o755 )
561+ . open ( & exe_path)
562+ . expect ( "create non-UTF-8 target file" ) ;
563+ f. write_all ( & bytes) . expect ( "write bytes" ) ;
564+ f. sync_all ( ) . expect ( "sync_all before exec" ) ;
565+ }
566+
567+ let mut child = std:: process:: Command :: new ( & exe_path)
568+ . arg ( "5" )
569+ . spawn ( )
570+ . unwrap ( ) ;
571+ let pid: i32 = child. id ( ) . cast_signed ( ) ;
572+ std:: fs:: remove_file ( & exe_path) . unwrap ( ) ;
573+
574+ // Sanity: raw readlink ends with " (deleted)" and is not valid UTF-8.
575+ let raw = std:: fs:: read_link ( format ! ( "/proc/{pid}/exe" ) ) . unwrap ( ) ;
576+ let raw_bytes = raw. as_os_str ( ) . as_bytes ( ) ;
577+ assert ! (
578+ raw_bytes. ends_with( b" (deleted)" ) ,
579+ "kernel should append ' (deleted)' to unlinked exe readlink"
580+ ) ;
581+ assert ! (
582+ std:: str :: from_utf8( raw_bytes) . is_err( ) ,
583+ "test precondition: raw readlink must contain non-UTF-8 bytes"
584+ ) ;
585+
586+ let resolved =
587+ binary_path ( pid) . expect ( "binary_path should succeed for non-UTF-8 unlinked path" ) ;
588+ assert_eq ! (
589+ resolved, exe_path,
590+ "binary_path must strip exactly one ' (deleted)' suffix for non-UTF-8 paths"
591+ ) ;
592+ assert ! (
593+ !resolved. as_os_str( ) . as_bytes( ) . ends_with( b" (deleted)" ) ,
594+ "stripped path must not end with ' (deleted)'"
595+ ) ;
596+
597+ let _ = child. kill ( ) ;
598+ let _ = child. wait ( ) ;
599+ }
600+
394601 #[ cfg( target_os = "linux" ) ]
395602 #[ test]
396603 fn collect_descendants_includes_self ( ) {
0 commit comments