Skip to content

Commit b1a9900

Browse files
authored
Add checksum to diagnostics (#3775)
1 parent 681cadf commit b1a9900

File tree

2 files changed

+142
-0
lines changed

2 files changed

+142
-0
lines changed

app/Actions/Diagnostics/Errors.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use App\Actions\Diagnostics\Pipes\Checks\DBSupportCheck;
2121
use App\Actions\Diagnostics\Pipes\Checks\ForeignKeyListInfo;
2222
use App\Actions\Diagnostics\Pipes\Checks\GDSupportCheck;
23+
use App\Actions\Diagnostics\Pipes\Checks\HashCheck;
2324
use App\Actions\Diagnostics\Pipes\Checks\IframeCheck;
2425
use App\Actions\Diagnostics\Pipes\Checks\ImageOptCheck;
2526
use App\Actions\Diagnostics\Pipes\Checks\ImagickPdfCheck;
@@ -46,6 +47,7 @@ class Errors
4647
* @var array<int,class-string>
4748
*/
4849
private array $pipes = [
50+
HashCheck::class,
4951
AdminUserExistsCheck::class,
5052
AuthDisabledCheck::class,
5153
BasicPermissionCheck::class,
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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

Comments
 (0)