Files
LisherSong--ps5-web-file-ma…/src/zip_extract.c
T
Songlx516 da565ccb7c feat(zip): extract multi-file ZIP volume sets
A split ZIP is several files that only form an archive together. Until now a
`.zip.001` was rejected as an unsupported format and a `.z01`/`.partN.zip`
member could not be resolved, so these downloads were unusable.

Three naming conventions are recognised, from any member of the set:

  name.zip.001, name.zip.002, ...   byte split (7-Zip)
  name.part1.zip, name.part2.zip    byte split (WinRAR)
  name.z01, ..., name.zip           zip split disks (Info-ZIP / PKZIP)

The parts are served through a new stream that makes them look like one
archive, so nothing is copied to disk first (a 160 GiB set would otherwise
need a second 160 GiB scratch copy):

- src/zipx_volstream.c implements an mz_stream over the ordered part list.
  Byte splits keep absolute offsets; zip split disks carry offsets relative to
  the disk an entry starts on, so that mode honours
  MZ_STREAM_PROP_DISK_NUMBER the way mz_zip_entry_seek_local_header drives it
  and starts on the last volume, where the central directory lives.
- src/zipx_volume.c groups the set, verifies the numbering is complete and
  reports exactly which volume is missing.
- zip_extract.c tries the layout implied by the naming first and the other one
  as a fallback, so tools that disagree about offset bases still work.
- extract.c dispatches volume members to the engines and, when the caller
  asked for the source to be deleted, removes every volume instead of leaving
  orphaned parts behind.

Error messages are specific on purpose: an incomplete set reports the missing
file name (e.g. "gap.zip.002 is missing"), a lone first volume says the
remaining volumes are missing, `.rar.001` sets explain the rename that makes
them work, and 7z volumes say 7z is not supported yet.

Tests: tests/make_split_fixtures.py builds all three layouts (the disk set is
written with per-disk offsets exactly as APPNOTE 4.4.11 describes) plus two
broken sets; test_zip_extract.c extracts each layout from a different member
and byte-compares the nested binary entries. 108 ZIP + 27 RAR checks, 0
failures.
2026-09-12 16:13:20 +08:00

1481 lines
41 KiB
C

/* Safe ZIP extraction engine used by the /api/extract task.
POSIX only; see tests/ for the host harness that exercises it. */
#include "zip_extract.h"
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <sys/types.h>
#include <time.h>
#include <unistd.h>
#include "mz.h"
#include "mz_zip.h"
#include "mz_strm.h"
#include "mz_strm_os.h"
#include "zipx_volume.h"
#include "zipx_volstream.h"
#ifndef O_CLOEXEC
#define O_CLOEXEC 0
#endif
#define ZIPX_IO_BUFFER (128 * 1024)
#define ZIPX_TMP_ATTEMPTS 64
#define ZIPX_STAGING_PREFIX ".wfm-extract-"
#define ZIPX_PART_PREFIX ".wfm-part-"
#define ZIPX_PUBLISH_MAX_DEPTH 128
#define ZIPX_SPACE_SLACK_PER_ENTRY 512
/* Default limits.
*
* Tuned to cover real-world PS5 workloads without prompting:
* - PS5 system backup ZIPs (~200-300 GiB total, individual chunks <64 GiB)
* - 3A-game archives with a single ~300 GiB uncompressed file
*
* Safety against zip bombs is delegated to:
* 1. `check_space()` (statvfs-based real disk space check) before extract
* 2. `max_ratio` below (declared compression ratio cap)
* The size caps here are an early-fail UX guard, not a security boundary.
*/
static const zipx_limits_t k_default_limits = {
.max_entries = 200000,
.max_total_bytes = 2ULL * 1024 * 1024 * 1024 * 1024,
.max_file_bytes = 512ULL * 1024 * 1024 * 1024,
.max_ratio = 500,
/* Only entries that would individually materialise >=1 GiB are screened
by ratio; anything smaller is harmless (bounded by declared size + the
real free-space check) and is commonly highly compressible in
legitimate archives. */
.ratio_min_bytes = 1ULL * 1024 * 1024 * 1024,
.max_depth = 32,
.max_name_len = 255,
.max_path_len = 1024
};
/* Large profile for archives that exceed the default cap.
*
* - max_file_bytes = 1 TiB (single uncompressed file)
* - max_total_bytes = 4 TiB (whole archive)
* - max_ratio = 1000 (relaxed ratio cap; check_space still applies)
*
* Requires the user to opt in via the web UI (large=1) before these take
* effect. Default limits must always be strictly smaller than large so the
* large profile is unambiguously a relaxation.
*/
static const zipx_limits_t k_large_limits = {
.max_entries = 500000,
.max_total_bytes = 4ULL * 1024 * 1024 * 1024 * 1024,
.max_file_bytes = 1ULL * 1024 * 1024 * 1024 * 1024,
.max_ratio = 1000,
.ratio_min_bytes = 1ULL * 1024 * 1024 * 1024,
.max_depth = 32,
.max_name_len = 255,
.max_path_len = 1024
};
const zipx_limits_t *
zipx_default_limits(void) {
return &k_default_limits;
}
const zipx_limits_t *
zipx_limits_profile(int profile) {
switch(profile) {
case ZIPX_LIMITS_LARGE:
return &k_large_limits;
case ZIPX_LIMITS_DEFAULT:
default:
return &k_default_limits;
}
}
const char *
zipx_status_string(zipx_status_t status) {
switch(status) {
case ZIPX_OK: return "ok";
case ZIPX_ERR_CANCELED: return "canceled";
case ZIPX_ERR_OPEN: return "cannot open archive";
case ZIPX_ERR_FORMAT: return "corrupt archive";
case ZIPX_ERR_UNSUPPORTED: return "unsupported archive";
case ZIPX_ERR_UNSAFE_NAME: return "unsafe entry name";
case ZIPX_ERR_SPECIAL: return "unsupported entry type";
case ZIPX_ERR_DUPLICATE: return "duplicate entry name";
case ZIPX_ERR_LIMIT_ENTRIES: return "too many entries";
case ZIPX_ERR_LIMIT_FILE: return "entry too large";
case ZIPX_ERR_LIMIT_TOTAL: return "archive contents too large";
case ZIPX_ERR_LIMIT_RATIO: return "compression ratio too high";
case ZIPX_ERR_LIMIT_DEPTH: return "path too deep";
case ZIPX_ERR_LIMIT_NAME: return "path too long";
case ZIPX_ERR_CONFLICT: return "target already exists";
case ZIPX_ERR_SPACE: return "not enough space";
case ZIPX_ERR_IO: return "read or write failed";
case ZIPX_ERR_CRC: return "crc mismatch";
default: return "internal error";
}
}
/**************************************************************************
* small helpers
**************************************************************************/
typedef struct {
zipx_conflict_t conflict;
zipx_limits_t limits;
zipx_cancel_fn cancel;
zipx_progress_fn progress;
void *userdata;
zipx_result_t *result;
uint64_t entries_total;
uint64_t entries_done;
uint64_t bytes_total;
uint64_t bytes_done;
uint64_t files_created;
uint64_t dirs_created;
uint64_t progress_floor;
struct timespec last_report;
char staging[ZIPX_PATH_MAX];
char **created; /* published destination paths, oldest first */
size_t created_count;
size_t created_cap;
unsigned char *created_is_dir;
int staging_created;
unsigned int part_counter;
} zipx_ctx_t;
static void
ctx_set_detail(zipx_ctx_t *c, const char *path) {
if(path) {
snprintf(c->result->detail, sizeof(c->result->detail), "%s", path);
}
}
static int
ctx_fail(zipx_ctx_t *c, zipx_status_t status, const char *detail,
const char *fmt, ...) {
va_list ap;
c->result->status = status;
c->result->sys_errno = errno;
ctx_set_detail(c, detail);
va_start(ap, fmt);
if(fmt) {
vsnprintf(c->result->message, sizeof(c->result->message), fmt, ap);
}
va_end(ap);
if(!c->result->message[0]) {
snprintf(c->result->message, sizeof(c->result->message), "%s",
zipx_status_string(status));
}
/* Every error code is non-zero, so callers can test the return value. */
return (int)status;
}
static long long
mono_ms(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
static void
report(zipx_ctx_t *c, int phase, const char *current, int force) {
zipx_progress_t p;
long long now;
if(!c->progress) {
return;
}
now = mono_ms();
if(!force && c->last_report.tv_sec &&
now - ((long long)c->last_report.tv_sec * 1000 +
c->last_report.tv_nsec / 1000000) < 200 &&
c->bytes_done - c->progress_floor < (1024 * 1024)) {
return;
}
clock_gettime(CLOCK_MONOTONIC, &c->last_report);
c->progress_floor = c->bytes_done;
p.phase = phase;
p.entries_total = c->entries_total;
p.entries_done = c->entries_done;
p.bytes_total = c->bytes_total;
p.bytes_done = c->bytes_done;
p.current = current;
c->progress(c->userdata, &p);
}
static int
canceled(zipx_ctx_t *c) {
return c->cancel && c->cancel(c->userdata);
}
static int
remember_published(zipx_ctx_t *c, const char *path, int is_dir) {
if(c->created_count == c->created_cap) {
size_t cap = c->created_cap ? c->created_cap * 2 : 64;
char **paths = realloc(c->created, cap * sizeof(*paths));
unsigned char *flags = realloc(c->created_is_dir, cap * sizeof(*flags));
if(!paths || !flags) {
free(paths);
free(flags);
return -1;
}
c->created = paths;
c->created_is_dir = flags;
c->created_cap = cap;
}
if(!(c->created[c->created_count] = strdup(path))) {
return -1;
}
c->created_is_dir[c->created_count] = is_dir ? 1 : 0;
c->created_count++;
return 0;
}
static void
free_published(zipx_ctx_t *c) {
size_t i;
for(i = 0; i < c->created_count; i++) {
free(c->created[i]);
}
free(c->created);
free(c->created_is_dir);
c->created = NULL;
c->created_is_dir = NULL;
c->created_count = c->created_cap = 0;
}
/**************************************************************************
* duplicate / file-vs-directory clash detection
**************************************************************************/
typedef struct {
uint64_t *hash;
unsigned char *is_dir;
size_t cap; /* power of two */
size_t count;
} zipx_nameset_t;
static uint64_t
fnv1a(const char *s, size_t len) {
uint64_t h = 1469598103934665603ULL;
size_t i;
for(i = 0; i < len; i++) {
h ^= (unsigned char)s[i];
h *= 1099511628211ULL;
}
return h ? h : 1; /* 0 is the empty slot marker */
}
static int
nameset_init(zipx_nameset_t *set, size_t hint) {
size_t cap = 1024;
while(cap < hint * 2) {
cap *= 2;
}
set->hash = calloc(cap, sizeof(*set->hash));
set->is_dir = calloc(cap, sizeof(*set->is_dir));
if(!set->hash || !set->is_dir) {
free(set->hash);
free(set->is_dir);
set->hash = NULL;
set->is_dir = NULL;
return -1;
}
set->cap = cap;
set->count = 0;
return 0;
}
static void
nameset_free(zipx_nameset_t *set) {
free(set->hash);
free(set->is_dir);
set->hash = NULL;
set->is_dir = NULL;
set->cap = set->count = 0;
}
static size_t
nameset_slot(zipx_nameset_t *set, uint64_t h) {
size_t mask = set->cap - 1;
size_t i = (size_t)(h & mask);
while(set->hash[i] && set->hash[i] != h) {
i = (i + 1) & mask;
}
return i;
}
static int
nameset_grow(zipx_nameset_t *set) {
zipx_nameset_t bigger;
size_t i;
if(nameset_init(&bigger, set->cap) ) {
return -1;
}
for(i = 0; i < set->cap; i++) {
if(!set->hash[i]) {
continue;
}
bigger.hash[nameset_slot(&bigger, set->hash[i])] = set->hash[i];
bigger.is_dir[nameset_slot(&bigger, set->hash[i])] = set->is_dir[i];
bigger.count++;
}
nameset_free(set);
*set = bigger;
return 0;
}
/* 0 = not present, 1 = present as directory, 2 = present as file */
static int
nameset_get(zipx_nameset_t *set, uint64_t h) {
size_t i = nameset_slot(set, h);
if(!set->hash[i]) {
return 0;
}
return set->is_dir[i] ? 1 : 2;
}
static int
nameset_put(zipx_nameset_t *set, uint64_t h, int is_dir) {
size_t i;
if(set->count * 4 >= set->cap * 3 && nameset_grow(set)) {
return -1;
}
i = nameset_slot(set, h);
if(!set->hash[i]) {
set->hash[i] = h;
set->count++;
}
set->is_dir[i] = is_dir ? 1 : 0;
return 0;
}
/**************************************************************************
* entry name validation
**************************************************************************/
/* Normalizes a ZIP entry name into out using '/' separators.
Returns 0 on success and sets *is_dir / *depth. */
static int
normalize_name(const char *in, char *out, size_t out_size, int *is_dir,
uint32_t *depth, zipx_ctx_t *c) {
size_t in_len = strlen(in);
size_t out_len = 0;
size_t i = 0;
size_t seg_len = 0;
uint32_t levels = 0;
*is_dir = 0;
*depth = 0;
if(!in_len) {
return ctx_fail(c, ZIPX_ERR_UNSAFE_NAME, in, "empty entry name");
}
if(in_len > c->limits.max_path_len) {
return ctx_fail(c, ZIPX_ERR_LIMIT_NAME, in, "entry name is too long");
}
/* Absolute paths, drive letters and UNC prefixes are never accepted. */
if(in[0] == '/' || in[0] == '\\') {
return ctx_fail(c, ZIPX_ERR_UNSAFE_NAME, in, "absolute entry name");
}
if(in_len >= 2 && ((in[0] >= 'A' && in[0] <= 'Z') ||
(in[0] >= 'a' && in[0] <= 'z')) && in[1] == ':') {
return ctx_fail(c, ZIPX_ERR_UNSAFE_NAME, in, "drive letter in entry name");
}
while(i < in_len) {
char ch = in[i];
if((unsigned char)ch < 0x20 || (unsigned char)ch == 0x7f) {
return ctx_fail(c, ZIPX_ERR_UNSAFE_NAME, in,
"control character in entry name");
}
if(ch == '/' || ch == '\\') {
if(!seg_len) {
return ctx_fail(c, ZIPX_ERR_UNSAFE_NAME, in, "empty path segment");
}
if(seg_len == 1 && out[out_len - 1] == '.') {
return ctx_fail(c, ZIPX_ERR_UNSAFE_NAME, in, "'.' path segment");
}
if(seg_len == 2 && !memcmp(out + out_len - 2, "..", 2)) {
return ctx_fail(c, ZIPX_ERR_UNSAFE_NAME, in, "'..' path segment");
}
if(seg_len > c->limits.max_name_len) {
return ctx_fail(c, ZIPX_ERR_LIMIT_NAME, in, "path component is too long");
}
out[out_len++] = '/';
levels++;
seg_len = 0;
i++;
continue;
}
if(out_len + 2 >= out_size) {
return ctx_fail(c, ZIPX_ERR_LIMIT_NAME, in, "entry name is too long");
}
out[out_len++] = ch;
seg_len++;
i++;
}
if(seg_len) {
if(seg_len == 1 && out[out_len - 1] == '.') {
return ctx_fail(c, ZIPX_ERR_UNSAFE_NAME, in, "'.' path segment");
}
if(seg_len == 2 && !memcmp(out + out_len - 2, "..", 2)) {
return ctx_fail(c, ZIPX_ERR_UNSAFE_NAME, in, "'..' path segment");
}
if(seg_len > c->limits.max_name_len) {
return ctx_fail(c, ZIPX_ERR_LIMIT_NAME, in, "path component is too long");
}
levels++;
} else {
*is_dir = 1; /* trailing separator */
}
out[out_len] = 0;
if(!levels) {
return ctx_fail(c, ZIPX_ERR_UNSAFE_NAME, in, "empty entry name");
}
if(levels > c->limits.max_depth) {
return ctx_fail(c, ZIPX_ERR_LIMIT_DEPTH, in, "entry path is too deep");
}
*depth = levels;
return 0;
}
/* Registers every ancestor of name as a directory and rejects clashes. */
static int
nameset_add_path(zipx_nameset_t *set, const char *name, int is_dir,
zipx_ctx_t *c) {
char buf[ZIPX_PATH_MAX];
size_t len = strlen(name);
size_t i;
if(len >= sizeof(buf)) {
return ctx_fail(c, ZIPX_ERR_LIMIT_NAME, name, "entry name is too long");
}
memcpy(buf, name, len + 1);
for(i = 0; i < len; i++) {
if(buf[i] != '/') {
continue;
}
buf[i] = 0;
if(nameset_get(set, fnv1a(buf, i)) == 2) {
return ctx_fail(c, ZIPX_ERR_DUPLICATE, name,
"entry uses a file as a directory");
}
if(nameset_put(set, fnv1a(buf, i), 1)) {
return ctx_fail(c, ZIPX_ERR_INTERNAL, name, "out of memory");
}
buf[i] = '/';
}
if(nameset_put(set, fnv1a(name, len), is_dir)) {
return ctx_fail(c, ZIPX_ERR_INTERNAL, name, "out of memory");
}
return 0;
}
/* Duplicate detection: a repeated file is rejected, a repeated directory is
harmless. A name used both as a file and as a directory is rejected. */
static int
nameset_check_duplicate(zipx_nameset_t *set, const char *name, int is_dir,
zipx_ctx_t *c) {
int existing = nameset_get(set, fnv1a(name, strlen(name)));
if(existing == 2 || (existing == 1 && !is_dir)) {
return ctx_fail(c, ZIPX_ERR_DUPLICATE, name, "duplicate entry name");
}
return 0;
}
/**************************************************************************
* staging helpers
**************************************************************************/
static int
path_parent(const char *path, char *out, size_t out_size) {
size_t len = strlen(path);
size_t i;
if(!len) {
return -1;
}
i = len;
while(i && path[i - 1] == '/') {
i--;
}
while(i && path[i - 1] != '/') {
i--;
}
while(i > 1 && path[i - 1] == '/') {
i--;
}
if(!i) {
return -1; /* relative path without a directory part */
}
if(i >= out_size) {
return -1;
}
memcpy(out, path, i);
out[i] = 0;
return 0;
}
static void
chmod_0777_fd(int fd) {
int err = errno;
/* FAT/exFAT style filesystems reject fchmod; that is not a failure. */
if(fchmod(fd, 0777)) {
errno = err;
}
}
static int
make_staging(zipx_ctx_t *c, const char *parent) {
unsigned int attempt;
for(attempt = 0; attempt < ZIPX_TMP_ATTEMPTS; attempt++) {
int n = snprintf(c->staging, sizeof(c->staging), "%s/%s%ld-%lld-%u.tmp",
parent, ZIPX_STAGING_PREFIX, (long)getpid(),
(long long)time(NULL), attempt);
if(n < 0 || (size_t)n >= sizeof(c->staging)) {
return ctx_fail(c, ZIPX_ERR_INTERNAL, NULL, "staging path is too long");
}
if(!mkdir(c->staging, 0777)) {
c->staging_created = 1;
return 0;
}
if(errno != EEXIST) {
return ctx_fail(c, ZIPX_ERR_IO, c->staging, "cannot create staging: %s",
strerror(errno));
}
}
return ctx_fail(c, ZIPX_ERR_IO, parent, "cannot create staging directory");
}
static void
remove_tree(const char *path) {
DIR *dir = opendir(path);
struct dirent *ent;
if(!dir) {
rmdir(path);
return;
}
while((ent = readdir(dir))) {
char child[ZIPX_PATH_MAX];
struct stat st;
if(!strcmp(ent->d_name, ".") || !strcmp(ent->d_name, "..")) {
continue;
}
if(snprintf(child, sizeof(child), "%s/%s", path, ent->d_name) >=
(int)sizeof(child)) {
continue;
}
if(!lstat(child, &st) && S_ISDIR(st.st_mode)) {
remove_tree(child);
} else {
unlink(child);
}
}
closedir(dir);
rmdir(path);
}
static void
cleanup_staging(zipx_ctx_t *c) {
if(c->staging_created) {
remove_tree(c->staging);
c->staging_created = 0;
}
}
/* Undo the objects this task already published, newest first. */
static void
rollback_published(zipx_ctx_t *c) {
size_t i = c->created_count;
while(i) {
i--;
if(c->created_is_dir[i]) {
rmdir(c->created[i]);
} else {
unlink(c->created[i]);
}
}
}
/**************************************************************************
* scan phase
**************************************************************************/
static int
check_space(zipx_ctx_t *c, const char *target) {
struct statvfs vfs;
unsigned long long available;
unsigned long long block;
unsigned long long required = c->bytes_total +
c->entries_total * ZIPX_SPACE_SLACK_PER_ENTRY;
if(statvfs(target, &vfs)) {
return ctx_fail(c, ZIPX_ERR_SPACE, target, "cannot read free space: %s",
strerror(errno));
}
block = vfs.f_frsize ? vfs.f_frsize : vfs.f_bsize;
available = (unsigned long long)vfs.f_bavail * block;
if(available < required) {
return ctx_fail(c, ZIPX_ERR_SPACE, target,
"not enough space, required %llu bytes, available %llu bytes",
required, available);
}
return 0;
}
static int
scan_archive(void *zip, zipx_ctx_t *c) {
zipx_nameset_t set;
int32_t err;
int ret = 0;
if(nameset_init(&set, 4096)) {
return ctx_fail(c, ZIPX_ERR_INTERNAL, NULL, "out of memory");
}
err = mz_zip_goto_first_entry(zip);
while(err == MZ_OK) {
mz_zip_file *info = NULL;
char name[ZIPX_PATH_MAX];
int is_dir = 0;
uint32_t depth = 0;
uint64_t uncompressed;
if(mz_zip_entry_get_info(zip, &info) || !info || !info->filename) {
ret = ctx_fail(c, ZIPX_ERR_FORMAT, NULL, "cannot read entry header");
break;
}
if(strlen(info->filename) != info->filename_size) {
ret = ctx_fail(c, ZIPX_ERR_UNSAFE_NAME, NULL,
"entry name contains a NUL byte");
break;
}
ret = normalize_name(info->filename, name, sizeof(name), &is_dir, &depth,
c);
(void)depth;
if(ret) {
break;
}
if((info->flag & MZ_ZIP_FLAG_ENCRYPTED) || info->aes_version) {
ret = ctx_fail(c, ZIPX_ERR_UNSUPPORTED, name, "encrypted entry");
break;
}
if(info->compression_method != MZ_COMPRESS_METHOD_STORE &&
info->compression_method != MZ_COMPRESS_METHOD_DEFLATE) {
ret = ctx_fail(c, ZIPX_ERR_UNSUPPORTED, name,
"unsupported compression method %d",
(int)info->compression_method);
break;
}
if(mz_zip_attrib_is_symlink(info->external_fa,
info->version_madeby) == MZ_OK) {
ret = ctx_fail(c, ZIPX_ERR_SPECIAL, name, "symbolic link entry");
break;
}
is_dir = is_dir || (mz_zip_attrib_is_dir(info->external_fa,
info->version_madeby) == MZ_OK);
if(!is_dir && MZ_HOST_SYSTEM(info->version_madeby) == MZ_HOST_SYSTEM_UNIX) {
mode_t mode = (mode_t)(info->external_fa >> 16);
if(mode && (mode & S_IFMT) != S_IFREG) {
ret = ctx_fail(c, ZIPX_ERR_SPECIAL, name, "special file entry");
break;
}
}
if(nameset_check_duplicate(&set, name, is_dir, c) ||
nameset_add_path(&set, name, is_dir, c)) {
ret = -1;
break;
}
uncompressed = info->uncompressed_size > 0 ?
(uint64_t)info->uncompressed_size : 0;
if(!is_dir) {
if(uncompressed > c->limits.max_file_bytes) {
ret = ctx_fail(c, ZIPX_ERR_LIMIT_FILE, name,
"entry is larger than %llu bytes",
(unsigned long long)c->limits.max_file_bytes);
break;
}
if(c->limits.max_ratio && info->compressed_size > 0 &&
uncompressed >= c->limits.ratio_min_bytes &&
uncompressed > (uint64_t)info->compressed_size *
c->limits.max_ratio) {
ret = ctx_fail(c, ZIPX_ERR_LIMIT_RATIO, name,
"compression ratio is above %u (%llu -> %llu bytes)",
c->limits.max_ratio,
(unsigned long long)info->compressed_size,
(unsigned long long)uncompressed);
break;
}
if(uncompressed >= c->limits.max_total_bytes ||
c->bytes_total > c->limits.max_total_bytes - uncompressed) {
ret = ctx_fail(c, ZIPX_ERR_LIMIT_TOTAL, name,
"archive contents are larger than %llu bytes",
(unsigned long long)c->limits.max_total_bytes);
break;
}
c->bytes_total += uncompressed;
}
c->entries_total++;
if(c->entries_total > c->limits.max_entries) {
ret = ctx_fail(c, ZIPX_ERR_LIMIT_ENTRIES, name,
"archive has more than %llu entries",
(unsigned long long)c->limits.max_entries);
break;
}
if(c->entries_total % 4096 == 0) {
if(canceled(c)) {
ret = ctx_fail(c, ZIPX_ERR_CANCELED, name, NULL);
break;
}
report(c, ZIPX_PHASE_SCAN, name, 0);
}
err = mz_zip_goto_next_entry(zip);
}
if(!ret && err != MZ_END_OF_LIST && err != MZ_OK) {
ret = ctx_fail(c, ZIPX_ERR_FORMAT, NULL, "corrupt central directory");
}
nameset_free(&set);
return ret;
}
/**************************************************************************
* extract phase
**************************************************************************/
/* Builds "<staging>/<rel>[/<leaf>]" for the plain-call fallbacks below.
rel may be empty (staging root); leaf may be NULL. */
static void
staging_path(const zipx_ctx_t *c, const char *rel, const char *leaf,
char *out, size_t cap) {
if(rel[0] && leaf) {
snprintf(out, cap, "%s/%s/%s", c->staging, rel, leaf);
} else if(rel[0]) {
snprintf(out, cap, "%s/%s", c->staging, rel);
} else if(leaf) {
snprintf(out, cap, "%s/%s", c->staging, leaf);
} else {
snprintf(out, cap, "%s", c->staging);
}
}
/* The *at() family can be present in the target libc yet fail at runtime
without setting errno (observed on PS5 hardware: mkdirat() returns -1
with errno 0, while plain path-based calls work). Every *at() call in
the extract phase therefore falls back to a full-path call built from
the staging root before reporting an I/O error. */
/* Opens (creating when needed) every directory of rel below root_fd.
Returns an open descriptor for the deepest directory. */
static int
open_parent_dirs(int root_fd, const char *rel, zipx_ctx_t *c) {
char buf[ZIPX_PATH_MAX];
char cur[ZIPX_PATH_MAX] = "";
int fd = root_fd;
char *seg;
char *save = NULL;
if(strlen(rel) >= sizeof(buf)) {
ctx_fail(c, ZIPX_ERR_LIMIT_NAME, rel, "path is too long");
return -1;
}
strcpy(buf, rel);
for(seg = strtok_r(buf, "/", &save); seg; seg = strtok_r(NULL, "/", &save)) {
int next;
int made = 0;
if(cur[0]) {
strncat(cur, "/", sizeof(cur) - strlen(cur) - 1);
}
strncat(cur, seg, sizeof(cur) - strlen(cur) - 1);
if(!mkdirat(fd, seg, 0777)) {
made = 1;
} else if(errno != EEXIST) {
char full[ZIPX_PATH_MAX];
staging_path(c, cur, NULL, full, sizeof(full));
if(mkdir(full, 0777) && errno != EEXIST) {
ctx_fail(c, ZIPX_ERR_IO, rel, "cannot create directory '%s': %s "
"(errno=%d)", seg, strerror(errno), errno);
if(fd != root_fd) {
close(fd);
}
return -1;
}
made = 1;
}
next = openat(fd, seg, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
if(next < 0) {
char full[ZIPX_PATH_MAX];
staging_path(c, cur, NULL, full, sizeof(full));
next = open(full, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
if(next < 0) {
ctx_fail(c, ZIPX_ERR_IO, rel, "cannot open directory '%s': %s "
"(errno=%d)", seg, strerror(errno), errno);
if(fd != root_fd) {
close(fd);
}
return -1;
}
}
if(made) {
chmod_0777_fd(next);
c->dirs_created++;
}
if(fd != root_fd) {
close(fd);
}
fd = next;
}
return fd;
}
static int
write_entry(void *zip, zipx_ctx_t *c, int root_fd, const char *name,
uint64_t declared) {
char dir_part[ZIPX_PATH_MAX];
char base[ZIPX_PATH_MAX];
char tmp[ZIPX_PATH_MAX];
uint64_t remaining = declared ? declared : c->limits.max_file_bytes;
uint64_t written = 0;
char *buf = malloc(ZIPX_IO_BUFFER);
char *slash;
int dir_fd;
int fd = -1;
int ret = 0;
if(!buf) {
return ctx_fail(c, ZIPX_ERR_INTERNAL, name, "out of memory");
}
snprintf(dir_part, sizeof(dir_part), "%s", name);
slash = strrchr(dir_part, '/');
if(slash) {
snprintf(base, sizeof(base), "%s", slash + 1);
*slash = 0;
} else {
snprintf(base, sizeof(base), "%s", name);
dir_part[0] = 0;
}
if(!base[0]) {
free(buf);
return ctx_fail(c, ZIPX_ERR_UNSAFE_NAME, name, "empty file name");
}
dir_fd = open_parent_dirs(root_fd, dir_part, c);
if(dir_fd < 0) {
free(buf);
return -1;
}
snprintf(tmp, sizeof(tmp), "%s%u", ZIPX_PART_PREFIX, ++c->part_counter);
fd = openat(dir_fd, tmp, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600);
if(fd < 0) {
/* *at() fallback (see open_parent_dirs). */
char full[ZIPX_PATH_MAX];
staging_path(c, dir_part, tmp, full, sizeof(full));
fd = open(full, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600);
}
if(fd < 0) {
ctx_fail(c, ZIPX_ERR_IO, name, "cannot create file '%s': %s (errno=%d)",
tmp, strerror(errno), errno);
if(dir_fd != root_fd) {
close(dir_fd);
}
free(buf);
return -1;
}
if(mz_zip_entry_read_open(zip, 0, NULL) != MZ_OK) {
ctx_fail(c, ZIPX_ERR_FORMAT, name, "cannot read entry data");
ret = -1;
goto done;
}
while(ret == 0) {
int32_t n = mz_zip_entry_read(zip, buf, (int32_t)ZIPX_IO_BUFFER);
if(n == 0) {
break;
}
if(n < 0) {
ctx_fail(c, n == MZ_CRC_ERROR ? ZIPX_ERR_CRC : ZIPX_ERR_FORMAT, name,
"%s", n == MZ_CRC_ERROR ? "crc mismatch" : "corrupt entry data");
ret = -1;
break;
}
if((uint64_t)n > remaining || written + (uint64_t)n > remaining) {
ctx_fail(c, ZIPX_ERR_LIMIT_FILE, name,
"entry is larger than the declared size");
ret = -1;
break;
}
if(written + (uint64_t)n > c->limits.max_total_bytes - c->bytes_done) {
ctx_fail(c, ZIPX_ERR_LIMIT_TOTAL, name,
"archive contents are larger than %llu bytes",
(unsigned long long)c->limits.max_total_bytes);
ret = -1;
break;
}
while(n > 0) {
ssize_t w = write(fd, buf, (size_t)n);
if(w <= 0) {
ctx_fail(c, ZIPX_ERR_IO, name, "write failed: %s",
strerror(errno ? errno : EIO));
ret = -1;
break;
}
n -= (int32_t)w;
written += (uint64_t)w;
c->bytes_done += (uint64_t)w;
}
if(ret) {
break;
}
if(canceled(c)) {
ctx_fail(c, ZIPX_ERR_CANCELED, name, NULL);
ret = -1;
break;
}
report(c, ZIPX_PHASE_EXTRACT, name, 0);
}
{
uint32_t crc = 0;
int64_t comp = 0;
int64_t uncomp = 0;
int32_t close_err = mz_zip_entry_read_close(zip, &crc, &comp, &uncomp);
if(!ret && close_err != MZ_OK) {
ctx_fail(c, close_err == MZ_CRC_ERROR ? ZIPX_ERR_CRC : ZIPX_ERR_FORMAT,
name, "%s", close_err == MZ_CRC_ERROR ?
"crc mismatch" : "corrupt entry data");
ret = -1;
}
}
done:
if(!ret) {
if(fsync(fd) || close(fd)) {
ctx_fail(c, ZIPX_ERR_IO, name, "cannot flush file: %s", strerror(errno));
ret = -1;
}
fd = -1;
}
if(!ret && renameat(dir_fd, tmp, dir_fd, base)) {
/* *at() fallback (see open_parent_dirs). */
char src_full[ZIPX_PATH_MAX];
char dst_full[ZIPX_PATH_MAX];
staging_path(c, dir_part, tmp, src_full, sizeof(src_full));
staging_path(c, dir_part, base, dst_full, sizeof(dst_full));
if(rename(src_full, dst_full)) {
ctx_fail(c, ZIPX_ERR_IO, name, "cannot move file into place: %s "
"(errno=%d)", strerror(errno), errno);
ret = -1;
}
}
if(fd >= 0) {
close(fd);
}
if(ret) {
if(unlinkat(dir_fd, tmp, 0)) {
/* *at() fallback (see open_parent_dirs). */
char full[ZIPX_PATH_MAX];
staging_path(c, dir_part, tmp, full, sizeof(full));
unlink(full);
}
} else {
c->files_created++;
}
if(dir_fd != root_fd) {
close(dir_fd);
}
free(buf);
return ret;
}
static int
extract_archive(void *zip, zipx_ctx_t *c, int root_fd) {
int32_t err = mz_zip_goto_first_entry(zip);
int ret = 0;
uint64_t index = 0;
while(err == MZ_OK && !ret) {
mz_zip_file *info = NULL;
char name[ZIPX_PATH_MAX];
int is_dir = 0;
uint32_t depth = 0;
if(mz_zip_entry_get_info(zip, &info) || !info || !info->filename) {
return ctx_fail(c, ZIPX_ERR_FORMAT, NULL, "cannot read entry header");
}
if(strlen(info->filename) != info->filename_size ||
normalize_name(info->filename, name, sizeof(name), &is_dir, &depth, c)) {
return (int)c->result->status;
}
(void)depth;
is_dir = is_dir || (mz_zip_attrib_is_dir(info->external_fa,
info->version_madeby) == MZ_OK);
if(canceled(c)) {
ret = ctx_fail(c, ZIPX_ERR_CANCELED, name, NULL);
break;
}
if(is_dir) {
char copy[ZIPX_PATH_MAX];
int dir_fd;
snprintf(copy, sizeof(copy), "%s", name);
dir_fd = open_parent_dirs(root_fd, copy, c);
if(dir_fd < 0) {
ret = -1;
break;
}
if(dir_fd != root_fd) {
close(dir_fd);
}
} else {
uint64_t declared = info->uncompressed_size > 0 ?
(uint64_t)info->uncompressed_size : 0;
if(write_entry(zip, c, root_fd, name, declared)) {
ret = -1;
break;
}
}
c->entries_done = ++index;
report(c, ZIPX_PHASE_EXTRACT, name, 0);
err = mz_zip_goto_next_entry(zip);
}
if(!ret && err != MZ_END_OF_LIST) {
ret = ctx_fail(c, ZIPX_ERR_FORMAT, NULL, "corrupt central directory");
}
return ret;
}
/**************************************************************************
* publish phase
**************************************************************************/
static int publish_dir(const char *src, const char *dst, int depth,
zipx_ctx_t *c);
static int
publish_entry(const char *src, const char *dst, const char *name, int depth,
zipx_ctx_t *c) {
char src_child[ZIPX_PATH_MAX];
char dst_child[ZIPX_PATH_MAX];
struct stat st;
struct stat src_st;
int src_is_dir;
if(snprintf(src_child, sizeof(src_child), "%s/%s", src, name) >=
(int)sizeof(src_child) ||
snprintf(dst_child, sizeof(dst_child), "%s/%s", dst, name) >=
(int)sizeof(dst_child)) {
return ctx_fail(c, ZIPX_ERR_LIMIT_NAME, name, "path is too long");
}
if(lstat(src_child, &src_st)) {
return ctx_fail(c, ZIPX_ERR_IO, src_child, "cannot read staging: %s",
strerror(errno));
}
src_is_dir = S_ISDIR(src_st.st_mode) ? 1 : 0;
if(lstat(dst_child, &st)) {
if(errno != ENOENT) {
return ctx_fail(c, ZIPX_ERR_IO, dst_child, "cannot check target: %s",
strerror(errno));
}
if(rename(src_child, dst_child)) {
return ctx_fail(c, ZIPX_ERR_IO, dst_child, "cannot publish: %s",
strerror(errno));
}
return remember_published(c, dst_child, src_is_dir);
}
if(S_ISDIR(st.st_mode)) {
if(!src_is_dir) {
/* A file in the archive collides with a directory on disk. */
return ctx_fail(c, ZIPX_ERR_CONFLICT, dst_child,
"file collides with an existing directory");
}
if(c->conflict != ZIPX_CONFLICT_MERGE) {
return ctx_fail(c, ZIPX_ERR_CONFLICT, dst_child,
"directory already exists");
}
if(depth >= ZIPX_PUBLISH_MAX_DEPTH) {
return ctx_fail(c, ZIPX_ERR_LIMIT_DEPTH, dst_child, "path is too deep");
}
if(publish_dir(src_child, dst_child, depth + 1, c)) {
return -1;
}
rmdir(src_child);
return 0;
}
if(S_ISREG(st.st_mode)) {
if(!src_is_dir) {
if(c->conflict == ZIPX_CONFLICT_OVERWRITE) {
if(rename(src_child, dst_child)) {
return ctx_fail(c, ZIPX_ERR_IO, dst_child, "cannot replace: %s",
strerror(errno));
}
return remember_published(c, dst_child, 0);
}
if(c->conflict == ZIPX_CONFLICT_MERGE) {
/* Preserve the existing file; drop the staged copy. */
if(unlink(src_child)) {
return ctx_fail(c, ZIPX_ERR_IO, dst_child,
"cannot drop staged file: %s", strerror(errno));
}
return 0;
}
}
return ctx_fail(c, ZIPX_ERR_CONFLICT, dst_child, "target already exists");
}
return ctx_fail(c, ZIPX_ERR_CONFLICT, dst_child, "target already exists");
}
static int
publish_dir(const char *src, const char *dst, int depth, zipx_ctx_t *c) {
DIR *dir = opendir(src);
struct dirent *ent;
char **names = NULL;
size_t count = 0;
size_t cap = 0;
size_t i;
int ret = 0;
if(!dir) {
return ctx_fail(c, ZIPX_ERR_IO, src, "cannot read staging: %s",
strerror(errno));
}
while((ent = readdir(dir))) {
if(!strcmp(ent->d_name, ".") || !strcmp(ent->d_name, "..")) {
continue;
}
if(count == cap) {
size_t next = cap ? cap * 2 : 64;
char **grown = realloc(names, next * sizeof(*grown));
if(!grown) {
ret = ctx_fail(c, ZIPX_ERR_INTERNAL, src, "out of memory");
break;
}
names = grown;
cap = next;
}
if(!(names[count] = strdup(ent->d_name))) {
ret = ctx_fail(c, ZIPX_ERR_INTERNAL, src, "out of memory");
break;
}
count++;
}
closedir(dir);
for(i = 0; i < count && !ret; i++) {
report(c, ZIPX_PHASE_PUBLISH, names[i], 0);
ret = publish_entry(src, dst, names[i], depth, c);
}
for(i = 0; i < count; i++) {
free(names[i]);
}
free(names);
return ret;
}
static int
publish_staging(zipx_ctx_t *c, const char *dst_dir, int dst_existed) {
int ret;
report(c, ZIPX_PHASE_PUBLISH, dst_dir, 1);
if(!dst_existed) {
if(rename(c->staging, dst_dir)) {
return ctx_fail(c, ZIPX_ERR_IO, dst_dir, "cannot publish: %s",
strerror(errno));
}
c->staging_created = 0; /* the directory now belongs to the user */
return 0;
}
ret = publish_dir(c->staging, dst_dir, 0, c);
if(ret) {
rollback_published(c);
}
return ret;
}
/**************************************************************************
* public entry point
**************************************************************************/
/* Opens the archive for scanning. A split set is served by the volume stream;
because tools disagree on whether a split keeps absolute offsets or offsets
relative to each volume, both layouts are attempted before giving up. */
static int
open_archive(const char *zip_path, zipx_volume_t *vol, int vol_set,
zipx_ctx_t *c, void **zip_out, void **stream_out) {
int attempts = vol_set ? 2 : 1;
int attempt;
for(attempt = 0; attempt < attempts; attempt++) {
void *stream = NULL;
void *zip = NULL;
int rc;
if(vol_set) {
int mode = vol->mode;
if(attempt == 1) {
mode = (vol->mode == ZIPX_VOL_MODE_DISK) ? ZIPX_VOL_MODE_CONCAT
: ZIPX_VOL_MODE_DISK;
}
stream = zipx_volstream_create(mode);
if(stream && zipx_volstream_set_parts(
stream, (const char *const *)vol->paths,
vol->count) != MZ_OK) {
zipx_volstream_delete(&stream);
stream = NULL;
}
} else {
stream = mz_stream_os_create();
}
zip = mz_zip_create();
if(!stream || !zip) {
if(stream) {
mz_stream_delete(&stream);
}
if(zip) {
mz_zip_delete(&zip);
}
ctx_fail(c, ZIPX_ERR_INTERNAL, zip_path, "out of memory");
return (int)c->result->status;
}
rc = mz_stream_open(stream, vol_set ? vol->paths[0] : zip_path,
MZ_OPEN_MODE_READ);
if(rc == MZ_OK) {
rc = mz_zip_open(zip, stream, MZ_OPEN_MODE_READ);
}
if(rc == MZ_OK) {
*zip_out = zip;
*stream_out = stream;
return ZIPX_OK;
}
/* Wrong layout for this set (or a corrupt archive): drop it and retry. */
mz_zip_close(zip);
mz_zip_delete(&zip);
mz_stream_close(stream);
mz_stream_delete(&stream);
}
if(vol_set) {
ctx_fail(c, ZIPX_ERR_OPEN, zip_path,
"cannot read the split archive starting at '%s' (%d volumes): %s",
vol->paths[0], vol->count,
errno ? strerror(errno) : "no known volume layout matched");
} else {
ctx_fail(c, ZIPX_ERR_OPEN, zip_path, "%s", strerror(errno ? errno : EIO));
}
return (int)c->result->status;
}
zipx_status_t
zipx_extract(const char *zip_path, const char *dst_dir,
zipx_conflict_t conflict, const zipx_limits_t *limits,
zipx_cancel_fn cancel, zipx_progress_fn progress,
void *userdata, zipx_result_t *result) {
zipx_ctx_t ctx;
zipx_ctx_t *c = &ctx;
char parent[ZIPX_PATH_MAX];
char dst_copy[ZIPX_PATH_MAX];
struct stat st;
void *zip = NULL;
void *stream = NULL;
int root_fd = -1;
int dst_existed = 0;
int status = ZIPX_OK;
zipx_volume_t vol;
int vol_set = 0;
if(!result || !zip_path || !dst_dir || !dst_dir[0]) {
if(result) {
memset(result, 0, sizeof(*result));
result->status = ZIPX_ERR_INTERNAL;
snprintf(result->message, sizeof(result->message), "invalid argument");
}
return ZIPX_ERR_INTERNAL;
}
memset(&ctx, 0, sizeof(ctx));
memset(&vol, 0, sizeof(vol));
vol.index = -1;
memset(result, 0, sizeof(*result));
c->result = result;
c->conflict = conflict;
c->limits = limits ? *limits : k_default_limits;
c->cancel = cancel;
c->progress = progress;
c->userdata = userdata;
snprintf(dst_copy, sizeof(dst_copy), "%s", dst_dir);
{
size_t len = strlen(dst_copy);
while(len > 1 && dst_copy[len - 1] == '/') {
dst_copy[--len] = 0;
}
}
if(path_parent(dst_copy, parent, sizeof(parent))) {
status = ctx_fail(c, ZIPX_ERR_INTERNAL, dst_dir, "invalid destination");
goto done;
}
if(stat(parent, &st) || !S_ISDIR(st.st_mode)) {
status = ctx_fail(c, ZIPX_ERR_IO, parent, "destination parent is missing");
goto done;
}
dst_existed = !lstat(dst_copy, &st);
if(dst_existed && !S_ISDIR(st.st_mode)) {
status = ctx_fail(c, ZIPX_ERR_CONFLICT, dst_copy,
"destination is not a directory");
goto done;
}
{
char *vol_err = NULL;
int vrc = zipx_volume_detect(zip_path, &vol, &vol_err);
if(vrc < 0) {
/* A volume of a set that is incomplete gets a precise message here
instead of a generic "cannot open" further down. */
status = ctx_fail(c, ZIPX_ERR_OPEN, zip_path, "%s",
vol_err ? vol_err : "cannot read the volume set");
free(vol_err);
goto done;
}
free(vol_err);
if(vrc > 0) {
vol_set = 1;
}
}
status = open_archive(zip_path, &vol, vol_set, c, &zip, &stream);
if(status != ZIPX_OK) {
goto done;
}
if(scan_archive(zip, c)) {
status = (int)c->result->status;
goto done;
}
if(canceled(c)) {
status = ctx_fail(c, ZIPX_ERR_CANCELED, NULL, NULL);
goto done;
}
if(check_space(c, dst_existed ? dst_copy : parent)) {
status = (int)c->result->status;
goto done;
}
if(make_staging(c, parent)) {
status = (int)c->result->status;
goto done;
}
root_fd = open(c->staging, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
if(root_fd < 0) {
status = ctx_fail(c, ZIPX_ERR_IO, c->staging, "cannot open staging: %s",
strerror(errno));
goto done;
}
if(extract_archive(zip, c, root_fd)) {
status = (int)c->result->status;
goto done;
}
if(root_fd >= 0) {
close(root_fd);
root_fd = -1;
}
if(canceled(c)) {
status = ctx_fail(c, ZIPX_ERR_CANCELED, NULL, NULL);
goto done;
}
if(publish_staging(c, dst_copy, dst_existed)) {
status = (int)c->result->status;
goto done;
}
result->status = ZIPX_OK;
status = ZIPX_OK;
done:
if(root_fd >= 0) {
close(root_fd);
}
cleanup_staging(c);
if(zip) {
mz_zip_close(zip);
mz_zip_delete(&zip);
}
if(stream) {
mz_stream_close(stream);
mz_stream_delete(&stream);
}
zipx_volume_free(&vol);
result->entries_total = c->entries_total;
result->entries_done = c->entries_done;
result->bytes_total = c->bytes_total;
result->files_created = c->files_created;
result->dirs_created = c->dirs_created;
if(status != ZIPX_OK && !result->message[0]) {
snprintf(result->message, sizeof(result->message), "%s",
zipx_status_string(result->status));
}
if(status != ZIPX_OK) {
result->status = (zipx_status_t)status;
}
report(c, ZIPX_PHASE_CLEANUP, NULL, 1);
free_published(c);
return result->status;
}