Report::toArray(), * 'context' => array( reference_date, checksums_consented, started_at, finished_at, trigger ), * ) * * @package Timaub\AfterHackChecklist */ namespace Timaub\AfterHackChecklist\WordPress; use Timaub\AfterHackChecklist\Domain\PassAccumulator; use Timaub\AfterHackChecklist\Domain\Rules\ContentRules; use Timaub\AfterHackChecklist\Domain\Rules\CronRules; use Timaub\AfterHackChecklist\Domain\Rules\SettingsRules; use Timaub\AfterHackChecklist\Domain\Rules\UserRules; defined( 'ABSPATH' ) || exit; /** * Pass in batches. */ final class Pass { const STATE = 'ahcl_state'; const REPORT = 'ahcl_last_report'; const LOCK = 'ahcl_lock'; /** Default seconds per request (ahcl_step_budget; 10 s, DECISIONS 2026-10-24). */ const DEFAULT_BUDGET = 10; const PHASES = array( 'sums', 'files', 'users', 'contents', 'cron', 'options', 'settings' ); /** Progress reached when a phase starts. */ const PROGRESS = array( 'sums' => 1, 'files' => 5, 'users' => 85, 'contents' => 88, 'cron' => 92, 'options' => 94, 'settings' => 98, ); /** * Starts a pass. * * @param array $args reference_date (Y-m-d), trigger ('manual' or 'scheduled'). * @return bool False when a pass is already in progress. */ public static function start( array $args = array() ) { if ( is_array( get_option( self::STATE ) ) ) { return false; } $date = isset( $args['reference_date'] ) ? self::valid_date( (string) $args['reference_date'] ) : ''; $trigger = isset( $args['trigger'] ) && 'scheduled' === $args['trigger'] ? 'scheduled' : 'manual'; $consent = (bool) get_option( 'ahcl_checksums_consent', false ); $state = array( 'version' => 1, 'reference_date' => '' !== $date ? $date : self::default_date(), 'trigger' => $trigger, 'consent' => $consent, 'started_at' => time(), 'phase' => 'sums', 'progress' => 0, 'lots' => 0, 'todo' => array(), 'sums' => array(), 'walk' => Walker::initial(), 'cursor' => 0, 'acc' => ( new PassAccumulator() )->toArray(), ); if ( $consent ) { $state['todo'][] = array( 'kind' => 'core' ); $seen = array(); foreach ( Site::plugins() as $p ) { if ( 'hello.php' === $p['file'] ) { continue; // Compared to the core checksums (SPEC contrôle 2). } $slug = \Timaub\AfterHackChecklist\Domain\Context::slugOf( $p['file'] ); if ( ! isset( $seen[ $slug ] ) ) { $seen[ $slug ] = true; $state['todo'][] = array( 'kind' => 'plugin', 'slug' => $slug, 'version' => $p['version'], ); } } } else { $state['sums']['core'] = array( 'status' => 'not_consented' ); foreach ( Site::plugins() as $p ) { if ( 'hello.php' !== $p['file'] ) { $state['sums']['plugins'][ \Timaub\AfterHackChecklist\Domain\Context::slugOf( $p['file'] ) ] = array( 'version' => $p['version'], 'status' => 'not_consented', ); } } $state['phase'] = 'files'; } $acc = new PassAccumulator(); if ( ! Site::inside( WP_CONTENT_DIR ) ) { $acc->addNotTraversed( wp_normalize_path( WP_CONTENT_DIR ) ); } $state['acc'] = $acc->toArray(); return add_option( self::STATE, $state, '', false ); } /** Whether a pass is in progress (or interrupted). */ public static function in_progress() { return is_array( get_option( self::STATE ) ); } /** State of the pass in progress, or null. */ public static function state() { $state = get_option( self::STATE ); return is_array( $state ) ? $state : null; } /** Drops the pass in progress ("Start over"). */ public static function abandon() { delete_option( self::STATE ); delete_option( self::LOCK ); } /** * Runs one batch. * * @return array{done:bool, progress:int} */ public static function step() { $started = microtime( true ); $state = self::state(); if ( null === $state ) { return array( 'done' => true, 'progress' => 100, ); } if ( ! self::lock() ) { // "locked": admin.js waits before the next request (D-14). return array( 'done' => false, 'progress' => (int) $state['progress'], 'locked' => true, ); } try { return self::run( $state, $started ); } finally { delete_option( self::LOCK ); } } /** Seconds of work per batch (ahcl_step_budget), at least 0.1. */ private static function budget() { return max( 0.1, (float) apply_filters( 'ahcl_step_budget', self::DEFAULT_BUDGET ) ); } /** * Works on the state until the budget is spent or the pass is over. * * @param array $state State. * @param float $started microtime(true) at the start of the batch. * @return array{done:bool, progress:int, error?:string} */ private static function run( array $state, $started ) { $deadline = $started + self::budget(); $acc = PassAccumulator::fromArray( $state['acc'] ); $ctx = null; $worked = false; while ( 'done' !== $state['phase'] ) { if ( $worked && microtime( true ) >= $deadline ) { break; } $worked = true; $phase = $state['phase']; if ( 'sums' === $phase ) { if ( ! $state['todo'] ) { self::next_phase( $state ); continue; } $item = array_shift( $state['todo'] ); if ( 'core' === $item['kind'] ) { $state['sums']['core'] = Checksums::fetch_core( Site::wp_version(), Site::package_locale() ); } else { $state['sums']['plugins'][ $item['slug'] ] = array( 'version' => $item['version'], 'status' => Checksums::fetch_plugin( $item['slug'], $item['version'] ), ); } continue; } if ( null === $ctx ) { $ctx = Site::context( $state ); } if ( 'files' === $phase ) { $batch = array(); $over = Walker::run( $state['walk'], $ctx, $acc, $deadline, $batch ); if ( $batch ) { do_action( 'ahcl_batch_collected', 'files', $batch ); } $files = (int) $state['walk']['files']; $pending = count( $state['walk']['stack'] ); $state['progress'] = max( (int) $state['progress'], 5 + (int) floor( 80 * $files / ( $files + 40 * $pending + 1 ) ) ); if ( $over ) { self::next_phase( $state ); } continue; } if ( 'users' === $phase ) { $page = Collectors::users( (int) $state['cursor'] ); foreach ( $page['items'] as $user ) { $acc->addFindings( UserRules::inspect( $user, $ctx ) ); } self::batch( 'users', $page['items'], $ctx ); self::page_done( $state, $page ); continue; } if ( 'contents' === $phase ) { $page = Collectors::contents( (int) $state['cursor'] ); foreach ( $page['items'] as $content ) { $acc->addFindings( ContentRules::inspect( $content, $ctx ) ); } self::page_done( $state, $page ); continue; } if ( 'cron' === $phase ) { $events = Collectors::cron(); foreach ( $events as $event ) { $acc->addFindings( CronRules::inspect( $event, $ctx ) ); } self::batch( 'cron', $events, $ctx ); self::next_phase( $state ); continue; } if ( 'options' === $phase ) { $page = Collectors::options( (int) $state['cursor'] ); $seen = array(); foreach ( $page['items'] as $option ) { $acc->addFindings( SettingsRules::inspectOption( $option, $ctx ) ); $seen[] = array( 'name' => $option->name, 'md5' => md5( $option->value ), ); } if ( $seen ) { do_action( 'ahcl_batch_collected', 'options', $seen ); } self::page_done( $state, $page ); continue; } // Settings, then finalization. $acc->addFindings( SettingsRules::inspect( Collectors::settings(), $ctx ) ); if ( ! self::finish( $state, $acc, $ctx ) ) { // The state stays: "Resume" redoes the settings and writes the report again (D-13). return array( 'done' => false, 'progress' => (int) $state['progress'], 'error' => 'report_write_failed', ); } return array( 'done' => true, 'progress' => 100, ); } ++$state['lots']; $state['acc'] = $acc->toArray(); if ( ! update_option( self::STATE, $state, false ) ) { // The database refused the state (max_allowed_packet...): stop with a message // instead of redoing the same batch forever (D-13). The stored state is unchanged. return array( 'done' => false, 'progress' => (int) $state['progress'], 'error' => 'state_write_failed', ); } return array( 'done' => false, 'progress' => (int) $state['progress'], ); } /** * Fires ahcl_batch_collected (shapes documented in NOTES-integration.md): * users: id, login, roles (role keys of the capabilities metadata), user_registered; * cron: hook, args_md5 (md5 of the serialized arguments), timestamp. * * @param string $kind users or cron. * @param array $items UserEntry[] or CronEvent[]. * @param \Timaub\AfterHackChecklist\Domain\Context $ctx Context (role names). */ private static function batch( $kind, array $items, $ctx ) { if ( ! $items || ! has_action( 'ahcl_batch_collected' ) ) { return; } $out = array(); foreach ( $items as $item ) { if ( 'users' === $kind ) { $roles = array(); foreach ( $item->capabilities as $key => $granted ) { if ( $granted && isset( $ctx->roles[ $key ] ) ) { $roles[] = (string) $key; } } $out[] = array( 'id' => $item->id, 'login' => $item->login, 'roles' => $roles, 'user_registered' => $item->registered, ); } else { $out[] = array( 'hook' => $item->hook, 'args_md5' => md5( $item->args ), 'timestamp' => $item->timestamp, ); } } do_action( 'ahcl_batch_collected', $kind, $out ); } /** * Moves the cursor of a paged phase, and to the next phase at the end. * * @param array $state State. * @param array $page Page returned by a collector. */ private static function page_done( array &$state, array $page ) { $state['cursor'] = (int) $page['cursor']; if ( $page['done'] ) { self::next_phase( $state ); } } /** * Moves to the next phase. * * @param array $state State. */ private static function next_phase( array &$state ) { $i = array_search( $state['phase'], self::PHASES, true ); $state['phase'] = false === $i || ! isset( self::PHASES[ $i + 1 ] ) ? 'done' : self::PHASES[ $i + 1 ]; $state['cursor'] = 0; $state['progress'] = 'done' === $state['phase'] ? 100 : max( (int) $state['progress'], self::PROGRESS[ $state['phase'] ] ); } /** * Finalizes, keeps the report, fires ahcl_report_ready. ahcl_state is deleted only * after ahcl_last_report was written (D-13). * * @param array $state State. * @param PassAccumulator $acc Accumulator. * @param \Timaub\AfterHackChecklist\Domain\Context $ctx Context. * @return bool False when the report could not be written. */ private static function finish( array $state, PassAccumulator $acc, $ctx ) { // Stored and passed to the hooks already translated, in the site language (orchestrator, 2026-10-23). $switched = function_exists( 'switch_to_locale' ) && switch_to_locale( get_locale() ); $report = Renderer::translate( $acc->finalize( $ctx )->toArray() ); if ( $switched ) { restore_previous_locale(); } $context = array( 'reference_date' => $state['reference_date'], 'checksums_consented' => (bool) $state['consent'], 'started_at' => (int) $state['started_at'], 'finished_at' => time(), 'trigger' => $state['trigger'], ); $written = update_option( self::REPORT, array( 'report' => $report, 'context' => $context, 'lots' => (int) $state['lots'] + 1, ), false ); if ( ! $written ) { return false; } delete_option( self::STATE ); do_action( 'ahcl_report_ready', $report, $context ); return true; } /** Takes the step lock; a lock older than the budget plus 30 seconds is considered left behind (D-14). */ private static function lock() { if ( add_option( self::LOCK, time(), '', false ) ) { return true; } $since = (int) get_option( self::LOCK ); if ( $since > 0 && time() - $since < (int) ceil( self::budget() ) + 30 ) { return false; } delete_option( self::LOCK ); return add_option( self::LOCK, time(), '', false ); } /** Today minus 30 days, site time zone (SPEC default). */ public static function default_date() { return wp_date( 'Y-m-d', time() - 30 * DAY_IN_SECONDS ); } /** * The date if it is a real Y-m-d date, '' otherwise. * * @param string $date Typed date. * @return string */ public static function valid_date( $date ) { $d = \DateTime::createFromFormat( '!Y-m-d', $date ); return $d && $d->format( 'Y-m-d' ) === $date ? $date : ''; } }