Development Guide¶
This guide outlines the standards and procedures for contributing to libphash.
Build Environment¶
Toolchains¶
The project supports gcc, clang, and msvc. Clang/LLVM is the priority default in
both build systems (it's the more actively developed of the two Unix compilers this
project tests, and several sanitizer/architecture checks in CMakeLists.txt are
already Clang-specific) — still fully overridable, and CI tests both gcc and clang on
every push:
# Makefile: CC defaults to clang, override with CC=
make CC=gcc
# CMake: the compiler is CC (or -DCMAKE_C_COMPILER), with or without a preset
CC=clang cmake --preset release
CC=gcc cmake -B build-gcc
The C standard is pinned explicitly: CMAKE_C_STANDARD 17 (CMakeLists.txt), with
CMAKE_C_EXTENSIONS OFF since nothing here needs the GNU dialect. It is not C23, even
though every toolchain this project tests on Linux/macOS handles it (the
c-standard-matrix CI job below builds it) — windows-latest's MSVC support for
/std:c23 isn't mature enough to trust as the project-wide default.
Build systems and their defaults¶
There are two build systems, and they are not interchangeable:
- CMake — the high-performance build. Vendored SIMD decoders (libjpeg-turbo,
libpng, libwebp, zlib-ng) from
vendor/,install()/find_package(phash)packaging,ctest. This is what CI builds and what releases ship. - Makefile — the portable/minimal build. No vendored decoders:
stb_imageonly, one test binary pertests/src/test_*.c, plus thedebug/coverageflows.
Because they are separate implementations, their defaults can drift, and a default
that differs between them means a code path that is exercised in one flow and dead in
the other. The table below is the single place where both are recorded — keep it in
sync when you add or flip a switch. scripts/check_docs_coverage.sh (the format-check
job) fails when a PHASH_* option() in CMakeLists.txt has no row here; the defaults
themselves are checked by reading both files.
| Knob | CMake default | Makefile default | Notes |
|---|---|---|---|
| Bundled libjpeg-turbo | PHASH_USE_LIBJPEG_TURBO=ON |
n/a (stb only) | Makefile has no native JPEG path |
| Bundled libpng | PHASH_USE_LIBPNG=ON |
n/a (stb only) | Makefile has no native PNG path |
| libwebp | PHASH_USE_WEBP=ON |
USE_WEBP=0 |
Makefile path expects a system libwebp |
| zlib-ng instead of system zlib | PHASH_USE_ZLIB_NG=ON |
n/a | |
| Batch thread pool | PHASH_ENABLE_THREADS=ON |
PHASH_ENABLE_THREADS=1 |
matches CMake's default |
| Shared library | PHASH_BUILD_SHARED=OFF |
n/a (static libphash.a only) |
|
| Tests | PHASH_BUILD_TESTS=ON |
always built by all |
|
-march=native |
PHASH_OPTIMIZE_NATIVE=OFF |
n/a | without it the flags follow the target (see below); no AVX anywhere |
| libFuzzer harnesses | PHASH_BUILD_FUZZERS=OFF |
n/a | requires Clang |
| Test-only mock decoder | PHASH_ENABLE_MOCK_BACKEND=OFF |
PHASH_ENABLE_MOCK_BACKEND=0 |
must never be on in a shipped build |
| Strict dependency handling | PHASH_STRICT_DEPS=OFF |
n/a | |
| Install | cmake --install (CMAKE_INSTALL_PREFIX) |
make install (PREFIX=/usr/local, DESTDIR) |
both write a relocatable libphash.pc from libphash.pc.in; the Makefile installs the static library only and has no find_package package |
| Warnings as errors (own code only) | PHASH_WARNINGS_AS_ERRORS=OFF |
n/a | for the strict-warnings CI job; never reaches the vendored decoders |
| Coverage instrumentation | PHASH_COVERAGE=OFF |
PHASH_COVERAGE=0 |
same flag name, independent implementations — see below for why one build alone isn't enough |
| C standard | C17, strict ISO (CMAKE_C_STANDARD 17, extensions off) |
-std=c17 |
the same dialect in both; C11 and C23 are checked by the c-standard-matrix job |
| Optimization | from the build type; Release (-O3 -DNDEBUG on GCC/Clang) when none is named |
-O3; debug/coverage switch to -O0 -g |
the library has no assert(), so NDEBUG changes nothing in it |
| Floating-point contraction | -ffp-contract=off (MSVC's default /fp:precise does not contract) |
-ffp-contract=off |
no fused multiply-add, so every target computes the same hash bits |
| Architecture flags | follow the target (table below); -march=native only with PHASH_OPTIMIZE_NATIVE |
follow the target (table below) | no per-file exceptions: every source gets the same flags |
| Static archive | deterministic (cmake/deterministic_archives.cmake) |
deterministic (ar D or ZERO_AR_DATE) |
the same sources give a byte-identical libphash.a |
Supported platforms¶
A platform is supported when CI builds the library on it and runs the whole test suite, the golden hashes included, on every push:
| Platform | Toolchains in CI | Release archive |
|---|---|---|
| Linux x86-64 | GCC, Clang; static and shared | yes |
| Linux arm64 | GCC | yes |
| Linux 32-bit x86 (i686) | GCC, -m32 |
no |
| macOS arm64 | Apple Clang | yes |
| Windows x86-64 | MSVC | yes |
Other little-endian targets with a C11 compiler and either POSIX threads or Win32 — the BSDs, Intel macOS, 32-bit Arm — are expected to build and to give the same hashes, but nothing runs there, so they are not claimed. Big-endian targets are not supported: the library reads every multi-byte field through explicit byte-order loads and has no known big-endian defect, but it has never been run on one.
Presets and supported option combinations¶
CMakePresets.json holds one configure, build and test preset per configuration CI
builds, each in its own build/<preset> directory, so any two can sit side by side. CI
configures through them, so a failing leg is reproduced locally with the same three
commands:
The compiler is not part of a preset: CMake takes it from CC (or
-DCMAKE_C_COMPILER), as CI does. Each test preset sets PH_EXPECT_BUILD to the decoders
its configuration builds (jpeg=libjpeg-turbo png=libpng webp=libwebp zlib=zlib-ng, for
one), and test_build_info fails unless ph_get_build_info() reports exactly those: a
decoder that fell back to stb_image changes the library's answer and its own test's
expectation together, so only a value set from outside sees it.
| Preset | Configuration | CI job |
|---|---|---|
release |
Release, every bundled decoder, PHASH_STRICT_DEPS=ON |
build-and-test (gcc and clang), build-and-test-windows (MSVC), bare-image |
shared |
release + PHASH_BUILD_SHARED=ON; the tests that use only the public API link the shared library itself |
build-and-test (ubuntu-x86_64-gcc-shared) |
strict-warnings |
release + PHASH_WARNINGS_AS_ERRORS=ON, compile commands for clang-tidy |
strict-warnings |
minimal |
Release, stb_image only | c-standard-matrix (with -DCMAKE_C_STANDARD=…) |
debug |
Debug, every bundled decoder | valgrind (native) |
minimal-debug |
Debug, stb_image only | valgrind (stb-only) |
no-threads |
minimal + PHASH_ENABLE_THREADS=OFF |
build-options |
mock-backend |
minimal + PHASH_ENABLE_MOCK_BACKEND=ON |
build-options |
i686 |
Release, -m32, libpng only |
build-and-test-32bit |
asan |
Debug, ASan + UBSan, every bundled decoder | sanitizers |
tsan, tsan-png, tsan-stb |
Debug, TSan: every decoder / libpng + libwebp + zlib-ng / stb_image only | tsan |
fuzz |
Debug, PHASH_BUILD_FUZZERS=ON (Clang only) |
fuzz |
minimal-build is the one job without a preset: it configures a checkout with no
submodules and the default options, which is the point of the job. The benchmark job
configures its two trees with explicit options, because the base commit may predate a
preset.
The decoder options (PHASH_USE_LIBJPEG_TURBO, PHASH_USE_LIBPNG, PHASH_USE_WEBP,
PHASH_USE_ZLIB_NG) are independent of each other, with these constraints:
- zlib-ng serves libpng only. Without libpng it is not configured, whatever
PHASH_USE_ZLIB_NGsays; with libpng andPHASH_USE_ZLIB_NG=OFF, libpng uses the system zlib. - libjpeg-turbo cannot be part of a macOS universal build (more than one
CMAKE_OSX_ARCHITECTURESvalue): its SIMD is per-architecture assembly. The configure stops with that message. PHASH_BUILD_FUZZERSneeds Clang (libFuzzer); the configure stops under GCC.PHASH_ENABLE_MOCK_BACKENDis for tests and never ships.- A missing submodule turns its decoder off with one warning, or fails the configure
under
PHASH_STRICT_DEPS=ON.
CI covers the presets above; any other combination of the four decoder options is expected to build, and is not tested on every push.
Architecture flags follow the target, not the host. Both build systems read the
target from the compiler's predefined macros, so -m32, a toolchain file and
CMAKE_OSX_ARCHITECTURES all count:
| Target | Flags | CPU baseline |
|---|---|---|
| x86-64 | -msse4.2 |
x86-64-v2 (SSE4.2 + POPCNT, which the Hamming distances run on) |
| 32-bit x86 | -msse2 -mfpmath=sse |
SSE2; x87 intermediates would make 32-bit hashes differ from 64-bit ones |
| arm64 | none | ARMv8-A; Advanced SIMD is part of the base architecture |
make EXTRA_CFLAGS=-m32 EXTRA_LDFLAGS=-m32 # 32-bit x86 on a 64-bit host
cmake -B build -DCMAKE_OSX_ARCHITECTURES=x86_64 # Intel macOS from Apple silicon
cmake -B build "-DCMAKE_OSX_ARCHITECTURES=arm64;x86_64" -DPHASH_USE_LIBJPEG_TURBO=OFF
EXTRA_CFLAGS/EXTRA_LDFLAGS are appended after everything the Makefile sets; plain
make CFLAGS=... would replace the include paths too. A universal build gives the x86-64
slice its flag through -Xarch_x86_64 and builds libpng with its portable filters, since
libpng chooses NEON or SSE2 sources for the whole build. The bundled libjpeg-turbo is a
separately configured single-architecture archive, so a universal build refuses it at
configure time: use stb_image for JPEG, or build each architecture and join them with
lipo -create.
Both spellings of the thread switch accept the same off-ramp:
make PHASH_ENABLE_THREADS=0 # portable build without -pthread
cmake -S . -B build -DPHASH_ENABLE_THREADS=OFF
Why threads are on in the portable build. With the pool compiled out, make test and
make coverage would never execute the threaded half of
ph_hash_files()/ph_hash_buffers(). -pthread is therefore a dependency of the
portable build: a pthread implementation is present on every platform the Makefile
targets (it uses uname and POSIX tooling throughout), and coverage of the concurrent
path is worth more than the dependency.
Instrumented build modes (Makefile)¶
debug and coverage are switch-driven (PHASH_SANITIZE=1, PHASH_COVERAGE=1) and
re-invoke make rather than listing clean as a sibling prerequisite, which would race
under -jN (clean deleting object files other jobs are compiling). Prefer the same shape for any future instrumented mode.
Two coverage targets, and why one isn't enough¶
make coverage and make coverage-cmake measure disjoint code:
make coverageruns the Makefile's stb_image-only build. Every line inside native decoder code insrc/loaders/doesn't exist in that binary at all —jpeg.candwebp.ccompile to nothing without theirPH_USE_*flag, andpng_libpng.cis not built. The overall percentage this target reports does not include the native decoders, no matter how high it reads.make coverage-cmake(scripts/coverage_cmake.sh) runs a CMake-DPHASH_COVERAGE=ON+ctestpass with the default vendored decoder set (libjpeg-turbo + libpng + libwebp + zlib-ng, i.e. what CI'sbuild-and-testjob and releases ship) and renders itslcovtrace underdocs/coverage/cmake/html/index.html. It is the only measurement of the native backends' own limit checks: each backend'smax_pixelscheck (test_decode_limits.c, every format at its exact boundary), the libpng backend's per-dimension cap and the JPEG backend's encoded-length check (called directly, since the dispatcher answers the same inputs first), andpng_error_fn/png_warning_fnwith thelongjmpthat carries libpng's error message out.
The row-buffer size checks behind max_pixels (alloc_size/stride in each backend)
fail only where size_t is 32 bits: an image within PH_MAX_SUPPORTED_PIXELS needs
at most 8 GiB, which a 64-bit size_t holds. libpng's is run by CI's 32-bit job; the
JPEG one has no 32-bit libjpeg-turbo build to run in, and the WebP one cannot fail
at all (VP8 caps a side at 16383 pixels). These blocks are marked as coverage
exclusions (see "Coverage standard" below).
Neither target subsumes the other — always read the two side by side, and treat a report that only ran one of them as measuring at most half the decoder surface.
scripts/coverage_cmake.sh treats a failing test the same way make coverage
does: coverage is a measurement pass, not a correctness gate, so a ctest failure
prints a warning and the script continues rather than aborting (a failed test still
executed its lines). If a decoder submodule isn't checked out locally, that backend's
native path simply can't be measured on that machine: the configure falls back to
stb_image with a warning, same as any other CMake build here, so check the summary's
per-file breakdown (lcov --list docs/coverage/cmake/native.info) rather than assuming the
option being ON means the backend was actually linked.
Coverage standard¶
Both targets measure lines and branches: the paths that lines alone report as
covered while their error branch never ran are where the defects live. CI's
coverage-cmake job (Linux x86_64, native decoders) checks the result against
scripts/coverage_thresholds.txt with scripts/check_coverage.py, and fails when an
area drops below its minimum:
| Area | Lines | Branches |
|---|---|---|
src/hashes/ |
95% | 91% |
src/image/ |
95% | 86% |
src/loaders/ |
92% | 74% |
src/core.c |
96% | 90% |
src/batch.c |
97% | 91% |
src/loader.c |
93% | 77% |
src/fileio.c |
94% | 85% |
all of src/ |
95% | 87% |
The thresholds file is the one source of these numbers; the table repeats it. Each
minimum is the coverage measured on that job's toolchain (GCC, lcov) less one or two
points: the threaded batch takes different branches from run to run, and the canonical
runner compiles x86 code that an arm64 machine does not. The same tests give a
different branch figure under clang: the two compilers split compound conditions
(&&, ||, ?:) into branches differently, so an area's percentage can differ by a
few points either way between them (src/loader.c: 78% under GCC, 82% under clang),
and a local macOS run is only an approximation of the gate. Linux-only code (the
cgroup and affinity reading in src/batch.c) is counted only on Linux. Functions are
not given a threshold -- one uncovered function out of two hundred is not a quantity
worth steering by.
A line may be left out only when no test can reach it: a defensive check that
validation upstream makes impossible, or a size check that can fail only where
size_t is 32 bits. It is marked in the code with LCOV_EXCL_START -- <reason> and
LCOV_EXCL_STOP around the block, and the reason says why it cannot run. One kind of
branch is left out although tests do reach it: in a first-use initialization behind a
spinlock (the decoder warm-up and the PNG CRC table in src/loader.c, pHash's DCT matrix),
waiting for the lock and finding the work already done happen only when two threads race
for the first call, and whether a run takes them is the scheduler's choice. Counted, they
would move the branch figure from run to run with no change in the code; they are marked
LCOV_EXCL_BR_LINE with that reason, and the TSan tests exercise the race. Everything
else that is uncovered is a missing test, not an exclusion.
Coverage is measured on Linux and macOS only. Lines under #ifdef _WIN32 (the CRT file
calls, the Win32 thread pool) are not in any number; they are compiled and tested by
CI's Windows build, which measures no coverage.
Locally: make coverage (stb_image build) or make coverage-cmake (native decoders),
then python3 scripts/check_coverage.py docs/coverage/cmake/native.info for the gate
itself. Both targets print the per-area table.
Reproducible archives¶
Two builds of the same sources produce byte-identical static libraries, shared
libraries, install trees and release archives, so a published release archive can be
rebuilt and checked against SHA256SUMS.txt. With GCC and Clang this holds from any build
directory: the object files carry no build path or date, and what would differ is the
time ar and ranlib write into a static archive. cmake/deterministic_archives.cmake
gives GNU and LLVM ar the D modifier (and ranlib -D), and runs Apple's ar/ranlib
under ZERO_AR_DATE=1, including the ranlib that cmake --install runs on every static
library it copies on macOS; the libjpeg-turbo sub-build gets the same file as
CMAKE_PROJECT_INCLUDE. MSVC stamps the build time into every object, archive member
and DLL, and NASM into its COFF objects; the same file passes /Brepro to cl, lib and
link and --reproducible to NASM. MSVC also records each object file's own path in the
object, so a Windows build reproduces only in the same build directory:
scripts/package_release.sh builds in build/package/ under the checkout, which on a
GitHub runner is always the same path. The Makefile picks
ar rcsD or ZERO_AR_DATE=1 ar rcs from what ar --version reports.
scripts/package_release.sh packs with scripts/deterministic_archive.py (Python's
standard library, not the platform's tar/zip): sorted entries, every timestamp
SOURCE_DATE_EPOCH — by default the time of the commit being packed — owner 0/0, and a
gzip header with no time or name. scripts/check_reproducible.sh cmake|make|package
builds twice and compares every installed file, libphash.a or the release archives; CI
runs all three on Linux (reproducible-builds, one leg each), the first two on macOS
(build-and-test), and package on Windows (reproducible-builds).
Installed package and pkg-config¶
cmake --install writes libphash.h, phash_version.h, the library, the exported
phash::phash CMake package and libphash.pc. Both consumer routes are covered by
scripts/smoke_install.sh static|shared (also make install-test), which installs into
a throwaway prefix, builds a consumer through find_package(phash) and through
pkg-config, then moves the prefix and repeats the pkg-config build from the new
location. make install (the Makefile build) writes the static library, both headers and
a libphash.pc in the same form; scripts/smoke_make_install.sh (also in
make install-test) installs it, builds and runs a consumer, moves the prefix, rebuilds,
and checks that make uninstall leaves no file behind.
scripts/smoke_release_artifact.sh does the same as smoke_install.sh against an unpacked release
archive. Both use the plain command README shows, pkg-config --cflags --libs libphash,
and run every consumer without LD_LIBRARY_PATH/DYLD_LIBRARY_PATH; a consumer that
starts only with them is reported as missing its rpath.
libphash.pc.in writes
prefix=${pcfiledir}/../..
libdir=${prefix}/@CMAKE_INSTALL_LIBDIR@
includedir=${prefix}/@CMAKE_INSTALL_INCLUDEDIR@
with one .. per component of <libdir>/pkgconfig (../../.. for lib/<triplet>).
${pcfiledir} is the directory the .pc file was read from, defined by every
pkg-config implementation, so the paths follow the file wherever the tree is moved or
unpacked, with no --define-prefix. A configure-time prefix would name the staging
directory of the release build, deleted once the archive is written.
- Static install: the codec archives and system libraries (
-lphash_jpeg -lpng16 -lwebpdecoder -lz -lm …) are inLibs, notLibs.private. There is no shared libphash to fall back on, so every link needs them,--staticor not. - Shared install:
Libscarries-Wl,-rpath,${libdir}(not on Windows, which has no rpath). The library's install name on macOS is@rpath/libphash.<N>.dylib, and on Linux a prefix outside the loader's default path is equally invisible without it. smoke_install.shalso asserts on the text of the generated.pc—prefixstarts with${pcfiledir}/,libdir/includedirwith${prefix}/— because the behavioral check alone depends on thepkg-configimplementation in use.GNUInstallDirsallowsCMAKE_INSTALL_LIBDIR/CMAKE_INSTALL_INCLUDEDIRto be absolute paths, and some distribution toolchain files set them that way. Such a value cannot be expressed relative to${prefix}, soCMakeLists.txtemits it verbatim, takesprefixfromCMAKE_INSTALL_PREFIX, and prints aSTATUSmessage saying the.pcwill not be relocatable. That is a property of the requested layout, not a bug to paper over.
Vendoring libphash with add_subdirectory()¶
Besides the installed package, libphash supports being dropped into another project's source tree:
Both parent configurations are supported and covered end to end (configure → build →
run a real PNG through ph_load_from_file) by
scripts/smoke_add_subdirectory.sh, which CI runs alongside smoke_install.sh:
BUILD_SHARED_LIBS=OFF(static parent),BUILD_SHARED_LIBS=ON(shared parent).
Two rules keep this working:
- The parent owns the global build settings.
BUILD_SHARED_LIBSandBUILD_TESTINGare global CMake variables, not options of the vendored codecs, yet the codecs have to see themOFFwhile they configure.CMakeLists.txttherefore saves the parent's values, forces its own, and restores them immediately (phash_push/pop_global_build_flags()). Forcing them without restoring would silently turn off a parent's shared build and itsctest. - Never force a dependency pin into the parent's cache. libpng's
find_package(ZLIB REQUIRED)has to be steered at the bundled zlib-ng, which is done by pre-settingZLIB_INCLUDE_DIR/ZLIB_LIBRARY(and pre-creating theZLIB::ZLIBalias) — but as normal, directory-scope variables.vendor/libpngis a child scope and inherits them, so nothing has to enter the cache. AsCACHE … FORCEentries they would persist into the next configure of the same build tree, where libphash's own "did the parent bring its own zlib?" check (DEFINED CACHE{ZLIB_LIBRARY}) would fire on libphash's own pin: libphash would stand aside, thezlib-ngtarget would never be created, and the cached absolute path to its archive would stay on libpng's link interface — a file dependency nothing produces (No rule to make target 'phash_build/vendor/zlib-ng/libz.a') on every incremental build. The smoke script re-configures deliberately to catch that.
ZLIB_LIBRARY also holds the zlib-ng target name, not a path to libz.a: CMake
resolves a target name to the real artifact plus a build-order dependency, whereas the
archive's file name is a guess that is wrong on MSVC and wrong for any build in which
zlib-ng comes out shared. FindZLIB never puts that value on a link line here — it is
guarded by if(NOT ZLIB_LIBRARY) and only feeds
find_package_handle_standard_args(); libpng links ZLIB::ZLIB, i.e. the target.
Formatting¶
.clang-format starts from the LLVM style and sets the rest explicitly, so that a practice
the code follows is held by the gate rather than by habit.
- Rule: Run make format before every commit; the CI format-check job fails on any
file that differs from what clang-format would print.
- Layout: 4-space indent, 100 columns (80 would rewrap a third of the tree for no gain
in a C API with ph_ prefixes), { on the same line, one statement per line --
no single-line if/loop/case bodies.
- Braces everywhere (InsertBraces): every if/for/while body is a block, so a
second statement added under an unbraced if cannot silently run unconditionally.
- Preprocessor nesting is visible (IndentPPDirectives: AfterHash): # include
inside #if shows the level of the conditional it belongs to.
- Tables stay tables: consecutive #defines, trailing comments, short case lines
and the \ of a multi-line macro are aligned.
- Includes are ordered by machine (IncludeBlocks: Regroup): the file's own header,
then the library's headers, then third-party, then system, each group sorted.
- One spelling: const on the left (const char *), * next to the name, upper-case
hex digits (0xFF), a trailing comma after the last enumerator (adding one is then a
one-line diff), return x; without parentheses, LF line endings, a newline at the end.
- Left alone on purpose: string literals are never split (a message stays greppable),
and macro bodies are formatted like the rest of the code.
- Blame across reformatting: commits that only reformat are listed in
.git-blame-ignore-revs; run git config blame.ignoreRevsFile .git-blame-ignore-revs
once, and git blame shows the commit that wrote a line rather than the one that
reindented it.
- Version: clang-format 23, pinned (pip install clang-format==23.1.1).
Different major versions format the same code differently, so make format refuses
to run with any other major. The pin is raised only in a commit of its own, together
with whatever reformatting the new version produces.
- Scope: src/, include/, all of tests/ (including tests/fuzz/),
examples/ and tools/. scripts/format.sh holds both the scope and the version; make format
and the CI format-check job both run it (scripts/format.sh --check for the CI
check).
- Line endings and whitespace: .gitattributes keeps every text file LF on every
platform and marks image fixtures and fuzz inputs binary; .editorconfig gives an
editor the same indentation, final newline and no trailing whitespace before
clang-format runs.
Documentation site¶
docs/ is the source of the documentation site and is read on GitHub as it is, so a page
works in both places. make site builds it into build/site/; make site-serve serves it
on 127.0.0.1:8000 with live reload, and a build can run while the server does (the
build works in build/site-src/, the server in build/site-serve/). Both run
scripts/site.sh, which needs the vendored submodules, Doxygen 1.18 and Python 3.12 or
later, and does four things:
- The Python packages.
scripts/site-requirements.txtis a lock, compiled fromscripts/site-requirements.in, that pins every package the site is built with, its dependencies included, with hashes. The script installs it intobuild/site-venv/— with uv when it is installed, withvenvand pip otherwise — on the first run and whenever the lock changes. A version is raised in the.infile, and the lock compiled again with the command written in it. - The API reference. Doxygen runs over the header, and
scripts/api_pages.pywrites its XML as pages of the site intodocs/api/(ignored by git), one page per topic, every declaration under an anchor equal to its name. Under each declaration it adds the examples that call it and the site pages that link to its anchor, underph_error_tthe functions that return each code (read from every@return), and an A–Z index on the overview. A path such asdocs/guide/batch.mdordocs/algorithm-provenance.md section 3in a doc comment becomes a link to that page or section. A function missing from the pages, markup the script has no rendering for, a function returningph_error_twithout an@returnnaming its codes as code (@c PH_ERR_IO), or a path that does not exist fails the build. - The figures and measured tables.
tools/site/buildssite_stages, a measuring tool linked against the library, from thereleasepreset, and draws every figure and measured table of the theory and guide pages intodocs/assets/generated/(ignored by git): each step of each algorithm, checked against the library's own result, the edits over two corpora, and the times on the machine that builds the site. The photo corpus is downloaded once into~/.cache/libphash-site/, from a fixed commit of libphash-site-corpus (Commons only for a file the copy lacks), and checked againsttools/site/corpus_photos.tsv; without a network a local build goes on with what it has, and thesite.ymlworkflow fails unless every photograph is there. Measurements are cached inbuild/site-cache/, so a rebuild with nothing changed measures nothing.tools/site/README.mddescribes the tool and how to add to it. - The strict build. Zensical builds the pages, and the build fails on any of the rules below.
The rules that keep the build green:
- Navigation is
navinzensical.toml; a new page goes there or the build fails. Pages written for the site live indocs/theory/,docs/guide/anddocs/project/; files that code and scripts refer to by path keep their path. - Links to a page or an anchor are checked by the build. A link to any other file
must stay inside
docs/—scripts/check_site_links.pyfails on../src/...— so a file elsewhere in the repository is linked by its GitHub URL, and a function by its anchor in the API reference,[ph_compute_ahash()](api/hash64.md#ph_compute_ahash), so a misspelled name fails the build too. - Code on a page is included from a file CI compiles (
--8<-- "examples/basic_hash.c"), not pasted, so it cannot drift from the header.CHANGELOG.md,MIGRATION.mdandSECURITY.mdare included the same way, from the repository root, and so is every table of measured numbers, fromdocs/assets/generated/. - Pictures are generated, never drawn or screenshotted by hand: each figure is written
twice, for the light and the dark theme, and a page shows the pair with
#only-lightand#only-dark. The logo,docs/assets/logo.svg, is the one picture kept in git.
Naming Conventions¶
- External linkage ⇒
ph_. Every function and variable of ours that is notstaticis namedph_*, public or internal: the library shares one C namespace with whatever links it.scripts/check_exported_symbols.shfails on any other name (the vendoredstbi_*/stbir_*instantiations excepted). static⇒ prefix optional. Astaticfunction is invisible outside its file; bothph_*and unprefixed names are in use, and neither is renamed to match the other.- Types:
ph_*_t(e.g.,ph_context_t). - Macros and constants:
PH_*, upper case with underscores (e.g.,PH_DIGEST_MAX_BYTES). - Include guards:
PH_<PATH>_Hfor the file's path frominclude/,src/ortests/src/(e.g.,PH_HASHES_HASHES_Hforsrc/hashes/hashes.h). - Files: lower case with underscores (e.g.,
color_moments.c).
Conversions and casts¶
The library builds with -Wconversion -Wsign-conversion: an implicit conversion that can
change a value -- a signed int turning into a huge size_t, a double narrowing into a
float or a byte -- is a warning. A cast is not the way to silence one. The order of
preference:
- The right type from the start. A size, count, offset or loop index over a buffer
is
size_t; a value that is naturally small and signed (a pixel, a weight, a clamped coordinate) staysintand is never mixed with unsigned arithmetic. - One conversion at the boundary. Dimensions arrive as
intfrom the public API and the configuration and are validated there. Where such a value enters size or address arithmetic, it goes throughph_size()(src/safety.h) -- once, into asize_tlocal -- which names the precondition and checks it in debug builds. - A cast only with its reason. A cast is right when the code knows something the type system does not: a value already range-checked, a narrowing that rounding makes exact, a type a third-party API insists on. The reason is stated next to it or is evident from the check directly above it. A cast whose only purpose is to make a warning go away is a defect: it hides exactly the value the warning is about.
Warnings see implicit conversions, not explicit ones, so two checks watch the casts.
scripts/check_casts.py counts the explicit casts in each file under src/ (the stb
instantiations and (void) discards aside) against scripts/explicit_casts.txt; any
difference fails format-check until the list is updated with
scripts/check_casts.py --update in the same change, so an added cast is always in
the diff for review. And clang-tidy's bugprone-misplaced-widening-cast, in the
strict-warnings job, catches a cast that widens the result of an arithmetic which has
already overflowed in the narrower type, such as (uint64_t)(a * b) on two ints.
Source map¶
What the library does, step by step and without the source, is on How libphash works. This is where each step lives.
src/, the context and everything around it.
| File | Holds |
|---|---|
core.c |
ph_create()/ph_free(), the error strings and the last-error message, and the three ph_load_from_*() entry points: decode, transparency, orientation, in that order |
config.c |
the ph_context_set_*() setters, ph_context_get_gray_weights(), ph_context_get_dimensions(), and the defaults a new context starts from |
arena.c |
the scratch arena the hashes take their working buffers from; the only file that touches its fields |
fileio.c |
a file's bytes from exactly one open(), memory-mapped where the platform allows it and read into the heap otherwise; every reason a path cannot be loaded is PH_ERR_IO here, before a decoder sees a byte |
loader.c |
ph_decode_buffer(), the dispatcher every encoded load goes through: the size limits, the table of decoder backends, the stb_image fallback and the classification of its failures |
compare.c |
the comparison functions and the hex text form of hashes and digests |
batch.c |
ph_hash_files()/ph_hash_buffers() and their _ex forms, the worker pool and the CPU count behind threads = 0 |
version.c |
ph_version(), ph_version_number(), ph_get_build_info() and the ph_can_use_*() queries |
src/loaders/, one file per decoder. jpeg.c (libjpeg-turbo through its libjpeg API),
png_libpng.c (libpng; the Makefile does not build it, as it needs the vendored library),
webp.c (libwebp's simple decode API), each compiled to nothing unless its PH_USE_* flag
is set, and stb_image_impl.c, the one translation unit that compiles vendor/stb_image.h.
The table that orders them is backends[] in loader.c: the native decoders, the test-only
mock decoder when PHASH_ENABLE_MOCK_BACKEND is on, then stb_image.
src/image/, pixel-level steps.
| File | Holds |
|---|---|
color.c |
transparency (ph_resolve_alpha()), grayscale conversion (NEON on Arm) and its cache (ph_get_gray()), gamma |
orient.c |
the EXIF orientation reader for JPEG, PNG and WebP, and the eight-case pixel transform |
resize.c |
the exact area average over the cached 32×32 grid of area sums (ph_area_downscale()), and the box and Mitchell filters |
stb_resize_impl.c |
the translation unit that compiles vendor/stb_image_resize2.h, behind the box and Mitchell filters |
filters.c |
the σ-parameterized Gaussian blur (mHash, Radial) and histogram equalization (mHash) |
src/hashes/, one file per algorithm — ahash.c, dhash.c, phash.c, whash.c,
mhash.c, bmh.c, radial.c, color_histogram.c (ColorHash), color_moments.c — and
three shared by them: algorithm.c (ph_algorithm_t as a value, its name, and
ph_digest_shape(), the one place a digest's size and kind are decided), multi.c
(ph_compute_multi()) and common.c (the median threshold pHash and wHash share). What
each algorithm computes is on its page under docs/theory/; where it comes from, in
docs/algorithm-provenance.md.
Internal headers. There is no catch-all header; each subsystem has its own.
| Header | Holds |
|---|---|
context.h |
struct ph_context: the loaded image and its caches, the configuration, the last-error message, the arena |
arena.h |
the arena's interface: scratch blocks, marks and releases |
safety.h |
the decode limits, overflow-checked allocation sizes and the diagnostic message helpers |
bytes.h |
fixed-width integers read from bytes in a stated byte order (container headers, EXIF) |
digest.h |
the checks on a caller's ph_digest_t that every comparison shares |
fileio.h |
the file's bytes, from fileio.c |
batch.h |
the available CPU count |
loader.h |
the build's PH_USE_* flags, ph_decode_buffer(), each native decoder's decode function and the backend type {can_read, decode} |
image/image.h |
the pixel-level steps of src/image/ |
hashes/hashes.h |
what the algorithms share, and every algorithm's constants and parameter bounds with their static assertions |
loaders/backends.h |
the format probes (magic bytes, header dimensions) and each native backend's can_read() |
One image, through the code. ph_load_from_file() (core.c) clears the context's
image and takes the file's bytes from ph_open_file_bytes() (fileio.c);
ph_load_from_memory() starts with the caller's buffer. Both continue in
ph_load_encoded_bytes() (core.c): ph_decode_buffer() (loader.c) checks the limits and
hands the bytes to the first backend whose can_read() claims them, ph_resolve_alpha()
(image/color.c) removes the alpha channel, and with auto-orientation on
ph_apply_exif_orientation() (image/orient.c) turns the pixels by the tag read from the
same bytes. A hash such as ph_compute_phash() (hashes/phash.c) then takes its buffers
from ph_get_scratchpad() (arena.c) and its input from ph_area_downscale()
(image/resize.c), which computes the grayscale image through ph_get_gray()
(image/color.c) on first use and keeps it, with the area sums, until the next load.
Headers and includes¶
include/libphash.his the whole public API. Everything undersrc/is internal and split by subsystem, one header each (Source map).- A file includes the headers whose names it uses, spelled by their path from
src/(#include "image/image.h"), never through a sibling's includes and never with../. - The vendored single-file stb headers are the one exception: they are included by their
path relative to the including file (
#include "../vendor/stb_image.h"), so thatvendor/is not an include directory and no other vendored header can be picked up by a bare name.
CI matrix (.github/workflows/ci.yml)¶
One job per concern, all triggered on push to main or to a release/** branch,
on any pull request targeting either, and by hand through workflow_dispatch (see
"Running CI on a branch" below):
| Job | What it checks |
|---|---|
format-check |
scripts/format.sh --check — clang-format --dry-run --Werror with the pinned clang-format 23 over src/, include/, tests/, examples/, tools/; scripts/check_docs_coverage.sh; scripts/check_final_state_voice.sh, which fails on tracker ids, paths into local planning notes and release-cycle wording (a feature "since" a version) in tracked text; shellcheck --severity=warning over scripts/*.sh; scripts/check_casts.py, the explicit-cast count per file in src/ against scripts/explicit_casts.txt; scripts/check_coverage.py --check-docs, which keeps the coverage table below equal to scripts/coverage_thresholds.txt; scripts/check_spelling.py, American spelling in the documentation and the public header; and make docs (scripts/api_docs.sh, the pinned Doxygen 1.18 from scripts/install_doxygen.sh), the API reference, which fails on any undocumented public declaration and is uploaded as the api-reference-html artifact. Fast, no library build, catches these before the slower jobs run. |
build-and-test |
Full vendored build (libjpeg-turbo + libpng + libwebp + zlib-ng) across linux-x86_64 (gcc, clang), linux-arm64, macos-arm64, plus the shared library on linux-x86_64 (gcc). PHASH_STRICT_DEPS=ON, so a decoder silently falling back to stb_image is a hard configure failure, not a quiet pass. |
build-and-test-windows |
The same full vendored build under MSVC on windows-latest, with NASM for libjpeg-turbo's SIMD: the configuration of the windows-x86_64 release archives, built and tested before a tag. |
build-options |
stb_image-only builds with PHASH_ENABLE_THREADS=OFF (the batch API's sequential path) and with PHASH_ENABLE_MOCK_BACKEND=ON (the test-only DE AD decoder and the test branches written for it). PHASH_OPTIMIZE_NATIVE has no job: -march=native compiles for whatever CPU the runner has, so a result would describe that machine rather than the option. |
strict-warnings |
The full vendored build with PHASH_WARNINGS_AS_ERRORS=ON (gcc, clang): any warning in libphash's own sources, tests or benchmark fails it. The only job with -Werror, so a newer compiler's new warning never breaks a build from source. Its clang leg also runs clang-tidy's bugprone-misplaced-widening-cast over src/. |
coverage-cmake |
scripts/coverage_cmake.sh — line and branch coverage of the vendored decoder build, checked against scripts/coverage_thresholds.txt (see "Coverage standard"); the HTML report is published as a downloadable artifact. |
minimal-build |
Zero-dependency build on ubuntu-24.04, macos-latest, windows-latest: a checkout without submodules, configured with the default options, so stb_image decodes everything. Checks the one warning that names every missing submodule, and that PHASH_STRICT_DEPS=ON turns it into a configure error. |
c-standard-matrix |
Full test suite under -DCMAKE_C_STANDARD=11/17/23, gcc+clang, Linux+macOS (no Windows — see the Toolchains section above for why). |
build-and-test-32bit |
The native PNG backend (libpng) built -m32, catching size_t/int-width overflow bugs a 64-bit build can't reach. |
bare-image |
The full vendored build in an ubuntu:24.04 container with a compiler, cmake and nasm and no library headers: a vendored decoder that compiles against a system header, or a target missing a build-order dependency, passes on a runner image and fails here. Builds test_golden_hashes and test_build_info alone in a fresh tree and runs them. |
benchmark |
Regression gate against the PR's base commit — see the Benchmarks section below. |
sanitizers |
ASan+UBSan via CMake, -fno-sanitize-recover=all (first report aborts the run). |
tsan |
ThreadSanitizer over the threaded batch path (src/batch.c) and the "one context per thread" contract. |
valgrind |
The allocation-failure test suite (tests/src/test_alloc_failure.c) under Valgrind — independent of ASan/LSan, which don't mix with it, and the only place that test injects failures under a memory checker: its allocator shim stands down under ASan. Two legs: stb_image only, with a subset of the library's allocation-heavy tests, and every bundled decoder, which fails allocations inside libjpeg-turbo, libpng and libwebp. |
fuzz |
A short (90s) libFuzzer run per PR — a fast regression check, not real corpus exploration; see "Fuzzing" below for the real thing. |
install-smoke-test |
scripts/smoke_install.sh and scripts/smoke_add_subdirectory.sh — both consumer routes (find_package, pkg-config, add_subdirectory()), both link configurations; scripts/smoke_make_install.sh — the Makefile's install/uninstall; scripts/check_exported_symbols.sh, which fails unless the shared library exports exactly the functions of include/libphash.h (no internal helper, no stb_image, no vendored decoder); scripts/build_examples.sh, which builds and runs examples/ against the shared library and compiles every C block of README.md and MIGRATION.md against the installed headers. |
reproducible-builds |
scripts/check_reproducible.sh in four parallel legs: the CMake install trees (static and shared), the Makefile's libphash.a, and the linux-x86_64 and windows-x86_64 release archives, each built twice and compared byte for byte. |
Every build job starts the same way: a plain actions/checkout, then the local
composite action .github/actions/setup-build,
which fetches the vendored submodules and installs the job's packages (apt on Linux,
Homebrew on macOS). A job passes only what differs — its package list, or
submodules: 'false' for the stb-only jobs that never read vendor/. The checkout
itself cannot move into the action, because a local action is read from the working
copy. Configure and build steps stay in the jobs, since their arguments are what
distinguishes one job from another.
Five more workflows, and Dependabot, run on their own trigger rather than per push:
-
.github/workflows/site.yml— the documentation site:scripts/site.sh, strict, so a broken link or anchor, a page missing from the navigation, a missing included file or a figure whose stages disagree with the library fails it. A pushed release tagv*publishes the site to GitHub Pages (https://gudoshnikovn.github.io/libphash/; Settings → Pages → Source is GitHub Actions, and thegithub-pagesenvironment deploys frommainand from tagsv*); a pre-release tag, one containing-, does not. No push to a branch runs it: a change to the library's sources measures every figure again. By hand (Run workflow) it builds the site on the chosen branch, and onmainpublishes it again. Python 3.12, the oldest the site supports, installs the hashed lock with pip. The photo corpus is fetched first withtools/site/fetch_corpus.py fetch --strict, which fails unless every photograph arrives and matches the manifest; the measurements inbuild/site-cache/are kept in the Actions cache. The site is uploaded as thesite-htmlartifact. -
.github/workflows/release.yml— on a pushedv*tag, builds the static and shared release archives for every platform, smoke-tests each one from a clean extraction, attests them and publishes the GitHub Release; a tag containing-is published as a pre-release. Run by hand, it builds and smoke-tests the same archives as workflow artifacts without publishing. -
.github/workflows/fuzz-scheduled.yml— a 30-minute libFuzzer run on the default branch on Mondays and Thursdays, sharing the corpus cache with thefuzzjob. See "Fuzzing" below. GitHub disables thescheduletrigger of a public repository after 60 days without activity; the workflow's page in the Actions tab then offers to enable it again, and a manual run (Run workflow) works either way. .github/workflows/stb-freshness-check.yml— monthly, checks whether the two copied-in stb headers (vendor/stb_image.h,vendor/stb_image_resize2.h) have drifted from upstream and opens a tracking issue if so. SeeSECURITY.md's "Vendored dependencies" section..github/workflows/submodule-tags-check.yml— weekly, checks that each of the four vendored submodules (libjpeg-turbo, libpng, libwebp, zlib-ng) is on an upstream release tag, and on the newest one, and opens a tracking issue if not. SeeSECURITY.md's "Vendored dependencies" section..github/dependabot.yml— weekly PRs bumping the GitHub Actions. The submodules are not in it: its submodule updates follow a branch's newest commit, not a release tag.
Running CI on a branch¶
A release branch validates itself: ci.yml triggers on push to release/**, so
every merge into one runs the full matrix. Runs are free here — the repository is
public and uses standard runners, so every leg bills zero job-minutes.
For a branch that is not main or release/**, open a draft pull request against
release/**. For a pull_request event GitHub takes the workflow definition from the
merge ref, so this works even when the branch is the only place the workflow change
exists.
workflow_dispatch ("Run workflow" in the Actions UI) is the one route that does not
work from an arbitrary branch: GitHub registers it from the copy of the workflow file
on the default branch, and ignores a declaration that exists only elsewhere. The
same applies to schedule, Dependabot, and issue/PR templates — all of them are read
from the default branch only.
Testing Strategy¶
1. Unit Tests (tests/src/test_*.c)¶
Each module should have a corresponding test file. We use a simple test_macros.h for assertions.
The PNG and WebP decoder fixtures in tests/data/png/ — one PNG per pixel format a decoder
converts (gray at 1, 4 and 8 bits, RGB at 8 and 16 bits, palette with and without tRNS,
RGBA, and RGB and RGBA Adam7-interlaced) and broken files that fail at the header, in truncated image data and on a bad
checksum — are generated by python3 scripts/gen_png_fixtures.py (standard library only).
Their pixels follow formulas that tests/src/test_png_variants.c recomputes. The files are
committed; regenerating them with another zlib may change the compressed bytes, never the
pixels.
The JPEG fixtures in tests/data/jpeg/ are one 61×45 image in every coding mode the
decoder reads — 4:4:4, 4:2:2, 4:4:0, 4:2:0, 4:1:1, grayscale, progressive, restart markers,
arithmetic coding — encoded by scripts/gen_jpeg_fixtures.py with libjpeg-turbo's cjpeg
(the script's docstring shows how to build it from the submodule). JPEG is lossy, so
tests/src/test_jpeg_variants.c cannot recompute the pixels from a formula; it pins the
size and a checksum of what the native decoder returns for each file, in color and in
grayscale, at every decode_scale. The golden hashes see a 32×32 reduction and miss a
shift of a few levels; this table does not. A change to the decoder's settings or to the
libjpeg-turbo version fails it, and the failure prints the new table — accepting it is a
decision about decoded pixels, made in the same commit as the change that caused it.
scripts/check_decoder_symbols.sh checks the linked result against the build tree's
PHASH_USE_* options: every decoder that is on is linked statically and every one that is
off is absent, zlib-ng is the only zlib (or the system one, with PHASH_USE_ZLIB_NG=OFF),
nothing is a dynamic dependency on a decoder library, and nothing from libjpeg-turbo's
TurboJPEG archive (its private zlib and libspng) is linked. Every CMake job in CI runs it.
Golden hashes. tests/src/test_golden_hashes.c compares every algorithm on every
fixture, exactly, with tests/data/golden_hashes.<jpeg>.txt — one file per JPEG decoder
(libjpegturbo, stbjpeg), because the two round their IDCT differently; nothing else
in a build changes a value, so there is no tolerance and no per-platform file. A build
without a WebP decoder skips the WebP fixtures. Regenerate only after a deliberate change
to an algorithm's output, both files, from builds that decode WebP:
cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --target test_golden_hashes
./build/test_golden_hashes --update
cmake -B build-stb -DPHASH_USE_LIBJPEG_TURBO=OFF && cmake --build build-stb --target test_golden_hashes
./build-stb/test_golden_hashes --update
--update writes the file only when every fixture and algorithm succeeded.
When the test fails, the failure line names the fixture, the algorithm, the expected and the computed hex. What it means depends on where it fails:
- On one platform, compiler or option set only. That build computes differently from the others — a vectorized loop that reorders a sum, a contracted multiply-add, a library function with a different rounding. This is the defect to find; regenerating would hide it and leave the other builds disagreeing with the files.
- Everywhere, after a change you meant to make to an algorithm's output. Regenerate
both files as above, in the same commit as the change, and say in the commit message
which algorithms moved and why. Any such change also gets a
CHANGELOG.mdentry naming the algorithms whose stored hashes must be recomputed: every hash a user stored with them stops matching new ones. - Everywhere, after a change you did not mean to alter output (a refactor, an optimization). The change is not the no-op it was meant to be: fix it, do not regenerate.
- With a hex string of a different length. The digest's size changed, which is a
change to the algorithm's output format: as above, plus the
ph_digest_info()size the header documents.
The rule behind all four: the files record what the library computes, and only a deliberate change to what it computes moves them. A difference between builds is never absorbed into the files, by a tolerance or by a per-platform copy.
2. Stability Tests (tests/src/test_stability.c)¶
A color load and a grayscale load of the same file must hash identically wherever both reach gray through the library's own weights (every PNG decoder, WebP, JPEG through stb_image). JPEG through libjpeg-turbo returns the luma it decoded from YCbCr, so there the bound is 3 bits.
3. Fuzzing (tests/fuzz/fuzz_load.c)¶
A libFuzzer harness over the whole untrusted-input path: ph_load_from_memory(), which
reaches every decoder through the same dispatcher as a file load
(Source map), then one hash algorithm on the result. Build it with
-DPHASH_BUILD_FUZZERS=ON; this is a configure-time error under GCC, since libFuzzer
needs compiler-rt, which only Clang ships. The option instruments every C file in the
build — libphash and the vendored libjpeg-turbo, libpng, zlib-ng and libwebp alike — with
libFuzzer's coverage feedback and ASan/UBSan, so the fuzzer steers by the decoders' own
branches and a memory error inside a decoder is reported where it happens:
cmake -B build-fuzz -DPHASH_BUILD_FUZZERS=ON -DCMAKE_BUILD_TYPE=Debug -DCMAKE_C_COMPILER=clang
cmake --build build-fuzz --target fuzz_load -j
mkdir -p tests/fuzz/corpus
./build-fuzz/fuzz_load -max_total_time=60 -max_len=65536 -dict=tests/fuzz/magic.dict \
tests/fuzz/corpus tests/fuzz/seeds
tests/fuzz/corpus/ is the working corpus libFuzzer writes into, ignored by git;
tests/fuzz/seeds/ is the tracked seed set, one or more minimal files per accepted
format — its README lists where each comes from — and tests/fuzz/magic.dict the
signatures, markers and chunk names a mutation would not find by chance. Each input picks
its own configuration from its last 8 bytes (decode scale, grayscale, EXIF orientation,
alpha mode, every algorithm's parameters, which algorithm or ph_compute_multi() mask to
run), so the fuzzer reaches the configured paths as well as the defaults. Beyond crashes
and sanitizer reports, the harness aborts when a successful load reports non-positive
dimensions, or when the same hash computed twice differs. A crash it finds is
minimized and added to tests/fuzz/seeds/ in the commit that fixes it.
CI runs this two ways: a 90-second smoke run on every push and pull request (the fuzz
job above, meant to catch a fast regression, not explore the input space) and a 30-minute
run twice a week (fuzz-scheduled.yml). The working corpus is kept in the Actions cache:
each run restores the newest one its branch can see — its own branch's, or the default
branch's, which is where the scheduled run saves — reduces it with -merge=1 to the
inputs that add coverage, and saves it back. A cache entry unused for 7 days is evicted;
the two scheduled runs a week stay inside that, and each also uploads the minimized
corpus as the fuzz-corpus artifact, kept 90 days; unpacked into tests/fuzz/corpus/ it
is the starting point for a local run. Report a crash found this way through
SECURITY.md's reporting channel if it looks like a real memory-safety issue, not a
public issue.
4. Benchmarks (tests/src/bench_hash.c)¶
Used for performance regression testing. It is a measuring tool, not a test: it asserts
nothing, and make test/ctest do not run it. make benchmark builds and runs it;
run it directly with:
./bench_hash hash tests/data/photo.jpeg 100 # every algorithm, on a loaded image
./bench_hash load tests/data/photo.jpeg 100 # decode only, grayscale and RGB
./bench_hash load tests/data/photo.jpeg 100 3 # the same at PH_DECODE_SCALE_EIGHTH
./bench_hash full tests/data/photo.jpeg 100 # decode + pHash
./bench_hash --json smoke # fixed CI configuration
smoke times one decode per format — photo.jpeg requested as grayscale and as RGB (two
decoder paths), photo_complex.png, and photo.webp when the build has libwebp — then
every algorithm on the loaded photo.jpeg. Its JSON carries a schema number that says
what the metrics measure; it changes whenever a metric keeps its name but starts timing
different work, so two runs are comparable only when their schemas match.
Measurement methodology¶
Every mode warms up before measuring and then times each iteration separately,
reporting min_ms, median_ms, p90_ms and avg_ms:
- Warmup is
max(3, iterations/10)discarded iterations. For the decode modes this also pulls the file into the page cache, which is deliberate: the numbers are meant to describe decode cost, and disk latency would only add variance. It follows that these benchmarks do not measure cold-cache I/O. min_msis the number to compare across builds. It is the best available estimate of how fast the code can run with OS noise removed.median_msshows the typical case;p90_msshows how noisy the machine was during the run — ap90_msfar abovemedian_msmeans the environment, not the code, changed.- A hashing row is the first hash computed on a loaded image, the cost a caller pays after a load: the grayscale conversion and the area-sum grid shared by aHash, pHash, wHash and BMH are included. The context caches both until the image changes, so the benchmark drops them before every iteration, outside the timed region; timing repeated hashes on one context would measure only the work left once they exist.
avg_msshould not be used for comparisons. It is a mean over the whole loop, so a single scheduler preemption shifts it by tens of percent. It is kept in the JSON only for schema compatibility with older baselines.
Measured noise floor¶
A benchmark number is only useful if its run-to-run spread is smaller than the
regression it is supposed to detect. The floor is measured by running
scripts/bench_regression_gate.sh with the same binary on both sides, a comparison
whose true answer is 0% for every metric, and reading it against the thresholds the gate
ships with: 10% for one metric, 5% for the median change over all of them.
| Machine | Gate runs | Worst single metric | Flags at 10% | Worst median change |
|---|---|---|---|---|
| Apple M3 Pro, macOS, desktop in use (2026-10-04) | 7 | 14.4% (loading_png_rgb; ColorHash and wHash Full about 7%, the rest under 3%) |
2 of 91 | 0.25% |
GitHub-hosted ubuntu-24.04 x86-64, the CI benchmark job (run 37198168665, 2026-10-04, a documentation-only push) |
1 | 0.49% (loading_grayscale; runs at most 2.1% apart) |
0 of 14 | 0.02% |
This is the only place these numbers are written; the gate script and the CI job refer here.
On a desktop with performance and efficiency cores one metric can land a clock step
apart between runs, so a lone per-metric flag whose spread is as large as its change is
noise; the median change, unmoved by any one metric, stays far below 5% there. The gate
compares min_ms for the same reason: a mean over the whole loop moves by tens of
percent when the scheduler preempts one iteration, and a binary compared against itself
on avg_ms shows false regressions of 40% and more.
The thresholds sit far below what the gate exists to catch: an extra decode pass or a
lost fast path costs far more than 10%, and an even slowdown of the whole pipeline by
5% is a real one. On the CI runner they are twenty times its worst single metric, so
the job runs the gate with STRICT=1: a flagged regression fails it, after the report
has been published to the run summary and the benchmark-report artifact. Run without
STRICT, the script only reports.
Running the gate¶
The CI benchmark job builds two trees in the same job and runs
scripts/bench_regression_gate.sh on them, so runner noise lands on both sides. Which
commit is the base depends on the event:
| Event | Base |
|---|---|
| pull request | the PR's base commit |
push to main or release/** |
the previous tip of the pushed branch: everything the push brought in is compared at once |
| manual run (Run workflow) | the bench_base_ref input — a branch, tag or commit, main by default |
A push that creates the branch has no previous tip, and the job says so in a notice instead of comparing.
The report lists every metric either side measured. Two rules flag a regression: one
metric more than 10% slower, or the median change over all metrics both sides have more
than 5% slower — an even slowdown of the whole pipeline that keeps each metric under 10%.
The median is unmoved by one noisy metric, so its threshold can sit lower. A metric on
one side only is shown as new or gone and is not a regression. The spread column
is how far apart the runs of the noisier side landed, (max - min) / median of their
min_ms; a flag on a metric with a spread near the change is worth a rerun before a
search for the cause. When the two sides report different schema numbers the report
says so and compares nothing.
The two binaries take turns, one run each, rather than all runs of one and then all of the other. A change in the machine's speed while the gate runs then falls on both sides. In blocks it would read as a difference in the code, and more runs would not average it out. Measured on arm64 macOS, a binary against itself, with every core loaded from the third second of each gate run, 10 gate runs per order: in blocks, the worst metric of every run was 13–31% off, always toward the side that ran under load, and the median change kept the same sign in all 10; taking turns, the worst metric stayed within 2.7% in 8 runs of 10 (15% and 18% in the other two) and the median change within 0.1%, its sign varying. GitHub offers the manual run only for workflows declared on the default branch.
Locally, build both versions and pass the two binaries, the one under test first:
5. Sanitizers (ASan + UBSan)¶
make debug # rebuilds with -O0 -g -fsanitize=address,undefined
# NOTE: this cleans and rebuilds; it does NOT run the tests
make test -j8 # ...so always run the suite afterwards
CI runs the same pair through CMake in the sanitizers job, with
-fno-sanitize-recover=all so the first report aborts the test.
Known vendor exemption — -fno-sanitize=alignment on src/image/stb_resize_impl.c.
The vendored vendor/stb_image_resize2.h packs its filter coefficients with
deliberately unaligned 64-bit moves: STBIR_MOVE_2 in stbir__pack_coefficients
casts a float* to stbir_uint64*, and the coefficient stride is frequently odd
(coeffs += coefficient_width, pc += 7), so every other move lands on a
4-mod-8 address. The buffer is stb's own internal bump allocation — 16-byte
aligned at its base — and the buffers we hand to stbir_resize* are always
16-byte aligned, so nothing about our call sites is at fault. The pattern is
present verbatim in current upstream master (v2.18), i.e. a version bump does not
help. Untreated it produces 5 runtime error: load/store of misaligned address
... for type 'stbir_uint64' per test-suite run, which is exactly the kind of
constant noise that lets a real finding of ours slip through.
The vendored implementation therefore lives in its own translation unit,
src/image/stb_resize_impl.c, which contains nothing but the
#define STB_IMAGE_RESIZE_IMPLEMENTATION / #include pair, and only that file
is compiled with -fno-sanitize=alignment (STB_NOSAN_CFLAGS in the Makefile,
set_source_files_properties(...) in CMakeLists.txt). Because the exempt TU has
no code of ours in it, alignment violations in libphash itself are still
reported normally — as are all other UBSan checks, including in that file.
A runtime UBSAN_OPTIONS=suppressions=... file does not work here: the suppression is
silently ignored under -fno-sanitize-recover=all, so the report still fires and the
process still aborts.
If you add another file that includes a vendored header with known UB, prefer the
same shape (isolated TU + narrowest possible -fno-sanitize=<check>) over a
blanket suppression, and document it here.
Known vendor patch — OOM handling in vendor/stb_image_resize2.h.
Under ASan/UBSan (and generally whenever assert()-style debug allocators are
in play) stbir__alloc_internal_mem_and_build_samplers() switches to
STBIR__SEPARATE_ALLOCATIONS, where every internal buffer is malloc()'d one
at a time instead of via one merged block. Upstream's out-of-memory handling
in that mode has three independent bugs, each patched in place in the vendored
header with a /* libphash local patch (not upstream): ... */ comment
explaining the reasoning at the point of the change (search the file for that
marker — there are five call sites):
stbir__info(and, one level down, itssplit_infoarray and each split'sring_bufferspointer array) is raw, unzeroed memory right after its own allocation. If a later allocation in the same call fails,stbir__free_internal_mem()walks these structures by count (info->splits,info->alloc_ring_buffer_num_entries) and frees whatever garbage pointers it finds in not-yet-populated fields — a segfault. Fixed by zeroing each of these blocks immediately after its own successful allocation, so an unpopulated field is a realNULLthe free path can skip.stbir__free_internal_mem()itself indexes into a split'sring_buffersarray without checking whether that array's own allocation succeeded, dereferencing a nullfloat**when it did not. Fixed by guarding that loop.- A handful of intermediate buffers (
vertical's gather/prescatter contributors and coefficients,horizontal/verticalcontributors and coefficients, and a small internal 15-byte sentinel block used only to keep the two-pass allocation loop's bookkeeping truthy) are recorded onto the persistentinfostruct — the thingstbir__free_internal_mem()actually knows how to free — only long after they are allocated. If a subsequent allocation fails in between, the block is unreachable frominfoand leaks. Fixed by mirroring each of these ontoinfoimmediately after its own successful allocation, instead of waiting for the later bulk copy.
The allocation-failure harness (tests/src/alloc_shim.h +
tests/src/test_alloc_failure.c), which fails a chosen allocation ordinal and checks
for crashes/leaks, exercises all five of its scenarios, including under sanitizers;
without the patch two of them crash or leak.
This diverges from upstream stb_image_resize2 (present verbatim in current
upstream master). The whole change is vendor/patches/stb_image_resize2.h.patch; a bump
of this vendored file applies it to the new upstream file and re-verifies it against
test_alloc_failure under make debug && make test — the steps are in
vendor/patches/README.md. A version bump without the patch reopens the crash and the
leaks, and scripts/check_stb_patches.sh fails on it in CI.
Known vendor patch — the reason reported for an allocation failure in
vendor/stb_image.h. src/loader.c classifies a failed stbi_load*() by
comparing stbi_failure_reason() against string literals from the vendored
header (ph_stb_oom_reasons[], ph_stb_unsupported_reasons[]). Upstream loses
that reason in two independent places, so a decode that failed purely because
malloc() returned NULL is reported to the caller as a verdict on the file:
- The three
stbi_zlib_decode_*malloc*entry points return NULL when their own initialstbi__malloc()fails without callingstbi__err()at all, while the PNG caller assumes they did (return 0; // zlib should set error).stbi_failure_reason()then still holds whatever unrelated string ran last — in our own call sequence,"no SOI", left by the JPEG probe inside thestbi_info_from_memory()thatph_decode_stb_mem()runs first to read the dimensions. An out-of-memory PNG arrives asPH_ERR_CORRUPT_DATA. Fixed by setting"outofmem"there, through astbi__errpc()spelled exactly like the file's existingstbi__errpuc()/stbi__errpf(). - The format probes that allocate (
stbi__jpeg_test,stbi__jpeg_info,stbi__gif_info_raw) report an allocation failure by returning 0 — the same value that means "this is not my format".stbi__load_main()/stbi__info_main()cannot tell the two apart, move on to the next format, and the dispatch's own"unknown image type"overwrites"outofmem"; a perfectly valid JPEG arrives asPH_ERR_UNSUPPORTED_FORMAT. Fixed with a thread-localstbi__g_probe_outofmemflag, set by those three probes, cleared at the top of each dispatch and read only where the dispatch is about to give up — the success path is untouched.
Known vendor patch — short reads in TGA and HDR in vendor/stb_image.h.
stbi__getn() copies nothing when the input ends before the requested length and
returns 0, and two callers ignore that: the row read of an uncompressed TGA (into the
malloc()ed image) and the flat pixel read of an HDR narrower than 8 pixels (into a
4-byte stack buffer). Upstream, a truncated file of either kind decodes successfully
with uninitialized memory in the image, so the same bytes hash differently from load to
load. Both reads fail the decode with "outofdata" instead, which src/loader.c
reports as PH_ERR_CORRUPT_DATA; test_loader loads every cut of a TGA and an HDR
fixture that ends inside the pixel data.
Each change carries the same /* libphash local patch (not upstream): ... */
marker as the resize patch above (search the file for it), and the whole change is
vendor/patches/stb_image.h.patch. Without the allocation-failure part, 5 of test_alloc_failure's 83
failure points misreport. As with the resize patch, a bump of this vendored file applies
it to the new upstream file, re-verifies it against test_alloc_failure, and records the
commit and the new pair of hashes in THIRD-PARTY-NOTICES.md (vendor/patches/README.md). src/loader.c classifies a
decode failure by comparing against error strings that live inside stb_image.h, so a
bump also has to be reviewed against ph_stb_unsupported_reasons[] and
test_stb_failure_classification.
See SECURITY.md's "Vendored dependencies" section for how this project tracks the two
copied-in stb headers against upstream.
Adding New Features¶
- Header: Add the public signature to
include/libphash.h. - Implementation:
- Add hash algorithms to
src/hashes/. - Add image processing kernels to
src/image/. - Add new decoders to
src/loaders/.
- Add hash algorithms to
- Build:
MakefileandCMakeLists.txtare configured to detect new files in these directories automatically. - Documentation: Update the algorithm's page in
docs/theory/, the Source map for a new file, and the function comments in the header (Doxygen style).