From cf6024e07b103b8096169726ab45fd6dd11b2460 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Thu, 30 Apr 2026 21:24:43 +0200 Subject: [PATCH 1/4] feat: add self-update command for in-place pvm upgrades Adds 'pvm self-update' (check only) and 'pvm self-update --apply' (check + interactive Confirm + atomic binary replace) using the GitHub releases API and the same release artifacts that install.sh publishes. Extracts shared HTTP client, target-triple detection, progress bar, and stream-to-tempfile helpers from network.rs so the new command reuses them instead of duplicating ~50 lines. --- src/cli.rs | 5 + src/commands/mod.rs | 1 + src/commands/self_update.rs | 199 ++++++++++++++++++++++++++++++++++++ src/network.rs | 78 ++++++++------ tests/cli.rs | 18 ++++ 5 files changed, 268 insertions(+), 33 deletions(-) create mode 100644 src/commands/self_update.rs diff --git a/src/cli.rs b/src/cli.rs index 7c06cf0..77f84a8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -52,6 +52,10 @@ pub enum Commands { /// Initialize a .php-version file in the current directory #[clap(name = "init")] Init(commands::init::Init), + + /// Check for and apply updates to pvm itself + #[clap(name = "self-update")] + SelfUpdate(commands::self_update::SelfUpdate), } impl Commands { @@ -65,6 +69,7 @@ impl Commands { Self::Current(cmd) => cmd.call().await, Self::Uninstall(cmd) => cmd.call().await, Self::Init(cmd) => cmd.call().await, + Self::SelfUpdate(cmd) => cmd.call().await, } } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 26a06c5..317902b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,5 +4,6 @@ pub mod init; pub mod install; pub mod ls; pub mod ls_remote; +pub mod self_update; pub mod uninstall; pub mod use_cmd; diff --git a/src/commands/self_update.rs b/src/commands/self_update.rs new file mode 100644 index 0000000..de8938e --- /dev/null +++ b/src/commands/self_update.rs @@ -0,0 +1,199 @@ +use crate::network; +use anyhow::{Context, Result}; +use clap::Parser; +use colored::Colorize; +use serde::Deserialize; +use std::io::Write; + +const GITHUB_REPO: &str = "WebProject-xyz/php-version-manager"; + +/// Check for and apply pvm updates +#[derive(Parser, Debug)] +pub struct SelfUpdate { + /// Apply the update if a newer version is available (asks for confirmation, default yes) + #[arg(long)] + pub apply: bool, +} + +#[derive(Deserialize)] +struct GhRelease { + tag_name: String, +} + +fn current_version() -> Result { + let raw = env!("PVM_VERSION"); + let token = raw.split_whitespace().next().unwrap_or(raw); + let trimmed = token.trim_start_matches('v'); + // git-describe extras (e.g. "-2-gabc") are not valid semver; drop them. + let core = trimmed.split('-').next().unwrap_or(trimmed); + semver::Version::parse(core).with_context(|| { + format!( + "Failed to parse current pvm version '{}' from '{}'", + core, raw + ) + }) +} + +fn parse_remote_version(tag: &str) -> Result { + let trimmed = tag.trim_start_matches('v'); + semver::Version::parse(trimmed) + .with_context(|| format!("Failed to parse remote release tag '{}'", tag)) +} + +async fn fetch_latest_release() -> Result { + let url = format!( + "https://api.github.com/repos/{}/releases/latest", + GITHUB_REPO + ); + let release = network::http_client()? + .get(&url) + .header("Accept", "application/vnd.github+json") + .send() + .await + .context("Failed to query GitHub releases API")? + .error_for_status() + .context("GitHub releases API returned an error")? + .json::() + .await + .context("Failed to parse GitHub release JSON")?; + Ok(release) +} + +async fn download_and_replace(tag: &str) -> Result<()> { + let target = network::get_target_triple()?; + let asset = format!("pvm-{}.tar.gz", target); + let url = format!( + "https://github.com/{}/releases/download/{}/{}", + GITHUB_REPO, tag, asset + ); + + let current_exe = + std::env::current_exe().context("Failed to determine current executable path")?; + let exe_dir = current_exe + .parent() + .context("Current executable has no parent directory")?; + + println!("{} Downloading {}...", "↻".blue(), url); + + let response = network::http_client()? + .get(&url) + .send() + .await + .context("Failed to download release archive")? + .error_for_status() + .context("Server returned an error when downloading release")?; + + let pb = network::build_download_progress_bar(response.content_length())?; + let tmp = network::stream_to_tempfile(response, &pb).await?; + pb.finish_and_clear(); + + // Stage the new binary in the same directory as the current exe so the rename is atomic + // (cross-filesystem renames would fail otherwise). + let mut staged = tempfile::Builder::new() + .prefix(".pvm-update-") + .tempfile_in(exe_dir) + .context("Failed to create staging file next to current executable")?; + + { + let tar = flate2::read::GzDecoder::new(tmp); + let mut archive = tar::Archive::new(tar); + let mut found = false; + for entry in archive + .entries() + .context("Failed to read archive entries")? + { + let mut entry = entry.context("Failed to read archive entry")?; + let path = entry.path().context("Invalid entry path")?.into_owned(); + let name = path.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if name == "pvm" { + std::io::copy(&mut entry, staged.as_file_mut()) + .context("Failed to extract pvm binary from archive")?; + staged + .as_file_mut() + .flush() + .context("Failed to flush staged binary")?; + found = true; + break; + } + } + if !found { + anyhow::bail!("Release archive did not contain a `pvm` binary"); + } + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(staged.path(), std::fs::Permissions::from_mode(0o755)) + .context("Failed to set permissions on staged binary")?; + } + + // Atomic rename. On Unix this is safe even while the current binary is executing — the + // kernel keeps the old inode alive through open fds until the process exits. + staged + .persist(¤t_exe) + .with_context(|| format!("Failed to replace current executable {:?}", current_exe))?; + + Ok(()) +} + +impl SelfUpdate { + pub async fn call(self) -> Result<()> { + let current = current_version()?; + let current_str = current.to_string(); + println!("{} Current version: {}", "↻".blue(), current_str.bold()); + println!("{} Checking GitHub for the latest release...", "↻".blue()); + + let release = fetch_latest_release().await?; + let remote = parse_remote_version(&release.tag_name)?; + + if remote <= current { + println!("{} pvm is up to date ({})", "✓".green(), current_str.bold()); + return Ok(()); + } + + let remote_str = remote.to_string(); + println!( + "{} New version available: {} → {}", + "💡".yellow(), + current_str.bold(), + remote_str.bold() + ); + + if !self.apply { + println!( + "{} Run `{}` to install it.", + "💡".yellow(), + "pvm self-update --apply".bold() + ); + return Ok(()); + } + + let theme = dialoguer::theme::ColorfulTheme::default(); + let confirmed = dialoguer::Confirm::with_theme(&theme) + .with_prompt(format!("Update pvm to {}?", remote_str).bold().to_string()) + .default(true) + .interact_opt() + .unwrap_or(Some(false)) + .unwrap_or(false); + + if !confirmed { + println!("{} Update cancelled.", "✗".red()); + return Ok(()); + } + + download_and_replace(&release.tag_name).await?; + println!( + "{} Successfully updated pvm to {}", + "✓".green(), + remote_str.bold() + ); + println!( + "{} Restart your shell or re-run `{}` to pick up the new binary.", + "💡".yellow(), + "pvm env".bold() + ); + + Ok(()) + } +} diff --git a/src/network.rs b/src/network.rs index eca3c8b..5edad19 100644 --- a/src/network.rs +++ b/src/network.rs @@ -17,11 +17,13 @@ use std::time::Duration; const CACHE_DURATION: Duration = Duration::from_secs(24 * 60 * 60); // 24 hours const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(300); +const USER_AGENT: &str = concat!("pvm/", env!("CARGO_PKG_VERSION")); -fn http_client() -> Result { +pub(crate) fn http_client() -> Result { Client::builder() .connect_timeout(HTTP_CONNECT_TIMEOUT) .timeout(HTTP_REQUEST_TIMEOUT) + .user_agent(USER_AGENT) .build() .context("Failed to initialize HTTP client") } @@ -32,7 +34,7 @@ struct RemoteFile { is_dir: bool, } -fn get_target_triple() -> Result<&'static str> { +pub(crate) fn get_target_triple() -> Result<&'static str> { use std::env::consts::{ARCH, OS}; match (OS, ARCH) { ("linux", "x86_64") => Ok("linux-x86_64"), @@ -43,6 +45,45 @@ fn get_target_triple() -> Result<&'static str> { } } +pub(crate) fn build_download_progress_bar(total_size: Option) -> Result { + let pb = if let Some(size) = total_size { + let pb = ProgressBar::new(size); + pb.set_style(ProgressStyle::default_bar() + .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})")? + .progress_chars("#>-")); + pb + } else { + let pb = ProgressBar::new_spinner(); + pb.set_style(ProgressStyle::default_spinner().template( + "{spinner:.green} [{elapsed_precise}] {bytes} downloaded ({bytes_per_sec})", + )?); + pb + }; + Ok(pb) +} + +// Stream the response into a temp file to avoid materializing the whole archive in memory, +// returning a handle rewound to position 0 so the caller can feed it to a decoder. +pub(crate) async fn stream_to_tempfile( + response: reqwest::Response, + pb: &ProgressBar, +) -> Result { + let mut tmp = tempfile::tempfile().context("Failed to create temporary archive file")?; + let mut downloaded: u64 = 0; + let mut stream = response.bytes_stream(); + while let Some(item) = stream.next().await { + let chunk = item.context("Error while downloading chunk")?; + tmp.write_all(&chunk) + .context("Failed to write archive chunk to temp file")?; + downloaded += chunk.len() as u64; + pb.set_position(downloaded); + } + tmp.flush().context("Failed to flush temp archive file")?; + tmp.seek(SeekFrom::Start(0)) + .context("Failed to rewind temp archive file")?; + Ok(tmp) +} + pub async fn get_available_versions() -> Result)>> { let pvm_dir = crate::fs::get_pvm_dir()?; let target = get_target_triple()?; @@ -222,37 +263,8 @@ pub async fn download_and_extract( resolved_version, package ))?; - let total_size = response.content_length(); - - let pb = if let Some(size) = total_size { - let pb = ProgressBar::new(size); - pb.set_style(ProgressStyle::default_bar() - .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})")? - .progress_chars("#>-")); - pb - } else { - let pb = ProgressBar::new_spinner(); - pb.set_style(ProgressStyle::default_spinner().template( - "{spinner:.green} [{elapsed_precise}] {bytes} downloaded ({bytes_per_sec})", - )?); - pb - }; - - // Stream the archive into a temp file to avoid materializing the whole tarball in memory. - let mut tmp = tempfile::tempfile().context("Failed to create temporary archive file")?; - let mut downloaded: u64 = 0; - let mut stream = response.bytes_stream(); - while let Some(item) = stream.next().await { - let chunk = item.context("Error while downloading chunk")?; - tmp.write_all(&chunk) - .context("Failed to write archive chunk to temp file")?; - downloaded += chunk.len() as u64; - pb.set_position(downloaded); - } - tmp.flush().context("Failed to flush temp archive file")?; - tmp.seek(SeekFrom::Start(0)) - .context("Failed to rewind temp archive file")?; - + let pb = build_download_progress_bar(response.content_length())?; + let tmp = stream_to_tempfile(response, &pb).await?; pb.finish_with_message(format!("Downloaded package {}", package)); let tar = GzDecoder::new(tmp); diff --git a/tests/cli.rs b/tests/cli.rs index f7edb90..4fcb5f2 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -27,6 +27,24 @@ fn test_version_short() { .stdout(predicate::str::contains("pvm")); } +#[test] +fn test_self_update_help() { + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.arg("self-update").arg("--help"); + cmd.assert() + .success() + .stdout(predicate::str::contains("--apply")); +} + +#[test] +fn test_help_lists_self_update() { + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.arg("--help"); + cmd.assert() + .success() + .stdout(predicate::str::contains("self-update")); +} + #[test] fn test_ls_empty() { let temp_dir = tempfile::tempdir().unwrap(); From 69e77ed490a1524f1ffdd8c1fc554f646c2c23f0 Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Wed, 6 May 2026 19:35:23 +0200 Subject: [PATCH 2/4] fix: address CodeRabbit review on PR #19 - self_update: fall back to CARGO_PKG_VERSION when PVM_VERSION is not valid semver so non-tagged/CI builds still parse a usable version. - self_update: propagate dialoguer prompt errors via .context()? instead of silently mapping them to a user cancellation. - tests: isolate the two new self-update CLI tests with tempdir + PVM_DIR per the project's integration-test convention. --- src/commands/self_update.rs | 16 +++++++++------- tests/cli.rs | 4 ++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/commands/self_update.rs b/src/commands/self_update.rs index de8938e..7d87f79 100644 --- a/src/commands/self_update.rs +++ b/src/commands/self_update.rs @@ -26,12 +26,14 @@ fn current_version() -> Result { let trimmed = token.trim_start_matches('v'); // git-describe extras (e.g. "-2-gabc") are not valid semver; drop them. let core = trimmed.split('-').next().unwrap_or(trimmed); - semver::Version::parse(core).with_context(|| { - format!( - "Failed to parse current pvm version '{}' from '{}'", - core, raw - ) - }) + semver::Version::parse(core) + .or_else(|_| semver::Version::parse(env!("CARGO_PKG_VERSION"))) + .with_context(|| { + format!( + "Failed to parse current pvm version '{}' from '{}'", + core, raw + ) + }) } fn parse_remote_version(tag: &str) -> Result { @@ -174,7 +176,7 @@ impl SelfUpdate { .with_prompt(format!("Update pvm to {}?", remote_str).bold().to_string()) .default(true) .interact_opt() - .unwrap_or(Some(false)) + .context("Failed to read update confirmation from terminal")? .unwrap_or(false); if !confirmed { diff --git a/tests/cli.rs b/tests/cli.rs index 4fcb5f2..28b0983 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -29,7 +29,9 @@ fn test_version_short() { #[test] fn test_self_update_help() { + let temp_dir = tempfile::tempdir().unwrap(); let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); cmd.arg("self-update").arg("--help"); cmd.assert() .success() @@ -38,7 +40,9 @@ fn test_self_update_help() { #[test] fn test_help_lists_self_update() { + let temp_dir = tempfile::tempdir().unwrap(); let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); cmd.arg("--help"); cmd.assert() .success() From 5074273e7d3ecb881bedbdccd63a1fe532613a5d Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Wed, 6 May 2026 22:23:10 +0200 Subject: [PATCH 3/4] test(self-update): add unit tests for version parsing Extract parse_pvm_version(&str) helper from current_version() so the leading-v strip, git-describe suffix drop, whitespace-token split, and CARGO_PKG_VERSION fallback paths can be exercised hermetically. Adds tests for parse_remote_version too. Addresses CodeRabbit nitpick on PR #19. The other three nitpicks (async blocking writes in stream_to_tempfile, permission-denied hint on tempfile_in, tar entry-type validation) were rated low-value / not-blocking by CodeRabbit itself and are intentionally skipped. --- src/commands/self_update.rs | 54 +++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/commands/self_update.rs b/src/commands/self_update.rs index 7d87f79..fd236da 100644 --- a/src/commands/self_update.rs +++ b/src/commands/self_update.rs @@ -20,8 +20,7 @@ struct GhRelease { tag_name: String, } -fn current_version() -> Result { - let raw = env!("PVM_VERSION"); +fn parse_pvm_version(raw: &str) -> Result { let token = raw.split_whitespace().next().unwrap_or(raw); let trimmed = token.trim_start_matches('v'); // git-describe extras (e.g. "-2-gabc") are not valid semver; drop them. @@ -36,6 +35,10 @@ fn current_version() -> Result { }) } +fn current_version() -> Result { + parse_pvm_version(env!("PVM_VERSION")) +} + fn parse_remote_version(tag: &str) -> Result { let trimmed = tag.trim_start_matches('v'); semver::Version::parse(trimmed) @@ -199,3 +202,50 @@ impl SelfUpdate { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn v(major: u64, minor: u64, patch: u64) -> semver::Version { + semver::Version::new(major, minor, patch) + } + + #[test] + fn parse_pvm_version_strips_leading_v() { + assert_eq!(parse_pvm_version("v1.2.3").unwrap(), v(1, 2, 3)); + } + + #[test] + fn parse_pvm_version_drops_git_describe_suffix() { + assert_eq!(parse_pvm_version("1.2.3-2-gabc").unwrap(), v(1, 2, 3)); + assert_eq!(parse_pvm_version("v1.2.3-2-gabc").unwrap(), v(1, 2, 3)); + } + + #[test] + fn parse_pvm_version_takes_first_whitespace_token() { + // build.rs may embed "VERSION (commit ...)" forms. + assert_eq!( + parse_pvm_version("1.2.3 (abcd1234 2026-01-01)").unwrap(), + v(1, 2, 3) + ); + } + + #[test] + fn parse_pvm_version_falls_back_to_cargo_pkg_version_on_unknown() { + let parsed = parse_pvm_version("unknown").unwrap(); + let pkg = semver::Version::parse(env!("CARGO_PKG_VERSION")).unwrap(); + assert_eq!(parsed, pkg); + } + + #[test] + fn parse_remote_version_strips_leading_v() { + assert_eq!(parse_remote_version("v1.2.3").unwrap(), v(1, 2, 3)); + assert_eq!(parse_remote_version("1.2.3").unwrap(), v(1, 2, 3)); + } + + #[test] + fn parse_remote_version_rejects_garbage() { + assert!(parse_remote_version("not-a-version").is_err()); + } +} From a054812fecec65881e8035aeba820236b9514e4e Mon Sep 17 00:00:00 2001 From: Benjamin Fahl Date: Thu, 7 May 2026 20:53:07 +0200 Subject: [PATCH 4/4] feat(self-update): verify SHA-256 of downloaded archive Fetches a companion .sha256 file alongside the release tarball and hashes the downloaded bytes before atomically replacing the running binary. Mismatched or missing checksums abort the update so a compromised CDN response or wrong-asset upload cannot silently install an unverified binary. Adds sha256sum/shasum step to the release workflow that uploads pvm-{target}.tar.gz.sha256 next to each archive. Addresses CodeRabbit review on PR #19. --- .github/workflows/release.yml | 16 +++- Cargo.lock | 130 ++++++++++++++++++++++--------- Cargo.toml | 1 + src/commands/self_update.rs | 139 +++++++++++++++++++++++++++++++++- 4 files changed, 249 insertions(+), 37 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a98993b..1841fe5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,9 +133,23 @@ jobs: shell: bash run: tar -C target/${{ matrix.target }}/release -czf ${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }} + - name: Generate SHA-256 checksum + shell: bash + env: + ASSET_NAME: ${{ matrix.asset_name }} + run: | + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${ASSET_NAME}.tar.gz" > "${ASSET_NAME}.tar.gz.sha256" + else + shasum -a 256 "${ASSET_NAME}.tar.gz" > "${ASSET_NAME}.tar.gz.sha256" + fi + cat "${ASSET_NAME}.tar.gz.sha256" + - name: Upload to Release uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 with: tag_name: v${{ needs.release.outputs.new_release_version }} - files: ${{ matrix.asset_name }}.tar.gz + files: | + ${{ matrix.asset_name }}.tar.gz + ${{ matrix.asset_name }}.tar.gz.sha256 token: ${{ steps.generate_token.outputs.token }} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ee0b0fb..c5934c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -315,6 +324,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -324,6 +342,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "dialoguer" version = "0.12.0" @@ -342,6 +370,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -563,6 +601,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -605,9 +653,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -907,16 +955,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1014,9 +1052,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -1045,7 +1083,7 @@ dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.7.4", + "redox_syscall 0.7.5", ] [[package]] @@ -1203,6 +1241,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2", "tar", "tempfile", "tokio", @@ -1419,9 +1458,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags", ] @@ -1728,6 +1767,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -1941,9 +1991,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" dependencies = [ "bytes", "libc", @@ -2007,20 +2057,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -2060,6 +2110,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2114,6 +2170,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -2168,9 +2230,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -2181,9 +2243,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.70" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -2191,9 +2253,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2201,9 +2263,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -2214,9 +2276,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -2270,9 +2332,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 3b15c83..1cafbb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ log = "0.4.29" reqwest = { version = "0.13.3", features = ["stream", "rustls", "json"] } semver = "1.0.28" serde = { version = "1.0.228", features = ["derive"] } +sha2 = "0.10.9" serde_json = "1.0.149" tar = "0.4.45" tempfile = "3.27.0" diff --git a/src/commands/self_update.rs b/src/commands/self_update.rs index fd236da..43822bc 100644 --- a/src/commands/self_update.rs +++ b/src/commands/self_update.rs @@ -3,7 +3,9 @@ use anyhow::{Context, Result}; use clap::Parser; use colored::Colorize; use serde::Deserialize; -use std::io::Write; +use std::fmt::Write as _; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; const GITHUB_REPO: &str = "WebProject-xyz/php-version-manager"; @@ -45,6 +47,60 @@ fn parse_remote_version(tag: &str) -> Result { .with_context(|| format!("Failed to parse remote release tag '{}'", tag)) } +fn parse_sha256_digest(raw: &str) -> Result { + // Accept either bare hex or `sha256sum` output (" "); take first token. + let token = raw + .split_whitespace() + .next() + .context("Checksum file is empty")?; + if token.len() != 64 || !token.chars().all(|c| c.is_ascii_hexdigit()) { + anyhow::bail!("Checksum file does not contain a 64-character hex SHA-256 digest"); + } + Ok(token.to_ascii_lowercase()) +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{:02x}", b); + } + s +} + +fn sha256_of_file(file: &mut File) -> Result { + use sha2::{Digest, Sha256}; + file.seek(SeekFrom::Start(0)) + .context("Failed to rewind archive for hashing")?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 8192]; + loop { + let n = file + .read(&mut buf) + .context("Failed to read archive while hashing")?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + file.seek(SeekFrom::Start(0)) + .context("Failed to rewind archive after hashing")?; + Ok(hex_encode(&hasher.finalize())) +} + +async fn fetch_checksum(url: &str) -> Result { + let body = network::http_client()? + .get(url) + .send() + .await + .context("Failed to download release checksum")? + .error_for_status() + .context("Server returned an error when downloading release checksum")? + .text() + .await + .context("Failed to read release checksum body")?; + parse_sha256_digest(&body) +} + async fn fetch_latest_release() -> Result { let url = format!( "https://api.github.com/repos/{}/releases/latest", @@ -89,9 +145,24 @@ async fn download_and_replace(tag: &str) -> Result<()> { .context("Server returned an error when downloading release")?; let pb = network::build_download_progress_bar(response.content_length())?; - let tmp = network::stream_to_tempfile(response, &pb).await?; + let mut tmp = network::stream_to_tempfile(response, &pb).await?; pb.finish_and_clear(); + let checksum_url = format!("{}.sha256", url); + println!("{} Verifying integrity ({})...", "↻".blue(), checksum_url); + let expected = fetch_checksum(&checksum_url) + .await + .context("Failed to fetch SHA-256 checksum for release archive")?; + let actual = sha256_of_file(&mut tmp)?; + if actual != expected { + anyhow::bail!( + "Integrity check failed for downloaded release: expected SHA-256 {}, got {}", + expected, + actual + ); + } + println!("{} Checksum OK", "✓".green()); + // Stage the new binary in the same directory as the current exe so the rename is atomic // (cross-filesystem renames would fail otherwise). let mut staged = tempfile::Builder::new() @@ -248,4 +319,68 @@ mod tests { fn parse_remote_version_rejects_garbage() { assert!(parse_remote_version("not-a-version").is_err()); } + + #[test] + fn parse_sha256_digest_accepts_bare_hex() { + let raw = "a".repeat(64); + assert_eq!(parse_sha256_digest(&raw).unwrap(), raw); + } + + #[test] + fn parse_sha256_digest_accepts_sha256sum_format() { + let hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let raw = format!("{} pvm-linux-x86_64.tar.gz\n", hex); + assert_eq!(parse_sha256_digest(&raw).unwrap(), hex); + } + + #[test] + fn parse_sha256_digest_lowercases_input() { + let upper = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"; + let lower = upper.to_ascii_lowercase(); + assert_eq!(parse_sha256_digest(upper).unwrap(), lower); + } + + #[test] + fn parse_sha256_digest_rejects_short() { + assert!(parse_sha256_digest("deadbeef").is_err()); + } + + #[test] + fn parse_sha256_digest_rejects_non_hex() { + let raw = "z".repeat(64); + assert!(parse_sha256_digest(&raw).is_err()); + } + + #[test] + fn parse_sha256_digest_rejects_empty() { + assert!(parse_sha256_digest("").is_err()); + assert!(parse_sha256_digest(" \n").is_err()); + } + + #[test] + fn sha256_of_file_matches_known_vector() { + // SHA-256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad + let mut tmp = tempfile::tempfile().unwrap(); + tmp.write_all(b"abc").unwrap(); + tmp.flush().unwrap(); + let digest = sha256_of_file(&mut tmp).unwrap(); + assert_eq!( + digest, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + // Hashing must rewind so the next reader sees the full content. + let mut s = String::new(); + tmp.read_to_string(&mut s).unwrap(); + assert_eq!(s, "abc"); + } + + #[test] + fn sha256_of_empty_file() { + let mut tmp = tempfile::tempfile().unwrap(); + let digest = sha256_of_file(&mut tmp).unwrap(); + assert_eq!( + digest, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } }