From d3fc00890155b22f603010ae09319b531ff8853c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E8=BE=89?= Date: Fri, 29 May 2026 17:05:36 +0800 Subject: [PATCH] feat: add ling self update command --- .github/workflows/release.yml | 17 + Cargo.lock | 280 ++++++++++++++++ README.md | 13 + crates/ling/Cargo.toml | 6 + crates/ling/src/main.rs | 4 + crates/ling/src/update.rs | 595 ++++++++++++++++++++++++++++++++++ 6 files changed, 915 insertions(+) create mode 100644 crates/ling/src/update.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02e7a04..d232952 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,6 +86,23 @@ jobs: with: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + - name: Verify release tag matches Cargo version + shell: bash + run: | + set -euo pipefail + + tag="${GITHUB_REF_NAME}" + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + tag="${{ inputs.tag }}" + fi + + cargo_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' crates/ling/Cargo.toml | head -n 1)" + expected_tag="v${cargo_version}" + if [[ "${tag}" != "${expected_tag}" ]]; then + echo "Release tag ${tag} must match crates/ling version ${expected_tag}" >&2 + exit 1 + fi + - name: Install Rust target run: rustup target add ${{ matrix.target }} diff --git a/Cargo.lock b/Cargo.lock index 836b6e2..8d4d54b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "anstream" version = "1.0.0" @@ -58,6 +64,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[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" @@ -76,6 +91,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -156,6 +180,61 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "5.0.1" @@ -188,12 +267,54 @@ dependencies = [ "syn", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -236,6 +357,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -263,6 +394,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -470,6 +607,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -522,15 +669,21 @@ dependencies = [ "anyhow", "clap", "dirs", + "flate2", "libc", "ling-plugin-app", "ling-plugin-wiki", "reqwest", "rpassword", + "semver", "serde", "serde_json", + "sha2", + "tar", + "tempfile", "tokio", "windows-sys 0.61.2", + "zip", ] [[package]] @@ -554,6 +707,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -578,6 +737,16 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -835,6 +1004,19 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -882,6 +1064,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -937,12 +1125,29 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -1014,6 +1219,30 @@ dependencies = [ "syn", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1185,6 +1414,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1227,6 +1462,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -1584,6 +1825,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.2" @@ -1687,8 +1938,37 @@ dependencies = [ "syn", ] +[[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 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[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/README.md b/README.md index dcc4459..c6a0bf8 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ ListenAI 本地 CLI 工具。使用 ListenAI API Key 登录后,可以在终端 - `ling app list`:查看平台应用列表,默认输出终端表格,`--json` 输出原始 JSON。 - `ling app inspect `:查看单个应用摘要,默认输出精简配置视图,`--json` 输出原始 JSON。 - `ling wiki search <关键词...>`:搜索 ListenAI 文档中心,默认输出标题和 URL;多关键词按词分组展示,`--json` 输出完整 JSON。 +- `ling update`:把当前 `ling` 可执行文件更新到最新 GitHub Release。 ## 快速安装 @@ -34,6 +35,18 @@ curl -fsSL https://raw.githubusercontent.com/LISTENAI/ling/main/install.sh | LIN $env:LING_VERSION = "v0.1.0"; irm https://raw.githubusercontent.com/LISTENAI/ling/main/install.ps1 | iex ``` +已安装后可直接更新到最新 GitHub Release: + +```bash +ling update +``` + +如果访问私有仓库或遇到 GitHub API 限流,可以设置 `GH_TOKEN` 或 `GITHUB_TOKEN` 后再执行: + +```bash +GH_TOKEN='' ling update +``` + ## 本地开发 开发机上默认安装到 `~/.local/bin/ling`,和快速安装脚本保持一致: diff --git a/crates/ling/Cargo.toml b/crates/ling/Cargo.toml index 99cfa60..2276d48 100644 --- a/crates/ling/Cargo.toml +++ b/crates/ling/Cargo.toml @@ -9,14 +9,20 @@ publish = false anyhow = "1" clap = { version = "4.5", features = ["derive", "env"] } dirs = "5" +flate2 = "1" libc = "0.2" ling-plugin-app = { path = "../ling-plugin-app" } ling-plugin-wiki = { path = "../ling-plugin-wiki" } rpassword = "7.3" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" +tar = "0.4" +tempfile = "3" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_System_Console"] } +zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/crates/ling/src/main.rs b/crates/ling/src/main.rs index 5ce3442..16710d5 100644 --- a/crates/ling/src/main.rs +++ b/crates/ling/src/main.rs @@ -2,6 +2,7 @@ mod api_key; mod config; mod secret_prompt; mod terminal; +mod update; mod v1_api; use anyhow::Result; @@ -58,6 +59,8 @@ enum Command { App(AppArgs), /// Search ListenAI documentation center. Wiki(WikiArgs), + /// Update ling to the latest GitHub release. + Update, } #[derive(Debug, Args)] @@ -172,6 +175,7 @@ async fn run(cli: Cli) -> Result<()> { Command::Chat(args) => chat_command(cli.api_base_url, args).await, Command::App(args) => app_command(cli.api_base_url, args).await, Command::Wiki(args) => wiki_command(cli.docs_graphql_url, cli.docs_base_url, args).await, + Command::Update => update::run().await, } } diff --git a/crates/ling/src/update.rs b/crates/ling/src/update.rs new file mode 100644 index 0000000..9deeeba --- /dev/null +++ b/crates/ling/src/update.rs @@ -0,0 +1,595 @@ +use anyhow::{anyhow, bail, ensure, Context, Result}; +use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, USER_AGENT}; +use semver::Version; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::env; +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const BIN: &str = "ling"; +const DEFAULT_REPO: &str = "LISTENAI/ling"; +const GITHUB_API_VERSION: &str = "2022-11-28"; + +#[derive(Debug, Deserialize)] +struct Release { + tag_name: String, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct ReleaseAsset { + name: String, + url: String, + #[serde(default)] + browser_download_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ReleaseTarget { + triple: String, + archive_ext: &'static str, + bin_ext: &'static str, +} + +pub async fn run() -> Result<()> { + let repo = env::var("LING_REPO").unwrap_or_else(|_| DEFAULT_REPO.to_owned()); + let token = github_token(); + let client = github_client()?; + let release = fetch_latest_release(&client, token.as_deref(), &repo).await?; + let current_version = parse_version(env!("CARGO_PKG_VERSION"))?; + let latest_version = parse_release_version(&release.tag_name)?; + + if !update_needed(¤t_version, &latest_version) { + println!("ling 已是最新版本 ({})", release.tag_name); + return Ok(()); + } + + let target = detect_target()?; + let asset_name = release_asset_name(&release.tag_name, &target); + let asset = release + .asset(&asset_name) + .ok_or_else(|| anyhow!("release asset not found: {asset_name}"))?; + let checksum_asset = release + .asset("SHA256SUMS") + .ok_or_else(|| anyhow!("release asset not found: SHA256SUMS"))?; + + println!( + "Updating ling {} -> {} ({})", + env!("CARGO_PKG_VERSION"), + release.tag_name, + target.triple + ); + println!("Downloading {asset_name}"); + + let archive = download_asset(&client, token.as_deref(), asset).await?; + let checksums = download_asset(&client, token.as_deref(), checksum_asset).await?; + verify_archive_checksum(&archive, &checksums, &asset_name)?; + println!("Checksum verified"); + + let temp_dir = tempfile::tempdir().context("failed to create temporary directory")?; + let new_binary = extract_archive(&archive, &target, &release.tag_name, temp_dir.path())?; + make_executable(&new_binary)?; + verify_binary(&new_binary)?; + + let current_exe = replace_current_exe(&new_binary)?; + println!( + "Updated ling to {} at {}", + release.tag_name, + current_exe.display() + ); + Ok(()) +} + +impl Release { + fn asset(&self, name: &str) -> Option<&ReleaseAsset> { + self.assets.iter().find(|asset| asset.name == name) + } +} + +fn github_client() -> Result { + let mut headers = HeaderMap::new(); + headers.insert( + USER_AGENT, + HeaderValue::from_str(&format!("ling/{}", env!("CARGO_PKG_VERSION")))?, + ); + headers.insert( + "X-GitHub-Api-Version", + HeaderValue::from_static(GITHUB_API_VERSION), + ); + + reqwest::Client::builder() + .default_headers(headers) + .build() + .context("failed to create HTTP client") +} + +fn github_token() -> Option { + env::var("GH_TOKEN") + .ok() + .filter(|token| !token.trim().is_empty()) + .or_else(|| { + env::var("GITHUB_TOKEN") + .ok() + .filter(|token| !token.trim().is_empty()) + }) +} + +async fn fetch_latest_release( + client: &reqwest::Client, + token: Option<&str>, + repo: &str, +) -> Result { + let url = format!("https://api.github.com/repos/{repo}/releases/latest"); + let mut request = client + .get(url) + .header(ACCEPT, "application/vnd.github+json"); + if let Some(token) = token { + request = request.bearer_auth(token); + } + + let response = request + .send() + .await + .with_context(|| format!("failed to fetch latest release for {repo}"))?; + let status = response.status(); + let body = response + .text() + .await + .context("failed to read latest release response")?; + ensure!( + status.is_success(), + "failed to fetch latest release for {repo}: {}", + http_error(status, &body) + ); + serde_json::from_str(&body).context("failed to parse latest release response") +} + +async fn download_asset( + client: &reqwest::Client, + token: Option<&str>, + asset: &ReleaseAsset, +) -> Result> { + let url = if token.is_some() { + &asset.url + } else if !asset.browser_download_url.is_empty() { + &asset.browser_download_url + } else { + &asset.url + }; + + let mut request = client.get(url); + if let Some(token) = token { + request = request + .bearer_auth(token) + .header(ACCEPT, "application/octet-stream"); + } + + let response = request + .send() + .await + .with_context(|| format!("failed to download release asset {}", asset.name))?; + let status = response.status(); + let bytes = response + .bytes() + .await + .with_context(|| format!("failed to read release asset {}", asset.name))?; + ensure!( + status.is_success(), + "failed to download release asset {}: {}", + asset.name, + http_error(status, bytes_as_lossy_text(&bytes).as_ref()) + ); + Ok(bytes.to_vec()) +} + +fn http_error(status: reqwest::StatusCode, body: &str) -> String { + let message = serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| compact_body(body)); + if message.is_empty() { + format!("HTTP {status}") + } else { + format!("HTTP {status}: {message}") + } +} + +fn compact_body(body: &str) -> String { + const MAX_LEN: usize = 300; + let compact = body.split_whitespace().collect::>().join(" "); + let truncated = compact.chars().take(MAX_LEN).collect::(); + if truncated.len() < compact.len() { + format!("{truncated}...") + } else { + compact + } +} + +fn bytes_as_lossy_text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +fn detect_target() -> Result { + let libc = env::var("LING_LIBC").ok(); + target_for(env::consts::OS, env::consts::ARCH, libc.as_deref()) +} + +fn target_for(os: &str, arch: &str, libc: Option<&str>) -> Result { + let arch = normalize_arch(arch)?; + match os { + "macos" => Ok(ReleaseTarget { + triple: format!("{arch}-apple-darwin"), + archive_ext: "tar.gz", + bin_ext: "", + }), + "linux" => { + let libc = libc.unwrap_or("musl"); + ensure!( + matches!(libc, "musl" | "gnu"), + "unsupported LING_LIBC={libc}; expected musl or gnu" + ); + Ok(ReleaseTarget { + triple: format!("{arch}-unknown-linux-{libc}"), + archive_ext: "tar.gz", + bin_ext: "", + }) + } + "windows" => Ok(ReleaseTarget { + triple: format!("{arch}-pc-windows-msvc"), + archive_ext: "zip", + bin_ext: ".exe", + }), + other => bail!("unsupported OS: {other}"), + } +} + +fn normalize_arch(arch: &str) -> Result<&'static str> { + match arch { + "x86_64" | "amd64" | "x64" => Ok("x86_64"), + "aarch64" | "arm64" => Ok("aarch64"), + other => bail!("unsupported CPU architecture: {other}"), + } +} + +fn release_asset_name(tag: &str, target: &ReleaseTarget) -> String { + format!("{BIN}-{tag}-{}.{}", target.triple, target.archive_ext) +} + +fn parse_version(version: &str) -> Result { + Version::parse(version).with_context(|| format!("invalid version: {version}")) +} + +fn parse_release_version(tag: &str) -> Result { + parse_version(tag.trim_start_matches('v')) +} + +fn update_needed(current: &Version, latest: &Version) -> bool { + latest > current +} + +fn expected_checksum(checksums: &[u8], asset_name: &str) -> Result { + let checksums = std::str::from_utf8(checksums).context("SHA256SUMS is not valid UTF-8 text")?; + for line in checksums.lines() { + let mut parts = line.split_whitespace(); + let Some(hash) = parts.next() else { + continue; + }; + let Some(name) = parts.next() else { + continue; + }; + if name.trim_start_matches('*') == asset_name { + let hash = hash.to_ascii_lowercase(); + ensure!( + hash.len() == 64 && hash.chars().all(|ch| ch.is_ascii_hexdigit()), + "invalid SHA256 checksum for {asset_name}" + ); + return Ok(hash); + } + } + bail!("checksum for {asset_name} not found in SHA256SUMS") +} + +fn verify_archive_checksum(archive: &[u8], checksums: &[u8], asset_name: &str) -> Result<()> { + let expected = expected_checksum(checksums, asset_name)?; + let actual = sha256_hex(archive); + ensure!( + expected == actual, + "checksum mismatch for {asset_name}: expected {expected}, got {actual}" + ); + Ok(()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn extract_archive( + archive: &[u8], + target: &ReleaseTarget, + tag: &str, + temp_dir: &Path, +) -> Result { + match target.archive_ext { + "tar.gz" => extract_tar_gz(archive, temp_dir)?, + "zip" => extract_zip(archive, temp_dir)?, + other => bail!("unsupported archive type: {other}"), + } + + let binary = temp_dir + .join(format!("{BIN}-{tag}-{}", target.triple)) + .join(format!("{BIN}{}", target.bin_ext)); + ensure!( + binary.is_file(), + "binary not found in archive: {}", + binary.display() + ); + Ok(binary) +} + +fn extract_tar_gz(archive: &[u8], temp_dir: &Path) -> Result<()> { + let decoder = flate2::read::GzDecoder::new(Cursor::new(archive)); + let mut archive = tar::Archive::new(decoder); + archive + .unpack(temp_dir) + .context("failed to extract tar.gz release asset") +} + +#[cfg(windows)] +fn extract_zip(archive: &[u8], temp_dir: &Path) -> Result<()> { + let reader = Cursor::new(archive); + let mut archive = zip::ZipArchive::new(reader).context("failed to open zip release asset")?; + archive + .extract(temp_dir) + .context("failed to extract zip release asset") +} + +#[cfg(not(windows))] +fn extract_zip(_archive: &[u8], _temp_dir: &Path) -> Result<()> { + bail!("zip release assets are only supported on Windows") +} + +#[cfg(unix)] +fn make_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(path) + .with_context(|| format!("failed to read permissions for {}", path.display()))? + .permissions(); + permissions.set_mode(permissions.mode() | 0o755); + fs::set_permissions(path, permissions) + .with_context(|| format!("failed to mark {} executable", path.display())) +} + +#[cfg(not(unix))] +fn make_executable(_path: &Path) -> Result<()> { + Ok(()) +} + +fn verify_binary(path: &Path) -> Result<()> { + let status = Command::new(path) + .arg("--help") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .with_context(|| format!("failed to run downloaded binary {}", path.display()))?; + ensure!( + status.success(), + "downloaded binary failed to run: {}", + path.display() + ); + Ok(()) +} + +#[cfg(not(windows))] +fn replace_current_exe(new_binary: &Path) -> Result { + let current_exe = env::current_exe().context("failed to locate current executable")?; + let parent = current_exe + .parent() + .ok_or_else(|| anyhow!("current executable has no parent directory"))?; + let staging = parent.join(format!(".{BIN}-update-{}", std::process::id())); + + let result = (|| { + fs::copy(new_binary, &staging).with_context(|| { + format!( + "failed to stage update from {} to {}", + new_binary.display(), + staging.display() + ) + })?; + let permissions = fs::metadata(new_binary) + .with_context(|| format!("failed to read permissions for {}", new_binary.display()))? + .permissions(); + fs::set_permissions(&staging, permissions) + .with_context(|| format!("failed to set permissions for {}", staging.display()))?; + fs::rename(&staging, ¤t_exe).with_context(|| { + format!( + "failed to replace current executable {}", + current_exe.display() + ) + })?; + Ok(current_exe) + })(); + + if result.is_err() { + let _ = fs::remove_file(&staging); + } + result +} + +#[cfg(windows)] +fn replace_current_exe(new_binary: &Path) -> Result { + use std::os::windows::process::CommandExt; + + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + const DETACHED_PROCESS: u32 = 0x0000_0008; + + let current_exe = env::current_exe().context("failed to locate current executable")?; + let parent = current_exe + .parent() + .ok_or_else(|| anyhow!("current executable has no parent directory"))?; + let pid = std::process::id(); + let staged = parent.join(format!(".{BIN}-update-{pid}.exe")); + let helper = parent.join(format!(".{BIN}-update-{pid}.cmd")); + + fs::copy(new_binary, &staged).with_context(|| { + format!( + "failed to stage update from {} to {}", + new_binary.display(), + staged.display() + ) + })?; + fs::write(&helper, windows_helper_script()) + .with_context(|| format!("failed to write update helper {}", helper.display()))?; + + Command::new("cmd") + .arg("/C") + .arg(&helper) + .arg(pid.to_string()) + .arg(&staged) + .arg(¤t_exe) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) + .spawn() + .with_context(|| format!("failed to start update helper {}", helper.display()))?; + + println!("Windows 已安排在当前进程退出后完成替换。"); + Ok(current_exe) +} + +#[cfg(windows)] +fn windows_helper_script() -> &'static str { + r#"@echo off +setlocal +set "LING_UPDATE_PID=%~1" +set "LING_UPDATE_SRC=%~2" +set "LING_UPDATE_DST=%~3" +:wait +tasklist /FI "PID eq %LING_UPDATE_PID%" 2>NUL | findstr /R /C:"[ ]%LING_UPDATE_PID%[ ]" >NUL +if not errorlevel 1 ( + timeout /T 1 /NOBREAK >NUL + goto wait +) +move /Y "%LING_UPDATE_SRC%" "%LING_UPDATE_DST%" >NUL +del "%~f0" >NUL 2>NUL +"# +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_macos_arm64_target() { + let target = target_for("macos", "arm64", None).unwrap(); + assert_eq!(target.triple, "aarch64-apple-darwin"); + assert_eq!(target.archive_ext, "tar.gz"); + assert_eq!(target.bin_ext, ""); + } + + #[test] + fn resolves_macos_x86_64_target() { + let target = target_for("macos", "x86_64", None).unwrap(); + assert_eq!(target.triple, "x86_64-apple-darwin"); + } + + #[test] + fn resolves_linux_targets() { + let musl = target_for("linux", "aarch64", None).unwrap(); + assert_eq!(musl.triple, "aarch64-unknown-linux-musl"); + + let gnu = target_for("linux", "amd64", Some("gnu")).unwrap(); + assert_eq!(gnu.triple, "x86_64-unknown-linux-gnu"); + } + + #[test] + fn resolves_windows_targets() { + let target = target_for("windows", "x64", None).unwrap(); + assert_eq!(target.triple, "x86_64-pc-windows-msvc"); + assert_eq!(target.archive_ext, "zip"); + assert_eq!(target.bin_ext, ".exe"); + } + + #[test] + fn rejects_unsupported_linux_libc() { + let error = target_for("linux", "x86_64", Some("uclibc")).unwrap_err(); + assert!(error.to_string().contains("unsupported LING_LIBC")); + } + + #[test] + fn builds_release_asset_name() { + let target = ReleaseTarget { + triple: "aarch64-apple-darwin".to_owned(), + archive_ext: "tar.gz", + bin_ext: "", + }; + assert_eq!( + release_asset_name("v0.2.0", &target), + "ling-v0.2.0-aarch64-apple-darwin.tar.gz" + ); + } + + #[test] + fn parses_release_versions_with_optional_v_prefix() { + assert_eq!( + parse_release_version("v0.2.0").unwrap(), + Version::new(0, 2, 0) + ); + assert_eq!( + parse_release_version("0.2.0").unwrap(), + Version::new(0, 2, 0) + ); + } + + #[test] + fn compares_current_and_latest_versions() { + let current = Version::new(0, 2, 0); + assert!(update_needed(¤t, &Version::new(0, 2, 1))); + assert!(!update_needed(¤t, &Version::new(0, 2, 0))); + assert!(!update_needed(¤t, &Version::new(0, 1, 9))); + } + + #[test] + fn parses_checksum_for_asset() { + let checksum = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + let checksums = format!( + "{checksum} ling-v0.2.0-aarch64-apple-darwin.tar.gz\n\ + 0000000000000000000000000000000000000000000000000000000000000000 other\n" + ); + assert_eq!( + expected_checksum( + checksums.as_bytes(), + "ling-v0.2.0-aarch64-apple-darwin.tar.gz" + ) + .unwrap(), + checksum + ); + } + + #[test] + fn verifies_archive_checksum() { + let asset = "ling-v0.2.0-aarch64-apple-darwin.tar.gz"; + let checksum = sha256_hex(b"hello"); + let checksums = format!("{checksum} {asset}\n"); + verify_archive_checksum(b"hello", checksums.as_bytes(), asset).unwrap(); + } + + #[test] + fn rejects_checksum_mismatch() { + let asset = "ling-v0.2.0-aarch64-apple-darwin.tar.gz"; + let checksums = format!("{} {asset}\n", "0".repeat(64)); + let error = verify_archive_checksum(b"hello", checksums.as_bytes(), asset).unwrap_err(); + assert!(error.to_string().contains("checksum mismatch")); + } +}