diff --git a/.greptile/config.json b/.greptile/config.json index 45bef947..111e6c04 100644 --- a/.greptile/config.json +++ b/.greptile/config.json @@ -3,13 +3,13 @@ "commentTypes": ["logic", "syntax"], "autoReview": ["open", "push"], "ignorePatterns": "Resources/*.png\nResources/*.icns\ndocs/assets/*.svg\n", - "instructions": "Insomnia is a macOS menu bar app that keeps a MacBook awake for one timed session. While a session runs it executes sudo -n pmset to disable sleep and to switch Low Power Mode, SIGSTOPs other apps' processes when the lid closes, mutes audio, sets the display and keyboard backlight to 0, and writes NSAppSleepDisabled into agent apps' preferences. Every change is journaled in state.json before it is made and is undone from that journal, by the app (reconcile at launch, lid open, session end, Quit) or by scripts/backstop.sh, which a per-user LaunchAgent runs at load and every 60 s with macOS /bin/bash 3.2. The worst bugs leave the Mac in a changed state with nothing able to undo it: a change made before its journal entry is on disk, an entry cleared before the undo was confirmed, a journal the shell can no longer parse, a SIGKILLed sudo, a recovery lock released while a privileged command still runs, or a SIGCONT sent to a process Insomnia did not stop. Weigh those far above style. Read .greptile/rules.md before flagging a design listed there as deliberate.", + "instructions": "Insomnia is a macOS menu bar app that keeps a MacBook awake for one timed session. A session starts by turning sleep off with pmset -a disablesleep 1 through the macOS administrator password prompt (osascript, AdministratorPrompt.swift); while it runs the app executes sudo -n pmset to turn sleep back on and to switch Low Power Mode, SIGSTOPs other apps' processes when the lid closes, mutes audio, sets the display and keyboard backlight to 0, and writes NSAppSleepDisabled into agent apps' preferences. Every change is journaled in state.json before it is made and is undone from that journal, by the app (reconcile at launch, lid open, session end, Quit) or by scripts/backstop.sh, which a per-user LaunchAgent runs at load and every 60 s with macOS /bin/bash 3.2. The worst bugs leave the Mac in a changed state with nothing able to undo it: a change made before its journal entry is on disk, an entry cleared before the undo was confirmed, a journal the shell can no longer parse, a SIGKILLed sudo, a recovery lock released while a privileged command still runs, or a SIGCONT sent to a process Insomnia did not stop. Weigh those far above style. Read .greptile/rules.md before flagging a design listed there as deliberate.", "rules": [ { "id": "journal-before-change", "severity": "high", "scope": ["Sources/Insomnia/Core/**", "Sources/Insomnia/System/**", "scripts/backstop.sh"], - "rule": "Any change to power state (sudo pmset disablesleep or lowpowermode), to another process (SIGSTOP), to audio output (volume or mute), to display or keyboard brightness, or to another app's NSAppSleepDisabled (AppNap.swift, opt-in) is written to state.json first, through Store.saveState (the journal helper in SessionManager), and that write has returned without error before the change is made. If the write fails the change is not made." + "rule": "Any change to power state (pmset disablesleep, through sudo -n or the administrator password prompt, or pmset lowpowermode), to another process (SIGSTOP), to audio output (volume or mute), to display or keyboard brightness, or to another app's NSAppSleepDisabled (AppNap.swift, opt-in) is written to state.json first, through Store.saveState (the journal helper in SessionManager), and that write has returned without error before the change is made. If the write fails the change is not made." }, { "id": "journal-cleared-only-after-confirmed-undo", @@ -26,32 +26,38 @@ { "id": "backstop-armed-before-sleep-is-disabled", "severity": "high", - "scope": ["Sources/Insomnia/Core/SessionManager.swift", "Sources/Insomnia/Core/LaunchdBackstop.swift"], - "rule": "Sleep is disabled (sudo -n pmset -a disablesleep 1) only after session.json and the sleepDisabledByUs journal entry are on disk and LaunchdBackstop.arm() has returned without error. A failed arm rolls the start back and never reaches pmset. The LaunchAgent plist at the trusted path only ever holds a plist launchd actually loaded." + "scope": ["Sources/Insomnia/Core/SessionManager.swift", "Sources/Insomnia/Core/LaunchdBackstop.swift", "Sources/Insomnia/System/AdministratorPrompt.swift"], + "rule": "Sleep is disabled (pmset -a disablesleep 1 through the administrator password prompt) only after session.json and the sleepDisabledByUs journal entry are on disk and LaunchdBackstop.arm() has returned without error. A failed arm rolls the start back and never reaches the prompt. Only an explicit Start shows the prompt: reconcile at launch and every other path read pmset -g and never prompt or re-apply disablesleep 1. The LaunchAgent plist at the trusted path only ever holds a plist launchd actually loaded." + }, + { + "id": "abandoned-password-prompt-cannot-turn-sleep-off", + "severity": "high", + "scope": ["Sources/Insomnia/Core/SessionManager.swift", "Sources/Insomnia/System/AdministratorPrompt.swift", "Sources/Insomnia/Core/SleepGuard.swift", "Sources/Insomnia/Store/Store.swift", "scripts/backstop.sh", "scripts/uninstall.sh"], + "rule": "The root command behind the password prompt runs as /usr/bin/lockf -k -n -t 10 /bin/sh -c , and runs pmset -a disablesleep 1 only when pending-start held the nonce of the start that showed the prompt at its check. lockf holds the marker's flock from before that check until pmset exits, and every deleter takes the same lock before it unlinks the file, and unlinks it only while the path still names the file it locked: Store.removePendingStart compares fstat of its locked descriptor with stat of the path and looks again if they differ; backstop.sh and uninstall.sh open the marker on fd 8 (regular files only), lock that descriptor with $LOCKF -s -t N 8, and compare $STAT -f %d:%i <&8 with $STAT -L -f %d:%i of the path before $RM -f. So the marker never goes between the check and the end of pmset. When a stuck prompt is voided, performStart also requires the locked file to be the one savePendingStart wrote (removePendingStart(expecting:)): a file put in its place may have been swapped in after the root command locked the original, and a missing one may have gone without the lock, so neither counts as voided. performStart writes a fresh nonce just before the prompt and deletes the marker on every outcome before it releases the recovery lock (on .stillRunning, before it waits and again after the prompt exits if its command held the lock). Every other holder of the recovery lock deletes it before it reads or clears the journal: exclusive() in the app, backstop.sh right after lockf, uninstall.sh right after lockf and before it runs a possibly older backstop. Nothing deletes it without the recovery lock, because a live start may be waiting on its prompt. sleepDisabledByUs is cleared only after the marker was removed in the same transaction (restoreAll, and the backstop's marker_stuck gate); a marker that cannot be removed keeps the entry, is reported, makes every backstop.sh run exit 1 (a still-valid session and a moved-aside session.json included), refuses new starts and is retried. A cancelled prompt (-128 as osascript's last word) or a launch failure ran nothing as root and is rolled back exactly, with no pmset. SleepGuarding.disableSleep takes a PendingStart, so no path turns sleep off without a marker. Root never writes the marker. Root's only record is the receipt /private/var/db/com.kgarg.insomnia/ (SleepOffReceipts): 82 bytes, ' writing|refused' and a newline, the file root's, mode 600, its only ACL entry letting the user's uid read it (so no other account can open it or take its lock), and every folder up to / root's with no group or other write and no allowing ACL, shared by every INSOMNIA_HOME of the user. The app reads the entry through acl(3); the scripts and the root command through ls -le and id -u, which do not see synchronize, folder-only rights or an entry ls cannot read on a file. It is also the command guard: the root command opens it read-only on fd 8 and takes an exclusive flock with /usr/bin/lockf -s -t 10 8 before any other check (exit 75 if it stays locked), and its shell and pmset keep fd 8 until they exit. SleepOffReceipts.lock in the app and lock_receipt in backstop.sh and uninstall.sh open it read-only and take the same exclusive lock, then check that the path still names the locked file, before a start claims it, before any settlement reads it, before install.sh writes the release file, and from uninstall.sh's shared check (before it removes anything) until the shared rule, agent, bundle, receipt and release file are gone. A lock that stays busy or fails is undecided, never clean. Under the lock the root command checks the receipt by fixed path with stat and ls -le, and that fd 8 and the path are the device:inode the start claimed ($7) (exit 7 otherwise), then the marker's nonce (3), expires (4), the sudo queries (5), expires again (4), that the receipt still begins with the predecessor nonce $6 (8) and pmset -g (6). Then it writes ' writing' in place with /usr/bin/env -i /usr/bin/perl (sysopen for writing with O_NOFOLLOW and no create or truncate, the same device:inode as fd 8, one full syswrite, fcntl F_FULLFSYNC (51), close, read back), writes ' refused' and exits 7 if any of that fails or perl is missing, reads pmset -g again under the same rule ('refused' and exit 6 on a SleepDisabled 1 or a failed read, skipped only when $5 is 1), checks expires ('refused' and exit 4), and only then runs pmset -a disablesleep 1. The second read narrows the time in which another tool's 1 is taken for Insomnia's; it is not an owner token, and a refused line that cannot be written while the status is lost still reads as writing. The user's release file .released (42 bytes, ' free|held', the user's, 0600, created by install.sh with sudo -n /usr/bin/install) holds a start's claim. performStart claims under the receipt's lock only while it shows the receipt's nonce free (SleepOffReceipts.claimable), journals sleepOffAttempt (nonce, owedBefore, predecessor, the receipt's device:inode, deadline, and before the prompt expires = min(session end, marker + AdministratorPrompt.answerWindow) and the marker's device:inode) with sleepDisabledByUs, then writes ' held'. Every settlement journals its decision first, while the claim is still held: sleepOffAttempt marked settled, with sleepDisabledByUs as below. Only then does it give the claim back (the receipt's nonce free) and publish the journal without the attempt (finishSettlement, finish_settlement). A record already settled is finished from its decision and never read against the receipt again. After any failure that is not a refusal, performStart deletes the marker under its lock and reads SleepOffReceipts.verdict under the receipt's lock. neverWrote: this nonce with refused, another line that names the same predecessor, or the predecessor itself once the dialog ended by itself (AdministratorPromptError.dialogOver) or expires has passed. mayHaveWritten: this nonce's writing, a later line that names another predecessor, or, once expires has passed, a missing, replaced, unsafe or malformed receipt. undecided: those last before expires, and a lock that is busy or fails at any time. neverWrote rolls back with no pmset; mayHaveWritten goes through performEnd(.startFailed); undecided keeps the attempt, the claim and the entry and holds the sleep undo (attemptHold, attempt_hold) at any time and whatever owedBefore says, because expires proves only that no command for the start enters later while the wall clock does not go back, not that one already entered has exited; the rest of the undo still runs, and a receipt that stays locked holds the sleep undo with no limit. The next holder of the recovery lock (exclusive() after a relaunch, backstop.sh settle_attempt, uninstall.sh settle_attempt) settles an attempt still journaled the same way once the marker is gone, before it reads the session: undecided keeps everything and holds the sleep undo; otherwise session.json whose endsAt is the deadline goes, then the decision is journaled (settled, neverWrote putting sleepDisabledByUs back to owedBefore and anything else keeping it), then the claim goes back, then the attempt goes. A step that fails before the decision is journaled keeps the attempt unsettled with the claim and says what was removed: with neverWrote and owedBefore false no pmset runs (attemptHold, attempt_hold) and Starts stay refused; otherwise the app and backstop.sh restore and keep the entry; uninstall.sh stops with no pmset. A step that fails after it keeps the settled record, which holds nothing back: the undo follows its decision, Starts stay refused, backstop.sh exits 1, uninstall.sh stops, and a later run finishes it. A marker removed with no attempt journaled takes session.json with it when no session is in memory. The production reader trusts uid 0 only; tests widen it only by constructing SleepOffReceipts or patching private script copies, never through an environment variable, file or flag a shipped build reads." }, { "id": "sudo-is-never-sigkilled", "severity": "high", - "scope": ["Sources/Insomnia/Core/SleepGuard.swift", "Sources/Insomnia/System/CancellableCommand.swift", "Sources/Insomnia/System/ShellTimeout.swift", "scripts/backstop.sh", "scripts/install.sh", "scripts/uninstall.sh"], - "rule": "A privileged command (sudo -n pmset) is never sent SIGKILL, in the app or in the scripts. On timeout it may get SIGTERM, which sudo relays to pmset and waits for. A command still alive after that is left running and reported with its pid, because killing sudo orphans a root pmset that can change power state later, outside any transaction." + "scope": ["Sources/Insomnia/Core/SleepGuard.swift", "Sources/Insomnia/System/AdministratorPrompt.swift", "Sources/Insomnia/System/CancellableCommand.swift", "Sources/Insomnia/System/ShellTimeout.swift", "scripts/backstop.sh", "scripts/install.sh", "scripts/uninstall.sh"], + "rule": "A privileged command (sudo -n pmset, or the osascript behind the administrator password prompt) is never sent SIGKILL, in the app or in the scripts. On timeout it may get SIGTERM, which sudo relays to pmset and waits for, and which takes the password dialog down with osascript. A command still alive after that is left running and reported with its pid, because killing sudo orphans a root pmset that can change power state later, outside any transaction." }, { "id": "recovery-lock-held-while-a-command-may-run", "severity": "high", "scope": ["Sources/Insomnia/Core/RecoveryLock.swift", "Sources/Insomnia/Core/SessionManager.swift", "Sources/Insomnia/Core/SleepGuard.swift", "scripts/backstop.sh", "scripts/uninstall.sh"], - "rule": "The recovery lock (.recovery.lock: flock in RecoveryLock.swift, lockf on fd 9 in the scripts) is not released, and no further undo runs, while a privileged command started under it may still be running, or while the app binary that backstop.sh runs for --resume-frozen may still send a signal: that binary keeps the lock on fd 9 and ends itself after the lifetime the shell passes. The lock file is never unlinked or replaced, so every holder locks the same inode. Failing to acquire the lock means doing nothing, never proceeding unlocked." + "rule": "The recovery lock (.recovery.lock: flock in RecoveryLock.swift, lockf on fd 9 in the scripts) is not released, and no further undo runs, while a privileged command started under it may still be running and may still change something, or while the app binary that backstop.sh runs for --resume-frozen may still send a signal: that binary keeps the lock on fd 9 and ends itself after the lifetime the shell passes. The one command that no longer can is a password prompt whose pending-start marker the transaction deleted under the marker's own lock: no root command held that lock, and a command the dialog starts later stops at its marker check unless this user writes the marker again or the clock is set back, so the transaction does not wait for the process. It settles the start from the receipt under the receipt's lock once expires has passed (undecided, with Starts refused and the sleep undo held, while a command for the start holds that lock) and releases the recovery lock (SessionManager's stillRunning branch). In install.sh and uninstall.sh, supervise turns errexit off and ignores TERM and HUP for the call's whole life, and only the call gets the default actions back, so a process-group signal or a failed status write never releases fd 9 before the call has exited and been reaped. The lock file is never unlinked or replaced, so every holder locks the same inode. Failing to acquire the lock means doing nothing, never proceeding unlocked." }, { "id": "only-verified-pids-are-resumed", "severity": "high", "scope": ["Sources/Insomnia/Core/ProcessControl.swift", "Sources/Insomnia/Core/LidActions.swift", "Sources/Insomnia/Core/ResumeFrozenCommand.swift", "Sources/Insomnia/System/Freezer.swift", "scripts/backstop.sh"], - "rule": "SIGCONT is sent only to a journaled pid whose recorded identity still matches the live process: start time to the microsecond and boot session in the app, and in the app binary that backstop.sh runs for entries that record startedAtMicros (Insomnia --resume-frozen, with a time limit, run only when the installed bundle's Info.plist declares InsomniaResumeFrozenVersion equal to RESUME_FROZEN_VERSION; a timeout, a missing declaration, or any answer that is not exactly one expected line per entry keeps the entries); start time to the second and boot session in backstop.sh only for entries without startedAtMicros. Provisional entries (identity nil, written before the kernel confirmed the stop) and legacy frozenPids are never signaled and are never cleared by the shell. A pid that cannot be observed stays journaled. One exception: when the write confirming a freeze fails, LidActions.freeze undoes the SIGSTOPs it sent moments earlier through cancelStops, checked against the identity it read before the SIGSTOP and still holds in memory (microseconds and boot session). That SIGCONT goes to a matching pid even if it does not show as stopped yet, because a SIGSTOP can still be pending and generating SIGCONT discards it. That rollback then removes the provisional entries of the pids it resumed, that are gone, or that the freeze never stopped, and keeps those it could not resume." + "rule": "SIGCONT is sent only to a journaled pid whose recorded identity still matches the live process: start time to the microsecond and boot session in the app, and in the app binary that backstop.sh runs for entries that record startedAtMicros (Insomnia --resume-frozen, with a time limit, run only when the Info.plist of the installed bundle, or with --own-bundle (install.sh, before the swap) of the staged bundle the script is sealed in, declares InsomniaResumeFrozenVersion equal to RESUME_FROZEN_VERSION; a timeout, a missing declaration, or any answer that is not exactly one expected line per entry keeps the entries); start time to the second and boot session in backstop.sh only for entries without startedAtMicros. Provisional entries (identity nil, written before the kernel confirmed the stop) and legacy frozenPids are never signaled and are never cleared by the shell. A pid that cannot be observed stays journaled. One exception: when the write confirming a freeze fails, LidActions.freeze undoes the SIGSTOPs it sent moments earlier through cancelStops, checked against the identity it read before the SIGSTOP and still holds in memory (microseconds and boot session). That SIGCONT goes to a matching pid even if it does not show as stopped yet, because a SIGSTOP can still be pending and generating SIGCONT discards it. That rollback then removes the provisional entries of the pids it resumed, that are gone, or that the freeze never stopped, and keeps those it could not resume." }, { "id": "no-shell-interpolation", "severity": "high", "scope": ["Sources/**"], - "rule": "A child process is started with an absolute executable path and an argument array: Shell.run, Shell.run(timeout:), CancellableCommand.run, or Process with executableURL and arguments. Nothing runs through /bin/sh -c, /bin/bash -c, zsh, osascript with a built command string, or a string that has data interpolated into it. No executable is resolved through PATH; Shell.locate picks from a fixed candidate list of absolute paths." + "rule": "A child process is started with an absolute executable path and an argument array: Shell.run, Shell.run(timeout:), CancellableCommand.run, or Process with executableURL and arguments. Nothing runs through /bin/sh -c, /bin/bash -c, zsh, osascript with a built command string, or a string that has data interpolated into it. The one AppleScript, AdministratorPrompt.disableSleepScript, is a compile-time literal, and so are the lockf prefix (AdministratorPrompt.markerLock) and the root command it hands to /bin/sh -c (AdministratorPrompt.rootCommand). Their only data, the pending-start marker path, the nonce, expires, the uid, the ownership flag, the predecessor nonce and the receipt's device:inode, arrive as osascript arguments and reach the root shell through quoted form of, as lockf's file operand and as $1 to $7; they are compared, never spliced into command text, and nothing read from a file is run. No executable is resolved through PATH; Shell.locate picks from a fixed candidate list of absolute paths." }, { "id": "scripts-run-under-bash-3-2", @@ -63,13 +69,13 @@ "id": "scripts-call-tools-through-fixed-path-variables", "severity": "high", "scope": ["scripts/**/*.sh"], - "rule": "Every tool that changes the machine or reads process, power or launchd state is called through the absolute-path variable declared at the top of the script (PMSET, SUDO, PLUTIL, LOCKF, PS, KILL, SYSCTL, PGREP, OSASCRIPT, LAUNCHCTL, CODESIGN, SWIFT, SWIFTC, ICONUTIL), never by bare name through PATH. A new tool gets a new variable in that block, because RecoveryScriptTests and PackagingTests patch exactly those lines in a private copy of the script to substitute fakes. A script's own bounded-call helper may signal the child it started with the shell builtin `kill`; every signal to a process the script did not start goes through `$KILL`." + "rule": "Every tool that changes the machine or reads process, power or launchd state is called through the absolute-path variable declared at the top of the script (PMSET, SUDO, PLUTIL, LOCKF, PS, KILL, SYSCTL, PGREP, OSASCRIPT, LAUNCHCTL, CODESIGN, SWIFT, SWIFTC, ICONUTIL, DEFAULTS, INSTALL, RM, RMDIR, MKDIR, CP, MV, MKTEMP, CAT, VISUDO, TEST, STAT), never by bare name through PATH. That includes cleanup in EXIT traps, and commands run through sudo: write `\"$SUDO\" \"$INSTALL\"`, not `\"$SUDO\" install`, because sudo looks a bare name up in the caller's PATH and runs what it finds as root. A new tool gets a new variable in that block, because RecoveryScriptTests and PackagingTests patch exactly those lines in a private copy of the script to substitute fakes. A script's own bounded-call helper may signal the child it started with the shell builtin `kill`; every signal to a process the script did not start goes through `$KILL`." }, { - "id": "sudoers-rule-is-four-exact-lines", + "id": "sudoers-rule-is-three-exact-lines", "severity": "high", - "scope": ["scripts/install.sh", "scripts/uninstall.sh", "Sources/Insomnia/Core/SleepGuard.swift", "README.md", "docs/install.md", "SECURITY.md", "docs/spec.md"], - "rule": "The file install.sh writes to /etc/sudoers.d/insomnia grants NOPASSWD for exactly these four commands and nothing else: /usr/bin/pmset -a disablesleep 1, /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -b lowpowermode 1, /usr/bin/pmset -b lowpowermode 0. No wildcard, no ALL, no bare /usr/bin/pmset, no other command, and it is validated with visudo -cf before it is installed. SleepGuard.swift runs only those four argument lists, and README.md, docs/install.md, SECURITY.md and docs/spec.md list the same four." + "scope": ["scripts/install.sh", "scripts/uninstall.sh", "Sources/Insomnia/Core/SleepGuard.swift", "Sources/Insomnia/System/AdministratorPrompt.swift", "README.md", "docs/install.md", "SECURITY.md", "docs/spec.md"], + "rule": "The file install.sh writes to /etc/sudoers.d/insomnia grants NOPASSWD for exactly these three commands and nothing else, to each account that installed: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -b lowpowermode 1, /usr/bin/pmset -b lowpowermode 0. No wildcard, no ALL as the command or run-as list, no bare /usr/bin/pmset, no other command, and it is validated with visudo -cf before it is installed. The file is one for every account: install.sh reads it first with sudo -n cat under the recovery lock, writes this account's three lines and keeps another account's line only when it is exactly one of the three (rule_lines, shared byte for byte with uninstall.sh), drops every disablesleep 1 line, and stops with nothing changed at any other line or a read that fails, stalls or is not read whole. uninstall.sh keeps the file while the receipt folder or the rule's own lines show another account, or cannot rule one out (other_accounts, other_rules). /usr/bin/pmset -a disablesleep 1 is never passwordless, on any install.sh path including failure and rollback paths: it runs only through the administrator password prompt (AdministratorPrompt.disableSleepScript via /usr/bin/osascript). SleepGuard.swift runs only those three argument lists through sudo, and nothing runs sudo before the prompt: SleepGuarding.checkSleepSettingForStart only reads pmset -g. The root command behind the prompt checks the passwordless restore before it turns sleep off with sudo's own policy answers, which run nothing and are not a run of the restore: after the receipt, nonce and deadline checks, under the marker's and the receipt's locks and before any pmset, root asks the user's sudo (/usr/bin/sudo -n -u \"#$4\" /usr/bin/env -i LC_ALL=C /usr/bin/sudo, stdin /dev/null) for -V, -k -n -l and -k -n -ll /usr/bin/pmset -a disablesleep 0, and runs /usr/bin/pmset -a disablesleep 1 only if /private/etc/sudo.conf does not exist, /private/etc/pam.d/sudo has exactly one session line, session required pam_permit.so, sudo -V shows exactly sudo 1.9.17p2 with only the sudoers plugins, the listing shows no Runas or command-specific Defaults (bound with > or !) and only Defaults entries the root command's list accepts among those sudo lists for the requesting user (global, user-bound and host-bound), and the -ll answer is exactly the /etc/sudoers.d/insomnia entry (RunAsUsers: root, Options: !authenticate, the restore as its only command, Matched: the restore), with the deadline checked again after the queries and right before the write. Otherwise it exits 5 with no pmset run and the start rolls back with nothing to undo (AdministratorPromptError.restoreNeedsPassword), so sleep is never turned off while the passwordless restore is missing. Its message names the check and says install.sh fixes only a missing rule, never that a reinstall changes the system sudo. Nothing treats a generic sudo -l, sudo -v, a bare exit status or a NOPASSWD grep as proof, because a listing passes for commands that still need a password. install.sh runs no pmset; after writing the rule it checks a sudo -k -n -l listing of the three commands. install.sh writes the file only under the recovery lock, after the running app has quit, so every stop before the write leaves the previous rule beside the previous app. Under the same sudo it creates /private/var/db/com.kgarg.insomnia (0755) and the user's receipt (0600, root:wheel, 82 bytes holding the all-zero nonce twice and refused) by those fixed paths with sudo -n mkdir and sudo -n /usr/bin/install, after checking the folders above by lstat, then gives the receipt its one ACL entry with sudo -n /bin/chmod +a \"user: allow read\" (the name from id -un, checked with id -u against the uid). A receipt it made before (root's, one link, 82 bytes, no group or other write, no entry or only that one) gets the entry and then mode 0600 in place, its bytes and inode kept, and only while the release file shows no claim (none, or a nonce and free): a held claim or a release file it cannot read stops the install before any chmod, and a receipt the user can open is locked first and checked again before each chmod; it stops at a folder or receipt already there in any other form without changing its owner or mode. Under the receipt's lock, and reading the receipt and the release file only while it holds that lock, it then writes the user's release file .released with sudo -n /usr/bin/install -m 0600 -o , the receipt's nonce free, unless the release file shows a held claim, which it keeps, also right after it made the receipt. A held claim in a file that is not the user's one-link 0600 42-byte regular file stops the install with nothing changed, and a replacement runs only after the locked receipt, its folders and the release file's bytes and shape are checked again. No sudoers line covers the receipt or the release file. README.md, docs/install.md, SECURITY.md and docs/spec.md list the same three and the prompt." }, { "id": "behavior-changes-come-with-regression-tests", diff --git a/.greptile/rules.md b/.greptile/rules.md index 8feaa9a5..23892ed6 100644 --- a/.greptile/rules.md +++ b/.greptile/rules.md @@ -13,7 +13,10 @@ keeps sleep disabled, and `state.json` (`RuntimeState`), the journal of everything Insomnia changed and must undo. Every change is journaled before it is made and undone from the journal, never from memory. A session starts by writing both files, arming the launchd backstop, and only then running -`sudo -n pmset -a disablesleep 1`; it never starts without the backstop. +`pmset -a disablesleep 1` through the macOS administrator password prompt +(`AdministratorPrompt.swift`); it never starts without the backstop. Only an +explicit Start shows that prompt. The sudoers rule covers turning sleep back +on and Low Power Mode, never turning sleep off. The app undoes the journal itself at session end, Quit, lid open, and at every launch (reconcile). `scripts/backstop.sh`, installed by `install.sh` @@ -29,8 +32,10 @@ microsecond and signals it, and keeps them on any unexpected answer. It runs the binary only when the bundle's `Info.plist` declares `InsomniaResumeFrozenVersion` equal to its `RESUME_FROZEN_VERSION`, and the binary keeps the recovery lock on fd 9 until it ends itself after -``. It keeps, and never -restores, `savedOutputVolume`, `savedMuted`, `savedAudioOutputs`, +``. With `--own-bundle`, which `install.sh` passes to the copy in the +bundle it staged, the binary and `Info.plist` are the ones beside that copy, +so the pre-swap recovery never runs the older installed build. It keeps, and +never restores, `savedOutputVolume`, `savedMuted`, `savedAudioOutputs`, `savedDisplayBrightness`, `savedKeyboardBrightness` and `displayRestoredUnderLowPower`: CoreAudio and the private brightness frameworks need the app. The app's records `keptDisplayUnderLowPower`, @@ -88,7 +93,14 @@ The app and the script serialize on one `flock(2)` lock, `.recovery.lock`, which is never unlinked so both lock the same inode (`RecoveryLock.swift`; `lockf` on fd 9 in the scripts). `uninstall.sh` takes the lock, runs the backstop with `--force` under it, and refuses to -remove the recovery machinery while anything is still journaled. Battery +remove the recovery machinery while anything is still journaled or while +any read of a private copy of the journal fails (a read it cannot finish +is never counted as clean), and removes state.json only while it still +equals that checked copy. Before it removes anything it also takes the +receipt's lock and refuses while the release file shows a claim, names +another nonce or cannot be read, or the lock stays busy; it keeps that +lock until the shared rule, agent, bundle, receipt and release file are +gone. Battery and thermal floors run only while the app is alive; the backstop does not provide them. @@ -205,8 +217,242 @@ Flag a change that breaks one of these; do not flag the behavior itself. announced (spec sections 4 and 6, PR #10). - `SessionManager.swift`, `performStart`. `session.json` and `sleepDisabledByUs` are written and `LaunchdBackstop.arm()` has succeeded - before `pmset -a disablesleep 1` runs. Quitting the app always ends the - session; there is no keep-awake after quit (spec section 1). + before `pmset -a disablesleep 1` runs through the administrator password + prompt. Quitting the app always ends the session; there is no keep-awake + after quit (spec section 1). +- `install.sh`, `SleepGuard.swift`, `AdministratorPrompt.swift`. The + sudoers rule has three NOPASSWD lines for each account that installed, + kept across the other accounts' installs, and none for `disablesleep 1`: + turning sleep off asks for the administrator password at every Start, so + nothing running as the user can keep the Mac awake unattended. Reconcile + at launch reads `pmset -g` and never prompts or turns sleep off again; a + session whose sleep was turned back on while the app was not running ends + (`EndReason.sleepReenabled`). No install path, failure paths included, + writes a passwordless `disablesleep 1` line (PR #32). +- `AdministratorPrompt.swift`, `SessionManager.swift`. The password prompt + gets SIGTERM at 120 s and is never sent SIGKILL. One still running 3 s + later is reported with its pid and its marker is deleted under the + marker's lock. Once that succeeds a command the dialog starts from then + on stops at its marker check, unless this user writes the marker again + or the clock is set back, so the start waits for its answer window + (`expires`, at most 15 s by then) to end, is settled from the receipt + under the receipt's lock (`.undecided`, with Starts refused and the + sleep undo held, while a command for the start holds that lock) and + releases the recovery lock; the prompt is watched outside the + transaction, and the menu line offers `kill ` until osascript exits + and goes once the prompt has. Only while the root command holds the + marker's lock (anywhere from its nonce check to its exit, a sudo query + that never returns included) or the marker cannot be deleted does the + start keep the recovery lock, `session.json` and the journal entry until + the prompt exits, with no time limit, the same rule as a stuck `sudo -n + pmset`. The 120 s is the app's wait before SIGTERM, not a bound on the + root command; docs must not call a hung query bounded by it. +- `AdministratorPrompt.swift`, `SessionManager.swift`, `backstop.sh`, + `uninstall.sh`. A prompt can outlive its start (the app crashes or is + force-quit under the dialog, the start is settled after a stuck prompt). + Its root command therefore runs under `lockf -k -n` on `pending-start` + and runs `pmset -a disablesleep 1` only while the file holds that start's + nonce and `/bin/date +%s` is below `expires` (the session's end or 130 + s after the marker, `AdministratorPrompt.answerWindow`, whichever is + first), passed as `$3`; a + `$3` that `[` cannot compare refuses too. The start deletes the file before it releases the recovery lock; + every other lock holder deletes it before it touches the journal, and + every deleter (Store.removePendingStart, backstop.sh, uninstall.sh) + takes the file's own lock first and deletes only while the path still + names the locked file (device and inode of the locked descriptor against + the path; the scripts lock the marker through fd 8 to have one), so the + file never goes between the nonce check and the end of pmset. Voiding a + stuck prompt also needs the locked file to be the one that start wrote. The file is written with no newline + and compared, never run. A marker that cannot be written rolls the start + back with no prompt. One that cannot be locked in time or deleted keeps + `sleepDisabledByUs` journaled after sleep is restored (the app and the + backstop both gate the clearing on it), is reported, refuses new starts, + and makes `uninstall.sh` refuse to remove anything. A cancelled dialog or + an osascript that never launched ran nothing as root: the start restores + the journal and session.json exactly and runs no pmset. +- `BackstopVersion.swift`, `backstop.sh`. Start reads the + `# insomnia-backstop-version:` line of the `backstop.sh` sealed in the + bundle the agent runs and shows no dialog below version 5 (2 is the + first that deletes `pending-start`, 3 the first that settles a journaled + `sleepOffAttempt` from its receipt, 4 the first that reads the receipt + under its lock as the 82-byte line with the predecessor and the start's + `expires`, and gives the start's claim back, 5 the first that journals a + settlement's decision before it gives the claim back and finishes a + settled record); the user is + told to run `install.sh` again. + Bump the version line and `BackstopVersion.required` together whenever + the app starts relying on new backstop behavior. +- `AdministratorPrompt.swift`, `SleepGuard.swift`, `install.sh`. Before the + dialog the app runs nothing through sudo: + `SleepGuarding.checkSleepSettingForStart` only reads `pmset -g`, and a + `SleepDisabled 1` the journal does not claim refuses Start with nothing + run, deliberately, so a setting another tool made stays. The check that + the end can restore without a password is in the root command, under + the marker's lock and the receipt's, before any write, in this order: a + uid that is a positive number (5) of plain digits (7), the receipt + opened read-only on fd 8 and locked with `/usr/bin/lockf -s -t 10 8` + (75 if it stays locked), checked by fixed path (7: `stat`/`ls -le` of + `/private/var/db/com.kgarg.insomnia/` and every folder up to / + showing a one-link 82-byte regular file of mode 600 whose only ACL + entry is `0: user: allow read` with `id -u ` equal to `$4`, + under folders with no allowing ACL, all root's with no group or other + write, and fd 8 and the path the device:inode the start claimed, `$7`; + `ls -le` does not show `synchronize`, folder-only rights or entries it + cannot read on a file, so only the app's acl(3) check is exact), the + nonce check (3), `expires` + (4), no + `/private/etc/sudo.conf` in any form (5: `sudo -V` does not list an + approval plugin with no show_version, and sudo consults one only when + it runs a command), `/private/etc/pam.d/sudo` with exactly one + uncommented session line, `session required pam_permit.so` (5: sudo + opens a PAM session only to run a command), then three queries as that + user through `q()` = `u /usr/bin/sudo "$@"`, `u()` being `sudo -n -u + "#$w" /usr/bin/env -i LC_ALL=C "$@" writing` written into the + receipt in place by `/usr/bin/env -i /usr/bin/perl` (sysopen + `O_WRONLY|O_NOFOLLOW` with no create or truncate, the same device:inode + as fd 8, one full syswrite, `fcntl` `F_FULLFSYNC` (51), close, reopen + and read back; ` refused` and 7 if any of that + fails or perl is missing), a second root read of `pmset -g` under the + same rule (`refused` and 6; it narrows the window and is not an owner + token), `expires` again (`refused` and 4), and only + then `pmset -a disablesleep 1`, the command's only power write (1 if it + fails). Its shell and pmset keep fd 8, so the receipt stays locked + until pmset exits. Root never writes the marker or anything in the + user's folders. `trap '' PIPE` keeps each refusal's status when the + dialog's stderr is gone. Exits 3, 4, 6, 7 and 8, and lockf's 69 and 75, + are `.refused`; 5 is `restoreNeedsPassword`. All refusals leave the sleep + setting as the command found it and roll back with no pmset, so another + tool's setting found by a read stays. Any other status is ambiguous: + the start deletes the marker under its lock, reads + `SleepOffReceipts.verdict` under the receipt's lock and rolls back with + no pmset only when the deleted file is the marker journaled in + `sleepOffAttempt` and the verdict is `.neverWrote`: the receipt passes + every check, is the journaled device:inode, and holds this nonce with + `refused`, another start's line naming the same predecessor, or the + predecessor itself once the dialog ended by itself + (`AdministratorPromptError.dialogOver`: cancelled, launch failure, + refusal, wrong password) or `expires` has passed. This nonce's + `writing`, or a later line naming another predecessor, is + `.mayHaveWritten` and undone like an end at once; a missing, replaced, + unsafe or malformed receipt is too once `expires` has passed. Before + then those, and at any time a receipt lock that stays busy or fails, + are `.undecided`: the start keeps the attempt, its claim and + `sleepDisabledByUs` and runs no pmset for sleep at any time, whatever + `owedBefore` says (`expires` proves only that no command for the start + enters later while the wall clock does not go back, not that one already + entered has exited); the rest of the end still runs. A dialog that did + not end by itself + (timeout, stuck prompt, interruption) and has at most 15 s left of + `expires` waits for it before the read. The next recovery-lock holder (the app after a relaunch, + backstop.sh, uninstall.sh) settles an attempt still journaled the same + way once the marker is gone and before it reads the session: + `.undecided` keeps everything and holds the sleep undo; otherwise the + session.json whose end is the attempt's deadline goes, then, with the + claim still held, the decision is journaled (`sleepOffAttempt.settled`, + `.neverWrote` putting `sleepDisabledByUs` back to `owedBefore`, anything + else keeping it), then the claim goes back, then the attempt goes + (`finishSettlement`, `finish_settlement`). A settlement that cannot + journal its decision keeps the attempt unsettled and the claim (with + `.neverWrote` and no `owedBefore` no pmset runs and Starts stay refused; + otherwise the app ends an unexpired session of it and refuses Start, + backstop.sh undoes as `--force` and exits 1, uninstall.sh stops with no + pmset). One that fails after that keeps the settled record, which holds + nothing back: the undo follows its decision, Starts stay refused, + backstop.sh exits 1, uninstall.sh stops, and later runs finish it + without reading the receipt again. The release file `.released` + (the user's, 0600, + 42 bytes, ` free|held`) holds the claim: a start claims only + while it shows the receipt's nonce `free`, and another + `INSOMNIA_HOME`'s unsettled claim refuses Start. A marker removed with no + attempt journaled takes session.json with it when no session is in + memory. Flag a change that writes anything (pmset, the receipt or a + restore run as the user) before the checks pass, has root write the + marker or any path in the user's folders, chmods or chowns a receipt + or folder install.sh did not make, gives the receipt any mode but 600 + or any ACL entry but the installing user's read, accepts a receipt with + another mode or entry, trusts a receipt owner other than + root in a shipped build (an environment variable, file or flag that + widens `owners` or `RECEIPT_OWNER` included), reads the receipt before + the marker is gone under its lock, lets `.neverWrote` clear an owed + `sleepDisabledByUs` rather than restore `owedBefore`, resumes a session + whose attempt is still journaled, reads or claims the receipt without + its lock or takes a busy or failed lock as clean, lets `.undecided` or + a predecessor read before `expires` (dialog not over) settle as + `.neverWrote`, lets `.undecided` or a busy receipt run the sleep undo + (owed or not, before or after `expires`), gives a claim back before the + decision is journaled, reads a settled record against the receipt again, + drops the second `pmset -g` read, has install.sh read the receipt or the + release file before it holds the receipt's lock or replace a `held` + claim, writes the receipt without `F_FULLFSYNC` or with more + than one write, claims while the release file shows another nonce or + `held`, drops the sudo.conf or PAM check, + accepts another sudo version or adds a Defaults name to the list + without reading what that setting does when sudo runs a command, + treats a missing, replaced or unreadable receipt, or a marker other + than the journaled one, as showing no write, runs a query that + executes a command, + accepts `sudo -l` or `sudo -v` alone, a bare exit status or a NOPASSWD + grep as the check, drops a clock check after a query or the read, reads + sudo's output in another locale or without fixed paths, or treats exit + 1 as a refusal. Known and disclosed (round 14 P1 narrowed to one + moment, not closed): pmset has no compare-and-set and one + `SleepDisabled` with no owner, so a 1 another tool sets between root's + second read and its `disablesleep 1` is taken for Insomnia's and set to + 0 by the end; one set during the session is set to 0 by the end; one set + while the `writing` line is written is caught by the second read, unless + its `refused` line cannot be written and the status is lost, and is then + set to 0; one set after root's second read during a dialog that then + fails ambiguously (timeout, + stuck prompt, pmset failure, a status lost to a signal) is set to 0 by + the undo once the command wrote `writing`, even when it stopped before + its write; once `expires` has passed a receipt that shows nothing, and + at any time a replaced marker or a settlement that cannot be written, + also undo; an attempt that stays `.undecided` keeps Start refused and + the session recorded until a later holder settles it, and holds the + sleep undo for as long as the receipt stays locked, with no limit, so a + root command that never exits means no restore and a process running as + the user can delay one (only root and the user can open the receipt; its + lock is advisory); the receipt + is + `F_FULLFSYNC`ed, but what that and pmset's own write guarantee across + a power loss is not measured, so older receipt content beside pmset's 1 + stays possible and that 1 is then reported as someone else's; a + process running as the user can rewrite state.json and the attempt, + swap the marker while a command holds it, or write a settled start's + nonce into a new marker before a password goes into that start's old + dialog; a start that cannot journal its settled success, and a marker + found with no attempt, end that session early, while a settled success + whose claim or record cleanup fails resumes its own session with Starts + refused until the cleanup finishes; and while the + journal claims a 1 another tool's 1 is taken for it, and a refusal + leaves it owed. The listing is about the rule when it is + read: a rule removed later, a sudo.conf or PAM file changed later, or + other groups at restore time can still make a restore fail, and the + backstop retries. The check refuses every Start under any sudo but + 1.9.17p2, a sudo.conf, other PAM session lines, Defaults outside the + list (global, user-bound or host-bound), Defaults bound to a Runas user + or a command, `listpw=always` or a later rule for the restore, + and install.sh changes none of these. The user types the password before a refusal is reported; a + sudoers without root's default entry fails closed. `install.sh` runs no + pmset: after writing the rule it checks a `sudo -k -n -l` listing, which + is not the check. - `DisplayPower.swift`, `LidActions.swift`. On lid close, brightness 0 is the primary mechanism; the display sleep request (`IORequestIdle`) is best effort and is ignored while any process holds a display assertion, @@ -226,6 +472,10 @@ Flag a change that breaks one of these; do not flag the behavior itself. trust boundary as `config.json`: anyone who can write the support directory already controls the app. A trigger left over from before the session started is drained and logged as stale, not delivered. -- `install.sh`. Not atomic. It asks for sudo once, for the sudoers file, - before anything of a previous install is touched, and a failure after - that step says exactly what was installed so far. +- `install.sh`. Not atomic. It asks for the password (`sudo -v`) before a + running app is asked to quit, so a cancelled password changes nothing and + a running session keeps going; when a session is running it says so first + and, in a terminal, asks to continue. It then quits the app and, under the + recovery lock, looks for a reopened app and writes the sudoers file on + the cached credential before the recovery and the bundle swap. Every stop says what was installed so + far; one after the rule is written gives the rerun command. diff --git a/README.md b/README.md index 351f7cd0..7c2c3f04 100644 --- a/README.md +++ b/README.md @@ -79,25 +79,31 @@ file. It installs: session's changes. - A **sudoers rule** at `/etc/sudoers.d/insomnia`. A sudoers rule decides who can run what with `sudo`. This one lets your user account, not just - Insomnia, run these four commands without a password: + Insomnia, run these three commands without a password: ```text -/usr/bin/pmset -a disablesleep 1 /usr/bin/pmset -a disablesleep 0 /usr/bin/pmset -b lowpowermode 1 /usr/bin/pmset -b lowpowermode 0 ``` -Any program running as you can use that rule too, so review it before you -install. [docs/install.md](docs/install.md#what-gets-installed) lists every -file and explains how upgrades replace them. +They turn sleep back on and switch battery Low Power Mode on or off. None of +them keeps the Mac awake. Turning sleep off (`pmset -a disablesleep 1`) is +not in the rule: each time you start a session, macOS asks for your +administrator password. Any program running as you can use the rule too, so +review it before you install. [docs/install.md](docs/install.md#what-gets-installed) +lists every file, including the receipt each Start's password command writes, +and explains how upgrades replace them. ## Using it 1. **Start.** Click the eye in the menu bar, enter days, hours and minutes, - and press Enter. + and press Enter. macOS asks for your administrator password to turn sleep + off. Cancel starts nothing. + [docs/recovery.md](docs/recovery.md#starting-a-session) says what happens + when a start fails. 2. **Add time.** Click the eye or the countdown during a session. 3. **End early.** Press and hold the end control next to the countdown. 4. **Settings and status.** Right-click the eye. Quit asks Insomnia to undo diff --git a/Resources/Info.plist b/Resources/Info.plist index 3f212a64..b14c7365 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -36,6 +36,8 @@ NSApplication InsomniaResumeFrozenVersion 1 + InsomniaAccessListsVersion + 1 NSHumanReadableCopyright Copyright © 2026 Krish Garg. MIT License. diff --git a/SECURITY.md b/SECURITY.md index 3bcbf397..6d6c1e41 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,11 +11,713 @@ on this repository's Security page, use that private channel. Otherwise open a public issue containing only a request for a private security contact, without the sensitive details, and wait for the maintainer to arrange one. -The installer grants the user account passwordless access to four exact pmset -commands listed in the README. This grant is not exclusive to the Insomnia app: -other processes running as that user can invoke them too. The app is not -sandboxed; local logs can contain SSIDs, process metadata, and tmux target names. -The lines the app writes to `insomnia.log` also reach the unified log with the +The installer grants the user account passwordless access to three exact pmset +commands listed in the README: turn sleep back on (`pmset -a disablesleep 0`) +and switch battery Low Power Mode on or off (`pmset -b lowpowermode 1` and +`0`). This grant is not exclusive to the Insomnia app: other processes running +as that user can invoke them too. The rule is one file, +`/etc/sudoers.d/insomnia`, with those three lines for each account on the Mac +that installed Insomnia: an install reads it first and keeps the other +accounts' lines for the same three commands. None of them can keep the Mac +awake. Turning sleep off (`pmset -a disablesleep 1`) has no passwordless +line; the app runs it through the standard macOS administrator dialog, with a +fixed command string, each time the user starts a session, and never on +relaunch or from the recovery agent. That command turns sleep off only while +the start that asked still holds its random nonce in `pending-start`, and +only before the start's `expires` (the session's end, or 130 s after that +file was written if sooner), which it receives as an argument and compares +with the clock as root. It holds a `lockf` lock on that file, and an flock(2) +lock on the user's receipt (below), from before the checks until pmset exits. +The start deletes the file when it finishes, and the app at launch, the recovery agent and uninstall delete it +under the recovery lock before they undo anything; every one of them takes the +file's own lock first, and deletes it only while its path still names the +file it locked, so the file never goes while that command is past its +check. A start that gave up on a stuck dialog counts it as voided only if +the file it locked is the one it wrote. The app after a relaunch, the +recovery agent and uninstall cannot make that last check, since they did +not write the file: a process running as you that puts a copy in its place +while the command runs could get it deleted early. Such a process could +already clear the journal entry in `state.json` directly. A dialog answered after its start was abandoned (the app died, recovery +ran, the start rolled back, a newer start began) runs nothing. The app shows +the dialog only when the `backstop.sh` sealed in its bundle, the copy the +recovery agent runs, declares in its `# insomnia-backstop-version:` line a +version that deletes `pending-start` and settles an unfinished start from +its receipt (below), so recovery after a crash under the dialog never +depends on an older script. + +Before the dialog the app runs nothing through sudo. It reads `pmset -g`, +and a SleepDisabled 1 the journal does not claim is left alone: Start is +refused with nothing run and gives the command that turns sleep back on. An +unreadable `pmset -g` refuses too. After the password, under the marker's +and the receipt's locks and after the receipt, nonce and deadline checks, +the root command checks +whether the session's end will be able to turn sleep back on without a +password, and writes nothing until it has the answer. As root it first +reads two files that change what the restore does but not what a listing +shows: + +- `/private/etc/sudo.conf` must not exist, as a file, a link or anything + else. Apple's sudo reads plugins from that one path, compiled in, with no + override, and without the file it loads only its built-in sudoers + policy, I/O and audit plugins. With the file it can load an approval + plugin that prints nothing in `sudo -V` (sudo skips a plugin with no + `show_version`) and that sudo consults only when it runs a command, so + that plugin can refuse the restore while every listing passes. macOS + installs no sudo.conf. +- `/private/etc/pam.d/sudo` must have exactly one uncommented line that + mentions a session, `session required pam_permit.so`, as macOS installs + it. sudo opens a PAM session only to run a command, never for a listing, + and a session module that fails fails the restore. + +Root then drops to the user who pressed Start (`sudo -n -u "#"`: sudo +never asks root for a password, and macOS's default `root ALL = (ALL) ALL` +permits it), empties the environment with `/usr/bin/env -i LC_ALL=C`, and +runs that user's `/usr/bin/sudo` three times, with stdin from /dev/null. +The answers must be the ones sudo 1.9.17p2 prints. That is the sudo in +macOS 26.2, whose manuals are headed Sudo 1.9.17p2 and whose source is +Apple's sudo-114.100.11. Only that source was read for what a listing +shares with running a command, so any other version refuses, older or +newer, until an Insomnia release is checked against it: + +- `sudo -V` must show `Sudo version 1.9.17p2`, the sudoers policy plugin + 1.9.17p2, sudoers file grammar version 50, and after them nothing but the + sudoers I/O and audit plugins' own lines. +- `sudo -k -n -l` must list the user's rules without a password, and each + Defaults entry it shows for the user must be one the check accepts: + sudo lists there the entries set for everyone and those bound to the + requesting user or to this host. The + list holds env_reset, env_keep, env_check and env_delete, which choose + only the environment pmset gets; log_allowed and log_denied, which only + turn logging to syslog and the audit log on or off; and lecture, + lecture_file, passprompt, badpass_message, passwd_timeout, passwd_tries, + timestamp_timeout, timestamp_type, tty_tickets, pwfeedback and insults, + which sudo reads only when it asks for a password. A log sudo cannot + write stops no command while `ignore_logfile_errors` keeps its default, + and the list accepts no entry that changes it. Any other entry refuses, + and the message names it. I/O logging is one example: under + `log_output`, sudo will not run a command whose I/O log it cannot write, + which a listing never tries. Defaults bound to a Runas user or a command + (`Defaults>user`, `Defaults!command`) refuse too, since they apply when + the restore runs but not to a listing, and so does a Defaults line with a + backslash or a tab, which cannot be split back into entries with + certainty. +- `sudo -k -n -ll /usr/bin/pmset -a disablesleep 0` must print exactly the + rule install.sh writes, in six lines: `Sudoers entry:` naming + /etc/sudoers.d/insomnia (as /private/etc or /etc), `RunAsUsers: root`, + `Options: !authenticate` (NOPASSWD and no other tag), the restore as the + only command, and `Matched:` the restore. Given a command, sudo prints + the rule that decides it, the last one that matches, so a later rule + that asks for a password, denies the command, or runs it as anyone else + is the one shown, and it refuses. + +`-k` ignores a credential cached by a recent sudo, and `-n` fails instead +of prompting. None of the three runs a command, and none is `sudo -l` alone, +`sudo -v`, an exit status read alone, or a search for NOPASSWD: the admin +group's `(ALL) ALL` lists commands the user may run with a password, and a +listing passes without one whenever any of the user's entries is +passwordless. A file check or query that fails, prints anything else, is +cut short, or asks for a password (a `listpw` setting that wants one) exits +5. The clock is then compared with the start's `expires` (exit 4). + +The receipt is `/private/var/db/com.kgarg.insomnia/`, exactly 82 +bytes on one line: a start's nonce (an uppercase UUID), a space, the nonce +the receipt held before that start (its predecessor), a space, `writing` or +`refused`, and a newline. install.sh makes the folder (mode 0755) and the +file (mode 0600, holding the all-zero nonce twice and `refused`), both +root's, through `sudo -n` with those fixed paths, after checking by lstat +that every folder from `/private/var/db` up to / is root's, is not a link, +has no group or other write permission and no access control entry that +allows anything. It then gives the file one access control entry, `sudo +-n chmod +a "user: allow read"`, for the account that ran it. Only +root and that account can open the receipt, so no other account can take +its lock. It checks the folder and the file again once they exist. A +receipt an earlier build made (root's, one link, 82 bytes, no group or +other write, with no entry or only that one) is repaired in place by `sudo +-n chmod`, keeping its bytes and its inode, and only while the release +file shows no claim: there is none, or it holds a nonce and `free`. A +claim, or a release file that is not a regular file, cannot be read or +holds anything else, stops the install before any change: settle that +start first, or remove both files by hand when no Insomnia folder has a +start to settle. A receipt the user can open (any mode but 0600) is +locked first, as the root command and every reader lock it, the release +file is read under that lock, and before each chmod the receipt must +still be the locked file with the same line. One the user cannot open +(0600 with no entry) is refused by the app, backstop.sh and the root +command until it has the entry, but a root command that opened it before +the entry went away may still hold its lock, which the mode does not +show. So one `sudo -n` call runs a shell as root that locks it as every +reader does, checks under that lock that it is still the file the user +saw, still has no entry and holds a receipt's line, and that the release +file is still as the user read it, then adds the entry with the lock +held. The lock ends with that call; the receipt is then locked and +checked with the release file below. A receipt this run just made gets +its entry without that lock: nothing could open it first. The entry goes +on before the mode, so the user can read the receipt throughout. The +readers of earlier builds of this change refuse a repaired receipt, and +they stay installed when the install stops later, or rolls back to the +bundle it was replacing. Those readers check the mode and entry before +they take the receipt's lock, so such a build can settle a start of its +own as one whose command may have written, and then turns sleep back on, +over another tool's 1 too. A folder or +file already there in any other form (another owner, a looser mode, a +link, another type or size, a second hard link, an allowing ACL on a +folder, any other entry on the file) stops the install: it never changes +the owner of something it did not make, and says to remove it by hand. +That includes the 45-byte receipt of earlier builds of this change, which +is not converted. A receipt already as it makes it is kept. Nothing +running as the user can write the receipt or replace it or any folder +above it. Every Insomnia folder of the user (`INSOMNIA_HOME`) shares the +one receipt. + +The app, backstop.sh, uninstall.sh and the root command accept the +receipt only at mode 0600 with exactly that one entry: allow, the read +right alone, for the user's uid, not inherited and with no flags. Another +account's or a group's entry, a deny entry, a second entry, or any other +right or flag refuses. The app reads the list through acl(3) and sees +every entry. backstop.sh and uninstall.sh do not parse a list: they ask +the installed app binary (`Insomnia --access-lists`), which reads the +receipt's list and each folder's through acl(3) the same way and prints +one word per path. They take only an answer that ended within its time +limit, is no longer than its size limit, holds no NUL byte and has one +expected word per path, with both exit statuses. install.sh and the root +command still read lists through `ls -le` and `ls -lde`, which never +print the synchronize right, print folder-only rights and the inherit +flags only for folders, and skip an entry they cannot read; a receipt +with one of those added can pass their check. They then ask `id -u` +whether the entry's name is the user's uid. The two checks are not +equivalent: such a receipt passes install.sh and the root command, and +the app and the other scripts refuse it. + +A check that could not be made is not a refusal. When an acl(3) call +fails or gives an answer acl(3) does not document, when lstat(2) fails +other than for a missing path, or when the scripts get no answer they can +take from the app binary, nothing shows whether the receipt can be +trusted. The app and backstop.sh then decide nothing from it at any time: +no pmset runs, and the start stays recorded with its claim and its sleep +entry, as while the receipt is locked. uninstall.sh stops with nothing +removed. When no app of this version is installed, so there is nothing +to ask, it removes only its own folder's files and keeps the rule, the +receipts folder and everything in it, running nothing as root. + +Beside it, install.sh makes `.released` through `sudo -n install`: +the user's own file, mode 0600, 42 bytes, holding a nonce and `free` or +`held`. A start claims the receipt before its dialog, under the receipt's +lock, and only while this file shows the receipt's current nonce `free`; +it then writes its own nonce `held`. The claim goes back (the receipt's +nonce, `free`) only once that start is settled. While a claim is out, a +start from any other Insomnia folder of the user is refused, so no root +command can replace the line that the claimed start's settlement will +read. Anything running as the user can rewrite this file, so it never +shows that a start did not turn sleep off. A false line can only make a +settlement find a later start's line, which shows nothing and leads to an +undo, or refuse every Start. install.sh keeps a `held` claim on an +existing receipt and otherwise writes the receipt's nonce `free`. + +The receipt is also the lock for every party that acts on a start. The +root command opens it read-only and takes an exclusive flock(2) lock on it +(`lockf -s -t 10` on the open descriptor) before any other check. Its shell +and every tool it starts, pmset included, inherit that descriptor, so the +lock lasts until all of them have exited. The app, backstop.sh, install.sh +and uninstall.sh open the receipt read-only and take the same lock before a +start claims it, before they read it to settle a start, before install.sh +writes the release file, and before uninstall.sh removes it. Each of them +therefore reads the receipt before a command took the lock or after that +command and its pmset exited, whichever marker file the command opened. +Taking the lock needs read access, which only root and the user have +(mode 0600 and the one entry), so no other account can hold it. While root +or a process of the user does, Start is refused after 10 s, the root +command refuses with exit 75 after 10 s, and a +settlement decides nothing (below). The lock is advisory: root can change +the file without it. + +Under that lock the root command checks the receipt: the uid must be plain +digits, and `stat` and `ls -le` of the receipt and of every folder above it +up to / must show a regular file with one link, 82 bytes, mode 0600 and +the one entry for the user under folders, each of them root's, with no +group or other write permission and no allowing entry. The open +descriptor and the path must both be the file +(device and inode) the start claimed. Anything else exits 7 with nothing +written. Exit 3 means the marker no longer holds the nonce. Exit 4 means the +clock is not before the start's `expires`. Then come the sudo checks above +and the clock again (exit 4). The receipt must still begin with the +predecessor the start claimed it from, or the command exits 8: another +start or a recovery came first. Then it reads `pmset -g` (exit 6 on a +SleepDisabled 1 or a failed read; it reads nothing only when the journal +claimed the 1 before this start). + +Then it writes ` writing` into the receipt in place. +`/usr/bin/perl`, run with an empty environment, opens the path for writing +without creating, truncating or following a link, checks that the +descriptor it got is the locked file, writes the 82 bytes with one write(2) +that must write them all, calls `fcntl(F_FULLFSYNC)`, closes the file, +opens it again and reads the line back. Any failure, a missing perl +included, tries to write `refused` the same way and exits 7, so a Mac +without `/usr/bin/perl` cannot start a session. Apple's fcntl(2) manual +describes F_FULLFSYNC as an fsync(2) followed by a request to the drive to +flush its own cache, and says that some drives ignore that request. Once +the line is read back, the command reads `pmset -g` again, under the same +rule: a SleepDisabled 1 set while the line was written, or a +read that fails, writes `refused` and exits 6, and that 1 stays. The +clock is compared once more: at or after `expires` the command writes +`refused` and exits 4. Only then does it run `pmset -a disablesleep 1`, its +only power write, which the start journaled before the dialog. If pmset +fails, the command exits 1. Root reads the marker but never writes it, and +never writes anything into the user's folders. The command ignores +SIGPIPE, so a refusal keeps its own status when the dialog's output is +already gone. Exits 3 to 8, and lockf's 69 and 75, leave the sleep setting +as the command found it, and roll the start back with nothing to undo: no +session, the journal and session.json as they were, the claim given back, +no pmset, and for exit 5 a message that names the check that stopped it. +That happens without the rule (an uninstall that stopped part way, a +hand-deleted file), on a Mac whose sudoers lacks root's default entry, and +with any sudo, sudo.conf, PAM file or sudoers setting the check does not +accept, even one under which the restore would run. Rerunning the +installer fixes only a missing rule: it changes no sudo version, +sudo.conf, PAM file or Defaults, and macOS keeps its sudo on the sealed +system volume. The user has typed the password by the time a refusal is +reported. + +A start's `expires` is the session's end, or 130 s after the start wrote +`pending-start` if that is sooner: the dialog's 120 s, the 3 s the app +waits after SIGTERM, and 7 s for osascript to start. No query and no read +has a time limit of its own. The dialog's 120 s limit is only how long the +app waits before it sends osascript SIGTERM, and that wait includes a +command that runs after the password was accepted. SIGTERM does not stop +the root command, and nothing is ever sent SIGKILL. If osascript has not +finished 3 s after SIGTERM, the app deletes the marker it wrote, under the +marker's lock. When that works, no command for the start is past its nonce +check: the start is settled from the receipt once `expires` has passed, a +few seconds later, and the menu names the leftover process until it exits. +While a root command holds the marker's lock (any time from its nonce +check to its exit, so in a sudo query that never returns too), the start +keeps session.json, the journal entry and the recovery lock, waits with no +limit for the command to exit and its output to close, and names what is +running in a notification and the menu. Starts, ends and the recovery +agent wait behind it. When the command does return at or after `expires`, +the clock check that follows every query and the read stops the write. + +After a failure whose status the app cannot trust (a wrong password, a +dialog that reached its time limit, a signal the app did not send, a pmset +failure, any other osascript error), the app deletes the marker under its +lock, so no command for this start can pass its nonce check any more, and +then reads the receipt under the receipt's lock. The receipt shows that no +command for the start turned sleep off only when it passes the checks +above (by lstat, then through an `O_NOFOLLOW` descriptor whose fstat +matches), is the file the start claimed (device and inode), and holds one +of three lines: + +- this start's nonce with `refused`; +- another start's line that names the same predecessor: that start wrote + first, and this start's command writes only while the receipt begins + with the predecessor; +- the predecessor itself, once no command for the start can still write. + That needs the dialog to have ended by itself (osascript exited on its + own, after any command it ran) or `expires` to have passed: a command + that takes the lock later refuses with exit 4 before it writes. After a + timeout the app waits up to 15 s for `expires` (about 10 s) rather than + leave the start unsettled. + +The start then rolls back with no pmset, and a SleepDisabled 1 another +tool set while the dialog was up stays. This start's `writing` and a +later start's line that names another predecessor are undone like an end +at once. A receipt that is missing, replaced, unreadable, of another size +or shape or under a folder someone other than root could change is undone +like an end once `expires` has passed. Before then it decides nothing +(below), and neither does the predecessor after a dialog that may still +be answered, nor, at any time, a receipt that stays locked, whose lock +fails or whose checks could not be made. A wrong +password never starts the command, so the receipt still holds the +predecessor. A cancelled dialog never starts it either, and rolls back on +its status alone. + +A start journals its attempt before its claim and before the dialog can run +anything: `sleepOffAttempt` in state.json, written with +`sleepDisabledByUs`, holds the nonce, the predecessor, the receipt's +device and inode, the session's end, the `sleepDisabledByUs` from before +the start (`owedBefore`) and, once the marker exists, `expires` and the +marker's device and inode. A start that finishes or rolls back marks the +attempt `settled` in the journal, then gives its claim back, then removes +the attempt. One left in the journal belongs to a start that never +finished (the app crashed or was force-quit) or to a settlement that +could not finish. The next holder of the recovery lock (the app at the +start of every transaction, backstop.sh or uninstall.sh) +settles it once it has deleted the marker under the marker's lock, before +it reads the session or restores anything. Which marker file went decides +nothing: the receipt does, read under its lock. + +A settlement knows of no dialog that is over, so a start whose `expires` +has not passed and whose receipt still holds the predecessor stays +journaled: its dialog can still be answered. So do the other receipts that +decide nothing before `expires`, and, at any time, a receipt that stays +locked or cannot be locked, since a command for that start may hold it and +still be in pmset, and one whose checks could not be made. While a start +stays journaled, new Starts in that Insomnia +folder are refused, its claim keeps Starts in every other folder of the +user refused, an unexpired session of that start is ended rather than +resumed, and no pmset runs for sleep, whatever an earlier session owes. +`expires` only stops commands that have not yet reached their last clock +check; one already past it may still turn sleep off after an undo, which +would then not hold. The rest of the undo (Low Power Mode, frozen +processes, audio) still runs. A start whose receipt stays locked keeps +that hold for as long as the lock is held, with no limit, so a command +that never exits leaves sleep as it is with no restore from Insomnia. +A receipt whose checks cannot be made (a list acl(3) cannot give whole, +an app binary that does not answer) keeps that hold the same way, until +a check can be made. +Otherwise session.json goes first if its end is that start's deadline +(that session never began, and is never resumed on a SleepDisabled 1 +someone else may have set). Then, while the +claim is still held, the journal records the decision: the attempt marked +`settled`, with `sleepDisabledByUs` put back to `owedBefore` when the +receipt shows no command turned sleep off (an attempt with no marker never +showed a dialog), so a restore an earlier session still owes stays owed +and no pmset runs, and kept otherwise, so the restore runs. Then the claim +goes back, then the journal drops the attempt. A record already marked +`settled` is finished the same way from its decision, and never read +against the receipt again: once its claim went back, a later start's line +may show something else. + +A settlement step that fails before the decision is in the journal keeps +the attempt unsettled, with the claim, so the next run reads the same +line: a session.json that cannot be removed (then nothing is removed) or a +journal that cannot be written. One that fails after it (a claim that +cannot be given back, a settled record that cannot be removed, a receipt +that cannot be locked to finish it) keeps the settled record. That record +holds nothing back, since its decision is made: the undo follows it, +Starts stay refused until it is finished, and every run tries again. A +crash at any point leaves one of those two journals. The menu, the log and +uninstall.sh's message say whether session.json of that start was removed +and whether the decision was journaled. While the receipt cannot decide +yet, or shows that no command turned sleep off with no earlier restore +owed, no pmset runs for sleep: the app refuses Start and retries at every +transaction, backstop.sh logs that sleep is left as it is, keeps the +journal dirty and exits 1, and uninstall.sh stops with nothing else +removed. Otherwise the app and backstop.sh run the restore and keep the +attempt. uninstall.sh runs no pmset while an attempt stays unsettled or a +settled one cannot be finished: it says what it removed or journaled and +stops. A marker the lock holder deletes that no journaled start accounts +for (left by an older build, or by a start that finished but could not +delete it) takes session.json with it, for the same reason. + +Only install.sh (which creates the receipt), uninstall.sh (which removes +this user's receipt and release file and then the folder once it is empty, +as root, after the same checks) and the root command of an authenticated +Start change the receipt. uninstall.sh removes the two files only under +the receipt's lock, and only while the release file shows the receipt's +own nonce `free`. Before that check it takes the standard folder's +recovery lock, the one install.sh holds from before the rule until it +ends, and keeps it to its own end, so no install can make a new receipt or +release file, or write the rule or the bundle, while it checks and removes +them. That covers a rerun that finishes an earlier removal too, when there +is no receipt to lock. In the standard folder that lock is its own +recovery lock. Every holder takes them in one order: a folder's recovery +lock, then the standard folder's, then the receipt's. The app locks a +recovery lock, and uninstall.sh the standard folder's, only as a regular +file: a symbolic link, a folder or a FIFO at the path is refused and left +as it is, and nothing unlinks or replaces a lock. install.sh, backstop.sh +and uninstall.sh still open a folder's own recovery lock through a link. +uninstall.sh stops before +removing anything and says why when its folder's recovery lock or the +standard folder's stays busy (exit 75), and with exit 1 when the +receipt's lock stays busy, a start claims the receipt, the release file shows +another nonce, the receipt, the release file or a folder above them +cannot be read or fails the checks, or one of the two files is there +without the other. With no app of this version to read the folders' +lists, it removes only its own folder's files instead (above). One lone release file is +let through: the one an uninstall of the same folder left when it stopped +between removing the receipt and removing the release file. Under the +receipt's lock, after the checks and before the LaunchAgent goes, it +writes `.uninstall-receipt-removal` in its own folder with the release +file's device, inode, change time to the nanosecond and line, flushes it +and its folder to the disk (F_FULLFSYNC) and reads it back whole, and +stops with nothing removed when it cannot. A rerun reads it only as one +line of that form, ended by a newline, in a regular file of the user's +with one link, mode 0600 and at most 200 bytes. It removes the lone +release file only while that is a regular file of this user's with one +link, mode 0600 and 42 bytes, matching the record exactly, with `free`. A +start claims and gives back only by writing to the release file, through +the receipt, which is gone, and any write changes the change time. A flush +shows that the kernel handed the bytes to the disk, not that a drive's +cache kept them through a power cut; a record lost that way makes the +rerun refuse, the safe side. It runs `sudo -v` before it takes any lock, +so the password prompt holds nothing, then `sudo -n -v` under the locks, +which stops it before anything is removed when sudo kept no credential or +it ran out meanwhile. Every command it then runs as root goes through +`sudo -n` with its 30 s call limit, while it holds the recovery lock, the +standard folder's lock and the receipt's lock. A call that fails, is +stopped by SIGTERM at that limit, or is still running after it stops the +uninstall there: what it did is not known, so nothing after it is removed. +sudo only ever gets SIGTERM, and a sudo still running keeps the three +locks until it exits. A stop after the LaunchAgent was booted out loads it +again from its plist, which stays until the shared files are gone, so this +folder's recovery runs as it did; while a sudo is still running it loads +nothing and says how to load it once that ends. The app, backstop.sh and +uninstall.sh also write the release file, which is the user's. Each start +rewrites the same 82 bytes in place, so nothing accumulates. A new start's +nonce is random and never the all-zero one. + +What this design does not close. pmset has no compare-and-set and one +SleepDisabled value with no owner, so no read is atomic with the write +after it, and a 1 another tool writes over a 1 already in effect leaves no +trace. + +- A SleepDisabled 1 another tool sets in the instant between the root + command's second read and its `disablesleep 1` (the clock check and the + time pmset takes to start) cannot be told from Insomnia's, and the + session's end sets it to 0. One set during the session is set to 0 by + the session's end. +- One set while a dialog is up, before the root command's first read, or + while its `writing` line is written, before the second read, stops the + command and stays, after a crash too, when the command reports exit 6 or + writes its `refused` line. When that line cannot be written, the + receipt still shows `writing`. The app that gets the exit 6 rolls the + start back with no undo, and while it cannot journal that rollback its + own later settlements of the start keep the exit 6 as their verdict. + When the status is lost (a signal, the dialog's time limit, a crash + before the app acts on it), or the app quits or crashes before it can + journal the rollback, or backstop.sh or uninstall.sh settles the start + first, that settlement reads `writing` and sets that 1 to 0. One set + after the + second read is set to 0 when the start then fails (a pmset failure, or a + signal or the dialog's time limit before pmset). The second read narrows + the window; it is not an owner token and does not close it. +- When the receipt shows nothing once `expires` has passed (missing, + replaced, unreadable, under a changed folder, or a later start's line + that names another predecessor), the start is undone like an end, and a + 1 another tool set is set to 0. A receipt that stays locked, or one + whose checks could not be made, decides nothing at any time, and the + sleep undo waits for it (below). +- The receipt's line reaches the drive before pmset runs only on a drive + that honours F_FULLFSYNC. On one that does not, a power loss can bring + the receipt back with older content while pmset's 1 persists. That 1 is + then reported as set by something else, Start is refused until it reads + 0, and the menu gives the command. A torn write shows nothing, and the + start is undone like an end. The app writes the release file with + fsync(2); backstop.sh and uninstall.sh write it with no fsync, since the + shell offers none. state.json and session.json are replaced by rename + without an fsync. A crash cannot put back a session.json from before the + start that the start overwrote. Nothing makes the receipt, the release + file, the journal and pmset's setting change together. +- A settlement journals its decision before it gives the claim back, so a + crash between the two leaves a settled record that the next run + finishes. An Insomnia folder deleted or abandoned while one of its + starts is still journaled (settled or not), or a release file someone + damaged, leaves a claim nothing gives back. Every Start of the user is + then refused, with the claim's nonce in the message, until `sudo rm -f + /private/var/db/com.kgarg.insomnia/ + /private/var/db/com.kgarg.insomnia/.released` and + `./scripts/install.sh` again. install.sh alone keeps a `held` claim, + read under the receipt's lock, even right after it made the receipt, and + stops at a claim in a file that is not the user's 0600 file with one + link. It replaces a release file with no claim by rename, after checking + that the file still holds the bytes it read; a write between that check + and the rename is lost. +- Only root and the user can open the receipt (mode 0600 and one read + entry for the user), so another account cannot take its lock. Anything + running as the user can hold it for as long as it likes, since the lock + is advisory; the user's own processes can already rewrite the journal. + Starts are then refused, and a journaled start is retried, unsettled, + with its sleep undo held, for as long as the lock is held, after its + `expires` too. A root + command that never exits (a hung pmset or sudo query) holds the lock the + same way, and Insomnia then never restores sleep for that start. + Deleting the marker does not stop a command already past its nonce + check, and the lock that command holds can outlast the dialog's answer + window. A crash or timeout under a dialog keeps Starts in that folder + refused for at least about 130 s, and with no limit while a command + for that start runs or the receipt's lock, the claim or the journal + cannot be had. A start in one Insomnia folder keeps + Starts in every other folder of the user refused until it is settled. + A credential that runs out after `sudo -n -v` found it makes the next + `sudo -n` fail, and the uninstall stops after the LaunchAgent was booted + out and loads it again. While a sudo it ran is still running it does + not, and no recovery agent runs for that folder until the user loads it, + a rerun finishes the uninstall or install.sh runs again. An uninstall of + another folder holds the standard folder's lock to its end, and with a + sudo still running until that sudo exits: the standard folder's app, + backstop.sh and install.sh cannot take that lock until then. After + uninstall.sh removes the shared receipt, any other Insomnia folder of the + user needs `./scripts/install.sh` again before its next start. A Mac + without `/usr/bin/perl` refuses every Start with exit 7. +- No claim in the release file does not prove that no other Insomnia + folder of the user owes a restore: a start gives its claim back once it + is settled, while the session it began still runs, and a settlement + gives the claim back before an undo that may then fail. Every restore + the rule runs sets SleepDisabled or Low Power Mode for battery to 0. So + uninstall.sh also reads both without sudo (`pmset -g`, and `pmset -g + custom` under Battery Power) and removes the shared rule, receipt, + release file and bundle only when both read 0, or this Mac has no + battery setting for Low Power Mode. A 1, any other value or a read that + fails keeps them: it removes this folder's own files, says what it read + and exits 1. It cannot tell another folder's setting from the user's own + or another tool's, so a user who keeps sleep off or Low Power Mode on + must switch it back for the uninstall to finish. The rule is one file + for every account on the Mac. install.sh reads it as root before it + writes it and keeps every other account's line that is exactly one of + the three, drops every `disablesleep 1` line, and stops with nothing + changed at a line it never wrote or a read it cannot trust (round 41; + before, a second account's install took away the first account's + restore). uninstall.sh keeps it while the receipt folder or the rule + itself shows another account, or cannot rule one out, and then this + account's own three lines stay in it too. Another account's install or + uninstall holds its own standard lock and no lock spans accounts, so + two accounts that install or uninstall at the same moment can still + drop the lines or the rule the other's restore needs. +- Every Insomnia folder of the user loads its recovery agent under the one + label `com.insomnia.backstop`, and launchd holds one job for it, from + whichever folder loaded it last. uninstall.sh boots that job out only + while `launchctl print` names this folder's plist or candidate as the + file it was loaded from, asked at the check and again just before the + bootout. While it names another folder's file, the uninstall leaves that + agent loaded, keeps the rule, the receipt, its release file and the + bundle the agent runs, removes this folder's own files and exits 1, + saying to uninstall that folder first. A print that fails or names no + single absolute file stops it with nothing removed. It keeps the + receipt's lock from the check until the rule, the receipt and the bundle + are gone, or to its end when they stay, so a start in another folder, + which claims the receipt under that lock before it loads its agent, + cannot load one between the check and the bootout. That start waits up + to 10 s for the lock, then is refused with nothing written. A load that + takes no receipt lock can: an extend, end or relaunch of a folder other + than this one and the standard one (whose recovery locks the uninstall + holds) between the last print and the bootout is unloaded, since launchctl + cannot unload a job only while it comes from a given file. That folder's + app loads it again at its next transaction. Two folders whose + LaunchAgents folders are one through a link count as one. Another + folder whose agent is not loaded, because this folder loaded its agent + last, needs install.sh again once the shared files are gone. +- On a full disk backstop.sh keeps its copies of session.json and + state.json in memory and still restores what the journal records. A + log line it cannot append goes to its standard error instead and the + run goes on. A journal it cannot publish stays as it was until a later + run can. A command whose supervisor + cannot write its status files counts as not known to have finished, so + the journal keeps its entry. A journal holding a NUL byte cannot be + kept in memory and is then left as it is. +- The clock is the wall clock, as session.json's end is, and `expires` + proves that no command for a start will begin only while that clock does + not go back. A clock set back after a settlement lets a dialog left on + screen pass the comparison with `expires` again, though only while a + marker holds its nonce. A settled record that says a command never wrote + is a record of what the receipt showed; it revokes nothing. +- The recovery agent is scheduled every 60 s, but launchd does not promise + when it runs, and it does not run while the Mac sleeps, so no recovery + has a fixed time bound. +- A process running as the user can write the marker, state.json, + session.json and the release file, so it can erase or rewrite the + journal entry and the attempt outright. It can swap a copy in for the + marker while a command holds the original; the settlement still waits + for the receipt's lock, which that command holds until pmset exits, and + then finds its `writing`. A settled start's nonce written into a new + marker turns nothing off: a start whose receipt still holds the + predecessor settles only once its dialog ended by itself or `expires` + passed, and its command refuses at or after `expires`. Without changing + state.json it cannot make a start that wrote `writing` read as one that + did not, since it cannot write the receipt or a folder above it, and a + forged release file only lets a later start's line in, which names this + start's nonce as its predecessor and shows nothing. The marker's and the + receipt's locks are advisory. +- A start that turned sleep off but could not then mark its attempt + settled keeps it unsettled; a later settlement finds its `writing`, + removes the session.json whose end is that deadline and so ends that + session early, with sleep restored. One that marked it settled but could + not give the claim back or remove the record keeps its session. The + journal write that marks a record settled also names the session the + settlement leaves to be resumed (`resumes`: its `startedAt` and first + end, its end less every extension, in whole seconds), before the claim + goes back: the start's own session when it turned sleep off, the + session a failed start put back as it was, or the one a settlement from + the receipt left in place. An app relaunched over that record resumes + that session under the checks every session gets (not expired, sleep + still off), as it would with no record, and ends any other session.json. + The menu keeps a line saying that the start is still recorded and why, + and new Starts stay refused until a run gives the claim back and removes + the record. A settlement that cannot read session.json publishes + nothing and retries at the next transaction. The name is what + session.json keeps of a session, not proof that it is the same one: a + session written in the same second with the same first end passes too. + A record without the name (one settled by a build from before it, or by + backstop.sh or uninstall.sh, which remove the start's own session + first) keeps the older rule: a session resumes only with the sleep entry + journaled, begun at least a minute before the start's deadline, with its + first end in the second the deadline names, or up to a second earlier + for each extension with a fraction of a second (one cut short at the + maximum). Under that rule another session with that first end passes, + and an earlier session a failed start put back ends while the record + stays. A build from before the name drops it when it rewrites the + journal. A session whose marker + could not be deleted when it started ends early too, when a later run + deletes that marker with no attempt journaled. +- The app reads a session.json whose extensions do not add up (one that + is not a finite number, a sum that is not, or a first end outside 1970 + to 9999) as one that does not parse: it moves the file aside and + restores the journal. No session the app wrote has such a history; a + process running as the user, or damage on disk, can write one. + backstop.sh reads only the session's end, so it honors that end until + the app moves the file aside; uninstall.sh ends any session either way. +- While the journal claims a 1 from an earlier session, another tool's 1 + is taken for it, and a refused start leaves that 1 in place with its + restore still owed. +- sudo answers for the moment it is asked: a rule removed later, a + sudo.conf or PAM change made later, or other groups for the user when + the app or backstop.sh runs sudo than when root switched to that user, + can still make a later restore fail, and backstop.sh then keeps the + journal entry and retries. Any sudo but 1.9.17p2 refuses every Start, a + newer macOS's included, until an Insomnia release is checked against it, + and so do an /etc/sudo.conf, PAM session lines other than macOS's own, a + Defaults entry for the user, the host or everyone outside the accepted + list, Defaults bound to a Runas user or a command, `listpw` set to + always, and a later rule for the restore, even a passwordless one. + Reinstalling Insomnia changes none of these. +- The stock ancestry of `/private/var/db` (root's, no group or other + write, no allowing ACL) is what macOS installs; a Mac where it differs + refuses install and Start. Neither the receipt, its lock, perl's + F_FULLFSYNC nor any of this was run on a Mac as root in testing: every + check above ran against fakes and files in a temporary folder. +- These rules cost availability, and none of that cost is waived: Start + is refused under any sudo, sudo.conf, PAM or Defaults setting the checks + do not accept, without `/usr/bin/perl`, while the user or root holds + the receipt's lock, while a claim nothing gives back is out, and + over the 45-byte receipt of earlier builds of this change, which + install.sh refuses rather than convert. A start can stay unsettled, with + Starts refused and its sleep undo held, for as long as its receipt stays + locked or its checks cannot be made. + +Neither of the two changes that come to mind closes all of these gaps, +and each costs something this design does not pay. A pmset that compares +before it sets would close the window between the root command's second +read and its `disablesleep 1`, but not a 1 another tool writes over +Insomnia's during the session: the value is still 1 at the session's +end, so a compare there still sets it to 0. A sleep assertion owned by a +process (what `caffeinate` takes) has an owner, but macOS honours one +against system sleep only on AC power, closing the lid still sleeps the +Mac, which SleepDisabled prevents, and it ends when its process exits or +crashes, so a session would end with that process rather than at its end +time. Either +would change what a session keeps awake and for how long. This design +has neither, and makes no choice between them. + +The installer runs no pmset: right after writing the rule it confirms that `sudo -k -n -l` lists the +three commands without a password, which catches a rule sudo does not read +but is not proof that the restore runs, because the listing passes once any +of the user's rules is passwordless. At every Start the root command's +`sudo -k -n -ll` reads the rule that decides the restore. A file that +cannot be deleted (its command is still running, an immutable flag, an ACL) +does not stop sleep from being turned back on, but the journal keeps the sleep +entry, the app and the agent report it and retry, and new sessions are refused +until it is gone. The rule stays in place after a failed install (the README says what +was installed). The installer never writes a passwordless `disablesleep 1` +line, on any path, including failed upgrades. It asks for the password before +it quits a running Insomnia, so a cancelled password changes nothing, and it +quits the app before it writes the rule, stopping with nothing changed if the +app will not quit. It writes the rule under the recovery lock, after the +app has quit. If it stops after the rule is written but before the app is replaced, an +older build left installed cannot start a session until the installer is +rerun; it fails closed and the installer prints the rerun command. The +uninstaller removes the file. + +The app is not sandboxed; local logs can contain SSIDs, process metadata, and +tmux target names. The lines the app writes to `insomnia.log` also reach the unified log with the body marked private, so programs reading `log show` see `` instead of those names unless private data logging is enabled on the Mac. The files Insomnia creates (logs, journal, session, config, recovery lock) are mode 0600 @@ -58,7 +760,7 @@ edited by any program running as that user. With ad-hoc signatures these checks are integrity against accidents and against the app relaying a tampered bundle; they are not a boundary against a process running as the same user, which can edit the plist, load its own agent, replace and relaunch -the app, and invoke the four pmset commands directly. +the app, and invoke the three pmset commands of the sudoers rule directly. Release zips on both channels, stable releases (`v` tags) and nightly prereleases (`nightly-*` tags built from `main`), are built by the diff --git a/Sources/Insomnia/Core/AccessListsCommand.swift b/Sources/Insomnia/Core/AccessListsCommand.swift new file mode 100644 index 00000000..7586a943 --- /dev/null +++ b/Sources/Insomnia/Core/AccessListsCommand.swift @@ -0,0 +1,120 @@ +import Darwin +import Foundation + +/// `Insomnia --access-lists ...` +/// +/// One-shot mode for backstop.sh and uninstall.sh: each path's own +/// extended access control list, read the way the app reads the receipt's +/// (`SleepOffReceipts.accessList`), every entry, right and flag through +/// acl(3). The two scripts used to read `ls -le`, which never prints +/// synchronize, prints delete_child and most inheritance flags only for a +/// folder, skips an entry whose rights or flags it cannot read, and stops +/// at an entry it cannot step past; no reading of its text recovers what +/// it leaves out. Both scripts run as the user, so this mode runs as the +/// user too and needs nothing the user does not already have. install.sh +/// and the root command (AdministratorPrompt) still read `ls -le`: the +/// root command must not run a binary the user can replace. +/// +/// `` is how long this process may live, as for +/// `ResumeFrozenCommand` (1 to 300), armed before any list is read. Each +/// path must be absolute. Nothing is read from standard input. Prints one +/// line per path, in order: +/// +/// none no entry at all +/// installed exactly the one entry install.sh adds to this +/// user's receipt (`AccessEntry.installed` for the +/// uid this process runs as) +/// denies entries, none of which allows anything +/// allows an entry that allows something, and not `installed` +/// unreadable lstat(2) or acl_get_link_np(3) failed with errno n +/// incomplete an entry, or one of its flags, could not be read +/// +/// Exits 0 when every list was read whole (the first four words), 1 when +/// any was not. A missing or malformed ``, no path, or a path that +/// is not absolute prints the single line `usage` (details on stderr), +/// reads nothing, and exits 64. A caller takes any other answer, or none, +/// as lists it does not know. +/// +/// The app bundle declares this interface as `InsomniaAccessListsVersion` +/// (`version`) in its Info.plist. The scripts run the binary only when the +/// bundle declares the version they speak, so they never start an older +/// build, which would open the menu bar app instead. +enum AccessListsCommand { + static let flag = "--access-lists" + /// `InsomniaAccessListsVersion` in Resources/Info.plist, and + /// ACCESS_LISTS_VERSION in backstop.sh and uninstall.sh. + static let version = 1 + + typealias Output = ResumeFrozenCommand.Output + + enum Word: Equatable, Sendable { + case none, installed, denies, allows, incomplete + case unreadable(Int32) + + var text: String { + switch self { + case .none: "none" + case .installed: "installed" + case .denies: "denies" + case .allows: "allows" + case .incomplete: "incomplete" + case let .unreadable(err): "unreadable \(err)" + } + } + + /// The list was read whole. + var whole: Bool { + switch self { + case .none, .installed, .denies, .allows: true + case .incomplete, .unreadable: false + } + } + } + + /// nil when `arguments` (the command line without the executable) do + /// not ask for this mode. `endAfter` gets the lifetime once the + /// arguments are valid, before any list is read (`endProcess` in the + /// binary). `read` gives a path's list (`native` in the binary). + static func run( + _ arguments: [String], + read: (String) -> AccessList = native, + user: uid_t = getuid(), + endAfter: (UInt32) -> Void + ) -> Output? { + guard arguments.first == flag else { return nil } + guard arguments.count >= 3, let seconds = ResumeFrozenCommand.lifetime(arguments[1]) else { return usage() } + let paths = arguments.dropFirst(2) + guard paths.allSatisfy({ $0.hasPrefix("/") }) else { return usage() } + endAfter(seconds) + let words = paths.map { word(read($0), user: user) } + return Output(lines: words.map(\.text), status: words.allSatisfy(\.whole) ? 0 : 1) + } + + private static func usage() -> Output { + FileHandle.standardError.write(Data("usage: Insomnia \(flag) ...\n".utf8)) + return Output(lines: ["usage"], status: ResumeFrozenCommand.usageStatus) + } + + /// `path`'s own list, never a link's target's. acl_get_link_np(3) + /// answers ENOENT both for a path with no list and for no path at all, + /// so lstat(2) tells the two apart first. + static func native(_ path: String) -> AccessList { + var info = stat() + guard lstat(path, &info) == 0 else { return .unreadable(errno) } + return SleepOffReceipts.accessList(path) + } + + /// The word for `list`, for `user`'s receipt. + static func word(_ list: AccessList, user: uid_t) -> Word { + switch list { + case let .unreadable(err): + return .unreadable(err) + case .incomplete: + return .incomplete + case let .entries(entries): + if entries.isEmpty { return .none } + if SleepOffReceipts.receiptAccessProblem(entries, user: user) == nil { return .installed } + return entries.contains(where: \.allows) ? .allows : .denies + } + } +} diff --git a/Sources/Insomnia/Core/BackstopVersion.swift b/Sources/Insomnia/Core/BackstopVersion.swift new file mode 100644 index 00000000..afa9fa0a --- /dev/null +++ b/Sources/Insomnia/Core/BackstopVersion.swift @@ -0,0 +1,57 @@ +import Foundation + +/// The recovery contract the agent's backstop.sh implements, read from +/// its `# insomnia-backstop-version: N` line. +/// +/// Start reads it before the password dialog, under the recovery lock. +/// Version 2 is the first backstop.sh that deletes the pending-start marker +/// under its lock before it restores sleep. An older one restores sleep +/// after a crash but leaves the marker, so a dialog still open could turn +/// sleep off again with nothing journaled. Version 3 is the first that +/// settles a start the journal still records (`sleepOffAttempt`) from its +/// receipt. Version 4 is the first that reads the receipt under its lock, +/// as the 82-byte line with the predecessor nonce, and the start's +/// `expires`, and gives the start's claim back (SleepOffReceipts). An older +/// one would misread that receipt, or leave the entry, so the session of a +/// start that never finished would stay on disk and no later Start could +/// run until the app settled it. Version 5 is the first that publishes a +/// settlement's decision in the journal (`sleepOffAttempt.settled`) before +/// it gives the claim back, and finishes a settled record it finds. An +/// older one would give the claim back first, so a crash between the two +/// would leave a record later starts' receipt lines could be misread +/// against, and it would refuse a settled record as malformed and restore +/// nothing else. With any of them in place no dialog is +/// shown at all. The script read is the copy sealed in the bundle the +/// agent runs (LaunchdBackstop.scriptPath), so this refuses a bundle whose +/// copy is missing, unreadable or older than this build expects. +enum BackstopVersion { + static let required = 5 + static let linePrefix = "# insomnia-backstop-version: " + + /// The number on the script's first version line; nil when there is + /// no such line or it does not hold a number. + static func declared(in text: String) -> Int? { + guard let line = text.split(separator: "\n").first(where: { $0.hasPrefix(linePrefix) }) else { return nil } + return Int(line.dropFirst(linePrefix.count).trimmingCharacters(in: .whitespaces)) + } + + /// Throws, with the reason and the fix, unless the script at `url` + /// declares at least `required`. + static func check(scriptAt url: URL) throws { + let text: String + do { + text = try String(contentsOf: url, encoding: .utf8) + } catch { + throw BackstopError(message: "could not read backstop.sh at \(url.path) (\(error.localizedDescription)); run scripts/install.sh again") + } + guard let version = declared(in: text), version >= required else { + throw BackstopError(message: "the installed backstop.sh at \(url.path) is older than this build and cannot cancel a password dialog left open by a crash; run scripts/install.sh again") + } + } +} + +extension LaunchdBackstop { + func checkVoidsPrompts() throws { + try BackstopVersion.check(scriptAt: URL(fileURLWithPath: scriptPath)) + } +} diff --git a/Sources/Insomnia/Core/LaunchdBackstop.swift b/Sources/Insomnia/Core/LaunchdBackstop.swift index b3dcd605..8ced762b 100644 --- a/Sources/Insomnia/Core/LaunchdBackstop.swift +++ b/Sources/Insomnia/Core/LaunchdBackstop.swift @@ -16,10 +16,26 @@ import Foundation /// that code and still passes the agent's check. A bundle edited or /// re-signed under the running app makes arm() fail, with the reason, rather /// than report an agent that refuses every run or pin the replacement. +/// +/// Every Insomnia folder of the user loads its agent under the one label, +/// so launchd holds one such job, from whichever folder loaded it last. +/// Every load and unload of the label happens under one lock, the standard +/// folder's recovery lock (`agentLock`): install.sh holds it as fd 9 and +/// uninstall.sh as fd 6 around each launchctl call, and arm() holds it from +/// the reading before a reload until the plist is published. So an +/// uninstall of one folder that has just read whose job is loaded unloads +/// that job, never one another folder loaded since. The lock order is the +/// folder's own recovery lock, then this one, then the receipt +/// (SleepOffReceipts); arm() runs inside a transaction and never holds the +/// receipt. protocol BackstopScheduling: Sendable { /// Make sure the polling agent is loaded with the current plist. Cheap /// when it already is; throws when it cannot be loaded. func arm() async throws + /// Throws unless the backstop.sh the agent runs deletes the + /// pending-start marker under its lock (see BackstopVersion). Start + /// checks this before it shows the password dialog. + func checkVoidsPrompts() throws } struct BackstopError: LocalizedError { @@ -39,7 +55,11 @@ struct BackstopTarget: Sendable, Equatable { } struct LaunchdBackstop: BackstopScheduling { - typealias Runner = @Sendable (_ exe: String, _ args: [String]) async throws -> ShellResult + /// Runs one launchctl call. `holding`, when there is one, is the agent + /// lock, handed to the child (`CancellableCommand.run(holding:)`) so a + /// bootout or bootstrap keeps it until it exits, even if this process + /// does not. + typealias Runner = @Sendable (_ exe: String, _ args: [String], _ holding: RecoveryLockHandle?) async throws -> ShellResult /// Returns the requirement to pin for a bundle that satisfies it right /// now, or throws with the reason it must not be pinned. typealias BundlePinner = @Sendable (_ bundle: URL) throws -> String @@ -48,6 +68,8 @@ struct LaunchdBackstop: BackstopScheduling { /// Seconds between backstop.sh runs while loaded. install.sh writes the same value. static let pollInterval = 60 static let commandTimeout: TimeInterval = 15 + /// How long arm() waits for the agent lock before it fails. + static let agentLockTimeout: TimeInterval = 10 /// What launchd runs: `/bin/sh -c sh `. /// The program verifies the bundle ($2) against the requirement ($1) with @@ -63,8 +85,15 @@ struct LaunchdBackstop: BackstopScheduling { let plistURL: URL let bundle: URL + /// This folder's journal folder (`Paths.appSupport`), where builds before + /// the sealed bundle kept the backstop.sh their plists ran. + let journalFolder: URL let label: String let uid: uid_t + /// The lock every load and unload of `label` takes (see + /// BackstopScheduling): `Paths.standard.recoveryLock` for the app. + let agentLock: URL + let agentLockTimeout: TimeInterval private let pin: BundlePinner private let run: Runner @@ -74,17 +103,23 @@ struct LaunchdBackstop: BackstopScheduling { /// installed app's sealed script. `pin` runs at every arm(), so an /// upgrade is pinned the first time the upgraded app arms; the default /// is CodeRequirement.pin (the running code's own requirement, and the - /// agent's check on the bundle). + /// agent's check on the bundle). `agentLock` has no default, so no + /// test can reach the standard folder's lock by leaving it out. init( paths: Paths, + agentLock: URL, + agentLockTimeout: TimeInterval = LaunchdBackstop.agentLockTimeout, bundle: URL? = nil, pin: @escaping BundlePinner = { try CodeRequirement.pin(bundle: $0) }, label: String = Paths.backstopLabel, uid: uid_t = getuid(), - run: @escaping Runner = { try await CancellableCommand().run($0, $1, timeout: LaunchdBackstop.commandTimeout) } + run: @escaping Runner = { try await CancellableCommand().run($0, $1, timeout: LaunchdBackstop.commandTimeout, holding: $2) } ) { self.plistURL = paths.backstopPlist self.bundle = bundle ?? Self.runningOrInstalledBundle(paths: paths) + self.journalFolder = paths.appSupport + self.agentLock = agentLock + self.agentLockTimeout = agentLockTimeout self.pin = pin self.label = label self.uid = uid @@ -112,16 +147,17 @@ struct LaunchdBackstop: BackstopScheduling { throw BackstopError(message: "the recovery agent cannot pin \(bundle.path): \(error.localizedDescription). Reinstall with scripts/install.sh") } let desired = Self.plistDictionary(label: label, target: BackstopTarget(bundle: bundle, requirement: requirement)) - // Armed when the plist the next login loads is this build's and the - // loaded job is the one it describes: the same command line, started - // every `pollInterval` seconds. A loaded label alone may be another - // build's job, pinning a bundle or requirement this one does not - // satisfy (install.sh can leave one loaded when it stops between its - // bootstrap and publishing the plist), or a job loaded from a plist - // without the interval, which never runs again to end a session. - if plistOnDiskMatches(desired), try await loadedJob() == LoadedJob(plist: desired) { - return - } + // Without the agent lock: reading changes nothing, and a job this + // folder loaded stays loaded until a holder of the lock unloads it, + // which only this folder's uninstall does (it holds this folder's + // recovery lock, so not during this transaction) or install.sh, + // which replaces it with the standard folder's. + if try await isArmed(desired) { return } + let held = try await takeAgentLock() + defer { held.release() } + // Again under the lock: another folder may have loaded its job, or + // unloaded this one, since the reading above. + if try await isArmed(desired) { return } // The plist at `plistURL` is what the next arm() trusts when the // loaded job is the one it describes, so it may only ever hold a // plist launchd actually loaded. Load through a private @@ -131,10 +167,96 @@ struct LaunchdBackstop: BackstopScheduling { // exactly as it was, whether or not any cleanup below works. let candidate = try writeCandidate(desired) defer { discard(candidate) } - try await reload(from: candidate) + try await reload(from: candidate, holding: held.handle) try publish(candidate) } + /// Armed when the plist the next login loads is this build's and the + /// loaded job is the one it describes, loaded from this folder: the same + /// command line, started every `pollInterval` seconds, from this + /// folder's plist or one of its candidates. A loaded label alone may be + /// another build's job, pinning a bundle or requirement this one does + /// not satisfy (install.sh can leave one loaded when it stops between + /// its bootstrap and publishing the plist), a job loaded from a plist + /// without the interval, which never runs again to end a session, or + /// another folder's job running the same command line, which that + /// folder's uninstall unloads. + private func isArmed(_ desired: [String: Any]) async throws -> Bool { + guard plistOnDiskMatches(desired) else { return false } + let r = try await run(Self.launchctl, ["print", "gui/\(uid)/\(label)"], nil) + guard r.succeeded, Self.loadedJob(fromPrint: r.stdout) == LoadedJob(plist: desired), + let path = Self.loadedPath(fromPrint: r.stdout) + else { return false } + return isOwnAgentFile(path) + } + + /// The agent lock for one reload, released by `release()` when arm() + /// took it itself. + private struct AgentLockHold { + let handle: RecoveryLockHandle + let taken: Bool + func release() { if taken { handle.release() } } + } + + /// The transaction's own lock when it already is the agent lock's file + /// (the standard folder, or an agent lock path that is a link to it; + /// the transaction's own lock is never a link, which RecoveryLock + /// refuses): a second flock of one file in this process would wait on + /// the first. + /// Otherwise the agent lock, within `agentLockTimeout`, made with its + /// folder when missing as uninstall.sh's lock_standard makes it, and + /// checked to still be the file its path names, which is what + /// install.sh and uninstall.sh open. + private func takeAgentLock() async throws -> AgentLockHold { + let path = agentLock.path + if let held = RecoveryLock.held, let file = held.file, FileIdentity(atPath: path) == file { + return AgentLockHold(handle: held, taken: false) + } + let handle: RecoveryLockHandle + do { + if let problem = try OwnerOnly.createDirectory(agentLock.deletingLastPathComponent()) { OwnerOnly.reportOnce(problem) } + handle = try await RecoveryLock(url: agentLock).acquire(timeout: agentLockTimeout) + } catch { + throw BackstopError(message: "the recovery agent was not reloaded: \(error.localizedDescription); install.sh, uninstall.sh and the standard Insomnia folder's app and backstop take that lock") + } + guard let file = handle.file, FileIdentity(atPath: path) == file else { + handle.release() + throw BackstopError(message: "the recovery agent was not reloaded: \(path) was replaced while it was locked") + } + return AgentLockHold(handle: handle, taken: true) + } + + /// Whether `path`, the file launchd says the loaded job came from, is + /// this folder's: its plist, or a candidate in its staging folder or, + /// for older builds, beside the plist. A candidate is renamed over the + /// plist after the load, so the file may be gone. The folder it was in + /// is this folder's LaunchAgents folder by path, or else when it has the + /// same name and both it and the folder above it are this folder's by + /// device and inode: a link to the whole folder is still this folder, + /// while a LaunchAgents folder that only leads here from another folder + /// is that folder's, with its own journal and lock. uninstall.sh's + /// own_agent_file reads the path the same way. + func isOwnAgentFile(_ path: String) -> Bool { + guard let slash = path.lastIndex(of: "/") else { return false } + let name = path[path.index(after: slash)...] + var dir = String(path[.. String { p.lastIndex(of: "/").map { String(p[..<$0]) } ?? "" } + func last(_ p: String) -> Substring { p.lastIndex(of: "/").map { p[p.index(after: $0)...] } ?? Substring(p) } + guard last(dir) == last(mine), + let a = FileIdentity(atPath: mine), FileIdentity(atPath: dir) == a, + let b = FileIdentity(atPath: parent(mine)), FileIdentity(atPath: parent(dir)) == b + else { return false } + return true + } + // MARK: Plist /// Pure builder, testable without launchd. Must produce exactly what @@ -210,11 +332,88 @@ struct LaunchdBackstop: BackstopScheduling { // report an error at every login. for dir in [stagingDirectory, plistURL.deletingLastPathComponent()] { for name in (try? fm.contentsOfDirectory(atPath: dir.path)) ?? [] where name.hasPrefix(candidatePrefix) { - try? fm.removeItem(at: dir.appendingPathComponent(name)) + let path = dir.appendingPathComponent(name).path + if isOwnLeftover(path) { + try? fm.removeItem(atPath: path) + } else { + Log.info("kept backstop candidate \(path): it may be another Insomnia folder's") + } } } } + /// Whether the leftover candidate at `path` is this folder's to remove. + /// Every Insomnia folder of this user names its candidates the same way, + /// so a LaunchAgents folder that two folders share (one reaches it + /// through a link) holds either one's, and a name is not enough. It is + /// this folder's only when this folder's LaunchAgents folder is within + /// this folder (the folder above it, after any link, is the folder it is + /// named under, by device and inode: a link to the whole folder counts, + /// a LaunchAgents folder leading into another folder does not), and the + /// file is a regular file, not a link, that this process's user owns, + /// of at most `leftoverLimit` bytes, whose ProgramArguments run this folder's + /// bundle (or, for a build before the sealed bundle, this folder's + /// backstop.sh): the same path, or the same name in a folder that is + /// this one's by device and inode. Another account sharing the + /// LaunchAgents folder runs the same bundle but owns its own files. + /// uninstall.sh's agents_dir_home and own_plist read the folder and the + /// plist the same way. Two folders of one user that run one bundle + /// write the same bytes; those cannot be told apart, and count as this + /// folder's. + func isOwnLeftover(_ path: String) -> Bool { + let agents = plistURL.deletingLastPathComponent() + guard let up = FileIdentity(atPath: agents.deletingLastPathComponent().path), + FileIdentity(atPath: agents.path + "/..") == up, + let data = Self.readSmallRegularFile(path, limit: Self.leftoverLimit, owner: getuid()), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let args = plist["ProgramArguments"] as? [String] + else { return false } + let runs: String + let want: String + if args.count == 6, args[0] == "/bin/sh" { + runs = args[5] + want = bundle.path + } else if args.count == 2, args[0] == "/bin/bash" { + runs = args[1] + want = journalFolder.appendingPathComponent("backstop.sh").path + } else { + return false + } + if runs == want { return true } + let r = URL(fileURLWithPath: runs), w = URL(fileURLWithPath: want) + guard runs.hasPrefix("/"), r.lastPathComponent == w.lastPathComponent, + let mine = FileIdentity(atPath: w.deletingLastPathComponent().path) + else { return false } + return FileIdentity(atPath: r.deletingLastPathComponent().path) == mine + } + + /// A candidate is about 1.5 KB; anything past this is not one. + static let leftoverLimit = 64 * 1024 + + /// The bytes of the regular file at `path`, opened without following a + /// link and without blocking (a FIFO with no writer would block a plain + /// open), when `owner` owns it and it holds at most `limit` bytes; nil + /// otherwise, or when it cannot be opened or read. + static func readSmallRegularFile(_ path: String, limit: Int, owner: uid_t) -> Data? { + let fd = open(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard fd >= 0 else { return nil } + defer { close(fd) } + var info = stat() + guard fstat(fd, &info) == 0, info.st_mode & S_IFMT == S_IFREG, info.st_uid == owner, info.st_size <= limit else { return nil } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 16 * 1024) + while true { + let n = read(fd, &buffer, buffer.count) + if n < 0 { + if errno == EINTR { continue } + return nil + } + if n == 0 { return data } + data.append(buffer, count: n) + if data.count > limit { return nil } + } + } + // MARK: launchctl /// What `launchctl print` shows of a loaded job that makes it this @@ -237,11 +436,15 @@ struct LaunchdBackstop: BackstopScheduling { } } - /// The loaded job, from `launchctl print`: nil when no job with the - /// label is loaded or the output cannot be read. - func loadedJob() async throws -> LoadedJob? { - let r = try await run(Self.launchctl, ["print", "gui/\(uid)/\(label)"]) - return r.succeeded ? Self.loadedJob(fromPrint: r.stdout) : nil + /// The file the job was loaded from, from `launchctl print`: the one + /// top-level `path =` line (one tab in, like every top-level key). nil + /// when there is none, more than one, or it is not an absolute path, + /// as uninstall.sh's loaded_agent reads it. + static func loadedPath(fromPrint output: String) -> String? { + let prefix = "\tpath = " + let found = output.split(separator: "\n", omittingEmptySubsequences: false).filter { $0.hasPrefix(prefix) } + guard found.count == 1, let path = found.first?.dropFirst(prefix.count), path.hasPrefix("/") else { return nil } + return String(path) } /// Reads `launchctl print ` output, whose top-level keys are @@ -285,10 +488,15 @@ struct LaunchdBackstop: BackstopScheduling { /// /// Throws when the agent cannot be loaded: a session must never hold /// sleep without an agent that will release it. - private func reload(from candidate: URL) async throws { + /// + /// Both calls get the agent lock: the runner returns only once the + /// child has exited and been reaped (SIGTERM, then SIGKILL, at its + /// limit), and the child's own descriptor keeps the lock if this + /// process ends first. + private func reload(from candidate: URL, holding lock: RecoveryLockHandle) async throws { let domain = "gui/\(uid)" - _ = try await run(Self.launchctl, ["bootout", "\(domain)/\(label)"]) - let r = try await run(Self.launchctl, ["bootstrap", domain, candidate.path]) + _ = try await run(Self.launchctl, ["bootout", "\(domain)/\(label)"], lock) + let r = try await run(Self.launchctl, ["bootstrap", domain, candidate.path], lock) if !r.succeeded { throw BackstopError(message: "launchctl bootstrap failed (\(r.status)): \(r.stderr.trimmingCharacters(in: .whitespacesAndNewlines))") } diff --git a/Sources/Insomnia/Core/RecoveryLock.swift b/Sources/Insomnia/Core/RecoveryLock.swift index 2daec938..3b4a6536 100644 --- a/Sources/Insomnia/Core/RecoveryLock.swift +++ b/Sources/Insomnia/Core/RecoveryLock.swift @@ -18,6 +18,7 @@ import Foundation enum RecoveryLockError: Error, LocalizedError, Equatable { case busy(path: String, seconds: TimeInterval) case open(path: String, errno: Int32) + case notRegularFile(path: String) case lock(path: String, errno: Int32) var errorDescription: String? { @@ -26,6 +27,8 @@ enum RecoveryLockError: Error, LocalizedError, Equatable { return "recovery lock \(path) still held by another process after \(Int(seconds)) s (backstop running?)" case let .open(path, errno): return "could not open recovery lock \(path): \(String(cString: strerror(errno)))" + case let .notRegularFile(path): + return "recovery lock \(path) is not a regular file, so it is not locked" case let .lock(path, errno): return "could not lock \(path): \(String(cString: strerror(errno)))" } @@ -63,6 +66,11 @@ final class RecoveryLockHandle: @unchecked Sendable { } } + /// The locked file, by device and inode; nil once released. + var file: FileIdentity? { + mutex.withLock { fd >= 0 ? FileIdentity(of: fd) : nil } + } + deinit { release() } } @@ -81,10 +89,19 @@ struct RecoveryLock: Sendable { func tryAcquire() throws -> RecoveryLockHandle? { // O_CLOEXEC: no child inherits the lock by accident. One that must // keep it is given its own descriptor (`descriptorForChild()`). + // O_NOFOLLOW and O_NONBLOCK: a symbolic link at the path fails the + // open (ELOOP) rather than leading somewhere else, and a FIFO or + // device there cannot hold the open; only a regular file is kept. + // Nothing that fails is unlinked or replaced. // Owner-only like every other file here; an older 0644 lock is // tightened in place, never replaced (same inode for both sides). - let fd = open(path, O_RDWR | O_CREAT | O_CLOEXEC, OwnerOnly.fileMode) + let fd = open(path, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK, OwnerOnly.fileMode) guard fd >= 0 else { throw RecoveryLockError.open(path: path, errno: errno) } + var info = stat() + guard fstat(fd, &info) == 0, info.st_mode & S_IFMT == S_IFREG else { + close(fd) + throw RecoveryLockError.notRegularFile(path: path) + } if let problem = OwnerOnly.tighten(fd: fd, path: path) { OwnerOnly.reportOnce(problem) } if flock(fd, LOCK_EX | LOCK_NB) == 0 { return RecoveryLockHandle(fd: fd) } let err = errno diff --git a/Sources/Insomnia/Core/SessionManager.swift b/Sources/Insomnia/Core/SessionManager.swift index 76b52a21..d8f7b5e3 100644 --- a/Sources/Insomnia/Core/SessionManager.swift +++ b/Sources/Insomnia/Core/SessionManager.swift @@ -12,13 +12,19 @@ enum EndReason: String, Sendable { case thermalCritical case backstop /// Reconcile found a session on disk but could not arm the recovery - /// agent, journal, or hold sleep for it, so it ended the session instead - /// of holding sleep with nothing to release it. + /// agent, read the sleep setting, or journal for it, so it ended the + /// session instead of holding sleep with nothing to release it. case recoveryUnavailable - /// `pmset disablesleep 1` failed or timed out during start. The setting - /// may still have been applied, so the start is undone from the journal - /// like an end rather than rolled back from memory. + /// The administrator password dialog that turns sleep off was cancelled, + /// answered wrongly, timed out, or pmset failed behind it, during start. + /// The setting may still have been applied, so the start is undone from + /// the journal like an end rather than rolled back from memory. case startFailed + /// Reconcile found a valid session on disk but `pmset -g` no longer + /// reports SleepDisabled: something turned sleep back on while Insomnia + /// was not running. Turning it off again needs a password, which a + /// relaunch must never ask for, so the session ends instead. + case sleepReenabled } /// What `end` achieved. Callers that are about to quit need to know whether @@ -129,6 +135,35 @@ final class SessionManager { /// visible beside it. Cleared by a session start, or by /// `recheckForeignSleep()` once the bit reads 0 again. private(set) var foreignSleepWarning: String? + /// Why this transaction could not remove the pending-start marker, or + /// nil once it is gone. While it is set, a dialog from an earlier start + /// could still turn sleep off, so `restoreAll` keeps the sleep entry + /// journaled, starts are refused, and the menu keeps the line. + @ObservationIgnored private(set) var markerProblem: String? + /// Why the start the journal still records (`sleepOffAttempt`) is not + /// settled, or nil when no start is recorded. Set by every settlement; + /// a new start is refused with it. + @ObservationIgnored private var attemptProblem: String? + /// Why the sleep undo of that start is held back, or nil when it runs. + /// Set by every settlement: a command for that start may still act + /// (undecided), or the receipt shows its command never turned sleep + /// off and no earlier restore is owed, but the journal could not record + /// that. `restoreAll` then runs no pmset for sleep, so a SleepDisabled + /// 1 another tool set stays and a pmset still running for that start is + /// not raced. Never set for a settled record. + @ObservationIgnored private var attemptHold: String? + /// The nonce of the start the journal still records when this process + /// rolled it back as one whose command never turned sleep off + /// (`abandonStart`: no dialog was shown, the command's status said it + /// stopped before its write, or the receipt showed it) but could not + /// journal that. Every later settlement of that start in this process + /// takes it as the verdict, whatever the receipt shows: a command whose + /// second read found another tool's 1 and that could not write + /// `refused` over its record stops with 6 and leaves its `writing`, + /// which on its own would undo the start and set that 1 to 0. Only this + /// process saw the status: a relaunch, backstop.sh and uninstall.sh + /// settle from the receipt alone. + @ObservationIgnored private var refusedNonce: String? /// The sudo pmset left running (`unfinishedCommand`), what it holds up /// and the pid to stop it by hand. Kept apart from `lastError` so the /// command's exit removes it without touching a newer failure: the task @@ -368,6 +403,9 @@ final class SessionManager { private let sleepGuard: any SleepGuarding private let processControl: any ProcessSignaling private let backstop: any BackstopScheduling + /// The root-owned record of what the command behind a start's password + /// dialog did, read to settle a start the journal still records. + private let receipts: SleepOffReceipts private let audio: any AudioControlling private let display: any DisplayDimming private let keyboard: any KeyboardBacklighting @@ -381,6 +419,14 @@ final class SessionManager { private let recoveryLock: RecoveryLock private let recoveryLockTimeout: TimeInterval private let recoveryRetryDelay: TimeInterval + /// How long a transaction waits for the root command behind a password + /// dialog to let go of the pending-start marker before it gives up on + /// removing it for this run. pmset itself takes well under a second. + private let markerLockTimeout: TimeInterval + /// How long a start or a settlement waits for the receipt's lock + /// (`SleepOffReceipts.lock`), which the root command behind a dialog + /// holds from before its checks until pmset exits. + private let receiptLockTimeout: TimeInterval /// How long after a display/keyboard restore the same values are /// written once more. powerd re-applies its own remembered brightness /// asynchronously after the wake and can override the first write. @@ -517,6 +563,11 @@ final class SessionManager { /// is refused and the end is retried. Cleared by the next reconcile, and /// by a start, whose own session.json replaces it. @ObservationIgnored private var keptSessionFile: KeptSessionFile? + /// Set when a session.json beside a pending-start marker that no + /// journaled start accounts for could not be removed + /// (dropSessionOfUnrecordedMarker): reconcile ends that session rather + /// than resume it. + @ObservationIgnored private var unrecordedMarkerSession = false /// What the one-time lid-close update changed at init, posted by the /// launch reconcile (`announceLidCloseUpdate`). Settings shows the same /// change (`Config.lidCloseDefaultsNotice`) for a user who has @@ -535,6 +586,7 @@ final class SessionManager { sleepGuard: any SleepGuarding, processControl: any ProcessSignaling, backstop: any BackstopScheduling, + receipts: SleepOffReceipts, audio: any AudioControlling = NoopAudioControl(), display: any DisplayDimming = NoopDisplayDimmer(), keyboard: any KeyboardBacklighting = NoopKeyboardBacklight(), @@ -545,17 +597,21 @@ final class SessionManager { processLookup: @escaping @Sendable (Int32) -> ProcessLookup = { SignalProcessControl.processTableState(pid: $0) }, recoveryLockTimeout: TimeInterval = 10, recoveryRetryDelay: TimeInterval = 30, + markerLockTimeout: TimeInterval = 10, + receiptLockTimeout: TimeInterval = 10, reassertDelay: Duration = .seconds(2), keptRecheckDelay: Duration = .seconds(3), keptRecheckAttempts: Int = 20, keptRecheckSlowDelay: Duration = .seconds(60), - bootSession: String = SignalProcessControl.bootSession + bootSession: String = SignalProcessControl.bootSession, + sessionReadCheck: (@Sendable () throws -> Void)? = nil ) { self.paths = paths - self.store = Store(paths: paths) + self.store = Store(paths: paths, sessionReadCheck: sessionReadCheck) self.sleepGuard = sleepGuard self.processControl = processControl self.backstop = backstop + self.receipts = receipts self.audio = audio self.display = display self.keyboard = keyboard @@ -567,6 +623,8 @@ final class SessionManager { self.recoveryLock = RecoveryLock(url: paths.recoveryLock) self.recoveryLockTimeout = recoveryLockTimeout self.recoveryRetryDelay = recoveryRetryDelay + self.markerLockTimeout = markerLockTimeout + self.receiptLockTimeout = receiptLockTimeout self.reassertDelay = reassertDelay self.keptRecheckDelay = keptRecheckDelay self.keptRecheckAttempts = keptRecheckAttempts @@ -637,7 +695,8 @@ final class SessionManager { paths: paths, sleepGuard: PmsetSleepGuard(), processControl: processControl, - backstop: LaunchdBackstop(paths: paths), + backstop: LaunchdBackstop(paths: paths, agentLock: Paths.standard.recoveryLock), + receipts: .live, audio: audio, display: display, keyboard: keyboard, @@ -730,11 +789,29 @@ final class SessionManager { } catch { Log.error("could not remove \(self.paths.unfinishedCommandFile.path): \(error.localizedDescription)") } + // Holding the lock means no start is waiting on a password + // dialog, so a marker left on disk belongs to an abandoned one. + // One that cannot be removed does not stop the transaction: + // restores still run, and `markerProblem` holds back only the + // clearing of the sleep entry and any new start. + let clearing = await self.clearPendingStart() do { try self.loadJournal() } catch { return .failure(.journalUnreadable(self.refuseForUnreadableJournal(what, error))) } + // A start the journal still records is settled from the + // receipt, whatever happened to its marker: deleting the marker + // does not stop a command for that start, the receipt's lock + // and the start's expiry do. + if self.state.sleepOffAttempt != nil { + await self.settleSleepOffAttempt() + } else { + self.attemptProblem = nil + self.attemptHold = nil + self.refusedNonce = nil + if case .cleared(file: _?) = clearing { self.dropSessionOfUnrecordedMarker() } + } self.writeOwedEdits() self.settleDisplayAfterUnreadOff() let before = self.unfinishedCommand @@ -865,6 +942,19 @@ final class SessionManager { } switch undo { case .sleepRestored: + if let problem = markerProblem { + // As in `restoreAll`: a dialog behind the marker could still + // turn sleep off, so the entry stays. + Log.error("sleep restored (\(command.description) exited 0), but its journal entry is kept") + fail(Self.markerProblemText(problem)) + return + } + if state.unsettledSleepOffAttempt != nil { + // As in `restoreAll`: only a settlement removes a start the + // journal still records, and the entry stays with it. + Log.error("sleep restored (\(command.description) exited 0), but its journal entry stays with the start the journal still records") + return + } guard clearUndone("sleep restored (\(command.description) exited 0)", clear) else { return } Log.info("sleep restored: \(command.description) exited 0") case .lowPowerOff: @@ -1160,12 +1250,16 @@ final class SessionManager { /// Start a session of `duration` seconds (clamped to `config.maxDuration`). /// Ignored if a session is already active; use `extend`. /// - /// Ordering: session.json, then state.json, then the recovery agent, - /// then `pmset disablesleep 1`. A failure before pmset is rolled back: - /// nothing has touched the machine. A pmset failure is ambiguous (the - /// setting may have been applied before the error or timeout), so it is - /// undone from the journal like an end, and the journal keeps the entry - /// until that undo is confirmed. + /// Ordering: a check that the installed backstop.sh can void the + /// dialog (BackstopVersion), then session.json, then state.json, then + /// the recovery agent, then the pending-start marker, then the password + /// dialog that runs `pmset disablesleep 1`. A failure before the dialog is rolled back: + /// nothing has touched the machine. So is a cancelled dialog or an + /// osascript that never started, which ran nothing as root: no pmset + /// runs, so a sleep setting someone else owns is left alone. Any other + /// failure is ambiguous (the setting may have been applied before the + /// error or timeout), so it is undone from the journal like an end, and + /// the journal keeps the entry until that undo is confirmed. func start(duration: TimeInterval) async { guard !quitRequested else { Log.info("start ignored: quit requested") @@ -1191,8 +1285,66 @@ final class SessionManager { Log.info("start abandoned: an end was requested first") return } + if let problem = markerProblem { + // The dialog behind that marker may still turn sleep off, or + // its command may still be running. Nothing is written. + // The menu keeps the marker's own line, which goes with it. + let text = Self.markerProblemText(problem) + Log.error("start refused, nothing changed") + fail(text) + notifier.post(title: Self.endTitle(.startFailed, had: false), body: "Nothing was changed: \(text)") + return + } + if let recorded = state.sleepOffAttempt { + // An earlier start the journal still records is not settled: + // its dialog can still be answered, its receipt could not be + // read, or the journal could not be updated with what the + // receipt shows. Or it is settled, but its claim on the + // receipt was not given back yet. A new start would replace + // that record. + let text = Self.recordedStartText(recorded, attemptProblem) + fail("start refused, nothing changed: \(text)") + notifier.post(title: Self.endTitle(.startFailed, had: false), body: "Nothing was changed: \(text).") + return + } + do { + // Only a backstop.sh that deletes the marker can void the + // dialog below if this process dies under it. + try backstop.checkVoidsPrompts() + } catch { + fail("start refused, nothing changed: \(error.localizedDescription)") + notifier.post(title: Self.endTitle(.startFailed, had: false), body: "Nothing was changed: \(error.localizedDescription).") + return + } + do { + // The record the root command keeps of what it did. Without a + // safe one, nothing could show later that a failed or abandoned + // start never turned sleep off. Claimed below, under its lock. + _ = try receipts.identity() + } catch { + let text = "the receipt \(receipts.file) that shows whether a start turned sleep off is missing or unsafe (\(error.localizedDescription)). Run scripts/install.sh again" + fail("start refused, nothing changed: \(text)") + notifier.post(title: Self.endTitle(.startFailed, had: false), body: "Nothing was changed: \(text).") + return + } + do { + // A SleepDisabled 1 the journal does not own is someone else's + // setting, and this session's end would turn it back on, so + // Start leaves it alone, as a cancelled dialog does. Whether + // the end can turn sleep back on without a password is asked of + // sudo later, by the root command before it writes anything: + // this app's end and backstop.sh both run `sudo -n`, and + // without the rule they would fail at the deadline and leave + // sleep off. + try await sleepGuard.checkSleepSettingForStart(sleepOffIsOurs: state.sleepDisabledByUs) + } catch { + fail("start refused, nothing changed: \(error.localizedDescription)") + notifier.post(title: Self.endTitle(.startFailed, had: false), body: "Nothing was changed: \(error.localizedDescription).") + return + } let now = clock() - let new = SessionMath.newSession(now: now, duration: duration, maxDuration: config.maxDuration) + var new = SessionMath.newSession(now: now, duration: duration, maxDuration: config.maxDuration) + new.id = UUID().uuidString // What was on disk before this attempt, read under the lock. A // rollback puts exactly this back: an entry an earlier failed restore // left behind is evidence, not something this start may clear. @@ -1209,13 +1361,76 @@ final class SessionManager { return } + // The claim on the receipt, under its lock: the receipt's nonce now + // is this start's predecessor, and the release file shows this + // start's nonce held until it is settled, so no start from another + // Insomnia folder can replace the line its settlement reads. The + // journal records the attempt before the claim is written, so a + // crash between the two leaves a record whose settlement finds no + // claim to give back. + let held: SleepOffReceipts.Guard + do { + held = try await receipts.lock(timeout: receiptLockTimeout) + } catch { + let text = "the receipt \(receipts.file) could not be locked to claim it (\(error.localizedDescription))" + fail("start refused, nothing changed: \(text)") + notifier.post(title: Self.endTitle(.startFailed, had: false), body: "Nothing was changed: \(text).") + return + } + let predecessor: String + do { + predecessor = try receipts.claimable(under: held) + } catch { + held.release() + let text = "the receipt \(receipts.file) cannot be claimed: \(error.localizedDescription)" + fail("start refused, nothing changed: \(text)") + notifier.post(title: Self.endTitle(.startFailed, had: false), body: "Nothing was changed: \(text).") + return + } + + // The command the dialog runs as root turns sleep off only while the + // pending-start marker holds this attempt's nonce (see below), the + // receipt still holds `predecessor`, and before `expires`, set when + // the marker is written. It reads `pmset -g` again as root, since + // another tool may turn sleep off while the password is typed, + // unless the journal owned a 1 before this start, for which Start's + // own read was skipped too. + var pending = PendingStart(marker: store.paths.pendingStartFile, nonce: UUID().uuidString, deadline: new.endsAt, sleepOffIsOurs: journalBefore.sleepDisabledByUs, predecessor: predecessor, receipt: held.identity) + var attempt = SleepOffAttempt(nonce: pending.nonce, owedBefore: journalBefore.sleepDisabledByUs, receipt: held.identity, predecessor: predecessor, deadline: pending.deadlineSeconds, expires: pending.expiresSeconds, marker: nil, session: new.id) do { + try journal { + $0.sleepDisabledByUs = true + $0.sleepOffAttempt = attempt + } + } catch { + held.release() + fail("could not write session: \(error.localizedDescription)") + rollBackStart(journal: journalBefore, session: sessionBefore) + return + } + do { + try receipts.writeRelease(.held(attempt.nonce)) + } catch { + // The claim may be half written: put back what it showed. A + // claim that landed all the same is given back by the + // settlement, and the record stays until then, so it is never + // left with no record to account for it. No dialog was shown. + try? receipts.writeRelease(.free(predecessor)) + held.release() + fail("could not claim the receipt: \(error.localizedDescription)") + await abandonStart(attempt, journal: journalBefore, session: sessionBefore) + return + } + held.release() + do { + // A crash from here leaves a record whose settlement finds no + // dialog, gives the claim back, puts the entry back and removes + // this session.json. try store.saveSession(new) keptSessionFile = nil - try journal { $0.sleepDisabledByUs = true } } catch { fail("could not write session: \(error.localizedDescription)") - rollBackStart(journal: journalBefore, session: sessionBefore) + await abandonStart(attempt, journal: journalBefore, session: sessionBefore) return } @@ -1224,30 +1439,151 @@ final class SessionManager { do { try await backstop.arm() } catch { - rollBackStart(journal: journalBefore, session: sessionBefore) fail("could not arm backstop: \(error.localizedDescription)") + await abandonStart(attempt, journal: journalBefore, session: sessionBefore) return } guard endTicket == ticket else { - rollBackStart(journal: journalBefore, session: sessionBefore) Log.info("start abandoned before disabling sleep: end requested meanwhile") + await abandonStart(attempt, journal: journalBefore, session: sessionBefore) return } + // The pending-start marker: the command the dialog runs as root + // turns sleep off only while this file holds this attempt's nonce, + // and keeps it locked until pmset exits. It is deleted below on + // every outcome, before this transaction lets go of the lock, and + // by whoever takes the lock next if this process dies first, so a + // dialog answered after its start was abandoned changes nothing. + // Its identity goes in the journal before the dialog: a marker + // that is not this file when it goes may have been replaced while + // a command for this start held it, and then the receipt is not + // trusted. + // `expires` bounds how long any command for this start can still + // write: the dialog's own limit and grace from now, or the + // session's end if sooner. Journaled with the marker, so every + // settlement knows when a receipt that still holds the predecessor + // proves something. + pending.expires = min(new.endsAt, clock().addingTimeInterval(AdministratorPrompt.answerWindow)) + attempt.expires = pending.expiresSeconds + let markerFile: FileIdentity do { - try await sleepGuard.setSleepDisabled(true) - } catch let still as CommandStillRunningError { - // Whether the setting was applied is unknown and no second pmset - // may run beside this one. session.json and the journal stay as - // written (the backstop honours that deadline if Insomnia dies); - // the start is undone like an end once the command has exited. - stopTransaction(for: still, thenEnd: .startFailed) + markerFile = try store.savePendingStart(pending.nonce) + } catch { + // No marker with this nonce exists, so no dialog was shown. + fail("could not write the pending-start marker: \(error.localizedDescription)") + await clearPendingStart() + await abandonStart(attempt, journal: journalBefore, session: sessionBefore) + return + } + attempt.marker = markerFile.text + do { + try journal { $0.sleepOffAttempt = attempt } + } catch { + // No dialog was shown. + fail("could not journal the pending-start marker: \(error.localizedDescription)") + attempt.marker = nil + await clearPendingStart() + await abandonStart(attempt, journal: journalBefore, session: sessionBefore) + return + } + + // The administrator password dialog. This is the only call that can + // prompt, and Start is the only path that reaches it: the user just + // pressed Enter, so someone is at the keyboard. A cancel, a wrong + // password, a root command that refused, a timeout or a pmset + // failure all land here. + // The marker goes on every outcome below before anything is + // restored, and a marker that cannot go keeps the sleep entry + // journaled (see restoreAll). + do { + try await sleepGuard.disableSleep(pending) + } catch let AdministratorPromptError.stillRunning(prompt, grace) { + // The prompt's process did not stop on SIGTERM. Nothing is + // killed. The marker goes first, under its lock, so an answer + // that still comes cannot turn sleep off. Only the file this + // start wrote counts: one put in its place may have been + // swapped in after the command locked the original. + let cleared = await clearPendingStart(expecting: markerFile) + let alive = prompt.osascriptAlive + if cleared != .kept { + // The command behind the dialog now finds no marker and + // stops at that check, and any command for this start + // refuses once `expires` has passed, so nothing here waits + // for the process: the start is settled from the receipt, + // under its lock, once `expires` has passed (a few seconds + // from now), and the recovery lock goes with this + // transaction. The leftover process is reported, and + // watched on its own until it exits. + reportStuckPrompt(prompt, grace: grace, osascriptAlive: alive, voided: true) + watchVoidedPrompt(prompt, grace: grace) + await endFailedStart(attempt, dialogOver: false, journal: journalBefore, session: sessionBefore, error: AdministratorPromptError.stillRunning(prompt, grace: grace).localizedDescription) + return + } + // A root command already past its check holds the marker's + // lock and may still run pmset (or the marker cannot be + // deleted): nothing is rolled back beside it. session.json, the + // journal entry and the recovery lock stay until the prompt + // exits, the same rule a stuck `sudo pmset` gets. The user is + // told what is running and, while it is osascript itself, how + // to stop it. Then the rollback. + reportStuckPrompt(prompt, grace: grace, osascriptAlive: alive, voided: false) + if alive { + // The pid is ours to name only until osascript exits; a + // root command it started can keep its output open longer. + await prompt.waitUntilOsascriptExits() + if prompt.isRunning { + fail("start: \(Self.stuckPromptText(prompt, grace: grace, osascriptAlive: false, voided: false))") + } + } + await prompt.waitUntilExit() + // The command that held the marker's lock has exited with the + // prompt, so the marker can go before the receipt is read. + let text = "\(prompt) did not finish in time and has now exited; rolling the start back" + await clearPendingStart() + fail("could not disable sleep: \(text)") + await endFailedStart(attempt, dialogOver: false, journal: journalBefore, session: sessionBefore, error: text) + return + } catch let error as AdministratorPromptError where error.nothingToUndo { + // Cancelled, osascript never started, or the root command + // refused: the start was over or expired, the session had + // ended, sudo did not confirm a passwordless restore, `pmset -g` + // showed a 1 this start did not set, or the receipt was locked, + // unsafe or held another start's line. Every refusal comes + // before the sleep write, so nothing is left for an undo to + // reverse. The claim is given back, the journal and + // session.json go back exactly as they were and no pmset runs, + // so a sleep setting another tool made, even while the dialog + // was up, is left alone. Nothing can use this attempt's marker + // any more, so one that cannot be deleted does not hold the + // rollback back; it is reported, and the next transaction tries + // it again. + await clearPendingStart() + fail("could not disable sleep: \(error.localizedDescription)") + if await abandonStart(attempt, journal: journalBefore, session: sessionBefore) { + notifier.post(title: Self.endTitle(.startFailed, had: false), body: "No session was started, and Insomnia undid anything it changed: \(error.localizedDescription).") + } return } catch { + // A wrong password, a signal, a timeout, a pmset failure: the + // status cannot say whether root's write came first, but the + // receipt can, under its lock. osascript exited by itself only + // for `.failed`: then its dialog is over, and no command for + // it can start any more. + await clearPendingStart() fail("could not disable sleep: \(error.localizedDescription)") - _ = await performEnd(reason: .startFailed) + let over = (error as? AdministratorPromptError)?.dialogOver ?? false + await endFailedStart(attempt, dialogOver: over, journal: journalBefore, session: sessionBefore, error: error.localizedDescription) return } + await clearPendingStart() + // Sleep is off and journaled as ours; the record of the attempt has + // nothing left to show. Under the receipt's lock the record is + // marked settled first, then the claim goes back and the record + // goes. Left unsettled, the record is settled by the next + // transaction, which keeps the sleep entry: the receipt shows this + // start's `writing`. Left settled, the next transaction finishes it. + await finishAttempt(attempt, resuming: new) guard endTicket == ticket else { // Sleep is disabled and journaled as ours. The end that was // requested runs next and restores from that journal; the session @@ -1257,7 +1593,7 @@ final class SessionManager { } session = new - lastError = nil + clearLastError() countdownPaused = clamshell() == true foreignSleepWarning = nil Log.info("session started until \(iso(new.endsAt)) (\(Int(duration))s requested)") @@ -1296,7 +1632,7 @@ final class SessionManager { return } session = updated - lastError = nil + clearLastError() Log.info("session extended by \(Int(extra))s until \(iso(updated.endsAt))") await armDeadline(updated.endsAt) } @@ -1706,17 +2042,43 @@ final class SessionManager { /// entry is left set so the next end, reconcile or the backstop /// retries it. /// + /// The sleep entry is cleared only when this transaction removed the + /// pending-start marker before the restore. Otherwise a dialog left on + /// screen could still turn sleep off after this point, or its root + /// command may still be running: sleep is restored all the same, but + /// the entry stays as the record that the bit may be Insomnia's, so the + /// end reports itself incomplete and every later run retries. Nor is + /// it cleared while the journal still records a start that is not + /// settled (`sleepOffAttempt`): only that start's settlement decides + /// it, and the entry goes with the restore after that. For such a + /// start no sleep pmset runs at all while `attemptHold` says why: a + /// command for it may still act, or its receipt shows its command + /// never turned sleep off and no earlier restore is owed. A settled + /// record already decided the entry, and changes nothing here. + /// /// A `sudo pmset` that does not stop on SIGTERM ends the restore right /// there, as stop_transaction does in backstop.sh: no later undo runs /// beside the live command, and the journal stays as it was. That /// command is returned; the caller (`performEnd`) reports the end as /// stopped and it is retried once the command has exited. func restoreAll() async -> UnfinishedCommand? { - if state.sleepDisabledByUs { + if state.sleepDisabledByUs, state.unsettledSleepOffAttempt != nil, let hold = attemptHold { + Log.info("sleep entry kept, and sleep left as it is: \(hold)") + } else if state.sleepDisabledByUs { do { - try await sleepGuard.setSleepDisabled(false) - clearUndone("sleep restored") { $0.sleepDisabledByUs = false } + try await sleepGuard.enableSleep() Log.info("sleep restored") + if let problem = markerProblem { + Log.error("sleep restored, but its journal entry is kept") + fail(Self.markerProblemText(problem)) + } else if state.unsettledSleepOffAttempt != nil { + // A command for that start may have turned sleep off, + // or the journal could not record its settlement. A + // settled record decided the entry already. + Log.error("sleep restored, but its journal entry stays with the start the journal still records") + } else { + clearUndone("sleep restored") { $0.sleepDisabledByUs = false } + } } catch let still as CommandStillRunningError { stopTransaction(for: still, thenEnd: nil, undoes: .sleepRestored) return still.command @@ -2610,10 +2972,49 @@ final class SessionManager { onDisk = nil } + // A start whose settlement is published, but whose claim could not + // be given back or record removed since (`settleSleepOffAttempt` + // tried again above). The record names the session it leaves to be + // resumed (`SleepOffAttempt.resumes`): that start's own when it + // turned sleep off, the one from before it that its rollback put + // back, or the one its settlement left in place. That session, and + // only that one, is resumed below like any other, under the same + // checks (not expired, sleep still off); the record names it before + // the claim goes back, so a crash at any point leaves either the + // record unsettled with its claim or the name. A record without the + // name (an older build's, or one the scripts settled after + // removing the start's own session) resumes the session whose first + // end is the start's deadline, with the sleep entry still journaled + // (`isSession`). The record stays, Starts are refused until it goes, + // and every transaction and agent run tries again; the decision is + // never read against the receipt again. + let settledResumes = onDisk.map { s in + state.sleepOffAttempt.map { Self.isResumed(s, by: $0, sleepEntry: state.sleepDisabledByUs) } ?? false + } ?? false + if let s = onDisk, !s.isExpired(at: now), (state.sleepOffAttempt != nil && !settledResumes) || unrecordedMarkerSession { + // A start the journal still records could not be settled: its + // dialog can still be answered, its receipt stayed locked or + // could not be read, or a removal or the journal write failed. + // Or its settlement is published, the session is not the one + // it leaves to be resumed, and its claim could not be given back + // or its record removed: the safe side, and only after a crash + // with that left over. + // Or the session.json beside a marker no journaled start + // accounts for could not be removed. Whether that start turned + // sleep off is not settled, so a SleepDisabled 1 now may be + // someone else's, and the session it wrote is never resumed on + // it. It ends like any session; the restore runs unless the + // settlement holds it back (`attemptHold`), and the entry stays + // with the record until a settlement removes it (restoreAll). + unrecordedMarkerSession = false + Log.info("reconcile: session.json until \(iso(s.endsAt)) belongs to a start that is not settled; ending it instead of resuming it") + _ = await performEnd(reason: .startFailed) + return + } if let s = onDisk, !s.isExpired(at: now) { - // Step 2: valid session. Arm first, then journal, then hold - // sleep. Any failure ends the session rather than holding sleep - // with nothing guaranteed to release it. + // Step 2: valid session. Arm first, then check that sleep is + // still off, then journal. Any failure ends the session rather + // than holding sleep with nothing guaranteed to release it. session = s do { try await backstop.arm() @@ -2622,6 +3023,24 @@ final class SessionManager { _ = await performEnd(reason: .recoveryUnavailable) return } + // Never re-applied: `disablesleep 1` needs the administrator + // password, and a relaunch (login, a crash) has nobody at the + // keyboard to type it. The session continues only if sleep is + // still off; if it was turned back on meanwhile the session ends + // and says so. + let sleepStillOff: Bool + do { + sleepStillOff = try await sleepGuard.isSleepDisabled() + } catch { + fail("could not read the sleep setting for the session on disk: \(error.localizedDescription); ending it") + _ = await performEnd(reason: .recoveryUnavailable) + return + } + guard sleepStillOff else { + Log.info("reconcile: sleep was turned back on while Insomnia was not running; ending the session") + _ = await performEnd(reason: .sleepReenabled) + return + } if !state.sleepDisabledByUs { do { try journal { $0.sleepDisabledByUs = true } @@ -2631,21 +3050,11 @@ final class SessionManager { return } } - do { - try await sleepGuard.setSleepDisabled(true) - lastError = nil - } catch let still as CommandStillRunningError { - // Not surfaced: the session file and journal stay for the end - // that runs once the command has exited. - session = nil - stopTransaction(for: still, thenEnd: .recoveryUnavailable) - return - } catch { - fail("could not re-apply sleep guard: \(error.localizedDescription); ending session") - _ = await performEnd(reason: .recoveryUnavailable) - return - } + clearLastError() Log.info("reconcile: session valid until \(iso(s.endsAt))") + if settledResumes { + Log.info("reconcile: this session's start is still recorded (\(attemptProblem ?? "it is settled")); starts are refused until a later run gives its claim back and removes the record") + } if !state.lowPowerSetByUs, state.displayRestoredUnderLowPower != nil { dropDisplayWrite(reason: "the mode is not ours") } @@ -2821,9 +3230,9 @@ final class SessionManager { /// entry: something other than Insomnia disabled sleep, and only its /// owner should re-enable it. Shown on its own menu line on every /// reconcile that finds it, and posted as a notification once per - /// launch. The line is cleared by the next session start, whose end - /// sets `disablesleep 0` whoever set the bit, or by a recheck that - /// reads the bit as 0. + /// launch. Start is refused while the bit reads 1 (see + /// `checkSleepSettingForStart`), so the line is cleared by a recheck + /// that reads it as 0, or by a session start that found it 0. private func reportForeignSleepDisable() { foreignSleepWarning = Self.foreignSleepLine guard !announcedForeignSleep else { @@ -2834,7 +3243,7 @@ final class SessionManager { Log.info("reconcile: \(Self.foreignSleepLine)") notifier.post( title: Self.foreignSleepTitle, - body: "pmset reports SleepDisabled 1, but Insomnia has no session and did not set it, so it is left alone. To re-enable sleep: \(Self.foreignSleepCommand). Ending an Insomnia session also sets it to 0." + body: "pmset reports SleepDisabled 1, but Insomnia has no session and did not set it, so it is left alone. To re-enable sleep: \(Self.foreignSleepCommand). Insomnia cannot start a session until then." ) } @@ -2903,17 +3312,27 @@ final class SessionManager { } catch { fail("could not restore the journal after a failed start: \(error.localizedDescription)") } + restoreSessionFile(previous) + } + + /// Puts session.json back as it was before a start: `previous`, or no + /// file. + private func restoreSessionFile(_ previous: Session?) { do { - if let previous { - try store.saveSession(previous) - } else { - try store.deleteSession() - } + try putBackSessionFile(previous) } catch { fail("could not restore session.json after a failed start: \(error.localizedDescription)") } } + private func putBackSessionFile(_ previous: Session?) throws { + if let previous { + try store.saveSession(previous) + } else { + try store.deleteSession() + } + } + /// In-process timers only; the launchd agent is armed by callers before /// this runs. private func armDeadline(_ endsAt: Date) async { @@ -2988,6 +3407,644 @@ final class SessionManager { Log.error(message) } + /// Posted once per stuck prompt: the notification and the menu's + /// warning line say what is running and what Insomnia does about it. + /// `kill ` is only offered while the pid is osascript's own, and + /// only on the menu line: that line is replaced as soon as osascript + /// exits, while a notification stays in Notification Center after the + /// pid is gone and possibly reused. + private func reportStuckPrompt(_ prompt: UnfinishedPrompt, grace: TimeInterval, osascriptAlive: Bool, voided: Bool) { + let body = Self.stuckPromptText(prompt, grace: grace, osascriptAlive: osascriptAlive, voided: voided) + if osascriptAlive { + fail("start: \(body) To stop it by hand while it runs: kill \(prompt.pid)") + notifier.post(title: Self.promptStuckTitle, body: "\(body) The Insomnia menu shows how to stop it while it runs.") + } else { + fail("start: \(body)") + notifier.post(title: Self.promptStuckTitle, body: body) + } + } + + private static func stuckPromptText(_ prompt: UnfinishedPrompt, grace: TimeInterval, osascriptAlive: Bool, voided: Bool) -> String { + let what = osascriptAlive + ? "osascript (pid \(prompt.pid)), the process behind the password dialog, did not stop within \(Int(grace)) s." + : "osascript (pid \(prompt.pid)) stopped, but a command it started as root is still running." + if voided { + return "\(what) Its start's marker is gone, so a command it starts from now on stops at that check, and any command for that start refuses once the start's answer window has ended. Insomnia then settles the start from the receipt once it can lock it; while a command for the start holds that lock, the start stays unsettled, Starts are refused and sleep is left as it is." + } + return "\(what) Insomnia keeps the session record and waits for it before rolling the start back; nothing else runs until then." + } + + /// Outside any transaction, keeps the menu line `reportStuckPrompt` + /// set for a voided prompt true until the prompt is gone: the kill hint + /// goes when osascript exits, the whole line when the prompt has + /// exited. A line something else has set since is left alone. + private func watchVoidedPrompt(_ prompt: UnfinishedPrompt, grace: TimeInterval) { + let reported = lastError + Task { @MainActor [weak self] in + var line = reported + await prompt.waitUntilOsascriptExits() + if let self, line != nil, self.lastError == line, prompt.isRunning { + line = "start: \(Self.stuckPromptText(prompt, grace: grace, osascriptAlive: false, voided: true))" + self.lastError = line + } + await prompt.waitUntilExit() + if let self, line != nil, self.lastError == line { self.lastError = nil } + Log.info("\(prompt), whose start was voided, has exited") + } + } + + /// What clearing the pending-start marker found. + enum MarkerClearing: Equatable { + /// The marker is still there (see clearPendingStart). + case kept + /// The marker is gone. `file` is the file that was locked and + /// deleted; nil when there was nothing, or a link to nothing. + case cleared(file: FileIdentity?) + } + + /// Locks, then deletes the pending-start marker (see PendingStart and + /// `Store.removePendingStart`), and records the outcome in + /// `markerProblem`. `.kept` when the root command behind a dialog still + /// held the marker's lock after `markerLockTimeout`, or the file could + /// not be deleted (an immutable flag, a deny-delete ACL, a directory in + /// its place), or, with `expecting`, the file at the path is not the + /// one this start wrote. The failure is logged and shown in the menu, + /// and every later transaction, backstop run and uninstall tries again. + @discardableResult + private func clearPendingStart(expecting written: FileIdentity? = nil) async -> MarkerClearing { + let removed: RemovedMarker? + do { + removed = try await store.removePendingStart(timeout: markerLockTimeout, expecting: written) + if removed != nil { + Log.info("deleted the pending-start marker; a command a password dialog left from that start runs stops at its marker check from now on, unless this user writes the marker again or the clock is set back") + } + if let old = markerProblem { + markerProblem = nil + if lastError == Self.markerProblemText(old) { lastError = nil } + } + } catch { + markerProblem = error.localizedDescription + fail(Self.markerProblemText(error.localizedDescription)) + return .kept + } + return .cleared(file: removed?.file) + } + + /// Whole seconds since 1970, rounded down, as the journal and + /// session.json hold times. For this Mac's clock only: times read from + /// disk are compared as Doubles (`isSession`), so nothing there can + /// trap the conversion. + private static func seconds(_ date: Date) -> Int { Int(date.timeIntervalSince1970.rounded(.down)) } + + /// Whether `s` is the session `attempt`'s start wrote: it began at + /// least `SessionMath.minimumDuration` before the start's deadline, and + /// its first end, before any extension (`SessionMath.firstEnd`), is + /// that deadline. The deadline is the first end rounded down to the + /// second, as session.json keeps every time, while it keeps each + /// extension to a fraction of a second. With whole-second extensions + /// the first end comes out exactly. An extension with a fraction (one + /// cut short at the maximum) leaves an end with a fraction, which each + /// save drops from the end but not from the extensions, and a relaunch + /// extends from the end without it. So each such extension can put + /// the first end that comes out up to a second earlier, and the + /// fraction the start's own end had, up to a second later: with `n` of + /// them it is after `deadline - n` and before `deadline + 1`. Outside + /// that, or with a history no session this app wrote has, the session + /// is not the start's own, and reconcile ends it as before. Everything + /// here compares Doubles, so no journal or session.json value can trap + /// a conversion or overflow a subtraction. + /// A session whose id (`Session.id`) is not the one the start + /// journaled (`SleepOffAttempt.session`) is not the start's own, + /// whatever its times; with no id on either side the times decide. + static func isSession(_ s: Session, of attempt: SleepOffAttempt) -> Bool { + if s.sameID(as: attempt.session) == false { return false } + let started = s.startedAt.timeIntervalSince1970.rounded(.down) + guard started + SessionMath.minimumDuration <= Double(attempt.deadline) else { return false } + return hasFirstEnd(s, attempt.deadline) + } + + /// Whether `s`'s first end comes out as `end`, within the window + /// `isSession` describes for extensions with a fraction of a second. + private static func hasFirstEnd(_ s: Session, _ end: Int) -> Bool { + guard let first = SessionMath.firstEnd(of: s) else { return false } + let deadline = Double(end) + let fractions = s.extensions.filter { $0 != $0.rounded(.down) }.count + if fractions == 0 { return first.rounded(.down) == deadline } + // A thousandth of a second for the rounding of the sums above. + let slack = 0.001 + return first > deadline - Double(fractions) - slack && first < deadline + 1 + slack + } + + /// Whether `s` is the session the settled `attempt` leaves to be + /// resumed (`SleepOffAttempt.resumes`): the same `startedAt`, to the + /// second, and the same first end, within `isSession`'s window. That + /// is what session.json keeps of a session, not proof that it is the + /// same one: a session written in that same second with that first end + /// passes too, unless both carry an id (`Session.id`, round 36), which + /// must then be the same. A session or record of an older build has + /// none, and the times alone decide. Without `resumes` (a record + /// settled by an older build or by the scripts), the first end alone + /// decides, as `isSession`, with the sleep entry journaled. `.none` + /// matches no session. + static func isResumed(_ s: Session, by attempt: SleepOffAttempt, sleepEntry: Bool) -> Bool { + guard attempt.isSettled else { return false } + guard let resumes = attempt.resumes else { return sleepEntry && isSession(s, of: attempt) } + guard let started = resumes.startedAt, let end = resumes.firstEnd else { return false } + if s.sameID(as: resumes.id) == false { return false } + return s.startedAt.timeIntervalSince1970.rounded(.down) == Double(started) && hasFirstEnd(s, end) + } + + /// The receipt's lock (`SleepOffReceipts.lock`), or the error it threw. + private func lockReceipt() async -> Result { + do { + return .success(try await receipts.lock(timeout: receiptLockTimeout)) + } catch { + return .failure(error) + } + } + + /// Records why the start the journal still records stays there: + /// `attemptProblem`, which a new start is refused with, and + /// `attemptHold` when no sleep undo may run for it. Undecided holds the + /// undo whatever the time and whatever an earlier session owes: the + /// receipt stays locked or cannot be locked, or the dialog can still + /// be answered, so a command for that start may still act, and a pmset + /// it already started can turn sleep off after the undo. The start's + /// `expires` only stops commands that have not begun. The undo is held + /// too when the receipt shows its command never turned sleep off and no + /// earlier restore is owed: a SleepDisabled 1 now may be another + /// tool's. A start that may have written, or one that never wrote + /// behind a restore an earlier session owes, runs the undo and keeps + /// the entry with the record (`restoreAll`). The restore an earlier + /// session owes stays journaled while it is held, and runs once the + /// start is settled. + private func keepAttempt(_ attempt: SleepOffAttempt, _ verdict: SleepOffVerdict, because why: String) { + attemptProblem = why + switch verdict { + case .neverWrote: + attemptHold = attempt.owedBefore ? nil : "the receipt shows that the command behind that start's dialog never turned sleep off and no earlier restore is owed, but \(why)" + case .mayHaveWritten: + attemptHold = nil + case .undecided: + attemptHold = why + } + } + + /// A settlement published its decision but could not give the claim + /// back or remove the record (`SettlementPublication.pending`). The + /// settled record adds nothing to undo and holds nothing back; it only + /// refuses new starts until a later run finishes it. + private func settlementPending(_ why: String) { + attemptProblem = "it is settled, but \(why)" + attemptHold = nil + } + + /// How far `publishSettlement` got. + private enum SettlementPublication { + /// Published, the claim given back and the record removed. + case done + /// Nothing was written: the record stays unsettled, the claim + /// held, and the receipt's line still shows the same thing next + /// time. The reason. + case notPublished(String) + /// The decision is in the journal (`SleepOffAttempt.settled`), but + /// the claim could not be given back or the settled record could + /// not be removed. The next transaction, backstop.sh or + /// uninstall.sh finishes it without reading the receipt again. The + /// reason. + case pending(String) + } + + /// Under `held`: runs `write`, which journals the decision with + /// `attempt` marked settled and naming the session it leaves to be + /// resumed (`resumes`), then gives the claim back and removes the + /// record (`finishSettlement`). The claim stays held until the + /// decision is on disk, so no start from another Insomnia folder can + /// add receipt lines that would make the receipt show something else + /// before the decision is kept: a crash or a failure at any point + /// leaves either the record unsettled with its claim, or the decision. + private func publishSettlement(of attempt: SleepOffAttempt, resumes: ResumedSession, under held: SleepOffReceipts.Guard, _ write: (SleepOffAttempt) throws -> Void) -> SettlementPublication { + var settled = attempt + settled.settled = true + settled.resumes = resumes + do { + try write(settled) + } catch { + return .notPublished("the journal could not be updated (\(error.localizedDescription))") + } + return finishSettlement(of: settled, under: held) + } + + /// Under `held`: gives a settled record's claim back, when the release + /// file still shows it held, then removes the record. + private func finishSettlement(of attempt: SleepOffAttempt, under held: SleepOffReceipts.Guard) -> SettlementPublication { + do { + try receipts.release(attempt.nonce, under: held) + } catch { + return .pending("its claim on the receipt could not be given back (\(error.localizedDescription))") + } + do { + try journal { $0.sleepOffAttempt = nil } + } catch { + return .pending("its settled record could not be removed from the journal (\(error.localizedDescription))") + } + return .done + } + + /// Why a settlement could not decide what to do with session.json + /// (`sessionFileOfSettlement`). + private struct SessionFileProblem: Error { + let why: String + } + + /// Decides, from one read of session.json, whether it goes and which + /// session the settlement of `attempt` leaves to be resumed + /// (`SleepOffAttempt.resumes`). With a session in memory, that one is + /// named and the file is left alone. Otherwise a session.json that + /// `attempt` wrote (its end is the start's deadline, and its id, when + /// both have one, the start's session id) is of a session that never + /// began: it is deleted before the settlement is published, so it is + /// never resumed because a SleepDisabled 1 someone else set reads as + /// still off, and the settlement names none. Any other is named as it + /// is now; one whose bytes are not a session names none, since nothing + /// in it could be resumed. A read or a deletion that fails throws + /// (`SessionFileProblem`): the settlement is not published, its record + /// stays unsettled with its claim, and the next transaction reads + /// session.json again, after reconcile moved aside one it cannot read. + /// A read that fails is never taken for no session.json, and the file + /// is not read a second time, so a later read that succeeds cannot + /// name the start's own session as the one to resume. + private func sessionFileOfSettlement(_ attempt: SleepOffAttempt) throws -> (removed: Bool, resumes: ResumedSession) { + if let session { return (false, ResumedSession(session)) } + let onDisk: Session? + do { + onDisk = try store.loadSession() + } catch StoreError.unreadable(_, _) { + return (false, .none) + } catch { + throw SessionFileProblem(why: "session.json could not be read to name the session this settlement leaves (\(error.localizedDescription))") + } + guard let s = onDisk, s.endsAt.timeIntervalSince1970.rounded(.down) == Double(attempt.deadline), s.sameID(as: attempt.session) != false else { + return (false, onDisk.map(ResumedSession.init) ?? .none) + } + do { + try store.deleteSession() + } catch { + throw SessionFileProblem(why: "session.json of that start could not be removed (\(error.localizedDescription))") + } + Log.info("removed session.json of a start that never finished") + return (true, .none) + } + + /// The reason a `sessionFileOfSettlement` error gives. + private static func sessionFileWhy(_ error: Error) -> String { + (error as? SessionFileProblem)?.why ?? "session.json could not be read to name the session this settlement leaves (\(error.localizedDescription))" + } + + /// Settles the start the journal still records (`sleepOffAttempt`) + /// from its receipt, under the receipt's lock: this process crashed or + /// was force-quit while the start ran, or an earlier settlement could + /// not finish, and neither backstop.sh nor uninstall.sh has settled it + /// since. Runs at the top of every transaction, whatever happened to + /// the marker. + /// + /// A record already settled is only finished (`finishSettledAttempt`). + /// Undecided (`SleepOffVerdict.undecided`: its dialog can still be + /// answered, or the receipt stays locked or cannot be locked) keeps + /// everything, the record, the entry, session.json and the claim, holds + /// the sleep undo (`keepAttempt`), and the next transaction reads the + /// receipt again. Otherwise that start never finished, so its session + /// never began: one read of session.json decides whether it is that + /// start's, which goes first, and which session the settlement leaves + /// (`sessionFileOfSettlement`). Then the journal records the + /// decision with the record marked settled: the sleep entry from before + /// the start when no command for it turned sleep off, which keeps a + /// restore an earlier session still owes, and the entry kept + /// otherwise, so the restore runs like any other, and the session left + /// is named as the one to resume. Only then + /// does the claim go back and the record go. A read, removal or write + /// that fails before the decision is published keeps the record + /// unsettled with its claim, so the receipt's line still shows the + /// same thing next time: + /// reconcile ends an unexpired session rather than resume it, starts + /// are refused, the menu says what was removed, and the next + /// transaction tries again. + /// + /// A start this process rolled back as one that never turned sleep off + /// but could not journal (`refusedNonce`) is settled that way whatever + /// its receipt shows. + private func settleSleepOffAttempt() async { + attemptProblem = nil + attemptHold = nil + guard let attempt = state.sleepOffAttempt else { return } + if attempt.isSettled { + refusedNonce = nil + await finishSettledAttempt(attempt) + return + } + let received = refusedNonce == attempt.nonce + if !received { refusedNonce = nil } + let outcome = await lockReceipt() + defer { if case let .success(held) = outcome { held.release() } } + let now = Self.seconds(clock()) + var verdict = receipts.verdict(for: attempt, lock: outcome, now: now) + if received { + switch verdict { + case .neverWrote: + break + case let .mayHaveWritten(shown), let .undecided(shown): + Log.info("an earlier start's receipt alone does not settle it as one that never turned sleep off (\(shown)), but this process rolled it back as one whose command never turned sleep off; it is settled that way") + verdict = .neverWrote + } + } + let held: SleepOffReceipts.Guard + switch (verdict, outcome) { + case let (.undecided(why), _): + keepAttempt(attempt, verdict, because: why) + Log.info("an earlier start is not settled yet: \(why)") + return + case let (_, .failure(error)): + let why = "the receipt could not be locked to give that start's claim back (\(error.localizedDescription))" + keepAttempt(attempt, verdict, because: why) + fail("could not settle an earlier start: \(why). Nothing was removed; starts are refused, and every run tries again") + return + case let (_, .success(g)): + held = g + } + let removed: String + let resumes: ResumedSession + do { + let file = try sessionFileOfSettlement(attempt) + removed = file.removed ? "; session.json of that start was removed" : "" + resumes = file.resumes + } catch { + let why = Self.sessionFileWhy(error) + keepAttempt(attempt, verdict, because: why) + fail("could not settle an earlier start: \(why). Nothing was removed; its session is ended rather than resumed, starts are refused, and every run tries again") + return + } + let owes = verdict == .neverWrote ? attempt.owedBefore : true + let published = publishSettlement(of: attempt, resumes: resumes, under: held) { settled in + var s = state + s.sleepOffAttempt = settled + s.sleepDisabledByUs = owes + try persistState(s) + } + if case let .notPublished(why) = published { + keepAttempt(attempt, verdict, because: why + removed) + fail("could not settle an earlier start: \(why)\(removed). The start stays recorded, starts are refused, and every run tries again") + return + } + refusedNonce = nil + switch verdict { + case .neverWrote: + Log.info("settled an earlier start: \(received ? "this process rolled it back as one whose command never turned sleep off" : "its receipt shows the command behind its dialog never turned sleep off"); the sleep entry is back to \(attempt.owedBefore ? "owed by an earlier session" : "clear")") + case let .mayHaveWritten(reason), let .undecided(reason): + Log.info("settled an earlier start as one that may have turned sleep off (\(reason)); the sleep entry stays") + } + if case let .pending(why) = published { + settlementPending(why) + fail("settled an earlier start, but \(why)\(removed). Its record stays in the journal, marked settled, starts are refused, and every run tries again") + } + } + + /// Finishes a settlement whose decision is already in the journal: + /// under the receipt's lock, the claim goes back and the record goes. + /// The receipt is not read again. Once the claim went back, later + /// starts' lines may make it show something else, so only the + /// published decision counts. + private func finishSettledAttempt(_ attempt: SleepOffAttempt) async { + let held: SleepOffReceipts.Guard + switch await lockReceipt() { + case let .success(g): + held = g + case let .failure(error): + let why = "the receipt could not be locked to give its claim back (\(error.localizedDescription))" + settlementPending(why) + if error is SleepOffReceipts.Busy { + Log.info("an earlier start is settled, but \(why)") + } else { + fail("an earlier start is settled, but \(why). Its record stays in the journal, starts are refused, and every run tries again") + } + return + } + defer { held.release() } + if case let .pending(why) = finishSettlement(of: attempt, under: held) { + settlementPending(why) + fail("an earlier start is settled, but \(why). Its record stays in the journal, starts are refused, and every run tries again") + return + } + Log.info("finished an earlier start's settlement: its claim on the receipt was given back and its record removed") + } + + /// After sleep was turned off for `attempt`: under the receipt's lock, + /// marks the record settled, naming `new` as the session to resume, + /// gives the claim back and drops the record, keeping the sleep entry. + /// A lock or write that fails before the record is marked leaves it + /// unsettled with its claim for the next transaction, whose settlement + /// finds this start's `writing` and keeps the entry. One that fails + /// after leaves the settled record for the next transaction to finish. + private func finishAttempt(_ attempt: SleepOffAttempt, resuming new: Session) async { + let held: SleepOffReceipts.Guard + switch await lockReceipt() { + case let .success(g): + held = g + case let .failure(error): + attemptProblem = "the receipt could not be locked to give this start's claim back (\(error.localizedDescription))" + Log.error("could not lock the receipt to give the start's claim back (\(error.localizedDescription)); the next run settles the start and keeps the sleep entry") + return + } + defer { held.release() } + switch publishSettlement(of: attempt, resumes: ResumedSession(new), under: held, { settled in try journal { $0.sleepOffAttempt = settled } }) { + case .done: + break + case let .notPublished(why): + attemptProblem = why + Log.error("could not clear the start's record: \(why); the next run settles it and keeps the sleep entry") + case let .pending(why): + settlementPending(why) + Log.error("the start's record is settled, but \(why); the next run finishes it") + } + } + + /// Rolls back a start whose command cannot have turned sleep off: no + /// dialog was shown, or it ended with nothing to undo + /// (`AdministratorPromptError.nothingToUndo`), or the receipt showed + /// that it never turned sleep off. session.json goes back + /// first, then under the receipt's lock the journal goes back exactly + /// as it was, with the record marked settled and naming `previous` as + /// the session to resume, and only then does the claim go back and the + /// record go. A relaunch with that record left over resumes `previous` + /// as it would without the record, and nothing else. Returns whether + /// the journal and session.json went back. A session.json, lock or + /// journal write that fails keeps the record unsettled with its claim, + /// so the next settlement finishes the rest; no pmset runs for it unless an + /// earlier restore is owed (`attemptHold`), and starts are refused + /// until then. That settlement, in this process, settles it as one + /// that never turned sleep off whatever the receipt shows + /// (`refusedNonce`); one by a relaunch, backstop.sh or uninstall.sh + /// reads the receipt alone. A claim or record removal that fails after + /// that leaves the settled record for the next run to finish. + @discardableResult + private func abandonStart(_ attempt: SleepOffAttempt, journal before: RuntimeState, session previous: Session?) async -> Bool { + attemptProblem = nil + attemptHold = nil + let why: String + do { + try putBackSessionFile(previous) + } catch { + why = "session.json could not be put back as it was (\(error.localizedDescription))" + keepAttempt(attempt, .neverWrote, because: why) + refusedNonce = attempt.nonce + fail("the failed start stays recorded in the journal: \(why). No pmset runs for it\(attempt.owedBefore ? " beyond the restore an earlier session owes" : ""), its session is ended rather than resumed, starts are refused, and every run tries again") + return false + } + switch await lockReceipt() { + case let .success(held): + defer { held.release() } + let published = publishSettlement(of: attempt, resumes: previous.map(ResumedSession.init) ?? .none, under: held) { settled in + var s = before + s.sleepOffAttempt = settled + try persistState(s) + } + switch published { + case .done: + return true + case let .pending(failed): + settlementPending(failed) + fail("the failed start was rolled back, but \(failed). Its record stays in the journal, marked settled, starts are refused, and every run tries again") + return true + case let .notPublished(failed): + why = failed + } + case let .failure(error): + why = "the receipt could not be locked to give this start's claim back (\(error.localizedDescription))" + } + keepAttempt(attempt, .neverWrote, because: why) + refusedNonce = attempt.nonce + fail("the failed start stays recorded in the journal: \(why). session.json was put back as it was. No pmset runs for it\(attempt.owedBefore ? " beyond the restore an earlier session owes" : ""), starts are refused, and every run tries again") + return false + } + + /// A pending-start marker went that no journaled start accounts for: + /// one left by a build from before `sleepOffAttempt`, or by a start + /// that finished or rolled back but could not delete it. Whether a + /// command behind its dialog turned sleep off is unknown, so with no + /// session in memory, session.json beside it is never resumed: it goes. + /// A sleep entry still journaled is restored like any other, and a + /// SleepDisabled 1 the journal does not own is left alone. A session + /// that had started and lost its marker only to a failed delete ends + /// early here, which is the safe side. A removal that fails makes + /// reconcile end that session rather than resume it. + private func dropSessionOfUnrecordedMarker() { + guard session == nil, let s = try? store.loadSession() else { return } + do { + try store.deleteSession() + Log.info("removed session.json until \(iso(s.endsAt)): it was beside a pending-start marker that no journaled start accounts for, so it is never resumed") + } catch { + unrecordedMarkerSession = true + fail("could not remove session.json beside a pending-start marker that no journaled start accounts for: \(error.localizedDescription). Its session is ended rather than resumed") + } + } + + /// Ends a start whose dialog failed in a way its status cannot vouch + /// for (a lost answer, a wrong password, a signal, a timeout, a prompt + /// that would not stop), from its receipt, under the receipt's lock. + /// `dialogOver`: osascript exited by itself, so no command for the + /// start can still begin. Otherwise, when the start's `expires` is at + /// most 15 s away (after a timeout it is about 10 s), this waits for + /// it rather than leave the start unsettled. + /// + /// A receipt that shows no command for the start turned sleep off rolls + /// it back like a refusal (`abandonStart`), and no pmset runs, so a + /// SleepDisabled 1 another tool set while the dialog was up stays. One + /// that may have removes the start's session.json, publishes the + /// settlement, gives the claim back, drops the record and undoes the + /// start like an end. Undecided keeps the record and the claim and + /// ends the start with the sleep undo held (`attemptHold`), however + /// long the receipt stays locked: a command for that start may still + /// be running pmset. The session never begins, starts are refused, + /// and the next transaction, or backstop.sh, which the end arms, + /// settles it and restores sleep then. + private func endFailedStart(_ attempt: SleepOffAttempt, dialogOver: Bool, journal before: RuntimeState, session previous: Session?, error: String) async { + attemptProblem = nil + attemptHold = nil + var outcome = await lockReceipt() + var now = Self.seconds(clock()) + var verdict = receipts.verdict(for: attempt, lock: outcome, now: now, dialogOver: dialogOver) + if case .undecided = verdict, !dialogOver, attempt.expires > now, attempt.expires - now <= 15 { + if case let .success(held) = outcome { held.release() } + Log.info("waiting \(attempt.expires - now) s for the failed start's answer window to end before reading its receipt") + try? await Task.sleep(for: .seconds(attempt.expires - now + 1)) + outcome = await lockReceipt() + now = Self.seconds(clock()) + verdict = receipts.verdict(for: attempt, lock: outcome, now: now, dialogOver: dialogOver) + } + switch verdict { + case .neverWrote: + if case let .success(held) = outcome { held.release() } + if await abandonStart(attempt, journal: before, session: previous) { + notifier.post(title: Self.endTitle(.startFailed, had: false), body: "No session was started, and Insomnia undid anything it changed: \(error). The receipt shows that the command behind the password dialog never turned sleep off.") + } + return + case let .mayHaveWritten(reason): + Log.info("the failed start is undone like an end: \(reason)") + switch outcome { + case let .success(held): + defer { held.release() } + let resumes: ResumedSession + do { + resumes = try sessionFileOfSettlement(attempt).resumes + } catch { + let why = Self.sessionFileWhy(error) + keepAttempt(attempt, verdict, because: why) + Log.error("the failed start stays recorded: \(why); the end below restores sleep and keeps the entry") + break + } + switch publishSettlement(of: attempt, resumes: resumes, under: held, { settled in try journal { $0.sleepOffAttempt = settled } }) { + case .done: + break + case let .notPublished(why): + keepAttempt(attempt, verdict, because: why) + Log.error("the failed start stays recorded: \(why); the end below restores sleep and keeps the entry") + case let .pending(why): + settlementPending(why) + Log.error("the failed start is settled, but \(why); the end below restores sleep, and the next run finishes the settlement") + } + case let .failure(error): + keepAttempt(attempt, verdict, because: "the receipt could not be locked to give the start's claim back (\(error.localizedDescription))") + } + case let .undecided(why): + if case let .success(held) = outcome { held.release() } + keepAttempt(attempt, verdict, because: why) + fail("could not disable sleep: \(error). The start is not settled yet: \(why). It stays recorded, starts are refused until it is settled, and sleep is left as it is until then, since a command for that start may still be running") + } + _ = await performEnd(reason: .startFailed) + } + + /// The menu's error line goes away after a success, except for a + /// pending-start marker that is still in place, which keeps its line + /// until a transaction removes it, and then for a start the journal + /// still records, which keeps its line until a run settles it and + /// removes it. That start's own session can go on beside the record, + /// but every new start is refused until then. + private func clearLastError() { + lastError = markerProblem.map(Self.markerProblemText) + ?? state.sleepOffAttempt.map { Self.recordedStartText($0, attemptProblem) } + } + + /// Why a start the journal still records refuses new starts. + static func recordedStartText(_ recorded: SleepOffAttempt, _ problem: String?) -> String { + let why = problem ?? "it could not be settled" + return recorded.isSettled + ? "an earlier start is still recorded in the journal (\(why)). Starts are refused until a later run gives its claim on the receipt back" + : "an earlier start is still recorded in the journal and is not settled (\(why)). Starts are refused until a later run settles it" + } + + static func markerProblemText(_ problem: String) -> String { + "the pending-start marker could not be removed (\(problem)). A password dialog left from an earlier start could still turn sleep off, so the sleep entry stays in the journal, new starts are refused, and every run retries. If no Insomnia password dialog is open, delete the file by hand" + } + private func warnAboutCommand(_ message: String) { commandWarning = message Log.error(message) @@ -3127,6 +4184,7 @@ final class SessionManager { static let incompleteTitle = "Restore incomplete" static let notEndedTitle = "Session not ended" static let journalTitle = "Recovery journal unreadable" + static let promptStuckTitle = "Password prompt still running" static let commandRunningTitle = "Power command still running" static let sessionFileTitle = "Session file unreadable" static let foreignSleepTitle = "Sleep is disabled by something else" @@ -3183,8 +4241,9 @@ final class SessionManager { case .backstop: outputsWaiting ? "A previous session left changes behind. Sleep is back to normal." : "A previous session left changes behind; everything has been undone." - case .recoveryUnavailable: "Insomnia could not arm its recovery agent for the session found on disk, so it ended the session. Sleep is back to normal." - case .startFailed: "Insomnia could not disable sleep, so no session was started. Sleep is back to normal." + case .recoveryUnavailable: "Insomnia could not confirm its recovery agent or the sleep setting for the session found on disk, so it ended the session. Sleep is back to normal." + case .startFailed: "The administrator password prompt was cancelled or failed, so no session was started. Sleep is back to normal." + case .sleepReenabled: "Sleep was turned back on while Insomnia was not running, so the session ended." } } } diff --git a/Sources/Insomnia/Core/SessionMath.swift b/Sources/Insomnia/Core/SessionMath.swift index 4660b827..4591973a 100644 --- a/Sources/Insomnia/Core/SessionMath.swift +++ b/Sources/Insomnia/Core/SessionMath.swift @@ -34,6 +34,27 @@ enum SessionMath { return s } + /// The times session.json can hold, in seconds since 1970: the dates + /// `Store.parseDate` reads, from 1970-01-01T00:00:00+23:59 to + /// 9999-12-31T23:59:59-23:59. + static let storableTimes: ClosedRange = -86_340 ... 253_402_387_139 + + /// The session's first end, before any extension: `endsAt` less every + /// extension, in seconds since 1970. Nil when an extension or their + /// running total is not finite, or the first end is outside + /// `storableTimes`. A decoder takes any finite number for an + /// extension (1e20, or two of 1e308, whose sum is infinite), and no + /// session this app wrote has such a history. + static func firstEnd(of s: Session) -> TimeInterval? { + var total: TimeInterval = 0 + for extra in s.extensions { + total += extra + guard extra.isFinite, total.isFinite else { return nil } + } + let first = s.endsAt.timeIntervalSince1970 - total + return storableTimes.contains(first) ? first : nil + } + /// Seconds from `now` to `endsAt`, floored at zero. static func remaining(until endsAt: Date, at now: Date) -> TimeInterval { max(endsAt.timeIntervalSince(now), 0) diff --git a/Sources/Insomnia/Core/SleepGuard.swift b/Sources/Insomnia/Core/SleepGuard.swift index a9cedd0a..1b5ba45d 100644 --- a/Sources/Insomnia/Core/SleepGuard.swift +++ b/Sources/Insomnia/Core/SleepGuard.swift @@ -1,9 +1,32 @@ import Foundation -/// The only two things Insomnia ever does as root, both through the four -/// sudoers-allowed pmset commands written by install.sh. +/// The only things Insomnia ever does as root. Turning sleep off goes +/// through the administrator password dialog (see AdministratorPrompt); +/// everything else goes through the three passwordless pmset lines +/// install.sh writes to sudoers, none of which can keep the Mac awake. protocol SleepGuarding: Sendable { - func setSleepDisabled(_ disabled: Bool) async throws + /// Start calls it before anything is written or the dialog is shown. + /// It only reads `pmset -g` and runs nothing as root. `sleepOffIsOurs` + /// is the journal's `sleepDisabledByUs`: when it is set, a SleepDisabled + /// 1 is Insomnia's own and nothing is read. Otherwise a 1 means someone + /// else turned sleep off, and a session's end would turn it back on for + /// them, so Start is refused; so it is when the setting cannot be read. + /// Throws `SleepSettingRefusal`. Behind the dialog, before it writes + /// anything, the root command asks sudo whether sleep can be turned + /// back on without a password and reads the setting again (see + /// `AdministratorPrompt.rootCommand`). + func checkSleepSettingForStart(sleepOffIsOurs: Bool) async throws + /// Shows the administrator password dialog and waits for it; only an + /// explicit Start by the user may call it, after writing `start`'s + /// marker. Sleep is turned off only while that marker holds its nonce. + /// The wait is bounded: an `AdministratorPromptError.stillRunning` means + /// the dialog's process would not stop and may still turn sleep off, so + /// the caller must keep its lock and journal entry until the handle it + /// carries resolves. + func disableSleep(_ start: PendingStart) async throws + /// Turns sleep back on. Never prompts, so a crashed or stuck session can + /// always be ended. + func enableSleep() async throws func isSleepDisabled() async throws -> Bool func setLowPowerMode(_ on: Bool) async throws /// Battery Low Power Mode as pmset reports it now. Throws when it cannot @@ -27,11 +50,36 @@ struct SleepGuardError: Error, LocalizedError, Sendable { } } -/// `sudo -n pmset …`. Never prompts; if the sudoers rule is missing the call -/// fails fast with a readable error instead of hanging on a password prompt. +/// Why `checkSleepSettingForStart` refused Start. +enum SleepSettingRefusal: Error, LocalizedError, Sendable { + /// `pmset -g` already reports SleepDisabled 1 and the journal does not + /// say Insomnia set it. + case sleepAlreadyOff + /// `pmset -g` could not be read, so a setting someone else made could + /// not be ruled out. + case sleepSettingUnreadable(String) + + var errorDescription: String? { + switch self { + case .sleepAlreadyOff: + "sleep is already off (pmset reports SleepDisabled 1) and Insomnia did not turn it off, so Start leaves it alone: the session's end would turn it back on. To re-enable sleep: sudo pmset -a disablesleep 0, then start again" + case let .sleepSettingUnreadable(detail): + "`pmset -g` could not be read (\(detail)), so it is not known whether someone else turned sleep off" + } + } +} + +/// `sudo -n pmset …` for everything but `disablesleep 1`. `sudo -n` never +/// prompts; if the sudoers rule is missing the call fails fast with a +/// readable error instead of hanging on a password prompt. `disablesleep +/// 1` has no sudoers line and runs through `prompt` instead. struct PmsetSleepGuard: SleepGuarding { static let sudo = "/usr/bin/sudo" static let pmset = "/usr/bin/pmset" + /// What `enableSleep` passes to pmset. The root command asks sudo + /// about the same line before it turns sleep off + /// (`AdministratorPrompt.rootCommand`). + static let restoreArguments = ["-a", "disablesleep", "0"] /// pmset normally returns in well under a second; a hung powerd must not /// hang a quit or a lid action forever. static let timeout: TimeInterval = 20 @@ -40,21 +88,30 @@ struct PmsetSleepGuard: SleepGuarding { /// scripts/backstop.sh. static let stopGrace: TimeInterval = 3 - /// The app uses the defaults; tests point `sudo` at a fake, shorten the - /// limits, and start the deadline once the fake is ready. + /// Shows the administrator password dialog for `disableSleep`. + let prompt: any AdministratorPromptRunning + /// The sudo that is run. Only tests pass another (a fake that records + /// its arguments); the app always runs `PmsetSleepGuard.sudo`. let sudoPath: String + /// The pmset that `pmset -g` reads run. Only tests pass another; what + /// sudo runs is always `PmsetSleepGuard.pmset`, the path the sudoers + /// rule names. let pmsetPath: String let commandTimeout: TimeInterval let grace: TimeInterval let runner: CancellableCommand + /// The app uses the defaults; tests point `sudo` at a fake, shorten the + /// limits, and start the deadline once the fake is ready. init( + prompt: any AdministratorPromptRunning = OsascriptAdministratorPrompt(), sudo: String = Self.sudo, pmset: String = Self.pmset, timeout: TimeInterval = Self.timeout, stopGrace: TimeInterval = Self.stopGrace, runner: CancellableCommand = CancellableCommand() ) { + self.prompt = prompt sudoPath = sudo pmsetPath = pmset commandTimeout = timeout @@ -62,8 +119,28 @@ struct PmsetSleepGuard: SleepGuarding { self.runner = runner } - func setSleepDisabled(_ disabled: Bool) async throws { - try await sudoPmset(["-a", "disablesleep", disabled ? "1" : "0"]) + /// Reads `pmset -g` unless the journal already owns a SleepDisabled 1. + /// Nothing runs as root here, and the restore is never run to test it: + /// it would turn sleep back on for whoever set it. Whether it needs a + /// password is asked of sudo by the root command, under the marker's + /// lock, with listings that change nothing. + func checkSleepSettingForStart(sleepOffIsOurs: Bool) async throws { + guard !sleepOffIsOurs else { return } + let sleepOff: Bool + do { + sleepOff = try await isSleepDisabled() + } catch { + throw SleepSettingRefusal.sleepSettingUnreadable(error.localizedDescription) + } + guard !sleepOff else { throw SleepSettingRefusal.sleepAlreadyOff } + } + + func disableSleep(_ start: PendingStart) async throws { + try await prompt.disableSleep(start) + } + + func enableSleep() async throws { + try await sudoPmset(Self.restoreArguments) } func setLowPowerMode(_ on: Bool) async throws { @@ -143,7 +220,7 @@ struct PmsetSleepGuard: SleepGuarding { /// run an undo beside it, or before it, and have it change power state /// afterwards with no journal entry left. func sudoPmset(_ args: [String], sudoOptions: [String] = []) async throws { - let full = [pmsetPath] + args + let full = [Self.pmset] + args let options = sudoOptions + ["-n"] let command = "sudo \(options.joined(separator: " ")) \(full.joined(separator: " "))" guard let lock = RecoveryLock.held else { diff --git a/Sources/Insomnia/Model/RuntimeState.swift b/Sources/Insomnia/Model/RuntimeState.swift index 0fde46ae..5ad8b6a0 100644 --- a/Sources/Insomnia/Model/RuntimeState.swift +++ b/Sources/Insomnia/Model/RuntimeState.swift @@ -202,6 +202,19 @@ struct RuntimeState: Codable, Equatable, Sendable { /// each with the value to put back. Not a lid action: restored at /// session end, at reconcile, or by the backstop with `defaults`. var appNapOverrides: [AppNapOverride] = [] + /// A start that may still turn sleep off, journaled with + /// `sleepDisabledByUs` before its password dialog (SleepOffAttempt). + /// Until it is settled it is never set without `sleepDisabledByUs`, so + /// it adds nothing to undo on its own; it records whether that entry + /// may be cleared without one. A settled one sits beside whatever + /// `sleepDisabledByUs` the settlement decided and only waits for its + /// claim on the receipt to be given back. + var sleepOffAttempt: SleepOffAttempt? = nil + + /// The journaled start, unless a settlement has already decided it. + var unsettledSleepOffAttempt: SleepOffAttempt? { + sleepOffAttempt.flatMap { $0.isSettled ? nil : $0 } + } /// Bare pids of every journaled freeze, for display and de-duplication. var frozenPids: [Int32] { frozenProcesses.map(\.pid) } @@ -328,7 +341,7 @@ struct RuntimeState: Codable, Equatable, Sendable { case savedDisplayBrightness, savedKeyboardBrightness, displayRestoredUnderLowPower case displayRestoreRefused, keyboardRestoreRefused case keptDisplayUnderLowPower, keptDisplayUnderLowPowerBoot, keptDisplayReadLit - case appNapOverrides + case appNapOverrides, sleepOffAttempt } // Tolerate missing keys so a state.json written by an older build, or by @@ -359,6 +372,7 @@ struct RuntimeState: Codable, Equatable, Sendable { keptDisplayUnderLowPowerBoot = try c.decodeIfPresent(String.self, forKey: .keptDisplayUnderLowPowerBoot) keptDisplayReadLit = try c.decodeIfPresent(Float.self, forKey: .keptDisplayReadLit) appNapOverrides = try c.decodeIfPresent([AppNapOverride].self, forKey: .appNapOverrides) ?? [] + sleepOffAttempt = try c.decodeIfPresent(SleepOffAttempt.self, forKey: .sleepOffAttempt) } /// `frozenPids` is read for migration only and never written again, so @@ -383,5 +397,6 @@ struct RuntimeState: Codable, Equatable, Sendable { try c.encodeIfPresent(keptDisplayUnderLowPowerBoot, forKey: .keptDisplayUnderLowPowerBoot) try c.encodeIfPresent(keptDisplayReadLit, forKey: .keptDisplayReadLit) try c.encode(appNapOverrides, forKey: .appNapOverrides) + try c.encodeIfPresent(sleepOffAttempt, forKey: .sleepOffAttempt) } } diff --git a/Sources/Insomnia/Model/Session.swift b/Sources/Insomnia/Model/Session.swift index 854c2f00..d724601e 100644 --- a/Sources/Insomnia/Model/Session.swift +++ b/Sources/Insomnia/Model/Session.swift @@ -7,11 +7,45 @@ struct Session: Codable, Equatable, Sendable { var endsAt: Date /// Every extension applied, in order, in seconds. var extensions: [TimeInterval] + /// A name no other session has, given at Start and kept through every + /// extension. A settled start names the session it leaves to be resumed + /// by it (`ResumedSession.id`), and a start names its own + /// (`SleepOffAttempt.session`), so two sessions with the same times are + /// told apart. nil in a session an older build wrote. Read leniently: + /// an id that is not a string reads as nil, as the scripts ignore the + /// key (session_shape_problems). Left out of the JSON when nil. + var id: String? - init(startedAt: Date, endsAt: Date, extensions: [TimeInterval] = []) { + init(startedAt: Date, endsAt: Date, extensions: [TimeInterval] = [], id: String? = nil) { self.startedAt = startedAt self.endsAt = endsAt self.extensions = extensions + self.id = id + } + + private enum CodingKeys: String, CodingKey { case startedAt, endsAt, extensions, id } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + startedAt = try c.decode(Date.self, forKey: .startedAt) + endsAt = try c.decode(Date.self, forKey: .endsAt) + extensions = try c.decode([TimeInterval].self, forKey: .extensions) + id = try? c.decodeIfPresent(String.self, forKey: .id) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(startedAt, forKey: .startedAt) + try c.encode(endsAt, forKey: .endsAt) + try c.encode(extensions, forKey: .extensions) + try c.encodeIfPresent(id, forKey: .id) + } + + /// Whether `self` and `other` are the same session as far as their ids + /// tell: equal ids, or nil when either has none (an older build's). + func sameID(as other: String?) -> Bool? { + guard let id, let other else { return nil } + return id == other } /// Seconds left until `endsAt`, never negative. diff --git a/Sources/Insomnia/Store/Paths.swift b/Sources/Insomnia/Store/Paths.swift index 5341d453..4483c57f 100644 --- a/Sources/Insomnia/Store/Paths.swift +++ b/Sources/Insomnia/Store/Paths.swift @@ -6,13 +6,13 @@ import Foundation /// /// Default layout: /// ~/Applications/Insomnia.app/Contents/Resources/backstop.sh -/// ~/Library/Application Support/Insomnia/{session.json,state.json,config.json,unfinished-command.json} +/// ~/Library/Application Support/Insomnia/{session.json,state.json,config.json,pending-start,unfinished-command.json} /// ~/Library/Logs/Insomnia/{insomnia.log,handoffs.log} /// ~/Library/LaunchAgents/com.insomnia.backstop.plist /// /// With INSOMNIA_HOME=/x: /// /x/Insomnia.app/Contents/Resources/backstop.sh -/// /x/{session.json,state.json,config.json,unfinished-command.json} +/// /x/{session.json,state.json,config.json,pending-start,unfinished-command.json} /// /x/Logs/{insomnia.log,handoffs.log} /// /x/LaunchAgents/com.insomnia.backstop.plist struct Paths: Sendable, Equatable { @@ -81,6 +81,12 @@ struct Paths: Sendable, Equatable { /// flock(2) file shared with backstop.sh (`lockf -k` on the same path). /// Created once, never unlinked, so both sides lock the same inode. var recoveryLock: URL { appSupport.appendingPathComponent(".recovery.lock") } + /// The nonce a Start writes just before the password dialog and deletes + /// before it releases the recovery lock (see PendingStart). backstop.sh + /// and uninstall.sh delete it under the same lock. Every deleter also + /// locks the file itself first (`lockf` or flock(2)), the lock the + /// dialog's root command holds while it runs. + var pendingStartFile: URL { appSupport.appendingPathComponent("pending-start") } /// The `sudo pmset` left running that holds the recovery lock, written /// while it runs so a relaunch after a crash can name it. Removed when /// it exits, and by the next transaction that takes the lock. diff --git a/Sources/Insomnia/Store/SleepOffReceipts.swift b/Sources/Insomnia/Store/SleepOffReceipts.swift new file mode 100644 index 00000000..6794e73c --- /dev/null +++ b/Sources/Insomnia/Store/SleepOffReceipts.swift @@ -0,0 +1,932 @@ +import Darwin +import Darwin.membership +import Foundation + +/// A start's intent to turn sleep off. Journaled with `sleepDisabledByUs` +/// before the password dialog can run anything, and settled from the +/// receipt by whoever next holds the recovery lock: the next transaction, +/// backstop.sh or uninstall.sh. A start that finishes, or rolls back, +/// removes it itself. +struct SleepOffAttempt: Codable, Equatable, Sendable { + /// The marker's content, and what the root command writes to the + /// receipt (`SleepOffReceipts`). + var nonce: String + /// `sleepDisabledByUs` before this start: a restore an earlier session + /// still owes. A receipt showing that this start never turned sleep off + /// puts this value back, so it never clears an older entry. + var owedBefore: Bool + /// "device:inode" of the receipt when the start claimed it. The root + /// command is given it and refuses another file; a receipt that is + /// another file now shows nothing about this start. + var receipt: String + /// The nonce the receipt held when this start claimed it. The root + /// command writes only while the receipt still holds it, and writes it + /// beside its own nonce, so a later start's line that names it shows + /// this start never wrote. + var predecessor: String + /// The session's end in whole seconds since 1970, as session.json holds + /// it. A settlement deletes session.json only when its end matches: + /// that session never began. + var deadline: Int + /// Whole seconds since 1970 from which the root command refuses + /// (`AdministratorPrompt.answerWindow` after the dialog was shown, or + /// the session's end if sooner). Journaled with `marker`, before the + /// dialog. Until then a receipt that still holds `predecessor` proves + /// nothing: the dialog may yet be answered. + var expires: Int + /// "device:inode" of the marker this start wrote, journaled after the + /// marker and before the dialog. nil: no dialog was shown, so nothing + /// ran as root for this start. + var marker: String? + /// true once a settlement decided this start and journaled what it + /// owes (`sleepDisabledByUs`), before it gives the start's claim back. + /// A settled record changes nothing to undo; it waits only for the + /// claim to be given back, under the receipt's lock, and is then + /// removed. So a crash or a failure after the decision is published + /// leaves the decision itself, never a record a later start's receipt + /// line could be read against again. Left out of the JSON when nil. + var settled: Bool? = nil + /// The session the settled record leaves to be resumed while it waits + /// for its claim to be given back: this start's own once sleep was + /// turned off for it, the one from before it that its rollback put + /// back, the one a settlement from the receipt left in place, or none + /// (`ResumedSession.none`). The app journals it in the same write as + /// `settled`, before the claim goes back, so a launch while the record + /// stays resumes that session only (`SessionManager.isResumed`) and + /// ends any other. nil in a record settled by a build from before it, + /// or by backstop.sh or uninstall.sh, which remove the start's own + /// session before they publish: a launch then takes the session whose + /// first end is `deadline` (`SessionManager.isSession`), and another + /// session with that first end passes too. Left out of the JSON when + /// nil. The scripts check only its shape, and a build from before it + /// drops it when it rewrites the journal. + var resumes: ResumedSession? = nil + /// The `Session.id` of the session this start writes, journaled with + /// the attempt before the dialog. A session.json with another id is + /// not this start's, whatever its times (`SessionManager.isSession`). + /// nil in a record of a build from before it. Read leniently, like + /// `Session.id`: the scripts do not check it. Left out of the JSON when + /// nil. + var session: String? = nil + + var isSettled: Bool { settled == true } + + private enum CodingKeys: String, CodingKey { + case nonce, owedBefore, receipt, predecessor, deadline, expires, marker, settled, resumes, session + } +} + +extension SleepOffAttempt { + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + nonce = try c.decode(String.self, forKey: .nonce) + owedBefore = try c.decode(Bool.self, forKey: .owedBefore) + receipt = try c.decode(String.self, forKey: .receipt) + predecessor = try c.decode(String.self, forKey: .predecessor) + deadline = try c.decode(Int.self, forKey: .deadline) + expires = try c.decode(Int.self, forKey: .expires) + marker = try c.decodeIfPresent(String.self, forKey: .marker) + settled = try c.decodeIfPresent(Bool.self, forKey: .settled) + resumes = try c.decodeIfPresent(ResumedSession.self, forKey: .resumes) + session = try? c.decodeIfPresent(String.self, forKey: .session) + } +} + +/// What `SleepOffAttempt.resumes` records of a session, in whole seconds +/// since 1970 as session.json keeps them: its `startedAt`, which no +/// extension changes, and its first end (`SessionMath.firstEnd`). With no +/// `startedAt`, no session. +struct ResumedSession: Codable, Equatable, Sendable { + var startedAt: Int? + var firstEnd: Int? + /// The session's `Session.id`; nil for a session without one. Read + /// leniently, like `Session.id`: the scripts check only the two times. + var id: String? + + /// No session is left to be resumed. + static let none = ResumedSession() + + init(startedAt: Int? = nil, firstEnd: Int? = nil, id: String? = nil) { + self.startedAt = startedAt + self.firstEnd = firstEnd + self.id = id + } + + /// `s` as session.json keeps it, or `.none` for a session with no first + /// end or a start session.json cannot hold. Both times are checked + /// against `SessionMath.storableTimes` first, so neither conversion + /// can trap. + init(_ s: Session) { + let started = s.startedAt.timeIntervalSince1970.rounded(.down) + guard SessionMath.storableTimes.contains(started), let first = SessionMath.firstEnd(of: s) else { + self.init() + return + } + self.init(startedAt: Int(started), firstEnd: Int(first.rounded(.down)), id: s.id) + } + + private enum CodingKeys: String, CodingKey { case startedAt, firstEnd, id } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + startedAt = try c.decodeIfPresent(Int.self, forKey: .startedAt) + firstEnd = try c.decodeIfPresent(Int.self, forKey: .firstEnd) + id = try? c.decodeIfPresent(String.self, forKey: .id) + } +} + +/// What a receipt shows about one start. +enum SleepOffVerdict: Equatable, Sendable { + /// The root command for that start never ran `pmset -a disablesleep 1`, + /// and never will (when that rests on `expires` having passed, only + /// while the wall clock does not go back). + case neverWrote + /// It may have, or nothing shows otherwise; the reason, for the log. + case mayHaveWritten(String) + /// Not known yet, because a command for that start could still write: + /// the receipt is locked, or its dialog can still be answered. The + /// start stays journaled and is settled again later; the reason, for + /// the menu and the log. + case undecided(String) +} + +/// The record the command behind the password dialog keeps as root: +/// `/private/var/db/com.kgarg.insomnia/`, one file per user, exactly +/// 82 bytes: a start's nonce, the nonce the receipt held before it, and +/// `writing` or `refused`, on one line. install.sh creates the folder and +/// the file, both root's, and only root can change either. The file is +/// 0600 with one access control entry, `user: allow read`, for the +/// user its name is the uid of (`AccessEntry.installed`): only root and +/// that user can open it, so only they can hold its lock. Every Insomnia +/// folder of the user (INSOMNIA_HOME) shares it. +/// +/// It is also the lock every party takes before it acts on a start (see +/// AdministratorPrompt.rootCommand). The root command opens it read-only, +/// takes an flock(2) lock on it with `lockf` before any check, and holds +/// the lock until the command and pmset exit. It writes only while the +/// receipt still holds the nonce this start claimed it from +/// (`SleepOffAttempt.predecessor`), and only before the start's +/// `SleepOffAttempt.expires`, and then first ` +/// writing`, flushed to the drive with F_FULLFSYNC and read back, before +/// `pmset -a disablesleep 1`. Readers open it read-only and take the same +/// exclusive lock, so they read either before the command took it or +/// after the command and its pmset exited. The lock is advisory: it +/// orders the parties that take it, and root can still change the file +/// without it. +/// +/// So, under the lock, the receipt shows that a start's command never +/// turned sleep off when it holds that start's nonce with `refused`; or +/// another start's line that names the same predecessor (that start wrote +/// first, and this one's command needs the predecessor); or the +/// predecessor itself once the start has expired (or its dialog has +/// ended), when no command for it can still write. This start's +/// `writing`, a receipt that is missing, replaced, unreadable or not in +/// that shape, a folder above it that someone other than root could +/// change, or a later start's line that names another predecessor all +/// prove nothing: once the start has expired it is undone like an end, +/// and until then it stays journaled (`SleepOffVerdict.undecided`). So +/// does a receipt that stays locked or cannot be locked, or that is the +/// file the start journaled but cannot be opened to be locked, whenever it +/// is read; and that file, locked, when a list or a path its checks need +/// cannot be read (`Unknown`): a check that did not run proves neither. +/// +/// The release file beside it (`releaseFile`) keeps those lines from +/// being overwritten while a start is not settled: see `claimable`. +struct SleepOffReceipts: Sendable { + /// Root's, outside every user's folders. + static let folder = "/private/var/db/com.kgarg.insomnia" + /// The nonce no start has: what a new receipt and release file hold. + static let zero = "00000000-0000-0000-0000-000000000000" + /// What install.sh writes into a new receipt. + static let initialContent = "\(zero) \(zero) refused\n" + /// Two nonces (36 each), two spaces, `writing` or `refused` (7) and a + /// newline. + static let size = 82 + /// What install.sh writes into a new release file: the new receipt's + /// nonce, free. + static let initialRelease = "\(zero) free\n" + /// A nonce (36), a space, `free` or `held` (4) and a newline. + static let releaseSize = 42 + + /// This user's receipt, trusting root alone, with the lists acl(3) + /// reads. + static var live: SleepOffReceipts { SleepOffReceipts(folder: folder, owners: [0], user: getuid(), lists: .native) } + + init(folder: String, owners: Set, user: uid_t, lists: AccessLists, stats: StatCalls = .system) { + self.folder = folder + self.owners = owners + self.user = user + self.lists = lists + self.stats = stats + } + + /// The lstat(2) and fstat(2) the receipt's checks make, each returning + /// 0 or the call's errno. The system's in the app (`system`); tests + /// give calls that fail, since nothing on a working disk makes the real + /// ones fail on cue. + struct StatCalls: Sendable { + var lstat: @Sendable (String, inout stat) -> Int32 + var fstat: @Sendable (Int32, inout stat) -> Int32 + + static let system = StatCalls( + lstat: { Darwin.lstat($0, &$1) == 0 ? 0 : errno }, + fstat: { Darwin.fstat($0, &$1) == 0 ? 0 : errno } + ) + } + + /// An absolute path with no `.`, `..`, empty or symbolic link + /// component; anything else fails every check. + let folder: String + /// Who may own the receipt, its folder and every folder above them. + /// Only root in production. Tests add their own uid for a folder in + /// their temporary directory, since they cannot create root's files. + let owners: Set + let user: uid_t + /// Where the checks read each access control list: acl(3) in the app + /// (`live`). Tests give every list, and every failure to read one, + /// here, so no real list is read (`AccessLists.fixed`); there is no + /// default, so no receipt reads a real list unless it asks for one. + let lists: AccessLists + let stats: StatCalls + + var file: String { folder + "/" + String(user) } + /// `.released`, beside the receipt: the user's own file, 42 bytes, + /// a nonce and `free` or `held` on one line. install.sh creates it. + var releaseFile: String { file + ".released" } + + struct Problem: Error, LocalizedError { + let detail: String + var errorDescription: String? { detail } + } + + /// The receipt stayed locked for the whole wait. + struct Busy: Error, LocalizedError { + let file: String + let seconds: TimeInterval + var errorDescription: String? { + "\(file) stayed locked for \(Int(seconds)) s: the command behind a password dialog may be running, or another Insomnia folder of this user, or something else running as this user, is holding it" + } + } + + /// flock(2) failed other than by finding the receipt locked. + struct LockFailed: Error, LocalizedError { + let file: String + let errno: Int32 + var errorDescription: String? { "\(file) could not be locked: \(String(cString: strerror(errno)))" } + } + + /// The file at the receipt's path could not be opened to be locked: + /// open(2)'s errno, and "device:inode" of the file, by lstat. When that + /// is the file a start claimed, a command for it may hold its lock, and + /// nothing shows whether one does. + struct Unopened: Error, LocalizedError { + let file: String + let identity: String + let errno: Int32 + var errorDescription: String? { "\(file) could not be opened to be locked: \(String(cString: strerror(errno)))" } + } + + /// Under the lock, something the checks need could not be read: an + /// access control list acl(3) could not give whole, a path whose + /// lstat(2) failed other than by finding nothing there, or an fstat(2) + /// of the locked file that failed (`record`). Nothing shows + /// whether the file can be trusted, or that it cannot, so it decides + /// nothing (`verdict`). `identity` is "device:inode" of the locked + /// file. + struct Unknown: Error, LocalizedError { + let detail: String + let identity: String + var errorDescription: String? { detail } + } + + /// Before the lock, the file at the receipt's path could not be + /// examined: lstat(2) failed other than by finding nothing there, or + /// fstat(2) of the opened file failed. That shows neither that the + /// receipt is missing or replaced nor that it is the file a start + /// claimed, and a command for that start may hold its lock, so it + /// decides nothing whatever the time (`verdict`). The release file's + /// fstat(2) failing throws it too (`readRelease`, `writeRelease`). + struct Unacquired: Error, LocalizedError { + let detail: String + var errorDescription: String? { detail } + } + + /// What `inspect` throws for `Unknown`, before `lock` adds the locked + /// file's identity. + private struct Unread: Error, LocalizedError { + let detail: String + var errorDescription: String? { detail } + } + + /// The release file shows another start's claim. + struct Claimed: Error, LocalizedError { + let nonce: String + var errorDescription: String? { + "another Insomnia start (\(nonce)) has claimed the receipt and is not settled yet. Its own Insomnia folder settles it: open Insomnia from that folder, or wait for its recovery agent, which tries every minute" + } + } + + /// One line of the receipt. + struct Record: Equatable, Sendable { + let nonce: String + let predecessor: String + let refused: Bool + } + + /// The release file's line. + enum Release: Equatable, Sendable { + /// No start is unsettled; the next may claim the receipt while it + /// holds this nonce. + case free(String) + /// The start with this nonce claimed the receipt and is not + /// settled. + case held(String) + + var line: String { + switch self { + case let .free(nonce): "\(nonce) free\n" + case let .held(nonce): "\(nonce) held\n" + } + } + } + + /// The receipt, open read-only and locked by this process (flock(2), + /// exclusive), as the root command locks it. Released by `release()` + /// or when it goes. + final class Guard: @unchecked Sendable { + let fd: Int32 + /// "device:inode" of the locked file. + let identity: String + private let lock = NSLock() + private var open = true + + init(fd: Int32, identity: String) { + self.fd = fd + self.identity = identity + } + + func release() { + lock.withLock { + guard open else { return } + open = false + close(fd) + } + } + + deinit { release() } + } + + /// "device:inode" of the receipt, once it and every folder from it up + /// to / pass the checks. + func identity() throws -> String { + FileIdentity(try check()).text + } + + /// Opens the receipt read-only and locks it, trying every `pollEvery` + /// for up to `timeout`, then checks it. Throws `Busy` when it stays + /// locked, `LockFailed` when flock(2) fails another way, `Unopened` + /// when the file at the path cannot be opened, `Unacquired` when the + /// file at the path cannot be examined before the lock (an lstat(2) + /// that fails other than by finding nothing there, or an fstat(2) of + /// the opened file that fails), `Unknown` when, once locked, a list or + /// a path the checks need cannot be read, and `Problem` when the + /// receipt is missing or not a regular file before the lock, is + /// another file once opened, or once locked, fails the checks or is not + /// the file at its path. + /// + /// Only what opening needs comes before the lock: a plain folder path + /// and a regular file at it, by lstat, so a FIFO or a device put there + /// is never opened. Its owner, mode, size, list and folders are checked + /// under the lock, as the root command checks them under its own: a + /// command that holds the lock is waited for, and decides nothing + /// (`Busy`), before anything about the file can decide a start. + func lock(timeout: TimeInterval, pollEvery: Duration = .milliseconds(50)) async throws -> Guard { + try checkFolderPath() + var info = stat() + let err = stats.lstat(file, &info) + guard err == 0 else { + let detail = "\(file): \(String(cString: strerror(err)))" + // Nothing there. Any other failure shows nothing about the file. + if err == ENOENT || err == ENOTDIR { throw Problem(detail: detail) } + throw Unacquired(detail: "\(file) could not be examined before its lock (lstat: \(String(cString: strerror(err))))") + } + guard info.st_mode & S_IFMT == S_IFREG else { throw Problem(detail: "\(file) is not a regular file") } + // O_NONBLOCK: lstat found a regular file, but a FIFO put in its + // place since must not hang the open. + let fd = open(file, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard fd >= 0 else { throw Unopened(file: file, identity: FileIdentity(info).text, errno: errno) } + var opened = stat() + let openErr = stats.fstat(fd, &opened) + guard openErr == 0 else { + close(fd) + throw Unacquired(detail: "\(file) could not be examined once opened (fstat: \(String(cString: strerror(openErr))))") + } + guard opened.st_dev == info.st_dev, opened.st_ino == info.st_ino else { + close(fd) + throw Problem(detail: "\(file) changed while it was opened") + } + let deadline = ContinuousClock.now + .seconds(timeout) + while true { + if flock(fd, LOCK_EX | LOCK_NB) == 0 { break } + let err = errno + guard err == EWOULDBLOCK else { + close(fd) + throw LockFailed(file: file, errno: err) + } + guard ContinuousClock.now < deadline else { + close(fd) + throw Busy(file: file, seconds: timeout) + } + do { + try await Task.sleep(for: pollEvery) + } catch { + close(fd) + throw error + } + } + let held = Guard(fd: fd, identity: FileIdentity(opened).text) + // The path must still name the locked file, and it must pass the + // checks. + do { + var now = stat() + let err = stats.lstat(file, &now) + guard err == 0 else { + if err == ENOENT || err == ENOTDIR { throw Problem(detail: "\(file) was removed while it was locked") } + throw Unread(detail: "\(file) could not be examined once locked (lstat: \(String(cString: strerror(err))))") + } + guard FileIdentity(now).text == held.identity else { + throw Problem(detail: "\(file) was replaced while it was locked") + } + guard FileIdentity(try check()).text == held.identity else { + throw Problem(detail: "\(file) was replaced while it was locked") + } + } catch let error as Unread { + held.release() + throw Unknown(detail: error.detail, identity: held.identity) + } catch { + held.release() + throw error + } + return held + } + + /// The receipt's line, through the locked descriptor. Throws `Unknown` + /// when fstat(2) of it fails, and `Problem` when it is not the file + /// install.sh made or holds no line. + func record(under held: Guard) throws -> Record { + var opened = stat() + let err = stats.fstat(held.fd, &opened) + guard err == 0 else { + throw Unknown(detail: "\(file) could not be examined once locked (fstat: \(String(cString: strerror(err))))", identity: held.identity) + } + guard opened.st_nlink == 1, opened.st_size == off_t(Self.size) else { + throw Problem(detail: "\(file) is not the \(Self.size)-byte file install.sh made") + } + var bytes = [UInt8](repeating: 0, count: Self.size + 1) + guard pread(held.fd, &bytes, bytes.count, 0) == Self.size else { + throw Problem(detail: "\(file) could not be read whole") + } + let nonce = bytes[0..<36] + let predecessor = bytes[37..<73] + let word = String(decoding: bytes[74..<81], as: UTF8.self) + guard Self.isNonce(nonce), bytes[36] == 0x20, Self.isNonce(predecessor), bytes[73] == 0x20, + word == "writing" || word == "refused", bytes[81] == 0x0A else { + throw Problem(detail: "\(file) does not hold two nonces and writing or refused") + } + return Record(nonce: String(decoding: nonce, as: UTF8.self), predecessor: String(decoding: predecessor, as: UTF8.self), refused: word == "refused") + } + + /// What the receipt shows about `attempt`, from `lock`'s outcome + /// (`held`, or the error it threw) at `now` (seconds since 1970). + /// `dialogOver`: the dialog this start showed has ended on its own + /// (osascript exited by itself, so the command it ran, if any, has + /// exited too, and no other can start for it). Until then, or until + /// `expires`, a command for the start may still write, so a receipt + /// that still holds the predecessor decides nothing, and neither does + /// a receipt this process could not lock as the file the start + /// claimed: a command may hold that file's lock, and after `expires` it + /// is undone like an end. A receipt that is locked, a lock that fails, + /// a file at the path that cannot be examined before the lock + /// (`Unacquired`), and the claimed file still at the path but one that + /// cannot be opened to lock it decide nothing: a command may hold that + /// lock. Every check + /// of the file itself comes after the lock (`lock`), so a file that + /// fails one decides only once no command holds it. The file the start + /// claimed, locked, whose checks could not be read (`Unknown`), decides + /// nothing whatever the time: a check that failed to run shows neither + /// that its line can be trusted nor that it cannot, so it neither + /// settles the start as one that never wrote nor lets its expiry undo + /// a SleepDisabled someone else may have set. + func verdict(for attempt: SleepOffAttempt, lock outcome: Result, now: Int, dialogOver: Bool = false) -> SleepOffVerdict { + // Nothing ran as root for a start that never showed its dialog. + guard attempt.marker != nil else { return .neverWrote } + let over = dialogOver || now >= attempt.expires + let until = "until \(ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: TimeInterval(attempt.expires))))" + let held: Guard + switch outcome { + case let .success(g): + held = g + case let .failure(error) where error is Busy || error is LockFailed || error is Unacquired || error is CancellationError: + return .undecided(error.localizedDescription) + case let .failure(error as Unopened) where error.identity == attempt.receipt: + return .undecided("\(error.localizedDescription); a command for that start may hold its lock") + case let .failure(error as Unknown) where error.identity == attempt.receipt: + return .undecided("\(error.localizedDescription); nothing shows whether the receipt can be trusted, so it decides nothing") + case let .failure(error): + return over ? .mayHaveWritten(error.localizedDescription) : .undecided("\(error.localizedDescription); a command for that start could still write \(until)") + } + guard held.identity == attempt.receipt else { + let why = "\(file) is not the file it was when the start began" + return over ? .mayHaveWritten(why) : .undecided("\(why); a command for that start could still write \(until)") + } + let found: Record + do { + found = try record(under: held) + } catch let error as Unknown { + return .undecided("\(error.localizedDescription); nothing shows whether the receipt can be trusted, so it decides nothing") + } catch { + // Locked as the file the start claimed: no command for it is + // running. One that starts before `expires` may still find its + // predecessor at the front of a damaged line. + return over ? .mayHaveWritten(error.localizedDescription) : .undecided("\(error.localizedDescription); a command for that start could still write \(until)") + } + if found.nonce == attempt.nonce { + return found.refused ? .neverWrote : .mayHaveWritten("\(file) holds that start's writing line: its command was about to turn sleep off and may have") + } + if found.nonce == attempt.predecessor { + return over ? .neverWrote : .undecided("the password dialog of that start can still be answered \(until)") + } + if found.predecessor == attempt.predecessor { return .neverWrote } + return .mayHaveWritten("\(file) holds a later start's line, which no longer shows what the command for this start did") + } + + /// Under `held`, for a start about to journal itself: the receipt's + /// nonce, which becomes the start's predecessor, once the release file + /// shows it free. The start then journals itself and writes + /// `.held(nonce)` (`writeRelease`) under the same lock. Until that + /// start gives the claim back (`release`), no start in another Insomnia + /// folder can claim the receipt, so no root command can replace the + /// line its settlement reads. Throws `Claimed` for another start's + /// claim, and `Problem` for a receipt or release file that is missing, + /// not in its shape, or a release file that names another nonce. + func claimable(under held: Guard) throws -> String { + let holder = try record(under: held).nonce + switch try readRelease() { + case let .free(free) where free == holder: + return holder + case let .held(other): + throw Claimed(nonce: other) + case .free: + throw Problem(detail: "\(releaseFile) names a start other than the one \(file) holds. Run install.sh again") + } + } + + /// Under `held`: gives back `nonce`'s claim, so the release file shows + /// the receipt's nonce free, and says whether it wrote. Writes nothing + /// when the file shows anything but that claim (it was given back + /// already, or never written). Throws when the file cannot be read or + /// written. + @discardableResult + func release(_ nonce: String, under held: Guard) throws -> Bool { + guard try readRelease() == .held(nonce) else { return false } + try writeRelease(.free(try record(under: held).nonce)) + return true + } + + /// The release file's line. It is the user's own file, so anything + /// running as the user can rewrite it: it never shows that a start + /// did not turn sleep off, it only keeps starts from replacing the + /// receipt's line. A false one can only make a settlement find a later + /// start's line, which proves nothing, or refuse every Start. + func readRelease() throws -> Release { + let fd = open(releaseFile, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard fd >= 0 else { throw Problem(detail: "\(releaseFile): \(String(cString: strerror(errno))). Run install.sh again") } + defer { close(fd) } + var info = stat() + let err = stats.fstat(fd, &info) + guard err == 0 else { + throw Unacquired(detail: "\(releaseFile) could not be examined once opened (fstat: \(String(cString: strerror(err))))") + } + guard info.st_mode & S_IFMT == S_IFREG, info.st_size == off_t(Self.releaseSize) else { + throw Problem(detail: "\(releaseFile) is not the \(Self.releaseSize)-byte file install.sh made. Run install.sh again") + } + var bytes = [UInt8](repeating: 0, count: Self.releaseSize + 1) + guard pread(fd, &bytes, bytes.count, 0) == Self.releaseSize else { + throw Problem(detail: "\(releaseFile) could not be read whole") + } + let nonce = bytes[0..<36] + let word = String(decoding: bytes[37..<41], as: UTF8.self) + guard Self.isNonce(nonce), bytes[36] == 0x20, word == "free" || word == "held", bytes[41] == 0x0A else { + throw Problem(detail: "\(releaseFile) does not hold a nonce and free or held. Run install.sh again") + } + let text = String(decoding: nonce, as: UTF8.self) + return word == "free" ? .free(text) : .held(text) + } + + /// Writes `release` over the file in place: never creates it, never + /// through a link, fsync(2), then reads it back. + func writeRelease(_ release: Release) throws { + let fd = open(releaseFile, O_WRONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard fd >= 0 else { throw Problem(detail: "\(releaseFile) could not be opened for writing: \(String(cString: strerror(errno)))") } + defer { close(fd) } + var info = stat() + let err = stats.fstat(fd, &info) + guard err == 0 else { + throw Unacquired(detail: "\(releaseFile) could not be examined once opened (fstat: \(String(cString: strerror(err))))") + } + guard info.st_mode & S_IFMT == S_IFREG, info.st_size == off_t(Self.releaseSize) else { + throw Problem(detail: "\(releaseFile) is not the \(Self.releaseSize)-byte file install.sh made. Run install.sh again") + } + let bytes = Array(release.line.utf8) + guard pwrite(fd, bytes, bytes.count, 0) == bytes.count, fsync(fd) == 0 else { + throw Problem(detail: "\(releaseFile) could not be written: \(String(cString: strerror(errno)))") + } + guard try readRelease() == release else { + throw Problem(detail: "\(releaseFile) did not read back as written") + } + } + + /// Uppercase hexadecimal digits and hyphens: what a nonce (an uppercase + /// UUID) is made of. + private static func isNonce(_ bytes: ArraySlice) -> Bool { + bytes.count == 36 && bytes.allSatisfy { (0x30...0x39).contains($0) || (0x41...0x46).contains($0) || $0 == 0x2D } + } + + /// lstat(2) of the receipt and of each folder up to /: never through a + /// link, each one root's (or an owner in `owners`), with no write + /// permission for group or others. Each folder has no access control + /// entry that allows anything. The receipt itself must also be a + /// regular file with one link, `size` bytes and mode 0600, whose list + /// is exactly `AccessEntry.installed(for: user)`. A path or a list that + /// cannot be read does not stop the checks of the others: a check that + /// fails anywhere throws `Problem`, and only when none does, the first + /// that could not be read throws `Unread`. + private func check() throws -> stat { + try checkFolderPath() + var unread: Unread? + let info = try inspect(file, directory: false, unread: &unread) + if let info { + guard info.st_nlink == 1 else { throw Problem(detail: "\(file) has \(info.st_nlink) links") } + guard info.st_size == off_t(Self.size) else { throw Problem(detail: "\(file) is \(info.st_size) bytes, not \(Self.size)") } + } + var dir = folder + while true { + _ = try inspect(dir, directory: true, unread: &unread) + if dir == "/" { break } + let cut = dir.lastIndex(of: "/")! + dir = cut == dir.startIndex ? "/" : String(dir[.. 1, parts.dropFirst().allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) else { + throw Problem(detail: "\(folder) is not a plain absolute path") + } + } + + /// Throws `Problem` for a check `path` fails, one that is not there + /// among them. A path whose lstat(2) fails another way, or whose list + /// acl(3) cannot give whole, is not known to pass or to fail them: that + /// is kept in `unread` when it holds nothing yet, and the stat is nil + /// for a path that could not be read at all. + private func inspect(_ path: String, directory: Bool, unread: inout Unread?) throws -> stat? { + var info = stat() + let err = stats.lstat(path, &info) + guard err == 0 else { + let detail = "\(path): \(String(cString: strerror(err)))" + if err == ENOENT || err == ENOTDIR { throw Problem(detail: detail) } + if unread == nil { unread = Unread(detail: detail) } + return nil + } + let type = info.st_mode & S_IFMT + guard type == (directory ? S_IFDIR : S_IFREG) else { + throw Problem(detail: "\(path) is not a \(directory ? "folder" : "regular file")") + } + guard owners.contains(info.st_uid) else { + throw Problem(detail: "\(path) belongs to uid \(info.st_uid), not root") + } + guard info.st_mode & 0o022 == 0 else { + throw Problem(detail: "\(path) can be changed by its group or by others") + } + guard directory || info.st_mode & 0o7777 == 0o600 else { + throw Problem(detail: "\(path) has mode \(String(info.st_mode & 0o7777, radix: 8)), not the 600 install.sh gives it") + } + let entries: [AccessEntry] + switch lists.list(of: path, receipt: !directory) { + case let .entries(found): + entries = found + case let .unreadable(err): + if unread == nil { unread = Unread(detail: "the access control list of \(path) could not be read: \(String(cString: strerror(err)))") } + return info + case .incomplete: + if unread == nil { unread = Unread(detail: "the access control list of \(path) could not be read whole") } + return info + } + if directory { + guard !entries.contains(where: \.allows) else { + throw Problem(detail: "\(path) has an access control entry that allows changes") + } + } else if let why = Self.receiptAccessProblem(entries, user: user) { + throw Problem(detail: "\(path) \(why)") + } + return info + } + + /// Why `entries` are not the receipt's list as install.sh makes it, or + /// nil. It must be exactly one entry, `AccessEntry.installed(for: + /// user)`: it allows, names `user`, grants `read` alone, and carries no + /// flag, so it was not inherited from the folder. Anything else lets + /// another account open the receipt and hold its lock, or lets someone + /// other than root change it, or is not what install.sh made. + static func receiptAccessProblem(_ entries: [AccessEntry], user: uid_t) -> String? { + let wanted = AccessEntry.installed(for: user) + guard entries.count == 1, let only = entries.first else { + return entries.isEmpty + ? "has no access control entry, so uid \(user) cannot read it" + : "has \(entries.count) access control entries, not the one that lets uid \(user) read it" + } + guard only == wanted else { + return "has an access control entry other than the one that lets uid \(user) read it: \(only.text)" + } + return nil + } + + /// `path`'s own extended access control list (never a link's target), + /// in order, through acl(3): no entries when it has none (acl_get_link_np + /// answers ENOENT), `unreadable` when acl_get_link_np fails another + /// way, `incomplete` when an entry or flag cannot be read. + static func accessList(_ path: String) -> AccessList { + errno = 0 + guard let acl = acl_get_link_np(path, ACL_TYPE_EXTENDED) else { + let err = errno + return err == ENOENT ? .entries([]) : .unreadable(err) + } + defer { acl_free(UnsafeMutableRawPointer(acl)) } + return accessEntries(of: acl).map(AccessList.entries) ?? .incomplete + } + + /// The acl(3) calls `accessEntries(of:)` makes to walk a list and read + /// each entry's flags, so a test can make either fail on a list it + /// builds in memory, without changing a real one. + struct ListCalls { + var entry: (acl_t?, Int32, UnsafeMutablePointer) -> Int32 = { acl_get_entry($0, $1, $2) } + var flag: (acl_flagset_t?, acl_flag_t) -> Int32 = { acl_get_flag_np($0, $1) } + } + + /// `acl`'s entries, in order; nil when any of them cannot be read + /// whole. acl_get_entry(3) ends the list with -1 and EINVAL; any other + /// failure, or an answer it does not document, is not taken for the end + /// of the list, and a flag acl_get_flag_np(3) cannot read is not taken + /// for one that is clear: either could hide an entry or a flag that + /// makes the list one the checks refuse. + static func accessEntries(of acl: acl_t?, calls: ListCalls = ListCalls()) -> [AccessEntry]? { + var entries: [AccessEntry] = [] + var entry: acl_entry_t? + var which = ACL_FIRST_ENTRY.rawValue + while true { + errno = 0 + let got = calls.entry(acl, which, &entry) + if got == -1 && errno == EINVAL { break } + guard got == 0 else { return nil } + which = ACL_NEXT_ENTRY.rawValue + var tag = ACL_UNDEFINED_TAG + var rights: acl_permset_mask_t = 0 + var flags: acl_flagset_t? + guard acl_get_tag_type(entry, &tag) == 0, acl_get_permset_mask_np(entry, &rights) == 0, + acl_get_flagset_np(UnsafeMutableRawPointer(entry), &flags) == 0, + let qualifier = acl_get_qualifier(entry) else { return nil } + defer { acl_free(qualifier) } + var set: Set = [] + for (flag, word) in AccessEntry.flagWords { + switch calls.flag(flags, flag) { + case 1: set.insert(word) + case 0: break + default: return nil + } + } + var id: id_t = 0 + var type: Int32 = -1 + let principal: AccessEntry.Principal + if mbr_uuid_to_id(qualifier.assumingMemoryBound(to: UInt8.self), &id, &type) == 0, type == ID_TYPE_UID { + principal = .user(id) + } else if type == ID_TYPE_GID { + principal = .group(id) + } else { + principal = .unknown + } + entries.append(AccessEntry( + allows: tag == ACL_EXTENDED_ALLOW, + principal: principal, + rights: AccessEntry.words(rights), + flags: set + )) + } + return entries + } +} + +/// What the checks read of one path's own extended access control list. +enum AccessList: Equatable, Sendable { + /// Its entries, in order; none when it has no list. + case entries([AccessEntry]) + /// The list could not be had at all: acl_get_link_np(3)'s errno. + case unreadable(Int32) + /// An entry, or one of its flags, could not be read, so the list is + /// not known whole. + case incomplete +} + +/// Where `SleepOffReceipts` reads each access control list. +enum AccessLists: Sendable { + /// acl(3), on each path itself (`SleepOffReceipts.accessList`). The + /// app's. + case native + /// What each read gives, with no real list read: the receipt's, and a + /// folder's by its path. A folder not named has no entries. For tests, + /// which cannot make root's files or give a file of their own the + /// entry install.sh adds without changing a real list. + case fixed(receipt: AccessList, folders: [String: AccessList]) + + func list(of path: String, receipt: Bool) -> AccessList { + switch self { + case .native: + SleepOffReceipts.accessList(path) + case let .fixed(file, folders): + receipt ? file : folders[path] ?? .entries([]) + } + } +} + +/// One entry of an extended access control list, as `SleepOffReceipts` +/// reads it: the words are chmod(1)'s and ls(1)'s. +struct AccessEntry: Equatable, Sendable { + enum Principal: Equatable, Sendable { + case user(uid_t) + case group(gid_t) + /// A UUID the directory does not map to a user or a group. + case unknown + } + + /// ACL_EXTENDED_ALLOW. false for a deny entry, or a tag that is + /// neither. + var allows: Bool + var principal: Principal + /// The rights it names. + var rights: Set + /// Its flags, `inherited` for an entry the file took from its folder. + var flags: Set + + /// The one entry install.sh adds to the receipt, with `chmod +a + /// "user: allow read"`. + static func installed(for user: uid_t) -> AccessEntry { + AccessEntry(allows: true, principal: .user(user), rights: ["read"], flags: []) + } + + /// For a message, about as `ls -le` prints it. + var text: String { + let who = switch principal { + case let .user(uid): "uid \(uid)" + case let .group(gid): "gid \(gid)" + case .unknown: "an unknown account" + } + let words = (rights.sorted() + flags.subtracting(["inherited"]).sorted()).joined(separator: ",") + return "\(who)\(flags.contains("inherited") ? " inherited" : "") \(allows ? "allow" : "deny") \(words)" + } + + /// Every right acl(3) defines, by its word. A file's `read` is the bit + /// a folder calls `list`, `write` is `add_file`, `execute` is `search` + /// and `append` is `add_subdirectory`. + static let rightWords: [(right: acl_perm_t, word: String)] = [ + (ACL_READ_DATA, "read"), (ACL_WRITE_DATA, "write"), (ACL_EXECUTE, "execute"), + (ACL_DELETE, "delete"), (ACL_APPEND_DATA, "append"), (ACL_DELETE_CHILD, "delete_child"), + (ACL_READ_ATTRIBUTES, "readattr"), (ACL_WRITE_ATTRIBUTES, "writeattr"), + (ACL_READ_EXTATTRIBUTES, "readextattr"), (ACL_WRITE_EXTATTRIBUTES, "writeextattr"), + (ACL_READ_SECURITY, "readsecurity"), (ACL_WRITE_SECURITY, "writesecurity"), + (ACL_CHANGE_OWNER, "chown"), (ACL_SYNCHRONIZE, "synchronize"), + ] + + /// The words for the rights in `mask`, an entry's whole permission set, + /// and the bits of any right not in `rightWords` in hex, so that a + /// right this list does not name still makes the entry differ from + /// `installed`. + static func words(_ mask: acl_permset_mask_t) -> Set { + var words = Set(rightWords.filter { mask & acl_permset_mask_t($0.right.rawValue) != 0 }.map(\.word)) + let named = rightWords.reduce(acl_permset_mask_t(0)) { $0 | acl_permset_mask_t($1.right.rawValue) } + if mask & ~named != 0 { + words.insert("0x" + String(mask & ~named, radix: 16)) + } + return words + } + + /// Every entry flag acl(3) defines, by its word. + static let flagWords: [(flag: acl_flag_t, word: String)] = [ + (ACL_ENTRY_INHERITED, "inherited"), (ACL_ENTRY_FILE_INHERIT, "file_inherit"), + (ACL_ENTRY_DIRECTORY_INHERIT, "directory_inherit"), (ACL_ENTRY_LIMIT_INHERIT, "limit_inherit"), + (ACL_ENTRY_ONLY_INHERIT, "only_inherit"), + ] +} diff --git a/Sources/Insomnia/Store/Store.swift b/Sources/Insomnia/Store/Store.swift index b598ec4e..99789ef9 100644 --- a/Sources/Insomnia/Store/Store.swift +++ b/Sources/Insomnia/Store/Store.swift @@ -8,9 +8,14 @@ import Foundation /// it is read. struct Store: Sendable { let paths: Paths + /// Runs before each read of session.json (`loadSession`) and fails it + /// by throwing, as an I/O error would. Nil in the app; tests use it for + /// a read that fails once and then succeeds. + let sessionReadCheck: (@Sendable () throws -> Void)? - init(paths: Paths) { + init(paths: Paths, sessionReadCheck: (@Sendable () throws -> Void)? = nil) { self.paths = paths + self.sessionReadCheck = sessionReadCheck } // MARK: Generic @@ -106,7 +111,14 @@ struct Store: Sendable { /// Atomic write: temp file + rename(2). func write(_ value: T, to url: URL) throws { - let data = try Store.makeEncoder().encode(value) + try writeAtomically(try Store.makeEncoder().encode(value), to: url) + } + + /// Returns the identity of the file now at `url`, taken from the temp + /// file before the rename (rename(2) keeps the inode), so nothing that + /// replaces `url` afterwards can lend it its own. + @discardableResult + private func writeAtomically(_ data: Data, to url: URL) throws -> FileIdentity { let dir = url.deletingLastPathComponent() if let problem = try OwnerOnly.createDirectory(dir) { OwnerOnly.reportOnce(problem) } let tmp = dir.appendingPathComponent(".\(url.lastPathComponent).tmp-\(UUID().uuidString)") @@ -116,11 +128,18 @@ struct Store: Sendable { try? FileManager.default.removeItem(at: tmp) throw error } + var info = stat() + guard lstat(tmp.path, &info) == 0 else { + let err = errno + try? FileManager.default.removeItem(at: tmp) + throw StoreError.open(path: tmp.path, errno: err) + } if rename(tmp.path, url.path) != 0 { let err = errno try? FileManager.default.removeItem(at: tmp) throw StoreError.rename(from: tmp.path, to: url.path, errno: err) } + return FileIdentity(info) } /// Removes the file; a missing file is not an error. @@ -132,13 +151,22 @@ struct Store: Sendable { // MARK: Typed helpers /// Throws StoreError.unreadable, with a one-line reason, when the file - /// does not decode. + /// does not decode, or when its extensions do not add up to a first + /// end it could hold (`SessionMath.firstEnd`): reconcile then moves it + /// aside as it moves any file that is not a session, before anything + /// does arithmetic with it. func loadSession() throws -> Session? { + try sessionReadCheck?() + let s: Session? do { - return try read(Session.self, from: paths.sessionFile) + s = try read(Session.self, from: paths.sessionFile) } catch let error as DecodingError { throw StoreError.unreadable(file: paths.sessionFile.path, detail: Self.brief(error)) } + if let s, SessionMath.firstEnd(of: s) == nil { + throw StoreError.unreadable(file: paths.sessionFile.path, detail: "its extensions do not add up to a first end between 1970 and 9999") + } + return s } func saveSession(_ s: Session) throws { try write(s, to: paths.sessionFile) } func deleteSession() throws { try remove(at: paths.sessionFile) } @@ -188,6 +216,97 @@ struct Store: Sendable { } func saveState(_ s: RuntimeState) throws { try write(s, to: paths.stateFile) } + /// The pending-start marker holds the nonce as plain bytes, no newline, + /// written atomically so the root command never reads half of it. + /// Returns the identity of the file written, for + /// `removePendingStart(expecting:)`. + @discardableResult + func savePendingStart(_ nonce: String) throws -> FileIdentity { + try writeAtomically(Data(nonce.utf8), to: paths.pendingStartFile) + } + + /// Deletes the pending-start marker under the marker's own lock. The + /// command the password dialog runs as root holds an flock(2) lock on + /// the marker from before its nonce check until pmset exits (see + /// AdministratorPrompt.markerLock), so taking that lock first means the + /// marker goes either before the check, which then fails, or after + /// pmset has exited, never in between. The link is followed to the + /// file, as lockf follows it, and the wait is polled so the caller is + /// never blocked. Returns what it deleted: the file, or a link to + /// nothing, which has none. nil when nothing was there. + /// + /// The lock belongs to the file, but everything else here goes by path. + /// Once the lock is held, the path must still name the locked file, or + /// the lock says nothing about what would be deleted: the file is let + /// go and looked up again. With `expecting`, the identity + /// `savePendingStart` returned, the locked file must also be the one + /// that start wrote. Something that replaced it (or deleted it without + /// the lock) may have done so after the root command locked the + /// original, and that command could still be running pmset, so nothing + /// is deleted. A path check alone cannot see that: the replacement is + /// unlocked, and it is what the path names. + /// + /// Throws `.markerBusy` when the lock is still held after `timeout`, + /// `.markerReplaced` when the file at the path is not the expected one + /// or kept changing until `timeout`, and `.unlink` when the file cannot + /// be deleted (an immutable flag, a deny-delete ACL, a directory in its + /// place). unlink(2) never removes a tree. The marker then stays, and + /// callers must treat the dialog it belongs to as still able to turn + /// sleep off. `onLocked` is for tests: it runs each time the lock is + /// taken, before the path is checked. + @discardableResult + func removePendingStart( + timeout: TimeInterval, + expecting written: FileIdentity? = nil, + pollEvery: Duration = .milliseconds(50), + onLocked: (() -> Void)? = nil + ) async throws -> RemovedMarker? { + let path = paths.pendingStartFile.path + let deadline = ContinuousClock.now + .seconds(timeout) + while true { + // O_NONBLOCK: a FIFO in its place must not hang the open. + let fd = open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC) + if fd < 0 { + let err = errno + guard err == ENOENT else { throw StoreError.open(path: path, errno: err) } + // The file that start wrote went without its lock. + if written != nil { throw StoreError.markerReplaced(path: path) } + // Missing, or a link to nothing, which lockf cannot open + // either; the link itself still goes. + return try Self.unlinkMarker(path) ? RemovedMarker(file: nil) : nil + } + let busy: Bool + if flock(fd, LOCK_EX | LOCK_NB) == 0 { + defer { close(fd) } + onLocked?() + let locked = FileIdentity(of: fd) + if let written, locked != written { throw StoreError.markerReplaced(path: path) } + // stat(2) follows a link, as open(2) and lockf do. + if let locked, locked == FileIdentity(atPath: path) { + return try Self.unlinkMarker(path) ? RemovedMarker(file: locked) : nil + } + busy = false + } else { + let err = errno + close(fd) + guard err == EWOULDBLOCK else { throw StoreError.lock(path: path, errno: err) } + busy = true + } + guard ContinuousClock.now < deadline else { + if busy { throw StoreError.markerBusy(path: path, seconds: timeout) } + throw StoreError.markerReplaced(path: path) + } + try await Task.sleep(for: pollEvery) + } + } + + private static func unlinkMarker(_ path: String) throws -> Bool { + if unlink(path) == 0 { return true } + let err = errno + if err == ENOENT { return false } + throw StoreError.unlink(path: path, errno: err) + } + func loadConfig() throws -> Config? { try read(Config.self, from: paths.configFile) } func saveConfig(_ c: Config) throws { try write(c, to: paths.configFile) } @@ -215,14 +334,66 @@ struct Store: Sendable { } } +/// A pending-start marker `Store.removePendingStart` deleted. Its content +/// is never read: anything running as the user can write it, so it shows +/// nothing about the command behind a dialog (see SleepOffReceipts). +struct RemovedMarker: Equatable, Sendable { + /// The file that was locked and deleted; nil for a link to nothing. + let file: FileIdentity? +} + +/// A file's device and inode: what flock(2) locks, whatever path led to it. +struct FileIdentity: Equatable, Sendable { + let device: dev_t + let inode: ino_t + + init(_ info: stat) { + device = info.st_dev + inode = info.st_ino + } + + /// The open file `fd`; nil if fstat(2) fails. + init?(of fd: Int32) { + var info = stat() + guard fstat(fd, &info) == 0 else { return nil } + self.init(info) + } + + /// The file `path` names now, following a link; nil if there is none. + init?(atPath path: String) { + var info = stat() + guard stat(path, &info) == 0 else { return nil } + self.init(info) + } + + /// "device:inode" as `stat -f '%d:%i'` prints it, the form the journal + /// keeps (SleepOffAttempt) and the scripts compare. + var text: String { "\(UInt32(bitPattern: device)):\(inode)" } +} + enum StoreError: Error, LocalizedError { case rename(from: String, to: String, errno: Int32) case corrupt(file: String, detail: String) + case unlink(path: String, errno: Int32) + case open(path: String, errno: Int32) + case lock(path: String, errno: Int32) + case markerBusy(path: String, seconds: TimeInterval) + case markerReplaced(path: String) case unreadable(file: String, detail: String) case notRegularFile(file: String) var errorDescription: String? { switch self { + case let .unlink(path, errno): + return "deleting \(path) failed: \(String(cString: strerror(errno)))" + case let .open(path, errno): + return "opening \(path) failed: \(String(cString: strerror(errno)))" + case let .lock(path, errno): + return "locking \(path) failed: \(String(cString: strerror(errno)))" + case let .markerBusy(path, seconds): + return "\(path) was still locked after \(String(format: "%g", seconds)) s by the command a password dialog started as root" + case let .markerReplaced(path): + return "\(path) was replaced or removed by something other than Insomnia, so its lock cannot show that the command a password dialog started as root is done with it" case let .rename(from, to, errno): return "rename \(from) -> \(to) failed: \(String(cString: strerror(errno)))" case let .corrupt(file, detail): diff --git a/Sources/Insomnia/System/AdministratorPrompt.swift b/Sources/Insomnia/System/AdministratorPrompt.swift new file mode 100644 index 00000000..1a4b754b --- /dev/null +++ b/Sources/Insomnia/System/AdministratorPrompt.swift @@ -0,0 +1,802 @@ +import Foundation + +/// Why the administrator password dialog did not turn sleep off. +enum AdministratorPromptError: Error, LocalizedError, Sendable { + /// The user dismissed the dialog. + case cancelled + /// No answer within the limit. osascript was sent SIGTERM, which takes + /// the dialog down with it, and it exited. + case timedOut(seconds: TimeInterval) + /// SIGTERM was sent at the deadline and `grace` seconds later the + /// prompt had still not finished: osascript is still running, or it + /// exited and a command it started as root still holds its output. + /// Nothing is killed. The caller keeps what it is protecting (the + /// recovery lock, session.json, the journal entry) until + /// `prompt.waitUntilExit()` returns, because the command may still turn + /// sleep off. + case stillRunning(UnfinishedPrompt, grace: TimeInterval) + /// osascript exited non-zero for another reason: the password was wrong + /// too many times, pmset itself failed (the root command's 1), or the + /// command ended some other way (a signal, a status it never uses). + /// The status alone cannot show whether any of these came after + /// `disablesleep 1`; the receipt can show that none did (see + /// `SleepOffReceipts` and `AdministratorPrompt.rootCommand`). osascript + /// exited by itself, so its dialog is over. + case failed(status: Int32, stderr: String) + /// A signal the runner did not send stopped osascript. A command it + /// had started as root may still be running, or about to start. + case interrupted(signal: Int32, stderr: String) + /// The password was accepted, but the root command could not confirm + /// that the user who pressed Start can turn sleep back on without a + /// password (exit 5): no usable uid came with it, /etc/sudo.conf + /// exists, /etc/pam.d/sudo has session lines other than macOS's own, + /// root could not switch to that user, or that user's sudo is not + /// 1.9.17p2 with only the sudoers plugins, did not list without a + /// password, listed a Defaults entry the check does not accept, or did + /// not show /etc/sudoers.d/insomnia's restore line as the matching + /// rule. Every one of these comes before the command writes anything. + case restoreNeedsPassword(stderr: String) + /// The root command stopped before it wrote anything, and its exit + /// status says why: the start was over (3); the session had ended, + /// before the sudo checks, during them or while `pmset -g` was read, + /// or the start had run out of time for an answer (4); `pmset -g` + /// showed a SleepDisabled 1 the start did not own, or could not be read + /// (6); the receipt was missing, unsafe or not the file the start + /// claimed, or could not be written, flushed and read back (7); or the + /// receipt no longer held the nonce the start claimed it from: another + /// start or a recovery came first (8). Or the receipt stayed locked for + /// 10 s (75), or lockf never started the command: the marker was gone + /// (69) or stayed locked for 10 s (75). `rootStatus` is that status, + /// which osascript ends its error line with; osascript itself exits 1. + case refused(rootStatus: Int32, stderr: String) + case launchFailed(String) + + /// Nothing the caller would undo is left in place: the dialog was + /// cancelled, osascript never started, or the root command stopped + /// with one of its refusals (`.restoreNeedsPassword`, `.refused`), all + /// of which come before the sleep write. An undo would add nothing but + /// a chance to clear a SleepDisabled 1 another tool set. Every other + /// failure may have left `disablesleep 1` in place, so the caller + /// undoes it like an end, unless the receipt shows that no command for + /// the start reached that write. `AdministratorPrompt.rootCommand` says + /// why a refusal keeps its status even when the dialog's output is gone. + var nothingToUndo: Bool { + switch self { + case .cancelled, .launchFailed, .restoreNeedsPassword, .refused: true + case .timedOut, .stillRunning, .failed, .interrupted: false + } + } + + /// The dialog has ended by itself: osascript exited on its own, after + /// the command it ran as root (if any) exited, and no other command + /// can start for that dialog. A timeout, a prompt left running and a + /// signal the runner did not send leave that open: a command may still + /// start, or hold the receipt, until the start's answer window ends. + var dialogOver: Bool { + switch self { + case .cancelled, .launchFailed, .restoreNeedsPassword, .refused, .failed: true + case .timedOut, .stillRunning, .interrupted: false + } + } + + var errorDescription: String? { + switch self { + case .cancelled: + return "the administrator password prompt was cancelled" + case let .timedOut(seconds): + return "the administrator password prompt was not answered within \(Int(seconds)) s" + case let .stillRunning(prompt, grace): + if prompt.osascriptAlive { + return "osascript (pid \(prompt.pid)) did not stop within \(Int(grace)) s of SIGTERM; it is left running, not killed" + } + return "osascript (pid \(prompt.pid)) exited after SIGTERM, but a command it started still holds its output; it is left running, not killed" + case let .failed(status, stderr): + let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return "the administrator password prompt failed (osascript exited \(status))" + (detail.isEmpty ? "" : ": \(detail)") + case let .interrupted(signal, stderr): + let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return "osascript, behind the administrator password prompt, was stopped by signal \(signal)" + (detail.isEmpty ? "" : ": \(detail)") + case let .restoreNeedsPassword(stderr): + let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return "sleep was not turned off: sudo did not confirm that `sudo -n /usr/bin/pmset -a disablesleep 0` runs for you without a password" + (detail.isEmpty ? "" : " (\(detail))") + ", so a session could not end without you. If /etc/sudoers.d/insomnia is missing or not in effect, run scripts/install.sh again. The check also stops on any sudo but 1.9.17p2, on an /etc/sudo.conf, on session lines in /etc/pam.d/sudo other than macOS's own, and on sudoers Defaults it does not accept. install.sh changes none of those, and the message above names the one found" + case let .refused(status, stderr): + let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return "Insomnia did not turn sleep off (the command behind the password dialog stopped with status \(status) before it changed anything)" + (detail.isEmpty ? "" : ": \(detail)") + case let .launchFailed(detail): + return "could not launch osascript for the administrator password prompt: \(detail)" + } + } +} + +/// A prompt the runner stopped waiting for. Two exits are tracked: osascript's +/// own (`waitUntilOsascriptExits()`), which is when its pid stops being ours +/// to name, and the whole prompt's (`waitUntilExit()`), once osascript has +/// been reaped and both its pipes have closed, which a root command it +/// started can delay. Nothing polls the pid, so a reused pid is never +/// mistaken for the child. Same shape as the handle the sudo pmset runner +/// hands out for a stuck `sudo pmset`. +final class UnfinishedPrompt: @unchecked Sendable, CustomStringConvertible { + /// osascript's pid. Only meaningful while `osascriptAlive`. + let pid: pid_t + + private let lock = NSLock() + private var osascriptRunning: Bool + private var exited = false + private var osascriptWaiters: [CheckedContinuation] = [] + private var waiters: [CheckedContinuation] = [] + + init(pid: pid_t, osascriptAlive: Bool) { + self.pid = pid + self.osascriptRunning = osascriptAlive + } + + var description: String { "osascript (pid \(pid))" } + + /// Whether osascript itself is still running. False means it has exited + /// and something it started (the root command behind the dialog) may + /// still hold its output; there is then no pid of ours to signal. + var osascriptAlive: Bool { lock.withLock { osascriptRunning } } + + var isRunning: Bool { lock.withLock { !exited } } + + /// Returns once osascript itself has exited, possibly before its output + /// closes. + func waitUntilOsascriptExits() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let done: Bool = lock.withLock { + if !osascriptRunning { return true } + osascriptWaiters.append(continuation) + return false + } + if done { continuation.resume() } + } + } + + func waitUntilExit() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let done: Bool = lock.withLock { + if exited { return true } + waiters.append(continuation) + return false + } + if done { continuation.resume() } + } + } + + /// Called by the runner when osascript has been reaped, whether or not + /// its output has closed. Tests call it in place of a child. + func markOsascriptExited() { + let waiting: [CheckedContinuation] = lock.withLock { + guard osascriptRunning else { return [] } + osascriptRunning = false + let w = osascriptWaiters + osascriptWaiters.removeAll() + return w + } + for waiter in waiting { waiter.resume() } + } + + /// Called by the runner once the child has exited and its output has + /// closed. Tests call it in place of a child. + func markExited() { + markOsascriptExited() + let waiting: [CheckedContinuation] = lock.withLock { + guard !exited else { return [] } + exited = true + let w = waiters + waiters.removeAll() + return w + } + for waiter in waiting { waiter.resume() } + } +} + +/// One Start's claim on the password dialog. Just before the dialog is +/// shown, the start transaction writes `nonce` (random, fresh per attempt) +/// to `marker`, and it deletes the file before it releases the recovery +/// lock. Everyone else who takes that lock (reconcile at launch, any later +/// transaction, backstop.sh, uninstall.sh) deletes it first: holding the +/// lock means no start is waiting on a dialog, so a dialog still on screen +/// was abandoned (the app crashed or was force-quit under it). The command +/// the dialog runs as root turns sleep off only while the file holds this +/// nonce, and holds a lock on the file until pmset exits; every deleter +/// takes that lock first. A late answer to an abandoned dialog therefore +/// cannot act for a newer start. Nor can an answer that comes at or after +/// `expires` (the session's end, or `AdministratorPrompt.answerWindow` +/// after the marker, if sooner) leave sleep off: the root command compares +/// it with the clock once it holds the receipt's lock, again after the +/// sudo checks, and again right before pmset. The command never writes the +/// marker: what it did is recorded in the root-owned receipt +/// (`SleepOffReceipts`), and only while the receipt still holds +/// `predecessor`. +struct PendingStart: Sendable, Equatable { + let marker: URL + let nonce: String + /// The session's end. + let deadline: Date + /// The journal already owned a SleepDisabled 1 before this start (an + /// earlier restore failed), the same `sleepDisabledByUs` Start's own + /// read is skipped for. Only then does the root command skip its reads + /// of `pmset -g`; by default it reads, and a 1 stops it. + var sleepOffIsOurs: Bool + /// The receipt's nonce when this start claimed it + /// (`SleepOffReceipts.claim`). + var predecessor: String + /// "device:inode" of the receipt this start claimed. + var receipt: String + /// From when the root command refuses. Set again when the marker is + /// written, and journaled with it. + var expires: Date + + init(marker: URL, nonce: String, deadline: Date, sleepOffIsOurs: Bool = false, predecessor: String = SleepOffReceipts.zero, receipt: String = "", expires: Date? = nil) { + self.marker = marker + self.nonce = nonce + self.deadline = deadline + self.sleepOffIsOurs = sleepOffIsOurs + self.predecessor = predecessor + self.receipt = receipt + self.expires = expires ?? deadline + } + + /// `deadline` in whole seconds since 1970, rounded down. session.json + /// holds the session's end the same way (ISO 8601 drops the fraction). + var deadlineSeconds: Int { Int(deadline.timeIntervalSince1970.rounded(.down)) } + + /// `expires` in whole seconds since 1970, rounded down, so a refusal + /// comes at most a second early, never late. + var expiresSeconds: Int { Int(expires.timeIntervalSince1970.rounded(.down)) } + + /// `expiresSeconds` as the root command's `$3`. + var expiresArgument: String { String(expiresSeconds) } + + /// `sleepOffIsOurs` as the root command's `$5`: `1` skips its reads, + /// anything else keeps them. + var ownershipArgument: String { sleepOffIsOurs ? "1" : "0" } +} + +/// The one privileged command Insomnia cannot run without a password: +/// `pmset -a disablesleep 1`, through the standard macOS administrator +/// dialog. The sudoers rule install.sh writes only covers turning sleep +/// back on and the battery Low Power Mode floor, so nothing running as the +/// user can keep the Mac awake unattended. Only an explicit Start by the +/// user may reach this; relaunch and reconcile read `pmset -g` instead. +protocol AdministratorPromptRunning: Sendable { + /// Returns once `pmset -a disablesleep 1` has run as root, which happens + /// only under the receipt's lock, while `start.marker` holds + /// `start.nonce` and the receipt holds `start.predecessor`, before + /// `start.expires`, once sudo has confirmed without running anything + /// that this user can run the restore without a password, while + /// `pmset -g` shows no SleepDisabled 1 (unless `start.sleepOffIsOurs`), + /// and after ` writing` is in the user's receipt, + /// flushed and read back. Throws an `AdministratorPromptError` when the + /// dialog was cancelled, the password was wrong, the root command + /// stopped before writing (`.refused`: the marker was gone or no longer + /// matched, the start had expired, someone else's SleepDisabled 1 was + /// found, the receipt was missing, unsafe, locked, claimed by another + /// start or could not take the record), sudo did not confirm the + /// restore (`.restoreNeedsPassword`), pmset failed, a signal stopped + /// osascript (`.interrupted`), nothing came back in time, or the + /// prompt's process would not stop (`.stillRunning`). + func disableSleep(_ start: PendingStart) async throws +} + +enum AdministratorPrompt { + /// The first half of what runs as root. `lockf` opens the marker + /// (`-n`: never creates it, and exits 69 when it is gone), takes an + /// flock(2) lock on it within 10 s (exit 75 otherwise), runs the rest + /// and holds the lock until that exits; `-k` leaves the file in place. + /// Everyone who deletes the marker takes the same lock first + /// (Store.removePendingStart, backstop.sh, uninstall.sh), so the + /// marker cannot go between the nonce check and the end of pmset: it + /// goes before the check, which then fails, or after pmset, whose + /// effect the journal entry the start wrote first still covers. + static let markerLock = "/usr/bin/lockf -k -n -t 10" + /// What runs as root under that lock, as `/bin/sh -c insomnia + /// `. + /// Fixed text: the marker path, the nonce, the time from which the + /// start takes no answer (`PendingStart.expires`), the uid of the user + /// who pressed Start, whether the journal already owned a SleepDisabled + /// 1, the nonce the start claimed the receipt from and the receipt's + /// "device:inode" arrive only as `$1` to `$7`, and the marker's and the + /// receipt's content are only compared, never run. Root changes two + /// things: the user's receipt, in a folder only root can change, and, + /// as its last step, `pmset -a disablesleep 1`. Every refusal comes + /// before the sleep write, so none leaves a change to the sleep + /// setting, and the start rolls back without running pmset. + /// + /// It first ignores SIGPIPE: a refusal printed to a dialog whose output + /// is gone then still exits with its own status instead of dying by the + /// signal, which lockf would report as 70. It sets `LC_ALL=C` for every + /// tool it runs, all by absolute path. Exit 5: `$4` is not a positive + /// whole number. Exit 7: it is not plain digits with no leading zero, + /// so it names no receipt. + /// + /// Then, before any other check, it takes the receipt's lock. It opens + /// /private/var/db/com.kgarg.insomnia/`$4` (`SleepOffReceipts`) + /// read-only as descriptor 8 (exit 7 when it cannot) and runs `lockf -s + /// -t 10 8`, which takes an exclusive flock(2) lock on that open file + /// and exits, leaving the lock with the shell's descriptor. A receipt + /// that stays locked for 10 s exits 75. The shell never closes + /// descriptor 8, and every tool it starts afterwards inherits it, pmset + /// included, so the lock lasts until the shell and pmset have both + /// exited; nothing in the command releases it earlier. The app, + /// backstop.sh and uninstall.sh take the same lock on the same file + /// before a start claims the receipt, before they read it to settle a + /// start and before uninstall.sh removes it, so each reads the receipt + /// before a command took the lock or after the command and its pmset + /// exited. + /// + /// Under the lock, one lstat-based `stat` of the receipt, its folder and + /// every folder above them up to / must show each one root's, with no + /// write permission for group or others, the receipt a regular file + /// with one link, 82 bytes and mode 600, and the rest folders. `ls -lde` + /// must show no access control entry that allows anything on the + /// folders, and `ls -le` exactly one on the receipt, `0: user: + /// allow read`, where `id -u ` exits 0 and prints `$4` (what it + /// prints before a nonzero exit counts for nothing): ls(1) prints + /// `inherited` after the name of an inherited entry, every right after `allow`, and + /// a UUID in place of `user:` for an account the directory cannot + /// name, so each of those fails. So no other account can open the + /// receipt and hold its lock. ls(1) never prints synchronize, prints the + /// rights and flags only folders use only for a folder, and skips an + /// entry it cannot read, so those pass here; the app's own check + /// (`SleepOffReceipts`) reads every entry, right and flag. A link anywhere on + /// that path fails the type check. Both the open descriptor + /// and the path must be the file `$7` names, the one the start claimed. + /// Each of these exits 7. Exit 3: the marker no longer holds the nonce + /// (the start was over before the password was accepted). Exit 4: the + /// clock (seconds since 1970) is not before `$3`, or `$3` is not a + /// number `[` can compare, which fails the test and so refuses too. + /// + /// Then it checks, without running anything, that the restore every end + /// and backstop.sh run, `sudo -n /usr/bin/pmset -a disablesleep 0`, runs + /// for that user without a password. A check that fails exits 5, with + /// nothing written. Root reads two files first: + /// + /// - /private/etc/sudo.conf must not exist in any form, a dangling link + /// included. Apple's sudo reads plugins from that one path, compiled + /// in, with no environment or command-line override, and without the + /// file it uses its built-in sudoers policy, I/O and audit plugins. + /// With the file it can load an approval plugin that prints no + /// version line, so `sudo -V` and both listings look the same, and + /// that plugin is consulted only when a command runs. macOS installs + /// no sudo.conf. + /// - /private/etc/pam.d/sudo must have exactly one line that mentions a + /// session, `session required pam_permit.so`, as macOS installs it. + /// sudo opens a PAM session only to run a command, never for a + /// listing, and a session module that failed would fail the restore. + /// + /// The rest ask sudo. Root drops to the user with `sudo -n -u "#$4"` and + /// starts the user's sudo with an empty environment but `LC_ALL=C` and + /// stdin from /dev/null. The answers must be the ones sudo 1.9.17p2 + /// (Apple's sudo-114.100.11) prints for the rule install.sh writes. Only + /// that source was read for what a listing shares with running a + /// command, so any other version fails: + /// + /// - `sudo -V` shows `Sudo version 1.9.17p2`, `Sudoers policy plugin + /// version 1.9.17p2`, `Sudoers file grammar version 50`, and at most + /// the sudoers I/O and audit plugins' own lines after them. + /// - `sudo -k -n -l` lists the user's rules without a password, and each + /// Defaults entry it shows for the user is one that cannot stop a + /// NOPASSWD rule from running: env_reset, env_keep, env_check and + /// env_delete, which only choose the environment pmset gets; + /// log_allowed and log_denied, which only decide what goes to syslog; + /// and lecture, lecture_file, passprompt, badpass_message, + /// passwd_timeout, passwd_tries, timestamp_timeout, timestamp_type, + /// tty_tickets, pwfeedback and insults, which sudo reads only when it + /// asks for a password. Any other entry fails, and the message names + /// it. So do Defaults bound to a Runas user or a command (the "Runas + /// and Command-specific defaults" header), which apply when the + /// restore runs but not to a listing, and a Defaults line with a + /// backslash or a tab in it: sudo does not escape a backslash inside + /// a quoted value, so such a line cannot be split back into entries + /// with certainty. The listing goes to a pipe, so sudo prints all the + /// user's Defaults on one line, unwrapped. + /// - `sudo -k -n -ll /usr/bin/pmset -a disablesleep 0` exits 0 and + /// prints exactly the six lines of the matching rule: `Sudoers entry:` + /// /private/etc/sudoers.d/insomnia (or /etc/sudoers.d/insomnia), + /// `RunAsUsers: root`, `Options: !authenticate` (NOPASSWD, and no + /// other option), `Commands:`, a tab and the restore line, and + /// `Matched:` with the restore line. sudo picks the last rule that + /// matches, as when the command runs, and prints it only when that + /// rule allows the command; a denial prints nothing. A rule from + /// another file, any other run-as list or option, a time limit, a + /// broader command, a path-only answer from an older sudo, a + /// truncated or extra line all fail. + /// + /// `-k` keeps a credential cached by a recent sudo in a terminal from + /// answering for the user, and `-n` fails instead of prompting, so a + /// `listpw` setting that wants a password refuses too. Nothing here can + /// change the power settings. + /// + /// The checks can take a while (sudo may look the user up in a + /// directory service), so the clock is compared with `$3` again after + /// them (exit 4). + /// + /// Then the receipt's line. `$2` and `$6` must be two different + /// uppercase UUIDs, and `$2` not the zero one (exit 7). The receipt must + /// still begin with `$6`, the nonce the start claimed it from (exit 8): + /// otherwise another start's command, or a newer claim, came first. + /// Then root reads `pmset -g` itself: another tool may have turned + /// sleep off while the password was typed. A SleepDisabled 1 (the first + /// `SleepDisabled` line with a value, as + /// `PmsetSleepGuard.parseSleepDisabled` reads it), or a `pmset -g` that + /// fails, exits 6 with nothing written, and that tool's setting stays. + /// `$5` is `1` only when the journal already owned a 1 before this start + /// (an earlier restore failed); that 1 is Insomnia's own, so nothing is + /// read, as at Start. + /// + /// Only then does root write the receipt: ` + /// writing` and a newline, 82 bytes over the file's 82. /usr/bin/perl, + /// with an empty environment and descriptor 8 as its input, opens the + /// path for writing without creating, truncating or following a link, + /// checks that the descriptor it got is the locked file (same device + /// and inode as its input), writes the line with one write(2) that must + /// write all 82 bytes, calls fcntl(F_FULLFSYNC) (51), closes the file, + /// opens it again and reads the line back. fcntl(2) describes + /// F_FULLFSYNC as fsync(2) followed by a request to the drive to flush + /// its own cache, and notes that some drives ignore that request. Any + /// failure, a missing perl included, tries to write ` + /// refused` the same way and exits 7. + /// + /// Every `refused` goes through `no`, which runs only on a branch that + /// has already decided to exit without the sleep write, while the + /// command still holds the receipt's lock. When the write, flush or + /// read-back fails, `no` waits a second and tries again, at most three + /// tries in all, and starts a try only while fewer than 3 seconds of + /// `/bin/date` have passed since the first failure: a clock it cannot + /// read allows no retry. Each try is the same perl program, so it + /// checks the locked inode, writes the whole line, flushes it with + /// F_FULLFSYNC and reads it back. When every try fails, the message + /// says so, and the exit status stays the branch's own (7, 6 or 4). + /// The limit counts tries and time between them, not one try: a + /// write or flush the drive stalls holds the command, and the + /// receipt's lock, for as long as it stalls. Storage that stays broken + /// leaves the line as it was, and a reader that never gets the exit + /// status may still undo the start like an end (below). Then root reads + /// `pmset -g` once more, as before, unless `$5` is `1`: the write and + /// its flush can take a while, and another tool may have turned sleep + /// off meanwhile. A SleepDisabled 1, or a read that fails, writes + /// `refused` the same way and exits 6, and that tool's setting stays. + /// The clock is compared once more (exit 4, after `refused` is + /// written), and the last step is `pmset -a disablesleep 1`. A session that ends in the + /// moment between that comparison and the write ends at once, because + /// the deadline timer the start arms next fires immediately for a date + /// in the past. + /// + /// So a start's own line, `refused`, shows that its command never + /// turned sleep off, and so does another start's line that names the + /// same predecessor: that start wrote first, and this start's command + /// cannot write once the receipt no longer holds the predecessor. + /// Once the start's dialog is over, or `$3` has passed, a receipt that + /// still holds the predecessor shows it too: a command that took the + /// lock later refuses with 4 before writing. Every reader that settles + /// a start the journal still records (the next transaction, + /// backstop.sh, uninstall.sh) reads that under the lock, and then puts + /// the journal back without an undo, which would clear a SleepDisabled + /// 1 another tool set meanwhile (SessionManager). The start's own + /// `writing`, a line it cannot place, and a receipt it cannot lock as + /// the file the start claimed once `$3` has passed are undone like an + /// end. Before then a receipt that still holds the predecessor, or that + /// is locked, decides nothing: the start stays journaled, Starts stay + /// refused, and the next transaction or recovery reads it again. + /// + /// What this cannot do: pmset has one SleepDisabled setting and no + /// compare-and-set, so a 1 another tool sets between root's last read + /// of 0 and its write cannot be told from Insomnia's own, and the + /// session's end sets it to 0, as it does for a 1 another tool sets + /// during a session. The second read only moves that window past the + /// receipt's write and flush; it is not a lock on the setting. A + /// failure after `writing` is undone even when it came before the + /// sleep write. So is a second read that refused but could not write + /// `refused` over `writing` in any of its tries, when the app never gets its exit 6 (it + /// died), or gets it but cannot journal the rollback before it quits + /// or backstop.sh or uninstall.sh settles the start: every reader but + /// that app then sees `writing`, undoes the start like an end, and + /// clears the 1 the second read found. The receipt, the journal and pmset's own + /// setting are separate files, and nothing makes the three change + /// together: F_FULLFSYNC only puts the receipt's line on the drive + /// before pmset runs, on a drive that honours it. The clock is the + /// wall clock, as session.json's end is: a clock set back after a + /// settlement lets a dialog left on screen pass the comparison with + /// `$3` again, though only while the marker still holds its nonce. The + /// marker's lock and the receipt's lock are advisory, so anything + /// running as root can still change either file while a command holds + /// it; the receipt then shows nothing about that command, and the + /// start is undone like an end. A listing is not the restore: a rule + /// removed, a sudo.conf or PAM change, or a host name or group + /// membership that changes which Defaults apply, after the check, can + /// still make a later restore fail, and backstop.sh then keeps the + /// journal entry and retries. pmset is not `exec`ed, so its own exit + /// status can never read as 3 to 8. + static let rootCommand = ##"trap '' PIPE; LC_ALL=C; export LC_ALL; if ! [ "$4" -gt 0 ] 2>/dev/null; then echo "no user id came with the password, so sudo could not be asked whether sleep can be turned back on without one; sleep was not turned off" >&2; exit 5; fi; bad() { echo "$1; sleep was not turned off" >&2; exit 7; }; case $4 in *[!0-9]*|0*) bad "the user id $4 is not plain digits, so it names no receipt";; esac; k=/private/var/db/com.kgarg.insomnia; f=$k/$4; { exec 8<"$f"; } 2>/dev/null || bad "$f could not be opened. Run install.sh again"; /usr/bin/lockf -s -t 10 8 || { echo "$f stayed locked for 10 s by another Insomnia start or recovery; sleep was not turned off" >&2; exit 75; }; d=; n=1; p=$k; while [ -n "$p" ]; do d="$d $p"; n=$((n + 1)); p=${p%/*}; done; d="$d /"; n=$((n + 1)); t=$(/usr/bin/stat -f '%u %Lp %l %z %HT' $f $d 2>/dev/null) && printf '%s\n' "$t" | /usr/bin/awk -v o=0 -v n=$n 'NR == 1 { k = NF == 6 && $5 == "Regular" && $6 == "File" && $3 == 1 && $4 == 82 && $2 == 600 }; NR > 1 { k = k && NF == 5 && $5 == "Directory" }; { k = k && ($1 == 0 || $1 == o) && $2 !~ /[2367].?$/ }; END { exit !(k && NR == n) }' && l=$(/bin/ls -lde $d 2>/dev/null) && printf '%s\n' "$l" | /usr/bin/awk '$1 ~ /^[0-9]+:$/ && / allow / { f = 1 }; END { exit f }' && l=$(/bin/ls -le $f 2>/dev/null) && e=$(printf '%s\n' "$l" | /usr/bin/awk 'NR == 1 { k = /^-/ }; NR == 2 && k && /^ 0: user:[^ :]+ allow read$/ { n = substr($2, 6) }; END { if (NR == 2) print n }') && [ -n "$e" ] && x=$(/usr/bin/id -u -- "$e" 2>/dev/null) && [ "$x" = "$4" ] || bad "$f is missing, is not the 82-byte file install.sh made, mode 600 with one access control entry that lets uid $4 read it and nothing else, or someone other than root can change it or a folder above it. Run install.sh again"; i=$(/usr/bin/stat -f '%d:%i' <&8 2>/dev/null) && [ "$i" = "$7" ] && [ "$i" = "$(/usr/bin/stat -f '%d:%i' "$f" 2>/dev/null)" ] || bad "$f is not the file it was when this start began. Run install.sh again"; m=$(/usr/bin/head -c 64 "$1" 2>/dev/null); if [ -z "$2" ] || [ "$m" != "$2" ]; then echo "the start that asked for this password is over; sleep was not turned off" >&2; exit 3; fi; late() { ! [ "$(/bin/date +%s)" -lt "$1" ] 2>/dev/null; }; if late "$3"; then echo "this password came after the start that asked for it had timed out, or after its session ended; sleep was not turned off" >&2; exit 4; fi; c=/private/etc/sudo.conf; if [ -e "$c" ] || [ -L "$c" ]; then echo "/etc/sudo.conf exists. sudo -V does not list every plugin that file can load, and an approval plugin can show nothing there and still refuse the restore, so this check works only with sudo's built-in plugins and no /etc/sudo.conf; sleep was not turned off" >&2; exit 5; fi; /usr/bin/awk '$1 !~ /^#/ && tolower($0) ~ /session/ { n++; k = $1 == "session" && $2 == "required" && $3 == "pam_permit.so" && NF == 3 }; END { exit !(n == 1 && k) }' /private/etc/pam.d/sudo 2>/dev/null || { echo "/etc/pam.d/sudo could not be read, or its session lines are not macOS's own single session required pam_permit.so. sudo runs them for the restore but not for a listing, so this check cannot tell whether the restore runs; sleep was not turned off" >&2; exit 5; }; w=$4; u() { /usr/bin/sudo -n -u "#$w" /usr/bin/env -i LC_ALL=C "$@" = 3) }' || { echo "sudo -V, run as this user, failed or does not show sudo 1.9.17p2 with only the sudoers plugins. This check follows how sudo 1.9.17p2 lists and runs commands, and another version needs an Insomnia release checked against it; sleep was not turned off" >&2; exit 5; }; l=$(q -k -n -l) || { echo "sudo -k -n -l did not list this user's sudoers rules without a password; sleep was not turned off" >&2; exit 5; }; d=$(printf %s "$l" | /usr/bin/awk 'function ok(e, n, o) { o = ""; n = e; if (match(n, /[-+]?=/)) { o = substr(n, RSTART, RLENGTH); n = substr(n, 1, RSTART - 1) }; if (n ~ /^!/) return (o == "" && index(" env_reset env_keep env_check env_delete lecture lecture_file log_allowed log_denied passprompt badpass_message passwd_timeout passwd_tries timestamp_timeout timestamp_type tty_tickets pwfeedback insults ", " " substr(n, 2) " ") > 0); if (o == "") return (index(" env_reset lecture log_allowed log_denied tty_tickets pwfeedback insults ", " " n " ") > 0); if (o != "=") return (index(" env_keep env_check env_delete ", " " n " ") > 0); return (index(" env_keep env_check env_delete lecture lecture_file passprompt badpass_message passwd_timeout passwd_tries timestamp_timeout timestamp_type ", " " n " ") > 0) }; NR == 1 && index($0, "Matching Defaults entries for ") == 1 { s = 1; next }; s == 1 { s = 2; t = $0; if (substr(t, 1, 4) != " " || index(t, sprintf("%c", 92)) || index(t, sprintf("%c", 9))) { print "a backslash, a tab or a layout it cannot read"; f = 1; exit }; t = substr(t, 5); while (1) { if (!match(t, /^!?[a-z_]+([-+]?=("[^"]*"|[^ ",:=#]*))?/)) { print substr(t, 1, 80); f = 1; exit }; e = substr(t, 1, RLENGTH); t = substr(t, RLENGTH + 1); if (!ok(e)) { print substr(e, 1, 80); f = 1; exit }; if (t == "") break; if (substr(t, 1, 2) != ", ") { print substr(t, 1, 80); f = 1; exit }; t = substr(t, 3) }; next }; s == 2 { if ($0 != "") { print "a layout it cannot read"; f = 1; exit }; s = 3; next }; index($0, "Runas and Command-specific defaults for ") == 1 { print "Defaults bound to a Runas user or a command, which apply to the restore but not to a listing"; f = 1; exit }; index($0, "Matching Defaults entries for ") == 1 { print "a layout it cannot read"; f = 1; exit }; END { exit f || s == 1 || s == 2 }') || { echo "sudo -k -n -l shows a Defaults entry this check does not accept: ${d:-output it cannot read}. Settings like that can make the restore fail where a listing does not; sleep was not turned off" >&2; exit 5; }; r=$(q -k -n -ll /usr/bin/pmset -a disablesleep 0) && printf %s "$r" | /usr/bin/awk 'BEGIN { c = "/usr/bin/pmset -a disablesleep 0" }; NR == 1 { k = $0 == "Sudoers entry: /private/etc/sudoers.d/insomnia" || $0 == "Sudoers entry: /etc/sudoers.d/insomnia" }; NR == 2 { k = k && $0 == " RunAsUsers: root" }; NR == 3 { k = k && $0 == " Options: !authenticate" }; NR == 4 { k = k && $0 == " Commands:" }; NR == 5 { k = k && $0 == sprintf("%c", 9) c }; NR == 6 { k = k && $0 == " Matched: " c }; END { exit !(k && NR == 6) }' || { echo "sudo -k -n -ll does not show the rule in /etc/sudoers.d/insomnia that lets this user turn sleep back on as root without a password; sleep was not turned off" >&2; exit 5; }; if late "$3"; then echo "the start this password was for timed out, or its session ended, while sudo was asked about the restore; sleep was not turned off" >&2; exit 4; fi; case $2$6 in *[!0-9A-F-]*) bad "the nonces are not uppercase UUIDs, so they cannot go in the receipt";; esac; [ ${#2} -eq 36 ] && [ ${#6} -eq 36 ] && [ "$2" != "$6" ] && [ "$2" != 00000000-0000-0000-0000-000000000000 ] || bad "the nonces are not two different uppercase UUIDs, so they cannot go in the receipt"; h=$(/usr/bin/head -c 36 <&8 2>/dev/null); if [ "$h" != "$6" ]; then echo "$f changed after this start read it: another start or a recovery came first; sleep was not turned off" >&2; exit 8; fi; foreign() { [ "$1" != 1 ] && { s=$(/usr/bin/pmset -g) || return 0; [ "$(printf %s "$s" | /usr/bin/awk '$1 == "SleepDisabled" && NF > 1 { print $2; exit }')" = 1 ]; }; }; if foreign "$5"; then echo "pmset -g shows a SleepDisabled 1 this start did not set, or could not be read; it was left alone and sleep was not turned off" >&2; exit 6; fi; put() { /usr/bin/env -i /usr/bin/perl -e 'my ($f, $l) = @ARGV; $l .= chr 10; sysopen(my $h, $f, 257) or exit 1; my @w = stat $h; my @g = stat STDIN; exit 1 unless @w && @g && $w[0] == $g[0] && $w[1] == $g[1]; my $n = syswrite $h, $l; exit 1 unless defined $n && $n == length $l; fcntl($h, 51, 0) or exit 1; close $h or exit 1; sysopen(my $r, $f, 256) or exit 1; my $s = q(); my $k = sysread $r, $s, 128; exit 1 unless defined $k && $s eq $l; exit 0' "$f" "$2 $3 $1" <&8; }; no() { o=; put refused "$1" "$2" && return; a=$(/bin/date +%s); j=1; [ "$a" -gt 0 ] 2>/dev/null && while [ $j -lt 3 ] && /bin/sleep 1 && [ "$(/bin/date +%s)" -lt $((a + 3)) ] 2>/dev/null; do j=$((j + 1)); put refused "$1" "$2" && return; done; o="; its refused line could not be written either (attempts: $j), so a recovery that never gets this exit status may undo the start like an end"; return 1; }; put writing "$2" "$6" || { no "$2" "$6"; echo "$f could not be written, flushed to the drive with F_FULLFSYNC and read back with this start's nonce; sleep was not turned off$o" >&2; exit 7; }; if foreign "$5"; then no "$2" "$6"; echo "pmset -g, read again once this start's record was written, shows a SleepDisabled 1 this start did not set, or could not be read; it was left alone and sleep was not turned off$o" >&2; exit 6; fi; if late "$3"; then no "$2" "$6"; echo "the start this password was for timed out, or its session ended, while pmset -g was read; sleep was not turned off$o" >&2; exit 4; fi; /usr/bin/pmset -a disablesleep 1 || exit 1"## + /// The whole AppleScript, as one literal: `markerLock`, `rootCommand` + /// (each `"` escaped for AppleScript), the privilege flag and the + /// dialog text are fixed at compile time. Its only inputs are the + /// marker path, the nonce, the time the start stops taking answers, the + /// uid, the ownership flag, the predecessor nonce and the receipt's + /// "device:inode", `item 1` to `item 7 of argv`, and each reaches the + /// root shell + /// through `quoted form of`, as lockf's file and as positional + /// parameters. No configuration value or environment variable reaches + /// the command that runs as root. + static let disableSleepScript = #""" + on run argv + do shell script "/usr/bin/lockf -k -n -t 10 " & quoted form of (item 1 of argv) & " /bin/sh -c " & quoted form of "trap '' PIPE; LC_ALL=C; export LC_ALL; if ! [ \"$4\" -gt 0 ] 2>/dev/null; then echo \"no user id came with the password, so sudo could not be asked whether sleep can be turned back on without one; sleep was not turned off\" >&2; exit 5; fi; bad() { echo \"$1; sleep was not turned off\" >&2; exit 7; }; case $4 in *[!0-9]*|0*) bad \"the user id $4 is not plain digits, so it names no receipt\";; esac; k=/private/var/db/com.kgarg.insomnia; f=$k/$4; { exec 8<\"$f\"; } 2>/dev/null || bad \"$f could not be opened. Run install.sh again\"; /usr/bin/lockf -s -t 10 8 || { echo \"$f stayed locked for 10 s by another Insomnia start or recovery; sleep was not turned off\" >&2; exit 75; }; d=; n=1; p=$k; while [ -n \"$p\" ]; do d=\"$d $p\"; n=$((n + 1)); p=${p%/*}; done; d=\"$d /\"; n=$((n + 1)); t=$(/usr/bin/stat -f '%u %Lp %l %z %HT' $f $d 2>/dev/null) && printf '%s\\n' \"$t\" | /usr/bin/awk -v o=0 -v n=$n 'NR == 1 { k = NF == 6 && $5 == \"Regular\" && $6 == \"File\" && $3 == 1 && $4 == 82 && $2 == 600 }; NR > 1 { k = k && NF == 5 && $5 == \"Directory\" }; { k = k && ($1 == 0 || $1 == o) && $2 !~ /[2367].?$/ }; END { exit !(k && NR == n) }' && l=$(/bin/ls -lde $d 2>/dev/null) && printf '%s\\n' \"$l\" | /usr/bin/awk '$1 ~ /^[0-9]+:$/ && / allow / { f = 1 }; END { exit f }' && l=$(/bin/ls -le $f 2>/dev/null) && e=$(printf '%s\\n' \"$l\" | /usr/bin/awk 'NR == 1 { k = /^-/ }; NR == 2 && k && /^ 0: user:[^ :]+ allow read$/ { n = substr($2, 6) }; END { if (NR == 2) print n }') && [ -n \"$e\" ] && x=$(/usr/bin/id -u -- \"$e\" 2>/dev/null) && [ \"$x\" = \"$4\" ] || bad \"$f is missing, is not the 82-byte file install.sh made, mode 600 with one access control entry that lets uid $4 read it and nothing else, or someone other than root can change it or a folder above it. Run install.sh again\"; i=$(/usr/bin/stat -f '%d:%i' <&8 2>/dev/null) && [ \"$i\" = \"$7\" ] && [ \"$i\" = \"$(/usr/bin/stat -f '%d:%i' \"$f\" 2>/dev/null)\" ] || bad \"$f is not the file it was when this start began. Run install.sh again\"; m=$(/usr/bin/head -c 64 \"$1\" 2>/dev/null); if [ -z \"$2\" ] || [ \"$m\" != \"$2\" ]; then echo \"the start that asked for this password is over; sleep was not turned off\" >&2; exit 3; fi; late() { ! [ \"$(/bin/date +%s)\" -lt \"$1\" ] 2>/dev/null; }; if late \"$3\"; then echo \"this password came after the start that asked for it had timed out, or after its session ended; sleep was not turned off\" >&2; exit 4; fi; c=/private/etc/sudo.conf; if [ -e \"$c\" ] || [ -L \"$c\" ]; then echo \"/etc/sudo.conf exists. sudo -V does not list every plugin that file can load, and an approval plugin can show nothing there and still refuse the restore, so this check works only with sudo's built-in plugins and no /etc/sudo.conf; sleep was not turned off\" >&2; exit 5; fi; /usr/bin/awk '$1 !~ /^#/ && tolower($0) ~ /session/ { n++; k = $1 == \"session\" && $2 == \"required\" && $3 == \"pam_permit.so\" && NF == 3 }; END { exit !(n == 1 && k) }' /private/etc/pam.d/sudo 2>/dev/null || { echo \"/etc/pam.d/sudo could not be read, or its session lines are not macOS's own single session required pam_permit.so. sudo runs them for the restore but not for a listing, so this check cannot tell whether the restore runs; sleep was not turned off\" >&2; exit 5; }; w=$4; u() { /usr/bin/sudo -n -u \"#$w\" /usr/bin/env -i LC_ALL=C \"$@\" = 3) }' || { echo \"sudo -V, run as this user, failed or does not show sudo 1.9.17p2 with only the sudoers plugins. This check follows how sudo 1.9.17p2 lists and runs commands, and another version needs an Insomnia release checked against it; sleep was not turned off\" >&2; exit 5; }; l=$(q -k -n -l) || { echo \"sudo -k -n -l did not list this user's sudoers rules without a password; sleep was not turned off\" >&2; exit 5; }; d=$(printf %s \"$l\" | /usr/bin/awk 'function ok(e, n, o) { o = \"\"; n = e; if (match(n, /[-+]?=/)) { o = substr(n, RSTART, RLENGTH); n = substr(n, 1, RSTART - 1) }; if (n ~ /^!/) return (o == \"\" && index(\" env_reset env_keep env_check env_delete lecture lecture_file log_allowed log_denied passprompt badpass_message passwd_timeout passwd_tries timestamp_timeout timestamp_type tty_tickets pwfeedback insults \", \" \" substr(n, 2) \" \") > 0); if (o == \"\") return (index(\" env_reset lecture log_allowed log_denied tty_tickets pwfeedback insults \", \" \" n \" \") > 0); if (o != \"=\") return (index(\" env_keep env_check env_delete \", \" \" n \" \") > 0); return (index(\" env_keep env_check env_delete lecture lecture_file passprompt badpass_message passwd_timeout passwd_tries timestamp_timeout timestamp_type \", \" \" n \" \") > 0) }; NR == 1 && index($0, \"Matching Defaults entries for \") == 1 { s = 1; next }; s == 1 { s = 2; t = $0; if (substr(t, 1, 4) != \" \" || index(t, sprintf(\"%c\", 92)) || index(t, sprintf(\"%c\", 9))) { print \"a backslash, a tab or a layout it cannot read\"; f = 1; exit }; t = substr(t, 5); while (1) { if (!match(t, /^!?[a-z_]+([-+]?=(\"[^\"]*\"|[^ \",:=#]*))?/)) { print substr(t, 1, 80); f = 1; exit }; e = substr(t, 1, RLENGTH); t = substr(t, RLENGTH + 1); if (!ok(e)) { print substr(e, 1, 80); f = 1; exit }; if (t == \"\") break; if (substr(t, 1, 2) != \", \") { print substr(t, 1, 80); f = 1; exit }; t = substr(t, 3) }; next }; s == 2 { if ($0 != \"\") { print \"a layout it cannot read\"; f = 1; exit }; s = 3; next }; index($0, \"Runas and Command-specific defaults for \") == 1 { print \"Defaults bound to a Runas user or a command, which apply to the restore but not to a listing\"; f = 1; exit }; index($0, \"Matching Defaults entries for \") == 1 { print \"a layout it cannot read\"; f = 1; exit }; END { exit f || s == 1 || s == 2 }') || { echo \"sudo -k -n -l shows a Defaults entry this check does not accept: ${d:-output it cannot read}. Settings like that can make the restore fail where a listing does not; sleep was not turned off\" >&2; exit 5; }; r=$(q -k -n -ll /usr/bin/pmset -a disablesleep 0) && printf %s \"$r\" | /usr/bin/awk 'BEGIN { c = \"/usr/bin/pmset -a disablesleep 0\" }; NR == 1 { k = $0 == \"Sudoers entry: /private/etc/sudoers.d/insomnia\" || $0 == \"Sudoers entry: /etc/sudoers.d/insomnia\" }; NR == 2 { k = k && $0 == \" RunAsUsers: root\" }; NR == 3 { k = k && $0 == \" Options: !authenticate\" }; NR == 4 { k = k && $0 == \" Commands:\" }; NR == 5 { k = k && $0 == sprintf(\"%c\", 9) c }; NR == 6 { k = k && $0 == \" Matched: \" c }; END { exit !(k && NR == 6) }' || { echo \"sudo -k -n -ll does not show the rule in /etc/sudoers.d/insomnia that lets this user turn sleep back on as root without a password; sleep was not turned off\" >&2; exit 5; }; if late \"$3\"; then echo \"the start this password was for timed out, or its session ended, while sudo was asked about the restore; sleep was not turned off\" >&2; exit 4; fi; case $2$6 in *[!0-9A-F-]*) bad \"the nonces are not uppercase UUIDs, so they cannot go in the receipt\";; esac; [ ${#2} -eq 36 ] && [ ${#6} -eq 36 ] && [ \"$2\" != \"$6\" ] && [ \"$2\" != 00000000-0000-0000-0000-000000000000 ] || bad \"the nonces are not two different uppercase UUIDs, so they cannot go in the receipt\"; h=$(/usr/bin/head -c 36 <&8 2>/dev/null); if [ \"$h\" != \"$6\" ]; then echo \"$f changed after this start read it: another start or a recovery came first; sleep was not turned off\" >&2; exit 8; fi; foreign() { [ \"$1\" != 1 ] && { s=$(/usr/bin/pmset -g) || return 0; [ \"$(printf %s \"$s\" | /usr/bin/awk '$1 == \"SleepDisabled\" && NF > 1 { print $2; exit }')\" = 1 ]; }; }; if foreign \"$5\"; then echo \"pmset -g shows a SleepDisabled 1 this start did not set, or could not be read; it was left alone and sleep was not turned off\" >&2; exit 6; fi; put() { /usr/bin/env -i /usr/bin/perl -e 'my ($f, $l) = @ARGV; $l .= chr 10; sysopen(my $h, $f, 257) or exit 1; my @w = stat $h; my @g = stat STDIN; exit 1 unless @w && @g && $w[0] == $g[0] && $w[1] == $g[1]; my $n = syswrite $h, $l; exit 1 unless defined $n && $n == length $l; fcntl($h, 51, 0) or exit 1; close $h or exit 1; sysopen(my $r, $f, 256) or exit 1; my $s = q(); my $k = sysread $r, $s, 128; exit 1 unless defined $k && $s eq $l; exit 0' \"$f\" \"$2 $3 $1\" <&8; }; no() { o=; put refused \"$1\" \"$2\" && return; a=$(/bin/date +%s); j=1; [ \"$a\" -gt 0 ] 2>/dev/null && while [ $j -lt 3 ] && /bin/sleep 1 && [ \"$(/bin/date +%s)\" -lt $((a + 3)) ] 2>/dev/null; do j=$((j + 1)); put refused \"$1\" \"$2\" && return; done; o=\"; its refused line could not be written either (attempts: $j), so a recovery that never gets this exit status may undo the start like an end\"; return 1; }; put writing \"$2\" \"$6\" || { no \"$2\" \"$6\"; echo \"$f could not be written, flushed to the drive with F_FULLFSYNC and read back with this start's nonce; sleep was not turned off$o\" >&2; exit 7; }; if foreign \"$5\"; then no \"$2\" \"$6\"; echo \"pmset -g, read again once this start's record was written, shows a SleepDisabled 1 this start did not set, or could not be read; it was left alone and sleep was not turned off$o\" >&2; exit 6; fi; if late \"$3\"; then no \"$2\" \"$6\"; echo \"the start this password was for timed out, or its session ended, while pmset -g was read; sleep was not turned off$o\" >&2; exit 4; fi; /usr/bin/pmset -a disablesleep 1 || exit 1" & " insomnia " & quoted form of (item 1 of argv) & " " & quoted form of (item 2 of argv) & " " & quoted form of (item 3 of argv) & " " & quoted form of (item 4 of argv) & " " & quoted form of (item 5 of argv) & " " & quoted form of (item 6 of argv) & " " & quoted form of (item 7 of argv) with administrator privileges with prompt "Insomnia needs your password to turn off system sleep for this session." + end run + """# + /// The user is typing a password, so the limit is generous. At the + /// deadline osascript gets SIGTERM, and the start is rolled back. + static let timeout: TimeInterval = 120 + /// How long after SIGTERM the runner keeps waiting before it answers + /// the caller with `.stillRunning`. The same 3 s as KILL_GRACE_SECONDS + /// in scripts/backstop.sh. + static let stopGrace: TimeInterval = 3 + /// How long after the marker is written the root command still takes an + /// answer (`PendingStart.expires`): the dialog's own limit, the grace + /// after SIGTERM, and 7 s for osascript to start. A dialog that is still + /// on screen after that (SIGTERM did not stop it) gets exit 4 from the + /// command. A crashed or timed-out start keeps Starts refused at least + /// this long. Its settlement then decides only once it can lock the + /// receipt and finish: a root query or pmset still running, another + /// start or recovery holding the receipt's lock, or a claim or journal + /// that cannot be cleaned up keeps Starts refused longer, with no + /// limit. + static let answerWindow: TimeInterval = timeout + stopGrace + 7 +} + +/// `/usr/bin/osascript -e