diff --git a/CLAUDE.md b/CLAUDE.md index 0ea1b94d..68041b58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,7 @@ To create an installable ZIP: `cd` to the repo root and run `zip -r digitizer-si | `Aura_Worker_Redact` | `includes/class-aura-worker-redact.php` | Agent read redaction (2.18.0, #419): detectors (`redact`, `redact_text`), audience (`is_audience`), the `rest_pre_echo_response` read seam (`filter_echo`), the `rest_request_before_callbacks` placeholder guard and unredacted-grant check (`before_callbacks`, `grant_shape`), counters, `status_fragment` | | `Aura_Worker_Redact_Decode` | `includes/class-aura-worker-redact-decode.php` | Redaction stage 2 (2.18.2, #113), pure: `decode_layers()` (the raw run, then every pass whose result changed, up to `MAX_DECODE_PASSES` layers of HTML5 character references, `%XX` and JSON escapes — `\\`, `\/`, `\uXXXX`; null past the check pass or the `MAX_DECODE_GROWTH` bound), `decode_run()` (its last layer), `decode_pass()`, `html5_code_point()`, `utf8()` | | `Aura_Worker_Redact_Idna` | `includes/class-aura-worker-redact-idna.php` | Redaction stage 2 view 2 (2.18.3, #116), pure, GENERATED — `map()` applies Unicode 18.0.0's UTS-46 hostname mappings and deletions with one `strtr()`; regenerate with `php bin/generate-idna-map.php` (pinned URL + SHA-256), then run the tests; to move to a new Unicode version, bump `IDNA_VERSION`, `IDNA_URL` and `IDNA_SHA256` in the script, regenerate, then update the three counts AND the MAP content pin in `RedactIdnaTest` | +| `Aura_Worker_Install_Ledger` | `includes/class-aura-worker-install-ledger.php` | The install ledger (2.22.0, Aura spec 2026-09-21 §4): observes `upgrader_package_options` (per-run token in `hook_extra`), `upgrader_pre_download` (`PHP_INT_MAX`: source + context under the token) and `upgrader_install_package_result` (the entry) for plugin/theme runs only; records and never decides. Ring of 200 entries / 90 days in `aura_worker_install_ledger` (+ `_state`), not autoloaded, network options on multisite; `report()` is `audit_agent_code`'s `installs`. `as_siteagent()` wraps the updater's four upgrader calls. `auto_update` = `doing_action( 'wp_maybe_auto_update' )`, never the skin (EMCP Pro installs with `Automatic_Upgrader_Skin`) | ### Initialization Flow diff --git a/digitizer-site-worker/digitizer-site-worker.php b/digitizer-site-worker/digitizer-site-worker.php index 3b65b812..9d0fa248 100644 --- a/digitizer-site-worker/digitizer-site-worker.php +++ b/digitizer-site-worker/digitizer-site-worker.php @@ -88,6 +88,7 @@ function () { require_once AURA_WORKER_DIR . 'includes/class-aura-worker-abilities.php'; require_once AURA_WORKER_DIR . 'includes/class-aura-worker-magic-link.php'; require_once AURA_WORKER_DIR . 'includes/class-aura-worker-unbind.php'; +require_once AURA_WORKER_DIR . 'includes/class-aura-worker-install-ledger.php'; /** * Initialize the plugin. @@ -117,6 +118,11 @@ function aura_worker_maybe_upgrade() { if ( get_option( 'aura_worker_version' ) === AURA_WORKER_VERSION ) { return; } + // The install ledger's coverage starts here (2.22.0, Ruling R3): once per + // version change, a fresh install included — never on every request. + if ( class_exists( 'Aura_Worker_Install_Ledger' ) ) { + Aura_Worker_Install_Ledger::ensure_started(); + } if ( class_exists( 'Aura_Worker_Rules' ) && ! Aura_Worker_Rules::backfill_from_stored_envelope() ) { return; // the NEXT request retries; the marker stays behind on purpose } @@ -129,6 +135,39 @@ function aura_worker_maybe_upgrade() { * Activation hook. */ function aura_worker_activate( $network_wide = false ) { + // The install ledger's coverage restarts here, once per ACTIVATION CALL — + // not once per site (Codex r1 on #139, install ledger 2.22.0): the ledger + // is a single NETWORK option even on a network-wide activation, so a call + // inside the per-site loop restarted that one shared ledger once per site, + // reporting `evicted: true` (coverage "moved") on every site after the + // first — even on a fresh network-wide install where nothing had + // happened yet. This hook fires exactly once per activation, whether + // network-wide or on a single site, which is the one moment a + // deactivate -> reactivate cycle should actually move coverage: the + // plugin observed nothing while inactive, and leaving `started` unmoved + // would have the ledger claim coverage over that gap. Guarded and + // swallowed on both sides — activation must never fail because of the + // ledger. + // + // BEFORE the per-site loop below, not after (Codex r2 on #139): the loop's + // own aura_worker_activate_site() calls aura_worker_maybe_upgrade(), which + // calls Aura_Worker_Install_Ledger::ensure_started() for a site with no + // ledger option yet. Restarting coverage AFTER the loop would then find + // that fresh ledger already sitting on disk and treat it as an existing + // ring being reactivated — marking a brand-new install `evicted: true` + // before it ever recorded anything. Running first means a genuinely fresh + // site still has no storage at all: restart_coverage() takes the + // ensure_started() path itself (`evicted: false`), and the loop's later + // ensure_started() call is then a no-op because the ledger already + // exists. A true reactivation still sees its ring already on disk and + // moves coverage to a boundary exactly as before. + if ( class_exists( 'Aura_Worker_Install_Ledger' ) ) { + try { + Aura_Worker_Install_Ledger::restart_coverage(); + } catch ( \Throwable $e ) { + // Activation must still complete. + } + } aura_worker_for_each_site( 'aura_worker_activate_site', (bool) $network_wide ); } register_activation_hook( __FILE__, 'aura_worker_activate' ); @@ -169,6 +208,17 @@ function aura_worker_activate_site() { error_log( 'SiteAgent: an Aura Application Password left over from a failed deactivation could not be revoked; revoke it by hand in Users → Profile → Application Passwords.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } Aura_Worker_Magic_Link::release_site( $aura_fence ); + // The install ledger's coverage restart lives in aura_worker_activate() + // itself, BEFORE this function ever runs (Codex r1/r2 on #139): the + // ledger is a single NETWORK option even on a network-wide activation, so + // restarting it once PER SITE from this function restarted that one + // shared ledger once per site — reporting `evicted: true` on every site + // after the first, even on a fresh network-wide install where nothing had + // happened yet. aura_worker_maybe_upgrade() below still calls + // ensure_started(), but by the time it runs the caller has already + // restarted coverage once for this whole activation, so a fresh site's + // ensure_started() call here is a no-op and a reactivated site's ring is + // untouched. // Through the SAME decision as a request-time upgrade (round-1 P2). A site // updated while the plugin was inactive reaches this hook with the marker // still behind and `plugins_loaded` already past: stamping the version diff --git a/digitizer-site-worker/includes/class-aura-worker-install-ledger.php b/digitizer-site-worker/includes/class-aura-worker-install-ledger.php new file mode 100644 index 00000000..4aea2cf9 --- /dev/null +++ b/digitizer-site-worker/includes/class-aura-worker-install-ledger.php @@ -0,0 +1,1273 @@ + $upgrader, + 'package' => $package, + 'action' => $action, + ); + try { + return $work(); + } finally { + array_pop( self::$siteagent_claims ); + } + } + + /** + * Append one entry: newest first, then retention — 90 days, then 200 + * entries. Read-modify-write without a lock: two installs finishing in + * the same instant can lose one entry (spec §4.3, stated, not fixed). + * + * @param array $entry The entry (spec §4.2). + * @return bool Whether the entry was valid and reached commit() — false + * when valid_entry() rejected it and nothing was written. + * A caller recording an in-scope install must treat a false + * return the same as a thrown exception (Codex review round + * 1, Task 2): the install happened and could not be + * recorded, so coverage moves to a boundary. + */ + public static function append( array $entry ) { + $now = self::now(); + $state = self::read_state(); + $stored = self::read_entries(); + if ( self::storage_corrupt( $stored, $state, $now ) ) { + // Corrupted storage: coverage restarts HERE — a recovery boundary, + // never the old `started` over a ring that no longer holds what that + // date promises (Codex r1 on #595). Readable orphaned rows are kept + // (Codex r10); anything unreadable is dropped. + $orphan = self::orphan_ring( $stored, $state, $now ); + $state = self::fresh_state( $now ); + $state['evicted'] = true; + $stored = $orphan ? $stored : array(); + } elseif ( ! is_array( $state ) ) { + $state = self::fresh_state( $now ); + } + $entries = is_array( $stored ) ? array_values( $stored ) : array(); + // Newest first is an invariant (entries_corrupt()), so a clock that + // stepped backward must not break it: the new row takes the head's + // stamp instead (Codex r15 on #595) — but only when the entry's OWN + // `when` parses. An unparseable one must reach valid_entry() below + // unmasked, or a malformed entry would look clamp-repaired and slip + // past it (controller ruling 2, Task 1 review round 1). The clamped + // row's true order is still right — it happened after the head. + if ( isset( $entries[0]['when'], $entry['when'] ) ) { + $entry_when = strtotime( (string) $entry['when'] ); + if ( false !== $entry_when && $entry_when < strtotime( (string) $entries[0]['when'] ) ) { + $entry['when'] = $entries[0]['when']; + } + } + // RECORDS AND NEVER DECIDES cuts both ways: an entry this class did + // not itself shape is not stored either. Unvalidated, a malformed row + // would make the NEXT read call the whole ring unreadable, and the + // read-modify-write after that would wipe it (controller ruling 2, + // Task 1 review round 1) — silently, on an append that has nothing to + // do with the bad row. Reject it here instead: nothing is written. + if ( ! self::valid_entry( $entry, $now ) ) { + return false; + } + array_unshift( $entries, $entry ); + + // The count edge already on disk marks rows an earlier — possibly + // interrupted — rotation already dropped (controller ruling 1, Task 1 + // review round 1): expired() ages them out here exactly like a + // 90-day-old row, rather than storage_corrupt() calling their mere + // presence corruption. + $edge = self::edge_timestamp( $state ); + + $kept = array(); + foreach ( $entries as $e ) { + if ( self::expired( $e, $now, $edge ) ) { + $state['evicted'] = true; + continue; + } + $kept[] = $e; + } + if ( count( $kept ) > self::MAX_ENTRIES ) { + $kept = array_slice( $kept, 0, self::MAX_ENTRIES ); + $oldest = $kept[ self::MAX_ENTRIES - 1 ]; + $state['count_edge'] = is_array( $oldest ) && isset( $oldest['when'] ) ? (string) $oldest['when'] : null; + $state['evicted'] = true; + } + // State FIRST (Codex r2 on #595): the two writes are separately + // visible, and a read between them must only ever under-claim — the + // new edge over the old, longer ring, never the old edge over a ring + // that has already lost an entry. (That in-between state — the new + // edge landed, the old ring still on disk — is exactly the reading + // report() now gives it: readable, the pre-edge row dropped like any + // other expired one, never `ledger_unreadable`; controller ruling 1, + // Task 1 review round 1.) And only once the state is PROVEN stored + // (Codex r5 on #595): a refused state write followed by a landed + // entries write would truncate the ring under the old edge. Losing + // this one entry is the stated cost of a failed write. + self::commit( $state, $kept, $now ); + return true; + } + + /** + * The `installs` subtree of audit_agent_code (spec §4.3). Purges rows past + * 90 days first — retention is physical, the rows hold personal data — and + * still leaves them out of the answer should that write fail. + * + * On multisite, checked BEFORE any storage read (final review Task 2): + * this class always stores network-wide (read_entries()/read_state()), so + * when SiteAgent is active on this site but not network-activated, that + * one ledger cannot see what every OTHER site's own (non-network) copy of + * this plugin is installing — the network option only ever holds what + * ran through THIS site's requests. A single site never checks (Ruling, + * final review Task 2, binding). + * + * @return array { since, entries, total, evicted } | { error } + */ + public static function report() { + if ( is_multisite() && ! (bool) self::probe()['network_active'] ) { + return array( 'error' => 'ledger_partial_network' ); + } + self::purge_rows(); + $now = self::now(); + $stored = self::read_entries(); + $state = self::read_state(); + if ( self::storage_corrupt( $stored, $state, $now ) ) { + return array( 'error' => 'ledger_unreadable' ); + } + $evicted = is_array( $state ) && ! empty( $state['evicted'] ); + $edge = self::edge_timestamp( $state ); + $entries = array(); + foreach ( is_array( $stored ) ? $stored : array() as $e ) { + if ( self::expired( $e, $now, $edge ) ) { + $evicted = true; + continue; + } + $entries[] = $e; + } + $since = $now - self::MAX_AGE; + if ( is_array( $state ) ) { + $started = isset( $state['started'] ) ? strtotime( (string) $state['started'] ) : false; + $since = max( $since, false === $started ? $now : $started ); + $edge = isset( $state['count_edge'] ) ? strtotime( (string) $state['count_edge'] ) : false; + if ( false !== $edge ) { + $since = max( $since, $edge ); + } + } else { + $since = $now; // never stamped: nothing was observed before now (Ruling R3) + } + return array( + 'since' => gmdate( 'c', $since ), + 'entries' => $entries, + 'total' => count( $entries ), + 'evicted' => $evicted, + ); + } + + /** + * The daily retention pass, on core's `wp_scheduled_delete` (spec §4.2: + * personal data kept at most 90 days — Codex r3/r5 on #595). Like core's + * own trash retention on the same event, it runs when WP-Cron (or the + * site's system cron) does; report() and every append() also age rows + * out, so a row past 90 days is deleted at the next of those three + * (spec §4.2, stated — Codex r12). Deletes rows + * past 90 days, and — unlike a read — also RECOVERS corrupted storage: an + * unreadable ledger may still hold personal data no expiry can reach, so + * it is deleted behind a recovery boundary (coverage restarts now, + * `evicted: true`). Until that pass, report() names the corruption. + * Takes no argument: do_action() passes '' to a callback with none. + */ + public static function purge_expired() { + try { + $now = self::now(); + $stored = self::read_entries(); + $state = self::read_state(); + if ( self::storage_corrupt( $stored, $state, $now ) ) { + $orphan = self::orphan_ring( $stored, $state, $now ); + $fresh = self::fresh_state( $now ); + $fresh['evicted'] = true; + // An orphaned ring keeps its rows (Codex r10 on #595); purge_rows() + // then ages them out as usual. + self::commit( $fresh, $orphan ? array_values( $stored ) : array(), $now ); + if ( ! $orphan ) { + return; + } + } + self::purge_rows(); + } catch ( \Throwable $e ) { + // A failed purge is retried by the next read or the next day. + } + } + + /** + * Delete rows past 90 days from READABLE storage — the part of the daily + * pass report() also runs. Corrupted storage is left for purge_expired(). + * State first, and only once it is proven stored, like append(). + */ + private static function purge_rows() { + try { + $now = self::now(); + $stored = self::read_entries(); + $state = self::read_state(); + if ( null === $stored || self::storage_corrupt( $stored, $state, $now ) ) { + return; + } + $edge = self::edge_timestamp( $state ); + $kept = array(); + foreach ( $stored as $e ) { + if ( ! self::expired( $e, $now, $edge ) ) { + $kept[] = $e; + } + } + if ( count( $kept ) === count( $stored ) ) { + return; + } + $state = is_array( $state ) ? $state : self::fresh_state( $now ); + $state['evicted'] = true; + self::commit( $state, $kept, $now ); + } catch ( \Throwable $e ) { + // Retried by the next read or the daily pass. + } + } + + /** + * Stamp `started` the first time — called from aura_worker_maybe_upgrade() + * (Ruling R3). Never moves an existing stamp. + */ + public static function ensure_started() { + // A ring without a state is an ORPHAN, not a new site: never + // overwritten here — the daily pass recovers it (Codex r10 on #595). + if ( null !== self::read_state() || null !== self::read_entries() ) { + return; + } + // The ring is created WITH the state (Codex r8 on #595): from here on + // a state without a ring means the rows disappeared, never "none yet". + self::commit( self::fresh_state( self::now() ), array(), self::now() ); + } + + /** + * Restart coverage on (re)activation — called from + * aura_worker_activate_site() for every site (final review Task 1). + * Deactivate → reactivate must not leave `started` unchanged: the plugin + * observed nothing while inactive, and an unmoved `started` would have + * report() claim coverage over that gap. + * + * A genuinely fresh site (no state, no ring at all) is not a + * reactivation — ensure_started() itself is correct there, `evicted: + * false`. Everything else moves coverage to a boundary at NOW, exactly + * like an install that could not be recorded, keeping whatever ring is on + * disk. The one shape this must never produce is a state with no ring at + * all (storage_corrupt() treats that as corruption, Codex r8 on #595): a + * state alone is repaired by committing an empty ring alongside the + * boundary rather than writing the state in isolation. + */ + public static function restart_coverage() { + try { + $ring = self::read_entries(); + if ( null === self::read_state() && null === $ring ) { + self::ensure_started(); + return; + } + if ( null === $ring ) { + $now = self::now(); + $state = self::fresh_state( $now ); + $state['evicted'] = true; + self::commit( $state, array(), $now ); + return; + } + self::write_boundary( self::now() ); // the ring already on disk is left untouched + } catch ( \Throwable $e ) { + // Reactivation must not fail because of the ledger. + } + } + + /** @return int */ + public static function now() { + $p = self::probe(); + return (int) $p['now']; + } + + /** + * Clip to STRING_MAX characters, multibyte-safe (audit_agent_code's clip()). + * + * @param mixed $s Value. + * @return string + */ + public static function clip( $s ) { + $s = (string) $s; + if ( function_exists( 'mb_substr' ) ) { + return mb_substr( $s, 0, self::STRING_MAX ); + } + return preg_match( '/^.{0,' . (int) self::STRING_MAX . '}/us', $s, $m ) ? $m[0] : ''; + } + + /** + * Characters, counted the way clip() cuts them — mb_strlen(), else the + * same /u regex clip() falls back to (Codex r12 on #595): a byte count + * would reject a 101-character Hebrew name clip() itself wrote. + * + * @param string $s Value. + * @return int + */ + private static function char_len( $s ) { + if ( function_exists( 'mb_strlen' ) ) { + return mb_strlen( $s ); + } + $n = preg_match_all( '/./us', $s ); + return false === $n ? PHP_INT_MAX : $n; + } + + /** + * Test seam (Ruling R2): override any subset of probe()'s facts. + * + * @param array $overrides Fact => value. + */ + public static function _set_probe_for_tests( array $overrides ) { + self::$probe_overrides = $overrides; + } + + /** + * Test seam: how many pre-download frames are still held, this request + * (Codex review round 1, Task 2) — a failed or ignored run must not leave + * one behind for the rest of a long bulk request. + * + * @return int + */ + public static function _frame_count_for_tests() { + return count( self::$frames ); + } + + /** Clear every static (sa_reset_state()). */ + public static function reset_for_tests() { + self::$probe_overrides = array(); + self::$frames = array(); + self::$siteagent_claims = array(); + } + + /** + * Every environment fact this class reads, in one place (Ruling R2). + * + * @return array + */ + private static function probe() { + $real = array( + 'now' => time(), + 'siteagent' => null, // null = decided by the run's token (context()); tests may force a bool + 'wp_cli' => defined( 'WP_CLI' ) && WP_CLI, + 'auto_update' => function_exists( 'doing_action' ) && doing_action( self::AUTO_UPDATE_ACTION ), + 'cron' => function_exists( 'wp_doing_cron' ) && wp_doing_cron(), + 'rest' => class_exists( 'Aura_Worker_Rules' ) && Aura_Worker_Rules::serving_rest(), + 'admin' => function_exists( 'is_admin' ) && is_admin(), + 'rest_cookie' => isset( $GLOBALS['wp_rest_auth_cookie'] ) && true === $GLOBALS['wp_rest_auth_cookie'], + 'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0, + 'app_password_uuid' => self::authenticated_uuid(), + 'route' => class_exists( 'Aura_Worker_Call_Context' ) ? Aura_Worker_Call_Context::rest_route() : null, + 'uploads' => self::uploads_dir(), + 'attachment_ids' => null, + 'versions' => null, + // report()'s multisite gate (final review Task 2): computed lazily + // — non-multisite never checks, so a single site never pays for + // requiring wp-admin/includes/plugin.php. Overridable in tests so + // they never depend on WP's real plugin-activation internals. + 'network_active' => is_multisite() ? self::network_active_now() : true, + ); + return array_merge( $real, self::$probe_overrides ); + } + + /** + * Whether SiteAgent is network-activated (spec, final review Task 2). + * Only ever consulted on multisite. A fact this class cannot resolve + * (the constant or the function missing) answers true — unable to prove + * partial coverage is not the same as proving it, and this call must + * never itself break a read. + * + * @return bool + */ + private static function network_active_now() { + if ( ! function_exists( 'is_plugin_active_for_network' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + if ( ! function_exists( 'is_plugin_active_for_network' ) || ! defined( 'AURA_WORKER_FILE' ) ) { + return true; + } + return (bool) is_plugin_active_for_network( plugin_basename( AURA_WORKER_FILE ) ); + } + + /** Register the three filters (spec §4.1). */ + public static function init() { + add_filter( 'upgrader_package_options', array( __CLASS__, 'on_package_options' ), PHP_INT_MAX, 1 ); + add_filter( 'upgrader_pre_download', array( __CLASS__, 'on_pre_download' ), PHP_INT_MAX, 4 ); + add_filter( 'upgrader_install_package_result', array( __CLASS__, 'on_install_result' ), PHP_INT_MAX, 2 ); + // Retention is physical (Codex r3 on #595): core's daily event, always scheduled. + add_action( 'wp_scheduled_delete', array( __CLASS__, 'purge_expired' ) ); + } + + /** + * Stamp a per-run token into hook_extra — plugin and theme runs only. + * + * @param mixed $options WP_Upgrader::run() options. + * @return mixed The options, with only the token added. + */ + public static function on_package_options( $options ) { + try { + if ( is_array( $options ) && isset( $options['hook_extra'] ) && is_array( $options['hook_extra'] ) && null !== self::type_of( $options['hook_extra'] ) ) { + $options['hook_extra'][ self::TOKEN_KEY ] = wp_generate_uuid4(); + } + } catch ( \Throwable $e ) { + // Recording never breaks an upgrade. + } + return $options; + } + + /** + * Classify the package and capture the request context, under the token. + * + * A run that stops here — download_url() itself fails, or anything after + * this filter throws before WordPress ever calls + * upgrader_install_package_result — leaves its frame in $frames with no + * on_install_result() call to clear it. That is not a leak: $frames is a + * static, so it lives only for THIS request and is gone when the request + * ends, and every token is a fresh wp_generate_uuid4() (on_package_options()), + * so a stray frame from an earlier aborted run in the same request can + * never be mistaken for a different one's. + * + * @param mixed $reply An earlier filter's answer: false, a local file, or a WP_Error. + * @param mixed $package The package WordPress would download. + * @param mixed $upgrader Compared by identity with SiteAgent's own (as_siteagent()); its skin is never read (Ruling R1). + * @param mixed $hook_extra The run's hook_extra. + * @return mixed $reply, unchanged. + */ + public static function on_pre_download( $reply, $package, $upgrader = null, $hook_extra = array() ) { + try { + if ( is_wp_error( $reply ) ) { + return $reply; // the run aborts before anything is downloaded — nothing to record + } + $type = self::type_of( $hook_extra ); + $token = self::token_of( $hook_extra ); + if ( null === $type || null === $token ) { + return $reply; + } + $claim = self::matching_claim( $upgrader ); + self::$frames[ $token ] = array( + 'type' => $type, + // A non-false reply is the file that will be installed, and its + // origin is not ours to know (Codex r2 on #594) — a non-false + // reply still means unknown even when the run carries a claimed + // package. A claimed run whose claim carries its own original + // package classifies THAT instead of $package (Codex r1 on + // #139): a verified self-update swaps the URL for a local temp + // file before calling install(), and that temp path is never + // the real source. + 'source' => false === $reply + ? self::classify_source( null !== $claim && null !== $claim['package'] ? $claim['package'] : $package ) + : array( 'kind' => 'unknown' ), + 'context' => self::context( null !== $claim ), + // The claim's action override, taken here — not re-derived at + // on_install_result() from $upgrader, which is not passed to + // that filter (Codex r2 on #139): a verified self-update + // installs OVER the existing plugin through + // Plugin_Upgrader::install(), whose hook_extra says `action: + // install` and names no plugin, so without this override every + // self-update would be recorded as an install. + 'action' => null !== $claim ? $claim['action'] : null, + ); + } catch ( \Throwable $e ) { + // Recording never breaks an upgrade. + } + return $reply; + } + + /** + * Write the entry for a package that installed. + * + * A frame this call never sees — because on_pre_download() never staked + * one for this token, or a run stopped before reaching here at all — is + * simply gone: $frames lives only for this request, and tokens are unique + * per run (wp_generate_uuid4() in on_package_options()), so there is never + * a stale frame from a PREVIOUS run to mistake for this one's. + * + * @param mixed $result install_package()'s result, or a WP_Error. + * @param mixed $hook_extra The run's hook_extra. + * @return mixed $result, unchanged. + */ + public static function on_install_result( $result, $hook_extra = array() ) { + try { + $token = self::token_of( $hook_extra ); + if ( is_wp_error( $result ) || ! is_array( $result ) ) { + // A failed install: nothing to record, and the frame this token + // staked at pre-download must not outlive it — left in place, it + // would leak for the rest of a long bulk request (Codex review + // round 1, Task 2). + if ( null !== $token ) { + unset( self::$frames[ $token ] ); + } + return $result; + } + $type = self::type_of( $hook_extra ); + if ( null === $type ) { + return $result; + } + $in_scope = true; // a plugin/theme package installed: from here a failure loses an install + $frame = null; + if ( null !== $token && isset( self::$frames[ $token ] ) ) { + $frame = self::$frames[ $token ]; + unset( self::$frames[ $token ] ); + } + $slug = isset( $result['destination_name'] ) ? (string) $result['destination_name'] : ''; + $destination = isset( $result['destination'] ) ? (string) $result['destination'] : ''; + // The frame's claim action wins over hook_extra when set (Codex r2 + // on #139): a verified self-update installs OVER the existing + // plugin through Plugin_Upgrader::install(), whose hook_extra says + // `action: install` and names no plugin — install() itself has no + // notion of "this replaces something already here". An unclaimed + // run, or a claimed run with no override, is classified exactly as + // before. + $action = null !== $frame && null !== $frame['action'] ? $frame['action'] : self::action_of( $hook_extra ); + $recorded = self::append( + array_merge( + array( + 'when' => gmdate( 'c', self::now() ), + 'type' => $type, + 'action' => $action, + 'slug' => self::clip( $slug ), + 'version' => self::installed_version( $type, $destination, $hook_extra, $slug ), + ), + null === $frame ? self::context( false ) : $frame['context'], // no frame: the upgrader was never seen, so never siteagent + array( 'source' => null === $frame ? array( 'kind' => 'unknown' ) : $frame['source'] ) + ) + ); + if ( ! $recorded ) { + // append() itself rejected the entry (valid_entry() failed) — + // no exception was thrown, so this is not caught below. The + // install still happened and still could not be recorded, so + // coverage moves to a boundary the same as a thrown probe + // would (Codex review round 1, Task 2). + self::mark_boundary(); + } + } catch ( \Throwable $e ) { + // Recording never breaks an upgrade — but an install that could not + // be recorded moves coverage to NOW (Codex r10 on #595), so the + // ledger never claims the stretch that just lost it. + if ( ! empty( $in_scope ) ) { + self::mark_boundary(); + } + } + return $result; + } + + /** + * `plugin` | `theme` | null (spec §4.1 scope). + * + * @param mixed $hook_extra hook_extra. + * @return string|null + */ + public static function type_of( $hook_extra ) { + if ( ! is_array( $hook_extra ) ) { + return null; + } + $type = isset( $hook_extra['type'] ) ? $hook_extra['type'] : null; + if ( 'plugin' === $type || 'theme' === $type ) { + return $type; + } + if ( ! empty( $hook_extra['plugin'] ) ) { + return 'plugin'; + } + if ( ! empty( $hook_extra['theme'] ) ) { + return 'theme'; + } + return null; + } + + /** + * Where the package comes from (spec §4.2 `source`). + * + * @param mixed $package Package URL or path. + * @return array + */ + public static function classify_source( $package ) { + if ( ! is_string( $package ) || '' === $package ) { + return array( 'kind' => 'unknown' ); + } + if ( preg_match( '#^https?://#i', $package ) ) { + $host = strtolower( (string) wp_parse_url( $package, PHP_URL_HOST ) ); + if ( '' === $host ) { + return array( 'kind' => 'unknown' ); + } + if ( 'downloads.wordpress.org' === $host ) { + return array( 'kind' => 'wporg' ); + } + return array( 'kind' => 'remote_host', 'host' => self::clip( $host ) ); + } + if ( preg_match( '#^[a-z][a-z0-9+.-]*://#i', $package ) ) { + return array( 'kind' => 'unknown' ); // ftp://, phar://, … — not a kind the spec names + } + $p = self::probe(); + $uploads = $p['uploads']; + $path = str_replace( '\\', '/', $package ); + // A traversal segment is never resolved as though it were safely + // under uploads — `/../` (or a leading `../`) always answers + // local_path, whatever the string looks like it starts with (final + // review Task 5). + if ( preg_match( '#(?:^|/)\.\.(?:/|$)#', $path ) ) { + return array( 'kind' => 'local_path' ); + } + if ( is_array( $uploads ) && ! empty( $uploads['basedir'] ) ) { + $base = rtrim( str_replace( '\\', '/', (string) $uploads['basedir'] ), '/' ) . '/'; + if ( 0 === strpos( $path, $base ) ) { + $out = array( 'kind' => 'uploaded_zip' ); + $id = self::attachment_id( rtrim( (string) ( $uploads['baseurl'] ?? '' ), '/' ) . '/' . substr( $path, strlen( $base ) ) ); + if ( $id > 0 ) { + $out['attachment_id'] = $id; + } + return $out; + } + } + return array( 'kind' => 'local_path' ); + } + + /** + * The request context, read now (spec §4.2). + * + * @return array { transport, user_id, auth, app_password_name, route } + */ + public static function context( $siteagent_run = false ) { + $p = self::probe(); + $user = (int) $p['user_id']; + $uuid = (string) $p['app_password_uuid']; + $sa = null !== $p['siteagent'] ? (bool) $p['siteagent'] : (bool) $siteagent_run; + + if ( $sa ) { + $transport = 'siteagent'; + } elseif ( $p['wp_cli'] ) { + $transport = 'wp_cli'; + } elseif ( $p['auto_update'] ) { + $transport = 'auto_update'; + } elseif ( $p['cron'] ) { + $transport = 'cron'; + } elseif ( $p['rest'] ) { + $transport = 'rest'; + } elseif ( $p['admin'] && $user > 0 ) { + $transport = 'wp_admin'; + } else { + $transport = 'unknown'; + } + + if ( '' !== $uuid ) { + $auth = 'application_password'; + } elseif ( $user <= 0 ) { + $auth = 'none'; + } elseif ( $p['rest'] ) { + $auth = $p['rest_cookie'] ? 'cookie' : 'unknown'; + } elseif ( $p['admin'] ) { + $auth = 'cookie'; // an admin screen authenticates only by cookie + } else { + $auth = 'unknown'; + } + + $route = null; + if ( $p['rest'] && is_string( $p['route'] ) && '' !== $p['route'] ) { + $route = self::clip( explode( '?', $p['route'], 2 )[0] ); // never strtok(): it shares global tokenizer state + } + + return array( + 'transport' => $transport, + 'user_id' => $user, + 'auth' => $auth, + 'app_password_name' => '' === $uuid ? null : self::app_password_name( $user, $uuid ), + 'route' => $route, + ); + } + + /** + * Coverage restarts NOW (`evicted: true`); the ring is kept. For an + * install that happened but could not be recorded. Swallows its own + * failure — the upgrade must still complete. + */ + private static function mark_boundary() { + try { + self::write_boundary( self::now() ); // proven and retried once (Codex r14 on #595) + } catch ( \Throwable $e ) { + // Nothing more can be done from inside an upgrader filter. + } + } + + /** + * The claim one of SiteAgent's own active as_siteagent() calls made for + * $upgrader (Task 3), if any — object identity only, never position, + * "first run", or a target-name match (Codex r16/r17/r18 on #595: all + * proved spoofable; object identity is not). Always null until + * as_siteagent() ever populates $siteagent_claims. + * + * @param mixed $upgrader The upgrader instance upgrader_pre_download hands us. + * @return array|null `{ upgrader, package, action }`, the exact claim as_siteagent() stored. + */ + private static function matching_claim( $upgrader ) { + if ( ! is_object( $upgrader ) ) { + return null; + } + foreach ( self::$siteagent_claims as $claim ) { + if ( $claim['upgrader'] === $upgrader ) { + return $claim; + } + } + return null; + } + + /** @return string|null */ + private static function token_of( $hook_extra ) { + return is_array( $hook_extra ) && isset( $hook_extra[ self::TOKEN_KEY ] ) && is_string( $hook_extra[ self::TOKEN_KEY ] ) && '' !== $hook_extra[ self::TOKEN_KEY ] + ? $hook_extra[ self::TOKEN_KEY ] + : null; + } + + /** + * `update` when hook_extra names the package; else `install` unless it says + * `update`. This is the FALLBACK a claim's own action override (Codex r2 on + * #139) preempts at on_install_result() — hook_extra alone cannot tell a + * fresh install from a self-update's Plugin_Upgrader::install() call over + * an existing plugin, which names no plugin and says `action: install`. + */ + private static function action_of( $hook_extra ) { + if ( ! empty( $hook_extra['plugin'] ) || ! empty( $hook_extra['theme'] ) ) { + return 'update'; + } + return isset( $hook_extra['action'] ) && 'update' === $hook_extra['action'] ? 'update' : 'install'; + } + + /** The uuid core recorded at authentication, else the one SiteAgent captured. Never stored. */ + private static function authenticated_uuid() { + $uuid = function_exists( 'rest_get_authenticated_app_password' ) ? rest_get_authenticated_app_password() : null; + if ( ( null === $uuid || '' === $uuid ) && class_exists( 'Aura_Worker_Security' ) ) { + $uuid = Aura_Worker_Security::authenticating_app_password_uuid(); + } + return null === $uuid ? '' : (string) $uuid; + } + + /** The password's NAME — never its uuid, never its hash. */ + private static function app_password_name( $user, $uuid ) { + if ( $user <= 0 || ! class_exists( 'WP_Application_Passwords' ) ) { + return null; + } + $pw = WP_Application_Passwords::get_user_application_password( $user, $uuid ); + return is_array( $pw ) && isset( $pw['name'] ) ? self::clip( $pw['name'] ) : null; + } + + /** @return array|null { basedir, baseurl } */ + private static function uploads_dir() { + if ( ! function_exists( 'wp_upload_dir' ) ) { + return null; + } + $u = wp_upload_dir( null, false ); + return is_array( $u ) && empty( $u['error'] ) ? array( 'basedir' => (string) $u['basedir'], 'baseurl' => (string) $u['baseurl'] ) : null; + } + + /** @return int 0 when none resolves */ + private static function attachment_id( $url ) { + $p = self::probe(); + if ( is_array( $p['attachment_ids'] ) ) { + return (int) ( $p['attachment_ids'][ $url ] ?? 0 ); + } + return function_exists( 'attachment_url_to_postid' ) ? (int) attachment_url_to_postid( $url ) : 0; + } + + /** + * The installed version, from the files under $destination (Ruling R4). + * A plugin's MAIN file, never merely the first header found (Codex r21 on + * #595 — a folder can carry a bundled companion): on an update the file + * hook_extra['plugin'] names; on an install `.php` when it carries a + * header, else the ONLY header file — several candidates and no + * `.php` is null, never a guess by sort order (Codex r22 on #595). + * + * @param string $type plugin|theme. + * @param string $destination Installed directory. + * @param mixed $hook_extra The run's hook_extra. + * @param string $slug The destination directory name. + * @return string|null + */ + private static function installed_version( $type, $destination, $hook_extra = array(), $slug = '' ) { + $p = self::probe(); + if ( is_array( $p['versions'] ) ) { + return isset( $p['versions'][ $destination ] ) ? self::clip( $p['versions'][ $destination ] ) : null; + } + if ( '' === $destination || ! function_exists( 'get_file_data' ) || ! is_dir( $destination ) ) { + return null; + } + $dir = rtrim( $destination, '/\\' ); + if ( 'theme' === $type ) { + $v = get_file_data( $dir . '/style.css', array( 'Version' => 'Version' ) ); + return '' !== (string) ( $v['Version'] ?? '' ) ? self::clip( $v['Version'] ) : null; + } + $read = static function ( $file ) { + if ( ! is_file( $file ) ) { + return null; + } + $h = get_file_data( $file, array( 'Name' => 'Plugin Name', 'Version' => 'Version' ) ); + return '' !== (string) ( $h['Name'] ?? '' ) ? (string) ( $h['Version'] ?? '' ) : null; // null = no plugin header + }; + $named = null; + if ( is_array( $hook_extra ) && ! empty( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) { + $named = $dir . '/' . basename( $hook_extra['plugin'] ); // an update: WordPress names the main file + } elseif ( '' !== (string) $slug && null !== $read( $dir . '/' . basename( (string) $slug ) . '.php' ) ) { + $named = $dir . '/' . basename( (string) $slug ) . '.php'; + } + if ( null !== $named ) { + $v = $read( $named ); + } else { + $found = array(); + foreach ( (array) glob( $dir . '/*.php' ) as $file ) { + $v = $read( $file ); + if ( null !== $v ) { + $found[] = $v; + } + } + $v = 1 === count( $found ) ? $found[0] : null; // several headers: not ours to guess + } + return null !== $v && '' !== $v ? self::clip( $v ) : null; + } + + /** @return array */ + private static function fresh_state( $now ) { + return array( 'started' => gmdate( 'c', $now ), 'count_edge' => null, 'evicted' => false ); + } + + /** + * A stored value that is not a list of entries — or holds one row that is + * not an entry — is unreadable, never a shorter clean ledger: a dropped + * row would be an install Aura never grades (Codex r2 on #595). + * + * @param mixed $stored The entries option, null when absent. + * @return bool + */ + private static function entries_corrupt( $stored, $now ) { + if ( null === $stored ) { + return false; + } + if ( ! is_array( $stored ) || count( $stored ) > self::MAX_ENTRIES ) { + return true; // more than the cap is not a ring this class wrote (Codex r13 on #595) + } + $prev = null; + foreach ( $stored as $e ) { + if ( ! self::valid_entry( $e, $now ) ) { + return true; + } + // Newest first, always (Codex r14 on #595): rotation drops the LAST + // row and takes the edge from the new last, so a reordered ring + // could discard a recent install under a coverage claim. + $t = strtotime( $e['when'] ); + if ( null !== $prev && $t > $prev ) { + return true; + } + $prev = $t; + } + return false; + } + + /** + * Store a new state and ring, in the one order that can only under-claim + * coverage (Codex r2/r5/r6 on #595): + * 1. the state, proven by read-back — refused, nothing else is written; + * 2. the entries, proven by read-back — refused, the state is moved to a + * recovery boundary (coverage from NOW, `evicted: true`), because the + * ring on disk is not the one that state describes and may be missing + * the install that just finished. + * + * @param array $state The new state. + * @param array $kept The new ring. + * @param int $now Now. + */ + private static function commit( array $state, array $kept, $now ) { + // The state, retried once; refused twice, the ring is left as it is and + // coverage moves to a boundary instead — the install this call carries + // is lost, and the ledger must not claim the stretch it happened in + // (Codex r23 on #595). + if ( ! self::write_state( $state ) && ! self::write_state( $state ) ) { + self::write_boundary( $now ); + return; + } + self::write_entries( $kept ); + if ( self::read_entries() !== $kept ) { + self::write_boundary( $now ); + } + } + + /** + * Coverage restarts at $now (`evicted: true`), the ring untouched — proven + * and retried once (Codex r9/r14/r23 on #595). A database that refuses + * this as well refuses every write: nothing can be recorded there, and the + * spec says so (§4.3, stated, not fixed — like the unlocked + * read-modify-write). + * + * @param int $now Now. + */ + private static function write_boundary( $now ) { + $boundary = self::fresh_state( $now ); + $boundary['evicted'] = true; + if ( ! self::write_state( $boundary ) ) { + self::write_state( $boundary ); + } + } + + /** + * Either option unreadable, or the ring missing beside a state: every + * writer stores the two together (ensure_started(), commit()), so a state + * alone means the rows were lost — never an empty ledger that still + * claims the old coverage (Codex r8 on #595). + * + * @return bool + */ + private static function storage_corrupt( $stored, $state, $now ) { + return self::entries_corrupt( $stored, $now ) + || self::state_corrupt( $state, $now ) + || ( null === $stored && null !== $state ) + || self::orphan_ring( $stored, $state, $now ) + || self::edge_contradicts_ring( $state ); + } + + /** + * A count edge stored without `evicted: true` (Codex r15 on #595, narrowed + * by controller ruling 1, Task 1 review round 1): a count eviction always + * sets `evicted` alongside the edge, so the two disagreeing is corruption + * no read can make sense of. + * + * A ring ROW older than the edge is deliberately NOT checked here anymore + * — it is not corruption, it is a row an earlier rotation already dropped. + * commit() writes the state (the new, narrower edge) before the entries + * (the new, shorter ring); when the entries write does not land — refused, + * or the process ends between the two writes — the edge is on disk ahead + * of a ring that is still the old, longer one, with exactly one row past + * it. That is the state append() and purge_rows() already expect: expired() + * ages a pre-edge row out the same way it ages out a 90-day-old one, and + * report() answers it as a normal, readable, `evicted: true` ledger. An + * edge corrupted to an EARLIER, still-valid instant is not detectable from + * storage — stated in spec §4.3. + * + * @param mixed $state The state option. + * @return bool + */ + private static function edge_contradicts_ring( $state ) { + if ( ! is_array( $state ) || null === $state['count_edge'] ) { + return false; + } + return true !== $state['evicted']; + } + + /** + * Readable rows whose state is gone (Codex r10 on #595): not a new + * ledger — recovered behind a boundary WITH the rows kept, never + * overwritten by ensure_started(). + * + * @return bool + */ + private static function orphan_ring( $stored, $state, $now ) { + return null !== $stored && null === $state && ! self::entries_corrupt( $stored, $now ); + } + + /** + * A state option that is present but not exactly `{ started, count_edge, + * evicted }` — a real ISO `started`, a null-or-ISO `count_edge`, a bool + * `evicted` — is corrupted: a half state can advertise coverage the ring + * no longer has (Codex r6 on #595). Absent (null) is not corrupted. + * + * @param mixed $state The state option. + * @param int $now Now. + * @return bool + */ + private static function state_corrupt( $state, $now ) { + if ( null === $state ) { + return false; + } + return ! is_array( $state ) + || array_keys( $state ) !== array( 'started', 'count_edge', 'evicted' ) + || ! self::valid_when( $state['started'], $now ) + || ( null !== $state['count_edge'] && ! self::valid_when( $state['count_edge'], $now ) ) + || ! is_bool( $state['evicted'] ); + } + + /** + * This class's own gmdate( 'c' ): UTC "+00:00", a real calendar day, and + * at most a year ahead of now. Far-future stamps are corruption — they would + * never expire (Codex r5) — but a stamp a few days ahead is this class's own + * write after the host clock was corrected BACKWARD, and must stay readable + * (Codex r20 on #595); such a row simply expires later, by the size of the + * correction. + * + * @param mixed $v Value. + * @param int $now Now. + * @return bool + */ + private static function valid_when( $v, $now ) { + return is_string( $v ) + && preg_match( '/^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):[0-5]\d:[0-5]\d\+00:00$/', $v, $m ) + && checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ) + && strtotime( $v ) <= $now + self::MAX_CLOCK_SKEW; + } + + /** + * The §4.2 shape every entry this class writes has. A row without a + * parseable `when` could never expire — personal data past 90 days — + * and one without its door or source could never be graded (Codex r4 + * on #595), so either makes the ledger unreadable. + * + * @param mixed $e A stored row. + * @return bool + */ + private static function valid_entry( $e, $now ) { + static $keys = array( 'when', 'type', 'action', 'slug', 'version', 'transport', 'user_id', 'auth', 'app_password_name', 'route', 'source' ); + if ( ! is_array( $e ) || array_keys( $e ) !== $keys ) { + return false; // exactly the §4.2 keys, in the order this class writes them + } + // Every string this class stores is clipped to STRING_MAX (Codex r11 on + // #595): a longer one is not a row this class wrote. + $bounded = static function ( $v ) { + return is_string( $v ) && self::char_len( $v ) <= self::STRING_MAX; + }; + $nullable_string = static function ( $v ) use ( $bounded ) { + return null === $v || $bounded( $v ); + }; + if ( ! self::valid_when( $e['when'], $now ) ) { + return false; + } + $src = $e['source']; + return in_array( $e['type'], array( 'plugin', 'theme' ), true ) + && in_array( $e['action'], array( 'install', 'update' ), true ) + && $bounded( $e['slug'] ) + && $nullable_string( $e['version'] ) + && in_array( $e['transport'], array( 'wp_admin', 'rest', 'wp_cli', 'cron', 'auto_update', 'siteagent', 'unknown' ), true ) + && is_int( $e['user_id'] ) && $e['user_id'] >= 0 + && in_array( $e['auth'], array( 'cookie', 'application_password', 'none', 'unknown' ), true ) + && $nullable_string( $e['app_password_name'] ) + && $nullable_string( $e['route'] ) + && self::valid_source( $e['source'] ); + } + + /** + * Exactly what classify_source() writes, per kind (Codex r7 on #595): no + * missing field, no extra key — a corrupted nested payload must reach the + * recovery boundary, never report() verbatim. + * + * @param mixed $src The source. + * @return bool + */ + private static function valid_source( $src ) { + if ( ! is_array( $src ) || ! isset( $src['kind'] ) ) { + return false; + } + $keys = array_keys( $src ); + switch ( $src['kind'] ) { + case 'wporg': + case 'local_path': + case 'unknown': + return array( 'kind' ) === $keys; + case 'remote_host': + return array( 'kind', 'host' ) === $keys && is_string( $src['host'] ) && '' !== $src['host'] + && self::char_len( $src['host'] ) <= self::STRING_MAX; + case 'uploaded_zip': + return array( 'kind' ) === $keys + || ( array( 'kind', 'attachment_id' ) === $keys && is_int( $src['attachment_id'] ) && $src['attachment_id'] > 0 ); + default: + return false; + } + } + + /** + * Past 90 days, OR past the count edge (controller ruling 1, Task 1 + * review round 1) — a row an earlier, possibly interrupted, rotation + * already dropped is aged out here exactly like one past 90 days, not + * flagged as corruption by storage_corrupt(). + * + * @param mixed $e A stored row. + * @param int $now Now. + * @param int|null $edge The count edge as a timestamp, or null when unset. + * @return bool + */ + private static function expired( $e, $now, $edge = null ) { + if ( ! is_array( $e ) || ! isset( $e['when'] ) ) { + return false; + } + $t = strtotime( (string) $e['when'] ); + if ( false === $t ) { + return false; + } + return $t < $now - self::MAX_AGE || ( null !== $edge && $t < $edge ); + } + + /** + * The state's count edge, as a timestamp — null when unset or unparseable + * (state_corrupt() already refuses a $state whose count_edge is set but + * does not parse, so null here means genuinely unset). + * + * @param mixed $state The state option. + * @return int|null + */ + private static function edge_timestamp( $state ) { + if ( ! is_array( $state ) || ! isset( $state['count_edge'] ) || null === $state['count_edge'] ) { + return null; + } + $t = strtotime( (string) $state['count_edge'] ); + return false !== $t ? $t : null; + } + + /** @return mixed null when absent */ + private static function read_entries() { + return is_multisite() ? get_site_option( self::OPTION, null ) : get_option( self::OPTION, null ); + } + + /** @return mixed null when absent */ + private static function read_state() { + return is_multisite() ? get_site_option( self::STATE_OPTION, null ) : get_option( self::STATE_OPTION, null ); + } + + // Order matters: append() writes the state before the entries (see there). + // The four writes name their option by constant, never through a variable: + // UninstallCoverageTest resolves storage keys from source (#434 Task 10). + + private static function write_entries( array $entries ) { + if ( is_multisite() ) { + update_site_option( self::OPTION, $entries ); + } else { + update_option( self::OPTION, $entries, false ); + } + } + + /** + * @return bool Whether the state is now stored — read back, because + * update_option() answers false for an unchanged value too. + */ + private static function write_state( array $state ) { + if ( is_multisite() ) { + update_site_option( self::STATE_OPTION, $state ); + } else { + update_option( self::STATE_OPTION, $state, false ); + } + return self::read_state() === $state; + } +} diff --git a/digitizer-site-worker/includes/class-aura-worker-updater.php b/digitizer-site-worker/includes/class-aura-worker-updater.php index c48266f5..942295d4 100644 --- a/digitizer-site-worker/includes/class-aura-worker-updater.php +++ b/digitizer-site-worker/includes/class-aura-worker-updater.php @@ -452,8 +452,16 @@ private function self_update_locked( $zip_url, $expected_sha256, $fence ) { // Heartbeaten from inside (SA#80): the install is the longest phase, and // renewing only around it left it seizable while it ran. The download // above already sits between two renewals and fires no upgrader filter. - $result = $this->heartbeat_during( $fence, function () use ( $upgrader, $install_source ) { - return $upgrader->install( $install_source, array( 'overwrite_package' => true ) ); + // + // `action: update` (Codex r2 on #139): Plugin_Upgrader::install() puts + // the new build straight over the plugin already at this slug, but its + // hook_extra says `action: install` and names no plugin — install() + // itself has no notion of "this replaces something already here". The + // ledger would otherwise record every self-update as an install. + $result = $this->heartbeat_during( $fence, function () use ( $upgrader, $install_source, $zip_url ) { + return Aura_Worker_Install_Ledger::as_siteagent( function () use ( $upgrader, $install_source ) { + return $upgrader->install( $install_source, array( 'overwrite_package' => true ) ); + }, $upgrader, array( 'package' => $zip_url, 'action' => 'update' ) ); } ); if ( '' !== $tmp && file_exists( $tmp ) ) { @@ -1692,7 +1700,9 @@ public function update_plugin( $plugin_file ) { $r = $this->heartbeat_during( $fence, function () use ( $plugin_file ) { $skin = new Automatic_Upgrader_Skin(); $upgrader = new Plugin_Upgrader( $skin ); - return $upgrader->upgrade( $plugin_file ); + return Aura_Worker_Install_Ledger::as_siteagent( function () use ( $upgrader, $plugin_file ) { + return $upgrader->upgrade( $plugin_file ); + }, $upgrader ); } ); // A claim lost during the phase (a heartbeat that fired only at // post-install passes through) is a successor owning these files: @@ -1748,7 +1758,9 @@ public function update_theme( $theme_slug ) { $skin = new Automatic_Upgrader_Skin(); $upgrader = new Theme_Upgrader( $skin ); - $result = $upgrader->upgrade( $theme_slug ); + $result = Aura_Worker_Install_Ledger::as_siteagent( function () use ( $upgrader, $theme_slug ) { + return $upgrader->upgrade( $theme_slug ); + }, $upgrader ); if ( is_wp_error( $result ) ) { return array( @@ -1865,7 +1877,9 @@ protected function update_single_plugin( $plugin_file ) { $skin = new Automatic_Upgrader_Skin(); $upgrader = new Plugin_Upgrader( $skin ); - $result = $upgrader->upgrade( $plugin_file ); + $result = Aura_Worker_Install_Ledger::as_siteagent( function () use ( $upgrader, $plugin_file ) { + return $upgrader->upgrade( $plugin_file ); + }, $upgrader ); if ( is_wp_error( $result ) ) { return array( 'success' => false, 'error' => $result->get_error_message() ); diff --git a/digitizer-site-worker/includes/class-aura-worker.php b/digitizer-site-worker/includes/class-aura-worker.php index 1997e340..12cc18ae 100644 --- a/digitizer-site-worker/includes/class-aura-worker.php +++ b/digitizer-site-worker/includes/class-aura-worker.php @@ -104,6 +104,11 @@ public function init() { // once Phase A/B exist; this only registers the init hook. Aura_Worker_Unbind::init(); + // The install ledger (2.22.0, P6.3 phase 2): every plugin and theme + // install or update, with the door it came through. Observes the + // upgrader and never decides. + Aura_Worker_Install_Ledger::init(); + // The breadcrumb Task 6 left behind fired into nothing until now // (#434 Task 9): a probe that cannot prove itself owes `app_passwords` // forever, so a tombstone that never completes had no explanation diff --git a/digitizer-site-worker/includes/tools/class-tool-audit-agent-code.php b/digitizer-site-worker/includes/tools/class-tool-audit-agent-code.php index 177aac9d..75bfb999 100644 --- a/digitizer-site-worker/includes/tools/class-tool-audit-agent-code.php +++ b/digitizer-site-worker/includes/tools/class-tool-audit-agent-code.php @@ -12,12 +12,17 @@ * every request from the active environment, no snapshot, no approval); our * own Power Pack's execute_php / write_file / run_wp_cli flags; and third-party * exec stores (EMCP Pro's sandbox, Atarim's execute-php / run-wp-cli abilities). + * Since 2.22.0 a fourth subtree, installs, reports the install ledger (Aura + * spec 2026-09-21 §4). * * Facts, not verdicts, under audit_mcp_exposure's contract: every subtree is * independent and answers { error } on a throw; `null` is unreadable, never * zero; walks are bounded and say so; strings are clipped. Never reads a - * file's contents, never executes anything, never touches the database - * beyond the CPT query and get_post_meta(). + * file's contents and never executes anything. Every read beyond the CPT + * query and get_post_meta() belongs to the installs subtree: it also DELETES + * — the install ledger's own retention pass (spec §4.2), purging rows past 90 + * days before answering, exactly as report() does on every other caller + * (final review Task 3). * * @package Aura_Worker */ @@ -68,11 +73,18 @@ public function get_returns() { 'angie_snippets' => 'object — { installed, version } (installed = loaded at runtime OR present in the installed-plugin inventory, so a deactivated Angie still reports its dormant rows and directories) and, when installed: module_active, total|published|drafts|agent_authored (int|null — null when the CPT read failed or hit its cap of 500), active_env ("prod"|"dev"|null — the environment Angie\'s loader includes for THIS request; null when the dev-mode API is not callable or the snippet module is inactive), deployed: { prod: { dirs, agent_authored, orphan }, dev: {…} } (int|null per environment — directories named snippet- that contain main.php, joined to the CPT by id; orphan = no row, which the loader still includes), latest_deploy_at: { prod, dev } (ISO8601|null, per environment, never a max across both), recent: [{ id, title, status, agent_authored, environments, modified }] (newest first, cap 20; recent_truncated when cut — never bounds a count), coverage: { total_seen, returned, truncated, cap } (the DIRECTORY WALK only, 200 entries per environment). Absent Angie: { installed: false, version: "" }. A scan that threw: { error }.', 'power_pack' => 'object — { installed, version, execute_php, fs_write, wp_cli, create_publish } from AURA_POWER_PACK_VERSION / AURA_POWER_EXECUTE_PHP / AURA_POWER_ALLOW_FS_WRITE / AURA_POWER_ALLOW_WP_CLI; every flag false when not installed', 'third_party' => 'object — { emcp_sandbox: { present, version, active, store }, atarim_exec: { present } }. active = EMCP_TOOLS_VERSION is defined (the plugin is loaded). store = null when wp-content/emcp-sandbox does not exist, { error } when it could not be walked — one of sandbox_unreadable (the root would not open), sandbox_is_link (the root is itself a link, so it is not walked at all) or sandbox_walk_failed (the walk threw; the sibling subtrees still answer) — else { files, executable_files, newest_mtime, truncated, unreadable_dirs } — a metadata walk: no file is opened and no name leaves the site; a link is one entry judged by its own name, never followed and never stat\'ed; truncated = the 20000-entry cap was reached, and that cap counts every entry visited, directories included; unreadable_dirs counts sub-directories that could not be opened or vanished mid-walk; truncated OR unreadable_dirs >= 1 means the counts are lower bounds (since 2.19.2)', + 'installs' => 'object — the install ledger (since 2.22.0): { since, entries, total, evicted } or { error } (ledger_unreadable | ledger_unavailable | ledger_partial_network). Every plugin and theme install or update this site performed, newest first, every retained entry (≤ 200, ≤ 90 days): { when, type: plugin|theme, action: install|update, slug, version|null, transport: wp_admin|rest|wp_cli|cron|auto_update|siteagent|unknown, user_id, auth: cookie|application_password|none|unknown, app_password_name|null (the name only — never the uuid), route|null (REST route, no query), source: { kind: wporg|uploaded_zip|remote_host|local_path|unknown, attachment_id?, host? } }. since = the edge of coverage — every install after it is held; evicted = something was rotated out. user_id, app_password_name and route are personal data kept by owner decision (Aura spec 2026-09-21 §4.2). ledger_partial_network = this is a multisite network and SiteAgent is active on this site but not network-activated, so the one network-wide ledger cannot see every site\'s installs', 'counters_as_of' => 'string — ISO8601 instant the counts were taken', ); } - /** Read-only: never mutates the site. */ + /** + * Annotated read_only for the tool registry's contract, not literally + * side-effect-free: the installs subtree's report() call deletes ledger + * rows past their 90-day retention window before answering (spec §4.2, + * final review Task 3) — a physical purge, not a mutation of the site + * an agent authored or can author, which is what read_only promises here. + */ public function get_annotations() { return array( 'read_only' => true, @@ -84,7 +96,7 @@ public function get_annotations() { public function execute( $params ) { $out = array(); - foreach ( array( 'angie_snippets', 'power_pack', 'third_party' ) as $subtree ) { + foreach ( array( 'angie_snippets', 'power_pack', 'third_party', 'installs' ) as $subtree ) { try { $out[ $subtree ] = $this->{ $subtree }(); } catch ( \Throwable $e ) { @@ -627,6 +639,21 @@ protected function emcp_store() { } } + // ---- installs ---------------------------------------------------------------- + + /** + * The install ledger (spec 2026-09-21 §4.3), verbatim. + * + * @since 2.22.0 + * @return array + */ + protected function installs() { + if ( ! class_exists( 'Aura_Worker_Install_Ledger' ) ) { + return array( 'error' => 'ledger_unavailable' ); + } + return Aura_Worker_Install_Ledger::report(); + } + // ---- helpers --------------------------------------------------------------- /** diff --git a/digitizer-site-worker/uninstall.php b/digitizer-site-worker/uninstall.php index 26f5e8b9..02bc12ee 100644 --- a/digitizer-site-worker/uninstall.php +++ b/digitizer-site-worker/uninstall.php @@ -251,3 +251,12 @@ } else { $aura_uninstall_site(); } + +// The install ledger's rows are NETWORK options on multisite (2.22.0, spec +// 2026-09-21 §4.3) — sitemeta, which the per-site prefix sweep above never +// reads. Deleted by name, once. On a single site they are ordinary options +// and the sweep already removed them. +if ( is_multisite() && function_exists( 'delete_site_option' ) ) { + delete_site_option( 'aura_worker_install_ledger' ); // mirrors Aura_Worker_Install_Ledger::OPTION + delete_site_option( 'aura_worker_install_ledger_state' ); // mirrors Aura_Worker_Install_Ledger::STATE_OPTION +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index b3c02297..788d9c72 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -81,6 +81,14 @@ // way through (normally the rule/grant guards stop it first). define( 'AURA_WORKER_VERSION', 'test' ); } +if ( ! defined( 'AURA_WORKER_FILE' ) ) { + // The install ledger's network-active probe (2.22.0) resolves this + // through plugin_basename() the same way digitizer-site-worker.php's own + // `define( 'AURA_WORKER_FILE', __FILE__ )` does; bootstrap.php loads the + // plugin's classes directly and never requires that top-level file, so + // the constant needs a test-only stand-in here. + define( 'AURA_WORKER_FILE', SA_PLUGIN_DIR . '/digitizer-site-worker.php' ); +} // --------------------------------------------------------------------------- // Mutable state used by the stubs @@ -240,6 +248,15 @@ function plugin_dir_path( string $file ): string { } } +if ( ! function_exists( 'plugin_basename' ) ) { + function plugin_basename( string $file ): string { + // Core's real algorithm is far more elaborate (symlinked plugin dirs, + // WP_PLUGIN_DIR vs muplugins), but every caller in this suite passes a + // path already under SA_PLUGIN_DIR, so stripping that prefix is exact. + return ltrim( str_replace( '\\', '/', str_replace( (string) SA_PLUGIN_DIR, '', $file ) ), '/' ); + } +} + if ( ! function_exists( 'wp_parse_url' ) ) { function wp_parse_url( string $url, int $component = -1 ) { return parse_url( $url, $component ); @@ -1562,6 +1579,16 @@ function is_plugin_active( $plugin ) { } } +if ( ! function_exists( 'is_plugin_active_for_network' ) ) { + // The install ledger's `installs.error: ledger_partial_network` probe + // (2.22.0) reads this on multisite. Default true: a single site never + // consults it (is_multisite() gates the call), and a test that cares sets + // $GLOBALS['_network_active_plugins'][ $plugin ] explicitly. + function is_plugin_active_for_network( $plugin ) { + return isset( $GLOBALS['_network_active_plugins'][ $plugin ] ); + } +} + if ( ! function_exists( 'activate_plugin' ) ) { function activate_plugin( $plugin ) { $GLOBALS['_active_plugins'][ $plugin ] = true; @@ -1652,6 +1679,17 @@ function get_plugin_data( $plugin_file, $markup = true, $translate = true ) { } } +if ( ! function_exists( 'get_file_data' ) ) { + function get_file_data( $file, $default_headers, $context = '' ) { + $head = is_readable( $file ) ? (string) file_get_contents( $file, false, null, 0, 8192 ) : ''; + $out = array(); + foreach ( $default_headers as $key => $label ) { + $out[ $key ] = preg_match( '/^[ \t\/*#@]*' . preg_quote( $label, '/' ) . ':(.*)$/mi', $head, $m ) ? trim( $m[1] ) : ''; + } + return $out; + } +} + if ( ! function_exists( 'download_url' ) ) { function download_url( $url, $timeout = 300, $signature_verification = false ) { $GLOBALS['_download_url_calls'][] = $url; // witnessed, so a refusal can prove it downloaded nothing (SA#95) @@ -1682,7 +1720,7 @@ public function upgrade( $plugin_file ) { // `_upgrade_effect` models what happens WHILE the upgrade runs (a // claim seized mid-phase), the way `_install_effect` does for install(). if ( isset( $GLOBALS['_upgrade_effect'] ) && is_callable( $GLOBALS['_upgrade_effect'] ) ) { - call_user_func( $GLOBALS['_upgrade_effect'] ); + call_user_func( $GLOBALS['_upgrade_effect'], $this ); } return true; } @@ -1693,7 +1731,7 @@ public function install( $package, $args = array() ) { // install does (replace the plugin directory), so a rollback can be // asserted on CONTENT rather than on a return value. if ( isset( $GLOBALS['_install_effect'] ) && is_callable( $GLOBALS['_install_effect'] ) ) { - call_user_func( $GLOBALS['_install_effect'] ); + call_user_func( $GLOBALS['_install_effect'], $this ); } // array_key_exists, not ??: a test may set null to model an upgrader that never reached its install step. return array_key_exists( '_install_result', $GLOBALS ) ? $GLOBALS['_install_result'] : true; @@ -1707,6 +1745,9 @@ public function __construct( $skin = null ) {} public function upgrade( $theme_slug ) { $GLOBALS['_mutations'][] = 'Theme_Upgrader::upgrade'; + if ( isset( $GLOBALS['_upgrade_effect'] ) && is_callable( $GLOBALS['_upgrade_effect'] ) ) { + call_user_func( $GLOBALS['_upgrade_effect'], $this ); + } return true; } } @@ -4389,6 +4430,7 @@ function has_post_thumbnail( $post = null ) { require_once SA_PLUGIN_DIR . '/includes/class-aura-worker-abilities.php'; require_once SA_PLUGIN_DIR . '/includes/credential-rules.php'; require_once SA_PLUGIN_DIR . '/includes/class-aura-worker-unbind.php'; +require_once SA_PLUGIN_DIR . '/includes/class-aura-worker-install-ledger.php'; // The Elementor door (2.16.0): Aura_Worker::init() wires the governor, so // the class has to be loaded here as the plugin bootstrap loads it — the // door tests require these three themselves too, harmlessly (require_once). @@ -4550,6 +4592,21 @@ function get_site_option( string $option, $default = false ) { } } +if ( ! function_exists( 'update_site_option' ) ) { + function update_site_option( string $option, $value ): bool { + $GLOBALS['_site_options'][ $option ] = $value; + return true; + } +} + +if ( ! function_exists( 'delete_site_option' ) ) { + function delete_site_option( string $option ): bool { + $had = array_key_exists( $option, (array) ( $GLOBALS['_site_options'] ?? array() ) ); + unset( $GLOBALS['_site_options'][ $option ] ); + return $had; + } +} + if ( ! function_exists( 'get_user_meta' ) ) { function get_user_meta( int $user_id, string $key = '', bool $single = false ) { if ( isset( $GLOBALS['_user_meta'][ $user_id ][ $key ] ) ) { @@ -5213,6 +5270,9 @@ function sa_reset_state(): void { if ( class_exists( 'Aura_Worker_Call_Context' ) ) { Aura_Worker_Call_Context::reset(); // the dispatching route is a static too } + if ( class_exists( 'Aura_Worker_Install_Ledger' ) ) { + Aura_Worker_Install_Ledger::reset_for_tests(); // frames, SiteAgent's claimed upgraders and probe overrides are statics (2.22.0) + } if ( class_exists( 'Aura_Worker_Redact' ) ) { Aura_Worker_Redact::reset_for_tests(); // the unredacted-grant exemption memo is a static (#419) } @@ -5368,6 +5428,7 @@ function sa_reset_state(): void { $GLOBALS['_sa_state'] = array(); $GLOBALS['_is_admin'] = false; // is_admin() — see the stub above. $GLOBALS['_is_multisite'] = false; + $GLOBALS['_network_active_plugins'] = array(); // is_plugin_active_for_network() — see the stub above. $GLOBALS['_current_blog_id'] = 1; // get_current_blog_id() — core's own default on a single site. $GLOBALS['_main_site_id'] = 1; // is_main_site() — which blog of a network is the main one (Ruling P39). $GLOBALS['_site_options'] = array(); diff --git a/tests/unit/AgentCodeAuditInstallsTest.php b/tests/unit/AgentCodeAuditInstallsTest.php new file mode 100644 index 00000000..4c71f407 --- /dev/null +++ b/tests/unit/AgentCodeAuditInstallsTest.php @@ -0,0 +1,69 @@ + 1790424000 ) ); + } + + private function run_tool(): array { + return ( new Aura_Tool_Audit_Agent_Code() )->execute( array() ); + } + + public function test_installs_is_the_ledger_report_verbatim(): void { + Aura_Worker_Install_Ledger::ensure_started(); + Aura_Worker_Install_Ledger::append( array( 'when' => gmdate( 'c', 1790424000 ), 'type' => 'plugin', 'action' => 'install', 'slug' => 'foo', 'version' => null, 'transport' => 'rest', 'user_id' => 1, 'auth' => 'cookie', 'app_password_name' => null, 'route' => null, 'source' => array( 'kind' => 'wporg' ) ) ); + $out = $this->run_tool(); + $this->assertSame( Aura_Worker_Install_Ledger::report(), $out['installs'] ); + $this->assertSame( 1, $out['installs']['total'] ); + } + + public function test_an_unreadable_ledger_stays_in_its_subtree(): void { + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = 'garbage'; + $out = $this->run_tool(); + $this->assertSame( array( 'error' => 'ledger_unreadable' ), $out['installs'] ); + $this->assertArrayHasKey( 'installed', $out['angie_snippets'] ); // siblings unaffected + $this->assertArrayHasKey( 'counters_as_of', $out ); + } + + public function test_the_returns_contract_documents_installs(): void { + $returns = ( new Aura_Tool_Audit_Agent_Code() )->get_returns(); + $this->assertArrayHasKey( 'installs', $returns ); + $this->assertStringContainsString( 'since', $returns['installs'] ); + // Final review Task 2: a network ledger cannot see every install when + // SiteAgent is active per-site rather than network-wide. + $this->assertStringContainsString( 'ledger_partial_network', $returns['installs'] ); + } + + public function test_installs_reports_partial_coverage_on_a_not_network_active_multisite(): void { + $GLOBALS['_is_multisite'] = true; + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => 1790424000, 'network_active' => false ) ); + Aura_Worker_Install_Ledger::ensure_started(); + $out = $this->run_tool(); + $this->assertSame( array( 'error' => 'ledger_partial_network' ), $out['installs'] ); + $this->assertArrayHasKey( 'installed', $out['angie_snippets'] ); // siblings unaffected + } + + public function test_uninstall_removes_the_network_rows_on_multisite(): void { + $GLOBALS['_is_multisite'] = true; + Aura_Worker_Install_Ledger::ensure_started(); + Aura_Worker_Install_Ledger::append( array( 'when' => gmdate( 'c', 1790424000 ), 'type' => 'plugin', 'action' => 'install', 'slug' => 'foo', 'version' => null, 'transport' => 'rest', 'user_id' => 1, 'auth' => 'cookie', 'app_password_name' => null, 'route' => null, 'source' => array( 'kind' => 'wporg' ) ) ); + $GLOBALS['_site_options']['unrelated_network_option'] = 1; + if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { + define( 'WP_UNINSTALL_PLUGIN', 'digitizer-site-worker/digitizer-site-worker.php' ); + } + include SA_PLUGIN_DIR . '/uninstall.php'; + $this->assertArrayNotHasKey( Aura_Worker_Install_Ledger::OPTION, $GLOBALS['_site_options'] ); + $this->assertArrayNotHasKey( Aura_Worker_Install_Ledger::STATE_OPTION, $GLOBALS['_site_options'] ); + $this->assertSame( 1, $GLOBALS['_site_options']['unrelated_network_option'] ); + } +} diff --git a/tests/unit/InstallLedgerObserverTest.php b/tests/unit/InstallLedgerObserverTest.php new file mode 100644 index 00000000..113be49b --- /dev/null +++ b/tests/unit/InstallLedgerObserverTest.php @@ -0,0 +1,409 @@ + self::NOW, + 'siteagent' => false, + 'wp_cli' => false, + 'auto_update' => false, + 'cron' => false, + 'rest' => false, + 'admin' => false, + 'rest_cookie' => false, + 'user_id' => 0, + 'app_password_uuid' => '', + 'route' => null, + 'uploads' => array( 'basedir' => '/srv/wp-content/uploads', 'baseurl' => 'https://example.com/wp-content/uploads' ), + 'attachment_ids' => array(), + 'versions' => array( '/srv/wp-content/plugins/foo' => '1.2.0', '/srv/wp-content/themes/bar' => '3.0' ), + ), + $over + ); + } + + protected function setUp(): void { + sa_reset_state(); + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet() ); + Aura_Worker_Install_Ledger::ensure_started(); + } + + /** Drive one run through the three filters the way WP_Upgrader::run() does. */ + private function run_package( array $hook_extra, $package, $result = null, $reply = false ) { + $options = Aura_Worker_Install_Ledger::on_package_options( array( 'package' => $package, 'hook_extra' => $hook_extra ) ); + $extra = $options['hook_extra']; + Aura_Worker_Install_Ledger::on_pre_download( $reply, $package, null, $extra ); + $result = null === $result ? array( 'destination_name' => 'foo', 'destination' => '/srv/wp-content/plugins/foo' ) : $result; + return array( $extra, Aura_Worker_Install_Ledger::on_install_result( $result, $extra ) ); + } + + private function entries(): array { + return Aura_Worker_Install_Ledger::report()['entries']; + } + + // ---- scope ---- + + public function test_type_of_reads_type_first_then_the_named_key(): void { + $this->assertSame( 'plugin', Aura_Worker_Install_Ledger::type_of( array( 'type' => 'plugin', 'action' => 'install' ) ) ); + $this->assertSame( 'theme', Aura_Worker_Install_Ledger::type_of( array( 'type' => 'theme', 'action' => 'install' ) ) ); + $this->assertSame( 'plugin', Aura_Worker_Install_Ledger::type_of( array( 'plugin' => 'foo/foo.php' ) ) ); + $this->assertSame( 'theme', Aura_Worker_Install_Ledger::type_of( array( 'theme' => 'bar' ) ) ); + $this->assertNull( Aura_Worker_Install_Ledger::type_of( array( 'language_update_type' => 'plugin', 'language_update' => (object) array() ) ) ); + $this->assertNull( Aura_Worker_Install_Ledger::type_of( array() ) ); + $this->assertNull( Aura_Worker_Install_Ledger::type_of( 'nope' ) ); + } + + public function test_a_language_pack_run_is_ignored_end_to_end(): void { + list( $extra, $result ) = $this->run_package( array( 'language_update_type' => 'plugin', 'language_update' => (object) array() ), 'https://downloads.wordpress.org/translation/x.zip' ); + $this->assertArrayNotHasKey( Aura_Worker_Install_Ledger::TOKEN_KEY, $extra ); + $this->assertSame( array(), $this->entries() ); + } + + // ---- pass-through ---- + + public function test_every_filter_returns_its_argument(): void { + $opts = array( 'package' => 'x', 'hook_extra' => array( 'type' => 'plugin', 'action' => 'install' ), 'other' => 1 ); + $out = Aura_Worker_Install_Ledger::on_package_options( $opts ); + unset( $out['hook_extra'][ Aura_Worker_Install_Ledger::TOKEN_KEY ] ); + $this->assertSame( $opts, $out ); + $err = new WP_Error( 'x', 'y' ); + $this->assertSame( $err, Aura_Worker_Install_Ledger::on_pre_download( $err, 'p', null, array() ) ); + $this->assertSame( '/tmp/local.zip', Aura_Worker_Install_Ledger::on_pre_download( '/tmp/local.zip', 'p', null, array() ) ); + $this->assertFalse( Aura_Worker_Install_Ledger::on_pre_download( false, 'p', null, array() ) ); + $res = array( 'destination_name' => 'foo' ); + $this->assertSame( $res, Aura_Worker_Install_Ledger::on_install_result( $res, array() ) ); + $this->assertSame( $err, Aura_Worker_Install_Ledger::on_install_result( $err, array( 'type' => 'plugin' ) ) ); + } + + public function test_a_throwing_probe_never_breaks_the_upgrade_and_moves_coverage(): void { + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::STATE_OPTION ] = array( 'started' => gmdate( 'c', self::NOW - 30 * 86400 ), 'count_edge' => null, 'evicted' => false ); + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet( array( 'app_password_uuid' => new ArrayObject() ) ) ); // (string) cast throws + list( , $result ) = $this->run_package( array( 'type' => 'plugin', 'action' => 'install' ), 'https://downloads.wordpress.org/plugin/foo.zip' ); + $this->assertSame( 'foo', $result['destination_name'] ); + // The install happened and could not be recorded: coverage restarts now (Codex r10 on #595). + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( gmdate( 'c', self::NOW ), true ), array( $r['since'], $r['evicted'] ) ); + } + + public function test_a_refused_callback_boundary_is_retried_once(): void { + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::STATE_OPTION ] = array( 'started' => gmdate( 'c', self::NOW - 30 * 86400 ), 'count_edge' => null, 'evicted' => false ); + $GLOBALS['_sa_option_write_fail'][ Aura_Worker_Install_Ledger::STATE_OPTION ] = 1; // the first boundary write is refused + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet( array( 'app_password_uuid' => new ArrayObject() ) ) ); + $this->run_package( array( 'type' => 'plugin', 'action' => 'install' ), 'https://downloads.wordpress.org/plugin/foo.zip' ); + $r = Aura_Worker_Install_Ledger::report(); // Codex r14 on #595 + $this->assertSame( array( gmdate( 'c', self::NOW ), true ), array( $r['since'], $r['evicted'] ) ); + } + + public function test_a_rejected_entry_moves_coverage_to_a_boundary(): void { + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::STATE_OPTION ] = array( 'started' => gmdate( 'c', self::NOW - 30 * 86400 ), 'count_edge' => null, 'evicted' => false ); + // A malformed FACT, not a thrown exception: a negative user_id makes + // context() build an entry valid_entry() itself refuses — append() + // returns false, nothing is thrown, and on_install_result() must + // still move coverage to a boundary the same as a thrown probe would + // (Codex review round 1, Task 2). + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet( array( 'user_id' => -1 ) ) ); + $this->run_package( array( 'type' => 'plugin', 'action' => 'install' ), 'https://downloads.wordpress.org/plugin/foo.zip' ); + $this->assertSame( array(), $this->entries() ); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( gmdate( 'c', self::NOW ), true ), array( $r['since'], $r['evicted'] ) ); + } + + // ---- the entry ---- + + public function test_a_rest_application_password_install_from_wporg(): void { + $GLOBALS['_app_passwords'][5] = array( array( 'uuid' => 'u-1', 'name' => 'claude', 'app_id' => '' ) ); + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet( array( 'rest' => true, 'user_id' => 5, 'app_password_uuid' => 'u-1', 'route' => '/mcp/elementor-mcp-server' ) ) ); + $this->run_package( array( 'type' => 'plugin', 'action' => 'install' ), 'https://downloads.wordpress.org/plugin/foo.1.2.0.zip' ); + $this->assertSame( + array( + array( + 'when' => gmdate( 'c', self::NOW ), + 'type' => 'plugin', + 'action' => 'install', + 'slug' => 'foo', + 'version' => '1.2.0', + 'transport' => 'rest', + 'user_id' => 5, + 'auth' => 'application_password', + 'app_password_name' => 'claude', + 'route' => '/mcp/elementor-mcp-server', + 'source' => array( 'kind' => 'wporg' ), + ), + ), + $this->entries() + ); + } + + public function test_the_uuid_is_never_stored(): void { + $GLOBALS['_app_passwords'][5] = array( array( 'uuid' => 'secret-uuid', 'name' => 'claude', 'app_id' => '' ) ); + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet( array( 'rest' => true, 'user_id' => 5, 'app_password_uuid' => 'secret-uuid' ) ) ); + $this->run_package( array( 'type' => 'plugin', 'action' => 'install' ), 'https://downloads.wordpress.org/plugin/foo.zip' ); + $this->assertStringNotContainsString( 'secret-uuid', serialize( get_option( Aura_Worker_Install_Ledger::OPTION ) ) ); + } + + public function test_a_failed_install_writes_nothing_and_leaves_no_frame_for_the_next_run(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet( array( 'rest' => true, 'user_id' => 5, 'rest_cookie' => false ) ) ); + $this->run_package( array( 'type' => 'plugin', 'action' => 'install' ), 'https://evil.example/x.zip', new WP_Error( 'bad', 'no' ) ); + $this->assertSame( array(), $this->entries() ); + // The frame this token staked at pre-download is actually gone, not + // merely orphaned — left in place it would leak for the rest of a + // long bulk request (Codex review round 1, Task 2). + $this->assertSame( 0, Aura_Worker_Install_Ledger::_frame_count_for_tests() ); + // An identical outer run with its own token still gets its OWN source (Codex r1 on #594). + $this->run_package( array( 'type' => 'plugin', 'action' => 'install' ), 'https://downloads.wordpress.org/plugin/foo.zip' ); + $this->assertSame( 'wporg', $this->entries()[0]['source']['kind'] ); + } + + public function test_a_wp_error_reply_stores_no_frame(): void { + $opts = Aura_Worker_Install_Ledger::on_package_options( array( 'hook_extra' => array( 'type' => 'plugin', 'action' => 'install' ) ) ); + Aura_Worker_Install_Ledger::on_pre_download( new WP_Error( 'aura_self_update_claim_lost', 'x' ), 'https://downloads.wordpress.org/plugin/foo.zip', null, $opts['hook_extra'] ); + Aura_Worker_Install_Ledger::on_install_result( array( 'destination_name' => 'foo', 'destination' => '/srv/wp-content/plugins/foo' ), $opts['hook_extra'] ); + $this->assertSame( 'unknown', $this->entries()[0]['source']['kind'] ); // no frame → unknown, never the URL + } + + public function test_a_substituted_download_is_an_unknown_source(): void { + $opts = Aura_Worker_Install_Ledger::on_package_options( array( 'hook_extra' => array( 'type' => 'plugin', 'action' => 'install' ) ) ); + Aura_Worker_Install_Ledger::on_pre_download( '/tmp/power-pack-supplied.zip', 'https://downloads.wordpress.org/plugin/foo.zip', null, $opts['hook_extra'] ); + Aura_Worker_Install_Ledger::on_install_result( array( 'destination_name' => 'foo', 'destination' => '/srv/wp-content/plugins/foo' ), $opts['hook_extra'] ); + $this->assertSame( array( 'kind' => 'unknown' ), $this->entries()[0]['source'] ); + } + + public function test_no_token_means_unknown_source_and_the_context_read_now(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet( array( 'wp_cli' => true ) ) ); + Aura_Worker_Install_Ledger::on_install_result( array( 'destination_name' => 'foo', 'destination' => '/srv/wp-content/plugins/foo' ), array( 'type' => 'plugin', 'action' => 'install' ) ); + $e = $this->entries()[0]; + $this->assertSame( array( 'kind' => 'unknown' ), $e['source'] ); + $this->assertSame( 'wp_cli', $e['transport'] ); + } + + public function test_bulk_items_each_get_their_own_token_and_entry(): void { + list( $a ) = $this->run_package( array( 'plugin' => 'foo/foo.php', 'temp_backup' => array() ), 'https://downloads.wordpress.org/plugin/foo.zip' ); + list( $b ) = $this->run_package( array( 'plugin' => 'baz/baz.php', 'temp_backup' => array() ), 'https://downloads.wordpress.org/plugin/baz.zip', array( 'destination_name' => 'baz', 'destination' => '/srv/wp-content/plugins/baz' ) ); + $this->assertNotSame( $a[ Aura_Worker_Install_Ledger::TOKEN_KEY ], $b[ Aura_Worker_Install_Ledger::TOKEN_KEY ] ); + $this->assertSame( array( 'baz', 'foo' ), array_column( $this->entries(), 'slug' ) ); + $this->assertSame( array( 'update', 'update' ), array_column( $this->entries(), 'action' ) ); + } + + public function test_a_theme_install_reads_the_theme_version(): void { + $this->run_package( array( 'type' => 'theme', 'action' => 'install' ), 'https://downloads.wordpress.org/theme/bar.zip', array( 'destination_name' => 'bar', 'destination' => '/srv/wp-content/themes/bar' ) ); + $e = $this->entries()[0]; + $this->assertSame( array( 'theme', 'install', 'bar', '3.0' ), array( $e['type'], $e['action'], $e['slug'], $e['version'] ) ); + } + + public function test_the_version_comes_from_the_main_plugin_file_not_a_companion(): void { + $dir = sys_get_temp_dir() . '/sa-ledger-' . uniqid(); + mkdir( $dir . '/foo', 0777, true ); + file_put_contents( $dir . '/foo/aaa-companion.php', "quiet( array( 'versions' => null ) ) ); // read the files + $result = array( 'destination_name' => 'foo', 'destination' => $dir . '/foo' ); + $this->run_package( array( 'type' => 'plugin', 'action' => 'install' ), 'https://downloads.wordpress.org/plugin/foo.zip', $result ); + $this->assertSame( '1.2', $this->entries()[0]['version'] ); // .php, not the alphabetically-first companion (Codex r21 on #595) + $this->run_package( array( 'plugin' => 'foo/main-file.php' ), 'https://downloads.wordpress.org/plugin/foo.zip', $result ); + $this->assertSame( '3.0', $this->entries()[0]['version'] ); // an update reads the file WordPress named + // No .php and several headers: unknown, never the sort-first companion (Codex r22 on #595). + unlink( $dir . '/foo/foo.php' ); + $this->run_package( array( 'type' => 'plugin', 'action' => 'install' ), 'https://downloads.wordpress.org/plugin/foo.zip', $result ); + $this->assertNull( $this->entries()[0]['version'] ); + // Exactly one header file: that one. + unlink( $dir . '/foo/aaa-companion.php' ); + $this->run_package( array( 'type' => 'plugin', 'action' => 'install' ), 'https://downloads.wordpress.org/plugin/foo.zip', $result ); + $this->assertSame( '3.0', $this->entries()[0]['version'] ); + array_map( 'unlink', glob( $dir . '/foo/*.php' ) ); + rmdir( $dir . '/foo' ); + rmdir( $dir ); + } + + public function test_an_unreadable_version_is_null(): void { + $this->run_package( array( 'type' => 'plugin', 'action' => 'install' ), 'https://downloads.wordpress.org/plugin/q.zip', array( 'destination_name' => 'q', 'destination' => '/srv/wp-content/plugins/q' ) ); + $this->assertNull( $this->entries()[0]['version'] ); + } + + // ---- transport and auth ---- + + public static function transports(): array { + return array( + 'siteagent beats everything' => array( array( 'siteagent' => true, 'wp_cli' => true, 'rest' => true ), 'siteagent' ), + 'wp-cli' => array( array( 'wp_cli' => true, 'cron' => true ), 'wp_cli' ), + 'core auto-update' => array( array( 'auto_update' => true, 'cron' => true ), 'auto_update' ), + 'cron' => array( array( 'cron' => true ), 'cron' ), + 'rest' => array( array( 'rest' => true, 'admin' => true, 'user_id' => 1 ), 'rest' ), + 'wp-admin with a user' => array( array( 'admin' => true, 'user_id' => 1 ), 'wp_admin' ), + 'wp-admin with nobody' => array( array( 'admin' => true ), 'unknown' ), + 'nothing known' => array( array(), 'unknown' ), + ); + } + + /** @dataProvider transports */ + public function test_transport_precedence( array $facts, string $expected ): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet( $facts ) ); + $this->assertSame( $expected, Aura_Worker_Install_Ledger::context()['transport'] ); + } + + public function test_an_automatic_skin_outside_core_auto_update_is_not_auto_update(): void { + // EMCP Pro installs over REST with Automatic_Upgrader_Skin (Ruling R1): + // a real, tokenized run through that skin stores `rest` (Codex r11 on #595). + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet( array( 'rest' => true, 'user_id' => 5, 'app_password_uuid' => 'u-1' ) ) ); + $opts = Aura_Worker_Install_Ledger::on_package_options( array( 'hook_extra' => array( 'type' => 'plugin', 'action' => 'install' ) ) ); + Aura_Worker_Install_Ledger::on_pre_download( false, 'https://downloads.wordpress.org/plugin/foo.zip', new Plugin_Upgrader( new Automatic_Upgrader_Skin() ), $opts['hook_extra'] ); + Aura_Worker_Install_Ledger::on_install_result( array( 'destination_name' => 'foo', 'destination' => '/srv/wp-content/plugins/foo' ), $opts['hook_extra'] ); + $e = $this->entries()[0]; + $this->assertSame( array( 'rest', 'application_password' ), array( $e['transport'], $e['auth'] ) ); + } + + public static function auths(): array { + return array( + 'app password wins' => array( array( 'rest' => true, 'user_id' => 5, 'rest_cookie' => true, 'app_password_uuid' => 'u-1' ), 'application_password' ), + 'rest cookie' => array( array( 'rest' => true, 'user_id' => 5, 'rest_cookie' => true ), 'cookie' ), + 'rest, a user, no cookie' => array( array( 'rest' => true, 'user_id' => 5 ), 'unknown' ), + 'rest, nobody' => array( array( 'rest' => true ), 'none' ), + 'admin screen' => array( array( 'admin' => true, 'user_id' => 1 ), 'cookie' ), + 'cli, nobody' => array( array( 'wp_cli' => true ), 'none' ), + 'cli as a user' => array( array( 'wp_cli' => true, 'user_id' => 1 ), 'unknown' ), + ); + } + + /** @dataProvider auths */ + public function test_auth( array $facts, string $expected ): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet( $facts ) ); + $this->assertSame( $expected, Aura_Worker_Install_Ledger::context()['auth'] ); + } + + public function test_route_only_on_rest_and_never_with_a_query(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet( array( 'rest' => true, 'route' => '/wp-abilities/v1/x?token=abc' ) ) ); + $this->assertSame( '/wp-abilities/v1/x', Aura_Worker_Install_Ledger::context()['route'] ); + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet( array( 'admin' => true, 'user_id' => 1, 'route' => '/stale' ) ) ); + $this->assertNull( Aura_Worker_Install_Ledger::context()['route'] ); + } + + public function test_an_unknown_uuid_names_nothing(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet( array( 'rest' => true, 'user_id' => 5, 'app_password_uuid' => 'gone' ) ) ); + $c = Aura_Worker_Install_Ledger::context(); + $this->assertSame( 'application_password', $c['auth'] ); + $this->assertNull( $c['app_password_name'] ); + } + + // ---- source ---- + + public static function sources(): array { + return array( + 'wp.org' => array( 'https://downloads.wordpress.org/plugin/foo.zip', array( 'kind' => 'wporg' ) ), + 'wp.org, any case' => array( 'HTTPS://Downloads.WordPress.org/plugin/foo.zip', array( 'kind' => 'wporg' ) ), + 'other host' => array( 'https://cdn.evil.example/p.zip?sig=1', array( 'kind' => 'remote_host', 'host' => 'cdn.evil.example' ) ), + 'uploads' => array( '/srv/wp-content/uploads/2026/09/p.zip', array( 'kind' => 'uploaded_zip' ) ), + 'other local path' => array( '/tmp/p.zip', array( 'kind' => 'local_path' ) ), + 'traversal segment' => array( '/srv/wp-content/uploads/2026/09/../../../etc/passwd.zip', array( 'kind' => 'local_path' ) ), + 'traversal, backslash normalised' => array( '/srv/wp-content/uploads\\..\\..\\etc\\passwd.zip', array( 'kind' => 'local_path' ) ), + 'another scheme' => array( 'ftp://x/p.zip', array( 'kind' => 'unknown' ) ), + 'empty' => array( '', array( 'kind' => 'unknown' ) ), + 'not a string' => array( array( 'x' ), array( 'kind' => 'unknown' ) ), + 'url with no host' => array( 'https:///p.zip', array( 'kind' => 'unknown' ) ), + ); + } + + /** @dataProvider sources */ + public function test_classify_source( $package, array $expected ): void { + $this->assertSame( $expected, Aura_Worker_Install_Ledger::classify_source( $package ) ); + } + + public function test_an_uploaded_zip_carries_its_attachment_when_one_resolves(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( $this->quiet( array( 'attachment_ids' => array( 'https://example.com/wp-content/uploads/2026/09/p.zip' => 42 ) ) ) ); + $this->assertSame( array( 'kind' => 'uploaded_zip', 'attachment_id' => 42 ), Aura_Worker_Install_Ledger::classify_source( '/srv/wp-content/uploads/2026/09/p.zip' ) ); + } + + // ---- wiring ---- + + public function test_init_registers_three_filters_and_the_daily_purge(): void { + Aura_Worker_Install_Ledger::init(); + $this->assertSame( PHP_INT_MAX, has_filter( 'upgrader_package_options', array( 'Aura_Worker_Install_Ledger', 'on_package_options' ) ) ); + $this->assertSame( PHP_INT_MAX, has_filter( 'upgrader_pre_download', array( 'Aura_Worker_Install_Ledger', 'on_pre_download' ) ) ); + $this->assertSame( PHP_INT_MAX, has_filter( 'upgrader_install_package_result', array( 'Aura_Worker_Install_Ledger', 'on_install_result' ) ) ); + $this->assertNotFalse( has_action( 'wp_scheduled_delete', array( 'Aura_Worker_Install_Ledger', 'purge_expired' ) ) ); // physical retention, daily + } + + /** + * Deactivate -> reactivate must not leave `started` unchanged (final + * review Task 1): the ledger would otherwise claim coverage over a gap it + * never observed while inactive. + * + * The call lives in `aura_worker_activate()` — once per activation call — + * not in `aura_worker_activate_site()` (Codex r1 on #139/install ledger + * 2.22.0): that per-site function runs once per site on a network-wide + * activation, but the ledger is a single NETWORK option even then, so + * calling restart_coverage() from inside it restarted the very same + * shared ledger once per site — reporting `evicted: true` on every site + * after the first, even on a fresh network-wide install where nothing had + * happened yet. `aura_worker_activate()` fires exactly once per + * activation, single-site or network-wide alike, matching the one moment + * coverage should actually move. + * + * BEFORE the per-site loop, not after (Codex r2 on #139): the loop calls + * aura_worker_activate_site() -> aura_worker_maybe_upgrade() -> + * Aura_Worker_Install_Ledger::ensure_started(), which creates a fresh + * ledger for a site that has none yet. restart_coverage() running AFTER + * the loop would then find that brand-new ledger already on disk and + * treat a fresh activation as a reactivation, marking it `evicted: true` + * before anything was ever recorded. Static source inspection, like this + * suite's other activation-hook assertions (ConnectAppPasswordTest) — + * digitizer-site-worker.php is never `require`d live by this bootstrap. + */ + public function test_activation_restarts_the_ledgers_coverage_once_per_activation(): void { + $main = (string) file_get_contents( SA_PLUGIN_DIR . '/digitizer-site-worker.php' ); + $opened = strpos( $main, 'function aura_worker_activate( $network_wide = false ) {' ); + $this->assertNotFalse( $opened, 'aura_worker_activate() must exist' ); + $activate = substr( $main, $opened ); + $next_fn = strpos( $activate, 'function ', 1 ); + $this->assertNotFalse( $next_fn, 'aura_worker_activate() must be followed by another function' ); + $activate = substr( $activate, 0, $next_fn ); // just this one function's body + + $call = strpos( $activate, 'Aura_Worker_Install_Ledger::restart_coverage()' ); + $this->assertNotFalse( $call, 'aura_worker_activate() must call Aura_Worker_Install_Ledger::restart_coverage()' ); + $before = substr( $activate, 0, $call ); + $this->assertStringContainsString( "class_exists( 'Aura_Worker_Install_Ledger' )", $before ); + $try = strrpos( $before, 'try {' ); + $this->assertNotFalse( $try, 'the call must be guarded by a try block so activation never fails because of the ledger' ); + $this->assertStringContainsString( 'catch ( \Throwable $e )', substr( $activate, $try ) ); + + // The call must precede the per-site loop (Codex r2 on #139): the + // loop's own ensure_started() must never run before this one. + $loop = strpos( $activate, "aura_worker_for_each_site( 'aura_worker_activate_site'" ); + $this->assertNotFalse( $loop, 'aura_worker_activate() must call the per-site activation loop' ); + $this->assertLessThan( $loop, $call, 'restart_coverage() must be called BEFORE the per-site activation loop, or the loop\'s own ensure_started() will have already created a fresh ledger that restart_coverage() then marks evicted' ); + } + + /** + * The per-site function must NOT also restart coverage — that would be + * once per site again on a network-wide activation, the exact defect + * test_activation_restarts_the_ledgers_coverage_once_per_activation() + * guards against from the other side (Codex r1 on #139). + */ + public function test_the_per_site_activation_function_does_not_restart_coverage(): void { + $main = (string) file_get_contents( SA_PLUGIN_DIR . '/digitizer-site-worker.php' ); + $opened = strpos( $main, 'function aura_worker_activate_site()' ); + $this->assertNotFalse( $opened, 'aura_worker_activate_site() must exist' ); + $activate_site = substr( $main, $opened ); + $next_fn = strpos( $activate_site, 'function ', 1 ); + $this->assertNotFalse( $next_fn, 'aura_worker_activate_site() must be followed by another function' ); + $activate_site = substr( $activate_site, 0, $next_fn ); // just this one function's body + + $this->assertStringNotContainsString( + 'Aura_Worker_Install_Ledger::restart_coverage()', + $activate_site, + 'aura_worker_activate_site() must not itself call restart_coverage() — the network ledger would restart once per site' + ); + } +} diff --git a/tests/unit/InstallLedgerSiteAgentTest.php b/tests/unit/InstallLedgerSiteAgentTest.php new file mode 100644 index 00000000..56c472e0 --- /dev/null +++ b/tests/unit/InstallLedgerSiteAgentTest.php @@ -0,0 +1,216 @@ + 1790424000, 'versions' => array() ) ); + Aura_Worker_Install_Ledger::ensure_started(); + } + + protected function tearDown(): void { + unset( $GLOBALS['_upgrade_effect'] ); + } + + /** What WP_Upgrader::run() does for one theme, as the upgrade effect. */ + private function theme_run(): void { + $GLOBALS['_upgrade_effect'] = static function ( $upgrader ) { + $o = Aura_Worker_Install_Ledger::on_package_options( array( 'hook_extra' => array( 'theme' => 'bar', 'type' => 'theme', 'action' => 'update' ) ) ); + Aura_Worker_Install_Ledger::on_pre_download( false, 'https://downloads.wordpress.org/theme/bar.zip', $upgrader, $o['hook_extra'] ); + Aura_Worker_Install_Ledger::on_install_result( array( 'destination_name' => 'bar', 'destination' => '/x/bar' ), $o['hook_extra'] ); + }; + } + + /** One full run through the three filters on $upgrader; returns its entry. */ + private function one_run( string $slug, $upgrader, array $extra = array( 'type' => 'plugin', 'action' => 'install' ) ): array { + $o = Aura_Worker_Install_Ledger::on_package_options( array( 'hook_extra' => $extra ) ); + Aura_Worker_Install_Ledger::on_pre_download( false, 'https://cdn.example/' . $slug . '.zip', $upgrader, $o['hook_extra'] ); + Aura_Worker_Install_Ledger::on_install_result( array( 'destination_name' => $slug, 'destination' => '/x/' . $slug ), $o['hook_extra'] ); + return Aura_Worker_Install_Ledger::report()['entries'][0]; + } + + public function test_only_runs_through_siteagents_own_upgrader_are_siteagent(): void { + $own = new Plugin_Upgrader( new Automatic_Upgrader_Skin() ); + $entries = Aura_Worker_Install_Ledger::as_siteagent( function () use ( $own ) { + // Nested runs a filter starts — before, and for the SAME plugin + // (Codex r17/r18 on #595) — go through their own upgrader. + $before = $this->one_run( 'companion', new Plugin_Upgrader() ); + $same = $this->one_run( 'own', new Plugin_Upgrader(), array( 'plugin' => 'own/own.php' ) ); + $mine = $this->one_run( 'own', $own, array( 'plugin' => 'own/own.php' ) ); + return array( $before, $same, $mine ); + }, $own ); + $this->assertSame( array( 'unknown', 'unknown', 'siteagent' ), array_column( $entries, 'transport' ) ); + } + + public function test_the_marker_ends_with_the_call_even_on_a_throw(): void { + try { + Aura_Worker_Install_Ledger::as_siteagent( static function () { + throw new RuntimeException( 'boom' ); + }, new Plugin_Upgrader() ); + } catch ( RuntimeException $e ) { + // expected + } + $this->assertSame( 'unknown', $this->one_run( 'after', new Plugin_Upgrader() )['transport'] ); + } + + /** + * A verified self-update downloads $zip_url to a temp file and installs + * from THAT local path — the temp path is never the real source (Codex r1 + * on #139, install ledger 2.22.0). as_siteagent()'s optional third + * argument carries the original package, and on_pre_download() classifies + * THAT when the run matches a claim that carries one. + */ + public function test_a_claimed_source_package_is_classified_over_the_installed_temp_path(): void { + $own = new Plugin_Upgrader( new Automatic_Upgrader_Skin() ); + $entry = Aura_Worker_Install_Ledger::as_siteagent( + function () use ( $own ) { + $o = Aura_Worker_Install_Ledger::on_package_options( array( 'hook_extra' => array( 'type' => 'plugin', 'action' => 'install' ) ) ); + Aura_Worker_Install_Ledger::on_pre_download( false, '/tmp/x.zip', $own, $o['hook_extra'] ); + Aura_Worker_Install_Ledger::on_install_result( array( 'destination_name' => 'x', 'destination' => '/x/x' ), $o['hook_extra'] ); + return Aura_Worker_Install_Ledger::report()['entries'][0]; + }, + $own, + 'https://downloads.wordpress.org/plugin/x.zip' + ); + $this->assertSame( 'siteagent', $entry['transport'] ); + $this->assertSame( array( 'kind' => 'wporg' ), $entry['source'] ); + } + + /** Without the third argument, behaviour is unchanged: the installed path is classified as before. */ + public function test_without_a_claimed_package_the_installed_path_is_classified_as_before(): void { + $own = new Plugin_Upgrader( new Automatic_Upgrader_Skin() ); + $entry = Aura_Worker_Install_Ledger::as_siteagent( + function () use ( $own ) { + $o = Aura_Worker_Install_Ledger::on_package_options( array( 'hook_extra' => array( 'type' => 'plugin', 'action' => 'install' ) ) ); + Aura_Worker_Install_Ledger::on_pre_download( false, '/tmp/y.zip', $own, $o['hook_extra'] ); + Aura_Worker_Install_Ledger::on_install_result( array( 'destination_name' => 'y', 'destination' => '/x/y' ), $o['hook_extra'] ); + return Aura_Worker_Install_Ledger::report()['entries'][0]; + }, + $own + // no third argument + ); + $this->assertSame( 'siteagent', $entry['transport'] ); + $this->assertSame( array( 'kind' => 'local_path' ), $entry['source'] ); + } + + /** A run through an upgrader that is NOT the claimed one is unaffected by another call's claimed package. */ + public function test_a_non_claimed_run_is_unaffected_by_another_calls_claimed_package(): void { + $own = new Plugin_Upgrader( new Automatic_Upgrader_Skin() ); + $other = new Plugin_Upgrader( new Automatic_Upgrader_Skin() ); + $entry = Aura_Worker_Install_Ledger::as_siteagent( + function () use ( $other ) { + return $this->one_run( 'z', $other ); + }, + $own, + 'https://downloads.wordpress.org/plugin/x.zip' + ); + $this->assertSame( 'unknown', $entry['transport'] ); + $this->assertSame( array( 'kind' => 'remote_host', 'host' => 'cdn.example' ), $entry['source'] ); + } + + /** + * A verified self-update installs OVER the existing plugin through + * Plugin_Upgrader::install(), whose hook_extra says `action: install` and + * names no plugin — install() itself has no notion of "this replaces + * something already here" (Codex r2 on #139, install ledger 2.22.0). + * as_siteagent()'s options-array third argument carries an action + * override, and the frame on_pre_download() stakes for the claimed run + * carries it through to on_install_result(), even though hook_extra + * itself never changes. + */ + public function test_a_claimed_actions_override_records_update_even_when_hook_extra_says_install(): void { + $own = new Plugin_Upgrader( new Automatic_Upgrader_Skin() ); + $entry = Aura_Worker_Install_Ledger::as_siteagent( + function () use ( $own ) { + return $this->one_run( 'self', $own, array( 'type' => 'plugin', 'action' => 'install' ) ); + }, + $own, + array( 'package' => 'https://downloads.wordpress.org/plugin/self.zip', 'action' => 'update' ) + ); + $this->assertSame( 'siteagent', $entry['transport'] ); + $this->assertSame( 'update', $entry['action'] ); + } + + /** Without an action override, a claimed run's action still comes from hook_extra, unchanged. */ + public function test_a_claimed_run_without_an_action_override_keeps_hook_extras_action(): void { + $own = new Plugin_Upgrader( new Automatic_Upgrader_Skin() ); + $entry = Aura_Worker_Install_Ledger::as_siteagent( + function () use ( $own ) { + return $this->one_run( 'plain', $own, array( 'type' => 'plugin', 'action' => 'install' ) ); + }, + $own + // no third argument + ); + $this->assertSame( 'siteagent', $entry['transport'] ); + $this->assertSame( 'install', $entry['action'] ); + } + + /** An unclaimed run's action is unaffected by another call's action override. */ + public function test_an_unclaimed_run_is_unaffected_by_another_calls_action_override(): void { + $own = new Plugin_Upgrader( new Automatic_Upgrader_Skin() ); + $other = new Plugin_Upgrader( new Automatic_Upgrader_Skin() ); + $entry = Aura_Worker_Install_Ledger::as_siteagent( + function () use ( $other ) { + return $this->one_run( 'unclaimed', $other, array( 'type' => 'plugin', 'action' => 'install' ) ); + }, + $own, + array( 'package' => 'https://downloads.wordpress.org/plugin/x.zip', 'action' => 'update' ) + ); + $this->assertSame( 'unknown', $entry['transport'] ); + $this->assertSame( 'install', $entry['action'] ); + } + + public function test_update_theme_records_exactly_one_siteagent_entry(): void { + $this->theme_run(); + ( new Aura_Worker_Updater() )->update_theme( 'bar' ); + $entries = Aura_Worker_Install_Ledger::report()['entries']; + $this->assertCount( 1, $entries ); + $this->assertSame( 'siteagent', $entries[0]['transport'] ); + $this->assertSame( 'update', $entries[0]['action'] ); + } + + /** + * Structural, not by argument name (Codex r3 on #595): EVERY call of an + * upgrader's install()/upgrade()/bulk_upgrade() in the updater, whatever + * its receiver or argument, is either inside an as_siteagent() closure or + * in one of the two functions whose runs the ledger ignores anyway (core + * and translations never reach the ledger's scope). + */ + public function test_every_upgrader_call_in_the_updater_is_wrapped(): void { + $src = (string) file_get_contents( SA_PLUGIN_DIR . '/includes/class-aura-worker-updater.php' ); + $exempt = array( 'update_core', 'update_translations' ); + preg_match_all( '/->(install|upgrade|bulk_upgrade)\s*\(/', $src, $m, PREG_OFFSET_CAPTURE ); + $this->assertNotEmpty( $m[0] ); + $wrapped = 0; + foreach ( $m[0] as $hit ) { + $before = substr( $src, 0, $hit[1] ); + preg_match_all( '/function\s+(\w+)\s*\(/', $before, $fn ); + $func = end( $fn[1] ); + if ( in_array( $func, $exempt, true ) ) { + continue; + } + $opened = strrpos( $before, 'Aura_Worker_Install_Ledger::as_siteagent(' ); + $this->assertNotFalse( $opened, "an upgrader call in {$func}() runs outside as_siteagent()" ); + // The closure opened there must still be open at the call: no + // `} );` closing an as_siteagent() between it and the call. + // Any closure terminator — `} );`, the wrapper's own + // `}, $upgrader );`, or its optional-third-argument form + // `}, $upgrader, $zip_url );` (Codex r1 on #139) — between the + // opener and the call means the call is outside it (Codex r19 on + // #595). The terminator group allows up to two trailing + // `, $arg` clauses so a claimed source package's extra argument + // does not itself defeat this guard. + $this->assertSame( 0, preg_match( '/\}\s*(,\s*\$\w+\s*){0,2}\)\s*;/', substr( $before, $opened ) ), "an upgrader call in {$func}() runs outside as_siteagent()" ); + $wrapped++; + } + $this->assertSame( 4, $wrapped ); // self-update install, update_plugin, update_theme, update_single_plugin + } +} diff --git a/tests/unit/InstallLedgerStoreTest.php b/tests/unit/InstallLedgerStoreTest.php new file mode 100644 index 00000000..07efa86b --- /dev/null +++ b/tests/unit/InstallLedgerStoreTest.php @@ -0,0 +1,642 @@ + self::NOW ) ); + } + + private function entry( int $t, string $slug = 'foo' ): array { + return array( + 'when' => gmdate( 'c', $t ), + 'type' => 'plugin', + 'action' => 'install', + 'slug' => $slug, + 'version' => '1.0', + 'transport' => 'rest', + 'user_id' => 1, + 'auth' => 'application_password', + 'app_password_name' => 'claude', + 'route' => '/mcp/elementor', + 'source' => array( 'kind' => 'wporg' ), + ); + } + + public function test_a_site_that_never_stamped_reports_since_now_and_nothing(): void { + $this->assertSame( + array( 'since' => gmdate( 'c', self::NOW ), 'entries' => array(), 'total' => 0, 'evicted' => false ), + Aura_Worker_Install_Ledger::report() + ); + } + + public function test_ensure_started_stamps_once_and_never_moves(): void { + Aura_Worker_Install_Ledger::ensure_started(); + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW + 3600 ) ); + Aura_Worker_Install_Ledger::ensure_started(); + $state = get_option( Aura_Worker_Install_Ledger::STATE_OPTION ); + $this->assertSame( array( 'started' => gmdate( 'c', self::NOW ), 'count_edge' => null, 'evicted' => false ), $state ); + $this->assertSame( gmdate( 'c', self::NOW ), Aura_Worker_Install_Ledger::report()['since'] ); + } + + // ---- restart_coverage() (deactivate -> reactivate; final review Task 1) ---- + + public function test_restart_coverage_on_a_fresh_site_behaves_like_ensure_started(): void { + Aura_Worker_Install_Ledger::restart_coverage(); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( gmdate( 'c', self::NOW ), $r['since'] ); + $this->assertSame( array(), $r['entries'] ); + $this->assertFalse( $r['evicted'] ); // a fresh site: nothing was ever evicted + $this->assertSame( + array( 'started' => gmdate( 'c', self::NOW ), 'count_edge' => null, 'evicted' => false ), + get_option( Aura_Worker_Install_Ledger::STATE_OPTION ) + ); + } + + public function test_restart_coverage_over_an_existing_ring_moves_since_and_keeps_the_rows(): void { + Aura_Worker_Install_Ledger::ensure_started(); + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW, 'kept' ) ); + $later = self::NOW + 3600; + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => $later ) ); + Aura_Worker_Install_Ledger::restart_coverage(); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( gmdate( 'c', $later ), $r['since'] ); + $this->assertTrue( $r['evicted'] ); + // The rows themselves are not deleted — restart_coverage() only moves + // the edge of coverage; report()'s own `since` hides them, but the + // ring on disk still holds them (a later restart_coverage(), or a + // bug, must not have silently wiped install history). + $this->assertSame( array( 'kept' ), array_column( get_option( Aura_Worker_Install_Ledger::OPTION ), 'slug' ) ); + } + + public function test_restart_coverage_over_an_orphaned_ring_keeps_it_and_becomes_readable(): void { + $rows = array( $this->entry( self::NOW - 60, 'evidence' ) ); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = $rows; // the state is gone: an orphan + $this->assertSame( array( 'error' => 'ledger_unreadable' ), Aura_Worker_Install_Ledger::report() ); + Aura_Worker_Install_Ledger::restart_coverage(); + $this->assertSame( $rows, get_option( Aura_Worker_Install_Ledger::OPTION ) ); // never overwritten + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( 'evidence' ), array_column( $r['entries'], 'slug' ) ); + $this->assertSame( gmdate( 'c', self::NOW ), $r['since'] ); + $this->assertTrue( $r['evicted'] ); + } + + public function test_restart_coverage_over_a_state_with_no_ring_commits_a_readable_empty_ring(): void { + Aura_Worker_Install_Ledger::ensure_started(); // the ring is created WITH the state + delete_option( Aura_Worker_Install_Ledger::OPTION ); // the rows vanish; the state stays (a state-alone orphan) + $this->assertSame( array( 'error' => 'ledger_unreadable' ), Aura_Worker_Install_Ledger::report() ); + Aura_Worker_Install_Ledger::restart_coverage(); + // storage_corrupt() treats a state without its ring as corruption + // (Codex r8 on #595): restart_coverage() must never leave that shape + // on disk, so it commits an empty ring alongside the boundary. + $this->assertSame( array(), get_option( Aura_Worker_Install_Ledger::OPTION ) ); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( gmdate( 'c', self::NOW ), array(), true ), array( $r['since'], $r['entries'], $r['evicted'] ) ); + } + + public function test_since_is_never_earlier_than_ninety_days(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW - 200 * 86400 ) ); + Aura_Worker_Install_Ledger::ensure_started(); + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW ) ); + $this->assertSame( gmdate( 'c', self::NOW - 7776000 ), Aura_Worker_Install_Ledger::report()['since'] ); + } + + public function test_append_is_newest_first_and_not_autoloaded(): void { + Aura_Worker_Install_Ledger::ensure_started(); + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW - 60, 'old' ) ); + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW, 'new' ) ); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( 'new', 'old' ), array_column( $r['entries'], 'slug' ) ); + $this->assertSame( 2, $r['total'] ); + $this->assertFalse( $r['evicted'] ); + $this->assertFalse( $GLOBALS['_rows_autoload'][ Aura_Worker_Install_Ledger::OPTION ] ); + $this->assertFalse( $GLOBALS['_rows_autoload'][ Aura_Worker_Install_Ledger::STATE_OPTION ] ); + } + + public function test_the_201st_entry_rotates_the_oldest_and_moves_since_to_the_count_edge(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW - 86400 ) ); + Aura_Worker_Install_Ledger::ensure_started(); + for ( $i = 0; $i < 201; $i++ ) { + $t = self::NOW - 3600 + $i; + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => $t ) ); + Aura_Worker_Install_Ledger::append( $this->entry( $t, 'p' . $i ) ); + } + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( 200, $r['total'] ); + $this->assertSame( 'p200', $r['entries'][0]['slug'] ); + $this->assertSame( 'p1', $r['entries'][199]['slug'] ); // p0 rotated out + $this->assertTrue( $r['evicted'] ); + $this->assertSame( gmdate( 'c', self::NOW - 3600 + 1 ), $r['since'] ); // the oldest entry still held + } + + public function test_an_append_drops_entries_past_ninety_days_and_marks_evicted(): void { + Aura_Worker_Install_Ledger::ensure_started(); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = array( $this->entry( self::NOW - 91 * 86400, 'ancient' ) ); + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW, 'fresh' ) ); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( 'fresh' ), array_column( $r['entries'], 'slug' ) ); + $this->assertTrue( $r['evicted'] ); + $this->assertTrue( get_option( Aura_Worker_Install_Ledger::STATE_OPTION )['evicted'] ); + } + + public function test_expired_entries_are_purged_by_the_read(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW - 200 * 86400 ) ); + Aura_Worker_Install_Ledger::ensure_started(); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = array( $this->entry( self::NOW - 10, 'fresh' ), $this->entry( self::NOW - 100 * 86400, 'stale' ) ); + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW ) ); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( 'fresh' ), array_column( $r['entries'], 'slug' ) ); + $this->assertTrue( $r['evicted'] ); + $this->assertSame( gmdate( 'c', self::NOW - 7776000 ), $r['since'] ); + // Personal data past 90 days is DELETED, not only hidden (Codex r3 on #595). + $this->assertSame( array( 'fresh' ), array_column( get_option( Aura_Worker_Install_Ledger::OPTION ), 'slug' ) ); + $this->assertTrue( get_option( Aura_Worker_Install_Ledger::STATE_OPTION )['evicted'] ); + } + + public function test_the_daily_purge_removes_expired_rows_without_a_read(): void { + Aura_Worker_Install_Ledger::ensure_started(); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = array( $this->entry( self::NOW - 100 * 86400, 'stale' ) ); + Aura_Worker_Install_Ledger::purge_expired(); // what core's daily wp_scheduled_delete calls (hooked in Task 2) + $this->assertSame( array(), get_option( Aura_Worker_Install_Ledger::OPTION ) ); + } + + public function test_the_daily_purge_deletes_an_unreadable_ledger_behind_a_boundary(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW - 30 * 86400 ) ); + Aura_Worker_Install_Ledger::ensure_started(); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = array( $this->entry( self::NOW - 60 ), 'broken' ); + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW ) ); + $this->assertSame( array( 'error' => 'ledger_unreadable' ), Aura_Worker_Install_Ledger::report() ); // a read names it… + Aura_Worker_Install_Ledger::purge_expired(); // …the daily pass deletes it + $this->assertSame( array(), get_option( Aura_Worker_Install_Ledger::OPTION ) ); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( gmdate( 'c', self::NOW ), array(), true ), array( $r['since'], $r['entries'], $r['evicted'] ) ); + } + + public function test_a_refused_state_write_leaves_the_entries_untouched(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW - 86400 ) ); + Aura_Worker_Install_Ledger::ensure_started(); + $stored = array(); + for ( $i = 0; $i < 200; $i++ ) { + $stored[] = $this->entry( self::NOW - 3600 - $i, 'p' . $i ); + } + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = $stored; + $GLOBALS['_sa_option_write_fail'][ Aura_Worker_Install_Ledger::STATE_OPTION ] = true; + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW ) ); + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW, 'the-201st' ) ); + $this->assertSame( $stored, get_option( Aura_Worker_Install_Ledger::OPTION ) ); // no truncation under the old edge + } + + public function test_the_boundary_write_is_retried_once(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW - 30 * 86400 ) ); + Aura_Worker_Install_Ledger::ensure_started(); + $GLOBALS['_sa_option_write_fail'][ Aura_Worker_Install_Ledger::OPTION ] = true; // the ring write is refused + $state_writes = 0; + add_filter( 'sanitize_option_' . Aura_Worker_Install_Ledger::STATE_OPTION, static function ( $v ) use ( &$state_writes ) { + if ( 1 === ++$state_writes ) { + // The new state lands; the NEXT state write — the first boundary + // attempt — is refused once (the stub's int counter). + $GLOBALS['_sa_option_write_fail'][ Aura_Worker_Install_Ledger::STATE_OPTION ] = 1; + } + return $v; + } ); + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW ) ); + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW, 'lost' ) ); + $this->assertSame( 2, $state_writes ); // the new state, then the retried boundary + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( gmdate( 'c', self::NOW ), true ), array( $r['since'], $r['evicted'] ) ); + } + + public function test_a_state_write_refused_once_is_retried_and_the_install_recorded(): void { + Aura_Worker_Install_Ledger::ensure_started(); + $GLOBALS['_sa_option_write_fail'][ Aura_Worker_Install_Ledger::STATE_OPTION ] = 1; // one transient refusal + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW, 'kept' ) ); + $this->assertSame( array( 'kept' ), array_column( Aura_Worker_Install_Ledger::report()['entries'], 'slug' ) ); // Codex r23 on #595 + } + + public function test_a_refused_entries_write_moves_coverage_to_a_boundary(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW - 30 * 86400 ) ); + Aura_Worker_Install_Ledger::ensure_started(); + $GLOBALS['_sa_option_write_fail'][ Aura_Worker_Install_Ledger::OPTION ] = true; + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW ) ); + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW, 'lost' ) ); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( gmdate( 'c', self::NOW ), $r['since'] ); // not the stamp from 30 days ago + $this->assertTrue( $r['evicted'] ); + } + + public function test_a_state_without_its_ring_is_unreadable_until_the_daily_pass(): void { + Aura_Worker_Install_Ledger::ensure_started(); + $this->assertSame( array(), get_option( Aura_Worker_Install_Ledger::OPTION ) ); // the ring is created with the state + delete_option( Aura_Worker_Install_Ledger::OPTION ); // the rows vanish; the state stays + $this->assertSame( array( 'error' => 'ledger_unreadable' ), Aura_Worker_Install_Ledger::report() ); + Aura_Worker_Install_Ledger::purge_expired(); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( gmdate( 'c', self::NOW ), true ), array( $r['since'], $r['evicted'] ) ); + } + + public function test_an_orphaned_ring_is_kept_behind_a_boundary_never_overwritten(): void { + $rows = array( $this->entry( self::NOW - 60, 'evidence' ) ); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = $rows; // the state is gone + $this->assertSame( array( 'error' => 'ledger_unreadable' ), Aura_Worker_Install_Ledger::report() ); + Aura_Worker_Install_Ledger::ensure_started(); // the next version change + $this->assertSame( $rows, get_option( Aura_Worker_Install_Ledger::OPTION ) ); // not overwritten + Aura_Worker_Install_Ledger::purge_expired(); // the daily pass + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( 'evidence' ), array_column( $r['entries'], 'slug' ) ); + $this->assertSame( array( gmdate( 'c', self::NOW ), true ), array( $r['since'], $r['evicted'] ) ); + } + + public function test_a_half_state_is_unreadable(): void { + foreach ( array( + 'started only' => array( 'started' => gmdate( 'c', self::NOW - 86400 ) ), + 'evicted a string' => array( 'started' => gmdate( 'c', self::NOW ), 'count_edge' => null, 'evicted' => 'no' ), + 'bad started' => array( 'started' => 'long ago', 'count_edge' => null, 'evicted' => false ), + 'bad count_edge' => array( 'started' => gmdate( 'c', self::NOW ), 'count_edge' => 7, 'evicted' => true ), + 'future started' => array( 'started' => gmdate( 'c', self::NOW + 400 * 86400 ), 'count_edge' => null, 'evicted' => false ), + ) as $label => $state ) { + sa_reset_state(); + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW ) ); + // A valid (empty) ring beside it, so only the state's own shape can + // make this unreadable (Codex r9 on #595). + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = array(); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::STATE_OPTION ] = $state; + $this->assertSame( array( 'error' => 'ledger_unreadable' ), Aura_Worker_Install_Ledger::report(), $label ); + } + } + + public function test_a_purge_with_nothing_expired_writes_nothing(): void { + Aura_Worker_Install_Ledger::ensure_started(); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = array( $this->entry( self::NOW, 'fresh' ) ); + $writes = 0; + foreach ( array( Aura_Worker_Install_Ledger::OPTION, Aura_Worker_Install_Ledger::STATE_OPTION ) as $opt ) { + add_filter( "sanitize_option_{$opt}", static function ( $v ) use ( &$writes ) { + $writes++; + return $v; + } ); + } + Aura_Worker_Install_Ledger::purge_expired(); + $this->assertSame( 0, $writes ); + } + + public function test_a_corrupted_option_is_an_error_not_an_empty_ledger(): void { + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = 'garbage'; + $this->assertSame( array( 'error' => 'ledger_unreadable' ), Aura_Worker_Install_Ledger::report() ); + sa_reset_state(); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::STATE_OPTION ] = 7; + $this->assertSame( array( 'error' => 'ledger_unreadable' ), Aura_Worker_Install_Ledger::report() ); + sa_reset_state(); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = array( $this->entry( self::NOW ), 'not-an-entry' ); + $this->assertSame( array( 'error' => 'ledger_unreadable' ), Aura_Worker_Install_Ledger::report() ); // one bad row, never a shorter clean ledger + foreach ( array( + 'no when' => array_diff_key( $this->entry( self::NOW ), array( 'when' => 1 ) ), + 'unparsable when' => array_merge( $this->entry( self::NOW ), array( 'when' => 'soon' ) ), + 'no source' => array_diff_key( $this->entry( self::NOW ), array( 'source' => 1 ) ), + 'no transport' => array_diff_key( $this->entry( self::NOW ), array( 'transport' => 1 ) ), + 'bad type' => array_merge( $this->entry( self::NOW ), array( 'type' => 'core' ) ), + 'no version key' => array_diff_key( $this->entry( self::NOW ), array( 'version' => 1 ) ), + 'bad transport' => array_merge( $this->entry( self::NOW ), array( 'transport' => 'rets' ) ), + 'bad auth' => array_merge( $this->entry( self::NOW ), array( 'auth' => 'bearer' ) ), + 'bad kind' => array_merge( $this->entry( self::NOW ), array( 'source' => array( 'kind' => 'martian' ) ) ), + 'host missing' => array_merge( $this->entry( self::NOW ), array( 'source' => array( 'kind' => 'remote_host' ) ) ), + 'empty host' => array_merge( $this->entry( self::NOW ), array( 'source' => array( 'kind' => 'remote_host', 'host' => '' ) ) ), + 'string attach' => array_merge( $this->entry( self::NOW ), array( 'source' => array( 'kind' => 'uploaded_zip', 'attachment_id' => '9' ) ) ), + 'extra key' => array_merge( $this->entry( self::NOW ), array( 'source' => array( 'kind' => 'wporg', 'uuid' => 'secret' ) ) ), + 'relative when' => array_merge( $this->entry( self::NOW ), array( 'when' => '+1 year' ) ), + 'future when' => array_merge( $this->entry( self::NOW ), array( 'when' => gmdate( 'c', self::NOW + 400 * 86400 ) ) ), + 'Feb 30' => array_merge( $this->entry( self::NOW ), array( 'when' => '2026-02-30T00:00:00+00:00' ) ), + 'negative user' => array_merge( $this->entry( self::NOW ), array( 'user_id' => -1 ) ), + 'long route' => array_merge( $this->entry( self::NOW ), array( 'route' => str_repeat( 'r', 201 ) ) ), + 'long name' => array_merge( $this->entry( self::NOW ), array( 'app_password_name' => str_repeat( 'n', 201 ) ) ), + 'long host' => array_merge( $this->entry( self::NOW ), array( 'source' => array( 'kind' => 'remote_host', 'host' => str_repeat( 'h', 201 ) ) ) ), + ) as $label => $row ) { + sa_reset_state(); + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW ) ); + // A valid state beside the ring, so only the row itself can make + // this unreadable (Codex r11 on #595). + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::STATE_OPTION ] = array( 'started' => gmdate( 'c', self::NOW - 86400 ), 'count_edge' => null, 'evicted' => false ); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = array( $this->entry( self::NOW ), $row ); + $this->assertSame( array( 'error' => 'ledger_unreadable' ), Aura_Worker_Install_Ledger::report(), $label ); + } + // …and the same valid pair with a good second row reads fine. + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = array( $this->entry( self::NOW ), $this->entry( self::NOW - 60, 'ok' ) ); + $this->assertSame( 2, Aura_Worker_Install_Ledger::report()['total'] ); + } + + public function test_an_append_writes_the_state_before_the_entries(): void { + Aura_Worker_Install_Ledger::ensure_started(); + $order = array(); + foreach ( array( Aura_Worker_Install_Ledger::STATE_OPTION, Aura_Worker_Install_Ledger::OPTION ) as $opt ) { + add_filter( "sanitize_option_{$opt}", static function ( $v ) use ( $opt, &$order ) { + $order[] = $opt; + return $v; + } ); + } + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW ) ); + $this->assertSame( array( Aura_Worker_Install_Ledger::STATE_OPTION, Aura_Worker_Install_Ledger::OPTION ), $order ); + } + + public function test_an_append_over_corrupted_entries_restarts_coverage_there(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW - 30 * 86400 ) ); + Aura_Worker_Install_Ledger::ensure_started(); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = 'garbage'; + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW ) ); + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW, 'after' ) ); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( 'after' ), array_column( $r['entries'], 'slug' ) ); + $this->assertSame( gmdate( 'c', self::NOW ), $r['since'] ); // not the stamp from 30 days ago + $this->assertTrue( $r['evicted'] ); + } + + public function test_an_append_over_a_malformed_row_restarts_coverage_there(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW - 30 * 86400 ) ); + Aura_Worker_Install_Ledger::ensure_started(); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = array( $this->entry( self::NOW - 60, 'kept?' ), 42 ); + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW ) ); + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW, 'after' ) ); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( 'after' ), array_column( $r['entries'], 'slug' ) ); + $this->assertSame( gmdate( 'c', self::NOW ), $r['since'] ); + $this->assertTrue( $r['evicted'] ); + } + + public function test_an_append_over_a_corrupted_state_restarts_coverage_there(): void { + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = array( $this->entry( self::NOW - 60, 'before' ) ); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::STATE_OPTION ] = 7; + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW, 'after' ) ); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( 'after' ), array_column( $r['entries'], 'slug' ) ); + $this->assertSame( gmdate( 'c', self::NOW ), $r['since'] ); + $this->assertTrue( $r['evicted'] ); + } + + public function test_multisite_keeps_both_rows_as_network_options(): void { + $GLOBALS['_is_multisite'] = true; + $GLOBALS['_network_active_plugins'][ plugin_basename( AURA_WORKER_FILE ) ] = true; // network-active: report() sees every site + Aura_Worker_Install_Ledger::ensure_started(); + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW ) ); + $this->assertArrayHasKey( Aura_Worker_Install_Ledger::OPTION, $GLOBALS['_site_options'] ); + $this->assertArrayHasKey( Aura_Worker_Install_Ledger::STATE_OPTION, $GLOBALS['_site_options'] ); + $this->assertArrayNotHasKey( Aura_Worker_Install_Ledger::OPTION, $GLOBALS['_options'] ); + $this->assertSame( 1, Aura_Worker_Install_Ledger::report()['total'] ); + } + + // ---- multisite, not network-active: `ledger_partial_network` (final review Task 2) ---- + + public function test_report_is_partial_on_multisite_when_not_network_active(): void { + $GLOBALS['_is_multisite'] = true; + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW, 'network_active' => false ) ); + Aura_Worker_Install_Ledger::ensure_started(); + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW ) ); + $this->assertSame( array( 'error' => 'ledger_partial_network' ), Aura_Worker_Install_Ledger::report() ); + } + + public function test_report_is_normal_on_multisite_when_network_active(): void { + $GLOBALS['_is_multisite'] = true; + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW, 'network_active' => true ) ); + Aura_Worker_Install_Ledger::ensure_started(); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertArrayNotHasKey( 'error', $r ); + $this->assertSame( gmdate( 'c', self::NOW ), $r['since'] ); + } + + public function test_report_never_checks_network_active_on_a_single_site(): void { + // non-multisite never checks (final review Task 2, binding): the + // probe is only consulted when is_multisite() is true. + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW, 'network_active' => false ) ); + Aura_Worker_Install_Ledger::ensure_started(); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertArrayNotHasKey( 'error', $r ); + } + + public function test_the_real_network_active_probe_reads_is_plugin_active_for_network(): void { + // No override this time: the REAL probe value, resolved through + // is_plugin_active_for_network( plugin_basename( AURA_WORKER_FILE ) ). + $GLOBALS['_is_multisite'] = true; + Aura_Worker_Install_Ledger::ensure_started(); + $this->assertSame( array( 'error' => 'ledger_partial_network' ), Aura_Worker_Install_Ledger::report() ); + $GLOBALS['_network_active_plugins'][ plugin_basename( AURA_WORKER_FILE ) ] = true; + $this->assertArrayNotHasKey( 'error', Aura_Worker_Install_Ledger::report() ); + } + + public function test_strings_are_clipped_multibyte_safe(): void { + // Counted without mbstring, so this runs where clip()'s fallback does (Codex r13 on #595). + $this->assertSame( 200, preg_match_all( '/./us', Aura_Worker_Install_Ledger::clip( str_repeat( 'ש', 300 ) ) ) ); + } + + public function test_a_clock_that_steps_back_keeps_the_ring_readable(): void { + Aura_Worker_Install_Ledger::ensure_started(); + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW, 'first' ) ); + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW - 3600 ) ); // NTP stepped the clock back + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW - 3600, 'second' ) ); + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW ) ); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( 'second', 'first' ), array_column( $r['entries'], 'slug' ) ); + $this->assertSame( gmdate( 'c', self::NOW ), $r['entries'][0]['when'] ); // clamped to the head + } + + public function test_a_count_edge_that_contradicts_the_ring_is_unreadable(): void { + // An edge stored without `evicted: true` beside it is the only shape + // edge_contradicts_ring() still refuses (controller ruling 1, Task 1 + // review round 1 narrowed this — a ring ROW older than the edge is no + // longer checked here at all; see the two tests below). + $ring = array( $this->entry( self::NOW, 'a' ), $this->entry( self::NOW - 600, 'b' ) ); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = $ring; + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::STATE_OPTION ] = array( 'started' => gmdate( 'c', self::NOW - 86400 ), 'count_edge' => gmdate( 'c', self::NOW - 600 ), 'evicted' => false ); + $this->assertSame( array( 'error' => 'ledger_unreadable' ), Aura_Worker_Install_Ledger::report() ); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::STATE_OPTION ] = array( 'started' => gmdate( 'c', self::NOW - 86400 ), 'count_edge' => gmdate( 'c', self::NOW - 600 ), 'evicted' => true ); + $this->assertSame( 2, Aura_Worker_Install_Ledger::report()['total'] ); // the edge AT the oldest row is the normal case + } + + /** + * An interrupted or raced count rotation: commit() lands the new, narrower + * count_edge (state first), but the new, shorter ring never lands — the + * OLD ring is still on disk, one row older than the edge now promises. + * Controller ruling 1 (Task 1 review round 1): this is not corruption, it + * is a row already rotated out. report() must stay readable, quietly + * dropping that one pre-edge row instead of refusing the whole ledger — + * the exact bug the reviewer reproduced (a spurious `ledger_unreadable` + * beyond spec §4.3's "can lose an entry", and the wipe that followed it). + */ + public function test_an_edge_ahead_of_the_old_ring_omits_the_pre_edge_row_but_stays_readable(): void { + $ring = array(); + for ( $i = 0; $i < 200; $i++ ) { + $ring[] = $this->entry( self::NOW - $i, 'p' . $i ); // p0 newest, p199 oldest + } + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = $ring; + // The exact interrupted state: the rotation's state write landed with + // the edge at what would be the 200th surviving row (p198); its + // entries write never did, so the ring on disk is still the one from + // BEFORE that rotation — p199 sits one row behind the edge. + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::STATE_OPTION ] = array( + 'started' => gmdate( 'c', self::NOW - 86400 ), + 'count_edge' => gmdate( 'c', self::NOW - 198 ), + 'evicted' => true, + ); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( 199, $r['total'] ); // p199 (older than the edge) is already rotated out + $this->assertSame( array_column( array_slice( $ring, 0, 199 ), 'slug' ), array_column( $r['entries'], 'slug' ) ); + $this->assertTrue( $r['evicted'] ); + } + + /** + * The same interrupted rotation, produced by an actual failed write rather + * than seeded directly: the 201st append's entries write throws (modelling + * a process that dies between the state write and the entries write). + * Controller ruling 1 regression test — before the fix, the next report() + * answered `ledger_unreadable` and the next append()'s read-modify-write + * would have wiped the whole 200-row ring instead of just dropping the one + * row past the edge. + */ + public function test_a_thrown_write_during_the_201st_append_leaves_it_recoverable_not_wiped(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW - 200 * 86400 ) ); + Aura_Worker_Install_Ledger::ensure_started(); + $stored = array(); + for ( $i = 0; $i < 200; $i++ ) { + $stored[] = $this->entry( self::NOW - 3600 - $i, 'p' . $i ); + } + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = $stored; + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW ) ); + add_filter( 'sanitize_option_' . Aura_Worker_Install_Ledger::OPTION, static function () { + throw new RuntimeException( 'the process died between the state write and the entries write' ); + } ); + $threw = false; + try { + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW, 'the-201st' ) ); + } catch ( RuntimeException $e ) { + $threw = true; + } + $this->assertTrue( $threw, 'the entries write must actually have thrown, or this test proves nothing' ); + // The state landed (with the new edge); the ring never got the new + // write — the OLD 200-row ring is still on disk, unmodified. + $this->assertSame( $stored, get_option( Aura_Worker_Install_Ledger::OPTION ) ); + unset( $GLOBALS['_filters'][ 'sanitize_option_' . Aura_Worker_Install_Ledger::OPTION ] ); // the failing write is over + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( 199, $r['total'] ); // readable: the pre-edge row (p199) is omitted, not corruption + $this->assertTrue( $r['evicted'] ); + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW, 'next' ) ); + $this->assertSame( 200, count( get_option( Aura_Worker_Install_Ledger::OPTION ) ) ); // no wipe + } + + /** + * append() must shape what it stores, not just what it reads back later. + * Controller ruling 2 (Task 1 review round 1): an unvalidated entry would + * make the NEXT read call the whole ring unreadable, and the + * read-modify-write after that would silently wipe it. + */ + public function test_append_rejects_an_invalid_entry_without_writing_anything(): void { + Aura_Worker_Install_Ledger::ensure_started(); + $this->assertTrue( Aura_Worker_Install_Ledger::append( $this->entry( self::NOW, 'good' ) ) ); + $entries_before = get_option( Aura_Worker_Install_Ledger::OPTION ); + $state_before = get_option( Aura_Worker_Install_Ledger::STATE_OPTION ); + $bad = $this->entry( self::NOW, 'bad' ); + unset( $bad['source'] ); // no source: not a row this class ever wrote + // A caller must be able to tell a rejected entry from a committed one + // without inspecting storage (Codex review round 1, Task 2): an + // in-scope caller treats false the same as append() throwing. + $this->assertFalse( Aura_Worker_Install_Ledger::append( $bad ) ); + $this->assertSame( $entries_before, get_option( Aura_Worker_Install_Ledger::OPTION ) ); + $this->assertSame( $state_before, get_option( Aura_Worker_Install_Ledger::STATE_OPTION ) ); + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( 'good' ), array_column( $r['entries'], 'slug' ) ); // readable, unaffected + } + + public function test_rows_written_before_a_large_backward_clock_correction_stay_readable(): void { + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW + 3 * 86400 ) ); + Aura_Worker_Install_Ledger::ensure_started(); + Aura_Worker_Install_Ledger::append( $this->entry( self::NOW + 3 * 86400, 'before-correction' ) ); + Aura_Worker_Install_Ledger::_set_probe_for_tests( array( 'now' => self::NOW ) ); // the clock went back 3 days + $r = Aura_Worker_Install_Ledger::report(); + $this->assertSame( array( 'before-correction' ), array_column( $r['entries'], 'slug' ) ); // not ledger_unreadable + } + + public function test_a_ring_out_of_order_is_unreadable(): void { + Aura_Worker_Install_Ledger::ensure_started(); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = array( $this->entry( self::NOW - 60, 'older' ), $this->entry( self::NOW, 'newer' ) ); + $this->assertSame( array( 'error' => 'ledger_unreadable' ), Aura_Worker_Install_Ledger::report() ); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = array( $this->entry( self::NOW, 'a' ), $this->entry( self::NOW, 'same-second' ) ); + $this->assertSame( 2, Aura_Worker_Install_Ledger::report()['total'] ); // equal stamps are fine + } + + public function test_a_ring_over_the_cap_is_unreadable(): void { + Aura_Worker_Install_Ledger::ensure_started(); + $rows = array(); + for ( $i = 0; $i < 201; $i++ ) { + $rows[] = $this->entry( self::NOW - $i, 'p' . $i ); + } + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = $rows; + $this->assertSame( array( 'error' => 'ledger_unreadable' ), Aura_Worker_Install_Ledger::report() ); + array_pop( $rows ); + $GLOBALS['_options'][ Aura_Worker_Install_Ledger::OPTION ] = $rows; // exactly 200 is fine + $this->assertSame( 200, Aura_Worker_Install_Ledger::report()['total'] ); + } + + public function test_a_long_multibyte_name_clip_wrote_is_a_valid_row(): void { + Aura_Worker_Install_Ledger::ensure_started(); + $name = Aura_Worker_Install_Ledger::clip( str_repeat( 'ש', 300 ) ); // 200 characters, 400 bytes + Aura_Worker_Install_Ledger::append( array_merge( $this->entry( self::NOW ), array( 'app_password_name' => $name ) ) ); + $this->assertSame( $name, Aura_Worker_Install_Ledger::report()['entries'][0]['app_password_name'] ); + } + + // ---- the wire shape (final review Task 6) ---- + + /** PHP 7.4 has no array_is_list(): a JSON list's keys are exactly 0..n-1. */ + private function is_json_list( array $arr ): bool { + return array() === $arr || array_keys( $arr ) === range( 0, count( $arr ) - 1 ); + } + + /** + * The tool layer json-encodes report() verbatim (audit_agent_code's + * `installs`), so its shape on the wire — a JSON list of entries, each an + * object with a fixed key order, `source` a nested object never + * re-indexed into a list — is a contract of its own, not just an array + * shape PHP happens to produce. + */ + public function test_report_round_trips_through_json_with_one_entry_per_source_kind(): void { + Aura_Worker_Install_Ledger::ensure_started(); + $sources = array( + array( 'kind' => 'wporg' ), + array( 'kind' => 'remote_host', 'host' => 'cdn.example' ), + array( 'kind' => 'uploaded_zip', 'attachment_id' => 42 ), + array( 'kind' => 'local_path' ), + array( 'kind' => 'unknown' ), + ); + foreach ( $sources as $i => $source ) { + $this->assertTrue( + Aura_Worker_Install_Ledger::append( array_merge( $this->entry( self::NOW - $i, 'p' . $i ), array( 'source' => $source ) ) ) + ); + } + + $decoded = json_decode( wp_json_encode( Aura_Worker_Install_Ledger::report() ), true ); + + $this->assertTrue( $this->is_json_list( $decoded['entries'] ), '`entries` must encode as a JSON list' ); + $this->assertSame( count( $decoded['entries'] ), $decoded['total'] ); + $this->assertIsBool( $decoded['evicted'] ); + $this->assertMatchesRegularExpression( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/', $decoded['since'] ); + + $expected_keys = array( 'when', 'type', 'action', 'slug', 'version', 'transport', 'user_id', 'auth', 'app_password_name', 'route', 'source' ); + $expected_source_keys = array( + 'wporg' => array( 'kind' ), + 'remote_host' => array( 'kind', 'host' ), + 'uploaded_zip' => array( 'kind', 'attachment_id' ), + 'local_path' => array( 'kind' ), + 'unknown' => array( 'kind' ), + ); + $this->assertCount( 5, $decoded['entries'] ); + foreach ( $decoded['entries'] as $entry ) { + $this->assertSame( $expected_keys, array_keys( $entry ), 'key order must match exactly what this class writes' ); + $this->assertIsArray( $entry['source'] ); + $this->assertFalse( $this->is_json_list( $entry['source'] ), '`source` must decode as an object, never re-indexed into a list' ); + $this->assertSame( $expected_source_keys[ $entry['source']['kind'] ], array_keys( $entry['source'] ) ); + } + } +}