mirror of
https://github.com/LisherSong/ps5-web-file-manager.git
synced 2026-10-06 08:00:23 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce55d08299 | ||
|
|
c46f5969c8 | ||
|
|
9c06fca17f | ||
|
|
400784942e | ||
|
|
cfa02562b2 | ||
|
|
9a03fb7c05 | ||
|
|
9740a252a9 | ||
|
|
9e0032c019 | ||
|
|
3af704f854 | ||
|
|
2fb1a1cf56 | ||
|
|
9f3e91241e | ||
|
|
de4b74c354 | ||
|
|
fb7ac30c15 | ||
|
|
ede12cb914 | ||
|
|
edd1a1c177 | ||
|
|
9d8c49a1d4 | ||
|
|
f85e60ed8c | ||
|
|
770dcb8a9f | ||
|
|
ba668ade50 | ||
|
|
3f80eb4692 | ||
|
|
807d129d8d | ||
|
|
70eaa1027d | ||
|
|
575f94cd39 | ||
|
|
2059c0e4b5 |
No files matched your search
@@ -17,6 +17,14 @@
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# Git Bash / MSYS2 rewrites anything that looks like a POSIX path before the
|
||||
# argument reaches wsl.exe, so `/mnt/c/...` becomes
|
||||
# `<msys-root>/mnt/c/...` and the WSL side reports "cannot stat" (this is how
|
||||
# the script first failed under PortableGit). WSL sees Linux paths, so the
|
||||
# translation must be off for the whole script. Harmless on a real Linux shell.
|
||||
export MSYS_NO_PATHCONV=1
|
||||
export MSYS2_ARG_CONV_EXCL='*'
|
||||
|
||||
REPO='/c/Users/songl/Desktop/Web File Manager/ps5-web-file-manager'
|
||||
WSL_DISTRO='Ubuntu-22.04'
|
||||
WSL_DIR='/home/song/ps5-web-file-manager/.build'
|
||||
|
||||
+54
-43
@@ -1,51 +1,62 @@
|
||||
"""Verify our large-file mode identifiers made it into the ELF.
|
||||
gen-asset-module.py gzips JS assets, so plain `strings` won't find them.
|
||||
This script finds all gzip streams in the ELF, decompresses them, and greps
|
||||
for the new identifiers."""
|
||||
import re, sys, zlib
|
||||
"""Verify that specific identifiers made it into the ELF's embedded assets.
|
||||
|
||||
`gen-asset-module.py` gzips every JS/CSS/HTML asset (compresslevel=9, mtime=0),
|
||||
so plain `strings` finds none of their text -- a zero hit means "compressed",
|
||||
not "missing". This script walks the file for gzip streams, decompresses each
|
||||
one and reports where each key landed.
|
||||
|
||||
Usage:
|
||||
python .build/check-elf-gzip.py <elf> [key ...]
|
||||
|
||||
With no keys it defaults to the current round: the v1.9.3M fork marker and the
|
||||
footer tooltip that explains it. Pass your own after the ELF path when reusing
|
||||
this for another change.
|
||||
|
||||
Scope note: this covers **assets only**. C-side string literals (e.g.
|
||||
`extract_dict_too_large`, `versionsTooltip` in a header) are not compressed and
|
||||
belong to a plain `strings` check.
|
||||
"""
|
||||
import sys
|
||||
import zlib
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
|
||||
f = sys.argv[1]
|
||||
data = open(f, "rb").read()
|
||||
|
||||
# Gzip streams start with 0x1f 0x8b. Walk through the file looking for them.
|
||||
keys = [
|
||||
b"extractLargeAsk",
|
||||
b"extractLargeActive",
|
||||
b"promptLargeMode",
|
||||
b"shouldPromptLargeMode",
|
||||
b"LARGE_FILE_THRESHOLD_BYTES",
|
||||
b"/api/extract",
|
||||
b"large",
|
||||
keys = [k.encode() for k in sys.argv[2:]] or [
|
||||
b"v1.9.3M", # assets/main.js footer fallback
|
||||
b"versionTooltip", # main.js + both lang files
|
||||
"本版为 LisherSong 改版".encode(), # assets/lang-zh.js
|
||||
b"Modified build by LisherSong", # assets/lang-en.js
|
||||
]
|
||||
seen = {}
|
||||
i = 0
|
||||
while i < len(data) - 10:
|
||||
if data[i] == 0x1f and data[i + 1] == 0x8b:
|
||||
# Try to decompress starting here, capped at 2 MiB.
|
||||
end_cap = min(len(data), i + 2 * 1024 * 1024)
|
||||
try:
|
||||
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
||||
chunk = dec.decompress(data[i:end_cap], 2 * 1024 * 1024)
|
||||
if not dec.eof:
|
||||
i += 1
|
||||
continue
|
||||
except Exception:
|
||||
i += 1
|
||||
continue
|
||||
# Check for any of our keys in the decompressed stream.
|
||||
for k in keys:
|
||||
if k in chunk and k not in seen:
|
||||
idx = chunk.find(k)
|
||||
ctx = chunk[max(0, idx - 24):idx + len(k) + 48]
|
||||
seen[k] = ctx.decode("utf-8", errors="replace")
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
print(f"decompressed streams scanned, keys found: {len(seen)} / {len(keys)}")
|
||||
seen = {}
|
||||
streams = 0
|
||||
i = data.find(b"\x1f\x8b\x08")
|
||||
while i >= 0:
|
||||
# Try to decompress starting here, capped at 2 MiB.
|
||||
end_cap = min(len(data), i + 2 * 1024 * 1024)
|
||||
try:
|
||||
dec = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
||||
chunk = dec.decompress(data[i:end_cap], 2 * 1024 * 1024)
|
||||
if dec.eof and chunk:
|
||||
streams += 1
|
||||
for k in keys:
|
||||
if k in chunk and k not in seen:
|
||||
idx = chunk.find(k)
|
||||
ctx = chunk[max(0, idx - 24):idx + len(k) + 48]
|
||||
seen[k] = ctx.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
i = data.find(b"\x1f\x8b\x08", i + 1)
|
||||
|
||||
print(f"{f}: {streams} gzip streams scanned, keys found: {len(seen)} / {len(keys)}")
|
||||
for k, v in seen.items():
|
||||
print(f" ✓ {k.decode()}")
|
||||
print(f" context: {v[:160]}")
|
||||
print(f" OK {k.decode()}")
|
||||
print(f" context: {v[:160]}")
|
||||
missing = [k for k in keys if k not in seen]
|
||||
if missing:
|
||||
print(f" ✗ not found: {[m.decode() for m in missing]}")
|
||||
for m in missing:
|
||||
print(f" MISS {m.decode()}")
|
||||
sys.exit(1 if missing else 0)
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Render the fork-vs-upstream comparison page, verify the filter logic actually
|
||||
filters, and shoot it for review.
|
||||
|
||||
Run: node .build/compare_html_check.mjs
|
||||
*/
|
||||
|
||||
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
|
||||
|
||||
const { chromium } = playwright;
|
||||
const URL =
|
||||
"file:///C:/Users/songl/Desktop/Web%20File%20Manager/ps5-wfm-fork-vs-upstream.html";
|
||||
const OUT = "C:/Users/songl/AppData/Local/Temp/readme-header";
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1320, height: 1100 },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
page.on("pageerror", e => console.log("pageerror: " + e.message));
|
||||
page.on("console", m => {
|
||||
if (m.type() === "error") console.log("console.error: " + m.text());
|
||||
});
|
||||
|
||||
await page.goto(URL, { waitUntil: "load" });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const counts = () =>
|
||||
page.evaluate(() => {
|
||||
const vis = sel => document.querySelectorAll(sel).length;
|
||||
const byKind = {};
|
||||
document.querySelectorAll("tbody tr").forEach(r => {
|
||||
const k = r.getAttribute("data-kind") || "common";
|
||||
byKind[k] = (byKind[k] || 0) + 1;
|
||||
});
|
||||
return {
|
||||
total: byKind.common + byKind.ours + byKind.theirs,
|
||||
byKind,
|
||||
visible: vis("tbody tr:not(.hide)"),
|
||||
visibleSections: vis("section[data-section]:not(.hide)"),
|
||||
chips: [...document.querySelectorAll(".filters button")].map(
|
||||
b => b.dataset.filter + "=" + b.querySelector(".count").textContent
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
console.log("initial:", JSON.stringify(await counts()));
|
||||
|
||||
for (const f of ["ours", "theirs", "diff", "all"]) {
|
||||
await page.click(`.filters button[data-filter="${f}"]`);
|
||||
await page.waitForTimeout(120);
|
||||
const c = await counts();
|
||||
console.log(
|
||||
`filter=${f.padEnd(6)} visible rows=${c.visible} visible sections=${c.visibleSections}`
|
||||
);
|
||||
}
|
||||
|
||||
await page.click('.filters button[data-filter="all"]');
|
||||
await page.waitForTimeout(150);
|
||||
await page.screenshot({ path: `${OUT}/compare-page.png`, fullPage: true });
|
||||
await page.click('.filters button[data-filter="diff"]');
|
||||
await page.waitForTimeout(150);
|
||||
await page.screenshot({ path: `${OUT}/compare-diff.png`, fullPage: true });
|
||||
|
||||
console.log("screenshots -> " + OUT);
|
||||
await browser.close();
|
||||
@@ -0,0 +1,103 @@
|
||||
// Verify the simplified comparison page: no external requests, no horizontal
|
||||
// overflow at narrow widths, expected row count, and that the two "winner"
|
||||
// columns carry readable contrast. Fails loudly (exit 1) on any problem.
|
||||
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
|
||||
|
||||
const FILE = "file:///C:/Users/songl/Desktop/Web File Manager/ps5-wfm-simple-compare.html";
|
||||
const browser = await playwright.chromium.launch();
|
||||
let fails = 0;
|
||||
const check = (ok, msg) => { console.log(`${ok ? " ok " : " FAIL "} ${msg}`); if (!ok) fails++; };
|
||||
|
||||
// ---- 1. desktop: offline + structure ----
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 900 } });
|
||||
const external = [];
|
||||
page.on("request", r => { if (!r.url().startsWith("file://")) external.push(r.url()); });
|
||||
page.on("requestfailed", r => external.push("FAILED " + r.url()));
|
||||
await page.goto(FILE, { waitUntil: "networkidle" });
|
||||
|
||||
const info = await page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll("tbody tr")];
|
||||
const cells = rows.map(r => [...r.children].map(td => td.innerText.replace(/\s+/g, " ").trim()));
|
||||
return {
|
||||
h1: document.querySelector("h1").innerText.trim(),
|
||||
rows: rows.length,
|
||||
cols: new Set(rows.map(r => r.children.length)).size,
|
||||
empty: cells.filter(c => c.some(t => t === "")).length,
|
||||
pick: document.querySelectorAll(".pick div").length,
|
||||
same: document.querySelectorAll("ul.same li").length,
|
||||
bodyH: document.body.scrollHeight,
|
||||
// per row: the verdict chip text in each of the two answer columns.
|
||||
// Read the DOM (.tag) rather than regex-matching a hardcoded word list,
|
||||
// so rewording a verdict never causes a false failure.
|
||||
tags: rows.map(r => [...r.children].slice(1).map(td => {
|
||||
const el = td.querySelector(".tag");
|
||||
return el ? el.innerText.trim() : "";
|
||||
})),
|
||||
};
|
||||
});
|
||||
check(external.length === 0, `no external requests (got ${external.length}${external.length ? ": " + external[0] : ""})`);
|
||||
check(info.rows === 10, `10 difference rows (got ${info.rows})`);
|
||||
check(info.cols === 1, "every row has the same column count");
|
||||
check(info.empty === 0, "no empty cells");
|
||||
check(info.pick === 2, "two 'which to pick' cards");
|
||||
check(info.same === 6, `6 'identical' bullet lines (got ${info.same})`);
|
||||
check(/两个版本/.test(info.h1), `heading present: ${info.h1}`);
|
||||
|
||||
// every row must have a verdict tag in both answer columns -> no row is silent
|
||||
const missing = info.tags.map((t, i) => (t[0] && t[1] ? null : `row${i + 1}:[${t}]`)).filter(Boolean);
|
||||
check(missing.length === 0, `both columns give a verdict on every row${missing.length ? " -> " + missing.join(" ") : ""}`);
|
||||
|
||||
// ---- 2. responsive: table on desktop, stacked cards on phones ----
|
||||
const BREAKPOINT = 700;
|
||||
for (const w of [1280, 768, 701, 700, 390, 320]) {
|
||||
const p = await browser.newPage({ viewport: { width: w, height: 900 } });
|
||||
await p.goto(FILE, { waitUntil: "load" });
|
||||
const r = await p.evaluate(() => {
|
||||
const de = document.documentElement;
|
||||
const tw = document.querySelector(".tw");
|
||||
const thead = document.querySelector("thead");
|
||||
const row = document.querySelector("tbody tr");
|
||||
const cell = document.querySelector("tbody td:nth-child(2)");
|
||||
const before = getComputedStyle(cell, "::before");
|
||||
return {
|
||||
pageOverflow: de.scrollWidth > de.clientWidth + 1,
|
||||
tableScrolls: tw.scrollWidth > tw.clientWidth,
|
||||
cardMode: getComputedStyle(thead).display === "none",
|
||||
rowDisplay: getComputedStyle(row).display,
|
||||
labelVisible: before.display !== "none" && before.content !== "none" && before.content !== "normal",
|
||||
usable: tw.clientWidth,
|
||||
tableMin: getComputedStyle(document.querySelector("table")).minWidth,
|
||||
};
|
||||
});
|
||||
const expectCards = w <= BREAKPOINT;
|
||||
check(!r.pageOverflow, `${w}px: no page-level horizontal overflow`);
|
||||
check(r.usable > 240, `${w}px: content area usable (${r.usable}px)`);
|
||||
check(r.cardMode === expectCards, `${w}px: ${expectCards ? "cards" : "table"} layout`);
|
||||
// Nothing should ever need sideways scrolling: cards reflow, and the table
|
||||
// only renders once the viewport can actually fit it.
|
||||
check(r.tableScrolls === false, `${w}px: no sideways scrolling (table min-width ${r.tableMin})`);
|
||||
check(
|
||||
r.labelVisible === expectCards,
|
||||
`${w}px: column labels ${expectCards ? "shown inside cards" : "hidden in table mode"}`
|
||||
);
|
||||
if (expectCards) {
|
||||
check(r.rowDisplay === "block", `${w}px: rows stack as blocks`);
|
||||
}
|
||||
await p.close();
|
||||
}
|
||||
|
||||
// ---- 3. screenshots ----
|
||||
const shots = [
|
||||
["wide", 1100, false],
|
||||
["narrow", 390, true],
|
||||
];
|
||||
for (const [label, width, full] of shots) {
|
||||
const p = await browser.newPage({ viewport: { width, height: 1000 }, deviceScaleFactor: 2 });
|
||||
await p.goto(FILE, { waitUntil: "load" });
|
||||
await p.screenshot({ path: `C:/Users/songl/AppData/Local/Temp/wfm-compare/simple-${label}.png`, fullPage: full });
|
||||
await p.close();
|
||||
}
|
||||
console.log(`\nscreenshots -> C:/Users/songl/AppData/Local/Temp/wfm-compare/simple-{wide,narrow}.png`);
|
||||
await browser.close();
|
||||
console.log(fails ? `\n${fails} FAILURES` : "\nall checks passed");
|
||||
process.exit(fails ? 1 : 0);
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebuild the offline preview under .build/preview/ from the real assets.
|
||||
|
||||
python .build/preview_build.py
|
||||
|
||||
Copies assets/* and re-injects .build/preview_stub.html into a COPY of
|
||||
index.html, so the page can be served from a plain static server
|
||||
(.build/preview_check.mjs drives it with a browser). assets/ is never touched.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = ROOT / "assets"
|
||||
DST = ROOT / ".build" / "preview"
|
||||
STUB = (ROOT / ".build" / "preview_stub.html").read_text(encoding="utf-8")
|
||||
MARKER = ' <script src="/main.js"></script>'
|
||||
|
||||
DST.mkdir(parents=True, exist_ok=True)
|
||||
for item in SRC.iterdir():
|
||||
if item.is_file():
|
||||
shutil.copy2(item, DST / item.name)
|
||||
|
||||
html = (DST / "index.html").read_text(encoding="utf-8")
|
||||
if MARKER not in html:
|
||||
raise SystemExit("index.html has no <script src=\"/main.js\"> tag to hook")
|
||||
(DST / "index.html").write_text(html.replace(MARKER, STUB + MARKER), encoding="utf-8")
|
||||
|
||||
print("preview ready:", DST)
|
||||
print("serve it with: python -m http.server 8899 --bind 127.0.0.1 (run inside .build/preview)")
|
||||
@@ -0,0 +1,267 @@
|
||||
/* Screenshot the toolbar out of the offline preview harness and report what the
|
||||
upload menu did, so the change can be looked at instead of trusted.
|
||||
|
||||
Run: node .build/preview_check.mjs
|
||||
*/
|
||||
|
||||
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
|
||||
|
||||
const { chromium } = playwright;
|
||||
|
||||
const URL = "http://127.0.0.1:8899/index.html";
|
||||
const out = [];
|
||||
const fails = [];
|
||||
function assert(cond, label) {
|
||||
out.push((cond ? " ok " : " FAIL ") + label);
|
||||
if (!cond) fails.push(label);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 720 }, locale: "zh-CN" });
|
||||
page.on("pageerror", err => out.push("pageerror: " + err.message));
|
||||
|
||||
await page.goto(URL, { waitUntil: "load" });
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const boxOf = selector => page.$eval(selector, el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
|
||||
});
|
||||
const hiddenOf = selector => page.$eval(selector, el => el.hidden);
|
||||
|
||||
out.push("menu hidden at rest: " + await hiddenOf("#uploadMenu"));
|
||||
out.push("hint hidden? " + await hiddenOf("#dropHint") + " text=" +
|
||||
await page.$eval("#dropHint", el => JSON.stringify(el.textContent)));
|
||||
out.push("button label: " + JSON.stringify(await page.$eval("#uploadBtn", el => el.textContent)));
|
||||
out.push("button box: " + JSON.stringify(await boxOf("#uploadBtn")));
|
||||
out.push("toolbar box (one row is 69px tall inside it): " + JSON.stringify(await boxOf(".toolbar")));
|
||||
out.push("footer box: " + JSON.stringify(await boxOf(".status")));
|
||||
out.push("hint box: " + JSON.stringify(await boxOf("#dropHint")));
|
||||
out.push("hint is between the status and the version: " +
|
||||
await page.evaluate(() => {
|
||||
const status = document.getElementById("statusText").getBoundingClientRect();
|
||||
const hint = document.getElementById("dropHint").getBoundingClientRect();
|
||||
const version = document.getElementById("versionText").getBoundingClientRect();
|
||||
return status.right <= hint.left && hint.right <= version.left;
|
||||
}));
|
||||
out.push("status text box: " + JSON.stringify(await boxOf("#statusText")));
|
||||
|
||||
await page.click("#uploadBtn");
|
||||
await page.waitForTimeout(250);
|
||||
out.push("menu hidden after click: " + await hiddenOf("#uploadMenu"));
|
||||
out.push("aria-expanded: " + await page.$eval("#uploadBtn", el => el.getAttribute("aria-expanded")));
|
||||
out.push("menu box: " + JSON.stringify(await boxOf("#uploadMenu")));
|
||||
out.push("items: " + JSON.stringify(await page.$$eval("#uploadMenu button",
|
||||
els => els.map(el => el.textContent))));
|
||||
out.push("focused: " + await page.evaluate(() => document.activeElement && document.activeElement.id));
|
||||
await page.screenshot({ path: ".build/preview/menu-open.png" });
|
||||
|
||||
/* The row highlight is a cascade question -- specificity, source order, and a
|
||||
generic rule that was never meant to reach a 46px list row -- so it can only
|
||||
be checked by a real engine reading back the computed values. */
|
||||
const rowStyle = sel => page.$eval(sel, el => {
|
||||
const cs = getComputedStyle(el);
|
||||
const panel = document.getElementById("uploadMenu").getBoundingClientRect();
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
background: cs.backgroundColor,
|
||||
outline: cs.outlineWidth + " " + cs.outlineStyle,
|
||||
outlineStyle: cs.outlineStyle,
|
||||
boxShadow: cs.boxShadow,
|
||||
bleeds: (r.left < panel.left) || (r.right > panel.right) ||
|
||||
(r.top < panel.top) || (r.bottom > panel.bottom),
|
||||
};
|
||||
});
|
||||
|
||||
const PANEL_FILL = "rgb(43, 52, 62)"; /* #2b343e -- the menu's own row fill */
|
||||
const TOOLBAR_HOVER = "rgb(48, 57, 69)"; /* #303945 -- the toolbar's hover fill */
|
||||
|
||||
const focusedRow = await rowStyle("#uploadFilesItem");
|
||||
out.push("a focused menu row carries no outer ring: " + (focusedRow.outlineStyle === "none"));
|
||||
out.push(" outline was: " + focusedRow.outline);
|
||||
out.push("a menu row never paints outside its panel: " + !focusedRow.bleeds);
|
||||
|
||||
await page.hover("#uploadFolderItem");
|
||||
await page.waitForTimeout(200);
|
||||
const hoveredRow = await rowStyle("#uploadFolderItem");
|
||||
out.push("a hovered row uses the menu fill, not the toolbar's: " + (hoveredRow.background === PANEL_FILL));
|
||||
out.push(" hover fill was: " + hoveredRow.background + " (the toolbar's would be " + TOOLBAR_HOVER + ")");
|
||||
out.push(" and it does not bleed outside the panel: " + !hoveredRow.bleeds);
|
||||
|
||||
/* Keyboard navigation is where the focus cue has to be visible, and it must be
|
||||
drawn inside the row so it cannot cross the panel edge. */
|
||||
await page.mouse.move(20, 700);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.waitForTimeout(200);
|
||||
const keyedRow = await rowStyle("#uploadFolderItem");
|
||||
out.push("ArrowDown moves the highlight to the second row: " +
|
||||
await page.evaluate(() => document.activeElement && document.activeElement.id));
|
||||
out.push(" the keyboard focus cue is an inset ring: " + /inset/.test(keyedRow.boxShadow));
|
||||
out.push(" its outline is still suppressed: " + (keyedRow.outlineStyle === "none"));
|
||||
out.push(" and the row stays inside the panel: " + !keyedRow.bleeds);
|
||||
out.push(" it is a single inset cue, not a stack of rings: " +
|
||||
((keyedRow.boxShadow.match(/inset/g) || []).length === 1));
|
||||
const menuClip = await page.$eval("#uploadMenu", el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: Math.max(0, r.x - 36), y: Math.max(0, r.y - 36), width: r.width + 72, height: r.height + 72 };
|
||||
});
|
||||
await page.screenshot({ path: ".build/preview/menu-highlight.png", clip: menuClip });
|
||||
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await page.waitForTimeout(150);
|
||||
out.push("ArrowUp wraps back to the first row: " +
|
||||
await page.evaluate(() => document.activeElement && document.activeElement.id));
|
||||
|
||||
/* the file entry must reach the hidden <input type=file> */
|
||||
await page.evaluate(() => {
|
||||
const input = document.getElementById("uploadFiles");
|
||||
window.__picked = false;
|
||||
input.addEventListener("click", event => { window.__picked = true; event.preventDefault(); });
|
||||
});
|
||||
await page.click("#uploadFilesItem");
|
||||
await page.waitForTimeout(200);
|
||||
out.push("file input was clicked: " + await page.evaluate(() => window.__picked));
|
||||
out.push("menu hidden after choosing: " + await hiddenOf("#uploadMenu"));
|
||||
|
||||
await page.click("#uploadBtn");
|
||||
await page.waitForTimeout(150);
|
||||
await page.mouse.click(640, 660);
|
||||
await page.waitForTimeout(150);
|
||||
out.push("menu hidden after an outside click: " + await hiddenOf("#uploadMenu"));
|
||||
|
||||
await page.click("#uploadBtn");
|
||||
await page.waitForTimeout(150);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(150);
|
||||
out.push("menu hidden after Escape: " + await hiddenOf("#uploadMenu"));
|
||||
|
||||
/* the wide layout: the button must not fall off the right edge */
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: ".build/preview/wide.png" });
|
||||
|
||||
/* the narrow layout, where the toolbar wraps */
|
||||
await page.setViewportSize({ width: 1024, height: 720 });
|
||||
await page.waitForTimeout(300);
|
||||
out.push("narrow button box: " + JSON.stringify(await boxOf("#uploadBtn")));
|
||||
out.push("narrow toolbar box: " + JSON.stringify(await boxOf(".toolbar")));
|
||||
out.push("narrow hint box: " + JSON.stringify(await boxOf("#dropHint")));
|
||||
out.push("narrow hint still fits the footer: " +
|
||||
await page.evaluate(() => {
|
||||
const footer = document.querySelector(".status").getBoundingClientRect();
|
||||
const hint = document.getElementById("dropHint").getBoundingClientRect();
|
||||
return hint.bottom <= footer.bottom + 1 && hint.right <= footer.right + 1;
|
||||
}));
|
||||
/* a long status must not squeeze the hint out of the footer */
|
||||
await page.evaluate(() => {
|
||||
document.getElementById("statusText").textContent =
|
||||
"正在上传 3/12: PPSA16608-2026-09-24-full-backup-part03.zip";
|
||||
});
|
||||
await page.waitForTimeout(150);
|
||||
out.push("with a long status, footer box: " + JSON.stringify(await boxOf(".status")));
|
||||
out.push(" hint box: " + JSON.stringify(await boxOf("#dropHint")));
|
||||
await page.screenshot({ path: ".build/preview/long-status.png" });
|
||||
await page.click("#uploadBtn");
|
||||
await page.waitForTimeout(250);
|
||||
out.push("narrow menu box: " + JSON.stringify(await boxOf("#uploadMenu")));
|
||||
out.push("narrow menu is inside the window: " +
|
||||
await page.$eval("#uploadMenu", el => el.getBoundingClientRect().right <= window.innerWidth + 1));
|
||||
await page.screenshot({ path: ".build/preview/narrow.png" });
|
||||
|
||||
/* ---- the extract button: always on screen, greyed until it can be used --- */
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
const toolbarHeight = () => page.$eval(".toolbar", el => Math.round(el.getBoundingClientRect().height));
|
||||
const toolbarFits = async widths => {
|
||||
const result = [];
|
||||
for (const width of widths) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.waitForTimeout(200);
|
||||
result.push(width + ":" + (await toolbarHeight()));
|
||||
}
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.waitForTimeout(200);
|
||||
return result;
|
||||
};
|
||||
|
||||
const extractState = () => page.$eval("#extractBtn", el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
const bar = document.querySelector(".toolbar").getBoundingClientRect();
|
||||
return {
|
||||
hidden: el.hidden,
|
||||
disabled: el.disabled,
|
||||
text: el.textContent,
|
||||
title: el.title,
|
||||
width: Math.round(r.width),
|
||||
opacity: cs.opacity,
|
||||
insideToolbar: r.top >= bar.top - 1 && r.bottom <= bar.bottom + 1,
|
||||
onScreen: r.right <= window.innerWidth + 1 && r.left >= -1,
|
||||
};
|
||||
});
|
||||
|
||||
const selectPaths = async paths => {
|
||||
await page.evaluate(() => {
|
||||
for (const box of document.querySelectorAll("#content .select-cell input")) {
|
||||
box.checked = false;
|
||||
box.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
for (const p of paths) {
|
||||
await page.evaluate(path => {
|
||||
const row = document.querySelector('#content tr[data-path="' + CSS.escape(path) + '"]');
|
||||
const box = row && row.querySelector(".select-cell input");
|
||||
if (!box) throw new Error("no checkbox for " + path);
|
||||
box.checked = true;
|
||||
box.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}, p);
|
||||
}
|
||||
await page.waitForTimeout(180);
|
||||
};
|
||||
|
||||
const rest = await extractState();
|
||||
out.push("extract button at rest: " + JSON.stringify(rest));
|
||||
assert(!rest.hidden, "the extract button is on screen with nothing selected");
|
||||
assert(rest.disabled, "and it is disabled");
|
||||
assert(rest.opacity !== "1", "and it is drawn dimmed (opacity " + rest.opacity + ")");
|
||||
assert(rest.insideToolbar && rest.onScreen, "and it sits inside the toolbar, on screen");
|
||||
assert(rest.title.length > 0, "and its tooltip explains why: " + JSON.stringify(rest.title));
|
||||
await page.screenshot({ path: ".build/preview/extract-disabled.png", clip: { x: 0, y: 54, width: 760, height: 90 } });
|
||||
await page.screenshot({ path: ".build/preview/extract-disabled-tight.png", clip: { x: 524, y: 60, width: 176, height: 78 } });
|
||||
|
||||
await selectPaths(["/saves"]);
|
||||
const onFolder = await extractState();
|
||||
assert(onFolder.disabled, "selecting a folder keeps it disabled");
|
||||
|
||||
await selectPaths(["/PPSA16608.zip"]);
|
||||
const onArchive = await extractState();
|
||||
assert(!onArchive.disabled, "selecting one archive enables it");
|
||||
assert(onArchive.opacity === "1", "and it becomes fully opaque");
|
||||
assert(onArchive.title.indexOf("PPSA16608.zip") >= 0, "and the tooltip names the archive: " + JSON.stringify(onArchive.title));
|
||||
await page.screenshot({ path: ".build/preview/extract-enabled.png", clip: { x: 0, y: 54, width: 760, height: 90 } });
|
||||
await page.screenshot({ path: ".build/preview/extract-enabled-tight.png", clip: { x: 524, y: 60, width: 176, height: 78 } });
|
||||
|
||||
await selectPaths(["/PPSA16608.zip", "/\u6e38\u620f\u5907\u4efd.7z"]);
|
||||
const onTwo = await extractState();
|
||||
assert(onTwo.disabled, "selecting two archives disables it again");
|
||||
assert(onTwo.title.indexOf("\u4e00\u6b21\u53ea\u80fd") >= 0, "with its own message: " + JSON.stringify(onTwo.title));
|
||||
|
||||
await selectPaths([]);
|
||||
|
||||
/* Keeping the button on screen widens the resting toolbar by its own width, so
|
||||
the wrap point has to be pinned. Measured in the zh locale, which is the one
|
||||
the console runs: it moved from 1080px to 1190px when the button stopped
|
||||
being hidden. The English labels are wider and that build wraps 1280px. */
|
||||
const fits = await toolbarFits([1920, 1600, 1280]);
|
||||
out.push("toolbar height at 1920/1600/1280 (one row is 85): " + fits.join(" "));
|
||||
assert(fits.every(entry => Number(entry.split(":")[1]) < 100),
|
||||
"the toolbar still fits on one row at every realistic console width");
|
||||
|
||||
await browser.close();
|
||||
console.log(out.join("\n"));
|
||||
console.log(fails.length
|
||||
? "\n" + fails.length + " FAILED:\n " + fails.join("\n ")
|
||||
: "\nall " + out.filter(line => line.indexOf(" ok ") === 0).length + " assertions passed");
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -0,0 +1,48 @@
|
||||
<script>
|
||||
/* Offline fixture: every /api call the page makes is answered here. Injected
|
||||
into a copy of index.html by .build/preview_build.py, just before the real
|
||||
script tag, so the toolbar can be rendered without a console. */
|
||||
(function () {
|
||||
var now = Math.floor(Date.now() / 1000);
|
||||
var dirs = {
|
||||
"/": [
|
||||
{ name: "PPSA16608.zip", type: "-", mode: 420, size: 3980000000, mtime: now - 3600 },
|
||||
{ name: "\u6e38\u620f\u5907\u4efd.7z", type: "-", mode: 420, size: 12345678, mtime: now - 7200 },
|
||||
{ name: "saves", type: "d", mode: 493, size: 0, mtime: now - 86400 }
|
||||
]
|
||||
};
|
||||
function json(payload) {
|
||||
return Promise.resolve(new Response(JSON.stringify(payload), {
|
||||
status: 200, headers: { "Content-Type": "application/json" }
|
||||
}));
|
||||
}
|
||||
window.fetch = function (url) {
|
||||
var target = String(url);
|
||||
var query = target.split("?")[1] || "";
|
||||
var path = "/";
|
||||
query.split("&").forEach(function (kv) {
|
||||
var pair = kv.split("=");
|
||||
if (pair[0] === "path") path = decodeURIComponent(pair[1] || "/");
|
||||
});
|
||||
if (target.indexOf("/api/list") === 0) {
|
||||
var entries = (dirs[path] || []).map(function (entry) {
|
||||
return Object.assign({}, entry, { path: (path === "/" ? "" : path) + "/" + entry.name });
|
||||
});
|
||||
return json({ ok: true, path: path, parent: "/", entries: entries });
|
||||
}
|
||||
if (target.indexOf("/api/tasks") === 0) {
|
||||
return json({ ok: true, tasks: [], completion: null, now: now });
|
||||
}
|
||||
if (target.indexOf("/api/space") === 0) {
|
||||
return json({ ok: true, mounts: [
|
||||
{ label_key: "storageRoot", path: "/", free: 812000000000, total: 2000000000000, current: true }
|
||||
] });
|
||||
}
|
||||
if (target.indexOf("/api/version") === 0) {
|
||||
return json({ ok: true, version: "v1.9.3M", title_id: "FMGR88888" });
|
||||
}
|
||||
if (target.indexOf("/api/") === 0) return json({ ok: true });
|
||||
return new Promise(function () {});
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,194 @@
|
||||
// Headless check for ps5-nas-rewrite-proposal.html
|
||||
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
|
||||
import fs from "node:fs";
|
||||
|
||||
const FILE = "file:///C:/Users/songl/Desktop/Web File Manager/ps5-nas-rewrite-proposal.html";
|
||||
const OUT = "C:/Users/songl/AppData/Local/Temp/wfm-proposal";
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
let fails = 0;
|
||||
const check = (ok, name) => { console.log((ok ? "PASS" : "FAIL") + " " + name); if (!ok) fails++; };
|
||||
|
||||
const browser = await playwright.chromium.launch();
|
||||
|
||||
// external request guard
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 900 } });
|
||||
const ext = [];
|
||||
page.on("request", r => { if (!r.url().startsWith("file://")) ext.push(r.url()); });
|
||||
await page.goto(FILE, { waitUntil: "load" });
|
||||
await page.waitForTimeout(300);
|
||||
check(ext.length === 0, `zero external requests (${ext.length})`);
|
||||
|
||||
const r = await page.evaluate(() => ({
|
||||
pageOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||
tables: document.querySelectorAll("table").length,
|
||||
phases: document.querySelectorAll(".phase").length,
|
||||
h2: [...document.querySelectorAll("h2")].map(h => h.textContent.trim()),
|
||||
bars: [...document.querySelectorAll(".speedbar .bar")].map(b => b.style.width),
|
||||
widestTable: Math.max(0, ...[...document.querySelectorAll("table")].map(t => Math.round(t.getBoundingClientRect().width))),
|
||||
body: document.body.textContent,
|
||||
}));
|
||||
check(!r.pageOverflow, "wide: no horizontal overflow");
|
||||
// Named, so the failure message points at the root cause instead of "page overflows".
|
||||
// TRAP (hit 2026-09-27): the stylesheet has a global `td:first-child{white-space:nowrap}`.
|
||||
// A wide table's first cell is therefore pinned to one line, and a `td[colspan]` full of
|
||||
// prose counts as `:first-child` too — one such row pushed a table to 2085px inside a
|
||||
// 912px column, which is a 2265px single line. Fix by removing the conflict (move prose
|
||||
// out of the table / use <br>), NOT by raising specificity or adding nowrap overrides.
|
||||
const WRAP = 960; // .wrap{max-width:960px}
|
||||
check(r.widestTable <= WRAP, `no table exceeds the 960px content column (widest ${r.widestTable}px)`);
|
||||
check(r.tables === 15, `15 tables present (${r.tables})`);
|
||||
// the "package name" derivation spec must survive edits (2026-09-26 round 9): the new
|
||||
// subdir name strips the WHOLE archive suffix, so a target like …-app.rar/ must never return
|
||||
for (const key of ["剥掉的整段后缀", "整段后缀匹配", "part01.rar", "isRarSubVolume", "回退用完整文件名"]) {
|
||||
check(r.body.includes(key), `subdir-name spec present: ${key}`);
|
||||
}
|
||||
check(r.phases === 6, `6 phase cards present (${r.phases})`);
|
||||
check(r.bars.length === 12, `12 speed bars (${r.bars.length})`);
|
||||
// save-manager capability domain must survive edits (section 2-2 / 6 / 7-Phase5)
|
||||
for (const key of ["garlic-savemgr", "/dev/pfsmgr", "sceFsMountSaveData", "存档管理", "Phase 5", "重签"]) {
|
||||
check(r.body.includes(key), `save-mgr content present: ${key}`);
|
||||
}
|
||||
// install-layer dependency must stay corrected: kstuff, NOT etaHEN (2026-09-26 round 6)
|
||||
for (const key of ["kstuff", "VoidShell", "elf-arsenal", "/proc/kstuff", "autoload.txt", "没安装过 etaHEN"]) {
|
||||
check(r.body.includes(key), `install-layer/competitor content present: ${key}`);
|
||||
}
|
||||
// singleDPI / "extract DPI, don't depend on etaHEN" (2026-09-26 round 7). These pin the
|
||||
// load-bearing facts: the GPL-3.0 reuse right, the real AuthID, the three-part readiness
|
||||
// probe, and the MetaInfo 0x30 ABI correction. Losing any of them silently guts the plan.
|
||||
for (const key of [
|
||||
"singleDPI", "ps5-direct-package-installer",
|
||||
"GPL-3.0-or-later", "NOTICE", // code may be reused, with attribution
|
||||
"DEBUG_AUTHID", "0x4800000000000006", // the AuthID that actually works (code, not docs)
|
||||
"kernel_set_ucred_authid", "kernel_sys", // self-elevation we currently lack
|
||||
"sceAppInstUtilGetInstallStatus", // status polling we currently lack
|
||||
"0x2700", "0x30", "is_playgo_enabled", // MetaInfo 8-field -> 6-field correction
|
||||
"Access-Control-Allow-Origin", // DPI v2 is browser-reachable (no app needed)
|
||||
]) {
|
||||
check(r.body.includes(key), `singleDPI content present: ${key}`);
|
||||
}
|
||||
// falsified claims may only survive as QUOTED corrections, never as live claims
|
||||
const quotedOnly = (claim, allowRe, label) => {
|
||||
let i = r.body.indexOf(claim), live = false;
|
||||
while (i !== -1) {
|
||||
const ctx = r.body.slice(Math.max(0, i - 160), i + 160);
|
||||
if (!allowRe.test(ctx)) live = true;
|
||||
i = r.body.indexOf(claim, i + 1);
|
||||
}
|
||||
check(!live, label);
|
||||
};
|
||||
quotedOnly("事实标准就是 etaHEN", /作废|修正|已删除/, "no unqualified 'etaHEN is the de-facto HEN' claim survives");
|
||||
quotedOnly("SDK 自动给", /收回|推翻|修正/, "no unqualified 'the SDK grants the permission' claim survives");
|
||||
check(r.body.includes("SDK 给不了"), "the corrected ShellCore-permission statement is present");
|
||||
quotedOnly("-app.rar/", /指出|修正|原型里写成了/, "no live '…-app.rar/' subdir target survives");
|
||||
// save-writeback safety (2026-09-27 round 8): "forced snapshot before write-back" was
|
||||
// verified to be a Vacuum — enumerate the implementation set by fingerprinting the one
|
||||
// system call every write-back must use, then read each write path. These keys keep the
|
||||
// differentiator and its evidence alive; losing them silently downgrades us to
|
||||
// "another save manager", which is exactly what the plan decided not to be.
|
||||
for (const key of [
|
||||
"写回前强制留快照", // the question this round answered
|
||||
"sceFsCreatePfsSaveDataImage", // the fingerprint used to enumerate implementations
|
||||
"savescum", "apollo-ps4", // the two closest competitors: both non-forcing
|
||||
"O_TRUNC", // garlic's in-place truncating write-back
|
||||
"save_periodic_cleanup", // the cleanup that deletes garlic's own copy
|
||||
"/data/savesnap/", // where OUR snapshots must live (independent dir)
|
||||
"强制且不可跳过", "失败自动回滚", // the two load-bearing hard rules
|
||||
]) {
|
||||
check(r.body.includes(key), `save-writeback content present: ${key}`);
|
||||
}
|
||||
// UI hard rules (2026-09-27 round 9). PS5's viewport SHAPE (wide, short) is what forces
|
||||
// the top nav — not taste. And the library cover must come from INSIDE the pkg, not from
|
||||
// a scraper. Lose these and the decisions the user already made get re-litigated.
|
||||
for (const key of [
|
||||
"kstuff 启动", // the status wording the user picked (was "在位")
|
||||
"1920×970", // the PS5 viewport shape that forces the top nav
|
||||
"顶部单行吸顶", // nav placement rule
|
||||
"sce_sys/icon0.png", // where covers actually come from
|
||||
".covers/", // the runtime cover cache
|
||||
"抽不到必须回退", // no blank holes in the cover grid
|
||||
"底部操作日志终端", // the Garlic-derived save-page skeleton
|
||||
"Decrypt / Encrypt / Resign / Import", // and the part we deliberately do NOT copy
|
||||
]) {
|
||||
check(r.body.includes(key), `UI rule content present: ${key}`);
|
||||
}
|
||||
// local-address rule (2026-09-27 round 10). The address is a FUNCTIONAL entry point
|
||||
// (how you open this same UI from a PC/phone), not decoration — and PS5 has no
|
||||
// `ipconfig`, so the UI is the only place it can come from. These keys pin the two
|
||||
// implementation constraints that are easy to "simplify away" later and would then be
|
||||
// quietly wrong: the port is probed at runtime, and clipboard is unavailable over plain
|
||||
// http on a LAN IP.
|
||||
for (const key of [
|
||||
"本机地址", // the rule itself
|
||||
"find_available_port", // the port is NOT a constant (8888 gets bumped)
|
||||
"navigator.clipboard", // the API that does not exist in this deployment
|
||||
"execCommand", // ...so the fallback must stay
|
||||
"局域网地址而非回环", // 127.0.0.1 is useless on the OTHER device
|
||||
"::after", // hit area is expanded separately from visual height
|
||||
"IP:PORT", // the accepted shape
|
||||
"98 项", // acceptance count (was 209; B/C/D retired in round 11)
|
||||
]) {
|
||||
check(r.body.includes(key), `local-address rule present: ${key}`);
|
||||
}
|
||||
// the install-layer AuthID was mis-stated in section 6 until this round; it may only
|
||||
// survive as a quoted correction, never as a live claim
|
||||
quotedOnly("0x3800000000000010", /0 命中|作废|误记|误写|上一轮/, "no live '0x3800000000000010' AuthID survives");
|
||||
// naming / port / status wording / motion family (2026-09-27 round 11). The project is
|
||||
// `PS5 Nexus` on port 2026; the status strip lists ONLY running services (never a
|
||||
// third-party bundle's install state -- we depend on the kstuff leaf, not on a packager);
|
||||
// and motion is a deliberate FAMILY (running progress bar sweep / breathing LEDs / CTA
|
||||
// sheen) rather than scattered decoration. Losing any of these silently re-opens
|
||||
// decisions the user already made.
|
||||
for (const key of [
|
||||
"PS5 Nexus", // the project name replacing "文件与安装中心"
|
||||
"ps5-nexus", // repo / ELF name
|
||||
"默认 2026", // the new default port (was 8888).
|
||||
// NB: assert on rendered text, not markup — `textContent` strips tags, so a key like
|
||||
// "<code>2026</code>" can never match and would fail forever without anyone noticing.
|
||||
"状态区只列「正在跑的服务」", // the status-strip rule
|
||||
"打包发行版", // ...and what must NOT be listed there
|
||||
"动效要克制", // the motion-family rule
|
||||
"var(--pri-fg)", // inverted-button sheen must use the fg colour
|
||||
"首尾同色", // seamless loop for the sweep gradient
|
||||
]) {
|
||||
check(r.body.includes(key), `naming/status/motion content present: ${key}`);
|
||||
}
|
||||
// 8888 may only survive as a quoted "we changed it" note, never as the live default
|
||||
quotedOnly("默认 <code>8888</code>", /第六轮|改过来|避开|旧仓/, "no live '8888 is the default port' claim survives");
|
||||
console.log(" sections: " + r.h2.join(" | "));
|
||||
await page.screenshot({ path: OUT + "/proposal-wide.png", fullPage: true });
|
||||
await page.close();
|
||||
|
||||
// narrow
|
||||
const np = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await np.goto(FILE, { waitUntil: "load" });
|
||||
const nr = await np.evaluate(() => {
|
||||
const de = document.documentElement;
|
||||
const cw = de.clientWidth;
|
||||
// NOTE: `table{overflow:hidden}` makes scrollWidth == clientWidth, so the old
|
||||
// scrollWidth test could never fire. Measure the box against the viewport, and
|
||||
// ignore tables that sit inside a horizontal scroll container (the .dectable
|
||||
// convention) — those are allowed to be wide.
|
||||
const clipped = el => {
|
||||
for (let a = el.parentElement; a && a !== de; a = a.parentElement) {
|
||||
const ox = getComputedStyle(a).overflowX;
|
||||
if (ox === "auto" || ox === "scroll" || ox === "hidden") return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const wide = [...document.querySelectorAll("table")]
|
||||
.filter(t => t.getBoundingClientRect().right > cw + 1 && !clipped(t));
|
||||
return {
|
||||
pageOverflow: de.scrollWidth > cw + 1,
|
||||
wideTables: wide.length,
|
||||
which: wide.map(t => `${t.className || "no-class"}:${Math.round(t.getBoundingClientRect().width)}px`),
|
||||
};
|
||||
});
|
||||
check(!nr.pageOverflow, "390px: no page-level overflow");
|
||||
check(nr.wideTables === 0, `390px: no unclipped wide table (${nr.wideTables}) ${nr.which.join(" ")}`);
|
||||
await np.screenshot({ path: OUT + "/proposal-narrow.png", fullPage: true });
|
||||
await np.close();
|
||||
|
||||
await browser.close();
|
||||
console.log(fails ? `\n${fails} FAILURE(S)` : "\nALL CHECKS PASSED");
|
||||
process.exit(fails ? 1 : 0);
|
||||
@@ -0,0 +1,114 @@
|
||||
/* Render the README header the way GitHub would, with the real shields.io
|
||||
images, and report whether every badge actually loaded.
|
||||
|
||||
Run: node .build/readme_header_render.mjs
|
||||
*/
|
||||
|
||||
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const { chromium } = playwright;
|
||||
const REPO = "C:/Users/songl/Desktop/Web File Manager/ps5-web-file-manager";
|
||||
const OUT = "C:/Users/songl/AppData/Local/Temp/readme-header";
|
||||
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
function extract(file) {
|
||||
const text = fs.readFileSync(path.join(REPO, file), "utf8");
|
||||
const div = (text.match(/<div align="right">[\s\S]*?<\/div>/) || [""])[0];
|
||||
const ps = text.match(/<p align="center">[\s\S]*?<\/p>/g) || [];
|
||||
const h1 = (text.match(/^# (.+)$/m) || [, ""])[1];
|
||||
const quote = (text.match(/^(?:> .*\n)+/m) || [""])[0]
|
||||
.split(/\r?\n/).filter(Boolean).map(l => l.replace(/^> ?/, "")).join(" ");
|
||||
return { div, ps, h1, quote, file };
|
||||
}
|
||||
|
||||
function page_html(d) {
|
||||
return `<!doctype html><meta charset="utf-8">
|
||||
<style>
|
||||
body{font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,"Microsoft YaHei",sans-serif;
|
||||
color:#1f2328;background:#fff;margin:0;padding:32px;max-width:1012px}
|
||||
h1{font-size:2em;font-weight:600;border-bottom:1px solid #d1d9e0;padding-bottom:.3em;margin:.67em 0}
|
||||
blockquote{margin:0 0 16px;padding:0 1em;color:#59636e;border-left:.25em solid #d1d9e0}
|
||||
p img{vertical-align:middle}
|
||||
</style>
|
||||
${d.div}
|
||||
<h1>${d.h1}</h1>
|
||||
${d.ps.join("\n")}
|
||||
<blockquote>${d.quote}</blockquote>`;
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const report = [];
|
||||
|
||||
for (const file of ["README.md", "README.zh-CN.md"]) {
|
||||
const d = extract(file);
|
||||
for (const [label, width] of [["wide", 1280], ["narrow", 420]]) {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: 700 },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
await page.setContent(page_html(d), { waitUntil: "load" });
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => [...document.images].every(i => i.complete),
|
||||
null,
|
||||
{ timeout: 20000 }
|
||||
)
|
||||
.catch(() => {});
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const imgs = await page.$$eval("img", els =>
|
||||
els.map(e => ({
|
||||
src: e.currentSrc || e.src,
|
||||
w: e.naturalWidth,
|
||||
h: e.naturalHeight,
|
||||
alt: e.alt,
|
||||
box: Math.round(e.getBoundingClientRect().width),
|
||||
}))
|
||||
);
|
||||
const ok = imgs.filter(i => i.w > 0).length;
|
||||
report.push(`--- ${file} @${label}(${width}px): ${ok}/${imgs.length} badges loaded`);
|
||||
for (const i of imgs) {
|
||||
const tail = i.src.split("/").slice(-2).join("/");
|
||||
report.push(` ${i.w}x${i.h} ${String(i.box).padStart(4)}px ${tail}`);
|
||||
}
|
||||
const overflow = await page.evaluate(() => document.body.scrollWidth > window.innerWidth);
|
||||
report.push(` horizontal overflow: ${overflow}`);
|
||||
|
||||
await page.screenshot({
|
||||
path: path.join(OUT, `${file.replace(/[^\w.]/g, "_")}.${label}.png`),
|
||||
fullPage: label === "narrow",
|
||||
});
|
||||
await page.close();
|
||||
|
||||
if (label === "wide") {
|
||||
// 5x zoom of the badge row — at 1x the colours are guesswork
|
||||
const zoom = await browser.newPage({
|
||||
viewport: { width: 640, height: 48 },
|
||||
deviceScaleFactor: 5,
|
||||
});
|
||||
await zoom.setContent(
|
||||
`<body style="margin:0;background:#fff">${d.ps[0]}</body>`,
|
||||
{ waitUntil: "load" }
|
||||
);
|
||||
await zoom
|
||||
.waitForFunction(
|
||||
() => [...document.images].every(i => i.complete && i.naturalWidth > 0),
|
||||
null,
|
||||
{ timeout: 20000 }
|
||||
)
|
||||
.catch(() => {});
|
||||
await zoom.waitForTimeout(300);
|
||||
await zoom.screenshot({
|
||||
path: path.join(OUT, `${file.replace(/[^\w.]/g, "_")}.badges-zoom.png`),
|
||||
});
|
||||
await zoom.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(report.join("\n"));
|
||||
console.log("screenshots -> " + OUT);
|
||||
@@ -0,0 +1,495 @@
|
||||
// ============================================================
|
||||
// UI 风格 demo 校验 + 截图 + 对比页生成
|
||||
// 跑法: node .build/ui_demos_check.mjs
|
||||
// 检查对象:工作区根的 ps5-ui-demo-{1,5}-*.html(这些 HTML 本身不入库;
|
||||
// 2026-09-27 起 B/C/D 三个候选已归档到工作区根的 _retired-demos/)
|
||||
//
|
||||
// 为什么这个脚本值得存在:
|
||||
// ① 溢出:transform 移出视口的抽屉会撑开 documentElement 滚动区,
|
||||
// 静态检查抓不住,只有真引擎能量出来(本次真实踩过 404px)。
|
||||
// ② 对比度:fg3 #737b8c on #0e1014 = 4.43:1,低于 4.5 阈值 —— 肉眼看不出来。
|
||||
// ③ 焦点环:密集列表里全局 outline 会压住邻行,必须验「行内 inset 环」。
|
||||
// ④ 禁用按钮:button:disabled 带 pointer-events:none ⇒ title 永远弹不出来。
|
||||
// ⑤ 宽度档不能只挑整数:1100 这种「不整不齐」的窗口宽度才是真实现场 ——
|
||||
// 风格 C 的顶栏正是在 1100 溢出 24px,而 1280 与 390 两档都给绿。
|
||||
// ⑥ 命中区与视觉高度是两件事:控件可以只画 24px 高(风格 B/C 的底栏就这么高),
|
||||
// 但 PS5 是触摸板光标 ⇒ 命中区必须 ≥40px,得用 ::after 单独撑,断言也要单独量。
|
||||
// ============================================================
|
||||
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ROOT = "C:/Users/songl/Desktop/Web File Manager";
|
||||
const OUT = "C:/Users/songl/AppData/Local/Temp/wfm-ui";
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
// ⚠️ 2026-09-27 第五轮:候选收敛到 A 与 E 两条,B/C/D 已归档到工作区根的 _retired-demos/。
|
||||
// 删掉它们的同时也删掉对应断言 —— 为已经出局的风格维持检查,成本远大于收益。
|
||||
const DEMOS = [
|
||||
{ key: "demo1", file: "ps5-ui-demo-1-console.html", name: "风格 A · 主机大厅" },
|
||||
{ key: "demo5", file: "ps5-ui-demo-5-harness.html", name: "风格 E · Harness 开发者页" },
|
||||
];
|
||||
|
||||
// 备用主题方向:只剩 demo5 是双主题(默认暗,忠于站点)→ 切亮再验一遍。
|
||||
const ALT_THEME = { demo5: "light" };
|
||||
|
||||
// 每个 demo 各自的对比度采样点:正文 / 次要文字 / 强调文字 / 有底色的提示条
|
||||
const CONTRAST_TARGETS = {
|
||||
demo1: [["h1", "大标题"], [".crumb", "次要说明"], [".row .meta", "行内数字"], [".chip", "状态胶囊"], [".banner", "提示条"], [".ipchip .ipv", "本机地址"]],
|
||||
// demo5 的采样点刻意跨了「页面底 / 卡片底 / 强调底」三种底色,
|
||||
// 因为这套语言全靠近黑底 + 极低对比叠层,底色一变就容易掉出阈值。
|
||||
// ⚠️ 六处全部落在**默认视图**内:视图化之后其余视图是 display:none,
|
||||
// getComputedStyle 仍读得出颜色,但「量一个看不见的元素」没有意义。
|
||||
demo5: [[".kicker", "大写分区标签"], ["#heroSub", "Hero 副标题"], [".card p", "卡片正文"], [".facts .pill.n", "状态胶囊"], [".note.info", "品牌色提示条"], [".ipchip .ipv", "本机地址"]],
|
||||
};
|
||||
|
||||
let fails = 0, total = 0;
|
||||
/* ⚠️ total 不能省:文档里「N 项断言」这个数字上一轮是靠人 grep 输出数出来的,结果多报了一项
|
||||
(文档写 99、实际 98 —— HEAD 版脚本复跑同样是 98)。现在每次运行末尾直接打印总数,
|
||||
改文档时照抄,不再靠数。 */
|
||||
const check = (ok, name) => { total++; console.log((ok ? " PASS " : " FAIL ") + name); if (!ok) fails++; };
|
||||
/* 逐 demo 的回归钉:只验「这个 demo 该有的东西」,不硬套到别的 demo 上。
|
||||
⚠️ 必须是**字符串形式**的箭头函数:playwright 的 evaluate 只会序列化普通值,
|
||||
把函数数组直接当参数传,会在序列化阶段就抛
|
||||
"Attempting to serialize unexpected value"(本次真踩过)。 */
|
||||
const EXTRA = {
|
||||
demo1: [
|
||||
["三盏状态绿灯与地址都在吸顶导航条里,且状态区不列第三方打包发行版",
|
||||
`() => { const rail = document.querySelector(".toprail"); if (!rail) return false;
|
||||
const r = rail.getBoundingClientRect();
|
||||
const items = [...rail.querySelectorAll(".led, .chip")].filter(e => /kstuff|HTTP|SMB|\\d+\\.\\d+/.test(e.textContent));
|
||||
if (items.length < 4) return false; // 三盏灯 + 地址胶囊
|
||||
const t = rail.textContent;
|
||||
if (!/kstuff/.test(t) || !/HTTP/.test(t) || !/SMB/.test(t)) return false;
|
||||
if (/etaHEN/.test(t)) return false; // 状态区只列正在跑的服务
|
||||
return items.every(e => { const b = e.getBoundingClientRect();
|
||||
return b.top >= r.top - 1 && b.bottom <= r.bottom + 1; }); }`],
|
||||
["导航改到顶部后触控目标仍 ≥44px(PS5 用触摸板光标)",
|
||||
`() => [...document.querySelectorAll(".nav button")].every(b => b.getBoundingClientRect().height >= 44)`],
|
||||
["导航不再产生左侧竖栏(横向空间全让给内容)",
|
||||
`() => { const r = document.querySelector("nav").getBoundingClientRect();
|
||||
return r.width > 900 && r.height <= 80; }`],
|
||||
],
|
||||
demo5: [
|
||||
["状态区只列正在跑的服务,不列第三方打包发行版",
|
||||
`() => { const el = document.querySelector(".topbar"); if (!el) return false;
|
||||
const t = el.textContent;
|
||||
return /kstuff/.test(t) && /HTTP/.test(t) && /SMB/.test(t) && !/etaHEN|未安装/.test(t); }`],
|
||||
["扫光只给正在运行的条:大卡与「进度」列都在闪,排队行与容量条不闪",
|
||||
`() => {
|
||||
/* ⚠️ 断言必须同时要求「可见」。computed style 在 display:none 的子树上**照样读得到**
|
||||
(animationName 仍是 "sh"),所以老版本只验「动画名不是 none」时,元素根本看不见也会
|
||||
全绿 —— 假通过。.track.pulse 住在 #view-tasks 里,默认(概览)视图下它就是
|
||||
display:none;2026-09-27 用户报「demo5 的进度条闪光效果没有」,根因有两层:
|
||||
⚠️ 这些检查体是**模板字面量**,里面连注释都不能出现反引号 —— 会把字符串提前截断,
|
||||
报成「Cannot read properties of undefined」。全角引号、角括号都可以,反引号不行。
|
||||
另一层根因是「同一屏、同一个任务,大卡在闪而表格那一列『进度』不闪」。
|
||||
一层是这条断言测不到可见性,另一层是「同一屏同一个任务,大卡在闪、表格『进度』列不闪」。
|
||||
⚠️ 下面切视图的代码是**纯同步**的(无 await),所以在 Promise.all 里是原子的:
|
||||
其它检查不可能观察到切走/切回的中间态。将来若给它加 await,必须改成串行或加锁。 */
|
||||
const prev = document.querySelector(".view.on");
|
||||
const prevId = prev ? prev.id : null;
|
||||
document.querySelectorAll(".view").forEach(v => v.classList.toggle("on", v.id === "view-tasks"));
|
||||
const visEl = el => !!el && el.offsetWidth > 0 && el.offsetHeight > 0;
|
||||
const fillOf = el => (el ? el.querySelector("i") : null);
|
||||
/* 「在闪」= 填充条真的有宽度 + 动画名不是 none。两条都要:
|
||||
只有动画名会让「元素根本看不见」的条也判成在闪(就是这条断言上次假通过的原因)。 */
|
||||
const sweeps = el => { const f = fillOf(el);
|
||||
return visEl(f) && getComputedStyle(f).animationName !== "none"; };
|
||||
/* 「不该闪」量的是轨道可见性 + 填充的动画名 —— 不要要求填充条自己可见:
|
||||
排队/已完成的填充宽度是 0(甚至没有填色),那属于「没在跑」,不是「没渲染」。 */
|
||||
const still = el => visEl(el) && getComputedStyle(fillOf(el)).animationName === "none";
|
||||
const bars = [...document.querySelectorAll(".row .bar")];
|
||||
const live = bars.find(b => b.hasAttribute("data-p")); // 「进度」列:JS 在推的那条
|
||||
const queued = bars.find(b => !b.hasAttribute("data-p")); // 排队/已完成:不该闪
|
||||
const ok = sweeps(document.querySelector(".track.pulse")) && sweeps(live) &&
|
||||
still(queued) && still(document.querySelector(".meter .bar"));
|
||||
document.querySelectorAll(".view").forEach(v => v.classList.toggle("on", v.id === prevId));
|
||||
return ok; }`],
|
||||
["游戏页有封面网格(≥6 张封面)",
|
||||
`() => document.querySelectorAll("#view-library .gcard .cover").length >= 6`],
|
||||
["封面有「抽不到 icon0.png」的回退态(一排卡片里不留空洞)",
|
||||
`() => !!document.querySelector("#view-library .cover.fb")`],
|
||||
["封面有加密锁定态(需要口令的包也得有封面)",
|
||||
`() => !!document.querySelector("#view-library .cover.locked")`],
|
||||
["筛选控件真会筛,不是只换按下态",
|
||||
`() => { const s = document.getElementById("view-library");
|
||||
const segs = s.querySelectorAll(".seg"); if (segs.length < 2) return false;
|
||||
const vis = () => [...s.querySelectorAll(".gcard")].filter(c => !c.hidden).length;
|
||||
const before = vis();
|
||||
segs[1].querySelectorAll("button")[2].click(); // 需口令
|
||||
const after = vis();
|
||||
segs[1].querySelectorAll("button")[0].click(); // 切回「在盘上」
|
||||
return before === 6 && after === 1 && vis() === 6; }`],
|
||||
["存档页有快照列(这是要卖的差异化,必须看得见)",
|
||||
`() => /快照/.test((document.getElementById("view-saves") || {}).textContent || "")`],
|
||||
["存档页有操作日志终端(借自 Garlic 的 TERMINAL 面板)",
|
||||
`() => !!document.querySelector("#view-saves .console .lines li")`],
|
||||
["存档页空状态可来回切换(空态不是留白,是真会出现的状态)",
|
||||
`() => { const d = document.getElementById("svDetail"), e = document.getElementById("svEmpty"),
|
||||
c = document.getElementById("svClose"), it = document.querySelector("#view-saves .svi");
|
||||
if (!d || !e || !c || !it) return false;
|
||||
c.click(); const a = d.hidden === true && e.hidden === false;
|
||||
it.click(); const b = d.hidden === false && e.hidden === true;
|
||||
return a && b; }`],
|
||||
],
|
||||
};
|
||||
|
||||
const browser = await playwright.chromium.launch();
|
||||
|
||||
/* ---------- 在页面里注入的工具:有效背景色 + 对比度 ---------- */
|
||||
const CONTRAST_HELPER = `
|
||||
window.__bg = el => {
|
||||
for (let n = el; n; n = n.parentElement) {
|
||||
const c = getComputedStyle(n).backgroundColor;
|
||||
const m = c.match(/rgba?\\(([^)]+)\\)/);
|
||||
if (m) { const p = m[1].split(",").map(s => parseFloat(s)); if (p.length < 4 || p[3] > 0.95) return p.slice(0, 3); }
|
||||
}
|
||||
return [255, 255, 255];
|
||||
};
|
||||
window.__lum = rgb => { const f = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
|
||||
return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]); };
|
||||
window.__ratio = (a, b) => { const l1 = window.__lum(a), l2 = window.__lum(b);
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); };
|
||||
window.__parse = s => { const m = s.match(/rgba?\\(([^)]+)\\)/); return m ? m[1].split(",").map(x => parseFloat(x)).slice(0, 3) : [0, 0, 0]; };
|
||||
`;
|
||||
|
||||
const shots = {};
|
||||
|
||||
/* ---------- 视图化页面的逐视图溢出测量 ----------
|
||||
风格 A 与 E 把内容分成若干互斥的 .view 容器(隐藏的那个是 display:none)。
|
||||
隐藏视图不贡献宽度 ⇒ 只量默认视图等于对其它视图「不设防」:溢出要等用户
|
||||
亲手点进去才暴露。这里逐个切过去量,并且把「有没有切成功」也验一遍 ——
|
||||
否则导航一旦失效(风格 E 第一版就是:按钮只换高亮、不换内容),
|
||||
「5 个视图都无溢出」这句话会在同一个视图上量五遍,变成静默假绿。 */
|
||||
async function widestOverflow(page) {
|
||||
const views = await page.evaluate(() => [...document.querySelectorAll("button[data-view]")].map(b => b.dataset.view));
|
||||
if (views.length < 2) return null;
|
||||
let worst = -1, at = "", switched = true;
|
||||
for (const k of views) {
|
||||
const r = await page.evaluate(key => {
|
||||
const b = document.querySelector(`button[data-view="${key}"]`);
|
||||
if (b) b.click();
|
||||
const on = document.querySelector(".view.on");
|
||||
const de = document.documentElement;
|
||||
return { ov: de.scrollWidth - de.clientWidth, on: on ? on.id : "?", ok: on ? on.id === "view-" + key : false };
|
||||
}, k);
|
||||
if (!r.ok) switched = false;
|
||||
if (r.ov > worst) { worst = r.ov; at = r.on; }
|
||||
}
|
||||
await page.evaluate(() => document.querySelector("button[data-view]")?.click()); // 切回默认视图
|
||||
return { n: views.length, worst, at, switched };
|
||||
}
|
||||
|
||||
for (const d of DEMOS) {
|
||||
const url = "file:///" + ROOT + "/" + d.file;
|
||||
console.log("\n== " + d.name + " (" + d.file + ")");
|
||||
|
||||
/* ---------- 1920 电视档 ---------- */
|
||||
const p = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
|
||||
const ext = [];
|
||||
p.on("request", r => { if (!r.url().startsWith("file://")) ext.push(r.url()); });
|
||||
const errs = [];
|
||||
p.on("pageerror", e => errs.push(e.message));
|
||||
await p.goto(url, { waitUntil: "load" });
|
||||
await p.waitForTimeout(450);
|
||||
await p.evaluate(CONTRAST_HELPER);
|
||||
|
||||
check(ext.length === 0, `零外部请求 (${ext.length})${ext.slice(0, 2).join(" ")}`);
|
||||
check(errs.length === 0, `零 JS 运行时错误 (${errs.length})${errs[0] ? " :: " + errs[0] : ""}`);
|
||||
|
||||
const base = await p.evaluate(() => ({
|
||||
ov: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
hasNav: ["文件", "任务", "游戏", "存档"].every(t => document.body.textContent.includes(t)),
|
||||
hasKstuff: document.body.textContent.includes("kstuff"),
|
||||
hasAvg: document.body.textContent.includes("均速"),
|
||||
hasLock: /单例|挂载中/.test(document.body.textContent),
|
||||
hasPick: /点选|选择落点|解压到/.test(document.body.textContent),
|
||||
hasSubdirDefault: /默认不勾/.test(document.body.textContent),
|
||||
archiveExtAsDir: (document.body.textContent.match(/\.(zip|rar|7z)\//gi) || []).length,
|
||||
ariaDisabled: document.querySelectorAll('[aria-disabled="true"]').length,
|
||||
nativelyDisabledWithTitle: [...document.querySelectorAll('button[disabled][title]')].length,
|
||||
reducedMotion: /prefers-reduced-motion/.test(document.documentElement.outerHTML),
|
||||
svgIcons: document.querySelectorAll("svg.i").length,
|
||||
emoji: (document.body.textContent.match(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu) || []).length,
|
||||
/* 本机地址:命中区要单独量 —— 视觉高度是按风格定的(24~37px 不等),
|
||||
命中区一律靠 ::after 撑到 40px。只量 getBoundingClientRect 会把
|
||||
「看着小但点得中」误判成不合格。 */
|
||||
ip: (() => {
|
||||
const c = document.getElementById("ipChip");
|
||||
if (!c) return { text: "", hit: 0 };
|
||||
const v = c.querySelector(".ipv");
|
||||
const after = getComputedStyle(c, "::after");
|
||||
return { text: v ? v.textContent.trim() : "",
|
||||
hit: Math.round(Math.max(c.getBoundingClientRect().height,
|
||||
parseFloat(after.height) || 0)) };
|
||||
})(),
|
||||
}));
|
||||
check(base.ov <= 1, `1920 无页面横向溢出 (${base.ov}px)`);
|
||||
check(base.hasNav, "四个一级导航项齐全(文件/任务/游戏/存档)");
|
||||
check(base.hasKstuff, "平台状态(kstuff)可见");
|
||||
check(base.hasAvg && base.hasLock, "进度口径(均速)+ 存档单例提示在场");
|
||||
check(base.hasPick && base.hasSubdirDefault, "解压落点点选 + 子目录默认不勾(已拍板)");
|
||||
check(base.archiveExtAsDir === 0, `新建子目录名已去归档扩展名(.rar/ .zip/ .7z/ 命中 ${base.archiveExtAsDir} 次)`);
|
||||
check(base.ariaDisabled > 0, `存在 aria-disabled 禁用项 (${base.ariaDisabled})`);
|
||||
check(base.nativelyDisabledWithTitle === 0, "没有 button[disabled][title](否则 title 永不弹出)");
|
||||
check(base.reducedMotion, "已处理 prefers-reduced-motion");
|
||||
check(base.svgIcons >= 6, `图标为内联 SVG 而非 emoji (${base.svgIcons} 个)`);
|
||||
check(base.emoji === 0, `正文无 emoji (${base.emoji})`);
|
||||
|
||||
/* ---------- 本机地址(2026-09-27 加) ----------
|
||||
它不是装饰件:插件跑在 PS5 上就是个 HTTP 服务,而这个地址是「用电脑 / 手机
|
||||
打开同一个界面」的唯一入口,偏偏 PS5 自己没有 ipconfig —— 界面不给就无处可查。
|
||||
端口来自服务端上报(默认 2026,被占用会顺延),所以这里只要求「像 IP:PORT」,
|
||||
不锁死具体端口;锁死了反而会把一个真实的运行时行为挡住。 */
|
||||
check(/^\d{1,3}(\.\d{1,3}){3}:\d{2,5}$/.test(base.ip.text),
|
||||
`本机地址形如 IP:PORT(${base.ip.text || "缺失"})`);
|
||||
check(!!base.ip.text && !/^(127\.|0\.0\.0\.0)/.test(base.ip.text),
|
||||
"地址是局域网地址而不是回环(回环对「用另一台设备打开」没有意义)");
|
||||
check(base.ip.hit >= 40, `本机地址可点区域 ≥40px(${base.ip.hit}px,PS5 是触摸板光标)`);
|
||||
|
||||
/* ---------- 本次改动的回归钉 ---------- */
|
||||
const extras = EXTRA[d.key] || [];
|
||||
if (extras.length) {
|
||||
/* ⚠️ 逐个 await:回归钉里有的检查必须等一个 tick(例:点了复制按钮之后
|
||||
DOM 才会显示「已复制」)。同步版只能拿到点击瞬间的状态,会把真功能判成假按钮。 */
|
||||
const got = await p.evaluate(async srcs => Promise.all(srcs.map(async s => {
|
||||
try { return !!(await (new Function("return (" + s + ")")())()); } catch (e) { return false; }
|
||||
})), extras.map(e => e[1]));
|
||||
extras.forEach((e, i) => check(got[i], e[0]));
|
||||
}
|
||||
|
||||
/* ---------- 本机地址:点一下必须真的有反应 ----------
|
||||
「点击复制」是本轮最容易做成假交互的地方:按钮看着是按钮,按下去什么都没发生,
|
||||
断言却只验了「元素存在」。这里验三件事:有可见反馈、反馈会复原、按钮不是死的。
|
||||
⚠️ 两条路径都失败时文案是「复制失败…」,仍然算有反馈 —— 因为「静默无反应」才是
|
||||
真正要防的那种 bug;headless 下能不能写进系统剪贴板本来就不该由页面决定。 */
|
||||
const ipClick = await p.evaluate(async () => {
|
||||
const c = document.getElementById("ipChip");
|
||||
if (!c) return { ok: false, restored: false, why: "找不到 #ipChip" };
|
||||
const v = c.querySelector(".ipv"), before = v.textContent;
|
||||
c.click();
|
||||
await new Promise(r => setTimeout(r, 700));
|
||||
const after = v.textContent, marked = c.hasAttribute("data-copied");
|
||||
await new Promise(r => setTimeout(r, 1000)); // 等复原:长留会把地址本身盖住,截图也拍错
|
||||
return { ok: after !== before, restored: v.textContent === before, why: after };
|
||||
});
|
||||
check(ipClick.ok, `本机地址点一下有可见反馈(显示「${ipClick.why}」)`);
|
||||
check(ipClick.restored, "复制提示 1.5s 后自动复原(否则地址会被提示文案长期盖掉)");
|
||||
|
||||
/* ---------- 视图化页面:逐视图量溢出 ---------- */
|
||||
const vw = await widestOverflow(p);
|
||||
if (vw) {
|
||||
check(vw.switched, `导航真能切换视图(${vw.n} 个视图逐个点过)`);
|
||||
check(vw.worst <= 1, `1920 · ${vw.n} 个视图切换后均无横向溢出 (最大 ${vw.worst}px @ ${vw.at})`);
|
||||
}
|
||||
|
||||
/* ---------- 对比度 ---------- */
|
||||
const cr = await p.evaluate(targets => targets.map(([sel, label]) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return { label, sel, missing: true };
|
||||
const cs = getComputedStyle(el);
|
||||
const size = parseFloat(cs.fontSize), weight = parseInt(cs.fontWeight) || 400;
|
||||
const fg = window.__parse(cs.color), bg = window.__bg(el);
|
||||
const large = size >= 24 || (size >= 18.66 && weight >= 700);
|
||||
return { label, sel, size, ratio: +window.__ratio(fg, bg).toFixed(2), min: large ? 3 : 4.5 };
|
||||
}), CONTRAST_TARGETS[d.key]);
|
||||
for (const c of cr) {
|
||||
if (c.missing) { check(false, `对比度采样点存在: ${c.sel}`); continue; }
|
||||
check(c.ratio >= c.min, `对比度 ${c.label} ${c.ratio}:1 (需 ≥${c.min}, ${c.size}px)`);
|
||||
}
|
||||
|
||||
/* ---------- 焦点环:必须是「不越出容器」的行内环 ----------
|
||||
视图化页面(风格 E)的默认视图里可能根本没有可聚焦的行 —— 那样这组检查会
|
||||
落到导航按钮上,等于把「行内 inset 环」这条约定静默跳过。先切到第一个含行
|
||||
的视图;测完再切回来(1920 截图必须拍默认视图)。 */
|
||||
const focusedView = await p.evaluate(() => {
|
||||
const btns = [...document.querySelectorAll("button[data-view]")];
|
||||
if (btns.length < 2) return false;
|
||||
const t = btns.find(b => {
|
||||
const v = document.querySelector("#view-" + b.dataset.view);
|
||||
return v && v.querySelector(".row[tabindex], .li[tabindex], .lr[tabindex]");
|
||||
});
|
||||
if (!t) return false;
|
||||
t.click();
|
||||
return true;
|
||||
});
|
||||
const foc = await p.evaluate(() => {
|
||||
// 只挑「当前可见」的候选 —— 隐藏视图里的列表聚焦不上,
|
||||
// 会给出 outline=0 shadow=0 的假失败(曾经有个候选默认停在总览视图,踩过)。
|
||||
const vis = el => el && el.offsetParent !== null && el.getClientRects().length > 0;
|
||||
const sel = [".row[tabindex]", ".li[tabindex]", ".lr[tabindex]", ".tabs button", ".seg button", ".nav button"]
|
||||
.map(s => [...document.querySelectorAll(s)].find(vis)).find(Boolean);
|
||||
if (!sel) return { none: true };
|
||||
sel.focus();
|
||||
const cs = getComputedStyle(sel);
|
||||
const r = sel.getBoundingClientRect();
|
||||
const par = sel.parentElement.getBoundingClientRect();
|
||||
return {
|
||||
tag: sel.className || sel.tagName,
|
||||
outline: cs.outlineStyle === "none" ? 0 : parseFloat(cs.outlineWidth),
|
||||
shadow: cs.boxShadow === "none" ? 0 : 1,
|
||||
insideParent: r.left >= par.left - 1 && r.right <= par.right + 1,
|
||||
};
|
||||
});
|
||||
check(!foc.none, "存在可聚焦的行元素");
|
||||
if (!foc.none) {
|
||||
check(foc.outline > 0 || foc.shadow > 0, `焦点态有可见指示 (outline=${foc.outline} shadow=${foc.shadow})`);
|
||||
check(foc.insideParent, "聚焦元素未越出容器(行内环约定)");
|
||||
}
|
||||
|
||||
/* ---------- 抽屉打开后不得产生溢出(本次真踩的坑) ---------- */
|
||||
const drawer = await p.evaluate(() => {
|
||||
const b = document.querySelector("#btnNotes, [id*='otes'], [id*='rawer']");
|
||||
if (!b) return { skip: true };
|
||||
b.click();
|
||||
return { ov: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
opened: !!document.querySelector(".notes.on, .docs.on") };
|
||||
});
|
||||
if (!drawer.skip) {
|
||||
check(drawer.opened, "设计说明抽屉可打开");
|
||||
check(drawer.ov <= 1, `抽屉打开后仍无横向溢出 (${drawer.ov}px)`);
|
||||
}
|
||||
|
||||
/* ---------- 对话框可打开 ---------- */
|
||||
const dlg = await p.evaluate(() => {
|
||||
const b = document.querySelector("#btnExtract");
|
||||
if (!b) return { skip: true };
|
||||
b.click();
|
||||
return { on: !!document.querySelector(".scrim.on"), ov: document.documentElement.scrollWidth - document.documentElement.clientWidth };
|
||||
});
|
||||
if (!dlg.skip) { check(dlg.on, "解压对话框可打开"); check(dlg.ov <= 1, `对话框打开后无横向溢出 (${dlg.ov}px)`); }
|
||||
|
||||
/* ---------- 截图 ----------
|
||||
必须先滚回顶部:焦点那一组检查调用了 el.focus(),浏览器会自动把该行滚进视口,
|
||||
于是长页 demo(风格 E)的「全页截图」拍到的是中段而不是首屏。 */
|
||||
await p.keyboard.press("Escape");
|
||||
if (focusedView) await p.evaluate(() => document.querySelector("button[data-view]")?.click()); // 切回默认视图再拍
|
||||
await p.evaluate(() => window.scrollTo(0, 0));
|
||||
await p.waitForTimeout(250);
|
||||
await p.screenshot({ path: `${OUT}/${d.key}-1920.jpg`, type: "jpeg", quality: 84 });
|
||||
shots[d.key] = `${OUT}/${d.key}-1920.jpg`;
|
||||
/* 视图化页面再拍一张「第二个视图」:默认视图(概览 / 落地页)看起来仍像宣传页,
|
||||
真正证明「这是工具界面」的是工作视图那一张。 */
|
||||
if (vw) {
|
||||
const second = await p.evaluate(() => {
|
||||
const bs = [...document.querySelectorAll("button[data-view]")];
|
||||
if (!bs[1]) return null;
|
||||
bs[1].click(); window.scrollTo(0, 0);
|
||||
return document.querySelector(".view.on")?.id || null;
|
||||
});
|
||||
if (second) {
|
||||
await p.waitForTimeout(320);
|
||||
await p.screenshot({ path: `${OUT}/${d.key}-2nd-1920.jpg`, type: "jpeg", quality: 84 });
|
||||
shots[`${d.key}-2nd`] = `${OUT}/${d.key}-2nd-1920.jpg`;
|
||||
await p.evaluate(() => document.querySelector("button[data-view]")?.click());
|
||||
}
|
||||
}
|
||||
if (ALT_THEME[d.key]) { // 备用主题:同布局只换 token,必须同样无溢出
|
||||
const mode = ALT_THEME[d.key];
|
||||
const alt = await p.evaluate(m => {
|
||||
document.getElementById("btnTheme").click();
|
||||
/* 备用主题下单独再量一次地址文字:它吃的是 --fg2 / --fg3 这类**主题令牌**,
|
||||
换一套色值就可能掉出阈值,而上面那一组采样只发生在默认主题。 */
|
||||
const el = document.querySelector(".ipchip .ipv");
|
||||
const cs = el ? getComputedStyle(el) : null;
|
||||
return { on: document.body.classList.contains(m),
|
||||
ov: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
ip: el ? +window.__ratio(window.__parse(cs.color), window.__bg(el)).toFixed(2) : 0 };
|
||||
}, mode);
|
||||
check(alt.on && alt.ov <= 1, `备用主题(切到 ${mode})正常且无溢出 (on=${alt.on} ov=${alt.ov}px)`);
|
||||
check(alt.ip >= 4.5, `备用主题(${mode})下本机地址仍可读 (${alt.ip}:1)`);
|
||||
await p.waitForTimeout(350);
|
||||
await p.screenshot({ path: `${OUT}/${d.key}-${mode}-1920.jpg`, type: "jpeg", quality: 84 });
|
||||
shots[`${d.key}-${mode}`] = `${OUT}/${d.key}-${mode}-1920.jpg`;
|
||||
}
|
||||
|
||||
/* ---------- 1280 电脑档 + 1100 窄窗档 + 390 手机档:只验溢出 ---------- */
|
||||
for (const [w, h, tag] of [[1280, 820, "1280"], [1100, 800, "1100"], [390, 844, "390"]]) {
|
||||
const q = await browser.newPage({ viewport: { width: w, height: h } });
|
||||
await q.goto(url, { waitUntil: "load" });
|
||||
await q.waitForTimeout(300);
|
||||
const o = await q.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
const vq = await widestOverflow(q);
|
||||
if (vq) check(vq.worst <= 1, `${tag} · ${vq.n} 个视图逐个切换后均无横向溢出 (最大 ${vq.worst}px @ ${vq.at})`);
|
||||
else check(o <= 1, `${tag} 无横向溢出 (${o}px)`);
|
||||
await q.evaluate(() => window.scrollTo(0, 0));
|
||||
if (w === 390) {
|
||||
await q.screenshot({ path: `${OUT}/${d.key}-390.jpg`, type: "jpeg", quality: 80 });
|
||||
shots[d.key + "-390"] = `${OUT}/${d.key}-390.jpg`;
|
||||
}
|
||||
await q.close();
|
||||
}
|
||||
/* ---------- PS5 档:1920×970 ----------
|
||||
PS5 的浏览器自己不把 1080 全留给页面,可视区形状是「横向充裕、纵向紧缺」。
|
||||
1080×1920 那一轮抓不到这个形状特有的问题,所以单独补一档:
|
||||
① 一级导航必须停在顶部并且**单行** —— 折行等于白吃纵向空间;
|
||||
② 导航必须在首屏内(吸顶或至少在顶部),滚一次就找不到了等于没有导航。 */
|
||||
const ps5p = await browser.newPage({ viewport: { width: 1920, height: 970 } });
|
||||
await ps5p.goto(url, { waitUntil: "load" });
|
||||
await ps5p.waitForTimeout(320);
|
||||
const ps5 = await ps5p.evaluate(() => {
|
||||
const de = document.documentElement;
|
||||
const bs = [...document.querySelectorAll("button[data-view]")];
|
||||
const rects = bs.map(b => b.getBoundingClientRect());
|
||||
const ipc = document.getElementById("ipChip");
|
||||
const ir = ipc ? ipc.getBoundingClientRect() : null;
|
||||
return {
|
||||
ov: de.scrollWidth - de.clientWidth,
|
||||
n: bs.length,
|
||||
rows: new Set(rects.map(r => Math.round(r.top))).size,
|
||||
top: rects.length ? Math.min(...rects.map(r => r.top)) : -1,
|
||||
bottom: rects.length ? Math.max(...rects.map(r => r.bottom)) : -1,
|
||||
ipTop: ir ? Math.round(ir.top) : null,
|
||||
ipBottom: ir ? Math.round(ir.bottom) : null,
|
||||
ipW: ir ? Math.round(ir.width) : 0,
|
||||
};
|
||||
});
|
||||
check(ps5.ov <= 1, `PS5 1920×970 无横向溢出 (${ps5.ov}px)`);
|
||||
/* 地址在 PS5 档必须可见且落在首屏里。
|
||||
1920 宽下它**不该**被任何响应式规则藏起来(风格 A 只在 ≤900px 才让位给导航),
|
||||
所以这里不给 SKIP 分支:查不到就是真失败。 */
|
||||
check(ps5.ipW > 0, "PS5 档本机地址可见(1920 宽下不该被响应式规则藏起来)");
|
||||
check(ps5.ipW > 0 && ps5.ipTop >= 0 && ps5.ipBottom <= 970,
|
||||
`PS5 档本机地址在首屏内(top=${ps5.ipTop} bottom=${ps5.ipBottom})`);
|
||||
/* 只有「视图化」页面(带 data-view 导航)才验「导航置顶且单行」。
|
||||
单页长滚动的候选压根没有一级导航 —— 硬套只会造出假失败,
|
||||
而假失败和假通过一样有毒:它会让人开始忽略这一组断言。 */
|
||||
if (ps5.n >= 2) {
|
||||
check(ps5.n >= 4 && ps5.rows === 1, `PS5 档一级导航单行不折行(${ps5.n} 项 / ${ps5.rows} 行)`);
|
||||
check(ps5.top >= 0 && ps5.bottom > 0 && ps5.bottom <= 970,
|
||||
`PS5 档导航在首屏顶部(top=${Math.round(ps5.top)}px bottom=${Math.round(ps5.bottom)}px)`);
|
||||
} else {
|
||||
console.log(" SKIP PS5 档导航检查(此 demo 无一级导航,是单页长滚动)");
|
||||
}
|
||||
const pvq = await widestOverflow(ps5p);
|
||||
if (pvq) check(pvq.worst <= 1, `PS5 档 ${pvq.n} 个视图逐个切换后均无横向溢出 (最大 ${pvq.worst}px @ ${pvq.at})`);
|
||||
await ps5p.close();
|
||||
|
||||
await p.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log("\n" + (fails ? fails + " FAILURE(S)" : "ALL CHECKS PASSED") +
|
||||
`\n 共 ${total} 项(${total - fails} 通过 / ${fails} 失败)`);
|
||||
|
||||
/* ---------- 把截图注入对比页(保持单文件、零外部依赖) ----------
|
||||
对比页里用 <img data-shot="demo1"> 作占位;这里填 src。
|
||||
用属性而非注释做标记 ⇒ 脚本可重复运行且幂等。 */
|
||||
if (!fails) {
|
||||
fs.writeFileSync(`${OUT}/shots.json`, JSON.stringify(shots, null, 2));
|
||||
const PAGE = path.join(ROOT, "ps5-ui-demos-compare.html");
|
||||
if (fs.existsSync(PAGE)) {
|
||||
let html = fs.readFileSync(PAGE, "utf8");
|
||||
let n = 0;
|
||||
for (const [key, file] of Object.entries(shots)) {
|
||||
if (!fs.existsSync(file)) continue;
|
||||
const uri = "data:image/jpeg;base64," + fs.readFileSync(file).toString("base64");
|
||||
const re = new RegExp(`(<img )((?:src="[^"]*" )?)data-shot="${key}"`, "g");
|
||||
html = html.replace(re, (_, pre) => { n++; return pre + `src="${uri}" data-shot="${key}"`; });
|
||||
}
|
||||
fs.writeFileSync(PAGE, html);
|
||||
console.log(` 注入 ${n} 张截图 → ps5-ui-demos-compare.html (${(html.length / 1048576).toFixed(2)} MB)`);
|
||||
}
|
||||
}
|
||||
process.exit(fails ? 1 : 0);
|
||||
@@ -0,0 +1,381 @@
|
||||
/* Headless check for the extract "retry with a password" flow in
|
||||
assets/main.js, plus the byte-mapped name translation its error messages go
|
||||
through.
|
||||
|
||||
This project has no browser test runner, so the real script is loaded into a
|
||||
stubbed DOM. Only the pieces the flow touches are faked: fetch (which also
|
||||
records the /api/extract bodies), prompt, alert and a permissive element
|
||||
object. init() is allowed to stall on its first real data fetch -- the retry
|
||||
path is driven directly.
|
||||
|
||||
Run: node .build/ui_retry_test.mjs
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
const mainSrc = fs.readFileSync(path.join(root, "assets/main.js"), "utf8");
|
||||
const langSrc = fs.readFileSync(path.join(root, "assets/lang-en.js"), "utf8");
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
function check(cond, label) {
|
||||
if (cond) {
|
||||
pass++;
|
||||
console.log(" ok " + label);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(" FAIL " + label);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- stubs ------------------------------------------------------------- */
|
||||
|
||||
const extractCalls = [];
|
||||
const alerts = [];
|
||||
let promptReply = null;
|
||||
let promptCount = 0;
|
||||
let lastPromptText = null;
|
||||
|
||||
function fakeElement(tag) {
|
||||
const node = {
|
||||
tagName: String(tag || "div").toUpperCase(),
|
||||
children: [],
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
||||
innerHTML: "",
|
||||
textContent: "",
|
||||
value: "",
|
||||
hidden: false,
|
||||
checked: false,
|
||||
disabled: false,
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
clientHeight: 0,
|
||||
files: [],
|
||||
parentNode: { removeChild() {} },
|
||||
title: "",
|
||||
type: "",
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent() {},
|
||||
appendChild() {},
|
||||
removeChild() {},
|
||||
insertBefore() {},
|
||||
replaceChildren() {},
|
||||
setAttribute() {},
|
||||
getAttribute() { return null; },
|
||||
removeAttribute() {},
|
||||
focus() {},
|
||||
blur() {},
|
||||
click() {},
|
||||
remove() {},
|
||||
after() {},
|
||||
before() {},
|
||||
closest() { return null; },
|
||||
contains() { return false; },
|
||||
cloneNode() { return fakeElement(tag); },
|
||||
querySelector() { return fakeElement("div"); },
|
||||
querySelectorAll() { return []; },
|
||||
getBoundingClientRect() { return { top: 0, left: 0, width: 0, height: 0, bottom: 0, right: 0 }; },
|
||||
scrollIntoView() {},
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
/* The language <script> is fetched in the browser; here the tag is handed back
|
||||
with an onload hook that fires as soon as main.js assigns the handler, which
|
||||
is the order loadLanguage() expects. */
|
||||
function fakeScriptElement() {
|
||||
const node = fakeElement("script");
|
||||
let src = "";
|
||||
Object.defineProperty(node, "src", {
|
||||
get() { return src; },
|
||||
set(value) { src = String(value); },
|
||||
});
|
||||
Object.defineProperty(node, "onload", {
|
||||
set(handler) { if (typeof handler === "function") queueMicrotask(handler); },
|
||||
get() { return null; },
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
const documentStub = {
|
||||
getElementById() { return fakeElement("div"); },
|
||||
querySelector() { return fakeElement("div"); },
|
||||
querySelectorAll() { return []; },
|
||||
createElement(tag) {
|
||||
return String(tag).toLowerCase() === "script" ? fakeScriptElement() : fakeElement(tag);
|
||||
},
|
||||
createDocumentFragment() { return fakeElement("div"); },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
body: fakeElement("body"),
|
||||
head: fakeElement("head"),
|
||||
documentElement: fakeElement("html"),
|
||||
title: "",
|
||||
cookie: "",
|
||||
hidden: false,
|
||||
};
|
||||
|
||||
function jsonResponse(payload) {
|
||||
return { ok: true, status: 200, json: async () => payload, text: async () => "" };
|
||||
}
|
||||
|
||||
function stubFetch(url, options) {
|
||||
const target = String(url);
|
||||
if (target.startsWith("/api/extract")) {
|
||||
extractCalls.push(String((options && options.body) || ""));
|
||||
return Promise.resolve(jsonResponse({ ok: true, task_id: extractCalls.length }));
|
||||
}
|
||||
if (target.startsWith("/api/tasks")) {
|
||||
return Promise.resolve(jsonResponse({ ok: true, tasks: [], completion: null }));
|
||||
}
|
||||
if (target.startsWith("/api/")) {
|
||||
return Promise.resolve(jsonResponse({ ok: true }));
|
||||
}
|
||||
/* Anything else (file listings, spaces) never settles: init() parks on it
|
||||
instead of walking into code paths this harness does not stub. */
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval() { return 0; },
|
||||
clearInterval() {},
|
||||
requestAnimationFrame(handler) { return setTimeout(handler, 0); },
|
||||
fetch: stubFetch,
|
||||
URLSearchParams,
|
||||
AbortController,
|
||||
Uint8Array,
|
||||
TextDecoder,
|
||||
TextEncoder,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent() {},
|
||||
alert(message) { alerts.push(String(message)); },
|
||||
confirm() { return true; },
|
||||
prompt(text) {
|
||||
promptCount++;
|
||||
lastPromptText = String(text);
|
||||
return promptReply;
|
||||
},
|
||||
XMLHttpRequest: class {
|
||||
abort() {}
|
||||
open() {}
|
||||
send() {}
|
||||
setRequestHeader() {}
|
||||
addEventListener() {}
|
||||
},
|
||||
localStorage: { getItem() { return null; }, setItem() {}, removeItem() {} },
|
||||
navigator: { languages: ["en-US"], language: "en-US", userAgent: "retry-test" },
|
||||
location: { href: "http://localhost:8888/", pathname: "/", search: "", hash: "", origin: "http://localhost:8888" },
|
||||
history: { pushState() {}, replaceState() {} },
|
||||
performance: { now: () => Date.now() },
|
||||
document: documentStub,
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
|
||||
process.on("unhandledRejection", reason => {
|
||||
console.log(" (note) init() rejected as expected in the stub: " + (reason && reason.message));
|
||||
});
|
||||
|
||||
const context = vm.createContext(sandbox);
|
||||
vm.runInContext(langSrc, context, { filename: "lang-en.js" });
|
||||
|
||||
const EXPORTS = ";globalThis.__WFM_TEST = { startExtractTask, handleTerminalTask, " +
|
||||
"retryExtractWithPassword, extractRequestRetries, extractRetryKey, " +
|
||||
"backendErrorText, decodeFsText, encodeFsText };\n";
|
||||
vm.runInContext(mainSrc + EXPORTS, context, { filename: "main.js" });
|
||||
|
||||
const T = sandbox.__WFM_TEST;
|
||||
if (!T) {
|
||||
console.log("FAIL: could not reach the functions under test");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const flush = async (ticks = 8) => {
|
||||
for (let i = 0; i < ticks; i++) await new Promise(resolve => setTimeout(resolve, 0));
|
||||
};
|
||||
|
||||
const failedExtract = (id, src, dst, code, arg) => ({
|
||||
id,
|
||||
op: "extract",
|
||||
state: "failed",
|
||||
error_code: code,
|
||||
error_arg: arg || "",
|
||||
error: "password required or wrong",
|
||||
src,
|
||||
dst,
|
||||
current: src,
|
||||
});
|
||||
|
||||
function bodyField(body, name) {
|
||||
const params = new URLSearchParams(body);
|
||||
return params.get(name);
|
||||
}
|
||||
|
||||
/* The stub hands out task ids 1, 2, 3 ... in call order, so the id of the task a
|
||||
startExtractTask() call created is simply the number of /api/extract calls
|
||||
made so far. Every terminal payload below must carry the id of the task the
|
||||
page actually remembered -- that is the whole point of keying by id. */
|
||||
const startedId = () => extractCalls.length;
|
||||
const lastField = name => bodyField(extractCalls[extractCalls.length - 1], name);
|
||||
|
||||
async function startExtract(...args) {
|
||||
await T.startExtractTask(...args);
|
||||
await flush();
|
||||
return startedId();
|
||||
}
|
||||
|
||||
/* ---- 1. the request is recorded when the task starts ------------------- */
|
||||
|
||||
console.log("recording the extract request");
|
||||
const idA = await startExtract("/enc-aes256.zip", "/", "overwrite", false, "enc-aes256.zip", true, "", 0);
|
||||
check(extractCalls.length === 1, "one /api/extract POST was sent");
|
||||
check(bodyField(extractCalls[0], "path") === "/enc-aes256.zip", "path is the archive");
|
||||
check(bodyField(extractCalls[0], "conflict") === "overwrite", "conflict policy is sent");
|
||||
check(bodyField(extractCalls[0], "large") === "1", "large-file opt-in is sent");
|
||||
check(bodyField(extractCalls[0], "password") === null, "no password field on a plain attempt");
|
||||
check(T.extractRequestRetries.get(T.extractRetryKey(idA)) !== undefined,
|
||||
"the request is remembered under the task id");
|
||||
check([...T.extractRequestRetries.keys()].every(key => key.startsWith("task:")),
|
||||
"nothing is keyed by a path -- see the regression below");
|
||||
|
||||
/* ---- 2. REGRESSION: a non-ASCII folder must still retry ---------------- */
|
||||
/* The page holds the byte-mapped form of a directory (see decodeFsText in
|
||||
main.js), the task reports the byte-repaired one, and the two differ whenever
|
||||
a name is not pure ASCII. Keyed by path the lookup missed for exactly those
|
||||
archives, so the password prompt never appeared and the user only ever saw the
|
||||
raw "extract failed" alert. */
|
||||
|
||||
console.log("\nnon-ASCII folder, path as reported by the server differs");
|
||||
promptCount = 0;
|
||||
alerts.length = 0;
|
||||
promptReply = "secret123";
|
||||
const wireDir = "/mnt/\u00e6\u0088\u0091\u00e7\u009a\u0084"; /* "/mnt/我的", byte-mapped */
|
||||
const idB = await startExtract(wireDir + "/games.zip", wireDir, "fail", false, "games.zip", false, "", 0);
|
||||
const beforeRetry = extractCalls.length;
|
||||
T.handleTerminalTask(failedExtract(idB, "/mnt/我的/games.zip", "/mnt/我的",
|
||||
"extract_password", "a.psd (entry is encrypted and no password was given)"));
|
||||
await flush();
|
||||
check(promptCount === 1, "the password is still asked for");
|
||||
check(extractCalls.length === beforeRetry + 1, "the retry was sent");
|
||||
check(lastField("path") === "/mnt/我的/games.zip",
|
||||
"the retry re-sends the path the server reported, got " + lastField("path"));
|
||||
check(lastField("password") === "secret123", "with the typed password");
|
||||
|
||||
/* ---- 3. a password failure retries with what the user types ------------ */
|
||||
|
||||
console.log("\npassword failure -> prompt -> retry");
|
||||
promptCount = 0;
|
||||
alerts.length = 0;
|
||||
promptReply = "secret123";
|
||||
const idC = await startExtract("/enc-aes256.zip", "/", "overwrite", false, "enc-aes256.zip", true, "", 0);
|
||||
const beforeC = extractCalls.length;
|
||||
T.handleTerminalTask(failedExtract(idC, "/enc-aes256.zip", "/", "extract_password", "enc-aes256.zip"));
|
||||
await flush();
|
||||
const idC2 = startedId();
|
||||
check(promptCount === 1, "the user was asked for a password once");
|
||||
check(String(lastPromptText).startsWith("This archive is encrypted"),
|
||||
"the prompt is the encrypted-archive one, not the up-front 7z one: " + lastPromptText);
|
||||
check(String(lastPromptText).indexOf("did not work") < 0,
|
||||
"a first failure does not blame a password that was never given: " + lastPromptText);
|
||||
check(extractCalls.length === beforeC + 1, "a second /api/extract POST was sent");
|
||||
check(lastField("password") === "secret123", "the typed password is sent");
|
||||
check(lastField("conflict") === "overwrite",
|
||||
"the original conflict policy survives the retry");
|
||||
check(lastField("large") === "1", "the large-file opt-in survives the retry");
|
||||
check(lastField("path") === "/enc-aes256.zip", "the same archive is retried");
|
||||
check(alerts.length === 0, "no failure alert while the retry is running");
|
||||
const afterFirstRetry = T.extractRequestRetries.get(T.extractRetryKey(idC2));
|
||||
check(afterFirstRetry && afterFirstRetry.attempts === 1, "the retry count is tracked");
|
||||
check(T.extractRequestRetries.get(T.extractRetryKey(idC)) === undefined,
|
||||
"the consumed entry is dropped, so the old id cannot re-prompt");
|
||||
|
||||
/* ---- 4. cancelling gives up and reports the failure -------------------- */
|
||||
|
||||
console.log("\ncancelling the prompt");
|
||||
promptCount = 0;
|
||||
alerts.length = 0;
|
||||
promptReply = null; // Cancel
|
||||
const beforeCancel = extractCalls.length;
|
||||
T.handleTerminalTask(failedExtract(idC2, "/enc-aes256.zip", "/", "extract_password", "enc-aes256.zip"));
|
||||
await flush();
|
||||
check(promptCount === 1, "the prompt was shown");
|
||||
check(String(lastPromptText).indexOf("did not work") >= 0,
|
||||
"the second failure does say the password was wrong: " + lastPromptText);
|
||||
check(extractCalls.length === beforeCancel, "no new request after cancelling");
|
||||
check(alerts.length === 1, "the failure is reported");
|
||||
check(T.extractRequestRetries.get(T.extractRetryKey(idC2)) === undefined,
|
||||
"the remembered request is dropped");
|
||||
|
||||
/* ---- 5. an empty password is the same as cancelling -------------------- */
|
||||
|
||||
console.log("\nempty password");
|
||||
promptReply = "";
|
||||
alerts.length = 0;
|
||||
const idD = await startExtract("/enc-aes256.zip", "/", "fail", false, "enc-aes256.zip", false, "", 0);
|
||||
const beforeEmpty = extractCalls.length;
|
||||
T.handleTerminalTask(failedExtract(idD, "/enc-aes256.zip", "/", "extract_password", "enc-aes256.zip"));
|
||||
await flush();
|
||||
check(extractCalls.length === beforeEmpty, "an empty box does not fire a retry");
|
||||
check(alerts.length === 1, "the failure is reported instead");
|
||||
|
||||
/* ---- 6. the retry count is capped ------------------------------------- */
|
||||
|
||||
console.log("\nretry cap");
|
||||
promptCount = 0;
|
||||
alerts.length = 0;
|
||||
promptReply = "pw";
|
||||
let cappedId = await startExtract("/capped.zip", "/", "fail", false, "capped.zip", false, "", 0);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
T.handleTerminalTask(failedExtract(cappedId, "/capped.zip", "/", "extract_password", "capped.zip"));
|
||||
await flush();
|
||||
cappedId = startedId();
|
||||
}
|
||||
check(promptCount === 3, "exactly three prompts for four failures, got " + promptCount);
|
||||
check(alerts.length === 1, "the fourth failure is reported instead of prompting again");
|
||||
|
||||
/* ---- 7. other failures are untouched ---------------------------------- */
|
||||
|
||||
console.log("\nunrelated failures");
|
||||
promptCount = 0;
|
||||
alerts.length = 0;
|
||||
promptReply = "pw";
|
||||
T.handleTerminalTask(failedExtract(startedId(), "/enc-aes256.zip", "/", "extract_ratio", "enc-aes256.zip"));
|
||||
await flush();
|
||||
check(promptCount === 0, "a ratio failure does not ask for a password");
|
||||
check(alerts.length === 1, "a ratio failure is reported as before");
|
||||
|
||||
promptCount = 0;
|
||||
alerts.length = 0;
|
||||
T.handleTerminalTask(failedExtract(987654, "/elsewhere.zip", "/tmp", "extract_password", "elsewhere.zip"));
|
||||
await flush();
|
||||
check(promptCount === 0, "a task this page did not start is not retried");
|
||||
check(alerts.length === 1, "it is reported as before");
|
||||
|
||||
/* ---- 8. names in the error text are translated back -------------------- */
|
||||
|
||||
console.log("\nbyte-mapped names reach the user as text");
|
||||
const gbkName = "\u00c4\u00a3\u00b0\u00e5.psd"; /* 模板.psd as GBK bytes, byte-mapped */
|
||||
check(T.decodeFsText(gbkName) === "模板.psd",
|
||||
"a GBK entry name decodes to the real name, got " + T.decodeFsText(gbkName));
|
||||
check(T.decodeFsText(T.encodeFsText("模板.psd")) === "模板.psd",
|
||||
"encode/decode is a round trip");
|
||||
check(T.encodeFsText("plain.zip") === "plain.zip", "ASCII names are left alone");
|
||||
const errText = T.backendErrorText("extract_password",
|
||||
gbkName + " (entry is encrypted and no password was given)", "hint");
|
||||
check(errText.indexOf("模板.psd") >= 0, "the message carries the real name: " + errText);
|
||||
check(errText.indexOf("\u00c4") < 0 && errText.indexOf("\u00a3") < 0,
|
||||
"no mojibake is left in it: " + errText);
|
||||
|
||||
console.log("\n" + pass + " checks, " + fail + " failures");
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,153 @@
|
||||
/* Static checks for the upload entry point in assets/index.html.
|
||||
|
||||
The upload button is markup plus i18n plus CSS plus three event bindings in
|
||||
main.js, and nothing else in the project validates any of those: a missing
|
||||
language key renders an empty button, a renamed id silently unbinds a click,
|
||||
and a class that only exists in the HTML looks fine in the diff but not in the
|
||||
browser. All three have happened here, so they are checked mechanically.
|
||||
|
||||
Run: node .build/ui_upload_menu_test.mjs
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
const read = name => fs.readFileSync(path.join(root, "assets", name), "utf8");
|
||||
|
||||
const html = read("index.html");
|
||||
const css = read("main.css");
|
||||
const js = read("main.js");
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
function check(cond, label) {
|
||||
if (cond) {
|
||||
pass++;
|
||||
console.log(" ok " + label);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(" FAIL " + label);
|
||||
}
|
||||
}
|
||||
|
||||
function loadLang(name) {
|
||||
const sandbox = { window: {} };
|
||||
vm.runInNewContext(read(name), sandbox, { filename: name });
|
||||
return sandbox.window.WFM_LANG;
|
||||
}
|
||||
|
||||
const zh = loadLang("lang-zh.js");
|
||||
const en = loadLang("lang-en.js");
|
||||
|
||||
/* ---- 1. every key the markup asks for exists, in both languages -------- */
|
||||
|
||||
console.log("i18n coverage");
|
||||
const used = [...html.matchAll(/data-i18n="([^"]+)"/g)].map(match => match[1]);
|
||||
const uniqueUsed = [...new Set(used)];
|
||||
check(uniqueUsed.length > 0, "the markup uses data-i18n at all");
|
||||
const missingZh = uniqueUsed.filter(key => !(key in zh));
|
||||
const missingEn = uniqueUsed.filter(key => !(key in en));
|
||||
check(missingZh.length === 0, "every key exists in lang-zh: " + (missingZh.join(", ") || "none missing"));
|
||||
check(missingEn.length === 0, "every key exists in lang-en: " + (missingEn.join(", ") || "none missing"));
|
||||
|
||||
const zhKeys = Object.keys(zh).sort();
|
||||
const enKeys = Object.keys(en).sort();
|
||||
const onlyZh = zhKeys.filter(key => !(key in en));
|
||||
const onlyEn = enKeys.filter(key => !(key in zh));
|
||||
check(onlyZh.length === 0 && onlyEn.length === 0,
|
||||
"the two language files carry the same keys" +
|
||||
(onlyZh.length || onlyEn.length ? " (zh-only: " + onlyZh.join(",") + "; en-only: " + onlyEn.join(",") + ")" : ""));
|
||||
|
||||
const emptyZh = zhKeys.filter(key => !String(zh[key]).trim());
|
||||
check(emptyZh.length === 0, "no empty values in lang-zh: " + (emptyZh.join(", ") || "none"));
|
||||
|
||||
/* ---- 2. the menu is there and wired ----------------------------------- */
|
||||
|
||||
console.log("\nupload menu markup");
|
||||
check(/id="uploadBtn"[^>]*data-i18n="upload"/.test(html), "the upload button is present");
|
||||
check(/id="uploadBtn"[^>]*aria-haspopup="menu"/.test(html) ||
|
||||
/aria-haspopup="menu"[^>]*id="uploadBtn"/.test(html), "the button announces it opens a menu");
|
||||
check(html.includes('id="uploadMenu"'), "the menu container is present");
|
||||
check(/id="uploadMenu"[^>]*role="menu"/.test(html), "the menu has menu role");
|
||||
check(/id="uploadMenu"[^>]*hidden/.test(html), "the menu starts hidden");
|
||||
check(html.includes('id="uploadFilesItem"') && html.includes('data-i18n="uploadFiles"'),
|
||||
"the file entry exists");
|
||||
check(html.includes('id="uploadFolderItem"') && html.includes('data-i18n="uploadFolder"'),
|
||||
"the folder entry exists");
|
||||
check(!html.includes("split-arrow") && !html.includes("split-button"),
|
||||
"the old main-button-plus-caret split is gone");
|
||||
check(!css.includes("split-button") && !css.includes("split-arrow"),
|
||||
"and so are its styles");
|
||||
|
||||
console.log("\ndrag hint");
|
||||
check(html.includes('id="dropHint"') && html.includes('data-i18n="dropUploadHint"'),
|
||||
"the drag hint element exists");
|
||||
check(/id="dropHint"[^>]*remote-only/.test(html),
|
||||
"the hint is remote-only, like the upload it advertises");
|
||||
check(/id="dropHint"[^>]*hidden/.test(html), "the hint starts hidden");
|
||||
check("dropUploadHint" in zh && zh.dropUploadHint.indexOf("拖") >= 0,
|
||||
"the hint tells the user they can drag: " + zh.dropUploadHint);
|
||||
|
||||
console.log("\nextract button");
|
||||
/* The extract entry used to be hidden until an archive was selected, so the
|
||||
resting toolbar had no extract button at all. It now stays on screen and
|
||||
only greys out, which means the markup must not hide it and main.js must
|
||||
only ever disable it -- a stray `hidden = true` left behind would silently
|
||||
restore the old behaviour on one code path. */
|
||||
check(/id="extractBtn"[^>]*data-i18n="extract"/.test(html),
|
||||
"the extract button carries the short toolbar label");
|
||||
check(!/id="extractBtn"[^>]*\shidden/.test(html), "the extract button is not hidden in the markup");
|
||||
check(!js.includes("extractBtn.hidden"), "main.js never hides it");
|
||||
check(/extractBtn\.disabled\s*=\s*true/.test(js), "it is disabled when the selection cannot be extracted");
|
||||
check(js.includes('t("extractSelectArchive")') && js.includes('t("extractOneAtATime")') &&
|
||||
js.includes('t("extractSelectMainVolume")'), "each disabled reason is wired to a message");
|
||||
check(/\.extract-action:disabled\s*\{[^}]*pointer-events:\s*auto/.test(css),
|
||||
"a disabled extract button keeps its tooltip readable");
|
||||
|
||||
/* Keys reached only through t("...") in main.js are invisible to the markup
|
||||
sweep above, which is exactly how a message can go missing unnoticed. */
|
||||
const tKeys = [...new Set([...js.matchAll(/\bt\("([A-Za-z0-9_]+)"/g)].map(match => match[1]))];
|
||||
const missingT = tKeys.filter(key => !(key in zh) || !(key in en));
|
||||
check(missingT.length === 0,
|
||||
"every t(\"...\") key in main.js exists in both languages (" + tKeys.length + " keys): " +
|
||||
(missingT.join(", ") || "none missing"));
|
||||
|
||||
console.log("\nwiring in main.js");
|
||||
check(js.includes('getElementById("uploadMenu")'), "main.js looks up the menu");
|
||||
check(js.includes('const uploadFolderItemEl = document.getElementById("uploadFolderItem")'),
|
||||
"main.js looks up the folder entry");
|
||||
check(!js.includes("uploadFolderBtn"), "no reference to the removed folder button is left");
|
||||
check(js.includes('uploadBtn.addEventListener("click", toggleUploadMenu)'),
|
||||
"the button toggles the menu instead of opening a dialog");
|
||||
check(js.includes('uploadFilesItemEl.addEventListener("click", actionUploadFiles)'),
|
||||
"the file entry opens the file dialog");
|
||||
check(js.includes('uploadFolderItemEl.addEventListener("click", actionUploadFolder)'),
|
||||
"the folder entry opens the folder dialog");
|
||||
check(js.includes("setupUploadMenu();"), "the menu behaviour is installed");
|
||||
|
||||
/* ---- 3. classes used by the markup exist in the stylesheet ------------- */
|
||||
|
||||
console.log("\nstylesheet");
|
||||
for (const cls of ["upload-menu", "upload-menu-list", "status-hint"]) {
|
||||
check(new RegExp("\\." + cls + "\\s*[,{]").test(css), "." + cls + " is styled");
|
||||
}
|
||||
check(/\.upload-menu-list\[hidden\]|\[hidden\]\s*\{\s*display:\s*none !important/.test(css),
|
||||
"a hidden menu really is invisible");
|
||||
|
||||
/* The row highlight has to out-specify the generic button rules, so it must be
|
||||
scoped to the panel id: the unscoped version tied on specificity (0,3,1)
|
||||
with `button:not(.row-action):hover:not(:disabled)` and lost on source
|
||||
order, which is why the menu's own hover fill never appeared. */
|
||||
check(/#uploadMenu button:hover:not\(:disabled\)/.test(css),
|
||||
"the menu row hover is scoped to the panel, not left to the generic rule");
|
||||
check(/#uploadMenu button:focus\s*\{[^}]*outline:\s*none/.test(css),
|
||||
"the toolbar's outer focus ring cannot land on a menu row");
|
||||
check(/#uploadMenu button:focus-visible\s*\{[^}]*box-shadow:\s*inset/.test(css),
|
||||
"the keyboard cue is drawn inside the row instead");
|
||||
check(!/\.upload-menu-list button:focus/.test(css),
|
||||
"no unscoped menu focus rule is left behind");
|
||||
|
||||
console.log("\n" + pass + " checks, " + fail + " failures");
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,28 @@
|
||||
<!--
|
||||
Release notes template — keep them SHORT.
|
||||
|
||||
One bullet per user-visible change, one line each. A release page is a
|
||||
"What do I get?" list, not a design document or a test report.
|
||||
|
||||
* `## What's Changed`, then a flat bullet list. No tables, no sub-sections,
|
||||
no design rationale, no test counts, no per-file detail.
|
||||
* Start each bullet with an area label: **extract** / **ui** / **fix** /
|
||||
**build** / **docs**.
|
||||
* Describe what the user can now DO, in their words. "encrypted archives
|
||||
work" beats "implemented a virtual ISeekInStream that splices a
|
||||
pseudo-header, the archive and the decrypted header".
|
||||
* Close with one line for the artifact (name · size · sha256 · machine) and
|
||||
one line for the fork marker / rollback build.
|
||||
* Implementation notes, measurements, verification runs and known gaps go to
|
||||
CHANGELOG.md and docs/ — link them if needed, never inline them.
|
||||
-->
|
||||
|
||||
## What's Changed
|
||||
|
||||
- **area**: one line, the user-visible outcome
|
||||
- **area**: another one
|
||||
- **fix**: what used to be broken, and is not any more
|
||||
|
||||
`your-artifact.elf` · NNN,NNN bytes · sha256 `…` · x86-64 (`e_machine 0x003e`)
|
||||
|
||||
One line for the fork marker and/or the rollback build, with a link.
|
||||
+30
-3
@@ -6,9 +6,11 @@ gen/
|
||||
web-file-mgr-linux
|
||||
|
||||
# Sandbox scratch under .build/: ignore the whole directory, then re-allow the
|
||||
# handful of files that are actually part of the repo (build scripts + the
|
||||
# ELF checker). A whitelist is the only thing that survives -- every debugging
|
||||
# session drops a new probe directory in here.
|
||||
# handful of files that are actually part of the repo -- the build scripts, the
|
||||
# ELF checker, and the validation harnesses. A whitelist is the only thing that
|
||||
# survives: every debugging session drops a new probe directory in here, and a
|
||||
# scratch-tree cleanup would otherwise delete the harnesses for good (they exist
|
||||
# nowhere else -- not in git, not on the release page).
|
||||
.build/*
|
||||
!.build/build-elf.sh
|
||||
!.build/build-elf-wsl.sh
|
||||
@@ -17,9 +19,34 @@ web-file-mgr-linux
|
||||
!.build/extract-demo.html
|
||||
!.build/build-elf.log
|
||||
|
||||
# Validation harnesses: headless-DOM unit tests against assets/main.js, the
|
||||
# real-page preview server and its Chromium pass, and the README / comparison /
|
||||
# proposal / UI-demo render checks. Run them before shipping a UI or docs change.
|
||||
# ui_demos_check.mjs also turns the UI review criteria (overflow at three
|
||||
# breakpoints, WCAG-AA contrast, focus-ring containment, aria-disabled hygiene)
|
||||
# into assertions — they are invisible to the eye, so they need a test.
|
||||
!.build/ui_*_test.mjs
|
||||
!.build/ui_demos_check.mjs
|
||||
!.build/preview_build.py
|
||||
!.build/preview_stub.html
|
||||
!.build/preview_check.mjs
|
||||
!.build/readme_header_render.mjs
|
||||
!.build/compare_*_check.mjs
|
||||
!.build/proposal_check.mjs
|
||||
|
||||
# Generated 7z fixtures (tests/make_sevenz_fixtures.py rebuilds them).
|
||||
tests/fixtures-7z/
|
||||
|
||||
# Staging trees the fixture generators copy from (make-rar-fixtures.bat uses
|
||||
# fixture-stage, make-zip-enc-fixtures.bat uses fixture-stage-zip). The
|
||||
# archives they produce under tests/fixtures-real/ ARE committed.
|
||||
tests/fixture-stage/
|
||||
tests/fixture-stage-zip/
|
||||
|
||||
# Python bytecode (the fixture generators and bench_driver are hand-run)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Editor / OS noise
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
+423
-1
@@ -4,6 +4,83 @@ All notable changes to **PS5 Web File Manager** are documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> Release artifact for v1.9.3M — **published** (<https://github.com/LisherSong/ps5-web-file-manager/releases/tag/v1.9.3M>),
|
||||
> after end-to-end validation on a real console:
|
||||
> `web-file-mgr-v1.9.3M.elf` — size 903 448 bytes (~882 KiB)
|
||||
> sha256 `8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7`
|
||||
> ELF class 64, little-endian, e_machine `0x003e` (x86_64-sie-ps5)
|
||||
>
|
||||
> **The trailing `M` is the fork marker** (Modified build, maintained by
|
||||
> LisherSong). Upstream owendswang ships plain `vX.Y.Z`, so a version string on
|
||||
> its own says which of the two you are looking at. The marker rides on
|
||||
> `VERSION_TAG` rather than on a display-only constant, so `/api/version`, the
|
||||
> PS5 start-up notification, the stdout banner, the UI footer and the ELF file
|
||||
> name all gain it in one move — nothing can be forgotten in one of the five.
|
||||
> A second benefit is that a fork build can no longer collide with an upstream
|
||||
> artifact of the same upstream version, a mix-up that has already happened
|
||||
> twice. The footer additionally carries a tooltip spelling the marker out
|
||||
> (`versionTooltip`, en + zh). This is the first release using the convention;
|
||||
> the older entries below keep their original plain numbers.
|
||||
>
|
||||
> This is the first **published** binary to carry the encrypted-archive work and
|
||||
> the dictionary-reporting fix (`[v1.9.3M]` below). It was validated end to end
|
||||
> on a real console before release; the acceptance checklist that was run is
|
||||
> `docs/DEVICE-TEST-v1.9.3M.md`. The previous release, v1.9.2, contains none of
|
||||
> this work.
|
||||
>
|
||||
> Its size is **unchanged yet again** (903 448 B) although the content grew, for
|
||||
> the sixth build in a row. Every round of this release has only moved
|
||||
> `.rodata`, and by less than the 16 KiB section alignment absorbs:
|
||||
>
|
||||
> | build | `.rodata` | delta | what changed |
|
||||
> |---|---|---|---|
|
||||
> | `v1.9.3` (pre-marker) | 0x025F80 | — | — |
|
||||
> | `v1.9.3M` + encrypted archives | 0x0260C0 | +0x140 | the `M` marker, its tooltip, re-gzipped assets |
|
||||
> | `+` upload menu and i18n names | 0x026A40 | +0x980 | the menu, the hint, the new copy |
|
||||
> | `+` hint move, status clamp, retry key | 0x026B40 | +0x100 | final copy and CSS |
|
||||
> | `+` menu row highlight fix | 0x026CC0 | +0x180 | the scoped row highlight rules and their comment |
|
||||
> | `+` always-on extract button | 0x026F00 | +0x240 | the un-hidden button, three disabled reasons, the tooltip fix |
|
||||
>
|
||||
> `.text` is byte-for-byte the same size across all six, which is the expected
|
||||
> shape for a change that touches no C logic. **Never infer "nothing changed"
|
||||
> from the file size** — compare sections with `readelf -SW`. Each of the six
|
||||
> carries a different sha256 despite the identical size, so the digest, not the
|
||||
> byte count, is what identifies a build.
|
||||
>
|
||||
> Putting the extract button on screen at all times is not free: it widens the
|
||||
> resting toolbar by its own width, so the width at which the toolbar wraps onto
|
||||
> a second row moves out from 1080px to 1190px in Chinese, and from 1230px to
|
||||
> 1350px in English, where the labels are longer. The console is 1920px wide and
|
||||
> 1280px still fits in Chinese, so the trade was accepted: the alternative was
|
||||
> leaving the entry hidden until an archive happened to be selected, which is
|
||||
> what made it undiscoverable in the first place. The measured threshold is now
|
||||
> pinned by the headless harness rather than left to be rediscovered.
|
||||
>
|
||||
> Release artifact for v1.9.2:
|
||||
> `web-file-mgr-v1.9.2.elf` — size 870 488 bytes (~850 KiB)
|
||||
> sha256 `177e90fecf93a0251e83f67884fba4551051be248330b0d70fda8ea732f88e84`
|
||||
> ELF class 64, little-endian, e_machine `0x003e` (x86_64-sie-ps5)
|
||||
>
|
||||
> Behaviourally identical to the published v1.9.1 binary — the only source
|
||||
> delta is the version literal itself (`VERSION_TAG` in the Makefile, plus the
|
||||
> UI footer fallback in `assets/main.js`). The build is reproducible: reverting
|
||||
> those two literals and rebuilding reproduces the v1.9.1 ELF byte for byte, so
|
||||
> nothing else differs. See [v1.9.2] below for why the version moved at all.
|
||||
>
|
||||
> Release artifact for v1.9.1 (superseded — the tag pointed four commits behind
|
||||
> the tree that actually produced this binary):
|
||||
> `web-file-mgr-v1.9.1.elf` — size 870 488 bytes (~850 KiB)
|
||||
> sha256 `24392aff6ddcca4dc0ea969cce356bd693ac52efe8a117d61ee1c814aa43cd07`
|
||||
> ELF class 64, little-endian, e_machine `0x003e` (x86_64-sie-ps5)
|
||||
>
|
||||
> Built on the 7z-complete tree: LZMA SDK decode subset + self-written codec
|
||||
> chain, 7zAES, and ZIP/RAR/7z volume support. Build-system-only delta vs the
|
||||
> first v1.9.1 artifact (1 017 864 B): `src/demangle_stub.c` keeps libc++abi's
|
||||
> Itanium name demangler (105 KiB, only reachable from the uncaught-exception
|
||||
> path) out of the link, and `-Wl,--icf=all` folds identical functions.
|
||||
> −15.8% overall with no change to functionality or decompression throughput.
|
||||
> See `docs/SIZE-OPTIMIZATION.md`.
|
||||
>
|
||||
> Release artifact for v1.9:
|
||||
> `web-file-mgr.elf` — size 919 440 bytes (~897 KiB)
|
||||
> sha256 `bb8f17e9addc6a9984f611503ca01b51f8984b773d353630da1f25bd1a28a997`
|
||||
@@ -34,6 +111,349 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
> (`Makefile` CFLAGS `-Ithird_party/unrar`, `src/extract.c` forward
|
||||
> declaration of `extract_progress`); no vendored or engine changes.
|
||||
|
||||
## [v1.9.3M] — 2026-09-24
|
||||
|
||||
**Encrypted archives now extract end to end — ZIP (both schemes), RAR, and 7z
|
||||
with an encrypted header.**
|
||||
|
||||
Until now every encrypted archive was refused up front, even though the
|
||||
password field, the prompt and `extract_password` copy had been in place
|
||||
since v1.9. The gap was in the engines, not the UI:
|
||||
|
||||
- **ZIP**: the vendored minizip-ng had been trimmed past its crypto
|
||||
backend, so the `-DHAVE_WZAES` / `-DHAVE_PKCRYPT` branches inside the
|
||||
(unmodified) `mz_zip.c` had no implementation to call.
|
||||
- **RAR**: rarlab UnRAR could decrypt, but `RARSetPassword` was never
|
||||
called.
|
||||
- **7z**: `-mhe=on` put the file names and the folder table inside the
|
||||
encrypted header, so the archive could not even be listed.
|
||||
|
||||
All three are wired now. A missing or wrong password is reported as
|
||||
`extract_password` (`ZIPX_ERR_PASSWORD` at the engine level), which is the
|
||||
code the task overlay's existing password prompt already reacts to.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The version string gains a fork marker: `v1.9.3` → `v1.9.3M`.** Upstream
|
||||
owendswang releases are plain `vX.Y.Z`, so the `M` (Modified) is what tells a
|
||||
user which of the two they are holding. It is part of `VERSION_TAG` in the
|
||||
Makefile, which means `/api/version`, the PS5 start-up notification, the
|
||||
stdout banner, the UI footer **and the ELF file name** all carry it at once.
|
||||
Because the file name changes too, a fork build can no longer shadow an
|
||||
upstream artifact of the same upstream version.
|
||||
- The UI footer tooltip (`versionTooltip`, en + zh) spells the marker out, so
|
||||
`v1.9.3M` is not left unexplained for someone who has not read this file.
|
||||
`loadVersion()` only ever rewrites the footer's text, so the tooltip survives
|
||||
the `/api/version` round trip.
|
||||
- **The upload button opens a menu instead of hiding half of itself behind a
|
||||
caret.** It used to be a main button plus a small arrow: users read the arrow
|
||||
as decoration and never found "upload a folder" at all. One click now lists
|
||||
**Upload Files / Upload Folder** (`#uploadMenu`, `role="menu"`), with Escape,
|
||||
an outside click and arrow keys handled, and focus moved into the list when it
|
||||
opens. The button keeps a caret so it is obvious that a list is coming.
|
||||
- The footer status line is now clamped to a single line with an ellipsis. It
|
||||
is a fixed-height row that also carries the version and the new drag hint, and
|
||||
a long "uploading 3/12: some-name.zip" used to wrap to two lines and spill out
|
||||
of the 46 px footer.
|
||||
- **The extract button is always on screen and greys out instead of being
|
||||
hidden.** It used to appear only once an extractable archive was selected, so
|
||||
the resting toolbar had no extract entry at all — the same discoverability
|
||||
problem the upload button was changed for. It is now always present, disabled
|
||||
and at 45% opacity whenever the selection cannot be extracted, and its tooltip
|
||||
names the reason: *"Select one archive to extract (ZIP / RAR / 7z)"*, the
|
||||
existing "please select the main volume" when only a `.partNN.rar` sub-volume
|
||||
is selected, and a new "only one archive can be extracted at a time" when
|
||||
several are selected — the old code answered *that* case with the main-volume
|
||||
message, which was simply the wrong sentence. Because `button:disabled` sets
|
||||
`pointer-events: none`, a disabled button cannot be hovered and its tooltip
|
||||
never appears at all, so `.extract-action:disabled` restores hit-testing
|
||||
without restoring clicks; the parent-directory button needed the same fix
|
||||
earlier.
|
||||
- The extract button's label is the short `extract` ("解压" / "Extract"),
|
||||
matching the other toolbar verbs, instead of `extractToCurrent` ("解压到当前
|
||||
目录" / "Extract to current folder"). A button that is on screen permanently
|
||||
should not also be the widest one in the toolbar, and the target folder is
|
||||
still named in the tooltip and in the confirmation dialog. The measured cost
|
||||
of the always-on button: the width at which the toolbar wraps onto a second
|
||||
row moves from 1080px to 1190px in Chinese and from 1230px to 1350px in
|
||||
English, where the labels are longer. 1280px still fits in Chinese and the
|
||||
console is 1920px wide.
|
||||
|
||||
### Added
|
||||
|
||||
- **Encrypted ZIP** — traditional PKWARE ("ZipCrypto", what `zip -e`
|
||||
writes) and WinZip AES-128/192/256 (method `99` + the `0x9901` extra
|
||||
field, what `7z -mem=AES256` writes), for stored and deflated entries.
|
||||
- **Encrypted RAR** — `-p` data encryption and `-hp` header encryption.
|
||||
`RARSetPassword` now runs right after `RAROpenArchiveEx` and before the
|
||||
first `RARReadHeaderEx`, which is the order unrar needs to decrypt a
|
||||
RAR5 header.
|
||||
- `password=` on `/api/extract` now reaches a real decrypt path for both
|
||||
engines. An empty or absent value means "no password", so the raw form
|
||||
field can be passed straight through.
|
||||
- **The UI retries a failed extraction with a password.** An
|
||||
`extract_password` failure used to end in an error box, which for ZIP and
|
||||
RAR meant the password could never be supplied at all — the prompt only
|
||||
existed for 7z. The failed task is now re-sent with whatever the user types,
|
||||
up to three times, and the remembered request keeps the original conflict
|
||||
policy and large-file opt-in. Cancelling or submitting an empty box falls
|
||||
back to the previous failure report. 7z keeps its up-front prompt so an
|
||||
encrypted header does not cost a wasted scan.
|
||||
- `extractPasswordRetryAsk` (en + zh) is the retry prompt's wording, distinct
|
||||
from the up-front `extractPasswordAsk`.
|
||||
- `third_party/minizip-ng/src/mz_crypt_wfm.c` — a local crypto provider for
|
||||
the trimmed minizip-ng: SHA-1, HMAC-SHA1 and AES-128/192/256, with the
|
||||
S-box and the GF(2^8) tables derived on first use so the binary gains no
|
||||
new `.rodata` lookup tables. PBKDF2 comes from the vendored `mz_crypt.c`;
|
||||
the CSPRNG reads `/dev/urandom` rather than `mz_os_rand()`, which keeps
|
||||
`rand`/`srand` out of the import table. Restored verbatim from upstream
|
||||
4.2.2: `mz_strm_wzaes.{c,h}`, `mz_strm_pkcrypt.{c,h}`.
|
||||
- `tests/make-zip-enc-fixtures.bat` and three real fixtures under
|
||||
`tests/fixtures-real/` (`enc-zipcrypto.zip`, `enc-aes256.zip`,
|
||||
`enc-aes256-store.zip`, password `secret123`).
|
||||
- **Encrypted 7z headers (`-mhe=on`)** — the last remaining format gap. With
|
||||
`-mhe=on` the header is itself a folder holding the file names, the folder
|
||||
table and every entry size, so the vendored SDK (whose C decoder has no
|
||||
7zAES coder at all) abandons the archive with `SZ_ERROR_UNSUPPORTED` before
|
||||
it can list a single entry. `src/sevenz_header.c` now reads the
|
||||
`k7zIdEncodedHeader` record, decodes its one folder through the project's
|
||||
own 7zAES path (`src/sevenz_chain.c`) and then gives the SDK a small virtual
|
||||
`ISeekInStream` in which that record has been replaced by the plaintext —
|
||||
the rewritten start header, the decrypted header at the offset the encoded
|
||||
one already occupied, and the real archive everywhere else, so every offset
|
||||
the archive stores still points where it did. Nothing on disk is written to.
|
||||
Archives whose header is only *compressed* (`-mhc=on`, the default) are
|
||||
detected from one byte and never touched, and a wrong password comes back as
|
||||
`ZIPX_ERR_PASSWORD` like any other encrypted archive.
|
||||
- **A visible drag-and-drop hint** (`dropUploadHint`, en + zh) in the footer:
|
||||
*"Drag files or folders into this window to upload"*. Dropping already worked,
|
||||
but nothing but the drop overlay itself ever said so, and that only appears
|
||||
once a drag is under way. It is `remote-only`, like the upload button it
|
||||
describes, and starts hidden so the console browser never flashes it.
|
||||
- `uploadFiles` (en + zh) for the menu's file entry, and
|
||||
`extractPasswordFirstAsk` — a first-failure prompt that says the archive is
|
||||
encrypted rather than blaming a password the user was never asked for.
|
||||
- `extractSelectArchive` and `extractOneAtATime` (en + zh): the two reasons the
|
||||
always-on extract button can be greyed out with nothing useful selected, and
|
||||
with several archives selected. The third reason, `extractSelectMainVolume`,
|
||||
already existed.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Compiler-flag changes now invalidate objects.** `make` cannot see a
|
||||
flag change, so adding `-DHAVE_WZAES -DHAVE_PKCRYPT` left the existing
|
||||
`mz_zip.o` / `mz_crypt.o` untouched — and since nothing referenced the
|
||||
new streams any more, `--gc-sections` dropped the encryption code again
|
||||
while the link still reported success (the first build of this change was
|
||||
byte-for-byte the published release). The Makefile now records the
|
||||
third-party flag set in `ps5-obj/.third_party_cflags` /
|
||||
`linux-obj/.third_party_cflags` and rebuilds only when it really changes —
|
||||
the same trap the older `LzmaDec.o` rule was working around, generalised.
|
||||
- `ZIPX_ERR_UNSUPPORTED` no longer covers encryption — it is now "multipart
|
||||
or unsupported compression method" only.
|
||||
|
||||
- **An oversized archive dictionary is now reported as such.** An archive whose
|
||||
dictionary exceeds what the build allows used to fail with
|
||||
`extract_entry_too_large` / "A file inside the archive is too large" naming the
|
||||
entry that happened to be in flight — for the 8 GiB-dictionary fixture the
|
||||
message blamed a 7 KB text file. The dictionary is a property of the archive,
|
||||
not of the entry, so it now has its own status (`ZIPX_ERR_LIMIT_DICT`), its own
|
||||
i18n code (`extract_dict_too_large`, en + zh) and the real numbers, which
|
||||
unrar hands over in the `UCM_LARGEDICT` callback: *"needs 8192 MiB (limit
|
||||
4096 MiB)"*.
|
||||
- The **behaviour is unchanged on purpose**: we still refuse, matching what
|
||||
rarlab's own CLI does by default ("8 GB dictionary exceeds the 4 GB limit and
|
||||
needs more than 8 GB of memory; use -md8g or -mdx8g"). Answering `1` to
|
||||
`UCM_LARGEDICT` would only move the problem into `Unpack::Init()`, which
|
||||
allocates the whole dictionary in one block — on a 16 GB console that trades a
|
||||
clean error for an OOM-kill of the payload mid-extraction. Note also that
|
||||
**RAR 5.0 headers cannot express more than 4 GiB** (four dictionary bits,
|
||||
`arcread.cpp:871`), so only RAR7 headers can reach this path at all.
|
||||
- `tests/test_rar_extract.c:test_dict_limit` + a new synthetic fixture
|
||||
`tests/fixtures/dict-8g.rar` (built by `tests/make_fixtures.py:bigdict()`,
|
||||
which writes a minimal valid RAR5 archive by hand: no compressor can produce
|
||||
such a header, and `Rar.exe 7.23` refuses to create RAR7 archives — `-ma4`,
|
||||
`-ma6`, `-ma7` all exit 7). Verified independently with rarlab's own tools:
|
||||
`UnRAR lt` reports `-md=8g` and `UnRAR t -mdx12g` extracts it cleanly.
|
||||
|
||||
- **`err_extract_unsupported` copy was two releases out of date.** It read
|
||||
*"only plain ZIP, single-volume RAR, and 7z are supported"* — the exact
|
||||
inverse of what the build does now, because encrypted archives and
|
||||
multi-volume RAR are both supported. The label now names what is actually
|
||||
accepted (`.zip` / `.rar` / `.7z`, including their multi-volume and
|
||||
encrypted forms); the backend's own sentence, which carries the real cause,
|
||||
is still appended after it. The same stale wording in both READMEs is
|
||||
corrected too. `.rodata` +0x40 (64 B), nothing else changed.
|
||||
|
||||
- **The password prompt never appeared for an archive in a non-ASCII folder
|
||||
— the user only ever saw the failure alert.** The retry that asks for a
|
||||
password was looked up by *path*, and paths are not stable across the wire:
|
||||
the server escapes every byte ≥ 0x80 as `\u00XX` when it serialises a name
|
||||
and `fs_path_value()` maps those back to raw bytes when it receives one, so
|
||||
the string the page holds for a directory and the string the task reports back
|
||||
differ for every name that is not pure ASCII. The lookup missed, the retry
|
||||
returned false, and the plain "extract failed" box was shown instead — the
|
||||
archive had to be extracted again by hand before the prompt would appear. The
|
||||
remembered request is now keyed by **task id**, which the server assigns and
|
||||
which survives the round trip untouched; the retry also re-sends the path the
|
||||
*server* reported rather than the one the page was holding. On-device symptom:
|
||||
upload `x.zip` into a Chinese-named folder, choose "upload and extract" → no
|
||||
prompt, then the raw error.
|
||||
|
||||
- **Entry names in error messages were unreadable mojibake** —
|
||||
`解压失败: 密码错误,或压缩包未使用所提供的密码加密: â®…ç§.psd (entry is
|
||||
encrypted and no password was given)`. The listing has always translated the
|
||||
byte-mapped names back through `decodeFsText()` for display, but
|
||||
`backendErrorText()` used the raw `error_arg` — the one place where the name
|
||||
matters most. Both `error_arg` and the backend's own sentence now go through
|
||||
the same translation, so a GBK name stored inside a ZIP reads as Chinese
|
||||
again.
|
||||
|
||||
- **An archive with a non-ASCII name inside a non-ASCII folder could not be
|
||||
extracted after upload.** `uploadAndExtractFile()` joined the byte-mapped
|
||||
directory with the real Unicode file name, producing a mixed path; the server
|
||||
only byte-repairs a string when *every* non-ASCII code point in it is ≤ 0xFF,
|
||||
so one CJK character made it skip the repair and look for a path that does not
|
||||
exist. `encodeFsText()` (the inverse of `decodeFsText()`) now byte-maps the
|
||||
name before the join, which is also applied to the path `New Text` hands to
|
||||
the editor. Pure-ASCII paths and pure-real-Unicode paths were unaffected,
|
||||
which is why this survived until someone hit the mixed case.
|
||||
|
||||
- **The upload menu's row highlight painted as a broken shape.** Selecting a
|
||||
row drew a 3px blue ring at `outline-offset: 2px` that cleared the panel's
|
||||
6px padding, overlapped the row above, and kept the row's own 6px corner
|
||||
radius instead of growing with the offset — so the highlight read as a
|
||||
detached outline with two arcs hanging off its sides rather than a selected
|
||||
row. Two rules were fighting. The ring came from the generic `button:focus`
|
||||
rule, which is sized for a 54px toolbar button and was never meant to reach a
|
||||
46px list row. Underneath it, the panel's own
|
||||
`.upload-menu-list button:hover:not(:disabled)` fill had **never applied at
|
||||
all**: it ties on specificity (0,3,1) with the generic
|
||||
`button:not(.row-action):hover:not(:disabled)` rule and that one comes later
|
||||
in the file, so it won the cascade — hover and focus therefore ended up two
|
||||
different colours (`#303945` against `#2b343e`), and a hovered row lit up
|
||||
while the keyboard-focused row stayed lit, which is what made two rows look
|
||||
selected at once. Both rules are now scoped to the panel id, rows highlight
|
||||
by fill alone, and the keyboard cue is a 2px **inset** ring drawn inside the
|
||||
row, where it cannot cross the panel edge at any row height.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_zip_extract.c` runs each encrypted fixture four ways (no
|
||||
password → `PASSWORD`, empty → `PASSWORD`, wrong → `PASSWORD`, correct →
|
||||
`ZIPX_OK` with a byte-level content check), plus a case proving the
|
||||
limits still apply when a password has been handed over.
|
||||
- `tests/test_rar_extract.c` exercises `enc-v6.rar` the same way, including
|
||||
that nothing is published on the failing paths.
|
||||
- `tests/test_sevenz_extract.c` runs `aeshe.7z` three ways: no password →
|
||||
`ZIPX_ERR_PASSWORD`, wrong password → `ZIPX_ERR_PASSWORD`, correct password
|
||||
→ success with the content compared byte for byte, and no staging tree left
|
||||
behind on any of them.
|
||||
- `.build/ui_retry_test.mjs` loads the real `assets/main.js` into a stubbed DOM
|
||||
and checks the frontend retry flow: the request is remembered with its
|
||||
conflict policy and large-file flag, a failure retries with the typed
|
||||
password, cancel and empty input give up, and the retry count caps at three.
|
||||
It also carries the regression case for the missing prompt: a task whose
|
||||
`src`/`dst` come back byte-repaired (a non-ASCII folder) must still be retried,
|
||||
and a structural check that no retry entry is keyed by a path. **40 checks,
|
||||
0 failures.**
|
||||
- `.build/ui_upload_menu_test.mjs` is the markup-side counterpart: it asserts
|
||||
that every `data-i18n` key in `index.html` exists in both language files, that
|
||||
the two language files carry the same keys, that the upload menu and the drag
|
||||
hint exist and start hidden, that the removed file/folder split is gone from
|
||||
both the markup and the stylesheet, that the click handlers are bound to the
|
||||
new ids, and that the classes the markup uses are actually styled. Four of its
|
||||
checks pin the row-highlight cascade: the hover and focus rules must be scoped
|
||||
to the panel, the row ring must be suppressed, the keyboard cue must be an
|
||||
inset shadow, and the unscoped forms must not come back — a rule that silently
|
||||
loses the cascade is exactly the kind of thing a static check can still catch.
|
||||
A further group covers the extract button, which is now always on screen: the
|
||||
markup must not hide it, main.js must never assign to `extractBtn.hidden`, the
|
||||
disabled rule must keep the tooltip hoverable, and there must be a message for
|
||||
each reason it can be unavailable. One check sweeps the other direction —
|
||||
every `t("...")` literal in `main.js` (117 keys) must exist in both language
|
||||
files — because a key reached only from script code is invisible to the markup
|
||||
sweep, which is how a message goes missing unnoticed.
|
||||
**40 checks, 0 failures.** Both scripts are hand-run — the project has no
|
||||
browser test runner — but each one exits non-zero on failure.
|
||||
- `.build/preview_build.py` + `.build/preview_check.mjs` render the real page
|
||||
against a fixture API in headless Chromium and assert what a screenshot alone
|
||||
cannot: the menu is hidden at rest, opens on click, moves focus into the list,
|
||||
reaches the hidden `<input type="file">`, and closes on a choice, an outside
|
||||
click and Escape. It reads back the *computed* highlight for a focused, a
|
||||
hovered and a keyboard-focused row, which is the only way to settle a cascade
|
||||
question — a rule losing to a generic one and a ring leaking out of its
|
||||
container both look fine in the source. It is how the three layout/highlight
|
||||
mistakes of this round were caught (a hint that pushed the toolbar onto a
|
||||
second row, a status line that wrapped out of the footer, and the broken row
|
||||
highlight described under Fixed).
|
||||
- Host totals: **140 ZIP + 37 RAR = 177 checks**, 0 failures.
|
||||
- 7z totals: **27 cases, 0 failures** (`tests/run-sevenz-tests.sh`), and the
|
||||
`KNOWN_GAPS` list that held `aeshe` is now empty — the encrypted-header
|
||||
fixture passes through both the folder decoder and the extraction facade.
|
||||
|
||||
### Still open
|
||||
|
||||
- End-to-end validation of the built ELF on a real console.
|
||||
|
||||
## [v1.9.2] — 2026-09-05
|
||||
|
||||
**Version-string-only re-release: the tag now points at the tree that produced
|
||||
the published binary.**
|
||||
|
||||
The `v1.9.1` tag sat four commits behind the tree its ELF was built from, so
|
||||
cloning that tag could not rebuild the published artifact. v1.9.2 is cut from
|
||||
the right commit. It is functionally identical to the v1.9.1 binary — the only
|
||||
source delta is the version literal itself (`VERSION_TAG` in the Makefile, plus
|
||||
the UI footer fallback in `assets/main.js`) — and the build is reproducible:
|
||||
reverting those two literals reproduces the v1.9.1 ELF byte for byte.
|
||||
|
||||
Release artifact: `web-file-mgr-v1.9.2.elf` — 870 488 bytes (~850 KiB), sha256
|
||||
`177e90fecf93a0251e83f67884fba4551051be248330b0d70fda8ea732f88e84`.
|
||||
|
||||
## [v1.9.1] — 2026-09-05
|
||||
|
||||
**7z extraction — a third engine — plus a size and throughput pass.**
|
||||
|
||||
Added:
|
||||
|
||||
- `src/sevenz_extract.{c,h}` — the 7z engine, built on the LZMA SDK 26.03
|
||||
decode subset plus the project's own pull-based codec chain
|
||||
(`src/sevenz_chain.c`). The SDK's own `SzArEx` path only understands folders
|
||||
of up to four coders, which cannot express BCJ2's five — hence the
|
||||
self-parsed folder table and the pull-based chain. Dispatch is by extension
|
||||
in `src/extract.c`; the three-phase model, the limit profiles and the
|
||||
conflict policy are shared with ZIP and RAR, so `.7z` files get the same
|
||||
**Extract** button as `.zip` and `.rar`.
|
||||
- `src/sevenz_volstream.{c,h}` — `.7z.001` / `.z01` byte-split volume sets,
|
||||
stitched by name; open the first volume.
|
||||
- 7zAES content decryption (AES-256-CBC). The frontend asks for the password
|
||||
*up front* here, so an unencrypted archive does not pay for a wasted scan.
|
||||
|
||||
Performance (decode-only, no functional change):
|
||||
|
||||
- LZMA SDK assembly decoder (`Asm/x86/LzmaDecOpt.asm` assembled with jwasm,
|
||||
with an automatic pure-C fallback) ≈ 1.26×.
|
||||
- Single-coder pure-LZMA2 folders decode multi-threaded
|
||||
(`Lzma2DecMt` via `src/sevenz_mt.c`, 8 threads) ≈ 1.37×.
|
||||
- The extract path drops its per-entry `fsync` — publish is rename-only and
|
||||
there is no resume feature to protect (≥ 14× measured on an 8000-file
|
||||
archive; see `docs/EXTRACTION-PERF.md`).
|
||||
|
||||
Build and size:
|
||||
|
||||
- `VERSION_TAG` v1.9.1. `src/demangle_stub.c` keeps libc++abi's Itanium name
|
||||
demangler (105 KiB, reachable only from the uncaught-exception path) out of
|
||||
the link, and `-Wl,--icf=all` folds identical functions: −15.8% overall with
|
||||
no functional or throughput change, 1 017 864 B → 870 488 B. Measured in
|
||||
`docs/SIZE-OPTIMIZATION.md`.
|
||||
|
||||
Known gap at the time: 7z `-mhe=on` encrypted headers — closed in v1.9.3M with
|
||||
`src/sevenz_header.c`.
|
||||
|
||||
Tests: **163 checks** (ZIP 108 + RAR 27 + 7z 28), 0 failures, plus a successful
|
||||
PS5 cross-compile.
|
||||
|
||||
## [v1.9] — 2026-09-05
|
||||
|
||||
**RAR engine replaced: rarlab UnRAR 7.20.1 (v6 / multi-volume / decryption-capable).**
|
||||
@@ -399,7 +819,9 @@ python3 .build/check-elf-gzip.py ./web-file-mgr.elf # 7/7 v1.7 keys + 1 v
|
||||
A long-form technical write-up of this upgrade lives in
|
||||
[`docs/UPGRADE-v1.8-rar-support.md`](./docs/UPGRADE-v1.8-rar-support.md).
|
||||
The vendoring decision tree (and the v1.9 plan) is in
|
||||
[`third_party/unrar/VENDORED.md`](./third_party/unrar/VENDORED.md).
|
||||
[`third_party/unrar7/VENDORED.md`](./third_party/unrar7/VENDORED.md) — v1.8
|
||||
shipped it at `third_party/unrar/VENDORED.md`; the directory was renamed in
|
||||
v1.9 when the engine was replaced.
|
||||
|
||||
### Credits
|
||||
|
||||
|
||||
+288
-42
@@ -1,10 +1,10 @@
|
||||
# 交接文档 — ps5-web-file-manager 工作进度
|
||||
|
||||
> 交接时间:2026-09-15 · 分支 `main` · 最新提交 **`0d036a7`** · tag `v1.9.1` 指向 `1fa2f09`(**需重打到 `0d036a7`**)
|
||||
> 交接时间:2026-09-25 · 分支 `main` · 最新提交 **`770dcb8`**(「docs: mark v1.9.3M as published」)——tag + Release **`v1.9.3M`** 指向发布提交 `ba668ad`,三者均已推送
|
||||
>
|
||||
> **主线(用户 2026-09-12 指令)**:「先从 zip 分卷开始吧,然后把六种组合打齐,并把密码通道补齐,注意一些报错信息提示的时候尽量详细准确」
|
||||
> **状态:主线全部闭合。** 六种组合(ZIP/RAR/7z × 单卷/分卷)+ 密码通道(RAR 加密 + 7zAES)+ 报错详细信息,全部落地、测试全绿、PS5 ELF 构建成功。
|
||||
> **剩余**:① 推送 `0d036a7`;② tag 重打;③ PS5 真机端到端验证;④ `-mhe=on` 加密头(唯一功能缺口)。
|
||||
> **状态:主线全部闭合,格式面已无已知缺口,且已发版。** 六种组合(ZIP/RAR/7z × 单卷/分卷)+ 密码通道(ZIP ZipCrypto/AES + RAR `-p`/`-hp` + 7zAES **含 `-mhe=on` 加密头**)+ 报错详细信息,全部落地、测试全绿、PS5 ELF 构建成功。
|
||||
> **剩余**:① ~~真机端到端验证~~ **已于 2026-09-24 通过**(用户实机复测三项反馈全过);② ~~发版~~ **已发 `v1.9.3M`**。当前唯一遗留是「含目录条目 + 覆盖模式」第二次解压失败是否属设计意图(见 §六)。
|
||||
|
||||
---
|
||||
|
||||
@@ -12,30 +12,30 @@
|
||||
|
||||
| 维度 | 状态 |
|
||||
|---|---|
|
||||
| 解压引擎 | ZIP / RAR / 7z × 单卷/分卷(6 组合)+ 7zAES / RAR 加密 |
|
||||
| 主机测试 | **ZIP 108 + RAR 27 + 7z 28 = 163 checks,0 失败**(MinGW gcc 16.2.0) |
|
||||
| PS5 构建 | ✅ WSL prospero-clang 18.1.8,一键脚本可复现 |
|
||||
| ELF 产物 | `web-file-mgr-v1.9.1.elf` · 1,017,864 B · sha256 `05952ac05fb937e8758846cbd8d495a427670e3cfb7f4020d42d4c6fe8c21631` · e_machine=0x003e |
|
||||
| GitHub | `main` 已推到 `1fa2f09`;`0d036a7` 待推;`v1.9.1` tag 待重打 |
|
||||
| 唯一功能缺口 | 7z `-mhe=on`(加密头) |
|
||||
| 解压引擎 | ZIP / RAR / 7z × 单卷/分卷(6 组合)+ 三种加密(ZIP ZipCrypto/WinZipAES、RAR `-p`/`-hp`、7zAES 与 `-mhe=on` 加密头)全部打通 |
|
||||
| 主机测试 | **ZIP 140 + RAR 37 = 177 checks,0 失败**(MinGW gcc)+ 7z 套件 **27 用例 0 失败**(`KNOWN_GAPS` 已清空)+ 前端三份:`.build/ui_retry_test.mjs` **40 checks**、`.build/ui_upload_menu_test.mjs` **40 checks**、`.build/preview_check.mjs` **12 断言**(无头 Chromium) |
|
||||
| PS5 构建 | ✅ WSL prospero-clang 18.1.8,一键脚本可复现;构建已实测**确定性**(同源两次构建 sha256 相同,并用 `cmp` 逐字节验证过) |
|
||||
| ELF 产物(**已发布**) | `web-file-mgr-v1.9.3M.elf` · 903,448 B · sha256 `8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7` · e_machine=0x003e(上传菜单 + 拖拽提示 + 口令重试改按键 id + 错误文案编码修复 + 菜单行高亮层叠修复 + 解压按钮常显置灰;2026-09-24 真机验证通过后发布) |
|
||||
| 上一个发布产物(回滚点) | `web-file-mgr-v1.9.2.elf` · 870,488 B · sha256 `177e90fecf93a0251e83f67884fba4551051be248330b0d70fda8ea732f88e84`(不含加密改动) |
|
||||
| GitHub | `main` = `ba668ad`、tag `v1.9.3M`、Release `v1.9.3M` 三者均已推送/发布,资产 sha256 与本地逐字节一致 |
|
||||
| 已知功能缺口 | **无**(`-mhe=on` 已于 2026-09-23 补齐) |
|
||||
| README | 2026-09-25 **重写为中英双语「功能导向」结构**(原为上游式的逐版本累加稿):删去六段 `What's new in vX.Y.Z`,改为 *功能 / 压缩包支持矩阵 / 限额与安全 / 快速上手 / 构建 / 使用 / 校验 / 测试 / 项目结构 / **与上游的差异** / 备注 / FAQ / 版本历史*;逐版本正文已迁入 `CHANGELOG.md`(并补上了原先缺正文的 `[v1.9.1]` `[v1.9.2]` 两节)。**注意**:上游 README 里的 `.elf` 一键启动(`localhost:9021`)**本仓源码中已不存在**,重写时未回填 |
|
||||
|
||||
### 待用户在自己终端执行
|
||||
### 发布命令(v1.9.2)
|
||||
|
||||
```bash
|
||||
cd "/c/Users/songl/Desktop/Web File Manager/ps5-web-file-manager"
|
||||
|
||||
# 1) 推送本轮的 /api/version 改动
|
||||
git push origin main
|
||||
git push origin v1.9.2
|
||||
|
||||
# 2) v1.9.1 重打到最新(1fa2f09 -> 0d036a7,让 tag 内含 /api/version + 新 ELF)
|
||||
git tag -f -a v1.9.1 -m "v1.9.1 -- 7z + multi-volume + 7zAES + PS5 build + version API" 0d036a7
|
||||
git push -f origin v1.9.1
|
||||
|
||||
# 3) 可选:发 Release(附带 ELF)
|
||||
gh release create v1.9.1 --title "v1.9.1" --notes-file .git/tag-v191.txt web-file-mgr-v1.9.1.elf
|
||||
gh release create v1.9.2 web-file-mgr-v1.9.2.elf \
|
||||
--repo LisherSong/ps5-web-file-manager --title "v1.9.2" --notes-file <notes.md>
|
||||
```
|
||||
|
||||
沙箱内 git 出站 HTTPS 被拦(502/403),推送只能用户侧执行。
|
||||
沙箱内 git 出站 HTTPS **可用**(早先记的"被拦"是误判:`timeout 25 git ...`
|
||||
命中的是 `C:\Windows\System32\TIMEOUT.EXE`,报参数错误而非网络错误)。`gh` 同样可用,
|
||||
所以 commit / tag / push / 发 Release 都可以在会话里直接跑。
|
||||
|
||||
---
|
||||
|
||||
@@ -69,24 +69,36 @@ bash /home/song/build.sh
|
||||
现在收敛到 `Makefile` 一处:
|
||||
|
||||
```make
|
||||
VERSION_TAG ?= v1.9.1 # 可用 make VERSION_TAG=v1.9.2 临时覆盖
|
||||
VERSION_TAG ?= v1.9.3M # 可用 make VERSION_TAG=v1.9.3 临时覆盖
|
||||
BIN := web-file-mgr-$(VERSION_TAG).elf
|
||||
```
|
||||
|
||||
改这一行会同时影响 **四处**:ELF 内嵌版本串、PS5 启动通知、输出文件名、UI 右下角。
|
||||
|
||||
**尾部 `M` = 改版标记(Modified,LisherSong 维护)**,从 v1.9.3M 起启用。上游
|
||||
owendswang 的发布版是纯 `vX.Y.Z`,故「带 M = 本仓、不带 = 上游」一眼可分。它刻意
|
||||
挂在 `VERSION_TAG` 上而不是做一个只管显示的独立常量:这样 `/api/version`、启动通知、
|
||||
stdout 横幅、UI 右下角、ELF 文件名**五处一次性全覆盖**,不可能只在其中一处漏掉。
|
||||
附带好处是产物名不再可能与上游同版本号的资产撞车(此前已撞过两次:本地
|
||||
`web-file-mgr-v1.9.2.elf` 与线上同名资产并存;`-DVERSION_TAG=v1.9.1` 的残留产物
|
||||
和已发布的 870 488 B 文件尺寸相同)。
|
||||
|
||||
| 提交 | 内容 |
|
||||
|---|---|
|
||||
| `f4fd464` | `VERSION_TAG` `v1.9` → `v1.9.1`;`.gitignore` 改白名单式(`.build/*` 全忽略 + `!` 放行 6 个脚本),`.workbuddy/` 也忽略 |
|
||||
| `1fa2f09` | 输出文件名派生自 `VERSION_TAG`;`build-elf-wsl.sh` 从 Makefile 反读版本(不硬编);`build-win.sh` 每次都推 WSL 脚本(原来只在缺失时推,导致改了脚本 WSL 侧仍跑旧版) |
|
||||
| `0d036a7` | **新增 `/api/version`**,前端右下角改从后端取值(见 2.3) |
|
||||
|
||||
⚠️ **`assets/main.js` 里还有第二处字面量** `APP_VERSION_FALLBACK`(`/api/version` 取不到时的兜底值)。
|
||||
它不在 Makefile 的控制范围内 —— **升版本号时必须一并改**,否则后端请求失败时页脚会显示旧版本。
|
||||
v1.9.2 就是这两处一起改的。
|
||||
|
||||
### 2.3 UI 版本号改由后端提供(`0d036a7`)
|
||||
|
||||
**问题**:`assets/main.js:38` 的 `const APP_VERSION = "v1.9"` 与 Makefile 无关,必然漂移。
|
||||
|
||||
**修法**:
|
||||
- 新 `src/version.c` — `GET /api/version` → `{"ok":true,"version":"v1.9.1","titleId":"FMGR88888"}`,直接来自 Makefile 已传的 `-DVERSION_TAG` / `-DTITLE_ID` 宏,没有第二处要记得改
|
||||
- 新 `src/version.c` — `GET /api/version` → `{"ok":true,"version":"v1.9.2","titleId":"FMGR88888"}`,直接来自 Makefile 已传的 `-DVERSION_TAG` / `-DTITLE_ID` 宏,没有第二处要记得改
|
||||
- `src/filemgr.c` 路由表加一行(紧邻 `/api/space`)+ `filemgr_internal.h` 声明 + `Makefile` `COMMON_SRCS`
|
||||
- 前端:字面量降级为 `APP_VERSION_FALLBACK`(先渲染,保证页脚不空),`loadVersion()` 后台刷新。**请求失败静默吞掉** —— 版本号显示错属于装饰性问题,不该弹错误 toast
|
||||
|
||||
@@ -100,17 +112,20 @@ BIN := web-file-mgr-$(VERSION_TAG).elf
|
||||
|
||||
| # | 引擎 | 单卷 | 分卷 | 密码 |
|
||||
|---|---|---|---|---|
|
||||
| ① | ZIP | ✅ | ✅ 三种命名约定 | ✅ |
|
||||
| ② | RAR | ✅ | ✅(vendor unrar 7.20.1) | ✅ `RARSetPassword` |
|
||||
| ③ | 7z | ✅ | ✅ `.7z.001` | ✅ 7zAES |
|
||||
| ① | ZIP | ✅ | ✅ 三种命名约定 | ✅ ZipCrypto + WinZip AES-128/192/256(2026-09-23 打通,此前 `mz_zip.c` 的加密分支没有后端可调) |
|
||||
| ② | RAR | ✅ | ✅(vendor unrar 7.20.1) | ✅ `RARSetPassword`(`-p` 与 `-hp` 头加密;2026-09-23 接线,此前从未被调用) |
|
||||
| ③ | 7z | ✅ | ✅ `.7z.001` | ✅ 7zAES(v1.9 起就有)+ `-mhe=on` 加密头(2026-09-23 打通,见 §八) |
|
||||
|
||||
### 3.2 测试
|
||||
|
||||
```bash
|
||||
export PATH="/c/mingw64/bin:/c/Users/songl/.workbuddy/binaries/PortableGit/versions/1.2.0/mingw64/bin:/c/Users/songl/.workbuddy/binaries/python/versions/3.13.12:/usr/bin:/bin:/c/Windows/System32:/c/Windows"
|
||||
cd "/c/Users/songl/Desktop/Web File Manager/ps5-web-file-manager"
|
||||
/usr/bin/bash tests/run-tests.sh # ZIP 108 + RAR 27
|
||||
/usr/bin/bash tests/run-sevenz-tests.sh # 7z 28(含 KNOWN_GAPS 检查)
|
||||
/usr/bin/bash tests/run-tests.sh # ZIP 140 + RAR 37 = 177
|
||||
/usr/bin/bash tests/run-sevenz-tests.sh # 7z 27(KNOWN_GAPS 已清空)
|
||||
|
||||
# 前端「密码失败后重试」流程(桩 DOM,无需浏览器)
|
||||
"/c/Users/songl/.workbuddy/binaries/node/versions/22.22.2-3/node.exe" .build/ui_retry_test.mjs # 27
|
||||
```
|
||||
|
||||
⚠️ **绝不写裸 `bash`** —— 可能解析到 `C:\Windows\System32\bash.exe`(WSL 启动器),脚本跑进 Linux,gcc/python 全变 Linux 版,报莫名错误。必须 `/usr/bin/bash`。
|
||||
@@ -135,15 +150,15 @@ cd "/c/Users/songl/Desktop/Web File Manager/ps5-web-file-manager"
|
||||
### 4.2 验证清单
|
||||
|
||||
```bash
|
||||
ls -lh web-file-mgr-v1.9.1.elf
|
||||
sha256sum web-file-mgr-v1.9.1.elf
|
||||
od -An -tx2 -j18 -N2 web-file-mgr-v1.9.1.elf # 期望 3e00
|
||||
strings -a web-file-mgr-v1.9.1.elf | grep -m1 '^v1\.'
|
||||
ls -lh web-file-mgr-v1.9.2.elf
|
||||
sha256sum web-file-mgr-v1.9.2.elf
|
||||
od -An -tx2 -j18 -N2 web-file-mgr-v1.9.2.elf # 期望 3e00
|
||||
strings -a web-file-mgr-v1.9.2.elf | grep -m1 '^v1\.'
|
||||
```
|
||||
|
||||
⚠️ `od -An -tx2` 打印的是**小端 short 的值**(`003e`),不是字节序(`3e00`)。脚本里比对用 `$((16#$EM))` 转数值(**62 = x86-64 ✅ / 183 = aarch64 ❌**)。
|
||||
|
||||
⚠️ **sha256 只在源码不变时可复现**。核对版本请用 `strings ... | grep ^v1.9`,比 sha256 直观且抗噪。
|
||||
✅ **构建已实测可复现**(2026-09-20):同一源码树两次构建 sha256 完全相同;把两处版本字面量回退成 `v1.9.1` 后重构,产物与已发布的 v1.9.1 ELF **逐字节一致**。所以 sha256 可以作为交付指纹用 —— 但它对任何源码改动都会全变(改一个字符串常量会令链接器重排 `.rodata` 字符串池,牵动 `.text` 里所有 RIP 相对位移,原始 diff 会放大到 5 万字节以上,属正常现象,别误判成"代码改了")。核对版本仍推荐 `strings ... | grep '^v1\.'`,最直观。
|
||||
|
||||
### 4.3 构建坑(已修,改 Makefile 前必读)
|
||||
|
||||
@@ -161,7 +176,7 @@ strings -a web-file-mgr-v1.9.1.elf | grep -m1 '^v1\.'
|
||||
LZMA SDK 26.03(public domain,已 vendor 到 `third_party/7z/`,解码子集 60 文件)有两个硬限制,**实测复现过**:
|
||||
|
||||
1. **`CSzFolder` 上限 4 coder / 3 bond** —— 7-Zip 默认 `-m0=bcj2` 链 = BCJ2 + 4×LZMA2 = 5 coder,`SzAr_DecodeFolder()` 返回 `SZ_ERROR_UNSUPPORTED`。注意 `SzArEx_Open()` 用的是另一套宽松扫描器(`k_Scan_NumCoders_MAX 64`),所以**文件列表和解压尺寸仍然全对**,失败只在解压时按条目暴露
|
||||
2. **C 解码器完全没有 7zAES coder** —— `-p` 与 `-mhe=on` 全被拒
|
||||
2. **C 解码器完全没有 7zAES coder** —— 所以 SDK 自己既解不了加密内容,也解不了加密头。内容侧由我们的 `sevenz_chain.c` 承担;**头部**侧由 `sevenz_header.c` 承担(见 §八)
|
||||
|
||||
→ 因此引擎**自解析 folder blob + 自己驱动 codec 链**(`src/sevenz_chain.c/.h`,pull pipeline:`node_pull(n, dst, want, &got)`,不够就 `node_refill()` 拉上游)。
|
||||
|
||||
@@ -197,7 +212,7 @@ LZMA SDK 26.03(public domain,已 vendor 到 `third_party/7z/`,解码子集
|
||||
|
||||
### 5.5 提取门面
|
||||
|
||||
`src/sevenz_extract.c`(1753 行)完全仿 `zip_extract.c` / `rar_extract.c`:scan → extract(staging, 每 entry fsync) → publish(整 rename) → cleanup。
|
||||
`src/sevenz_extract.c`(1753 行)完全仿 `zip_extract.c` / `rar_extract.c`:scan → extract(staging,**不 fsync**) → publish(整 rename) → cleanup。
|
||||
|
||||
- **OVERWRITE 与 MERGE 对目录-目录碰撞都递归下钻**(仅叶子文件不同)
|
||||
- 三方共用 `src/zipx_common.c`(限额 profile + `zipx_status_string()`)
|
||||
@@ -239,29 +254,76 @@ LZMA SDK 26.03(public domain,已 vendor 到 `third_party/7z/`,解码子集
|
||||
|
||||
---
|
||||
|
||||
## 八、唯一功能缺口
|
||||
## 八、7z `-mhe=on` 加密头(2026-09-23 已闭合)
|
||||
|
||||
### `-mhe=on` 加密头 7z
|
||||
**它为什么难**:`-mhe=on` 时整个 header 也是一条独立的 7z 流——归档末尾的下一头部区域以 `k7zIdEncodedHeader`(0x17)开头,后接一段 StreamsInfo,描述「一个 folder,其输出就是真正的 header」。而 vendored SDK 的 **C 解码器没有 7zAES coder**,`SzArEx_Open2()` 走到 `SzAr_DecodeFolder()` 就返回 `SZ_ERROR_UNSUPPORTED`,于是**连文件列表都读不出来**(文件名、folder 表、每个条目的尺寸全在那份加密头里)。
|
||||
|
||||
`-mhe=on` 时整个 header(含 folder 表)也被加密,引擎必须在**解析 folder 之前**先用密码解密第二份 header,才能知道有哪些 folder / 用什么 coder。工作量约为标准 7zAES 的 2 倍(两次 AES 解密路径)。
|
||||
**做法**(`src/sevenz_header.{c,h}`):
|
||||
1. 自己读 32 字节 start header,只探**一个字节**——不是 0x17 就立刻 `SZH_PLAIN` 收工(普通的 `-mhc=on` 压缩头、`-mhc=off` 明文头都走这条,SDK 行为一字不变)。
|
||||
2. 是 0x17 就整段读下来(CRC 校验),**最小解析** PackInfo + UnpackInfo:pack 位置/大小、folder 的 coder 描述字节范围、每个 coder 的 unpack size、folder CRC。解析刻意宽容——任何异常一律回落 `SZH_PLAIN`,把诊断权留给 SDK,保证非加密归档的报错一字不改。
|
||||
3. 把这份描述喂给 **`sz_chain_parse()` / `sz_chain_decode()`**,也就是内容走的同一条 7zAES 路径,密码规则完全一致:需要密码而没给 → `SZH_ERR_PASSWORD`;解出来 CRC 不对 / 不是 `k7zIdHeader` → 同样是密码错。
|
||||
4. 造一个**虚拟 `ISeekInStream`**:`[0,32)` 是改写过的 start header(指向明文头),`[hdr_off, hdr_off+L)` 是解出来的明文头,其余一律透传真实归档。`hdr_off` 就用加密头原本所在的偏移,所以**归档里存的任何一个偏移都不用搬**——SDK 在它预期的位置读到明文头,从明文头推出的 dataPos 依旧指向真实的内容 pack 流。
|
||||
5. 交给 `SzArEx_Open()`,之后一切照旧(内容仍由 `sevenz_chain.c` 直接读 `sevenz_volstream`)。
|
||||
|
||||
当前 `SZ_ERROR_UNSUPPORTED`,已在 `tests/run-sevenz-tests.sh` 的 `KNOWN_GAPS`(`aeshe`)标注 —— 缺口修好后脚本会主动报错提醒移除。
|
||||
**要点/坑**:
|
||||
- 明文头比它替换掉的那条记录**长**(实测 aeshe.7z:记录 64 B、明文 462 B),所以虚拟流的 `total` 要取 `max(真实文件长度, hdr_off+L)`,否则 `SzArEx_Open2()` 的 seek-to-END 长度检查会报 `SZ_ERROR_INPUT_EOF`。
|
||||
- **`LookToRead2_INIT` 不 seek**,第一次 `Look` 从真实流当前位置读。`szh_prepare()` 会把流移来移去,所以交给 SDK 之前必须显式 seek 回 0(`sevenz_extract.c` 里那一段有注释)。这原本是个隐性依赖。
|
||||
- 明文头大小有上限(`SZH_MAX_HEADER` 64 MiB),sink 按需增长、不信头部里声明的 unpack size。
|
||||
- 只处理 `numFolders == 1`(SDK 自己对这条记录就传 `numFoldersMax = 1`)与 `external == 0`。
|
||||
|
||||
**覆盖**:`tests/fixtures-7z/aeshe.7z`(密码 `Secret123`),`tests/run-sevenz-tests.sh` 的 `KNOWN_GAPS` 已清空——chain 驱动与 façade 两条路径都跑通;`test_sevenz_extract.c --cases` 另验无密码 / 错密码 → `ZIPX_ERR_PASSWORD`、正确密码 → 成功,且失败后不留 staging。
|
||||
|
||||
### 真机端到端待验证
|
||||
|
||||
ELF 已构建,但需装 PS5 实测:
|
||||
1. ZIP / RAR / 7z 三类**分卷**真机解压
|
||||
2. **加密 7z(7zAES)** 真机解压
|
||||
2. **加密 7z(7zAES + `-mhe=on` 加密头)** 真机解压(`aeshe.7z` 那类归档在真机上连文件列表都要走新代码)
|
||||
3. 160GB / 9.5 万文件大 ZIP
|
||||
4. 分卷 RAR 进度条实时走动
|
||||
5. UI 右下角版本号显示 `v1.9.1`
|
||||
5. UI 右下角版本号显示(`/api/version` 与兜底字面量应一致)
|
||||
|
||||
> **📊 2026-09-23 首个真机性能数据**:解一个 **18 GB 的包**,**11 分钟**、**1252 个条目**
|
||||
> (平均 14.7 MB),UI 报 **10–40 MB/s**;**18 GB 是压缩包自身的大小**(✅ 已确认)。
|
||||
> **格式 = RAR**(✅ 已确认);包原本在 **PC 上**,**经插件上传**进 PS5,**上传速度 30–40 MB/s**。
|
||||
> 口径是**解压后的字节**(`zip_extract.c:651-678` 累加 `uncompressed_size`,`:900/910` 累加
|
||||
> `write()` 写出的解压字节),且是 **250 ms 采样的瞬时值**(`task.c:202-206`,进度只在 ≥1 MiB
|
||||
> 时上报)—— 摆动里含采样噪声,**只有「总字节 ÷ 总耗时」可信**。
|
||||
> 仍然成立的一条:**per-entry 开销不是主因**(平均 14.7 MB/条目,不是小文件场景)。
|
||||
>
|
||||
> **⚠️ 2026-09-23 晚 更正:本节原先写的「解码不是瓶颈」已撤回。** 两条理由:
|
||||
> ① 它拿「PS5 解 **RAR**」的 28 MiB/s 去比「PC 解 **7z**」的 427 MiB/s —— **不同格式、不同
|
||||
> 解码器、不同机器**,量级论证不成立;② 「4× 摆动 = 解码无罪」此前已降级为 250 ms 采样
|
||||
> 噪声的弱证据。**原先那句「源盘交付速度 ≈ 28 MiB/s 是硬上界」同样站不住** —— 它是从总耗时
|
||||
> 反推的*观测结果*,不是设备能力上限;若解码是瓶颈,源盘恰恰没跑满。
|
||||
>
|
||||
> **③ 新发现(有据可查、且直接针对真实负载):RAR 解码在 PS5 上是单线程的。**
|
||||
> `third_party/unrar7/os.hpp:43-45` 的 `#define RAR_SMP` 落在 `#ifdef _WIN_ALL` 分支**内**
|
||||
> ⇒ POSIX 构建不定义(我们 Makefile 里 0 次出现),而官方 POSIX makefile 第 11 行是
|
||||
> `DEFINES=… -DRAR_SMP` —— **我们漏了这个开关**。后果:`unpack50mt.cpp`
|
||||
> (`Unpack::Unpack5MT`,rarlab 专门调过的多线程 RAR5 解压器)**没编进来**,
|
||||
> `unpack.cpp:185-198` 的 MT 分支整段不参与编译,`SetThreads` / `ThreadPool` 一并消失。
|
||||
> ⇒ **首要假设:28 MiB/s ≈ 单线程 RAR5 解码的正常量级**(`18 GB ÷ 660 s` 是**解码输入**速率;
|
||||
> 而上传实测证明**写入端**至少能到 30–40 MB/s、**读取通常快于写入** ⇒ 纯存储上限解释不了它)。
|
||||
> ⚠️ 自查一条:**用 ELF 符号表查内部符号是无效手段** —— 该 ELF 只有 `.dynsym`(513 项)、
|
||||
> **无 `.symtab`**,「零命中」是假象(本次差点据此误判)。结论来自 Makefile 与 `os.hpp`。
|
||||
> **下一步**:先在 PC/WSL 上 A/B(`-DRAR_SMP` + `unpack50mt.cpp` + `-pthread`,跑同一批 RAR5
|
||||
> fixture 并保证 37 项 RAR 断言全绿),收益显著再上真机;同时真机补两个小事实
|
||||
> (**RAR4 还是 RAR5**、**解压后多大**)与 `T_copy`。详见 `docs/EXTRACTION-PERF.md` §六 文首
|
||||
> 更正块与 `docs/REAL-CONSOLE-PROFILE.md`。
|
||||
>
|
||||
> **⇒ 终局(2026-09-23 18:15,用户决定):这条线不做。** 不做的依据是**当前证据判不了收益**,
|
||||
> 而不是没收益:MT 只并行**解码**(worker 只跑 `unpack50mt.cpp:190` 的 `UnpackDecodeThread`),
|
||||
> 写盘恒为主线程串行(`UnpWriteBuf()` 只在 `unpack50mt.cpp:283/475/587` 被主线程调用)——
|
||||
> 若瓶颈在写路径(真机上传已证明写入端只有 30–40 MB/s),收益退化为 1.0×。要判定必须先做
|
||||
> `T_copy`(拿插件自己的 `TASK_COPY` 搬同一份包,`src/filemgr.c:836`),再决定是否值得改构建
|
||||
> 并刷机验证;用户选择停在第一步之前。**重开的第一个动作是 `T_copy`,不是改 `-DRAR_SMP`。**
|
||||
|
||||
### 可选项(非阻塞)
|
||||
|
||||
- **性能**:实测上游(7-Zip 本体)在 **7z 格式上快 1.9×(单线程)/ 3.4×(8 线程)**;ZIP 无显著差异。差距不在我们的架构(我们比 SDK 自己的 `SzArEx` 路径还快 1.02×)。**已全部落地(2026-09-16)**:①汇编解码器(`LzmaDecOpt.asm`+jwasm,1.26×,无 jwasm 自动退纯 C)②多线程 LZMA2(`Lzma2DecMt`,8 线程,1.37×,线程失败自动降级 chain;BCJ2/加密布局仍走 chain)③ZIP 逐条目 fsync 移除(8000 文件 ≥14×)。7z 现与 7-Zip 单线程打平、ZIP 已压过官方(本机受 Defender 拖累不可比,PS5 无该因素)。RAR 与官方 UnRAR 同速(unrar 自带 `target("aes")` SIMD 已启用,无逐条目 fsync)。完整数据见 `docs/EXTRACTION-PERF.md`,基准工具 `tests/bench_driver.py`
|
||||
- fsync 批量化(每 64MB/N 条刷一次)—— 9.5 万文件级可省 20–30 分钟
|
||||
- **性能**:**优化前**实测上游(7-Zip 本体)在 7z 格式上快 1.9×(单线程)/ 3.4×(8 线程);ZIP 无显著差异。差距不在我们的架构(我们比 SDK 自己的 `SzArEx` 路径还快 1.02×)。**已全部落地(2026-09-16)**:①汇编解码器(`LzmaDecOpt.asm`+jwasm,1.26×,无 jwasm 自动退纯 C)②多线程 LZMA2(`Lzma2DecMt`,8 线程,1.37×,线程失败自动降级 chain;BCJ2/加密布局仍走 chain)③ZIP 逐条目 fsync 移除(8000 文件 ≥14×)。7z 现与 7-Zip 单线程打平、ZIP 已压过官方(本机受 Defender 拖累不可比,PS5 无该因素)。RAR 与官方 UnRAR 同速(unrar 自带 `target("aes")` SIMD 已启用,无逐条目 fsync)。完整数据见 `docs/EXTRACTION-PERF.md`,基准工具 `tests/bench_driver.py`。**⚠️ 但「单线程打平 / 8 线程 1.59×」是在最有利的输入形状上测的**:基准归档是 `-m0=lzma2 -ms=on` 的**单文件**(`tests/bench_driver.py:179`),恰好是唯一能让 `sz_chain_lzma2_root()`(`src/sevenz_chain.c:750`,要求 1 coder / 0 bond / 1 pack stream / 纯 LZMA2)生效的形状;真实的**多 folder / BCJ2 / 7zAES** 归档会让 MT 失效、退回单线程 chain —— 所以那个 1.59× **既不是上限也不是下限,方向未知**。剩余优化(CRC 硬件化、MT 扩到 BCJ2、条目级并行、ZIP inflate 换 libdeflate、AES-NI)**全部集中在 7z 多线程这一条线上**,建议**先拿真实归档在真机上 profile 再排序**,别按 PC 上这份数字动手
|
||||
- ~~fsync 批量化(每 64MB/N 条刷一次)~~ → **已作废,改为「彻底移除」**(2026-09-16):实际落地的不是批量刷,而是把 ZIP 引擎的逐条目 fsync 直接删掉(RAR/7z 本来就没有),三引擎统一为「**不 sync、只 rename**」——publish 是纯 rename、也没有续解功能,该 fsync 无收益。8000 文件 fixture:fsync 版 >200 s 未跑完 → 无 fsync **14.5 s(≥14×)**。已知取舍:publish 之后到落盘之间断电,可能出现「文件在但内容不完整」;要补只需在 extract 收尾做**一次**目录/整盘 flush(PS5 是 FreeBSD 系,`syncfs()` 不一定有,`sync()` 是全盘、偏重)。代码现状见 `src/zip_extract.c:937-945`,实测见 `docs/EXTRACTION-PERF.md:18-21`
|
||||
- 解压失败保留 staging 支持续解(中等改动)
|
||||
- 进度条 % / 文字进度 / ETA 三处口径统一为字节
|
||||
- ~~进度条 % / 文字进度 / ETA 三处口径统一为字节~~ → **已完成**(`assets/main.js:2071-2079`,条目计数已移除并注明原因)
|
||||
|
||||
---
|
||||
|
||||
@@ -284,7 +346,9 @@ ELF 已构建,但需装 PS5 实测:
|
||||
|
||||
## 十、工作区状态
|
||||
|
||||
工作树已干净(`git status` 仅剩有意保留的未跟踪文档)。
|
||||
✅ **2026-09-24:工作树已重新干净并发布** —— 该批次改动(59 个文件)已提交为 `ba668ad`、打 tag `v1.9.3M` 并发布 Release,资产与本地逐字节一致。以下为当时(2026-09-23 起)的状态记录,保留作历史。
|
||||
|
||||
以下为 2026-09-15 的历史清理记录(当时工作树干净,"仅剩有意保留的未跟踪文档")。
|
||||
|
||||
已清理(2026-09-15):
|
||||
|
||||
@@ -296,3 +360,185 @@ ELF 已构建,但需装 PS5 实测:
|
||||
> ⚠️ **清理这类特殊文件名时**:`SHFileOperationW`(带 `FOF_ALLOWUNDO` 走回收站)对含私用区码位的路径会返回 `ERROR_FILE_NOT_FOUND (2)`,**但动作实际已生效**。删完务必查 `C:\$Recycle.Bin\<SID>\$I*` 记录确认落在回收站(`$I` 存原路径 UTF-16,`$R` 是内容)。本沙箱里 `Add-Type` 与 `rm` 都被拦(后者有 safe-delete 钩子),只能用 Python `ctypes` 调 shell32。
|
||||
|
||||
`.build/` 下的探针/调试产物已被 `.gitignore` 白名单覆盖,不再污染 `git status`。
|
||||
|
||||
---
|
||||
|
||||
## 十一、本轮(2026-09-23)变更:加密通道补齐(已随 v1.9.3M 发布)
|
||||
|
||||
**目标**:让 ZIP 与 RAR 的加密归档真正可解(7zAES 早已可用)。两者此前都报
|
||||
`extract_unsupported`,但**缺口在引擎侧,不在 UI** —— 密码框、`password=` 字段、
|
||||
`err_extract_password` 文案从 v1.9 起就已就位。
|
||||
|
||||
### 11.1 ZIP:给裁剪过的 minizip-ng 补一个 crypto 后端
|
||||
|
||||
`third_party/minizip-ng` 是裁到只读路径的精简副本,`mz_zip.c` 里
|
||||
`#ifdef HAVE_WZAES / HAVE_PKCRYPT` 的分支保留着,但**对应的流与 crypto 后端被裁掉了**。
|
||||
本轮补回:
|
||||
|
||||
| 文件 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| `src/mz_strm_wzaes.{c,h}` | 上游 4.2.2 原样恢复 | WinZip AES 流(方法 99 + `0x9901` 扩展字段) |
|
||||
| `src/mz_strm_pkcrypt.{c,h}` | 上游 4.2.2 原样恢复 | 传统 PKWARE / ZipCrypto 流 |
|
||||
| `src/mz_crypt_wfm.c` | **新写**(~860 行) | 本地 crypto 后端:SHA-1、HMAC-SHA1、AES-128/192/256 |
|
||||
|
||||
后端要点:
|
||||
- S-box 与 GF(2^8) log/alog 表**首次使用时推导**,所以不新增 `.rodata` 查表(实测 `.rodata` 仅 +256 B,是字符串)。
|
||||
- 随机数直接 `open("/dev/urandom")`,**不要**走 `mz_os_rand()` —— 后者会退回 `rand()`/`srand()`,把两个新符号塞进导入表。最终产物**动态符号零新增**。
|
||||
- PBKDF2 复用 vendored 的 `mz_crypt.c`(与上游逐字节一致),没有重写。
|
||||
- 非 SHA-1 算法与 AEAD aad 一律返回 `MZ_SUPPORT_ERROR`(本项目只读,不需要)。
|
||||
- KAT 先行:写完后先用 FIPS 197 / RFC 3174 / RFC 2202 / RFC 6070 / SP 800-38A
|
||||
向量单独验算(`.build/kat_crypto.c`,24/24),再接线。踩到的三个坑:AES 仿射用 `rol32`
|
||||
应为 `rol8`;GF 乘法 `(uint8_t)(a+b) % 255` 截断,应全程 int;HMAC 的 ipad 必须由
|
||||
**已 XOR 过 0x5c 的 opad** 再推。
|
||||
|
||||
### 11.2 RAR:把 `RARSetPassword` 接上
|
||||
|
||||
`src/rar_extract.{c,h}`:新增 `password` 形参(`rar_extract()` 为第 8 个参数)。
|
||||
调用点是 `RAROpenArchiveEx` 之后、**首次 `RARReadHeaderEx` 之前**——这是解密 `-hp`
|
||||
头加密归档的硬性顺序要求(scan 与 extract 两个阶段各自开档,两处都要设)。
|
||||
`ERAR_MISSING_PASSWORD` / `ERAR_BAD_PASSWORD` 由 `ZIPX_ERR_UNSUPPORTED` 改映射为
|
||||
`ZIPX_ERR_PASSWORD`;`RHDF_ENCRYPTED` 只在**没给密码**时提前拒绝。
|
||||
|
||||
### 11.3 前端:密码失败后自动重试(这步不做,功能等于不可达)
|
||||
|
||||
原先密码框**只对 7z 弹**(`actionExtract()` 里的 `isSevenZipArchive()` 判断),
|
||||
ZIP/RAR 加密归档失败后用户根本没机会输密码。现在 `handleTerminalTask()` 在
|
||||
`op === "extract" && error_code === "extract_password"` 时走
|
||||
`retryExtractWithPassword()`:
|
||||
|
||||
- 记忆原始请求(`extractRetryKey(task.id)` → `{conflict, removeSource, name, large, attempts}`),
|
||||
重试时**保持冲突策略与大文件选配**;
|
||||
- **必须按任务 id 记,不能按路径记**(v1.9.3M 后期修正)。路径在传输中被
|
||||
「服务端 JSON 逐字节转义为 `\u00XX`」+「`fs_path_value()` 反向还原成原始字节」这一对
|
||||
转换改了表示 ⇒ **非 ASCII 目录下**「页面手里的路径」≠「任务回报的路径」,按路径查必然
|
||||
落空 ⇒ 口令框永远不弹,用户只看到一个失败框,必须先手动再解压一次。任务 id 由服务端
|
||||
分配、原样回传,不受编码影响;重试时也改用**服务端回报的** `task.src` / `task.dst` 重发。
|
||||
消费即删(重试注册到新 id 下),Map 最多留 8 条(同一时刻只可能有一个活动任务)。
|
||||
- 最多 3 次;取消或空输入即放弃,回落到原有失败提示;
|
||||
- 7z 保留提前询问(免得白跑一次 scan + folder 解析)。
|
||||
|
||||
新增文案 `extractPasswordRetryAsk`(重试:密码不正确)+ `extractPasswordFirstAsk`(首次:
|
||||
此压缩包已加密),与提前询问用的 `extractPasswordAsk` 区分 —— 第一次失败时用户还没输过密码,
|
||||
再说「密码不正确」就是误导。
|
||||
|
||||
错误文案里的条目名必须过 `decodeFsText()`:`backendErrorText()` 原样用了 `error_arg`,
|
||||
而服务端把它逐字节转义过 ⇒ 中文/日文条目名在错误框里显示成 `â®…ç§.psd`。列表侧一直有这层
|
||||
翻译(`displayName()`),只有错误文案漏了。
|
||||
|
||||
同源的编码坑:`pathJoin(服务端回报的目录, 本地文件名)` 把两种表示混进同一个字符串,而
|
||||
`fs_path_value()` **只要发现任一个码点 > 0xFF 就整体不修** ⇒ 中文名文件放进中文名目录时
|
||||
路径失效。新增 `encodeFsText()`(`decodeFsText()` 的逆)在拼接前把本地名转成同一表示,
|
||||
`uploadAndExtractFile()` 与 `actionNewText()` 两处都用它。
|
||||
|
||||
无头回归:`.build/ui_retry_test.mjs`(真 `main.js` 载入桩 DOM,40 checks,含「非 ASCII 目录
|
||||
必须仍弹口令框」的回归用例)、`.build/ui_upload_menu_test.mjs`(40 checks:i18n 键覆盖、
|
||||
菜单接线、样式、高亮规则的层叠作用域,以及**解压按钮不许被隐藏、只许被置灰**)、
|
||||
`.build/preview_check.mjs`(无头 Chromium 跑真页面,验菜单开关、页脚布局、**解压按钮的
|
||||
显隐/置灰/提示随选区变化**,并**读回三种交互状态下高亮的计算值**;该脚本已改为失败即
|
||||
非零退出)。
|
||||
|
||||
**菜单行的「选中高亮」曾被两条规则同时破坏**(用户报「选中下面那个高亮效果不对」):
|
||||
① 全局 `button:focus` 的 `outline: 3px + offset 2px` 是按 54px 工具栏按钮设计的,套在 46px
|
||||
菜单行上会越过面板 6px 内边距、压住相邻行,且 outline 的圆角半径不随 offset 自适应 ⇒ 视觉上
|
||||
成了一个「脱离的框 + 两侧挂着的弧线」;② 面板自己的
|
||||
`.upload-menu-list button:hover:not(:disabled)` **从未生效过** —— 它与
|
||||
`button:not(.row-action):hover:not(:disabled)` 特异性同为 `(0,3,1)`,而后者在文件里更靠后 ⇒
|
||||
后者胜出,于是 hover 是 `#303945`、focus 是 `#2b343e`,**两个高亮两个颜色**,且一行 hover 时
|
||||
另一行仍因 focus 亮着 ⇒ 看起来「两行同时被选中」。修法:两条规则都收敛到面板 id
|
||||
(`#uploadMenu button:…`,`(1,1,1)` / `(1,2,1)` 稳赢通用规则),行只用填充表示选中,键盘焦点
|
||||
提示改为**行内 `inset` 环**(`box-shadow: inset 0 0 0 2px`)—— 画在行内,任何行高都不可能
|
||||
越界。**这类坑只有真引擎读计算值才抓得住**,光看源码两条规则都「像是对的」。
|
||||
|
||||
**解压按钮改为「常显 + 置灰」**(用户要求「直接显示出来 只不过是灰色的 只有能解压的文件才可以
|
||||
点击」):`index.html` 去掉 `hidden`,`renderExtractButton()` 不再碰 `.hidden`,改成按选区设
|
||||
disabled 并给一条说明原因的工具提示(什么都没选 ⇒ 新增 `extractSelectArchive`;只选中子卷 ⇒
|
||||
沿用 `extractSelectMainVolume`;选中**多个** ⇒ 新增 `extractOneAtATime`,旧代码这种情况错用了
|
||||
「请改选主卷」,文案本身是错的)。🪤 **`button:disabled` 带 `pointer-events: none` ⇒ 禁用按钮
|
||||
无法 hover,`title` 永远不弹** —— 必须像既有的 `.parent-nav-button:disabled` 那样把
|
||||
`pointer-events` 还回来(点击仍无效,`disabled` 属性本身挡激活)。标签同时从 `extractToCurrent`
|
||||
(「解压到当前目录」)换成短词 `extract`(「解压」),与工具栏其他动词一致:**常显按钮不该
|
||||
同时又是最宽的那个**(英文下 `Extract to current folder` 会到 107 px)。
|
||||
**代价必须实测而不是估**:按钮宽 96 px ⇒ 工具栏换行阈值(zh)1080 → 1190 px、(en)1230 →
|
||||
1350 px。`.build/preview_check.mjs` 已把阈值**钉成断言**(1920/1600/1280 必须都是一行),
|
||||
并按四种选区验 disabled / opacity / title;该脚本同时从「只打印」改成**失败即非零退出**。
|
||||
|
||||
### 11.4 构建坑:编译选项变化必须让目标文件失效(**改 Makefile 前必读**)
|
||||
|
||||
`make` **看不见**编译选项变化。加 `-DHAVE_WZAES -DHAVE_PKCRYPT` 后,
|
||||
`mz_zip.o` / `mz_crypt.o` 被判定为最新而复用 → 此时已无线程引用新流 →
|
||||
`--gc-sections` 把加密代码再丢一次,**链接却报成功**(本次第一次构建的产物与
|
||||
已发布 v1.9.2 **逐字节相同**,`readelf` 才发现 `.text` 只长了 336 B)。
|
||||
|
||||
修法(取代原先的 `LzmaDec.o` 特例):把第三方编译选项写进标记文件,
|
||||
内容变了才重编。
|
||||
|
||||
```make
|
||||
PS5_FLAGS_STAMP := ps5-obj/.third_party_cflags
|
||||
LINUX_FLAGS_STAMP := linux-obj/.third_party_cflags
|
||||
$(PS5_FLAGS_STAMP): FORCE
|
||||
@printf '%s\n' '$(THIRD_PARTY_C_FLAGS_7Z) $(LZMA_DEC_OPT_FLAG)' > $@.tmp
|
||||
@cmp -s $@.tmp $@ || { mv -f $@.tmp $@; echo ' [cflags] ...'; }
|
||||
```
|
||||
|
||||
> 诊断手法:拿未 strip 的产物比 `readelf -S` 各段尺寸,而不是看总体积。
|
||||
> 改一个字符串常量会重排 `.rodata` 字符串池,字节 diff 会被放大到几万字节,
|
||||
> 但段尺寸是守恒的——判断"代码到底有没有变"要看段尺寸 + 助记符序列。
|
||||
> 现成脚本:`.build/_seccmp.py`、`.build/_operandcheck.sh`。
|
||||
|
||||
### 11.5 产物与验证
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 主机测试 | ZIP 140 + RAR 37 = **177 checks / 0 失败**(`tests/run-tests.sh`) |
|
||||
| 前端测试 | **27 checks / 0 失败**(`.build/ui_retry_test.mjs`) |
|
||||
| ELF | 870,680 B · sha256 `b1409f5c1bc4b1a39ab337853b956f4807f95c5770dee6eca7a18a62cc08f80e` · e_machine 0x003e(加密轮结束时;`-mhe=on` 之后的产物见 §12.3) |
|
||||
| 确定性 | 同一源码树构建两次逐字节一致 |
|
||||
| 段变化(vs 已发布 v1.9.2) | `.text` +11,296 · `.bss` +5,120(AES 表) · `.rodata` +256 · 动态符号零新增 |
|
||||
| 内嵌资产核验 | ELF 内 gzip 资源中可检出 `retryExtractWithPassword` / `extractPasswordRetryAsk`(普通 `strings` 找不到,要先解 gzip;脚本 `.build/check-elf-gzip.py`) |
|
||||
|
||||
**未做(发版前必做)**:未 commit / tag / 发 Release;真机端到端未验。
|
||||
版本号**已升**为 `v1.9.3M`(2026-09-24 加改版标记 `M`,见 §2.2)。
|
||||
README(中英)、CHANGELOG、本文档已同步为「已随 v1.9.3M 发布」状态。
|
||||
|
||||
---
|
||||
|
||||
## 十二、本轮(2026-09-23)变更:7z `-mhe=on` 加密头(已随 v1.9.3M 发布)
|
||||
|
||||
**目标**:补上最后一个 7z 格式缺口(设计与坑见 §八)。
|
||||
|
||||
### 12.1 新增
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| `src/sevenz_header.{c,h}` | **新写**(~900 行)。头部读取 + `k7zIdEncodedHeader` 最小解析 + 虚拟 `ISeekInStream` |
|
||||
| `Makefile` | `src/sevenz_header.c` 进 `COMMON_SRCS`(PS5 与 linux 共用) |
|
||||
| `tests/run-sevenz-tests.sh` | 编 `sevenz_header.o` 进 `ENGINE_OBJS`;`KNOWN_GAPS` 清空;façade 矩阵加入 `aeshe` |
|
||||
| `tests/sevenz_chain_e2e.c` | 按与产品相同的顺序接线 `szh_prepare()`(否则 chain 矩阵读不了 `aeshe`) |
|
||||
| `tests/test_sevenz_extract.c` | `aeshe` 三例(无密码 / 错密码 → `ZIPX_ERR_PASSWORD`;正确密码 → 成功),另修一处 `snprintf` 截断告警 |
|
||||
|
||||
### 12.2 关键设计(细节见 §八)
|
||||
|
||||
- 只探**一个字节**:不是 `0x17` 立刻返回 `SZH_PLAIN`,SDK 行为与改动前完全一致(12 个既有 fixture 全部复跑通过)。
|
||||
- 解析刻意宽容:PackInfo/UnpackInfo 之外的任何异常都回落 `SZH_PLAIN`,把诊断权留给 SDK。
|
||||
- 复用 `sz_chain_parse()` / `sz_chain_decode()`,所以 7zAES 的密码/错误语义与内容侧**完全同源**,不新增第二个 crypto 实现。
|
||||
- 虚拟流的 `total` 必须 `max(真实长度, hdr_off + L)`;`LookToRead2_INIT` 不 seek,交回 SDK 前必须显式 seek 到 0。
|
||||
|
||||
### 12.3 产物与验证
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 7z 套件 | **27 用例 / 0 失败**,`aeshe` 在 chain 与 façade 两条路径都 `ok`,`KNOWN_GAPS` 为空 |
|
||||
| 主机测试(ZIP/RAR 回归) | ZIP 140 + RAR 37 = **177 checks / 0 失败**(无回归) |
|
||||
| ELF | 903,448 B · sha256 `8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7` · e_machine 0x003e(== 本轮最终产物,见 §12.4) |
|
||||
| 确定性 | 同一源码树构建两次 sha256 相同 |
|
||||
| 段变化(解压按钮常显 vs 上一轮) | **只有 `.rodata` 变化**:`0x026CC0` → `0x026F00`(+0x240 = 576 B:index.html 去掉 `hidden` 并换短标签、`main.js` 的三条禁用理由、两份语言文件各两条新文案、`.extract-action:disabled` 及其注释)。`.text` 两次均为 `0x087780`、`.data` 均为 `0x00034C` —— 第六次「只改内嵌前端资源」 |
|
||||
| 段变化(菜单行高亮修复 vs 上一轮) | **只有 `.rodata` 变化**:`0x026B40` → `0x026CC0`(+0x180 = 384 B,三条收敛后的高亮规则加其注释)。`.text` 两次 readelf 均为 `0x087780` —— 又一次「只改内嵌前端资源、不碰 C 逻辑」的标准形状 |
|
||||
| 段变化(本轮前端三项 vs 上一轮) | **只有 `.rodata` 变化**:`0x026A40` → `0x026B40`(+0x100 = 256 B)。`.text` / `.data` / `.eh_frame*` 一字节未变 —— 「只改内嵌前端资源 + 加两条文案」的标准形状 |
|
||||
| 段变化(加 `M` 标记 + 修正 `err_extract_unsupported` 文案 vs 加密轮产物) | **只有 `.rodata` 变化**:加 `M` 标记 +0x100(256 B),修正文案再 +0x40(64 B);`.text` / `.data` / `.bss` / `.eh_frame*` / `.gcc_except_table` **一个字节都没变**。又因 16 KiB 段对齐留有余量,**六次构建的文件总尺寸都是 903,448 B**:尺寸相同**不代表**二进制相同(sha256 逐个不同:`f3164efa…` → `53296d29…` → `7b5ab00c…` → `212107a6…` → `da36834d…` → `cf2c0fcf…` → `8ca47d5a…`) |
|
||||
| 段变化(加密轮 vs 其前一轮) | `.text` +4,880 · `.rodata` +640 · `.eh_frame_hdr` +32 · `.eh_frame` +160 —— 正文合计 **+5,712**;其余 **+27,056** 是 `p_align=0x4000` 的两处段对齐填充(LOAD#1 越过 0x8C000 边界)。**段数仍为 20,动态符号零新增(513 → 513)** |
|
||||
|
||||
> 判读提示:这次文件涨了 32,768 B,但正文只涨 5,712 B —— 不要按体积下结论。
|
||||
> 权威做法是比较**段尺寸**与**动态符号集合**(见 §11.4 的诊断手法)。
|
||||
|
||||
**未做(发版前必做)**:未 commit / tag / 发 Release;真机端到端未验。
|
||||
版本号已升为 `v1.9.3M`(改版标记 `M` 于 2026-09-24 加入)。
|
||||
@@ -14,10 +14,25 @@ ifeq ($(MAKECMDGOALS),)
|
||||
endif
|
||||
|
||||
# Bump this together with the git tag -- it is baked into the binary (the PS5
|
||||
# notification and `--version` print it) AND into the output filename, so a
|
||||
# stale value silently mislabels everything. Override per-build with:
|
||||
# make VERSION_TAG=v1.9.2
|
||||
VERSION_TAG ?= v1.9.1
|
||||
# notification, the stdout banner and /api/version all print it) AND into the
|
||||
# output filename, so a stale value silently mislabels everything. Override
|
||||
# per-build with:
|
||||
# make VERSION_TAG=v1.9.3M
|
||||
#
|
||||
# **The trailing M is the fork marker** (Modified build, maintained by
|
||||
# LisherSong). Upstream owendswang releases are plain `vX.Y.Z`, so any string
|
||||
# carrying the M is ours and anything without it is not. The marker rides on
|
||||
# VERSION_TAG rather than on a separate display-only constant on purpose: it
|
||||
# therefore reaches every surface at once -- /api/version, the PS5 start-up
|
||||
# notification, the stdout banner, the UI footer and the ELF file name -- and
|
||||
# is impossible to forget in one of them. The file name gains a second benefit:
|
||||
# a fork build can no longer collide with an upstream artifact of the same
|
||||
# upstream version, which has already caused two mix-ups (a local
|
||||
# `web-file-mgr-v1.9.2.elf` sitting next to the released one under the same
|
||||
# name, and a `-DVERSION_TAG=v1.9.1` leftover wearing the released 870 488 B
|
||||
# file's size). To cut a build that is byte-for-byte upstream-shaped, pass
|
||||
# `make VERSION_TAG=v1.9.3`.
|
||||
VERSION_TAG ?= v1.9.3M
|
||||
TITLE_ID := FMGR88888
|
||||
PYTHON ?= python3
|
||||
STRIP ?= $(PS5_PAYLOAD_SDK)/bin/prospero-strip
|
||||
@@ -30,7 +45,7 @@ HOST_PKG_CONFIG ?= pkg-config
|
||||
# and you can tell at a glance which ELF is on the USB stick.
|
||||
BIN := web-file-mgr-$(VERSION_TAG).elf
|
||||
LINUX_BIN := web-file-mgr-linux-$(VERSION_TAG)
|
||||
COMMON_SRCS := src/main.c src/websrv.c src/filemgr.c src/file_response.c src/task.c src/upload.c src/download.c src/text.c src/list.c src/space.c src/version.c src/fs_util.c src/json_util.c src/path_util.c src/asset.c src/mime.c src/notify.c src/pkg_installer.c src/pkg_info.c src/extract.c src/zip_extract.c src/rar_extract.c src/zipx_volume.c src/zipx_volstream.c src/zipx_common.c src/sevenz_extract.c src/sevenz_chain.c src/sevenz_volstream.c src/sevenz_mt.c
|
||||
COMMON_SRCS := src/main.c src/websrv.c src/filemgr.c src/file_response.c src/task.c src/upload.c src/download.c src/text.c src/list.c src/space.c src/version.c src/fs_util.c src/json_util.c src/path_util.c src/asset.c src/mime.c src/notify.c src/pkg_installer.c src/pkg_info.c src/extract.c src/zip_extract.c src/rar_extract.c src/zipx_volume.c src/zipx_volstream.c src/zipx_common.c src/sevenz_extract.c src/sevenz_chain.c src/sevenz_header.c src/sevenz_volstream.c src/sevenz_mt.c src/demangle_stub.c
|
||||
PS5_SRCS := $(COMMON_SRCS) src/app_installer.c src/cpu_support_stub.c
|
||||
LINUX_SRCS := $(COMMON_SRCS)
|
||||
BASE_ASSETS := $(filter-out %.dds,$(wildcard assets/*))
|
||||
@@ -84,9 +99,16 @@ THIRD_PARTY_C_SRCS := $(wildcard third_party/zlib/src/*.c) $(wildcard third_pa
|
||||
# every one of these, so we just enable them for the 7z TU family instead of
|
||||
# dropping AesOpt.c (Aes.c references those HW symbol names via AesGenTables).
|
||||
SEVENZ_C_FLAGS := -maes -mavx2 -mvaes
|
||||
# HAVE_WZAES / HAVE_PKCRYPT switch on minizip-ng's two ZIP encryption paths:
|
||||
# mz_strm_wzaes.c (WinZip AES, method 99 / extra field 0x9901) and
|
||||
# mz_strm_pkcrypt.c (traditional PKWARE "ZipCrypto"). The mz_zip.c branches
|
||||
# behind these macros are already present, so defining them only pulls in the
|
||||
# two streams plus the local crypto backend in mz_crypt_wfm.c. Everything they
|
||||
# need (crc32, PBKDF2-HMAC-SHA1, AES-ECB) is implemented in-tree; see the
|
||||
# header of third_party/minizip-ng/src/mz_crypt_wfm.c.
|
||||
THIRD_PARTY_C_FLAGS := -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -Ithird_party/7z \
|
||||
-DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE \
|
||||
-DHAVE_FSEEKO -DZ7_PPMD_SUPPORT
|
||||
-DHAVE_FSEEKO -DZ7_PPMD_SUPPORT -DHAVE_WZAES -DHAVE_PKCRYPT
|
||||
THIRD_PARTY_C_FLAGS_7Z := $(THIRD_PARTY_C_FLAGS) $(SEVENZ_C_FLAGS)
|
||||
|
||||
# Assembly-optimised LZMA decoder (optional, on when jwasm is present).
|
||||
@@ -123,9 +145,13 @@ LINUX_TP_OBJS := $(patsubst %.c,linux-obj/%.o,$(THIRD_PARTY_C_SRCS)) \
|
||||
|
||||
CFLAGS := -Oz -fno-asynchronous-unwind-tables -fno-unwind-tables -Wall -Werror -ffunction-sections -fdata-sections -Isrc -Ithird_party/minizip-ng/include -Ithird_party/unrar7 -Ithird_party/7z -DVERSION_TAG=\"$(VERSION_TAG)\" -DTITLE_ID=\"$(TITLE_ID)\"
|
||||
CFLAGS += `$(PKG_CONFIG) libmicrohttpd --cflags`
|
||||
LDFLAGS := -Wl,--gc-sections
|
||||
# --icf=all: fold byte-identical functions. LLD-only (GNU ld's --icf is
|
||||
# incomplete), so it stays on the PS5 line -- the linux target never uses
|
||||
# LDFLAGS. Paired with src/demangle_stub.c this takes the ELF from ~1010 to
|
||||
# ~850 KiB; see docs/SIZE-OPTIMIZATION.md.
|
||||
LDFLAGS := -Wl,--gc-sections -Wl,--icf=all
|
||||
LDADD := `$(PKG_CONFIG) libmicrohttpd --libs`
|
||||
LDADD += -lSceIpmi -lSceAppInstUtil -lSceUserService
|
||||
LDADD += -lSceIpmi -lSceAppInstUtil -lSceUserService -lkernel_sys
|
||||
LINUX_CFLAGS := -O2 -flto -Wall -Werror -Isrc -Ithird_party/minizip-ng/include -Ithird_party/unrar7 -Ithird_party/7z -DVERSION_TAG=\"$(VERSION_TAG)\" -DTITLE_ID=\"$(TITLE_ID)\"
|
||||
LINUX_CFLAGS += `$(HOST_PKG_CONFIG) libmicrohttpd --cflags`
|
||||
LINUX_LDADD := `$(HOST_PKG_CONFIG) libmicrohttpd --libs` -pthread
|
||||
@@ -160,18 +186,54 @@ ps5-obj/third_party/7z/LzmaDec.o: THIRD_PARTY_C_FLAGS_7Z += $(LZMA_DEC_OPT_FLA
|
||||
linux-obj/third_party/7z/LzmaDec.o: THIRD_PARTY_C_FLAGS_7Z += $(LZMA_DEC_OPT_FLAG)
|
||||
endif
|
||||
|
||||
# make does not track flag changes, and installing or removing jwasm flips the
|
||||
# switch above. Without this, an existing LzmaDec.o silently keeps the old
|
||||
# decoder and the asm object just sits in the link line unreferenced (the
|
||||
# binary comes out byte-identical, which is how the problem was noticed).
|
||||
ps5-obj/third_party/7z/LzmaDec.o: Makefile
|
||||
linux-obj/third_party/7z/LzmaDec.o: Makefile
|
||||
# make does not track compiler-flag changes, so an object built with the old
|
||||
# flags is silently reused and the binary links "successfully" without the
|
||||
# feature. Two cases have already bitten:
|
||||
# * installing or removing jwasm flips LZMA_DEC_OPT_FLAG, and the asm object
|
||||
# just sits in the link line unreferenced (the binary came out
|
||||
# byte-identical, which is how the problem was noticed);
|
||||
# * adding -DHAVE_WZAES / -DHAVE_PKCRYPT, which only mz_zip.c and mz_crypt.c
|
||||
# compile differently -- without a rebuild the ZIP encryption streams are
|
||||
# never referenced and --gc-sections quietly drops them again.
|
||||
# Recording the flags in a stamp file invalidates the objects when the flags
|
||||
# actually change, instead of rebuilding them for every unrelated Makefile edit.
|
||||
PS5_FLAGS_STAMP := ps5-obj/.third_party_cflags
|
||||
LINUX_FLAGS_STAMP := linux-obj/.third_party_cflags
|
||||
|
||||
ps5-obj/%.o: %.c
|
||||
$(PS5_FLAGS_STAMP): FORCE
|
||||
@mkdir -p $(dir $@)
|
||||
@printf '%s\n' '$(THIRD_PARTY_C_FLAGS_7Z) $(LZMA_DEC_OPT_FLAG)' > $@.tmp
|
||||
@cmp -s $@.tmp $@ || { mv -f $@.tmp $@; echo ' [cflags] third-party flags changed -> rebuilding objects'; }
|
||||
@rm -f $@.tmp
|
||||
|
||||
$(LINUX_FLAGS_STAMP): FORCE
|
||||
@mkdir -p $(dir $@)
|
||||
@printf '%s\n' '$(THIRD_PARTY_C_FLAGS_7Z) $(LZMA_DEC_OPT_FLAG)' > $@.tmp
|
||||
@cmp -s $@.tmp $@ || { mv -f $@.tmp $@; echo ' [cflags] third-party flags changed -> rebuilding objects'; }
|
||||
@rm -f $@.tmp
|
||||
|
||||
.PHONY: FORCE
|
||||
FORCE:
|
||||
|
||||
# Note on -DVERSION_TAG / -DTITLE_ID: they live in CFLAGS, which make cannot
|
||||
# see -- but nothing here relies on make seeing them. The link target's *name*
|
||||
# carries the version ($(BIN) = web-file-mgr-$(VERSION_TAG).elf), so a bump
|
||||
# always misses the existing target and re-runs the link rule, and that rule
|
||||
# compiles every file in $(PS5_SRCS) on the spot (-x c, one clang invocation,
|
||||
# no intermediate .o). src/version.c and src/main.c -- the only two readers of
|
||||
# the macros -- are therefore always rebuilt with the new string. (Verified:
|
||||
# `strings` on the v1.9.3M ELF finds "v1.9.3M" once and "v1.9.2" zero times.)
|
||||
#
|
||||
# The version trap that *is* real: overriding VERSION_TAG back to a version
|
||||
# whose ELF already exists in the tree, with sources older than that file,
|
||||
# returns the existing file and silently skips the rebuild. Delete the stale
|
||||
# ELF, or build a differently named copy, when re-cutting a version.
|
||||
|
||||
ps5-obj/%.o: %.c $(PS5_FLAGS_STAMP)
|
||||
@mkdir -p $(dir $@)
|
||||
$(CC) $(if $(findstring third_party/7z,$<),$(THIRD_PARTY_C_FLAGS_7Z),$(THIRD_PARTY_C_FLAGS)) -c -o $@ $<
|
||||
|
||||
linux-obj/%.o: %.c
|
||||
linux-obj/%.o: %.c $(LINUX_FLAGS_STAMP)
|
||||
@mkdir -p $(dir $@)
|
||||
$(HOST_CC) $(if $(findstring third_party/7z,$<),$(THIRD_PARTY_C_FLAGS_7Z),$(THIRD_PARTY_C_FLAGS)) -c -o $@ $<
|
||||
|
||||
|
||||
@@ -1,109 +1,53 @@
|
||||
<div align="right">
|
||||
<a href="README.md">English</a> · <a href="README.zh-CN.md">简体中文</a>
|
||||
</div>
|
||||
|
||||
# PS5 Web File Manager
|
||||
|
||||
> Homebrew HTTP file manager for jailbroken PS5 consoles. Browse, edit, upload, download and extract ZIPs through any browser on the same network — single self-contained ELF payload, no external services, no telemetry.
|
||||
<p align="center">
|
||||
<a href="https://github.com/LisherSong/ps5-web-file-manager/releases/latest"><img src="https://img.shields.io/github/v/release/LisherSong/ps5-web-file-manager" alt="Latest release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/LisherSong/ps5-web-file-manager?color=blue" alt="License"></a>
|
||||
<img src="https://img.shields.io/badge/target-x86__64--sie--ps5-blue" alt="Target platform: x86_64-sie-ps5">
|
||||
<a href="https://github.com/LisherSong/ps5-web-file-manager/releases"><img src="https://img.shields.io/github/downloads/LisherSong/ps5-web-file-manager/total?color=green" alt="Total downloads"></a>
|
||||
</p>
|
||||
|
||||
**Version:** v1.9 · **Title ID:** `FMGR88888` · **License:** GPLv3+ · **Target:** `x86_64-sie-ps5`
|
||||
<p align="center">
|
||||
<a href="https://github.com/LisherSong/ps5-web-file-manager/releases/latest"><img src="https://img.shields.io/badge/Download-ELF%20payload-2ea44f?style=for-the-badge" alt="Download the ELF payload"></a>
|
||||
<a href="docs/USER-GUIDE-zh-CN.md"><img src="https://img.shields.io/badge/%E6%96%B0%E6%89%8B%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E-%E4%B8%AD%E6%96%87-2563eb?style=for-the-badge" alt="Beginner's guide (Chinese)"></a>
|
||||
</p>
|
||||
|
||||
> A homebrew HTTP file manager for jailbroken PS5 consoles. Browse, edit, upload,
|
||||
> download and extract archives from any browser on the same network — one
|
||||
> self-contained ELF payload, no external helper file, no telemetry.
|
||||
|
||||
**Version:** v1.9.3M · **Title ID:** `FMGR88888` · **License:** GPLv3+ · **Target:** `x86_64-sie-ps5`
|
||||
|
||||
**Download:** [latest release](https://github.com/LisherSong/ps5-web-file-manager/releases/latest) · **First time here?** [Beginner's guide (中文)](docs/USER-GUIDE-zh-CN.md)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
## What it is
|
||||
|
||||
A payload ELF that runs an HTTP file manager inside a jailbroken PS5. Open `http://<PS5_IP>:8888/` from any browser on the LAN — including the PS5 browser itself — to manage files on attached USB storage and the user partition. Designed for safely copying game-dump folders from USB to internal storage, but it also handles general file management, in-place text editing, PKG preview/install, image preview, and ZIP extraction with built-in zip-bomb protection.
|
||||
A single payload ELF that runs an HTTP file manager inside a jailbroken PS5.
|
||||
Send it to the console's ELF loader, and the console starts an HTTP service on
|
||||
port `8888` (it walks up to the next free port if that one is taken). Open
|
||||
`http://<PS5_IP>:8888/` from any browser on the LAN — including the PS5's own
|
||||
browser — and manage files on attached USB storage and the user partition.
|
||||
|
||||
The same source tree builds a Linux binary for development and a PS5 payload ELF for deployment — see `make linux` below.
|
||||
It was written to make one job safe and fast: **copying game-dump folders from
|
||||
USB storage to internal storage.** Everything else it does — browsing, sorting,
|
||||
permissions, in-place text editing, image preview, PKG install, multi-select
|
||||
copy/move/delete, upload and download — exists to make that job practical. On
|
||||
top of that this fork adds **native archive extraction** for ZIP, RAR and 7z,
|
||||
with the safety rails ("zip bomb", path traversal, disk-full) that the upstream
|
||||
helper approach does not have.
|
||||
|
||||
## What's new in v1.9
|
||||
The same source tree also builds a Linux binary, so the whole UI can be worked
|
||||
on without a console or the PS5 SDK:
|
||||
|
||||
- **RAR engine replaced with the official rarlab UnRAR 7.20.1**
|
||||
(`third_party/unrar7/`, replacing dmc_unrar). This is what actually
|
||||
makes RAR extraction work on real files: dmc_unrar could not decode
|
||||
archives written by **WinRAR 6.x/7.x** (RAR5 "v6" compression) and had
|
||||
no multi-volume support — both now work.
|
||||
- **RAR5 "v6" archives extract** (the v1.8-era "corrupt archive" report
|
||||
on WinRAR 6/7 files is gone).
|
||||
- **Multi-volume RAR** (`.part01.rar` chains): unrar stitches the parts by
|
||||
name when the full set sits next to the volume you open.
|
||||
- Engine can decrypt encrypted RAR (`RARSetPassword`) — password UI /
|
||||
API plumbing still pending, encrypted archives are rejected for now.
|
||||
- Host tests now run real archives (v6 / encrypted / 3-volume fixtures
|
||||
committed under `tests/fixtures-real/`): **70 ZIP + 24 RAR = 94 checks**.
|
||||
|
||||
## What's new in v1.8
|
||||
|
||||
- **Single-volume RAR extraction** via the vendored FLOSS library
|
||||
[`dmc_unrar`](https://github.com/DrMcCoy/dmc_unrar) (GPL-2.0-or-later).
|
||||
RAR 1.5, 2.x, 3.x, 4.x and 5.x archives are supported. `.rar` files
|
||||
appear in the file list with the **Extract** button enabled; the button
|
||||
is greyed out on `.part02+.rar` sub-volumes with the tooltip "select
|
||||
the main volume instead" — v1.8 cannot stitch multi-volume RARs (see
|
||||
the [RAR extraction](#rar-extraction) section below).
|
||||
- **Shared extraction protocol** between the new `src/rar_extract.c`
|
||||
engine and the existing `src/zip_extract.c` engine: same `zipx_status_t`
|
||||
codes, same `zipx_limits_t` profile (default / `large=1`), same
|
||||
three-phase model (`scan → extract → publish → cleanup`), same staging
|
||||
directory layout, same conflict policy, same error mapping into the
|
||||
task UI. The dispatcher in `src/extract.c` is one tiny
|
||||
`ends_with_ci(…)` switch.
|
||||
- **14 new host-side C tests** (`tests/test_rar_extract.c`) wired into
|
||||
the existing `tests/run-tests.sh`. Coverage: format dispatch, error
|
||||
translation across every `DMC_UNRAR_*` code that affects RAR users,
|
||||
limit-profile handoff. Total host checks: **69 ZIP + 14 RAR = 83**.
|
||||
- **Documentation**: [`CHANGELOG.md`](./CHANGELOG.md),
|
||||
[`docs/UPGRADE-v1.8-rar-support.md`](./docs/UPGRADE-v1.8-rar-support.md)
|
||||
and the vendoring decision tree at
|
||||
[`third_party/unrar/VENDORED.md`](./third_party/unrar/VENDORED.md).
|
||||
- See the [dedicated section](#rar-extraction) below for scope and the
|
||||
limitations that come from using dmc_unrar (no multi-volume, no
|
||||
encryption in v1.8 — both lift in v1.9 when the library is replaced).
|
||||
|
||||
## What's new in v1.8.1
|
||||
|
||||
- **Default ZIP limits relaxed** (companion to v1.7's large profile).
|
||||
v1.7 shipped with a 64 GiB default per-entry cap, which was too
|
||||
aggressive for typical PS5 system-backup ZIPs (200-300 GiB). v1.8.1
|
||||
raises the default profile to **1 TiB total / 256 GiB per entry /
|
||||
500 : 1 ratio**, with the `large=1` opt-in kept at 2 TiB / 1 TiB /
|
||||
1000 : 1. The frontend threshold rises from 60 GiB to 240 GiB so
|
||||
common system-backup archives no longer trigger the prompt.
|
||||
- RAR extraction inherits the new defaults (rar_extract.c threads
|
||||
`c->limits` from the engine — no engine change required).
|
||||
- Rationale: the real zip-bomb defence is `check_space()` (statvfs-based
|
||||
real disk-space check before staging) + `max_ratio` (declared
|
||||
compression ratio cap). The size caps are a UX guard, not a security
|
||||
boundary.
|
||||
|
||||
## What's new in v1.8.2
|
||||
|
||||
- **Default ZIP limits relaxed again** for the 3A-game single-file case.
|
||||
A single ~300 GiB uncompressed file inside an archive was still
|
||||
silently rejected by v1.8.1 (the default scan returns
|
||||
`ZIPX_ERR_LIMIT_FILE_SIZE` before the request ever reaches the
|
||||
frontend confirmation prompt). v1.8.2 raises the default profile to
|
||||
**2 TiB total / 512 GiB per entry / 500 : 1 ratio**, with the `large=1`
|
||||
opt-in bumped to 4 TiB / 1 TiB / 1000 : 1. Frontend threshold rises
|
||||
from 240 GiB to 480 GiB.
|
||||
- **Two PS5-only build fixes** discovered when cross-compiling for the
|
||||
PS5 target. The host-side test suite (`tests/run-tests.sh`) had
|
||||
silently accepted both because it links the same sources but uses
|
||||
gcc rather than clang 18 and a different include path:
|
||||
- `Makefile` CFLAGS: add `-Ithird_party/unrar` so `src/rar_extract.c`
|
||||
can find the project-authored `dmc_unrar_api.h` facade header.
|
||||
- `src/extract.c`: move `extract_progress()` definition above
|
||||
`extract_dispatch()` so the implicit function declaration is not
|
||||
flagged by `-Werror=implicit-function-declaration` (clang 18 in the
|
||||
PS5 SDK is stricter than the host gcc used by tests).
|
||||
- **Release artifact** for v1.8.2: `web-file-mgr.elf` — 509 704 bytes,
|
||||
sha256 `1b2c3d68b35e32737105f17d14a80a3c159ceca0cabd274ee168cbcd81906f65`,
|
||||
ELF class 64, little-endian, e_machine `0x003e` (x86_64-sie-ps5).
|
||||
- Tests: **84 host-side checks** (70 ZIP + 14 RAR), 0 failures. PS5
|
||||
cross-compile succeeds end-to-end.
|
||||
|
||||
## What's new in v1.7
|
||||
|
||||
- **ZIP large-file profile** (opt-in via the new `large=1` argument on `/api/extract`): relaxed caps of **2 TiB** archive total, **1 TiB** per entry, **1000 : 1** compression ratio. The frontend prompts for confirmation whenever the archive on disk is larger than **60 GiB**; the server only activates the profile when the user explicitly agrees.
|
||||
- **Stricter default ZIP profile** stays safe: **1 TiB** total / **256 GiB** per entry / **500 : 1** ratio. A 4 MiB compressed payload that expands to 800 GiB still gets rejected before any output file is opened.
|
||||
- **69 host-side C tests** (`tests/run-tests.sh`) now cover path traversal, ZIP64, encryption rejection, ratios, conflict policies and the new large-file profile (`tests/test_zip_extract.c`).
|
||||
- Earlier refinements — see `git log` since v1.6.
|
||||
```sh
|
||||
make linux && ./web-file-mgr-linux-v1.9.3M
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -118,52 +62,232 @@ The same source tree builds a Linux binary for development and a PS5 payload ELF
|
||||
|
||||
## Features
|
||||
|
||||
- **Browse** — list files and folders; sort by name, type, size, mtime or permissions. Last sort mode persists in `localStorage`.
|
||||
- **Permissions** — toggle read/write/execute with checkboxes, or paste a validated four-digit octal mode.
|
||||
- **Operations** — copy, move, delete (recursive, no recycle bin), rename, create files and folders.
|
||||
- **Editor** — in-place UTF-8 text editor for files ≤ 1 MiB across a curated extension list: `.txt .json .xml .ini .cfg .conf .md .log .lua .js .css .html .htm .c .h .cpp .hpp .sh .csv .yaml .yml .shn`.
|
||||
**Files and folders**
|
||||
|
||||
- **Browse and sort** — list files and folders; sort by name, type, size,
|
||||
modified time or permissions. The chosen sort mode persists in
|
||||
`localStorage`.
|
||||
- **Permissions** — toggle read / write / execute from the permissions column
|
||||
with checkboxes, or paste a validated four-digit octal mode.
|
||||
- **Copy and move** — the two-step, clipboard-style flow: select the sources,
|
||||
then paste (copy) or move them into the folder you browse to next. Conflict
|
||||
prompts appear for overwriting files and for merging folders.
|
||||
- **Rename** — rename one selected item in place.
|
||||
- **Delete** — recursive and permanent; there is no recycle bin.
|
||||
- **Create** — new folders and new empty text files.
|
||||
- **Multi-select** — copy, move, delete or tar-download many items in one go.
|
||||
- **Upload** — single files or folder trees from any device on the LAN (hidden in the PS5 browser). Atomic temp + rename.
|
||||
- **Download** — single file as raw bytes, or folders/multi-select as a streaming `.tar`. Hidden in the PS5 browser.
|
||||
- **Tasks** — full-screen overlay with delayed show, live progress, throughput, ETA, cancel, and recovery if the browser is closed and reopened mid-task.
|
||||
- **Archive extraction** — ZIP (encrypted rejected) and RAR (v1.9: RAR4 + RAR5 incl. WinRAR 6/7 "v6", multi-volume; encrypted still rejected pending password UI); see the [ZIP extraction](#zip-extraction) and [RAR extraction](#rar-extraction) sections below for scope.
|
||||
- **PKG** — install and preview `.pkg` files.
|
||||
- **Images** — preview `.png .jpg .jpeg .gif .bmp .webp`.
|
||||
- **Localization** — English + Simplified Chinese, auto-selected from `navigator.languages`.
|
||||
- **Mobile-friendly** — responsive layout with wrapped toolbars and horizontally scrollable file lists.
|
||||
- **Copied and moved files are chmod'ed `0777`** where the filesystem supports
|
||||
Unix permissions. FAT/exFAT-style filesystems may ignore the chmod — that is
|
||||
the filesystem's answer, not an error.
|
||||
|
||||
**Content**
|
||||
|
||||
- **Text editor** — in-place UTF-8 editing for files up to 1 MiB, across a
|
||||
curated extension list: `.txt .json .xml .ini .cfg .conf .md .log .lua .js
|
||||
.css .html .htm .c .h .cpp .hpp .sh .csv .yaml .yml .shn`. Non-UTF-8 and
|
||||
oversized files are refused rather than mangled.
|
||||
- **Image preview** — `.png .jpg .jpeg .gif .bmp .webp`, served straight from
|
||||
the console.
|
||||
- **PKG** — install `.pkg` files and preview their metadata.
|
||||
|
||||
**Moving data in and out**
|
||||
|
||||
- **Upload** — a "Upload ▾" menu offering *single file* and *folder tree*;
|
||||
full-page drag and drop works too, and the footer says so. Files are written
|
||||
to a temporary name and renamed into place when the transfer completes.
|
||||
Hidden in the PS5 browser, since the point is to drive the console from
|
||||
another device.
|
||||
- **Download** — a single file as raw bytes, or folders / multi-selection as a
|
||||
streamed `.tar` that is never written to console storage first. Hidden in the
|
||||
PS5 browser.
|
||||
- **Upload and extract** — pick an archive, choose "extract after upload", and
|
||||
the extraction starts as soon as the upload lands. If it turns out to be
|
||||
encrypted, the password prompt appears immediately.
|
||||
|
||||
**Archive extraction** — see [Archive support](#archive-support) for the full
|
||||
matrix. In short: ZIP, RAR and 7z, plain or encrypted, single or split, all
|
||||
behind the same size / ratio / traversal / disk-space protection, and all
|
||||
implemented inside this payload — no second file to install.
|
||||
|
||||
**Everything else**
|
||||
|
||||
- **Task overlay** — a full-screen overlay with delayed appearance, live
|
||||
progress, throughput, ETA, cancel, and recovery of the active task display if
|
||||
the browser is closed and reopened while the payload keeps running.
|
||||
- **Localization** — English and Simplified Chinese, selected from
|
||||
`navigator.languages` / `navigator.language` (`zh*` → Chinese, everything
|
||||
else → English).
|
||||
- **Mobile-friendly** — responsive layout with wrapped toolbars and
|
||||
horizontally scrollable file lists.
|
||||
- **Start-up notification and home-screen launcher** — the notification shows
|
||||
the app name, the version and the actual listen port; on first start the
|
||||
payload installs a "PS5 Web File Manager" shortcut in the Media category
|
||||
without overwriting launcher files that already exist. The launcher icon and
|
||||
the browser favicon are the same embedded `icon0.png`, so the icon is stored
|
||||
once in the ELF.
|
||||
- **Filenames survive mixed encodings** — names are transported as UTF-8 over
|
||||
the web API, but the payload also preserves the byte-oriented names returned
|
||||
by mounted filesystems, so a USB stick holding GBK names still displays and
|
||||
operates correctly. Upstream carries the same mechanism; what this fork adds
|
||||
is that backend error messages decode too, so an entry name is not mangled at
|
||||
the exact moment it matters most (see [Notes](#notes)).
|
||||
|
||||
## Archive support
|
||||
|
||||
Three engines, dispatched by extension in `src/extract.c`, sharing one
|
||||
three-phase pipeline (`scan → extract to staging → publish by rename`) and one
|
||||
set of limit profiles and conflict policies. Vendoring decisions and the
|
||||
per-library licence position are in
|
||||
[`third_party/unrar7/VENDORED.md`](third_party/unrar7/VENDORED.md) and
|
||||
[`THIRD_PARTY_NOTICES`](THIRD_PARTY_NOTICES).
|
||||
|
||||
| | ZIP | RAR | 7z |
|
||||
|---|---|---|---|
|
||||
| Engine | `src/zip_extract.{c,h}` | `src/rar_extract.{c,h}` | `src/sevenz_extract.{c,h}` |
|
||||
| Backend | vendored minizip-ng 4.2.2 + zlib | vendored **rarlab UnRAR 7.20.1** (official source) | LZMA SDK 26.03 decode subset + self-written codec chain |
|
||||
| Stored / deflated | ✅ | n/a | ✅ (Copy / LZMA / LZMA2 / PPMd) |
|
||||
| 64-bit sizes | ✅ ZIP64 | ✅ | ✅ |
|
||||
| Filters / converters | — | — | ✅ Delta, BCJ2, PPC / IA64 / ARM / ARMT / SPARC |
|
||||
| Multi-volume | ✅ parts offered by the engine | ✅ unrar stitches by name | ✅ |
|
||||
| Traditional password | ✅ PKWARE "ZipCrypto" (`zip -e`) | ✅ `-p` | — |
|
||||
| AES encryption | ✅ WinZip AES-128/192/256 | ✅ | ✅ 7zAES (AES-256-CBC) |
|
||||
| Encrypted file names | — | ✅ `-hp` header encryption | ✅ `-mhe=on` encrypted header |
|
||||
| Password prompt | on failure, retried | on failure, retried | asked up front |
|
||||
|
||||
**Volume naming that works**
|
||||
|
||||
| Format | Accepted | Note |
|
||||
|---|---|---|
|
||||
| ZIP | `name.zip.001…` (7-Zip), `name.part1.zip…` (WinRAR), `name.z01…` + `name.zip` (Info-ZIP) | Any part can be selected; the engine finds the rest in the same folder |
|
||||
| RAR | `name.part1.rar` / `name.part01.rar` (first volume) | Select the **first** volume. Other volumes are greyed out with a hint |
|
||||
| 7z | `name.7z.001…` | Any volume works; the engine walks the directory for the rest |
|
||||
|
||||
### Size and safety limits
|
||||
|
||||
Two profiles. The default is shipped safe; the large profile is engaged **only**
|
||||
when the request carries `large=1`, and the UI asks for that opt-in through a
|
||||
confirmation prompt.
|
||||
|
||||
| Limit | Default | Large (`large=1`) |
|
||||
|---|---|---|
|
||||
| `max_entries` | 200 000 | 500 000 |
|
||||
| `max_total_bytes` (uncompressed) | 2 TiB | 4 TiB |
|
||||
| `max_file_bytes` (per entry) | 512 GiB | 1 TiB |
|
||||
| `max_ratio` (uncompressed ÷ compressed) | 500 : 1 | 1000 : 1 |
|
||||
| `max_depth` (folder nesting) | 32 | 32 |
|
||||
| `max_name_len` / `max_path_len` | 255 / 1024 | 255 / 1024 |
|
||||
|
||||
The default caps are sized for the console's real workload: a 3A title packed
|
||||
as one ~300 GiB file inside an archive extracts without any prompt.
|
||||
|
||||
### Security checks
|
||||
|
||||
Extraction refuses, before creating a single output file:
|
||||
|
||||
- **Path traversal** — `..` segments, absolute POSIX paths, Windows drive
|
||||
letters, `\` treated as a separator inside RAR.
|
||||
- **Special files** — symbolic links, devices, FIFOs, sockets
|
||||
(`ZIPX_ERR_SPECIAL`).
|
||||
- **Duplicate entries**, and directory / file name clashes inside one archive.
|
||||
- **Limit breaches** — expanded size, entry count, nesting depth, name length or
|
||||
compression ratio over the active profile.
|
||||
- **Disk space** — `check_space()` consults `statvfs` for the *expanded* total
|
||||
before staging begins, so a download that cannot finish is never started.
|
||||
|
||||
Handing over a password does **not** skip the scan phase: an encrypted archive
|
||||
gets the same limits as a plain one.
|
||||
|
||||
### Conflict policy
|
||||
|
||||
Passed as `conflict=` on `/api/extract`:
|
||||
|
||||
- `fail` (default) — refuse to overwrite anything that already exists.
|
||||
- `overwrite` — replace existing files, merge into existing folders.
|
||||
- `merge` — keep existing files, add the new ones.
|
||||
|
||||
### Password handling
|
||||
|
||||
A missing or wrong password comes back as `ZIPX_ERR_PASSWORD`
|
||||
(`err_extract_password` in the UI). The frontend shows a password box and
|
||||
re-sends **the same request** — same conflict policy, same large-file opt-in —
|
||||
up to three times; cancelling or submitting an empty box falls back to the
|
||||
original failure report. The first failure says the archive is encrypted rather
|
||||
than blaming a password you were never asked for.
|
||||
|
||||
7z is the exception: because `-mhe=on` hides the file names inside the header,
|
||||
the prompt comes **up front**, before the scan — otherwise an encrypted 7z would
|
||||
cost a wasted scan before anyone could ask.
|
||||
|
||||
### Tuning the large-file prompt
|
||||
|
||||
The frontend threshold lives in `assets/main.js`:
|
||||
|
||||
```js
|
||||
const LARGE_FILE_THRESHOLD_BYTES = 480 * 1024 * 1024 * 1024; // 480 GiB
|
||||
```
|
||||
|
||||
An archive larger than this on disk triggers the confirmation prompt. Set it to
|
||||
`Infinity` to silence the prompt, lower it to be more conservative, or remove
|
||||
the call — the server honours `large=1` regardless of what the frontend does.
|
||||
|
||||
### What this build refuses, on purpose
|
||||
|
||||
- **Formats other than ZIP / RAR / 7z.** `.tar`, `.tar.gz` / `.tgz`, `.gz`,
|
||||
`.xz`, `.bz2`, `.zst`, `.cab`, `.arj`, `.lzh`, `.cpio`, `.xar` and the rest of
|
||||
the long tail are not recognised. Upstream covers ~30 extensions by shipping
|
||||
a full 7-Zip as an external helper process; this fork deliberately does not —
|
||||
see [How this fork differs from upstream](#how-this-fork-differs-from-upstream).
|
||||
- **ZIP entries using a compression method other than stored / deflated**, 7z
|
||||
folders with an unsupported coder, RAR older than 1.4.
|
||||
- **RAR volume sets named `x.rar.001`.** unrar chains its own `x.partN.rar`
|
||||
naming; rename the parts (`.rar.001` → `.part1.rar`, `.002` → `.part2.rar`, …)
|
||||
and it works. Split ZIP and 7z sets accept the `.001` style directly.
|
||||
- **RAR dictionaries above 4 GiB.** Such an archive is refused with its own
|
||||
`err_extract_dict_too_large` code and a message naming both the required and
|
||||
the supported size. Honouring it would mean one single allocation of the whole
|
||||
dictionary window — what rarlab's own CLI refuses by default and what a 16 GB
|
||||
shared-memory console cannot afford. (RAR5 caps the header field at 4 GiB, so
|
||||
this can only come from the newer RAR7 header format.) A wrong password is
|
||||
**not** in this category, and neither is a multi-volume set.
|
||||
|
||||
## Quickstart
|
||||
|
||||
1. **Build** the ELF:
|
||||
1. **Build** the payload:
|
||||
|
||||
```sh
|
||||
export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk # see "Build" for SDK setup
|
||||
export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk # see Build below
|
||||
make
|
||||
```
|
||||
2. **Send** the payload to the PS5 (default ELF-loader port `9021`):
|
||||
|
||||
2. **Send** it to the console (the usual ELF-loader port is `9021`):
|
||||
|
||||
```sh
|
||||
nc -q0 "$PS5_HOST" 9021 < web-file-mgr.elf
|
||||
nc -q0 "$PS5_HOST" 9021 < web-file-mgr-v1.9.3M.elf
|
||||
```
|
||||
3. **Read** the on-screen PS5 notification — it prints the actual listen port (default `8888`).
|
||||
4. **Open** `http://<PS5_IP>:<port>/` in any browser on the same LAN — the PS5 browser works too.
|
||||
5. On first run, the payload also writes a **Media**-category home-screen launcher; existing launcher files are not overwritten.
|
||||
|
||||
3. **Read** the on-screen notification — it prints the actual listen port
|
||||
(usually `8888`).
|
||||
4. **Open** `http://<PS5_IP>:<port>/` in any browser on the same LAN.
|
||||
5. On first start the payload also writes a **Media**-category home-screen
|
||||
launcher; existing launcher files are left alone.
|
||||
|
||||
## Build
|
||||
|
||||
Requires [ps5-payload-dev/sdk](https://github.com/ps5-payload-dev/sdk#quick-start):
|
||||
Requires the [ps5-payload-dev/sdk](https://github.com/ps5-payload-dev/sdk#quick-start):
|
||||
|
||||
```sh
|
||||
export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk
|
||||
```
|
||||
|
||||
This project links against `libmicrohttpd`. `make` checks for it before building and runs the installer automatically when missing:
|
||||
The project links against `libmicrohttpd`. `make` checks for it and runs the
|
||||
installer automatically when it is missing:
|
||||
|
||||
```sh
|
||||
make
|
||||
```
|
||||
|
||||
If the build host has no network access, drop the libmicrohttpd tarball in advance and run the installer manually:
|
||||
On a host without network access, drop the tarball in place and install it
|
||||
manually first:
|
||||
|
||||
```sh
|
||||
LIBMICROHTTPD_TARBALL=/path/to/libmicrohttpd-1.0.1.tar.gz \
|
||||
@@ -174,268 +298,282 @@ make
|
||||
Output:
|
||||
|
||||
```text
|
||||
web-file-mgr.elf (~several hundred KiB, larger in v1.9 with unrar; x86_64-sie-ps5)
|
||||
web-file-mgr-v1.9.3M.elf # x86_64-sie-ps5, ~882 KiB
|
||||
```
|
||||
|
||||
For pure UI/JS work without the PS5 toolchain:
|
||||
The version string is part of `VERSION_TAG` and therefore of the output **file
|
||||
name**, so a build cannot silently shadow another version's artifact. Override
|
||||
it when needed:
|
||||
|
||||
```sh
|
||||
make VERSION_TAG=v1.9.4M
|
||||
```
|
||||
|
||||
For UI/JS work without the PS5 toolchain:
|
||||
|
||||
```sh
|
||||
make linux
|
||||
./web-file-mgr-linux
|
||||
./web-file-mgr-linux-v1.9.3M
|
||||
```
|
||||
|
||||
The Linux build does **not** include the PS5 home-screen launcher installer.
|
||||
The Linux build does not include the PS5 home-screen launcher installer.
|
||||
|
||||
## Usage
|
||||
|
||||
Start an ELF loader on the PS5 (port `9021` is common). Send the payload:
|
||||
Start an ELF loader on the console (port `9021` is the common one) and send the
|
||||
payload:
|
||||
|
||||
```sh
|
||||
export PS5_HOST=ps5_ip_address
|
||||
nc -q0 "$PS5_HOST" 9021 < web-file-mgr.elf
|
||||
nc -q0 "$PS5_HOST" 9021 < web-file-mgr-v1.9.3M.elf
|
||||
```
|
||||
|
||||
After the payload starts, the PS5 notification shows the app name, version and actual listen port. Open the URL it prints, for example:
|
||||
After it starts, the notification shows the app name, the version and the actual
|
||||
listen port. Open the URL it prints:
|
||||
|
||||
```text
|
||||
http://${PS5_IP_ADDRESS}:8888/
|
||||
```
|
||||
|
||||
If the payload had to fall back to a different port (e.g. `8889`), use whatever port the notification shows — the URL is not hard-coded.
|
||||
If `8888` was already in use the payload walked up to the next free port — use
|
||||
whatever the notification says, the URL is not hard-coded. On first start it
|
||||
installs a `PS5 Web File Manager` shortcut in the Media category when needed;
|
||||
missing launcher files are written, existing ones are preserved.
|
||||
|
||||
On first startup, the payload installs a `PS5 Web File Manager` shortcut in the Media category when needed. Existing launcher files are preserved; only missing ones are written.
|
||||
|
||||
## ZIP extraction
|
||||
|
||||
Plain ZIPs only — stored / deflated / ZIP64, **never encrypted**. The engine is a standalone three-phase module (`scan → extract → publish → cleanup`) at `src/zip_extract.{c,h}`, with a separate host-side C test suite. Each entry is first written into a staging directory (`*.wfm-part-*`), fsynced, then atomically renamed into the destination. Any failure mid-archive rolls back partial changes; cancel and fatal errors always clean up staging.
|
||||
|
||||
### Limits
|
||||
|
||||
| Limit | Default profile | Large profile (`ZIPX_LIMITS_LARGE`) |
|
||||
|---|---|---|
|
||||
| `max_entries` | 200 000 | 500 000 |
|
||||
| `max_total_bytes` (uncompressed) | 2 TiB | 4 TiB |
|
||||
| `max_file_bytes` (per entry) | 512 GiB | 1 TiB |
|
||||
| `max_ratio` (uncompressed / compressed) | 500 : 1 | 1000 : 1 |
|
||||
| `max_depth` (folder nesting) | 32 | 32 |
|
||||
| `max_name_len` / `max_path_len` | 255 / 1024 | 255 / 1024 |
|
||||
|
||||
The **default profile** is shipped safe: a 4 MiB compressed blob that decodes to 800 GiB is rejected before any output file is opened. The **large profile** is engaged **only** when the request includes `large=1` — the archive dialog prompts the user automatically whenever the archive on disk is larger than `LARGE_FILE_THRESHOLD_BYTES` (480 GiB by default; configurable in `assets/main.js`). Confirming the prompt is the user's explicit opt-in; the server still records nothing extra on its own.
|
||||
|
||||
### Security checks
|
||||
|
||||
The engine refuses to extract:
|
||||
|
||||
- Encrypted entries (any encryption flag set).
|
||||
- Path traversal (`..` segments, absolute POSIX paths, Windows drive letters).
|
||||
- Symbolic links, devices, FIFOs, sockets (`ZIPX_ERR_SPECIAL`).
|
||||
- Duplicate entries or directory/file name clashes inside the same archive.
|
||||
- Archives whose expanded size, entry count, depth, name length or compression ratio breach the active profile.
|
||||
|
||||
### Conflict policy
|
||||
|
||||
Passed as `conflict=` on `/api/extract`:
|
||||
|
||||
- `fail` (default) — refuse to overwrite any existing target.
|
||||
- `overwrite` — replace existing files; merge into existing folders.
|
||||
- `merge` — keep existing files, add new ones.
|
||||
|
||||
### Tuning the threshold
|
||||
|
||||
The 480 GiB frontend threshold lives in `assets/main.js`:
|
||||
|
||||
```js
|
||||
const LARGE_FILE_THRESHOLD_BYTES = 480 * 1024 * 1024 * 1024;
|
||||
```
|
||||
|
||||
Set it to `Infinity` to silence the prompt, lower it to be more conservative, or remove the call entirely — the server still respects `large=1` regardless of the threshold.
|
||||
|
||||
## RAR extraction
|
||||
|
||||
A RAR extraction engine (`src/rar_extract.{c,h}`) backed by the **official
|
||||
rarlab UnRAR source** (`third_party/unrar7/`, version 7.20.1, compiled as a
|
||||
static library and driven through its C-compatible DLL API). Files with the
|
||||
extension `.rar` get the same **Extract** button as `.zip` files; the engine
|
||||
is dispatched by `src/extract.c` based on extension.
|
||||
|
||||
> v1.9 replaced the v1.8 engine (dmc_unrar 1.7.0). dmc_unrar could not
|
||||
> decode archives written by WinRAR 6.x/7.x (RAR5 "v6" compression) and had
|
||||
> no multi-volume support; unrar handles both natively.
|
||||
|
||||
### Scope
|
||||
|
||||
| Format | Support | Notes |
|
||||
|---|---|---|
|
||||
| RAR 1.5 → 4.x (incl. 2.9 / 3.6 / 4.0) | ✅ | |
|
||||
| RAR 5.0 and **5.0 "v6"** (WinRAR 6.x / 7.x) | ✅ | The v1.9 trigger |
|
||||
| Solid blocks, dictionary up to 1 GiB | ✅ | |
|
||||
| PPMd decompression (RAR 3.0+) | ✅ | |
|
||||
| **Multi-volume** (`.part01.rar` + `.part02.rar` + …) | ✅ | unrar stitches parts by name when the whole set sits next to the volume you open. Select the first volume (`name.part1.rar` / `name.part01.rar`); non-first volumes are still greyed out in the UI with a hint. |
|
||||
| **Encrypted RAR** | ⏳ | The engine can decrypt (`RARSetPassword`), but the password field / prompt is not wired into `/api/extract` yet. Encrypted archives are rejected up front with `ZIPX_ERR_UNSUPPORTED`. |
|
||||
| Symbolic links / FIFOs / sockets / devices | ❌ | Rejected with `ZIPX_ERR_SPECIAL` (mirrors ZIP behaviour) |
|
||||
| RAR 1.3 (pre-1.4) | ❌ | Rejected upstream by unrar |
|
||||
|
||||
When an archive is rejected, the user gets an `extract_unsupported`
|
||||
failure with the file name as the detail argument. The frontend already
|
||||
shows this with the typical bilingual retry guidance.
|
||||
|
||||
### Limits
|
||||
|
||||
The RAR engine re-uses the ZIP limits table verbatim — there is no RAR
|
||||
profile table on top. Defaults and the `large=1` opt-in are identical:
|
||||
|
||||
| Limit | Default profile | Large profile (`large=1`) |
|
||||
|---|---|---|
|
||||
| `max_entries` | 200 000 | 500 000 |
|
||||
| `max_total_bytes` (uncompressed) | 2 TiB | 4 TiB |
|
||||
| `max_file_bytes` (per entry) | 512 GiB | 1 TiB |
|
||||
| `max_ratio` (uncompressed / compressed) | 500 : 1 | 1000 : 1 |
|
||||
| `max_depth` (folder nesting) | 32 | 32 |
|
||||
| `max_name_len` / `max_path_len` | 255 / 1024 | 255 / 1024 |
|
||||
|
||||
Large-profile RAR extraction uses the same `LARGE_FILE_THRESHOLD_BYTES`
|
||||
(480 GiB) prompt as ZIP — the frontend treats `.rar` and `.zip` the same
|
||||
way for the prompt, and the server only ever activates the large caps
|
||||
when the request carries `large=1` (opt-in).
|
||||
|
||||
### Security checks
|
||||
|
||||
The RAR engine applies the same checks as the ZIP engine — re-uses
|
||||
`zipx_status_t` codes, so the task UI's `err_extract_unsafe_name`,
|
||||
`err_extract_too_deep`, `err_extract_ratio`, etc. all fire identically:
|
||||
|
||||
- Path traversal (`..` segments, absolute POSIX paths, Windows drive
|
||||
letters, `\` treated as a path separator after a `Rar!\x1a\x07…`
|
||||
header, etc.).
|
||||
- Symbolic links, FIFOs, sockets, devices.
|
||||
- Duplicate entries or directory/file name clashes inside the archive.
|
||||
- Archive size, entry count, depth, name length or compression ratio
|
||||
breaches of the active profile.
|
||||
|
||||
### Vendoring and licence
|
||||
|
||||
`third_party/unrar7/` is a verbatim copy of the official **rarlab UnRAR
|
||||
source** (7.20.1), mirrored by
|
||||
[`opello/unrar`](https://github.com/opello/unrar) at commit `97e1780`. It is
|
||||
distributed under the **UnRAR freeware licence** (see
|
||||
`third_party/unrar7/license.txt`): it may be used in any software to handle
|
||||
RAR archives, but may not be used to develop a RAR-compatible *archiver* or
|
||||
re-create the RAR compression algorithm. The project-authored facade
|
||||
`third_party/unrar7/unrar_c_api.h` carries the project's own licence.
|
||||
|
||||
> The v1.8 engine `third_party/unrar/dmc_unrar.c` (DrMcCoy/dmc_unrar 1.7.0,
|
||||
> GPL-2.0-or-later) was removed in v1.9; its notice lives in git history.
|
||||
|
||||
### Encrypted RAR (planned)
|
||||
|
||||
The engine can decrypt archives (via `RARSetPassword`), but the password
|
||||
channel — a `password=` field on `/api/extract` plus a frontend prompt —
|
||||
is not wired yet. Encrypted archives currently fail with
|
||||
`extract_unsupported`. The engine swap (v1.9) removed the hard engine
|
||||
limits; the remaining work is purely API/UI plumbing.
|
||||
|
||||
## Verification
|
||||
|
||||
After `make`, sanity-check the produced ELF:
|
||||
## Verifying the build
|
||||
|
||||
```sh
|
||||
ls -la web-file-mgr.elf # size grew in v1.9 (unrar static library); ~509 KiB was v1.8.3
|
||||
sha256sum web-file-mgr.elf # record the digest in your release notes
|
||||
file web-file-mgr.elf # expect "ELF 64-bit LSB pie executable, x86-64"
|
||||
od -An -tx1 -N20 web-file-mgr.elf | head -2 # magic 7f45 4c46 0201 + e_machine 003e
|
||||
ls -la web-file-mgr-v1.9.3M.elf # ~882 KiB
|
||||
sha256sum web-file-mgr-v1.9.3M.elf # 8ca47d5a…c9bb for v1.9.3M
|
||||
file web-file-mgr-v1.9.3M.elf # "ELF 64-bit LSB pie executable, x86-64"
|
||||
od -An -tx1 -N20 web-file-mgr-v1.9.3M.elf | head -2 # magic 7f45 4c46 0201, e_machine 003e
|
||||
```
|
||||
|
||||
The `e_machine = 0x003e` confirms the PS5 target triple `x86_64-sie-ps5`. The `e_type = 3` (`ET_DYN`) confirms the position-independent payload expected by ELF loaders.
|
||||
`e_machine = 0x003e` confirms the PS5 target triple `x86_64-sie-ps5`;
|
||||
`e_type = 3` (`ET_DYN`) confirms the position-independent payload an ELF loader
|
||||
expects.
|
||||
|
||||
The JS/CSS/HTML assets are **gzip-compressed and embedded** in the ELF, so a
|
||||
plain `strings` search for anything from `assets/` returns nothing useful. Use
|
||||
the helper script instead:
|
||||
|
||||
```sh
|
||||
python3 .build/check-elf-gzip.py ./web-file-mgr-v1.9.3M.elf uploadMenu extractRetryKey
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
A POSIX/host-side C test suite covers the ZIP engine and runs on any Linux / macOS / MSYS shell without the PS5 SDK:
|
||||
A POSIX / host-side C suite covers the ZIP, RAR and 7z engines and runs on any
|
||||
Linux / macOS / MSYS shell without the PS5 SDK:
|
||||
|
||||
```sh
|
||||
cd tests && bash run-tests.sh
|
||||
cd tests && bash run-tests.sh # ZIP + RAR suites
|
||||
bash run-sevenz-tests.sh # 7z suite (needs MinGW gcc and a 7-Zip binary)
|
||||
```
|
||||
|
||||
Output is a per-case `check`-style report — **84 checks** on the current `main`
|
||||
(70 ZIP + 14 RAR). Coverage:
|
||||
Current `main`: **177 checks** (140 ZIP + 37 RAR), 0 failures, plus **27 7z
|
||||
cases**, 0 failures. Coverage:
|
||||
|
||||
- ZIP entry parsing (stored + deflated + ZIP64)
|
||||
- Path traversal, absolute paths, backslash, Windows drive letters
|
||||
- Symbolic links, FIFOs, encrypted entries, bad CRC, truncated archives, non-ZIP files
|
||||
- Limits: `entries`, `total_bytes`, `file_bytes`, `ratio`, `depth`, `name_len`
|
||||
- Conflict policies: `fail` / `overwrite` / `merge`
|
||||
- Cancellation in every phase
|
||||
- **Large-file profile** — `medium_bomb.zip` (ratio ≈ 238) is rejected under default caps and accepted under large caps; lowered large caps still enforce.
|
||||
- **RAR engine** (`tests/test_rar_extract.c`, 14 checks) — format
|
||||
dispatch (renamed ZIP rejected, junk blob rejected), error translation
|
||||
across every reachable `DMC_UNRAR_*` code, limits handoff (the
|
||||
`large=1` opt-in flows into `rar_extract()` unchanged).
|
||||
- ZIP entry parsing (stored, deflated, ZIP64), and a byte-for-byte comparison
|
||||
of extracted content against real archives
|
||||
- Path traversal, absolute paths, backslashes, Windows drive letters
|
||||
- Symbolic links, FIFOs, bad CRC, truncated archives, non-ZIP input
|
||||
- Every limit (entries, total bytes, file bytes, ratio, depth, name length)
|
||||
- Conflict policies `fail` / `overwrite` / `merge`
|
||||
- Cancellation in every phase, and the guarantee that a failure publishes
|
||||
nothing and cleans up its staging tree
|
||||
- **Encrypted archives** — each real fixture is run four ways: no password,
|
||||
empty password and wrong password all yield `ZIPX_ERR_PASSWORD`, correct
|
||||
password succeeds with a byte-level content check. Two further cases prove the
|
||||
limits still apply once a password has been handed over. Fixtures:
|
||||
`enc-zipcrypto.zip`, `enc-aes256.zip`, `enc-aes256-store.zip` (ZIP),
|
||||
`enc-v6.rar` (RAR), `aeshe.7z` (7z, encrypted header)
|
||||
- **Large-file profile** — `medium_bomb.zip` (ratio ≈ 238) is rejected under the
|
||||
default caps and accepted under the large ones
|
||||
- **Format dispatch** — a renamed ZIP and a junk blob are both refused
|
||||
|
||||
Three frontend/served-page harnesses live in `.build/` — a scratch directory
|
||||
that is gitignored apart from an explicit whitelist. These harnesses, together
|
||||
with the document render checks, are on that whitelist, so they are tracked in
|
||||
the repository and survive a scratch cleanup:
|
||||
|
||||
| Script | Covers | Checks |
|
||||
|---|---|---|
|
||||
| `ui_retry_test.mjs` | the password retry flow in the real `assets/main.js` against a stubbed DOM: remembered request, retry cap, give-up paths, and the regression case for a non-ASCII folder | 40 |
|
||||
| `ui_upload_menu_test.mjs` | the markup side: every `data-i18n` key exists in both languages, all 117 `t("…")` keys used in `main.js` are translated, the upload menu is wired to the right handlers, the classes it uses are styled, the row-highlight rules keep their panel scope, and the extract button is never hidden — only disabled | 40 |
|
||||
| `preview_check.mjs` | the real page against a fixture API in headless Chromium: menu hidden at rest / opens / focus / reaches the file input / closes, footer layout, and the pinned toolbar wrap thresholds | 12 assertions |
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
.
|
||||
├── Makefile # PS5 + Linux builds (VERSION_TAG v1.8.2)
|
||||
├── .build/ # build scripts + validation harnesses (rest of the dir is gitignored)
|
||||
├── Makefile # PS5 + Linux builds (VERSION_TAG v1.9.3M)
|
||||
├── install-libmicrohttpd.sh # one-shot dependency installer
|
||||
├── gen-asset-module.py # embeds assets/* as gzip-compressed C arrays
|
||||
├── assets/ # HTML / CSS / JS / icons / param.json
|
||||
├── src/ # C payload sources
|
||||
│ ├── main.c websrv.c filemgr.c # entry, HTTP frontend, task model
|
||||
│ ├── upload.c download.c # stream handlers
|
||||
│ ├── extract.c # /api/extract dispatcher (ZIP + RAR)
|
||||
│ ├── zip_extract.{c,h} # ZIP engine (v1.7)
|
||||
│ ├── rar_extract.{c,h} # RAR engine (v1.8, dmc_unrar backend)
|
||||
│ └── app_installer.c # PS5 Media launcher installer
|
||||
├── third_party/ # vendored: zlib, minizip-ng, dmc_unrar
|
||||
│ └── unrar/
|
||||
│ ├── dmc_unrar.c # GPL-2.0-or-later, verbatim upstream
|
||||
│ └── dmc_unrar_api.h # project-authored facade header
|
||||
│ ├── upload.c download.c text.c # stream and in-place edit handlers
|
||||
│ ├── extract.c # /api/extract dispatcher (ZIP + RAR + 7z)
|
||||
│ ├── zip_extract.{c,h} zipx_common.c # ZIP engine (minizip-ng backend)
|
||||
│ ├── zipx_volume.c zipx_volstream.c # ZIP volume detection + concatenating stream
|
||||
│ ├── rar_extract.{c,h} # RAR engine (rarlab UnRAR 7.20.1 backend)
|
||||
│ ├── sevenz_extract.{c,h} sevenz_chain.{c,h} # 7z engine, self-parsed codec chain
|
||||
│ ├── sevenz_header.{c,h} # 7z header reader / -mhe=on decryption
|
||||
│ ├── sevenz_mt.c sevenz_volstream.c # multi-threaded LZMA2 + .7z.001 volumes
|
||||
│ ├── app_installer.c pkg_installer.c pkg_info.c # PS5 PKG preview / install
|
||||
│ └── demangle_stub.c cpu_support_stub.c # size / portability stubs
|
||||
├── third_party/ # vendored libraries
|
||||
│ ├── unrar7/ # rarlab UnRAR 7.20.1 — RAR engine
|
||||
│ ├── minizip-ng/ # 4.2.2, trimmed to the read path
|
||||
│ ├── 7z/ # LZMA SDK 26.03 decode subset
|
||||
│ └── zlib/ # minizip's compression backend
|
||||
├── tests/ # POSIX/host test suite
|
||||
│ ├── test_zip_extract.c
|
||||
│ ├── test_rar_extract.c # 14 RAR negative-path checks (v1.8)
|
||||
│ ├── make_fixtures.py # regenerate test fixtures
|
||||
│ ├── run-tests.sh # one-shot runner (now runs ZIP + RAR suites)
|
||||
│ ├── compat/ # tiny Win32/MSYS shims
|
||||
│ └── fixtures/ # generated test ZIPs (and a couple of stub .rar blobs)
|
||||
│ ├── test_zip_extract.c test_rar_extract.c test_sevenz_extract.c
|
||||
│ ├── sevenz_chain_e2e.c sevenz_e2e.c bigfile_e2e.c
|
||||
│ ├── make_fixtures.py make_sevenz_fixtures.py make_split_fixtures.py
|
||||
│ ├── run-tests.sh # one-shot runner (ZIP + RAR suites)
|
||||
│ ├── run-sevenz-tests.sh # 7z suite
|
||||
│ ├── bench_driver.py bench_formats.py # throughput benchmarks
|
||||
│ ├── compat/ # tiny Win32/MSYS shims
|
||||
│ └── fixtures/ fixtures-7z/ fixtures-real/
|
||||
├── docs/
|
||||
│ ├── HANDOVER.md # engineering handover / dev playbook (also §14 v1.8 close-out)
|
||||
│ ├── USER-GUIDE-zh-CN.md # beginner's walkthrough (Chinese)
|
||||
│ ├── DEVICE-TEST-v1.9.3M.md # the acceptance checklist run before release
|
||||
│ ├── SIZE-OPTIMIZATION.md # ELF size analysis + per-symbol ledger
|
||||
│ ├── EXTRACTION-PERF.md # decompression benchmarks
|
||||
│ ├── REAL-CONSOLE-PROFILE.md # measured on-device throughput
|
||||
│ ├── UPSTREAM-V1.8-COMPARISON.md # this fork vs upstream's helper approach
|
||||
│ ├── REWRITE-FEASIBILITY.md # engine-extraction study
|
||||
│ ├── UPGRADE-v1.7-zip-large-file-profile.md
|
||||
│ ├── UPGRADE-v1.8-rar-support.md
|
||||
│ └── screenshots/ # README screenshot images
|
||||
├── THIRD_PARTY_NOTICES # bundled-library credits (incl. dmc_unrar section)
|
||||
│ └── screenshots/ # README screenshot images
|
||||
├── CHANGELOG.md # per-release history
|
||||
├── THIRD_PARTY_NOTICES # per-library licence summary
|
||||
├── HANDOVER.md # current engineering handover
|
||||
├── LICENSE # GPLv3+
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## How this fork differs from upstream
|
||||
|
||||
This project is a fork of
|
||||
[owendswang/ps5-web-file-manager](https://github.com/owendswang/ps5-web-file-manager).
|
||||
The web UI, the task model and the PS5 packaging all originate upstream, and the
|
||||
upstream author's release under GPL-3.0 is what makes this derivative work
|
||||
possible. From v1.8 onward, upstream outsources extraction to a **separate
|
||||
helper process** — a full 7-Zip shipped as `wfm-7zip-helper.elf`, which the user
|
||||
must install at `/data/wfm/` themselves. This fork takes the opposite route: the
|
||||
decoders are vendored *into* the payload.
|
||||
|
||||
| | Upstream | This fork |
|
||||
|---|---|---|
|
||||
| Extraction architecture | external `wfm-7zip-helper.elf` (1,017,616 B, distributed separately, fixed path `/data/wfm/`), driven over a Unix-socket IPC protocol | the engines live **inside the payload**; there is no second file and no IPC |
|
||||
| Deployment | two files totalling 1,363,048 B; a missing or misplaced helper means extraction is dead (`archive_helper_not_running`) | one ELF of 903,448 B, no external dependency — **33.7% smaller**, and upstream's helper alone is larger than this entire payload |
|
||||
| Formats | ~30 extensions (`.tar`, `.gz`, `.xz`, `.bz2`, `.zst`, `.cab`, `.arj`, `.lzh`, `.cpio`, …) | `.zip` / `.rar` / `.7z` and their volume forms — three, each complete |
|
||||
| Zip-bomb and ratio defence | none | entry count, total size, per-file size, compression ratio, and a 1 GiB exemption so small files are not falsely flagged |
|
||||
| Disk-space pre-check | none | `statvfs` against the expanded total before staging |
|
||||
| Path-traversal defence | delegated to 7-Zip | implemented here, with a dedicated test group |
|
||||
| Failure residue | can leave a half-extracted directory | staging directory + rename; a failure or cancel cleans up and publishes nothing |
|
||||
| Password prompts | the helper's IPC protocol carries a `PASSWORD_REQUIRED` message | prompt + retry (capped at three attempts) reported as `extract_password`; 7z asks up front |
|
||||
| Task survivability across a payload restart | ✅ the helper is a separate process, so a job survives | ❌ a restart loses the running task |
|
||||
| Memory isolation | ✅ extraction runs in its own process | ❌ shares the address space (the LZMA2 dictionary is capped instead) |
|
||||
| Version identity | plain `vX.Y.Z` | `vX.Y.ZM` — the trailing `M` marks a fork build |
|
||||
|
||||
The measurement and the reasoning behind this trade-off are in
|
||||
[`docs/UPSTREAM-V1.8-COMPARISON.md`](docs/UPSTREAM-V1.8-COMPARISON.md). In one
|
||||
line: upstream wins on format breadth and process architecture, this fork wins
|
||||
on safety, deployment and error quality. The format gap is incremental work
|
||||
inside the existing architecture, not a reason to go back.
|
||||
|
||||
## Notes
|
||||
|
||||
- Copy, move, delete, upload and download run as single background tasks. While one task is running, other file operations are rejected.
|
||||
- Copy, move, delete, upload and download run as single background tasks. While
|
||||
one task is running, other file operations are rejected.
|
||||
- Delete is recursive and permanent. There is no recycle bin.
|
||||
- Copy/move tasks can be canceled. A partially copied single file is removed, but partially copied folders are left in place to avoid deleting pre-existing files when merging into an existing target folder.
|
||||
- Upload tasks can be canceled. A partially uploaded temporary file is removed when possible.
|
||||
- Downloading a folder or multiple selected items produces a tar stream. The tar archive is generated by the payload and is not written to PS5 storage first.
|
||||
- The UI can recover the active task display if the browser is closed and reopened while the payload process is still running.
|
||||
- Text editing is limited to the curated extension list above. Non-UTF-8 and oversized files are rejected.
|
||||
- File names are transmitted as UTF-8 through the web API. The payload also preserves legacy byte-oriented names returned by mounted filesystems so mixed USB filename encodings still display and operate correctly.
|
||||
- Copy/move tasks can be cancelled. A partially copied single file is removed;
|
||||
partially copied **folders** are left in place, to avoid deleting pre-existing
|
||||
files when merging into an existing target folder.
|
||||
- Upload tasks can be cancelled; a partially uploaded temporary file is removed
|
||||
when possible.
|
||||
- Downloading a folder or a multi-selection produces a tar stream generated on
|
||||
the fly — it is not written to console storage first.
|
||||
- The UI recovers the active-task display if the browser is closed and reopened
|
||||
while the payload is still running.
|
||||
- Text editing is limited to the extension list above; non-UTF-8 and oversized
|
||||
files are refused.
|
||||
- **Filename encoding:** names travel over the web API as UTF-8, while a mounted
|
||||
filesystem may hand back legacy byte sequences (a GBK USB stick, for example).
|
||||
Rather than losing those bytes, the API maps every byte ≥ `0x80` to `\u00XX`
|
||||
and restores it on the way back, and the frontend decodes to GBK/gb18030 for
|
||||
display. The practical consequence is that the same directory has two
|
||||
different string representations — the page's and the server's — which is why
|
||||
nothing in the frontend may use a path as a cross-request key.
|
||||
|
||||
## FAQ
|
||||
|
||||
- **This is a homebrew app and should not intentionally modify system processes or kernel memory.** If you hit a kernel panic, make sure you are using a recent jailbreak method and ELF loader, or revert to the stable method you normally use.
|
||||
- **P2JB users** — if this payload triggers a kernel panic, avoid using it on that setup. Stability matters more than convenience when each retry is expensive.
|
||||
- **The preparing stage can take a while** when a folder contains many files — it sums folder size and checks free space, which helps avoid starting a copy / move / upload / download that cannot finish safely.
|
||||
- **`err_extract_entry_too_large`** — default archive caps are 512 GiB per
|
||||
entry / 500:1 ratio (covers a typical 3A-game archive with one ~300 GiB
|
||||
uncompressed file). If you exceed the default, confirm the large-file
|
||||
prompt (appears for archives > 480 GiB on disk), split the archive, or
|
||||
pass `large=1` directly to the API.
|
||||
- **`err_extract_unsupported`** — the archive uses a feature the engine
|
||||
cannot handle: encrypted ZIP, encrypted RAR, multi-volume RAR
|
||||
(`.part02+.rar`), very-old RAR 1.4, RAR symlinks / FIFOs, or a file
|
||||
that is neither `.zip` nor `.rar`. For RAR specifically the message
|
||||
lists the failure cause and points the user back to a PC extractor.
|
||||
- **This is homebrew software and does not intentionally modify system processes
|
||||
or kernel memory.** If you hit a kernel panic, make sure you are on a recent
|
||||
jailbreak method and ELF loader, or go back to the setup you normally use.
|
||||
- **P2JB users** — if this payload triggers a kernel panic on that setup, do not
|
||||
use it there. Stability matters more than convenience when every retry is
|
||||
expensive.
|
||||
- **The "preparing" stage can take a while** on a folder with many files — it
|
||||
sums the folder size and checks free space, which is what stops a copy, move,
|
||||
upload or download that could not finish safely from starting at all.
|
||||
- **`err_extract_unsupported`** — the archive is one this build cannot read: a
|
||||
file that is not `.zip` / `.rar` / `.7z`, a ZIP entry using a compression
|
||||
method other than stored/deflated, a 7z folder with an unsupported coder, a
|
||||
split set whose naming is not recognised (a RAR set named `x.rar.001` must be
|
||||
renamed to `x.part1.rar`, `x.part2.rar`, …), or a RAR older than 1.4.
|
||||
**Encrypted and multi-volume archives are not in this category** — both are
|
||||
supported. The backend's own sentence is appended in parentheses and names the
|
||||
actual cause.
|
||||
- **`err_extract_entry_too_large`** — the archive exceeds the default caps
|
||||
(512 GiB per entry / 500:1 ratio). Confirm the large-file prompt (which
|
||||
appears for archives over 480 GiB on disk), split the archive, or pass
|
||||
`large=1` to the API directly.
|
||||
- **`err_extract_dict_too_large`** — the RAR archive declares a compression
|
||||
dictionary larger than this build supports (4096 MiB). Recompress it on a PC
|
||||
with `-md` at or below 4 GiB, or extract it there.
|
||||
- **`err_extract_password`** — the archive is encrypted and the password was
|
||||
missing or wrong. That includes a 7z with an encrypted header (`-mhe=on`),
|
||||
where the file names and entry sizes live inside the header, so nothing can be
|
||||
listed until it decrypts.
|
||||
|
||||
## Version history
|
||||
|
||||
Per-release detail — artefacts, digests, section-size deltas, test counts — lives
|
||||
in [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
|
||||
| Release | Date | Headline |
|
||||
|---|---|---|
|
||||
| `v1.9.3M` | 2026-09-24 | Encrypted archives end to end (ZIP ZipCrypto + WinZip AES, RAR `-p`/`-hp`, 7z 7zAES incl. `-mhe=on`), dictionary reporting, and a UI pass (upload menu, drag hint, always-visible extract button) |
|
||||
| `v1.9.2` | 2026-09-05 | Version-string-only re-release; tag re-cut so tag = source = binary |
|
||||
| `v1.9.1` | 2026-09-05 | 7z engine, volume sets, 7zAES, and a −15.8 % size / throughput pass |
|
||||
| `v1.9` | 2026-09-05 | RAR engine replaced with rarlab UnRAR 7.20.1 (RAR5 "v6", multi-volume) |
|
||||
| `v1.8.3` | 2026-09-05 | "Upload and extract" accepts `.rar` |
|
||||
| `v1.8.2` | 2026-09-05 | Per-entry cap raised for 3A single-file archives; two PS5-only build fixes |
|
||||
| `v1.8.1` | 2026-09-05 | Default ZIP caps relaxed for system-backup archives |
|
||||
| `v1.8` | 2026-09-05 | First RAR support (dmc_unrar), shared extraction protocol |
|
||||
| `v1.7` | 2026-09-04 | ZIP large-file profile (`large=1`) |
|
||||
|
||||
## Credits
|
||||
|
||||
This project was built with reference to these projects:
|
||||
This project is a **fork of [owendswang/ps5-web-file-manager](https://github.com/owendswang/ps5-web-file-manager)** (GPL-3.0). The web UI,
|
||||
the task model and the PS5 packaging all originate there, and the upstream
|
||||
author's release under GPL-3.0 is what makes this derivative work possible.
|
||||
|
||||
**Telling a fork build from an upstream one:** since v1.9.3 the version string
|
||||
carries an `M` suffix (`vX.Y.ZM`) — *M* for *Modified*. Upstream owendswang
|
||||
releases are plain `vX.Y.Z`. So `v1.9.2` is upstream/fork-shared numbering while
|
||||
`v1.9.3M` can only have come from this repository; the same letter appears in the
|
||||
ELF file name, the PS5 start-up notification, `/api/version` and the web UI
|
||||
footer. Releases before v1.9.3M predate the convention and keep their plain
|
||||
numbers.
|
||||
|
||||
Built with reference to these projects:
|
||||
|
||||
- **[ps5-payload-dev/websrv](https://github.com/ps5-payload-dev/websrv):** HTTP server structure, static asset embedding ideas, PS5 browser/websrv behaviour and PKG install function. License: GPLv3+.
|
||||
- **[ps5-payload-dev/ftpsrv](https://github.com/ps5-payload-dev/ftpsrv):** PS5 payload conventions, home-screen launcher/install flow reference, process handling style and startup installation reference. License: GPLv3+.
|
||||
@@ -444,10 +582,13 @@ This project was built with reference to these projects:
|
||||
- **[libmicrohttpd](https://ftp.gnu.org/gnu/libmicrohttpd/):** Used as the embedded HTTP server library. Licensed by GNU under the LGPL; this payload links it as the SDK-provided static library.
|
||||
- **[ps5-payload-dev/sdk](https://github.com/ps5-payload-dev/sdk):** Payload building foundation. License: GPLv3+.
|
||||
- **[etaHEN](https://github.com/etaHEN/etaHEN):** ShellUI URI navigation used to return to the PS5 home screen before exit. License: GPLv3.
|
||||
- **[ezremote](https://github.com/cy33hc/ps5-ezremote-client):** Preview PKG info. License: GPLv2.
|
||||
- **[ezremote](https://github.com/cy33hc/ps5-ezremote-client):** cited for the PKG-preview feature. License: **GPL-2.0-only** — its source files carry no "or later" notice, so it is **not** combinable with this GPL-3.0 codebase. **No code was taken from it:** `src/pkg_info.c` is an independent C99 implementation (it also reads the `.pkg` entry table and `param.json` fields, for which ezremote has no counterpart, and it uses the hand-written tokenizer in `src/json_util.c` rather than json-c). See `docs/REWRITE-FEASIBILITY.md` §2.2.
|
||||
- **[zlib-ng/minizip-ng](https://github.com/zlib-ng/minizip-ng):** ZIP reader used by the `/api/extract` endpoint. Vendored under `third_party/minizip-ng/`. License: zlib.
|
||||
- **[zlib](https://www.zlib.net/):** Compression backend for minizip-ng. Vendored under `third_party/zlib/`. License: zlib.
|
||||
- **[DrMcCoy/dmc_unrar](https://github.com/DrMcCoy/dmc_unrar):** RAR reader used by the `/api/extract` endpoint. Vendored under `third_party/unrar/` as a single-file drop-in (`dmc_unrar.c`); the project-authored facade `dmc_unrar_api.h` carries the project's own licence. License: GPL-2.0-or-later — see `third_party/unrar/COPYING`.
|
||||
- **[rarlab UnRAR](https://www.rarlab.com/rar_add.htm)** — RAR reader used by the `/api/extract` endpoint since v1.9. Vendored under `third_party/unrar7/` (version 7.20.1, the RARDLL source set). License: **UnRAR freeware license** — see `third_party/unrar7/license.txt`. Note this is a restricted licence rather than a FLOSS one: it permits using the source to handle RAR archives but forbids using it to build a RAR-compatible compressor.
|
||||
- **[opello/unrar](https://github.com/opello/unrar)** — the mirror the vendored rarlab sources were fetched from (commit `97e1780`).
|
||||
- **[LZMA SDK](https://www.7-zip.org/sdk.html)** (7-Zip / Igor Pavlov) — 7z decoder used by the `/api/extract` endpoint since v1.9.1, vendored as a decode subset under `third_party/7z/`. License: public domain.
|
||||
- **[DrMcCoy/dmc_unrar](https://github.com/DrMcCoy/dmc_unrar)** — RAR engine shipped in v1.8 only, superseded in v1.9 by rarlab UnRAR (it could not decode RAR5 "v6" archives or multi-volume sets). Removed from the tree; its licence was GPL-2.0-or-later.
|
||||
|
||||
## License
|
||||
|
||||
@@ -460,12 +601,14 @@ in addition to this project's GPL license. The vendored `zlib` and
|
||||
`minizip-ng` sources are distributed under the zlib license; retain the
|
||||
copyright notices in `third_party/zlib/LICENSE` and
|
||||
`third_party/minizip-ng/LICENSE` when redistributing binaries built
|
||||
with this feature. The vendored `dmc_unrar` (RAR engine) is distributed
|
||||
under the GPL-2.0-or-later; retain the copyright notice in
|
||||
`third_party/unrar/COPYING` and ship the corresponding sources when
|
||||
redistributing binaries built with v1.8 or later (the `web-file-mgr.elf`
|
||||
binary is already GPLv3+, so the additional source-disclosure
|
||||
requirement is the only practical effect).
|
||||
with this feature.
|
||||
|
||||
The vendored `third_party/unrar7/` sources (rarlab UnRAR — the RAR engine
|
||||
behind `src/rar_extract.c`) are **not** GPL: they ship under the UnRAR
|
||||
freeware license (see `third_party/unrar7/license.txt`), which forbids
|
||||
using them to develop a RAR-compatible compressor. Keep that notice and
|
||||
that restriction intact when redistributing. `THIRD_PARTY_NOTICES` carries
|
||||
the full per-library summary.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
+367
-269
@@ -1,64 +1,46 @@
|
||||
<div align="right">
|
||||
<a href="README.md">English</a> · <a href="README.zh-CN.md">简体中文</a>
|
||||
</div>
|
||||
|
||||
# PS5 网页文件管理器(PS5 Web File Manager)
|
||||
|
||||
> 面向已越狱 PS5 主机的自制 HTTP 文件管理器。通过同一局域网内的任意浏览器(包括 PS5 自带浏览器)即可浏览、编辑、上传、下载并解压 ZIP / RAR / 7z 压缩包——单个自包含 ELF 载荷,无外部服务、无遥测上报。
|
||||
<p align="center">
|
||||
<a href="https://github.com/LisherSong/ps5-web-file-manager/releases/latest"><img src="https://img.shields.io/github/v/release/LisherSong/ps5-web-file-manager" alt="最新发布版"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/LisherSong/ps5-web-file-manager?color=blue" alt="许可证"></a>
|
||||
<img src="https://img.shields.io/badge/target-x86__64--sie--ps5-blue" alt="目标平台:x86_64-sie-ps5">
|
||||
<a href="https://github.com/LisherSong/ps5-web-file-manager/releases"><img src="https://img.shields.io/github/downloads/LisherSong/ps5-web-file-manager/total?color=green" alt="总下载量"></a>
|
||||
</p>
|
||||
|
||||
**版本:** v1.9.1 · **标题 ID:** `FMGR88888` · **许可证:** GPLv3+ · **目标平台:** `x86_64-sie-ps5`
|
||||
<p align="center">
|
||||
<a href="https://github.com/LisherSong/ps5-web-file-manager/releases/latest"><img src="https://img.shields.io/badge/%E4%B8%8B%E8%BD%BD-ELF%20%E8%BD%BD%E8%8D%B7-2ea44f?style=for-the-badge" alt="下载 ELF 载荷"></a>
|
||||
<a href="docs/USER-GUIDE-zh-CN.md"><img src="https://img.shields.io/badge/%E6%96%B0%E6%89%8B%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E-%E4%B8%AD%E6%96%87-2563eb?style=for-the-badge" alt="新手使用说明"></a>
|
||||
</p>
|
||||
|
||||
> 面向已越狱 PS5 主机的自制 HTTP 文件管理器。通过同一局域网内的任意浏览器即可浏览、编辑、上传、
|
||||
> 下载并解压压缩包——单个自包含 ELF 载荷,不需要任何外部 helper 文件,不上报任何遥测。
|
||||
|
||||
**版本:** v1.9.3M · **标题 ID:** `FMGR88888` · **许可证:** GPLv3+ · **目标平台:** `x86_64-sie-ps5`
|
||||
|
||||
**下载:** [最新发布版](https://github.com/LisherSong/ps5-web-file-manager/releases/latest) · **第一次用?** [《新手使用说明》](docs/USER-GUIDE-zh-CN.md)
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
## 这是什么
|
||||
|
||||
一个在已越狱 PS5 上运行的 HTTP 文件管理器载荷。从局域网内任意浏览器(含 PS5 浏览器本身)打开 `http://<PS5_IP>:8888/`,即可管理外接 USB 存储与用户分区的文件。设计初衷是安全地把游戏 dump 文件夹从 USB 拷贝到内置存储,但它同时也支持常规文件管理、原地文本编辑、PKG 预览/安装、图片预览,以及内置防 zip 炸弹保护的解压功能。
|
||||
一个单文件载荷 ELF,在已越狱的 PS5 上跑起一个 HTTP 文件管理器。把它发给主机的 ELF 加载器,主机会在
|
||||
`8888` 端口启动 HTTP 服务(该端口被占用时自动往上找下一个空闲端口)。在局域网内任意浏览器(包括
|
||||
PS5 自带浏览器)打开 `http://<PS5_IP>:8888/`,即可管理外接 USB 存储与用户分区上的文件。
|
||||
|
||||
同一套源码树可构建出供开发用的 Linux 二进制,以及供部署的 PS5 载荷 ELF——见下方 `make linux`。
|
||||
它只为把一件事做安全、做快而写:**把游戏 dump 文件夹从 USB 拷进内置存储。** 其余能力——浏览、排序、
|
||||
改权限、原地编辑文本、预览图片、安装 PKG、多选复制/移动/删除、上传与下载——都是为了让这件事在
|
||||
实际操作中行得通。在这之上,本仓又加了 **ZIP / RAR / 7z 的原生解压**,并配上了上游那套 helper 路线
|
||||
所没有的安全护栏(防压缩炸弹、防路径穿越、防写满磁盘)。
|
||||
|
||||
## v1.9.1 新增内容
|
||||
同一套源码树也能编出 Linux 二进制,因此整个前端界面不依赖主机、也不需要 PS5 SDK 就能开发:
|
||||
|
||||
- **7z 解压引擎**(`src/sevenz_extract.{c,h}`):自研解码子集 + 拉式 codec 链(`src/sevenz_chain.c`,覆盖 LZMA2 / BCJ2 等),由 `src/extract.c` 按扩展名分派,与 ZIP / RAR 共用同一套三阶段模型与限额档位。`.7z` 文件在文件列表中同样带「解压」按钮。
|
||||
- **7zAES 内容解密**(AES-256-CBC):引擎层可解密带密码的 7z 内容;密码输入 UI / API 通道尚未接入,目前加密归档仍被拒绝。
|
||||
- **7z 分卷**:`.7z.001` / `.z01` 等链式分卷由 `src/sevenz_volstream.c` 按名拼接,打开首个分卷即可。
|
||||
- **性能三项**(纯解码提速,不影响功能面):
|
||||
- SDK 汇编 LZMA 解码器(`Asm/x86/LzmaDecOpt.asm` + jwasm,无 jwasm 自动回退纯 C)≈ 1.26×。
|
||||
- 纯 LZMA2 文件夹多线程解码(`src/sevenz_mt.c` + `Lzma2DecMt`,8 线程)≈ 1.37×。
|
||||
- 移除 ZIP 逐条目 fsync,减少 staging 重命名前的写盘开销。
|
||||
- **唯一缺口**:7z `-mhe=on` 加密头(独立单元,读取需自研头解析器),其余 7z 特性均已支持。
|
||||
|
||||
## v1.9 新增内容
|
||||
|
||||
- **RAR 引擎替换为官方 rarlab UnRAR 7.20.1**(`third_party/unrar7/`,取代 dmc_unrar)。这正是让 RAR 解压在真实文件上可用的一步:dmc_unrar 无法解码 **WinRAR 6.x/7.x** 写出的归档(RAR5「v6」压缩),也不支持多卷;两者现在都能工作。
|
||||
- **RAR5「v6」归档可解压**(v1.8 时代在 WinRAR 6/7 文件上报「归档损坏」的问题已消失)。
|
||||
- **多卷 RAR**(`.part01.rar` 链):当完整卷集与被打开的卷放在同一目录时,unrar 按文件名拼接各部分。
|
||||
- 引擎可解密加密 RAR(`RARSetPassword`)——密码 UI / API 接线仍未完成,加密归档暂时被拒绝。
|
||||
- 主机测试现用真实归档(v6 / 加密 / 3 卷 fixture,提交于 `tests/fixtures-real/`):**70 ZIP + 24 RAR = 94 项检查**。
|
||||
|
||||
## v1.8 新增内容
|
||||
|
||||
- **单卷 RAR 解压**,基于内置的 FLOSS 库 [`dmc_unrar`](https://github.com/DrMcCoy/dmc_unrar)(GPL-2.0-or-later)。支持 RAR 1.5、2.x、3.x、4.x、5.x 归档。`.rar` 文件出现在文件列表中且「解压」按钮可用;`.part02+.rar` 子卷上的按钮置灰,提示「请选择主卷」——v1.8 无法拼接多卷 RAR(见下方 [RAR 解压](#rar-解压) 章节)。
|
||||
- 新引擎 `src/rar_extract.c` 与既有 `src/zip_extract.c` 之间**共享解压协议**:相同的 `zipx_status_t` 状态码、相同的 `zipx_limits_t` 档位(默认 / `large=1`)、相同的三阶段模型(`scan → extract → publish → cleanup`)、相同的 staging 目录布局、相同的冲突策略、相同的错误映射到任务 UI。`src/extract.c` 中的分派器只是一个微小的 `ends_with_ci(…)` 判断。
|
||||
- **14 个新增主机端 C 测试**(`tests/test_rar_extract.c`)接入现有 `tests/run-tests.sh`。覆盖:格式分派、每个影响 RAR 用户的 `DMC_UNRAR_*` 错误码翻译、限额档位交接。主机检查总数:**69 ZIP + 14 RAR = 83**。
|
||||
- **文档**:[`CHANGELOG.md`](./CHANGELOG.md)、[`docs/UPGRADE-v1.8-rar-support.md`](./docs/UPGRADE-v1.8-rar-support.md),以及 `third_party/unrar/VENDORED.md` 中的 vendoring 决策树。
|
||||
|
||||
## v1.8.1 新增内容
|
||||
|
||||
- **放宽默认 ZIP 限额**(配合 v1.7 的大档案档位)。v1.7 默认单条目上限为 64 GiB,对典型 PS5 系统备份 ZIP(200–300 GiB)过于激进。v1.8.1 将默认档位提高到 **总量 1 TiB / 单条目 256 GiB / 500:1 比率**,保留 `large=1` 选项为 2 TiB / 1 TiB / 1000:1。前端阈值从 60 GiB 提升到 240 GiB,使常见系统备份归档不再触发确认提示。
|
||||
- RAR 解压继承这些新默认值(`rar_extract.c` 直接从引擎透传 `c->limits`,无需改引擎)。
|
||||
- 理由:真正的防 zip 炸弹防线是 `check_space()`(staging 前基于 statvfs 的真实磁盘空间检查)+ `max_ratio`(声明的压缩比上限)。尺寸上限只是 UX 护栏,而非安全边界。
|
||||
|
||||
## v1.8.2 新增内容
|
||||
|
||||
- **再次放宽默认 ZIP 限额**,针对 3A 游戏单文件场景。v1.8.1 仍会静默拒绝归档内单个约 300 GiB 的未压缩文件(默认扫描在请求到达前端确认提示之前就返回 `ZIPX_ERR_LIMIT_FILE_SIZE`)。v1.8.2 将默认档位提高到 **总量 2 TiB / 单条目 512 GiB / 500:1 比率**,`large=1` 选件提到 4 TiB / 1 TiB / 1000:1。前端阈值从 240 GiB 提升到 480 GiB。
|
||||
- **两个 PS5 专属构建修复**,在交叉编译 PS5 目标时发现。主机端测试套件(`tests/run-tests.sh`)曾静默接受二者,因为它链接相同源码但使用 gcc 而非 clang 18,且包含路径不同:
|
||||
- `Makefile` CFLAGS:加入 `-Ithird_party/unrar`,使 `src/rar_extract.c` 能找到项目自有的 `dmc_unrar_api.h` 门面头文件。
|
||||
- `src/extract.c`:把 `extract_progress()` 定义移到 `extract_dispatch()` 之前,避免被 `-Werror=implicit-function-declaration` 标记(PS5 SDK 的 clang 18 比测试用的主机 gcc 更严格)。
|
||||
- v1.8.2 发布产物:`web-file-mgr.elf` —— 509 704 字节,sha256 `1b2c3d68b35e32737105f17d14a80a3c159ceca0cabd274ee168cbcd81906f65`,ELF 64 位小端,e_machine `0x003e`(x86_64-sie-ps5)。
|
||||
- 测试:**84 项主机端检查**(70 ZIP + 14 RAR),0 失败。PS5 交叉编译端到端成功。
|
||||
|
||||
## v1.7 新增内容
|
||||
|
||||
- **ZIP 大文件档位**(通过在 `/api/extract` 传入新的 `large=1` 参数选配启用):放宽的限额为 **总量 2 TiB** / **单条目 1 TiB** / **1000:1 压缩比**。当磁盘上归档大于 **60 GiB** 时前端会提示确认;仅当用户明确同意时服务器才启用该档位。
|
||||
- **更严格的默认 ZIP 档位**保持安全:**总量 1 TiB** / **单条目 256 GiB** / **500:1 比率**。一个 4 MiB 压缩包解压到 800 GiB 仍会在打开任何输出文件之前被拒绝。
|
||||
- **69 项主机端 C 测试**(`tests/run-tests.sh`)现已覆盖路径穿越、ZIP64、加密拒绝、压缩比、冲突策略与新增大文件档位(`tests/test_zip_extract.c`)。
|
||||
- 更早的细化——见 v1.6 以来的 `git log`。
|
||||
```sh
|
||||
make linux && ./web-file-mgr-linux-v1.9.3M
|
||||
```
|
||||
|
||||
## 截图
|
||||
|
||||
@@ -73,36 +55,176 @@
|
||||
|
||||
## 功能
|
||||
|
||||
- **浏览** —— 列出文件与文件夹;按名称、类型、大小、修改时间或权限排序。上次排序方式持久化在 `localStorage`。
|
||||
- **权限** —— 用复选框切换读/写/执行,或粘贴经过校验的四位八进制模式。
|
||||
- **操作** —— 复制、移动、删除(递归、无回收站)、重命名、创建文件与文件夹。
|
||||
- **编辑器** —— 针对 ≤ 1 MiB 的文件,跨精选扩展名列表的原地 UTF-8 文本编辑器:`.txt .json .xml .ini .cfg .conf .md .log .lua .js .css .html .htm .c .h .cpp .hpp .sh .csv .yaml .yml .shn`。
|
||||
**文件与目录**
|
||||
|
||||
- **浏览与排序** —— 列出文件与文件夹;按名称、类型、大小、修改时间或权限排序。上次选的排序方式
|
||||
持久化在 `localStorage` 里。
|
||||
- **权限** —— 在权限列用复选框切换读 / 写 / 执行,或粘贴一个经过校验的四位八进制模式。
|
||||
- **复制与移动** —— 两步式「剪贴板」流程:先选中要处理的项,再浏览到目标目录粘贴(复制)或移入
|
||||
(移动)。覆盖文件与合并文件夹时都会弹出冲突确认。
|
||||
- **重命名** —— 就地重命名选中的单个条目。
|
||||
- **删除** —— 递归且永久,没有回收站。
|
||||
- **新建** —— 新建文件夹与新建空文本文件。
|
||||
- **多选** —— 一次性复制、移动、删除或打包下载多个项目。
|
||||
- **上传** —— 从局域网内任意设备上传单文件或文件夹树(在 PS5 浏览器中隐藏)。原子化的临时文件 + 重命名。
|
||||
- **下载** —— 单文件以原始字节下载,或文件夹/多选以流式 `.tar` 下载。在 PS5 浏览器中隐藏。
|
||||
- **任务** —— 全屏覆盖层,带延迟显示、实时进度、吞吐率、ETA、取消,以及浏览器中途关闭重开后的恢复能力。
|
||||
- **归档解压** —— ZIP(加密拒绝)、RAR(v1.9:RAR4 + RAR5 含 WinRAR 6/7「v6」、多卷;加密仍拒绝,待密码 UI 接入)、7z(v1.9.1:LZMA2 / BCJ2 / 分卷 / 内容加密;`-mhe=on` 加密头除外)。详见下方 [ZIP 解压](#zip-解压)、[RAR 解压](#rar-解压)、[7z 解压](#7z-解压)。
|
||||
- **PKG** —— 安装并预览 `.pkg` 文件。
|
||||
- **图片** —— 预览 `.png .jpg .jpeg .gif .bmp .webp`。
|
||||
- **本地化** —— 英文 + 简体中文,根据 `navigator.languages` 自动选择。
|
||||
- **复制/移动后的文件会被 chmod 成 `0777`**(前提是文件系统支持 Unix 权限)。FAT/exFAT 类文件系统
|
||||
可能忽略 chmod——那是文件系统自己的答复,不是出错。
|
||||
|
||||
**内容**
|
||||
|
||||
- **文本编辑器** —— 对 ≤ 1 MiB 的文件做原地 UTF-8 编辑,覆盖一份精选扩展名列表:`.txt .json .xml
|
||||
.ini .cfg .conf .md .log .lua .js .css .html .htm .c .h .cpp .hpp .sh .csv .yaml .yml .shn`。
|
||||
非 UTF-8 与超大文件会被直接拒绝,而不是改坏。
|
||||
- **图片预览** —— `.png .jpg .jpeg .gif .bmp .webp`,直接由主机串出。
|
||||
- **PKG** —— 安装 `.pkg` 文件,并预览其元信息。
|
||||
|
||||
**数据的进出**
|
||||
|
||||
- **上传** —— 工具条上的「上传 ▾」菜单里选**单文件**或**文件夹树**;整页拖拽上传同样可用,页脚也
|
||||
写明了这一点。文件先写临时名,传输完成后重命名就位。在 PS5 浏览器中隐藏——它的用途是让你从
|
||||
另一台设备去驱动主机。
|
||||
- **下载** —— 单文件按原始字节下载;文件夹或多选则打成流式 `.tar`,不会先写进主机存储。在 PS5
|
||||
浏览器中隐藏。
|
||||
- **上传并解压** —— 选中一个压缩包并勾选「上传后解压」,上传一落盘就开始解压;若发现是加密包,
|
||||
密码框会立刻弹出。
|
||||
|
||||
**压缩包解压** —— 完整支持矩阵见 [压缩包支持](#压缩包支持)。一句话:ZIP、RAR、7z,明文或加密、
|
||||
单卷或分卷,全都走同一套尺寸 / 压缩比 / 路径穿越 / 磁盘空间保护,而且全部实现在本载荷内部——
|
||||
不需要再装第二个文件。
|
||||
|
||||
**其他**
|
||||
|
||||
- **任务浮层** —— 全屏浮层,延迟显示、实时进度、吞吐率、ETA、取消,并能在浏览器中途关闭重开、
|
||||
而载荷进程仍在运行时恢复活动任务的显示。
|
||||
- **本地化** —— 英文与简体中文,依 `navigator.languages` / `navigator.language` 自动选择
|
||||
(`zh*` → 中文,其余 → 英文)。
|
||||
- **移动端友好** —— 响应式布局,工具栏自动换行,文件列表可横向滚动。
|
||||
- **启动通知与主屏启动器** —— 通知会显示应用名、版本与实际监听端口;首次启动时载荷会在 Media
|
||||
分类安装一个「PS5 Web File Manager」快捷方式,且不覆盖已存在的启动器文件。启动器图标与浏览器
|
||||
favicon 用的是同一份内嵌 `icon0.png`,所以图标在 ELF 里只存一份。
|
||||
- **文件名不因编码混杂而丢失** —— 名字经 Web API 以 UTF-8 传输,但载荷也会保留挂载文件系统返回的
|
||||
字节序名称,因此一块装着 GBK 文件名的 U 盘仍能正确显示与操作。上游有同一套机制;本仓多出来的
|
||||
是错误提示也会解码,不至于在最需要看清条目的那一刻给出乱码名(见[备注](#备注))。
|
||||
|
||||
## 压缩包支持
|
||||
|
||||
三个引擎,由 `src/extract.c` 按扩展名分派,共用同一条三阶段流水线
|
||||
(`scan → 解压到 staging → 按 rename 发布`)与同一套限额档位、冲突策略。
|
||||
vendoring 决策与逐库许可证立场见
|
||||
[`third_party/unrar7/VENDORED.md`](third_party/unrar7/VENDORED.md) 与
|
||||
[`THIRD_PARTY_NOTICES`](THIRD_PARTY_NOTICES)。
|
||||
|
||||
| | ZIP | RAR | 7z |
|
||||
|---|---|---|---|
|
||||
| 引擎 | `src/zip_extract.{c,h}` | `src/rar_extract.{c,h}` | `src/sevenz_extract.{c,h}` |
|
||||
| 后端 | vendored minizip-ng 4.2.2 + zlib | vendored **rarlab UnRAR 7.20.1**(官方源码) | LZMA SDK 26.03 解码子集 + 自研 codec 链 |
|
||||
| stored / deflated | ✅ | 不适用 | ✅(Copy / LZMA / LZMA2 / PPMd) |
|
||||
| 64 位尺寸 | ✅ ZIP64 | ✅ | ✅ |
|
||||
| 过滤器 / 转换器 | — | — | ✅ Delta、BCJ2、PPC / IA64 / ARM / ARMT / SPARC |
|
||||
| 分卷 | ✅ 引擎自行找齐各卷 | ✅ unrar 按名拼接 | ✅ |
|
||||
| 传统密码 | ✅ PKWARE「ZipCrypto」(`zip -e`) | ✅ `-p` | — |
|
||||
| AES 加密 | ✅ WinZip AES-128/192/256 | ✅ | ✅ 7zAES(AES-256-CBC) |
|
||||
| 加密文件名 | — | ✅ `-hp` 头加密 | ✅ `-mhe=on` 加密头 |
|
||||
| 密码询问时机 | 失败后询问并重试 | 失败后询问并重试 | 解压前提前询问 |
|
||||
|
||||
**可识别的分卷命名**
|
||||
|
||||
| 格式 | 接受 | 说明 |
|
||||
|---|---|---|
|
||||
| ZIP | `name.zip.001…`(7-Zip)、`name.part1.zip…`(WinRAR)、`name.z01…` + `name.zip`(Info-ZIP) | 任意一卷都可选,引擎会自己在同目录找齐其余分卷 |
|
||||
| RAR | `name.part1.rar` / `name.part01.rar`(首卷) | 请选**首卷**;其余卷在界面中置灰并带提示 |
|
||||
| 7z | `name.7z.001…` | 任意一卷均可,引擎会遍历目录取齐其余分卷 |
|
||||
|
||||
### 尺寸与安全限额
|
||||
|
||||
两档档位。默认档位出厂即安全;大档案档位**仅**在请求携带 `large=1` 时才启用,而界面会通过一次
|
||||
确认提示让用户做出这个选择。
|
||||
|
||||
| 限额 | 默认 | 大档案(`large=1`) |
|
||||
|---|---|---|
|
||||
| `max_entries` | 200 000 | 500 000 |
|
||||
| `max_total_bytes`(未压缩) | 2 TiB | 4 TiB |
|
||||
| `max_file_bytes`(单条目) | 512 GiB | 1 TiB |
|
||||
| `max_ratio`(未压缩 ÷ 压缩) | 500 : 1 | 1000 : 1 |
|
||||
| `max_depth`(文件夹嵌套) | 32 | 32 |
|
||||
| `max_name_len` / `max_path_len` | 255 / 1024 | 255 / 1024 |
|
||||
|
||||
默认上限是按主机上的真实工作量定的:一个 3A 作品打成「单个约 300 GiB 文件」的归档,无需任何提示
|
||||
即可解出。
|
||||
|
||||
### 安全检查
|
||||
|
||||
在创建任何一个输出文件**之前**,解压就会拒绝以下情况:
|
||||
|
||||
- **路径穿越** —— `..` 段、绝对 POSIX 路径、Windows 盘符、RAR 内把 `\` 当分隔符。
|
||||
- **特殊文件** —— 符号链接、设备、FIFO、套接字(`ZIPX_ERR_SPECIAL`)。
|
||||
- **重复条目**,以及同一归档内目录与文件同名冲突。
|
||||
- **突破限额** —— 解压后总尺寸、条目数、嵌套深度、名称长度或压缩比超出当前档位。
|
||||
- **磁盘空间** —— `check_space()` 在开始写 staging 之前就按**解压后总量**查 `statvfs`,因此一个
|
||||
不可能完成的解压根本不会启动。
|
||||
|
||||
提交密码**不会**跳过 scan 阶段:加密归档与明文归档受同一套限额约束。
|
||||
|
||||
### 冲突策略
|
||||
|
||||
通过 `/api/extract` 上的 `conflict=` 传入:
|
||||
|
||||
- `fail`(默认)—— 只要目标已存在就失败。
|
||||
- `overwrite` —— 覆盖已存在文件,合并进已存在文件夹。
|
||||
- `merge` —— 保留已存在文件,只新增其余文件。
|
||||
|
||||
### 密码处理
|
||||
|
||||
密码缺失或错误会返回 `ZIPX_ERR_PASSWORD`(界面上的 `err_extract_password`)。前端会弹出密码框,
|
||||
并**按原请求**重新发起——冲突策略、大文件选配全部沿用——最多三次;取消或留空则回落到最初的失败
|
||||
提示。首次失败时的文案说的是「此压缩包已加密」,而不会去责怪一个你压根还没被问过的密码。
|
||||
|
||||
7z 是例外:因为 `-mhe=on` 把文件名藏在加密头里,密码框会**提前**出现、早于 scan——否则一个加密
|
||||
的 7z 会先白跑一遍扫描,才轮到有人问你要密码。
|
||||
|
||||
### 调整大文件提示阈值
|
||||
|
||||
前端阈值位于 `assets/main.js`:
|
||||
|
||||
```js
|
||||
const LARGE_FILE_THRESHOLD_BYTES = 480 * 1024 * 1024 * 1024; // 480 GiB
|
||||
```
|
||||
|
||||
磁盘上大于该值的归档会触发确认提示。设为 `Infinity` 可静音提示,调低则更保守,或干脆删掉该调用
|
||||
——无论前端如何,服务器始终遵循 `large=1`。
|
||||
|
||||
### 本构建刻意不做的部分
|
||||
|
||||
- **ZIP / RAR / 7z 之外的格式。** `.tar`、`.tar.gz` / `.tgz`、`.gz`、`.xz`、`.bz2`、`.zst`、`.cab`、
|
||||
`.arj`、`.lzh`、`.cpio`、`.xar` 以及长尾里的其他格式都不识别。上游是靠把一整个 7-Zip 当作外部
|
||||
helper 进程分发,从而覆盖约 30 种后缀;本仓刻意不走这条路——原因见
|
||||
[与上游的差异](#与上游的差异)。
|
||||
- **ZIP 中 stored / deflated 之外的压缩方法**、7z 中使用了不受支持 coder 的 folder、早于 RAR 1.4 的归档。
|
||||
- **命名成 `x.rar.001` 的 RAR 分卷集。** unrar 只认它自己的 `x.partN.rar` 命名;把分卷改名
|
||||
(`.rar.001` → `.part1.rar`、`.002` → `.part2.rar`……)即可正常解压。ZIP 与 7z 的分卷集可以直接吃
|
||||
`.001` 风格。
|
||||
- **字典超过 4 GiB 的 RAR。** 这类归档会被独立地报成 `err_extract_dict_too_large`,文案同时给出
|
||||
归档需要的尺寸与构建支持的尺寸。放行意味着**一次性分配整个字典窗口**——正是 rarlab 自家 CLI
|
||||
默认拒绝、16 GB 共享内存的主机也承受不起的那件事。(RAR5 头字段本身卡在 4 GiB,所以这种情况只
|
||||
可能来自更新版的 RAR7 头格式。)密码错误**不属于**这一类,多卷归档也不属于。
|
||||
|
||||
## 快速上手
|
||||
|
||||
1. **构建** ELF:
|
||||
1. **构建**载荷:
|
||||
|
||||
```sh
|
||||
export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk # 见「构建」章节的 SDK 配置
|
||||
export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk # SDK 配置见下方「构建」
|
||||
make
|
||||
```
|
||||
2. **发送** 载荷到 PS5(默认 ELF 加载器端口 `9021`):
|
||||
|
||||
2. **发送**到主机(ELF 加载器常用端口 `9021`):
|
||||
|
||||
```sh
|
||||
nc -q0 "$PS5_HOST" 9021 < web-file-mgr.elf
|
||||
nc -q0 "$PS5_HOST" 9021 < web-file-mgr-v1.9.3M.elf
|
||||
```
|
||||
3. **读取** PS5 屏幕上的通知——它会打印实际监听端口(默认 `8888`)。
|
||||
4. 在**同一局域网**内的任意浏览器中打开 `http://<PS5_IP>:<port>/`——PS5 浏览器也可以。
|
||||
5. 首次运行时,载荷还会写入一个 **Media** 分类的主屏启动器;已有的启动器文件不会被覆盖。
|
||||
|
||||
3. **读取**主机屏幕上的通知——它会打印实际监听端口(通常 `8888`)。
|
||||
4. 在同一局域网内任意浏览器中**打开** `http://<PS5_IP>:<port>/`。
|
||||
5. 首次启动时载荷还会写入一个 **Media** 分类的主屏启动器;已有的启动器文件不会被改动。
|
||||
|
||||
## 构建
|
||||
|
||||
@@ -112,13 +234,13 @@
|
||||
export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk
|
||||
```
|
||||
|
||||
本项目链接 `libmicrohttpd`。`make` 在构建前会检查它,缺失时自动运行安装器:
|
||||
本项目链接 `libmicrohttpd`。`make` 会先检查它,缺失时自动运行安装器:
|
||||
|
||||
```sh
|
||||
make
|
||||
```
|
||||
|
||||
若构建主机无网络访问,可提前放入 libmicrohttpd 源码包并手动运行安装器:
|
||||
若构建主机没有外网,可提前放入源码包并手动装一次:
|
||||
|
||||
```sh
|
||||
LIBMICROHTTPD_TARBALL=/path/to/libmicrohttpd-1.0.1.tar.gz \
|
||||
@@ -129,263 +251,234 @@ make
|
||||
输出:
|
||||
|
||||
```text
|
||||
web-file-mgr.elf (约数百 KiB,v1.9.1 含 unrar7 + 7z 后更大;x86_64-sie-ps5)
|
||||
web-file-mgr-v1.9.3M.elf # x86_64-sie-ps5,约 882 KiB
|
||||
```
|
||||
|
||||
若只想做纯 UI/JS 开发而不需要 PS5 工具链:
|
||||
版本号是 `VERSION_TAG` 的一部分,因此也是输出**文件名**的一部分——一次构建不可能悄悄顶替掉另一个
|
||||
版本的产物。需要时可以直接覆盖:
|
||||
|
||||
```sh
|
||||
make VERSION_TAG=v1.9.4M
|
||||
```
|
||||
|
||||
只想做纯 UI / JS 开发、不需要 PS5 工具链时:
|
||||
|
||||
```sh
|
||||
make linux
|
||||
./web-file-mgr-linux
|
||||
./web-file-mgr-linux-v1.9.3M
|
||||
```
|
||||
|
||||
Linux 构建**不包含** PS5 主屏启动器安装器。
|
||||
|
||||
## 使用
|
||||
|
||||
在 PS5 上启动一个 ELF 加载器(端口 `9021` 常见)。发送载荷:
|
||||
在主机上启动一个 ELF 加载器(常用端口 `9021`),发送载荷:
|
||||
|
||||
```sh
|
||||
export PS5_HOST=ps5_ip_address
|
||||
nc -q0 "$PS5_HOST" 9021 < web-file-mgr.elf
|
||||
nc -q0 "$PS5_HOST" 9021 < web-file-mgr-v1.9.3M.elf
|
||||
```
|
||||
|
||||
载荷启动后,PS5 通知会显示应用名、版本与实际监听端口。打开它打印的 URL,例如:
|
||||
启动后,通知会显示应用名、版本与实际监听端口。打开它打印的 URL:
|
||||
|
||||
```text
|
||||
http://${PS5_IP_ADDRESS}:8888/
|
||||
```
|
||||
|
||||
若载荷不得不回退到其它端口(如 `8889`),请以通知显示的端口为准——URL 并未硬编码。
|
||||
如果 `8888` 已被占用,载荷会往上走到下一个空闲端口——请以通知显示的端口为准,URL 并未硬编码。
|
||||
首次启动时它会在需要时于 Media 分类安装一个 `PS5 Web File Manager` 快捷方式;缺失的启动器文件会
|
||||
被写入,已存在的会被保留。
|
||||
|
||||
首次启动时,载荷会在需要时于 Media 分类安装一个 `PS5 Web File Manager` 快捷方式。已有的启动器文件会被保留;只补写缺失的文件。
|
||||
|
||||
## ZIP 解压
|
||||
|
||||
仅支持普通 ZIP——stored / deflated / ZIP64,**绝不解密**。引擎是一个独立的三阶段模块(`scan → extract → publish → cleanup`),位于 `src/zip_extract.{c,h}`,配有独立的主机端 C 测试套件。每个条目先写入 staging 目录(`*.wfm-part-*`),fsync 后原子重命名到目标位置。归档中途任何失败都会回滚部分改动;取消与致命错误总会清理 staging。
|
||||
|
||||
### 限额
|
||||
|
||||
| 限额 | 默认档位 | 大档案档位(`ZIPX_LIMITS_LARGE`) |
|
||||
|---|---|---|
|
||||
| `max_entries` | 200 000 | 500 000 |
|
||||
| `max_total_bytes`(未压缩) | 2 TiB | 4 TiB |
|
||||
| `max_file_bytes`(单条目) | 512 GiB | 1 TiB |
|
||||
| `max_ratio`(未压缩 / 压缩) | 500 : 1 | 1000 : 1 |
|
||||
| `max_depth`(文件夹嵌套) | 32 | 32 |
|
||||
| `max_name_len` / `max_path_len` | 255 / 1024 | 255 / 1024 |
|
||||
|
||||
**默认档位**出厂即安全:一个解压到 800 GiB 的 4 MiB 压缩块会在打开任何输出文件之前被拒绝。**大档案档位**仅在请求携带 `large=1` 时才启用——当磁盘上归档大于 `LARGE_FILE_THRESHOLD_BYTES`(默认 480 GiB;可在 `assets/main.js` 配置)时,解压对话框会自动提示用户。确认提示即为用户的明确选配;服务器自身不会额外记录任何内容。
|
||||
|
||||
### 安全检查
|
||||
|
||||
引擎拒绝解压以下归档:
|
||||
|
||||
- 加密条目(设置了任何加密标志)。
|
||||
- 路径穿越(`..` 段、绝对 POSIX 路径、Windows 盘符)。
|
||||
- 符号链接、设备、FIFO、套接字(`ZIPX_ERR_SPECIAL`)。
|
||||
- 同一归档内的重复条目或目录/文件名冲突。
|
||||
- 解压后尺寸、条目数、嵌套深度、名称长度或压缩比突破当前档位。
|
||||
|
||||
### 冲突策略
|
||||
|
||||
通过 `/api/extract` 上的 `conflict=` 传入:
|
||||
|
||||
- `fail`(默认)—— 拒绝覆盖任何已存在的目标。
|
||||
- `overwrite` —— 替换已存在文件;合并进已存在文件夹。
|
||||
- `merge` —— 保留已存在文件,新增其余文件。
|
||||
|
||||
### 调整阈值
|
||||
|
||||
480 GiB 的前端阈值位于 `assets/main.js`:
|
||||
|
||||
```js
|
||||
const LARGE_FILE_THRESHOLD_BYTES = 480 * 1024 * 1024 * 1024;
|
||||
```
|
||||
|
||||
设为 `Infinity` 可静音提示,调低则更保守,或干脆删掉该调用——无论阈值如何,服务器始终遵循 `large=1`。
|
||||
|
||||
## RAR 解压
|
||||
|
||||
RAR 解压引擎(`src/rar_extract.{c,h}`)由 **官方 rarlab UnRAR 源码** 支撑(`third_party/unrar7/`,版本 7.20.1,编译为静态库并通过其 C 兼容的 DLL API 驱动)。扩展名为 `.rar` 的文件与 `.zip` 文件一样拥有**解压**按钮;引擎由 `src/extract.c` 按扩展名分派。
|
||||
|
||||
> v1.9 替换了 v1.8 的引擎(dmc_unrar 1.7.0)。dmc_unrar 无法解码 WinRAR 6.x/7.x 写出的归档(RAR5「v6」压缩)且不支持多卷;unrar 原生支持两者。
|
||||
|
||||
### 支持范围
|
||||
|
||||
| 格式 | 支持 | 备注 |
|
||||
|---|---|---|
|
||||
| RAR 1.5 → 4.x(含 2.9 / 3.6 / 4.0) | ✅ | |
|
||||
| RAR 5.0 及 **5.0「v6」**(WinRAR 6.x / 7.x) | ✅ | v1.9 的触发点 |
|
||||
| Solid 块、最大 1 GiB 字典 | ✅ | |
|
||||
| PPMd 解压(RAR 3.0+) | ✅ | |
|
||||
| **多卷**(`.part01.rar` + `.part02.rar` + …) | ✅ | 当完整卷集与被打开的卷同处一目录时,unrar 按名拼接。选择首个卷(`name.part1.rar` / `name.part01.rar`);非首卷在 UI 中仍置灰并给出提示。 |
|
||||
| **加密 RAR** | ⏳ | 引擎可解密(`RARSetPassword`),但密码字段/提示尚未接入 `/api/extract`。加密归档以 `ZIPX_ERR_UNSUPPORTED` 被提前拒绝。 |
|
||||
| 符号链接 / FIFO / 套接字 / 设备 | ❌ | 以 `ZIPX_ERR_SPECIAL` 拒绝(与 ZIP 行为一致) |
|
||||
| RAR 1.3(1.4 之前) | ❌ | 被 unrar 上游拒绝 |
|
||||
|
||||
当某归档被拒绝时,用户会收到 `extract_unsupported` 失败,文件名作为详情参数。前端已用典型的双语重试指引显示该错误。
|
||||
|
||||
### 限额
|
||||
|
||||
RAR 引擎原样复用 ZIP 的限额表——其上并无额外的 RAR 档位表。默认值与 `large=1` 选配完全相同:
|
||||
|
||||
| 限额 | 默认档位 | 大档案档位(`large=1`) |
|
||||
|---|---|---|
|
||||
| `max_entries` | 200 000 | 500 000 |
|
||||
| `max_total_bytes`(未压缩) | 2 TiB | 4 TiB |
|
||||
| `max_file_bytes`(单条目) | 512 GiB | 1 TiB |
|
||||
| `max_ratio`(未压缩 / 压缩) | 500 : 1 | 1000 : 1 |
|
||||
| `max_depth`(文件夹嵌套) | 32 | 32 |
|
||||
| `max_name_len` / `max_path_len` | 255 / 1024 | 255 / 1024 |
|
||||
|
||||
大档案档位的 RAR 解压使用与 ZIP 相同的 `LARGE_FILE_THRESHOLD_BYTES`(480 GiB)提示——前端对 `.rar` 与 `.zip` 的提示处理相同,且服务器仅在请求携带 `large=1`(选配)时才启用大限额。
|
||||
|
||||
### 安全检查
|
||||
|
||||
RAR 引擎应用与 ZIP 引擎相同的检查——复用 `zipx_status_t` 状态码,因此任务 UI 的 `err_extract_unsafe_name`、`err_extract_too_deep`、`err_extract_ratio` 等会一致触发:
|
||||
|
||||
- 路径穿越(`..` 段、绝对 POSIX 路径、Windows 盘符、`\` 在 `Rar!\x1a\x07…` 头之后被视为路径分隔符等)。
|
||||
- 符号链接、FIFO、套接字、设备。
|
||||
- 归档内重复条目或目录/文件名冲突。
|
||||
- 归档尺寸、条目数、深度、名称长度或压缩比突破当前档位。
|
||||
|
||||
### Vendoring 与许可
|
||||
|
||||
`third_party/unrar7/` 是官方 **rarlab UnRAR 源码**(7.20.1)的逐字副本,由 [`opello/unrar`](https://github.com/opello/unrar) 在提交 `97e1780` 处镜像。它依 **UnRAR 免费软件许可** 分发(见 `third_party/unrar7/license.txt`):可于任何软件中用于处理 RAR 归档,但不得用于开发 RAR 兼容的*归档器*或重新实现 RAR 压缩算法。项目自有的门面 `third_party/unrar7/unrar_c_api.h` 携带项目自身许可。
|
||||
|
||||
> v1.8 引擎 `third_party/unrar/dmc_unrar.c`(DrMcCoy/dmc_unrar 1.7.0,GPL-2.0-or-later)已在 v1.9 移除;其声明留存于 git 历史。
|
||||
|
||||
### 加密 RAR(计划中)
|
||||
|
||||
引擎可解密归档(经 `RARSetPassword`),但密码通道——`/api/extract` 上的 `password=` 字段加前端提示——尚未接线。加密归档目前以 `extract_unsupported` 失败。引擎替换(v1.9)已移除硬性引擎限制;剩余工作纯粹是 API/UI 接线。
|
||||
|
||||
## 7z 解压
|
||||
|
||||
7z 解压引擎(`src/sevenz_extract.{c,h}`)基于 SDK 解码子集(LZMA2 / LZMA / BCJ2 等)加上项目自研的拉式 codec 链(`src/sevenz_chain.c`,位于 `src/sevenz_chain.h`)。扩展名为 `.7z` 的文件与 ZIP / RAR 一样拥有**解压**按钮;引擎由 `src/extract.c` 按扩展名分派,并复用同一套三阶段模型、限额档位与冲突策略。
|
||||
|
||||
> v1.9.1 新增。SDK 自带的 `SzArEx` 路径仅覆盖 4 个 coder 的文件夹,不足以装下 BCJ2 的 5 coder;本项目改为自研 folder 解析 + 拉式 codec 链,从而原生支持 BCJ2 与多 coder 组合。
|
||||
|
||||
### 支持范围
|
||||
|
||||
| 格式 | 支持 | 备注 |
|
||||
|---|---|---|
|
||||
| LZMA2 / LZMA(含 ZIP64 式大尺寸) | ✅ | 单 coder 纯 LZMA2 走多线程解码(`src/sevenz_mt.c`,8 线程) |
|
||||
| BCJ2(x86 反汇编后处理) | ✅ | 经自研拉式链;SDK `SzArEx` 装不下 5 coder 时由本项目承载 |
|
||||
| 多 coder 组合文件夹 | ✅ | 自研 `sevenz_chain.c` 解析 |
|
||||
| **分卷**(`.7z.001` / `.z01` 链) | ✅ | `src/sevenz_volstream.c` 按名拼接;打开首个分卷 |
|
||||
| **内容加密**(7zAES,AES-256-CBC) | ⏳ | 引擎可解密;密码 UI / API 尚未接入,暂时以 `ZIPX_ERR_UNSUPPORTED` 拒绝 |
|
||||
| **`-mhe=on` 加密头** | ❌ | 需自研头解析器;vendored SDK 在涉及我们之前就以 `SZ_ERROR_UNSUPPORTED` 拒绝。此为唯一已知缺口 |
|
||||
|
||||
当某归档被拒绝时,用户同样收到 `extract_unsupported` 失败,UI 显示双语重试指引。
|
||||
|
||||
### 限额
|
||||
|
||||
7z 引擎复用与 ZIP / RAR 完全相同的限额表;默认档位与 `large=1` 选配一致(见 [ZIP 解压 → 限额](#限额))。
|
||||
|
||||
### 安全检查
|
||||
|
||||
7z 引擎复用相同的 `zipx_status_t` 错误码与检查集合:路径穿越、特殊文件、重复条目/名冲突、以及突破当前档位的尺寸/条目数/深度/名称长度/压缩比。coder 的 `out_size` 取自 `coder_unpack_sizes[index]`(而非文件夹尺寸),`SzArEx` 失败时重置 `blockIndex` 以避免伪 CRC。
|
||||
|
||||
## 校验
|
||||
|
||||
`make` 之后,对生成的 ELF 做健全性检查:
|
||||
## 校验产物
|
||||
|
||||
```sh
|
||||
ls -la web-file-mgr.elf # v1.9.1 因含 unrar7 + 7z 体积更大;v1.8.3 约 509 KiB
|
||||
sha256sum web-file-mgr.elf # 把摘要记录进你的发布说明
|
||||
file web-file-mgr.elf # 期望 "ELF 64-bit LSB pie executable, x86-64"
|
||||
od -An -tx1 -N20 web-file-mgr.elf | head -2 # 魔数 7f45 4c46 0201 + e_machine 003e
|
||||
ls -la web-file-mgr-v1.9.3M.elf # 约 882 KiB
|
||||
sha256sum web-file-mgr-v1.9.3M.elf # v1.9.3M 应为 8ca47d5a…c9bb
|
||||
file web-file-mgr-v1.9.3M.elf # 期望 "ELF 64-bit LSB pie executable, x86-64"
|
||||
od -An -tx1 -N20 web-file-mgr-v1.9.3M.elf | head -2 # 魔数 7f45 4c46 0201,e_machine 003e
|
||||
```
|
||||
|
||||
`e_machine = 0x003e` 确认了 PS5 目标三元组 `x86_64-sie-ps5`。`e_type = 3`(`ET_DYN`)确认了 ELF 加载器期望的位置无关载荷。
|
||||
`e_machine = 0x003e` 确认了 PS5 目标三元组 `x86_64-sie-ps5`;`e_type = 3`(`ET_DYN`)确认了 ELF
|
||||
加载器期望的位置无关载荷。
|
||||
|
||||
前端资源(JS / CSS / HTML)是 **gzip 压缩后内嵌** 进 ELF 的,所以拿 `strings` 去搜 `assets/` 里的
|
||||
任何东西都不会有命中——这是压缩所致,不是内容缺失。请改用附带脚本:
|
||||
|
||||
```sh
|
||||
python3 .build/check-elf-gzip.py ./web-file-mgr-v1.9.3M.elf uploadMenu extractRetryKey
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
一套 POSIX / 主机端 C 测试套件覆盖 ZIP、RAR 与 7z 三个引擎,可在任意 Linux / macOS / MSYS shell 下、无需 PS5 SDK 运行:
|
||||
一套 POSIX / 主机端 C 测试套件覆盖 ZIP、RAR 与 7z 三个引擎,可在任意 Linux / macOS / MSYS shell
|
||||
下、无需 PS5 SDK 运行:
|
||||
|
||||
```sh
|
||||
cd tests && bash run-tests.sh # ZIP + RAR 套件
|
||||
bash run-sevenz-tests.sh # 7z 套件(需 MinGW gcc 与 7-Zip 二进制)
|
||||
cd tests && bash run-tests.sh # ZIP + RAR 套件
|
||||
bash run-sevenz-tests.sh # 7z 套件(需 MinGW gcc 与 7-Zip 二进制)
|
||||
```
|
||||
|
||||
输出为逐用例的 `check` 风格报告,覆盖:
|
||||
当前 `main`:**177 项检查**(140 ZIP + 37 RAR),0 失败;7z 套件另有 **27 项用例**,0 失败。覆盖:
|
||||
|
||||
- ZIP 条目解析(stored + deflated + ZIP64)
|
||||
- ZIP 条目解析(stored、deflated、ZIP64),并与真实归档做逐字节内容比对
|
||||
- 路径穿越、绝对路径、反斜杠、Windows 盘符
|
||||
- 符号链接、FIFO、加密条目、坏 CRC、截断归档、非 ZIP 文件
|
||||
- 限额:`entries`、`total_bytes`、`file_bytes`、`ratio`、`depth`、`name_len`
|
||||
- 冲突策略:`fail` / `overwrite` / `merge`
|
||||
- 每个阶段的取消
|
||||
- **大文件档位** —— `medium_bomb.zip`(比率 ≈ 238)在默认限额下被拒、在大档位下通过;降低后的大档位仍生效
|
||||
- **RAR 引擎**(`tests/test_rar_extract.c`)—— 格式分派、每个可达 `DMC_UNRAR_*` 码的错误翻译、限额交接
|
||||
- **7z 引擎**(`tests/test_sevenz_extract.c` + `tests/run-sevenz-tests.sh`)—— 真实 `.7z` fixture 逐字节比对、加密头拒绝、各策略下的冲突、取消、限额、缺失目标父目录,以及失败时绝不发布且 staging 树被清理的保证
|
||||
- 符号链接、FIFO、坏 CRC、截断归档、非 ZIP 输入
|
||||
- 全部限额(条目数、总字节、单文件字节、压缩比、深度、名称长度)
|
||||
- 冲突策略 `fail` / `overwrite` / `merge`
|
||||
- 每个阶段的取消,以及「失败绝不发布任何文件、并清理自己的 staging 树」这一保证
|
||||
- **加密归档** —— 每个真实 fixture 各跑四种情况:无密码、空密码、错密码都得
|
||||
`ZIPX_ERR_PASSWORD`,正确密码则成功并做逐字节内容校验。另有两项证明「提供密码后限额仍然生效」。
|
||||
fixture:`enc-zipcrypto.zip`、`enc-aes256.zip`、`enc-aes256-store.zip`(ZIP)、
|
||||
`enc-v6.rar`(RAR)、`aeshe.7z`(7z,加密头)
|
||||
- **大档案档位** —— `medium_bomb.zip`(压缩比 ≈ 238)在默认档位下被拒、在大档案档位下通过
|
||||
- **格式分派** —— 改名的 ZIP 与垃圾数据块都会被拒
|
||||
|
||||
三份前端 / 真页面验证脚本位于 `.build/` —— 该目录整体被 gitignore 忽略、只放行白名单,
|
||||
而这三份连同文档渲染检查脚本都在白名单内,因此它们受版本控制、清理临时文件时不会被误删:
|
||||
|
||||
| 脚本 | 覆盖内容 | 检查数 |
|
||||
|---|---|---|
|
||||
| `ui_retry_test.mjs` | 真实 `assets/main.js` 载入桩 DOM 后的密码重试流程:参数记忆、重试上限、取消 / 空密码的回落,以及「非 ASCII 目录必须仍弹口令框」的回归用例 | 40 |
|
||||
| `ui_upload_menu_test.mjs` | 标记侧:`index.html` 里每个 `data-i18n` 键在两份语言文件中都存在、`main.js` 里 117 个 `t("…")` 键全部有译文、上传菜单接对了回调、用到的 class 确实有样式、菜单行高亮规则保住了面板作用域,以及解压按钮**绝不隐藏、只置灰** | 40 |
|
||||
| `preview_check.mjs` | 真页面 + 桩 API + 无头 Chromium:菜单静止时隐藏 / 点击打开 / 焦点落位 / 真能点到 file input / 关闭,页脚布局,以及被钉死的工具栏换行阈值 | 12 项断言 |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── Makefile # PS5 + Linux 构建(VERSION_TAG v1.9.1)
|
||||
├── .build/ # 构建脚本 + 验证脚本(目录其余部分被 gitignore)
|
||||
├── Makefile # PS5 + Linux 构建(VERSION_TAG v1.9.3M)
|
||||
├── install-libmicrohttpd.sh # 一次性依赖安装器
|
||||
├── gen-asset-module.py # 将 assets/* 内联为 gzip 压缩的 C 数组
|
||||
├── assets/ # HTML / CSS / JS / 图标 / param.json
|
||||
├── src/ # C 载荷源码
|
||||
│ ├── main.c websrv.c filemgr.c # 入口、HTTP 前端、任务模型
|
||||
│ ├── upload.c download.c # 流处理
|
||||
│ ├── upload.c download.c text.c # 流处理与原地编辑
|
||||
│ ├── extract.c # /api/extract 分派器(ZIP + RAR + 7z)
|
||||
│ ├── zip_extract.{c,h} # ZIP 引擎
|
||||
│ ├── rar_extract.{c,h} # RAR 引擎(unrar7 后端)
|
||||
│ ├── sevenz_extract.{c,h} # 7z 引擎
|
||||
│ ├── sevenz_chain.{c,h} # 7z 拉式 codec 链(BCJ2 等)
|
||||
│ ├── sevenz_mt.{c,h} # 7z 多线程 LZMA2 解码
|
||||
│ ├── sevenz_volstream.{c,h} # 7z 分卷流拼接
|
||||
│ └── app_installer.c # PS5 Media 启动器安装器
|
||||
├── third_party/ # vendored:zlib、minizip-ng、unrar7、7z(SDK 子集)
|
||||
│ ├── unrar7/ # rarlab UnRAR 7.20.1,静态库 + C API 门面
|
||||
│ ├── minizip-ng/ # ZIP 读取器
|
||||
│ ├── zlib/ # minizip-ng 的压缩后端
|
||||
│ └── 7z/ # LZMA SDK 解码子集
|
||||
├── tests/ # POSIX / 主机测试套件
|
||||
│ ├── test_zip_extract.c
|
||||
│ ├── test_rar_extract.c
|
||||
│ ├── test_sevenz_extract.c # 7z 用例驱动
|
||||
│ ├── make_fixtures.py # 重新生成测试 fixture
|
||||
│ ├── run-tests.sh # 一次性运行器(ZIP + RAR)
|
||||
│ ├── run-sevenz-tests.sh # 7z 运行器
|
||||
│ ├── compat/ # 小型 Win32 / MSYS 垫片
|
||||
│ └── fixtures/ fixtures-7z/ fixtures-real/ # 生成的测试归档
|
||||
│ ├── zip_extract.{c,h} zipx_common.c # ZIP 引擎(minizip-ng 后端)
|
||||
│ ├── zipx_volume.c zipx_volstream.c # ZIP 分卷探测 + 拼接流
|
||||
│ ├── rar_extract.{c,h} # RAR 引擎(rarlab UnRAR 7.20.1 后端)
|
||||
│ ├── sevenz_extract.{c,h} sevenz_chain.{c,h} # 7z 引擎,自解析 codec 链
|
||||
│ ├── sevenz_header.{c,h} # 7z 头部读取 / `-mhe=on` 解密
|
||||
│ ├── sevenz_mt.c sevenz_volstream.c # 多线程 LZMA2 + `.7z.001` 分卷
|
||||
│ ├── app_installer.c pkg_installer.c pkg_info.c # PS5 PKG 预览 / 安装
|
||||
│ └── demangle_stub.c cpu_support_stub.c # 体积 / 可移植性桩
|
||||
├── third_party/ # vendored 库
|
||||
│ ├── unrar7/ # rarlab UnRAR 7.20.1 —— RAR 引擎
|
||||
│ ├── minizip-ng/ # 4.2.2,裁剪到只留读取路径
|
||||
│ ├── 7z/ # LZMA SDK 26.03 解码子集
|
||||
│ └── zlib/ # minizip 的压缩后端
|
||||
├── tests/ # POSIX / 主机端测试套件
|
||||
│ ├── test_zip_extract.c test_rar_extract.c test_sevenz_extract.c
|
||||
│ ├── sevenz_chain_e2e.c sevenz_e2e.c bigfile_e2e.c
|
||||
│ ├── make_fixtures.py make_sevenz_fixtures.py make_split_fixtures.py
|
||||
│ ├── run-tests.sh # 一次性运行器(ZIP + RAR)
|
||||
│ ├── run-sevenz-tests.sh # 7z 套件
|
||||
│ ├── bench_driver.py bench_formats.py # 吞吐基准
|
||||
│ ├── compat/ # 小型 Win32 / MSYS 垫片
|
||||
│ └── fixtures/ fixtures-7z/ fixtures-real/
|
||||
├── docs/
|
||||
│ ├── HANDOVER.md # 工程交接 / 开发手册
|
||||
│ ├── USER-GUIDE-zh-CN.md # 新手使用说明(中文)
|
||||
│ ├── DEVICE-TEST-v1.9.3M.md # 发布前跑过的真机验收清单
|
||||
│ ├── SIZE-OPTIMIZATION.md # ELF 体积分析 + 逐符号台账
|
||||
│ ├── EXTRACTION-PERF.md # 解压基准
|
||||
│ ├── REAL-CONSOLE-PROFILE.md # 真机实测吞吐
|
||||
│ ├── UPSTREAM-V1.8-COMPARISON.md # 本仓 vs 上游 helper 路线
|
||||
│ ├── REWRITE-FEASIBILITY.md # 引擎抽取可行性研究
|
||||
│ ├── UPGRADE-v1.7-zip-large-file-profile.md
|
||||
│ ├── UPGRADE-v1.8-rar-support.md
|
||||
│ └── screenshots/ # README 截图
|
||||
│ └── screenshots/ # README 截图
|
||||
├── CHANGELOG.md # 逐版本变更记录
|
||||
├── THIRD_PARTY_NOTICES # 捆绑库署名
|
||||
├── HANDOVER.md # 现行开发交接文档
|
||||
├── LICENSE # GPLv3+
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 与上游的差异
|
||||
|
||||
本项目 **fork 自 [owendswang/ps5-web-file-manager](https://github.com/owendswang/ps5-web-file-manager)**。
|
||||
Web UI、任务模型与 PS5 打包方式均源自该项目;上游作者以 GPL-3.0 发布,是本衍生作品得以存在的前提。
|
||||
从 v1.8 起,上游把解压**外包给一个独立 helper 进程**——一整个 7-Zip,做成
|
||||
`wfm-7zip-helper.elf`,由用户自行安装到 `/data/wfm/`。本仓走的是相反的路:解码器 vendor **进**
|
||||
载荷内部。
|
||||
|
||||
| | 上游 | 本仓 |
|
||||
|---|---|---|
|
||||
| 解压架构 | 外部 `wfm-7zip-helper.elf`(1,017,616 B,单独分发,路径写死 `/data/wfm/`),用 Unix socket IPC 协议驱动 | 引擎就在**载荷内部**;没有第二个文件,没有 IPC |
|
||||
| 部署 | 两个文件合计 1,363,048 B;helper 缺失或放错位置,解压功能全废(`archive_helper_not_running`) | 单 ELF 903,448 B,零外部依赖 —— **小 33.7%**,且上游那个 helper 单独一个就比本仓整个载荷还大 |
|
||||
| 格式 | 约 30 种后缀(`.tar`、`.gz`、`.xz`、`.bz2`、`.zst`、`.cab`、`.arj`、`.lzh`、`.cpio`……) | `.zip` / `.rar` / `.7z` 及其分卷形态——三种,但每一种都完整 |
|
||||
| 防压缩炸弹 / 压缩比 | 无 | 条目数、总尺寸、单文件尺寸、压缩比筛查,并对 1 GiB 以下小文件豁免以免误判 |
|
||||
| 磁盘空间预检 | 无 | 写 staging 前按解压后总量查 `statvfs` |
|
||||
| 路径穿越防护 | 交给 7-Zip | 本仓实现,并有专项测试组 |
|
||||
| 失败残留 | 可能留下解压了一半的目录 | staging 目录 + rename;失败或取消都会清理,且不发布任何文件 |
|
||||
| 密码提示 | helper 的 IPC 协议里带 `PASSWORD_REQUIRED` 消息 | 失败后弹框重试(上限三次),统一报为 `extract_password`;7z 提前询问 |
|
||||
| 载荷重启后的任务存活 | ✅ helper 是独立进程,解压任务不会丢 | ❌ 重启会丢掉正在跑的任务 |
|
||||
| 内存隔离 | ✅ 解压在独立进程里 | ❌ 共享地址空间(改为对 LZMA2 字典封顶) |
|
||||
| 版本标识 | 纯 `vX.Y.Z` | `vX.Y.ZM`——尾部的 `M` 标记本仓改版 |
|
||||
|
||||
这套取舍的实测数据与推理过程在
|
||||
[`docs/UPSTREAM-V1.8-COMPARISON.md`](docs/UPSTREAM-V1.8-COMPARISON.md)。一句话:
|
||||
**上游赢在格式广度与进程架构,本仓赢在安全、部署与错误质量。** 格式覆盖的差距是现有架构里可以
|
||||
增量补的活,不构成推倒重来的理由。
|
||||
|
||||
## 备注
|
||||
|
||||
- 复制、移动、删除、上传、下载作为单个后台任务运行。一个任务运行时,其它文件操作会被拒绝。
|
||||
- 删除是递归且永久的。没有回收站。
|
||||
- 复制/移动任务可取消。单个文件的部分拷贝会被移除,但部分拷贝的文件夹会保留在原地,以避免在合并进已存在目标文件夹时误删既有文件。
|
||||
- 上传任务可取消。尽可能移除部分上传的临时文件。
|
||||
- 下载文件夹或多个选中项会生成 tar 流。tar 归档由载荷生成,不会先写入 PS5 存储。
|
||||
- 若浏览器在载荷进程仍在运行时被关闭重开,UI 可恢复活动任务显示。
|
||||
- 文本编辑仅限于上述精选扩展名列表。非 UTF-8 与超大文件会被拒绝。
|
||||
- 文件名通过 Web API 以 UTF-8 传输。载荷也会保留挂载文件系统返回的遗留字节序名称,以便混合 USB 文件名编码仍能正确显示与操作。
|
||||
- 复制、移动、删除、上传、下载都作为单个后台任务运行。一个任务运行时,其它文件操作会被拒绝。
|
||||
- 删除是递归且永久的,没有回收站。
|
||||
- 复制 / 移动任务可取消。单个文件的部分拷贝会被移除;部分拷贝的**文件夹**会保留在原地,以免在
|
||||
合并进已存在的目标文件夹时误删既有文件。
|
||||
- 上传任务可取消;尽可能移除部分上传的临时文件。
|
||||
- 下载文件夹或多选会生成 tar 流,就地生成——不会先写入主机存储。
|
||||
- 若浏览器在载荷进程仍在运行时被关闭重开,界面可恢复活动任务的显示。
|
||||
- 文本编辑仅限上述扩展名列表;非 UTF-8 与超大文件会被拒绝。
|
||||
- **文件名编码:** 名字经 Web API 以 UTF-8 传输,而挂载的文件系统可能返回遗留字节序列(比如一块
|
||||
GBK 的 U 盘)。为了不丢这些字节,API 会把每个 ≥ `0x80` 的字节映射成 `\u00XX`、回程再还原,
|
||||
前端显示时按 GBK / gb18030 解码。实际后果是:同一个目录在「页面手里」与「服务端手里」是两串
|
||||
不同的字符串——这就是为什么前端里任何东西都不能拿路径当跨请求的键。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **这是自制应用,不应故意修改系统进程或内核内存。** 若遇到内核崩溃(kernel panic),请确保使用较新的越狱方法与 ELF 加载器,或回退到你惯用的稳定方法。
|
||||
- **P2JB 用户** —— 若此载荷触发内核崩溃,请避免在该环境下使用。当每次重试代价高昂时,稳定性比便利更重要。
|
||||
- **准备阶段可能耗时较久** —— 当文件夹含大量文件时,它会累加文件夹大小并检查剩余空间,这有助于避免启动一个无法安全完成的复制 / 移动 / 上传 / 下载。
|
||||
- **`err_extract_entry_too_large`** —— 默认归档上限为单条目 512 GiB / 500:1 比率(覆盖典型 3A 游戏归档中单个约 300 GiB 未压缩文件)。若超过默认,请确认大文件提示(磁盘上 > 480 GiB 的归档会出现),拆分归档,或直接向 API 传入 `large=1`。
|
||||
- **`err_extract_unsupported`** —— 归档使用了引擎无法处理的功能:加密 ZIP、加密 RAR、多卷 RAR(`.part02+.rar`)、极老的 RAR 1.4、RAR 符号链接 / FIFO,或既非 `.zip` 也非 `.rar` / `.7z` 的文件。对于 7z,特指 `-mhe=on` 加密头。针对 RAR 的消息会列出失败原因并提示用户回到 PC 端解压器。
|
||||
- **这是自制软件,不会有意修改系统进程或内核内存。** 若遇到内核崩溃(kernel panic),请确认使用
|
||||
较新的越狱方法与 ELF 加载器,或回到你惯用的稳定方案。
|
||||
- **P2JB 用户** —— 若此载荷在该环境下触发内核崩溃,请勿在此环境使用。当每次重试代价都很高时,
|
||||
稳定性比便利更重要。
|
||||
- **「准备阶段」在文件很多的目录下可能耗时较久** —— 它会累加目录大小并检查剩余空间,这正是让一个
|
||||
无法安全完成的复制 / 移动 / 上传 / 下载从一开始就不会启动的原因。
|
||||
- **`err_extract_unsupported`** —— 这个包本机读不了:既非 `.zip` / `.rar` / `.7z` 的文件;ZIP 条目
|
||||
用了 stored / deflated 之外的压缩方法;7z 用了不支持的 coder;分卷命名不被识别(RAR 分卷若叫
|
||||
`x.rar.001`,需改名为 `x.part1.rar`、`x.part2.rar`……);或早于 RAR 1.4 的归档。**加密归档与
|
||||
多卷归档不属于这一类**——两者都支持。界面会在括号里附上后端原文,指明具体原因。
|
||||
- **`err_extract_entry_too_large`** —— 归档超出默认上限(单条目 512 GiB / 500:1 比率)。确认那个
|
||||
大文件提示(磁盘上 > 480 GiB 的归档会出现)、拆分归档,或直接向 API 传入 `large=1`。
|
||||
- **`err_extract_dict_too_large`** —— RAR 归档声明的压缩字典超过本构建支持的上限(4096 MiB)。
|
||||
请在 PC 上用不超过 4 GiB 的字典(`-md`)重新压缩,或直接在 PC 上解压。
|
||||
- **`err_extract_password`** —— 归档已加密,而密码缺失或错误。这也包括带加密头(`-mhe=on`)的 7z:
|
||||
文件名与条目尺寸都在头部里,头解密之前连条目列表都读不出来。
|
||||
|
||||
## 版本历史
|
||||
|
||||
逐版本的产物、摘要、段尺寸增量与测试计数见 [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
|
||||
| 版本 | 日期 | 一句话 |
|
||||
|---|---|---|
|
||||
| `v1.9.3M` | 2026-09-24 | 加密归档端到端打通(ZIP ZipCrypto + WinZip AES、RAR `-p`/`-hp`、7z 7zAES 含 `-mhe=on`)、字典超限独立报错,以及一轮 UI(上传菜单、拖拽提示、解压按钮常显置灰) |
|
||||
| `v1.9.2` | 2026-09-05 | 只改版本号的重发版;重新打 tag 使 tag = 源码 = 二进制 |
|
||||
| `v1.9.1` | 2026-09-05 | 7z 引擎、分卷、7zAES,以及 −15.8% 体积 / 吞吐优化 |
|
||||
| `v1.9` | 2026-09-05 | RAR 引擎换成 rarlab UnRAR 7.20.1(RAR5「v6」、多卷) |
|
||||
| `v1.8.3` | 2026-09-05 | 「上传并解压」开始接受 `.rar` |
|
||||
| `v1.8.2` | 2026-09-05 | 为 3A 单文件归档放宽单条目上限;两个 PS5 专属构建修复 |
|
||||
| `v1.8.1` | 2026-09-05 | 为系统备份归档放宽默认 ZIP 上限 |
|
||||
| `v1.8` | 2026-09-05 | 首个 RAR 支持(dmc_unrar),共享解压协议 |
|
||||
| `v1.7` | 2026-09-04 | ZIP 大文件档位(`large=1`) |
|
||||
|
||||
## 署名
|
||||
|
||||
本项目参考了以下项目构建:
|
||||
本项目 **fork 自 [owendswang/ps5-web-file-manager](https://github.com/owendswang/ps5-web-file-manager)**(GPL-3.0)。Web UI、任务模型与 PS5 打包方式均源自该项目;上游作者以 GPL-3.0 发布,是本衍生作品得以存在的前提。
|
||||
|
||||
**怎么区分上游原版与本仓改版:** 自 v1.9.3 起版本号带 `M` 后缀(`vX.Y.ZM`),*M* 即 *Modified*(改版);上游 owendswang 的发布版是纯 `vX.Y.Z`。因此 `v1.9.2` 是上游 / 本仓共用的编号,而 `v1.9.3M` 只可能出自本仓;这个字母同时出现在 ELF 文件名、PS5 启动通知、`/api/version` 与网页右下角。v1.9.3M 之前的发布早于该约定,保留原本的无后缀编号。
|
||||
|
||||
本项目另参考了以下项目构建:
|
||||
|
||||
- **[ps5-payload-dev/websrv](https://github.com/ps5-payload-dev/websrv):** HTTP 服务器结构、静态资源内联思路、PS5 浏览器/websrv 行为与 PKG 安装函数。许可证:GPLv3+。
|
||||
- **[ps5-payload-dev/ftpsrv](https://github.com/ps5-payload-dev/ftpsrv):** PS5 载荷约定、主屏启动器/安装流程参考、进程处理风格与启动安装参考。许可证:GPLv3+。
|
||||
@@ -394,10 +487,13 @@ bash run-sevenz-tests.sh # 7z 套件(需 MinGW gcc 与 7-Zip 二
|
||||
- **[libmicrohttpd](https://ftp.gnu.org/gnu/libmicrohttpd/):** 用作内嵌 HTTP 服务器库。由 GNU 以 LGPL 许可;本载荷以 SDK 提供的静态库链接它。
|
||||
- **[ps5-payload-dev/sdk](https://github.com/ps5-payload-dev/sdk):** 载荷构建基础。许可证:GPLv3+。
|
||||
- **[etaHEN](https://github.com/etaHEN/etaHEN):** 退出前用于返回 PS5 主屏的 ShellUI URI 导航。许可证:GPLv3。
|
||||
- **[ezremote](https://github.com/cy33hc/ps5-ezremote-client):** 预览 PKG 信息。许可证:GPLv2。
|
||||
- **[ezremote](https://github.com/cy33hc/ps5-ezremote-client):** PKG 预览功能的参考出处。许可证:**GPL-2.0-only** —— 其源文件未声明 "or later",因此**无法**与本项目的 GPL-3.0 代码组合。**未取其任何代码**:`src/pkg_info.c` 是独立的 C99 实现(它还负责 `.pkg` 条目表与 `param.json` 字段,而 ezremote 根本没有 `.pkg` 解析器;JSON 走的是 `src/json_util.c` 里自写的分词器,不是 json-c)。详见 `docs/REWRITE-FEASIBILITY.md` §2.2。
|
||||
- **[zlib-ng/minizip-ng](https://github.com/zlib-ng/minizip-ng):** `/api/extract` 端点使用的 ZIP 读取器。vendored 于 `third_party/minizip-ng/`。许可证:zlib。
|
||||
- **[zlib](https://www.zlib.net/):** minizip-ng 的压缩后端。vendored 于 `third_party/zlib/`。许可证:zlib。
|
||||
- **[rarlab UnRAR (opello/unrar)](https://github.com/opello/unrar):** v1.9 起 `/api/extract` 使用的 RAR 读取器(7.20.1)。vendored 于 `third_party/unrar7/`。许可证:UnRAR 免费软件许可。
|
||||
- **[rarlab UnRAR](https://www.rarlab.com/rar_add.htm)** —— v1.9 起 `/api/extract` 使用的 RAR 读取器(7.20.1,RARDLL 源文件集)。vendored 于 `third_party/unrar7/`。许可证:**UnRAR 免费软件许可**(见 `third_party/unrar7/license.txt`)。注意这是受限许可而非 FLOSS 许可:它允许用源码处理 RAR 归档,但禁止用它开发 RAR 兼容的压缩器。
|
||||
- **[opello/unrar](https://github.com/opello/unrar)** —— vendored 的 rarlab 源码取自该镜像(提交 `97e1780`)。
|
||||
- **[LZMA SDK](https://www.7-zip.org/sdk.html)**(7-Zip / Igor Pavlov)—— v1.9.1 起 `/api/extract` 使用的 7z 解码器,以解码子集形式 vendored 于 `third_party/7z/`。许可证:公有领域。
|
||||
- **[DrMcCoy/dmc_unrar](https://github.com/DrMcCoy/dmc_unrar)** —— 仅 v1.8 使用的 RAR 引擎,v1.9 被 rarlab UnRAR 取代(它无法解码 RAR5「v6」归档,也不支持多卷)。已从树中移除;其许可证为 GPL-2.0-or-later。
|
||||
|
||||
## 许可证
|
||||
|
||||
@@ -405,7 +501,9 @@ bash run-sevenz-tests.sh # 7z 套件(需 MinGW gcc 与 7-Zip 二
|
||||
|
||||
第三方项目保留各自许可证。请勿在未保留相应许可证声明的情况下,将署名项目的资源或源码复制到其它发行版中。
|
||||
|
||||
若分发二进制,除本项目 GPL 许可外,还需遵守 `libmicrohttpd` 的 LGPL 条款。vendored 的 `zlib` 与 `minizip-ng` 源码以 zlib 许可分发;再分发用此特性构建的二进制时,保留 `third_party/zlib/LICENSE` 与 `third_party/minizip-ng/LICENSE` 中的版权声明。vendored 的 `unrar7`(RAR 引擎)依 UnRAR 免费软件许可分发;再分发用 v1.9 或更高版本构建的二进制时,保留 `third_party/unrar7/license.txt` 中的声明,且不得用其开发 RAR 兼容归档器或重新实现 RAR 压缩算法。
|
||||
若分发二进制,除本项目 GPL 许可外,还需遵守 `libmicrohttpd` 的 LGPL 条款。vendored 的 `zlib` 与 `minizip-ng` 源码以 zlib 许可分发;再分发用此特性构建的二进制时,保留 `third_party/zlib/LICENSE` 与 `third_party/minizip-ng/LICENSE` 中的版权声明。
|
||||
|
||||
vendored 的 `third_party/unrar7/`(rarlab UnRAR —— `src/rar_extract.c` 背后的 RAR 引擎)**不是** GPL:它依 UnRAR 免费软件许可分发(见 `third_party/unrar7/license.txt`),该许可禁止用它开发 RAR 兼容的压缩器。再分发时请保留该声明与限制。`THIRD_PARTY_NOTICES` 载有逐库完整摘要。
|
||||
|
||||
## 免责声明
|
||||
|
||||
|
||||
@@ -64,3 +64,27 @@ notice and this list of conditions are retained. unrar is distributed under
|
||||
its own freeware terms. The LZMA SDK (section 4) is in the public domain and
|
||||
carries no conditions. See the individual LICENSE / license.txt files in
|
||||
each `third_party/` subdirectory for the complete terms.
|
||||
|
||||
|
||||
Reference-only projects (NOT vendored)
|
||||
--------------------------------------
|
||||
|
||||
The README Credits section lists a second class of project: ones this payload
|
||||
was *written with reference to*, whose code is not present in this repository
|
||||
and which therefore impose no obligations here. Keeping the two classes apart
|
||||
matters, because one of them is licence-incompatible with this codebase:
|
||||
|
||||
* websrv, ftpsrv, ps5-payload-manager, ps5-payload-dev/sdk, etaHEN (GPL-3.0 / 3.0+)
|
||||
* zftpd (MIT)
|
||||
* libmicrohttpd (LGPL; linked as the SDK-provided static library)
|
||||
* ezremote (GPL-2.0-only)
|
||||
|
||||
For ezremote specifically: GPL-2.0-only cannot be combined with this project's
|
||||
GPL-3.0, so it matters that no code was taken from it. The PKG-preview code in
|
||||
`src/pkg_info.c` is an independent implementation -- the two share only the
|
||||
on-disk SFO format facts, which no implementation can avoid. Line-by-line
|
||||
comparison and reasoning: `docs/REWRITE-FEASIBILITY.md` section 2.2.
|
||||
|
||||
`owendswang/ps5-web-file-manager` (GPL-3.0) is not a mere reference: it is the
|
||||
fork this project descends from, and the web UI, the task model and the PS5
|
||||
packaging all originate there.
|
||||
+64
-8
@@ -44,7 +44,7 @@
|
||||
<button id="downloadBtn" class="remote-only" data-i18n="download"></button>
|
||||
<button id="deleteBtn" class="danger" data-i18n="delete"></button>
|
||||
<button id="installPkgBtn" class="install-action" data-i18n="install" hidden></button>
|
||||
<button id="extractBtn" class="extract-action" data-i18n="extractToCurrent" hidden></button>
|
||||
<button id="extractBtn" class="extract-action" data-i18n="extract"></button>
|
||||
<button id="pasteBtn" class="paste-action" hidden>
|
||||
<span id="pasteVerb" data-i18n="paste"></span>
|
||||
<span id="pasteName" class="paste-name"></span><span id="pasteCount" class="paste-count"></span>
|
||||
@@ -54,12 +54,12 @@
|
||||
</div>
|
||||
<div class="tool-right">
|
||||
<button id="refreshBtn" data-i18n="refresh"></button>
|
||||
<div id="uploadMenu" class="split-button remote-only">
|
||||
<button id="uploadBtn" class="split-main" data-i18n="upload"></button>
|
||||
<button id="uploadMenuBtn" class="split-arrow" type="button" aria-label="Upload menu"></button>
|
||||
<div class="split-menu">
|
||||
<button id="uploadFolderBtn" type="button" data-i18n="uploadFolder"></button>
|
||||
<button id="uploadAndExtractBtn" type="button" data-i18n="extractUpload"></button>
|
||||
<div id="uploadGroup" class="upload-menu remote-only">
|
||||
<button id="uploadBtn" class="upload-main" data-i18n="upload"
|
||||
aria-haspopup="menu" aria-expanded="false"></button>
|
||||
<div id="uploadMenu" class="upload-menu-list" role="menu" hidden>
|
||||
<button id="uploadFilesItem" type="button" role="menuitem" data-i18n="uploadFiles"></button>
|
||||
<button id="uploadFolderItem" type="button" role="menuitem" data-i18n="uploadFolder"></button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="newTextBtn" data-i18n="newText"></button>
|
||||
@@ -68,7 +68,6 @@
|
||||
</section>
|
||||
<input id="uploadFiles" type="file" multiple hidden>
|
||||
<input id="uploadFolder" type="file" multiple webkitdirectory hidden>
|
||||
<input id="uploadZip" type="file" accept=".zip,.rar,application/zip,application/x-zip-compressed,application/vnd.rar,application/x-rar-compressed" hidden>
|
||||
|
||||
<section id="content" class="content">
|
||||
<table>
|
||||
@@ -99,6 +98,7 @@
|
||||
|
||||
<footer class="status">
|
||||
<div id="statusText" data-i18n="ready"></div>
|
||||
<div id="dropHint" class="status-hint remote-only" data-i18n="dropUploadHint" hidden></div>
|
||||
<div id="versionText" class="version-text"></div>
|
||||
</footer>
|
||||
</main>
|
||||
@@ -142,6 +142,58 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="extractDialog" class="text-editor-overlay" hidden>
|
||||
<section class="text-editor-panel extract-panel" role="dialog" aria-modal="true" aria-labelledby="extractTitle">
|
||||
<h2 id="extractTitle" class="extract-title" data-i18n="extractDialogTitle"></h2>
|
||||
<div class="extract-row">
|
||||
<span class="extract-label" data-i18n="extractArchive"></span>
|
||||
<span id="extractArchiveName" class="extract-archive-name"></span>
|
||||
</div>
|
||||
<label class="extract-field">
|
||||
<span data-i18n="extractDestination"></span>
|
||||
<span class="extract-dest-row">
|
||||
<input id="extractDestInput" class="extract-dest-input" type="text"
|
||||
spellcheck="false" autocomplete="off" autocapitalize="off">
|
||||
<button id="extractBrowseBtn" type="button" class="secondary" data-i18n="browseFolder"></button>
|
||||
</span>
|
||||
</label>
|
||||
<label class="extract-field">
|
||||
<span data-i18n="extractConflict"></span>
|
||||
<select id="extractConflictSelect" class="extract-select">
|
||||
<option value="fail" data-i18n="extractConflictFail"></option>
|
||||
<option value="overwrite" data-i18n="extractConflictOverwrite"></option>
|
||||
<option value="merge" data-i18n="extractConflictMerge"></option>
|
||||
</select>
|
||||
</label>
|
||||
<label id="extractPasswordField" class="extract-field" hidden>
|
||||
<span data-i18n="extractPasswordLabel"></span>
|
||||
<input id="extractPasswordInput" class="extract-password-input" type="text"
|
||||
spellcheck="false" autocomplete="off" autocapitalize="off">
|
||||
</label>
|
||||
<label class="extract-field extract-large-field">
|
||||
<input id="extractLargeCheck" type="checkbox">
|
||||
<span data-i18n="extractLargeMode"></span>
|
||||
</label>
|
||||
<div class="text-editor-actions">
|
||||
<button id="extractCancelBtn" type="button" class="secondary" data-i18n="cancel"></button>
|
||||
<button id="extractConfirmBtn" type="button" class="primary" data-i18n="extract"></button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="folderPickerOverlay" class="text-editor-overlay" hidden>
|
||||
<section class="text-editor-panel folder-picker-panel" role="dialog" aria-modal="true" aria-labelledby="folderPickerTitle">
|
||||
<h2 id="folderPickerTitle" class="folder-picker-title" data-i18n="selectFolder"></h2>
|
||||
<div id="folderPickerPath" class="folder-picker-path"></div>
|
||||
<div id="folderPickerList" class="folder-picker-list"></div>
|
||||
<div class="text-editor-actions">
|
||||
<button id="folderPickerCancelBtn" type="button" class="secondary" data-i18n="cancel"></button>
|
||||
<button id="folderPickerUpBtn" type="button" class="secondary" data-i18n="parent"></button>
|
||||
<button id="folderPickerOkBtn" type="button" class="primary" data-i18n="selectHere"></button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="permissionOverlay" class="permission-overlay" hidden>
|
||||
<section class="permission-panel" role="dialog" aria-modal="true" aria-labelledby="permissionTitle">
|
||||
<h2 id="permissionTitle" class="permission-title" data-i18n="permissionsTitle"></h2>
|
||||
@@ -186,6 +238,10 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="dropUploadOverlay" class="drop-upload-overlay remote-only" hidden>
|
||||
<div class="drop-upload-message" data-i18n="dropUpload"></div>
|
||||
</div>
|
||||
|
||||
<div id="taskOverlay" class="task-overlay" hidden>
|
||||
<div class="task-panel">
|
||||
<div id="tasks" class="tasks"></div>
|
||||
|
||||
+24
-1
@@ -1,11 +1,15 @@
|
||||
window.WFM_LANG = {
|
||||
appTitle: "PS5 Web File Manager",
|
||||
versionTooltip: "Modified build by LisherSong (upstream releases carry no M suffix)",
|
||||
copy: "Copy",
|
||||
move: "Move",
|
||||
delete: "Delete",
|
||||
download: "Download",
|
||||
upload: "Upload",
|
||||
uploadFiles: "Upload Files",
|
||||
uploadFolder: "Upload Folder",
|
||||
dropUploadHint: "Drag files or folders into this window to upload",
|
||||
dropUpload: "Release to upload into this folder",
|
||||
copying: "Copying",
|
||||
moving: "Moving",
|
||||
deleting: "Deleting",
|
||||
@@ -102,13 +106,31 @@ window.WFM_LANG = {
|
||||
extractConfirm: "Extract {name} to {path}?",
|
||||
extractOverwriteAsk: "If a file or folder with the same name already exists in the target:\n\nOK = overwrite same-name files (folders still merge)\nCancel = fail if the target already exists",
|
||||
extractPasswordAsk: "This archive may be encrypted (e.g. 7zAES).\n\nEnter the password to extract, or leave it empty to try without one.",
|
||||
extractPasswordFirstAsk: "This archive is encrypted.\n\nEnter the password to extract, or cancel to stop.",
|
||||
extractPasswordRetryAsk: "This archive is encrypted and the password did not work.\n\nEnter the password to try again, or cancel to stop.",
|
||||
extractUploadConfirm: "Upload and extract {name}?\n\nTarget folder: {path}\nThe uploaded archive will be deleted after success.",
|
||||
extractUploadAsk: "{name} is an archive.\n\nOK: upload and extract here\nCancel: upload only",
|
||||
extractStarted: "Extraction started: {name}",
|
||||
extractDone: "Extraction complete: {name}",
|
||||
extractProgress: "{done} / {total} files",
|
||||
extractLargeAsk: "The archive looks large ({size}). Enable the large-file profile?\n\nOK = yes (single file up to 1 TiB, archive total up to 4 TiB)\nCancel = default limits (single file 512 GiB, archive total 2 TiB); this archive may be rejected",
|
||||
extractLargeActive: "Large-file profile is enabled for this task",
|
||||
extractSelectArchive: "Select one archive to extract (ZIP / RAR / 7z)",
|
||||
extractOneAtATime: "Only one archive can be extracted at a time",
|
||||
extractSelectMainVolume: "Please select the main volume (.rar or .part01.rar)",
|
||||
extractDialogTitle: "Extract archive",
|
||||
extractArchive: "Archive",
|
||||
extractDestination: "Extract into (destination directory)",
|
||||
browseFolder: "Browse folders",
|
||||
extractConflict: "File conflict policy",
|
||||
extractConflictFail: "Fail if name exists",
|
||||
extractConflictOverwrite: "Overwrite files",
|
||||
extractConflictMerge: "Merge directories",
|
||||
extractPasswordLabel: "Password (7z encryption; leave empty if none)",
|
||||
extractLargeMode: "Large-file mode (max 1 TiB file / 4 TiB total)",
|
||||
extractDestEmpty: "Please enter a destination directory",
|
||||
selectFolder: "Select folder",
|
||||
selectHere: "Select this folder",
|
||||
extractArchivePending: "Preparing to extract {name}",
|
||||
sameSourceTarget: "Source and destination are the same. Cannot {label} {name}",
|
||||
removeConflictFirst: "A {existingType} named {name} already exists. To {label} this {sourceType}, delete that {existingType} first.",
|
||||
@@ -180,7 +202,7 @@ window.WFM_LANG = {
|
||||
err_destination_must_be_directory: "Destination must be a folder for multiple items",
|
||||
err_extract_open_failed: "Cannot open archive: {arg}",
|
||||
err_extract_corrupt: "Archive is corrupt or incomplete: {arg}",
|
||||
err_extract_unsupported: "Unsupported archive (only plain ZIP, single-volume RAR, and 7z are supported): {arg}",
|
||||
err_extract_unsupported: "Unsupported archive (only .zip, .rar and .7z are accepted, including their multi-volume and encrypted forms; this one uses a feature this build cannot handle): {arg}",
|
||||
err_extract_password: "Wrong password or the archive is not encrypted with the one supplied: {arg}",
|
||||
err_extract_unsafe_name: "Archive contains an unsafe path: {arg}",
|
||||
err_extract_special_entry: "Archive contains an unsupported special file: {arg}",
|
||||
@@ -191,6 +213,7 @@ window.WFM_LANG = {
|
||||
err_extract_ratio: "Suspicious compression ratio (possible zip bomb): {arg}",
|
||||
err_extract_too_deep: "Directory nesting is too deep: {arg}",
|
||||
err_extract_name_too_long: "File name or path is too long: {arg}",
|
||||
err_extract_dict_too_large: "The archive needs a larger dictionary than this device can handle: {arg}",
|
||||
err_extract_conflict: "A file or folder with the same name already exists: {arg}",
|
||||
err_extract_io: "Extraction read/write failed: {arg}",
|
||||
err_extract_crc: "CRC check failed: {arg}",
|
||||
|
||||
+24
-1
@@ -1,11 +1,15 @@
|
||||
window.WFM_LANG = {
|
||||
appTitle: "PS5 Web File Manager",
|
||||
versionTooltip: "本版为 LisherSong 改版(上游原版无 M 后缀)",
|
||||
copy: "复制",
|
||||
move: "移动",
|
||||
delete: "删除",
|
||||
download: "下载",
|
||||
upload: "上传",
|
||||
uploadFiles: "上传文件",
|
||||
uploadFolder: "上传文件夹",
|
||||
dropUploadHint: "可直接把文件或文件夹拖进窗口上传",
|
||||
dropUpload: "松开即上传到当前目录",
|
||||
copying: "复制",
|
||||
moving: "移动",
|
||||
deleting: "删除",
|
||||
@@ -102,13 +106,31 @@ window.WFM_LANG = {
|
||||
extractConfirm: "解压 {name} 到 {path}?",
|
||||
extractOverwriteAsk: "若目标已存在同名文件或目录:\n\n确定 = 覆盖同名文件(目录仍会合并)\n取消 = 若目标已存在则失败",
|
||||
extractPasswordAsk: "此压缩包可能加密了(如 7zAES)。\n\n输入密码后解压,留空则尝试无密码解压。",
|
||||
extractPasswordFirstAsk: "此压缩包已加密。\n\n输入密码后解压,取消则停止解压。",
|
||||
extractPasswordRetryAsk: "此压缩包已加密,密码不正确。\n\n输入密码重试,取消则停止解压。",
|
||||
extractUploadConfirm: "上传并解压 {name}?\n\n目标目录:{path}\n成功后将删除上传的压缩包。",
|
||||
extractUploadAsk: "这是压缩包 {name}。\n\n确定:上传后自动解压到当前目录\n取消:仅上传,不解压",
|
||||
extractStarted: "已开始解压 {name}",
|
||||
extractDone: "解压完成:{name}",
|
||||
extractProgress: "{done} / {total} 个文件",
|
||||
extractLargeAsk: "压缩包体积较大({size}),是否启用「大文件模式」?\n\n确定 = 启用(单文件最大 1 TiB / 总解压最大 4 TiB)\n取消 = 默认限制(单文件 512 GiB / 总解压 2 TiB),可能拒绝此压缩包",
|
||||
extractLargeActive: "此任务已启用大文件模式",
|
||||
extractSelectArchive: "选中一个压缩包后才能解压(ZIP / RAR / 7z)",
|
||||
extractOneAtATime: "一次只能解压一个压缩包",
|
||||
extractSelectMainVolume: "请改选主卷(如 .rar 或 .part01.rar)",
|
||||
extractDialogTitle: "解压压缩包",
|
||||
extractArchive: "压缩包",
|
||||
extractDestination: "解压到(目标目录)",
|
||||
browseFolder: "浏览目录",
|
||||
extractConflict: "文件冲突策略",
|
||||
extractConflictFail: "同名则失败",
|
||||
extractConflictOverwrite: "覆盖同名文件",
|
||||
extractConflictMerge: "合并目录",
|
||||
extractPasswordLabel: "密码(7z 加密,可留空)",
|
||||
extractLargeMode: "大文件模式(单文件上限 1 TiB / 总计 4 TiB)",
|
||||
extractDestEmpty: "请填写目标目录",
|
||||
selectFolder: "选择目录",
|
||||
selectHere: "选择此目录",
|
||||
extractArchivePending: "正在准备解压 {name}",
|
||||
sameSourceTarget: "源和目标相同,不能{label} {name}",
|
||||
removeConflictFirst: "目标中已存在同名{existingType} {name}。要{label}{sourceType},请先删除该{existingType}才能继续。",
|
||||
@@ -180,7 +202,7 @@ window.WFM_LANG = {
|
||||
err_destination_must_be_directory: "多个项目的目标必须是目录",
|
||||
err_extract_open_failed: "无法打开压缩包: {arg}",
|
||||
err_extract_corrupt: "压缩包损坏或不完整: {arg}",
|
||||
err_extract_unsupported: "不支持的压缩包(仅支持未加密的普通 ZIP、单卷 RAR,以及 7z):{arg}",
|
||||
err_extract_unsupported: "不支持的压缩包(只认 .zip / .rar / .7z,含它们的分卷与加密版本;这个包用了本机处理不了的特性):{arg}",
|
||||
err_extract_password: "密码错误,或压缩包未使用所提供的密码加密: {arg}",
|
||||
err_extract_unsafe_name: "压缩包包含不安全的路径: {arg}",
|
||||
err_extract_special_entry: "压缩包包含不支持的特殊文件: {arg}",
|
||||
@@ -191,6 +213,7 @@ window.WFM_LANG = {
|
||||
err_extract_ratio: "压缩比异常(疑似压缩炸弹): {arg}",
|
||||
err_extract_too_deep: "目录层级过深: {arg}",
|
||||
err_extract_name_too_long: "文件名或路径过长: {arg}",
|
||||
err_extract_dict_too_large: "压缩包需要的字典超出本机可承受范围: {arg}",
|
||||
err_extract_conflict: "目标已存在同名文件或目录: {arg}",
|
||||
err_extract_io: "解压读写失败: {arg}",
|
||||
err_extract_crc: "CRC 校验失败: {arg}",
|
||||
|
||||
+270
-39
@@ -250,7 +250,7 @@ body {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.split-button {
|
||||
.upload-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
@@ -259,53 +259,98 @@ body {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.split-button .split-main {
|
||||
.upload-menu .upload-main {
|
||||
min-width: 82px;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.split-button .split-arrow {
|
||||
min-width: 38px;
|
||||
width: 38px;
|
||||
padding: 0;
|
||||
border-left: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.split-arrow::before {
|
||||
/* The caret is what tells the user this button opens a list. Without it the
|
||||
button looks like a plain one-shot action, which is exactly how the old
|
||||
file/folder split was misread. */
|
||||
.upload-main::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-left: 10px;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 7px solid #f2f5f7;
|
||||
}
|
||||
|
||||
.split-menu {
|
||||
.upload-menu-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
display: none;
|
||||
min-width: 150px;
|
||||
padding-top: 6px;
|
||||
z-index: 700;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px;
|
||||
border: 1px solid #46515f;
|
||||
border-radius: 8px;
|
||||
background: #1b2128;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, .5);
|
||||
}
|
||||
|
||||
.split-menu button {
|
||||
width: 100%;
|
||||
min-width: 150px;
|
||||
margin: 0;
|
||||
.upload-menu-list button {
|
||||
height: 46px;
|
||||
min-height: 46px;
|
||||
min-width: 176px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.split-button:hover .split-menu,
|
||||
.split-button.open .split-menu {
|
||||
display: block;
|
||||
/* Menu rows highlight by fill only. The generic button ring (3px at offset
|
||||
2px) is sized for a 54px toolbar button: on a 46px row it clears the
|
||||
panel's 6px padding, overlaps the neighbouring row, and its corners do not
|
||||
follow the row's radius, so the highlight reads as a broken shape rather
|
||||
than a selected row. The panel's own hover fill had also never applied --
|
||||
the generic `button:not(.row-action):hover:not(:disabled)` rule has the
|
||||
same specificity (0,3,1) and comes later in this file, so it won and hover
|
||||
and focus ended up two different colours. Scoping both rules to the panel
|
||||
settles the cascade, and the keyboard cue is drawn inside the row where it
|
||||
cannot cross the panel edge. */
|
||||
#uploadMenu button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#uploadMenu button:hover:not(:disabled),
|
||||
#uploadMenu button:focus-visible {
|
||||
background: #2b343e;
|
||||
}
|
||||
|
||||
#uploadMenu button:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px #6fb1ff;
|
||||
}
|
||||
|
||||
/* The status can be a long "uploading 3/12: some-very-long-name.zip". The footer
|
||||
is a fixed 46px row, so a wrapped status used to spill out of it; clamping it
|
||||
to one line with an ellipsis is what keeps it, the hint and the version on the
|
||||
same row at every width. */
|
||||
#statusText {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A hint that has to fit next to the status line and the version, so it shrinks
|
||||
with an ellipsis instead of pushing either of them around. */
|
||||
.status-hint {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
margin: 0 16px;
|
||||
overflow: hidden;
|
||||
color: #6f7a84;
|
||||
font-size: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -396,7 +441,10 @@ button.install-action:hover:not(:disabled) {
|
||||
}
|
||||
|
||||
.extract-action {
|
||||
min-width: 88px;
|
||||
/* Matches the width of the other two-character verbs (copy/move/delete): the
|
||||
button is on screen at all times now, so it has to line up with them
|
||||
instead of being a special case that appears only on selection. */
|
||||
min-width: 96px;
|
||||
border-color: #2a6b66;
|
||||
background: #17302e;
|
||||
color: #a8e6df;
|
||||
@@ -406,6 +454,16 @@ button.extract-action:hover:not(:disabled) {
|
||||
background: #1d3f3c;
|
||||
}
|
||||
|
||||
/* The extract button is always on screen and only greys out when the selection
|
||||
cannot be extracted, so the reason has to be readable. `button:disabled`
|
||||
sets `pointer-events: none`, which makes the button un-hoverable and swallows
|
||||
its `title` tooltip -- the same fix the parent-directory button already uses.
|
||||
Clicks stay dead either way: the disabled attribute blocks activation
|
||||
regardless of hit-testing. */
|
||||
.extract-action:disabled {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.paste-count {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -901,6 +959,145 @@ input[type="checkbox"] {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* --- Extract dialog + folder picker --- */
|
||||
.extract-panel {
|
||||
width: 600px;
|
||||
max-width: calc(100vw - 48px);
|
||||
}
|
||||
|
||||
.extract-title {
|
||||
margin: 0 0 16px;
|
||||
color: #edf0f2;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.extract-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.extract-label {
|
||||
flex: 0 0 auto;
|
||||
color: #8f9ba6;
|
||||
}
|
||||
|
||||
.extract-archive-name {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
color: #d9e0e6;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.extract-field {
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.extract-field > span:first-child,
|
||||
.extract-large-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.extract-field > span[data-i18n] {
|
||||
color: #8f9ba6;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.extract-dest-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.extract-dest-input,
|
||||
.extract-password-input,
|
||||
.extract-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 18px;
|
||||
font-family: inherit;
|
||||
color: #d9e0e6;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.extract-dest-input:focus,
|
||||
.extract-password-input:focus,
|
||||
.extract-select:focus {
|
||||
outline: none;
|
||||
border-color: #6fb1ff;
|
||||
}
|
||||
|
||||
.extract-large-field {
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.folder-picker-panel {
|
||||
width: 600px;
|
||||
max-width: calc(100vw - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.folder-picker-title {
|
||||
margin: 0 0 14px;
|
||||
color: #edf0f2;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.folder-picker-path {
|
||||
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
font-size: 16px;
|
||||
color: #aab4be;
|
||||
margin-bottom: 10px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.folder-picker-list {
|
||||
max-height: calc(100vh - 320px);
|
||||
min-height: 120px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.folder-picker-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-size: 18px;
|
||||
color: #d9e0e6;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.folder-picker-row:hover,
|
||||
.folder-picker-row:focus {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.folder-picker-empty {
|
||||
padding: 14px 12px;
|
||||
color: #8f9ba6;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.permission-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -1057,6 +1254,38 @@ input[type="checkbox"] {
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
/* Shown while a file drag hovers the window. pointer-events stays off so the
|
||||
drop still lands on the window listener rather than on this element, and the
|
||||
z-index sits above the task overlay so the hint is visible during a running
|
||||
task (the drop itself is ignored while busy). */
|
||||
.drop-upload-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 19000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(8, 11, 14, .88);
|
||||
box-shadow: inset 0 0 0 4px #6fb1ff;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drop-upload-message {
|
||||
max-width: calc(100% - 48px);
|
||||
padding: 26px 34px;
|
||||
border: 1px solid #46515f;
|
||||
border-radius: 8px;
|
||||
background: #1b2026;
|
||||
box-shadow: 0 18px 54px rgba(0, 0, 0, .5);
|
||||
color: #edf0f2;
|
||||
font-size: 24px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.task-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -1334,28 +1563,30 @@ input[type="checkbox"] {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.split-button {
|
||||
.upload-menu {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.split-button .split-main {
|
||||
.upload-menu .upload-main {
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.split-button .split-arrow {
|
||||
min-width: 34px;
|
||||
width: 34px;
|
||||
.upload-menu-list {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.split-menu {
|
||||
min-width: 126px;
|
||||
padding-top: 4px;
|
||||
.upload-menu-list button {
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
min-width: 148px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.split-menu button {
|
||||
min-width: 126px;
|
||||
.status-hint {
|
||||
margin: 0 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.paste-action {
|
||||
|
||||
+494
-76
@@ -39,7 +39,8 @@ let L = {};
|
||||
// /api/version, which reports the build's VERSION_TAG -- see loadVersion().
|
||||
// Keeping a literal here used to be the only source, and it inevitably
|
||||
// drifted (the footer said "v1.9" throughout the v1.9.1 release).
|
||||
const APP_VERSION_FALLBACK = "v1.9.1";
|
||||
// The trailing "M" is the fork marker; see VERSION_TAG in the Makefile.
|
||||
const APP_VERSION_FALLBACK = "v1.9.3M";
|
||||
const LAST_PATH_KEY = "ps5-web-file-mgr:last-path";
|
||||
const SORT_KEY = "ps5-web-file-mgr:list-sort";
|
||||
const LOADING_DISPLAY_DELAY = 250;
|
||||
@@ -72,14 +73,14 @@ const installPkgBtn = document.getElementById("installPkgBtn");
|
||||
const extractBtn = document.getElementById("extractBtn");
|
||||
const clearClipboardBtn = document.getElementById("clearClipboardBtn");
|
||||
const downloadBtn = document.getElementById("downloadBtn");
|
||||
const uploadMenuEl = document.getElementById("uploadMenu");
|
||||
const uploadBtn = document.getElementById("uploadBtn");
|
||||
const uploadMenuBtn = document.getElementById("uploadMenuBtn");
|
||||
const uploadFolderBtn = document.getElementById("uploadFolderBtn");
|
||||
const uploadAndExtractBtn = document.getElementById("uploadAndExtractBtn");
|
||||
const uploadMenuEl = document.getElementById("uploadMenu");
|
||||
const uploadFilesItemEl = document.getElementById("uploadFilesItem");
|
||||
const uploadFolderItemEl = document.getElementById("uploadFolderItem");
|
||||
const dropHintEl = document.getElementById("dropHint");
|
||||
const uploadFilesEl = document.getElementById("uploadFiles");
|
||||
const uploadFolderEl = document.getElementById("uploadFolder");
|
||||
const uploadZipEl = document.getElementById("uploadZip");
|
||||
const dropUploadOverlayEl = document.getElementById("dropUploadOverlay");
|
||||
const initLoadingEl = document.getElementById("initLoading");
|
||||
const exitBtn = document.getElementById("exitBtn");
|
||||
const textEditorOverlayEl = document.getElementById("textEditorOverlay");
|
||||
@@ -99,6 +100,22 @@ const pkgInfoTitleEl = document.getElementById("pkgInfoTitle");
|
||||
const pkgInfoFieldsEl = document.getElementById("pkgInfoFields");
|
||||
const pkgInfoCloseBtn = document.getElementById("pkgInfoCloseBtn");
|
||||
const pkgInfoInstallBtn = document.getElementById("pkgInfoInstallBtn");
|
||||
const extractDialogEl = document.getElementById("extractDialog");
|
||||
const extractArchiveNameEl = document.getElementById("extractArchiveName");
|
||||
const extractDestInputEl = document.getElementById("extractDestInput");
|
||||
const extractBrowseBtnEl = document.getElementById("extractBrowseBtn");
|
||||
const extractConflictSelectEl = document.getElementById("extractConflictSelect");
|
||||
const extractPasswordFieldEl = document.getElementById("extractPasswordField");
|
||||
const extractPasswordInputEl = document.getElementById("extractPasswordInput");
|
||||
const extractLargeCheckEl = document.getElementById("extractLargeCheck");
|
||||
const extractCancelBtnEl = document.getElementById("extractCancelBtn");
|
||||
const extractConfirmBtnEl = document.getElementById("extractConfirmBtn");
|
||||
const folderPickerOverlayEl = document.getElementById("folderPickerOverlay");
|
||||
const folderPickerPathEl = document.getElementById("folderPickerPath");
|
||||
const folderPickerListEl = document.getElementById("folderPickerList");
|
||||
const folderPickerCancelBtnEl = document.getElementById("folderPickerCancelBtn");
|
||||
const folderPickerUpBtnEl = document.getElementById("folderPickerUpBtn");
|
||||
const folderPickerOkBtnEl = document.getElementById("folderPickerOkBtn");
|
||||
const permissionOverlayEl = document.getElementById("permissionOverlay");
|
||||
const permissionPathEl = document.getElementById("permissionPath");
|
||||
const permissionModeEl = document.getElementById("permissionMode");
|
||||
@@ -152,7 +169,15 @@ function t(key, params) {
|
||||
}
|
||||
|
||||
function backendErrorText(code, arg, fallback) {
|
||||
if (!code) return fallback || t("backendError");
|
||||
if (!code) return decodeFsText(fallback) || t("backendError");
|
||||
// Names arrive byte-mapped (see decodeFsText below): the server escapes every
|
||||
// byte >= 0x80 as \u00XX so that names which are not valid UTF-8 -- a GBK entry
|
||||
// name inside a ZIP, say -- survive the JSON round trip unchanged. The listing
|
||||
// has always translated them back for display; an error message must do the
|
||||
// same, or the entry name is unreadable at the exact moment the user needs to
|
||||
// read it (which is how "解压失败: ... â®â¡.psd" happened).
|
||||
arg = decodeFsText(arg);
|
||||
fallback = decodeFsText(fallback);
|
||||
const params = { path: arg || "", arg: arg || "" };
|
||||
if (code === "no_space") {
|
||||
const parts = String(arg || "").split(",");
|
||||
@@ -180,11 +205,18 @@ function applyStaticText() {
|
||||
if (isPlayStationBrowser()) {
|
||||
for (const el of document.querySelectorAll(".remote-only")) el.hidden = true;
|
||||
}
|
||||
// The hint starts hidden so the console browser never flashes it; the browser
|
||||
// that can actually drag (and therefore has the upload button) shows it.
|
||||
dropHintEl.hidden = isPlayStationBrowser();
|
||||
exitBtn.title = t("exit");
|
||||
exitBtn.setAttribute("aria-label", t("exit"));
|
||||
parentBtn.title = t("parent");
|
||||
parentBtn.setAttribute("aria-label", t("parent"));
|
||||
versionEl.textContent = APP_VERSION_FALLBACK;
|
||||
// "v1.9.3M" is opaque to anyone who has not read the release notes, so spell
|
||||
// out what the trailing M means on hover. loadVersion() only rewrites the
|
||||
// text, never the tooltip, so this survives the /api/version round trip.
|
||||
versionEl.title = t("versionTooltip");
|
||||
if (initLoadingEl) initLoadingEl.hidden = true;
|
||||
}
|
||||
|
||||
@@ -329,6 +361,32 @@ function decodeFsBytes(bytes, fallback) {
|
||||
}
|
||||
}
|
||||
|
||||
// The inverse of decodeFsText(): turn a real Unicode name -- one that came from
|
||||
// a File object or a prompt, not from the server -- into the byte-mapped form
|
||||
// the server expects. Anything that crosses over has to be in one convention,
|
||||
// because the server byte-repairs a path only when every non-ASCII code point in
|
||||
// it is <= 0xFF: a single real CJK character in the same string makes
|
||||
// fs_path_value() leave the whole thing alone, and a half-repaired path finds
|
||||
// nothing on disk.
|
||||
function encodeFsText(text) {
|
||||
text = String(text || "");
|
||||
const encoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
|
||||
if (!encoder) return text;
|
||||
let bytes;
|
||||
try {
|
||||
bytes = encoder.encode(text);
|
||||
} catch (err) {
|
||||
return text;
|
||||
}
|
||||
let ascii = true;
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
if (bytes[i] >= 0x80) ascii = false;
|
||||
out += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return ascii ? text : out;
|
||||
}
|
||||
|
||||
function isUtf8Bytes(bytes) {
|
||||
for (let i = 0; i < bytes.length;) {
|
||||
const c = bytes[i];
|
||||
@@ -529,6 +587,10 @@ function handleTerminalTask(task) {
|
||||
}
|
||||
if (task.state === "failed") {
|
||||
clearTrackedTask();
|
||||
if (task.op === "extract" && task.error_code === "extract_password" &&
|
||||
retryExtractWithPassword(task)) {
|
||||
return;
|
||||
}
|
||||
const message = taskFailureMessage(task);
|
||||
setStatus(message);
|
||||
alert(message);
|
||||
@@ -822,6 +884,14 @@ function isExtractableArchive(item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The upload path knows a filename but has no directory entry to inspect, so
|
||||
// reuse the toolbar's archive test on a synthetic item. Keeps a dropped
|
||||
// .7z.001 or .part1.rar recognised as "offer to extract" without duplicating
|
||||
// the rules.
|
||||
function isExtractableName(name) {
|
||||
return isExtractableArchive({ type: "-", name: String(name || "") });
|
||||
}
|
||||
|
||||
function isSpecialDirectory(item) {
|
||||
if (item.type !== "d") return false;
|
||||
if (item.path === "/data") return true;
|
||||
@@ -865,7 +935,69 @@ function actionInstallSelectedPkgs() {
|
||||
return queuePkgInstall(selectedEntries().filter(isPkgPackage));
|
||||
}
|
||||
|
||||
async function startExtractTask(path, dstDir, conflict, removeSource, name, large, password) {
|
||||
// The engine reports a missing or wrong password as `extract_password`. ZIP and
|
||||
// RAR only find out once they have looked inside, so they are not asked up
|
||||
// front; the failed task is re-sent with whatever the user types here instead.
|
||||
// Remembering the original request is what keeps the conflict policy and the
|
||||
// large-file opt-in intact across the retry.
|
||||
//
|
||||
// The request is remembered by TASK ID, never by path. Paths travel through the
|
||||
// byte-mapped JSON described at decodeFsText(), and the server maps them back to
|
||||
// raw bytes on the way in (fs_path_value), so the string this page holds for a
|
||||
// directory and the one the task reports back differ for every name that is not
|
||||
// pure ASCII. Keying the retry by path therefore made the password prompt
|
||||
// silently never appear for archives in a non-ASCII directory -- the exact case
|
||||
// the retry exists for. The id is assigned by the server and survives the round
|
||||
// trip untouched.
|
||||
const MAX_EXTRACT_PASSWORD_RETRIES = 3;
|
||||
const extractRequestRetries = new Map();
|
||||
|
||||
function extractRetryKey(taskId) {
|
||||
return "task:" + String(taskId);
|
||||
}
|
||||
|
||||
// Entries are consumed by a retry or by a give-up, but a successful extraction
|
||||
// never asks for one again, so its entry would sit here for the life of the
|
||||
// page. Only one task can be live at a time (the server rejects a second with
|
||||
// 409), so anything older than the last few is dead weight.
|
||||
const MAX_REMEMBERED_EXTRACTS = 8;
|
||||
|
||||
function rememberExtractRequest(taskId, entry) {
|
||||
extractRequestRetries.set(extractRetryKey(taskId), entry);
|
||||
while (extractRequestRetries.size > MAX_REMEMBERED_EXTRACTS) {
|
||||
extractRequestRetries.delete(extractRequestRetries.keys().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
function retryExtractWithPassword(task) {
|
||||
const key = extractRetryKey(task.id);
|
||||
const remembered = extractRequestRetries.get(key);
|
||||
if (!remembered || remembered.attempts >= MAX_EXTRACT_PASSWORD_RETRIES) {
|
||||
extractRequestRetries.delete(key);
|
||||
return false;
|
||||
}
|
||||
// The first failure is usually "no password was given at all"; only a later
|
||||
// one is a password that did not work. Saying "the password is wrong" to
|
||||
// someone who was never asked for one is what made this flow look broken.
|
||||
const asked = prompt(t(remembered.attempts
|
||||
? "extractPasswordRetryAsk" : "extractPasswordFirstAsk"), "");
|
||||
if (!asked) {
|
||||
// Cancel or an empty box means "give up": fall through so the normal failure
|
||||
// report still explains what happened.
|
||||
extractRequestRetries.delete(key);
|
||||
return false;
|
||||
}
|
||||
// Consume the entry: the retry below registers itself under the new task id.
|
||||
extractRequestRetries.delete(key);
|
||||
clearTrackedTask();
|
||||
startExtractTask(task.src, task.dst, remembered.conflict,
|
||||
remembered.removeSource, remembered.name, remembered.large,
|
||||
asked, remembered.attempts + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function startExtractTask(path, dstDir, conflict, removeSource, name, large,
|
||||
password, attempts) {
|
||||
try {
|
||||
taskRefreshPath = cwd;
|
||||
setBusy(true);
|
||||
@@ -879,6 +1011,10 @@ async function startExtractTask(path, dstDir, conflict, removeSource, name, larg
|
||||
};
|
||||
if (password) form.password = password;
|
||||
const data = await apiForm("/api/extract", form);
|
||||
rememberExtractRequest(data.task_id, {
|
||||
conflict, removeSource, name, large: Boolean(large),
|
||||
attempts: Number(attempts || 0)
|
||||
});
|
||||
trackTask(data.task_id, "extract", false);
|
||||
clearSelection(false);
|
||||
await pollTasks();
|
||||
@@ -908,22 +1044,137 @@ function actionExtract() {
|
||||
if (busy || loadingPath) return;
|
||||
const archives = selectedEntries().filter(isExtractableArchive);
|
||||
if (archives.length !== 1) return;
|
||||
const item = archives[0];
|
||||
if (!confirm(t("extractConfirm", { name: displayName(item), path: displayPath(cwd) }))) return;
|
||||
const conflict = confirm(t("extractOverwriteAsk")) ? "overwrite" : "fail";
|
||||
const large = shouldPromptLargeMode(item.size) ? promptLargeMode(item.size) : false;
|
||||
// 7z archives can be encrypted (7zAES); ask up front so an unprotected
|
||||
// archive doesn't pay a wasted scan + folder parse. An empty submission is
|
||||
// fine — the engine returns ZIPX_ERR_PASSWORD and the user retries.
|
||||
let password = "";
|
||||
if (isSevenZipArchive(item) || isSevenZipSplitVolume(item)) {
|
||||
const asked = prompt(t("extractPasswordAsk"), "");
|
||||
if (asked === null) return;
|
||||
password = asked;
|
||||
}
|
||||
startExtractTask(item.path, cwd, conflict, false, displayName(item), large, password);
|
||||
openExtractDialog(archives[0]);
|
||||
}
|
||||
|
||||
// Opens the extract dialog. The destination defaults to the current browsing
|
||||
// directory, but the user may type any path or pick one with the folder
|
||||
// browser. Confirming hands the chosen destination to startExtractTask, so the
|
||||
// backend /api/extract dst_dir is whatever the user selected (or the current
|
||||
// path when they leave it untouched).
|
||||
function openExtractDialog(item) {
|
||||
if (busy || loadingPath) return;
|
||||
extractDialogArchive = item;
|
||||
extractArchiveNameEl.textContent = displayName(item);
|
||||
extractDestInputEl.value = cwd;
|
||||
extractConflictSelectEl.value = "fail";
|
||||
extractPasswordInputEl.value = "";
|
||||
const wantsPassword = isSevenZipArchive(item) || isSevenZipSplitVolume(item);
|
||||
extractPasswordFieldEl.hidden = !wantsPassword;
|
||||
// Large archives default to large-file mode (matching the old "ask + likely
|
||||
// yes" behaviour) but the user can still toggle it off.
|
||||
extractLargeCheckEl.checked = shouldPromptLargeMode(item.size);
|
||||
extractDialogEl.hidden = false;
|
||||
extractDestInputEl.focus();
|
||||
extractDestInputEl.select();
|
||||
}
|
||||
|
||||
function closeExtractDialog() {
|
||||
extractDialogEl.hidden = true;
|
||||
extractDialogArchive = null;
|
||||
}
|
||||
|
||||
let extractDialogArchive = null;
|
||||
|
||||
extractCancelBtnEl.addEventListener("click", closeExtractDialog);
|
||||
extractConfirmBtnEl.addEventListener("click", () => {
|
||||
const item = extractDialogArchive;
|
||||
if (!item) return;
|
||||
const dest = extractDestInputEl.value.trim();
|
||||
if (!dest) {
|
||||
alert(t("extractDestEmpty"));
|
||||
extractDestInputEl.focus();
|
||||
return;
|
||||
}
|
||||
const conflict = extractConflictSelectEl.value;
|
||||
const large = extractLargeCheckEl.checked;
|
||||
const password = extractPasswordFieldEl.hidden ? "" : extractPasswordInputEl.value;
|
||||
closeExtractDialog();
|
||||
startExtractTask(item.path, dest, conflict, false, displayName(item), large, password);
|
||||
});
|
||||
extractDialogEl.addEventListener("click", event => {
|
||||
if (event.target === extractDialogEl) closeExtractDialog();
|
||||
});
|
||||
|
||||
// --- Folder picker -------------------------------------------------------
|
||||
// A minimal directory browser reused by the extract dialog's "Browse folders"
|
||||
// button. It lists subdirectories of the current navigation path and lets the
|
||||
// user drill down or step up, then confirms the highlighted directory as the
|
||||
// destination.
|
||||
let folderPickerOnPick = null;
|
||||
let folderPickerCurrent = "/";
|
||||
|
||||
function openFolderPicker(initialPath, onPick) {
|
||||
folderPickerOnPick = onPick;
|
||||
folderPickerCurrent = initialPath || cwd;
|
||||
folderPickerOverlayEl.hidden = false;
|
||||
folderPickerRefresh();
|
||||
}
|
||||
|
||||
async function folderPickerRefresh() {
|
||||
folderPickerPathEl.textContent = folderPickerCurrent;
|
||||
folderPickerListEl.innerHTML = "";
|
||||
const loading = document.createElement("div");
|
||||
loading.className = "folder-picker-empty";
|
||||
loading.textContent = t("ready");
|
||||
folderPickerListEl.appendChild(loading);
|
||||
try {
|
||||
const data = await api("/api/list", { path: folderPickerCurrent });
|
||||
const dirs = (data.entries || [])
|
||||
.filter(e => e.type === "d")
|
||||
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
||||
folderPickerListEl.innerHTML = "";
|
||||
if (!dirs.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "folder-picker-empty";
|
||||
empty.textContent = t("empty");
|
||||
folderPickerListEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const d of dirs) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "folder-picker-row";
|
||||
row.textContent = d.name + "/";
|
||||
row.addEventListener("click", () => {
|
||||
folderPickerCurrent = d.path;
|
||||
folderPickerRefresh();
|
||||
});
|
||||
folderPickerListEl.appendChild(row);
|
||||
}
|
||||
} catch (err) {
|
||||
folderPickerListEl.innerHTML = "";
|
||||
const msg = document.createElement("div");
|
||||
msg.className = "folder-picker-empty";
|
||||
msg.textContent = err.message;
|
||||
folderPickerListEl.appendChild(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function closeFolderPicker(picked) {
|
||||
folderPickerOverlayEl.hidden = true;
|
||||
const cb = folderPickerOnPick;
|
||||
folderPickerOnPick = null;
|
||||
if (picked && cb) cb(folderPickerCurrent);
|
||||
}
|
||||
|
||||
folderPickerCancelBtnEl.addEventListener("click", () => closeFolderPicker(false));
|
||||
folderPickerOkBtnEl.addEventListener("click", () => closeFolderPicker(true));
|
||||
folderPickerUpBtnEl.addEventListener("click", () => {
|
||||
const trimmed = folderPickerCurrent.replace(/\/+$/, "");
|
||||
const idx = trimmed.lastIndexOf("/");
|
||||
folderPickerCurrent = idx <= 0 ? "/" : trimmed.slice(0, idx) || "/";
|
||||
folderPickerRefresh();
|
||||
});
|
||||
folderPickerOverlayEl.addEventListener("click", event => {
|
||||
if (event.target === folderPickerOverlayEl) closeFolderPicker(false);
|
||||
});
|
||||
extractBrowseBtnEl.addEventListener("click", () => {
|
||||
openFolderPicker(extractDestInputEl.value.trim() || cwd, p => {
|
||||
if (p) extractDestInputEl.value = p;
|
||||
});
|
||||
});
|
||||
|
||||
function openImagePreview(item) {
|
||||
if (busy) return;
|
||||
setModalBackgroundLocked(true);
|
||||
@@ -1247,7 +1498,9 @@ async function actionNewText() {
|
||||
if (input === null) return;
|
||||
const name = input.trim();
|
||||
if (!name) return;
|
||||
const item = { name, path: pathJoin(dir, name), type: "-" };
|
||||
// dir is the byte-mapped echo of the listing; name typed here is real Unicode.
|
||||
// Joining them raw would hand the server a path it cannot repair.
|
||||
const item = { name, path: pathJoin(dir, encodeFsText(name)), type: "-" };
|
||||
|
||||
try {
|
||||
setBusy(true);
|
||||
@@ -1423,25 +1676,26 @@ function renderExtractButton(items, locked) {
|
||||
const archives = items.filter(isExtractableArchive);
|
||||
const subs = items.filter(isRarSubVolume);
|
||||
|
||||
// 没有可解压档案也没有子卷 → 隐藏按钮
|
||||
if (archives.length === 0 && subs.length === 0) {
|
||||
extractBtn.hidden = true;
|
||||
extractBtn.title = "";
|
||||
extractBtn.disabled = true;
|
||||
/* The button is always on screen and merely greys out when the selection
|
||||
cannot be extracted. It used to be hidden until an archive was selected,
|
||||
which left the resting toolbar with no extract entry at all -- the same
|
||||
discoverability problem the upload button was changed for. Being disabled
|
||||
is not self-explanatory though, so the tooltip carries the reason. */
|
||||
if (archives.length === 1) {
|
||||
extractBtn.title = t("extractToCurrent") + ": " + itemTitle(archives);
|
||||
extractBtn.disabled = locked;
|
||||
return;
|
||||
}
|
||||
|
||||
extractBtn.hidden = false;
|
||||
|
||||
// 只选中子卷(比如 .part02.rar),没有对应主卷 → 按钮置灰 + 提示改选主卷
|
||||
if (archives.length !== 1) {
|
||||
extractBtn.disabled = true;
|
||||
if (archives.length > 1) {
|
||||
extractBtn.title = t("extractOneAtATime");
|
||||
} else if (subs.length > 0) {
|
||||
// 只选中子卷(比如 .part02.rar),没有对应主卷 → 置灰 + 提示改选主卷
|
||||
extractBtn.title = t("extractSelectMainVolume");
|
||||
extractBtn.disabled = true;
|
||||
return;
|
||||
} else {
|
||||
extractBtn.title = t("extractSelectArchive");
|
||||
}
|
||||
|
||||
extractBtn.title = t("extractToCurrent") + ": " + itemTitle(archives);
|
||||
extractBtn.disabled = locked;
|
||||
}
|
||||
|
||||
function singleSelected() {
|
||||
@@ -1462,9 +1716,9 @@ function updateButtons() {
|
||||
downloadBtn.disabled = locked || items.length === 0;
|
||||
document.getElementById("refreshBtn").disabled = locked;
|
||||
uploadBtn.disabled = locked;
|
||||
uploadMenuBtn.disabled = locked;
|
||||
uploadFolderBtn.disabled = locked;
|
||||
uploadAndExtractBtn.disabled = locked;
|
||||
uploadFilesItemEl.disabled = locked;
|
||||
uploadFolderItemEl.disabled = locked;
|
||||
if (locked && uploadMenuOpen) setUploadMenuOpen(false);
|
||||
document.getElementById("mkdirBtn").disabled = locked;
|
||||
newTextBtn.disabled = locked;
|
||||
for (const button of filesEl.querySelectorAll(".row-action, .mode-action")) button.disabled = locked;
|
||||
@@ -2031,7 +2285,6 @@ function renderTasks(tasks) {
|
||||
const isDelete = task.op === "delete";
|
||||
const isDownload = task.op === "download";
|
||||
const isChmod = task.op === "chmod";
|
||||
const isExtract = task.op === "extract";
|
||||
const isPreparing = (task.op === "copy" || task.op === "move" || isChmod) &&
|
||||
task.state === "running" && done === 0;
|
||||
const isFinishing = !isDelete && !isDownload && task.state === "running" && total > 0 && done >= total;
|
||||
@@ -2064,9 +2317,15 @@ function renderTasks(tasks) {
|
||||
speedItem.textContent = t("speedLabel") + ": " + (isChmod ?
|
||||
t("itemsPerSecond", { count: Math.round(speed) }) : formatSpeed(speed));
|
||||
const progressItem = document.createElement("div");
|
||||
progressItem.textContent = t("progressLabel") + ": " + (isExtract && Number(task.entries_total) ?
|
||||
t("extractProgress", { done: Number(task.entries_done || 0), total: Number(task.entries_total || 0) }) :
|
||||
isChmod ? t("permissionProgress", { done, total }) : formatSize(done) + " / " + formatSize(total));
|
||||
// Bytes only, for every task type including extract. An entry counter was
|
||||
// tried here and read as a hang: the archives that matter are a handful of
|
||||
// huge entries (a game blob split across volumes), so entries_done sits at 0
|
||||
// for the whole of the first one while bytes are plainly moving. The current
|
||||
// file name is already shown on its own line, so the entry index added
|
||||
// nothing a user could act on. Benchmarked: reporting is not a cost -- see
|
||||
// tests/bench_progress.c --mode -- so this is about clarity, not speed.
|
||||
progressItem.textContent = t("progressLabel") + ": " + (isChmod ?
|
||||
t("permissionProgress", { done, total }) : formatSize(done) + " / " + formatSize(total));
|
||||
const etaItem = document.createElement("div");
|
||||
etaItem.textContent = t("etaLabel") + ": " + averageEta(task, done, total);
|
||||
appendChildren(meta, speedItem, progressItem, etaItem);
|
||||
@@ -2289,7 +2548,7 @@ function uploadFileRequest(taskId, file, rel, overwrite, index) {
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadFiles(files) {
|
||||
async function uploadFiles(files, relativeNames) {
|
||||
if (busy || loadingPath || !files.length) return;
|
||||
const useLoading = files.length >= SELECT_ALL_LOADING_THRESHOLD;
|
||||
let list;
|
||||
@@ -2306,7 +2565,9 @@ async function uploadFiles(files) {
|
||||
list = Array.prototype.slice.call(files);
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const file = list[i];
|
||||
rels.push(uploadRelativeName(file));
|
||||
// Dropped items carry no webkitRelativePath, so the drop collector
|
||||
// rebuilds the path itself and passes it in.
|
||||
rels.push(relativeNames && relativeNames[i] ? relativeNames[i] : uploadRelativeName(file));
|
||||
sizes.push(String(file.size || 0));
|
||||
total += Number(file.size || 0);
|
||||
}
|
||||
@@ -2315,6 +2576,17 @@ async function uploadFiles(files) {
|
||||
if (useLoading) hideContentLoading();
|
||||
}
|
||||
|
||||
// A lone archive used to need the "upload and extract" menu entry. Ask once
|
||||
// here instead, so the plain Upload button covers it too; declining is an
|
||||
// ordinary upload, and the toolbar Extract button still works afterwards.
|
||||
if (list.length === 1 && isExtractableName(rels[0])) {
|
||||
uploadFilesEl.value = "";
|
||||
uploadFolderEl.value = "";
|
||||
if (confirm(t("extractUploadAsk", { name: rels[0] }))) {
|
||||
return uploadAndExtractFile(list[0], rels[0], true);
|
||||
}
|
||||
}
|
||||
|
||||
const overwrite = conflicts.length &&
|
||||
confirm(t("uploadOverwriteConfirm", { names: conflictText(conflicts) }));
|
||||
if (conflicts.length && !overwrite) return;
|
||||
@@ -2374,38 +2646,192 @@ async function uploadFiles(files) {
|
||||
}
|
||||
}
|
||||
|
||||
function actionUploadFiles() {
|
||||
if (busy || loadingPath) return;
|
||||
uploadMenuEl.classList.remove("open");
|
||||
uploadFilesEl.click();
|
||||
// A native file dialog is either file-only or folder-only (webkitdirectory), so
|
||||
// a single dialog cannot offer both. The button therefore opens a two-entry
|
||||
// list: a main button plus a small caret next to it was the old shape, and users
|
||||
// read the caret as decoration and never found "upload a folder" at all. The
|
||||
// drop target below is still the one gesture that accepts either.
|
||||
let uploadMenuOpen = false;
|
||||
|
||||
function uploadMenuItems() {
|
||||
return [uploadFilesItemEl, uploadFolderItemEl];
|
||||
}
|
||||
|
||||
function actionUploadFolder() {
|
||||
if (busy || loadingPath) return;
|
||||
uploadMenuEl.classList.remove("open");
|
||||
uploadFolderEl.click();
|
||||
function setUploadMenuOpen(open) {
|
||||
uploadMenuOpen = Boolean(open) && !busy && !loadingPath;
|
||||
uploadMenuEl.hidden = !uploadMenuOpen;
|
||||
uploadBtn.setAttribute("aria-expanded", uploadMenuOpen ? "true" : "false");
|
||||
}
|
||||
|
||||
function toggleUploadMenu() {
|
||||
if (busy || loadingPath) return;
|
||||
uploadMenuEl.classList.toggle("open");
|
||||
setUploadMenuOpen(!uploadMenuOpen);
|
||||
if (uploadMenuOpen) uploadMenuItems()[0].focus();
|
||||
}
|
||||
|
||||
function actionUploadAndExtract() {
|
||||
function actionUploadFiles() {
|
||||
setUploadMenuOpen(false);
|
||||
if (busy || loadingPath) return;
|
||||
uploadMenuEl.classList.remove("open");
|
||||
uploadZipEl.click();
|
||||
uploadFilesEl.click();
|
||||
}
|
||||
|
||||
async function uploadAndExtractFile(file) {
|
||||
function actionUploadFolder() {
|
||||
setUploadMenuOpen(false);
|
||||
if (busy || loadingPath) return;
|
||||
if (!/\.(zip|rar)$/i.test(file.name || "")) {
|
||||
uploadFolderEl.click();
|
||||
}
|
||||
|
||||
function moveUploadMenuFocus(step) {
|
||||
const items = uploadMenuItems();
|
||||
const current = items.indexOf(document.activeElement);
|
||||
const next = ((current < 0 ? 0 : current + step) + items.length) % items.length;
|
||||
items[next].focus();
|
||||
}
|
||||
|
||||
function setupUploadMenu() {
|
||||
// Closing on any click outside is what keeps a menu honest; it is bound to the
|
||||
// document so that a reflowed toolbar cannot leave a stale open list behind.
|
||||
document.addEventListener("click", event => {
|
||||
if (!uploadMenuOpen) return;
|
||||
const target = event.target;
|
||||
if (target === uploadBtn || (target && uploadMenuEl.contains(target))) return;
|
||||
setUploadMenuOpen(false);
|
||||
});
|
||||
document.addEventListener("keydown", event => {
|
||||
if (!uploadMenuOpen) return;
|
||||
if (event.key === "Escape") {
|
||||
setUploadMenuOpen(false);
|
||||
uploadBtn.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
moveUploadMenuFocus(event.key === "ArrowDown" ? 1 : -1);
|
||||
}
|
||||
});
|
||||
uploadBtn.addEventListener("keydown", event => {
|
||||
if (uploadMenuOpen || event.key !== "ArrowDown") return;
|
||||
event.preventDefault();
|
||||
toggleUploadMenu();
|
||||
});
|
||||
}
|
||||
|
||||
// A dropped directory arrives as a FileSystemEntry, which has no recursive
|
||||
// listing of its own: readEntries() hands back one batch at a time and an
|
||||
// empty batch marks the end, so it has to be driven until it drains.
|
||||
async function readDroppedDirectory(directory) {
|
||||
const reader = directory.createReader();
|
||||
const out = [];
|
||||
for (;;) {
|
||||
const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
|
||||
if (!batch.length) return out;
|
||||
for (const entry of batch) out.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectDroppedEntry(entry, prefix, files, relativeNames) {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise((resolve, reject) => entry.file(resolve, reject));
|
||||
files.push(file);
|
||||
relativeNames.push(prefix + file.name);
|
||||
return;
|
||||
}
|
||||
if (!entry.isDirectory) return;
|
||||
const children = await readDroppedDirectory(entry);
|
||||
const childPrefix = prefix + entry.name + "/";
|
||||
for (const child of children) {
|
||||
await collectDroppedEntry(child, childPrefix, files, relativeNames);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadDroppedItems(dataTransfer) {
|
||||
const files = [];
|
||||
const relativeNames = [];
|
||||
const dirEntries = [];
|
||||
const items = dataTransfer.items;
|
||||
if (items && items.length) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].kind !== "file") continue;
|
||||
const entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
|
||||
if (entry) {
|
||||
dirEntries.push(entry);
|
||||
continue;
|
||||
}
|
||||
const file = items[i].getAsFile();
|
||||
if (file) {
|
||||
files.push(file);
|
||||
relativeNames.push(file.name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < dataTransfer.files.length; i++) {
|
||||
const file = dataTransfer.files[i];
|
||||
files.push(file);
|
||||
relativeNames.push(uploadRelativeName(file));
|
||||
}
|
||||
}
|
||||
for (const entry of dirEntries) {
|
||||
await collectDroppedEntry(entry, "", files, relativeNames);
|
||||
}
|
||||
await uploadFiles(files, relativeNames);
|
||||
}
|
||||
|
||||
function dataTransferHasFiles(dataTransfer) {
|
||||
const types = dataTransfer && dataTransfer.types;
|
||||
if (!types) return false;
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
if (types[i] === "Files") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function setupDropUpload() {
|
||||
// The console browser has no drag source of its own, so the listeners would
|
||||
// only ever raise a hint that nothing can dismiss.
|
||||
if (isPlayStationBrowser()) return;
|
||||
let dragDepth = 0;
|
||||
window.addEventListener("dragenter", event => {
|
||||
if (!dataTransferHasFiles(event.dataTransfer)) return;
|
||||
event.preventDefault();
|
||||
dragDepth++;
|
||||
if (!busy && !loadingPath) dropUploadOverlayEl.hidden = false;
|
||||
});
|
||||
window.addEventListener("dragover", event => {
|
||||
if (!dataTransferHasFiles(event.dataTransfer)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
});
|
||||
window.addEventListener("dragleave", () => {
|
||||
dragDepth = Math.max(0, dragDepth - 1);
|
||||
if (!dragDepth) dropUploadOverlayEl.hidden = true;
|
||||
});
|
||||
window.addEventListener("drop", event => {
|
||||
if (!dataTransferHasFiles(event.dataTransfer)) return;
|
||||
event.preventDefault();
|
||||
dragDepth = 0;
|
||||
dropUploadOverlayEl.hidden = true;
|
||||
if (busy || loadingPath) return;
|
||||
uploadDroppedItems(event.dataTransfer).catch(err => {
|
||||
const message = t("uploadFailed", { error: err.message || t("backendError") });
|
||||
setStatus(message);
|
||||
alert(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadAndExtractFile(file, relativeName, alreadyAsked) {
|
||||
if (busy || loadingPath) return;
|
||||
if (!isExtractableName(relativeName || file.name || "")) {
|
||||
alert(t("err_extract_unsupported", { arg: file.name }));
|
||||
return;
|
||||
}
|
||||
const rel = uploadRelativeName(file);
|
||||
const zipPath = pathJoin(cwd, rel);
|
||||
if (!confirm(t("extractUploadConfirm", { name: rel, path: displayPath(cwd) }))) return;
|
||||
const rel = relativeName || uploadRelativeName(file);
|
||||
// cwd is the byte-mapped echo from the listing while rel comes straight from
|
||||
// the File object; byte-map the name so the joined path stays in one
|
||||
// convention (see encodeFsText).
|
||||
const zipPath = pathJoin(cwd, encodeFsText(rel));
|
||||
if (!alreadyAsked &&
|
||||
!confirm(t("extractUploadConfirm", { name: rel, path: displayPath(cwd) }))) return;
|
||||
const conflict = confirm(t("extractOverwriteAsk")) ? "overwrite" : "fail";
|
||||
|
||||
let taskId = 0;
|
||||
@@ -2452,7 +2878,6 @@ async function uploadAndExtractFile(file) {
|
||||
}
|
||||
} finally {
|
||||
uploadXhr = null;
|
||||
uploadZipEl.value = "";
|
||||
if (!trackedTask) setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -2484,16 +2909,13 @@ clearClipboardBtn.addEventListener("click", clearClipboard);
|
||||
document.getElementById("renameBtn").addEventListener("click", actionRename);
|
||||
downloadBtn.addEventListener("click", actionDownload);
|
||||
document.getElementById("deleteBtn").addEventListener("click", actionDelete);
|
||||
uploadBtn.addEventListener("click", actionUploadFiles);
|
||||
uploadMenuBtn.addEventListener("click", toggleUploadMenu);
|
||||
uploadFolderBtn.addEventListener("click", actionUploadFolder);
|
||||
uploadAndExtractBtn.addEventListener("click", actionUploadAndExtract);
|
||||
uploadBtn.addEventListener("click", toggleUploadMenu);
|
||||
uploadFilesItemEl.addEventListener("click", actionUploadFiles);
|
||||
uploadFolderItemEl.addEventListener("click", actionUploadFolder);
|
||||
uploadFilesEl.addEventListener("change", () => uploadFiles(uploadFilesEl.files));
|
||||
uploadFolderEl.addEventListener("change", () => uploadFiles(uploadFolderEl.files));
|
||||
uploadZipEl.addEventListener("change", () => {
|
||||
if (uploadZipEl.files.length === 1) uploadAndExtractFile(uploadZipEl.files[0]);
|
||||
else uploadZipEl.value = "";
|
||||
});
|
||||
setupUploadMenu();
|
||||
setupDropUpload();
|
||||
exitBtn.addEventListener("click", actionExit);
|
||||
parentBtn.addEventListener("click", actionParentDirectory);
|
||||
textEditorCloseBtn.addEventListener("click", requestCloseTextEditor);
|
||||
@@ -2542,10 +2964,6 @@ window.addEventListener("popstate", event => {
|
||||
loadAndReveal(path, false, false, true, null, revealPath);
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", event => {
|
||||
if (!uploadMenuEl || uploadMenuEl.contains(event.target)) return;
|
||||
uploadMenuEl.classList.remove("open");
|
||||
});
|
||||
document.querySelector("thead").addEventListener("click", event => {
|
||||
if (busy || loadingPath) return;
|
||||
if (parentBtn.contains(event.target)) return;
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
# v1.9.3M 真机验证清单
|
||||
|
||||
> 目标:在真机上把 **v1.9.3M 相对 v1.9.2 的全部改动**走一遍。
|
||||
> 对应任务 #46(五项规定项)+ 「字典超限报错」修复 + 本次「版本号加 M 改版标记」。
|
||||
> 逐项打勾,失败项记文案原文。
|
||||
|
||||
## 0. 物料
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 已发布 ELF | `web-file-mgr-v1.9.3M.elf`(903,448 B,sha256 `8ca47d5a…86b7`) |
|
||||
| **回滚 ELF** | `.build/rel-v1.9.2/web-file-mgr-v1.9.2.elf`(870,488 B,sha256 `177e90fe…8e84`,**从 GitHub Release 下载并已核验**) |
|
||||
| 测试归档 | `.build/device-test/`(22 个文件,2.5 MB,含 `MANIFEST.txt` 指纹) |
|
||||
| 监听端口 | 默认 `8888`,通知栏显示实际端口 |
|
||||
|
||||
> **这一轮(2026-09-24 晚)连续重编了多次**,都只动前端资源(上传菜单、拖拽提示、页脚
|
||||
> 状态行钳制、口令提示键、菜单行高亮、解压按钮常显),C 代码一字节没改。六个构建的
|
||||
> **文件尺寸都是 903,448 B**,sha256 各不相同,`.rodata` 逐轮
|
||||
> +0x140 / +0x980 / +0x100 / +0x180 / +0x240:
|
||||
> `7b5ab00c…`(首轮)→ `212107a6…`(+ 上传菜单)→ `da36834d…`(+ 文案与状态行钳制)→
|
||||
> `cf2c0fcf…`(+ 菜单行高亮修复)→ **`8ca47d5a…`(+ 解压按钮常显置灰,已发布)**。
|
||||
> **以 sha256 为准,别用文件尺寸判断"包换没换"。**
|
||||
>
|
||||
> ✅ **本清单已于 2026-09-24 在真机跑通,并据此发布 `v1.9.3M`。** 下面的记录表保留原始
|
||||
> 条目,供后续回归对照;已发布资产同样是 sha256 `8ca47d5a…`(903,448 B),与本地逐字节
|
||||
> 一致。
|
||||
|
||||
⚠️ **回滚只能用 `.build/rel-v1.9.2/` 那个**。项目根目录里曾经并存的、同名的
|
||||
`web-file-mgr-v1.9.2.elf`(903,448 B 的**当时未发布工作树**)已挪到
|
||||
`.build/elf-v1.9.2-worktree-f3164efa.elf`,根目录现在只剩 `v1.9.3M`。回滚包也可直接从
|
||||
Release 页下载:<https://github.com/LisherSong/ps5-web-file-manager/releases/tag/v1.9.2>。
|
||||
|
||||
> **带 `M` = LisherSong 改版,不带 `M` = 上游原版。** 从这一版起版本号统一带 `M`
|
||||
> 后缀(如 `v1.9.3M`),所以「名字里有没有 M」本身就是上游 / 改版的判据。
|
||||
|
||||
### 发送与打开
|
||||
|
||||
```sh
|
||||
nc -q0 <PS5_IP> 9021 < web-file-mgr-v1.9.3M.elf
|
||||
# 看 PS5 左上角通知:应显示 PS5 Web File Manager + v1.9.3M + 监听端口
|
||||
# 浏览器打开 http://<PS5_IP>:8888/
|
||||
```
|
||||
|
||||
### 通用纪律
|
||||
|
||||
1. **每次解压都新建一个空目标目录**。已知遗留问题:含目录条目的包在「覆盖」模式下解到同一
|
||||
目录第二次必失败(三引擎同构,与本次改动无关)—— 别把它记成回归。
|
||||
2. 多卷归档**一次把整个文件夹拖进去**,别只传子卷(UI 对「只选中子卷」会置灰并要求改选首卷)。
|
||||
3. 每项记三样:**通过/失败**、**界面文案原文**、**截图**。
|
||||
4. 任务列表可直接在浏览器看:`http://<PS5_IP>:8888/api/tasks`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 版本号四处一致 + 改版标记(规定项⑤,10 秒)
|
||||
|
||||
| 位置 | 期望 |
|
||||
|---|---|
|
||||
| PS5 启动通知 | `v1.9.3M` |
|
||||
| 页面底部状态栏的版本号(`#versionText`) | `v1.9.3M` |
|
||||
| `http://<PS5_IP>:8888/api/version` | JSON 里 version = `v1.9.3M` |
|
||||
| **鼠标悬停**在版本号上 | 浮出提示「本版为 LisherSong 改版(上游原版无 M 后缀)」/ 英文版同义 |
|
||||
|
||||
三处(+ 悬停)不一致 = 版本宏或前端兜底串没进二进制。ELF 内已核对:`v1.9.3M`
|
||||
出现 1 次、`v1.9.2` 出现 **0** 次;两条 tooltip 文案在解压后的 lang 资源里逐一命中。
|
||||
|
||||
> 若你此前已经刷过不带 M 的 `v1.9.3`:那个包只差字符串,**功能行为与本版完全一致**,
|
||||
> 所以先前测出的结果仍然有效,不必因为加了 M 就重测一遍功能项。
|
||||
> 反过来,只要界面显示的是 `v1.9.3`(无 M)就是旧包,`v1.9.3M` 才是本次发布版。
|
||||
|
||||
---
|
||||
|
||||
## 2. 字典超限报错(本次修复的**唯一**新行为,优先做)
|
||||
|
||||
上传 `00-dict-limit.rar`(**7,055 B**,一个 8 GiB 字典的合成归档)→ 解压 → 选冲突策略 → 开始。
|
||||
|
||||
| 检查 | 期望 |
|
||||
|---|---|
|
||||
| 错误码/文案 | `extract_dict_too_large`,文案含 **`8192 MiB (limit 4096 MiB)`** |
|
||||
| **不应**出现 | 「压缩包内单个文件过大: **hello.txt**」← 修复前的错误归因(该文件只有 7 B 级) |
|
||||
| 目标目录 | **不应**出现 `hello.txt`,也不留下垃圾文件 |
|
||||
| 行为 | 干净失败,不是崩溃、不是中途 OOM |
|
||||
|
||||
> 判读:若看到「单个文件过大」⇒ 装的是旧 ELF;若看到字典文案 ⇒ 这一项通过。
|
||||
> 这项改动**只改报错、行为零变化**,所以「拒绝解压」是**正确**结果。
|
||||
|
||||
---
|
||||
|
||||
## 3. 三类分卷解压(规定项①)
|
||||
|
||||
准备:把 `.build/device-test/` 里对应子目录整体上传到 PS5 的某个目录,然后在该目录里解压。
|
||||
|
||||
| 引擎 | 上传哪个目录 | 点哪个文件 | 期望输出 |
|
||||
|---|---|---|---|
|
||||
| **ZIP**(WinRAR 命名,字节切分) | `01-zip-vol/` | `parts.part1.zip` | 4 项:`readme.txt`、`sub/data.bin`、`sub/deep/more.bin`、`tail.bin` |
|
||||
| **ZIP**(Info-ZIP 分盘,偏移按盘算) | `01-zip-vol/` | `disks.zip` | 同上 4 项 |
|
||||
| **RAR**(RAR5,3 卷) | `01-rar-vol/` | `vol.part1.rar`(**必须选首卷**) | `big.bin`,**524,288 B** |
|
||||
| **7z**(7 卷,`.7z.001…`) | `01-7z-vol/` | 任一卷均可触发 | 一个 `_src/` 目录,内含 6 项:`readme.txt`、`binary.bin`、`zeros.bin`、`sub/code.bin`、`sub/nested.txt`、`sub/中文-テスト.txt` |
|
||||
|
||||
要点:
|
||||
- ZIP/RAR/7z 各测一遍**首卷与子卷**的触发行为:ZIP/7z 任一卷都该能触发;RAR 选子卷应**置灰并提示改选首卷**(这是设计行为,不是 bug)。
|
||||
- 7z 那组含**中文 + 日文文件名**,顺手验证 UTF-8 落盘。
|
||||
- 删掉(或改名)某一卷再试一次,应给「找不到分卷」类报错而不是静默成功 —— 顺带查负路径;负向夹具用的是
|
||||
`tests/fixtures/broken.zip.001`(缺后续卷)与 `gap.zip.001 + gap.zip.003`(缺第 2 卷),需要时一并上传。
|
||||
|
||||
---
|
||||
|
||||
## 4. 加密归档(规定项②,改动最集中)
|
||||
|
||||
口令:三个套件用的是**同一个测试口令**,定义在 `tests/run-sevenz-tests.sh`
|
||||
(`FIXTURE_PASSWORD`)与 `tests/make-zip-enc-fixtures.bat` / `tests/make-rar-fixtures.bat` 里,先去看一眼。
|
||||
|
||||
| 文件 | 加密方式 | 期望 |
|
||||
|---|---|---|
|
||||
| `02-encrypted/enc-zipcrypto.zip` | ZipCrypto | 提示输入口令 → 解出 `root.txt`、`dir/nested.txt` |
|
||||
| `02-encrypted/enc-aes256.zip` | WinZip AES-256 | 同上 |
|
||||
| `02-encrypted/enc-aes256-store.zip` | AES-256 + 存储 | 同上 |
|
||||
| `02-encrypted/enc-v6.rar` | RAR5 `-hp`(加密头) | **连列表都要口令** → 提示输入 → 解出同两文件 |
|
||||
| `02-encrypted/aes.7z` | 7zAES(数据加密) | 7z 会在**开始前**先问口令,然后解出 `_src/` 那 6 项 |
|
||||
| `02-encrypted/aeshe.7z` | **7zAES + `-mhe=on`(加密头)** | **本次头号目标**:加密头由新增的 `src/sevenz_header.c` 自解;应能正常列出并解出 `_src/` 那 6 项 |
|
||||
|
||||
必测的负路径:
|
||||
|
||||
| 场景 | 期望 |
|
||||
|---|---|
|
||||
| 口令故意输错(每格式至少一次) | 报 `err_extract_password` 并弹出重试框,**最多 3 次**;取消即结束,不卡死 |
|
||||
| 重试时换正确口令 | 第 2/3 次能成功(证明「记住原请求」的逻辑生效:冲突策略、大文件选配不丢) |
|
||||
| 7z 加密头 + 错口令 | 应是口令错误提示,**不是**「不受支持的归档 / 损坏」 |
|
||||
| **某次提示文案** | 第一次失败说「**此压缩包已加密。输入密码后解压,取消则停止解压。**」;第二次起才是「密码不正确」(第一次没输过密码,不该说你输错了) |
|
||||
| **错误文案里的条目名** | 中文/日文条目名必须**正常显示**,不得出现 `â®…` 或方框乱码 |
|
||||
|
||||
> 判读:`aeshe.7z` 在 v1.9.2 上是**已知缺口**(`tests/run-sevenz-tests.sh` 的 `KNOWN_GAPS`
|
||||
> 里原来就写着 `aeshe`),v1.9.3M 才闭合。这一项通过 = 最后一个 7z 缺口在真机确认关闭。
|
||||
|
||||
---
|
||||
|
||||
## 5. 上传入口 / 口令提示 / 名称编码(本轮修复,优先做,2 分钟)
|
||||
|
||||
这一节全部是**界面与前端 codec** 的改动,测起来最快,也最容易看出装的是不是新包。
|
||||
|
||||
### 5a 上传按钮变成菜单
|
||||
|
||||
| 检查 | 期望 |
|
||||
|---|---|
|
||||
| 按钮外观 | 「上传」右侧带小三角(▾) |
|
||||
| 点一下 | 在按钮正下方弹出列表:**上传文件 / 上传文件夹**(不再是「主按钮 + 一个小箭头」) |
|
||||
| 选「上传文件」 | 弹系统文件选择器,可多选 |
|
||||
| 选「上传文件夹」 | 弹目录选择器(原「小箭头」的功能,没丢) |
|
||||
| 键盘 | 打开后焦点在第一项;`Esc` 关闭并把焦点还给按钮;`↑/↓` 在两项间移动 |
|
||||
| 选中高亮 | 鼠标移到哪一项、或 `↑/↓` 停在哪一项,**只有那一行**亮;颜色一致;高亮完全落在菜单面板内,**不越出边框、不压住相邻行**(修复前是越界蓝框 + 两侧弧线,且 hover 与键盘焦点两行同时亮) |
|
||||
| 点别处 | 菜单关闭 |
|
||||
|
||||
### 5b 拖拽提示文案
|
||||
|
||||
| 检查 | 期望 |
|
||||
|---|---|
|
||||
| 页脚(版本号左边) | 常显灰字:**可直接把文件或文件夹拖进窗口上传** |
|
||||
| 拖文件到窗口 | 仍然出现「松开即上传到当前目录」覆盖层(旧行为不变) |
|
||||
| 在 PS5 自带浏览器里打开 | 上传按钮与这行灰色提示**都不显示**(`remote-only`,控制台浏览器没有拖拽源) |
|
||||
| 上传中/选中文件时 | 页脚状态文案变长时,状态文字**单行截断成 `…`**,提示与版本号都还在同一行(不再撑破页脚) |
|
||||
|
||||
### 5c 加密包「上传即解压」必须问口令(**这就是你报的那个 bug**)
|
||||
|
||||
**复现原步骤**:进一个**中文名字的文件夹**(或任意非 ASCII 目录名),上传一个**有密码的 ZIP**
|
||||
→ 弹「这是压缩包 x.zip,确定:上传后自动解压 / 取消:仅上传」→ 点确定。
|
||||
|
||||
| 检查 | 期望(修复后) |
|
||||
|---|---|
|
||||
| **上传完成后** | 直接弹出「**此压缩包已加密。输入密码后解压,取消则停止解压。**」 |
|
||||
| **不应**出现 | 一个只有「确定」的错误框、且必须自己去按工具栏「解压」才给输密码 ← 修复前的行为 |
|
||||
| 输入正确口令 | 继续解压并成功;结束后源压缩包被删除(上传即解压的既有行为) |
|
||||
| 取消口令框 | 走原来的失败提示,不静默 |
|
||||
| 中文条目名的包解压失败时 | 错误框里的条目名是**中文**,不是 `â®…ç§.psd` 那种乱码 |
|
||||
|
||||
> 根因(供判读):口令重试原来是按**路径**记住原始请求的,而路径是非 ASCII 时
|
||||
> 「页面持有的字符串」与「服务端任务回报的字符串」编码表示不同 ⇒ 查不到 ⇒ 不弹口令框,
|
||||
> 只能手动再解压一次。现在按**任务 id** 记,路径编码再也不会影响它。
|
||||
|
||||
### 5e 解压按钮常显置灰
|
||||
|
||||
工具栏里现在**一直有「解压」按钮**,不再只在选中压缩包时才冒出来。
|
||||
|
||||
| 检查 | 期望 |
|
||||
|---|---|
|
||||
| 什么都不选 | 按钮**在**工具栏里(复制/移动/重命名/下载/删除 之后),**灰色、点不动** |
|
||||
| 停在灰色按钮上 | 出现提示「**选中一个压缩包后才能解压(ZIP / RAR / 7z)**」 |
|
||||
| 选中一个文件夹 | 仍然灰色、点不动 |
|
||||
| 选中**一个**压缩包(`.zip` / `.rar` / `.7z`) | 按钮**变亮可点**,提示变成「解压到当前目录: <包名>」 |
|
||||
| 选中**两个**压缩包 | 又变灰,提示「一次只能解压一个压缩包」 |
|
||||
| 只选中 `.part02.rar` 这类子卷 | 变灰,提示「请改选主卷(如 .rar 或 .part01.rar)」(旧行为) |
|
||||
| 按钮文字 | 是短标签「**解压**」,与相邻按钮等宽(不再是「解压到当前目录」) |
|
||||
| 有任务在跑时 | 变灰(和其他按钮一起被锁) |
|
||||
|
||||
> 为什么改成常显:旧版不选中压缩包就**完全没有**这个按钮,用户不知道有这个功能。
|
||||
> 代价是工具栏常态宽了 96 px ⇒ 窗口窄到 **1190 px** 以下工具栏会换成两行
|
||||
> (英文界面是 1350 px;控制台 1920 px、1280 px 都不受影响)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 分卷 RAR 进度条实时走动(规定项④)
|
||||
|
||||
`01-rar-vol/` 那组只有 512 KB,进度条会一闪而过 ⇒ 用你自己那份大分卷 RAR(之前那个 ≈11.6 GB 的包已经被清理了,
|
||||
可以用 WinRAR 现造一个:`-m1 -md=4g -v1g`,内容选一个几 GB 的可压缩文件)。
|
||||
|
||||
| 检查 | 期望 |
|
||||
|---|---|
|
||||
| 进度条 | 按**字节**持续推进,不是长时间 0% 后直接跳 100% |
|
||||
| 速度读数 | 有合理 MB/s 读数(它是 250 ms 瞬时采样,抖动正常;只信「总字节 ÷ 总耗时」) |
|
||||
| `entries_done` | 跨卷的**单个大条目**场景下可能长时间停在 0 —— 这是设计如此(`assets/main.js:2118`),看字节进度即可 |
|
||||
| 取消 | 中途取消能停下,不残留半成品(取消是条目粒度) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 大 ZIP:160 GB / 9.5 万文件(规定项③,最后做)
|
||||
|
||||
这项只能在真机跑,且最费时。
|
||||
|
||||
| 检查 | 期望 |
|
||||
|---|---|
|
||||
| scan 阶段 | 不 OOM、不长时间无响应;进度条在走 |
|
||||
| 内存 | 峰值平稳(scan 只读中央目录,不解码) |
|
||||
| 空间预检 | 目标分区空间不足时应**提前**报错(`check_space()` 要求双份空间) |
|
||||
| 完成 | 0 报错解完;条目数与源一致 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 取证与回滚
|
||||
|
||||
**失败时提供这三样**(我按这个定位,不用你再复述):
|
||||
1. 界面/通知栏**文案原文**(含错误码,如 `extract_dict_too_large`);
|
||||
2. `http://<PS5_IP>:8888/api/tasks` 的返回;
|
||||
3. 截图(含目标目录文件列表)。
|
||||
|
||||
**回滚**(一条命令,产物已核验):
|
||||
|
||||
```sh
|
||||
nc -q0 <PS5_IP> 9021 < .build/rel-v1.9.2/web-file-mgr-v1.9.2.elf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 结果记录表
|
||||
|
||||
| # | 项目 | 结果 | 文案/备注 |
|
||||
|---|---|---|---|
|
||||
| 1 | 版本号四处一致 + 悬停改版提示 | ☐ | |
|
||||
| 2 | 字典超限报错(8 GiB 字典) | ☐ | |
|
||||
| 3a | ZIP 分卷(`parts.partN.zip`) | ☐ | |
|
||||
| 3b | ZIP 分盘(`disks.z01+.zip`) | ☐ | |
|
||||
| 3c | RAR 分卷(选首卷 / 选子卷置灰) | ☐ | |
|
||||
| 3d | 7z 分卷(含中日文文件名) | ☐ | |
|
||||
| 4a | ZIP 加密 ×3(ZipCrypto / AES / AES-store) | ☐ | |
|
||||
| 4b | RAR 加密头 `-hp` | ☐ | |
|
||||
| 4c | 7z `aes.7z` | ☐ | |
|
||||
| 4d | **7z `aeshe.7z`(`-mhe=on` 加密头)** | ☐ | |
|
||||
| 4e | 错口令 + 重试(三格式) | ☐ | |
|
||||
| 4f | 首次失败提示文案 / 条目名不乱码 | ☐ | |
|
||||
| 5a | 上传按钮 → 菜单(文件 / 文件夹) | ☐ | |
|
||||
| 5b | 页脚拖拽提示 + PS5 浏览器里隐藏 | ☐ | |
|
||||
| 5c | **加密包上传即解压 → 直接弹口令框**(原 bug) | ☐ | |
|
||||
| 5d | 菜单行高亮(只有一行亮、不越界、hover 与键盘同色) | ☐ | |
|
||||
| 5e | 解压按钮常显置灰(灰 → 选中一个包变亮 → 选两个又变灰) | ☐ | |
|
||||
| 6 | 分卷 RAR 进度条 | ☐ | |
|
||||
| 7 | 大 ZIP 160 GB / 9.5 万文件 | ☐ | |
|
||||
+448
-18
@@ -15,10 +15,17 @@
|
||||
> 布局本来就由 chain 负责)。实测 329 MiB:1.05 s → **0.77 s(1.37×)**,与 7za -mmt=off
|
||||
> 打平(898 ms);7za -mmt=8 = 485 ms。7z/ZIP/RAR 163 checks 全绿。
|
||||
>
|
||||
> **✅ ZIP 引擎 fsync 批量化(2026-09-16)**:逐条目 fsync 已移除(publish 是纯 rename、
|
||||
> 无续解功能,该 fsync 无收益;RAR/7z 引擎本来就没有)。8000 文件 fixture:fsync 版
|
||||
> **✅ ZIP 引擎逐条目 fsync 移除(2026-09-16)** —— 原计划写的是「批量化(每 64MB/N 条刷一次)」,
|
||||
> **实际落地改为彻底移除**:publish 是纯 rename、又没有续解功能,逐条目 fsync 换不到任何东西
|
||||
> (RAR/7z 引擎本来就没有,三引擎现在统一为「不 sync、只 rename」)。8000 文件 fixture:fsync 版
|
||||
> \>200 s 未完成 → 无 fsync **14.5 s(≥14×)**。同机官方 7-Zip 反而要 >400 s(Defender
|
||||
> 实时扫描逐文件查杀;PS5 无此因素)。
|
||||
>
|
||||
> 与 §二 排除 1 不矛盾:那里测的是**单一大文件**归档,一次 fsync 本来就近乎免费;这里是
|
||||
> **8000 个文件**,成本随条目数线性叠加(且 PS5 无 Defender,比例只会更极端)。
|
||||
> 已知取舍:publish 之后到落盘之间断电,会出现「文件在但内容不完整」;要补只需在 extract
|
||||
> 收尾做**一次**目录/整盘 flush(PS5 是 FreeBSD 系,`syncfs()` 不一定有,`sync()` 是全盘、偏重)。
|
||||
> 代码现状见 `src/zip_extract.c:937-945`。
|
||||
|
||||
## 结论
|
||||
|
||||
@@ -47,7 +54,7 @@ int Z7_FASTCALL LZMA_DECODE_REAL(CLzmaDec *p, SizeT limit, const Byte *bufLimit)
|
||||
|
||||
| 配置 | 单线程 | 8 线程 | 相对我们 |
|
||||
|---|---:|---:|---:|
|
||||
| **ours**(facade,含 staging + fsync + publish) | **1.39 s** | — | 1.00× |
|
||||
| **ours**(facade,含 staging + publish;当时仍含逐条目 fsync,2026-09-16 已移除) | **1.39 s** | — | 1.00× |
|
||||
| 官方 7-Zip(Linux 构建) | **0.89 s** | **0.51 s** | **1.57× / 2.73×** |
|
||||
|
||||
> 两个数字都是同一台机器上的实测。7-Zip 的 Linux 版和 Windows 版几乎一样快(0.89 vs 0.84 s),说明平台差异不是因素。
|
||||
@@ -92,7 +99,27 @@ int Z7_FASTCALL LZMA_DECODE_REAL(CLzmaDec *p, SizeT limit, const Byte *bufLimit)
|
||||
|
||||
### 附带发现:CRC 占 17%
|
||||
|
||||
`CrcUpdateT12`(slicing-by-12)花掉 0.22 s。7-Zip 解压时同样校验 CRC,所以这部分**不构成差距**,但如果单独优化(SSE4.2 硬件 `crc32` 指令,Zen 2 支持)能再省约 0.15 s。
|
||||
`CrcUpdateT12`(slicing-by-12,**纯软件实现**)花掉 0.22 s。
|
||||
|
||||
> ⚠️ **2026-09-23 更正**:本节原先写着「7-Zip 解压时同样校验 CRC,所以这部分**不构成差距**」——**这条是错的**。
|
||||
> 依据是本仓 vendor 的官方构建规则 `third_party/7z/7zip_gcc_c.mak:298-310`:
|
||||
> ```
|
||||
> ifdef USE_X86_ASM
|
||||
> $O/7zCrcOpt.o: ../../../Asm/x86/7zCrcOpt.asm ← 官方走这条:汇编版
|
||||
> else
|
||||
> $O/7zCrcOpt.o: ../../7zCrcOpt.c ← 我们走这条:纯 C
|
||||
> ```
|
||||
> 而 `CpuArch.h:691` 的 `CPU_IsSupported_CRC32()` 说明那份汇编就是 SSE4.2 硬件 `crc32`。
|
||||
> 我们的 `third_party/7z/Asm/x86/` 里**只有** `7zAsm.asm` 与 `LzmaDecOpt.asm`,**没有 `7zCrcOpt.asm`**。
|
||||
> 所以这 17% **是真实差距的一部分**,不只是「可选的净提速」;它同时还是 MT 路径的**串行瓶颈**
|
||||
> (`src/sevenz_mt.c` 的 `mt_seq_write` 在调用线程上算 CRC,Amdahl 意义上压住了多线程上限)。
|
||||
>
|
||||
> **2026-09-23 实测补充(`.build/_crcbench.c`,本机 MinGW x64,329 MiB 同一块数据,跑两次)**:
|
||||
> `CrcUpdateT12` **3.18–3.51 GB/s**(329 MiB → 0.098–0.109 s),zlib `crc32` **2.59–2.80 GB/s**,
|
||||
> SSE4.2 `crc32` 指令(直线写法)**6.03–6.11 GB/s**。
|
||||
> → CRC 实际只占单线程 1.39 s 的 **≈7%**,上面那个「0.22 s / 17%」**大概率是 gprof 插桩放大的**
|
||||
> (`-pg` 对纯循环函数特别吃亏)。**本节往下请按 ≤7% 理解,不要再引用 17%。**
|
||||
> 另外实测挖出一个**会算错的陷阱**,见「方案 C」。
|
||||
|
||||
---
|
||||
|
||||
@@ -121,13 +148,34 @@ SDK 自带 `C/Lzma2DecMt.c`(1095 行,public domain)就是 7-Zip `-mmt` 的
|
||||
- **风险**:中高(并发正确性、内存峰值;PS5 只有 8 核且 HTTP/任务系统同进程,建议限制线程数)
|
||||
- **前置**:建议先完成 A,因为 A 不改架构、收益确定、能独立验证
|
||||
|
||||
### 方案 C:CRC 硬件加速(可选)
|
||||
### 方案 C:CRC 加速(**实测后收益大幅缩水,且有一个会算错的陷阱**)
|
||||
|
||||
用 SSE4.2 的 `crc32` 指令替换 `CrcUpdateT12`。Zen 2 支持。
|
||||
`.build/_crcbench.c` 实测(本机 MinGW x64,329 MiB 同一块数据,两次运行):
|
||||
|
||||
- **预期收益:约 0.15 s(10%)**
|
||||
- **工作量**:小
|
||||
- **注意**:7-Zip 也做 CRC 校验,这不会拉开差距,只是净提速
|
||||
| 实现 | 吞吐 | 329 MiB 耗时 | 谁在用 |
|
||||
|---|---:|---:|---|
|
||||
| `CrcUpdateT12`(slicing-by-12) | 3.18–3.51 GB/s | 0.098–0.109 s | 我们:7z chain / MT 输出 / 逐条目 CRC |
|
||||
| zlib `crc32` | 2.59–2.80 GB/s | 0.123–0.133 s | 我们:ZIP 引擎 |
|
||||
| SSE4.2 `crc32` 指令(直线写法) | 6.03–6.11 GB/s | 0.056–0.057 s | 候选 |
|
||||
|
||||
> ⚠️ **陷阱:x86 的 `crc32` 指令算的是 CRC-32C(Castagnoli),不是三个格式要的 IEEE CRC-32。**
|
||||
> 同一份基准里的判定(标准向量 `"123456789"`):
|
||||
> `_mm_crc32_*` 得 **`0xE3069283`(CRC-32C)**,而 `CrcCalc` / zlib 得 **`0xCBF43926`(IEEE)**。
|
||||
> 所以**不能把 `CrcUpdate` 直接换成 `_mm_crc32_u64`** —— 必须补一个多项式转换
|
||||
> (GF(2) 上的 32×32 矩阵),或改用 pshufb / PCLMULQDQ 手写 IEEE 并行 CRC。
|
||||
> **这正是官方 `Asm/x86/7zCrcOpt.asm` 存在的意义**:它不是两行 intrinsic 包装。
|
||||
> UnRAR 那边同理 —— `third_party/unrar7/crc.cpp` 的硬件路径是 `USE_NEON_CRC32`(**ARM 专属**),
|
||||
> x86 上是 slicing-by-16 纯软件。
|
||||
|
||||
**收益重估(按实测)**:CRC 只占单线程 ≈7%(0.098 s / 1.39 s)。硬件指令直线写法 1.8× 于
|
||||
slicing-by-12,但还要扣掉多项式转换的开销 → 乐观估计单线程省 **0.05–0.07 s ≈ 4–5%**;
|
||||
MT 路径里它是串行分量(`sevenz_mt.c:64`),按 0.098 s 算 0.77 s → 约 0.72 s(≈6%),
|
||||
**不足以解释 8 线程下与 `7za -mmt=8`(0.485 s)的 1.6× 差距**。
|
||||
|
||||
**结论**:这仍然是**我们与 7-Zip 差距里确定存在**的一块(官方走 `USE_X86_ASM` 分支编汇编版,
|
||||
我们走 `else` 编纯 C),但**收益是个位数百分比,不是 10–15%,实现也不平凡**。
|
||||
风险倒是低:CRC 算错会**响亮失败**(每个条目报 `ZIPX_ERR_CRC`),现有 177 + 27 项测试会立刻抓住,
|
||||
不会静默写坏数据。**优先级从「最高」降为「可做,但别指望它拉平差距」。**
|
||||
|
||||
### 方案 D(备选,不推荐):上游的 helper 路线
|
||||
|
||||
@@ -144,20 +192,402 @@ SDK 自带 `C/Lzma2DecMt.c`(1095 行,public domain)就是 7-Zip `-mmt` 的
|
||||
## 五、执行顺序建议
|
||||
|
||||
```
|
||||
第一步 A(asm 解码器)
|
||||
└─ 先验证 jwasm → ELF64 → prospero-ld 这条链能否走通
|
||||
└─ 通过则:1.39 s → ~0.95 s,测试矩阵全绿后提交
|
||||
第二步 B(多线程)
|
||||
└─ 在 A 的基础上做,目标 ~0.55 s
|
||||
第三步 C(CRC 硬件加速,可选)
|
||||
└─ 再省 ~0.15 s
|
||||
第一步 A(asm 解码器) ✅ 已落地 2026-09-16,实测 1.26×
|
||||
└─ 先验证 jwasm → ELF64 → prospero-ld 这条链能否走通 ✅ 走通了
|
||||
第二步 B(多线程) ✅ 已落地 2026-09-16,实测 1.37×
|
||||
└─ 在 A 的基础上做,目标 ~0.55 s ⚠️ 实际 0.77 s:仍慢于 7za -mmt=8 的 0.485 s
|
||||
第三步 C(CRC 加速) ⬜ 未做 —— 但**实测后收益降到 4–5%**,且有 CRC-32C 陷阱
|
||||
└─ 官方走 USE_X86_ASM 编汇编版,我们编纯 C,确是真实差距;但不值得为它冒险
|
||||
```
|
||||
|
||||
**不做任何优化时的现状也是可接受的**:1.39 s / 329 MiB ≈ 237 MiB/s 单线程吞吐。在真实场景(大游戏包)里受存储 I/O 限制,差距往往比这个倍数更小。
|
||||
> **2026-09-23 补充**:B 之后我们与 `7za` 的对比是 0.77 s vs 单线程 0.898 s / 8 线程 0.485 s。
|
||||
> 也就是说单线程已打平,**8 线程下仍差约 1.6×**。
|
||||
> 曾把这段差距归给「没做的 CRC 硬件化」,但实测否掉了这个假设:CRC 全程只占 ≈7%(**且那是
|
||||
> gprof 放大后的口径,实测 0.098 s 更小**),把它全消掉也只值 4–5%,撑不起 1.6×。
|
||||
> 更可能的来源是 BCJ2 / 7zAES 布局仍走单线程 chain、以及 `Lzma2DecMt` 自身的线程扇出效率
|
||||
> —— 两者都要真机 profile 才能定性。
|
||||
> 其余候选(ZIP inflate 换 libdeflate、条目级并行、AES-NI)同理,均属「收益不可预期」或「工程量大」。
|
||||
|
||||
> **⚠️ 2026-09-23 限定:上面这个 1.59× 是在「最有利的输入形状」上测出来的,不能外推到真实归档。**
|
||||
>
|
||||
> 先看基准归档到底是什么(`tests/bench_driver.py:156-179` + payload 构造 `:144-156`):
|
||||
>
|
||||
> ```
|
||||
> payload = 文本块 ×240 → 一个大文件;再拼 ntoskrnl.exe ×30 → payload_mix.bin
|
||||
> 7za add = -t7z -m0=lzma2 -mx=5 -ms=on
|
||||
> ```
|
||||
>
|
||||
> 也就是 **1 个条目 / 1 个 solid folder / 1 个纯 LZMA2 coder / 未加密**。而 `sz_chain_lzma2_root()`
|
||||
> (`src/sevenz_chain.c:750-762`)要求**恰好** `num_coders == 1 && num_bonds == 0 &&
|
||||
> num_pack_streams == 1 && method == LZMA2` 才走 MT —— 换句话说,这个 fixture 是**唯一能让我们的
|
||||
> MT 生效的形状**,也是 7-Zip 拿不到任何结构优势的形状。它把 per-entry 开销(我们的强项)和
|
||||
> 非 LZMA2 布局(我们的弱项)**同时排除在外**了。
|
||||
>
|
||||
> 真实 PS5 归档(游戏包 / repack)几乎全是相反的形状:
|
||||
>
|
||||
> | 真实特征 | 对我们的后果 | 对 7-Zip 的后果 |
|
||||
> |---|---|---|
|
||||
> | 几千~几万个条目 | per-entry 开销主导(这块我们反而大幅领先,见上方 8000 文件数据) | 同样逐条目,无优势 |
|
||||
> | `-ms=off` / 超大归档切块 → **多 folder** | `num_pack_streams != 1` → **MT 失效**,退回单线程 chain | 跨 folder/块并行,`-mmt` 照常吃满 |
|
||||
> | exe/dll 用 **BCJ2** | `num_coders != 1` → **MT 失效** | 照常多线程 |
|
||||
> | **7zAES 加密** | `sz_chain_needs_password` → **MT 失效** | 照常多线程 |
|
||||
>
|
||||
> 结论:**1.59× 既不是上限也不是下限**,真实方向未知 —— 可能因 I/O 与 per-entry 成本被摊薄到无关,
|
||||
> 也可能因为整条解码退回单线程而比 1.59× 更糟。**在拿到真实归档上的 profile 之前,任何一侧的
|
||||
> 断言都是猜的。**
|
||||
>
|
||||
> 同样,下面这句原话也**未经验证**,暂按假设保留:
|
||||
> 「在真实场景(大游戏包)里受存储 I/O 限制,差距往往比这个倍数更小」——它成立的前提是解码项
|
||||
> 只占 wall-clock 的一小部分;按 0.77 s / 329 MiB ≈ 427 MiB/s 的解码吞吐与 PS5 存储带宽同量级来算,
|
||||
> 这个前提**未必成立**。
|
||||
|
||||
**不做任何优化时的现状也是可接受的**:1.39 s / 329 MiB ≈ 237 MiB/s 单线程吞吐;ZIP/RAR 两个引擎
|
||||
已分别压过/追平各自的官方实现,7z 单线程与 `7za -mmt=off` 打平。
|
||||
|
||||
---
|
||||
|
||||
## 六、复现
|
||||
## 六、真机首次实测(2026-09-23)
|
||||
|
||||
> ### ⚠️⚠️ 第二次更正(2026-09-23 深夜):拿到**真实归档的参数**,并实测了 MT
|
||||
> 用户给了 `D:\PPSA16608-e.part{1,2,3}.rar`,其中 **part3 当时还在盘上**,我用 WinRAR 7.23 的
|
||||
> `UnRAR lt` 直接读了它的头(该文件随后被用户清理掉,故下列数字是**一次性的实测记录**):
|
||||
>
|
||||
> | 项 | 实测值 |
|
||||
> |---|---|
|
||||
> | 格式 | **RAR 5**(不是 RAR4;头 8 字节 `52 61 72 21 1A 07 01 00`) |
|
||||
> | 分卷 | **卷 3 / 锁定(locked)** |
|
||||
> | **固实** | **不是固实** —— 直接解析 part3 主头:archive flags = `0x0013` = `VOLUME|VOLNUMBER|LOCK`,**`MHFL_SOLID=0x0004` 未置位**(`third_party/unrar7/headers5.hpp:25-29`)。旁证两处自洽:`MHFL_VOLNUMBER` ⇒ volnumber=2 ⇒ unrar 显示「卷 3」✓;`MHFL_LOCK` ⇒ unrar 显示「锁定」✓ |
|
||||
> | 关键条目 | `PPSA16608.exfat` **19 493 027 840 B → 打包 3 831 295 727 B**(**ratio 5.09 : 1**,高度可压缩) |
|
||||
> | 压缩参数 | **`RAR 5.0(v50) -m1 -md=4g`** —— **-m1「最快」档 + 4 GiB 字典** |
|
||||
> | 体量 | part3 = **3.568 GiB ≈ 该条目的打包大小** ⇒ 这个 19.5 GB 条目**整段都在 part3 内**(另有两个 KB 级小文件);parts 1+2(各 4 GB)装的是其余 ~1250 个条目 |
|
||||
>
|
||||
> **① UI 速度口径已核实 = 解压后字节。** `src/rar_extract.c:766` 在 `UCM_PROCESSDATA` 回调里
|
||||
> `c->bytes_done += p2`(`p2` 是 unrar 交出的**解压后**长度),`bytes_total` 累加 `hdr.UnpSize`。
|
||||
> ⇒ 用户看到的 **10–40 MB/s 是"吐出数据的速度"**。
|
||||
>
|
||||
> **② 「scan 阶段白解码一遍(2×)」这个最大嫌疑——已排除。**
|
||||
> `third_party/unrar7/dll.cpp:341-342` 只在 **`!Arc.Solid`** 时把 `RAR_SKIP` 走廉价的
|
||||
> `Arc.SeekToNext()` 分支;`extract.cpp:529`(`SkipSolid=Arc.Solid` ⇒ 真解码后丢弃)只在
|
||||
> **固实**时成立。本档**非固实** ⇒ 我们的 scan 只读头 + 跳过字节,**不解码**。
|
||||
> (这条曾是最有希望的一项:真固实的话 `scan + extract` 会把 11.6 GB 解码两遍。)
|
||||
>
|
||||
> **③ MT 收益已实测**(host、WinRAR 自带 **UnRAR 7.23**、`-mt<N>` 开关虽未见于 `-?` 帮助但可解析):
|
||||
>
|
||||
> | 夹具 | 载荷 | `-mt1` | `-mt8` | 加速 |
|
||||
> |---|---|---:|---:|---:|
|
||||
> | **代表性**(`-m1 -md4g`、5.27:1 可压缩、固实、2 GB、16 文件) | 2 GB | **386 MB/s** | **946 MB/s** | **2.45×** |
|
||||
> | 代表性同上但不固实(5.01:1) | 2 GB | 505 MB/s | 1222 MB/s | 2.42× |
|
||||
> | ~~非代表性~~(90% 随机数据 ⇒ 压缩块小) | 240 MB | 129 MB/s | 331 MB/s | ~~2.55×~~ **作废** |
|
||||
>
|
||||
> ⇒ **形状错误会让结论偏乐观**:第一版夹具用 90% 随机数据,压缩块远小于
|
||||
> `unpack50mt.cpp:150-152` 的 `LargeBlockSize=0x20000`(128 KiB)阈值,MT 全程生效;
|
||||
> 真实归档是 5:1 可压缩数据,块更大、会触发 `LargeBlock` 退化路径,实测也确实从 2.55× 降到 2.45×。
|
||||
> 结论:**在真实形状上 MT 值 ~2.4×**,可信。(单位均为**解压后** MB/s。)
|
||||
>
|
||||
> **④ 接线比原计划简单:1 个编译开关 + 1 个链接开关,不用改 vendored 源码、不用增删源文件。**
|
||||
> (下表中间那一行「把 `threadmisc.cpp` 加进源列表」**经实测作废** —— 行内已更正。)
|
||||
> | 改动 | 位置 | 为什么必需 |
|
||||
> |---|---|---|
|
||||
> | `-DRAR_SMP` | `Makefile:UNRAR7_CXX_FLAGS`(PS5 与 host 两处) | `os.hpp:42-45` 的 `#define RAR_SMP` 在 `#ifdef _WIN_ALL` 内;且 `unpack.cpp:7-9` 的 `#include "unpack50mt.cpp"` 就在 `#ifdef RAR_SMP` 里 ⇒ 不定义宏则整个 MT 解码器**根本不参与编译** |
|
||||
> | ~~把 `threadmisc.cpp` 加进 `UNRAR7_SRCS`~~ **← 这一条是错的,不要做** | — | `GetNumberOfThreads()` 确实定义在 `threadmisc.cpp:178`,但 `threadpool.cpp:5` **已经** `#include "threadmisc.cpp"` ⇒ 它**已经**被编进 `threadpool.o`(实测回执:`nm unrar7_threadpool.o` 能查到 `T GetNumberOfThreads` / `T GetNumberOfCPU`)。**再加进源列表 = 重符号链接失败。** 同理 `blake2sp.cpp` 也不必补 —— `blake2s.cpp:27` 已经 `#include "blake2sp.cpp"`。官方 POSIX makefile 只列 `threadpool.o`、不列 `threadmisc.o`/`blake2sp.o`,正是这个原因 |
|
||||
> | `-pthread`(编译+链接) | `Makefile` 的 PS5 link 行 | `threadpool.cpp` 的 `_UNIX` 路径用 pthread cond/mutex(**不用 `sem_t`**)。SDK 侧 `target/lib/libpthread.a` 在位、`target/include/pthread.h:198-237` 声明齐全 |
|
||||
>
|
||||
> **实测回执(宿主 MinGW,2026-09-23)**:用**与 PS5 完全相同的 50 个源文件**、只加 `-DRAR_SMP`,
|
||||
> 链接**一次通过**(`bench_mt.exe` 1,011,448 B):`nm` 里 `Unpack5MT` 出现 1 次、`ThreadPool` 12 次;
|
||||
> 换成 `-DZIPSFX`(关掉 `RAR_SMP`)后 `Unpack5MT` 归 0。
|
||||
> ⇒ **接线 = 1 个编译开关 + 1 个链接开关,零源码改动、零源文件增删。**
|
||||
> **为什么不必改 `dll.cpp`**:`dll.cpp:6-12` 的 `DataSet` 成员顺序是 `CommandData Cmd; Archive Arc; CmdExtract Extract;`,
|
||||
> 构造时 `Cmd` 先完成 → `RAROptions::Init()`(`options.cpp:22-24`)在 `RAR_SMP` 下执行
|
||||
> `Threads=GetNumberOfThreads()` → 随后 `CmdExtract Extract(&Cmd)` 构造函数里
|
||||
> `Unp->SetThreads(Cmd->Threads)`(`extract.cpp:25-27`;上限 `Min(Threads,8)`,`unpack.cpp:65-71`)。
|
||||
> ⇒ **宏一开,RARDLL 路径自动拿到 MT**(这正是 CLI 与 DLL 共用的那条链路)。
|
||||
> 内存代价:MT 下 `UnpackThreadData` × `MaxUserThreads*2`(每个 `Decoded` 预分配 0x4100 项)
|
||||
> + `ReadBufMT` 4 MiB ≈ **15 MB 量级**,PS5 上可忽略。
|
||||
>
|
||||
> **⑤ 但是:新证据把矛头指向「写路径 / 存储」,而不是解码 —— 先别改代码。**
|
||||
> - 同形状**单线程**解码在 PC 上是 **386–505 MB/s(解压后口径)**;PS5 的 Zen 2 单核即使按 1/3 算也有 **~130 MB/s**。
|
||||
> - 真机只算 `.exfat` 一项就是 **19.49 GB ÷ 660 s = ≥29.5 MB/s 解压后**(且与 UI 的 10–40 吻合)。
|
||||
> 若 parts 1+2 的 8 GB 打包数据解开后还有十几 GB,那么全流水线就是 **30–70 MB/s 解压后**,
|
||||
> 比 CPU 能力低 **3–10×**。
|
||||
> - **两次独立操作撞同一个数**:上传(写 11.6 GB)实测 30–40 MB/s;解压(写 ≥19.5 GB)≈30 MB/s。
|
||||
> ⇒ 优先怀疑 **PS5 这条写路径的上限就在 30–40 MB/s**(内置盘 / 外置盘 / 目标目录待确认)。
|
||||
> - ⇒ **决策顺序**:先做 `T_copy`(零改动、纯搬运)。纯搬运也 ~10 分钟 ⇒ 收工,MT 不必做
|
||||
> (做了也会被 I/O 吃掉);纯搬运明显快 ⇒ 再上 MT,那 2.4× 才是真金白银。
|
||||
>
|
||||
> 复现脚本:`.build/rabtest/ab_rar_mt.py`(第一版,形状错误,留作反例)、
|
||||
> `.build/rabtest/ab_rar_mt2.py`(代表性版)。夹具留在 `D:\_wfm_rabtest{,2}\`(≈1 GB)。
|
||||
|
||||
> ### ⚠️ 2026-09-23 晚 更正:**「瓶颈不在解码」这个结论已撤回**
|
||||
>
|
||||
> 本节最初写它时只知道「18 GB / 11 分钟 / 1252 条目」,**不知道归档格式**。随后的补充
|
||||
> (**格式是 RAR**;包在 PC 上、经插件上传进 PS5;上传速度 30–40 MB/s)把两个前提都改了:
|
||||
>
|
||||
> **① 参照物错了 —— 这是方法错误,不是估计偏差。**
|
||||
> 原文拿「PS5 上解 **RAR** 的 28 MiB/s」去比「PC 上解 **7z** 的 427 MiB/s」。**不同格式、
|
||||
> 不同解码器、不同机器**:RAR 我们直接用 rarlab 的 UnRAR 库,7z 走自建 chain,两者毫无
|
||||
> 可比性。⇒ 原文「推论二(量级差 3–10×)」**不成立**,不能作为解码无罪的证据。
|
||||
> 「推论一(摆动)」此前已自行降级为弱证据(250 ms 采样噪声)。**两条都没了。**
|
||||
>
|
||||
> **② 查代码查出一个具体缺口:RAR 解码在我们这里是单线程的。**
|
||||
> `third_party/unrar7/os.hpp:43-45` 的 `#define RAR_SMP` 落在 `#ifdef _WIN_ALL` 分支**内**,
|
||||
> 所以 POSIX(PS5)构建**不定义** `RAR_SMP` —— 我们 Makefile 里 0 次出现;而官方 POSIX
|
||||
> makefile 第 11 行是 `DEFINES=... -DRAR_SMP`,**我们漏了这个开关**。后果:
|
||||
> - `unpack.cpp:185-198` 的 MT 分支整段不参与编译 ⇒ 永远走单线程 `Unpack5()`
|
||||
> - `unpack50mt.cpp`(`Unpack::Unpack5MT`)**不在我们的源列表里**;这是 rarlab 专门调过的
|
||||
> 多线程 RAR5 解压器(文件头注释:「0x400000 和 2 对 i9-12900K 最优」)
|
||||
> - `Unpack::SetThreads()` / `ThreadPool` 随之消失
|
||||
> - ⚠️ **我们在 ELF 上做的符号核查是无效的**:该 ELF 只有 `.dynsym`(513 项)、**没有
|
||||
> `.symtab`**,任何内部符号都查不到(零命中是假象)。上面的结论来自 Makefile 与 `os.hpp`。
|
||||
>
|
||||
> **③ 新的首要假设:28 MiB/s ≈ 单线程 RAR5 解码的典型量级。**
|
||||
> RAR5 `-m5` 单线程在现代桌面 CPU 上约 40–80 MB/s 输出,PS5 的 Zen 2 单核更低。
|
||||
> 另一个角度:`18 GB ÷ 660 s = 27.9 MB/s` 是**解码输入**速率,而上传实测证明**写入端**
|
||||
> 至少能到 30–40 MB/s、**读取通常快于写入** ⇒ 「纯存储上限」解释不了这个数。
|
||||
>
|
||||
> **④ 附带作废一条**:「读粒度只有复制路径 1/32–1/64」是 **7z 的数字**(`SZ_IN_CHUNK`
|
||||
> 256 KiB),对 RAR 不适用 —— RAR 走 `od.ArcName` 按路径打开(`src/rar_extract.c:1059`),
|
||||
> 归档 I/O 由 UnRAR 自己的 `File` 类完成,我们的回调只收到**解压后的数据**,
|
||||
> **插不进 read-ahead**。⇒ 待办 #48 对 RAR 无效。
|
||||
>
|
||||
> **⑤ 归档真实参数(口径已闭合)** —— 两个来源合起来读得通:WinRAR 信息页(待解压版本 5.0、
|
||||
> 加密「缺少」、无恢复记录、压缩文件锁定「存在」、**字典 4 GB**、压缩率 19%、
|
||||
> **总大小 19,493,028,232 B = 18.15 GiB**、打包大小 3,831,296,089 B、**总文件 3**)+ 文件列表
|
||||
> (三卷 `.part1/2/3.rar` = 4 GB + 4 GB + 3.6 GB ≈ 11.6 GB)。
|
||||
> **两者不矛盾**:信息页是把 **part3 当独立归档**打开的 —— part3 = 3.568 GiB,里面就是那 3 个条目
|
||||
> (一个 19.5 GB 的 `PPSA16608.exfat` + 两个 KB 级小文件),上面的「第二次更正」块已用
|
||||
> `UnRAR lt` 直接读头证实;而三卷合计 ≈11.6 GB 才是整个包(另外还有 ~1250 个条目)。
|
||||
> ⚠️ 我先前按"两图互相矛盾、需用户确认"写的那一版**作废**:不是两个归档,是"单卷视图 vs 整包视图"。
|
||||
> 速度口径不受影响:**18.15 GiB ÷ 660 s = 29.5 MB/s(解压后字节)**;就那个大条目而言
|
||||
> 读侧只需 3.83 GB ÷ 660 s ≈ **5.8 MB/s** ⇒ **读侧不是瓶颈,29.5 MB/s 是解码+写盘的真实速率**。
|
||||
>
|
||||
> ## ⚑ 终局:RAR5 多线程**不做**(2026-09-23 18:15,用户决定)
|
||||
>
|
||||
> **#51 结案:保持单线程现状,生产代码不动、不刷机。**
|
||||
>
|
||||
> **为什么不做的依据是"判不了",不是"没收益"** —— 收益本身已实测(块 ⑧:我们的引擎
|
||||
> 1.40–1.74×;rarlab CLI 在用户那种形状上 2.45×)。卡住的是**它能不能兑现**:
|
||||
> - `T_copy` 没做 ⇒ 无法区分「解码慢」与「写路径上限 ≈30 MB/s」。
|
||||
> - 而 MT **只并行解码**:worker 只跑 `UnpackDecodeThread`(`unpack50mt.cpp:190`),
|
||||
> **写盘恒为主线程串行**(`UnpWriteBuf()` 仅由主线程调用 —— `unpack50mt.cpp:283/475/587`)。
|
||||
> ⇒ 若墙在写路径,MT 的收益直接退化成 **1.0×**。
|
||||
> - 成本收益:判定要人上手测一次复制;上线要刷机 + 重跑 11 分钟。而收益可能为 0
|
||||
> ⇒ **不做**。维持 11 分钟,把不确定性留在文档里,比赌一次更划算。
|
||||
>
|
||||
> **下面是已完成的技术取证,全部保留** —— 将来若要重开,它就是现成答案。
|
||||
> ⚠️ **重开的第一个动作是 `T_copy`,不是改构建**(判读见 `docs/REAL-CONSOLE-PROFILE.md` 第 1 步)。
|
||||
|
||||
> **⑥ 开 RAR5 多线程只需一个编译开关**(把 #51 的工作量从"未知"降到"一行"):
|
||||
> - `third_party/unrar7/unpack.cpp:7-9` **已经** `#include "unpack50mt.cpp"`;
|
||||
> `threadpool.cpp:5` **已经** `#include "threadmisc.cpp"`。⇒ 官方 POSIX makefile 不列这两个
|
||||
> `.o` 是**正常的**,**不需要新增源文件**(此前"官方 makefile 自相矛盾"的疑点已消除)。
|
||||
> - `raros.hpp:23-25`:非 Windows 一律 `#define _UNIX` ⇒ PS5 构建自动走 Unix 分支;
|
||||
> `threadpool.cpp` 的 `_UNIX` 路径用 pthread cond/mutex,**不用 `sem_t`**。
|
||||
> - 线程数在 DLL 模式下**自动接上**:`dll.cpp` 每个归档持有一个 `CommandData`,
|
||||
> 构造函数 `cmddata.cpp:6-9 → Init() → RAROptions::Init()` →
|
||||
> `options.cpp:22-24 Threads=GetNumberOfThreads()`;`extract.cpp:25-27` 再
|
||||
> `Unp->SetThreads(Cmd->Threads)` → `unpack.cpp:65-71 MaxUserThreads=Min(Threads,8)`。
|
||||
> ⇒ **不需要 CLI 开关、不需要 vendor 补丁**。
|
||||
> - ⇒ 改动 = 给 unrar 对象加 `-DRAR_SMP` + 链接 pthread。**PS5 侧唯一未验证点是
|
||||
> `sysconf(_SC_NPROCESSORS_ONLN)` 是否返回真核数**(`threadmisc.cpp:111-124`):
|
||||
> SDK 的 `unistd.h:291` 定义了 `_SC_NPROCESSORS_ONLN 58`、`pthread_*` 在
|
||||
> `target/include/pthread.h:198-237` 齐全;但若 `sysconf` 返回 1,**MT 会静默失效**。
|
||||
> ⚠️ 注意 `threadmisc.cpp:116-124` 在 `_UNIX` 且未定义 `_SC_NPROCESSORS_ONLN` 时**没有
|
||||
> return 语句**(UB)—— 真机上要能看到核数才算数。
|
||||
>
|
||||
> **⑦ MT 对固实归档同样生效,唯一例外是分片窗口**:`unpack.cpp:185-198` 在
|
||||
> `MaxUserThreads>1` 时调 `Unpack5MT(Solid)` —— 形参本身就带 `Solid`。会把它挡在外面的只有
|
||||
> `Fragmented`(`unpack.cpp:193`):`unpack.cpp:130-145` 只在 4 GiB 窗口的**连续分配失败**
|
||||
> 且 `WinSize>=16 MiB` 且 64 位时才置位,而且分片窗口路径**本身更慢**。
|
||||
> 64 位下单次 malloc 失败通常是"量不够"而非"地址空间碎",此时 `FragWindow.Init(同一大小)`
|
||||
> 也会失败 ⇒ **分片窗口属于罕见回退,概率低**。但它是可判读的:
|
||||
> **真机 A/B 若"开了 MT 却一点没变",第一嫌疑就是它或 `sysconf`。**
|
||||
>
|
||||
> **⑧ 宿主 A/B 实测:MT 值 1.4–1.75×**(同一台机器、同一份二进制,只差一个 `-DZIPSFX`)。
|
||||
> 方法:MinGW 下 `_WIN32 ⇒ `_WIN_ALL` ⇒ `os.hpp:43` 自动定义 `RAR_SMP`,所以**我们过去所有
|
||||
> 宿主基准跑的都是多线程路径**;反过来单线程基线只能靠 `-DZIPSFX`(该宏在整棵源码树里
|
||||
> **只出现一次**,就是 `os.hpp:43`,干净可用)。回执:`nm` 查 `unpack.o`,base 的
|
||||
> `Unpack5MT` 符号数 = 0、mt = 1。样本:341 MiB 现实混合数据(127 MiB 真实二进制
|
||||
> + 158 MiB 短匹配文本 + 48 MiB 随机),RAR5 固实 `-m3`,压缩后 125–153 MB(比率 37–45%):
|
||||
>
|
||||
> | 样本 | base(单线程) | mt | 加速 |
|
||||
> |---|---:|---:|---:|
|
||||
> | 固实 `-md1m` | 69.7 MiB/s | 97.3 MiB/s | **1.40×** |
|
||||
> | 固实 `-md256m` | 63.4 MiB/s | 110.6 MiB/s | **1.74×** |
|
||||
> | 分卷 `-md256m`(96+23 MiB) | 64.2 MiB/s | 100.8 MiB/s | **1.57×** |
|
||||
>
|
||||
> 两点附带信息:①**字典越大 MT 越划算** —— 单线程随字典从 1 MiB 涨到 256 MiB 掉到
|
||||
> 63–70 MiB/s(内存局部性),而 MT 稳定在 97–110;用户的包字典 4 GB,比这里最大的样本
|
||||
> 还大 16×,**方向上有理由期望 MT 收益不小于 1.6×**。②**跨机器外推不算结论**:
|
||||
> 宿主单线程 63–70 MiB/s vs PS5 的 28.1 MiB/s 是不同 CPU,只作量级参考。
|
||||
> ⇒ 若 PS5 上同样拿到 1.5–1.75×,11 分钟 → **约 6.3–7.3 分钟**。
|
||||
>
|
||||
> **⑧b 与上面「第二次更正」块的 2.45× 不矛盾 —— 差在样本形状,不在实现。**
|
||||
> 那块用 rarlab 自带 **UnRAR 7.23 CLI** 的 `-mt1` vs `-mt8`,夹具是 `-m1 -md4g`、**5.27:1**
|
||||
> 可压缩的 2 GB /16 文件;我这边是 `-m3`、只有 **2.4:1** 的短匹配数据。方向一致:
|
||||
> **数据越可压缩(匹配越长)MT 越划算** —— 长匹配让一条符号吐出更多字节,串行 apply 被摊薄、
|
||||
> 并行解码占比上升,同时更容易越过 `unpack50mt.cpp:150-152` 的 `LargeBlockSize=0x20000` 退化阈值。
|
||||
> ⇒ **对用户这个包应以 ~2.4× 为预期**(它是 `-m1 -md4g`、5.09:1,正落在那个夹具的形状上),
|
||||
> 我测到的 1.4–1.75× 作为**更难数据下的下界**。绝对吞吐那 10× 的差距(386 MB/s vs 63–70 MiB/s)
|
||||
> **纯粹是夹具可压缩性差异,不能拿来比较两个实现**(这正是"基准代表性"那类错误)。
|
||||
> 综合估计:11 分钟 → **约 4.6 分钟**;悲观情形(1.5×)→ 6.3–7.3 分钟。
|
||||
>
|
||||
> **⑨ 样本代表性教训(第一版 A/B 是废的)**:初版样本是「同一个 60 KiB 区块重复 2400 次」,
|
||||
> 压缩到 0.2%、解码 **602 MiB/s** —— 长匹配让范围解码器的每条符号吐出上千字节,
|
||||
> 这个形状**真实归档里不存在**,而且它恰恰是 MT 最不擅长的形状(串行 apply 占主导)。
|
||||
> 后改用"真实二进制 + 短匹配文本 + 随机"混合,比率 37–45%,吞吐落到 63–110 MiB/s 的正常带。
|
||||
> 另:初版把 `-v96m` 的目标名写成 `vol.part1.rar`,rar 会翻倍成 `vol.part1.part1.rar`
|
||||
> (`tests/make-rar-fixtures.bat` 里已记过这个坑,我复现了一遍)。
|
||||
>
|
||||
> **⑩ 顺带查出两个真实缺陷/隐患**:
|
||||
> 1. **字典 > 4 GiB 的归档会直接失败** —— **已修,但修的是"说清楚",不是"放行"**。
|
||||
> 机制:`extract.cpp:1748-1767 CheckWinLimit()` 在 `WinSize > Cmd->WinSizeLimit` 时调
|
||||
> `uiDictLimit()`;DLL/silent 构建里 `uisilent.cpp:65-73` 只在
|
||||
> `Cmd->Callback(UCM_LARGEDICT, …) == 1` 时才放行,否则 `DllError=ERAR_LARGE_DICT` 并跳过
|
||||
> 该文件。我们的回调原先**只处理 `UCM_PROCESSDATA`**,其余一律返回 0。默认
|
||||
> `Cmd->WinSize`/`WinSizeLimit` = `0x2000000` / `0x100000000`(`options.cpp:12-13`)
|
||||
> ⇒ 分界线正好是 4 GiB(比较是 `<=`)。
|
||||
>
|
||||
> **⚠️ 三条订正(2026-09-23 18:40,把先前"1 行可修"的判断推翻):**
|
||||
> - **RAR5 根本到不了这里。** `arcread.cpp:871` 把 RAR5 字典读成
|
||||
> `0x20000 << ((CompInfo>>10) & 0x0f)` —— **只有 4 bit** ⇒ 格式自身上限 = `0x20000<<15`
|
||||
> = **正好 4 GiB**,与我们的 limit 相等。⇒ 任何 `-ma5`(含用户这个包)**永远不触发**。
|
||||
> 只有 **RAR7 头**(`UnpVer==1`,5 bit,上限 `UNPACK_MAX_DICT` = 64 GiB)才可能超。
|
||||
> - **而 RAR7 造不出来。** 实测 `Rar.exe 7.23`:`-ma4` / `-ma6` / `-ma7` **全部 exit 7**
|
||||
> (命令行错误),只有 `-ma5` 可用。⇒ 本机连验证样本都得手工合成。
|
||||
> - **"放行"是陷阱不是修复。** 放行后 unrar 会去 `new` 一个**完整的字典窗口**;rarlab 自己的
|
||||
> CLI 对这个样本的答复是:「8 GB 字典超过 4 GB 限制,而且需要大于 8 GB 内存来解压缩。
|
||||
> 使用 `-md8g` 或 `-mdx8g` 参数来解压缩。」PS5 只有 16 GB **共享**内存 ⇒ >4 GiB 字典在
|
||||
> 该设备上本就解不动;**中途被 OOM 杀掉(整个 payload/UI 一起没)比干净失败更糟**。
|
||||
> ⇒ 决定:**保持拒绝**,与上游 CLI 默认一致。
|
||||
>
|
||||
> **实际改动(在生产代码里,2026-09-23):** 拒绝时不再把锅甩给条目。原先
|
||||
> `ERAR_LARGE_DICT → ZIPX_ERR_LIMIT_FILE` → i18n `extract_entry_too_large`
|
||||
> ⇒ 用户看到的是「**压缩包内单个文件过大: hello.txt**」,而那个条目只有 7 KB —— 完全错。
|
||||
> 现在新增 `ZIPX_ERR_LIMIT_DICT`(`src/zip_extract.h`)+ `extract_dict_too_large`
|
||||
> (中/英),并在 `rar_data_cb` 里从 `UCM_LARGEDICT` 的 `p1/p2` 取回真实数字,
|
||||
> 报成「需要 8192 MiB(上限 4096 MiB)」。回归用例 `tests/test_rar_extract.c:test_dict_limit`
|
||||
> + 固定样本 `tests/fixtures/dict-8g.rar`(合成器 **`tests/make_fixtures.py:bigdict()`**,
|
||||
> 纯 Python 手写最小合法 RAR5 归档、不依赖任何压缩器;说明见该函数 docstring)。
|
||||
> ⚠️ **样本必须由 `make_fixtures.py` 生成** —— `fresh()` 会 `shutil.rmtree()` 整个
|
||||
> `tests/fixtures/`,提交进去的二进制会被抹掉,所以不能单独放一个生成脚本。
|
||||
> 2. **归档里的目录条目 +「覆盖」策略 = 第二次解压到同一目录必失败**。
|
||||
> `src/rar_extract.c:884-892`:目标已存在且是目录、而归档条目也是目录时,
|
||||
> 只有 `ZIPX_CONFLICT_MERGE` 能过;`OVERWRITE` 报 `ZIPX_ERR_CONFLICT`
|
||||
> (状态串是 "target already exists",`detail` 才是 "directory already exists")。
|
||||
> `zip_extract.c:1123` / `sevenz_extract.c:1526` 同构。⇒ 用户对含目录的包用「覆盖」
|
||||
> 解两次,第二次会失败。**这条是我在搭 A/B 时被挡了才知道的**,需要确认是否设计意图。
|
||||
>
|
||||
> 下文数据与推论**保留原文**(它们是当时判断的依据),但**结论以本块为准**。
|
||||
|
||||
### 数据
|
||||
用户在真机上解一个 **18 GB 的包**,UI 上报的解压速度在 **10–40 MB/s** 之间摆动;
|
||||
随后补上两个关键数字:**总耗时 11 分钟(660 s)**、**条目数 1252**(平均 14.7 MB/条目);
|
||||
再确认 **18 GB 是压缩包自身的大小**(解压后多大未知)。
|
||||
|
||||
⇒ **平均吞吐 ≥ 28 MiB/s**(18 GB = 18 432 MiB ÷ 660 s;解压后更大则更高,故为下限)。
|
||||
**这个数字才是基线**,UI 上那个 10–40 的区间只是瞬时值。
|
||||
|
||||
「18 GB = 压缩包大小」顺带给出一个**与解码无关的硬上界**:源盘必须在 660 s 内交出
|
||||
18 GB 归档数据 ⇒ 整条流水线的平均吞吐上界就 ≈ 28 MiB/s。**无论解码多快,源盘只有这个交付速度。**
|
||||
这也是为什么第 1 步的 `T_copy`(同一个 18 GB 文件的纯搬运)能与 660 s 直接比大小。
|
||||
|
||||
顺带排除一项:**per-entry(小文件)开销不是主因** —— 1252 个文件、平均 14.7 MB,不是
|
||||
"几万个小文件"那种形态,建文件 + rename 分摊到 0.53 s/文件里微乎其微。
|
||||
|
||||
口径先确认(不是猜):进度条的"字节"是**解压后的字节**——
|
||||
`src/zip_extract.c:651-678` 把 `bytes_total` 累加自 `info->uncompressed_size`;
|
||||
`:875` 的 `mz_zip_entry_read()` 返回解压字节,`:900/910` 用它累加 `bytes_done`。
|
||||
所以 10–40 MB/s 是**吐出数据的速度**,正是用户关心的那个口径。
|
||||
|
||||
### 推论一:摆动说明"负载不恒定",但它是弱证据
|
||||
solid 块(同一字典、同一条码流)的解码速率**几乎是恒定的**。要出现 4× 的摆动,
|
||||
更像是 I/O 侧在变:源盘读取、目标盘写入、逐条目同步开销、或存储设备自身在忙。
|
||||
|
||||
但这条**不能单独定案** —— 那个 MB/s 是 250 ms 窗口 + 1 MiB 上报阈值的**瞬时值**
|
||||
(`src/zip_extract.c:123`、`src/task.c:202-206`),写缓冲突发本身就能在窗口里造成大幅跳动。
|
||||
它能说的只有"负载不恒定",不构成"解码无罪"的证明。有解释力的是推论二(量级)和推论三(粒度)。
|
||||
|
||||
### 推论二:量级上差 3–10×,解码没有解释力
|
||||
| | 吞吐 |
|
||||
|---|---:|
|
||||
| PC / WSL,329 MiB 混合数据,8 线程(本仓实测) | **427 MiB/s** |
|
||||
| PS5 单线程解码的乐观上界(按核数×频率外推,**未实测**) | ~100 MB/s |
|
||||
| **真机实测(18 GB 包,端到端)** | **10–40 MB/s** |
|
||||
|
||||
18 GB @ 10–40 MB/s = **7.7 ~ 31 分钟**;同样数据在 PC 上纯解码约 43 s。
|
||||
⇒ 解码最多占 10–25%,**很可能远低于此**。
|
||||
|
||||
### 推论三:三个引擎的读请求粒度都只有复制路径的 1/32–1/64
|
||||
粒度审计(已核对源码):
|
||||
|
||||
| 路径 | 源侧读粒度 | 目标侧写粒度 |
|
||||
|---|---|---|
|
||||
| 复制(`copy_file_pipeline`,≥256 MiB) | **8 MiB** × 3 slot,4096 对齐,独立读线程 | 8 MiB |
|
||||
| 7z | chain `SZ_IN_CHUNK = 256 KiB`(`src/sevenz_chain.c:87`);MT 路径 `inBufSize_MT = 1 MiB` | `SZ_OUT_CHUNK = 64 KiB`(`:94`) |
|
||||
| ZIP | `ZIPX_IO_BUFFER = 128 KiB`(`src/zip_extract.c:32`) | 128 KiB |
|
||||
| RAR | UnRAR `File::CopyBufferSize() = 4 MiB`(`third_party/unrar7/file.hpp:148-153`) | 4 MiB |
|
||||
|
||||
都不是 4 KB 那种「小读」灾难,但**都比复制小 32–64 倍**。在延迟主导的设备上,
|
||||
吞吐 ≈ 单次请求大小 ÷ 每次请求的等效延迟:
|
||||
|
||||
```
|
||||
256 KiB / 10 ms = 25 MB/s ← 正好落在实测 10–40 MB/s 的中间
|
||||
8 MiB / 10 ms = 800 MB/s ← 复制路径不会撞这个上限
|
||||
```
|
||||
|
||||
若源与目标在**同一块盘**(例如外置 USB HDD 上解压到同一块盘),读流与写流并存,
|
||||
磁头来回跑、read-ahead 被写回刷打断 → 每次请求退化成一次寻道,上面这个算术即成立。
|
||||
**这是目前唯一可疑的代码级病因**,而它的改动面极小:所有 7z 的读都只经过
|
||||
`src/sevenz_volstream.c` 的 `vol_read()` 一个函数(ZIP/RAR 同理在 `src/zipx_volstream.c`)。
|
||||
|
||||
### ~~因此:剩余解码优化项全部搁置~~(**已撤回,见文首更正块**)
|
||||
原文在这一段把 CRC 硬件化、MT 扩到 BCJ2 / 多 folder、ZIP inflate 换 libdeflate、AES-NI 全部判为
|
||||
「在 10–40 MB/s 的现实面前没有意义」。**建立在「解码不是瓶颈」之上,而那个前提已撤回。**
|
||||
|
||||
更正后的分层判断(按真实工作负载 **RAR** 重排):
|
||||
|
||||
| 项 | 更正后的判断 |
|
||||
|---|---|
|
||||
| **UnRAR RAR5 多线程解压(`RAR_SMP`)** | **❌ 不做(2026-09-23 用户决定,见文首「终局」块)**。收益已实测(rarlab CLI `-mt1` vs `-mt8` **2.45×**,见「第二次更正」块 ③;我们引擎在更难数据上 1.40–1.74×,块 ⑧b),接线也只是 **1 个编译开关 + 1 个链接开关、零源码改动**(块 ④)—— **但 `T_copy` 没做,无法排除「写路径上限 30–40 MB/s」**;MT 只并行解码、写盘恒为主线程串行(`unpack50mt.cpp:190` / `:283,475,587`)⇒ 收益可能是 1.0×。刷机 + 重跑 11 分钟的成本压在不确定收益上 ⇒ 搁置 |
|
||||
| CRC 硬件化(4–5%) | 仅对 7z / ZIP 有意义;**对 RAR 完全无关**(CRC 由 UnRAR 自己算) |
|
||||
| MT 扩到 BCJ2 / 多 folder | 仅 7z;RAR 工作负载下无意义 |
|
||||
| ZIP inflate 换 libdeflate / AES-NI | 同上,与 RAR 无关 |
|
||||
| 读粒度 / read-ahead(#48) | **对 RAR 无效**(UnRAR 按路径自读,回调只收解压后数据);只对 7z/ZIP 有意义 |
|
||||
|
||||
「先 profile 再排序」这个结论**仍然成立**,但目标从「查清为什么只有 10–40 MB/s」变成
|
||||
**「先把 RAR 单线程这条确认掉 / 排除掉」** —— 见 `docs/REAL-CONSOLE-PROFILE.md`。
|
||||
|
||||
### 尚未定案的部分
|
||||
- ~~**归档格式未知**~~ → **已确认(2026-09-23):格式是 RAR**,包原本在 PC 上、经插件上传进
|
||||
PS5(上传速度 30–40 MB/s)。⇒ 解码用的是 rarlab 的 UnRAR 库本身,**我们自己的代码在 RAR
|
||||
解码路径上只剩一层很薄的 facade**;「我们的解码器慢」这个方向基本不成立,
|
||||
但「**我们的构建没打开 UnRAR 的多线程**」成立(见文首更正块 ②)。
|
||||
仍未确认:**RAR4 还是 RAR5**(`Unpack5MT` 只对 RAR5/7.0 生效)、是否固实、是否加密、
|
||||
**解压后多大**(决定输出吞吐)。
|
||||
- ~~**18 GB 是压缩后还是解压后**~~ → **已确认(2026-09-23):18 GB 是压缩包自身的大小**。
|
||||
于是有了一个**不需要知道解压后大小的硬上界**:源盘要在 660 s 内交出 18 GB 归档数据
|
||||
⇒ 整条流水线平均吞吐上界 ≈ **28 MiB/s**;`T_copy` 与 660 s 可直接比大小。
|
||||
- **存储未知**:包在哪个设备上(内置 SSD / 外置 USB / 同盘还是异盘)、输出写到哪个设备。
|
||||
- ~~**下一步(决定性、零改动)**~~ → **未执行(2026-09-23 用户决定搁置,见文首「终局」块)**:
|
||||
用插件自己的**复制**功能把那个包搬到解压输出所在的那块盘上,记耗时。复制的
|
||||
`copy_file_pipeline` 是 8 MiB × 3 slot 的双缓冲搬运,⇒ 它就是"同一台机器、同一对设备、
|
||||
只把解压换掉"的对照。**将来重开时这是第一个动作**,判读见 `docs/REAL-CONSOLE-PROFILE.md` 第 1 步。
|
||||
|
||||
> 一句话:**"我们比 7-Zip 慢 1.59×" 这个议题在真机上不成立** —— 两边都被同一个 I/O
|
||||
> 上限压着,谁先撞墙取决于存储,而不是解码器。真机目标从"提速解码"改成"查清 28 MiB/s"。
|
||||
> ~~目前指向一个具体、可改的位置:**读请求粒度只有复制路径的 1/32–1/64**(推论三),
|
||||
> 而全部 7z 读都经过 `vol_read()` 一个函数。**先用复制做基线验证它,再决定改不改。**~~
|
||||
> → **推论三对 RAR 不适用**(那是 7z 的 256 KiB 数字;UnRAR 按路径自读,见文首更正块 ④)。
|
||||
> 更正后:真机工作负载是 RAR,而**我们的 RAR 解码是单线程的**(`RAR_SMP` 未定义 ⇒
|
||||
> `unpack50mt.cpp` 不参与编译)—— 这是一项有据可查、且直接针对真实负载的改进点,
|
||||
> 在真实形状上**已实测 2.4×**。
|
||||
> ~~**但优先级已被文首二次更正块 ⑤ 改写**:先做零改动的 `T_copy`,确认墙在解码还是在写路径。~~
|
||||
> → **闭环(2026-09-23 18:15):`T_copy` 没做,用户决定不做了** ⇒ 见文首「终局」块。
|
||||
> 单线程现状保留,此项转入"已评估、搁置(重开先测 `T_copy`)"。
|
||||
|
||||
---
|
||||
|
||||
## 七、复现
|
||||
|
||||
```bash
|
||||
export PATH="/c/mingw64/bin:/c/Users/songl/.workbuddy/binaries/PortableGit/versions/1.2.0/mingw64/bin:/c/Users/songl/.workbuddy/binaries/python/versions/3.13.12:/usr/bin:/bin:/c/Windows/System32:/c/Windows"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# PS5 网页文件管理器 v1.9.2(自编译版)发布 + 与上游原版对比 + 使用简介
|
||||
|
||||
> 这是我自己维护的一个分支版本,基于 owendswang 的 `ps5-web-file-manager` 二次开发。本文讲三件事:**它和上游原版差在哪、我这个版本强在哪、怎么用**。
|
||||
|
||||
---
|
||||
|
||||
## 一、它是什么
|
||||
|
||||
一个在已越狱 PS5 上跑的 HTTP 文件管理器(单文件 ELF 载荷)。局域网内任意浏览器(含 PS5 自带浏览器)打开 `http://<PS5的IP>:8888/` 就能管理外接 USB 和内置存储:浏览、改权限、上传、下载、原地编辑文本、装/预览 PKG、看图,以及**解压 ZIP / RAR / 7z**。
|
||||
|
||||
- 版本:**v1.9.2**
|
||||
- 标题 ID:`FMGR88888`
|
||||
- 许可证:GPLv3+
|
||||
- 目标平台:`x86_64-sie-ps5`(Zen 2,不是 ARM)
|
||||
|
||||
> **关于版本号**:v1.9.2 与 v1.9.1 功能完全相同,只更新了内嵌版本号。原先的 `v1.9.1` tag 打在了产出发布二进制的提交**之前 4 个提交**,导致"tag 对应的源码"重建不出发布的那份 ELF;v1.9.2 重新从正确提交上打,使 **tag = 源码 = 二进制**。
|
||||
|
||||
---
|
||||
|
||||
## 二、和上游原版(owendswang v1.8)对比
|
||||
|
||||
先说结论:**不是同一条路线,各有胜负手。**
|
||||
|
||||
| 维度 | 上游 owendswang v1.8 | 我的 v1.9.2 |
|
||||
|---|---|---|
|
||||
| 一句话定位 | 把 7-Zip 本体做成**外部 helper 进程**,靠 IPC 调用 | **自研 / vendor 解码库,全部内嵌同一进程** |
|
||||
| 支持的压缩格式 | **30 种**(zip/7z/rar/tar/gz/xz/zst/bz2/cab/arj…) | **3 种**:ZIP / RAR / 7z |
|
||||
| 部署方式 | **两个文件**,helper 必须放 `/data/wfm/` 指定路径 | **单个 ELF,零外部依赖** |
|
||||
| 载荷体积 | 主程序 + 7-Zip 本体(两份) | **850 KiB 单文件**(含 ZIP/RAR/7z 三个解码引擎) |
|
||||
| 防压缩炸弹(zip bomb) | ❌ 无 | ✅ 压缩比上限 + 1 GiB 下限豁免 |
|
||||
| 磁盘写满保护 | ❌ 无 | ✅ 解压前按实际剩余空间 `statvfs` 预检 |
|
||||
| 路径穿越防护 | ❌ 无(交给 7-Zip) | ✅ 有专项测试 |
|
||||
| 解压中断留残留 | ⚠️ 可能留半成品 | ✅ staging 隔离,失败即清 |
|
||||
| 解压任务跨重启恢复 | ✅ helper 独立进程,重启不丢 | ❌ 暂无 |
|
||||
| 内存隔离 | ✅ 独立进程 | ❌ 与主程序共享地址空间 |
|
||||
| 错误信息详细度 | 中等 | ✅ 含条目名 / errno / 字节数 |
|
||||
| 解压核心正确性 | 7-Zip 本体(20 年验证) | 自研 7z 链 + 成熟 vendor 库 |
|
||||
|
||||
### 上游原版强在哪
|
||||
- **格式多**:30 种,常见游戏包/备份/Mod 里 `.tar.gz`、`.xz`、`.zst`、`.bz2` 都能直接解。
|
||||
- **任务恢复**:helper 是独立进程,主程序被系统杀掉或浏览器重开,大包解压不丢。
|
||||
- **内存隔离**:解压峰值不影响主文件服务。
|
||||
|
||||
### 我的版本强在哪
|
||||
- **安全护栏齐全**:压缩炸弹、写满磁盘、路径穿越、中断残留——这四项上游一个都没有,而我这边都有实现和测试(测试矩阵 163 项检查,0 失败)。说白了,**一个恶意压缩包不会把你的内置存储搞崩**。
|
||||
- **单文件部署**:丢一个 ELF 就行,不用记第二个文件该放哪;helper 丢了功能全废的事在我这不存在。
|
||||
- **7z 引擎是硬啃出来的**:上游靠 7-Zip 本体"白嫖",我这边是自己解析 7z folder + 拉式 codec 链,原生支持 BCJ2 反汇编后处理和多 coder 组合,还顺手做了多线程 LZMA2 解码(约 1.37×)和汇编 LZMA 解码器(约 1.26×)。
|
||||
- **RAR 用官方 UnRAR 7.20.1**:能解 WinRAR 6.x/7.x 写的 RAR5「v6」归档和多卷 RAR——上游原版的旧引擎在这类文件上会报"归档损坏"。
|
||||
|
||||
### 我不回避的短板
|
||||
- **格式覆盖只有 3 种**,这是最明显的弱项。`.tar.gz`、`.xz`、`.zst`、`.bz2` 你现在还得在 PC 上先解开。这是我接下来增量要补的方向(tar+zlib 已有、xz/lzma 复用 LZMA SDK、bz2/zst 可 vendor 单文件解码器),但**不打算照抄上游的 helper 路线**——那样会丢掉上面那些安全护栏和单文件部署优势。
|
||||
- **任务跨重启恢复**和**内存隔离**暂时没有(和"单进程内嵌"的架构取舍有关)。
|
||||
|
||||
> 一句话:**上游赢在"格式广度 + 进程架构",我赢在"安全 + 部署 + 错误质量"。**
|
||||
|
||||
---
|
||||
|
||||
## 三、v1.9.2 / v1.9.1 我做了哪些具体改进
|
||||
|
||||
- **7z 解压引擎**:自研解码子集 + 拉式 codec 链(`sevenz_chain.c`),支持 LZMA2 / LZMA / BCJ2,覆盖 SDK `SzArEx` 装不下的 5-coder 文件夹。
|
||||
- **分卷(多卷)支持**:三种格式都支持分卷,但命名约定不同(引擎按文件名自动识别,打开首个分卷即可):
|
||||
- **ZIP 分卷**:① 经典多磁盘 `name.z01 … name.zNN … name.zip`(索引目录固定在最后一个 `.zip`);② 7-Zip 字节分割 `name.zip.001 / .002 / …`;③ WinRAR 卷 `name.part1.zip … name.partN.zip`。最多 512 卷。
|
||||
- **7z 分卷**:7-Zip 字节分割 `name.7z.001 / .002 / …`,各分卷需等大,打开首个即可。最多 512 卷。
|
||||
- **RAR 分卷**:标准多卷 `name.part1.rar / .part2.rar / …`(unrar 自动按名合并)。**注意**:用 7-Zip 切出来的 `name.rar.001` 这种命名**暂不支持**,引擎会提示你先把分卷改名成 `.partN.rar` 再解。
|
||||
- **性能三项**(纯解码提速,不影响功能):
|
||||
- 汇编 LZMA 解码器 ≈ **1.26×**
|
||||
- 纯 LZMA2 多线程解码(8 线程)≈ **1.37×**
|
||||
- 移除 ZIP 逐条目 fsync,减少写盘开销
|
||||
- **RAR 升级到官方 UnRAR 7.20.1**:RAR5「v6」+ 多卷可用(v1.8 时代在 WinRAR 6/7 文件上报"归档损坏"的问题已消失)。
|
||||
- **载荷瘦了 15.8%**(1,034,328 → 870,488 字节):链接期去掉了 libc++abi 里一段**永远不会执行**的 C++ 名字还原器(只服务于"未捕获异常打印类型名"这条路径),再加上相同机器码折叠。**不损失任何功能,也不影响解压速度**。
|
||||
- **上传更顺手**:点上传按钮直接选文件或文件夹(不再弹二级菜单);支持**把文件/文件夹直接拖到页面上**上传。
|
||||
- **中英双语界面** + 项目主页右上角可切换语言说明。
|
||||
|
||||
### 已知缺口(诚实列出)
|
||||
|
||||
> 本节描述的是 **v1.9.2 发布时**的状态。此后补齐的两条缺口 —— 加密 ZIP/RAR 与
|
||||
> 7z `-mhe=on` 加密头 —— 已随 **v1.9.3M** 发布。详见 README 的「压缩包支持」一节、
|
||||
> [`CHANGELOG.md`](../CHANGELOG.md) 的 `[v1.9.3M]` 段,以及 `HANDOVER.md` §十一 / §十二。
|
||||
|
||||
- 带密码的 ZIP / RAR / 7z:**拒绝解压**(引擎有解密能力,但密码输入 UI/API 还没接,临时先挡掉)。
|
||||
- 7z `-mhe=on` **加密头**:暂不支持(需要自研头解析器)。这是 7z 侧唯一已知缺口。
|
||||
|
||||
---
|
||||
|
||||
## 四、使用简介(三步上手)
|
||||
|
||||
### 1. 把 ELF 发到 PS5
|
||||
PS5 上先运行一个 ELF 加载器(端口 `9021` 常见),然后在 PC 上:
|
||||
|
||||
```sh
|
||||
# 把 PS5_IP 换成你主机实际 IP
|
||||
nc -q0 <PS5的IP> 9021 < web-file-mgr-v1.9.2.elf
|
||||
```
|
||||
|
||||
PS5 屏幕会弹通知,显示实际监听端口(默认 `8888`)。首次运行还会在主屏 Media 分类装一个「PS5 Web File Manager」快捷方式。
|
||||
|
||||
### 2. 浏览器打开
|
||||
同一局域网下,任意设备浏览器打开:
|
||||
|
||||
```
|
||||
http://<PS5的IP>:8888/
|
||||
```
|
||||
|
||||
PS5 自带浏览器也能开。
|
||||
|
||||
### 3. 日常操作
|
||||
- **浏览/管理**:列文件、排序、改权限、复制/移动/重命名/删除、原地编辑文本(≤1 MiB 的 txt/json/js/c/h…)。
|
||||
- **上传下载**:单文件或文件夹树上传(也可直接拖拽到页面);文件夹/多选以 `.tar` 流式下载。
|
||||
- **解压**:在文件列表里点 ZIP / RAR / 7z 的「解压」按钮即可。大档案(>480 GiB)会弹确认提示,确认后用更宽松的限额解压。
|
||||
- **冲突策略**:同名文件默认拒绝覆盖;需要时可选覆盖或合并。
|
||||
|
||||
---
|
||||
|
||||
## 五、下载与校验
|
||||
|
||||
> 请在 GitHub Release 页面下载:`https://github.com/lishersong/ps5-web-file-manager/releases/tag/v1.9.2`
|
||||
|
||||
发布文件:`web-file-mgr-v1.9.2.elf`
|
||||
|
||||
| 项目 | 值 |
|
||||
|---|---|
|
||||
| 大小 | 870,488 字节(约 850 KiB) |
|
||||
| sha256 | `177e90fecf93a0251e83f67884fba4551051be248330b0d70fda8ea732f88e84` |
|
||||
| 文件类型 | ELF 64-bit LSB,x86-64(e_machine `0x003e`,即 PS5 目标三元组) |
|
||||
|
||||
下载后建议先核对 sha256 再发到主机:
|
||||
|
||||
```sh
|
||||
sha256sum web-file-mgr-v1.9.2.elf
|
||||
# 应等于 177e90fecf93a0251e83f67884fba4551051be248330b0d70fda8ea732f88e84
|
||||
```
|
||||
|
||||
> 本次构建是**可复现**的:同一份源码树连编两次 sha256 完全相同。所以这个哈希可以放心当交付指纹用。
|
||||
|
||||
---
|
||||
|
||||
## 六、免责声明
|
||||
|
||||
非官方自制软件,仅在已越狱 PS5 上运行。使用风险自负——作者不对损坏、数据丢失、账号处罚或保修影响负责。请勿再分发 Sony 专有内容。依 GPLv3+,本分支修改后的源码已公开在上面的仓库。
|
||||
|
||||
---
|
||||
|
||||
*想继续补 `.tar.gz` / `.xz` 等格式、或接上密码解压 UI 的,欢迎在仓库提 issue。*
|
||||
+29
-1703
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,285 @@
|
||||
# 真机验证清单:解压速度(10–40 MB/s 到底卡在哪)
|
||||
|
||||
> ### ⚑ 本清单的终局:停在第 1 步之前(2026-09-23 18:15,用户决定)
|
||||
>
|
||||
> **`T_copy` 没做;RAR 多线程(#51)不做;生产代码一行不动、不刷机 —— 11 分钟维持现状。**
|
||||
>
|
||||
> 理由:MT 的收益在 PC 上**已实测**(下面「第三件」2.45×/我们引擎 1.40–1.74×),
|
||||
> 但**能不能兑现,刚好吊在本文档第 1 步那个实验上** —— 而 MT 只并行解码、写盘恒为主线程
|
||||
> 串行(`unpack50mt.cpp` 的 `UnpWriteBuf()` 只在主线程调,见 §六 终局块),
|
||||
> 所以那个实验的结果很可能是「收工」。赌一次的成本(改构建 + 刷机 + 重跑 11 分钟 + 真机验证)
|
||||
> 压在不确定收益上 ⇒ **不做**。
|
||||
>
|
||||
> **下面全部内容保留**,作为「将来重开时的现成答案」。
|
||||
> ⚠️ 重开的**第一个动作是第 1 步的 `T_copy`,不是改构建**。
|
||||
|
||||
> ### ⚠️⚠️ 2026-09-23 深夜(第二次更正):**归档参数已拿到、MT 已实测、最大嫌疑已排除**
|
||||
>
|
||||
> **口径修正**:包不是 18 GB,是 **3 个分卷 ≈ 11.6 GB**(4 GB + 4 GB + 3.6 GB)。
|
||||
> **下文凡写「18 GB」处,一律按「≈11.6 GB」读**;核心算术已在本块重算。
|
||||
>
|
||||
> **① 真实归档参数(从 part3 的头实测,该文件随后被清理)**
|
||||
> RAR **5**、**卷 3/3**、**锁定**、**不是固实**(直解主头 `archive flags=0x0013`,`MHFL_SOLID=0x0004` 未置位)、
|
||||
> 压缩参数 **`-m1 -md=4g`**(**最快档 + 4 GiB 字典**)。关键条目 `PPSA16608.exfat`
|
||||
> **19.49 GB → 打包 3.83 GB(ratio 5.09)**,并**整段都在 part3 内**。
|
||||
> 详见 `docs/EXTRACTION-PERF.md` §六 第二次更正块。
|
||||
>
|
||||
> **② 最大嫌疑(scan 白解码一遍 = 2×)已排除。** 那建立在「固实 skip 必须解码」
|
||||
> (`dll.cpp:341` 只在 `!Arc.Solid` 走廉价 `SeekToNext()`)之上;**本档非固实** ⇒ 我们的 scan 不解码。
|
||||
>
|
||||
> **③ MT 收益已实测 = ~2.4×**(真实形状夹具:`-m1 -md4g`、5:1 可压缩、2 GB、`-mt1` 386→`-mt8` 946 MB/s 解压后)。
|
||||
> 接线只需 **1 个编译开关 + 1 个链接开关**(`-DRAR_SMP` + `-lpthread`),**不必改 vendored 源码、
|
||||
> 不必增删源文件** —— 早期版本写的「还要加 `threadmisc.cpp`」**是错的**:`threadpool.cpp:5` 已经
|
||||
> `#include "threadmisc.cpp"`(实测 `nm unrar7_threadpool.o` 里有 `T GetNumberOfThreads`),再加会重符号。
|
||||
>
|
||||
> **④ 但矛头现在指向「PS5 写路径」——所以本清单第 1 件事(`T_copy`)不变,而且更该先做。**
|
||||
> - 单线程解码在 PC 上同形状就是 **386–505 MB/s**;PS5 单核按 1/3 算也 ~130 MB/s。
|
||||
> - 真机:**只算 `.exfat` 一项**就是 19.49 GB ÷ 660 s = **≥29.5 MB/s 解压后**。
|
||||
> - **两次独立操作撞同一个数**:上传(写 11.6 GB)**30–40 MB/s** = 解压(写 ≥19.5 GB)≈30 MB/s。
|
||||
> ⇒ 优先怀疑这条写路径的上限就是 **30–40 MB/s**。**若 `T_copy` 也 ~10 分钟 ⇒ 收工,MT 不做**
|
||||
> (做了会被 I/O 吃掉);**若 `T_copy` 明显快 ⇒ 立刻上 MT,那 2.4× 是真的**。
|
||||
>
|
||||
> 下面是原始判断(保留),**优先级以本块为准**。
|
||||
|
||||
> ### ⚠️ 2026-09-23 晚 更正 —— 本文档的起点判断已反转。
|
||||
> 本文最初的前提是「已排除解码器」,那建立在两条现已作废的论证上:
|
||||
> ① 拿 **PS5 上解 RAR** 的 28 MiB/s 去比 **PC 上解 7z** 的 427 MiB/s(不同格式/解码器/机器);
|
||||
> ② UI 速度的 4× 摆动(已自行降级为 250 ms 采样噪声的弱证据)。
|
||||
>
|
||||
> **新增的三条事实把方向翻了过来**:格式是 **RAR**(解码就是 rarlab 的 UnRAR 库本身);
|
||||
> 包原本在 PC 上、经插件上传进 PS5;上传实测 30–40 MB/s。随后查代码发现:
|
||||
> **我们的 POSIX 构建没有定义 `RAR_SMP`**(`os.hpp:43-45` 的 `#define RAR_SMP` 在
|
||||
> `#ifdef _WIN_ALL` 内;官方 POSIX makefile 第 11 行是 `DEFINES=... -DRAR_SMP`),
|
||||
> 后果是 `unpack50mt.cpp`(`Unpack::Unpack5MT`,多线程 RAR5 解压器)**根本没编进来**,
|
||||
> `unpack.cpp` 的 MT 分支整段不参与编译 ⇒ **RAR 解码在 PS5 上是单线程的**。
|
||||
>
|
||||
> ⇒ **新的首要假设:28 MiB/s ≈ 单线程 RAR5 解码的正常量级。**
|
||||
> 详细更正与代码行号见 `docs/EXTRACTION-PERF.md` §六 文首更正块。
|
||||
> **下文按「实测 → 判读」仍有效;但第 2 步(读粒度)对 RAR 不适用**,已加注。
|
||||
|
||||
## 已到手的数据(2026-09-23 真机)
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 包大小 | **≈11.6 GB 压缩包**(4 GB + 4 GB + 3.6 GB 三个分卷;✅ 2026-09-23 晚更正,原写 18 GB 有误) |
|
||||
| 条目数 | 1252 |
|
||||
| 总耗时 | **11 分钟 = 660 s**(UI 显示) |
|
||||
| **UI 速度口径** | **解压后字节**(`src/rar_extract.c:766` 在 `UCM_PROCESSDATA` 里 `bytes_done += p2`)⇒ 10–40 MB/s 是「吐出数据的速度」 |
|
||||
| 平均吞吐(输入侧) | **≥ 17.6 MB/s 打包字节**(11.6 GB ÷ 660 s) |
|
||||
| 平均吞吐(输出侧) | **≥ 29.5 MB/s 解压后**——只算 `PPSA16608.exfat` 一项就是 19.49 GB ÷ 660 s |
|
||||
| 格式 | **RAR 5** ✅(头 8 字节 `52 61 72 21 1A 07 01 00`) |
|
||||
| 固实 | **不是固实** ✅(主头 `MHFL_SOLID=0x0004` 未置位) |
|
||||
| 压缩参数 | **`-m1 -md=4g`**(最快档 + 4 GiB 字典)✅ |
|
||||
| 关键条目 | `PPSA16608.exfat` 19 493 027 840 B → 打包 3 831 295 727 B(ratio **5.09**),整段在 part3 内 |
|
||||
| 来源 | 包原本在 **PC 上**,**经插件本身上传**进 PS5,上传速度 **30–40 MB/s**(写 11.6 GB) |
|
||||
| 源 / 目标设备 | **待确认** ⬅ **现在最关键的一条**(内置 SSD / 外置 USB / 是否同盘) |
|
||||
| 解压后总大小 | **待确认** ⬅ 决定输出侧吞吐的确切值(≥19.49 GB 已知) |
|
||||
|
||||
「18 GB 是压缩包大小」这条把口径钉住了,而且给出一个**不需要知道解压后大小的硬上界**:
|
||||
|
||||
```
|
||||
源盘必须在 660 s 内交出 18 GB 的归档数据
|
||||
⇒ 整条流水线的平均吞吐上界 = 18 GB ÷ 660 s ≈ 28 MiB/s
|
||||
```
|
||||
|
||||
解压后有多大都不影响这个上界 —— **无论解码多快,源盘就只有这个交付速度**。
|
||||
所以第 1 步的 `T_copy` 和它**可以直接比大小**(同一个 18 GB 文件、同一对设备)。
|
||||
|
||||
两个立刻可用的推论:
|
||||
|
||||
1. **per-entry(小文件)开销解释不了它。** 1252 个文件、平均 14.7 MB,不是「几万个小文件」那种
|
||||
形态;建文件 + rename 这两种 per-entry 成本加起来分摊到 0.53 s/文件 里微乎其微。
|
||||
2. **UI 上那个 10–40 MB/s 是瞬时值,不是平均值。** 进度回调只在累计 ≥ 1 MiB 时上报
|
||||
(`src/zip_extract.c:123`),`src/task.c:202-206` 每 **250 ms** 采一次样。250 ms 窗口 +
|
||||
写缓冲突发会让速度在「忽停忽走」之间跳,**波动里含相当比例的采样噪声**。
|
||||
⇒ 只有「解压后字节 ÷ 总耗时」可信;那 4× 的摆动只能说明「不是恒定负载」,不能单独定案。
|
||||
|
||||
---
|
||||
|
||||
## ★ 你要在真机上做的事(就两件)
|
||||
|
||||
### 第一件:补一个数字 `T_copy`(3 分钟操作 + 一次等待)
|
||||
|
||||
1. 把那个 **18 GB 的包**,用插件自己的**复制**功能,复制到**解压时输出所在的那块盘**
|
||||
2. **记下耗时**(UI 上有)
|
||||
3. 顺便记下:这个包**原本在哪**(内置 SSD / 外置 USB),**解压输出到哪**(同盘还是另一块)
|
||||
|
||||
判读(就在 `T_copy` 与 **660 s** 之间比):
|
||||
|
||||
| `T_copy` | 结论 | 我接下来做什么 |
|
||||
|---|---|---|
|
||||
| **≥ 10 分钟** | 存储物理极限,与代码无关 | **收工**,解码优化全部关闭(#48 取消) |
|
||||
| **1–2 分钟** | 解压比同一对设备上的纯搬运慢 5–10× ⇒ **代码里有真问题** | 做 #48:`vol_read()` 加 read-ahead |
|
||||
| 介于中间 | 混合 | 按字节数扣掉 I/O 分量,差值才是能改的部分 |
|
||||
|
||||
> 为什么这个数字这么关键:它是**同一台机器、同一对设备、同一份代码**,只把「解压」换成
|
||||
> 「纯搬运」。不需要造新包、不需要第二块盘、不需要任何假设。上面那 4 个对照实验(第 3 步)
|
||||
> 只在它的结论模糊时才需要。
|
||||
|
||||
### 第二件:两个小事实 —— **已答一半,剩两个待确认**
|
||||
|
||||
1. ~~RAR4 还是 RAR5?~~ **RAR5 ✅**;~~是否固实?~~ **非固实 ✅**;压缩参数 **`-m1 -md=4g` ✅**
|
||||
(都从 part3 的头上直接读到,见文首第二次更正块 ①)。
|
||||
顺带确认:**UI 的口径是解压后字节**(`src/rar_extract.c:766`)。
|
||||
2. **还缺两个数(都很便宜)**:
|
||||
- **解压后总大小** —— 决定输出侧吞吐。已知 ≥19.49 GB(仅 `.exfat` 一项)。
|
||||
插件跑解压时 UI 上的 `total` 就是它(`task->total = p->bytes_total`,`src/extract.c:64`)。
|
||||
- **源 / 目标设备** —— 包在哪块盘、解压输出写到哪块盘、是否同一块盘。
|
||||
这一条现在比什么都重要:写路径被怀疑是墙(文首块 ④)。
|
||||
|
||||
### 第三件(我已做完,不用上真机):多线程 RAR5 的收益
|
||||
|
||||
用 WinRAR 自带的 **UnRAR 7.23**(`-mt<N>` 开关可解析,虽然不出现在 `-?` 帮助里)
|
||||
在一份**按真实形状**造的夹具上做 A/B(`-m1 -md4g`、5.27:1 可压缩、2 GB、分卷):
|
||||
|
||||
| 线程 | 耗时 | 解压后吞吐 | 加速 |
|
||||
|---:|---:|---:|---:|
|
||||
| 1 | 5.18 s | 386 MB/s | 1.00× |
|
||||
| 4 | 2.39 s | 835 MB/s | 2.16× |
|
||||
| **8** | **2.11 s** | **946 MB/s** | **2.45×** |
|
||||
|
||||
⇒ **收益 2.4×,且已在真实形状上验证**(非固实版本 2.42×,量级一致)。
|
||||
接线只需 **1 个编译开关 + 1 个链接开关**(`-DRAR_SMP` / `-lpthread`),**不用改 vendored 源码、
|
||||
不用增删源文件**(早期写的 `threadmisc.cpp` 那一条经实测作废,见 §六 块 ④ 行内更正)——
|
||||
原因与行号见 `docs/EXTRACTION-PERF.md` §六 第二次更正块 ④。
|
||||
|
||||
> ⚠️ 但结论顺序仍以第一件为准:**先 `T_copy`**。如果墙是 30–40 MB/s 的写路径,
|
||||
> 这 2.45× 会被 I/O 完全吃掉,做了等于白做。
|
||||
> ⇒ **闭环(2026-09-23 18:15):`T_copy` 没做,用户决定不做这项了**(见文首终局块)。
|
||||
> 本节的实测数据**保留** —— 将来重开时它就是收益依据;但**第一步仍是 `T_copy`**。
|
||||
|
||||
---
|
||||
|
||||
## 第 1 步(决定性实验,= 上面「第一件」):用插件自己的「复制」功能做 I/O 基线
|
||||
|
||||
**这是目前唯一能把「存储」和「我们的代码」一刀切开的实验,而且零改动。**
|
||||
|
||||
插件里 **`TASK_COPY` 是已实现功能**,并且对 ≥ 256 MiB 的文件自动走
|
||||
`copy_file_pipeline()`(`src/filemgr.c:836`,阈值见 `:37`):**3 个 slot × 8 MiB、4096 字节对齐、
|
||||
独立读线程 + 独立写线程**。也就是说,它就是「同一台 PS5、同一对设备、同一份代码,
|
||||
只把解压那一步换成纯搬运」。
|
||||
|
||||
**操作**:把那个 18 GB 的包,用插件自己的复制功能,复制到**解压时输出所在的那块盘**上。
|
||||
记下耗时 `T_copy`。
|
||||
|
||||
**判读:**
|
||||
|
||||
| 观察到 | 结论 | 下一步 |
|
||||
|---|---|---|
|
||||
| `T_copy` ≈ 10 分钟或更多 | **存储物理极限**,与我们的代码无关 | 收工。解码优化全部搁置 |
|
||||
| `T_copy` ≈ 1–2 分钟(远小于 660 s) | 我们的解压比同一对设备上的纯 I/O **慢 5–10×** ⇒ **代码里有真问题** | 进第 2、第 4 步 |
|
||||
| 介于两者之间 | 混合 | 按字节数把 I/O 分量扣掉,差值才是我们能动的部分 |
|
||||
|
||||
严格比较要看**总移动字节**:复制读 18 GB + 写 18 GB = 36 GB;解压读 ≤ 18 GB、写 = 解压后大小。
|
||||
所以「复制 36 GB 用了多久」和「解压搬了 (18 GB + 解压后大小) 用了 660 s」才是同一口径。
|
||||
|
||||
> 为什么这个实验优于第 3 步那一堆:它不需要造新包、不需要两块盘、不需要任何假设,
|
||||
> 而且**测的就是出事的那对设备**。第 3 步只在第 1 步结论模糊时才需要。
|
||||
|
||||
---
|
||||
|
||||
## 第 2 步:读写粒度审计 —— ~~目前唯一可疑的代码级病因~~(⚠️ **对 RAR 不适用**)
|
||||
|
||||
> **2026-09-23 晚加注:本节整段只对 7z / ZIP 有效。** 它比较的是「引擎内部缓冲大小」,
|
||||
> 而 RAR 的归档 I/O 在 UnRAR 自己的 `File` 类里(我们按路径打开,
|
||||
> `src/rar_extract.c:1059`),我们的回调只收到解压后的数据 —— **读粒度我们改不了**。
|
||||
> 唯一还能对上 RAR 的那半句是「**写入**粒度」,而 RAR 的写出也在 UnRAR 内。
|
||||
> ⇒ **真机工作负载是 RAR 时,本节没有可操作性**;留着是为了 7z/ZIP 场景。
|
||||
> 顺带:本表里 RAR 那行写的 4 MiB 是 `File::CopyBufferSize()`
|
||||
> (`third_party/unrar7/file.hpp:148-153`),那是**文件复制**的缓冲,**不是解压读取缓冲** ——
|
||||
> 原文引用错了行,这条勘误一并记在这里。
|
||||
|
||||
把四条路径的**单次请求大小**摊开看(已核对源码):
|
||||
|
||||
| 路径 | 源侧读粒度 | 目标侧写粒度 |
|
||||
|---|---|---|
|
||||
| **复制**(pipeline,≥ 256 MiB) | **8 MiB** × 3 slot,4096 对齐,独立读线程 | 8 MiB |
|
||||
| **7z** | chain `SZ_IN_CHUNK = 256 KiB`(`src/sevenz_chain.c:87`);MT 路径 `inBufSize_MT = 1 MiB` | `SZ_OUT_CHUNK = 64 KiB`(`src/sevenz_chain.c:94`) |
|
||||
| **ZIP** | `ZIPX_IO_BUFFER = 128 KiB`(`src/zip_extract.c:32`) | 128 KiB(`src/zip_extract.c:900`) |
|
||||
| **RAR** | UnRAR 内部 `File::CopyBufferSize() = 4 MiB`(`third_party/unrar7/file.hpp:148-153`) | 4 MiB |
|
||||
|
||||
⇒ 结论一:**三个引擎都不是「小读」病理**,最小也有 128 KiB,不是 4 KB 那种灾难。
|
||||
|
||||
⇒ 结论二:**但都比复制小 32–64 倍**。
|
||||
|
||||
为什么这可能正是病根 —— 在**延迟主导**的设备上,吞吐 ≈ 单次请求大小 ÷ 每次请求的等效延迟:
|
||||
|
||||
```
|
||||
256 KiB / 10 ms = 25 MB/s ← 正好落在实测 10–40 MB/s 的中间
|
||||
8 MiB / 10 ms = 800 MB/s ← 复制路径不会撞这个上限
|
||||
```
|
||||
|
||||
这个算术顺带解释两件事:
|
||||
|
||||
- **为什么吞吐会 4× 摆动**:等效延迟随设备状态(HDD 寻道、USB 桥接、写缓存回刷)变化,
|
||||
线性映射到吞吐上就是大幅波动。
|
||||
- **为什么「条目平均 14.7 MB」没能救我们**:per-entry 成本的确不是主因,但**读请求粒度**是另一回事 ——
|
||||
它由引擎内部缓冲决定,与条目大小无关。
|
||||
|
||||
最可能的场景是**源和目标在同一块盘**(外置 USB HDD 上解压到同一块盘):读流和写流同时存在,
|
||||
磁头来回跑,OS read-ahead 被写回刷反复打断,于是每次请求退化成一次寻道 —— 这正好让上面那个
|
||||
算术成立。第 1 步如果出现「复制明显快于解压」,就是在支持它(复制的寻道次数只有解压的 1/32)。
|
||||
|
||||
**如果第 1 步指向这里,改动面其实很小**:所有 7z 的读都只经过 `src/sevenz_volstream.c` 的
|
||||
`vol_read()` 这一个函数(ZIP/RAR 同理在 `src/zipx_volstream.c`)。在那里加一层 read-ahead
|
||||
(向后 seek 时丢弃缓存)即可,**不需要动解码器**。但这要等第 1 步的结论,现在不写。
|
||||
|
||||
---
|
||||
|
||||
## 第 3 步:四个对照实验(零改动)—— 第 1 步结论模糊时才需要
|
||||
|
||||
| 实验 | 做法 | 若结果为 A | 若结果为 B |
|
||||
|---|---|---|---|
|
||||
| **A 换简单包** | 造一个「**单一大文件**(如 10 GB 伪随机数据)」的 zip,放内置 SSD,解到内置 SSD | 吞吐跳到 **150+ MiB/s** ⇒ 慢在**条目数 / per-entry 开销** | 仍是 10–40 ⇒ 慢在**存储或 I/O 模式**,与条目数无关 |
|
||||
| **B 换源设备** | 同一个包,分别放**内置 SSD** 与**外置 USB** | 两者差别巨大 ⇒ 就是源盘带宽 | 两者一样慢 ⇒ 不是源盘 |
|
||||
| **C 换目标设备** | 同一个包,分别解到**内置**与**外置** | 差别巨大 ⇒ 写入端是瓶颈 | 一样慢 ⇒ 不是目标盘 |
|
||||
| **D 同盘 vs 异盘** | 包与输出在同一块盘 / 在两块盘 | 同盘明显更慢 ⇒ 读写争用(这也是第 2 步最看好的假设) | — |
|
||||
|
||||
> 参考量级:机械/低成本 USB HDD 顺序读写约 30–80 MB/s,小文件随机访问降到 1–10 MB/s。
|
||||
|
||||
---
|
||||
|
||||
## 第 4 步:只有在第 1/3 步指向「我们的代码」时才做(要改代码)
|
||||
|
||||
1. **阶段计时**:在四个阶段边界累加时间戳 —— `scan` / `read+decode` / `write staging` /
|
||||
`publish`。用 `clock_gettime(CLOCK_MONOTONIC, ...)`(该原语已在三个引擎里用于进度上报,
|
||||
真机可用)。收尾用 `printf` 打进日志。
|
||||
2. **拆开 `read+decode`**:这一段目前分不开。可用「同一归档跑两遍(第二遍吃页缓存)」或
|
||||
「解到 /dev/null 类目标」来逼近 I/O 与解码的分界。
|
||||
3. **`SZX_MT_THREADS` 运行时可配**:现在是 `src/sevenz_mt.h:21` 的编译期 `#define 8`,
|
||||
做线程数 A/B 必须重编四次。改成运行时读(默认仍 8)会让实验便宜很多。
|
||||
4. **读粒度 A/B**:`SZ_IN_CHUNK` / `ZIPX_IO_BUFFER` 加大到 1–4 MiB 各构建一版,
|
||||
看第 1 步指出的那条曲线是否真的跟着动。
|
||||
5. 埋点要用**编译开关**控制,关闭时零开销 —— 避免影响已发布的产物指纹。
|
||||
|
||||
---
|
||||
|
||||
## 已排除的项(不要再花时间)
|
||||
|
||||
> ⚠️ 本表 2026-09-23 晚已按「格式 = RAR」重排。原表里「解码整体就不是瓶颈」这条前提已撤回,
|
||||
> 所以**同一批项现在被分成两类**:与 RAR 无关(不用看)、以及真已排除。**两类都不要花时间。**
|
||||
|
||||
**A. 与 RAR 工作负载无关**(它们是 7z / ZIP 的项;RAR 解码在 rarlab 库里,我们碰不到)
|
||||
|
||||
| 项 | 为什么无关 |
|
||||
|---|---|
|
||||
| CRC 硬件化 | 对 7z/ZIP 才值 4–5%;**RAR 的 CRC 由 UnRAR 自己算** |
|
||||
| MT 扩到 BCJ2 / 多 folder | 纯 7z 概念 |
|
||||
| ZIP inflate 换 libdeflate | ZIP 专属 |
|
||||
| AES-NI | 只影响 ZIP 加密流与 7zAES;RAR 加密走 UnRAR 自己的 rijndael |
|
||||
| 读粒度 / read-ahead(#48) | RAR 按路径自读(`src/rar_extract.c:1059` 的 `od.ArcName`),回调只收解压后数据,**插不进去** |
|
||||
|
||||
**B. 真已排除**(与格式无关,或已结论)
|
||||
|
||||
| 项 | 为什么排除 |
|
||||
|---|---|
|
||||
| 与 7-Zip 比倍数 | 真机上没有可比对象(上游那个 `wfm-7zip-helper.elf` 单独分发,本仓刻意不走 helper 路线);而且**那是 7z 的对比,与 RAR 负载无关** |
|
||||
| 逐条目 fsync | 已经全部移除(2026-09-16) |
|
||||
| 「条目太小」 | 1252 条 / 18 GB,平均 14.7 MB,不是小文件场景 |
|
||||
| 「我们的 RAR 解码器写得慢」 | 解码就是 vendored 的 UnRAR 库本体,我们只剩一层 facade |
|
||||
|
||||
**C. 唯一还站着的、且直接针对真实负载的一项** ⬅ **优先看这个**
|
||||
|
||||
| 项 | 状态 |
|
||||
|---|---|
|
||||
| **UnRAR RAR5 多线程解压(`RAR_SMP` + `-lpthread`)** | **❌ 不做(2026-09-23 用户决定,见文首终局块)**。我们没打开它;收益已实测(PC 上 2.45× / 我们引擎 1.40–1.74×)但被「写路径是否为墙」卡住,而验证它的 `T_copy` 未执行 ⇒ 搁置 |
|
||||
+71
-10
@@ -13,7 +13,7 @@
|
||||
|---|---|
|
||||
| 上游真的没有许可吗? | **否。上游是 GPL-3.0**,我们自己也是 GPL-3.0,两者一致 |
|
||||
| 现在能合法发布吗? | **能**。GPL-3.0 允许修改和再分发,只需满足归因 + 源码可得 |
|
||||
| 有没有真实风险? | 有 4 个,全部**可在 1 天内修完**,没有一个需要重写 |
|
||||
| 有没有真实风险? | 原本 5 个,**4 个已于 2026-09-23 关闭、第 5 个经确认接受现状**(见 §2);**从头到尾没有一个需要重写** |
|
||||
| 全量重写要多少人日? | **35–50 人日**(约 1.4–2 万行需重写,第三方 7.1 万行可直接复用) |
|
||||
| 值得重写吗? | **取决于目标**:想闭源/商用 → 必须重写;想开源分享 → **完全不必** |
|
||||
|
||||
@@ -57,11 +57,11 @@ LisherSong/ps5-web-file-manager (GPL-3.0) ← 本项目
|
||||
|
||||
| # | 风险 | 严重度 | 具体位置 | 修法 | 成本 |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | **归因缺失**:README Credits 列了 8 个项目,唯独漏了 `owendswang`;git 历史被重写成 "Initial import",上游作者署名在历史里也找不到 | 🟡 中 | `README.md:436-451` | Credits 加一行 + 补 `NOTICE` 文件 | 1 小时 |
|
||||
| 2 | **unRAR 与 GPL-3.0 的附加限制冲突**:UnRAR 许可禁止"用于开发 RAR 兼容压缩器",GPL-3.0 §7 禁止附加限制,严格讲不兼容 | 🟡 中 | `third_party/unrar7/` | 见 §2.1 | 0(接受)或 移除 RAR |
|
||||
| 3 | **ezremote 是 GPLv2**:README 只写 "GPLv2",未标 "or later"。GPLv2-only 与 GPL-3.0 **不兼容** | 🟡 中 | `src/pkg_info.c`(PKG 预览) | 确认其许可措辞;若 v2-only 则重写该模块(591 行) | 1 天 |
|
||||
| 4 | **二进制分发需提供源码**(GPL §6) | 🟢 低 | Release 里的 ELF | 仓库已公开,Release notes 附仓库链接即可 | 10 分钟 |
|
||||
| 5 | Title ID `FMGR88888` 与上游相同,可能与他人 payload 冲突 | 🟢 低 | `Makefile:20` | 换一个自定义 ID | 5 分钟 |
|
||||
| 1 | ~~**归因缺失**~~ → **已修正(2026-09-23)**:README Credits 已补上 `owendswang` 与 rarlab UnRAR / opello 镜像、并把已删除的 `third_party/unrar/` 从 Credits 里清理掉;`THIRD_PARTY_NOTICES` 本就正确。**残留**:git 历史里的 `5cb0b76 Initial import` 无法追溯上游提交 | 🟡 中 → 🟢 低 | `README.md` Credits | 历史归属只能在 Release 说明与 Credits 里声明;如需彻底重建历史得重写仓库 | 已完成 |
|
||||
| 2 | **unRAR 与 GPL-3.0 的附加限制冲突**:UnRAR 许可禁止"用于开发 RAR 兼容压缩器",GPL-3.0 §7 禁止附加限制,严格讲不兼容 | 🟡 中 | `third_party/unrar7/` | 见 §2.1 | 0(接受) |
|
||||
| 3 | ~~**ezremote 是 GPLv2**:README 只写 "GPLv2",未标 "or later"。GPLv2-only 与 GPL-3.0 **不兼容**~~ → **已定性并关闭(2026-09-23)**:确为 **GPL-2.0-only**,但逐行比对确认**我们未取其代码** | ✅ 已关闭 | `src/pkg_info.c`(PKG 预览) | 无需重写;README 中英措辞已改准,代码加了来源注记 —— 见 §2.2 | 0 |
|
||||
| 4 | ~~**二进制分发需提供源码**(GPL §6)~~ → **已补(2026-09-23)**:v1.9.2 Release 说明的 License 段原先只链了**上游**仓库,未链本仓库 | ✅ 已关闭 | Release 里的 ELF | 已用 `gh release edit` 加入 "Corresponding source for this binary" 段(资产未动、仍非 draft/pre、仍是 latest);今后发版沿用 `.build/release-notes-*.md` 模板 | 0 |
|
||||
| 5 | Title ID `FMGR88888` 与上游相同,可能与他人 payload 冲突 | 🟢 低 | `Makefile:21` | ~~换一个自定义 ID~~ → **2026-09-23 决定维持现状(用户确认)**。核对结论:`TITLE_ID` 只出现在两处 —— `src/app_installer.c:86`(PKG 安装目标目录 `/user/app/<TITLE_ID>`)和 `src/version.c:31`(`/api/version` 上报),**与解压 / 上传 / 浏览功能无关**,且本项目是上游 fork 的直接替代者(同 ID 便于覆盖安装)。代价:与上游 payload **不能共存**,同时装会撞目录 | 0(接受) |
|
||||
|
||||
### 2.1 关于 unRAR(风险 2 详解)
|
||||
|
||||
@@ -77,6 +77,64 @@ UnRAR 许可原文允许"在任何软件中处理 RAR 归档",但**禁止用
|
||||
|
||||
**建议 A**。风险等级实际很低:你只做解压不做压缩,本来就不触碰被禁止的那一条。
|
||||
|
||||
### 2.2 关于 ezremote(风险 3 详解,2026-09-23 结案)
|
||||
|
||||
**结论:风险不成立 —— 确为 `GPL-2.0-only`,但我们一行代码都没取。**
|
||||
|
||||
#### 第一步:许可措辞(用 `gh api` 查上游,不是猜)
|
||||
|
||||
```
|
||||
gh api repos/cy33hc/ps5-ezremote-client → license.spdx_id = "GPL-2.0"
|
||||
gh api .../contents/LICENSE → GNU GPL v2 全文(June 1991)
|
||||
gh api .../contents/source/actions.cpp → 无任何版权 / GPL 声明头
|
||||
gh api .../contents/source/clients/*.h → 同上,一个声明头都没有
|
||||
```
|
||||
|
||||
上游**全部源文件都不带许可声明**,唯一的许可陈述就是那份 GPLv2 全文。
|
||||
GPLv2 的 "or later" 只能由版权人**明示**授予(LICENSE 全文本身不含该授予),
|
||||
所以按其自身现状应认定为 **`GPL-2.0-only`**。
|
||||
|
||||
这一点很关键:**`GPL-2.0-only` 与 GPL-3.0 不兼容**(这正是 FSF 发明
|
||||
"GPLv2 or later" 惯例的原因)。所以「到底抄没抄」不是学术问题 ——
|
||||
抄了就必须重写这个模块。
|
||||
|
||||
#### 第二步:逐行比对(决定性的一步)
|
||||
|
||||
上游与 PKG 相关的**只有一个文件**:`source/sfo.cpp`(4,209 B / 141 行 / C++)。
|
||||
上游**没有 `.pkg` 容器解析器** —— tree 里的 `Ps5_ezRemote_Client_2.00.pkg`
|
||||
是一个已编译的 payload(10 MB),不是源码。
|
||||
|
||||
| 维度 | 上游 `sfo.cpp` | 我们的 `src/pkg_info.c` |
|
||||
|---|---|---|
|
||||
| 语言 | C++(`reinterpret_cast` / `std::map` / `namespace SFO`) | **C99** |
|
||||
| 函数分解 | 三个独立函数 `GetString` / `GetParams` / `GetParamsFromParamJson` | 单个 `append_sfo_fields(strbuf_t *, ...)` 直接流式产出 JSON 片段 |
|
||||
| 返回值 | `std::map<std::string,std::string>` | 写进 `strbuf_t`,无中间容器 |
|
||||
| JSON | 依赖 **json-c**(`json_tokener_parse` / `json_object_object_get`) | **自写分词器**(`parse_json_tokens()` / `json_object_value()`,在 `json_util.c`) |
|
||||
| 越界防护 | 仅两处 `size <` 检查,其余裸指针 + `reinterpret_cast` | 逐项校验(`count > SFO_ENTRY_MAX`、`index_end > size`、`key_offset < index_end`、`memchr` 找 NUL、`read_le32` 定长读) |
|
||||
| 覆盖范围 | 只有 SFO + param.json | 另有 **`.pkg` 条目表**(`PKG_CNT_MAGIC` / FIH / LIH / 条目类型 `0x1000` / `0x1200` / `0x121f` / `0x2000`)、本地化图标选择、两个 HTTP 端点 |
|
||||
|
||||
**唯一重合的是格式事实**:SFO magic `0x46535000`、20 字节头 / 16 字节条目、
|
||||
`keyofs`+`nameofs` 与 `valofs`+`dataofs` 的间接寻址。这些是 PS5 文件格式的客观规定,
|
||||
也是解析它的**唯一办法**(等同合并原则),不构成可保护的表达。
|
||||
|
||||
⇒ **不存在代码衍生关系**,`src/pkg_info.c` 无需重写。
|
||||
|
||||
#### 第三步:已落地的处置
|
||||
|
||||
1. `README.md` / `README.zh-CN.md` 的 Credits 条目:由 "Preview PKG info. License: GPLv2"
|
||||
改为明确写出 **GPL-2.0-only、与本项目不兼容、未取其代码**,并指向本节 ——
|
||||
后来人不会再把它当成"我们的依赖"去理解授权链。
|
||||
2. `src/pkg_info.c` 文件头补了来源注记(含比对理由与本节指引)。
|
||||
**纯注释,不改变编译产物** —— 已确认 `src/` 内没有 `__LINE__` / `__FILE__` 依赖,
|
||||
注释被预处理器丢弃后目标文件逐字节相同。
|
||||
3. 本节即为 provenance 留档。
|
||||
|
||||
> 适用范围:这套「先查许可措辞 → 再做逐行比对 → 最后把结论写进代码注记」的流程,
|
||||
> 对任何「README 里 credits 了某个项目」的情形都适用。因为 README 的 Credits 段落里
|
||||
> 混着两类东西:**真正 vendored 的代码**(minizip-ng / zlib / UnRAR)和
|
||||
> **只是参考了思路的项目**(websrv / ftpsrv / zftpd / etaHEN / ezremote)。
|
||||
> 两者在授权义务上完全不同,但排版把它们放在同一张列表里 —— 这就是这个疑问的由来。
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码归属盘点(决定重写成本的关键)
|
||||
@@ -123,7 +181,7 @@ v1.7 之后**新建**的文件(6,745 行),逐个检查其依赖:
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 做什么 | README 补 owendswang 署名、加 NOTICE、确认 ezremote 许可、Release 附源码链接、换 Title ID |
|
||||
| 做什么 | README 补 owendswang 署名、加 NOTICE、确认 ezremote 许可、Release 附源码链接、换 Title ID(→ 末项已于 2026-09-23 决定维持现状,见风险 5) |
|
||||
| 成本 | **0.5–1 人日** |
|
||||
| 收益 | 合规闭环,零功能损失,保留全部现有能力 |
|
||||
| 风险 | 无 |
|
||||
@@ -182,7 +240,7 @@ v1.7 之后**新建**的文件(6,745 行),逐个检查其依赖:
|
||||
| 4 | `main.js` 2,652 行单文件,无模块拆分 | `assets/main.js` | 按 view / api / task 拆模块 |
|
||||
| 5 | `filemgr.c` 2,458 行,路由 + 业务逻辑 + 平台调用混在一起 | `src/filemgr.c` | 分 handler / service / platform 三层 |
|
||||
| 6 | 测试靠手工脚本,未接入 `make test` | `tests/` | 接 CI,覆盖率可量化 |
|
||||
| 7 | 唯一功能缺口:7z `-mhe=on` 加密头 | `sevenz_extract.c` | 重写时一并补上(工作量约翻倍于现有 7z 头解析) |
|
||||
| 7 | ~~唯一功能缺口:7z `-mhe=on` 加密头~~ **已闭合**(2026-09-23,`src/sevenz_header.c`) | `sevenz_extract.c` | 无剩余格式缺口;重写时该项可删 |
|
||||
|
||||
---
|
||||
|
||||
@@ -206,8 +264,11 @@ v1.7 之后**新建**的文件(6,745 行),逐个检查其依赖:
|
||||
+ zipx_volstream.{c,h} 抽成独立仓库,MIT 授权,主项目作为 submodule 引用。
|
||||
→ 3,661 行成果立刻获得独立身份,且证明这部分是你的原创。
|
||||
|
||||
3. 确认 ezremote 是 "GPLv2" 还是 "GPLv2 or later";
|
||||
若是 v2-only,重写 pkg_info.c(591 行)或改用别的数据源。
|
||||
3. ~~确认 ezremote 是 "GPLv2" 还是 "GPLv2 or later";若是 v2-only,重写 pkg_info.c~~
|
||||
→ **已结案(2026-09-23)**:是 `GPL-2.0-only`,但逐行比对确认**我们未取其代码**,
|
||||
**无需重写**。比对记录与处置见 §2.2。
|
||||
4. ~~v1.9.2 Release 说明补本仓库链接(GPL §6 源码提供义务)~~
|
||||
→ **已补(2026-09-23)**,见风险 4 行。
|
||||
```
|
||||
|
||||
### 6.3 需要你回答的问题
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
# ELF 瘦身可行性分析
|
||||
|
||||
> 2026-09-20 · 环境:WSL Ubuntu-22.04 + `/opt/ps5-payload-sdk` + LLD 18.1.8
|
||||
> 基线产物 `web-file-mgr-v1.9.1.elf` = **1,034,328 B**
|
||||
> 复现脚本:`.build/_sizeprobe.sh`、`.build/_probe3.sh`、`.build/_slimtest.sh`、`.build/_slimtest2.sh`、`.build/_modsize.sh`
|
||||
|
||||
## 结论
|
||||
|
||||
> ✅ **4.1 + 4.2 已落地(2026-09-20)** — `src/demangle_stub.c` 已加入 `COMMON_SRCS`,
|
||||
> `LDFLAGS` 已加 `-Wl,--icf=all`。
|
||||
>
|
||||
> 产物:**870,488 B** · sha256 `24392aff6ddcca4dc0ea969cce356bd693ac52efe8a117d61ee1c814aa43cd07` · e_machine `0x003e`
|
||||
>
|
||||
> 落地后复核:`__cxa_demangle` 本体 11 B、`itanium_demangle` 符号 0、
|
||||
> `__cxa_throw` / `_Unwind_Resume` 等异常符号齐全、`sevenz_extract` / `rar_extract` /
|
||||
> `zipx_extract` / `MHD_start_daemon_va` 全部存在。
|
||||
> section 变化:`.text` 637,616 → 538,336、`.rela.dyn` 54,816 → 25,800、
|
||||
> `.eh_frame` 58,904 → 43,972、`.rodata` 162,016 → 153,664。
|
||||
>
|
||||
> 4.3(RELR)与 4.4 未启用 —— 待真机验证 / 权衡后再决定。
|
||||
|
||||
**能瘦,而且有一个"白捡"的 15.8%。**
|
||||
|
||||
| 方案 | 结果 | 降幅 | 风险 |
|
||||
|---|---:|---:|---|
|
||||
| 瘦身前 | 1,034,328 B | — | — |
|
||||
| **+ `__cxa_demangle` 桩** | **886,872 B** | **−147 KB** | 极低 |
|
||||
| **+ ICF 代码折叠(当前产物)** | **870,488 B** | **−164 KB (−15.8%)** | 极低 |
|
||||
| 再 + RELR 重定位压缩 | 854,176 B | −180 KB (−17.4%) | 需真机验证 |
|
||||
| 第三方改 `-Oz`(单列) | 969,504 B | −65 KB | 可能降速 |
|
||||
|
||||
前两项**不损失任何功能,也不影响解压速度** —— 去掉的是一段永远不会被执行的代码。
|
||||
|
||||
---
|
||||
|
||||
## 一、现状构成
|
||||
|
||||
数据源:`size -A` / `nm --size-sort --print-size` / 未 strip 重链接。
|
||||
|
||||
### section 级
|
||||
|
||||
| section | 大小 | 备注 |
|
||||
|---|---:|---|
|
||||
| `.text` | 637,616 | 代码主体 |
|
||||
| `.rodata` | 162,016 | 前端资源(gzip)+ 字符串常量 |
|
||||
| `.eh_frame` + `.eh_frame_hdr` | 71,444 | **C++ 异常展开表** |
|
||||
| `.gcc_except_table` | 8,500 | **C++ 异常处理器表** |
|
||||
| `.rela.dyn` | 54,816 | 2,119 × `R_X86_64_RELATIVE` + 165 × `GLOB_DAT` |
|
||||
| `.data.rel.ro` | 20,192 | 含指针的只读数据 |
|
||||
| `.dynsym` + `.dynstr` + `.gnu.hash` | 23,863 | 动态符号表(PIE 必需) |
|
||||
| `.text$LZMADECOPT` | 4,719 | LZMA 汇编解码器(提速 1.26×,保留) |
|
||||
| `.bss` | 56,416 | **不占文件体积** |
|
||||
|
||||
### 模块级(按目标文件归属,text+data)
|
||||
|
||||
| 模块 | 文件数 | text | data |
|
||||
|---|---:|---:|---:|
|
||||
| unrar7(RAR 引擎) | 48 | 314,528 | 658 |
|
||||
| libc++ / libc++abi / PS5 运行时 | — | ~161,900 | 20,738 |
|
||||
| **C++ 异常机制** | — | **151,288** | — |
|
||||
| zlib | 8 | 65,211 | 336 |
|
||||
| 7z SDK + 自研链 | 30 | 85,406 | 56 |
|
||||
| minizip-ng | 8 | 39,382 | 384 |
|
||||
| 自有代码 `src/` | 14 | 60,653 | 472 |
|
||||
| libmicrohttpd | — | 44,647 | — |
|
||||
| 前端资源(gzip 后) | 13 | 40,278 | 104 |
|
||||
|
||||
---
|
||||
|
||||
## 二、最大的单一发现:151 KB 的 C++ 异常机制
|
||||
|
||||
`nm` 统计出 **607 个 `itanium_demangle::*` 符号,合计 105,431 B** —— 这是 libc++abi 的
|
||||
C++ 名字还原器(`__cxa_demangle`),单独一块就占了整个 ELF 的 **10.2%,比 zlib 整个库还大**。
|
||||
|
||||
它是怎么被拉进来的:
|
||||
|
||||
1. `third_party/unrar7/dll.cpp` 用了 `catch (RAR_EXIT)` / `catch (std::bad_alloc&)`
|
||||
(`unpack.cpp` / `model.cpp` 里也有 `throw std::bad_alloc()`)
|
||||
2. 只要 C++ 异常运行时存在,libc++abi 的 `__cxa_throw` 链路就会引用 `__cxa_demangle`
|
||||
(用于打印未捕获异常的类型名)
|
||||
3. 链接器于是把整个 `cxa_demangle.cpp` 拉进来 —— 一个深度内联的模板解析器,
|
||||
展开成 607 个函数
|
||||
|
||||
连带被拖进来的还有 `libunwind`(21,953 B,栈回溯)和异常胶水(23,904 B),
|
||||
以及散落在每个 C++ 目标文件里的 `.eh_frame`(58,904 B)/ `.gcc_except_table`(8,500 B)。
|
||||
|
||||
**但这个 demangler 只在「未捕获异常」的诊断路径上才会被调用。**
|
||||
我们的 unrar7 走 DLL 模式,所有异常都在 `dll.cpp` 内部被 catch 掉,
|
||||
程序逻辑永远走不到那条路径。
|
||||
|
||||
---
|
||||
|
||||
## 三、实测(同一 WSL、同一份源码、同一个链接器)
|
||||
|
||||
| # | 变更 | 结果 | 差值 |
|
||||
|---|---|---:|---:|
|
||||
| E0 | 重新链接(校验基线) | 1,034,328 | 0 |
|
||||
| E1 | `-Wl,--icf=all` | 1,017,944 | −16,384 |
|
||||
| E2 | 注入 `__cxa_demangle` 桩 | 886,872 | **−147,456** |
|
||||
| E3 | 桩 + `--icf=all` | 870,488 | **−163,840** |
|
||||
| E4 | `-Wl,-z,pack-relative-relocs` | 985,248 | −49,080 |
|
||||
| E5 | `-Wl,-z,noseparate-code` | 1,034,328 | 0(无效) |
|
||||
| E7 | 桩 + ICF + RELR | 854,176 | −180,152 |
|
||||
| E8 | 第三方全部改 `-Oz` | 969,504 | −64,824 |
|
||||
|
||||
> E4 单独能省 49 KB,但与 ICF 组合后只剩 16 KB —— ICF 已经合并掉了一批重定位。
|
||||
|
||||
---
|
||||
|
||||
## 四、落地方案
|
||||
|
||||
### 4.1 立即可用:`__cxa_demangle` 桩(−147 KB)
|
||||
|
||||
新增 `src/demangle_stub.c`:
|
||||
|
||||
```c
|
||||
/* 只提供 __cxa_demangle 的桩,让 libc++abi 里 105 KB 的名字还原器
|
||||
* 不被链接进来。该函数只在打印「未捕获异常的类型名」时被调用;
|
||||
* 返回 NULL 时调用方退回打印 mangled 名,不影响任何业务流程。 */
|
||||
#include <stddef.h>
|
||||
|
||||
char *__cxa_demangle(const char *mangled, char *buf, size_t *len, int *status)
|
||||
{
|
||||
(void)mangled; (void)buf; (void)len;
|
||||
if (status) *status = -1;
|
||||
return NULL;
|
||||
}
|
||||
```
|
||||
|
||||
Makefile 里把它加进 `COMMON_SRCS`(PS5 与 Linux 两条链路都受益):
|
||||
|
||||
```make
|
||||
COMMON_SRCS := src/main.c src/websrv.c ... src/sevenz_mt.c src/demangle_stub.c
|
||||
```
|
||||
|
||||
**原理**:链接器解析 `__cxa_demangle` 引用时,命令行上的 `.o` 优先于归档成员,
|
||||
所以 `libc++abi.a` 里的 `cxa_demangle.o` 压根不会被取出。
|
||||
|
||||
**安全性(已实测验证)**:
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|---|---|
|
||||
| `__cxa_demangle` 本体大小 | **11 B**(我们的桩;原 demangler 入口是 1 701 B) |
|
||||
| `itanium_demangle::*` 符号残留 | **0** |
|
||||
| `__cxa_throw` | 存在 |
|
||||
| `__cxa_begin_catch` / `__cxa_end_catch` | 存在 |
|
||||
| `_Unwind_Resume` / `__gxx_personality_v0` | 存在 |
|
||||
|
||||
**唯一的行为变化**:万一真的出现未捕获异常,`std::terminate` 打印的是 mangled 名
|
||||
而不是可读名。解压逻辑、错误码、进度上报、HTTP 服务一概不受影响。
|
||||
|
||||
### 4.2 立即可用:ICF 代码折叠(−16 KB)
|
||||
|
||||
```make
|
||||
LDFLAGS := -Wl,--gc-sections -Wl,--icf=all
|
||||
```
|
||||
|
||||
lld 的 identical code folding,合并字节完全相同的函数。工具链已确认为 LLVM LLD 18。
|
||||
建议只加在 PS5 的 `LDFLAGS`,不动 Linux 链路(GNU ld 的 `--icf` 支持不完整)。
|
||||
|
||||
### 4.3 需真机验证:RELR 重定位压缩(−16 KB)
|
||||
|
||||
```make
|
||||
LDFLAGS += -Wl,-z,pack-relative-relocs
|
||||
```
|
||||
|
||||
把 2,119 条 `R_X86_64_RELATIVE`(24 B/条)压成 RELR 位图格式(8 B/条)。
|
||||
|
||||
**风险**:需要 PS5 的 ELF 加载器认得 `.relr.dyn`。如果加载器只处理 `.rela.dyn`,
|
||||
重定位根本不会执行 —— 表现是启动即崩。**先在一台机器上验证再推广。**
|
||||
|
||||
### 4.4 不建议作为默认项
|
||||
|
||||
| 项 | 收益 | 为什么不默认开 |
|
||||
|---|---:|---|
|
||||
| 第三方改 `-Oz` | −65 KB | 作用于 LZMA / Deflate / RAR 的热循环,解压速度有下降风险。要用先跑 `tests/bench_driver.py` 对比 |
|
||||
| 关掉 PPMd(`-DZ7_PPMD_SUPPORT`) | −10 KB | PPMd 压缩的 7z 就解不开了 —— 违背"功能完整" |
|
||||
| 去掉 zlib `deflate`(只留 inflate) | −18 KB | `mz_strm_zlib_write` 引用了它,需要桩或改库,收益/风险不划算 |
|
||||
|
||||
---
|
||||
|
||||
## 五、还能挖的(未实测,仅估算)
|
||||
|
||||
| 项 | 预估 | 代价 |
|
||||
|---|---:|---|
|
||||
| unrar7 去掉 C++ 异常(`throw`/`catch` 改错误码 + `-fno-exceptions -fno-rtti`) | −100~110 KB | 改 vendored 代码,需回归 163 checks。可回收 `.eh_frame` 59 KB + `.gcc_except_table` 8.5 KB + libunwind 22 KB + 异常胶水 |
|
||||
| LTO(`-flto=thin`) | −30~60 KB | 全量重编,第三方 `.o` 需统一编译选项;有一定概率反而提速 |
|
||||
| 前端资源改 LZMA 压缩(复用已有解码器,替代 gzip) | ~−10 KB | 改 `gen-asset-module.py` + `asset.c` |
|
||||
|
||||
理论极限在 700 KB 上下(−32%),但边际成本递增:4.1 + 4.2 用 20 行代码换 164 KB,
|
||||
而再往下 100 KB 要动 vendored 源码或验证加载器行为。
|
||||
|
||||
---
|
||||
|
||||
## 六、推荐执行顺序
|
||||
|
||||
1. ~~落 4.1 + 4.2 → 构建~~ ✅ **已完成(2026-09-20)**,产物 870,488 B
|
||||
2. 跑 163 checks(`tests/run-tests.sh` + `tests/run-sevenz-tests.sh`)确认无回归
|
||||
3. PS5 真机跑一轮 ZIP / RAR / 7z(含分卷)解压,确认行为不变
|
||||
4. 真机验证 4.3(RELR)后再决定是否加入
|
||||
5. 有需要再评估第五节的三项
|
||||
|
||||
> 本次改动只动链接期(新增一个 TU + 一个 lld 参数),未触碰任何解压逻辑,
|
||||
> 因此 163 checks 的预期是"逐条不变"。
|
||||
|
||||
**结果(2026-09-20)**:163 checks(ZIP 108 + RAR 27 + 7z 28)**0 失败**,
|
||||
`aeshe` 仍是已知的 `-mhe=on` 缺口。README / CHANGELOG / HANDOVER / 论坛帖里的
|
||||
产物指纹已同步为 870,488 B · sha256 `177e90fe…8e84`。
|
||||
|
||||
> 后续(2026-09-23):测试计数已变为 ZIP 140 + RAR 37 = 177(7z 套件 27 用例),
|
||||
> `aeshe` 缺口也已闭合;产物指纹随之更新。本节保留的是上面的历史测量值。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:v1.9.2 产物一致性验证(2026-09-20)
|
||||
|
||||
v1.9.2 是一次"只改内嵌版本号"的重发(原 `v1.9.1` tag 落后产出发布二进制的提交
|
||||
4 个提交)。为确认这次重发**真的**只动了版本号,在 WSL 里做了下面的验证。
|
||||
|
||||
### A.1 复现性实验(决定性证据)
|
||||
|
||||
当前工作区相对 `HEAD` 只有两处改动:`Makefile` 的 `VERSION_TAG` 与
|
||||
`assets/main.js` 的 `APP_VERSION_FALLBACK`。把这两处用 `sed` 回退成 `v1.9.1`
|
||||
后重新构建:
|
||||
|
||||
| 构建 | sha256 |
|
||||
|---|---|
|
||||
| 已发布的 v1.9.1 ELF | `24392aff6ddcca4dc0ea969cce356bd693ac52efe8a117d61ee1c814aa43cd07` |
|
||||
| 回退后重建的产物 | `24392aff6ddcca4dc0ea969cce356bd693ac52efe8a117d61ee1c814aa43cd07` |
|
||||
|
||||
**逐字节相同。** 再恢复 `v1.9.2` 重构,sha256 也精确回到 `177e90fe…8e84`。
|
||||
|
||||
→ 构建是**确定性**的,因此 v1.9.2 与 v1.9.1 的全部差异就等于那两处版本字面量。
|
||||
复现脚本:`.build/_repro.sh`。
|
||||
|
||||
### A.2 为什么原始字节 diff 有 5.5 万字节 —— 别被吓到
|
||||
|
||||
`cmp` 两个 ELF 会看到 **55,280 字节不同(6.35%)**,但这是链接器字符串池重排的
|
||||
副作用,不是代码变了:
|
||||
|
||||
| section | 差异字节 | 占该 section |
|
||||
|---|---:|---:|
|
||||
| `.rodata` | 53,496 | 34.8% |
|
||||
| `.text` | 1,543 | 0.3% |
|
||||
| `.rela.dyn` | 241 | 0.9% |
|
||||
|
||||
而**每个 section 的尺寸完全相同**(`.text` 538,336 = 538,336),段数也都 17 个。
|
||||
|
||||
机制:`.rodata` 里 7 字节的 `"v1.9.1\0"` 被换成 `"v1.9.2\0"` 后落点变了,其后
|
||||
所有字符串整体平移 7 字节 → 指向它们的 `lea rdi,[rip+disp]` 位移和 `.rela.dyn`
|
||||
重定位加数全部跟着变。
|
||||
|
||||
两条量化证据:
|
||||
|
||||
| 检查 | 结果 |
|
||||
|---|---|
|
||||
| 指令**助记符**序列(`objdump -d --no-show-raw-insn` 只取 mnemonic) | 141,780 条 vs 141,780 条,**完全一致** —— 没有任何指令被增删改 |
|
||||
| `.text` 差异字节的增量分布 | 1,543 个里 **1,506 个恰好是 −7**(正是那个 7 字节平移);`.rela.dyn` 241/241 个 8 字节字段减 7 |
|
||||
| 嵌入的 gzip 资产 | 6 个成员,5 个逐字节相同,唯一不同的是 `main.js`,且差异 = `APP_VERSION_FALLBACK` 那一行 |
|
||||
|
||||
脚本:`.build/_diffmap.py`、`.build/_operandcheck.sh`、`.build/_fieldcheck.py`、
|
||||
`.build/_verify_v192b.py`、`.build/_seccmp.py`。
|
||||
|
||||
### A.3 源码层的约束
|
||||
|
||||
`VERSION_TAG` 在源码里**只出现在字符串上下文**:
|
||||
|
||||
```
|
||||
src/version.c:29 json_escape(&b, VERSION_TAG);
|
||||
src/main.c:127 printf("version: %s\n", VERSION_TAG);
|
||||
src/main.c:147 notify_user("Web File Manager\nVersion: %s\nPort: %u", VERSION_TAG, port);
|
||||
```
|
||||
|
||||
没有任何算术、比较或分支依赖它,因此改版本号在语言层面就不可能改变控制流。
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:瘦身逐符号账目
|
||||
|
||||
在 **同一个 Makefile / 同一个 `VERSION_TAG`(v1.9.2)** 下重建三个变体,差异只落在
|
||||
"有没有桩"和"有没有 ICF"这两处,因此是干净的 A/B/C 对照。
|
||||
|
||||
| 变体 | 内容 | stripped | unstripped |
|
||||
|---|---|---:|---:|
|
||||
| `base` | 无桩、无 ICF(瘦身前) | **1,034,328** | 1,222,752 |
|
||||
| `nicf` | 有桩、无 ICF | **886,872** | 1,010,208 |
|
||||
| `new` | 有桩 + `--icf=all`(发布态) | **870,488** | 993,824 |
|
||||
|
||||
拆分:桩贡献 **−147,456 B**,ICF 再贡献 **−16,384 B**,合计 **−163,840 B(−15.8%)**。
|
||||
|
||||
### B.1 section 位移(base → new)
|
||||
|
||||
| section | base | new | 差值 |
|
||||
|---|---:|---:|---:|
|
||||
| `.text` | 637,616 | 538,336 | −99,280 |
|
||||
| `.rela.dyn` | 54,816 | 25,800 | −29,016 |
|
||||
| `.eh_frame` | 58,904 | 43,972 | −14,932 |
|
||||
| `.data.rel.ro` | 20,192 | 9,328 | −10,864 |
|
||||
| `.rodata` | 162,016 | 153,664 | −8,352 |
|
||||
| `.eh_frame_hdr` | 12,540 | 9,108 | −3,432 |
|
||||
| `.gcc_except_table` | 8,500 | 7,364 | −1,136 |
|
||||
| `.dynsym` / `.dynstr` / `.got` | — | — | −24 / −9 / −8 |
|
||||
|
||||
### B.2 符号集合差
|
||||
|
||||
| 项 | 数量 |
|
||||
|---|---:|
|
||||
| base 定义符号 | 2,657 |
|
||||
| new 定义符号 | 2,029 |
|
||||
| base → new **消失** | **628** |
|
||||
| base → new **新增** | **0** |
|
||||
|
||||
628 个消失符号的构成:
|
||||
|
||||
- `itanium_demangle::*` —— **607**
|
||||
- `GCC_except_table*` —— **21**(上面那批代码自己的异常表标签,不是独立函数)
|
||||
|
||||
### B.3 桩本体与 demangler 符号
|
||||
|
||||
| 变体 | `__cxa_demangle` 符号大小 | `itanium_demangle` 符号数 |
|
||||
|---|---:|---:|
|
||||
| base | 1,701 B(真身) | 607 |
|
||||
| nicf | **11 B**(我们的桩) | **0** |
|
||||
| new | **11 B** | **0** |
|
||||
|
||||
异常机制在所有三个变体里都完好:`__cxa_throw` / `__cxa_begin_catch` /
|
||||
`__cxa_end_catch` / `_Unwind_Resume` / `__gxx_personality_v0` /
|
||||
`__cxa_allocate_exception` / `__cxa_free_exception` 各 1 个,无变化。
|
||||
|
||||
自有 `src/` 关键符号(`ctx_fail` / `rarx_fail` / `szx_fail` / `fnv1a` /
|
||||
`nameset_init` / `remove_tree` / `ensure_parent_dirs` / `zipx_volume_detect` /
|
||||
`sevenz_extract` / `rar_extract` / `filemgr_api_request` 等)base 与 new 数量一致。
|
||||
|
||||
### B.4 ICF 折叠了什么
|
||||
|
||||
**符号数 2,029 → 2,029,一个没少** —— ICF 是"合并"不是"删除"。共 **76 个折叠组**,
|
||||
全部含具名符号。典型几类:
|
||||
|
||||
- 我们自己的同码副本:`ctx_fail == rarx_fail == szx_fail`、
|
||||
`nameset_init == szx_nameset_init`、`fnv1a == rarx_fnv1a == szx_fnv1a`、
|
||||
`remove_tree == szx_remove_tree`
|
||||
- C++ 的 `C1 == C2` / `D1 == D2` 构造析构对(编译器为同一函数生成两个 ABI 入口):
|
||||
`_ZN10CmdExtractC1EP11CommandData == ...C2...`、`_ZN4FileD1Ev == _ZN4FileD2Ev` 等
|
||||
- 只读常量表:`Sbox == _ZL1S`(AES 表在 `rijndael.cpp` 与 `Aes.c` 各一份)、
|
||||
`SHA256_K_ARRAY == _ZL1K`、`PPMD7_kExpEscape == _ZL9ExpEscape`
|
||||
- RARDLL 模式下被置空的 UI 函数、`mz_stream_read_int64 == read_uint64`、
|
||||
libunwind 的 `__unw_* == unw_*`、`__unw_resume == unw_resume`
|
||||
|
||||
**风险提示**:`--icf=all` 是 LLD 的激进模式,**不做地址敏感性检查**
|
||||
(`--icf=safe` 才会读 `.llvm_addrsig` 跳过被取地址的函数)。逐组核对下来这 76 组
|
||||
都是同码副本、没有"比较函数/常量表地址"的用法 —— 但这是人工判断,不是编译器给的
|
||||
保证。想绝对保守就把 `--icf=all` 换成 `--icf=safe`,代价是少省几 KB。
|
||||
|
||||
复现脚本:`.build/_whatremoved_v192.sh`(一次跑完三个变体 + 全部核对)。
|
||||
@@ -124,6 +124,9 @@ zipx_limits_profile(int profile) {
|
||||
Each entry is first written to a staging directory (`*.wfm-part-*`),
|
||||
`fsync()`'d, then atomically renamed into place. A failure mid-archive
|
||||
rolls back partial changes.
|
||||
*(Superseded 2026-09-16: the per-entry `fsync` was removed — all three
|
||||
engines now apply "sync nothing, rename everything". See
|
||||
`docs/EXTRACTION-PERF.md`.)*
|
||||
* Security checks run before any output file is opened:
|
||||
- encryption
|
||||
- path traversal (`..`), absolute POSIX paths, Windows drive letters
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
> This is the long-form maintainer's manual for the v1.8 archive-engine
|
||||
> expansion. It is written for the next developer, not the user. The
|
||||
> user-facing description lives in [`README.md → RAR extraction`](../README.md#rar-extraction);
|
||||
> user-facing description lives in [`README.md → Archive support`](../README.md#archive-support);
|
||||
> the release notes are in [`CHANGELOG.md`](../CHANGELOG.md). The vendoring
|
||||
> decision tree (and the v1.9 upgrade path) is at
|
||||
> [`third_party/unrar/VENDORED.md`](../third_party/unrar/VENDORED.md) — most
|
||||
> of the "why" questions are answered there, not here.
|
||||
> [`third_party/unrar7/VENDORED.md`](../third_party/unrar7/VENDORED.md) — most
|
||||
> of the "why" questions are answered there, not here. (v1.8 shipped that file
|
||||
> as `third_party/unrar/VENDORED.md`; the directory was renamed in v1.9.)
|
||||
|
||||
---
|
||||
|
||||
@@ -54,7 +55,8 @@ in `third_party/unrar/`, add a CXX link step to `Makefile`, switch
|
||||
`src/rar_extract.c` to the `RAROpenArchiveEx` / `RARSetPassword` DLL
|
||||
API. **The `rar_extract()` signature, the dispatch layer and the host
|
||||
tests do not need to change.** Full step-by-step recipe is in
|
||||
[`third_party/unrar/VENDORED.md`](../third_party/unrar/VENDORED.md).
|
||||
[`third_party/unrar7/VENDORED.md`](../third_party/unrar7/VENDORED.md) (the v1.8
|
||||
original was `third_party/unrar/VENDORED.md`).
|
||||
|
||||
---
|
||||
|
||||
@@ -144,6 +146,13 @@ progress callbacks and result-mapping logic are copied from
|
||||
engines can evolve independently. (Refactoring them into a
|
||||
`src/archive_common/` module is on the post-v1.9 roadmap; see §10.)
|
||||
|
||||
> **Note — superseded 2026-09-16.** The per-entry `fsync` described above was
|
||||
> removed. It cost 20–30 minutes on a 95k-file archive and bought nothing the
|
||||
> design needs: a crash mid-extract leaves the staging tree, which is discarded
|
||||
> on the next run, and publish is a rename-only phase. All three engines now
|
||||
> share the same "sync nothing, rename everything" policy. Measurements and the
|
||||
> accepted durability trade-off: `docs/EXTRACTION-PERF.md`.
|
||||
|
||||
### 3.3 Error mapping
|
||||
|
||||
`rar_translate_error()` in `src/rar_extract.c` maps the dmc_unrar
|
||||
@@ -502,7 +511,7 @@ git -c core.autocrlf=false commit -m "v1.8: RAR4/RAR5 single-volume unencrypted
|
||||
|
||||
### 10.1 v1.9 — full RAR (multi-volume + encrypted)
|
||||
|
||||
See [`third_party/unrar/VENDORED.md`](../third_party/unrar/VENDORED.md)
|
||||
See [`third_party/unrar7/VENDORED.md`](../third_party/unrar7/VENDORED.md)
|
||||
§"Upgrading to a fuller library (v1.9 plan)" for the migration recipe.
|
||||
The public `rar_extract()` signature and the dispatch layer do **not**
|
||||
need to change; only:
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|---|---|---|
|
||||
| `src/archive_extract.c`(124 行) | 本仓库 | 只做**后缀识别** + 输出目录名推导 |
|
||||
| `src/archive_helper.c`(732 行) | 本仓库 | **IPC 客户端**:启动 helper + Unix socket 协议 |
|
||||
| `wfm-7zip-helper.elf`(~百 MB 级) | `/data/wfm/`,**不在仓库里,单独分发** | 真正的解压 = **7-Zip 本体** |
|
||||
| `wfm-7zip-helper.elf`(**实测 1,017,616 B ≈ 1 MB**) | `/data/wfm/`,**不在仓库里,单独分发** | 真正的解压 = **7-Zip 本体** |
|
||||
|
||||
README 原文:
|
||||
|
||||
@@ -112,7 +112,7 @@ README 原文:
|
||||
| 压缩比筛查 | ✅ `max_ratio` 500/1000,**1 GiB 下限豁免**小文件 | `zip_extract.c` |
|
||||
| 磁盘空间预检 | ✅ `check_space()` 按**解压后总量**查 `statvfs` | `zip_extract.c:636` |
|
||||
| 路径穿越防护 | ✅ 有专项测试(`path traversal variants`) | 测试矩阵 |
|
||||
| 原子发布 | ✅ staging 目录 + 整 rename + 每 entry fsync | 三引擎统一 |
|
||||
| 原子发布 | ✅ staging 目录 + 整 rename(**无逐条目 fsync**,2026-09-16 起) | 三引擎统一 |
|
||||
| 冲突策略 | ✅ FAIL / OVERWRITE / MERGE,目录碰撞递归下钻 | 三引擎统一 |
|
||||
| 取消 | ✅ 条目粒度 | — |
|
||||
| 任务恢复 | ❌ **没有** | — |
|
||||
@@ -191,7 +191,7 @@ ZIP 108 + RAR 27 + 7z 28 = **163 checks**,0 失败(MinGW host)+ PS5 真机
|
||||
|
||||
1. **部署体验倒退** —— 用户要装两个文件,还得记住放 `/data/wfm/`;丢一个功能全废。现在单 ELF 是无状态交付,这是真实优势
|
||||
2. **安全护栏会一起丢** —— 走 7-Zip 就意味着放弃我们对 entries/ratio/空间/穿越的控制
|
||||
3. **helper 上游自己都不敢放进仓库**("separately distributed"),大概率是体积或许可原因,跟着走会继承同样的问题
|
||||
3. **helper 上游自己都不敢放进仓库**("separately distributed")。已实测它只有 **1,017,616 B ≈ 1 MB**(v1.9 release 资产),所以原因**不是体积**——更可能是许可(7-Zip 为 LGPL,其 unRAR 部分另带 "unRAR restriction")与交叉编译成本,跟着走会继承同样的问题
|
||||
4. **我们已经付过的成本会沉没** —— 7z 引擎(自解析 folder + pull 链 + 7zAES)+ 三类分卷抽象共约 3,600 行零耦合代码
|
||||
|
||||
### 一句话总结
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<div align="right">
|
||||
简体中文 · 开发者文档见 <a href="../README.zh-CN.md">README</a>
|
||||
</div>
|
||||
|
||||
# PS5 网页文件管理器 · 新手使用说明
|
||||
|
||||
> 适用版本:**v1.9.3M**(版本号末尾的 `M` = 改版,文末有解释)
|
||||
> 本文不假设你懂任何技术名词,照着做即可。
|
||||
|
||||
---
|
||||
|
||||
## 一、它到底是什么
|
||||
|
||||
一句话:**在你的 PS5 上开一个"网页版文件管理器"**。只要设备和 PS5 连着同一个 WiFi,用手机、电脑、甚至 PS5 自带的浏览器打开一个网址,就能像在电脑上一样管理 PS5 里的文件和插在 PS5 上的 U 盘。
|
||||
|
||||
### 能做的事
|
||||
|
||||
| 想干什么 | 可以吗 |
|
||||
|---|---|
|
||||
| 看文件、建文件夹、改名、复制、移动 | ✅ |
|
||||
| 电脑 ↔ PS5 互传文件 | ✅ |
|
||||
| 解压 ZIP / RAR / 7z(**带密码的也行**) | ✅ |
|
||||
| 改小的文本文件(`.txt` `.json` `.ini` 等) | ✅ |
|
||||
| 看图片(`.png` `.jpg` `.gif` `.webp` 等) | ✅ |
|
||||
| 安装 PKG | ✅ |
|
||||
| 改文件权限 | ✅ |
|
||||
|
||||
### 不能做的事(先说清楚,省得白试)
|
||||
|
||||
| 想干什么 | 可以吗 | 说明 |
|
||||
|---|---|---|
|
||||
| 把一堆文件**打包**成压缩包 | ❌ | 它只会"解",不会"压"。下载多个文件时会自动打成一个 `.tar` 包,那只是下载用的 |
|
||||
| 同时做两件事 | ❌ | 一个任务在跑的时候,其它操作会被拒绝(提示"有任务正在执行") |
|
||||
| 删错了找回 | ❌ | 删除是**永久**的,没有回收站 |
|
||||
| 解压 ZIP / RAR / 7z **以外**的格式 | ❌ | 比如 `.tar.gz`、`.iso`、`.xz` 现在不行,见第七节 |
|
||||
|
||||
---
|
||||
|
||||
## 二、三步把它跑起来
|
||||
|
||||
**第 1 步:PS5 上先运行一个"ELF 加载器"**(常见端口是 `9021`)。这一步取决于你用的越狱方案,这里不展开。
|
||||
|
||||
**第 2 步:在电脑上把程序发过去。** 打开终端(Windows 用 Git Bash / PowerShell 都行),执行:
|
||||
|
||||
```sh
|
||||
nc -q0 你的PS5的IP 9021 < web-file-mgr-v1.9.3M.elf
|
||||
```
|
||||
|
||||
例如你的 PS5 是 `192.168.1.50`:
|
||||
|
||||
```sh
|
||||
nc -q0 192.168.1.50 9021 < web-file-mgr-v1.9.3M.elf
|
||||
```
|
||||
|
||||
**第 3 步:看 PS5 左上角的通知。** 它会显示程序名、版本和一个网址,通常是:
|
||||
|
||||
```text
|
||||
http://192.168.1.50:8888/
|
||||
```
|
||||
|
||||
在浏览器里打开这个网址就行。**如果通知显示的端口不是 `8888`(比如 `8889`),以通知为准** —— 端口不是写死的,程序会自动挑一个能用的。
|
||||
|
||||
> 第一次运行时,它还会在 PS5 主屏装一个「PS5 Web File Manager」快捷方式(Media 分类)。已有的不会被覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 三、界面上都是些什么
|
||||
|
||||
打开页面后大致是这样:
|
||||
|
||||
- **最上面 / 侧边**:选"去哪儿"。会看到 **内部存储**、**USB存储**、**M2扩充存储**、**扩展存储**、**根分区** 这几项,点哪个就进哪个。
|
||||
- **中间的大列表**:当前文件夹里的内容,可以按名称、类型、大小、修改时间、权限排序(排序方式会被记住)。
|
||||
- **列表每一行的按钮**:下载、解压(压缩包才有)、安装(`.pkg` 才有)、文本编辑、改权限、删除等。
|
||||
- **上方工具条**:复制、移动、重命名、下载、删除、**解压**、上传(点开选"上传文件 / 上传文件夹")、新建目录、新建文本、刷新、退出。
|
||||
- **解压按钮一直都在**,只是没选中压缩包时是**灰色**的、点不动。选中**一个**压缩包(`.zip` / `.rar` / `.7z`)它才会变亮可点。鼠标停在灰色按钮上会告诉你为什么不能点。
|
||||
- **右下角**:版本号(这里应该显示 `v1.9.3M`)。
|
||||
|
||||
界面截图(点击看大图):
|
||||
|
||||
<p>
|
||||
<a href="screenshots/20260617_231827.376.jpg" target="_blank"><img src="screenshots/20260617_231827.376.jpg" width="31%" alt="界面截图 1"></a>
|
||||
<a href="screenshots/20260619_131432.399.jpg" target="_blank"><img src="screenshots/20260619_131432.399.jpg" width="31%" alt="界面截图 2"></a>
|
||||
<a href="screenshots/20260617_232348.855.jpg" target="_blank"><img src="screenshots/20260617_232348.855.jpg" width="31%" alt="界面截图 3"></a>
|
||||
</p>
|
||||
|
||||
> 小提示:用 **PS5 自带浏览器** 打开时,"上传"和"下载"按钮是隐藏的(PS5 浏览器不支持);要用电脑或手机浏览器才能传文件。
|
||||
|
||||
---
|
||||
|
||||
## 四、六个最常用操作
|
||||
|
||||
### 1. 把电脑上的文件传到 PS5
|
||||
|
||||
1. 用**电脑或手机**浏览器打开那个网址(不要用 PS5 浏览器)。
|
||||
2. 进入你想放到的文件夹。
|
||||
3. 点 **上传**,在弹出的列表里选 **上传文件**(可多选)或 **上传文件夹**(整个目录一起传)。
|
||||
4. 也可以**直接把文件或文件夹拖进网页**——页脚那行灰字就是提醒这件事的。拖进来后会提示"松开即上传到当前目录"。
|
||||
5. 看到全屏进度条就是开始传了,可以随时**取消**。
|
||||
|
||||
### 2. 在 PS5 内部搬运文件(比如 U 盘 → 内置存储)
|
||||
|
||||
1. 选中要搬的项目 → 点 **复制**(保留原文件)或 **移动**(不保留)。
|
||||
2. 进入目标文件夹 → 点 **粘贴**。
|
||||
3. 会有几秒到几十秒的"准备中"(它在统计大小和检查目标空间够不够),别急。
|
||||
|
||||
### 3. 解压一个压缩包
|
||||
|
||||
1. 在列表里找到那个包,点它右边的 **解压**。
|
||||
2. 问你"若目标已存在同名文件或目录"时:
|
||||
- **确定** = 同名文件覆盖掉(同名文件夹会合并进去)
|
||||
- **取消** = 只要目标已经有同名东西就**直接失败**(这是默认,最安全)
|
||||
3. 如果包是加密的,会弹出密码框,输入密码即可。**密码错了会让你重填,最多 3 次。**
|
||||
- 上传压缩包时如果你选了"上传后自动解压",加密包**会自己弹密码框**,不用先手动点一次"解压"。
|
||||
- 第一次弹框写的是"此压缩包已加密"(因为还没输过密码);之后才写"密码不正确"。
|
||||
|
||||
> ⚠️ **强烈建议每次解压都新建一个空文件夹作为目标。** 已知问题:往同一个文件夹里**第二次**用"覆盖"解一个含文件夹的压缩包,会失败。换个空文件夹就没事。
|
||||
|
||||
### 4. 分卷压缩包怎么解
|
||||
|
||||
一个文件被切成好几段的那种(比如 `xxx.part1.rar` / `xxx.part2.rar`,或 `xxx.z01` + `xxx.zip`,或 `xxx.7z.001` …):
|
||||
|
||||
- **RAR:必须点第一个分卷**(`xxx.part1.rar` 或 `xxx.part01.rar`)。点到后面的卷,按钮是灰的,会提示"请改选主卷"。
|
||||
- **ZIP / 7z:点第一卷即可**,其余会自动接上。
|
||||
- **整卷必须在同一个文件夹里**,少一个都会失败(提示"压缩包损坏或不完整")。
|
||||
|
||||
### 5. 改一个文本文件
|
||||
|
||||
点文本文件右边打开编辑器即可。限制:文件 **小于 1 MiB**,且是纯文本(UTF-8)。太大或不是文本会被拒绝。
|
||||
|
||||
### 6. 安装 PKG
|
||||
|
||||
点 `.pkg` 文件右边的 **安装**,会先显示这个包的标题等信息,确认后提交给系统安装。
|
||||
|
||||
---
|
||||
|
||||
## 五、解压的"规矩"(为什么有的包不给解)
|
||||
|
||||
它不是拿到包就解,而是**先检查**,不符合规矩会直接拒绝。这是为了保护你的 PS5 不被一个恶意压缩包搞崩。默认规矩:
|
||||
|
||||
| 规矩 | 上限 |
|
||||
|---|---|
|
||||
| 包里的文件/文件夹数量 | 20 万个 |
|
||||
| 解压后的总大小 | 2 TiB |
|
||||
| 单个文件最大 | 512 GiB |
|
||||
| 压缩比(解压后 ÷ 压缩包) | 500 倍 |
|
||||
| 文件夹最多嵌套多少层 | 32 层 |
|
||||
|
||||
**包特别大时**(磁盘上超过 480 GiB),它会问你要不要开「大文件模式」:开了之后上限放宽到 50 万个 / 4 TiB / 单个 1 TiB / 1000 倍。
|
||||
|
||||
**还有一条最容易踩的:硬盘要留出大约"两份"空间。** 它是先把东西解到一个临时地方,确认全部成功后才整体搬过去 —— 所以一个解压后 50 GB 的包,你得有大约 100 GB 可用空间。空间不够会直接告诉你"需要 X,可用 Y"。
|
||||
|
||||
---
|
||||
|
||||
## 六、看到这句话,是什么意思
|
||||
|
||||
| 界面上显示 | 说人话 | 怎么办 |
|
||||
|---|---|---|
|
||||
| **密码错误,或压缩包未使用所提供的密码加密** | 密码不对,或者这个包根本没加密你却填了密码 | 重填(最多 3 次);确认密码大小写 |
|
||||
| **此压缩包已加密,密码不正确** | 同上,重试提示 | 输入正确密码,或取消 |
|
||||
| **目标空间不足,需要 X,可用 Y** | 硬盘不够 | 记住要留**两份**空间;删东西或换更大的盘 |
|
||||
| **压缩包内单个文件过大** | 包里有个超大文件,超过默认 512 GiB | 出现提示时选「大文件模式」;或拆包 |
|
||||
| **压缩比异常(疑似压缩炸弹)** | 一个很小的包声称能解出一大堆东西,被判定为危险 | 基本是恶意包,别解 |
|
||||
| **目标已存在同名文件或目录** | 目标位置已经有同名东西了 | 换一个空文件夹(推荐),或在提示时选"确定"覆盖 |
|
||||
| **压缩包损坏或不完整** | 包坏了,或分卷少了一卷 | 重新下载/拷贝,确认所有分卷都在同一目录 |
|
||||
| **不支持的压缩包** | 不是 ZIP / RAR / 7z,或用了它处理不了的特性 | 在电脑上解好再传 |
|
||||
| **压缩包需要的字典超出本机可承受范围** | 极少数用超大设置压的 RAR,PS5 解不动 | 在电脑上用普通设置重新压一次 |
|
||||
| **请改选主卷** | 你点的是分卷的后面几卷 | 点第一个分卷(`.part1.rar` / `.part01.rar`) |
|
||||
| **有任务正在执行** | 已经有一个活儿在干 | 等它结束,或先取消它 |
|
||||
| **操作失败** | 其它错误 | 记下原文,反馈时带上 |
|
||||
|
||||
---
|
||||
|
||||
## 七、和原版(上游)有什么不一样
|
||||
|
||||
这个程序是从开源项目 **owendswang/ps5-web-file-manager** 改来的。下面是和你有关的差别,说人话:
|
||||
|
||||
| 对你意味着什么 | 原版(上游 v1.9) | 本版(v1.9.3M) |
|
||||
|---|---|---|
|
||||
| **要装几个东西** | **两个**:主程序 + 一个约 1 MB 的 7-Zip 辅助程序(`wfm-7zip-helper.elf`),还得放到固定目录 `/data/wfm/`。**辅助文件丢了,解压功能直接全废** | **就一个文件**,拷上去就能用 |
|
||||
| **能解多少种格式** | **约 30 种**(`.tar.gz` `.xz` `.cab` `.iso` 类……) | **3 种**:`.zip` `.rar` `.7z` |
|
||||
| **带密码的压缩包** | 靠 7-Zip 支持 | **三种格式都支持**,密码错了会弹框让你重填(最多 3 次) |
|
||||
| **RAR 分卷** | 支持 | 支持(要点第一个分卷) |
|
||||
| **防"压缩炸弹"/防硬盘写满** | ❌ **没有**,交给 7-Zip 自己看着办 | ✅ 有:压缩比筛查、提前算空间够不够 |
|
||||
| **失败后会不会留下一堆半成品** | 会(直接解到目标目录,中断就留在那儿) | 不会:先解到临时地方,全部成功才整体搬过去,失败自动清理 |
|
||||
| **程序被重启后,正在解的任务** | 还能接着跑(辅助程序是独立进程) | **会丢**(关掉浏览器再打开还能看到进度,但程序本身重启就没了) |
|
||||
| **出错时的提示** | 比较笼统 | 会告诉你具体是哪个文件、差多少字节 |
|
||||
| **版本号长什么样** | `v1.8`、`v1.9` 这样纯数字 | **`v1.9.3M`**,末尾多一个 `M` |
|
||||
|
||||
### 一句话总结
|
||||
|
||||
- **原版赢在"格式多"**:`.tar.gz`、`.xz` 这类它也能解,本版不行。如果你经常遇到这三种以外的格式,原版更方便。
|
||||
- **本版赢在"省心和安全"**:一个文件就完事,不会因为你少放一个辅助文件就整个不能用;也不会被一个恶意压缩包写满你的内置存储、或者在半路留一堆垃圾。
|
||||
|
||||
### `M` 是什么意思
|
||||
|
||||
版本号末尾的 **`M`** = **Modified(改版)**。上游原版是纯数字(如 `v1.8`),所以:
|
||||
|
||||
- 看到 **`v1.9.3M`** → 这是本仓的改版
|
||||
- 看到 **`v1.9.3`**(没有 M)→ 那不是本仓出的
|
||||
|
||||
这个字母会同时出现在:文件名、PS5 启动通知、网页右下角。网页右下角把鼠标**悬停**在版本号上,会弹出说明文字。
|
||||
|
||||
---
|
||||
|
||||
## 八、怎么确认你装的是哪一版
|
||||
|
||||
| 看哪里 | 应该显示 |
|
||||
|---|---|
|
||||
| 网页右下角 | `v1.9.3M` |
|
||||
| PS5 启动时的通知 | 程序名 + `v1.9.3M` + 监听端口 |
|
||||
| 浏览器打开 `http://<PS5的IP>:<端口>/api/version` | JSON 里的版本号 = `v1.9.3M` |
|
||||
|
||||
**只要界面上显示带 `M` 的版本号,就说明装对了。**
|
||||
|
||||
---
|
||||
|
||||
## 九、几条重要提醒
|
||||
|
||||
1. **解压时别断电、别重启 PS5。** 它为了速度不做逐文件强制落盘,如果在最后搬运阶段断电,可能出现"文件在,但内容不完整"。
|
||||
2. **解压前确认空间够**,而且是**两份**(见第五节)。
|
||||
3. **每次解压用新的空文件夹**,别往同一个目录连解两次(见第四节第 3 条)。
|
||||
4. **删除不可恢复。** 删文件夹会把它里面所有东西都删掉,弹窗会提醒。
|
||||
5. **一次只做一件事**,有任务在跑时其它操作会被拒绝。
|
||||
6. 这是自制程序。如果遇到 PS5 内核崩溃,请换更新的越狱方式 / ELF 加载器,或回到你常用的稳定方案。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
/* Stub for the C++ name demangler (__cxa_demangle).
|
||||
*
|
||||
* unrar7 is compiled with C++ exceptions enabled -- dll.cpp catches
|
||||
* RAR_EXIT and std::bad_alloc, unpack.cpp/model.cpp throw bad_alloc -- so
|
||||
* the runtime's __cxa_throw chain holds a reference to __cxa_demangle. That
|
||||
* one reference drags the whole Itanium demangler TU into the link: 607
|
||||
* symbols, ~105 KiB, over 10% of the final ELF (see docs/SIZE-OPTIMIZATION.md).
|
||||
*
|
||||
* __cxa_demangle is only ever reached on the uncaught-exception diagnostic
|
||||
* path (std::terminate printing the exception's type name). Every unrar
|
||||
* exception is caught inside dll.cpp, so that path is unreachable here.
|
||||
* Defining the symbol in our own TU keeps cxa_demangle.o out of the archive
|
||||
* pull -- the linker resolves against ours and never opens the member.
|
||||
*
|
||||
* Returning NULL is the documented "demangle failed" result; the caller
|
||||
* falls back to printing the mangled name. Exception handling itself
|
||||
* (__cxa_throw / __cxa_begin_catch / _Unwind_Resume / __gxx_personality_v0)
|
||||
* is untouched. Applies to both the PS5 and the host/linux builds. */
|
||||
#include <stddef.h>
|
||||
|
||||
char *__cxa_demangle(const char *mangled, char *buf, size_t *len, int *status) {
|
||||
(void)mangled;
|
||||
(void)buf;
|
||||
(void)len;
|
||||
|
||||
if(status) {
|
||||
*status = -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
+13
-3
@@ -175,7 +175,10 @@ extract_dispatch(file_task_t *task, zipx_conflict_t conflict,
|
||||
|
||||
if(kind == 0) {
|
||||
status = zipx_extract(task->src, task->dst, conflict, limits,
|
||||
extract_cancel, extract_progress, task, result);
|
||||
extract_cancel, extract_progress, task,
|
||||
task->extract_password[0] ? task->extract_password
|
||||
: NULL,
|
||||
result);
|
||||
} else if(kind == 1) {
|
||||
/* unrar chains its own volume naming (x.part1.rar); a byte contiguous
|
||||
set named x.rar.001 cannot be handed to it as-is. */
|
||||
@@ -202,11 +205,17 @@ extract_dispatch(file_task_t *task, zipx_conflict_t conflict,
|
||||
}
|
||||
if(ends_with_ci(task->src, ".zip")) {
|
||||
return zipx_extract(task->src, task->dst, conflict, limits,
|
||||
extract_cancel, extract_progress, task, result);
|
||||
extract_cancel, extract_progress, task,
|
||||
task->extract_password[0] ? task->extract_password
|
||||
: NULL,
|
||||
result);
|
||||
}
|
||||
if(ends_with_ci(task->src, ".rar")) {
|
||||
return rar_extract(task->src, task->dst, conflict, limits,
|
||||
extract_cancel, extract_progress, task, result);
|
||||
extract_cancel, extract_progress, task,
|
||||
task->extract_password[0] ? task->extract_password
|
||||
: NULL,
|
||||
result);
|
||||
}
|
||||
if(ends_with_ci(task->src, ".7z")) {
|
||||
return sevenz_extract(task->src, task->dst, conflict, limits,
|
||||
@@ -236,6 +245,7 @@ extract_error_code(zipx_status_t status) {
|
||||
case ZIPX_ERR_LIMIT_RATIO: return "extract_ratio";
|
||||
case ZIPX_ERR_LIMIT_DEPTH: return "extract_too_deep";
|
||||
case ZIPX_ERR_LIMIT_NAME: return "extract_name_too_long";
|
||||
case ZIPX_ERR_LIMIT_DICT: return "extract_dict_too_large";
|
||||
case ZIPX_ERR_CONFLICT: return "extract_conflict";
|
||||
case ZIPX_ERR_SPACE: return "no_space";
|
||||
case ZIPX_ERR_IO: return "extract_io";
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/* PKG preview -- the /api/pkg_info and /api/pkg_icon endpoints.
|
||||
*
|
||||
* Independent C99 implementation of the PS5 .pkg container layout (entry table
|
||||
* + PARAM.SFO + param.json + ICON0), written for this project.
|
||||
*
|
||||
* NOT derived from cy33hc/ps5-ezremote-client, which the README credits for the
|
||||
* same feature. That project is GPL-2.0-only -- its sources carry no "or later"
|
||||
* notice -- and therefore cannot be combined with this GPL-3.0 codebase at all.
|
||||
* The two share nothing but the on-disk format facts: the SFO magic
|
||||
* 0x46535000, the 20-byte header / 16-byte entry layout, and the key/value
|
||||
* offset indirection. Those are dictated by the format and no implementation
|
||||
* can avoid them. Everything else differs -- this file is C where that one is
|
||||
* C++, it parses the .pkg entry table and param.json (upstream has no .pkg
|
||||
* parser), and it tokenizes JSON itself (upstream links json-c).
|
||||
*
|
||||
* Provenance record and the line-by-line comparison: docs/REWRITE-FEASIBILITY.md
|
||||
* section 2.2. Revisit that note if this file is ever rewritten or the upstream
|
||||
* licence wording changes.
|
||||
*/
|
||||
|
||||
#include "pkg_info.h"
|
||||
|
||||
#include <errno.h>
|
||||
|
||||
+27
-5
@@ -8,6 +8,10 @@
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Native sceAppInstUtil MetaInfo ABI is 6 pointers (0x30). The old 0x38 layout
|
||||
carried two extra Mono-managed fields (slot, is_playgo_enabled) that do not
|
||||
exist in the firmware's native struct; passing the oversized struct shifts
|
||||
every subsequent argument and makes InstallByPackage fail or misbehave. */
|
||||
typedef struct pkg_metadata {
|
||||
const char *uri;
|
||||
const char *ex_uri;
|
||||
@@ -15,13 +19,23 @@ typedef struct pkg_metadata {
|
||||
const char *content_id;
|
||||
const char *content_name;
|
||||
const char *icon_url;
|
||||
uint32_t slot;
|
||||
uint32_t is_playgo_enabled;
|
||||
} pkg_metadata_t;
|
||||
|
||||
_Static_assert(sizeof(pkg_metadata_t) == 0x38,
|
||||
_Static_assert(sizeof(pkg_metadata_t) == 0x30,
|
||||
"sceAppInstUtil metadata ABI mismatch");
|
||||
|
||||
/* The stock process lacks the privilege sceAppInstUtil needs. kstuff/etaHEN
|
||||
expose kernel_set_ucred_authid through libkernel_sys; raising the authid to
|
||||
the debug value before install is what lets the call succeed on a real
|
||||
console. Declared here (PS5 build only) and resolved by -lkernel_sys. */
|
||||
int kernel_set_ucred_authid(uint64_t authid);
|
||||
|
||||
#ifndef DEBUG_AUTHID
|
||||
#define DEBUG_AUTHID 0x4800000000000006ULL
|
||||
#endif
|
||||
|
||||
#define PKG_INSTALL_PRIV_FAILED 0x80000001
|
||||
|
||||
typedef struct pkg_info {
|
||||
char content_id[48];
|
||||
int type;
|
||||
@@ -78,8 +92,6 @@ pkg_installer_install(const char *path) {
|
||||
.content_id = "",
|
||||
.content_name = "",
|
||||
.icon_url = "",
|
||||
.slot = 0,
|
||||
.is_playgo_enabled = 0
|
||||
};
|
||||
pkg_info_t pkg_info = {0};
|
||||
playgo_info_t playgo_info = {0};
|
||||
@@ -98,6 +110,16 @@ pkg_installer_install(const char *path) {
|
||||
pthread_mutex_unlock(&installer_lock);
|
||||
return result;
|
||||
}
|
||||
/* Raise the process authid to the debug value so sceAppInstUtil is allowed
|
||||
to install. Failure here means the kernel privilege was not granted
|
||||
(no kstuff/etaHEN present or not patched) -- report it distinctly rather
|
||||
than handing a privileged call to an unprivileged process. */
|
||||
if(kernel_set_ucred_authid(DEBUG_AUTHID)) {
|
||||
printf("pkg_installer: kernel_set_ucred_authid failed (0x%016llx)\n",
|
||||
(unsigned long long)DEBUG_AUTHID);
|
||||
pthread_mutex_unlock(&installer_lock);
|
||||
return PKG_INSTALL_PRIV_FAILED;
|
||||
}
|
||||
result = sceAppInstUtilInstallByPackage(&metadata, &pkg_info, &playgo_info);
|
||||
pthread_mutex_unlock(&installer_lock);
|
||||
return result;
|
||||
|
||||
+82
-22
@@ -11,9 +11,11 @@
|
||||
single-volume archives.
|
||||
* Multi-volume archives: unrar auto-merges subsequent volumes by name
|
||||
pattern when all .partNN.rar files sit next to the opened volume.
|
||||
* Encrypted RAR: the engine can decrypt via RARSetPassword, but the
|
||||
password plumbing (API + UI) is not wired yet — encrypted archives
|
||||
currently fail with ZIPX_ERR_UNSUPPORTED.
|
||||
* Encrypted archives (`-p` data encryption and `-hp` header encryption):
|
||||
the password is handed to the engine via RARSetPassword immediately
|
||||
after RAROpenArchiveEx and before the first RARReadHeaderEx, which is
|
||||
the order unrar needs to decrypt a RAR5 header. A missing or wrong
|
||||
password surfaces as ZIPX_ERR_PASSWORD.
|
||||
|
||||
See third_party/unrar7/VENDORED.md for the full integration notes. */
|
||||
|
||||
@@ -62,6 +64,9 @@ typedef struct {
|
||||
zipx_progress_fn progress;
|
||||
void *userdata;
|
||||
zipx_result_t *result;
|
||||
/* NULL when no password was supplied. Owned by the caller for the whole
|
||||
call; RARSetPassword copies it into the engine, so it never dangles. */
|
||||
const char *password;
|
||||
uint64_t entries_total;
|
||||
uint64_t entries_done;
|
||||
uint64_t bytes_total;
|
||||
@@ -69,6 +74,13 @@ typedef struct {
|
||||
uint64_t files_created;
|
||||
uint64_t dirs_created;
|
||||
uint64_t progress_floor;
|
||||
/* Set by the UCM_LARGEDICT callback only: the dictionary the archive asks
|
||||
for and the limit we refuse above, both in KiB (0 = never raised, i.e. the
|
||||
archive's dictionary was within Cmd->WinSizeLimit). unrar hands these over
|
||||
as p1/p2, so the refusal can name the real numbers instead of blaming the
|
||||
entry that happened to be in flight. */
|
||||
uint64_t dict_kb;
|
||||
uint64_t dict_limit_kb;
|
||||
struct timespec last_report;
|
||||
char staging[ZIPX_PATH_MAX];
|
||||
char **created;
|
||||
@@ -456,16 +468,33 @@ rar_translate_error(int code, const char *detail, rarx_ctx_t *c) {
|
||||
|
||||
case ERAR_MISSING_PASSWORD:
|
||||
case ERAR_BAD_PASSWORD:
|
||||
/* Password plumbing (API + UI) is not wired yet. */
|
||||
return rarx_fail(c, ZIPX_ERR_UNSUPPORTED, detail,
|
||||
"encrypted RAR entries are not supported");
|
||||
/* The archive needs a password we do not have, or the one supplied was
|
||||
wrong. ZIPX_ERR_PASSWORD lets the caller prompt and retry. */
|
||||
return rarx_fail(c, ZIPX_ERR_PASSWORD, detail,
|
||||
"the archive is encrypted and the password is missing "
|
||||
"or wrong");
|
||||
|
||||
case ERAR_SMALL_BUF:
|
||||
return rarx_fail(c, ZIPX_ERR_LIMIT_NAME, detail, "name buffer is too small");
|
||||
|
||||
case ERAR_LARGE_DICT:
|
||||
return rarx_fail(c, ZIPX_ERR_LIMIT_FILE, detail,
|
||||
"archive needs a larger dictionary than supported");
|
||||
case ERAR_LARGE_DICT: {
|
||||
/* Not "entry too large": the *dictionary* is, and that is a property of the
|
||||
archive (RAR7 headers can ask for up to 64 GiB), not of the entry that
|
||||
happened to be in flight. Report both numbers; unrar gave us exactly
|
||||
these when it asked. */
|
||||
char need[96];
|
||||
|
||||
if(c->dict_kb) {
|
||||
snprintf(need, sizeof(need), "%llu MiB (limit %llu MiB)",
|
||||
(unsigned long long)(c->dict_kb / 1024),
|
||||
(unsigned long long)(c->dict_limit_kb / 1024));
|
||||
} else {
|
||||
snprintf(need, sizeof(need), "more than 4096 MiB");
|
||||
}
|
||||
return rarx_fail(c, ZIPX_ERR_LIMIT_DICT, need,
|
||||
"the archive needs a dictionary larger than this build "
|
||||
"supports (%s)", need);
|
||||
}
|
||||
|
||||
case ERAR_BAD_DATA:
|
||||
return rarx_fail(c, ZIPX_ERR_CRC, detail, "checksum mismatch in entry data");
|
||||
@@ -651,12 +680,13 @@ scan_archive(HANDLE hArc, rarx_ctx_t *c) {
|
||||
}
|
||||
is_dir = (hdr.Flags & RHDF_DIRECTORY) ? 1 : 0;
|
||||
|
||||
/* Encrypted entries: the unrar engine can decrypt them via RARSetPassword,
|
||||
but the password plumbing is not wired yet — reject up front with the
|
||||
same message the v1.8 backend used. */
|
||||
if(hdr.Flags & RHDF_ENCRYPTED) {
|
||||
ret = rarx_fail(c, ZIPX_ERR_UNSUPPORTED, hdr.FileName,
|
||||
"encrypted RAR entries are not supported");
|
||||
/* Encrypted entries are fine as long as a password is in play: the engine
|
||||
decrypts them during RARProcessFile once RARSetPassword has run. With no
|
||||
password, fail here — before anything is written to staging — so the
|
||||
caller can prompt and retry. */
|
||||
if((hdr.Flags & RHDF_ENCRYPTED) && !c->password) {
|
||||
ret = rarx_fail(c, ZIPX_ERR_PASSWORD, hdr.FileName,
|
||||
"the archive is encrypted and no password was supplied");
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -748,16 +778,35 @@ scan_archive(HANDLE hArc, rarx_ctx_t *c) {
|
||||
|
||||
Returning -1 is how unrar aborts a run, but the break path is only armed
|
||||
when console break handling is enabled, which never happens in DLL mode —
|
||||
so we always return 0 and cancellation stays entry-granular. */
|
||||
so we always return 0 and cancellation stays entry-granular.
|
||||
|
||||
The same callback is the only channel through which unrar asks permission
|
||||
for an oversized dictionary (UCM_LARGEDICT); see below. */
|
||||
static int CALLBACK
|
||||
rar_data_cb(UINT msg, LPARAM user, LPARAM p1, LPARAM p2) {
|
||||
rarx_ctx_t *c = (rarx_ctx_t *)user;
|
||||
(void)p1;
|
||||
|
||||
if(msg == UCM_PROCESSDATA) {
|
||||
c->bytes_done += (uint64_t)(unsigned long)p2;
|
||||
report(c, ZIPX_PHASE_EXTRACT, NULL, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if(msg == UCM_LARGEDICT) {
|
||||
/* unrar asks permission before it allocates a window bigger than
|
||||
Cmd->WinSizeLimit (default 4 GiB, options.cpp:13). Answering 1 would let
|
||||
the run continue -- and that is a *trap*, not a fix: the window is one
|
||||
contiguous allocation of the full dictionary size, and rarlab's own CLI
|
||||
refuses the same case with "8 GB dictionary exceeds the 4 GB limit and
|
||||
needs more than 8 GB of memory; use -md8g or -mdx8g". A PS5 has 16 GB of
|
||||
shared memory, so >4 GiB dictionaries are not extractable there anyway;
|
||||
failing cleanly beats being OOM-killed mid-extraction with the UI gone.
|
||||
Record the numbers (p1/p2, both KiB) so the refusal can explain itself. */
|
||||
c->dict_kb = (uint64_t)(unsigned long)p1;
|
||||
c->dict_limit_kb = (uint64_t)(unsigned long)p2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -794,10 +843,10 @@ extract_archive(HANDLE hArc, rarx_ctx_t *c) {
|
||||
ret = rarx_fail(c, ZIPX_ERR_FORMAT, NULL, "empty entry name");
|
||||
break;
|
||||
}
|
||||
if(hdr.Flags & RHDF_ENCRYPTED) {
|
||||
/* scan already rejected these; defensive only. */
|
||||
ret = rarx_fail(c, ZIPX_ERR_UNSUPPORTED, hdr.FileName,
|
||||
"encrypted RAR entries are not supported");
|
||||
if((hdr.Flags & RHDF_ENCRYPTED) && !c->password) {
|
||||
/* scan already rejected password-less encrypted sets; defensive only. */
|
||||
ret = rarx_fail(c, ZIPX_ERR_PASSWORD, hdr.FileName,
|
||||
"the archive is encrypted and no password was supplied");
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -989,7 +1038,7 @@ zipx_status_t
|
||||
rar_extract(const char *rar_path, const char *dst_dir,
|
||||
zipx_conflict_t conflict, const zipx_limits_t *limits,
|
||||
zipx_cancel_fn cancel, zipx_progress_fn progress,
|
||||
void *userdata, zipx_result_t *result) {
|
||||
void *userdata, const char *password, zipx_result_t *result) {
|
||||
rarx_ctx_t ctx;
|
||||
rarx_ctx_t *c = &ctx;
|
||||
char parent[ZIPX_PATH_MAX];
|
||||
@@ -1016,6 +1065,9 @@ rar_extract(const char *rar_path, const char *dst_dir,
|
||||
c->cancel = cancel;
|
||||
c->progress = progress;
|
||||
c->userdata = userdata;
|
||||
/* An empty string means "no password" so that callers can pass the raw
|
||||
form field without a separate emptiness check. */
|
||||
c->password = (password && password[0]) ? password : NULL;
|
||||
|
||||
snprintf(dst_copy, sizeof(dst_copy), "%s", dst_dir);
|
||||
{
|
||||
@@ -1052,6 +1104,11 @@ rar_extract(const char *rar_path, const char *dst_dir,
|
||||
status = rar_translate_error((int)od.OpenResult, rar_path, c);
|
||||
goto done;
|
||||
}
|
||||
/* Must precede the first RARReadHeaderEx: unrar needs the password in
|
||||
place to decrypt a -hp (encrypted header) archive. */
|
||||
if(c->password) {
|
||||
WFM_RAR_PASSWORD(hArc, (char *)c->password);
|
||||
}
|
||||
}
|
||||
|
||||
if(scan_archive(hArc, c)) {
|
||||
@@ -1094,6 +1151,9 @@ rar_extract(const char *rar_path, const char *dst_dir,
|
||||
status = rar_translate_error((int)od.OpenResult, rar_path, c);
|
||||
goto done;
|
||||
}
|
||||
if(c->password) {
|
||||
WFM_RAR_PASSWORD(hArc, (char *)c->password);
|
||||
}
|
||||
}
|
||||
|
||||
if(extract_archive(hArc, c)) {
|
||||
|
||||
+6
-3
@@ -12,9 +12,10 @@
|
||||
Backend notes (v1.9, unrar 7.20.1):
|
||||
* RAR4 and RAR5, any compression version including WinRAR 6/7 "v6".
|
||||
* Multi-volume: unrar merges next .partNN.rar by name automatically.
|
||||
* Encrypted RAR is NOT yet supported end-to-end: the engine can decrypt
|
||||
via RARSetPassword, but password plumbing (API + UI) is unwired, so
|
||||
encrypted headers/entries fail with ZIPX_ERR_UNSUPPORTED today.
|
||||
* Encrypted archives work end-to-end (both `-p` data encryption and
|
||||
`-hp` header encryption). Pass the password in `password`; NULL or an
|
||||
empty string means "no password supplied". A missing or wrong password
|
||||
is reported as ZIPX_ERR_PASSWORD so the caller can prompt and retry.
|
||||
|
||||
See third_party/unrar7/VENDORED.md for full integration notes. */
|
||||
|
||||
@@ -24,6 +25,7 @@
|
||||
#include <stddef.h>
|
||||
|
||||
/* Extract rar_path into dst_dir using the same protocol as zipx_extract().
|
||||
`password` may be NULL when the archive is not encrypted.
|
||||
Returns ZIPX_OK or an error code; *result is always filled in.
|
||||
On any failure the staging directory is removed and dst_dir is left as it
|
||||
was, except for objects already published with the overwrite policy. */
|
||||
@@ -33,4 +35,5 @@ zipx_status_t rar_extract(const char *rar_path, const char *dst_dir,
|
||||
zipx_cancel_fn cancel,
|
||||
zipx_progress_fn progress,
|
||||
void *userdata,
|
||||
const char *password,
|
||||
zipx_result_t *result);
|
||||
+4
-5
@@ -37,11 +37,10 @@
|
||||
* 7zAES (method 0x06F10701), the coder 7-Zip wraps around the streams when
|
||||
`-p` is used, driven from a caller supplied password
|
||||
|
||||
Not supported here (by design, see sz_chain_check):
|
||||
* an encrypted *header* (`-mhe=on`): that is not a coder in a folder but a
|
||||
second, encrypted copy of the archive header, which has to be decrypted
|
||||
and parsed before any folder exists at all. Reported by the SDK header
|
||||
reader as SZ_ERROR_UNSUPPORTED, not by this module.
|
||||
An encrypted *header* (`-mhe=on`) is not a coder in a folder: it is a second,
|
||||
encrypted copy of the archive header that has to be decoded before any folder
|
||||
exists at all. src/sevenz_header.c does that -- by parsing that one folder's
|
||||
descriptor and running it through this module, password and all.
|
||||
*/
|
||||
|
||||
#ifndef SEVENZ_CHAIN_H
|
||||
|
||||
+49
-3
@@ -37,6 +37,7 @@
|
||||
#include "7zFile.h"
|
||||
|
||||
#include "sevenz_chain.h"
|
||||
#include "sevenz_header.h"
|
||||
#include "sevenz_mt.h"
|
||||
#include "sevenz_volstream.h"
|
||||
|
||||
@@ -1619,6 +1620,9 @@ sevenz_extract(const char *sevenz_path, const char *dst_dir,
|
||||
int dst_existed = 0;
|
||||
int status;
|
||||
static int tables_ready;
|
||||
szh_prep *hdr_prep = NULL;
|
||||
ISeekInStream *hdr_stream = NULL;
|
||||
char hdr_msg[256] = "";
|
||||
|
||||
if(!result || !sevenz_path || !sevenz_path[0] || !dst_dir || !dst_dir[0]) {
|
||||
if(result) {
|
||||
@@ -1686,8 +1690,49 @@ sevenz_extract(const char *sevenz_path, const char *dst_dir,
|
||||
}
|
||||
look_stream.buf = look_buf;
|
||||
look_stream.bufSize = SZX_INPUT_BUF_SIZE;
|
||||
look_stream.realStream = sevenz_volstream_stream(vol);
|
||||
|
||||
/* An encrypted header (-mhe=on) hides the whole folder table, so it has to be
|
||||
decrypted before the SDK can read anything at all. szh_prepare() reports
|
||||
SZH_PLAIN for every other archive, and then the SDK sees exactly the stream
|
||||
it always did. */
|
||||
hdr_stream = sevenz_volstream_stream(vol);
|
||||
switch(szh_prepare(&hdr_prep, hdr_stream, c->password, hdr_msg,
|
||||
sizeof(hdr_msg))) {
|
||||
case SZH_PATCHED:
|
||||
hdr_stream = szh_stream(hdr_prep);
|
||||
break;
|
||||
case SZH_PLAIN:
|
||||
break;
|
||||
case SZH_ERR_PASSWORD:
|
||||
status = szx_fail(c, ZIPX_ERR_PASSWORD, sevenz_path, "%s: %s", vol_desc,
|
||||
hdr_msg);
|
||||
goto done;
|
||||
case SZH_ERR_UNSUPPORTED:
|
||||
status = szx_fail(c, ZIPX_ERR_UNSUPPORTED, sevenz_path, "%s: %s", vol_desc,
|
||||
hdr_msg);
|
||||
goto done;
|
||||
case SZH_ERR_IO:
|
||||
status = szx_fail(c, ZIPX_ERR_IO, sevenz_path, "%s: %s", vol_desc, hdr_msg);
|
||||
goto done;
|
||||
default:
|
||||
status = szx_fail(c, ZIPX_ERR_FORMAT, sevenz_path, "%s: %s", vol_desc,
|
||||
hdr_msg);
|
||||
goto done;
|
||||
}
|
||||
look_stream.realStream = hdr_stream;
|
||||
LookToRead2_INIT(&look_stream);
|
||||
{
|
||||
/* Inspecting the start header moved the stream around, and LookToRead2
|
||||
reads from wherever it finds the stream on its first call -- it does not
|
||||
seek. Put it back at byte 0. */
|
||||
Int64 zero = 0;
|
||||
|
||||
if(hdr_stream->Seek(hdr_stream, &zero, SZ_SEEK_SET) != SZ_OK) {
|
||||
status = szx_fail(c, ZIPX_ERR_IO, sevenz_path, "%s: seek failed",
|
||||
vol_desc);
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
|
||||
SzArEx_Init(&db);
|
||||
res = SzArEx_Open(&db, &look_stream.vt, &g_sz_alloc, &g_sz_alloc);
|
||||
@@ -1695,8 +1740,8 @@ sevenz_extract(const char *sevenz_path, const char *dst_dir,
|
||||
/* SzArEx_Open already released everything it allocated. */
|
||||
if(res == SZ_ERROR_UNSUPPORTED) {
|
||||
status = szx_fail(c, ZIPX_ERR_UNSUPPORTED, sevenz_path,
|
||||
"%s: the archive header is encrypted (-mhe=on); only "
|
||||
"archives whose header is readable can be unpacked",
|
||||
"%s: the archive header is compressed with a method the "
|
||||
"bundled decoder does not have",
|
||||
vol_desc);
|
||||
} else {
|
||||
status = szx_fail(c, ZIPX_ERR_FORMAT, sevenz_path,
|
||||
@@ -1772,6 +1817,7 @@ done:
|
||||
if(look_buf) {
|
||||
ISzAlloc_Free(&g_sz_alloc, look_buf);
|
||||
}
|
||||
szh_prep_free(hdr_prep);
|
||||
sevenz_volstream_free(vol);
|
||||
szx_cleanup_staging(c);
|
||||
result->entries_total = c->entries_total;
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
Backend notes (LZMA SDK 26.03 + src/sevenz_chain.c):
|
||||
* Copy / LZMA / LZMA2 / PPMd, the Delta filter and the x86 / PPC / IA64 /
|
||||
ARM / ARMT / SPARC branch converters, BCJ2, and 7zAES.
|
||||
* An encrypted *header* (`-mhe=on`) is not readable: the SDK refuses it
|
||||
before any folder is known, and we report exactly that.
|
||||
* An encrypted *header* (`-mhe=on`) is decrypted by src/sevenz_header.c
|
||||
first: the SDK refuses such an archive before any folder is known, so
|
||||
the header has to be readable before the SDK is asked to read it.
|
||||
*/
|
||||
|
||||
#include "zip_extract.h"
|
||||
|
||||
@@ -0,0 +1,917 @@
|
||||
/* sevenz_header -- see sevenz_header.h for what this does and why. */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "7z.h"
|
||||
#include "7zCrc.h"
|
||||
#include "7zTypes.h"
|
||||
|
||||
#include "sevenz_chain.h"
|
||||
#include "sevenz_header.h"
|
||||
|
||||
/* The header property ids we have to recognise. They are the same enum the
|
||||
SDK's header scanner uses (7zArcIn.c), spelled out here so this file does
|
||||
not depend on that translation unit's internals. */
|
||||
#define SZH_ID_END 0x00
|
||||
#define SZH_ID_HEADER 0x01
|
||||
#define SZH_ID_PACK_INFO 0x06
|
||||
#define SZH_ID_UNPACK_INFO 0x07
|
||||
#define SZH_ID_SIZE 0x09
|
||||
#define SZH_ID_CRC 0x0A
|
||||
#define SZH_ID_FOLDER 0x0B
|
||||
#define SZH_ID_CODERS_UNPACK_SIZE 0x0C
|
||||
#define SZH_ID_ENCODED_HEADER 0x17
|
||||
|
||||
#define SZH_MAX_CODERS SZ_CHAIN_MAX_CODERS
|
||||
#define SZH_MAX_STREAMS SZ_CHAIN_MAX_STREAMS
|
||||
|
||||
/* --------------------------------------------------------------- numbers */
|
||||
|
||||
static uint32_t
|
||||
szh_le32(const uint8_t *p) {
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) |
|
||||
((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
static uint64_t
|
||||
szh_le64(const uint8_t *p) {
|
||||
return (uint64_t)szh_le32(p) | ((uint64_t)szh_le32(p + 4) << 32);
|
||||
}
|
||||
|
||||
static void
|
||||
szh_put_le32(uint8_t *p, uint32_t v) {
|
||||
p[0] = (uint8_t)v;
|
||||
p[1] = (uint8_t)(v >> 8);
|
||||
p[2] = (uint8_t)(v >> 16);
|
||||
p[3] = (uint8_t)(v >> 24);
|
||||
}
|
||||
|
||||
static void
|
||||
szh_put_le64(uint8_t *p, uint64_t v) {
|
||||
szh_put_le32(p, (uint32_t)v);
|
||||
szh_put_le32(p + 4, (uint32_t)(v >> 32));
|
||||
}
|
||||
|
||||
/* The 7z variable length number: the high bits of the first byte say how many
|
||||
more bytes follow, and the remaining bits of the first byte are the high
|
||||
part of the value. This mirrors ReadNumber() in 7zArcIn.c byte for byte,
|
||||
including its habit of returning a partial value when all eight flag bits
|
||||
are set -- the caller checks the CRC of the whole record anyway. */
|
||||
static int
|
||||
szh_num(const uint8_t *d, size_t size, size_t *pos, uint64_t *value) {
|
||||
size_t p = *pos;
|
||||
unsigned first, mask, v, i;
|
||||
|
||||
if(p >= size) {
|
||||
return -1;
|
||||
}
|
||||
first = d[p++];
|
||||
if((first & 0x80) == 0) {
|
||||
*value = first;
|
||||
*pos = p;
|
||||
return 0;
|
||||
}
|
||||
if(p >= size) {
|
||||
return -1;
|
||||
}
|
||||
v = d[p++];
|
||||
if((first & 0x40) == 0) {
|
||||
*value = ((uint64_t)(first & 0x3F) << 8) | v;
|
||||
*pos = p;
|
||||
return 0;
|
||||
}
|
||||
if(p >= size) {
|
||||
return -1;
|
||||
}
|
||||
mask = d[p++];
|
||||
*value = (uint64_t)v | ((uint64_t)mask << 8);
|
||||
mask = 0x20;
|
||||
for(i = 16; i < 64; i += 8) {
|
||||
if((first & mask) == 0) {
|
||||
*value |= (uint64_t)(first & (mask - 1)) << i;
|
||||
*pos = p;
|
||||
return 0;
|
||||
}
|
||||
mask >>= 1;
|
||||
if(p >= size) {
|
||||
return -1;
|
||||
}
|
||||
*value |= (uint64_t)d[p++] << i;
|
||||
}
|
||||
*pos = p;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The 32 bit form used for counts and property sizes. */
|
||||
static int
|
||||
szh_num32(const uint8_t *d, size_t size, size_t *pos, uint32_t *value) {
|
||||
uint64_t v;
|
||||
|
||||
if(*pos < size && (d[*pos] & 0x80) == 0) {
|
||||
*value = d[(*pos)++];
|
||||
return 0;
|
||||
}
|
||||
if(szh_num(d, size, pos, &v)) {
|
||||
return -1;
|
||||
}
|
||||
if(v >= (uint64_t)0x80000000u - 1) {
|
||||
return -1;
|
||||
}
|
||||
*value = (uint32_t)v;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static uint32_t
|
||||
szh_count_bits(const uint8_t *d, uint32_t num_items) {
|
||||
uint32_t n = 0, i;
|
||||
|
||||
for(i = 0; i < num_items; i++) {
|
||||
if(d[i >> 3] & (0x80u >> (i & 7))) {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* A digest block: one "all are defined" byte, an optional bit vector, then one
|
||||
little endian CRC per defined item. With `first` the value of the first
|
||||
defined item comes back, which is the folder CRC when the block covers a
|
||||
single folder. */
|
||||
static int
|
||||
szh_digests(const uint8_t *d, size_t size, size_t *pos, uint32_t num_items,
|
||||
uint32_t *first, int *has_first) {
|
||||
size_t p = *pos;
|
||||
uint32_t defined = num_items;
|
||||
unsigned all;
|
||||
|
||||
if(p >= size) {
|
||||
return -1;
|
||||
}
|
||||
all = d[p++];
|
||||
if(!all) {
|
||||
size_t bytes = ((size_t)num_items + 7) >> 3;
|
||||
|
||||
if(bytes > size - p) {
|
||||
return -1;
|
||||
}
|
||||
defined = szh_count_bits(d + p, num_items);
|
||||
p += bytes;
|
||||
}
|
||||
if((size_t)defined > (size - p) >> 2) {
|
||||
return -1;
|
||||
}
|
||||
if(first && has_first) {
|
||||
if(defined) {
|
||||
*first = szh_le32(d + p);
|
||||
*has_first = 1;
|
||||
} else {
|
||||
*has_first = 0;
|
||||
}
|
||||
}
|
||||
p += (size_t)defined * 4;
|
||||
*pos = p;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The SDK's SkipData(): a length, then that many bytes. */
|
||||
static int
|
||||
szh_skip_data(const uint8_t *d, size_t size, size_t *pos) {
|
||||
uint64_t n;
|
||||
|
||||
if(szh_num(d, size, pos, &n)) {
|
||||
return -1;
|
||||
}
|
||||
if(n > (uint64_t)(size - *pos)) {
|
||||
return -1;
|
||||
}
|
||||
*pos += (size_t)n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- encoded header */
|
||||
|
||||
typedef struct {
|
||||
uint64_t pack_pos; /* where this folder's packs live, from offset 32 */
|
||||
uint32_t num_pack;
|
||||
uint64_t pack_sizes[SZH_MAX_STREAMS];
|
||||
uint64_t pack_positions[SZH_MAX_STREAMS + 1]; /* cumulative, as the SDK builds */
|
||||
const uint8_t *blob; /* coder descriptor, in place */
|
||||
size_t blob_size;
|
||||
uint32_t num_coders;
|
||||
uint32_t main_coder;
|
||||
uint64_t coder_unpack_sizes[SZH_MAX_CODERS];
|
||||
uint64_t unpack_size;
|
||||
uint32_t crc;
|
||||
int has_crc;
|
||||
} szh_streams_t;
|
||||
|
||||
/* One folder descriptor: the coders, then the bond pairs and the pack-stream
|
||||
indices that follow them. `*pos` ends up just past the last of those, so
|
||||
[start, *pos) is exactly the byte range the SDK keeps as
|
||||
CSzAr::CodersData[FoCodersOffsets[0] .. FoCodersOffsets[1]). */
|
||||
static int
|
||||
szh_parse_folder(const uint8_t *d, size_t size, size_t *pos, szh_streams_t *ss) {
|
||||
const size_t start = *pos;
|
||||
size_t p = start;
|
||||
uint32_t num_coders = 0, num_in = 0, num_bonds, num_pack, i;
|
||||
uint8_t coder_used[SZH_MAX_CODERS];
|
||||
uint8_t stream_used[SZH_MAX_STREAMS];
|
||||
uint32_t main_index = 0;
|
||||
|
||||
if(szh_num32(d, size, &p, &num_coders)) {
|
||||
return -1;
|
||||
}
|
||||
if(num_coders == 0 || num_coders > SZH_MAX_CODERS) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for(i = 0; i < num_coders; i++) {
|
||||
uint8_t main_byte;
|
||||
uint32_t id_size, coder_in = 1;
|
||||
|
||||
if(p >= size) {
|
||||
return -1;
|
||||
}
|
||||
main_byte = d[p++];
|
||||
if(main_byte & 0xC0) {
|
||||
return -1;
|
||||
}
|
||||
id_size = main_byte & 0x0F;
|
||||
if(id_size > 8 || (size_t)id_size > size - p) {
|
||||
return -1;
|
||||
}
|
||||
p += id_size;
|
||||
if(main_byte & 0x10) {
|
||||
uint32_t coder_out;
|
||||
|
||||
if(szh_num32(d, size, &p, &coder_in) ||
|
||||
szh_num32(d, size, &p, &coder_out)) {
|
||||
return -1;
|
||||
}
|
||||
/* The header scanner accepts exactly one output stream per coder, so a
|
||||
coder index and its output-stream index coincide. */
|
||||
if(coder_out != 1) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
if(num_in >= SZH_MAX_STREAMS || coder_in > SZH_MAX_STREAMS - num_in) {
|
||||
return -1;
|
||||
}
|
||||
num_in += coder_in;
|
||||
if(main_byte & 0x20) {
|
||||
uint32_t props_size;
|
||||
|
||||
if(szh_num32(d, size, &p, &props_size) ||
|
||||
(size_t)props_size > size - p) {
|
||||
return -1;
|
||||
}
|
||||
p += props_size;
|
||||
}
|
||||
}
|
||||
|
||||
num_bonds = num_coders - 1;
|
||||
if(num_in < num_bonds) {
|
||||
return -1;
|
||||
}
|
||||
num_pack = num_in - num_bonds;
|
||||
if(num_pack != ss->num_pack) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
memset(coder_used, 0, sizeof(coder_used));
|
||||
memset(stream_used, 0, sizeof(stream_used));
|
||||
|
||||
for(i = 0; i < num_bonds; i++) {
|
||||
uint32_t in_index, out_index;
|
||||
|
||||
if(szh_num32(d, size, &p, &in_index)) {
|
||||
return -1;
|
||||
}
|
||||
if(in_index >= num_in || stream_used[in_index]) {
|
||||
return -1;
|
||||
}
|
||||
stream_used[in_index] = 1;
|
||||
if(szh_num32(d, size, &p, &out_index)) {
|
||||
return -1;
|
||||
}
|
||||
if(out_index >= num_coders || coder_used[out_index]) {
|
||||
return -1;
|
||||
}
|
||||
coder_used[out_index] = 1;
|
||||
}
|
||||
if(num_pack != 1) {
|
||||
for(i = 0; i < num_pack; i++) {
|
||||
uint32_t index;
|
||||
|
||||
if(szh_num32(d, size, &p, &index)) {
|
||||
return -1;
|
||||
}
|
||||
if(index >= num_in || stream_used[index]) {
|
||||
return -1;
|
||||
}
|
||||
stream_used[index] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* The coder no bond consumes produces the folder's output. */
|
||||
while(main_index < num_coders && coder_used[main_index]) {
|
||||
main_index++;
|
||||
}
|
||||
if(main_index >= num_coders) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
ss->blob = d + start;
|
||||
ss->blob_size = p - start;
|
||||
ss->num_coders = num_coders;
|
||||
ss->main_coder = main_index;
|
||||
*pos = p;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Reads the StreamsInfo of a k7zIdEncodedHeader record. Only the parts this
|
||||
module acts on are interpreted; anything else is skipped the way the SDK
|
||||
skips an id it does not know. */
|
||||
static int
|
||||
szh_parse_streams(const uint8_t *d, size_t size, szh_streams_t *ss) {
|
||||
size_t pos = 1; /* past k7zIdEncodedHeader */
|
||||
uint64_t id;
|
||||
uint32_t i;
|
||||
int seen_folder = 0, seen_sizes = 0;
|
||||
|
||||
memset(ss, 0, sizeof(*ss));
|
||||
|
||||
if(szh_num(d, size, &pos, &id) || id != SZH_ID_PACK_INFO) {
|
||||
return -1;
|
||||
}
|
||||
if(szh_num(d, size, &pos, &ss->pack_pos) ||
|
||||
szh_num32(d, size, &pos, &ss->num_pack)) {
|
||||
return -1;
|
||||
}
|
||||
if(ss->num_pack == 0 || ss->num_pack > SZH_MAX_STREAMS) {
|
||||
return -1;
|
||||
}
|
||||
{
|
||||
int got_sizes = 0;
|
||||
|
||||
for(;;) {
|
||||
if(szh_num(d, size, &pos, &id)) {
|
||||
return -1;
|
||||
}
|
||||
if(id == SZH_ID_END) {
|
||||
break;
|
||||
}
|
||||
if(id == SZH_ID_SIZE) {
|
||||
if(got_sizes) {
|
||||
return -1;
|
||||
}
|
||||
for(i = 0; i < ss->num_pack; i++) {
|
||||
if(szh_num(d, size, &pos, &ss->pack_sizes[i])) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
got_sizes = 1;
|
||||
continue;
|
||||
}
|
||||
if(szh_skip_data(d, size, &pos)) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
if(!got_sizes) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
ss->pack_positions[0] = 0;
|
||||
for(i = 0; i < ss->num_pack; i++) {
|
||||
ss->pack_positions[i + 1] = ss->pack_positions[i] + ss->pack_sizes[i];
|
||||
if(ss->pack_positions[i + 1] < ss->pack_positions[i]) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if(szh_num(d, size, &pos, &id) || id != SZH_ID_UNPACK_INFO) {
|
||||
return -1;
|
||||
}
|
||||
for(;;) {
|
||||
if(szh_num(d, size, &pos, &id)) {
|
||||
return -1;
|
||||
}
|
||||
if(id == SZH_ID_END) {
|
||||
break;
|
||||
}
|
||||
if(id == SZH_ID_FOLDER) {
|
||||
uint32_t num_folders;
|
||||
|
||||
if(seen_folder || szh_num32(d, size, &pos, &num_folders)) {
|
||||
return -1;
|
||||
}
|
||||
/* SzArEx_Open2 decodes this record with numFoldersMax = 1, and the
|
||||
`external` flag has to be clear for the table to be inline. */
|
||||
if(num_folders != 1 || pos >= size || d[pos] != 0) {
|
||||
return -1;
|
||||
}
|
||||
pos++;
|
||||
if(szh_parse_folder(d, size, &pos, ss)) {
|
||||
return -1;
|
||||
}
|
||||
seen_folder = 1;
|
||||
continue;
|
||||
}
|
||||
if(id == SZH_ID_CODERS_UNPACK_SIZE) {
|
||||
if(!seen_folder || seen_sizes) {
|
||||
return -1;
|
||||
}
|
||||
for(i = 0; i < ss->num_coders; i++) {
|
||||
if(szh_num(d, size, &pos, &ss->coder_unpack_sizes[i])) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
seen_sizes = 1;
|
||||
continue;
|
||||
}
|
||||
if(id == SZH_ID_CRC) {
|
||||
uint32_t crc = 0;
|
||||
int has = 0;
|
||||
|
||||
if(ss->has_crc || szh_digests(d, size, &pos, 1, &crc, &has)) {
|
||||
return -1;
|
||||
}
|
||||
if(has) {
|
||||
ss->crc = crc;
|
||||
ss->has_crc = 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if(szh_skip_data(d, size, &pos)) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
if(!seen_folder || !seen_sizes) {
|
||||
return -1;
|
||||
}
|
||||
ss->unpack_size = ss->coder_unpack_sizes[ss->main_coder];
|
||||
|
||||
/* A SubStreamsInfo may hold the folder CRC when UnpackInfo carried none, but
|
||||
nothing past this point changes what we decode. */
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- stream I/O */
|
||||
|
||||
static uint64_t
|
||||
szh_size_of(ISeekInStream *s) {
|
||||
Int64 pos = 0;
|
||||
|
||||
if(s->Seek(s, &pos, SZ_SEEK_END) != SZ_OK || pos < 0) {
|
||||
return 0;
|
||||
}
|
||||
return (uint64_t)pos;
|
||||
}
|
||||
|
||||
static int
|
||||
szh_read_at(ISeekInStream *s, uint64_t offset, void *dst, size_t size) {
|
||||
Int64 pos = (Int64)offset;
|
||||
size_t got = 0;
|
||||
|
||||
if(s->Seek(s, &pos, SZ_SEEK_SET) != SZ_OK) {
|
||||
return -1;
|
||||
}
|
||||
while(got < size) {
|
||||
size_t want = size - got;
|
||||
|
||||
if(s->Read(s, (uint8_t *)dst + got, &want) != SZ_OK) {
|
||||
return -1;
|
||||
}
|
||||
if(want == 0) {
|
||||
return -1;
|
||||
}
|
||||
got += want;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The virtual view handed to the SDK. `vt` has to stay first: the SDK casts
|
||||
the interface pointer straight back to this struct, exactly as
|
||||
sevenz_volstream.c does. */
|
||||
typedef struct {
|
||||
ISeekInStream vt;
|
||||
|
||||
ISeekInStream *raw;
|
||||
uint64_t raw_size;
|
||||
uint64_t pos;
|
||||
uint64_t total; /* the virtual length; the header may stick out past the file */
|
||||
|
||||
uint8_t sig[k7zStartHeaderSize]; /* start header, rewritten for the plaintext */
|
||||
uint64_t hdr_off;
|
||||
uint8_t *hdr;
|
||||
uint64_t hdr_len;
|
||||
} szh_view;
|
||||
|
||||
struct szh_prep {
|
||||
szh_view view;
|
||||
};
|
||||
|
||||
static SRes
|
||||
szh_view_read(const ISeekInStream *p, void *buf, size_t *size) {
|
||||
szh_view *v = (szh_view *)p;
|
||||
uint8_t *dst = (uint8_t *)buf;
|
||||
const size_t want = *size;
|
||||
size_t got = 0;
|
||||
|
||||
*size = 0;
|
||||
while(got < want) {
|
||||
const uint64_t pos = v->pos;
|
||||
const uint64_t hdr_end = v->hdr_off + v->hdr_len;
|
||||
|
||||
if(pos < k7zStartHeaderSize) {
|
||||
size_t n = (size_t)(k7zStartHeaderSize - pos);
|
||||
|
||||
if(n > want - got) {
|
||||
n = want - got;
|
||||
}
|
||||
memcpy(dst + got, v->sig + pos, n);
|
||||
got += n;
|
||||
v->pos += n;
|
||||
continue;
|
||||
}
|
||||
if(pos >= v->hdr_off && pos < hdr_end) {
|
||||
size_t n = (size_t)(hdr_end - pos);
|
||||
|
||||
if(n > want - got) {
|
||||
n = want - got;
|
||||
}
|
||||
memcpy(dst + got, v->hdr + (pos - v->hdr_off), n);
|
||||
got += n;
|
||||
v->pos += n;
|
||||
continue;
|
||||
}
|
||||
/* Everything else is the real archive. The header region can reach past
|
||||
its end, so a read is allowed to stop short here. */
|
||||
if(pos >= v->raw_size) {
|
||||
break;
|
||||
}
|
||||
{
|
||||
Int64 raw_pos = (Int64)pos;
|
||||
size_t n = want - got;
|
||||
const uint64_t avail = v->raw_size - pos;
|
||||
|
||||
if((uint64_t)n > avail) {
|
||||
n = (size_t)avail;
|
||||
}
|
||||
if(v->raw->Seek(v->raw, &raw_pos, SZ_SEEK_SET) != SZ_OK) {
|
||||
return SZ_ERROR_READ;
|
||||
}
|
||||
if(v->raw->Read(v->raw, dst + got, &n) != SZ_OK) {
|
||||
return SZ_ERROR_READ;
|
||||
}
|
||||
if(n == 0) {
|
||||
break;
|
||||
}
|
||||
got += n;
|
||||
v->pos += n;
|
||||
}
|
||||
}
|
||||
*size = got;
|
||||
return SZ_OK;
|
||||
}
|
||||
|
||||
static SRes
|
||||
szh_view_seek(const ISeekInStream *p, Int64 *pos, ESzSeek origin) {
|
||||
szh_view *v = (szh_view *)p;
|
||||
Int64 base;
|
||||
Int64 next;
|
||||
|
||||
switch(origin) {
|
||||
case SZ_SEEK_SET:
|
||||
base = 0;
|
||||
break;
|
||||
case SZ_SEEK_CUR:
|
||||
base = (Int64)v->pos;
|
||||
break;
|
||||
case SZ_SEEK_END:
|
||||
base = (Int64)v->total;
|
||||
break;
|
||||
default:
|
||||
return SZ_ERROR_PARAM;
|
||||
}
|
||||
next = base + *pos;
|
||||
if(next < 0) {
|
||||
next = 0;
|
||||
}
|
||||
v->pos = (uint64_t)next;
|
||||
*pos = next;
|
||||
return SZ_OK;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ decoding the header */
|
||||
|
||||
typedef struct {
|
||||
ISeekInStream *raw;
|
||||
uint64_t base;
|
||||
} szh_reader_t;
|
||||
|
||||
static int
|
||||
szh_chain_read(void *ctx, uint64_t offset, void *dst, size_t size) {
|
||||
szh_reader_t *r = (szh_reader_t *)ctx;
|
||||
|
||||
return szh_read_at(r->raw, r->base + offset, dst, size);
|
||||
}
|
||||
|
||||
/* Collects the decrypted header. It grows on demand rather than trusting the
|
||||
declared unpack size, which is attacker controlled. */
|
||||
typedef struct {
|
||||
uint8_t *data;
|
||||
size_t len;
|
||||
size_t cap;
|
||||
int failed;
|
||||
} szh_sink_t;
|
||||
|
||||
static int
|
||||
szh_sink_write(void *ctx, const void *data, size_t size) {
|
||||
szh_sink_t *s = (szh_sink_t *)ctx;
|
||||
|
||||
if(s->failed) {
|
||||
return -1;
|
||||
}
|
||||
if((uint64_t)size > SZH_MAX_HEADER - (uint64_t)s->len) {
|
||||
s->failed = 1;
|
||||
return -1;
|
||||
}
|
||||
if(s->len + size > s->cap) {
|
||||
size_t cap = s->cap ? s->cap : 4096;
|
||||
uint8_t *grown;
|
||||
|
||||
while(cap < s->len + size) {
|
||||
cap *= 2;
|
||||
}
|
||||
grown = (uint8_t *)realloc(s->data, cap);
|
||||
if(!grown) {
|
||||
s->failed = 1;
|
||||
return -1;
|
||||
}
|
||||
s->data = grown;
|
||||
s->cap = cap;
|
||||
}
|
||||
memcpy(s->data + s->len, data, size);
|
||||
s->len += size;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ public */
|
||||
|
||||
const char *
|
||||
szh_status_string(szh_status_t status) {
|
||||
switch(status) {
|
||||
case SZH_PLAIN:
|
||||
return "the archive header is readable";
|
||||
case SZH_PATCHED:
|
||||
return "the archive header was decrypted";
|
||||
case SZH_ERR_PASSWORD:
|
||||
return "the archive header is encrypted";
|
||||
case SZH_ERR_UNSUPPORTED:
|
||||
return "the archive header uses an unsupported arrangement";
|
||||
case SZH_ERR_FORMAT:
|
||||
return "the archive header is malformed";
|
||||
case SZH_ERR_IO:
|
||||
return "the archive header could not be read";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
static void
|
||||
szh_set_msg(char *msg, size_t msg_size, const char *text, const char *detail) {
|
||||
if(!msg || !msg_size) {
|
||||
return;
|
||||
}
|
||||
if(detail) {
|
||||
snprintf(msg, msg_size, "%s: %s", text, detail);
|
||||
} else {
|
||||
snprintf(msg, msg_size, "%s", text);
|
||||
}
|
||||
}
|
||||
|
||||
szh_status_t
|
||||
szh_prepare(szh_prep **out, ISeekInStream *raw, const char *password,
|
||||
char *msg, size_t msg_size) {
|
||||
uint8_t sig[k7zStartHeaderSize];
|
||||
uint64_t raw_size, next_off, next_size;
|
||||
uint32_t next_crc;
|
||||
uint8_t *raw_hdr = NULL;
|
||||
szh_streams_t ss;
|
||||
sz_chain *chain = NULL;
|
||||
sz_chain_err_t cerr;
|
||||
szh_sink_t sink;
|
||||
szh_reader_t reader;
|
||||
szh_prep *prep = NULL;
|
||||
szh_status_t status = SZH_PLAIN;
|
||||
uint32_t decoded_crc = 0;
|
||||
|
||||
if(out) {
|
||||
*out = NULL;
|
||||
}
|
||||
if(!out || !raw) {
|
||||
return SZH_ERR_IO;
|
||||
}
|
||||
if(msg && msg_size) {
|
||||
msg[0] = 0;
|
||||
}
|
||||
|
||||
/* Sevenz extraction runs this before SzArEx_Open, but the table is what
|
||||
every CRC below needs and generating it twice costs nothing. */
|
||||
CrcGenerateTable();
|
||||
memset(&cerr, 0, sizeof(cerr));
|
||||
memset(&sink, 0, sizeof(sink));
|
||||
|
||||
/* --- the start header ------------------------------------------------ */
|
||||
raw_size = szh_size_of(raw);
|
||||
if(raw_size < k7zStartHeaderSize || szh_read_at(raw, 0, sig, sizeof(sig))) {
|
||||
return SZH_PLAIN;
|
||||
}
|
||||
if(memcmp(sig, k7zSignature, k7zSignatureSize) != 0 || sig[6] != 0) {
|
||||
return SZH_PLAIN;
|
||||
}
|
||||
if(CrcCalc(sig + 12, 20) != szh_le32(sig + 8)) {
|
||||
return SZH_PLAIN;
|
||||
}
|
||||
|
||||
next_off = szh_le64(sig + 12);
|
||||
next_size = szh_le64(sig + 20);
|
||||
next_crc = szh_le32(sig + 28);
|
||||
|
||||
if(next_size == 0 || next_size > SZH_MAX_HEADER) {
|
||||
return SZH_PLAIN;
|
||||
}
|
||||
if(next_off > raw_size || next_size > raw_size - next_off) {
|
||||
return SZH_PLAIN;
|
||||
}
|
||||
|
||||
/* --- the next header ------------------------------------------------- */
|
||||
/* One byte decides it: an ordinary header (k7zIdHeader) is none of our
|
||||
business, and a big uncompressed one is not worth reading twice. */
|
||||
{
|
||||
uint8_t first_byte = 0;
|
||||
|
||||
if(szh_read_at(raw, k7zStartHeaderSize + next_off, &first_byte, 1) ||
|
||||
first_byte != SZH_ID_ENCODED_HEADER) {
|
||||
return SZH_PLAIN;
|
||||
}
|
||||
}
|
||||
|
||||
raw_hdr = (uint8_t *)malloc((size_t)next_size);
|
||||
if(!raw_hdr) {
|
||||
szh_set_msg(msg, msg_size, "out of memory reading the archive header",
|
||||
NULL);
|
||||
return SZH_ERR_IO;
|
||||
}
|
||||
if(szh_read_at(raw, k7zStartHeaderSize + next_off, raw_hdr,
|
||||
(size_t)next_size) ||
|
||||
CrcCalc(raw_hdr, (size_t)next_size) != next_crc) {
|
||||
/* Broken or truncated: let the SDK diagnose it exactly as it always has. */
|
||||
status = SZH_PLAIN;
|
||||
goto done;
|
||||
}
|
||||
if(szh_parse_streams(raw_hdr, (size_t)next_size, &ss)) {
|
||||
status = SZH_PLAIN;
|
||||
goto done;
|
||||
}
|
||||
|
||||
/* --- the folder behind it -------------------------------------------- */
|
||||
if(sz_chain_parse(&chain, ss.blob, ss.blob_size, ss.pack_positions,
|
||||
ss.num_pack, ss.coder_unpack_sizes, ss.unpack_size,
|
||||
sz_chain_default_limits(), &cerr) != 0) {
|
||||
/* Not ours to report: the SDK rejects such an archive too, and it is the
|
||||
one that knows how to describe it. */
|
||||
status = SZH_PLAIN;
|
||||
goto done;
|
||||
}
|
||||
if(!sz_chain_needs_password(chain)) {
|
||||
/* An ordinary compressed header (-mhc=on), which the SDK decodes itself. */
|
||||
status = SZH_PLAIN;
|
||||
goto done;
|
||||
}
|
||||
if(!password || !password[0]) {
|
||||
szh_set_msg(msg, msg_size,
|
||||
"the archive header is encrypted (-mhe=on), so the file names, "
|
||||
"the folder table and the entry sizes are all inside it and the "
|
||||
"archive cannot be listed or unpacked without the password",
|
||||
NULL);
|
||||
status = SZH_ERR_PASSWORD;
|
||||
goto done;
|
||||
}
|
||||
|
||||
reader.raw = raw;
|
||||
reader.base = (uint64_t)k7zStartHeaderSize + ss.pack_pos;
|
||||
if(sz_chain_decode(chain, szh_chain_read, &reader, szh_sink_write, &sink, NULL,
|
||||
NULL, password, &decoded_crc, &cerr) != 0 ||
|
||||
sink.failed) {
|
||||
switch(cerr.status) {
|
||||
case SZ_CHAIN_ERR_PASSWORD:
|
||||
case SZ_CHAIN_ERR_DATA:
|
||||
szh_set_msg(msg, msg_size,
|
||||
"the encrypted archive header did not decrypt: the password "
|
||||
"is wrong, or the archive is damaged",
|
||||
cerr.message);
|
||||
status = SZH_ERR_PASSWORD;
|
||||
break;
|
||||
case SZ_CHAIN_ERR_LIMIT:
|
||||
case SZ_CHAIN_ERR_METHOD:
|
||||
case SZ_CHAIN_ERR_LAYOUT:
|
||||
szh_set_msg(msg, msg_size, "cannot decode the encrypted archive header",
|
||||
cerr.message);
|
||||
status = SZH_ERR_UNSUPPORTED;
|
||||
break;
|
||||
case SZ_CHAIN_ERR_MEM:
|
||||
szh_set_msg(msg, msg_size, "out of memory decoding the archive header",
|
||||
NULL);
|
||||
status = SZH_ERR_IO;
|
||||
break;
|
||||
default:
|
||||
szh_set_msg(msg, msg_size, "cannot decode the encrypted archive header",
|
||||
cerr.message);
|
||||
status = SZH_ERR_FORMAT;
|
||||
break;
|
||||
}
|
||||
goto done;
|
||||
}
|
||||
if(sink.len == 0) {
|
||||
szh_set_msg(msg, msg_size,
|
||||
"the encrypted archive header decrypted to nothing", NULL);
|
||||
status = SZH_ERR_FORMAT;
|
||||
goto done;
|
||||
}
|
||||
if(ss.has_crc && decoded_crc != ss.crc) {
|
||||
szh_set_msg(msg, msg_size,
|
||||
"the encrypted archive header decrypted to data that fails its "
|
||||
"CRC -- the password is wrong",
|
||||
NULL);
|
||||
status = SZH_ERR_PASSWORD;
|
||||
goto done;
|
||||
}
|
||||
if(sink.data[0] != SZH_ID_HEADER) {
|
||||
szh_set_msg(msg, msg_size,
|
||||
"the encrypted archive header did not decrypt to a 7z header "
|
||||
"-- the password is wrong",
|
||||
NULL);
|
||||
status = SZH_ERR_PASSWORD;
|
||||
goto done;
|
||||
}
|
||||
|
||||
/* --- hand the plaintext to the SDK ----------------------------------- */
|
||||
prep = (szh_prep *)calloc(1, sizeof(*prep));
|
||||
if(!prep) {
|
||||
szh_set_msg(msg, msg_size, "out of memory reading the archive header",
|
||||
NULL);
|
||||
status = SZH_ERR_IO;
|
||||
goto done;
|
||||
}
|
||||
prep->view.raw = raw;
|
||||
prep->view.raw_size = raw_size;
|
||||
prep->view.pos = 0;
|
||||
prep->view.hdr_off = (uint64_t)k7zStartHeaderSize + next_off;
|
||||
prep->view.hdr = sink.data;
|
||||
prep->view.hdr_len = sink.len;
|
||||
prep->view.total = prep->view.hdr_off + prep->view.hdr_len;
|
||||
if(prep->view.total < raw_size) {
|
||||
prep->view.total = raw_size;
|
||||
}
|
||||
sink.data = NULL; /* owned by the view from here on */
|
||||
|
||||
memcpy(prep->view.sig, sig, sizeof(prep->view.sig));
|
||||
szh_put_le64(prep->view.sig + 12, prep->view.hdr_off - k7zStartHeaderSize);
|
||||
szh_put_le64(prep->view.sig + 20, prep->view.hdr_len);
|
||||
szh_put_le32(prep->view.sig + 28,
|
||||
CrcCalc(prep->view.hdr, (size_t)prep->view.hdr_len));
|
||||
szh_put_le32(prep->view.sig + 8, CrcCalc(prep->view.sig + 12, 20));
|
||||
|
||||
prep->view.vt.Read = szh_view_read;
|
||||
prep->view.vt.Seek = szh_view_seek;
|
||||
|
||||
*out = prep;
|
||||
prep = NULL;
|
||||
status = SZH_PATCHED;
|
||||
|
||||
done:
|
||||
free(raw_hdr);
|
||||
free(sink.data);
|
||||
sz_chain_free(chain);
|
||||
if(prep) {
|
||||
free(prep->view.hdr);
|
||||
free(prep);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
ISeekInStream *
|
||||
szh_stream(const szh_prep *p) {
|
||||
return p ? (ISeekInStream *)&p->view.vt : NULL;
|
||||
}
|
||||
|
||||
void
|
||||
szh_prep_free(szh_prep *p) {
|
||||
if(p) {
|
||||
free(p->view.hdr);
|
||||
free(p);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/* sevenz_header -- reads the 7z header, decrypting it when it is encrypted.
|
||||
part of ps5-web-file-manager
|
||||
|
||||
A 7z archive keeps its header at the END of the file, and when that header
|
||||
grows past a threshold 7-Zip stores it *compressed*: the next-header region
|
||||
then starts with a `k7zIdEncodedHeader` (0x17) record describing a single
|
||||
folder whose output is the real header. That folder is one of two things:
|
||||
|
||||
* LZMA / LZMA2 -- `-mhc=on`, the default. The vendored SDK decodes it
|
||||
itself, so this module reads a few bytes, sees no AES coder and steps
|
||||
aside without changing anything.
|
||||
* LZMA + 7zAES -- `-mhe=on`. The C half of the SDK has no 7zAES coder at
|
||||
all, so SzArEx_Open() gives up with SZ_ERROR_UNSUPPORTED before a single
|
||||
folder is known: the archive cannot even be listed.
|
||||
|
||||
The second case is what this module exists for. It decodes that one folder
|
||||
with src/sevenz_chain.c -- the same decoder the archive's content goes
|
||||
through -- and then hands the SDK a stream in which the encoded header has
|
||||
been replaced by its plaintext. The plaintext is longer than the record it
|
||||
replaces, so the stream is a small virtual view over the real one:
|
||||
|
||||
[0, 32) the start header, rewritten to describe the
|
||||
plaintext (offset, size and CRC)
|
||||
[32, hdr_off) the real archive: packed streams
|
||||
[hdr_off, hdr_off + L) the decrypted header
|
||||
beyond that the real archive again
|
||||
|
||||
`hdr_off` is where the encoded header already lived, so no offset that the
|
||||
archive itself stores has to move: the SDK reads the plaintext at exactly
|
||||
the position it expected the encoded record, and the main data position it
|
||||
derives from the plaintext still points at the real packed streams.
|
||||
|
||||
Nothing on disk is touched, the archive is opened read-only, and an archive
|
||||
whose header is not encrypted is never touched at all.
|
||||
*/
|
||||
|
||||
#ifndef SEVENZ_HEADER_H
|
||||
#define SEVENZ_HEADER_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#include "7zTypes.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* The plaintext header is tiny in every real archive (kilobytes); the ceiling
|
||||
only exists so a hostile encoded header cannot ask for a gigabyte. */
|
||||
#define SZH_MAX_HEADER ((uint64_t)64 * 1024 * 1024)
|
||||
|
||||
typedef enum {
|
||||
/* The header is readable as it stands. Use the stream you passed in and
|
||||
let the SDK parse it, exactly as before this module existed. */
|
||||
SZH_PLAIN = 0,
|
||||
|
||||
/* The header was encrypted and is now decrypted: hand szh_stream() to
|
||||
SzArEx_Open() instead of the raw stream. */
|
||||
SZH_PATCHED,
|
||||
|
||||
/* The header is encrypted and the password given was missing or wrong.
|
||||
Actionable: the caller should ask for one and retry. */
|
||||
SZH_ERR_PASSWORD,
|
||||
|
||||
/* The encoded header uses an arrangement this module does not read. */
|
||||
SZH_ERR_UNSUPPORTED,
|
||||
|
||||
/* The encoded header is malformed. */
|
||||
SZH_ERR_FORMAT,
|
||||
|
||||
/* Reading the archive failed. */
|
||||
SZH_ERR_IO
|
||||
} szh_status_t;
|
||||
|
||||
typedef struct szh_prep szh_prep;
|
||||
|
||||
/* Inspects the archive header behind `raw`.
|
||||
|
||||
On SZH_PLAIN *out is NULL and the caller proceeds with `raw` untouched.
|
||||
On SZH_PATCHED *out owns everything and szh_stream(*out) must be used.
|
||||
Otherwise *out is NULL and `msg` says why, in a form meant for the user.
|
||||
|
||||
`password` is the archive password as UTF-8, or NULL / "" when the caller
|
||||
has none. It is only consulted when the header turns out to be encrypted.
|
||||
|
||||
The function never reports an error for an archive the SDK would diagnose
|
||||
better: anything unexpected *before* an AES coder is found -- a short file,
|
||||
a bad signature, a header CRC mismatch, an unparsable StreamsInfo -- comes
|
||||
back as SZH_PLAIN so the SDK keeps producing the message it always did. */
|
||||
szh_status_t szh_prepare(szh_prep **out, ISeekInStream *raw, const char *password,
|
||||
char *msg, size_t msg_size);
|
||||
|
||||
/* The stream to give SzArEx_Open(); NULL when p is NULL. Valid until
|
||||
szh_prep_free(). */
|
||||
ISeekInStream *szh_stream(const szh_prep *p);
|
||||
|
||||
/* Static description of a status, for messages that have no better text. */
|
||||
const char *szh_status_string(szh_status_t status);
|
||||
|
||||
void szh_prep_free(szh_prep *p);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* SEVENZ_HEADER_H */
|
||||
+32
-7
@@ -49,6 +49,11 @@ typedef struct {
|
||||
zipx_cancel_fn cancel;
|
||||
zipx_progress_fn progress;
|
||||
void *userdata;
|
||||
/* NULL or empty means "no password supplied". Both ZIP encryption schemes
|
||||
(traditional PKWARE and WinZip AES) only need it for the entry data: the
|
||||
central directory is never encrypted, so the scan phase can read every
|
||||
header without it. */
|
||||
const char *password;
|
||||
zipx_result_t *result;
|
||||
uint64_t entries_total;
|
||||
uint64_t entries_done;
|
||||
@@ -604,8 +609,16 @@ scan_archive(void *zip, zipx_ctx_t *c) {
|
||||
break;
|
||||
}
|
||||
if((info->flag & MZ_ZIP_FLAG_ENCRYPTED) || info->aes_version) {
|
||||
ret = ctx_fail(c, ZIPX_ERR_UNSUPPORTED, name, "encrypted entry");
|
||||
break;
|
||||
/* Encrypted entries are readable, but only with a password. The
|
||||
password itself is verified when the entry data is opened -- for
|
||||
ZipCrypto against the 1-2 byte header check, for WinZip AES against
|
||||
the 2 byte PBKDF2 verifier -- so a wrong password surfaces as
|
||||
ZIPX_ERR_PASSWORD from the extract phase rather than here. */
|
||||
if(!c->password) {
|
||||
ret = ctx_fail(c, ZIPX_ERR_PASSWORD, name,
|
||||
"entry is encrypted and no password was given");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(info->compression_method != MZ_COMPRESS_METHOD_STORE &&
|
||||
info->compression_method != MZ_COMPRESS_METHOD_DEFLATE) {
|
||||
@@ -841,10 +854,21 @@ write_entry(void *zip, zipx_ctx_t *c, int root_fd, const char *name,
|
||||
return -1;
|
||||
}
|
||||
|
||||
if(mz_zip_entry_read_open(zip, 0, NULL) != MZ_OK) {
|
||||
ctx_fail(c, ZIPX_ERR_FORMAT, name, "cannot read entry data");
|
||||
ret = -1;
|
||||
goto done;
|
||||
{
|
||||
/* Opening the entry data is also where a supplied password is checked:
|
||||
mz_strm_pkcrypt.c compares the decrypted header byte(s) and
|
||||
mz_strm_wzaes.c the 2 byte PBKDF2 verifier, both returning
|
||||
MZ_PASSWORD_ERROR on a mismatch. */
|
||||
int err = mz_zip_entry_read_open(zip, 0, c->password);
|
||||
|
||||
if(err != MZ_OK) {
|
||||
ctx_fail(c, err == MZ_PASSWORD_ERROR ? ZIPX_ERR_PASSWORD : ZIPX_ERR_FORMAT,
|
||||
name, "%s",
|
||||
err == MZ_PASSWORD_ERROR ? "the password is wrong"
|
||||
: "cannot read entry data");
|
||||
ret = -1;
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
|
||||
while(ret == 0) {
|
||||
@@ -1247,7 +1271,7 @@ zipx_status_t
|
||||
zipx_extract(const char *zip_path, const char *dst_dir,
|
||||
zipx_conflict_t conflict, const zipx_limits_t *limits,
|
||||
zipx_cancel_fn cancel, zipx_progress_fn progress,
|
||||
void *userdata, zipx_result_t *result) {
|
||||
void *userdata, const char *password, zipx_result_t *result) {
|
||||
zipx_ctx_t ctx;
|
||||
zipx_ctx_t *c = &ctx;
|
||||
char parent[ZIPX_PATH_MAX];
|
||||
@@ -1280,6 +1304,7 @@ zipx_extract(const char *zip_path, const char *dst_dir,
|
||||
c->cancel = cancel;
|
||||
c->progress = progress;
|
||||
c->userdata = userdata;
|
||||
c->password = (password && password[0]) ? password : NULL;
|
||||
|
||||
snprintf(dst_copy, sizeof(dst_copy), "%s", dst_dir);
|
||||
{
|
||||
|
||||
+8
-1
@@ -20,7 +20,7 @@ typedef enum {
|
||||
ZIPX_ERR_CANCELED,
|
||||
ZIPX_ERR_OPEN, /* cannot open the archive */
|
||||
ZIPX_ERR_FORMAT, /* corrupt central directory / truncated */
|
||||
ZIPX_ERR_UNSUPPORTED,/* encryption, multipart or unsupported method */
|
||||
ZIPX_ERR_UNSUPPORTED,/* multipart or unsupported compression method */
|
||||
ZIPX_ERR_UNSAFE_NAME,/* traversal, absolute path, control chars, NUL */
|
||||
ZIPX_ERR_SPECIAL, /* symlink / device / fifo / socket entry */
|
||||
ZIPX_ERR_DUPLICATE, /* repeated entry or file/dir name clash inside zip */
|
||||
@@ -30,6 +30,8 @@ typedef enum {
|
||||
ZIPX_ERR_LIMIT_RATIO,
|
||||
ZIPX_ERR_LIMIT_DEPTH,
|
||||
ZIPX_ERR_LIMIT_NAME,
|
||||
ZIPX_ERR_LIMIT_DICT, /* the archive's dictionary exceeds what we allow
|
||||
(RAR7 headers may ask for up to 64 GiB) */
|
||||
ZIPX_ERR_CONFLICT, /* target already exists for the chosen policy */
|
||||
ZIPX_ERR_PASSWORD, /* the archive is encrypted and the password is missing
|
||||
or wrong; the caller can prompt and retry */
|
||||
@@ -101,6 +103,10 @@ const zipx_limits_t *zipx_limits_profile(int profile);
|
||||
const char *zipx_status_string(zipx_status_t status);
|
||||
|
||||
/* Extract zip_path into dst_dir.
|
||||
`password` may be NULL or empty when the archive is not encrypted; it is
|
||||
used for both ZIP encryption schemes, traditional PKWARE ("ZipCrypto") and
|
||||
WinZip AES. A missing or wrong password is reported as ZIPX_ERR_PASSWORD,
|
||||
which the caller is expected to turn into a prompt and retry.
|
||||
Returns ZIPX_OK or an error code; *result is always filled in.
|
||||
On any failure the staging directory is removed and dst_dir is left as it
|
||||
was, except for objects already published with the overwrite policy. */
|
||||
@@ -110,4 +116,5 @@ zipx_status_t zipx_extract(const char *zip_path, const char *dst_dir,
|
||||
zipx_cancel_fn cancel,
|
||||
zipx_progress_fn progress,
|
||||
void *userdata,
|
||||
const char *password,
|
||||
zipx_result_t *result);
|
||||
@@ -89,6 +89,7 @@ zipx_status_string(zipx_status_t status) {
|
||||
case ZIPX_ERR_LIMIT_RATIO: return "compression ratio too high";
|
||||
case ZIPX_ERR_LIMIT_DEPTH: return "path too deep";
|
||||
case ZIPX_ERR_LIMIT_NAME: return "path too long";
|
||||
case ZIPX_ERR_LIMIT_DICT: return "dictionary too large";
|
||||
case ZIPX_ERR_CONFLICT: return "target already exists";
|
||||
case ZIPX_ERR_PASSWORD: return "password required or wrong";
|
||||
case ZIPX_ERR_SPACE: return "not enough space";
|
||||
|
||||
+107
-35
@@ -1,14 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Performance baseline for the extraction engines.
|
||||
|
||||
python tests/bench_driver.py # 82 MiB fixture, fast
|
||||
python tests/bench_driver.py --big # 320 MiB fixture, accurate
|
||||
python tests/bench_driver.py # 82 MiB fixture, fast
|
||||
python tests/bench_driver.py --big # 320 MiB fixture, accurate
|
||||
python tests/bench_driver.py --format zip
|
||||
python tests/bench_driver.py --format rar
|
||||
python tests/bench_driver.py --runs 5
|
||||
|
||||
Times our facades (src/zip_extract.c, src/sevenz_extract.c) against the two
|
||||
external references that matter: the vendored SDK's own SzArEx path, and the
|
||||
official 7-Zip binary -- the latter being what upstream v1.8 gets by shelling
|
||||
out to a helper, so it doubles as the "how fast could we be" ceiling.
|
||||
Times our facades (src/zip_extract.c, src/rar_extract.c,
|
||||
src/sevenz_extract.c) against the external references that matter: the
|
||||
vendored SDK's own SzArEx path, and the official 7-Zip binary -- the latter
|
||||
being what upstream v1.8 gets by shelling out to a helper, so it doubles as
|
||||
the "how fast could we be" ceiling.
|
||||
|
||||
The three formats are packed from one shared payload, so the rows are
|
||||
comparable across formats and not just within one engine.
|
||||
|
||||
Why Python drives the measurement: in this sandbox a single `date` costs
|
||||
~350 ms, so the usual `t0=$(date)` / `t1=$(date)` pair adds ~700 ms of pure
|
||||
@@ -22,6 +28,7 @@ spawn tax entirely -- that is the most trustworthy figure for our side.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
@@ -35,9 +42,23 @@ HT_BUILD = os.path.join(REPO, ".build", "host-test")
|
||||
SEVENZ_BIN = os.path.join(REPO, ".build", "7zdl", "extra", "x64", "7za.exe")
|
||||
BENCH_BIN = os.path.join(BUILD, "bench_extract.exe")
|
||||
SDK_BIN = os.path.join(SZ_BUILD, "sevenz_e2e.exe")
|
||||
# RAR is write-only in WinRAR (7-Zip can read the format but not create it),
|
||||
# so a RAR fixture needs rar.exe. Missing means the rar fixture is skipped,
|
||||
# never that the run fails.
|
||||
RAR_BIN = next((p for p in (
|
||||
os.environ.get("WFM_RAR"),
|
||||
r"C:\Program Files\WinRAR\rar.exe",
|
||||
r"C:\Program Files (x86)\WinRAR\rar.exe",
|
||||
"/usr/bin/rar", "/usr/local/bin/rar") if p and os.path.exists(p)), None)
|
||||
|
||||
# Object lists mirror what tests/run-sevenz-tests.sh and tests/run-tests.sh
|
||||
# build; those scripts must have run once before this can link.
|
||||
#
|
||||
# These lists are the one thing here that can rot: adding a source file to
|
||||
# either engine (sevenz_header.c for -mhe=on, mz_crypt_wfm.c plus the two
|
||||
# restored minizip-ng crypto streams for encrypted ZIP) silently leaves the
|
||||
# link with undefined symbols. If `g++` below fails on an unresolved symbol
|
||||
# that clearly lives in src/ or third_party/, check here first.
|
||||
VENDOR_7Z = ["7zAlloc", "7zArcIn", "7zBuf", "7zBuf2", "7zCrc", "7zCrcOpt",
|
||||
"7zDec", "7zFile", "7zStream", "Aes", "AesOpt", "Alloc", "Bcj2",
|
||||
"Bra", "Bra86", "BraIA64", "CpuArch", "Delta", "DllSecur",
|
||||
@@ -45,8 +66,9 @@ VENDOR_7Z = ["7zAlloc", "7zArcIn", "7zBuf", "7zBuf2", "7zCrc", "7zCrcOpt",
|
||||
"Ppmd7Dec", "Sha256", "Sha256Opt", "SwapBytes"]
|
||||
ZLIB = ["adler32", "crc32", "deflate", "inffast", "inflate", "inftrees",
|
||||
"trees", "zutil"]
|
||||
MINIZIP = ["mz_crypt", "mz_os", "mz_os_posix", "mz_strm", "mz_strm_mem",
|
||||
"mz_strm_os_posix", "mz_strm_zlib", "mz_zip"]
|
||||
MINIZIP = ["mz_crypt", "mz_crypt_wfm", "mz_os", "mz_os_posix", "mz_strm",
|
||||
"mz_strm_mem", "mz_strm_os_posix", "mz_strm_pkcrypt",
|
||||
"mz_strm_wzaes", "mz_strm_zlib", "mz_zip"]
|
||||
EXTRA_LIBS = ["-lole32", "-loleaut32", "-luuid", "-ladvapi32", "-luser32",
|
||||
"-lshell32"]
|
||||
|
||||
@@ -60,12 +82,15 @@ def build_bench():
|
||||
if os.path.exists(BENCH_BIN):
|
||||
return True
|
||||
|
||||
# The RAR engine is C++ (vendored UnRAR), so bench_extract.c is compiled
|
||||
# with gcc and the link goes through g++.
|
||||
unrar = sorted(glob.glob(os.path.join(HT_BUILD, "unrar7_*.o")))
|
||||
needed = (objs(SZ_BUILD, ["sevenz_extract", "sevenz_chain",
|
||||
"sevenz_mt", "sevenz_volstream", "zipx_common",
|
||||
"zipx_volume"])
|
||||
+ objs(HT_BUILD, ["zip_extract", "zipx_volstream"])
|
||||
"sevenz_header", "sevenz_mt", "sevenz_volstream",
|
||||
"zipx_common", "zipx_volume"])
|
||||
+ objs(HT_BUILD, ["zip_extract", "zipx_volstream", "rar_extract"])
|
||||
+ objs(HT_BUILD, ZLIB) + objs(HT_BUILD, MINIZIP)
|
||||
+ objs(SZ_BUILD, VENDOR_7Z))
|
||||
+ objs(SZ_BUILD, VENDOR_7Z) + unrar)
|
||||
missing = [p for p in needed if not os.path.exists(p)]
|
||||
if missing:
|
||||
print("missing engine objects, run these first:")
|
||||
@@ -74,17 +99,26 @@ def build_bench():
|
||||
print("first missing: %s" % missing[0])
|
||||
return False
|
||||
|
||||
cmd = ["gcc", "-O2", "-w",
|
||||
"-I" + os.path.join(REPO, "third_party", "7z"),
|
||||
"-I" + os.path.join(REPO, "third_party", "minizip-ng", "include"),
|
||||
"-I" + os.path.join(REPO, "third_party", "zlib", "include"),
|
||||
"-I" + os.path.join(REPO, "src"),
|
||||
"-I" + os.path.join(REPO, "tests", "compat"),
|
||||
"-include", os.path.join(REPO, "tests", "posix_compat.h"),
|
||||
"-o", BENCH_BIN, os.path.join(REPO, "tests", "bench_extract.c")]
|
||||
cmd += needed + EXTRA_LIBS
|
||||
includes = ["-I" + os.path.join(REPO, "third_party", "7z"),
|
||||
"-I" + os.path.join(REPO, "third_party", "minizip-ng", "include"),
|
||||
"-I" + os.path.join(REPO, "third_party", "zlib", "include"),
|
||||
"-I" + os.path.join(REPO, "src"),
|
||||
"-I" + os.path.join(REPO, "tests", "compat"),
|
||||
"-include", os.path.join(REPO, "tests", "posix_compat.h")]
|
||||
obj = os.path.join(BUILD, "bench_extract.o")
|
||||
print("== compiling bench harness ==")
|
||||
if subprocess.run(cmd, cwd=REPO).returncode != 0:
|
||||
if subprocess.run(["gcc", "-O2", "-w"] + includes
|
||||
+ ["-c", "-o", obj,
|
||||
os.path.join(REPO, "tests", "bench_extract.c")],
|
||||
cwd=REPO).returncode != 0:
|
||||
return False
|
||||
|
||||
libs = list(EXTRA_LIBS)
|
||||
if os.name == "nt":
|
||||
# Windows unrar system.cpp references SetSuspendState (PowrProf).
|
||||
libs.append("-lpowrprof")
|
||||
if subprocess.run(["g++", "-O2", "-o", BENCH_BIN, obj] + needed + libs,
|
||||
cwd=REPO).returncode != 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -138,23 +172,48 @@ def make_fixture(big, fmt):
|
||||
out.write(fh.read())
|
||||
|
||||
print("== creating fixture (first run only) ==")
|
||||
if fmt == "7z":
|
||||
add = ["a", "-t7z", "-m0=lzma2", "-mx=5", "-ms=on"]
|
||||
if fmt == "rar":
|
||||
# RAR needs WinRAR's rar.exe; 7-Zip cannot write the format.
|
||||
rar = RAR_BIN
|
||||
if not rar:
|
||||
print("WinRAR (rar.exe) not found; cannot create a RAR fixture")
|
||||
return None
|
||||
add = ["a", "-m3", "-idq", "-ep1"]
|
||||
subprocess.run([rar] + add + [archive, payload], cwd=REPO,
|
||||
stdout=subprocess.DEVNULL)
|
||||
else:
|
||||
add = ["a", "-tzip", "-mx=5", "-mm=Deflate"]
|
||||
subprocess.run([SEVENZ_BIN] + add + [archive, payload], cwd=REPO,
|
||||
stdout=subprocess.DEVNULL)
|
||||
if fmt == "7z":
|
||||
add = ["a", "-t7z", "-m0=lzma2", "-mx=5", "-ms=on"]
|
||||
else:
|
||||
add = ["a", "-tzip", "-mx=5", "-mm=Deflate"]
|
||||
subprocess.run([SEVENZ_BIN] + add + [archive, payload], cwd=REPO,
|
||||
stdout=subprocess.DEVNULL)
|
||||
return archive
|
||||
|
||||
|
||||
def sample(argv, runs, workdir):
|
||||
"""Run argv `runs` times; return (best wall ms, best in-process ms|None)."""
|
||||
"""Run argv `runs` times; return (best wall ms, best in-process ms|None).
|
||||
|
||||
Every run writes to its own path (`workdir0`, `workdir1`, ...) that does
|
||||
not exist yet. Reusing one output directory is not an option: publishing
|
||||
into a tree left by the previous run charges the rename step for the
|
||||
collision, and that alone moved the same archive from 0.77 s to 1.28 s.
|
||||
The placeholder `{out}` in argv marks where the run directory goes.
|
||||
|
||||
Nothing is deleted between runs either -- clearing these trees is a bulk
|
||||
delete this host blocks -- so `.build/bench/W*` does accumulate and is
|
||||
worth clearing by hand now and then.
|
||||
"""
|
||||
best = None
|
||||
internal = None
|
||||
for _ in range(runs):
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
for i in range(runs):
|
||||
# Forward slashes on purpose: the engines derive the destination's
|
||||
# parent with a '/' scan (they only ever see POSIX paths on the PS5),
|
||||
# so a Windows-style relative or absolute path is rejected outright.
|
||||
run_dir = ("%s%d" % (workdir, i)).replace("\\", "/")
|
||||
cmd = [arg.replace("{out}", run_dir) for arg in argv]
|
||||
started = time.perf_counter()
|
||||
proc = subprocess.run(argv, stdout=subprocess.PIPE,
|
||||
proc = subprocess.run(cmd, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, cwd=REPO, timeout=1800)
|
||||
elapsed = (time.perf_counter() - started) * 1000.0
|
||||
if proc.returncode != 0:
|
||||
@@ -175,7 +234,7 @@ def main():
|
||||
ap.add_argument("--big", action="store_true",
|
||||
help="4x fixture (~320 MiB) for accurate ratios")
|
||||
ap.add_argument("--runs", type=int, default=3)
|
||||
ap.add_argument("--format", choices=["7z", "zip"], default="7z",
|
||||
ap.add_argument("--format", choices=["7z", "zip", "rar"], default="7z",
|
||||
help="which engine to benchmark (default 7z)")
|
||||
args = ap.parse_args()
|
||||
|
||||
@@ -199,29 +258,42 @@ def main():
|
||||
print()
|
||||
|
||||
rows = []
|
||||
wall, inner = sample([BENCH_BIN, archive, ".build/bench/W7"], args.runs,
|
||||
wall, inner = sample([BENCH_BIN, archive, "{out}"], args.runs,
|
||||
os.path.join(BUILD, "W7"))
|
||||
if wall:
|
||||
rows.append(("ours / " + args.format, wall, inner))
|
||||
|
||||
if args.format == "7z":
|
||||
wall, _ = sample([SDK_BIN, archive, ".build/bench/W8"], args.runs,
|
||||
wall, _ = sample([SDK_BIN, archive, "{out}"], args.runs,
|
||||
os.path.join(BUILD, "W8"))
|
||||
if wall:
|
||||
rows.append(("sdk SzArEx", wall, None))
|
||||
variants = (("7za 1 thread", ["-mmt=off"]),
|
||||
("7za 8 threads", ["-mmt=8"]),
|
||||
("7za all cores", []))
|
||||
elif args.format == "rar":
|
||||
# 7-Zip reads RAR, so it is a valid cross-check on the same archive.
|
||||
variants = (("7za 1 thread", ["-mmt=off"]),
|
||||
("7za all cores", []))
|
||||
else:
|
||||
variants = (("7za 1 thread", ["-mmt=off"]),)
|
||||
|
||||
for label, extra in variants:
|
||||
wall, _ = sample([SEVENZ_BIN, "x", "-y", "-aoa"] + extra
|
||||
+ ["-o.build/bench/W9", archive], args.runs,
|
||||
+ ["-o{out}", archive], args.runs,
|
||||
os.path.join(BUILD, "W9"))
|
||||
if wall:
|
||||
rows.append((label, wall, None))
|
||||
|
||||
if args.format == "rar" and RAR_BIN:
|
||||
# rar.exe treats the trailing separator as "this is the target
|
||||
# directory"; without it the path is parsed as a file mask and the
|
||||
# command reports that there is nothing to extract.
|
||||
wall, _ = sample([RAR_BIN, "x", "-y", "-o+", archive, "{out}/"],
|
||||
args.runs, os.path.join(BUILD, "W10"))
|
||||
if wall:
|
||||
rows.append(("winrar x", wall, None))
|
||||
|
||||
print(" %-18s %10s %12s" % ("configuration", "external", "internal"))
|
||||
for label, wall, inner in rows:
|
||||
print(" %-18s %9.0f ms %12s"
|
||||
|
||||
+24
-5
@@ -1,16 +1,18 @@
|
||||
/* Wall-clock benchmark for the extraction paths, ZIP and 7z.
|
||||
/* Wall-clock benchmark for the extraction paths, ZIP, RAR and 7z.
|
||||
*
|
||||
* bench_extract <archive> <out-dir> [password]
|
||||
*
|
||||
* Reports how long the facade takes end to end -- decode, staging writes,
|
||||
* fsync, publish -- which is exactly what a PS5 user waits for. It is
|
||||
* publish -- which is exactly what a PS5 user waits for. Note there is no
|
||||
* fsync in the extract path at all (see the comment in zip_extract.c's
|
||||
* publish_entry): the pipeline is "sync nothing, rename everything". It is
|
||||
* deliberately separate from the correctness drivers: those assert on bytes,
|
||||
* this one only prints numbers, and it is not part of the test matrix.
|
||||
*
|
||||
* Keeping it in-tree matters because "is our engine fast?" is a question that
|
||||
* will come up again, and the answer should be a command anyone can rerun
|
||||
* rather than a number somebody remembers. The format is picked from the
|
||||
* suffix so the same binary covers both engines.
|
||||
* suffix so the same binary covers all three engines.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
@@ -20,6 +22,10 @@
|
||||
|
||||
#include "sevenz_extract.h"
|
||||
|
||||
#ifndef BENCH_NO_RAR
|
||||
#include "rar_extract.h"
|
||||
#endif
|
||||
|
||||
static int
|
||||
has_suffix(const char *path, const char *suffix) {
|
||||
size_t path_len;
|
||||
@@ -76,6 +82,12 @@ main(int argc, char **argv) {
|
||||
has_suffix(archive, ".z01")) {
|
||||
format = "zip";
|
||||
}
|
||||
#endif
|
||||
#ifndef BENCH_NO_RAR
|
||||
else if(has_suffix(archive, ".rar") || has_suffix(archive, ".part1.rar") ||
|
||||
has_suffix(archive, ".r00")) {
|
||||
format = "rar";
|
||||
}
|
||||
#endif
|
||||
else {
|
||||
fprintf(stderr, "unsupported benchmark format: %s\n", archive);
|
||||
@@ -84,10 +96,17 @@ main(int argc, char **argv) {
|
||||
|
||||
memset(&result, 0, sizeof(result));
|
||||
started = now_seconds();
|
||||
#ifndef BENCH_NO_RAR
|
||||
if(!strcmp(format, "rar")) {
|
||||
status = rar_extract(archive, out_dir, ZIPX_CONFLICT_OVERWRITE,
|
||||
zipx_default_limits(), NULL, NULL, NULL, NULL,
|
||||
&result);
|
||||
} else
|
||||
#endif
|
||||
#ifndef BENCH_SEVENZ_ONLY
|
||||
if(!strcmp(format, "zip")) {
|
||||
status = zipx_extract(archive, out_dir, ZIPX_CONFLICT_OVERWRITE,
|
||||
zipx_default_limits(), NULL, NULL, NULL, &result);
|
||||
zipx_default_limits(), NULL, NULL, NULL, NULL, &result);
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
@@ -105,7 +124,7 @@ main(int argc, char **argv) {
|
||||
printf("unpacked : %.1f MiB\n", mebibytes);
|
||||
printf("wall : %.3f s\n", elapsed);
|
||||
if(elapsed > 0.0) {
|
||||
printf("through : %.1f MiB/s (single thread)\n", mebibytes / elapsed);
|
||||
printf("through : %.1f MiB/s\n", mebibytes / elapsed);
|
||||
}
|
||||
if(status != ZIPX_OK) {
|
||||
printf("detail : %s\n", result.detail[0] ? result.detail : "(none)");
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cross-format extraction benchmark: same payload, three containers.
|
||||
|
||||
python tests/bench_formats.py # one 329 MiB file, 3 runs
|
||||
python tests/bench_formats.py --source .build/bench/payload_bin.bin \\
|
||||
--stem bin --runs 5 # 39 MiB of machine code
|
||||
python tests/bench_formats.py --source .build/bench/manyfiles_src --stem mf
|
||||
|
||||
bench_driver.py answers "how do we compare with 7-Zip for one format"; this
|
||||
answers "which container should a user expect to unpack fastest", which is a
|
||||
different question and needs the three archives to hold the same bytes.
|
||||
|
||||
Method notes that took a while to get right, so they are pinned here:
|
||||
|
||||
* Fresh output directory per run. Publishing on top of the previous run's
|
||||
tree took the same 7z archive from 0.77 s to 1.28 s.
|
||||
* Forward slashes in every path. The engines derive the destination parent
|
||||
with a '/' scan, so "C:\\...\\W0" is rejected as an invalid destination.
|
||||
* Best of N, because a single run varies widely on this host: extraction
|
||||
creates hundreds of MiB that the on-access scanner inspects and the page
|
||||
cache has to write back, and neither is under our control.
|
||||
* Corpus matters as much as container. Deflate decodes faster than LZMA2 on
|
||||
ordinary data, but on highly repetitive text LZMA2 finds long matches
|
||||
where deflate only finds 32 KiB ones, and the order flips. Run the same
|
||||
archive set on more than one source before concluding anything.
|
||||
|
||||
Packed sizes are printed next to the times on purpose: a container that packs
|
||||
5x smaller also reads 5x less from the card, which is why the ranking on the
|
||||
PS5 -- where storage is the slow part -- can differ from the ranking here.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
BUILD = os.path.join(REPO, ".build", "bench")
|
||||
BENCH_BIN = os.path.join(BUILD, "bench_extract.exe")
|
||||
SEVENZ_BIN = os.path.join(REPO, ".build", "7zdl", "extra", "x64", "7za.exe")
|
||||
RAR_BIN = next((p for p in (
|
||||
os.environ.get("WFM_RAR"),
|
||||
r"C:\Program Files\WinRAR\rar.exe",
|
||||
r"C:\Program Files (x86)\WinRAR\rar.exe",
|
||||
"/usr/bin/rar", "/usr/local/bin/rar") if p and os.path.exists(p)), None)
|
||||
DEFAULT_SOURCE = os.path.join(BUILD, "payload4.bin")
|
||||
|
||||
# Nominal level 5 in both packers, which is the GUI default of each:
|
||||
# 7-Zip -mx=5, WinRAR -m3 ("Normal"). They are not equivalent amounts of
|
||||
# work -- LZMA2 at level 5 is a far stronger compressor than deflate at 5 --
|
||||
# but they are what a user who never opens the advanced panel ends up with.
|
||||
FORMATS = ("zip", "7z", "rar")
|
||||
|
||||
|
||||
def pack(stem, payload):
|
||||
"""Create stem.{zip,7z,rar} from payload if they are not there yet."""
|
||||
made = []
|
||||
for fmt in FORMATS:
|
||||
archive = os.path.join(BUILD, "%s.%s" % (stem, fmt))
|
||||
if os.path.exists(archive):
|
||||
continue
|
||||
if fmt == "rar":
|
||||
if not RAR_BIN:
|
||||
print("skip %s: WinRAR (rar.exe) not found" % fmt)
|
||||
continue
|
||||
cmd = [RAR_BIN, "a", "-m3", "-idq"]
|
||||
if os.path.isfile(payload):
|
||||
cmd.append("-ep1")
|
||||
cmd += [archive, payload]
|
||||
else:
|
||||
cmd = [SEVENZ_BIN, "a", "-t" + fmt] + (
|
||||
["-m0=lzma2", "-mx=5", "-ms=on"] if fmt == "7z"
|
||||
else ["-mx=5", "-mm=Deflate"]) + [archive, payload]
|
||||
print("== packing %s ==" % os.path.basename(archive))
|
||||
if subprocess.run(cmd, cwd=REPO, stdout=subprocess.DEVNULL).returncode:
|
||||
print("packing failed")
|
||||
return None
|
||||
made.append(archive)
|
||||
return [os.path.join(BUILD, "%s.%s" % (stem, f)) for f in FORMATS]
|
||||
|
||||
|
||||
def time_archive(archive, runs):
|
||||
"""Best in-process wall time over `runs` fresh-directory extractions."""
|
||||
tag = os.path.splitext(os.path.basename(archive))[0]
|
||||
times = []
|
||||
unpacked = entries = 0
|
||||
for i in range(runs):
|
||||
# A path that does not exist yet: the engines publish with a rename,
|
||||
# and a rename into a tree that already has the file costs extra.
|
||||
out = os.path.join(BUILD, "F-%s-%d" % (tag, i)).replace("\\", "/")
|
||||
started = time.perf_counter()
|
||||
proc = subprocess.run([BENCH_BIN, archive, out], cwd=REPO,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, timeout=1800)
|
||||
external = time.perf_counter() - started
|
||||
if proc.returncode != 0:
|
||||
sys.stdout.write(proc.stdout.decode("utf-8", "replace")[:400])
|
||||
return None
|
||||
text = proc.stdout.decode("utf-8", "replace")
|
||||
match = re.search(r"wall\s*:\s*([0-9.]+)", text)
|
||||
if not match:
|
||||
return None
|
||||
times.append(float(match.group(1)))
|
||||
unpacked = float(re.search(r"unpacked\s*:\s*([0-9.]+)", text).group(1))
|
||||
entries = int(re.search(r"entries\s*:\s*(\d+)", text).group(1))
|
||||
return {"times": sorted(times), "unpacked": unpacked, "entries": entries}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--runs", type=int, default=3)
|
||||
ap.add_argument("--source", default=DEFAULT_SOURCE,
|
||||
help="file or directory to pack (default: %s)" % DEFAULT_SOURCE)
|
||||
ap.add_argument("--stem", help="archive base name (default: source basename)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.exists(BENCH_BIN):
|
||||
print("missing %s -- run: python tests/bench_driver.py" % BENCH_BIN)
|
||||
return 1
|
||||
|
||||
payload = args.source
|
||||
if not os.path.isabs(payload):
|
||||
payload = os.path.join(REPO, payload)
|
||||
if not os.path.exists(payload):
|
||||
print("missing payload: %s" % payload)
|
||||
return 1
|
||||
stem = args.stem or os.path.splitext(os.path.basename(payload))[0]
|
||||
|
||||
archives = pack(stem, payload)
|
||||
if not archives:
|
||||
return 1
|
||||
|
||||
rows = []
|
||||
for archive in archives:
|
||||
if not os.path.exists(archive):
|
||||
continue
|
||||
result = time_archive(archive, args.runs)
|
||||
if result is None:
|
||||
print("failed: %s" % archive)
|
||||
continue
|
||||
rows.append((os.path.splitext(archive)[1][1:], archive, result))
|
||||
|
||||
if not rows:
|
||||
return 1
|
||||
|
||||
print()
|
||||
print("best of %d runs, fresh output directory each time" % args.runs)
|
||||
print("payload: %d entries, %.1f MiB unpacked"
|
||||
% (rows[0][2]["entries"], rows[0][2]["unpacked"]))
|
||||
print()
|
||||
print(" %-5s %10s %9s %12s %12s" %
|
||||
("fmt", "packed", "best", "median", "throughput"))
|
||||
best = min(r[2]["times"][0] for r in rows)
|
||||
for fmt, archive, result in sorted(rows, key=lambda r: r[2]["times"][0]):
|
||||
packed = os.path.getsize(archive) / 1048576.0
|
||||
times = result["times"]
|
||||
median = times[len(times) // 2]
|
||||
rate = result["unpacked"] / times[0]
|
||||
print(" %-5s %8.1f M %7.0f ms %9.0f ms %8.0f MiB/s"
|
||||
% (fmt, packed, times[0] * 1000.0, median * 1000.0, rate))
|
||||
print()
|
||||
for fmt, archive, result in sorted(rows, key=lambda r: r[2]["times"][0]):
|
||||
print(" %-5s vs fastest: %.2fx" % (fmt, result["times"][0] / best))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,161 @@
|
||||
/* Prints every extraction progress report, with a monotonic timestamp.
|
||||
*
|
||||
* bench_progress <archive> <out-dir> [--mode print|empty|none]
|
||||
*
|
||||
* bench_extract answers "how long"; this answers "did the UI move while it
|
||||
* took that long". A report that sits at entries=0/N for minutes reads to the
|
||||
* user as a hang even though bytes are still flowing, so being able to see the
|
||||
* phase/entries/bytes sequence is what turns "the progress bar freezes" into a
|
||||
* named phase. Format is picked from the suffix, same as bench_extract.
|
||||
*
|
||||
* --mode exists to answer a second question: "does reporting itself cost
|
||||
* time?". The engine's report() helper runs on the hot path -- unrar calls it
|
||||
* once per decompressed chunk -- and it reads the clock before it decides
|
||||
* whether to throttle, so the cost is paid even when no report goes out.
|
||||
* print a callback that formats and prints every report (diagnostic)
|
||||
* empty a callback that returns immediately (engine cost, no consumer)
|
||||
* none no callback at all (the engine skips report() entirely)
|
||||
* Comparing wall time across the three separates "our bookkeeping" from
|
||||
* "the file I/O we cannot avoid".
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "zip_extract.h"
|
||||
#include "rar_extract.h"
|
||||
#include "sevenz_extract.h"
|
||||
|
||||
static double g_t0;
|
||||
|
||||
static int
|
||||
has_suffix(const char *path, const char *suffix) {
|
||||
size_t path_len, suffix_len, i;
|
||||
|
||||
if(!path || !suffix) return 0;
|
||||
path_len = strlen(path);
|
||||
suffix_len = strlen(suffix);
|
||||
if(path_len < suffix_len) return 0;
|
||||
for(i = 0; i < suffix_len; i++) {
|
||||
char a = path[path_len - suffix_len + i];
|
||||
char b = suffix[i];
|
||||
|
||||
if(a >= 'A' && a <= 'Z') a = (char)(a - 'A' + 'a');
|
||||
if(b >= 'A' && b <= 'Z') b = (char)(b - 'A' + 'a');
|
||||
if(a != b) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static double
|
||||
now_seconds(void) {
|
||||
struct timespec ts;
|
||||
|
||||
if(timespec_get(&ts, TIME_UTC) != TIME_UTC) return 0.0;
|
||||
return (double)ts.tv_sec + (double)ts.tv_nsec / 1000000000.0;
|
||||
}
|
||||
|
||||
static const char *
|
||||
phase_name(int phase) {
|
||||
switch(phase) {
|
||||
case ZIPX_PHASE_SCAN: return "scan";
|
||||
case ZIPX_PHASE_EXTRACT: return "extract";
|
||||
case ZIPX_PHASE_PUBLISH: return "publish";
|
||||
case ZIPX_PHASE_CLEANUP: return "cleanup";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
/* Costs exactly what the engine's report() costs, without a consumer. */
|
||||
static void
|
||||
on_progress_empty(void *userdata, const zipx_progress_t *p) {
|
||||
(void)userdata;
|
||||
(void)p;
|
||||
}
|
||||
|
||||
static void
|
||||
on_progress(void *userdata, const zipx_progress_t *p) {
|
||||
(void)userdata;
|
||||
printf("%8.3f %-7s entries=%llu/%llu bytes=%llu/%llu %s\n",
|
||||
now_seconds() - g_t0, phase_name(p->phase),
|
||||
(unsigned long long)p->entries_done,
|
||||
(unsigned long long)p->entries_total,
|
||||
(unsigned long long)p->bytes_done,
|
||||
(unsigned long long)p->bytes_total,
|
||||
p->current ? p->current : "");
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
int
|
||||
main(int argc, char **argv) {
|
||||
zipx_result_t result;
|
||||
zipx_status_t status;
|
||||
const char *archive;
|
||||
const char *out_dir;
|
||||
const char *format;
|
||||
const char *mode = "print";
|
||||
zipx_progress_fn on_report = on_progress;
|
||||
double elapsed;
|
||||
int i;
|
||||
|
||||
if(argc < 3) {
|
||||
fprintf(stderr, "usage: %s <archive> <out-dir> [--mode print|empty|none]\n",
|
||||
argv[0]);
|
||||
return 2;
|
||||
}
|
||||
archive = argv[1];
|
||||
out_dir = argv[2];
|
||||
|
||||
for(i = 3; i < argc; i++) {
|
||||
if(!strcmp(argv[i], "--mode") && i + 1 < argc) {
|
||||
mode = argv[++i];
|
||||
} else {
|
||||
fprintf(stderr, "unknown argument: %s\n", argv[i]);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
if(!strcmp(mode, "empty")) {
|
||||
on_report = on_progress_empty;
|
||||
} else if(!strcmp(mode, "none")) {
|
||||
on_report = NULL;
|
||||
} else if(strcmp(mode, "print")) {
|
||||
fprintf(stderr, "unknown --mode: %s\n", mode);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if(has_suffix(archive, ".rar") || has_suffix(archive, ".part1.rar") ||
|
||||
has_suffix(archive, ".r00")) {
|
||||
format = "rar";
|
||||
} else if(has_suffix(archive, ".zip") || has_suffix(archive, ".z01") ||
|
||||
has_suffix(archive, ".zip.001")) {
|
||||
format = "zip";
|
||||
} else {
|
||||
format = "7z";
|
||||
}
|
||||
|
||||
memset(&result, 0, sizeof(result));
|
||||
g_t0 = now_seconds();
|
||||
|
||||
if(!strcmp(format, "rar")) {
|
||||
status = rar_extract(archive, out_dir, ZIPX_CONFLICT_OVERWRITE,
|
||||
zipx_default_limits(), NULL, on_report, NULL, NULL,
|
||||
&result);
|
||||
} else if(!strcmp(format, "zip")) {
|
||||
status = zipx_extract(archive, out_dir, ZIPX_CONFLICT_OVERWRITE,
|
||||
zipx_default_limits(), NULL, on_report, NULL, NULL,
|
||||
&result);
|
||||
} else {
|
||||
status = sevenz_extract(archive, out_dir, ZIPX_CONFLICT_OVERWRITE,
|
||||
zipx_default_limits(), NULL, on_report, NULL,
|
||||
NULL, &result);
|
||||
}
|
||||
|
||||
elapsed = now_seconds() - g_t0;
|
||||
printf("---- done: format=%s mode=%s status=%s entries=%llu bytes=%llu wall=%.3fs\n",
|
||||
format, mode, zipx_status_string(status),
|
||||
(unsigned long long)result.entries_total,
|
||||
(unsigned long long)result.bytes_total, elapsed);
|
||||
return status == ZIPX_OK ? 0 : 1;
|
||||
}
|
||||
+1
-1
@@ -24,7 +24,7 @@ main(int argc, char **argv) {
|
||||
}
|
||||
st = zipx_extract(argv[1], argv[2], ZIPX_CONFLICT_FAIL,
|
||||
zipx_limits_profile(ZIPX_LIMITS_LARGE),
|
||||
NULL, NULL, NULL, &res);
|
||||
NULL, NULL, NULL, NULL, &res);
|
||||
printf("status=%d (%s)\n", (int)st, zipx_status_string(st));
|
||||
printf("sys_errno=%d entries=%llu/%llu files=%llu dirs=%llu\n",
|
||||
res.sys_errno, (unsigned long long)res.entries_done,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
@echo off
|
||||
REM Generate real encrypted ZIP fixtures for the host test suite.
|
||||
REM Requires a 7-Zip compatible command line tool: 7-Zip, or the NanaZip
|
||||
REM console alias that ships with the Store package (both accept the same
|
||||
REM switches used here).
|
||||
REM Outputs into tests\fixtures-real\ - kept OUT of tests\fixtures\ because
|
||||
REM tests\make_fixtures.py wipes that directory on every test run. Only the
|
||||
REM *.zip files are rewritten, so the RAR fixtures produced by
|
||||
REM make-rar-fixtures.bat survive.
|
||||
REM Rerun any time fixture needs change. NOTE: keep this file pure ASCII.
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
set SZEXE=
|
||||
if exist "%ProgramFiles%\7-Zip\7z.exe" set SZEXE=%ProgramFiles%\7-Zip\7z.exe
|
||||
if exist "%ProgramFiles(x86)%\7-Zip\7z.exe" set SZEXE=%ProgramFiles(x86)%\7-Zip\7z.exe
|
||||
if exist "%~dp07z.exe" set SZEXE=%~dp07z.exe
|
||||
if "%SZEXE%"=="" (
|
||||
for /f "delims=" %%I in ('where 7z 2^>nul') do if "!SZEXE!"=="" set SZEXE=%%I
|
||||
)
|
||||
if "%SZEXE%"=="" (
|
||||
echo [ERROR] No 7z.exe found. Install 7-Zip or NanaZip, or drop 7z.exe next to this script.
|
||||
exit /b 1
|
||||
)
|
||||
echo Using: %SZEXE%
|
||||
|
||||
set FIX=%~dp0fixtures-real
|
||||
if not exist "%FIX%" mkdir "%FIX%"
|
||||
del "%FIX%\enc-*.zip" 2>nul
|
||||
|
||||
set STAGE=%~dp0fixture-stage-zip
|
||||
if exist "%STAGE%" rmdir /s /q "%STAGE%"
|
||||
mkdir "%STAGE%\dir" 2>nul
|
||||
|
||||
REM Same payload shape as the RAR fixtures so both suites assert the same
|
||||
REM file list and the same bytes.
|
||||
echo ############### > "%STAGE%\root.txt"
|
||||
echo zip v1.9 fixture root content >> "%STAGE%\root.txt"
|
||||
echo nested payload line one > "%STAGE%\dir\nested.txt"
|
||||
echo nested payload line two >> "%STAGE%\dir\nested.txt"
|
||||
|
||||
REM Work from inside the stage dir with RELATIVE names so the archive keeps
|
||||
REM the dir\ structure (no -spf full paths).
|
||||
pushd "%STAGE%"
|
||||
|
||||
echo.
|
||||
echo [1/3] traditional PKWARE / ZipCrypto (password: secret123) - enc-zipcrypto.zip
|
||||
"%SZEXE%" a -tzip -psecret123 -mem=ZipCrypto -y -bso0 -bsp0 "%FIX%\enc-zipcrypto.zip" root.txt dir\nested.txt
|
||||
if errorlevel 1 echo [FAIL] & goto :badpop
|
||||
|
||||
echo [2/3] WinZip AES-256 (password: secret123) - enc-aes256.zip
|
||||
"%SZEXE%" a -tzip -psecret123 -mem=AES256 -y -bso0 -bsp0 "%FIX%\enc-aes256.zip" root.txt dir\nested.txt
|
||||
if errorlevel 1 echo [FAIL] & goto :badpop
|
||||
|
||||
echo [3/3] WinZip AES-256 with stored (uncompressed) entries - enc-aes256-store.zip
|
||||
"%SZEXE%" a -tzip -psecret123 -mem=AES256 -mx0 -y -bso0 -bsp0 "%FIX%\enc-aes256-store.zip" root.txt dir\nested.txt
|
||||
if errorlevel 1 echo [FAIL] & goto :badpop
|
||||
|
||||
popd
|
||||
rmdir /s /q "%STAGE%" 2>nul
|
||||
echo.
|
||||
echo OK. Fixtures written to %FIX%:
|
||||
dir /b "%FIX%\enc-*.zip" 2>nul
|
||||
exit /b 0
|
||||
|
||||
:badpop
|
||||
popd
|
||||
echo [ERROR] 7z command failed. Is this 7-Zip 21+ (or NanaZip) with ZIP encryption support?
|
||||
exit /b 1
|
||||
+72
-1
@@ -282,13 +282,84 @@ def rar_fixtures():
|
||||
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def bigdict():
|
||||
"""dict-8g.rar -- a RAR5 block whose header asks for an 8 GiB dictionary.
|
||||
|
||||
This one cannot be produced by any compressor, so it is synthesised here:
|
||||
|
||||
* arcread.cpp:871 reads a RAR 5.0 dictionary as
|
||||
`0x20000 << ((CompInfo>>10) & 0x0f)` -- FOUR bits, so the format's own
|
||||
ceiling is 128 KiB << 15 = exactly 4 GiB, the same as our default
|
||||
Cmd->WinSizeLimit (options.cpp:13). No `-ma5` archive can ever ask for
|
||||
more, which is why -m0 store archives never reach CheckWinLimit().
|
||||
* Only a RAR7 header (UnpVer==1, five bits, up to UNPACK_MAX_DICT = 64 GiB)
|
||||
can -- and Rar.exe 7.23 refuses to create one (`-ma4`, `-ma6`, `-ma7` all
|
||||
exit 7; only `-ma5` works).
|
||||
|
||||
So we emit a minimal, valid RAR5 archive by hand: signature, main header,
|
||||
one store-method file header (FHFL_CRC32 set), the raw payload, end block.
|
||||
CompInfo says UnpVer=1 with 16 dictionary bits (= 8 GiB) plus
|
||||
FCI_RAR5_COMPAT, and arcread.cpp:878 then forces the algorithm back to
|
||||
VER_PACK5 -- the payload really is stored, so nothing has to decode it.
|
||||
|
||||
Method 0 also means Unpack::Init() is never reached, i.e. the archive
|
||||
exercises exactly the gate under test (CheckWinLimit -> uiDictLimit ->
|
||||
UCM_LARGEDICT) and never allocates anything multi-gigabyte.
|
||||
|
||||
Sanity check with rarlab's own tools before trusting a change here:
|
||||
UnRAR.exe lt dict-8g.rar -> "-md=8g"
|
||||
UnRAR.exe t -mdx12g dict-8g.rar -> all OK
|
||||
Without -mdx UnRAR refuses it exactly as we do ("8 GB dictionary exceeds the
|
||||
4 GB limit and needs more than 8 GB of memory").
|
||||
"""
|
||||
name = b"hello.txt"
|
||||
data = b"".join(b"line %04d dictionary probe payload\n" % i for i in range(200))
|
||||
comp_info = 1 | (16 << 10) | 0x00100000 # UnpVer=1, method=0, 8 GiB, RAR5 compat
|
||||
|
||||
def vint(v):
|
||||
out = bytearray()
|
||||
while True:
|
||||
c = v & 0x7F
|
||||
v >>= 7
|
||||
out.append(c | 0x80 if v else c)
|
||||
if not v:
|
||||
return bytes(out)
|
||||
|
||||
def block(htype, flags, payload, data_size=None):
|
||||
# The HFL_DATA size lives in the block header prologue, right after the
|
||||
# flags -- it is not part of the per-type payload (arcread.cpp:710).
|
||||
hd = vint(htype) + vint(flags)
|
||||
if data_size is not None:
|
||||
hd += vint(data_size)
|
||||
hd += payload
|
||||
size = vint(len(hd))
|
||||
# rawread.cpp:185 GetCRC50() == zlib.crc32 over (size field + header data)
|
||||
crc = zlib.crc32(size + hd) & 0xFFFFFFFF
|
||||
return struct.pack("<I", crc) + size + hd
|
||||
|
||||
main_hdr = block(1, 0x04, vint(0)) # HEAD_MAIN, ArcFlags=0
|
||||
file_hdr = block(2, 0x02, # HEAD_FILE, HFL_DATA
|
||||
vint(0x0004) + # FileFlags: FHFL_CRC32
|
||||
vint(len(data)) + # UnpSize
|
||||
vint(0) + # FileAttr
|
||||
struct.pack("<I", zlib.crc32(data) & 0xFFFFFFFF) +
|
||||
vint(comp_info) +
|
||||
vint(0) + # HostOS: Windows
|
||||
vint(len(name)) + name,
|
||||
data_size=len(data))
|
||||
end_hdr = block(5, 0x00, vint(0)) # HEAD_ENDARC
|
||||
|
||||
with open(path("dict-8g.rar"), "wb") as f:
|
||||
f.write(b"Rar!\x1a\x07\x01\x00" + main_hdr + file_hdr + data + end_hdr)
|
||||
|
||||
|
||||
def main():
|
||||
fresh()
|
||||
for fn in (basic, stored, unicode_names, zip64, traversal,
|
||||
traversal_backslash, absolute, drive_letter, duplicate,
|
||||
file_dir_clash, symlink_entry, fifo_entry, encrypted, bad_crc,
|
||||
truncated, not_a_zip, bomb, medium_bomb, many_files,
|
||||
conflict_source, rar_fixtures):
|
||||
conflict_source, rar_fixtures, bigdict):
|
||||
fn()
|
||||
print("fixtures written to %s" % OUT)
|
||||
return 0
|
||||
|
||||
+13
-10
@@ -24,11 +24,11 @@ CC="${CC:-gcc}"
|
||||
|
||||
# Archives the engine cannot read yet. Each entry needs a reason; when one of
|
||||
# them starts passing the script says so, so the list cannot rot.
|
||||
KNOWN_GAPS="aeshe"
|
||||
# aeshe - the header itself is encrypted (-mhe=on). Reading it means
|
||||
# decrypting a standalone 7z stream *before* any folder is
|
||||
# known, i.e. a header parser of our own; the vendored SDK
|
||||
# refuses with SZ_ERROR_UNSUPPORTED before we are involved.
|
||||
#
|
||||
# Empty since v1.9.3M: `aeshe` (-mhe=on encrypted header) used to live here. An
|
||||
# encrypted header is now decrypted by src/sevenz_header.c before the SDK sees
|
||||
# the folder table, so the last known 7z gap is closed.
|
||||
KNOWN_GAPS=""
|
||||
|
||||
# Must match PASSWORD in tests/make_sevenz_fixtures.py.
|
||||
FIXTURE_PASSWORD="Secret123"
|
||||
@@ -56,6 +56,9 @@ done
|
||||
"$CC" -c -O2 -Wall -Wextra -Werror -D_FILE_OFFSET_BITS=64 -D_LARGEFILE_SOURCE \
|
||||
-I"$SEVENZ_DIR" -I"$ROOT/src" -o "$BUILD/sevenz_volstream.o" \
|
||||
"$ROOT/src/sevenz_volstream.c"
|
||||
"$CC" -c -O2 -Wall -Wextra -Werror -D_FILE_OFFSET_BITS=64 -D_LARGEFILE_SOURCE \
|
||||
-I"$SEVENZ_DIR" -I"$ROOT/src" -o "$BUILD/sevenz_header.o" \
|
||||
"$ROOT/src/sevenz_header.c"
|
||||
"$CC" -c -O2 -Wall -Wextra -Werror -D_FILE_OFFSET_BITS=64 -D_LARGEFILE_SOURCE \
|
||||
-I"$SEVENZ_DIR" -I"$ROOT/src" -o "$BUILD/sevenz_mt.o" "$ROOT/src/sevenz_mt.c"
|
||||
"$CC" -c -O2 -Wall -Wextra -Werror -D_FILE_OFFSET_BITS=64 -D_LARGEFILE_SOURCE \
|
||||
@@ -72,9 +75,9 @@ done
|
||||
-include "$ROOT/tests/posix_compat.h" \
|
||||
-o "$BUILD/sevenz_extract.o" "$ROOT/src/sevenz_extract.c"
|
||||
|
||||
ENGINE_OBJS=("$BUILD/sevenz_chain.o" "$BUILD/sevenz_volstream.o"
|
||||
"$BUILD/sevenz_mt.o" "$BUILD/zipx_volume.o"
|
||||
"$BUILD/zipx_common.o")
|
||||
ENGINE_OBJS=("$BUILD/sevenz_chain.o" "$BUILD/sevenz_header.o"
|
||||
"$BUILD/sevenz_volstream.o" "$BUILD/sevenz_mt.o"
|
||||
"$BUILD/zipx_volume.o" "$BUILD/zipx_common.o")
|
||||
|
||||
FACADE_OBJS=("$BUILD/sevenz_extract.o" "${ENGINE_OBJS[@]}")
|
||||
|
||||
@@ -177,14 +180,14 @@ fi
|
||||
echo
|
||||
echo "== 7z extraction facade (engine: src/sevenz_extract.c) =="
|
||||
mkdir -p "$BUILD/fx"
|
||||
for a in store lzma2 lzma ppmd bcj delta utf8 bcj2 solidoff bcj2off aes; do
|
||||
for a in store lzma2 lzma ppmd bcj delta utf8 bcj2 solidoff bcj2off aes aeshe; do
|
||||
[ -f "$FIXTURES/$a.7z" ] || continue
|
||||
out="$(mktemp -d "$BUILD/fx/XXXXXX")" || continue
|
||||
target="$out/$a"
|
||||
mkdir -p "$target"
|
||||
rc=0
|
||||
case "$a" in
|
||||
aes) "$BUILD/test_sevenz_extract" "$FIXTURES/$a.7z" "$target" \
|
||||
aes|aeshe) "$BUILD/test_sevenz_extract" "$FIXTURES/$a.7z" "$target" \
|
||||
"$FIXTURE_PASSWORD" >"$out.log" 2>&1 || rc=$? ;;
|
||||
*) "$BUILD/test_sevenz_extract" "$FIXTURES/$a.7z" "$target" \
|
||||
>"$out.log" 2>&1 || rc=$? ;;
|
||||
|
||||
+8
-5
@@ -25,7 +25,8 @@ mkdir -p "$BUILD"
|
||||
"$PYTHON" "$ROOT/tests/make_fixtures.py"
|
||||
"$PYTHON" "$ROOT/tests/make_split_fixtures.py"
|
||||
|
||||
MZ_CFLAGS=(-I"$ROOT/third_party/minizip-ng/include" -DHAVE_ZLIB -DZLIB_COMPAT -D_FILE_OFFSET_BITS=64)
|
||||
MZ_CFLAGS=(-I"$ROOT/third_party/minizip-ng/include" -DHAVE_ZLIB -DZLIB_COMPAT -D_FILE_OFFSET_BITS=64 \
|
||||
-DHAVE_WZAES -DHAVE_PKCRYPT)
|
||||
RAR_CFLAGS=(-I"$ROOT/third_party/unrar" -DDMC_UNRAR_DISABLE_BE32TOH_BE64TOH=1)
|
||||
HOST_KIND=posix
|
||||
# MinGW has no O_NOFOLLOW; the flag is only a host build workaround.
|
||||
@@ -116,9 +117,11 @@ RAR_LIBS=()
|
||||
"$BUILD/rar_extract.o" "$BUILD/test_rar_extract.o" \
|
||||
"$BUILD/zip_extract.o" "$BUILD/zipx_common.o" "${objs[@]}" "${rar_objs[@]}" "${RAR_LIBS[@]}"
|
||||
|
||||
"$BUILD/test-zip-extract" "$ROOT/tests/fixtures" "$BUILD/work-zip"
|
||||
# Real RAR fixtures (v6 / multi-volume / encrypted) live in fixtures-real/,
|
||||
# generated by tests/make-rar-fixtures.bat (WinRAR required); fixtures/
|
||||
# itself is wiped by make_fixtures.py on every run.
|
||||
# Real archives live in fixtures-real/, generated by tests/make-rar-fixtures.bat
|
||||
# (WinRAR) and tests/make-zip-enc-fixtures.bat (7-Zip); fixtures/ itself is
|
||||
# wiped by make_fixtures.py on every run. The ZIP suite needs it for the
|
||||
# encrypted samples, and skips those sections when the directory is missing.
|
||||
"$BUILD/test-zip-extract" "$ROOT/tests/fixtures" "$BUILD/work-zip" \
|
||||
"$ROOT/tests/fixtures-real"
|
||||
"$BUILD/test-rar-extract" "$ROOT/tests/fixtures" "$BUILD/work-rar" \
|
||||
"$ROOT/tests/fixtures-real"
|
||||
@@ -33,6 +33,7 @@
|
||||
#include "7zFile.h"
|
||||
|
||||
#include "sevenz_chain.h"
|
||||
#include "sevenz_header.h"
|
||||
#include "sevenz_volstream.h"
|
||||
|
||||
#define INPUT_BUF_SIZE (1u << 18)
|
||||
@@ -346,6 +347,9 @@ int main(int argc, char **argv) {
|
||||
char vol_desc[512];
|
||||
int is_set = 0;
|
||||
int rc = 0;
|
||||
szh_prep *hdr_prep = NULL;
|
||||
ISeekInStream *hdr_stream = NULL;
|
||||
char hdr_msg[256] = "";
|
||||
|
||||
if(argc < 3) {
|
||||
fprintf(stderr, "usage: %s <archive.7z> <out-dir> [password]\n", argv[0]);
|
||||
@@ -367,19 +371,50 @@ int main(int argc, char **argv) {
|
||||
return 1;
|
||||
}
|
||||
look_stream.bufSize = INPUT_BUF_SIZE;
|
||||
look_stream.realStream = sevenz_volstream_stream(vol);
|
||||
|
||||
/* Same order as src/sevenz_extract.c: an encrypted header has to come off
|
||||
before the SDK can see the folder table. */
|
||||
hdr_stream = sevenz_volstream_stream(vol);
|
||||
{
|
||||
szh_status_t hs = szh_prepare(&hdr_prep, hdr_stream,
|
||||
argc > 3 ? argv[3] : NULL, hdr_msg,
|
||||
sizeof(hdr_msg));
|
||||
|
||||
if(hs == SZH_PATCHED) {
|
||||
hdr_stream = szh_stream(hdr_prep);
|
||||
} else if(hs != SZH_PLAIN) {
|
||||
fprintf(stderr, "%s: %s\n", vol_desc,
|
||||
hdr_msg[0] ? hdr_msg : szh_status_string(hs));
|
||||
szh_prep_free(hdr_prep);
|
||||
sevenz_volstream_free(vol);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
look_stream.realStream = hdr_stream;
|
||||
LookToRead2_INIT(&look_stream)
|
||||
{
|
||||
Int64 zero = 0;
|
||||
|
||||
if(hdr_stream->Seek(hdr_stream, &zero, SZ_SEEK_SET) != SZ_OK) {
|
||||
fprintf(stderr, "%s: cannot rewind the archive\n", vol_desc);
|
||||
szh_prep_free(hdr_prep);
|
||||
sevenz_volstream_free(vol);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
CrcGenerateTable();
|
||||
SzArEx_Init(&db);
|
||||
|
||||
res = SzArEx_Open(&db, &look_stream.vt, &g_alloc, &g_temp);
|
||||
/* The header is in `db` now; nothing reads through the view again. */
|
||||
szh_prep_free(hdr_prep);
|
||||
hdr_prep = NULL;
|
||||
if(res != SZ_OK) {
|
||||
if(res == SZ_ERROR_UNSUPPORTED)
|
||||
fprintf(stderr,
|
||||
"%s: the archive header is encrypted (-mhe=on); this build reads "
|
||||
"encrypted *streams* only, so the header itself cannot be "
|
||||
"decoded\n",
|
||||
"%s: the archive header is compressed with a method this build "
|
||||
"does not have\n",
|
||||
vol_desc);
|
||||
else
|
||||
fprintf(stderr, "%s: cannot read the 7z header (res=%d)\n", vol_desc,
|
||||
|
||||
+110
-37
@@ -1,18 +1,18 @@
|
||||
/* Host test suite for the RAR extraction engine.
|
||||
Build: see run-tests.sh (uses gcc + the MinGW POSIX shim).
|
||||
|
||||
Scope (v1.8):
|
||||
* Pure error-path coverage — we do not ship a genuine RAR fixture
|
||||
because no rar/7z writer is available in the sandboxed host test
|
||||
environment. The test suite therefore focuses on the negative
|
||||
branches that the dispatcher hits when a user uploads something
|
||||
that is not a valid single-volume, unencrypted RAR.
|
||||
* Happy-path smoke coverage is provided by make_fixtures.py when a
|
||||
rar or 7z binary is available; if neither is present, the script
|
||||
writes 1-byte placeholder files so that test_rar_extract.c still
|
||||
has something to point at for the "format rejected" assertions.
|
||||
* See docs/HANDOVER.md for the manual smoke procedure and the
|
||||
fixture TODO that should be cleared when opello/unrar is vendored. */
|
||||
Scope (v1.9):
|
||||
* Negative paths: the dispatcher must classify garbage, truncated and
|
||||
non-archive input without ever returning ZIPX_OK. These fixtures are
|
||||
generated on the fly, so this part runs anywhere.
|
||||
* Real-archive coverage: RAR5 "v6", RAR4, multi-volume and encrypted
|
||||
samples live in tests/fixtures-real/ and are regenerated by
|
||||
tests/make-rar-fixtures.bat (needs WinRAR). Those sections skip
|
||||
themselves when the directory is missing.
|
||||
* Encrypted archives are exercised end to end: no password and a wrong
|
||||
password must both yield ZIPX_ERR_PASSWORD with nothing published,
|
||||
and the correct password must actually decrypt the payload. The
|
||||
password for enc-v6.rar is fixed by make-rar-fixtures.bat. */
|
||||
|
||||
#include "../src/zip_extract.h"
|
||||
#include "../src/rar_extract.h"
|
||||
@@ -162,7 +162,7 @@ run_rar(const char *fixture, const char *dst, zipx_status_t expected,
|
||||
work_path((char *)dst, 0, dst); /* dst is already absolute under work */
|
||||
memset(&res, 0, sizeof(res));
|
||||
st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, NULL, NULL, &res);
|
||||
zipx_default_limits(), NULL, NULL, NULL, NULL, &res);
|
||||
check(st == expected, label);
|
||||
if(st != ZIPX_OK) {
|
||||
check(res.message[0] != 0, " has error message");
|
||||
@@ -194,7 +194,7 @@ test_engine_dispatch(void) {
|
||||
if(exists(src)) {
|
||||
memset(&res, 0, sizeof(res));
|
||||
st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, NULL, NULL, &res);
|
||||
zipx_default_limits(), NULL, NULL, NULL, NULL, &res);
|
||||
check(st != ZIPX_OK, "notar.rar rejected");
|
||||
check(!exists(dst), " no files written for rejected archive");
|
||||
if(st == ZIPX_OK) {
|
||||
@@ -215,7 +215,7 @@ test_engine_dispatch(void) {
|
||||
zipx_result_t res;
|
||||
zipx_status_t st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, NULL, NULL,
|
||||
&res);
|
||||
NULL, &res);
|
||||
check(st != ZIPX_OK, "junk.rar rejected");
|
||||
check(!exists(dst), " no files written for junk archive");
|
||||
if(st == ZIPX_OK) {
|
||||
@@ -237,7 +237,7 @@ test_engine_dispatch(void) {
|
||||
fixture_path(src, sizeof(src), "does-not-exist.rar");
|
||||
memset(&res, 0, sizeof(res));
|
||||
st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, NULL, NULL, &res);
|
||||
zipx_default_limits(), NULL, NULL, NULL, NULL, &res);
|
||||
check(st == ZIPX_ERR_OPEN, "missing source -> ZIPX_ERR_OPEN");
|
||||
check(!exists(dst), " no files written when source is missing");
|
||||
if(st == ZIPX_OK) {
|
||||
@@ -249,13 +249,13 @@ test_engine_dispatch(void) {
|
||||
{
|
||||
zipx_result_t res;
|
||||
check(rar_extract(NULL, "/tmp", ZIPX_CONFLICT_FAIL, NULL, NULL, NULL,
|
||||
NULL, &res) == ZIPX_ERR_INTERNAL,
|
||||
NULL, NULL, &res) == ZIPX_ERR_INTERNAL,
|
||||
"null rar_path -> ZIPX_ERR_INTERNAL");
|
||||
check(rar_extract("/tmp", NULL, ZIPX_CONFLICT_FAIL, NULL, NULL, NULL,
|
||||
NULL, &res) == ZIPX_ERR_INTERNAL,
|
||||
NULL, NULL, &res) == ZIPX_ERR_INTERNAL,
|
||||
"null dst_dir -> ZIPX_ERR_INTERNAL");
|
||||
check(rar_extract("/tmp", "", ZIPX_CONFLICT_FAIL, NULL, NULL, NULL,
|
||||
NULL, &res) == ZIPX_ERR_INTERNAL,
|
||||
NULL, NULL, &res) == ZIPX_ERR_INTERNAL,
|
||||
"empty dst_dir -> ZIPX_ERR_INTERNAL");
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ test_engine_dispatch(void) {
|
||||
if(exists(src)) {
|
||||
zipx_status_t st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, NULL, NULL,
|
||||
&res);
|
||||
NULL, &res);
|
||||
check(st == ZIPX_ERR_CONFLICT, "dst is a regular file -> ZIPX_ERR_CONFLICT");
|
||||
} else {
|
||||
printf(" SKIP dst-is-file (junk.rar missing)\n");
|
||||
@@ -291,16 +291,15 @@ test_engine_dispatch(void) {
|
||||
|
||||
static void
|
||||
test_format_translation(void) {
|
||||
/* Direct exercise of the error-code mapping: this only requires that the
|
||||
dispatcher classifies "encrypted" and "multi-volume" correctly. Since
|
||||
those states are reached only with a real RAR that has the right flags
|
||||
set, we cannot generate that fixture on the fly — but we can confirm
|
||||
that the rejection paths we *do* hit (FORMAT / OPEN / IO) never
|
||||
/* Direct exercise of the error-code mapping. The encrypted and
|
||||
multi-volume states now have real fixtures and are asserted in the
|
||||
real-archive section above; this function only guarantees that the
|
||||
rejection paths reachable without a fixture (FORMAT / OPEN / IO) never
|
||||
accidentally return ZIPX_OK. */
|
||||
printf("test_format_translation\n");
|
||||
/* The dispatch test already covered the negative paths; nothing more to do
|
||||
here for v1.8. Future fixtures can directly assert ZIPX_ERR_UNSUPPORTED
|
||||
once we have encrypted/multi-volume samples (see docs/HANDOVER.md). */
|
||||
/* Nothing further to check here: encrypted (-p) coverage lives in the
|
||||
real-archive section, which asserts ZIPX_ERR_PASSWORD for a missing or
|
||||
wrong password and a successful decrypt for the right one. */
|
||||
}
|
||||
|
||||
static void
|
||||
@@ -343,7 +342,7 @@ test_real_archives(void) {
|
||||
if(exists(src)) {
|
||||
memset(&res, 0, sizeof(res));
|
||||
st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, NULL, NULL, &res);
|
||||
zipx_default_limits(), NULL, NULL, NULL, NULL, &res);
|
||||
check(st == ZIPX_OK, "basic-v6.rar extracts (v6 RAR5)");
|
||||
if(st == ZIPX_OK) {
|
||||
snprintf(checkp, sizeof(checkp), "%s/root.txt", dst);
|
||||
@@ -371,7 +370,7 @@ test_real_archives(void) {
|
||||
if(exists(src)) {
|
||||
memset(&res, 0, sizeof(res));
|
||||
st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, NULL, NULL, &res);
|
||||
zipx_default_limits(), NULL, NULL, NULL, NULL, &res);
|
||||
check(st == ZIPX_OK, "basic-rar4.rar extracts (RAR4)");
|
||||
if(st == ZIPX_OK) {
|
||||
snprintf(checkp, sizeof(checkp), "%s/root.txt", dst);
|
||||
@@ -400,7 +399,7 @@ test_real_archives(void) {
|
||||
g_pmid_extract = 0;
|
||||
memset(&g_last_progress, 0, sizeof(g_last_progress));
|
||||
st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, progress_cb, NULL, &res);
|
||||
zipx_default_limits(), NULL, progress_cb, NULL, NULL, &res);
|
||||
check(st == ZIPX_OK, "vol.part1.rar auto-merges volumes");
|
||||
if(st == ZIPX_OK) {
|
||||
snprintf(checkp, sizeof(checkp), "%s/root.txt", dst);
|
||||
@@ -423,23 +422,50 @@ test_real_archives(void) {
|
||||
}
|
||||
remove_dir(dst);
|
||||
|
||||
/* Encrypted: engine can decrypt but the password channel is not wired yet,
|
||||
so encrypted entries must be rejected up front with UNSUPPORTED. */
|
||||
/* Encrypted RAR (-p: entry data encrypted, names stored in the clear).
|
||||
The engine decrypts through RARSetPassword, which the wrapper must call
|
||||
after RAROpenArchiveEx and before the first RARReadHeaderEx. */
|
||||
work_path(dst, sizeof(dst), "rdst_enc");
|
||||
remove_dir(dst);
|
||||
{
|
||||
zipx_result_t res;
|
||||
zipx_status_t st;
|
||||
char src[4096];
|
||||
char checkp[4096];
|
||||
fixture_real_path(src, sizeof(src), "enc-v6.rar");
|
||||
if(exists(src)) {
|
||||
/* (a) no password: rejected before anything is written, and with the
|
||||
retryable code rather than a blanket UNSUPPORTED. */
|
||||
memset(&res, 0, sizeof(res));
|
||||
st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, NULL, NULL, &res);
|
||||
check(st == ZIPX_ERR_UNSUPPORTED, "enc-v6.rar rejected (no password channel)");
|
||||
check(!exists(dst), " no files written for encrypted archive");
|
||||
zipx_default_limits(), NULL, NULL, NULL, NULL, &res);
|
||||
check(st == ZIPX_ERR_PASSWORD,
|
||||
"enc-v6.rar with no password -> ZIPX_ERR_PASSWORD");
|
||||
check(!exists(dst), " nothing written (no password)");
|
||||
|
||||
/* (b) wrong password: same code, still nothing published. */
|
||||
memset(&res, 0, sizeof(res));
|
||||
st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, NULL, NULL, "wrong-pw",
|
||||
&res);
|
||||
check(st == ZIPX_ERR_PASSWORD,
|
||||
"enc-v6.rar with wrong password -> ZIPX_ERR_PASSWORD");
|
||||
check(!exists(dst), " nothing written (wrong password)");
|
||||
|
||||
/* (c) correct password: really decrypts. */
|
||||
memset(&res, 0, sizeof(res));
|
||||
st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, NULL, NULL, "secret123",
|
||||
&res);
|
||||
check(st == ZIPX_OK, "enc-v6.rar with correct password -> ZIPX_OK");
|
||||
if(st == ZIPX_OK) {
|
||||
remove_dir(dst);
|
||||
snprintf(checkp, sizeof(checkp), "%s/root.txt", dst);
|
||||
check(exists(checkp), " root.txt decrypted");
|
||||
snprintf(checkp, sizeof(checkp), "%s/dir/nested.txt", dst);
|
||||
check(exists(checkp), " dir/nested.txt decrypted");
|
||||
check(res.entries_done >= 2, " entries_done >= 2");
|
||||
} else {
|
||||
printf(" message=%s\n", res.message);
|
||||
}
|
||||
} else {
|
||||
printf(" SKIP enc-v6.rar (missing)\n");
|
||||
@@ -448,6 +474,52 @@ test_real_archives(void) {
|
||||
remove_dir(dst);
|
||||
}
|
||||
|
||||
/* A dictionary above Cmd->WinSizeLimit (4 GiB, options.cpp:13) makes unrar call
|
||||
back with UCM_LARGEDICT and abort unless the callback answers 1. We answer 0
|
||||
on purpose (see rar_data_cb), so this must surface as ZIPX_ERR_LIMIT_DICT and
|
||||
say *what* is oversized -- it is the dictionary, not the 7 KB entry that
|
||||
happened to be in flight.
|
||||
|
||||
dict-8g.rar is synthetic: RAR 5.0 headers carry the dictionary in four bits
|
||||
(arcread.cpp:871), i.e. the format's own ceiling is exactly 4 GiB, so no
|
||||
`-ma5` archive can reach this path at all. The fixture rewrites CompInfo to
|
||||
the RAR7 field width plus FCI_RAR5_COMPAT; tests/make_fixtures.py:bigdict()
|
||||
builds it and UnRAR 7.23 confirms it as a valid 8 GiB-dictionary archive. */
|
||||
static void
|
||||
test_dict_limit(void) {
|
||||
char src[4096];
|
||||
char dst[4096];
|
||||
zipx_result_t res;
|
||||
|
||||
printf("test_dict_limit\n");
|
||||
|
||||
fixture_path(src, sizeof(src), "dict-8g.rar");
|
||||
if(!exists(src)) {
|
||||
printf(" SKIP dict-8g.rar (missing)\n");
|
||||
return;
|
||||
}
|
||||
|
||||
work_path(dst, sizeof(dst), "dict-limit");
|
||||
remove_dir(dst);
|
||||
memset(&res, 0, sizeof(res));
|
||||
|
||||
check(rar_extract(src, dst, ZIPX_CONFLICT_FAIL, zipx_default_limits(),
|
||||
NULL, NULL, NULL, NULL, &res) == ZIPX_ERR_LIMIT_DICT,
|
||||
"8 GiB dictionary is refused as ZIPX_ERR_LIMIT_DICT");
|
||||
check(res.detail[0] != 0 && strstr(res.detail, "8192 MiB") != NULL,
|
||||
" refusal names the dictionary size");
|
||||
check(strstr(res.detail, "4096 MiB") != NULL,
|
||||
" refusal names the limit it exceeded");
|
||||
{
|
||||
char landed[4096];
|
||||
snprintf(landed, sizeof(landed), "%s/hello.txt", dst);
|
||||
check(!exists(landed), " the entry was not extracted");
|
||||
}
|
||||
printf(" detail=%s\n", res.detail);
|
||||
printf(" message=%s\n", res.message);
|
||||
remove_dir(dst);
|
||||
}
|
||||
|
||||
int
|
||||
main(int argc, char **argv) {
|
||||
if(argc < 3) {
|
||||
@@ -494,6 +566,7 @@ main(int argc, char **argv) {
|
||||
test_format_translation();
|
||||
test_limits_handoff();
|
||||
test_real_archives();
|
||||
test_dict_limit();
|
||||
|
||||
printf("\nrar_extract: %d checks, %d failures\n", g_checks, g_failures);
|
||||
return g_failures == 0 ? 0 : 1;
|
||||
|
||||
@@ -172,7 +172,7 @@ run_cases(const char *fx, const char *work) {
|
||||
ZIPX_CONFLICT_FAIL, zipx_default_limits(), NULL,
|
||||
ZIPX_ERR_PASSWORD, "7zAES");
|
||||
{
|
||||
char b2[256];
|
||||
char b2[ZIPX_PATH_MAX + 96];
|
||||
zipx_result_t r;
|
||||
(void)sevenz_extract(arc, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, NULL, NULL,
|
||||
@@ -188,12 +188,29 @@ run_cases(const char *fx, const char *work) {
|
||||
check(extract_one(arc, dst, PASSWORD) == 0,
|
||||
"aes extracts with the right password");
|
||||
|
||||
/* --- encrypted header ---------------------------------------------- */
|
||||
/* --- encrypted header (-mhe=on) ------------------------------------- */
|
||||
/* The file names, the folder table and every entry size live inside the
|
||||
encrypted header, so this is the case where nothing at all is readable
|
||||
without the password -- not even the entry list. */
|
||||
snprintf(arc, sizeof(arc), "%s/aeshe.7z", fx);
|
||||
snprintf(dst, sizeof(dst), "%s/he", work);
|
||||
|
||||
snprintf(dst, sizeof(dst), "%s/he-missing", work);
|
||||
mkdir(dst, 0777);
|
||||
expect_fail("aeshe (encrypted header)", arc, dst, PASSWORD, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, ZIPX_ERR_UNSUPPORTED, "-mhe=on");
|
||||
expect_fail("aeshe with no password", arc, dst, NULL, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, ZIPX_ERR_PASSWORD, "mhe=on");
|
||||
check(!has_staging_leftover(work), "no staging tree survives a locked header");
|
||||
|
||||
snprintf(dst, sizeof(dst), "%s/he-wrong", work);
|
||||
mkdir(dst, 0777);
|
||||
expect_fail("aeshe with the wrong password", arc, dst, "NotThePassword",
|
||||
ZIPX_CONFLICT_FAIL, zipx_default_limits(), NULL,
|
||||
ZIPX_ERR_PASSWORD, "password");
|
||||
|
||||
snprintf(dst, sizeof(dst), "%s/he-ok", work);
|
||||
mkdir(dst, 0777);
|
||||
check(extract_one(arc, dst, PASSWORD) == 0,
|
||||
"aeshe extracts and verifies with the right password");
|
||||
check(!has_staging_leftover(work), "no staging tree survives an aeshe run");
|
||||
|
||||
/* --- open failures -------------------------------------------------- */
|
||||
snprintf(dst, sizeof(dst), "%s/missing-file", work);
|
||||
|
||||
+129
-5
@@ -1,5 +1,12 @@
|
||||
/* Host test suite for the ZIP extraction engine.
|
||||
Build: see run-tests.sh (uses gcc + the MinGW POSIX shim). */
|
||||
Build: see run-tests.sh (uses gcc + the MinGW POSIX shim).
|
||||
|
||||
Scope: the generated fixtures under tests/fixtures cover the negative and
|
||||
structural paths. Encrypted archives cannot be produced with the Python
|
||||
standard library, so enc-zipcrypto.zip / enc-aes256.zip / enc-aes256-store.zip
|
||||
live in tests/fixtures-real/ and are regenerated by
|
||||
tests/make-zip-enc-fixtures.bat (needs 7-Zip or NanaZip). The password is
|
||||
fixed at "secret123" by that script. */
|
||||
|
||||
#include "../src/zip_extract.h"
|
||||
|
||||
@@ -15,6 +22,7 @@
|
||||
#include "posix_compat.h"
|
||||
|
||||
static const char *g_fixtures;
|
||||
static const char *g_fixtures_real;
|
||||
static char g_work[4096];
|
||||
static int g_failures;
|
||||
static int g_checks;
|
||||
@@ -24,6 +32,11 @@ fixture_path(char *out, size_t size, const char *name) {
|
||||
snprintf(out, size, "%s/%s", g_fixtures, name);
|
||||
}
|
||||
|
||||
static void
|
||||
fixture_real_path(char *out, size_t size, const char *name) {
|
||||
snprintf(out, size, "%s/%s", g_fixtures_real, name);
|
||||
}
|
||||
|
||||
static void
|
||||
work_path(char *out, size_t size, const char *name) {
|
||||
snprintf(out, size, "%s/%s", g_work, name);
|
||||
@@ -179,6 +192,14 @@ cb_progress(void *userdata, const zipx_progress_t *p) {
|
||||
}
|
||||
}
|
||||
|
||||
static zipx_status_t
|
||||
run_ex(const char *zip, const char *dst, zipx_conflict_t conflict,
|
||||
const zipx_limits_t *limits, test_ctx_t *t, const char *password,
|
||||
zipx_result_t *res) {
|
||||
return zipx_extract(zip, dst, conflict, limits, cb_cancel, cb_progress,
|
||||
t, password, res);
|
||||
}
|
||||
|
||||
static zipx_status_t
|
||||
run(const char *fixture, const char *dst_name, zipx_conflict_t conflict,
|
||||
const zipx_limits_t *limits, test_ctx_t *t, zipx_result_t *res) {
|
||||
@@ -187,8 +208,20 @@ run(const char *fixture, const char *dst_name, zipx_conflict_t conflict,
|
||||
|
||||
fixture_path(zip, sizeof(zip), fixture);
|
||||
work_path(dst, sizeof(dst), dst_name);
|
||||
return zipx_extract(zip, dst, conflict, limits, cb_cancel, cb_progress,
|
||||
t, res);
|
||||
return run_ex(zip, dst, conflict, limits, t, NULL, res);
|
||||
}
|
||||
|
||||
/* Same, but against tests/fixtures-real/ and with a password. */
|
||||
static zipx_status_t
|
||||
run_real(const char *fixture, const char *dst_name, zipx_conflict_t conflict,
|
||||
const zipx_limits_t *limits, test_ctx_t *t, const char *password,
|
||||
zipx_result_t *res) {
|
||||
char zip[4096];
|
||||
char dst[4096];
|
||||
|
||||
fixture_real_path(zip, sizeof(zip), fixture);
|
||||
work_path(dst, sizeof(dst), dst_name);
|
||||
return run_ex(zip, dst, conflict, limits, t, password, res);
|
||||
}
|
||||
|
||||
static void
|
||||
@@ -561,9 +594,18 @@ test_unsupported(void) {
|
||||
zipx_limits_t limits;
|
||||
|
||||
printf("encrypted, corrupt and truncated archives\n");
|
||||
/* encrypted.zip only has the general purpose encryption bit flipped on a
|
||||
plain archive, so there is no password that could open it: the scan must
|
||||
stop at the missing password before touching any entry data. */
|
||||
expect_status(&res, run("encrypted.zip", "out_encrypted",
|
||||
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
|
||||
ZIPX_ERR_UNSUPPORTED, "encrypted entry rejected");
|
||||
ZIPX_ERR_PASSWORD, "encrypted entry without password rejected");
|
||||
{
|
||||
char dst[4096];
|
||||
|
||||
work_path(dst, sizeof(dst), "out_encrypted");
|
||||
check(!exists(dst), "nothing published for encrypted zip");
|
||||
}
|
||||
expect_status(&res, run("bad_crc.zip", "out_bad_crc", ZIPX_CONFLICT_FAIL,
|
||||
NULL, &t, &res),
|
||||
ZIPX_ERR_CRC, "crc mismatch detected");
|
||||
@@ -605,6 +647,85 @@ test_unsupported(void) {
|
||||
ZIPX_ERR_LIMIT_DEPTH, "depth limit enforced");
|
||||
}
|
||||
|
||||
/* One encrypted archive, exercised four ways. The password is fixed by
|
||||
tests/make-zip-enc-fixtures.bat. */
|
||||
static void
|
||||
encrypted_archive(const char *fixture, const char *dst_name) {
|
||||
static const char *password = "secret123";
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
char zip[4096];
|
||||
char dst[4096];
|
||||
char file[4224];
|
||||
char buf[2048];
|
||||
|
||||
fixture_real_path(zip, sizeof(zip), fixture);
|
||||
if(!exists(zip)) {
|
||||
printf(" skip %s (run tests/make-zip-enc-fixtures.bat)\n", fixture);
|
||||
return;
|
||||
}
|
||||
work_path(dst, sizeof(dst), dst_name);
|
||||
|
||||
/* No password: the scan phase refuses before any entry data is touched. */
|
||||
expect_status(&res, run_real(fixture, dst_name, ZIPX_CONFLICT_FAIL, NULL,
|
||||
&t, NULL, &res),
|
||||
ZIPX_ERR_PASSWORD, "no password rejected");
|
||||
check(!exists(dst), "nothing published without a password");
|
||||
|
||||
/* An empty string means the same as no password at all. */
|
||||
expect_status(&res, run_real(fixture, dst_name, ZIPX_CONFLICT_FAIL, NULL,
|
||||
&t, "", &res),
|
||||
ZIPX_ERR_PASSWORD, "empty password rejected");
|
||||
|
||||
/* Wrong password: caught by the ZipCrypto header check / the AES verifier. */
|
||||
expect_status(&res, run_real(fixture, dst_name, ZIPX_CONFLICT_FAIL, NULL,
|
||||
&t, "not-the-password", &res),
|
||||
ZIPX_ERR_PASSWORD, "wrong password rejected");
|
||||
check(!exists(dst), "nothing published with a wrong password");
|
||||
|
||||
/* Correct password: the payload has to come out byte for byte. */
|
||||
expect_ok(&res, run_real(fixture, dst_name, ZIPX_CONFLICT_FAIL, NULL, &t,
|
||||
password, &res),
|
||||
"correct password decrypts");
|
||||
check(res.entries_total == 2, "entry count reported");
|
||||
snprintf(file, sizeof(file), "%s/root.txt", dst);
|
||||
check(!read_text(file, buf, sizeof(buf)) &&
|
||||
!strcmp(buf, "###############\nzip v1.9 fixture root content\n"),
|
||||
"decrypted root.txt content");
|
||||
snprintf(file, sizeof(file), "%s/dir/nested.txt", dst);
|
||||
check(!read_text(file, buf, sizeof(buf)) &&
|
||||
!strcmp(buf, "nested payload line one\nnested payload line two\n"),
|
||||
"decrypted dir/nested.txt content");
|
||||
check(count_staging_leftovers(dst) == 0, "no staging leftovers inside dst");
|
||||
}
|
||||
|
||||
static void
|
||||
test_encrypted(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
const zipx_limits_t *base = zipx_default_limits();
|
||||
zipx_limits_t limits;
|
||||
char zip[4096];
|
||||
|
||||
printf("encrypted archives (traditional PKWARE + WinZip AES)\n");
|
||||
encrypted_archive("enc-zipcrypto.zip", "out_enc_zipcrypto");
|
||||
encrypted_archive("enc-aes256.zip", "out_enc_aes256");
|
||||
encrypted_archive("enc-aes256-store.zip", "out_enc_aes256_store");
|
||||
|
||||
/* Handing over a password must not skip the scan phase: the limits still
|
||||
apply to encrypted archives. */
|
||||
fixture_real_path(zip, sizeof(zip), "enc-aes256.zip");
|
||||
if(exists(zip)) {
|
||||
limits = *base;
|
||||
limits.max_entries = 1;
|
||||
expect_status(&res, run_real("enc-aes256.zip", "out_enc_limit",
|
||||
ZIPX_CONFLICT_FAIL, &limits, &t, "secret123",
|
||||
&res),
|
||||
ZIPX_ERR_LIMIT_ENTRIES,
|
||||
"entry limit still applies to encrypted archives");
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
test_cancel(void) {
|
||||
zipx_result_t res;
|
||||
@@ -749,11 +870,13 @@ test_large_profile(void) {
|
||||
int
|
||||
main(int argc, char **argv) {
|
||||
if(argc < 3) {
|
||||
fprintf(stderr, "usage: %s <fixtures-dir> <work-dir>\n", argv[0]);
|
||||
fprintf(stderr, "usage: %s <fixtures-dir> <work-dir> [fixtures-real-dir]\n",
|
||||
argv[0]);
|
||||
return 2;
|
||||
}
|
||||
g_fixtures = argv[1];
|
||||
snprintf(g_work, sizeof(g_work), "%s", argv[2]);
|
||||
g_fixtures_real = (argc >= 4) ? argv[3] : "";
|
||||
remove_dir(g_work);
|
||||
if(make_dirs(g_work)) {
|
||||
fprintf(stderr, "cannot create work dir\n");
|
||||
@@ -771,6 +894,7 @@ main(int argc, char **argv) {
|
||||
test_duplicates();
|
||||
test_special_entries();
|
||||
test_unsupported();
|
||||
test_encrypted();
|
||||
test_cancel();
|
||||
test_many_files();
|
||||
test_large_profile();
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/* mz_strm_pkcrypt.h -- Code for traditional PKWARE encryption
|
||||
part of the minizip-ng project
|
||||
|
||||
Copyright (C) Nathan Moinvaziri
|
||||
https://github.com/zlib-ng/minizip-ng
|
||||
|
||||
This program is distributed under the terms of the same license as zlib.
|
||||
See the accompanying LICENSE file for the full text of the license.
|
||||
*/
|
||||
|
||||
#ifndef MZ_STREAM_PKCRYPT_H
|
||||
#define MZ_STREAM_PKCRYPT_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/***************************************************************************/
|
||||
|
||||
int32_t mz_stream_pkcrypt_open(void *stream, const char *filename, int32_t mode);
|
||||
int32_t mz_stream_pkcrypt_is_open(void *stream);
|
||||
int32_t mz_stream_pkcrypt_read(void *stream, void *buf, int32_t size);
|
||||
int32_t mz_stream_pkcrypt_write(void *stream, const void *buf, int32_t size);
|
||||
int64_t mz_stream_pkcrypt_tell(void *stream);
|
||||
int32_t mz_stream_pkcrypt_seek(void *stream, int64_t offset, int32_t origin);
|
||||
int32_t mz_stream_pkcrypt_close(void *stream);
|
||||
int32_t mz_stream_pkcrypt_error(void *stream);
|
||||
|
||||
void mz_stream_pkcrypt_set_password(void *stream, const char *password);
|
||||
void mz_stream_pkcrypt_set_verify(void *stream, uint8_t verify1, uint8_t verify2, uint16_t version);
|
||||
void mz_stream_pkcrypt_get_verify(void *stream, uint8_t *verify1, uint8_t *verify2, uint16_t *version);
|
||||
int32_t mz_stream_pkcrypt_get_prop_int64(void *stream, int32_t prop, int64_t *value);
|
||||
int32_t mz_stream_pkcrypt_set_prop_int64(void *stream, int32_t prop, int64_t value);
|
||||
|
||||
void *mz_stream_pkcrypt_create(void);
|
||||
void mz_stream_pkcrypt_delete(void **stream);
|
||||
|
||||
void *mz_stream_pkcrypt_get_interface(void);
|
||||
|
||||
/***************************************************************************/
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/* mz_strm_wzaes.h -- Stream for WinZIP AES encryption
|
||||
part of the minizip-ng project
|
||||
|
||||
Copyright (C) Nathan Moinvaziri
|
||||
https://github.com/zlib-ng/minizip-ng
|
||||
|
||||
This program is distributed under the terms of the same license as zlib.
|
||||
See the accompanying LICENSE file for the full text of the license.
|
||||
*/
|
||||
|
||||
#ifndef MZ_STREAM_WZAES_SHA1_H
|
||||
#define MZ_STREAM_WZAES_SHA1_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/***************************************************************************/
|
||||
|
||||
int32_t mz_stream_wzaes_open(void *stream, const char *filename, int32_t mode);
|
||||
int32_t mz_stream_wzaes_is_open(void *stream);
|
||||
int32_t mz_stream_wzaes_read(void *stream, void *buf, int32_t size);
|
||||
int32_t mz_stream_wzaes_write(void *stream, const void *buf, int32_t size);
|
||||
int64_t mz_stream_wzaes_tell(void *stream);
|
||||
int32_t mz_stream_wzaes_seek(void *stream, int64_t offset, int32_t origin);
|
||||
int32_t mz_stream_wzaes_close(void *stream);
|
||||
int32_t mz_stream_wzaes_error(void *stream);
|
||||
|
||||
void mz_stream_wzaes_set_password(void *stream, const char *password);
|
||||
void mz_stream_wzaes_set_strength(void *stream, uint8_t strength);
|
||||
|
||||
int32_t mz_stream_wzaes_get_prop_int64(void *stream, int32_t prop, int64_t *value);
|
||||
int32_t mz_stream_wzaes_set_prop_int64(void *stream, int32_t prop, int64_t value);
|
||||
|
||||
void *mz_stream_wzaes_create(void);
|
||||
void mz_stream_wzaes_delete(void **stream);
|
||||
|
||||
void *mz_stream_wzaes_get_interface(void);
|
||||
|
||||
/***************************************************************************/
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
+825
@@ -0,0 +1,825 @@
|
||||
/* mz_crypt_wfm.c -- crypto/hash backend for the vendored minizip-ng
|
||||
part of the PS5 web file manager
|
||||
|
||||
minizip-ng keeps its hash and AES primitives behind a small pluggable
|
||||
provider (mz_crypt_openssl.c, mz_crypt_brg.c, mz_crypt_apple.c, ...).
|
||||
This file is the provider used here. It implements exactly the primitives
|
||||
the ZIP decompression path needs and nothing else.
|
||||
|
||||
What the two ZIP encryption schemes require:
|
||||
|
||||
* Traditional PKWARE ("ZipCrypto", i.e. `zip -e`) needs neither AES nor a
|
||||
hash: only crc32, which mz_crypt.c already provides, plus
|
||||
mz_crypt_rand() on the write path. mz_strm_pkcrypt.c carries the
|
||||
algorithm itself.
|
||||
|
||||
* WinZip AES (the 0x9901 extra field, `-Z aes`) needs
|
||||
- PBKDF2-HMAC-SHA1 to derive the keys -> mz_crypt.c (HAVE_WZAES)
|
||||
- HMAC-SHA1 over the ciphertext -> here
|
||||
- AES-128/192/256 in ECB on the counter -> here
|
||||
mz_strm_wzaes.c turns the ECB primitive into the CTR-like keystream
|
||||
WinZip specifies, so only single-block ECB is on the hot path.
|
||||
|
||||
The primitives are implemented locally instead of delegating to another
|
||||
vendored library so that this subtree stays self contained: it builds and
|
||||
is testable with nothing but zlib and libc, on both prospero-clang and the
|
||||
MinGW host test harness. It also deliberately avoids the 16-byte alignment
|
||||
contract that third_party/7z/Aes.h imposes, because mz_strm_wzaes.c
|
||||
encrypts a counter block that lives inside a heap struct.
|
||||
|
||||
Only MZ_HASH_SHA1 is implemented. That is the only algorithm minizip-ng
|
||||
asks for on these code paths (mz_strm_wzaes.c and mz_crypt_pbkdf2()); every
|
||||
other MZ_HASH_* identifier is rejected with MZ_SUPPORT_ERROR rather than
|
||||
silently producing a wrong digest.
|
||||
|
||||
The AES tables are derived at first use and live in .bss, so the .rodata
|
||||
cost of this file is zero.
|
||||
*/
|
||||
|
||||
#include "mz.h"
|
||||
#include "mz_crypt.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/**************************************************************************
|
||||
* entropy
|
||||
**************************************************************************/
|
||||
|
||||
int32_t mz_crypt_rand(uint8_t *buf, int32_t size) {
|
||||
/* Only the archive *writing* paths reach this (mz_strm_wzaes.c generates
|
||||
the salt and mz_strm_pkcrypt.c the encryption header); this payload only
|
||||
ever reads archives. It is served from /dev/urandom rather than
|
||||
minizip's mz_os_rand() ladder on purpose:
|
||||
* mz_os_rand() falls back to libc rand()/srand() here, which would add
|
||||
a load-time dependency on two symbols for a path we never take --
|
||||
and rand() is not a sound source for an encryption key anyway;
|
||||
* open/read/close are already imported by the rest of the payload, so
|
||||
this adds no new symbol.
|
||||
If /dev/urandom cannot be opened the caller gets MZ_INTERNAL_ERROR
|
||||
instead of silently weak randomness. */
|
||||
int fd;
|
||||
int32_t done = 0;
|
||||
|
||||
if (!buf || size < 0)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (size == 0)
|
||||
return 0;
|
||||
|
||||
fd = open("/dev/urandom", O_RDONLY);
|
||||
if (fd < 0)
|
||||
return MZ_INTERNAL_ERROR;
|
||||
|
||||
while (done < size) {
|
||||
ssize_t n = read(fd, buf + done, (size_t)(size - done));
|
||||
|
||||
if (n < 0) {
|
||||
if (errno == EINTR)
|
||||
continue;
|
||||
close(fd);
|
||||
return MZ_INTERNAL_ERROR;
|
||||
}
|
||||
if (n == 0)
|
||||
break;
|
||||
done += (int32_t)n;
|
||||
}
|
||||
|
||||
close(fd);
|
||||
return done == size ? size : MZ_INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
/**************************************************************************
|
||||
* SHA-1 (FIPS 180-4)
|
||||
**************************************************************************/
|
||||
|
||||
#define WFM_SHA1_SIZE 20
|
||||
#define WFM_SHA1_BLOCK 64
|
||||
|
||||
typedef struct wfm_sha1_s {
|
||||
uint32_t h[5];
|
||||
uint64_t total; /* message bytes consumed so far */
|
||||
uint8_t block[WFM_SHA1_BLOCK];
|
||||
uint32_t fill; /* bytes currently buffered in block */
|
||||
} wfm_sha1_t;
|
||||
|
||||
static uint32_t wfm_rol32(uint32_t value, unsigned bits) {
|
||||
return (value << bits) | (value >> (32 - bits));
|
||||
}
|
||||
|
||||
static void wfm_sha1_init(wfm_sha1_t *s) {
|
||||
s->h[0] = 0x67452301u;
|
||||
s->h[1] = 0xefcdab89u;
|
||||
s->h[2] = 0x98badcfeu;
|
||||
s->h[3] = 0x10325476u;
|
||||
s->h[4] = 0xc3d2e1f0u;
|
||||
s->total = 0;
|
||||
s->fill = 0;
|
||||
}
|
||||
|
||||
static void wfm_sha1_compress(wfm_sha1_t *s, const uint8_t *p) {
|
||||
uint32_t w[80];
|
||||
uint32_t a, b, c, d, e, f, k, t;
|
||||
int i;
|
||||
|
||||
for (i = 0; i < 16; i++) {
|
||||
w[i] = ((uint32_t)p[i * 4] << 24) | ((uint32_t)p[i * 4 + 1] << 16) | ((uint32_t)p[i * 4 + 2] << 8) |
|
||||
(uint32_t)p[i * 4 + 3];
|
||||
}
|
||||
for (i = 16; i < 80; i++)
|
||||
w[i] = wfm_rol32(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
|
||||
|
||||
a = s->h[0];
|
||||
b = s->h[1];
|
||||
c = s->h[2];
|
||||
d = s->h[3];
|
||||
e = s->h[4];
|
||||
|
||||
for (i = 0; i < 80; i++) {
|
||||
if (i < 20) {
|
||||
f = (b & c) | (~b & d);
|
||||
k = 0x5a827999u;
|
||||
} else if (i < 40) {
|
||||
f = b ^ c ^ d;
|
||||
k = 0x6ed9eba1u;
|
||||
} else if (i < 60) {
|
||||
f = (b & c) | (b & d) | (c & d);
|
||||
k = 0x8f1bbcdcu;
|
||||
} else {
|
||||
f = b ^ c ^ d;
|
||||
k = 0xca62c1d6u;
|
||||
}
|
||||
t = wfm_rol32(a, 5) + f + e + k + w[i];
|
||||
e = d;
|
||||
d = c;
|
||||
c = wfm_rol32(b, 30);
|
||||
b = a;
|
||||
a = t;
|
||||
}
|
||||
|
||||
s->h[0] += a;
|
||||
s->h[1] += b;
|
||||
s->h[2] += c;
|
||||
s->h[3] += d;
|
||||
s->h[4] += e;
|
||||
}
|
||||
|
||||
static void wfm_sha1_update(wfm_sha1_t *s, const void *buf, size_t size) {
|
||||
const uint8_t *p = (const uint8_t *)buf;
|
||||
|
||||
s->total += size;
|
||||
|
||||
if (s->fill) {
|
||||
uint32_t need = WFM_SHA1_BLOCK - s->fill;
|
||||
if (size < need) {
|
||||
memcpy(s->block + s->fill, p, size);
|
||||
s->fill += (uint32_t)size;
|
||||
return;
|
||||
}
|
||||
memcpy(s->block + s->fill, p, need);
|
||||
wfm_sha1_compress(s, s->block);
|
||||
p += need;
|
||||
size -= need;
|
||||
s->fill = 0;
|
||||
}
|
||||
|
||||
while (size >= WFM_SHA1_BLOCK) {
|
||||
wfm_sha1_compress(s, p);
|
||||
p += WFM_SHA1_BLOCK;
|
||||
size -= WFM_SHA1_BLOCK;
|
||||
}
|
||||
|
||||
if (size) {
|
||||
memcpy(s->block, p, size);
|
||||
s->fill = (uint32_t)size;
|
||||
}
|
||||
}
|
||||
|
||||
/* Writes the full 20-byte digest. The context is left in a finalized state. */
|
||||
static void wfm_sha1_final(wfm_sha1_t *s, uint8_t out[WFM_SHA1_SIZE]) {
|
||||
uint64_t bits = s->total * 8;
|
||||
uint8_t pad = 0x80;
|
||||
uint8_t len[8];
|
||||
uint32_t i;
|
||||
|
||||
wfm_sha1_update(s, &pad, 1);
|
||||
pad = 0x00;
|
||||
while (s->fill != WFM_SHA1_BLOCK - 8)
|
||||
wfm_sha1_update(s, &pad, 1);
|
||||
|
||||
for (i = 0; i < 8; i++)
|
||||
len[i] = (uint8_t)(bits >> (56 - 8 * i));
|
||||
wfm_sha1_update(s, len, 8);
|
||||
|
||||
for (i = 0; i < 5; i++) {
|
||||
out[i * 4 + 0] = (uint8_t)(s->h[i] >> 24);
|
||||
out[i * 4 + 1] = (uint8_t)(s->h[i] >> 16);
|
||||
out[i * 4 + 2] = (uint8_t)(s->h[i] >> 8);
|
||||
out[i * 4 + 3] = (uint8_t)(s->h[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/**************************************************************************
|
||||
* HMAC-SHA1 (RFC 2104)
|
||||
**************************************************************************/
|
||||
|
||||
typedef struct wfm_hmac_s {
|
||||
uint16_t algorithm;
|
||||
int32_t initialized;
|
||||
wfm_sha1_t inner; /* running hash of ipad || message */
|
||||
uint8_t opad[WFM_SHA1_BLOCK]; /* key ^ 0x5c, zero padded */
|
||||
} wfm_hmac_t;
|
||||
|
||||
/* ipad = key ^ 0x36 and opad = key ^ 0x5c, so ipad can be recovered from
|
||||
opad without storing the key twice. */
|
||||
#define WFM_HMAC_OPAD 0x5c
|
||||
#define WFM_HMAC_PAD_DIFF (0x5c ^ 0x36)
|
||||
|
||||
int32_t mz_crypt_hmac_init(void *handle, const void *key, int32_t key_length) {
|
||||
wfm_hmac_t *hmac = (wfm_hmac_t *)handle;
|
||||
uint8_t ipad[WFM_SHA1_BLOCK];
|
||||
uint8_t digest[WFM_SHA1_SIZE];
|
||||
wfm_sha1_t k;
|
||||
uint32_t i;
|
||||
|
||||
if (!hmac || (!key && key_length > 0) || key_length < 0)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (hmac->algorithm != MZ_HASH_SHA1)
|
||||
return MZ_SUPPORT_ERROR;
|
||||
|
||||
memset(hmac->opad, 0, sizeof(hmac->opad));
|
||||
|
||||
if (key_length > WFM_SHA1_BLOCK) {
|
||||
/* The key is hashed down to the digest size first. */
|
||||
wfm_sha1_init(&k);
|
||||
wfm_sha1_update(&k, key, (size_t)key_length);
|
||||
wfm_sha1_final(&k, digest);
|
||||
memcpy(hmac->opad, digest, WFM_SHA1_SIZE);
|
||||
} else if (key_length > 0) {
|
||||
memcpy(hmac->opad, key, (size_t)key_length);
|
||||
}
|
||||
|
||||
/* opad = K ^ 0x5c, and ipad = K ^ 0x36 = opad ^ 0x5c ^ 0x36, so the key
|
||||
only has to be stored once. */
|
||||
for (i = 0; i < WFM_SHA1_BLOCK; i++)
|
||||
hmac->opad[i] ^= WFM_HMAC_OPAD;
|
||||
for (i = 0; i < WFM_SHA1_BLOCK; i++)
|
||||
ipad[i] = (uint8_t)(hmac->opad[i] ^ WFM_HMAC_PAD_DIFF);
|
||||
|
||||
wfm_sha1_init(&hmac->inner);
|
||||
wfm_sha1_update(&hmac->inner, ipad, WFM_SHA1_BLOCK);
|
||||
|
||||
hmac->initialized = 1;
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_crypt_hmac_update(void *handle, const void *buf, int32_t size) {
|
||||
wfm_hmac_t *hmac = (wfm_hmac_t *)handle;
|
||||
|
||||
if (!hmac || !buf || size < 0)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (!hmac->initialized)
|
||||
return MZ_PARAM_ERROR;
|
||||
|
||||
wfm_sha1_update(&hmac->inner, buf, (size_t)size);
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_crypt_hmac_end(void *handle, uint8_t *digest, int32_t digest_size) {
|
||||
wfm_hmac_t *hmac = (wfm_hmac_t *)handle;
|
||||
uint8_t inner_digest[WFM_SHA1_SIZE];
|
||||
wfm_sha1_t outer;
|
||||
|
||||
if (!hmac || !digest)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (!hmac->initialized)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (digest_size < WFM_SHA1_SIZE)
|
||||
return MZ_BUF_ERROR;
|
||||
|
||||
wfm_sha1_final(&hmac->inner, inner_digest);
|
||||
|
||||
wfm_sha1_init(&outer);
|
||||
wfm_sha1_update(&outer, hmac->opad, WFM_SHA1_BLOCK);
|
||||
wfm_sha1_update(&outer, inner_digest, WFM_SHA1_SIZE);
|
||||
wfm_sha1_final(&outer, digest);
|
||||
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_crypt_hmac_copy(void *src_handle, void *target_handle) {
|
||||
wfm_hmac_t *source = (wfm_hmac_t *)src_handle;
|
||||
wfm_hmac_t *target = (wfm_hmac_t *)target_handle;
|
||||
|
||||
if (!source || !target)
|
||||
return MZ_PARAM_ERROR;
|
||||
/* The context owns no pointers, so a plain copy is a deep copy. This is
|
||||
what mz_crypt_pbkdf2() relies on when it clones the mid state. */
|
||||
memcpy(target, source, sizeof(*target));
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
void mz_crypt_hmac_set_algorithm(void *handle, uint16_t algorithm) {
|
||||
wfm_hmac_t *hmac = (wfm_hmac_t *)handle;
|
||||
if (hmac)
|
||||
hmac->algorithm = algorithm;
|
||||
}
|
||||
|
||||
void mz_crypt_hmac_reset(void *handle) {
|
||||
wfm_hmac_t *hmac = (wfm_hmac_t *)handle;
|
||||
if (!hmac)
|
||||
return;
|
||||
memset(&hmac->inner, 0, sizeof(hmac->inner));
|
||||
memset(hmac->opad, 0, sizeof(hmac->opad));
|
||||
hmac->initialized = 0;
|
||||
}
|
||||
|
||||
void *mz_crypt_hmac_create(void) {
|
||||
wfm_hmac_t *hmac = (wfm_hmac_t *)calloc(1, sizeof(wfm_hmac_t));
|
||||
if (hmac)
|
||||
hmac->algorithm = MZ_HASH_SHA1;
|
||||
return hmac;
|
||||
}
|
||||
|
||||
void mz_crypt_hmac_delete(void **handle) {
|
||||
wfm_hmac_t *hmac = NULL;
|
||||
if (!handle)
|
||||
return;
|
||||
hmac = (wfm_hmac_t *)*handle;
|
||||
if (hmac) {
|
||||
memset(hmac, 0, sizeof(*hmac));
|
||||
free(hmac);
|
||||
}
|
||||
*handle = NULL;
|
||||
}
|
||||
|
||||
/**************************************************************************
|
||||
* mz_crypt_sha_* -- the standalone hash API declared by mz_crypt.h.
|
||||
* Only SHA-1 is available (see the file header).
|
||||
**************************************************************************/
|
||||
|
||||
typedef struct wfm_sha_handle_s {
|
||||
uint16_t algorithm;
|
||||
int32_t initialized;
|
||||
wfm_sha1_t ctx;
|
||||
} wfm_sha_handle_t;
|
||||
|
||||
int32_t mz_crypt_sha_set_algorithm(void *handle, uint16_t algorithm) {
|
||||
wfm_sha_handle_t *sha = (wfm_sha_handle_t *)handle;
|
||||
if (!sha)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (algorithm != MZ_HASH_SHA1)
|
||||
return MZ_SUPPORT_ERROR;
|
||||
sha->algorithm = algorithm;
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_crypt_sha_begin(void *handle) {
|
||||
wfm_sha_handle_t *sha = (wfm_sha_handle_t *)handle;
|
||||
if (!sha)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (sha->algorithm != MZ_HASH_SHA1)
|
||||
return MZ_SUPPORT_ERROR;
|
||||
wfm_sha1_init(&sha->ctx);
|
||||
sha->initialized = 1;
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_crypt_sha_update(void *handle, const void *buf, int32_t size) {
|
||||
wfm_sha_handle_t *sha = (wfm_sha_handle_t *)handle;
|
||||
if (!sha || !buf || size < 0)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (!sha->initialized)
|
||||
return MZ_PARAM_ERROR;
|
||||
wfm_sha1_update(&sha->ctx, buf, (size_t)size);
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_crypt_sha_end(void *handle, uint8_t *digest, int32_t digest_size) {
|
||||
wfm_sha_handle_t *sha = (wfm_sha_handle_t *)handle;
|
||||
if (!sha || !digest)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (!sha->initialized)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (digest_size < WFM_SHA1_SIZE)
|
||||
return MZ_PARAM_ERROR;
|
||||
wfm_sha1_final(&sha->ctx, digest);
|
||||
sha->initialized = 0;
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
void mz_crypt_sha_reset(void *handle) {
|
||||
wfm_sha_handle_t *sha = (wfm_sha_handle_t *)handle;
|
||||
if (!sha)
|
||||
return;
|
||||
wfm_sha1_init(&sha->ctx);
|
||||
sha->initialized = 0;
|
||||
}
|
||||
|
||||
void *mz_crypt_sha_create(void) {
|
||||
wfm_sha_handle_t *sha = (wfm_sha_handle_t *)calloc(1, sizeof(wfm_sha_handle_t));
|
||||
if (sha)
|
||||
sha->algorithm = MZ_HASH_SHA1;
|
||||
return sha;
|
||||
}
|
||||
|
||||
void mz_crypt_sha_delete(void **handle) {
|
||||
wfm_sha_handle_t *sha = NULL;
|
||||
if (!handle)
|
||||
return;
|
||||
sha = (wfm_sha_handle_t *)*handle;
|
||||
if (sha) {
|
||||
memset(sha, 0, sizeof(*sha));
|
||||
free(sha);
|
||||
}
|
||||
*handle = NULL;
|
||||
}
|
||||
|
||||
/**************************************************************************
|
||||
* AES-128/192/256 (FIPS 197)
|
||||
*
|
||||
* The tables are derived once from the field arithmetic instead of being
|
||||
* spelled out, which keeps the .rodata cost at zero and removes any chance of
|
||||
* a transcription error in a 256 entry table.
|
||||
**************************************************************************/
|
||||
|
||||
#define WFM_AES_BLOCK 16
|
||||
#define WFM_AES_MAX_WORDS 60 /* (14 rounds + 1) * 4 */
|
||||
|
||||
static uint8_t wfm_aes_sbox[256];
|
||||
static uint8_t wfm_aes_inv_sbox[256];
|
||||
static uint8_t wfm_aes_log[256]; /* log base 3 */
|
||||
static uint8_t wfm_aes_alog[256]; /* 3^i, alog[255] wraps to 1 */
|
||||
static uint32_t wfm_aes_te[4][256];
|
||||
static int wfm_aes_tables_ready;
|
||||
|
||||
static uint8_t wfm_aes_xtime(uint8_t x) {
|
||||
return (uint8_t)((x << 1) ^ ((x & 0x80) ? 0x1b : 0x00));
|
||||
}
|
||||
|
||||
static uint8_t wfm_rol8(uint8_t value, unsigned bits) {
|
||||
return (uint8_t)((value << bits) | (value >> (8 - bits)));
|
||||
}
|
||||
|
||||
static void wfm_aes_build_tables(void) {
|
||||
uint8_t x = 1;
|
||||
int i;
|
||||
|
||||
if (wfm_aes_tables_ready)
|
||||
return;
|
||||
wfm_aes_tables_ready = 1;
|
||||
|
||||
for (i = 0; i < 255; i++) {
|
||||
wfm_aes_alog[i] = x;
|
||||
wfm_aes_log[x] = (uint8_t)i;
|
||||
x ^= wfm_aes_xtime(x); /* x *= 3 */
|
||||
}
|
||||
wfm_aes_alog[255] = 1;
|
||||
wfm_aes_log[0] = 0; /* log is never used for 0 */
|
||||
|
||||
for (i = 0; i < 256; i++) {
|
||||
/* Multiplicative inverse in GF(2^8), then the affine transform. */
|
||||
uint8_t inv = (i == 0) ? 0 : wfm_aes_alog[255 - wfm_aes_log[(uint8_t)i]];
|
||||
uint8_t r = (uint8_t)(inv ^ wfm_rol8(inv, 1) ^ wfm_rol8(inv, 2) ^ wfm_rol8(inv, 3) ^ wfm_rol8(inv, 4));
|
||||
r ^= 0x63;
|
||||
|
||||
wfm_aes_sbox[i] = r;
|
||||
wfm_aes_inv_sbox[r] = (uint8_t)i;
|
||||
}
|
||||
|
||||
for (i = 0; i < 256; i++) {
|
||||
uint8_t s = wfm_aes_sbox[i];
|
||||
uint8_t s2 = wfm_aes_xtime(s);
|
||||
uint8_t s3 = (uint8_t)(s2 ^ s);
|
||||
|
||||
wfm_aes_te[0][i] = ((uint32_t)s2 << 24) | ((uint32_t)s << 16) | ((uint32_t)s << 8) | s3;
|
||||
wfm_aes_te[1][i] = ((uint32_t)s3 << 24) | ((uint32_t)s2 << 16) | ((uint32_t)s << 8) | s;
|
||||
wfm_aes_te[2][i] = ((uint32_t)s << 24) | ((uint32_t)s3 << 16) | ((uint32_t)s2 << 8) | s;
|
||||
wfm_aes_te[3][i] = ((uint32_t)s << 24) | ((uint32_t)s << 16) | ((uint32_t)s3 << 8) | s2;
|
||||
}
|
||||
}
|
||||
|
||||
static uint8_t wfm_aes_gf_mul(uint8_t a, uint8_t b) {
|
||||
if (a == 0 || b == 0)
|
||||
return 0;
|
||||
/* The sum of two logarithms reaches 508, so the modulo has to happen in
|
||||
int arithmetic: truncating to uint8_t first would wrap a large sum. */
|
||||
return wfm_aes_alog[(wfm_aes_log[a] + wfm_aes_log[b]) % 255];
|
||||
}
|
||||
|
||||
typedef struct wfm_aes_s {
|
||||
uint32_t rk[WFM_AES_MAX_WORDS];
|
||||
uint8_t iv[WFM_AES_BLOCK];
|
||||
int32_t mode;
|
||||
int32_t nr; /* number of rounds */
|
||||
int32_t keyed;
|
||||
} wfm_aes_t;
|
||||
|
||||
static uint32_t wfm_load_be32(const uint8_t *p) {
|
||||
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | (uint32_t)p[3];
|
||||
}
|
||||
|
||||
static void wfm_store_be32(uint8_t *p, uint32_t v) {
|
||||
p[0] = (uint8_t)(v >> 24);
|
||||
p[1] = (uint8_t)(v >> 16);
|
||||
p[2] = (uint8_t)(v >> 8);
|
||||
p[3] = (uint8_t)v;
|
||||
}
|
||||
|
||||
static void wfm_aes_set_key(wfm_aes_t *a, const uint8_t *key, int32_t key_length) {
|
||||
int32_t nk = key_length / 4;
|
||||
int32_t total = 4 * (nk + 7);
|
||||
int32_t i;
|
||||
uint8_t rcon = 1;
|
||||
uint32_t temp;
|
||||
|
||||
wfm_aes_build_tables();
|
||||
|
||||
for (i = 0; i < nk; i++)
|
||||
a->rk[i] = wfm_load_be32(key + 4 * i);
|
||||
|
||||
for (i = nk; i < total; i++) {
|
||||
temp = a->rk[i - 1];
|
||||
if (i % nk == 0) {
|
||||
/* SubWord(RotWord(temp)) ^ Rcon */
|
||||
temp = ((uint32_t)wfm_aes_sbox[(temp >> 16) & 0xff] << 24) |
|
||||
((uint32_t)wfm_aes_sbox[(temp >> 8) & 0xff] << 16) |
|
||||
((uint32_t)wfm_aes_sbox[temp & 0xff] << 8) | (uint32_t)wfm_aes_sbox[(temp >> 24) & 0xff];
|
||||
temp ^= (uint32_t)rcon << 24;
|
||||
rcon = wfm_aes_xtime(rcon);
|
||||
} else if (nk > 6 && (i % nk) == 4) {
|
||||
/* SubWord(temp) for AES-256 only */
|
||||
temp = ((uint32_t)wfm_aes_sbox[(temp >> 24) & 0xff] << 24) |
|
||||
((uint32_t)wfm_aes_sbox[(temp >> 16) & 0xff] << 16) |
|
||||
((uint32_t)wfm_aes_sbox[(temp >> 8) & 0xff] << 8) | (uint32_t)wfm_aes_sbox[temp & 0xff];
|
||||
}
|
||||
a->rk[i] = a->rk[i - nk] ^ temp;
|
||||
}
|
||||
|
||||
a->nr = nk + 6;
|
||||
a->keyed = 1;
|
||||
}
|
||||
|
||||
static void wfm_aes_encrypt_block(const wfm_aes_t *a, const uint8_t *in, uint8_t *out) {
|
||||
const uint32_t *rk = a->rk;
|
||||
uint32_t s0, s1, s2, s3, t0, t1, t2, t3;
|
||||
int32_t r;
|
||||
|
||||
s0 = wfm_load_be32(in) ^ rk[0];
|
||||
s1 = wfm_load_be32(in + 4) ^ rk[1];
|
||||
s2 = wfm_load_be32(in + 8) ^ rk[2];
|
||||
s3 = wfm_load_be32(in + 12) ^ rk[3];
|
||||
|
||||
for (r = 1; r < a->nr; r++) {
|
||||
t0 = wfm_aes_te[0][(s0 >> 24) & 0xff] ^ wfm_aes_te[1][(s1 >> 16) & 0xff] ^ wfm_aes_te[2][(s2 >> 8) & 0xff] ^
|
||||
wfm_aes_te[3][s3 & 0xff] ^ rk[4 * r + 0];
|
||||
t1 = wfm_aes_te[0][(s1 >> 24) & 0xff] ^ wfm_aes_te[1][(s2 >> 16) & 0xff] ^ wfm_aes_te[2][(s3 >> 8) & 0xff] ^
|
||||
wfm_aes_te[3][s0 & 0xff] ^ rk[4 * r + 1];
|
||||
t2 = wfm_aes_te[0][(s2 >> 24) & 0xff] ^ wfm_aes_te[1][(s3 >> 16) & 0xff] ^ wfm_aes_te[2][(s0 >> 8) & 0xff] ^
|
||||
wfm_aes_te[3][s1 & 0xff] ^ rk[4 * r + 2];
|
||||
t3 = wfm_aes_te[0][(s3 >> 24) & 0xff] ^ wfm_aes_te[1][(s0 >> 16) & 0xff] ^ wfm_aes_te[2][(s1 >> 8) & 0xff] ^
|
||||
wfm_aes_te[3][s2 & 0xff] ^ rk[4 * r + 3];
|
||||
|
||||
s0 = t0;
|
||||
s1 = t1;
|
||||
s2 = t2;
|
||||
s3 = t3;
|
||||
}
|
||||
|
||||
/* Final round: SubBytes, ShiftRows, AddRoundKey. */
|
||||
t0 = ((uint32_t)wfm_aes_sbox[(s0 >> 24) & 0xff] << 24) | ((uint32_t)wfm_aes_sbox[(s1 >> 16) & 0xff] << 16) |
|
||||
((uint32_t)wfm_aes_sbox[(s2 >> 8) & 0xff] << 8) | (uint32_t)wfm_aes_sbox[s3 & 0xff];
|
||||
t1 = ((uint32_t)wfm_aes_sbox[(s1 >> 24) & 0xff] << 24) | ((uint32_t)wfm_aes_sbox[(s2 >> 16) & 0xff] << 16) |
|
||||
((uint32_t)wfm_aes_sbox[(s3 >> 8) & 0xff] << 8) | (uint32_t)wfm_aes_sbox[s0 & 0xff];
|
||||
t2 = ((uint32_t)wfm_aes_sbox[(s2 >> 24) & 0xff] << 24) | ((uint32_t)wfm_aes_sbox[(s3 >> 16) & 0xff] << 16) |
|
||||
((uint32_t)wfm_aes_sbox[(s0 >> 8) & 0xff] << 8) | (uint32_t)wfm_aes_sbox[s1 & 0xff];
|
||||
t3 = ((uint32_t)wfm_aes_sbox[(s3 >> 24) & 0xff] << 24) | ((uint32_t)wfm_aes_sbox[(s0 >> 16) & 0xff] << 16) |
|
||||
((uint32_t)wfm_aes_sbox[(s1 >> 8) & 0xff] << 8) | (uint32_t)wfm_aes_sbox[s2 & 0xff];
|
||||
|
||||
wfm_store_be32(out, t0 ^ rk[4 * a->nr + 0]);
|
||||
wfm_store_be32(out + 4, t1 ^ rk[4 * a->nr + 1]);
|
||||
wfm_store_be32(out + 8, t2 ^ rk[4 * a->nr + 2]);
|
||||
wfm_store_be32(out + 12, t3 ^ rk[4 * a->nr + 3]);
|
||||
}
|
||||
|
||||
/* Inverse cipher straight out of FIPS 197. Byte oriented on purpose: only
|
||||
the encryption direction is on the hot path (WinZip AES encrypts the
|
||||
counter block) and the decoder must stay small. */
|
||||
static void wfm_aes_decrypt_block(const wfm_aes_t *a, const uint8_t *in, uint8_t *out) {
|
||||
const uint32_t *rk = a->rk;
|
||||
uint8_t st[WFM_AES_BLOCK];
|
||||
uint8_t tmp[WFM_AES_BLOCK];
|
||||
int32_t round;
|
||||
int32_t c, r;
|
||||
|
||||
/* AddRoundKey with the last round key. */
|
||||
for (c = 0; c < 4; c++) {
|
||||
uint32_t k = rk[4 * a->nr + c];
|
||||
st[4 * c + 0] = (uint8_t)(in[4 * c + 0] ^ (k >> 24));
|
||||
st[4 * c + 1] = (uint8_t)(in[4 * c + 1] ^ (k >> 16));
|
||||
st[4 * c + 2] = (uint8_t)(in[4 * c + 2] ^ (k >> 8));
|
||||
st[4 * c + 3] = (uint8_t)(in[4 * c + 3] ^ k);
|
||||
}
|
||||
|
||||
for (round = a->nr - 1; round >= 1; round--) {
|
||||
/* InvShiftRows: row r rotates right by r. */
|
||||
for (c = 0; c < 4; c++) {
|
||||
for (r = 0; r < 4; r++)
|
||||
tmp[4 * c + r] = st[4 * ((c - r + 4) & 3) + r];
|
||||
}
|
||||
/* InvSubBytes */
|
||||
for (c = 0; c < WFM_AES_BLOCK; c++)
|
||||
tmp[c] = wfm_aes_inv_sbox[tmp[c]];
|
||||
/* AddRoundKey */
|
||||
for (c = 0; c < 4; c++) {
|
||||
uint32_t k = rk[4 * round + c];
|
||||
st[4 * c + 0] = (uint8_t)(tmp[4 * c + 0] ^ (k >> 24));
|
||||
st[4 * c + 1] = (uint8_t)(tmp[4 * c + 1] ^ (k >> 16));
|
||||
st[4 * c + 2] = (uint8_t)(tmp[4 * c + 2] ^ (k >> 8));
|
||||
st[4 * c + 3] = (uint8_t)(tmp[4 * c + 3] ^ k);
|
||||
}
|
||||
/* InvMixColumns */
|
||||
for (c = 0; c < 4; c++) {
|
||||
uint8_t a0 = st[4 * c + 0], a1 = st[4 * c + 1], a2 = st[4 * c + 2], a3 = st[4 * c + 3];
|
||||
tmp[4 * c + 0] = (uint8_t)(wfm_aes_gf_mul(a0, 14) ^ wfm_aes_gf_mul(a1, 11) ^ wfm_aes_gf_mul(a2, 13) ^
|
||||
wfm_aes_gf_mul(a3, 9));
|
||||
tmp[4 * c + 1] = (uint8_t)(wfm_aes_gf_mul(a0, 9) ^ wfm_aes_gf_mul(a1, 14) ^ wfm_aes_gf_mul(a2, 11) ^
|
||||
wfm_aes_gf_mul(a3, 13));
|
||||
tmp[4 * c + 2] = (uint8_t)(wfm_aes_gf_mul(a0, 13) ^ wfm_aes_gf_mul(a1, 9) ^ wfm_aes_gf_mul(a2, 14) ^
|
||||
wfm_aes_gf_mul(a3, 11));
|
||||
tmp[4 * c + 3] = (uint8_t)(wfm_aes_gf_mul(a0, 11) ^ wfm_aes_gf_mul(a1, 13) ^ wfm_aes_gf_mul(a2, 9) ^
|
||||
wfm_aes_gf_mul(a3, 14));
|
||||
}
|
||||
memcpy(st, tmp, WFM_AES_BLOCK);
|
||||
}
|
||||
|
||||
/* Final round without InvMixColumns. */
|
||||
for (c = 0; c < 4; c++) {
|
||||
for (r = 0; r < 4; r++)
|
||||
tmp[4 * c + r] = st[4 * ((c - r + 4) & 3) + r];
|
||||
}
|
||||
for (c = 0; c < WFM_AES_BLOCK; c++)
|
||||
tmp[c] = wfm_aes_inv_sbox[tmp[c]];
|
||||
for (c = 0; c < 4; c++) {
|
||||
uint32_t k = rk[c];
|
||||
out[4 * c + 0] = (uint8_t)(tmp[4 * c + 0] ^ (k >> 24));
|
||||
out[4 * c + 1] = (uint8_t)(tmp[4 * c + 1] ^ (k >> 16));
|
||||
out[4 * c + 2] = (uint8_t)(tmp[4 * c + 2] ^ (k >> 8));
|
||||
out[4 * c + 3] = (uint8_t)(tmp[4 * c + 3] ^ k);
|
||||
}
|
||||
}
|
||||
|
||||
/**************************************************************************
|
||||
* mz_crypt_aes_* -- the streaming AES API declared by mz_crypt.h
|
||||
**************************************************************************/
|
||||
|
||||
void *mz_crypt_aes_create(void) {
|
||||
wfm_aes_t *aes = (wfm_aes_t *)calloc(1, sizeof(wfm_aes_t));
|
||||
if (aes)
|
||||
aes->mode = MZ_AES_MODE_ECB;
|
||||
return aes;
|
||||
}
|
||||
|
||||
void mz_crypt_aes_delete(void **handle) {
|
||||
wfm_aes_t *aes = NULL;
|
||||
if (!handle)
|
||||
return;
|
||||
aes = (wfm_aes_t *)*handle;
|
||||
if (aes) {
|
||||
memset(aes, 0, sizeof(*aes));
|
||||
free(aes);
|
||||
}
|
||||
*handle = NULL;
|
||||
}
|
||||
|
||||
void mz_crypt_aes_reset(void *handle) {
|
||||
wfm_aes_t *aes = (wfm_aes_t *)handle;
|
||||
if (!aes)
|
||||
return;
|
||||
/* Deliberately keeps the mode: mz_strm_wzaes.c resets and then sets the
|
||||
key, relying on ECB being the default. */
|
||||
memset(aes->iv, 0, sizeof(aes->iv));
|
||||
aes->keyed = 0;
|
||||
}
|
||||
|
||||
void mz_crypt_aes_set_mode(void *handle, int32_t mode) {
|
||||
wfm_aes_t *aes = (wfm_aes_t *)handle;
|
||||
if (!aes)
|
||||
return;
|
||||
aes->mode = mode;
|
||||
}
|
||||
|
||||
int32_t mz_crypt_aes_set_encrypt_key(void *handle, const void *key, int32_t key_length, const void *iv,
|
||||
int32_t iv_length) {
|
||||
wfm_aes_t *aes = (wfm_aes_t *)handle;
|
||||
|
||||
if (!aes || !key)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (key_length != 16 && key_length != 24 && key_length != 32)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (iv_length != 0 && (iv_length != WFM_AES_BLOCK || !iv))
|
||||
return MZ_PARAM_ERROR;
|
||||
if (aes->mode != MZ_AES_MODE_ECB && aes->mode != MZ_AES_MODE_CBC)
|
||||
return MZ_SUPPORT_ERROR;
|
||||
|
||||
wfm_aes_set_key(aes, (const uint8_t *)key, key_length);
|
||||
memset(aes->iv, 0, sizeof(aes->iv));
|
||||
if (iv_length == WFM_AES_BLOCK)
|
||||
memcpy(aes->iv, iv, WFM_AES_BLOCK);
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_crypt_aes_set_decrypt_key(void *handle, const void *key, int32_t key_length, const void *iv,
|
||||
int32_t iv_length) {
|
||||
/* The key schedule is the encryption one in both directions; the inverse
|
||||
cipher uses it directly. */
|
||||
return mz_crypt_aes_set_encrypt_key(handle, key, key_length, iv, iv_length);
|
||||
}
|
||||
|
||||
int32_t mz_crypt_aes_encrypt(void *handle, const void *aad, int32_t aad_size, uint8_t *buf, int32_t size) {
|
||||
wfm_aes_t *aes = (wfm_aes_t *)handle;
|
||||
int32_t done;
|
||||
|
||||
if (!aes || !buf || size < 0)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (aad_size != 0)
|
||||
return MZ_SUPPORT_ERROR; /* no AEAD modes are implemented */
|
||||
if (!aes->keyed)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (size % WFM_AES_BLOCK != 0)
|
||||
return MZ_PARAM_ERROR;
|
||||
|
||||
if (aes->mode == MZ_AES_MODE_ECB) {
|
||||
/* Every block is encrypted independently, which is exactly what
|
||||
mz_strm_wzaes.c wants for its counter. */
|
||||
for (done = 0; done < size; done += WFM_AES_BLOCK)
|
||||
wfm_aes_encrypt_block(aes, buf + done, buf + done);
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
for (done = 0; done < size; done += WFM_AES_BLOCK) {
|
||||
uint8_t block[WFM_AES_BLOCK];
|
||||
int32_t i;
|
||||
|
||||
for (i = 0; i < WFM_AES_BLOCK; i++)
|
||||
block[i] = (uint8_t)(buf[done + i] ^ aes->iv[i]);
|
||||
wfm_aes_encrypt_block(aes, block, block);
|
||||
memcpy(aes->iv, block, WFM_AES_BLOCK);
|
||||
memcpy(buf + done, block, WFM_AES_BLOCK);
|
||||
}
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_crypt_aes_decrypt(void *handle, const void *aad, int32_t aad_size, uint8_t *buf, int32_t size) {
|
||||
wfm_aes_t *aes = (wfm_aes_t *)handle;
|
||||
int32_t done;
|
||||
|
||||
if (!aes || !buf || size < 0)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (aad_size != 0)
|
||||
return MZ_SUPPORT_ERROR;
|
||||
if (!aes->keyed)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (size % WFM_AES_BLOCK != 0)
|
||||
return MZ_PARAM_ERROR;
|
||||
|
||||
if (aes->mode == MZ_AES_MODE_ECB) {
|
||||
for (done = 0; done < size; done += WFM_AES_BLOCK)
|
||||
wfm_aes_decrypt_block(aes, buf + done, buf + done);
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
for (done = 0; done < size; done += WFM_AES_BLOCK) {
|
||||
uint8_t ctext[WFM_AES_BLOCK];
|
||||
uint8_t ptext[WFM_AES_BLOCK];
|
||||
int32_t i;
|
||||
|
||||
memcpy(ctext, buf + done, WFM_AES_BLOCK);
|
||||
wfm_aes_decrypt_block(aes, ctext, ptext);
|
||||
for (i = 0; i < WFM_AES_BLOCK; i++)
|
||||
ptext[i] ^= aes->iv[i];
|
||||
memcpy(aes->iv, ctext, WFM_AES_BLOCK);
|
||||
memcpy(buf + done, ptext, WFM_AES_BLOCK);
|
||||
}
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_crypt_aes_encrypt_final(void *handle, uint8_t *buf, int32_t size, uint8_t *tag, int32_t tag_size) {
|
||||
if (!handle || !buf || size < 0)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (tag && tag_size > 0)
|
||||
memset(tag, 0, (size_t)tag_size);
|
||||
/* ECB/CBC are block modes with no trailing state to flush. */
|
||||
return (size == 0) ? MZ_OK : MZ_PARAM_ERROR;
|
||||
}
|
||||
|
||||
int32_t mz_crypt_aes_decrypt_final(void *handle, uint8_t *buf, int32_t size, const uint8_t *tag, int32_t tag_size) {
|
||||
if (!handle || !buf || size < 0)
|
||||
return MZ_PARAM_ERROR;
|
||||
if (tag && tag_size > 0)
|
||||
return MZ_SUPPORT_ERROR; /* no authentication is implemented */
|
||||
return (size == 0) ? MZ_OK : MZ_PARAM_ERROR;
|
||||
}
|
||||
|
||||
/***************************************************************************/
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
/* mz_strm_pkcrypt.c -- Code for traditional PKWARE encryption
|
||||
part of the minizip-ng project
|
||||
|
||||
Copyright (C) Nathan Moinvaziri
|
||||
https://github.com/zlib-ng/minizip-ng
|
||||
Copyright (C) 1998-2005 Gilles Vollant
|
||||
Modifications for Info-ZIP crypting
|
||||
https://www.winimage.com/zLibDll/minizip.html
|
||||
Copyright (C) 2003 Terry Thorsen
|
||||
|
||||
This code is a modified version of crypting code in Info-ZIP distribution
|
||||
|
||||
Copyright (C) 1990-2000 Info-ZIP. All rights reserved.
|
||||
|
||||
This program is distributed under the terms of the same license as zlib.
|
||||
See the accompanying LICENSE file for the full text of the license.
|
||||
|
||||
This encryption code is a direct transcription of the algorithm from
|
||||
Roger Schlafly, described by Phil Katz in the file appnote.txt. This
|
||||
file (appnote.txt) is distributed with the PKZIP program (even in the
|
||||
version without encryption capabilities).
|
||||
*/
|
||||
|
||||
#include "mz.h"
|
||||
#include "mz_crypt.h"
|
||||
#include "mz_strm.h"
|
||||
#include "mz_strm_pkcrypt.h"
|
||||
|
||||
/***************************************************************************/
|
||||
|
||||
static mz_stream_vtbl mz_stream_pkcrypt_vtbl = {
|
||||
mz_stream_pkcrypt_open, mz_stream_pkcrypt_is_open, mz_stream_pkcrypt_read,
|
||||
mz_stream_pkcrypt_write, mz_stream_pkcrypt_tell, mz_stream_pkcrypt_seek,
|
||||
mz_stream_pkcrypt_close, mz_stream_pkcrypt_error, mz_stream_pkcrypt_create,
|
||||
mz_stream_pkcrypt_delete, mz_stream_pkcrypt_get_prop_int64, mz_stream_pkcrypt_set_prop_int64};
|
||||
|
||||
/***************************************************************************/
|
||||
|
||||
typedef struct mz_stream_pkcrypt_s {
|
||||
mz_stream stream;
|
||||
int32_t error;
|
||||
int16_t initialized;
|
||||
uint8_t buffer[UINT16_MAX];
|
||||
int64_t total_in;
|
||||
int64_t max_total_in;
|
||||
int64_t total_out;
|
||||
uint32_t keys[3]; /* keys defining the pseudo-random sequence */
|
||||
uint8_t verify1;
|
||||
uint8_t verify2;
|
||||
uint16_t verify_version;
|
||||
const char *password;
|
||||
} mz_stream_pkcrypt;
|
||||
|
||||
/***************************************************************************/
|
||||
|
||||
#define mz_stream_pkcrypt_decode(strm, c) \
|
||||
(mz_stream_pkcrypt_update_keys(strm, c ^= mz_stream_pkcrypt_decrypt_byte(strm)))
|
||||
|
||||
#define mz_stream_pkcrypt_encode(strm, c, t) \
|
||||
(t = mz_stream_pkcrypt_decrypt_byte(strm), mz_stream_pkcrypt_update_keys(strm, (uint8_t)c), (uint8_t)(t ^ (c)))
|
||||
|
||||
/***************************************************************************/
|
||||
|
||||
static uint8_t mz_stream_pkcrypt_decrypt_byte(void *stream) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
|
||||
unsigned temp; /* POTENTIAL BUG: temp*(temp^1) may overflow in an */
|
||||
/* unpredictable manner on 16-bit systems; not a problem */
|
||||
/* with any known compiler so far, though. */
|
||||
|
||||
temp = pkcrypt->keys[2] | 2;
|
||||
return (uint8_t)(((temp * (temp ^ 1)) >> 8) & 0xff);
|
||||
}
|
||||
|
||||
static uint8_t mz_stream_pkcrypt_update_keys(void *stream, uint8_t c) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
uint8_t buf = c;
|
||||
|
||||
pkcrypt->keys[0] = (uint32_t)~mz_crypt_crc32_update(~pkcrypt->keys[0], &buf, 1);
|
||||
|
||||
pkcrypt->keys[1] += pkcrypt->keys[0] & 0xff;
|
||||
pkcrypt->keys[1] *= 134775813L;
|
||||
pkcrypt->keys[1] += 1;
|
||||
|
||||
buf = (uint8_t)(pkcrypt->keys[1] >> 24);
|
||||
pkcrypt->keys[2] = (uint32_t)~mz_crypt_crc32_update(~pkcrypt->keys[2], &buf, 1);
|
||||
|
||||
return (uint8_t)c;
|
||||
}
|
||||
|
||||
static void mz_stream_pkcrypt_init_keys(void *stream, const char *password) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
|
||||
pkcrypt->keys[0] = 305419896L;
|
||||
pkcrypt->keys[1] = 591751049L;
|
||||
pkcrypt->keys[2] = 878082192L;
|
||||
|
||||
while (*password != 0) {
|
||||
mz_stream_pkcrypt_update_keys(stream, (uint8_t)*password);
|
||||
password += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************/
|
||||
|
||||
int32_t mz_stream_pkcrypt_open(void *stream, const char *path, int32_t mode) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
uint16_t t = 0;
|
||||
int16_t i = 0;
|
||||
uint8_t verify1 = 0;
|
||||
uint8_t verify2 = 0;
|
||||
uint8_t header[MZ_PKCRYPT_HEADER_SIZE];
|
||||
const char *password = path;
|
||||
|
||||
pkcrypt->total_in = 0;
|
||||
pkcrypt->total_out = 0;
|
||||
pkcrypt->initialized = 0;
|
||||
|
||||
if (mz_stream_is_open(pkcrypt->stream.base) != MZ_OK)
|
||||
return MZ_OPEN_ERROR;
|
||||
|
||||
if (!password)
|
||||
password = pkcrypt->password;
|
||||
if (!password)
|
||||
return MZ_PARAM_ERROR;
|
||||
|
||||
mz_stream_pkcrypt_init_keys(stream, password);
|
||||
|
||||
if (mode & MZ_OPEN_MODE_WRITE) {
|
||||
/* First generate RAND_HEAD_LEN - 2 random bytes. */
|
||||
mz_crypt_rand(header, MZ_PKCRYPT_HEADER_SIZE - 2);
|
||||
|
||||
/* Encrypt random header (last two bytes is high word of crc) */
|
||||
for (i = 0; i < MZ_PKCRYPT_HEADER_SIZE - 2; i++)
|
||||
header[i] = mz_stream_pkcrypt_encode(stream, header[i], t);
|
||||
|
||||
header[i++] = mz_stream_pkcrypt_encode(stream, pkcrypt->verify1, t);
|
||||
header[i++] = mz_stream_pkcrypt_encode(stream, pkcrypt->verify2, t);
|
||||
|
||||
if (mz_stream_write(pkcrypt->stream.base, header, sizeof(header)) != sizeof(header))
|
||||
return MZ_WRITE_ERROR;
|
||||
|
||||
pkcrypt->total_out += MZ_PKCRYPT_HEADER_SIZE;
|
||||
} else if (mode & MZ_OPEN_MODE_READ) {
|
||||
if (mz_stream_read(pkcrypt->stream.base, header, sizeof(header)) != sizeof(header))
|
||||
return MZ_READ_ERROR;
|
||||
|
||||
for (i = 0; i < MZ_PKCRYPT_HEADER_SIZE - 2; i++)
|
||||
header[i] = mz_stream_pkcrypt_decode(stream, header[i]);
|
||||
|
||||
verify1 = mz_stream_pkcrypt_decode(stream, header[i++]);
|
||||
verify2 = mz_stream_pkcrypt_decode(stream, header[i++]);
|
||||
|
||||
/* PKZIP 2.0 and higher use 1 byte check, older versions used 2 byte check.
|
||||
See app note section 6.1.6. */
|
||||
if (verify2 != pkcrypt->verify2)
|
||||
return MZ_PASSWORD_ERROR;
|
||||
if (pkcrypt->verify_version < 2) {
|
||||
if (verify1 != pkcrypt->verify1)
|
||||
return MZ_PASSWORD_ERROR;
|
||||
}
|
||||
|
||||
pkcrypt->total_in += MZ_PKCRYPT_HEADER_SIZE;
|
||||
}
|
||||
|
||||
pkcrypt->initialized = 1;
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_stream_pkcrypt_is_open(void *stream) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
if (!pkcrypt->initialized)
|
||||
return MZ_OPEN_ERROR;
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_stream_pkcrypt_read(void *stream, void *buf, int32_t size) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
uint8_t *buf_ptr = (uint8_t *)buf;
|
||||
int32_t bytes_to_read = size;
|
||||
int32_t read = 0;
|
||||
int32_t i = 0;
|
||||
|
||||
if ((int64_t)bytes_to_read > (pkcrypt->max_total_in - pkcrypt->total_in))
|
||||
bytes_to_read = (int32_t)(pkcrypt->max_total_in - pkcrypt->total_in);
|
||||
|
||||
read = mz_stream_read(pkcrypt->stream.base, buf, bytes_to_read);
|
||||
|
||||
for (i = 0; i < read; i++)
|
||||
buf_ptr[i] = mz_stream_pkcrypt_decode(stream, buf_ptr[i]);
|
||||
|
||||
if (read > 0)
|
||||
pkcrypt->total_in += read;
|
||||
|
||||
return read;
|
||||
}
|
||||
|
||||
int32_t mz_stream_pkcrypt_write(void *stream, const void *buf, int32_t size) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
const uint8_t *buf_ptr = (const uint8_t *)buf;
|
||||
int32_t bytes_to_write = sizeof(pkcrypt->buffer);
|
||||
int32_t total_written = 0;
|
||||
int32_t written = 0;
|
||||
int32_t i = 0;
|
||||
uint16_t t = 0;
|
||||
|
||||
if (size < 0)
|
||||
return MZ_PARAM_ERROR;
|
||||
|
||||
do {
|
||||
if (bytes_to_write > (size - total_written))
|
||||
bytes_to_write = (size - total_written);
|
||||
|
||||
for (i = 0; i < bytes_to_write; i += 1) {
|
||||
pkcrypt->buffer[i] = mz_stream_pkcrypt_encode(stream, *buf_ptr, t);
|
||||
buf_ptr += 1;
|
||||
}
|
||||
|
||||
written = mz_stream_write(pkcrypt->stream.base, pkcrypt->buffer, bytes_to_write);
|
||||
if (written < 0)
|
||||
return written;
|
||||
|
||||
total_written += written;
|
||||
} while (total_written < size && written > 0);
|
||||
|
||||
pkcrypt->total_out += total_written;
|
||||
return total_written;
|
||||
}
|
||||
|
||||
int64_t mz_stream_pkcrypt_tell(void *stream) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
return mz_stream_tell(pkcrypt->stream.base);
|
||||
}
|
||||
|
||||
int32_t mz_stream_pkcrypt_seek(void *stream, int64_t offset, int32_t origin) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
return mz_stream_seek(pkcrypt->stream.base, offset, origin);
|
||||
}
|
||||
|
||||
int32_t mz_stream_pkcrypt_close(void *stream) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
pkcrypt->initialized = 0;
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_stream_pkcrypt_error(void *stream) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
return pkcrypt->error;
|
||||
}
|
||||
|
||||
void mz_stream_pkcrypt_set_password(void *stream, const char *password) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
pkcrypt->password = password;
|
||||
}
|
||||
|
||||
void mz_stream_pkcrypt_set_verify(void *stream, uint8_t verify1, uint8_t verify2, uint16_t version) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
pkcrypt->verify1 = verify1;
|
||||
pkcrypt->verify2 = verify2;
|
||||
pkcrypt->verify_version = version;
|
||||
}
|
||||
|
||||
void mz_stream_pkcrypt_get_verify(void *stream, uint8_t *verify1, uint8_t *verify2, uint16_t *version) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
*verify1 = pkcrypt->verify1;
|
||||
*verify2 = pkcrypt->verify2;
|
||||
*version = pkcrypt->verify_version;
|
||||
}
|
||||
|
||||
int32_t mz_stream_pkcrypt_get_prop_int64(void *stream, int32_t prop, int64_t *value) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
switch (prop) {
|
||||
case MZ_STREAM_PROP_TOTAL_IN:
|
||||
*value = pkcrypt->total_in;
|
||||
break;
|
||||
case MZ_STREAM_PROP_TOTAL_OUT:
|
||||
*value = pkcrypt->total_out;
|
||||
break;
|
||||
case MZ_STREAM_PROP_TOTAL_IN_MAX:
|
||||
*value = pkcrypt->max_total_in;
|
||||
break;
|
||||
case MZ_STREAM_PROP_HEADER_SIZE:
|
||||
*value = MZ_PKCRYPT_HEADER_SIZE;
|
||||
break;
|
||||
case MZ_STREAM_PROP_FOOTER_SIZE:
|
||||
*value = 0;
|
||||
break;
|
||||
default:
|
||||
return MZ_EXIST_ERROR;
|
||||
}
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_stream_pkcrypt_set_prop_int64(void *stream, int32_t prop, int64_t value) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)stream;
|
||||
switch (prop) {
|
||||
case MZ_STREAM_PROP_TOTAL_IN_MAX:
|
||||
pkcrypt->max_total_in = value;
|
||||
break;
|
||||
default:
|
||||
return MZ_EXIST_ERROR;
|
||||
}
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
void *mz_stream_pkcrypt_create(void) {
|
||||
mz_stream_pkcrypt *pkcrypt = (mz_stream_pkcrypt *)calloc(1, sizeof(mz_stream_pkcrypt));
|
||||
if (pkcrypt)
|
||||
pkcrypt->stream.vtbl = &mz_stream_pkcrypt_vtbl;
|
||||
return pkcrypt;
|
||||
}
|
||||
|
||||
void mz_stream_pkcrypt_delete(void **stream) {
|
||||
mz_stream_pkcrypt *pkcrypt = NULL;
|
||||
if (!stream)
|
||||
return;
|
||||
pkcrypt = (mz_stream_pkcrypt *)*stream;
|
||||
free(pkcrypt);
|
||||
*stream = NULL;
|
||||
}
|
||||
|
||||
void *mz_stream_pkcrypt_get_interface(void) {
|
||||
return (void *)&mz_stream_pkcrypt_vtbl;
|
||||
}
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
/* mz_strm_wzaes.c -- Stream for WinZip AES encryption
|
||||
part of the minizip-ng project
|
||||
|
||||
Copyright (C) Nathan Moinvaziri
|
||||
https://github.com/zlib-ng/minizip-ng
|
||||
Copyright (C) 1998-2010 Brian Gladman, Worcester, UK
|
||||
|
||||
This program is distributed under the terms of the same license as zlib.
|
||||
See the accompanying LICENSE file for the full text of the license.
|
||||
*/
|
||||
|
||||
#include "mz.h"
|
||||
#include "mz_crypt.h"
|
||||
#include "mz_strm.h"
|
||||
#include "mz_strm_wzaes.h"
|
||||
|
||||
/***************************************************************************/
|
||||
|
||||
#define MZ_AES_KEY_LENGTH(STRENGTH) (8 * (STRENGTH & 3) + 8)
|
||||
#define MZ_AES_KEYING_ITERATIONS (1000)
|
||||
#define MZ_AES_SALT_LENGTH(STRENGTH) (4 * (STRENGTH & 3) + 4)
|
||||
#define MZ_AES_SALT_LENGTH_MAX (16)
|
||||
#define MZ_AES_PW_LENGTH_MAX (128)
|
||||
#define MZ_AES_PW_VERIFY_SIZE (2)
|
||||
#define MZ_AES_AUTHCODE_SIZE (10)
|
||||
|
||||
/***************************************************************************/
|
||||
|
||||
static mz_stream_vtbl mz_stream_wzaes_vtbl = {
|
||||
mz_stream_wzaes_open, mz_stream_wzaes_is_open, mz_stream_wzaes_read, mz_stream_wzaes_write,
|
||||
mz_stream_wzaes_tell, mz_stream_wzaes_seek, mz_stream_wzaes_close, mz_stream_wzaes_error,
|
||||
mz_stream_wzaes_create, mz_stream_wzaes_delete, mz_stream_wzaes_get_prop_int64, mz_stream_wzaes_set_prop_int64};
|
||||
|
||||
/***************************************************************************/
|
||||
|
||||
typedef struct mz_stream_wzaes_s {
|
||||
mz_stream stream;
|
||||
int32_t mode;
|
||||
int32_t error;
|
||||
int16_t initialized;
|
||||
uint8_t buffer[UINT16_MAX];
|
||||
int64_t total_in;
|
||||
int64_t max_total_in;
|
||||
int64_t total_out;
|
||||
uint8_t strength;
|
||||
const char *password;
|
||||
void *aes;
|
||||
uint32_t crypt_pos;
|
||||
uint8_t crypt_block[MZ_AES_BLOCK_SIZE];
|
||||
void *hmac;
|
||||
uint8_t nonce[MZ_AES_BLOCK_SIZE];
|
||||
} mz_stream_wzaes;
|
||||
|
||||
/***************************************************************************/
|
||||
|
||||
int32_t mz_stream_wzaes_open(void *stream, const char *path, int32_t mode) {
|
||||
mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream;
|
||||
uint16_t salt_length = 0;
|
||||
uint16_t password_length = 0;
|
||||
uint16_t key_length = 0;
|
||||
uint8_t kbuf[2 * MZ_AES_KEY_LENGTH_MAX + MZ_AES_PW_VERIFY_SIZE];
|
||||
uint8_t verify[MZ_AES_PW_VERIFY_SIZE];
|
||||
uint8_t verify_expected[MZ_AES_PW_VERIFY_SIZE];
|
||||
uint8_t salt_value[MZ_AES_SALT_LENGTH_MAX];
|
||||
const char *password = path;
|
||||
|
||||
wzaes->total_in = 0;
|
||||
wzaes->total_out = 0;
|
||||
wzaes->initialized = 0;
|
||||
|
||||
if (mz_stream_is_open(wzaes->stream.base) != MZ_OK)
|
||||
return MZ_OPEN_ERROR;
|
||||
|
||||
if (!password)
|
||||
password = wzaes->password;
|
||||
if (!password)
|
||||
return MZ_PARAM_ERROR;
|
||||
password_length = (uint16_t)strlen(password);
|
||||
if (password_length > MZ_AES_PW_LENGTH_MAX)
|
||||
return MZ_PARAM_ERROR;
|
||||
|
||||
if (wzaes->strength < 1 || wzaes->strength > 3)
|
||||
return MZ_PARAM_ERROR;
|
||||
|
||||
key_length = MZ_AES_KEY_LENGTH(wzaes->strength);
|
||||
salt_length = MZ_AES_SALT_LENGTH(wzaes->strength);
|
||||
|
||||
if (mode & MZ_OPEN_MODE_WRITE) {
|
||||
mz_crypt_rand(salt_value, salt_length);
|
||||
} else if (mode & MZ_OPEN_MODE_READ) {
|
||||
if (mz_stream_read(wzaes->stream.base, salt_value, salt_length) != salt_length)
|
||||
return MZ_READ_ERROR;
|
||||
}
|
||||
|
||||
/* Derive the encryption and authentication keys and the password verifier */
|
||||
mz_crypt_pbkdf2((uint8_t *)password, password_length, salt_value, salt_length, MZ_AES_KEYING_ITERATIONS, kbuf,
|
||||
2 * key_length + MZ_AES_PW_VERIFY_SIZE);
|
||||
|
||||
/* Initialize the buffer pos */
|
||||
wzaes->crypt_pos = MZ_AES_BLOCK_SIZE;
|
||||
|
||||
/* Use fixed zeroed IV/nonce for CTR mode */
|
||||
memset(wzaes->nonce, 0, sizeof(wzaes->nonce));
|
||||
|
||||
/* Initialize for encryption using key 1 */
|
||||
mz_crypt_aes_reset(wzaes->aes);
|
||||
mz_crypt_aes_set_encrypt_key(wzaes->aes, kbuf, key_length, NULL, 0);
|
||||
|
||||
/* Initialize for authentication using key 2 */
|
||||
mz_crypt_hmac_reset(wzaes->hmac);
|
||||
mz_crypt_hmac_set_algorithm(wzaes->hmac, MZ_HASH_SHA1);
|
||||
mz_crypt_hmac_init(wzaes->hmac, kbuf + key_length, key_length);
|
||||
|
||||
memcpy(verify, kbuf + (2 * key_length), MZ_AES_PW_VERIFY_SIZE);
|
||||
|
||||
if (mode & MZ_OPEN_MODE_WRITE) {
|
||||
if (mz_stream_write(wzaes->stream.base, salt_value, salt_length) != salt_length)
|
||||
return MZ_WRITE_ERROR;
|
||||
|
||||
wzaes->total_out += salt_length;
|
||||
|
||||
if (mz_stream_write(wzaes->stream.base, verify, MZ_AES_PW_VERIFY_SIZE) != MZ_AES_PW_VERIFY_SIZE)
|
||||
return MZ_WRITE_ERROR;
|
||||
|
||||
wzaes->total_out += MZ_AES_PW_VERIFY_SIZE;
|
||||
} else if (mode & MZ_OPEN_MODE_READ) {
|
||||
wzaes->total_in += salt_length;
|
||||
|
||||
if (mz_stream_read(wzaes->stream.base, verify_expected, MZ_AES_PW_VERIFY_SIZE) != MZ_AES_PW_VERIFY_SIZE)
|
||||
return MZ_READ_ERROR;
|
||||
|
||||
wzaes->total_in += MZ_AES_PW_VERIFY_SIZE;
|
||||
|
||||
if (memcmp(verify_expected, verify, MZ_AES_PW_VERIFY_SIZE) != 0)
|
||||
return MZ_PASSWORD_ERROR;
|
||||
}
|
||||
|
||||
wzaes->mode = mode;
|
||||
wzaes->initialized = 1;
|
||||
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_stream_wzaes_is_open(void *stream) {
|
||||
mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream;
|
||||
if (!wzaes->initialized)
|
||||
return MZ_OPEN_ERROR;
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
static int32_t mz_stream_wzaes_ctr_encrypt(void *stream, uint8_t *buf, int32_t size) {
|
||||
mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream;
|
||||
uint32_t pos = wzaes->crypt_pos;
|
||||
uint32_t i = 0;
|
||||
int32_t err = MZ_OK;
|
||||
|
||||
while (i < (uint32_t)size) {
|
||||
if (pos == MZ_AES_BLOCK_SIZE) {
|
||||
uint32_t j = 0;
|
||||
|
||||
/* Increment encryption nonce */
|
||||
while (j < 8 && !++wzaes->nonce[j])
|
||||
j += 1;
|
||||
|
||||
/* Encrypt the nonce using ECB mode to form next xor buffer */
|
||||
memcpy(wzaes->crypt_block, wzaes->nonce, MZ_AES_BLOCK_SIZE);
|
||||
mz_crypt_aes_encrypt(wzaes->aes, NULL, 0, wzaes->crypt_block, sizeof(wzaes->crypt_block));
|
||||
pos = 0;
|
||||
}
|
||||
|
||||
buf[i++] ^= wzaes->crypt_block[pos++];
|
||||
}
|
||||
|
||||
wzaes->crypt_pos = pos;
|
||||
return err;
|
||||
}
|
||||
|
||||
int32_t mz_stream_wzaes_read(void *stream, void *buf, int32_t size) {
|
||||
mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream;
|
||||
int64_t max_total_in = 0;
|
||||
int32_t bytes_to_read = size;
|
||||
int32_t read = 0;
|
||||
|
||||
max_total_in = wzaes->max_total_in - MZ_AES_FOOTER_SIZE;
|
||||
if ((int64_t)bytes_to_read > (max_total_in - wzaes->total_in))
|
||||
bytes_to_read = (int32_t)(max_total_in - wzaes->total_in);
|
||||
|
||||
read = mz_stream_read(wzaes->stream.base, buf, bytes_to_read);
|
||||
|
||||
if (read > 0) {
|
||||
mz_crypt_hmac_update(wzaes->hmac, (uint8_t *)buf, read);
|
||||
mz_stream_wzaes_ctr_encrypt(stream, (uint8_t *)buf, read);
|
||||
|
||||
wzaes->total_in += read;
|
||||
}
|
||||
|
||||
return read;
|
||||
}
|
||||
|
||||
int32_t mz_stream_wzaes_write(void *stream, const void *buf, int32_t size) {
|
||||
mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream;
|
||||
const uint8_t *buf_ptr = (const uint8_t *)buf;
|
||||
int32_t bytes_to_write = sizeof(wzaes->buffer);
|
||||
int32_t total_written = 0;
|
||||
int32_t written = 0;
|
||||
|
||||
if (size < 0)
|
||||
return MZ_PARAM_ERROR;
|
||||
|
||||
do {
|
||||
if (bytes_to_write > (size - total_written))
|
||||
bytes_to_write = (size - total_written);
|
||||
|
||||
memcpy(wzaes->buffer, buf_ptr, bytes_to_write);
|
||||
buf_ptr += bytes_to_write;
|
||||
|
||||
mz_stream_wzaes_ctr_encrypt(stream, (uint8_t *)wzaes->buffer, bytes_to_write);
|
||||
mz_crypt_hmac_update(wzaes->hmac, wzaes->buffer, bytes_to_write);
|
||||
|
||||
written = mz_stream_write(wzaes->stream.base, wzaes->buffer, bytes_to_write);
|
||||
if (written < 0)
|
||||
return written;
|
||||
|
||||
total_written += written;
|
||||
} while (total_written < size && written > 0);
|
||||
|
||||
wzaes->total_out += total_written;
|
||||
return total_written;
|
||||
}
|
||||
|
||||
int64_t mz_stream_wzaes_tell(void *stream) {
|
||||
mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream;
|
||||
return mz_stream_tell(wzaes->stream.base);
|
||||
}
|
||||
|
||||
int32_t mz_stream_wzaes_seek(void *stream, int64_t offset, int32_t origin) {
|
||||
mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream;
|
||||
return mz_stream_seek(wzaes->stream.base, offset, origin);
|
||||
}
|
||||
|
||||
int32_t mz_stream_wzaes_close(void *stream) {
|
||||
mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream;
|
||||
uint8_t expected_hash[MZ_AES_AUTHCODE_SIZE];
|
||||
uint8_t computed_hash[MZ_HASH_SHA1_SIZE];
|
||||
|
||||
mz_crypt_hmac_end(wzaes->hmac, computed_hash, sizeof(computed_hash));
|
||||
|
||||
if (wzaes->mode & MZ_OPEN_MODE_WRITE) {
|
||||
if (mz_stream_write(wzaes->stream.base, computed_hash, MZ_AES_AUTHCODE_SIZE) != MZ_AES_AUTHCODE_SIZE)
|
||||
return MZ_WRITE_ERROR;
|
||||
|
||||
wzaes->total_out += MZ_AES_AUTHCODE_SIZE;
|
||||
} else if (wzaes->mode & MZ_OPEN_MODE_READ) {
|
||||
if (mz_stream_read(wzaes->stream.base, expected_hash, MZ_AES_AUTHCODE_SIZE) != MZ_AES_AUTHCODE_SIZE)
|
||||
return MZ_READ_ERROR;
|
||||
|
||||
wzaes->total_in += MZ_AES_AUTHCODE_SIZE;
|
||||
|
||||
/* If entire entry was not read this will fail */
|
||||
if (memcmp(computed_hash, expected_hash, MZ_AES_AUTHCODE_SIZE) != 0)
|
||||
return MZ_CRC_ERROR;
|
||||
}
|
||||
|
||||
wzaes->initialized = 0;
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_stream_wzaes_error(void *stream) {
|
||||
mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream;
|
||||
return wzaes->error;
|
||||
}
|
||||
|
||||
void mz_stream_wzaes_set_password(void *stream, const char *password) {
|
||||
mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream;
|
||||
wzaes->password = password;
|
||||
}
|
||||
|
||||
void mz_stream_wzaes_set_strength(void *stream, uint8_t strength) {
|
||||
mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream;
|
||||
wzaes->strength = strength;
|
||||
}
|
||||
|
||||
int32_t mz_stream_wzaes_get_prop_int64(void *stream, int32_t prop, int64_t *value) {
|
||||
mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream;
|
||||
switch (prop) {
|
||||
case MZ_STREAM_PROP_TOTAL_IN:
|
||||
*value = wzaes->total_in;
|
||||
break;
|
||||
case MZ_STREAM_PROP_TOTAL_OUT:
|
||||
*value = wzaes->total_out;
|
||||
break;
|
||||
case MZ_STREAM_PROP_TOTAL_IN_MAX:
|
||||
*value = wzaes->max_total_in;
|
||||
break;
|
||||
case MZ_STREAM_PROP_HEADER_SIZE:
|
||||
*value = MZ_AES_SALT_LENGTH((int64_t)wzaes->strength) + MZ_AES_PW_VERIFY_SIZE;
|
||||
break;
|
||||
case MZ_STREAM_PROP_FOOTER_SIZE:
|
||||
*value = MZ_AES_AUTHCODE_SIZE;
|
||||
break;
|
||||
default:
|
||||
return MZ_EXIST_ERROR;
|
||||
}
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
int32_t mz_stream_wzaes_set_prop_int64(void *stream, int32_t prop, int64_t value) {
|
||||
mz_stream_wzaes *wzaes = (mz_stream_wzaes *)stream;
|
||||
switch (prop) {
|
||||
case MZ_STREAM_PROP_TOTAL_IN_MAX:
|
||||
wzaes->max_total_in = value;
|
||||
break;
|
||||
default:
|
||||
return MZ_EXIST_ERROR;
|
||||
}
|
||||
return MZ_OK;
|
||||
}
|
||||
|
||||
void *mz_stream_wzaes_create(void) {
|
||||
mz_stream_wzaes *wzaes = (mz_stream_wzaes *)calloc(1, sizeof(mz_stream_wzaes));
|
||||
if (wzaes) {
|
||||
wzaes->stream.vtbl = &mz_stream_wzaes_vtbl;
|
||||
wzaes->strength = MZ_AES_STRENGTH_256;
|
||||
|
||||
wzaes->hmac = mz_crypt_hmac_create();
|
||||
if (!wzaes->hmac) {
|
||||
free(wzaes);
|
||||
return NULL;
|
||||
}
|
||||
wzaes->aes = mz_crypt_aes_create();
|
||||
if (!wzaes->aes) {
|
||||
mz_crypt_hmac_delete(&wzaes->hmac);
|
||||
free(wzaes);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
return wzaes;
|
||||
}
|
||||
|
||||
void mz_stream_wzaes_delete(void **stream) {
|
||||
mz_stream_wzaes *wzaes = NULL;
|
||||
if (!stream)
|
||||
return;
|
||||
wzaes = (mz_stream_wzaes *)*stream;
|
||||
if (wzaes) {
|
||||
mz_crypt_aes_delete(&wzaes->aes);
|
||||
mz_crypt_hmac_delete(&wzaes->hmac);
|
||||
free(wzaes);
|
||||
}
|
||||
*stream = NULL;
|
||||
}
|
||||
|
||||
void *mz_stream_wzaes_get_interface(void) {
|
||||
return (void *)&mz_stream_wzaes_vtbl;
|
||||
}
|
||||
Reference in new issue
Block a user