From 0973bf41af288be6fab180eacf068aa870e91c44 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:55:19 +0800 Subject: [PATCH 1/3] feat(provider): resolve first-party OPY providers Refs #244 --- Cargo.lock | 3 + Cargo.toml | 3 + crates/wright-cli/src/cli.rs | 32 + crates/wright-cli/src/main.rs | 33 +- crates/wright-cli/src/provider.rs | 8 + crates/wright-driver/Cargo.toml | 3 + crates/wright-driver/src/config.rs | 3 + crates/wright-driver/src/lib.rs | 4 + crates/wright-driver/src/opy_provider.rs | 828 +++++++++++++++++++++++ crates/wright-driver/src/session.rs | 37 + crates/wright-driver/tests/lpp.rs | 31 +- crates/wright-lpp/src/error.rs | 31 + crates/wright-lpp/src/lib.rs | 2 +- crates/wright-lpp/src/registry.rs | 5 + 14 files changed, 1016 insertions(+), 7 deletions(-) create mode 100644 crates/wright-cli/src/provider.rs create mode 100644 crates/wright-driver/src/opy_provider.rs diff --git a/Cargo.lock b/Cargo.lock index 24f7248..6951fce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2146,9 +2146,12 @@ dependencies = [ name = "wright-driver" version = "0.2.16" dependencies = [ + "flate2", "serde", "serde_json", "sha2", + "tar", + "ureq", "workshop-rs", "wright-analyzer", "wright-core", diff --git a/Cargo.toml b/Cargo.toml index 607b392..0da06f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,9 @@ libc = "0.2" serde = "1" serde_json = "1" sha2 = "0.10" +flate2 = "1" +tar = "0.4" +ureq = "2" wright-analyzer = { path = "crates/wright-analyzer" } wright-core = { path = "crates/wright-core" } wright-driver = { path = "crates/wright-driver" } diff --git a/crates/wright-cli/src/cli.rs b/crates/wright-cli/src/cli.rs index a9f0b7e..b1e8b73 100644 --- a/crates/wright-cli/src/cli.rs +++ b/crates/wright-cli/src/cli.rs @@ -41,6 +41,7 @@ WORKFLOW OPTIONS: --target Reconstruction target for convert: opy|ostw --locale Workshop client locale override --root Include/project root for source inputs + --opy-provider Explicit local first-party OPY provider executable --profile WIR transformation policy: off|compat|aggressive -o, --output Write compiled output to PATH (compile only) -f, --format Output format: text|json @@ -73,6 +74,8 @@ pub(crate) enum Command { Completion(CompletionArgs), /// Update a standalone installation. Update(UpdateArgs), + /// Manage first-party language providers. + Provider(ProviderArgs), /// Show the top-level help. Help, /// Show version and result-contract metadata. @@ -111,6 +114,9 @@ pub(crate) struct CommonArgs { /// Include/project root for source inputs. #[arg(long, value_name = "DIR")] pub(crate) root: Option, + /// Explicit local first-party OPY provider executable. + #[arg(long, value_name = "PATH")] + pub(crate) opy_provider: Option, /// WIR transformation policy. #[arg(long, value_enum, default_value_t = ProfileArg::Off)] pub(crate) profile: ProfileArg, @@ -196,6 +202,32 @@ pub(crate) struct UpdateArgs { pub(crate) version: Option, } +#[derive(Debug, Args)] +pub(crate) struct ProviderArgs { + #[command(subcommand)] + pub(crate) command: ProviderCommand, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum ProviderCommand { + /// Install or update a first-party provider. + Update(ProviderUpdateArgs), +} + +#[derive(Debug, Args)] +pub(crate) struct ProviderUpdateArgs { + /// The provider to install. + pub(crate) provider: ProviderNameArg, + /// Install an exact release instead of the latest stable release. + #[arg(long, value_name = "VERSION")] + pub(crate) version: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum ProviderNameArg { + Opy, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] pub(crate) enum SourceKindArg { Auto, diff --git a/crates/wright-cli/src/main.rs b/crates/wright-cli/src/main.rs index 916b4ff..9bc2dfd 100644 --- a/crates/wright-cli/src/main.rs +++ b/crates/wright-cli/src/main.rs @@ -3,6 +3,7 @@ mod cli; mod completion; mod present; +mod provider; mod update; use std::io::Write; @@ -13,7 +14,9 @@ use clap::{CommandFactory, Parser}; use wright_driver::config::{InputSpec, OutputFormat, SessionConfig, SourceKind}; use wright_driver::result::exit; -use crate::cli::{Cli, Command, CommonArgs, ConvertTargetArg, OutputFormatArg}; +use crate::cli::{ + Cli, Command, CommonArgs, ConvertTargetArg, OutputFormatArg, ProviderCommand, ProviderNameArg, +}; /// The CLI name and version banner. pub const CLI_NAME: &str = "wright"; @@ -89,6 +92,24 @@ fn main() -> ExitCode { ExitCode::from(error.exit_code()) } }, + Some(Command::Provider(args)) => match args.command { + ProviderCommand::Update(update) => match update.provider { + ProviderNameArg::Opy => match provider::update(update.version.as_deref()) { + Ok(resolved) => { + println!( + "installed first-party OPY provider {} at {}", + resolved.version.as_deref().unwrap_or("local"), + resolved.executable.display() + ); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("wright: {}: {}", error.code(), error); + ExitCode::from(error.exit_code()) + } + }, + }, + }, Some(command) => run_workflow(command), } } @@ -156,7 +177,11 @@ fn run_workflow(command: Command) -> ExitCode { present::Presentation::from_common(&args), None, ), - Command::Completion(_) | Command::Update(_) | Command::Help | Command::Version => { + Command::Completion(_) + | Command::Update(_) + | Command::Provider(_) + | Command::Help + | Command::Version => { unreachable!("non-workflow command handled before run_workflow") } }; @@ -230,6 +255,10 @@ fn config_from_common(common: &CommonArgs) -> SessionConfig { }, locale: common.locale.clone(), root: common.root.clone(), + opy_provider: wright_driver::OpyProviderConfig { + executable: common.opy_provider.clone(), + ..wright_driver::OpyProviderConfig::default() + }, format: match common.format { OutputFormatArg::Text => OutputFormat::Text, OutputFormatArg::Json => OutputFormat::Json, diff --git a/crates/wright-cli/src/provider.rs b/crates/wright-cli/src/provider.rs new file mode 100644 index 0000000..a85b146 --- /dev/null +++ b/crates/wright-cli/src/provider.rs @@ -0,0 +1,8 @@ +//! First-party language-provider management commands. + +use wright_driver::{OpyProviderConfig, OpyProviderError, ResolvedOpyProvider}; + +/// Explicitly install/update the first-party OPY provider. +pub(crate) fn update(version: Option<&str>) -> Result { + OpyProviderConfig::default().update(version) +} diff --git a/crates/wright-driver/Cargo.toml b/crates/wright-driver/Cargo.toml index 1e856ad..f83e599 100644 --- a/crates/wright-driver/Cargo.toml +++ b/crates/wright-driver/Cargo.toml @@ -14,6 +14,9 @@ workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true sha2.workspace = true +flate2.workspace = true +tar.workspace = true +ureq.workspace = true wright-analyzer.workspace = true wright-core.workspace = true wright-ir.workspace = true diff --git a/crates/wright-driver/src/config.rs b/crates/wright-driver/src/config.rs index f620666..05a9026 100644 --- a/crates/wright-driver/src/config.rs +++ b/crates/wright-driver/src/config.rs @@ -130,6 +130,8 @@ pub struct SessionConfig { /// locate the mock provider through the `LPP_MOCK_PROVIDER` /// environment variable). pub providers: wright_lpp::ProviderRegistry, + /// First-party OPY provider resolution settings (#244). + pub opy_provider: crate::opy_provider::OpyProviderConfig, } impl Default for SessionConfig { @@ -144,6 +146,7 @@ impl Default for SessionConfig { profile: wright_transform::Profile::Off, lint: LintConfig::default(), providers: wright_lpp::ProviderRegistry::default(), + opy_provider: crate::opy_provider::OpyProviderConfig::default(), } } } diff --git a/crates/wright-driver/src/lib.rs b/crates/wright-driver/src/lib.rs index e6d58cf..d410912 100644 --- a/crates/wright-driver/src/lib.rs +++ b/crates/wright-driver/src/lib.rs @@ -17,6 +17,7 @@ pub mod diag; pub mod edit; pub mod input; pub mod opy; +pub mod opy_provider; pub mod progress; pub mod provider_edit; pub mod result; @@ -27,6 +28,9 @@ pub mod workshop_provider; pub use config::{InputSpec, LintConfig, OutputFormat, SessionConfig, SourceKind}; pub use diag::{Diagnostic, Origin, Position, Severity, SourceSpan, Stage}; pub use input::{ResolvedInput, sha256_hex}; +pub use opy_provider::{ + OpyProviderConfig, OpyProviderError, OpyProviderResolver, ResolvedOpyProvider, +}; pub use progress::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit}; pub use result::{ AnalyzeResult, CheckResult, CompileResult, CompiledOutput, ConvertResult, ConvertTarget, diff --git a/crates/wright-driver/src/opy_provider.rs b/crates/wright-driver/src/opy_provider.rs new file mode 100644 index 0000000..b6f11a3 --- /dev/null +++ b/crates/wright-driver/src/opy_provider.rs @@ -0,0 +1,828 @@ +//! Resolution and installation of the first-party OPY LPP provider (#244). +//! +//! This module owns only distribution state. The provider process and wire +//! protocol remain owned by `wright-lpp`, and OPY project loading remains an +//! `opy-rs` concern. + +use std::fmt; +use std::io::{Read, Write}; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use flate2::read::GzDecoder; +use sha2::{Digest, Sha256}; + +const DEFAULT_API_URL: &str = "https://api.github.com/repos/wrightkit/opy-rs/releases/latest"; +const DEFAULT_BASE_URL: &str = "https://github.com/wrightkit/opy-rs/releases/download"; +const PROVIDER_BINARY: &str = "opy-provider"; +const MAX_DOWNLOAD_BYTES: u64 = 128 * 1024 * 1024; + +/// The LPP language id served by the first-party OPY provider. +pub const OPY_LANGUAGE_ID: &str = "opy"; + +/// Settings for first-party OPY provider resolution. +#[derive(Debug, Clone, Default)] +pub struct OpyProviderConfig { + /// An explicitly selected local executable. It has highest precedence. + pub executable: Option, + /// Override the per-user provider store, primarily for embedding/tests. + pub store_dir: Option, +} + +impl OpyProviderConfig { + /// Select a local provider executable without enabling first-party + /// download behavior. + pub fn with_executable(path: impl Into) -> Self { + Self { + executable: Some(path.into()), + ..Self::default() + } + } + + /// Resolve using this configuration. + pub fn resolve(&self) -> Result { + let resolver = + OpyProviderResolver::new(self.store_dir.clone().unwrap_or_else(default_store_dir)); + resolver.resolve(self.executable.as_deref()) + } + + /// Explicitly install/update a provider version. Unlike [`Self::resolve`] + /// for an already installed provider, this operation may contact the + /// release source by design. + pub fn update(&self, version: Option<&str>) -> Result { + let resolver = + OpyProviderResolver::new(self.store_dir.clone().unwrap_or_else(default_store_dir)); + resolver.update(version) + } +} + +/// A resolved executable ready to pass to `wright-lpp::ProviderRegistry`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedOpyProvider { + /// The local executable path. + pub executable: PathBuf, + /// The installed release version, if this came from the first-party + /// store. Explicit local providers have no release version. + pub version: Option, +} + +/// First-party OPY provider resolver and installer. +#[derive(Debug, Clone)] +pub struct OpyProviderResolver { + store_dir: PathBuf, + target: Option, + api_url: String, + base_url: String, +} + +impl OpyProviderResolver { + /// Create a resolver using the current supported host target. + pub fn new(store_dir: impl Into) -> Self { + Self { + store_dir: store_dir.into(), + target: None, + api_url: DEFAULT_API_URL.to_string(), + base_url: DEFAULT_BASE_URL.to_string(), + } + } + + /// Override the target triple. This is useful for deterministic tests and + /// cross-target embedding; normal callers should leave it unset. + pub fn with_target(mut self, target: impl Into) -> Self { + self.target = Some(target.into()); + self + } + + /// Override the release endpoints without changing the artifact layout. + pub fn with_release_urls( + mut self, + api_url: impl Into, + base_url: impl Into, + ) -> Self { + self.api_url = api_url.into(); + self.base_url = base_url.into(); + self + } + + /// Resolve an OPY provider in precedence order: explicit local executable, + /// active installed provider, then lazy first-party bootstrap. + pub fn resolve( + &self, + explicit: Option<&Path>, + ) -> Result { + if let Some(path) = explicit { + return validate_explicit(path); + } + + let target = self.target()?; + if let Some((version, executable)) = self.active_provider(&target)? { + return Ok(ResolvedOpyProvider { + executable, + version: Some(version), + }); + } + + self.install_release(None, &target) + } + + /// Explicitly update/bootstrap the provider from the first-party release + /// source. No caller should invoke this as part of an ordinary source + /// command. + pub fn update( + &self, + requested_version: Option<&str>, + ) -> Result { + let target = self.target()?; + self.install_release(requested_version, &target) + } + + fn target(&self) -> Result { + let target = match &self.target { + Some(target) => Ok(target.clone()), + None => current_target(), + }?; + if matches!( + target.as_str(), + "x86_64-unknown-linux-gnu" | "x86_64-apple-darwin" | "aarch64-apple-darwin" + ) { + Ok(target) + } else { + Err(OpyProviderError::unsupported(format!( + "unsupported OPY provider target '{target}'" + ))) + } + } + + fn active_provider(&self, target: &str) -> Result, OpyProviderError> { + let active = self.store_dir.join("active"); + let version = match std::fs::read_to_string(&active) { + Ok(value) => normalize_version(value.trim())?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(OpyProviderError::install(format!( + "cannot read active OPY provider pointer '{}': {error}", + active.display() + ))); + } + }; + let executable = self.provider_path(&version, target); + if is_executable(&executable) { + Ok(Some((version, executable))) + } else { + Ok(None) + } + } + + fn provider_path(&self, version: &str, target: &str) -> PathBuf { + self.store_dir + .join(version) + .join(target) + .join(PROVIDER_BINARY) + } + + fn install_release( + &self, + requested_version: Option<&str>, + target: &str, + ) -> Result { + let version = match requested_version { + Some(version) => normalize_version(version)?, + None => self.fetch_latest_version()?, + }; + let archive_name = format!("opy-provider-{version}-{target}.tar.gz"); + let archive_url = format!( + "{}/v{version}/{archive_name}", + self.base_url.trim_end_matches('/') + ); + let checksum_url = format!("{archive_url}.sha256"); + let archive = fetch(&archive_url)?; + let checksum = fetch_text(&checksum_url)?; + verify_checksum(&archive, &checksum, &archive_name)?; + self.install_archive(&version, target, &archive)?; + Ok(ResolvedOpyProvider { + executable: self.provider_path(&version, target), + version: Some(version), + }) + } + + fn fetch_latest_version(&self) -> Result { + let body = fetch_text(&self.api_url)?; + let value: serde_json::Value = serde_json::from_str(&body).map_err(|error| { + OpyProviderError::download(format!( + "cannot parse the OPY release response from {}: {error}", + self.api_url + )) + })?; + let tag = value + .get("tag_name") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + OpyProviderError::download(format!( + "the OPY release response from {} has no tag_name", + self.api_url + )) + })?; + normalize_version(tag) + } + + fn install_archive( + &self, + version: &str, + target: &str, + archive: &[u8], + ) -> Result<(), OpyProviderError> { + std::fs::create_dir_all(&self.store_dir).map_err(|error| { + OpyProviderError::install(format!( + "cannot create the OPY provider store '{}': {error}", + self.store_dir.display() + )) + })?; + + let final_dir = self.store_dir.join(version).join(target); + let final_executable = final_dir.join(PROVIDER_BINARY); + if final_executable.is_file() { + if !is_executable(&final_executable) { + return Err(OpyProviderError::install(format!( + "installed OPY provider '{}' is not executable", + final_executable.display() + ))); + } + return self.activate(version); + } + if final_dir.exists() { + return Err(OpyProviderError::install(format!( + "cannot install OPY provider: target directory '{}' already exists without a valid executable", + final_dir.display() + ))); + } + + let staging = self.store_dir.join(format!( + ".opy-provider-{version}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() + )); + let result = (|| -> Result<(), OpyProviderError> { + std::fs::create_dir_all(&staging).map_err(|error| { + OpyProviderError::install(format!( + "cannot create OPY provider staging directory '{}': {error}", + staging.display() + )) + })?; + extract_provider(archive, &staging, version, target)?; + std::fs::create_dir_all(final_dir.parent().expect("provider target has a parent")) + .map_err(|error| { + OpyProviderError::install(format!( + "cannot create OPY provider version directory '{}': {error}", + final_dir.display() + )) + })?; + std::fs::rename(&staging, &final_dir).map_err(|error| { + OpyProviderError::install(format!( + "cannot activate staged OPY provider '{}': {error}", + final_dir.display() + )) + })?; + self.activate(version) + })(); + if result.is_err() { + let _ = std::fs::remove_dir_all(&staging); + } + result + } + + fn activate(&self, version: &str) -> Result<(), OpyProviderError> { + let temporary = self.store_dir.join(format!( + "active.{}.{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() + )); + let result = (|| -> Result<(), OpyProviderError> { + let mut file = std::fs::File::create(&temporary).map_err(|error| { + OpyProviderError::install(format!( + "cannot write temporary OPY provider pointer '{}': {error}", + temporary.display() + )) + })?; + file.write_all(version.as_bytes()).map_err(|error| { + OpyProviderError::install(format!( + "cannot write temporary OPY provider pointer '{}': {error}", + temporary.display() + )) + })?; + file.sync_all().map_err(|error| { + OpyProviderError::install(format!( + "cannot persist temporary OPY provider pointer '{}': {error}", + temporary.display() + )) + })?; + std::fs::rename(&temporary, self.store_dir.join("active")).map_err(|error| { + OpyProviderError::install(format!( + "cannot atomically activate OPY provider {version}: {error}" + )) + }) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result + } +} + +/// A machine-readable provider distribution failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpyProviderError { + Missing(String), + UnsupportedPlatform(String), + Offline(String), + Download(String), + Integrity(String), + Install(String), +} + +impl OpyProviderError { + fn missing(message: impl Into) -> Self { + Self::Missing(message.into()) + } + fn unsupported(message: impl Into) -> Self { + Self::UnsupportedPlatform(message.into()) + } + fn offline(message: impl Into) -> Self { + Self::Offline(message.into()) + } + fn download(message: impl Into) -> Self { + Self::Download(message.into()) + } + fn integrity(message: impl Into) -> Self { + Self::Integrity(message.into()) + } + fn install(message: impl Into) -> Self { + Self::Install(message.into()) + } + + /// Stable machine-readable error code. + pub fn code(&self) -> &'static str { + match self { + Self::Missing(_) => "provider-missing", + Self::UnsupportedPlatform(_) => "provider-unsupported-platform", + Self::Offline(_) => "provider-offline", + Self::Download(_) => "provider-download", + Self::Integrity(_) => "provider-integrity", + Self::Install(_) => "provider-install", + } + } + + /// The CLI exit code for this failure. + pub fn exit_code(&self) -> u8 { + match self { + Self::UnsupportedPlatform(_) => crate::result::exit::UNSUPPORTED, + _ => crate::result::exit::INTERNAL, + } + } +} + +impl fmt::Display for OpyProviderError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing(message) + | Self::UnsupportedPlatform(message) + | Self::Offline(message) + | Self::Download(message) + | Self::Integrity(message) + | Self::Install(message) => formatter.write_str(message), + } + } +} + +impl std::error::Error for OpyProviderError {} + +fn validate_explicit(path: &Path) -> Result { + if !is_executable(path) { + return Err(OpyProviderError::missing(format!( + "explicit OPY provider '{}' does not exist or is not executable", + path.display() + ))); + } + Ok(ResolvedOpyProvider { + executable: path.to_path_buf(), + version: None, + }) +} + +fn current_target() -> Result { + target_for(std::env::consts::OS, std::env::consts::ARCH) +} + +fn target_for(os: &str, arch: &str) -> Result { + match (os, arch) { + ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu".to_string()), + ("macos", "x86_64") => Ok("x86_64-apple-darwin".to_string()), + ("macos", "aarch64") => Ok("aarch64-apple-darwin".to_string()), + _ => Err(OpyProviderError::unsupported(format!( + "unsupported OPY provider target {os}/{arch}; supported targets are linux/x86_64 and darwin x86_64/aarch64" + ))), + } +} + +fn default_store_dir() -> PathBuf { + if let Some(path) = std::env::var_os("WRIGHT_PROVIDER_DATA_DIR") { + return PathBuf::from(path).join("providers").join("opy"); + } + let base = match std::env::consts::OS { + "macos" => std::env::var_os("HOME") + .map(PathBuf::from) + .map(|path| path.join("Library").join("Application Support")), + "windows" => std::env::var_os("APPDATA").map(PathBuf::from), + _ => std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|path| PathBuf::from(path).join(".local"))), + }; + base.unwrap_or_else(|| PathBuf::from(".")) + .join("wright") + .join("providers") + .join("opy") +} + +fn normalize_version(version: &str) -> Result { + let version = version.trim_start_matches('v'); + let mut parts = version.split('.'); + let valid = match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some(a), Some(b), Some(c), None) => [a, b, c] + .into_iter() + .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())), + _ => false, + }; + if !valid { + return Err(OpyProviderError::download(format!( + "invalid OPY provider release version '{version}' (expected X.Y.Z)" + ))); + } + Ok(version.to_string()) +} + +fn is_executable(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +fn fetch_text(url: &str) -> Result { + let bytes = fetch(url)?; + String::from_utf8(bytes).map_err(|error| { + OpyProviderError::download(format!("cannot decode response from {url}: {error}")) + }) +} + +fn fetch(url: &str) -> Result, OpyProviderError> { + let response = ureq::get(url) + .set( + "User-Agent", + concat!("wright-opy-provider/", env!("CARGO_PKG_VERSION")), + ) + .timeout(Duration::from_secs(60)) + .call() + .map_err(|error| match error { + ureq::Error::Status(status, _) => { + OpyProviderError::download(format!("GET {url} failed with HTTP {status}")) + } + ureq::Error::Transport(error) => OpyProviderError::offline(format!( + "cannot reach OPY provider release source {url}: {error}" + )), + })?; + if !(200..300).contains(&response.status()) { + return Err(OpyProviderError::download(format!( + "GET {url} failed with HTTP {}", + response.status() + ))); + } + let mut bytes = Vec::new(); + response + .into_reader() + .take(MAX_DOWNLOAD_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| { + OpyProviderError::download(format!("cannot read response from {url}: {error}")) + })?; + if bytes.len() as u64 > MAX_DOWNLOAD_BYTES { + return Err(OpyProviderError::download(format!( + "response from {url} exceeds the 128 MiB provider artifact limit" + ))); + } + Ok(bytes) +} + +fn verify_checksum( + archive: &[u8], + 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!( + "invalid SHA-256 checksum for {archive_name}" + ))); + } + let actual = Sha256::digest(archive); + let actual = actual + .iter() + .fold(String::with_capacity(64), |mut output, byte| { + use std::fmt::Write as _; + let _ = write!(output, "{byte:02x}"); + output + }); + if !actual.eq_ignore_ascii_case(published) { + return Err(OpyProviderError::integrity(format!( + "SHA-256 verification failed for {archive_name} (published {published}, got {actual}); the active provider was not changed" + ))); + } + Ok(()) +} + +fn extract_provider( + archive: &[u8], + destination: &Path, + version: &str, + target: &str, +) -> Result<(), OpyProviderError> { + let expected_root = format!("opy-provider-{version}-{target}"); + let decoder = GzDecoder::new(archive); + let mut archive = tar::Archive::new(decoder); + let mut found = false; + let entries = archive.entries().map_err(|error| { + OpyProviderError::install(format!("cannot read OPY provider archive: {error}")) + })?; + for entry in entries { + let mut entry = entry.map_err(|error| { + OpyProviderError::install(format!("cannot read OPY provider archive entry: {error}")) + })?; + let path = entry.path().map_err(|error| { + OpyProviderError::install(format!("cannot inspect OPY provider archive path: {error}")) + })?; + let components: Vec<_> = path.components().collect(); + let expected_path = Path::new(&expected_root).join(PROVIDER_BINARY); + let is_root = components.len() == 1 + && components[0] == std::path::Component::Normal(expected_root.as_ref()); + if path == expected_path { + if !entry.header().entry_type().is_file() { + return Err(OpyProviderError::install( + "OPY provider archive executable is not a regular file", + )); + } + let output = destination.join(PROVIDER_BINARY); + let mut file = std::fs::File::create(&output).map_err(|error| { + OpyProviderError::install(format!( + "cannot create staged OPY provider '{}': {error}", + output.display() + )) + })?; + std::io::copy(&mut entry, &mut file).map_err(|error| { + OpyProviderError::install(format!( + "cannot unpack staged OPY provider '{}': {error}", + output.display() + )) + })?; + file.sync_all().map_err(|error| { + OpyProviderError::install(format!( + "cannot persist staged OPY provider '{}': {error}", + output.display() + )) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = entry.header().mode().unwrap_or(0o755) | 0o111; + std::fs::set_permissions(&output, std::fs::Permissions::from_mode(mode)).map_err( + |error| { + OpyProviderError::install(format!( + "cannot make staged OPY provider executable '{}': {error}", + output.display() + )) + }, + )?; + } + found = true; + } else if !is_root { + return Err(OpyProviderError::install( + "OPY provider archive contains an unexpected path", + )); + } + } + if !found || !is_executable(&destination.join(PROVIDER_BINARY)) { + return Err(OpyProviderError::install( + "OPY provider archive does not contain an executable provider", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{TcpListener, TcpStream}; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + use std::thread; + + fn test_root(name: &str) -> PathBuf { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target") + .join(format!("wright-opy-provider-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn archive(version: &str, target: &str, body: &[u8]) -> Vec { + let mut builder = tar::Builder::new(Vec::new()); + 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, + format!("opy-provider-{version}-{target}/{PROVIDER_BINARY}"), + body, + ) + .unwrap(); + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&builder.into_inner().unwrap()).unwrap(); + encoder.finish().unwrap() + } + + #[test] + fn explicit_provider_wins_without_target_or_network_access() { + let root = test_root("explicit"); + let executable = root.join(PROVIDER_BINARY); + std::fs::write(&executable, b"#!/bin/sh\n").unwrap(); + make_executable(&executable); + let resolver = OpyProviderResolver::new(root.join("store")).with_target("unsupported"); + let resolved = resolver.resolve(Some(&executable)).unwrap(); + assert_eq!(resolved.executable, executable); + assert_eq!(resolved.version, None); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn clean_install_and_failed_integrity_update_preserve_active_provider() { + let root = test_root("install"); + let target = "x86_64-unknown-linux-gnu"; + let resolver = OpyProviderResolver::new(&root).with_target(target); + let first = archive("1.2.3", target, b"first"); + let first_sum = format!("{} archive\n", hex(&first)); + verify_checksum(&first, &first_sum, "archive").unwrap(); + resolver.install_archive("1.2.3", target, &first).unwrap(); + assert_eq!( + std::fs::read_to_string(root.join("active")).unwrap(), + "1.2.3" + ); + let bad = archive("1.2.4", target, b"second"); + let error = verify_checksum(&bad, &first_sum, "archive").unwrap_err(); + assert_eq!(error.code(), "provider-integrity"); + assert_eq!( + std::fs::read_to_string(root.join("active")).unwrap(), + "1.2.3" + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn installed_provider_is_reused_without_network_access() { + let root = test_root("offline"); + let target = "x86_64-unknown-linux-gnu"; + let resolver = OpyProviderResolver::new(&root).with_target(target); + let bytes = archive("2.0.0", target, b"cached"); + resolver.install_archive("2.0.0", target, &bytes).unwrap(); + let offline = resolver.with_release_urls("http://127.0.0.1:1/latest", "http://127.0.0.1:1"); + let resolved = offline.resolve(None).unwrap(); + assert_eq!(resolved.version.as_deref(), Some("2.0.0")); + assert_eq!(std::fs::read(resolved.executable).unwrap(), b"cached"); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn missing_provider_bootstraps_from_release_api_and_artifacts() { + let root = test_root("bootstrap"); + let target = "x86_64-unknown-linux-gnu"; + let version = "3.1.4"; + let bytes = archive(version, target, b"bootstrapped"); + let checksum = format!("{} opy-provider-{version}-{target}.tar.gz\n", hex(&bytes)); + let (base_url, requests, server) = test_server( + format!(r#"{{"tag_name":"v{version}"}}"#).into_bytes(), + bytes, + checksum.into_bytes(), + ); + let resolver = OpyProviderResolver::new(&root) + .with_target(target) + .with_release_urls(format!("{base_url}/latest"), &base_url); + let resolved = resolver.resolve(None).unwrap(); + server.join().unwrap(); + assert_eq!(requests.load(Ordering::Relaxed), 3); + assert_eq!(resolved.version.as_deref(), Some(version)); + assert_eq!(std::fs::read(resolved.executable).unwrap(), b"bootstrapped"); + assert_eq!( + std::fs::read_to_string(root.join("active")).unwrap(), + version + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn unsupported_target_is_structured() { + let error = OpyProviderResolver::new(test_root("target")) + .with_target("mips-unknown-linux-gnu") + .update(Some("1.0.0")) + .unwrap_err(); + assert_eq!(error.code(), "provider-unsupported-platform"); + let error = target_for("windows", "x86_64").unwrap_err(); + assert_eq!(error.code(), "provider-unsupported-platform"); + } + + fn hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .fold(String::new(), |mut value, byte| { + use std::fmt::Write as _; + let _ = write!(value, "{byte:02x}"); + value + }) + } + + fn test_server( + api: Vec, + archive: Vec, + checksum: Vec, + ) -> (String, Arc, thread::JoinHandle<()>) { + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap(); + let address = listener.local_addr().unwrap(); + let requests = Arc::new(AtomicUsize::new(0)); + let seen = Arc::clone(&requests); + let server = thread::spawn(move || { + for _ in 0..3 { + let (mut stream, _) = listener.accept().unwrap(); + respond(&mut stream, &api, &archive, &checksum); + seen.fetch_add(1, Ordering::Relaxed); + } + }); + (format!("http://{address}"), requests, server) + } + + fn respond(stream: &mut TcpStream, api: &[u8], archive: &[u8], checksum: &[u8]) { + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let count = stream.read(&mut buffer).unwrap(); + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + let request = String::from_utf8_lossy(&request); + let path = request.split_whitespace().nth(1).unwrap_or_default(); + let body = if path.ends_with("/latest") { + api + } else if path.ends_with(".sha256") { + checksum + } else { + archive + }; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .unwrap(); + stream.write_all(body).unwrap(); + } + + #[cfg(unix)] + fn make_executable(path: &Path) { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + #[cfg(not(unix))] + fn make_executable(_path: &Path) {} +} diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index a49434b..02b8a69 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -19,6 +19,7 @@ use crate::WorkshopProvider; use crate::config::{SessionConfig, SourceKind}; use crate::diag::{Diagnostic, Origin, Position, Severity, SourceSpan, Stage}; use crate::input::{self, ResolvedInput}; +use crate::opy_provider; use crate::progress::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit}; use crate::result::{ AnalyzeResult, CheckResult, CompileResult, CompiledOutput, ConvertResult, ConvertTarget, @@ -27,6 +28,21 @@ use crate::result::{ }; use crate::{input_identity, opy}; +fn error_kind(error: &opy_provider::OpyProviderError) -> wright_lpp::LocalProviderErrorKind { + match error { + opy_provider::OpyProviderError::Missing(_) => wright_lpp::LocalProviderErrorKind::Missing, + opy_provider::OpyProviderError::UnsupportedPlatform(_) => { + wright_lpp::LocalProviderErrorKind::UnsupportedPlatform + } + opy_provider::OpyProviderError::Offline(_) => wright_lpp::LocalProviderErrorKind::Offline, + opy_provider::OpyProviderError::Download(_) => wright_lpp::LocalProviderErrorKind::Download, + opy_provider::OpyProviderError::Integrity(_) => { + wright_lpp::LocalProviderErrorKind::Integrity + } + opy_provider::OpyProviderError::Install(_) => wright_lpp::LocalProviderErrorKind::Install, + } +} + /// A successfully loaded program with its input and origin metadata. #[derive(Clone)] pub struct Loaded { @@ -230,6 +246,27 @@ impl CompilerSession { &self, language_id: &str, ) -> Result, wright_lpp::ProviderError> { + if language_id == opy_provider::OPY_LANGUAGE_ID + && !self.config.providers.contains(language_id) + { + let resolved = self.config.opy_provider.resolve().map_err(|error| { + wright_lpp::ProviderError::Local { + kind: error_kind(&error), + message: error.to_string(), + } + })?; + let mut providers = self.config.providers.clone(); + providers + .register(wright_lpp::ProviderConfig::new( + opy_provider::OPY_LANGUAGE_ID, + resolved.executable, + Vec::new(), + )) + .expect("the first-party OPY provider language id is not registered"); + return providers + .spawn(language_id) + .map(|provider| Box::new(provider) as Box); + } self.config .providers .spawn(language_id) diff --git a/crates/wright-driver/tests/lpp.rs b/crates/wright-driver/tests/lpp.rs index 6bba874..1062719 100644 --- a/crates/wright-driver/tests/lpp.rs +++ b/crates/wright-driver/tests/lpp.rs @@ -88,22 +88,45 @@ fn session_spawns_initializes_and_queries_a_provider() { #[test] fn unconfigured_language_refuses_explicitly() { - // "opy" here is an unconfigured opaque language id — there is no - // OPY-specific branch in the client; the refusal is the registry's. + // A non-OPY language remains an unconfigured opaque language id; the + // first-party OPY resolver must not change generic registry behavior. let session = CompilerSession::new(SessionConfig::default()).expect("session"); let error = session - .language_provider("opy") + .language_provider("x-unconfigured-lang") .err() .expect("not configured"); assert_eq!(error.code(), "provider-not-configured"); assert_eq!( error, ProviderError::NotConfigured { - language_id: "opy".to_string(), + language_id: "x-unconfigured-lang".to_string(), } ); } +#[test] +fn missing_first_party_opy_provider_is_a_structured_local_failure() { + let config = SessionConfig { + opy_provider: wright_driver::OpyProviderConfig::with_executable( + workspace_root().join("does-not-exist/opy-provider"), + ), + ..SessionConfig::default() + }; + let session = CompilerSession::new(config).expect("session"); + let error = session + .language_provider("opy") + .err() + .expect("missing provider must be visible"); + assert_eq!(error.code(), "provider-missing"); + assert!(matches!( + error, + ProviderError::Local { + kind: wright_lpp::LocalProviderErrorKind::Missing, + .. + } + )); +} + #[test] fn tool_service_exposes_the_provider_seam() { let Some(path) = mock_provider_path() else { diff --git a/crates/wright-lpp/src/error.rs b/crates/wright-lpp/src/error.rs index abb9030..bf218e3 100644 --- a/crates/wright-lpp/src/error.rs +++ b/crates/wright-lpp/src/error.rs @@ -174,6 +174,35 @@ pub enum ProviderError { supported: Vec, message: String, }, + /// A local provider could not be resolved or installed. + Local { + kind: LocalProviderErrorKind, + message: String, + }, +} + +/// Machine-readable local provider resolution failure classes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LocalProviderErrorKind { + Missing, + UnsupportedPlatform, + Offline, + Download, + Integrity, + Install, +} + +impl LocalProviderErrorKind { + pub fn code(&self) -> &'static str { + match self { + Self::Missing => "provider-missing", + Self::UnsupportedPlatform => "provider-unsupported-platform", + Self::Offline => "provider-offline", + Self::Download => "provider-download", + Self::Integrity => "provider-integrity", + Self::Install => "provider-install", + } + } } impl ProviderError { @@ -192,6 +221,7 @@ impl ProviderError { ProviderError::AlreadyInitialized => "provider-already-initialized", ProviderError::ShutDown { .. } => "provider-shutdown", ProviderError::ProtocolVersionMismatch { .. } => "protocol-version-mismatch", + ProviderError::Local { kind, .. } => kind.code(), } } @@ -277,6 +307,7 @@ impl fmt::Display for ProviderError { ) } } + ProviderError::Local { message, .. } => write!(f, "local provider failure: {message}"), } } } diff --git a/crates/wright-lpp/src/lib.rs b/crates/wright-lpp/src/lib.rs index 6bebc12..5229219 100644 --- a/crates/wright-lpp/src/lib.rs +++ b/crates/wright-lpp/src/lib.rs @@ -68,7 +68,7 @@ pub const LPP_PROTOCOL_VERSION: &str = "1.0"; pub const LPP_CLIENT_NAME: &str = "wright"; pub use client::{ClientConfig, ClientPhase, JsonRpcClient}; -pub use error::{LppError, LppErrorKind, ProviderError}; +pub use error::{LocalProviderErrorKind, LppError, LppErrorKind, ProviderError}; pub use process::ChildProcess; pub use provider::{LanguageProvider, NegotiatedCapabilities, StdioLanguageProvider}; pub use registry::{ProviderConfig, ProviderRegistry, RegistryError}; diff --git a/crates/wright-lpp/src/registry.rs b/crates/wright-lpp/src/registry.rs index ad6d06d..9c3e030 100644 --- a/crates/wright-lpp/src/registry.rs +++ b/crates/wright-lpp/src/registry.rs @@ -89,6 +89,11 @@ impl ProviderRegistry { Ok(()) } + /// Whether a provider has been explicitly registered for `language_id`. + pub fn contains(&self, language_id: &str) -> bool { + self.providers.contains_key(language_id) + } + /// Spawn a fresh provider session for `language_id`. /// /// Refuses explicitly when no provider is configured for the id From 74c3e13c63a1bb074f14f41c3071f4cffff12fea Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Thu, 3 Sep 2026 04:26:50 +0800 Subject: [PATCH 2/3] fix(provider): support Windows OPY provider archives Refs #244 --- Cargo.lock | 76 +++++++++++ Cargo.toml | 1 + crates/wright-driver/Cargo.toml | 1 + crates/wright-driver/src/opy_provider.rs | 158 ++++++++++++++++++++--- 4 files changed, 220 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6951fce..b138e2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,6 +87,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -310,6 +319,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -343,6 +358,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -1609,6 +1635,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "time" version = "0.3.45" @@ -2160,6 +2206,7 @@ dependencies = [ "wright-opy", "wright-ostw", "wright-transform", + "zip", ] [[package]] @@ -2360,6 +2407,23 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.7" @@ -2371,3 +2435,15 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 0da06f8..d45d4bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ sha2 = "0.10" flate2 = "1" tar = "0.4" ureq = "2" +zip = { version = "2", default-features = false, features = ["deflate"] } wright-analyzer = { path = "crates/wright-analyzer" } wright-core = { path = "crates/wright-core" } wright-driver = { path = "crates/wright-driver" } diff --git a/crates/wright-driver/Cargo.toml b/crates/wright-driver/Cargo.toml index f83e599..e07dfac 100644 --- a/crates/wright-driver/Cargo.toml +++ b/crates/wright-driver/Cargo.toml @@ -17,6 +17,7 @@ sha2.workspace = true flate2.workspace = true tar.workspace = true ureq.workspace = true +zip.workspace = true wright-analyzer.workspace = true wright-core.workspace = true wright-ir.workspace = true diff --git a/crates/wright-driver/src/opy_provider.rs b/crates/wright-driver/src/opy_provider.rs index b6f11a3..d983f2c 100644 --- a/crates/wright-driver/src/opy_provider.rs +++ b/crates/wright-driver/src/opy_provider.rs @@ -12,10 +12,10 @@ use std::time::Duration; use flate2::read::GzDecoder; use sha2::{Digest, Sha256}; +use zip::ZipArchive; const DEFAULT_API_URL: &str = "https://api.github.com/repos/wrightkit/opy-rs/releases/latest"; const DEFAULT_BASE_URL: &str = "https://github.com/wrightkit/opy-rs/releases/download"; -const PROVIDER_BINARY: &str = "opy-provider"; const MAX_DOWNLOAD_BYTES: u64 = 128 * 1024 * 1024; /// The LPP language id served by the first-party OPY provider. @@ -178,7 +178,7 @@ impl OpyProviderResolver { self.store_dir .join(version) .join(target) - .join(PROVIDER_BINARY) + .join(provider_binary(target)) } fn install_release( @@ -190,7 +190,10 @@ impl OpyProviderResolver { Some(version) => normalize_version(version)?, None => self.fetch_latest_version()?, }; - let archive_name = format!("opy-provider-{version}-{target}.tar.gz"); + let archive_name = format!( + "opy-provider-{version}-{target}.{}", + archive_extension(target) + ); let archive_url = format!( "{}/v{version}/{archive_name}", self.base_url.trim_end_matches('/') @@ -240,7 +243,7 @@ impl OpyProviderResolver { })?; let final_dir = self.store_dir.join(version).join(target); - let final_executable = final_dir.join(PROVIDER_BINARY); + let final_executable = final_dir.join(provider_binary(target)); if final_executable.is_file() { if !is_executable(&final_executable) { return Err(OpyProviderError::install(format!( @@ -424,8 +427,9 @@ fn target_for(os: &str, arch: &str) -> Result { ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu".to_string()), ("macos", "x86_64") => Ok("x86_64-apple-darwin".to_string()), ("macos", "aarch64") => Ok("aarch64-apple-darwin".to_string()), + ("windows", "x86_64") => Ok("x86_64-pc-windows-msvc".to_string()), _ => Err(OpyProviderError::unsupported(format!( - "unsupported OPY provider target {os}/{arch}; supported targets are linux/x86_64 and darwin x86_64/aarch64" + "unsupported OPY provider target {os}/{arch}; supported targets are linux/x86_64, darwin x86_64/aarch64, and windows x86_64" ))), } } @@ -565,6 +569,10 @@ fn extract_provider( target: &str, ) -> Result<(), OpyProviderError> { let expected_root = format!("opy-provider-{version}-{target}"); + let binary = provider_binary(target); + if archive_extension(target) == "zip" { + return extract_provider_zip(archive, destination, &expected_root, binary); + } let decoder = GzDecoder::new(archive); let mut archive = tar::Archive::new(decoder); let mut found = false; @@ -579,7 +587,7 @@ fn extract_provider( OpyProviderError::install(format!("cannot inspect OPY provider archive path: {error}")) })?; let components: Vec<_> = path.components().collect(); - let expected_path = Path::new(&expected_root).join(PROVIDER_BINARY); + let expected_path = Path::new(&expected_root).join(binary); let is_root = components.len() == 1 && components[0] == std::path::Component::Normal(expected_root.as_ref()); if path == expected_path { @@ -588,7 +596,7 @@ fn extract_provider( "OPY provider archive executable is not a regular file", )); } - let output = destination.join(PROVIDER_BINARY); + let output = destination.join(binary); let mut file = std::fs::File::create(&output).map_err(|error| { OpyProviderError::install(format!( "cannot create staged OPY provider '{}': {error}", @@ -627,7 +635,7 @@ fn extract_provider( )); } } - if !found || !is_executable(&destination.join(PROVIDER_BINARY)) { + if !found || !is_executable(&destination.join(binary)) { return Err(OpyProviderError::install( "OPY provider archive does not contain an executable provider", )); @@ -635,9 +643,101 @@ fn extract_provider( Ok(()) } +fn extract_provider_zip( + archive: &[u8], + destination: &Path, + expected_root: &str, + binary: &str, +) -> Result<(), OpyProviderError> { + let mut archive = ZipArchive::new(std::io::Cursor::new(archive)).map_err(|error| { + OpyProviderError::install(format!("cannot read OPY provider archive: {error}")) + })?; + let expected_path = format!("{expected_root}/{binary}"); + let mut found = false; + for index in 0..archive.len() { + let mut entry = archive.by_index(index).map_err(|error| { + OpyProviderError::install(format!("cannot read OPY provider archive entry: {error}")) + })?; + let name = entry.name(); + if name == expected_root || name == format!("{expected_root}/") { + if !entry.is_dir() { + return Err(OpyProviderError::install( + "OPY provider archive root is not a directory", + )); + } + continue; + } + if name != expected_path { + return Err(OpyProviderError::install( + "OPY provider archive contains an unexpected path", + )); + } + if entry.is_dir() { + return Err(OpyProviderError::install( + "OPY provider archive executable is not a regular file", + )); + } + let output = destination.join(binary); + let mut file = std::fs::File::create(&output).map_err(|error| { + OpyProviderError::install(format!( + "cannot create staged OPY provider '{}': {error}", + output.display() + )) + })?; + std::io::copy(&mut entry, &mut file).map_err(|error| { + OpyProviderError::install(format!( + "cannot unpack staged OPY provider '{}': {error}", + output.display() + )) + })?; + file.sync_all().map_err(|error| { + OpyProviderError::install(format!( + "cannot persist staged OPY provider '{}': {error}", + output.display() + )) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&output, std::fs::Permissions::from_mode(0o755)).map_err( + |error| { + OpyProviderError::install(format!( + "cannot make staged OPY provider executable '{}': {error}", + output.display() + )) + }, + )?; + } + found = true; + } + if !found || !is_executable(&destination.join(binary)) { + return Err(OpyProviderError::install( + "OPY provider archive does not contain an executable provider", + )); + } + Ok(()) +} + +fn provider_binary(target: &str) -> &'static str { + if target == "x86_64-pc-windows-msvc" { + "opy-provider.exe" + } else { + "opy-provider" + } +} + +fn archive_extension(target: &str) -> &'static str { + if target == "x86_64-pc-windows-msvc" { + "zip" + } else { + "tar.gz" + } +} + #[cfg(test)] mod tests { use super::*; + use std::io::Cursor; use std::net::{TcpListener, TcpStream}; use std::sync::{ Arc, @@ -655,17 +755,25 @@ mod tests { } fn archive(version: &str, target: &str, body: &[u8]) -> Vec { + let root = format!("opy-provider-{version}-{target}"); + let binary = provider_binary(target); + if archive_extension(target) == "zip" { + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default().unix_permissions(0o755); + writer.add_directory(format!("{root}/"), options).unwrap(); + writer + .start_file(format!("{root}/{binary}"), options) + .unwrap(); + writer.write_all(body).unwrap(); + return writer.finish().unwrap().into_inner(); + } let mut builder = tar::Builder::new(Vec::new()); 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, - format!("opy-provider-{version}-{target}/{PROVIDER_BINARY}"), - body, - ) + .append_data(&mut header, format!("{root}/{binary}"), body) .unwrap(); let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); encoder.write_all(&builder.into_inner().unwrap()).unwrap(); @@ -675,7 +783,7 @@ mod tests { #[test] fn explicit_provider_wins_without_target_or_network_access() { let root = test_root("explicit"); - let executable = root.join(PROVIDER_BINARY); + let executable = root.join(provider_binary("x86_64-unknown-linux-gnu")); std::fs::write(&executable, b"#!/bin/sh\n").unwrap(); make_executable(&executable); let resolver = OpyProviderResolver::new(root.join("store")).with_target("unsupported"); @@ -756,8 +864,26 @@ mod tests { .update(Some("1.0.0")) .unwrap_err(); assert_eq!(error.code(), "provider-unsupported-platform"); - let error = target_for("windows", "x86_64").unwrap_err(); - assert_eq!(error.code(), "provider-unsupported-platform"); + assert_eq!( + target_for("windows", "x86_64").unwrap(), + "x86_64-pc-windows-msvc" + ); + } + + #[test] + fn windows_target_uses_exe_and_zip_archive() { + let target = target_for("windows", "x86_64").unwrap(); + assert_eq!(target, "x86_64-pc-windows-msvc"); + assert_eq!(provider_binary(&target), "opy-provider.exe"); + assert_eq!(archive_extension(&target), "zip"); + + let root = test_root("windows"); + let resolver = OpyProviderResolver::new(&root).with_target(&target); + let bytes = archive("1.0.0", &target, b"windows-provider"); + resolver.install_archive("1.0.0", &target, &bytes).unwrap(); + let executable = root.join("1.0.0").join(&target).join("opy-provider.exe"); + assert_eq!(std::fs::read(executable).unwrap(), b"windows-provider"); + let _ = std::fs::remove_dir_all(root); } fn hex(bytes: &[u8]) -> String { From c6dbe54c0355e564f9ff2c5e271718e042a8dc0d Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Thu, 3 Sep 2026 04:51:42 +0800 Subject: [PATCH 3/3] fix(provider): route Windows through resolver Refs #244 --- crates/wright-driver/src/opy_provider.rs | 34 +++++++++++++++++++----- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/wright-driver/src/opy_provider.rs b/crates/wright-driver/src/opy_provider.rs index d983f2c..34d2a19 100644 --- a/crates/wright-driver/src/opy_provider.rs +++ b/crates/wright-driver/src/opy_provider.rs @@ -144,7 +144,10 @@ impl OpyProviderResolver { }?; if matches!( target.as_str(), - "x86_64-unknown-linux-gnu" | "x86_64-apple-darwin" | "aarch64-apple-darwin" + "x86_64-unknown-linux-gnu" + | "x86_64-apple-darwin" + | "aarch64-apple-darwin" + | "x86_64-pc-windows-msvc" ) { Ok(target) } else { @@ -878,11 +881,30 @@ mod tests { assert_eq!(archive_extension(&target), "zip"); let root = test_root("windows"); - let resolver = OpyProviderResolver::new(&root).with_target(&target); - let bytes = archive("1.0.0", &target, b"windows-provider"); - resolver.install_archive("1.0.0", &target, &bytes).unwrap(); - let executable = root.join("1.0.0").join(&target).join("opy-provider.exe"); - assert_eq!(std::fs::read(executable).unwrap(), b"windows-provider"); + let version = "1.0.0"; + let bytes = archive(version, &target, b"windows-provider"); + let checksum = format!("{} opy-provider-{version}-{target}.zip\n", hex(&bytes)); + let (base_url, requests, server) = test_server( + format!(r#"{{"tag_name":"v{version}"}}"#).into_bytes(), + bytes, + checksum.into_bytes(), + ); + let resolver = OpyProviderResolver::new(&root) + .with_target(&target) + .with_release_urls(format!("{base_url}/latest"), &base_url); + let updated = resolver.update(None).unwrap(); + server.join().unwrap(); + assert_eq!(requests.load(Ordering::Relaxed), 3); + assert_eq!(updated.version.as_deref(), Some(version)); + assert_eq!( + std::fs::read(&updated.executable).unwrap(), + b"windows-provider" + ); + let resolved = resolver + .with_release_urls("http://127.0.0.1:1/latest", "http://127.0.0.1:1") + .resolve(None) + .unwrap(); + assert_eq!(resolved, updated); let _ = std::fs::remove_dir_all(root); }