system settings
git clone https://git.lucas.co/cce-system-interface.git
timers: escape a timer's command for systemd, not for a shell
A created timer's service ran ExecStart=/bin/sh -c '<command>' with the
command quoted for a shell (' becomes '\'') and nothing else. systemd
parses ExecStart itself before any shell sees it, so a % began a unit
specifier, and `date +%F > file` wrote a unit systemd refused to load
("Failed to resolve unit specifiers"). A $VAR was expanded by systemd
from its own environment. The page still said "Created and enabled".
exec_start_for escapes the command for systemd: \ ' and newline as C
escapes inside the quotes, % as %%, $ as $$. shell_command_of inverts
it, so the edit form shows the command as typed and a save re-encodes
it. Any ExecStart this page did not write, including one from the old
quoting, is still shown and saved raw.
Checked against systemd. systemd-analyze verify accepts every escaped
unit (an opt-in test, --ignored). A temporary runtime unit ran a command
with a quote, $HOME, 100%, a backslash and date +%F, and printed each
exactly as typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/pages/timers.rs | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 119 insertions(+), 3 deletions(-)
diff --git a/src/pages/timers.rs b/src/pages/timers.rs
index db8afdc..5697a2e 100644
--- a/src/pages/timers.rs
+++ b/src/pages/timers.rs
@@ -238,9 +238,9 @@ fn create_user_timer(name: &str, command: &str, schedule: &str) -> Result<String
}
let service = format!(
- "[Unit]\nDescription={name} (created by cce-system-interface)\n\n[Service]\nType=oneshot\nExecStart=/bin/sh -c '{command}'\n",
+ "[Unit]\nDescription={name} (created by cce-system-interface)\n\n[Service]\nType=oneshot\nExecStart={exec}\n",
name = name,
- command = command.replace('\'', "'\\''"),
+ exec = exec_start_for(command),
);
let timer = format!(
"[Unit]\nDescription={name} schedule\n\n[Timer]\nOnCalendar={schedule}\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n",
@@ -254,6 +254,65 @@ fn create_user_timer(name: &str, command: &str, schedule: &str) -> Result<String
Ok(format!("Created and enabled {}.timer", name))
}
+/// The `ExecStart=` value that runs `command` through `/bin/sh -c`.
+///
+/// systemd parses `ExecStart` itself before any shell sees it: `%` starts a
+/// specifier, `$` an environment substitution, and inside the quotes `\` is
+/// a C escape. Until 2026-10-02 the command was quoted for a SHELL
+/// (`'` → `'\''`) and nothing else, so `date +%F > file` wrote a unit
+/// systemd refused to load ("Failed to resolve unit specifiers"), a `$HOME`
+/// was expanded by systemd from its own environment, and the page still
+/// said "Created and enabled". Escaped here for systemd, the command reaches
+/// `sh -c` exactly as typed.
+fn exec_start_for(command: &str) -> String {
+ let mut out = String::from("/bin/sh -c '");
+ for c in command.chars() {
+ match c {
+ '\\' => out.push_str("\\\\"),
+ '\'' => out.push_str("\\'"),
+ '\n' => out.push_str("\\n"),
+ '%' => out.push_str("%%"),
+ '$' => out.push_str("$$"),
+ c => out.push(c),
+ }
+ }
+ out.push('\'');
+ out
+}
+
+/// The command an `ExecStart=` value written by `exec_start_for` runs, or
+/// None for any other value (a unit this page did not write, or one written
+/// before 2026-10-02 with shell-style quoting), which the edit form then
+/// shows and saves raw.
+fn shell_command_of(exec_start: &str) -> Option<String> {
+ let body = exec_start.strip_prefix("/bin/sh -c '")?.strip_suffix('\'')?;
+ let mut out = String::new();
+ let mut chars = body.chars().peekable();
+ while let Some(c) = chars.next() {
+ match c {
+ '\\' => match chars.next()? {
+ '\\' => out.push('\\'),
+ '\'' => out.push('\''),
+ 'n' => out.push('\n'),
+ _ => return None,
+ },
+ '%' if chars.peek() == Some(&'%') => {
+ chars.next();
+ out.push('%');
+ }
+ '$' if chars.peek() == Some(&'$') => {
+ chars.next();
+ out.push('$');
+ }
+ // An unescaped quote, `%` or `$` is not something exec_start_for
+ // writes: a shell-quoted legacy unit, or a hand-written one.
+ '\'' | '%' | '$' => return None,
+ c => out.push(c),
+ }
+ }
+ Some(out)
+}
+
/// First `Key=value` in a unit file, or None.
fn read_unit_field(path: &std::path::Path, key: &str) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
@@ -302,7 +361,13 @@ fn update_user_timer(base: &str, command: &str, schedule: &str) -> Result<String
replace_unit_field(&dir.join(format!("{}.timer", base)), "OnCalendar", schedule)?;
let service_path = dir.join(format!("{}.service", base));
if service_path.exists() {
- replace_unit_field(&service_path, "ExecStart", command)?;
+ // The form showed this unit's command decoded when it was one
+ // `exec_start_for` wrote (see EditStart), so it goes back encoded;
+ // any other ExecStart was shown raw and is written back raw.
+ let ours = read_unit_field(&service_path, "ExecStart")
+ .is_some_and(|v| shell_command_of(&v).is_some());
+ let value = if ours { exec_start_for(command) } else { command.to_string() };
+ replace_unit_field(&service_path, "ExecStart", &value)?;
}
let _ = tokio::process::Command::new("sh")
.args(["-c", &format!("systemctl --user daemon-reload && systemctl --user try-restart {}.timer", base)])
@@ -622,6 +687,7 @@ pub fn update(state: &mut TimersState, msg: TimersMessage) {
.unwrap_or_default();
let command = dir.as_ref()
.and_then(|d| read_unit_field(&d.join(format!("{}.service", base)), "ExecStart"))
+ .map(|raw| shell_command_of(&raw).unwrap_or(raw))
.unwrap_or_default();
state.creating = false;
state.editing = Some(base.clone());
@@ -753,6 +819,56 @@ impl crate::pages::AppPage for TimersState {
mod tests {
use super::*;
+ const AWKWARD: [&str; 6] = [
+ "date +%F > /tmp/x",
+ "echo it's $HOME",
+ r"printf 'a\tb\n' | tr '\t' ,",
+ "rsync -a ~/a/ /mnt/b/ && notify-send 'done 100%'",
+ "echo $$ ${USER} %h %%",
+ "plain-command --flag",
+ ];
+
+ #[test]
+ fn a_timer_command_round_trips_through_its_exec_start() {
+ for cmd in AWKWARD {
+ let exec = exec_start_for(cmd);
+ assert_eq!(shell_command_of(&exec).as_deref(), Some(cmd), "{exec}");
+ // No bare specifier or substitution survives for systemd to act on.
+ let body = exec.trim_start_matches("/bin/sh -c '");
+ assert!(!body.replace("%%", "").contains('%'), "{exec}");
+ assert!(!body.replace("$$", "").contains('$'), "{exec}");
+ }
+ }
+
+ #[test]
+ fn a_unit_this_page_did_not_write_is_edited_raw() {
+ // The old shell-style quoting, and hand-written lines.
+ for raw in [r"/bin/sh -c 'echo it'\''s'", "/usr/bin/backup --all", "/bin/sh -c 'echo 100%'"] {
+ assert_eq!(shell_command_of(raw), None, "{raw}");
+ }
+ }
+
+ /// Asks systemd itself: `cargo test -p cce-system-interface --lib
+ /// timers -- --ignored`. Writes units to a temp dir only and loads none.
+ #[test]
+ #[ignore]
+ fn systemd_accepts_every_written_unit() {
+ let dir = std::env::temp_dir().join(format!("cce-timer-units-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ for (i, cmd) in AWKWARD.iter().enumerate() {
+ let path = dir.join(format!("t{i}.service"));
+ std::fs::write(&path, format!("[Service]\nType=oneshot\nExecStart={}\n", exec_start_for(cmd))).unwrap();
+ let out = std::process::Command::new("systemd-analyze")
+ .args(["--user", "verify"])
+ .arg(&path)
+ .output()
+ .unwrap();
+ let err = String::from_utf8_lossy(&out.stderr);
+ assert!(!err.contains("Failed to resolve") && !err.contains("fatal"), "{cmd:?}: {err}");
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
#[test]
fn humanize_ranges() {
assert_eq!(humanize(30), "30s");