Skip to content

feat: machines update themselves, through the daemon's installer - #92

Merged
teo-goulois merged 8 commits into
mainfrom
feat/automatic-updates-core
Nov 9, 2026
Merged

teo-goulois merged 8 commits into
mainfrom
feat/automatic-updates-core

Conversation

@teo-goulois

@teo-goulois teo-goulois commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Part A of automatic updates (ADR 0034). The PR carries the contract: the three doc commits from docs/automatic-updates (ADR docs/adr/0034-machines-update-themselves.md and "Automatic updates" in docs/desktop.md), then the code.

What it does

  • One installer, in the core (src/infrastructure/updates/), run unattended by the daemon and by localify update install when a person asks:
    • reads latest.json (same endpoint source as the app: LOCALIFY_UPDATER_ENDPOINT / LOCALIFY_UPDATER_BASE_URL, none in debug builds), downloads the archive capped at 256 MiB, HTTPS only, no redirects;
    • verifies the minisign signature (Tauri's format, via minisign-verify, prehashed only) with the key from src-tauri/updater.pub before extracting;
    • extracts into a new private staging dir on the bundle's volume. Every folder from / down to the updates folder must be owned by this account or root, with no group/other write and no ACL entry letting another account add, delete or rename entries or change the ACL or owner (denials, this account's own entries and inherit-only entries don't count); otherwise the install stops and the status names the folder. The folders are opened from / one component at a time with O_NOFOLLOW, and the staging dir stays open until the swap. The updates folder, staging/ and the new dir must be owned by this account, lose group/other write and any ACL (inherited ones included), and the new dir is checked empty. One Localify.app, no absolute or .. paths, no hard links, devices or FIFOs, no setuid/setgid, no data in non-file entries, relative symlinks that stay inside (checked lexically, then through chains after extraction). Every path is walked from directory descriptors (openat/mkdirat with O_NOFOLLOW, files with O_EXCL) and symlinks are made last, so nothing ever follows a symlink, case-insensitive volumes included. tar is read in raw mode: long-name and PAX headers count toward 20,000 entries and are read here under 1 MiB together; PAX path/linkpath are used, size, sparse and global headers refused; files capped at 1 GiB, the decompressed stream capped too. No xattrs, so no quarantine;
    • checks the bundle's version equals the manifest's exactly and is newer (semver precedence, build metadata ignored) than both the installed one and the highest ever installed; skips a release whose archive failed here before, by archive SHA-256 and by release identity (version + manifest signature), the latter before downloading. Only the archive's failures are recorded (unreadable or malformed archive, a broken rule, a failed check), not this machine's: a write error such as a full disk, the staging dir, or a codesign/plutil that didn't start or was killed;
    • codesign --verify --deep --strict with a requirement anchored to Apple, Developer ID, team P5G8WAJ2G2 and com.localify.desktop, plus the same on Contents/MacOS/localify (localify). The team ID comes from the shipped app's signature (codesign -dvv /Applications/Localify.app), not a guess. Behind a SignatureCheck trait;
    • destination rules: the bundle holding this binary, at /Applications or ~/Applications, identifier com.localify.desktop, owned by this account, writable along with its parent, not a dev build. Each refusal is a sentence for Settings;
    • under update.lock: app.lock must be free, and no process may run from the bundle (proc_listallpids + proc_pidpath; an unreadable path stops the install). Then it re-checks the destination's inode and version, the staged bundle's version and codesign again (right before and after each tool, the path it passes must lead to the bundle in the staging dir it holds open), re-reads automatic when unattended, swaps with renameatx_np(RENAME_SWAP) from the open staging dir and the destination's open folder (fails without touching anything when unsupported), and moves the old bundle into previous/ before removing the older copy. If that move fails, the staging dir is kept and its path is in the result (Previous::Stranded) and the status's reason;
    • state in updates.json, log in updater.log, all in ~/Library/Application Support/Localify/. A missing updates.json is a first use; an unreadable or invalid one (bad JSON, a floor that isn't a version, malformed digests) stops every check and install with a failed status naming the file and the way out (delete it).
  • Daemon: first check 2 min after start, then every 6 h ± 30 min, and early when a paired machine announces a newer version (once per version). Every check, from the daemon or the CLI, goes through one coalesced operation under check.lock, with a 10-minute cooldown doubled after each failure up to 6 h, persisted. Unattended installs only for a launchd-managed daemon, with automatic on and the app checks passing; it checks the app before downloading, then again under the lock. Unattended downloads are at most one per 6 h until an install succeeds, recorded before the download starts, so peer hints and restarts can't repeat one.
  • Socket: update_status → {installed, available, state, reason, automatic}; update_settings {automatic}.
  • CLI: localify update [--status] [--json] (JSON when stdout isn't a TTY; read locally when the daemon is down), localify update --auto on|off, localify update install [--relaunch] (waits up to 2 min for app.lock, installs, then opens the app, the new one or the old one if the install failed). No GUI needed, so it works over SSH.
  • Config: [updates] automatic. localify install writes true for a new install; an existing config without the key reads false.
  • Linux: check only, no install code path. localify status and the status say "Update available: run the install command again". release.yml now builds the CLIs with LOCALIFY_UPDATER_BASE_URL so a Linux daemon knows the manifest.
  • For part B: update_status, update_settings, MgmtSocketClient::update_status/update_settings, and localify::infrastructure::updates::app_lock_path().

Choices to review

  • Process check exceptions. The bundle's own Contents/MacOS/localify is not "in use": the daemon and every localify dev run from it, and they outlive a swap. A process whose executable was deleted (proc_pidpath → ENOENT) can't be running the bundle's files on disk. Without that exception, any app that updated itself while running would block installs forever; this Mac has four such processes. Documented in docs/desktop.md.
  • Cooldown and a person's install. update install goes through the same coalesced check: within the cooldown it uses the last check's release instead of the network; within a failure's cooldown it reports that failure. The 6 h download cooldown applies only to the daemon.
  • Raw tar parsing. Real releases are made by macOS tar and carry PAX headers (mtime and com.apple.provenance xattrs, 11 of 22 headers in 1.6.0), so PAX can't be refused outright. Parsing in raw mode, with long names handled here, means the size checks see exactly the structure that gets extracted.
  • updater.log location. The installer logs to ~/Library/Application Support/Localify/updater.log, next to its locks. The app's own updater log stays in Localify Desktop/ until B moves it.
  • src-tauri is untouched: the core reads the same pubkey file and the same build env.

Tested

  • cargo fmt, cargo clippy --locked -- -D warnings, cargo test --locked (all pass, macOS). Clippy on --tests adds no new warnings.
  • Extraction rejections with crafted tar.gz fixtures: traversal, absolute, hard link, FIFO, setuid/setgid, escaping symlinks (direct and through a chain), entry under a symlink (before or after it), two bundles / stray file, duplicates, size and entry caps, empty and garbage archives. Case aliases with chained links (here -> ., out -> here/.., up -> out/.., then UP/payload, in both orders) leave nothing outside staging; long-name/PAX headers count toward the entry cap; oversized PAX and GNU long names are refused before they are read; PAX size/sparse/global headers and payloads on directories and symlinks are refused; GNU and PAX long names extract where they say.
  • Review fixes, each with a test that fails when the fix is undone (checked by reverting each one): inherited write ACL on the parent never reaches staging; a staged bundle changed after verification is not swapped in; a corrupt or unreadable updates.json stops checks, installs and downloads and is left as it is, and the status says how to start again; a rejected release isn't downloaded again, and unattended downloads wait out their cooldown, cleared by an install; a previous/ that can't be written leaves the replaced bundle in staging and reports it, and the older copy survives; build metadata never makes a version newer or raises the floor.
  • Signatures with a test minisign key (ed25519-dalek, dev-only): valid, tampered data, wrong key, garbage.
  • Version, downgrade and highest-installed rules; failed digests (recorded on bad signature, wrong version, failed codesign; skipped afterwards).
  • Lock and app-running decisions (pure), plus the installer on temp bundles: app.lock held stops it, a --relaunch-style wait proceeds once the lock is released, a process from the bundle or an unreadable one stops it, automatic turned off at the last moment stops an unattended install, a destination replaced during the install is left alone.
  • Second review's fixes, each with a test that fails when the fix is undone (checked by reverting each one): a folder above staging writable by others, by mode or by ACL, stops the install with its path and isn't recorded as the archive's fault (an entry for this account, a denial, and an inherit-only entry are fine); a tree put at the staging path during the last codesign is never swapped in; the swap uses the staging dir held open, not its path; an ENOSPC while extracting (injected through a test hook) and a codesign that can't run don't mark the release as failed, and the same release installs afterwards. The ACL reader is checked entry by entry against chmod +a.
  • A renameatx_np(RENAME_SWAP) swap on a temp APFS dir (#[cfg(target_os = "macos")]), and the full stage-and-swap pipeline on fake bundles with previous/ kept.
  • Coalescing (4 threads, one fetch), backoff doubling persisted across a "restart", and the daemon schedule and peer hints on a paused tokio clock.
  • Config: new install writes automatic = true; a pre-existing config reads and saves false.
  • Socket: update_settings then update_status.
  • Live dry run (run again after the second review's fixes, with the checked staging path and the path/inode bracket around the real codesign): the ignored test the_live_release_stages_with_every_real_check downloaded the real 1.6.0 archive from downloads.localify.dev, verified it with the built-in key, extracted it under the strict rules, matched the version, and passed the real pinned codesign check, all in a temp dir with no swap. Run it with LOCALIFY_UPDATE_MANIFEST=https://downloads.localify.dev/latest.json cargo test the_live_release -- --ignored.
  • CLI by hand with a throwaway HOME and --config: status JSON, --auto on writing [updates], a debug build refusing install, clap refusing --auto with install.

Not tested

  • A real swap of an installed /Applications/Localify.app and the daemon restarting on it. On purpose: nothing ran against the real bundle on this machine. The first real end-to-end run needs a published release newer than an installed signed build.
  • --relaunch opening the app, and installs over SSH on a real runner.
  • The Linux build was not compiled locally (CI's ubuntu job covers it). The Linux paths are small and behind cfg.
  • Intel Macs: the manifest only publishes darwin-aarch64, so an Intel Mac reports that the manifest has no archive for it.

ADR 0034 and the contract in docs/desktop.md: the daemon installs signed
releases with the app closed, a paired machine on a newer version only
triggers an early check of the official feed, the app relaunches only
when no service runs in its tabs, and Linux keeps its manual install.
The daemon runs as the user, installs only while the app is closed and
only into a bundle the user owns; the app updates on the person's click
and never relaunches by itself; existing installs opt in; presence
versions are hints behind one coalesced, backed-off check; extraction
and signing requirements are spelled out; no automatic rollback in this
version.
Part A of ADR 0034. A Mac with the app closed now installs new releases
by itself, so a runner nobody sits at stops falling behind its paired
machines. There is one installer, in the core, used by the daemon
unattended and by `localify update install` when a person asks.

- The installer verifies the archive's minisign signature with the
  built-in updater key before extracting, extracts under the spec's rules
  (one Localify.app, no absolute or `..` paths, no hard links or special
  files, no setuid or setgid, internal relative symlinks never followed,
  1 GiB and 20,000 entries), checks the bundle's version, the
  strictly-newer and highest-installed rules, failed digests, and
  `codesign` pinned to Localify's team and bundle id. It swaps with
  renamex_np(RENAME_SWAP) under update.lock, only with app.lock free and
  no process running from the bundle, and keeps the previous bundle.
- The daemon checks 2 minutes after start, then every 6 h +/- 30 min, and
  early when a paired machine announces a newer version. Every check goes
  through one machine-wide coalesced operation with a 10-minute cooldown,
  doubled after failures up to 6 h, persisted in updates.json.
- `update_status` and `update_settings` on the management socket, and
  `localify update [--status] [--auto on|off]` and
  `localify update install [--relaunch]` in the CLI.
- `[updates] automatic`: `localify install` writes it on for a new
  install; a config without it keeps updating by hand.
- Linux only reports "Update available: run the install command again";
  its release build now gets the manifest's address.

New dependencies: flate2 and tar (no default features, so no xattr) to
read the release .tar.gz; minisign-verify, the crate the app's updater
already uses for this signature format; semver, already in the tree, to
compare versions; ed25519-dalek as a dev-dependency to sign test archives.
Extraction no longer follows symlinks at all: every path is walked from
directory descriptors with O_NOFOLLOW, files are created with O_EXCL, and
symlinks are made after every other entry. On a case-insensitive volume,
`up` and `UP` are one name, so the old path check let a chain of links
write outside staging.

The archive is read in tar's raw mode, so long-name and PAX headers count
toward the entry cap and are read here under a 1 MiB cap, instead of whole
by tar. PAX `path`/`linkpath` are honoured; `size`, sparse records and
global headers are refused. The decompressed stream is capped, and entries
that aren't files may carry no data.

Staging is private even when the parent has an inheritable ACL: the
updates folder, staging/ and each staging dir are checked owned by this
account, lose group/other write and their ACLs. The staged bundle's
version and codesign are checked again under the lock, right before the
swap.

An unreadable or invalid updates.json now stops every check and install
with a failed status naming the file, instead of reading as new and
forgetting the version floor and the failed releases. Deleting it starts
again.

A release whose archive failed is remembered by version and manifest
signature and skipped before any download, and the daemon downloads at
most once every 6 hours until an install succeeds, so peer hints can't
make it fetch a rejected archive again and again.

The replaced bundle is moved into previous/ before the older copy is
removed. When the move fails, its staging dir is kept and its path is in
the install's result and the status.

Freshness uses semver precedence, so build metadata never makes a release
newer or raises the floor; the manifest/bundle match stays exact.
The desktop-shell job builds src-tauri with --locked, and its lock file
still listed the core crate without tar, flate2, minisign-verify and
semver, so the job failed before any test ran.
…cting releases

Another account that could change a folder above staging (search, create
and delete_child on ~/Library/Application Support, for example) could
rename Localify/ and put its own tree at the staged bundle's path between
the last codesign check and the swap, since both resolved the absolute
path again. Now every folder from / down to the updates folder must be
owned by this account or root, with no group or other write and no ACL
entry letting another account write in it, or the installer stops and the
status names the folder. The staging directory is opened from / one
component at a time, held open until the swap, and swapped with
renameatx_np from that descriptor and the destination's folder. codesign
and plutil still need a path, so the installer checks right before and
right after each that the path leads to the bundle it holds.

A full disk or another write error while extracting was reported as an
archive error, and a codesign or plutil that couldn't run as a failed
check, so a valid release was skipped for good. Writes now fail as this
machine's errors, and a tool that didn't start or was killed is no
verdict; only archive defects and real verification failures are
remembered.
…the checked path

codesign exits 1 for local reasons too (too many open files), so a
refusal of an archive whose minisign signature passed is retried at the
next check instead of held against the release. The staging path given
to codesign and plutil is now the canonical one whose every folder was
checked, so a symlink in the configured spelling can no longer let an
unchecked folder redirect them.
@teo-goulois
teo-goulois merged commit 0cafd93 into main Nov 9, 2026
4 checks passed
@teo-goulois
teo-goulois deleted the feat/automatic-updates-core branch November 9, 2026 19:07
@teo-goulois teo-goulois mentioned this pull request Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant