mfonte / sparsedigest
Fast, deterministic file hashing for change detection: samples selected blocks of large binary files instead of reading them in full, and falls back to a full-file hash for text files. Not cryptographic.
Requires
- php: ^7.2 || ^8.0
- ext-hash: *
Requires (Dev)
- ext-mbstring: *
- friendsofphp/php-cs-fixer: ^3.4
- phpbench/phpbench: ^0.17 || ^1.2
- phpstan/phpstan: ^1.12
- phpunit/phpunit: ^8.5.21
Suggests
- ext-fileinfo: Required by File::getMimeType() to detect MIME types; without it, "application/octet-stream" is returned.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-09 06:11:01 UTC
README
SparseDigest is a PHP library that provides fast file hashing by intelligently sampling chunks of data instead of hashing the entire content.
Tip
In a hurry? Go to Installation and Usage Examples to get started quickly!
SparseDigest is a sampling hash: it fingerprints a file from a fixed, deterministic subset of its blocks. Note that it is not a locality-sensitive or fuzzy hash: two similar files do not produce similar digests. The output is a regular digest of the sampled bytes, so any change inside a sampled block produces a completely different hash.
It's designed to be significantly faster than traditional full-file hashing, especially for large files, by using a technique called sampled hashing. This involves hashing only specific portions (samples) of the file, rather than the entire content.
For large files (typically binary or compressed), the sampling approach dramatically reduces I/O overhead. For text-based files, where even a minor modification outside the sampled regions may be critical, SparseDigest automatically falls back to a full-file hash.
The implementation is deterministic. On the same input, with the same sampling parameters, it will always produce the same hash.
The amount of data read is proportional to coverage: with the default 10% coverage, SparseDigest reads about a tenth of the file, so it is still linear in file size but with a much smaller constant than a full-file hash. In the benchmarks below this translates into a 3-5× speedup over hash_file() for binary files larger than ~1MB.
For a detailed performance comparison of Sampled Hashing vs Native Hashing (Full Hash), refer to PERFORMANCE.md.
Performance Highlights
Based on real benchmarks (PHP 8.3, PHPBench 1.2.14):
| Scenario | SparseDigest | Native hash_file | Winner |
|---|---|---|---|
| 1MB binary file | ~37μs | ~111μs (xxh3) | ✅ SparseDigest 3× faster |
| 5MB binary file | ~174μs | ~806μs (xxh3) | ✅ SparseDigest 4.6× faster |
| 10MB binary file | ~399μs | ~1.8ms (xxh3) | ✅ SparseDigest 4.5× faster |
| 100MB binary file | ~4.8ms | ~18.2ms (xxh3) | ✅ SparseDigest 3.8× faster |
| Batch of large media (3×100MB) | ~12.4ms | ~47ms | ✅ SparseDigest 3.8× faster |
| Repeated hashing (5×) | ~28.3ms | ~110.7ms | ✅ SparseDigest 3.9× faster |
| Small files (<500KB) | ~16μs | ~13μs | ❌ Native 1.3× faster |
Key insights:
- Break-even point: ~500KB-1MB — SparseDigest becomes faster than native
hash_file()for files larger than this threshold. - Constant memory usage: ~1.85MB — Memory is now constant regardless of file size, thanks to incremental hashing.
- Algorithm choice is irrelevant for sparse hashing — With only 10% of data processed, all algorithms perform similarly.
- Massive speedup on large files — Up to 5× faster than native
hash_file(xxh3)on typical file sizes.
Important
When to use native hash_file() instead:
- Files smaller than 500KB
- Text/document files (library falls back anyway)
- Cryptographic integrity requirements
TL;DR
composer require mfonte/sparsedigest
A minimum PHP 7.2 environment is required. There are no runtime dependencies.
Minimal usage example:
use Mfonte\SparseDigest\SparseHasher; use Mfonte\SparseDigest\FileHasher; // Sparse hashing (fast, samples only portions of the file) $sd = SparseHasher::new('/path/to/file'); $sparseHash = $sd->hash(); echo "Sparse Hash: " . $sparseHash->toHex() . "\n"; // Full-file hashing (reads the entire file) $fd = FileHasher::new('/path/to/file'); $fullHash = $fd->hash(); echo "Full Hash: " . $fullHash->toHex() . "\n";
For a complete list of examples, refer to the Usage Examples section.
Key Features
-
Sparse Hashing (
SparseHasher):- hash(): Computes a sampled hash using the fastest available algorithm. Accepts
coverage(1-100%) andsampleSizeparameters. - hashAs($algo): Same as
hash(), but uses the specified algorithm for the output representation. - For files detected as text, or too small to be sampled, automatically falls back to full-file hashing.
- hash(): Computes a sampled hash using the fastest available algorithm. Accepts
-
Full Hashing (
FileHasher):- hash(): Computes a complete hash of the entire file using the fastest available algorithm.
- hashAs($algo): Same as
hash(), but uses the specified algorithm (e.g., 'sha256', 'md5'). - Use this when you must detect any single-byte change. For integrity checks against tampering, pick a cryptographic algorithm explicitly (e.g.
hashAs('sha256')): the default algorithm (xxh3where available) is fast but not cryptographic.
-
Hash Result (
HashResult):
Immutable value object returned by every hash call:toHex(),toBinary(),toBase64(),equals(),getAlgorithm(),isSparse(),__toString()andjsonSerialize(). Rebuild one from storage withHashResult::fromHex()orHashResult::fromBinary(). -
File Abstraction (
File):
Low-level class for file operations: reading, hashing, MIME detection, and binary/text detection. Accepts a path, anSplFileInfoor an open stream. -
File comparison:
SparseHasher::matches()andFileHasher::matches()compare two files, checking sizes first. -
Command line:
vendor/bin/sparsedigestprints hashes in thesha256sumlayout, or as JSON. -
Performance:
Dramatically reduced I/O overhead for large binary files through intelligent sampling.
Visual Representation: Full-File Hash vs. Sampled Hash (240KB File)
File Size: 245760 bytes (can be divided into 60 blocks of 4096 bytes each)
Coverage: 10% (default) — determines how many blocks are actually sampled
Sample Size: 4096 bytes (default block size)
Calculation:
- Total sample bytes = 245760 × 10% = 24576 bytes
- Total blocks to sample = 24576 / 4096 = 6 blocks
- Distribution: 2 first blocks + 2 last blocks + 2 intermediate blocks
Legend:
██: Data block used in the hash calculation (4096 bytes each)░░: Data block not used in the hash calculation
Full-File Hashing (60 blocks × 4096 bytes = 245760 bytes)
████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ Total data read: 245760 bytes (all 60 blocks)
Sampled Hashing (6 blocks at 10% coverage)
████░░░░░░░░░░░░░░░░░░░░██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██░░░░░░░░░░████ | | | | | | First 2 blocks Intermediate 1 Interm. 2 Last 2 Total data read: 24576 bytes (~10% of file, 6 blocks)
Warning
Caveat about change detection:
Because sparse hashing only reads sampled regions, modifications in non-sampled areas will NOT be detected. This is by-design: the algorithm trades detection granularity for speed. If your use case requires detecting every single-byte change anywhere in the file, use full-file hashing instead (FileHasher::new($file)->hash()).
It is evident that the sampled hashing technique relies on fractions of a file, assuming that relevant changes are likely to be captured in the sampled regions. This approach is particularly effective for these scenarios:
- Compressed Archives: Where a minor change can result in a completely different file.
- Compressed Images (e.g., JPEG): Where a small change can affect the entire image (think about the metadata, or a single pixel change in a large image).
- Compressed Videos: Where a minor edit can alter the entire video.
- Large Binary Files: Where the cost of reading the entire file is prohibitive, and you can tolerate a small chance of false negatives.
Underlying Logic of Sampled Hashing in SparseDigest
SparseDigest's hashing strategy is built around the concept of sampled hashing. Instead of processing every byte of a file, the algorithm reads only selected portions, significantly reducing I/O overhead for large files. The following outlines the precise, step‐by‐step logic of the implementation:
1. File Sampling Determination
-
Minimum File Size Check:
The file is deemed too small to sample based on thecoveragepercentage andsampleSize. The algorithm calculates:totalSampleBytes = floor(fileSize × coverage / 100), thentotalBlocks = floor(totalSampleBytes / sampleSize). IftotalBlocks < 4, the file is too small for sparse hashing and falls back to full-file hashing. -
Binary File Detection:
Prior to sampling, the file is examined via theisLikelyBinary()method, which reads the first 512 bytes and applies a fast heuristic:- If any null byte (
\0) is found, the file is immediately classified as binary (covers images, videos, archives, executables). - If control characters (ASCII < 9 or 14-31) are found, the file is classified as binary.
- Otherwise, the file is considered text.
If the file is detected as text, the library falls back to full-file hashing to ensure that even subtle modifications (which might occur outside the sampled regions) are caught.
- If any null byte (
2. Sample Selection and Offset Calculation
-
Fixed Edge Blocks:
The algorithm always includes the first two blocks (starting at offset 0 and offset = sampleSize) and the last two blocks (offsets atfileSize − 2 × sampleSizeandfileSize − sampleSize). These edge blocks typically contain file headers and footers, which are critical in many binary formats. -
Evenly Distributed Intermediate Samples:
The number of intermediate samples is calculated as:intermediateCount = totalBlocks - 4(subtracting the 4 fixed edge blocks). These intermediate blocks are evenly distributed between the fixed edge blocks. This ensures that the sampled regions provide a representative snapshot of the file's interior content.
3. Reading Data from Each Sample Block
- Direct Block Reading:
Each sample block is read directly viareadBytesAtOffset(), which performs a simplefseek()+fread()operation. This approach is optimized for speed:- No preprocessing or transformation is applied to the raw bytes.
- The data is read exactly as stored on disk.
- This minimizes I/O overhead and CPU processing time.
4. Incremental Hashing
-
Single-Pass Incremental Hashing:
Instead of computing intermediate hashes and concatenating them, SparseDigest uses PHP's incremental hashing API (hash_init,hash_update,hash_final):- A hash context is initialized with the selected algorithm.
- Each sample block is fed directly to
hash_update()as it's read. - The file size is appended as the final piece of data to enhance uniqueness.
hash_final()produces the final digest.
This approach eliminates intermediate string allocations and reduces memory usage to a constant ~1.85MB regardless of file size.
5. Determinism and Performance
- Determinism:
Given the same file and the same sampling parameters (coverageandsampleSize), the algorithm always selects the same sample offsets and produces the same hash output. - Performance:
By only reading a fraction of the file (determined bycoverage), the I/O overhead is drastically reduced. With the default 10% coverage, SparseDigest is 3-5× faster than nativehash_file()for files larger than ~500KB. Memory usage is constant at ~1.85MB regardless of file size.
6. Non-Cryptographic Nature
It is critical to note that while SparseDigest is optimized for speed and efficiency in detecting file changes, it is not designed for cryptographic security. The sampled hash is a heuristic representation and may not be suitable for applications requiring cryptographic integrity.
Summary
SparseDigest implements a sampled hashing strategy that:
- Selects a fixed set of edge samples (first 2 and last 2 blocks) and evenly distributes intermediate samples based on
coverage. - Reads each sample block directly without preprocessing, feeding data incrementally to the hash function.
- Uses incremental hashing (
hash_init/hash_update/hash_final) for constant memory usage. - Automatically falls back to full-file hashing for text-based files or files that are too small to sample.
- Guarantees determinism with minimal I/O overhead, making it ideal for large binary files where performance is a priority.
- Does not detect changes in non-sampled regions — this is an intentional trade-off for speed.
This highly opinionated approach prioritizes speed and practical consistency over cryptographic security, offering an efficient method to detect file changes while balancing I/O performance and data uniqueness.
Installation
Install via Composer:
composer require mfonte/sparsedigest
Requirements:
- PHP 7.2 or higher (tested up to PHP 8.4)
ext-hash(always enabled since PHP 7.4)ext-fileinfo(optional): only needed byFile::getMimeType()
Note
Upgrading from 1.x? See UPGRADE.md. Hashes computed by 1.x stay valid.
Note
Empty files are supported: they always get a full (non-sparse) hash, equal to the digest of the empty string.
Usage Examples
Sparse Hashing (Sampled Hash)
use Mfonte\SparseDigest\SparseHasher; $sd = SparseHasher::new('/path/to/file'); // Uses default sampling parameters (10% coverage, 4096 byte blocks) // Returns a HashResult using the fastest available algorithm $digest = $sd->hash(); echo "Sparse Hash: " . $digest->toHex() . "\n"; // With custom sampling parameters (20% coverage, 8192 byte blocks) $digest = $sd->hash(20, 8192); echo "Sparse Hash (20% coverage): " . $digest->toHex() . "\n"; // With a specific output algorithm (SHA256) $digest = $sd->hashAs('sha256'); echo "Sparse Hash (SHA256): " . $digest->toHex() . "\n"; // Combining custom sampling and algorithm $digest = $sd->hashAs('md5', 15, 2048); echo "Sparse Hash (MD5, 15% coverage): " . $digest->toHex() . "\n";
NOTE: The hash output will differ when changing sampling parameters. This is expected behavior.
Heads up!
If you intend to persist sparse hashes (for example, in a database), remember to store the sampling parameters used. For re-hashing and comparison, you should use the same
coverageandsampleSizevalues to obtain consistent results.
Full File Hashing
When you need to detect any single-byte change, use FileHasher. For tamper-proof integrity checks, also choose a cryptographic algorithm such as sha256:
use Mfonte\SparseDigest\FileHasher; $fd = FileHasher::new('/path/to/file'); // Full hash with the fastest available algorithm $digest = $fd->hash(); echo "Full Hash: " . $digest->toHex() . "\n"; // Full hash with a specific algorithm $digest = $fd->hashAs('sha256'); echo "Full Hash (SHA256): " . $digest->toHex() . "\n"; // Full hash with MD5 $digest = $fd->hashAs('md5'); echo "Full Hash (MD5): " . $digest->toHex() . "\n";
Low-Level File Operations with File
The File class provides low-level access to file operations:
use Mfonte\SparseDigest\File; $file = File::new('/path/to/file'); // File information echo "Size: " . $file->getSize() . " bytes\n"; echo "MIME: " . $file->getMimeType() . "\n"; echo "Binary: " . ($file->isLikelyBinary() ? 'yes' : 'no') . "\n"; // Full-file hash (alternative to FileHasher) $digest = $file->hashWith('xxh3'); echo "Hash: " . $digest->toHex() . "\n";
Hashing streams
File::new(), SparseHasher::new() and FileHasher::new() also accept an open stream, as long as it is readable, seekable and has a known size (local files, php://memory, php://temp, ...):
use Mfonte\SparseDigest\SparseHasher; $stream = fopen('php://temp', 'w+b'); fwrite($stream, $uploadedContents); $digest = SparseHasher::new($stream)->hash();
The library never closes a stream it did not open, but it does move the stream position: save and restore it with ftell()/fseek() if you need it. Non-seekable streams (pipes, sockets, php://stdin) are rejected with an InvalidFileException.
Comparing files
matches() compares the file with another one (path, SplFileInfo, stream or File). Sizes are compared first, so files of different size are rejected without reading them:
use Mfonte\SparseDigest\FileHasher; use Mfonte\SparseDigest\SparseHasher; // Fast: compares sparse hashes (same caveat as hash(): changes outside the sampled blocks are not seen) SparseHasher::new('/videos/a.mp4')->matches('/backup/a.mp4'); SparseHasher::new('/videos/a.mp4')->matches('/backup/a.mp4', 20, 8192); // custom sampling // Exact: compares full-file hashes FileHasher::new('/videos/a.mp4')->matches('/backup/a.mp4');
Working with HashResult
use Mfonte\SparseDigest\HashResult; use Mfonte\SparseDigest\SparseHasher; $a = SparseHasher::new('/path/to/a.mp4')->hashAs('xxh128'); $b = SparseHasher::new('/path/to/b.mp4')->hashAs('xxh128'); $a->toHex(); // "3f1c..." (lowercase hex) $a->toBinary(); // raw bytes, e.g. for a BINARY(16) column (string) $a; // same as toHex() json_encode(['h' => $a]); // {"h":"3f1c..."} $a->equals($b); // constant-time comparison with another HashResult $a->equals('3f1c...'); // ...or with a hex string (case-insensitive) $a->toBase64(); // compact text form $a->getAlgorithm(); // "xxh128" $a->isSparse(); // true if blocks were sampled, false if the file was hashed in full // (text file, or too small for the requested coverage) // Rebuild a stored hash and compare it with a fresh one $stored = HashResult::fromHex($row['hash'], $row['algo']); $stored->equals(SparseHasher::new($path)->hashAs($row['algo'])); // bool
Error handling
Every exception thrown by the library implements Mfonte\SparseDigest\Exception\SparseDigestException, so you can catch them all in one place. They also extend the matching SPL exception, so catch (\InvalidArgumentException $e) and catch (\RuntimeException $e) keep working.
Exception (namespace Mfonte\SparseDigest\Exception) |
Extends | When |
|---|---|---|
InvalidFileException |
InvalidArgumentException |
Path does not exist, is not a regular file or is unreadable; input is neither a string nor an SplFileInfo |
UnsupportedAlgorithmException |
InvalidArgumentException |
Algorithm not listed by hash_algos() (see getAlgorithm()) |
InvalidSamplingParameterException |
InvalidArgumentException |
coverage outside 1-100, or sampleSize not a positive multiple of 64 |
InvalidRangeException |
InvalidArgumentException |
Invalid offset/length passed to File::readBytesAtOffset() and similar |
InvalidArgumentException |
\InvalidArgumentException |
Base class of the above; also thrown by HashResult::fromHex() |
IOException |
RuntimeException |
The file cannot be opened, read or hashed |
RuntimeException |
\RuntimeException |
Base class of IOException |
BadMethodCallException |
\BadMethodCallException |
Cloning or serializing a File |
InvalidFileException is also thrown for streams that are not readable, not seekable, or whose size is unknown.
use Mfonte\SparseDigest\Exception\InvalidFileException; use Mfonte\SparseDigest\Exception\SparseDigestException; try { $hash = SparseHasher::new($path)->hash(); } catch (InvalidFileException $e) { // missing or unreadable file } catch (SparseDigestException $e) { // any other library error }
Custom strategies
The concrete classes are final: to change how files are hashed, implement Mfonte\SparseDigest\HashingStrategy and pass it to SparseHasher or FileHasher as second argument:
use Mfonte\SparseDigest\File; use Mfonte\SparseDigest\HashingStrategy; use Mfonte\SparseDigest\HashResult; use Mfonte\SparseDigest\SparseHasher; final class HeaderOnlyStrategy implements HashingStrategy { public function hash(File $file, int $coverage = 10, int $sampleSize = 4096): HashResult { return $this->hashAs($file, 'sha256', $coverage, $sampleSize); } public function hashAs(File $file, string $algo, int $coverage = 10, int $sampleSize = 4096): HashResult { $length = min($sampleSize, $file->getSize()); return HashResult::new(hash($algo, $file->readBytesAtOffset(0, $length), true)); } } $digest = SparseHasher::new('/path/to/file', new HeaderOnlyStrategy())->hash();
Command line
The package ships a small CLI, installed as vendor/bin/sparsedigest. Its output follows the sha256sum layout, so it is easy to pipe into other tools:
$ vendor/bin/sparsedigest movie.mkv notes.txt 5e2c1b0f8a9d3e47 movie.mkv 0a00ce703ce97158 notes.txt $ vendor/bin/sparsedigest --full --algo=sha256 movie.mkv $ vendor/bin/sparsedigest --coverage=20 --sample-size=8192 movie.mkv $ vendor/bin/sparsedigest --json movie.mkv [ { "path": "movie.mkv", "hash": "5e2c1b0f8a9d3e47", "algorithm": "xxh3", "sparse": true, "size": 734003200 } ]
Run vendor/bin/sparsedigest --help for all options. The exit code is 0 on success, 1 if at least one file could not be hashed (the others are still printed), and 2 on invalid usage.
Order of preference for hashing algorithms
The library will use the fastest available algorithm from the following list, in ordered sequence (the first, the fastest):
'xxh3', 'xxh128', 'crc32c', 'xxh64', 'crc32', 'crc32b', 'murmur3f', 'xxh32',
'murmur3c', 'murmur3a', 'adler32', 'md4', 'fnv1a64', 'fnv132', 'fnv164', 'fnv1a32',
'joaat', 'md5', 'sha1', 'sha256'
If none of these is available, the first algorithm returned by hash_algos() is used. The list is exposed as Mfonte\SparseDigest\Strategy\AbstractStrategy::PREFERRED_ALGORITHMS, and the algorithm actually picked can be read with getDefaultAlgorithm() on a strategy instance.
Important
The default algorithm depends on the PHP build (xxh3 requires PHP 8.1+). If you persist hashes and may run them on different PHP versions, use hashAs() with an explicit algorithm so that the digests stay comparable.
This pre-defined list has been derived from https://php.watch/articles/php-hash-benchmark.
Testing and Benchmarking
Run the test suite with:
composer test
Run coding style checks, static analysis and tests in one go (this is what CI runs):
composer check
Run the full benchmark suite with:
composer bench
Run specific benchmark groups with:
composer bench:core composer bench:scaling composer bench:algorithms composer bench:realworld
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security
If you discover a security issue, please follow the security policy instead of opening a public issue.
License
The MIT License (MIT). Please see License File for more information.