class-file-hasher.php
125 lines
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace BMI\Plugin\Services; |
| 6 | |
| 7 | use RuntimeException; |
| 8 | |
| 9 | require_once __DIR__ . '/class-incremental-chained-hasher.php'; |
| 10 | |
| 11 | /** |
| 12 | * One-shot file-hashing utility. |
| 13 | * |
| 14 | * Computes either a standard (v1) or chained (v2) hash of an entire file. |
| 15 | * |
| 16 | * ── Modes ───────────────────────────────────────────────────────────────── |
| 17 | * STANDARD (v1) — equivalent to PHP's hash_file(); one digest over the |
| 18 | * whole file. |
| 19 | * |
| 20 | * CHAINED (v2) — splits the file into $chunkSize-byte links and builds a |
| 21 | * rolling chained digest via IncrementalChainedHasher. |
| 22 | * The chain formula is: |
| 23 | * Hₙ = hash(algo, Hₙ₋₁ ‖ hash(algo, piece)) |
| 24 | * |
| 25 | * ── Usage ───────────────────────────────────────────────────────────────── |
| 26 | * // Standard hash (v1) |
| 27 | * $hash = FileHasher::compute('/path/to/file.zip'); |
| 28 | * |
| 29 | * // Chained hash (v2) with CHAINED_HASH_CHUNK_SIZE MiB links and md5 algorithm |
| 30 | * $hash = FileHasher::compute('/path/to/file.zip', FileHasher::CHAINED, CHAINED_HASH_CHUNK_SIZE, 'md5'); |
| 31 | */ |
| 32 | class FileHasher |
| 33 | { |
| 34 | // ── Mode constants ───────────────────────────────────────────────────── |
| 35 | |
| 36 | /** Standard hash: hash_file() equivalent (v1). */ |
| 37 | public const STANDARD = 'standard'; |
| 38 | |
| 39 | /** Chained hash: linked rolling digest over per-chunk hashes (v2). */ |
| 40 | public const CHAINED = 'chained'; |
| 41 | |
| 42 | /** Internal read-buffer size when streaming the file (64 KiB). */ |
| 43 | private const IO_BUFFER = 65536; |
| 44 | |
| 45 | // ── Public API ───────────────────────────────────────────────────────── |
| 46 | |
| 47 | /** |
| 48 | * Compute the hash of a file. |
| 49 | * |
| 50 | * @param string $filePath Absolute path to the file. |
| 51 | * @param string $mode FileHasher::STANDARD or FileHasher::CHAINED. |
| 52 | * @param string $algorithm PHP hash() algorithm (default 'md5'). |
| 53 | * @param int|null $chunkSize Chain-link size in bytes for CHAINED mode. |
| 54 | * Ignored in STANDARD mode. |
| 55 | * Defaults to 1 MiB when null. |
| 56 | * @return string Hex-encoded hash. |
| 57 | * @throws RuntimeException If the file is not readable or the mode is unrecognised. |
| 58 | */ |
| 59 | public static function compute( |
| 60 | string $filePath, |
| 61 | string $mode = self::STANDARD, |
| 62 | int $chunkSize = CHAINED_HASH_CHUNK_SIZE, |
| 63 | string $algorithm = 'md5' |
| 64 | ): string { |
| 65 | if (!is_readable($filePath)) { |
| 66 | throw new RuntimeException('File is not readable: ' . $filePath); |
| 67 | } |
| 68 | |
| 69 | if ($mode === self::CHAINED) { |
| 70 | return self::computeChained($filePath, $algorithm, $chunkSize); |
| 71 | } |
| 72 | |
| 73 | if ($mode === self::STANDARD) { |
| 74 | return self::computeStandard($filePath, $algorithm); |
| 75 | } |
| 76 | |
| 77 | throw new RuntimeException('Unknown hash mode: ' . $mode); |
| 78 | } |
| 79 | |
| 80 | // ── Private ──────────────────────────────────────────────────────────── |
| 81 | |
| 82 | /** |
| 83 | * Standard hash: stream the file and produce a single digest. |
| 84 | */ |
| 85 | private static function computeStandard(string $filePath, string $algorithm): string |
| 86 | { |
| 87 | $hash = hash_file($algorithm, $filePath); |
| 88 | if ($hash === false) { |
| 89 | throw new RuntimeException('hash_file() failed for: ' . $filePath); |
| 90 | } |
| 91 | |
| 92 | return $hash; |
| 93 | } |
| 94 | |
| 95 | /** |
| 96 | * Chained hash: stream the file through IncrementalChainedHasher in |
| 97 | * IO_BUFFER-sized reads; chain links are $chunkSize bytes. |
| 98 | */ |
| 99 | private static function computeChained(string $filePath, string $algorithm, int $chunkSize): string |
| 100 | { |
| 101 | $handle = fopen($filePath, 'rb'); |
| 102 | if ($handle === false) { |
| 103 | throw new RuntimeException('Cannot open file for hashing: ' . $filePath); |
| 104 | } |
| 105 | |
| 106 | $hasher = new IncrementalChainedHasher($algorithm, $chunkSize); |
| 107 | |
| 108 | while (!feof($handle)) { |
| 109 | $piece = fread($handle, self::IO_BUFFER); |
| 110 | if ($piece === false || $piece === '') { |
| 111 | break; |
| 112 | } |
| 113 | $hasher->update($piece); |
| 114 | } |
| 115 | |
| 116 | fclose($handle); |
| 117 | |
| 118 | return $hasher->finalize(); |
| 119 | } |
| 120 | |
| 121 | // ── Prevent instantiation ────────────────────────────────────────────── |
| 122 | |
| 123 | private function __construct() {} |
| 124 | } |
| 125 |