*/ public static function start( string $key, int $epoch = 0 ): array { return array( 'expected' => Seal::genesis( $key, $epoch ), 'last_id' => 0, 'rows' => 0, 'sealed' => 0, 'redacted' => 0, 'unsealed' => 0, 'imported' => 0, 'head_seen' => false, 'counts' => array(), 'findings' => array(), ); } /** * Check a batch of rows, in id order, after the state's last row. * * @param string $key Signing key. * @param array $state A start() or earlier step() state. * @param array> $rows Rows as the database returns them. * @param array $cuts Recorded cuts. * @param array{id: int, hash: string}|null $head The verified head, when there is one. * @param bool $four_bytes Whether the table stores four-byte characters. * @return array The new state. */ public static function step( string $key, array $state, array $rows, array $cuts, ?array $head, bool $four_bytes = true ): array { foreach ( $rows as $row ) { $id = (int) ( $row['id'] ?? 0 ); $state['last_id'] = max( (int) $state['last_id'], $id ); ++$state['rows']; if ( isset( $row['import_source'] ) && '' !== (string) $row['import_source'] ) { // Another plugin's claim, brought in by the importer: never // sealed, and said so rather than counted as missing. ++$state['imported']; continue; } $hash = $row['chain_hash'] ?? null; if ( null === $hash ) { ++$state['unsealed']; $state = self::note( $state, self::UNSEALED, $id ); continue; } ++$state['sealed']; $prev = $row['chain_prev'] ?? null; $content = $row['chain_content'] ?? null; if ( ! Seal::is_digest( $hash ) || ! Seal::is_digest( $prev ) || ! Seal::is_digest( $content ) || ! Seal::same( Seal::link( $key, $prev, $content ), $hash ) ) { $state = self::note( $state, self::FORGED, $id ); continue; } if ( ! empty( $row['chain_redacted'] ) ) { ++$state['redacted']; } elseif ( ! Seal::same( Seal::content( $key, Seal::normalise( $row, $four_bytes ) ), $content ) ) { $state = self::note( $state, self::EDITED, $id ); } if ( $prev !== $state['expected'] && ! isset( Cuts::reachable( $cuts, (string) $state['expected'] )[ $prev ] ) ) { $state = self::note( $state, self::GAP, $id ); } if ( null !== $head && Seal::same( $head['hash'], $hash ) ) { $state['head_seen'] = true; } $state['expected'] = $hash; } return $state; } /** * Close a walk that has seen every row: the checks that need the whole * run, and the verdict. * * @param array $state A step() state. * @param array{id: int, hash: string}|null $head The verified head, or null when absent or unsigned. * @param array $cuts Recorded cuts. * @return array The state with `findings` completed. */ public static function finish( array $state, ?array $head, array $cuts ): array { if ( null === $head ) { // A chain that has sealed rows has written a head after each; one // missing is the first thing someone shortening the log removes. if ( (int) $state['sealed'] > 0 ) { $state = self::note( $state, self::HEAD, 0 ); } return $state; } if ( $state['head_seen'] ) { return $state; } foreach ( $cuts as $cut ) { if ( $cut['to'] === $head['hash'] ) { // The plugin's own retention removed the newest row. return $state; } } return self::note( $state, self::TAIL, (int) $head['id'] ); } /** * The verdict for a finished state. * * @param array $state A finish() state. * @return string `intact`, `warning` (unsealed rows only), `tampered`, or `empty` (nothing sealed yet). */ public static function verdict( array $state ): string { $counts = (array) $state['counts']; foreach ( array( self::FORGED, self::EDITED, self::GAP, self::TAIL, self::HEAD, self::LEDGER ) as $kind ) { if ( ! empty( $counts[ $kind ] ) ) { return 'tampered'; } } if ( ! empty( $counts[ self::UNSEALED ] ) ) { return 'warning'; } return (int) $state['sealed'] > 0 ? 'intact' : 'empty'; } /** * Record one finding: always counted, kept in full up to the limit. * * @param array $state State. * @param string $kind A finding kind. * @param int $id The row it concerns (0: none). * @return array */ public static function note( array $state, string $kind, int $id ): array { $state['counts'][ $kind ] = (int) ( $state['counts'][ $kind ] ?? 0 ) + 1; if ( count( $state['findings'] ) < self::FINDINGS_LIMIT ) { $state['findings'][] = array( 'kind' => $kind, 'id' => $id, ); } return $state; } }