From 2e3b359937d7a4c0285dc193d2c056edf2ddd32b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Szil=C3=A1gyi?= Date: Fri, 25 Sep 2026 17:49:22 +0300 Subject: [PATCH] doctor, help, update: announce newer ark releases Co-authored-by: Astra Co-authored-by: Claude Opus 5.5 (1M context) --- Cargo.lock | 5 + Cargo.toml | 8 +- src/doctor.rs | 42 ++- src/help.rs | 2 +- src/help/agents.md | 14 +- src/help/output.md | 23 +- src/logging.rs | 34 +- src/main.rs | 16 + src/output/human.rs | 6 +- src/update.rs | 781 ++++++++++++++++++++++++++++++++++++++++++++ tests/palette.rs | 141 ++++++++ 11 files changed, 1044 insertions(+), 28 deletions(-) create mode 100644 src/update.rs diff --git a/Cargo.lock b/Cargo.lock index e5d77e9..d66e239 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -183,6 +183,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -1382,6 +1383,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" diff --git a/Cargo.toml b/Cargo.toml index 87a1f24..3dd1307 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ path = ".github/packaging/winget/main.rs" test = true [dependencies] -chrono = "0.4.41" +chrono = { version = "0.4.41", features = ["serde"] } clap = { version = "4.5.60", features = ["derive"] } darkbio-crypto = { version = "0.18.2", features = ["cbor", "xdsa"] } darkbio-trust = { version = "0.5.1", features = ["release", "staging", "develop"] } @@ -43,17 +43,15 @@ qrcode = { version = "0.14", default-features = false } ureq = { version = "3.4", default-features = false, features = ["rustls"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } +semver = { version = "1.0.28", features = ["serde"] } sha2 = "0.10" thiserror = "2.0" tracing = "0.1" tracing-subscriber = "0.3.23" tungstenite = { version = "0.30", features = ["rustls-tls-webpki-roots"] } -[dev-dependencies] -semver = "1" - [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_System_Console"] } +windows-sys = { version = "0.61", features = ["Win32_System_Console", "Win32_System_Threading"] } [target.'cfg(unix)'.dependencies] signal-hook = "0.3" diff --git a/src/doctor.rs b/src/doctor.rs index f82ee76..34dac8a 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -6,7 +6,7 @@ //! Independent diagnostics composed from connection primitives. -use crate::{context::Context, error::Error, firmware::Packages}; +use crate::{context::Context, error::Error, firmware::Packages, update}; use darkbio_connect::schema; use serde_json::{Value, json}; @@ -28,6 +28,7 @@ pub(crate) fn run(context: &Context) -> Result<(), Error> { darkbio_connect::wire::VERSION, ), ); + checks.update(); let mut discovered = 0; for (name, result) in [ ("usb", darkbio_connect::hardware::list()), @@ -197,10 +198,49 @@ struct Checks<'a> { failure: Option, } impl Checks<'_> { + /// Looks up the newest ark afresh. A newer one is a warn and a failed + /// lookup a skip, so neither sets the exit code. + fn update(&mut self) { + // Under CI nothing is looked up + if update::disabled() { + self.skip("update", "CI is set"); + return; + } + + // Look up within --timeout, keeping the answer for later commands + let running = update::running(); + let channel = update::Channel::for_version(&running); + let asked = chrono::Utc::now(); + let result = update::refresh( + &crate::data::cache::directory(), + channel, + asked, + std::time::Duration::from_secs(self.context.options.timeout), + ); + + // Only a newer version needs action; an unpublished local build is current too + match result { + Ok(newest) if newest.cmp_precedence(&running).is_gt() => self.warn( + "update", + &format!("ark {newest} is available, this is {running}"), + &update::hint(channel), + ), + Ok(_) => self.ok( + "update", + &format!("ark {running} is the newest {}", channel.description()), + ), + Err(error) => self.skip("update", error), + } + } + /// Records a successful diagnostic with its observed detail. fn ok(&mut self, name: &str, detail: &str) { self.add(name, "ok", detail, None); } + /// Records something to act on with its hint, never failing the command. + fn warn(&mut self, name: &str, detail: &str, hint: &str) { + self.add(name, "warn", detail, Some(hint)); + } /// Records an unmet prerequisite without making the command fail by itself. fn skip(&mut self, name: &str, detail: &str) { self.add(name, "skip", detail, None); diff --git a/src/help.rs b/src/help.rs index 4b876ac..8a1b275 100644 --- a/src/help.rs +++ b/src/help.rs @@ -217,7 +217,7 @@ fn decorate(command: &mut clap::Command, parent: &str, theme: &Theme) { "nothing; unavailable checks are skipped", "none; develop and staging package hosts may need browser login", "seconds per check", - "checks: result, name, detail, hint; JSON: tool, connect, wire, minimum_firmware, minimum_develop_publish, checks", + "checks: result (ok, warn, fail or skip), name, detail, hint; JSON: tool, connect, wire, minimum_firmware, minimum_develop_publish, checks", "ark doctor\nark doctor --json", ), "completions" => ( diff --git a/src/help/agents.md b/src/help/agents.md index a858b82..a7d8877 100644 --- a/src/help/agents.md +++ b/src/help/agents.md @@ -41,11 +41,15 @@ on their phone, in Ark Companion. You cannot approve for them. ## Reading results -An approve event means the owner needs to act. CLI error codes are stable; -`ark help output` lists them with next steps. error[ark]: passes through the -Ark's own verdict; read its message, never match its number or wording. -Partial results survive errors. JSON also preserves the latest partial result -on interruption. +An approve event means the owner needs to act. A note that a newer ark is +available names the upgrade and repeats on every command until it happens, so +pass it on to the person; upgrading is their call. doctor reports the same as +its update check, and a nonempty CI turns the note off. + +CLI error codes are stable; `ark help output` lists them with next steps. +error[ark]: passes through the Ark's own verdict; read its message, never +match its number or wording. Partial results survive errors. JSON also +preserves the latest partial result on interruption. Without --json, app reports stream raw to stdout. The app's own stderr is announced, then written unprefixed. With --json both app streams are in the diff --git a/src/help/output.md b/src/help/output.md index 14e6fa9..f0fc6c0 100644 --- a/src/help/output.md +++ b/src/help/output.md @@ -50,9 +50,10 @@ one key, error, with the fields listed under Error codes: -q drops progress, notes, warnings and steps. Errors, hints, owner approval instructions, diagnostic logs and app output remain. -v enables step narration. ---log debug enables connect diagnostics; --log trace enables connect and wire -traces. They are independent of -v. HTTP and subprocess log targets are excluded -so authorization headers and package login credentials cannot enter the stream. +--log debug enables update and connect diagnostics; --log trace adds wire +traces. They are independent of -v. HTTP and subprocess log targets are +excluded so authorization headers and package login credentials cannot enter +the stream. Terminal progress refreshes once a second, showing new steps and completion at once. Redirected progress and JSON report at ten-percent boundaries or every @@ -70,6 +71,22 @@ error events carry an error object. Progress messages describe transfers, processing phases or elapsed time. Their wording is for reading, not a structured progress API. +## New releases + +At most once an hour, ark asks GitHub for its newest release. A release build +reads the redirect at https://github.com/dark-bio/cli/releases/latest, and a +development build reads the release list from api.github.com. The request +carries nothing about this computer, its Arks or the running version. It runs +in a detached copy of ark that exits within 30 s, so no command waits for it. +The answer is kept in update.json in ark's cache directory, and a nonempty CI +turns the lookup off. + +While the kept answer names a newer version, every command except help, +completions, --version and doctor starts with a note naming both versions and +how to upgrade. Under --json it is an ordinary note event. The note never +changes the result or the exit code, and -q hides it. doctor looks up afresh +and reports the answer as its update check. + ## Error codes The code in error[code]: is stable and its exit code is the class below. This diff --git a/src/logging.rs b/src/logging.rs index 225395e..72cc90f 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -4,8 +4,8 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//! Only connect and wire diagnostics enter the CLI's log stream. HTTP and -//! subprocess logging is excluded so authorization headers cannot appear. +//! Only update, connect and wire diagnostics enter the CLI's log stream. HTTP +//! and subprocess logging is excluded so authorization headers cannot appear. use crate::{args::Log as Level, output::Output}; use serde_json::{Map, Value, json}; @@ -29,22 +29,27 @@ pub(crate) fn init(output: Output, verbose: bool, level: Option) { .try_init(); } -/// Rejects every target outside connect and wire, regardless of diagnostic level. +/// Rejects every target outside update, connect and wire, regardless of diagnostic level. fn enabled(target: &str, severity: tracing::Level, verbose: bool, level: Option) -> bool { if target == "darkbio_connect::setup" { return verbose; } match level { Some(Level::Debug) => { - (target == "darkbio_connect" || target.starts_with("darkbio_connect::")) + (target == "ark::update" + || target == "darkbio_connect" + || target.starts_with("darkbio_connect::")) && severity <= tracing::Level::DEBUG } - Some(Level::Trace) => ["darkbio_connect", "darkbio_wire"].iter().any(|name| { - target == *name - || target - .strip_prefix(name) - .is_some_and(|suffix| suffix.starts_with("::")) - }), + Some(Level::Trace) => { + target == "ark::update" + || ["darkbio_connect", "darkbio_wire"].iter().any(|name| { + target == *name + || target + .strip_prefix(name) + .is_some_and(|suffix| suffix.starts_with("::")) + }) + } None => false, } } @@ -109,10 +114,16 @@ impl Visit for Fields { mod tests { use super::*; + /// Diagnostic selection includes update failures without exposing HTTP or subprocess logs. #[test] - fn diagnostics_are_separate_from_steps_and_exclude_http_and_subprocesses() { + fn test_diagnostics_are_separate_from_steps_and_exclude_http_and_subprocesses() { for level in [None, Some(Level::Debug), Some(Level::Trace)] { for verbose in [false, true] { + assert_eq!( + enabled("ark::update", tracing::Level::DEBUG, verbose, level), + level.is_some(), + "{level:?}, verbose={verbose}" + ); assert_eq!( enabled( "darkbio_connect::setup", @@ -127,6 +138,7 @@ mod tests { "hyper::client", "rustls", "ark::access", + "ark::update_http", "std::process", "darkbio_connect_http", ] { diff --git a/src/main.rs b/src/main.rs index 6fd18c7..3d25892 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ mod output; mod pairing; mod progress; mod style; +mod update; use args::{Cli, Command}; use clap::{FromArgMatches, Parser}; @@ -35,6 +36,10 @@ use std::process::ExitCode; /// Help and usage failures honor stream formatting even before typed parsing succeeds. fn main() -> ExitCode { let arguments: Vec<_> = std::env::args_os().collect(); + if arguments.len() == 2 && arguments[1] == update::ENTRY_POINT { + update::run(); + return ExitCode::SUCCESS; + } let json = arguments .iter() .skip(1) @@ -89,6 +94,17 @@ fn main() -> ExitCode { output, interrupt, }; + // Valid commands print the release note, except help, completions, --version, a bare run and doctor + if validation.is_ok() + && !cli.help + && !cli.version + && !matches!( + &cli.command, + None | Some(Command::Help { .. } | Command::Completions { .. } | Command::Doctor) + ) + { + update::start(&context.output, chrono::Utc::now()); + } let result = if let Err(error) = validation { Err(error) } else if cli.help { diff --git a/src/output/human.rs b/src/output/human.rs index 8729e35..c5bc717 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -459,17 +459,19 @@ mod tests { ); } + /// Every result carries its own mark, and each hint stays under its check. #[test] - fn checklist_keeps_skips_explicit_and_hints_local() { + fn test_checklist_keeps_skips_explicit_and_hints_local() { let theme = Theme::test(80, Color::Basic, true); let rows = [ json!({"name":"usb","result":"ok","detail":"1 device found","hint":null}), json!({"name":"relay","result":"fail","detail":"no answer","hint":"open `Ark Companion`"}), json!({"name":"slots","result":"skip","detail":"Ark locked","hint":null}), + json!({"name":"tool","result":"warn","detail":"new release","hint":"run `brew upgrade ark-cli`"}), ]; assert_eq!( checklist(&theme, &rows), - " \x1b[1m\u{2713} usb\x1b[0m 1 device found\n \x1b[1m\u{2717} relay\x1b[0m no answer\n \x1b[1mhint:\x1b[0m open \x1b[1mArk Companion\x1b[0m\n \u{00b7} slots skipped: Ark locked" + " \x1b[1m\u{2713} usb\x1b[0m 1 device found\n \x1b[1m\u{2717} relay\x1b[0m no answer\n \x1b[1mhint:\x1b[0m open \x1b[1mArk Companion\x1b[0m\n \u{00b7} slots skipped: Ark locked\n \x1b[1m! tool\x1b[0m new release\n \x1b[1mhint:\x1b[0m run \x1b[1mbrew upgrade ark-cli\x1b[0m" ); } } diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..eeccd3e --- /dev/null +++ b/src/update.rs @@ -0,0 +1,781 @@ +// ark: command line interface to Ark enclaves +// Copyright 2026 Dark Bio AG. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//! Lookups of the newest published ark and the note that announces it. + +use crate::output::Output; +use chrono::{DateTime, Utc}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use std::fs::{self, File}; +use std::io::{self, Read}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Hidden sole argument that makes ark run the detached lookup and nothing else. +pub(crate) const ENTRY_POINT: &str = "__update"; + +/// Largest kept answer read from disk, in bytes. +const CACHE_LIMIT: u64 = 4 * 1024; +/// Largest development release response accepted from GitHub, in bytes. +const RESPONSE_LIMIT: u64 = 1024 * 1024; + +/// Release channel of a build, told apart by its version's prerelease field. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Channel { + /// Stable releases, published from version tags. + Release, + /// Development builds, published as prereleases from every push to main. + Develop, +} + +impl Channel { + /// Puts a version without a prerelease identifier on the release channel. + pub fn for_version(version: &Version) -> Self { + if version.pre.is_empty() { + Self::Release + } else { + Self::Develop + } + } + + /// Names the channel in the detail of doctor's update check. + pub fn description(self) -> &'static str { + match self { + Self::Release => "release", + Self::Develop => "development build", + } + } +} + +/// The kept answer, stamped when its lookup started. +#[derive(Deserialize, Serialize)] +pub(crate) struct Answer { + /// Channel the answer belongs to. + pub channel: Channel, + /// When the last lookup started, whether or not it succeeded. + pub asked: DateTime, + /// Newest version found, absent until a lookup succeeds. + pub newest: Option, +} + +impl Answer { + /// Reads the kept answer for one channel. An unreadable file counts as no answer. + pub fn read(directory: &Path, channel: Channel) -> Option { + // Read at most 4 KiB, far more than an answer ever takes + let file = File::open(directory.join("update.json")).ok()?; + let mut bytes = Vec::new(); + file.take(CACHE_LIMIT).read_to_end(&mut bytes).ok()?; + + // Typed fields reject malformed versions and timestamps + let answer: Self = serde_json::from_slice(&bytes).ok()?; + (answer.channel == channel).then_some(answer) + } + + /// Reports whether a lookup is due. It is when the answer is absent, belongs + /// to the other channel, is stamped in the future, or is an hour old. + pub fn stale(answer: Option<&Self>, channel: Channel, now: DateTime) -> bool { + answer.is_none_or(|answer| { + answer.channel != channel + || answer.asked > now + || now.signed_duration_since(answer.asked) >= chrono::Duration::hours(1) + }) + } + + /// Replaces the kept answer through a renamed temporary file, so a reader + /// never sees half of it. + pub fn write(&self, directory: &Path) -> io::Result<()> { + // Serialize the answer and make sure the cache directory exists + let bytes = serde_json::to_vec(self).map_err(io::Error::other)?; + fs::create_dir_all(directory)?; + + // Rename only a completely written file over the kept answer + let temporary = directory.join(format!(".update-{}.tmp", std::process::id())); + let result = fs::write(&temporary, bytes) + .and_then(|()| fs::rename(&temporary, directory.join("update.json"))); + if result.is_err() { + let _ = fs::remove_file(temporary); + } + result + } + + /// Words the note while the kept version is newer than the running one. + fn note(&self, running: &Version, hint: &str) -> Option { + let newest = self.newest.as_ref()?; + newest + .cmp_precedence(running) + .is_gt() + .then(|| format!("ark {newest} is available, this is {running}; {hint}")) + } +} + +/// Reports whether a nonempty `CI` turns off lookups and notes alike. +pub(crate) fn disabled() -> bool { + std::env::var_os("CI").is_some_and(|value| !value.is_empty()) +} + +/// Parses the version stamped into this executable by Cargo or the publish workflow. +pub(crate) fn running() -> Version { + Version::parse(env!("CARGO_PKG_VERSION")).expect("Cargo package version is semver") +} + +/// Prints the note from the kept answer and starts a background lookup when +/// one is due. The lookup runs in a detached copy of ark. +pub(crate) fn start(output: &Output, now: DateTime) { + // Under CI nothing is read, printed or looked up + if disabled() { + return; + } + + // Print the note from the kept answer, and go on only if a lookup is due + let running = running(); + let channel = Channel::for_version(&running); + let directory = crate::data::cache::directory(); + let answer = Answer::read(&directory, channel); + if let Some(note) = answer + .as_ref() + .and_then(|answer| answer.note(&running, &hint(channel))) + { + output.event("note", note); + } + if !Answer::stale(answer.as_ref(), channel, now) { + return; + } + + // Stamp the attempt before asking, so a failing network asks once an hour. + // A cache that cannot be written gets no lookup at all. + if let Err(error) = claim(&directory, channel, now) { + tracing::debug!("update claim could not be written: {}", error); + return; + } + + // Start the copy in its own process group with no streams, so a harness + // waiting for this command's output never waits for the lookup + let result = std::env::current_exe().and_then(|executable| { + let mut command = Command::new(executable); + command + .arg(ENTRY_POINT) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + use windows_sys::Win32::System::Threading::{ + CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS, + }; + command.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + } + command.spawn().map(drop) + }); + if let Err(error) = result { + tracing::debug!("update process could not be started: {}", error); + } +} + +/// Runs the lookup in the detached copy, which ends within 30 s whatever happens. +pub(crate) fn run() { + // Under CI the copy does nothing + if disabled() { + return; + } + let started = Instant::now(); + let asked = Utc::now(); + + // End the process at 30 s, and skip the lookup when nothing can enforce that + if thread::Builder::new() + .name("ark-update-watchdog".into()) + .spawn(move || { + thread::sleep(Duration::from_secs(30).saturating_sub(started.elapsed())); + std::process::exit(0); + }) + .is_err() + { + return; + } + + // Keep a successful answer, stamped with this copy's start time + let channel = Channel::for_version(&running()); + let _ = refresh( + &crate::data::cache::directory(), + channel, + asked, + Duration::from_secs(20), + ); +} + +/// Stamps a new attempt, keeping the version last found on the same channel. +fn claim(directory: &Path, channel: Channel, now: DateTime) -> io::Result<()> { + Answer { + channel, + asked: now, + newest: Answer::read(directory, channel).and_then(|answer| answer.newest), + } + .write(directory) +} + +/// Looks up the newest version and keeps it. A failed lookup leaves the kept +/// answer as it was. +pub(crate) fn refresh( + directory: &Path, + channel: Channel, + asked: DateTime, + timeout: Duration, +) -> Result { + // A failed lookup leaves the kept version and attempt time untouched + let newest = lookup(channel, timeout)?; + let answer = Answer { + channel, + asked, + newest: Some(newest.clone()), + }; + + // A failed write still returns the version, since doctor shows it either way + if let Err(error) = answer.write(directory) { + tracing::debug!("update answer could not be written: {}", error); + } + Ok(newest) +} + +/// Fetches only a parsed version; failure reasons never contain response text. +fn lookup(channel: Channel, timeout: Duration) -> Result { + let agent = crate::http::agent(timeout, 0); + let result = match channel { + Channel::Release => { + // Inspect the redirect without following it or reading its body + let response = agent + .head("https://github.com/dark-bio/cli/releases/latest") + .header("User-Agent", "ark") + .call() + .map_err(|error| { + tracing::debug!("update lookup failed: {}", error); + "GitHub could not be reached" + })?; + if !response.status().is_redirection() { + Err("GitHub returned an unexpected status") + } else { + response + .headers() + .get("Location") + .and_then(|location| location.to_str().ok()) + .ok_or("GitHub returned no release redirect") + .and_then(release) + } + } + Channel::Develop => { + // Bound the public release list before parsing any of its entries + let mut response = agent + .get("https://api.github.com/repos/dark-bio/cli/releases?per_page=10") + .header("User-Agent", "ark") + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .call() + .map_err(|error| { + tracing::debug!("update lookup failed: {}", error); + "GitHub could not be reached" + })?; + if !response.status().is_success() { + Err("GitHub returned an unexpected status") + } else { + let bytes = response + .body_mut() + .with_config() + .limit(RESPONSE_LIMIT) + .read_to_vec() + .map_err(|error| { + tracing::debug!("update lookup failed: {}", error); + "GitHub release list could not be read within 1 MiB" + })?; + develop(&bytes) + } + } + }; + + // Log validation failures with their fixed texts, never response content + if let Err(error) = result { + tracing::debug!("update lookup failed: {}", error); + } + result +} + +/// Accepts only a strict stable version at this repository's exact release URL. +fn release(location: &str) -> Result { + location + .strip_prefix("https://github.com/dark-bio/cli/releases/tag/v") + .and_then(|tag| Version::parse(tag).ok()) + .filter(|version| version.pre.is_empty()) + .ok_or("GitHub returned an invalid release redirect") +} + +/// Selects the highest semantic version among published prerelease entries. +fn develop(bytes: &[u8]) -> Result { + /// Only these public release fields participate in version selection. + #[derive(Deserialize)] + struct Release { + /// Version tag stamped by the publish workflow. + tag_name: String, + /// Unpublished drafts never announce an available build. + draft: bool, + /// Stable releases do not belong to the development channel. + prerelease: bool, + } + + // Decode the public fields without retaining unrelated response text + let releases: Vec = + serde_json::from_slice(bytes).map_err(|_| "GitHub returned an invalid release list")?; + + // Ignore unpublished entries and invalid tags before comparing semantic precedence + releases + .into_iter() + .filter(|release| !release.draft && release.prerelease) + .filter_map(|release| { + release + .tag_name + .strip_prefix('v') + .and_then(|tag| Version::parse(tag).ok()) + }) + .max_by(Version::cmp_precedence) + .ok_or("GitHub returned no development builds") +} + +/// Words the upgrade advice for the way this executable was installed. +pub(crate) fn hint(channel: Channel) -> String { + // Gather the paths that tell the install methods apart + let executable = std::env::current_exe().ok(); + let home = directories::BaseDirs::new(); + let cargo_home = std::env::var_os("CARGO_HOME"); + + // An install the tool cannot place gets the repository link + executable + .as_deref() + .and_then(|executable| { + upgrade( + channel, + executable, + home.as_ref().map(|dirs| dirs.home_dir()), + cargo_home.as_deref().map(Path::new), + ) + }) + .map(|command| format!("upgrade with `{command}`")) + .unwrap_or_else(|| "download it from https://github.com/dark-bio/cli".into()) +} + +/// Picks the upgrade command for an executable's location. The installer and +/// crates.io carry releases only, so a development build gets a command only +/// from Homebrew. +fn upgrade( + channel: Channel, + executable: &Path, + home: Option<&Path>, + cargo_home: Option<&Path>, +) -> Option<&'static str> { + // Follow Homebrew's link into its Cellar and match the formula + let executable = executable.canonicalize().ok()?; + let mut components = executable.components(); + while let Some(component) = components.next() { + if component.as_os_str() == "Cellar" { + match components.next()?.as_os_str().to_str()? { + "ark-cli" => return Some("brew update && brew upgrade ark-cli"), + "ark-cli-dev" => return Some("brew update && brew upgrade ark-cli-dev"), + _ => {} + } + } + } + + // Past Homebrew, only a release build has an upgrade command + if channel == Channel::Develop { + return None; + } + + // Compare canonical directories, since the home or bin directory may be a link + let directory = executable.parent()?; + if home + .and_then(|home| home.join(".local/bin").canonicalize().ok()) + .as_deref() + == Some(directory) + { + return Some( + "curl -fsSL https://github.com/dark-bio/cli/releases/latest/download/ark-installer.sh | sh", + ); + } + let cargo = cargo_home + .map(Path::to_path_buf) + .or_else(|| home.map(|home| home.join(".cargo"))); + if cargo + .and_then(|cargo| cargo.join("bin").canonicalize().ok()) + .as_deref() + == Some(directory) + { + return Some("cargo install darkbio-ark --locked"); + } + None +} + +/// Tests of the kept answer, the response parsing and the install detection. +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + /// Distinguishes temporary test directories without relying on timestamps. + static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); + + /// Removes a test's cache and installation files on scope exit. + struct Directory { + /// Isolated root for one test's real filesystem operations. + path: PathBuf, + } + + impl Directory { + /// Creates a process-specific directory without overwriting existing files. + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "ark-update-test-{}-{}", + std::process::id(), + NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self { path } + } + } + + impl Drop for Directory { + /// Cleans up even when an assertion unwinds the test. + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + /// A lookup is due after an hour, for a future stamp and for the other channel. + #[test] + fn test_cache_staleness_uses_the_claim_time_and_channel() { + // Use a fixed instant so the boundary never depends on test runtime + let directory = Directory::new(); + let now = "2026-09-25T12:00:00Z".parse::>().unwrap(); + let cache = directory.path.join("cache"); + assert!(Answer::stale( + Answer::read(&cache, Channel::Release).as_ref(), + Channel::Release, + now + )); + + // Keep each case through the production writer and read it back + for (case, asked, channel, stale) in [ + ( + "under an hour", + "2026-09-25T11:00:00.001Z", + Channel::Release, + false, + ), + ("one hour", "2026-09-25T11:00:00Z", Channel::Release, true), + ("future", "2026-09-25T12:00:00.001Z", Channel::Release, true), + ( + "other channel", + "2026-09-25T12:00:00Z", + Channel::Develop, + true, + ), + ] { + let answer = Answer { + channel, + asked: asked.parse().unwrap(), + newest: Some(Version::parse("0.3.7").unwrap()), + }; + answer.write(&cache).unwrap(); + let kept = Answer::read(&cache, Channel::Release); + assert_eq!( + Answer::stale(kept.as_ref(), Channel::Release, now), + stale, + "{case}" + ); + } + + // The last write leaves one file holding the documented shape + let stored: serde_json::Value = + serde_json::from_slice(&fs::read(cache.join("update.json")).unwrap()).unwrap(); + assert_eq!( + stored, + json!({"channel":"develop", "asked":"2026-09-25T12:00:00Z", "newest":"0.3.7"}) + ); + assert_eq!(fs::read_dir(cache).unwrap().count(), 1); + } + + /// Malformed and unreadable answers never postpone a fresh lookup. + #[test] + fn test_invalid_cache_answers_are_stale() { + // Keep the malformed fields close to the documented cache shape + let directory = Directory::new(); + let now = "2026-09-25T12:00:00Z".parse::>().unwrap(); + let path = directory.path.join("update.json"); + for (case, bytes) in [ + ("broken JSON", b"{".to_vec()), + ( + "invalid time", + br#"{"channel":"release","asked":"today","newest":"0.3.7"}"#.to_vec(), + ), + ( + "invalid version", + br#"{"channel":"release","asked":"2026-09-25T12:00:00Z","newest":"latest"}"# + .to_vec(), + ), + ] { + fs::write(&path, bytes).unwrap(); + let answer = Answer::read(&directory.path, Channel::Release); + assert!( + Answer::stale(answer.as_ref(), Channel::Release, now), + "{case}" + ); + } + + // A directory in place of the answer exercises an unreadable file portably + fs::remove_file(&path).unwrap(); + fs::create_dir(&path).unwrap(); + assert!(Answer::stale( + Answer::read(&directory.path, Channel::Release).as_ref(), + Channel::Release, + now + )); + } + + /// Claims keep only the same channel's previous version and require a writable cache. + #[test] + fn test_claim_preserves_only_the_same_channels_previous_answer() { + // Publish an expired answer through the same atomic writer used by the worker + let directory = Directory::new(); + let now = "2026-09-25T12:00:00Z".parse::>().unwrap(); + Answer { + channel: Channel::Release, + asked: "2026-09-25T10:00:00Z".parse().unwrap(), + newest: Some(Version::parse("0.3.7").unwrap()), + } + .write(&directory.path) + .unwrap(); + + // A claim keeps the known version and records the new attempt time + claim(&directory.path, Channel::Release, now).unwrap(); + let claimed = Answer::read(&directory.path, Channel::Release).unwrap(); + assert_eq!(claimed.asked.to_rfc3339(), "2026-09-25T12:00:00+00:00"); + assert_eq!(claimed.newest.unwrap().to_string(), "0.3.7"); + + // A claim for the other channel drops the version found on this one + claim(&directory.path, Channel::Develop, now).unwrap(); + let claimed = Answer::read(&directory.path, Channel::Develop).unwrap(); + assert!(claimed.newest.is_none()); + assert!(Answer::read(&directory.path, Channel::Release).is_none()); + + // An unwritable cache path cannot produce a claim or start a request + let file = directory.path.join("file"); + fs::write(&file, []).unwrap(); + assert!(claim(&file, Channel::Release, now).is_err()); + } + + /// Only a newer kept version produces the exact notice, regardless of claim age. + #[test] + fn test_note_requires_a_newer_kept_version() { + // The old timestamp deliberately leaves the notice independent of freshness + let mut answer = Answer { + channel: Channel::Release, + asked: "2026-01-01T00:00:00Z".parse().unwrap(), + newest: None, + }; + let running = Version::parse("0.3.6").unwrap(); + let hint = "upgrade with `brew update && brew upgrade ark-cli`"; + assert!(answer.note(&running, hint).is_none()); + for newest in ["0.3.5", "0.3.6", "0.3.6+different-build"] { + answer.newest = Some(Version::parse(newest).unwrap()); + assert!(answer.note(&running, hint).is_none(), "{newest}"); + } + + // Both known and unknown installation methods keep the promised sentence + answer.newest = Some(Version::parse("0.3.7").unwrap()); + assert_eq!( + answer.note(&running, hint).unwrap(), + "ark 0.3.7 is available, this is 0.3.6; upgrade with `brew update && brew upgrade ark-cli`" + ); + assert_eq!( + answer + .note(&running, "download it from https://github.com/dark-bio/cli") + .unwrap(), + "ark 0.3.7 is available, this is 0.3.6; download it from https://github.com/dark-bio/cli" + ); + + // Numeric prerelease identifiers follow semantic precedence rather than text order + answer.channel = Channel::Develop; + answer.newest = Some(Version::parse("0.3.6-dev.34").unwrap()); + assert_eq!( + answer + .note( + &Version::parse("0.3.6-dev.9").unwrap(), + "upgrade with `brew update && brew upgrade ark-cli-dev`" + ) + .unwrap(), + "ark 0.3.6-dev.34 is available, this is 0.3.6-dev.9; upgrade with `brew update && brew upgrade ark-cli-dev`" + ); + } + + /// Stable redirects must name this repository and a strict release version. + #[test] + fn test_release_redirect_rejects_foreign_and_nonrelease_locations() { + // Captured with curl -sI from releases/latest on 2026-09-25 (HTTP 302) + assert_eq!( + release("https://github.com/dark-bio/cli/releases/tag/v0.3.5").unwrap(), + Version::parse("0.3.5").unwrap() + ); + for location in [ + "https://example.com/dark-bio/cli/releases/tag/v0.3.5", + "https://github.com/dark-bio/emulator/releases/tag/v0.3.5", + "https://github.com/dark-bio/cli/releases/tag/v0.3.6-dev.34", + "https://github.com/dark-bio/cli/releases/tag/v0.3", + "https://github.com/dark-bio/cli/releases/tag/v0.3.5/extra", + "https://github.com/dark-bio/cli/releases/tag/v0.3.5?next=bad", + "https://github.com/dark-bio/cli/releases/tag/v0.3.5\n", + "garbage", + ] { + assert!(release(location).is_err(), "{location:?}"); + } + } + + /// Development selection ignores drafts and releases and does not depend on list order. + #[test] + fn test_development_selection_uses_the_highest_published_prerelease() { + // Captured 2026-09-25 from https://api.github.com/repos/dark-bio/cli/releases?per_page=10 + // Only unrelated object fields are removed from this public response + let bytes = br#"[ + {"tag_name":"v0.3.6-dev.34","draft":false,"prerelease":true}, + {"tag_name":"v0.3.5","draft":false,"prerelease":false}, + {"tag_name":"v0.3.5-dev.32","draft":false,"prerelease":true}, + {"tag_name":"v0.3.5-dev.31","draft":false,"prerelease":true}, + {"tag_name":"v0.3.4","draft":false,"prerelease":false}, + {"tag_name":"v0.3.4-dev.28","draft":false,"prerelease":true}, + {"tag_name":"v0.3.4-dev.27","draft":false,"prerelease":true}, + {"tag_name":"v0.3.4-dev.26","draft":false,"prerelease":true}, + {"tag_name":"v0.3.3","draft":false,"prerelease":false}, + {"tag_name":"v0.3.3-dev.23","draft":false,"prerelease":true} + ]"#; + assert_eq!( + develop(bytes).unwrap(), + Version::parse("0.3.6-dev.34").unwrap() + ); + + // Reverse the captured order so the winner is neither first nor assumed latest + let mut releases: Vec = serde_json::from_slice(bytes).unwrap(); + releases.reverse(); + assert_eq!( + develop(&serde_json::to_vec(&releases).unwrap()).unwrap(), + Version::parse("0.3.6-dev.34").unwrap() + ); + + // Turning just the highest entry into a draft leaves a stable release above the winner + releases.last_mut().unwrap()["draft"] = json!(true); + assert_eq!( + develop(&serde_json::to_vec(&releases).unwrap()).unwrap(), + Version::parse("0.3.5-dev.32").unwrap() + ); + for release in &mut releases { + release["draft"] = json!(true); + } + assert!(develop(&serde_json::to_vec(&releases).unwrap()).is_err()); + assert!(develop(b"not JSON").is_err()); + assert!(develop(br#"[{"tag_name":"garbage","draft":false,"prerelease":true}]"#).is_err()); + } + + /// Install advice follows canonical paths and keeps development builds off release installers. + #[test] + fn test_upgrade_commands_follow_the_installation_layout() { + // Create the installed files because detection resolves the executable itself + let directory = Directory::new(); + let home = directory.path.join("home"); + let cargo = directory.path.join("custom-cargo"); + for (path, cargo_home, release, develop) in [ + ( + "Cellar/ark-cli/0.3.6/bin/ark", + None, + Some("brew update && brew upgrade ark-cli"), + Some("brew update && brew upgrade ark-cli"), + ), + ( + "Cellar/ark-cli-dev/0.3.6-dev.34/bin/ark", + None, + Some("brew update && brew upgrade ark-cli-dev"), + Some("brew update && brew upgrade ark-cli-dev"), + ), + ( + "home/.local/bin/ark", + None, + Some( + "curl -fsSL https://github.com/dark-bio/cli/releases/latest/download/ark-installer.sh | sh", + ), + None, + ), + ( + "home/.cargo/bin/ark", + None, + Some("cargo install darkbio-ark --locked"), + None, + ), + ( + "custom-cargo/bin/ark", + Some(cargo.as_path()), + Some("cargo install darkbio-ark --locked"), + None, + ), + ("home/.cargo/bin/ark", Some(cargo.as_path()), None, None), + ("custom-cargo/bin/ark", None, None, None), + ("Cellar/ark-cli-extra/0.3.6/bin/ark", None, None, None), + ("Cellarish/ark-cli/0.3.6/bin/ark", None, None, None), + ("opt/bin/ark", None, None, None), + ] { + let executable = directory.path.join(path); + fs::create_dir_all(executable.parent().unwrap()).unwrap(); + fs::write(&executable, []).unwrap(); + assert_eq!( + upgrade(Channel::Release, &executable, Some(&home), cargo_home), + release, + "{path}, release, {cargo_home:?}" + ); + assert_eq!( + upgrade(Channel::Develop, &executable, Some(&home), cargo_home), + develop, + "{path}, develop, {cargo_home:?}" + ); + } + + // A normal Homebrew entry point resolves through its Cellar symlink + #[cfg(unix)] + { + let link = directory.path.join("ark"); + std::os::unix::fs::symlink(directory.path.join("Cellar/ark-cli/0.3.6/bin/ark"), &link) + .unwrap(); + assert_eq!( + upgrade(Channel::Release, &link, Some(&home), None), + Some("brew update && brew upgrade ark-cli") + ); + let linked_home = directory.path.join("linked-home"); + std::os::unix::fs::symlink(&home, &linked_home).unwrap(); + assert_eq!( + upgrade( + Channel::Release, + &home.join(".local/bin/ark"), + Some(&linked_home), + None + ), + Some( + "curl -fsSL https://github.com/dark-bio/cli/releases/latest/download/ark-installer.sh | sh" + ) + ); + } + } +} diff --git a/tests/palette.rs b/tests/palette.rs index 07925c7..5f34e14 100644 --- a/tests/palette.rs +++ b/tests/palette.rs @@ -17,10 +17,151 @@ fn ark(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_ark")) .args(args) .env("NO_COLOR", "1") + .env("CI", "1") .output() .unwrap() } +/// The private update entry point does nothing and prints nothing under CI. +#[test] +fn test_update_entry_point_is_silent_under_ci() { + let output = ark(&["__update"]); + assert_eq!(output.status.code(), Some(0)); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); +} + +/// A fresh isolated answer produces one stderr note while help and invalid invocations stay quiet. +#[cfg(unix)] +#[test] +fn test_update_note_preserves_command_output_and_excludes_noncommands() { + /// Removes the subprocess home and cache even after an assertion failure. + struct Directory( + /// Isolated root used for both HOME and XDG_CACHE_HOME. + std::path::PathBuf, + ); + impl Drop for Directory { + /// Cleans up files owned by this process test. + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + // macOS uses Library/Caches while other Unix targets use XDG_CACHE_HOME + let _process = PROCESS.lock().unwrap(); + let directory = + Directory(std::env::temp_dir().join(format!("ark-update-palette-{}", std::process::id()))); + let home = directory.0.join("home"); + let xdg_cache = directory.0.join("cache"); + let cache = if cfg!(target_os = "macos") { + home.join("Library/Caches/ark") + } else { + xdg_cache.join("ark") + }; + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&cache).unwrap(); + let version = semver::Version::parse(env!("CARGO_PKG_VERSION")).unwrap(); + let newest = format!("{}.0.0", version.major + 1); + let answer = serde_json::to_vec(&serde_json::json!({ + "channel": if version.pre.is_empty() { "release" } else { "develop" }, + "asked": chrono::Utc::now().to_rfc3339(), + "newest": newest, + })) + .unwrap(); + std::fs::write(cache.join("update.json"), &answer).unwrap(); + let invoke = |args: &[&str], ci: Option<&str>| { + let mut command = Command::new(env!("CARGO_BIN_EXE_ark")); + command + .args(args) + .env_remove("CI") + .env("HOME", &home) + .env("XDG_CACHE_HOME", &xdg_cache) + .env("NO_COLOR", "1"); + if let Some(ci) = ci { + command.env("CI", ci); + } + command.output().unwrap() + }; + + // The note precedes the error and repeats in both reading and JSON output + let message = format!( + "ark {newest} is available, this is {version}; download it from https://github.com/dark-bio/cli" + ); + for json in [false, true] { + let mut args = vec!["status", "--device", "hardware:palette-no-device"]; + if json { + args.push("--json"); + } + let baseline = invoke(&args, Some("1")); + for ci in [None, Some("")] { + let output = invoke(&args, ci); + assert_eq!(output.status.code(), Some(3), "json={json}, CI={ci:?}"); + assert_eq!(output.stdout, baseline.stdout, "json={json}, CI={ci:?}"); + let stderr = String::from_utf8(output.stderr).unwrap(); + if json { + let events: Vec = stderr + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + events[0], + serde_json::json!({"event":"note","message":message}) + ); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "note") + .count(), + 1 + ); + assert_eq!(events[1]["event"], "error"); + } else { + assert_eq!(stderr.lines().next().unwrap(), format!("note: {message}")); + assert_eq!( + stderr + .lines() + .filter(|line| line.starts_with("note:")) + .count(), + 1 + ); + } + } + + // Quiet suppresses the notice without changing the result or status + args.push("-q"); + let quiet = invoke(&args, None); + assert_eq!(quiet.stdout, baseline.stdout); + assert_eq!(quiet.status.code(), baseline.status.code()); + assert!(!String::from_utf8_lossy(&quiet.stderr).contains("is available")); + assert!(!String::from_utf8_lossy(&baseline.stderr).contains("is available")); + } + + // None of these paths may announce or refresh a release, even with a known newer build + for args in [ + vec!["help"], + vec!["help", "--all"], + vec!["-h"], + vec!["--help"], + vec!["--help", "--all"], + vec!["status", "--help"], + vec!["completions", "zsh"], + vec!["--version"], + vec![], + vec!["--all"], + vec!["--timeout", "0", "status"], + vec!["bogus"], + vec!["--json", "data", "upload", "x", "--dry-run", "--unlock"], + vec!["--version", "status"], + ] { + let output = invoke(&args, None); + assert!( + !String::from_utf8_lossy(&output.stderr).contains("is available"), + "{args:?}" + ); + } + assert_eq!(std::fs::read(cache.join("update.json")).unwrap(), answer); +} + /// Walks the executable command tree through its generated help pages. fn commands() -> Vec<(Vec, String)> { let mut pending = vec![Vec::new()];