mirror of
https://github.com/LisherSong/ps5-web-file-manager.git
synced 2026-10-06 14:00:24 +02:00
First release under the fork-marker convention: VERSION_TAG carries a trailing
`M`, so /api/version, the PS5 start-up notification, the stdout banner, the UI
footer and the ELF file name all read "v1.9.3M" in one move -- and a fork build
can no longer collide with an upstream artifact of the same version, a mix-up
that already happened twice. The footer carries a tooltip spelling the marker
out.
Two bodies of work.
1. Encrypted archives (ZIP / RAR / 7z)
- ZIP: ZipCrypto (traditional PKWARE) and WinZip AES-256, through
minizip-ng plus a vendored crypto layer (mz_crypt_wfm.c,
mz_strm_pkcrypt.c, mz_strm_wzaes.c).
- RAR: RARSetPassword, wired after RAROpenArchiveEx and before the first
RARReadHeaderEx. The ordering is load-bearing, not stylistic.
- 7z: 7zAES including -mhe=on encrypted headers, via a virtual
ISeekInStream that splices a pseudo-header + the real archive + the
decrypted header, so no offset stored inside the archive has to move.
A wrong password is reported as ZIPX_ERR_PASSWORD, and a failed attempt
leaves no staging directory behind.
2. Reporting, and the UI round that on-device testing produced
- A RAR whose dictionary exceeds what the build supports now gets its own
extract_dict_too_large code instead of being mis-reported as "entry too
large"; the message names both the required and the supported size. The
behaviour is deliberately unchanged -- such archives are still refused,
because admitting one means allocating the whole window up front, which
is why rarlab's own CLI refuses them by default.
- The upload entry is a menu again: one "Upload" button opening "Upload
files / Upload folder". The previous main-button-plus-small-arrow made
"upload folder" effectively undiscoverable.
- A drag-and-drop hint sits in the footer (hidden on the console browser,
where drag is not how anyone uploads).
- The extract button is now always present and merely disabled until
exactly one archive is selected, instead of appearing out of nowhere.
- Upload-and-extract on an encrypted archive now prompts for the password
directly. The retry table used to be keyed by PATH, and for a non-ASCII
directory the string the page holds and the string the server reports
are not the same bytes -- the lookup missed, so the user got a bare
error box and had to press Extract by hand before the prompt appeared.
It is now keyed by task id, which the server assigns and echoes back
verbatim.
- Error text passes through decodeFsText(), so a GBK entry name no longer
surfaces as `â®…ç§.psd`.
- Local names are encoded with encodeFsText() before being joined onto a
server-side path. fs_path_value() declines to rewrite a path if ANY code
point exceeds 0xFF, so concatenating a local name onto a server directory
produced a mixed representation and a silently dead path.
- The menu row highlight was losing the cascade to the generic button rule
(identical specificity, later in the file) while inheriting the toolbar's
3px focus ring, which overflowed a 46px row. Both rules are now scoped to
the panel and the keyboard cue is an inset ring, so it cannot escape the
row at any line height.
- The footer status line is clamped to a single line; a long
"uploading 3/12: some-name.zip" used to wrap out of the 46px footer.
- A first failed password attempt now says the archive is encrypted,
instead of blaming a password the user was never asked for.
Artifact
web-file-mgr-v1.9.3M.elf
903,448 B
sha256 8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7
e_machine 0x003e (x86-64 / PS5)
The file size is identical to the four builds before it, and every one of
them carries a different sha256: only .rodata moved, and by less than the
16 KiB section alignment absorbs. Compare sections with `readelf -SW` --
never infer "nothing changed" from the byte count.
Verification
- host suites: 140 ZIP + 37 RAR = 177 checks, 0 failures
- 7z suite: 27 cases, 0 failures. The `aeshe` entry that used to sit in
KNOWN_GAPS is gone -- the -mhe=on fixture now passes both the folder
decoder and the extraction facade
- frontend: .build/ui_retry_test.mjs (40 checks), .build/ui_upload_menu_test.mjs
(40 checks), .build/preview_check.mjs (12 assertions in headless Chromium
against the real page and a fixture API). Two layout regressions and the
highlight cascade bug were caught by the last one and by nothing else --
reading the source, both CSS rules "look correct"
- built twice from this tree: byte-identical (cmp clean). rsync refreshes
every asset mtime, so this is a genuine recompile, not make short-circuiting
on unchanged sources
- embedded assets verified in place with .build/check-elf-gzip.py, because
gen-asset-module.py gzips them and plain `strings` finds none of their text
- exercised end to end on a real PS5; the checklist is
docs/DEVICE-TEST-v1.9.3M.md
Docs
- docs/USER-GUIDE-zh-CN.md (new, simplified Chinese user guide)
- docs/DEVICE-TEST-v1.9.3M.md (new, on-device acceptance checklist)
- docs/REAL-CONSOLE-PROFILE.md (new, measured console behaviour)
- docs/archive/HANDOVER-v1.8-planning.md (superseded v1.8 design notes)
- CHANGELOG / README (both languages) / HANDOVER updated with the artifact
fingerprint, the section deltas and the new test counts
370 lines
13 KiB
Python
370 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate the ZIP fixtures used by test_zip_extract."""
|
|
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import struct
|
|
import sys
|
|
import time as _time
|
|
import zipfile
|
|
import zlib
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
OUT = os.path.join(HERE, "fixtures")
|
|
|
|
# Force every ZIP entry's date_time to a fixed value (1980-01-01 00:00:00)
|
|
# so generated archives are byte-stable across runs. Without this fix,
|
|
# Python 3.13's zipfile.writestr() passes time.localtime(time.time())[:6]
|
|
# into ZipInfo(...) when the caller supplies a string arcname, which makes
|
|
# every fixture differ each run and the git diff stat balloons on every
|
|
# "regenerate fixtures" pass. We swap the zipfile module's `time` symbol
|
|
# for a fake that always returns the same struct_time; the fake also stubs
|
|
# `time.time` since zipfile.writestr chains localtime(time.time()).
|
|
_FIXED_DT = (1980, 1, 1, 0, 0, 0)
|
|
zipfile.time = type("_FakeTimeMod", (), {
|
|
"localtime": staticmethod(lambda *_a, **_k: _time.struct_time(_FIXED_DT + (0, 1, 0))),
|
|
"time": staticmethod(lambda *_a, **_k: 0.0),
|
|
})()
|
|
|
|
|
|
def fresh():
|
|
if os.path.isdir(OUT):
|
|
shutil.rmtree(OUT)
|
|
os.makedirs(OUT)
|
|
|
|
|
|
def path(name):
|
|
return os.path.join(OUT, name)
|
|
|
|
|
|
def basic():
|
|
with zipfile.ZipFile(path("basic.zip"), "w") as zf:
|
|
zf.writestr("root.txt", "root content")
|
|
zf.writestr("dir/nested.txt", "nested content")
|
|
zf.writestr("dir/deep/deeper.txt", "deeper content")
|
|
zi = zipfile.ZipInfo("empty_dir/")
|
|
zi.external_attr = (stat.S_IFDIR | 0o755) << 16
|
|
zi.create_system = 3
|
|
zf.writestr(zi, b"")
|
|
|
|
|
|
def stored():
|
|
with zipfile.ZipFile(path("stored.zip"), "w", zipfile.ZIP_STORED) as zf:
|
|
zf.writestr("stored.txt", "stored content" * 100)
|
|
|
|
|
|
def unicode_names():
|
|
with zipfile.ZipFile(path("unicode.zip"), "w") as zf:
|
|
zf.writestr("中文目录/文件.txt", "unicode content")
|
|
zf.writestr("emoji-\U0001f600.txt", "emoji content")
|
|
|
|
|
|
def zip64():
|
|
"""A genuine ZIP64 archive. Python's zipfile only emits zip64 fields when
|
|
sizes exceed 4 GiB (impractical for a fixture), so the layout is written by
|
|
hand: 32-bit size fields are set to 0xFFFFFFFF and the real values live in
|
|
the zip64 extra fields and the zip64 end-of-central-directory record."""
|
|
name = "big.bin"
|
|
data = bytes(range(256)) * 16 # 4096 bytes, deterministic
|
|
name_b = name.encode("utf-8")
|
|
crc = zlib.crc32(data) & 0xFFFFFFFF
|
|
size = len(data)
|
|
|
|
def extra_local():
|
|
return struct.pack("<HHQQ", 0x0001, 16, size, size)
|
|
|
|
def extra_central(offset):
|
|
return struct.pack("<HHQQQ", 0x0001, 24, size, size, offset)
|
|
|
|
out = bytearray()
|
|
|
|
# Local file header (stored, sizes deferred to zip64 extra field).
|
|
local_offset = 0
|
|
el = extra_local()
|
|
out += struct.pack("<IHHHHHIIIHH", 0x04034B50, 45, 0, 0, 0, 0, crc,
|
|
0xFFFFFFFF, 0xFFFFFFFF, len(name_b), len(el))
|
|
out += name_b + el + data
|
|
|
|
# Central directory header.
|
|
cd_offset = len(out)
|
|
ec = extra_central(local_offset)
|
|
out += struct.pack("<IHHHHHHIIIHHHHHII", 0x02014B50, 45, 45, 0, 0, 0, 0,
|
|
crc, 0xFFFFFFFF, 0xFFFFFFFF, len(name_b), len(ec), 0,
|
|
0, 0, 0, 0xFFFFFFFF)
|
|
out += name_b + ec
|
|
cd_size = len(out) - cd_offset
|
|
|
|
# ZIP64 end of central directory record.
|
|
zip64_eocd_offset = len(out)
|
|
out += struct.pack("<IQHHIIQQQQ", 0x06064B50, 44, 45, 45, 0, 0, 1, 1,
|
|
cd_size, cd_offset)
|
|
|
|
# ZIP64 end of central directory locator.
|
|
out += struct.pack("<IIQI", 0x07064B50, 0, zip64_eocd_offset, 1)
|
|
|
|
# End of central directory record (offsets deferred to zip64).
|
|
out += struct.pack("<IHHHHIIH", 0x06054B50, 0, 0, 1, 1, 0xFFFFFFFF,
|
|
0xFFFFFFFF, 0)
|
|
|
|
with open(path("zip64.zip"), "wb") as fh:
|
|
fh.write(bytes(out))
|
|
|
|
|
|
def traversal():
|
|
with zipfile.ZipFile(path("traversal.zip"), "w") as zf:
|
|
zf.writestr("ok.txt", "ok")
|
|
zf.writestr("../evil.txt", "evil")
|
|
|
|
|
|
def traversal_backslash():
|
|
with zipfile.ZipFile(path("traversal_bs.zip"), "w") as zf:
|
|
zf.writestr("ok.txt", "ok")
|
|
zf.writestr("..\\evil.txt", "evil")
|
|
|
|
|
|
def absolute():
|
|
with zipfile.ZipFile(path("absolute.zip"), "w") as zf:
|
|
zf.writestr("/tmp/evil.txt", "evil")
|
|
|
|
|
|
def drive_letter():
|
|
with zipfile.ZipFile(path("drive.zip"), "w") as zf:
|
|
zf.writestr("C:/evil.txt", "evil")
|
|
|
|
|
|
def duplicate():
|
|
with zipfile.ZipFile(path("duplicate.zip"), "w") as zf:
|
|
zf.writestr("a.txt", "first")
|
|
zf.writestr("a.txt", "second")
|
|
|
|
|
|
def file_dir_clash():
|
|
with zipfile.ZipFile(path("clash.zip"), "w") as zf:
|
|
zf.writestr("a", "file")
|
|
zf.writestr("a/b.txt", "child")
|
|
|
|
|
|
def symlink_entry():
|
|
with zipfile.ZipFile(path("symlink.zip"), "w") as zf:
|
|
zf.writestr("ok.txt", "ok")
|
|
zi = zipfile.ZipInfo("link")
|
|
zi.create_system = 3
|
|
zi.external_attr = (stat.S_IFLNK | 0o777) << 16
|
|
zf.writestr(zi, "/etc/passwd")
|
|
|
|
|
|
def fifo_entry():
|
|
with zipfile.ZipFile(path("fifo.zip"), "w") as zf:
|
|
zi = zipfile.ZipInfo("pipe")
|
|
zi.create_system = 3
|
|
zi.external_attr = (stat.S_IFIFO | 0o644) << 16
|
|
zf.writestr(zi, b"")
|
|
|
|
|
|
def encrypted():
|
|
with zipfile.ZipFile(path("encrypted.zip"), "w") as zf:
|
|
zf.writestr("secret.txt", "secret")
|
|
data = bytearray(open(path("encrypted.zip"), "rb").read())
|
|
idx = data.find(b"PK\x03\x04")
|
|
if idx < 0:
|
|
raise SystemExit("local header not found")
|
|
data[idx + 6] |= 0x01 # general purpose bit 0 = encrypted
|
|
cd = data.find(b"PK\x01\x02")
|
|
data[cd + 8] |= 0x01
|
|
open(path("encrypted.zip"), "wb").write(bytes(data))
|
|
|
|
|
|
def bad_crc():
|
|
with zipfile.ZipFile(path("bad_crc.zip"), "w") as zf:
|
|
zf.writestr("data.bin", "x" * 4096)
|
|
data = bytearray(open(path("bad_crc.zip"), "rb").read())
|
|
cd = data.find(b"PK\x01\x02")
|
|
if cd < 0:
|
|
raise SystemExit("central directory not found")
|
|
data[cd + 16:cd + 20] = b"\xde\xad\xbe\xef"
|
|
open(path("bad_crc.zip"), "wb").write(bytes(data))
|
|
|
|
|
|
def truncated():
|
|
with zipfile.ZipFile(path("full.zip"), "w") as zf:
|
|
zf.writestr("data.txt", "y" * 4096)
|
|
data = open(path("full.zip"), "rb").read()
|
|
open(path("truncated.zip"), "wb").write(data[:-40])
|
|
|
|
|
|
def not_a_zip():
|
|
open(path("notazip.zip"), "wb").write(b"this is definitely not a zip file")
|
|
|
|
|
|
def bomb():
|
|
with zipfile.ZipFile(path("bomb.zip"), "w", zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr("bomb.bin", "A" * (4 * 1024 * 1024))
|
|
|
|
|
|
def medium_bomb():
|
|
"""1 MiB of 0..255 cycled, which deflate squeezes to ~4.4 KiB
|
|
(ratio ~238). Sits between the default cap (200) and the large cap
|
|
(1000) so the default profile rejects it and the large profile
|
|
accepts it. Used by the large-profile host test."""
|
|
with zipfile.ZipFile(path("medium_bomb.zip"), "w",
|
|
zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr("medium.bin", bytes(range(256)) * 4096)
|
|
|
|
|
|
def many_files():
|
|
with zipfile.ZipFile(path("many.zip"), "w", zipfile.ZIP_DEFLATED) as zf:
|
|
for i in range(500):
|
|
zf.writestr("many/f%03d.txt" % i, "%d" % i)
|
|
|
|
|
|
def conflict_source():
|
|
"""A zip whose top level collides with an existing destination layout."""
|
|
with zipfile.ZipFile(path("conflict.zip"), "w") as zf:
|
|
zf.writestr("shared.txt", "from zip")
|
|
zf.writestr("shareddir/inner.txt", "inner from zip")
|
|
zf.writestr("shareddir/added.txt", "added from zip")
|
|
|
|
|
|
def rar_fixtures():
|
|
"""Generate RAR fixtures if a rar/7z writer is available.
|
|
|
|
We intentionally do not depend on a rar binary being installed in the
|
|
host test environment; when neither `rar` nor `7z` is present we leave
|
|
1-byte placeholders so that tests/test_rar_extract.c can still hit its
|
|
"not a real archive" branches. See docs/HANDOVER.md for the manual
|
|
fixture procedure used in CI on a developer workstation that has WinRAR.
|
|
"""
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
|
|
candidates = []
|
|
for cmd in ("rar", "7z", "7za"):
|
|
if shutil.which(cmd):
|
|
candidates.append(cmd)
|
|
|
|
staging_dir = tempfile.mkdtemp(prefix="wfm-rar-fixtures-")
|
|
try:
|
|
# Build a small directory we can compress into a RAR.
|
|
staging_root = os.path.join(staging_dir, "stage")
|
|
os.makedirs(staging_root)
|
|
with open(os.path.join(staging_root, "root.txt"), "wb") as f:
|
|
f.write(b"rar root content\n")
|
|
nested = os.path.join(staging_root, "dir")
|
|
os.makedirs(nested)
|
|
with open(os.path.join(nested, "nested.txt"), "wb") as f:
|
|
f.write(b"rar nested content\n")
|
|
|
|
out = path("basic.rar")
|
|
ok = False
|
|
for cmd in candidates:
|
|
args = [cmd, "a", "-r", "-ep1", out,
|
|
os.path.join(staging_root, "root.txt"),
|
|
os.path.join(staging_root, "dir")]
|
|
# 7z uses -t7z / -rr differently; for the purposes of a smoke
|
|
# fixture we only need any small valid RAR.
|
|
try:
|
|
rc = subprocess.call(args, stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL)
|
|
if rc == 0 and os.path.exists(out) and os.path.getsize(out) > 16:
|
|
print("rar_fixtures: built %s via %s" % (out, cmd))
|
|
ok = True
|
|
break
|
|
except Exception:
|
|
pass
|
|
if not ok:
|
|
# Placeholder: the test suite only needs a file that dmc_unrar
|
|
# will reject. 8 bytes is far too small to be a valid archive.
|
|
with open(out, "wb") as f:
|
|
f.write(b"placeholder")
|
|
finally:
|
|
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, bigdict):
|
|
fn()
|
|
print("fixtures written to %s" % OUT)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|