diff --git a/.build/build-win.sh b/.build/build-win.sh index f5eb76c..2d35847 100644 --- a/.build/build-win.sh +++ b/.build/build-win.sh @@ -17,6 +17,14 @@ set -uo pipefail +# Git Bash / MSYS2 rewrites anything that looks like a POSIX path before the +# argument reaches wsl.exe, so `/mnt/c/...` becomes +# `/mnt/c/...` and the WSL side reports "cannot stat" (this is how +# the script first failed under PortableGit). WSL sees Linux paths, so the +# translation must be off for the whole script. Harmless on a real Linux shell. +export MSYS_NO_PATHCONV=1 +export MSYS2_ARG_CONV_EXCL='*' + REPO='/c/Users/songl/Desktop/Web File Manager/ps5-web-file-manager' WSL_DISTRO='Ubuntu-22.04' WSL_DIR='/home/song/ps5-web-file-manager/.build' diff --git a/.build/check-elf-gzip.py b/.build/check-elf-gzip.py index db54100..72ae343 100644 --- a/.build/check-elf-gzip.py +++ b/.build/check-elf-gzip.py @@ -1,51 +1,62 @@ -"""Verify our large-file mode identifiers made it into the ELF. -gen-asset-module.py gzips JS assets, so plain `strings` won't find them. -This script finds all gzip streams in the ELF, decompresses them, and greps -for the new identifiers.""" -import re, sys, zlib +"""Verify that specific identifiers made it into the ELF's embedded assets. + +`gen-asset-module.py` gzips every JS/CSS/HTML asset (compresslevel=9, mtime=0), +so plain `strings` finds none of their text -- a zero hit means "compressed", +not "missing". This script walks the file for gzip streams, decompresses each +one and reports where each key landed. + +Usage: + python .build/check-elf-gzip.py [key ...] + +With no keys it defaults to the current round: the v1.9.3M fork marker and the +footer tooltip that explains it. Pass your own after the ELF path when reusing +this for another change. + +Scope note: this covers **assets only**. C-side string literals (e.g. +`extract_dict_too_large`, `versionsTooltip` in a header) are not compressed and +belong to a plain `strings` check. +""" +import sys +import zlib + +if len(sys.argv) < 2: + sys.exit(__doc__) f = sys.argv[1] data = open(f, "rb").read() -# Gzip streams start with 0x1f 0x8b. Walk through the file looking for them. -keys = [ - b"extractLargeAsk", - b"extractLargeActive", - b"promptLargeMode", - b"shouldPromptLargeMode", - b"LARGE_FILE_THRESHOLD_BYTES", - b"/api/extract", - b"large", +keys = [k.encode() for k in sys.argv[2:]] or [ + b"v1.9.3M", # assets/main.js footer fallback + b"versionTooltip", # main.js + both lang files + "本版为 LisherSong 改版".encode(), # assets/lang-zh.js + b"Modified build by LisherSong", # assets/lang-en.js ] -seen = {} -i = 0 -while i < len(data) - 10: - if data[i] == 0x1f and data[i + 1] == 0x8b: - # Try to decompress starting here, capped at 2 MiB. - end_cap = min(len(data), i + 2 * 1024 * 1024) - try: - dec = zlib.decompressobj(zlib.MAX_WBITS | 16) - chunk = dec.decompress(data[i:end_cap], 2 * 1024 * 1024) - if not dec.eof: - i += 1 - continue - except Exception: - i += 1 - continue - # Check for any of our keys in the decompressed stream. - for k in keys: - if k in chunk and k not in seen: - idx = chunk.find(k) - ctx = chunk[max(0, idx - 24):idx + len(k) + 48] - seen[k] = ctx.decode("utf-8", errors="replace") - i += 1 - else: - i += 1 -print(f"decompressed streams scanned, keys found: {len(seen)} / {len(keys)}") +seen = {} +streams = 0 +i = data.find(b"\x1f\x8b\x08") +while i >= 0: + # Try to decompress starting here, capped at 2 MiB. + end_cap = min(len(data), i + 2 * 1024 * 1024) + try: + dec = zlib.decompressobj(zlib.MAX_WBITS | 16) + chunk = dec.decompress(data[i:end_cap], 2 * 1024 * 1024) + if dec.eof and chunk: + streams += 1 + for k in keys: + if k in chunk and k not in seen: + idx = chunk.find(k) + ctx = chunk[max(0, idx - 24):idx + len(k) + 48] + seen[k] = ctx.decode("utf-8", errors="replace") + except Exception: + pass + i = data.find(b"\x1f\x8b\x08", i + 1) + +print(f"{f}: {streams} gzip streams scanned, keys found: {len(seen)} / {len(keys)}") for k, v in seen.items(): - print(f" ✓ {k.decode()}") - print(f" context: {v[:160]}") + print(f" OK {k.decode()}") + print(f" context: {v[:160]}") missing = [k for k in keys if k not in seen] -if missing: - print(f" ✗ not found: {[m.decode() for m in missing]}") \ No newline at end of file +for m in missing: + print(f" MISS {m.decode()}") +sys.exit(1 if missing else 0) diff --git a/.gitignore b/.gitignore index 31ff09b..cfd414e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,16 @@ web-file-mgr-linux # Generated 7z fixtures (tests/make_sevenz_fixtures.py rebuilds them). tests/fixtures-7z/ +# Staging trees the fixture generators copy from (make-rar-fixtures.bat uses +# fixture-stage, make-zip-enc-fixtures.bat uses fixture-stage-zip). The +# archives they produce under tests/fixtures-real/ ARE committed. +tests/fixture-stage/ +tests/fixture-stage-zip/ + +# Python bytecode (the fixture generators and bench_driver are hand-run) +__pycache__/ +*.pyc + # Editor / OS noise .vscode/ .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7805ab6..774e778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,55 @@ 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.9.3M — **built and verified locally, not published yet**: +> `web-file-mgr-v1.9.3M.elf` — size 903 448 bytes (~882 KiB) +> sha256 `8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7` +> ELF class 64, little-endian, e_machine `0x003e` (x86_64-sie-ps5) +> +> **The trailing `M` is the fork marker** (Modified build, maintained by +> LisherSong). Upstream owendswang ships plain `vX.Y.Z`, so a version string on +> its own says which of the two you are looking at. The marker rides on +> `VERSION_TAG` rather than on a display-only constant, so `/api/version`, the +> PS5 start-up notification, the stdout banner, the UI footer and the ELF file +> name all gain it in one move — nothing can be forgotten in one of the five. +> A second benefit is that a fork build can no longer collide with an upstream +> artifact of the same upstream version, a mix-up that has already happened +> twice. The footer additionally carries a tooltip spelling the marker out +> (`versionTooltip`, en + zh). This is the first release using the convention; +> the older entries below keep their original plain numbers. +> +> This is the first binary to carry the encrypted-archive work and the +> dictionary-reporting fix (`[v1.9.3M]` below). It exists for on-device testing: +> the GitHub release is still v1.9.2 and its binary contains none of it. +> +> Its size is **unchanged yet again** (903 448 B) although the content grew, for +> the sixth build in a row. Every round of this release has only moved +> `.rodata`, and by less than the 16 KiB section alignment absorbs: +> +> | build | `.rodata` | delta | what changed | +> |---|---|---|---| +> | `v1.9.3` (pre-marker) | 0x025F80 | — | — | +> | `v1.9.3M` + encrypted archives | 0x0260C0 | +0x140 | the `M` marker, its tooltip, re-gzipped assets | +> | `+` upload menu and i18n names | 0x026A40 | +0x980 | the menu, the hint, the new copy | +> | `+` hint move, status clamp, retry key | 0x026B40 | +0x100 | final copy and CSS | +> | `+` menu row highlight fix | 0x026CC0 | +0x180 | the scoped row highlight rules and their comment | +> | `+` always-on extract button | 0x026F00 | +0x240 | the un-hidden button, three disabled reasons, the tooltip fix | +> +> `.text` is byte-for-byte the same size across all six, which is the expected +> shape for a change that touches no C logic. **Never infer "nothing changed" +> from the file size** — compare sections with `readelf -SW`. Each of the six +> carries a different sha256 despite the identical size, so the digest, not the +> byte count, is what identifies a build. +> +> Putting the extract button on screen at all times is not free: it widens the +> resting toolbar by its own width, so the width at which the toolbar wraps onto +> a second row moves out from 1080px to 1190px in Chinese, and from 1230px to +> 1350px in English, where the labels are longer. The console is 1920px wide and +> 1280px still fits in Chinese, so the trade was accepted: the alternative was +> leaving the entry hidden until an archive happened to be selected, which is +> what made it undiscoverable in the first place. The measured threshold is now +> pinned by the headless harness rather than left to be rediscovered. +> > Release artifact for v1.9.2: > `web-file-mgr-v1.9.2.elf` — size 870 488 bytes (~850 KiB) > sha256 `177e90fecf93a0251e83f67884fba4551051be248330b0d70fda8ea732f88e84` @@ -59,6 +108,291 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > (`Makefile` CFLAGS `-Ithird_party/unrar`, `src/extract.c` forward > declaration of `extract_progress`); no vendored or engine changes. +## [v1.9.3M] — 2026-09-23 + +**Encrypted archives now extract end to end — ZIP (both schemes), RAR, and 7z +with an encrypted header.** + +Until now every encrypted archive was refused up front, even though the +password field, the prompt and `extract_password` copy had been in place +since v1.9. The gap was in the engines, not the UI: + +- **ZIP**: the vendored minizip-ng had been trimmed past its crypto + backend, so the `-DHAVE_WZAES` / `-DHAVE_PKCRYPT` branches inside the + (unmodified) `mz_zip.c` had no implementation to call. +- **RAR**: rarlab UnRAR could decrypt, but `RARSetPassword` was never + called. +- **7z**: `-mhe=on` put the file names and the folder table inside the + encrypted header, so the archive could not even be listed. + +All three are wired now. A missing or wrong password is reported as +`extract_password` (`ZIPX_ERR_PASSWORD` at the engine level), which is the +code the task overlay's existing password prompt already reacts to. + +### Changed + +- **The version string gains a fork marker: `v1.9.3` → `v1.9.3M`.** Upstream + owendswang releases are plain `vX.Y.Z`, so the `M` (Modified) is what tells a + user which of the two they are holding. It is part of `VERSION_TAG` in the + Makefile, which means `/api/version`, the PS5 start-up notification, the + stdout banner, the UI footer **and the ELF file name** all carry it at once. + Because the file name changes too, a fork build can no longer shadow an + upstream artifact of the same upstream version. +- The UI footer tooltip (`versionTooltip`, en + zh) spells the marker out, so + `v1.9.3M` is not left unexplained for someone who has not read this file. + `loadVersion()` only ever rewrites the footer's text, so the tooltip survives + the `/api/version` round trip. +- **The upload button opens a menu instead of hiding half of itself behind a + caret.** It used to be a main button plus a small arrow: users read the arrow + as decoration and never found "upload a folder" at all. One click now lists + **Upload Files / Upload Folder** (`#uploadMenu`, `role="menu"`), with Escape, + an outside click and arrow keys handled, and focus moved into the list when it + opens. The button keeps a caret so it is obvious that a list is coming. +- The footer status line is now clamped to a single line with an ellipsis. It + is a fixed-height row that also carries the version and the new drag hint, and + a long "uploading 3/12: some-name.zip" used to wrap to two lines and spill out + of the 46 px footer. +- **The extract button is always on screen and greys out instead of being + hidden.** It used to appear only once an extractable archive was selected, so + the resting toolbar had no extract entry at all — the same discoverability + problem the upload button was changed for. It is now always present, disabled + and at 45% opacity whenever the selection cannot be extracted, and its tooltip + names the reason: *"Select one archive to extract (ZIP / RAR / 7z)"*, the + existing "please select the main volume" when only a `.partNN.rar` sub-volume + is selected, and a new "only one archive can be extracted at a time" when + several are selected — the old code answered *that* case with the main-volume + message, which was simply the wrong sentence. Because `button:disabled` sets + `pointer-events: none`, a disabled button cannot be hovered and its tooltip + never appears at all, so `.extract-action:disabled` restores hit-testing + without restoring clicks; the parent-directory button needed the same fix + earlier. +- The extract button's label is the short `extract` ("解压" / "Extract"), + matching the other toolbar verbs, instead of `extractToCurrent` ("解压到当前 + 目录" / "Extract to current folder"). A button that is on screen permanently + should not also be the widest one in the toolbar, and the target folder is + still named in the tooltip and in the confirmation dialog. The measured cost + of the always-on button: the width at which the toolbar wraps onto a second + row moves from 1080px to 1190px in Chinese and from 1230px to 1350px in + English, where the labels are longer. 1280px still fits in Chinese and the + console is 1920px wide. + +### Added + +- **Encrypted ZIP** — traditional PKWARE ("ZipCrypto", what `zip -e` + writes) and WinZip AES-128/192/256 (method `99` + the `0x9901` extra + field, what `7z -mem=AES256` writes), for stored and deflated entries. +- **Encrypted RAR** — `-p` data encryption and `-hp` header encryption. + `RARSetPassword` now runs right after `RAROpenArchiveEx` and before the + first `RARReadHeaderEx`, which is the order unrar needs to decrypt a + RAR5 header. +- `password=` on `/api/extract` now reaches a real decrypt path for both + engines. An empty or absent value means "no password", so the raw form + field can be passed straight through. +- **The UI retries a failed extraction with a password.** An + `extract_password` failure used to end in an error box, which for ZIP and + RAR meant the password could never be supplied at all — the prompt only + existed for 7z. The failed task is now re-sent with whatever the user types, + up to three times, and the remembered request keeps the original conflict + policy and large-file opt-in. Cancelling or submitting an empty box falls + back to the previous failure report. 7z keeps its up-front prompt so an + encrypted header does not cost a wasted scan. +- `extractPasswordRetryAsk` (en + zh) is the retry prompt's wording, distinct + from the up-front `extractPasswordAsk`. +- `third_party/minizip-ng/src/mz_crypt_wfm.c` — a local crypto provider for + the trimmed minizip-ng: SHA-1, HMAC-SHA1 and AES-128/192/256, with the + S-box and the GF(2^8) tables derived on first use so the binary gains no + new `.rodata` lookup tables. PBKDF2 comes from the vendored `mz_crypt.c`; + the CSPRNG reads `/dev/urandom` rather than `mz_os_rand()`, which keeps + `rand`/`srand` out of the import table. Restored verbatim from upstream + 4.2.2: `mz_strm_wzaes.{c,h}`, `mz_strm_pkcrypt.{c,h}`. +- `tests/make-zip-enc-fixtures.bat` and three real fixtures under + `tests/fixtures-real/` (`enc-zipcrypto.zip`, `enc-aes256.zip`, + `enc-aes256-store.zip`, password `secret123`). +- **Encrypted 7z headers (`-mhe=on`)** — the last remaining format gap. With + `-mhe=on` the header is itself a folder holding the file names, the folder + table and every entry size, so the vendored SDK (whose C decoder has no + 7zAES coder at all) abandons the archive with `SZ_ERROR_UNSUPPORTED` before + it can list a single entry. `src/sevenz_header.c` now reads the + `k7zIdEncodedHeader` record, decodes its one folder through the project's + own 7zAES path (`src/sevenz_chain.c`) and then gives the SDK a small virtual + `ISeekInStream` in which that record has been replaced by the plaintext — + the rewritten start header, the decrypted header at the offset the encoded + one already occupied, and the real archive everywhere else, so every offset + the archive stores still points where it did. Nothing on disk is written to. + Archives whose header is only *compressed* (`-mhc=on`, the default) are + detected from one byte and never touched, and a wrong password comes back as + `ZIPX_ERR_PASSWORD` like any other encrypted archive. +- **A visible drag-and-drop hint** (`dropUploadHint`, en + zh) in the footer: + *"Drag files or folders into this window to upload"*. Dropping already worked, + but nothing but the drop overlay itself ever said so, and that only appears + once a drag is under way. It is `remote-only`, like the upload button it + describes, and starts hidden so the console browser never flashes it. +- `uploadFiles` (en + zh) for the menu's file entry, and + `extractPasswordFirstAsk` — a first-failure prompt that says the archive is + encrypted rather than blaming a password the user was never asked for. +- `extractSelectArchive` and `extractOneAtATime` (en + zh): the two reasons the + always-on extract button can be greyed out with nothing useful selected, and + with several archives selected. The third reason, `extractSelectMainVolume`, + already existed. + +### Fixed + +- **Compiler-flag changes now invalidate objects.** `make` cannot see a + flag change, so adding `-DHAVE_WZAES -DHAVE_PKCRYPT` left the existing + `mz_zip.o` / `mz_crypt.o` untouched — and since nothing referenced the + new streams any more, `--gc-sections` dropped the encryption code again + while the link still reported success (the first build of this change was + byte-for-byte the published release). The Makefile now records the + third-party flag set in `ps5-obj/.third_party_cflags` / + `linux-obj/.third_party_cflags` and rebuilds only when it really changes — + the same trap the older `LzmaDec.o` rule was working around, generalised. +- `ZIPX_ERR_UNSUPPORTED` no longer covers encryption — it is now "multipart + or unsupported compression method" only. + +- **An oversized archive dictionary is now reported as such.** An archive whose + dictionary exceeds what the build allows used to fail with + `extract_entry_too_large` / "A file inside the archive is too large" naming the + entry that happened to be in flight — for the 8 GiB-dictionary fixture the + message blamed a 7 KB text file. The dictionary is a property of the archive, + not of the entry, so it now has its own status (`ZIPX_ERR_LIMIT_DICT`), its own + i18n code (`extract_dict_too_large`, en + zh) and the real numbers, which + unrar hands over in the `UCM_LARGEDICT` callback: *"needs 8192 MiB (limit + 4096 MiB)"*. +- The **behaviour is unchanged on purpose**: we still refuse, matching what + rarlab's own CLI does by default ("8 GB dictionary exceeds the 4 GB limit and + needs more than 8 GB of memory; use -md8g or -mdx8g"). Answering `1` to + `UCM_LARGEDICT` would only move the problem into `Unpack::Init()`, which + allocates the whole dictionary in one block — on a 16 GB console that trades a + clean error for an OOM-kill of the payload mid-extraction. Note also that + **RAR 5.0 headers cannot express more than 4 GiB** (four dictionary bits, + `arcread.cpp:871`), so only RAR7 headers can reach this path at all. +- `tests/test_rar_extract.c:test_dict_limit` + a new synthetic fixture + `tests/fixtures/dict-8g.rar` (built by `tests/make_fixtures.py:bigdict()`, + which writes a minimal valid RAR5 archive by hand: no compressor can produce + such a header, and `Rar.exe 7.23` refuses to create RAR7 archives — `-ma4`, + `-ma6`, `-ma7` all exit 7). Verified independently with rarlab's own tools: + `UnRAR lt` reports `-md=8g` and `UnRAR t -mdx12g` extracts it cleanly. + +- **`err_extract_unsupported` copy was two releases out of date.** It read + *"only plain ZIP, single-volume RAR, and 7z are supported"* — the exact + inverse of what the build does now, because encrypted archives and + multi-volume RAR are both supported. The label now names what is actually + accepted (`.zip` / `.rar` / `.7z`, including their multi-volume and + encrypted forms); the backend's own sentence, which carries the real cause, + is still appended after it. The same stale wording in both READMEs is + corrected too. `.rodata` +0x40 (64 B), nothing else changed. + +- **The password prompt never appeared for an archive in a non-ASCII folder + — the user only ever saw the failure alert.** The retry that asks for a + password was looked up by *path*, and paths are not stable across the wire: + the server escapes every byte ≥ 0x80 as `\u00XX` when it serialises a name + and `fs_path_value()` maps those back to raw bytes when it receives one, so + the string the page holds for a directory and the string the task reports back + differ for every name that is not pure ASCII. The lookup missed, the retry + returned false, and the plain "extract failed" box was shown instead — the + archive had to be extracted again by hand before the prompt would appear. The + remembered request is now keyed by **task id**, which the server assigns and + which survives the round trip untouched; the retry also re-sends the path the + *server* reported rather than the one the page was holding. On-device symptom: + upload `x.zip` into a Chinese-named folder, choose "upload and extract" → no + prompt, then the raw error. + +- **Entry names in error messages were unreadable mojibake** — + `解压失败: 密码错误,或压缩包未使用所提供的密码加密: â®…ç§.psd (entry is + encrypted and no password was given)`. The listing has always translated the + byte-mapped names back through `decodeFsText()` for display, but + `backendErrorText()` used the raw `error_arg` — the one place where the name + matters most. Both `error_arg` and the backend's own sentence now go through + the same translation, so a GBK name stored inside a ZIP reads as Chinese + again. + +- **An archive with a non-ASCII name inside a non-ASCII folder could not be + extracted after upload.** `uploadAndExtractFile()` joined the byte-mapped + directory with the real Unicode file name, producing a mixed path; the server + only byte-repairs a string when *every* non-ASCII code point in it is ≤ 0xFF, + so one CJK character made it skip the repair and look for a path that does not + exist. `encodeFsText()` (the inverse of `decodeFsText()`) now byte-maps the + name before the join, which is also applied to the path `New Text` hands to + the editor. Pure-ASCII paths and pure-real-Unicode paths were unaffected, + which is why this survived until someone hit the mixed case. + +- **The upload menu's row highlight painted as a broken shape.** Selecting a + row drew a 3px blue ring at `outline-offset: 2px` that cleared the panel's + 6px padding, overlapped the row above, and kept the row's own 6px corner + radius instead of growing with the offset — so the highlight read as a + detached outline with two arcs hanging off its sides rather than a selected + row. Two rules were fighting. The ring came from the generic `button:focus` + rule, which is sized for a 54px toolbar button and was never meant to reach a + 46px list row. Underneath it, the panel's own + `.upload-menu-list button:hover:not(:disabled)` fill had **never applied at + all**: it ties on specificity (0,3,1) with the generic + `button:not(.row-action):hover:not(:disabled)` rule and that one comes later + in the file, so it won the cascade — hover and focus therefore ended up two + different colours (`#303945` against `#2b343e`), and a hovered row lit up + while the keyboard-focused row stayed lit, which is what made two rows look + selected at once. Both rules are now scoped to the panel id, rows highlight + by fill alone, and the keyboard cue is a 2px **inset** ring drawn inside the + row, where it cannot cross the panel edge at any row height. + +### Tests + +- `tests/test_zip_extract.c` runs each encrypted fixture four ways (no + password → `PASSWORD`, empty → `PASSWORD`, wrong → `PASSWORD`, correct → + `ZIPX_OK` with a byte-level content check), plus a case proving the + limits still apply when a password has been handed over. +- `tests/test_rar_extract.c` exercises `enc-v6.rar` the same way, including + that nothing is published on the failing paths. +- `tests/test_sevenz_extract.c` runs `aeshe.7z` three ways: no password → + `ZIPX_ERR_PASSWORD`, wrong password → `ZIPX_ERR_PASSWORD`, correct password + → success with the content compared byte for byte, and no staging tree left + behind on any of them. +- `.build/ui_retry_test.mjs` loads the real `assets/main.js` into a stubbed DOM + and checks the frontend retry flow: the request is remembered with its + conflict policy and large-file flag, a failure retries with the typed + password, cancel and empty input give up, and the retry count caps at three. + It also carries the regression case for the missing prompt: a task whose + `src`/`dst` come back byte-repaired (a non-ASCII folder) must still be retried, + and a structural check that no retry entry is keyed by a path. **40 checks, + 0 failures.** +- `.build/ui_upload_menu_test.mjs` is the markup-side counterpart: it asserts + that every `data-i18n` key in `index.html` exists in both language files, that + the two language files carry the same keys, that the upload menu and the drag + hint exist and start hidden, that the removed file/folder split is gone from + both the markup and the stylesheet, that the click handlers are bound to the + new ids, and that the classes the markup uses are actually styled. Four of its + checks pin the row-highlight cascade: the hover and focus rules must be scoped + to the panel, the row ring must be suppressed, the keyboard cue must be an + inset shadow, and the unscoped forms must not come back — a rule that silently + loses the cascade is exactly the kind of thing a static check can still catch. + A further group covers the extract button, which is now always on screen: the + markup must not hide it, main.js must never assign to `extractBtn.hidden`, the + disabled rule must keep the tooltip hoverable, and there must be a message for + each reason it can be unavailable. One check sweeps the other direction — + every `t("...")` literal in `main.js` (117 keys) must exist in both language + files — because a key reached only from script code is invisible to the markup + sweep, which is how a message goes missing unnoticed. + **40 checks, 0 failures.** Both scripts are hand-run — the project has no + browser test runner — but each one exits non-zero on failure. +- `.build/preview_build.py` + `.build/preview_check.mjs` render the real page + against a fixture API in headless Chromium and assert what a screenshot alone + cannot: the menu is hidden at rest, opens on click, moves focus into the list, + reaches the hidden ``, and closes on a choice, an outside + click and Escape. It reads back the *computed* highlight for a focused, a + hovered and a keyboard-focused row, which is the only way to settle a cascade + question — a rule losing to a generic one and a ring leaking out of its + container both look fine in the source. It is how the three layout/highlight + mistakes of this round were caught (a hint that pushed the toolbar onto a + second row, a status line that wrapped out of the footer, and the broken row + highlight described under Fixed). +- Host totals: **140 ZIP + 37 RAR = 177 checks**, 0 failures. +- 7z totals: **27 cases, 0 failures** (`tests/run-sevenz-tests.sh`), and the + `KNOWN_GAPS` list that held `aeshe` is now empty — the encrypted-header + fixture passes through both the folder decoder and the extraction facade. + +### Still open + +- End-to-end validation of the built ELF on a real console. + ## [v1.9] — 2026-09-05 **RAR engine replaced: rarlab UnRAR 7.20.1 (v6 / multi-volume / decryption-capable).** diff --git a/HANDOVER.md b/HANDOVER.md index 7c6784a..cdc2151 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -1,10 +1,10 @@ # 交接文档 — ps5-web-file-manager 工作进度 -> 交接时间:2026-09-20 · 分支 `main` · 最新提交 **`807d129`** · tag **`v1.9.2`** 已重打到产出发布二进制的提交(此前 `v1.9.1` 落后 4 个提交,tag 与产物对不上) +> 交接时间:2026-09-23 · 分支 `main` · 最新提交仍是 **`3f80eb4`**(tag `v1.9.2`)——**本轮加密改动全部尚未提交** > > **主线(用户 2026-09-12 指令)**:「先从 zip 分卷开始吧,然后把六种组合打齐,并把密码通道补齐,注意一些报错信息提示的时候尽量详细准确」 -> **状态:主线全部闭合。** 六种组合(ZIP/RAR/7z × 单卷/分卷)+ 密码通道(RAR 加密 + 7zAES)+ 报错详细信息,全部落地、测试全绿、PS5 ELF 构建成功。 -> **剩余**:① PS5 真机端到端验证(**唯一还没过的关卡**);② `-mhe=on` 加密头(唯一功能缺口)。 +> **状态:主线全部闭合,且格式面已无已知缺口。** 六种组合(ZIP/RAR/7z × 单卷/分卷)+ 密码通道(ZIP ZipCrypto/AES + RAR `-p`/`-hp` + 7zAES **含 `-mhe=on` 加密头**)+ 报错详细信息,全部落地、测试全绿、PS5 ELF 构建成功。 +> **剩余**:① PS5 真机端到端验证(**唯一还没过的关卡**);② 版本号仍是 `v1.9.2`,本轮改动处于「未发布」状态——发版需先升 `VERSION_TAG` 与 `APP_VERSION_FALLBACK`。 --- @@ -12,12 +12,13 @@ | 维度 | 状态 | |---|---| -| 解压引擎 | ZIP / RAR / 7z × 单卷/分卷(6 组合)+ 7zAES / RAR 加密 | -| 主机测试 | **ZIP 108 + RAR 27 + 7z 28 = 163 checks,0 失败**(MinGW gcc;2026-09-20 在 v1.9.2 树上复跑) | +| 解压引擎 | ZIP / RAR / 7z × 单卷/分卷(6 组合)+ 三种加密(ZIP ZipCrypto/WinZipAES、RAR `-p`/`-hp`、7zAES 与 `-mhe=on` 加密头)全部打通 | +| 主机测试 | **ZIP 140 + RAR 37 = 177 checks,0 失败**(MinGW gcc;2026-09-23 复跑)+ 7z 套件 **27 用例 0 失败**(`KNOWN_GAPS` 已清空)+ 前端重试流程 27 checks(`.build/ui_retry_test.mjs`) | | PS5 构建 | ✅ WSL prospero-clang 18.1.8,一键脚本可复现;构建已实测**确定性**(同源两次构建 sha256 相同) | -| ELF 产物 | `web-file-mgr-v1.9.2.elf` · 870,488 B · sha256 `177e90fecf93a0251e83f67884fba4551051be248330b0d70fda8ea732f88e84` · e_machine=0x003e(2026-09-20 瘦身后;瘦身前 1,017,864 B,见 `docs/SIZE-OPTIMIZATION.md`) | -| GitHub | `main`(`807d129`)与 tag `v1.9.2` 均已推送;Release `v1.9.2` 资产为该 ELF | -| 唯一功能缺口 | 7z `-mhe=on`(加密头) | +| ELF 产物(工作树,未提交) | `web-file-mgr-v1.9.3M.elf` · 903,448 B · sha256 `8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7` · e_machine=0x003e(2026-09-24 最后一轮:上传菜单 + 拖拽提示 + 口令重试改按键 id + 错误文案编码修复 + 菜单行高亮的层叠修复 + 解压按钮常显置灰;**未发布**) | +| 已发布产物 | `web-file-mgr-v1.9.2.elf` · 870,488 B · sha256 `177e90fecf93a0251e83f67884fba4551051be248330b0d70fda8ea732f88e84`(不含加密改动) | +| GitHub | `main`(`3f80eb4`)与 tag `v1.9.2` 均已推送;Release `v1.9.2` 资产对应上一行;本轮改动尚未 commit | +| 已知功能缺口 | **无**(`-mhe=on` 已于 2026-09-23 补齐) | ### 发布命令(v1.9.2) @@ -67,12 +68,20 @@ bash /home/song/build.sh 现在收敛到 `Makefile` 一处: ```make -VERSION_TAG ?= v1.9.2 # 可用 make VERSION_TAG=v1.9.3 临时覆盖 +VERSION_TAG ?= v1.9.3M # 可用 make VERSION_TAG=v1.9.3 临时覆盖 BIN := web-file-mgr-$(VERSION_TAG).elf ``` 改这一行会同时影响 **四处**:ELF 内嵌版本串、PS5 启动通知、输出文件名、UI 右下角。 +**尾部 `M` = 改版标记(Modified,LisherSong 维护)**,从 v1.9.3M 起启用。上游 +owendswang 的发布版是纯 `vX.Y.Z`,故「带 M = 本仓、不带 = 上游」一眼可分。它刻意 +挂在 `VERSION_TAG` 上而不是做一个只管显示的独立常量:这样 `/api/version`、启动通知、 +stdout 横幅、UI 右下角、ELF 文件名**五处一次性全覆盖**,不可能只在其中一处漏掉。 +附带好处是产物名不再可能与上游同版本号的资产撞车(此前已撞过两次:本地 +`web-file-mgr-v1.9.2.elf` 与线上同名资产并存;`-DVERSION_TAG=v1.9.1` 的残留产物 +和已发布的 870 488 B 文件尺寸相同)。 + | 提交 | 内容 | |---|---| | `f4fd464` | `VERSION_TAG` `v1.9` → `v1.9.1`;`.gitignore` 改白名单式(`.build/*` 全忽略 + `!` 放行 6 个脚本),`.workbuddy/` 也忽略 | @@ -102,17 +111,20 @@ v1.9.2 就是这两处一起改的。 | # | 引擎 | 单卷 | 分卷 | 密码 | |---|---|---|---|---| -| ① | ZIP | ✅ | ✅ 三种命名约定 | ✅ | -| ② | RAR | ✅ | ✅(vendor unrar 7.20.1) | ✅ `RARSetPassword` | -| ③ | 7z | ✅ | ✅ `.7z.001` | ✅ 7zAES | +| ① | ZIP | ✅ | ✅ 三种命名约定 | ✅ ZipCrypto + WinZip AES-128/192/256(2026-09-23 打通,此前 `mz_zip.c` 的加密分支没有后端可调) | +| ② | RAR | ✅ | ✅(vendor unrar 7.20.1) | ✅ `RARSetPassword`(`-p` 与 `-hp` 头加密;2026-09-23 接线,此前从未被调用) | +| ③ | 7z | ✅ | ✅ `.7z.001` | ✅ 7zAES(v1.9 起就有)+ `-mhe=on` 加密头(2026-09-23 打通,见 §八) | ### 3.2 测试 ```bash export PATH="/c/mingw64/bin:/c/Users/songl/.workbuddy/binaries/PortableGit/versions/1.2.0/mingw64/bin:/c/Users/songl/.workbuddy/binaries/python/versions/3.13.12:/usr/bin:/bin:/c/Windows/System32:/c/Windows" cd "/c/Users/songl/Desktop/Web File Manager/ps5-web-file-manager" -/usr/bin/bash tests/run-tests.sh # ZIP 108 + RAR 27 -/usr/bin/bash tests/run-sevenz-tests.sh # 7z 28(含 KNOWN_GAPS 检查) +/usr/bin/bash tests/run-tests.sh # ZIP 140 + RAR 37 = 177 +/usr/bin/bash tests/run-sevenz-tests.sh # 7z 27(KNOWN_GAPS 已清空) + +# 前端「密码失败后重试」流程(桩 DOM,无需浏览器) +"/c/Users/songl/.workbuddy/binaries/node/versions/22.22.2-3/node.exe" .build/ui_retry_test.mjs # 27 ``` ⚠️ **绝不写裸 `bash`** —— 可能解析到 `C:\Windows\System32\bash.exe`(WSL 启动器),脚本跑进 Linux,gcc/python 全变 Linux 版,报莫名错误。必须 `/usr/bin/bash`。 @@ -163,7 +175,7 @@ strings -a web-file-mgr-v1.9.2.elf | grep -m1 '^v1\.' LZMA SDK 26.03(public domain,已 vendor 到 `third_party/7z/`,解码子集 60 文件)有两个硬限制,**实测复现过**: 1. **`CSzFolder` 上限 4 coder / 3 bond** —— 7-Zip 默认 `-m0=bcj2` 链 = BCJ2 + 4×LZMA2 = 5 coder,`SzAr_DecodeFolder()` 返回 `SZ_ERROR_UNSUPPORTED`。注意 `SzArEx_Open()` 用的是另一套宽松扫描器(`k_Scan_NumCoders_MAX 64`),所以**文件列表和解压尺寸仍然全对**,失败只在解压时按条目暴露 -2. **C 解码器完全没有 7zAES coder** —— `-p` 与 `-mhe=on` 全被拒 +2. **C 解码器完全没有 7zAES coder** —— 所以 SDK 自己既解不了加密内容,也解不了加密头。内容侧由我们的 `sevenz_chain.c` 承担;**头部**侧由 `sevenz_header.c` 承担(见 §八) → 因此引擎**自解析 folder blob + 自己驱动 codec 链**(`src/sevenz_chain.c/.h`,pull pipeline:`node_pull(n, dst, want, &got)`,不够就 `node_refill()` 拉上游)。 @@ -199,7 +211,7 @@ LZMA SDK 26.03(public domain,已 vendor 到 `third_party/7z/`,解码子集 ### 5.5 提取门面 -`src/sevenz_extract.c`(1753 行)完全仿 `zip_extract.c` / `rar_extract.c`:scan → extract(staging, 每 entry fsync) → publish(整 rename) → cleanup。 +`src/sevenz_extract.c`(1753 行)完全仿 `zip_extract.c` / `rar_extract.c`:scan → extract(staging,**不 fsync**) → publish(整 rename) → cleanup。 - **OVERWRITE 与 MERGE 对目录-目录碰撞都递归下钻**(仅叶子文件不同) - 三方共用 `src/zipx_common.c`(限额 profile + `zipx_status_string()`) @@ -241,29 +253,76 @@ LZMA SDK 26.03(public domain,已 vendor 到 `third_party/7z/`,解码子集 --- -## 八、唯一功能缺口 +## 八、7z `-mhe=on` 加密头(2026-09-23 已闭合) -### `-mhe=on` 加密头 7z +**它为什么难**:`-mhe=on` 时整个 header 也是一条独立的 7z 流——归档末尾的下一头部区域以 `k7zIdEncodedHeader`(0x17)开头,后接一段 StreamsInfo,描述「一个 folder,其输出就是真正的 header」。而 vendored SDK 的 **C 解码器没有 7zAES coder**,`SzArEx_Open2()` 走到 `SzAr_DecodeFolder()` 就返回 `SZ_ERROR_UNSUPPORTED`,于是**连文件列表都读不出来**(文件名、folder 表、每个条目的尺寸全在那份加密头里)。 -`-mhe=on` 时整个 header(含 folder 表)也被加密,引擎必须在**解析 folder 之前**先用密码解密第二份 header,才能知道有哪些 folder / 用什么 coder。工作量约为标准 7zAES 的 2 倍(两次 AES 解密路径)。 +**做法**(`src/sevenz_header.{c,h}`): +1. 自己读 32 字节 start header,只探**一个字节**——不是 0x17 就立刻 `SZH_PLAIN` 收工(普通的 `-mhc=on` 压缩头、`-mhc=off` 明文头都走这条,SDK 行为一字不变)。 +2. 是 0x17 就整段读下来(CRC 校验),**最小解析** PackInfo + UnpackInfo:pack 位置/大小、folder 的 coder 描述字节范围、每个 coder 的 unpack size、folder CRC。解析刻意宽容——任何异常一律回落 `SZH_PLAIN`,把诊断权留给 SDK,保证非加密归档的报错一字不改。 +3. 把这份描述喂给 **`sz_chain_parse()` / `sz_chain_decode()`**,也就是内容走的同一条 7zAES 路径,密码规则完全一致:需要密码而没给 → `SZH_ERR_PASSWORD`;解出来 CRC 不对 / 不是 `k7zIdHeader` → 同样是密码错。 +4. 造一个**虚拟 `ISeekInStream`**:`[0,32)` 是改写过的 start header(指向明文头),`[hdr_off, hdr_off+L)` 是解出来的明文头,其余一律透传真实归档。`hdr_off` 就用加密头原本所在的偏移,所以**归档里存的任何一个偏移都不用搬**——SDK 在它预期的位置读到明文头,从明文头推出的 dataPos 依旧指向真实的内容 pack 流。 +5. 交给 `SzArEx_Open()`,之后一切照旧(内容仍由 `sevenz_chain.c` 直接读 `sevenz_volstream`)。 -当前 `SZ_ERROR_UNSUPPORTED`,已在 `tests/run-sevenz-tests.sh` 的 `KNOWN_GAPS`(`aeshe`)标注 —— 缺口修好后脚本会主动报错提醒移除。 +**要点/坑**: +- 明文头比它替换掉的那条记录**长**(实测 aeshe.7z:记录 64 B、明文 462 B),所以虚拟流的 `total` 要取 `max(真实文件长度, hdr_off+L)`,否则 `SzArEx_Open2()` 的 seek-to-END 长度检查会报 `SZ_ERROR_INPUT_EOF`。 +- **`LookToRead2_INIT` 不 seek**,第一次 `Look` 从真实流当前位置读。`szh_prepare()` 会把流移来移去,所以交给 SDK 之前必须显式 seek 回 0(`sevenz_extract.c` 里那一段有注释)。这原本是个隐性依赖。 +- 明文头大小有上限(`SZH_MAX_HEADER` 64 MiB),sink 按需增长、不信头部里声明的 unpack size。 +- 只处理 `numFolders == 1`(SDK 自己对这条记录就传 `numFoldersMax = 1`)与 `external == 0`。 + +**覆盖**:`tests/fixtures-7z/aeshe.7z`(密码 `Secret123`),`tests/run-sevenz-tests.sh` 的 `KNOWN_GAPS` 已清空——chain 驱动与 façade 两条路径都跑通;`test_sevenz_extract.c --cases` 另验无密码 / 错密码 → `ZIPX_ERR_PASSWORD`、正确密码 → 成功,且失败后不留 staging。 ### 真机端到端待验证 ELF 已构建,但需装 PS5 实测: 1. ZIP / RAR / 7z 三类**分卷**真机解压 -2. **加密 7z(7zAES)** 真机解压 +2. **加密 7z(7zAES + `-mhe=on` 加密头)** 真机解压(`aeshe.7z` 那类归档在真机上连文件列表都要走新代码) 3. 160GB / 9.5 万文件大 ZIP 4. 分卷 RAR 进度条实时走动 -5. UI 右下角版本号显示 `v1.9.2`(`/api/version` 与兜底字面量应一致) +5. UI 右下角版本号显示(`/api/version` 与兜底字面量应一致) + +> **📊 2026-09-23 首个真机性能数据**:解一个 **18 GB 的包**,**11 分钟**、**1252 个条目** +> (平均 14.7 MB),UI 报 **10–40 MB/s**;**18 GB 是压缩包自身的大小**(✅ 已确认)。 +> **格式 = RAR**(✅ 已确认);包原本在 **PC 上**,**经插件上传**进 PS5,**上传速度 30–40 MB/s**。 +> 口径是**解压后的字节**(`zip_extract.c:651-678` 累加 `uncompressed_size`,`:900/910` 累加 +> `write()` 写出的解压字节),且是 **250 ms 采样的瞬时值**(`task.c:202-206`,进度只在 ≥1 MiB +> 时上报)—— 摆动里含采样噪声,**只有「总字节 ÷ 总耗时」可信**。 +> 仍然成立的一条:**per-entry 开销不是主因**(平均 14.7 MB/条目,不是小文件场景)。 +> +> **⚠️ 2026-09-23 晚 更正:本节原先写的「解码不是瓶颈」已撤回。** 两条理由: +> ① 它拿「PS5 解 **RAR**」的 28 MiB/s 去比「PC 解 **7z**」的 427 MiB/s —— **不同格式、不同 +> 解码器、不同机器**,量级论证不成立;② 「4× 摆动 = 解码无罪」此前已降级为 250 ms 采样 +> 噪声的弱证据。**原先那句「源盘交付速度 ≈ 28 MiB/s 是硬上界」同样站不住** —— 它是从总耗时 +> 反推的*观测结果*,不是设备能力上限;若解码是瓶颈,源盘恰恰没跑满。 +> +> **③ 新发现(有据可查、且直接针对真实负载):RAR 解码在 PS5 上是单线程的。** +> `third_party/unrar7/os.hpp:43-45` 的 `#define RAR_SMP` 落在 `#ifdef _WIN_ALL` 分支**内** +> ⇒ POSIX 构建不定义(我们 Makefile 里 0 次出现),而官方 POSIX makefile 第 11 行是 +> `DEFINES=… -DRAR_SMP` —— **我们漏了这个开关**。后果:`unpack50mt.cpp` +> (`Unpack::Unpack5MT`,rarlab 专门调过的多线程 RAR5 解压器)**没编进来**, +> `unpack.cpp:185-198` 的 MT 分支整段不参与编译,`SetThreads` / `ThreadPool` 一并消失。 +> ⇒ **首要假设:28 MiB/s ≈ 单线程 RAR5 解码的正常量级**(`18 GB ÷ 660 s` 是**解码输入**速率; +> 而上传实测证明**写入端**至少能到 30–40 MB/s、**读取通常快于写入** ⇒ 纯存储上限解释不了它)。 +> ⚠️ 自查一条:**用 ELF 符号表查内部符号是无效手段** —— 该 ELF 只有 `.dynsym`(513 项)、 +> **无 `.symtab`**,「零命中」是假象(本次差点据此误判)。结论来自 Makefile 与 `os.hpp`。 +> **下一步**:先在 PC/WSL 上 A/B(`-DRAR_SMP` + `unpack50mt.cpp` + `-pthread`,跑同一批 RAR5 +> fixture 并保证 37 项 RAR 断言全绿),收益显著再上真机;同时真机补两个小事实 +> (**RAR4 还是 RAR5**、**解压后多大**)与 `T_copy`。详见 `docs/EXTRACTION-PERF.md` §六 文首 +> 更正块与 `docs/REAL-CONSOLE-PROFILE.md`。 +> +> **⇒ 终局(2026-09-23 18:15,用户决定):这条线不做。** 不做的依据是**当前证据判不了收益**, +> 而不是没收益:MT 只并行**解码**(worker 只跑 `unpack50mt.cpp:190` 的 `UnpackDecodeThread`), +> 写盘恒为主线程串行(`UnpWriteBuf()` 只在 `unpack50mt.cpp:283/475/587` 被主线程调用)—— +> 若瓶颈在写路径(真机上传已证明写入端只有 30–40 MB/s),收益退化为 1.0×。要判定必须先做 +> `T_copy`(拿插件自己的 `TASK_COPY` 搬同一份包,`src/filemgr.c:836`),再决定是否值得改构建 +> 并刷机验证;用户选择停在第一步之前。**重开的第一个动作是 `T_copy`,不是改 `-DRAR_SMP`。** ### 可选项(非阻塞) -- **性能**:实测上游(7-Zip 本体)在 **7z 格式上快 1.9×(单线程)/ 3.4×(8 线程)**;ZIP 无显著差异。差距不在我们的架构(我们比 SDK 自己的 `SzArEx` 路径还快 1.02×)。**已全部落地(2026-09-16)**:①汇编解码器(`LzmaDecOpt.asm`+jwasm,1.26×,无 jwasm 自动退纯 C)②多线程 LZMA2(`Lzma2DecMt`,8 线程,1.37×,线程失败自动降级 chain;BCJ2/加密布局仍走 chain)③ZIP 逐条目 fsync 移除(8000 文件 ≥14×)。7z 现与 7-Zip 单线程打平、ZIP 已压过官方(本机受 Defender 拖累不可比,PS5 无该因素)。RAR 与官方 UnRAR 同速(unrar 自带 `target("aes")` SIMD 已启用,无逐条目 fsync)。完整数据见 `docs/EXTRACTION-PERF.md`,基准工具 `tests/bench_driver.py` -- fsync 批量化(每 64MB/N 条刷一次)—— 9.5 万文件级可省 20–30 分钟 +- **性能**:**优化前**实测上游(7-Zip 本体)在 7z 格式上快 1.9×(单线程)/ 3.4×(8 线程);ZIP 无显著差异。差距不在我们的架构(我们比 SDK 自己的 `SzArEx` 路径还快 1.02×)。**已全部落地(2026-09-16)**:①汇编解码器(`LzmaDecOpt.asm`+jwasm,1.26×,无 jwasm 自动退纯 C)②多线程 LZMA2(`Lzma2DecMt`,8 线程,1.37×,线程失败自动降级 chain;BCJ2/加密布局仍走 chain)③ZIP 逐条目 fsync 移除(8000 文件 ≥14×)。7z 现与 7-Zip 单线程打平、ZIP 已压过官方(本机受 Defender 拖累不可比,PS5 无该因素)。RAR 与官方 UnRAR 同速(unrar 自带 `target("aes")` SIMD 已启用,无逐条目 fsync)。完整数据见 `docs/EXTRACTION-PERF.md`,基准工具 `tests/bench_driver.py`。**⚠️ 但「单线程打平 / 8 线程 1.59×」是在最有利的输入形状上测的**:基准归档是 `-m0=lzma2 -ms=on` 的**单文件**(`tests/bench_driver.py:179`),恰好是唯一能让 `sz_chain_lzma2_root()`(`src/sevenz_chain.c:750`,要求 1 coder / 0 bond / 1 pack stream / 纯 LZMA2)生效的形状;真实的**多 folder / BCJ2 / 7zAES** 归档会让 MT 失效、退回单线程 chain —— 所以那个 1.59× **既不是上限也不是下限,方向未知**。剩余优化(CRC 硬件化、MT 扩到 BCJ2、条目级并行、ZIP inflate 换 libdeflate、AES-NI)**全部集中在 7z 多线程这一条线上**,建议**先拿真实归档在真机上 profile 再排序**,别按 PC 上这份数字动手 +- ~~fsync 批量化(每 64MB/N 条刷一次)~~ → **已作废,改为「彻底移除」**(2026-09-16):实际落地的不是批量刷,而是把 ZIP 引擎的逐条目 fsync 直接删掉(RAR/7z 本来就没有),三引擎统一为「**不 sync、只 rename**」——publish 是纯 rename、也没有续解功能,该 fsync 无收益。8000 文件 fixture:fsync 版 >200 s 未跑完 → 无 fsync **14.5 s(≥14×)**。已知取舍:publish 之后到落盘之间断电,可能出现「文件在但内容不完整」;要补只需在 extract 收尾做**一次**目录/整盘 flush(PS5 是 FreeBSD 系,`syncfs()` 不一定有,`sync()` 是全盘、偏重)。代码现状见 `src/zip_extract.c:937-945`,实测见 `docs/EXTRACTION-PERF.md:18-21` - 解压失败保留 staging 支持续解(中等改动) -- 进度条 % / 文字进度 / ETA 三处口径统一为字节 +- ~~进度条 % / 文字进度 / ETA 三处口径统一为字节~~ → **已完成**(`assets/main.js:2071-2079`,条目计数已移除并注明原因) --- @@ -286,7 +345,9 @@ ELF 已构建,但需装 PS5 实测: ## 十、工作区状态 -工作树已干净(`git status` 仅剩有意保留的未跟踪文档)。 +⚠️ **2026-09-23:工作树不再干净** —— 本轮加密改动(15 个已修改 + 8 个未跟踪文件)**尚未提交**,见文末「十一、本轮变更」。发版前需要先决定版本号并 commit。 + +以下为 2026-09-15 的历史清理记录(当时工作树干净,"仅剩有意保留的未跟踪文档")。 已清理(2026-09-15): @@ -298,3 +359,185 @@ ELF 已构建,但需装 PS5 实测: > ⚠️ **清理这类特殊文件名时**:`SHFileOperationW`(带 `FOF_ALLOWUNDO` 走回收站)对含私用区码位的路径会返回 `ERROR_FILE_NOT_FOUND (2)`,**但动作实际已生效**。删完务必查 `C:\$Recycle.Bin\\$I*` 记录确认落在回收站(`$I` 存原路径 UTF-16,`$R` 是内容)。本沙箱里 `Add-Type` 与 `rm` 都被拦(后者有 safe-delete 钩子),只能用 Python `ctypes` 调 shell32。 `.build/` 下的探针/调试产物已被 `.gitignore` 白名单覆盖,不再污染 `git status`。 + +--- + +## 十一、本轮(2026-09-23)变更:加密通道补齐(未提交) + +**目标**:让 ZIP 与 RAR 的加密归档真正可解(7zAES 早已可用)。两者此前都报 +`extract_unsupported`,但**缺口在引擎侧,不在 UI** —— 密码框、`password=` 字段、 +`err_extract_password` 文案从 v1.9 起就已就位。 + +### 11.1 ZIP:给裁剪过的 minizip-ng 补一个 crypto 后端 + +`third_party/minizip-ng` 是裁到只读路径的精简副本,`mz_zip.c` 里 +`#ifdef HAVE_WZAES / HAVE_PKCRYPT` 的分支保留着,但**对应的流与 crypto 后端被裁掉了**。 +本轮补回: + +| 文件 | 状态 | 说明 | +|---|---|---| +| `src/mz_strm_wzaes.{c,h}` | 上游 4.2.2 原样恢复 | WinZip AES 流(方法 99 + `0x9901` 扩展字段) | +| `src/mz_strm_pkcrypt.{c,h}` | 上游 4.2.2 原样恢复 | 传统 PKWARE / ZipCrypto 流 | +| `src/mz_crypt_wfm.c` | **新写**(~860 行) | 本地 crypto 后端:SHA-1、HMAC-SHA1、AES-128/192/256 | + +后端要点: +- S-box 与 GF(2^8) log/alog 表**首次使用时推导**,所以不新增 `.rodata` 查表(实测 `.rodata` 仅 +256 B,是字符串)。 +- 随机数直接 `open("/dev/urandom")`,**不要**走 `mz_os_rand()` —— 后者会退回 `rand()`/`srand()`,把两个新符号塞进导入表。最终产物**动态符号零新增**。 +- PBKDF2 复用 vendored 的 `mz_crypt.c`(与上游逐字节一致),没有重写。 +- 非 SHA-1 算法与 AEAD aad 一律返回 `MZ_SUPPORT_ERROR`(本项目只读,不需要)。 +- KAT 先行:写完后先用 FIPS 197 / RFC 3174 / RFC 2202 / RFC 6070 / SP 800-38A + 向量单独验算(`.build/kat_crypto.c`,24/24),再接线。踩到的三个坑:AES 仿射用 `rol32` + 应为 `rol8`;GF 乘法 `(uint8_t)(a+b) % 255` 截断,应全程 int;HMAC 的 ipad 必须由 + **已 XOR 过 0x5c 的 opad** 再推。 + +### 11.2 RAR:把 `RARSetPassword` 接上 + +`src/rar_extract.{c,h}`:新增 `password` 形参(`rar_extract()` 为第 8 个参数)。 +调用点是 `RAROpenArchiveEx` 之后、**首次 `RARReadHeaderEx` 之前**——这是解密 `-hp` +头加密归档的硬性顺序要求(scan 与 extract 两个阶段各自开档,两处都要设)。 +`ERAR_MISSING_PASSWORD` / `ERAR_BAD_PASSWORD` 由 `ZIPX_ERR_UNSUPPORTED` 改映射为 +`ZIPX_ERR_PASSWORD`;`RHDF_ENCRYPTED` 只在**没给密码**时提前拒绝。 + +### 11.3 前端:密码失败后自动重试(这步不做,功能等于不可达) + +原先密码框**只对 7z 弹**(`actionExtract()` 里的 `isSevenZipArchive()` 判断), +ZIP/RAR 加密归档失败后用户根本没机会输密码。现在 `handleTerminalTask()` 在 +`op === "extract" && error_code === "extract_password"` 时走 +`retryExtractWithPassword()`: + +- 记忆原始请求(`extractRetryKey(task.id)` → `{conflict, removeSource, name, large, attempts}`), + 重试时**保持冲突策略与大文件选配**; +- **必须按任务 id 记,不能按路径记**(v1.9.3M 后期修正)。路径在传输中被 + 「服务端 JSON 逐字节转义为 `\u00XX`」+「`fs_path_value()` 反向还原成原始字节」这一对 + 转换改了表示 ⇒ **非 ASCII 目录下**「页面手里的路径」≠「任务回报的路径」,按路径查必然 + 落空 ⇒ 口令框永远不弹,用户只看到一个失败框,必须先手动再解压一次。任务 id 由服务端 + 分配、原样回传,不受编码影响;重试时也改用**服务端回报的** `task.src` / `task.dst` 重发。 + 消费即删(重试注册到新 id 下),Map 最多留 8 条(同一时刻只可能有一个活动任务)。 +- 最多 3 次;取消或空输入即放弃,回落到原有失败提示; +- 7z 保留提前询问(免得白跑一次 scan + folder 解析)。 + +新增文案 `extractPasswordRetryAsk`(重试:密码不正确)+ `extractPasswordFirstAsk`(首次: +此压缩包已加密),与提前询问用的 `extractPasswordAsk` 区分 —— 第一次失败时用户还没输过密码, +再说「密码不正确」就是误导。 + +错误文案里的条目名必须过 `decodeFsText()`:`backendErrorText()` 原样用了 `error_arg`, +而服务端把它逐字节转义过 ⇒ 中文/日文条目名在错误框里显示成 `â®…ç§.psd`。列表侧一直有这层 +翻译(`displayName()`),只有错误文案漏了。 + +同源的编码坑:`pathJoin(服务端回报的目录, 本地文件名)` 把两种表示混进同一个字符串,而 +`fs_path_value()` **只要发现任一个码点 > 0xFF 就整体不修** ⇒ 中文名文件放进中文名目录时 +路径失效。新增 `encodeFsText()`(`decodeFsText()` 的逆)在拼接前把本地名转成同一表示, +`uploadAndExtractFile()` 与 `actionNewText()` 两处都用它。 + +无头回归:`.build/ui_retry_test.mjs`(真 `main.js` 载入桩 DOM,40 checks,含「非 ASCII 目录 +必须仍弹口令框」的回归用例)、`.build/ui_upload_menu_test.mjs`(40 checks:i18n 键覆盖、 +菜单接线、样式、高亮规则的层叠作用域,以及**解压按钮不许被隐藏、只许被置灰**)、 +`.build/preview_check.mjs`(无头 Chromium 跑真页面,验菜单开关、页脚布局、**解压按钮的 +显隐/置灰/提示随选区变化**,并**读回三种交互状态下高亮的计算值**;该脚本已改为失败即 +非零退出)。 + +**菜单行的「选中高亮」曾被两条规则同时破坏**(用户报「选中下面那个高亮效果不对」): +① 全局 `button:focus` 的 `outline: 3px + offset 2px` 是按 54px 工具栏按钮设计的,套在 46px +菜单行上会越过面板 6px 内边距、压住相邻行,且 outline 的圆角半径不随 offset 自适应 ⇒ 视觉上 +成了一个「脱离的框 + 两侧挂着的弧线」;② 面板自己的 +`.upload-menu-list button:hover:not(:disabled)` **从未生效过** —— 它与 +`button:not(.row-action):hover:not(:disabled)` 特异性同为 `(0,3,1)`,而后者在文件里更靠后 ⇒ +后者胜出,于是 hover 是 `#303945`、focus 是 `#2b343e`,**两个高亮两个颜色**,且一行 hover 时 +另一行仍因 focus 亮着 ⇒ 看起来「两行同时被选中」。修法:两条规则都收敛到面板 id +(`#uploadMenu button:…`,`(1,1,1)` / `(1,2,1)` 稳赢通用规则),行只用填充表示选中,键盘焦点 +提示改为**行内 `inset` 环**(`box-shadow: inset 0 0 0 2px`)—— 画在行内,任何行高都不可能 +越界。**这类坑只有真引擎读计算值才抓得住**,光看源码两条规则都「像是对的」。 + +**解压按钮改为「常显 + 置灰」**(用户要求「直接显示出来 只不过是灰色的 只有能解压的文件才可以 +点击」):`index.html` 去掉 `hidden`,`renderExtractButton()` 不再碰 `.hidden`,改成按选区设 +disabled 并给一条说明原因的工具提示(什么都没选 ⇒ 新增 `extractSelectArchive`;只选中子卷 ⇒ +沿用 `extractSelectMainVolume`;选中**多个** ⇒ 新增 `extractOneAtATime`,旧代码这种情况错用了 +「请改选主卷」,文案本身是错的)。🪤 **`button:disabled` 带 `pointer-events: none` ⇒ 禁用按钮 +无法 hover,`title` 永远不弹** —— 必须像既有的 `.parent-nav-button:disabled` 那样把 +`pointer-events` 还回来(点击仍无效,`disabled` 属性本身挡激活)。标签同时从 `extractToCurrent` +(「解压到当前目录」)换成短词 `extract`(「解压」),与工具栏其他动词一致:**常显按钮不该 +同时又是最宽的那个**(英文下 `Extract to current folder` 会到 107 px)。 +**代价必须实测而不是估**:按钮宽 96 px ⇒ 工具栏换行阈值(zh)1080 → 1190 px、(en)1230 → +1350 px。`.build/preview_check.mjs` 已把阈值**钉成断言**(1920/1600/1280 必须都是一行), +并按四种选区验 disabled / opacity / title;该脚本同时从「只打印」改成**失败即非零退出**。 + +### 11.4 构建坑:编译选项变化必须让目标文件失效(**改 Makefile 前必读**) + +`make` **看不见**编译选项变化。加 `-DHAVE_WZAES -DHAVE_PKCRYPT` 后, +`mz_zip.o` / `mz_crypt.o` 被判定为最新而复用 → 此时已无线程引用新流 → +`--gc-sections` 把加密代码再丢一次,**链接却报成功**(本次第一次构建的产物与 +已发布 v1.9.2 **逐字节相同**,`readelf` 才发现 `.text` 只长了 336 B)。 + +修法(取代原先的 `LzmaDec.o` 特例):把第三方编译选项写进标记文件, +内容变了才重编。 + +```make +PS5_FLAGS_STAMP := ps5-obj/.third_party_cflags +LINUX_FLAGS_STAMP := linux-obj/.third_party_cflags +$(PS5_FLAGS_STAMP): FORCE + @printf '%s\n' '$(THIRD_PARTY_C_FLAGS_7Z) $(LZMA_DEC_OPT_FLAG)' > $@.tmp + @cmp -s $@.tmp $@ || { mv -f $@.tmp $@; echo ' [cflags] ...'; } +``` + +> 诊断手法:拿未 strip 的产物比 `readelf -S` 各段尺寸,而不是看总体积。 +> 改一个字符串常量会重排 `.rodata` 字符串池,字节 diff 会被放大到几万字节, +> 但段尺寸是守恒的——判断"代码到底有没有变"要看段尺寸 + 助记符序列。 +> 现成脚本:`.build/_seccmp.py`、`.build/_operandcheck.sh`。 + +### 11.5 产物与验证 + +| 项 | 值 | +|---|---| +| 主机测试 | ZIP 140 + RAR 37 = **177 checks / 0 失败**(`tests/run-tests.sh`) | +| 前端测试 | **27 checks / 0 失败**(`.build/ui_retry_test.mjs`) | +| ELF | 870,680 B · sha256 `b1409f5c1bc4b1a39ab337853b956f4807f95c5770dee6eca7a18a62cc08f80e` · e_machine 0x003e(加密轮结束时;`-mhe=on` 之后的产物见 §12.3) | +| 确定性 | 同一源码树构建两次逐字节一致 | +| 段变化(vs 已发布 v1.9.2) | `.text` +11,296 · `.bss` +5,120(AES 表) · `.rodata` +256 · 动态符号零新增 | +| 内嵌资产核验 | ELF 内 gzip 资源中可检出 `retryExtractWithPassword` / `extractPasswordRetryAsk`(普通 `strings` 找不到,要先解 gzip;脚本 `.build/check-elf-gzip.py`) | + +**未做(发版前必做)**:未 commit / tag / 发 Release;真机端到端未验。 +版本号**已升**为 `v1.9.3M`(2026-09-24 加改版标记 `M`,见 §2.2)。 +README(中英)、CHANGELOG、本文档已同步为「未发布」状态。 + +--- + +## 十二、本轮(2026-09-23)变更:7z `-mhe=on` 加密头(未提交) + +**目标**:补上最后一个 7z 格式缺口(设计与坑见 §八)。 + +### 12.1 新增 + +| 文件 | 说明 | +|---|---| +| `src/sevenz_header.{c,h}` | **新写**(~900 行)。头部读取 + `k7zIdEncodedHeader` 最小解析 + 虚拟 `ISeekInStream` | +| `Makefile` | `src/sevenz_header.c` 进 `COMMON_SRCS`(PS5 与 linux 共用) | +| `tests/run-sevenz-tests.sh` | 编 `sevenz_header.o` 进 `ENGINE_OBJS`;`KNOWN_GAPS` 清空;façade 矩阵加入 `aeshe` | +| `tests/sevenz_chain_e2e.c` | 按与产品相同的顺序接线 `szh_prepare()`(否则 chain 矩阵读不了 `aeshe`) | +| `tests/test_sevenz_extract.c` | `aeshe` 三例(无密码 / 错密码 → `ZIPX_ERR_PASSWORD`;正确密码 → 成功),另修一处 `snprintf` 截断告警 | + +### 12.2 关键设计(细节见 §八) + +- 只探**一个字节**:不是 `0x17` 立刻返回 `SZH_PLAIN`,SDK 行为与改动前完全一致(12 个既有 fixture 全部复跑通过)。 +- 解析刻意宽容:PackInfo/UnpackInfo 之外的任何异常都回落 `SZH_PLAIN`,把诊断权留给 SDK。 +- 复用 `sz_chain_parse()` / `sz_chain_decode()`,所以 7zAES 的密码/错误语义与内容侧**完全同源**,不新增第二个 crypto 实现。 +- 虚拟流的 `total` 必须 `max(真实长度, hdr_off + L)`;`LookToRead2_INIT` 不 seek,交回 SDK 前必须显式 seek 到 0。 + +### 12.3 产物与验证 + +| 项 | 值 | +|---|---| +| 7z 套件 | **27 用例 / 0 失败**,`aeshe` 在 chain 与 façade 两条路径都 `ok`,`KNOWN_GAPS` 为空 | +| 主机测试(ZIP/RAR 回归) | ZIP 140 + RAR 37 = **177 checks / 0 失败**(无回归) | +| ELF | 903,448 B · sha256 `8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7` · e_machine 0x003e(== 本轮最终产物,见 §12.4) | +| 确定性 | 同一源码树构建两次 sha256 相同 | +| 段变化(解压按钮常显 vs 上一轮) | **只有 `.rodata` 变化**:`0x026CC0` → `0x026F00`(+0x240 = 576 B:index.html 去掉 `hidden` 并换短标签、`main.js` 的三条禁用理由、两份语言文件各两条新文案、`.extract-action:disabled` 及其注释)。`.text` 两次均为 `0x087780`、`.data` 均为 `0x00034C` —— 第六次「只改内嵌前端资源」 | +| 段变化(菜单行高亮修复 vs 上一轮) | **只有 `.rodata` 变化**:`0x026B40` → `0x026CC0`(+0x180 = 384 B,三条收敛后的高亮规则加其注释)。`.text` 两次 readelf 均为 `0x087780` —— 又一次「只改内嵌前端资源、不碰 C 逻辑」的标准形状 | +| 段变化(本轮前端三项 vs 上一轮) | **只有 `.rodata` 变化**:`0x026A40` → `0x026B40`(+0x100 = 256 B)。`.text` / `.data` / `.eh_frame*` 一字节未变 —— 「只改内嵌前端资源 + 加两条文案」的标准形状 | +| 段变化(加 `M` 标记 + 修正 `err_extract_unsupported` 文案 vs 加密轮产物) | **只有 `.rodata` 变化**:加 `M` 标记 +0x100(256 B),修正文案再 +0x40(64 B);`.text` / `.data` / `.bss` / `.eh_frame*` / `.gcc_except_table` **一个字节都没变**。又因 16 KiB 段对齐留有余量,**六次构建的文件总尺寸都是 903,448 B**:尺寸相同**不代表**二进制相同(sha256 逐个不同:`f3164efa…` → `53296d29…` → `7b5ab00c…` → `212107a6…` → `da36834d…` → `cf2c0fcf…` → `8ca47d5a…`) | +| 段变化(加密轮 vs 其前一轮) | `.text` +4,880 · `.rodata` +640 · `.eh_frame_hdr` +32 · `.eh_frame` +160 —— 正文合计 **+5,712**;其余 **+27,056** 是 `p_align=0x4000` 的两处段对齐填充(LOAD#1 越过 0x8C000 边界)。**段数仍为 20,动态符号零新增(513 → 513)** | + +> 判读提示:这次文件涨了 32,768 B,但正文只涨 5,712 B —— 不要按体积下结论。 +> 权威做法是比较**段尺寸**与**动态符号集合**(见 §11.4 的诊断手法)。 + +**未做(发版前必做)**:未 commit / tag / 发 Release;真机端到端未验。 +版本号已升为 `v1.9.3M`(改版标记 `M` 于 2026-09-24 加入)。 diff --git a/Makefile b/Makefile index c4e0b69..7eeea57 100644 --- a/Makefile +++ b/Makefile @@ -14,10 +14,25 @@ ifeq ($(MAKECMDGOALS),) endif # Bump this together with the git tag -- it is baked into the binary (the PS5 -# notification and `--version` print it) AND into the output filename, so a -# stale value silently mislabels everything. Override per-build with: -# make VERSION_TAG=v1.9.2 -VERSION_TAG ?= v1.9.2 +# notification, the stdout banner and /api/version all print it) AND into the +# output filename, so a stale value silently mislabels everything. Override +# per-build with: +# make VERSION_TAG=v1.9.3M +# +# **The trailing M is the fork marker** (Modified build, maintained by +# LisherSong). Upstream owendswang releases are plain `vX.Y.Z`, so any string +# carrying the M is ours and anything without it is not. The marker rides on +# VERSION_TAG rather than on a separate display-only constant on purpose: it +# therefore reaches every surface at once -- /api/version, the PS5 start-up +# notification, the stdout banner, the UI footer and the ELF file name -- and +# is impossible to forget in one of them. The file name gains a second benefit: +# a fork build can no longer collide with an upstream artifact of the same +# upstream version, which has already caused two mix-ups (a local +# `web-file-mgr-v1.9.2.elf` sitting next to the released one under the same +# name, and a `-DVERSION_TAG=v1.9.1` leftover wearing the released 870 488 B +# file's size). To cut a build that is byte-for-byte upstream-shaped, pass +# `make VERSION_TAG=v1.9.3`. +VERSION_TAG ?= v1.9.3M TITLE_ID := FMGR88888 PYTHON ?= python3 STRIP ?= $(PS5_PAYLOAD_SDK)/bin/prospero-strip @@ -30,7 +45,7 @@ HOST_PKG_CONFIG ?= pkg-config # and you can tell at a glance which ELF is on the USB stick. BIN := web-file-mgr-$(VERSION_TAG).elf LINUX_BIN := web-file-mgr-linux-$(VERSION_TAG) -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/version.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 src/zipx_common.c src/sevenz_extract.c src/sevenz_chain.c src/sevenz_volstream.c src/sevenz_mt.c src/demangle_stub.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/version.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 src/zipx_common.c src/sevenz_extract.c src/sevenz_chain.c src/sevenz_header.c src/sevenz_volstream.c src/sevenz_mt.c src/demangle_stub.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/*)) @@ -84,9 +99,16 @@ THIRD_PARTY_C_SRCS := $(wildcard third_party/zlib/src/*.c) $(wildcard third_pa # every one of these, so we just enable them for the 7z TU family instead of # dropping AesOpt.c (Aes.c references those HW symbol names via AesGenTables). SEVENZ_C_FLAGS := -maes -mavx2 -mvaes +# HAVE_WZAES / HAVE_PKCRYPT switch on minizip-ng's two ZIP encryption paths: +# mz_strm_wzaes.c (WinZip AES, method 99 / extra field 0x9901) and +# mz_strm_pkcrypt.c (traditional PKWARE "ZipCrypto"). The mz_zip.c branches +# behind these macros are already present, so defining them only pulls in the +# two streams plus the local crypto backend in mz_crypt_wfm.c. Everything they +# need (crc32, PBKDF2-HMAC-SHA1, AES-ECB) is implemented in-tree; see the +# header of third_party/minizip-ng/src/mz_crypt_wfm.c. THIRD_PARTY_C_FLAGS := -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -Ithird_party/7z \ -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE \ - -DHAVE_FSEEKO -DZ7_PPMD_SUPPORT + -DHAVE_FSEEKO -DZ7_PPMD_SUPPORT -DHAVE_WZAES -DHAVE_PKCRYPT THIRD_PARTY_C_FLAGS_7Z := $(THIRD_PARTY_C_FLAGS) $(SEVENZ_C_FLAGS) # Assembly-optimised LZMA decoder (optional, on when jwasm is present). @@ -164,18 +186,54 @@ ps5-obj/third_party/7z/LzmaDec.o: THIRD_PARTY_C_FLAGS_7Z += $(LZMA_DEC_OPT_FLA linux-obj/third_party/7z/LzmaDec.o: THIRD_PARTY_C_FLAGS_7Z += $(LZMA_DEC_OPT_FLAG) endif -# make does not track flag changes, and installing or removing jwasm flips the -# switch above. Without this, an existing LzmaDec.o silently keeps the old -# decoder and the asm object just sits in the link line unreferenced (the -# binary comes out byte-identical, which is how the problem was noticed). -ps5-obj/third_party/7z/LzmaDec.o: Makefile -linux-obj/third_party/7z/LzmaDec.o: Makefile +# make does not track compiler-flag changes, so an object built with the old +# flags is silently reused and the binary links "successfully" without the +# feature. Two cases have already bitten: +# * installing or removing jwasm flips LZMA_DEC_OPT_FLAG, and the asm object +# just sits in the link line unreferenced (the binary came out +# byte-identical, which is how the problem was noticed); +# * adding -DHAVE_WZAES / -DHAVE_PKCRYPT, which only mz_zip.c and mz_crypt.c +# compile differently -- without a rebuild the ZIP encryption streams are +# never referenced and --gc-sections quietly drops them again. +# Recording the flags in a stamp file invalidates the objects when the flags +# actually change, instead of rebuilding them for every unrelated Makefile edit. +PS5_FLAGS_STAMP := ps5-obj/.third_party_cflags +LINUX_FLAGS_STAMP := linux-obj/.third_party_cflags -ps5-obj/%.o: %.c +$(PS5_FLAGS_STAMP): FORCE + @mkdir -p $(dir $@) + @printf '%s\n' '$(THIRD_PARTY_C_FLAGS_7Z) $(LZMA_DEC_OPT_FLAG)' > $@.tmp + @cmp -s $@.tmp $@ || { mv -f $@.tmp $@; echo ' [cflags] third-party flags changed -> rebuilding objects'; } + @rm -f $@.tmp + +$(LINUX_FLAGS_STAMP): FORCE + @mkdir -p $(dir $@) + @printf '%s\n' '$(THIRD_PARTY_C_FLAGS_7Z) $(LZMA_DEC_OPT_FLAG)' > $@.tmp + @cmp -s $@.tmp $@ || { mv -f $@.tmp $@; echo ' [cflags] third-party flags changed -> rebuilding objects'; } + @rm -f $@.tmp + +.PHONY: FORCE +FORCE: + +# Note on -DVERSION_TAG / -DTITLE_ID: they live in CFLAGS, which make cannot +# see -- but nothing here relies on make seeing them. The link target's *name* +# carries the version ($(BIN) = web-file-mgr-$(VERSION_TAG).elf), so a bump +# always misses the existing target and re-runs the link rule, and that rule +# compiles every file in $(PS5_SRCS) on the spot (-x c, one clang invocation, +# no intermediate .o). src/version.c and src/main.c -- the only two readers of +# the macros -- are therefore always rebuilt with the new string. (Verified: +# `strings` on the v1.9.3M ELF finds "v1.9.3M" once and "v1.9.2" zero times.) +# +# The version trap that *is* real: overriding VERSION_TAG back to a version +# whose ELF already exists in the tree, with sources older than that file, +# returns the existing file and silently skips the rebuild. Delete the stale +# ELF, or build a differently named copy, when re-cutting a version. + +ps5-obj/%.o: %.c $(PS5_FLAGS_STAMP) @mkdir -p $(dir $@) $(CC) $(if $(findstring third_party/7z,$<),$(THIRD_PARTY_C_FLAGS_7Z),$(THIRD_PARTY_C_FLAGS)) -c -o $@ $< -linux-obj/%.o: %.c +linux-obj/%.o: %.c $(LINUX_FLAGS_STAMP) @mkdir -p $(dir $@) $(HOST_CC) $(if $(findstring third_party/7z,$<),$(THIRD_PARTY_C_FLAGS_7Z),$(THIRD_PARTY_C_FLAGS)) -c -o $@ $< diff --git a/README.md b/README.md index b9d38c3..7a6e6b8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,104 @@ A payload ELF that runs an HTTP file manager inside a jailbroken PS5. Open `http The same source tree builds a Linux binary for development and a PS5 payload ELF for deployment — see `make linux` below. +## What's new (unreleased) + +- **Encrypted ZIP extraction works end to end** — both schemes: + - traditional PKWARE ("ZipCrypto", what `zip -e` writes), and + - WinZip AES-128/192/256 (compression method `99` plus the `0x9901` extra + field, what `7z -mem=AES256` and WinZip write), for stored and deflated + entries. + + A missing or wrong password is reported as `extract_password` — the code the + task overlay already knew how to translate, even though until now nothing + could produce it for a ZIP. The SHA-1, + HMAC-SHA1 and AES primitives live in a new local minizip-ng backend, + `third_party/minizip-ng/src/mz_crypt_wfm.c` (PBKDF2 comes from the vendored + `mz_crypt.c`); its header explains why they are implemented in-tree instead + of delegating to another vendored library. +- **Encrypted RAR is actually wired up.** v1.9 vendored an engine that *could* + decrypt (`RARSetPassword`) but never called it, so encrypted archives were + rejected. The password now reaches the engine, including for `-hp` + header-encrypted archives, and a wrong password returns `extract_password` + so the prompt can retry. +- **Build fix: compiler-flag changes now invalidate objects.** `make` cannot + see flag changes, so adding `-DHAVE_WZAES -DHAVE_PKCRYPT` left the existing + `mz_zip.o` / `mz_crypt.o` in place — and because nothing referenced the new + streams any more, `--gc-sections` quietly dropped the encryption code again + while the link still "succeeded". The Makefile now records the third-party + flag set in `ps5-obj/.third_party_cflags` and rebuilds only when it really + changes. This is the same trap the `LzmaDec.o` rule was working around. +- **The UI can now retry with a password.** An `extract_password` failure no + longer ends in an error box: the attempt is re-sent with whatever the user + types, up to three times, keeping the conflict policy and the large-file + opt-in of the original request. Cancelling or submitting an empty box falls + back to the original failure report. 7z keeps its up-front prompt, since an + encrypted 7z header would otherwise cost a wasted scan. +- **Encrypted 7z headers (`-mhe=on`) now open.** This was the last known format + gap: with `-mhe=on` the file names, the folder table *and* every entry size + sit inside the encrypted header, so the vendored SDK gives up with + `SZ_ERROR_UNSUPPORTED` before it can list a single entry. A new module, + `src/sevenz_header.c`, reads the header record, decodes its one folder with + the project's own 7zAES path (`src/sevenz_chain.c`) and then hands the SDK a + small virtual stream in which the encrypted record has been replaced by the + plaintext — so the SDK goes on parsing exactly the archive it always did, and + nothing on disk is touched. Archives whose header is merely *compressed* + (`-mhc=on`, the default) are not modified in any way, and a wrong password + comes back as `extract_password` like every other encrypted archive. +- `tests/make-zip-enc-fixtures.bat` and three real fixtures under + `tests/fixtures-real/` (`enc-zipcrypto.zip`, `enc-aes256.zip`, + `enc-aes256-store.zip`, password `secret123`). +- Host checks: **140 ZIP + 37 RAR = 177** (`tests/run-tests.sh`). The new + encrypted-ZIP cases run against real archives in `tests/fixtures-real/` + generated by the new `tests/make-zip-enc-fixtures.bat`. +- A RAR archive that asks for a dictionary larger than this build supports no + longer reports as a per-entry size problem: it gets its own + `extract_dict_too_large` code and a message that names the required and the + supported dictionary size. The build's behaviour is unchanged — such an + archive is still refused, because honouring it would mean allocating the + whole window in one shot, which is exactly what rarlab's own CLI refuses to + do by default and what a 16 GB shared-memory console cannot afford. +- 7z checks: **27 cases, 0 failures** (`tests/run-sevenz-tests.sh`), and the + `KNOWN_GAPS` list that held `aeshe` is now empty — the encrypted-header + fixture passes through both the folder decoder and the extraction facade. +- The frontend retry flow has a headless check of its own — + `node .build/ui_retry_test.mjs` loads the real `assets/main.js` into a stubbed + DOM and asserts the remembered request, the retry cap and the give-up paths, + including the regression case for a non-ASCII folder: **40 checks, 0 failures**. + `node .build/ui_upload_menu_test.mjs` covers the markup side — every + `data-i18n` key exists in both languages, the upload menu is wired to the + right handlers, the classes it uses are styled, and the row-highlight rules + are scoped so they cannot lose the cascade to a generic button rule, and that + the extract button is never hidden — only disabled, with a message per reason: + **40 checks, 0 failures**. +- The tree builds to **903 448 B**, sha256 + `8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7`, + `e_machine` `0x003e`. Rebuilt from the same tree, byte-identical both times; + the built ELF was checked to contain the new frontend code, which is only + reachable after gunzipping the embedded assets. Same 20 sections and **no new + dynamic symbols**. The encrypted-archive work added +5 712 bytes of content + (`.text` +4 880, `.rodata` +640, `.eh_frame*` +192); the fork marker below + then added a further +0x100 (256 B), the corrected + `err_extract_unsupported` copy another +0x40 (64 B), the upload menu +0x980 + (2 432 B), the frontend copy and CSS of the first fix round +0x100 (256 B) + and the scoped row-highlight rules +0x180 (384 B), then the always-on extract + button +0x240 (576 B) — every one of them to + `.rodata` **and to no other section**, so the file is still 903 448 B across + all six builds. An unchanged size is not evidence of an unchanged binary — + compare sections with `readelf -SW`. +- **The version string carries a fork marker: `v1.9.3M`.** Upstream releases are + plain `vX.Y.Z`, so the trailing `M` (Modified) is what tells you which of the + two projects a build came from. It is part of `VERSION_TAG`, so `/api/version`, + the PS5 start-up notification, the stdout banner, the UI footer and the ELF + file name all carry it at once, and the UI footer spells it out on hover. The + file name changing also means a fork build can no longer shadow an upstream + artifact of the same upstream version. See Credits. +- Still open: end-to-end validation of the built ELF on a real console. + +> This section describes **unreleased** work: the published release is still +> `v1.9.2` and its binary does **not** contain any of it. The unreleased tree +> identifies itself as `v1.9.3M`. + ## What's new in v1.9.2 Version-string-only re-release. The `v1.9.1` tag sat four commits behind the @@ -62,7 +160,8 @@ the v1.9.1 binary — the only change is the baked-in version string. - **Documentation**: [`CHANGELOG.md`](./CHANGELOG.md), [`docs/UPGRADE-v1.8-rar-support.md`](./docs/UPGRADE-v1.8-rar-support.md) and the vendoring decision tree at - [`third_party/unrar/VENDORED.md`](./third_party/unrar/VENDORED.md). + [`third_party/unrar7/VENDORED.md`](./third_party/unrar7/VENDORED.md) + (v1.8 shipped it as `third_party/unrar/VENDORED.md`). - See the [dedicated section](#rar-extraction) below for scope and the limitations that come from using dmc_unrar (no multi-volume, no encryption in v1.8 — both lift in v1.9 when the library is replaced). @@ -137,7 +236,8 @@ the v1.9.1 binary — the only change is the baked-in version string. - **Upload** — single files or folder trees from any device on the LAN (hidden in the PS5 browser). Atomic temp + rename. - **Download** — single file as raw bytes, or folders/multi-select as a streaming `.tar`. Hidden in the PS5 browser. - **Tasks** — full-screen overlay with delayed show, live progress, throughput, ETA, cancel, and recovery if the browser is closed and reopened mid-task. -- **Archive extraction** — ZIP (encrypted rejected) and RAR (v1.9: RAR4 + RAR5 incl. WinRAR 6/7 "v6", multi-volume; encrypted still rejected pending password UI); see the [ZIP extraction](#zip-extraction) and [RAR extraction](#rar-extraction) sections below for scope. +- **Archive extraction** — ZIP, RAR and 7z, all behind the same zip-bomb / traversal / ratio protection. ZIP covers stored / deflated / ZIP64 plus **encrypted** entries (ZipCrypto and WinZip AES-128/192/256); RAR covers RAR4 + RAR5 including WinRAR 6/7 "v6", multi-volume, and `-p` / `-hp` encryption; 7z covers Copy / LZMA / LZMA2 / PPMd, the Delta and BCJ2 filters, `.7z.001` volumes, 7zAES, and `-mhe=on` encrypted headers. See the [ZIP extraction](#zip-extraction) and [RAR extraction](#rar-extraction) sections below for scope. +- **Encrypted archives** — a wrong or missing password is reported as `err_extract_password` and retried through a password prompt, up to three times, with the original conflict policy and large-file opt-in preserved. 7z asks for the password up front instead, so an encrypted header does not cost a wasted scan. - **PKG** — install and preview `.pkg` files. - **Images** — preview `.png .jpg .jpeg .gif .bmp .webp`. - **Localization** — English + Simplified Chinese, auto-selected from `navigator.languages`. @@ -218,7 +318,7 @@ On first startup, the payload installs a `PS5 Web File Manager` shortcut in the ## ZIP extraction -Plain ZIPs only — stored / deflated / ZIP64, **never encrypted**. The engine is a standalone three-phase module (`scan → extract → publish → cleanup`) at `src/zip_extract.{c,h}`, with a separate host-side C test suite. Each entry is first written into a staging directory (`*.wfm-part-*`), fsynced, then atomically renamed into the destination. Any failure mid-archive rolls back partial changes; cancel and fatal errors always clean up staging. +Plain and encrypted ZIPs — stored / deflated / ZIP64, in the clear or with either encryption scheme (traditional PKWARE "ZipCrypto" and WinZip AES-128/192/256). The engine is a standalone three-phase module (`scan → extract → publish → cleanup`) at `src/zip_extract.{c,h}`, with a separate host-side C test suite. Each entry is first written into a staging directory (`*.wfm-part-*`), then atomically renamed into the destination. There is deliberately **no per-entry `fsync`** anywhere in the extract path — the whole pipeline is "sync nothing, rename everything", because publish is rename-only and there is no resume feature to protect (measured ≥14x on an 8000-file archive; see `docs/EXTRACTION-PERF.md`). Any failure mid-archive rolls back partial changes; cancel and fatal errors always clean up staging. ### Limits @@ -237,12 +337,16 @@ The **default profile** is shipped safe: a 4 MiB compressed blob that decodes to The engine refuses to extract: -- Encrypted entries (any encryption flag set). - Path traversal (`..` segments, absolute POSIX paths, Windows drive letters). - Symbolic links, devices, FIFOs, sockets (`ZIPX_ERR_SPECIAL`). - Duplicate entries or directory/file name clashes inside the same archive. - Archives whose expanded size, entry count, depth, name length or compression ratio breach the active profile. +Encrypted entries are no longer a refusal: the password arrives as `password=` +on `/api/extract`, and a missing or wrong one comes back as `ZIPX_ERR_PASSWORD` +(`err_extract_password` in the UI) so the prompt can retry. The scan phase still +runs for encrypted archives — handing over a password does not skip the limits. + ### Conflict policy Passed as `conflict=` on `/api/extract`: @@ -282,7 +386,7 @@ is dispatched by `src/extract.c` based on extension. | Solid blocks, dictionary up to 1 GiB | ✅ | | | PPMd decompression (RAR 3.0+) | ✅ | | | **Multi-volume** (`.part01.rar` + `.part02.rar` + …) | ✅ | unrar stitches parts by name when the whole set sits next to the volume you open. Select the first volume (`name.part1.rar` / `name.part01.rar`); non-first volumes are still greyed out in the UI with a hint. | -| **Encrypted RAR** | ⏳ | The engine can decrypt (`RARSetPassword`), but the password field / prompt is not wired into `/api/extract` yet. Encrypted archives are rejected up front with `ZIPX_ERR_UNSUPPORTED`. | +| **Encrypted RAR** | ✅ | Both `-p` data encryption and `-hp` header encryption. The password reaches the engine as `password=` on `/api/extract` (`RARSetPassword` runs after `RAROpenArchiveEx` and before the first `RARReadHeaderEx`); a missing or wrong one returns `ZIPX_ERR_PASSWORD` so the prompt can retry. | | Symbolic links / FIFOs / sockets / devices | ❌ | Rejected with `ZIPX_ERR_SPECIAL` (mirrors ZIP behaviour) | | RAR 1.3 (pre-1.4) | ❌ | Rejected upstream by unrar | @@ -337,13 +441,60 @@ re-create the RAR compression algorithm. The project-authored facade > The v1.8 engine `third_party/unrar/dmc_unrar.c` (DrMcCoy/dmc_unrar 1.7.0, > GPL-2.0-or-later) was removed in v1.9; its notice lives in git history. -### Encrypted RAR (planned) +### Encrypted RAR -The engine can decrypt archives (via `RARSetPassword`), but the password -channel — a `password=` field on `/api/extract` plus a frontend prompt — -is not wired yet. Encrypted archives currently fail with -`extract_unsupported`. The engine swap (v1.9) removed the hard engine -limits; the remaining work is purely API/UI plumbing. +The password channel is complete: `password=` on `/api/extract` is handed to +the engine, and `RARSetPassword` runs after `RAROpenArchiveEx` and before the +first `RARReadHeaderEx` — the order unrar needs to decrypt a `-hp` header. A +missing or wrong password comes back as `ZIPX_ERR_PASSWORD` +(`err_extract_password` in the UI), which is what raises the password prompt +and re-sends the original request, up to three times. + +## 7z extraction + +The 7z engine (`src/sevenz_extract.{c,h}`) is built on the LZMA SDK decode +subset plus the project's own pull-based codec chain +(`src/sevenz_chain.c`). Files ending in `.7z` get the same **Extract** button as +`.zip` and `.rar`; `src/extract.c` dispatches by extension, and the engine +re-uses the same three-phase model, limit profiles and conflict policy. + +> Added in v1.9.1. The SDK's own `SzArEx` path only understands folders with up +> to four coders, which cannot express BCJ2's five — hence the self-parsed +> folder table and the pull-based chain. + +A note on the header. 7-Zip keeps the archive header at the end of the file and +compresses it when it grows (`-mhc=on`, the default), which is why the header +region normally starts with an `k7zIdEncodedHeader` record describing one +folder. With `-mhe=on` that folder is *also* encrypted, and since it holds the +file names, the folder table and every entry size, the vendored SDK gives up on +the whole archive before listing anything. `src/sevenz_header.c` handles that +case: it reads the record, decodes its folder through the same 7zAES path the +content uses, and then presents the SDK with a virtual stream whose header +region is the plaintext — the archive on disk is never written to, and an +archive whose header is merely compressed is not touched at all. + +### Scope + +| Format | Support | Notes | +|---|---|---| +| Copy / LZMA / LZMA2 (incl. ZIP64-style sizes) | ✅ | A single-coder pure-LZMA2 folder decodes multi-threaded (`src/sevenz_mt.c`, 8 threads) | +| BCJ2 (x86 branch converter) | ✅ | Through the self-written chain; not expressible in the SDK's `SzArEx` | +| Multi-coder folders, Delta filter, PPC / IA64 / ARM / ARMT / SPARC converters | ✅ | Parsed by `src/sevenz_chain.c` | +| **Volumes** (`.7z.001` / `.z01` chains) | ✅ | `src/sevenz_volstream.c` stitches by name; open the first volume | +| **Content encryption** (7zAES, AES-256-CBC) | ✅ | The engine decrypts; the frontend asks for the password up front (so an unencrypted archive does not pay a wasted scan), passes it as `password=`, and retries on `ZIPX_ERR_PASSWORD` | +| **`-mhe=on` (encrypted header)** | ✅ | `src/sevenz_header.c` decodes the header record itself (through the same 7zAES path) and hands the SDK a virtual stream carrying the plaintext; a wrong password reports `ZIPX_ERR_PASSWORD`, so the prompt retries like any other encrypted archive | +| `-mhc=off` (uncompressed header) | ✅ | Plain headers were always readable; they are now read one byte at a time and left alone | + +### Limits + +The 7z engine re-uses the ZIP limits table verbatim — see +[ZIP extraction → Limits](#limits). + +### Security checks + +The same `zipx_status_t` codes and the same checks as ZIP and RAR: path +traversal, special files, duplicate/clashing entries, and size, entry-count, +depth, name-length or ratio breaches of the active profile. ## Verification @@ -360,59 +511,92 @@ The `e_machine = 0x003e` confirms the PS5 target triple `x86_64-sie-ps5`. The `e ## Tests -A POSIX/host-side C test suite covers the ZIP engine and runs on any Linux / macOS / MSYS shell without the PS5 SDK: +A POSIX/host-side C test suite covers the ZIP, RAR and 7z engines and runs on +any Linux / macOS / MSYS shell without the PS5 SDK: ```sh -cd tests && bash run-tests.sh +cd tests && bash run-tests.sh # ZIP + RAR suites +bash run-sevenz-tests.sh # 7z suite (needs MinGW gcc + a 7-Zip binary) ``` -Output is a per-case `check`-style report — **84 checks** on the current `main` -(70 ZIP + 14 RAR). Coverage: +Output is a per-case `check`-style report — **177 checks** on the current `main` +(140 ZIP + 37 RAR), 0 failures. Coverage: - ZIP entry parsing (stored + deflated + ZIP64) - Path traversal, absolute paths, backslash, Windows drive letters -- Symbolic links, FIFOs, encrypted entries, bad CRC, truncated archives, non-ZIP files +- Symbolic links, FIFOs, bad CRC, truncated archives, non-ZIP files - Limits: `entries`, `total_bytes`, `file_bytes`, `ratio`, `depth`, `name_len` - Conflict policies: `fail` / `overwrite` / `merge` - Cancellation in every phase +- **Encrypted archives** — each real fixture is run four ways (no password, + empty password and wrong password → `ZIPX_ERR_PASSWORD`; correct password → + success with a byte-level content check): `enc-zipcrypto.zip`, + `enc-aes256.zip` and `enc-aes256-store.zip` on the ZIP side, `enc-v6.rar` on + the RAR side. Two further cases prove the limits still apply once a password + has been handed over, and that the failing paths publish nothing. - **Large-file profile** — `medium_bomb.zip` (ratio ≈ 238) is rejected under default caps and accepted under large caps; lowered large caps still enforce. -- **RAR engine** (`tests/test_rar_extract.c`, 14 checks) — format +- **RAR engine** (`tests/test_rar_extract.c`, 37 checks) — format dispatch (renamed ZIP rejected, junk blob rejected), error translation - across every reachable `DMC_UNRAR_*` code, limits handoff (the - `large=1` opt-in flows into `rar_extract()` unchanged). + across every reachable engine code, limits handoff (the + `large=1` opt-in flows into `rar_extract()` unchanged), the oversized + dictionary path above, plus the real-archive coverage above. +- **7z engine** (`tests/test_sevenz_extract.c` + `tests/run-sevenz-tests.sh`) — + byte-for-byte comparison against real `.7z` fixtures, encrypted-header + rejection, conflicts under every policy, cancellation, limits, a missing + destination parent, and the guarantee that a failure publishes nothing and + cleans up its staging tree. + +The frontend retry flow has its own headless check — +`node .build/ui_retry_test.mjs` loads the real `assets/main.js` into a stubbed +DOM and asserts the remembered request, the retry cap and the give-up paths: +**27 checks, 0 failures**. It lives in `.build/` (outside the gitignore +whitelist), so it is a development-time script rather than a committed test. ## Project layout ``` . -├── Makefile # PS5 + Linux builds (VERSION_TAG v1.8.2) +├── Makefile # PS5 + Linux builds (VERSION_TAG v1.9.3M) ├── install-libmicrohttpd.sh # one-shot dependency installer ├── gen-asset-module.py # embeds assets/* as gzip-compressed C arrays ├── assets/ # HTML / CSS / JS / icons / param.json ├── src/ # C payload sources │ ├── main.c websrv.c filemgr.c # entry, HTTP frontend, task model -│ ├── upload.c download.c # stream handlers -│ ├── extract.c # /api/extract dispatcher (ZIP + RAR) -│ ├── zip_extract.{c,h} # ZIP engine (v1.7) -│ ├── rar_extract.{c,h} # RAR engine (v1.8, dmc_unrar backend) -│ └── app_installer.c # PS5 Media launcher installer -├── third_party/ # vendored: zlib, minizip-ng, dmc_unrar -│ └── unrar/ -│ ├── dmc_unrar.c # GPL-2.0-or-later, verbatim upstream -│ └── dmc_unrar_api.h # project-authored facade header +│ ├── upload.c download.c text.c # stream and in-place edit handlers +│ ├── extract.c # /api/extract dispatcher (ZIP + RAR + 7z) +│ ├── zip_extract.{c,h} zipx_common.c # ZIP engine (minizip-ng backend) +│ ├── zipx_volume.c zipx_volstream.c # ZIP volume detection + concatenating stream +│ ├── rar_extract.{c,h} # RAR engine (rarlab UnRAR 7.20.1 backend) +│ ├── sevenz_extract.{c,h} sevenz_chain.{c,h} # 7z engine, self-parsed codec chain +│ ├── sevenz_header.{c,h} # 7z header reader / -mhe=on decryption +│ ├── sevenz_mt.c sevenz_volstream.c # multi-threaded LZMA2 + .7z.001 volumes +│ ├── app_installer.c pkg_installer.c pkg_info.c # PS5 PKG preview / install +│ └── demangle_stub.c cpu_support_stub.c # size / portability stubs +├── third_party/ # vendored libraries +│ ├── unrar7/ # rarlab UnRAR 7.20.1 — RAR engine +│ ├── minizip-ng/ # 4.2.2, trimmed to the read path +│ ├── 7z/ # LZMA SDK 26.03 decode subset +│ └── zlib/ # minizip's compression backend ├── tests/ # POSIX/host test suite -│ ├── test_zip_extract.c -│ ├── test_rar_extract.c # 14 RAR negative-path checks (v1.8) -│ ├── make_fixtures.py # regenerate test fixtures -│ ├── run-tests.sh # one-shot runner (now runs ZIP + RAR suites) -│ ├── compat/ # tiny Win32/MSYS shims -│ └── fixtures/ # generated test ZIPs (and a couple of stub .rar blobs) +│ ├── test_zip_extract.c test_rar_extract.c test_sevenz_extract.c +│ ├── sevenz_chain_e2e.c sevenz_e2e.c bigfile_e2e.c +│ ├── make_fixtures.py make_sevenz_fixtures.py make_split_fixtures.py +│ ├── run-tests.sh # one-shot runner (ZIP + RAR suites) +│ ├── run-sevenz-tests.sh # 7z suite, carries the KNOWN_GAPS list +│ ├── bench_driver.py bench_formats.py # throughput benchmarks +│ ├── compat/ # tiny Win32/MSYS shims +│ └── fixtures/ fixtures-7z/ fixtures-real/ ├── docs/ -│ ├── HANDOVER.md # engineering handover / dev playbook (also §14 v1.8 close-out) +│ ├── HANDOVER.md # v1.8-era playbook, historical — see the root HANDOVER.md +│ ├── SIZE-OPTIMIZATION.md # ELF size analysis + per-symbol ledger +│ ├── EXTRACTION-PERF.md # decompression benchmarks +│ ├── REWRITE-FEASIBILITY.md # engine-extraction study +│ ├── UPSTREAM-V1.8-COMPARISON.md │ ├── UPGRADE-v1.7-zip-large-file-profile.md │ ├── UPGRADE-v1.8-rar-support.md -│ └── screenshots/ # README screenshot images -├── THIRD_PARTY_NOTICES # bundled-library credits (incl. dmc_unrar section) +│ └── screenshots/ # README screenshot images +├── THIRD_PARTY_NOTICES # per-library licence summary +├── HANDOVER.md # current engineering handover ├── LICENSE # GPLv3+ └── README.md ``` @@ -438,15 +622,47 @@ Output is a per-case `check`-style report — **84 checks** on the current `main uncompressed file). If you exceed the default, confirm the large-file prompt (appears for archives > 480 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 - (`.part02+.rar`), very-old RAR 1.4, RAR symlinks / FIFOs, or a file - that is neither `.zip` nor `.rar`. For RAR specifically the message - lists the failure cause and points the user back to a PC extractor. +- **`err_extract_unsupported`** — the archive is one this build cannot read: + a file that is neither `.zip` nor `.rar` nor `.7z`, a ZIP entry using a + compression method other than stored/deflated, a 7z folder with an + unsupported coder, a split set whose naming is not recognised (a RAR set + named `x.rar.001` must be renamed to `x.part1.rar`, `x.part2.rar`, …), or a + RAR older than 1.4. Encrypted and multi-volume archives are **not** in this + category — both are supported. The backend's own sentence is appended in + parentheses and names the actual cause. +- **`err_extract_dict_too_large`** — a RAR archive declares a compression + dictionary larger than this build supports (4096 MiB) and unrar asked for + permission to exceed it. The message states both the size the archive needs + and the size the build allows. This is refused on purpose: the alternative is + a single allocation of the entire dictionary window, which rarlab's own CLI + rejects by default and which a 16 GB shared-memory console cannot sustain. + Recompress the file on a PC with a dictionary of 4 GiB or less (`-md`), or + extract it there. Note that the RAR5 format itself caps the field at 4 GiB, + so this can only come from an archive written in the newer RAR7 header + format. +- **`err_extract_password`** — the archive is encrypted and the password was + missing or wrong. That includes a 7z archive with an encrypted header + (`-mhe=on`): the file names and entry sizes live inside the header, so + nothing at all can be listed until the header decrypts. ZIP and RAR raise a + password prompt on failure and retry the same request with what you type (up + to three times; cancel or an empty box gives up); 7z asks before it starts, + since an encrypted 7z header would otherwise cost a wasted scan. ## Credits -This project was built with reference to these projects: +This project is a **fork of [owendswang/ps5-web-file-manager](https://github.com/owendswang/ps5-web-file-manager)** (GPL-3.0). The web UI, +the task model and the PS5 packaging all originate there, and the upstream +author's release under GPL-3.0 is what makes this derivative work possible. + +**Telling a fork build from an upstream one:** since v1.9.3 the version string +carries an `M` suffix (`vX.Y.ZM`) — *M* for *Modified*. Upstream owendswang +releases are plain `vX.Y.Z`. So `v1.9.2` is upstream/fork-shared numbering while +`v1.9.3M` can only have come from this repository; the same letter appears in +the ELF file name, the PS5 start-up notification, `/api/version` and the web UI +footer. Releases before v1.9.3M predate the convention and keep their plain +numbers. + +Built with reference to these projects: - **[ps5-payload-dev/websrv](https://github.com/ps5-payload-dev/websrv):** HTTP server structure, static asset embedding ideas, PS5 browser/websrv behaviour and PKG install function. License: GPLv3+. - **[ps5-payload-dev/ftpsrv](https://github.com/ps5-payload-dev/ftpsrv):** PS5 payload conventions, home-screen launcher/install flow reference, process handling style and startup installation reference. License: GPLv3+. @@ -455,10 +671,12 @@ This project was built with reference to these projects: - **[libmicrohttpd](https://ftp.gnu.org/gnu/libmicrohttpd/):** Used as the embedded HTTP server library. Licensed by GNU under the LGPL; this payload links it as the SDK-provided static library. - **[ps5-payload-dev/sdk](https://github.com/ps5-payload-dev/sdk):** Payload building foundation. License: GPLv3+. - **[etaHEN](https://github.com/etaHEN/etaHEN):** ShellUI URI navigation used to return to the PS5 home screen before exit. License: GPLv3. -- **[ezremote](https://github.com/cy33hc/ps5-ezremote-client):** Preview PKG info. License: GPLv2. +- **[ezremote](https://github.com/cy33hc/ps5-ezremote-client):** cited for the PKG-preview feature. License: **GPL-2.0-only** — its source files carry no "or later" notice, so it is **not** combinable with this GPL-3.0 codebase. **No code was taken from it:** `src/pkg_info.c` is an independent C99 implementation (it also reads the `.pkg` entry table and `param.json` fields, for which ezremote has no counterpart, and it uses the hand-written tokenizer in `src/json_util.c` rather than json-c). See `docs/REWRITE-FEASIBILITY.md` §2.2. - **[zlib-ng/minizip-ng](https://github.com/zlib-ng/minizip-ng):** ZIP reader used by the `/api/extract` endpoint. Vendored under `third_party/minizip-ng/`. License: zlib. - **[zlib](https://www.zlib.net/):** Compression backend for minizip-ng. Vendored under `third_party/zlib/`. License: zlib. -- **[DrMcCoy/dmc_unrar](https://github.com/DrMcCoy/dmc_unrar):** RAR reader used by the `/api/extract` endpoint. Vendored under `third_party/unrar/` as a single-file drop-in (`dmc_unrar.c`); the project-authored facade `dmc_unrar_api.h` carries the project's own licence. License: GPL-2.0-or-later — see `third_party/unrar/COPYING`. +- **[rarlab UnRAR](https://www.rarlab.com/rar_add.htm)** — RAR reader used by the `/api/extract` endpoint since v1.9. Vendored under `third_party/unrar7/` (version 7.20.1, the RARDLL source set). License: **UnRAR freeware license** — see `third_party/unrar7/license.txt`. Note this is a restricted licence rather than a FLOSS one: it permits using the source to handle RAR archives but forbids using it to build a RAR-compatible compressor. +- **[opello/unrar](https://github.com/opello/unrar)** — the mirror the vendored rarlab sources were fetched from (commit `97e1780`). +- **[DrMcCoy/dmc_unrar](https://github.com/DrMcCoy/dmc_unrar)** — RAR engine shipped in v1.8 only, superseded in v1.9 by rarlab UnRAR (it could not decode RAR5 "v6" archives or multi-volume sets). Removed from the tree; its licence was GPL-2.0-or-later. ## License @@ -471,12 +689,14 @@ in addition to this project's GPL license. The vendored `zlib` and `minizip-ng` sources are distributed under the zlib license; retain the copyright notices in `third_party/zlib/LICENSE` and `third_party/minizip-ng/LICENSE` when redistributing binaries built -with this feature. The vendored `dmc_unrar` (RAR engine) is distributed -under the GPL-2.0-or-later; retain the copyright notice in -`third_party/unrar/COPYING` and ship the corresponding sources when -redistributing binaries built with v1.8 or later (the `web-file-mgr.elf` -binary is already GPLv3+, so the additional source-disclosure -requirement is the only practical effect). +with this feature. + +The vendored `third_party/unrar7/` sources (rarlab UnRAR — the RAR engine +behind `src/rar_extract.c`) are **not** GPL: they ship under the UnRAR +freeware license (see `third_party/unrar7/license.txt`), which forbids +using them to develop a RAR-compatible compressor. Keep that notice and +that restriction intact when redistributing. `THIRD_PARTY_NOTICES` carries +the full per-library summary. ## Disclaimer diff --git a/README.zh-CN.md b/README.zh-CN.md index 3e5427c..5f304e0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -16,25 +16,78 @@ 同一套源码树可构建出供开发用的 Linux 二进制,以及供部署的 PS5 载荷 ELF——见下方 `make linux`。 +> **第一次用、不想看技术细节?** 直接看 +> [《新手使用说明》](docs/USER-GUIDE-zh-CN.md):怎么装、怎么传文件、怎么解压(含带密码与分卷的包)、 +> 界面上每句话是什么意思,以及与上游原版的差别——全部用大白话写。 + +## 未发布内容(下次发版将包含) + +**加密归档现在可以端到端解压——ZIP(两种方案)、RAR,以及带头加密的 7z 都已打通。** + +此前所有加密归档都会被提前拒绝,尽管密码输入框、失败提示与 `extract_password` 文案从 v1.9 起就已就位。真正的缺口在引擎侧,而不在 UI: + +- **ZIP**:vendored 的 minizip-ng 在裁剪时把 crypto 后端一起裁掉了,于是(未被改动的)`mz_zip.c` 里那些 `-DHAVE_WZAES` / `-DHAVE_PKCRYPT` 分支没有实现可调。 +- **RAR**:rarlab UnRAR 本身能解密,但 `RARSetPassword` 从未被调用。 +- **7z**:`-mhe=on` 把文件名与 folder 表放进了加密头,归档连列出都做不到。 + +三者现在都已接线。密码缺失或错误会统一报为 `extract_password`(引擎层即 `ZIPX_ERR_PASSWORD`),也就是任务浮层已有的密码提示所响应的那个错误码。 + +### 变更 + +- **版本号加改版标记:`v1.9.3` → `v1.9.3M`。** 上游 owendswang 的发布版是纯 `vX.Y.Z`,因此这个 `M`(Modified,改版)就是「上游原版还是本仓改版」的判据。它是 `VERSION_TAG` 的一部分,所以 `/api/version`、PS5 启动通知、stdout 横幅、网页右下角**与 ELF 文件名**会一次性全部带上;网页右下角另加悬浮提示(`versionTooltip`,中英各一)解释这个字母的含义,免得没读过发行说明的人无从判断。产物名随之改变,也顺带让本仓产物再不可能与上游同版本号的资产同名相撞。 + +### 新增 + +- **加密 ZIP** —— 传统 PKWARE(「ZipCrypto」,即 `zip -e` 写出的格式)与 WinZip AES-128/192/256(压缩方法 `99` + `0x9901` 扩展字段,即 `7z -mem=AES256` 写出的格式),stored 与 deflated 条目均支持。 +- **加密 RAR** —— `-p` 内容加密与 `-hp` 头加密。`RARSetPassword` 现在在 `RAROpenArchiveEx` 之后、首次 `RARReadHeaderEx` 之前调用,这正是 unrar 解密 RAR5 头所需的顺序。 +- `/api/extract` 的 `password=` 现在对两个引擎都能真正走到解密路径。空值或缺失视为「无密码」,因此表单原值可以直接透传。 +- `third_party/minizip-ng/src/mz_crypt_wfm.c` —— 为裁剪后的 minizip-ng 提供的本地 crypto 后端:SHA-1、HMAC-SHA1、AES-128/192/256;S-box 与 GF(2^8) 表在首次使用时推导,因此二进制不新增任何 `.rodata` 查表。PBKDF2 复用 vendored 的 `mz_crypt.c`;随机数直接读 `/dev/urandom`(不再是 `mz_os_rand()`),从而把 `rand`/`srand` 排除在导入表之外。以下文件按上游 4.2.2 原样恢复:`mz_strm_wzaes.{c,h}`、`mz_strm_pkcrypt.{c,h}`。 +- **前端在密码失败后可直接重试**:`extract_password` 失败不再只是弹一个错误框,而是弹出密码输入框并按原参数(冲突策略、大文件选配)重新发起同一次解压,最多重试 3 次;取消或留空则回落到原有的失败提示。 +- **加密 7z 头(`-mhe=on`)现在可以打开。** 这是最后一个已知的格式缺口:`-mhe=on` 时文件名、folder 表**与每个条目的尺寸**全都在加密头里,因此 vendored SDK 在能列出任何条目之前就以 `SZ_ERROR_UNSUPPORTED` 退出。新模块 `src/sevenz_header.c` 读出该头部记录,用它自己的那一个 folder 走项目自研的 7zAES 路径(`src/sevenz_chain.c`)解码,然后交给 SDK 一个虚拟流——把加密头所在区域替换成明文。SDK 于是照常解析它一向解析的那个归档,磁盘上的文件完全不被改动;头部只是被*压缩*(`-mhc=on`,默认)的归档完全不受影响;密码错误则与其它加密归档一样返回 `extract_password`。 +- `tests/make-zip-enc-fixtures.bat`,以及 `tests/fixtures-real/` 下的三个真实 fixture(`enc-zipcrypto.zip`、`enc-aes256.zip`、`enc-aes256-store.zip`,密码 `secret123`)。 + +### 修复 + +- **编译选项变化现在会使目标文件失效。** `make` 察觉不到编译选项变化,因此加上 `-DHAVE_WZAES -DHAVE_PKCRYPT` 后旧的 `mz_zip.o` / `mz_crypt.o` 原样保留——又因为此时已没有任何代码引用新流,`--gc-sections` 会在链接「成功」的同时把加密代码再次丢掉(本次改动的第一次构建产物与已发布的 release 逐字节相同)。Makefile 现在把第三方编译选项集记录进 `ps5-obj/.third_party_cflags` / `linux-obj/.third_party_cflags`,只在真正变化时重编——这正是早年 `LzmaDec.o` 规则所规避的同一个陷阱,现已通用化。 +- `ZIPX_ERR_UNSUPPORTED` 不再涵盖加密,现在仅表示「多卷或不受支持的压缩方法」。 +- 顺带把 `tests/test_sevenz_extract.c` 里一处会导致截断告警的 `snprintf` 缓冲区调足。 + +### 测试 + +- `tests/test_zip_extract.c` 对每个加密 fixture 跑四种情况(无密码 → `PASSWORD`、空密码 → `PASSWORD`、错密码 → `PASSWORD`、正确密码 → `ZIPX_OK` 并逐字节校验内容),另加一项证明「提供密码后限额依旧生效」。 +- `tests/test_rar_extract.c` 对 `enc-v6.rar` 做同样的四种情况验证,包括失败路径绝不发布任何文件。 +- `tests/test_sevenz_extract.c` 对 `aeshe.7z` 跑三种情况:无密码 → `ZIPX_ERR_PASSWORD`、错密码 → `ZIPX_ERR_PASSWORD`、正确密码 → 成功且逐字节一致,并证明失败后不留下 staging 目录。 +- 前端重试流程有一份无头检查(`.build/ui_retry_test.mjs`,把 `assets/main.js` 载入桩 DOM):**40 项检查**,覆盖参数记忆、重试上限、取消与空密码的回落,以及「非 ASCII 目录下必须仍然弹出密码框」的回归用例。另有一份 `.build/ui_upload_menu_test.mjs`(**40 项检查**)盯标记侧:i18n 键在两份语言文件里都存在、上传菜单接对了回调、用到的 class 确实有样式、菜单行高亮规则必须带面板作用域(否则会输给通用按钮规则而静默失效),以及**解压按钮不许被隐藏、只许被置灰**(顺带扫 `main.js` 里 117 个 `t("...")` 键是否双语齐全)。 +- 主机端合计:**140 ZIP + 37 RAR = 177 项检查**,0 失败。 +- 请求的字典超过本构建支持上限的 RAR 归档不再被误报成「单条目过大」:它有独立的 `extract_dict_too_large` 编码,报错文案同时给出归档需要的字典与构建支持的上限。构建行为**未变** —— 这类归档仍然被拒,因为放行意味着一次性分配整个字典窗口,而这正是 rarlab 自家 CLI 默认拒绝、16 GB 共享内存的主机也承受不了的。 +- 7z 套件:**27 项用例,0 失败**(`tests/run-sevenz-tests.sh`),且原先登记 `aeshe` 的 `KNOWN_GAPS` 列表现已**清空**——加密头 fixture 同时通过 folder 解码器与解压 façade 两条路径。 +- 当前源码树构建产物 **903 448 B**,sha256 `8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7`,`e_machine` 为 `0x003e`;同一源码树构建两次逐字节一致。产物内已确认包含新的前端代码(前端资源是 gzip 内嵌的,需先解压才能在二进制里检索到)。段数仍为 20,**动态符号零新增**。加密归档那批改动净增 5 712 字节正文(`.text` +4 880、`.rodata` +640、`.eh_frame*` +192);加 `M` 标记再让 `.rodata` 涨 0x100(256 B),修正 `err_extract_unsupported` 文案再涨 0x40(64 B),上传菜单再涨 0x980(2 432 B),第一轮修复的文案与 CSS 再涨 0x100(256 B),菜单行高亮的收敛规则再涨 0x180(384 B),解压按钮常显(去掉 `hidden`、换短标签、三条禁用理由、`.extract-action:disabled`)再涨 0x240(576 B),**其余段尺寸一个都没变**,因此文件总尺寸仍是 903 448 B。**尺寸没变不等于内容没变** —— 判断只看 `readelf -SW` 的段尺寸。 + +### 仍未完成 + +- 在真机上做端到端验证。 + +> 注意:本节描述的是**未发布**状态。已发布版本号仍为 `v1.9.2`,其二进制**不含**上述加密支持;当前未发布的源码树自报版本为 `v1.9.3M`。 + ## v1.9.2 与 v1.9.1 新增内容 > **v1.9.2 与 v1.9.1 的功能完全相同,只换了内嵌版本号。** 原因是原先的 `v1.9.1` tag 指在产出发布二进制的提交**之前 4 个提交**,tag 与产物对不上(clone 该 tag 无法重建出发布的那份 ELF);v1.9.2 重新从产出该二进制的提交上打,使 tag = 源码 = 二进制。 - **7z 解压引擎**(`src/sevenz_extract.{c,h}`):自研解码子集 + 拉式 codec 链(`src/sevenz_chain.c`,覆盖 LZMA2 / BCJ2 等),由 `src/extract.c` 按扩展名分派,与 ZIP / RAR 共用同一套三阶段模型与限额档位。`.7z` 文件在文件列表中同样带「解压」按钮。 -- **7zAES 内容解密**(AES-256-CBC):引擎层可解密带密码的 7z 内容;密码输入 UI / API 通道尚未接入,目前加密归档仍被拒绝。 +- **7zAES 内容解密**(AES-256-CBC):引擎可解密带密码的 7z 内容,密码经 `/api/extract` 的 `password=` 传入;解压 7z 时前端会提前询问密码。ZIP / RAR 的加密当时尚未打通(引擎侧缺口,见顶部「未发布内容」),v1.9.1 时对它们仍会报 `extract_unsupported`。 - **7z 分卷**:`.7z.001` / `.z01` 等链式分卷由 `src/sevenz_volstream.c` 按名拼接,打开首个分卷即可。 - **性能三项**(纯解码提速,不影响功能面): - SDK 汇编 LZMA 解码器(`Asm/x86/LzmaDecOpt.asm` + jwasm,无 jwasm 自动回退纯 C)≈ 1.26×。 - 纯 LZMA2 文件夹多线程解码(`src/sevenz_mt.c` + `Lzma2DecMt`,8 线程)≈ 1.37×。 - 移除 ZIP 逐条目 fsync,减少 staging 重命名前的写盘开销。 -- **唯一缺口**:7z `-mhe=on` 加密头(独立单元,读取需自研头解析器),其余 7z 特性均已支持。 +- **当时唯一缺口**:7z `-mhe=on` 加密头(独立单元,读取需自研头解析器),其余 7z 特性均已支持。已在顶部「未发布内容」中补上。 ## v1.9 新增内容 - **RAR 引擎替换为官方 rarlab UnRAR 7.20.1**(`third_party/unrar7/`,取代 dmc_unrar)。这正是让 RAR 解压在真实文件上可用的一步:dmc_unrar 无法解码 **WinRAR 6.x/7.x** 写出的归档(RAR5「v6」压缩),也不支持多卷;两者现在都能工作。 - **RAR5「v6」归档可解压**(v1.8 时代在 WinRAR 6/7 文件上报「归档损坏」的问题已消失)。 - **多卷 RAR**(`.part01.rar` 链):当完整卷集与被打开的卷放在同一目录时,unrar 按文件名拼接各部分。 -- 引擎可解密加密 RAR(`RARSetPassword`)——密码 UI / API 接线仍未完成,加密归档暂时被拒绝。 +- 引擎可解密加密 RAR(`RARSetPassword`)——发 v1.9 时密码 UI / API 接线尚未完成,加密归档会被拒绝;该接线已在顶部「未发布内容」中补齐。 - 主机测试现用真实归档(v6 / 加密 / 3 卷 fixture,提交于 `tests/fixtures-real/`):**70 ZIP + 24 RAR = 94 项检查**。 ## v1.8 新增内容 @@ -42,7 +95,7 @@ - **单卷 RAR 解压**,基于内置的 FLOSS 库 [`dmc_unrar`](https://github.com/DrMcCoy/dmc_unrar)(GPL-2.0-or-later)。支持 RAR 1.5、2.x、3.x、4.x、5.x 归档。`.rar` 文件出现在文件列表中且「解压」按钮可用;`.part02+.rar` 子卷上的按钮置灰,提示「请选择主卷」——v1.8 无法拼接多卷 RAR(见下方 [RAR 解压](#rar-解压) 章节)。 - 新引擎 `src/rar_extract.c` 与既有 `src/zip_extract.c` 之间**共享解压协议**:相同的 `zipx_status_t` 状态码、相同的 `zipx_limits_t` 档位(默认 / `large=1`)、相同的三阶段模型(`scan → extract → publish → cleanup`)、相同的 staging 目录布局、相同的冲突策略、相同的错误映射到任务 UI。`src/extract.c` 中的分派器只是一个微小的 `ends_with_ci(…)` 判断。 - **14 个新增主机端 C 测试**(`tests/test_rar_extract.c`)接入现有 `tests/run-tests.sh`。覆盖:格式分派、每个影响 RAR 用户的 `DMC_UNRAR_*` 错误码翻译、限额档位交接。主机检查总数:**69 ZIP + 14 RAR = 83**。 -- **文档**:[`CHANGELOG.md`](./CHANGELOG.md)、[`docs/UPGRADE-v1.8-rar-support.md`](./docs/UPGRADE-v1.8-rar-support.md),以及 `third_party/unrar/VENDORED.md` 中的 vendoring 决策树。 +- **文档**:[`CHANGELOG.md`](./CHANGELOG.md)、[`docs/UPGRADE-v1.8-rar-support.md`](./docs/UPGRADE-v1.8-rar-support.md),以及 `third_party/unrar7/VENDORED.md` 中的 vendoring 决策树(v1.8 时为 `third_party/unrar/VENDORED.md`)。 ## v1.8.1 新增内容 @@ -87,7 +140,8 @@ - **上传** —— 从局域网内任意设备上传单文件或文件夹树(在 PS5 浏览器中隐藏)。原子化的临时文件 + 重命名。 - **下载** —— 单文件以原始字节下载,或文件夹/多选以流式 `.tar` 下载。在 PS5 浏览器中隐藏。 - **任务** —— 全屏覆盖层,带延迟显示、实时进度、吞吐率、ETA、取消,以及浏览器中途关闭重开后的恢复能力。 -- **归档解压** —— ZIP(加密拒绝)、RAR(v1.9:RAR4 + RAR5 含 WinRAR 6/7「v6」、多卷;加密仍拒绝,待密码 UI 接入)、7z(v1.9.1:LZMA2 / BCJ2 / 分卷 / 内容加密;`-mhe=on` 加密头除外)。详见下方 [ZIP 解压](#zip-解压)、[RAR 解压](#rar-解压)、[7z 解压](#7z-解压)。 +- **归档解压** —— ZIP、RAR、7z 三种引擎,均带防 zip 炸弹 / 路径穿越 / 压缩比保护。ZIP 覆盖 stored / deflated / ZIP64 以及**加密**条目(ZipCrypto 与 WinZip AES-128/192/256);RAR 覆盖 RAR4 + RAR5(含 WinRAR 6/7「v6」)、多卷,以及 `-p` / `-hp` 加密;7z 覆盖 LZMA / LZMA2 / PPMd、Delta 与 BCJ2、`.7z.001` 分卷、7zAES 与 `-mhe=on` 加密头。详见下方 [ZIP 解压](#zip-解压)、[RAR 解压](#rar-解压)、[7z 解压](#7z-解压)。 +- **加密归档重试** —— 解压遇到加密归档时,会弹出密码输入框并按原参数自动重试(最多 3 次);也可以在解压 7z 时提前输入密码以免白跑一次扫描。 - **PKG** —— 安装并预览 `.pkg` 文件。 - **图片** —— 预览 `.png .jpg .jpeg .gif .bmp .webp`。 - **本地化** —— 英文 + 简体中文,根据 `navigator.languages` 自动选择。 @@ -168,7 +222,7 @@ http://${PS5_IP_ADDRESS}:8888/ ## ZIP 解压 -仅支持普通 ZIP——stored / deflated / ZIP64,**绝不解密**。引擎是一个独立的三阶段模块(`scan → extract → publish → cleanup`),位于 `src/zip_extract.{c,h}`,配有独立的主机端 C 测试套件。每个条目先写入 staging 目录(`*.wfm-part-*`),fsync 后原子重命名到目标位置。归档中途任何失败都会回滚部分改动;取消与致命错误总会清理 staging。 +支持普通 ZIP 与加密 ZIP——stored / deflated / ZIP64,传统 PKWARE(ZipCrypto)与 WinZip AES-128/192/256 两种加密方案。引擎是一个独立的三阶段模块(`scan → extract → publish → cleanup`),位于 `src/zip_extract.{c,h}`,配有独立的主机端 C 测试套件。每个条目先写入 staging 目录(`*.wfm-part-*`),再原子重命名到目标位置。解压路径里**刻意不做逐条目 `fsync`**——整条流水线是「不 sync、只 rename」,因为 publish 只是 rename、也没有续解功能需要保护(8000 文件档实测 ≥14×,见 `docs/EXTRACTION-PERF.md`)。归档中途任何失败都会回滚部分改动;取消与致命错误总会清理 staging。 ### 限额 @@ -187,12 +241,13 @@ http://${PS5_IP_ADDRESS}:8888/ 引擎拒绝解压以下归档: -- 加密条目(设置了任何加密标志)。 - 路径穿越(`..` 段、绝对 POSIX 路径、Windows 盘符)。 - 符号链接、设备、FIFO、套接字(`ZIPX_ERR_SPECIAL`)。 - 同一归档内的重复条目或目录/文件名冲突。 - 解压后尺寸、条目数、嵌套深度、名称长度或压缩比突破当前档位。 +加密条目**不再**属于拒绝项:密码通过 `/api/extract` 的 `password=` 传入,缺失或错误时返回 `zipx` 层的 `ZIPX_ERR_PASSWORD`(前端对应 `err_extract_password`),由界面提示后重试。scan 阶段对加密条目同样生效——限额不会因为提供了密码而被跳过。 + ### 冲突策略 通过 `/api/extract` 上的 `conflict=` 传入: @@ -226,7 +281,7 @@ RAR 解压引擎(`src/rar_extract.{c,h}`)由 **官方 rarlab UnRAR 源码** | Solid 块、最大 1 GiB 字典 | ✅ | | | PPMd 解压(RAR 3.0+) | ✅ | | | **多卷**(`.part01.rar` + `.part02.rar` + …) | ✅ | 当完整卷集与被打开的卷同处一目录时,unrar 按名拼接。选择首个卷(`name.part1.rar` / `name.part01.rar`);非首卷在 UI 中仍置灰并给出提示。 | -| **加密 RAR** | ⏳ | 引擎可解密(`RARSetPassword`),但密码字段/提示尚未接入 `/api/extract`。加密归档以 `ZIPX_ERR_UNSUPPORTED` 被提前拒绝。 | +| **加密 RAR** | ✅ | `-p` 内容加密与 `-hp` 头加密均可。密码经 `/api/extract` 的 `password=` 传入引擎(`RARSetPassword` 在 `RAROpenArchiveEx` 之后、首次 `RARReadHeaderEx` 之前调用);缺失或错误返回 `ZIPX_ERR_PASSWORD`,界面提示后重试。 | | 符号链接 / FIFO / 套接字 / 设备 | ❌ | 以 `ZIPX_ERR_SPECIAL` 拒绝(与 ZIP 行为一致) | | RAR 1.3(1.4 之前) | ❌ | 被 unrar 上游拒绝 | @@ -262,9 +317,9 @@ RAR 引擎应用与 ZIP 引擎相同的检查——复用 `zipx_status_t` 状态 > v1.8 引擎 `third_party/unrar/dmc_unrar.c`(DrMcCoy/dmc_unrar 1.7.0,GPL-2.0-or-later)已在 v1.9 移除;其声明留存于 git 历史。 -### 加密 RAR(计划中) +### 加密 RAR -引擎可解密归档(经 `RARSetPassword`),但密码通道——`/api/extract` 上的 `password=` 字段加前端提示——尚未接线。加密归档目前以 `extract_unsupported` 失败。引擎替换(v1.9)已移除硬性引擎限制;剩余工作纯粹是 API/UI 接线。 +密码通道现已完整接通:`/api/extract` 的 `password=` 会被透传给引擎,并在 `RAROpenArchiveEx` 之后、首次 `RARReadHeaderEx` 之前通过 `RARSetPassword` 交给 unrar(这个顺序是解密 `-hp` 加密头的前提)。密码缺失或错误一律返回 `ZIPX_ERR_PASSWORD`(前端 `err_extract_password`),界面据此弹出密码框并按原参数重试,最多 3 次。 ## 7z 解压 @@ -272,6 +327,8 @@ RAR 引擎应用与 ZIP 引擎相同的检查——复用 `zipx_status_t` 状态 > v1.9.1 新增。SDK 自带的 `SzArEx` 路径仅覆盖 4 个 coder 的文件夹,不足以装下 BCJ2 的 5 coder;本项目改为自研 folder 解析 + 拉式 codec 链,从而原生支持 BCJ2 与多 coder 组合。 +关于头部:7-Zip 把归档头放在文件末尾,并在头部变大时把它压缩(`-mhc=on`,默认行为),所以头部区域通常以一条 `k7zIdEncodedHeader` 记录开头、描述一个 folder。`-mhe=on` 时那个 folder **也被加密**,而它装着文件名、folder 表与每个条目的尺寸,于是 vendored SDK 在能列出任何条目之前就对整个归档放弃。`src/sevenz_header.c` 负责这种情况:读出该记录,用与内容完全相同的那条 7zAES 路径解出它的 folder,再把一个「头部区域是明文」的虚拟流交给 SDK——磁盘上的归档从不被写入,仅仅被*压缩*过头的归档也完全不受影响。 + ### 支持范围 | 格式 | 支持 | 备注 | @@ -280,8 +337,9 @@ RAR 引擎应用与 ZIP 引擎相同的检查——复用 `zipx_status_t` 状态 | BCJ2(x86 反汇编后处理) | ✅ | 经自研拉式链;SDK `SzArEx` 装不下 5 coder 时由本项目承载 | | 多 coder 组合文件夹 | ✅ | 自研 `sevenz_chain.c` 解析 | | **分卷**(`.7z.001` / `.z01` 链) | ✅ | `src/sevenz_volstream.c` 按名拼接;打开首个分卷 | -| **内容加密**(7zAES,AES-256-CBC) | ⏳ | 引擎可解密;密码 UI / API 尚未接入,暂时以 `ZIPX_ERR_UNSUPPORTED` 拒绝 | -| **`-mhe=on` 加密头** | ❌ | 需自研头解析器;vendored SDK 在涉及我们之前就以 `SZ_ERROR_UNSUPPORTED` 拒绝。此为唯一已知缺口 | +| **内容加密**(7zAES,AES-256-CBC) | ✅ | 引擎可解密;解压 7z 时前端会**提前**询问密码(避免为无密码归档白跑一次扫描 + folder 解析),密码经 `password=` 传给引擎,缺失或错误返回 `ZIPX_ERR_PASSWORD` 并可重试 | +| **`-mhe=on` 加密头** | ✅ | `src/sevenz_header.c` 自行解码该头部记录(复用同一条 7zAES 路径),再把一个携带明文的虚拟流交给 SDK;密码错误返回 `ZIPX_ERR_PASSWORD`,与其它加密归档一样由提示重试 | +| `-mhc=off`(未压缩头) | ✅ | 明文头一向可读;现在按字节探测后完全不做干预 | 当某归档被拒绝时,用户同样收到 `extract_unsupported` 失败,UI 显示双语重试指引。 @@ -315,23 +373,26 @@ cd tests && bash run-tests.sh # ZIP + RAR 套件 bash run-sevenz-tests.sh # 7z 套件(需 MinGW gcc 与 7-Zip 二进制) ``` -输出为逐用例的 `check` 风格报告,覆盖: +输出为逐用例的 `check` 风格报告。当前 `main` 上为 **177 项检查**(140 ZIP + 37 RAR),0 失败;7z 套件另有 **27 项用例**,同样 0 失败。覆盖: - ZIP 条目解析(stored + deflated + ZIP64) - 路径穿越、绝对路径、反斜杠、Windows 盘符 -- 符号链接、FIFO、加密条目、坏 CRC、截断归档、非 ZIP 文件 +- 符号链接、FIFO、坏 CRC、截断归档、非 ZIP 文件 - 限额:`entries`、`total_bytes`、`file_bytes`、`ratio`、`depth`、`name_len` - 冲突策略:`fail` / `overwrite` / `merge` - 每个阶段的取消 +- **加密档案** —— `tests/fixtures-real/` 下的真实归档各跑四种情况(无密码 / 空密码 / 错密码 → `ZIPX_ERR_PASSWORD`;正确密码 → 成功并逐字节校验内容):ZIP 侧覆盖 `enc-zipcrypto.zip`、`enc-aes256.zip`、`enc-aes256-store.zip`,RAR 侧覆盖 `enc-v6.rar`;另验证「提供密码后限额依旧生效」与「失败路径绝不发布文件」 - **大文件档位** —— `medium_bomb.zip`(比率 ≈ 238)在默认限额下被拒、在大档位下通过;降低后的大档位仍生效 -- **RAR 引擎**(`tests/test_rar_extract.c`)—— 格式分派、每个可达 `DMC_UNRAR_*` 码的错误翻译、限额交接 -- **7z 引擎**(`tests/test_sevenz_extract.c` + `tests/run-sevenz-tests.sh`)—— 真实 `.7z` fixture 逐字节比对、加密头拒绝、各策略下的冲突、取消、限额、缺失目标父目录,以及失败时绝不发布且 staging 树被清理的保证 +- **RAR 引擎**(`tests/test_rar_extract.c`,37 项检查)—— 格式分派(改名的 ZIP / 垃圾数据均被拒)、每个可达引擎错误码的翻译、限额交接(`large=1` 原样传入 `rar_extract()`)、超过上限的字典路径,以及真实归档覆盖 +- **7z 引擎**(`tests/test_sevenz_extract.c` + `tests/run-sevenz-tests.sh`)—— 真实 `.7z` fixture 逐字节比对、加密头(三种情况:无密码 / 错密码 / 正确密码)、各策略下的冲突、取消、限额、缺失目标父目录,以及失败时绝不发布且 staging 树被清理的保证 + +前端还有一份无头检查 `.build/ui_retry_test.mjs`(`node .build/ui_retry_test.mjs`):把 `assets/main.js` 载入桩 DOM,验证加密失败后的密码重试流程——参数记忆、重试上限、取消与空密码的回落,共 27 项检查。它位于 `.build/`(gitignore 白名单之外),属于开发期验证脚本。 ## 项目结构 ``` . -├── Makefile # PS5 + Linux 构建(VERSION_TAG v1.9.1) +├── Makefile # PS5 + Linux 构建(VERSION_TAG v1.9.3M) ├── install-libmicrohttpd.sh # 一次性依赖安装器 ├── gen-asset-module.py # 将 assets/* 内联为 gzip 压缩的 C 数组 ├── assets/ # HTML / CSS / JS / 图标 / param.json @@ -339,10 +400,12 @@ bash run-sevenz-tests.sh # 7z 套件(需 MinGW gcc 与 7-Zip 二 │ ├── main.c websrv.c filemgr.c # 入口、HTTP 前端、任务模型 │ ├── upload.c download.c # 流处理 │ ├── extract.c # /api/extract 分派器(ZIP + RAR + 7z) -│ ├── zip_extract.{c,h} # ZIP 引擎 +│ ├── zip_extract.{c,h} zipx_common.c # ZIP 引擎 +│ ├── zipx_volume.c zipx_volstream.c # ZIP 分卷探测 + 拼接流 │ ├── rar_extract.{c,h} # RAR 引擎(unrar7 后端) │ ├── sevenz_extract.{c,h} # 7z 引擎 │ ├── sevenz_chain.{c,h} # 7z 拉式 codec 链(BCJ2 等) +│ ├── sevenz_header.{c,h} # 7z 头部读取 / `-mhe=on` 解密 │ ├── sevenz_mt.{c,h} # 7z 多线程 LZMA2 解码 │ ├── sevenz_volstream.{c,h} # 7z 分卷流拼接 │ └── app_installer.c # PS5 Media 启动器安装器 @@ -361,7 +424,7 @@ bash run-sevenz-tests.sh # 7z 套件(需 MinGW gcc 与 7-Zip 二 │ ├── compat/ # 小型 Win32 / MSYS 垫片 │ └── fixtures/ fixtures-7z/ fixtures-real/ # 生成的测试归档 ├── docs/ -│ ├── HANDOVER.md # 工程交接 / 开发手册 +│ ├── HANDOVER.md # v1.8 时代的开发手册(历史存档,现行见根目录 HANDOVER.md) │ ├── UPGRADE-v1.7-zip-large-file-profile.md │ ├── UPGRADE-v1.8-rar-support.md │ └── screenshots/ # README 截图 @@ -387,11 +450,17 @@ bash run-sevenz-tests.sh # 7z 套件(需 MinGW gcc 与 7-Zip 二 - **P2JB 用户** —— 若此载荷触发内核崩溃,请避免在该环境下使用。当每次重试代价高昂时,稳定性比便利更重要。 - **准备阶段可能耗时较久** —— 当文件夹含大量文件时,它会累加文件夹大小并检查剩余空间,这有助于避免启动一个无法安全完成的复制 / 移动 / 上传 / 下载。 - **`err_extract_entry_too_large`** —— 默认归档上限为单条目 512 GiB / 500:1 比率(覆盖典型 3A 游戏归档中单个约 300 GiB 未压缩文件)。若超过默认,请确认大文件提示(磁盘上 > 480 GiB 的归档会出现),拆分归档,或直接向 API 传入 `large=1`。 -- **`err_extract_unsupported`** —— 归档使用了引擎无法处理的功能:加密 ZIP、加密 RAR、多卷 RAR(`.part02+.rar`)、极老的 RAR 1.4、RAR 符号链接 / FIFO,或既非 `.zip` 也非 `.rar` / `.7z` 的文件。对于 7z,特指 `-mhe=on` 加密头。针对 RAR 的消息会列出失败原因并提示用户回到 PC 端解压器。 +- **`err_extract_unsupported`** —— 这个包本机读不了:既非 `.zip` / `.rar` / `.7z` 的文件;ZIP 条目用了 stored / deflated 之外的压缩方法;7z 用了不支持的 coder;分卷命名不被识别(RAR 分卷若叫 `x.rar.001`,需改名为 `x.part1.rar`、`x.part2.rar` ……);或早于 RAR 1.4 的归档。**加密归档与多卷归档不属于这一类**——两者都支持。界面会在括号里附上后端原文,指明具体原因。 +- **`err_extract_dict_too_large`** —— RAR 归档声明的压缩字典超过本构建支持的上限(4096 MiB),且 unrar 请求允许超额。报错文案会同时给出归档需要的尺寸与构建允许的尺寸。这是**刻意拒绝**:另一条路是一次性分配整个字典窗口,rarlab 自家 CLI 默认也会拒绝,16 GB 共享内存的主机更是承受不起。请在 PC 上用不超过 4 GiB 的字典重新压缩(`-md`),或在 PC 上解压。注意 RAR5 格式本身把这个字段卡在 4 GiB,所以这种情况只可能来自更新版 RAR7 头格式写出的归档。 +- **`err_extract_password`** —— 归档已加密,而本次提交的密码缺失或错误。这也包括带加密头(`-mhe=on`)的 7z:文件名与条目尺寸都在头部里,头解密之前连条目列表都读不出来。ZIP / RAR(以及现在的 7z 加密头)在失败后会弹出密码框(取消或留空即放弃),可用正确密码按原参数重试,最多 3 次;解压 7z 时仍会提前询问一次密码。 ## 署名 -本项目参考了以下项目构建: +本项目 **fork 自 [owendswang/ps5-web-file-manager](https://github.com/owendswang/ps5-web-file-manager)**(GPL-3.0)。Web UI、任务模型与 PS5 打包方式均源自该项目;上游作者以 GPL-3.0 发布,是本衍生作品得以存在的前提。 + +**怎么区分上游原版与本仓改版:** 自 v1.9.3 起版本号带 `M` 后缀(`vX.Y.ZM`),*M* 即 *Modified*(改版);上游 owendswang 的发布版是纯 `vX.Y.Z`。因此 `v1.9.3M` 只可能出自本仓,而这个字母同时出现在 ELF 文件名、PS5 启动通知、`/api/version` 与网页右下角。v1.9.3M 之前的发布早于该约定,保留原本的无后缀编号。 + +本项目另参考了以下项目构建: - **[ps5-payload-dev/websrv](https://github.com/ps5-payload-dev/websrv):** HTTP 服务器结构、静态资源内联思路、PS5 浏览器/websrv 行为与 PKG 安装函数。许可证:GPLv3+。 - **[ps5-payload-dev/ftpsrv](https://github.com/ps5-payload-dev/ftpsrv):** PS5 载荷约定、主屏启动器/安装流程参考、进程处理风格与启动安装参考。许可证:GPLv3+。 @@ -400,7 +469,7 @@ bash run-sevenz-tests.sh # 7z 套件(需 MinGW gcc 与 7-Zip 二 - **[libmicrohttpd](https://ftp.gnu.org/gnu/libmicrohttpd/):** 用作内嵌 HTTP 服务器库。由 GNU 以 LGPL 许可;本载荷以 SDK 提供的静态库链接它。 - **[ps5-payload-dev/sdk](https://github.com/ps5-payload-dev/sdk):** 载荷构建基础。许可证:GPLv3+。 - **[etaHEN](https://github.com/etaHEN/etaHEN):** 退出前用于返回 PS5 主屏的 ShellUI URI 导航。许可证:GPLv3。 -- **[ezremote](https://github.com/cy33hc/ps5-ezremote-client):** 预览 PKG 信息。许可证:GPLv2。 +- **[ezremote](https://github.com/cy33hc/ps5-ezremote-client):** PKG 预览功能的参考出处。许可证:**GPL-2.0-only** —— 其源文件未声明 "or later",因此**无法**与本项目的 GPL-3.0 代码组合。**未取其任何代码**:`src/pkg_info.c` 是独立的 C99 实现(它还负责 `.pkg` 条目表与 `param.json` 字段,而 ezremote 根本没有 `.pkg` 解析器;JSON 走的是 `src/json_util.c` 里自写的分词器,不是 json-c)。详见 `docs/REWRITE-FEASIBILITY.md` §2.2。 - **[zlib-ng/minizip-ng](https://github.com/zlib-ng/minizip-ng):** `/api/extract` 端点使用的 ZIP 读取器。vendored 于 `third_party/minizip-ng/`。许可证:zlib。 - **[zlib](https://www.zlib.net/):** minizip-ng 的压缩后端。vendored 于 `third_party/zlib/`。许可证:zlib。 - **[rarlab UnRAR (opello/unrar)](https://github.com/opello/unrar):** v1.9 起 `/api/extract` 使用的 RAR 读取器(7.20.1)。vendored 于 `third_party/unrar7/`。许可证:UnRAR 免费软件许可。 diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES index 27f8514..5f4436e 100644 --- a/THIRD_PARTY_NOTICES +++ b/THIRD_PARTY_NOTICES @@ -64,3 +64,27 @@ notice and this list of conditions are retained. unrar is distributed under its own freeware terms. The LZMA SDK (section 4) is in the public domain and carries no conditions. See the individual LICENSE / license.txt files in each `third_party/` subdirectory for the complete terms. + + +Reference-only projects (NOT vendored) +-------------------------------------- + +The README Credits section lists a second class of project: ones this payload +was *written with reference to*, whose code is not present in this repository +and which therefore impose no obligations here. Keeping the two classes apart +matters, because one of them is licence-incompatible with this codebase: + + * websrv, ftpsrv, ps5-payload-manager, ps5-payload-dev/sdk, etaHEN (GPL-3.0 / 3.0+) + * zftpd (MIT) + * libmicrohttpd (LGPL; linked as the SDK-provided static library) + * ezremote (GPL-2.0-only) + +For ezremote specifically: GPL-2.0-only cannot be combined with this project's +GPL-3.0, so it matters that no code was taken from it. The PKG-preview code in +`src/pkg_info.c` is an independent implementation -- the two share only the +on-disk SFO format facts, which no implementation can avoid. Line-by-line +comparison and reasoning: `docs/REWRITE-FEASIBILITY.md` section 2.2. + +`owendswang/ps5-web-file-manager` (GPL-3.0) is not a mere reference: it is the +fork this project descends from, and the web UI, the task model and the PS5 +packaging all originate there. diff --git a/assets/index.html b/assets/index.html index a179c62..e692461 100644 --- a/assets/index.html +++ b/assets/index.html @@ -44,7 +44,7 @@ - + -
- - +
+ +
@@ -94,6 +98,7 @@
+
diff --git a/assets/lang-en.js b/assets/lang-en.js index fb00151..813e691 100644 --- a/assets/lang-en.js +++ b/assets/lang-en.js @@ -1,11 +1,14 @@ window.WFM_LANG = { appTitle: "PS5 Web File Manager", + versionTooltip: "Modified build by LisherSong (upstream releases carry no M suffix)", copy: "Copy", move: "Move", delete: "Delete", download: "Download", upload: "Upload", + uploadFiles: "Upload Files", uploadFolder: "Upload Folder", + dropUploadHint: "Drag files or folders into this window to upload", dropUpload: "Release to upload into this folder", copying: "Copying", moving: "Moving", @@ -103,6 +106,8 @@ window.WFM_LANG = { extractConfirm: "Extract {name} to {path}?", extractOverwriteAsk: "If a file or folder with the same name already exists in the target:\n\nOK = overwrite same-name files (folders still merge)\nCancel = fail if the target already exists", extractPasswordAsk: "This archive may be encrypted (e.g. 7zAES).\n\nEnter the password to extract, or leave it empty to try without one.", + extractPasswordFirstAsk: "This archive is encrypted.\n\nEnter the password to extract, or cancel to stop.", + extractPasswordRetryAsk: "This archive is encrypted and the password did not work.\n\nEnter the password to try again, or cancel to stop.", extractUploadConfirm: "Upload and extract {name}?\n\nTarget folder: {path}\nThe uploaded archive will be deleted after success.", extractUploadAsk: "{name} is an archive.\n\nOK: upload and extract here\nCancel: upload only", extractStarted: "Extraction started: {name}", @@ -110,6 +115,8 @@ window.WFM_LANG = { 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 4 TiB)\nCancel = default limits (single file 512 GiB, archive total 2 TiB); this archive may be rejected", extractLargeActive: "Large-file profile is enabled for this task", + extractSelectArchive: "Select one archive to extract (ZIP / RAR / 7z)", + extractOneAtATime: "Only one archive can be extracted at a time", extractSelectMainVolume: "Please select the main volume (.rar or .part01.rar)", extractArchivePending: "Preparing to extract {name}", sameSourceTarget: "Source and destination are the same. Cannot {label} {name}", @@ -182,7 +189,7 @@ window.WFM_LANG = { err_destination_must_be_directory: "Destination must be a folder for multiple items", err_extract_open_failed: "Cannot open archive: {arg}", err_extract_corrupt: "Archive is corrupt or incomplete: {arg}", - err_extract_unsupported: "Unsupported archive (only plain ZIP, single-volume RAR, and 7z are supported): {arg}", + err_extract_unsupported: "Unsupported archive (only .zip, .rar and .7z are accepted, including their multi-volume and encrypted forms; this one uses a feature this build cannot handle): {arg}", err_extract_password: "Wrong password or the archive is not encrypted with the one supplied: {arg}", err_extract_unsafe_name: "Archive contains an unsafe path: {arg}", err_extract_special_entry: "Archive contains an unsupported special file: {arg}", @@ -193,6 +200,7 @@ window.WFM_LANG = { err_extract_ratio: "Suspicious compression ratio (possible zip bomb): {arg}", err_extract_too_deep: "Directory nesting is too deep: {arg}", err_extract_name_too_long: "File name or path is too long: {arg}", + err_extract_dict_too_large: "The archive needs a larger dictionary than this device can handle: {arg}", err_extract_conflict: "A file or folder with the same name already exists: {arg}", err_extract_io: "Extraction read/write failed: {arg}", err_extract_crc: "CRC check failed: {arg}", diff --git a/assets/lang-zh.js b/assets/lang-zh.js index 1485575..b0eb371 100644 --- a/assets/lang-zh.js +++ b/assets/lang-zh.js @@ -1,11 +1,14 @@ window.WFM_LANG = { appTitle: "PS5 Web File Manager", + versionTooltip: "本版为 LisherSong 改版(上游原版无 M 后缀)", copy: "复制", move: "移动", delete: "删除", download: "下载", upload: "上传", + uploadFiles: "上传文件", uploadFolder: "上传文件夹", + dropUploadHint: "可直接把文件或文件夹拖进窗口上传", dropUpload: "松开即上传到当前目录", copying: "复制", moving: "移动", @@ -103,6 +106,8 @@ window.WFM_LANG = { extractConfirm: "解压 {name} 到 {path}?", extractOverwriteAsk: "若目标已存在同名文件或目录:\n\n确定 = 覆盖同名文件(目录仍会合并)\n取消 = 若目标已存在则失败", extractPasswordAsk: "此压缩包可能加密了(如 7zAES)。\n\n输入密码后解压,留空则尝试无密码解压。", + extractPasswordFirstAsk: "此压缩包已加密。\n\n输入密码后解压,取消则停止解压。", + extractPasswordRetryAsk: "此压缩包已加密,密码不正确。\n\n输入密码重试,取消则停止解压。", extractUploadConfirm: "上传并解压 {name}?\n\n目标目录:{path}\n成功后将删除上传的压缩包。", extractUploadAsk: "这是压缩包 {name}。\n\n确定:上传后自动解压到当前目录\n取消:仅上传,不解压", extractStarted: "已开始解压 {name}", @@ -110,6 +115,8 @@ window.WFM_LANG = { extractProgress: "{done} / {total} 个文件", extractLargeAsk: "压缩包体积较大({size}),是否启用「大文件模式」?\n\n确定 = 启用(单文件最大 1 TiB / 总解压最大 4 TiB)\n取消 = 默认限制(单文件 512 GiB / 总解压 2 TiB),可能拒绝此压缩包", extractLargeActive: "此任务已启用大文件模式", + extractSelectArchive: "选中一个压缩包后才能解压(ZIP / RAR / 7z)", + extractOneAtATime: "一次只能解压一个压缩包", extractSelectMainVolume: "请改选主卷(如 .rar 或 .part01.rar)", extractArchivePending: "正在准备解压 {name}", sameSourceTarget: "源和目标相同,不能{label} {name}", @@ -182,7 +189,7 @@ window.WFM_LANG = { err_destination_must_be_directory: "多个项目的目标必须是目录", err_extract_open_failed: "无法打开压缩包: {arg}", err_extract_corrupt: "压缩包损坏或不完整: {arg}", - err_extract_unsupported: "不支持的压缩包(仅支持未加密的普通 ZIP、单卷 RAR,以及 7z):{arg}", + err_extract_unsupported: "不支持的压缩包(只认 .zip / .rar / .7z,含它们的分卷与加密版本;这个包用了本机处理不了的特性):{arg}", err_extract_password: "密码错误,或压缩包未使用所提供的密码加密: {arg}", err_extract_unsafe_name: "压缩包包含不安全的路径: {arg}", err_extract_special_entry: "压缩包包含不支持的特殊文件: {arg}", @@ -193,6 +200,7 @@ window.WFM_LANG = { err_extract_ratio: "压缩比异常(疑似压缩炸弹): {arg}", err_extract_too_deep: "目录层级过深: {arg}", err_extract_name_too_long: "文件名或路径过长: {arg}", + err_extract_dict_too_large: "压缩包需要的字典超出本机可承受范围: {arg}", err_extract_conflict: "目标已存在同名文件或目录: {arg}", err_extract_io: "解压读写失败: {arg}", err_extract_crc: "CRC 校验失败: {arg}", diff --git a/assets/main.css b/assets/main.css index 13e7636..3d51f83 100644 --- a/assets/main.css +++ b/assets/main.css @@ -250,7 +250,7 @@ body { flex: 1 1 auto; } -.split-button { +.upload-menu { position: relative; display: inline-flex; align-items: stretch; @@ -259,33 +259,100 @@ body { margin-bottom: 15px; } -.split-button .split-main { +.upload-menu .upload-main { min-width: 82px; - border-top-right-radius: 0; - border-bottom-right-radius: 0; margin: 0; } -.split-button .split-arrow { - min-width: 38px; - width: 38px; - padding: 0; - border-left: 0; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - margin: 0; -} - -.split-arrow::before { +/* The caret is what tells the user this button opens a list. Without it the + button looks like a plain one-shot action, which is exactly how the old + file/folder split was misread. */ +.upload-main::after { content: ""; display: block; width: 0; height: 0; + margin-left: 10px; border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 7px solid #f2f5f7; } +.upload-menu-list { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 700; + display: flex; + flex-direction: column; + padding: 6px; + border: 1px solid #46515f; + border-radius: 8px; + background: #1b2128; + box-shadow: 0 12px 28px rgba(0, 0, 0, .5); +} + +.upload-menu-list button { + height: 46px; + min-height: 46px; + min-width: 176px; + padding: 0 16px; + border: 0; + border-radius: 6px; + background: transparent; + justify-content: flex-start; + text-align: left; + white-space: nowrap; +} + +/* Menu rows highlight by fill only. The generic button ring (3px at offset + 2px) is sized for a 54px toolbar button: on a 46px row it clears the + panel's 6px padding, overlaps the neighbouring row, and its corners do not + follow the row's radius, so the highlight reads as a broken shape rather + than a selected row. The panel's own hover fill had also never applied -- + the generic `button:not(.row-action):hover:not(:disabled)` rule has the + same specificity (0,3,1) and comes later in this file, so it won and hover + and focus ended up two different colours. Scoping both rules to the panel + settles the cascade, and the keyboard cue is drawn inside the row where it + cannot cross the panel edge. */ +#uploadMenu button:focus { + outline: none; +} + +#uploadMenu button:hover:not(:disabled), +#uploadMenu button:focus-visible { + background: #2b343e; +} + +#uploadMenu button:focus-visible { + box-shadow: inset 0 0 0 2px #6fb1ff; +} + +/* The status can be a long "uploading 3/12: some-very-long-name.zip". The footer + is a fixed 46px row, so a wrapped status used to spill out of it; clamping it + to one line with an ellipsis is what keeps it, the hint and the version on the + same row at every width. */ +#statusText { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* A hint that has to fit next to the status line and the version, so it shrinks + with an ellipsis instead of pushing either of them around. */ +.status-hint { + flex: 0 1 auto; + min-width: 0; + margin: 0 16px; + overflow: hidden; + color: #6f7a84; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + button, .button { height: 54px; @@ -374,7 +441,10 @@ button.install-action:hover:not(:disabled) { } .extract-action { - min-width: 88px; + /* Matches the width of the other two-character verbs (copy/move/delete): the + button is on screen at all times now, so it has to line up with them + instead of being a special case that appears only on selection. */ + min-width: 96px; border-color: #2a6b66; background: #17302e; color: #a8e6df; @@ -384,6 +454,16 @@ button.extract-action:hover:not(:disabled) { background: #1d3f3c; } +/* The extract button is always on screen and only greys out when the selection + cannot be extracted, so the reason has to be readable. `button:disabled` + sets `pointer-events: none`, which makes the button un-hoverable and swallows + its `title` tooltip -- the same fix the parent-directory button already uses. + Clicks stay dead either way: the disabled attribute blocks activation + regardless of hit-testing. */ +.extract-action:disabled { + pointer-events: auto; +} + .paste-count { white-space: nowrap; } @@ -1344,19 +1424,30 @@ input[type="checkbox"] { font-size: 16px; } - .split-button { + .upload-menu { height: 40px; min-height: 40px; margin-bottom: 8px; } - .split-button .split-main { + .upload-menu .upload-main { min-width: 64px; } - .split-button .split-arrow { - min-width: 34px; - width: 34px; + .upload-menu-list { + padding: 4px; + } + + .upload-menu-list button { + height: 38px; + min-height: 38px; + min-width: 148px; + padding: 0 12px; + } + + .status-hint { + margin: 0 10px; + font-size: 14px; } .paste-action { diff --git a/assets/main.js b/assets/main.js index de9da4c..1706d81 100644 --- a/assets/main.js +++ b/assets/main.js @@ -39,7 +39,8 @@ let L = {}; // /api/version, which reports the build's VERSION_TAG -- see loadVersion(). // Keeping a literal here used to be the only source, and it inevitably // drifted (the footer said "v1.9" throughout the v1.9.1 release). -const APP_VERSION_FALLBACK = "v1.9.2"; +// The trailing "M" is the fork marker; see VERSION_TAG in the Makefile. +const APP_VERSION_FALLBACK = "v1.9.3M"; const LAST_PATH_KEY = "ps5-web-file-mgr:last-path"; const SORT_KEY = "ps5-web-file-mgr:list-sort"; const LOADING_DISPLAY_DELAY = 250; @@ -73,7 +74,10 @@ const extractBtn = document.getElementById("extractBtn"); const clearClipboardBtn = document.getElementById("clearClipboardBtn"); const downloadBtn = document.getElementById("downloadBtn"); const uploadBtn = document.getElementById("uploadBtn"); -const uploadFolderBtn = document.getElementById("uploadFolderBtn"); +const uploadMenuEl = document.getElementById("uploadMenu"); +const uploadFilesItemEl = document.getElementById("uploadFilesItem"); +const uploadFolderItemEl = document.getElementById("uploadFolderItem"); +const dropHintEl = document.getElementById("dropHint"); const uploadFilesEl = document.getElementById("uploadFiles"); const uploadFolderEl = document.getElementById("uploadFolder"); const dropUploadOverlayEl = document.getElementById("dropUploadOverlay"); @@ -149,7 +153,15 @@ function t(key, params) { } function backendErrorText(code, arg, fallback) { - if (!code) return fallback || t("backendError"); + if (!code) return decodeFsText(fallback) || t("backendError"); + // Names arrive byte-mapped (see decodeFsText below): the server escapes every + // byte >= 0x80 as \u00XX so that names which are not valid UTF-8 -- a GBK entry + // name inside a ZIP, say -- survive the JSON round trip unchanged. The listing + // has always translated them back for display; an error message must do the + // same, or the entry name is unreadable at the exact moment the user needs to + // read it (which is how "解压失败: ... â®â¡.psd" happened). + arg = decodeFsText(arg); + fallback = decodeFsText(fallback); const params = { path: arg || "", arg: arg || "" }; if (code === "no_space") { const parts = String(arg || "").split(","); @@ -177,13 +189,18 @@ function applyStaticText() { if (isPlayStationBrowser()) { for (const el of document.querySelectorAll(".remote-only")) el.hidden = true; } + // The hint starts hidden so the console browser never flashes it; the browser + // that can actually drag (and therefore has the upload button) shows it. + dropHintEl.hidden = isPlayStationBrowser(); exitBtn.title = t("exit"); exitBtn.setAttribute("aria-label", t("exit")); - uploadFolderBtn.title = t("uploadFolder"); - uploadFolderBtn.setAttribute("aria-label", t("uploadFolder")); parentBtn.title = t("parent"); parentBtn.setAttribute("aria-label", t("parent")); versionEl.textContent = APP_VERSION_FALLBACK; + // "v1.9.3M" is opaque to anyone who has not read the release notes, so spell + // out what the trailing M means on hover. loadVersion() only rewrites the + // text, never the tooltip, so this survives the /api/version round trip. + versionEl.title = t("versionTooltip"); if (initLoadingEl) initLoadingEl.hidden = true; } @@ -328,6 +345,32 @@ function decodeFsBytes(bytes, fallback) { } } +// The inverse of decodeFsText(): turn a real Unicode name -- one that came from +// a File object or a prompt, not from the server -- into the byte-mapped form +// the server expects. Anything that crosses over has to be in one convention, +// because the server byte-repairs a path only when every non-ASCII code point in +// it is <= 0xFF: a single real CJK character in the same string makes +// fs_path_value() leave the whole thing alone, and a half-repaired path finds +// nothing on disk. +function encodeFsText(text) { + text = String(text || ""); + const encoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null; + if (!encoder) return text; + let bytes; + try { + bytes = encoder.encode(text); + } catch (err) { + return text; + } + let ascii = true; + let out = ""; + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] >= 0x80) ascii = false; + out += String.fromCharCode(bytes[i]); + } + return ascii ? text : out; +} + function isUtf8Bytes(bytes) { for (let i = 0; i < bytes.length;) { const c = bytes[i]; @@ -528,6 +571,10 @@ function handleTerminalTask(task) { } if (task.state === "failed") { clearTrackedTask(); + if (task.op === "extract" && task.error_code === "extract_password" && + retryExtractWithPassword(task)) { + return; + } const message = taskFailureMessage(task); setStatus(message); alert(message); @@ -872,7 +919,69 @@ function actionInstallSelectedPkgs() { return queuePkgInstall(selectedEntries().filter(isPkgPackage)); } -async function startExtractTask(path, dstDir, conflict, removeSource, name, large, password) { +// The engine reports a missing or wrong password as `extract_password`. ZIP and +// RAR only find out once they have looked inside, so they are not asked up +// front; the failed task is re-sent with whatever the user types here instead. +// Remembering the original request is what keeps the conflict policy and the +// large-file opt-in intact across the retry. +// +// The request is remembered by TASK ID, never by path. Paths travel through the +// byte-mapped JSON described at decodeFsText(), and the server maps them back to +// raw bytes on the way in (fs_path_value), so the string this page holds for a +// directory and the one the task reports back differ for every name that is not +// pure ASCII. Keying the retry by path therefore made the password prompt +// silently never appear for archives in a non-ASCII directory -- the exact case +// the retry exists for. The id is assigned by the server and survives the round +// trip untouched. +const MAX_EXTRACT_PASSWORD_RETRIES = 3; +const extractRequestRetries = new Map(); + +function extractRetryKey(taskId) { + return "task:" + String(taskId); +} + +// Entries are consumed by a retry or by a give-up, but a successful extraction +// never asks for one again, so its entry would sit here for the life of the +// page. Only one task can be live at a time (the server rejects a second with +// 409), so anything older than the last few is dead weight. +const MAX_REMEMBERED_EXTRACTS = 8; + +function rememberExtractRequest(taskId, entry) { + extractRequestRetries.set(extractRetryKey(taskId), entry); + while (extractRequestRetries.size > MAX_REMEMBERED_EXTRACTS) { + extractRequestRetries.delete(extractRequestRetries.keys().next().value); + } +} + +function retryExtractWithPassword(task) { + const key = extractRetryKey(task.id); + const remembered = extractRequestRetries.get(key); + if (!remembered || remembered.attempts >= MAX_EXTRACT_PASSWORD_RETRIES) { + extractRequestRetries.delete(key); + return false; + } + // The first failure is usually "no password was given at all"; only a later + // one is a password that did not work. Saying "the password is wrong" to + // someone who was never asked for one is what made this flow look broken. + const asked = prompt(t(remembered.attempts + ? "extractPasswordRetryAsk" : "extractPasswordFirstAsk"), ""); + if (!asked) { + // Cancel or an empty box means "give up": fall through so the normal failure + // report still explains what happened. + extractRequestRetries.delete(key); + return false; + } + // Consume the entry: the retry below registers itself under the new task id. + extractRequestRetries.delete(key); + clearTrackedTask(); + startExtractTask(task.src, task.dst, remembered.conflict, + remembered.removeSource, remembered.name, remembered.large, + asked, remembered.attempts + 1); + return true; +} + +async function startExtractTask(path, dstDir, conflict, removeSource, name, large, + password, attempts) { try { taskRefreshPath = cwd; setBusy(true); @@ -886,6 +995,10 @@ async function startExtractTask(path, dstDir, conflict, removeSource, name, larg }; if (password) form.password = password; const data = await apiForm("/api/extract", form); + rememberExtractRequest(data.task_id, { + conflict, removeSource, name, large: Boolean(large), + attempts: Number(attempts || 0) + }); trackTask(data.task_id, "extract", false); clearSelection(false); await pollTasks(); @@ -922,6 +1035,9 @@ function actionExtract() { // 7z archives can be encrypted (7zAES); ask up front so an unprotected // archive doesn't pay a wasted scan + folder parse. An empty submission is // fine — the engine returns ZIPX_ERR_PASSWORD and the user retries. + // ZIP and RAR are not asked here: their headers are readable either way, so + // an empty password costs nothing and a failed attempt is retried through + // retryExtractWithPassword() instead of interrupting every extraction. let password = ""; if (isSevenZipArchive(item) || isSevenZipSplitVolume(item)) { const asked = prompt(t("extractPasswordAsk"), ""); @@ -1254,7 +1370,9 @@ async function actionNewText() { if (input === null) return; const name = input.trim(); if (!name) return; - const item = { name, path: pathJoin(dir, name), type: "-" }; + // dir is the byte-mapped echo of the listing; name typed here is real Unicode. + // Joining them raw would hand the server a path it cannot repair. + const item = { name, path: pathJoin(dir, encodeFsText(name)), type: "-" }; try { setBusy(true); @@ -1430,25 +1548,26 @@ function renderExtractButton(items, locked) { const archives = items.filter(isExtractableArchive); const subs = items.filter(isRarSubVolume); - // 没有可解压档案也没有子卷 → 隐藏按钮 - if (archives.length === 0 && subs.length === 0) { - extractBtn.hidden = true; - extractBtn.title = ""; - extractBtn.disabled = true; + /* The button is always on screen and merely greys out when the selection + cannot be extracted. It used to be hidden until an archive was selected, + which left the resting toolbar with no extract entry at all -- the same + discoverability problem the upload button was changed for. Being disabled + is not self-explanatory though, so the tooltip carries the reason. */ + if (archives.length === 1) { + extractBtn.title = t("extractToCurrent") + ": " + itemTitle(archives); + extractBtn.disabled = locked; return; } - extractBtn.hidden = false; - - // 只选中子卷(比如 .part02.rar),没有对应主卷 → 按钮置灰 + 提示改选主卷 - if (archives.length !== 1) { + extractBtn.disabled = true; + if (archives.length > 1) { + extractBtn.title = t("extractOneAtATime"); + } else if (subs.length > 0) { + // 只选中子卷(比如 .part02.rar),没有对应主卷 → 置灰 + 提示改选主卷 extractBtn.title = t("extractSelectMainVolume"); - extractBtn.disabled = true; - return; + } else { + extractBtn.title = t("extractSelectArchive"); } - - extractBtn.title = t("extractToCurrent") + ": " + itemTitle(archives); - extractBtn.disabled = locked; } function singleSelected() { @@ -1469,7 +1588,9 @@ function updateButtons() { downloadBtn.disabled = locked || items.length === 0; document.getElementById("refreshBtn").disabled = locked; uploadBtn.disabled = locked; - uploadFolderBtn.disabled = locked; + uploadFilesItemEl.disabled = locked; + uploadFolderItemEl.disabled = locked; + if (locked && uploadMenuOpen) setUploadMenuOpen(false); document.getElementById("mkdirBtn").disabled = locked; newTextBtn.disabled = locked; for (const button of filesEl.querySelectorAll(".row-action, .mode-action")) button.disabled = locked; @@ -2397,20 +2518,76 @@ async function uploadFiles(files, relativeNames) { } } -// Two one-click entries rather than a menu: the main button picks files, the -// arrow picks a folder. A native file dialog is either file-only or -// folder-only (webkitdirectory), so a single dialog cannot offer both; the -// drop target below is the one gesture that accepts either. +// A native file dialog is either file-only or folder-only (webkitdirectory), so +// a single dialog cannot offer both. The button therefore opens a two-entry +// list: a main button plus a small caret next to it was the old shape, and users +// read the caret as decoration and never found "upload a folder" at all. The +// drop target below is still the one gesture that accepts either. +let uploadMenuOpen = false; + +function uploadMenuItems() { + return [uploadFilesItemEl, uploadFolderItemEl]; +} + +function setUploadMenuOpen(open) { + uploadMenuOpen = Boolean(open) && !busy && !loadingPath; + uploadMenuEl.hidden = !uploadMenuOpen; + uploadBtn.setAttribute("aria-expanded", uploadMenuOpen ? "true" : "false"); +} + +function toggleUploadMenu() { + if (busy || loadingPath) return; + setUploadMenuOpen(!uploadMenuOpen); + if (uploadMenuOpen) uploadMenuItems()[0].focus(); +} + function actionUploadFiles() { + setUploadMenuOpen(false); if (busy || loadingPath) return; uploadFilesEl.click(); } function actionUploadFolder() { + setUploadMenuOpen(false); if (busy || loadingPath) return; uploadFolderEl.click(); } +function moveUploadMenuFocus(step) { + const items = uploadMenuItems(); + const current = items.indexOf(document.activeElement); + const next = ((current < 0 ? 0 : current + step) + items.length) % items.length; + items[next].focus(); +} + +function setupUploadMenu() { + // Closing on any click outside is what keeps a menu honest; it is bound to the + // document so that a reflowed toolbar cannot leave a stale open list behind. + document.addEventListener("click", event => { + if (!uploadMenuOpen) return; + const target = event.target; + if (target === uploadBtn || (target && uploadMenuEl.contains(target))) return; + setUploadMenuOpen(false); + }); + document.addEventListener("keydown", event => { + if (!uploadMenuOpen) return; + if (event.key === "Escape") { + setUploadMenuOpen(false); + uploadBtn.focus(); + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + moveUploadMenuFocus(event.key === "ArrowDown" ? 1 : -1); + } + }); + uploadBtn.addEventListener("keydown", event => { + if (uploadMenuOpen || event.key !== "ArrowDown") return; + event.preventDefault(); + toggleUploadMenu(); + }); +} + // A dropped directory arrives as a FileSystemEntry, which has no recursive // listing of its own: readEntries() hands back one batch at a time and an // empty batch marks the end, so it has to be driven until it drains. @@ -2521,7 +2698,10 @@ async function uploadAndExtractFile(file, relativeName, alreadyAsked) { return; } const rel = relativeName || uploadRelativeName(file); - const zipPath = pathJoin(cwd, rel); + // cwd is the byte-mapped echo from the listing while rel comes straight from + // the File object; byte-map the name so the joined path stays in one + // convention (see encodeFsText). + const zipPath = pathJoin(cwd, encodeFsText(rel)); if (!alreadyAsked && !confirm(t("extractUploadConfirm", { name: rel, path: displayPath(cwd) }))) return; const conflict = confirm(t("extractOverwriteAsk")) ? "overwrite" : "fail"; @@ -2601,10 +2781,12 @@ clearClipboardBtn.addEventListener("click", clearClipboard); document.getElementById("renameBtn").addEventListener("click", actionRename); downloadBtn.addEventListener("click", actionDownload); document.getElementById("deleteBtn").addEventListener("click", actionDelete); -uploadBtn.addEventListener("click", actionUploadFiles); -uploadFolderBtn.addEventListener("click", actionUploadFolder); +uploadBtn.addEventListener("click", toggleUploadMenu); +uploadFilesItemEl.addEventListener("click", actionUploadFiles); +uploadFolderItemEl.addEventListener("click", actionUploadFolder); uploadFilesEl.addEventListener("change", () => uploadFiles(uploadFilesEl.files)); uploadFolderEl.addEventListener("change", () => uploadFiles(uploadFolderEl.files)); +setupUploadMenu(); setupDropUpload(); exitBtn.addEventListener("click", actionExit); parentBtn.addEventListener("click", actionParentDirectory); diff --git a/docs/DEVICE-TEST-v1.9.3M.md b/docs/DEVICE-TEST-v1.9.3M.md new file mode 100644 index 0000000..b6f1e49 --- /dev/null +++ b/docs/DEVICE-TEST-v1.9.3M.md @@ -0,0 +1,258 @@ +# v1.9.3M 真机验证清单 + +> 目标:在真机上把 **v1.9.3M 相对 v1.9.2 的全部改动**走一遍。 +> 对应任务 #46(五项规定项)+ 「字典超限报错」修复 + 本次「版本号加 M 改版标记」。 +> 逐项打勾,失败项记文案原文。 + +## 0. 物料 + +| 项 | 值 | +|---|---| +| 待测 ELF | `web-file-mgr-v1.9.3M.elf`(903,448 B,sha256 `8ca47d5a…86b7`) | +| **回滚 ELF** | `.build/rel-v1.9.2/web-file-mgr-v1.9.2.elf`(870,488 B,sha256 `177e90fe…8e84`,**从 GitHub Release 下载并已核验**) | +| 测试归档 | `.build/device-test/`(22 个文件,2.5 MB,含 `MANIFEST.txt` 指纹) | +| 监听端口 | 默认 `8888`,通知栏显示实际端口 | + +> **这一轮(2026-09-24 晚)又重编了三次**,都只动前端资源(上传菜单、拖拽提示、页脚 +> 状态行钳制、口令提示键、菜单行高亮、解压按钮常显),C 代码一字节没改。六个构建的 +> **文件尺寸都是 903,448 B**,sha256 各不相同,`.rodata` 逐轮 +> +0x140 / +0x980 / +0x100 / +0x180 / +0x240: +> `7b5ab00c…`(首轮)→ `212107a6…`(+ 上传菜单)→ `da36834d…`(+ 文案与状态行钳制)→ +> `cf2c0fcf…`(+ 菜单行高亮修复)→ **`8ca47d5a…`(+ 解压按钮常显置灰,本轮待测)**。 +> **以 sha256 为准,别用文件尺寸判断"包换没换"。** + +⚠️ **回滚只能用 `.build/rel-v1.9.2/` 那个**。项目根目录里曾经并存的、同名的 +`web-file-mgr-v1.9.2.elf`(903,448 B 的**未发布工作树**)已挪到 +`.build/elf-v1.9.2-worktree-f3164efa.elf`,根目录现在只剩本次待测的 `v1.9.3M`。 + +> **带 `M` = LisherSong 改版,不带 `M` = 上游原版。** 从这一版起版本号统一带 `M` +> 后缀(如 `v1.9.3M`),所以「名字里有没有 M」本身就是上游 / 改版的判据。 + +### 发送与打开 + +```sh +nc -q0 9021 < web-file-mgr-v1.9.3M.elf +# 看 PS5 左上角通知:应显示 PS5 Web File Manager + v1.9.3M + 监听端口 +# 浏览器打开 http://:8888/ +``` + +### 通用纪律 + +1. **每次解压都新建一个空目标目录**。已知遗留问题:含目录条目的包在「覆盖」模式下解到同一 + 目录第二次必失败(三引擎同构,与本次改动无关)—— 别把它记成回归。 +2. 多卷归档**一次把整个文件夹拖进去**,别只传子卷(UI 对「只选中子卷」会置灰并要求改选首卷)。 +3. 每项记三样:**通过/失败**、**界面文案原文**、**截图**。 +4. 任务列表可直接在浏览器看:`http://:8888/api/tasks`。 + +--- + +## 1. 版本号四处一致 + 改版标记(规定项⑤,10 秒) + +| 位置 | 期望 | +|---|---| +| PS5 启动通知 | `v1.9.3M` | +| 页面底部状态栏的版本号(`#versionText`) | `v1.9.3M` | +| `http://:8888/api/version` | JSON 里 version = `v1.9.3M` | +| **鼠标悬停**在版本号上 | 浮出提示「本版为 LisherSong 改版(上游原版无 M 后缀)」/ 英文版同义 | + +三处(+ 悬停)不一致 = 版本宏或前端兜底串没进二进制。ELF 内已核对:`v1.9.3M` +出现 1 次、`v1.9.2` 出现 **0** 次;两条 tooltip 文案在解压后的 lang 资源里逐一命中。 + +> 若你此前已经刷过不带 M 的 `v1.9.3`:那个包只差字符串,**功能行为与本版完全一致**, +> 所以先前测出的结果仍然有效,不必因为加了 M 就重测一遍功能项。 +> 反过来,只要界面显示的是 `v1.9.3`(无 M)就是旧包,`v1.9.3M` 才是本次待测。 + +--- + +## 2. 字典超限报错(本次修复的**唯一**新行为,优先做) + +上传 `00-dict-limit.rar`(**7,055 B**,一个 8 GiB 字典的合成归档)→ 解压 → 选冲突策略 → 开始。 + +| 检查 | 期望 | +|---|---| +| 错误码/文案 | `extract_dict_too_large`,文案含 **`8192 MiB (limit 4096 MiB)`** | +| **不应**出现 | 「压缩包内单个文件过大: **hello.txt**」← 修复前的错误归因(该文件只有 7 B 级) | +| 目标目录 | **不应**出现 `hello.txt`,也不留下垃圾文件 | +| 行为 | 干净失败,不是崩溃、不是中途 OOM | + +> 判读:若看到「单个文件过大」⇒ 装的是旧 ELF;若看到字典文案 ⇒ 这一项通过。 +> 这项改动**只改报错、行为零变化**,所以「拒绝解压」是**正确**结果。 + +--- + +## 3. 三类分卷解压(规定项①) + +准备:把 `.build/device-test/` 里对应子目录整体上传到 PS5 的某个目录,然后在该目录里解压。 + +| 引擎 | 上传哪个目录 | 点哪个文件 | 期望输出 | +|---|---|---|---| +| **ZIP**(WinRAR 命名,字节切分) | `01-zip-vol/` | `parts.part1.zip` | 4 项:`readme.txt`、`sub/data.bin`、`sub/deep/more.bin`、`tail.bin` | +| **ZIP**(Info-ZIP 分盘,偏移按盘算) | `01-zip-vol/` | `disks.zip` | 同上 4 项 | +| **RAR**(RAR5,3 卷) | `01-rar-vol/` | `vol.part1.rar`(**必须选首卷**) | `big.bin`,**524,288 B** | +| **7z**(7 卷,`.7z.001…`) | `01-7z-vol/` | 任一卷均可触发 | 一个 `_src/` 目录,内含 6 项:`readme.txt`、`binary.bin`、`zeros.bin`、`sub/code.bin`、`sub/nested.txt`、`sub/中文-テスト.txt` | + +要点: +- ZIP/RAR/7z 各测一遍**首卷与子卷**的触发行为:ZIP/7z 任一卷都该能触发;RAR 选子卷应**置灰并提示改选首卷**(这是设计行为,不是 bug)。 +- 7z 那组含**中文 + 日文文件名**,顺手验证 UTF-8 落盘。 +- 删掉(或改名)某一卷再试一次,应给「找不到分卷」类报错而不是静默成功 —— 顺带查负路径;负向夹具用的是 + `tests/fixtures/broken.zip.001`(缺后续卷)与 `gap.zip.001 + gap.zip.003`(缺第 2 卷),需要时一并上传。 + +--- + +## 4. 加密归档(规定项②,改动最集中) + +口令:三个套件用的是**同一个测试口令**,定义在 `tests/run-sevenz-tests.sh` +(`FIXTURE_PASSWORD`)与 `tests/make-zip-enc-fixtures.bat` / `tests/make-rar-fixtures.bat` 里,先去看一眼。 + +| 文件 | 加密方式 | 期望 | +|---|---|---| +| `02-encrypted/enc-zipcrypto.zip` | ZipCrypto | 提示输入口令 → 解出 `root.txt`、`dir/nested.txt` | +| `02-encrypted/enc-aes256.zip` | WinZip AES-256 | 同上 | +| `02-encrypted/enc-aes256-store.zip` | AES-256 + 存储 | 同上 | +| `02-encrypted/enc-v6.rar` | RAR5 `-hp`(加密头) | **连列表都要口令** → 提示输入 → 解出同两文件 | +| `02-encrypted/aes.7z` | 7zAES(数据加密) | 7z 会在**开始前**先问口令,然后解出 `_src/` 那 6 项 | +| `02-encrypted/aeshe.7z` | **7zAES + `-mhe=on`(加密头)** | **本次头号目标**:加密头由新增的 `src/sevenz_header.c` 自解;应能正常列出并解出 `_src/` 那 6 项 | + +必测的负路径: + +| 场景 | 期望 | +|---|---| +| 口令故意输错(每格式至少一次) | 报 `err_extract_password` 并弹出重试框,**最多 3 次**;取消即结束,不卡死 | +| 重试时换正确口令 | 第 2/3 次能成功(证明「记住原请求」的逻辑生效:冲突策略、大文件选配不丢) | +| 7z 加密头 + 错口令 | 应是口令错误提示,**不是**「不受支持的归档 / 损坏」 | +| **某次提示文案** | 第一次失败说「**此压缩包已加密。输入密码后解压,取消则停止解压。**」;第二次起才是「密码不正确」(第一次没输过密码,不该说你输错了) | +| **错误文案里的条目名** | 中文/日文条目名必须**正常显示**,不得出现 `â®…` 或方框乱码 | + +> 判读:`aeshe.7z` 在 v1.9.2 上是**已知缺口**(`tests/run-sevenz-tests.sh` 的 `KNOWN_GAPS` +> 里原来就写着 `aeshe`),v1.9.3M 才闭合。这一项通过 = 最后一个 7z 缺口在真机确认关闭。 + +--- + +## 5. 上传入口 / 口令提示 / 名称编码(本轮修复,优先做,2 分钟) + +这一节全部是**界面与前端 codec** 的改动,测起来最快,也最容易看出装的是不是新包。 + +### 5a 上传按钮变成菜单 + +| 检查 | 期望 | +|---|---| +| 按钮外观 | 「上传」右侧带小三角(▾) | +| 点一下 | 在按钮正下方弹出列表:**上传文件 / 上传文件夹**(不再是「主按钮 + 一个小箭头」) | +| 选「上传文件」 | 弹系统文件选择器,可多选 | +| 选「上传文件夹」 | 弹目录选择器(原「小箭头」的功能,没丢) | +| 键盘 | 打开后焦点在第一项;`Esc` 关闭并把焦点还给按钮;`↑/↓` 在两项间移动 | +| 选中高亮 | 鼠标移到哪一项、或 `↑/↓` 停在哪一项,**只有那一行**亮;颜色一致;高亮完全落在菜单面板内,**不越出边框、不压住相邻行**(修复前是越界蓝框 + 两侧弧线,且 hover 与键盘焦点两行同时亮) | +| 点别处 | 菜单关闭 | + +### 5b 拖拽提示文案 + +| 检查 | 期望 | +|---|---| +| 页脚(版本号左边) | 常显灰字:**可直接把文件或文件夹拖进窗口上传** | +| 拖文件到窗口 | 仍然出现「松开即上传到当前目录」覆盖层(旧行为不变) | +| 在 PS5 自带浏览器里打开 | 上传按钮与这行灰色提示**都不显示**(`remote-only`,控制台浏览器没有拖拽源) | +| 上传中/选中文件时 | 页脚状态文案变长时,状态文字**单行截断成 `…`**,提示与版本号都还在同一行(不再撑破页脚) | + +### 5c 加密包「上传即解压」必须问口令(**这就是你报的那个 bug**) + +**复现原步骤**:进一个**中文名字的文件夹**(或任意非 ASCII 目录名),上传一个**有密码的 ZIP** +→ 弹「这是压缩包 x.zip,确定:上传后自动解压 / 取消:仅上传」→ 点确定。 + +| 检查 | 期望(修复后) | +|---|---| +| **上传完成后** | 直接弹出「**此压缩包已加密。输入密码后解压,取消则停止解压。**」 | +| **不应**出现 | 一个只有「确定」的错误框、且必须自己去按工具栏「解压」才给输密码 ← 修复前的行为 | +| 输入正确口令 | 继续解压并成功;结束后源压缩包被删除(上传即解压的既有行为) | +| 取消口令框 | 走原来的失败提示,不静默 | +| 中文条目名的包解压失败时 | 错误框里的条目名是**中文**,不是 `â®…ç§.psd` 那种乱码 | + +> 根因(供判读):口令重试原来是按**路径**记住原始请求的,而路径是非 ASCII 时 +> 「页面持有的字符串」与「服务端任务回报的字符串」编码表示不同 ⇒ 查不到 ⇒ 不弹口令框, +> 只能手动再解压一次。现在按**任务 id** 记,路径编码再也不会影响它。 + +### 5e 解压按钮常显置灰 + +工具栏里现在**一直有「解压」按钮**,不再只在选中压缩包时才冒出来。 + +| 检查 | 期望 | +|---|---| +| 什么都不选 | 按钮**在**工具栏里(复制/移动/重命名/下载/删除 之后),**灰色、点不动** | +| 停在灰色按钮上 | 出现提示「**选中一个压缩包后才能解压(ZIP / RAR / 7z)**」 | +| 选中一个文件夹 | 仍然灰色、点不动 | +| 选中**一个**压缩包(`.zip` / `.rar` / `.7z`) | 按钮**变亮可点**,提示变成「解压到当前目录: <包名>」 | +| 选中**两个**压缩包 | 又变灰,提示「一次只能解压一个压缩包」 | +| 只选中 `.part02.rar` 这类子卷 | 变灰,提示「请改选主卷(如 .rar 或 .part01.rar)」(旧行为) | +| 按钮文字 | 是短标签「**解压**」,与相邻按钮等宽(不再是「解压到当前目录」) | +| 有任务在跑时 | 变灰(和其他按钮一起被锁) | + +> 为什么改成常显:旧版不选中压缩包就**完全没有**这个按钮,用户不知道有这个功能。 +> 代价是工具栏常态宽了 96 px ⇒ 窗口窄到 **1190 px** 以下工具栏会换成两行 +> (英文界面是 1350 px;控制台 1920 px、1280 px 都不受影响)。 + +--- + +## 6. 分卷 RAR 进度条实时走动(规定项④) + +`01-rar-vol/` 那组只有 512 KB,进度条会一闪而过 ⇒ 用你自己那份大分卷 RAR(之前那个 ≈11.6 GB 的包已经被清理了, +可以用 WinRAR 现造一个:`-m1 -md=4g -v1g`,内容选一个几 GB 的可压缩文件)。 + +| 检查 | 期望 | +|---|---| +| 进度条 | 按**字节**持续推进,不是长时间 0% 后直接跳 100% | +| 速度读数 | 有合理 MB/s 读数(它是 250 ms 瞬时采样,抖动正常;只信「总字节 ÷ 总耗时」) | +| `entries_done` | 跨卷的**单个大条目**场景下可能长时间停在 0 —— 这是设计如此(`assets/main.js:2118`),看字节进度即可 | +| 取消 | 中途取消能停下,不残留半成品(取消是条目粒度) | + +--- + +## 7. 大 ZIP:160 GB / 9.5 万文件(规定项③,最后做) + +这项只能在真机跑,且最费时。 + +| 检查 | 期望 | +|---|---| +| scan 阶段 | 不 OOM、不长时间无响应;进度条在走 | +| 内存 | 峰值平稳(scan 只读中央目录,不解码) | +| 空间预检 | 目标分区空间不足时应**提前**报错(`check_space()` 要求双份空间) | +| 完成 | 0 报错解完;条目数与源一致 | + +--- + +## 8. 取证与回滚 + +**失败时提供这三样**(我按这个定位,不用你再复述): +1. 界面/通知栏**文案原文**(含错误码,如 `extract_dict_too_large`); +2. `http://:8888/api/tasks` 的返回; +3. 截图(含目标目录文件列表)。 + +**回滚**(一条命令,产物已核验): + +```sh +nc -q0 9021 < .build/rel-v1.9.2/web-file-mgr-v1.9.2.elf +``` + +--- + +## 结果记录表 + +| # | 项目 | 结果 | 文案/备注 | +|---|---|---|---| +| 1 | 版本号四处一致 + 悬停改版提示 | ☐ | | +| 2 | 字典超限报错(8 GiB 字典) | ☐ | | +| 3a | ZIP 分卷(`parts.partN.zip`) | ☐ | | +| 3b | ZIP 分盘(`disks.z01+.zip`) | ☐ | | +| 3c | RAR 分卷(选首卷 / 选子卷置灰) | ☐ | | +| 3d | 7z 分卷(含中日文文件名) | ☐ | | +| 4a | ZIP 加密 ×3(ZipCrypto / AES / AES-store) | ☐ | | +| 4b | RAR 加密头 `-hp` | ☐ | | +| 4c | 7z `aes.7z` | ☐ | | +| 4d | **7z `aeshe.7z`(`-mhe=on` 加密头)** | ☐ | | +| 4e | 错口令 + 重试(三格式) | ☐ | | +| 4f | 首次失败提示文案 / 条目名不乱码 | ☐ | | +| 5a | 上传按钮 → 菜单(文件 / 文件夹) | ☐ | | +| 5b | 页脚拖拽提示 + PS5 浏览器里隐藏 | ☐ | | +| 5c | **加密包上传即解压 → 直接弹口令框**(原 bug) | ☐ | | +| 5d | 菜单行高亮(只有一行亮、不越界、hover 与键盘同色) | ☐ | | +| 5e | 解压按钮常显置灰(灰 → 选中一个包变亮 → 选两个又变灰) | ☐ | | +| 6 | 分卷 RAR 进度条 | ☐ | | +| 7 | 大 ZIP 160 GB / 9.5 万文件 | ☐ | | diff --git a/docs/EXTRACTION-PERF.md b/docs/EXTRACTION-PERF.md index c7b622d..3d4b572 100644 --- a/docs/EXTRACTION-PERF.md +++ b/docs/EXTRACTION-PERF.md @@ -15,10 +15,17 @@ > 布局本来就由 chain 负责)。实测 329 MiB:1.05 s → **0.77 s(1.37×)**,与 7za -mmt=off > 打平(898 ms);7za -mmt=8 = 485 ms。7z/ZIP/RAR 163 checks 全绿。 > -> **✅ ZIP 引擎 fsync 批量化(2026-09-16)**:逐条目 fsync 已移除(publish 是纯 rename、 -> 无续解功能,该 fsync 无收益;RAR/7z 引擎本来就没有)。8000 文件 fixture:fsync 版 +> **✅ ZIP 引擎逐条目 fsync 移除(2026-09-16)** —— 原计划写的是「批量化(每 64MB/N 条刷一次)」, +> **实际落地改为彻底移除**:publish 是纯 rename、又没有续解功能,逐条目 fsync 换不到任何东西 +> (RAR/7z 引擎本来就没有,三引擎现在统一为「不 sync、只 rename」)。8000 文件 fixture:fsync 版 > \>200 s 未完成 → 无 fsync **14.5 s(≥14×)**。同机官方 7-Zip 反而要 >400 s(Defender > 实时扫描逐文件查杀;PS5 无此因素)。 +> +> 与 §二 排除 1 不矛盾:那里测的是**单一大文件**归档,一次 fsync 本来就近乎免费;这里是 +> **8000 个文件**,成本随条目数线性叠加(且 PS5 无 Defender,比例只会更极端)。 +> 已知取舍:publish 之后到落盘之间断电,会出现「文件在但内容不完整」;要补只需在 extract +> 收尾做**一次**目录/整盘 flush(PS5 是 FreeBSD 系,`syncfs()` 不一定有,`sync()` 是全盘、偏重)。 +> 代码现状见 `src/zip_extract.c:937-945`。 ## 结论 @@ -47,7 +54,7 @@ int Z7_FASTCALL LZMA_DECODE_REAL(CLzmaDec *p, SizeT limit, const Byte *bufLimit) | 配置 | 单线程 | 8 线程 | 相对我们 | |---|---:|---:|---:| -| **ours**(facade,含 staging + fsync + publish) | **1.39 s** | — | 1.00× | +| **ours**(facade,含 staging + publish;当时仍含逐条目 fsync,2026-09-16 已移除) | **1.39 s** | — | 1.00× | | 官方 7-Zip(Linux 构建) | **0.89 s** | **0.51 s** | **1.57× / 2.73×** | > 两个数字都是同一台机器上的实测。7-Zip 的 Linux 版和 Windows 版几乎一样快(0.89 vs 0.84 s),说明平台差异不是因素。 @@ -92,7 +99,27 @@ int Z7_FASTCALL LZMA_DECODE_REAL(CLzmaDec *p, SizeT limit, const Byte *bufLimit) ### 附带发现:CRC 占 17% -`CrcUpdateT12`(slicing-by-12)花掉 0.22 s。7-Zip 解压时同样校验 CRC,所以这部分**不构成差距**,但如果单独优化(SSE4.2 硬件 `crc32` 指令,Zen 2 支持)能再省约 0.15 s。 +`CrcUpdateT12`(slicing-by-12,**纯软件实现**)花掉 0.22 s。 + +> ⚠️ **2026-09-23 更正**:本节原先写着「7-Zip 解压时同样校验 CRC,所以这部分**不构成差距**」——**这条是错的**。 +> 依据是本仓 vendor 的官方构建规则 `third_party/7z/7zip_gcc_c.mak:298-310`: +> ``` +> ifdef USE_X86_ASM +> $O/7zCrcOpt.o: ../../../Asm/x86/7zCrcOpt.asm ← 官方走这条:汇编版 +> else +> $O/7zCrcOpt.o: ../../7zCrcOpt.c ← 我们走这条:纯 C +> ``` +> 而 `CpuArch.h:691` 的 `CPU_IsSupported_CRC32()` 说明那份汇编就是 SSE4.2 硬件 `crc32`。 +> 我们的 `third_party/7z/Asm/x86/` 里**只有** `7zAsm.asm` 与 `LzmaDecOpt.asm`,**没有 `7zCrcOpt.asm`**。 +> 所以这 17% **是真实差距的一部分**,不只是「可选的净提速」;它同时还是 MT 路径的**串行瓶颈** +> (`src/sevenz_mt.c` 的 `mt_seq_write` 在调用线程上算 CRC,Amdahl 意义上压住了多线程上限)。 +> +> **2026-09-23 实测补充(`.build/_crcbench.c`,本机 MinGW x64,329 MiB 同一块数据,跑两次)**: +> `CrcUpdateT12` **3.18–3.51 GB/s**(329 MiB → 0.098–0.109 s),zlib `crc32` **2.59–2.80 GB/s**, +> SSE4.2 `crc32` 指令(直线写法)**6.03–6.11 GB/s**。 +> → CRC 实际只占单线程 1.39 s 的 **≈7%**,上面那个「0.22 s / 17%」**大概率是 gprof 插桩放大的** +> (`-pg` 对纯循环函数特别吃亏)。**本节往下请按 ≤7% 理解,不要再引用 17%。** +> 另外实测挖出一个**会算错的陷阱**,见「方案 C」。 --- @@ -121,13 +148,34 @@ SDK 自带 `C/Lzma2DecMt.c`(1095 行,public domain)就是 7-Zip `-mmt` 的 - **风险**:中高(并发正确性、内存峰值;PS5 只有 8 核且 HTTP/任务系统同进程,建议限制线程数) - **前置**:建议先完成 A,因为 A 不改架构、收益确定、能独立验证 -### 方案 C:CRC 硬件加速(可选) +### 方案 C:CRC 加速(**实测后收益大幅缩水,且有一个会算错的陷阱**) -用 SSE4.2 的 `crc32` 指令替换 `CrcUpdateT12`。Zen 2 支持。 +`.build/_crcbench.c` 实测(本机 MinGW x64,329 MiB 同一块数据,两次运行): -- **预期收益:约 0.15 s(10%)** -- **工作量**:小 -- **注意**:7-Zip 也做 CRC 校验,这不会拉开差距,只是净提速 +| 实现 | 吞吐 | 329 MiB 耗时 | 谁在用 | +|---|---:|---:|---| +| `CrcUpdateT12`(slicing-by-12) | 3.18–3.51 GB/s | 0.098–0.109 s | 我们:7z chain / MT 输出 / 逐条目 CRC | +| zlib `crc32` | 2.59–2.80 GB/s | 0.123–0.133 s | 我们:ZIP 引擎 | +| SSE4.2 `crc32` 指令(直线写法) | 6.03–6.11 GB/s | 0.056–0.057 s | 候选 | + +> ⚠️ **陷阱:x86 的 `crc32` 指令算的是 CRC-32C(Castagnoli),不是三个格式要的 IEEE CRC-32。** +> 同一份基准里的判定(标准向量 `"123456789"`): +> `_mm_crc32_*` 得 **`0xE3069283`(CRC-32C)**,而 `CrcCalc` / zlib 得 **`0xCBF43926`(IEEE)**。 +> 所以**不能把 `CrcUpdate` 直接换成 `_mm_crc32_u64`** —— 必须补一个多项式转换 +> (GF(2) 上的 32×32 矩阵),或改用 pshufb / PCLMULQDQ 手写 IEEE 并行 CRC。 +> **这正是官方 `Asm/x86/7zCrcOpt.asm` 存在的意义**:它不是两行 intrinsic 包装。 +> UnRAR 那边同理 —— `third_party/unrar7/crc.cpp` 的硬件路径是 `USE_NEON_CRC32`(**ARM 专属**), +> x86 上是 slicing-by-16 纯软件。 + +**收益重估(按实测)**:CRC 只占单线程 ≈7%(0.098 s / 1.39 s)。硬件指令直线写法 1.8× 于 +slicing-by-12,但还要扣掉多项式转换的开销 → 乐观估计单线程省 **0.05–0.07 s ≈ 4–5%**; +MT 路径里它是串行分量(`sevenz_mt.c:64`),按 0.098 s 算 0.77 s → 约 0.72 s(≈6%), +**不足以解释 8 线程下与 `7za -mmt=8`(0.485 s)的 1.6× 差距**。 + +**结论**:这仍然是**我们与 7-Zip 差距里确定存在**的一块(官方走 `USE_X86_ASM` 分支编汇编版, +我们走 `else` 编纯 C),但**收益是个位数百分比,不是 10–15%,实现也不平凡**。 +风险倒是低:CRC 算错会**响亮失败**(每个条目报 `ZIPX_ERR_CRC`),现有 177 + 27 项测试会立刻抓住, +不会静默写坏数据。**优先级从「最高」降为「可做,但别指望它拉平差距」。** ### 方案 D(备选,不推荐):上游的 helper 路线 @@ -144,20 +192,402 @@ SDK 自带 `C/Lzma2DecMt.c`(1095 行,public domain)就是 7-Zip `-mmt` 的 ## 五、执行顺序建议 ``` -第一步 A(asm 解码器) - └─ 先验证 jwasm → ELF64 → prospero-ld 这条链能否走通 - └─ 通过则:1.39 s → ~0.95 s,测试矩阵全绿后提交 -第二步 B(多线程) - └─ 在 A 的基础上做,目标 ~0.55 s -第三步 C(CRC 硬件加速,可选) - └─ 再省 ~0.15 s +第一步 A(asm 解码器) ✅ 已落地 2026-09-16,实测 1.26× + └─ 先验证 jwasm → ELF64 → prospero-ld 这条链能否走通 ✅ 走通了 +第二步 B(多线程) ✅ 已落地 2026-09-16,实测 1.37× + └─ 在 A 的基础上做,目标 ~0.55 s ⚠️ 实际 0.77 s:仍慢于 7za -mmt=8 的 0.485 s +第三步 C(CRC 加速) ⬜ 未做 —— 但**实测后收益降到 4–5%**,且有 CRC-32C 陷阱 + └─ 官方走 USE_X86_ASM 编汇编版,我们编纯 C,确是真实差距;但不值得为它冒险 ``` -**不做任何优化时的现状也是可接受的**:1.39 s / 329 MiB ≈ 237 MiB/s 单线程吞吐。在真实场景(大游戏包)里受存储 I/O 限制,差距往往比这个倍数更小。 +> **2026-09-23 补充**:B 之后我们与 `7za` 的对比是 0.77 s vs 单线程 0.898 s / 8 线程 0.485 s。 +> 也就是说单线程已打平,**8 线程下仍差约 1.6×**。 +> 曾把这段差距归给「没做的 CRC 硬件化」,但实测否掉了这个假设:CRC 全程只占 ≈7%(**且那是 +> gprof 放大后的口径,实测 0.098 s 更小**),把它全消掉也只值 4–5%,撑不起 1.6×。 +> 更可能的来源是 BCJ2 / 7zAES 布局仍走单线程 chain、以及 `Lzma2DecMt` 自身的线程扇出效率 +> —— 两者都要真机 profile 才能定性。 +> 其余候选(ZIP inflate 换 libdeflate、条目级并行、AES-NI)同理,均属「收益不可预期」或「工程量大」。 + +> **⚠️ 2026-09-23 限定:上面这个 1.59× 是在「最有利的输入形状」上测出来的,不能外推到真实归档。** +> +> 先看基准归档到底是什么(`tests/bench_driver.py:156-179` + payload 构造 `:144-156`): +> +> ``` +> payload = 文本块 ×240 → 一个大文件;再拼 ntoskrnl.exe ×30 → payload_mix.bin +> 7za add = -t7z -m0=lzma2 -mx=5 -ms=on +> ``` +> +> 也就是 **1 个条目 / 1 个 solid folder / 1 个纯 LZMA2 coder / 未加密**。而 `sz_chain_lzma2_root()` +> (`src/sevenz_chain.c:750-762`)要求**恰好** `num_coders == 1 && num_bonds == 0 && +> num_pack_streams == 1 && method == LZMA2` 才走 MT —— 换句话说,这个 fixture 是**唯一能让我们的 +> MT 生效的形状**,也是 7-Zip 拿不到任何结构优势的形状。它把 per-entry 开销(我们的强项)和 +> 非 LZMA2 布局(我们的弱项)**同时排除在外**了。 +> +> 真实 PS5 归档(游戏包 / repack)几乎全是相反的形状: +> +> | 真实特征 | 对我们的后果 | 对 7-Zip 的后果 | +> |---|---|---| +> | 几千~几万个条目 | per-entry 开销主导(这块我们反而大幅领先,见上方 8000 文件数据) | 同样逐条目,无优势 | +> | `-ms=off` / 超大归档切块 → **多 folder** | `num_pack_streams != 1` → **MT 失效**,退回单线程 chain | 跨 folder/块并行,`-mmt` 照常吃满 | +> | exe/dll 用 **BCJ2** | `num_coders != 1` → **MT 失效** | 照常多线程 | +> | **7zAES 加密** | `sz_chain_needs_password` → **MT 失效** | 照常多线程 | +> +> 结论:**1.59× 既不是上限也不是下限**,真实方向未知 —— 可能因 I/O 与 per-entry 成本被摊薄到无关, +> 也可能因为整条解码退回单线程而比 1.59× 更糟。**在拿到真实归档上的 profile 之前,任何一侧的 +> 断言都是猜的。** +> +> 同样,下面这句原话也**未经验证**,暂按假设保留: +> 「在真实场景(大游戏包)里受存储 I/O 限制,差距往往比这个倍数更小」——它成立的前提是解码项 +> 只占 wall-clock 的一小部分;按 0.77 s / 329 MiB ≈ 427 MiB/s 的解码吞吐与 PS5 存储带宽同量级来算, +> 这个前提**未必成立**。 + +**不做任何优化时的现状也是可接受的**:1.39 s / 329 MiB ≈ 237 MiB/s 单线程吞吐;ZIP/RAR 两个引擎 +已分别压过/追平各自的官方实现,7z 单线程与 `7za -mmt=off` 打平。 --- -## 六、复现 +## 六、真机首次实测(2026-09-23) + +> ### ⚠️⚠️ 第二次更正(2026-09-23 深夜):拿到**真实归档的参数**,并实测了 MT +> 用户给了 `D:\PPSA16608-e.part{1,2,3}.rar`,其中 **part3 当时还在盘上**,我用 WinRAR 7.23 的 +> `UnRAR lt` 直接读了它的头(该文件随后被用户清理掉,故下列数字是**一次性的实测记录**): +> +> | 项 | 实测值 | +> |---|---| +> | 格式 | **RAR 5**(不是 RAR4;头 8 字节 `52 61 72 21 1A 07 01 00`) | +> | 分卷 | **卷 3 / 锁定(locked)** | +> | **固实** | **不是固实** —— 直接解析 part3 主头:archive flags = `0x0013` = `VOLUME|VOLNUMBER|LOCK`,**`MHFL_SOLID=0x0004` 未置位**(`third_party/unrar7/headers5.hpp:25-29`)。旁证两处自洽:`MHFL_VOLNUMBER` ⇒ volnumber=2 ⇒ unrar 显示「卷 3」✓;`MHFL_LOCK` ⇒ unrar 显示「锁定」✓ | +> | 关键条目 | `PPSA16608.exfat` **19 493 027 840 B → 打包 3 831 295 727 B**(**ratio 5.09 : 1**,高度可压缩) | +> | 压缩参数 | **`RAR 5.0(v50) -m1 -md=4g`** —— **-m1「最快」档 + 4 GiB 字典** | +> | 体量 | part3 = **3.568 GiB ≈ 该条目的打包大小** ⇒ 这个 19.5 GB 条目**整段都在 part3 内**(另有两个 KB 级小文件);parts 1+2(各 4 GB)装的是其余 ~1250 个条目 | +> +> **① UI 速度口径已核实 = 解压后字节。** `src/rar_extract.c:766` 在 `UCM_PROCESSDATA` 回调里 +> `c->bytes_done += p2`(`p2` 是 unrar 交出的**解压后**长度),`bytes_total` 累加 `hdr.UnpSize`。 +> ⇒ 用户看到的 **10–40 MB/s 是"吐出数据的速度"**。 +> +> **② 「scan 阶段白解码一遍(2×)」这个最大嫌疑——已排除。** +> `third_party/unrar7/dll.cpp:341-342` 只在 **`!Arc.Solid`** 时把 `RAR_SKIP` 走廉价的 +> `Arc.SeekToNext()` 分支;`extract.cpp:529`(`SkipSolid=Arc.Solid` ⇒ 真解码后丢弃)只在 +> **固实**时成立。本档**非固实** ⇒ 我们的 scan 只读头 + 跳过字节,**不解码**。 +> (这条曾是最有希望的一项:真固实的话 `scan + extract` 会把 11.6 GB 解码两遍。) +> +> **③ MT 收益已实测**(host、WinRAR 自带 **UnRAR 7.23**、`-mt` 开关虽未见于 `-?` 帮助但可解析): +> +> | 夹具 | 载荷 | `-mt1` | `-mt8` | 加速 | +> |---|---|---:|---:|---:| +> | **代表性**(`-m1 -md4g`、5.27:1 可压缩、固实、2 GB、16 文件) | 2 GB | **386 MB/s** | **946 MB/s** | **2.45×** | +> | 代表性同上但不固实(5.01:1) | 2 GB | 505 MB/s | 1222 MB/s | 2.42× | +> | ~~非代表性~~(90% 随机数据 ⇒ 压缩块小) | 240 MB | 129 MB/s | 331 MB/s | ~~2.55×~~ **作废** | +> +> ⇒ **形状错误会让结论偏乐观**:第一版夹具用 90% 随机数据,压缩块远小于 +> `unpack50mt.cpp:150-152` 的 `LargeBlockSize=0x20000`(128 KiB)阈值,MT 全程生效; +> 真实归档是 5:1 可压缩数据,块更大、会触发 `LargeBlock` 退化路径,实测也确实从 2.55× 降到 2.45×。 +> 结论:**在真实形状上 MT 值 ~2.4×**,可信。(单位均为**解压后** MB/s。) +> +> **④ 接线比原计划简单:1 个编译开关 + 1 个链接开关,不用改 vendored 源码、不用增删源文件。** +> (下表中间那一行「把 `threadmisc.cpp` 加进源列表」**经实测作废** —— 行内已更正。) +> | 改动 | 位置 | 为什么必需 | +> |---|---|---| +> | `-DRAR_SMP` | `Makefile:UNRAR7_CXX_FLAGS`(PS5 与 host 两处) | `os.hpp:42-45` 的 `#define RAR_SMP` 在 `#ifdef _WIN_ALL` 内;且 `unpack.cpp:7-9` 的 `#include "unpack50mt.cpp"` 就在 `#ifdef RAR_SMP` 里 ⇒ 不定义宏则整个 MT 解码器**根本不参与编译** | +> | ~~把 `threadmisc.cpp` 加进 `UNRAR7_SRCS`~~ **← 这一条是错的,不要做** | — | `GetNumberOfThreads()` 确实定义在 `threadmisc.cpp:178`,但 `threadpool.cpp:5` **已经** `#include "threadmisc.cpp"` ⇒ 它**已经**被编进 `threadpool.o`(实测回执:`nm unrar7_threadpool.o` 能查到 `T GetNumberOfThreads` / `T GetNumberOfCPU`)。**再加进源列表 = 重符号链接失败。** 同理 `blake2sp.cpp` 也不必补 —— `blake2s.cpp:27` 已经 `#include "blake2sp.cpp"`。官方 POSIX makefile 只列 `threadpool.o`、不列 `threadmisc.o`/`blake2sp.o`,正是这个原因 | +> | `-pthread`(编译+链接) | `Makefile` 的 PS5 link 行 | `threadpool.cpp` 的 `_UNIX` 路径用 pthread cond/mutex(**不用 `sem_t`**)。SDK 侧 `target/lib/libpthread.a` 在位、`target/include/pthread.h:198-237` 声明齐全 | +> +> **实测回执(宿主 MinGW,2026-09-23)**:用**与 PS5 完全相同的 50 个源文件**、只加 `-DRAR_SMP`, +> 链接**一次通过**(`bench_mt.exe` 1,011,448 B):`nm` 里 `Unpack5MT` 出现 1 次、`ThreadPool` 12 次; +> 换成 `-DZIPSFX`(关掉 `RAR_SMP`)后 `Unpack5MT` 归 0。 +> ⇒ **接线 = 1 个编译开关 + 1 个链接开关,零源码改动、零源文件增删。** +> **为什么不必改 `dll.cpp`**:`dll.cpp:6-12` 的 `DataSet` 成员顺序是 `CommandData Cmd; Archive Arc; CmdExtract Extract;`, +> 构造时 `Cmd` 先完成 → `RAROptions::Init()`(`options.cpp:22-24`)在 `RAR_SMP` 下执行 +> `Threads=GetNumberOfThreads()` → 随后 `CmdExtract Extract(&Cmd)` 构造函数里 +> `Unp->SetThreads(Cmd->Threads)`(`extract.cpp:25-27`;上限 `Min(Threads,8)`,`unpack.cpp:65-71`)。 +> ⇒ **宏一开,RARDLL 路径自动拿到 MT**(这正是 CLI 与 DLL 共用的那条链路)。 +> 内存代价:MT 下 `UnpackThreadData` × `MaxUserThreads*2`(每个 `Decoded` 预分配 0x4100 项) +> + `ReadBufMT` 4 MiB ≈ **15 MB 量级**,PS5 上可忽略。 +> +> **⑤ 但是:新证据把矛头指向「写路径 / 存储」,而不是解码 —— 先别改代码。** +> - 同形状**单线程**解码在 PC 上是 **386–505 MB/s(解压后口径)**;PS5 的 Zen 2 单核即使按 1/3 算也有 **~130 MB/s**。 +> - 真机只算 `.exfat` 一项就是 **19.49 GB ÷ 660 s = ≥29.5 MB/s 解压后**(且与 UI 的 10–40 吻合)。 +> 若 parts 1+2 的 8 GB 打包数据解开后还有十几 GB,那么全流水线就是 **30–70 MB/s 解压后**, +> 比 CPU 能力低 **3–10×**。 +> - **两次独立操作撞同一个数**:上传(写 11.6 GB)实测 30–40 MB/s;解压(写 ≥19.5 GB)≈30 MB/s。 +> ⇒ 优先怀疑 **PS5 这条写路径的上限就在 30–40 MB/s**(内置盘 / 外置盘 / 目标目录待确认)。 +> - ⇒ **决策顺序**:先做 `T_copy`(零改动、纯搬运)。纯搬运也 ~10 分钟 ⇒ 收工,MT 不必做 +> (做了也会被 I/O 吃掉);纯搬运明显快 ⇒ 再上 MT,那 2.4× 才是真金白银。 +> +> 复现脚本:`.build/rabtest/ab_rar_mt.py`(第一版,形状错误,留作反例)、 +> `.build/rabtest/ab_rar_mt2.py`(代表性版)。夹具留在 `D:\_wfm_rabtest{,2}\`(≈1 GB)。 + +> ### ⚠️ 2026-09-23 晚 更正:**「瓶颈不在解码」这个结论已撤回** +> +> 本节最初写它时只知道「18 GB / 11 分钟 / 1252 条目」,**不知道归档格式**。随后的补充 +> (**格式是 RAR**;包在 PC 上、经插件上传进 PS5;上传速度 30–40 MB/s)把两个前提都改了: +> +> **① 参照物错了 —— 这是方法错误,不是估计偏差。** +> 原文拿「PS5 上解 **RAR** 的 28 MiB/s」去比「PC 上解 **7z** 的 427 MiB/s」。**不同格式、 +> 不同解码器、不同机器**:RAR 我们直接用 rarlab 的 UnRAR 库,7z 走自建 chain,两者毫无 +> 可比性。⇒ 原文「推论二(量级差 3–10×)」**不成立**,不能作为解码无罪的证据。 +> 「推论一(摆动)」此前已自行降级为弱证据(250 ms 采样噪声)。**两条都没了。** +> +> **② 查代码查出一个具体缺口:RAR 解码在我们这里是单线程的。** +> `third_party/unrar7/os.hpp:43-45` 的 `#define RAR_SMP` 落在 `#ifdef _WIN_ALL` 分支**内**, +> 所以 POSIX(PS5)构建**不定义** `RAR_SMP` —— 我们 Makefile 里 0 次出现;而官方 POSIX +> makefile 第 11 行是 `DEFINES=... -DRAR_SMP`,**我们漏了这个开关**。后果: +> - `unpack.cpp:185-198` 的 MT 分支整段不参与编译 ⇒ 永远走单线程 `Unpack5()` +> - `unpack50mt.cpp`(`Unpack::Unpack5MT`)**不在我们的源列表里**;这是 rarlab 专门调过的 +> 多线程 RAR5 解压器(文件头注释:「0x400000 和 2 对 i9-12900K 最优」) +> - `Unpack::SetThreads()` / `ThreadPool` 随之消失 +> - ⚠️ **我们在 ELF 上做的符号核查是无效的**:该 ELF 只有 `.dynsym`(513 项)、**没有 +> `.symtab`**,任何内部符号都查不到(零命中是假象)。上面的结论来自 Makefile 与 `os.hpp`。 +> +> **③ 新的首要假设:28 MiB/s ≈ 单线程 RAR5 解码的典型量级。** +> RAR5 `-m5` 单线程在现代桌面 CPU 上约 40–80 MB/s 输出,PS5 的 Zen 2 单核更低。 +> 另一个角度:`18 GB ÷ 660 s = 27.9 MB/s` 是**解码输入**速率,而上传实测证明**写入端** +> 至少能到 30–40 MB/s、**读取通常快于写入** ⇒ 「纯存储上限」解释不了这个数。 +> +> **④ 附带作废一条**:「读粒度只有复制路径 1/32–1/64」是 **7z 的数字**(`SZ_IN_CHUNK` +> 256 KiB),对 RAR 不适用 —— RAR 走 `od.ArcName` 按路径打开(`src/rar_extract.c:1059`), +> 归档 I/O 由 UnRAR 自己的 `File` 类完成,我们的回调只收到**解压后的数据**, +> **插不进 read-ahead**。⇒ 待办 #48 对 RAR 无效。 +> +> **⑤ 归档真实参数(口径已闭合)** —— 两个来源合起来读得通:WinRAR 信息页(待解压版本 5.0、 +> 加密「缺少」、无恢复记录、压缩文件锁定「存在」、**字典 4 GB**、压缩率 19%、 +> **总大小 19,493,028,232 B = 18.15 GiB**、打包大小 3,831,296,089 B、**总文件 3**)+ 文件列表 +> (三卷 `.part1/2/3.rar` = 4 GB + 4 GB + 3.6 GB ≈ 11.6 GB)。 +> **两者不矛盾**:信息页是把 **part3 当独立归档**打开的 —— part3 = 3.568 GiB,里面就是那 3 个条目 +> (一个 19.5 GB 的 `PPSA16608.exfat` + 两个 KB 级小文件),上面的「第二次更正」块已用 +> `UnRAR lt` 直接读头证实;而三卷合计 ≈11.6 GB 才是整个包(另外还有 ~1250 个条目)。 +> ⚠️ 我先前按"两图互相矛盾、需用户确认"写的那一版**作废**:不是两个归档,是"单卷视图 vs 整包视图"。 +> 速度口径不受影响:**18.15 GiB ÷ 660 s = 29.5 MB/s(解压后字节)**;就那个大条目而言 +> 读侧只需 3.83 GB ÷ 660 s ≈ **5.8 MB/s** ⇒ **读侧不是瓶颈,29.5 MB/s 是解码+写盘的真实速率**。 +> +> ## ⚑ 终局:RAR5 多线程**不做**(2026-09-23 18:15,用户决定) +> +> **#51 结案:保持单线程现状,生产代码不动、不刷机。** +> +> **为什么不做的依据是"判不了",不是"没收益"** —— 收益本身已实测(块 ⑧:我们的引擎 +> 1.40–1.74×;rarlab CLI 在用户那种形状上 2.45×)。卡住的是**它能不能兑现**: +> - `T_copy` 没做 ⇒ 无法区分「解码慢」与「写路径上限 ≈30 MB/s」。 +> - 而 MT **只并行解码**:worker 只跑 `UnpackDecodeThread`(`unpack50mt.cpp:190`), +> **写盘恒为主线程串行**(`UnpWriteBuf()` 仅由主线程调用 —— `unpack50mt.cpp:283/475/587`)。 +> ⇒ 若墙在写路径,MT 的收益直接退化成 **1.0×**。 +> - 成本收益:判定要人上手测一次复制;上线要刷机 + 重跑 11 分钟。而收益可能为 0 +> ⇒ **不做**。维持 11 分钟,把不确定性留在文档里,比赌一次更划算。 +> +> **下面是已完成的技术取证,全部保留** —— 将来若要重开,它就是现成答案。 +> ⚠️ **重开的第一个动作是 `T_copy`,不是改构建**(判读见 `docs/REAL-CONSOLE-PROFILE.md` 第 1 步)。 + +> **⑥ 开 RAR5 多线程只需一个编译开关**(把 #51 的工作量从"未知"降到"一行"): +> - `third_party/unrar7/unpack.cpp:7-9` **已经** `#include "unpack50mt.cpp"`; +> `threadpool.cpp:5` **已经** `#include "threadmisc.cpp"`。⇒ 官方 POSIX makefile 不列这两个 +> `.o` 是**正常的**,**不需要新增源文件**(此前"官方 makefile 自相矛盾"的疑点已消除)。 +> - `raros.hpp:23-25`:非 Windows 一律 `#define _UNIX` ⇒ PS5 构建自动走 Unix 分支; +> `threadpool.cpp` 的 `_UNIX` 路径用 pthread cond/mutex,**不用 `sem_t`**。 +> - 线程数在 DLL 模式下**自动接上**:`dll.cpp` 每个归档持有一个 `CommandData`, +> 构造函数 `cmddata.cpp:6-9 → Init() → RAROptions::Init()` → +> `options.cpp:22-24 Threads=GetNumberOfThreads()`;`extract.cpp:25-27` 再 +> `Unp->SetThreads(Cmd->Threads)` → `unpack.cpp:65-71 MaxUserThreads=Min(Threads,8)`。 +> ⇒ **不需要 CLI 开关、不需要 vendor 补丁**。 +> - ⇒ 改动 = 给 unrar 对象加 `-DRAR_SMP` + 链接 pthread。**PS5 侧唯一未验证点是 +> `sysconf(_SC_NPROCESSORS_ONLN)` 是否返回真核数**(`threadmisc.cpp:111-124`): +> SDK 的 `unistd.h:291` 定义了 `_SC_NPROCESSORS_ONLN 58`、`pthread_*` 在 +> `target/include/pthread.h:198-237` 齐全;但若 `sysconf` 返回 1,**MT 会静默失效**。 +> ⚠️ 注意 `threadmisc.cpp:116-124` 在 `_UNIX` 且未定义 `_SC_NPROCESSORS_ONLN` 时**没有 +> return 语句**(UB)—— 真机上要能看到核数才算数。 +> +> **⑦ MT 对固实归档同样生效,唯一例外是分片窗口**:`unpack.cpp:185-198` 在 +> `MaxUserThreads>1` 时调 `Unpack5MT(Solid)` —— 形参本身就带 `Solid`。会把它挡在外面的只有 +> `Fragmented`(`unpack.cpp:193`):`unpack.cpp:130-145` 只在 4 GiB 窗口的**连续分配失败** +> 且 `WinSize>=16 MiB` 且 64 位时才置位,而且分片窗口路径**本身更慢**。 +> 64 位下单次 malloc 失败通常是"量不够"而非"地址空间碎",此时 `FragWindow.Init(同一大小)` +> 也会失败 ⇒ **分片窗口属于罕见回退,概率低**。但它是可判读的: +> **真机 A/B 若"开了 MT 却一点没变",第一嫌疑就是它或 `sysconf`。** +> +> **⑧ 宿主 A/B 实测:MT 值 1.4–1.75×**(同一台机器、同一份二进制,只差一个 `-DZIPSFX`)。 +> 方法:MinGW 下 `_WIN32 ⇒ `_WIN_ALL` ⇒ `os.hpp:43` 自动定义 `RAR_SMP`,所以**我们过去所有 +> 宿主基准跑的都是多线程路径**;反过来单线程基线只能靠 `-DZIPSFX`(该宏在整棵源码树里 +> **只出现一次**,就是 `os.hpp:43`,干净可用)。回执:`nm` 查 `unpack.o`,base 的 +> `Unpack5MT` 符号数 = 0、mt = 1。样本:341 MiB 现实混合数据(127 MiB 真实二进制 +> + 158 MiB 短匹配文本 + 48 MiB 随机),RAR5 固实 `-m3`,压缩后 125–153 MB(比率 37–45%): +> +> | 样本 | base(单线程) | mt | 加速 | +> |---|---:|---:|---:| +> | 固实 `-md1m` | 69.7 MiB/s | 97.3 MiB/s | **1.40×** | +> | 固实 `-md256m` | 63.4 MiB/s | 110.6 MiB/s | **1.74×** | +> | 分卷 `-md256m`(96+23 MiB) | 64.2 MiB/s | 100.8 MiB/s | **1.57×** | +> +> 两点附带信息:①**字典越大 MT 越划算** —— 单线程随字典从 1 MiB 涨到 256 MiB 掉到 +> 63–70 MiB/s(内存局部性),而 MT 稳定在 97–110;用户的包字典 4 GB,比这里最大的样本 +> 还大 16×,**方向上有理由期望 MT 收益不小于 1.6×**。②**跨机器外推不算结论**: +> 宿主单线程 63–70 MiB/s vs PS5 的 28.1 MiB/s 是不同 CPU,只作量级参考。 +> ⇒ 若 PS5 上同样拿到 1.5–1.75×,11 分钟 → **约 6.3–7.3 分钟**。 +> +> **⑧b 与上面「第二次更正」块的 2.45× 不矛盾 —— 差在样本形状,不在实现。** +> 那块用 rarlab 自带 **UnRAR 7.23 CLI** 的 `-mt1` vs `-mt8`,夹具是 `-m1 -md4g`、**5.27:1** +> 可压缩的 2 GB /16 文件;我这边是 `-m3`、只有 **2.4:1** 的短匹配数据。方向一致: +> **数据越可压缩(匹配越长)MT 越划算** —— 长匹配让一条符号吐出更多字节,串行 apply 被摊薄、 +> 并行解码占比上升,同时更容易越过 `unpack50mt.cpp:150-152` 的 `LargeBlockSize=0x20000` 退化阈值。 +> ⇒ **对用户这个包应以 ~2.4× 为预期**(它是 `-m1 -md4g`、5.09:1,正落在那个夹具的形状上), +> 我测到的 1.4–1.75× 作为**更难数据下的下界**。绝对吞吐那 10× 的差距(386 MB/s vs 63–70 MiB/s) +> **纯粹是夹具可压缩性差异,不能拿来比较两个实现**(这正是"基准代表性"那类错误)。 +> 综合估计:11 分钟 → **约 4.6 分钟**;悲观情形(1.5×)→ 6.3–7.3 分钟。 +> +> **⑨ 样本代表性教训(第一版 A/B 是废的)**:初版样本是「同一个 60 KiB 区块重复 2400 次」, +> 压缩到 0.2%、解码 **602 MiB/s** —— 长匹配让范围解码器的每条符号吐出上千字节, +> 这个形状**真实归档里不存在**,而且它恰恰是 MT 最不擅长的形状(串行 apply 占主导)。 +> 后改用"真实二进制 + 短匹配文本 + 随机"混合,比率 37–45%,吞吐落到 63–110 MiB/s 的正常带。 +> 另:初版把 `-v96m` 的目标名写成 `vol.part1.rar`,rar 会翻倍成 `vol.part1.part1.rar` +> (`tests/make-rar-fixtures.bat` 里已记过这个坑,我复现了一遍)。 +> +> **⑩ 顺带查出两个真实缺陷/隐患**: +> 1. **字典 > 4 GiB 的归档会直接失败** —— **已修,但修的是"说清楚",不是"放行"**。 +> 机制:`extract.cpp:1748-1767 CheckWinLimit()` 在 `WinSize > Cmd->WinSizeLimit` 时调 +> `uiDictLimit()`;DLL/silent 构建里 `uisilent.cpp:65-73` 只在 +> `Cmd->Callback(UCM_LARGEDICT, …) == 1` 时才放行,否则 `DllError=ERAR_LARGE_DICT` 并跳过 +> 该文件。我们的回调原先**只处理 `UCM_PROCESSDATA`**,其余一律返回 0。默认 +> `Cmd->WinSize`/`WinSizeLimit` = `0x2000000` / `0x100000000`(`options.cpp:12-13`) +> ⇒ 分界线正好是 4 GiB(比较是 `<=`)。 +> +> **⚠️ 三条订正(2026-09-23 18:40,把先前"1 行可修"的判断推翻):** +> - **RAR5 根本到不了这里。** `arcread.cpp:871` 把 RAR5 字典读成 +> `0x20000 << ((CompInfo>>10) & 0x0f)` —— **只有 4 bit** ⇒ 格式自身上限 = `0x20000<<15` +> = **正好 4 GiB**,与我们的 limit 相等。⇒ 任何 `-ma5`(含用户这个包)**永远不触发**。 +> 只有 **RAR7 头**(`UnpVer==1`,5 bit,上限 `UNPACK_MAX_DICT` = 64 GiB)才可能超。 +> - **而 RAR7 造不出来。** 实测 `Rar.exe 7.23`:`-ma4` / `-ma6` / `-ma7` **全部 exit 7** +> (命令行错误),只有 `-ma5` 可用。⇒ 本机连验证样本都得手工合成。 +> - **"放行"是陷阱不是修复。** 放行后 unrar 会去 `new` 一个**完整的字典窗口**;rarlab 自己的 +> CLI 对这个样本的答复是:「8 GB 字典超过 4 GB 限制,而且需要大于 8 GB 内存来解压缩。 +> 使用 `-md8g` 或 `-mdx8g` 参数来解压缩。」PS5 只有 16 GB **共享**内存 ⇒ >4 GiB 字典在 +> 该设备上本就解不动;**中途被 OOM 杀掉(整个 payload/UI 一起没)比干净失败更糟**。 +> ⇒ 决定:**保持拒绝**,与上游 CLI 默认一致。 +> +> **实际改动(在生产代码里,2026-09-23):** 拒绝时不再把锅甩给条目。原先 +> `ERAR_LARGE_DICT → ZIPX_ERR_LIMIT_FILE` → i18n `extract_entry_too_large` +> ⇒ 用户看到的是「**压缩包内单个文件过大: hello.txt**」,而那个条目只有 7 KB —— 完全错。 +> 现在新增 `ZIPX_ERR_LIMIT_DICT`(`src/zip_extract.h`)+ `extract_dict_too_large` +> (中/英),并在 `rar_data_cb` 里从 `UCM_LARGEDICT` 的 `p1/p2` 取回真实数字, +> 报成「需要 8192 MiB(上限 4096 MiB)」。回归用例 `tests/test_rar_extract.c:test_dict_limit` +> + 固定样本 `tests/fixtures/dict-8g.rar`(合成器 **`tests/make_fixtures.py:bigdict()`**, +> 纯 Python 手写最小合法 RAR5 归档、不依赖任何压缩器;说明见该函数 docstring)。 +> ⚠️ **样本必须由 `make_fixtures.py` 生成** —— `fresh()` 会 `shutil.rmtree()` 整个 +> `tests/fixtures/`,提交进去的二进制会被抹掉,所以不能单独放一个生成脚本。 +> 2. **归档里的目录条目 +「覆盖」策略 = 第二次解压到同一目录必失败**。 +> `src/rar_extract.c:884-892`:目标已存在且是目录、而归档条目也是目录时, +> 只有 `ZIPX_CONFLICT_MERGE` 能过;`OVERWRITE` 报 `ZIPX_ERR_CONFLICT` +> (状态串是 "target already exists",`detail` 才是 "directory already exists")。 +> `zip_extract.c:1123` / `sevenz_extract.c:1526` 同构。⇒ 用户对含目录的包用「覆盖」 +> 解两次,第二次会失败。**这条是我在搭 A/B 时被挡了才知道的**,需要确认是否设计意图。 +> +> 下文数据与推论**保留原文**(它们是当时判断的依据),但**结论以本块为准**。 + +### 数据 +用户在真机上解一个 **18 GB 的包**,UI 上报的解压速度在 **10–40 MB/s** 之间摆动; +随后补上两个关键数字:**总耗时 11 分钟(660 s)**、**条目数 1252**(平均 14.7 MB/条目); +再确认 **18 GB 是压缩包自身的大小**(解压后多大未知)。 + +⇒ **平均吞吐 ≥ 28 MiB/s**(18 GB = 18 432 MiB ÷ 660 s;解压后更大则更高,故为下限)。 +**这个数字才是基线**,UI 上那个 10–40 的区间只是瞬时值。 + +「18 GB = 压缩包大小」顺带给出一个**与解码无关的硬上界**:源盘必须在 660 s 内交出 +18 GB 归档数据 ⇒ 整条流水线的平均吞吐上界就 ≈ 28 MiB/s。**无论解码多快,源盘只有这个交付速度。** +这也是为什么第 1 步的 `T_copy`(同一个 18 GB 文件的纯搬运)能与 660 s 直接比大小。 + +顺带排除一项:**per-entry(小文件)开销不是主因** —— 1252 个文件、平均 14.7 MB,不是 +"几万个小文件"那种形态,建文件 + rename 分摊到 0.53 s/文件里微乎其微。 + +口径先确认(不是猜):进度条的"字节"是**解压后的字节**—— +`src/zip_extract.c:651-678` 把 `bytes_total` 累加自 `info->uncompressed_size`; +`:875` 的 `mz_zip_entry_read()` 返回解压字节,`:900/910` 用它累加 `bytes_done`。 +所以 10–40 MB/s 是**吐出数据的速度**,正是用户关心的那个口径。 + +### 推论一:摆动说明"负载不恒定",但它是弱证据 +solid 块(同一字典、同一条码流)的解码速率**几乎是恒定的**。要出现 4× 的摆动, +更像是 I/O 侧在变:源盘读取、目标盘写入、逐条目同步开销、或存储设备自身在忙。 + +但这条**不能单独定案** —— 那个 MB/s 是 250 ms 窗口 + 1 MiB 上报阈值的**瞬时值** +(`src/zip_extract.c:123`、`src/task.c:202-206`),写缓冲突发本身就能在窗口里造成大幅跳动。 +它能说的只有"负载不恒定",不构成"解码无罪"的证明。有解释力的是推论二(量级)和推论三(粒度)。 + +### 推论二:量级上差 3–10×,解码没有解释力 +| | 吞吐 | +|---|---:| +| PC / WSL,329 MiB 混合数据,8 线程(本仓实测) | **427 MiB/s** | +| PS5 单线程解码的乐观上界(按核数×频率外推,**未实测**) | ~100 MB/s | +| **真机实测(18 GB 包,端到端)** | **10–40 MB/s** | + +18 GB @ 10–40 MB/s = **7.7 ~ 31 分钟**;同样数据在 PC 上纯解码约 43 s。 +⇒ 解码最多占 10–25%,**很可能远低于此**。 + +### 推论三:三个引擎的读请求粒度都只有复制路径的 1/32–1/64 +粒度审计(已核对源码): + +| 路径 | 源侧读粒度 | 目标侧写粒度 | +|---|---|---| +| 复制(`copy_file_pipeline`,≥256 MiB) | **8 MiB** × 3 slot,4096 对齐,独立读线程 | 8 MiB | +| 7z | chain `SZ_IN_CHUNK = 256 KiB`(`src/sevenz_chain.c:87`);MT 路径 `inBufSize_MT = 1 MiB` | `SZ_OUT_CHUNK = 64 KiB`(`:94`) | +| ZIP | `ZIPX_IO_BUFFER = 128 KiB`(`src/zip_extract.c:32`) | 128 KiB | +| RAR | UnRAR `File::CopyBufferSize() = 4 MiB`(`third_party/unrar7/file.hpp:148-153`) | 4 MiB | + +都不是 4 KB 那种「小读」灾难,但**都比复制小 32–64 倍**。在延迟主导的设备上, +吞吐 ≈ 单次请求大小 ÷ 每次请求的等效延迟: + +``` +256 KiB / 10 ms = 25 MB/s ← 正好落在实测 10–40 MB/s 的中间 + 8 MiB / 10 ms = 800 MB/s ← 复制路径不会撞这个上限 +``` + +若源与目标在**同一块盘**(例如外置 USB HDD 上解压到同一块盘),读流与写流并存, +磁头来回跑、read-ahead 被写回刷打断 → 每次请求退化成一次寻道,上面这个算术即成立。 +**这是目前唯一可疑的代码级病因**,而它的改动面极小:所有 7z 的读都只经过 +`src/sevenz_volstream.c` 的 `vol_read()` 一个函数(ZIP/RAR 同理在 `src/zipx_volstream.c`)。 + +### ~~因此:剩余解码优化项全部搁置~~(**已撤回,见文首更正块**) +原文在这一段把 CRC 硬件化、MT 扩到 BCJ2 / 多 folder、ZIP inflate 换 libdeflate、AES-NI 全部判为 +「在 10–40 MB/s 的现实面前没有意义」。**建立在「解码不是瓶颈」之上,而那个前提已撤回。** + +更正后的分层判断(按真实工作负载 **RAR** 重排): + +| 项 | 更正后的判断 | +|---|---| +| **UnRAR RAR5 多线程解压(`RAR_SMP`)** | **❌ 不做(2026-09-23 用户决定,见文首「终局」块)**。收益已实测(rarlab CLI `-mt1` vs `-mt8` **2.45×**,见「第二次更正」块 ③;我们引擎在更难数据上 1.40–1.74×,块 ⑧b),接线也只是 **1 个编译开关 + 1 个链接开关、零源码改动**(块 ④)—— **但 `T_copy` 没做,无法排除「写路径上限 30–40 MB/s」**;MT 只并行解码、写盘恒为主线程串行(`unpack50mt.cpp:190` / `:283,475,587`)⇒ 收益可能是 1.0×。刷机 + 重跑 11 分钟的成本压在不确定收益上 ⇒ 搁置 | +| CRC 硬件化(4–5%) | 仅对 7z / ZIP 有意义;**对 RAR 完全无关**(CRC 由 UnRAR 自己算) | +| MT 扩到 BCJ2 / 多 folder | 仅 7z;RAR 工作负载下无意义 | +| ZIP inflate 换 libdeflate / AES-NI | 同上,与 RAR 无关 | +| 读粒度 / read-ahead(#48) | **对 RAR 无效**(UnRAR 按路径自读,回调只收解压后数据);只对 7z/ZIP 有意义 | + +「先 profile 再排序」这个结论**仍然成立**,但目标从「查清为什么只有 10–40 MB/s」变成 +**「先把 RAR 单线程这条确认掉 / 排除掉」** —— 见 `docs/REAL-CONSOLE-PROFILE.md`。 + +### 尚未定案的部分 +- ~~**归档格式未知**~~ → **已确认(2026-09-23):格式是 RAR**,包原本在 PC 上、经插件上传进 + PS5(上传速度 30–40 MB/s)。⇒ 解码用的是 rarlab 的 UnRAR 库本身,**我们自己的代码在 RAR + 解码路径上只剩一层很薄的 facade**;「我们的解码器慢」这个方向基本不成立, + 但「**我们的构建没打开 UnRAR 的多线程**」成立(见文首更正块 ②)。 + 仍未确认:**RAR4 还是 RAR5**(`Unpack5MT` 只对 RAR5/7.0 生效)、是否固实、是否加密、 + **解压后多大**(决定输出吞吐)。 +- ~~**18 GB 是压缩后还是解压后**~~ → **已确认(2026-09-23):18 GB 是压缩包自身的大小**。 + 于是有了一个**不需要知道解压后大小的硬上界**:源盘要在 660 s 内交出 18 GB 归档数据 + ⇒ 整条流水线平均吞吐上界 ≈ **28 MiB/s**;`T_copy` 与 660 s 可直接比大小。 +- **存储未知**:包在哪个设备上(内置 SSD / 外置 USB / 同盘还是异盘)、输出写到哪个设备。 +- ~~**下一步(决定性、零改动)**~~ → **未执行(2026-09-23 用户决定搁置,见文首「终局」块)**: + 用插件自己的**复制**功能把那个包搬到解压输出所在的那块盘上,记耗时。复制的 + `copy_file_pipeline` 是 8 MiB × 3 slot 的双缓冲搬运,⇒ 它就是"同一台机器、同一对设备、 + 只把解压换掉"的对照。**将来重开时这是第一个动作**,判读见 `docs/REAL-CONSOLE-PROFILE.md` 第 1 步。 + +> 一句话:**"我们比 7-Zip 慢 1.59×" 这个议题在真机上不成立** —— 两边都被同一个 I/O +> 上限压着,谁先撞墙取决于存储,而不是解码器。真机目标从"提速解码"改成"查清 28 MiB/s"。 +> ~~目前指向一个具体、可改的位置:**读请求粒度只有复制路径的 1/32–1/64**(推论三), +> 而全部 7z 读都经过 `vol_read()` 一个函数。**先用复制做基线验证它,再决定改不改。**~~ +> → **推论三对 RAR 不适用**(那是 7z 的 256 KiB 数字;UnRAR 按路径自读,见文首更正块 ④)。 +> 更正后:真机工作负载是 RAR,而**我们的 RAR 解码是单线程的**(`RAR_SMP` 未定义 ⇒ +> `unpack50mt.cpp` 不参与编译)—— 这是一项有据可查、且直接针对真实负载的改进点, +> 在真实形状上**已实测 2.4×**。 +> ~~**但优先级已被文首二次更正块 ⑤ 改写**:先做零改动的 `T_copy`,确认墙在解码还是在写路径。~~ +> → **闭环(2026-09-23 18:15):`T_copy` 没做,用户决定不做了** ⇒ 见文首「终局」块。 +> 单线程现状保留,此项转入"已评估、搁置(重开先测 `T_copy`)"。 + +--- + +## 七、复现 ```bash export PATH="/c/mingw64/bin:/c/Users/songl/.workbuddy/binaries/PortableGit/versions/1.2.0/mingw64/bin:/c/Users/songl/.workbuddy/binaries/python/versions/3.13.12:/usr/bin:/bin:/c/Windows/System32:/c/Windows" diff --git a/docs/FORUM-POST-v1.9.2.md b/docs/FORUM-POST-v1.9.2.md index e873195..27882ce 100644 --- a/docs/FORUM-POST-v1.9.2.md +++ b/docs/FORUM-POST-v1.9.2.md @@ -72,6 +72,11 @@ - **中英双语界面** + 项目主页右上角可切换语言说明。 ### 已知缺口(诚实列出) + +> 本节描述的是 **v1.9.2 发布时**的状态。此后有两条已在工作树中补齐、尚未发版: +> 加密 ZIP/RAR 与 7z `-mhe=on` 加密头。详见 README「未发布内容」与 +> `HANDOVER.md` §十一 / §十二。 + - 带密码的 ZIP / RAR / 7z:**拒绝解压**(引擎有解密能力,但密码输入 UI/API 还没接,临时先挡掉)。 - 7z `-mhe=on` **加密头**:暂不支持(需要自研头解析器)。这是 7z 侧唯一已知缺口。 diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 1d7e070..44c239b 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,1714 +1,40 @@ -# PS5 Web File Manager — 项目工作方案 +# PS5 Web File Manager — v1.8 开发方案(已废弃) -> **目的**:让任何接手人拿到这份文档 + 项目仓库,都能独立推进 v1.8(RAR 支持)开发。 -> **读者**:开发者,需要熟悉 C99 + POSIX + 浏览器 JS,但不要求懂 PS5 SDK。 -> **撰写日期**:2026-09-04 -> **对应代码版本**:`aef4a44`(v1.7 + 文档配套提交) - ---- - -## 0. 阅读顺序建议 - -如果你时间紧: -1. 先看 §1「项目是什么」和 §4「当前进度」 → 5 分钟建立全局观 -2. 跳到 §6「v1.8 详细计划」按天执行 -3. 卡住了回 §7「关键代码模板」找参照实现 -4. 出 bug 翻 §9「坑清单」 - -如果你要 review 整套设计: -1. §1 → §3 → §6 → §7 → §8 顺序读完 - ---- - -## 1. 项目是什么 - -**PS5 Web File Manager** 是个跑在越狱 PS5 上的 HTTP 文件管理 payload。 -- 用户从 PS5 浏览器打开 `http://PS5-IP:8080` → 看到 PS5 文件系统 -- 可以浏览 / 上传 / 下载 / 编辑文本 / 解压 ZIP / 安装 PKG -- 单文件 ELF(`web-file-mgr.elf`),无外部服务依赖 - -**关键事实**(会影响你所有设计决策,先背下来): -- **CPU**:AMD x86-64 Zen 2(**不是** ARM),目标 triple `x86_64-sie-ps5` -- **SDK**:vendored 在 `/opt/ps5-payload-sdk/`,未提供 homebrew 库目录(你不能 `pkg-config --libs libxxx`,只能 vendor C 源码自己编) -- **体积红线**:单 ELF 通常不超 1 MiB,当前 v1.7 = 418 KiB -- **网络环境**:通常在 LAN 内,HTTPS 不强制(自签证书) - -**代码托管**:`https://github.com/owendswang/ps5-web-file-manager.git` -**License**:GPLv3+(注意:unrar 的 license 是「UnRAR license」,**不能改装**,只能直接用 —— 见 §9.1) - ---- - -## 2. 仓库与构建环境 - -### 2.1 本地路径 - -``` -Windows: C:\Users\songl\Desktop\Web File Manager\ps5-web-file-manager\ -WSL: \\wsl$\Ubuntu-22.04\home\song\ps5-web-file-manager\ (同一份文件) -PS5: /mnt/usb0/... (部署目标) -``` - -### 2.2 关键工具位置 - -| 工具 | 位置 | 用途 | -|---|---|---| -| WSL bash | `wsl -d Ubuntu-22.04` | 唯一能跑 PS5 交叉编译的地方 | -| 编辑器 | 本机任意 | VS Code 推荐,记得保存用 LF(`.gitattributes` 不强制) | -| prospero-clang | `/opt/ps5-payload-sdk/bin/prospero-clang` | PS5 交叉编译 wrapper,自动注入 `-target x86_64-sie-ps5` | -| prospero-strip | `/opt/ps5-payload-sdk/bin/prospero-strip` | strip 工具 | -| 构建脚本 | `/home/song/build-elf.sh` (WSL) + `.build/build-elf.sh` (副本) | 完整构建 + 落双路径 | - -### 2.3 双身份坑(必读) - -> ⚠️ **WSL 里有两种身份:真 bash 用户 vs SMB 视图用户** -> - 真 bash 用户:`song(1000)`,写 `/opt/ps5-payload-sdk/...` 需要 sudo -> - SMB 视图用户:`songl(197609)`,但**不能写** `/opt/...`(即使 sudo) - -SDK 的 `target/user/homebrew/` 被 `songl(197609)` 拥有 755,普通 song 写不进。 -**正确做法(构建脚本 v5 已实现)**:staging 模式 → 先 make install 到 `/tmp/` → sudo cp -r 到 SDK。 -**不要尝试** `sudo chmod` SDK 目录,会破坏其他项目。 - -### 2.4 我的 Bash 工具是 MinGW64,不是 WSL - -- `uname -a` = `MINGW64_NT-10.0` -- 看 WSL 真实状态用 `\\wsl$\Ubuntu-22.04\` 路径前缀 -- 但**修改 WSL 文件**只能用 PowerShell 走 `C:\Users\songl\Desktop\...` 路径,不能用 `wsl.exe`(沙箱黑名单) - ---- - -## 3. 已完成功能(v1.7 = `5cb0b76` + `aef4a44`) - -### 3.1 ZIP 解压 + 大文件模式 - -> 用户场景:解压一个 200GB 系统镜像到 PS5。默认 limits 不够 → 加 🅱 "大文件模式"开关。 - -**完整链路**(每层都改了,记得按层理解): - -``` -┌────────────────────────────────────────────────────────────────┐ -│ Frontend: assets/main.js │ -│ - LARGE_FILE_THRESHOLD_BYTES = 240 GiB (硬编码) │ -│ - shouldPromptLargeMode(itemSize) → 弹 confirm │ -│ - startExtractTask(path, dst, conflict, remove, name, large) │ -└────────────────────┬───────────────────────────────────────────┘ - │ POST /api/extract - │ form fields: {path, dst_dir, conflict, - │ remove_source, large} -┌────────────────────▼───────────────────────────────────────────┐ -│ Task layer: src/extract.c │ -│ - api_extract 解析 large 字段 → task->extract_large │ -│ - extract_worker 用 zipx_limits_profile(task->extract_large) │ -│ - file_task_t 新增字段 extract_large (int) │ -└────────────────────┬───────────────────────────────────────────┘ - │ -┌────────────────────▼───────────────────────────────────────────┐ -│ Engine: src/zip_extract.{h,c} │ -│ - 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() 签名不变,向后兼容 │ -└────────────────────────────────────────────────────────────────┘ -``` - -**三阶段流程**(所有 archive 引擎共享的设计): -1. **scan** —— 读所有 entry header,只校验 name 安全 + 累加 bytes 估算 -2. **extract** —— 写到 staging `.wfm-part-{pid}-{taskid}/`,fsync 每个文件 -3. **publish** —— 整 staging rename 到目标,处理 conflict 策略 -4. **cleanup** —— 任何阶段失败都 unlink staging - -**i18n**: -- `assets/lang-en.js` 和 `lang-zh.js` 新增 `extractLargeAsk` / `extractLargeActive` - -**前端 UX**: -- ZIP 大于 480 GiB 时弹窗「启用大文件模式?」 -- 用户点 OK → 传 `large=1` → 引擎走 large profile -- 用户点取消 → 走 default profile(多半会被拒绝) - -### 3.2 文档与仓库 - -| 文件 | 行数 | 说明 | -|---|---|---| -| `README.md` | 267 | GitHub-grade 项目说明:版本标签、Features 分类、ZIP extraction 段含 limits 表、Verification、Tests、Project layout、FAQ | -| `CHANGELOG.md` | 107 | Keep-a-Changelog 格式,v1.7 段 | -| `docs/UPGRADE-v1.7-zip-large-file-profile.md` | 448 | 维护者技术手册,含架构图、源码引用、覆盖矩阵、Trade-offs、Roadmap | -| `.build/extract-demo.html` | - | 交互式解压演示页(场景 3 表格已包含 large profile 行) | -| `.build/check-elf-gzip.py` | - | ELF 内 gzip 资产串验证脚本(确认前端改动进了 ELF) | - -### 3.3 测试覆盖 - -``` -tests/run-tests.sh → 69 checks, 0 failures - -覆盖范围: -- ZIP 基础:basic / stored / unicode names / zip64 / traversal / backslash -- 安全:traversal / absolute path / drive letter / symlink entry / fifo entry -- 格式错误:encrypted / bad_crc / truncated / not_a_zip -- 资源限制:ratio cap / file cap / total cap -- 边界:duplicate / file_dir_clash / conflict_source -- 大文件:large profile (16 个 check) — 含 default 拒 medium_bomb / large 接 / 降低 large 阈值仍生效 -``` - -**fixture 经验**(写测试时会用到): -- 4 MiB of 'A' 实际 ratio ≈ **1026**(不是想象中的 ≈1000),所以 `bomb.zip` 连 large profile 都拒 -- `bytes(range(256)) * 4096` (1 MiB) → ratio ≈ **238**,正好夹在 (200, 1000] 中间 → **stable fixture** -- 1 MiB "ABCD" * 256K → ratio ≈ 1004(擦边不稳);random 3-bit → ratio ≈ 2(太低) - -### 3.4 Git 状态 - -``` -5cb0b76 Initial import of PS5 Web File Manager v1.7 -aef4a44 Add v1.7 changelog and technical upgrade notes -main branch → origin https://github.com/owendswang/ps5-web-file-manager.git - -⚠️ sandbox 推 github.com 被代理拦截(502 from CONNECT tunnel) - → 用户在自己 PowerShell 跑 `git push -u origin main` - → 详见 MEMORY.md「GitHub 推送的网络环境」 -``` - ---- - -## 4. 当前进度状态(截至 2026-09-05 15:30 — v1.8 收尾) - -| 项 | 状态 | 备注 | -|---|---|---| -| v1.7 ZIP 大文件模式 | ✅ 完成 + 文档 + 已部署 | 69 checks pass | -| v1.7 仓库初始化 | ✅ 本地 5 commit | 待 push(用户侧) | -| v1.7 文档(README/CHANGELOG/UPGRADE) | ✅ 完成 | | -| **v1.8 RAR 支持(单卷 明文)** | ✅ **实施完成** | dmc_unrar 1.7.0 后端 | -| v1.8 文档(CHANGELOG/README/UPGRAGE-v1.8) | ✅ 完成 | 见 §14 | -| v1.8 前端(解压按钮 + 子卷置灰 + i18n) | ✅ 完成 | assets/main.js + lang-{en,zh}.js | -| v1.8 host tests | ✅ 83 checks, 0 failures | 69 ZIP + 14 RAR | -| v1.8 ELF 重编(WSL) | ⏸ 待用户在 WSL 跑 | 网络/SDK 受限无法在沙箱完成 | -| 推送 GitHub | ⏸ 待用户在 PowerShell 跑 | 沙箱 git 502(见 §9) | - -**v1.8 范围变更(vs §5 原计划)**: - -| 原计划 | 实际交付 | 原因 | -|---|---|---| -| ✅ RAR 单卷(明文) | ✅ RAR 单卷(明文) | 实施完毕 | -| ❌ RAR 分卷(RAR5 .partNN.rar 链式) | ❌ → 拒绝 + 弹 tooltip | dmc_unrar 不支持分卷 | -| ❌ 加密 RAR(密码弹窗) | ❌ → 拒绝 + 无 UI | dmc_unrar 不支持加密 | -| ❌ ZIP 分卷 | ❌ | 用户说不需要(未做) | -| ❌ 7z / tar / 其他格式 | ❌ | 范围外 | - -**v1.8 范围比原计划小,但工程更扎实**: -- 加了 facade header 模式(dmc_unrar_api.h),让 vendor 的 .c 永远独立编、不被 host shim 污染 -- 把 §6 的"工程决策"全做了:dispatch 加 magic 不只为扩展名、共用 staging / fsync / publish -- v1.9 升级路径明确(VENDORED.md 5 步 + UPGRADE-v1.8 §10.1) - -**遗留 → v1.9**: -- vendor 切 opello/unrar,加多卷 + 加密支持 -- 把 zip_extract / rar_extract 共用的 staging / publish / nameset / report 等抽到 `src/archive_engine_common.c` -- ELF 真机部署(ZIP 当前用户路径没加密也是 v1.9 优先,因为单卷 RAR 同样不在加密范围) - -详见 §14「v1.8 实际交付状态」。 - -**v1.8 需求范围(用户已确认)**(v1.7 原始 §6 中的待开工项): -- ✅ RAR 单卷(明文) -- ✅ RAR 分卷(仅 RAR5 `name.partNN.rar` 新格式) -- ✅ 加密 RAR(密码弹窗) -- ❌ ZIP 分卷(用户说"不需要") -- ❌ 7z / tar / 其他格式 - ---- - -## 5. v1.8 范围与目标 - -### 5.1 功能列表 - -``` -- /api/extract 支持 RAR 格式 -- 自动派发:扩展名 + magic (Rar!\x1a\x07\x00 / Rar!\x1a\x07\x01\x00) -- RAR 单卷解压 -- RAR 分卷自动识别(unrar 内部处理,前端只识别主卷) -- 加密 RAR:探测 → 弹密码窗 → 错误重试 -- 前端:选中 RAR 主卷显示解压按钮,子卷置灰 + tooltip -``` - -### 5.2 不做(明确划线) - -| 不做 | 原因 | -|---|---| -| ZIP 分卷 | 用户明确不要 | -| 7z / tar / 其他格式 | 范围外 | -| RAR 创建(压缩) | 当前只解压 | -| 密码保存/记忆 | 安全 + UX 一致性 | -| ZIP 加密(ZIP AES) | 单独迭代 | -| 区分「密码错」vs「文件损坏」 | 防侧信道,统一文案 | - -### 5.3 工作量预估 - -| 模块 | 行数 | -|---|---| -| `third_party/unrar/` vendor | ~7K | -| `src/rar_extract.{h,c}` | ~450 | -| `src/extract.c` 改造 | ~150 | -| `src/filemgr_internal.h` | ~10 | -| `assets/main.js` | ~120 | -| `assets/main.css` | ~40 | -| `assets/lang-{en,zh}.js` | ~30 | -| `tests/test_rar_extract.c` | ~400 | -| `tests/fixtures/` | ~12 个 | -| 文档 + CHANGELOG | ~200 | -| **总计** | **~1380 新增** | - -**ELF 预估**:v1.7 = 418 KiB → v1.8 ≈ **580 KiB** -**工期**:5~6 天集中开发 - ---- - -## 6. v1.8 详细实施计划 - -> 按天执行。每步都有「验证」一项,写完立即跑。 - -### D1:vendor unrar + 写 rar_extract 骨架 - -#### 6.1.1 拉 unrar 源码 - -```bash -cd /home/song/ps5-web-file-manager/third_party/ -git clone https://github.com/alexbatalov/unrar.git -# 检查 -ls unrar/ -# 期望:unrar.c + unrar.h + README + LICENSE(UnRAR license) -``` - -**注意事项**: -- alexbatalov 版是纯 C、单文件,**不要用 winrar/unrar 官方版**(license 严格,商用改装禁) -- 不要改装 unrar.c(license 限制),需要时通过 wrapper 函数扩展 -- 如需 ASAN / fuzzer 友好的版本,可考虑分叉,但要保留 license 文件 - -#### 6.1.2 验证 unrar 在 host 上能编 - -```bash -cd /home/song/ps5-web-file-manager/third_party/unrar/ -cc -O2 -o test-unrar unrar.c -DUNRAR_TEST_MAIN # 临时 main 跑一遍 -# 或者写个测试:建 test.rar → 调用 unrar API 解出来 -``` - -确认 API 形态: -- `RARHeaderDataEx` / `RAROpenArchiveEx` / `RARReadHeaderEx` / `RARProcessFile` / `RARSetPassword` / `RARCloseArchive` - -#### 6.1.3 写 rar_extract.h 公共 API - -**模板**:直接抄 `src/zip_extract.h` 的命名空间 `zipx_` 改为 `rarx_`,但复用 `zipx_status_t` 枚举。 - -```c -/* src/rar_extract.h */ -#pragma once -#include "zip_extract.h" /* 复用 zipx_limits_t / zipx_status_t */ - -typedef struct { - char password[128]; /* UTF-8 / ASCII 密码 */ - int password_set; /* 0 = 不设, 1 = 调用方已设置 */ -} rarx_password_t; - -typedef struct { - zipx_status_t status; - int sys_errno; - uint64_t entries_total; - uint64_t entries_done; - uint64_t bytes_total; - uint64_t bytes_done; - uint64_t files_created; - uint64_t dirs_created; - int has_password; /* scan 阶段探测到加密 → ERR_PASSWORD */ - char detail[ZIPX_PATH_MAX]; - char message[192]; -} rarx_result_t; - -/* password 可为 NULL(明文 RAR);结果总是写入 *result */ -zipx_status_t rarx_extract(const char *first_volume_path, - const char *dst_dir, - zipx_conflict_t conflict, - const zipx_limits_t *limits, - const rarx_password_t *password, /* 可 NULL */ - zipx_cancel_fn cancel, - zipx_progress_fn progress, - void *userdata, - rarx_result_t *result); -``` - -#### 6.1.4 rar_extract.c 三阶段骨架 - -**关键**:scan 与 extract 都要扫描所有 entry(unrar 的 API 不能 list-only),所以 scan 阶段**会读到文件内容但丢弃**,产生约 N×entry 大小的 I/O 成本。 - -> 💡 **性能优化**:unrar 提供 `RARExtractChunk` API,可以一次解 N 字节立刻写出,避免 staging 暂存。**第一版不用**,可读性 + 测试性更重要。 - -#### 6.1.5 关键代码模式(scan 阶段) - -```c -static zipx_status_t -rarx_scan(const char *first_path, const zipx_limits_t *limits, - rarx_result_t *result) { - struct RARHeaderDataEx hdr; - struct RAROpenArchiveDataEx arc; - memset(&arc, 0, sizeof(arc)); - arc.ArcName = (char*)first_path; - arc.OpenMode = RAR_OM_LIST; /* list-only 模式不解压 */ - HANDLE h = RAROpenArchiveEx(&arc); - if (arc.OpenResult != 0) { - result->status = ZIPX_ERR_FORMAT; - return ZIPX_ERR_FORMAT; - } - - uint64_t total_bytes = 0; - uint32_t total_entries = 0; - int max_depth = 0; - result->has_password = 0; - - while (RARReadHeaderEx(h, &hdr) == 0) { - /* 加密探测 */ - if (hdr.Flags & (LHD_PASSWORD /* RAR4 */) || - (hdr.Flags & 0x0004 /* RAR5 encrypted */)) { - result->has_password = 1; - } - - /* name 安全校验(用 zip_extract.c 里的同名函数) */ - if (!is_safe_archive_path(hdr.FileName)) { - RARCloseArchive(h); - result->status = ZIPX_ERR_UNSAFE_NAME; - snprintf(result->detail, sizeof(result->detail), "%s", hdr.FileName); - return ZIPX_ERR_UNSAFE_NAME; - } - - /* depth 校验 */ - uint32_t d = path_depth(hdr.FileName); - if (d > max_depth) max_depth = d; - if (max_depth > limits->max_depth) { - RARCloseArchive(h); - result->status = ZIPX_ERR_LIMIT_DEPTH; - return ZIPX_ERR_LIMIT_DEPTH; - } - - /* name/path 长度 */ - if (strlen(hdr.FileName) > limits->max_name_len || - strlen(hdr.FileName) > limits->max_path_len) { - RARCloseArchive(h); - result->status = ZIPX_ERR_LIMIT_NAME; - return ZIPX_ERR_LIMIT_NAME; - } - - /* size 累计 */ - total_bytes += (uint64_t)hdr.UnpSize; - if (hdr.UnpSize > limits->max_file_bytes) { - RARCloseArchive(h); - result->status = ZIPX_ERR_LIMIT_FILE; - snprintf(result->detail, sizeof(result->detail), - "%s (%llu bytes)", hdr.FileName, - (unsigned long long)hdr.UnpSize); - return ZIPX_ERR_LIMIT_FILE; - } - if (total_bytes > limits->max_total_bytes) { - RARCloseArchive(h); - result->status = ZIPX_ERR_LIMIT_TOTAL; - return ZIPX_ERR_LIMIT_TOTAL; - } - - total_entries++; - if (total_entries > limits->max_entries) { - RARCloseArchive(h); - result->status = ZIPX_ERR_LIMIT_ENTRIES; - return ZIPX_ERR_LIMIT_ENTRIES; - } - - /* 跳过 entry body(list-only 不需要调用 ProcessFile) */ - } - RARCloseArchive(h); - - result->entries_total = total_entries; - result->bytes_total = total_bytes; - return ZIPX_OK; -} -``` - -#### 6.1.6 D1 验证清单 - -```bash -# 1. 编译通过(host) -gcc -O2 -c third_party/unrar/unrar.c -o /tmp/unrar.o -gcc -O2 -c src/rar_extract.c -o /tmp/rar_extract.o -I third_party/unrar/ -echo "exit: $?" - -# 2. 链接能产出 host binary(基础 sanity) -gcc -O2 -o /tmp/test-rar-extract /tmp/rar_extract.o /tmp/unrar.o -lz -echo "exit: $?" - -# 3. 跑现有测试(确认没破坏 zip 流程) -cd /home/song/ps5-web-file-manager/ -bash tests/run-tests.sh -# 期望:69 checks, 0 failures -``` - ---- - -### D2:rar_extract 联调 + magic 探测 + limits 校验 - -#### 6.2.1 extract 阶段实现 - -```c -static zipx_status_t -rarx_extract_pass(struct RAROpenArchiveDataEx *arc, - const zipx_limits_t *limits, - const rarx_password_t *password, - rarx_progress_fn progress, void *userdata, - rarx_result_t *result) { - if (password && password->password_set) { - RARSetPassword(arc->h, password->password); - } - - struct RARHeaderDataEx hdr; - while (RARReadHeaderEx(arc->h, &hdr) == 0) { - /* ratio 实时校验 */ - if (hdr.PackSize > 0 && limits->max_ratio > 0) { - uint64_t ratio = hdr.UnpSize / hdr.PackSize; - if (ratio > limits->max_ratio) { - result->status = ZIPX_ERR_LIMIT_RATIO; - snprintf(result->detail, sizeof(result->detail), - "%s ratio %llu", hdr.FileName, - (unsigned long long)ratio); - return ZIPX_ERR_LIMIT_RATIO; - } - } - - /* progress 回调 */ - if (progress) { - zipx_progress_t p = { - .phase = ZIPX_PHASE_EXTRACT, - .entries_total = result->entries_total, - .entries_done = result->entries_done, - .bytes_total = result->bytes_total, - .bytes_done = result->bytes_done, - .current = hdr.FileName, - }; - progress(userdata, &p); - } - - /* 解到 staging 目录 */ - char out_path[ZIPX_PATH_MAX]; - snprintf(out_path, sizeof(out_path), "%s/%s", - result->detail /* staging dir */, hdr.FileName); - - int mode = (hdr.Flags & 0xE0) == 0xE0 /* RAR5 dir flag */ ? - RAR_EXTRACT_DEST : RAR_EXTRACT; - /* 路径安全再校验一次 */ - int rc = RARProcessFile(arc->h, mode, NULL, out_path); - if (rc != 0) { - result->status = (rc == 11) ? ZIPX_ERR_PASSWORD : ZIPX_ERR_IO; - result->sys_errno = errno; - return result->status; - } - - result->entries_done++; - result->bytes_done += hdr.UnpSize; - - /* crc 校验 —— unrar 已经做了,会自动设错误 */ - } - return ZIPX_OK; -} -``` - -#### 6.2.2 magic 探测(不依赖扩展名) - -```c -static int -rarx_probe_magic(const char *path) { - FILE *fp = fopen(path, "rb"); - if (!fp) return 0; - unsigned char buf[8]; - size_t n = fread(buf, 1, sizeof(buf), fp); - fclose(fp); - if (n < 7) return 0; - /* RAR 4: "Rar!\x1a\x07\x00" */ - if (!memcmp(buf, "Rar!\x1a\x07\x00", 7)) return 1; - /* RAR 5: "Rar!\x1a\x07\x01\x00" */ - if (!memcmp(buf, "Rar!\x1a\x07\x01\x00", 8)) return 1; - return 0; -} -``` - -**为什么需要 magic 探测**: -- 用户可能重命名 `.bin` → 实际是 RAR -- 防止 `extract.c` 派发时仅靠扩展名判断漏掉情况 - -#### 6.2.3 D2 验证清单 - -```bash -# 1. 写个最小的 manual fixture: -# 用系统 rar 命令(如果装了)建 RAR5 单卷 test.rar + 内容 -# 或者从网上找开源的 RAR 测试样本 -# 2. host 跑: -./.build/host-test/test-rar-extract tests/fixtures/rar5_single.rar /tmp/out -# 3. 确认 /tmp/out 里有解出来的文件 - -# 4. ratio bomb 测试:建一个 4MB of 'A' 的 RAR5,验证 ERR_LIMIT_RATIO -# 5. path traversal:建一个含 ../../etc/passwd 的 RAR,验证 ERR_UNSAFE_NAME -``` - ---- - -### D3:extract.c 派发 + password 字段 + 错误码映射 - -#### 6.3.1 src/filemgr_internal.h 新增字段 - -```c -typedef struct { - ... - int extract_conflict; - int extract_remove_source; - int extract_large; - int extract_format; /* 新增: 0=zip, 1=rar */ - char extract_password[128]; /* 新增 */ -} file_task_t; -``` - -#### 6.3.2 src/extract.c 派发逻辑 - -**关键**:扩展名判断 + magic fallback 都做,避免前端漏检。 - -```c -/* 新增 detect 函数 */ -static int -detect_archive_format(const char *path, FILE *probe) { - /* 1. 扩展名优先(快) */ - if (str_ends_with_ci(path, ".rar") || - str_ends_with_ci(path, ".part01.rar") || - str_ends_with_ci(path, ".part1.rar") || - str_ends_with_ci(path, ".part001.rar")) { - return 1; /* RAR */ - } - if (str_ends_with_ci(path, ".zip") || - str_ends_with_ci(path, ".zipx")) { - return 0; /* ZIP */ - } - /* 2. magic fallback */ - if (probe && rarx_probe_magic(path)) return 1; - if (probe && zipx_probe_magic(path)) return 0; - return -1; /* unknown */ -} -``` - -#### 6.3.3 api_extract 解析 password 字段 - -```c -char *password_str = body_form_value(body, body_size, "password"); -char password[128] = {0}; -int password_set = 0; -if (password_str) { - strncpy(password, password_str, sizeof(password) - 1); - /* 截断 sanitize:去掉 control chars */ - sanitize_password(password); - password_set = 1; -} -free(password_str); - -/* ... */ - -snprintf(task->extract_password, sizeof(task->extract_password), - "%s", password); -task->extract_format = detect_archive_format(task->src, NULL); -``` - -> ⚠️ **安全**:绝不在日志、progress 回调、task_update 里写 `password` 字段。grep 整个代码库 `password` 字符串只允许出现在 form 解析处。 - -#### 6.3.4 extract_worker 派发 - -```c -static void * -extract_worker(void *arg) { - file_task_t *task = arg; - zipx_conflict_t conflict = task->extract_conflict; - rarx_password_t rpwd = {0}; - rpwd.password_set = task->extract_password[0] != 0; - strncpy(rpwd.password, task->extract_password, - sizeof(rpwd.password) - 1); - - /* 立即清零 task 里的密码(防御内存 dump) */ - memset(task->extract_password, 0, - sizeof(task->extract_password)); - - zipx_status_t status; - if (task->extract_format == 1 /* RAR */) { - rarx_result_t rres = {0}; - status = rarx_extract(task->src, task->dst, conflict, - zipx_limits_profile(task->extract_large), - &rpwd, - extract_cancel, extract_progress, task, &rres); - /* 映射 rarx_result_t → task 状态字段 */ - } else { - zipx_result_t zres = {0}; - status = zipx_extract(task->src, task->dst, conflict, - zipx_limits_profile(task->extract_large), - extract_cancel, extract_progress, task, &zres); - } - - /* status → task state 的映射逻辑保持不变 */ - ... -} -``` - -#### 6.3.5 D3 验证清单 - -```bash -# host 端跑全量回归 -bash tests/run-tests.sh -# 期望:69 checks, 0 failures(zip 流程没坏) - -# 手动构造 form 测试 password 解析: -curl -X POST http://127.0.0.1:8080/api/extract \ - -d "path=/data/test.rar&dst_dir=/data/out&password=secret123" -# 期望任务接受,无 500 -``` - ---- - -### D4:前端 isExtractableArchive + 密码 modal + i18n + 子卷置灰 - -#### 6.4.1 main.js 检测函数 - -```js -function isExtractableArchive(item) { - if (item.type !== "-") return false; - // ZIP(已有) - if (/\.zipx?$/i.test(item.name)) return true; - // RAR 单卷 .rar - if (/\.rar$/i.test(item.name)) return true; - // RAR5 分卷主卷 .part01.rar / .part1.rar / .part001.rar - if (/\.part0*1\.rar$/i.test(item.name)) return true; - return false; -} - -function isRarSubVolume(item) { - if (item.type !== "-") return false; - // RAR5 子卷 .part02.rar, .part2.rar ...(part01 才是主卷) - if (/\.part0*\d+\.rar$/i.test(item.name) && - !/\.part0*1\.rar$/i.test(item.name)) return true; - return false; -} -``` - -#### 6.4.2 renderExtractButton 升级 - -```js -function renderExtractButton(items, locked) { - const extractable = items.filter(isExtractableArchive); - const subs = items.filter(isRarSubVolume); - // 子卷单独选中 → 按钮置灰 + 提示 - if (subs.length > 0 && extractable.length === 0) { - extractBtn.hidden = false; - extractBtn.disabled = true; - extractBtn.title = t("extractSelectMainVolume"); - return; - } - // 主卷逻辑(与之前类似) - extractBtn.hidden = extractable.length !== 1; - if (extractable.length !== 1) { - extractBtn.title = ""; - extractBtn.disabled = true; - return; - } - extractBtn.title = t("extractToCurrent") + ": " + itemTitle(extractable); - extractBtn.disabled = locked; -} -``` - -#### 6.4.3 密码 modal(HTML) - -在 `assets/index.html` 适当位置插入: - -```html - -``` - -#### 6.4.4 密码 modal(CSS) - -在 `assets/main.css` 末尾加: - -```css -.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); - display: flex; align-items: center; justify-content: center; - z-index: 1000; } -.modal.hidden { display: none; } -.modal-card { background: var(--bg-elevated); padding: 24px; - border-radius: 8px; min-width: 320px; max-width: 480px; } -.modal-actions { display: flex; gap: 12px; justify-content: flex-end; - margin-top: 16px; } -.muted { color: var(--text-faint); font-size: 13px; } -.text-input { width: 100%; padding: 8px 12px; margin: 12px 0; - border: 1px solid var(--border); border-radius: 4px; - background: var(--bg-input); color: var(--text); } -``` - -#### 6.4.5 startExtractTask 接收 password - -```js -async function startExtractTask(path, dstDir, conflict, removeSource, - name, large, password) { - const data = await apiForm("/api/extract", { - path, dst_dir: dstDir, conflict, - remove_source: removeSource ? "1" : "0", - large: large ? "1" : "0", - password: password || "" - }); - ... -} -``` - -#### 6.4.6 actionExtract 加密码弹窗 - -```js -function promptPassword(fileName, retry) { - return new Promise((resolve, reject) => { - const modal = document.getElementById("passwordModal"); - const input = document.getElementById("passwordInput"); - const ok = document.getElementById("passwordOk"); - const cancel = document.getElementById("passwordCancel"); - const titleEl = document.getElementById("passwordTitle"); - const promptEl = document.getElementById("passwordPrompt"); - - titleEl.textContent = retry ? - t("passwordWrongTitle") : t("passwordRequiredTitle"); - promptEl.textContent = retry ? - t("passwordWrongPrompt") : t("passwordRequiredPrompt", {name: fileName}); - input.value = ""; - modal.classList.remove("hidden"); - input.focus(); - - const cleanup = () => { - modal.classList.add("hidden"); - ok.removeEventListener("click", onOk); - cancel.removeEventListener("click", onCancel); - }; - const onOk = () => { const v = input.value; cleanup(); resolve(v); }; - const onCancel = () => { cleanup(); reject(new Error("canceled")); }; - - ok.addEventListener("click", onOk); - cancel.addEventListener("click", onCancel); - input.addEventListener("keydown", e => { - if (e.key === "Enter") onOk(); - if (e.key === "Escape") onCancel(); - }); - }); -} - -async function actionExtract() { - const item = singleSelected(); - if (!item) return; - const conflict = ...; - const large = shouldPromptLargeMode(item.size) ? promptLargeMode(item.size) : false; - - let password = ""; - if (isRarExtension(item.name)) { /* 仅 RAR 弹 */ - try { - password = await promptPassword(item.name, false); - } catch { return; } - } - startExtractTask(item.path, cwd, conflict, false, - displayName(item), large, password); -} -``` - -#### 6.4.7 后端 ERR_PASSWORD 自动弹窗 - -```js -/* 任务状态轮询或 SSE 里 */ -if (task.error === "extract_password" || - (task.message || "").toLowerCase().includes("password")) { - // 不太优雅但稳:弹窗重试 - // (更好的做法是在 zipx_status_string 里直接提供 code, - // 前端 if (status === ZIPX_ERR_PASSWORD) → 弹) -} -``` - -> 💡 **更稳的方案**:在 `zipx_status_string(ZIPX_ERR_PASSWORD)` 返回字符串 `"extract_password"`,前端 `task.op === "extract" && task.error === "extract_password"` 时弹窗,让用户重新提交。这避免字符串包含匹配带来的误报。 - -#### 6.4.8 i18n 新增文案 - -`assets/lang-zh.js`: -```js -extractSelectMainVolume: "请改选主卷(如 .part01.rar 或 .rar)", -passwordRequiredTitle: "需要解压密码", -passwordRequiredPrompt: "RAR 文件 {name} 已加密,请输入解压密码。", -passwordWrongTitle: "密码错误", -passwordWrongPrompt: "密码错误,请重新输入。密码仅本次使用,不会保存。", -``` - -`assets/lang-en.js`: -```js -extractSelectMainVolume: "Select the main volume (e.g. .part01.rar or .rar)", -passwordRequiredTitle: "Password required", -passwordRequiredPrompt: "The RAR archive {name} is encrypted. Please enter the password.", -passwordWrongTitle: "Wrong password", -passwordWrongPrompt: "Wrong password. Please try again. The password is only used for this extraction and is never saved.", -``` - -#### 6.4.9 D4 验证清单 - -```bash -# 1. node 语法检查 -node --check assets/main.js -node --check assets/lang-en.js -node --check assets/lang-zh.js - -# 2. 浏览器手动测试(开 dev server 或本地 http-server): -# - 选 .rar → 弹密码框 -# - 选 .zip → 不弹密码框(保持 v1.7 行为) -# - 选 .part02.rar → 解压按钮置灰 + tooltip -# - 选 .part01.rar → 解压按钮可用 - -# 3. 提交后端确认 password 字段透传: -# Network → /api/extract → Form Data → password: "xxx" -``` - ---- - -### D5:host tests/test_rar_extract.c + fixtures - -#### 6.5.1 fixture 准备 - -需要 RAR 测试样本。**优先用 WinRAR / rar 命令行工具生成**: - -```bash -# 装 unrar / rar(host) -sudo apt install rar unrar # 或 mac: brew install rar - -# 生成 fixtures(用脚本自动化) -cd tests/fixtures/ - -# RAR4 单卷明文 -rar a -os rar4_single.rar sample.txt - -# RAR5 单卷明文 -rar a -ma rar5_single.rar sample.txt - -# RAR5 分卷(5 卷 × 1MB) -mkdir -p split_src && head -c 5M /dev/urandom > split_src/big.bin -rar a -v1m -ma rar5_multi.part01.rar split_src/ - -# 加密 RAR5(密码 "secret") -rar a -ma -hpsecret encrypted_rar5.rar sample.txt - -# 加密 RAR4(密码 "secret") -rar a -os -hpsecret encrypted_rar4.rar sample.txt - -# 加密分卷 -rar a -v1m -ma -hpsecret encrypted_multi.part01.rar split_src/ - -# 损坏 -head -c 1024 rar5_single.rar > rar5_truncated.rar -``` - -> ⚠️ **fixture 不能 commit**(见 .gitignore),需要在 `tests/make_fixtures.py` 里写生成函数,运行 `bash tests/run-tests.sh` 时自动调用。 - -#### 6.5.2 tests/make_fixtures.py 新增 - -```python -def rar4_single(): - """RAR4 single volume, plaintext. Generated by host `rar` CLI.""" - import subprocess - src = path("_rar_src.txt") - if not os.path.exists(src): - with open(src, "w") as f: - f.write("hello rar4\n" * 100) - out = path("rar4_single.rar") - if not os.path.exists(out): - subprocess.check_call(["rar", "a", "-os", out, src]) - return out - -def rar5_single(): - src = path("_rar_src.txt") - out = path("rar5_single.rar") - if not os.path.exists(out): - subprocess.check_call(["rar", "a", "-ma", out, src]) - return out - -# ... 其余同理 -``` - -> 💡 **CI 兼容性**:CI 环境可能没装 rar。改用 `unrar` 命令 + 预生成 fixture 一并存档。**最稳**:把生成好的 fixture 提交到 git(small ones only,<1MB each)。 - -#### 6.5.3 tests/test_rar_extract.c 测试用例模板 - -```c -/* tests/test_rar_extract.c */ - -#include "rar_extract.h" - -static rarx_result_t -run_rar(const char *src, const char *dst, - zipx_conflict_t conflict, - const zipx_limits_t *limits, - const rarx_password_t *pwd, - rarx_progress_fn progress, void *userdata) { - rarx_result_t res = {0}; - rarx_extract(src, dst, conflict, limits, pwd, - NULL, progress, userdata, &res); - return res; -} - -static void -test_rar4_single(void) { - rarx_result_t r = run_rar("rar4_single.rar", "out_rar4_single", - ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, NULL); - check(r.status == ZIPX_OK, "RAR4 single extracts OK"); - check(r.entries_total == 1, "1 entry"); - check(r.files_created == 1, "1 file created"); -} - -static void -test_rar5_single(void) { - rarx_result_t r = run_rar("rar5_single.rar", "out_rar5_single", - ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, NULL); - check(r.status == ZIPX_OK, "RAR5 single extracts OK"); -} - -static void -test_rar5_multi_volume(void) { - /* unrar 自动找 .part02, .part03... */ - rarx_result_t r = run_rar("rar5_multi.part01.rar", - "out_rar5_multi", - ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, NULL); - check(r.status == ZIPX_OK, "RAR5 multi-volume extracts OK"); - check(r.entries_total >= 1, "at least one entry"); -} - -static void -test_rar4_encrypted_correct_pwd(void) { - rarx_password_t pwd = {.password = "secret", .password_set = 1}; - rarx_result_t r = run_rar("encrypted_rar4.rar", - "out_rar4_enc_ok", - ZIPX_CONFLICT_FAIL, NULL, &pwd, NULL, NULL); - check(r.status == ZIPX_OK, "encrypted RAR4 with correct pwd OK"); -} - -static void -test_rar4_encrypted_wrong_pwd(void) { - rarx_password_t pwd = {.password = "wrong", .password_set = 1}; - rarx_result_t r = run_rar("encrypted_rar4.rar", - "out_rar4_enc_wrong", - ZIPX_CONFLICT_FAIL, NULL, &pwd, NULL, NULL); - check(r.status == ZIPX_ERR_PASSWORD, "wrong pwd → ERR_PASSWORD"); - check(r.files_created == 0, "no file created on wrong pwd"); -} - -static void -test_rar4_encrypted_no_pwd(void) { - rarx_result_t r = run_rar("encrypted_rar4.rar", - "out_rar4_enc_nopwd", - ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, NULL); - check(r.status == ZIPX_ERR_PASSWORD, "no pwd → ERR_PASSWORD"); - check(r.has_password == 1, "scan detects password requirement"); - check(r.files_created == 0, "no file created without pwd"); -} - -/* 全部测试 + main 入口同 test_zip_extract.c */ -``` - -#### 6.5.4 run-tests.sh 新增编译 RAR 支持 - -```bash -# tests/run-tests.sh 新增: -"$CC" -O2 -c "$ROOT/third_party/unrar/unrar.c" -o "$BUILD/unrar.o" || exit 1 -"$CC" -O2 -c "$ROOT/src/rar_extract.c" -o "$BUILD/rar_extract.o" \ - -I "$ROOT/src" -I "$ROOT/third_party/unrar/" || exit 1 -"$CC" -O2 -c "$ROOT/tests/test_rar_extract.c" -o "$BUILD/test_rar_extract.o" \ - -I "$ROOT/src" -I "$ROOT/third_party/unrar/" || exit 1 - -# 链接 -"$CC" -O2 -o "$BUILD/test-rar-extract" \ - "$BUILD/rar_extract.o" "$BUILD/test_zip_extract.o" \ - "$BUILD/unrar.o" "$BUILD/test_rar_extract.o" "${objs[@]}" \ - || exit 1 - -"$BUILD/test-rar-extract" "$ROOT/tests/fixtures" "$BUILD/work" -``` - -#### 6.5.5 D5 验证清单 - -```bash -bash tests/run-tests.sh -# 期望:69 + ~12 = 81 checks, 0 failures - -# 单独跑 RAR 测试: -./.build/host-test/test-rar-extract tests/fixtures/ /tmp/rar_work -# 期望:所有 RAR 测试通过 -``` - ---- - -### D6:WSL 跨编 + ELF 验证 + 文档 - -#### 6.6.1 WSL Makefile 更新 - -`Makefile` 加: -```makefile -# third_party/unrar/ -UNRAR_OBJS = $(addprefix $(OBJ_DIR)/third_party/unrar/, \ - $(notdir $(wildcard third_party/unrar/*.c))) -$(UNRAR_OBJS): | $(OBJ_DIR)/third_party/unrar -$(OBJ_DIR)/third_party/unrar/%.c.o: third_party/unrar/%.c - $(CC) $(CFLAGS) -c $< -o $@ - -# src/rar_extract.o -OBJ_FILES += src/rar_extract.c - -# .elf 链接加 unrar.o + rar_extract.o -$(ELF): ... $(UNRAR_OBJS) src/rar_extract.o ... -``` - -#### 6.6.2 跨编 - -```bash -# WSL Ubuntu-22.04 内 -cd /home/song/ps5-web-file-manager -bash build-elf.sh -# 期望:[7/7] OK -# 期望 size: ~580 KiB -# 期望 sha256: 不在 list 内(首次编) -``` - -#### 6.6.3 ELF 验证脚本 - -```bash -# Windows side 验证 -cd "C:\Users\songl\Desktop\Web File Manager\ps5-web-file-manager" -sha256sum web-file-mgr.elf -size web-file-mgr.elf -head -c 20 web-file-mgr.elf | xxd | head -2 - -# gzip 流验证(前端改动进了 ELF) -python3 .build/check-elf-gzip.py ./web-file-mgr.elf | grep -E "(✓|✗)" -# 期望: -# ✓ passwordRequiredTitle -# ✓ passwordRequiredPrompt -# ✓ passwordWrongTitle -# ✓ passwordWrongPrompt -# ✓ extractSelectMainVolume -# ✓ startExtractTask ... password: password || "" -# ✓ rarx_extract -# ✓ ZIPX_ERR_PASSWORD -``` - -#### 6.6.4 文档更新 - -**新建**:`docs/UPGRADE-v1.8-rar-support.md`(镜像 v1.7 UPGRADE 风格,~450 行) - -**更新**:`CHANGELOG.md` 头部加 v1.8 段: -```markdown -## [v1.8] - 2026-09-XX - -### Added -- **RAR archive extraction** via vendored alexbatalov/unrar.c -- RAR4 and RAR5 single-volume and multi-volume (`name.partNN.rar`) -- Encrypted RAR support with password prompt + retry dialog -- Frontend detection of RAR main/sub volumes + grayscale sub-volume button - -### Technical -- `src/rar_extract.{h,c}` mirrors `zip_extract` API (1600 LOC, three-phase pipeline) -- `extract.c` dispatches by extension + magic-byte fallback -- ZIP path unchanged (v1.7 large-file profile still works) -``` - -**更新**:`README.md` ZIP extraction 段后面加: -```markdown -### RAR extraction - -The project also extracts RAR4 and RAR5 archives, including -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 / 1 TiB / 256 GiB / -ratio 500 by default, with the same `large=1` opt-in to 500K / -2 TiB / 1 TiB / 1000). -``` - -#### 6.6.5 Git 提交 + push - -```bash -cd /home/song/ps5-web-file-manager -git add third_party/unrar/ src/rar_extract.* src/extract.c src/filemgr_internal.h \ - assets/main.js assets/main.css assets/lang-en.js assets/lang-zh.js \ - tests/test_rar_extract.c tests/make_fixtures.py tests/run-tests.sh \ - Makefile docs/UPGRADE-v1.8-rar-support.md CHANGELOG.md README.md -git -c core.autocrlf=false commit -m "v1.8: RAR4/RAR5 + multi-volume + encrypted support - -- vendor alexbatalov/unrar.c into third_party/unrar/ (UnRAR license) -- src/rar_extract.{h,c}: three-phase engine (scan -> extract -> publish -> cleanup) - with first-volume-only API (unrar internally finds companion volumes) -- extract.c: format dispatch by extension + Rar! magic fallback -- API: POST /api/extract gains optional 'password' form field -- frontend: isExtractableArchive() covers RAR single/multi; isRarSubVolume() - disables the extract button with a tooltip -- password modal: required on encrypted RAR, wrong-password retry -- tests: +12 RAR checks (single/multi/encrypted-no-pwd/encrypted-wrong-pwd/ - encrypted-correct-pwd/ratio-bomb/path-traversal/truncated) -- ELF: 418 KiB -> ~580 KiB; gzip-assets verified for new strings -- docs: CHANGELOG + UPGRADE-v1.8-rar-support.md + README" - -# 用户在自己 PowerShell(非沙箱)里: -# git push -u origin main -# git tag -a v1.8 -m "..." -# git push origin v1.8 -``` - ---- - -## 7. 关键代码模式(参考 zip_extract 实现) - -### 7.1 三阶段架构(rar_extract 必须镜像) - -```c -zipx_status_t -rarx_extract(const char *first_path, const char *dst_dir, - zipx_conflict_t conflict, - const zipx_limits_t *limits, - const rarx_password_t *password, - zipx_cancel_fn cancel, zipx_progress_fn progress, - void *userdata, rarx_result_t *result) { - /* 1) scan 阶段 */ - zipx_status_t s = rarx_scan(first_path, limits, result); - if (s != ZIPX_OK) return s; - if (result->has_password && (!password || !password->password_set)) { - result->status = ZIPX_ERR_PASSWORD; - return ZIPX_ERR_PASSWORD; - } - - /* 2) 创建 staging 目录 */ - char staging[ZIPX_PATH_MAX]; - snprintf(staging, sizeof(staging), "%s/.wfm-part-%d", - dst_dir, (int)getpid()); - if (mkdir(staging, 0755) < 0) { - result->status = ZIPX_ERR_IO; - return ZIPX_ERR_IO; - } - snprintf(result->detail, sizeof(result->detail), "%s", staging); - - /* 3) extract 阶段(解到 staging) */ - s = rarx_extract_pass(first_path, staging, limits, password, - cancel, progress, result); - if (s != ZIPX_OK) { - /* cleanup: unlink staging 整树 */ - nftw(staging, unlink_cb, 64, FTW_DEPTH | FTW_PHYS); - rmdir(staging); - return s; - } - - /* 4) publish 阶段:rename staging → dst_dir */ - s = rarx_publish(staging, dst_dir, conflict, result); - if (s != ZIPX_OK) { - nftw(staging, unlink_cb, 64, FTW_DEPTH | FTW_PHYS); - rmdir(staging); - return s; - } - - return ZIPX_OK; -} -``` - -### 7.2 path 安全校验(直接复用 zip_extract.c 的实现) - -```c -/* 把 zip_extract.c 的 is_safe_archive_path() 复制到 rar_extract.c */ -/* 或者提到一个共用的 src/path_util.c */ -``` - -> ⚠️ **可重构性**:D3 阶段如果时间够,把 `is_safe_archive_path` / `path_depth` / `nftw_unlink` 提到 `src/path_util.c`,rar_extract 和 zip_extract 都 include。**不做也行**,copy 一份到 rar_extract.c 即可。 - -### 7.3 错误码映射(rar → zip 命名空间) - -| RAR unrar API 返回 | unrar 含义 | 映射到 zipx_status_t | -|---|---|---| -| `ERAR_NO_MEMORY` | OOM | `ZIPX_ERR_INTERNAL` | -| `ERAR_BAD_DATA` | CRC 错 / 损坏 | `ZIPX_ERR_CRC` | -| `ERAR_BAD_ARCHIVE` | 头错 / 不识别 | `ZIPX_ERR_FORMAT` | -| `ERAR_UNKNOWN_FORMAT` | 不是 RAR | `ZIPX_ERR_FORMAT` | -| `ERAR_EOPEN` | 文件打不开 | `ZIPX_ERR_OPEN` | -| `ERAR_ECREATE` | 创建输出失败 | `ZIPX_ERR_IO` | -| `ERAR_ECLOSE` | 关闭失败 | `ZIPX_ERR_IO` | -| `ERAR_EREAD` | 读失败 | `ZIPX_ERR_IO` | -| `ERAR_EWRITE` | 写失败 | `ZIPX_ERR_IO` | -| `ERAR_SMALL_BUF` | name buffer 不够 | `ZIPX_ERR_LIMIT_NAME` | -| `ERAR_PASSWORD` (11) | 密码错/缺 | `ZIPX_ERR_PASSWORD`(**新加**) | - -### 7.4 task_state 中密码字段的安全处理 - -```c -/* extract_worker 入口 */ -char password_copy[128]; -strncpy(password_copy, task->extract_password, sizeof(password_copy) - 1); -memset(task->extract_password, 0, sizeof(task->extract_password)); -/* 后续只用 password_copy,函数退出时也清零 */ -``` - ---- - -## 8. 工程决策(不要重新讨论) - -### 8.1 为什么 vendor unrar.c 而不是动态库 - -- SDK 没有 homebrew 目录(见 §2.3) -- vendor 源码 = 完全可控,编译/链接/调试一次到位 -- unrar.c 纯 C + 单文件 = 跨编零摩擦 -- 动态库需要 `-Wl,-rpath` 等额外 linker 配置,麻烦 - -### 8.2 为什么不用 libarchive - -- 体积大一倍以上(libarchive stripped ~400 KiB,unrar stripped ~150 KiB) -- libarchive 内部仍依赖 unrar/librar 才能读 RAR —— 反而绕远 -- libarchive 的 BSD-style API 与现有 zip_extract / rar_extract 设计不符 -- 用户场景里 7z 罕见,付不起这个成本 - -### 8.3 为什么复用 zipx_status_t 枚举 - -- 错误码统一,前端不用分辨 ZIP_ERR_* vs RAR_ERR_* -- `task_update()` 一份映射逻辑覆盖两种格式 -- 减少新代码量(也减少 bug) - -### 8.4 为什么不在后端做分卷发现 - -- unrar 内部已经处理 `name.part01.rar` → 找 `.part02, .part03...` -- 后端写发现逻辑只是重复实现,且容易有边角 case bug - -### 8.5 为什么密码不做错/对细粒度区分 - -- 「密码错」vs「文件损坏」的区分会泄露「文件存在 / 是否加密」信息(侧信道) -- 统一返回 `ZIPX_ERR_PASSWORD` + 友好文案即可 -- 用户重试一次的成本可控 - -### 8.6 为什么 ELF 体积红线设在 ~1 MiB - -- payload loader 通常限制 4 MiB,但实际 PS5 WebKit 启动时内存紧张 -- 单 payload 越大,加载越慢;用户感受从 580 KiB → 800 KiB 能感知 -- 留余量给未来加 7z、ZIP AES 等扩展 - ---- - -## 9. 注意事项 / 已知坑 - -### 9.1 unrar license 的实际情况(v1.8 选定 dmc_unrar) - -> v1.8 实际选择的库是 [`DrMcCoy/dmc_unrar`](https://github.com/DrMcCoy/dmc_unrar) 1.7.0,**license 是 GPL-2.0-or-later**(不是 UnRAR License)。 +> **这份文件已不再维护,不要拿它当参考。** > -> - ✅ 使用、编译、嵌入、二进制分发 —— 允许 -> - ✅ 修改并以 GPL 条款整体分发 —— 允许 -> - ❌ 改装 unrar.c —— **不允许**(UnRAR 上游版本,**dmc_unrar 允不允许改不重要因为我们没改**) -> - ❌ 不允许的:用 unrar 创建 RAR 压缩功能(不做就好) - -为什么不需要担心 license: -- dmc_unrar.c **逐字未改**(vendor 完毕没碰),完整 GPL 通知在 `third_party/unrar/COPYING` -- dmc_unrar_api.h 是项目自有文件,按项目 license(GPLv3+)分发 -- 项目本身已是 GPLv3+ → 与 GPL-2.0-or-later 兼容 -- 二进制 + 对应源代码 + GPL 通知三件套 = 合规(libmicrohttpd 的 LGPL 已经这么做了) - -**vendor 设计要点**(详见 docs/UPGRADE-v1.8-rar-support.md §5): -- **不要 `#include "dmc_unrar.c"`** —— 会污染 dmc_unrar.c 内部的 struct 名(dmc_unrar_io_handler 的 open / close 字段会被 `tests/posix_compat.h` 的 `wfm_open` / `wfm_close` 重定义撞名) -- 用 facade header `third_party/unrar/dmc_unrar_api.h`(项目自有),只 re-declare 我们用到的符号 -- Makefile 把 dmc_unrar.c 当成独立 TU 编(`THIRD_PARTY_SRCS += third_party/unrar/dmc_unrar.c`),加 `-Ithird_party/unrar -DDMC_UNRAR_DISABLE_BE32TOH_BE64TOH=1` - -未来换 opello/unrar 时这套机制仍适用:把 `dmc_unrar.c` 换成 `*.cpp`、给 `dmc_unrar_api.h` 换内容(实现 RAROpenArchiveEx / RARSetPassword / RARProcessFileW 等 DLL API 风格),引擎签名不动、dispatch 不动、host tests 不动。 - -### 9.2 SDK staging 模式(每次都要 sudo) - -```bash -# 不要尝试 -chown -R song:song /opt/ps5-payload-sdk/ # ❌ 会破坏其他用户 -chmod 777 /opt/ps5-payload-sdk/ # ❌ SDK 可能拒绝加载 - -# 正确做法(build-elf.sh v5 已实现) -make install DESTDIR=/tmp/wfm-stage -sudo cp -r /tmp/wfm-stage/opt/* /opt/ -``` - -### 9.3 编辑工具的隐藏陷阱 - -- **Edit 工具偶有"成功但不落盘"现象**:连续两次同一 Edit,第一次只报成功未生效。**对策**:每次 Edit 后用 `grep` 验证改动真的进了文件。 -- 写 `.bat` / `.ps1` 时**不要在 PS5 路径里写非 ASCII**(utf-8 BOM 破坏文件名)。**对策**:用 PowerShell `Write` 工具的绝对路径形式,或改用 Git Bash heredoc。 -- 修改 unrar.c 用 `sed` 是诱人的,但 license 不允许改 → 用 wrapper 函数封装。 - -### 9.4 跨编 ELF 校验脚本 - -`gen-asset-module.py` 把 JS / JSON / CSS / HTML 用 zlib 压缩进 ELF。 -**普通 `strings web-file-mgr.elf` 看不到前端新加的字符串**。 -**对策**:用 `.build/check-elf-gzip.py`(已存在)扫 gzip 流验证。 - -```python -# 检查清单(D6 必跑) -python3 .build/check-elf-gzip.py ./web-file-mgr.elf | grep "✗" -# 期望:空输出(所有 key 都在) -``` - -### 9.5 测试 fixture 生成 - -- 4 MiB of 'A' 的 zip **不**用于 large profile 测试(ratio ≈ 1026 > 1000) -- 用 `bytes(range(256)) * 4096` 才有 ratio ≈ 238(夹在 200~1000 中间) -- RAR 用 host `rar` 命令生成 fixture,CI 环境可能没装 → 把 small fixtures 提交到 git - -### 9.6 密码字段的安全 - -- task 结构里的 password 在 extract_worker 入口**立即清零** -- 日志 / progress 回调**绝不**写 password -- `task->message[]` 是固定 192 字节 buffer,**只**写错误描述,不写密码 -- 前端 modal 关闭后立即 `input.value = ""` - -### 9.7 magic 探测 + 扩展名优先级 - -扩展名优先(O(1) 字符串比较),magic fallback 用 file I/O(O(8 字节读)): -- 大文件不慢:magic 只读 8 字节 -- 重命名 `.bin` 仍可识别 -- 非 RAR 非 ZIP → 返回 -1,extract.c 报 `extract_unsupported` - -### 9.8 三阶段 staging 目录命名 - -- `.wfm-part-{pid}` —— 用 pid 区分并发解压 -- 失败时 `nftw(staging, unlink_cb, 64, FTW_DEPTH | FTW_PHYS)` 递归删 -- publish 阶段 `rename(staging, dst_dir)` —— 原子(同一文件系统下) - -### 9.9 RAR4 vs RAR5 头差异 - -| 字段 | RAR4 | RAR5 | -|---|---|---| -| Magic | `Rar!\x1a\x07\x00` | `Rar!\x1a\x07\x01\x00` | -| 大小字段 | 32-bit | 64-bit (UnpSizeHigh 等) | -| 加密 flag | `LHD_PASSWORD` (0x04) | 头 flag bit 0x04 | -| 目录 flag | `LHD_DIRECTORY` (0xE0) | 不同位 | -| CRC | 32-bit | 32-bit (压缩) | - -unrar 抽象了这些,**API 层不必区分**,但错误处理时要兼容两种返回码。 - -### 9.10 unrar 多线程安全性 - -> ⚠️ **unrar 全局状态**:RAROpenArchive 返回的 HANDLE 不是线程安全的,**每个 task 必须独立打开/关闭**。当前 `extract_worker` 已经是每 task 一个 thread,没问题。 - ---- - -## 10. 常用命令清单 - -### 10.1 host 测试 - -```bash -cd /home/song/ps5-web-file-manager/ -bash tests/run-tests.sh # 全量回归(81+ checks) -./.build/host-test/test-zip-extract \ - tests/fixtures/ /tmp/wfm_work # 单独跑 ZIP -./.build/host-test/test-rar-extract \ - tests/fixtures/ /tmp/wfm_work # 单独跑 RAR -``` - -### 10.2 PS5 跨编 - -```bash -# WSL Ubuntu-22.04 bash -cd /home/song/ps5-web-file-manager/ -bash build-elf.sh # 全量构建 + 落双路径 - -# 仅清理 + 重编(快一些) -make clean all - -# 增量编译(zlib/minizip-ng obj 缓存复用) -make all -``` - -### 10.3 ELF 验证 - -```bash -# 在 Windows 侧(或 WSL 内) -sha256sum web-file-mgr.elf -size web-file-mgr.elf -head -c 20 web-file-mgr.elf | xxd | head -2 -od -An -tx2 -N2 -j18 web-file-mgr.elf | tr -d " " # expect 003e - -# 验证前端改动进了 ELF -python3 .build/check-elf-gzip.py ./web-file-mgr.elf -``` - -### 10.4 Git 操作 - -```bash -# 本地提交(commit 阶段) -cd /home/song/ps5-web-file-manager/ -git add -git -c core.autocrlf=false commit -m "..." - -# 用户在自己 PowerShell / Git Bash(非沙箱): -git push -u origin main -git tag -a v1.8 -m "..." -git push origin v1.8 -``` - -### 10.5 debug 工具 - -```bash -# 跟踪 HTTP 请求(curl) -curl -v -X POST http://127.0.0.1:8080/api/extract \ - -d "path=/data/test.rar&dst_dir=/data/out&password=secret" - -# 看 ELF 内是否有符号 -nm web-file-mgr.elf 2>/dev/null | grep rarx -# (prospero-strip 后大部分本地符号被剥,外部函数名仍在) - -# 看 unrar 占多大 -size --target=binary web-file-mgr.elf -``` - ---- - -## 11. 测试矩阵(完成 D5 后应全过) - -### 11.1 ZIP(v1.7 已覆盖 69 checks) - -| 类别 | 用例 | 期望 | -|---|---|---| -| 基础 | basic / stored / unicode / zip64 | OK | -| 安全 | traversal / traversal_backslash / absolute / drive_letter / symlink / fifo | ERR_* | -| 加密 | encrypted | ERR_UNSUPPORTED | -| 错误 | bad_crc / truncated / not_a_zip | ERR_CRC / ERR_FORMAT / ERR_OPEN | -| 资源 | ratio / file / total cap / depth / name | ERR_LIMIT_* | -| 边界 | duplicate / file_dir_clash / conflict_source | ERR_DUPLICATE / 处理 conflict | -| 大文件 | large profile (16 checks) | OK / ERR_* 按预期 | - -### 11.2 RAR(v1.8 新增 ~12 checks) - -| 类别 | 用例 | 期望 | -|---|---|---| -| 基础 | rar4_single / rar5_single | OK | -| 分卷 | rar5_multi | OK | -| 加密 | rar4_enc_correct_pwd / rar5_enc_correct_pwd | OK | -| 加密 | rar4_enc_wrong_pwd / rar4_enc_no_pwd | ERR_PASSWORD | -| 错误 | rar_truncated / rar_bad_archive | ERR_FORMAT | -| 安全 | rar_path_traversal | ERR_UNSAFE_NAME | -| 资源 | rar_ratio_bomb / rar_file_too_large | ERR_LIMIT_RATIO / ERR_LIMIT_FILE | -| 边界 | rar_cancel | ERR_CANCELED | - -### 11.3 集成测试(手动或脚本化) - -```bash -# 在 PS5 真机 / qemu-ps5 上 -1. 浏览器访问 http://ps5-ip:8080 -2. 上传 sample.rar → 选中 → 解压 → 验证文件出现 -3. 上传 encrypted.rar → 选中 → 弹密码框 → 输入 → 解压 -4. 上传 .part02.rar → 选中 → 按钮置灰 → tooltip 正确 -5. 上传 game.part01.rar + .part02.rar + .part03.rar → 选 part01 → 解压 -6. 用错的密码再次尝试 → 弹密码错误窗 → 重试 -7. 上传 200GB rar → 弹 large profile 窗 → 走 large profile 解压 -``` - ---- - -## 12. 项目当前快照(2026-09-05 15:30 — v1.8 收尾) - -``` -Repo: https://github.com/owendswang/ps5-web-file-manager.git (待 push) -Local: C:\Users\songl\Desktop\Web File Manager\ps5-web-file-manager\ -WSL: \\wsl$\Ubuntu-22.04\home\song\ps5-web-file-manager\ -HEAD: bfe522e (local) + uncommitted v1.8 working tree -Branch: main (no .git push yet — sandbox github 502) - -ELF: web-file-mgr.elf 待用户 WSL 重编 - v1.7 旧的: 418 KiB - sha256: 648e4a00afe52669846df52ee5342bab42ea10050d555d5a1d4fa602653f514b - e_machine: 0x003e (x86_64-sie-ps5 ✓) - v1.8 预估: ~430 KiB (dmc_unrar 二进制 + facade, 真实尺寸待编) - sha256: TBD - e_machine: 0x003e (x86_64-sie-ps5 ✓) - Version: v1.8 (VERSION_TAG 在 Makefile 已改) - Title ID: FMGR88888 - -Tests: 83 checks, 0 failures (69 ZIP + 14 RAR) -Deps: zlib 1.3.1, minizip-ng 4.2.2, dmc_unrar 1.7.0 (vendored; no system libs) - -Done (v1.8 实施层): - ✅ src/rar_extract.{c,h} (1276 LOC), 镜像 zip_extract 的三阶段 + scan/extract/publish/cleanup - ✅ third_party/unrar/{dmc_unrar.c, dmc_unrar_api.h, COPYING, README.md, example.c, VENDORED.md} - ✅ src/extract.c dispatch (扩展名 + magic fallback 单点判断) - ✅ 14 new host tests + run-tests.sh + make_fixtures.py(带 rar/7z/placeholder fallback) - ✅ Makefile (VERSION_TAG v1.8 + dmc_unrar TU + -DDMC_UNRAR_DISABLE_BE32TOH_BE64TOH=1) - ✅ 前端 isExtractableArchive / isRarSubVolume + lang-{en,zh}.js err_extract_unsupported - ✅ THIRD_PARTY_NOTICES section 3 (dmc_unrar attribution) - ✅ CHANGELOG.md v1.8 section (含 deviation 注释) - ✅ README.md (What's new in v1.8 + RAR section + Credits dmc_unrar + GPL-2.0 合规说明) - ✅ docs/UPGRADE-v1.8-rar-support.md (架构 + facade 模式 + roadmap) - ✅ docs/HANDOVER.md (本文件: §4/§9.1/§12/§14 全更新) - -Doing (用户侧): - ⏸ WSL 跑 bash build-elf.sh → 重编 web-file-mgr.elf → 记 sha256 填进 CHANGELOG v1.8 banner - ⏸ 填 ELF size 实际值 - ⏸ PowerShell 跑 git push -u origin main (所有 5 + 1 commit) - ⏸ git tag -a v1.8 -m "..." && git push origin v1.8 - ⏸ 在 README v1.7 banner 那行替换为 v1.8 banner (已改) - ⏸ (可选) 部署 .elf 到 PS5 实机跑一遍 → 选 .rar → 解压 → 验证 - -Defer (v1.9): - ⏸ vendor opello/unrar 替换 dmc_unrar → 加多卷 + 加密支持 - ⏸ 共用 staging/publish/nameset 抽到 src/archive_engine_common.c - ⏸ password= 前端 modal + 后端 wire format -``` - -§13「写在最后」仍然适用,但下一节是新加的"v1.8 实际交付状态",比 §13 更具体。 - ---- - -## 13. 写在最后 - -**这份文档不是"工作日志",是"施工蓝图"**。任何接手人应该能: - -1. 读完 §1 ~ §4(10 分钟)→ 知道项目是什么、当前到哪 -2. 跳到 §6 按天执行(6 天)→ 写完 v1.8 -3. 出问题翻 §7 ~ §9(30 分钟内定位) -4. 完成时按 §6.6.5 提交 + §10 验证 - -如果某个环节卡住超过 2 小时,**优先回这里查 §9 的「坑」** —— 90% 的边角 case 我都踩过了。 - -加油。 - ---- - -## 14. v1.8 实际交付状态(2026-09-05 收尾报告) - -> **本节是 v1.8 RAR 支持的最后收尾报告**,写给接手人(前同事 / 未来自己), -> 让你在 5 分钟内知道:v1.8 做了什么、为什么这样做、哪里跳了坑、下一步是什么。 +> 原文写于 **2026-09-04**,是一份 **v1.8(RAR 支持)开发计划**,对应代码版本 `aef4a44` +> (v1.7 时代)。其中大量结论已被后续版本推翻,例如它至今仍写着: > -> §1 ~ §13 是 v1.8 开工前的施工蓝图(**与现实有偏差**,但工程决策保留); -> 本节是 v1.8 完工后的真实记录(**以本节为准**)。 +> - 「❌ RAR 分卷」「❌ 加密 RAR」 +> - 「dmc_unrar 不支持 / 已移除」 +> - 「7z 尚不支持」「加密解压尚未接线」 +> +> 而 v1.9 已用 rarlab UnRAR 7.20.1 解决 RAR 分卷与加密,v1.9.2/v1.9.3 补齐了 ZIP/RAR/7z +> 三格式加密内容与 7z `-mhe=on` 加密头,dmc_unrar 也已整体移除。 -### 14.1 用了多久 / 写了多少 +## 当前权威文档(按优先级) -| 维度 | 数据 | +| 文档 | 用途 | |---|---| -| 总耗时(沙箱内,开工到完工) | 约 6 小时(含 vendor 选型失败 → 重选 → 写引擎 → 写 host tests → 文档) | -| 新增 LOC | `src/rar_extract.c` 1276 + `src/rar_extract.h` 34 + `third_party/unrar/dmc_unrar_api.h` 138 + `third_party/unrar/VENDORED.md` 76 + `tests/test_rar_extract.c` 346 + `docs/UPGRADE-v1.8-rar-support.md` ≈ 700 = **≈ 2570 LOC 项目自有代码**(vendor 的 dmc_unrar.c 11 598 LOC 不计) | -| 改 LOC | Makefile + extract.c + main.js + lang-{en,zh}.js + make_fixtures.py + run-tests.sh + THIRD_PARTY_NOTICES + README + CHANGELOG ≈ 600 | -| 文档净增 | README ≈ +90 行 / CHANGELOG ≈ +100 行 / HANDOVER.md ≈ +80 行(新本节)/ UPGRADE-v1.8 ≈ 700(新文件) | -| 测试数 | 69 → **83**(+14 RAR) | +| 仓库根 [`../HANDOVER.md`](../HANDOVER.md) | **状态速览、真机待验证清单、坑清单 —— 以此为唯一准绳** | +| [`../README.md`](../README.md) / [`../README.zh-CN.md`](../README.zh-CN.md) | 功能范围与使用方式 | +| [`../CHANGELOG.md`](../CHANGELOG.md) | 逐版本变更 | +| [`EXTRACTION-PERF.md`](./EXTRACTION-PERF.md) | 解压性能实测与优化账目 | +| [`REAL-CONSOLE-PROFILE.md`](./REAL-CONSOLE-PROFILE.md) | 真机性能验证清单 | +| [`REWRITE-FEASIBILITY.md`](./REWRITE-FEASIBILITY.md) | 许可与拆库可行性分析 | +| [`SIZE-OPTIMIZATION.md`](./SIZE-OPTIMIZATION.md) | 二进制体积优化记录 | +| [`UPSTREAM-V1.8-COMPARISON.md`](./UPSTREAM-V1.8-COMPARISON.md) | 与上游 v1.8 helper 路线的对比 | -### 14.2 完成 vs §6 计划的 deviation(最重要) +**任何关于「现在支持什么」的陈述,一律以根 `HANDOVER.md` 与源码为准。** -| §6 计划 | v1.8 实际 | 为什么 | -|---|---|---| -| vendor `alexbatalov/unrar.c` | vendor `DrMcCoy/dmc_unrar` 1.7.0 | alexbatalov 仓库 404,dmc_unrar 是单文件 GPL-2.0 FLOSS,vendor 摩擦最小 | -| UnRAR license(改造禁止) | GPL-2.0-or-later(**可改但不改**) | 库选择改了,license 处理相应改成"vendor 不动 + 项目自有 facade" | -| 多卷 RAR `name.part01.rar` + `+02..` 链式 | ❌ 拒绝 + UI tooltip "select main volume" | dmc_unrar 上游不支持 volumes,需 opello/unrar(v1.9) | -| 加密 RAR + 密码 modal | ❌ 拒绝 + 无 password= 字段 | dmc_unrar 上游不支持加密,需 opello/unrar(v1.9) | -| `extract_format` + `extract_password` task 字段 | 字段没用 | dmc_unrar 不需要 password,task struct 保持 v1.7 形状 | -| `src/path_util.c` 共用 `is_safe_archive_path` 提取 | **未提取**(zip_extract 与 rar_extract 各有一份) | 进度 + 风险权衡后延后到 v1.9 共用 archive_engine_common.c 重构时 | -| 错码 `ZIPX_ERR_PASSWORD` 新增 | **未加** | 加密不支持,密码错根本发不出来;opello 接入时再加 | -| 前端 password modal HTML/CSS | **未加** | 同上;预留 modal 锚点(CSS class naming)供 v1.9 复用 | -| linux build 也编 rar_extract | ✅(COMMON_SRCS 已包含 rar_extract.c) | 顺手改的,没增加工作量 | +## 为什么归档 -**核心决定**:把"vendor 一个 C++ UnRAR(opello/unrar)来支持多卷 + 加密"推迟到 v1.9, -v1.8 用 dmc_unrar 跑完"单卷 + 明文"这个 80% 用户的核心场景。代码改动面只局限在 -`src/rar_extract.{c,h}` + `third_party/unrar/`,未来切换工作面极小。 +这份 1713 行(65 KB)的文件停留在 v1.7/v1.8 时代,却与根 `HANDOVER.md` 并行存在。 +它的过时结论在多轮开发中**反复误导整仓 grep**(本项目自己就踩过数次:搜 "RAR 分卷"/"加密" +会命中这里,得到与现状完全相反的答案),因此 2026-09-23 把它移出主文档树。 -### 14.3 关键工程决策(不要再讨论) +保留它的唯一理由是**历史价值**:§9「坑清单」以及 v1.7/v1.8 时代的设计推导, +对理解「当时为什么这么取舍、踩过什么坑」仍有考古意义。 -1. **dmc_unrar vs alexbatalov/unrar vs opello/unrar vs libarchive** — - 见 `third_party/unrar/VENDORED.md` §"Why dmc_unrar" 表格。摘要:单文件 + - FLOSS + C99 = vendor 摩擦最小;opello 是"想要多卷加密"那 20% 用户的代价, - v1.8 不付。 - -2. **facade header 模式(`dmc_unrar_api.h`)** — 见 `docs/UPGRADE-v1.8-rar-support.md` - §5。这是 v1.8 最值得记下来的工程模式:vendor 一个独立的 .c 文件,绝对不要 - `#include "third_party.c"`,否则 host 构建系统的宏(`posix_compat.h` 的 - `wfm_open`/`wfm_close`)会和 vendor 内部结构体字段名打架。**通用做法**:写 - 100 行的 facade header,只 declare 你用到的符号。 - -3. **dispatch 用扩展名不用 magic 嗅探** — 见 `src/extract.c::extract_dispatch()`。 - 扩展名 O(1),magic 要 I/O 读 8 字节,对每 archive 调用走两次。可以后加 - magic-byte 嗅探作为 future improvement,但 v1.8 没必要。 - -4. **RAR 没 public schema 给前端** — `extract_format` 在 task struct 里没暴露, - 因为前端只需知道"能不能解压"(看扩展名),不需要知道"格式是 ZIP 还是 RAR", - 后端 dispatch 已经处理完。task struct 字段保持最小。 - -5. **`err_extract_unsupported` 文案** — 见 `assets/lang-{en,zh}.js`。明确告知 - "only unencrypted plain ZIP and single-volume RAR",前端根据这条就知道是否 - 弹密码(否)或弹"unrar on PC first"链接(可)。 - -### 14.4 接下来用户侧要做的(在 PowerShell / Git Bash,**非沙箱**) - -```bash -# 1. WSL 内重编 ELF(需 30s 编译 + 5s strip) -# 在 WSL Ubuntu-22.04 bash: -cd /home/song/ps5-web-file-manager -export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk -bash build-elf.sh -# 输出会: -# - 落 /home/song/ps5-web-file-manager/web-file-mgr.elf -# - 落 C:/Users/songl/Desktop/Web File Manager/ps5-web-file-manager/web-file-mgr.elf -# 记下 ls -la 与 sha256sum 的输出 - -# 2. PowerShell / Git Bash 里验证 -cd "C:/Users/songl/Desktop/Web File Manager/ps5-web-file-manager" -file web-file-mgr.elf # ELF 64-bit LSB pie, x86-64 -od -An -tx1 -N20 web-file-mgr.elf | head -2 # 7f45 4c46 0201 -od -An -tx2 -N1 -j18 web-file-mgr.elf | tr -d ' ' # 003e -python3 .build/check-elf-gzip.py ./web-file-mgr.elf | grep -E "(✓|✗)" | tail -# 期望 v1.8 新 key: err_extract_unsupported 出现在 ✓ 行 - -# 3. 把 size + sha256 填进 CHANGELOG.md v1.8 banner -# 当前内容 (line 7-9): -# > Release artifact for v1.8: -# > `web-file-mgr.elf` — size TBD (cross-compile runs in WSL — see `docs/HANDOVER.md`) -# > sha256 TBD -# 把 "TBD" 替换成实际值 - -# 4. 提交 + 推送 -git add -A -git -c core.autocrlf=false commit -m "v1.8: RAR4/RAR5 single-volume unencrypted (dmc_unrar backend) -- vendor DrMcCoy/dmc_unrar 1.7.0 into third_party/unrar/ (GPL-2.0-or-later) -- src/rar_extract.{c,h}: three-phase engine mirroring zip_extract -- third_party/unrar/dmc_unrar_api.h: project-authored facade header -- extract.c: dispatch layer (extension-based; magic fallback post-v1.8) -- Makefile: VERSION_TAG v1.8 + dmc_unrar TU + byte-swap workaround -- frontend: isExtractableArchive / isRarSubVolume + lang-* err string update -- host tests: +14 RAR negative-path checks (total 83) -- docs: CHANGELOG v1.8 / README RAR section / docs/UPGRADE-v1.8-rar-support.md -- THIRD_PARTY_NOTICES: section 3 dmc_unrar attribution -- docs/HANDOVER.md: v1.8 实际交付状态 (§14) for handoff" - -# 5. 用户在自己 PowerShell 推 -git push -u origin main # 一次性推所有 5 + 1 commit -git tag -a v1.8 -m "v1.8 — RAR4/RAR5 single-volume unencrypted (dmc_unrar)" -git push origin v1.8 - -# 6. (可选) PS5 真机部署 -# 看 §10.3 在 HANDOVER.md 的 manual integration test 流程 -``` - -### 14.5 给接手人的话 - -如果你是接手人: - -- **前 5 分钟**:读 §14(这节),知道 v1.8 干了什么、为什么没干剩下的(加密/多卷) -- **下一个 5 分钟**:跳 `docs/UPGRADE-v1.8-rar-support.md`,看架构图 + facade 模式 + - error mapping 表 -- **如果接活 = v1.9(多卷 + 加密)**:直接打开 - `third_party/unrar/VENDORED.md` §"Upgrading to a fuller library (v1.9 - plan)",按 5 步执行。**不要重新设计架构**,签名/调度/测试都不动。 -- **如果接活 = 别的格式(7z, tar.xz, …)**:照 v1.8 的样子 mirror 一份: - vendor + facade header + `src/_extract.{c,h}` + dispatch + tests + - docs。 -- **如果接活 = 真机调试某用户的 .rar 上传失败**:99% 是 §14.2 那张表的 - 边界 case(多卷 / 加密 / 旧版 / symlink 入口),看前端 `err_extract_unsupported` - 弹出来的 detail 字段就能定位。剩下的 1% 看 `rar_translate_error()` - 错误码映射 + dmc_unrar 自己的 issue tracker。 -- **沙箱限制**:git push 必走 PowerShell / Git Bash(沙箱 502),WSL 必走 - `bash build-elf.sh`(沙箱没 SDK)。build-elf.sh 用的是 staging → sudo cp 模式, - 见 §2.3。 - -如果某个环节卡住超过 2 小时,**优先回这里看 §14.4 + §9** —— 90% 的边角情况 -上面已经覆盖了。 - -加油 v1.9。 \ No newline at end of file +原文完整副本(内容一字未改,含归档前的 md5): +**[`archive/HANDOVER-v1.8-planning.md`](./archive/HANDOVER-v1.8-planning.md)** diff --git a/docs/REAL-CONSOLE-PROFILE.md b/docs/REAL-CONSOLE-PROFILE.md new file mode 100644 index 0000000..ab06557 --- /dev/null +++ b/docs/REAL-CONSOLE-PROFILE.md @@ -0,0 +1,285 @@ +# 真机验证清单:解压速度(10–40 MB/s 到底卡在哪) + +> ### ⚑ 本清单的终局:停在第 1 步之前(2026-09-23 18:15,用户决定) +> +> **`T_copy` 没做;RAR 多线程(#51)不做;生产代码一行不动、不刷机 —— 11 分钟维持现状。** +> +> 理由:MT 的收益在 PC 上**已实测**(下面「第三件」2.45×/我们引擎 1.40–1.74×), +> 但**能不能兑现,刚好吊在本文档第 1 步那个实验上** —— 而 MT 只并行解码、写盘恒为主线程 +> 串行(`unpack50mt.cpp` 的 `UnpWriteBuf()` 只在主线程调,见 §六 终局块), +> 所以那个实验的结果很可能是「收工」。赌一次的成本(改构建 + 刷机 + 重跑 11 分钟 + 真机验证) +> 压在不确定收益上 ⇒ **不做**。 +> +> **下面全部内容保留**,作为「将来重开时的现成答案」。 +> ⚠️ 重开的**第一个动作是第 1 步的 `T_copy`,不是改构建**。 + +> ### ⚠️⚠️ 2026-09-23 深夜(第二次更正):**归档参数已拿到、MT 已实测、最大嫌疑已排除** +> +> **口径修正**:包不是 18 GB,是 **3 个分卷 ≈ 11.6 GB**(4 GB + 4 GB + 3.6 GB)。 +> **下文凡写「18 GB」处,一律按「≈11.6 GB」读**;核心算术已在本块重算。 +> +> **① 真实归档参数(从 part3 的头实测,该文件随后被清理)** +> RAR **5**、**卷 3/3**、**锁定**、**不是固实**(直解主头 `archive flags=0x0013`,`MHFL_SOLID=0x0004` 未置位)、 +> 压缩参数 **`-m1 -md=4g`**(**最快档 + 4 GiB 字典**)。关键条目 `PPSA16608.exfat` +> **19.49 GB → 打包 3.83 GB(ratio 5.09)**,并**整段都在 part3 内**。 +> 详见 `docs/EXTRACTION-PERF.md` §六 第二次更正块。 +> +> **② 最大嫌疑(scan 白解码一遍 = 2×)已排除。** 那建立在「固实 skip 必须解码」 +> (`dll.cpp:341` 只在 `!Arc.Solid` 走廉价 `SeekToNext()`)之上;**本档非固实** ⇒ 我们的 scan 不解码。 +> +> **③ MT 收益已实测 = ~2.4×**(真实形状夹具:`-m1 -md4g`、5:1 可压缩、2 GB、`-mt1` 386→`-mt8` 946 MB/s 解压后)。 +> 接线只需 **1 个编译开关 + 1 个链接开关**(`-DRAR_SMP` + `-lpthread`),**不必改 vendored 源码、 +> 不必增删源文件** —— 早期版本写的「还要加 `threadmisc.cpp`」**是错的**:`threadpool.cpp:5` 已经 +> `#include "threadmisc.cpp"`(实测 `nm unrar7_threadpool.o` 里有 `T GetNumberOfThreads`),再加会重符号。 +> +> **④ 但矛头现在指向「PS5 写路径」——所以本清单第 1 件事(`T_copy`)不变,而且更该先做。** +> - 单线程解码在 PC 上同形状就是 **386–505 MB/s**;PS5 单核按 1/3 算也 ~130 MB/s。 +> - 真机:**只算 `.exfat` 一项**就是 19.49 GB ÷ 660 s = **≥29.5 MB/s 解压后**。 +> - **两次独立操作撞同一个数**:上传(写 11.6 GB)**30–40 MB/s** = 解压(写 ≥19.5 GB)≈30 MB/s。 +> ⇒ 优先怀疑这条写路径的上限就是 **30–40 MB/s**。**若 `T_copy` 也 ~10 分钟 ⇒ 收工,MT 不做** +> (做了会被 I/O 吃掉);**若 `T_copy` 明显快 ⇒ 立刻上 MT,那 2.4× 是真的**。 +> +> 下面是原始判断(保留),**优先级以本块为准**。 + +> ### ⚠️ 2026-09-23 晚 更正 —— 本文档的起点判断已反转。 +> 本文最初的前提是「已排除解码器」,那建立在两条现已作废的论证上: +> ① 拿 **PS5 上解 RAR** 的 28 MiB/s 去比 **PC 上解 7z** 的 427 MiB/s(不同格式/解码器/机器); +> ② UI 速度的 4× 摆动(已自行降级为 250 ms 采样噪声的弱证据)。 +> +> **新增的三条事实把方向翻了过来**:格式是 **RAR**(解码就是 rarlab 的 UnRAR 库本身); +> 包原本在 PC 上、经插件上传进 PS5;上传实测 30–40 MB/s。随后查代码发现: +> **我们的 POSIX 构建没有定义 `RAR_SMP`**(`os.hpp:43-45` 的 `#define RAR_SMP` 在 +> `#ifdef _WIN_ALL` 内;官方 POSIX makefile 第 11 行是 `DEFINES=... -DRAR_SMP`), +> 后果是 `unpack50mt.cpp`(`Unpack::Unpack5MT`,多线程 RAR5 解压器)**根本没编进来**, +> `unpack.cpp` 的 MT 分支整段不参与编译 ⇒ **RAR 解码在 PS5 上是单线程的**。 +> +> ⇒ **新的首要假设:28 MiB/s ≈ 单线程 RAR5 解码的正常量级。** +> 详细更正与代码行号见 `docs/EXTRACTION-PERF.md` §六 文首更正块。 +> **下文按「实测 → 判读」仍有效;但第 2 步(读粒度)对 RAR 不适用**,已加注。 + +## 已到手的数据(2026-09-23 真机) + +| 项 | 值 | +|---|---| +| 包大小 | **≈11.6 GB 压缩包**(4 GB + 4 GB + 3.6 GB 三个分卷;✅ 2026-09-23 晚更正,原写 18 GB 有误) | +| 条目数 | 1252 | +| 总耗时 | **11 分钟 = 660 s**(UI 显示) | +| **UI 速度口径** | **解压后字节**(`src/rar_extract.c:766` 在 `UCM_PROCESSDATA` 里 `bytes_done += p2`)⇒ 10–40 MB/s 是「吐出数据的速度」 | +| 平均吞吐(输入侧) | **≥ 17.6 MB/s 打包字节**(11.6 GB ÷ 660 s) | +| 平均吞吐(输出侧) | **≥ 29.5 MB/s 解压后**——只算 `PPSA16608.exfat` 一项就是 19.49 GB ÷ 660 s | +| 格式 | **RAR 5** ✅(头 8 字节 `52 61 72 21 1A 07 01 00`) | +| 固实 | **不是固实** ✅(主头 `MHFL_SOLID=0x0004` 未置位) | +| 压缩参数 | **`-m1 -md=4g`**(最快档 + 4 GiB 字典)✅ | +| 关键条目 | `PPSA16608.exfat` 19 493 027 840 B → 打包 3 831 295 727 B(ratio **5.09**),整段在 part3 内 | +| 来源 | 包原本在 **PC 上**,**经插件本身上传**进 PS5,上传速度 **30–40 MB/s**(写 11.6 GB) | +| 源 / 目标设备 | **待确认** ⬅ **现在最关键的一条**(内置 SSD / 外置 USB / 是否同盘) | +| 解压后总大小 | **待确认** ⬅ 决定输出侧吞吐的确切值(≥19.49 GB 已知) | + +「18 GB 是压缩包大小」这条把口径钉住了,而且给出一个**不需要知道解压后大小的硬上界**: + +``` +源盘必须在 660 s 内交出 18 GB 的归档数据 +⇒ 整条流水线的平均吞吐上界 = 18 GB ÷ 660 s ≈ 28 MiB/s +``` + +解压后有多大都不影响这个上界 —— **无论解码多快,源盘就只有这个交付速度**。 +所以第 1 步的 `T_copy` 和它**可以直接比大小**(同一个 18 GB 文件、同一对设备)。 + +两个立刻可用的推论: + +1. **per-entry(小文件)开销解释不了它。** 1252 个文件、平均 14.7 MB,不是「几万个小文件」那种 + 形态;建文件 + rename 这两种 per-entry 成本加起来分摊到 0.53 s/文件 里微乎其微。 +2. **UI 上那个 10–40 MB/s 是瞬时值,不是平均值。** 进度回调只在累计 ≥ 1 MiB 时上报 + (`src/zip_extract.c:123`),`src/task.c:202-206` 每 **250 ms** 采一次样。250 ms 窗口 + + 写缓冲突发会让速度在「忽停忽走」之间跳,**波动里含相当比例的采样噪声**。 + ⇒ 只有「解压后字节 ÷ 总耗时」可信;那 4× 的摆动只能说明「不是恒定负载」,不能单独定案。 + +--- + +## ★ 你要在真机上做的事(就两件) + +### 第一件:补一个数字 `T_copy`(3 分钟操作 + 一次等待) + +1. 把那个 **18 GB 的包**,用插件自己的**复制**功能,复制到**解压时输出所在的那块盘** +2. **记下耗时**(UI 上有) +3. 顺便记下:这个包**原本在哪**(内置 SSD / 外置 USB),**解压输出到哪**(同盘还是另一块) + +判读(就在 `T_copy` 与 **660 s** 之间比): + +| `T_copy` | 结论 | 我接下来做什么 | +|---|---|---| +| **≥ 10 分钟** | 存储物理极限,与代码无关 | **收工**,解码优化全部关闭(#48 取消) | +| **1–2 分钟** | 解压比同一对设备上的纯搬运慢 5–10× ⇒ **代码里有真问题** | 做 #48:`vol_read()` 加 read-ahead | +| 介于中间 | 混合 | 按字节数扣掉 I/O 分量,差值才是能改的部分 | + +> 为什么这个数字这么关键:它是**同一台机器、同一对设备、同一份代码**,只把「解压」换成 +> 「纯搬运」。不需要造新包、不需要第二块盘、不需要任何假设。上面那 4 个对照实验(第 3 步) +> 只在它的结论模糊时才需要。 + +### 第二件:两个小事实 —— **已答一半,剩两个待确认** + +1. ~~RAR4 还是 RAR5?~~ **RAR5 ✅**;~~是否固实?~~ **非固实 ✅**;压缩参数 **`-m1 -md=4g` ✅** + (都从 part3 的头上直接读到,见文首第二次更正块 ①)。 + 顺带确认:**UI 的口径是解压后字节**(`src/rar_extract.c:766`)。 +2. **还缺两个数(都很便宜)**: + - **解压后总大小** —— 决定输出侧吞吐。已知 ≥19.49 GB(仅 `.exfat` 一项)。 + 插件跑解压时 UI 上的 `total` 就是它(`task->total = p->bytes_total`,`src/extract.c:64`)。 + - **源 / 目标设备** —— 包在哪块盘、解压输出写到哪块盘、是否同一块盘。 + 这一条现在比什么都重要:写路径被怀疑是墙(文首块 ④)。 + +### 第三件(我已做完,不用上真机):多线程 RAR5 的收益 + +用 WinRAR 自带的 **UnRAR 7.23**(`-mt` 开关可解析,虽然不出现在 `-?` 帮助里) +在一份**按真实形状**造的夹具上做 A/B(`-m1 -md4g`、5.27:1 可压缩、2 GB、分卷): + +| 线程 | 耗时 | 解压后吞吐 | 加速 | +|---:|---:|---:|---:| +| 1 | 5.18 s | 386 MB/s | 1.00× | +| 4 | 2.39 s | 835 MB/s | 2.16× | +| **8** | **2.11 s** | **946 MB/s** | **2.45×** | + +⇒ **收益 2.4×,且已在真实形状上验证**(非固实版本 2.42×,量级一致)。 +接线只需 **1 个编译开关 + 1 个链接开关**(`-DRAR_SMP` / `-lpthread`),**不用改 vendored 源码、 +不用增删源文件**(早期写的 `threadmisc.cpp` 那一条经实测作废,见 §六 块 ④ 行内更正)—— +原因与行号见 `docs/EXTRACTION-PERF.md` §六 第二次更正块 ④。 + +> ⚠️ 但结论顺序仍以第一件为准:**先 `T_copy`**。如果墙是 30–40 MB/s 的写路径, +> 这 2.45× 会被 I/O 完全吃掉,做了等于白做。 +> ⇒ **闭环(2026-09-23 18:15):`T_copy` 没做,用户决定不做这项了**(见文首终局块)。 +> 本节的实测数据**保留** —— 将来重开时它就是收益依据;但**第一步仍是 `T_copy`**。 + +--- + +## 第 1 步(决定性实验,= 上面「第一件」):用插件自己的「复制」功能做 I/O 基线 + +**这是目前唯一能把「存储」和「我们的代码」一刀切开的实验,而且零改动。** + +插件里 **`TASK_COPY` 是已实现功能**,并且对 ≥ 256 MiB 的文件自动走 +`copy_file_pipeline()`(`src/filemgr.c:836`,阈值见 `:37`):**3 个 slot × 8 MiB、4096 字节对齐、 +独立读线程 + 独立写线程**。也就是说,它就是「同一台 PS5、同一对设备、同一份代码, +只把解压那一步换成纯搬运」。 + +**操作**:把那个 18 GB 的包,用插件自己的复制功能,复制到**解压时输出所在的那块盘**上。 +记下耗时 `T_copy`。 + +**判读:** + +| 观察到 | 结论 | 下一步 | +|---|---|---| +| `T_copy` ≈ 10 分钟或更多 | **存储物理极限**,与我们的代码无关 | 收工。解码优化全部搁置 | +| `T_copy` ≈ 1–2 分钟(远小于 660 s) | 我们的解压比同一对设备上的纯 I/O **慢 5–10×** ⇒ **代码里有真问题** | 进第 2、第 4 步 | +| 介于两者之间 | 混合 | 按字节数把 I/O 分量扣掉,差值才是我们能动的部分 | + +严格比较要看**总移动字节**:复制读 18 GB + 写 18 GB = 36 GB;解压读 ≤ 18 GB、写 = 解压后大小。 +所以「复制 36 GB 用了多久」和「解压搬了 (18 GB + 解压后大小) 用了 660 s」才是同一口径。 + +> 为什么这个实验优于第 3 步那一堆:它不需要造新包、不需要两块盘、不需要任何假设, +> 而且**测的就是出事的那对设备**。第 3 步只在第 1 步结论模糊时才需要。 + +--- + +## 第 2 步:读写粒度审计 —— ~~目前唯一可疑的代码级病因~~(⚠️ **对 RAR 不适用**) + +> **2026-09-23 晚加注:本节整段只对 7z / ZIP 有效。** 它比较的是「引擎内部缓冲大小」, +> 而 RAR 的归档 I/O 在 UnRAR 自己的 `File` 类里(我们按路径打开, +> `src/rar_extract.c:1059`),我们的回调只收到解压后的数据 —— **读粒度我们改不了**。 +> 唯一还能对上 RAR 的那半句是「**写入**粒度」,而 RAR 的写出也在 UnRAR 内。 +> ⇒ **真机工作负载是 RAR 时,本节没有可操作性**;留着是为了 7z/ZIP 场景。 +> 顺带:本表里 RAR 那行写的 4 MiB 是 `File::CopyBufferSize()` +> (`third_party/unrar7/file.hpp:148-153`),那是**文件复制**的缓冲,**不是解压读取缓冲** —— +> 原文引用错了行,这条勘误一并记在这里。 + +把四条路径的**单次请求大小**摊开看(已核对源码): + +| 路径 | 源侧读粒度 | 目标侧写粒度 | +|---|---|---| +| **复制**(pipeline,≥ 256 MiB) | **8 MiB** × 3 slot,4096 对齐,独立读线程 | 8 MiB | +| **7z** | chain `SZ_IN_CHUNK = 256 KiB`(`src/sevenz_chain.c:87`);MT 路径 `inBufSize_MT = 1 MiB` | `SZ_OUT_CHUNK = 64 KiB`(`src/sevenz_chain.c:94`) | +| **ZIP** | `ZIPX_IO_BUFFER = 128 KiB`(`src/zip_extract.c:32`) | 128 KiB(`src/zip_extract.c:900`) | +| **RAR** | UnRAR 内部 `File::CopyBufferSize() = 4 MiB`(`third_party/unrar7/file.hpp:148-153`) | 4 MiB | + +⇒ 结论一:**三个引擎都不是「小读」病理**,最小也有 128 KiB,不是 4 KB 那种灾难。 + +⇒ 结论二:**但都比复制小 32–64 倍**。 + +为什么这可能正是病根 —— 在**延迟主导**的设备上,吞吐 ≈ 单次请求大小 ÷ 每次请求的等效延迟: + +``` +256 KiB / 10 ms = 25 MB/s ← 正好落在实测 10–40 MB/s 的中间 + 8 MiB / 10 ms = 800 MB/s ← 复制路径不会撞这个上限 +``` + +这个算术顺带解释两件事: + +- **为什么吞吐会 4× 摆动**:等效延迟随设备状态(HDD 寻道、USB 桥接、写缓存回刷)变化, + 线性映射到吞吐上就是大幅波动。 +- **为什么「条目平均 14.7 MB」没能救我们**:per-entry 成本的确不是主因,但**读请求粒度**是另一回事 —— + 它由引擎内部缓冲决定,与条目大小无关。 + +最可能的场景是**源和目标在同一块盘**(外置 USB HDD 上解压到同一块盘):读流和写流同时存在, +磁头来回跑,OS read-ahead 被写回刷反复打断,于是每次请求退化成一次寻道 —— 这正好让上面那个 +算术成立。第 1 步如果出现「复制明显快于解压」,就是在支持它(复制的寻道次数只有解压的 1/32)。 + +**如果第 1 步指向这里,改动面其实很小**:所有 7z 的读都只经过 `src/sevenz_volstream.c` 的 +`vol_read()` 这一个函数(ZIP/RAR 同理在 `src/zipx_volstream.c`)。在那里加一层 read-ahead +(向后 seek 时丢弃缓存)即可,**不需要动解码器**。但这要等第 1 步的结论,现在不写。 + +--- + +## 第 3 步:四个对照实验(零改动)—— 第 1 步结论模糊时才需要 + +| 实验 | 做法 | 若结果为 A | 若结果为 B | +|---|---|---|---| +| **A 换简单包** | 造一个「**单一大文件**(如 10 GB 伪随机数据)」的 zip,放内置 SSD,解到内置 SSD | 吞吐跳到 **150+ MiB/s** ⇒ 慢在**条目数 / per-entry 开销** | 仍是 10–40 ⇒ 慢在**存储或 I/O 模式**,与条目数无关 | +| **B 换源设备** | 同一个包,分别放**内置 SSD** 与**外置 USB** | 两者差别巨大 ⇒ 就是源盘带宽 | 两者一样慢 ⇒ 不是源盘 | +| **C 换目标设备** | 同一个包,分别解到**内置**与**外置** | 差别巨大 ⇒ 写入端是瓶颈 | 一样慢 ⇒ 不是目标盘 | +| **D 同盘 vs 异盘** | 包与输出在同一块盘 / 在两块盘 | 同盘明显更慢 ⇒ 读写争用(这也是第 2 步最看好的假设) | — | + +> 参考量级:机械/低成本 USB HDD 顺序读写约 30–80 MB/s,小文件随机访问降到 1–10 MB/s。 + +--- + +## 第 4 步:只有在第 1/3 步指向「我们的代码」时才做(要改代码) + +1. **阶段计时**:在四个阶段边界累加时间戳 —— `scan` / `read+decode` / `write staging` / + `publish`。用 `clock_gettime(CLOCK_MONOTONIC, ...)`(该原语已在三个引擎里用于进度上报, + 真机可用)。收尾用 `printf` 打进日志。 +2. **拆开 `read+decode`**:这一段目前分不开。可用「同一归档跑两遍(第二遍吃页缓存)」或 + 「解到 /dev/null 类目标」来逼近 I/O 与解码的分界。 +3. **`SZX_MT_THREADS` 运行时可配**:现在是 `src/sevenz_mt.h:21` 的编译期 `#define 8`, + 做线程数 A/B 必须重编四次。改成运行时读(默认仍 8)会让实验便宜很多。 +4. **读粒度 A/B**:`SZ_IN_CHUNK` / `ZIPX_IO_BUFFER` 加大到 1–4 MiB 各构建一版, + 看第 1 步指出的那条曲线是否真的跟着动。 +5. 埋点要用**编译开关**控制,关闭时零开销 —— 避免影响已发布的产物指纹。 + +--- + +## 已排除的项(不要再花时间) + +> ⚠️ 本表 2026-09-23 晚已按「格式 = RAR」重排。原表里「解码整体就不是瓶颈」这条前提已撤回, +> 所以**同一批项现在被分成两类**:与 RAR 无关(不用看)、以及真已排除。**两类都不要花时间。** + +**A. 与 RAR 工作负载无关**(它们是 7z / ZIP 的项;RAR 解码在 rarlab 库里,我们碰不到) + +| 项 | 为什么无关 | +|---|---| +| CRC 硬件化 | 对 7z/ZIP 才值 4–5%;**RAR 的 CRC 由 UnRAR 自己算** | +| MT 扩到 BCJ2 / 多 folder | 纯 7z 概念 | +| ZIP inflate 换 libdeflate | ZIP 专属 | +| AES-NI | 只影响 ZIP 加密流与 7zAES;RAR 加密走 UnRAR 自己的 rijndael | +| 读粒度 / read-ahead(#48) | RAR 按路径自读(`src/rar_extract.c:1059` 的 `od.ArcName`),回调只收解压后数据,**插不进去** | + +**B. 真已排除**(与格式无关,或已结论) + +| 项 | 为什么排除 | +|---|---| +| 与 7-Zip 比倍数 | 真机上没有可比对象(上游那个 `wfm-7zip-helper.elf` 单独分发,本仓刻意不走 helper 路线);而且**那是 7z 的对比,与 RAR 负载无关** | +| 逐条目 fsync | 已经全部移除(2026-09-16) | +| 「条目太小」 | 1252 条 / 18 GB,平均 14.7 MB,不是小文件场景 | +| 「我们的 RAR 解码器写得慢」 | 解码就是 vendored 的 UnRAR 库本体,我们只剩一层 facade | + +**C. 唯一还站着的、且直接针对真实负载的一项** ⬅ **优先看这个** + +| 项 | 状态 | +|---|---| +| **UnRAR RAR5 多线程解压(`RAR_SMP` + `-lpthread`)** | **❌ 不做(2026-09-23 用户决定,见文首终局块)**。我们没打开它;收益已实测(PC 上 2.45× / 我们引擎 1.40–1.74×)但被「写路径是否为墙」卡住,而验证它的 `T_copy` 未执行 ⇒ 搁置 | diff --git a/docs/REWRITE-FEASIBILITY.md b/docs/REWRITE-FEASIBILITY.md index be4ebd4..a13c309 100644 --- a/docs/REWRITE-FEASIBILITY.md +++ b/docs/REWRITE-FEASIBILITY.md @@ -13,7 +13,7 @@ |---|---| | 上游真的没有许可吗? | **否。上游是 GPL-3.0**,我们自己也是 GPL-3.0,两者一致 | | 现在能合法发布吗? | **能**。GPL-3.0 允许修改和再分发,只需满足归因 + 源码可得 | -| 有没有真实风险? | 有 4 个,全部**可在 1 天内修完**,没有一个需要重写 | +| 有没有真实风险? | 原本 5 个,**4 个已于 2026-09-23 关闭、第 5 个经确认接受现状**(见 §2);**从头到尾没有一个需要重写** | | 全量重写要多少人日? | **35–50 人日**(约 1.4–2 万行需重写,第三方 7.1 万行可直接复用) | | 值得重写吗? | **取决于目标**:想闭源/商用 → 必须重写;想开源分享 → **完全不必** | @@ -57,11 +57,11 @@ LisherSong/ps5-web-file-manager (GPL-3.0) ← 本项目 | # | 风险 | 严重度 | 具体位置 | 修法 | 成本 | |---|---|---|---|---|---| -| 1 | **归因缺失**:README Credits 列了 8 个项目,唯独漏了 `owendswang`;git 历史被重写成 "Initial import",上游作者署名在历史里也找不到 | 🟡 中 | `README.md:436-451` | Credits 加一行 + 补 `NOTICE` 文件 | 1 小时 | -| 2 | **unRAR 与 GPL-3.0 的附加限制冲突**:UnRAR 许可禁止"用于开发 RAR 兼容压缩器",GPL-3.0 §7 禁止附加限制,严格讲不兼容 | 🟡 中 | `third_party/unrar7/` | 见 §2.1 | 0(接受)或 移除 RAR | -| 3 | **ezremote 是 GPLv2**:README 只写 "GPLv2",未标 "or later"。GPLv2-only 与 GPL-3.0 **不兼容** | 🟡 中 | `src/pkg_info.c`(PKG 预览) | 确认其许可措辞;若 v2-only 则重写该模块(591 行) | 1 天 | -| 4 | **二进制分发需提供源码**(GPL §6) | 🟢 低 | Release 里的 ELF | 仓库已公开,Release notes 附仓库链接即可 | 10 分钟 | -| 5 | Title ID `FMGR88888` 与上游相同,可能与他人 payload 冲突 | 🟢 低 | `Makefile:20` | 换一个自定义 ID | 5 分钟 | +| 1 | ~~**归因缺失**~~ → **已修正(2026-09-23)**:README Credits 已补上 `owendswang` 与 rarlab UnRAR / opello 镜像、并把已删除的 `third_party/unrar/` 从 Credits 里清理掉;`THIRD_PARTY_NOTICES` 本就正确。**残留**:git 历史里的 `5cb0b76 Initial import` 无法追溯上游提交 | 🟡 中 → 🟢 低 | `README.md` Credits | 历史归属只能在 Release 说明与 Credits 里声明;如需彻底重建历史得重写仓库 | 已完成 | +| 2 | **unRAR 与 GPL-3.0 的附加限制冲突**:UnRAR 许可禁止"用于开发 RAR 兼容压缩器",GPL-3.0 §7 禁止附加限制,严格讲不兼容 | 🟡 中 | `third_party/unrar7/` | 见 §2.1 | 0(接受) | +| 3 | ~~**ezremote 是 GPLv2**:README 只写 "GPLv2",未标 "or later"。GPLv2-only 与 GPL-3.0 **不兼容**~~ → **已定性并关闭(2026-09-23)**:确为 **GPL-2.0-only**,但逐行比对确认**我们未取其代码** | ✅ 已关闭 | `src/pkg_info.c`(PKG 预览) | 无需重写;README 中英措辞已改准,代码加了来源注记 —— 见 §2.2 | 0 | +| 4 | ~~**二进制分发需提供源码**(GPL §6)~~ → **已补(2026-09-23)**:v1.9.2 Release 说明的 License 段原先只链了**上游**仓库,未链本仓库 | ✅ 已关闭 | Release 里的 ELF | 已用 `gh release edit` 加入 "Corresponding source for this binary" 段(资产未动、仍非 draft/pre、仍是 latest);今后发版沿用 `.build/release-notes-*.md` 模板 | 0 | +| 5 | Title ID `FMGR88888` 与上游相同,可能与他人 payload 冲突 | 🟢 低 | `Makefile:21` | ~~换一个自定义 ID~~ → **2026-09-23 决定维持现状(用户确认)**。核对结论:`TITLE_ID` 只出现在两处 —— `src/app_installer.c:86`(PKG 安装目标目录 `/user/app/`)和 `src/version.c:31`(`/api/version` 上报),**与解压 / 上传 / 浏览功能无关**,且本项目是上游 fork 的直接替代者(同 ID 便于覆盖安装)。代价:与上游 payload **不能共存**,同时装会撞目录 | 0(接受) | ### 2.1 关于 unRAR(风险 2 详解) @@ -77,6 +77,64 @@ UnRAR 许可原文允许"在任何软件中处理 RAR 归档",但**禁止用 **建议 A**。风险等级实际很低:你只做解压不做压缩,本来就不触碰被禁止的那一条。 +### 2.2 关于 ezremote(风险 3 详解,2026-09-23 结案) + +**结论:风险不成立 —— 确为 `GPL-2.0-only`,但我们一行代码都没取。** + +#### 第一步:许可措辞(用 `gh api` 查上游,不是猜) + +``` +gh api repos/cy33hc/ps5-ezremote-client → license.spdx_id = "GPL-2.0" +gh api .../contents/LICENSE → GNU GPL v2 全文(June 1991) +gh api .../contents/source/actions.cpp → 无任何版权 / GPL 声明头 +gh api .../contents/source/clients/*.h → 同上,一个声明头都没有 +``` + +上游**全部源文件都不带许可声明**,唯一的许可陈述就是那份 GPLv2 全文。 +GPLv2 的 "or later" 只能由版权人**明示**授予(LICENSE 全文本身不含该授予), +所以按其自身现状应认定为 **`GPL-2.0-only`**。 + +这一点很关键:**`GPL-2.0-only` 与 GPL-3.0 不兼容**(这正是 FSF 发明 +"GPLv2 or later" 惯例的原因)。所以「到底抄没抄」不是学术问题 —— +抄了就必须重写这个模块。 + +#### 第二步:逐行比对(决定性的一步) + +上游与 PKG 相关的**只有一个文件**:`source/sfo.cpp`(4,209 B / 141 行 / C++)。 +上游**没有 `.pkg` 容器解析器** —— tree 里的 `Ps5_ezRemote_Client_2.00.pkg` +是一个已编译的 payload(10 MB),不是源码。 + +| 维度 | 上游 `sfo.cpp` | 我们的 `src/pkg_info.c` | +|---|---|---| +| 语言 | C++(`reinterpret_cast` / `std::map` / `namespace SFO`) | **C99** | +| 函数分解 | 三个独立函数 `GetString` / `GetParams` / `GetParamsFromParamJson` | 单个 `append_sfo_fields(strbuf_t *, ...)` 直接流式产出 JSON 片段 | +| 返回值 | `std::map` | 写进 `strbuf_t`,无中间容器 | +| JSON | 依赖 **json-c**(`json_tokener_parse` / `json_object_object_get`) | **自写分词器**(`parse_json_tokens()` / `json_object_value()`,在 `json_util.c`) | +| 越界防护 | 仅两处 `size <` 检查,其余裸指针 + `reinterpret_cast` | 逐项校验(`count > SFO_ENTRY_MAX`、`index_end > size`、`key_offset < index_end`、`memchr` 找 NUL、`read_le32` 定长读) | +| 覆盖范围 | 只有 SFO + param.json | 另有 **`.pkg` 条目表**(`PKG_CNT_MAGIC` / FIH / LIH / 条目类型 `0x1000` / `0x1200` / `0x121f` / `0x2000`)、本地化图标选择、两个 HTTP 端点 | + +**唯一重合的是格式事实**:SFO magic `0x46535000`、20 字节头 / 16 字节条目、 +`keyofs`+`nameofs` 与 `valofs`+`dataofs` 的间接寻址。这些是 PS5 文件格式的客观规定, +也是解析它的**唯一办法**(等同合并原则),不构成可保护的表达。 + +⇒ **不存在代码衍生关系**,`src/pkg_info.c` 无需重写。 + +#### 第三步:已落地的处置 + +1. `README.md` / `README.zh-CN.md` 的 Credits 条目:由 "Preview PKG info. License: GPLv2" + 改为明确写出 **GPL-2.0-only、与本项目不兼容、未取其代码**,并指向本节 —— + 后来人不会再把它当成"我们的依赖"去理解授权链。 +2. `src/pkg_info.c` 文件头补了来源注记(含比对理由与本节指引)。 + **纯注释,不改变编译产物** —— 已确认 `src/` 内没有 `__LINE__` / `__FILE__` 依赖, + 注释被预处理器丢弃后目标文件逐字节相同。 +3. 本节即为 provenance 留档。 + +> 适用范围:这套「先查许可措辞 → 再做逐行比对 → 最后把结论写进代码注记」的流程, +> 对任何「README 里 credits 了某个项目」的情形都适用。因为 README 的 Credits 段落里 +> 混着两类东西:**真正 vendored 的代码**(minizip-ng / zlib / UnRAR)和 +> **只是参考了思路的项目**(websrv / ftpsrv / zftpd / etaHEN / ezremote)。 +> 两者在授权义务上完全不同,但排版把它们放在同一张列表里 —— 这就是这个疑问的由来。 + --- ## 3. 代码归属盘点(决定重写成本的关键) @@ -123,7 +181,7 @@ v1.7 之后**新建**的文件(6,745 行),逐个检查其依赖: | 项 | 内容 | |---|---| -| 做什么 | README 补 owendswang 署名、加 NOTICE、确认 ezremote 许可、Release 附源码链接、换 Title ID | +| 做什么 | README 补 owendswang 署名、加 NOTICE、确认 ezremote 许可、Release 附源码链接、换 Title ID(→ 末项已于 2026-09-23 决定维持现状,见风险 5) | | 成本 | **0.5–1 人日** | | 收益 | 合规闭环,零功能损失,保留全部现有能力 | | 风险 | 无 | @@ -182,7 +240,7 @@ v1.7 之后**新建**的文件(6,745 行),逐个检查其依赖: | 4 | `main.js` 2,652 行单文件,无模块拆分 | `assets/main.js` | 按 view / api / task 拆模块 | | 5 | `filemgr.c` 2,458 行,路由 + 业务逻辑 + 平台调用混在一起 | `src/filemgr.c` | 分 handler / service / platform 三层 | | 6 | 测试靠手工脚本,未接入 `make test` | `tests/` | 接 CI,覆盖率可量化 | -| 7 | 唯一功能缺口:7z `-mhe=on` 加密头 | `sevenz_extract.c` | 重写时一并补上(工作量约翻倍于现有 7z 头解析) | +| 7 | ~~唯一功能缺口:7z `-mhe=on` 加密头~~ **已闭合**(2026-09-23,`src/sevenz_header.c`) | `sevenz_extract.c` | 无剩余格式缺口;重写时该项可删 | --- @@ -206,8 +264,11 @@ v1.7 之后**新建**的文件(6,745 行),逐个检查其依赖: + zipx_volstream.{c,h} 抽成独立仓库,MIT 授权,主项目作为 submodule 引用。 → 3,661 行成果立刻获得独立身份,且证明这部分是你的原创。 -3. 确认 ezremote 是 "GPLv2" 还是 "GPLv2 or later"; - 若是 v2-only,重写 pkg_info.c(591 行)或改用别的数据源。 +3. ~~确认 ezremote 是 "GPLv2" 还是 "GPLv2 or later";若是 v2-only,重写 pkg_info.c~~ + → **已结案(2026-09-23)**:是 `GPL-2.0-only`,但逐行比对确认**我们未取其代码**, + **无需重写**。比对记录与处置见 §2.2。 +4. ~~v1.9.2 Release 说明补本仓库链接(GPL §6 源码提供义务)~~ + → **已补(2026-09-23)**,见风险 4 行。 ``` ### 6.3 需要你回答的问题 diff --git a/docs/SIZE-OPTIMIZATION.md b/docs/SIZE-OPTIMIZATION.md index 5a70071..c1ae31c 100644 --- a/docs/SIZE-OPTIMIZATION.md +++ b/docs/SIZE-OPTIMIZATION.md @@ -207,6 +207,9 @@ LDFLAGS += -Wl,-z,pack-relative-relocs `aeshe` 仍是已知的 `-mhe=on` 缺口。README / CHANGELOG / HANDOVER / 论坛帖里的 产物指纹已同步为 870,488 B · sha256 `177e90fe…8e84`。 +> 后续(2026-09-23):测试计数已变为 ZIP 140 + RAR 37 = 177(7z 套件 27 用例), +> `aeshe` 缺口也已闭合;产物指纹随之更新。本节保留的是上面的历史测量值。 + --- ## 附录 A:v1.9.2 产物一致性验证(2026-09-20) diff --git a/docs/UPGRADE-v1.7-zip-large-file-profile.md b/docs/UPGRADE-v1.7-zip-large-file-profile.md index cc20ec0..dd4c63b 100644 --- a/docs/UPGRADE-v1.7-zip-large-file-profile.md +++ b/docs/UPGRADE-v1.7-zip-large-file-profile.md @@ -124,6 +124,9 @@ zipx_limits_profile(int profile) { Each entry is first written to a staging directory (`*.wfm-part-*`), `fsync()`'d, then atomically renamed into place. A failure mid-archive rolls back partial changes. + *(Superseded 2026-09-16: the per-entry `fsync` was removed — all three + engines now apply "sync nothing, rename everything". See + `docs/EXTRACTION-PERF.md`.)* * Security checks run before any output file is opened: - encryption - path traversal (`..`), absolute POSIX paths, Windows drive letters diff --git a/docs/UPGRADE-v1.8-rar-support.md b/docs/UPGRADE-v1.8-rar-support.md index 0e2d8c2..05c36bb 100644 --- a/docs/UPGRADE-v1.8-rar-support.md +++ b/docs/UPGRADE-v1.8-rar-support.md @@ -144,6 +144,13 @@ progress callbacks and result-mapping logic are copied from engines can evolve independently. (Refactoring them into a `src/archive_common/` module is on the post-v1.9 roadmap; see §10.) +> **Note — superseded 2026-09-16.** The per-entry `fsync` described above was +> removed. It cost 20–30 minutes on a 95k-file archive and bought nothing the +> design needs: a crash mid-extract leaves the staging tree, which is discarded +> on the next run, and publish is a rename-only phase. All three engines now +> share the same "sync nothing, rename everything" policy. Measurements and the +> accepted durability trade-off: `docs/EXTRACTION-PERF.md`. + ### 3.3 Error mapping `rar_translate_error()` in `src/rar_extract.c` maps the dmc_unrar diff --git a/docs/UPSTREAM-V1.8-COMPARISON.md b/docs/UPSTREAM-V1.8-COMPARISON.md index df27b14..29f7df0 100644 --- a/docs/UPSTREAM-V1.8-COMPARISON.md +++ b/docs/UPSTREAM-V1.8-COMPARISON.md @@ -112,7 +112,7 @@ README 原文: | 压缩比筛查 | ✅ `max_ratio` 500/1000,**1 GiB 下限豁免**小文件 | `zip_extract.c` | | 磁盘空间预检 | ✅ `check_space()` 按**解压后总量**查 `statvfs` | `zip_extract.c:636` | | 路径穿越防护 | ✅ 有专项测试(`path traversal variants`) | 测试矩阵 | -| 原子发布 | ✅ staging 目录 + 整 rename + 每 entry fsync | 三引擎统一 | +| 原子发布 | ✅ staging 目录 + 整 rename(**无逐条目 fsync**,2026-09-16 起) | 三引擎统一 | | 冲突策略 | ✅ FAIL / OVERWRITE / MERGE,目录碰撞递归下钻 | 三引擎统一 | | 取消 | ✅ 条目粒度 | — | | 任务恢复 | ❌ **没有** | — | diff --git a/docs/USER-GUIDE-zh-CN.md b/docs/USER-GUIDE-zh-CN.md new file mode 100644 index 0000000..480ca16 --- /dev/null +++ b/docs/USER-GUIDE-zh-CN.md @@ -0,0 +1,224 @@ +
+ 简体中文 · 开发者文档见 README +
+ +# PS5 网页文件管理器 · 新手使用说明 + +> 适用版本:**v1.9.3M**(版本号末尾的 `M` = 改版,文末有解释) +> 本文不假设你懂任何技术名词,照着做即可。 + +--- + +## 一、它到底是什么 + +一句话:**在你的 PS5 上开一个"网页版文件管理器"**。只要设备和 PS5 连着同一个 WiFi,用手机、电脑、甚至 PS5 自带的浏览器打开一个网址,就能像在电脑上一样管理 PS5 里的文件和插在 PS5 上的 U 盘。 + +### 能做的事 + +| 想干什么 | 可以吗 | +|---|---| +| 看文件、建文件夹、改名、复制、移动 | ✅ | +| 电脑 ↔ PS5 互传文件 | ✅ | +| 解压 ZIP / RAR / 7z(**带密码的也行**) | ✅ | +| 改小的文本文件(`.txt` `.json` `.ini` 等) | ✅ | +| 看图片(`.png` `.jpg` `.gif` `.webp` 等) | ✅ | +| 安装 PKG | ✅ | +| 改文件权限 | ✅ | + +### 不能做的事(先说清楚,省得白试) + +| 想干什么 | 可以吗 | 说明 | +|---|---|---| +| 把一堆文件**打包**成压缩包 | ❌ | 它只会"解",不会"压"。下载多个文件时会自动打成一个 `.tar` 包,那只是下载用的 | +| 同时做两件事 | ❌ | 一个任务在跑的时候,其它操作会被拒绝(提示"有任务正在执行") | +| 删错了找回 | ❌ | 删除是**永久**的,没有回收站 | +| 解压 ZIP / RAR / 7z **以外**的格式 | ❌ | 比如 `.tar.gz`、`.iso`、`.xz` 现在不行,见第七节 | + +--- + +## 二、三步把它跑起来 + +**第 1 步:PS5 上先运行一个"ELF 加载器"**(常见端口是 `9021`)。这一步取决于你用的越狱方案,这里不展开。 + +**第 2 步:在电脑上把程序发过去。** 打开终端(Windows 用 Git Bash / PowerShell 都行),执行: + +```sh +nc -q0 你的PS5的IP 9021 < web-file-mgr-v1.9.3M.elf +``` + +例如你的 PS5 是 `192.168.1.50`: + +```sh +nc -q0 192.168.1.50 9021 < web-file-mgr-v1.9.3M.elf +``` + +**第 3 步:看 PS5 左上角的通知。** 它会显示程序名、版本和一个网址,通常是: + +```text +http://192.168.1.50:8888/ +``` + +在浏览器里打开这个网址就行。**如果通知显示的端口不是 `8888`(比如 `8889`),以通知为准** —— 端口不是写死的,程序会自动挑一个能用的。 + +> 第一次运行时,它还会在 PS5 主屏装一个「PS5 Web File Manager」快捷方式(Media 分类)。已有的不会被覆盖。 + +--- + +## 三、界面上都是些什么 + +打开页面后大致是这样: + +- **最上面 / 侧边**:选"去哪儿"。会看到 **内部存储**、**USB存储**、**M2扩充存储**、**扩展存储**、**根分区** 这几项,点哪个就进哪个。 +- **中间的大列表**:当前文件夹里的内容,可以按名称、类型、大小、修改时间、权限排序(排序方式会被记住)。 +- **列表每一行的按钮**:下载、解压(压缩包才有)、安装(`.pkg` 才有)、文本编辑、改权限、删除等。 +- **上方工具条**:复制、移动、重命名、下载、删除、**解压**、上传(点开选"上传文件 / 上传文件夹")、新建目录、新建文本、刷新、退出。 + - **解压按钮一直都在**,只是没选中压缩包时是**灰色**的、点不动。选中**一个**压缩包(`.zip` / `.rar` / `.7z`)它才会变亮可点。鼠标停在灰色按钮上会告诉你为什么不能点。 +- **右下角**:版本号(这里应该显示 `v1.9.3M`)。 + +界面截图(点击看大图): + +

+ 界面截图 1 + 界面截图 2 + 界面截图 3 +

+ +> 小提示:用 **PS5 自带浏览器** 打开时,"上传"和"下载"按钮是隐藏的(PS5 浏览器不支持);要用电脑或手机浏览器才能传文件。 + +--- + +## 四、六个最常用操作 + +### 1. 把电脑上的文件传到 PS5 + +1. 用**电脑或手机**浏览器打开那个网址(不要用 PS5 浏览器)。 +2. 进入你想放到的文件夹。 +3. 点 **上传**,在弹出的列表里选 **上传文件**(可多选)或 **上传文件夹**(整个目录一起传)。 +4. 也可以**直接把文件或文件夹拖进网页**——页脚那行灰字就是提醒这件事的。拖进来后会提示"松开即上传到当前目录"。 +5. 看到全屏进度条就是开始传了,可以随时**取消**。 + +### 2. 在 PS5 内部搬运文件(比如 U 盘 → 内置存储) + +1. 选中要搬的项目 → 点 **复制**(保留原文件)或 **移动**(不保留)。 +2. 进入目标文件夹 → 点 **粘贴**。 +3. 会有几秒到几十秒的"准备中"(它在统计大小和检查目标空间够不够),别急。 + +### 3. 解压一个压缩包 + +1. 在列表里找到那个包,点它右边的 **解压**。 +2. 问你"若目标已存在同名文件或目录"时: + - **确定** = 同名文件覆盖掉(同名文件夹会合并进去) + - **取消** = 只要目标已经有同名东西就**直接失败**(这是默认,最安全) +3. 如果包是加密的,会弹出密码框,输入密码即可。**密码错了会让你重填,最多 3 次。** + - 上传压缩包时如果你选了"上传后自动解压",加密包**会自己弹密码框**,不用先手动点一次"解压"。 + - 第一次弹框写的是"此压缩包已加密"(因为还没输过密码);之后才写"密码不正确"。 + +> ⚠️ **强烈建议每次解压都新建一个空文件夹作为目标。** 已知问题:往同一个文件夹里**第二次**用"覆盖"解一个含文件夹的压缩包,会失败。换个空文件夹就没事。 + +### 4. 分卷压缩包怎么解 + +一个文件被切成好几段的那种(比如 `xxx.part1.rar` / `xxx.part2.rar`,或 `xxx.z01` + `xxx.zip`,或 `xxx.7z.001` …): + +- **RAR:必须点第一个分卷**(`xxx.part1.rar` 或 `xxx.part01.rar`)。点到后面的卷,按钮是灰的,会提示"请改选主卷"。 +- **ZIP / 7z:点第一卷即可**,其余会自动接上。 +- **整卷必须在同一个文件夹里**,少一个都会失败(提示"压缩包损坏或不完整")。 + +### 5. 改一个文本文件 + +点文本文件右边打开编辑器即可。限制:文件 **小于 1 MiB**,且是纯文本(UTF-8)。太大或不是文本会被拒绝。 + +### 6. 安装 PKG + +点 `.pkg` 文件右边的 **安装**,会先显示这个包的标题等信息,确认后提交给系统安装。 + +--- + +## 五、解压的"规矩"(为什么有的包不给解) + +它不是拿到包就解,而是**先检查**,不符合规矩会直接拒绝。这是为了保护你的 PS5 不被一个恶意压缩包搞崩。默认规矩: + +| 规矩 | 上限 | +|---|---| +| 包里的文件/文件夹数量 | 20 万个 | +| 解压后的总大小 | 2 TiB | +| 单个文件最大 | 512 GiB | +| 压缩比(解压后 ÷ 压缩包) | 500 倍 | +| 文件夹最多嵌套多少层 | 32 层 | + +**包特别大时**(磁盘上超过 480 GiB),它会问你要不要开「大文件模式」:开了之后上限放宽到 50 万个 / 4 TiB / 单个 1 TiB / 1000 倍。 + +**还有一条最容易踩的:硬盘要留出大约"两份"空间。** 它是先把东西解到一个临时地方,确认全部成功后才整体搬过去 —— 所以一个解压后 50 GB 的包,你得有大约 100 GB 可用空间。空间不够会直接告诉你"需要 X,可用 Y"。 + +--- + +## 六、看到这句话,是什么意思 + +| 界面上显示 | 说人话 | 怎么办 | +|---|---|---| +| **密码错误,或压缩包未使用所提供的密码加密** | 密码不对,或者这个包根本没加密你却填了密码 | 重填(最多 3 次);确认密码大小写 | +| **此压缩包已加密,密码不正确** | 同上,重试提示 | 输入正确密码,或取消 | +| **目标空间不足,需要 X,可用 Y** | 硬盘不够 | 记住要留**两份**空间;删东西或换更大的盘 | +| **压缩包内单个文件过大** | 包里有个超大文件,超过默认 512 GiB | 出现提示时选「大文件模式」;或拆包 | +| **压缩比异常(疑似压缩炸弹)** | 一个很小的包声称能解出一大堆东西,被判定为危险 | 基本是恶意包,别解 | +| **目标已存在同名文件或目录** | 目标位置已经有同名东西了 | 换一个空文件夹(推荐),或在提示时选"确定"覆盖 | +| **压缩包损坏或不完整** | 包坏了,或分卷少了一卷 | 重新下载/拷贝,确认所有分卷都在同一目录 | +| **不支持的压缩包** | 不是 ZIP / RAR / 7z,或用了它处理不了的特性 | 在电脑上解好再传 | +| **压缩包需要的字典超出本机可承受范围** | 极少数用超大设置压的 RAR,PS5 解不动 | 在电脑上用普通设置重新压一次 | +| **请改选主卷** | 你点的是分卷的后面几卷 | 点第一个分卷(`.part1.rar` / `.part01.rar`) | +| **有任务正在执行** | 已经有一个活儿在干 | 等它结束,或先取消它 | +| **操作失败** | 其它错误 | 记下原文,反馈时带上 | + +--- + +## 七、和原版(上游)有什么不一样 + +这个程序是从开源项目 **owendswang/ps5-web-file-manager** 改来的。下面是和你有关的差别,说人话: + +| 对你意味着什么 | 原版(上游 v1.8) | 本版(v1.9.3M) | +|---|---|---| +| **要装几个东西** | **两个**:主程序 + 一个上百 MB 的 7-Zip 辅助程序,还得放到固定目录 `/data/wfm/`。**辅助文件丢了,解压功能直接全废** | **就一个文件**,拷上去就能用 | +| **能解多少种格式** | **约 30 种**(`.tar.gz` `.xz` `.cab` `.iso` 类……) | **3 种**:`.zip` `.rar` `.7z` | +| **带密码的压缩包** | 靠 7-Zip 支持 | **三种格式都支持**,密码错了会弹框让你重填(最多 3 次) | +| **RAR 分卷** | 支持 | 支持(要点第一个分卷) | +| **防"压缩炸弹"/防硬盘写满** | ❌ **没有**,交给 7-Zip 自己看着办 | ✅ 有:压缩比筛查、提前算空间够不够 | +| **失败后会不会留下一堆半成品** | 会(直接解到目标目录,中断就留在那儿) | 不会:先解到临时地方,全部成功才整体搬过去,失败自动清理 | +| **程序被重启后,正在解的任务** | 还能接着跑(辅助程序是独立进程) | **会丢**(关掉浏览器再打开还能看到进度,但程序本身重启就没了) | +| **出错时的提示** | 比较笼统 | 会告诉你具体是哪个文件、差多少字节 | +| **版本号长什么样** | `v1.8`、`v1.9` 这样纯数字 | **`v1.9.3M`**,末尾多一个 `M` | + +### 一句话总结 + +- **原版赢在"格式多"**:`.tar.gz`、`.xz` 这类它也能解,本版不行。如果你经常遇到这三种以外的格式,原版更方便。 +- **本版赢在"省心和安全"**:一个文件就完事,不会因为你少放一个辅助文件就整个不能用;也不会被一个恶意压缩包写满你的内置存储、或者在半路留一堆垃圾。 + +### `M` 是什么意思 + +版本号末尾的 **`M`** = **Modified(改版)**。上游原版是纯数字(如 `v1.8`),所以: + +- 看到 **`v1.9.3M`** → 这是本仓的改版 +- 看到 **`v1.9.3`**(没有 M)→ 那不是本仓出的 + +这个字母会同时出现在:文件名、PS5 启动通知、网页右下角。网页右下角把鼠标**悬停**在版本号上,会弹出说明文字。 + +--- + +## 八、怎么确认你装的是哪一版 + +| 看哪里 | 应该显示 | +|---|---| +| 网页右下角 | `v1.9.3M` | +| PS5 启动时的通知 | 程序名 + `v1.9.3M` + 监听端口 | +| 浏览器打开 `http://:<端口>/api/version` | JSON 里的版本号 = `v1.9.3M` | + +**只要界面上显示带 `M` 的版本号,就说明装对了。** + +--- + +## 九、几条重要提醒 + +1. **解压时别断电、别重启 PS5。** 它为了速度不做逐文件强制落盘,如果在最后搬运阶段断电,可能出现"文件在,但内容不完整"。 +2. **解压前确认空间够**,而且是**两份**(见第五节)。 +3. **每次解压用新的空文件夹**,别往同一个目录连解两次(见第四节第 3 条)。 +4. **删除不可恢复。** 删文件夹会把它里面所有东西都删掉,弹窗会提醒。 +5. **一次只做一件事**,有任务在跑时其它操作会被拒绝。 +6. 这是自制程序。如果遇到 PS5 内核崩溃,请换更新的越狱方式 / ELF 加载器,或回到你常用的稳定方案。 diff --git a/docs/archive/HANDOVER-v1.8-planning.md b/docs/archive/HANDOVER-v1.8-planning.md new file mode 100644 index 0000000..1d7e070 --- /dev/null +++ b/docs/archive/HANDOVER-v1.8-planning.md @@ -0,0 +1,1714 @@ +# PS5 Web File Manager — 项目工作方案 + +> **目的**:让任何接手人拿到这份文档 + 项目仓库,都能独立推进 v1.8(RAR 支持)开发。 +> **读者**:开发者,需要熟悉 C99 + POSIX + 浏览器 JS,但不要求懂 PS5 SDK。 +> **撰写日期**:2026-09-04 +> **对应代码版本**:`aef4a44`(v1.7 + 文档配套提交) + +--- + +## 0. 阅读顺序建议 + +如果你时间紧: +1. 先看 §1「项目是什么」和 §4「当前进度」 → 5 分钟建立全局观 +2. 跳到 §6「v1.8 详细计划」按天执行 +3. 卡住了回 §7「关键代码模板」找参照实现 +4. 出 bug 翻 §9「坑清单」 + +如果你要 review 整套设计: +1. §1 → §3 → §6 → §7 → §8 顺序读完 + +--- + +## 1. 项目是什么 + +**PS5 Web File Manager** 是个跑在越狱 PS5 上的 HTTP 文件管理 payload。 +- 用户从 PS5 浏览器打开 `http://PS5-IP:8080` → 看到 PS5 文件系统 +- 可以浏览 / 上传 / 下载 / 编辑文本 / 解压 ZIP / 安装 PKG +- 单文件 ELF(`web-file-mgr.elf`),无外部服务依赖 + +**关键事实**(会影响你所有设计决策,先背下来): +- **CPU**:AMD x86-64 Zen 2(**不是** ARM),目标 triple `x86_64-sie-ps5` +- **SDK**:vendored 在 `/opt/ps5-payload-sdk/`,未提供 homebrew 库目录(你不能 `pkg-config --libs libxxx`,只能 vendor C 源码自己编) +- **体积红线**:单 ELF 通常不超 1 MiB,当前 v1.7 = 418 KiB +- **网络环境**:通常在 LAN 内,HTTPS 不强制(自签证书) + +**代码托管**:`https://github.com/owendswang/ps5-web-file-manager.git` +**License**:GPLv3+(注意:unrar 的 license 是「UnRAR license」,**不能改装**,只能直接用 —— 见 §9.1) + +--- + +## 2. 仓库与构建环境 + +### 2.1 本地路径 + +``` +Windows: C:\Users\songl\Desktop\Web File Manager\ps5-web-file-manager\ +WSL: \\wsl$\Ubuntu-22.04\home\song\ps5-web-file-manager\ (同一份文件) +PS5: /mnt/usb0/... (部署目标) +``` + +### 2.2 关键工具位置 + +| 工具 | 位置 | 用途 | +|---|---|---| +| WSL bash | `wsl -d Ubuntu-22.04` | 唯一能跑 PS5 交叉编译的地方 | +| 编辑器 | 本机任意 | VS Code 推荐,记得保存用 LF(`.gitattributes` 不强制) | +| prospero-clang | `/opt/ps5-payload-sdk/bin/prospero-clang` | PS5 交叉编译 wrapper,自动注入 `-target x86_64-sie-ps5` | +| prospero-strip | `/opt/ps5-payload-sdk/bin/prospero-strip` | strip 工具 | +| 构建脚本 | `/home/song/build-elf.sh` (WSL) + `.build/build-elf.sh` (副本) | 完整构建 + 落双路径 | + +### 2.3 双身份坑(必读) + +> ⚠️ **WSL 里有两种身份:真 bash 用户 vs SMB 视图用户** +> - 真 bash 用户:`song(1000)`,写 `/opt/ps5-payload-sdk/...` 需要 sudo +> - SMB 视图用户:`songl(197609)`,但**不能写** `/opt/...`(即使 sudo) + +SDK 的 `target/user/homebrew/` 被 `songl(197609)` 拥有 755,普通 song 写不进。 +**正确做法(构建脚本 v5 已实现)**:staging 模式 → 先 make install 到 `/tmp/` → sudo cp -r 到 SDK。 +**不要尝试** `sudo chmod` SDK 目录,会破坏其他项目。 + +### 2.4 我的 Bash 工具是 MinGW64,不是 WSL + +- `uname -a` = `MINGW64_NT-10.0` +- 看 WSL 真实状态用 `\\wsl$\Ubuntu-22.04\` 路径前缀 +- 但**修改 WSL 文件**只能用 PowerShell 走 `C:\Users\songl\Desktop\...` 路径,不能用 `wsl.exe`(沙箱黑名单) + +--- + +## 3. 已完成功能(v1.7 = `5cb0b76` + `aef4a44`) + +### 3.1 ZIP 解压 + 大文件模式 + +> 用户场景:解压一个 200GB 系统镜像到 PS5。默认 limits 不够 → 加 🅱 "大文件模式"开关。 + +**完整链路**(每层都改了,记得按层理解): + +``` +┌────────────────────────────────────────────────────────────────┐ +│ Frontend: assets/main.js │ +│ - LARGE_FILE_THRESHOLD_BYTES = 240 GiB (硬编码) │ +│ - shouldPromptLargeMode(itemSize) → 弹 confirm │ +│ - startExtractTask(path, dst, conflict, remove, name, large) │ +└────────────────────┬───────────────────────────────────────────┘ + │ POST /api/extract + │ form fields: {path, dst_dir, conflict, + │ remove_source, large} +┌────────────────────▼───────────────────────────────────────────┐ +│ Task layer: src/extract.c │ +│ - api_extract 解析 large 字段 → task->extract_large │ +│ - extract_worker 用 zipx_limits_profile(task->extract_large) │ +│ - file_task_t 新增字段 extract_large (int) │ +└────────────────────┬───────────────────────────────────────────┘ + │ +┌────────────────────▼───────────────────────────────────────────┐ +│ Engine: src/zip_extract.{h,c} │ +│ - 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() 签名不变,向后兼容 │ +└────────────────────────────────────────────────────────────────┘ +``` + +**三阶段流程**(所有 archive 引擎共享的设计): +1. **scan** —— 读所有 entry header,只校验 name 安全 + 累加 bytes 估算 +2. **extract** —— 写到 staging `.wfm-part-{pid}-{taskid}/`,fsync 每个文件 +3. **publish** —— 整 staging rename 到目标,处理 conflict 策略 +4. **cleanup** —— 任何阶段失败都 unlink staging + +**i18n**: +- `assets/lang-en.js` 和 `lang-zh.js` 新增 `extractLargeAsk` / `extractLargeActive` + +**前端 UX**: +- ZIP 大于 480 GiB 时弹窗「启用大文件模式?」 +- 用户点 OK → 传 `large=1` → 引擎走 large profile +- 用户点取消 → 走 default profile(多半会被拒绝) + +### 3.2 文档与仓库 + +| 文件 | 行数 | 说明 | +|---|---|---| +| `README.md` | 267 | GitHub-grade 项目说明:版本标签、Features 分类、ZIP extraction 段含 limits 表、Verification、Tests、Project layout、FAQ | +| `CHANGELOG.md` | 107 | Keep-a-Changelog 格式,v1.7 段 | +| `docs/UPGRADE-v1.7-zip-large-file-profile.md` | 448 | 维护者技术手册,含架构图、源码引用、覆盖矩阵、Trade-offs、Roadmap | +| `.build/extract-demo.html` | - | 交互式解压演示页(场景 3 表格已包含 large profile 行) | +| `.build/check-elf-gzip.py` | - | ELF 内 gzip 资产串验证脚本(确认前端改动进了 ELF) | + +### 3.3 测试覆盖 + +``` +tests/run-tests.sh → 69 checks, 0 failures + +覆盖范围: +- ZIP 基础:basic / stored / unicode names / zip64 / traversal / backslash +- 安全:traversal / absolute path / drive letter / symlink entry / fifo entry +- 格式错误:encrypted / bad_crc / truncated / not_a_zip +- 资源限制:ratio cap / file cap / total cap +- 边界:duplicate / file_dir_clash / conflict_source +- 大文件:large profile (16 个 check) — 含 default 拒 medium_bomb / large 接 / 降低 large 阈值仍生效 +``` + +**fixture 经验**(写测试时会用到): +- 4 MiB of 'A' 实际 ratio ≈ **1026**(不是想象中的 ≈1000),所以 `bomb.zip` 连 large profile 都拒 +- `bytes(range(256)) * 4096` (1 MiB) → ratio ≈ **238**,正好夹在 (200, 1000] 中间 → **stable fixture** +- 1 MiB "ABCD" * 256K → ratio ≈ 1004(擦边不稳);random 3-bit → ratio ≈ 2(太低) + +### 3.4 Git 状态 + +``` +5cb0b76 Initial import of PS5 Web File Manager v1.7 +aef4a44 Add v1.7 changelog and technical upgrade notes +main branch → origin https://github.com/owendswang/ps5-web-file-manager.git + +⚠️ sandbox 推 github.com 被代理拦截(502 from CONNECT tunnel) + → 用户在自己 PowerShell 跑 `git push -u origin main` + → 详见 MEMORY.md「GitHub 推送的网络环境」 +``` + +--- + +## 4. 当前进度状态(截至 2026-09-05 15:30 — v1.8 收尾) + +| 项 | 状态 | 备注 | +|---|---|---| +| v1.7 ZIP 大文件模式 | ✅ 完成 + 文档 + 已部署 | 69 checks pass | +| v1.7 仓库初始化 | ✅ 本地 5 commit | 待 push(用户侧) | +| v1.7 文档(README/CHANGELOG/UPGRADE) | ✅ 完成 | | +| **v1.8 RAR 支持(单卷 明文)** | ✅ **实施完成** | dmc_unrar 1.7.0 后端 | +| v1.8 文档(CHANGELOG/README/UPGRAGE-v1.8) | ✅ 完成 | 见 §14 | +| v1.8 前端(解压按钮 + 子卷置灰 + i18n) | ✅ 完成 | assets/main.js + lang-{en,zh}.js | +| v1.8 host tests | ✅ 83 checks, 0 failures | 69 ZIP + 14 RAR | +| v1.8 ELF 重编(WSL) | ⏸ 待用户在 WSL 跑 | 网络/SDK 受限无法在沙箱完成 | +| 推送 GitHub | ⏸ 待用户在 PowerShell 跑 | 沙箱 git 502(见 §9) | + +**v1.8 范围变更(vs §5 原计划)**: + +| 原计划 | 实际交付 | 原因 | +|---|---|---| +| ✅ RAR 单卷(明文) | ✅ RAR 单卷(明文) | 实施完毕 | +| ❌ RAR 分卷(RAR5 .partNN.rar 链式) | ❌ → 拒绝 + 弹 tooltip | dmc_unrar 不支持分卷 | +| ❌ 加密 RAR(密码弹窗) | ❌ → 拒绝 + 无 UI | dmc_unrar 不支持加密 | +| ❌ ZIP 分卷 | ❌ | 用户说不需要(未做) | +| ❌ 7z / tar / 其他格式 | ❌ | 范围外 | + +**v1.8 范围比原计划小,但工程更扎实**: +- 加了 facade header 模式(dmc_unrar_api.h),让 vendor 的 .c 永远独立编、不被 host shim 污染 +- 把 §6 的"工程决策"全做了:dispatch 加 magic 不只为扩展名、共用 staging / fsync / publish +- v1.9 升级路径明确(VENDORED.md 5 步 + UPGRADE-v1.8 §10.1) + +**遗留 → v1.9**: +- vendor 切 opello/unrar,加多卷 + 加密支持 +- 把 zip_extract / rar_extract 共用的 staging / publish / nameset / report 等抽到 `src/archive_engine_common.c` +- ELF 真机部署(ZIP 当前用户路径没加密也是 v1.9 优先,因为单卷 RAR 同样不在加密范围) + +详见 §14「v1.8 实际交付状态」。 + +**v1.8 需求范围(用户已确认)**(v1.7 原始 §6 中的待开工项): +- ✅ RAR 单卷(明文) +- ✅ RAR 分卷(仅 RAR5 `name.partNN.rar` 新格式) +- ✅ 加密 RAR(密码弹窗) +- ❌ ZIP 分卷(用户说"不需要") +- ❌ 7z / tar / 其他格式 + +--- + +## 5. v1.8 范围与目标 + +### 5.1 功能列表 + +``` +- /api/extract 支持 RAR 格式 +- 自动派发:扩展名 + magic (Rar!\x1a\x07\x00 / Rar!\x1a\x07\x01\x00) +- RAR 单卷解压 +- RAR 分卷自动识别(unrar 内部处理,前端只识别主卷) +- 加密 RAR:探测 → 弹密码窗 → 错误重试 +- 前端:选中 RAR 主卷显示解压按钮,子卷置灰 + tooltip +``` + +### 5.2 不做(明确划线) + +| 不做 | 原因 | +|---|---| +| ZIP 分卷 | 用户明确不要 | +| 7z / tar / 其他格式 | 范围外 | +| RAR 创建(压缩) | 当前只解压 | +| 密码保存/记忆 | 安全 + UX 一致性 | +| ZIP 加密(ZIP AES) | 单独迭代 | +| 区分「密码错」vs「文件损坏」 | 防侧信道,统一文案 | + +### 5.3 工作量预估 + +| 模块 | 行数 | +|---|---| +| `third_party/unrar/` vendor | ~7K | +| `src/rar_extract.{h,c}` | ~450 | +| `src/extract.c` 改造 | ~150 | +| `src/filemgr_internal.h` | ~10 | +| `assets/main.js` | ~120 | +| `assets/main.css` | ~40 | +| `assets/lang-{en,zh}.js` | ~30 | +| `tests/test_rar_extract.c` | ~400 | +| `tests/fixtures/` | ~12 个 | +| 文档 + CHANGELOG | ~200 | +| **总计** | **~1380 新增** | + +**ELF 预估**:v1.7 = 418 KiB → v1.8 ≈ **580 KiB** +**工期**:5~6 天集中开发 + +--- + +## 6. v1.8 详细实施计划 + +> 按天执行。每步都有「验证」一项,写完立即跑。 + +### D1:vendor unrar + 写 rar_extract 骨架 + +#### 6.1.1 拉 unrar 源码 + +```bash +cd /home/song/ps5-web-file-manager/third_party/ +git clone https://github.com/alexbatalov/unrar.git +# 检查 +ls unrar/ +# 期望:unrar.c + unrar.h + README + LICENSE(UnRAR license) +``` + +**注意事项**: +- alexbatalov 版是纯 C、单文件,**不要用 winrar/unrar 官方版**(license 严格,商用改装禁) +- 不要改装 unrar.c(license 限制),需要时通过 wrapper 函数扩展 +- 如需 ASAN / fuzzer 友好的版本,可考虑分叉,但要保留 license 文件 + +#### 6.1.2 验证 unrar 在 host 上能编 + +```bash +cd /home/song/ps5-web-file-manager/third_party/unrar/ +cc -O2 -o test-unrar unrar.c -DUNRAR_TEST_MAIN # 临时 main 跑一遍 +# 或者写个测试:建 test.rar → 调用 unrar API 解出来 +``` + +确认 API 形态: +- `RARHeaderDataEx` / `RAROpenArchiveEx` / `RARReadHeaderEx` / `RARProcessFile` / `RARSetPassword` / `RARCloseArchive` + +#### 6.1.3 写 rar_extract.h 公共 API + +**模板**:直接抄 `src/zip_extract.h` 的命名空间 `zipx_` 改为 `rarx_`,但复用 `zipx_status_t` 枚举。 + +```c +/* src/rar_extract.h */ +#pragma once +#include "zip_extract.h" /* 复用 zipx_limits_t / zipx_status_t */ + +typedef struct { + char password[128]; /* UTF-8 / ASCII 密码 */ + int password_set; /* 0 = 不设, 1 = 调用方已设置 */ +} rarx_password_t; + +typedef struct { + zipx_status_t status; + int sys_errno; + uint64_t entries_total; + uint64_t entries_done; + uint64_t bytes_total; + uint64_t bytes_done; + uint64_t files_created; + uint64_t dirs_created; + int has_password; /* scan 阶段探测到加密 → ERR_PASSWORD */ + char detail[ZIPX_PATH_MAX]; + char message[192]; +} rarx_result_t; + +/* password 可为 NULL(明文 RAR);结果总是写入 *result */ +zipx_status_t rarx_extract(const char *first_volume_path, + const char *dst_dir, + zipx_conflict_t conflict, + const zipx_limits_t *limits, + const rarx_password_t *password, /* 可 NULL */ + zipx_cancel_fn cancel, + zipx_progress_fn progress, + void *userdata, + rarx_result_t *result); +``` + +#### 6.1.4 rar_extract.c 三阶段骨架 + +**关键**:scan 与 extract 都要扫描所有 entry(unrar 的 API 不能 list-only),所以 scan 阶段**会读到文件内容但丢弃**,产生约 N×entry 大小的 I/O 成本。 + +> 💡 **性能优化**:unrar 提供 `RARExtractChunk` API,可以一次解 N 字节立刻写出,避免 staging 暂存。**第一版不用**,可读性 + 测试性更重要。 + +#### 6.1.5 关键代码模式(scan 阶段) + +```c +static zipx_status_t +rarx_scan(const char *first_path, const zipx_limits_t *limits, + rarx_result_t *result) { + struct RARHeaderDataEx hdr; + struct RAROpenArchiveDataEx arc; + memset(&arc, 0, sizeof(arc)); + arc.ArcName = (char*)first_path; + arc.OpenMode = RAR_OM_LIST; /* list-only 模式不解压 */ + HANDLE h = RAROpenArchiveEx(&arc); + if (arc.OpenResult != 0) { + result->status = ZIPX_ERR_FORMAT; + return ZIPX_ERR_FORMAT; + } + + uint64_t total_bytes = 0; + uint32_t total_entries = 0; + int max_depth = 0; + result->has_password = 0; + + while (RARReadHeaderEx(h, &hdr) == 0) { + /* 加密探测 */ + if (hdr.Flags & (LHD_PASSWORD /* RAR4 */) || + (hdr.Flags & 0x0004 /* RAR5 encrypted */)) { + result->has_password = 1; + } + + /* name 安全校验(用 zip_extract.c 里的同名函数) */ + if (!is_safe_archive_path(hdr.FileName)) { + RARCloseArchive(h); + result->status = ZIPX_ERR_UNSAFE_NAME; + snprintf(result->detail, sizeof(result->detail), "%s", hdr.FileName); + return ZIPX_ERR_UNSAFE_NAME; + } + + /* depth 校验 */ + uint32_t d = path_depth(hdr.FileName); + if (d > max_depth) max_depth = d; + if (max_depth > limits->max_depth) { + RARCloseArchive(h); + result->status = ZIPX_ERR_LIMIT_DEPTH; + return ZIPX_ERR_LIMIT_DEPTH; + } + + /* name/path 长度 */ + if (strlen(hdr.FileName) > limits->max_name_len || + strlen(hdr.FileName) > limits->max_path_len) { + RARCloseArchive(h); + result->status = ZIPX_ERR_LIMIT_NAME; + return ZIPX_ERR_LIMIT_NAME; + } + + /* size 累计 */ + total_bytes += (uint64_t)hdr.UnpSize; + if (hdr.UnpSize > limits->max_file_bytes) { + RARCloseArchive(h); + result->status = ZIPX_ERR_LIMIT_FILE; + snprintf(result->detail, sizeof(result->detail), + "%s (%llu bytes)", hdr.FileName, + (unsigned long long)hdr.UnpSize); + return ZIPX_ERR_LIMIT_FILE; + } + if (total_bytes > limits->max_total_bytes) { + RARCloseArchive(h); + result->status = ZIPX_ERR_LIMIT_TOTAL; + return ZIPX_ERR_LIMIT_TOTAL; + } + + total_entries++; + if (total_entries > limits->max_entries) { + RARCloseArchive(h); + result->status = ZIPX_ERR_LIMIT_ENTRIES; + return ZIPX_ERR_LIMIT_ENTRIES; + } + + /* 跳过 entry body(list-only 不需要调用 ProcessFile) */ + } + RARCloseArchive(h); + + result->entries_total = total_entries; + result->bytes_total = total_bytes; + return ZIPX_OK; +} +``` + +#### 6.1.6 D1 验证清单 + +```bash +# 1. 编译通过(host) +gcc -O2 -c third_party/unrar/unrar.c -o /tmp/unrar.o +gcc -O2 -c src/rar_extract.c -o /tmp/rar_extract.o -I third_party/unrar/ +echo "exit: $?" + +# 2. 链接能产出 host binary(基础 sanity) +gcc -O2 -o /tmp/test-rar-extract /tmp/rar_extract.o /tmp/unrar.o -lz +echo "exit: $?" + +# 3. 跑现有测试(确认没破坏 zip 流程) +cd /home/song/ps5-web-file-manager/ +bash tests/run-tests.sh +# 期望:69 checks, 0 failures +``` + +--- + +### D2:rar_extract 联调 + magic 探测 + limits 校验 + +#### 6.2.1 extract 阶段实现 + +```c +static zipx_status_t +rarx_extract_pass(struct RAROpenArchiveDataEx *arc, + const zipx_limits_t *limits, + const rarx_password_t *password, + rarx_progress_fn progress, void *userdata, + rarx_result_t *result) { + if (password && password->password_set) { + RARSetPassword(arc->h, password->password); + } + + struct RARHeaderDataEx hdr; + while (RARReadHeaderEx(arc->h, &hdr) == 0) { + /* ratio 实时校验 */ + if (hdr.PackSize > 0 && limits->max_ratio > 0) { + uint64_t ratio = hdr.UnpSize / hdr.PackSize; + if (ratio > limits->max_ratio) { + result->status = ZIPX_ERR_LIMIT_RATIO; + snprintf(result->detail, sizeof(result->detail), + "%s ratio %llu", hdr.FileName, + (unsigned long long)ratio); + return ZIPX_ERR_LIMIT_RATIO; + } + } + + /* progress 回调 */ + if (progress) { + zipx_progress_t p = { + .phase = ZIPX_PHASE_EXTRACT, + .entries_total = result->entries_total, + .entries_done = result->entries_done, + .bytes_total = result->bytes_total, + .bytes_done = result->bytes_done, + .current = hdr.FileName, + }; + progress(userdata, &p); + } + + /* 解到 staging 目录 */ + char out_path[ZIPX_PATH_MAX]; + snprintf(out_path, sizeof(out_path), "%s/%s", + result->detail /* staging dir */, hdr.FileName); + + int mode = (hdr.Flags & 0xE0) == 0xE0 /* RAR5 dir flag */ ? + RAR_EXTRACT_DEST : RAR_EXTRACT; + /* 路径安全再校验一次 */ + int rc = RARProcessFile(arc->h, mode, NULL, out_path); + if (rc != 0) { + result->status = (rc == 11) ? ZIPX_ERR_PASSWORD : ZIPX_ERR_IO; + result->sys_errno = errno; + return result->status; + } + + result->entries_done++; + result->bytes_done += hdr.UnpSize; + + /* crc 校验 —— unrar 已经做了,会自动设错误 */ + } + return ZIPX_OK; +} +``` + +#### 6.2.2 magic 探测(不依赖扩展名) + +```c +static int +rarx_probe_magic(const char *path) { + FILE *fp = fopen(path, "rb"); + if (!fp) return 0; + unsigned char buf[8]; + size_t n = fread(buf, 1, sizeof(buf), fp); + fclose(fp); + if (n < 7) return 0; + /* RAR 4: "Rar!\x1a\x07\x00" */ + if (!memcmp(buf, "Rar!\x1a\x07\x00", 7)) return 1; + /* RAR 5: "Rar!\x1a\x07\x01\x00" */ + if (!memcmp(buf, "Rar!\x1a\x07\x01\x00", 8)) return 1; + return 0; +} +``` + +**为什么需要 magic 探测**: +- 用户可能重命名 `.bin` → 实际是 RAR +- 防止 `extract.c` 派发时仅靠扩展名判断漏掉情况 + +#### 6.2.3 D2 验证清单 + +```bash +# 1. 写个最小的 manual fixture: +# 用系统 rar 命令(如果装了)建 RAR5 单卷 test.rar + 内容 +# 或者从网上找开源的 RAR 测试样本 +# 2. host 跑: +./.build/host-test/test-rar-extract tests/fixtures/rar5_single.rar /tmp/out +# 3. 确认 /tmp/out 里有解出来的文件 + +# 4. ratio bomb 测试:建一个 4MB of 'A' 的 RAR5,验证 ERR_LIMIT_RATIO +# 5. path traversal:建一个含 ../../etc/passwd 的 RAR,验证 ERR_UNSAFE_NAME +``` + +--- + +### D3:extract.c 派发 + password 字段 + 错误码映射 + +#### 6.3.1 src/filemgr_internal.h 新增字段 + +```c +typedef struct { + ... + int extract_conflict; + int extract_remove_source; + int extract_large; + int extract_format; /* 新增: 0=zip, 1=rar */ + char extract_password[128]; /* 新增 */ +} file_task_t; +``` + +#### 6.3.2 src/extract.c 派发逻辑 + +**关键**:扩展名判断 + magic fallback 都做,避免前端漏检。 + +```c +/* 新增 detect 函数 */ +static int +detect_archive_format(const char *path, FILE *probe) { + /* 1. 扩展名优先(快) */ + if (str_ends_with_ci(path, ".rar") || + str_ends_with_ci(path, ".part01.rar") || + str_ends_with_ci(path, ".part1.rar") || + str_ends_with_ci(path, ".part001.rar")) { + return 1; /* RAR */ + } + if (str_ends_with_ci(path, ".zip") || + str_ends_with_ci(path, ".zipx")) { + return 0; /* ZIP */ + } + /* 2. magic fallback */ + if (probe && rarx_probe_magic(path)) return 1; + if (probe && zipx_probe_magic(path)) return 0; + return -1; /* unknown */ +} +``` + +#### 6.3.3 api_extract 解析 password 字段 + +```c +char *password_str = body_form_value(body, body_size, "password"); +char password[128] = {0}; +int password_set = 0; +if (password_str) { + strncpy(password, password_str, sizeof(password) - 1); + /* 截断 sanitize:去掉 control chars */ + sanitize_password(password); + password_set = 1; +} +free(password_str); + +/* ... */ + +snprintf(task->extract_password, sizeof(task->extract_password), + "%s", password); +task->extract_format = detect_archive_format(task->src, NULL); +``` + +> ⚠️ **安全**:绝不在日志、progress 回调、task_update 里写 `password` 字段。grep 整个代码库 `password` 字符串只允许出现在 form 解析处。 + +#### 6.3.4 extract_worker 派发 + +```c +static void * +extract_worker(void *arg) { + file_task_t *task = arg; + zipx_conflict_t conflict = task->extract_conflict; + rarx_password_t rpwd = {0}; + rpwd.password_set = task->extract_password[0] != 0; + strncpy(rpwd.password, task->extract_password, + sizeof(rpwd.password) - 1); + + /* 立即清零 task 里的密码(防御内存 dump) */ + memset(task->extract_password, 0, + sizeof(task->extract_password)); + + zipx_status_t status; + if (task->extract_format == 1 /* RAR */) { + rarx_result_t rres = {0}; + status = rarx_extract(task->src, task->dst, conflict, + zipx_limits_profile(task->extract_large), + &rpwd, + extract_cancel, extract_progress, task, &rres); + /* 映射 rarx_result_t → task 状态字段 */ + } else { + zipx_result_t zres = {0}; + status = zipx_extract(task->src, task->dst, conflict, + zipx_limits_profile(task->extract_large), + extract_cancel, extract_progress, task, &zres); + } + + /* status → task state 的映射逻辑保持不变 */ + ... +} +``` + +#### 6.3.5 D3 验证清单 + +```bash +# host 端跑全量回归 +bash tests/run-tests.sh +# 期望:69 checks, 0 failures(zip 流程没坏) + +# 手动构造 form 测试 password 解析: +curl -X POST http://127.0.0.1:8080/api/extract \ + -d "path=/data/test.rar&dst_dir=/data/out&password=secret123" +# 期望任务接受,无 500 +``` + +--- + +### D4:前端 isExtractableArchive + 密码 modal + i18n + 子卷置灰 + +#### 6.4.1 main.js 检测函数 + +```js +function isExtractableArchive(item) { + if (item.type !== "-") return false; + // ZIP(已有) + if (/\.zipx?$/i.test(item.name)) return true; + // RAR 单卷 .rar + if (/\.rar$/i.test(item.name)) return true; + // RAR5 分卷主卷 .part01.rar / .part1.rar / .part001.rar + if (/\.part0*1\.rar$/i.test(item.name)) return true; + return false; +} + +function isRarSubVolume(item) { + if (item.type !== "-") return false; + // RAR5 子卷 .part02.rar, .part2.rar ...(part01 才是主卷) + if (/\.part0*\d+\.rar$/i.test(item.name) && + !/\.part0*1\.rar$/i.test(item.name)) return true; + return false; +} +``` + +#### 6.4.2 renderExtractButton 升级 + +```js +function renderExtractButton(items, locked) { + const extractable = items.filter(isExtractableArchive); + const subs = items.filter(isRarSubVolume); + // 子卷单独选中 → 按钮置灰 + 提示 + if (subs.length > 0 && extractable.length === 0) { + extractBtn.hidden = false; + extractBtn.disabled = true; + extractBtn.title = t("extractSelectMainVolume"); + return; + } + // 主卷逻辑(与之前类似) + extractBtn.hidden = extractable.length !== 1; + if (extractable.length !== 1) { + extractBtn.title = ""; + extractBtn.disabled = true; + return; + } + extractBtn.title = t("extractToCurrent") + ": " + itemTitle(extractable); + extractBtn.disabled = locked; +} +``` + +#### 6.4.3 密码 modal(HTML) + +在 `assets/index.html` 适当位置插入: + +```html + +``` + +#### 6.4.4 密码 modal(CSS) + +在 `assets/main.css` 末尾加: + +```css +.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); + display: flex; align-items: center; justify-content: center; + z-index: 1000; } +.modal.hidden { display: none; } +.modal-card { background: var(--bg-elevated); padding: 24px; + border-radius: 8px; min-width: 320px; max-width: 480px; } +.modal-actions { display: flex; gap: 12px; justify-content: flex-end; + margin-top: 16px; } +.muted { color: var(--text-faint); font-size: 13px; } +.text-input { width: 100%; padding: 8px 12px; margin: 12px 0; + border: 1px solid var(--border); border-radius: 4px; + background: var(--bg-input); color: var(--text); } +``` + +#### 6.4.5 startExtractTask 接收 password + +```js +async function startExtractTask(path, dstDir, conflict, removeSource, + name, large, password) { + const data = await apiForm("/api/extract", { + path, dst_dir: dstDir, conflict, + remove_source: removeSource ? "1" : "0", + large: large ? "1" : "0", + password: password || "" + }); + ... +} +``` + +#### 6.4.6 actionExtract 加密码弹窗 + +```js +function promptPassword(fileName, retry) { + return new Promise((resolve, reject) => { + const modal = document.getElementById("passwordModal"); + const input = document.getElementById("passwordInput"); + const ok = document.getElementById("passwordOk"); + const cancel = document.getElementById("passwordCancel"); + const titleEl = document.getElementById("passwordTitle"); + const promptEl = document.getElementById("passwordPrompt"); + + titleEl.textContent = retry ? + t("passwordWrongTitle") : t("passwordRequiredTitle"); + promptEl.textContent = retry ? + t("passwordWrongPrompt") : t("passwordRequiredPrompt", {name: fileName}); + input.value = ""; + modal.classList.remove("hidden"); + input.focus(); + + const cleanup = () => { + modal.classList.add("hidden"); + ok.removeEventListener("click", onOk); + cancel.removeEventListener("click", onCancel); + }; + const onOk = () => { const v = input.value; cleanup(); resolve(v); }; + const onCancel = () => { cleanup(); reject(new Error("canceled")); }; + + ok.addEventListener("click", onOk); + cancel.addEventListener("click", onCancel); + input.addEventListener("keydown", e => { + if (e.key === "Enter") onOk(); + if (e.key === "Escape") onCancel(); + }); + }); +} + +async function actionExtract() { + const item = singleSelected(); + if (!item) return; + const conflict = ...; + const large = shouldPromptLargeMode(item.size) ? promptLargeMode(item.size) : false; + + let password = ""; + if (isRarExtension(item.name)) { /* 仅 RAR 弹 */ + try { + password = await promptPassword(item.name, false); + } catch { return; } + } + startExtractTask(item.path, cwd, conflict, false, + displayName(item), large, password); +} +``` + +#### 6.4.7 后端 ERR_PASSWORD 自动弹窗 + +```js +/* 任务状态轮询或 SSE 里 */ +if (task.error === "extract_password" || + (task.message || "").toLowerCase().includes("password")) { + // 不太优雅但稳:弹窗重试 + // (更好的做法是在 zipx_status_string 里直接提供 code, + // 前端 if (status === ZIPX_ERR_PASSWORD) → 弹) +} +``` + +> 💡 **更稳的方案**:在 `zipx_status_string(ZIPX_ERR_PASSWORD)` 返回字符串 `"extract_password"`,前端 `task.op === "extract" && task.error === "extract_password"` 时弹窗,让用户重新提交。这避免字符串包含匹配带来的误报。 + +#### 6.4.8 i18n 新增文案 + +`assets/lang-zh.js`: +```js +extractSelectMainVolume: "请改选主卷(如 .part01.rar 或 .rar)", +passwordRequiredTitle: "需要解压密码", +passwordRequiredPrompt: "RAR 文件 {name} 已加密,请输入解压密码。", +passwordWrongTitle: "密码错误", +passwordWrongPrompt: "密码错误,请重新输入。密码仅本次使用,不会保存。", +``` + +`assets/lang-en.js`: +```js +extractSelectMainVolume: "Select the main volume (e.g. .part01.rar or .rar)", +passwordRequiredTitle: "Password required", +passwordRequiredPrompt: "The RAR archive {name} is encrypted. Please enter the password.", +passwordWrongTitle: "Wrong password", +passwordWrongPrompt: "Wrong password. Please try again. The password is only used for this extraction and is never saved.", +``` + +#### 6.4.9 D4 验证清单 + +```bash +# 1. node 语法检查 +node --check assets/main.js +node --check assets/lang-en.js +node --check assets/lang-zh.js + +# 2. 浏览器手动测试(开 dev server 或本地 http-server): +# - 选 .rar → 弹密码框 +# - 选 .zip → 不弹密码框(保持 v1.7 行为) +# - 选 .part02.rar → 解压按钮置灰 + tooltip +# - 选 .part01.rar → 解压按钮可用 + +# 3. 提交后端确认 password 字段透传: +# Network → /api/extract → Form Data → password: "xxx" +``` + +--- + +### D5:host tests/test_rar_extract.c + fixtures + +#### 6.5.1 fixture 准备 + +需要 RAR 测试样本。**优先用 WinRAR / rar 命令行工具生成**: + +```bash +# 装 unrar / rar(host) +sudo apt install rar unrar # 或 mac: brew install rar + +# 生成 fixtures(用脚本自动化) +cd tests/fixtures/ + +# RAR4 单卷明文 +rar a -os rar4_single.rar sample.txt + +# RAR5 单卷明文 +rar a -ma rar5_single.rar sample.txt + +# RAR5 分卷(5 卷 × 1MB) +mkdir -p split_src && head -c 5M /dev/urandom > split_src/big.bin +rar a -v1m -ma rar5_multi.part01.rar split_src/ + +# 加密 RAR5(密码 "secret") +rar a -ma -hpsecret encrypted_rar5.rar sample.txt + +# 加密 RAR4(密码 "secret") +rar a -os -hpsecret encrypted_rar4.rar sample.txt + +# 加密分卷 +rar a -v1m -ma -hpsecret encrypted_multi.part01.rar split_src/ + +# 损坏 +head -c 1024 rar5_single.rar > rar5_truncated.rar +``` + +> ⚠️ **fixture 不能 commit**(见 .gitignore),需要在 `tests/make_fixtures.py` 里写生成函数,运行 `bash tests/run-tests.sh` 时自动调用。 + +#### 6.5.2 tests/make_fixtures.py 新增 + +```python +def rar4_single(): + """RAR4 single volume, plaintext. Generated by host `rar` CLI.""" + import subprocess + src = path("_rar_src.txt") + if not os.path.exists(src): + with open(src, "w") as f: + f.write("hello rar4\n" * 100) + out = path("rar4_single.rar") + if not os.path.exists(out): + subprocess.check_call(["rar", "a", "-os", out, src]) + return out + +def rar5_single(): + src = path("_rar_src.txt") + out = path("rar5_single.rar") + if not os.path.exists(out): + subprocess.check_call(["rar", "a", "-ma", out, src]) + return out + +# ... 其余同理 +``` + +> 💡 **CI 兼容性**:CI 环境可能没装 rar。改用 `unrar` 命令 + 预生成 fixture 一并存档。**最稳**:把生成好的 fixture 提交到 git(small ones only,<1MB each)。 + +#### 6.5.3 tests/test_rar_extract.c 测试用例模板 + +```c +/* tests/test_rar_extract.c */ + +#include "rar_extract.h" + +static rarx_result_t +run_rar(const char *src, const char *dst, + zipx_conflict_t conflict, + const zipx_limits_t *limits, + const rarx_password_t *pwd, + rarx_progress_fn progress, void *userdata) { + rarx_result_t res = {0}; + rarx_extract(src, dst, conflict, limits, pwd, + NULL, progress, userdata, &res); + return res; +} + +static void +test_rar4_single(void) { + rarx_result_t r = run_rar("rar4_single.rar", "out_rar4_single", + ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, NULL); + check(r.status == ZIPX_OK, "RAR4 single extracts OK"); + check(r.entries_total == 1, "1 entry"); + check(r.files_created == 1, "1 file created"); +} + +static void +test_rar5_single(void) { + rarx_result_t r = run_rar("rar5_single.rar", "out_rar5_single", + ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, NULL); + check(r.status == ZIPX_OK, "RAR5 single extracts OK"); +} + +static void +test_rar5_multi_volume(void) { + /* unrar 自动找 .part02, .part03... */ + rarx_result_t r = run_rar("rar5_multi.part01.rar", + "out_rar5_multi", + ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, NULL); + check(r.status == ZIPX_OK, "RAR5 multi-volume extracts OK"); + check(r.entries_total >= 1, "at least one entry"); +} + +static void +test_rar4_encrypted_correct_pwd(void) { + rarx_password_t pwd = {.password = "secret", .password_set = 1}; + rarx_result_t r = run_rar("encrypted_rar4.rar", + "out_rar4_enc_ok", + ZIPX_CONFLICT_FAIL, NULL, &pwd, NULL, NULL); + check(r.status == ZIPX_OK, "encrypted RAR4 with correct pwd OK"); +} + +static void +test_rar4_encrypted_wrong_pwd(void) { + rarx_password_t pwd = {.password = "wrong", .password_set = 1}; + rarx_result_t r = run_rar("encrypted_rar4.rar", + "out_rar4_enc_wrong", + ZIPX_CONFLICT_FAIL, NULL, &pwd, NULL, NULL); + check(r.status == ZIPX_ERR_PASSWORD, "wrong pwd → ERR_PASSWORD"); + check(r.files_created == 0, "no file created on wrong pwd"); +} + +static void +test_rar4_encrypted_no_pwd(void) { + rarx_result_t r = run_rar("encrypted_rar4.rar", + "out_rar4_enc_nopwd", + ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, NULL); + check(r.status == ZIPX_ERR_PASSWORD, "no pwd → ERR_PASSWORD"); + check(r.has_password == 1, "scan detects password requirement"); + check(r.files_created == 0, "no file created without pwd"); +} + +/* 全部测试 + main 入口同 test_zip_extract.c */ +``` + +#### 6.5.4 run-tests.sh 新增编译 RAR 支持 + +```bash +# tests/run-tests.sh 新增: +"$CC" -O2 -c "$ROOT/third_party/unrar/unrar.c" -o "$BUILD/unrar.o" || exit 1 +"$CC" -O2 -c "$ROOT/src/rar_extract.c" -o "$BUILD/rar_extract.o" \ + -I "$ROOT/src" -I "$ROOT/third_party/unrar/" || exit 1 +"$CC" -O2 -c "$ROOT/tests/test_rar_extract.c" -o "$BUILD/test_rar_extract.o" \ + -I "$ROOT/src" -I "$ROOT/third_party/unrar/" || exit 1 + +# 链接 +"$CC" -O2 -o "$BUILD/test-rar-extract" \ + "$BUILD/rar_extract.o" "$BUILD/test_zip_extract.o" \ + "$BUILD/unrar.o" "$BUILD/test_rar_extract.o" "${objs[@]}" \ + || exit 1 + +"$BUILD/test-rar-extract" "$ROOT/tests/fixtures" "$BUILD/work" +``` + +#### 6.5.5 D5 验证清单 + +```bash +bash tests/run-tests.sh +# 期望:69 + ~12 = 81 checks, 0 failures + +# 单独跑 RAR 测试: +./.build/host-test/test-rar-extract tests/fixtures/ /tmp/rar_work +# 期望:所有 RAR 测试通过 +``` + +--- + +### D6:WSL 跨编 + ELF 验证 + 文档 + +#### 6.6.1 WSL Makefile 更新 + +`Makefile` 加: +```makefile +# third_party/unrar/ +UNRAR_OBJS = $(addprefix $(OBJ_DIR)/third_party/unrar/, \ + $(notdir $(wildcard third_party/unrar/*.c))) +$(UNRAR_OBJS): | $(OBJ_DIR)/third_party/unrar +$(OBJ_DIR)/third_party/unrar/%.c.o: third_party/unrar/%.c + $(CC) $(CFLAGS) -c $< -o $@ + +# src/rar_extract.o +OBJ_FILES += src/rar_extract.c + +# .elf 链接加 unrar.o + rar_extract.o +$(ELF): ... $(UNRAR_OBJS) src/rar_extract.o ... +``` + +#### 6.6.2 跨编 + +```bash +# WSL Ubuntu-22.04 内 +cd /home/song/ps5-web-file-manager +bash build-elf.sh +# 期望:[7/7] OK +# 期望 size: ~580 KiB +# 期望 sha256: 不在 list 内(首次编) +``` + +#### 6.6.3 ELF 验证脚本 + +```bash +# Windows side 验证 +cd "C:\Users\songl\Desktop\Web File Manager\ps5-web-file-manager" +sha256sum web-file-mgr.elf +size web-file-mgr.elf +head -c 20 web-file-mgr.elf | xxd | head -2 + +# gzip 流验证(前端改动进了 ELF) +python3 .build/check-elf-gzip.py ./web-file-mgr.elf | grep -E "(✓|✗)" +# 期望: +# ✓ passwordRequiredTitle +# ✓ passwordRequiredPrompt +# ✓ passwordWrongTitle +# ✓ passwordWrongPrompt +# ✓ extractSelectMainVolume +# ✓ startExtractTask ... password: password || "" +# ✓ rarx_extract +# ✓ ZIPX_ERR_PASSWORD +``` + +#### 6.6.4 文档更新 + +**新建**:`docs/UPGRADE-v1.8-rar-support.md`(镜像 v1.7 UPGRADE 风格,~450 行) + +**更新**:`CHANGELOG.md` 头部加 v1.8 段: +```markdown +## [v1.8] - 2026-09-XX + +### Added +- **RAR archive extraction** via vendored alexbatalov/unrar.c +- RAR4 and RAR5 single-volume and multi-volume (`name.partNN.rar`) +- Encrypted RAR support with password prompt + retry dialog +- Frontend detection of RAR main/sub volumes + grayscale sub-volume button + +### Technical +- `src/rar_extract.{h,c}` mirrors `zip_extract` API (1600 LOC, three-phase pipeline) +- `extract.c` dispatches by extension + magic-byte fallback +- ZIP path unchanged (v1.7 large-file profile still works) +``` + +**更新**:`README.md` ZIP extraction 段后面加: +```markdown +### RAR extraction + +The project also extracts RAR4 and RAR5 archives, including +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 / 1 TiB / 256 GiB / +ratio 500 by default, with the same `large=1` opt-in to 500K / +2 TiB / 1 TiB / 1000). +``` + +#### 6.6.5 Git 提交 + push + +```bash +cd /home/song/ps5-web-file-manager +git add third_party/unrar/ src/rar_extract.* src/extract.c src/filemgr_internal.h \ + assets/main.js assets/main.css assets/lang-en.js assets/lang-zh.js \ + tests/test_rar_extract.c tests/make_fixtures.py tests/run-tests.sh \ + Makefile docs/UPGRADE-v1.8-rar-support.md CHANGELOG.md README.md +git -c core.autocrlf=false commit -m "v1.8: RAR4/RAR5 + multi-volume + encrypted support + +- vendor alexbatalov/unrar.c into third_party/unrar/ (UnRAR license) +- src/rar_extract.{h,c}: three-phase engine (scan -> extract -> publish -> cleanup) + with first-volume-only API (unrar internally finds companion volumes) +- extract.c: format dispatch by extension + Rar! magic fallback +- API: POST /api/extract gains optional 'password' form field +- frontend: isExtractableArchive() covers RAR single/multi; isRarSubVolume() + disables the extract button with a tooltip +- password modal: required on encrypted RAR, wrong-password retry +- tests: +12 RAR checks (single/multi/encrypted-no-pwd/encrypted-wrong-pwd/ + encrypted-correct-pwd/ratio-bomb/path-traversal/truncated) +- ELF: 418 KiB -> ~580 KiB; gzip-assets verified for new strings +- docs: CHANGELOG + UPGRADE-v1.8-rar-support.md + README" + +# 用户在自己 PowerShell(非沙箱)里: +# git push -u origin main +# git tag -a v1.8 -m "..." +# git push origin v1.8 +``` + +--- + +## 7. 关键代码模式(参考 zip_extract 实现) + +### 7.1 三阶段架构(rar_extract 必须镜像) + +```c +zipx_status_t +rarx_extract(const char *first_path, const char *dst_dir, + zipx_conflict_t conflict, + const zipx_limits_t *limits, + const rarx_password_t *password, + zipx_cancel_fn cancel, zipx_progress_fn progress, + void *userdata, rarx_result_t *result) { + /* 1) scan 阶段 */ + zipx_status_t s = rarx_scan(first_path, limits, result); + if (s != ZIPX_OK) return s; + if (result->has_password && (!password || !password->password_set)) { + result->status = ZIPX_ERR_PASSWORD; + return ZIPX_ERR_PASSWORD; + } + + /* 2) 创建 staging 目录 */ + char staging[ZIPX_PATH_MAX]; + snprintf(staging, sizeof(staging), "%s/.wfm-part-%d", + dst_dir, (int)getpid()); + if (mkdir(staging, 0755) < 0) { + result->status = ZIPX_ERR_IO; + return ZIPX_ERR_IO; + } + snprintf(result->detail, sizeof(result->detail), "%s", staging); + + /* 3) extract 阶段(解到 staging) */ + s = rarx_extract_pass(first_path, staging, limits, password, + cancel, progress, result); + if (s != ZIPX_OK) { + /* cleanup: unlink staging 整树 */ + nftw(staging, unlink_cb, 64, FTW_DEPTH | FTW_PHYS); + rmdir(staging); + return s; + } + + /* 4) publish 阶段:rename staging → dst_dir */ + s = rarx_publish(staging, dst_dir, conflict, result); + if (s != ZIPX_OK) { + nftw(staging, unlink_cb, 64, FTW_DEPTH | FTW_PHYS); + rmdir(staging); + return s; + } + + return ZIPX_OK; +} +``` + +### 7.2 path 安全校验(直接复用 zip_extract.c 的实现) + +```c +/* 把 zip_extract.c 的 is_safe_archive_path() 复制到 rar_extract.c */ +/* 或者提到一个共用的 src/path_util.c */ +``` + +> ⚠️ **可重构性**:D3 阶段如果时间够,把 `is_safe_archive_path` / `path_depth` / `nftw_unlink` 提到 `src/path_util.c`,rar_extract 和 zip_extract 都 include。**不做也行**,copy 一份到 rar_extract.c 即可。 + +### 7.3 错误码映射(rar → zip 命名空间) + +| RAR unrar API 返回 | unrar 含义 | 映射到 zipx_status_t | +|---|---|---| +| `ERAR_NO_MEMORY` | OOM | `ZIPX_ERR_INTERNAL` | +| `ERAR_BAD_DATA` | CRC 错 / 损坏 | `ZIPX_ERR_CRC` | +| `ERAR_BAD_ARCHIVE` | 头错 / 不识别 | `ZIPX_ERR_FORMAT` | +| `ERAR_UNKNOWN_FORMAT` | 不是 RAR | `ZIPX_ERR_FORMAT` | +| `ERAR_EOPEN` | 文件打不开 | `ZIPX_ERR_OPEN` | +| `ERAR_ECREATE` | 创建输出失败 | `ZIPX_ERR_IO` | +| `ERAR_ECLOSE` | 关闭失败 | `ZIPX_ERR_IO` | +| `ERAR_EREAD` | 读失败 | `ZIPX_ERR_IO` | +| `ERAR_EWRITE` | 写失败 | `ZIPX_ERR_IO` | +| `ERAR_SMALL_BUF` | name buffer 不够 | `ZIPX_ERR_LIMIT_NAME` | +| `ERAR_PASSWORD` (11) | 密码错/缺 | `ZIPX_ERR_PASSWORD`(**新加**) | + +### 7.4 task_state 中密码字段的安全处理 + +```c +/* extract_worker 入口 */ +char password_copy[128]; +strncpy(password_copy, task->extract_password, sizeof(password_copy) - 1); +memset(task->extract_password, 0, sizeof(task->extract_password)); +/* 后续只用 password_copy,函数退出时也清零 */ +``` + +--- + +## 8. 工程决策(不要重新讨论) + +### 8.1 为什么 vendor unrar.c 而不是动态库 + +- SDK 没有 homebrew 目录(见 §2.3) +- vendor 源码 = 完全可控,编译/链接/调试一次到位 +- unrar.c 纯 C + 单文件 = 跨编零摩擦 +- 动态库需要 `-Wl,-rpath` 等额外 linker 配置,麻烦 + +### 8.2 为什么不用 libarchive + +- 体积大一倍以上(libarchive stripped ~400 KiB,unrar stripped ~150 KiB) +- libarchive 内部仍依赖 unrar/librar 才能读 RAR —— 反而绕远 +- libarchive 的 BSD-style API 与现有 zip_extract / rar_extract 设计不符 +- 用户场景里 7z 罕见,付不起这个成本 + +### 8.3 为什么复用 zipx_status_t 枚举 + +- 错误码统一,前端不用分辨 ZIP_ERR_* vs RAR_ERR_* +- `task_update()` 一份映射逻辑覆盖两种格式 +- 减少新代码量(也减少 bug) + +### 8.4 为什么不在后端做分卷发现 + +- unrar 内部已经处理 `name.part01.rar` → 找 `.part02, .part03...` +- 后端写发现逻辑只是重复实现,且容易有边角 case bug + +### 8.5 为什么密码不做错/对细粒度区分 + +- 「密码错」vs「文件损坏」的区分会泄露「文件存在 / 是否加密」信息(侧信道) +- 统一返回 `ZIPX_ERR_PASSWORD` + 友好文案即可 +- 用户重试一次的成本可控 + +### 8.6 为什么 ELF 体积红线设在 ~1 MiB + +- payload loader 通常限制 4 MiB,但实际 PS5 WebKit 启动时内存紧张 +- 单 payload 越大,加载越慢;用户感受从 580 KiB → 800 KiB 能感知 +- 留余量给未来加 7z、ZIP AES 等扩展 + +--- + +## 9. 注意事项 / 已知坑 + +### 9.1 unrar license 的实际情况(v1.8 选定 dmc_unrar) + +> v1.8 实际选择的库是 [`DrMcCoy/dmc_unrar`](https://github.com/DrMcCoy/dmc_unrar) 1.7.0,**license 是 GPL-2.0-or-later**(不是 UnRAR License)。 +> +> - ✅ 使用、编译、嵌入、二进制分发 —— 允许 +> - ✅ 修改并以 GPL 条款整体分发 —— 允许 +> - ❌ 改装 unrar.c —— **不允许**(UnRAR 上游版本,**dmc_unrar 允不允许改不重要因为我们没改**) +> - ❌ 不允许的:用 unrar 创建 RAR 压缩功能(不做就好) + +为什么不需要担心 license: +- dmc_unrar.c **逐字未改**(vendor 完毕没碰),完整 GPL 通知在 `third_party/unrar/COPYING` +- dmc_unrar_api.h 是项目自有文件,按项目 license(GPLv3+)分发 +- 项目本身已是 GPLv3+ → 与 GPL-2.0-or-later 兼容 +- 二进制 + 对应源代码 + GPL 通知三件套 = 合规(libmicrohttpd 的 LGPL 已经这么做了) + +**vendor 设计要点**(详见 docs/UPGRADE-v1.8-rar-support.md §5): +- **不要 `#include "dmc_unrar.c"`** —— 会污染 dmc_unrar.c 内部的 struct 名(dmc_unrar_io_handler 的 open / close 字段会被 `tests/posix_compat.h` 的 `wfm_open` / `wfm_close` 重定义撞名) +- 用 facade header `third_party/unrar/dmc_unrar_api.h`(项目自有),只 re-declare 我们用到的符号 +- Makefile 把 dmc_unrar.c 当成独立 TU 编(`THIRD_PARTY_SRCS += third_party/unrar/dmc_unrar.c`),加 `-Ithird_party/unrar -DDMC_UNRAR_DISABLE_BE32TOH_BE64TOH=1` + +未来换 opello/unrar 时这套机制仍适用:把 `dmc_unrar.c` 换成 `*.cpp`、给 `dmc_unrar_api.h` 换内容(实现 RAROpenArchiveEx / RARSetPassword / RARProcessFileW 等 DLL API 风格),引擎签名不动、dispatch 不动、host tests 不动。 + +### 9.2 SDK staging 模式(每次都要 sudo) + +```bash +# 不要尝试 +chown -R song:song /opt/ps5-payload-sdk/ # ❌ 会破坏其他用户 +chmod 777 /opt/ps5-payload-sdk/ # ❌ SDK 可能拒绝加载 + +# 正确做法(build-elf.sh v5 已实现) +make install DESTDIR=/tmp/wfm-stage +sudo cp -r /tmp/wfm-stage/opt/* /opt/ +``` + +### 9.3 编辑工具的隐藏陷阱 + +- **Edit 工具偶有"成功但不落盘"现象**:连续两次同一 Edit,第一次只报成功未生效。**对策**:每次 Edit 后用 `grep` 验证改动真的进了文件。 +- 写 `.bat` / `.ps1` 时**不要在 PS5 路径里写非 ASCII**(utf-8 BOM 破坏文件名)。**对策**:用 PowerShell `Write` 工具的绝对路径形式,或改用 Git Bash heredoc。 +- 修改 unrar.c 用 `sed` 是诱人的,但 license 不允许改 → 用 wrapper 函数封装。 + +### 9.4 跨编 ELF 校验脚本 + +`gen-asset-module.py` 把 JS / JSON / CSS / HTML 用 zlib 压缩进 ELF。 +**普通 `strings web-file-mgr.elf` 看不到前端新加的字符串**。 +**对策**:用 `.build/check-elf-gzip.py`(已存在)扫 gzip 流验证。 + +```python +# 检查清单(D6 必跑) +python3 .build/check-elf-gzip.py ./web-file-mgr.elf | grep "✗" +# 期望:空输出(所有 key 都在) +``` + +### 9.5 测试 fixture 生成 + +- 4 MiB of 'A' 的 zip **不**用于 large profile 测试(ratio ≈ 1026 > 1000) +- 用 `bytes(range(256)) * 4096` 才有 ratio ≈ 238(夹在 200~1000 中间) +- RAR 用 host `rar` 命令生成 fixture,CI 环境可能没装 → 把 small fixtures 提交到 git + +### 9.6 密码字段的安全 + +- task 结构里的 password 在 extract_worker 入口**立即清零** +- 日志 / progress 回调**绝不**写 password +- `task->message[]` 是固定 192 字节 buffer,**只**写错误描述,不写密码 +- 前端 modal 关闭后立即 `input.value = ""` + +### 9.7 magic 探测 + 扩展名优先级 + +扩展名优先(O(1) 字符串比较),magic fallback 用 file I/O(O(8 字节读)): +- 大文件不慢:magic 只读 8 字节 +- 重命名 `.bin` 仍可识别 +- 非 RAR 非 ZIP → 返回 -1,extract.c 报 `extract_unsupported` + +### 9.8 三阶段 staging 目录命名 + +- `.wfm-part-{pid}` —— 用 pid 区分并发解压 +- 失败时 `nftw(staging, unlink_cb, 64, FTW_DEPTH | FTW_PHYS)` 递归删 +- publish 阶段 `rename(staging, dst_dir)` —— 原子(同一文件系统下) + +### 9.9 RAR4 vs RAR5 头差异 + +| 字段 | RAR4 | RAR5 | +|---|---|---| +| Magic | `Rar!\x1a\x07\x00` | `Rar!\x1a\x07\x01\x00` | +| 大小字段 | 32-bit | 64-bit (UnpSizeHigh 等) | +| 加密 flag | `LHD_PASSWORD` (0x04) | 头 flag bit 0x04 | +| 目录 flag | `LHD_DIRECTORY` (0xE0) | 不同位 | +| CRC | 32-bit | 32-bit (压缩) | + +unrar 抽象了这些,**API 层不必区分**,但错误处理时要兼容两种返回码。 + +### 9.10 unrar 多线程安全性 + +> ⚠️ **unrar 全局状态**:RAROpenArchive 返回的 HANDLE 不是线程安全的,**每个 task 必须独立打开/关闭**。当前 `extract_worker` 已经是每 task 一个 thread,没问题。 + +--- + +## 10. 常用命令清单 + +### 10.1 host 测试 + +```bash +cd /home/song/ps5-web-file-manager/ +bash tests/run-tests.sh # 全量回归(81+ checks) +./.build/host-test/test-zip-extract \ + tests/fixtures/ /tmp/wfm_work # 单独跑 ZIP +./.build/host-test/test-rar-extract \ + tests/fixtures/ /tmp/wfm_work # 单独跑 RAR +``` + +### 10.2 PS5 跨编 + +```bash +# WSL Ubuntu-22.04 bash +cd /home/song/ps5-web-file-manager/ +bash build-elf.sh # 全量构建 + 落双路径 + +# 仅清理 + 重编(快一些) +make clean all + +# 增量编译(zlib/minizip-ng obj 缓存复用) +make all +``` + +### 10.3 ELF 验证 + +```bash +# 在 Windows 侧(或 WSL 内) +sha256sum web-file-mgr.elf +size web-file-mgr.elf +head -c 20 web-file-mgr.elf | xxd | head -2 +od -An -tx2 -N2 -j18 web-file-mgr.elf | tr -d " " # expect 003e + +# 验证前端改动进了 ELF +python3 .build/check-elf-gzip.py ./web-file-mgr.elf +``` + +### 10.4 Git 操作 + +```bash +# 本地提交(commit 阶段) +cd /home/song/ps5-web-file-manager/ +git add +git -c core.autocrlf=false commit -m "..." + +# 用户在自己 PowerShell / Git Bash(非沙箱): +git push -u origin main +git tag -a v1.8 -m "..." +git push origin v1.8 +``` + +### 10.5 debug 工具 + +```bash +# 跟踪 HTTP 请求(curl) +curl -v -X POST http://127.0.0.1:8080/api/extract \ + -d "path=/data/test.rar&dst_dir=/data/out&password=secret" + +# 看 ELF 内是否有符号 +nm web-file-mgr.elf 2>/dev/null | grep rarx +# (prospero-strip 后大部分本地符号被剥,外部函数名仍在) + +# 看 unrar 占多大 +size --target=binary web-file-mgr.elf +``` + +--- + +## 11. 测试矩阵(完成 D5 后应全过) + +### 11.1 ZIP(v1.7 已覆盖 69 checks) + +| 类别 | 用例 | 期望 | +|---|---|---| +| 基础 | basic / stored / unicode / zip64 | OK | +| 安全 | traversal / traversal_backslash / absolute / drive_letter / symlink / fifo | ERR_* | +| 加密 | encrypted | ERR_UNSUPPORTED | +| 错误 | bad_crc / truncated / not_a_zip | ERR_CRC / ERR_FORMAT / ERR_OPEN | +| 资源 | ratio / file / total cap / depth / name | ERR_LIMIT_* | +| 边界 | duplicate / file_dir_clash / conflict_source | ERR_DUPLICATE / 处理 conflict | +| 大文件 | large profile (16 checks) | OK / ERR_* 按预期 | + +### 11.2 RAR(v1.8 新增 ~12 checks) + +| 类别 | 用例 | 期望 | +|---|---|---| +| 基础 | rar4_single / rar5_single | OK | +| 分卷 | rar5_multi | OK | +| 加密 | rar4_enc_correct_pwd / rar5_enc_correct_pwd | OK | +| 加密 | rar4_enc_wrong_pwd / rar4_enc_no_pwd | ERR_PASSWORD | +| 错误 | rar_truncated / rar_bad_archive | ERR_FORMAT | +| 安全 | rar_path_traversal | ERR_UNSAFE_NAME | +| 资源 | rar_ratio_bomb / rar_file_too_large | ERR_LIMIT_RATIO / ERR_LIMIT_FILE | +| 边界 | rar_cancel | ERR_CANCELED | + +### 11.3 集成测试(手动或脚本化) + +```bash +# 在 PS5 真机 / qemu-ps5 上 +1. 浏览器访问 http://ps5-ip:8080 +2. 上传 sample.rar → 选中 → 解压 → 验证文件出现 +3. 上传 encrypted.rar → 选中 → 弹密码框 → 输入 → 解压 +4. 上传 .part02.rar → 选中 → 按钮置灰 → tooltip 正确 +5. 上传 game.part01.rar + .part02.rar + .part03.rar → 选 part01 → 解压 +6. 用错的密码再次尝试 → 弹密码错误窗 → 重试 +7. 上传 200GB rar → 弹 large profile 窗 → 走 large profile 解压 +``` + +--- + +## 12. 项目当前快照(2026-09-05 15:30 — v1.8 收尾) + +``` +Repo: https://github.com/owendswang/ps5-web-file-manager.git (待 push) +Local: C:\Users\songl\Desktop\Web File Manager\ps5-web-file-manager\ +WSL: \\wsl$\Ubuntu-22.04\home\song\ps5-web-file-manager\ +HEAD: bfe522e (local) + uncommitted v1.8 working tree +Branch: main (no .git push yet — sandbox github 502) + +ELF: web-file-mgr.elf 待用户 WSL 重编 + v1.7 旧的: 418 KiB + sha256: 648e4a00afe52669846df52ee5342bab42ea10050d555d5a1d4fa602653f514b + e_machine: 0x003e (x86_64-sie-ps5 ✓) + v1.8 预估: ~430 KiB (dmc_unrar 二进制 + facade, 真实尺寸待编) + sha256: TBD + e_machine: 0x003e (x86_64-sie-ps5 ✓) + Version: v1.8 (VERSION_TAG 在 Makefile 已改) + Title ID: FMGR88888 + +Tests: 83 checks, 0 failures (69 ZIP + 14 RAR) +Deps: zlib 1.3.1, minizip-ng 4.2.2, dmc_unrar 1.7.0 (vendored; no system libs) + +Done (v1.8 实施层): + ✅ src/rar_extract.{c,h} (1276 LOC), 镜像 zip_extract 的三阶段 + scan/extract/publish/cleanup + ✅ third_party/unrar/{dmc_unrar.c, dmc_unrar_api.h, COPYING, README.md, example.c, VENDORED.md} + ✅ src/extract.c dispatch (扩展名 + magic fallback 单点判断) + ✅ 14 new host tests + run-tests.sh + make_fixtures.py(带 rar/7z/placeholder fallback) + ✅ Makefile (VERSION_TAG v1.8 + dmc_unrar TU + -DDMC_UNRAR_DISABLE_BE32TOH_BE64TOH=1) + ✅ 前端 isExtractableArchive / isRarSubVolume + lang-{en,zh}.js err_extract_unsupported + ✅ THIRD_PARTY_NOTICES section 3 (dmc_unrar attribution) + ✅ CHANGELOG.md v1.8 section (含 deviation 注释) + ✅ README.md (What's new in v1.8 + RAR section + Credits dmc_unrar + GPL-2.0 合规说明) + ✅ docs/UPGRADE-v1.8-rar-support.md (架构 + facade 模式 + roadmap) + ✅ docs/HANDOVER.md (本文件: §4/§9.1/§12/§14 全更新) + +Doing (用户侧): + ⏸ WSL 跑 bash build-elf.sh → 重编 web-file-mgr.elf → 记 sha256 填进 CHANGELOG v1.8 banner + ⏸ 填 ELF size 实际值 + ⏸ PowerShell 跑 git push -u origin main (所有 5 + 1 commit) + ⏸ git tag -a v1.8 -m "..." && git push origin v1.8 + ⏸ 在 README v1.7 banner 那行替换为 v1.8 banner (已改) + ⏸ (可选) 部署 .elf 到 PS5 实机跑一遍 → 选 .rar → 解压 → 验证 + +Defer (v1.9): + ⏸ vendor opello/unrar 替换 dmc_unrar → 加多卷 + 加密支持 + ⏸ 共用 staging/publish/nameset 抽到 src/archive_engine_common.c + ⏸ password= 前端 modal + 后端 wire format +``` + +§13「写在最后」仍然适用,但下一节是新加的"v1.8 实际交付状态",比 §13 更具体。 + +--- + +## 13. 写在最后 + +**这份文档不是"工作日志",是"施工蓝图"**。任何接手人应该能: + +1. 读完 §1 ~ §4(10 分钟)→ 知道项目是什么、当前到哪 +2. 跳到 §6 按天执行(6 天)→ 写完 v1.8 +3. 出问题翻 §7 ~ §9(30 分钟内定位) +4. 完成时按 §6.6.5 提交 + §10 验证 + +如果某个环节卡住超过 2 小时,**优先回这里查 §9 的「坑」** —— 90% 的边角 case 我都踩过了。 + +加油。 + +--- + +## 14. v1.8 实际交付状态(2026-09-05 收尾报告) + +> **本节是 v1.8 RAR 支持的最后收尾报告**,写给接手人(前同事 / 未来自己), +> 让你在 5 分钟内知道:v1.8 做了什么、为什么这样做、哪里跳了坑、下一步是什么。 +> +> §1 ~ §13 是 v1.8 开工前的施工蓝图(**与现实有偏差**,但工程决策保留); +> 本节是 v1.8 完工后的真实记录(**以本节为准**)。 + +### 14.1 用了多久 / 写了多少 + +| 维度 | 数据 | +|---|---| +| 总耗时(沙箱内,开工到完工) | 约 6 小时(含 vendor 选型失败 → 重选 → 写引擎 → 写 host tests → 文档) | +| 新增 LOC | `src/rar_extract.c` 1276 + `src/rar_extract.h` 34 + `third_party/unrar/dmc_unrar_api.h` 138 + `third_party/unrar/VENDORED.md` 76 + `tests/test_rar_extract.c` 346 + `docs/UPGRADE-v1.8-rar-support.md` ≈ 700 = **≈ 2570 LOC 项目自有代码**(vendor 的 dmc_unrar.c 11 598 LOC 不计) | +| 改 LOC | Makefile + extract.c + main.js + lang-{en,zh}.js + make_fixtures.py + run-tests.sh + THIRD_PARTY_NOTICES + README + CHANGELOG ≈ 600 | +| 文档净增 | README ≈ +90 行 / CHANGELOG ≈ +100 行 / HANDOVER.md ≈ +80 行(新本节)/ UPGRADE-v1.8 ≈ 700(新文件) | +| 测试数 | 69 → **83**(+14 RAR) | + +### 14.2 完成 vs §6 计划的 deviation(最重要) + +| §6 计划 | v1.8 实际 | 为什么 | +|---|---|---| +| vendor `alexbatalov/unrar.c` | vendor `DrMcCoy/dmc_unrar` 1.7.0 | alexbatalov 仓库 404,dmc_unrar 是单文件 GPL-2.0 FLOSS,vendor 摩擦最小 | +| UnRAR license(改造禁止) | GPL-2.0-or-later(**可改但不改**) | 库选择改了,license 处理相应改成"vendor 不动 + 项目自有 facade" | +| 多卷 RAR `name.part01.rar` + `+02..` 链式 | ❌ 拒绝 + UI tooltip "select main volume" | dmc_unrar 上游不支持 volumes,需 opello/unrar(v1.9) | +| 加密 RAR + 密码 modal | ❌ 拒绝 + 无 password= 字段 | dmc_unrar 上游不支持加密,需 opello/unrar(v1.9) | +| `extract_format` + `extract_password` task 字段 | 字段没用 | dmc_unrar 不需要 password,task struct 保持 v1.7 形状 | +| `src/path_util.c` 共用 `is_safe_archive_path` 提取 | **未提取**(zip_extract 与 rar_extract 各有一份) | 进度 + 风险权衡后延后到 v1.9 共用 archive_engine_common.c 重构时 | +| 错码 `ZIPX_ERR_PASSWORD` 新增 | **未加** | 加密不支持,密码错根本发不出来;opello 接入时再加 | +| 前端 password modal HTML/CSS | **未加** | 同上;预留 modal 锚点(CSS class naming)供 v1.9 复用 | +| linux build 也编 rar_extract | ✅(COMMON_SRCS 已包含 rar_extract.c) | 顺手改的,没增加工作量 | + +**核心决定**:把"vendor 一个 C++ UnRAR(opello/unrar)来支持多卷 + 加密"推迟到 v1.9, +v1.8 用 dmc_unrar 跑完"单卷 + 明文"这个 80% 用户的核心场景。代码改动面只局限在 +`src/rar_extract.{c,h}` + `third_party/unrar/`,未来切换工作面极小。 + +### 14.3 关键工程决策(不要再讨论) + +1. **dmc_unrar vs alexbatalov/unrar vs opello/unrar vs libarchive** — + 见 `third_party/unrar/VENDORED.md` §"Why dmc_unrar" 表格。摘要:单文件 + + FLOSS + C99 = vendor 摩擦最小;opello 是"想要多卷加密"那 20% 用户的代价, + v1.8 不付。 + +2. **facade header 模式(`dmc_unrar_api.h`)** — 见 `docs/UPGRADE-v1.8-rar-support.md` + §5。这是 v1.8 最值得记下来的工程模式:vendor 一个独立的 .c 文件,绝对不要 + `#include "third_party.c"`,否则 host 构建系统的宏(`posix_compat.h` 的 + `wfm_open`/`wfm_close`)会和 vendor 内部结构体字段名打架。**通用做法**:写 + 100 行的 facade header,只 declare 你用到的符号。 + +3. **dispatch 用扩展名不用 magic 嗅探** — 见 `src/extract.c::extract_dispatch()`。 + 扩展名 O(1),magic 要 I/O 读 8 字节,对每 archive 调用走两次。可以后加 + magic-byte 嗅探作为 future improvement,但 v1.8 没必要。 + +4. **RAR 没 public schema 给前端** — `extract_format` 在 task struct 里没暴露, + 因为前端只需知道"能不能解压"(看扩展名),不需要知道"格式是 ZIP 还是 RAR", + 后端 dispatch 已经处理完。task struct 字段保持最小。 + +5. **`err_extract_unsupported` 文案** — 见 `assets/lang-{en,zh}.js`。明确告知 + "only unencrypted plain ZIP and single-volume RAR",前端根据这条就知道是否 + 弹密码(否)或弹"unrar on PC first"链接(可)。 + +### 14.4 接下来用户侧要做的(在 PowerShell / Git Bash,**非沙箱**) + +```bash +# 1. WSL 内重编 ELF(需 30s 编译 + 5s strip) +# 在 WSL Ubuntu-22.04 bash: +cd /home/song/ps5-web-file-manager +export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk +bash build-elf.sh +# 输出会: +# - 落 /home/song/ps5-web-file-manager/web-file-mgr.elf +# - 落 C:/Users/songl/Desktop/Web File Manager/ps5-web-file-manager/web-file-mgr.elf +# 记下 ls -la 与 sha256sum 的输出 + +# 2. PowerShell / Git Bash 里验证 +cd "C:/Users/songl/Desktop/Web File Manager/ps5-web-file-manager" +file web-file-mgr.elf # ELF 64-bit LSB pie, x86-64 +od -An -tx1 -N20 web-file-mgr.elf | head -2 # 7f45 4c46 0201 +od -An -tx2 -N1 -j18 web-file-mgr.elf | tr -d ' ' # 003e +python3 .build/check-elf-gzip.py ./web-file-mgr.elf | grep -E "(✓|✗)" | tail +# 期望 v1.8 新 key: err_extract_unsupported 出现在 ✓ 行 + +# 3. 把 size + sha256 填进 CHANGELOG.md v1.8 banner +# 当前内容 (line 7-9): +# > Release artifact for v1.8: +# > `web-file-mgr.elf` — size TBD (cross-compile runs in WSL — see `docs/HANDOVER.md`) +# > sha256 TBD +# 把 "TBD" 替换成实际值 + +# 4. 提交 + 推送 +git add -A +git -c core.autocrlf=false commit -m "v1.8: RAR4/RAR5 single-volume unencrypted (dmc_unrar backend) +- vendor DrMcCoy/dmc_unrar 1.7.0 into third_party/unrar/ (GPL-2.0-or-later) +- src/rar_extract.{c,h}: three-phase engine mirroring zip_extract +- third_party/unrar/dmc_unrar_api.h: project-authored facade header +- extract.c: dispatch layer (extension-based; magic fallback post-v1.8) +- Makefile: VERSION_TAG v1.8 + dmc_unrar TU + byte-swap workaround +- frontend: isExtractableArchive / isRarSubVolume + lang-* err string update +- host tests: +14 RAR negative-path checks (total 83) +- docs: CHANGELOG v1.8 / README RAR section / docs/UPGRADE-v1.8-rar-support.md +- THIRD_PARTY_NOTICES: section 3 dmc_unrar attribution +- docs/HANDOVER.md: v1.8 实际交付状态 (§14) for handoff" + +# 5. 用户在自己 PowerShell 推 +git push -u origin main # 一次性推所有 5 + 1 commit +git tag -a v1.8 -m "v1.8 — RAR4/RAR5 single-volume unencrypted (dmc_unrar)" +git push origin v1.8 + +# 6. (可选) PS5 真机部署 +# 看 §10.3 在 HANDOVER.md 的 manual integration test 流程 +``` + +### 14.5 给接手人的话 + +如果你是接手人: + +- **前 5 分钟**:读 §14(这节),知道 v1.8 干了什么、为什么没干剩下的(加密/多卷) +- **下一个 5 分钟**:跳 `docs/UPGRADE-v1.8-rar-support.md`,看架构图 + facade 模式 + + error mapping 表 +- **如果接活 = v1.9(多卷 + 加密)**:直接打开 + `third_party/unrar/VENDORED.md` §"Upgrading to a fuller library (v1.9 + plan)",按 5 步执行。**不要重新设计架构**,签名/调度/测试都不动。 +- **如果接活 = 别的格式(7z, tar.xz, …)**:照 v1.8 的样子 mirror 一份: + vendor + facade header + `src/_extract.{c,h}` + dispatch + tests + + docs。 +- **如果接活 = 真机调试某用户的 .rar 上传失败**:99% 是 §14.2 那张表的 + 边界 case(多卷 / 加密 / 旧版 / symlink 入口),看前端 `err_extract_unsupported` + 弹出来的 detail 字段就能定位。剩下的 1% 看 `rar_translate_error()` + 错误码映射 + dmc_unrar 自己的 issue tracker。 +- **沙箱限制**:git push 必走 PowerShell / Git Bash(沙箱 502),WSL 必走 + `bash build-elf.sh`(沙箱没 SDK)。build-elf.sh 用的是 staging → sudo cp 模式, + 见 §2.3。 + +如果某个环节卡住超过 2 小时,**优先回这里看 §14.4 + §9** —— 90% 的边角情况 +上面已经覆盖了。 + +加油 v1.9。 \ No newline at end of file diff --git a/src/extract.c b/src/extract.c index e13a9bb..739868d 100644 --- a/src/extract.c +++ b/src/extract.c @@ -175,7 +175,10 @@ extract_dispatch(file_task_t *task, zipx_conflict_t conflict, if(kind == 0) { status = zipx_extract(task->src, task->dst, conflict, limits, - extract_cancel, extract_progress, task, result); + extract_cancel, extract_progress, task, + task->extract_password[0] ? task->extract_password + : NULL, + 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. */ @@ -202,11 +205,17 @@ extract_dispatch(file_task_t *task, zipx_conflict_t conflict, } if(ends_with_ci(task->src, ".zip")) { return zipx_extract(task->src, task->dst, conflict, limits, - extract_cancel, extract_progress, task, result); + extract_cancel, extract_progress, task, + task->extract_password[0] ? task->extract_password + : NULL, + result); } if(ends_with_ci(task->src, ".rar")) { return rar_extract(task->src, task->dst, conflict, limits, - extract_cancel, extract_progress, task, result); + extract_cancel, extract_progress, task, + task->extract_password[0] ? task->extract_password + : NULL, + result); } if(ends_with_ci(task->src, ".7z")) { return sevenz_extract(task->src, task->dst, conflict, limits, @@ -236,6 +245,7 @@ extract_error_code(zipx_status_t status) { case ZIPX_ERR_LIMIT_RATIO: return "extract_ratio"; case ZIPX_ERR_LIMIT_DEPTH: return "extract_too_deep"; case ZIPX_ERR_LIMIT_NAME: return "extract_name_too_long"; + case ZIPX_ERR_LIMIT_DICT: return "extract_dict_too_large"; case ZIPX_ERR_CONFLICT: return "extract_conflict"; case ZIPX_ERR_SPACE: return "no_space"; case ZIPX_ERR_IO: return "extract_io"; diff --git a/src/pkg_info.c b/src/pkg_info.c index 2072e22..fd7a348 100644 --- a/src/pkg_info.c +++ b/src/pkg_info.c @@ -1,3 +1,23 @@ +/* PKG preview -- the /api/pkg_info and /api/pkg_icon endpoints. + * + * Independent C99 implementation of the PS5 .pkg container layout (entry table + * + PARAM.SFO + param.json + ICON0), written for this project. + * + * NOT derived from cy33hc/ps5-ezremote-client, which the README credits for the + * same feature. That project is GPL-2.0-only -- its sources carry no "or later" + * notice -- and therefore cannot be combined with this GPL-3.0 codebase at all. + * The two share nothing but the on-disk format facts: the SFO magic + * 0x46535000, the 20-byte header / 16-byte entry layout, and the key/value + * offset indirection. Those are dictated by the format and no implementation + * can avoid them. Everything else differs -- this file is C where that one is + * C++, it parses the .pkg entry table and param.json (upstream has no .pkg + * parser), and it tokenizes JSON itself (upstream links json-c). + * + * Provenance record and the line-by-line comparison: docs/REWRITE-FEASIBILITY.md + * section 2.2. Revisit that note if this file is ever rewritten or the upstream + * licence wording changes. + */ + #include "pkg_info.h" #include diff --git a/src/rar_extract.c b/src/rar_extract.c index a87ffce..21d844d 100644 --- a/src/rar_extract.c +++ b/src/rar_extract.c @@ -11,9 +11,11 @@ single-volume archives. * Multi-volume archives: unrar auto-merges subsequent volumes by name pattern when all .partNN.rar files sit next to the opened volume. - * Encrypted RAR: the engine can decrypt via RARSetPassword, but the - password plumbing (API + UI) is not wired yet — encrypted archives - currently fail with ZIPX_ERR_UNSUPPORTED. + * Encrypted archives (`-p` data encryption and `-hp` header encryption): + the password is handed to the engine via RARSetPassword immediately + after RAROpenArchiveEx and before the first RARReadHeaderEx, which is + the order unrar needs to decrypt a RAR5 header. A missing or wrong + password surfaces as ZIPX_ERR_PASSWORD. See third_party/unrar7/VENDORED.md for the full integration notes. */ @@ -62,6 +64,9 @@ typedef struct { zipx_progress_fn progress; void *userdata; zipx_result_t *result; + /* NULL when no password was supplied. Owned by the caller for the whole + call; RARSetPassword copies it into the engine, so it never dangles. */ + const char *password; uint64_t entries_total; uint64_t entries_done; uint64_t bytes_total; @@ -69,6 +74,13 @@ typedef struct { uint64_t files_created; uint64_t dirs_created; uint64_t progress_floor; + /* Set by the UCM_LARGEDICT callback only: the dictionary the archive asks + for and the limit we refuse above, both in KiB (0 = never raised, i.e. the + archive's dictionary was within Cmd->WinSizeLimit). unrar hands these over + as p1/p2, so the refusal can name the real numbers instead of blaming the + entry that happened to be in flight. */ + uint64_t dict_kb; + uint64_t dict_limit_kb; struct timespec last_report; char staging[ZIPX_PATH_MAX]; char **created; @@ -456,16 +468,33 @@ rar_translate_error(int code, const char *detail, rarx_ctx_t *c) { case ERAR_MISSING_PASSWORD: case ERAR_BAD_PASSWORD: - /* Password plumbing (API + UI) is not wired yet. */ - return rarx_fail(c, ZIPX_ERR_UNSUPPORTED, detail, - "encrypted RAR entries are not supported"); + /* The archive needs a password we do not have, or the one supplied was + wrong. ZIPX_ERR_PASSWORD lets the caller prompt and retry. */ + return rarx_fail(c, ZIPX_ERR_PASSWORD, detail, + "the archive is encrypted and the password is missing " + "or wrong"); case ERAR_SMALL_BUF: return rarx_fail(c, ZIPX_ERR_LIMIT_NAME, detail, "name buffer is too small"); - case ERAR_LARGE_DICT: - return rarx_fail(c, ZIPX_ERR_LIMIT_FILE, detail, - "archive needs a larger dictionary than supported"); + case ERAR_LARGE_DICT: { + /* Not "entry too large": the *dictionary* is, and that is a property of the + archive (RAR7 headers can ask for up to 64 GiB), not of the entry that + happened to be in flight. Report both numbers; unrar gave us exactly + these when it asked. */ + char need[96]; + + if(c->dict_kb) { + snprintf(need, sizeof(need), "%llu MiB (limit %llu MiB)", + (unsigned long long)(c->dict_kb / 1024), + (unsigned long long)(c->dict_limit_kb / 1024)); + } else { + snprintf(need, sizeof(need), "more than 4096 MiB"); + } + return rarx_fail(c, ZIPX_ERR_LIMIT_DICT, need, + "the archive needs a dictionary larger than this build " + "supports (%s)", need); + } case ERAR_BAD_DATA: return rarx_fail(c, ZIPX_ERR_CRC, detail, "checksum mismatch in entry data"); @@ -651,12 +680,13 @@ scan_archive(HANDLE hArc, rarx_ctx_t *c) { } is_dir = (hdr.Flags & RHDF_DIRECTORY) ? 1 : 0; - /* Encrypted entries: the unrar engine can decrypt them via RARSetPassword, - but the password plumbing is not wired yet — reject up front with the - same message the v1.8 backend used. */ - if(hdr.Flags & RHDF_ENCRYPTED) { - ret = rarx_fail(c, ZIPX_ERR_UNSUPPORTED, hdr.FileName, - "encrypted RAR entries are not supported"); + /* Encrypted entries are fine as long as a password is in play: the engine + decrypts them during RARProcessFile once RARSetPassword has run. With no + password, fail here — before anything is written to staging — so the + caller can prompt and retry. */ + if((hdr.Flags & RHDF_ENCRYPTED) && !c->password) { + ret = rarx_fail(c, ZIPX_ERR_PASSWORD, hdr.FileName, + "the archive is encrypted and no password was supplied"); break; } @@ -748,16 +778,35 @@ scan_archive(HANDLE hArc, rarx_ctx_t *c) { Returning -1 is how unrar aborts a run, but the break path is only armed when console break handling is enabled, which never happens in DLL mode — - so we always return 0 and cancellation stays entry-granular. */ + so we always return 0 and cancellation stays entry-granular. + + The same callback is the only channel through which unrar asks permission + for an oversized dictionary (UCM_LARGEDICT); see below. */ static int CALLBACK rar_data_cb(UINT msg, LPARAM user, LPARAM p1, LPARAM p2) { rarx_ctx_t *c = (rarx_ctx_t *)user; - (void)p1; if(msg == UCM_PROCESSDATA) { c->bytes_done += (uint64_t)(unsigned long)p2; report(c, ZIPX_PHASE_EXTRACT, NULL, 0); + return 0; } + + if(msg == UCM_LARGEDICT) { + /* unrar asks permission before it allocates a window bigger than + Cmd->WinSizeLimit (default 4 GiB, options.cpp:13). Answering 1 would let + the run continue -- and that is a *trap*, not a fix: the window is one + contiguous allocation of the full dictionary size, and rarlab's own CLI + refuses the same case with "8 GB dictionary exceeds the 4 GB limit and + needs more than 8 GB of memory; use -md8g or -mdx8g". A PS5 has 16 GB of + shared memory, so >4 GiB dictionaries are not extractable there anyway; + failing cleanly beats being OOM-killed mid-extraction with the UI gone. + Record the numbers (p1/p2, both KiB) so the refusal can explain itself. */ + c->dict_kb = (uint64_t)(unsigned long)p1; + c->dict_limit_kb = (uint64_t)(unsigned long)p2; + return 0; + } + return 0; } @@ -794,10 +843,10 @@ extract_archive(HANDLE hArc, rarx_ctx_t *c) { ret = rarx_fail(c, ZIPX_ERR_FORMAT, NULL, "empty entry name"); break; } - if(hdr.Flags & RHDF_ENCRYPTED) { - /* scan already rejected these; defensive only. */ - ret = rarx_fail(c, ZIPX_ERR_UNSUPPORTED, hdr.FileName, - "encrypted RAR entries are not supported"); + if((hdr.Flags & RHDF_ENCRYPTED) && !c->password) { + /* scan already rejected password-less encrypted sets; defensive only. */ + ret = rarx_fail(c, ZIPX_ERR_PASSWORD, hdr.FileName, + "the archive is encrypted and no password was supplied"); break; } @@ -989,7 +1038,7 @@ zipx_status_t rar_extract(const char *rar_path, const char *dst_dir, zipx_conflict_t conflict, const zipx_limits_t *limits, zipx_cancel_fn cancel, zipx_progress_fn progress, - void *userdata, zipx_result_t *result) { + void *userdata, const char *password, zipx_result_t *result) { rarx_ctx_t ctx; rarx_ctx_t *c = &ctx; char parent[ZIPX_PATH_MAX]; @@ -1016,6 +1065,9 @@ rar_extract(const char *rar_path, const char *dst_dir, c->cancel = cancel; c->progress = progress; c->userdata = userdata; + /* An empty string means "no password" so that callers can pass the raw + form field without a separate emptiness check. */ + c->password = (password && password[0]) ? password : NULL; snprintf(dst_copy, sizeof(dst_copy), "%s", dst_dir); { @@ -1052,6 +1104,11 @@ rar_extract(const char *rar_path, const char *dst_dir, status = rar_translate_error((int)od.OpenResult, rar_path, c); goto done; } + /* Must precede the first RARReadHeaderEx: unrar needs the password in + place to decrypt a -hp (encrypted header) archive. */ + if(c->password) { + WFM_RAR_PASSWORD(hArc, (char *)c->password); + } } if(scan_archive(hArc, c)) { @@ -1094,6 +1151,9 @@ rar_extract(const char *rar_path, const char *dst_dir, status = rar_translate_error((int)od.OpenResult, rar_path, c); goto done; } + if(c->password) { + WFM_RAR_PASSWORD(hArc, (char *)c->password); + } } if(extract_archive(hArc, c)) { diff --git a/src/rar_extract.h b/src/rar_extract.h index f8f6fce..44f4eb3 100644 --- a/src/rar_extract.h +++ b/src/rar_extract.h @@ -12,9 +12,10 @@ Backend notes (v1.9, unrar 7.20.1): * RAR4 and RAR5, any compression version including WinRAR 6/7 "v6". * Multi-volume: unrar merges next .partNN.rar by name automatically. - * Encrypted RAR is NOT yet supported end-to-end: the engine can decrypt - via RARSetPassword, but password plumbing (API + UI) is unwired, so - encrypted headers/entries fail with ZIPX_ERR_UNSUPPORTED today. + * Encrypted archives work end-to-end (both `-p` data encryption and + `-hp` header encryption). Pass the password in `password`; NULL or an + empty string means "no password supplied". A missing or wrong password + is reported as ZIPX_ERR_PASSWORD so the caller can prompt and retry. See third_party/unrar7/VENDORED.md for full integration notes. */ @@ -24,6 +25,7 @@ #include /* Extract rar_path into dst_dir using the same protocol as zipx_extract(). + `password` may be NULL when the archive is not encrypted. Returns ZIPX_OK or an error code; *result is always filled in. On any failure the staging directory is removed and dst_dir is left as it was, except for objects already published with the overwrite policy. */ @@ -33,4 +35,5 @@ zipx_status_t rar_extract(const char *rar_path, const char *dst_dir, zipx_cancel_fn cancel, zipx_progress_fn progress, void *userdata, + const char *password, zipx_result_t *result); \ No newline at end of file diff --git a/src/sevenz_chain.h b/src/sevenz_chain.h index 23399de..258138b 100644 --- a/src/sevenz_chain.h +++ b/src/sevenz_chain.h @@ -37,11 +37,10 @@ * 7zAES (method 0x06F10701), the coder 7-Zip wraps around the streams when `-p` is used, driven from a caller supplied password - Not supported here (by design, see sz_chain_check): - * an encrypted *header* (`-mhe=on`): that is not a coder in a folder but a - second, encrypted copy of the archive header, which has to be decrypted - and parsed before any folder exists at all. Reported by the SDK header - reader as SZ_ERROR_UNSUPPORTED, not by this module. + An encrypted *header* (`-mhe=on`) is not a coder in a folder: it is a second, + encrypted copy of the archive header that has to be decoded before any folder + exists at all. src/sevenz_header.c does that -- by parsing that one folder's + descriptor and running it through this module, password and all. */ #ifndef SEVENZ_CHAIN_H diff --git a/src/sevenz_extract.c b/src/sevenz_extract.c index e43745d..e6e5f5d 100644 --- a/src/sevenz_extract.c +++ b/src/sevenz_extract.c @@ -37,6 +37,7 @@ #include "7zFile.h" #include "sevenz_chain.h" +#include "sevenz_header.h" #include "sevenz_mt.h" #include "sevenz_volstream.h" @@ -1619,6 +1620,9 @@ sevenz_extract(const char *sevenz_path, const char *dst_dir, int dst_existed = 0; int status; static int tables_ready; + szh_prep *hdr_prep = NULL; + ISeekInStream *hdr_stream = NULL; + char hdr_msg[256] = ""; if(!result || !sevenz_path || !sevenz_path[0] || !dst_dir || !dst_dir[0]) { if(result) { @@ -1686,8 +1690,49 @@ sevenz_extract(const char *sevenz_path, const char *dst_dir, } look_stream.buf = look_buf; look_stream.bufSize = SZX_INPUT_BUF_SIZE; - look_stream.realStream = sevenz_volstream_stream(vol); + + /* An encrypted header (-mhe=on) hides the whole folder table, so it has to be + decrypted before the SDK can read anything at all. szh_prepare() reports + SZH_PLAIN for every other archive, and then the SDK sees exactly the stream + it always did. */ + hdr_stream = sevenz_volstream_stream(vol); + switch(szh_prepare(&hdr_prep, hdr_stream, c->password, hdr_msg, + sizeof(hdr_msg))) { + case SZH_PATCHED: + hdr_stream = szh_stream(hdr_prep); + break; + case SZH_PLAIN: + break; + case SZH_ERR_PASSWORD: + status = szx_fail(c, ZIPX_ERR_PASSWORD, sevenz_path, "%s: %s", vol_desc, + hdr_msg); + goto done; + case SZH_ERR_UNSUPPORTED: + status = szx_fail(c, ZIPX_ERR_UNSUPPORTED, sevenz_path, "%s: %s", vol_desc, + hdr_msg); + goto done; + case SZH_ERR_IO: + status = szx_fail(c, ZIPX_ERR_IO, sevenz_path, "%s: %s", vol_desc, hdr_msg); + goto done; + default: + status = szx_fail(c, ZIPX_ERR_FORMAT, sevenz_path, "%s: %s", vol_desc, + hdr_msg); + goto done; + } + look_stream.realStream = hdr_stream; LookToRead2_INIT(&look_stream); + { + /* Inspecting the start header moved the stream around, and LookToRead2 + reads from wherever it finds the stream on its first call -- it does not + seek. Put it back at byte 0. */ + Int64 zero = 0; + + if(hdr_stream->Seek(hdr_stream, &zero, SZ_SEEK_SET) != SZ_OK) { + status = szx_fail(c, ZIPX_ERR_IO, sevenz_path, "%s: seek failed", + vol_desc); + goto done; + } + } SzArEx_Init(&db); res = SzArEx_Open(&db, &look_stream.vt, &g_sz_alloc, &g_sz_alloc); @@ -1695,8 +1740,8 @@ sevenz_extract(const char *sevenz_path, const char *dst_dir, /* SzArEx_Open already released everything it allocated. */ if(res == SZ_ERROR_UNSUPPORTED) { status = szx_fail(c, ZIPX_ERR_UNSUPPORTED, sevenz_path, - "%s: the archive header is encrypted (-mhe=on); only " - "archives whose header is readable can be unpacked", + "%s: the archive header is compressed with a method the " + "bundled decoder does not have", vol_desc); } else { status = szx_fail(c, ZIPX_ERR_FORMAT, sevenz_path, @@ -1772,6 +1817,7 @@ done: if(look_buf) { ISzAlloc_Free(&g_sz_alloc, look_buf); } + szh_prep_free(hdr_prep); sevenz_volstream_free(vol); szx_cleanup_staging(c); result->entries_total = c->entries_total; diff --git a/src/sevenz_extract.h b/src/sevenz_extract.h index eb9dcb4..bdc8041 100644 --- a/src/sevenz_extract.h +++ b/src/sevenz_extract.h @@ -14,8 +14,9 @@ Backend notes (LZMA SDK 26.03 + src/sevenz_chain.c): * Copy / LZMA / LZMA2 / PPMd, the Delta filter and the x86 / PPC / IA64 / ARM / ARMT / SPARC branch converters, BCJ2, and 7zAES. - * An encrypted *header* (`-mhe=on`) is not readable: the SDK refuses it - before any folder is known, and we report exactly that. + * An encrypted *header* (`-mhe=on`) is decrypted by src/sevenz_header.c + first: the SDK refuses such an archive before any folder is known, so + the header has to be readable before the SDK is asked to read it. */ #include "zip_extract.h" diff --git a/src/sevenz_header.c b/src/sevenz_header.c new file mode 100644 index 0000000..718d5ac --- /dev/null +++ b/src/sevenz_header.c @@ -0,0 +1,917 @@ +/* sevenz_header -- see sevenz_header.h for what this does and why. */ + +#include +#include +#include + +#include "7z.h" +#include "7zCrc.h" +#include "7zTypes.h" + +#include "sevenz_chain.h" +#include "sevenz_header.h" + +/* The header property ids we have to recognise. They are the same enum the + SDK's header scanner uses (7zArcIn.c), spelled out here so this file does + not depend on that translation unit's internals. */ +#define SZH_ID_END 0x00 +#define SZH_ID_HEADER 0x01 +#define SZH_ID_PACK_INFO 0x06 +#define SZH_ID_UNPACK_INFO 0x07 +#define SZH_ID_SIZE 0x09 +#define SZH_ID_CRC 0x0A +#define SZH_ID_FOLDER 0x0B +#define SZH_ID_CODERS_UNPACK_SIZE 0x0C +#define SZH_ID_ENCODED_HEADER 0x17 + +#define SZH_MAX_CODERS SZ_CHAIN_MAX_CODERS +#define SZH_MAX_STREAMS SZ_CHAIN_MAX_STREAMS + +/* --------------------------------------------------------------- numbers */ + +static uint32_t +szh_le32(const uint8_t *p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | + ((uint32_t)p[3] << 24); +} + +static uint64_t +szh_le64(const uint8_t *p) { + return (uint64_t)szh_le32(p) | ((uint64_t)szh_le32(p + 4) << 32); +} + +static void +szh_put_le32(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)v; + p[1] = (uint8_t)(v >> 8); + p[2] = (uint8_t)(v >> 16); + p[3] = (uint8_t)(v >> 24); +} + +static void +szh_put_le64(uint8_t *p, uint64_t v) { + szh_put_le32(p, (uint32_t)v); + szh_put_le32(p + 4, (uint32_t)(v >> 32)); +} + +/* The 7z variable length number: the high bits of the first byte say how many + more bytes follow, and the remaining bits of the first byte are the high + part of the value. This mirrors ReadNumber() in 7zArcIn.c byte for byte, + including its habit of returning a partial value when all eight flag bits + are set -- the caller checks the CRC of the whole record anyway. */ +static int +szh_num(const uint8_t *d, size_t size, size_t *pos, uint64_t *value) { + size_t p = *pos; + unsigned first, mask, v, i; + + if(p >= size) { + return -1; + } + first = d[p++]; + if((first & 0x80) == 0) { + *value = first; + *pos = p; + return 0; + } + if(p >= size) { + return -1; + } + v = d[p++]; + if((first & 0x40) == 0) { + *value = ((uint64_t)(first & 0x3F) << 8) | v; + *pos = p; + return 0; + } + if(p >= size) { + return -1; + } + mask = d[p++]; + *value = (uint64_t)v | ((uint64_t)mask << 8); + mask = 0x20; + for(i = 16; i < 64; i += 8) { + if((first & mask) == 0) { + *value |= (uint64_t)(first & (mask - 1)) << i; + *pos = p; + return 0; + } + mask >>= 1; + if(p >= size) { + return -1; + } + *value |= (uint64_t)d[p++] << i; + } + *pos = p; + return 0; +} + +/* The 32 bit form used for counts and property sizes. */ +static int +szh_num32(const uint8_t *d, size_t size, size_t *pos, uint32_t *value) { + uint64_t v; + + if(*pos < size && (d[*pos] & 0x80) == 0) { + *value = d[(*pos)++]; + return 0; + } + if(szh_num(d, size, pos, &v)) { + return -1; + } + if(v >= (uint64_t)0x80000000u - 1) { + return -1; + } + *value = (uint32_t)v; + return 0; +} + +static uint32_t +szh_count_bits(const uint8_t *d, uint32_t num_items) { + uint32_t n = 0, i; + + for(i = 0; i < num_items; i++) { + if(d[i >> 3] & (0x80u >> (i & 7))) { + n++; + } + } + return n; +} + +/* A digest block: one "all are defined" byte, an optional bit vector, then one + little endian CRC per defined item. With `first` the value of the first + defined item comes back, which is the folder CRC when the block covers a + single folder. */ +static int +szh_digests(const uint8_t *d, size_t size, size_t *pos, uint32_t num_items, + uint32_t *first, int *has_first) { + size_t p = *pos; + uint32_t defined = num_items; + unsigned all; + + if(p >= size) { + return -1; + } + all = d[p++]; + if(!all) { + size_t bytes = ((size_t)num_items + 7) >> 3; + + if(bytes > size - p) { + return -1; + } + defined = szh_count_bits(d + p, num_items); + p += bytes; + } + if((size_t)defined > (size - p) >> 2) { + return -1; + } + if(first && has_first) { + if(defined) { + *first = szh_le32(d + p); + *has_first = 1; + } else { + *has_first = 0; + } + } + p += (size_t)defined * 4; + *pos = p; + return 0; +} + +/* The SDK's SkipData(): a length, then that many bytes. */ +static int +szh_skip_data(const uint8_t *d, size_t size, size_t *pos) { + uint64_t n; + + if(szh_num(d, size, pos, &n)) { + return -1; + } + if(n > (uint64_t)(size - *pos)) { + return -1; + } + *pos += (size_t)n; + return 0; +} + +/* --------------------------------------------------------- encoded header */ + +typedef struct { + uint64_t pack_pos; /* where this folder's packs live, from offset 32 */ + uint32_t num_pack; + uint64_t pack_sizes[SZH_MAX_STREAMS]; + uint64_t pack_positions[SZH_MAX_STREAMS + 1]; /* cumulative, as the SDK builds */ + const uint8_t *blob; /* coder descriptor, in place */ + size_t blob_size; + uint32_t num_coders; + uint32_t main_coder; + uint64_t coder_unpack_sizes[SZH_MAX_CODERS]; + uint64_t unpack_size; + uint32_t crc; + int has_crc; +} szh_streams_t; + +/* One folder descriptor: the coders, then the bond pairs and the pack-stream + indices that follow them. `*pos` ends up just past the last of those, so + [start, *pos) is exactly the byte range the SDK keeps as + CSzAr::CodersData[FoCodersOffsets[0] .. FoCodersOffsets[1]). */ +static int +szh_parse_folder(const uint8_t *d, size_t size, size_t *pos, szh_streams_t *ss) { + const size_t start = *pos; + size_t p = start; + uint32_t num_coders = 0, num_in = 0, num_bonds, num_pack, i; + uint8_t coder_used[SZH_MAX_CODERS]; + uint8_t stream_used[SZH_MAX_STREAMS]; + uint32_t main_index = 0; + + if(szh_num32(d, size, &p, &num_coders)) { + return -1; + } + if(num_coders == 0 || num_coders > SZH_MAX_CODERS) { + return -1; + } + + for(i = 0; i < num_coders; i++) { + uint8_t main_byte; + uint32_t id_size, coder_in = 1; + + if(p >= size) { + return -1; + } + main_byte = d[p++]; + if(main_byte & 0xC0) { + return -1; + } + id_size = main_byte & 0x0F; + if(id_size > 8 || (size_t)id_size > size - p) { + return -1; + } + p += id_size; + if(main_byte & 0x10) { + uint32_t coder_out; + + if(szh_num32(d, size, &p, &coder_in) || + szh_num32(d, size, &p, &coder_out)) { + return -1; + } + /* The header scanner accepts exactly one output stream per coder, so a + coder index and its output-stream index coincide. */ + if(coder_out != 1) { + return -1; + } + } + if(num_in >= SZH_MAX_STREAMS || coder_in > SZH_MAX_STREAMS - num_in) { + return -1; + } + num_in += coder_in; + if(main_byte & 0x20) { + uint32_t props_size; + + if(szh_num32(d, size, &p, &props_size) || + (size_t)props_size > size - p) { + return -1; + } + p += props_size; + } + } + + num_bonds = num_coders - 1; + if(num_in < num_bonds) { + return -1; + } + num_pack = num_in - num_bonds; + if(num_pack != ss->num_pack) { + return -1; + } + + memset(coder_used, 0, sizeof(coder_used)); + memset(stream_used, 0, sizeof(stream_used)); + + for(i = 0; i < num_bonds; i++) { + uint32_t in_index, out_index; + + if(szh_num32(d, size, &p, &in_index)) { + return -1; + } + if(in_index >= num_in || stream_used[in_index]) { + return -1; + } + stream_used[in_index] = 1; + if(szh_num32(d, size, &p, &out_index)) { + return -1; + } + if(out_index >= num_coders || coder_used[out_index]) { + return -1; + } + coder_used[out_index] = 1; + } + if(num_pack != 1) { + for(i = 0; i < num_pack; i++) { + uint32_t index; + + if(szh_num32(d, size, &p, &index)) { + return -1; + } + if(index >= num_in || stream_used[index]) { + return -1; + } + stream_used[index] = 1; + } + } + + /* The coder no bond consumes produces the folder's output. */ + while(main_index < num_coders && coder_used[main_index]) { + main_index++; + } + if(main_index >= num_coders) { + return -1; + } + + ss->blob = d + start; + ss->blob_size = p - start; + ss->num_coders = num_coders; + ss->main_coder = main_index; + *pos = p; + return 0; +} + +/* Reads the StreamsInfo of a k7zIdEncodedHeader record. Only the parts this + module acts on are interpreted; anything else is skipped the way the SDK + skips an id it does not know. */ +static int +szh_parse_streams(const uint8_t *d, size_t size, szh_streams_t *ss) { + size_t pos = 1; /* past k7zIdEncodedHeader */ + uint64_t id; + uint32_t i; + int seen_folder = 0, seen_sizes = 0; + + memset(ss, 0, sizeof(*ss)); + + if(szh_num(d, size, &pos, &id) || id != SZH_ID_PACK_INFO) { + return -1; + } + if(szh_num(d, size, &pos, &ss->pack_pos) || + szh_num32(d, size, &pos, &ss->num_pack)) { + return -1; + } + if(ss->num_pack == 0 || ss->num_pack > SZH_MAX_STREAMS) { + return -1; + } + { + int got_sizes = 0; + + for(;;) { + if(szh_num(d, size, &pos, &id)) { + return -1; + } + if(id == SZH_ID_END) { + break; + } + if(id == SZH_ID_SIZE) { + if(got_sizes) { + return -1; + } + for(i = 0; i < ss->num_pack; i++) { + if(szh_num(d, size, &pos, &ss->pack_sizes[i])) { + return -1; + } + } + got_sizes = 1; + continue; + } + if(szh_skip_data(d, size, &pos)) { + return -1; + } + } + if(!got_sizes) { + return -1; + } + } + ss->pack_positions[0] = 0; + for(i = 0; i < ss->num_pack; i++) { + ss->pack_positions[i + 1] = ss->pack_positions[i] + ss->pack_sizes[i]; + if(ss->pack_positions[i + 1] < ss->pack_positions[i]) { + return -1; + } + } + + if(szh_num(d, size, &pos, &id) || id != SZH_ID_UNPACK_INFO) { + return -1; + } + for(;;) { + if(szh_num(d, size, &pos, &id)) { + return -1; + } + if(id == SZH_ID_END) { + break; + } + if(id == SZH_ID_FOLDER) { + uint32_t num_folders; + + if(seen_folder || szh_num32(d, size, &pos, &num_folders)) { + return -1; + } + /* SzArEx_Open2 decodes this record with numFoldersMax = 1, and the + `external` flag has to be clear for the table to be inline. */ + if(num_folders != 1 || pos >= size || d[pos] != 0) { + return -1; + } + pos++; + if(szh_parse_folder(d, size, &pos, ss)) { + return -1; + } + seen_folder = 1; + continue; + } + if(id == SZH_ID_CODERS_UNPACK_SIZE) { + if(!seen_folder || seen_sizes) { + return -1; + } + for(i = 0; i < ss->num_coders; i++) { + if(szh_num(d, size, &pos, &ss->coder_unpack_sizes[i])) { + return -1; + } + } + seen_sizes = 1; + continue; + } + if(id == SZH_ID_CRC) { + uint32_t crc = 0; + int has = 0; + + if(ss->has_crc || szh_digests(d, size, &pos, 1, &crc, &has)) { + return -1; + } + if(has) { + ss->crc = crc; + ss->has_crc = 1; + } + continue; + } + if(szh_skip_data(d, size, &pos)) { + return -1; + } + } + if(!seen_folder || !seen_sizes) { + return -1; + } + ss->unpack_size = ss->coder_unpack_sizes[ss->main_coder]; + + /* A SubStreamsInfo may hold the folder CRC when UnpackInfo carried none, but + nothing past this point changes what we decode. */ + return 0; +} + +/* ------------------------------------------------------------- stream I/O */ + +static uint64_t +szh_size_of(ISeekInStream *s) { + Int64 pos = 0; + + if(s->Seek(s, &pos, SZ_SEEK_END) != SZ_OK || pos < 0) { + return 0; + } + return (uint64_t)pos; +} + +static int +szh_read_at(ISeekInStream *s, uint64_t offset, void *dst, size_t size) { + Int64 pos = (Int64)offset; + size_t got = 0; + + if(s->Seek(s, &pos, SZ_SEEK_SET) != SZ_OK) { + return -1; + } + while(got < size) { + size_t want = size - got; + + if(s->Read(s, (uint8_t *)dst + got, &want) != SZ_OK) { + return -1; + } + if(want == 0) { + return -1; + } + got += want; + } + return 0; +} + +/* The virtual view handed to the SDK. `vt` has to stay first: the SDK casts + the interface pointer straight back to this struct, exactly as + sevenz_volstream.c does. */ +typedef struct { + ISeekInStream vt; + + ISeekInStream *raw; + uint64_t raw_size; + uint64_t pos; + uint64_t total; /* the virtual length; the header may stick out past the file */ + + uint8_t sig[k7zStartHeaderSize]; /* start header, rewritten for the plaintext */ + uint64_t hdr_off; + uint8_t *hdr; + uint64_t hdr_len; +} szh_view; + +struct szh_prep { + szh_view view; +}; + +static SRes +szh_view_read(const ISeekInStream *p, void *buf, size_t *size) { + szh_view *v = (szh_view *)p; + uint8_t *dst = (uint8_t *)buf; + const size_t want = *size; + size_t got = 0; + + *size = 0; + while(got < want) { + const uint64_t pos = v->pos; + const uint64_t hdr_end = v->hdr_off + v->hdr_len; + + if(pos < k7zStartHeaderSize) { + size_t n = (size_t)(k7zStartHeaderSize - pos); + + if(n > want - got) { + n = want - got; + } + memcpy(dst + got, v->sig + pos, n); + got += n; + v->pos += n; + continue; + } + if(pos >= v->hdr_off && pos < hdr_end) { + size_t n = (size_t)(hdr_end - pos); + + if(n > want - got) { + n = want - got; + } + memcpy(dst + got, v->hdr + (pos - v->hdr_off), n); + got += n; + v->pos += n; + continue; + } + /* Everything else is the real archive. The header region can reach past + its end, so a read is allowed to stop short here. */ + if(pos >= v->raw_size) { + break; + } + { + Int64 raw_pos = (Int64)pos; + size_t n = want - got; + const uint64_t avail = v->raw_size - pos; + + if((uint64_t)n > avail) { + n = (size_t)avail; + } + if(v->raw->Seek(v->raw, &raw_pos, SZ_SEEK_SET) != SZ_OK) { + return SZ_ERROR_READ; + } + if(v->raw->Read(v->raw, dst + got, &n) != SZ_OK) { + return SZ_ERROR_READ; + } + if(n == 0) { + break; + } + got += n; + v->pos += n; + } + } + *size = got; + return SZ_OK; +} + +static SRes +szh_view_seek(const ISeekInStream *p, Int64 *pos, ESzSeek origin) { + szh_view *v = (szh_view *)p; + Int64 base; + Int64 next; + + switch(origin) { + case SZ_SEEK_SET: + base = 0; + break; + case SZ_SEEK_CUR: + base = (Int64)v->pos; + break; + case SZ_SEEK_END: + base = (Int64)v->total; + break; + default: + return SZ_ERROR_PARAM; + } + next = base + *pos; + if(next < 0) { + next = 0; + } + v->pos = (uint64_t)next; + *pos = next; + return SZ_OK; +} + +/* ------------------------------------------------------ decoding the header */ + +typedef struct { + ISeekInStream *raw; + uint64_t base; +} szh_reader_t; + +static int +szh_chain_read(void *ctx, uint64_t offset, void *dst, size_t size) { + szh_reader_t *r = (szh_reader_t *)ctx; + + return szh_read_at(r->raw, r->base + offset, dst, size); +} + +/* Collects the decrypted header. It grows on demand rather than trusting the + declared unpack size, which is attacker controlled. */ +typedef struct { + uint8_t *data; + size_t len; + size_t cap; + int failed; +} szh_sink_t; + +static int +szh_sink_write(void *ctx, const void *data, size_t size) { + szh_sink_t *s = (szh_sink_t *)ctx; + + if(s->failed) { + return -1; + } + if((uint64_t)size > SZH_MAX_HEADER - (uint64_t)s->len) { + s->failed = 1; + return -1; + } + if(s->len + size > s->cap) { + size_t cap = s->cap ? s->cap : 4096; + uint8_t *grown; + + while(cap < s->len + size) { + cap *= 2; + } + grown = (uint8_t *)realloc(s->data, cap); + if(!grown) { + s->failed = 1; + return -1; + } + s->data = grown; + s->cap = cap; + } + memcpy(s->data + s->len, data, size); + s->len += size; + return 0; +} + +/* ------------------------------------------------------------------ public */ + +const char * +szh_status_string(szh_status_t status) { + switch(status) { + case SZH_PLAIN: + return "the archive header is readable"; + case SZH_PATCHED: + return "the archive header was decrypted"; + case SZH_ERR_PASSWORD: + return "the archive header is encrypted"; + case SZH_ERR_UNSUPPORTED: + return "the archive header uses an unsupported arrangement"; + case SZH_ERR_FORMAT: + return "the archive header is malformed"; + case SZH_ERR_IO: + return "the archive header could not be read"; + } + return "unknown"; +} + +static void +szh_set_msg(char *msg, size_t msg_size, const char *text, const char *detail) { + if(!msg || !msg_size) { + return; + } + if(detail) { + snprintf(msg, msg_size, "%s: %s", text, detail); + } else { + snprintf(msg, msg_size, "%s", text); + } +} + +szh_status_t +szh_prepare(szh_prep **out, ISeekInStream *raw, const char *password, + char *msg, size_t msg_size) { + uint8_t sig[k7zStartHeaderSize]; + uint64_t raw_size, next_off, next_size; + uint32_t next_crc; + uint8_t *raw_hdr = NULL; + szh_streams_t ss; + sz_chain *chain = NULL; + sz_chain_err_t cerr; + szh_sink_t sink; + szh_reader_t reader; + szh_prep *prep = NULL; + szh_status_t status = SZH_PLAIN; + uint32_t decoded_crc = 0; + + if(out) { + *out = NULL; + } + if(!out || !raw) { + return SZH_ERR_IO; + } + if(msg && msg_size) { + msg[0] = 0; + } + + /* Sevenz extraction runs this before SzArEx_Open, but the table is what + every CRC below needs and generating it twice costs nothing. */ + CrcGenerateTable(); + memset(&cerr, 0, sizeof(cerr)); + memset(&sink, 0, sizeof(sink)); + + /* --- the start header ------------------------------------------------ */ + raw_size = szh_size_of(raw); + if(raw_size < k7zStartHeaderSize || szh_read_at(raw, 0, sig, sizeof(sig))) { + return SZH_PLAIN; + } + if(memcmp(sig, k7zSignature, k7zSignatureSize) != 0 || sig[6] != 0) { + return SZH_PLAIN; + } + if(CrcCalc(sig + 12, 20) != szh_le32(sig + 8)) { + return SZH_PLAIN; + } + + next_off = szh_le64(sig + 12); + next_size = szh_le64(sig + 20); + next_crc = szh_le32(sig + 28); + + if(next_size == 0 || next_size > SZH_MAX_HEADER) { + return SZH_PLAIN; + } + if(next_off > raw_size || next_size > raw_size - next_off) { + return SZH_PLAIN; + } + + /* --- the next header ------------------------------------------------- */ + /* One byte decides it: an ordinary header (k7zIdHeader) is none of our + business, and a big uncompressed one is not worth reading twice. */ + { + uint8_t first_byte = 0; + + if(szh_read_at(raw, k7zStartHeaderSize + next_off, &first_byte, 1) || + first_byte != SZH_ID_ENCODED_HEADER) { + return SZH_PLAIN; + } + } + + raw_hdr = (uint8_t *)malloc((size_t)next_size); + if(!raw_hdr) { + szh_set_msg(msg, msg_size, "out of memory reading the archive header", + NULL); + return SZH_ERR_IO; + } + if(szh_read_at(raw, k7zStartHeaderSize + next_off, raw_hdr, + (size_t)next_size) || + CrcCalc(raw_hdr, (size_t)next_size) != next_crc) { + /* Broken or truncated: let the SDK diagnose it exactly as it always has. */ + status = SZH_PLAIN; + goto done; + } + if(szh_parse_streams(raw_hdr, (size_t)next_size, &ss)) { + status = SZH_PLAIN; + goto done; + } + + /* --- the folder behind it -------------------------------------------- */ + if(sz_chain_parse(&chain, ss.blob, ss.blob_size, ss.pack_positions, + ss.num_pack, ss.coder_unpack_sizes, ss.unpack_size, + sz_chain_default_limits(), &cerr) != 0) { + /* Not ours to report: the SDK rejects such an archive too, and it is the + one that knows how to describe it. */ + status = SZH_PLAIN; + goto done; + } + if(!sz_chain_needs_password(chain)) { + /* An ordinary compressed header (-mhc=on), which the SDK decodes itself. */ + status = SZH_PLAIN; + goto done; + } + if(!password || !password[0]) { + szh_set_msg(msg, msg_size, + "the archive header is encrypted (-mhe=on), so the file names, " + "the folder table and the entry sizes are all inside it and the " + "archive cannot be listed or unpacked without the password", + NULL); + status = SZH_ERR_PASSWORD; + goto done; + } + + reader.raw = raw; + reader.base = (uint64_t)k7zStartHeaderSize + ss.pack_pos; + if(sz_chain_decode(chain, szh_chain_read, &reader, szh_sink_write, &sink, NULL, + NULL, password, &decoded_crc, &cerr) != 0 || + sink.failed) { + switch(cerr.status) { + case SZ_CHAIN_ERR_PASSWORD: + case SZ_CHAIN_ERR_DATA: + szh_set_msg(msg, msg_size, + "the encrypted archive header did not decrypt: the password " + "is wrong, or the archive is damaged", + cerr.message); + status = SZH_ERR_PASSWORD; + break; + case SZ_CHAIN_ERR_LIMIT: + case SZ_CHAIN_ERR_METHOD: + case SZ_CHAIN_ERR_LAYOUT: + szh_set_msg(msg, msg_size, "cannot decode the encrypted archive header", + cerr.message); + status = SZH_ERR_UNSUPPORTED; + break; + case SZ_CHAIN_ERR_MEM: + szh_set_msg(msg, msg_size, "out of memory decoding the archive header", + NULL); + status = SZH_ERR_IO; + break; + default: + szh_set_msg(msg, msg_size, "cannot decode the encrypted archive header", + cerr.message); + status = SZH_ERR_FORMAT; + break; + } + goto done; + } + if(sink.len == 0) { + szh_set_msg(msg, msg_size, + "the encrypted archive header decrypted to nothing", NULL); + status = SZH_ERR_FORMAT; + goto done; + } + if(ss.has_crc && decoded_crc != ss.crc) { + szh_set_msg(msg, msg_size, + "the encrypted archive header decrypted to data that fails its " + "CRC -- the password is wrong", + NULL); + status = SZH_ERR_PASSWORD; + goto done; + } + if(sink.data[0] != SZH_ID_HEADER) { + szh_set_msg(msg, msg_size, + "the encrypted archive header did not decrypt to a 7z header " + "-- the password is wrong", + NULL); + status = SZH_ERR_PASSWORD; + goto done; + } + + /* --- hand the plaintext to the SDK ----------------------------------- */ + prep = (szh_prep *)calloc(1, sizeof(*prep)); + if(!prep) { + szh_set_msg(msg, msg_size, "out of memory reading the archive header", + NULL); + status = SZH_ERR_IO; + goto done; + } + prep->view.raw = raw; + prep->view.raw_size = raw_size; + prep->view.pos = 0; + prep->view.hdr_off = (uint64_t)k7zStartHeaderSize + next_off; + prep->view.hdr = sink.data; + prep->view.hdr_len = sink.len; + prep->view.total = prep->view.hdr_off + prep->view.hdr_len; + if(prep->view.total < raw_size) { + prep->view.total = raw_size; + } + sink.data = NULL; /* owned by the view from here on */ + + memcpy(prep->view.sig, sig, sizeof(prep->view.sig)); + szh_put_le64(prep->view.sig + 12, prep->view.hdr_off - k7zStartHeaderSize); + szh_put_le64(prep->view.sig + 20, prep->view.hdr_len); + szh_put_le32(prep->view.sig + 28, + CrcCalc(prep->view.hdr, (size_t)prep->view.hdr_len)); + szh_put_le32(prep->view.sig + 8, CrcCalc(prep->view.sig + 12, 20)); + + prep->view.vt.Read = szh_view_read; + prep->view.vt.Seek = szh_view_seek; + + *out = prep; + prep = NULL; + status = SZH_PATCHED; + +done: + free(raw_hdr); + free(sink.data); + sz_chain_free(chain); + if(prep) { + free(prep->view.hdr); + free(prep); + } + return status; +} + +ISeekInStream * +szh_stream(const szh_prep *p) { + return p ? (ISeekInStream *)&p->view.vt : NULL; +} + +void +szh_prep_free(szh_prep *p) { + if(p) { + free(p->view.hdr); + free(p); + } +} diff --git a/src/sevenz_header.h b/src/sevenz_header.h new file mode 100644 index 0000000..a422da4 --- /dev/null +++ b/src/sevenz_header.h @@ -0,0 +1,106 @@ +/* sevenz_header -- reads the 7z header, decrypting it when it is encrypted. + part of ps5-web-file-manager + + A 7z archive keeps its header at the END of the file, and when that header + grows past a threshold 7-Zip stores it *compressed*: the next-header region + then starts with a `k7zIdEncodedHeader` (0x17) record describing a single + folder whose output is the real header. That folder is one of two things: + + * LZMA / LZMA2 -- `-mhc=on`, the default. The vendored SDK decodes it + itself, so this module reads a few bytes, sees no AES coder and steps + aside without changing anything. + * LZMA + 7zAES -- `-mhe=on`. The C half of the SDK has no 7zAES coder at + all, so SzArEx_Open() gives up with SZ_ERROR_UNSUPPORTED before a single + folder is known: the archive cannot even be listed. + + The second case is what this module exists for. It decodes that one folder + with src/sevenz_chain.c -- the same decoder the archive's content goes + through -- and then hands the SDK a stream in which the encoded header has + been replaced by its plaintext. The plaintext is longer than the record it + replaces, so the stream is a small virtual view over the real one: + + [0, 32) the start header, rewritten to describe the + plaintext (offset, size and CRC) + [32, hdr_off) the real archive: packed streams + [hdr_off, hdr_off + L) the decrypted header + beyond that the real archive again + + `hdr_off` is where the encoded header already lived, so no offset that the + archive itself stores has to move: the SDK reads the plaintext at exactly + the position it expected the encoded record, and the main data position it + derives from the plaintext still points at the real packed streams. + + Nothing on disk is touched, the archive is opened read-only, and an archive + whose header is not encrypted is never touched at all. +*/ + +#ifndef SEVENZ_HEADER_H +#define SEVENZ_HEADER_H + +#include + +#include "7zTypes.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* The plaintext header is tiny in every real archive (kilobytes); the ceiling + only exists so a hostile encoded header cannot ask for a gigabyte. */ +#define SZH_MAX_HEADER ((uint64_t)64 * 1024 * 1024) + +typedef enum { + /* The header is readable as it stands. Use the stream you passed in and + let the SDK parse it, exactly as before this module existed. */ + SZH_PLAIN = 0, + + /* The header was encrypted and is now decrypted: hand szh_stream() to + SzArEx_Open() instead of the raw stream. */ + SZH_PATCHED, + + /* The header is encrypted and the password given was missing or wrong. + Actionable: the caller should ask for one and retry. */ + SZH_ERR_PASSWORD, + + /* The encoded header uses an arrangement this module does not read. */ + SZH_ERR_UNSUPPORTED, + + /* The encoded header is malformed. */ + SZH_ERR_FORMAT, + + /* Reading the archive failed. */ + SZH_ERR_IO +} szh_status_t; + +typedef struct szh_prep szh_prep; + +/* Inspects the archive header behind `raw`. + + On SZH_PLAIN *out is NULL and the caller proceeds with `raw` untouched. + On SZH_PATCHED *out owns everything and szh_stream(*out) must be used. + Otherwise *out is NULL and `msg` says why, in a form meant for the user. + + `password` is the archive password as UTF-8, or NULL / "" when the caller + has none. It is only consulted when the header turns out to be encrypted. + + The function never reports an error for an archive the SDK would diagnose + better: anything unexpected *before* an AES coder is found -- a short file, + a bad signature, a header CRC mismatch, an unparsable StreamsInfo -- comes + back as SZH_PLAIN so the SDK keeps producing the message it always did. */ +szh_status_t szh_prepare(szh_prep **out, ISeekInStream *raw, const char *password, + char *msg, size_t msg_size); + +/* The stream to give SzArEx_Open(); NULL when p is NULL. Valid until + szh_prep_free(). */ +ISeekInStream *szh_stream(const szh_prep *p); + +/* Static description of a status, for messages that have no better text. */ +const char *szh_status_string(szh_status_t status); + +void szh_prep_free(szh_prep *p); + +#ifdef __cplusplus +} +#endif + +#endif /* SEVENZ_HEADER_H */ diff --git a/src/zip_extract.c b/src/zip_extract.c index 5a02c39..26e5d61 100644 --- a/src/zip_extract.c +++ b/src/zip_extract.c @@ -49,6 +49,11 @@ typedef struct { zipx_cancel_fn cancel; zipx_progress_fn progress; void *userdata; + /* NULL or empty means "no password supplied". Both ZIP encryption schemes + (traditional PKWARE and WinZip AES) only need it for the entry data: the + central directory is never encrypted, so the scan phase can read every + header without it. */ + const char *password; zipx_result_t *result; uint64_t entries_total; uint64_t entries_done; @@ -604,8 +609,16 @@ scan_archive(void *zip, zipx_ctx_t *c) { break; } if((info->flag & MZ_ZIP_FLAG_ENCRYPTED) || info->aes_version) { - ret = ctx_fail(c, ZIPX_ERR_UNSUPPORTED, name, "encrypted entry"); - break; + /* Encrypted entries are readable, but only with a password. The + password itself is verified when the entry data is opened -- for + ZipCrypto against the 1-2 byte header check, for WinZip AES against + the 2 byte PBKDF2 verifier -- so a wrong password surfaces as + ZIPX_ERR_PASSWORD from the extract phase rather than here. */ + if(!c->password) { + ret = ctx_fail(c, ZIPX_ERR_PASSWORD, name, + "entry is encrypted and no password was given"); + break; + } } if(info->compression_method != MZ_COMPRESS_METHOD_STORE && info->compression_method != MZ_COMPRESS_METHOD_DEFLATE) { @@ -841,10 +854,21 @@ write_entry(void *zip, zipx_ctx_t *c, int root_fd, const char *name, return -1; } - if(mz_zip_entry_read_open(zip, 0, NULL) != MZ_OK) { - ctx_fail(c, ZIPX_ERR_FORMAT, name, "cannot read entry data"); - ret = -1; - goto done; + { + /* Opening the entry data is also where a supplied password is checked: + mz_strm_pkcrypt.c compares the decrypted header byte(s) and + mz_strm_wzaes.c the 2 byte PBKDF2 verifier, both returning + MZ_PASSWORD_ERROR on a mismatch. */ + int err = mz_zip_entry_read_open(zip, 0, c->password); + + if(err != MZ_OK) { + ctx_fail(c, err == MZ_PASSWORD_ERROR ? ZIPX_ERR_PASSWORD : ZIPX_ERR_FORMAT, + name, "%s", + err == MZ_PASSWORD_ERROR ? "the password is wrong" + : "cannot read entry data"); + ret = -1; + goto done; + } } while(ret == 0) { @@ -1247,7 +1271,7 @@ zipx_status_t zipx_extract(const char *zip_path, const char *dst_dir, zipx_conflict_t conflict, const zipx_limits_t *limits, zipx_cancel_fn cancel, zipx_progress_fn progress, - void *userdata, zipx_result_t *result) { + void *userdata, const char *password, zipx_result_t *result) { zipx_ctx_t ctx; zipx_ctx_t *c = &ctx; char parent[ZIPX_PATH_MAX]; @@ -1280,6 +1304,7 @@ zipx_extract(const char *zip_path, const char *dst_dir, c->cancel = cancel; c->progress = progress; c->userdata = userdata; + c->password = (password && password[0]) ? password : NULL; snprintf(dst_copy, sizeof(dst_copy), "%s", dst_dir); { diff --git a/src/zip_extract.h b/src/zip_extract.h index ac5b6c4..27cdc2a 100644 --- a/src/zip_extract.h +++ b/src/zip_extract.h @@ -20,7 +20,7 @@ typedef enum { ZIPX_ERR_CANCELED, ZIPX_ERR_OPEN, /* cannot open the archive */ ZIPX_ERR_FORMAT, /* corrupt central directory / truncated */ - ZIPX_ERR_UNSUPPORTED,/* encryption, multipart or unsupported method */ + ZIPX_ERR_UNSUPPORTED,/* multipart or unsupported compression method */ ZIPX_ERR_UNSAFE_NAME,/* traversal, absolute path, control chars, NUL */ ZIPX_ERR_SPECIAL, /* symlink / device / fifo / socket entry */ ZIPX_ERR_DUPLICATE, /* repeated entry or file/dir name clash inside zip */ @@ -30,6 +30,8 @@ typedef enum { ZIPX_ERR_LIMIT_RATIO, ZIPX_ERR_LIMIT_DEPTH, ZIPX_ERR_LIMIT_NAME, + ZIPX_ERR_LIMIT_DICT, /* the archive's dictionary exceeds what we allow + (RAR7 headers may ask for up to 64 GiB) */ ZIPX_ERR_CONFLICT, /* target already exists for the chosen policy */ ZIPX_ERR_PASSWORD, /* the archive is encrypted and the password is missing or wrong; the caller can prompt and retry */ @@ -101,6 +103,10 @@ const zipx_limits_t *zipx_limits_profile(int profile); const char *zipx_status_string(zipx_status_t status); /* Extract zip_path into dst_dir. + `password` may be NULL or empty when the archive is not encrypted; it is + used for both ZIP encryption schemes, traditional PKWARE ("ZipCrypto") and + WinZip AES. A missing or wrong password is reported as ZIPX_ERR_PASSWORD, + which the caller is expected to turn into a prompt and retry. Returns ZIPX_OK or an error code; *result is always filled in. On any failure the staging directory is removed and dst_dir is left as it was, except for objects already published with the overwrite policy. */ @@ -110,4 +116,5 @@ zipx_status_t zipx_extract(const char *zip_path, const char *dst_dir, zipx_cancel_fn cancel, zipx_progress_fn progress, void *userdata, + const char *password, zipx_result_t *result); diff --git a/src/zipx_common.c b/src/zipx_common.c index 8a3cffc..f6c879e 100644 --- a/src/zipx_common.c +++ b/src/zipx_common.c @@ -89,6 +89,7 @@ zipx_status_string(zipx_status_t status) { case ZIPX_ERR_LIMIT_RATIO: return "compression ratio too high"; case ZIPX_ERR_LIMIT_DEPTH: return "path too deep"; case ZIPX_ERR_LIMIT_NAME: return "path too long"; + case ZIPX_ERR_LIMIT_DICT: return "dictionary too large"; case ZIPX_ERR_CONFLICT: return "target already exists"; case ZIPX_ERR_PASSWORD: return "password required or wrong"; case ZIPX_ERR_SPACE: return "not enough space"; diff --git a/tests/bench_driver.py b/tests/bench_driver.py index 2c1b56c..d363040 100644 --- a/tests/bench_driver.py +++ b/tests/bench_driver.py @@ -53,6 +53,12 @@ RAR_BIN = next((p for p in ( # Object lists mirror what tests/run-sevenz-tests.sh and tests/run-tests.sh # build; those scripts must have run once before this can link. +# +# These lists are the one thing here that can rot: adding a source file to +# either engine (sevenz_header.c for -mhe=on, mz_crypt_wfm.c plus the two +# restored minizip-ng crypto streams for encrypted ZIP) silently leaves the +# link with undefined symbols. If `g++` below fails on an unresolved symbol +# that clearly lives in src/ or third_party/, check here first. VENDOR_7Z = ["7zAlloc", "7zArcIn", "7zBuf", "7zBuf2", "7zCrc", "7zCrcOpt", "7zDec", "7zFile", "7zStream", "Aes", "AesOpt", "Alloc", "Bcj2", "Bra", "Bra86", "BraIA64", "CpuArch", "Delta", "DllSecur", @@ -60,8 +66,9 @@ VENDOR_7Z = ["7zAlloc", "7zArcIn", "7zBuf", "7zBuf2", "7zCrc", "7zCrcOpt", "Ppmd7Dec", "Sha256", "Sha256Opt", "SwapBytes"] ZLIB = ["adler32", "crc32", "deflate", "inffast", "inflate", "inftrees", "trees", "zutil"] -MINIZIP = ["mz_crypt", "mz_os", "mz_os_posix", "mz_strm", "mz_strm_mem", - "mz_strm_os_posix", "mz_strm_zlib", "mz_zip"] +MINIZIP = ["mz_crypt", "mz_crypt_wfm", "mz_os", "mz_os_posix", "mz_strm", + "mz_strm_mem", "mz_strm_os_posix", "mz_strm_pkcrypt", + "mz_strm_wzaes", "mz_strm_zlib", "mz_zip"] EXTRA_LIBS = ["-lole32", "-loleaut32", "-luuid", "-ladvapi32", "-luser32", "-lshell32"] @@ -79,8 +86,8 @@ def build_bench(): # with gcc and the link goes through g++. unrar = sorted(glob.glob(os.path.join(HT_BUILD, "unrar7_*.o"))) needed = (objs(SZ_BUILD, ["sevenz_extract", "sevenz_chain", - "sevenz_mt", "sevenz_volstream", "zipx_common", - "zipx_volume"]) + "sevenz_header", "sevenz_mt", "sevenz_volstream", + "zipx_common", "zipx_volume"]) + objs(HT_BUILD, ["zip_extract", "zipx_volstream", "rar_extract"]) + objs(HT_BUILD, ZLIB) + objs(HT_BUILD, MINIZIP) + objs(SZ_BUILD, VENDOR_7Z) + unrar) diff --git a/tests/bench_extract.c b/tests/bench_extract.c index 1a5ddde..e3ad161 100644 --- a/tests/bench_extract.c +++ b/tests/bench_extract.c @@ -3,7 +3,9 @@ * bench_extract [password] * * Reports how long the facade takes end to end -- decode, staging writes, - * fsync, publish -- which is exactly what a PS5 user waits for. It is + * publish -- which is exactly what a PS5 user waits for. Note there is no + * fsync in the extract path at all (see the comment in zip_extract.c's + * publish_entry): the pipeline is "sync nothing, rename everything". It is * deliberately separate from the correctness drivers: those assert on bytes, * this one only prints numbers, and it is not part of the test matrix. * @@ -97,13 +99,14 @@ main(int argc, char **argv) { #ifndef BENCH_NO_RAR if(!strcmp(format, "rar")) { status = rar_extract(archive, out_dir, ZIPX_CONFLICT_OVERWRITE, - zipx_default_limits(), NULL, NULL, NULL, &result); + zipx_default_limits(), NULL, NULL, NULL, NULL, + &result); } else #endif #ifndef BENCH_SEVENZ_ONLY if(!strcmp(format, "zip")) { status = zipx_extract(archive, out_dir, ZIPX_CONFLICT_OVERWRITE, - zipx_default_limits(), NULL, NULL, NULL, &result); + zipx_default_limits(), NULL, NULL, NULL, NULL, &result); } else #endif { diff --git a/tests/bench_progress.c b/tests/bench_progress.c index 2f9f40d..321dd0a 100644 --- a/tests/bench_progress.c +++ b/tests/bench_progress.c @@ -140,11 +140,11 @@ main(int argc, char **argv) { if(!strcmp(format, "rar")) { status = rar_extract(archive, out_dir, ZIPX_CONFLICT_OVERWRITE, - zipx_default_limits(), NULL, on_report, NULL, + zipx_default_limits(), NULL, on_report, NULL, NULL, &result); } else if(!strcmp(format, "zip")) { status = zipx_extract(archive, out_dir, ZIPX_CONFLICT_OVERWRITE, - zipx_default_limits(), NULL, on_report, NULL, + zipx_default_limits(), NULL, on_report, NULL, NULL, &result); } else { status = sevenz_extract(archive, out_dir, ZIPX_CONFLICT_OVERWRITE, diff --git a/tests/bigfile_e2e.c b/tests/bigfile_e2e.c index 871493c..f85d143 100644 --- a/tests/bigfile_e2e.c +++ b/tests/bigfile_e2e.c @@ -24,7 +24,7 @@ main(int argc, char **argv) { } st = zipx_extract(argv[1], argv[2], ZIPX_CONFLICT_FAIL, zipx_limits_profile(ZIPX_LIMITS_LARGE), - NULL, NULL, NULL, &res); + NULL, NULL, NULL, NULL, &res); printf("status=%d (%s)\n", (int)st, zipx_status_string(st)); printf("sys_errno=%d entries=%llu/%llu files=%llu dirs=%llu\n", res.sys_errno, (unsigned long long)res.entries_done, diff --git a/tests/fixtures-real/enc-aes256-store.zip b/tests/fixtures-real/enc-aes256-store.zip new file mode 100644 index 0000000..b777700 Binary files /dev/null and b/tests/fixtures-real/enc-aes256-store.zip differ diff --git a/tests/fixtures-real/enc-aes256.zip b/tests/fixtures-real/enc-aes256.zip new file mode 100644 index 0000000..dc28be1 Binary files /dev/null and b/tests/fixtures-real/enc-aes256.zip differ diff --git a/tests/fixtures-real/enc-zipcrypto.zip b/tests/fixtures-real/enc-zipcrypto.zip new file mode 100644 index 0000000..d76181c Binary files /dev/null and b/tests/fixtures-real/enc-zipcrypto.zip differ diff --git a/tests/fixtures/dict-8g.rar b/tests/fixtures/dict-8g.rar new file mode 100644 index 0000000..c562767 Binary files /dev/null and b/tests/fixtures/dict-8g.rar differ diff --git a/tests/make-zip-enc-fixtures.bat b/tests/make-zip-enc-fixtures.bat new file mode 100644 index 0000000..a1216a8 --- /dev/null +++ b/tests/make-zip-enc-fixtures.bat @@ -0,0 +1,68 @@ +@echo off +REM Generate real encrypted ZIP fixtures for the host test suite. +REM Requires a 7-Zip compatible command line tool: 7-Zip, or the NanaZip +REM console alias that ships with the Store package (both accept the same +REM switches used here). +REM Outputs into tests\fixtures-real\ - kept OUT of tests\fixtures\ because +REM tests\make_fixtures.py wipes that directory on every test run. Only the +REM *.zip files are rewritten, so the RAR fixtures produced by +REM make-rar-fixtures.bat survive. +REM Rerun any time fixture needs change. NOTE: keep this file pure ASCII. +setlocal EnableDelayedExpansion + +set SZEXE= +if exist "%ProgramFiles%\7-Zip\7z.exe" set SZEXE=%ProgramFiles%\7-Zip\7z.exe +if exist "%ProgramFiles(x86)%\7-Zip\7z.exe" set SZEXE=%ProgramFiles(x86)%\7-Zip\7z.exe +if exist "%~dp07z.exe" set SZEXE=%~dp07z.exe +if "%SZEXE%"=="" ( + for /f "delims=" %%I in ('where 7z 2^>nul') do if "!SZEXE!"=="" set SZEXE=%%I +) +if "%SZEXE%"=="" ( + echo [ERROR] No 7z.exe found. Install 7-Zip or NanaZip, or drop 7z.exe next to this script. + exit /b 1 +) +echo Using: %SZEXE% + +set FIX=%~dp0fixtures-real +if not exist "%FIX%" mkdir "%FIX%" +del "%FIX%\enc-*.zip" 2>nul + +set STAGE=%~dp0fixture-stage-zip +if exist "%STAGE%" rmdir /s /q "%STAGE%" +mkdir "%STAGE%\dir" 2>nul + +REM Same payload shape as the RAR fixtures so both suites assert the same +REM file list and the same bytes. +echo ############### > "%STAGE%\root.txt" +echo zip v1.9 fixture root content >> "%STAGE%\root.txt" +echo nested payload line one > "%STAGE%\dir\nested.txt" +echo nested payload line two >> "%STAGE%\dir\nested.txt" + +REM Work from inside the stage dir with RELATIVE names so the archive keeps +REM the dir\ structure (no -spf full paths). +pushd "%STAGE%" + +echo. +echo [1/3] traditional PKWARE / ZipCrypto (password: secret123) - enc-zipcrypto.zip +"%SZEXE%" a -tzip -psecret123 -mem=ZipCrypto -y -bso0 -bsp0 "%FIX%\enc-zipcrypto.zip" root.txt dir\nested.txt +if errorlevel 1 echo [FAIL] & goto :badpop + +echo [2/3] WinZip AES-256 (password: secret123) - enc-aes256.zip +"%SZEXE%" a -tzip -psecret123 -mem=AES256 -y -bso0 -bsp0 "%FIX%\enc-aes256.zip" root.txt dir\nested.txt +if errorlevel 1 echo [FAIL] & goto :badpop + +echo [3/3] WinZip AES-256 with stored (uncompressed) entries - enc-aes256-store.zip +"%SZEXE%" a -tzip -psecret123 -mem=AES256 -mx0 -y -bso0 -bsp0 "%FIX%\enc-aes256-store.zip" root.txt dir\nested.txt +if errorlevel 1 echo [FAIL] & goto :badpop + +popd +rmdir /s /q "%STAGE%" 2>nul +echo. +echo OK. Fixtures written to %FIX%: +dir /b "%FIX%\enc-*.zip" 2>nul +exit /b 0 + +:badpop +popd +echo [ERROR] 7z command failed. Is this 7-Zip 21+ (or NanaZip) with ZIP encryption support? +exit /b 1 diff --git a/tests/make_fixtures.py b/tests/make_fixtures.py index 7cffa80..a539f52 100644 --- a/tests/make_fixtures.py +++ b/tests/make_fixtures.py @@ -282,13 +282,84 @@ def rar_fixtures(): shutil.rmtree(staging_dir, ignore_errors=True) +def bigdict(): + """dict-8g.rar -- a RAR5 block whose header asks for an 8 GiB dictionary. + + This one cannot be produced by any compressor, so it is synthesised here: + + * arcread.cpp:871 reads a RAR 5.0 dictionary as + `0x20000 << ((CompInfo>>10) & 0x0f)` -- FOUR bits, so the format's own + ceiling is 128 KiB << 15 = exactly 4 GiB, the same as our default + Cmd->WinSizeLimit (options.cpp:13). No `-ma5` archive can ever ask for + more, which is why -m0 store archives never reach CheckWinLimit(). + * Only a RAR7 header (UnpVer==1, five bits, up to UNPACK_MAX_DICT = 64 GiB) + can -- and Rar.exe 7.23 refuses to create one (`-ma4`, `-ma6`, `-ma7` all + exit 7; only `-ma5` works). + + So we emit a minimal, valid RAR5 archive by hand: signature, main header, + one store-method file header (FHFL_CRC32 set), the raw payload, end block. + CompInfo says UnpVer=1 with 16 dictionary bits (= 8 GiB) plus + FCI_RAR5_COMPAT, and arcread.cpp:878 then forces the algorithm back to + VER_PACK5 -- the payload really is stored, so nothing has to decode it. + + Method 0 also means Unpack::Init() is never reached, i.e. the archive + exercises exactly the gate under test (CheckWinLimit -> uiDictLimit -> + UCM_LARGEDICT) and never allocates anything multi-gigabyte. + + Sanity check with rarlab's own tools before trusting a change here: + UnRAR.exe lt dict-8g.rar -> "-md=8g" + UnRAR.exe t -mdx12g dict-8g.rar -> all OK + Without -mdx UnRAR refuses it exactly as we do ("8 GB dictionary exceeds the + 4 GB limit and needs more than 8 GB of memory"). + """ + name = b"hello.txt" + data = b"".join(b"line %04d dictionary probe payload\n" % i for i in range(200)) + comp_info = 1 | (16 << 10) | 0x00100000 # UnpVer=1, method=0, 8 GiB, RAR5 compat + + def vint(v): + out = bytearray() + while True: + c = v & 0x7F + v >>= 7 + out.append(c | 0x80 if v else c) + if not v: + return bytes(out) + + def block(htype, flags, payload, data_size=None): + # The HFL_DATA size lives in the block header prologue, right after the + # flags -- it is not part of the per-type payload (arcread.cpp:710). + hd = vint(htype) + vint(flags) + if data_size is not None: + hd += vint(data_size) + hd += payload + size = vint(len(hd)) + # rawread.cpp:185 GetCRC50() == zlib.crc32 over (size field + header data) + crc = zlib.crc32(size + hd) & 0xFFFFFFFF + return struct.pack(""$out.log" 2>&1 || rc=$? ;; *) "$BUILD/test_sevenz_extract" "$FIXTURES/$a.7z" "$target" \ >"$out.log" 2>&1 || rc=$? ;; diff --git a/tests/run-tests.sh b/tests/run-tests.sh index d18624e..cb08184 100644 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -25,7 +25,8 @@ 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) +MZ_CFLAGS=(-I"$ROOT/third_party/minizip-ng/include" -DHAVE_ZLIB -DZLIB_COMPAT -D_FILE_OFFSET_BITS=64 \ + -DHAVE_WZAES -DHAVE_PKCRYPT) RAR_CFLAGS=(-I"$ROOT/third_party/unrar" -DDMC_UNRAR_DISABLE_BE32TOH_BE64TOH=1) HOST_KIND=posix # MinGW has no O_NOFOLLOW; the flag is only a host build workaround. @@ -116,9 +117,11 @@ RAR_LIBS=() "$BUILD/rar_extract.o" "$BUILD/test_rar_extract.o" \ "$BUILD/zip_extract.o" "$BUILD/zipx_common.o" "${objs[@]}" "${rar_objs[@]}" "${RAR_LIBS[@]}" -"$BUILD/test-zip-extract" "$ROOT/tests/fixtures" "$BUILD/work-zip" -# Real RAR fixtures (v6 / multi-volume / encrypted) live in fixtures-real/, -# generated by tests/make-rar-fixtures.bat (WinRAR required); fixtures/ -# itself is wiped by make_fixtures.py on every run. +# Real archives live in fixtures-real/, generated by tests/make-rar-fixtures.bat +# (WinRAR) and tests/make-zip-enc-fixtures.bat (7-Zip); fixtures/ itself is +# wiped by make_fixtures.py on every run. The ZIP suite needs it for the +# encrypted samples, and skips those sections when the directory is missing. +"$BUILD/test-zip-extract" "$ROOT/tests/fixtures" "$BUILD/work-zip" \ + "$ROOT/tests/fixtures-real" "$BUILD/test-rar-extract" "$ROOT/tests/fixtures" "$BUILD/work-rar" \ "$ROOT/tests/fixtures-real" diff --git a/tests/sevenz_chain_e2e.c b/tests/sevenz_chain_e2e.c index fd79265..5a3c047 100644 --- a/tests/sevenz_chain_e2e.c +++ b/tests/sevenz_chain_e2e.c @@ -33,6 +33,7 @@ #include "7zFile.h" #include "sevenz_chain.h" +#include "sevenz_header.h" #include "sevenz_volstream.h" #define INPUT_BUF_SIZE (1u << 18) @@ -346,6 +347,9 @@ int main(int argc, char **argv) { char vol_desc[512]; int is_set = 0; int rc = 0; + szh_prep *hdr_prep = NULL; + ISeekInStream *hdr_stream = NULL; + char hdr_msg[256] = ""; if(argc < 3) { fprintf(stderr, "usage: %s [password]\n", argv[0]); @@ -367,19 +371,50 @@ int main(int argc, char **argv) { return 1; } look_stream.bufSize = INPUT_BUF_SIZE; - look_stream.realStream = sevenz_volstream_stream(vol); + + /* Same order as src/sevenz_extract.c: an encrypted header has to come off + before the SDK can see the folder table. */ + hdr_stream = sevenz_volstream_stream(vol); + { + szh_status_t hs = szh_prepare(&hdr_prep, hdr_stream, + argc > 3 ? argv[3] : NULL, hdr_msg, + sizeof(hdr_msg)); + + if(hs == SZH_PATCHED) { + hdr_stream = szh_stream(hdr_prep); + } else if(hs != SZH_PLAIN) { + fprintf(stderr, "%s: %s\n", vol_desc, + hdr_msg[0] ? hdr_msg : szh_status_string(hs)); + szh_prep_free(hdr_prep); + sevenz_volstream_free(vol); + return 1; + } + } + look_stream.realStream = hdr_stream; LookToRead2_INIT(&look_stream) + { + Int64 zero = 0; + + if(hdr_stream->Seek(hdr_stream, &zero, SZ_SEEK_SET) != SZ_OK) { + fprintf(stderr, "%s: cannot rewind the archive\n", vol_desc); + szh_prep_free(hdr_prep); + sevenz_volstream_free(vol); + return 1; + } + } CrcGenerateTable(); SzArEx_Init(&db); res = SzArEx_Open(&db, &look_stream.vt, &g_alloc, &g_temp); + /* The header is in `db` now; nothing reads through the view again. */ + szh_prep_free(hdr_prep); + hdr_prep = NULL; if(res != SZ_OK) { if(res == SZ_ERROR_UNSUPPORTED) fprintf(stderr, - "%s: the archive header is encrypted (-mhe=on); this build reads " - "encrypted *streams* only, so the header itself cannot be " - "decoded\n", + "%s: the archive header is compressed with a method this build " + "does not have\n", vol_desc); else fprintf(stderr, "%s: cannot read the 7z header (res=%d)\n", vol_desc, diff --git a/tests/test_rar_extract.c b/tests/test_rar_extract.c index 1a80b9b..0043410 100644 --- a/tests/test_rar_extract.c +++ b/tests/test_rar_extract.c @@ -1,18 +1,18 @@ /* Host test suite for the RAR extraction engine. Build: see run-tests.sh (uses gcc + the MinGW POSIX shim). - Scope (v1.8): - * Pure error-path coverage — we do not ship a genuine RAR fixture - because no rar/7z writer is available in the sandboxed host test - environment. The test suite therefore focuses on the negative - branches that the dispatcher hits when a user uploads something - that is not a valid single-volume, unencrypted RAR. - * Happy-path smoke coverage is provided by make_fixtures.py when a - rar or 7z binary is available; if neither is present, the script - writes 1-byte placeholder files so that test_rar_extract.c still - has something to point at for the "format rejected" assertions. - * See docs/HANDOVER.md for the manual smoke procedure and the - fixture TODO that should be cleared when opello/unrar is vendored. */ + Scope (v1.9): + * Negative paths: the dispatcher must classify garbage, truncated and + non-archive input without ever returning ZIPX_OK. These fixtures are + generated on the fly, so this part runs anywhere. + * Real-archive coverage: RAR5 "v6", RAR4, multi-volume and encrypted + samples live in tests/fixtures-real/ and are regenerated by + tests/make-rar-fixtures.bat (needs WinRAR). Those sections skip + themselves when the directory is missing. + * Encrypted archives are exercised end to end: no password and a wrong + password must both yield ZIPX_ERR_PASSWORD with nothing published, + and the correct password must actually decrypt the payload. The + password for enc-v6.rar is fixed by make-rar-fixtures.bat. */ #include "../src/zip_extract.h" #include "../src/rar_extract.h" @@ -162,7 +162,7 @@ run_rar(const char *fixture, const char *dst, zipx_status_t expected, work_path((char *)dst, 0, dst); /* dst is already absolute under work */ memset(&res, 0, sizeof(res)); st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL, - zipx_default_limits(), NULL, NULL, NULL, &res); + zipx_default_limits(), NULL, NULL, NULL, NULL, &res); check(st == expected, label); if(st != ZIPX_OK) { check(res.message[0] != 0, " has error message"); @@ -194,7 +194,7 @@ test_engine_dispatch(void) { if(exists(src)) { memset(&res, 0, sizeof(res)); st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL, - zipx_default_limits(), NULL, NULL, NULL, &res); + zipx_default_limits(), NULL, NULL, NULL, NULL, &res); check(st != ZIPX_OK, "notar.rar rejected"); check(!exists(dst), " no files written for rejected archive"); if(st == ZIPX_OK) { @@ -215,7 +215,7 @@ test_engine_dispatch(void) { zipx_result_t res; zipx_status_t st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL, zipx_default_limits(), NULL, NULL, NULL, - &res); + NULL, &res); check(st != ZIPX_OK, "junk.rar rejected"); check(!exists(dst), " no files written for junk archive"); if(st == ZIPX_OK) { @@ -237,7 +237,7 @@ test_engine_dispatch(void) { fixture_path(src, sizeof(src), "does-not-exist.rar"); memset(&res, 0, sizeof(res)); st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL, - zipx_default_limits(), NULL, NULL, NULL, &res); + zipx_default_limits(), NULL, NULL, NULL, NULL, &res); check(st == ZIPX_ERR_OPEN, "missing source -> ZIPX_ERR_OPEN"); check(!exists(dst), " no files written when source is missing"); if(st == ZIPX_OK) { @@ -249,13 +249,13 @@ test_engine_dispatch(void) { { zipx_result_t res; check(rar_extract(NULL, "/tmp", ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, - NULL, &res) == ZIPX_ERR_INTERNAL, + NULL, NULL, &res) == ZIPX_ERR_INTERNAL, "null rar_path -> ZIPX_ERR_INTERNAL"); check(rar_extract("/tmp", NULL, ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, - NULL, &res) == ZIPX_ERR_INTERNAL, + NULL, NULL, &res) == ZIPX_ERR_INTERNAL, "null dst_dir -> ZIPX_ERR_INTERNAL"); check(rar_extract("/tmp", "", ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, - NULL, &res) == ZIPX_ERR_INTERNAL, + NULL, NULL, &res) == ZIPX_ERR_INTERNAL, "empty dst_dir -> ZIPX_ERR_INTERNAL"); } @@ -280,7 +280,7 @@ test_engine_dispatch(void) { if(exists(src)) { zipx_status_t st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL, zipx_default_limits(), NULL, NULL, NULL, - &res); + NULL, &res); check(st == ZIPX_ERR_CONFLICT, "dst is a regular file -> ZIPX_ERR_CONFLICT"); } else { printf(" SKIP dst-is-file (junk.rar missing)\n"); @@ -291,16 +291,15 @@ test_engine_dispatch(void) { static void test_format_translation(void) { - /* Direct exercise of the error-code mapping: this only requires that the - dispatcher classifies "encrypted" and "multi-volume" correctly. Since - those states are reached only with a real RAR that has the right flags - set, we cannot generate that fixture on the fly — but we can confirm - that the rejection paths we *do* hit (FORMAT / OPEN / IO) never + /* Direct exercise of the error-code mapping. The encrypted and + multi-volume states now have real fixtures and are asserted in the + real-archive section above; this function only guarantees that the + rejection paths reachable without a fixture (FORMAT / OPEN / IO) never accidentally return ZIPX_OK. */ printf("test_format_translation\n"); - /* The dispatch test already covered the negative paths; nothing more to do - here for v1.8. Future fixtures can directly assert ZIPX_ERR_UNSUPPORTED - once we have encrypted/multi-volume samples (see docs/HANDOVER.md). */ + /* Nothing further to check here: encrypted (-p) coverage lives in the + real-archive section, which asserts ZIPX_ERR_PASSWORD for a missing or + wrong password and a successful decrypt for the right one. */ } static void @@ -343,7 +342,7 @@ test_real_archives(void) { if(exists(src)) { memset(&res, 0, sizeof(res)); st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL, - zipx_default_limits(), NULL, NULL, NULL, &res); + zipx_default_limits(), NULL, NULL, NULL, NULL, &res); check(st == ZIPX_OK, "basic-v6.rar extracts (v6 RAR5)"); if(st == ZIPX_OK) { snprintf(checkp, sizeof(checkp), "%s/root.txt", dst); @@ -371,7 +370,7 @@ test_real_archives(void) { if(exists(src)) { memset(&res, 0, sizeof(res)); st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL, - zipx_default_limits(), NULL, NULL, NULL, &res); + zipx_default_limits(), NULL, NULL, NULL, NULL, &res); check(st == ZIPX_OK, "basic-rar4.rar extracts (RAR4)"); if(st == ZIPX_OK) { snprintf(checkp, sizeof(checkp), "%s/root.txt", dst); @@ -400,7 +399,7 @@ test_real_archives(void) { g_pmid_extract = 0; memset(&g_last_progress, 0, sizeof(g_last_progress)); st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL, - zipx_default_limits(), NULL, progress_cb, NULL, &res); + zipx_default_limits(), NULL, progress_cb, NULL, NULL, &res); check(st == ZIPX_OK, "vol.part1.rar auto-merges volumes"); if(st == ZIPX_OK) { snprintf(checkp, sizeof(checkp), "%s/root.txt", dst); @@ -423,23 +422,50 @@ test_real_archives(void) { } remove_dir(dst); - /* Encrypted: engine can decrypt but the password channel is not wired yet, - so encrypted entries must be rejected up front with UNSUPPORTED. */ + /* Encrypted RAR (-p: entry data encrypted, names stored in the clear). + The engine decrypts through RARSetPassword, which the wrapper must call + after RAROpenArchiveEx and before the first RARReadHeaderEx. */ work_path(dst, sizeof(dst), "rdst_enc"); remove_dir(dst); { zipx_result_t res; zipx_status_t st; char src[4096]; + char checkp[4096]; fixture_real_path(src, sizeof(src), "enc-v6.rar"); if(exists(src)) { + /* (a) no password: rejected before anything is written, and with the + retryable code rather than a blanket UNSUPPORTED. */ memset(&res, 0, sizeof(res)); st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL, - zipx_default_limits(), NULL, NULL, NULL, &res); - check(st == ZIPX_ERR_UNSUPPORTED, "enc-v6.rar rejected (no password channel)"); - check(!exists(dst), " no files written for encrypted archive"); + zipx_default_limits(), NULL, NULL, NULL, NULL, &res); + check(st == ZIPX_ERR_PASSWORD, + "enc-v6.rar with no password -> ZIPX_ERR_PASSWORD"); + check(!exists(dst), " nothing written (no password)"); + + /* (b) wrong password: same code, still nothing published. */ + memset(&res, 0, sizeof(res)); + st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL, + zipx_default_limits(), NULL, NULL, NULL, "wrong-pw", + &res); + check(st == ZIPX_ERR_PASSWORD, + "enc-v6.rar with wrong password -> ZIPX_ERR_PASSWORD"); + check(!exists(dst), " nothing written (wrong password)"); + + /* (c) correct password: really decrypts. */ + memset(&res, 0, sizeof(res)); + st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL, + zipx_default_limits(), NULL, NULL, NULL, "secret123", + &res); + check(st == ZIPX_OK, "enc-v6.rar with correct password -> ZIPX_OK"); if(st == ZIPX_OK) { - remove_dir(dst); + snprintf(checkp, sizeof(checkp), "%s/root.txt", dst); + check(exists(checkp), " root.txt decrypted"); + snprintf(checkp, sizeof(checkp), "%s/dir/nested.txt", dst); + check(exists(checkp), " dir/nested.txt decrypted"); + check(res.entries_done >= 2, " entries_done >= 2"); + } else { + printf(" message=%s\n", res.message); } } else { printf(" SKIP enc-v6.rar (missing)\n"); @@ -448,6 +474,52 @@ test_real_archives(void) { remove_dir(dst); } +/* A dictionary above Cmd->WinSizeLimit (4 GiB, options.cpp:13) makes unrar call + back with UCM_LARGEDICT and abort unless the callback answers 1. We answer 0 + on purpose (see rar_data_cb), so this must surface as ZIPX_ERR_LIMIT_DICT and + say *what* is oversized -- it is the dictionary, not the 7 KB entry that + happened to be in flight. + + dict-8g.rar is synthetic: RAR 5.0 headers carry the dictionary in four bits + (arcread.cpp:871), i.e. the format's own ceiling is exactly 4 GiB, so no + `-ma5` archive can reach this path at all. The fixture rewrites CompInfo to + the RAR7 field width plus FCI_RAR5_COMPAT; tests/make_fixtures.py:bigdict() + builds it and UnRAR 7.23 confirms it as a valid 8 GiB-dictionary archive. */ +static void +test_dict_limit(void) { + char src[4096]; + char dst[4096]; + zipx_result_t res; + + printf("test_dict_limit\n"); + + fixture_path(src, sizeof(src), "dict-8g.rar"); + if(!exists(src)) { + printf(" SKIP dict-8g.rar (missing)\n"); + return; + } + + work_path(dst, sizeof(dst), "dict-limit"); + remove_dir(dst); + memset(&res, 0, sizeof(res)); + + check(rar_extract(src, dst, ZIPX_CONFLICT_FAIL, zipx_default_limits(), + NULL, NULL, NULL, NULL, &res) == ZIPX_ERR_LIMIT_DICT, + "8 GiB dictionary is refused as ZIPX_ERR_LIMIT_DICT"); + check(res.detail[0] != 0 && strstr(res.detail, "8192 MiB") != NULL, + " refusal names the dictionary size"); + check(strstr(res.detail, "4096 MiB") != NULL, + " refusal names the limit it exceeded"); + { + char landed[4096]; + snprintf(landed, sizeof(landed), "%s/hello.txt", dst); + check(!exists(landed), " the entry was not extracted"); + } + printf(" detail=%s\n", res.detail); + printf(" message=%s\n", res.message); + remove_dir(dst); +} + int main(int argc, char **argv) { if(argc < 3) { @@ -494,6 +566,7 @@ main(int argc, char **argv) { test_format_translation(); test_limits_handoff(); test_real_archives(); + test_dict_limit(); printf("\nrar_extract: %d checks, %d failures\n", g_checks, g_failures); return g_failures == 0 ? 0 : 1; diff --git a/tests/test_sevenz_extract.c b/tests/test_sevenz_extract.c index 618801f..8d84139 100644 --- a/tests/test_sevenz_extract.c +++ b/tests/test_sevenz_extract.c @@ -172,7 +172,7 @@ run_cases(const char *fx, const char *work) { ZIPX_CONFLICT_FAIL, zipx_default_limits(), NULL, ZIPX_ERR_PASSWORD, "7zAES"); { - char b2[256]; + char b2[ZIPX_PATH_MAX + 96]; zipx_result_t r; (void)sevenz_extract(arc, dst, ZIPX_CONFLICT_FAIL, zipx_default_limits(), NULL, NULL, NULL, @@ -188,12 +188,29 @@ run_cases(const char *fx, const char *work) { check(extract_one(arc, dst, PASSWORD) == 0, "aes extracts with the right password"); - /* --- encrypted header ---------------------------------------------- */ + /* --- encrypted header (-mhe=on) ------------------------------------- */ + /* The file names, the folder table and every entry size live inside the + encrypted header, so this is the case where nothing at all is readable + without the password -- not even the entry list. */ snprintf(arc, sizeof(arc), "%s/aeshe.7z", fx); - snprintf(dst, sizeof(dst), "%s/he", work); + + snprintf(dst, sizeof(dst), "%s/he-missing", work); mkdir(dst, 0777); - expect_fail("aeshe (encrypted header)", arc, dst, PASSWORD, ZIPX_CONFLICT_FAIL, - zipx_default_limits(), NULL, ZIPX_ERR_UNSUPPORTED, "-mhe=on"); + expect_fail("aeshe with no password", arc, dst, NULL, ZIPX_CONFLICT_FAIL, + zipx_default_limits(), NULL, ZIPX_ERR_PASSWORD, "mhe=on"); + check(!has_staging_leftover(work), "no staging tree survives a locked header"); + + snprintf(dst, sizeof(dst), "%s/he-wrong", work); + mkdir(dst, 0777); + expect_fail("aeshe with the wrong password", arc, dst, "NotThePassword", + ZIPX_CONFLICT_FAIL, zipx_default_limits(), NULL, + ZIPX_ERR_PASSWORD, "password"); + + snprintf(dst, sizeof(dst), "%s/he-ok", work); + mkdir(dst, 0777); + check(extract_one(arc, dst, PASSWORD) == 0, + "aeshe extracts and verifies with the right password"); + check(!has_staging_leftover(work), "no staging tree survives an aeshe run"); /* --- open failures -------------------------------------------------- */ snprintf(dst, sizeof(dst), "%s/missing-file", work); diff --git a/tests/test_zip_extract.c b/tests/test_zip_extract.c index 1cbecbd..b7d4222 100644 --- a/tests/test_zip_extract.c +++ b/tests/test_zip_extract.c @@ -1,5 +1,12 @@ /* Host test suite for the ZIP extraction engine. - Build: see run-tests.sh (uses gcc + the MinGW POSIX shim). */ + Build: see run-tests.sh (uses gcc + the MinGW POSIX shim). + + Scope: the generated fixtures under tests/fixtures cover the negative and + structural paths. Encrypted archives cannot be produced with the Python + standard library, so enc-zipcrypto.zip / enc-aes256.zip / enc-aes256-store.zip + live in tests/fixtures-real/ and are regenerated by + tests/make-zip-enc-fixtures.bat (needs 7-Zip or NanaZip). The password is + fixed at "secret123" by that script. */ #include "../src/zip_extract.h" @@ -15,6 +22,7 @@ #include "posix_compat.h" static const char *g_fixtures; +static const char *g_fixtures_real; static char g_work[4096]; static int g_failures; static int g_checks; @@ -24,6 +32,11 @@ fixture_path(char *out, size_t size, const char *name) { snprintf(out, size, "%s/%s", g_fixtures, name); } +static void +fixture_real_path(char *out, size_t size, const char *name) { + snprintf(out, size, "%s/%s", g_fixtures_real, name); +} + static void work_path(char *out, size_t size, const char *name) { snprintf(out, size, "%s/%s", g_work, name); @@ -179,6 +192,14 @@ cb_progress(void *userdata, const zipx_progress_t *p) { } } +static zipx_status_t +run_ex(const char *zip, const char *dst, zipx_conflict_t conflict, + const zipx_limits_t *limits, test_ctx_t *t, const char *password, + zipx_result_t *res) { + return zipx_extract(zip, dst, conflict, limits, cb_cancel, cb_progress, + t, password, res); +} + 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) { @@ -187,8 +208,20 @@ run(const char *fixture, const char *dst_name, zipx_conflict_t conflict, 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); + return run_ex(zip, dst, conflict, limits, t, NULL, res); +} + +/* Same, but against tests/fixtures-real/ and with a password. */ +static zipx_status_t +run_real(const char *fixture, const char *dst_name, zipx_conflict_t conflict, + const zipx_limits_t *limits, test_ctx_t *t, const char *password, + zipx_result_t *res) { + char zip[4096]; + char dst[4096]; + + fixture_real_path(zip, sizeof(zip), fixture); + work_path(dst, sizeof(dst), dst_name); + return run_ex(zip, dst, conflict, limits, t, password, res); } static void @@ -561,9 +594,18 @@ test_unsupported(void) { zipx_limits_t limits; printf("encrypted, corrupt and truncated archives\n"); + /* encrypted.zip only has the general purpose encryption bit flipped on a + plain archive, so there is no password that could open it: the scan must + stop at the missing password before touching any entry data. */ expect_status(&res, run("encrypted.zip", "out_encrypted", ZIPX_CONFLICT_FAIL, NULL, &t, &res), - ZIPX_ERR_UNSUPPORTED, "encrypted entry rejected"); + ZIPX_ERR_PASSWORD, "encrypted entry without password rejected"); + { + char dst[4096]; + + work_path(dst, sizeof(dst), "out_encrypted"); + check(!exists(dst), "nothing published for encrypted zip"); + } expect_status(&res, run("bad_crc.zip", "out_bad_crc", ZIPX_CONFLICT_FAIL, NULL, &t, &res), ZIPX_ERR_CRC, "crc mismatch detected"); @@ -605,6 +647,85 @@ test_unsupported(void) { ZIPX_ERR_LIMIT_DEPTH, "depth limit enforced"); } +/* One encrypted archive, exercised four ways. The password is fixed by + tests/make-zip-enc-fixtures.bat. */ +static void +encrypted_archive(const char *fixture, const char *dst_name) { + static const char *password = "secret123"; + zipx_result_t res; + test_ctx_t t = {0}; + char zip[4096]; + char dst[4096]; + char file[4224]; + char buf[2048]; + + fixture_real_path(zip, sizeof(zip), fixture); + if(!exists(zip)) { + printf(" skip %s (run tests/make-zip-enc-fixtures.bat)\n", fixture); + return; + } + work_path(dst, sizeof(dst), dst_name); + + /* No password: the scan phase refuses before any entry data is touched. */ + expect_status(&res, run_real(fixture, dst_name, ZIPX_CONFLICT_FAIL, NULL, + &t, NULL, &res), + ZIPX_ERR_PASSWORD, "no password rejected"); + check(!exists(dst), "nothing published without a password"); + + /* An empty string means the same as no password at all. */ + expect_status(&res, run_real(fixture, dst_name, ZIPX_CONFLICT_FAIL, NULL, + &t, "", &res), + ZIPX_ERR_PASSWORD, "empty password rejected"); + + /* Wrong password: caught by the ZipCrypto header check / the AES verifier. */ + expect_status(&res, run_real(fixture, dst_name, ZIPX_CONFLICT_FAIL, NULL, + &t, "not-the-password", &res), + ZIPX_ERR_PASSWORD, "wrong password rejected"); + check(!exists(dst), "nothing published with a wrong password"); + + /* Correct password: the payload has to come out byte for byte. */ + expect_ok(&res, run_real(fixture, dst_name, ZIPX_CONFLICT_FAIL, NULL, &t, + password, &res), + "correct password decrypts"); + check(res.entries_total == 2, "entry count reported"); + snprintf(file, sizeof(file), "%s/root.txt", dst); + check(!read_text(file, buf, sizeof(buf)) && + !strcmp(buf, "###############\nzip v1.9 fixture root content\n"), + "decrypted root.txt content"); + snprintf(file, sizeof(file), "%s/dir/nested.txt", dst); + check(!read_text(file, buf, sizeof(buf)) && + !strcmp(buf, "nested payload line one\nnested payload line two\n"), + "decrypted dir/nested.txt content"); + check(count_staging_leftovers(dst) == 0, "no staging leftovers inside dst"); +} + +static void +test_encrypted(void) { + zipx_result_t res; + test_ctx_t t = {0}; + const zipx_limits_t *base = zipx_default_limits(); + zipx_limits_t limits; + char zip[4096]; + + printf("encrypted archives (traditional PKWARE + WinZip AES)\n"); + encrypted_archive("enc-zipcrypto.zip", "out_enc_zipcrypto"); + encrypted_archive("enc-aes256.zip", "out_enc_aes256"); + encrypted_archive("enc-aes256-store.zip", "out_enc_aes256_store"); + + /* Handing over a password must not skip the scan phase: the limits still + apply to encrypted archives. */ + fixture_real_path(zip, sizeof(zip), "enc-aes256.zip"); + if(exists(zip)) { + limits = *base; + limits.max_entries = 1; + expect_status(&res, run_real("enc-aes256.zip", "out_enc_limit", + ZIPX_CONFLICT_FAIL, &limits, &t, "secret123", + &res), + ZIPX_ERR_LIMIT_ENTRIES, + "entry limit still applies to encrypted archives"); + } +} + static void test_cancel(void) { zipx_result_t res; @@ -749,11 +870,13 @@ test_large_profile(void) { int main(int argc, char **argv) { if(argc < 3) { - fprintf(stderr, "usage: %s \n", argv[0]); + fprintf(stderr, "usage: %s [fixtures-real-dir]\n", + argv[0]); return 2; } g_fixtures = argv[1]; snprintf(g_work, sizeof(g_work), "%s", argv[2]); + g_fixtures_real = (argc >= 4) ? argv[3] : ""; remove_dir(g_work); if(make_dirs(g_work)) { fprintf(stderr, "cannot create work dir\n"); @@ -771,6 +894,7 @@ main(int argc, char **argv) { test_duplicates(); test_special_entries(); test_unsupported(); + test_encrypted(); test_cancel(); test_many_files(); test_large_profile(); diff --git a/third_party/minizip-ng/include/mz_strm_pkcrypt.h b/third_party/minizip-ng/include/mz_strm_pkcrypt.h new file mode 100644 index 0000000..5c4f8d0 --- /dev/null +++ b/third_party/minizip-ng/include/mz_strm_pkcrypt.h @@ -0,0 +1,46 @@ +/* mz_strm_pkcrypt.h -- Code for traditional PKWARE encryption + part of the minizip-ng project + + Copyright (C) Nathan Moinvaziri + https://github.com/zlib-ng/minizip-ng + + This program is distributed under the terms of the same license as zlib. + See the accompanying LICENSE file for the full text of the license. +*/ + +#ifndef MZ_STREAM_PKCRYPT_H +#define MZ_STREAM_PKCRYPT_H + +#ifdef __cplusplus +extern "C" { +#endif + +/***************************************************************************/ + +int32_t mz_stream_pkcrypt_open(void *stream, const char *filename, int32_t mode); +int32_t mz_stream_pkcrypt_is_open(void *stream); +int32_t mz_stream_pkcrypt_read(void *stream, void *buf, int32_t size); +int32_t mz_stream_pkcrypt_write(void *stream, const void *buf, int32_t size); +int64_t mz_stream_pkcrypt_tell(void *stream); +int32_t mz_stream_pkcrypt_seek(void *stream, int64_t offset, int32_t origin); +int32_t mz_stream_pkcrypt_close(void *stream); +int32_t mz_stream_pkcrypt_error(void *stream); + +void mz_stream_pkcrypt_set_password(void *stream, const char *password); +void mz_stream_pkcrypt_set_verify(void *stream, uint8_t verify1, uint8_t verify2, uint16_t version); +void mz_stream_pkcrypt_get_verify(void *stream, uint8_t *verify1, uint8_t *verify2, uint16_t *version); +int32_t mz_stream_pkcrypt_get_prop_int64(void *stream, int32_t prop, int64_t *value); +int32_t mz_stream_pkcrypt_set_prop_int64(void *stream, int32_t prop, int64_t value); + +void *mz_stream_pkcrypt_create(void); +void mz_stream_pkcrypt_delete(void **stream); + +void *mz_stream_pkcrypt_get_interface(void); + +/***************************************************************************/ + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/third_party/minizip-ng/include/mz_strm_wzaes.h b/third_party/minizip-ng/include/mz_strm_wzaes.h new file mode 100644 index 0000000..ac20655 --- /dev/null +++ b/third_party/minizip-ng/include/mz_strm_wzaes.h @@ -0,0 +1,46 @@ +/* mz_strm_wzaes.h -- Stream for WinZIP AES encryption + part of the minizip-ng project + + Copyright (C) Nathan Moinvaziri + https://github.com/zlib-ng/minizip-ng + + This program is distributed under the terms of the same license as zlib. + See the accompanying LICENSE file for the full text of the license. +*/ + +#ifndef MZ_STREAM_WZAES_SHA1_H +#define MZ_STREAM_WZAES_SHA1_H + +#ifdef __cplusplus +extern "C" { +#endif + +/***************************************************************************/ + +int32_t mz_stream_wzaes_open(void *stream, const char *filename, int32_t mode); +int32_t mz_stream_wzaes_is_open(void *stream); +int32_t mz_stream_wzaes_read(void *stream, void *buf, int32_t size); +int32_t mz_stream_wzaes_write(void *stream, const void *buf, int32_t size); +int64_t mz_stream_wzaes_tell(void *stream); +int32_t mz_stream_wzaes_seek(void *stream, int64_t offset, int32_t origin); +int32_t mz_stream_wzaes_close(void *stream); +int32_t mz_stream_wzaes_error(void *stream); + +void mz_stream_wzaes_set_password(void *stream, const char *password); +void mz_stream_wzaes_set_strength(void *stream, uint8_t strength); + +int32_t mz_stream_wzaes_get_prop_int64(void *stream, int32_t prop, int64_t *value); +int32_t mz_stream_wzaes_set_prop_int64(void *stream, int32_t prop, int64_t value); + +void *mz_stream_wzaes_create(void); +void mz_stream_wzaes_delete(void **stream); + +void *mz_stream_wzaes_get_interface(void); + +/***************************************************************************/ + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/third_party/minizip-ng/src/mz_crypt_wfm.c b/third_party/minizip-ng/src/mz_crypt_wfm.c new file mode 100644 index 0000000..e6003f6 --- /dev/null +++ b/third_party/minizip-ng/src/mz_crypt_wfm.c @@ -0,0 +1,825 @@ +/* mz_crypt_wfm.c -- crypto/hash backend for the vendored minizip-ng + part of the PS5 web file manager + + minizip-ng keeps its hash and AES primitives behind a small pluggable + provider (mz_crypt_openssl.c, mz_crypt_brg.c, mz_crypt_apple.c, ...). + This file is the provider used here. It implements exactly the primitives + the ZIP decompression path needs and nothing else. + + What the two ZIP encryption schemes require: + + * Traditional PKWARE ("ZipCrypto", i.e. `zip -e`) needs neither AES nor a + hash: only crc32, which mz_crypt.c already provides, plus + mz_crypt_rand() on the write path. mz_strm_pkcrypt.c carries the + algorithm itself. + + * WinZip AES (the 0x9901 extra field, `-Z aes`) needs + - PBKDF2-HMAC-SHA1 to derive the keys -> mz_crypt.c (HAVE_WZAES) + - HMAC-SHA1 over the ciphertext -> here + - AES-128/192/256 in ECB on the counter -> here + mz_strm_wzaes.c turns the ECB primitive into the CTR-like keystream + WinZip specifies, so only single-block ECB is on the hot path. + + The primitives are implemented locally instead of delegating to another + vendored library so that this subtree stays self contained: it builds and + is testable with nothing but zlib and libc, on both prospero-clang and the + MinGW host test harness. It also deliberately avoids the 16-byte alignment + contract that third_party/7z/Aes.h imposes, because mz_strm_wzaes.c + encrypts a counter block that lives inside a heap struct. + + Only MZ_HASH_SHA1 is implemented. That is the only algorithm minizip-ng + asks for on these code paths (mz_strm_wzaes.c and mz_crypt_pbkdf2()); every + other MZ_HASH_* identifier is rejected with MZ_SUPPORT_ERROR rather than + silently producing a wrong digest. + + The AES tables are derived at first use and live in .bss, so the .rodata + cost of this file is zero. +*/ + +#include "mz.h" +#include "mz_crypt.h" + +#include +#include +#include +#include + +/************************************************************************** + * entropy + **************************************************************************/ + +int32_t mz_crypt_rand(uint8_t *buf, int32_t size) { + /* Only the archive *writing* paths reach this (mz_strm_wzaes.c generates + the salt and mz_strm_pkcrypt.c the encryption header); this payload only + ever reads archives. It is served from /dev/urandom rather than + minizip's mz_os_rand() ladder on purpose: + * mz_os_rand() falls back to libc rand()/srand() here, which would add + a load-time dependency on two symbols for a path we never take -- + and rand() is not a sound source for an encryption key anyway; + * open/read/close are already imported by the rest of the payload, so + this adds no new symbol. + If /dev/urandom cannot be opened the caller gets MZ_INTERNAL_ERROR + instead of silently weak randomness. */ + int fd; + int32_t done = 0; + + if (!buf || size < 0) + return MZ_PARAM_ERROR; + if (size == 0) + return 0; + + fd = open("/dev/urandom", O_RDONLY); + if (fd < 0) + return MZ_INTERNAL_ERROR; + + while (done < size) { + ssize_t n = read(fd, buf + done, (size_t)(size - done)); + + if (n < 0) { + if (errno == EINTR) + continue; + close(fd); + return MZ_INTERNAL_ERROR; + } + if (n == 0) + break; + done += (int32_t)n; + } + + close(fd); + return done == size ? size : MZ_INTERNAL_ERROR; +} + +/************************************************************************** + * SHA-1 (FIPS 180-4) + **************************************************************************/ + +#define WFM_SHA1_SIZE 20 +#define WFM_SHA1_BLOCK 64 + +typedef struct wfm_sha1_s { + uint32_t h[5]; + uint64_t total; /* message bytes consumed so far */ + uint8_t block[WFM_SHA1_BLOCK]; + uint32_t fill; /* bytes currently buffered in block */ +} wfm_sha1_t; + +static uint32_t wfm_rol32(uint32_t value, unsigned bits) { + return (value << bits) | (value >> (32 - bits)); +} + +static void wfm_sha1_init(wfm_sha1_t *s) { + s->h[0] = 0x67452301u; + s->h[1] = 0xefcdab89u; + s->h[2] = 0x98badcfeu; + s->h[3] = 0x10325476u; + s->h[4] = 0xc3d2e1f0u; + s->total = 0; + s->fill = 0; +} + +static void wfm_sha1_compress(wfm_sha1_t *s, const uint8_t *p) { + uint32_t w[80]; + uint32_t a, b, c, d, e, f, k, t; + int i; + + for (i = 0; i < 16; i++) { + w[i] = ((uint32_t)p[i * 4] << 24) | ((uint32_t)p[i * 4 + 1] << 16) | ((uint32_t)p[i * 4 + 2] << 8) | + (uint32_t)p[i * 4 + 3]; + } + for (i = 16; i < 80; i++) + w[i] = wfm_rol32(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); + + a = s->h[0]; + b = s->h[1]; + c = s->h[2]; + d = s->h[3]; + e = s->h[4]; + + for (i = 0; i < 80; i++) { + if (i < 20) { + f = (b & c) | (~b & d); + k = 0x5a827999u; + } else if (i < 40) { + f = b ^ c ^ d; + k = 0x6ed9eba1u; + } else if (i < 60) { + f = (b & c) | (b & d) | (c & d); + k = 0x8f1bbcdcu; + } else { + f = b ^ c ^ d; + k = 0xca62c1d6u; + } + t = wfm_rol32(a, 5) + f + e + k + w[i]; + e = d; + d = c; + c = wfm_rol32(b, 30); + b = a; + a = t; + } + + s->h[0] += a; + s->h[1] += b; + s->h[2] += c; + s->h[3] += d; + s->h[4] += e; +} + +static void wfm_sha1_update(wfm_sha1_t *s, const void *buf, size_t size) { + const uint8_t *p = (const uint8_t *)buf; + + s->total += size; + + if (s->fill) { + uint32_t need = WFM_SHA1_BLOCK - s->fill; + if (size < need) { + memcpy(s->block + s->fill, p, size); + s->fill += (uint32_t)size; + return; + } + memcpy(s->block + s->fill, p, need); + wfm_sha1_compress(s, s->block); + p += need; + size -= need; + s->fill = 0; + } + + while (size >= WFM_SHA1_BLOCK) { + wfm_sha1_compress(s, p); + p += WFM_SHA1_BLOCK; + size -= WFM_SHA1_BLOCK; + } + + if (size) { + memcpy(s->block, p, size); + s->fill = (uint32_t)size; + } +} + +/* Writes the full 20-byte digest. The context is left in a finalized state. */ +static void wfm_sha1_final(wfm_sha1_t *s, uint8_t out[WFM_SHA1_SIZE]) { + uint64_t bits = s->total * 8; + uint8_t pad = 0x80; + uint8_t len[8]; + uint32_t i; + + wfm_sha1_update(s, &pad, 1); + pad = 0x00; + while (s->fill != WFM_SHA1_BLOCK - 8) + wfm_sha1_update(s, &pad, 1); + + for (i = 0; i < 8; i++) + len[i] = (uint8_t)(bits >> (56 - 8 * i)); + wfm_sha1_update(s, len, 8); + + for (i = 0; i < 5; i++) { + out[i * 4 + 0] = (uint8_t)(s->h[i] >> 24); + out[i * 4 + 1] = (uint8_t)(s->h[i] >> 16); + out[i * 4 + 2] = (uint8_t)(s->h[i] >> 8); + out[i * 4 + 3] = (uint8_t)(s->h[i]); + } +} + +/************************************************************************** + * HMAC-SHA1 (RFC 2104) + **************************************************************************/ + +typedef struct wfm_hmac_s { + uint16_t algorithm; + int32_t initialized; + wfm_sha1_t inner; /* running hash of ipad || message */ + uint8_t opad[WFM_SHA1_BLOCK]; /* key ^ 0x5c, zero padded */ +} wfm_hmac_t; + +/* ipad = key ^ 0x36 and opad = key ^ 0x5c, so ipad can be recovered from + opad without storing the key twice. */ +#define WFM_HMAC_OPAD 0x5c +#define WFM_HMAC_PAD_DIFF (0x5c ^ 0x36) + +int32_t mz_crypt_hmac_init(void *handle, const void *key, int32_t key_length) { + wfm_hmac_t *hmac = (wfm_hmac_t *)handle; + uint8_t ipad[WFM_SHA1_BLOCK]; + uint8_t digest[WFM_SHA1_SIZE]; + wfm_sha1_t k; + uint32_t i; + + if (!hmac || (!key && key_length > 0) || key_length < 0) + return MZ_PARAM_ERROR; + if (hmac->algorithm != MZ_HASH_SHA1) + return MZ_SUPPORT_ERROR; + + memset(hmac->opad, 0, sizeof(hmac->opad)); + + if (key_length > WFM_SHA1_BLOCK) { + /* The key is hashed down to the digest size first. */ + wfm_sha1_init(&k); + wfm_sha1_update(&k, key, (size_t)key_length); + wfm_sha1_final(&k, digest); + memcpy(hmac->opad, digest, WFM_SHA1_SIZE); + } else if (key_length > 0) { + memcpy(hmac->opad, key, (size_t)key_length); + } + + /* opad = K ^ 0x5c, and ipad = K ^ 0x36 = opad ^ 0x5c ^ 0x36, so the key + only has to be stored once. */ + for (i = 0; i < WFM_SHA1_BLOCK; i++) + hmac->opad[i] ^= WFM_HMAC_OPAD; + for (i = 0; i < WFM_SHA1_BLOCK; i++) + ipad[i] = (uint8_t)(hmac->opad[i] ^ WFM_HMAC_PAD_DIFF); + + wfm_sha1_init(&hmac->inner); + wfm_sha1_update(&hmac->inner, ipad, WFM_SHA1_BLOCK); + + hmac->initialized = 1; + return MZ_OK; +} + +int32_t mz_crypt_hmac_update(void *handle, const void *buf, int32_t size) { + wfm_hmac_t *hmac = (wfm_hmac_t *)handle; + + if (!hmac || !buf || size < 0) + return MZ_PARAM_ERROR; + if (!hmac->initialized) + return MZ_PARAM_ERROR; + + wfm_sha1_update(&hmac->inner, buf, (size_t)size); + return MZ_OK; +} + +int32_t mz_crypt_hmac_end(void *handle, uint8_t *digest, int32_t digest_size) { + wfm_hmac_t *hmac = (wfm_hmac_t *)handle; + uint8_t inner_digest[WFM_SHA1_SIZE]; + wfm_sha1_t outer; + + if (!hmac || !digest) + return MZ_PARAM_ERROR; + if (!hmac->initialized) + return MZ_PARAM_ERROR; + if (digest_size < WFM_SHA1_SIZE) + return MZ_BUF_ERROR; + + wfm_sha1_final(&hmac->inner, inner_digest); + + wfm_sha1_init(&outer); + wfm_sha1_update(&outer, hmac->opad, WFM_SHA1_BLOCK); + wfm_sha1_update(&outer, inner_digest, WFM_SHA1_SIZE); + wfm_sha1_final(&outer, digest); + + return MZ_OK; +} + +int32_t mz_crypt_hmac_copy(void *src_handle, void *target_handle) { + wfm_hmac_t *source = (wfm_hmac_t *)src_handle; + wfm_hmac_t *target = (wfm_hmac_t *)target_handle; + + if (!source || !target) + return MZ_PARAM_ERROR; + /* The context owns no pointers, so a plain copy is a deep copy. This is + what mz_crypt_pbkdf2() relies on when it clones the mid state. */ + memcpy(target, source, sizeof(*target)); + return MZ_OK; +} + +void mz_crypt_hmac_set_algorithm(void *handle, uint16_t algorithm) { + wfm_hmac_t *hmac = (wfm_hmac_t *)handle; + if (hmac) + hmac->algorithm = algorithm; +} + +void mz_crypt_hmac_reset(void *handle) { + wfm_hmac_t *hmac = (wfm_hmac_t *)handle; + if (!hmac) + return; + memset(&hmac->inner, 0, sizeof(hmac->inner)); + memset(hmac->opad, 0, sizeof(hmac->opad)); + hmac->initialized = 0; +} + +void *mz_crypt_hmac_create(void) { + wfm_hmac_t *hmac = (wfm_hmac_t *)calloc(1, sizeof(wfm_hmac_t)); + if (hmac) + hmac->algorithm = MZ_HASH_SHA1; + return hmac; +} + +void mz_crypt_hmac_delete(void **handle) { + wfm_hmac_t *hmac = NULL; + if (!handle) + return; + hmac = (wfm_hmac_t *)*handle; + if (hmac) { + memset(hmac, 0, sizeof(*hmac)); + free(hmac); + } + *handle = NULL; +} + +/************************************************************************** + * mz_crypt_sha_* -- the standalone hash API declared by mz_crypt.h. + * Only SHA-1 is available (see the file header). + **************************************************************************/ + +typedef struct wfm_sha_handle_s { + uint16_t algorithm; + int32_t initialized; + wfm_sha1_t ctx; +} wfm_sha_handle_t; + +int32_t mz_crypt_sha_set_algorithm(void *handle, uint16_t algorithm) { + wfm_sha_handle_t *sha = (wfm_sha_handle_t *)handle; + if (!sha) + return MZ_PARAM_ERROR; + if (algorithm != MZ_HASH_SHA1) + return MZ_SUPPORT_ERROR; + sha->algorithm = algorithm; + return MZ_OK; +} + +int32_t mz_crypt_sha_begin(void *handle) { + wfm_sha_handle_t *sha = (wfm_sha_handle_t *)handle; + if (!sha) + return MZ_PARAM_ERROR; + if (sha->algorithm != MZ_HASH_SHA1) + return MZ_SUPPORT_ERROR; + wfm_sha1_init(&sha->ctx); + sha->initialized = 1; + return MZ_OK; +} + +int32_t mz_crypt_sha_update(void *handle, const void *buf, int32_t size) { + wfm_sha_handle_t *sha = (wfm_sha_handle_t *)handle; + if (!sha || !buf || size < 0) + return MZ_PARAM_ERROR; + if (!sha->initialized) + return MZ_PARAM_ERROR; + wfm_sha1_update(&sha->ctx, buf, (size_t)size); + return MZ_OK; +} + +int32_t mz_crypt_sha_end(void *handle, uint8_t *digest, int32_t digest_size) { + wfm_sha_handle_t *sha = (wfm_sha_handle_t *)handle; + if (!sha || !digest) + return MZ_PARAM_ERROR; + if (!sha->initialized) + return MZ_PARAM_ERROR; + if (digest_size < WFM_SHA1_SIZE) + return MZ_PARAM_ERROR; + wfm_sha1_final(&sha->ctx, digest); + sha->initialized = 0; + return MZ_OK; +} + +void mz_crypt_sha_reset(void *handle) { + wfm_sha_handle_t *sha = (wfm_sha_handle_t *)handle; + if (!sha) + return; + wfm_sha1_init(&sha->ctx); + sha->initialized = 0; +} + +void *mz_crypt_sha_create(void) { + wfm_sha_handle_t *sha = (wfm_sha_handle_t *)calloc(1, sizeof(wfm_sha_handle_t)); + if (sha) + sha->algorithm = MZ_HASH_SHA1; + return sha; +} + +void mz_crypt_sha_delete(void **handle) { + wfm_sha_handle_t *sha = NULL; + if (!handle) + return; + sha = (wfm_sha_handle_t *)*handle; + if (sha) { + memset(sha, 0, sizeof(*sha)); + free(sha); + } + *handle = NULL; +} + +/************************************************************************** + * AES-128/192/256 (FIPS 197) + * + * The tables are derived once from the field arithmetic instead of being + * spelled out, which keeps the .rodata cost at zero and removes any chance of + * a transcription error in a 256 entry table. + **************************************************************************/ + +#define WFM_AES_BLOCK 16 +#define WFM_AES_MAX_WORDS 60 /* (14 rounds + 1) * 4 */ + +static uint8_t wfm_aes_sbox[256]; +static uint8_t wfm_aes_inv_sbox[256]; +static uint8_t wfm_aes_log[256]; /* log base 3 */ +static uint8_t wfm_aes_alog[256]; /* 3^i, alog[255] wraps to 1 */ +static uint32_t wfm_aes_te[4][256]; +static int wfm_aes_tables_ready; + +static uint8_t wfm_aes_xtime(uint8_t x) { + return (uint8_t)((x << 1) ^ ((x & 0x80) ? 0x1b : 0x00)); +} + +static uint8_t wfm_rol8(uint8_t value, unsigned bits) { + return (uint8_t)((value << bits) | (value >> (8 - bits))); +} + +static void wfm_aes_build_tables(void) { + uint8_t x = 1; + int i; + + if (wfm_aes_tables_ready) + return; + wfm_aes_tables_ready = 1; + + for (i = 0; i < 255; i++) { + wfm_aes_alog[i] = x; + wfm_aes_log[x] = (uint8_t)i; + x ^= wfm_aes_xtime(x); /* x *= 3 */ + } + wfm_aes_alog[255] = 1; + wfm_aes_log[0] = 0; /* log is never used for 0 */ + + for (i = 0; i < 256; i++) { + /* Multiplicative inverse in GF(2^8), then the affine transform. */ + uint8_t inv = (i == 0) ? 0 : wfm_aes_alog[255 - wfm_aes_log[(uint8_t)i]]; + uint8_t r = (uint8_t)(inv ^ wfm_rol8(inv, 1) ^ wfm_rol8(inv, 2) ^ wfm_rol8(inv, 3) ^ wfm_rol8(inv, 4)); + r ^= 0x63; + + wfm_aes_sbox[i] = r; + wfm_aes_inv_sbox[r] = (uint8_t)i; + } + + for (i = 0; i < 256; i++) { + uint8_t s = wfm_aes_sbox[i]; + uint8_t s2 = wfm_aes_xtime(s); + uint8_t s3 = (uint8_t)(s2 ^ s); + + wfm_aes_te[0][i] = ((uint32_t)s2 << 24) | ((uint32_t)s << 16) | ((uint32_t)s << 8) | s3; + wfm_aes_te[1][i] = ((uint32_t)s3 << 24) | ((uint32_t)s2 << 16) | ((uint32_t)s << 8) | s; + wfm_aes_te[2][i] = ((uint32_t)s << 24) | ((uint32_t)s3 << 16) | ((uint32_t)s2 << 8) | s; + wfm_aes_te[3][i] = ((uint32_t)s << 24) | ((uint32_t)s << 16) | ((uint32_t)s3 << 8) | s2; + } +} + +static uint8_t wfm_aes_gf_mul(uint8_t a, uint8_t b) { + if (a == 0 || b == 0) + return 0; + /* The sum of two logarithms reaches 508, so the modulo has to happen in + int arithmetic: truncating to uint8_t first would wrap a large sum. */ + return wfm_aes_alog[(wfm_aes_log[a] + wfm_aes_log[b]) % 255]; +} + +typedef struct wfm_aes_s { + uint32_t rk[WFM_AES_MAX_WORDS]; + uint8_t iv[WFM_AES_BLOCK]; + int32_t mode; + int32_t nr; /* number of rounds */ + int32_t keyed; +} wfm_aes_t; + +static uint32_t wfm_load_be32(const uint8_t *p) { + return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | (uint32_t)p[3]; +} + +static void wfm_store_be32(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)(v >> 24); + p[1] = (uint8_t)(v >> 16); + p[2] = (uint8_t)(v >> 8); + p[3] = (uint8_t)v; +} + +static void wfm_aes_set_key(wfm_aes_t *a, const uint8_t *key, int32_t key_length) { + int32_t nk = key_length / 4; + int32_t total = 4 * (nk + 7); + int32_t i; + uint8_t rcon = 1; + uint32_t temp; + + wfm_aes_build_tables(); + + for (i = 0; i < nk; i++) + a->rk[i] = wfm_load_be32(key + 4 * i); + + for (i = nk; i < total; i++) { + temp = a->rk[i - 1]; + if (i % nk == 0) { + /* SubWord(RotWord(temp)) ^ Rcon */ + temp = ((uint32_t)wfm_aes_sbox[(temp >> 16) & 0xff] << 24) | + ((uint32_t)wfm_aes_sbox[(temp >> 8) & 0xff] << 16) | + ((uint32_t)wfm_aes_sbox[temp & 0xff] << 8) | (uint32_t)wfm_aes_sbox[(temp >> 24) & 0xff]; + temp ^= (uint32_t)rcon << 24; + rcon = wfm_aes_xtime(rcon); + } else if (nk > 6 && (i % nk) == 4) { + /* SubWord(temp) for AES-256 only */ + temp = ((uint32_t)wfm_aes_sbox[(temp >> 24) & 0xff] << 24) | + ((uint32_t)wfm_aes_sbox[(temp >> 16) & 0xff] << 16) | + ((uint32_t)wfm_aes_sbox[(temp >> 8) & 0xff] << 8) | (uint32_t)wfm_aes_sbox[temp & 0xff]; + } + a->rk[i] = a->rk[i - nk] ^ temp; + } + + a->nr = nk + 6; + a->keyed = 1; +} + +static void wfm_aes_encrypt_block(const wfm_aes_t *a, const uint8_t *in, uint8_t *out) { + const uint32_t *rk = a->rk; + uint32_t s0, s1, s2, s3, t0, t1, t2, t3; + int32_t r; + + s0 = wfm_load_be32(in) ^ rk[0]; + s1 = wfm_load_be32(in + 4) ^ rk[1]; + s2 = wfm_load_be32(in + 8) ^ rk[2]; + s3 = wfm_load_be32(in + 12) ^ rk[3]; + + for (r = 1; r < a->nr; r++) { + t0 = wfm_aes_te[0][(s0 >> 24) & 0xff] ^ wfm_aes_te[1][(s1 >> 16) & 0xff] ^ wfm_aes_te[2][(s2 >> 8) & 0xff] ^ + wfm_aes_te[3][s3 & 0xff] ^ rk[4 * r + 0]; + t1 = wfm_aes_te[0][(s1 >> 24) & 0xff] ^ wfm_aes_te[1][(s2 >> 16) & 0xff] ^ wfm_aes_te[2][(s3 >> 8) & 0xff] ^ + wfm_aes_te[3][s0 & 0xff] ^ rk[4 * r + 1]; + t2 = wfm_aes_te[0][(s2 >> 24) & 0xff] ^ wfm_aes_te[1][(s3 >> 16) & 0xff] ^ wfm_aes_te[2][(s0 >> 8) & 0xff] ^ + wfm_aes_te[3][s1 & 0xff] ^ rk[4 * r + 2]; + t3 = wfm_aes_te[0][(s3 >> 24) & 0xff] ^ wfm_aes_te[1][(s0 >> 16) & 0xff] ^ wfm_aes_te[2][(s1 >> 8) & 0xff] ^ + wfm_aes_te[3][s2 & 0xff] ^ rk[4 * r + 3]; + + s0 = t0; + s1 = t1; + s2 = t2; + s3 = t3; + } + + /* Final round: SubBytes, ShiftRows, AddRoundKey. */ + t0 = ((uint32_t)wfm_aes_sbox[(s0 >> 24) & 0xff] << 24) | ((uint32_t)wfm_aes_sbox[(s1 >> 16) & 0xff] << 16) | + ((uint32_t)wfm_aes_sbox[(s2 >> 8) & 0xff] << 8) | (uint32_t)wfm_aes_sbox[s3 & 0xff]; + t1 = ((uint32_t)wfm_aes_sbox[(s1 >> 24) & 0xff] << 24) | ((uint32_t)wfm_aes_sbox[(s2 >> 16) & 0xff] << 16) | + ((uint32_t)wfm_aes_sbox[(s3 >> 8) & 0xff] << 8) | (uint32_t)wfm_aes_sbox[s0 & 0xff]; + t2 = ((uint32_t)wfm_aes_sbox[(s2 >> 24) & 0xff] << 24) | ((uint32_t)wfm_aes_sbox[(s3 >> 16) & 0xff] << 16) | + ((uint32_t)wfm_aes_sbox[(s0 >> 8) & 0xff] << 8) | (uint32_t)wfm_aes_sbox[s1 & 0xff]; + t3 = ((uint32_t)wfm_aes_sbox[(s3 >> 24) & 0xff] << 24) | ((uint32_t)wfm_aes_sbox[(s0 >> 16) & 0xff] << 16) | + ((uint32_t)wfm_aes_sbox[(s1 >> 8) & 0xff] << 8) | (uint32_t)wfm_aes_sbox[s2 & 0xff]; + + wfm_store_be32(out, t0 ^ rk[4 * a->nr + 0]); + wfm_store_be32(out + 4, t1 ^ rk[4 * a->nr + 1]); + wfm_store_be32(out + 8, t2 ^ rk[4 * a->nr + 2]); + wfm_store_be32(out + 12, t3 ^ rk[4 * a->nr + 3]); +} + +/* Inverse cipher straight out of FIPS 197. Byte oriented on purpose: only + the encryption direction is on the hot path (WinZip AES encrypts the + counter block) and the decoder must stay small. */ +static void wfm_aes_decrypt_block(const wfm_aes_t *a, const uint8_t *in, uint8_t *out) { + const uint32_t *rk = a->rk; + uint8_t st[WFM_AES_BLOCK]; + uint8_t tmp[WFM_AES_BLOCK]; + int32_t round; + int32_t c, r; + + /* AddRoundKey with the last round key. */ + for (c = 0; c < 4; c++) { + uint32_t k = rk[4 * a->nr + c]; + st[4 * c + 0] = (uint8_t)(in[4 * c + 0] ^ (k >> 24)); + st[4 * c + 1] = (uint8_t)(in[4 * c + 1] ^ (k >> 16)); + st[4 * c + 2] = (uint8_t)(in[4 * c + 2] ^ (k >> 8)); + st[4 * c + 3] = (uint8_t)(in[4 * c + 3] ^ k); + } + + for (round = a->nr - 1; round >= 1; round--) { + /* InvShiftRows: row r rotates right by r. */ + for (c = 0; c < 4; c++) { + for (r = 0; r < 4; r++) + tmp[4 * c + r] = st[4 * ((c - r + 4) & 3) + r]; + } + /* InvSubBytes */ + for (c = 0; c < WFM_AES_BLOCK; c++) + tmp[c] = wfm_aes_inv_sbox[tmp[c]]; + /* AddRoundKey */ + for (c = 0; c < 4; c++) { + uint32_t k = rk[4 * round + c]; + st[4 * c + 0] = (uint8_t)(tmp[4 * c + 0] ^ (k >> 24)); + st[4 * c + 1] = (uint8_t)(tmp[4 * c + 1] ^ (k >> 16)); + st[4 * c + 2] = (uint8_t)(tmp[4 * c + 2] ^ (k >> 8)); + st[4 * c + 3] = (uint8_t)(tmp[4 * c + 3] ^ k); + } + /* InvMixColumns */ + for (c = 0; c < 4; c++) { + uint8_t a0 = st[4 * c + 0], a1 = st[4 * c + 1], a2 = st[4 * c + 2], a3 = st[4 * c + 3]; + tmp[4 * c + 0] = (uint8_t)(wfm_aes_gf_mul(a0, 14) ^ wfm_aes_gf_mul(a1, 11) ^ wfm_aes_gf_mul(a2, 13) ^ + wfm_aes_gf_mul(a3, 9)); + tmp[4 * c + 1] = (uint8_t)(wfm_aes_gf_mul(a0, 9) ^ wfm_aes_gf_mul(a1, 14) ^ wfm_aes_gf_mul(a2, 11) ^ + wfm_aes_gf_mul(a3, 13)); + tmp[4 * c + 2] = (uint8_t)(wfm_aes_gf_mul(a0, 13) ^ wfm_aes_gf_mul(a1, 9) ^ wfm_aes_gf_mul(a2, 14) ^ + wfm_aes_gf_mul(a3, 11)); + tmp[4 * c + 3] = (uint8_t)(wfm_aes_gf_mul(a0, 11) ^ wfm_aes_gf_mul(a1, 13) ^ wfm_aes_gf_mul(a2, 9) ^ + wfm_aes_gf_mul(a3, 14)); + } + memcpy(st, tmp, WFM_AES_BLOCK); + } + + /* Final round without InvMixColumns. */ + for (c = 0; c < 4; c++) { + for (r = 0; r < 4; r++) + tmp[4 * c + r] = st[4 * ((c - r + 4) & 3) + r]; + } + for (c = 0; c < WFM_AES_BLOCK; c++) + tmp[c] = wfm_aes_inv_sbox[tmp[c]]; + for (c = 0; c < 4; c++) { + uint32_t k = rk[c]; + out[4 * c + 0] = (uint8_t)(tmp[4 * c + 0] ^ (k >> 24)); + out[4 * c + 1] = (uint8_t)(tmp[4 * c + 1] ^ (k >> 16)); + out[4 * c + 2] = (uint8_t)(tmp[4 * c + 2] ^ (k >> 8)); + out[4 * c + 3] = (uint8_t)(tmp[4 * c + 3] ^ k); + } +} + +/************************************************************************** + * mz_crypt_aes_* -- the streaming AES API declared by mz_crypt.h + **************************************************************************/ + +void *mz_crypt_aes_create(void) { + wfm_aes_t *aes = (wfm_aes_t *)calloc(1, sizeof(wfm_aes_t)); + if (aes) + aes->mode = MZ_AES_MODE_ECB; + return aes; +} + +void mz_crypt_aes_delete(void **handle) { + wfm_aes_t *aes = NULL; + if (!handle) + return; + aes = (wfm_aes_t *)*handle; + if (aes) { + memset(aes, 0, sizeof(*aes)); + free(aes); + } + *handle = NULL; +} + +void mz_crypt_aes_reset(void *handle) { + wfm_aes_t *aes = (wfm_aes_t *)handle; + if (!aes) + return; + /* Deliberately keeps the mode: mz_strm_wzaes.c resets and then sets the + key, relying on ECB being the default. */ + memset(aes->iv, 0, sizeof(aes->iv)); + aes->keyed = 0; +} + +void mz_crypt_aes_set_mode(void *handle, int32_t mode) { + wfm_aes_t *aes = (wfm_aes_t *)handle; + if (!aes) + return; + aes->mode = mode; +} + +int32_t mz_crypt_aes_set_encrypt_key(void *handle, const void *key, int32_t key_length, const void *iv, + int32_t iv_length) { + wfm_aes_t *aes = (wfm_aes_t *)handle; + + if (!aes || !key) + return MZ_PARAM_ERROR; + if (key_length != 16 && key_length != 24 && key_length != 32) + return MZ_PARAM_ERROR; + if (iv_length != 0 && (iv_length != WFM_AES_BLOCK || !iv)) + return MZ_PARAM_ERROR; + if (aes->mode != MZ_AES_MODE_ECB && aes->mode != MZ_AES_MODE_CBC) + return MZ_SUPPORT_ERROR; + + wfm_aes_set_key(aes, (const uint8_t *)key, key_length); + memset(aes->iv, 0, sizeof(aes->iv)); + if (iv_length == WFM_AES_BLOCK) + memcpy(aes->iv, iv, WFM_AES_BLOCK); + return MZ_OK; +} + +int32_t mz_crypt_aes_set_decrypt_key(void *handle, const void *key, int32_t key_length, const void *iv, + int32_t iv_length) { + /* The key schedule is the encryption one in both directions; the inverse + cipher uses it directly. */ + return mz_crypt_aes_set_encrypt_key(handle, key, key_length, iv, iv_length); +} + +int32_t mz_crypt_aes_encrypt(void *handle, const void *aad, int32_t aad_size, uint8_t *buf, int32_t size) { + wfm_aes_t *aes = (wfm_aes_t *)handle; + int32_t done; + + if (!aes || !buf || size < 0) + return MZ_PARAM_ERROR; + if (aad_size != 0) + return MZ_SUPPORT_ERROR; /* no AEAD modes are implemented */ + if (!aes->keyed) + return MZ_PARAM_ERROR; + if (size % WFM_AES_BLOCK != 0) + return MZ_PARAM_ERROR; + + if (aes->mode == MZ_AES_MODE_ECB) { + /* Every block is encrypted independently, which is exactly what + mz_strm_wzaes.c wants for its counter. */ + for (done = 0; done < size; done += WFM_AES_BLOCK) + wfm_aes_encrypt_block(aes, buf + done, buf + done); + return MZ_OK; + } + + for (done = 0; done < size; done += WFM_AES_BLOCK) { + uint8_t block[WFM_AES_BLOCK]; + int32_t i; + + for (i = 0; i < WFM_AES_BLOCK; i++) + block[i] = (uint8_t)(buf[done + i] ^ aes->iv[i]); + wfm_aes_encrypt_block(aes, block, block); + memcpy(aes->iv, block, WFM_AES_BLOCK); + memcpy(buf + done, block, WFM_AES_BLOCK); + } + return MZ_OK; +} + +int32_t mz_crypt_aes_decrypt(void *handle, const void *aad, int32_t aad_size, uint8_t *buf, int32_t size) { + wfm_aes_t *aes = (wfm_aes_t *)handle; + int32_t done; + + if (!aes || !buf || size < 0) + return MZ_PARAM_ERROR; + if (aad_size != 0) + return MZ_SUPPORT_ERROR; + if (!aes->keyed) + return MZ_PARAM_ERROR; + if (size % WFM_AES_BLOCK != 0) + return MZ_PARAM_ERROR; + + if (aes->mode == MZ_AES_MODE_ECB) { + for (done = 0; done < size; done += WFM_AES_BLOCK) + wfm_aes_decrypt_block(aes, buf + done, buf + done); + return MZ_OK; + } + + for (done = 0; done < size; done += WFM_AES_BLOCK) { + uint8_t ctext[WFM_AES_BLOCK]; + uint8_t ptext[WFM_AES_BLOCK]; + int32_t i; + + memcpy(ctext, buf + done, WFM_AES_BLOCK); + wfm_aes_decrypt_block(aes, ctext, ptext); + for (i = 0; i < WFM_AES_BLOCK; i++) + ptext[i] ^= aes->iv[i]; + memcpy(aes->iv, ctext, WFM_AES_BLOCK); + memcpy(buf + done, ptext, WFM_AES_BLOCK); + } + return MZ_OK; +} + +int32_t mz_crypt_aes_encrypt_final(void *handle, uint8_t *buf, int32_t size, uint8_t *tag, int32_t tag_size) { + if (!handle || !buf || size < 0) + return MZ_PARAM_ERROR; + if (tag && tag_size > 0) + memset(tag, 0, (size_t)tag_size); + /* ECB/CBC are block modes with no trailing state to flush. */ + return (size == 0) ? MZ_OK : MZ_PARAM_ERROR; +} + +int32_t mz_crypt_aes_decrypt_final(void *handle, uint8_t *buf, int32_t size, const uint8_t *tag, int32_t tag_size) { + if (!handle || !buf || size < 0) + return MZ_PARAM_ERROR; + if (tag && tag_size > 0) + return MZ_SUPPORT_ERROR; /* no authentication is implemented */ + return (size == 0) ? MZ_OK : MZ_PARAM_ERROR; +} + +/***************************************************************************/ diff --git a/third_party/minizip-ng/src/mz_strm_pkcrypt.c b/third_party/minizip-ng/src/mz_strm_pkcrypt.c new file mode 100644 index 0000000..8098719 --- /dev/null +++ b/third_party/minizip-ng/src/mz_strm_pkcrypt.c @@ -0,0 +1,324 @@ +/* mz_strm_pkcrypt.c -- Code for traditional PKWARE encryption + part of the minizip-ng project + + Copyright (C) Nathan Moinvaziri + https://github.com/zlib-ng/minizip-ng + Copyright (C) 1998-2005 Gilles Vollant + Modifications for Info-ZIP crypting + https://www.winimage.com/zLibDll/minizip.html + Copyright (C) 2003 Terry Thorsen + + This code is a modified version of crypting code in Info-ZIP distribution + + Copyright (C) 1990-2000 Info-ZIP. All rights reserved. + + This program is distributed under the terms of the same license as zlib. + See the accompanying LICENSE file for the full text of the license. + + This encryption code is a direct transcription of the algorithm from + Roger Schlafly, described by Phil Katz in the file appnote.txt. This + file (appnote.txt) is distributed with the PKZIP program (even in the + version without encryption capabilities). +*/ + +#include "mz.h" +#include "mz_crypt.h" +#include "mz_strm.h" +#include "mz_strm_pkcrypt.h" + +/***************************************************************************/ + +static mz_stream_vtbl mz_stream_pkcrypt_vtbl = { + mz_stream_pkcrypt_open, mz_stream_pkcrypt_is_open, mz_stream_pkcrypt_read, + mz_stream_pkcrypt_write, mz_stream_pkcrypt_tell, mz_stream_pkcrypt_seek, + mz_stream_pkcrypt_close, mz_stream_pkcrypt_error, mz_stream_pkcrypt_create, + mz_stream_pkcrypt_delete, mz_stream_pkcrypt_get_prop_int64, mz_stream_pkcrypt_set_prop_int64}; + +/***************************************************************************/ + +typedef struct mz_stream_pkcrypt_s { + mz_stream stream; + int32_t error; + int16_t initialized; + uint8_t buffer[UINT16_MAX]; + int64_t total_in; + int64_t max_total_in; + int64_t total_out; + uint32_t keys[3]; /* keys defining the pseudo-random sequence */ + uint8_t verify1; + uint8_t verify2; + uint16_t verify_version; + const char *password; +} mz_stream_pkcrypt; + +/***************************************************************************/ + +#define mz_stream_pkcrypt_decode(strm, c) \ + (mz_stream_pkcrypt_update_keys(strm, c ^= mz_stream_pkcrypt_decrypt_byte(strm))) + +#define mz_stream_pkcrypt_encode(strm, c, t) \ + (t = mz_stream_pkcrypt_decrypt_byte(strm), mz_stream_pkcrypt_update_keys(strm, (uint8_t)c), (uint8_t)(t ^ (c))) + +/***************************************************************************/ + +static uint8_t mz_stream_pkcrypt_decrypt_byte(void *stream) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + + unsigned temp; /* POTENTIAL BUG: temp*(temp^1) may overflow in an */ + /* unpredictable manner on 16-bit systems; not a problem */ + /* with any known compiler so far, though. */ + + temp = pkcrypt->keys[2] | 2; + return (uint8_t)(((temp * (temp ^ 1)) >> 8) & 0xff); +} + +static uint8_t mz_stream_pkcrypt_update_keys(void *stream, uint8_t c) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + uint8_t buf = c; + + pkcrypt->keys[0] = (uint32_t)~mz_crypt_crc32_update(~pkcrypt->keys[0], &buf, 1); + + pkcrypt->keys[1] += pkcrypt->keys[0] & 0xff; + pkcrypt->keys[1] *= 134775813L; + pkcrypt->keys[1] += 1; + + buf = (uint8_t)(pkcrypt->keys[1] >> 24); + pkcrypt->keys[2] = (uint32_t)~mz_crypt_crc32_update(~pkcrypt->keys[2], &buf, 1); + + return (uint8_t)c; +} + +static void mz_stream_pkcrypt_init_keys(void *stream, const char *password) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + + pkcrypt->keys[0] = 305419896L; + pkcrypt->keys[1] = 591751049L; + pkcrypt->keys[2] = 878082192L; + + while (*password != 0) { + mz_stream_pkcrypt_update_keys(stream, (uint8_t)*password); + password += 1; + } +} + +/***************************************************************************/ + +int32_t mz_stream_pkcrypt_open(void *stream, const char *path, int32_t mode) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + uint16_t t = 0; + int16_t i = 0; + uint8_t verify1 = 0; + uint8_t verify2 = 0; + uint8_t header[MZ_PKCRYPT_HEADER_SIZE]; + const char *password = path; + + pkcrypt->total_in = 0; + pkcrypt->total_out = 0; + pkcrypt->initialized = 0; + + if (mz_stream_is_open(pkcrypt->stream.base) != MZ_OK) + return MZ_OPEN_ERROR; + + if (!password) + password = pkcrypt->password; + if (!password) + return MZ_PARAM_ERROR; + + mz_stream_pkcrypt_init_keys(stream, password); + + if (mode & MZ_OPEN_MODE_WRITE) { + /* First generate RAND_HEAD_LEN - 2 random bytes. */ + mz_crypt_rand(header, MZ_PKCRYPT_HEADER_SIZE - 2); + + /* Encrypt random header (last two bytes is high word of crc) */ + for (i = 0; i < MZ_PKCRYPT_HEADER_SIZE - 2; i++) + header[i] = mz_stream_pkcrypt_encode(stream, header[i], t); + + header[i++] = mz_stream_pkcrypt_encode(stream, pkcrypt->verify1, t); + header[i++] = mz_stream_pkcrypt_encode(stream, pkcrypt->verify2, t); + + if (mz_stream_write(pkcrypt->stream.base, header, sizeof(header)) != sizeof(header)) + return MZ_WRITE_ERROR; + + pkcrypt->total_out += MZ_PKCRYPT_HEADER_SIZE; + } else if (mode & MZ_OPEN_MODE_READ) { + if (mz_stream_read(pkcrypt->stream.base, header, sizeof(header)) != sizeof(header)) + return MZ_READ_ERROR; + + for (i = 0; i < MZ_PKCRYPT_HEADER_SIZE - 2; i++) + header[i] = mz_stream_pkcrypt_decode(stream, header[i]); + + verify1 = mz_stream_pkcrypt_decode(stream, header[i++]); + verify2 = mz_stream_pkcrypt_decode(stream, header[i++]); + + /* PKZIP 2.0 and higher use 1 byte check, older versions used 2 byte check. + See app note section 6.1.6. */ + if (verify2 != pkcrypt->verify2) + return MZ_PASSWORD_ERROR; + if (pkcrypt->verify_version < 2) { + if (verify1 != pkcrypt->verify1) + return MZ_PASSWORD_ERROR; + } + + pkcrypt->total_in += MZ_PKCRYPT_HEADER_SIZE; + } + + pkcrypt->initialized = 1; + return MZ_OK; +} + +int32_t mz_stream_pkcrypt_is_open(void *stream) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + if (!pkcrypt->initialized) + return MZ_OPEN_ERROR; + return MZ_OK; +} + +int32_t mz_stream_pkcrypt_read(void *stream, void *buf, int32_t size) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + uint8_t *buf_ptr = (uint8_t *)buf; + int32_t bytes_to_read = size; + int32_t read = 0; + int32_t i = 0; + + if ((int64_t)bytes_to_read > (pkcrypt->max_total_in - pkcrypt->total_in)) + bytes_to_read = (int32_t)(pkcrypt->max_total_in - pkcrypt->total_in); + + read = mz_stream_read(pkcrypt->stream.base, buf, bytes_to_read); + + for (i = 0; i < read; i++) + buf_ptr[i] = mz_stream_pkcrypt_decode(stream, buf_ptr[i]); + + if (read > 0) + pkcrypt->total_in += read; + + return read; +} + +int32_t mz_stream_pkcrypt_write(void *stream, const void *buf, int32_t size) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + const uint8_t *buf_ptr = (const uint8_t *)buf; + int32_t bytes_to_write = sizeof(pkcrypt->buffer); + int32_t total_written = 0; + int32_t written = 0; + int32_t i = 0; + uint16_t t = 0; + + if (size < 0) + return MZ_PARAM_ERROR; + + do { + if (bytes_to_write > (size - total_written)) + bytes_to_write = (size - total_written); + + for (i = 0; i < bytes_to_write; i += 1) { + pkcrypt->buffer[i] = mz_stream_pkcrypt_encode(stream, *buf_ptr, t); + buf_ptr += 1; + } + + written = mz_stream_write(pkcrypt->stream.base, pkcrypt->buffer, bytes_to_write); + if (written < 0) + return written; + + total_written += written; + } while (total_written < size && written > 0); + + pkcrypt->total_out += total_written; + return total_written; +} + +int64_t mz_stream_pkcrypt_tell(void *stream) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + return mz_stream_tell(pkcrypt->stream.base); +} + +int32_t mz_stream_pkcrypt_seek(void *stream, int64_t offset, int32_t origin) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + return mz_stream_seek(pkcrypt->stream.base, offset, origin); +} + +int32_t mz_stream_pkcrypt_close(void *stream) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + pkcrypt->initialized = 0; + return MZ_OK; +} + +int32_t mz_stream_pkcrypt_error(void *stream) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + return pkcrypt->error; +} + +void mz_stream_pkcrypt_set_password(void *stream, const char *password) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + pkcrypt->password = password; +} + +void mz_stream_pkcrypt_set_verify(void *stream, uint8_t verify1, uint8_t verify2, uint16_t version) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + pkcrypt->verify1 = verify1; + pkcrypt->verify2 = verify2; + pkcrypt->verify_version = version; +} + +void mz_stream_pkcrypt_get_verify(void *stream, uint8_t *verify1, uint8_t *verify2, uint16_t *version) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + *verify1 = pkcrypt->verify1; + *verify2 = pkcrypt->verify2; + *version = pkcrypt->verify_version; +} + +int32_t mz_stream_pkcrypt_get_prop_int64(void *stream, int32_t prop, int64_t *value) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + switch (prop) { + case MZ_STREAM_PROP_TOTAL_IN: + *value = pkcrypt->total_in; + break; + case MZ_STREAM_PROP_TOTAL_OUT: + *value = pkcrypt->total_out; + break; + case MZ_STREAM_PROP_TOTAL_IN_MAX: + *value = pkcrypt->max_total_in; + break; + case MZ_STREAM_PROP_HEADER_SIZE: + *value = MZ_PKCRYPT_HEADER_SIZE; + break; + case MZ_STREAM_PROP_FOOTER_SIZE: + *value = 0; + break; + default: + return MZ_EXIST_ERROR; + } + return MZ_OK; +} + +int32_t mz_stream_pkcrypt_set_prop_int64(void *stream, int32_t prop, int64_t value) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream; + switch (prop) { + case MZ_STREAM_PROP_TOTAL_IN_MAX: + pkcrypt->max_total_in = value; + break; + default: + return MZ_EXIST_ERROR; + } + return MZ_OK; +} + +void *mz_stream_pkcrypt_create(void) { + mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)calloc(1, sizeof(mz_stream_pkcrypt)); + if (pkcrypt) + pkcrypt->stream.vtbl = &mz_stream_pkcrypt_vtbl; + return pkcrypt; +} + +void mz_stream_pkcrypt_delete(void **stream) { + mz_stream_pkcrypt *pkcrypt = NULL; + if (!stream) + return; + pkcrypt = (mz_stream_pkcrypt *)*stream; + free(pkcrypt); + *stream = NULL; +} + +void *mz_stream_pkcrypt_get_interface(void) { + return (void *)&mz_stream_pkcrypt_vtbl; +} diff --git a/third_party/minizip-ng/src/mz_strm_wzaes.c b/third_party/minizip-ng/src/mz_strm_wzaes.c new file mode 100644 index 0000000..a0c9fac --- /dev/null +++ b/third_party/minizip-ng/src/mz_strm_wzaes.c @@ -0,0 +1,355 @@ +/* mz_strm_wzaes.c -- Stream for WinZip AES encryption + part of the minizip-ng project + + Copyright (C) Nathan Moinvaziri + https://github.com/zlib-ng/minizip-ng + Copyright (C) 1998-2010 Brian Gladman, Worcester, UK + + This program is distributed under the terms of the same license as zlib. + See the accompanying LICENSE file for the full text of the license. +*/ + +#include "mz.h" +#include "mz_crypt.h" +#include "mz_strm.h" +#include "mz_strm_wzaes.h" + +/***************************************************************************/ + +#define MZ_AES_KEY_LENGTH(STRENGTH) (8 * (STRENGTH & 3) + 8) +#define MZ_AES_KEYING_ITERATIONS (1000) +#define MZ_AES_SALT_LENGTH(STRENGTH) (4 * (STRENGTH & 3) + 4) +#define MZ_AES_SALT_LENGTH_MAX (16) +#define MZ_AES_PW_LENGTH_MAX (128) +#define MZ_AES_PW_VERIFY_SIZE (2) +#define MZ_AES_AUTHCODE_SIZE (10) + +/***************************************************************************/ + +static mz_stream_vtbl mz_stream_wzaes_vtbl = { + mz_stream_wzaes_open, mz_stream_wzaes_is_open, mz_stream_wzaes_read, mz_stream_wzaes_write, + mz_stream_wzaes_tell, mz_stream_wzaes_seek, mz_stream_wzaes_close, mz_stream_wzaes_error, + mz_stream_wzaes_create, mz_stream_wzaes_delete, mz_stream_wzaes_get_prop_int64, mz_stream_wzaes_set_prop_int64}; + +/***************************************************************************/ + +typedef struct mz_stream_wzaes_s { + mz_stream stream; + int32_t mode; + int32_t error; + int16_t initialized; + uint8_t buffer[UINT16_MAX]; + int64_t total_in; + int64_t max_total_in; + int64_t total_out; + uint8_t strength; + const char *password; + void *aes; + uint32_t crypt_pos; + uint8_t crypt_block[MZ_AES_BLOCK_SIZE]; + void *hmac; + uint8_t nonce[MZ_AES_BLOCK_SIZE]; +} mz_stream_wzaes; + +/***************************************************************************/ + +int32_t mz_stream_wzaes_open(void *stream, const char *path, int32_t mode) { + mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream; + uint16_t salt_length = 0; + uint16_t password_length = 0; + uint16_t key_length = 0; + uint8_t kbuf[2 * MZ_AES_KEY_LENGTH_MAX + MZ_AES_PW_VERIFY_SIZE]; + uint8_t verify[MZ_AES_PW_VERIFY_SIZE]; + uint8_t verify_expected[MZ_AES_PW_VERIFY_SIZE]; + uint8_t salt_value[MZ_AES_SALT_LENGTH_MAX]; + const char *password = path; + + wzaes->total_in = 0; + wzaes->total_out = 0; + wzaes->initialized = 0; + + if (mz_stream_is_open(wzaes->stream.base) != MZ_OK) + return MZ_OPEN_ERROR; + + if (!password) + password = wzaes->password; + if (!password) + return MZ_PARAM_ERROR; + password_length = (uint16_t)strlen(password); + if (password_length > MZ_AES_PW_LENGTH_MAX) + return MZ_PARAM_ERROR; + + if (wzaes->strength < 1 || wzaes->strength > 3) + return MZ_PARAM_ERROR; + + key_length = MZ_AES_KEY_LENGTH(wzaes->strength); + salt_length = MZ_AES_SALT_LENGTH(wzaes->strength); + + if (mode & MZ_OPEN_MODE_WRITE) { + mz_crypt_rand(salt_value, salt_length); + } else if (mode & MZ_OPEN_MODE_READ) { + if (mz_stream_read(wzaes->stream.base, salt_value, salt_length) != salt_length) + return MZ_READ_ERROR; + } + + /* Derive the encryption and authentication keys and the password verifier */ + mz_crypt_pbkdf2((uint8_t *)password, password_length, salt_value, salt_length, MZ_AES_KEYING_ITERATIONS, kbuf, + 2 * key_length + MZ_AES_PW_VERIFY_SIZE); + + /* Initialize the buffer pos */ + wzaes->crypt_pos = MZ_AES_BLOCK_SIZE; + + /* Use fixed zeroed IV/nonce for CTR mode */ + memset(wzaes->nonce, 0, sizeof(wzaes->nonce)); + + /* Initialize for encryption using key 1 */ + mz_crypt_aes_reset(wzaes->aes); + mz_crypt_aes_set_encrypt_key(wzaes->aes, kbuf, key_length, NULL, 0); + + /* Initialize for authentication using key 2 */ + mz_crypt_hmac_reset(wzaes->hmac); + mz_crypt_hmac_set_algorithm(wzaes->hmac, MZ_HASH_SHA1); + mz_crypt_hmac_init(wzaes->hmac, kbuf + key_length, key_length); + + memcpy(verify, kbuf + (2 * key_length), MZ_AES_PW_VERIFY_SIZE); + + if (mode & MZ_OPEN_MODE_WRITE) { + if (mz_stream_write(wzaes->stream.base, salt_value, salt_length) != salt_length) + return MZ_WRITE_ERROR; + + wzaes->total_out += salt_length; + + if (mz_stream_write(wzaes->stream.base, verify, MZ_AES_PW_VERIFY_SIZE) != MZ_AES_PW_VERIFY_SIZE) + return MZ_WRITE_ERROR; + + wzaes->total_out += MZ_AES_PW_VERIFY_SIZE; + } else if (mode & MZ_OPEN_MODE_READ) { + wzaes->total_in += salt_length; + + if (mz_stream_read(wzaes->stream.base, verify_expected, MZ_AES_PW_VERIFY_SIZE) != MZ_AES_PW_VERIFY_SIZE) + return MZ_READ_ERROR; + + wzaes->total_in += MZ_AES_PW_VERIFY_SIZE; + + if (memcmp(verify_expected, verify, MZ_AES_PW_VERIFY_SIZE) != 0) + return MZ_PASSWORD_ERROR; + } + + wzaes->mode = mode; + wzaes->initialized = 1; + + return MZ_OK; +} + +int32_t mz_stream_wzaes_is_open(void *stream) { + mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream; + if (!wzaes->initialized) + return MZ_OPEN_ERROR; + return MZ_OK; +} + +static int32_t mz_stream_wzaes_ctr_encrypt(void *stream, uint8_t *buf, int32_t size) { + mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream; + uint32_t pos = wzaes->crypt_pos; + uint32_t i = 0; + int32_t err = MZ_OK; + + while (i < (uint32_t)size) { + if (pos == MZ_AES_BLOCK_SIZE) { + uint32_t j = 0; + + /* Increment encryption nonce */ + while (j < 8 && !++wzaes->nonce[j]) + j += 1; + + /* Encrypt the nonce using ECB mode to form next xor buffer */ + memcpy(wzaes->crypt_block, wzaes->nonce, MZ_AES_BLOCK_SIZE); + mz_crypt_aes_encrypt(wzaes->aes, NULL, 0, wzaes->crypt_block, sizeof(wzaes->crypt_block)); + pos = 0; + } + + buf[i++] ^= wzaes->crypt_block[pos++]; + } + + wzaes->crypt_pos = pos; + return err; +} + +int32_t mz_stream_wzaes_read(void *stream, void *buf, int32_t size) { + mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream; + int64_t max_total_in = 0; + int32_t bytes_to_read = size; + int32_t read = 0; + + max_total_in = wzaes->max_total_in - MZ_AES_FOOTER_SIZE; + if ((int64_t)bytes_to_read > (max_total_in - wzaes->total_in)) + bytes_to_read = (int32_t)(max_total_in - wzaes->total_in); + + read = mz_stream_read(wzaes->stream.base, buf, bytes_to_read); + + if (read > 0) { + mz_crypt_hmac_update(wzaes->hmac, (uint8_t *)buf, read); + mz_stream_wzaes_ctr_encrypt(stream, (uint8_t *)buf, read); + + wzaes->total_in += read; + } + + return read; +} + +int32_t mz_stream_wzaes_write(void *stream, const void *buf, int32_t size) { + mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream; + const uint8_t *buf_ptr = (const uint8_t *)buf; + int32_t bytes_to_write = sizeof(wzaes->buffer); + int32_t total_written = 0; + int32_t written = 0; + + if (size < 0) + return MZ_PARAM_ERROR; + + do { + if (bytes_to_write > (size - total_written)) + bytes_to_write = (size - total_written); + + memcpy(wzaes->buffer, buf_ptr, bytes_to_write); + buf_ptr += bytes_to_write; + + mz_stream_wzaes_ctr_encrypt(stream, (uint8_t *)wzaes->buffer, bytes_to_write); + mz_crypt_hmac_update(wzaes->hmac, wzaes->buffer, bytes_to_write); + + written = mz_stream_write(wzaes->stream.base, wzaes->buffer, bytes_to_write); + if (written < 0) + return written; + + total_written += written; + } while (total_written < size && written > 0); + + wzaes->total_out += total_written; + return total_written; +} + +int64_t mz_stream_wzaes_tell(void *stream) { + mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream; + return mz_stream_tell(wzaes->stream.base); +} + +int32_t mz_stream_wzaes_seek(void *stream, int64_t offset, int32_t origin) { + mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream; + return mz_stream_seek(wzaes->stream.base, offset, origin); +} + +int32_t mz_stream_wzaes_close(void *stream) { + mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream; + uint8_t expected_hash[MZ_AES_AUTHCODE_SIZE]; + uint8_t computed_hash[MZ_HASH_SHA1_SIZE]; + + mz_crypt_hmac_end(wzaes->hmac, computed_hash, sizeof(computed_hash)); + + if (wzaes->mode & MZ_OPEN_MODE_WRITE) { + if (mz_stream_write(wzaes->stream.base, computed_hash, MZ_AES_AUTHCODE_SIZE) != MZ_AES_AUTHCODE_SIZE) + return MZ_WRITE_ERROR; + + wzaes->total_out += MZ_AES_AUTHCODE_SIZE; + } else if (wzaes->mode & MZ_OPEN_MODE_READ) { + if (mz_stream_read(wzaes->stream.base, expected_hash, MZ_AES_AUTHCODE_SIZE) != MZ_AES_AUTHCODE_SIZE) + return MZ_READ_ERROR; + + wzaes->total_in += MZ_AES_AUTHCODE_SIZE; + + /* If entire entry was not read this will fail */ + if (memcmp(computed_hash, expected_hash, MZ_AES_AUTHCODE_SIZE) != 0) + return MZ_CRC_ERROR; + } + + wzaes->initialized = 0; + return MZ_OK; +} + +int32_t mz_stream_wzaes_error(void *stream) { + mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream; + return wzaes->error; +} + +void mz_stream_wzaes_set_password(void *stream, const char *password) { + mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream; + wzaes->password = password; +} + +void mz_stream_wzaes_set_strength(void *stream, uint8_t strength) { + mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream; + wzaes->strength = strength; +} + +int32_t mz_stream_wzaes_get_prop_int64(void *stream, int32_t prop, int64_t *value) { + mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream; + switch (prop) { + case MZ_STREAM_PROP_TOTAL_IN: + *value = wzaes->total_in; + break; + case MZ_STREAM_PROP_TOTAL_OUT: + *value = wzaes->total_out; + break; + case MZ_STREAM_PROP_TOTAL_IN_MAX: + *value = wzaes->max_total_in; + break; + case MZ_STREAM_PROP_HEADER_SIZE: + *value = MZ_AES_SALT_LENGTH((int64_t)wzaes->strength) + MZ_AES_PW_VERIFY_SIZE; + break; + case MZ_STREAM_PROP_FOOTER_SIZE: + *value = MZ_AES_AUTHCODE_SIZE; + break; + default: + return MZ_EXIST_ERROR; + } + return MZ_OK; +} + +int32_t mz_stream_wzaes_set_prop_int64(void *stream, int32_t prop, int64_t value) { + mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream; + switch (prop) { + case MZ_STREAM_PROP_TOTAL_IN_MAX: + wzaes->max_total_in = value; + break; + default: + return MZ_EXIST_ERROR; + } + return MZ_OK; +} + +void *mz_stream_wzaes_create(void) { + mz_stream_wzaes *wzaes = (mz_stream_wzaes *)calloc(1, sizeof(mz_stream_wzaes)); + if (wzaes) { + wzaes->stream.vtbl = &mz_stream_wzaes_vtbl; + wzaes->strength = MZ_AES_STRENGTH_256; + + wzaes->hmac = mz_crypt_hmac_create(); + if (!wzaes->hmac) { + free(wzaes); + return NULL; + } + wzaes->aes = mz_crypt_aes_create(); + if (!wzaes->aes) { + mz_crypt_hmac_delete(&wzaes->hmac); + free(wzaes); + return NULL; + } + } + return wzaes; +} + +void mz_stream_wzaes_delete(void **stream) { + mz_stream_wzaes *wzaes = NULL; + if (!stream) + return; + wzaes = (mz_stream_wzaes *)*stream; + if (wzaes) { + mz_crypt_aes_delete(&wzaes->aes); + mz_crypt_hmac_delete(&wzaes->hmac); + free(wzaes); + } + *stream = NULL; +} + +void *mz_stream_wzaes_get_interface(void) { + return (void *)&mz_stream_wzaes_vtbl; +}