use super::{Sandbox, SpacesdClient, run};
use crate::{CuaError, Result};
use std::sync::Arc;
use std::time::{Duration, Instant};
pub const OVERLAY_RECORD_DIR: &str = "/var/lib/cua/overlays";
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct Overlay {
pub name: String,
pub path: String,
#[uniffi(default = None)]
pub target: Option<String>,
#[uniffi(default = None)]
pub source: Option<String>,
}
impl Overlay {
pub fn parse(spec: &str) -> Result<Overlay> {
let (name, rest) = spec.split_once('=').ok_or_else(|| {
CuaError::InvalidArgument(format!(
"--overlay {spec:?}: use NAME=PATH or NAME=PATH:GUEST_PATH, e.g. cua-driver=./target/release/cua-driver"
))
})?;
let drive = |i: usize| i == 1 && rest.as_bytes()[0].is_ascii_alphabetic();
let (path, target) = match rest.rfind(":/") {
Some(i) if i > 0 && !drive(i) => (&rest[..i], Some(rest[i + 1..].to_string())),
_ => (rest, None),
};
let o = Overlay {
name: name.trim().to_string(),
path: path.to_string(),
target,
source: None,
};
o.validate()?;
Ok(o)
}
pub(crate) fn validate(&self) -> Result<()> {
let bad = |m: String| {
Err(CuaError::InvalidArgument(format!(
"overlay {}: {m}",
self.name
)))
};
if self.name.is_empty()
|| self.name.len() > 64
|| !self
.name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|| self.name.starts_with('.')
{
return bad("the name must be 1 to 64 letters, digits, '.', '_' or '-'".into());
}
if self.path.is_empty() {
return bad("no local path".into());
}
if let Some(t) = &self.target {
if !t.starts_with('/') || t.ends_with('/') || !safe_text(t) || t.contains("/../") {
return bad(format!("guest path {t:?} must be an absolute file path"));
}
} else if !matches!(self.name.as_str(), "cua-driver" | "cua-spacesd") {
return bad("give the guest path: NAME=PATH:GUEST_PATH".into());
}
if let Some(s) = &self.source
&& !safe_text(s)
{
return bad("the source must be printable text without quotes or backslashes".into());
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct OverlayResult {
pub name: String,
pub target: String,
pub sha256: String,
pub previous_sha256: String,
pub size: u64,
pub restarted: String,
}
fn safe_text(s: &str) -> bool {
s.len() <= 1024
&& s.chars()
.all(|c| !c.is_control() && c != '"' && c != '\\' && c != '\'')
}
fn sq(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
const FIND_DAEMON: &str = r#"cua_exe() {
e=$(readlink "/proc/$1/exe" 2>/dev/null) || e=""
[ -n "$e" ] || e=$(tr '\0' '\n' <"/proc/$1/cmdline" 2>/dev/null | head -n1) || true
[ -n "$e" ] || return 1
echo "${e% (deleted)}"
}
cua_exe_sha() {
s=$(sha256sum "/proc/$1/exe" 2>/dev/null | cut -d' ' -f1)
if [ -z "$s" ] && [ "$(id -u)" = 0 ]; then
u=$(stat -c %U "/proc/$1" 2>/dev/null)
s=$(runuser -u "$u" -- sha256sum "/proc/$1/exe" 2>/dev/null | cut -d' ' -f1)
fi
echo "$s"
}
cua_server_exe() {
exe=$(cua_exe "$1") || return 1
case "${exe##*/}" in cua-spacesd|cua-guestd|cua-env-driver) ;; *) return 1;; esac
args=$(cat "/proc/$1/cmdline" 2>/dev/null | tr '\0' '\n')
[ -n "$args" ] || { echo "cannot identify cua-spacesd role for pid $1" >&2; return 2; }
arg=$(printf '%s\n' "$args" | sed -n '2p')
case "$arg" in
--help|-h|--version|-V) return 1;;
''|serve|join|-*) echo "$exe";;
*) return 1;;
esac
}
cua_daemon() {
if exe=$(cua_server_exe "$PPID"); then echo "$PPID $exe"; return 0
else rc=$?; [ "$rc" = 1 ] || return "$rc"; fi
found=""
for p in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do
[ "$p" != "$PPID" ] || continue
if exe=$(cua_server_exe "$p"); then
[ -z "$found" ] || { echo "multiple cua-spacesd servers in the guest" >&2; return 2; }
found="$p $exe"
else rc=$?; [ "$rc" = 1 ] || return "$rc"; fi
done
[ -n "$found" ] || return 1
echo "$found"
}
"#;
const ROOT: &str = r#"S=""
if [ "$(id -u)" != 0 ]; then
S="sudo -n"
$S true 2>/dev/null || { echo "overlays need root in the guest: run as root or give this user passwordless sudo" >&2; exit 5; }
fi
"#;
const RESOLVE: &str = r#"set -eu
name=$1; target=$2
if [ -z "$target" ]; then
case "$name" in
cua-spacesd)
# The image's daemon may still be starting: wait for it (bounded).
i=0; d=""
while [ $i -lt 120 ]; do
if d=$(cua_daemon); then break
else rc=$?; [ "$rc" = 1 ] || exit "$rc"; fi
sleep 1; i=$((i + 1))
done
if [ -n "$d" ]; then target=${d#* }
elif [ -e /usr/local/bin/cua-spacesd ]; then target=$(readlink -f /usr/local/bin/cua-spacesd)
else echo "no cua-spacesd runs in this guest" >&2; exit 4; fi ;;
cua-driver)
if p=$(command -v cua-driver 2>/dev/null); then target=$(readlink -f "$p"); else target=/usr/local/bin/cua-driver; fi ;;
*) echo "overlay $name needs a guest path" >&2; exit 4 ;;
esac
fi
echo "target=$target"
"#;
const INSTALL: &str = r#"set -eu
name=$1; tmp=$2; dst=$3; want=$4; src=$5; now=$6
got=$(sha256sum "$tmp" | cut -d' ' -f1)
[ "$got" = "$want" ] || { echo "uploaded $name has sha256 $got, expected $want" >&2; exit 3; }
prev=""
[ -f "$dst" ] && prev=$(sha256sum "$dst" | cut -d' ' -f1)
$S mkdir -p "$(dirname "$dst")" /var/lib/cua/overlays
$S cp "$tmp" "$dst.cua-overlay-new"
$S chmod 0755 "$dst.cua-overlay-new"
$S mv -f "$dst.cua-overlay-new" "$dst"
rm -f "$tmp"
got=$(sha256sum "$dst" | cut -d' ' -f1)
[ "$got" = "$want" ] || { echo "$dst has sha256 $got after the rename, expected $want" >&2; exit 3; }
printf '{"name":"%s","path":"%s","sha256":"%s","previous_sha256":"%s","source":"%s","applied_at":"%s"}\n' \
"$name" "$dst" "$want" "$prev" "$src" "$now" | $S tee "/var/lib/cua/overlays/$name.json" >/dev/null
echo "previous=$prev"
"#;
const RESTART_DAEMON: &str = r#"set -eu
d=$(cua_daemon) || { rc=$?; [ "$rc" = 1 ] || exit "$rc"; echo "how="; exit 0; }
pid=${d%% *}
unit=$(sed -n 's#.*/\([^/]*\.service\)$#\1#p' "/proc/$pid/cgroup" 2>/dev/null | head -n1 || true)
prog=""
if test -S /run/supervisor.sock || test -S /var/run/supervisor.sock; then
programs=$($S supervisorctl status 2>/dev/null | awk '{print $1}' | grep -E '^(cua-spacesd|cua-guestd|cua-env-driver)$' || true)
for candidate in $programs; do
managed_pid=$($S supervisorctl pid "$candidate" 2>/dev/null) || managed_pid=""
if [ "$managed_pid" = "$pid" ]; then
[ -z "$prog" ] || { echo "multiple supervisor programs identify the selected cua-spacesd server" >&2; exit 2; }
prog=$candidate
fi
done
[ -n "$prog" ] || { echo "supervisor does not identify the selected cua-spacesd server" >&2; exit 2; }
fi
if [ -n "$prog" ]; then cmd="supervisorctl restart $prog"; how="supervisor:$prog"
elif [ -n "$unit" ] && command -v systemctl >/dev/null 2>&1; then cmd="systemctl restart $unit"; how="systemd:$unit"
else cmd="kill -TERM $pid"; how="signal:$pid"; fi
$S setsid -f sh -c "sleep 1; $cmd" >/dev/null 2>&1 </dev/null
sleep 0.3
echo "pid=$pid"
echo "how=$how"
"#;
const DAEMON_IDENTITY: &str = r#"d=$(cua_daemon) || exit 1
pid=${d%% *}
echo "$pid $(cua_exe_sha "$pid")"
"#;
const RESTART_PROGRAM: &str = r#"set -eu
prog=$1; want=$2; how=""
if [ -S /run/supervisor.sock ] && $S supervisorctl status "$prog" >/dev/null 2>&1; then
$S supervisorctl restart "$prog" >/dev/null && how="supervisor:$prog"
fi
stopped=""
for p in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do
exe=$(cua_exe "$p") || continue
case "${exe##*/}" in "$prog") ;; *) continue ;; esac
sha=$(cua_exe_sha "$p")
[ -n "$sha" ] || continue
if [ "$sha" != "$want" ]; then $S kill -TERM "$p" 2>/dev/null && stopped="$stopped,$p"; fi
done
[ -n "$stopped" ] && how="${how:+$how;}stopped:${stopped#,}"
echo "how=$how"
"#;
fn field<'a>(stdout: &'a str, key: &str) -> Option<&'a str> {
stdout
.lines()
.find_map(|l| l.strip_prefix(key).and_then(|r| r.strip_prefix('=')))
.map(str::trim)
}
enum Guest {
Engine { engine: String, container: String },
Spacesd(Arc<SpacesdClient>),
}
impl Guest {
async fn engine_for(sandbox: &Sandbox) -> Option<Guest> {
let info = sandbox.info();
let container = info.provider_details.get("container_id")?.clone();
if container.is_empty() {
return None;
}
let engine = std::env::var("CUA_CONTAINER_ENGINE")
.ok()
.filter(|e| !e.is_empty())
.unwrap_or_else(|| "docker".into());
let ok = tokio::process::Command::new(&engine)
.args(["inspect", "-f", "{{.State.Running}}", &container])
.output()
.await
.ok()
.filter(|o| o.status.success())
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == "true");
ok.then_some(Guest::Engine { engine, container })
}
async fn sh(&self, script: String, what: &str) -> Result<String> {
let (success, stdout, stderr) = match self {
Guest::Engine { engine, container } => {
let o = tokio::time::timeout(
Duration::from_secs(300),
tokio::process::Command::new(engine)
.args(["exec", "-u", "0", container, "/bin/sh", "-c", &script])
.stdin(std::process::Stdio::null())
.output(),
)
.await
.map_err(|_| CuaError::Timeout(format!("overlay {what}: {engine} exec timed out")))?
.map_err(|e| CuaError::HostCapabilityMissing(format!("{engine} exec: {e}")))?;
(
o.status.success(),
String::from_utf8_lossy(&o.stdout).into_owned(),
String::from_utf8_lossy(&o.stderr).into_owned(),
)
}
Guest::Spacesd(env) => {
let o = env.sh(script, Some(120_000)).await?;
(
o.exit.success,
String::from_utf8_lossy(&o.stdout).into_owned(),
String::from_utf8_lossy(&o.stderr).into_owned(),
)
}
};
if !success {
return Err(CuaError::Runtime(format!(
"overlay {what}: {}",
stderr.trim().lines().last().unwrap_or("failed")
)));
}
Ok(stdout)
}
async fn upload(&self, local: &str, guest: &str) -> Result<String> {
match self {
Guest::Engine { engine, container } => {
let file = std::fs::File::open(local)
.map_err(|e| CuaError::NotFound(format!("overlay file {local}: {e}")))?;
let o = tokio::time::timeout(
Duration::from_secs(600),
tokio::process::Command::new(engine)
.args([
"exec",
"-i",
"-u",
"0",
container,
"/bin/sh",
"-c",
&format!("umask 077; cat >{}", sq(guest)),
])
.stdin(std::process::Stdio::from(file))
.output(),
)
.await
.map_err(|_| CuaError::Timeout(format!("overlay upload: {engine} exec timed out")))?
.map_err(|e| CuaError::HostCapabilityMissing(format!("{engine} exec: {e}")))?;
if !o.status.success() {
return Err(CuaError::Runtime(format!(
"overlay upload: {}",
String::from_utf8_lossy(&o.stderr).trim()
)));
}
Ok(String::new())
}
Guest::Spacesd(env) => Ok(env
.upload_file(local.to_string(), guest.to_string(), None)
.await?
.sha256),
}
}
}
fn local_digest(path: &str) -> Result<(String, u64)> {
use sha2::{Digest, Sha256};
use std::io::Read;
let meta = std::fs::metadata(path)
.map_err(|e| CuaError::NotFound(format!("overlay file {path}: {e}")))?;
if !meta.is_file() {
return Err(CuaError::InvalidArgument(format!(
"overlay file {path} is not a file"
)));
}
let mut file = std::fs::File::open(path)
.map_err(|e| CuaError::NotFound(format!("overlay file {path}: {e}")))?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 1 << 16];
loop {
let n = file
.read(&mut buf)
.map_err(|e| CuaError::Internal(format!("reading {path}: {e}")))?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok((hex::encode(hasher.finalize()), meta.len()))
}
fn now_rfc3339() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
rem / 3600,
rem % 3600 / 60,
rem % 60
)
}
async fn apply_one(
sandbox: &Sandbox,
guest: &Guest,
o: Overlay,
deadline: Instant,
) -> Result<OverlayResult> {
o.validate()?;
let (sha256, size) = local_digest(&o.path)?;
let stdout = guest
.sh(
format!(
"set -- {} {}\n{FIND_DAEMON}{RESOLVE}",
sq(&o.name),
sq(o.target.as_deref().unwrap_or(""))
),
"resolving the guest path",
)
.await?;
let target = field(&stdout, "target")
.filter(|t| t.starts_with('/') && safe_text(t))
.ok_or_else(|| CuaError::Runtime(format!("overlay {}: no guest path resolved", o.name)))?
.to_string();
let tmp = format!(
"/tmp/.cua-overlay-{}-{}-{}",
o.name,
std::process::id(),
&sha256[..12]
);
let sent = guest.upload(&o.path, &tmp).await?;
if !sent.is_empty() && !sent.eq_ignore_ascii_case(&sha256) {
return Err(CuaError::Runtime(format!(
"overlay {}: uploaded sha256 {sent} differs from the local file's {sha256}",
o.name
)));
}
let source = o.source.clone().unwrap_or_else(|| o.path.clone());
let source = if safe_text(&source) {
source
} else {
String::new()
};
let stdout = guest
.sh(
format!(
"set -- {} {} {} {} {} {}\n{ROOT}{INSTALL}",
sq(&o.name),
sq(&tmp),
sq(&target),
sq(&sha256),
sq(&source),
sq(&now_rfc3339()),
),
"installing",
)
.await?;
let previous_sha256 = field(&stdout, "previous").unwrap_or_default().to_string();
let restarted = if o.name == "cua-spacesd" {
restart_daemon(sandbox, guest, &sha256, deadline).await?
} else {
let program = target.rsplit('/').next().unwrap_or_default().to_string();
let stdout = guest
.sh(
format!(
"set -- {} {}\n{FIND_DAEMON}{ROOT}{RESTART_PROGRAM}",
sq(&program),
sq(&sha256)
),
"restarting",
)
.await?;
field(&stdout, "how").unwrap_or_default().to_string()
};
Ok(OverlayResult {
name: o.name,
target,
sha256,
previous_sha256,
size,
restarted,
})
}
async fn restart_daemon(
sandbox: &Sandbox,
guest: &Guest,
sha256: &str,
deadline: Instant,
) -> Result<String> {
let identity = format!("{FIND_DAEMON}{DAEMON_IDENTITY}");
if let Ok(out) = guest.sh(identity.clone(), "identity").await
&& out.split_whitespace().nth(1) == Some(sha256)
{
return Ok(String::new());
}
let stdout = guest
.sh(
format!("{FIND_DAEMON}{ROOT}{RESTART_DAEMON}"),
"restarting cua-spacesd",
)
.await?;
let how = field(&stdout, "how").unwrap_or_default().to_string();
if how.is_empty() {
return Err(CuaError::Runtime(
"overlay cua-spacesd: the daemon process was not found in the guest".into(),
));
}
let old_pid = field(&stdout, "pid").unwrap_or_default().to_string();
let mut last = String::from("no answer");
for _ in 0..600 {
if Instant::now() >= deadline {
break;
}
tokio::time::sleep(Duration::from_secs(1)).await;
let probe = match guest {
Guest::Engine { .. } => guest.sh(identity.clone(), "identity").await,
Guest::Spacesd(_) => match sandbox.spacesd(Some(5_000)).await {
Ok(env) => Guest::Spacesd(env).sh(identity.clone(), "identity").await,
Err(e) => Err(e),
},
};
match probe {
Ok(out) => {
let mut parts = out.split_whitespace();
let (pid, sha) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""));
if pid != old_pid && sha == sha256 {
return Ok(how);
}
last = format!("pid {pid} runs sha256 {}", &sha[..sha.len().min(12)]);
}
Err(e) => last = e.to_string(),
}
}
Err(CuaError::Timeout(format!(
"overlay cua-spacesd: the restarted daemon does not run the injected build ({last})"
)))
}
#[uniffi::export]
impl Sandbox {
#[uniffi::method(default(timeout_ms = None))]
pub async fn overlay(
self: Arc<Self>,
overlays: Vec<Overlay>,
timeout_ms: Option<u32>,
) -> Result<Vec<OverlayResult>> {
for o in &overlays {
o.validate()?;
}
let timeout = Duration::from_millis(u64::from(timeout_ms.unwrap_or(180_000)));
let ordered_has_daemon = overlays.iter().any(|o| o.name == "cua-spacesd");
run(async move {
let deadline = Instant::now() + timeout;
let guest = match Guest::engine_for(&self).await {
Some(g) => {
if ordered_has_daemon {
let _ = self.spacesd(Some(120_000)).await;
}
g
}
None => Guest::Spacesd(self.spacesd(Some(120_000)).await?),
};
let mut ordered = overlays;
ordered.sort_by_key(|o| o.name == "cua-spacesd");
let mut out = Vec::with_capacity(ordered.len());
for o in ordered {
out.push(apply_one(&self, &guest, o, deadline).await?);
}
Ok(out)
})
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn daemon_shell(
processes: &[(&str, &str, &str, &str)],
setup: &str,
script: &str,
) -> std::process::Output {
let mut fixture = String::new();
for (command, suffix, column) in [
("readlink", "exe", 1),
("cat", "cmdline", 2),
("sha256sum", "exe", 3),
] {
fixture.push_str(&format!("{command}() {{ case \"$1\" in\n"));
for &(pid, exe, args, hash) in processes {
let output = match column {
1 => format!("printf '%s\\n' {}", sq(exe)),
2 => format!(
"printf '%s\\0' {}",
std::iter::once(exe)
.chain(args.split('\n').filter(|arg| !arg.is_empty()))
.map(sq)
.collect::<Vec<_>>()
.join(" ")
),
_ => format!("printf '%s\\n' {}", sq(hash)),
};
fixture.push_str(&format!("/proc/{pid}/{suffix}) {output};;\n"));
}
fixture.push_str(if command == "readlink" {
"*) echo /bin/sh;; esac; }\n"
} else {
"*) return 1;; esac; }\n"
});
}
fixture.push_str(&format!(
"ls() {{ printf '%s\\n' {}; }}\n",
processes
.iter()
.map(|p| sq(p.0))
.collect::<Vec<_>>()
.join(" ")
));
std::process::Command::new("/bin/sh")
.arg("-c")
.arg(format!("{FIND_DAEMON}\n{fixture}\n{setup}\n{script}"))
.output()
.unwrap()
}
#[cfg(unix)]
#[test]
fn daemon_identity_ignores_helpers() {
for (helper_hash, server_hash) in [("old", "new"), ("new", "old")] {
let out = daemon_shell(
&[
(
"25",
"/usr/local/bin/cua-spacesd (deleted)",
"volume-helper\n--allow-uid\n1000",
helper_hash,
),
("30", "/usr/local/bin/cua-spacesd", "", server_hash),
],
"",
DAEMON_IDENTITY,
);
assert!(out.status.success(), "{out:?}");
assert_eq!(
String::from_utf8_lossy(&out.stdout).trim(),
format!("30 {server_hash}")
);
}
}
#[cfg(unix)]
#[test]
fn daemon_selection_accepts_serving_roles_and_aliases() {
for name in ["cua-spacesd", "cua-guestd", "cua-env-driver"] {
for args in ["", "serve", "join", "--listen\n127.0.0.1:3211"] {
let exe = format!("/usr/local/bin/{name}");
let out = daemon_shell(&[("30", &exe, args, "new")], "", DAEMON_IDENTITY);
assert!(out.status.success(), "{name} {args}: {out:?}");
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "30 new");
}
}
}
#[cfg(unix)]
#[test]
fn daemon_selection_rejects_nonserving_roles() {
for args in [
"volume-helper",
"volume-mount",
"token-sync",
"doctor",
"build-info",
"check-permissions",
"legacy",
"--help",
"-h",
"--version",
"-V",
"unknown",
] {
let out = daemon_shell(
&[("25", "/usr/local/bin/cua-spacesd", args, "old")],
"",
"cua_daemon",
);
assert_eq!(out.status.code(), Some(1), "{args}: {out:?}");
assert!(out.stdout.is_empty());
}
}
#[cfg(unix)]
#[test]
fn daemon_selection_refuses_ambiguous_or_unreadable_identity() {
let processes = [
("25", "/usr/local/bin/cua-spacesd", "serve", "old"),
("30", "/usr/local/bin/cua-spacesd", "join", "new"),
];
let out = daemon_shell(&processes, "", "cua_daemon");
assert_eq!(out.status.code(), Some(2), "{out:?}");
assert!(String::from_utf8_lossy(&out.stderr).contains("multiple cua-spacesd servers"));
let out = daemon_shell(&processes[..1], "cat() { return 1; }", "cua_daemon");
assert_eq!(out.status.code(), Some(2), "{out:?}");
assert!(String::from_utf8_lossy(&out.stderr).contains("cannot identify cua-spacesd role"));
let out = daemon_shell(&processes, "", &format!("set -- cua-spacesd ''\n{RESOLVE}"));
assert_eq!(out.status.code(), Some(2), "{out:?}");
assert!(!String::from_utf8_lossy(&out.stdout).contains("target="));
}
#[cfg(unix)]
#[test]
fn daemon_selection_prefers_its_serving_parent() {
let out = daemon_shell(
&[
("$PPID", "/usr/local/bin/cua-spacesd", "join", "parent"),
("30", "/usr/local/bin/cua-spacesd", "serve", "other"),
],
"",
DAEMON_IDENTITY,
);
assert!(out.status.success(), "{out:?}");
assert_eq!(
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.nth(1),
Some("parent")
);
}
#[cfg(unix)]
#[test]
fn daemon_restart_matches_supervisor_pid_or_refuses() {
let fixture = r#"
S=""
sleep() { :; }
setsid() { printf '%s\n' "$*" >&2; }
sed() { case "$*" in */cgroup*) return 0;; *) command sed "$@";; esac; }
test() { if [ "$1" = -S ]; then [ "$SUPERVISOR" != absent ]; else command test "$@"; fi; }
supervisorctl() {
[ "$SUPERVISOR" != error ] || return 1
if [ "$1" = pid ] && [ "$SUPERVISOR" = pid-error ]; then return 1; fi
case "$1" in
status) printf '%s\n' 'cua-guestd RUNNING pid 25, uptime 0:01:00' 'cua-spacesd RUNNING pid 30, uptime 0:01:00';;
pid) case "$2" in cua-guestd) echo 25;; cua-spacesd) echo "$SERVER_PID";; esac;;
esac
}
"#;
for (mode, pid, expected) in [
("present", "30", Some("supervisor:cua-spacesd")),
("absent", "30", Some("signal:30")),
("present", "99", None),
("error", "30", None),
("pid-error", "30", None),
] {
let setup = format!("{fixture}\nSUPERVISOR={mode}; SERVER_PID={pid}");
let out = daemon_shell(
&[("30", "/usr/local/bin/cua-spacesd", "serve", "new")],
&setup,
RESTART_DAEMON,
);
if let Some(how) = expected {
assert!(out.status.success(), "{out:?}");
assert!(
String::from_utf8_lossy(&out.stdout).contains(&format!("how={how}")),
"{out:?}"
);
} else {
assert_eq!(out.status.code(), Some(2), "{out:?}");
assert!(
String::from_utf8_lossy(&out.stderr)
.contains("supervisor does not identify the selected cua-spacesd server")
);
assert!(!String::from_utf8_lossy(&out.stderr).contains("sleep 1;"));
}
}
}
#[test]
fn parses_specs() {
let o = Overlay::parse("cua-driver=./target/release/cua-driver").unwrap();
assert_eq!(o.name, "cua-driver");
assert_eq!(o.path, "./target/release/cua-driver");
assert_eq!(o.target, None);
let o = Overlay::parse("tool=/tmp/a:b/tool:/opt/bin/tool").unwrap();
assert_eq!(o.path, "/tmp/a:b/tool");
assert_eq!(o.target.as_deref(), Some("/opt/bin/tool"));
let o = Overlay::parse("cua-spacesd=C:/build/cua-spacesd").unwrap();
assert_eq!(o.path, "C:/build/cua-spacesd");
assert_eq!(o.target, None);
assert!(Overlay::parse("cua-driver").is_err());
assert!(
Overlay::parse("tool=./tool").is_err(),
"unknown names need a guest path"
);
assert!(Overlay::parse("../x=./x:/opt/x").is_err());
assert!(Overlay::parse("x y=./x:/opt/x").is_err());
assert!(Overlay::parse("x=./x:/opt/\"x").is_err());
assert!(Overlay::parse("cua-driver=").is_err());
}
#[test]
fn quoting_is_shell_safe() {
assert_eq!(sq("a b"), "'a b'");
assert_eq!(sq("it's"), "'it'\\''s'");
}
#[test]
fn fields_are_read_from_script_output() {
let out = "noise\ntarget=/usr/local/bin/cua-guestd\nprevious=\n";
assert_eq!(field(out, "target"), Some("/usr/local/bin/cua-guestd"));
assert_eq!(field(out, "previous"), Some(""));
assert_eq!(field(out, "how"), None);
}
#[test]
fn digests_local_files() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("abc");
std::fs::write(&p, b"abc").unwrap();
let (sha, size) = local_digest(p.to_str().unwrap()).unwrap();
assert_eq!(
sha,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(size, 3);
assert!(local_digest(dir.path().to_str().unwrap()).is_err());
assert!(local_digest("/nonexistent/cua-overlay").is_err());
}
#[test]
fn timestamps_are_rfc3339() {
let t = now_rfc3339();
assert_eq!(t.len(), 20, "{t}");
assert!(t.starts_with("20") && t.ends_with('Z') && t.as_bytes()[10] == b'T');
}
}