addFile( FileEntry, Context ) every file of every batch * $acc->addFindings( UserRules::inspect( ... ) ) users, contents, cron, options, settings * $acc->addNotTraversed( 'wp-content/cache' ) folder skipped (budget, permissions) * $report = $acc->finalize( Context ) once, at the end * * @package Timaub\AfterHackChecklist */ namespace Timaub\AfterHackChecklist\Domain; final class PassAccumulator { /** @var Finding[] */ private $findings = array(); /** @var array */ private $counted = array(); /** @var array */ private $aggregates = array(); /** @var array */ private $coreSeen = array(); /** @var array{count:int, items:string[]} */ private $unreadable = array( 'count' => 0, 'items' => array(), ); /** @var array{count:int, items:string[]} */ private $tooLarge = array( 'count' => 0, 'items' => array(), ); /** @var array{count:int, items:string[]} Files above HASH_MAX_BYTES in the hashed zones (D-05). */ private $tooLargeToCompare = array( 'count' => 0, 'items' => array(), ); /** @var array Unjudged content folder => PHP files (Q-01). */ private $unjudged = array(); /** @var array Code => findings kept (D-13). */ private $kept = array(); /** @var array Code => findings counted beyond FINDINGS_PER_CODE_MAX (D-13). */ private $capped = array(); /** @var string[] */ private $notTraversed = array(); /** @var bool */ private $helloSeen = false; /** * Inspects one file and keeps what matters. * * @return Finding[] Findings of this file (members and COUNTED included), for logging. */ public function addFile( FileEntry $f, Context $ctx ): array { $key = FileInspector::coreKey( $f, $ctx ); if ( null !== $key ) { $this->coreSeen[ $key ] = true; } if ( $f->path === $ctx->pluginsDir . '/hello.php' ) { $this->helloSeen = true; } if ( ! $f->readable ) { self::push( $this->unreadable, $f->path ); return array(); } if ( FileInspector::tooLarge( $f, $ctx ) ) { self::push( $this->tooLarge, $f->path ); } if ( FileInspector::tooLargeToCompare( $f, $ctx ) ) { self::push( $this->tooLargeToCompare, $f->path ); } $folder = FileInspector::unjudgedContentFolder( $f, $ctx ); if ( null !== $folder ) { $this->unjudged[ $folder ] = ( isset( $this->unjudged[ $folder ] ) ? $this->unjudged[ $folder ] : 0 ) + 1; } $found = FileInspector::inspect( $f, $ctx ); $this->addFindings( $found ); return $found; } /** @param Finding[] $findings Output of any rule. */ public function addFindings( array $findings ): void { foreach ( $findings as $f ) { if ( Level::COUNTED === $f->level ) { $this->counted[ $f->code ] = ( isset( $this->counted[ $f->code ] ) ? $this->counted[ $f->code ] : 0 ) + 1; } elseif ( isset( Catalogue::AGGREGATES[ $f->code ] ) ) { $this->aggregate( $f ); } else { $this->keep( $this->findings, $f ); } } } /** * Appends a finding, at most FINDINGS_PER_CODE_MAX per code; the next ones are counted (D-13). * * @param Finding[] $into List kept. * @param Finding $f Finding. */ private function keep( array &$into, Finding $f ): void { $n = isset( $this->kept[ $f->code ] ) ? $this->kept[ $f->code ] : 0; if ( $n >= Assumptions::FINDINGS_PER_CODE_MAX ) { $this->capped[ $f->code ] = ( isset( $this->capped[ $f->code ] ) ? $this->capped[ $f->code ] : 0 ) + 1; return; } $this->kept[ $f->code ] = $n + 1; $into[] = $f; } public function addNotTraversed( string $dir ): void { $dir = trim( str_replace( '\\', '/', $dir ), '/' ); if ( ! in_array( $dir, $this->notTraversed, true ) ) { $this->notTraversed[] = $dir; } } private function aggregate( Finding $f ): void { list( $summary, $groupIndex ) = Catalogue::AGGREGATES[ $f->code ]; $group = null === $groupIndex ? null : ( isset( $f->args[ $groupIndex ] ) ? $f->args[ $groupIndex ] : '' ); $k = $summary . '|' . (string) $group; if ( ! isset( $this->aggregates[ $k ] ) ) { $this->aggregates[ $k ] = array( 'code' => $summary, 'group' => $group, 'count' => 0, 'items' => array(), ); } ++$this->aggregates[ $k ]['count']; if ( count( $this->aggregates[ $k ]['items'] ) < Assumptions::AGGREGATE_LISTED_MAX ) { $this->aggregates[ $k ]['items'][] = array( 'subject' => $f->subject, 'args' => $f->args, 'text' => $f->compared, ); } } /** @param array{count:int, items:string[]} $list */ private static function push( array &$list, string $path ): void { ++$list['count']; if ( count( $list['items'] ) < Assumptions::UNREADABLE_LISTED_MAX ) { $list['items'][] = $path; } } public function finalize( Context $ctx ): Report { $findings = $this->findings; // finalize() leaves the state as it was: the cap of core_missing works on copies. $kept = $this->kept; $capped = $this->capped; // Control 1: files of the checksums not found on disk. $core = $ctx->coreChecksums; if ( null !== $core ) { foreach ( $core->sums as $path => $md5 ) { if ( Text::startsWith( $path, Assumptions::CORE_IGNORED_PREFIX ) || isset( $this->coreSeen[ $path ] ) ) { continue; } if ( $core->isFallback() && in_array( $path, Assumptions::LOCALE_DEPENDENT_FILES, true ) ) { continue; } if ( $this->inNotTraversed( $path ) ) { continue; } $this->keep( $findings, Finding::make( 'core_missing', $path, array( $path, $core->version, $core->locale ) ) ); } } // Aggregated "to identify" lines. foreach ( $this->aggregates as $a ) { if ( 'privileged_users' === $a['code'] ) { $args = array( $a['count'], $ctx->referenceDate ); } elseif ( 'content_recent_by_type' === $a['code'] ) { $args = array( $a['group'], $a['count'], $ctx->referenceDate ); } elseif ( 'other_dir_php_by_folder' === $a['code'] ) { $args = array( $a['group'], $a['count'] ); } else { $args = array( $a['count'] ); } $findings[] = Finding::make( $a['code'], $a['code'] . ( null === $a['group'] ? '' : ' ' . $a['group'] ), $args, '', $a['items'] ); } $report = Report::build( $findings, $this->notLooked( $ctx ), $this->counted, $this->capped ); $this->kept = $kept; $this->capped = $capped; return $report; } private function inNotTraversed( string $path ): bool { foreach ( $this->notTraversed as $dir ) { if ( '' === $dir || Context::under( $path, $dir ) ) { return true; } } return false; } /** @return NotLookedEntry[] */ private function notLooked( Context $ctx ): array { $out = array(); foreach ( Catalogue::NOT_LOOKED_FIXED as $code ) { $out[] = NotLookedEntry::make( $code ); } $core = $ctx->coreChecksums; if ( null === $core ) { switch ( (string) $ctx->coreUnavailable ) { case 'not_consented': $out[] = NotLookedEntry::make( 'core_not_consented' ); break; case 'network_error': $out[] = NotLookedEntry::make( 'core_network_error' ); break; case 'not_published': $out[] = NotLookedEntry::make( 'core_not_published', array( $ctx->wpVersion, $ctx->locale ) ); break; default: $out[] = NotLookedEntry::make( 'core_unexpected_response' ); } if ( $this->helloSeen ) { $out[] = NotLookedEntry::make( 'hello_no_core_sums' ); } } elseif ( $core->isFallback() ) { foreach ( Assumptions::LOCALE_DEPENDENT_FILES as $file ) { $out[] = NotLookedEntry::make( 'core_locale_fallback', array( $core->requestedLocale, $file ) ); } } $done = array(); $notConsented = false; foreach ( $ctx->plugins as $p ) { if ( 'hello.php' === $p['file'] ) { continue; } $slug = Context::slugOf( $p['file'] ); if ( isset( $done[ $slug ] ) || isset( $ctx->pluginChecksums[ $slug ] ) ) { continue; } $done[ $slug ] = true; $reason = isset( $ctx->pluginUnavailable[ $slug ] ) ? $ctx->pluginUnavailable[ $slug ] : ''; if ( 'not_consented' === $reason ) { $notConsented = true; } elseif ( 'network_error' === $reason ) { $out[] = NotLookedEntry::make( 'plugin_network_error', array( $slug, $p['version'] ) ); } elseif ( 'no_sums_for_version' === $reason ) { $out[] = NotLookedEntry::make( 'plugin_no_sums_for_version', array( $slug, $p['version'] ) ); } elseif ( 'not_on_wporg' === $reason ) { $out[] = NotLookedEntry::make( 'plugin_not_on_wporg', array( $slug ) ); } elseif ( false === strpos( $p['file'], '/' ) ) { $out[] = NotLookedEntry::make( 'plugin_single_file_no_sums', array( $p['file'] ) ); } else { $out[] = NotLookedEntry::make( 'plugin_not_compared', array( $slug, $p['version'] ) ); } } if ( $notConsented ) { $out[] = NotLookedEntry::make( 'plugins_not_consented' ); } if ( $this->unreadable['count'] > 0 ) { $out[] = NotLookedEntry::make( 'unreadable_files', array( $this->unreadable['count'] ), $this->unreadable['items'] ); } if ( $this->tooLarge['count'] > 0 ) { $out[] = NotLookedEntry::make( 'too_large_files', array( $this->tooLarge['count'] ), $this->tooLarge['items'] ); } if ( $this->tooLargeToCompare['count'] > 0 ) { $out[] = NotLookedEntry::make( 'too_large_to_compare', array( $this->tooLargeToCompare['count'] ), $this->tooLargeToCompare['items'] ); } $unjudged = $this->unjudged; ksort( $unjudged ); foreach ( $unjudged as $folder => $count ) { $out[] = NotLookedEntry::make( 'content_folder_unjudged', array( $folder, $count ) ); } foreach ( $this->notTraversed as $dir ) { $out[] = NotLookedEntry::make( 'folder_not_traversed', array( '' === $dir ? '/' : $dir ) ); } return $out; } public function toArray(): array { return array( 'findings' => array_map( function ( Finding $f ) { return $f->toArray(); }, $this->findings ), 'counted' => $this->counted, 'aggregates' => array_values( $this->aggregates ), 'coreSeen' => array_keys( $this->coreSeen ), 'unreadable' => $this->unreadable, 'tooLarge' => $this->tooLarge, 'tooLargeCmp' => $this->tooLargeToCompare, 'unjudged' => $this->unjudged, 'kept' => $this->kept, 'capped' => $this->capped, 'notTraversed' => $this->notTraversed, 'helloSeen' => $this->helloSeen, ); } public static function fromArray( array $a ): self { $acc = new self(); $acc->findings = array_map( array( Finding::class, 'fromArray' ), isset( $a['findings'] ) ? (array) $a['findings'] : array() ); $acc->counted = isset( $a['counted'] ) ? array_map( 'intval', (array) $a['counted'] ) : array(); foreach ( isset( $a['aggregates'] ) ? (array) $a['aggregates'] : array() as $g ) { $acc->aggregates[ $g['code'] . '|' . (string) $g['group'] ] = $g; } $acc->coreSeen = array_fill_keys( isset( $a['coreSeen'] ) ? (array) $a['coreSeen'] : array(), true ); $acc->unreadable = isset( $a['unreadable'] ) ? $a['unreadable'] : $acc->unreadable; $acc->tooLarge = isset( $a['tooLarge'] ) ? $a['tooLarge'] : $acc->tooLarge; $acc->unjudged = isset( $a['unjudged'] ) ? array_map( 'intval', (array) $a['unjudged'] ) : array(); $acc->kept = isset( $a['kept'] ) ? array_map( 'intval', (array) $a['kept'] ) : array(); $acc->capped = isset( $a['capped'] ) ? array_map( 'intval', (array) $a['capped'] ) : array(); if ( isset( $a['tooLargeCmp'] ) ) { $acc->tooLargeToCompare = $a['tooLargeCmp']; } $acc->notTraversed = isset( $a['notTraversed'] ) ? (array) $a['notTraversed'] : array(); $acc->helloSeen = ! empty( $a['helloSeen'] ); return $acc; } }