|
| 1 | +<?php |
| 2 | + |
| 3 | +/** |
| 4 | + * SPDX-License-Identifier: MIT |
| 5 | + * Copyright (c) 2017-2018 Tobias Reich |
| 6 | + * Copyright (c) 2018-2025 LycheeOrg. |
| 7 | + */ |
| 8 | + |
| 9 | +namespace App\Actions\Diagnostics\Pipes\Checks; |
| 10 | + |
| 11 | +use App\Contracts\DiagnosticPipe; |
| 12 | +use App\DTO\DiagnosticData; |
| 13 | +use function Safe\hash_update_file; |
| 14 | + |
| 15 | +/** |
| 16 | + * Calculate the hash of Lychee installation to validate integrity. |
| 17 | + */ |
| 18 | +class HashCheck implements DiagnosticPipe |
| 19 | +{ |
| 20 | + /** |
| 21 | + * {@inheritDoc} |
| 22 | + */ |
| 23 | + public function handle(array &$data, \Closure $next): array |
| 24 | + { |
| 25 | + $paths_to_scan = [ |
| 26 | + app_path(), |
| 27 | + base_path('bootstrap'), |
| 28 | + config_path(), |
| 29 | + base_path('database/migrations'), |
| 30 | + lang_path(), |
| 31 | + resource_path(), |
| 32 | + public_path('build'), |
| 33 | + base_path('routes'), |
| 34 | + base_path('version.md'), |
| 35 | + base_path('composer.json'), |
| 36 | + base_path('composer.lock'), |
| 37 | + ]; |
| 38 | + |
| 39 | + $files = $this->collectFiles($paths_to_scan); |
| 40 | + $files_hash = $this->computeHash($files, 'xxh3'); |
| 41 | + |
| 42 | + $vendor_files = $this->collectFiles([base_path('vendor')]); |
| 43 | + $vendor_hash = $this->computeHash($vendor_files, 'xxh3'); |
| 44 | + |
| 45 | + $data[] = DiagnosticData::info('Hash: ' . $files_hash . '—' . $vendor_hash, self::class, [ |
| 46 | + (string) count($files) . ' files and ' . (string) count($vendor_files) . ' vendor files', |
| 47 | + ]); |
| 48 | + |
| 49 | + return $next($data); |
| 50 | + } |
| 51 | + |
| 52 | + /** |
| 53 | + * Collect all files from the provided paths (files or directories). |
| 54 | + * |
| 55 | + * @param string[] $paths |
| 56 | + * |
| 57 | + * @return string[] absolute, sorted file paths |
| 58 | + */ |
| 59 | + private function collectFiles(array $paths): array |
| 60 | + { |
| 61 | + $file_list = []; |
| 62 | + |
| 63 | + foreach ($paths as $path) { |
| 64 | + if (!is_string($path)) { |
| 65 | + continue; |
| 66 | + } |
| 67 | + |
| 68 | + if (is_file($path)) { |
| 69 | + $file_list[] = $path; |
| 70 | + continue; |
| 71 | + } |
| 72 | + |
| 73 | + if (is_dir($path)) { |
| 74 | + $directory_iterator = new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS); |
| 75 | + $iterator = new \RecursiveIteratorIterator($directory_iterator); |
| 76 | + foreach ($iterator as $file_info) { |
| 77 | + if ($file_info instanceof \SplFileInfo && $file_info->isFile()) { |
| 78 | + $file_list[] = $file_info->getPathname(); |
| 79 | + } |
| 80 | + } |
| 81 | + } |
| 82 | + } |
| 83 | + |
| 84 | + // Sort deterministically by path to ensure stable hash |
| 85 | + sort($file_list, SORT_STRING); |
| 86 | + |
| 87 | + return $file_list; |
| 88 | + } |
| 89 | + |
| 90 | + /** |
| 91 | + * Compute a combined hash of the provided files using incremental hashing. |
| 92 | + * The file path is included alongside file contents to prevent collisions |
| 93 | + * where different files contain identical data. |
| 94 | + * |
| 95 | + * @param string[] $files |
| 96 | + * @param string $algo |
| 97 | + * |
| 98 | + * @return string hex-encoded hash |
| 99 | + */ |
| 100 | + private function computeHash(array $files, string $algo): string |
| 101 | + { |
| 102 | + $selected_algo = in_array($algo, hash_algos(), true) ? $algo : 'sha256'; |
| 103 | + $ctx = hash_init($selected_algo); |
| 104 | + |
| 105 | + foreach ($files as $file_path) { |
| 106 | + // Add the path itself to the stream for extra safety and ordering |
| 107 | + $rel = $this->normalizePath($file_path); |
| 108 | + hash_update($ctx, 'PATH::' . $rel . "\n"); |
| 109 | + if (is_readable($file_path) && is_file($file_path)) { |
| 110 | + // Update with file content; ignore errors silently (e.g., permission changes) |
| 111 | + try { |
| 112 | + hash_update_file($ctx, $file_path); |
| 113 | + } catch (\Exception $e) { |
| 114 | + hash_update($ctx, "UNREADABLE\n"); |
| 115 | + } |
| 116 | + } else { |
| 117 | + hash_update($ctx, "UNREADABLE\n"); |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + return hash_final($ctx); |
| 122 | + } |
| 123 | + |
| 124 | + /** |
| 125 | + * Normalize absolute path to a base-relative, forward‑slash path for stable hashing. |
| 126 | + */ |
| 127 | + private function normalizePath(string $abs_path): string |
| 128 | + { |
| 129 | + $base = rtrim(base_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; |
| 130 | + if (strncmp($abs_path, $base, strlen($base)) === 0) { |
| 131 | + $rel = substr($abs_path, strlen($base)); |
| 132 | + } else { |
| 133 | + $rel = $abs_path; |
| 134 | + } |
| 135 | + // Normalize separators for cross‑platform stability |
| 136 | + $rel = str_replace('\\', '/', $rel); |
| 137 | + |
| 138 | + return ltrim($rel, '/'); |
| 139 | + } |
| 140 | +} |
0 commit comments