clonetty 0.0.1

Spawn a new Alacritty window cloning an existing terminal's working directory and its nested-shell environment stack, so Ctrl-D peels back one shell layer at a time.
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
//! Reading a terminal's state from `/proc`.
//!
//! On Linux every process exposes its live state under `/proc/<pid>/`:
//!   * `cwd`      — a symlink to the process's current working directory,
//!   * `environ`  — the environment it was started with, NUL-separated,
//!   * `comm`     — the executable name (truncated to 15 bytes),
//!   * `status`   — human-readable status, including `Name:` and `PPid:`.
//!
//! Reading another process's `cwd`/`environ` requires the same UID (or root),
//! which is fine for cloning your own terminals.

use anyhow::{Context, Result, bail};
use std::fs;
use std::path::{Path, PathBuf};

/// One shell layer's captured environment.
///
/// We keep the environment (not the PID) because reconstruction replays
/// *environments*, not processes: the source processes are never signalled or
/// modified. Everything here is read out of `/proc` read-only, so capturing a
/// terminal cannot exit or alter the shells running in it.
pub type Level = Vec<(String, String)>;

/// A terminal captured as the stack of nested shell environments that produced
/// it, plus the innermost shell's working directory.
///
/// `levels[0]` is the outermost login shell — the one Alacritty itself spawned,
/// which predates any `nix-shell`/subshell nesting. `levels.last()` is the
/// innermost interactive shell (the "leaf"). Each additional level is one
/// nested shell (e.g. a `nix-shell`) layered on top of the one below it.
#[derive(Debug)]
pub struct Terminal {
    /// Working directory of the innermost shell — where the clone should open.
    pub cwd: PathBuf,
    /// Environments of each nested shell, outermost → innermost, with adjacent
    /// duplicate layers collapsed.
    pub levels: Vec<Level>,
    /// Whether an `alacritty` ancestor was reached while walking up the tree.
    /// `false` means we hit the top without finding one, so `levels[0]` may not
    /// be a pristine login shell (best-effort fallback; the caller warns).
    pub found_alacritty: bool,
}

impl Terminal {
    /// Capture the terminal this process is *already running in* (high fidelity).
    ///
    /// We start from our parent (the shell that launched us) and walk up the
    /// process tree; our own cwd is the innermost shell's cwd. Crucially, **our
    /// own inherited environment is the innermost shell's live, post-setup env** —
    /// we are a child spawned after any `nix-shell`/`venv`/`export` ran — so this
    /// path captures `PATH` to scoped commands and interactive changes that are
    /// invisible in the shell's own `/proc/environ` (see [`levels_from_chain`]).
    pub fn current() -> Result<Self> {
        let cwd = std::env::current_dir().context("reading current working directory")?;
        let self_env: Level = std::env::vars().collect();
        let start = ppid_of(std::process::id()).unwrap_or_else(std::process::id);
        let (chain, found_alacritty) = ancestor_chain(start);
        let mut levels = levels_from_chain(&chain, self_env.clone());
        if levels.is_empty() {
            // Fallback: no readable ancestry (e.g. an odd launcher).
            levels.push(self_env);
        }
        Ok(Self { cwd, levels, found_alacritty })
    }

    /// Capture the terminal that owns `pid` (external / best-effort).
    ///
    /// If `pid` is the Alacritty process itself we descend to its innermost shell
    /// (see [`resolve_shell_pid`]/[`descend_to_leaf`]). Env is read from `/proc`,
    /// so an **idle** innermost shell's in-process setup (nix-shell `PATH`, a
    /// `venv`) may be unrecoverable — use the in-shell path for full fidelity.
    pub fn from_pid(pid: u32) -> Result<Self> {
        let leaf = resolve_shell_pid(pid)?;
        let cwd = read_cwd(leaf)?;
        let leaf_env = leaf_witness_env(leaf)?;
        let (chain, found_alacritty) = ancestor_chain(leaf);
        let mut levels = levels_from_chain(&chain, leaf_env.clone());
        if levels.is_empty() {
            levels.push(leaf_env);
        }
        Ok(Self { cwd, levels, found_alacritty })
    }
}

/// The executable name of `pid` (from `/proc/<pid>/comm`, trailing newline
/// trimmed). Note this is truncated to 15 bytes by the kernel; `"alacritty"`
/// fits comfortably.
pub fn read_comm(pid: u32) -> Result<String> {
    let raw = fs::read_to_string(format!("/proc/{pid}/comm"))
        .with_context(|| format!("reading /proc/{pid}/comm (does pid {pid} exist?)"))?;
    Ok(raw.trim_end().to_string())
}

/// The working directory of `pid`, by resolving the `/proc/<pid>/cwd` symlink.
pub fn read_cwd(pid: u32) -> Result<PathBuf> {
    fs::read_link(format!("/proc/{pid}/cwd")).with_context(|| {
        format!("reading working directory of pid {pid} (owned by another user?)")
    })
}

/// The environment of `pid`, parsed from the NUL-separated `/proc/<pid>/environ`.
///
/// This is the environment the process was *started* with; interactive `export`s
/// made later in the shell are not reflected here. Entries that aren't valid
/// UTF-8 or lack a `=` are skipped rather than failing the whole read.
pub fn read_environ(pid: u32) -> Result<Vec<(String, String)>> {
    let raw = fs::read(format!("/proc/{pid}/environ"))
        .with_context(|| format!("reading environment of pid {pid} (owned by another user?)"))?;
    Ok(parse_environ(&raw))
}

/// Parse NUL-separated `KEY=VALUE` pairs — the `/proc/<pid>/environ` layout, also
/// emitted verbatim by the opt-in env recorder (which writes `/proc/self/environ`
/// of a child, so the two share this parser). Entries that aren't valid UTF-8 or
/// lack a `=` are skipped rather than failing the whole parse.
fn parse_environ(raw: &[u8]) -> Vec<(String, String)> {
    raw.split(|&b| b == 0)
        .filter(|entry| !entry.is_empty())
        .filter_map(|entry| {
            let text = String::from_utf8_lossy(entry);
            let (key, value) = text.split_once('=')?;
            Some((key.to_string(), value.to_string()))
        })
        .collect()
}

/// The parent PID of `pid`, parsed from the `PPid:` field of its `status` file.
/// `None` if the process vanished or has no parent field.
pub fn ppid_of(pid: u32) -> Option<u32> {
    let status = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
    status
        .lines()
        .find_map(|line| line.strip_prefix("PPid:")?.trim().parse().ok())
}

/// Walk the process tree upward from `start` to the hosting `alacritty`,
/// returning the ancestor PID chain outermost → innermost (the login shell first,
/// the leaf last), plus whether an `alacritty` ancestor was actually reached.
///
/// The walk is purely read-only — it only reads `/proc/<pid>/{comm,status}` and
/// never signals or writes to the running shells — so cloning a terminal cannot
/// exit, disturb, or alter the source terminal's shell stack.
pub fn ancestor_chain(start: u32) -> (Vec<u32>, bool) {
    let mut chain = Vec::new();
    let mut cur = start;
    let mut found_alacritty = false;
    // Hard cap guards against a pathological or looping tree.
    for _ in 0..256 {
        if read_comm(cur).ok().as_deref() == Some("alacritty") {
            found_alacritty = true;
            break;
        }
        chain.push(cur);
        match ppid_of(cur) {
            Some(parent) if parent > 1 => cur = parent,
            _ => break,
        }
    }
    chain.reverse(); // outermost → innermost
    (chain, found_alacritty)
}

/// Build the collapsed env-delta levels for an ancestor `chain`
/// (outermost → innermost shells) whose innermost post-setup env is `leaf_env`.
///
/// Each level's env is sourced, in order of fidelity:
///
/// 1. **Leaf** (innermost): `leaf_env` — already resolved by [`leaf_witness_env`]
///    (recorder ▸ live child ▸ frozen).
/// 2. **Any non-leaf shell with a recorder file**: its own *live* env
///    ([`recorded_env`]). Authoritative and correctly attributed — pristine for the
///    base login shell, fully post-setup for a `nix-shell`.
/// 3. **Base login shell (i == 0) without a recorder**: its **own** frozen
///    `environ`. This is the pristine pre-`nix` base (Alacritty spawned it before
///    any `nix-shell`). We must *not* read its child here: the child is the first
///    `nix-shell`, whose exec-injected markers (`IN_NIX_SHELL`, `buildInputs`, …)
///    would wrongly land in the base and survive `Ctrl-D` to the bottom. `~/.bashrc`
///    is re-sourced in the clone, so any in-process `PATH` the login shell added is
///    reapplied there.
/// 4. **Inner nested shell without a recorder**: its **child's** frozen `environ`,
///    which reflects this shell's post-`exec` in-process env (e.g. a `nix-shell`'s
///    `PATH`) that its own frozen `environ` lacks.
///
/// Adjacent identical layers are collapsed.
fn levels_from_chain(chain: &[u32], leaf_env: Level) -> Vec<Level> {
    let n = chain.len();
    let mut levels: Vec<Level> = Vec::new();
    for i in 0..n {
        let env = if i + 1 == n {
            leaf_env.clone()
        } else if let Some(rec) = recorded_env(chain[i]) {
            rec
        } else if i == 0 {
            match read_environ(chain[0]) {
                Ok(env) => env,
                Err(_) => continue,
            }
        } else {
            match read_environ(chain[i + 1]) {
                Ok(env) => env,
                Err(_) => continue,
            }
        };
        if levels.last().is_some_and(|prev| same_env(prev, &env)) {
            continue;
        }
        levels.push(env);
    }
    levels
}

/// Best available witness of a shell's post-setup environment for the `--pid`
/// path: the environ of one of its children (a process spawned *after* the
/// shell's in-process setup, so it carries the full env — e.g. a running program,
/// or a nested shell we didn't descend into). Falls back to the shell's own
/// (pre-setup) environ when it has no child — an idle interactive nix-shell, so
/// its own in-process `PATH`/`venv` is then unrecoverable from outside (a
/// documented limitation of external `--pid` capture; use the in-shell path).
fn leaf_witness_env(leaf: u32) -> Result<Level> {
    // 1. A live child was spawned *after* the shell's in-process setup, so it
    //    carries the full post-setup env (a running program, or a nested shell we
    //    didn't descend into). Freshest and always correct when present.
    if let Ok(kids) = children_of(leaf) {
        for (child, _) in kids {
            if let Ok(env) = read_environ(child) {
                return Ok(env);
            }
        }
    }
    // 2. No live witness — an idle interactive shell. If the opt-in recorder is
    //    installed, it dumped this shell's live env at its last prompt; that is
    //    the only way to recover an idle nix-shell's post-`exec` PATH/venv from
    //    outside the process. Keyed by the shell PID (see the README snippet).
    if let Some(env) = recorded_env(leaf) {
        return Ok(env);
    }
    // 3. Last resort: the shell's own frozen (pre-setup) environ. An idle
    //    nix-shell's in-process additions are unrecoverable here, so the innermost
    //    layer may collapse into its parent (documented limitation of external
    //    `--pid`/`--focused` capture without the recorder).
    read_environ(leaf)
}

/// Directory the opt-in env recorder writes per-shell env dumps to, mirroring the
/// snippet's `${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/clonetty`. `None` only if
/// neither the env var nor our real UID is readable (so the recorder is skipped).
fn recorder_dir() -> Option<PathBuf> {
    match std::env::var("XDG_RUNTIME_DIR") {
        Ok(dir) if !dir.is_empty() => Some(PathBuf::from(dir).join("clonetty")),
        _ => Some(PathBuf::from(format!("/run/user/{}/clonetty", real_uid()?))),
    }
}

/// Real UID from `/proc/self/status` (`Uid:` first field), avoiding a libc
/// dependency and any `unsafe`.
fn real_uid() -> Option<u32> {
    let status = fs::read_to_string("/proc/self/status").ok()?;
    status
        .lines()
        .find_map(|line| line.strip_prefix("Uid:")?.split_whitespace().next()?.parse().ok())
}

/// The env the opt-in recorder last dumped for shell `pid`, if present. Same
/// NUL-separated format as `/proc/<pid>/environ`, so [`parse_environ`] is shared.
/// `None` when the recorder isn't installed or wrote nothing for this shell.
fn recorded_env(pid: u32) -> Option<Level> {
    let raw = fs::read(recorder_dir()?.join(pid.to_string())).ok()?;
    let env = parse_environ(&raw);
    (!env.is_empty()).then_some(env)
}

/// Order-insensitive equality of two environments (`/proc/<pid>/environ` does
/// not guarantee a stable key order across processes).
fn same_env(a: &[(String, String)], b: &[(String, String)]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut a: Vec<&(String, String)> = a.iter().collect();
    let mut b: Vec<&(String, String)> = b.iter().collect();
    a.sort();
    b.sort();
    a == b
}

/// The immediate children of `pid`, as `(child_pid, name)` pairs.
///
/// We scan `/proc` and read each process's `PPid:` from its `status` file. That
/// is more portable than the `/proc/<pid>/task/<tid>/children` file (which
/// depends on `CONFIG_PROC_CHILDREN`) and avoids parsing the parenthesised,
/// space-containing `comm` field in `stat`.
pub fn children_of(pid: u32) -> Result<Vec<(u32, String)>> {
    let mut kids = Vec::new();
    for entry in fs::read_dir("/proc").context("scanning /proc")? {
        let entry = entry?;
        // Only numeric directory names are process IDs.
        let Some(child) = entry
            .file_name()
            .to_str()
            .and_then(|name| name.parse::<u32>().ok())
        else {
            continue;
        };
        if let Some((name, ppid)) = read_name_and_ppid(&entry.path())
            && ppid == pid
        {
            kids.push((child, name));
        }
    }
    kids.sort_by_key(|(pid, _)| *pid);
    Ok(kids)
}

/// Parse the `Name:` and `PPid:` fields out of `<proc_dir>/status`.
///
/// Returns `None` (rather than erroring) if the process vanished mid-scan or the
/// file is unreadable — races are expected when walking `/proc`.
fn read_name_and_ppid(proc_dir: &Path) -> Option<(String, u32)> {
    let status = fs::read_to_string(proc_dir.join("status")).ok()?;
    let mut name = None;
    let mut ppid = None;
    for line in status.lines() {
        if let Some(rest) = line.strip_prefix("Name:") {
            name = Some(rest.trim().to_string());
        } else if let Some(rest) = line.strip_prefix("PPid:") {
            ppid = rest.trim().parse().ok();
        }
        if name.is_some() && ppid.is_some() {
            break;
        }
    }
    Some((name?, ppid?))
}

/// Names (from `/proc/<pid>/comm`) we treat as interactive shells when walking
/// the process tree. Kept small and explicit; extend as new shells are supported.
const SHELL_NAMES: &[&str] = &[
    "bash", "sh", "dash", "ash", "zsh", "fish", "nu", "ksh", "mksh", "tcsh", "csh",
];

/// True if `name` is a shell we descend through.
fn is_shell(name: &str) -> bool {
    SHELL_NAMES.contains(&name)
}

/// Descend from a shell to the innermost (leaf) shell of its window, following the
/// chain of shell children.
///
/// A fresh Alacritty window's direct child is the *login* shell; entering a
/// `nix-shell` (or any subshell) stacks another shell process beneath it. To clone
/// the nesting the caller actually sees, we must reach the deepest shell, then
/// walk back up from there (see [`ancestry`]).
///
/// We only follow **shell** children, so a foreground program (e.g. `vim`) does
/// not divert us — the shell running it is the leaf. If a shell has several shell
/// children (e.g. a backgrounded subshell), that is ambiguous, so we stop and
/// treat the current shell as the leaf rather than guessing.
pub fn descend_to_leaf(mut pid: u32) -> u32 {
    // Bounded to guard against a pathological tree; real stacks are shallow.
    for _ in 0..256 {
        let shell_kids: Vec<u32> = children_of(pid)
            .unwrap_or_default()
            .into_iter()
            .filter(|(_, name)| is_shell(name))
            .map(|(child, _)| child)
            .collect();
        match shell_kids.as_slice() {
            [next] => pid = *next,
            _ => break, // no shell child (leaf), or ambiguous → stop here
        }
    }
    pid
}

/// Map a user-supplied PID to the leaf shell PID we should actually read.
///
/// * A non-Alacritty PID is used as-is (the caller pointed us at a shell, or at
///   whatever program is running in the window — its cwd/environ are what we
///   want).
/// * The Alacritty PID is ambiguous because a single process hosts every window.
///   We descend to its child, then [`descend_to_leaf`] further into any nested
///   shells so the full stack is captured: if there's exactly one child, use it;
///   if there are several windows, list them (with cwd) and ask the user to pick.
pub fn resolve_shell_pid(pid: u32) -> Result<u32> {
    if read_comm(pid)? != "alacritty" {
        return Ok(pid);
    }

    let kids = children_of(pid)?;
    match kids.as_slice() {
        [] => bail!("alacritty pid {pid} has no child process to clone"),
        [(only, _)] => Ok(descend_to_leaf(*only)),
        many => {
            let mut msg = format!(
                "alacritty pid {pid} hosts {} windows; re-run with the shell PID of the one you want:\n",
                many.len()
            );
            for (child, name) in many {
                let where_ = read_cwd(*child)
                    .map(|p| p.display().to_string())
                    .unwrap_or_else(|_| "?".to_string());
                msg.push_str(&format!("  --pid {child:<7} {name:<16} {where_}\n"));
            }
            bail!(msg.trim_end().to_string())
        }
    }
}