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;