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}