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.
This commit is contained in:
Songlx516 committed 2026-09-12 16:13:20 +08:00
1 parent 01e27f3825
commit da565ccb7c
11 files changed
+1690 -24

No files matched your search

+1 -1
View File
@@ -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/*))
+12
View File
@@ -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;
}
+131 -12
View File
@@ -1,5 +1,6 @@
#include "filemgr.h"
#include <ctype.h>
#include <errno.h>
#include <pthread.h>
#include <stdio.h>
@@ -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;
+96 -9
View File
@@ -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;
+438
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}
+57
View File
@@ -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 <stdint.h>
#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
+601
View File
@@ -0,0 +1,601 @@
/* zipx_volume -- see zipx_volume.h for what this groups and why. */
#include <ctype.h>
#include <dirent.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/stat.h>
#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 "PREFIX<digits>SUFFIX"; 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 PREFIX<digits>SUFFIX, 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;
}
+50
View File
@@ -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 <stdint.h>
#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
+171
View File
@@ -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("<HII", blob, eocd + 10)
entries = []
pos = cd_offset
for _ in range(count):
if blob[pos : pos + 4] != b"PK\x01\x02":
raise SystemExit("fixture build: bad central directory signature")
name_len, extra_len, comment_len = struct.unpack_from("<HHH", blob, pos + 28)
local_offset = struct.unpack_from("<I", blob, pos + 42)[0]
name = blob[pos + 46 : pos + 46 + name_len].decode("utf-8")
entries.append((name, local_offset, pos))
pos += 46 + name_len + extra_len + comment_len
return cd_offset, cd_size, count, entries
def split_into_disks(blob, parts=3):
"""Rewrites offsets so the archive looks like a real zip split disk set.
Entries are packed into `parts - 1` disks; the last disk carries whatever
is left plus the central directory and the end record.
"""
cd_offset, _cd_size, count, entries = read_central_directory(blob)
entries.sort(key=lambda e: e[1])
# Local header size = fixed part + name + extra, so an entry ends where the
# next one begins (or at the central directory for the last one).
limits = []
for index, (_name, local_offset, _cd_pos) in enumerate(entries):
end = entries[index + 1][1] if index + 1 < len(entries) else cd_offset
limits.append((local_offset, end))
target = max(1, cd_offset // (parts - 1))
disk_of_entry = []
disk_starts = [0]
current = 0
for local_offset, end in limits:
if (end - disk_starts[current]) > 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("<H", out, cd_pos + 34, disk)
struct.pack_into("<I", out, cd_pos + 42, local_offset - disk_starts[disk])
last_disk = current
eocd = len(blob) - 22
struct.pack_into("<H", out, eocd + 4, last_disk) # this disk
struct.pack_into("<H", out, eocd + 6, last_disk) # disk with the cd
struct.pack_into("<H", out, eocd + 8, count) # entries on this disk
struct.pack_into("<H", out, eocd + 10, count) # entries in total
struct.pack_into("<I", out, eocd + 16, cd_offset - disk_starts[last_disk])
bounds = disk_starts[1:] + [len(blob)]
return [bytes(out[a:b]) for a, b in zip(disk_starts, bounds)]
def write(path, data):
with open(path, "wb") as handle:
handle.write(data)
print(" %s (%d bytes)" % (os.path.basename(path), len(data)))
def write_set(base, names, chunks):
for name, chunk in zip(names, chunks):
write(os.path.join(OUT, name % base), chunk)
def write_disk_set(base, chunks):
"""Split disks are named name.z01, name.z02, ..., name.zip: the last volume
(the one holding the central directory) drops the numeric suffix."""
for index, chunk in enumerate(chunks):
if index == len(chunks) - 1:
name = "%s.zip" % base
else:
name = "%s.z%02d" % (base, index + 1)
write(os.path.join(OUT, name), chunk)
def main():
if not os.path.isdir(OUT):
os.makedirs(OUT)
for name in os.listdir(OUT):
if name.startswith(OWN_PREFIXES):
os.remove(os.path.join(OUT, name))
plain = build_plain_zip()
third = (len(plain) + 2) // 3
chunks = [plain[0:third], plain[third : 2 * third], plain[2 * third :]]
chunks = [c for c in chunks if c]
print("byte split (name.zip.NNN):")
write_set("plain", ["%s.zip.001", "%s.zip.002", "%s.zip.003"], chunks)
print("byte split (name.partN.zip):")
write_set("parts", ["%s.part1.zip", "%s.part2.zip", "%s.part3.zip"], chunks)
print("zip split disks (name.zNN + name.zip):")
disks = split_into_disks(plain)
write_disk_set("disks", disks)
print("broken sets:")
write(os.path.join(OUT, "broken.zip.001"), chunks[0])
write(os.path.join(OUT, "gap.zip.001"), chunks[0])
write(os.path.join(OUT, "gap.zip.003"), chunks[2] if len(chunks) > 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()
+11
View File
@@ -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" \
+122 -2
View File
@@ -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;