retach 0.10.0

Persistent terminal sessions with native scrollback passthrough
Documentation
use std::os::unix::fs::DirBuilderExt;

/// Socket directory with proper permissions (0o700, created atomically).
/// Prefers `$XDG_RUNTIME_DIR/retach` (per-user, mode 0700, managed by systemd)
/// and falls back to `/tmp/retach-{uid}`.
pub fn socket_dir() -> anyhow::Result<std::path::PathBuf> {
    let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
        .ok()
        .map(std::path::PathBuf::from);
    socket_dir_impl(runtime_dir.as_deref())
}

/// Internal implementation that accepts an optional runtime directory override.
/// Used by tests to avoid mutating the process environment (which is unsound
/// in multi-threaded test runners).
fn socket_dir_impl(runtime_dir: Option<&std::path::Path>) -> anyhow::Result<std::path::PathBuf> {
    let uid = nix::unistd::getuid();
    // Only trust XDG_RUNTIME_DIR if its parent honours the systemd contract:
    // a real directory, owned by us, mode 0o700. Otherwise fall back to the
    // hardened /tmp path so we never bind under an attacker-controlled parent.
    let dir = match runtime_dir {
        Some(xdg) if xdg_parent_is_trustworthy(xdg, uid) => xdg.join("retach"),
        _ => std::path::PathBuf::from(format!("/tmp/retach-{}", uid)),
    };
    // Create directory with 0o700 atomically — no TOCTOU window
    match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
        Ok(()) => {}
        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
            // Use symlink_metadata (lstat) to detect symlinks — metadata() follows them
            let meta = std::fs::symlink_metadata(&dir)
                .map_err(|e| anyhow::anyhow!("cannot stat socket directory {dir:?}: {e}"))?;
            if meta.file_type().is_symlink() {
                anyhow::bail!(
                    "socket directory {dir:?} is a symlink — possible symlink attack, refusing to start"
                );
            }
            use std::os::unix::fs::MetadataExt;
            if meta.uid() != uid.as_raw() {
                anyhow::bail!(
                    "socket directory {dir:?} owned by uid {} (expected {}) — possible attack",
                    meta.uid(),
                    uid.as_raw(),
                );
            }
            // Fix permissions if they drifted. The directory's mode is the sole
            // access barrier, so a failed repair is fatal — refuse to start.
            use std::os::unix::fs::PermissionsExt;
            if meta.permissions().mode() & 0o777 != 0o700 {
                std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).map_err(
                    |e| anyhow::anyhow!("failed to fix socket directory permissions {dir:?}: {e}"),
                )?;
            }
        }
        Err(e) => {
            return Err(anyhow::anyhow!(
                "failed to create socket directory {dir:?}: {e}"
            ));
        }
    }
    // Re-stat and assert the mode is exactly 0o700 before we trust it.
    use std::os::unix::fs::PermissionsExt;
    let mode = std::fs::metadata(&dir)
        .map_err(|e| anyhow::anyhow!("cannot stat socket directory {dir:?}: {e}"))?
        .permissions()
        .mode()
        & 0o777;
    if mode != 0o700 {
        anyhow::bail!(
            "socket directory {dir:?} has mode {mode:#o} (expected 0o700) — refusing to start"
        );
    }
    Ok(dir)
}

/// Validate that an `XDG_RUNTIME_DIR` honours the systemd contract: a real
/// directory (not a symlink), owned by `uid`, mode 0o700. Returns false on any
/// deviation so the caller falls back to the hardened `/tmp` path.
fn xdg_parent_is_trustworthy(xdg: &std::path::Path, uid: nix::unistd::Uid) -> bool {
    use std::os::unix::fs::{MetadataExt, PermissionsExt};
    let meta = match std::fs::symlink_metadata(xdg) {
        Ok(m) => m,
        Err(_) => return false,
    };
    meta.is_dir()
        && !meta.file_type().is_symlink()
        && meta.uid() == uid.as_raw()
        && meta.permissions().mode() & 0o777 == 0o700
}

/// Return the full path to the server's Unix domain socket.
pub fn socket_path() -> anyhow::Result<std::path::PathBuf> {
    Ok(socket_dir()?.join("retach.sock"))
}

/// Return the full path to the server startup lockfile.
pub fn lock_path() -> anyhow::Result<std::path::PathBuf> {
    Ok(socket_dir()?.join("retach.lock"))
}

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

    #[test]
    fn socket_dir_returns_correct_format() {
        let uid = nix::unistd::getuid();
        let dir = socket_dir().unwrap();
        if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
            let expected = std::path::PathBuf::from(xdg).join("retach");
            assert_eq!(dir, expected);
        } else {
            let expected = std::path::PathBuf::from(format!("/tmp/retach-{}", uid));
            assert_eq!(dir, expected);
        }
    }

    #[test]
    fn socket_path_ends_with_sock() {
        let path = socket_path().unwrap();
        assert!(
            path.ends_with("retach.sock"),
            "socket_path should end with 'retach.sock', got: {:?}",
            path
        );
    }

    #[test]
    fn socket_dir_creates_directory() {
        let dir = socket_dir().unwrap();
        assert!(
            dir.exists(),
            "socket_dir() should create the directory at {:?}",
            dir
        );
        assert!(
            dir.is_dir(),
            "socket_dir() path should be a directory, not a file"
        );
    }

    #[test]
    fn socket_dir_has_correct_permissions() {
        use std::os::unix::fs::PermissionsExt;

        let dir = socket_dir().unwrap();
        let meta = std::fs::metadata(&dir).expect("should be able to stat socket directory");
        let mode = meta.permissions().mode() & 0o777;
        assert_eq!(
            mode, 0o700,
            "socket directory should have mode 0o700, got: {:#o}",
            mode
        );
    }

    #[test]
    fn socket_dir_idempotent() {
        let first = socket_dir().unwrap();
        let second = socket_dir().unwrap();
        assert_eq!(
            first, second,
            "calling socket_dir() twice should return the same path"
        );
        assert!(
            second.exists(),
            "directory should still exist after second call"
        );
    }

    #[test]
    fn socket_dir_rejects_symlink() {
        use std::os::unix::fs::PermissionsExt;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
        let real_dir = tmp.path().join("real");
        let sym_dir = tmp.path().join("retach");
        std::fs::create_dir(&real_dir).unwrap();
        std::os::unix::fs::symlink(&real_dir, &sym_dir).unwrap();

        // Use socket_dir_impl with an explicit override instead of mutating
        // the process environment, which is unsound in multi-threaded test runners.
        let result = socket_dir_impl(Some(tmp.path()));

        assert!(result.is_err(), "should reject symlink socket directory");
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("symlink"),
            "error should mention symlink: {}",
            err
        );
    }

    #[test]
    fn socket_dir_repairs_wrong_permissions() {
        use std::os::unix::fs::PermissionsExt;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
        let dir = tmp.path().join("retach");
        std::fs::DirBuilder::new().mode(0o755).create(&dir).unwrap();

        // Use socket_dir_impl with an explicit override instead of mutating
        // the process environment, which is unsound in multi-threaded test runners.
        let result = socket_dir_impl(Some(tmp.path()));

        assert!(result.is_ok(), "should succeed and repair permissions");
        let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
        assert_eq!(
            mode, 0o700,
            "permissions should be repaired to 0o700, got: {:#o}",
            mode
        );
    }

    #[test]
    fn socket_dir_fails_when_repair_cannot_set_mode() {
        use std::os::unix::fs::PermissionsExt;
        // The parent stays trustworthy (0o700, owned, writable) so we reach the
        // repair path; the child carries the immutable flag so chmod fails with
        // EPERM. A failed repair must be fatal, not a warning.
        let tmp = tempfile::tempdir().unwrap();
        std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
        let dir = tmp.path().join("retach");
        std::fs::DirBuilder::new().mode(0o755).create(&dir).unwrap();

        // chflags uchg (macOS) / chattr +i (Linux) makes set_permissions fail.
        let set_immutable = |on: bool| {
            #[cfg(target_os = "macos")]
            let ok = std::process::Command::new("chflags")
                .arg(if on { "uchg" } else { "nouchg" })
                .arg(&dir)
                .status()
                .map(|s| s.success())
                .unwrap_or(false);
            #[cfg(target_os = "linux")]
            let ok = std::process::Command::new("chattr")
                .arg(if on { "+i" } else { "-i" })
                .arg(&dir)
                .status()
                .map(|s| s.success())
                .unwrap_or(false);
            #[cfg(not(any(target_os = "macos", target_os = "linux")))]
            let ok = false;
            ok
        };

        if !set_immutable(true) {
            // Cannot make chmod fail in this environment (e.g. no privileges /
            // filesystem support) — skip rather than assert nothing.
            eprintln!("skipping: could not set immutable flag");
            return;
        }

        let result = socket_dir_impl(Some(tmp.path()));

        // Clear the flag so tempdir cleanup can remove the directory.
        set_immutable(false);

        assert!(
            result.is_err(),
            "failed permission repair must be fatal, got: {:?}",
            result
        );
    }

    #[test]
    fn socket_dir_falls_back_when_xdg_parent_world_writable() {
        use std::os::unix::fs::PermissionsExt;
        // XDG parent with a non-0o700 mode violates the systemd contract — we
        // must ignore it and fall back to /tmp/retach-{uid} instead of binding
        // under an untrusted parent.
        let tmp = tempfile::tempdir().unwrap();
        std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o777)).unwrap();

        let uid = nix::unistd::getuid();
        let result = socket_dir_impl(Some(tmp.path())).unwrap();

        let _ = std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700));

        assert_eq!(
            result,
            std::path::PathBuf::from(format!("/tmp/retach-{}", uid)),
            "untrusted XDG parent should fall back to /tmp path"
        );
    }

    #[test]
    fn socket_dir_uses_xdg_when_parent_trustworthy() {
        use std::os::unix::fs::PermissionsExt;
        // A 0o700, uid-owned parent satisfies the contract — use $XDG/retach.
        let tmp = tempfile::tempdir().unwrap();
        std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();

        let result = socket_dir_impl(Some(tmp.path())).unwrap();

        assert_eq!(
            result,
            tmp.path().join("retach"),
            "trustworthy XDG parent should be used"
        );
    }

    #[test]
    fn lock_path_format() {
        let path = lock_path().unwrap();
        assert!(
            path.ends_with("retach.lock"),
            "lock_path should end with 'retach.lock', got: {:?}",
            path
        );
    }
}