Seregon/zftpd

Zero-copy FTP/HTTP Daemon compatible with all POSIX systems

C/11.0 KB/No license
include/pkg_unpacker.h
zftpd / include / pkg_unpacker.h
1#ifndef PKG_UNPACKER_H
2#define PKG_UNPACKER_H
3/*
4 * GNU GPLv3 License — Copyright (c) 2026 SeregonWar
5 * See LICENSE for full text.
6 */
7
8/*
9 * pkg_unpacker — PS4 PKG archive metadata parser
10 *
11 * Provides read-only access to *unencrypted* metadata entries embedded in the
12 * PS4 PKG entry table (e.g., param.sfo, icon0.png, pic1.png). It does NOT
13 * decrypt NPDRM-protected entries or the PFS filesystem image.
14 *
15 * Modelled after the exfat_unpacker.h API for consistency.
16 *
17 * ── Thread-safety ──────────────────────────────────────────────────────────
18 * NONE. All functions require external synchronisation when a context is
19 * shared across threads.
20 *
21 * ── Re-entrancy ────────────────────────────────────────────────────────────
22 * Functions are NOT re-entrant.
23 *
24 * ── Platform ───────────────────────────────────────────────────────────────
25 * POSIX (_FILE_OFFSET_BITS=64) and Windows (_fseeki64 / _ftelli64).
26 *
27 * ── Format reference ───────────────────────────────────────────────────────
28 * PS4 PKG specification: https://www.psdevwiki.com/ps4/PKG_files
29 * shadPS4 implementation (pkg.h / pkg.cpp) cross-referenced for field offsets.
30 */
31 
32#include <stdint.h>
33#include <stddef.h>
34#include <stdio.h>
35#include <sys/types.h> /* ssize_t */
36 
37#if defined(_MSC_VER)
38# ifndef ssize_t
39 typedef SSIZE_T ssize_t;
40# endif
41#endif
42 
43#ifdef __cplusplus
44extern "C" {
45#endif
46 
47/* ═══════════════════════════════════════════════════════════════════════════
48 * Constants
49 * ═════════════════════════════════════════════════════════════════════════*/
50 
51/** ASCII ".CNT" (big-endian) — used in PS4 PKGs. */
52#define PKG_MAGIC_CNT 0x7F434E54U
53 
54/** ASCII ".PKG" (big-endian) — used in PS3/some mock PKGs. */
55#define PKG_MAGIC_PKG 0x7F504B47U
56 
57/** Bytes read from the file start to cover all used header fields. */
58#define PKG_HEADER_SIZE 0x1000U
59 
60/** On-disk byte length of the content_id field (no NUL in the file). */
61#define PKG_CONTENT_ID_LEN 36U
62 
63/**
64 * Hard cap on entry_count. A PKG with more than this many entries is
65 * treated as malformed. Keeps allocation bounded and avoids amplification
66 * attacks on untrusted files.
67 */
68#define PKG_MAX_ENTRY_COUNT 10000U
69 
70/**
71 * Hard cap on the size of a single entry that may be extracted into memory.
72 * Individual metadata entries (SFO, icons) are never this large in practice;
73 * this guards against corrupt or crafted size fields.
74 */
75#define PKG_MAX_ENTRY_SIZE (64U * 1024U * 1024U) /* 64 MiB */
76 
77/**
78 * The entry table must not overlap the header.
79 * Any table_offset below this value is treated as corrupt.
80 */
81#define PKG_MIN_TABLE_OFFSET PKG_HEADER_SIZE
82 
83/** Size in bytes of one serialised entry record in the file. */
84#define PKG_ENTRY_RECORD_SIZE 32U
85 
86/* ── Common unencrypted entry IDs ────────────────────────────────────────── */
87#define PKG_ENTRY_ID_PARAM_SFO 0x1000U
88#define PKG_ENTRY_ID_ICON0_PNG 0x1200U
89#define PKG_ENTRY_ID_ICON1_PNG 0x1210U
90#define PKG_ENTRY_ID_PIC0_PNG 0x1220U
91#define PKG_ENTRY_ID_PIC1_PNG 0x1230U
92 
93/**
94 * Bit 31 of flags1.
95 * 0 = entry data is plaintext; safe to extract.
96 * 1 = entry data is encrypted (NPDRM / system key); extraction yields
97 * raw ciphertext, which is almost certainly not what the caller wants.
98 *
99 * Always test with pkg_entry_is_encrypted() before extracting.
100 */
101#define PKG_ENTRY_FLAG_ENCRYPTED 0x80000000U
102 
103/* ═══════════════════════════════════════════════════════════════════════════
104 * Error codes
105 * ═════════════════════════════════════════════════════════════════════════*/
106 
107/**
108 * Return type for all pkg_* functions that can fail.
109 * Values are negative so that a single `if (rc < 0)` test catches any error.
110 */
111typedef enum {
112 PKG_OK = 0, /**< Success. */
113 PKG_ERR_PARAM = -1, /**< NULL or invalid argument. */
114 PKG_ERR_IO = -2, /**< File I/O failure (check errno for detail). */
115 PKG_ERR_FORMAT = -3, /**< File does not conform to the PKG format. */
116 PKG_ERR_RANGE = -4, /**< Value would overflow or is out of range. */
117 PKG_ERR_NOMEM = -5, /**< malloc() returned NULL. */
118 PKG_ERR_ENCRYPTED = -6, /**< Entry is encrypted; cannot yield plaintext.*/
119 PKG_ERR_BUFFER_SMALL = -7 /**< Caller buffer is smaller than entry->size. */
120} pkg_error_t;
121 
122/* ═══════════════════════════════════════════════════════════════════════════
123 * Data structures
124 * ═════════════════════════════════════════════════════════════════════════*/
125 
126/**
127 * Decoded fields from the on-disk PKG header.
128 *
129 * @note content_id is always NUL-terminated even though the on-disk field
130 * has no NUL (PKG_CONTENT_ID_LEN bytes, no terminator).
131 */
132typedef struct {
133 uint32_t magic;
134 uint32_t type;
135 uint32_t file_count;
136 uint32_t entry_count;
137 uint32_t table_offset;
138 char content_id[PKG_CONTENT_ID_LEN + 1U]; /* +1 for '\0' */
139} pkg_header_t;
140 
141/**
142 * One entry decoded from the PKG entry table.
143 *
144 * DESIGN RATIONALE — why expose flags1 / flags2:
145 * Bit 31 of flags1 signals encryption (PKG_ENTRY_FLAG_ENCRYPTED).
146 * Callers must check this before extracting to avoid silently processing
147 * ciphertext. Hiding these fields would force callers to call
148 * pkg_extract_* and check for PKG_ERR_ENCRYPTED after the seek — wasteful
149 * and harder to reason about.
150 *
151 * @note Use pkg_entry_is_encrypted() rather than testing flags1 directly.
152 */
153typedef struct {
154 uint32_t id; /**< Entry type identifier (e.g. PKG_ENTRY_ID_*). */
155 uint32_t filename_offset; /**< Offset into the "entry_names" name table. */
156 uint32_t flags1; /**< Bit 31 set → PKG_ENTRY_FLAG_ENCRYPTED. */
157 uint32_t flags2; /**< Reserved / key index for encrypted entries. */
158 uint32_t offset; /**< Absolute byte offset of data in the PKG file.*/
159 uint32_t size; /**< Byte length of the entry data. */
160} pkg_entry_t;
161 
162/**
163 * Parser context.
164 *
165 * Treat as opaque: do not modify fields directly. All internal invariants
166 * are established by pkg_init() and held until pkg_cleanup().
167 */
168typedef struct {
169 FILE *file; /**< Open file handle (owned by this context). */
170 uint64_t file_size; /**< Total PKG file size in bytes. */
171 pkg_header_t header; /**< Decoded header fields. */
172 pkg_entry_t *entries; /**< Heap-allocated array [0, num_entries). */
173 size_t num_entries; /**< Element count of entries[]. */
174} pkg_context_t;
175 
176/* ═══════════════════════════════════════════════════════════════════════════
177 * Inline helpers
178 * ═════════════════════════════════════════════════════════════════════════*/
179 
180/**
181 * @brief Test whether an entry is flagged as encrypted.
182 *
183 * Always call this before pkg_extract_to_buffer() or pkg_extract_file_fd()
184 * to avoid obtaining ciphertext instead of plaintext.
185 *
186 * @param[in] entry Non-NULL pointer to an entry; behaviour undefined if NULL.
187 * @return Non-zero if the entry is encrypted, zero if it is plaintext.
188 *
189 * @pre entry != NULL
190 * @note WCET: O(1), no I/O.
191 */
192static inline int pkg_entry_is_encrypted(const pkg_entry_t *entry)
193{
194 return (entry->flags1 & PKG_ENTRY_FLAG_ENCRYPTED) != 0U;
195}
196 
197/* ═══════════════════════════════════════════════════════════════════════════
198 * API
199 * ═════════════════════════════════════════════════════════════════════════*/
200 
201/**
202 * @brief Open a PKG file and parse its header and entry table into ctx.
203 *
204 * On failure the context is left in a state that is safe to pass to
205 * pkg_cleanup() — the caller MUST still call pkg_cleanup() to release any
206 * partially-acquired resources (e.g., the file handle that was opened before
207 * the header was validated).
208 *
209 * Validation performed:
210 * - Magic bytes == PKG_MAGIC.
211 * - entry_count in [1, PKG_MAX_ENTRY_COUNT].
212 * - table_offset >= PKG_MIN_TABLE_OFFSET.
213 * - table_offset + entry_count * PKG_ENTRY_RECORD_SIZE <= file_size (no OOB).
214 * - Each entry's [offset, offset+size) is contained within the file.
215 *
216 * @param[out] ctx Caller-allocated context; must not be NULL.
217 * @param[in] pkg_path NUL-terminated path to the PKG file; must not be NULL
218 * or empty.
219 *
220 * @return PKG_OK on success.
221 * @retval PKG_ERR_PARAM ctx or pkg_path is NULL / pkg_path is empty.
222 * @retval PKG_ERR_IO fopen, fread, fseeko, or ftello failed.
223 * @retval PKG_ERR_FORMAT File is too small, magic mismatch, entry_count
224 * out of range, or an entry extends beyond EOF.
225 * @retval PKG_ERR_RANGE Internal arithmetic overflow (defensive).
226 * @retval PKG_ERR_NOMEM malloc() returned NULL.
227 *
228 * @pre ctx != NULL
229 * @pre pkg_path != NULL && pkg_path[0] != '\0'
230 * @post On PKG_OK: ctx->file != NULL, ctx->entries != NULL,
231 * ctx->num_entries == ctx->header.entry_count,
232 * ctx->header.content_id is NUL-terminated.
233 *
234 * @note Thread-safety: NOT thread-safe.
235 * @note WCET: Unbounded (disk I/O; up to PKG_MAX_ENTRY_COUNT entry reads).
236 * @warning Do not call from interrupt or hard-real-time context.
237 */
238int pkg_init(pkg_context_t *ctx, const char *pkg_path);
239 
240/**
241 * @brief Release all resources owned by ctx.
242 *
243 * Closes the file handle and frees the entries array. Safe to call on a
244 * partially-initialised context (e.g., after a failed pkg_init).
245 * Idempotent: safe to call more than once on the same ctx.
246 *
247 * @param[in,out] ctx Context to clean up. Ignored if NULL.
248 *
249 * @note Thread-safety: NOT thread-safe.
250 * @note WCET: O(1), no I/O.
251 */
252void pkg_cleanup(pkg_context_t *ctx);
253 
254/**
255 * @brief Find an entry in the table by its numeric ID.
256 *
257 * Performs a linear scan over the parsed entry array. For the typical
258 * entry counts found in real PKG files (<200) this is faster than a hash
259 * map due to cache locality.
260 *
261 * @param[in] ctx Initialised context.
262 * @param[in] id Entry ID to search for (e.g., PKG_ENTRY_ID_PARAM_SFO).
263 *
264 * @return Pointer into ctx->entries[] for the first matching entry, or NULL
265 * if not found or if ctx / ctx->entries is NULL.
266 *
267 * @warning The returned pointer is invalidated by pkg_cleanup().
268 * Do not free or retain it beyond the lifetime of ctx.
269 *
270 * @note Thread-safety: NOT thread-safe.
271 * @note WCET: O(num_entries).
272 */
273const pkg_entry_t *pkg_find_entry_by_id(const pkg_context_t *ctx, uint32_t id);
274 
275/**
276 * @brief Extract a plaintext entry's raw bytes into a caller-supplied buffer.
277 *
278 * Seeks to entry->offset and reads entry->size bytes into buf.
279 * The call is rejected if the entry is encrypted (flags1 bit 31 set).
280 *
281 * @param[in,out] ctx Initialised context (file position is modified).
282 * @param[in] entry Entry to extract; must not be NULL.
283 * @param[out] buf Destination buffer; must not be NULL.
284 * @param[in] buf_size Usable bytes in buf.
285 *
286 * @return Bytes written (>= 0) on success, or a negative pkg_error_t value.
287 * @retval PKG_ERR_PARAM A required argument is NULL or buf_size == 0.
288 * @retval PKG_ERR_ENCRYPTED Entry is encrypted; call refused.
289 * @retval PKG_ERR_RANGE entry->size > PKG_MAX_ENTRY_SIZE or > SSIZE_MAX.
290 * @retval PKG_ERR_BUFFER_SMALL buf_size < entry->size.
291 * @retval PKG_ERR_IO fseeko or fread failed, or short read occurred.
292 *
293 * @pre (entry->flags1 & PKG_ENTRY_FLAG_ENCRYPTED) == 0
294 * @pre buf_size >= entry->size
295 * @post On success, buf[0..entry->size) contains the raw entry data.
296 *
297 * @note Thread-safety: NOT thread-safe.
298 * @note WCET: Proportional to entry->size; dominated by disk I/O.
299 */
300ssize_t pkg_extract_to_buffer(pkg_context_t *ctx, const pkg_entry_t *entry,
301 uint8_t *buf, size_t buf_size);
302 
303/**
304 * @brief Stream a plaintext entry's raw bytes to an open file descriptor.
305 *
306 * Uses a fixed-size stack buffer (COPY_BUF_SIZE bytes) internally; never
307 * allocates from the heap. EINTR-interrupted write(2) calls are retried
308 * automatically.
309 *
310 * @param[in,out] ctx Initialised context (file position is modified).
311 * @param[in] entry Entry to extract; must not be NULL.
312 * @param[in] output_fd Open, writable, blocking file descriptor (>= 0).
313 *
314 * @return PKG_OK on success.
315 * @retval PKG_ERR_PARAM A required argument is NULL or output_fd < 0.
316 * @retval PKG_ERR_ENCRYPTED Entry is encrypted; call refused.
317 * @retval PKG_ERR_RANGE entry->size > PKG_MAX_ENTRY_SIZE.
318 * @retval PKG_ERR_IO fseeko, fread, or write(2) failed, or the read
319 * returned fewer bytes than entry->size (truncated).
320 *
321 * @pre (entry->flags1 & PKG_ENTRY_FLAG_ENCRYPTED) == 0
322 * @pre output_fd is open for writing in blocking mode.
323 *
324 * @note Thread-safety: NOT thread-safe.
325 * @note WCET: Proportional to entry->size; dominated by disk I/O.
326 */
327int pkg_extract_file_fd(pkg_context_t *ctx, const pkg_entry_t *entry,
328 int output_fd);
329 
330#ifdef __cplusplus
331}
332#endif
333 
334#endif /* PKG_UNPACKER_H */
335