Skip to main content

rtb_update/
flow.rs

1//! The 10-step self-update flow. Pure functions + small helpers the
2//! [`crate::Updater`] composes.
3//!
4//! The steps run in strict order — each precondition is checked and a
5//! failure leaves the disk in one of two states: the old binary
6//! untouched, or the new one fully verified and swapped in. No
7//! in-between.
8
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use rtb_forge::{ReleaseAsset, ReleaseProvider};
13use tokio::io::AsyncReadExt as _;
14
15use crate::error::{Result, UpdateError};
16use crate::options::{ProgressEvent, ProgressSink, RunOutcome};
17
18/// Swap function — lets tests substitute a double for
19/// `self_replace::self_replace`. Returns `Ok` iff the running binary
20/// has been replaced.
21pub type SwapFn = Arc<dyn Fn(&Path) -> std::io::Result<()> + Send + Sync + 'static>;
22
23/// Self-test function — runs the staged binary with `--version` and
24/// returns its captured stdout. Lets tests avoid spawning a real child.
25pub type SelfTestFn = Arc<dyn Fn(&Path) -> std::io::Result<String> + Send + Sync + 'static>;
26
27/// Default `SwapFn` — real `self-replace` call.
28#[must_use]
29pub fn default_swap_fn() -> SwapFn {
30    Arc::new(|src: &Path| self_replace::self_replace(src))
31}
32
33/// Default `SelfTestFn` — exec `<staged> --version` with a 10 s timeout,
34/// return stdout. A non-zero exit or timeout surfaces as
35/// `UpdateError::SelfTestFailed` at the call site.
36#[must_use]
37pub fn default_self_test_fn() -> SelfTestFn {
38    Arc::new(|binary: &Path| {
39        let output = std::process::Command::new(binary).arg("--version").output()?;
40        if !output.status.success() {
41            return Err(std::io::Error::other(format!(
42                "--version exited with status {}",
43                output.status
44            )));
45        }
46        Ok(String::from_utf8_lossy(&output.stdout).to_string())
47    })
48}
49
50/// Stream `asset` into `dest`, returning the byte count. Emits
51/// `Downloading` progress events.
52pub async fn download_to_file(
53    provider: &dyn ReleaseProvider,
54    asset: &ReleaseAsset,
55    dest: &Path,
56    progress: Option<&ProgressSink>,
57) -> Result<u64> {
58    let (mut reader, total) = provider.download_asset(asset).await?;
59    let mut file = tokio::fs::File::create(dest).await?;
60    // Heap-allocated so the enclosing async state doesn't grow by 64 KiB
61    // (clippy::large_futures otherwise fires on every caller up the
62    // tree). 64 KiB matches the default `tokio::io::copy` block size.
63    let mut buf = vec![0u8; 64 * 1024];
64    let mut done = 0u64;
65    loop {
66        let n = reader.read(&mut buf).await?;
67        if n == 0 {
68            break;
69        }
70        tokio::io::AsyncWriteExt::write_all(&mut file, &buf[..n]).await?;
71        done += n as u64;
72        if let Some(sink) = progress {
73            sink(ProgressEvent::Downloading { bytes_done: done, bytes_total: total });
74        }
75    }
76    tokio::io::AsyncWriteExt::flush(&mut file).await?;
77    Ok(done)
78}
79
80/// Fully buffer an asset into memory. Returns the bytes. Used for
81/// signatures and checksum files, which are small.
82pub async fn fetch_small_asset(
83    provider: &dyn ReleaseProvider,
84    asset: &ReleaseAsset,
85) -> Result<Vec<u8>> {
86    let (mut reader, _) = provider.download_asset(asset).await?;
87    let mut out = Vec::new();
88    reader.read_to_end(&mut out).await?;
89    Ok(out)
90}
91
92/// Extract the binary matching `tool_name` from the archive at `src`
93/// into `dest_dir`. Returns the extracted binary's path.
94///
95/// Supports `.tar.gz` (via `tar` + `flate2`) and `.zip` (via `zip`).
96/// Other formats produce [`UpdateError::Archive`].
97pub fn extract_binary(src: &Path, dest_dir: &Path, tool_name: &str) -> Result<PathBuf> {
98    std::fs::create_dir_all(dest_dir)?;
99    let file_name =
100        src.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_ascii_lowercase();
101
102    // `file_name` is already lowercased; `ends_with` is a byte comparison
103    // so the lint-flagged case-sensitivity concern doesn't apply here.
104    #[allow(clippy::case_sensitive_file_extension_comparisons)]
105    let is_tar_gz = file_name.ends_with(".tar.gz") || file_name.ends_with(".tgz");
106    #[allow(clippy::case_sensitive_file_extension_comparisons)]
107    let is_zip = file_name.ends_with(".zip");
108    if is_tar_gz {
109        extract_tar_gz(src, dest_dir)?;
110    } else if is_zip {
111        extract_zip(src, dest_dir)?;
112    } else {
113        return Err(UpdateError::Archive(format!("unsupported archive extension: {file_name}")));
114    }
115
116    // Locate the binary. Executable bit check on POSIX, extension on
117    // Windows. Fall back to name match.
118    let expected_name_unix = tool_name.to_string();
119    let expected_name_windows = format!("{tool_name}.exe");
120    for entry in walk_files(dest_dir) {
121        let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
122        if name == expected_name_unix || name == expected_name_windows {
123            return Ok(entry);
124        }
125    }
126    Err(UpdateError::Archive(format!("extracted archive contained no `{tool_name}` binary")))
127}
128
129fn extract_tar_gz(src: &Path, dest_dir: &Path) -> Result<()> {
130    let file = std::fs::File::open(src)?;
131    let gz = flate2::read::GzDecoder::new(file);
132    let mut archive = tar::Archive::new(gz);
133    archive.unpack(dest_dir).map_err(|e| UpdateError::Archive(e.to_string()))
134}
135
136fn extract_zip(src: &Path, dest_dir: &Path) -> Result<()> {
137    let file = std::fs::File::open(src)?;
138    let mut archive =
139        zip::ZipArchive::new(file).map_err(|e| UpdateError::Archive(e.to_string()))?;
140    for i in 0..archive.len() {
141        let mut entry = archive.by_index(i).map_err(|e| UpdateError::Archive(e.to_string()))?;
142        let Some(rel_path) = entry.enclosed_name() else {
143            continue;
144        };
145        let out_path = dest_dir.join(rel_path);
146        if entry.is_dir() {
147            std::fs::create_dir_all(&out_path)?;
148            continue;
149        }
150        if let Some(parent) = out_path.parent() {
151            std::fs::create_dir_all(parent)?;
152        }
153        let mut out_file = std::fs::File::create(&out_path)?;
154        std::io::copy(&mut entry, &mut out_file)?;
155    }
156    Ok(())
157}
158
159fn walk_files(root: &Path) -> Vec<PathBuf> {
160    let mut stack = vec![root.to_path_buf()];
161    let mut out = Vec::new();
162    while let Some(dir) = stack.pop() {
163        let Ok(read) = std::fs::read_dir(&dir) else {
164            continue;
165        };
166        for entry in read.flatten() {
167            let path = entry.path();
168            if path.is_dir() {
169                stack.push(path);
170            } else {
171                out.push(path);
172            }
173        }
174    }
175    out
176}
177
178/// Set POSIX executable bits on `path`. No-op on Windows.
179pub fn mark_executable(path: &Path) -> Result<()> {
180    // `path` is unused on Windows but clippy reports `underscore_bindings`
181    // when the parameter is `_path`. Accept both by silencing the
182    // non-unix arm's warning.
183    #[cfg(unix)]
184    {
185        use std::os::unix::fs::PermissionsExt as _;
186        let mut perm = std::fs::metadata(path)?.permissions();
187        perm.set_mode(0o755);
188        std::fs::set_permissions(path, perm)?;
189    }
190    #[cfg(not(unix))]
191    let _ = path;
192    Ok(())
193}
194
195/// Dry-run outcome shape — no swap.
196#[must_use]
197pub const fn dry_run_outcome(
198    from: semver::Version,
199    to: semver::Version,
200    bytes: u64,
201    staged_at: PathBuf,
202) -> RunOutcome {
203    RunOutcome {
204        from_version: from,
205        to_version: to,
206        bytes,
207        swapped: false,
208        staged_at: Some(staged_at),
209    }
210}
211
212/// Swap outcome shape — after a successful `self-replace`.
213#[must_use]
214pub const fn swap_outcome(from: semver::Version, to: semver::Version, bytes: u64) -> RunOutcome {
215    RunOutcome { from_version: from, to_version: to, bytes, swapped: true, staged_at: None }
216}
217
218/// Strip any leading `v` / `V` from a tag and parse as semver. Used
219/// for the `check` comparison and downgrade check.
220#[must_use]
221pub fn parse_release_tag(tag: &str) -> Option<semver::Version> {
222    let stripped = tag.strip_prefix(['v', 'V']).unwrap_or(tag);
223    semver::Version::parse(stripped).ok()
224}
225
226/// Default cache dir — `<project-cache-dir>/update/<version>/`.
227/// Falls back to the system temp dir if directories-rs can't resolve.
228pub fn cache_dir_for(tool_name: &str, version: &str) -> PathBuf {
229    let base = directories::ProjectDirs::from("", "", tool_name)
230        .map_or_else(std::env::temp_dir, |p| p.cache_dir().to_path_buf());
231    base.join("update").join(version)
232}