Coverage Report

Created: 2026-09-04 07:25

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/zxc/src/lib/zxc_seekable.c
Line
Count
Source
1
/*
2
 * ZXC - High-performance lossless compression
3
 *
4
 * Copyright (c) 2025-2026 Bertrand Lebonnois and contributors.
5
 * SPDX-License-Identifier: BSD-3-Clause
6
 */
7
8
/**
9
 * @file zxc_seekable.c
10
 * @brief Seekable archive reader (random-access decompression) and seek table writer.
11
 *
12
 * The seek table is a standard ZXC block (type = ZXC_BLOCK_SEK) appended
13
 * between the EOF block and the file footer.  It records the compressed size
14
 * of every block (decompressed sizes are derived from the header's block_size),
15
 * enabling O(1) lookup + O(block_size) decompression for any byte range.
16
 *
17
 * On-disk layout of a SEK block:
18
 *
19
 *   [Block Header (8B)]   block_type=SEK, block_flags=0, comp_size=N*4
20
 *   [N x Entry (4B)]      comp_size(u32 LE) per block
21
 *
22
 * Detection from end of file:
23
 *   1. Read file header (first 16 bytes) => block_size
24
 *   2. Read file footer (last 12 bytes) => total_decompressed_size
25
 *   3. Derive num_blocks = ceil(total_decomp / block_size)
26
 *   4. Compute seek block size, read backward to the block header
27
 *   5. Validate block_type == ZXC_BLOCK_SEK
28
 */
29
30
#include "../../include/zxc_seekable.h"
31
32
#include "../../include/zxc_dict.h"
33
#include "../../include/zxc_error.h"
34
#include "zxc_internal.h"
35
#include "zxc_threads.h"
36
37
// =========================================================================
38
// Seek Table Writer
39
// =========================================================================
40
41
/**
42
 * @brief Byte size of a seek table holding @p num_blocks entries.
43
 *
44
 * Public API (declared in @c zxc_seekable.h): one block header plus
45
 * @p num_blocks fixed-size entries. Use it to size the destination buffer
46
 * before @ref zxc_write_seek_table.
47
 */
48
9.45k
size_t zxc_seek_table_size(const uint32_t num_blocks) {
49
9.45k
    return ZXC_BLOCK_HEADER_SIZE + (size_t)num_blocks * ZXC_SEEK_ENTRY_SIZE;
50
9.45k
}
51
52
/**
53
 * @brief Serialises a seek table (a @c ZXC_BLOCK_SEK block) into @p dst.
54
 *
55
 * Public API; full contract in @c zxc_seekable.h. Emits the standard ZXC block
56
 * header followed by one little-endian @c u32 compressed-size entry per block.
57
 */
58
int64_t zxc_write_seek_table(uint8_t* dst, const size_t dst_capacity, const uint32_t* comp_sizes,
59
9.45k
                             const uint32_t num_blocks) {
60
9.45k
    if (UNLIKELY(num_blocks > UINT32_MAX / ZXC_SEEK_ENTRY_SIZE)) return ZXC_ERROR_OVERFLOW;
61
62
9.45k
    const size_t total = zxc_seek_table_size(num_blocks);
63
9.45k
    if (UNLIKELY(dst_capacity < total)) return ZXC_ERROR_DST_TOO_SMALL;
64
9.45k
    if (UNLIKELY(!dst || !comp_sizes)) return ZXC_ERROR_NULL_INPUT;
65
66
9.45k
    const uint32_t payload_size = num_blocks * ZXC_SEEK_ENTRY_SIZE;
67
68
    // Write standard ZXC block header
69
9.45k
    const zxc_block_header_t bh = {
70
9.45k
        .block_type = ZXC_BLOCK_SEK, .block_flags = 0, .reserved = 0, .comp_size = payload_size};
71
9.45k
    const int hdr_res = zxc_write_block_header(dst, dst_capacity, &bh);
72
9.45k
    if (UNLIKELY(hdr_res < 0)) return hdr_res;
73
9.45k
    uint8_t* p = dst + hdr_res;
74
75
    // Write entries: comp_size(4) only
76
20.1k
    for (uint32_t i = 0; i < num_blocks; i++) {
77
10.6k
        zxc_store_le32(p, comp_sizes[i]);
78
10.6k
        p += sizeof(uint32_t);
79
10.6k
    }
80
81
9.45k
    return (int64_t)(p - dst);
82
9.45k
}
83
84
// =========================================================================
85
// Seekable Reader (Opaque Handle)
86
// =========================================================================
87
88
struct zxc_seekable_s {
89
    // Source - exactly one of {src, reader.read_at} is set. The FILE* variant
90
    // wraps pread() in its own reader ctx, indistinguishable from here.
91
    const uint8_t* src;
92
    uint64_t src_size;
93
    zxc_reader_t reader; /* user-supplied callback reader; read_at == NULL when unused */
94
95
    // Reader context owned by the handle and freed in zxc_seekable_free, set by
96
    // the thin wrappers. NULL when the caller owns reader.ctx itself.
97
    void* owned_reader_ctx;
98
99
    // Parsed seek table
100
    uint32_t num_blocks;
101
    uint32_t* comp_sizes;   /* array[num_blocks] */
102
    uint64_t* comp_offsets; /* prefix-sum: byte offset in compressed file per block */
103
    uint64_t total_decomp;  /* total decompressed size (from footer) */
104
    uint32_t max_comp_size; /* largest entry of comp_sizes, from the same walk */
105
106
    // File header info - block_size is always a power of 2 in [4KB, 2MB],
107
    // fits in 21 bits.
108
    uint32_t block_size;
109
    int file_has_checksums;
110
    uint32_t expected_dict_id; /* dict_id from the file header; 0 = no dictionary */
111
112
    // Reusable decompression context and compressed-block scratch. Both belong
113
    // to the single-threaded path, which is already not reentrant per handle;
114
    // the multi-threaded path gives each worker its own.
115
    zxc_cctx_t dctx;
116
    int dctx_initialized;
117
    uint8_t* read_buf;
118
    size_t read_buf_cap;
119
120
    // Dictionary (owned copy, freed in zxc_seekable_free).
121
    uint8_t* dict;
122
    size_t dict_size;
123
    // Shared literal Huffman table (owned copy; meaningful when has_dict_huf).
124
    uint8_t dict_huf[ZXC_HUF_TABLE_SIZE];
125
    int has_dict_huf;
126
};
127
128
/**
129
 * @struct zxc_seek_source_t
130
 * @brief Where the archive bytes come from during parsing.
131
 *
132
 * The two public entry points differ only in this: @ref zxc_seekable_open holds
133
 * the whole archive in memory, @ref zxc_seekable_open_reader reaches it through
134
 * a positioned callback. Everything after the first read is common, so the
135
 * parser below takes a source instead of being written twice.
136
 */
137
typedef struct {
138
    const uint8_t* data;     /* in-memory archive, NULL in callback mode */
139
    const zxc_reader_t* rdr; /* callback mode, NULL in buffer mode */
140
    uint64_t size;           /* archive size, both modes */
141
} zxc_seek_source_t;
142
143
/**
144
 * @brief Reads a byte range from the archive, whatever backs it.
145
 *
146
 * Returns 1 on success, 0 if the range falls outside the archive or the
147
 * caller's reader came up short: every bounds check on a parsed offset goes
148
 * through here.
149
 */
150
static int zxc_seek_source_read(const zxc_seek_source_t* src, void* dst, const size_t len,
151
27.4k
                                const uint64_t off) {
152
27.4k
    if (UNLIKELY(off > src->size || (uint64_t)len > src->size - off)) return 0;
153
27.4k
    if (src->data) {
154
27.4k
        ZXC_MEMCPY(dst, src->data + off, len);
155
27.4k
        return 1;
156
27.4k
    }
157
0
    return src->rdr->read_at(src->rdr->ctx, dst, len, off) == (int64_t)len;
158
27.4k
}
159
160
/**
161
 * @brief Parses and validates the seek table at the end of the archive.
162
 *
163
 * Detection (backward from end):
164
 *   1. Read file header => block_size
165
 *   2. Read file footer => total_decomp_size
166
 *   3. Derive num_blocks = ceil(total_decomp_size / block_size)
167
 *   4. Compute expected seek block position, validate block_type == SEK
168
 *   5. Read comp_sizes, build the compressed-offset prefix sums, and check the
169
 *      layout lands exactly on the EOF block
170
 *
171
 * Returns a handle to free via @ref zxc_seekable_free, or NULL if the archive
172
 * is too small or the seek table is missing / malformed.
173
 */
174
18.9k
static zxc_seekable* zxc_seekable_parse(const zxc_seek_source_t* src) {
175
    // Minimum: file_header(16) + eof_block(8) + seek_block_header(8)
176
    //          + file_footer(12) = 44
177
18.9k
    const uint64_t MIN_SEEKABLE_SIZE =
178
18.9k
        ZXC_FILE_HEADER_SIZE + ZXC_BLOCK_HEADER_SIZE + ZXC_BLOCK_HEADER_SIZE + ZXC_FILE_FOOTER_SIZE;
179
18.9k
    if (UNLIKELY(src->size < MIN_SEEKABLE_SIZE)) return NULL;
180
181
    // Step 1: validate file header => block_size
182
18.0k
    uint8_t header[ZXC_FILE_HEADER_SIZE];
183
18.0k
    if (UNLIKELY(!zxc_seek_source_read(src, header, sizeof(header), 0))) return NULL;
184
185
18.0k
    size_t block_size_sz = 0;
186
18.0k
    int file_has_chk = 0;
187
18.0k
    uint32_t header_dict_id = 0;
188
18.0k
    if (UNLIKELY(zxc_read_file_header(header, sizeof(header), &block_size_sz, &file_has_chk,
189
18.0k
                                      &header_dict_id) != ZXC_OK))
190
8.56k
        return NULL;  // LCOV_EXCL_LINE
191
9.45k
    const uint32_t block_size = (uint32_t)block_size_sz;
192
9.45k
    if (UNLIKELY(block_size == 0)) return NULL;  // LCOV_EXCL_LINE
193
194
    // Step 2: read total decompressed size from the file footer
195
9.45k
    uint8_t footer[ZXC_FILE_FOOTER_SIZE];
196
9.45k
    if (UNLIKELY(
197
9.45k
            !zxc_seek_source_read(src, footer, sizeof(footer), src->size - ZXC_FILE_FOOTER_SIZE)))
198
0
        return NULL;
199
9.45k
    const uint64_t total_decomp = zxc_le64(footer);
200
201
    // A value of 0 means empty file - no seek table
202
9.45k
    if (UNLIKELY(total_decomp == 0)) return NULL;
203
204
    // Step 3: derive num_blocks = ceil(total_decomp / block_size)
205
9.45k
    const uint64_t num_blocks_64 = total_decomp / block_size + (total_decomp % block_size != 0);
206
9.45k
    if (UNLIKELY(num_blocks_64 > UINT32_MAX)) return NULL;
207
9.45k
    const uint32_t num_blocks = (uint32_t)num_blocks_64;
208
209
    // Step 4: locate and validate the seek block. Two headers of margin, not one:
210
    // the tail read below spans the EOF block, so tail_total could wrap on 32 bits.
211
9.45k
    const uint64_t entries_total = num_blocks_64 * ZXC_SEEK_ENTRY_SIZE;
212
9.45k
    if (UNLIKELY(entries_total > SIZE_MAX - 2 * ZXC_BLOCK_HEADER_SIZE)) return NULL;
213
214
9.45k
    const size_t seek_block_total = ZXC_BLOCK_HEADER_SIZE + (size_t)entries_total;
215
9.45k
    if (UNLIKELY((uint64_t)seek_block_total + ZXC_FILE_FOOTER_SIZE > src->size)) return NULL;
216
217
9.45k
    const uint64_t seek_off = src->size - ZXC_FILE_FOOTER_SIZE - (uint64_t)seek_block_total;
218
9.45k
    if (UNLIKELY(seek_off < ZXC_BLOCK_HEADER_SIZE)) return NULL;
219
220
    // The EOF block sits immediately before the seek block, so one read covers
221
    // both: [EOF 8][SEK header 8][entries]. Keeps a reader-backed open at three
222
    // reads (header, footer, tail) while validating the same layout the
223
    // in-memory path does.
224
9.45k
    const size_t tail_total = ZXC_BLOCK_HEADER_SIZE + seek_block_total;
225
9.45k
    const uint64_t tail_off = seek_off - ZXC_BLOCK_HEADER_SIZE;
226
227
9.45k
    uint8_t* tail = NULL;
228
9.45k
    const uint8_t* tail_view;
229
9.45k
    zxc_seekable* s = NULL;
230
9.45k
    if (src->data) {
231
9.45k
        tail_view = src->data + tail_off;
232
9.45k
    } else {
233
0
        tail = (uint8_t*)ZXC_MALLOC(tail_total);
234
0
        if (UNLIKELY(!tail)) return NULL;  // LCOV_EXCL_LINE
235
0
        if (UNLIKELY(!zxc_seek_source_read(src, tail, tail_total, tail_off))) goto fail;
236
0
        tail_view = tail;
237
0
    }
238
239
9.45k
    const uint8_t* const eof_hdr = tail_view;
240
9.45k
    const uint8_t* const seek_blk = tail_view + ZXC_BLOCK_HEADER_SIZE;
241
242
9.45k
    zxc_block_header_t bh;
243
    // The SEK header stores the table size in a 32-bit field, so reject any larger value before
244
    // reading it.
245
9.45k
    if (UNLIKELY(entries_total > UINT32_MAX)) goto fail;
246
9.45k
    if (UNLIKELY(zxc_read_block_header(seek_blk, seek_block_total, &bh) != ZXC_OK ||
247
9.45k
                 bh.block_type != ZXC_BLOCK_SEK || bh.comp_size != entries_total))
248
0
        goto fail;
249
250
    // Step 5: allocate the handle and parse the entries
251
9.45k
    s = (zxc_seekable*)ZXC_CALLOC(1, sizeof(zxc_seekable));
252
9.45k
    if (UNLIKELY(!s)) goto fail;  // LCOV_EXCL_LINE
253
254
9.45k
    if (src->rdr) s->reader = *src->rdr;
255
9.45k
    s->src = src->data;
256
9.45k
    s->src_size = src->size;
257
9.45k
    s->num_blocks = num_blocks;
258
9.45k
    s->block_size = block_size;
259
9.45k
    s->file_has_checksums = file_has_chk;
260
9.45k
    s->expected_dict_id = header_dict_id;
261
9.45k
    s->total_decomp = total_decomp;
262
263
9.45k
    s->comp_sizes = (uint32_t*)ZXC_CALLOC(num_blocks, sizeof(uint32_t));
264
9.45k
    s->comp_offsets = (uint64_t*)ZXC_CALLOC((size_t)num_blocks + 1, sizeof(uint64_t));
265
9.45k
    if (UNLIKELY(!s->comp_sizes || !s->comp_offsets)) goto fail;  // LCOV_EXCL_LINE
266
267
    // Parse comp_sizes and build compressed prefix sums. Every entry is checked
268
    // against the archive size, so the prefix sum can neither overflow nor
269
    // point a later read out of bounds.
270
9.45k
    {
271
9.45k
        const uint8_t* ep = seek_blk + ZXC_BLOCK_HEADER_SIZE;
272
9.45k
        uint64_t comp_acc = ZXC_FILE_HEADER_SIZE; /* blocks start after file header */
273
20.1k
        for (uint32_t i = 0; i < num_blocks; i++) {
274
10.6k
            s->comp_sizes[i] = zxc_le32(ep);
275
10.6k
            ep += sizeof(uint32_t);
276
277
            // Reject entries below minimum (block header) or larger than the file
278
10.6k
            if (UNLIKELY(s->comp_sizes[i] < ZXC_BLOCK_HEADER_SIZE || s->comp_sizes[i] > src->size))
279
0
                goto fail;
280
10.6k
            if (s->comp_sizes[i] > s->max_comp_size) s->max_comp_size = s->comp_sizes[i];
281
10.6k
            s->comp_offsets[i] = comp_acc;
282
10.6k
            comp_acc += s->comp_sizes[i];
283
            // Reject if cumulative offset exceeds file size (inconsistent table)
284
10.6k
            if (UNLIKELY(comp_acc > src->size)) goto fail;  // LCOV_EXCL_LINE
285
10.6k
        }
286
9.45k
        s->comp_offsets[num_blocks] = comp_acc;
287
288
        // Verify the prefix sum lands exactly on the EOF block, and that an EOF
289
        // block really sits there. Expected layout:
290
        // [header 16][data blocks][EOF 8][SEK block][footer 12]
291
9.45k
        zxc_block_header_t eof_bh;
292
9.45k
        if (UNLIKELY(comp_acc != seek_off - ZXC_BLOCK_HEADER_SIZE ||
293
9.45k
                     zxc_read_block_header(eof_hdr, ZXC_BLOCK_HEADER_SIZE, &eof_bh) != ZXC_OK ||
294
9.45k
                     eof_bh.block_type != ZXC_BLOCK_EOF))
295
0
            goto fail;
296
9.45k
    }
297
298
9.45k
    ZXC_FREE(tail);
299
9.45k
    return s;
300
301
0
fail:
302
0
    ZXC_FREE(tail);
303
0
    zxc_seekable_free(s);
304
0
    return NULL;
305
9.45k
}
306
307
/**
308
 * @brief Opens a seekable archive held entirely in a memory buffer.
309
 *
310
 * Public API; see @c zxc_seekable.h. Thin guard around
311
 * @ref zxc_seekable_parse, which detects and validates the trailing seek table.
312
 */
313
18.9k
zxc_seekable* zxc_seekable_open(const void* src, const size_t src_size) {
314
18.9k
    if (UNLIKELY(!src || src_size == 0)) return NULL;
315
18.9k
    const zxc_seek_source_t source = {(const uint8_t*)src, NULL, (uint64_t)src_size};
316
18.9k
    return zxc_seekable_parse(&source);
317
18.9k
}
318
319
// zxc_seekable_open_file lives elsewhere: it builds a zxc_reader_t over pread()
320
// and delegates below, keeping this TU free of <stdio.h>.
321
322
/**
323
 * @brief Opens a seekable archive over a caller-supplied random-access reader.
324
 *
325
 * Public API; see @c zxc_seekable.h. Reads the file header, footer and seek
326
 * block through @p r->read_at (the FILE* variant wraps @c pread this way),
327
 * validates the SEK block, and builds the per-block compressed-offset prefix
328
 * sums. Unlike @ref zxc_seekable_open the archive is never mapped whole; only
329
 * the metadata is read up front.
330
 */
331
0
zxc_seekable* zxc_seekable_open_reader(const zxc_reader_t* r) {
332
0
    if (UNLIKELY(!r || !r->read_at || r->size == 0)) return NULL;
333
0
    const zxc_seek_source_t source = {NULL, r, r->size};
334
0
    return zxc_seekable_parse(&source);
335
0
}
336
337
/**
338
 * @brief Number of blocks in the archive.
339
 */
340
18.9k
uint32_t zxc_seekable_get_num_blocks(const zxc_seekable* s) { return s ? s->num_blocks : 0; }
341
342
/**
343
 * @brief Total decompressed size of the archive.
344
 */
345
18.9k
uint64_t zxc_seekable_get_decompressed_size(const zxc_seekable* s) {
346
18.9k
    return s ? s->total_decomp : 0;
347
18.9k
}
348
349
/**
350
 * @brief Compressed byte size of a given block.
351
 */
352
20.1k
uint32_t zxc_seekable_get_block_comp_size(const zxc_seekable* s, const uint32_t block_idx) {
353
20.1k
    if (UNLIKELY(!s || block_idx >= s->num_blocks)) return 0;
354
10.6k
    return s->comp_sizes[block_idx];
355
20.1k
}
356
357
/**
358
 * @brief Decompressed byte size of a given block.
359
 *
360
 * Every block decompresses to @c block_size except the last, which holds the
361
 * remainder of @c total_decomp.
362
 */
363
20.1k
uint32_t zxc_seekable_get_block_decomp_size(const zxc_seekable* s, const uint32_t block_idx) {
364
20.1k
    if (UNLIKELY(!s || block_idx >= s->num_blocks)) return 0;
365
10.6k
    const uint64_t start = (uint64_t)block_idx * (uint64_t)s->block_size;
366
10.6k
    const uint64_t remaining = s->total_decomp - start;
367
10.6k
    return (remaining >= (uint64_t)s->block_size) ? s->block_size : (uint32_t)remaining;
368
20.1k
}
369
370
// =========================================================================
371
// Random-Access Decompression
372
// =========================================================================
373
374
/**
375
 * @brief Maps a decompressed @p offset to its containing block index (O(1)).
376
 * @param[in] block_size  Fixed decompressed block size (a power of two).
377
 * @param[in] offset      Absolute decompressed byte offset.
378
 * @return Zero-based index of the block that holds @p offset.
379
 */
380
40.0k
static uint32_t zxc_seek_find_block(const uint32_t block_size, const uint64_t offset) {
381
40.0k
    return (uint32_t)(offset / (uint64_t)block_size);
382
40.0k
}
383
384
/**
385
 * @brief Decompressed start offset of block @p idx (O(1)).
386
 * @param[in] block_size  Fixed decompressed block size.
387
 * @param[in] idx         Zero-based block index.
388
 * @return Absolute decompressed byte offset where block @p idx begins.
389
 */
390
20.2k
static uint64_t zxc_seek_decomp_offset(const uint32_t block_size, const uint32_t idx) {
391
20.2k
    return (uint64_t)idx * (uint64_t)block_size;
392
20.2k
}
393
394
/**
395
 * @brief Decompressed size of block @p idx (O(1)).
396
 *
397
 * Returns @p block_size for every block except the last, which holds the
398
 * remainder of @p total_decomp.
399
 *
400
 * @param[in] block_size    Fixed decompressed block size.
401
 * @param[in] total_decomp  Total decompressed archive size.
402
 * @param[in] idx           Zero-based block index.
403
 * @return Decompressed byte size of block @p idx.
404
 */
405
static uint32_t zxc_seek_decomp_size(const uint32_t block_size, const uint64_t total_decomp,
406
1.20k
                                     const uint32_t idx) {
407
1.20k
    const uint64_t start = (uint64_t)idx * (uint64_t)block_size;
408
1.20k
    const uint64_t remaining = total_decomp - start;
409
1.20k
    return (remaining >= (uint64_t)block_size) ? block_size : (uint32_t)remaining;
410
1.20k
}
411
412
/**
413
 * @brief Reads a compressed block into @p buf from the memory buffer or reader.
414
 *
415
 * Copies from @c s->src in buffer mode, otherwise calls @c s->reader.read_at
416
 * (which also backs the FILE* variant).
417
 *
418
 * @param[in]  s          Seekable handle.
419
 * @param[in]  block_idx  Zero-based block index to read.
420
 * @param[out] buf        Destination buffer.
421
 * @param[in]  buf_cap    Capacity of @p buf in bytes.
422
 * @return The block's compressed byte count on success, or a negative
423
 *         @ref zxc_error_t (@ref ZXC_ERROR_DST_TOO_SMALL,
424
 *         @ref ZXC_ERROR_SRC_TOO_SMALL, @ref ZXC_ERROR_IO).
425
 */
426
static int zxc_seek_read_block(const zxc_seekable* s, const uint32_t block_idx, uint8_t* buf,
427
20.2k
                               const size_t buf_cap) {
428
20.2k
    const uint64_t off = s->comp_offsets[block_idx];
429
20.2k
    const uint32_t csz = s->comp_sizes[block_idx];
430
20.2k
    if (UNLIKELY(csz > buf_cap)) return ZXC_ERROR_DST_TOO_SMALL;
431
432
20.2k
    if (s->src) {
433
        // Buffer mode
434
20.2k
        if (UNLIKELY(off + csz > s->src_size)) return ZXC_ERROR_SRC_TOO_SMALL;
435
20.2k
        ZXC_MEMCPY(buf, s->src + off, csz);
436
20.2k
    } else if (s->reader.read_at) {
437
        // Caller-supplied reader (also covers the FILE* variant, which
438
        // provides a pread-backed callback from zxc_seekable_file.c).
439
0
        const int64_t r = s->reader.read_at(s->reader.ctx, buf, csz, off);
440
0
        if (UNLIKELY(r != (int64_t)csz)) return (r < 0) ? (int)r : ZXC_ERROR_IO;
441
1
    } else {
442
1
        return ZXC_ERROR_NULL_INPUT;  // LCOV_EXCL_LINE
443
1
    }
444
20.2k
    return (int)csz;
445
20.2k
}
446
447
/**
448
 * @brief Decompresses the byte range [@p offset, @p offset + @p len) into @p dst.
449
 *
450
 * Public API; full contract in @c zxc_seekable.h. Maps the range to its block
451
 * span via O(1) division, decodes each covered block through a reusable,
452
 * lazily-initialised, dictionary-aware context, and copies out only the
453
 * requested sub-range. Single-threaded; see @ref zxc_seekable_decompress_range_mt
454
 * for the parallel variant.
455
 */
456
int64_t zxc_seekable_decompress_range(zxc_seekable* s, void* dst, const size_t dst_capacity,
457
37.1k
                                      const uint64_t offset, const size_t len) {
458
37.1k
    if (UNLIKELY(len == 0)) return 0;
459
27.7k
    if (UNLIKELY(!s || !dst)) return ZXC_ERROR_NULL_INPUT;
460
27.7k
    if (UNLIKELY(dst_capacity < len)) return ZXC_ERROR_DST_TOO_SMALL;
461
27.7k
    if (UNLIKELY(offset + len > s->total_decomp)) return ZXC_ERROR_SRC_TOO_SMALL;
462
18.2k
    if (UNLIKELY(s->expected_dict_id != 0 && (!s->dict || s->dict_size == 0)))
463
0
        return ZXC_ERROR_DICT_REQUIRED;
464
465
    // Initialize decompression context on first use
466
18.2k
    if (!s->dctx_initialized) {
467
        // LCOV_EXCL_START
468
9.45k
        if (UNLIKELY(zxc_cctx_init(&s->dctx, (size_t)s->block_size, 0, 0, 0, s->dict_size) !=
469
9.45k
                     ZXC_OK))
470
0
            return ZXC_ERROR_MEMORY;
471
        // LCOV_EXCL_STOP
472
9.45k
        if (UNLIKELY(zxc_cctx_attach_dict_huf(&s->dctx, s->has_dict_huf ? s->dict_huf : NULL) !=
473
9.45k
                     ZXC_OK)) {
474
            // LCOV_EXCL_START
475
0
            zxc_cctx_free(&s->dctx);
476
0
            return ZXC_ERROR_CORRUPT_DATA;
477
            // LCOV_EXCL_STOP
478
0
        }
479
9.45k
        s->dctx_initialized = 1;
480
9.45k
        if (s->dict_size > 0) ZXC_MEMCPY(s->dctx.dict_buffer, s->dict, s->dict_size);
481
9.45k
    }
482
18.2k
    s->dctx.dict_size = s->dict_size;
483
484
    // work_buf is pre-sized to block_size + ZXC_DECOMPRESS_TAIL_PAD by the
485
    // matching zxc_cctx_init above.
486
18.2k
    const size_t work_sz = (size_t)s->block_size + ZXC_DECOMPRESS_TAIL_PAD;
487
488
    // Find block range - O(1) division
489
18.2k
    const uint32_t blk_start = zxc_seek_find_block(s->block_size, offset);
490
18.2k
    const uint32_t blk_end = zxc_seek_find_block(s->block_size, offset + len - 1);
491
492
18.2k
    uint8_t* out = (uint8_t*)dst;
493
18.2k
    size_t remaining = len;
494
495
    // Compressed-block scratch, sized once for the largest block of the archive
496
    // and kept on the handle: a range read is often one of many.
497
18.2k
    const size_t read_cap = (size_t)s->max_comp_size + ZXC_PAD_SIZE;
498
18.2k
    if (s->read_buf_cap < read_cap) {
499
9.45k
        uint8_t* const nb = (uint8_t*)ZXC_REALLOC(s->read_buf, read_cap);
500
9.45k
        if (UNLIKELY(!nb)) return ZXC_ERROR_MEMORY;  // LCOV_EXCL_LINE
501
9.45k
        s->read_buf = nb;
502
9.45k
        s->read_buf_cap = read_cap;
503
9.45k
    }
504
18.2k
    uint8_t* const read_buf = s->read_buf;
505
506
37.3k
    for (uint32_t bi = blk_start; bi <= blk_end; bi++) {
507
        // Read compressed block data
508
19.0k
        const int read_res = zxc_seek_read_block(s, bi, read_buf, read_cap);
509
19.0k
        if (UNLIKELY(read_res < 0)) return read_res;  // LCOV_EXCL_LINE
510
511
        // Decompress the block: when a dictionary is active, decode into the
512
        // cctx-owned dict_buffer (which has dict content prepended) so that
513
        // match copies referencing dictionary bytes resolve naturally.
514
19.0k
        uint8_t* dec_dst =
515
19.0k
            s->dctx.dict_buffer ? s->dctx.dict_buffer + s->dict_size : s->dctx.work_buf;
516
19.0k
        const int dec_res =
517
19.0k
            zxc_decompress_chunk_wrapper(&s->dctx, read_buf, (size_t)read_res, dec_dst, work_sz);
518
19.0k
        if (UNLIKELY(dec_res < 0)) return dec_res;  // LCOV_EXCL_LINE
519
520
        // Calculate which portion of this block's decompressed data we need
521
19.0k
        const uint64_t blk_decomp_start = zxc_seek_decomp_offset(s->block_size, bi);
522
19.0k
        const size_t skip = (offset > blk_decomp_start) ? (size_t)(offset - blk_decomp_start) : 0;
523
19.0k
        if (UNLIKELY((size_t)dec_res < skip)) return ZXC_ERROR_CORRUPT_DATA;  // LCOV_EXCL_LINE
524
19.0k
        const size_t avail = (size_t)dec_res - skip;
525
19.0k
        const size_t copy = (avail < remaining) ? avail : remaining;
526
527
19.0k
        ZXC_MEMCPY(out, dec_dst + skip, copy);
528
19.0k
        out += copy;
529
19.0k
        remaining -= copy;
530
19.0k
    }
531
532
18.2k
    return (int64_t)len;
533
18.2k
}
534
535
// =========================================================================
536
// Multi-Threaded Random-Access Decompression (Fork-Join)
537
// =========================================================================
538
539
/**
540
 * @brief Per-block job descriptor for multi-threaded decompression.
541
 *
542
 * Each worker thread receives a pointer to one of these, performs the read +
543
 * decompress + memcpy sequence, and writes the result code into @c result.
544
 * The main thread inspects @c result after join.
545
 */
546
typedef struct {
547
    const zxc_seekable* s; /* shared handle (read-only) */
548
    uint32_t block_idx;    /* block to decompress */
549
    uint8_t* dst;          /* output pointer within caller's buffer */
550
    size_t skip;           /* bytes to skip at start of decompressed block */
551
    size_t copy_len;       /* bytes to copy into dst */
552
    int result;            /* 0 = OK, < 0 = error */
553
} zxc_seek_mt_job_t;
554
555
/**
556
 * @struct zxc_seek_mt_stripe_t
557
 * @brief Per-thread stripe descriptor for multi-threaded decompression.
558
 *
559
 * Each worker owns the job subset {first, first+stride, first+2*stride, ...}
560
 * of the shared @c jobs array and reuses one decompression context, one
561
 * dictionary copy and one read buffer across all of them, amortising what
562
 * would otherwise be per-block costs (context init, dict memcpy, malloc and
563
 * a thread spawn per block).  Stripes are pairwise disjoint, so workers
564
 * touch distinct jobs and distinct output ranges and need no
565
 * synchronisation beyond the final join.
566
 *
567
 * Written by @ref zxc_seekable_decompress_range_mt before the fork phase and
568
 * read-only for the worker (@ref zxc_seek_mt_worker); results travel through
569
 * the jobs themselves (@c zxc_seek_mt_job_t::result).
570
 *
571
 * @var zxc_seek_mt_stripe_t::jobs
572
 *      Job array shared by all workers; this worker only reads/writes the
573
 *      entries of its own stripe.
574
 * @var zxc_seek_mt_stripe_t::num_jobs
575
 *      Total number of jobs in @c jobs (stripe iteration bound).
576
 * @var zxc_seek_mt_stripe_t::first
577
 *      Index of this worker's first job (equals its worker index, in
578
 *      [0, @c stride)).
579
 * @var zxc_seek_mt_stripe_t::stride
580
 *      Stripe step between consecutive jobs of this worker; equals the
581
 *      worker-thread count.
582
 */
583
typedef struct {
584
    zxc_seek_mt_job_t* jobs;
585
    uint32_t num_jobs;
586
    uint32_t first;
587
    uint32_t stride;
588
} zxc_seek_mt_stripe_t;
589
590
/**
591
 * @brief Marks every job of a stripe with @p code (setup-failure path).
592
 *
593
 * @param[in,out] st   Stripe whose jobs to mark.
594
 * @param[in]     code Negative @ref zxc_error_t value.
595
 */
596
0
static void zxc_seek_mt_fail_stripe(zxc_seek_mt_stripe_t* st, const int code) {
597
0
    for (uint32_t i = st->first; i < st->num_jobs; i += st->stride) st->jobs[i].result = code;
598
0
}
599
600
/**
601
 * @brief Worker thread entry point for multi-threaded seekable decompression.
602
 *
603
 * Sets up its context, dictionary copy and read buffer once, then for each
604
 * block of its stripe: read (thread-safe pread), decompress, copy the
605
 * requested sub-range into the caller's output.  The dict prefix survives
606
 * across blocks because the decoder never writes below its dst.
607
 *
608
 * Each job's outcome goes into its @c result (read by the main thread after
609
 * join); on error the worker abandons the rest of its stripe.
610
 *
611
 * @param[in,out] arg  Pointer to this worker's `zxc_seek_mt_stripe_t`.
612
 * @return Always NULL (result codes are reported via the jobs).
613
 */
614
1.19k
static void* zxc_seek_mt_worker(void* arg) {
615
1.19k
    zxc_seek_mt_stripe_t* const st = (zxc_seek_mt_stripe_t*)arg;
616
1.19k
    zxc_seek_mt_job_t* const jobs = st->jobs;
617
1.19k
    const zxc_seekable* const s = jobs[st->first].s;
618
619
    // Thread-local decompression context (mode=0 for decompress-only)
620
1.19k
    zxc_cctx_t dctx;
621
1.19k
    if (UNLIKELY(zxc_cctx_init(&dctx, (size_t)s->block_size, 0, 0, 0, s->dict_size) != ZXC_OK)) {
622
        // LCOV_EXCL_START
623
0
        zxc_seek_mt_fail_stripe(st, ZXC_ERROR_MEMORY);
624
0
        return NULL;
625
        // LCOV_EXCL_STOP
626
0
    }
627
628
1.19k
    if (UNLIKELY(zxc_cctx_attach_dict_huf(&dctx, s->has_dict_huf ? s->dict_huf : NULL) != ZXC_OK)) {
629
        // LCOV_EXCL_START
630
0
        zxc_cctx_free(&dctx);
631
0
        zxc_seek_mt_fail_stripe(st, ZXC_ERROR_CORRUPT_DATA);
632
0
        return NULL;
633
        // LCOV_EXCL_STOP
634
0
    }
635
1.19k
    const size_t work_sz = (size_t)s->block_size + ZXC_DECOMPRESS_TAIL_PAD;
636
637
1.19k
    uint8_t* const dict_work = dctx.dict_buffer;
638
1.19k
    if (dict_work) ZXC_MEMCPY(dict_work, s->dict, s->dict_size);
639
640
    // Read buffer sized for the largest compressed block of the stripe.
641
1.19k
    size_t max_csz = 0;
642
2.39k
    for (uint32_t i = st->first; i < st->num_jobs; i += st->stride) {
643
1.20k
        const uint32_t csz = s->comp_sizes[jobs[i].block_idx];
644
1.20k
        if (csz > max_csz) max_csz = csz;
645
1.20k
    }
646
1.19k
    uint8_t* const read_buf = (uint8_t*)ZXC_MALLOC(max_csz + ZXC_PAD_SIZE);
647
1.19k
    if (UNLIKELY(!read_buf)) {
648
        // LCOV_EXCL_START
649
0
        zxc_cctx_free(&dctx);
650
0
        zxc_seek_mt_fail_stripe(st, ZXC_ERROR_MEMORY);
651
0
        return NULL;
652
        // LCOV_EXCL_STOP
653
0
    }
654
655
2.40k
    for (uint32_t i = st->first; i < st->num_jobs; i += st->stride) {
656
1.20k
        zxc_seek_mt_job_t* const job = &jobs[i];
657
658
1.20k
        const int read_res =
659
1.20k
            zxc_seek_read_block(s, job->block_idx, read_buf, max_csz + ZXC_PAD_SIZE);
660
1.20k
        if (UNLIKELY(read_res < 0)) {
661
            // LCOV_EXCL_START
662
0
            job->result = read_res;
663
0
            break;
664
            // LCOV_EXCL_STOP
665
0
        }
666
667
        // Decompress: use dict bounce buffer when dictionary is active
668
1.20k
        uint8_t* dec_dst = dict_work ? dict_work + s->dict_size : dctx.work_buf;
669
1.20k
        const int dec_res =
670
1.20k
            zxc_decompress_chunk_wrapper(&dctx, read_buf, (size_t)read_res, dec_dst, work_sz);
671
672
1.20k
        if (UNLIKELY(dec_res < 0)) {
673
            // LCOV_EXCL_START
674
0
            job->result = dec_res;
675
0
            break;
676
            // LCOV_EXCL_STOP
677
0
        }
678
1.20k
        if (UNLIKELY((size_t)dec_res < job->skip + job->copy_len)) {
679
            // LCOV_EXCL_START
680
0
            job->result = ZXC_ERROR_CORRUPT_DATA;
681
0
            break;
682
            // LCOV_EXCL_STOP
683
0
        }
684
685
        // Copy the requested portion directly into the caller's output buffer
686
1.20k
        ZXC_MEMCPY(job->dst, dec_dst + job->skip, job->copy_len);
687
1.20k
        job->result = 0;
688
1.20k
    }
689
690
1.19k
    ZXC_FREE(read_buf);
691
1.19k
    zxc_cctx_free(&dctx);
692
1.19k
    return NULL;
693
1.19k
}
694
695
/**
696
 * @brief Multi-threaded variant of @ref zxc_seekable_decompress_range.
697
 *
698
 * Public API; full contract in @c zxc_seekable.h. Plans one job per covered
699
 * block (each with its own thread-local context and read buffer) and runs them
700
 * fork-join in waves of up to @p n_threads. Falls back to the single-threaded
701
 * path for trivial spans. @p n_threads == 0 auto-detects the core count.
702
 */
703
int64_t zxc_seekable_decompress_range_mt(zxc_seekable* s, void* dst, const size_t dst_capacity,
704
1.72k
                                         const uint64_t offset, const size_t len, int n_threads) {
705
1.72k
    if (UNLIKELY(len == 0)) return 0;
706
1.72k
    if (UNLIKELY(!s || !dst)) return ZXC_ERROR_NULL_INPUT;
707
1.72k
    if (UNLIKELY(dst_capacity < len)) return ZXC_ERROR_DST_TOO_SMALL;
708
1.72k
    if (UNLIKELY(offset + len > s->total_decomp)) return ZXC_ERROR_SRC_TOO_SMALL;
709
1.72k
    if (UNLIKELY(s->expected_dict_id != 0 && (!s->dict || s->dict_size == 0)))
710
0
        return ZXC_ERROR_DICT_REQUIRED;
711
712
    // Find block range - O(1) division
713
1.72k
    const uint32_t blk_start = zxc_seek_find_block(s->block_size, offset);
714
1.72k
    const uint32_t blk_end = zxc_seek_find_block(s->block_size, offset + len - 1);
715
1.72k
    const uint32_t num_jobs = blk_end - blk_start + 1;
716
717
    // Auto-detect thread count (0 = use all available cores)
718
1.72k
    if (n_threads == 0) n_threads = zxc_num_procs();
719
720
    // Fallback to single-threaded path for trivial cases
721
1.72k
    if (n_threads <= 1 || num_jobs <= 1) {
722
1.12k
        return zxc_seekable_decompress_range(s, dst, dst_capacity, offset, len);
723
1.12k
    }
724
725
    // Cap threads to number of blocks and max limit
726
601
    if ((uint32_t)n_threads > num_jobs) n_threads = (int)num_jobs;
727
601
    if (n_threads > ZXC_MAX_THREADS) n_threads = ZXC_MAX_THREADS;
728
729
    // Allocate job descriptors
730
601
    zxc_seek_mt_job_t* const jobs =
731
601
        (zxc_seek_mt_job_t*)ZXC_CALLOC(num_jobs, sizeof(zxc_seek_mt_job_t));
732
601
    if (UNLIKELY(!jobs)) return ZXC_ERROR_MEMORY;  // LCOV_EXCL_LINE
733
734
    // Plan jobs: compute skip, copy_len, and dst pointer for each block
735
601
    uint8_t* out = (uint8_t*)dst;
736
601
    size_t remaining = len;
737
1.80k
    for (uint32_t i = 0; i < num_jobs; i++) {
738
1.20k
        const uint32_t bi = blk_start + i;
739
1.20k
        const uint64_t blk_decomp_start = zxc_seek_decomp_offset(s->block_size, bi);
740
1.20k
        const size_t skip = (offset > blk_decomp_start) ? (size_t)(offset - blk_decomp_start) : 0;
741
1.20k
        const size_t blk_decomp_sz = zxc_seek_decomp_size(s->block_size, s->total_decomp, bi);
742
1.20k
        if (UNLIKELY(blk_decomp_sz < skip)) {
743
            // LCOV_EXCL_START
744
0
            ZXC_FREE(jobs);
745
0
            return ZXC_ERROR_CORRUPT_DATA;
746
            // LCOV_EXCL_STOP
747
0
        }
748
1.20k
        const size_t avail = blk_decomp_sz - skip;
749
1.20k
        const size_t copy = (avail < remaining) ? avail : remaining;
750
751
1.20k
        jobs[i].s = s;
752
1.20k
        jobs[i].block_idx = bi;
753
1.20k
        jobs[i].dst = out;
754
1.20k
        jobs[i].skip = skip;
755
1.20k
        jobs[i].copy_len = copy;
756
1.20k
        jobs[i].result = 0;
757
758
1.20k
        out += copy;
759
1.20k
        remaining -= copy;
760
1.20k
    }
761
762
    // Launch one persistent worker per thread
763
601
    pthread_t* const threads = (pthread_t*)ZXC_MALLOC((size_t)n_threads * sizeof(pthread_t));
764
601
    zxc_seek_mt_stripe_t* const stripes =
765
601
        (zxc_seek_mt_stripe_t*)ZXC_MALLOC((size_t)n_threads * sizeof(zxc_seek_mt_stripe_t));
766
601
    if (UNLIKELY(!threads || !stripes)) {
767
        // LCOV_EXCL_START
768
0
        ZXC_FREE(threads);
769
0
        ZXC_FREE(stripes);
770
0
        ZXC_FREE(jobs);
771
0
        return ZXC_ERROR_MEMORY;
772
        // LCOV_EXCL_STOP
773
0
    }
774
775
601
    int launched = 0;
776
1.80k
    for (int t = 0; t < n_threads; t++) {
777
1.20k
        stripes[t].jobs = jobs;
778
1.20k
        stripes[t].num_jobs = num_jobs;
779
1.20k
        stripes[t].first = (uint32_t)t;
780
1.20k
        stripes[t].stride = (uint32_t)n_threads;
781
1.20k
        if (UNLIKELY(pthread_create(&threads[t], NULL, zxc_seek_mt_worker, &stripes[t]) != 0)) {
782
            // LCOV_EXCL_START
783
            // Failed to create thread - mark its stripe as errored; already
784
            // launched workers keep running and are joined below.
785
0
            zxc_seek_mt_fail_stripe(&stripes[t], ZXC_ERROR_MEMORY);
786
0
            continue;
787
            // LCOV_EXCL_STOP
788
0
        }
789
790
1.20k
        launched++;
791
1.20k
        threads[launched - 1] = threads[t];
792
1.20k
    }
793
794
    // Join phase
795
1.80k
    for (int t = 0; t < launched; t++) pthread_join(threads[t], NULL);
796
797
601
    ZXC_FREE(threads);
798
601
    ZXC_FREE(stripes);
799
800
    // Report the first error in job order, if any.
801
601
    int64_t result = (int64_t)len;
802
1.80k
    for (uint32_t i = 0; i < num_jobs; i++) {
803
1.20k
        if (jobs[i].result < 0) {
804
0
            result = (int64_t)jobs[i].result;
805
0
            break;
806
0
        }
807
1.20k
    }
808
809
601
    ZXC_FREE(jobs);
810
601
    return result;
811
601
}
812
813
/**
814
 * @brief Releases a seekable handle and every resource it owns.
815
 *
816
 * Public API; see @c zxc_seekable.h. Tears down the reusable context, the seek
817
 * arrays (comp sizes / offsets), the owned dictionary copy and any attached
818
 * reader context. NULL-safe.
819
 */
820
9.45k
void zxc_seekable_free(zxc_seekable* s) {
821
9.45k
    if (UNLIKELY(!s)) return;
822
9.45k
    if (s->dctx_initialized) zxc_cctx_free(&s->dctx);
823
9.45k
    ZXC_FREE(s->dict);
824
9.45k
    ZXC_FREE(s->read_buf);
825
9.45k
    ZXC_FREE(s->comp_sizes);
826
9.45k
    ZXC_FREE(s->comp_offsets);
827
9.45k
    ZXC_FREE(s->owned_reader_ctx);
828
9.45k
    ZXC_FREE(s);
829
9.45k
}
830
831
/**
832
 * @brief Installs the dictionary needed to decode a dict-compressed archive.
833
 *
834
 * Public API; full contract in @c zxc_seekable.h. Validates the dict_id against
835
 * the file header, then takes an owned copy of @p dict (and the optional shared
836
 * literal Huffman table @p dict_huf). Drops any context already built so the
837
 * [dict | decode] bounce buffer is re-carved on the next decompress.
838
 */
839
int zxc_seekable_set_dict(zxc_seekable* s, const void* dict, const size_t dict_size,
840
0
                          const void* dict_huf) {
841
0
    if (UNLIKELY(!s || !dict || dict_size == 0)) return ZXC_ERROR_NULL_INPUT;
842
0
    if (UNLIKELY(dict_size > ZXC_DICT_SIZE_MAX)) return ZXC_ERROR_DICT_TOO_LARGE;
843
0
    if (UNLIKELY(s->expected_dict_id != 0 &&
844
0
                 zxc_dict_id(dict, dict_size, (const uint8_t*)dict_huf) != s->expected_dict_id))
845
0
        return ZXC_ERROR_DICT_MISMATCH;
846
847
0
    ZXC_FREE(s->dict);
848
0
    s->dict = NULL;
849
0
    s->dict_size = 0;
850
0
    s->has_dict_huf = 0;
851
852
0
    s->dict = (uint8_t*)ZXC_MALLOC(dict_size);
853
0
    if (UNLIKELY(!s->dict)) return ZXC_ERROR_MEMORY;
854
0
    ZXC_MEMCPY(s->dict, dict, dict_size);
855
0
    s->dict_size = dict_size;
856
0
    if (dict_huf) {
857
0
        ZXC_MEMCPY(s->dict_huf, dict_huf, ZXC_HUF_TABLE_SIZE);
858
0
        s->has_dict_huf = 1;
859
0
    }
860
861
    // The [dict | decode] bounce buffer is carved into the dctx workspace.
862
    // Drop any context built without it (or for a different dict size) so it is
863
    // re-carved with the new dict on the next decompress.
864
0
    if (s->dctx_initialized) {
865
0
        zxc_cctx_free(&s->dctx);
866
0
        s->dctx_initialized = 0;
867
0
    }
868
0
    return ZXC_OK;
869
0
}
870
871
/**
872
 * @brief Transfers ownership of a heap reader context to the handle.
873
 *
874
 * Cross-TU hook (declared in @c zxc_internal.h): @p ctx is released via
875
 * @c ZXC_FREE when @ref zxc_seekable_free runs. Used by
876
 * @ref zxc_seekable_open_file so its allocated reader state outlives the open
877
 * call. NULL-safe on @p s.
878
 */
879
0
void zxc_seekable_attach_owned_ctx(zxc_seekable* s, void* ctx) {
880
0
    if (s) s->owned_reader_ctx = ctx;
881
0
}