Skip to main content

decdn_cli/commands/self_update/
check.rs

1//! The once-a-day notice that a newer signed release exists.
2//!
3//! At most once per day, the CLI asks GitHub for the latest release tag and,
4//! once the command's own work has succeeded, prints one line on stderr when
5//! that tag is newer than this build. The lookup starts alongside the
6//! command and the command waits for it at most `GRACE` after finishing,
7//! so a long command pays nothing and a short one at most a second, once a
8//! day. The check never runs for `--json` output or when stdout or stderr is
9//! not a terminal, and is silent on every failure. `[self_update] check =
10//! false` in the config file or `DECDN_SELF_UPDATE_CHECK=0` turns it off.
11
12use std::io::{IsTerminal, Write};
13use std::path::{Path, PathBuf};
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use decdn_common::config::load_file_config;
17
18use super::release::{
19    Clients, Upgrade, decide, releases_base, resolve_latest, running_version, version_from_tag,
20};
21
22/// How long after one check the next one is due.
23const INTERVAL: Duration = Duration::from_hours(24);
24
25/// The whole-request bound on the lookup, which runs alongside the command.
26const LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
27
28/// How long a finished command waits for a lookup still in flight: enough
29/// for a GitHub round trip after a short command, nothing after a long one.
30const GRACE: Duration = Duration::from_secs(1);
31
32/// The environment variable that overrides `[self_update] check`.
33pub(crate) const ENV_CHECK: &str = "DECDN_SELF_UPDATE_CHECK";
34
35/// What the finished command tells the notice about itself. Either field
36/// true suppresses the notice.
37#[derive(Clone, Copy, Debug)]
38pub struct NudgePolicy {
39    /// The command rendered JSON for a program to read.
40    pub json: bool,
41    /// The command is `self-update` itself.
42    pub excluded: bool,
43}
44
45/// The stamp recording the last check, under the data directory.
46pub(crate) fn stamp_path(data_dir: &Path) -> PathBuf {
47    data_dir.join("self-update-check")
48}
49
50/// Whether a check is due at `now_secs` given the stamp's contents. A
51/// missing, unreadable, or future-dated stamp means due.
52pub(crate) fn is_due(stamp: Option<&str>, now_secs: u64) -> bool {
53    let Some(last) = stamp.and_then(|s| s.trim().parse::<u64>().ok()) else {
54        return true;
55    };
56    last > now_secs || now_secs - last >= INTERVAL.as_secs()
57}
58
59/// `1|true|yes|on` → `Some(true)`, `0|false|no|off` → `Some(false)`, case
60/// folded and trimmed; anything else is ignored.
61pub(crate) fn env_flag(raw: Option<&str>) -> Option<bool> {
62    match raw?.trim().to_ascii_lowercase().as_str() {
63        "1" | "true" | "yes" | "on" => Some(true),
64        "0" | "false" | "no" | "off" => Some(false),
65        _ => None,
66    }
67}
68
69/// Resolve the knob: the env var wins, then `[self_update] check`, then on.
70/// A config file that cannot be read or parsed (`Err`) turns the check off.
71pub(crate) fn check_enabled(env: Option<bool>, file_check: Result<Option<bool>, ()>) -> bool {
72    match (env, file_check) {
73        (Some(v), _) => v,
74        (None, Ok(v)) => v.unwrap_or(true),
75        (None, Err(())) => false,
76    }
77}
78
79/// Stamp the check at `now_secs`, look the latest release up, and return the
80/// notice line when it is newer than this build. The stamp is written
81/// first: a lookup that hangs or fails must not repeat on every command.
82/// When the stamp cannot be written, nothing is looked up.
83pub(crate) async fn notice(
84    stamp: &Path,
85    base: &str,
86    timeout: Duration,
87    now_secs: u64,
88) -> Option<String> {
89    write_stamp(stamp, now_secs).ok()?;
90    let clients = Clients::build(timeout).ok()?;
91    let tag = tokio::time::timeout(timeout, resolve_latest(&clients, base))
92        .await
93        .ok()?
94        .ok()?;
95    let latest = version_from_tag(&tag).ok()?;
96    let running = running_version().ok()?;
97    match decide(&running, &latest) {
98        Upgrade::Newer => Some(format!(
99            "decdn {latest} is available; run `decdn self-update`"
100        )),
101        Upgrade::UpToDate | Upgrade::Downgrade | Upgrade::PreRelease => None,
102    }
103}
104
105/// Write the stamp. Its directory is `~/.decdn`, which the identity code
106/// requires to be private (mode 0700), so a missing directory is created
107/// private rather than with the umask's bits.
108fn write_stamp(stamp: &Path, now_secs: u64) -> std::io::Result<()> {
109    if let Some(dir) = stamp.parent() {
110        let mut builder = std::fs::DirBuilder::new();
111        builder.recursive(true);
112        #[cfg(unix)]
113        {
114            use std::os::unix::fs::DirBuilderExt;
115            builder.mode(0o700);
116        }
117        builder.create(dir)?;
118    }
119    let mut options = std::fs::OpenOptions::new();
120    options.write(true).create(true).truncate(true);
121    #[cfg(unix)]
122    {
123        use std::os::unix::fs::OpenOptionsExt;
124        options.mode(0o600);
125    }
126    let mut file = options.open(stamp)?;
127    writeln!(file, "{now_secs}")
128}
129
130/// A lookup in flight, started before the command ran.
131#[derive(Debug)]
132pub struct Pending {
133    handle: tokio::task::JoinHandle<Option<String>>,
134}
135
136impl Pending {
137    /// The command succeeded: print the notice if the lookup produced one
138    /// within the grace period. A lookup still in flight after that is dropped.
139    pub async fn finish(self) {
140        if let Some(line) = settle(self.handle, GRACE).await {
141            eprintln!("{line}");
142        }
143    }
144
145    /// The command failed: the terminal has its attention already.
146    pub fn cancel(self) {
147        self.handle.abort();
148    }
149}
150
151/// Wait at most `grace` for the lookup's line; abort it otherwise.
152pub(crate) async fn settle(
153    handle: tokio::task::JoinHandle<Option<String>>,
154    grace: Duration,
155) -> Option<String> {
156    let abort = handle.abort_handle();
157    match tokio::time::timeout(grace, handle).await {
158        Ok(Ok(line)) => line,
159        Ok(Err(_)) => None,
160        Err(_elapsed) => {
161            abort.abort();
162            None
163        }
164    }
165}
166
167/// Start the daily check for a command about to run, when one is due and
168/// the command's output is a terminal. Returns the lookup to settle once the
169/// command has finished, or `None` when no check runs.
170pub fn start(policy: NudgePolicy, config_path: Option<&Path>) -> Option<Pending> {
171    if policy.excluded
172        || policy.json
173        || !std::io::stdout().is_terminal()
174        || !std::io::stderr().is_terminal()
175    {
176        return None;
177    }
178    let env = env_flag(std::env::var(ENV_CHECK).ok().as_deref());
179    let file_check = load_file_config(config_path)
180        .map(|f| f.self_update.and_then(|s| s.check))
181        .map_err(|_| ());
182    if !check_enabled(env, file_check) {
183        return None;
184    }
185    let data_dir = decdn_common::cli::default_data_dir()?;
186    let stamp = stamp_path(&data_dir);
187    let now_secs = SystemTime::now()
188        .duration_since(UNIX_EPOCH)
189        .map_or(0, |d| d.as_secs());
190    if !is_due(std::fs::read_to_string(&stamp).ok().as_deref(), now_secs) {
191        return None;
192    }
193    let base = releases_base();
194    let handle = tokio::spawn(async move { notice(&stamp, &base, LOOKUP_TIMEOUT, now_secs).await });
195    Some(Pending { handle })
196}
197
198#[cfg(test)]
199#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // tests
200mod tests;