diff --git a/crates/wright-cli/Cargo.toml b/crates/wright-cli/Cargo.toml index 585dc9ea..1edaaf60 100644 --- a/crates/wright-cli/Cargo.toml +++ b/crates/wright-cli/Cargo.toml @@ -14,6 +14,10 @@ workspace = true name = "wright" path = "src/main.rs" +[[test]] +name = "update_provider" +path = "tests/update/provider.rs" + [dependencies] arboard = { version = "3.6", default-features = false } clap = { version = "4.5", features = ["derive"] } diff --git a/crates/wright-cli/src/update.rs b/crates/wright-cli/src/update.rs index 5009a6e3..bd048d05 100644 --- a/crates/wright-cli/src/update.rs +++ b/crates/wright-cli/src/update.rs @@ -1,14 +1,13 @@ use std::cmp::Ordering; -use std::io::Read; use std::path::Path; -use std::time::Duration; use clap::{Args, Subcommand, ValueEnum}; use wright_driver::result::exit; -use wright_driver::{OpyProviderConfig, OpyProviderError, ResolvedOpyProvider, sha256_hex}; +use wright_driver::{OpyProviderConfig, OpyProviderError, ResolvedOpyProvider}; const DEFAULT_BASE_URL: &str = "https://releases.wrightkit.dev/wright"; const USER_AGENT: &str = concat!("wright-update/", env!("CARGO_PKG_VERSION")); +const MAX_DOWNLOAD_BYTES: u64 = 128 * 1024 * 1024; mod install; @@ -501,15 +500,11 @@ fn parse_latest_version(body: &str) -> Option { } fn update_client() -> Result { - reqwest::blocking::Client::builder() - .user_agent(USER_AGENT) - .timeout(Duration::from_secs(60)) - .build() - .map_err(|e| { - UpdateError::failed(format!( - "could not initialize HTTPS client for release downloads: {e}" - )) - }) + wright_driver::store::client(USER_AGENT).map_err(|e| { + UpdateError::failed(format!( + "could not initialize HTTPS client for release downloads: {e}" + )) + }) } fn fetch_text(client: &reqwest::blocking::Client, url: &str) -> Result { @@ -526,12 +521,14 @@ fn fetch(client: &reqwest::blocking::Client, url: &str) -> Result, Updat response.status() ))); } - let mut bytes = Vec::new(); - response - .take(128 * 1024 * 1024) - .read_to_end(&mut bytes) - .map_err(|e| UpdateError::failed(format!("could not download {url}: {e}")))?; - Ok(bytes) + wright_driver::store::read_limited(response, MAX_DOWNLOAD_BYTES).map_err(|error| match error { + wright_driver::store::ReadError::Io(error) => { + UpdateError::failed(format!("could not download {url}: {error}")) + } + wright_driver::store::ReadError::OverLimit => UpdateError::failed(format!( + "download from {url} exceeds the 128 MiB size limit" + )), + }) } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/wright-cli/src/update/install.rs b/crates/wright-cli/src/update/install.rs index 004324e5..5a4cc653 100644 --- a/crates/wright-cli/src/update/install.rs +++ b/crates/wright-cli/src/update/install.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use std::process::Command as Process; -use super::{Platform, UpdateError, fetch, fetch_text, sha256_hex, truncate}; +use super::{Platform, UpdateError, fetch, fetch_text, truncate}; pub(super) fn install_version( client: &reqwest::blocking::Client, @@ -59,21 +59,8 @@ pub(super) fn install_version( } fn staging_dir(install_dir: &Path) -> Result { - let staging = install_dir.join(format!( - ".wright-update-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or_default() - )); - std::fs::create_dir_all(&staging).map_err(|e| { - UpdateError::failed(format!( - "could not create a staging directory in {}: {e}", - install_dir.display() - )) - })?; - Ok(staging) + wright_driver::store::reserve_staging_dir(install_dir, ".wright-update", "an update") + .map_err(UpdateError::failed) } fn unpack(archive: &[u8], dest: &Path, platform: Platform) -> Result<(), UpdateError> { @@ -112,23 +99,20 @@ pub(super) fn verify_checksum( checksum_file: &str, archive_name: &str, ) -> Result<(), UpdateError> { - let published = checksum_file - .split_whitespace() - .next() - .ok_or_else(|| UpdateError::failed(format!("empty checksum file for {archive_name}")))?; - if published.len() != 64 || !published.bytes().all(|b| b.is_ascii_hexdigit()) { - return Err(UpdateError::failed(format!( + wright_driver::store::verify_sha256(archive, checksum_file).map_err(|error| match error { + wright_driver::store::ChecksumError::Empty => { + UpdateError::failed(format!("empty checksum file for {archive_name}")) + } + wright_driver::store::ChecksumError::Invalid(digest) => UpdateError::failed(format!( "invalid checksum file for {archive_name}: expected one SHA-256 hex digest (got '{}')", - truncate(published) - ))); - } - let actual = sha256_hex(archive); - if !actual.eq_ignore_ascii_case(published) { - return Err(UpdateError::failed(format!( - "checksum verification failed for {archive_name} (published {published}, got {actual}); the download may be corrupted or tampered with — nothing was changed" - ))); - } - Ok(()) + truncate(&digest) + )), + wright_driver::store::ChecksumError::Mismatch(published, actual) => { + UpdateError::failed(format!( + "checksum verification failed for {archive_name} (published {published}, got {actual}); the download may be corrupted or tampered with — nothing was changed" + )) + } + }) } fn ensure_writable(install_dir: &Path, platform: Platform) -> Result<(), UpdateError> { @@ -223,10 +207,10 @@ mod tests { fn checksum_verification_accepts_good_and_rejects_bad() { let bytes = b"the archive bytes"; let name = "wright-9.9.9-x86_64-unknown-linux-gnu.tar.gz"; - let good = format!("{} {name}\n", sha256_hex(bytes)); + let good = format!("{} {name}\n", wright_driver::sha256_hex(bytes)); verify_checksum(bytes, &good, name).unwrap(); - let bad = format!("{} archive\n", sha256_hex(b"other bytes")); + let bad = format!("{} archive\n", wright_driver::sha256_hex(b"other bytes")); let error = verify_checksum(bytes, &bad, "archive").unwrap_err(); assert!(error.message().contains("checksum verification failed")); diff --git a/crates/wright-cli/tests/update/main.rs b/crates/wright-cli/tests/update/main.rs index 04e6a0d1..6984e8f4 100644 --- a/crates/wright-cli/tests/update/main.rs +++ b/crates/wright-cli/tests/update/main.rs @@ -1,178 +1,10 @@ use std::collections::HashMap; use std::io::Write; -use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -mod server; +mod support; -use server::MockServer; - -const RELEASE: &str = "9.9.9"; -const TRIPLE: &str = "x86_64-unknown-linux-gnu"; -const PROVIDER_OLD: &str = "1.0.0"; -const PROVIDER_RELEASE: &str = "1.4.0"; - -struct Release { - files: HashMap>, -} - -fn release(version: &str) -> Release { - let archive = build_archive(version); - let name = format!("wright-{version}-{TRIPLE}.tar.gz"); - let mut files = HashMap::new(); - files.insert( - "/latest/version".to_string(), - format!("{version}\n").into_bytes(), - ); - files.insert(format!("/releases/{version}/{name}"), archive.clone()); - files.insert( - format!("/releases/{version}/{name}.sha256"), - format!("{} {name}\n", wright_driver::sha256_hex(&archive)).into_bytes(), - ); - Release { files } -} - -fn build_archive(version: &str) -> Vec { - let dir = format!("wright-{version}-{TRIPLE}"); - let mut builder = tar::Builder::new(Vec::new()); - for (name, content) in [ - ( - "wright", - format!("#!/bin/sh\necho \"fake wright {version}\"\n").into_bytes(), - ), - ( - "wright-lsp", - format!("#!/bin/sh\necho \"fake wright-lsp {version}\"\n").into_bytes(), - ), - ] { - let path = format!("{dir}/{name}"); - let mut header = tar::Header::new_gnu(); - header.set_size(content.len() as u64); - header.set_mode(0o755); - header.set_cksum(); - builder - .append_data(&mut header, path, &content[..]) - .unwrap(); - } - let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); - std::io::Write::write_all(&mut encoder, &builder.into_inner().unwrap()).unwrap(); - encoder.finish().unwrap() -} - -fn provider_target() -> Option<&'static str> { - match (std::env::consts::OS, std::env::consts::ARCH) { - ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"), - ("macos", "x86_64") => Some("x86_64-apple-darwin"), - ("macos", "aarch64") => Some("aarch64-apple-darwin"), - ("windows", "x86_64") => Some("x86_64-pc-windows-msvc"), - _ => None, - } -} - -fn provider_binary() -> &'static str { - if provider_target() == Some("x86_64-pc-windows-msvc") { - "opy-provider.exe" - } else { - "opy-provider" - } -} - -fn provider_store(dir: &Path) -> PathBuf { - dir.join("provider-data").join("providers").join("opy") -} - -fn seed_provider(dir: &Path, version: &str) { - let target = provider_target().expect("provider tests need a supported host"); - let store = provider_store(dir); - let executable = store.join(version).join(target).join(provider_binary()); - std::fs::create_dir_all(executable.parent().unwrap()).unwrap(); - std::fs::write(&executable, provider_body(version)).unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); - } - std::fs::write(store.join("active"), format!("{version}\n")).unwrap(); -} - -fn provider_body(version: &str) -> Vec { - format!("#!/bin/sh\necho \"fake opy-provider {version}\"\n").into_bytes() -} - -fn provider_release(version: &str, files: &mut HashMap>) { - let target = provider_target().expect("provider tests need a supported host"); - let binary = provider_binary(); - let name = format!("opy-provider-{version}-{target}.tar.gz"); - let mut builder = tar::Builder::new(Vec::new()); - let body = provider_body(version); - let mut header = tar::Header::new_gnu(); - header.set_size(body.len() as u64); - header.set_mode(0o755); - header.set_cksum(); - builder.append_data(&mut header, binary, &body[..]).unwrap(); - let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); - std::io::Write::write_all(&mut encoder, &builder.into_inner().unwrap()).unwrap(); - let archive = encoder.finish().unwrap(); - files.insert( - "/opy-rs/latest/version".to_string(), - format!("{version}\n").into_bytes(), - ); - files.insert( - format!("/opy-rs/releases/{version}/{name}"), - archive.clone(), - ); - files.insert( - format!("/opy-rs/releases/{version}/{name}.sha256"), - format!("{} {name}\n", wright_driver::sha256_hex(&archive)).into_bytes(), - ); -} - -fn provider_env(server: &MockServer) -> Vec<(&'static str, String)> { - let mut env = server.env(); - env.push(( - "WRIGHT_OPY_PROVIDER_BASE_URL", - format!("{}/opy-rs", server.base_url()), - )); - env -} - -fn install_dir(tag: &str) -> PathBuf { - install_dir_under(std::env::temp_dir(), tag) -} - -fn install_dir_under(parent: PathBuf, tag: &str) -> PathBuf { - let dir = parent.join(format!("wright-update-it-{}-{tag}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let wright = env!("CARGO_BIN_EXE_wright"); - std::fs::copy(wright, dir.join("wright")).unwrap(); - std::fs::copy(wright, dir.join("wright-lsp")).unwrap(); - dir -} - -fn homebrew_install_dir(tag: &str) -> PathBuf { - install_dir_under(std::env::temp_dir().join("homebrew"), tag) -} - -fn run_update(dir: &Path, args: &[&str], env: &[(&'static str, String)]) -> std::process::Output { - let mut command = Command::new(dir.join("wright")); - command - .args(args) - .env("WRIGHT_INSTALL_OS", "linux") - .env("WRIGHT_INSTALL_ARCH", "x86_64") - .env("WRIGHT_PROVIDER_DATA_DIR", dir.join("provider-data")); - for (key, value) in env { - command.env(key, value); - } - command - .stdin(Stdio::null()) - .output() - .expect("wright update runs") -} - -fn read(path: &Path) -> Vec { - std::fs::read(path).unwrap() -} +use support::*; #[test] fn check_reports_an_available_update_without_modifying_files() { @@ -398,6 +230,36 @@ fn update_already_at_latest_reports_up_to_date() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn an_archive_over_the_size_limit_fails_with_a_limit_error() { + let name = format!("wright-{RELEASE}-{TRIPLE}.tar.gz"); + let mut files = HashMap::new(); + files.insert( + "/latest/version".to_string(), + format!("{RELEASE}\n").into_bytes(), + ); + files.insert( + format!("/releases/{RELEASE}/{name}"), + vec![0u8; 128 * 1024 * 1024 + 1], + ); + let server = MockServer::new(files); + let dir = install_dir("update-overlimit"); + let before = read(&dir.join("wright")); + let output = run_update(&dir, &["update"], &server.env()); + assert_eq!(output.status.code(), Some(4)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("exceeds") && stderr.contains("size limit"), + "an over-limit download reports the size limit, not a checksum failure: {stderr}" + ); + assert!( + !stderr.contains("checksum"), + "the failure is not reported as a checksum mismatch: {stderr}" + ); + assert_eq!(read(&dir.join("wright")), before); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn checksum_mismatch_is_rejected_before_any_change() { let mut mock = release(RELEASE); @@ -492,188 +354,6 @@ fn downgrade_is_refused() { let _ = std::fs::remove_dir_all(&dir); } -#[test] -fn update_installs_a_named_provider_into_an_empty_store() { - if provider_target().is_none() { - return; - } - let dir = install_dir("provider-install"); - let mut files = HashMap::new(); - provider_release(PROVIDER_RELEASE, &mut files); - let server = MockServer::new(files); - let output = run_update(&dir, &["update", "provider", "opy"], &provider_env(&server)); - assert_eq!( - output.status.code(), - Some(0), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains(&format!("installed opy provider {PROVIDER_RELEASE}")), - "{stdout}" - ); - let store = provider_store(&dir); - assert_eq!( - std::fs::read_to_string(store.join("active")) - .unwrap() - .trim(), - PROVIDER_RELEASE - ); - let executable = store - .join(PROVIDER_RELEASE) - .join(provider_target().unwrap()) - .join(provider_binary()); - assert_eq!(read(&executable), provider_body(PROVIDER_RELEASE)); - assert!(!stdout.contains("installing wright"), "{stdout}"); - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn update_upgrades_the_installed_provider_alongside_self() { - if provider_target().is_none() { - return; - } - let dir = install_dir("provider-upgrade"); - seed_provider(&dir, PROVIDER_OLD); - let mut files = release(RELEASE).files; - provider_release(PROVIDER_RELEASE, &mut files); - let server = MockServer::new(files); - let output = run_update(&dir, &["update"], &provider_env(&server)); - assert_eq!( - output.status.code(), - Some(0), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains(&format!( - "updated opy provider {PROVIDER_OLD} -> {PROVIDER_RELEASE}" - )), - "{stdout}" - ); - assert_eq!( - std::fs::read_to_string(provider_store(&dir).join("active")) - .unwrap() - .trim(), - PROVIDER_RELEASE - ); - assert!( - String::from_utf8_lossy(&read(&dir.join("wright"))) - .contains(&format!("fake wright {RELEASE}")), - "the self target still updated" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn update_does_not_install_an_absent_provider() { - let dir = install_dir("provider-absent"); - let server = MockServer::new(release(RELEASE).files); - let output = run_update(&dir, &["update"], &server.env()); - assert_eq!(output.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("opy provider is not installed; skipping"), - "{stdout}" - ); - assert!( - !provider_store(&dir).join("active").exists(), - "a bare update must not create a provider installation" - ); - assert!( - String::from_utf8_lossy(&read(&dir.join("wright"))) - .contains(&format!("fake wright {RELEASE}")), - "the self target still updated" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn check_reports_provider_availability_without_modifying_anything() { - if provider_target().is_none() { - return; - } - let dir = install_dir("provider-check"); - seed_provider(&dir, PROVIDER_OLD); - let mut files = release(RELEASE).files; - provider_release(PROVIDER_RELEASE, &mut files); - let server = MockServer::new(files); - let before = read(&dir.join("wright")); - let output = run_update(&dir, &["update", "--check"], &provider_env(&server)); - assert_eq!(output.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains(&format!( - "update available: opy provider {PROVIDER_OLD} -> {PROVIDER_RELEASE}" - )), - "{stdout}" - ); - assert_eq!( - std::fs::read_to_string(provider_store(&dir).join("active")) - .unwrap() - .trim(), - PROVIDER_OLD, - "--check must not touch the active provider" - ); - assert_eq!(read(&dir.join("wright")), before); - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn update_provider_without_a_name_skips_an_empty_store() { - let dir = install_dir("provider-none"); - let output = run_update(&dir, &["update", "provider"], &[]); - assert_eq!(output.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("opy provider is not installed; skipping"), - "{stdout}" - ); - assert!(!provider_store(&dir).join("active").exists()); - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn package_managed_wright_skips_self_update_but_updates_providers() { - if provider_target().is_none() { - return; - } - let dir = homebrew_install_dir("managed"); - seed_provider(&dir, PROVIDER_OLD); - let mut files = HashMap::new(); - provider_release(PROVIDER_RELEASE, &mut files); - let server = MockServer::new(files); - let before = read(&dir.join("wright")); - let output = run_update(&dir, &["update"], &provider_env(&server)); - assert_eq!( - output.status.code(), - Some(0), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("skipped wright self-update") && stdout.contains("brew upgrade"), - "the skip line carries actionable package-manager guidance: {stdout}" - ); - assert!( - stdout.contains(&format!( - "updated opy provider {PROVIDER_OLD} -> {PROVIDER_RELEASE}" - )), - "{stdout}" - ); - assert_eq!(read(&dir.join("wright")), before, "self-update was skipped"); - assert_eq!( - std::fs::read_to_string(provider_store(&dir).join("active")) - .unwrap() - .trim(), - PROVIDER_RELEASE - ); - let _ = std::fs::remove_dir_all(&dir); -} - #[test] fn explicit_self_update_still_refuses_package_managed_installations() { let dir = homebrew_install_dir("self"); @@ -718,66 +398,6 @@ fn update_self_check_reports_availability_without_modifying_files() { let _ = std::fs::remove_dir_all(&dir); } -#[test] -fn update_provider_check_reports_availability_without_installing() { - if provider_target().is_none() { - return; - } - let dir = install_dir("provider-check-flag"); - seed_provider(&dir, PROVIDER_OLD); - let mut files = HashMap::new(); - provider_release(PROVIDER_RELEASE, &mut files); - let server = MockServer::new(files); - let output = run_update( - &dir, - &["update", "provider", "--check"], - &provider_env(&server), - ); - assert_eq!( - output.status.code(), - Some(0), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - String::from_utf8_lossy(&output.stdout).contains(&format!( - "update available: opy provider {PROVIDER_OLD} -> {PROVIDER_RELEASE}" - )), - "{}", - String::from_utf8_lossy(&output.stdout) - ); - assert_eq!( - std::fs::read_to_string(provider_store(&dir).join("active")) - .unwrap() - .trim(), - PROVIDER_OLD, - "--check must not touch the active provider" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn a_failed_provider_update_makes_update_exit_nonzero() { - if provider_target().is_none() { - return; - } - let dir = homebrew_install_dir("provider-fails"); - seed_provider(&dir, PROVIDER_OLD); - let server = MockServer::new(HashMap::new()); - let output = run_update(&dir, &["update"], &provider_env(&server)); - assert_ne!( - output.status.code(), - Some(0), - "a failed provider update must not report success" - ); - assert!( - String::from_utf8_lossy(&output.stderr).contains("opy"), - "stderr names the failing target: {}", - String::from_utf8_lossy(&output.stderr) - ); - let _ = std::fs::remove_dir_all(&dir); -} - #[test] fn a_prerelease_pinned_version_installs() { let server = MockServer::new(release("9.9.9-beta.1").files); diff --git a/crates/wright-cli/tests/update/provider.rs b/crates/wright-cli/tests/update/provider.rs new file mode 100644 index 00000000..1e37f9fd --- /dev/null +++ b/crates/wright-cli/tests/update/provider.rs @@ -0,0 +1,247 @@ +use std::collections::HashMap; + +mod support; + +use support::*; + +#[test] +fn update_installs_a_named_provider_into_an_empty_store() { + if provider_target().is_none() { + return; + } + let dir = install_dir("provider-install"); + let mut files = HashMap::new(); + provider_release(PROVIDER_RELEASE, &mut files); + let server = MockServer::new(files); + let output = run_update(&dir, &["update", "provider", "opy"], &provider_env(&server)); + assert_eq!( + output.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(&format!("installed opy provider {PROVIDER_RELEASE}")), + "{stdout}" + ); + let store = provider_store(&dir); + assert_eq!( + std::fs::read_to_string(store.join("active")) + .unwrap() + .trim(), + PROVIDER_RELEASE + ); + let executable = store + .join(PROVIDER_RELEASE) + .join(provider_target().unwrap()) + .join(provider_binary()); + assert_eq!(read(&executable), provider_body(PROVIDER_RELEASE)); + assert!(!stdout.contains("installing wright"), "{stdout}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn update_upgrades_the_installed_provider_alongside_self() { + if provider_target().is_none() { + return; + } + let dir = install_dir("provider-upgrade"); + seed_provider(&dir, PROVIDER_OLD); + let mut files = release(RELEASE).files; + provider_release(PROVIDER_RELEASE, &mut files); + let server = MockServer::new(files); + let output = run_update(&dir, &["update"], &provider_env(&server)); + assert_eq!( + output.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(&format!( + "updated opy provider {PROVIDER_OLD} -> {PROVIDER_RELEASE}" + )), + "{stdout}" + ); + assert_eq!( + std::fs::read_to_string(provider_store(&dir).join("active")) + .unwrap() + .trim(), + PROVIDER_RELEASE + ); + assert!( + String::from_utf8_lossy(&read(&dir.join("wright"))) + .contains(&format!("fake wright {RELEASE}")), + "the self target still updated" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn update_does_not_install_an_absent_provider() { + let dir = install_dir("provider-absent"); + let server = MockServer::new(release(RELEASE).files); + let output = run_update(&dir, &["update"], &server.env()); + assert_eq!(output.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("opy provider is not installed; skipping"), + "{stdout}" + ); + assert!( + !provider_store(&dir).join("active").exists(), + "a bare update must not create a provider installation" + ); + assert!( + String::from_utf8_lossy(&read(&dir.join("wright"))) + .contains(&format!("fake wright {RELEASE}")), + "the self target still updated" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn check_reports_provider_availability_without_modifying_anything() { + if provider_target().is_none() { + return; + } + let dir = install_dir("provider-check"); + seed_provider(&dir, PROVIDER_OLD); + let mut files = release(RELEASE).files; + provider_release(PROVIDER_RELEASE, &mut files); + let server = MockServer::new(files); + let before = read(&dir.join("wright")); + let output = run_update(&dir, &["update", "--check"], &provider_env(&server)); + assert_eq!(output.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(&format!( + "update available: opy provider {PROVIDER_OLD} -> {PROVIDER_RELEASE}" + )), + "{stdout}" + ); + assert_eq!( + std::fs::read_to_string(provider_store(&dir).join("active")) + .unwrap() + .trim(), + PROVIDER_OLD, + "--check must not touch the active provider" + ); + assert_eq!(read(&dir.join("wright")), before); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn update_provider_without_a_name_skips_an_empty_store() { + let dir = install_dir("provider-none"); + let output = run_update(&dir, &["update", "provider"], &[]); + assert_eq!(output.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("opy provider is not installed; skipping"), + "{stdout}" + ); + assert!(!provider_store(&dir).join("active").exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn package_managed_wright_skips_self_update_but_updates_providers() { + if provider_target().is_none() { + return; + } + let dir = homebrew_install_dir("managed"); + seed_provider(&dir, PROVIDER_OLD); + let mut files = HashMap::new(); + provider_release(PROVIDER_RELEASE, &mut files); + let server = MockServer::new(files); + let before = read(&dir.join("wright")); + let output = run_update(&dir, &["update"], &provider_env(&server)); + assert_eq!( + output.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("skipped wright self-update") && stdout.contains("brew upgrade"), + "the skip line carries actionable package-manager guidance: {stdout}" + ); + assert!( + stdout.contains(&format!( + "updated opy provider {PROVIDER_OLD} -> {PROVIDER_RELEASE}" + )), + "{stdout}" + ); + assert_eq!(read(&dir.join("wright")), before, "self-update was skipped"); + assert_eq!( + std::fs::read_to_string(provider_store(&dir).join("active")) + .unwrap() + .trim(), + PROVIDER_RELEASE + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn update_provider_check_reports_availability_without_installing() { + if provider_target().is_none() { + return; + } + let dir = install_dir("provider-check-flag"); + seed_provider(&dir, PROVIDER_OLD); + let mut files = HashMap::new(); + provider_release(PROVIDER_RELEASE, &mut files); + let server = MockServer::new(files); + let output = run_update( + &dir, + &["update", "provider", "--check"], + &provider_env(&server), + ); + assert_eq!( + output.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains(&format!( + "update available: opy provider {PROVIDER_OLD} -> {PROVIDER_RELEASE}" + )), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + assert_eq!( + std::fs::read_to_string(provider_store(&dir).join("active")) + .unwrap() + .trim(), + PROVIDER_OLD, + "--check must not touch the active provider" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_failed_provider_update_makes_update_exit_nonzero() { + if provider_target().is_none() { + return; + } + let dir = homebrew_install_dir("provider-fails"); + seed_provider(&dir, PROVIDER_OLD); + let server = MockServer::new(HashMap::new()); + let output = run_update(&dir, &["update"], &provider_env(&server)); + assert_ne!( + output.status.code(), + Some(0), + "a failed provider update must not report success" + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("opy"), + "stderr names the failing target: {}", + String::from_utf8_lossy(&output.stderr) + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/wright-cli/tests/update/support.rs b/crates/wright-cli/tests/update/support.rs new file mode 100644 index 00000000..20382ddc --- /dev/null +++ b/crates/wright-cli/tests/update/support.rs @@ -0,0 +1,181 @@ +#![allow(dead_code)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +#[path = "server.rs"] +mod server; + +pub(crate) use server::MockServer; + +pub(crate) const RELEASE: &str = "9.9.9"; +pub(crate) const TRIPLE: &str = "x86_64-unknown-linux-gnu"; +pub(crate) const PROVIDER_OLD: &str = "1.0.0"; +pub(crate) const PROVIDER_RELEASE: &str = "1.4.0"; + +pub(crate) struct Release { + pub(crate) files: HashMap>, +} + +pub(crate) fn release(version: &str) -> Release { + let archive = build_archive(version); + let name = format!("wright-{version}-{TRIPLE}.tar.gz"); + let mut files = HashMap::new(); + files.insert( + "/latest/version".to_string(), + format!("{version}\n").into_bytes(), + ); + files.insert(format!("/releases/{version}/{name}"), archive.clone()); + files.insert( + format!("/releases/{version}/{name}.sha256"), + format!("{} {name}\n", wright_driver::sha256_hex(&archive)).into_bytes(), + ); + Release { files } +} + +fn build_archive(version: &str) -> Vec { + let dir = format!("wright-{version}-{TRIPLE}"); + let mut builder = tar::Builder::new(Vec::new()); + for (name, content) in [ + ( + "wright", + format!("#!/bin/sh\necho \"fake wright {version}\"\n").into_bytes(), + ), + ( + "wright-lsp", + format!("#!/bin/sh\necho \"fake wright-lsp {version}\"\n").into_bytes(), + ), + ] { + let path = format!("{dir}/{name}"); + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder + .append_data(&mut header, path, &content[..]) + .unwrap(); + } + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &builder.into_inner().unwrap()).unwrap(); + encoder.finish().unwrap() +} + +pub(crate) fn provider_target() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"), + ("macos", "x86_64") => Some("x86_64-apple-darwin"), + ("macos", "aarch64") => Some("aarch64-apple-darwin"), + ("windows", "x86_64") => Some("x86_64-pc-windows-msvc"), + _ => None, + } +} + +pub(crate) fn provider_binary() -> &'static str { + if provider_target() == Some("x86_64-pc-windows-msvc") { + "opy-provider.exe" + } else { + "opy-provider" + } +} + +pub(crate) fn provider_store(dir: &Path) -> PathBuf { + dir.join("provider-data").join("providers").join("opy") +} + +pub(crate) fn seed_provider(dir: &Path, version: &str) { + let target = provider_target().expect("provider tests need a supported host"); + let store = provider_store(dir); + let executable = store.join(version).join(target).join(provider_binary()); + std::fs::create_dir_all(executable.parent().unwrap()).unwrap(); + std::fs::write(&executable, provider_body(version)).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + std::fs::write(store.join("active"), format!("{version}\n")).unwrap(); +} + +pub(crate) fn provider_body(version: &str) -> Vec { + format!("#!/bin/sh\necho \"fake opy-provider {version}\"\n").into_bytes() +} + +pub(crate) fn provider_release(version: &str, files: &mut HashMap>) { + let target = provider_target().expect("provider tests need a supported host"); + let binary = provider_binary(); + let name = format!("opy-provider-{version}-{target}.tar.gz"); + let mut builder = tar::Builder::new(Vec::new()); + let body = provider_body(version); + let mut header = tar::Header::new_gnu(); + header.set_size(body.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder.append_data(&mut header, binary, &body[..]).unwrap(); + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &builder.into_inner().unwrap()).unwrap(); + let archive = encoder.finish().unwrap(); + files.insert( + "/opy-rs/latest/version".to_string(), + format!("{version}\n").into_bytes(), + ); + files.insert( + format!("/opy-rs/releases/{version}/{name}"), + archive.clone(), + ); + files.insert( + format!("/opy-rs/releases/{version}/{name}.sha256"), + format!("{} {name}\n", wright_driver::sha256_hex(&archive)).into_bytes(), + ); +} + +pub(crate) fn provider_env(server: &MockServer) -> Vec<(&'static str, String)> { + let mut env = server.env(); + env.push(( + "WRIGHT_OPY_PROVIDER_BASE_URL", + format!("{}/opy-rs", server.base_url()), + )); + env +} + +pub(crate) fn install_dir(tag: &str) -> PathBuf { + install_dir_under(std::env::temp_dir(), tag) +} + +pub(crate) fn install_dir_under(parent: PathBuf, tag: &str) -> PathBuf { + let dir = parent.join(format!("wright-update-it-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let wright = env!("CARGO_BIN_EXE_wright"); + std::fs::copy(wright, dir.join("wright")).unwrap(); + std::fs::copy(wright, dir.join("wright-lsp")).unwrap(); + dir +} + +pub(crate) fn homebrew_install_dir(tag: &str) -> PathBuf { + install_dir_under(std::env::temp_dir().join("homebrew"), tag) +} + +pub(crate) fn run_update( + dir: &Path, + args: &[&str], + env: &[(&'static str, String)], +) -> std::process::Output { + let mut command = Command::new(dir.join("wright")); + command + .args(args) + .env("WRIGHT_INSTALL_OS", "linux") + .env("WRIGHT_INSTALL_ARCH", "x86_64") + .env("WRIGHT_PROVIDER_DATA_DIR", dir.join("provider-data")); + for (key, value) in env { + command.env(key, value); + } + command + .stdin(Stdio::null()) + .output() + .expect("wright update runs") +} + +pub(crate) fn read(path: &Path) -> Vec { + std::fs::read(path).unwrap() +} diff --git a/crates/wright-driver/src/lib.rs b/crates/wright-driver/src/lib.rs index be1a77cf..2f12f185 100644 --- a/crates/wright-driver/src/lib.rs +++ b/crates/wright-driver/src/lib.rs @@ -25,7 +25,7 @@ pub mod session; pub mod source_provider; pub(crate) mod statement_edit; #[cfg(not(target_arch = "wasm32"))] -pub(crate) mod store; +pub mod store; pub mod workshop_compat; pub use config::{ diff --git a/crates/wright-driver/src/opy_provider/download.rs b/crates/wright-driver/src/opy_provider/download.rs index 0cd36ce1..316dfe3e 100644 --- a/crates/wright-driver/src/opy_provider/download.rs +++ b/crates/wright-driver/src/opy_provider/download.rs @@ -54,24 +54,21 @@ pub(super) fn verify_checksum( checksum_file: &str, archive_name: &str, ) -> Result<(), OpyProviderError> { - let published = checksum_file.split_whitespace().next().ok_or_else(|| { - OpyProviderError::integrity(format!("empty checksum file for {}", archive_name)) - })?; - if published.len() != 64 || !published.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err(OpyProviderError::integrity(format!( + crate::store::verify_sha256(archive, checksum_file).map_err(|error| match error { + crate::store::ChecksumError::Empty => { + OpyProviderError::integrity(format!("empty checksum file for {}", archive_name)) + } + crate::store::ChecksumError::Invalid(_) => OpyProviderError::integrity(format!( "invalid SHA-256 checksum for {}", archive_name - ))); - } - let expected = published; - let actual = crate::input::sha256_hex(archive); - if !actual.eq_ignore_ascii_case(expected) { - return Err(OpyProviderError::integrity(format!( - "SHA-256 verification failed for {} (published {expected}, got {actual}); the active provider was not changed", - archive_name - ))); - } - Ok(()) + )), + crate::store::ChecksumError::Mismatch(expected, actual) => { + OpyProviderError::integrity(format!( + "SHA-256 verification failed for {} (published {expected}, got {actual}); the active provider was not changed", + archive_name + )) + } + }) } pub(super) fn extract_provider( diff --git a/crates/wright-driver/src/store.rs b/crates/wright-driver/src/store.rs index 1eb4b0ac..d4785266 100644 --- a/crates/wright-driver/src/store.rs +++ b/crates/wright-driver/src/store.rs @@ -25,11 +25,7 @@ pub(crate) fn wright_data_base() -> PathBuf { /// `root`, retrying name collisions through `0..1024`. `noun` names what /// is being staged, article included, for the reservation failure ("an /// OPY provider", "a lint package"); the create error drops the article. -pub(crate) fn reserve_staging_dir( - root: &Path, - prefix: &str, - noun: &str, -) -> Result { +pub fn reserve_staging_dir(root: &Path, prefix: &str, noun: &str) -> Result { let bare = noun .strip_prefix("an ") .or_else(|| noun.strip_prefix("a ")) @@ -89,7 +85,7 @@ pub(crate) fn promote_dir( /// The HTTPS client every bundled fetcher uses: the caller's user agent /// and a 60-second timeout. -pub(crate) fn client(agent: &str) -> Result { +pub fn client(agent: &str) -> Result { reqwest::blocking::Client::builder() .user_agent(agent) .timeout(std::time::Duration::from_secs(60)) @@ -98,7 +94,8 @@ pub(crate) fn client(agent: &str) -> Result Result, ReadError> { @@ -122,3 +119,34 @@ pub(crate) fn read_limited( } Ok(body) } + +/// The way a [`verify_sha256`] call failed, so each caller words its own +/// diagnostic. +#[derive(Debug)] +pub enum ChecksumError { + /// The checksum file held no digest. + Empty, + /// The first field was not a 64-digit SHA-256 hex digest. + Invalid(String), + /// The published digest did not match the archive. Carries + /// `(published, actual)`. + Mismatch(String, String), +} + +/// The one SHA-256 checksum verification used by every download: the first +/// whitespace-separated field of `checksum_file` must be a 64-digit hex +/// digest matching the SHA-256 of `archive`. +pub fn verify_sha256(archive: &[u8], checksum_file: &str) -> Result<(), ChecksumError> { + let published = checksum_file + .split_whitespace() + .next() + .ok_or(ChecksumError::Empty)?; + if published.len() != 64 || !published.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(ChecksumError::Invalid(published.to_string())); + } + let actual = crate::input::sha256_hex(archive); + if !actual.eq_ignore_ascii_case(published) { + return Err(ChecksumError::Mismatch(published.to_string(), actual)); + } + Ok(()) +}