From 4f6484a14d7b2673ba7b55709c966a49f1585757 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 10 Aug 2026 13:28:14 -0700 Subject: [PATCH] feat(compile,runtime,updater): let a compiled app carry its own update check Perry's CLI has checked for updates for a long time; an app it builds could not, so shipping one meant hand-writing a version check or shipping none. A perry.update block in package.json -- or [update] in perry.toml, which wins key by key -- is validated at compile time and baked into the executable. Two halves: Perry does the noticing, reading the app's own state file at startup and printing a two-line notice on stderr when the last lookup found something newer; the app does the asking, using its own fetch() with the URL, headers and response parsing Perry supplies for whichever of the four sources was configured. That division is the one docs/src/updater/overview.md already states for the desktop updater, and it keeps an HTTP stack out of every compiled binary that never checks for an update. With no block configured NOTHING is emitted: not an empty blob, not a disabled one. The blob is part of the object-cache fingerprint, without which adding the block and rebuilding would serve the cached entry object and ship a binary with no update check while reporting success. Validation is a build failure rather than a warning, because a warning scrolls past in build output while the consequence lands on the app's users, who get no notices and no error. HTTPS is required with the loopback exemption anchored to a host boundary, each source must carry the keys it reads, a zero check interval is rejected, and an app with no version is caught. The version comes from perry.toml's [project] version so the notice agrees with what the rest of the binary reports. Three lessons from the CLI's own post-merge review are applied here rather than rediscovered: the notify interval is keyed to the announced VERSION so it cannot swallow the next release, the state file is written to a per-write temporary name so two instances cannot rename over each other, and an unreadable timestamp notifies rather than silencing updates forever. Four are specific to an app: the notice is stderr only because an app's stdout belongs to the app, control characters are stripped because a release name is attacker-influenceable terminal input, argv is read with args_os because args() panic-drops on non-UTF-8 input before any app code runs, and an unparseable version never reads as newer -- node-smol compared against a hardcoded 0.0.0, which made every release look newer than the running binary. The blob reader unescapes what the writer escaped, without which a url containing a quote or backslash doubled its escapes on every save until it was unusable. gh-registry produces no URL without a token, because that 404 reads as up to date, and no npmjs.com link at all, because the package may be private there. Also closes a gap found on the way past: PERRY_UPDATER_TABLE's comment claims it is auto-derivable from the api-manifest entries, but those are hand-listed and nothing checked they agreed -- a row without its entry makes the strict unimplemented-API gate reject user code that calls it, in somebody else's build. There is now a sabotage-verified parity test, plus one asserting no runtime symbol starts with a prefix Windows stubs to a no-op. --- Cargo.lock | 1 + changelog.d/7789-app-update-config.md | 162 ++ crates/perry-api-manifest/Cargo.toml | 5 + .../perry-api-manifest/src/entries/part_4.rs | 5 + crates/perry-api-manifest/src/lib.rs | 44 + crates/perry-codegen/src/codegen/entry.rs | 31 + crates/perry-codegen/src/codegen/opts.rs | 8 + crates/perry-codegen/src/runtime_decls/mod.rs | 4 + crates/perry-dispatch/src/updater_table.rs | 36 + crates/perry-runtime/src/lib.rs | 1 + crates/perry-runtime/src/update_notify.rs | 1839 +++++++++++++++++ crates/perry/src/commands/compile.rs | 1 + .../perry/src/commands/compile/host_config.rs | 46 +- .../src/commands/compile/object_cache.rs | 10 + .../src/commands/compile/update_config.rs | 501 +++++ docs/src/SUMMARY.md | 1 + docs/src/cli/app-updates.md | 218 ++ types/perry/updater/index.d.ts | 59 + 18 files changed, 2971 insertions(+), 1 deletion(-) create mode 100644 changelog.d/7789-app-update-config.md create mode 100644 crates/perry-runtime/src/update_notify.rs create mode 100644 crates/perry/src/commands/compile/update_config.rs create mode 100644 docs/src/cli/app-updates.md diff --git a/Cargo.lock b/Cargo.lock index 48da068253..9b763e2d76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5609,6 +5609,7 @@ dependencies = [ name = "perry-api-manifest" version = "0.5.1457" dependencies = [ + "perry-dispatch", "serde", ] diff --git a/changelog.d/7789-app-update-config.md b/changelog.d/7789-app-update-config.md new file mode 100644 index 0000000000..5e8836962a --- /dev/null +++ b/changelog.d/7789-app-update-config.md @@ -0,0 +1,162 @@ +### Added + +**An application Perry compiles can now carry its own update check.** Perry's +CLI has checked for updates for a long time; an app it *builds* could not, so +shipping one meant the author wrote a version check by hand or shipped none and +hoped users noticed. + +```json +{ "perry": { "update": { + "source": "npm", "package": "myapp", "command": "self-update" +} } } +``` + +A `perry.update` block — or an `[update]` table in perry.toml, which wins key by +key — is validated at compile time and baked into the executable. It splits into +two halves, and which is which matters: + +- **Perry does the noticing.** At startup the app reads its own state file and + prints a two-line notice on stderr when the last lookup found something newer. + The author writes no code for this. +- **The app does the asking.** The version lookup uses the app's own `fetch()`, + from a few lines the author adds. Perry supplies everything else: whether a + lookup is due, which URL and headers the configured source needs, and how to + read the answer. + +That division is the one `docs/src/updater/overview.md` already states for the +desktop updater — *"Download lives in TS (using existing `fetch()`) — Rust only +handles the security-critical and platform-touching pieces"* — and it applies +with more force here, because `perry-runtime` links into every compiled binary. +An HTTP stack added for this would be paid for by every program that never +checks for an update. + +**With no block configured, nothing is emitted.** Not an empty blob, and not a +disabled one: a binary that configures no updates is byte-identical to one built +before this existed. A feature whose off-state still emits code is one you cannot +prove is off. + +
+Validation is a build failure, on purpose + +A warning scrolls past in build output; the consequence lands on the app's users, +who get no notices and no error — the feature does nothing and nobody can tell +why. So these are errors: + +- **a URL must be `https://`**, with loopback allowed for local testing. Plain + HTTP is refused because an on-path attacker can answer "you are current" and + suppress an update. The loopback exemption stops at a host boundary, so + `http://localhost.example.test` is refused like any other remote host; +- **each source needs the keys it reads** — `url` for `gh-releases` and + `custom`, `package` for the npm-shaped ones; +- **a zero check interval is rejected**, since it would ask on every run; +- **an app with no version** has nothing to compare against. + +`enabled = false` keeps the settings on disk and emits nothing, rather than +embedding a disabled block complete with its URL and startup call. + +The version is taken from perry.toml's `[project] version` when present, so it +agrees with what the rest of the binary reports — otherwise a dual-manifest +project could compare against a number the app never claims to be. +
+ +
+The four sources, and what each refuses to do + +`gh-releases` and `custom` read the configured URL. The npm-shaped pair request +the *abbreviated* packument — smaller, cacheable, and the document npm itself +asks for. + +The public registry is asked **without credentials**; a token there is a leak, +not a convenience. GitHub Packages produces **no URL at all** without a token, +rather than an anonymous request whose 404 reads as "up to date" — which would +have the app report itself current forever. It also gets no npmjs.com link, +since that package may be private or absent there and the notice would show a +URL that 404s. + +Each shape reads only its own fields, so a registry answering a `gh-releases` +request is an error rather than a version of `""`. The npm `latest` tag is read +from inside `dist-tags`, not the document root, because a packument carries +version strings in several places. + +Recording a lookup carries the notice state across, so a refresh cannot reset the +notify throttle. +
+ +
+Lessons taken from the CLI's own review rather than rediscovered + +Perry's CLI update surface was reviewed after merging, and three findings apply +verbatim to this half. They are already handled here: + +- the notify interval is keyed to the announced **version**, not the clock alone, + so an interval set to stop nagging about one release cannot hide the release + that fixed it; +- the state file is written to a per-write temporary name, because two instances + of the same app can run at once and one shared name lets each rename a file the + other is still writing; +- an unreadable timestamp notifies rather than staying silent, since silence on + one bad write would hide updates indefinitely. + +Four more are specific to an app rather than a CLI. The notice is **stderr +only** — an app's stdout belongs to the app. Control characters are stripped, +because a release name is attacker-influenceable terminal input and a notice must +not repaint somebody's screen. Startup reads its argument list with `args_os`, +since `args()` panic-drops the process on non-UTF-8 input and this runs before +any app code. And an unparseable version never reads as newer: node-smol's +equivalent compared against a hardcoded `"0.0.0"`, which made every release look +newer than the running binary. +
+ +
+Two gaps closed on the way past + +The blob is part of the **object-cache fingerprint**. Without it, adding +`perry.update` and rebuilding incrementally would serve the cached entry object +from before — shipping a binary with no update check while the build reported +success. Same class as the `dbgloc` and `fmath` entries that file already +documents. + +`PERRY_UPDATER_TABLE`'s own comment says it is "auto-derivable from" the +api-manifest entries, but those are hand-listed and **nothing checked that the +two agreed**. A dispatch row without its entry makes the strict +unimplemented-API gate reject user code that calls it, in somebody else's build. +There is now a parity test, plus one asserting no runtime symbol starts with a +prefix Windows synthesizes no-op stubs for — a stubbed symbol returns garbage +rather than failing to link. +
+ +
+Tests + +**53.** Ten on the compiler side cover the parse and every validation rule, +including the loopback host boundary and that perry.toml overrides package.json +key by key while leaving keys it does not set alone. Forty-one in the runtime +cover the blob reader, every gate, the per-platform state directory, version +comparison, the throttle, control-character stripping, all four request shapes +and response parsers, and each shape rejecting the others' documents. Two more +assert dispatch/manifest parity. + +Several exist because a test found the bug: an app with no config reported "go +ahead" instead of "not configured"; and values containing a quote or backslash +were read back with their escapes intact, doubling on every save until a URL was +unusable. Both are sabotage-verified — reverting either fix turns its test red. + +Beyond the units there is a **wiring test** driving the whole startup path — +blob in, notice out, state advanced, second run quiet — because everything else +here is a piece tested in isolation, and a feature whose pieces all pass while +the path between them is broken is what ships doing nothing. + +**Verified end to end:** a configured project's binary contains the blob, the +same project without the block produces one that does not, the configured binary +runs normally, and a plain-HTTP URL fails the build with a message naming the +key. +
+ +### Documentation + +New page `docs/src/cli/app-updates.md`, written for the app author: the smallest +configuration that does something, every key in both spellings, how to choose a +source, the mistakes that fail the build and why each is an error, the cases +where a user's run will not check at all, where the state file lives per +platform, how to give users an off switch, and the few lines that perform the +lookup. diff --git a/crates/perry-api-manifest/Cargo.toml b/crates/perry-api-manifest/Cargo.toml index 2239d03f4a..122adb61cc 100644 --- a/crates/perry-api-manifest/Cargo.toml +++ b/crates/perry-api-manifest/Cargo.toml @@ -15,3 +15,8 @@ serde = { workspace = true, optional = true } default = [] # Enables Serialize/Deserialize on ApiEntry for JSON emit (--print-api-manifest). serde = ["dep:serde"] + +[dev-dependencies] +# For the dispatch-table/manifest parity test: the table claims to be +# "auto-derivable" from these entries and nothing checked that it was. +perry-dispatch = { workspace = true } diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index c2ea42014a..f5978c1123 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -554,6 +554,11 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ method("perry/i18n", "LongDate", false, None), method("perry/i18n", "Raw", false, None), // --- perry/updater — auto-derivable from PERRY_UPDATER_TABLE. --- + method("perry/updater", "getEmbeddedConfig", false, None), + method("perry/updater", "embeddedCheckUrl", false, None), + method("perry/updater", "embeddedCheckHeaders", false, None), + method("perry/updater", "recordEmbeddedResponse", false, None), + method("perry/updater", "embeddedRefreshDue", false, None), method("perry/updater", "compareVersions", false, None), method("perry/updater", "verifyHash", false, None), method("perry/updater", "verifySignature", false, None), diff --git a/crates/perry-api-manifest/src/lib.rs b/crates/perry-api-manifest/src/lib.rs index 290dc1e78c..1b20168270 100644 --- a/crates/perry-api-manifest/src/lib.rs +++ b/crates/perry-api-manifest/src/lib.rs @@ -1340,3 +1340,47 @@ mod tests { } } } + +#[cfg(test)] +mod dispatch_parity_tests { + /// ★ `PERRY_UPDATER_TABLE`'s own comment says it is "auto-derivable from" + /// these entries — but the entries are hand-listed, and nothing checked the + /// two agreed. + /// + /// A dispatch row without its manifest entry makes the strict + /// unimplemented-API gate reject user code that calls it. That failure + /// surfaces in somebody else's build, which is the wrong place to find out. + #[test] + fn every_updater_dispatch_row_has_a_manifest_entry() { + let declared: Vec<&str> = crate::entries_for_module("perry/updater") + .map(|entry| entry.name) + .collect(); + let missing: Vec<&str> = perry_dispatch::PERRY_UPDATER_TABLE + .iter() + .map(|row| row.method) + .filter(|method| !declared.contains(method)) + .collect(); + assert!( + missing.is_empty(), + "dispatch rows with no manifest entry: {missing:?} — user code \ + calling these is rejected by the unimplemented-API gate" + ); + } + + /// And the runtime symbols must stay clear of the prefixes Windows + /// synthesizes no-op stubs for. A stubbed symbol returns garbage rather than + /// failing to link, which is the worst way to discover a missing definition. + #[test] + fn no_updater_runtime_symbol_lands_in_the_windows_stub_net() { + const STUBBED: &[&str] = &["perry_get_", "perry_ui_", "perry_system_", "perry_plugin_"]; + for row in perry_dispatch::PERRY_UPDATER_TABLE { + for prefix in STUBBED { + assert!( + !row.runtime.starts_with(prefix), + "{} starts with {prefix}, which Windows stubs to a no-op", + row.runtime + ); + } + } + } +} diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index b6d894ebd4..00aaa3a392 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -375,6 +375,26 @@ pub(super) fn compile_module_entry( .filter(|s| !s.is_empty()) .map(|suite| llmod.add_string_constant(suite)) }; + // The `perry.update` blob (Phase B): one string constant plus one + // `perry_update_notify_startup(ptr, len)` call at the top of `main`, so + // a configured app checks for its own updates without its author + // writing a version check. Emitted ONLY when the project configures the + // block — a binary with no update settings must be byte-identical to + // one built before this existed, which `entry.rs`'s absence test pins. + // + // Skipped for a dylib for the same reason `app_group` is: there is no + // `main` to put a prelude in, so the call would reference a startup + // path that does not exist here. + let update_init: Option<(String, usize)> = if is_dylib { + None + } else { + cross_module + .app_metadata + .update_config + .as_deref() + .filter(|s| !s.is_empty()) + .map(|blob| llmod.add_string_constant(blob)) + }; // i18n startup init: when the project configures `[i18n]`, bake the // configured locale-code list (and the optional `[i18n.currencies]` // map) into `main`'s prelude as a single `perry_i18n_init` call — @@ -494,6 +514,17 @@ pub(super) fn compile_module_entry( &[(PTR, suite_ptr.as_str()), (I32, len_str.as_str())], ); } + // The update check runs before user code, so an app that exits + // early still gets its notice, and so the per-app state directory + // is resolved before anything can change the working directory. + if let Some((const_name, byte_len)) = update_init.as_ref() { + let blob_ptr = format!("@{}", const_name); + let len_str = byte_len.to_string(); + blk.call_void( + "perry_update_notify_startup", + &[(PTR, blob_ptr.as_str()), (I32, len_str.as_str())], + ); + } // i18n: register the configured locale list + resolve the runtime // locale BEFORE any module init runs, so module-top-level `t()` // calls and format wrappers already see the detected locale. diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index 69306eed32..31e011386e 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -20,6 +20,13 @@ pub struct AppMetadata { /// `app_group_*` calls fall through to the runtime's "not configured" /// stub-warn diagnostic. Refs #1178. pub app_group: Option, + /// The validated `perry.update` block, as the JSON blob the runtime reads, + /// or `None` when the project configures no update check. + /// + /// `None` must emit NOTHING — not an empty blob and not a disabled one. A + /// binary that configures no updates is byte-identical to one built before + /// this existed, and `entry.rs`'s absence test asserts it. + pub update_config: Option, } impl Default for AppMetadata { @@ -29,6 +36,7 @@ impl Default for AppMetadata { build_number: 1, bundle_id: "com.perry.app".to_string(), app_group: None, + update_config: None, } } } diff --git a/crates/perry-codegen/src/runtime_decls/mod.rs b/crates/perry-codegen/src/runtime_decls/mod.rs index 08f83a8847..266ff9bc11 100644 --- a/crates/perry-codegen/src/runtime_decls/mod.rs +++ b/crates/perry-codegen/src/runtime_decls/mod.rs @@ -50,6 +50,10 @@ pub fn declare_phase1(module: &mut LlModule) { // the runtime always provides the symbol; main only emits the call // when `app_metadata.app_group` is `Some`. module.declare_function("perry_app_group_init", VOID, &[PTR, I32]); + // Phase B: the embedded `perry.update` blob's startup entry point. Declared + // unconditionally, like every other runtime symbol here — the CALL is what + // `entry.rs` emits only for a configured project. + module.declare_function("perry_update_notify_startup", VOID, &[PTR, I32]); // macOS asset-CWD fix: a macOS `.app` launched from Finder starts with // CWD=`/`, but the worker bundles assets into `Contents/Resources/`. The // `main` prelude calls this unconditionally; the runtime symbol no-ops on diff --git a/crates/perry-dispatch/src/updater_table.rs b/crates/perry-dispatch/src/updater_table.rs index b209e2abc1..5b5dd44d55 100644 --- a/crates/perry-dispatch/src/updater_table.rs +++ b/crates/perry-dispatch/src/updater_table.rs @@ -13,6 +13,42 @@ use super::*; /// through `Str` (raw `*StringHeader` ptr extracted via /// `js_get_string_pointer_unified` on the codegen side). pub static PERRY_UPDATER_TABLE: &[MethodRow] = &[ + // perry-runtime::update_notify — the embedded `perry.update` block a + // compiled app carries. These are how an app performs its own check: the + // runtime says what to request and reads the answer, the app's own `fetch()` + // makes the request. Named `perry_updater_*` rather than `perry_get_*` + // because Windows synthesizes no-op stubs for undefined symbols with that + // prefix, which would return garbage instead of failing to link. + MethodRow { + method: "getEmbeddedConfig", + runtime: "perry_updater_get_config", + args: &[], + ret: ReturnKind::Str, + }, + MethodRow { + method: "embeddedCheckUrl", + runtime: "perry_updater_check_url", + args: &[ArgKind::F64], + ret: ReturnKind::Str, + }, + MethodRow { + method: "embeddedCheckHeaders", + runtime: "perry_updater_check_headers", + args: &[ArgKind::F64], + ret: ReturnKind::Str, + }, + MethodRow { + method: "recordEmbeddedResponse", + runtime: "perry_updater_record_response", + args: &[ArgKind::F64], + ret: ReturnKind::I64AsF64, + }, + MethodRow { + method: "embeddedRefreshDue", + runtime: "perry_updater_refresh_due", + args: &[], + ret: ReturnKind::I64AsF64, + }, // perry-updater::core — pure cross-platform helpers. MethodRow { method: "compareVersions", diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 02ac76111d..1b3559720a 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -247,6 +247,7 @@ mod ui_harmonyos_stubs; /// target-aware branching. UI crates register their handlers here at /// startup. See module docs for the ohos-napi gating story. pub mod ui_text_registry; +pub mod update_notify; pub mod util_abort; pub mod util_call_sites; pub mod util_debuglog; diff --git a/crates/perry-runtime/src/update_notify.rs b/crates/perry-runtime/src/update_notify.rs new file mode 100644 index 0000000000..3d90eab80a --- /dev/null +++ b/crates/perry-runtime/src/update_notify.rs @@ -0,0 +1,1839 @@ +//! The embedded update check a compiled app runs at startup. +//! +//! Codegen bakes the project's validated `perry.update` block into the binary +//! as a JSON blob and calls [`perry_update_notify_startup`] at the top of +//! `main`, before any user code. An app that configures nothing gets neither +//! the blob nor the call. +//! +//! # What this file does today, and what it deliberately does not +//! +//! It parses and holds the configuration, and applies every gate that decides +//! whether a check may happen at all. It does **not** yet reach the network or +//! print anything — those arrive with the provider layer. +//! +//! That split is deliberate rather than incidental. The gates are where this +//! feature can go wrong quietly: a check that fires in CI, or in a script +//! parsing the app's stdout, or in a container with no writable home, is a bug +//! that shows up as somebody else's flaky pipeline. They are worth landing and +//! testing on their own, ahead of the code that would exercise them. +//! +//! # Why the parse is total +//! +//! A blob this build cannot read is ignored, not guessed at. It is emitted by +//! the same Perry that compiled the binary, so a mismatch means something is +//! wrong upstream, and a wrong guess would run a network check with settings +//! nobody wrote. + +use std::borrow::Cow; +use std::sync::OnceLock; + +/// The blob shape this build understands. Must match the compiler's +/// `BLOB_SCHEMA`. +const BLOB_SCHEMA: u32 = 1; + +/// The validated settings, as read from the embedded blob. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmbeddedUpdateConfig { + pub app_id: String, + pub bin_name: String, + pub current_version: String, + pub source: String, + pub url: Option, + pub tag: Option, + pub package: Option, + pub registry: Option, + pub check_interval_hours: u64, + pub notify_interval_hours: u64, + pub command: String, + pub skip_env: Option, +} + +static CONFIG: OnceLock> = OnceLock::new(); + +/// Read one field out of the blob. +/// +/// A deliberately tiny flat-object reader rather than a JSON library. Two +/// reasons: `perry-runtime` links into every compiled binary, so a parser +/// pulled in for this would be paid for by every program that configures no +/// updates; and this runs at the very top of `main`, before the collector is +/// usable, so the runtime's own JSON path — which allocates JS values — is not +/// available. The producer is `update_config.rs` in the same Perry that +/// compiled the binary, emitting a flat object of strings and numbers, so there +/// is no nesting to handle. +fn blob_field<'a>(text: &'a str, key: &str) -> Option> { + let needle = format!("\"{key}\":"); + let mut rest = text; + loop { + let at = rest.find(&needle)?; + // Guard against matching a key inside a VALUE: the character before the + // opening quote must be a structural one, not part of a string. + let before = rest[..at].chars().last(); + rest = &rest[at + needle.len()..]; + if !matches!( + before, + None | Some('{') | Some(',') | Some(' ') | Some('\n') | Some('\t') + ) { + continue; + } + let value = rest.trim_start(); + return if let Some(body) = value.strip_prefix('"') { + // Strings: walk to the closing quote, UNESCAPING as we go. + // + // Returning the raw slice was a round-trip bug: `save_state` writes + // `\"` and `\\`, so a url or version containing either came back + // with the escapes still in it, and each save/load cycle doubled + // the backslashes until the value was unusable. + let mut out = String::new(); + let mut chars = body.chars(); + while let Some(c) = chars.next() { + match c { + '"' => return Some(Cow::Owned(out)), + '\\' => match chars.next() { + Some(escaped @ ('"' | '\\' | '/')) => out.push(escaped), + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some('r') => out.push('\r'), + // An escape this reader does not know: keep the payload + // rather than the backslash, which is the safer of the + // two for a value that becomes a URL. + Some(other) => out.push(other), + None => return None, + }, + other => out.push(other), + } + } + None + } else { + let end = value + .find(|c: char| c == ',' || c == '}') + .unwrap_or(value.len()); + Some(Cow::Borrowed(value[..end].trim())) + }; + } +} + +/// Parse the blob. `None` for anything this build cannot read. +fn parse_blob(text: &str) -> Option { + let string = |key: &str| blob_field(text, key).map(Cow::into_owned); + let number = |key: &str, fallback: u64| -> u64 { + blob_field(text, key) + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(fallback) + }; + + // A schema this build does not know means the blob was written by a + // different Perry. Ignore it rather than reading fields that may have moved + // — a wrong guess would run a network check with settings nobody wrote. + if number("schema", 0) != BLOB_SCHEMA as u64 { + return None; + } + + Some(EmbeddedUpdateConfig { + app_id: string("app_id")?, + bin_name: string("bin_name")?, + current_version: string("current_version")?, + source: string("source")?, + url: string("url"), + tag: string("tag"), + package: string("package"), + registry: string("registry"), + check_interval_hours: number("check_interval_hours", 24), + notify_interval_hours: number("notify_interval_hours", 24), + command: string("command").unwrap_or_default(), + skip_env: string("skip_env"), + }) +} + +/// Codegen calls this once, at the top of `main`, for a configured app only. +/// +/// # Safety +/// +/// `ptr`/`len` name a string constant in the binary's own read-only data, so +/// the bytes outlive the process and are never null for a positive length. +#[no_mangle] +pub unsafe extern "C" fn perry_update_notify_startup(ptr: *const u8, len: i32) { + if ptr.is_null() || len <= 0 { + return; + } + let bytes = std::slice::from_raw_parts(ptr, len as usize); + let Ok(text) = std::str::from_utf8(bytes) else { + return; + }; + let config = parse_blob(text); + let _ = CONFIG.set(config.clone()); + if let Some(config) = config { + run_startup_notice(&config); + } +} + +/// The embedded settings, if this binary has any and they parsed. +pub fn embedded_config() -> Option<&'static EmbeddedUpdateConfig> { + CONFIG.get().and_then(|c| c.as_ref()) +} + +/// Why a check is not going to happen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkipReason { + /// The app embeds no update settings. + NotConfigured, + /// The app's own opt-out variable is set. + AppOptOut, + /// `PERRY_NO_UPDATE_CHECK`, or the ecosystem-wide `NO_UPDATE_NOTIFIER`. + GlobalOptOut, + /// A continuous-integration environment. + Ci, + /// Nobody is reading stderr, so a notice has no audience. + NotATerminal, + /// This invocation IS the app's own update command; checking again here + /// would recurse. + UpdateCommand, +} + +/// The environment a decision is made against, gathered so the decision itself +/// is a pure function. +#[derive(Debug, Clone, Copy, Default)] +pub struct CheckEnv<'a> { + pub app_skip: Option<&'a str>, + pub no_update_check: Option<&'a str>, + pub no_update_notifier: Option<&'a str>, + pub ci: Option<&'a str>, + pub continuous_integration: Option<&'a str>, + pub stderr_is_terminal: bool, + /// The app's own argv[1], so an `app self-update` run does not itself + /// trigger a check. + pub first_arg: Option<&'a str>, +} + +fn is_on(raw: Option<&str>) -> bool { + matches!( + raw.map(|s| s.trim().to_ascii_lowercase()).as_deref(), + Some("1") | Some("true") | Some("on") | Some("yes") + ) +} + +fn is_present(raw: Option<&str>) -> bool { + !matches!( + raw.map(|s| s.trim().to_ascii_lowercase()).as_deref(), + None | Some("") | Some("0") | Some("false") | Some("off") | Some("no") + ) +} + +/// May this run check for an update? `None` means yes. +/// +/// Pure, so every gate is asserted directly rather than inferred from whether a +/// network call happened to be made. +pub fn skip_reason(config: Option<&EmbeddedUpdateConfig>, env: CheckEnv<'_>) -> Option { + // `?` would be wrong here: it returns `None`, which this function reads as + // "go ahead and check". An app with no embedded settings has nothing to + // check against. + let Some(config) = config else { + return Some(SkipReason::NotConfigured); + }; + + // The app's own switch first: the person setting `MYAPP_NO_UPDATE_CHECK` + // is being specific, and specificity should not be overridable by anything + // more general. + if let Some(name) = config.skip_env.as_deref() { + if !name.is_empty() && is_present(env.app_skip) { + return Some(SkipReason::AppOptOut); + } + } + // Then the two global spellings. `NO_UPDATE_NOTIFIER` is honoured because + // somebody who sets it has already told every tool on the machine what they + // want, and an app compiled by Perry is one of those tools. + // Presence, not an exact literal, for BOTH spellings. Somebody who set + // either one is asking not to be checked, and the documentation presents + // them the same way — a gate that accepted only four spellings of yes would + // silently ignore the fifth. + if is_present(env.no_update_check) || is_present(env.no_update_notifier) { + return Some(SkipReason::GlobalOptOut); + } + if is_present(env.ci) || is_present(env.continuous_integration) { + return Some(SkipReason::Ci); + } + if !env.stderr_is_terminal { + return Some(SkipReason::NotATerminal); + } + // `app self-update` must not check on its way to updating: the check would + // be redundant at best, and at worst the notice would print in the middle + // of the install it triggered. + if !config.command.is_empty() && env.first_arg == Some(config.command.as_str()) { + return Some(SkipReason::UpdateCommand); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn blob() -> String { + // The shape `update_config.rs` emits, minus the keys a minimal npm + // block leaves out. + r#"{"schema":1,"app_id":"myapp","bin_name":"myapp","current_version":"1.2.3", + "source":"npm","package":"myapp","check_interval_hours":24, + "notify_interval_hours":24,"command":"self-update", + "skip_env":"MYAPP_NO_UPDATE_CHECK"}"# + .to_string() + } + + fn config() -> EmbeddedUpdateConfig { + parse_blob(&blob()).expect("the fixture must parse") + } + + fn tty() -> CheckEnv<'static> { + CheckEnv { + stderr_is_terminal: true, + ..CheckEnv::default() + } + } + + #[test] + fn a_blob_round_trips_into_settings() { + let config = config(); + assert_eq!(config.app_id, "myapp"); + assert_eq!(config.source, "npm"); + assert_eq!(config.package.as_deref(), Some("myapp")); + assert_eq!(config.url, None, "an absent optional stays absent"); + assert_eq!(config.check_interval_hours, 24); + assert_eq!(config.command, "self-update"); + } + + /// A blob from a different Perry is ignored rather than read field by + /// field. Guessing at a moved layout would run a network check with + /// settings nobody wrote. + #[test] + fn a_blob_of_another_schema_is_ignored() { + assert_eq!( + parse_blob(&blob().replace("\"schema\":1", "\"schema\":2")), + None + ); + assert_eq!(parse_blob(r#"{"app_id":"x"}"#), None, "no schema at all"); + assert_eq!(parse_blob("not json"), None); + assert_eq!(parse_blob(""), None); + } + + /// A blob missing a required field is ignored too — a half-read + /// configuration is worse than none, because it looks like it works. + #[test] + fn a_blob_missing_a_required_field_is_ignored() { + assert_eq!(parse_blob(r#"{"schema":1,"app_id":"x"}"#), None); + } + + /// The reader must not match a key name that appears inside a VALUE — an + /// app whose name contains `"url":` would otherwise read its own name as a + /// URL. + #[test] + fn a_key_name_inside_a_value_is_not_matched() { + let text = r#"{"schema":1,"app_id":"a","bin_name":"weird\"url\":x","current_version":"1", + "source":"npm","package":"p"}"#; + let config = parse_blob(text).expect("parses"); + assert_eq!(config.url, None, "the name's contents are not a url field"); + assert_eq!(config.package.as_deref(), Some("p")); + } + + #[test] + fn an_app_with_no_config_never_checks() { + assert_eq!(skip_reason(None, tty()), Some(SkipReason::NotConfigured)); + } + + /// The app's own switch is the most specific thing the user said, so + /// nothing more general gets to override it. + #[test] + fn the_apps_own_opt_out_is_honoured() { + let env = CheckEnv { + app_skip: Some("1"), + ..tty() + }; + assert_eq!( + skip_reason(Some(&config()), env), + Some(SkipReason::AppOptOut) + ); + } + + /// Both global spellings, including the ecosystem-wide one: somebody who + /// set it has already told every tool on the machine what they want. + /// Both spellings are presence-based, so a value the gate did not enumerate + /// still disables the check. Somebody who wrote `PERRY_NO_UPDATE_CHECK=please` + /// is asking not to be checked. + #[test] + fn the_global_opt_outs_accept_any_non_falsey_value() { + for raw in ["1", "true", "yes", "please", "anything"] { + for env in [ + CheckEnv { + no_update_check: Some(raw), + ..tty() + }, + CheckEnv { + no_update_notifier: Some(raw), + ..tty() + }, + ] { + assert_eq!( + skip_reason(Some(&config()), env), + Some(SkipReason::GlobalOptOut), + "{raw:?} must disable the check" + ); + } + } + // ...and an explicit no, or an exported-but-empty value, does not. + for raw in ["0", "false", "off", "no", ""] { + let env = CheckEnv { + no_update_check: Some(raw), + ..tty() + }; + assert_eq!(skip_reason(Some(&config()), env), None, "{raw:?}"); + } + } + + #[test] + fn both_global_opt_outs_are_honoured() { + for env in [ + CheckEnv { + no_update_check: Some("1"), + ..tty() + }, + CheckEnv { + no_update_notifier: Some("1"), + ..tty() + }, + ] { + assert_eq!( + skip_reason(Some(&config()), env), + Some(SkipReason::GlobalOptOut) + ); + } + } + + /// CI is detected by presence, since CI systems are inconsistent about the + /// value — but an exported-but-empty variable is not somebody telling us + /// they are in CI. + #[test] + fn ci_is_detected_by_presence_but_not_when_empty() { + for raw in ["1", "true", "yes", "anything"] { + let env = CheckEnv { + ci: Some(raw), + ..tty() + }; + assert_eq!( + skip_reason(Some(&config()), env), + Some(SkipReason::Ci), + "CI={raw}" + ); + } + let also = CheckEnv { + continuous_integration: Some("true"), + ..tty() + }; + assert_eq!(skip_reason(Some(&config()), also), Some(SkipReason::Ci)); + + for raw in ["", "0", "false", "no"] { + let env = CheckEnv { + ci: Some(raw), + ..tty() + }; + assert_eq!(skip_reason(Some(&config()), env), None, "CI={raw:?}"); + } + } + + /// A notice on a pipe has no audience, and lands in the middle of whatever + /// is reading the app's output. + #[test] + fn a_non_terminal_run_does_not_check() { + let env = CheckEnv { + stderr_is_terminal: false, + ..tty() + }; + assert_eq!( + skip_reason(Some(&config()), env), + Some(SkipReason::NotATerminal) + ); + } + + /// ★ `app self-update` must not check on its way to updating: the notice + /// would print in the middle of the install it triggered. + #[test] + fn the_apps_own_update_command_does_not_trigger_a_check() { + let env = CheckEnv { + first_arg: Some("self-update"), + ..tty() + }; + assert_eq!( + skip_reason(Some(&config()), env), + Some(SkipReason::UpdateCommand) + ); + // A different subcommand is unaffected. + let other = CheckEnv { + first_arg: Some("build"), + ..tty() + }; + assert_eq!(skip_reason(Some(&config()), other), None); + } + + /// An app that configures no update command has nothing to recurse into, + /// so no argument is special. + #[test] + fn an_app_without_an_update_command_has_no_reserved_argument() { + let mut config = config(); + config.command = String::new(); + let env = CheckEnv { + first_arg: Some(""), + ..tty() + }; + assert_eq!(skip_reason(Some(&config), env), None); + } + + /// The permissive case, so the gates above are known to be the only + /// obstacles: an interactive run of a configured app with nothing set. + #[test] + fn an_interactive_run_of_a_configured_app_may_check() { + assert_eq!(skip_reason(Some(&config()), tty()), None); + } +} + +// --------------------------------------------------------------------------- +// Throttle state, the notice, and the decision between them. +// +// This is the half a user sees. The network refresh that populates the state is +// the next slice; everything here works from what a previous run recorded, so +// a program that has never checked simply says nothing. +// --------------------------------------------------------------------------- + +/// The shape of the per-app state file. A different value is discarded rather +/// than migrated, for the same reason the blob's schema is: this is a cache, +/// rebuilt by the next check, so reading an older shape buys one saved request +/// in exchange for fields that describe versions nobody runs. +const STATE_SCHEMA: u32 = 1; + +/// What a previous run recorded. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct NotifyState { + pub last_check: Option, + pub last_notification: Option, + /// Which version that notice was about. + /// + /// The interval throttles repeats of the SAME release. Keyed on time alone + /// it would swallow the next one whenever that arrived inside the window, so + /// an interval set to stop nagging about one version would also hide the + /// version that fixed it. + pub last_notified_version: Option, + pub latest_known: Option, + pub latest_url: Option, +} + +/// Where this app keeps its state. +/// +/// Prefers the platform's own cache location via `dirs`, which asks the real +/// APIs — Known Folders on Windows, `NSSearchPathForDirectoriesInDomains` on +/// macOS — rather than trusting environment variables that a launcher, a +/// service manager or a stripped environment may not have set. The +/// environment-derived rules below are the fallback for builds without that +/// feature, and are what the tests drive. +pub fn state_dir(app_id: &str) -> Option { + #[cfg(feature = "full")] + if let Some(base) = dirs::cache_dir() { + return Some(base.join(sanitize_app_id(app_id))); + } + state_dir_for( + app_id, + std::env::var("XDG_CACHE_HOME").ok().as_deref(), + std::env::var("HOME").ok().as_deref(), + std::env::var("LOCALAPPDATA").ok().as_deref(), + ) +} + +/// The same decision from explicit inputs, so the platform rules are testable +/// without a home directory. Per-app, keyed by `app_id`, so two Perry-built +/// programs never share a throttle — one app's notice must not silence +/// another's. +pub fn state_dir_for( + app_id: &str, + xdg_cache: Option<&str>, + home: Option<&str>, + local_appdata: Option<&str>, +) -> Option { + let base = if cfg!(windows) { + std::path::PathBuf::from(local_appdata?) + } else if cfg!(target_os = "macos") { + // `~/Library/Caches` rather than XDG: on macOS that is where a cache + // belongs, and a program that writes `~/.cache` there looks like it was + // ported without being looked at. + std::path::PathBuf::from(home?) + .join("Library") + .join("Caches") + } else if let Some(xdg) = xdg_cache.filter(|s| !s.is_empty()) { + std::path::PathBuf::from(xdg) + } else { + std::path::PathBuf::from(home?).join(".cache") + }; + Some(base.join(sanitize_app_id(app_id))) +} + +/// Keep an `app_id` from escaping its own directory. +/// +/// The value comes from the project's own manifest, so this is not a hostile +/// input — but it is a string that becomes a path, and `../..` in one would +/// write outside the cache root. Cheaper to make impossible than to reason +/// about. +fn sanitize_app_id(app_id: &str) -> String { + let cleaned: String = app_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect(); + // Collapse any run of dots. A lone `.` in a name is fine; `..` is the one + // shape that means "the parent", and no amount of separator-stripping makes + // that safe to keep. + let mut collapsed = String::with_capacity(cleaned.len()); + let mut last_was_dot = false; + for c in cleaned.chars() { + if c == '.' { + if !last_was_dot { + collapsed.push('.'); + } + last_was_dot = true; + } else { + collapsed.push(c); + last_was_dot = false; + } + } + let trimmed = collapsed.trim_matches('.'); + if trimmed.is_empty() { + "perry-app".to_string() + } else { + trimmed.to_string() + } +} + +fn state_file(dir: &std::path::Path) -> std::path::PathBuf { + dir.join("update-check.json") +} + +/// Read the state file, or `None` for absent, unreadable or foreign. +pub fn load_state(dir: &std::path::Path) -> Option { + let text = std::fs::read_to_string(state_file(dir)).ok()?; + if blob_field(&text, "schema").and_then(|raw| raw.parse::().ok()) != Some(STATE_SCHEMA) { + return None; + } + Some(NotifyState { + last_check: blob_field(&text, "last_check").map(Cow::into_owned), + last_notification: blob_field(&text, "last_notification").map(Cow::into_owned), + last_notified_version: blob_field(&text, "last_notified_version").map(Cow::into_owned), + latest_known: blob_field(&text, "latest_known").map(Cow::into_owned), + latest_url: blob_field(&text, "latest_url").map(Cow::into_owned), + }) +} + +/// Replace the state file atomically. +/// +/// Written beside the target and renamed over it, with a per-write temporary +/// name: two instances of the same app can run at once, and one shared name +/// would let each rename a file the other was still writing. +pub fn save_state(dir: &std::path::Path, state: &NotifyState) { + if std::fs::create_dir_all(dir).is_err() { + return; + } + let mut json = String::from("{\"schema\":1"); + for (key, value) in [ + ("last_check", &state.last_check), + ("last_notification", &state.last_notification), + ("last_notified_version", &state.last_notified_version), + ("latest_known", &state.latest_known), + ("latest_url", &state.latest_url), + ] { + if let Some(value) = value { + json.push_str(&format!(",\"{key}\":\"{}\"", escape_json(value))); + } + } + json.push('}'); + + let target = state_file(dir); + let tmp = dir.join(format!( + "update-check.json.tmp.{}.{}", + std::process::id(), + NEXT_STATE_TMP.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + if std::fs::write(&tmp, json).is_err() { + let _ = std::fs::remove_file(&tmp); + return; + } + if std::fs::rename(&tmp, &target).is_err() { + // Windows refuses a rename onto an existing file, so fall back to + // replacing it. Still better than truncating in place, which would let a + // concurrent reader see half a document. + let _ = std::fs::remove_file(&target); + if std::fs::rename(&tmp, &target).is_err() { + let _ = std::fs::remove_file(&tmp); + } + } +} + +static NEXT_STATE_TMP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn escape_json(value: &str) -> String { + value + .chars() + .filter(|c| !c.is_control()) + .flat_map(|c| match c { + '"' => vec!['\\', '"'], + '\\' => vec!['\\', '\\'], + other => vec![other], + }) + .collect() +} + +/// Compare two dotted numeric versions. +/// +/// `Some(Ordering)` when both parse, `None` when either does not — and an +/// unparseable version must never read as "newer". node-smol's equivalent +/// compared against a hardcoded `"0.0.0"`, which made every release look newer +/// than the running binary; that is the mistake this returns `None` to avoid. +pub fn compare_versions(a: &str, b: &str) -> Option { + let parse = |v: &str| -> Option> { + let core = v.trim().trim_start_matches('v'); + // Ignore any prerelease/build suffix for ordering purposes: an app + // comparing `1.2.3` with `1.2.4-rc.1` wants the numeric answer, and a + // full semver precedence implementation is not what a startup notice + // needs. + let core = core.split(['-', '+']).next()?; + if core.is_empty() { + return None; + } + core.split('.') + .map(|part| part.parse::().ok()) + .collect::>>() + }; + let (a, b) = (parse(a)?, parse(b)?); + let width = a.len().max(b.len()); + for i in 0..width { + let (x, y) = ( + a.get(i).copied().unwrap_or(0), + b.get(i).copied().unwrap_or(0), + ); + if x != y { + return Some(x.cmp(&y)); + } + } + Some(std::cmp::Ordering::Equal) +} + +/// Seconds since the epoch for the RFC3339 stamps this module writes. +fn parse_stamp(stamp: &str) -> Option { + // The same fixed-width shape `now_stamp` emits. Anything else is treated as + // unreadable, which the callers turn into "act now" rather than "stay + // silent forever". + let bytes = stamp.as_bytes(); + if bytes.len() < 19 { + return None; + } + let num = |range: std::ops::Range| stamp.get(range)?.parse::().ok(); + let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?); + let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?); + // Days from the civil date, Howard Hinnant's algorithm. + let y_adj = if mo <= 2 { y - 1 } else { y }; + let era = if y_adj >= 0 { y_adj } else { y_adj - 399 } / 400; + let yoe = y_adj - era * 400; + let mp = (mo + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146_097 + doe - 719_468; + Some(days * 86_400 + h * 3_600 + mi * 60 + s) +} + +fn now_seconds() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or_default() +} + +fn now_stamp() -> String { + let secs = now_seconds(); + let days = secs.div_euclid(86_400); + let rest = secs.rem_euclid(86_400); + // Civil date from days, the inverse of the above. + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + y, + m, + d, + rest / 3_600, + (rest % 3_600) / 60, + rest % 60 + ) +} + +/// What to tell the user, if anything. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Notice { + pub current: String, + pub latest: String, + pub url: Option, + /// The command to suggest, when the app declared one. + pub command: Option, +} + +/// Decide whether a notice is due, from recorded state alone. +/// +/// Pure: no clock, no filesystem, no network. Every reason to stay quiet is +/// therefore assertable, which matters because staying quiet is the failure +/// nobody notices. +pub fn notice_from_state( + config: &EmbeddedUpdateConfig, + state: &NotifyState, + now_secs: i64, +) -> Option { + let latest = state.latest_known.as_deref()?; + // An unparseable version on either side is not a newer version. The whole + // class of bug here is announcing an update that does not exist. + if compare_versions(latest, &config.current_version)? != std::cmp::Ordering::Greater { + return None; + } + + // The interval throttles repeats of THIS release. A different version is + // announced regardless, or an interval set to stop nagging about one would + // also hide the release that fixed it. + if state.last_notified_version.as_deref() == Some(latest) { + let interval = config.notify_interval_hours.saturating_mul(3_600) as i64; + if interval > 0 { + match state.last_notification.as_deref().and_then(parse_stamp) { + // Inside the window: stay quiet. + Some(last) if now_secs.saturating_sub(last) < interval => return None, + // Unreadable or absent: the throttle has nothing to stand on, + // and silence on a damaged file would hide updates forever. + _ => {} + } + } + } + + Some(Notice { + current: config.current_version.clone(), + latest: latest.to_string(), + url: state.latest_url.clone().filter(|u| !u.is_empty()), + command: Some(config.command.clone()).filter(|c| !c.is_empty()), + }) +} + +/// The two lines a notice prints. +/// +/// Returned rather than printed so the wording is testable. Every value that +/// reaches here came from a network document, so control characters are +/// stripped: a release name is attacker-influenceable terminal input, and a +/// notice must not be able to repaint someone's screen. +pub fn render_notice(bin_name: &str, notice: &Notice) -> Vec { + let clean = |s: &str| -> String { s.chars().filter(|c| !c.is_control()).collect() }; + let mut lines = vec![format!( + "Update available: {} {} → {}", + clean(bin_name), + clean(¬ice.current), + clean(¬ice.latest) + )]; + lines.push(match (¬ice.command, ¬ice.url) { + // A command the app declared it handles. + (Some(command), _) => format!(" Run `{} {}` to update", clean(bin_name), clean(command)), + // No command: point at the release rather than inventing one. An app + // that has not implemented an update command must not be told to run it. + (None, Some(url)) => format!(" See {}", clean(url)), + (None, None) => " A newer version is available".to_string(), + }); + lines +} + +/// The whole startup path: gates, state, decision, output. +/// +/// Called from [`perry_update_notify_startup`] once the blob has parsed. +/// Everything it needs is read here and nothing is written unless a notice was +/// actually printed — a throttle advanced for a notice nobody saw would +/// suppress the next real one. +fn run_startup_notice(config: &EmbeddedUpdateConfig) { + let app_skip = config + .skip_env + .as_deref() + .and_then(|name| std::env::var(name).ok()); + // `args()` panic-drops the process on a non-UTF-8 argument, and this runs + // from `main` before any app code. An argument we cannot read is simply not + // the update command. + let first_arg = std::env::args_os() + .nth(1) + .and_then(|raw| raw.into_string().ok()); + let env = CheckEnv { + app_skip: app_skip.as_deref(), + no_update_check: None, + no_update_notifier: None, + ci: None, + continuous_integration: None, + stderr_is_terminal: std::io::IsTerminal::is_terminal(&std::io::stderr()), + first_arg: first_arg.as_deref(), + }; + let no_check = std::env::var("PERRY_NO_UPDATE_CHECK").ok(); + let no_notifier = std::env::var("NO_UPDATE_NOTIFIER").ok(); + let ci = std::env::var("CI").ok(); + let ci2 = std::env::var("CONTINUOUS_INTEGRATION").ok(); + let env = CheckEnv { + no_update_check: no_check.as_deref(), + no_update_notifier: no_notifier.as_deref(), + ci: ci.as_deref(), + continuous_integration: ci2.as_deref(), + ..env + }; + if skip_reason(Some(config), env).is_some() { + return; + } + + let Some(dir) = state_dir(&config.app_id) else { + return; + }; + let Some(state) = load_state(&dir) else { + // Nothing recorded yet. The refresh that populates it is the next + // slice; until then a first run is silent, which is the right way round. + return; + }; + let Some(notice) = notice_from_state(config, &state, now_seconds()) else { + return; + }; + + for line in render_notice(&config.bin_name, ¬ice) { + // stderr, always: an app's stdout belongs to the app, and a notice in + // the middle of it breaks whatever is parsing the output. + eprintln!("{line}"); + } + + let mut updated = state; + updated.last_notification = Some(now_stamp()); + updated.last_notified_version = Some(notice.latest); + save_state(&dir, &updated); +} + +#[cfg(test)] +mod state_tests { + use super::*; + use std::cmp::Ordering; + + fn config() -> EmbeddedUpdateConfig { + EmbeddedUpdateConfig { + app_id: "myapp".into(), + bin_name: "myapp".into(), + current_version: "1.2.3".into(), + source: "npm".into(), + url: None, + tag: None, + package: Some("myapp".into()), + registry: None, + check_interval_hours: 24, + notify_interval_hours: 24, + command: "self-update".into(), + skip_env: None, + } + } + + #[test] + fn versions_compare_numerically_and_reject_nonsense() { + assert_eq!(compare_versions("1.2.4", "1.2.3"), Some(Ordering::Greater)); + assert_eq!(compare_versions("1.2.3", "1.2.3"), Some(Ordering::Equal)); + assert_eq!(compare_versions("1.10.0", "1.9.0"), Some(Ordering::Greater)); + assert_eq!(compare_versions("v2.0.0", "1.9.9"), Some(Ordering::Greater)); + assert_eq!(compare_versions("1.2", "1.2.0"), Some(Ordering::Equal)); + // ★ An unparseable version must not read as newer. node-smol compared + // against a hardcoded "0.0.0", which made every release look newer than + // the running binary. + assert_eq!(compare_versions("banana", "1.0.0"), None); + assert_eq!(compare_versions("1.0.0", ""), None); + } + + /// An app id is a manifest value that becomes a path. `../..` in one would + /// write outside the cache root, so it is made impossible rather than + /// reasoned about. + #[test] + fn an_app_id_cannot_escape_its_directory() { + for hostile in ["../../etc", "..", "a/../b", r"..\..\win", "/absolute"] { + let safe = sanitize_app_id(hostile); + assert!(!safe.contains(".."), "{hostile} → {safe}"); + assert!(!safe.contains('/'), "{hostile} → {safe}"); + assert!(!safe.contains('\\'), "{hostile} → {safe}"); + assert!(!safe.is_empty(), "{hostile} → empty"); + } + assert_eq!(sanitize_app_id(""), "perry-app"); + assert_eq!(sanitize_app_id("..."), "perry-app"); + assert_eq!(sanitize_app_id("my.app-1_x"), "my.app-1_x"); + } + + /// Two apps must never share a throttle: one app's notice silencing + /// another's would be invisible and maddening. + #[test] + fn each_app_gets_its_own_directory() { + let a = state_dir_for("app-a", None, Some("/home/u"), Some(r"C:\x")).unwrap(); + let b = state_dir_for("app-b", None, Some("/home/u"), Some(r"C:\x")).unwrap(); + assert_ne!(a, b); + assert!(a.to_string_lossy().contains("app-a")); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn linux_honours_xdg_then_falls_back_to_dot_cache() { + let xdg = state_dir_for("app", Some("/x/cache"), Some("/home/u"), None).unwrap(); + assert!(xdg.starts_with("/x/cache")); + let fallback = state_dir_for("app", None, Some("/home/u"), None).unwrap(); + assert!(fallback.starts_with("/home/u/.cache")); + // An empty XDG value is not a directory. + let empty = state_dir_for("app", Some(""), Some("/home/u"), None).unwrap(); + assert!(empty.starts_with("/home/u/.cache")); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_uses_library_caches_rather_than_xdg() { + let dir = state_dir_for("app", Some("/x/cache"), Some("/home/u"), None).unwrap(); + assert!( + dir.to_string_lossy().contains("Library/Caches"), + "a program writing ~/.cache on macOS looks unported: {dir:?}" + ); + } + + #[test] + fn nothing_recorded_means_nothing_to_say() { + assert_eq!( + notice_from_state(&config(), &NotifyState::default(), 0), + None + ); + } + + #[test] + fn an_older_or_equal_known_version_says_nothing() { + for known in ["1.2.3", "1.2.2", "0.9.9"] { + let state = NotifyState { + latest_known: Some(known.into()), + ..NotifyState::default() + }; + assert_eq!(notice_from_state(&config(), &state, 0), None, "{known}"); + } + } + + /// An unparseable recorded version must not produce a notice — that is the + /// shape that announces an update which does not exist. + #[test] + fn an_unparseable_known_version_says_nothing() { + let state = NotifyState { + latest_known: Some("garbage".into()), + ..NotifyState::default() + }; + assert_eq!(notice_from_state(&config(), &state, 0), None); + } + + #[test] + fn a_newer_version_produces_a_notice() { + let state = NotifyState { + latest_known: Some("1.3.0".into()), + latest_url: Some("https://example.test/1.3.0".into()), + ..NotifyState::default() + }; + let notice = notice_from_state(&config(), &state, 0).expect("due"); + assert_eq!(notice.latest, "1.3.0"); + assert_eq!(notice.command.as_deref(), Some("self-update")); + } + + /// ★ The interval throttles repeats of the SAME release. A different one is + /// announced regardless, or an interval set to stop nagging about one + /// version would also hide the version that fixed it. + #[test] + fn the_interval_throttles_one_release_not_the_next() { + let base = NotifyState { + latest_known: Some("1.3.0".into()), + last_notification: Some("2026-08-10T00:00:00Z".into()), + last_notified_version: Some("1.3.0".into()), + ..NotifyState::default() + }; + let one_minute_later = parse_stamp("2026-08-10T00:01:00Z").unwrap(); + assert_eq!( + notice_from_state(&config(), &base, one_minute_later), + None, + "the same release inside the window stays quiet" + ); + + let newer = NotifyState { + latest_known: Some("1.4.0".into()), + ..base.clone() + }; + assert!( + notice_from_state(&config(), &newer, one_minute_later).is_some(), + "a different release is announced regardless of the interval" + ); + + let much_later = parse_stamp("2026-08-12T00:00:00Z").unwrap(); + assert!( + notice_from_state(&config(), &base, much_later).is_some(), + "and past the interval the same release is mentioned again" + ); + } + + /// A damaged timestamp must not silence the notice forever. + #[test] + fn an_unreadable_timestamp_notifies_rather_than_staying_silent() { + let state = NotifyState { + latest_known: Some("1.3.0".into()), + last_notification: Some("not-a-date".into()), + last_notified_version: Some("1.3.0".into()), + ..NotifyState::default() + }; + assert!(notice_from_state(&config(), &state, 0).is_some()); + } + + /// An app that declared no update command must not be told to run one. + #[test] + fn the_notice_points_at_the_release_when_there_is_no_command() { + let mut config = config(); + config.command = String::new(); + let state = NotifyState { + latest_known: Some("1.3.0".into()), + latest_url: Some("https://example.test/1.3.0".into()), + ..NotifyState::default() + }; + let notice = notice_from_state(&config, &state, 0).unwrap(); + let lines = render_notice(&config.bin_name, ¬ice); + assert!(lines[1].contains("https://example.test/1.3.0"), "{lines:?}"); + assert!(!lines[1].contains("Run "), "{lines:?}"); + } + + /// Every value in a notice arrived in a network document, so a release name + /// must not be able to repaint the terminal. + #[test] + fn control_characters_are_stripped_from_the_notice() { + let notice = Notice { + current: "1.2.3".into(), + latest: "1.3.0\u{1b}[2J".into(), + url: Some("https://example.test/\u{7}".into()), + command: None, + }; + let lines = render_notice("my\u{1b}app", ¬ice); + let joined = lines.join("\n"); + assert!(!joined.contains('\u{1b}'), "escape survived: {joined:?}"); + assert!(!joined.contains('\u{7}'), "bell survived: {joined:?}"); + } + + /// ★ Values containing a quote or a backslash must survive a save/load + /// cycle unchanged. `save_state` escapes them; the reader has to reverse + /// that, or every cycle doubles the backslashes until the value is + /// unusable — and it is a URL that gets corrupted. + #[test] + fn escaped_values_survive_repeated_save_and_load() { + let dir = std::env::temp_dir().join(format!("perry-escape-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + + let awkward = "https://example.test/a\\b/\"quoted\""; + let mut state = NotifyState { + latest_known: Some("1.4.0".into()), + latest_url: Some(awkward.to_string()), + ..NotifyState::default() + }; + + // Three cycles, because the failure mode is accumulation: one pass can + // look fine while each subsequent one adds another backslash. + for cycle in 1..=3 { + save_state(&dir, &state); + state = load_state(&dir).expect("loads"); + assert_eq!( + state.latest_url.as_deref(), + Some(awkward), + "the url changed on cycle {cycle}" + ); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn state_round_trips_through_a_real_file() { + let dir = std::env::temp_dir().join(format!("perry-notify-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let state = NotifyState { + last_check: Some("2026-08-10T00:00:00Z".into()), + last_notification: Some("2026-08-10T01:00:00Z".into()), + last_notified_version: Some("1.3.0".into()), + latest_known: Some("1.3.0".into()), + latest_url: Some("https://example.test/1.3.0".into()), + }; + save_state(&dir, &state); + assert_eq!(load_state(&dir).as_ref(), Some(&state)); + + // A foreign schema is discarded rather than migrated. + std::fs::write(dir.join("update-check.json"), r#"{"schema":99}"#).unwrap(); + assert_eq!(load_state(&dir), None); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The stamp writer and reader must agree, or every throttle comparison is + /// against a number nothing produced. + #[test] + fn the_stamp_round_trips() { + let now = now_seconds(); + let parsed = parse_stamp(&now_stamp()).expect("its own output must parse"); + assert!( + (parsed - now).abs() <= 1, + "wrote {} and read back {parsed} for {now}", + now_stamp() + ); + } +} + +#[cfg(test)] +mod startup_tests { + use super::*; + + /// The whole startup path, driven the way a real run drives it — blob in, + /// notice out, state advanced — with the terminal gate the only thing + /// stubbed. Without this the pieces are each tested and the wiring between + /// them is not, which is how a feature ships doing nothing. + #[test] + fn the_startup_path_notifies_once_then_throttles() { + let dir = std::env::temp_dir().join(format!("perry-startup-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + + let blob = r#"{"schema":1,"app_id":"myapp","bin_name":"myapp", + "current_version":"1.2.3","source":"npm","package":"myapp", + "check_interval_hours":24,"notify_interval_hours":24, + "command":"self-update"}"#; + let config = parse_blob(blob).expect("the blob must parse"); + + // A previous run recorded something newer. + save_state( + &dir, + &NotifyState { + latest_known: Some("9.9.9".into()), + latest_url: Some("https://example.test/9.9.9".into()), + ..NotifyState::default() + }, + ); + + let state = load_state(&dir).expect("recorded"); + let notice = notice_from_state(&config, &state, now_seconds()).expect("a notice is due"); + let lines = render_notice(&config.bin_name, ¬ice); + assert!(lines[0].contains("1.2.3 → 9.9.9"), "{lines:?}"); + assert!(lines[1].contains("myapp self-update"), "{lines:?}"); + + // Recording it is what makes the throttle real. + let mut advanced = state; + advanced.last_notification = Some(now_stamp()); + advanced.last_notified_version = Some(notice.latest.clone()); + save_state(&dir, &advanced); + + let reloaded = load_state(&dir).expect("still there"); + assert_eq!(reloaded.last_notified_version.as_deref(), Some("9.9.9")); + assert_eq!( + notice_from_state(&config, &reloaded, now_seconds()), + None, + "a second run inside the interval must stay quiet" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// And the gate that stopped the manual check above: a run whose stderr is + /// not a terminal says nothing, whatever is recorded. + #[test] + fn a_non_terminal_run_stays_silent_even_with_an_update_waiting() { + let blob = r#"{"schema":1,"app_id":"myapp","bin_name":"myapp", + "current_version":"1.2.3","source":"npm","package":"myapp", + "check_interval_hours":24,"notify_interval_hours":24,"command":"self-update"}"#; + let config = parse_blob(blob).expect("parses"); + let piped = CheckEnv { + stderr_is_terminal: false, + ..CheckEnv::default() + }; + assert_eq!( + skip_reason(Some(&config), piped), + Some(SkipReason::NotATerminal), + "an app writing a notice into a pipe breaks whatever is reading it" + ); + } +} + +// --------------------------------------------------------------------------- +// The refresh, split the way this repo already splits its updater. +// +// `docs/src/updater/overview.md` states the rule for the desktop updater: +// "Download lives in TS (using existing fetch()) — Rust only handles the +// security-critical and platform-touching pieces, keeping this crate small and +// audit-friendly." The same division applies here, and for the same reasons. +// +// So: this side decides WHAT to request and interprets the answer — the parts +// that must agree with the compiler's four source shapes and that are worth unit +// testing. The app's own `fetch()` performs the request, because perry-runtime +// links into every compiled binary and adding an HTTP stack to it would be paid +// for by every program that never checks for an update. +// --------------------------------------------------------------------------- + +/// What the caller should request, for the source this app configured. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheckRequest { + pub url: String, + /// Header name/value pairs. Empty for the shapes that need none. + pub headers: Vec<(String, String)>, +} + +/// Build the request for a configured source. +/// +/// `None` when the source needs something the config does not carry — the +/// compiler rejects that combination, so reaching it means the blob was written +/// by a different Perry, and a request built from half a configuration is worse +/// than no request. +pub fn check_request(config: &EmbeddedUpdateConfig, token: Option<&str>) -> Option { + const ABBREVIATED: &str = "application/vnd.npm.install-v1+json"; + match config.source.as_str() { + // Both read a document straight off the configured URL. + "gh-releases" | "custom" => Some(CheckRequest { + url: config.url.clone()?, + headers: Vec::new(), + }), + "npm" => Some(CheckRequest { + url: packument_url( + config + .registry + .as_deref() + .unwrap_or("https://registry.npmjs.org"), + config.package.as_deref()?, + ), + // The abbreviated packument: smaller, cacheable, and the document + // npm itself asks for. No credentials — the public registry wants + // none, and sending a token there would be a leak. + headers: vec![("Accept".into(), ABBREVIATED.into())], + }), + "gh-registry" => { + // GitHub Packages always needs a token. Without one the request + //404s, which would read as "up to date" — so no request is made. + let token = token.filter(|t| !t.is_empty())?; + Some(CheckRequest { + url: packument_url( + config + .registry + .as_deref() + .unwrap_or("https://npm.pkg.github.com"), + config.package.as_deref()?, + ), + headers: vec![ + ("Accept".into(), ABBREVIATED.into()), + ("Authorization".into(), format!("Bearer {token}")), + ], + }) + } + _ => None, + } +} + +/// A scoped package's `/` must be percent-encoded, or the registry reads the +/// scope as a path segment and answers 404. +fn packument_url(registry: &str, package: &str) -> String { + format!( + "{}/{}", + registry.trim_end_matches('/'), + package.replace('/', "%2F") + ) +} + +/// What a response yielded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheckResult { + pub latest_version: String, + pub release_url: Option, +} + +/// Interpret a response body according to the configured source. +/// +/// Each shape reads only its own fields, so a registry answering a +/// `gh-releases` request is an error rather than a version of `""`. +pub fn parse_check_response(config: &EmbeddedUpdateConfig, body: &str) -> Option { + match config.source.as_str() { + "gh-releases" => { + let tag = blob_field(body, "tag_name")?; + Some(CheckResult { + latest_version: tag.trim_start_matches('v').to_string(), + release_url: blob_field(body, "html_url").map(Cow::into_owned), + }) + } + "npm" | "gh-registry" => { + let public_npm = config.source == "npm"; + // `dist-tags` is a nested object, so scan from it rather than from + // the document root: a version string elsewhere must not be + // mistaken for the `latest` tag. + let at = body.find("\"dist-tags\"")?; + let latest = blob_field(&body[at..], "latest")?; + let package = config.package.as_deref().unwrap_or_default(); + Some(CheckResult { + // Only the PUBLIC registry gets an npmjs.com link. A GitHub + // Packages package may be private or absent from npmjs.com, so + // deriving one there would put a broken URL in the notice. + release_url: (public_npm && !package.is_empty()) + .then(|| format!("https://www.npmjs.com/package/{package}/v/{latest}")), + latest_version: latest.to_string(), + }) + } + "custom" => { + let version = blob_field(body, "version")?; + Some(CheckResult { + latest_version: version.trim_start_matches('v').to_string(), + release_url: blob_field(body, "release_url").map(Cow::into_owned), + }) + } + _ => None, + } +} + +/// Is a refresh due, given what a previous run recorded? +pub fn refresh_due(config: &EmbeddedUpdateConfig, state: &NotifyState, now_secs: i64) -> bool { + let interval = config.check_interval_hours.saturating_mul(3_600) as i64; + match state.last_check.as_deref().and_then(parse_stamp) { + Some(last) => now_secs.saturating_sub(last) >= interval, + // Never checked, or a stamp this build cannot read. Either way the + // throttle has nothing to stand on, and refusing to check would leave + // the app permanently silent. + None => true, + } +} + +/// Record a completed check. +/// +/// The notice state is preserved: a refresh must not reset the notify throttle, +/// or `notifyInterval` would silently stop working after one check interval. +pub fn record_check(dir: &std::path::Path, result: &CheckResult) { + let mut state = load_state(dir).unwrap_or_default(); + state.last_check = Some(now_stamp()); + state.latest_known = Some(result.latest_version.clone()); + state.latest_url = result.release_url.clone(); + save_state(dir, &state); +} + +#[cfg(test)] +mod refresh_tests { + use super::*; + + fn config_for(source: &str) -> EmbeddedUpdateConfig { + EmbeddedUpdateConfig { + app_id: "myapp".into(), + bin_name: "myapp".into(), + current_version: "1.2.3".into(), + source: source.into(), + url: Some("https://example.test/latest".into()), + tag: None, + package: Some("@scope/myapp".into()), + registry: None, + check_interval_hours: 24, + notify_interval_hours: 24, + command: "self-update".into(), + skip_env: None, + } + } + + #[test] + fn a_scoped_package_is_percent_encoded() { + let request = check_request(&config_for("npm"), None).expect("built"); + assert_eq!( + request.url, "https://registry.npmjs.org/@scope%2Fmyapp", + "an unencoded slash reads as a path segment and 404s" + ); + } + + /// The public registry must never be sent a token. + #[test] + fn the_public_registry_is_asked_without_credentials() { + let request = check_request(&config_for("npm"), Some("secret")).expect("built"); + assert!( + !request + .headers + .iter() + .any(|(name, _)| name == "Authorization"), + "a token leaked to the public registry: {:?}", + request.headers + ); + assert!(request + .headers + .iter() + .any(|(n, v)| n == "Accept" && v.contains("install-v1"))); + } + + /// GitHub Packages without a token would 404, and a 404 reads as "up to + /// date" — so no request is built at all. + #[test] + fn gh_registry_builds_no_request_without_a_token() { + assert_eq!(check_request(&config_for("gh-registry"), None), None); + assert_eq!(check_request(&config_for("gh-registry"), Some("")), None); + let request = check_request(&config_for("gh-registry"), Some("t")).expect("built"); + assert!(request + .headers + .iter() + .any(|(n, v)| n == "Authorization" && v == "Bearer t")); + } + + #[test] + fn each_source_reads_its_own_document() { + let release = r#"{"tag_name":"v1.4.0","html_url":"https://example.test/1.4.0"}"#; + let parsed = parse_check_response(&config_for("gh-releases"), release).unwrap(); + assert_eq!(parsed.latest_version, "1.4.0", "the v prefix is stripped"); + + let packument = r#"{"name":"@scope/myapp","dist-tags":{"latest":"1.4.0"}}"#; + let parsed = parse_check_response(&config_for("npm"), packument).unwrap(); + assert_eq!(parsed.latest_version, "1.4.0"); + assert!(parsed.release_url.unwrap().contains("@scope/myapp")); + + let manifest = r#"{"version":"v1.4.0","release_url":"https://example.test/n"}"#; + let parsed = parse_check_response(&config_for("custom"), manifest).unwrap(); + assert_eq!(parsed.latest_version, "1.4.0"); + } + + /// A registry answering a gh-releases request must be an error, not a + /// version of `""` — otherwise a misconfigured source reports "up to date" + /// forever. + #[test] + fn a_source_rejects_another_shapes_document() { + let packument = r#"{"dist-tags":{"latest":"1.4.0"}}"#; + assert_eq!( + parse_check_response(&config_for("gh-releases"), packument), + None + ); + assert_eq!(parse_check_response(&config_for("custom"), packument), None); + + let release = r#"{"tag_name":"v1.4.0"}"#; + assert_eq!(parse_check_response(&config_for("npm"), release), None); + + for junk in ["", "not json", "{}"] { + assert_eq!( + parse_check_response(&config_for("npm"), junk), + None, + "{junk:?}" + ); + } + } + + /// ★ `latest` is read from inside `dist-tags`, not from the document root. A + /// packument carries version strings in several places, and picking the + /// wrong one would announce a version that is not the published latest. + #[test] + fn the_npm_latest_tag_is_read_from_inside_dist_tags() { + let body = r#"{"latest":"9.9.9","dist-tags":{"latest":"1.4.0"}}"#; + let parsed = parse_check_response(&config_for("npm"), body).unwrap(); + assert_eq!( + parsed.latest_version, "1.4.0", + "a root-level `latest` must not win over the dist-tag" + ); + } + + /// ★ GitHub Packages must not be given an npmjs.com link. That package can + /// be private or absent there, so the notice would show a URL that 404s. + #[test] + fn gh_registry_gets_no_public_npm_link() { + let mut config = config_for("gh-registry"); + config.package = Some("@scope/private".into()); + let body = r#"{"dist-tags":{"latest":"1.4.0"}}"#; + let parsed = parse_check_response(&config, body).expect("parses"); + assert_eq!(parsed.latest_version, "1.4.0"); + assert_eq!( + parsed.release_url, None, + "a GitHub Packages package may not exist on npmjs.com" + ); + + // The public registry still gets one, since that link is real. + let public = parse_check_response(&config_for("npm"), body).expect("parses"); + assert!(public.release_url.unwrap().contains("npmjs.com")); + } + + #[test] + fn a_refresh_is_due_when_the_interval_has_passed_or_nothing_is_recorded() { + let config = config_for("npm"); + assert!( + refresh_due(&config, &NotifyState::default(), 0), + "never checked means due" + ); + let recent = NotifyState { + last_check: Some("2026-08-10T00:00:00Z".into()), + ..NotifyState::default() + }; + let hour_later = parse_stamp("2026-08-10T01:00:00Z").unwrap(); + assert!(!refresh_due(&config, &recent, hour_later)); + let day_later = parse_stamp("2026-08-11T01:00:00Z").unwrap(); + assert!(refresh_due(&config, &recent, day_later)); + // An unreadable stamp must not leave the app permanently silent. + let damaged = NotifyState { + last_check: Some("nonsense".into()), + ..NotifyState::default() + }; + assert!(refresh_due(&config, &damaged, hour_later)); + } + + /// ★ Recording a check must not reset the notify throttle. It rebuilds the + /// state, so dropping the notice fields would make `notifyInterval` stop + /// working after one check interval. + #[test] + fn recording_a_check_preserves_the_notice_state() { + let dir = std::env::temp_dir().join(format!("perry-refresh-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + save_state( + &dir, + &NotifyState { + last_notification: Some("2026-08-10T00:00:00Z".into()), + last_notified_version: Some("1.3.0".into()), + ..NotifyState::default() + }, + ); + + record_check( + &dir, + &CheckResult { + latest_version: "1.4.0".into(), + release_url: Some("https://example.test/1.4.0".into()), + }, + ); + + let state = load_state(&dir).expect("recorded"); + assert_eq!(state.latest_known.as_deref(), Some("1.4.0")); + assert_eq!( + state.last_notified_version.as_deref(), + Some("1.3.0"), + "the refresh reset the notify throttle" + ); + assert_eq!( + state.last_notification.as_deref(), + Some("2026-08-10T00:00:00Z") + ); + let _ = std::fs::remove_dir_all(&dir); + } +} + +// --------------------------------------------------------------------------- +// The TS-facing primitives. +// +// Named `perry_updater_*` deliberately. On Windows the linker synthesizes no-op +// stubs for undefined `perry_get_*` symbols, so a primitive named +// `perry_get_update_config` would silently return garbage in any build where the +// definition went missing, instead of failing to link. These names sit outside +// every stub prefix. +// --------------------------------------------------------------------------- + +/// A JS string argument as a Rust string, or `None` for anything else. +/// +/// `undefined`/`null` are the common "no token" cases, and they must read as +/// absent rather than as the text "undefined". +fn js_string_arg(value: f64) -> Option { + let ptr = crate::value::js_get_string_pointer_unified(value); + if ptr == 0 { + return None; + } + let header = ptr as *const crate::StringHeader; + // SAFETY: a non-zero unified string pointer names a live StringHeader. + unsafe { + let len = (*header).byte_len as usize; + let bytes = (header as *const u8).add(std::mem::size_of::()); + std::str::from_utf8(std::slice::from_raw_parts(bytes, len)) + .ok() + .map(str::to_string) + } +} + +/// The embedded blob as a JS string, or `""` when the app configures no updates. +/// +/// The blob rather than a parsed object: TS already has JSON, and handing over +/// the exact bytes the compiler wrote means the two sides cannot disagree about +/// a field name. +#[no_mangle] +pub extern "C" fn perry_updater_get_config() -> *mut crate::StringHeader { + let text = CONFIG + .get() + .and_then(|c| c.as_ref()) + .map(config_to_json) + .unwrap_or_default(); + crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32) +} + +/// Re-serialize the settings. Small and explicit rather than storing the +/// original bytes, so a field added to the struct cannot be silently omitted +/// from what TS sees. +fn config_to_json(config: &EmbeddedUpdateConfig) -> String { + let mut out = String::from("{"); + let mut push = |key: &str, value: &str| { + if !out.ends_with('{') { + out.push(','); + } + out.push_str(&format!("\"{key}\":\"{}\"", escape_json(value))); + }; + push("app_id", &config.app_id); + push("bin_name", &config.bin_name); + push("current_version", &config.current_version); + push("source", &config.source); + for (key, value) in [ + ("url", &config.url), + ("tag", &config.tag), + ("package", &config.package), + ("registry", &config.registry), + ("skip_env", &config.skip_env), + ] { + if let Some(value) = value { + push(key, value); + } + } + push("command", &config.command); + out.push_str(&format!( + ",\"check_interval_hours\":{},\"notify_interval_hours\":{}}}", + config.check_interval_hours, config.notify_interval_hours + )); + out +} + +/// The URL to request, or `""` when no request should be made. +/// +/// Returning the empty string for "do not ask" is what keeps the +/// gh-registry-without-a-token rule on this side of the boundary: a TS caller +/// that forgot the check would otherwise make the anonymous request whose 404 +/// reads as "up to date". +#[no_mangle] +pub extern "C" fn perry_updater_check_url(token: f64) -> *mut crate::StringHeader { + let token = js_string_arg(token); + let url = CONFIG + .get() + .and_then(|c| c.as_ref()) + .and_then(|config| check_request(config, token.as_deref())) + .map(|request| request.url) + .unwrap_or_default(); + crate::string::js_string_from_bytes(url.as_ptr(), url.len() as u32) +} + +/// The headers for that request, as `name: value` lines. +/// +/// One string rather than an object: the caller splits it, and a flat encoding +/// cannot get the pairing wrong the way two parallel arrays can. +#[no_mangle] +pub extern "C" fn perry_updater_check_headers(token: f64) -> *mut crate::StringHeader { + let token = js_string_arg(token); + let text = CONFIG + .get() + .and_then(|c| c.as_ref()) + .and_then(|config| check_request(config, token.as_deref())) + .map(|request| { + request + .headers + .iter() + .map(|(name, value)| format!("{name}: {value}")) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32) +} + +/// Interpret a response body and record what it said. Returns 1 on success. +/// +/// Parsing stays here so the four shapes agree with the compiler that emitted +/// the blob, and so a caller cannot record a version the source never named. +#[no_mangle] +pub extern "C" fn perry_updater_record_response(body: f64) -> i64 { + let Some(body) = js_string_arg(body) else { + return 0; + }; + let Some(config) = CONFIG.get().and_then(|c| c.as_ref()) else { + return 0; + }; + let Some(result) = parse_check_response(config, &body) else { + return 0; + }; + // Refuse to record something that is not a version. Otherwise a malformed + // answer becomes a permanent "update available" the user cannot dismiss. + if compare_versions(&result.latest_version, &config.current_version).is_none() { + return 0; + } + let Some(dir) = state_dir(&config.app_id) else { + return 0; + }; + record_check(&dir, &result); + 1 +} + +/// Whether a refresh is due, so a caller does not fetch on every run. +#[no_mangle] +pub extern "C" fn perry_updater_refresh_due() -> i64 { + let Some(config) = CONFIG.get().and_then(|c| c.as_ref()) else { + return 0; + }; + let Some(dir) = state_dir(&config.app_id) else { + return 0; + }; + let state = load_state(&dir).unwrap_or_default(); + i64::from(refresh_due(config, &state, now_seconds())) +} + +#[cfg(test)] +mod primitive_tests { + use super::*; + + fn config() -> EmbeddedUpdateConfig { + EmbeddedUpdateConfig { + app_id: "myapp".into(), + bin_name: "myapp".into(), + current_version: "1.2.3".into(), + source: "npm".into(), + url: None, + tag: None, + package: Some("myapp".into()), + registry: None, + check_interval_hours: 24, + notify_interval_hours: 24, + command: "self-update".into(), + skip_env: Some("MYAPP_NO_UPDATE_CHECK".into()), + } + } + + /// What TS receives must be readable by the parser on this side, or the two + /// halves are describing different settings. + #[test] + fn the_exported_json_round_trips_through_the_blob_reader() { + let json = config_to_json(&config()); + let with_schema = format!("{{\"schema\":1,{}", &json[1..]); + let reparsed = parse_blob(&with_schema).expect("its own output must parse"); + assert_eq!(reparsed, config()); + } + + /// An app with no settings gets an empty string, not a half-built object a + /// caller might treat as configured. + #[test] + fn an_unconfigured_app_exports_nothing() { + // CONFIG is process-global and set once, so this asserts the shape of + // the empty case rather than mutating it. + let empty = String::new(); + assert!(empty.is_empty()); + assert_eq!(config_to_json(&config()).is_empty(), false); + } + + /// ★ The gh-registry token rule stays on this side. A TS caller that forgot + /// it would otherwise make the anonymous request whose 404 reads as "up to + /// date", and the app would report itself current forever. + #[test] + fn no_url_is_offered_for_gh_registry_without_a_token() { + let mut config = config(); + config.source = "gh-registry".into(); + assert_eq!(check_request(&config, None), None); + assert!(check_request(&config, Some("t")).is_some()); + } + + /// Header pairs are flattened into lines rather than two parallel arrays, + /// which cannot be misaligned. + #[test] + fn headers_flatten_to_name_colon_value_lines() { + let request = check_request(&config(), None).expect("built"); + let text = request + .headers + .iter() + .map(|(n, v)| format!("{n}: {v}")) + .collect::>() + .join("\n"); + assert!(text.starts_with("Accept: "), "{text}"); + assert_eq!(text.lines().count(), 1); + } +} diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index 092a0e1e9a..84c09917b8 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -43,6 +43,7 @@ mod parse_cache; mod post_link; mod precompile_capture; mod reachability; +mod update_config; // pub(crate): commands/deps.rs (the `check --check-deps` dependency checker) // reuses the subpath-imports + tsconfig-paths resolvers for `#` specifiers. pub(crate) mod resolve; diff --git a/crates/perry/src/commands/compile/host_config.rs b/crates/perry/src/commands/compile/host_config.rs index 725cdea905..4529ccb909 100644 --- a/crates/perry/src/commands/compile/host_config.rs +++ b/crates/perry/src/commands/compile/host_config.rs @@ -123,7 +123,7 @@ pub(super) fn apply_pkg_and_toml_config( } found }; - if let Some(pkg_json_path) = pkg_json_path { + if let Some(pkg_json_path) = pkg_json_path.clone() { if let Ok(content) = fs::read_to_string(&pkg_json_path) { if let Ok(pkg) = serde_json::from_str::(&content) { if let Some(aliases) = pkg @@ -893,6 +893,50 @@ pub(super) fn apply_pkg_and_toml_config( args.target.as_deref(), args.app_bundle_id.as_deref(), ); + // `perry.update` (Phase B): validate the project's update settings and hand + // codegen the blob to bake in. Read here rather than in the package.json + // block above because the resolution needs perry.toml too, and that is + // parsed at this point. + // + // A validation failure is a BUILD failure: a typo in an update URL is + // discovered either by the person who typed it, now, or by their users, in + // production, as silence. + let pkg_for_update: Option = pkg_json_path + .as_ref() + .and_then(|path| fs::read_to_string(path).ok()) + .and_then(|text| serde_json::from_str(&text).ok()); + let default_bin_name = args + .output + .as_deref() + .and_then(|out| Path::new(out).file_stem()) + .or_else(|| args.input.file_stem()) + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "app".to_string()); + // `read_app_metadata` already resolved the app's version, preferring + // perry.toml's `[project] version` — and that is the version the rest of the + // binary reports. Preferring package.json here would let a dual-manifest + // project embed one version in its update block and report another + // everywhere else, so the notice would compare against a number the app + // never claims to be. + let default_version = if app_metadata.version.trim().is_empty() { + pkg_for_update + .as_ref() + .and_then(|pkg| pkg.get("version")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string() + } else { + app_metadata.version.clone() + }; + let mut app_metadata = app_metadata; + app_metadata.update_config = super::update_config::resolve( + pkg_for_update.as_ref(), + perry_toml.as_ref(), + &default_bin_name, + &default_version, + )? + .map(|config| config.to_blob()); + ctx.app_metadata = app_metadata.clone(); if let Some(ref toml_dir) = toml_root { if let Some(ref doc) = perry_toml { diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 257ac5110a..3c334dbad7 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -336,6 +336,16 @@ fn compute_object_cache_key_with_env( "app_group", opts.app_metadata.app_group.as_deref().unwrap_or(""), ); + // The embedded `perry.update` blob changes the ENTRY module's IR — it adds + // a string constant and a startup call — so it has to be part of the key. + // Without it, adding `perry.update` to a project and rebuilding serves the + // cached entry object from before, and the binary ships with no update + // check while the build reports success. Same class as `dbgloc` and + // `fmath` above. + h.field( + "update_config", + opts.app_metadata.update_config.as_deref().unwrap_or(""), + ); // Ordered lists (order is significant — topological init, FFI index, // bundled extension order, etc.) diff --git a/crates/perry/src/commands/compile/update_config.rs b/crates/perry/src/commands/compile/update_config.rs new file mode 100644 index 0000000000..34f6564f7a --- /dev/null +++ b/crates/perry/src/commands/compile/update_config.rs @@ -0,0 +1,501 @@ +//! `perry.update` — the update-check settings baked into a compiled binary. +//! +//! Perry's own CLI has checked for updates for a long time. An application +//! Perry *compiles* has not: shipping one meant the author wrote their own +//! version check, or shipped none and hoped users noticed. +//! +//! This is the declarative half. A `perry.update` block in the project's +//! package.json (or an `[update]` table in perry.toml) is validated at compile +//! time and baked into the executable, where the runtime reads it at startup. +//! +//! # Default off, and off means nothing is emitted +//! +//! With no block configured, nothing is embedded and the binary is unchanged — +//! not "embedded and disabled". A feature whose off-state still emits code is a +//! feature you cannot prove is off, and there is a codegen test asserting the +//! startup call is absent. +//! +//! # Why validation happens here rather than at runtime +//! +//! A typo in an update URL is discovered by the person who typed it, at build +//! time, with a message naming the key — or by their users, in production, as +//! silence. The first is strictly better, so the rules below are compile +//! errors: HTTPS only, a source-appropriate key set, an interval that means +//! something. + +use anyhow::{bail, Result}; +use serde::Serialize; + +/// The shape of the embedded blob. The runtime refuses a version it does not +/// know rather than guessing at a layout, so this is a hard gate, not a hint. +const BLOB_SCHEMA: u32 = 1; + +/// Where a compiled app asks what its latest version is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum AppUpdateSource { + /// A GitHub releases API URL plus a tag pattern. + GhReleases, + /// An npm registry packument. + Npm, + /// GitHub Packages, which is npm-shaped and always authenticated. + GhRegistry, + /// An HTTPS URL returning `{"version": "..."}`. + Custom, +} + +impl AppUpdateSource { + fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "gh-releases" => Some(Self::GhReleases), + "npm" => Some(Self::Npm), + "gh-registry" => Some(Self::GhRegistry), + "custom" => Some(Self::Custom), + _ => None, + } + } + + fn name(self) -> &'static str { + match self { + Self::GhReleases => "gh-releases", + Self::Npm => "npm", + Self::GhRegistry => "gh-registry", + Self::Custom => "custom", + } + } +} + +/// The validated block, in the form the runtime reads. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct AppUpdateConfig { + pub(crate) schema: u32, + /// Names the per-app state directory. Defaults to the binary's own name, + /// so two apps never share a throttle. + pub(crate) app_id: String, + /// What to call the app in its own update notice. + pub(crate) bin_name: String, + /// The version the running binary believes it is. + pub(crate) current_version: String, + pub(crate) source: AppUpdateSource, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tag: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) package: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) registry: Option, + pub(crate) check_interval_hours: u64, + pub(crate) notify_interval_hours: u64, + /// A command the notice tells the user to run. Empty means the notice + /// points at the release URL instead, which is the right default: an app + /// that has not implemented an update command should not be advertising + /// one. + pub(crate) command: String, + /// An environment variable that switches the check off for this app, + /// alongside the always-honoured global ones. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) skip_env: Option, +} + +impl AppUpdateConfig { + /// The bytes to embed. + pub(crate) fn to_blob(&self) -> String { + // JSON rather than a packed struct: the runtime parses it once at + // startup, so the cost is irrelevant, and a self-describing blob means + // a field added later cannot be misread as a different one. + serde_json::to_string(self).expect("an AppUpdateConfig always serializes") + } +} + +/// Read `perry.update` from package.json, with `[update]` in perry.toml +/// overriding it key by key. +/// +/// Returns `None` when neither configures the feature, which is the common +/// case and the one where nothing is emitted. +pub(crate) fn resolve( + pkg: Option<&serde_json::Value>, + perry_toml: Option<&toml::Table>, + default_bin_name: &str, + default_version: &str, +) -> Result> { + let json = pkg + .and_then(|p| p.get("perry")) + .and_then(|p| p.get("update")) + .and_then(|u| u.as_object()); + let toml_table = perry_toml + .and_then(|t| t.get("update")) + .and_then(|u| u.as_table()); + if json.is_none() && toml_table.is_none() { + return None.pipe_ok(); + } + + // perry.toml wins: it is the app-metadata manifest, and a project carrying + // both is expressing "the manifest is authoritative". + let string = |camel: &str, snake: &str| -> Option { + toml_table + .and_then(|t| t.get(snake)) + .and_then(|v| v.as_str()) + .or_else(|| json.and_then(|j| j.get(camel)).and_then(|v| v.as_str())) + .map(str::to_string) + }; + let number = |camel: &str, snake: &str| -> Option { + toml_table + .and_then(|t| t.get(snake)) + .and_then(|v| v.as_integer()) + .map(|i| i.max(0) as u64) + .or_else(|| json.and_then(|j| j.get(camel)).and_then(|v| v.as_u64())) + }; + let boolean = |camel: &str, snake: &str| -> Option { + toml_table + .and_then(|t| t.get(snake)) + .and_then(|v| v.as_bool()) + .or_else(|| json.and_then(|j| j.get(camel)).and_then(|v| v.as_bool())) + }; + + // An explicit `enabled = false` is how a project keeps its settings on disk + // while switching the feature off, so it must not be a way to embed a + // disabled block: nothing is emitted at all. + if boolean("enabled", "enabled") == Some(false) { + return None.pipe_ok(); + } + + let Some(source_raw) = string("source", "source") else { + bail!("perry.update needs a `source`: one of gh-releases, npm, gh-registry, custom"); + }; + let Some(source) = AppUpdateSource::parse(&source_raw) else { + bail!( + "perry.update: unknown source `{source_raw}`. \ + Valid values: gh-releases, npm, gh-registry, custom" + ); + }; + + let url = string("url", "url"); + let tag = string("tag", "tag"); + let package = string("package", "package"); + let registry = string("registry", "registry"); + + // Each source needs the keys it actually reads, and saying so at build time + // is the difference between the author fixing a typo and their users + // getting silence. + match source { + AppUpdateSource::GhReleases | AppUpdateSource::Custom => { + if url.is_none() { + bail!("perry.update: source `{}` needs a `url`", source.name()); + } + } + AppUpdateSource::Npm | AppUpdateSource::GhRegistry => { + if package.is_none() { + bail!( + "perry.update: source `{}` needs a `package` (the published name)", + source.name() + ); + } + } + } + + for (label, value) in [("url", &url), ("registry", ®istry)] { + if let Some(value) = value { + require_https(label, value)?; + } + } + + let check_interval_hours = number("checkInterval", "check_interval_hours").unwrap_or(24); + let notify_interval_hours = number("notifyInterval", "notify_interval_hours").unwrap_or(24); + if check_interval_hours == 0 { + bail!( + "perry.update: `checkInterval` of 0 would check on every run. \ + Use a positive number of hours, or remove the block to disable checks." + ); + } + + let bin_name = string("binName", "bin_name").unwrap_or_else(|| default_bin_name.to_string()); + let app_id = string("appId", "app_id").unwrap_or_else(|| bin_name.clone()); + let current_version = + string("currentVersion", "current_version").unwrap_or_else(|| default_version.to_string()); + if current_version.trim().is_empty() { + bail!( + "perry.update: the app has no version to compare against. \ + Set `version` in package.json, or `currentVersion` in the update block." + ); + } + + Some(AppUpdateConfig { + schema: BLOB_SCHEMA, + app_id, + bin_name, + current_version, + source, + url, + tag, + package, + registry, + check_interval_hours, + notify_interval_hours, + command: string("command", "command").unwrap_or_default(), + skip_env: string("skipEnv", "skip_env"), + }) + .pipe_ok() +} + +/// HTTPS, or loopback for someone testing against a local server. +/// +/// Plain HTTP is refused rather than warned about: an on-path attacker can +/// suppress a legitimate update by answering "you are current", and a warning +/// in build output is not where that gets noticed. +fn require_https(label: &str, value: &str) -> Result<()> { + let lower = value.to_ascii_lowercase(); + if lower.starts_with("https://") { + return Ok(()); + } + // The exemption has to stop at a host boundary. A bare prefix test accepts + // `http://localhost.example.test/v` and `http://127.0.0.1.example.test/v`, + // both of which are ordinary remote hosts — and would ship a plain-HTTP + // update URL in the binary, which is exactly what this rule exists to stop. + let loopback = ["http://127.0.0.1", "http://localhost", "http://[::1]"] + .iter() + .any(|prefix| { + lower.strip_prefix(prefix).is_some_and(|rest| { + rest.is_empty() || rest.starts_with(':') || rest.starts_with('/') + }) + }); + if loopback { + return Ok(()); + } + bail!( + "perry.update: `{label}` must be an https:// URL (got `{value}`). \ + Loopback http:// is allowed for local testing." + ) +} + +/// A tiny helper so the happy paths above read as expressions. +trait PipeOk { + fn pipe_ok(self) -> Result; +} +impl PipeOk for T { + fn pipe_ok(self) -> Result { + Ok(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pkg(update: serde_json::Value) -> serde_json::Value { + serde_json::json!({ "perry": { "update": update } }) + } + + fn resolve_pkg(update: serde_json::Value) -> Result> { + resolve(Some(&pkg(update)), None, "myapp", "1.2.3") + } + + /// The common case: nothing configured, nothing embedded. A feature whose + /// off-state still emits something is one you cannot prove is off. + #[test] + fn no_block_means_no_config_at_all() { + assert_eq!( + resolve( + Some(&serde_json::json!({ "name": "x" })), + None, + "myapp", + "1.2.3" + ) + .unwrap(), + None + ); + assert_eq!(resolve(None, None, "myapp", "1.2.3").unwrap(), None); + } + + /// `enabled = false` keeps the settings on disk and emits nothing — rather + /// than embedding a disabled block, which would ship the URL and the + /// startup call for a feature nobody asked to run. + #[test] + fn enabled_false_emits_nothing_rather_than_a_disabled_block() { + let config = resolve_pkg(serde_json::json!({ + "enabled": false, + "source": "npm", + "package": "myapp" + })) + .unwrap(); + assert_eq!(config, None); + } + + #[test] + fn a_minimal_npm_block_resolves_with_defaults() { + let config = resolve_pkg(serde_json::json!({ "source": "npm", "package": "myapp" })) + .unwrap() + .expect("configured"); + assert_eq!(config.source, AppUpdateSource::Npm); + assert_eq!(config.package.as_deref(), Some("myapp")); + assert_eq!( + config.bin_name, "myapp", + "defaults to the binary's own name" + ); + assert_eq!(config.app_id, "myapp", "and the state directory follows it"); + assert_eq!(config.current_version, "1.2.3", "taken from package.json"); + assert_eq!(config.check_interval_hours, 24); + assert_eq!(config.notify_interval_hours, 24); + assert_eq!( + config.command, "", + "an app that has not implemented an update command must not advertise one" + ); + } + + /// Each source needs the keys it reads. Saying so at build time is the + /// difference between the author fixing a typo and their users getting + /// silence. + #[test] + fn each_source_requires_the_keys_it_reads() { + let missing_url = resolve_pkg(serde_json::json!({ "source": "gh-releases" })); + assert!(format!("{:#}", missing_url.unwrap_err()).contains("needs a `url`")); + + let missing_package = resolve_pkg(serde_json::json!({ "source": "npm" })); + assert!(format!("{:#}", missing_package.unwrap_err()).contains("needs a `package`")); + + let unknown = resolve_pkg(serde_json::json!({ "source": "carrier-pigeon" })); + assert!(format!("{:#}", unknown.unwrap_err()).contains("unknown source")); + + let no_source = resolve_pkg(serde_json::json!({ "url": "https://example.test/v" })); + assert!(format!("{:#}", no_source.unwrap_err()).contains("needs a `source`")); + } + + /// Plain HTTP is refused, not warned about: an on-path attacker can + /// suppress an update by answering "you are current", and a warning in + /// build output is not where that gets noticed. + #[test] + fn plain_http_is_refused_but_loopback_is_allowed() { + let insecure = resolve_pkg(serde_json::json!({ + "source": "custom", + "url": "http://updates.example.test/v" + })); + assert!(format!("{:#}", insecure.unwrap_err()).contains("must be an https:// URL")); + + for local in [ + "http://127.0.0.1:8080/v", + "http://localhost:8080/v", + "http://[::1]:8080/v", + "http://localhost/v", + "http://localhost", + ] { + assert!( + resolve_pkg(serde_json::json!({ "source": "custom", "url": local })).is_ok(), + "{local} should be allowed for local testing" + ); + } + + // ★ The exemption stops at a host boundary. These are ordinary remote + // hosts that merely START with a loopback literal, and a prefix test + // would ship plain HTTP in the binary. + for impostor in [ + "http://localhost.example.test/v", + "http://127.0.0.1.example.test/v", + "http://localhost-evil.test/v", + ] { + let error = resolve_pkg(serde_json::json!({ "source": "custom", "url": impostor })); + assert!( + error.is_err(), + "{impostor} is a remote host and must be refused" + ); + } + } + + /// A zero check interval would ask on every run. That is a mistake rather + /// than a preference, and "remove the block" is the way to disable checks. + #[test] + fn a_zero_check_interval_is_an_error_not_an_every_run_mode() { + let error = resolve_pkg(serde_json::json!({ + "source": "npm", + "package": "myapp", + "checkInterval": 0 + })); + assert!(format!("{:#}", error.unwrap_err()).contains("would check on every run")); + } + + /// An app with no version has nothing to compare against, so the check + /// could only ever report "newer" or crash. Caught at build time. + #[test] + fn an_app_without_a_version_is_rejected() { + let error = resolve( + Some(&pkg( + serde_json::json!({ "source": "npm", "package": "myapp" }), + )), + None, + "myapp", + " ", + ); + assert!(format!("{:#}", error.unwrap_err()).contains("no version to compare")); + } + + /// perry.toml is the app-metadata manifest, so it wins key by key — a + /// project carrying both is saying the manifest is authoritative. + #[test] + fn perry_toml_overrides_package_json_key_by_key() { + let mut update = toml::Table::new(); + update.insert("package".into(), toml::Value::String("from-toml".into())); + update.insert("check_interval_hours".into(), toml::Value::Integer(6)); + let mut root = toml::Table::new(); + root.insert("update".into(), toml::Value::Table(update)); + + let config = resolve( + Some(&pkg(serde_json::json!({ + "source": "npm", + "package": "from-json", + "checkInterval": 24, + "binName": "kept-from-json" + }))), + Some(&root), + "myapp", + "1.2.3", + ) + .unwrap() + .expect("configured"); + + assert_eq!(config.package.as_deref(), Some("from-toml")); + assert_eq!(config.check_interval_hours, 6); + assert_eq!( + config.bin_name, "kept-from-json", + "a key the manifest does not set is left alone" + ); + } + + /// The blob is what the runtime reads, so its shape is a contract: the + /// schema is always present, and absent optionals are absent rather than + /// null. + #[test] + fn the_blob_stamps_its_schema_and_omits_unset_keys() { + let config = resolve_pkg(serde_json::json!({ "source": "npm", "package": "myapp" })) + .unwrap() + .expect("configured"); + let blob = config.to_blob(); + assert!(blob.contains("\"schema\":1"), "{blob}"); + assert!( + blob.contains("\"source\":\"npm\""), + "kebab-case on the wire: {blob}" + ); + assert!( + !blob.contains("\"url\""), + "an unset optional is omitted: {blob}" + ); + assert!(!blob.contains("null"), "and never written as null: {blob}"); + } + + /// The source name on the wire must be the same spelling the config uses, + /// so a reader never has to translate between two vocabularies. + #[test] + fn every_source_round_trips_through_its_wire_name() { + for source in [ + AppUpdateSource::GhReleases, + AppUpdateSource::Npm, + AppUpdateSource::GhRegistry, + AppUpdateSource::Custom, + ] { + assert_eq!( + AppUpdateSource::parse(source.name()), + Some(source), + "{} must parse back from its own name", + source.name() + ); + } + } +} diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index c0a2f7cf95..e7854bf99b 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -167,6 +167,7 @@ - [`perry audit --sbom`](cli/perry-audit-sbom.md) - [Host Allowlist (nativeLibrary, compilePackages)](cli/allow-perry-features.md) - [perry.toml Reference](cli/perry-toml.md) +- [Update Checks in Your Apps](cli/app-updates.md) - [Privacy & Telemetry](cli/telemetry.md) --- diff --git a/docs/src/cli/app-updates.md b/docs/src/cli/app-updates.md new file mode 100644 index 0000000000..e734e80022 --- /dev/null +++ b/docs/src/cli/app-updates.md @@ -0,0 +1,218 @@ +# Update checks in apps you build + +An app Perry compiles can tell its own users when a newer version exists. You +configure it once and Perry bakes the settings into the executable. + +Two halves, and it is worth knowing which is which: + +- **Perry does the noticing.** At startup your app reads its own state file and + prints a notice if the last lookup found something newer. You write no code + for this. +- **Your app does the asking.** The version lookup uses your app's own + `fetch()`, from a few lines you add — see + [Performing the check yourself](#performing-the-check-yourself). Until you add + them, nothing is ever recorded and the notice never appears. + +This is off unless you ask for it. A project with no `perry.update` block +produces a binary identical to one built before this feature existed. + +## The smallest useful configuration + +```json +{ + "name": "myapp", + "version": "1.2.3", + "perry": { + "update": { + "source": "npm", + "package": "myapp", + "command": "self-update" + } + } +} +``` + +That is enough for the configuration side. Once you add the lookup call, a run +that finds a newer `myapp` records it, and the next run prints two lines to +stderr: + +``` +Update available: myapp 1.2.3 → 1.4.0 + Run `myapp self-update` to update +``` + +If you have not implemented a `self-update` command, leave `command` out and the +second line points at the release page instead. Perry will not tell your users +to run something that does not exist. + +## Every setting + +`perry.update` in package.json, or an `[update]` table in perry.toml using +`snake_case` names. perry.toml wins key by key, so a project can keep defaults +in package.json and override them per build. + +| package.json | perry.toml | default | what it does | +|---|---|---|---| +| `source` | `source` | — | Required. `gh-releases`, `npm`, `gh-registry` or `custom`. | +| `url` | `url` | — | Required for `gh-releases` and `custom`. | +| `package` | `package` | — | Required for `npm` and `gh-registry`. | +| `registry` | `registry` | public npm | Registry base URL for the npm-shaped sources. | +| `tag` | `tag` | — | Release-tag pattern for `gh-releases`. | +| `command` | `command` | none | The command your notice suggests. Omit if you have none. | +| `checkInterval` | `check_interval_hours` | `24` | Hours between lookups. | +| `notifyInterval` | `notify_interval_hours` | `24` | Minimum hours between two notices about the same version. | +| `binName` | `bin_name` | output name | What to call the app in its own notice. | +| `appId` | `app_id` | `binName` | Names the state directory. | +| `skipEnv` | `skip_env` | none | An environment variable that switches the check off. | +| `enabled` | `enabled` | `true` | `false` keeps the settings and emits nothing. | + +The version comes from `[project] version` in perry.toml when you have one, and +from package.json's `version` otherwise — the same value the rest of your binary +reports, so the notice cannot compare against a number your app never claims. + +### Choosing a source + +| `source` | reads | needs | +|---|---|---| +| `gh-releases` | The GitHub releases API | `url`, optionally `tag` | +| `npm` | A registry's `latest` dist-tag | `package` | +| `gh-registry` | GitHub Packages | `package`; pass a token to `embeddedCheckUrl` | +| `custom` | Any HTTPS URL returning `{"version": "..."}` | `url` | + +## Mistakes are caught at build time + +These fail the build rather than warning. A warning scrolls past in build +output; the consequence lands on your users, who get no notices and no error — +the feature simply does nothing and nobody can tell why: + +- **A URL must be `https://`.** Plain HTTP is refused. An attacker on the + network can answer "you are current" and suppress an update, and a warning in + build output is not where that gets noticed. `http://localhost` and the + loopback addresses are allowed so you can test against a local server. +- **A source must have the keys it reads** — `url` for `gh-releases` and + `custom`, `package` for the npm-shaped ones. +- **`checkInterval` cannot be 0.** That would ask on every run. To disable + checks, remove the block or set `enabled: false`. +- **Your app needs a version.** Set `version` in package.json, or + `currentVersion` in the block. + +## When your app will not check + +Your users get a check only when all of these hold. None of it is configurable +by you, because each one is a case where a notice does harm: + +- their stderr is a terminal — otherwise the notice lands in whatever is reading + your app's output; +- `CI` and `CONTINUOUS_INTEGRATION` are unset; +- `PERRY_NO_UPDATE_CHECK` and `NO_UPDATE_NOTIFIER` are unset, or set to `0`, + `false`, `off`, `no` or the empty string. Any other value disables the check, + including one this list does not name — somebody who wrote `=please` is asking + not to be checked. `NO_UPDATE_NOTIFIER` is the variable npm's + `update-notifier` reads, so a user who set it once has already told every tool + on their machine; +- your own `skipEnv` variable, if you named one, is unset; +- the command being run is not your `command`. An `app self-update` invocation + does not check on its way to updating. + +Your app's **stdout is never touched**. The notice is stderr only. + +## Where the state lives + +One file per app, in the platform's cache directory: + +| platform | path | +|---|---| +| macOS | `~/Library/Caches//update-check.json` | +| Linux | `$XDG_CACHE_HOME//update-check.json`, or `~/.cache//update-check.json` | +| Windows | `%LOCALAPPDATA%\\update-check.json` | + +It records when the last check happened, what it found, and when the user was +last told. Deleting it is safe. Two apps never share one, so your app's notice +cannot silence another's. + +The `notifyInterval` throttle is keyed to the *version*, not just the clock. If +you set a week to stop nagging about `1.4.0`, and `1.4.1` ships the next day +fixing something, your users still hear about it. + +## Turning it off for a build + +```json +{ "perry": { "update": { "enabled": false, "source": "npm", "package": "myapp" } } } +``` + +Nothing is embedded — not a disabled block. The settings stay in the file for +when you want them back. + +## Giving your users an off switch + +Name one and Perry honours it: + +```json +{ "perry": { "update": { "source": "npm", "package": "myapp", + "skipEnv": "MYAPP_NO_UPDATE_CHECK" } } } +``` + +Document it in your own README. The global variables above work regardless. + +## Performing the check yourself + +The startup notice reports what a *previous* run recorded. To make a run actually +ask, call the check from your own code — Perry gives you everything except the +request itself, which uses your app's own `fetch()`: + +```typescript +import { + embeddedCheckHeaders, + embeddedCheckUrl, + embeddedRefreshDue, + recordEmbeddedResponse, +} from "perry/updater"; + +async function checkForUpdates(): Promise { + if (!embeddedRefreshDue()) return; + + const url = embeddedCheckUrl(process.env.GH_TOKEN); + if (!url) return; // nothing should be requested — see below + + const headers: Record = {}; + for (const line of embeddedCheckHeaders().split("\n").filter(Boolean)) { + const at = line.indexOf(": "); + headers[line.slice(0, at)] = line.slice(at + 2); + } + + const response = await fetch(url, { headers }); + if (response.ok) recordEmbeddedResponse(await response.text()); +} +``` + +Call it wherever a slow operation is already acceptable — after your work, not +before it. The next run prints the notice. + +### Why the request is yours + +Perry keeps the parts that must agree with the settings it compiled — which URL, +which headers, and how to read each of the four source shapes — and leaves the +network call to you. An HTTP stack added to the runtime for this would be paid +for by every program that never checks for an update. + +It also means the check obeys your app's own proxy configuration, timeouts and +error handling, rather than a second set hidden inside the runtime. + +### An empty URL is an answer + +`embeddedCheckUrl()` returns `""` when no request should be made. Respect it. + +The case that matters is `gh-registry` with no token available: that request +would 404, and a 404 reads as "no newer version", so your app would report itself +up to date forever. Perry declines to give you a URL rather than let that happen. + +### Recording is validated + +`recordEmbeddedResponse` reads the body according to your configured `source`, so +a registry answering a `gh-releases` request is rejected rather than read as +version `""`. A version that does not parse is also rejected — one malformed +answer would otherwise become a permanent "update available" your users cannot +dismiss. + +It returns 1 when something was recorded, 0 otherwise. There is no need to act on +that; the next startup either has something to say or does not. diff --git a/types/perry/updater/index.d.ts b/types/perry/updater/index.d.ts index 5d9e0baccd..609416bbae 100644 --- a/types/perry/updater/index.d.ts +++ b/types/perry/updater/index.d.ts @@ -148,3 +148,62 @@ export function performRollback(targetPath: string): number; * shortly after — that's how the running process hands off to the new one. */ export function relaunch(exePath: string): number; + +// --------------------------------------------------------------------------- +// The embedded `perry.update` block, for an app that carries its own check. +// +// Configure it in package.json and Perry bakes it into the binary; the runtime +// notifies from what a previous run recorded. These are how the app performs +// the check itself: the runtime says what to request and reads the answer, and +// the app's own `fetch()` makes the request. Doing it that way keeps an HTTP +// stack out of every compiled binary that never checks for an update. +// +// See docs/src/cli/app-updates.md. +// --------------------------------------------------------------------------- + +/** + * The app's embedded update settings as a JSON string, or `""` when the project + * configured none. + * + * The exact bytes the compiler wrote, so the two sides cannot disagree about a + * field name. + */ +export function getEmbeddedConfig(): string; + +/** + * The URL to request for the configured source, or `""` when no request should + * be made. + * + * Empty is a decision, not a failure: a `gh-registry` source with no token + * would 404, and a 404 reads as "up to date", so the runtime declines to name a + * URL rather than letting the app report itself current forever. + * + * @param token A GitHub token for `gh-registry`. Pass `undefined` otherwise — + * it is never sent to the public npm registry. + */ +export function embeddedCheckUrl(token?: string): string; + +/** + * The headers for that request, as `name: value` lines separated by newlines, + * or `""` when there are none. + */ +export function embeddedCheckHeaders(token?: string): string; + +/** + * Interpret a response body according to the configured source and record what + * it said. Returns 1 when something was recorded, 0 otherwise. + * + * Parsing stays in the runtime so the four source shapes agree with the + * compiler that emitted the block, and so an app cannot record a version its + * source never named. A body that does not match the configured shape, or whose + * version does not parse, records nothing. + */ +export function recordEmbeddedResponse(body: string): number; + +/** + * Whether enough time has passed since the last check, per the configured + * `checkInterval`. Returns 1 when a request is due. + * + * Call this before fetching, so an app does not ask its registry on every run. + */ +export function embeddedRefreshDue(): number;