Files
LisherSong--ps5-web-file-ma…/tests/test_zip_extract.c
T
Songlx516 2a346d694c relax(extract): default limits 256 GiB -> 512 GiB / 1 TiB -> 2 TiB; large 2 TiB -> 4 TiB; threshold 240 GiB -> 480 GiB (v1.8.2)
A 3A-game archive with a single ~300 GiB uncompressed file was silently
rejected by the v1.8.1 default profile (scan_archive returns
ZIPX_ERR_LIMIT_FILE_SIZE before the request ever reaches the frontend
confirmation prompt, so the user just sees "卡壳"). The 256 GiB default
cap was tuned for PS5 system backups (many smaller entries) and was wrong
for the 3A-game single-file case.

Limits changes:
- src/zip_extract.c k_default_limits: 1 TiB / 256 GiB / 500 -> 2 TiB / 512 GiB / 500
- src/zip_extract.c k_large_limits:   2 TiB / 1 TiB   / 1000 -> 4 TiB / 1 TiB / 1000 (must stay > default)
- assets/main.js LARGE_FILE_THRESHOLD_BYTES: 240 GiB -> 480 GiB
- assets/lang-{en,zh}.js extractLargeAsk copy reflects new numbers
- tests/test_zip_extract.c: large.max_total_bytes advertised-number assertion updated to 4 TiB
- README.md / docs/HANDOVER.md: limit tables, FAQ, threshold snippets updated
- CHANGELOG.md: v1.8.2 entry added above v1.8.1

PS5-only build fixes (caught by WSL cross-compile, host tests pass without):
- Makefile CFLAGS: add -Ithird_party/unrar so src/rar_extract.c can find
  the dmc_unrar_api.h facade header (THIRD_PARTY_CFLAGS already had it
  but the main CFLAGS for project sources did not).
- src/extract.c: move extract_progress() definition before
  extract_dispatch() so the implicit function declaration is not flagged
  by -Werror=implicit-function-declaration (clang 18 in the PS5 SDK is
  stricter than the host gcc used by tests/run-tests.sh).

Release artifact: web-file-mgr.elf 509 704 bytes,
sha256 1b2c3d68b35e32737105f17d14a80a3c159ceca0cabd274ee168cbcd81906f65,
e_machine 0x003e (x86_64-sie-ps5).

Safety argument is unchanged from v1.8.1: zip-bomb defence is
check_space() (statvfs-based real disk-space check before staging) +
max_ratio (declared compression ratio cap). The size caps are a UX guard,
not a security boundary.

RAR extraction inherits the new defaults automatically (rar_extract.c
threads c->limits from the engine).

84 host-side checks (70 ZIP + 14 RAR), 0 failures. PS5 cross-compile
succeeds; ELF size grew from 427 656 (v1.8) to 509 704 (v1.8.2) due to
the dmc_unrar vendor TU being linked in.
2026-09-05 16:10:31 +08:00

643 lines
21 KiB
C

/* Host test suite for the ZIP extraction engine.
Build: see run-tests.sh (uses gcc + the MinGW POSIX shim). */
#include "../src/zip_extract.h"
#include <dirent.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
/* Pulled in by the test build only (see run-tests.sh). */
#include "posix_compat.h"
static const char *g_fixtures;
static char g_work[4096];
static int g_failures;
static int g_checks;
static void
fixture_path(char *out, size_t size, const char *name) {
snprintf(out, size, "%s/%s", g_fixtures, name);
}
static void
work_path(char *out, size_t size, const char *name) {
snprintf(out, size, "%s/%s", g_work, name);
}
static void
check(int ok, const char *what) {
g_checks++;
if(!ok) {
g_failures++;
printf(" FAIL %s\n", what);
}
}
static int
exists(const char *path) {
struct stat st;
return !stat(path, &st);
}
static int
read_text(const char *path, char *buf, size_t size) {
FILE *f = fopen(path, "rb");
size_t n;
if(!f) {
return -1;
}
n = fread(buf, 1, size - 1, f);
buf[n] = 0;
fclose(f);
return 0;
}
static int
remove_dir(const char *path) {
DIR *dir = opendir(path);
struct dirent *ent;
if(!dir) {
return rmdir(path);
}
while((ent = readdir(dir))) {
char child[4096];
struct stat st;
if(!strcmp(ent->d_name, ".") || !strcmp(ent->d_name, "..")) {
continue;
}
snprintf(child, sizeof(child), "%s/%s", path, ent->d_name);
if(!stat(child, &st) && S_ISDIR(st.st_mode)) {
remove_dir(child);
} else {
unlink(child);
}
}
closedir(dir);
return rmdir(path);
}
static int
make_dirs(const char *path) {
char buf[4096];
char *slash;
struct stat st;
if(!*path || !stat(path, &st)) {
return 0;
}
snprintf(buf, sizeof(buf), "%s", path);
for(slash = buf + 1; *slash; slash++) {
if(*slash != '/') {
continue;
}
*slash = 0;
if(mkdir(buf, 0777) && errno != EEXIST) {
return -1;
}
*slash = '/';
}
return mkdir(buf, 0777) && errno != EEXIST ? -1 : 0;
}
static int
write_text(const char *path, const char *content) {
FILE *f = fopen(path, "wb");
if(!f) {
return -1;
}
fputs(content, f);
fclose(f);
return 0;
}
static int
count_staging_leftovers(const char *dir) {
DIR *d = opendir(dir);
struct dirent *ent;
int count = 0;
if(!d) {
return 0;
}
while((ent = readdir(d))) {
if(!strncmp(ent->d_name, ".wfm-extract-", 13)) {
count++;
}
}
closedir(d);
return count;
}
/**************************************************************************/
typedef struct {
int cancel_after_progress;
zipx_progress_t last;
int reports;
char last_current[512];
} test_ctx_t;
static int
cb_cancel(void *userdata) {
test_ctx_t *t = userdata;
return t->cancel_after_progress && t->reports >= t->cancel_after_progress;
}
static void
cb_progress(void *userdata, const zipx_progress_t *p) {
test_ctx_t *t = userdata;
t->reports++;
t->last = *p;
if(p->current) {
snprintf(t->last_current, sizeof(t->last_current), "%s", p->current);
}
}
static zipx_status_t
run(const char *fixture, const char *dst_name, zipx_conflict_t conflict,
const zipx_limits_t *limits, test_ctx_t *t, zipx_result_t *res) {
char zip[4096];
char dst[4096];
fixture_path(zip, sizeof(zip), fixture);
work_path(dst, sizeof(dst), dst_name);
return zipx_extract(zip, dst, conflict, limits, cb_cancel, cb_progress,
t, res);
}
static void
expect_ok(zipx_result_t *res, zipx_status_t status, const char *label) {
check(status == ZIPX_OK, label);
if(status != ZIPX_OK) {
printf(" status=%s (%d) %s / %s\n", zipx_status_string(status),
status, res->message, res->detail);
}
}
static void
expect_status(zipx_result_t *res, zipx_status_t status,
zipx_status_t expected, const char *label) {
check(status == expected, label);
if(status != expected) {
printf(" expected %s, got %s (%d) %s / %s\n",
zipx_status_string(expected), zipx_status_string(status), status,
res->message, res->detail);
}
}
/**************************************************************************/
static void
test_basic(void) {
zipx_result_t res;
test_ctx_t t = {0};
char dst[4096];
char file[4224];
char buf[64];
printf("basic (deflate + nested dirs + empty dir)\n");
expect_ok(&res, run("basic.zip", "out_basic", ZIPX_CONFLICT_FAIL, NULL, &t, &res),
"extract succeeds");
work_path(dst, sizeof(dst), "out_basic");
check(exists(dst), "destination created");
snprintf(file, sizeof(file), "%s/root.txt", dst);
check(!read_text(file, buf, sizeof(buf)) && !strcmp(buf, "root content"),
"root.txt content");
snprintf(file, sizeof(file), "%s/dir/nested.txt", dst);
check(!read_text(file, buf, sizeof(buf)) && !strcmp(buf, "nested content"),
"nested.txt content");
snprintf(file, sizeof(file), "%s/dir/deep/deeper.txt", dst);
check(!read_text(file, buf, sizeof(buf)) && !strcmp(buf, "deeper content"),
"deeper.txt content");
snprintf(file, sizeof(file), "%s/empty_dir", dst);
check(exists(file), "empty directory created");
check(res.entries_total == 4, "entry count reported");
check(res.bytes_total > 0, "byte total reported");
check(count_staging_leftovers(dst) == 0, "no staging leftovers inside dst");
}
static void
test_stored(void) {
zipx_result_t res;
test_ctx_t t = {0};
char dst[4096];
char file[4224];
char buf[2048];
size_t i;
int ok = 1;
printf("stored (no compression)\n");
expect_ok(&res, run("stored.zip", "out_stored", ZIPX_CONFLICT_FAIL, NULL, &t, &res),
"extract succeeds");
work_path(dst, sizeof(dst), "out_stored");
snprintf(file, sizeof(file), "%s/stored.txt", dst);
if(read_text(file, buf, sizeof(buf))) {
ok = 0;
} else {
for(i = 0; i < 1400; i++) {
if(buf[i] != "stored content"[i % 14]) {
ok = 0;
break;
}
}
}
check(ok, "stored content matches");
}
static void
test_zip64(void) {
zipx_result_t res;
test_ctx_t t = {0};
char dst[4096];
char file[4224];
struct stat st;
printf("zip64\n");
expect_ok(&res, run("zip64.zip", "out_zip64", ZIPX_CONFLICT_FAIL, NULL, &t, &res),
"extract succeeds");
work_path(dst, sizeof(dst), "out_zip64");
snprintf(file, sizeof(file), "%s/big.bin", dst);
check(!stat(file, &st) && st.st_size == 4096, "zip64 size");
}
static void
test_unicode(void) {
zipx_result_t res;
test_ctx_t t = {0};
char dst[4096];
char file[4224];
char buf[64];
printf("utf-8 entry names\n");
expect_ok(&res, run("unicode.zip", "out_unicode", ZIPX_CONFLICT_FAIL, NULL,
&t, &res), "extract succeeds");
work_path(dst, sizeof(dst), "out_unicode");
snprintf(file, sizeof(file), "%s/\xe4\xb8\xad\xe6\x96\x87\xe7\x9b\xae\xe5\xbd\x95/\xe6\x96\x87\xe4\xbb\xb6.txt", dst);
check(!read_text(file, buf, sizeof(buf)) &&
!strcmp(buf, "unicode content"), "chinese path content");
}
static void
test_conflict_fail(void) {
zipx_result_t res;
test_ctx_t t = {0};
char dst[4096];
char file[4224];
char buf[64];
printf("conflict policy: fail\n");
work_path(dst, sizeof(dst), "out_conflict_fail");
remove_dir(dst);
make_dirs(dst);
snprintf(file, sizeof(file), "%s/shared.txt", dst);
write_text(file, "original");
expect_status(&res, run("conflict.zip", "out_conflict_fail",
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
ZIPX_ERR_CONFLICT, "existing file fails the task");
snprintf(file, sizeof(file), "%s/shared.txt", dst);
check(!read_text(file, buf, sizeof(buf)) && !strcmp(buf, "original"),
"existing file untouched");
snprintf(file, sizeof(file), "%s/shareddir", dst);
check(!exists(file), "nothing published");
}
static void
test_conflict_overwrite(void) {
zipx_result_t res;
test_ctx_t t = {0};
char dst[4096];
char file[4224];
char buf[64];
printf("conflict policy: overwrite\n");
work_path(dst, sizeof(dst), "out_overwrite");
remove_dir(dst);
make_dirs(dst);
/* An existing directory where the archive has a file must still conflict,
even under OVERWRITE (a directory is never replaced by a file). */
snprintf(file, sizeof(file), "%s/shared.txt", dst);
make_dirs(file);
expect_status(&res, run("conflict.zip", "out_overwrite",
ZIPX_CONFLICT_OVERWRITE, NULL, &t, &res),
ZIPX_ERR_CONFLICT, "existing directory still blocks overwrite");
snprintf(file, sizeof(file), "%s/shared.txt", dst);
check(exists(file), "directory untouched while it conflicts");
remove_dir(dst);
make_dirs(dst);
snprintf(file, sizeof(file), "%s/shared.txt", dst);
write_text(file, "original");
snprintf(file, sizeof(file), "%s/shareddir", dst);
remove_dir(file);
expect_ok(&res, run("conflict.zip", "out_overwrite",
ZIPX_CONFLICT_OVERWRITE, NULL, &t, &res),
"overwrite succeeds without a directory clash");
snprintf(file, sizeof(file), "%s/shared.txt", dst);
check(!read_text(file, buf, sizeof(buf)) && !strcmp(buf, "from zip"),
"file replaced");
}
static void
test_conflict_merge(void) {
zipx_result_t res;
test_ctx_t t = {0};
char dst[4096];
char file[4224];
char buf[64];
printf("conflict policy: merge\n");
work_path(dst, sizeof(dst), "out_merge");
remove_dir(dst);
make_dirs(dst);
snprintf(file, sizeof(file), "%s/shareddir", dst);
make_dirs(file);
snprintf(file, sizeof(file), "%s/shareddir/inner.txt", dst);
check(!write_text(file, "original inner"), "prepare merge destination");
expect_ok(&res, run("conflict.zip", "out_merge", ZIPX_CONFLICT_MERGE, NULL,
&t, &res), "merge succeeds");
snprintf(file, sizeof(file), "%s/shareddir/inner.txt", dst);
check(!read_text(file, buf, sizeof(buf)) && !strcmp(buf, "original inner"),
"existing file preserved by merge");
snprintf(file, sizeof(file), "%s/shareddir/added.txt", dst);
check(!read_text(file, buf, sizeof(buf)) &&
!strcmp(buf, "added from zip"), "new sibling merged in");
snprintf(file, sizeof(file), "%s/shared.txt", dst);
check(!read_text(file, buf, sizeof(buf)) && !strcmp(buf, "from zip"),
"top level file published");
}
static void
test_unsafe_names(void) {
zipx_result_t res;
test_ctx_t t = {0};
char parent[4096];
char file[4224];
printf("path traversal variants\n");
expect_status(&res, run("traversal.zip", "out_traversal",
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
ZIPX_ERR_UNSAFE_NAME, "../ entry rejected");
expect_status(&res, run("traversal_bs.zip", "out_traversal_bs",
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
ZIPX_ERR_UNSAFE_NAME, "..\\ entry rejected");
expect_status(&res, run("absolute.zip", "out_absolute",
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
ZIPX_ERR_UNSAFE_NAME, "absolute entry rejected");
expect_status(&res, run("drive.zip", "out_drive", ZIPX_CONFLICT_FAIL, NULL,
&t, &res),
ZIPX_ERR_UNSAFE_NAME, "drive letter entry rejected");
work_path(parent, sizeof(parent), ".");
snprintf(file, sizeof(file), "%s/evil.txt", parent);
check(!exists(file), "no file escaped into the work directory");
snprintf(file, sizeof(file), "%s/out_traversal", parent);
check(!exists(file), "no destination created for a rejected archive");
}
static void
test_duplicates(void) {
zipx_result_t res;
test_ctx_t t = {0};
printf("duplicate and clashing entries\n");
expect_status(&res, run("duplicate.zip", "out_duplicate",
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
ZIPX_ERR_DUPLICATE, "duplicate file rejected");
expect_status(&res, run("clash.zip", "out_clash", ZIPX_CONFLICT_FAIL, NULL,
&t, &res),
ZIPX_ERR_DUPLICATE, "file used as a directory rejected");
}
static void
test_special_entries(void) {
zipx_result_t res;
test_ctx_t t = {0};
printf("symlink and fifo entries\n");
expect_status(&res, run("symlink.zip", "out_symlink", ZIPX_CONFLICT_FAIL,
NULL, &t, &res),
ZIPX_ERR_SPECIAL, "symlink rejected");
expect_status(&res, run("fifo.zip", "out_fifo", ZIPX_CONFLICT_FAIL, NULL,
&t, &res),
ZIPX_ERR_SPECIAL, "fifo rejected");
}
static void
test_unsupported(void) {
zipx_result_t res;
test_ctx_t t = {0};
const zipx_limits_t *base = zipx_default_limits();
zipx_limits_t limits;
printf("encrypted, corrupt and truncated archives\n");
expect_status(&res, run("encrypted.zip", "out_encrypted",
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
ZIPX_ERR_UNSUPPORTED, "encrypted entry rejected");
expect_status(&res, run("bad_crc.zip", "out_bad_crc", ZIPX_CONFLICT_FAIL,
NULL, &t, &res),
ZIPX_ERR_CRC, "crc mismatch detected");
check(run("truncated.zip", "out_truncated", ZIPX_CONFLICT_FAIL, NULL, &t, &res) !=
ZIPX_OK, "truncated archive rejected");
check(run("notazip.zip", "out_notazip", ZIPX_CONFLICT_FAIL, NULL, &t, &res) !=
ZIPX_OK, "non-zip file rejected");
printf("limits\n");
limits = *base;
limits.max_entries = 2;
expect_status(&res, run("many.zip", "out_limit_entries",
ZIPX_CONFLICT_FAIL, &limits, &t, &res),
ZIPX_ERR_LIMIT_ENTRIES, "entry limit enforced");
limits = *base;
limits.max_ratio = 10;
expect_status(&res, run("bomb.zip", "out_limit_ratio", ZIPX_CONFLICT_FAIL,
&limits, &t, &res),
ZIPX_ERR_LIMIT_RATIO, "compression ratio limit enforced");
limits = *base;
limits.max_file_bytes = 1024;
expect_status(&res, run("zip64.zip", "out_limit_file", ZIPX_CONFLICT_FAIL,
&limits, &t, &res),
ZIPX_ERR_LIMIT_FILE, "single file limit enforced");
limits = *base;
limits.max_total_bytes = 1000; /* many.zip totals ~1390 bytes */
expect_status(&res, run("many.zip", "out_limit_total", ZIPX_CONFLICT_FAIL,
&limits, &t, &res),
ZIPX_ERR_LIMIT_TOTAL, "total size limit enforced");
limits = *base;
limits.max_depth = 1;
expect_status(&res, run("basic.zip", "out_limit_depth", ZIPX_CONFLICT_FAIL,
&limits, &t, &res),
ZIPX_ERR_LIMIT_DEPTH, "depth limit enforced");
}
static void
test_cancel(void) {
zipx_result_t res;
test_ctx_t t;
char dst[4096];
printf("cancel leaves nothing behind\n");
memset(&t, 0, sizeof(t));
t.cancel_after_progress = 1;
expect_status(&res, run("many.zip", "out_cancel", ZIPX_CONFLICT_FAIL, NULL,
&t, &res),
ZIPX_ERR_CANCELED, "cancel honored");
work_path(dst, sizeof(dst), "out_cancel");
check(!exists(dst), "no destination after cancel");
work_path(dst, sizeof(dst), ".");
check(count_staging_leftovers(dst) == 0, "no staging left after cancel");
}
static void
test_many_files(void) {
zipx_result_t res;
test_ctx_t t = {0};
char dst[4096];
char file[4224];
int missing = 0;
int i;
printf("500 file archive\n");
expect_ok(&res, run("many.zip", "out_many", ZIPX_CONFLICT_FAIL, NULL, &t, &res),
"extract succeeds");
work_path(dst, sizeof(dst), "out_many");
for(i = 0; i < 500; i++) {
snprintf(file, sizeof(file), "%s/many/f%03d.txt", dst, i);
if(!exists(file)) {
missing++;
}
}
check(!missing, "all 500 files present");
check(res.entries_total == 500, "entry count reported");
check(res.bytes_total > 0, "byte total reported");
}
static void
test_large_profile(void) {
zipx_result_t res;
test_ctx_t t = {0};
const zipx_limits_t *base = zipx_default_limits();
const zipx_limits_t *large = zipx_limits_profile(ZIPX_LIMITS_LARGE);
zipx_limits_t tight;
printf("large-file profile\n");
/* The profile must exist and be a real struct, distinct from default. */
check(large != NULL, "large profile returned");
check(large != base, "large profile differs from default");
check(zipx_limits_profile(ZIPX_LIMITS_DEFAULT) == base,
"ZIPX_LIMITS_DEFAULT == zipx_default_limits()");
/* Every cap in the large profile must be at least as large as the default
cap. The profile is strictly an upper bound, never a tighter one. */
check(large->max_entries > base->max_entries,
"max_entries greater than default");
check(large->max_total_bytes > base->max_total_bytes,
"max_total_bytes greater than default");
check(large->max_file_bytes > base->max_file_bytes,
"max_file_bytes greater than default");
check(large->max_ratio > base->max_ratio,
"max_ratio greater than default");
/* Concrete advertised numbers. If anyone ever changes the profile these
assertions keep the public promise honest. */
check(large->max_entries == 500000, "max_entries == 500000");
check(large->max_file_bytes == 1ULL * 1024 * 1024 * 1024 * 1024,
"max_file_bytes == 1 TiB");
check(large->max_total_bytes == 4ULL * 1024 * 1024 * 1024 * 1024,
"max_total_bytes == 4 TiB");
check(large->max_ratio == 1000, "max_ratio == 1000");
/* Behaviour: medium_bomb.zip is 1 MiB of 0..255 cycled, compressing to
~4 KiB (ratio ~238). Default ratio cap 500 accepts it; large ratio
cap 1000 also accepts it. This shows that real-world high-ratio
archives (think raw image dumps, fat binaries) are not artificially
blocked by the relaxed default. */
printf("ratio cap\n");
expect_ok(&res, run("medium_bomb.zip", "out_ratio_medium_default",
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
"default ratio cap (500) accepts medium_bomb.zip (~238:1)");
expect_ok(&res, run("medium_bomb.zip", "out_ratio_medium_large",
ZIPX_CONFLICT_FAIL, large, &t, &res),
"large ratio cap (1000) accepts medium_bomb.zip (~238:1)");
/* bomb.zip is 4 MiB of identical 'A' bytes, compressing to ~4 KiB
(ratio ~1026). Both default cap 500 and large cap 1000 reject it.
A bomb is a bomb regardless of which profile you opt into. */
expect_status(&res, run("bomb.zip", "out_ratio_bomb_default",
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
ZIPX_ERR_LIMIT_RATIO,
"default ratio cap (500) rejects bomb.zip (~1026:1)");
expect_status(&res, run("bomb.zip", "out_ratio_bomb_large",
ZIPX_CONFLICT_FAIL, large, &t, &res),
ZIPX_ERR_LIMIT_RATIO,
"large ratio cap (1000) rejects bomb.zip (~1026:1)");
/* Lowering the user's chosen ratio below the medium bomb's actual
ratio still rejects the archive. The caps are still enforced; the
profile just starts at a higher number. */
tight = *large;
tight.max_ratio = 200;
expect_status(&res, run("medium_bomb.zip", "out_ratio_medium_tight",
ZIPX_CONFLICT_FAIL, &tight, &t, &res),
ZIPX_ERR_LIMIT_RATIO,
"user-lowered ratio (200) rejects medium_bomb.zip (~238:1)");
/* Lowering the large profile's file cap below zip64.zip's 4 KiB still
rejects the archive. */
tight = *large;
tight.max_file_bytes = 1024;
expect_status(&res, run("zip64.zip", "out_large_file_cap",
ZIPX_CONFLICT_FAIL, &tight, &t, &res),
ZIPX_ERR_LIMIT_FILE,
"lowered large file cap still enforced");
}
int
main(int argc, char **argv) {
if(argc < 3) {
fprintf(stderr, "usage: %s <fixtures-dir> <work-dir>\n", argv[0]);
return 2;
}
g_fixtures = argv[1];
snprintf(g_work, sizeof(g_work), "%s", argv[2]);
remove_dir(g_work);
if(make_dirs(g_work)) {
fprintf(stderr, "cannot create work dir\n");
return 2;
}
test_basic();
test_stored();
test_zip64();
test_unicode();
test_conflict_fail();
test_conflict_overwrite();
test_conflict_merge();
test_unsafe_names();
test_duplicates();
test_special_entries();
test_unsupported();
test_cancel();
test_many_files();
test_large_profile();
printf("\n%d checks, %d failures\n", g_checks, g_failures);
return g_failures ? 1 : 0;
}