' ) ) { return $t; } } return end( $targets ); } public static function start( array $keys, $target, $check_updates ) { $job = self::job(); if ( $job && in_array( $job['status'], array( 'running', 'paused' ), true ) ) { return new \WP_Error( 'leaplens_busy', __( 'A scan is already in progress. Cancel it first.', 'leaplens' ), array( 'status' => 409 ) ); } if ( ! in_array( $target, self::TARGETS, true ) ) { return new \WP_Error( 'leaplens_target', __( 'Unsupported PHP version.', 'leaplens' ), array( 'status' => 400 ) ); } $reason = Engine::unavailable_reason(); if ( '' !== $reason ) { return new \WP_Error( 'leaplens_engine', $reason, array( 'status' => 500 ) ); } $all = Components::all(); $components = array(); foreach ( array_unique( $keys ) as $key ) { if ( isset( $all[ $key ] ) ) { $components[] = self::entry( $all[ $key ] ); } } if ( ! $components ) { return new \WP_Error( 'leaplens_empty', __( 'Choose at least one plugin or theme.', 'leaplens' ), array( 'status' => 400 ) ); } self::clear(); $job = array( 'id' => wp_generate_password( 12, false ), 'target' => $target, 'baseline' => self::baseline(), 'check_updates' => (bool) $check_updates, 'status' => 'running', 'started_at' => time(), 'finished_at' => 0, 'last_step_at' => 0, 'steps' => 0, 'index' => 0, 'offset' => 0, 'poison' => array(), 'components' => $components, ); update_option( self::JOB, $job, false ); self::schedule(); return $job; } private static function entry( array $c ) { $readable = Components::readable( $c['root'] ); return array_merge( self::entry_template(), array( 'key' => $c['key'], 'type' => $c['type'], 'id' => $c['id'], 'slug' => $c['slug'], 'name' => $c['name'], 'version' => $c['version'], 'active' => $c['active'], 'root' => $c['root'], 'files' => $readable ? count( Components::files( $c['root'] ) ) : 0, 'update' => Updates::available( $c ), 'wporg' => Updates::on_wporg( $c ), 'error' => $readable ? '' : __( 'The folder could not be read.', 'leaplens' ), ) ); } private static function entry_template() { return array( 'root' => '', 'files' => 0, 'done' => 0, 'errors' => 0, 'warnings' => 0, 'existing' => 0, 'guarded' => 0, 'skipped' => array(), 'failed' => array(), 'nskipped' => 0, 'nfailed' => 0, 'status' => 'queued', 'active' => false, 'update' => null, 'wporg' => true, 'directory' => null, 'error' => '', ); } /** An update that can run on the target PHP, or null. */ public static function usable_update( array $c, $target ) { $u = $c['update']; return $u && ( '' === $u['requires_php'] || version_compare( $u['requires_php'], $target, '<=' ) ) ? $u : null; } /** Pause, resume or cancel; a step running meanwhile keeps the new status when it saves. */ public static function set_status( $status ) { $job = self::job(); if ( ! $job || in_array( $job['status'], array( 'done', 'cancelled' ), true ) ) { return $job; } $job['status'] = $status; if ( 'cancelled' === $status ) { $job['finished_at'] = time(); } update_option( self::JOB, $job, false ); if ( 'running' === $status ) { self::schedule(); } elseif ( 'cancelled' === $status && self::lock() ) { self::cleanup( $job ); self::release(); } return $job; } /** Removes the last job and every stored result. */ public static function clear() { $job = self::job(); if ( $job ) { self::cleanup( $job ); foreach ( $job['components'] as $c ) { delete_option( self::result_name( $job['id'], $c['key'] ) ); } } delete_option( self::JOB ); delete_option( self::INFLIGHT ); wp_clear_scheduled_hook( self::CRON ); } private static function cleanup( array $job ) { foreach ( $job['components'] as $c ) { if ( 'update' === $c['type'] && '' !== $c['root'] ) { Updates::remove( $c['root'] ); } } Updates::sweep(); delete_option( self::FILES ); } /** Advances the scan for about $budget seconds; returns the job, untouched when another step holds the lock. */ public static function step( $budget ) { self::$busy = ! self::lock(); if ( self::$busy ) { return self::job(); } // Freed at shutdown so a step killed by the memory limit can still release its lock. self::$reserve = str_repeat( ' ', MB_IN_BYTES ); register_shutdown_function( array( __CLASS__, 'release' ) ); try { $job = self::job(); if ( $job && 'running' === $job['status'] ) { $job = self::advance( $job, $budget ); $job['last_step_at'] = time(); ++$job['steps']; if ( ! self::save( $job ) ) { return self::job(); } if ( 'running' === $job['status'] ) { self::schedule(); } elseif ( 'cancelled' === $job['status'] ) { self::cleanup( $job ); } } return $job; } finally { self::release(); } } /** Drops this step's lock; a fatal error mid-file keeps the in-flight marker, so the next step skips that file. */ public static function release() { global $wpdb; self::$reserve = null; if ( '' !== self::$lock_token ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- only the owner may drop the lock. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", self::LOCK, self::$lock_token ) ); wp_cache_delete( self::LOCK, 'options' ); self::$lock_token = ''; } } /** WP-Cron keeps a scan going after the admin page is closed. */ public static function cron() { $job = self::job(); if ( ! $job || 'running' !== $job['status'] ) { return; } // Re-armed first: a step that dies on a fatal error must not end the chain. self::schedule(); if ( time() - (int) $job['last_step_at'] >= 30 && '' === Engine::unavailable_reason() ) { self::step( self::budget( 20 ) ); } } /** Seconds a step may take: the wanted value, kept well under max_execution_time. */ public static function budget( $wanted ) { $max = (int) ini_get( 'max_execution_time' ); return $max > 0 ? max( 1, min( $wanted, (int) floor( $max / 3 ) ) ) : $wanted; } private static function schedule() { if ( ! wp_next_scheduled( self::CRON ) ) { wp_schedule_single_event( time() + 60, self::CRON ); } } /** Atomic, with an owner token: add_option() upserts, so two steps racing through it would both win. */ private static function lock() { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the lock must bypass the options cache. $held = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", self::LOCK ) ); if ( null !== $held && time() - (int) $held > self::STALE_LOCK ) { $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", self::LOCK, $held ) ); } $token = time() . ':' . wp_generate_password( 12, false ); $won = 1 === (int) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", self::LOCK, $token ) ); // phpcs:enable wp_cache_delete( self::LOCK, 'options' ); if ( $won ) { self::$lock_token = $token; } return $won; } /** The status stored now, which Pause or Cancel may have changed during this step; null when the job was replaced. */ private static function stored_status( $id ) { wp_cache_delete( self::JOB, 'options' ); $stored = get_option( self::JOB ); return is_array( $stored ) && $stored['id'] === $id ? $stored['status'] : null; } /** Saves the step's progress without undoing a Pause or Cancel; false when a new scan replaced this one. */ private static function save( array &$job ) { $status = self::stored_status( $job['id'] ); if ( null === $status ) { return false; } if ( 'running' === $job['status'] && in_array( $status, array( 'paused', 'cancelled' ), true ) ) { $job['status'] = $status; if ( 'cancelled' === $status ) { $job['finished_at'] = time(); } } update_option( self::JOB, $job, false ); return true; } private static function mark( array $job, $offset ) { update_option( self::INFLIGHT, $job['id'] . ':' . $job['index'] . ':' . $offset, false ); } /** Records the file a crashed step died on, so later steps skip it. */ private static function poison( array $job ) { $marker = get_option( self::INFLIGHT ); if ( ! is_string( $marker ) || 0 !== strpos( $marker, $job['id'] . ':' ) ) { return $job; } update_option( self::INFLIGHT, '', false ); list( , $index, $offset ) = array_map( 'intval', explode( ':', $marker ) ); if ( ! isset( $job['components'][ $index ] ) || in_array( $index . ':' . $offset, $job['poison'], true ) ) { return $job; } $c = &$job['components'][ $index ]; if ( $offset < 0 ) { $c['error'] = __( 'Downloading or unpacking the update did not finish.', 'leaplens' ); } else { $job['poison'][] = $index . ':' . $offset; $files = Components::files( $c['root'] ); self::note( $c, 'failed', array( isset( $files[ $offset ] ) ? $files[ $offset ] : '?', __( 'The scanner stopped on this file: out of memory or time.', 'leaplens' ) ) ); } unset( $c ); self::save( $job ); return $job; } /** Bytes PHP_CodeSniffer will need for a file: about 1000 per token, comments and strings counted as one token each. */ private static function estimate( $path ) { $src = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local file inside a plugin or theme. if ( false === $src ) { return 0; } $tokens = preg_match_all( self::TOKEN_PATTERN, $src ); return ( false === $tokens ? strlen( $src ) / 2 : $tokens ) * self::BYTES_PER_TOKEN; } private static function advance( array $job, $budget ) { wp_raise_memory_limit( 'admin' ); $deadline = microtime( true ) + $budget; $memory = wp_convert_hr_to_bytes( (string) ini_get( 'memory_limit' ) ); $ceiling = $memory > 0 ? (int) ( $memory * 0.9 ) : PHP_INT_MAX; $max_size = (int) apply_filters( 'leaplens_max_file_size', MB_IN_BYTES ); $engine = new Engine( $job['target'] ); $found = array(); $scanned = 0; $job = self::poison( $job ); while ( microtime( true ) < $deadline ) { if ( 'running' !== self::stored_status( $job['id'] ) ) { break; } if ( ! isset( $job['components'][ $job['index'] ] ) ) { $job['status'] = 'done'; $job['finished_at'] = time(); delete_option( self::FILES ); break; } $c = &$job['components'][ $job['index'] ]; // A job started by 0.1.0 has no guarded counter. $c += array( 'guarded' => 0 ); if ( 'update' === $c['type'] && '' === $c['root'] && '' === $c['error'] ) { self::mark( $job, -1 ); $prepared = Updates::download( $c, $job['id'] ); if ( is_wp_error( $prepared ) ) { $c['error'] = $prepared->get_error_message(); } else { $c['root'] = $prepared; $c['files'] = count( Components::files( $prepared ) ); } unset( $c ); update_option( self::INFLIGHT, '', false ); if ( ! self::save( $job ) ) { break; } continue; } $c['status'] = 'scanning'; $files = '' === $c['error'] ? self::file_list( $job ) : array(); if ( $job['offset'] >= count( $files ) ) { self::save_found( $job['id'], $c['key'], $found ); unset( $c ); $found = array(); self::finish( $job, $job['index'] ); ++$job['index']; $job['offset'] = 0; if ( ! self::save( $job ) ) { break; } continue; } $rel = $files[ $job['offset'] ]; $path = Components::path( $c['root'], $rel ); if ( in_array( $job['index'] . ':' . $job['offset'], $job['poison'], true ) ) { ++$job['offset']; ++$c['done']; unset( $c ); continue; } $size = @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a vanished file is reported below. if ( false === $size ) { self::note( $c, 'failed', array( $rel, __( 'The file could not be read.', 'leaplens' ) ) ); } elseif ( $size > $max_size ) { self::note( $c, 'skipped', $rel ); } else { $need = self::estimate( $path ) + ( $engine->booted() ? 0 : Engine::BOOT_BYTES ); if ( memory_get_usage() + $need >= $ceiling ) { if ( $scanned > 0 ) { unset( $c ); break; } self::note( $c, 'failed', array( $rel, __( 'Too large for the PHP memory limit on this server.', 'leaplens' ) ) ); } else { ++$scanned; self::mark( $job, $job['offset'] ); try { $found[ $rel ] = array(); foreach ( $engine->check_file( $path ) as $f ) { list( $since, $existing ) = self::classify( $f[3], $job['target'], $job['baseline'] ); $guarded = ! $existing && $f[4]; if ( $existing ) { ++$c['existing']; } elseif ( $guarded ) { ++$c['guarded']; } elseif ( 'error' === $f[1] ) { ++$c['errors']; } else { ++$c['warnings']; } $found[ $rel ][] = array( $f[0], $f[1], $f[2], $f[3], $since, $existing, $guarded ); } } catch ( \Throwable $e ) { unset( $found[ $rel ] ); self::note( $c, 'failed', array( $rel, wp_strip_all_tags( $e->getMessage() ) ) ); } update_option( self::INFLIGHT, '', false ); } } ++$job['offset']; ++$c['done']; unset( $c ); } if ( isset( $job['components'][ $job['index'] ] ) ) { self::save_found( $job['id'], $job['components'][ $job['index'] ]['key'], $found ); } return $job; } private static function file_list( array $job ) { $cached = get_option( self::FILES ); $tag = $job['id'] . ':' . $job['index']; if ( is_array( $cached ) && $tag === $cached['tag'] ) { return $cached['files']; } $files = Components::files( $job['components'][ $job['index'] ]['root'] ); update_option( self::FILES, array( 'tag' => $tag, 'files' => $files, ), false ); return $files; } private static function finish( array &$job, $index ) { $c = &$job['components'][ $index ]; $c['status'] = 'done'; $c['files'] = max( $c['files'], $c['done'] ); if ( 'update' === $c['type'] ) { Updates::remove( $c['root'] ); $p = $index - 1; if ( $c['errors'] > 0 && isset( $job['components'][ $p ] ) && $job['components'][ $p ]['key'] === $c['of'] ) { $job['components'][ $p ]['directory'] = Updates::directory( $job['components'][ $p ] ); } return; } $update = self::usable_update( $c, $job['target'] ); if ( $c['errors'] > 0 && $update && $job['check_updates'] && Updates::downloadable( $update['package'] ) ) { $pseudo = array_merge( self::entry_template(), array( 'key' => 'update:' . $c['key'], 'type' => 'update', 'of' => $c['key'], 'id' => $c['id'], 'slug' => $c['slug'], 'name' => $c['name'], 'version' => $update['version'], 'package' => $update['package'], ) ); array_splice( $job['components'], $index + 1, 0, array( $pseudo ) ); } elseif ( $c['errors'] > 0 && ! $update ) { $c['directory'] = Updates::directory( $c ); } } /** Lists a file as failed or skipped; the list is capped, the count is not. */ private static function note( array &$c, $list, $item ) { ++$c[ 'n' . $list ]; if ( count( $c[ $list ] ) < self::MAX_LISTED ) { $c[ $list ][] = $item; } } private static function result_name( $job_id, $key ) { return self::RESULT . md5( $job_id . '|' . $key ); } /** Stores findings per file, overwriting: a step that rescans files after a crash never stores them twice. */ private static function save_found( $job_id, $key, array $found ) { if ( ! $found ) { return; } $stored = self::findings( $job_id, $key ); foreach ( $found as $rel => $list ) { $stored['files'][ $rel ] = $list; } $count = 0; foreach ( $stored['files'] as $rel => $list ) { if ( ! $list ) { unset( $stored['files'][ $rel ] ); continue; } $room = self::MAX_FINDINGS - $count; if ( count( $list ) > $room ) { $stored['files'][ $rel ] = array_slice( $list, 0, max( 0, $room ) ); $stored['truncated'] = true; } $count += count( $stored['files'][ $rel ] ); } $stored['files'] = array_filter( $stored['files'] ); $stored['count'] = $count; update_option( self::result_name( $job_id, $key ), $stored, false ); } /** Findings of one component: [line, type, code, message, since, existing, guarded] per file. */ public static function findings( $job_id, $key ) { $stored = get_option( self::result_name( $job_id, $key ) ); return is_array( $stored ) ? $stored : array( 'files' => array(), 'count' => 0, 'truncated' => false, ); } /** [since, existing]: the PHP version a finding is about; existing when it already applies to the running PHP, so the target adds nothing. */ public static function classify( $message, $target, $baseline ) { $since = ''; if ( preg_match_all( '/(?:PHP|version) (?:version )?(\d+\.\d+)/', $message, $m ) ) { foreach ( $m[1] as $v ) { if ( version_compare( $v, $target, '<=' ) && ( '' === $since || version_compare( $v, $since, '>' ) ) ) { $since = $v; } } } // A finding without a version applies to every PHP version, the running one included. $existing = version_compare( $baseline, $target, '<' ) && ( '' === $since || version_compare( $since, $baseline, '<=' ) ); return array( $since, $existing ); } }