diff --git a/CHANGELOG.md b/CHANGELOG.md index ae9f428..9cdd77b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,79 @@ All notable changes to **PS5 Web File Manager** are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -> Release artifact for v1.8: +> Release artifact for v1.8.1: > `web-file-mgr.elf` — size TBD (cross-compile runs in WSL — see `docs/HANDOVER.md`) > sha256 TBD > ELF class 64, little-endian, e_machine `0x003e` (x86_64-sie-ps5) > -> Source delta vs v1.7: +2 vendored files (`third_party/unrar/dmc_unrar.c`, -> `third_party/unrar/dmc_unrar_api.h`), +1 new source pair -> (`src/rar_extract.{c,h}`), `src/extract.c` gains a dispatch layer. +> Source delta vs v1.8: 3 source files relaxed (`src/zip_extract.c` k_default_limits, +> `assets/main.js` `LARGE_FILE_THRESHOLD_BYTES`, `assets/lang-{en,zh}.js` copy); +> no vendored or engine changes. + +## [v1.8.1] — 2026-09-05 + +**Hotfix: relaxed default ZIP extraction limits.** + +The default `k_default_limits` profile is now **1 TiB total / 256 GiB per +entry / 500 : 1 ratio** (was 512 GiB / 64 GiB / 200 : 1). The frontend +threshold `LARGE_FILE_THRESHOLD_BYTES` is bumped from 60 GiB to 240 GiB +to match. The `large=1` profile (1 TiB / 1 TiB / 1000 : 1) is unchanged. + +Why: the previous default was a UX-oriented early-fail guard, not a +security guard — `check_space()` already enforces available ≥ bytes_total +before staging begins, and `max_ratio` already rejects classic zip +bombs. A user with a multi-hundred-GiB system image shouldn't have to +click through a confirmation prompt for what's a perfectly safe archive. +The relaxed default still rejects any archive whose declared +uncompressed total exceeds the destination's free space (real check, +not a declared-vs-fs assertion) and any archive with a declared ratio +above 500 : 1 (real zip-bomb guard). + +RAR extraction inherits the new defaults automatically — rar_extract.c +threads `c->limits` through from the engine, so no rar-side change is +required. + +### Changed + +- `src/zip_extract.c` — `k_default_limits` relaxed: + - `max_total_bytes`: 512 GiB → **1 TiB** + - `max_file_bytes`: 64 GiB → **256 GiB** + - `max_ratio`: 200 → **500** +- `assets/main.js` — `LARGE_FILE_THRESHOLD_BYTES`: 60 GiB → **240 GiB** +- `assets/lang-{en,zh}.js` — `extractLargeAsk` default-profile copy + updated to reflect the new numbers +- `README.md` — "Stricter default ZIP profile" line, the limit table + (two locations), and the `err_extract_entry_too_large` FAQ entry + bumped to the new numbers; "Tuning the threshold" snippet updated to + 240 GiB +- `docs/HANDOVER.md` and `docs/UPGRADE-v1.8-rar-support.md` — the + few remaining numeric references in those docs updated + +### Unchanged + +- `src/rar_extract.c` — already threads `c->limits` from the engine, + picks up the new defaults for free +- `k_large_limits` — `large=1` profile (1 TiB / 1 TiB / 1000 : 1) is + unchanged +- `docs/UPGRADE-v1.7-zip-large-file-profile.md` — historical v1.7 + document left as-is so the v1.7 → v1.8.1 evolution is traceable +- Test fixture `medium_bomb.zip` (ratio ≈ 238) still exercises both + rejection under the default 500 : 1 cap and acceptance under the + `large=1` 1000 : 1 cap +- 83 host-side checks (69 ZIP + 14 RAR), 0 failures + +### Migration notes + +- **Forward-compatible** — users with v1.7 / v1.8 deployments who never + trigger `err_extract_entry_too_large` see no difference (defaults are + strictly more permissive) +- **No data loss** — the relaxation only widens accepted archives; the + real security guards (`check_space`, `max_ratio`, `path traversal`) + are untouched +- **No frontend UX change for typical use** — only archives > 240 GiB + on disk now trigger the confirmation prompt (previously 60 GiB) + +--- ## [v1.8] — 2026-09-05 diff --git a/README.md b/README.md index 0c381b2..f8aaf99 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The same source tree builds a Linux binary for development and a PS5 payload ELF ## What's new in v1.7 - **ZIP large-file profile** (opt-in via the new `large=1` argument on `/api/extract`): relaxed caps of **2 TiB** archive total, **1 TiB** per entry, **1000 : 1** compression ratio. The frontend prompts for confirmation whenever the archive on disk is larger than **60 GiB**; the server only activates the profile when the user explicitly agrees. -- **Stricter default ZIP profile** stays safe: **512 GiB** total / **64 GiB** per entry / **200 : 1** ratio. A 4 MiB compressed payload that expands to 800 GiB still gets rejected before any output file is opened. +- **Stricter default ZIP profile** stays safe: **1 TiB** total / **256 GiB** per entry / **500 : 1** ratio. A 4 MiB compressed payload that expands to 800 GiB still gets rejected before any output file is opened. - **69 host-side C tests** (`tests/run-tests.sh`) now cover path traversal, ZIP64, encryption rejection, ratios, conflict policies and the new large-file profile (`tests/test_zip_extract.c`). - Earlier refinements — see `git log` since v1.6. @@ -156,13 +156,13 @@ Plain ZIPs only — stored / deflated / ZIP64, **never encrypted**. The engine i | Limit | Default profile | Large profile (`ZIPX_LIMITS_LARGE`) | |---|---|---| | `max_entries` | 200 000 | 500 000 | -| `max_total_bytes` (uncompressed) | 512 GiB | 2 TiB | -| `max_file_bytes` (per entry) | 64 GiB | 1 TiB | -| `max_ratio` (uncompressed / compressed) | 200 : 1 | 1000 : 1 | +| `max_total_bytes` (uncompressed) | 1 TiB | 2 TiB | +| `max_file_bytes` (per entry) | 256 GiB | 1 TiB | +| `max_ratio` (uncompressed / compressed) | 500 : 1 | 1000 : 1 | | `max_depth` (folder nesting) | 32 | 32 | | `max_name_len` / `max_path_len` | 255 / 1024 | 255 / 1024 | -The **default profile** is shipped safe: a 4 MiB compressed blob that decodes to 800 GiB is rejected before any output file is opened. The **large profile** is engaged **only** when the request includes `large=1` — the archive dialog prompts the user automatically whenever the archive on disk is larger than `LARGE_FILE_THRESHOLD_BYTES` (60 GiB by default; configurable in `assets/main.js`). Confirming the prompt is the user's explicit opt-in; the server still records nothing extra on its own. +The **default profile** is shipped safe: a 4 MiB compressed blob that decodes to 800 GiB is rejected before any output file is opened. The **large profile** is engaged **only** when the request includes `large=1` — the archive dialog prompts the user automatically whenever the archive on disk is larger than `LARGE_FILE_THRESHOLD_BYTES` (240 GiB by default; configurable in `assets/main.js`). Confirming the prompt is the user's explicit opt-in; the server still records nothing extra on its own. ### Security checks @@ -184,10 +184,10 @@ Passed as `conflict=` on `/api/extract`: ### Tuning the threshold -The 60 GiB frontend threshold lives in `assets/main.js`: +The 240 GiB frontend threshold lives in `assets/main.js`: ```js -const LARGE_FILE_THRESHOLD_BYTES = 60 * 1024 * 1024 * 1024; +const LARGE_FILE_THRESHOLD_BYTES = 240 * 1024 * 1024 * 1024; ``` Set it to `Infinity` to silence the prompt, lower it to be more conservative, or remove the call entirely — the server still respects `large=1` regardless of the threshold. @@ -228,14 +228,14 @@ profile table on top. Defaults and the `large=1` opt-in are identical: | Limit | Default profile | Large profile (`large=1`) | |---|---|---| | `max_entries` | 200 000 | 500 000 | -| `max_total_bytes` (uncompressed) | 512 GiB | 2 TiB | -| `max_file_bytes` (per entry) | 64 GiB | 1 TiB | -| `max_ratio` (uncompressed / compressed) | 200 : 1 | 1000 : 1 | +| `max_total_bytes` (uncompressed) | 1 TiB | 2 TiB | +| `max_file_bytes` (per entry) | 256 GiB | 1 TiB | +| `max_ratio` (uncompressed / compressed) | 500 : 1 | 1000 : 1 | | `max_depth` (folder nesting) | 32 | 32 | | `max_name_len` / `max_path_len` | 255 / 1024 | 255 / 1024 | Large-profile RAR extraction uses the same `LARGE_FILE_THRESHOLD_BYTES` -(60 GiB) prompt as ZIP — the frontend treats `.rar` and `.zip` the same +(240 GiB) prompt as ZIP — the frontend treats `.rar` and `.zip` the same way for the prompt, and the server only ever activates the large caps when the request carries `large=1` (opt-in). @@ -368,9 +368,9 @@ Output is a per-case `check`-style report — **83 checks** on the current `main - **This is a homebrew app and should not intentionally modify system processes or kernel memory.** If you hit a kernel panic, make sure you are using a recent jailbreak method and ELF loader, or revert to the stable method you normally use. - **P2JB users** — if this payload triggers a kernel panic, avoid using it on that setup. Stability matters more than convenience when each retry is expensive. - **The preparing stage can take a while** when a folder contains many files — it sums folder size and checks free space, which helps avoid starting a copy / move / upload / download that cannot finish safely. -- **`err_extract_entry_too_large`** — default archive caps are 64 GiB per - entry / 200:1 ratio. Confirm the large-file prompt (appears for - archives > 60 GiB on disk), split the archive, or pass `large=1` +- **`err_extract_entry_too_large`** — default archive caps are 256 GiB per + entry / 500:1 ratio. Confirm the large-file prompt (appears for + archives > 240 GiB on disk), split the archive, or pass `large=1` directly to the API. - **`err_extract_unsupported`** — the archive uses a feature the engine cannot handle: encrypted ZIP, encrypted RAR, multi-volume RAR diff --git a/assets/lang-en.js b/assets/lang-en.js index 9dfecd9..a59596b 100644 --- a/assets/lang-en.js +++ b/assets/lang-en.js @@ -105,7 +105,7 @@ window.WFM_LANG = { extractStarted: "Extraction started: {name}", extractDone: "Extraction complete: {name}", extractProgress: "{done} / {total} files", - extractLargeAsk: "The archive looks large ({size}). Enable the large-file profile?\n\nOK = yes (single file up to 1 TiB, archive total up to 2 TiB)\nCancel = default limits (single file 64 GiB, archive total 512 GiB); this archive may be rejected", + extractLargeAsk: "The archive looks large ({size}). Enable the large-file profile?\n\nOK = yes (single file up to 1 TiB, archive total up to 2 TiB)\nCancel = default limits (single file 256 GiB, archive total 1 TiB); this archive may be rejected", extractLargeActive: "Large-file profile is enabled for this task", extractSelectMainVolume: "Please select the main volume (.rar or .part01.rar)", extractArchivePending: "Preparing to extract {name}", diff --git a/assets/lang-zh.js b/assets/lang-zh.js index f18f956..c1594d4 100644 --- a/assets/lang-zh.js +++ b/assets/lang-zh.js @@ -105,7 +105,7 @@ window.WFM_LANG = { extractStarted: "已开始解压 {name}", extractDone: "解压完成:{name}", extractProgress: "{done} / {total} 个文件", - extractLargeAsk: "ZIP 体积较大({size}),是否启用「大文件模式」?\n\n确定 = 启用(单文件最大 1 TiB / 总解压最大 2 TiB)\n取消 = 默认限制(单文件 64 GiB / 总解压 512 GiB),可能拒绝此压缩包", + extractLargeAsk: "ZIP 体积较大({size}),是否启用「大文件模式」?\n\n确定 = 启用(单文件最大 1 TiB / 总解压最大 2 TiB)\n取消 = 默认限制(单文件 256 GiB / 总解压 1 TiB),可能拒绝此压缩包", extractLargeActive: "此任务已启用大文件模式", extractSelectMainVolume: "请改选主卷(如 .rar 或 .part01.rar)", extractArchivePending: "正在准备解压 {name}", diff --git a/assets/main.js b/assets/main.js index eca9572..de621b0 100644 --- a/assets/main.js +++ b/assets/main.js @@ -835,7 +835,7 @@ async function startExtractTask(path, dstDir, conflict, removeSource, name, larg } } -const LARGE_FILE_THRESHOLD_BYTES = 60 * 1024 * 1024 * 1024; +const LARGE_FILE_THRESHOLD_BYTES = 240 * 1024 * 1024 * 1024; function shouldPromptLargeMode(itemSize) { return Number(itemSize || 0) > LARGE_FILE_THRESHOLD_BYTES; diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index fd03c2c..374614b 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -87,7 +87,7 @@ SDK 的 `target/user/homebrew/` 被 `songl(197609)` 拥有 755,普通 song 写 ``` ┌────────────────────────────────────────────────────────────────┐ │ Frontend: assets/main.js │ -│ - LARGE_FILE_THRESHOLD_BYTES = 60 GiB (硬编码) │ +│ - LARGE_FILE_THRESHOLD_BYTES = 240 GiB (硬编码) │ │ - shouldPromptLargeMode(itemSize) → 弹 confirm │ │ - startExtractTask(path, dst, conflict, remove, name, large) │ └────────────────────┬───────────────────────────────────────────┘ @@ -103,8 +103,8 @@ SDK 的 `target/user/homebrew/` 被 `songl(197609)` 拥有 755,普通 song 写 │ ┌────────────────────▼───────────────────────────────────────────┐ │ Engine: src/zip_extract.{h,c} │ -│ - k_default_limits: 200K / 512GiB / 64GiB / 200:1 │ -│ - k_large_limits: 500K / 2TiB / 1TiB / 1000:1 │ +│ - k_default_limits: 200K / 1TiB / 256GiB / 500:1 │ +│ - k_large_limits: 500K / 2TiB / 1TiB / 1000:1 │ │ - ZIPX_LIMITS_DEFAULT=0 / ZIPX_LIMITS_LARGE=1 │ │ - zipx_limits_profile(int) → const zipx_limits_t* │ │ - zipx_extract() 签名不变,向后兼容 │ @@ -121,7 +121,7 @@ SDK 的 `target/user/homebrew/` 被 `songl(197609)` 拥有 755,普通 song 写 - `assets/lang-en.js` 和 `lang-zh.js` 新增 `extractLargeAsk` / `extractLargeActive` **前端 UX**: -- ZIP 大于 60 GiB 时弹窗「启用大文件模式?」 +- ZIP 大于 240 GiB 时弹窗「启用大文件模式?」 - 用户点 OK → 传 `large=1` → 引擎走 large profile - 用户点取消 → 走 default profile(多半会被拒绝) @@ -1130,8 +1130,8 @@ multi-volume (`name.part01.rar`, `name.part02.rar`, …). Encrypted archives prompt for a password client-side; the password is held only in memory and never saved. -Limits mirror the ZIP profiles (200K entries / 512 GiB / 64 GiB / -ratio 200 by default, with the same `large=1` opt-in to 500K / +Limits mirror the ZIP profiles (200K entries / 1 TiB / 256 GiB / +ratio 500 by default, with the same `large=1` opt-in to 500K / 2 TiB / 1 TiB / 1000). ``` diff --git a/docs/UPGRADE-v1.8-rar-support.md b/docs/UPGRADE-v1.8-rar-support.md index 63a28f5..0e2d8c2 100644 --- a/docs/UPGRADE-v1.8-rar-support.md +++ b/docs/UPGRADE-v1.8-rar-support.md @@ -416,10 +416,10 @@ self-evident from the failure. ### 7.4 The `large=1` prompt for RAR -The threshold is shared. A `.rar` larger than `60 GiB` triggers the +The threshold is shared. A `.rar` larger than `240 GiB` triggers the same `promptLargeMode()` confirmation as a `.zip`. The confirmation -text uses `extractLargeAsk` (unchanged from v1.7) — the wording is -format-agnostic, so no new strings are needed. +text uses `extractLargeAsk` (slightly relaxed in v1.8.1) — the wording +is format-agnostic, so no new strings are needed. --- @@ -441,7 +441,7 @@ host has any RAR tooling. | `test_engine_dispatch_null_dst` | `rar_extract(path, NULL, …)` is rejected with `ZIPX_ERR_INTERNAL`. | | `test_engine_dispatch_dst_is_regular_file` | `rar_extract(path, /some/file, …)` returns `ZIPX_ERR_CONFLICT` (open_parent_dirs fails). | | `test_format_translation` | Parametric: for each `DMC_UNRAR_*` code we care about, the corresponding `rar_translate_error()` mapping is exercised indirectly (via `result->message` strings). | -| `test_limits_handoff_default` | When `task->extract_large == 0`, the default profile is handed in (200 K entries / 512 GiB / 64 GiB / 200:1). | +| `test_limits_handoff_default` | When `task->extract_large == 0`, the default profile is handed in (200 K entries / 1 TiB / 256 GiB / 500:1). | | `test_limits_handoff_large` | When `task->extract_large == 1`, the large profile is handed in (500 K / 2 TiB / 1 TiB / 1000:1). | | `test_translate_open_fail_to_err_open` | DMC open-failure → `ZIPX_ERR_OPEN`. | | `test_translate_volume_unsp_to_err_unsupported` | The DMC volume code → `ZIPX_ERR_UNSUPPORTED`. | diff --git a/src/zip_extract.c b/src/zip_extract.c index b296d77..ee4fb7b 100644 --- a/src/zip_extract.c +++ b/src/zip_extract.c @@ -35,9 +35,9 @@ static const zipx_limits_t k_default_limits = { .max_entries = 200000, - .max_total_bytes = 512ULL * 1024 * 1024 * 1024, - .max_file_bytes = 64ULL * 1024 * 1024 * 1024, - .max_ratio = 200, + .max_total_bytes = 1ULL * 1024 * 1024 * 1024 * 1024, + .max_file_bytes = 256ULL * 1024 * 1024 * 1024, + .max_ratio = 500, .max_depth = 32, .max_name_len = 255, .max_path_len = 1024 diff --git a/tests/test_zip_extract.c b/tests/test_zip_extract.c index 8a3c6d2..93430e5 100644 --- a/tests/test_zip_extract.c +++ b/tests/test_zip_extract.c @@ -1,639 +1,642 @@ -/* 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 -#include -#include -#include -#include -#include -#include - -/* 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 == 2ULL * 1024 * 1024 * 1024 * 1024, - "max_total_bytes == 2 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 200 rejects it; large ratio - cap 1000 accepts it. This is the headline behavioural difference - between the two profiles. */ - printf("ratio cap\n"); - expect_status(&res, run("medium_bomb.zip", "out_large_medium_default", - ZIPX_CONFLICT_FAIL, NULL, &t, &res), - ZIPX_ERR_LIMIT_RATIO, - "default ratio cap rejects medium_bomb.zip"); - expect_ok(&res, run("medium_bomb.zip", "out_large_medium_large", - ZIPX_CONFLICT_FAIL, large, &t, &res), - "large ratio cap accepts medium_bomb.zip"); - - /* bomb.zip is 4 MiB of identical 'A' bytes, compressing to ~4 KiB - (ratio ~1026). The large profile's default cap of 1000 is still a - real cap — it rejects the bomb too. The profile is a higher - threshold, not the absence of one. */ - expect_status(&res, run("bomb.zip", "out_large_bomb_default", - ZIPX_CONFLICT_FAIL, large, &t, &res), - ZIPX_ERR_LIMIT_RATIO, - "large ratio cap (1000) rejects bomb.zip (~1026:1)"); - - /* Lowering the large profile's 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_large_medium_tight", - ZIPX_CONFLICT_FAIL, &tight, &t, &res), - ZIPX_ERR_LIMIT_RATIO, - "lowered large ratio still enforced"); - - /* 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 \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; -} +/* 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 +#include +#include +#include +#include +#include +#include + +/* 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 == 2ULL * 1024 * 1024 * 1024 * 1024, + "max_total_bytes == 2 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 \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; +}