Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ jobs:
# No round trip: Python would need the sanitizer runtime preloaded.
# linux-tsan ThreadSanitizer: decode calls run at once on one reader.
# linux-tidy clang-tidy with .clang-tidy, findings are errors.
# linux-coverage coverage of minipsd.h, round trip included: lines 100 %, branches >= 95 %.
# linux-coverage coverage of minipsd.h, round trip included: lines 100 %, branches >= 97 %.
analysis:
if: ${{ !github.event.pull_request.draft }}
name: ${{ matrix.preset }}
Expand Down Expand Up @@ -149,13 +149,15 @@ jobs:
# --exclude-throw-branches: the C++ builds add exception edges that C code never takes.
# The C units (the C smoke test, the examples, the fuzz replay) check that the header builds
# and behaves as C, not coverage: gcc numbers a line's branches differently in C, so gcovr
# cannot merge them with the C++ units' and lists them twice. Their data goes first.
# cannot merge them with the C++ units' and lists them twice. Their data goes first, and so
# do the C++ compile checks' (*_check.cpp, built, never run): their functions, never called,
# would be listed beside the tests' under their C names.
- name: Coverage report
if: matrix.preset == 'linux-coverage'
run: |
find build/linux-coverage \( -name '*.c.gcda' -o -name '*.c.gcno' \) -delete
find build/linux-coverage \( -name '*.c.gcda' -o -name '*.c.gcno' -o -name '*_check.cpp.gcno' \) -delete
gcovr --root . --filter include/ --merge-lines --exclude-throw-branches \
--fail-under-line 100 --fail-under-branch 95 \
--fail-under-line 100 --fail-under-branch 97 \
--cobertura coverage.xml --txt coverage.txt --txt-metric branch --txt-summary \
build/linux-coverage || status=$?
{ echo '```'; cat coverage.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
Expand Down
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
Changes between releases, for users of minipsd. Versioning follows the pre-1.0 rules in
[TODO.md](TODO.md): breaking changes bump the MINOR, additions and fixes the PATCH.

## [Unreleased]

### Changed
- Stated what `minipsd_read` always required: a `MINIPSD_MALLOC` of your own must give blocks
aligned to 8 bytes, as `malloc`'s are, or `minipsd_read` returns `MINIPSD_ERR_ALLOC`.

## [0.4.1] — 2026-11-03

### Added
Expand Down
12 changes: 8 additions & 4 deletions docs/CHEATSHEET.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ cmake --preset linux-asan && cmake --build --preset linux-asan && ctest --preset
cmake --preset linux-tidy && cmake --build --preset linux-tidy # findings fail the build

cmake --preset linux-coverage && cmake --build --preset linux-coverage && ctest --preset linux-coverage
find build/linux-coverage \( -name '*.c.gcda' -o -name '*.c.gcno' \) -delete # the C units: see below
find build/linux-coverage \( -name '*.c.gcda' -o -name '*.c.gcno' -o -name '*_check.cpp.gcno' \) -delete # see below
gcovr --root . --filter include/ --merge-lines --exclude-throw-branches \
--txt-metric branch --txt build/linux-coverage # lines and branches missing a test
```
Expand All @@ -52,8 +52,10 @@ gcovr --root . --filter include/ --merge-lines --exclude-throw-branches \
The C units (the C smoke test, the examples, the fuzz replay) are left out, as CI leaves them
out: they check the header builds and behaves as C, and gcc numbers a line's branches
differently in C, so gcovr would list those lines twice and the duplicates would hide which test
is missing. Coverage data adds up across runs: delete `build/linux-coverage` for a clean report.
CI fails below 100 % of lines or 95 % of branches and publishes the report to Coveralls.
is missing. So are the C++ compile checks (`*_check.cpp`, built, never run): their functions,
never called, would show as missing a test. Coverage data adds up across runs: delete
`build/linux-coverage` for a clean report.
CI fails below 100 % of lines or 97 % of branches and publishes the report to Coveralls.

## Fuzzing

Expand Down Expand Up @@ -160,7 +162,9 @@ paths also check the SBOM fragment's version and that it lists the headers.
| `MINIPSD_TEST_FIXTURES` | ON (`linux-coverage`: OFF) | read the local PSDs in `tests/fixtures/` too; off, a local coverage run measures what CI's does |
| `MINIPSD_CLANG_TIDY` | empty (`linux-tidy`: `clang-tidy`) | run clang-tidy on the test targets |

GoogleTest comes from the system if installed (`libgtest-dev`), otherwise it is fetched.
GoogleTest comes from the system if installed (`libgtest-dev`), otherwise it is fetched (v1.14.0,
pinned by hash). Offline, `-DFETCHCONTENT_SOURCE_DIR_GTEST=<unpacked googletest>` takes a copy
of your own.

The round trip needs Python with `psd-tools`; without it the test is skipped. A venv keeps it
out of the system Python:
Expand Down
2 changes: 1 addition & 1 deletion docs/CODE-ARCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,7 @@ Every stage follows these rules:
- **Independent reader for every byte change.** `psd-tools` in CI checks new bytes, as
[Verification](SPECIFICATION.md#verification) requires. minipsd's own reader is never the
only check of its own writer.
- **Coverage stays at 100 % of lines** and at least 95 % of branches, per module. A new module
- **Coverage stays at 100 % of lines** and at least 97 % of branches in all (95 % per module). A new module
comes with its `test_<module>.cpp`.
- **The spec follows the code.** When a stage lands, its part of this file moves into
[SPECIFICATION.md](SPECIFICATION.md), and this file keeps only what is still ahead.
Expand Down
2 changes: 1 addition & 1 deletion docs/MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -991,7 +991,7 @@ Define before the `#include` in the implementation file:
| Macro | Effect |
|---|---|
| `MINIPSD_STATIC` | every function `static`, private to that file: several copies never clash, and those you do not call do not warn |
| `MINIPSD_MALLOC` / `MINIPSD_FREE` | replace `malloc` / `free`; define both |
| `MINIPSD_MALLOC` / `MINIPSD_FREE` | replace `malloc` / `free`; define both. `minipsd_read` needs blocks aligned to 8 bytes, as `malloc`'s are, or gives `MINIPSD_ERR_ALLOC` |
| `MINIPSD_NO_MALLOC` | no allocation at all, and no `<stdlib.h>`: `minipsd_write` goes, the pull writer and the reader stay |
| `MINIPSD_NO_READER` | no reader, typed views or `minipsd_read`: less code for a program that only writes |
| `MINIPSD_NO_WRITER` | no writer, typed setters or `minipsd_read`: less code for a program that only reads |
4 changes: 2 additions & 2 deletions docs/SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ its layers and composite as stored: no colour is converted.
| `MINIPSD_ERR_ARG` | -1 | NULL document or callback, no layers and no composite, ICC size without data, or a dpi that 16.16 fixed point cannot hold (see below) |
| `MINIPSD_ERR_SIZE` | -2 | a side is 0 or above the format's (`MINIPSD_MAX_SIDE`, `MINIPSD_MAX_SIDE_PSB`), or a section is longer than its length field allows (see below) |
| `MINIPSD_ERR_WRITE` | -3 | the callback returned 0 |
| `MINIPSD_ERR_ALLOC` | -4 | `MINIPSD_MALLOC` returned NULL |
| `MINIPSD_ERR_ALLOC` | -4 | `MINIPSD_MALLOC` returned NULL, or for `minipsd_read` a block aligned to less than 8 bytes |
| `MINIPSD_ERR_OPEN` | -5 | not returned by minipsd; reserved for the caller's file code (`examples/write_file.c`) |
| `MINIPSD_ERR_FORMAT` | -6 | reader: not a well-formed PSD |
| `MINIPSD_ERR_UNSUPPORTED` | -7 | reader: a valid file outside what minipsd reads: for decoding, a file of another depth than `opts->depth`, or a ZIP-compressed channel without `opts->inflate`; for `minipsd_read`, a file the writer could not take back (a mode at a depth it does not write, such as 32-bit CMYK, or layers in a flat mode); writer: a mode at a depth it does not write; on a 32-bit platform, a PSB whose layer and mask section `size_t` cannot hold |
Expand Down Expand Up @@ -886,7 +886,7 @@ Platemaker is the first consumer. Breaking any of these breaks it:
with the correct dpi.
- The same tests also run instrumented in CI: under AddressSanitizer and UBSan, under clang-tidy
(`.clang-tidy`, findings are errors), and with coverage of `minipsd.h`: **lines must stay at
100 %** and branches at 95 % or more, so new code comes with the test that executes it,
100 %** and branches at 97 % or more, so new code comes with the test that executes it,
error paths included (`test_alloc_failure.cpp` fails each allocation in turn).
- **Single file:** the same tests also run against the single-file `minipsd.h` the release
attaches, and `minipsd.h` is compiled alone and then with its implementation, with no internal
Expand Down
13 changes: 9 additions & 4 deletions include/minipsd.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
* \par Configuration
* Define before the implementation include:
* - \c MINIPSD_STATIC: make every function static (private to that file);
* - \c MINIPSD_MALLOC / \c MINIPSD_FREE: replace malloc / free (define both);
* - \c MINIPSD_MALLOC / \c MINIPSD_FREE: replace malloc / free (define both; minipsd_read needs
* blocks aligned to 8 bytes, as malloc's are);
* - \c MINIPSD_NO_MALLOC: leave out every allocation: minipsd_write, minipsd_read and
* minipsd_doc_free go (and <stdlib.h>); the pull writer, the reader and minipsd_read_into, on
* the caller's memory, stay;
Expand Down Expand Up @@ -158,7 +159,8 @@ enum {
MINIPSD_MAX_SIDE_PSB), or the document is too large for
PSD's 32-bit section lengths (about 4 GiB). */
MINIPSD_ERR_WRITE = -3, /*!< The write callback reported a failure. */
MINIPSD_ERR_ALLOC = -4, /*!< MINIPSD_MALLOC returned NULL. */
MINIPSD_ERR_ALLOC = -4, /*!< MINIPSD_MALLOC returned NULL, or for minipsd_read a
block aligned to less than 8 bytes. */
MINIPSD_ERR_OPEN = -5, /*!< Not returned by minipsd; kept for the caller's own file
code (examples/): a file not opened or closed. */
MINIPSD_ERR_FORMAT = -6, /*!< Reader and typed views: the bytes are not well-formed. */
Expand Down Expand Up @@ -1049,7 +1051,9 @@ MINIPSDDEF int minipsd_reader_workspace_size(size_t *size);
* thumbnail), and nothing after; for layers in a global block (see minipsd_info.layer_tail), up
* to their count in that block. A buffer that ends sooner gives MINIPSD_ERR_SHORT: read more and
* call again, on the same workspace; minipsd_reader_needed says how much. Nothing is allocated or
* copied, and calling init again restarts every iteration.
* copied, and calling init again restarts every iteration. What follows is checked as it is
* read: a record or channel data that overruns its section is minipsd_reader_next_layer's
* MINIPSD_ERR_FORMAT, so MINIPSD_OK here does not mean the whole file is sound.
*
* \param r Receives the reader, built inside workspace; set on MINIPSD_OK and on
* MINIPSD_ERR_SHORT, NULL otherwise.
Expand Down Expand Up @@ -1744,7 +1748,8 @@ MINIPSDDEF int minipsd_read_into(const void *data, size_t size, const minipsd_li
* \brief Reads a whole file into a document in one MINIPSD_MALLOC block; minipsd_doc_free frees it.
*
* minipsd_read_size and minipsd_read_into on a block of its own: the same document, the same
* return codes, and MINIPSD_ERR_ALLOC when the allocation fails.
* return codes, and MINIPSD_ERR_ALLOC when the allocation fails or its block is aligned to less
* than 8 bytes (malloc's never is): the document must start the block for minipsd_doc_free.
*/
MINIPSDDEF int minipsd_read(const void *data, size_t size, const minipsd_limits *limits,
const minipsd_read_opts *opts, minipsd_doc **doc, uint32_t *lost);
Expand Down
2 changes: 1 addition & 1 deletion include/minipsd/read.h
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ static int mpsd__read(const void *data, size_t size, const minipsd_limits *limit
int k, keep;
unsigned lplanes; /* decoded: transparency only when the file has it */
rc = minipsd_reader_next_layer(r, &v);
if (rc != MINIPSD_MORE) return rc == MINIPSD_OK ? MINIPSD_ERR_FORMAT : rc;
if (rc != MINIPSD_MORE) return rc == MINIPSD_OK ? MINIPSD_ERR_FORMAT : rc; /* OK: never, init counted them */
if (v.other_channels) loss |= MINIPSD_LOST_CHANNELS; /* TODO: alpha and spot channels (ROADMAP, Not scheduled) */
w = (uint64_t)((int64_t)v.right - v.left);
h = (uint64_t)((int64_t)v.bottom - v.top);
Expand Down
2 changes: 1 addition & 1 deletion include/minipsd/reader.h
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ MINIPSDDEF int minipsd_reader_init(minipsd_reader **out, const void *data, size_

r->data = (const uint8_t *)data;
r->size = size;
r->base = 0;
r->base = 0; /* the window starts the file; nothing moves it yet (ROADMAP, streaming read) */
r->needed_from = r->needed = 0;
r->limits.max_side = limits ? limits->max_side : 0; /* 0: the format's own, known from the header */
r->limits.max_layers = limits && limits->max_layers ? limits->max_layers : MPSD__MAX_LAYERS;
Expand Down
5 changes: 3 additions & 2 deletions include/minipsd/writer.h
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@ static mpsd__source mpsd__channel_source(const minipsd_doc *d, const minipsd_lay
src.height = r.height;
} else if (k == mpsd__ch_mask(d)) {
const minipsd_mask *m = l->mask;
const uint32_t w = (uint32_t)((int64_t)m->right - m->left), h = (uint32_t)((int64_t)m->bottom - m->top);
/* NOLINTNEXTLINE(clang-analyzer-core.NullDereference): mpsd__channel_count counts this channel only with a mask */
const uint32_t w =(uint32_t)((int64_t)m->right - m->left), h = (uint32_t)((int64_t)m->bottom - m->top);
src.height = h;
if (m->stored.size) { src.stored = &m->stored; return src; }
if (m->pixels && w && h) { src.samples = m->pixels; src.width = w; }
Expand Down Expand Up @@ -789,7 +790,7 @@ static int mpsd__own_layers_block(const minipsd_doc *d)
none. */
static int mpsd__alpha_uncounted(const minipsd_doc *d)
{
return mpsd__composite_alpha(d) && !mpsd__doc_flat(d) && (mpsd__own_layers_block(d) || !d->layer_count);
return mpsd__composite_alpha(d) && (mpsd__own_layers_block(d) || !d->layer_count);
}

/* Then the writer names it as Photoshop does, which GIMP reads it by, unless the document keeps
Expand Down
4 changes: 3 additions & 1 deletion tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ if(NOT GTest_FOUND)
set(INSTALL_GTEST OFF CACHE BOOL "" FORCE)
FetchContent_Declare(GTest
URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip
URL_HASH SHA256=1f357c27ca988c3f7c6b4bf68a9395005ac6761f034046e9dde0896e3aba00e4
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
)
FetchContent_MakeAvailable(GTest)
Expand Down Expand Up @@ -85,7 +86,8 @@ set(_minipsd_test_sources
test_composite.cpp
test_writer.cpp
test_reader.cpp
minipsd_c_smoke.c # minipsd.h compiled as C, compared byte for byte with the C++ build
test_edges.cpp # NULLs, overflowing counts, empty inputs and limits: the branches left over
minipsd_c_smoke.c # minipsd.h compiled as C, compared byte for byte with the C++ build
test_alloc_failure.cpp # minipsd.h with an allocator that fails on demand
test_golden.cpp # the bytes of 0.1.0, by SHA-256, for a fixed set of documents
)
Expand Down
27 changes: 22 additions & 5 deletions tests/test_alloc_failure.cpp
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* \file
* \brief minipsd.h built with an allocator that fails on demand, so every MINIPSD_ERR_ALLOC path
* runs (its own translation unit: MINIPSD_MALLOC must be set before the implementation).
* \brief minipsd.h built with an allocator that fails on demand, or aligns less than malloc, so
* every MINIPSD_ERR_ALLOC path runs (its own translation unit: MINIPSD_MALLOC must be set
* before the implementation).
*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: Copyright (c) 2026 Bartlomiej Mucha <shadobadev@gmail.com>
Expand All @@ -13,19 +14,26 @@
#include <utility>

namespace {
int g_allocs_left = -1; // allocations that still succeed; -1: all of them
int g_allocs_left = -1; // allocations that still succeed; -1: all of them
std::size_t g_misalign = 0; // bytes past malloc's address: a pool that aligns less

void *failingMalloc(std::size_t size)
{
if (g_allocs_left == 0) return nullptr;
if (g_allocs_left > 0) --g_allocs_left;
return std::malloc(size);
auto *p = static_cast<unsigned char *>(std::malloc(size + g_misalign));
return p ? p + g_misalign : nullptr;
}

void misalignedFree(void *ptr)
{
std::free(ptr ? static_cast<unsigned char *>(ptr) - g_misalign : nullptr);
}
} // namespace

#define MINIPSD_STATIC
#define MINIPSD_MALLOC(size) failingMalloc(size)
#define MINIPSD_FREE(ptr) std::free(ptr)
#define MINIPSD_FREE(ptr) misalignedFree(ptr)
#define MINIPSD_IMPLEMENTATION
#include "minipsd.h"

Expand Down Expand Up @@ -71,4 +79,13 @@ TEST(MinipsdAllocTest, EachFailedAllocationIsReported)
ASSERT_EQ(minipsd_read(file, n, nullptr, nullptr, &doc, nullptr), MINIPSD_OK) << "one allocation is all a read needs";
minipsd_doc_free(doc);
g_allocs_left = -1;

// Blocks aligned to less than the document needs: the writer aligns inside its workspace, but
// minipsd_read's document could not start its block, so minipsd_doc_free could not free it.
g_misalign = MPSD__ALIGN / 2;
EXPECT_EQ(minipsd_write(&d, nullptr, count, &size), MINIPSD_OK) << "the writer takes any alignment";
doc = reinterpret_cast<minipsd_doc *>(1);
EXPECT_EQ(minipsd_read(file, n, nullptr, nullptr, &doc, nullptr), MINIPSD_ERR_ALLOC);
EXPECT_EQ(doc, nullptr);
g_misalign = 0;
}
Loading
Loading