Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 34 additions & 19 deletions crates/wright-cli/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ use std::time::Duration;
use clap::{Args, Subcommand, ValueEnum};
use wright_driver::{OpyProviderError, ResolvedOpyProvider, sha256_hex};

const DEFAULT_BASE_URL: &str = "https://github.com/wrightkit/wright/releases/download";
const DEFAULT_API_URL: &str = "https://api.github.com/repos/wrightkit/wright/releases/latest";
const DEFAULT_BASE_URL: &str = "https://releases.wrightkit.dev/wright";
const USER_AGENT: &str = concat!("wright-update/", env!("CARGO_PKG_VERSION"));

mod exit {
Expand Down Expand Up @@ -297,7 +296,7 @@ fn self_update(check_only: bool, requested: Option<&str>) -> Result<u8, UpdateEr
}
None => {
let client = update_client()?;
let version = resolve_latest(&client, &env_api_url())?;
let version = resolve_latest(&client, &env_base_url())?;
(version, Some(client))
}
};
Expand Down Expand Up @@ -395,22 +394,27 @@ fn detect_provenance(exe: &Path) -> Provenance {
}
}

/// Resolve the latest stable version through the shared R2 distribution
/// contract: `<base>/latest/version` is a plain-text version pointer, the same
/// route `install.sh` and `install.ps1` read.
fn resolve_latest(
client: &reqwest::blocking::Client,
api_url: &str,
base_url: &str,
) -> Result<String, UpdateError> {
let body = fetch_text(client, api_url)?;
let val: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
let url = format!("{}/latest/version", base_url.trim_end_matches('/'));
let body = fetch_text(client, &url)?;
parse_latest_version(&body).ok_or_else(|| {
UpdateError::failed(format!(
"could not parse the latest-release response from {api_url}: {e}"
"could not parse the latest release version from {url} (got '{}'); pin a version with `wright update self --version`",
truncate(body.trim())
))
})?;
let tag = val.get("tag_name").and_then(serde_json::Value::as_str).ok_or_else(|| {
UpdateError::failed(format!("could not find the latest release tag in the response from {api_url}; pin a version with `wright update self --version`"))
})?;
let version = tag.trim_start_matches('v');
parse_version(version)?;
Ok(version.to_string())
})
}

fn parse_latest_version(body: &str) -> Option<String> {
let version = body.trim().trim_start_matches('v');
parse_version(version).ok()?;
Some(version.to_string())
}

fn install_version(
Expand All @@ -421,7 +425,10 @@ fn install_version(
install_dir: &Path,
) -> Result<(), UpdateError> {
let archive_name = format!("wright-{version}-{}.tar.gz", platform.target);
let archive_url = format!("{base_url}/v{version}/{archive_name}");
let archive_url = format!(
"{}/releases/{version}/{archive_name}",
base_url.trim_end_matches('/')
);
let checksum_url = format!("{archive_url}.sha256");

println!("==> downloading {archive_url}");
Expand Down Expand Up @@ -650,10 +657,6 @@ fn env_base_url() -> String {
std::env::var("WRIGHT_INSTALL_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
}

fn env_api_url() -> String {
std::env::var("WRIGHT_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -681,6 +684,18 @@ mod tests {
}
}

#[test]
fn latest_version_pointer_is_plain_text() {
assert_eq!(parse_latest_version("9.9.9\n"), Some("9.9.9".to_string()));
assert_eq!(
parse_latest_version(" v0.2.10 \n"),
Some("0.2.10".to_string())
);
for bad in ["", "latest", "1.2", "{\"tag_name\":\"v9.9.9\"}", "0.1\n0.2"] {
assert_eq!(parse_latest_version(bad), None, "{bad:?} must be rejected");
}
}

#[test]
fn compare_versions_orders_numerically() {
use std::cmp::Ordering::*;
Expand Down
133 changes: 111 additions & 22 deletions crates/wright-cli/tests/update.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! End-to-end `wright update` tests (#116, #439) against mock release servers.
//! End-to-end `wright update` tests (#116, #439, #455) against a mock release
//! server.
//!
//! Serves fake release archives and checksums over a local HTTP server (the
//! same shape `scripts/test-install.sh` uses for `install.sh`) and exercises
//! Serves fake R2 release routes — the `latest/version` pointer plus
//! versioned archives and checksums, the same shape `scripts/test-install.sh`
//! uses for `install.sh` — and exercises
//! the real `wright` binary: the consolidated update surface (`update`,
//! `update self`, `update provider [opy]`), version resolution, `--check`
//! without modification, checksum-verified installs, atomic replacement of
Expand All @@ -12,8 +14,8 @@ use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;

use sha2::Digest;
Expand All @@ -25,10 +27,12 @@ const TRIPLE: &str = "x86_64-unknown-linux-gnu";
const PROVIDER_OLD: &str = "1.0.0";
const PROVIDER_RELEASE: &str = "1.4.0";

/// A minimal HTTP/1.1 server serving a fixed path -> body map.
/// A minimal HTTP/1.1 server serving a fixed path -> body map and recording
/// the paths clients requested.
struct MockServer {
addr: SocketAddr,
shutdown: Arc<AtomicBool>,
requests: Arc<Mutex<Vec<String>>>,
thread: Option<JoinHandle<()>>,
}

Expand All @@ -37,19 +41,22 @@ impl MockServer {
let listener = TcpListener::bind("127.0.0.1:0").expect("binds an ephemeral port");
let addr = listener.local_addr().expect("bound address");
let files = Arc::new(files);
let requests = Arc::new(Mutex::new(Vec::new()));
let shutdown = Arc::new(AtomicBool::new(false));
let thread_shutdown = shutdown.clone();
let thread_requests = requests.clone();
let thread = std::thread::spawn(move || {
while !thread_shutdown.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => serve(stream, &files),
Ok((stream, _)) => serve(stream, &files, &thread_requests),
Err(_) => break,
}
}
});
MockServer {
addr,
shutdown,
requests,
thread: Some(thread),
}
}
Expand All @@ -58,15 +65,15 @@ impl MockServer {
format!("http://127.0.0.1:{}", self.addr.port())
}

fn api_url(&self) -> String {
format!("{}/repos/wrightkit/wright/releases/latest", self.base_url())
/// The request paths the server has served so far, in order.
fn requests(&self) -> Vec<String> {
self.requests.lock().unwrap().clone()
}

/// Environment overrides that point the child at this server.
fn env(&self) -> Vec<(&'static str, String)> {
vec![
("WRIGHT_INSTALL_BASE_URL", self.base_url()),
("WRIGHT_API_URL", self.api_url()),
("WRIGHT_INSTALL_OS", "linux".to_string()),
("WRIGHT_INSTALL_ARCH", "x86_64".to_string()),
]
Expand All @@ -87,7 +94,7 @@ impl Drop for MockServer {
}
}

fn serve(mut stream: TcpStream, files: &HashMap<String, Vec<u8>>) {
fn serve(mut stream: TcpStream, files: &HashMap<String, Vec<u8>>, requests: &Mutex<Vec<String>>) {
let mut request = Vec::new();
let mut buf = [0u8; 1024];
loop {
Expand All @@ -108,6 +115,7 @@ fn serve(mut stream: TcpStream, files: &HashMap<String, Vec<u8>>) {
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("/");
let path = path.split('?').next().unwrap_or(path);
requests.lock().unwrap().push(path.to_string());
match files.get(path) {
Some(body) => {
let header = format!(
Expand All @@ -126,7 +134,8 @@ fn serve(mut stream: TcpStream, files: &HashMap<String, Vec<u8>>) {
let _ = stream.flush();
}

/// A release mock: archive + checksum + latest-release metadata.
/// A release mock in the R2 distribution shape: the plain-text latest pointer
/// plus the immutable versioned archive and checksum.
struct Release {
files: HashMap<String, Vec<u8>>,
}
Expand All @@ -136,13 +145,12 @@ fn release(version: &str) -> Release {
let name = format!("wright-{version}-{TRIPLE}.tar.gz");
let mut files = HashMap::new();
files.insert(
"/repos/wrightkit/wright/releases/latest".to_string(),
format!("{{\"tag_name\":\"v{version}\",\"draft\":false,\"prerelease\":false}}\n")
.into_bytes(),
"/latest/version".to_string(),
format!("{version}\n").into_bytes(),
);
files.insert(format!("/v{version}/{name}"), archive.clone());
files.insert(format!("/releases/{version}/{name}"), archive.clone());
files.insert(
format!("/v{version}/{name}.sha256"),
format!("/releases/{version}/{name}.sha256"),
format!("{} {name}\n", sha256_hex(&archive)).into_bytes(),
);
Release { files }
Expand Down Expand Up @@ -263,13 +271,9 @@ fn provider_release(version: &str, files: &mut HashMap<String, Vec<u8>>) {
/// Environment overrides that point the child at this server's provider routes.
fn provider_env(server: &MockServer) -> Vec<(&'static str, String)> {
let mut env = server.env();
env.push((
"WRIGHT_OPY_PROVIDER_LATEST_URL",
format!("{}/opy-rs/latest/version", server.base_url()),
));
env.push((
"WRIGHT_OPY_PROVIDER_BASE_URL",
format!("{}/opy-rs/releases", server.base_url()),
format!("{}/opy-rs", server.base_url()),
));
env
}
Expand Down Expand Up @@ -403,6 +407,91 @@ fn update_installs_and_replaces_both_binaries() {
leftovers.is_empty(),
"staging dirs must be cleaned up: {leftovers:?}"
);

// Self-update touches only the R2 distribution contract — the latest
// pointer plus the immutable versioned routes — never a release API.
let name = format!("wright-{RELEASE}-{TRIPLE}.tar.gz");
assert_eq!(
server.requests(),
vec![
"/latest/version".to_string(),
format!("/releases/{RELEASE}/{name}"),
format!("/releases/{RELEASE}/{name}.sha256"),
]
);
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn pinned_self_update_uses_only_the_versioned_release_routes() {
let server = MockServer::new(release(RELEASE).files);
let dir = install_dir("update-pinned");
let output = run_update(
&dir,
&["update", "self", "--version", RELEASE],
&server.env(),
);
assert_eq!(
output.status.code(),
Some(0),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let name = format!("wright-{RELEASE}-{TRIPLE}.tar.gz");
assert_eq!(
server.requests(),
vec![
format!("/releases/{RELEASE}/{name}"),
format!("/releases/{RELEASE}/{name}.sha256"),
],
"a pinned update must not resolve the latest pointer"
);
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn malformed_latest_version_pointer_is_rejected() {
// A release-API JSON body is not a valid version pointer: the R2 contract
// serves a bare version and anything else must fail without changes.
let mut files = HashMap::new();
files.insert(
"/latest/version".to_string(),
b"{\"tag_name\":\"v9.9.9\"}\n".to_vec(),
);
let server = MockServer::new(files);
let dir = install_dir("update-badlatest");
let before = read(&dir.join("wright"));
let output = run_update(&dir, &["update", "--check"], &server.env());
assert_eq!(output.status.code(), Some(4));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("could not parse the latest release version"),
"{stderr}"
);
assert_eq!(read(&dir.join("wright")), before);
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn missing_release_archive_fails_before_any_change() {
// The latest pointer resolves but no archive is published: the download
// failure is an environment error and nothing is replaced.
let mut files = HashMap::new();
files.insert(
"/latest/version".to_string(),
format!("{RELEASE}\n").into_bytes(),
);
let server = MockServer::new(files);
let dir = install_dir("update-missing-archive");
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("could not download") && stderr.contains("404"),
"{stderr}"
);
assert_eq!(read(&dir.join("wright")), before);
let _ = std::fs::remove_dir_all(&dir);
}

Expand Down Expand Up @@ -460,7 +549,7 @@ fn checksum_mismatch_is_rejected_before_any_change() {
let mut mock = release(RELEASE);
let name = format!("wright-{RELEASE}-{TRIPLE}.tar.gz");
mock.files.insert(
format!("/v{RELEASE}/{name}.sha256"),
format!("/releases/{RELEASE}/{name}.sha256"),
format!("{} {name}\n", sha256_hex(b"not the archive")).into_bytes(),
);
let server = MockServer::new(mock.files);
Expand Down
Loading
Loading