pseudoroot 0.2.1

A Rust fakeroot via library interposition (LD_PRELOAD / DYLD_INSERT_LIBRARIES): run commands as if root, no real root access needed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! pseudoroot library — API-compatible with [`fakeroost`](https://github.com/koca-build/fakeroost).
//!
//! Swap backends by changing the import:
//!
//! ```ignore
//! use fakeroost::FakerootCommandExt;   // ptrace + seccomp
//! // use pseudoroot::FakerootCommandExt; // LD_PRELOAD
//!
//! fn main() {
//!     pseudoroot::init(); // required: handles session re-exec (no-op otherwise)
//!     std::process::Command::new("id").fakeroot().status().unwrap();
//! }
//! ```
//!
//! Pseudoroot-specific options (`PSEUDOROOT_UID`, `PSEUDOROOT_GID`,
//! `PSEUDOROOT_DAEMON_SOCKET`, `PSEUDOROOT_LIB`) are passed via [`Command::env`]
//! before calling [`.fakeroot()`](FakerootCommandExt::fakeroot).

use pseudoroot_core::daemon_client::DAEMON_SOCKET_ENV;
use pseudoroot_core::daemon_server::SessionDaemon;
use pseudoroot_core::shm_map::{SHM_FD_ENV, SHM_LEN_ENV, ShmInodeMap};

/// Disable memfd session backing and use Unix socket IPC instead.
pub const SESSION_SHM_ENV: &str = "PSEUDOROOT_SESSION_SHM";
use std::env;
use std::ffi::OsString;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
use tempfile::TempDir;

/// The interposed library, embedded at build time by `build.rs`.
static EMBEDDED_LIB: &[u8] = include_bytes!(env!("PSEUDOROOT_LIB_EMBED_PATH"));

mod sealed {
    pub trait Sealed {}
    impl Sealed for std::process::Command {}
}

/// Environment variable marking a process re-executed to supervise a fakeroot session.
/// Set by [`FakerootCommandExt::fakeroot`], consumed by [`init`].
const SUPERVISE_VAR: &str = "__PSEUDOROOT_SUPERVISE";

/// Opt out of session supervision and use per-process state only.
pub const STANDALONE_ENV: &str = "PSEUDOROOT_STANDALONE";

/// Environment variable override for the interposed library path (tests/installs).
pub const LIB_PATH_ENV: &str = "PSEUDOROOT_LIB";

/// Adds `fakeroot`-like execution to [`std::process::Command`].
///
/// By default each `.fakeroot()` invocation re-executes the current program in a
/// short-lived session with an in-process IPC server, runs the target under
/// `LD_PRELOAD`, and tears down on exit — so separate `exec`s (`install`, `tar`, …)
/// share one inode map without manual `--daemon` or a separate `pdrd` install.
pub trait FakerootCommandExt: sealed::Sealed {
    /// Rewrite this command so that running it executes the same program under
    /// fakeroot, returning it as a plain [`std::process::Command`].
    ///
    /// Configure stdio (`.stdout`, pipes, …) on the **returned** command, not before:
    /// `Command` exposes no way to read back its stdio, so any redirection set prior
    /// to this call cannot be carried over.
    #[must_use = "fakeroot() builds a new Command; running the original executes unwrapped"]
    fn fakeroot(&self) -> Command;
}

impl FakerootCommandExt for Command {
    fn fakeroot(&self) -> Command {
        if session_supervision_enabled(self) {
            return build_supervise_command(self);
        }

        let lib_path = library_path().unwrap_or_else(|| {
            panic!(
                "pseudoroot: could not find libpseudoroot_lib.so — build with \
                 `cargo build -p pseudoroot-lib` or set {LIB_PATH_ENV}"
            );
        });

        let mut cmd = clone_command(self);
        apply_preload(&mut cmd, &lib_path);
        cmd
    }
}

/// Become the session supervisor when this process was launched as one.
///
/// Call once at the start of `main`, identical to fakeroost. On a normal launch
/// this returns immediately. On a supervise re-exec it runs the requested command
/// under a private in-process daemon and exits with that command's status, never returning.
///
/// A `#[ctor]` hook also calls this before `main` so test binaries and other
/// consumers work without an explicit call site.
pub fn init() {
    if env::var_os(SUPERVISE_VAR).is_none() {
        return;
    }

    let args: Vec<OsString> = env::args_os().skip(1).collect();
    let code = match run_session(&args) {
        Ok(status) => status.code().unwrap_or(1),
        Err(err) => {
            eprintln!("pseudoroot: {err}");
            1
        }
    };
    std::process::exit(code);
}

#[cfg(any(target_os = "linux", target_os = "macos"))]
#[ctor::ctor]
fn supervise_ctor() {
    init();
}

/// Locate the interposed shared library, extracting the embedded copy to a
/// cache directory on first use.
#[must_use]
pub fn library_path() -> Option<PathBuf> {
    if let Ok(path) = env::var(LIB_PATH_ENV) {
        let path = PathBuf::from(path);
        if path.exists() {
            return Some(path);
        }
    }

    match extract_embedded_lib() {
        Ok(path) => Some(path),
        Err(err) => {
            eprintln!(
                "pseudoroot: failed to extract embedded library: {err} \
                 (set {LIB_PATH_ENV} to override)"
            );
            None
        }
    }
}

/// Root directory for cached extracted assets.
///
/// Always the Linux/XDG-style path, regardless of platform: `$XDG_CACHE_HOME`
/// if set, else `$HOME/.cache`, else `std::env::temp_dir()` as a last resort.
/// Preferring the user's cache dir over `/tmp` matters beyond tidiness: `/tmp`
/// is frequently mounted `noexec` (hardened distros, containers), which blocks
/// `mmap(PROT_EXEC)` there — exactly what `dlopen()`/`LD_PRELOAD` needs.
/// `~/.cache` is essentially never `noexec`.
fn cache_root() -> PathBuf {
    if let Ok(xdg) = env::var("XDG_CACHE_HOME")
        && !xdg.is_empty()
    {
        return PathBuf::from(xdg);
    }
    if let Ok(home) = env::var("HOME") {
        return PathBuf::from(home).join(".cache");
    }
    env::temp_dir()
}

/// Extract [`EMBEDDED_LIB`] to a content-hash-keyed cache path, skipping the
/// write if it's already there.
fn extract_embedded_lib() -> io::Result<PathBuf> {
    let lib_name = if cfg!(target_os = "macos") {
        "libpseudoroot_lib.dylib"
    } else {
        "libpseudoroot_lib.so"
    };

    let mut hasher = DefaultHasher::new();
    EMBEDDED_LIB.hash(&mut hasher);
    let hash = hasher.finish();

    let mut dir = cache_root().join("pseudoroot").join("embed");
    // /tmp is shared/1777; ~/.cache is already private, so only the temp_dir
    // fallback needs a uid segment to avoid cross-user permission contention.
    if dir.starts_with(env::temp_dir()) {
        dir = dir.join(format!("uid-{}", unsafe { libc::getuid() }));
    }
    let dir = dir.join(format!("{hash:016x}"));
    let path = dir.join(lib_name);

    if path.exists() {
        return Ok(path);
    }

    std::fs::create_dir_all(&dir)?;
    let tmp_path = dir.join(format!(".{lib_name}.{}.tmp", std::process::id()));
    std::fs::write(&tmp_path, EMBEDDED_LIB)?;
    std::fs::rename(&tmp_path, &path)?; // atomic on POSIX
    Ok(path)
}

fn session_supervision_enabled(cmd: &Command) -> bool {
    if env::var_os(SUPERVISE_VAR).is_some() {
        return false;
    }
    if command_has_env(cmd, DAEMON_SOCKET_ENV) || env::var(DAEMON_SOCKET_ENV).is_ok() {
        return false;
    }
    if command_has_env(cmd, STANDALONE_ENV) || env::var(STANDALONE_ENV).is_ok() {
        return false;
    }
    true
}

fn build_supervise_command(base: &Command) -> Command {
    let exe = supervisor_exe().unwrap_or_else(|err| {
        panic!("pseudoroot: could not resolve supervisor executable: {err}");
    });

    let mut cmd = Command::new(exe);
    cmd.env(SUPERVISE_VAR, "1");
    cmd.arg(base.get_program());
    cmd.args(base.get_args());
    for (key, val) in base.get_envs() {
        match val {
            Some(val) => cmd.env(key, val),
            None => cmd.env_remove(key),
        };
    }
    if let Some(dir) = base.get_current_dir() {
        cmd.current_dir(dir);
    }
    cmd
}

fn run_session(target_args: &[OsString]) -> Result<ExitStatus, String> {
    let program = target_args
        .first()
        .ok_or_else(|| "no program given to pseudoroot session".to_string())?;

    let lib_path = library_path().ok_or_else(|| {
        format!(
            "could not find libpseudoroot_lib.so — build with \
             `cargo build -p pseudoroot-lib` or set {LIB_PATH_ENV}"
        )
    })?;
    let uid = env_u32("PSEUDOROOT_UID", 0);
    let gid = env_u32("PSEUDOROOT_GID", 0);

    let socket_dir =
        TempDir::new().map_err(|err| format!("failed to create session dir: {err}"))?;

    let use_shm = session_shm_enabled();
    let shm = if use_shm {
        Some(
            ShmInodeMap::create(1 << 16, uid, gid)
                .map_err(|err| format!("failed to create session shm map: {err}"))?,
        )
    } else {
        None
    };
    let daemon = if shm.is_none() {
        let socket_path = socket_dir.path().join("pseudoroot.sock");
        Some(
            SessionDaemon::start(&socket_path, uid, gid)
                .map_err(|err| format!("failed to start session daemon: {err}"))?,
        )
    } else {
        None
    };

    let mut cmd = Command::new(program);
    cmd.args(&target_args[1..]);
    // The rest of the environment is inherited; the marker must be *removed*,
    // not merely left out of overrides, or a pseudoroot-linked target would
    // re-enter supervision on its own argv.
    cmd.env_remove(SUPERVISE_VAR);
    if let Some(shm) = &shm {
        // fd inheritance across exec is plain POSIX: clear CLOEXEC on the shm
        // descriptor and hand the child its number plus the map length. Works
        // the same on Linux (memfd) and macOS (shm_open'd object).
        let fd = shm.inherited_fd();
        let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
        if flags >= 0 {
            let _ = unsafe { libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) };
        }
        cmd.env(SHM_FD_ENV, fd.to_string());
        cmd.env(SHM_LEN_ENV, shm.map_len().to_string());
    } else if let Some(daemon) = &daemon {
        cmd.env(DAEMON_SOCKET_ENV, daemon.socket_path());
    }
    apply_preload(&mut cmd, &lib_path);

    let status = cmd
        .status()
        .map_err(|err| format!("failed to execute supervised command: {err}"))?;

    Ok(status)
}

#[inline]
fn session_shm_enabled() -> bool {
    #[cfg(any(target_os = "linux", target_os = "macos"))]
    {
        match env::var(SESSION_SHM_ENV) {
            Ok(value) => value != "0" && !value.eq_ignore_ascii_case("false"),
            Err(_) => true,
        }
    }
    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
    {
        false
    }
}

fn supervisor_exe() -> Result<PathBuf, String> {
    #[cfg(target_os = "linux")]
    {
        Ok(PathBuf::from("/proc/self/exe"))
    }
    #[cfg(target_os = "macos")]
    {
        env::current_exe().map_err(|err| err.to_string())
    }
    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
    {
        Err("unsupported platform (Linux and macOS only)".into())
    }
}

fn clone_command(base: &Command) -> Command {
    let mut cmd = Command::new(base.get_program());
    cmd.args(base.get_args());
    for (key, val) in base.get_envs() {
        match val {
            Some(val) => cmd.env(key, val),
            None => cmd.env_remove(key),
        };
    }
    if let Some(dir) = base.get_current_dir() {
        cmd.current_dir(dir);
    }
    cmd
}

fn apply_preload(cmd: &mut Command, lib_path: &Path) {
    let env_var_name = if cfg!(target_os = "linux") {
        "LD_PRELOAD"
    } else if cfg!(target_os = "macos") {
        "DYLD_INSERT_LIBRARIES"
    } else {
        panic!("pseudoroot: unsupported platform (Linux and macOS only)");
    };

    ensure_default_env(cmd, "PSEUDOROOT_UID", "0");
    ensure_default_env(cmd, "PSEUDOROOT_GID", "0");

    let env_var_value = if let Some(existing) = env_in_command(cmd, env_var_name) {
        let mut value = existing;
        value.push(":");
        value.push(lib_path.as_os_str());
        value
    } else if let Some(existing) = env::var_os(env_var_name) {
        let mut value = existing;
        value.push(":");
        value.push(lib_path.as_os_str());
        value
    } else {
        OsString::from(lib_path)
    };

    cmd.env(env_var_name, env_var_value);
}

fn env_u32(key: &str, default: u32) -> u32 {
    env::var(key)
        .ok()
        .and_then(|value| value.parse().ok())
        .unwrap_or(default)
}

fn ensure_default_env(cmd: &mut Command, key: &str, default: &str) {
    // A value in the calling process's environment is inherited by the child;
    // defaulting over it would clobber it (same lookup order as the preload
    // merge above).
    if !command_has_env(cmd, key) && env::var_os(key).is_none() {
        cmd.env(key, default);
    }
}

fn command_has_env(cmd: &Command, key: &str) -> bool {
    cmd.get_envs().any(|(k, v)| k == key && v.is_some())
}

fn env_in_command(cmd: &Command, key: &str) -> Option<OsString> {
    cmd.get_envs()
        .find_map(|(k, v)| (k == key).then(|| v.map(|s| s.to_os_string())).flatten())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn session_supervision_skips_when_daemon_socket_set() {
        let mut cmd = Command::new("true");
        cmd.env(DAEMON_SOCKET_ENV, "/tmp/pseudoroot.sock");
        assert!(!session_supervision_enabled(&cmd));
    }

    #[test]
    fn session_supervision_skips_when_standalone_set() {
        let mut cmd = Command::new("true");
        cmd.env(STANDALONE_ENV, "1");
        assert!(!session_supervision_enabled(&cmd));
    }
}