mirror of
https://github.com/LisherSong/ps5-web-file-manager.git
synced 2026-10-06 07:00:28 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba668ade50 |
No files matched your search
@@ -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
|
||||
# `<msys-root>/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'
|
||||
|
||||
+54
-43
@@ -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 <elf> [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]}")
|
||||
for m in missing:
|
||||
print(f" MISS {m.decode()}")
|
||||
sys.exit(1 if missing else 0)
|
||||
+10
@@ -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/
|
||||
|
||||
+334
@@ -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 `<input type="file">`, 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).**
|
||||
|
||||
+269
-26
@@ -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\<SID>\$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 加入)。
|
||||
@@ -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 $@ $<
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+91
-22
@@ -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 免费软件许可。
|
||||
|
||||
@@ -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.
|
||||
+9
-4
@@ -44,7 +44,7 @@
|
||||
<button id="downloadBtn" class="remote-only" data-i18n="download"></button>
|
||||
<button id="deleteBtn" class="danger" data-i18n="delete"></button>
|
||||
<button id="installPkgBtn" class="install-action" data-i18n="install" hidden></button>
|
||||
<button id="extractBtn" class="extract-action" data-i18n="extractToCurrent" hidden></button>
|
||||
<button id="extractBtn" class="extract-action" data-i18n="extract"></button>
|
||||
<button id="pasteBtn" class="paste-action" hidden>
|
||||
<span id="pasteVerb" data-i18n="paste"></span>
|
||||
<span id="pasteName" class="paste-name"></span><span id="pasteCount" class="paste-count"></span>
|
||||
@@ -54,9 +54,13 @@
|
||||
</div>
|
||||
<div class="tool-right">
|
||||
<button id="refreshBtn" data-i18n="refresh"></button>
|
||||
<div id="uploadGroup" class="split-button remote-only">
|
||||
<button id="uploadBtn" class="split-main" data-i18n="upload"></button>
|
||||
<button id="uploadFolderBtn" class="split-arrow" type="button" aria-label="Upload folder"></button>
|
||||
<div id="uploadGroup" class="upload-menu remote-only">
|
||||
<button id="uploadBtn" class="upload-main" data-i18n="upload"
|
||||
aria-haspopup="menu" aria-expanded="false"></button>
|
||||
<div id="uploadMenu" class="upload-menu-list" role="menu" hidden>
|
||||
<button id="uploadFilesItem" type="button" role="menuitem" data-i18n="uploadFiles"></button>
|
||||
<button id="uploadFolderItem" type="button" role="menuitem" data-i18n="uploadFolder"></button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="newTextBtn" data-i18n="newText"></button>
|
||||
<button id="mkdirBtn" data-i18n="mkdir"></button>
|
||||
@@ -94,6 +98,7 @@
|
||||
|
||||
<footer class="status">
|
||||
<div id="statusText" data-i18n="ready"></div>
|
||||
<div id="dropHint" class="status-hint remote-only" data-i18n="dropUploadHint" hidden></div>
|
||||
<div id="versionText" class="version-text"></div>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
+9
-1
@@ -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}",
|
||||
|
||||
+9
-1
@@ -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}",
|
||||
|
||||
+112
-21
@@ -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 {
|
||||
|
||||
+211
-29
@@ -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);
|
||||
|
||||
@@ -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 <PS5_IP> 9021 < web-file-mgr-v1.9.3M.elf
|
||||
# 看 PS5 左上角通知:应显示 PS5 Web File Manager + v1.9.3M + 监听端口
|
||||
# 浏览器打开 http://<PS5_IP>:8888/
|
||||
```
|
||||
|
||||
### 通用纪律
|
||||
|
||||
1. **每次解压都新建一个空目标目录**。已知遗留问题:含目录条目的包在「覆盖」模式下解到同一
|
||||
目录第二次必失败(三引擎同构,与本次改动无关)—— 别把它记成回归。
|
||||
2. 多卷归档**一次把整个文件夹拖进去**,别只传子卷(UI 对「只选中子卷」会置灰并要求改选首卷)。
|
||||
3. 每项记三样:**通过/失败**、**界面文案原文**、**截图**。
|
||||
4. 任务列表可直接在浏览器看:`http://<PS5_IP>:8888/api/tasks`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 版本号四处一致 + 改版标记(规定项⑤,10 秒)
|
||||
|
||||
| 位置 | 期望 |
|
||||
|---|---|
|
||||
| PS5 启动通知 | `v1.9.3M` |
|
||||
| 页面底部状态栏的版本号(`#versionText`) | `v1.9.3M` |
|
||||
| `http://<PS5_IP>: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://<PS5_IP>:8888/api/tasks` 的返回;
|
||||
3. 截图(含目标目录文件列表)。
|
||||
|
||||
**回滚**(一条命令,产物已核验):
|
||||
|
||||
```sh
|
||||
nc -q0 <PS5_IP> 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 万文件 | ☐ | |
|
||||
+448
-18
@@ -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<N>` 开关虽未见于 `-?` 帮助但可解析):
|
||||
>
|
||||
> | 夹具 | 载荷 | `-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"
|
||||
|
||||
@@ -72,6 +72,11 @@
|
||||
- **中英双语界面** + 项目主页右上角可切换语言说明。
|
||||
|
||||
### 已知缺口(诚实列出)
|
||||
|
||||
> 本节描述的是 **v1.9.2 发布时**的状态。此后有两条已在工作树中补齐、尚未发版:
|
||||
> 加密 ZIP/RAR 与 7z `-mhe=on` 加密头。详见 README「未发布内容」与
|
||||
> `HANDOVER.md` §十一 / §十二。
|
||||
|
||||
- 带密码的 ZIP / RAR / 7z:**拒绝解压**(引擎有解密能力,但密码输入 UI/API 还没接,临时先挡掉)。
|
||||
- 7z `-mhe=on` **加密头**:暂不支持(需要自研头解析器)。这是 7z 侧唯一已知缺口。
|
||||
|
||||
|
||||
+29
-1703
File diff suppressed because it is too large.
Load diff
@@ -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<N>` 开关可解析,虽然不出现在 `-?` 帮助里)
|
||||
在一份**按真实形状**造的夹具上做 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` 未执行 ⇒ 搁置 |
|
||||
+71
-10
@@ -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/<TITLE_ID>`)和 `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<std::string,std::string>` | 写进 `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 需要你回答的问题
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,目录碰撞递归下钻 | 三引擎统一 |
|
||||
| 取消 | ✅ 条目粒度 | — |
|
||||
| 任务恢复 | ❌ **没有** | — |
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<div align="right">
|
||||
简体中文 · 开发者文档见 <a href="../README.zh-CN.md">README</a>
|
||||
</div>
|
||||
|
||||
# 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`)。
|
||||
|
||||
界面截图(点击看大图):
|
||||
|
||||
<p>
|
||||
<a href="screenshots/20260617_231827.376.jpg" target="_blank"><img src="screenshots/20260617_231827.376.jpg" width="31%" alt="界面截图 1"></a>
|
||||
<a href="screenshots/20260619_131432.399.jpg" target="_blank"><img src="screenshots/20260619_131432.399.jpg" width="31%" alt="界面截图 2"></a>
|
||||
<a href="screenshots/20260617_232348.855.jpg" target="_blank"><img src="screenshots/20260617_232348.855.jpg" width="31%" alt="界面截图 3"></a>
|
||||
</p>
|
||||
|
||||
> 小提示:用 **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://<PS5的IP>:<端口>/api/version` | JSON 里的版本号 = `v1.9.3M` |
|
||||
|
||||
**只要界面上显示带 `M` 的版本号,就说明装对了。**
|
||||
|
||||
---
|
||||
|
||||
## 九、几条重要提醒
|
||||
|
||||
1. **解压时别断电、别重启 PS5。** 它为了速度不做逐文件强制落盘,如果在最后搬运阶段断电,可能出现"文件在,但内容不完整"。
|
||||
2. **解压前确认空间够**,而且是**两份**(见第五节)。
|
||||
3. **每次解压用新的空文件夹**,别往同一个目录连解两次(见第四节第 3 条)。
|
||||
4. **删除不可恢复。** 删文件夹会把它里面所有东西都删掉,弹窗会提醒。
|
||||
5. **一次只做一件事**,有任务在跑时其它操作会被拒绝。
|
||||
6. 这是自制程序。如果遇到 PS5 内核崩溃,请换更新的越狱方式 / ELF 加载器,或回到你常用的稳定方案。
|
||||
File diff suppressed because it is too large.
Load diff
+13
-3
@@ -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";
|
||||
|
||||
@@ -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 <errno.h>
|
||||
|
||||
+82
-22
@@ -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)) {
|
||||
|
||||
+6
-3
@@ -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 <stddef.h>
|
||||
|
||||
/* 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);
|
||||
+4
-5
@@ -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
|
||||
|
||||
+49
-3
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,917 @@
|
||||
/* sevenz_header -- see sevenz_header.h for what this does and why. */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#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);
|
||||
}
|
||||
}
|
||||
@@ -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 <stddef.h>
|
||||
|
||||
#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 */
|
||||
+32
-7
@@ -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);
|
||||
{
|
||||
|
||||
+8
-1
@@ -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);
|
||||
@@ -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";
|
||||
|
||||
+11
-4
@@ -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)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* bench_extract <archive> <out-dir> [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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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
|
||||
+72
-1
@@ -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("<I", crc) + size + hd
|
||||
|
||||
main_hdr = block(1, 0x04, vint(0)) # HEAD_MAIN, ArcFlags=0
|
||||
file_hdr = block(2, 0x02, # HEAD_FILE, HFL_DATA
|
||||
vint(0x0004) + # FileFlags: FHFL_CRC32
|
||||
vint(len(data)) + # UnpSize
|
||||
vint(0) + # FileAttr
|
||||
struct.pack("<I", zlib.crc32(data) & 0xFFFFFFFF) +
|
||||
vint(comp_info) +
|
||||
vint(0) + # HostOS: Windows
|
||||
vint(len(name)) + name,
|
||||
data_size=len(data))
|
||||
end_hdr = block(5, 0x00, vint(0)) # HEAD_ENDARC
|
||||
|
||||
with open(path("dict-8g.rar"), "wb") as f:
|
||||
f.write(b"Rar!\x1a\x07\x01\x00" + main_hdr + file_hdr + data + end_hdr)
|
||||
|
||||
|
||||
def main():
|
||||
fresh()
|
||||
for fn in (basic, stored, unicode_names, zip64, traversal,
|
||||
traversal_backslash, absolute, drive_letter, duplicate,
|
||||
file_dir_clash, symlink_entry, fifo_entry, encrypted, bad_crc,
|
||||
truncated, not_a_zip, bomb, medium_bomb, many_files,
|
||||
conflict_source, rar_fixtures):
|
||||
conflict_source, rar_fixtures, bigdict):
|
||||
fn()
|
||||
print("fixtures written to %s" % OUT)
|
||||
return 0
|
||||
|
||||
+13
-10
@@ -24,11 +24,11 @@ CC="${CC:-gcc}"
|
||||
|
||||
# Archives the engine cannot read yet. Each entry needs a reason; when one of
|
||||
# them starts passing the script says so, so the list cannot rot.
|
||||
KNOWN_GAPS="aeshe"
|
||||
# aeshe - the header itself is encrypted (-mhe=on). Reading it means
|
||||
# decrypting a standalone 7z stream *before* any folder is
|
||||
# known, i.e. a header parser of our own; the vendored SDK
|
||||
# refuses with SZ_ERROR_UNSUPPORTED before we are involved.
|
||||
#
|
||||
# Empty since v1.9.3M: `aeshe` (-mhe=on encrypted header) used to live here. An
|
||||
# encrypted header is now decrypted by src/sevenz_header.c before the SDK sees
|
||||
# the folder table, so the last known 7z gap is closed.
|
||||
KNOWN_GAPS=""
|
||||
|
||||
# Must match PASSWORD in tests/make_sevenz_fixtures.py.
|
||||
FIXTURE_PASSWORD="Secret123"
|
||||
@@ -56,6 +56,9 @@ done
|
||||
"$CC" -c -O2 -Wall -Wextra -Werror -D_FILE_OFFSET_BITS=64 -D_LARGEFILE_SOURCE \
|
||||
-I"$SEVENZ_DIR" -I"$ROOT/src" -o "$BUILD/sevenz_volstream.o" \
|
||||
"$ROOT/src/sevenz_volstream.c"
|
||||
"$CC" -c -O2 -Wall -Wextra -Werror -D_FILE_OFFSET_BITS=64 -D_LARGEFILE_SOURCE \
|
||||
-I"$SEVENZ_DIR" -I"$ROOT/src" -o "$BUILD/sevenz_header.o" \
|
||||
"$ROOT/src/sevenz_header.c"
|
||||
"$CC" -c -O2 -Wall -Wextra -Werror -D_FILE_OFFSET_BITS=64 -D_LARGEFILE_SOURCE \
|
||||
-I"$SEVENZ_DIR" -I"$ROOT/src" -o "$BUILD/sevenz_mt.o" "$ROOT/src/sevenz_mt.c"
|
||||
"$CC" -c -O2 -Wall -Wextra -Werror -D_FILE_OFFSET_BITS=64 -D_LARGEFILE_SOURCE \
|
||||
@@ -72,9 +75,9 @@ done
|
||||
-include "$ROOT/tests/posix_compat.h" \
|
||||
-o "$BUILD/sevenz_extract.o" "$ROOT/src/sevenz_extract.c"
|
||||
|
||||
ENGINE_OBJS=("$BUILD/sevenz_chain.o" "$BUILD/sevenz_volstream.o"
|
||||
"$BUILD/sevenz_mt.o" "$BUILD/zipx_volume.o"
|
||||
"$BUILD/zipx_common.o")
|
||||
ENGINE_OBJS=("$BUILD/sevenz_chain.o" "$BUILD/sevenz_header.o"
|
||||
"$BUILD/sevenz_volstream.o" "$BUILD/sevenz_mt.o"
|
||||
"$BUILD/zipx_volume.o" "$BUILD/zipx_common.o")
|
||||
|
||||
FACADE_OBJS=("$BUILD/sevenz_extract.o" "${ENGINE_OBJS[@]}")
|
||||
|
||||
@@ -177,14 +180,14 @@ fi
|
||||
echo
|
||||
echo "== 7z extraction facade (engine: src/sevenz_extract.c) =="
|
||||
mkdir -p "$BUILD/fx"
|
||||
for a in store lzma2 lzma ppmd bcj delta utf8 bcj2 solidoff bcj2off aes; do
|
||||
for a in store lzma2 lzma ppmd bcj delta utf8 bcj2 solidoff bcj2off aes aeshe; do
|
||||
[ -f "$FIXTURES/$a.7z" ] || continue
|
||||
out="$(mktemp -d "$BUILD/fx/XXXXXX")" || continue
|
||||
target="$out/$a"
|
||||
mkdir -p "$target"
|
||||
rc=0
|
||||
case "$a" in
|
||||
aes) "$BUILD/test_sevenz_extract" "$FIXTURES/$a.7z" "$target" \
|
||||
aes|aeshe) "$BUILD/test_sevenz_extract" "$FIXTURES/$a.7z" "$target" \
|
||||
"$FIXTURE_PASSWORD" >"$out.log" 2>&1 || rc=$? ;;
|
||||
*) "$BUILD/test_sevenz_extract" "$FIXTURES/$a.7z" "$target" \
|
||||
>"$out.log" 2>&1 || rc=$? ;;
|
||||
|
||||
+8
-5
@@ -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"
|
||||
@@ -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 <archive.7z> <out-dir> [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,
|
||||
|
||||
+110
-37
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+129
-5
@@ -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 <fixtures-dir> <work-dir>\n", argv[0]);
|
||||
fprintf(stderr, "usage: %s <fixtures-dir> <work-dir> [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();
|
||||
|
||||
@@ -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
|
||||
+46
@@ -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
|
||||
+825
@@ -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 <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/**************************************************************************
|
||||
* 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;
|
||||
}
|
||||
|
||||
/***************************************************************************/
|
||||
+324
@@ -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;
|
||||
}
|
||||
+355
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user