diff --git a/Cargo.lock b/Cargo.lock index 6774572..052b7fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler" @@ -558,12 +558,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.17" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "md5" diff --git a/src/commands/install.rs b/src/commands/install.rs index 83099b6..8a68a80 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -4,7 +4,7 @@ mod progress_reader; use super::{Command, Config}; use crate::curl; use crate::decorized::Decorized; -use crate::release::{self, Hash}; +use crate::releases::{self, hash::Hash, Installable}; use crate::version::{self, Version}; use colored::Colorize; use flate2::read::GzDecoder; @@ -51,13 +51,13 @@ pub enum Error { UnsupportedPHP3, #[error(transparent)] - FailedFetchRelease(#[from] release::FetchError), + FailedFetchRelease(#[from] releases::FetchError), #[error(transparent)] FailedDownload(#[from] curl::Error), #[error(transparent)] - InvalidChecksum(#[from] release::ChecksumError), + InvalidChecksum(#[from] releases::ChecksumError), #[error(transparent)] FailedMake(#[from] make::Error), @@ -78,8 +78,8 @@ impl Command for Install { return Err(Error::UnsupportedPHP3); } - let release = release::fetch_latest(request_version)?; - let install_version = release.version.unwrap(); + let installable = Installable::fetch(request_version)?; + let install_version = installable.version(); if version::latest_installed_by(&request_version, config) == Some(install_version) { println!( @@ -100,7 +100,7 @@ impl Command for Install { .prefix(".downloads-") .tempdir_in(&config.base_dir())?; - let (url, checksum) = release.source_url(); + let (url, checksum) = installable.source_url(); let tar_gz = download(&url, &download_dir)?; verify(&tar_gz, checksum)?; @@ -140,7 +140,8 @@ impl Install { fn download(url: &str, dir: impl AsRef) -> Result { let curl::Header { content_length } = curl::get_header(url)?; - let progress_bar = ProgressBar::new(content_length.unwrap() as u64) + // Sometimes we don't get a content length header + let progress_bar = ProgressBar::new(content_length.unwrap_or(0) as u64) .with_style(PROGRESS_STYLE.clone()) .with_prefix("Downloading") .with_message(url.to_owned()); diff --git a/src/commands/list_remote.rs b/src/commands/list_remote.rs index f262f32..60409b1 100644 --- a/src/commands/list_remote.rs +++ b/src/commands/list_remote.rs @@ -1,10 +1,11 @@ use super::{Command, Config}; -use crate::release; +use crate::releases; use crate::version; use crate::version::Local; use crate::version::Version; use colored::Colorize; use itertools::Itertools; +use std::collections::BTreeSet; use thiserror::Error; #[derive(clap::Parser, Debug)] @@ -21,7 +22,7 @@ pub struct ListRemote { #[derive(Error, Debug)] pub enum Error { #[error(transparent)] - FailedFetchRelease(#[from] release::FetchError), + FailedFetchRelease(#[from] releases::FetchError), } impl Command for ListRemote { @@ -52,18 +53,35 @@ impl Command for ListRemote { let installed_versions = version::installed(config).collect_vec(); let current_version = Local::current(config); + let pre_releases = releases::pre_release::fetch_all()?; for query_version in query_versions { - let releases = release::fetch_all(query_version)?; - let remote_versions = releases.keys(); - - let remote_versions = if self.only_latest_patch { - filter_latest_patch(remote_versions).collect_vec() + let remote_versions = if query_version.pre_type().is_some() { + match pre_releases.get(&query_version) { + Some(v) => Ok(vec![v.version]), + None => Err(releases::FetchError::NotFoundRelease(query_version)), + } } else { - remote_versions.collect_vec() - }; + let releases = releases::release::fetch_all(query_version); + let pre_release_keys = pre_releases + .get_versions_included_by(&query_version) + .sorted(); + match releases { + Ok(r) => { + let mut keys: BTreeSet = r.keys().copied().collect(); + keys.extend(pre_release_keys); + if self.only_latest_patch { + Ok(filter_latest_patch(keys.iter()).copied().collect_vec()) + } else { + Ok(keys.iter().copied().collect_vec()) + } + } + Err(_) if pre_release_keys.len() > 0 => Ok(pre_release_keys.copied().collect()), + Err(e) => Err(e), + } + }?; - for &remote_version in remote_versions { + for remote_version in remote_versions { let installed = installed_versions.contains(&remote_version); let remote_version = Local::Installed(remote_version); let used = Some(&remote_version) == current_version.as_ref(); diff --git a/src/lib.rs b/src/lib.rs index 062d67b..be7a8e0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,7 @@ pub mod commands; pub mod config; pub mod curl; pub mod decorized; -pub mod release; +pub mod releases; pub mod shell; pub mod symlink; pub mod version; diff --git a/src/releases.rs b/src/releases.rs new file mode 100644 index 0000000..05e8948 --- /dev/null +++ b/src/releases.rs @@ -0,0 +1,63 @@ +use self::{hash::Hash, pre_release::PreRelease, release::Release}; +use crate::curl; +use crate::version::Version; +use thiserror::Error; + +pub mod hash; +pub mod pre_release; +pub mod release; + +pub enum Installable { + Stable(Release), + Pre(PreRelease), +} + +impl Installable { + pub fn fetch(version: Version) -> Result { + match version.pre_type() { + Some(_) => { + let pre_release = pre_release::fetch(version)?; + Ok(Self::Pre(pre_release)) + } + None => { + let release = release::fetch_latest(version)?; + Ok(Self::Stable(release)) + } + } + } + + pub fn version(&self) -> Version { + match self { + Self::Stable(release) => release.version.unwrap(), + Self::Pre(pre_release) => pre_release.version, + } + } + + pub fn source_url(&self) -> (String, Option<&Hash>) { + match self { + Self::Stable(release) => release.source_url(), + Self::Pre(pre_release) => pre_release.source_url(), + } + } +} + +#[derive(Error, Debug)] +pub enum FetchError { + #[error("Can't find releases that matches {0}")] + NotFoundRelease(Version), + + #[error(transparent)] + CurlError(#[from] curl::Error), + + #[error("Receive error message from release site: {0}")] + Other(String), +} + +#[derive(Error, Debug)] +pub enum ChecksumError { + #[error("Invalid checksum\nexptected: {expected}\ngot: {got}")] + InvalidChecksum { expected: String, got: String }, + + #[error(transparent)] + Io(#[from] std::io::Error), +} diff --git a/src/releases/hash.rs b/src/releases/hash.rs new file mode 100644 index 0000000..00c8dd2 --- /dev/null +++ b/src/releases/hash.rs @@ -0,0 +1,43 @@ +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all(deserialize = "lowercase", serialize = "lowercase"))] +pub enum Hash { + SHA256(String), + MD5(String), +} + +use crate::releases::ChecksumError; +use serde::{Deserialize, Serialize}; +use sha2::Digest; + +impl Hash { + pub fn hash_type(&self) -> &'static str { + match self { + Hash::SHA256(_) => "SHA-256", + Hash::MD5(_) => "MD5", + } + } + pub fn verify(&self, mut data: impl std::io::Read) -> Result<(), ChecksumError> { + let (checksum, hash) = match self { + Hash::SHA256(checksum) => { + let mut sha256 = sha2::Sha256::new(); + std::io::copy(&mut data, &mut sha256)?; + let hash = sha256.finalize(); + (checksum, format!("{:x}", hash)) + } + Hash::MD5(checksum) => { + let mut md5 = md5::Context::new(); + std::io::copy(&mut data, &mut md5)?; + let hash = md5.compute(); + (checksum, format!("{:x}", hash)) + } + }; + if checksum == &hash { + Ok(()) + } else { + Err(ChecksumError::InvalidChecksum { + expected: checksum.clone(), + got: hash, + }) + } + } +} diff --git a/src/releases/pre_release.rs b/src/releases/pre_release.rs new file mode 100644 index 0000000..1862473 --- /dev/null +++ b/src/releases/pre_release.rs @@ -0,0 +1,167 @@ +use super::{FetchError, Hash}; +use crate::curl; +use crate::version::Version; +use serde::{de, Deserialize}; +use std::collections::HashMap; + +#[derive(Deserialize, Debug)] +pub struct PreRelease { + pub version: Version, + baseurl: String, + #[serde(deserialize_with = "hash_de", alias = "sha256_gz")] + checksum: Hash, +} + +fn hash_de<'de, D>(deserializer: D) -> Result +where + D: de::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + Ok(Hash::SHA256(s)) +} + +#[derive(Deserialize, Debug)] +pub struct PreReleaseMap(HashMap); + +impl PreReleaseMap { + pub fn get_versions_included_by<'a>(&'a self, version: &'a Version) -> impl Iterator { + self.0.values().filter_map(|v| { + if version.includes(&v.version) { + Some(&v.version) + } else { + None + } + }) + } + + pub fn get(&self, version: &Version) -> Option<&PreRelease> { + let key = Self::build_key_from_pre_release_version(version); + + match self.0.get(&key) { + Some(pr) if &pr.version == version => Some(pr), + _ => None + } + } + + pub fn remove(&mut self, version: &Version) -> Option { + let key = Self::build_key_from_pre_release_version(&version); + + match self.0.get(&key) { + Some(pr) if &pr.version == version => self.0.remove(&key), + _ => None + } + } + + fn build_key_from_pre_release_version(version: &Version) -> Version { + assert!( + version.pre_type().is_some(), + "Version {} is not pre-release", + version + ); + + Version::from_numbers( + version.major_version(), + version.minor_version(), + version.patch_version(), + None, + ) + } +} + +#[derive(Deserialize, Debug)] +struct Response { + releases: PreReleaseMap, +} + +pub fn fetch_all() -> Result { + let url = "https://www.php.net/release-candidates.php?format=json"; + let json = curl::get_as_slice(url)?; + + let resp: Response = + serde_json::from_slice(&json).unwrap_or_else(|_| panic!("Can't parse json from {}", url)); + + Ok(resp.releases) +} + +pub fn fetch(version: Version) -> Result { + let mut releases = fetch_all()?; + + releases.remove(&version).ok_or(FetchError::NotFoundRelease(version)) +} + +impl PreRelease { + pub fn source_url(&self) -> (String, Option<&Hash>) { + ( + format!("{}php-{}.tar.gz", self.baseurl, self.version), + Some(&self.checksum), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::version::semantic::PreType; + + #[test] + fn deserialize() { + let json = r#"{ + "reported": [ + "8.1.27-dev", + "8.2.27-dev", + "8.3.26-dev", + "8.4.13-dev", + "8.5.0-dev", + "8.5.0RC1" + ], + "releases": { + "8.5.0": { + "type": "RC", + "number": 1, + "sha256_bz2": "8365ae9263cc160e6182302f0bdcc80edf1806029556e6870beb3078a625389c", + "sha256_gz": "0ea5059a387117fe6ed9a72cdc20945dbff6acc072df936e97d35a9cb26420e0", + "sha256_xz": "96f064b5d604e00e5fe1c993d4881da659a99d6d1d8ac0b1df8fec6406e34a9d", + "date": "25 Sep 2025", + "baseurl": "https://downloads.php.net/~daniels/", + "version": "8.5.0RC1", + "files": { + "bz2": { + "sha256": "8365ae9263cc160e6182302f0bdcc80edf1806029556e6870beb3078a625389c", + "path": "https://downloads.php.net/~daniels/php-8.5.0RC1.tar.bz2" + }, + "gz": { + "sha256": "0ea5059a387117fe6ed9a72cdc20945dbff6acc072df936e97d35a9cb26420e0", + "path": "https://downloads.php.net/~daniels/php-8.5.0RC1.tar.gz" + }, + "xz": { + "sha256": "96f064b5d604e00e5fe1c993d4881da659a99d6d1d8ac0b1df8fec6406e34a9d", + "path": "https://downloads.php.net/~daniels/php-8.5.0RC1.tar.xz" + } + } + } + } + }"#; + + let resp: Result = serde_json::from_str(json); + assert!(resp.is_ok()); + + let resp = resp.unwrap(); + let key = "8.5.0RC1".parse().unwrap(); + + let pre_release = resp.releases.get(&key).unwrap(); + assert_eq!( + pre_release.version, + Version::from_numbers(8, Some(5), Some(0), Some((PreType::Rc, 1))) + ); + assert_eq!( + pre_release.source_url().0, + "https://downloads.php.net/~daniels/php-8.5.0RC1.tar.gz" + ); + + assert!(match pre_release.source_url().1 { + Some(Hash::SHA256(val)) => + val == "0ea5059a387117fe6ed9a72cdc20945dbff6acc072df936e97d35a9cb26420e0", + _ => false, + }) + } +} diff --git a/src/release.rs b/src/releases/release.rs similarity index 83% rename from src/release.rs rename to src/releases/release.rs index c5a5e57..e56143e 100644 --- a/src/release.rs +++ b/src/releases/release.rs @@ -1,3 +1,4 @@ +use super::{hash::Hash, FetchError}; use crate::curl; use crate::version::Version; use chrono::{Datelike, NaiveDate, Utc}; @@ -5,19 +6,6 @@ use derive_more::Display; use serde::{de, Deserialize, Serialize}; use serde_with::serde_as; use std::collections::BTreeMap; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum FetchError { - #[error("Can't find releases that matches {0}")] - NotFoundRelease(Version), - - #[error(transparent)] - CurlError(#[from] curl::Error), - - #[error("Receive error message from release site: {0}")] - Other(String), -} fn fetch_and_parse( version: Option, @@ -60,8 +48,12 @@ pub fn fetch_latest(version: Version) -> Result { } pub fn fetch_oldest_patch(version: Version) -> Result { - let oldest_minor_version = - Version::from_numbers(version.major_version(), version.minor_version(), Some(0)); + let oldest_minor_version = Version::from_numbers( + version.major_version(), + version.minor_version(), + Some(0), + None, + ); fetch_latest(oldest_minor_version) } @@ -131,55 +123,6 @@ pub struct File { // TODO: Option date: Option, } -#[derive(Serialize, Deserialize, Debug)] -#[serde(rename_all(deserialize = "lowercase", serialize = "lowercase"))] -pub enum Hash { - SHA256(String), - MD5(String), -} - -#[derive(Error, Debug)] -pub enum ChecksumError { - #[error("Invalid checksum\nexptected: {expected}\ngot: {got}")] - InvalidChecksum { expected: String, got: String }, - - #[error(transparent)] - Io(#[from] std::io::Error), -} - -use sha2::Digest; -impl Hash { - pub fn hash_type(&self) -> &'static str { - match self { - Hash::SHA256(_) => "SHA-256", - Hash::MD5(_) => "MD5", - } - } - pub fn verify(&self, mut data: impl std::io::Read) -> Result<(), ChecksumError> { - let (checksum, hash) = match self { - Hash::SHA256(checksum) => { - let mut sha256 = sha2::Sha256::new(); - std::io::copy(&mut data, &mut sha256)?; - let hash = sha256.finalize(); - (checksum, format!("{:x}", hash)) - } - Hash::MD5(checksum) => { - let mut md5 = md5::Context::new(); - std::io::copy(&mut data, &mut md5)?; - let hash = md5.compute(); - (checksum, format!("{:x}", hash)) - } - }; - if checksum == &hash { - Ok(()) - } else { - Err(ChecksumError::InvalidChecksum { - expected: checksum.clone(), - got: hash, - }) - } - } -} #[derive(Debug, Clone, Copy, Display, PartialEq, Eq)] pub enum Support { diff --git a/src/version/semantic.rs b/src/version/semantic.rs index 659087d..fe98769 100644 --- a/src/version/semantic.rs +++ b/src/version/semantic.rs @@ -62,12 +62,20 @@ pub enum PreType { } impl Version { - pub fn from_numbers(major: usize, minor: Option, patch: Option) -> Self { + pub fn from_numbers( + major: usize, + minor: Option, + patch: Option, + pre: Option<(PreType, usize)>, + ) -> Self { Self { version: major, minor: minor.map(|version| Minor { version, - patch: patch.map(|version| Patch { version, pre: None }), + patch: patch.map(|version| Patch { + version, + pre: pre.map(|(pre_type, version)| Pre { version, pre_type }), + }), }), } } @@ -141,6 +149,7 @@ impl FromStr for Version { 3, Some(0), Some(if cfg!(target_os = "windows") { 17 } else { 18 }), + None, )); } let cap = VERSION_REGEX @@ -231,7 +240,17 @@ mod tests { assert!(matches!(version3_1_4, Ok(_))); assert_eq!( version3_1_4.unwrap(), - Version::from_numbers(3, Some(1), Some(4)) + Version::from_numbers(3, Some(1), Some(4), None) + ); + } + + #[test] + fn parsed_from_str_pre_release() { + let version: Result = "8.5.0RC1".parse(); + assert!(matches!(version, Ok(_))); + assert_eq!( + version.unwrap(), + Version::from_numbers(8, Some(5), Some(0), Some((PreType::Rc, 1))) ); } @@ -244,7 +263,7 @@ mod tests { println!("{:?}", parsed); assert!(parsed.is_ok()); - let version3_1_4 = Version::from_numbers(3, Some(1), Some(4)); + let version3_1_4 = Version::from_numbers(3, Some(1), Some(4), None); assert_eq!( parsed.unwrap().get(&version3_1_4), Some(&vec!["abc", "cdf"]) @@ -253,9 +272,9 @@ mod tests { #[test] fn includes_test() { - let version3_1_4 = Version::from_numbers(3, Some(1), Some(4)); - let version3_1 = Version::from_numbers(3, Some(1), None); - let version3 = Version::from_numbers(3, None, None); + let version3_1_4 = Version::from_numbers(3, Some(1), Some(4), None); + let version3_1 = Version::from_numbers(3, Some(1), None, None); + let version3 = Version::from_numbers(3, None, None, None); assert!(version3.includes(&version3)); assert!(version3.includes(&version3_1));