Per user decision on 2026-09-04 21:01 GMT+8, the v1.8 plan no longer
covers the legacy split-archive format (name.rar + .r00/.r01/.r02...).
Rationale: PS5 users almost exclusively ship the modern RAR5
part-volume format (name.part01.rar, name.part02.rar, ...); the
legacy .rNN format is from the 2005-2010 era. Removing it cuts:
- Frontend: isRarSubVolume() no longer needs /\.r\d{2,}$/ branch
- Fixtures: drop rar4_multi_old.rar generation
- Tests: drop test_rar4_multi_volume_old()
- Backend: unrar API surface still supports legacy .rNN, but we just
never feed it one
Documentation sweep:
- Section 5.1: range updated
- Section 6.4.1: isExtractableArchive/isRarSubVolume simplified
- Section 6.5.1: fixture generation script updated
- Section 6.5.3: test_rar4_multi_volume_old removed
- Section 8.4: decision rationale no longer mentions .rNN
- Section 6.6.4: CHANGELOG / README example updated
- Section 11.2: test matrix updated
- Section 6.4.9: D4 manual test list no longer tests .r00
51 KiB
PS5 Web File Manager — 项目工作方案
目的:让任何接手人拿到这份文档 + 项目仓库,都能独立推进 v1.8(RAR 支持)开发。 读者:开发者,需要熟悉 C99 + POSIX + 浏览器 JS,但不要求懂 PS5 SDK。 撰写日期:2026-09-04 对应代码版本:
aef4a44(v1.7 + 文档配套提交)
0. 阅读顺序建议
如果你时间紧:
- 先看 §1「项目是什么」和 §4「当前进度」 → 5 分钟建立全局观
- 跳到 §6「v1.8 详细计划」按天执行
- 卡住了回 §7「关键代码模板」找参照实现
- 出 bug 翻 §9「坑清单」
如果你要 review 整套设计:
- §1 → §3 → §6 → §7 → §8 顺序读完
1. 项目是什么
PS5 Web File Manager 是个跑在越狱 PS5 上的 HTTP 文件管理 payload。
- 用户从 PS5 浏览器打开
http://PS5-IP:8080→ 看到 PS5 文件系统 - 可以浏览 / 上传 / 下载 / 编辑文本 / 解压 ZIP / 安装 PKG
- 单文件 ELF(
web-file-mgr.elf),无外部服务依赖
关键事实(会影响你所有设计决策,先背下来):
- CPU:AMD x86-64 Zen 2(不是 ARM),目标 triple
x86_64-sie-ps5 - SDK:vendored 在
/opt/ps5-payload-sdk/,未提供 homebrew 库目录(你不能pkg-config --libs libxxx,只能 vendor C 源码自己编) - 体积红线:单 ELF 通常不超 1 MiB,当前 v1.7 = 418 KiB
- 网络环境:通常在 LAN 内,HTTPS 不强制(自签证书)
代码托管:https://github.com/owendswang/ps5-web-file-manager.git
License:GPLv3+(注意:unrar 的 license 是「UnRAR license」,不能改装,只能直接用 —— 见 §9.1)
2. 仓库与构建环境
2.1 本地路径
Windows: C:\Users\songl\Desktop\Web File Manager\ps5-web-file-manager\
WSL: \\wsl$\Ubuntu-22.04\home\song\ps5-web-file-manager\ (同一份文件)
PS5: /mnt/usb0/... (部署目标)
2.2 关键工具位置
| 工具 | 位置 | 用途 |
|---|---|---|
| WSL bash | wsl -d Ubuntu-22.04 |
唯一能跑 PS5 交叉编译的地方 |
| 编辑器 | 本机任意 | VS Code 推荐,记得保存用 LF(.gitattributes 不强制) |
| prospero-clang | /opt/ps5-payload-sdk/bin/prospero-clang |
PS5 交叉编译 wrapper,自动注入 -target x86_64-sie-ps5 |
| prospero-strip | /opt/ps5-payload-sdk/bin/prospero-strip |
strip 工具 |
| 构建脚本 | /home/song/build-elf.sh (WSL) + .build/build-elf.sh (副本) |
完整构建 + 落双路径 |
2.3 双身份坑(必读)
⚠️ WSL 里有两种身份:真 bash 用户 vs SMB 视图用户
- 真 bash 用户:
song(1000),写/opt/ps5-payload-sdk/...需要 sudo- SMB 视图用户:
songl(197609),但不能写/opt/...(即使 sudo)
SDK 的 target/user/homebrew/ 被 songl(197609) 拥有 755,普通 song 写不进。
正确做法(构建脚本 v5 已实现):staging 模式 → 先 make install 到 /tmp/ → sudo cp -r 到 SDK。
不要尝试 sudo chmod SDK 目录,会破坏其他项目。
2.4 我的 Bash 工具是 MinGW64,不是 WSL
uname -a=MINGW64_NT-10.0- 看 WSL 真实状态用
\\wsl$\Ubuntu-22.04\路径前缀 - 但修改 WSL 文件只能用 PowerShell 走
C:\Users\songl\Desktop\...路径,不能用wsl.exe(沙箱黑名单)
3. 已完成功能(v1.7 = 5cb0b76 + aef4a44)
3.1 ZIP 解压 + 大文件模式
用户场景:解压一个 200GB 系统镜像到 PS5。默认 limits 不够 → 加 🅱 "大文件模式"开关。
完整链路(每层都改了,记得按层理解):
┌────────────────────────────────────────────────────────────────┐
│ Frontend: assets/main.js │
│ - LARGE_FILE_THRESHOLD_BYTES = 60 GiB (硬编码) │
│ - shouldPromptLargeMode(itemSize) → 弹 confirm │
│ - startExtractTask(path, dst, conflict, remove, name, large) │
└────────────────────┬───────────────────────────────────────────┘
│ POST /api/extract
│ form fields: {path, dst_dir, conflict,
│ remove_source, large}
┌────────────────────▼───────────────────────────────────────────┐
│ Task layer: src/extract.c │
│ - api_extract 解析 large 字段 → task->extract_large │
│ - extract_worker 用 zipx_limits_profile(task->extract_large) │
│ - file_task_t 新增字段 extract_large (int) │
└────────────────────┬───────────────────────────────────────────┘
│
┌────────────────────▼───────────────────────────────────────────┐
│ Engine: src/zip_extract.{h,c} │
│ - k_default_limits: 200K / 512GiB / 64GiB / 200:1 │
│ - k_large_limits: 500K / 2TiB / 1TiB / 1000:1 │
│ - ZIPX_LIMITS_DEFAULT=0 / ZIPX_LIMITS_LARGE=1 │
│ - zipx_limits_profile(int) → const zipx_limits_t* │
│ - zipx_extract() 签名不变,向后兼容 │
└────────────────────────────────────────────────────────────────┘
三阶段流程(所有 archive 引擎共享的设计):
- scan —— 读所有 entry header,只校验 name 安全 + 累加 bytes 估算
- extract —— 写到 staging
.wfm-part-{pid}-{taskid}/,fsync 每个文件 - publish —— 整 staging rename 到目标,处理 conflict 策略
- cleanup —— 任何阶段失败都 unlink staging
i18n:
assets/lang-en.js和lang-zh.js新增extractLargeAsk/extractLargeActive
前端 UX:
- ZIP 大于 60 GiB 时弹窗「启用大文件模式?」
- 用户点 OK → 传
large=1→ 引擎走 large profile - 用户点取消 → 走 default profile(多半会被拒绝)
3.2 文档与仓库
| 文件 | 行数 | 说明 |
|---|---|---|
README.md |
267 | GitHub-grade 项目说明:版本标签、Features 分类、ZIP extraction 段含 limits 表、Verification、Tests、Project layout、FAQ |
CHANGELOG.md |
107 | Keep-a-Changelog 格式,v1.7 段 |
docs/UPGRADE-v1.7-zip-large-file-profile.md |
448 | 维护者技术手册,含架构图、源码引用、覆盖矩阵、Trade-offs、Roadmap |
.build/extract-demo.html |
- | 交互式解压演示页(场景 3 表格已包含 large profile 行) |
.build/check-elf-gzip.py |
- | ELF 内 gzip 资产串验证脚本(确认前端改动进了 ELF) |
3.3 测试覆盖
tests/run-tests.sh → 69 checks, 0 failures
覆盖范围:
- ZIP 基础:basic / stored / unicode names / zip64 / traversal / backslash
- 安全:traversal / absolute path / drive letter / symlink entry / fifo entry
- 格式错误:encrypted / bad_crc / truncated / not_a_zip
- 资源限制:ratio cap / file cap / total cap
- 边界:duplicate / file_dir_clash / conflict_source
- 大文件:large profile (16 个 check) — 含 default 拒 medium_bomb / large 接 / 降低 large 阈值仍生效
fixture 经验(写测试时会用到):
- 4 MiB of 'A' 实际 ratio ≈ 1026(不是想象中的 ≈1000),所以
bomb.zip连 large profile 都拒 bytes(range(256)) * 4096(1 MiB) → ratio ≈ 238,正好夹在 (200, 1000] 中间 → stable fixture- 1 MiB "ABCD" * 256K → ratio ≈ 1004(擦边不稳);random 3-bit → ratio ≈ 2(太低)
3.4 Git 状态
5cb0b76 Initial import of PS5 Web File Manager v1.7
aef4a44 Add v1.7 changelog and technical upgrade notes
main branch → origin https://github.com/owendswang/ps5-web-file-manager.git
⚠️ sandbox 推 github.com 被代理拦截(502 from CONNECT tunnel)
→ 用户在自己 PowerShell 跑 `git push -u origin main`
→ 详见 MEMORY.md「GitHub 推送的网络环境」
4. 当前进度状态(截至 2026-09-04 20:50)
| 项 | 状态 | 备注 |
|---|---|---|
| v1.7 ZIP 大文件模式 | ✅ 完成 + 文档 + 已部署 | 53→69 checks pass |
| v1.7 仓库初始化 | ✅ 本地 2 commit | 待 push(用户侧) |
| v1.7 文档(README/CHANGELOG/UPGRADE) | ✅ 完成 | |
| v1.8 RAR 支持 | ⏳ 方案已确认,待开工 | 用户已确认需求范围(详见 §6) |
| v1.8 ELF 重编 | ⏸ 未启动 | |
| v1.8 文档 | ⏸ 未启动 |
v1.8 需求范围(用户已确认):
- ✅ RAR 单卷(明文)
- ✅ RAR 分卷(仅 RAR5
name.partNN.rar新格式) - ✅ 加密 RAR(密码弹窗)
- ❌ ZIP 分卷(用户说"不需要")
- ❌ 7z / tar / 其他格式
5. v1.8 范围与目标
5.1 功能列表
- /api/extract 支持 RAR 格式
- 自动派发:扩展名 + magic (Rar!\x1a\x07\x00 / Rar!\x1a\x07\x01\x00)
- RAR 单卷解压
- RAR 分卷自动识别(unrar 内部处理,前端只识别主卷)
- 加密 RAR:探测 → 弹密码窗 → 错误重试
- 前端:选中 RAR 主卷显示解压按钮,子卷置灰 + tooltip
5.2 不做(明确划线)
| 不做 | 原因 |
|---|---|
| ZIP 分卷 | 用户明确不要 |
| 7z / tar / 其他格式 | 范围外 |
| RAR 创建(压缩) | 当前只解压 |
| 密码保存/记忆 | 安全 + UX 一致性 |
| ZIP 加密(ZIP AES) | 单独迭代 |
| 区分「密码错」vs「文件损坏」 | 防侧信道,统一文案 |
5.3 工作量预估
| 模块 | 行数 |
|---|---|
third_party/unrar/ vendor |
~7K |
src/rar_extract.{h,c} |
~450 |
src/extract.c 改造 |
~150 |
src/filemgr_internal.h |
~10 |
assets/main.js |
~120 |
assets/main.css |
~40 |
assets/lang-{en,zh}.js |
~30 |
tests/test_rar_extract.c |
~400 |
tests/fixtures/ |
~12 个 |
| 文档 + CHANGELOG | ~200 |
| 总计 | ~1380 新增 |
ELF 预估:v1.7 = 418 KiB → v1.8 ≈ 580 KiB 工期:5~6 天集中开发
6. v1.8 详细实施计划
按天执行。每步都有「验证」一项,写完立即跑。
D1:vendor unrar + 写 rar_extract 骨架
6.1.1 拉 unrar 源码
cd /home/song/ps5-web-file-manager/third_party/
git clone https://github.com/alexbatalov/unrar.git
# 检查
ls unrar/
# 期望:unrar.c + unrar.h + README + LICENSE(UnRAR license)
注意事项:
- alexbatalov 版是纯 C、单文件,不要用 winrar/unrar 官方版(license 严格,商用改装禁)
- 不要改装 unrar.c(license 限制),需要时通过 wrapper 函数扩展
- 如需 ASAN / fuzzer 友好的版本,可考虑分叉,但要保留 license 文件
6.1.2 验证 unrar 在 host 上能编
cd /home/song/ps5-web-file-manager/third_party/unrar/
cc -O2 -o test-unrar unrar.c -DUNRAR_TEST_MAIN # 临时 main 跑一遍
# 或者写个测试:建 test.rar → 调用 unrar API 解出来
确认 API 形态:
RARHeaderDataEx/RAROpenArchiveEx/RARReadHeaderEx/RARProcessFile/RARSetPassword/RARCloseArchive
6.1.3 写 rar_extract.h 公共 API
模板:直接抄 src/zip_extract.h 的命名空间 zipx_ 改为 rarx_,但复用 zipx_status_t 枚举。
/* src/rar_extract.h */
#pragma once
#include "zip_extract.h" /* 复用 zipx_limits_t / zipx_status_t */
typedef struct {
char password[128]; /* UTF-8 / ASCII 密码 */
int password_set; /* 0 = 不设, 1 = 调用方已设置 */
} rarx_password_t;
typedef struct {
zipx_status_t status;
int sys_errno;
uint64_t entries_total;
uint64_t entries_done;
uint64_t bytes_total;
uint64_t bytes_done;
uint64_t files_created;
uint64_t dirs_created;
int has_password; /* scan 阶段探测到加密 → ERR_PASSWORD */
char detail[ZIPX_PATH_MAX];
char message[192];
} rarx_result_t;
/* password 可为 NULL(明文 RAR);结果总是写入 *result */
zipx_status_t rarx_extract(const char *first_volume_path,
const char *dst_dir,
zipx_conflict_t conflict,
const zipx_limits_t *limits,
const rarx_password_t *password, /* 可 NULL */
zipx_cancel_fn cancel,
zipx_progress_fn progress,
void *userdata,
rarx_result_t *result);
6.1.4 rar_extract.c 三阶段骨架
关键:scan 与 extract 都要扫描所有 entry(unrar 的 API 不能 list-only),所以 scan 阶段会读到文件内容但丢弃,产生约 N×entry 大小的 I/O 成本。
💡 性能优化:unrar 提供
RARExtractChunkAPI,可以一次解 N 字节立刻写出,避免 staging 暂存。第一版不用,可读性 + 测试性更重要。
6.1.5 关键代码模式(scan 阶段)
static zipx_status_t
rarx_scan(const char *first_path, const zipx_limits_t *limits,
rarx_result_t *result) {
struct RARHeaderDataEx hdr;
struct RAROpenArchiveDataEx arc;
memset(&arc, 0, sizeof(arc));
arc.ArcName = (char*)first_path;
arc.OpenMode = RAR_OM_LIST; /* list-only 模式不解压 */
HANDLE h = RAROpenArchiveEx(&arc);
if (arc.OpenResult != 0) {
result->status = ZIPX_ERR_FORMAT;
return ZIPX_ERR_FORMAT;
}
uint64_t total_bytes = 0;
uint32_t total_entries = 0;
int max_depth = 0;
result->has_password = 0;
while (RARReadHeaderEx(h, &hdr) == 0) {
/* 加密探测 */
if (hdr.Flags & (LHD_PASSWORD /* RAR4 */) ||
(hdr.Flags & 0x0004 /* RAR5 encrypted */)) {
result->has_password = 1;
}
/* name 安全校验(用 zip_extract.c 里的同名函数) */
if (!is_safe_archive_path(hdr.FileName)) {
RARCloseArchive(h);
result->status = ZIPX_ERR_UNSAFE_NAME;
snprintf(result->detail, sizeof(result->detail), "%s", hdr.FileName);
return ZIPX_ERR_UNSAFE_NAME;
}
/* depth 校验 */
uint32_t d = path_depth(hdr.FileName);
if (d > max_depth) max_depth = d;
if (max_depth > limits->max_depth) {
RARCloseArchive(h);
result->status = ZIPX_ERR_LIMIT_DEPTH;
return ZIPX_ERR_LIMIT_DEPTH;
}
/* name/path 长度 */
if (strlen(hdr.FileName) > limits->max_name_len ||
strlen(hdr.FileName) > limits->max_path_len) {
RARCloseArchive(h);
result->status = ZIPX_ERR_LIMIT_NAME;
return ZIPX_ERR_LIMIT_NAME;
}
/* size 累计 */
total_bytes += (uint64_t)hdr.UnpSize;
if (hdr.UnpSize > limits->max_file_bytes) {
RARCloseArchive(h);
result->status = ZIPX_ERR_LIMIT_FILE;
snprintf(result->detail, sizeof(result->detail),
"%s (%llu bytes)", hdr.FileName,
(unsigned long long)hdr.UnpSize);
return ZIPX_ERR_LIMIT_FILE;
}
if (total_bytes > limits->max_total_bytes) {
RARCloseArchive(h);
result->status = ZIPX_ERR_LIMIT_TOTAL;
return ZIPX_ERR_LIMIT_TOTAL;
}
total_entries++;
if (total_entries > limits->max_entries) {
RARCloseArchive(h);
result->status = ZIPX_ERR_LIMIT_ENTRIES;
return ZIPX_ERR_LIMIT_ENTRIES;
}
/* 跳过 entry body(list-only 不需要调用 ProcessFile) */
}
RARCloseArchive(h);
result->entries_total = total_entries;
result->bytes_total = total_bytes;
return ZIPX_OK;
}
6.1.6 D1 验证清单
# 1. 编译通过(host)
gcc -O2 -c third_party/unrar/unrar.c -o /tmp/unrar.o
gcc -O2 -c src/rar_extract.c -o /tmp/rar_extract.o -I third_party/unrar/
echo "exit: $?"
# 2. 链接能产出 host binary(基础 sanity)
gcc -O2 -o /tmp/test-rar-extract /tmp/rar_extract.o /tmp/unrar.o -lz
echo "exit: $?"
# 3. 跑现有测试(确认没破坏 zip 流程)
cd /home/song/ps5-web-file-manager/
bash tests/run-tests.sh
# 期望:69 checks, 0 failures
D2:rar_extract 联调 + magic 探测 + limits 校验
6.2.1 extract 阶段实现
static zipx_status_t
rarx_extract_pass(struct RAROpenArchiveDataEx *arc,
const zipx_limits_t *limits,
const rarx_password_t *password,
rarx_progress_fn progress, void *userdata,
rarx_result_t *result) {
if (password && password->password_set) {
RARSetPassword(arc->h, password->password);
}
struct RARHeaderDataEx hdr;
while (RARReadHeaderEx(arc->h, &hdr) == 0) {
/* ratio 实时校验 */
if (hdr.PackSize > 0 && limits->max_ratio > 0) {
uint64_t ratio = hdr.UnpSize / hdr.PackSize;
if (ratio > limits->max_ratio) {
result->status = ZIPX_ERR_LIMIT_RATIO;
snprintf(result->detail, sizeof(result->detail),
"%s ratio %llu", hdr.FileName,
(unsigned long long)ratio);
return ZIPX_ERR_LIMIT_RATIO;
}
}
/* progress 回调 */
if (progress) {
zipx_progress_t p = {
.phase = ZIPX_PHASE_EXTRACT,
.entries_total = result->entries_total,
.entries_done = result->entries_done,
.bytes_total = result->bytes_total,
.bytes_done = result->bytes_done,
.current = hdr.FileName,
};
progress(userdata, &p);
}
/* 解到 staging 目录 */
char out_path[ZIPX_PATH_MAX];
snprintf(out_path, sizeof(out_path), "%s/%s",
result->detail /* staging dir */, hdr.FileName);
int mode = (hdr.Flags & 0xE0) == 0xE0 /* RAR5 dir flag */ ?
RAR_EXTRACT_DEST : RAR_EXTRACT;
/* 路径安全再校验一次 */
int rc = RARProcessFile(arc->h, mode, NULL, out_path);
if (rc != 0) {
result->status = (rc == 11) ? ZIPX_ERR_PASSWORD : ZIPX_ERR_IO;
result->sys_errno = errno;
return result->status;
}
result->entries_done++;
result->bytes_done += hdr.UnpSize;
/* crc 校验 —— unrar 已经做了,会自动设错误 */
}
return ZIPX_OK;
}
6.2.2 magic 探测(不依赖扩展名)
static int
rarx_probe_magic(const char *path) {
FILE *fp = fopen(path, "rb");
if (!fp) return 0;
unsigned char buf[8];
size_t n = fread(buf, 1, sizeof(buf), fp);
fclose(fp);
if (n < 7) return 0;
/* RAR 4: "Rar!\x1a\x07\x00" */
if (!memcmp(buf, "Rar!\x1a\x07\x00", 7)) return 1;
/* RAR 5: "Rar!\x1a\x07\x01\x00" */
if (!memcmp(buf, "Rar!\x1a\x07\x01\x00", 8)) return 1;
return 0;
}
为什么需要 magic 探测:
- 用户可能重命名
.bin→ 实际是 RAR - 防止
extract.c派发时仅靠扩展名判断漏掉情况
6.2.3 D2 验证清单
# 1. 写个最小的 manual fixture:
# 用系统 rar 命令(如果装了)建 RAR5 单卷 test.rar + 内容
# 或者从网上找开源的 RAR 测试样本
# 2. host 跑:
./.build/host-test/test-rar-extract tests/fixtures/rar5_single.rar /tmp/out
# 3. 确认 /tmp/out 里有解出来的文件
# 4. ratio bomb 测试:建一个 4MB of 'A' 的 RAR5,验证 ERR_LIMIT_RATIO
# 5. path traversal:建一个含 ../../etc/passwd 的 RAR,验证 ERR_UNSAFE_NAME
D3:extract.c 派发 + password 字段 + 错误码映射
6.3.1 src/filemgr_internal.h 新增字段
typedef struct {
...
int extract_conflict;
int extract_remove_source;
int extract_large;
int extract_format; /* 新增: 0=zip, 1=rar */
char extract_password[128]; /* 新增 */
} file_task_t;
6.3.2 src/extract.c 派发逻辑
关键:扩展名判断 + magic fallback 都做,避免前端漏检。
/* 新增 detect 函数 */
static int
detect_archive_format(const char *path, FILE *probe) {
/* 1. 扩展名优先(快) */
if (str_ends_with_ci(path, ".rar") ||
str_ends_with_ci(path, ".part01.rar") ||
str_ends_with_ci(path, ".part1.rar") ||
str_ends_with_ci(path, ".part001.rar")) {
return 1; /* RAR */
}
if (str_ends_with_ci(path, ".zip") ||
str_ends_with_ci(path, ".zipx")) {
return 0; /* ZIP */
}
/* 2. magic fallback */
if (probe && rarx_probe_magic(path)) return 1;
if (probe && zipx_probe_magic(path)) return 0;
return -1; /* unknown */
}
6.3.3 api_extract 解析 password 字段
char *password_str = body_form_value(body, body_size, "password");
char password[128] = {0};
int password_set = 0;
if (password_str) {
strncpy(password, password_str, sizeof(password) - 1);
/* 截断 sanitize:去掉 control chars */
sanitize_password(password);
password_set = 1;
}
free(password_str);
/* ... */
snprintf(task->extract_password, sizeof(task->extract_password),
"%s", password);
task->extract_format = detect_archive_format(task->src, NULL);
⚠️ 安全:绝不在日志、progress 回调、task_update 里写
password字段。grep 整个代码库password字符串只允许出现在 form 解析处。
6.3.4 extract_worker 派发
static void *
extract_worker(void *arg) {
file_task_t *task = arg;
zipx_conflict_t conflict = task->extract_conflict;
rarx_password_t rpwd = {0};
rpwd.password_set = task->extract_password[0] != 0;
strncpy(rpwd.password, task->extract_password,
sizeof(rpwd.password) - 1);
/* 立即清零 task 里的密码(防御内存 dump) */
memset(task->extract_password, 0,
sizeof(task->extract_password));
zipx_status_t status;
if (task->extract_format == 1 /* RAR */) {
rarx_result_t rres = {0};
status = rarx_extract(task->src, task->dst, conflict,
zipx_limits_profile(task->extract_large),
&rpwd,
extract_cancel, extract_progress, task, &rres);
/* 映射 rarx_result_t → task 状态字段 */
} else {
zipx_result_t zres = {0};
status = zipx_extract(task->src, task->dst, conflict,
zipx_limits_profile(task->extract_large),
extract_cancel, extract_progress, task, &zres);
}
/* status → task state 的映射逻辑保持不变 */
...
}
6.3.5 D3 验证清单
# host 端跑全量回归
bash tests/run-tests.sh
# 期望:69 checks, 0 failures(zip 流程没坏)
# 手动构造 form 测试 password 解析:
curl -X POST http://127.0.0.1:8080/api/extract \
-d "path=/data/test.rar&dst_dir=/data/out&password=secret123"
# 期望任务接受,无 500
D4:前端 isExtractableArchive + 密码 modal + i18n + 子卷置灰
6.4.1 main.js 检测函数
function isExtractableArchive(item) {
if (item.type !== "-") return false;
// ZIP(已有)
if (/\.zipx?$/i.test(item.name)) return true;
// RAR 单卷 .rar
if (/\.rar$/i.test(item.name)) return true;
// RAR5 分卷主卷 .part01.rar / .part1.rar / .part001.rar
if (/\.part0*1\.rar$/i.test(item.name)) return true;
return false;
}
function isRarSubVolume(item) {
if (item.type !== "-") return false;
// RAR5 子卷 .part02.rar, .part2.rar ...(part01 才是主卷)
if (/\.part0*\d+\.rar$/i.test(item.name) &&
!/\.part0*1\.rar$/i.test(item.name)) return true;
return false;
}
6.4.2 renderExtractButton 升级
function renderExtractButton(items, locked) {
const extractable = items.filter(isExtractableArchive);
const subs = items.filter(isRarSubVolume);
// 子卷单独选中 → 按钮置灰 + 提示
if (subs.length > 0 && extractable.length === 0) {
extractBtn.hidden = false;
extractBtn.disabled = true;
extractBtn.title = t("extractSelectMainVolume");
return;
}
// 主卷逻辑(与之前类似)
extractBtn.hidden = extractable.length !== 1;
if (extractable.length !== 1) {
extractBtn.title = "";
extractBtn.disabled = true;
return;
}
extractBtn.title = t("extractToCurrent") + ": " + itemTitle(extractable);
extractBtn.disabled = locked;
}
6.4.3 密码 modal(HTML)
在 assets/index.html 适当位置插入:
<div id="passwordModal" class="modal hidden">
<div class="modal-card">
<h3 id="passwordTitle">请输入解压密码</h3>
<p id="passwordPrompt" class="muted"></p>
<input type="password" id="passwordInput" autocomplete="off"
class="text-input" />
<div class="modal-actions">
<button id="passwordCancel" class="btn-secondary">取消</button>
<button id="passwordOk" class="btn-primary">确定</button>
</div>
</div>
</div>
6.4.4 密码 modal(CSS)
在 assets/main.css 末尾加:
.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
z-index: 1000; }
.modal.hidden { display: none; }
.modal-card { background: var(--bg-elevated); padding: 24px;
border-radius: 8px; min-width: 320px; max-width: 480px; }
.modal-actions { display: flex; gap: 12px; justify-content: flex-end;
margin-top: 16px; }
.muted { color: var(--text-faint); font-size: 13px; }
.text-input { width: 100%; padding: 8px 12px; margin: 12px 0;
border: 1px solid var(--border); border-radius: 4px;
background: var(--bg-input); color: var(--text); }
6.4.5 startExtractTask 接收 password
async function startExtractTask(path, dstDir, conflict, removeSource,
name, large, password) {
const data = await apiForm("/api/extract", {
path, dst_dir: dstDir, conflict,
remove_source: removeSource ? "1" : "0",
large: large ? "1" : "0",
password: password || ""
});
...
}
6.4.6 actionExtract 加密码弹窗
function promptPassword(fileName, retry) {
return new Promise((resolve, reject) => {
const modal = document.getElementById("passwordModal");
const input = document.getElementById("passwordInput");
const ok = document.getElementById("passwordOk");
const cancel = document.getElementById("passwordCancel");
const titleEl = document.getElementById("passwordTitle");
const promptEl = document.getElementById("passwordPrompt");
titleEl.textContent = retry ?
t("passwordWrongTitle") : t("passwordRequiredTitle");
promptEl.textContent = retry ?
t("passwordWrongPrompt") : t("passwordRequiredPrompt", {name: fileName});
input.value = "";
modal.classList.remove("hidden");
input.focus();
const cleanup = () => {
modal.classList.add("hidden");
ok.removeEventListener("click", onOk);
cancel.removeEventListener("click", onCancel);
};
const onOk = () => { const v = input.value; cleanup(); resolve(v); };
const onCancel = () => { cleanup(); reject(new Error("canceled")); };
ok.addEventListener("click", onOk);
cancel.addEventListener("click", onCancel);
input.addEventListener("keydown", e => {
if (e.key === "Enter") onOk();
if (e.key === "Escape") onCancel();
});
});
}
async function actionExtract() {
const item = singleSelected();
if (!item) return;
const conflict = ...;
const large = shouldPromptLargeMode(item.size) ? promptLargeMode(item.size) : false;
let password = "";
if (isRarExtension(item.name)) { /* 仅 RAR 弹 */
try {
password = await promptPassword(item.name, false);
} catch { return; }
}
startExtractTask(item.path, cwd, conflict, false,
displayName(item), large, password);
}
6.4.7 后端 ERR_PASSWORD 自动弹窗
/* 任务状态轮询或 SSE 里 */
if (task.error === "extract_password" ||
(task.message || "").toLowerCase().includes("password")) {
// 不太优雅但稳:弹窗重试
// (更好的做法是在 zipx_status_string 里直接提供 code,
// 前端 if (status === ZIPX_ERR_PASSWORD) → 弹)
}
💡 更稳的方案:在
zipx_status_string(ZIPX_ERR_PASSWORD)返回字符串"extract_password",前端task.op === "extract" && task.error === "extract_password"时弹窗,让用户重新提交。这避免字符串包含匹配带来的误报。
6.4.8 i18n 新增文案
assets/lang-zh.js:
extractSelectMainVolume: "请改选主卷(如 .part01.rar 或 .rar)",
passwordRequiredTitle: "需要解压密码",
passwordRequiredPrompt: "RAR 文件 {name} 已加密,请输入解压密码。",
passwordWrongTitle: "密码错误",
passwordWrongPrompt: "密码错误,请重新输入。密码仅本次使用,不会保存。",
assets/lang-en.js:
extractSelectMainVolume: "Select the main volume (e.g. .part01.rar or .rar)",
passwordRequiredTitle: "Password required",
passwordRequiredPrompt: "The RAR archive {name} is encrypted. Please enter the password.",
passwordWrongTitle: "Wrong password",
passwordWrongPrompt: "Wrong password. Please try again. The password is only used for this extraction and is never saved.",
6.4.9 D4 验证清单
# 1. node 语法检查
node --check assets/main.js
node --check assets/lang-en.js
node --check assets/lang-zh.js
# 2. 浏览器手动测试(开 dev server 或本地 http-server):
# - 选 .rar → 弹密码框
# - 选 .zip → 不弹密码框(保持 v1.7 行为)
# - 选 .part02.rar → 解压按钮置灰 + tooltip
# - 选 .part01.rar → 解压按钮可用
# 3. 提交后端确认 password 字段透传:
# Network → /api/extract → Form Data → password: "xxx"
D5:host tests/test_rar_extract.c + fixtures
6.5.1 fixture 准备
需要 RAR 测试样本。优先用 WinRAR / rar 命令行工具生成:
# 装 unrar / rar(host)
sudo apt install rar unrar # 或 mac: brew install rar
# 生成 fixtures(用脚本自动化)
cd tests/fixtures/
# RAR4 单卷明文
rar a -os rar4_single.rar sample.txt
# RAR5 单卷明文
rar a -ma rar5_single.rar sample.txt
# RAR5 分卷(5 卷 × 1MB)
mkdir -p split_src && head -c 5M /dev/urandom > split_src/big.bin
rar a -v1m -ma rar5_multi.part01.rar split_src/
# 加密 RAR5(密码 "secret")
rar a -ma -hpsecret encrypted_rar5.rar sample.txt
# 加密 RAR4(密码 "secret")
rar a -os -hpsecret encrypted_rar4.rar sample.txt
# 加密分卷
rar a -v1m -ma -hpsecret encrypted_multi.part01.rar split_src/
# 损坏
head -c 1024 rar5_single.rar > rar5_truncated.rar
⚠️ fixture 不能 commit(见 .gitignore),需要在
tests/make_fixtures.py里写生成函数,运行bash tests/run-tests.sh时自动调用。
6.5.2 tests/make_fixtures.py 新增
def rar4_single():
"""RAR4 single volume, plaintext. Generated by host `rar` CLI."""
import subprocess
src = path("_rar_src.txt")
if not os.path.exists(src):
with open(src, "w") as f:
f.write("hello rar4\n" * 100)
out = path("rar4_single.rar")
if not os.path.exists(out):
subprocess.check_call(["rar", "a", "-os", out, src])
return out
def rar5_single():
src = path("_rar_src.txt")
out = path("rar5_single.rar")
if not os.path.exists(out):
subprocess.check_call(["rar", "a", "-ma", out, src])
return out
# ... 其余同理
💡 CI 兼容性:CI 环境可能没装 rar。改用
unrar命令 + 预生成 fixture 一并存档。最稳:把生成好的 fixture 提交到 git(small ones only,<1MB each)。
6.5.3 tests/test_rar_extract.c 测试用例模板
/* tests/test_rar_extract.c */
#include "rar_extract.h"
static rarx_result_t
run_rar(const char *src, const char *dst,
zipx_conflict_t conflict,
const zipx_limits_t *limits,
const rarx_password_t *pwd,
rarx_progress_fn progress, void *userdata) {
rarx_result_t res = {0};
rarx_extract(src, dst, conflict, limits, pwd,
NULL, progress, userdata, &res);
return res;
}
static void
test_rar4_single(void) {
rarx_result_t r = run_rar("rar4_single.rar", "out_rar4_single",
ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, NULL);
check(r.status == ZIPX_OK, "RAR4 single extracts OK");
check(r.entries_total == 1, "1 entry");
check(r.files_created == 1, "1 file created");
}
static void
test_rar5_single(void) {
rarx_result_t r = run_rar("rar5_single.rar", "out_rar5_single",
ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, NULL);
check(r.status == ZIPX_OK, "RAR5 single extracts OK");
}
static void
test_rar5_multi_volume(void) {
/* unrar 自动找 .part02, .part03... */
rarx_result_t r = run_rar("rar5_multi.part01.rar",
"out_rar5_multi",
ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, NULL);
check(r.status == ZIPX_OK, "RAR5 multi-volume extracts OK");
check(r.entries_total >= 1, "at least one entry");
}
static void
test_rar4_encrypted_correct_pwd(void) {
rarx_password_t pwd = {.password = "secret", .password_set = 1};
rarx_result_t r = run_rar("encrypted_rar4.rar",
"out_rar4_enc_ok",
ZIPX_CONFLICT_FAIL, NULL, &pwd, NULL, NULL);
check(r.status == ZIPX_OK, "encrypted RAR4 with correct pwd OK");
}
static void
test_rar4_encrypted_wrong_pwd(void) {
rarx_password_t pwd = {.password = "wrong", .password_set = 1};
rarx_result_t r = run_rar("encrypted_rar4.rar",
"out_rar4_enc_wrong",
ZIPX_CONFLICT_FAIL, NULL, &pwd, NULL, NULL);
check(r.status == ZIPX_ERR_PASSWORD, "wrong pwd → ERR_PASSWORD");
check(r.files_created == 0, "no file created on wrong pwd");
}
static void
test_rar4_encrypted_no_pwd(void) {
rarx_result_t r = run_rar("encrypted_rar4.rar",
"out_rar4_enc_nopwd",
ZIPX_CONFLICT_FAIL, NULL, NULL, NULL, NULL);
check(r.status == ZIPX_ERR_PASSWORD, "no pwd → ERR_PASSWORD");
check(r.has_password == 1, "scan detects password requirement");
check(r.files_created == 0, "no file created without pwd");
}
/* 全部测试 + main 入口同 test_zip_extract.c */
6.5.4 run-tests.sh 新增编译 RAR 支持
# tests/run-tests.sh 新增:
"$CC" -O2 -c "$ROOT/third_party/unrar/unrar.c" -o "$BUILD/unrar.o" || exit 1
"$CC" -O2 -c "$ROOT/src/rar_extract.c" -o "$BUILD/rar_extract.o" \
-I "$ROOT/src" -I "$ROOT/third_party/unrar/" || exit 1
"$CC" -O2 -c "$ROOT/tests/test_rar_extract.c" -o "$BUILD/test_rar_extract.o" \
-I "$ROOT/src" -I "$ROOT/third_party/unrar/" || exit 1
# 链接
"$CC" -O2 -o "$BUILD/test-rar-extract" \
"$BUILD/rar_extract.o" "$BUILD/test_zip_extract.o" \
"$BUILD/unrar.o" "$BUILD/test_rar_extract.o" "${objs[@]}" \
|| exit 1
"$BUILD/test-rar-extract" "$ROOT/tests/fixtures" "$BUILD/work"
6.5.5 D5 验证清单
bash tests/run-tests.sh
# 期望:69 + ~12 = 81 checks, 0 failures
# 单独跑 RAR 测试:
./.build/host-test/test-rar-extract tests/fixtures/ /tmp/rar_work
# 期望:所有 RAR 测试通过
D6:WSL 跨编 + ELF 验证 + 文档
6.6.1 WSL Makefile 更新
Makefile 加:
# third_party/unrar/
UNRAR_OBJS = $(addprefix $(OBJ_DIR)/third_party/unrar/, \
$(notdir $(wildcard third_party/unrar/*.c)))
$(UNRAR_OBJS): | $(OBJ_DIR)/third_party/unrar
$(OBJ_DIR)/third_party/unrar/%.c.o: third_party/unrar/%.c
$(CC) $(CFLAGS) -c $< -o $@
# src/rar_extract.o
OBJ_FILES += src/rar_extract.c
# .elf 链接加 unrar.o + rar_extract.o
$(ELF): ... $(UNRAR_OBJS) src/rar_extract.o ...
6.6.2 跨编
# WSL Ubuntu-22.04 内
cd /home/song/ps5-web-file-manager
bash build-elf.sh
# 期望:[7/7] OK
# 期望 size: ~580 KiB
# 期望 sha256: 不在 list 内(首次编)
6.6.3 ELF 验证脚本
# Windows side 验证
cd "C:\Users\songl\Desktop\Web File Manager\ps5-web-file-manager"
sha256sum web-file-mgr.elf
size web-file-mgr.elf
head -c 20 web-file-mgr.elf | xxd | head -2
# gzip 流验证(前端改动进了 ELF)
python3 .build/check-elf-gzip.py ./web-file-mgr.elf | grep -E "(✓|✗)"
# 期望:
# ✓ passwordRequiredTitle
# ✓ passwordRequiredPrompt
# ✓ passwordWrongTitle
# ✓ passwordWrongPrompt
# ✓ extractSelectMainVolume
# ✓ startExtractTask ... password: password || ""
# ✓ rarx_extract
# ✓ ZIPX_ERR_PASSWORD
6.6.4 文档更新
新建:docs/UPGRADE-v1.8-rar-support.md(镜像 v1.7 UPGRADE 风格,~450 行)
更新:CHANGELOG.md 头部加 v1.8 段:
## [v1.8] - 2026-09-XX
### Added
- **RAR archive extraction** via vendored alexbatalov/unrar.c
- RAR4 and RAR5 single-volume and multi-volume (`name.partNN.rar`)
- Encrypted RAR support with password prompt + retry dialog
- Frontend detection of RAR main/sub volumes + grayscale sub-volume button
### Technical
- `src/rar_extract.{h,c}` mirrors `zip_extract` API (1600 LOC, three-phase pipeline)
- `extract.c` dispatches by extension + magic-byte fallback
- ZIP path unchanged (v1.7 large-file profile still works)
更新:README.md ZIP extraction 段后面加:
### RAR extraction
The project also extracts RAR4 and RAR5 archives, including
multi-volume (`name.part01.rar`, `name.part02.rar`, …). Encrypted
archives prompt for a password client-side; the password is held
only in memory and never saved.
Limits mirror the ZIP profiles (200K entries / 512 GiB / 64 GiB /
ratio 200 by default, with the same `large=1` opt-in to 500K /
2 TiB / 1 TiB / 1000).
6.6.5 Git 提交 + push
cd /home/song/ps5-web-file-manager
git add third_party/unrar/ src/rar_extract.* src/extract.c src/filemgr_internal.h \
assets/main.js assets/main.css assets/lang-en.js assets/lang-zh.js \
tests/test_rar_extract.c tests/make_fixtures.py tests/run-tests.sh \
Makefile docs/UPGRADE-v1.8-rar-support.md CHANGELOG.md README.md
git -c core.autocrlf=false commit -m "v1.8: RAR4/RAR5 + multi-volume + encrypted support
- vendor alexbatalov/unrar.c into third_party/unrar/ (UnRAR license)
- src/rar_extract.{h,c}: three-phase engine (scan -> extract -> publish -> cleanup)
with first-volume-only API (unrar internally finds companion volumes)
- extract.c: format dispatch by extension + Rar! magic fallback
- API: POST /api/extract gains optional 'password' form field
- frontend: isExtractableArchive() covers RAR single/multi; isRarSubVolume()
disables the extract button with a tooltip
- password modal: required on encrypted RAR, wrong-password retry
- tests: +12 RAR checks (single/multi/encrypted-no-pwd/encrypted-wrong-pwd/
encrypted-correct-pwd/ratio-bomb/path-traversal/truncated)
- ELF: 418 KiB -> ~580 KiB; gzip-assets verified for new strings
- docs: CHANGELOG + UPGRADE-v1.8-rar-support.md + README"
# 用户在自己 PowerShell(非沙箱)里:
# git push -u origin main
# git tag -a v1.8 -m "..."
# git push origin v1.8
7. 关键代码模式(参考 zip_extract 实现)
7.1 三阶段架构(rar_extract 必须镜像)
zipx_status_t
rarx_extract(const char *first_path, const char *dst_dir,
zipx_conflict_t conflict,
const zipx_limits_t *limits,
const rarx_password_t *password,
zipx_cancel_fn cancel, zipx_progress_fn progress,
void *userdata, rarx_result_t *result) {
/* 1) scan 阶段 */
zipx_status_t s = rarx_scan(first_path, limits, result);
if (s != ZIPX_OK) return s;
if (result->has_password && (!password || !password->password_set)) {
result->status = ZIPX_ERR_PASSWORD;
return ZIPX_ERR_PASSWORD;
}
/* 2) 创建 staging 目录 */
char staging[ZIPX_PATH_MAX];
snprintf(staging, sizeof(staging), "%s/.wfm-part-%d",
dst_dir, (int)getpid());
if (mkdir(staging, 0755) < 0) {
result->status = ZIPX_ERR_IO;
return ZIPX_ERR_IO;
}
snprintf(result->detail, sizeof(result->detail), "%s", staging);
/* 3) extract 阶段(解到 staging) */
s = rarx_extract_pass(first_path, staging, limits, password,
cancel, progress, result);
if (s != ZIPX_OK) {
/* cleanup: unlink staging 整树 */
nftw(staging, unlink_cb, 64, FTW_DEPTH | FTW_PHYS);
rmdir(staging);
return s;
}
/* 4) publish 阶段:rename staging → dst_dir */
s = rarx_publish(staging, dst_dir, conflict, result);
if (s != ZIPX_OK) {
nftw(staging, unlink_cb, 64, FTW_DEPTH | FTW_PHYS);
rmdir(staging);
return s;
}
return ZIPX_OK;
}
7.2 path 安全校验(直接复用 zip_extract.c 的实现)
/* 把 zip_extract.c 的 is_safe_archive_path() 复制到 rar_extract.c */
/* 或者提到一个共用的 src/path_util.c */
⚠️ 可重构性:D3 阶段如果时间够,把
is_safe_archive_path/path_depth/nftw_unlink提到src/path_util.c,rar_extract 和 zip_extract 都 include。不做也行,copy 一份到 rar_extract.c 即可。
7.3 错误码映射(rar → zip 命名空间)
| RAR unrar API 返回 | unrar 含义 | 映射到 zipx_status_t |
|---|---|---|
ERAR_NO_MEMORY |
OOM | ZIPX_ERR_INTERNAL |
ERAR_BAD_DATA |
CRC 错 / 损坏 | ZIPX_ERR_CRC |
ERAR_BAD_ARCHIVE |
头错 / 不识别 | ZIPX_ERR_FORMAT |
ERAR_UNKNOWN_FORMAT |
不是 RAR | ZIPX_ERR_FORMAT |
ERAR_EOPEN |
文件打不开 | ZIPX_ERR_OPEN |
ERAR_ECREATE |
创建输出失败 | ZIPX_ERR_IO |
ERAR_ECLOSE |
关闭失败 | ZIPX_ERR_IO |
ERAR_EREAD |
读失败 | ZIPX_ERR_IO |
ERAR_EWRITE |
写失败 | ZIPX_ERR_IO |
ERAR_SMALL_BUF |
name buffer 不够 | ZIPX_ERR_LIMIT_NAME |
ERAR_PASSWORD (11) |
密码错/缺 | ZIPX_ERR_PASSWORD(新加) |
7.4 task_state 中密码字段的安全处理
/* extract_worker 入口 */
char password_copy[128];
strncpy(password_copy, task->extract_password, sizeof(password_copy) - 1);
memset(task->extract_password, 0, sizeof(task->extract_password));
/* 后续只用 password_copy,函数退出时也清零 */
8. 工程决策(不要重新讨论)
8.1 为什么 vendor unrar.c 而不是动态库
- SDK 没有 homebrew 目录(见 §2.3)
- vendor 源码 = 完全可控,编译/链接/调试一次到位
- unrar.c 纯 C + 单文件 = 跨编零摩擦
- 动态库需要
-Wl,-rpath等额外 linker 配置,麻烦
8.2 为什么不用 libarchive
- 体积大一倍以上(libarchive stripped ~400 KiB,unrar stripped ~150 KiB)
- libarchive 内部仍依赖 unrar/librar 才能读 RAR —— 反而绕远
- libarchive 的 BSD-style API 与现有 zip_extract / rar_extract 设计不符
- 用户场景里 7z 罕见,付不起这个成本
8.3 为什么复用 zipx_status_t 枚举
- 错误码统一,前端不用分辨 ZIP_ERR_* vs RAR_ERR_*
task_update()一份映射逻辑覆盖两种格式- 减少新代码量(也减少 bug)
8.4 为什么不在后端做分卷发现
- unrar 内部已经处理
name.part01.rar→ 找.part02, .part03... - 后端写发现逻辑只是重复实现,且容易有边角 case bug
8.5 为什么密码不做错/对细粒度区分
- 「密码错」vs「文件损坏」的区分会泄露「文件存在 / 是否加密」信息(侧信道)
- 统一返回
ZIPX_ERR_PASSWORD+ 友好文案即可 - 用户重试一次的成本可控
8.6 为什么 ELF 体积红线设在 ~1 MiB
- payload loader 通常限制 4 MiB,但实际 PS5 WebKit 启动时内存紧张
- 单 payload 越大,加载越慢;用户感受从 580 KiB → 800 KiB 能感知
- 留余量给未来加 7z、ZIP AES 等扩展
9. 注意事项 / 已知坑
9.1 unrar license 是「UnRAR License」(不是 MIT/BSD/GPL)
⚠️ 法律红线:
- 允许:使用、编译、嵌入、二进制分发
- 禁止:改装 unrar.c、反编译、创作派生作品
- 禁止:用 unrar 创建 RAR 压缩功能(只能用解压)
实操:
- 不要修改
third_party/unrar/unrar.c - 不要把
unrar.c拆开编译(必须作为单个 TU 整体编译) - 不能基于 unrar 源码创作 RAR 压缩工具(不做就好)
9.2 SDK staging 模式(每次都要 sudo)
# 不要尝试
chown -R song:song /opt/ps5-payload-sdk/ # ❌ 会破坏其他用户
chmod 777 /opt/ps5-payload-sdk/ # ❌ SDK 可能拒绝加载
# 正确做法(build-elf.sh v5 已实现)
make install DESTDIR=/tmp/wfm-stage
sudo cp -r /tmp/wfm-stage/opt/* /opt/
9.3 编辑工具的隐藏陷阱
- Edit 工具偶有"成功但不落盘"现象:连续两次同一 Edit,第一次只报成功未生效。对策:每次 Edit 后用
grep验证改动真的进了文件。 - 写
.bat/.ps1时不要在 PS5 路径里写非 ASCII(utf-8 BOM 破坏文件名)。对策:用 PowerShellWrite工具的绝对路径形式,或改用 Git Bash heredoc。 - 修改 unrar.c 用
sed是诱人的,但 license 不允许改 → 用 wrapper 函数封装。
9.4 跨编 ELF 校验脚本
gen-asset-module.py 把 JS / JSON / CSS / HTML 用 zlib 压缩进 ELF。
普通 strings web-file-mgr.elf 看不到前端新加的字符串。
对策:用 .build/check-elf-gzip.py(已存在)扫 gzip 流验证。
# 检查清单(D6 必跑)
python3 .build/check-elf-gzip.py ./web-file-mgr.elf | grep "✗"
# 期望:空输出(所有 key 都在)
9.5 测试 fixture 生成
- 4 MiB of 'A' 的 zip 不用于 large profile 测试(ratio ≈ 1026 > 1000)
- 用
bytes(range(256)) * 4096才有 ratio ≈ 238(夹在 200~1000 中间) - RAR 用 host
rar命令生成 fixture,CI 环境可能没装 → 把 small fixtures 提交到 git
9.6 密码字段的安全
- task 结构里的 password 在 extract_worker 入口立即清零
- 日志 / progress 回调绝不写 password
task->message[]是固定 192 字节 buffer,只写错误描述,不写密码- 前端 modal 关闭后立即
input.value = ""
9.7 magic 探测 + 扩展名优先级
扩展名优先(O(1) 字符串比较),magic fallback 用 file I/O(O(8 字节读)):
- 大文件不慢:magic 只读 8 字节
- 重命名
.bin仍可识别 - 非 RAR 非 ZIP → 返回 -1,extract.c 报
extract_unsupported
9.8 三阶段 staging 目录命名
.wfm-part-{pid}—— 用 pid 区分并发解压- 失败时
nftw(staging, unlink_cb, 64, FTW_DEPTH | FTW_PHYS)递归删 - publish 阶段
rename(staging, dst_dir)—— 原子(同一文件系统下)
9.9 RAR4 vs RAR5 头差异
| 字段 | RAR4 | RAR5 |
|---|---|---|
| Magic | Rar!\x1a\x07\x00 |
Rar!\x1a\x07\x01\x00 |
| 大小字段 | 32-bit | 64-bit (UnpSizeHigh 等) |
| 加密 flag | LHD_PASSWORD (0x04) |
头 flag bit 0x04 |
| 目录 flag | LHD_DIRECTORY (0xE0) |
不同位 |
| CRC | 32-bit | 32-bit (压缩) |
unrar 抽象了这些,API 层不必区分,但错误处理时要兼容两种返回码。
9.10 unrar 多线程安全性
⚠️ unrar 全局状态:RAROpenArchive 返回的 HANDLE 不是线程安全的,每个 task 必须独立打开/关闭。当前
extract_worker已经是每 task 一个 thread,没问题。
10. 常用命令清单
10.1 host 测试
cd /home/song/ps5-web-file-manager/
bash tests/run-tests.sh # 全量回归(81+ checks)
./.build/host-test/test-zip-extract \
tests/fixtures/ /tmp/wfm_work # 单独跑 ZIP
./.build/host-test/test-rar-extract \
tests/fixtures/ /tmp/wfm_work # 单独跑 RAR
10.2 PS5 跨编
# WSL Ubuntu-22.04 bash
cd /home/song/ps5-web-file-manager/
bash build-elf.sh # 全量构建 + 落双路径
# 仅清理 + 重编(快一些)
make clean all
# 增量编译(zlib/minizip-ng obj 缓存复用)
make all
10.3 ELF 验证
# 在 Windows 侧(或 WSL 内)
sha256sum web-file-mgr.elf
size web-file-mgr.elf
head -c 20 web-file-mgr.elf | xxd | head -2
od -An -tx2 -N2 -j18 web-file-mgr.elf | tr -d " " # expect 003e
# 验证前端改动进了 ELF
python3 .build/check-elf-gzip.py ./web-file-mgr.elf
10.4 Git 操作
# 本地提交(commit 阶段)
cd /home/song/ps5-web-file-manager/
git add <files>
git -c core.autocrlf=false commit -m "..."
# 用户在自己 PowerShell / Git Bash(非沙箱):
git push -u origin main
git tag -a v1.8 -m "..."
git push origin v1.8
10.5 debug 工具
# 跟踪 HTTP 请求(curl)
curl -v -X POST http://127.0.0.1:8080/api/extract \
-d "path=/data/test.rar&dst_dir=/data/out&password=secret"
# 看 ELF 内是否有符号
nm web-file-mgr.elf 2>/dev/null | grep rarx
# (prospero-strip 后大部分本地符号被剥,外部函数名仍在)
# 看 unrar 占多大
size --target=binary web-file-mgr.elf
11. 测试矩阵(完成 D5 后应全过)
11.1 ZIP(v1.7 已覆盖 69 checks)
| 类别 | 用例 | 期望 |
|---|---|---|
| 基础 | basic / stored / unicode / zip64 | OK |
| 安全 | traversal / traversal_backslash / absolute / drive_letter / symlink / fifo | ERR_* |
| 加密 | encrypted | ERR_UNSUPPORTED |
| 错误 | bad_crc / truncated / not_a_zip | ERR_CRC / ERR_FORMAT / ERR_OPEN |
| 资源 | ratio / file / total cap / depth / name | ERR_LIMIT_* |
| 边界 | duplicate / file_dir_clash / conflict_source | ERR_DUPLICATE / 处理 conflict |
| 大文件 | large profile (16 checks) | OK / ERR_* 按预期 |
11.2 RAR(v1.8 新增 ~12 checks)
| 类别 | 用例 | 期望 |
|---|---|---|
| 基础 | rar4_single / rar5_single | OK |
| 分卷 | rar5_multi | OK |
| 加密 | rar4_enc_correct_pwd / rar5_enc_correct_pwd | OK |
| 加密 | rar4_enc_wrong_pwd / rar4_enc_no_pwd | ERR_PASSWORD |
| 错误 | rar_truncated / rar_bad_archive | ERR_FORMAT |
| 安全 | rar_path_traversal | ERR_UNSAFE_NAME |
| 资源 | rar_ratio_bomb / rar_file_too_large | ERR_LIMIT_RATIO / ERR_LIMIT_FILE |
| 边界 | rar_cancel | ERR_CANCELED |
11.3 集成测试(手动或脚本化)
# 在 PS5 真机 / qemu-ps5 上
1. 浏览器访问 http://ps5-ip:8080
2. 上传 sample.rar → 选中 → 解压 → 验证文件出现
3. 上传 encrypted.rar → 选中 → 弹密码框 → 输入 → 解压
4. 上传 .part02.rar → 选中 → 按钮置灰 → tooltip 正确
5. 上传 game.part01.rar + .part02.rar + .part03.rar → 选 part01 → 解压
6. 用错的密码再次尝试 → 弹密码错误窗 → 重试
7. 上传 200GB rar → 弹 large profile 窗 → 走 large profile 解压
12. 项目当前快照(2026-09-04 20:50)
Repo: https://github.com/owendswang/ps5-web-file-manager.git (待 push)
Local: C:\Users\songl\Desktop\Web File Manager\ps5-web-file-manager\
WSL: \\wsl$\Ubuntu-22.04\home\song\ps5-web-file-manager\
HEAD: aef4a44 (v1.7 + 文档)
Branch: main (no .git remote yet)
ELF: web-file-mgr.elf (418 KiB)
sha256: 648e4a00afe52669846df52ee5342bab42ea10050d555d5a1d4fa602653f514b
e_machine: 0x003e (x86_64-sie-ps5 ✓)
Version: v1.7
Title ID: FMGR88888
Tests: 69 checks, 0 failures
Deps: zlib 1.3.1, minizip-ng 4.2.2 (vendored, no system libs)
Done:
✅ v1.7 ZIP large-file profile (full stack)
✅ README / CHANGELOG / UPGRADE-v1.7 docs
✅ Git repo initialized locally (2 commits, push pending)
Doing:
⏳ v1.8 RAR support (planning phase, ready to start D1)
13. 写在最后
这份文档不是"工作日志",是"施工蓝图"。任何接手人应该能:
- 读完 §1 ~ §4(10 分钟)→ 知道项目是什么、当前到哪
- 跳到 §6 按天执行(6 天)→ 写完 v1.8
- 出问题翻 §7 ~ §9(30 分钟内定位)
- 完成时按 §6.6.5 提交 + §10 验证
如果某个环节卡住超过 2 小时,优先回这里查 §9 的「坑」 —— 90% 的边角 case 我都踩过了。
加油。