Files
LisherSong--ps5-web-file-ma…/docs/HANDOVER.md
T
Songlx516 c7d9e965b8 v1.8: drop legacy RAR .rNN multi-volume format
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
2026-09-04 21:03:34 +08:00

51 KiB
Raw Blame History

PS5 Web File Manager — 项目工作方案

目的:让任何接手人拿到这份文档 + 项目仓库,都能独立推进 v1.8(RAR 支持)开发。 读者:开发者,需要熟悉 C99 + POSIX + 浏览器 JS,但不要求懂 PS5 SDK。 撰写日期:2026-09-04 对应代码版本:aef4a44(v1.7 + 文档配套提交)


0. 阅读顺序建议

如果你时间紧:

  1. 先看 §1「项目是什么」和 §4「当前进度」 → 5 分钟建立全局观
  2. 跳到 §6「v1.8 详细计划」按天执行
  3. 卡住了回 §7「关键代码模板」找参照实现
  4. 出 bug 翻 §9「坑清单」

如果你要 review 整套设计:

  1. §1 → §3 → §6 → §7 → §8 顺序读完

1. 项目是什么

PS5 Web File Manager 是个跑在越狱 PS5 上的 HTTP 文件管理 payload。

  • 用户从 PS5 浏览器打开 http://PS5-IP:8080 → 看到 PS5 文件系统
  • 可以浏览 / 上传 / 下载 / 编辑文本 / 解压 ZIP / 安装 PKG
  • 单文件 ELF(web-file-mgr.elf),无外部服务依赖

关键事实(会影响你所有设计决策,先背下来):

  • CPU:AMD x86-64 Zen 2(不是 ARM),目标 triple x86_64-sie-ps5
  • SDK:vendored 在 /opt/ps5-payload-sdk/,未提供 homebrew 库目录(你不能 pkg-config --libs libxxx,只能 vendor C 源码自己编)
  • 体积红线:单 ELF 通常不超 1 MiB,当前 v1.7 = 418 KiB
  • 网络环境:通常在 LAN 内,HTTPS 不强制(自签证书)

代码托管:https://github.com/owendswang/ps5-web-file-manager.git License:GPLv3+(注意:unrar 的 license 是「UnRAR license」,不能改装,只能直接用 —— 见 §9.1)


2. 仓库与构建环境

2.1 本地路径

Windows:  C:\Users\songl\Desktop\Web File Manager\ps5-web-file-manager\
WSL:      \\wsl$\Ubuntu-22.04\home\song\ps5-web-file-manager\   (同一份文件)
PS5:      /mnt/usb0/...                                         (部署目标)

2.2 关键工具位置

工具 位置 用途
WSL bash wsl -d Ubuntu-22.04 唯一能跑 PS5 交叉编译的地方
编辑器 本机任意 VS Code 推荐,记得保存用 LF(.gitattributes 不强制)
prospero-clang /opt/ps5-payload-sdk/bin/prospero-clang PS5 交叉编译 wrapper,自动注入 -target x86_64-sie-ps5
prospero-strip /opt/ps5-payload-sdk/bin/prospero-strip strip 工具
构建脚本 /home/song/build-elf.sh (WSL) + .build/build-elf.sh (副本) 完整构建 + 落双路径

2.3 双身份坑(必读)

⚠️ WSL 里有两种身份:真 bash 用户 vs SMB 视图用户

  • 真 bash 用户:song(1000),写 /opt/ps5-payload-sdk/... 需要 sudo
  • SMB 视图用户:songl(197609),但不能写 /opt/...(即使 sudo)

SDK 的 target/user/homebrew/ 被 songl(197609) 拥有 755,普通 song 写不进。 正确做法(构建脚本 v5 已实现):staging 模式 → 先 make install 到 /tmp/ → sudo cp -r 到 SDK。 不要尝试 sudo chmod SDK 目录,会破坏其他项目。

2.4 我的 Bash 工具是 MinGW64,不是 WSL

  • uname -a = MINGW64_NT-10.0
  • 看 WSL 真实状态用 \\wsl$\Ubuntu-22.04\ 路径前缀
  • 但修改 WSL 文件只能用 PowerShell 走 C:\Users\songl\Desktop\... 路径,不能用 wsl.exe(沙箱黑名单)

3. 已完成功能(v1.7 = 5cb0b76 + aef4a44)

3.1 ZIP 解压 + 大文件模式

用户场景:解压一个 200GB 系统镜像到 PS5。默认 limits 不够 → 加 🅱 "大文件模式"开关。

完整链路(每层都改了,记得按层理解):

┌────────────────────────────────────────────────────────────────┐
│ Frontend: assets/main.js                                       │
│   - LARGE_FILE_THRESHOLD_BYTES = 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 引擎共享的设计):

  1. scan —— 读所有 entry header,只校验 name 安全 + 累加 bytes 估算
  2. extract —— 写到 staging .wfm-part-{pid}-{taskid}/,fsync 每个文件
  3. publish —— 整 staging rename 到目标,处理 conflict 策略
  4. cleanup —— 任何阶段失败都 unlink staging

i18n:

  • assets/lang-en.js 和 lang-zh.js 新增 extractLargeAsk / extractLargeActive

前端 UX:

  • ZIP 大于 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 提供 RARExtractChunk API,可以一次解 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 破坏文件名)。对策:用 PowerShell Write 工具的绝对路径形式,或改用 Git Bash heredoc。
  • 修改 unrar.c 用 sed 是诱人的,但 license 不允许改 → 用 wrapper 函数封装。

9.4 跨编 ELF 校验脚本

gen-asset-module.py 把 JS / JSON / CSS / HTML 用 zlib 压缩进 ELF。 普通 strings web-file-mgr.elf 看不到前端新加的字符串。 对策:用 .build/check-elf-gzip.py(已存在)扫 gzip 流验证。

# 检查清单(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. 读完 §1 ~ §4(10 分钟)→ 知道项目是什么、当前到哪
  2. 跳到 §6 按天执行(6 天)→ 写完 v1.8
  3. 出问题翻 §7 ~ §9(30 分钟内定位)
  4. 完成时按 §6.6.5 提交 + §10 验证

如果某个环节卡住超过 2 小时,优先回这里查 §9 的「坑」 —— 90% 的边角 case 我都踩过了。

加油。