Zero-copy FTP/HTTP Daemon compatible with all POSIX systems
| 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 |
| 44 | extern "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 | */ |
| 111 | typedef 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 | */ |
| 132 | typedef 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 | */ |
| 153 | typedef 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 | */ |
| 168 | typedef 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 | */ |
| 192 | static 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 | */ |
| 238 | int 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 | */ |
| 252 | void 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 | */ |
| 273 | const 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 | */ |
| 300 | ssize_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 | */ |
| 327 | int 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 |