//! One-command control of the rdsh auto-update timer.
//!
//! The repository ships `systemd/rdsh-sync.{service,timer}`, but they are
//! templates: the service points `ExecStart` at the machine the unit was
//! written on, and nothing installed or enabled them, so a published release
//! was only picked up when someone ran `sync-dsh.sh` by hand.
//!
//! This command closes that gap. It writes the two units with the sync script
//! path resolved for this machine and hands them to the user's systemd
//! instance:
//!
//! * `rdsh update auto status` — where the units live, which script they run,
//! and whether the timer is enabled and active.
//! * `rdsh update auto on` — write the units, `daemon-reload`, then
//! `enable --now rdsh-sync.timer`.
//! * `rdsh update auto off` — `disable --now`, then remove the units.
//!
//! The script path comes from `RDSH_SYNC_SCRIPT`, else the checkout that holds
//! this binary's sources (`RDSH_SOURCE_DIR`, else the directory above the
//! running executable when it contains `sync-dsh.sh`). Nothing is written when
//! the script cannot be found: a timer that silently fails every day is worse
//! than no timer.
//!
//! The same test decides *ownership*: the timer would run the script
//! unattended forever, so a script that is not owned by us or that user and
//! group can write is refused before any unit exists. `--force` overrides that
//! one check for setups that are trusted on purpose.
//!
//! `--dry-run` prints the plan without touching the filesystem or systemd, and
//! `RDSH_SYSTEMD_DIR` redirects the unit directory (used by the tests and by
//! anyone running an unsupported init system).
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
use anyhow::{bail, Context, Result};
use serde_json::json;
/// Unit name of the timer; the service is its `Service` counterpart.
const TIMER: &str = "rdsh-sync.timer";
const SERVICE: &str = "rdsh-sync.service";
/// Where the user units live, unless the caller redirects them.
fn unit_directory() -> PathBuf {
if let Some(dir) = std::env::var_os("RDSH_SYSTEMD_DIR") {
return PathBuf::from(dir);
}
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
.unwrap_or_else(|| PathBuf::from(".config"));
base.join("systemd/user")
}
/// The sync script this installation should run.
fn sync_script() -> Option<PathBuf> {
let candidates = [
std::env::var_os("RDSH_SYNC_SCRIPT").map(PathBuf::from),
std::env::var_os("RDSH_SOURCE_DIR").map(|dir| PathBuf::from(dir).join("sync-dsh.sh")),
std::env::current_exe().ok().and_then(|exe| {
exe.parent()
.and_then(|bin| bin.parent())
.map(|root| root.join("sync-dsh.sh"))
}),
];
candidates
.into_iter()
.flatten()
.find(|candidate| candidate.is_file())
}
fn service_unit(script: &Path) -> String {
format!(
"[Unit]\n\
Description=rdsh dsh auto-update (check upstream, update, verify, roll back)\n\
\n\
[Service]\n\
Type=oneshot\n\
# Low-spec hosts: never steal the machine during auto-update.\n\
Nice=19\n\
IOSchedulingClass=idle\n\
CPUWeight=10\n\
ExecStart={}\n",
exec_start_path(script)
)
}
/// Quote for systemd's command-line syntax.
///
/// `%` is a unit specifier and `$` starts a runtime variable expansion; both
/// would rewrite the path between here and `execve`, so both are escaped.
fn exec_start_path(script: &Path) -> String {
let mut quoted = String::from("\"");
for character in script.display().to_string().chars() {
match character {
'"' | '\\' => {
quoted.push('\\');
quoted.push(character);
}
'%' => quoted.push_str("%%"),
'$' => quoted.push_str("$$"),
_ => quoted.push(character),
}
}
quoted.push('"');
quoted
}
/// Why the candidate must not become a daily, unattended `ExecStart`.
///
/// Same bar as `passthrough::warn_if_risky_origin`: a regular file we own that
/// user and group cannot rewrite. The difference is that this one refuses
/// instead of warning, because the timer re-runs the script every day without
/// anyone watching.
#[cfg(unix)]
fn script_trust_problem(script: &Path) -> Option<&'static str> {
let metadata = fs::metadata(script).ok()?;
if !metadata.is_file() {
// A character device or fifo would block or misread `execve` forever.
return Some("not a regular file");
}
// SAFETY: geteuid takes no arguments and always succeeds.
if metadata.uid() != unsafe { libc::geteuid() } {
return Some("not owned by you");
}
if metadata.mode() & 0o022 != 0 {
return Some("group/world-writable");
}
None
}
#[cfg(not(unix))]
fn script_trust_problem(_script: &Path) -> Option<&'static str> {
None
}
/// The refusal message for one [`script_trust_problem`] reason.
fn trust_refusal(locale: crate::locale::Locale, script: &Path, problem: &str) -> String {
let path = script.display().to_string();
if problem == "not a regular file" {
return localized_format!(
locale,
"{path} is not a regular file, so the daily timer would not run the updater; pass --force to register it anyway",
"{path} 不是普通文件,每日计时器无法运行更新器;传入 --force 可强制注册",
"{path} は通常ファイルではないため、毎日のタイマーは更新処理を実行できません。--force を付けると登録できます"
);
}
if problem == "not owned by you" {
return localized_format!(
locale,
"{path} is not owned by you, so another process could change what runs as you every day; fix the owner, or pass --force to register it anyway",
"{path} 不属于你,其他进程可能改变每天以你的身份运行的内容;请修正所有者,或传入 --force 强制注册",
"{path} はあなたの所有ではないため、毎日あなたの権限で実行される内容を第三者に書き換えられる可能性があります。所有者を修正するか、--force を付けると登録できます"
);
}
localized_format!(
locale,
"{path} is group/world-writable, so any local process could replace what runs as you every day; run chmod go-w on it, or pass --force to register it anyway",
"{path} 可被同组或其他用户写入,本地进程可能替换每天以你的身份运行的内容;请对其执行 chmod go-w,或传入 --force 强制注册",
"{path} はグループまたは全ユーザーが書き込み可能なため、毎日あなたの権限で実行される内容を他のプロセスに差し替えられる可能性があります。chmod go-w を実行するか、--force を付けると登録できます"
)
}
fn timer_unit() -> String {
"[Unit]\n\
Description=Daily dsh auto-update check for rdsh\n\
\n\
[Timer]\n\
OnCalendar=daily\n\
RandomizedDelaySec=1800\n\
Persistent=true\n\
\n\
[Install]\n\
WantedBy=timers.target\n"
.to_string()
}
/// True when systemctl must not be called (tests, containers, other init
/// systems). The units are still written, so the operator keeps the file plan.
fn systemctl_skipped() -> bool {
std::env::var_os("RDSH_SYSTEMD_NO_RELOAD").is_some_and(|value| value == "1")
}
/// Absolute, trust-checked `systemctl`; a hostile `PATH` head must not be
/// able to shadow a binary the timer logic runs unattended.
fn systemctl_program() -> Result<&'static PathBuf> {
static SYSTEMCTL: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
let resolved = SYSTEMCTL.get_or_init(|| {
crate::exec_trust::find_trusted_or_well_known(
"systemctl",
&["/usr/bin/systemctl", "/bin/systemctl"],
)
});
resolved
.as_ref()
.ok_or_else(|| anyhow::anyhow!("trusted systemctl executable not found on PATH"))
}
fn systemctl(args: &[&str], locale: crate::locale::Locale) -> Result<String> {
let output = Command::new(systemctl_program()?)
.arg("--user")
.args(args)
.output()
.with_context(|| {
localized_format!(
locale,
"running systemctl --user {}",
"运行 systemctl --user {}",
"systemctl --user {} を実行",
args.join(" ")
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"{}",
localized_format!(
locale,
"systemctl --user {} failed: {}",
"systemctl --user {} 失败:{}",
"systemctl --user {} が失敗しました: {}",
args.join(" "),
stderr.trim()
)
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn systemctl_read(args: &[&str]) -> String {
let Ok(program) = systemctl_program() else {
return "unknown".to_string();
};
Command::new(program)
.arg("--user")
.args(args)
.output()
.ok()
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "unknown".to_string())
}
fn status(json: bool, locale: crate::locale::Locale) -> Result<()> {
let directory = unit_directory();
let script = sync_script();
let installed = directory.join(TIMER).is_file() && directory.join(SERVICE).is_file();
let state = json!({
"unit_directory": directory.display().to_string(),
"sync_script": script.as_ref().map(|path| path.display().to_string()),
"installed": installed,
"enabled": if installed { systemctl_read(&["is-enabled", TIMER]) } else { "not-installed".into() },
"active": if installed { systemctl_read(&["is-active", TIMER]) } else { "not-installed".into() },
});
if json {
println!("{}", serde_json::to_string_pretty(&state)?);
} else {
println!(
"{}",
localized_format!(
locale,
"units: {}",
"单元文件:{}",
"ユニットファイル: {}",
directory.display()
)
);
println!(
"{}",
localized_format!(
locale,
"script: {}",
"脚本:{}",
"スクリプト: {}",
script
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| crate::locale::diagnostic(
locale,
"(not found; set RDSH_SYNC_SCRIPT)"
)
.to_string())
)
);
println!(
"{}",
localized_format!(
locale,
"installed: {}",
"已安装:{}",
"インストール済み: {}",
if installed {
crate::locale::diagnostic(locale, "yes")
} else {
crate::locale::diagnostic(locale, "no")
}
)
);
if installed {
println!(
"{}",
localized_format!(
locale,
"enabled: {}",
"已启用:{}",
"有効: {}",
crate::locale::diagnostic(
locale,
state["enabled"].as_str().unwrap_or("unknown")
)
)
);
println!(
"{}",
localized_format!(
locale,
"active: {}",
"运行状态:{}",
"実行状態: {}",
crate::locale::diagnostic(
locale,
state["active"].as_str().unwrap_or("unknown")
)
)
);
}
}
Ok(())
}
/// Turn the timer on. Returns the plan that was applied so `--dry-run` and
/// callers share one code path.
fn enable(dry_run: bool, json: bool, force: bool, locale: crate::locale::Locale) -> Result<()> {
let directory = unit_directory();
let Some(script) = sync_script() else {
bail!(
match locale { crate::locale::Locale::English => "sync-dsh.sh was not found; set RDSH_SYNC_SCRIPT=/path/to/sync-dsh.sh (a timer that cannot run the updater would fail silently every day)", crate::locale::Locale::Chinese => "未找到 sync-dsh.sh;请设置 RDSH_SYNC_SCRIPT=/path/to/sync-dsh.sh(无法运行更新器的计时器会每天失败)", crate::locale::Locale::Japanese => "sync-dsh.sh が見つかりません。RDSH_SYNC_SCRIPT=/path/to/sync-dsh.sh を設定してください(更新処理を実行できないタイマーは毎日失敗します)" }
);
};
// Refuse before writing a unit: a timer that shuts itself off is worse than
// no timer, and a dead unit left behind would be worse still.
if let Some(problem) = script_trust_problem(&script) {
if !force {
bail!("{}", trust_refusal(locale, &script, problem));
}
}
let service = directory.join(SERVICE);
let timer = directory.join(TIMER);
if !dry_run {
fs::create_dir_all(&directory).with_context(|| {
localized_format!(
locale,
"creating {}",
"创建 {}",
"{} を作成",
directory.display()
)
})?;
fs::write(&service, service_unit(&script)).with_context(|| {
localized_format!(
locale,
"writing {}",
"写入 {}",
"{} を書き込み",
service.display()
)
})?;
fs::write(&timer, timer_unit()).with_context(|| {
localized_format!(
locale,
"writing {}",
"写入 {}",
"{} を書き込み",
timer.display()
)
})?;
if !systemctl_skipped() {
systemctl(&["daemon-reload"], locale)?;
systemctl(&["enable", "--now", TIMER], locale)?;
}
}
let result = json!({
"service": service.display().to_string(),
"timer": timer.display().to_string(),
"sync_script": script.display().to_string(),
"dry_run": dry_run,
"enabled": !dry_run,
"forced": force,
"systemctl": if dry_run || systemctl_skipped() { "skipped" } else { "applied" },
});
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else if dry_run {
println!(
"{}",
localized_format!(
locale,
"would write: {}",
"将写入:{}",
"書き込み予定: {}",
service.display()
)
);
println!(
"{}",
localized_format!(
locale,
"would write: {}",
"将写入:{}",
"書き込み予定: {}",
timer.display()
)
);
println!(
"{}",
localized_format!(
locale,
"would run: systemctl --user enable --now {TIMER}",
"将运行:systemctl --user enable --now {TIMER}",
"実行予定: systemctl --user enable --now {TIMER}"
)
);
println!(
"{}",
localized_format!(
locale,
"script: {}",
"脚本:{}",
"スクリプト: {}",
script.display()
)
);
} else {
println!(
"{}",
localized_format!(
locale,
"auto-update enabled: {} (daily)",
"已启用自动更新:{}(每天)",
"自動更新を有効にしました: {}(毎日)",
timer.display()
)
);
println!(
"{}",
localized_format!(
locale,
"script: {}",
"脚本:{}",
"スクリプト: {}",
script.display()
)
);
println!(
"{}",
localized_format!(
locale,
"check with: rdsh update auto status",
"检查状态:rdsh update auto status",
"状態確認: rdsh update auto status"
)
);
}
Ok(())
}
fn disable(dry_run: bool, json: bool, locale: crate::locale::Locale) -> Result<()> {
let directory = unit_directory();
let service = directory.join(SERVICE);
let timer = directory.join(TIMER);
let installed = timer.is_file() || service.is_file();
if !dry_run && installed {
// A timer that is not loaded cannot be disabled; ignore that case so an
// interrupted install can still be cleaned up.
if !systemctl_skipped() {
let _ = systemctl(&["disable", "--now", TIMER], locale);
}
for path in [&timer, &service] {
if path.is_file() {
fs::remove_file(path).with_context(|| {
localized_format!(
locale,
"removing {}",
"移除 {}",
"{} を削除",
path.display()
)
})?;
}
}
if !systemctl_skipped() {
systemctl(&["daemon-reload"], locale)?;
}
}
let result = json!({
"timer": timer.display().to_string(),
"service": service.display().to_string(),
"dry_run": dry_run,
"removed": installed && !dry_run,
});
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else if dry_run {
println!(
"{}",
localized_format!(
locale,
"would remove: {} and {}",
"将移除:{} 和 {}",
"削除予定: {} と {}",
timer.display(),
service.display()
)
);
} else if installed {
println!(
"{}",
localized_format!(
locale,
"auto-update disabled and units removed",
"已禁用自动更新并移除单元文件",
"自動更新を無効にし、ユニットファイルを削除しました"
)
);
} else {
println!(
"{}",
localized_format!(
locale,
"auto-update was not installed",
"未安装自动更新",
"自動更新は未インストールです"
)
);
}
Ok(())
}
/// Entry point behind `rdsh update auto ...`.
pub(crate) fn cmd_update_auto(
action: &str,
dry_run: bool,
json: bool,
force: bool,
locale: crate::locale::Locale,
) -> Result<()> {
match action {
"status" => status(json, locale),
"on" => enable(dry_run, json, force, locale),
"off" => disable(dry_run, json, locale),
other => bail!(
"{}",
localized_format!(
locale,
"unknown action {other:?}: use status, on or off",
"未知操作 {other:?}:请使用 status、on 或 off",
"不明な操作 {other:?}: status、on、off を指定してください"
)
),
}
}