Skip to main content

coop/
wrapper.rs

1use std::sync::OnceLock;
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use base64::Engine;
6
7use crate::config::Host;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Job {
11    pub id: JobId,
12    pub cmd: String,
13    pub cwd: Option<String>,
14    pub max_secs: u64,
15}
16
17/// Remote per-job state, as a shell string (never a local `PathBuf`).
18///
19/// Jobs live under `jobs/` rather than directly in the state dir because the
20/// ticket lock keeps `<host>.lock` in the same tree. Sharing one parent made
21/// `coop ls` report `dev.lock` as an orphaned job, and would have let prune
22/// delete a live lock. They collide whenever the orchestrator and the target
23/// are the same machine, which is exactly the local-sshd test setup.
24pub fn state_dir(id: &JobId) -> String {
25    format!("{JOBS_ROOT}/{id}")
26}
27
28/// Parent of every job's state directory.
29pub const JOBS_ROOT: &str = "${XDG_STATE_HOME:-$HOME/.local/state}/coop/jobs";
30
31pub fn dispatch_script(host: &Host, job: &Job) -> String {
32    let dir = state_dir(&job.id);
33    let command = encode_command(job.cmd.as_bytes());
34    let cwd = job
35        .cwd
36        .as_deref()
37        .or(host.default_cwd.as_deref())
38        .unwrap_or("$HOME");
39
40    // The cwd is encoded for the same reason the command is: it is user input
41    // crossing the same four expansion layers. Interpolated raw, a path with a
42    // space splits into two words and `cd` either fails or -- worse -- succeeds
43    // against the wrong directory. `$HOME` is the one value coop supplies
44    // itself, and it must stay unencoded so the remote shell expands it.
45    // The cwd is a PATH, not a shell expression, and those two goals conflict:
46    // encoding it keeps a space or a `$(...)` from being interpreted, but it
47    // also stops `~` and `$HOME` from expanding. Since only the remote shell
48    // knows the remote home, a home-relative path has to be emitted as an
49    // unquoted `$HOME` with the remainder still encoded.
50    //
51    // Previously only the exact string `$HOME` was special-cased, so every
52    // other spelling became a literal directory name that cannot exist: `cd`
53    // failed, the `&&` short-circuited, and the job reported rc 1 with an empty
54    // log. Measured as broken: `~/`, `~/work`, `$HOME/work`, `${HOME}/work` --
55    // and coop's own config template suggested `~/work`, so following the
56    // documentation produced a host where nothing ran.
57    let cd = match home_relative(cwd) {
58        // Nothing after the home directory.
59        Some("") => "cd \"$HOME\"".to_string(),
60        // `$HOME` unquoted so the remote shell expands it; the rest encoded so
61        // a space or a metacharacter in the path is still inert.
62        Some(rest) => format!(
63            "cd \"$HOME/$(printf %s {} | base64 -d)\"",
64            encode_command(rest.as_bytes())
65        ),
66        None => format!(
67            "cd \"$(printf %s {} | base64 -d)\"",
68            encode_command(cwd.as_bytes())
69        ),
70    };
71
72    let run = if job.max_secs == 0 {
73        format!("{cd} && printf %s {command} | base64 -d | sh; echo $? > {dir}/rc")
74    } else {
75        // POSIX sh has no portable process-group primitive, so the inner tmux
76        // session supplies one: `kill-session` terminates the command and all
77        // descendants. The watchdog runs in a separate tmux session, because a
78        // background `sleep` inside the job session would keep that session
79        // alive after a fast command exits. Whichever path finishes first
80        // destroys the other session. 124 follows GNU timeout and is distinct
81        // from coop kill's 137. Write 124 only when rc is missing, matching
82        // the job path, so a concurrent kill's 137 is not overwritten.
83        let watchdog = format!(
84            "sleep {secs}; if tmux -L {socket} has-session -t coop-{id} 2>/dev/null; then \
85             if [ ! -f {dir}/rc ]; then echo 124 > {dir}/rc; fi; \
86             tmux -L {socket} kill-session -t coop-{id}; fi",
87            socket = host.tmux_socket,
88            id = job.id,
89            secs = job.max_secs,
90        );
91        format!(
92            "tmux -L {socket} -f /dev/null new-session -d -s watch-{id} \
93             \"printf %s {watchdog} | base64 -d | sh\"; \
94             {cd} && printf %s {command} | base64 -d | sh; rc=$?; \
95             tmux -L {socket} kill-session -t watch-{id} 2>/dev/null; \
96             if [ ! -f {dir}/rc ]; then echo $rc > {dir}/rc; fi",
97            socket = host.tmux_socket,
98            id = job.id,
99            watchdog = encode_command(watchdog.as_bytes()),
100        )
101    };
102
103    format!(
104        "mkdir -p {dir} && printf %s {command} | base64 -d > {dir}/cmd && \
105         tmux -L {} -f /dev/null new-session -d -s coop-{} \
106         '{{ {run}; }} \
107          | {{ head -c {} > {dir}/log; cat > {dir}/.overflow; \
108               if [ -s {dir}/.overflow ]; then echo 1 > {dir}/truncated; fi; \
109               rm -f {dir}/.overflow; }}'",
110        host.tmux_socket, job.id, host.max_log_bytes
111    )
112}
113
114/// The part of `path` after the user's home directory, if it is home-relative.
115///
116/// Recognises the spellings people actually write. Deliberately a fixed set
117/// rather than general shell expansion: expanding arbitrary `$(...)` in a
118/// configured path would hand the shell back the injection surface the base64
119/// encoding exists to remove.
120fn home_relative(path: &str) -> Option<&str> {
121    for prefix in ["~", "$HOME", "${HOME}"] {
122        if let Some(rest) = path.strip_prefix(prefix) {
123            // `~foo` is another user's home, a different problem; `$HOMEDIR` is
124            // simply a different variable. Neither is home-relative.
125            if rest.is_empty() {
126                return Some("");
127            }
128            if let Some(rest) = rest.strip_prefix('/') {
129                return Some(rest.trim_end_matches('/'));
130            }
131        }
132    }
133    None
134}
135
136/// Width of a generated id, in hex digits.
137pub const ID_HEX_LEN: usize = 6;
138
139pub fn new_id() -> String {
140    // Time plus pid avoids a dependency for a non-secret id; the odd step keeps
141    // the low 24 bits unique until the six-hex-digit space wraps.
142    static NEXT: OnceLock<AtomicU64> = OnceLock::new();
143    let next = NEXT.get_or_init(|| {
144        let time = SystemTime::now()
145            .duration_since(UNIX_EPOCH)
146            .unwrap_or_default()
147            .as_nanos() as u64;
148        AtomicU64::new(time ^ u64::from(std::process::id()))
149    });
150    let value = next.fetch_add(0x9e37_79b9_7f4a_7c15, Ordering::Relaxed);
151    format!("{:06x}", value & 0x00ff_ffff)
152}
153
154pub fn encode_command(input: &[u8]) -> String {
155    base64::engine::general_purpose::STANDARD.encode(input)
156}
157
158/// A validated job id: exactly six lowercase hex digits.
159///
160/// Every verb takes an id from the command line and interpolates it into a
161/// remote path, a tmux target, and a shell script. Unvalidated, that is command
162/// injection: `coop poll 'x$(touch /tmp/pwn)y'` reached the remote shell as
163/// syntax and would have executed. Parsing at the boundary makes the unsafe
164/// value unrepresentable rather than relying on every call site to quote.
165///
166/// Hex also avoids `:` and `.`, which tmux's target grammar reserves.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct JobId(String);
169
170impl JobId {
171    pub fn as_str(&self) -> &str {
172        &self.0
173    }
174}
175
176impl std::str::FromStr for JobId {
177    type Err = anyhow::Error;
178
179    fn from_str(raw: &str) -> Result<Self, Self::Err> {
180        let ok = raw.len() == ID_HEX_LEN
181            && raw
182                .bytes()
183                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
184        if !ok {
185            anyhow::bail!(
186                "invalid job id {raw:?}: expected {ID_HEX_LEN} lowercase hex digits, as printed by `coop run`"
187            );
188        }
189        Ok(Self(raw.to_string()))
190    }
191}
192
193impl std::fmt::Display for JobId {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        f.write_str(&self.0)
196    }
197}