diff --git a/Makefile b/Makefile index 7f20bc1..877f8e9 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ HOST_PKG_CONFIG ?= pkg-config BIN := web-file-mgr.elf LINUX_BIN := web-file-mgr-linux -COMMON_SRCS := src/main.c src/websrv.c src/filemgr.c src/file_response.c src/task.c src/upload.c src/download.c src/text.c src/list.c src/space.c src/fs_util.c src/json_util.c src/path_util.c src/asset.c src/mime.c src/notify.c src/pkg_installer.c src/pkg_info.c src/extract.c src/zip_extract.c src/rar_extract.c +COMMON_SRCS := src/main.c src/websrv.c src/filemgr.c src/file_response.c src/task.c src/upload.c src/download.c src/text.c src/list.c src/space.c src/fs_util.c src/json_util.c src/path_util.c src/asset.c src/mime.c src/notify.c src/pkg_installer.c src/pkg_info.c src/extract.c src/zip_extract.c src/rar_extract.c src/zipx_volume.c src/zipx_volstream.c PS5_SRCS := $(COMMON_SRCS) src/app_installer.c src/cpu_support_stub.c LINUX_SRCS := $(COMMON_SRCS) BASE_ASSETS := $(filter-out %.dds,$(wildcard assets/*)) diff --git a/assets/main.js b/assets/main.js index acd7893..d16422d 100644 --- a/assets/main.js +++ b/assets/main.js @@ -773,9 +773,21 @@ function isRarSubVolume(item) { return false; } +function isZipSplitVolume(item) { + if (item.type !== "-") return false; + const name = item.name; + // 分卷 ZIP 的任意一卷都能解压(引擎会自己找齐同目录的其余分卷): + // name.zip.001…(7-Zip)/ name.part1.zip…(WinRAR)/ name.z01…+name.zip(Info-ZIP) + if (/\.zip\.0*\d+$/i.test(name)) return true; + if (/\.part0*\d+\.zip$/i.test(name)) return true; + if (/\.z0*\d+$/i.test(name)) return true; + return false; +} + function isExtractableArchive(item) { if (item.type !== "-") return false; if (/\.zip$/i.test(item.name)) return true; + if (isZipSplitVolume(item)) return true; if (isRarMainVolume(item)) return true; return false; } diff --git a/src/extract.c b/src/extract.c index 134674d..308f9fd 100644 --- a/src/extract.c +++ b/src/extract.c @@ -1,5 +1,6 @@ #include "filemgr.h" +#include #include #include #include @@ -15,6 +16,7 @@ #include "path_util.h" #include "rar_extract.h" #include "zip_extract.h" +#include "zipx_volume.h" /* Cancellation callback: stop when the task is asked to cancel. */ static int @@ -67,11 +69,135 @@ extract_progress(void *userdata, const zipx_progress_t *p) { delta, NULL); } +/* Case-insensitive substring search (strcasestr is not available on MinGW). */ +static const char * +ci_strstr(const char *hay, const char *needle) { + size_t nlen = strlen(needle); + const char *p; + + if(!nlen) { + return hay; + } + for(p = hay; *p; p++) { + size_t i; + + for(i = 0; i < nlen; i++) { + if(!p[i] || + tolower((unsigned char)p[i]) != tolower((unsigned char)needle[i])) { + break; + } + } + if(i == nlen) { + return p; + } + } + return NULL; +} + +static void +extract_set_detail(zipx_result_t *result, const char *text) { + size_t len = strlen(text); + + if(len > sizeof(result->detail) - 1) { + len = sizeof(result->detail) - 1; + } + memcpy(result->detail, text, len); + result->detail[len] = 0; +} + +/* Which engine a volume set belongs to, decided from the member names: + 0 zip, 1 rar, 2 7z, -1 unknown. */ +static int +volume_format(const zipx_volume_t *vol) { + static const char *const exts[] = { ".zip", ".rar", ".7z", NULL }; + const char *best = NULL; + int best_kind = -1; + int i; + int j; + + for(i = 0; i < vol->count; i++) { + for(j = 0; exts[j]; j++) { + const char *hit = ci_strstr(vol->paths[i], exts[j]); + + if(hit && (!best || hit > best)) { + best = hit; + best_kind = j; + } + } + } + return best_kind; +} + +/* Removes the source archive once a task is done with it. For a split set + every volume has to go: leaving the other parts behind would leave the user + with something that still looks like a usable archive. */ +static void +remove_source_archives(const char *path) { + zipx_volume_t vol; + char *err = NULL; + int rc = zipx_volume_detect(path, &vol, &err); + int i; + + free(err); + if(rc > 0) { + for(i = 0; i < vol.count; i++) { + unlink(vol.paths[i]); + } + zipx_volume_free(&vol); + return; + } + unlink(path); +} + /* Pick the right engine by the archive file name. Returns ZIPX_ERR_FORMAT for anything that does not look like a supported archive. */ static zipx_status_t extract_dispatch(file_task_t *task, zipx_conflict_t conflict, const zipx_limits_t *limits, zipx_result_t *result) { + zipx_volume_t vol; + char *vol_err = NULL; + int vrc = zipx_volume_detect(task->src, &vol, &vol_err); + int kind = vrc > 0 ? volume_format(&vol) : -1; + + if(vrc < 0) { + /* A broken set gets the precise reason (which volume is missing, ...) + instead of a generic "unsupported format". */ + extract_set_detail(result, task->src); + snprintf(result->message, sizeof(result->message), "%s", + vol_err ? vol_err : "the archive volumes are incomplete"); + free(vol_err); + return ZIPX_ERR_OPEN; + } + free(vol_err); + if(vrc > 0) { + zipx_status_t status; + + if(kind == 0) { + status = zipx_extract(task->src, task->dst, conflict, limits, + extract_cancel, extract_progress, task, result); + } else if(kind == 1) { + /* unrar chains its own volume naming (x.part1.rar); a byte contiguous + set named x.rar.001 cannot be handed to it as-is. */ + extract_set_detail(result, task->src); + snprintf(result->message, sizeof(result->message), + "RAR volume sets named 'x.rar.001' are not supported yet " + "(rename the parts to 'x.part1.rar', 'x.part2.rar', ...)"); + status = ZIPX_ERR_UNSUPPORTED; + } else if(kind == 2) { + extract_set_detail(result, task->src); + snprintf(result->message, sizeof(result->message), + "7z archives are not supported yet (repack as .zip or .rar)"); + status = ZIPX_ERR_UNSUPPORTED; + } else { + extract_set_detail(result, task->src); + snprintf(result->message, sizeof(result->message), + "unsupported split archive (only .zip, .rar and .7z volumes " + "are recognised)"); + status = ZIPX_ERR_UNSUPPORTED; + } + zipx_volume_free(&vol); + return status; + } if(ends_with_ci(task->src, ".zip")) { return zipx_extract(task->src, task->dst, conflict, limits, extract_cancel, extract_progress, task, result); @@ -80,17 +206,10 @@ extract_dispatch(file_task_t *task, zipx_conflict_t conflict, return rar_extract(task->src, task->dst, conflict, limits, extract_cancel, extract_progress, task, result); } - { - size_t len = strlen(task->src); - if(len > sizeof(result->detail) - 1) { - len = sizeof(result->detail) - 1; - } - memcpy(result->detail, task->src, len); - result->detail[len] = 0; - snprintf(result->message, sizeof(result->message), - "unsupported archive format (only .zip and .rar are accepted)"); - return ZIPX_ERR_UNSUPPORTED; - } + extract_set_detail(result, task->src); + snprintf(result->message, sizeof(result->message), + "unsupported archive format (only .zip and .rar are accepted)"); + return ZIPX_ERR_UNSUPPORTED; } static const char * @@ -170,7 +289,7 @@ extract_worker(void *arg) { /* Only delete the source archive when this task owns it (upload flow). */ if(task->extract_remove_source && task->src[0]) { - unlink(task->src); + remove_source_archives(task->src); } pthread_mutex_lock(&g_tasks_lock); task->state = TASK_DONE; diff --git a/src/zip_extract.c b/src/zip_extract.c index 176c4fe..64de918 100644 --- a/src/zip_extract.c +++ b/src/zip_extract.c @@ -22,6 +22,9 @@ #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 @@ -1248,6 +1251,76 @@ publish_staging(zipx_ctx_t *c, const char *dst_dir, int dst_existed) { * 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, @@ -1263,6 +1336,8 @@ zipx_extract(const char *zip_path, const char *dst_dir, 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) { @@ -1274,6 +1349,8 @@ zipx_extract(const char *zip_path, const char *dst_dir, } memset(&ctx, 0, sizeof(ctx)); + memset(&vol, 0, sizeof(vol)); + vol.index = -1; memset(result, 0, sizeof(*result)); c->result = result; c->conflict = conflict; @@ -1305,16 +1382,25 @@ zipx_extract(const char *zip_path, const char *dst_dir, goto done; } - stream = mz_stream_os_create(); - zip = mz_zip_create(); - if(!stream || !zip) { - status = ctx_fail(c, ZIPX_ERR_INTERNAL, zip_path, "out of memory"); - 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; + } } - if(mz_stream_open(stream, zip_path, MZ_OPEN_MODE_READ) || - mz_zip_open(zip, stream, MZ_OPEN_MODE_READ)) { - status = ctx_fail(c, ZIPX_ERR_OPEN, zip_path, "%s", - strerror(errno ? errno : EIO)); + status = open_archive(zip_path, &vol, vol_set, c, &zip, &stream); + if(status != ZIPX_OK) { goto done; } @@ -1375,6 +1461,7 @@ done: 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; diff --git a/src/zipx_volstream.c b/src/zipx_volstream.c new file mode 100644 index 0000000..c32fe09 --- /dev/null +++ b/src/zipx_volstream.c @@ -0,0 +1,438 @@ +/* zipx_volstream -- present a multi-file archive volume set as one stream. + part of ps5-web-file-manager + + See zipx_volstream.h for the two split layouts this supports. */ + +#include +#include +#include + +#include "mz.h" +#include "mz_os.h" +#include "mz_strm.h" +#include "mz_strm_os.h" + +#include "zipx_volstream.h" + +#define VOL_INT32_MAX 0x7fffffffLL + +typedef struct { + mz_stream stream; /* first member: callbacks cast the handle to this */ + char **paths; /* ordered part paths */ + int32_t count; + int64_t *prefix; /* count + 1 entries, prefix[count] == total */ + int64_t total; + int32_t mode; /* ZIPX_VOL_MODE_* */ + int32_t disk; /* active part index */ + int64_t pos; /* absolute position inside the concatenated set */ + int32_t os_part; /* part currently held by os, -1 when nothing is open */ + void *os; + int32_t opened; + int32_t error; +} zipx_volstream_t; + +/* ------------------------------------------------------------------------- */ + +static int32_t +vol_use_part(zipx_volstream_t *v, int32_t part) { + if(v->os_part == part) { + return MZ_OK; + } + if(v->os_part >= 0) { + mz_stream_close(v->os); + v->os_part = -1; + } + if(mz_stream_open(v->os, v->paths[part], MZ_OPEN_MODE_READ) != MZ_OK) { + v->error = MZ_OPEN_ERROR; + return MZ_OPEN_ERROR; + } + v->os_part = part; + return MZ_OK; +} + +/* Part holding an absolute offset, walking from `hint` (parts are laid out in + order and reads are sequential, so this stays O(1) amortised). */ +static int32_t +vol_part_of(zipx_volstream_t *v, int64_t pos, int32_t hint) { + int32_t i; + + if(pos < 0 || pos >= v->total) { + return -1; + } + i = hint; + if(i < 0) { + i = 0; + } + if(i > v->count - 1) { + i = v->count - 1; + } + while(i > 0 && pos < v->prefix[i]) { + i--; + } + while(i < v->count - 1 && pos >= v->prefix[i + 1]) { + i++; + } + return i; +} + +static int32_t +vol_open(void *stream, const char *path, int32_t mode) { + zipx_volstream_t *v = (zipx_volstream_t *)stream; + int64_t sum = 0; + int32_t i; + + (void)path; + (void)mode; + if(!v || v->count <= 0 || !v->paths) { + return MZ_PARAM_ERROR; + } + v->prefix = (int64_t *)calloc((size_t)v->count + 1, sizeof(*v->prefix)); + if(!v->prefix) { + v->error = MZ_MEM_ERROR; + return MZ_MEM_ERROR; + } + for(i = 0; i < v->count; i++) { + int64_t size = mz_os_get_file_size(v->paths[i]); + + if(size < 0) { + v->error = MZ_OPEN_ERROR; + return MZ_OPEN_ERROR; + } + v->prefix[i] = sum; + sum += size; + } + v->prefix[v->count] = sum; + v->total = sum; + + if(!v->os) { + v->os = mz_stream_os_create(); + if(!v->os) { + v->error = MZ_MEM_ERROR; + return MZ_MEM_ERROR; + } + } + /* The end-of-central-directory record sits on the last disk, so a split + disk set starts there; a byte split is read from its first byte. */ + v->disk = (v->mode == ZIPX_VOL_MODE_DISK) ? v->count - 1 : 0; + v->pos = v->prefix[v->disk]; + v->os_part = -1; + v->opened = 1; + v->error = MZ_OK; + return MZ_OK; +} + +static int32_t +vol_is_open(void *stream) { + zipx_volstream_t *v = (zipx_volstream_t *)stream; + + return (v && v->opened) ? MZ_OK : MZ_OPEN_ERROR; +} + +static int32_t +vol_read(void *stream, void *buf, int32_t size) { + zipx_volstream_t *v = (zipx_volstream_t *)stream; + int32_t done = 0; + int32_t hint; + + if(!v || !v->opened || !buf) { + return MZ_PARAM_ERROR; + } + if(size <= 0) { + return 0; + } + hint = v->os_part >= 0 ? v->os_part : 0; + while(done < size) { + int32_t part = vol_part_of(v, v->pos, hint); + int64_t in_part; + int64_t avail; + int64_t want; + int32_t got; + + if(part < 0) { + break; /* end of the logical archive */ + } + hint = part; + if(vol_use_part(v, part) != MZ_OK) { + break; + } + in_part = v->pos - v->prefix[part]; + avail = (v->prefix[part + 1] - v->prefix[part]) - in_part; + if(avail <= 0) { /* empty part: step over it */ + v->pos = v->prefix[part + 1]; + continue; + } + want = (int64_t)(size - done); + if(want > avail) { + want = avail; + } + if(want > VOL_INT32_MAX) { + want = VOL_INT32_MAX; + } + if(mz_stream_tell(v->os) != in_part) { + if(mz_stream_seek(v->os, in_part, MZ_SEEK_SET) != MZ_OK) { + v->error = MZ_SEEK_ERROR; + break; + } + } + got = mz_stream_read(v->os, (uint8_t *)buf + done, (int32_t)want); + if(got <= 0) { + if(got < 0) { + v->error = MZ_READ_ERROR; + } + break; + } + done += got; + v->pos += got; + if(got < (int32_t)want) { + break; /* short read: let the caller come back */ + } + } + if(done == 0 && v->error != MZ_OK) { + return v->error; + } + return done; +} + +static int32_t +vol_write(void *stream, const void *buf, int32_t size) { + (void)stream; + (void)buf; + (void)size; + return MZ_SUPPORT_ERROR; +} + +static int64_t +vol_tell(void *stream) { + zipx_volstream_t *v = (zipx_volstream_t *)stream; + + if(!v || !v->opened) { + return -1; + } + if(v->mode == ZIPX_VOL_MODE_DISK) { + return v->pos - v->prefix[v->disk]; + } + return v->pos; +} + +static int32_t +vol_seek(void *stream, int64_t offset, int32_t origin) { + zipx_volstream_t *v = (zipx_volstream_t *)stream; + int64_t target; + + if(!v || !v->opened) { + return MZ_PARAM_ERROR; + } + if(origin == MZ_SEEK_SET) { + target = offset; + if(v->mode == ZIPX_VOL_MODE_DISK) { + target += v->prefix[v->disk]; /* offsets are disk relative there */ + } + } else if(origin == MZ_SEEK_CUR) { + target = v->pos + offset; + } else if(origin == MZ_SEEK_END) { + target = v->total + offset; + } else { + return MZ_PARAM_ERROR; + } + if(target < 0) { + target = 0; + } + if(target > v->total) { + target = v->total; + } + v->pos = target; + return MZ_OK; +} + +static int32_t +vol_close(void *stream) { + zipx_volstream_t *v = (zipx_volstream_t *)stream; + + if(!v) { + return MZ_PARAM_ERROR; + } + if(v->os && v->os_part >= 0) { + mz_stream_close(v->os); + v->os_part = -1; + } + v->opened = 0; + return MZ_OK; +} + +static int32_t +vol_error(void *stream) { + zipx_volstream_t *v = (zipx_volstream_t *)stream; + + return v ? v->error : MZ_PARAM_ERROR; +} + +static int32_t +vol_get_prop(void *stream, int32_t prop, int64_t *value) { + zipx_volstream_t *v = (zipx_volstream_t *)stream; + + if(!v || !value) { + return MZ_PARAM_ERROR; + } + if(v->mode != ZIPX_VOL_MODE_DISK) { + /* A byte split keeps absolute offsets, so the disk properties must look + unsupported: minizip-ng then leaves every offset alone. */ + return MZ_PARAM_ERROR; + } + if(prop == MZ_STREAM_PROP_DISK_NUMBER) { + *value = v->disk; + return MZ_OK; + } + if(prop == MZ_STREAM_PROP_DISK_SIZE) { + *value = v->prefix[v->disk + 1] - v->prefix[v->disk]; + return MZ_OK; + } + return MZ_PARAM_ERROR; +} + +static int32_t +vol_set_prop(void *stream, int32_t prop, int64_t value) { + zipx_volstream_t *v = (zipx_volstream_t *)stream; + + if(!v) { + return MZ_PARAM_ERROR; + } + if(prop != MZ_STREAM_PROP_DISK_NUMBER || v->mode != ZIPX_VOL_MODE_DISK) { + return MZ_PARAM_ERROR; + } + if(value < 0) { + /* minizip-ng passes -1 for entries that live on the same disk as the + central directory, which is the final volume of the set. */ + v->disk = v->count - 1; + v->pos = v->prefix[v->disk]; + return MZ_OK; + } + if(value >= v->count) { + return MZ_PARAM_ERROR; + } + v->disk = (int32_t)value; + v->pos = v->prefix[v->disk]; + return MZ_OK; +} + +/* ------------------------------------------------------------------------- */ + +static void vol_destroy(void **stream); + +static mz_stream_vtbl vol_vtbl = { + vol_open, vol_is_open, vol_read, vol_write, vol_tell, vol_seek, vol_close, + vol_error, NULL, vol_destroy, vol_get_prop, vol_set_prop, +}; + +static void * +vol_create(void) { + zipx_volstream_t *v = (zipx_volstream_t *)calloc(1, sizeof(*v)); + + if(!v) { + return NULL; + } + v->stream.vtbl = &vol_vtbl; + v->os_part = -1; + return (void *)&v->stream; +} + +static void +vol_destroy(void **stream) { + zipx_volstream_t *v; + int32_t i; + + if(!stream || !*stream) { + return; + } + v = (zipx_volstream_t *)*stream; + vol_close(&v->stream); + if(v->os) { + mz_stream_os_delete(&v->os); + } + if(v->paths) { + for(i = 0; i < v->count; i++) { + free(v->paths[i]); + } + free(v->paths); + } + free(v->prefix); + free(v); + *stream = NULL; +} + +/* ------------------------------------------------------------------------- */ + +void * +zipx_volstream_create(int32_t mode) { + void *stream = vol_create(); + zipx_volstream_t *v = (zipx_volstream_t *)stream; + + if(!v) { + return NULL; + } + v->mode = (mode == ZIPX_VOL_MODE_DISK) ? ZIPX_VOL_MODE_DISK : + ZIPX_VOL_MODE_CONCAT; + return stream; +} + +void +zipx_volstream_delete(void **stream) { + mz_stream_delete(stream); /* routes through vtbl->destroy */ +} + +int32_t +zipx_volstream_set_parts(void *stream, const char *const *paths, int32_t count) { + zipx_volstream_t *v = (zipx_volstream_t *)stream; + int32_t i; + + if(!v || !paths || count <= 0) { + return MZ_PARAM_ERROR; + } + v->paths = (char **)calloc((size_t)count, sizeof(*v->paths)); + if(!v->paths) { + return MZ_MEM_ERROR; + } + v->count = count; + for(i = 0; i < count; i++) { + size_t len = strlen(paths[i]) + 1; + + v->paths[i] = (char *)malloc(len); + if(!v->paths[i]) { + return MZ_MEM_ERROR; + } + memcpy(v->paths[i], paths[i], len); + } + return MZ_OK; +} + +int64_t +zipx_volstream_total(void *stream) { + zipx_volstream_t *v = (zipx_volstream_t *)stream; + + return v ? v->total : -1; +} + +const char * +zipx_volstream_describe(void *stream, char *buf, unsigned int size) { + zipx_volstream_t *v = (zipx_volstream_t *)stream; + const char *base; + + if(!buf || size == 0) { + return ""; + } + if(!v || v->count <= 0) { + snprintf(buf, size, "(no volumes)"); + return buf; + } + base = strrchr(v->paths[0], '/'); +#ifdef _WIN32 + { + const char *alt = strrchr(v->paths[0], '\\'); + if(alt && (!base || alt > base)) { + base = alt; + } + } +#endif + base = base ? base + 1 : v->paths[0]; + snprintf(buf, size, "%s (%d volumes)", base, (int)v->count); + return buf; +} diff --git a/src/zipx_volstream.h b/src/zipx_volstream.h new file mode 100644 index 0000000..1686400 --- /dev/null +++ b/src/zipx_volstream.h @@ -0,0 +1,57 @@ +/* zipx_volstream -- present a multi-file archive volume set as one stream. + part of ps5-web-file-manager + + Two split layouts exist in the wild and they need different behaviour: + + ZIPX_VOL_MODE_CONCAT (byte split) + `name.zip.001`, `name.zip.002`, ... (7-Zip) and `name.part1.zip`, + `name.part2.zip` (WinRAR). Each part is a byte slice of one archive, so + byte N of the logical archive is byte N of the concatenation and every + offset stored inside the archive is already absolute. Offsets are passed + through untouched and the disk properties are reported as unsupported so + minizip-ng keeps using absolute offsets. + + ZIPX_VOL_MODE_DISK (zip split disks) + `name.z01`, `name.z02`, ..., `name.zip` (Info-ZIP / PKZIP style). The + central directory stores the offset of a local header relative to the + disk it starts on, so minizip-ng switches the active disk through + MZ_STREAM_PROP_DISK_NUMBER before seeking. Seek/tell are relative to the + active disk here, which is exactly what mz_zip_entry_seek_local_header + expects, and the stream starts on the last disk because that is where the + end-of-central-directory record lives. */ + +#ifndef ZIPX_VOLSTREAM_H +#define ZIPX_VOLSTREAM_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define ZIPX_VOL_MODE_CONCAT 0 +#define ZIPX_VOL_MODE_DISK 1 + +/* Creates a stream handle; pass it to mz_stream_open() afterwards. */ +void *zipx_volstream_create(int32_t mode); + +/* Deletes a handle created above (safe with *stream == NULL). */ +void zipx_volstream_delete(void **stream); + +/* Copies the ordered part paths into the handle. Must be called before the + stream is opened. Returns MZ_OK (0) or MZ_MEM_ERROR (-4). */ +int32_t zipx_volstream_set_parts(void *stream, const char *const *paths, + int32_t count); + +/* Total logical size (sum of the part sizes), or -1 when not resolved. */ +int64_t zipx_volstream_total(void *stream); + +/* Human readable description of the set, e.g. "name.z01 (3 volumes)". + Writes into buf and returns buf. */ +const char *zipx_volstream_describe(void *stream, char *buf, unsigned int size); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/zipx_volume.c b/src/zipx_volume.c new file mode 100644 index 0000000..104477b --- /dev/null +++ b/src/zipx_volume.c @@ -0,0 +1,601 @@ +/* zipx_volume -- see zipx_volume.h for what this groups and why. */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "zipx_volstream.h" +#include "zipx_volume.h" + +typedef struct { + long num; + char *path; +} vol_part_t; + +/* ------------------------------------------------------------------------- */ +/* path helpers */ + +static const char * +file_base(const char *path) { + const char *slash = strrchr(path, '/'); + const char *back = strrchr(path, '\\'); + + if(back && (!slash || back > slash)) { + slash = back; + } + return slash ? slash + 1 : path; +} + +/* Directory part without a trailing separator; "" for a bare filename. */ +static void +file_dir(const char *path, char *buf, size_t size) { + const char *base = file_base(path); + size_t len = (size_t)(base - path); + + while(len > 0 && (path[len - 1] == '/' || path[len - 1] == '\\')) { + len--; + } + if(len >= size) { + len = size - 1; + } + memcpy(buf, path, len); + buf[len] = 0; +} + +static int +ends_with_ci(const char *s, const char *suffix) { + size_t ls = strlen(s); + size_t lf = strlen(suffix); + + if(lf > ls) { + return 0; + } + return strcasecmp(s + ls - lf, suffix) == 0; +} + +static int +file_exists(const char *path) { + struct stat st; + + return stat(path, &st) == 0; +} + +static char * +vol_join(const char *dir, const char *name) { + size_t need = strlen(dir) + strlen(name) + 2; + char *out = (char *)malloc(need); + + if(!out) { + return NULL; + } + if(dir[0]) { + snprintf(out, need, "%s/%s", dir, name); + } else { + snprintf(out, need, "%s", name); + } + return out; +} + +static char * +err_printf(const char *fmt, ...) { + va_list ap; + char *buf; + int need; + + va_start(ap, fmt); + need = vsnprintf(NULL, 0, fmt, ap); + va_end(ap); + if(need < 0) { + return NULL; + } + buf = (char *)malloc((size_t)need + 1); + if(!buf) { + return NULL; + } + va_start(ap, fmt); + vsnprintf(buf, (size_t)need + 1, fmt, ap); + va_end(ap); + return buf; +} + +/* ------------------------------------------------------------------------- */ +/* part collection */ + +/* Matches "PREFIXSUFFIX"; returns 1 and the value on a match. */ +static int +match_numbered(const char *entry, const char *prefix, const char *suffix, + long *num) { + size_t plen = strlen(prefix); + size_t slen = strlen(suffix); + const char *p; + char *end = NULL; + long value; + + if(strncmp(entry, prefix, plen) != 0) { + return 0; + } + p = entry + plen; + if(!isdigit((unsigned char)*p)) { + return 0; + } + value = strtol(p, &end, 10); + if(end == p) { + return 0; + } + if(slen > 0) { + if(strcmp(end, suffix) != 0) { + return 0; + } + } else if(*end != 0) { + return 0; + } + *num = value; + return 1; +} + +static int +part_compare(const void *a, const void *b) { + const vol_part_t *pa = (const vol_part_t *)a; + const vol_part_t *pb = (const vol_part_t *)b; + + if(pa->num < pb->num) { + return -1; + } + if(pa->num > pb->num) { + return 1; + } + return 0; +} + +static void +free_parts(vol_part_t *parts, int count) { + int i; + + for(i = 0; i < count; i++) { + free(parts[i].path); + parts[i].path = NULL; + } +} + +/* Collects every entry in `dir` matching PREFIXSUFFIX, ordered by the + number. Returns the count, or -1 when the directory cannot be listed (with + *err set) or the set is larger than `max`. */ +static int +scan_parts(const char *dir, const char *prefix, const char *suffix, + vol_part_t *parts, int max, char **err) { + const char *target = dir[0] ? dir : "."; + DIR *d = opendir(target); + struct dirent *ent; + int count = 0; + + if(!d) { + if(err && !*err) { + *err = err_printf("cannot list the directory '%s' that holds the other " + "volumes", target); + } + return -1; + } + while((ent = readdir(d)) != NULL) { + long num = 0; + + if(!match_numbered(ent->d_name, prefix, suffix, &num)) { + continue; + } + if(count >= max) { + if(err && !*err) { + *err = err_printf("volume set has more than %d parts, the supported " + "maximum", max); + } + free_parts(parts, count); + closedir(d); + return -1; + } + parts[count].num = num; + parts[count].path = vol_join(dir, ent->d_name); + if(!parts[count].path) { + free_parts(parts, count + 1); + closedir(d); + return -1; + } + count++; + } + closedir(d); + qsort(parts, (size_t)count, sizeof(*parts), part_compare); + return count; +} + +/* Verifies the parts are numbered 1..count with no gap (and no duplicate), + filling *err with the exact missing name when they are not. */ +static int +check_contiguous(vol_part_t *parts, int count, const char *dir, + const char *prefix, const char *suffix, int width, + char **err) { + int i; + + for(i = 0; i < count; i++) { + if(parts[i].num != (long)(i + 1)) { + if(err && !*err) { + char name[512]; + char *full; + + snprintf(name, sizeof(name), "%s%0*ld%s", prefix, width, + (long)(i + 1), suffix); + full = vol_join(dir, name); + *err = err_printf("volume set is incomplete: '%s' is missing", + full ? full : name); + free(full); + } + return -1; + } + } + return 0; +} + +/* ------------------------------------------------------------------------- */ +/* volume set bookkeeping */ + +static void +volume_reset(zipx_volume_t *vol) { + memset(vol, 0, sizeof(*vol)); + vol->index = -1; +} + +static int +volume_take(zipx_volume_t *vol, vol_part_t *parts, int count, int mode, + const char *selected) { + int i; + + vol->paths = (char **)calloc((size_t)count, sizeof(*vol->paths)); + if(!vol->paths) { + return -1; + } + for(i = 0; i < count; i++) { + vol->paths[i] = parts[i].path; + parts[i].path = NULL; /* ownership moves into vol */ + } + vol->count = count; + vol->mode = mode; + vol->is_set = 1; + vol->index = -1; + for(i = 0; i < count; i++) { + if(selected && strcmp(vol->paths[i], selected) == 0) { + vol->index = i; + break; + } + } + return 0; +} + +/* ------------------------------------------------------------------------- */ +/* detection: name.zip.001 / name.7z.001 / name.rar.001 */ + +static int +detect_digit_suffix(const char *dir, const char *base, const char *selected, + zipx_volume_t *vol, char **err) { + static const char *const known[] = { "zip", "7z", "rar", NULL }; + const char *dot = strrchr(base, '.'); + vol_part_t parts[ZIPX_VOL_MAX_PARTS]; + char stem[2048]; + char prefix[2100]; + size_t stem_len; + int count; + int i; + int known_ext = 0; + + if(!dot || dot == base || !dot[1]) { + return 0; + } + for(i = 1; dot[i]; i++) { + if(!isdigit((unsigned char)dot[i])) { + return 0; + } + } + stem_len = (size_t)(dot - base); + if(stem_len >= sizeof(stem)) { + return 0; + } + memcpy(stem, base, stem_len); + stem[stem_len] = 0; + for(i = 0; known[i]; i++) { + char tail[8]; + + snprintf(tail, sizeof(tail), ".%s", known[i]); + if(ends_with_ci(stem, tail)) { + known_ext = 1; + break; + } + } + if(!known_ext) { + return 0; /* "backup.001" style names are not archive volumes */ + } + snprintf(prefix, sizeof(prefix), "%s.", stem); + count = scan_parts(dir, prefix, "", parts, ZIPX_VOL_MAX_PARTS, err); + if(count < 0) { + return -1; + } + if(count <= 1) { + free_parts(parts, count > 0 ? count : 0); + if(count == 1 && err && !*err) { + *err = err_printf("'%s' is the first volume of a split archive but no " + "other volumes ('%s.002', ...) are present", base, + stem); + } + return count == 1 ? -1 : 0; + } + if(check_contiguous(parts, count, dir, prefix, "", 3, err)) { + free_parts(parts, count); + return -1; + } + if(volume_take(vol, parts, count, ZIPX_VOL_MODE_CONCAT, selected)) { + free_parts(parts, count); + return -1; + } + return 1; +} + +/* ------------------------------------------------------------------------- */ +/* detection: name.z01 ... name.zip */ + +static int +detect_z_suffix(const char *dir, const char *base, const char *selected, + zipx_volume_t *vol, char **err) { + const char *dot = strrchr(base, '.'); + vol_part_t parts[ZIPX_VOL_MAX_PARTS]; + char stem[2048]; + char prefix[2100]; + size_t stem_len; + int count; + + if(!dot || dot == base) { + return 0; + } + if((dot[1] != 'z' && dot[1] != 'Z') || !isdigit((unsigned char)dot[2])) { + return 0; + } + { + int i; + + for(i = 2; dot[i]; i++) { + if(!isdigit((unsigned char)dot[i])) { + return 0; + } + } + } + stem_len = (size_t)(dot - base); + if(stem_len >= sizeof(stem)) { + return 0; + } + memcpy(stem, base, stem_len); + stem[stem_len] = 0; + + snprintf(prefix, sizeof(prefix), "%s.z", stem); + count = scan_parts(dir, prefix, "", parts, ZIPX_VOL_MAX_PARTS - 1, err); + if(count < 0) { + return -1; + } + if(count == 0) { + return 0; + } + if(check_contiguous(parts, count, dir, prefix, "", 2, err)) { + free_parts(parts, count); + return -1; + } + /* The central directory always lives in "name.zip", the final volume. */ + { + char name[2100]; + char *last; + + snprintf(name, sizeof(name), "%s.zip", stem); + last = vol_join(dir, name); + if(!last) { + free_parts(parts, count); + return -1; + } + if(!file_exists(last)) { + if(err && !*err) { + *err = err_printf("volume set is incomplete: the last volume '%s' that " + "holds the archive index is missing", name); + } + free(last); + free_parts(parts, count); + return -1; + } + parts[count].num = (long)count + 1; + parts[count].path = last; + count++; + } + if(volume_take(vol, parts, count, ZIPX_VOL_MODE_DISK, selected)) { + free_parts(parts, count); + return -1; + } + return 1; +} + +/* ------------------------------------------------------------------------- */ +/* detection: the final "name.zip" of a name.z01 ... name.zip set */ + +static int +detect_zip_tail(const char *dir, const char *base, const char *selected, + zipx_volume_t *vol, char **err) { + vol_part_t parts[ZIPX_VOL_MAX_PARTS]; + char stem[2048]; + char prefix[2100]; + size_t stem_len; + int count; + + if(!ends_with_ci(base, ".zip")) { + return 0; + } + stem_len = strlen(base) - 4; + if(stem_len == 0 || stem_len >= sizeof(stem)) { + return 0; + } + memcpy(stem, base, stem_len); + stem[stem_len] = 0; + + snprintf(prefix, sizeof(prefix), "%s.z", stem); + count = scan_parts(dir, prefix, "", parts, ZIPX_VOL_MAX_PARTS - 1, err); + if(count < 0) { + return -1; + } + if(count == 0) { + return 0; /* an ordinary single volume archive */ + } + if(check_contiguous(parts, count, dir, prefix, "", 2, err)) { + free_parts(parts, count); + return -1; + } + { + char *last = vol_join(dir, base); + + if(!last) { + free_parts(parts, count); + return -1; + } + parts[count].num = (long)count + 1; + parts[count].path = last; + count++; + } + if(volume_take(vol, parts, count, ZIPX_VOL_MODE_DISK, selected)) { + free_parts(parts, count); + return -1; + } + return 1; +} + +/* ------------------------------------------------------------------------- */ +/* detection: name.part1.zip ... */ + +static int +detect_part_suffix(const char *dir, const char *base, const char *selected, + zipx_volume_t *vol, char **err) { + vol_part_t parts[ZIPX_VOL_MAX_PARTS]; + char stem[2048]; + char prefix[2100]; + char trimmed[2048]; + const char *dot; + size_t len; + int count; + + if(!ends_with_ci(base, ".zip")) { + return 0; + } + len = strlen(base) - 4; + if(len == 0 || len >= sizeof(trimmed)) { + return 0; + } + memcpy(trimmed, base, len); + trimmed[len] = 0; + dot = strrchr(trimmed, '.'); + if(!dot || dot == trimmed || strncasecmp(dot, ".part", 5) != 0 || + !isdigit((unsigned char)dot[5])) { + return 0; + } + { + size_t stem_len = (size_t)(dot - trimmed); + + if(stem_len >= sizeof(stem)) { + return 0; + } + memcpy(stem, trimmed, stem_len); + stem[stem_len] = 0; + } + snprintf(prefix, sizeof(prefix), "%s.part", stem); + count = scan_parts(dir, prefix, ".zip", parts, ZIPX_VOL_MAX_PARTS, err); + if(count < 0) { + return -1; + } + if(count <= 1) { + free_parts(parts, count > 0 ? count : 0); + if(count == 1 && err && !*err) { + *err = err_printf("'%s' is a volume of a split archive but the other " + "volumes ('%s.part1.zip', ...) are missing", base, stem); + } + return count == 1 ? -1 : 0; + } + if(check_contiguous(parts, count, dir, prefix, ".zip", 1, err)) { + free_parts(parts, count); + return -1; + } + if(volume_take(vol, parts, count, ZIPX_VOL_MODE_DISK, selected)) { + free_parts(parts, count); + return -1; + } + return 1; +} + +/* ------------------------------------------------------------------------- */ + +int +zipx_volume_detect(const char *path, zipx_volume_t *out, char **err) { + char dir[4096]; + const char *base; + int rc; + + if(err) { + *err = NULL; + } + if(!path || !out) { + return -1; + } + volume_reset(out); + base = file_base(path); + file_dir(path, dir, sizeof(dir)); + + rc = detect_digit_suffix(dir, base, path, out, err); + if(rc != 0) { + return rc; + } + rc = detect_z_suffix(dir, base, path, out, err); + if(rc != 0) { + return rc; + } + rc = detect_zip_tail(dir, base, path, out, err); + if(rc != 0) { + return rc; + } + return detect_part_suffix(dir, base, path, out, err); +} + +void +zipx_volume_free(zipx_volume_t *vol) { + int i; + + if(!vol) { + return; + } + if(vol->paths) { + for(i = 0; i < vol->count; i++) { + free(vol->paths[i]); + } + free(vol->paths); + } + memset(vol, 0, sizeof(*vol)); + vol->index = -1; +} + +int +zipx_volume_is_first(const char *path) { + const char *base = file_base(path); + const char *dot = strrchr(base, '.'); + + if(!dot) { + return 0; + } + if(strcmp(dot, ".001") == 0) { + return 1; + } + if((dot[1] == 'z' || dot[1] == 'Z') && dot[2] == '0' && dot[3] == '1' && + dot[4] == 0) { + return 1; + } + if(ends_with_ci(base, ".part1.zip")) { + return 1; + } + return 0; +} diff --git a/src/zipx_volume.h b/src/zipx_volume.h new file mode 100644 index 0000000..57d0f33 --- /dev/null +++ b/src/zipx_volume.h @@ -0,0 +1,50 @@ +/* zipx_volume -- group a multi-file archive volume set into an ordered list. + part of ps5-web-file-manager + + Supports the naming conventions seen in the wild: + + name.zip.001, name.zip.002, ... byte split (7-Zip "split to volumes") + name.part1.zip, name.part2.zip byte split (WinRAR zip volumes) + name.z01, name.z02, ..., name.zip zip split disks (Info-ZIP / PKZIP) + + A caller can hand in any member of the set (the user usually clicks one file + in the browser) and gets back the full ordered list plus the split layout so + the engine can pick the matching stream behaviour. */ + +#ifndef ZIPX_VOLUME_H +#define ZIPX_VOLUME_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define ZIPX_VOL_MAX_PARTS 512 + +typedef struct { + char **paths; /* ordered part paths, owned by this struct */ + int count; + int index; /* position of the path that was handed in (-1 unknown) */ + int mode; /* ZIPX_VOL_MODE_CONCAT or ZIPX_VOL_MODE_DISK */ + int is_set; /* 1 when the path is part of a multi-file set */ +} zipx_volume_t; + +/* Inspects `path`: 1 when it belongs to a multi-file set (out is filled), + 0 when it is an ordinary single file (out is cleared), -1 on a hard error + (*err, when non-NULL, receives a malloc'd message the caller must free; + it is also set for the 0 case when a sibling set looks broken, so callers + can surface "volumes are incomplete" instead of a generic open failure). */ +int zipx_volume_detect(const char *path, zipx_volume_t *out, char **err); + +void zipx_volume_free(zipx_volume_t *vol); + +/* True when `path` looks like the first volume of a set ("x.zip.001", + "x.z01", "x.part1.zip"), used by the UI to label the entry. */ +int zipx_volume_is_first(const char *path); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/tests/make_split_fixtures.py b/tests/make_split_fixtures.py new file mode 100644 index 0000000..dc6b071 --- /dev/null +++ b/tests/make_split_fixtures.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Builds split-archive fixtures for the multi-volume extraction tests. + +Output goes to tests/fixtures-split/ and covers the three naming conventions +the engine recognises, plus two broken sets used to check the error messages: + + plain.zip.001 .002 .003 byte split (7-Zip "split to volumes") + parts.part1.zip ... .part3.zip byte split (WinRAR zip volumes) + disks.z01 .z02 .zip zip split disks, offsets relative to each disk + broken.zip.001 first volume only, the rest missing + gap.zip.001 .gap.zip.003 volume 2 missing + +The byte-split sets are produced by slicing one ordinary archive. The +"disks" set is built by hand: the central directory is rewritten so that every +entry records the disk it starts on and an offset relative to that disk, which +is what a real zip split disk archive looks like (APPNOTE 4.4.11). +""" + +import io +import os +import shutil +import struct +import zipfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +# The test binary receives tests/fixtures as its fixture directory, and +# make_fixtures.py has already rebuilt that tree by the time this script runs, +# so the split sets are added there (only our own files are replaced). +OUT = os.path.join(HERE, "fixtures") + +OWN_PREFIXES = ("plain.zip.", "parts.part", "disks.", "broken.zip.", + "gap.zip.", "split_single.zip") + +ENTRIES = [ + ("readme.txt", b"split archive fixture\n" * 20), + ("sub/data.bin", bytes(range(256)) * 40), + ("sub/deep/more.bin", b"ABCD" * 3000), + ("tail.bin", bytes(reversed(range(256))) * 30), +] + + +def build_plain_zip(): + """One ordinary archive; ZIP_STORED keeps the layout predictable.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf: + for name, data in ENTRIES: + info = zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_STORED + zf.writestr(info, data) + return buf.getvalue() + + +def read_central_directory(blob): + """Returns (cd_offset, cd_size, count, [(name, local_offset, cd_pos)]).""" + eocd = blob.rfind(b"PK\x05\x06") + if eocd < 0: + raise SystemExit("fixture build: no end-of-central-directory record") + count, cd_size, cd_offset = struct.unpack_from(" target and current < parts - 2: + current += 1 + disk_starts.append(local_offset) + disk_of_entry.append(current) + + out = bytearray(blob) + # Central directory entries: disk number + offset relative to that disk. + for (name, local_offset, cd_pos), disk in zip(entries, disk_of_entry): + struct.pack_into(" 2 else b"") + + # A plain archive next to the sets, so the tests can prove the normal + # single file path still works now that volume detection runs first. + write(os.path.join(OUT, "split_single.zip"), plain) + + +if __name__ == "__main__": + main() diff --git a/tests/run-tests.sh b/tests/run-tests.sh index c18960b..f689b64 100644 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -23,6 +23,7 @@ find "$BUILD" -maxdepth 1 -type f -name '*.log' -delete 2>/dev/null || true mkdir -p "$BUILD" "$PYTHON" "$ROOT/tests/make_fixtures.py" +"$PYTHON" "$ROOT/tests/make_split_fixtures.py" MZ_CFLAGS=(-I"$ROOT/third_party/minizip-ng/include" -DHAVE_ZLIB -DZLIB_COMPAT -D_FILE_OFFSET_BITS=64) RAR_CFLAGS=(-I"$ROOT/third_party/unrar" -DDMC_UNRAR_DISABLE_BE32TOH_BE64TOH=1) @@ -66,6 +67,16 @@ COMPAT_INC="$ROOT/tests/compat" -include "$ROOT/tests/posix_compat.h" \ -o "$BUILD/zip_extract.o" "$ROOT/src/zip_extract.c" +# Volume support: the concatenating stream and the volume set detector. +"$CC" -c -O2 -Wall -Wextra -Wno-unused-parameter \ + -I"$ROOT/third_party/minizip-ng/include" -I"$ROOT/src" -I"$COMPAT_INC" \ + -include "$ROOT/tests/posix_compat.h" \ + -o "$BUILD/zipx_volstream.o" "$ROOT/src/zipx_volstream.c" + +"$CC" -c -O2 -Wall -Wextra -Wno-unused-parameter -I"$ROOT/src" \ + -I"$COMPAT_INC" -include "$ROOT/tests/posix_compat.h" \ + -o "$BUILD/zipx_volume.o" "$ROOT/src/zipx_volume.c" + "$CC" -c -O2 -Wall -Wextra -Wno-unused-parameter \ -I"$ROOT/third_party/minizip-ng/include" -I"$ROOT/third_party/unrar7" -I"$ROOT/src" -I"$COMPAT_INC" \ -include "$ROOT/tests/posix_compat.h" \ diff --git a/tests/test_zip_extract.c b/tests/test_zip_extract.c index 295afd5..1cbecbd 100644 --- a/tests/test_zip_extract.c +++ b/tests/test_zip_extract.c @@ -59,9 +59,23 @@ read_text(const char *path, char *buf, size_t size) { return 0; } +/* Reads binary content (entries may contain NUL bytes, so read_text cannot be + used to verify them). Returns the number of bytes read, or -1. */ +static long +read_bytes(const char *path, unsigned char *buf, size_t size) { + FILE *f = fopen(path, "rb"); + size_t n; + + if(!f) { + return -1; + } + n = fread(buf, 1, size, f); + fclose(f); + return (long)n; +} + static int -remove_dir(const char *path) { - DIR *dir = opendir(path); +remove_dir(const char *path) { DIR *dir = opendir(path); struct dirent *ent; if(!dir) { @@ -199,6 +213,111 @@ expect_status(zipx_result_t *res, zipx_status_t status, /**************************************************************************/ +/* Verifies the entries all three split layouts share: the same archive was + sliced three ways, so every extraction has to produce identical content. */ +static void +check_split_content(const char *dst_name, const char *label) { + static unsigned char data[16384]; + char dst[4096]; + char file[4224]; + char buf[512]; + long n; + size_t i; + int ok = 1; + + work_path(dst, sizeof(dst), dst_name); + check(exists(dst), label); + + snprintf(file, sizeof(file), "%s/readme.txt", dst); + n = read_bytes(file, (unsigned char *)buf, sizeof(buf)); + check(n == 440 && !memcmp(buf, "split archive fixture\n", 22), + " readme.txt content"); + + snprintf(file, sizeof(file), "%s/sub/data.bin", dst); + n = read_bytes(file, data, 10240); + if(n != 10240) { + ok = 0; + } else { + for(i = 0; i < 10240; i++) { + if(data[i] != (unsigned char)(i % 256)) { + ok = 0; + break; + } + } + } + check(ok, " sub/data.bin binary content (10 KiB across volumes)"); + + snprintf(file, sizeof(file), "%s/sub/deep/more.bin", dst); + n = read_bytes(file, data, 12000); + check(n == 12000 && data[0] == 'A' && data[3] == 'D', + " sub/deep/more.bin content"); +} + +static void +test_volumes(void) { + zipx_result_t res; + test_ctx_t t = {0}; + char dst[4096]; + + printf("split volumes\n"); + + /* name.zip.001 style (7-Zip byte split), picked from the first volume. */ + expect_ok(&res, run("plain.zip.001", "out_vol_plain_a", ZIPX_CONFLICT_FAIL, + NULL, &t, &res), + "byte split: extract from part 001"); + check_split_content("out_vol_plain_a", " output tree exists"); + + /* Same set, but the user clicked the last volume this time. */ + expect_ok(&res, run("plain.zip.003", "out_vol_plain_b", ZIPX_CONFLICT_FAIL, + NULL, &t, &res), + "byte split: extract from part 003"); + check_split_content("out_vol_plain_b", " output tree exists"); + + /* name.partN.zip style, picked from the middle volume. */ + expect_ok(&res, run("parts.part2.zip", "out_vol_parts", ZIPX_CONFLICT_FAIL, + NULL, &t, &res), + "partN split: extract from part2"); + check_split_content("out_vol_parts", " output tree exists"); + + /* name.z01 ... name.zip: offsets are relative to each disk, so this one + exercises the disk-aware volume stream. */ + expect_ok(&res, run("disks.z01", "out_vol_disks_a", ZIPX_CONFLICT_FAIL, + NULL, &t, &res), + "split disks: extract from z01"); + check_split_content("out_vol_disks_a", " output tree exists"); + + expect_ok(&res, run("disks.zip", "out_vol_disks_b", ZIPX_CONFLICT_FAIL, + NULL, &t, &res), + "split disks: extract from the final volume"); + check_split_content("out_vol_disks_b", " output tree exists"); + + /* A plain archive must still take the ordinary path. */ + expect_ok(&res, run("split_single.zip", "out_vol_single", ZIPX_CONFLICT_FAIL, + NULL, &t, &res), + "plain archive still extracts (no volume set detected)"); + check_split_content("out_vol_single", " output tree exists"); + + /* Broken sets must fail with the exact missing volume, not a vague error. */ + expect_status(&res, run("gap.zip.001", "out_vol_gap", ZIPX_CONFLICT_FAIL, + NULL, &t, &res), + ZIPX_ERR_OPEN, "incomplete set: reported as an open failure"); + check(strstr(res.message, "gap.zip.002") != NULL, + " error names the missing volume"); + printf(" message: %s\n", res.message); + + expect_status(&res, run("broken.zip.001", "out_vol_broken", ZIPX_CONFLICT_FAIL, + NULL, &t, &res), + ZIPX_ERR_OPEN, "lone first volume: reported as an open failure"); + check(strstr(res.message, "no other volumes") != NULL, + " error says the remaining volumes are missing"); + printf(" message: %s\n", res.message); + + work_path(dst, sizeof(dst), "out_vol_gap"); + check(!exists(dst), "nothing was written for the incomplete set"); +} + +/**************************************************************************/ + static void test_basic(void) { zipx_result_t res; @@ -655,6 +774,7 @@ main(int argc, char **argv) { test_cancel(); test_many_files(); test_large_profile(); + test_volumes(); printf("\n%d checks, %d failures\n", g_checks, g_failures); return g_failures ? 1 : 0;