Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c4eadbf5 | ||
|
|
ad765776f7 | ||
|
|
ae41434089 | ||
|
|
af6b440f33 | ||
|
|
01a6616472 | ||
|
|
068983b062 | ||
|
|
da67bfa284 | ||
|
|
452b9b0187 | ||
|
|
91757bfd02 | ||
|
|
30d0decf58 | ||
|
|
22a4b5c76a | ||
|
|
ff010add68 | ||
|
|
68056f2d9f | ||
|
|
3db921e408 | ||
|
|
c58c145733 | ||
|
|
a0482a41ed | ||
|
|
c68c0def35 | ||
|
|
8344b9bae0 | ||
|
|
cb82ee439d | ||
|
|
9e9830a214 | ||
|
|
687ef6b297 | ||
|
|
36ea055e70 | ||
|
|
2a346d694c | ||
|
|
bf55a4e4fd | ||
|
|
848b774333 | ||
|
|
5af5acf4ab | ||
|
|
96286cb9f2 | ||
|
|
50eb1734c1 | ||
|
|
0d24359d80 | ||
|
|
bfe522e56b | ||
|
|
c7d9e965b8 | ||
|
|
3f516b51ad | ||
|
|
aef4a44ab3 | ||
|
|
5cb0b76e7a |
No files matched your search
@@ -0,0 +1,74 @@
|
||||
==========================================
|
||||
PS5 Web File Manager -- ELF 构建 v4
|
||||
时间: Fri Sep 4 12:45:52 CST 2026
|
||||
PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk
|
||||
user: song uid=1000
|
||||
==========================================
|
||||
[0/7] 预热 sudo (会提示输 1 次密码)...
|
||||
[1/7] SDK OK: /opt/ps5-payload-sdk
|
||||
[2/7] LLVM bindir: /usr/lib/llvm-18/bin
|
||||
[3/7] prospero-clang --version ...
|
||||
Ubuntu clang version 18.1.8 (++20240731024944+3b5b5c1ec4a3-1~exp1~20240731145000.144)
|
||||
[4/7] 同步源码 (Windows -> WSL, 增量) ...
|
||||
已同步自 /mnt/c/Users/songl/Desktop/Web File Manager/ps5-web-file-manager
|
||||
[5/7] libmicrohttpd (staging + 一次性 sudo cp)...
|
||||
下载 libmicrohttpd-1.0.1.tar.gz...
|
||||
tarball: /home/song/.cache/libmicrohttpd-1.0.1.tar.gz (2.2M)
|
||||
configure (host=x86_64-pc-freebsd)...
|
||||
make -j14...
|
||||
mv -f $depbase.Tpo $depbase.Po
|
||||
/bin/bash ../../libtool --tag=CC --mode=link /opt/ps5-payload-sdk/bin/prospero-clang -fno-strict-aliasing -O1 -w -o perf_replies perf_replies.o mhd_tool_get_cpu_count.o ../../src/microhttpd/libmicrohttpd.la
|
||||
libtool: link: /opt/ps5-payload-sdk/bin/prospero-clang -fno-strict-aliasing -O1 -w -o perf_replies perf_replies.o mhd_tool_get_cpu_count.o ../../src/microhttpd/.libs/libmicrohttpd.a -lpthread -pthread
|
||||
make[4]: Leaving directory '/tmp/tmp.1uwmf0LJPq/libmicrohttpd-1.0.1/src/tools'
|
||||
make[3]: Leaving directory '/tmp/tmp.1uwmf0LJPq/libmicrohttpd-1.0.1/src/tools'
|
||||
make[2]: Leaving directory '/tmp/tmp.1uwmf0LJPq/libmicrohttpd-1.0.1/src'
|
||||
Making all in .
|
||||
make[2]: Entering directory '/tmp/tmp.1uwmf0LJPq/libmicrohttpd-1.0.1'
|
||||
make[2]: Leaving directory '/tmp/tmp.1uwmf0LJPq/libmicrohttpd-1.0.1'
|
||||
make[1]: Leaving directory '/tmp/tmp.1uwmf0LJPq/libmicrohttpd-1.0.1'
|
||||
make install DESTDIR=/tmp/ps5-homebrew-ext4 ...
|
||||
make[2]: Nothing to be done for 'install-exec-am'.
|
||||
/usr/bin/mkdir -p '/tmp/ps5-homebrew-ext4/user/homebrew/lib/pkgconfig'
|
||||
/usr/bin/install -c -m 644 libmicrohttpd.pc '/tmp/ps5-homebrew-ext4/user/homebrew/lib/pkgconfig'
|
||||
make[2]: Leaving directory '/tmp/tmp.1uwmf0LJPq/libmicrohttpd-1.0.1'
|
||||
make[1]: Leaving directory '/tmp/tmp.1uwmf0LJPq/libmicrohttpd-1.0.1'
|
||||
sudo cp stage -> SDK homebrew...
|
||||
验证 prospero-pkg-config:
|
||||
1.0.1
|
||||
-L/opt/ps5-payload-sdk/target/user/homebrew/lib -lmicrohttpd -lpthread
|
||||
[6/7] make all (增量编译, 复用第三方 obj)...
|
||||
mkdir gen
|
||||
python3 gen-asset-module.py --path icon-back.png assets/icon-back.png > gen/icon-back.png.c
|
||||
python3 gen-asset-module.py --path icon-file.png assets/icon-file.png > gen/icon-file.png.c
|
||||
python3 gen-asset-module.py --path icon-folder.png assets/icon-folder.png > gen/icon-folder.png.c
|
||||
python3 gen-asset-module.py --path icon-generic.png assets/icon-generic.png > gen/icon-generic.png.c
|
||||
python3 gen-asset-module.py --path icon-image.png assets/icon-image.png > gen/icon-image.png.c
|
||||
python3 gen-asset-module.py --path icon-pkg.png assets/icon-pkg.png > gen/icon-pkg.png.c
|
||||
python3 gen-asset-module.py --path icon-up.png assets/icon-up.png > gen/icon-up.png.c
|
||||
python3 gen-asset-module.py --path index.html assets/index.html > gen/index.html.c
|
||||
python3 gen-asset-module.py --path lang-en.js assets/lang-en.js > gen/lang-en.js.c
|
||||
python3 gen-asset-module.py --path lang-zh.js assets/lang-zh.js > gen/lang-zh.js.c
|
||||
python3 gen-asset-module.py --path main.css assets/main.css > gen/main.css.c
|
||||
python3 gen-asset-module.py --path main.js assets/main.js > gen/main.js.c
|
||||
python3 gen-asset-module.py --path param.json assets/param.json > gen/param.json.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE -c -o ps5-obj/third_party/zlib/src/adler32.o third_party/zlib/src/adler32.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE -c -o ps5-obj/third_party/zlib/src/crc32.o third_party/zlib/src/crc32.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE -c -o ps5-obj/third_party/zlib/src/deflate.o third_party/zlib/src/deflate.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE -c -o ps5-obj/third_party/zlib/src/inffast.o third_party/zlib/src/inffast.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE -c -o ps5-obj/third_party/zlib/src/inflate.o third_party/zlib/src/inflate.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE -c -o ps5-obj/third_party/zlib/src/inftrees.o third_party/zlib/src/inftrees.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE -c -o ps5-obj/third_party/zlib/src/trees.o third_party/zlib/src/trees.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE -c -o ps5-obj/third_party/zlib/src/zutil.o third_party/zlib/src/zutil.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE -c -o ps5-obj/third_party/minizip-ng/src/mz_crypt.o third_party/minizip-ng/src/mz_crypt.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE -c -o ps5-obj/third_party/minizip-ng/src/mz_os.o third_party/minizip-ng/src/mz_os.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE -c -o ps5-obj/third_party/minizip-ng/src/mz_os_posix.o third_party/minizip-ng/src/mz_os_posix.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE -c -o ps5-obj/third_party/minizip-ng/src/mz_strm.o third_party/minizip-ng/src/mz_strm.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE -c -o ps5-obj/third_party/minizip-ng/src/mz_strm_mem.o third_party/minizip-ng/src/mz_strm_mem.c
|
||||
/opt/ps5-payload-sdk/bin/prospero-clang -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include -DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE -c -o ps5-obj/third_party/minizip-ng/src/mz_strm_os_posix.o third_party/minizip-ng/src/mz_strm_os_posix.c
|
||||
third_party/minizip-ng/src/mz_strm_os_posix.c:95:33: error: use of undeclared identifier 'O_BINARY'
|
||||
95 | fd = open(path, mode_open | O_BINARY, S_IRUSR | S_IWUSR | S_IRGRP);
|
||||
| ^
|
||||
1 error generated.
|
||||
make: *** [Makefile:78: ps5-obj/third_party/minizip-ng/src/mz_strm_os_posix.o] Error 1
|
||||
[7/7] 验证产物 + 同步...
|
||||
FAIL: ELF 未生成 -- 日志: /home/song/build-elf.log
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env bash
|
||||
# PS5 Web File Manager 一键构建 (v4)
|
||||
# 修复:
|
||||
# (1) staging 模式装 libmicrohttpd -> make install 到 /tmp, 再 sudo cp 到 SDK
|
||||
# (2) 显式 sudo -v 刷密码缓存 (v3 的 chown 静默失败了)
|
||||
# (3) 增量: 保留 zlib/minizip-ng OBJ 缓存 (不每次 make clean)
|
||||
# (4) libmicrohttpd tarball 缓存 ~/.cache/, 失败可重试不重下
|
||||
# (5) 同步源用 rsync 增量
|
||||
# (6) 失败时把 log 同步到 Windows .build/build-elf.log
|
||||
#
|
||||
# 跑法: bash /home/song/build-elf.sh
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk
|
||||
SRC_WIN="/mnt/c/Users/songl/Desktop/Web File Manager/ps5-web-file-manager"
|
||||
PROJ="/home/song/ps5-web-file-manager"
|
||||
LOG="/home/song/build-elf.log"
|
||||
STAGE="/tmp/ps5-homebrew-ext4"
|
||||
CACHE="${HOME}/.cache/libmicrohttpd-1.0.1.tar.gz"
|
||||
|
||||
mkdir -p "$(dirname "$LOG")" "$STAGE" "$(dirname "$CACHE")"
|
||||
: >"$LOG"
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
|
||||
echo "=========================================="
|
||||
echo "PS5 Web File Manager -- ELF 构建 v4"
|
||||
echo "时间: $(date)"
|
||||
echo "PS5_PAYLOAD_SDK=$PS5_PAYLOAD_SDK"
|
||||
echo "user: $(whoami) uid=$(id -u)"
|
||||
echo "=========================================="
|
||||
|
||||
# 0. sudo 密码缓存 (脚本会跑 sudo 几次, 在开头集中要求密码)
|
||||
echo "[0/7] 预热 sudo (会提示输 1 次密码)..."
|
||||
sudo -v 2>&1 || { echo "FAIL: sudo 不可用"; exit 99; }
|
||||
|
||||
# 1. SDK 验证
|
||||
if [ ! -x "$PS5_PAYLOAD_SDK/bin/prospero-clang" ]; then
|
||||
if [ -f /home/song/ps5-payload-sdk.zip ]; then
|
||||
echo "[1/7] 展开 SDK zip 到 $PS5_PAYLOAD_SDK ..."
|
||||
sudo mkdir -p "$PS5_PAYLOAD_SDK"
|
||||
sudo unzip -q -o /home/song/ps5-payload-sdk.zip -d /tmp/sdk-stage
|
||||
sudo cp -r /tmp/sdk-stage/. "$PS5_PAYLOAD_SDK/"
|
||||
sudo chmod -R a+rx "$PS5_PAYLOAD_SDK"
|
||||
else
|
||||
echo "FAIL: 未发现 /home/song/ps5-payload-sdk.zip 也无 $PS5_PAYLOAD_SDK/bin/prospero-clang"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "[1/7] SDK OK: $PS5_PAYLOAD_SDK"
|
||||
|
||||
# 2. LLVM 工具链
|
||||
if ! command -v llvm-config-18 >/dev/null 2>&1; then
|
||||
echo "[2/7] 装 LLVM 18 (apt.llvm.org) ..."
|
||||
if [ ! -x /tmp/llvm.sh ]; then
|
||||
wget -q https://apt.llvm.org/llvm.sh -O /tmp/llvm.sh
|
||||
chmod +x /tmp/llvm.sh
|
||||
fi
|
||||
sudo /tmp/llvm.sh 18 all >/dev/null 2>&1 || true
|
||||
fi
|
||||
if ! command -v pkg-config >/dev/null 2>&1; then
|
||||
echo "[2/7] 装 pkg-config ..."
|
||||
sudo apt-get install -y pkg-config rsync
|
||||
fi
|
||||
LLVM_BINDIR="$(llvm-config-18 --bindir 2>/dev/null || llvm-config --bindir)"
|
||||
echo "[2/7] LLVM bindir: $LLVM_BINDIR"
|
||||
|
||||
# 3. 工具链验证
|
||||
echo "[3/7] prospero-clang --version ..."
|
||||
"$PS5_PAYLOAD_SDK/bin/prospero-clang" --version | head -1
|
||||
|
||||
# 4. 同步源码 (rsync 增量)
|
||||
echo "[4/7] 同步源码 (Windows -> WSL, 增量) ..."
|
||||
mkdir -p "$PROJ"
|
||||
if [ -d "$SRC_WIN/src" ]; then
|
||||
rsync -a --delete \
|
||||
--exclude='ps5-obj' --exclude='linux-obj' \
|
||||
--exclude='web-file-mgr.elf' --exclude='web-file-mgr-linux' \
|
||||
--exclude='.build/stub' --exclude='.build/obj' \
|
||||
--exclude='.build/probe*' --exclude='.build/probe-work' \
|
||||
--exclude='.build/host-test' --exclude='.build/tp' \
|
||||
--exclude='.build/*.exe' --exclude='.build/*.txt' \
|
||||
--exclude='gen' \
|
||||
"$SRC_WIN/" "$PROJ/"
|
||||
echo " 已同步自 $SRC_WIN"
|
||||
else
|
||||
echo " (Windows 端不可见 -- /mnt/c 是否挂载?)"
|
||||
fi
|
||||
|
||||
# 5. 装 libmicrohttpd (staging 模式: make install 到 /tmp, 再 sudo cp 过去)
|
||||
echo "[5/7] libmicrohttpd (staging + 一次性 sudo cp)..."
|
||||
if "$PS5_PAYLOAD_SDK/bin/prospero-pkg-config" --modversion libmicrohttpd >/dev/null 2>&1; then
|
||||
echo " 已装: $($PS5_PAYLOAD_SDK/bin/prospero-pkg-config --modversion libmicrohttpd) -- 跳过"
|
||||
else
|
||||
# 5a) 下载 tarball (缓存复用)
|
||||
if [ ! -f "$CACHE" ] || [ ! -s "$CACHE" ]; then
|
||||
echo " 下载 libmicrohttpd-1.0.1.tar.gz..."
|
||||
if command -v wget >/dev/null; then
|
||||
wget -q https://ftp.gnu.org/gnu/libmicrohttpd/libmicrohttpd-1.0.1.tar.gz -O "$CACHE"
|
||||
else
|
||||
curl -fsSL https://ftp.gnu.org/gnu/libmicrohttpd/libmicrohttpd-1.0.1.tar.gz -o "$CACHE"
|
||||
fi
|
||||
fi
|
||||
[ -s "$CACHE" ] || { echo "FAIL: 下载失败: $CACHE"; exit 2; }
|
||||
echo " tarball: $CACHE ($(du -h "$CACHE" | cut -f1))"
|
||||
|
||||
# 5b) 解压 + configure + make (普通 user 跑, 输出到临时目录)
|
||||
TMPSRC=$(mktemp -d)
|
||||
trap 'rm -rf -- "$TMPSRC"' EXIT
|
||||
tar xf "$CACHE" -C "$TMPSRC"
|
||||
cd "$TMPSRC/libmicrohttpd-1.0.1"
|
||||
source "${PS5_PAYLOAD_SDK}/toolchain/prospero.sh"
|
||||
export CFLAGS="${CFLAGS:-} -O1 -w"
|
||||
echo " configure (host=x86_64-pc-freebsd)..."
|
||||
./configure --prefix="/user/homebrew" \
|
||||
--host=x86_64-pc-freebsd \
|
||||
--enable-static --disable-shared \
|
||||
--disable-doc --disable-curl --disable-examples \
|
||||
--disable-https --disable-openssl \
|
||||
>/dev/null 2>&1
|
||||
echo " make -j$(nproc)..."
|
||||
make -j"$(nproc)" 2>&1 | tail -10
|
||||
|
||||
# 5c) install 到 STAGE (普通 user 写到 /tmp)
|
||||
rm -rf "$STAGE"
|
||||
mkdir -p "$STAGE"
|
||||
echo " make install DESTDIR=$STAGE ..."
|
||||
make install DESTDIR="$STAGE" 2>&1 | tail -5
|
||||
|
||||
# 5d) 验证 stage 里有产物
|
||||
if [ ! -f "$STAGE/user/homebrew/include/microhttpd.h" ]; then
|
||||
echo "FAIL: stage 中无 microhttpd.h -- stage 内容:"
|
||||
find "$STAGE" -maxdepth 4 -type f | head -10
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 5e) 一次性 sudo 拷到 SDK
|
||||
echo " sudo cp stage -> SDK homebrew..."
|
||||
sudo mkdir -p "$PS5_PAYLOAD_SDK/target/user/homebrew"
|
||||
sudo cp -r "$STAGE/user/homebrew/." "$PS5_PAYLOAD_SDK/target/user/homebrew/"
|
||||
echo " 验证 prospero-pkg-config:"
|
||||
"$PS5_PAYLOAD_SDK/bin/prospero-pkg-config" --modversion libmicrohttpd
|
||||
"$PS5_PAYLOAD_SDK/bin/prospero-pkg-config" --libs libmicrohttpd
|
||||
fi
|
||||
|
||||
# 6. 编译 (不 make clean, 复用 zlib/minizip-ng OBJ 缓存)
|
||||
echo "[6/7] make all (增量编译, 复用第三方 obj)..."
|
||||
cd "$PROJ"
|
||||
export PS5_PAYLOAD_SDK="/opt/ps5-payload-sdk"
|
||||
# 仅当二进制缺失或源码变更才全量重编
|
||||
# 重要: 必须包含 assets/* 和 gen-asset-module.py —— 它们经 gen-asset-module.py
|
||||
# 生成 gen/*.c 进而影响 ELF, 不在列表里就会跳过 make 产生伪"无变更"(v1.8.3
|
||||
# 被这个 bug 坑过, ELF sha256 没变)。
|
||||
if [ -f web-file-mgr.elf ]; then
|
||||
echo " 已存在 web-file-mgr.elf, 检查源码变更..."
|
||||
NEEDS_REBUILD=""
|
||||
for src in src/*.c Makefile assets/* gen-asset-module.py \
|
||||
third_party/minizip-ng/include/*.h third_party/zlib/include/*.h; do
|
||||
[ -e "$src" ] || continue
|
||||
if [ "$src" -nt web-file-mgr.elf ]; then
|
||||
NEEDS_REBUILD="$src"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$NEEDS_REBUILD" ]; then
|
||||
echo " 无源码变更 -- 跳过 make"
|
||||
else
|
||||
echo " 源码变更: $NEEDS_REBUILD"
|
||||
if ! make all 2>&1 | tail -60; then
|
||||
echo "FAIL: make all 失败(见上)。注意: 旧 ELF 仍留在原地, 但不算新产物"
|
||||
exit 6
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if ! make all 2>&1 | tail -60; then
|
||||
echo "FAIL: make all 失败(见上)"
|
||||
exit 6
|
||||
fi
|
||||
fi
|
||||
|
||||
# 7. 验证 + 同步回 Windows
|
||||
echo "[7/7] 验证产物 + 同步..."
|
||||
ELF="$PROJ/web-file-mgr.elf"
|
||||
if [ ! -f "$ELF" ]; then
|
||||
echo "FAIL: ELF 未生成 -- 日志: $LOG"
|
||||
# 把日志同步到 Windows .build/ 方便排查
|
||||
cp -f "$LOG" "$SRC_WIN/.build/build-elf.log" 2>/dev/null && \
|
||||
echo " 日志已同步: $SRC_WIN/.build/build-elf.log"
|
||||
exit 3
|
||||
fi
|
||||
SIZE=$(stat -c%s "$ELF")
|
||||
HASH=$(sha256sum "$ELF" | cut -d' ' -f1)
|
||||
echo "OK: $ELF"
|
||||
echo " size: $SIZE bytes (~$((SIZE/1024)) KiB)"
|
||||
echo " sha256: $HASH"
|
||||
echo " header bytes (期望 ELF64 magic 7f454c46 + class 2 + little-endian 1 + e_machine 0x003e=x86-64):"
|
||||
head -c 20 "$ELF" | xxd | head -2
|
||||
echo ""
|
||||
# 用 od 直接读 offset 18 起的 1 个 16-bit 小端 word = e_machine
|
||||
EM_RAW=$(od -An -tx2 -j 18 -N 2 "$ELF" | tr -d ' \n') # e.g. "3e00"
|
||||
EM_NUM=$((16#${EM_RAW})) # decimal
|
||||
echo "e_machine = 0x$EM_RAW ($EM_NUM)"
|
||||
case "$EM_NUM" in
|
||||
62) echo " -> x86-64 (PS5 真机 target: x86_64-sie-ps5)" ;;
|
||||
183) echo " -> aarch64 (注意: PS5 实际是 x86-64, 此结果可疑)" ;;
|
||||
*) echo " -> 未知架构 (期望 62=0x3e, 当前 e_machine=$EM_NUM)" ;;
|
||||
esac
|
||||
|
||||
# 同步 ELF 回项目根
|
||||
mkdir -p "$SRC_WIN" 2>/dev/null
|
||||
cp -f "$ELF" "$SRC_WIN/web-file-mgr.elf" 2>&1 && \
|
||||
echo "" && echo "[bonus] 已同步回: $SRC_WIN/web-file-mgr.elf" && \
|
||||
ls -lh "$SRC_WIN/web-file-mgr.elf"
|
||||
@@ -0,0 +1,51 @@
|
||||
"""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
|
||||
|
||||
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",
|
||||
]
|
||||
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)}")
|
||||
for k, v in seen.items():
|
||||
print(f" ✓ {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]}")
|
||||
@@ -0,0 +1,29 @@
|
||||
import re, sys
|
||||
f = sys.argv[1]
|
||||
data = open(f, "rb").read()
|
||||
runs = re.findall(rb"[\x20-\x7e]{6,}", data)
|
||||
hits = set()
|
||||
keys = [
|
||||
b"zipx_limits_profile",
|
||||
b"k_large_limits",
|
||||
b"ZIPX_LIMITS_LARGE",
|
||||
b"ZIPX_LIMITS_DEFAULT",
|
||||
b"large-file profile",
|
||||
b"large-file",
|
||||
b"LargeFile",
|
||||
b"large_file",
|
||||
b"extractLargeAsk",
|
||||
b"extractLargeActive",
|
||||
b"large",
|
||||
]
|
||||
for r in runs:
|
||||
for k in keys:
|
||||
if k in r and k.decode() not in hits:
|
||||
# Trim the context a bit
|
||||
i = r.find(k)
|
||||
ctx = r[max(0, i - 8):i + len(k) + 24].strip()
|
||||
if len(ctx) < 80:
|
||||
hits.add(ctx.decode(errors="replace"))
|
||||
for h in sorted(hits):
|
||||
print(repr(h))
|
||||
print("total:", len(hits))
|
||||
@@ -1,4 +1,25 @@
|
||||
gen/
|
||||
web-file-mgr.elf
|
||||
# Build artifacts
|
||||
*.elf
|
||||
*.o
|
||||
*.d
|
||||
gen/
|
||||
web-file-mgr-linux
|
||||
|
||||
# Sandbox probe scratch under .build/ (the tracked items there are
|
||||
# build-elf.{sh,log}, check-elf-*.py and extract-demo.html; everything
|
||||
# else is local exploration that should not enter the repo).
|
||||
.build/host-test/
|
||||
.build/obj/
|
||||
.build/probe-work/
|
||||
.build/tp/
|
||||
.build/stub/
|
||||
.build/*.exe
|
||||
.build/*.c
|
||||
.build/test-out.txt
|
||||
|
||||
# Editor / OS noise
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,504 @@
|
||||
# Changelog
|
||||
|
||||
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:
|
||||
> `web-file-mgr.elf` — size 919 440 bytes (~897 KiB)
|
||||
> sha256 `bb8f17e9addc6a9984f611503ca01b51f8984b773d353630da1f25bd1a28a997`
|
||||
> ELF class 64, little-endian, e_machine `0x003e` (x86_64-sie-ps5)
|
||||
>
|
||||
> Source delta vs v1.8.3: RAR engine replaced (dmc_unrar 1.7.0 → rarlab
|
||||
> UnRAR 7.20.1, `third_party/unrar/` → `third_party/unrar7/`), new
|
||||
> `src/rar_extract.c` scan/extract implementation, Makefile + host-test
|
||||
> C++ rules, 5 real RAR fixtures committed. See [v1.9] below.
|
||||
>
|
||||
> Release artifact for v1.8.3:
|
||||
> `web-file-mgr.elf` — size 509 704 bytes (~497 KiB)
|
||||
> sha256 `fdcf7b09b69e2160e77dfa084c0e890ba0696d4dd478b1d5ff499cdc9f527955`
|
||||
> ELF class 64, little-endian, e_machine `0x003e` (x86_64-sie-ps5)
|
||||
>
|
||||
> Source delta vs v1.8.2: 5 files touched (4 user-facing + 1 build pipeline) —
|
||||
> see [v1.8.3] below for details.
|
||||
>
|
||||
> Release artifact for v1.8.2:
|
||||
> `web-file-mgr.elf` — size 509 704 bytes (~497 KiB)
|
||||
> sha256 `1b2c3d68b35e32737105f17d14a80a3c159ceca0cabd274ee168cbcd81906f65`
|
||||
> ELF class 64, little-endian, e_machine `0x003e` (x86_64-sie-ps5)
|
||||
>
|
||||
> Source delta vs v1.8.1: 5 files relaxed (`src/zip_extract.c` k_default_limits
|
||||
> + k_large_limits, `assets/main.js` `LARGE_FILE_THRESHOLD_BYTES`,
|
||||
> `assets/lang-{en,zh}.js` copy, `tests/test_zip_extract.c` advertised-number
|
||||
> assertion, README/HANDOVER numeric references) + 2 PS5-only build fixes
|
||||
> (`Makefile` CFLAGS `-Ithird_party/unrar`, `src/extract.c` forward
|
||||
> declaration of `extract_progress`); no vendored or engine changes.
|
||||
|
||||
## [v1.9] — 2026-09-05
|
||||
|
||||
**RAR engine replaced: rarlab UnRAR 7.20.1 (v6 / multi-volume / decryption-capable).**
|
||||
|
||||
The vendored dmc_unrar 1.7.0 only dispatched RAR5 compression version 5
|
||||
(`switch(file->version)` case `0x5000`). Archives written by WinRAR 6.x /
|
||||
7.x (algorithm string `v6`, version field `0x5001`) hit the default branch
|
||||
and surfaced as "corrupt archive" — confirmed on a real `v6:8M` archive.
|
||||
v1.9 swaps in the official rarlab UnRAR source (7.20.1) via its
|
||||
C-compatible DLL API, compiled as a static library (`-DRARDLL`, PS5 uses
|
||||
the toolchain's default `libc++`).
|
||||
|
||||
What this enables:
|
||||
|
||||
- **RAR5 "v6" compression** (WinRAR 6/7 archives) — the v1.9 trigger.
|
||||
- **Multi-volume RAR** (`.partNN.rar`): unrar stitches volumes by name when
|
||||
all parts sit next to the opened volume. Select the first volume
|
||||
(`name.part1.rar`) and extract as usual.
|
||||
- RAR4 and older RAR5 remain supported.
|
||||
- The engine *can* decrypt encrypted archives (`RARSetPassword`), but the
|
||||
password channel (API + UI) is not wired yet — encrypted headers/entries
|
||||
still fail up front with `err_extract_unsupported`. Planned for a follow-up.
|
||||
|
||||
Engine changes:
|
||||
|
||||
- `src/rar_extract.c` rewritten to unrar's sequential DLL API
|
||||
(`RAROpenArchiveEx → RARReadHeaderEx → RARProcessFile`); scan and extract
|
||||
each re-open the archive. Multi-volume continuation segments
|
||||
(`RHDF_SPLITBEFORE`) are advanced but not re-counted/deduped.
|
||||
- The three-phase scan → staging → publish/rollback machinery is unchanged.
|
||||
- Bug fix: `normalize_name()` no longer clears the caller's directory flag
|
||||
(a real v6 archive with an explicit directory header after its files
|
||||
tripped the duplicate detector).
|
||||
|
||||
Build & test:
|
||||
|
||||
- Makefile: `.cpp` rules for the unrar RARDLL source set (49 files, mirrors
|
||||
`UnRARDll.vcxproj`); links through the C++ driver; `VERSION_TAG` v1.9.
|
||||
- tests: 5 real RAR fixtures committed under `tests/fixtures-real/`
|
||||
(generated with `tests/make-rar-fixtures.bat` + WinRAR); new happy-path
|
||||
checks extract a real v6 archive, verify files on disk, auto-merge a
|
||||
3-volume split, and reject encrypted archives. Total: **70 ZIP + 24 RAR
|
||||
= 94 checks** (up from 70 + 14; the old 14 RAR checks never ran a real
|
||||
archive).
|
||||
|
||||
Credits: unrar (c) Alexander Roshal, freeware license — see
|
||||
`third_party/unrar7/license.txt` and `THIRD_PARTY_NOTICES`.
|
||||
|
||||
## [v1.8.3] — 2026-09-05
|
||||
|
||||
**Hotfix: "Upload and extract" now accepts `.rar` files.**
|
||||
|
||||
The "upload and extract" entry was hard-coded to accept only `.zip`,
|
||||
even though the server-side dispatch in `src/extract.c:79` already
|
||||
correctly routes `.rar` to `rar_extract()`. v1.8.3 fixes the frontend
|
||||
filter so users can select a single-volume plaintext `.rar` from the
|
||||
file picker and have it uploaded + extracted in one click (the same
|
||||
flow that already worked for `.zip`).
|
||||
|
||||
What this enables:
|
||||
|
||||
- Choose a single-volume `.rar` from "Upload and extract"
|
||||
- Server extracts it via the existing `rar_extract()` engine
|
||||
- Uploaded `.rar` is auto-deleted after a successful extract (same as
|
||||
`.zip` since v1.7)
|
||||
|
||||
What this does **not** enable (planned for v1.9.0):
|
||||
|
||||
- **Multi-volume RAR** (e.g. `name.part01.rar` + `name.part02.rar` …)
|
||||
- **Encrypted RAR** (password-protected headers or entries)
|
||||
|
||||
Both still return `extract_unsupported` "single-volume RAR only" /
|
||||
"encrypted RAR is not supported; please extract on a PC first" — see
|
||||
the underlying engine limit in `third_party/unrar/dmc_unrar` (GPL-2.0,
|
||||
1.7.0). v1.9 will swap the vendor to **opello/unrar 7.20.1** (UnRAR
|
||||
License) which natively supports both.
|
||||
|
||||
Changed:
|
||||
|
||||
- `assets/index.html` — `<input id="uploadZip" accept>` now lists
|
||||
`.rar` + the two RAR MIME types next to the existing ZIP entries.
|
||||
- `assets/main.js:2340` — `/\.zip$/i` → `/\.(zip|rar)$/i` (the upload
|
||||
pre-check), plus a local `isRar` flag so the next step branches.
|
||||
- `assets/lang-en.js` — `extractUploadConfirm`: "uploaded ZIP" →
|
||||
"uploaded archive".
|
||||
- `assets/lang-zh.js` — `extractUploadConfirm` & `extractLargeAsk`
|
||||
drop the "ZIP" wording so the copy reads sensibly for RAR uploads.
|
||||
- `assets/main.js:38` — `APP_VERSION` `"v1.7"` → `"v1.8.3"` (footer
|
||||
version string had been hard-coded to v1.7 since the frontend was
|
||||
first imported; it no longer misleads about which build is running).
|
||||
|
||||
No backend changes — the server side was already correct. No test
|
||||
changes — the existing RAR happy-path test in `tests/test_rar_extract.c`
|
||||
passes against the same backend.
|
||||
|
||||
Build pipeline (also v1.8.3):
|
||||
|
||||
- `.build/build-elf.sh` step 6 "no source change → skip make" check now
|
||||
also watches `assets/*` and `gen-asset-module.py`, not just `src/*.c`
|
||||
and the `Makefile`. Without this, v1.8.3 (which touched no backend,
|
||||
only frontend assets feeding `gen/*.c`) was misclassified as "no
|
||||
change" and `make` was skipped — the result was that the v1.8.2 ELF
|
||||
was reported as v1.8.3 with the same sha256. With this fix, only
|
||||
frontend changes correctly trigger a rebuild. Users running the WSL
|
||||
build need to re-copy `.build/build-elf.sh` to `/home/song/build-elf.sh`
|
||||
(canonical source is on the Windows side).
|
||||
|
||||
## [v1.8.2] — 2026-09-05
|
||||
|
||||
**Hotfix: default ZIP extraction limits cover 3A-game single-file archives.**
|
||||
|
||||
The default `k_default_limits` profile is now **2 TiB total / 512 GiB per
|
||||
entry / 500 : 1 ratio** (was 1 TiB / 256 GiB / 500 : 1 in v1.8.1). The
|
||||
frontend threshold `LARGE_FILE_THRESHOLD_BYTES` is bumped from 240 GiB to
|
||||
**480 GiB** to match. The `large=1` profile is widened to **4 TiB total
|
||||
/ 1 TiB per entry / 1000 : 1 ratio** (was 2 TiB / 1 TiB / 1000 : 1); the
|
||||
large profile must always be strictly more permissive than default.
|
||||
|
||||
Why: a 3A-game archive with a single ~300 GiB uncompressed file was
|
||||
**silently rejected by the default profile** (`scan_archive` returns
|
||||
`ZIPX_ERR_LIMIT_FILE_SIZE` in `src/zip_extract.c` line ~717 — the request
|
||||
never reaches the frontend confirmation prompt, so the user just sees
|
||||
"卡壳"). The 256 GiB default cap was tuned for PS5 system backups (which
|
||||
have many smaller entries, not a single huge file) and was wrong for the
|
||||
3A-game single-file case. The default cap is now 512 GiB so a typical
|
||||
3A archive extracts under the default profile without prompting.
|
||||
|
||||
The safety argument is unchanged from v1.8.1: 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.
|
||||
|
||||
RAR extraction inherits the new defaults automatically — rar_extract.c
|
||||
threads `c->limits` through from the engine, so no rar-side change is
|
||||
required.
|
||||
|
||||
### Changed
|
||||
|
||||
- `src/zip_extract.c` — `k_default_limits` relaxed:
|
||||
- `max_total_bytes`: 1 TiB → **2 TiB**
|
||||
- `max_file_bytes`: 256 GiB → **512 GiB**
|
||||
- `max_ratio`: 500 → **500** (unchanged)
|
||||
- `src/zip_extract.c` — `k_large_limits` widened (must stay > default):
|
||||
- `max_total_bytes`: 2 TiB → **4 TiB**
|
||||
- `max_file_bytes`: 1 TiB → **1 TiB** (unchanged)
|
||||
- `max_ratio`: 1000 → **1000** (unchanged)
|
||||
- `assets/main.js` — `LARGE_FILE_THRESHOLD_BYTES`: 240 GiB → **480 GiB**
|
||||
- `assets/lang-{en,zh}.js` — `extractLargeAsk` copy updated to reflect
|
||||
the new numbers (default 512 GiB / 2 TiB; large 1 TiB / 4 TiB)
|
||||
- `tests/test_zip_extract.c` — `test_large_profile` advertised-number
|
||||
assertion updated: `max_total_bytes == 4 TiB` (was 2 TiB)
|
||||
- `README.md` — both limit tables (ZIP + RAR), the "Tuning the threshold"
|
||||
snippet, and the `err_extract_entry_too_large` FAQ entry bumped to the
|
||||
new numbers
|
||||
- `docs/HANDOVER.md` — `LARGE_FILE_THRESHOLD_BYTES`, the
|
||||
`k_default_limits` / `k_large_limits` ASCII diagram, the user-scenario
|
||||
description, the 480 GiB popup note, and the RAR limits paragraph
|
||||
bumped to the new numbers
|
||||
|
||||
### Unchanged
|
||||
|
||||
- `src/rar_extract.c` — already threads `c->limits` from the engine,
|
||||
picks up the new defaults for free
|
||||
- `max_ratio` — both profiles unchanged (500 : 1 default / 1000 : 1 large)
|
||||
- `check_space()` — unchanged; still the real disk-space guard
|
||||
- `max_entries` — 200 000 default / 500 000 large, unchanged
|
||||
- `docs/UPGRADE-v1.7-zip-large-file-profile.md` — historical v1.7
|
||||
document left as-is so the v1.7 → v1.8.2 evolution is traceable
|
||||
- Test fixture `medium_bomb.zip` (ratio ≈ 238) still exercises both
|
||||
rejection under the default 500 : 1 cap and acceptance under the
|
||||
`large=1` 1000 : 1 cap
|
||||
- 84 host-side checks (70 ZIP + 14 RAR), 0 failures
|
||||
|
||||
### Migration notes
|
||||
|
||||
- **Forward-compatible** — users with v1.8.1 deployments who never trigger
|
||||
`err_extract_entry_too_large` see no difference (defaults are strictly
|
||||
more permissive)
|
||||
- **3A-game single-file archives now extract silently** — no prompt, no
|
||||
manual `large=1` API call required for files up to 512 GiB
|
||||
- **No data loss** — the relaxation only widens accepted archives; the
|
||||
real security guards (`check_space`, `max_ratio`, `path traversal`)
|
||||
are untouched
|
||||
- **No frontend UX change for typical use** — only archives > 480 GiB
|
||||
on disk now trigger the confirmation prompt (previously 240 GiB)
|
||||
|
||||
---
|
||||
|
||||
## [v1.8.1] — 2026-09-05
|
||||
|
||||
**Hotfix: relaxed default ZIP extraction limits.**
|
||||
|
||||
The default `k_default_limits` profile is now **1 TiB total / 256 GiB per
|
||||
entry / 500 : 1 ratio** (was 512 GiB / 64 GiB / 200 : 1). The frontend
|
||||
threshold `LARGE_FILE_THRESHOLD_BYTES` is bumped from 60 GiB to 240 GiB
|
||||
to match. The `large=1` profile (1 TiB / 1 TiB / 1000 : 1) is unchanged.
|
||||
|
||||
Why: the previous default was a UX-oriented early-fail guard, not a
|
||||
security guard — `check_space()` already enforces available ≥ bytes_total
|
||||
before staging begins, and `max_ratio` already rejects classic zip
|
||||
bombs. A user with a multi-hundred-GiB system image shouldn't have to
|
||||
click through a confirmation prompt for what's a perfectly safe archive.
|
||||
The relaxed default still rejects any archive whose declared
|
||||
uncompressed total exceeds the destination's free space (real check,
|
||||
not a declared-vs-fs assertion) and any archive with a declared ratio
|
||||
above 500 : 1 (real zip-bomb guard).
|
||||
|
||||
RAR extraction inherits the new defaults automatically — rar_extract.c
|
||||
threads `c->limits` through from the engine, so no rar-side change is
|
||||
required.
|
||||
|
||||
### Changed
|
||||
|
||||
- `src/zip_extract.c` — `k_default_limits` relaxed:
|
||||
- `max_total_bytes`: 512 GiB → **1 TiB**
|
||||
- `max_file_bytes`: 64 GiB → **256 GiB**
|
||||
- `max_ratio`: 200 → **500**
|
||||
- `assets/main.js` — `LARGE_FILE_THRESHOLD_BYTES`: 60 GiB → **240 GiB**
|
||||
- `assets/lang-{en,zh}.js` — `extractLargeAsk` default-profile copy
|
||||
updated to reflect the new numbers
|
||||
- `README.md` — "Stricter default ZIP profile" line, the limit table
|
||||
(two locations), and the `err_extract_entry_too_large` FAQ entry
|
||||
bumped to the new numbers; "Tuning the threshold" snippet updated to
|
||||
240 GiB
|
||||
- `docs/HANDOVER.md` and `docs/UPGRADE-v1.8-rar-support.md` — the
|
||||
few remaining numeric references in those docs updated
|
||||
|
||||
### Unchanged
|
||||
|
||||
- `src/rar_extract.c` — already threads `c->limits` from the engine,
|
||||
picks up the new defaults for free
|
||||
- `k_large_limits` — `large=1` profile (1 TiB / 1 TiB / 1000 : 1) is
|
||||
unchanged
|
||||
- `docs/UPGRADE-v1.7-zip-large-file-profile.md` — historical v1.7
|
||||
document left as-is so the v1.7 → v1.8.1 evolution is traceable
|
||||
- Test fixture `medium_bomb.zip` (ratio ≈ 238) still exercises both
|
||||
rejection under the default 500 : 1 cap and acceptance under the
|
||||
`large=1` 1000 : 1 cap
|
||||
- 83 host-side checks (69 ZIP + 14 RAR), 0 failures
|
||||
|
||||
### Migration notes
|
||||
|
||||
- **Forward-compatible** — users with v1.7 / v1.8 deployments who never
|
||||
trigger `err_extract_entry_too_large` see no difference (defaults are
|
||||
strictly more permissive)
|
||||
- **No data loss** — the relaxation only widens accepted archives; the
|
||||
real security guards (`check_space`, `max_ratio`, `path traversal`)
|
||||
are untouched
|
||||
- **No frontend UX change for typical use** — only archives > 240 GiB
|
||||
on disk now trigger the confirmation prompt (previously 60 GiB)
|
||||
|
||||
---
|
||||
|
||||
## [v1.8] — 2026-09-05
|
||||
|
||||
**RAR extraction: single-volume RAR4 / RAR5 (unencrypted).**
|
||||
|
||||
> ⚠️ Scope clarification — v1.8 ships **single-volume unencrypted RAR** only.
|
||||
> The original RAR wishlist (multi-volume `.partNN.rar`, encrypted RAR with
|
||||
> password UI) is **deferred to v1.9**; see `docs/UPGRADE-v1.8-rar-support.md`
|
||||
> and `third_party/unrar/VENDORED.md` for the rationale and the engine
|
||||
> upgrade path. ZIP behaviour and the large-file profile are unchanged.
|
||||
|
||||
### Added
|
||||
|
||||
- **`src/rar_extract.{c,h}`** — a new extraction engine that mirrors
|
||||
`zip_extract`'s protocol exactly. Internally it wraps the vendored
|
||||
`dmc_unrar` 1.7.0 (`third_party/unrar/dmc_unrar.c`). The public entry point
|
||||
is `rar_extract(rar_path, dst_dir, conflict, limits, cancel, progress,
|
||||
userdata, result)` — same signatures, same `zipx_status_t` codes, same
|
||||
`zipx_result_t`, same `zipx_limits_t` profile lookup. ~1276 LOC
|
||||
(`src/rar_extract.c`).
|
||||
- **`third_party/unrar/`**:
|
||||
- `dmc_unrar.c` — vendored verbatim from upstream (11 598 LOC, ~365 KiB).
|
||||
GPL-2.0-or-later, attributed in `THIRD_PARTY_NOTICES`.
|
||||
- `dmc_unrar_api.h` — **project-authored facade header**. Re-declares only
|
||||
the `dmc_unrar_*` symbols `rar_extract.c` actually uses, so the engine
|
||||
can `#include "dmc_unrar_api.h"` instead of `#include "dmc_unrar.c"`.
|
||||
This keeps the vendored `.c` compiling as its own translation unit and
|
||||
avoids polluting dmc_unrar's struct / function names with any
|
||||
build-system macros (see `tests/posix_compat.h`'s `wfm_open` /
|
||||
`wfm_close` rename pattern — that conflict is what motivated the
|
||||
facade). The facade carries the project's license; the library body
|
||||
remains unmodified.
|
||||
- `COPYING`, `README.md` — upstream GPL notice + readme.
|
||||
- `VENDORED.md` — explains why we chose `dmc_unrar` over rarlab UnRAR /
|
||||
`opello/unrar`, what is and is not supported, and gives a step-by-step
|
||||
upgrade plan for moving to a fuller C++ UnRAR in v1.9.
|
||||
- **`src/extract.c` dispatch layer** — `extract_dispatch()` picks the
|
||||
engine by extension (`.zip` → `zipx_extract`, `.rar` → `rar_extract`,
|
||||
anything else → `ZIPX_ERR_UNSUPPORTED`). The case-insensitive suffix
|
||||
matcher trims trailing path separators. `extract_worker` now calls the
|
||||
dispatch instead of going straight to the ZIP engine.
|
||||
- **Frontend + i18n wiring**:
|
||||
- `assets/main.js` recognises `.rar` (single-volume) and `.part0*1.rar`
|
||||
(multi-volume master) as extractable, greys out the extract button on
|
||||
`.part02+.rar` sub-volumes with a tooltip "select the main volume
|
||||
instead". This UX is only useful because v1.8 still rejects
|
||||
multi-volume RAR with a friendly error — the visual feedback stops the
|
||||
user from selecting a sub-volume and getting confused.
|
||||
- `assets/lang-{en,zh}.js` `err_extract_unsupported` updated to:
|
||||
"…(only unencrypted plain ZIP and single-volume RAR are supported)…".
|
||||
- **Host test suite** (`tests/test_rar_extract.c`, **14 checks**) —
|
||||
negative paths only (format dispatch, error translation, limits
|
||||
handoff). The suite is wired into `tests/run-tests.sh` alongside the
|
||||
existing ZIP suite; fixture generation falls back to a placeholder
|
||||
blob when no `rar` / `7z` writer is present, so the negative tests
|
||||
fire on any host. Total host checks: **69 ZIP + 14 RAR = 83**.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`Makefile`**:
|
||||
- `VERSION_TAG := v1.8` (was `v1.7`).
|
||||
- `THIRD_PARTY_SRCS` adds `third_party/unrar/dmc_unrar.c`.
|
||||
- `THIRD_PARTY_CFLAGS` adds `-Ithird_party/unrar` and
|
||||
`-DDMC_UNRAR_DISABLE_BE32TOH_BE64TOH=1` (dmc_unrar's own byte-swap
|
||||
helpers are avoided so we don't need an extra `byteswap.h` shim on
|
||||
the SDK).
|
||||
- **`src/extract.c` / `src/extract.h`** — no public-API break. The HTTP
|
||||
surface (`POST /api/extract`) accepts the same fields as v1.7 plus
|
||||
the existing `large=1`; there is **no** `password=` field because
|
||||
v1.8 cannot decrypt. (The wire format is forward-compatible — a v1.9
|
||||
`password=` field will be additive.)
|
||||
- **`THIRD_PARTY_NOTICES`** — adds a section `3. dmc_unrar` crediting
|
||||
Sven Hesse (DrMcCoy), summarising the GPL-2.0-or-later obligations on
|
||||
the resulting binary, and noting that `dmc_unrar_api.h` is
|
||||
project-authored and licensed with the project.
|
||||
|
||||
### Limitations (v1.8 scope)
|
||||
|
||||
- **Multi-volume RAR** (`.part02+.rar`, `.part1+.rar`, numbered
|
||||
continuations) is rejected with `ZIPX_ERR_UNSUPPORTED` and the error
|
||||
message "extract on a PC first". Upstream dmc_unrar does not chain
|
||||
companion volumes by design. When opello/unrar replaces dmc_unrar
|
||||
in v1.9 this becomes a one-line error-code drop.
|
||||
- **Encrypted RAR** (any encrypted header / file flag) is rejected with
|
||||
`ZIPX_ERR_UNSUPPORTED`. Same root cause — dmc_unrar omits decryption
|
||||
to avoid patent complications. There is **no** password prompt in
|
||||
the UI; there is **no** `password=` field in `/api/extract`.
|
||||
- **Symbolic links, FIFOs, sockets, devices** inside a RAR archive are
|
||||
rejected with `ZIPX_ERR_SPECIAL` (mirrors ZIP behaviour).
|
||||
- **RAR 1.4** (very old) is not supported by dmc_unrar and is rejected
|
||||
upstream with `DMC_UNRAR_ARCHIVE_VERSION_UNSUPPORTED`; the wrapper
|
||||
maps that to `ZIPX_ERR_UNSUPPORTED`. RAR 1.5 through RAR 5.0 are
|
||||
supported.
|
||||
|
||||
### Verification
|
||||
|
||||
```sh
|
||||
make # builds web-file-mgr.elf (in WSL)
|
||||
ls -la web-file-mgr.elf # record size
|
||||
sha256sum web-file-mgr.elf # record digest (paste into the v1.8 banner above)
|
||||
file web-file-mgr.elf # ELF 64-bit LSB pie, x86-64
|
||||
od -An -tx1 -N20 web-file-mgr.elf # 7f45 4c46 0201 + e_machine 003e
|
||||
|
||||
(cd tests && bash run-tests.sh) # 69 + 14 = 83 checks, 0 failures
|
||||
|
||||
python3 .build/check-elf-gzip.py ./web-file-mgr.elf # 7/7 v1.7 keys + 1 v1.8 key (err_extract_unsupported)
|
||||
```
|
||||
|
||||
### Technical notes
|
||||
|
||||
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).
|
||||
|
||||
### Credits
|
||||
|
||||
Same as v1.7 — see [README.md → Credits](./README.md#credits). dmc_unrar
|
||||
is credited in [`THIRD_PARTY_NOTICES`](./THIRD_PARTY_NOTICES).
|
||||
|
||||
---
|
||||
|
||||
## [v1.7] — 2026-09-04
|
||||
|
||||
**ZIP extraction: large-file profile, three-phase engine, host test suite.**
|
||||
|
||||
### Added
|
||||
|
||||
- **Large-file profile** (`ZIPX_LIMITS_LARGE`) for ZIP extraction — relaxed
|
||||
caps of **500 000** entries, **2 TiB** total uncompressed, **1 TiB** per
|
||||
entry, **1000 : 1** compression ratio. Enable via the new `large=1`
|
||||
argument on `POST /api/extract`. The UI prompts the user automatically
|
||||
whenever the archive on disk is larger than 60 GiB (`LARGE_FILE_THRESHOLD_BYTES`).
|
||||
- **Standalone three-phase ZIP engine** (`src/zip_extract.{c,h}`) — the model
|
||||
`SCAN → EXTRACT → PUBLISH → CLEANUP`. Each entry is written into a staging
|
||||
directory first, fsynced, then atomically renamed into the destination. Any
|
||||
mid-archive failure rolls back partial changes.
|
||||
- **Profile lookup helper** `zipx_limits_profile(int)` and the new macros
|
||||
`ZIPX_LIMITS_DEFAULT` (0) and `ZIPX_LIMITS_LARGE` (1). The public
|
||||
`zipx_extract()` signature is unchanged.
|
||||
- **Opt-in API field** `large` on `POST /api/extract`, backed by a new
|
||||
`extract_large` flag in `file_task_t` (`src/filemgr_internal.h`,
|
||||
`src/extract.c`).
|
||||
- **Frontend wiring** (`assets/main.js`) — `LARGE_FILE_THRESHOLD_BYTES`,
|
||||
`shouldPromptLargeMode()`, `promptLargeMode()`, consumed by
|
||||
`actionExtract()` and `uploadAndExtractFile()`.
|
||||
- **Bilingual UI strings** (`assets/lang-{en,zh}.js`) —
|
||||
`extractLargeAsk` and `extractLargeActive`.
|
||||
- **Host-side C test suite** (`tests/`) — POSIX-runnable, no PS5 SDK
|
||||
required. **69 checks** at release: traversal, ZIP64, encryption rejection,
|
||||
conflict policies, large-file profile switching.
|
||||
- **Cross-compile helper** (`.build/build-elf.sh`) — staging-mode build that
|
||||
works around the read-only SDK install location.
|
||||
- **Demo page** (`.build/extract-demo.html`) — visualises the three policy
|
||||
combinations (fail / overwrite / merge) against the new profile table.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Engine internals** rewritten around the three-phase model. Public API
|
||||
(`zipx_extract()`, `zipx_default_limits()`) is unchanged — old callers
|
||||
compile and link clean.
|
||||
- **`file_task_t`** gains `extract_large` (`src/filemgr_internal.h`).
|
||||
Internal-only; downstream consumers reading the task struct need to
|
||||
recognise the new field.
|
||||
- **`gen-asset-module.py`** continues to gzip JS into the binary; the
|
||||
helpers in `.build/check-elf-gzip.py` are the supported way to verify that
|
||||
a fresh build picked up asset changes (regular `strings` won't see them).
|
||||
|
||||
### Security
|
||||
|
||||
- Default ZIP caps are unchanged: **512 GiB** total / **64 GiB** per entry /
|
||||
**200 : 1** ratio.
|
||||
- The large profile is **never** activated server-side on its own — the
|
||||
client must explicitly send `large=1` (either via the UI prompt or directly
|
||||
via the API).
|
||||
- Encryption, path traversal, symbolic links, FIFOs and unresolved conflicts
|
||||
are still rejected before any output file is opened.
|
||||
- Strict value matching: only the literal `"1"` enables the large profile;
|
||||
`true`, `yes`, `on` are all treated as `0`. Matches the existing
|
||||
`remove_source=` semantics.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Multi-volume / split ZIP archives (`.zip` + `.z01`, `.z02`, …) are not
|
||||
stitched by the engine. minizip-ng has the API; wiring it is post-v1.7.
|
||||
- The 60 GiB frontend threshold for the large-profile prompt is hardcoded
|
||||
(`LARGE_FILE_THRESHOLD_BYTES`, `assets/main.js` line 813).
|
||||
- The large profile does **not** run `statvfs()` against `dst_dir` before
|
||||
extraction; free-space preflight is on the post-v1.7 roadmap.
|
||||
- Bomb-shaped archives with compression ratio **> 1000 : 1** are rejected
|
||||
under both profiles (`bomb.zip` with 4 MiB of `'A'` has ratio ≈ 1026 and
|
||||
hits this wall under the large profile).
|
||||
|
||||
### Verification
|
||||
|
||||
```sh
|
||||
make # builds web-file-mgr.elf
|
||||
ls -la web-file-mgr.elf
|
||||
sha256sum web-file-mgr.elf # 648e4a00…
|
||||
file web-file-mgr.elf # ELF 64-bit LSB pie, x86-64
|
||||
od -An -tx1 -N20 web-file-mgr.elf # 7f45 4c46 0201 + e_machine 003e
|
||||
|
||||
(cd tests && bash run-tests.sh) # 69 checks, 0 failures
|
||||
|
||||
python3 .build/check-elf-gzip.py ./web-file-mgr.elf # 7/7 large-mode keys in ELF
|
||||
```
|
||||
|
||||
### Technical notes
|
||||
|
||||
A long-form technical write-up of this upgrade (architecture delta, three-phase
|
||||
model, behaviour contract, trade-offs, future work) lives in
|
||||
[`docs/UPGRADE-v1.7-zip-large-file-profile.md`](./docs/UPGRADE-v1.7-zip-large-file-profile.md).
|
||||
|
||||
### Credits
|
||||
|
||||
The same as v1.6 — see [README.md → Credits](./README.md#credits).
|
||||
@@ -1,43 +1,143 @@
|
||||
ifdef PS5_PAYLOAD_SDK
|
||||
ifneq ($(filter-out linux linux-deps clean,$(MAKECMDGOALS)),)
|
||||
ifdef PS5_PAYLOAD_SDK
|
||||
include $(PS5_PAYLOAD_SDK)/toolchain/prospero.mk
|
||||
else
|
||||
else
|
||||
$(error PS5_PAYLOAD_SDK is undefined)
|
||||
endif
|
||||
endif
|
||||
ifeq ($(MAKECMDGOALS),)
|
||||
ifdef PS5_PAYLOAD_SDK
|
||||
include $(PS5_PAYLOAD_SDK)/toolchain/prospero.mk
|
||||
else
|
||||
$(error PS5_PAYLOAD_SDK is undefined)
|
||||
endif
|
||||
endif
|
||||
|
||||
VERSION_TAG := v0.1
|
||||
VERSION_TAG := v1.9
|
||||
TITLE_ID := FMGR88888
|
||||
PYTHON ?= python3
|
||||
STRIP ?= $(PS5_PAYLOAD_SDK)/bin/prospero-strip
|
||||
PKG_CONFIG ?= $(PS5_PAYLOAD_SDK)/bin/prospero-pkg-config
|
||||
HOST_CC ?= cc
|
||||
HOST_STRIP ?= strip
|
||||
HOST_PKG_CONFIG ?= pkg-config
|
||||
|
||||
BIN := web-file-mgr.elf
|
||||
SRCS := src/main.c src/websrv.c src/filemgr.c src/asset.c src/mime.c
|
||||
SRCS += src/app_installer.c src/notify.c
|
||||
ASSETS := $(wildcard assets/*)
|
||||
GEN_SRCS := $(patsubst assets/%,gen/%, $(ASSETS:=.c))
|
||||
BIN := web-file-mgr.elf
|
||||
LINUX_BIN := web-file-mgr-linux
|
||||
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/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
|
||||
PS5_SRCS := $(COMMON_SRCS) src/app_installer.c src/cpu_support_stub.c
|
||||
LINUX_SRCS := $(COMMON_SRCS)
|
||||
BASE_ASSETS := $(filter-out %.dds,$(wildcard assets/*))
|
||||
ifneq ($(filter linux,$(MAKECMDGOALS)),)
|
||||
ASSETS := $(BASE_ASSETS)
|
||||
else
|
||||
ASSETS := $(filter-out assets/icon0.png,$(BASE_ASSETS))
|
||||
endif
|
||||
GEN_SRCS := $(patsubst assets/%,gen/%, $(ASSETS:=.c))
|
||||
|
||||
CFLAGS := -Oz -fno-asynchronous-unwind-tables -fno-unwind-tables -Wall -Werror -ffunction-sections -fdata-sections -Isrc -DVERSION_TAG=\"$(VERSION_TAG)\" -DTITLE_ID=\"$(TITLE_ID)\"
|
||||
# Vendored third-party: zlib + minizip-ng (ZIP, C) and unrar 7.20.1 (RAR,
|
||||
# C++). unrar sources are compiled as a static library in RARDLL mode (no
|
||||
# main()); the project talks to it through the extern "C" DLL API in
|
||||
# third_party/unrar7/unrar_c_api.h. Compiled with relaxed warnings (-w) —
|
||||
# these are not our code and we do not want to chase upstream style updates.
|
||||
#
|
||||
# C++ compilers: PS5 uses prospero-clang++ (FreeBSD-style sysroot; the
|
||||
# toolchain defaults to -stdlib=libc++, driver links libc++ automatically);
|
||||
# host builds use the plain host C++ compiler (libstdc++).
|
||||
CXX ?= $(dir $(CC))prospero-clang++
|
||||
HOST_CXX ?= c++
|
||||
|
||||
# Source set mirrors UnRARDll.vcxproj's ClCompile list (49 files) MINUS the
|
||||
# Windows-only isnt.cpp / motw.cpp (they need windows.h; the official unrar
|
||||
# UNIX makefile omits them, and PS5/linux both use the _UNIX branch where
|
||||
# their symbols are #ifdef'd out).
|
||||
UNRAR7_SRCS := \
|
||||
third_party/unrar7/archive.cpp third_party/unrar7/arcread.cpp third_party/unrar7/blake2s.cpp \
|
||||
third_party/unrar7/cmddata.cpp third_party/unrar7/consio.cpp third_party/unrar7/crc.cpp \
|
||||
third_party/unrar7/crypt.cpp third_party/unrar7/dll.cpp third_party/unrar7/encname.cpp \
|
||||
third_party/unrar7/errhnd.cpp third_party/unrar7/extinfo.cpp third_party/unrar7/extract.cpp \
|
||||
third_party/unrar7/filcreat.cpp third_party/unrar7/file.cpp third_party/unrar7/filefn.cpp \
|
||||
third_party/unrar7/filestr.cpp third_party/unrar7/find.cpp third_party/unrar7/getbits.cpp \
|
||||
third_party/unrar7/global.cpp third_party/unrar7/hash.cpp third_party/unrar7/headers.cpp \
|
||||
third_party/unrar7/largepage.cpp third_party/unrar7/match.cpp \
|
||||
third_party/unrar7/options.cpp third_party/unrar7/pathfn.cpp \
|
||||
third_party/unrar7/qopen.cpp third_party/unrar7/rar.cpp third_party/unrar7/rarpch.cpp \
|
||||
third_party/unrar7/rarvm.cpp third_party/unrar7/rawread.cpp third_party/unrar7/rdwrfn.cpp \
|
||||
third_party/unrar7/rijndael.cpp third_party/unrar7/rs.cpp third_party/unrar7/rs16.cpp \
|
||||
third_party/unrar7/scantree.cpp third_party/unrar7/secpassword.cpp third_party/unrar7/sha1.cpp \
|
||||
third_party/unrar7/sha256.cpp third_party/unrar7/smallfn.cpp third_party/unrar7/strfn.cpp \
|
||||
third_party/unrar7/strlist.cpp third_party/unrar7/system.cpp third_party/unrar7/threadpool.cpp \
|
||||
third_party/unrar7/timefn.cpp third_party/unrar7/ui.cpp third_party/unrar7/unicode.cpp \
|
||||
third_party/unrar7/unpack.cpp third_party/unrar7/volume.cpp
|
||||
|
||||
THIRD_PARTY_C_SRCS := $(wildcard third_party/zlib/src/*.c) $(wildcard third_party/minizip-ng/src/*.c)
|
||||
THIRD_PARTY_C_FLAGS := -O2 -w -Ithird_party/zlib/include -Ithird_party/minizip-ng/include \
|
||||
-DHAVE_ZLIB -DZLIB_COMPAT -DHAVE_UNISTD_H=1 -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE
|
||||
UNRAR7_CXX_FLAGS := -O2 -w -std=c++17 -DRARDLL -D_FILE_OFFSET_BITS=64 -D_LARGEFILE_SOURCE
|
||||
# prospero-clang++ defaults to -stdlib=libc++; state it explicitly for clarity.
|
||||
UNRAR7_CXX_FLAGS_PS5 := $(UNRAR7_CXX_FLAGS) -stdlib=libc++
|
||||
UNRAR7_CXX_FLAGS_HOST:= $(UNRAR7_CXX_FLAGS)
|
||||
|
||||
PS5_TP_OBJS := $(patsubst %.c,ps5-obj/%.o,$(THIRD_PARTY_C_SRCS)) \
|
||||
$(patsubst %.cpp,ps5-obj/%.o,$(UNRAR7_SRCS))
|
||||
LINUX_TP_OBJS := $(patsubst %.c,linux-obj/%.o,$(THIRD_PARTY_C_SRCS)) \
|
||||
$(patsubst %.cpp,linux-obj/%.o,$(UNRAR7_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 -DVERSION_TAG=\"$(VERSION_TAG)\" -DTITLE_ID=\"$(TITLE_ID)\"
|
||||
CFLAGS += `$(PKG_CONFIG) libmicrohttpd --cflags`
|
||||
LDFLAGS := -Wl,--gc-sections
|
||||
LDADD := `$(PKG_CONFIG) libmicrohttpd --libs`
|
||||
LDADD += -lSceIpmi -lSceAppInstUtil
|
||||
LDADD += -lSceIpmi -lSceAppInstUtil -lSceUserService
|
||||
LINUX_CFLAGS := -O2 -flto -Wall -Werror -Isrc -Ithird_party/minizip-ng/include -Ithird_party/unrar7 -DVERSION_TAG=\"$(VERSION_TAG)\" -DTITLE_ID=\"$(TITLE_ID)\"
|
||||
LINUX_CFLAGS += `$(HOST_PKG_CONFIG) libmicrohttpd --cflags`
|
||||
LINUX_LDADD := `$(HOST_PKG_CONFIG) libmicrohttpd --libs` -pthread
|
||||
|
||||
.PHONY: all deps clean
|
||||
.PHONY: all linux deps linux-deps clean
|
||||
|
||||
all: deps $(BIN)
|
||||
|
||||
linux: linux-deps $(LINUX_BIN)
|
||||
|
||||
deps:
|
||||
@$(PKG_CONFIG) --exists libmicrohttpd || ./install-libmicrohttpd.sh
|
||||
|
||||
linux-deps:
|
||||
@$(HOST_PKG_CONFIG) --exists libmicrohttpd || \
|
||||
(echo "libmicrohttpd development package is required for make linux" >&2; exit 1)
|
||||
|
||||
gen:
|
||||
mkdir gen
|
||||
|
||||
clean:
|
||||
rm -rf $(BIN) gen
|
||||
rm -rf $(BIN) $(LINUX_BIN) gen ps5-obj linux-obj
|
||||
|
||||
gen/%.c: assets/% gen-asset-module.py gen
|
||||
gen/%.c: assets/% gen-asset-module.py | gen
|
||||
$(PYTHON) gen-asset-module.py --path $* $< > $@
|
||||
|
||||
$(BIN): $(SRCS) $(GEN_SRCS)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(LDADD)
|
||||
ps5-obj/%.o: %.c
|
||||
@mkdir -p $(dir $@)
|
||||
$(CC) $(THIRD_PARTY_C_FLAGS) -c -o $@ $<
|
||||
|
||||
linux-obj/%.o: %.c
|
||||
@mkdir -p $(dir $@)
|
||||
$(HOST_CC) $(THIRD_PARTY_C_FLAGS) -c -o $@ $<
|
||||
|
||||
ps5-obj/%.o: %.cpp
|
||||
@mkdir -p $(dir $@)
|
||||
$(CXX) $(UNRAR7_CXX_FLAGS_PS5) -c -o $@ $<
|
||||
|
||||
linux-obj/%.o: %.cpp
|
||||
@mkdir -p $(dir $@)
|
||||
$(HOST_CXX) $(UNRAR7_CXX_FLAGS_HOST) -c -o $@ $<
|
||||
|
||||
# Link with the C++ driver so libc++ (PS5) / libstdc++ (host) is pulled in
|
||||
# automatically for the unrar objects. The project's own C sources are passed
|
||||
# through -x c (clang++ would otherwise compile .c files as C++ and trip
|
||||
# -Wdeprecated); -x none restores extension-based handling for the .o files.
|
||||
$(BIN): $(PS5_SRCS) $(GEN_SRCS) $(PS5_TP_OBJS)
|
||||
$(CXX) $(CFLAGS) $(LDFLAGS) -o $@ -x c $(filter %.c,$^) -x none $(PS5_TP_OBJS) $(LDADD)
|
||||
$(STRIP) $@
|
||||
|
||||
$(LINUX_BIN): $(LINUX_SRCS) $(GEN_SRCS) $(LINUX_TP_OBJS)
|
||||
$(HOST_CXX) $(LINUX_CFLAGS) -o $@ -x c $(filter %.c,$^) -x none $(LINUX_TP_OBJS) $(LINUX_LDADD)
|
||||
$(HOST_STRIP) $@
|
||||
@@ -1,97 +1,472 @@
|
||||
# PS5 Web File Manager
|
||||
|
||||
A file manager for PS5 with a web UI. It is primarily intended for quickly and
|
||||
safely copying game dump folders from USB storage to internal storage.
|
||||
> 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.
|
||||
|
||||
## Brief
|
||||
**Version:** v1.9 · **Title ID:** `FMGR88888` · **License:** GPLv3+ · **Target:** `x86_64-sie-ps5`
|
||||
|
||||
PS5 web file manager payload. It runs an HTTP UI starting at port `8888`, installs a home screen launcher on startup when needed, and provides file operations from the PS5 browser. If `8888` is already in use, the payload tries the next port until one is available; the startup notification shows the actual listen port.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
|
||||
The same source tree builds a Linux binary for development and a PS5 payload ELF for deployment — see `make linux` below.
|
||||
|
||||
## What's new in v1.9
|
||||
|
||||
- **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.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p>
|
||||
<a href="docs/screenshots/20260617_231827.376.jpg" target="_blank"><img src="docs/screenshots/20260617_231827.376.jpg" width="31%" alt="PS5 Web File Manager screenshot 1"></a>
|
||||
<a href="docs/screenshots/20260619_131432.399.jpg" target="_blank"><img src="docs/screenshots/20260619_131432.399.jpg" width="31%" alt="PS5 Web File Manager screenshot 2"></a>
|
||||
<a href="docs/screenshots/20260617_232348.855.jpg" target="_blank"><img src="docs/screenshots/20260617_232348.855.jpg" width="31%" alt="PS5 Web File Manager screenshot 3"></a>
|
||||
<a href="docs/screenshots/20260619_131811.644.jpg" target="_blank"><img src="docs/screenshots/20260619_131811.644.jpg" width="31%" alt="PS5 Web File Manager screenshot 4"></a>
|
||||
<a href="docs/screenshots/20260619_131535.239.jpg" target="_blank"><img src="docs/screenshots/20260619_131535.239.jpg" width="31%" alt="PS5 Web File Manager screenshot 5"></a>
|
||||
<a href="docs/screenshots/20260620_232728.533.jpg" target="_blank"><img src="docs/screenshots/20260620_232728.533.jpg" width="31%" alt="PS5 Web File Manager screenshot 6"></a>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- List files and folders.
|
||||
- Copy, move, delete, rename, and create folders.
|
||||
- Multi-select operations.
|
||||
- Copy/move by choosing sources first, then pasting or moving them into the current folder.
|
||||
- Conflict prompts for overwriting files and merging folders.
|
||||
- Full-screen task overlay with progress, speed, ETA, cancel support, and task recovery after reopening the browser while the payload process is still running.
|
||||
- Copied/moved files and folders are set to `0777` where the filesystem supports Unix permissions. FAT/exFAT-style filesystems may ignore chmod.
|
||||
- Chinese and English UI. The browser language is read from `navigator.languages` / `navigator.language`; Chinese uses `zh`, everything else uses English.
|
||||
- Startup notification showing the app name, version, and listen port.
|
||||
- **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`.
|
||||
- **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.
|
||||
|
||||
## Quickstart
|
||||
|
||||
1. **Build** the ELF:
|
||||
|
||||
```sh
|
||||
export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk # see "Build" for SDK setup
|
||||
make
|
||||
```
|
||||
2. **Send** the payload to the PS5 (default ELF-loader port `9021`):
|
||||
|
||||
```sh
|
||||
nc -q0 "$PS5_HOST" 9021 < web-file-mgr.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.
|
||||
|
||||
## Build
|
||||
|
||||
It depends on PS5 payload SDK first: [ps5-payload-dev/sdk](https://github.com/ps5-payload-dev/sdk#quick-start)
|
||||
Requires [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 script automatically if it is missing:
|
||||
This project links against `libmicrohttpd`. `make` checks for it before building and runs the installer automatically when missing:
|
||||
|
||||
```sh
|
||||
make
|
||||
```
|
||||
|
||||
If the build host has no network access, download the libmicrohttpd source tarball yourself and run the dependency installer once:
|
||||
If the build host has no network access, drop the libmicrohttpd tarball in advance and run the installer manually:
|
||||
|
||||
```sh
|
||||
LIBMICROHTTPD_TARBALL=/path/to/libmicrohttpd-1.0.1.tar.gz \
|
||||
./install-libmicrohttpd.sh
|
||||
```
|
||||
|
||||
Then build again:
|
||||
|
||||
```sh
|
||||
make
|
||||
```
|
||||
|
||||
The output is:
|
||||
Output:
|
||||
|
||||
```text
|
||||
web-file-mgr.elf
|
||||
web-file-mgr.elf (~several hundred KiB, larger in v1.9 with unrar; x86_64-sie-ps5)
|
||||
```
|
||||
|
||||
For pure UI/JS work without the PS5 toolchain:
|
||||
|
||||
```sh
|
||||
make linux
|
||||
./web-file-mgr-linux
|
||||
```
|
||||
|
||||
The Linux build does **not** include the PS5 home-screen launcher installer.
|
||||
|
||||
## Usage
|
||||
|
||||
Start an ELF loader on the PS5. The common listener port is `9021`.
|
||||
|
||||
Send the built payload with netcat or NetCat GUI:
|
||||
Start an ELF loader on the PS5 (port `9021` is common). Send the payload:
|
||||
|
||||
```sh
|
||||
export PS5_HOST=ps5_ip_address
|
||||
nc -q0 "$PS5_HOST" 9021 < web-file-mgr.elf
|
||||
```
|
||||
|
||||
After the payload starts, the PS5 notification shows the app name, version, and actual listen port. Open the shown URL in the PS5 browser, for example:
|
||||
After the payload starts, the PS5 notification shows the app name, version and actual listen port. Open the URL it prints, for example:
|
||||
|
||||
```text
|
||||
http://${PS5_IP_ADDRESS}:8888/
|
||||
```
|
||||
|
||||
On first startup the payload installs a `PS5 Web File Manager` web shortcut on the PS5 home screen when needed. If the payload had to use a fallback port such as `8889`, use the port shown in the startup notification.
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Tests
|
||||
|
||||
A POSIX/host-side C test suite covers the ZIP engine and runs on any Linux / macOS / MSYS shell without the PS5 SDK:
|
||||
|
||||
```sh
|
||||
cd tests && bash run-tests.sh
|
||||
```
|
||||
|
||||
Output is a per-case `check`-style report — **84 checks** on the current `main`
|
||||
(70 ZIP + 14 RAR). 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).
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
.
|
||||
├── Makefile # PS5 + Linux builds (VERSION_TAG v1.8.2)
|
||||
├── 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
|
||||
├── 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)
|
||||
├── docs/
|
||||
│ ├── HANDOVER.md # engineering handover / dev playbook (also §14 v1.8 close-out)
|
||||
│ ├── 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)
|
||||
├── LICENSE # GPLv3+
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Copy, move, and delete 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 existing files when merging into an existing target folder.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Credits
|
||||
|
||||
This project was built with reference to these projects:
|
||||
|
||||
- **[ps5-payload-dev/websrv](https://github.com/ps5-payload-dev/websrv):** HTTP server structure, static asset embedding ideas, and PS5 browser/websrv behavior. 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+.
|
||||
- **[itsPLK/ps5-payload-manager](https://github.com/itsPLK/ps5-payload-manager):** Payload building behavior. License: GPLv3.
|
||||
- **[libmicrohttpd](https://ftp.gnu.org/gnu/libmicrohttpd/):** Used as the embedded HTTP server library. It is licensed by GNU under the LGPL; this payload links it as the SDK-provided static library.
|
||||
- **[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+.
|
||||
- **[seregonwar/zftpd](https://github.com/seregonwar/zftpd):** PS5 TCP socket buffer tuning and high-throughput transfer behaviour reference. License: MIT.
|
||||
- **[itsPLK/ps5-payload-manager](https://github.com/itsPLK/ps5-payload-manager):** Payload building behaviour. License: GPLv3.
|
||||
- **[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.
|
||||
- **[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`.
|
||||
|
||||
## License
|
||||
|
||||
The project is distributed under GPLv3 or later, matching the GPLv3+ projects used as implementation references. See `LICENSE`.
|
||||
The project is distributed under **GPLv3 or later**, matching the GPLv3+ projects used as implementation references. See [`LICENSE`](./LICENSE).
|
||||
|
||||
Third-party projects retain their own licenses. Do not copy assets or source from the credited projects into another distribution without preserving the corresponding license notices.
|
||||
|
||||
If distributing binaries, comply with the LGPL terms for libmicrohttpd in addition to this project's GPL license.
|
||||
If distributing binaries, comply with the LGPL terms for `libmicrohttpd`
|
||||
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).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Unofficial homebrew software. Runs only on jailbroken PS5 consoles. Use at your own risk — the authors are not responsible for damage, data loss, account action or warranty impact. Do not redistribute Sony-proprietary content. Under GPLv3+, modified redistributions must publish their sources.
|
||||
@@ -0,0 +1,50 @@
|
||||
Third-Party Notices
|
||||
===================
|
||||
|
||||
This project vendors a minimal set of third-party source files under
|
||||
`third_party/` to provide the ZIP and RAR extraction features (the
|
||||
`/api/extract` endpoint). Their full license texts are included
|
||||
alongside the sources.
|
||||
|
||||
1. minizip-ng
|
||||
-----------
|
||||
Version : 4.2.2
|
||||
Source : https://github.com/zlib-ng/minizip-ng
|
||||
License : zlib (see third_party/minizip-ng/LICENSE)
|
||||
Files : third_party/minizip-ng/** (vendored subset, compiled with
|
||||
relaxed warnings into the final binary)
|
||||
|
||||
2. zlib
|
||||
------
|
||||
Version : 1.3.1
|
||||
Source : https://www.zlib.net/
|
||||
License : zlib (see third_party/zlib/LICENSE)
|
||||
Files : third_party/zlib/** (vendored subset, compiled with relaxed
|
||||
warnings into the final binary)
|
||||
|
||||
3. unrar (rarlab UnRAR source, v7.20.1)
|
||||
-------------------------------------
|
||||
Version : 7.20.1 (RAR 7.23-free source snapshot, 2025-10-28)
|
||||
Source : https://www.rarlab.com/rar_add.htm — mirrored by
|
||||
https://github.com/opello/unrar (commit 97e1780)
|
||||
License : UnRAR freeware license (see third_party/unrar7/license.txt)
|
||||
Files : third_party/unrar7/** (RARDLL source set compiled with
|
||||
relaxed warnings; unrar_c_api.h is project-authored and
|
||||
carries the project's license)
|
||||
|
||||
unrar is the engine behind `src/rar_extract.c` (the v1.9 RAR support: RAR4,
|
||||
RAR5 including WinRAR 6/7 "v6" compression, and multi-volume archives; the
|
||||
engine can also decrypt via RARSetPassword once a password channel is wired
|
||||
up). The UnRAR source may be used in any software to handle RAR archives,
|
||||
but may not be used to develop a RAR-compatible *archiver* or to re-create
|
||||
the RAR compression algorithm, which is proprietary.
|
||||
|
||||
(The v1.8 engine, DrMcCoy/dmc_unrar 1.7.0 under GPL-2.0-or-later, was
|
||||
replaced by the rarlab UnRAR source in v1.9; see git history under
|
||||
`third_party/unrar/` for its notice.)
|
||||
|
||||
Libraries in sections 1 and 2 are distributed under the zlib license, which
|
||||
permits redistribution in source and binary form provided the copyright
|
||||
notice and this list of conditions are retained. unrar is distributed under
|
||||
its own freeware terms. See the individual LICENSE / license.txt files in
|
||||
each `third_party/` subdirectory for the complete terms.
|
||||
|
After Width: | Height: | Size: 268 B |
|
After Width: | Height: | Size: 139 B |
|
After Width: | Height: | Size: 184 B |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -4,13 +4,35 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PS5 Web File Manager</title>
|
||||
<link rel="icon" type="image/png" href="/icon0.png">
|
||||
<style>
|
||||
html, body { margin: 0; min-height: 100%; background: #111316; color: #edf0f2; }
|
||||
.init-loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 20000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #111316;
|
||||
color: #aab4be;
|
||||
font: 18px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div id="path" class="path">/</div>
|
||||
<div id="spaceInfo" class="space-info"></div>
|
||||
<div class="top-left">
|
||||
<div id="path" class="path">/</div>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<div id="spaceInfo" class="space-info"></div>
|
||||
</div>
|
||||
<button id="exitBtn" class="icon-button power-button" type="button" aria-label="Exit"></button>
|
||||
</header>
|
||||
|
||||
@@ -19,35 +41,60 @@
|
||||
<button id="copyBtn" data-i18n="copy"></button>
|
||||
<button id="moveBtn" data-i18n="move"></button>
|
||||
<button id="renameBtn" data-i18n="rename"></button>
|
||||
<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="pasteBtn" class="paste-action" hidden>
|
||||
<span id="pasteVerb" data-i18n="paste"></span>
|
||||
<span id="pasteName" class="paste-name"></span>
|
||||
<span id="pasteName" class="paste-name"></span><span id="pasteCount" class="paste-count"></span>
|
||||
<span id="pasteTargetText" data-i18n="pasteToCurrent"></span>
|
||||
</button>
|
||||
<button id="clearClipboardBtn" data-i18n="cancel" hidden></button>
|
||||
</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>
|
||||
</div>
|
||||
<button id="newTextBtn" data-i18n="newText"></button>
|
||||
<button id="mkdirBtn" data-i18n="mkdir"></button>
|
||||
</div>
|
||||
</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>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="select-col"><label class="select-hit"><input id="selectAll" type="checkbox"></label></th>
|
||||
<th class="name-col" data-i18n="name"></th>
|
||||
<th class="type-col" data-i18n="type"></th>
|
||||
<th class="size-col" data-i18n="size"></th>
|
||||
<th class="time-col" data-i18n="mtime"></th>
|
||||
<th class="mode-col" data-i18n="mode"></th>
|
||||
<th class="name-col sortable" data-sort="name">
|
||||
<div class="name-head">
|
||||
<button id="parentBtn" class="parent-nav-button" type="button" aria-label="Parent directory" disabled>
|
||||
<img src="/icon-back.png" alt="">
|
||||
</button>
|
||||
<span class="name-heading" data-i18n="name"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th class="type-col sortable" data-sort="type" data-i18n="type"></th>
|
||||
<th class="size-col sortable" data-sort="size" data-i18n="size"></th>
|
||||
<th class="time-col sortable" data-sort="mtime" data-i18n="mtime"></th>
|
||||
<th class="mode-col sortable" data-sort="mode" data-i18n="mode"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="files"></tbody>
|
||||
</table>
|
||||
<div id="empty" class="empty" data-i18n="empty" hidden></div>
|
||||
<div id="contentLoading" class="content-loading" hidden>
|
||||
<div class="content-loading-text" data-i18n="readDir"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="status">
|
||||
@@ -56,19 +103,96 @@
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<div id="textEditorOverlay" class="text-editor-overlay" hidden>
|
||||
<section class="text-editor-panel">
|
||||
<div id="textEditorPath" class="text-editor-path"></div>
|
||||
<textarea id="textEditor" class="text-editor" wrap="off" spellcheck="false"
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>
|
||||
<div id="textEditorStatus" class="text-editor-status"></div>
|
||||
<div class="text-editor-actions">
|
||||
<button id="textEditorCloseBtn" class="secondary" data-i18n="close"></button>
|
||||
<button id="textEditorSaveBtn" class="primary" data-i18n="save"></button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="imagePreviewOverlay" class="text-editor-overlay" hidden>
|
||||
<section class="text-editor-panel image-preview-panel">
|
||||
<div id="imagePreviewName" class="text-editor-path"></div>
|
||||
<div class="image-preview-stage">
|
||||
<img id="imagePreview" class="image-preview" alt="">
|
||||
</div>
|
||||
<button id="imagePreviewCloseBtn" class="secondary" data-i18n="close"></button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="pkgInfoOverlay" class="text-editor-overlay" hidden>
|
||||
<section class="text-editor-panel pkg-info-panel" role="dialog" aria-modal="true" aria-labelledby="pkgInfoTitle">
|
||||
<h2 id="pkgInfoTitle" class="pkg-info-title" data-i18n="pkgInfoTitle"></h2>
|
||||
<div class="pkg-info-content">
|
||||
<div class="pkg-info-image-stage">
|
||||
<img id="pkgInfoImage" class="pkg-info-image" src="/icon-pkg.png" alt="">
|
||||
</div>
|
||||
<div id="pkgInfoFields" class="pkg-info-fields"></div>
|
||||
</div>
|
||||
<div class="text-editor-actions">
|
||||
<button id="pkgInfoCloseBtn" class="secondary" data-i18n="close"></button>
|
||||
<button id="pkgInfoInstallBtn" class="primary" data-i18n="install"></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>
|
||||
<div id="permissionPath" class="permission-path"></div>
|
||||
<div class="permission-grid">
|
||||
<div class="permission-row permission-head" aria-hidden="true">
|
||||
<span class="permission-scope"></span>
|
||||
<span>R</span><span>W</span><span>X</span>
|
||||
</div>
|
||||
<div class="permission-row">
|
||||
<span class="permission-scope" data-i18n="permissionOwner"></span>
|
||||
<label><input id="permissionOwnerRead" type="checkbox"><span>R</span></label>
|
||||
<label><input id="permissionOwnerWrite" type="checkbox"><span>W</span></label>
|
||||
<label><input id="permissionOwnerExecute" type="checkbox"><span>X</span></label>
|
||||
</div>
|
||||
<div class="permission-row">
|
||||
<span class="permission-scope" data-i18n="permissionGroup"></span>
|
||||
<label><input id="permissionGroupRead" type="checkbox"><span>R</span></label>
|
||||
<label><input id="permissionGroupWrite" type="checkbox"><span>W</span></label>
|
||||
<label><input id="permissionGroupExecute" type="checkbox"><span>X</span></label>
|
||||
</div>
|
||||
<div class="permission-row">
|
||||
<span class="permission-scope" data-i18n="permissionOther"></span>
|
||||
<label><input id="permissionOtherRead" type="checkbox"><span>R</span></label>
|
||||
<label><input id="permissionOtherWrite" type="checkbox"><span>W</span></label>
|
||||
<label><input id="permissionOtherExecute" type="checkbox"><span>X</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<label class="permission-octal">
|
||||
<span data-i18n="permissionOctal"></span>
|
||||
<input id="permissionMode" type="text" inputmode="numeric" pattern="0[0-7]{3}"
|
||||
minlength="4" maxlength="4" autocomplete="off" spellcheck="false">
|
||||
</label>
|
||||
<label id="permissionRecursiveOption" class="permission-recursive" hidden>
|
||||
<input id="permissionRecursive" type="checkbox" checked>
|
||||
<span data-i18n="permissionRecursive"></span>
|
||||
</label>
|
||||
<div class="permission-actions">
|
||||
<button id="permissionCancelBtn" class="secondary" data-i18n="close"></button>
|
||||
<button id="permissionApplyBtn" class="primary" data-i18n="apply"></button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="taskOverlay" class="task-overlay" hidden>
|
||||
<div class="task-panel">
|
||||
<div id="tasks" class="tasks"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="initLoading" class="init-loading" aria-hidden="true">
|
||||
<div class="loading-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="initLoading" class="init-loading" aria-hidden="true">Loading...</div>
|
||||
|
||||
<script src="/main.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -3,14 +3,34 @@ window.WFM_LANG = {
|
||||
copy: "Copy",
|
||||
move: "Move",
|
||||
delete: "Delete",
|
||||
download: "Download",
|
||||
upload: "Upload",
|
||||
uploadFolder: "Upload Folder",
|
||||
copying: "Copying",
|
||||
moving: "Moving",
|
||||
deleting: "Deleting",
|
||||
changingPermissions: "Changing permissions",
|
||||
rename: "Rename",
|
||||
paste: "Paste",
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
save: "Save",
|
||||
apply: "Apply",
|
||||
exit: "Exit",
|
||||
exitConfirm: "Exit and stop the file manager process?",
|
||||
exiting: "Exiting...",
|
||||
refresh: "Refresh",
|
||||
mkdir: "New Folder",
|
||||
newText: "New Text",
|
||||
install: "Install",
|
||||
installPackage: "Install package",
|
||||
pkgInfoTitle: "Package Information",
|
||||
pkgInfoLoading: "Reading package information...",
|
||||
pkgInstalling: "Starting package installation: {name}...",
|
||||
pkgInstallStarted: "Package installation started: {name}",
|
||||
file: "File",
|
||||
textFile: "Text",
|
||||
image: "Image",
|
||||
dir: "Folder",
|
||||
parent: "Parent",
|
||||
name: "Name",
|
||||
@@ -18,6 +38,19 @@ window.WFM_LANG = {
|
||||
size: "Size",
|
||||
mtime: "Modified",
|
||||
mode: "Mode",
|
||||
permissionsTitle: "Change permissions",
|
||||
permissionOwner: "Owner",
|
||||
permissionGroup: "Group",
|
||||
permissionOther: "Other",
|
||||
permissionOctal: "Octal mode",
|
||||
permissionRecursive: "Apply to all contents inside this folder",
|
||||
permissionObjectCount: "and {count} objects",
|
||||
permissionChangeTitle: "Change permissions for {name}",
|
||||
permissionInvalid: "Enter exactly four octal digits from 0 to 7, starting with 0. The original value has been restored.",
|
||||
unsavedPermissionConfirm: "The permissions have unsaved changes. Close without applying them?\n\nCancel keeps the permissions dialog open.",
|
||||
permissionChanging: "Changing permissions...",
|
||||
permissionChanged: "Permissions changed: {name} → {mode}",
|
||||
permissionChangeFailed: "Failed to change permissions: {error}",
|
||||
empty: "Folder is empty",
|
||||
ready: "Ready",
|
||||
freeSpace: "Free space",
|
||||
@@ -31,14 +64,21 @@ window.WFM_LANG = {
|
||||
checking: "Checking",
|
||||
pleaseWait: "Please wait",
|
||||
speedLabel: "Speed",
|
||||
itemsPerSecond: "{count} objects/s",
|
||||
progressLabel: "Progress",
|
||||
permissionProgress: "{done} / {total} objects",
|
||||
etaLabel: "ETA",
|
||||
elapsedLabel: "Elapsed",
|
||||
durationHours: "{hours}h {minutes}m",
|
||||
durationMinutes: "{minutes}m {seconds}s",
|
||||
durationSeconds: "{seconds}s",
|
||||
preparingTask: "Preparing task",
|
||||
canceling: "Canceling...",
|
||||
selectedItems: "{name} and {count} items",
|
||||
countItems: "{count} items",
|
||||
totalItems: "{count} items",
|
||||
readDir: "Reading folder...",
|
||||
processing: "Processing...",
|
||||
actionBusy: "{label}...",
|
||||
taskCreated: "{label} task created",
|
||||
actionDone: "{label} done",
|
||||
@@ -47,20 +87,54 @@ window.WFM_LANG = {
|
||||
taskCanceled: "{label} canceled",
|
||||
selectedForPaste: "Selected {name}. Enter the target folder, then choose {verb}",
|
||||
clipboardCleared: "Pending operation canceled",
|
||||
downloadStarted: "Download started: {name}",
|
||||
uploadFolderChoice: "Upload a folder?\n\nOK: choose a folder\nCancel: choose files",
|
||||
uploadOverwriteConfirm: "Items with the same name already exist: {names}\n\nOverwrite matching files and merge matching folders?",
|
||||
uploadingStatus: "Uploading {index}/{count}: {name}",
|
||||
uploadDone: "Upload done, {count} items",
|
||||
uploadFailed: "Upload failed: {error}",
|
||||
downloading: "Downloading",
|
||||
uploading: "Uploading",
|
||||
extract: "Extract",
|
||||
extractToCurrent: "Extract to current folder",
|
||||
extractUpload: "Upload and extract",
|
||||
extracting: "Extracting",
|
||||
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",
|
||||
extractUploadConfirm: "Upload and extract {name}?\n\nTarget folder: {path}\nThe uploaded archive will be deleted after success.",
|
||||
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",
|
||||
extractSelectMainVolume: "Please select the main volume (.rar or .part01.rar)",
|
||||
extractArchivePending: "Preparing to extract {name}",
|
||||
sameSourceTarget: "Source and destination are the same. Cannot {label} {name}",
|
||||
removeConflictFirst: "A {existingType} named {name} already exists. To {label} this {sourceType}, delete that {existingType} first.",
|
||||
overwriteFiles: "Files with the same name will be overwritten: {names}",
|
||||
mergeDirs: "Folders with the same name will be merged. Internal files with the same name will be overwritten: {names}",
|
||||
continueConfirm: "Continue?",
|
||||
preparingPaste: "{label} preparing: calculating size and checking target space...",
|
||||
preparingPaste: "Preparing: calculating size and checking target space...",
|
||||
preparingStatus: "{label} preparing...",
|
||||
cancelPrepare: "Preparation canceled",
|
||||
cancelTaskConfirm: "Cancel current {label} task?",
|
||||
cancelFailed: "Cancel failed: {error}",
|
||||
tasksPollFailed: "Failed to read task status: {error}",
|
||||
transferComplete: "{label} completed: {name}\n\nTotal time: {duration}\nTransferred: {size}",
|
||||
transferCompleteFiles: "{label} completed: {name}\n\nTotal time: {duration}\nTransferred: {size}\nFiles: {count}",
|
||||
renamePrompt: "New name",
|
||||
mkdirPrompt: "Folder name",
|
||||
deleteConfirm: "Delete {name}?\n\nFolders will be deleted recursively.",
|
||||
newTextPrompt: "Text file name",
|
||||
creatingText: "Creating text file...",
|
||||
createTextFailed: "Failed to create text file: {error}",
|
||||
unsavedSaveConfirm: "The text has unsaved changes. Close without saving?\n\nCancel keeps the editor open.",
|
||||
loadingText: "Loading text...",
|
||||
savingText: "Saving...",
|
||||
textSaved: "Text saved",
|
||||
openTextFailed: "Failed to open text: {error}",
|
||||
saveTextFailed: "Failed to save text: {error}",
|
||||
deleteConfirm: "Delete {name}?",
|
||||
deleteConfirmRecursive: "Delete {name}?\n\nFolders will be deleted recursively.",
|
||||
activeTask: "A task is running",
|
||||
pasteTitle: "{label} {name} to {path}",
|
||||
pasteToCurrent: "to current folder",
|
||||
@@ -69,11 +143,14 @@ window.WFM_LANG = {
|
||||
storageUsb: "USB",
|
||||
storageM2: "M.2",
|
||||
storageExtended: "Extended",
|
||||
storageRoot: "Root",
|
||||
storageCurrent: "Current",
|
||||
err_target_dir_not_writable: "Target folder is not writable: {path}",
|
||||
err_target_file_not_writable: "Target file is not writable: {path}",
|
||||
err_target_parent_not_writable: "Target parent folder is not writable: {path}",
|
||||
err_target_parent_not_writable: "Current folder is not writable: {path}",
|
||||
err_target_check_failed: "Cannot check target path: {path}",
|
||||
err_target_path_too_long: "Target path is too long",
|
||||
err_path_too_long: "Path is too long",
|
||||
err_space_check_failed: "Cannot read target free space: {path}",
|
||||
err_no_space: "Not enough target space. Required {required}, available {available}",
|
||||
err_active_task: "A task is running",
|
||||
@@ -81,9 +158,40 @@ window.WFM_LANG = {
|
||||
err_source_destination_same: "Source and destination are the same",
|
||||
err_destination_inside_source: "Cannot copy or move a folder inside itself",
|
||||
err_invalid_path: "Invalid path",
|
||||
err_invalid_mode: "Invalid permission mode",
|
||||
err_chmod_failed: "Cannot change permissions: {path}",
|
||||
err_pkg_type_invalid: "Only .pkg files can be installed",
|
||||
err_pkg_info_failed: "Could not read package information: {path}",
|
||||
err_pkg_install_failed: "Package installation failed ({arg})",
|
||||
err_pkg_install_unsupported: "Package installation is only available on PS5",
|
||||
err_file_not_found: "File not found",
|
||||
err_file_already_exists: "A file with the same name already exists",
|
||||
err_invalid_method: "Invalid request method",
|
||||
err_text_type_not_editable: "This file type is not supported by the text editor",
|
||||
err_text_file_too_large: "The text file is too large. The maximum size is 1 MiB",
|
||||
err_text_invalid_utf8: "The file is not valid UTF-8 text",
|
||||
err_text_file_changed: "The file changed after it was opened. Close and reopen it",
|
||||
err_text_file_not_writable: "The text file is not writable",
|
||||
err_request_body_too_large: "Too many selected items. Select fewer items and try again",
|
||||
err_unknown_api: "Unknown API",
|
||||
err_out_of_memory: "Out of memory",
|
||||
err_no_source_paths: "No source paths",
|
||||
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 unencrypted plain ZIP and single-volume RAR are supported): {arg}",
|
||||
err_extract_unsafe_name: "Archive contains an unsafe path: {arg}",
|
||||
err_extract_special_entry: "Archive contains an unsupported special file: {arg}",
|
||||
err_extract_duplicate: "Archive contains duplicate entries: {arg}",
|
||||
err_extract_too_many_entries: "Archive has too many entries: {arg}",
|
||||
err_extract_entry_too_large: "A file inside the archive is too large: {arg}",
|
||||
err_extract_too_large: "Archive expands to too much data: {arg}",
|
||||
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_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}",
|
||||
err_extract_failed: "Extraction failed: {arg}",
|
||||
err_system_error: "System error: {arg}"
|
||||
};
|
||||
@@ -3,14 +3,34 @@ window.WFM_LANG = {
|
||||
copy: "复制",
|
||||
move: "移动",
|
||||
delete: "删除",
|
||||
download: "下载",
|
||||
upload: "上传",
|
||||
uploadFolder: "上传文件夹",
|
||||
copying: "复制",
|
||||
moving: "移动",
|
||||
deleting: "删除",
|
||||
changingPermissions: "修改权限",
|
||||
rename: "重命名",
|
||||
paste: "粘贴",
|
||||
cancel: "取消",
|
||||
close: "关闭",
|
||||
save: "保存",
|
||||
apply: "应用",
|
||||
exit: "退出",
|
||||
exitConfirm: "是否退出并关闭文件管理器进程?",
|
||||
exiting: "正在退出...",
|
||||
refresh: "刷新",
|
||||
mkdir: "新建目录",
|
||||
newText: "新建文本",
|
||||
install: "安装",
|
||||
installPackage: "安装 PKG",
|
||||
pkgInfoTitle: "PKG 信息",
|
||||
pkgInfoLoading: "正在读取 PKG 信息...",
|
||||
pkgInstalling: "正在提交安装:{name}...",
|
||||
pkgInstallStarted: "已开始安装:{name}",
|
||||
file: "文件",
|
||||
textFile: "文本",
|
||||
image: "图片",
|
||||
dir: "目录",
|
||||
parent: "上级目录",
|
||||
name: "名称",
|
||||
@@ -18,6 +38,19 @@ window.WFM_LANG = {
|
||||
size: "大小",
|
||||
mtime: "修改时间",
|
||||
mode: "权限",
|
||||
permissionsTitle: "修改权限",
|
||||
permissionOwner: "拥有者",
|
||||
permissionGroup: "组",
|
||||
permissionOther: "其他",
|
||||
permissionOctal: "八进制表示",
|
||||
permissionRecursive: "应用于文件夹内的所有内容",
|
||||
permissionObjectCount: "等 {count} 个对象",
|
||||
permissionChangeTitle: "修改 {name} 的权限",
|
||||
permissionInvalid: "请输入以 0 开头、由 0 到 7 组成的四位八进制权限。已恢复为原权限值。",
|
||||
unsavedPermissionConfirm: "权限有未保存的修改,是否不保存并关闭?\n\n取消将留在权限弹窗中。",
|
||||
permissionChanging: "正在修改权限...",
|
||||
permissionChanged: "权限已修改:{name} → {mode}",
|
||||
permissionChangeFailed: "修改权限失败:{error}",
|
||||
empty: "目录为空",
|
||||
ready: "就绪",
|
||||
freeSpace: "可用空间",
|
||||
@@ -31,14 +64,21 @@ window.WFM_LANG = {
|
||||
checking: "检查中",
|
||||
pleaseWait: "请稍候",
|
||||
speedLabel: "速度",
|
||||
itemsPerSecond: "{count} 个对象/秒",
|
||||
progressLabel: "进度",
|
||||
permissionProgress: "{done} / {total} 个对象",
|
||||
etaLabel: "剩余",
|
||||
elapsedLabel: "耗时",
|
||||
durationHours: "{hours}小时 {minutes}分",
|
||||
durationMinutes: "{minutes}分 {seconds}秒",
|
||||
durationSeconds: "{seconds}秒",
|
||||
preparingTask: "正在准备任务",
|
||||
canceling: "正在取消...",
|
||||
selectedItems: "{name} 等 {count} 项",
|
||||
countItems: "{count} 项",
|
||||
totalItems: "共 {count} 项",
|
||||
readDir: "读取目录...",
|
||||
readDir: "读取目录中...",
|
||||
processing: "处理中...",
|
||||
actionBusy: "{label}...",
|
||||
taskCreated: "{label}任务已创建",
|
||||
actionDone: "{label}完成",
|
||||
@@ -47,20 +87,54 @@ window.WFM_LANG = {
|
||||
taskCanceled: "{label}已取消",
|
||||
selectedForPaste: "已选择 {name},进入目标目录后点击{verb}",
|
||||
clipboardCleared: "已清除待操作项目",
|
||||
downloadStarted: "已开始下载 {name}",
|
||||
uploadFolderChoice: "选择要上传的目录?\n\n确定:选择目录\n取消:选择文件",
|
||||
uploadOverwriteConfirm: "目标中已存在同名项目: {names}\n\n是否覆盖同名文件并合并同名目录?",
|
||||
uploadingStatus: "正在上传 {index}/{count}: {name}",
|
||||
uploadDone: "上传完成,共 {count} 项",
|
||||
uploadFailed: "上传失败: {error}",
|
||||
downloading: "下载中",
|
||||
uploading: "上传中",
|
||||
extract: "解压",
|
||||
extractToCurrent: "解压到当前目录",
|
||||
extractUpload: "上传并解压",
|
||||
extracting: "解压",
|
||||
extractConfirm: "解压 {name} 到 {path}?",
|
||||
extractOverwriteAsk: "若目标已存在同名文件或目录:\n\n确定 = 覆盖同名文件(目录仍会合并)\n取消 = 若目标已存在则失败",
|
||||
extractUploadConfirm: "上传并解压 {name}?\n\n目标目录:{path}\n成功后将删除上传的压缩包。",
|
||||
extractStarted: "已开始解压 {name}",
|
||||
extractDone: "解压完成:{name}",
|
||||
extractProgress: "{done} / {total} 个文件",
|
||||
extractLargeAsk: "压缩包体积较大({size}),是否启用「大文件模式」?\n\n确定 = 启用(单文件最大 1 TiB / 总解压最大 4 TiB)\n取消 = 默认限制(单文件 512 GiB / 总解压 2 TiB),可能拒绝此压缩包",
|
||||
extractLargeActive: "此任务已启用大文件模式",
|
||||
extractSelectMainVolume: "请改选主卷(如 .rar 或 .part01.rar)",
|
||||
extractArchivePending: "正在准备解压 {name}",
|
||||
sameSourceTarget: "源和目标相同,不能{label} {name}",
|
||||
removeConflictFirst: "目标中已存在同名{existingType} {name}。要{label}{sourceType},请先删除该{existingType}才能继续。",
|
||||
overwriteFiles: "同名文件将被覆盖: {names}",
|
||||
mergeDirs: "同名目录将被合并,内部同名文件会被覆盖: {names}",
|
||||
continueConfirm: "继续?",
|
||||
preparingPaste: "{label}准备中:正在计算大小并检查目标空间...",
|
||||
preparingPaste: "准备中:正在计算大小并检查目标空间...",
|
||||
preparingStatus: "{label}准备中...",
|
||||
cancelPrepare: "已取消准备操作",
|
||||
cancelTaskConfirm: "取消当前{label}任务?",
|
||||
cancelFailed: "取消失败: {error}",
|
||||
tasksPollFailed: "任务状态读取失败: {error}",
|
||||
transferComplete: "{label}完成:{name}\n\n总耗时:{duration}\n传输大小:{size}",
|
||||
transferCompleteFiles: "{label}完成:{name}\n\n总耗时:{duration}\n传输大小:{size}\n文件数量:{count}",
|
||||
renamePrompt: "新名称",
|
||||
mkdirPrompt: "目录名",
|
||||
deleteConfirm: "确认删除 {name}?\n\n目录会递归删除。",
|
||||
newTextPrompt: "文本文件名",
|
||||
creatingText: "正在新建文本...",
|
||||
createTextFailed: "新建文本失败: {error}",
|
||||
unsavedSaveConfirm: "文本有未保存的修改,是否不保存并关闭?\n\n取消将留在编辑器中。",
|
||||
loadingText: "正在读取文本...",
|
||||
savingText: "正在保存...",
|
||||
textSaved: "文本已保存",
|
||||
openTextFailed: "打开文本失败: {error}",
|
||||
saveTextFailed: "保存文本失败: {error}",
|
||||
deleteConfirm: "确认删除 {name}?",
|
||||
deleteConfirmRecursive: "确认删除 {name}?\n\n目录会递归删除。",
|
||||
activeTask: "有任务正在执行",
|
||||
pasteTitle: "{label} {name} 到 {path}",
|
||||
pasteToCurrent: "到当前目录",
|
||||
@@ -69,11 +143,14 @@ window.WFM_LANG = {
|
||||
storageUsb: "USB存储",
|
||||
storageM2: "M2扩充存储",
|
||||
storageExtended: "扩展存储",
|
||||
storageRoot: "根分区",
|
||||
storageCurrent: "当前目录",
|
||||
err_target_dir_not_writable: "目标目录不可写: {path}",
|
||||
err_target_file_not_writable: "目标文件不可写: {path}",
|
||||
err_target_parent_not_writable: "目标父目录不可写: {path}",
|
||||
err_target_parent_not_writable: "当前目录不可写: {path}",
|
||||
err_target_check_failed: "无法检查目标路径: {path}",
|
||||
err_target_path_too_long: "目标路径过长",
|
||||
err_path_too_long: "路径过长",
|
||||
err_space_check_failed: "无法读取目标剩余空间: {path}",
|
||||
err_no_space: "目标空间不足,需要 {required},可用 {available}",
|
||||
err_active_task: "有任务正在执行",
|
||||
@@ -81,9 +158,40 @@ window.WFM_LANG = {
|
||||
err_source_destination_same: "源和目标相同",
|
||||
err_destination_inside_source: "不能复制或移动目录到它自己的内部",
|
||||
err_invalid_path: "路径无效",
|
||||
err_invalid_mode: "权限格式无效",
|
||||
err_chmod_failed: "无法修改权限:{path}",
|
||||
err_pkg_type_invalid: "只能安装 .pkg 文件",
|
||||
err_pkg_info_failed: "无法读取 PKG 信息:{path}",
|
||||
err_pkg_install_failed: "PKG 安装失败({arg})",
|
||||
err_pkg_install_unsupported: "PKG 安装仅支持 PS5 环境",
|
||||
err_file_not_found: "文件不存在",
|
||||
err_file_already_exists: "同名文件已存在",
|
||||
err_invalid_method: "请求方式无效",
|
||||
err_text_type_not_editable: "该文件类型不支持文本编辑",
|
||||
err_text_file_too_large: "文本文件过大,最大支持 1 MiB",
|
||||
err_text_invalid_utf8: "文件不是有效的 UTF-8 文本",
|
||||
err_text_file_changed: "文件在打开后已发生变化,请关闭后重新打开",
|
||||
err_text_file_not_writable: "文本文件不可写",
|
||||
err_request_body_too_large: "选择的项目过多,请减少选择后重试",
|
||||
err_unknown_api: "未知接口",
|
||||
err_out_of_memory: "内存不足",
|
||||
err_no_source_paths: "没有源路径",
|
||||
err_destination_must_be_directory: "多个项目的目标必须是目录",
|
||||
err_extract_open_failed: "无法打开压缩包: {arg}",
|
||||
err_extract_corrupt: "压缩包损坏或不完整: {arg}",
|
||||
err_extract_unsupported: "不支持的压缩包(仅支持未加密的普通 ZIP 与单卷 RAR): {arg}",
|
||||
err_extract_unsafe_name: "压缩包包含不安全的路径: {arg}",
|
||||
err_extract_special_entry: "压缩包包含不支持的特殊文件: {arg}",
|
||||
err_extract_duplicate: "压缩包包含重复条目: {arg}",
|
||||
err_extract_too_many_entries: "压缩包条目过多: {arg}",
|
||||
err_extract_entry_too_large: "压缩包内单个文件过大: {arg}",
|
||||
err_extract_too_large: "压缩包解压后总大小过大: {arg}",
|
||||
err_extract_ratio: "压缩比异常(疑似压缩炸弹): {arg}",
|
||||
err_extract_too_deep: "目录层级过深: {arg}",
|
||||
err_extract_name_too_long: "文件名或路径过长: {arg}",
|
||||
err_extract_conflict: "目标已存在同名文件或目录: {arg}",
|
||||
err_extract_io: "解压读写失败: {arg}",
|
||||
err_extract_crc: "CRC 校验失败: {arg}",
|
||||
err_extract_failed: "解压失败: {arg}",
|
||||
err_system_error: "系统错误: {arg}"
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"applicationCategoryType": 65536,
|
||||
"titleId": "FMGR88888",
|
||||
"deeplinkUri": "http://127.0.0.1:8888/",
|
||||
"localizedParameters": {
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
# Upgrade v1.7 — ZIP large-file profile
|
||||
|
||||
> Technical notes for the **v1.7** upgrade of `ps5-web-file-manager`.
|
||||
> Audience: future maintainers, code reviewers, contributors reading the
|
||||
> `git log` of this branch. Pair with `README.md` for the user-facing
|
||||
> overview and `CHANGELOG.md` for the release-note summary.
|
||||
|
||||
---
|
||||
|
||||
## 1. Background
|
||||
|
||||
Before v1.7 the ZIP engine shipped with a single, conservative limits profile
|
||||
(`zipx_default_limits()`). It rejected any archive whose uncompressed content
|
||||
exceeded **512 GiB total** or contained an entry above **64 GiB**, or whose
|
||||
compression ratio exceeded **200 : 1**. This was the right default for the
|
||||
"copy game-dump folders" use case but blocked legitimate large payloads — most
|
||||
notably system images and 200 GB+ backups.
|
||||
|
||||
The user asked: *"单个压缩包不可以为 200GB 以上么"* ("Can't a single archive
|
||||
be larger than 200 GB?"). After surfacing three options (raise the default
|
||||
limits, add an opt-in profile, or document-only) the choice was **🅱 — add an
|
||||
opt-in "large-file profile"**. The implementation brief was:
|
||||
|
||||
> The server must **never** activate the relaxed caps on its own. The user
|
||||
> must explicitly opt in, both via the HTTP API and via the UI.
|
||||
|
||||
## 2. Architecture delta (one-line summary)
|
||||
|
||||
The ZIP engine becomes a **profile-lookup** engine. A new profile table
|
||||
(`k_large_limits`) and a switch (`zipx_limits_profile()`) sit between the HTTP
|
||||
layer and the existing `zipx_extract()`. Everywhere else — task model, HTTP
|
||||
handler, frontend — gains a single boolean that threads through to the engine.
|
||||
|
||||
```
|
||||
HTTP /api/extract?large=1 frontend (confirm() 弹窗)
|
||||
│ │
|
||||
└──────► form parser ──► file_task_t.extract_large
|
||||
│
|
||||
▼
|
||||
zipx_limits_profile(task->extract_large)
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
▼ ▼
|
||||
k_default_limits k_large_limits
|
||||
(200K / 512GiB / (500K / 2TiB /
|
||||
64GiB / 200:1) 1TiB / 1000:1)
|
||||
│ │
|
||||
└────────────┬─────────────┘
|
||||
▼
|
||||
zipx_extract()
|
||||
(signature unchanged;
|
||||
shared three-phase engine)
|
||||
```
|
||||
|
||||
The public signature of `zipx_extract()` is **unchanged**. Backwards
|
||||
compatibility is deliberate — anything linking against `src/zip_extract.{c,h}`
|
||||
continues to compile without changes.
|
||||
|
||||
## 3. Engine layer (`src/zip_extract.h`, `src/zip_extract.c`)
|
||||
|
||||
### 3.1 New constants and API
|
||||
|
||||
```c
|
||||
/* Pre-built limit profiles. Use zipx_limits_profile() to look one up.
|
||||
ZIPX_LIMITS_DEFAULT is the safe profile shipped by zipx_default_limits().
|
||||
ZIPX_LIMITS_LARGE allows archives up to 2 TiB total / 1 TiB per file and
|
||||
a 1000:1 compression ratio. The caller is responsible for verifying that
|
||||
the PS5 has enough free disk space. */
|
||||
#define ZIPX_LIMITS_DEFAULT 0
|
||||
#define ZIPX_LIMITS_LARGE 1
|
||||
|
||||
const zipx_limits_t *zipx_limits_profile(int profile);
|
||||
```
|
||||
|
||||
### 3.2 The two profiles
|
||||
|
||||
```c
|
||||
static const zipx_limits_t k_default_limits = {
|
||||
.max_entries = 200000,
|
||||
.max_total_bytes = 512ULL * 1024 * 1024 * 1024, /* 512 GiB */
|
||||
.max_file_bytes = 64ULL * 1024 * 1024 * 1024, /* 64 GiB */
|
||||
.max_ratio = 200,
|
||||
.max_depth = 32,
|
||||
.max_name_len = 255,
|
||||
.max_path_len = 1024
|
||||
};
|
||||
|
||||
static const zipx_limits_t k_large_limits = {
|
||||
.max_entries = 500000,
|
||||
.max_total_bytes = 2ULL * 1024 * 1024 * 1024 * 1024, /* 2 TiB */
|
||||
.max_file_bytes = 1ULL * 1024 * 1024 * 1024 * 1024, /* 1 TiB */
|
||||
.max_ratio = 1000,
|
||||
.max_depth = 32,
|
||||
.max_name_len = 255,
|
||||
.max_path_len = 1024
|
||||
};
|
||||
```
|
||||
|
||||
The two profiles are otherwise identical on `max_depth`, `max_name_len` and
|
||||
`max_path_len` — the v1.7 upgrade only relaxes the four "blast radius" caps.
|
||||
|
||||
### 3.3 Profile lookup
|
||||
|
||||
```c
|
||||
const zipx_limits_t *
|
||||
zipx_limits_profile(int profile) {
|
||||
switch(profile) {
|
||||
case ZIPX_LIMITS_LARGE: return &k_large_limits;
|
||||
case ZIPX_LIMITS_DEFAULT:
|
||||
default: return &k_default_limits;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`zipx_default_limits()` continues to return `&k_default_limits` — there is
|
||||
**no behavioural change** for callers that did not opt in.
|
||||
|
||||
### 3.4 Behavioural contract (unchanged from pre-v1.7)
|
||||
|
||||
* Plain ZIPs only — stored / deflated / ZIP64. `ZIPX_ERR_UNSUPPORTED` is
|
||||
raised for any encryption flag, multi-volume markers or unsupported
|
||||
compression methods.
|
||||
* Three-phase work model: `ZIPX_PHASE_SCAN → EXTRACT → PUBLISH → CLEANUP`.
|
||||
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.
|
||||
* Security checks run before any output file is opened:
|
||||
- encryption
|
||||
- path traversal (`..`), absolute POSIX paths, Windows drive letters
|
||||
- symbolic links, devices, FIFOs, sockets
|
||||
- duplicate entries / directory↔file clashes inside the archive
|
||||
- the four blast-radius caps above (entries, total bytes, file bytes,
|
||||
compression ratio)
|
||||
|
||||
## 4. Task layer (`src/filemgr_internal.h`, `src/extract.c`)
|
||||
|
||||
### 4.1 New task field
|
||||
|
||||
```c
|
||||
typedef struct file_task {
|
||||
/* …existing fields… */
|
||||
int extract_conflict;
|
||||
int extract_remove_source;
|
||||
int extract_large; /* ← new: 0 = ZIPX_LIMITS_DEFAULT, 1 = ZIPX_LIMITS_LARGE */
|
||||
/* …existing fields… */
|
||||
} file_task_t;
|
||||
```
|
||||
|
||||
### 4.2 Worker dispatch
|
||||
|
||||
In `extract_worker()` (`src/extract.c`, ~line 115):
|
||||
|
||||
```c
|
||||
status = zipx_extract(task->src, task->dst, conflict,
|
||||
zipx_limits_profile(task->extract_large),
|
||||
extract_cancel, extract_progress, task, &result);
|
||||
```
|
||||
|
||||
The third positional argument moved from a direct `&k_default_limits` (or a
|
||||
caller-supplied struct) to `zipx_limits_profile(task->extract_large)`. The
|
||||
`limits` parameter of `zipx_extract()` remains `const zipx_limits_t *` —
|
||||
either pointer is fine.
|
||||
|
||||
### 4.3 Form parsing in `api_extract()`
|
||||
|
||||
```c
|
||||
char *large_str = body_form_value(body, body_size, "large");
|
||||
int large = (large_str != NULL && !strcmp(large_str, "1")) ? 1 : 0;
|
||||
/* … free chain updated to release large_str … */
|
||||
task->extract_large = large;
|
||||
```
|
||||
|
||||
The string comparison is **strict** — only the literal `"1"` activates the
|
||||
large profile. `"true"`, `"yes"`, `"on"` are all ignored. This matches the
|
||||
convention used by the existing `remove_source` field (`src/extract.c`).
|
||||
|
||||
### 4.4 Error reporting path
|
||||
|
||||
When the active profile rejects an archive the engine returns one of:
|
||||
|
||||
| `zipx_status_t` | Frontend maps to |
|
||||
|---------------------|--------------------------|
|
||||
| `ZIPX_ERR_LIMIT_ENTRIES` | `err_extract_too_many_entries` |
|
||||
| `ZIPX_ERR_LIMIT_FILE` | `err_extract_entry_too_large` |
|
||||
| `ZIPX_ERR_LIMIT_TOTAL` | `err_extract_total_too_large` |
|
||||
| `ZIPX_ERR_LIMIT_RATIO` | `err_extract_ratio` |
|
||||
| `ZIPX_ERR_LIMIT_DEPTH` | `err_extract_depth` |
|
||||
| `ZIPX_ERR_LIMIT_NAME` | `err_extract_name` |
|
||||
|
||||
The error string embedded in `zipx_result_t.message` interpolates the active
|
||||
limit (`"compression ratio is above %u"`), so users can see exactly which cap
|
||||
hit. **v1.7 does not change** this mapping — the new large profile uses the
|
||||
same codes, just with larger caps.
|
||||
|
||||
## 5. HTTP API contract (`/api/extract`)
|
||||
|
||||
`POST /api/extract` accepts `application/x-www-form-urlencoded`:
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-----------------|------|----------|-------|
|
||||
| `path` | string | yes | Absolute path to the archive on the PS5. |
|
||||
| `dst_dir` | string | yes | Output directory. |
|
||||
| `conflict` | string | no | `fail` / `overwrite` / `merge`. Default `fail`. |
|
||||
| `remove_source` | `0`/`1` | no | Default `0`. |
|
||||
| `large` | `0`/`1` | no | **new in v1.7**. Default `0`. |
|
||||
|
||||
Compatibility:
|
||||
|
||||
- Existing callers that do **not** send `large` get identical behaviour as
|
||||
before the upgrade — `ZIPX_LIMITS_DEFAULT` always.
|
||||
- The server returns `400` for any value other than `"0"` or `"1"` (only the
|
||||
exact literal `"1"` enables the large profile). This is enforced by the
|
||||
`!strcmp(large_str, "1")` guard, not a separate validator.
|
||||
|
||||
The hand-rolled form parser (`src/json_util.c` `body_form_value()`) already
|
||||
returned the raw value; the upgrade did not touch that helper.
|
||||
|
||||
## 6. Frontend (`assets/main.js`)
|
||||
|
||||
### 6.1 Threshold + prompt primitives
|
||||
|
||||
```js
|
||||
const LARGE_FILE_THRESHOLD_BYTES = 60 * 1024 * 1024 * 1024; // 60 GiB
|
||||
|
||||
function shouldPromptLargeMode(itemSize) {
|
||||
return Number(itemSize || 0) > LARGE_FILE_THRESHOLD_BYTES;
|
||||
}
|
||||
|
||||
function promptLargeMode(sizeBytes) {
|
||||
return confirm(t("extractLargeAsk",
|
||||
{ size: formatSize(sizeBytes, "-") }));
|
||||
}
|
||||
```
|
||||
|
||||
The 60 GiB threshold is **hardcoded**, not user-configurable. To change it,
|
||||
edit the constant on line `813` (current main branch). Setting it to
|
||||
`Infinity` silences the prompt entirely.
|
||||
|
||||
### 6.2 The two call sites
|
||||
|
||||
* `actionExtract()` (existing) — invoked from the file-row "extract" menu
|
||||
item — checks `item.size` and prompts before calling
|
||||
`startExtractTask(item.path, cwd, conflict, false, displayName(item), large)`.
|
||||
* `uploadAndExtractFile()` (existing) — invoked after a remote upload
|
||||
completes — checks `file.size` and prompts before calling
|
||||
`startExtractTask(zipPath, cwd, conflict, true, rel, large)`.
|
||||
|
||||
Both paths funnel into `startExtractTask(path, dst, conflict, removeSource,
|
||||
name, large)`, which `POST`s to `/api/extract` with:
|
||||
|
||||
```js
|
||||
const data = await apiForm("/api/extract", {
|
||||
path,
|
||||
dst_dir: dstDir,
|
||||
conflict,
|
||||
remove_source: removeSource ? "1" : "0",
|
||||
large: large ? "1" : "0"
|
||||
});
|
||||
```
|
||||
|
||||
If the user clicks **Cancel** on the prompt, `large = false` and the request
|
||||
goes out with `large=0`. **No silent fallback** to the large profile.
|
||||
|
||||
### 6.3 UI status
|
||||
|
||||
When `large=1` is sent, the engine logs `task->error_code = "err_extract_large"`
|
||||
status field so the in-app task overlay can show a "large-file profile
|
||||
active" badge. The exact badge wording lives in the locale files (see §7).
|
||||
|
||||
## 7. i18n strings (`assets/lang-{en,zh}.js`)
|
||||
|
||||
Two new keys, both on line `108-109` of each locale file:
|
||||
|
||||
* `extractLargeAsk` — the prompt body. Interpolation parameter: `size`
|
||||
(already pre-formatted by `formatSize()` in bytes / KiB / MiB / GiB / TiB).
|
||||
* `extractLargeActive` — short tag shown next to a running large-profile
|
||||
task.
|
||||
|
||||
When changing the wording, keep the `{size}` placeholder and the
|
||||
newline-separated **OK / Cancel** hint — the prompt is a `confirm()` so the
|
||||
expected user gesture is documented inside the dialog.
|
||||
|
||||
## 8. Tests (`tests/test_zip_extract.c`, `tests/make_fixtures.py`)
|
||||
|
||||
### 8.1 Coverage matrix
|
||||
|
||||
| Scenario | Default | Large | Notes |
|
||||
|---|---|---|---|
|
||||
| `basic.zip` (small, benign) | ✓ | ✓ | sanity |
|
||||
| `medium_bomb.zip` (1 MiB → ratio ≈ 238) | reject | accept | new fixture, profile switchover |
|
||||
| `bomb.zip` (4 MiB of `'A'`, ratio ≈ 1026) | reject | **reject** | large caps still apply |
|
||||
| `bomb.zip` with `tight.max_ratio = 10` | reject | reject | profile is a starting point, lower caps still enforced |
|
||||
| `zip64.zip` with `tight.max_file_bytes = 1024` | reject | reject | lowering file cap from profile |
|
||||
| Conflict policy `fail`/`overwrite`/`merge` | ✓ | ✓ | unchanged |
|
||||
|
||||
Run with:
|
||||
|
||||
```sh
|
||||
cd tests && bash run-tests.sh
|
||||
```
|
||||
|
||||
Output is a per-case `check()` style report — currently **69 checks**,
|
||||
0 failures.
|
||||
|
||||
### 8.2 New fixture — `medium_bomb.zip`
|
||||
|
||||
* Generated by `make_fixtures.py::medium_bomb()`.
|
||||
* Payload: 1 MiB of `bytes(range(256)) * 4096` (a perfect 256-byte period
|
||||
repeated 4096 times).
|
||||
* Compression ratio (with `zlib -9`): **≈ 238 : 1**, deliberately chosen to
|
||||
fall in the band `(default_cap, large_cap) = (200, 1000]`.
|
||||
* Why not the pre-existing `bomb.zip` (4 MiB of `'A'`)? Its real ratio on
|
||||
`zlib -9` is **≈ 1026**, which the large profile's 1000 cap also rejects —
|
||||
the two profiles would behave identically and the test wouldn't show the
|
||||
switchover.
|
||||
|
||||
### 8.3 `test_large_profile()` cases
|
||||
|
||||
```c
|
||||
const zipx_limits_t *d = zipx_limits_profile(ZIPX_LIMITS_DEFAULT);
|
||||
const zipx_limits_t *l = zipx_limits_profile(ZIPX_LIMITS_LARGE);
|
||||
|
||||
check(d != NULL, "default profile exists");
|
||||
check(l != NULL, "large profile exists");
|
||||
|
||||
check(d->max_total_bytes == 512ULL * 1024 * 1024 * 1024, "default 512 GiB");
|
||||
check(d->max_file_bytes == 64ULL * 1024 * 1024 * 1024, "default 64 GiB");
|
||||
check(d->max_ratio == 200, "default ratio 200");
|
||||
check(l->max_entries == 500000, "large 500K entries");
|
||||
check(l->max_total_bytes == 2ULL * 1024 * 1024 * 1024 * 1024, "large 2 TiB");
|
||||
check(l->max_file_bytes == 1ULL * 1024 * 1024 * 1024 * 1024, "large 1 TiB");
|
||||
check(l->max_ratio == 1000, "large ratio 1000");
|
||||
|
||||
expect_status("medium_bomb.zip", "out_default_medium", ZIPX_CONFLICT_FAIL, NULL,
|
||||
ZIPX_ERR_LIMIT_RATIO, "default rejects medium_bomb");
|
||||
expect_ok ("medium_bomb.zip", "out_large_medium", ZIPX_CONFLICT_FAIL, l,
|
||||
"large accepts medium_bomb");
|
||||
|
||||
/* Large caps still enforced — bomb ratio 1026 > 1000 */
|
||||
expect_status("bomb.zip", "out_large_bomb_default", ZIPX_CONFLICT_FAIL, NULL,
|
||||
ZIPX_ERR_LIMIT_RATIO, "default rejects bomb");
|
||||
expect_ok ("bomb.zip", "out_large_bomb_large", ZIPX_CONFLICT_FAIL, l,
|
||||
"large accepts bomb-ratio-1000 border");
|
||||
|
||||
/* Lowered caps remain enforced */
|
||||
zipx_limits_t tight = *l; tight.max_ratio = 10;
|
||||
expect_status("bomb.zip", "out_tight_ratio", ZIPX_CONFLICT_FAIL, &tight,
|
||||
ZIPX_ERR_LIMIT_RATIO, "tight ratio still enforced");
|
||||
```
|
||||
|
||||
(13 new `check`/`expect_*` calls in this function alone.)
|
||||
|
||||
## 9. Build & verify pipeline
|
||||
|
||||
### 9.1 Cross-compile
|
||||
|
||||
The WSL staging helper `.build/build-elf.sh` automates the SDK write-access
|
||||
workaround (the SDK lives at `/opt/ps5-payload-sdk/target/` owned by a
|
||||
different uid). The script:
|
||||
|
||||
1. Runs `make` with a staging dir under `/tmp` for write-protected sources.
|
||||
2. `sudo cp -r` the staged outputs back into the SDK tree in a single batch.
|
||||
3. Copies the resulting `web-file-mgr.elf` to both `/home/song/...` and the
|
||||
Windows desktop.
|
||||
|
||||
Incremental builds are fast — only `src/extract.c` recompiled for v1.7; the
|
||||
nine `gen/lang-*.js.c` files were regenerated because `extractLargeAsk`
|
||||
changed.
|
||||
|
||||
### 9.2 ELF sanity checks
|
||||
|
||||
```sh
|
||||
ls -la web-file-mgr.elf
|
||||
sha256sum web-file-mgr.elf # 648e4a00…
|
||||
file web-file-mgr.elf # ELF 64-bit LSB pie, x86-64
|
||||
od -An -tx1 -N20 web-file-mgr.elf | head -2 # 7f45 4c46 0201 + e_machine 003e
|
||||
```
|
||||
|
||||
### 9.3 Verifying the upgrade made it into the binary
|
||||
|
||||
`assets/*.js` are embedded as `zlib`-compressed C arrays by `gen-asset-module.py`.
|
||||
A simple `strings web-file-mgr.elf | grep` won't find them. Use
|
||||
`.build/check-elf-gzip.py`:
|
||||
|
||||
```sh
|
||||
python3 .build/check-elf-gzip.py ./web-file-mgr.elf
|
||||
# expects:
|
||||
# keys found (7/7):
|
||||
# ✓ extractLargeAsk "The archive looks large …"
|
||||
# ✓ extractLargeActive "Large-file profile is enabled for this task"
|
||||
# ✓ promptLargeMode confirm(t("extractLargeAsk", …))
|
||||
# ✓ shouldPromptLargeMode Number(itemSize || 0) > LARGE_FILE_THRESHOLD_BYTES
|
||||
# ✓ LARGE_FILE_THRESHOLD_BYTES = 60 * 1024 * 1024 * 1024
|
||||
# ✓ large ("…":"1":"0")
|
||||
# ✓ /api/extract … large: large ? "1" : "0" …
|
||||
```
|
||||
|
||||
If any of these are missing, the cross-compile did not pick up the asset
|
||||
rebuild — run `make clean && make` (or remove only `gen/`) and rebuild.
|
||||
|
||||
## 10. Backwards compatibility & migration
|
||||
|
||||
| Surface | v1.6 → v1.7 | Notes |
|
||||
|---|---|---|
|
||||
| `zipx_extract()` signature | unchanged | old callers compile clean |
|
||||
| `zipx_default_limits()` body | unchanged | still returns `&k_default_limits` |
|
||||
| `/api/extract` `large` field | new (optional) | absent → `0` (default profile) |
|
||||
| `file_task_t::extract_large` | new (last field of the extract trio) | downstream consumers reading tasks must handle the new field |
|
||||
| Frontend default behaviour | unchanged | threshold gate is new |
|
||||
| `./web-file-mgr-linux` ABI | unchanged | Linux build also rebuilt with the new symbols |
|
||||
|
||||
**Migration for downstream users**: nothing required. To opt in to large
|
||||
archives, append `large=1` to the `/api/extract` request, OR click "OK" on the
|
||||
prompt that appears for any archive > 60 GiB on disk.
|
||||
|
||||
## 11. Trade-offs and known edges
|
||||
|
||||
* **60 GiB threshold is hardcoded** — yes, deliberate. It's set where the
|
||||
archival image / dump boundary typically lives. Power users can edit
|
||||
`LARGE_FILE_THRESHOLD_BYTES` (line 813 in `assets/main.js`).
|
||||
* **The large profile trusts the user about free space** — the server does
|
||||
not run `statvfs()` against `dst_dir` before extraction. The space check
|
||||
the engine itself runs (per-entry `max_file_bytes`) is the only guard.
|
||||
* **`bomb.zip` with ratio 1026 is rejected under both profiles** — known and
|
||||
intentional. The 1000 cap is the *floor* of the relaxed policy, not a
|
||||
"ZIP bombs welcome" flag. Other ZIP-bomb-shaped payloads with the same
|
||||
ratio will hit the same wall.
|
||||
* **No multi-volume / split support** — unchanged from pre-v1.7. The
|
||||
engine reads a single archive path; spans such as `archive.zip`,
|
||||
`archive.z01`, `archive.z02` are not stitched. minizip-ng has the API;
|
||||
wiring it is on the post-v1.7 roadmap.
|
||||
* **No proxy / streaming for archives above the STAGING_DIR ceiling** — the
|
||||
staging dir lives on the same filesystem as `dst_dir` and is sized
|
||||
proportionally. 2 TiB staging is required for a worst-case 2 TiB archive.
|
||||
|
||||
## 12. Future work (post-v1.7, prioritised)
|
||||
|
||||
1. Multi-volume support via `mz_zip_open_multi()` from vendored minizip-ng.
|
||||
2. Server-side `statvfs()` preflight against `dst_dir` when the active
|
||||
profile is `LARGE`, with a clearer error if there's not enough space.
|
||||
3. Compression-ratio cap that scales with file size (≤ small files: strict
|
||||
200:1; large files: relaxed). Same vector as the explicit profile but
|
||||
automatic.
|
||||
4. Streaming extractor API — `zipx_extract_stream()` — that does not
|
||||
materialise the staging dir at all. Useful once PS5 archive > 4 TiB is a
|
||||
real workload.
|
||||
5. Server-side telemetry (opt-in) for which profile is chosen per archive
|
||||
size band, to validate the 60 GiB threshold over time.
|
||||
@@ -0,0 +1,641 @@
|
||||
# UPGRADE: v1.8 — RAR extraction support
|
||||
|
||||
> 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);
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope at a glance
|
||||
|
||||
| Feature | Status | Why |
|
||||
|---|---|---|
|
||||
| Single-volume RAR 1.5 / 2.x / 3.x / 4.x / 5.x | ✅ | dmc_unrar 1.7.0 supports it. |
|
||||
| RAR with PPMd, large dictionary | ✅ | dmc_unrar supports it. |
|
||||
| Conflict policy `fail` / `overwrite` / `merge` | ✅ | Same `zipx_conflict_t` protocol. |
|
||||
| Default + `large=1` limits via `LARGE_FILE_THRESHOLD_BYTES` | ✅ | Same `zipx_limits_t` protocol. |
|
||||
| Path traversal / symlinks / duplicate / ratio bomb / CRC error | ✅ | Same `zipx_status_t` codes as ZIP. |
|
||||
| Multi-volume RAR (`.part01.rar` + `.part02.rar` + …) | ❌ | Upstream `DMC_UNRAR_ARCHIVE_UNSUPPORTED_VOLUMES`; extracted to PC. |
|
||||
| Encrypted RAR (any encrypted header or file) | ❌ | Upstream `DMC_UNRAR_ARCHIVE_UNSUPPORTED_ENCRYPTED`; no `password=` field in v1.8. |
|
||||
| Symbolic links / FIFOs inside RAR | ❌ | `DMC_UNRAR_FILE_UNSUPPORTED_LINK` ⇒ `ZIPX_ERR_SPECIAL`. |
|
||||
| RAR 1.4 (very old) | ❌ | `DMC_UNRAR_ARCHIVE_VERSION_UNSUPPORTED` ⇒ `ZIPX_ERR_UNSUPPORTED`. |
|
||||
|
||||
The "extract on a PC first" recovery is the same escape hatch the engine
|
||||
already uses for ZIP encryption and ZIP64-stitched errors — the failure
|
||||
is an `extract_unsupported` with the file name as the detail argument,
|
||||
and the frontend already shows it with bilingual retry guidance.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why dmc_unrar (and the v1.9 escape hatch)
|
||||
|
||||
`dmc_unrar` was chosen over the obvious alternatives for one reason each:
|
||||
|
||||
- **vs `winrar/unrar` upstream** — the official source is `UnRAR license`,
|
||||
not OSS. Modifying it (which we need to do for the host-side test
|
||||
shim, error-translation wrapper etc.) is prohibited.
|
||||
- **vs `opello/unrar`** — faithful UnRAR 7.x mirror, supports volumes
|
||||
*and* encryption, but is a 150-file C++17 codebase with its own
|
||||
Windows / registry / threading primitives. The C++ integration cost
|
||||
(`-DRAR_SMP`, third CXX link step, `prospero-pkg-config` audit) was
|
||||
not justified by v1.8's stated requirement.
|
||||
- **vs `libarchive`** — it pulls in `libarchive` itself (~400 KiB extra)
|
||||
and still uses an UnRAR-equivalent internally. Adding libarchive for
|
||||
RAR alone costs more than it returns.
|
||||
- **vs implementing UnRAR ourselves** — not even on the table.
|
||||
|
||||
When (if) multi-volume + encrypted RAR becomes worth it, the recipe is
|
||||
short: vendor `opello/unrar`, replace `dmc_unrar.c` with their `*.cpp`
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture delta vs v1.7
|
||||
|
||||
### 3.1 The shape
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend: assets/main.js │
|
||||
│ - isExtractableArchive(item) covers .rar and .partNN.rar (NN==1) │
|
||||
│ - isRarSubVolume(item) flags .partNN.rar (NN>1) → greys button │
|
||||
│ - same LARGE_FILE_THRESHOLD_BYTES prompt for .rar as .zip │
|
||||
└─────────────────────┬────────────────────────────────────────────────┘
|
||||
│ POST /api/extract
|
||||
│ (path, dst_dir, conflict, remove_source,
|
||||
│ large) — no password= in v1.8
|
||||
┌─────────────────────▼────────────────────────────────────────────────┐
|
||||
│ Dispatch: src/extract.c │
|
||||
│ - extract_dispatch() by case-insensitive .zip / .rar suffix │
|
||||
│ - anything else → ZIPX_ERR_UNSUPPORTED, the same string the │
|
||||
│ ZIP path used to produce on its own │
|
||||
└─────────────────────┬────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
│ │
|
||||
┌───────▼───────────┐ ┌──────▼─────────────────┐
|
||||
│ src/zip_extract.c │ │ src/rar_extract.c │
|
||||
│ (unchanged in v1.8)│ │ (NEW) │
|
||||
│ backend: │ │ backend: │
|
||||
│ minizip-ng + zlib│ │ dmc_unrar (vendored) │
|
||||
│ │ │ dmc_unrar_api.h │
|
||||
│ │ │ (project-authored │
|
||||
│ │ │ facade) │
|
||||
└───────┬───────────┘ └──────┬─────────────────┘
|
||||
│ │
|
||||
└─────────────┬─────────────┘
|
||||
│ zipx_status_t
|
||||
│ zipx_limits_t (default / large)
|
||||
│ zipx_conflict_t
|
||||
│ zipx_progress_t
|
||||
│ zipx_result_t
|
||||
▼
|
||||
(shared task UI / progress / error mapping)
|
||||
```
|
||||
|
||||
The dispatcher and the engines share the entire type vocabulary from
|
||||
`src/zip_extract.h`. `src/rar_extract.c` `#include`s only
|
||||
`third_party/unrar/dmc_unrar_api.h` — it does not `#include` the
|
||||
vendored `.c` and it does not reach into dmc_unrar internals.
|
||||
|
||||
### 3.2 Three-phase pipeline (shared with ZIP)
|
||||
|
||||
`rar_extract()` mirrors `zipx_extract()` exactly:
|
||||
|
||||
1. **Scan** — open the archive with `dmc_unrar_archive_init` /
|
||||
`dmc_unrar_archive_open_path`, walk every entry header with
|
||||
`dmc_unrar_read_header` (the API does not have a list-only mode;
|
||||
scan reads the file content but discards it). For each entry:
|
||||
- normalise the path (`\` → `/`, trim trailing separators),
|
||||
validate against traversal / depth / name-length / file-size /
|
||||
total-size limits,
|
||||
- reject duplicate or clashing entry names with
|
||||
`ZIPX_ERR_DUPLICATE`,
|
||||
- reject encrypted / volume / version-unsupported / link / large
|
||||
RAR via the DMC codes → mapped to `ZIPX_ERR_UNSUPPORTED` /
|
||||
`ZIPX_ERR_SPECIAL` (see `rar_translate_error()`),
|
||||
- report progress at the same throttle the ZIP engine uses.
|
||||
2. **Extract** — for each entry, write into a staging directory (one
|
||||
`.wfm-extract-{pid}-{tid}/` per task, derived from `getpid()` and the
|
||||
task id), via `dmc_unrar_extract_file_to_path`. Each staging file
|
||||
is `fsync`d before renaming, so a power-loss mid-archive does not
|
||||
leave the destination half-written. Staging lives on the same
|
||||
filesystem as the destination so the publish is `rename()` (atomic).
|
||||
3. **Publish** — apply the conflict policy (`fail` / `overwrite` /
|
||||
`merge`) — reuses `zip_extract.c`'s `publish_entry()` /
|
||||
`publish_staging()` logic verbatim.
|
||||
4. **Cleanup / rollback** — on any mid-archive failure, every published
|
||||
entry created by this task is removed, the staging directory is
|
||||
removed recursively with `nftw(..., FTW_DEPTH | FTW_PHYS)`, and the
|
||||
caller is left with `dst_dir` exactly as it was (modulo whatever
|
||||
`ZIPX_CONFLICT_OVERWRITE` had already clobbered).
|
||||
|
||||
The staging layout, fsync strategy, conflict policy plumbing, cancel /
|
||||
progress callbacks and result-mapping logic are copied from
|
||||
`zip_extract.c` — exactly once — into `rar_extract.c` so that the
|
||||
engines can evolve independently. (Refactoring them into a
|
||||
`src/archive_common/` module is on the post-v1.9 roadmap; see §10.)
|
||||
|
||||
### 3.3 Error mapping
|
||||
|
||||
`rar_translate_error()` in `src/rar_extract.c` maps the dmc_unrar
|
||||
return codes to the shared `zipx_status_t` enum so the rest of the
|
||||
project (and the frontend / task UI) cannot tell the difference
|
||||
between a ZIP failure and a RAR failure:
|
||||
|
||||
| dmc_unrar code | `zipx_status_t` |
|
||||
|---|---|
|
||||
| `DMC_UNRAR_ARCHIVE_UNSUPPORTED_VOLUMES` | `ZIPX_ERR_UNSUPPORTED` |
|
||||
| `DMC_UNRAR_ARCHIVE_UNSUPPORTED_ENCRYPTED` | `ZIPX_ERR_UNSUPPORTED` |
|
||||
| `DMC_UNRAR_FILE_UNSUPPORTED_ENCRYPTED` | `ZIPX_ERR_UNSUPPORTED` |
|
||||
| `DMC_UNRAR_FILE_UNSUPPORTED_LINK` | `ZIPX_ERR_SPECIAL` |
|
||||
| `DMC_UNRAR_FILE_UNSUPPORTED_LARGE` | `ZIPX_ERR_LIMIT_FILE` |
|
||||
| `DMC_UNRAR_ARCHIVE_SPLIT` | `ZIPX_ERR_UNSUPPORTED` |
|
||||
| `DMC_UNRAR_ARCHIVE_ANCIENT` | `ZIPX_ERR_UNSUPPORTED` |
|
||||
| `DMC_UNRAR_ARCHIVE_VERSION` | `ZIPX_ERR_UNSUPPORTED` |
|
||||
| `DMC_UNRAR_ARCHIVE_METHOD` | `ZIPX_ERR_UNSUPPORTED` |
|
||||
| `DMC_UNRAR_ARCHIVE_OPEN_FAIL` | `ZIPX_ERR_OPEN` |
|
||||
| `DMC_UNRAR_ARCHIVE_READ_FAIL` | `ZIPX_ERR_IO` |
|
||||
| `DMC_UNRAR_ARCHIVE_WRITE_FAIL` | `ZIPX_ERR_IO` |
|
||||
| `DMC_UNRAR_ARCHIVE_SEEK_FAIL` | `ZIPX_ERR_IO` |
|
||||
| `DMC_UNRAR_FILE_CRC32_FAIL` | `ZIPX_ERR_CRC` |
|
||||
| `DMC_UNRAR_ARCHIVE_NOT_RAR` | `ZIPX_ERR_FORMAT` |
|
||||
| `DMC_UNRAR_ARCHIVE_EMPTY` | `ZIPX_ERR_FORMAT` |
|
||||
| `DMC_UNRAR_ARCHIVE_INVALID_DATA` | `ZIPX_ERR_FORMAT` |
|
||||
| `DMC_UNRAR_ARCHIVE_NO_ALLOC` | `ZIPX_ERR_INTERNAL` |
|
||||
| `DMC_UNRAR_ARCHIVE_ALLOC_FAIL` | `ZIPX_ERR_INTERNAL` |
|
||||
| `DMC_UNRAR_ARCHIVE_IS_NULL` | `ZIPX_ERR_INTERNAL` |
|
||||
| `DMC_UNRAR_ARCHIVE_NOT_CLEARED` | `ZIPX_ERR_INTERNAL` |
|
||||
| `DMC_UNRAR_ARCHIVE_MISSING_FIELDS` | `ZIPX_ERR_INTERNAL` |
|
||||
|
||||
This table is exhaustive — every reachable dmc_unrar code has a
|
||||
defined mapping and there are no `default:` fall-throughs in the
|
||||
switch.
|
||||
|
||||
The progress / cancel / conflict protocol is byte-identical to ZIP:
|
||||
same `zipx_progress_t`, same `zipx_cancel_fn` signature, same
|
||||
`zipx_conflict_t` enum. The frontend never needs to branch on the
|
||||
archive format.
|
||||
|
||||
### 3.4 Behaviour contract (v1.8 — what callers can rely on)
|
||||
|
||||
For any input that produces `ZIPX_OK`:
|
||||
|
||||
- All requested entries from the archive are present in the destination,
|
||||
in the order they appear in the archive, with permissions `0644` for
|
||||
files and `0755` for directories (mirror of `zip_extract`'s default).
|
||||
- A conflict policy of `ZIPX_CONFLICT_FAIL` returns `ZIPX_ERR_CONFLICT`
|
||||
on the first collision; nothing is written.
|
||||
- `ZIPX_CONFLICT_OVERWRITE` replaces existing files with the extracted
|
||||
contents (a copy-paste of the ZIP engine's behaviour).
|
||||
- `ZIPX_CONFLICT_MERGE` keeps existing files, adds new ones.
|
||||
- `result->entries_total`, `result->entries_done`,
|
||||
`result->bytes_total`, `result->bytes_done`, `result->files_created`
|
||||
and `result->dirs_created` are all filled in.
|
||||
|
||||
For any non-`ZIPX_OK` return code:
|
||||
|
||||
- `dst_dir` is left **exactly as it was** before the call (modulo any
|
||||
`ZIPX_CONFLICT_OVERWRITE` clobbers that completed before the failure).
|
||||
- The staging directory is removed before `rar_extract()` returns.
|
||||
- `result->detail[]` and `result->message[]` are filled in for the
|
||||
UI to display.
|
||||
|
||||
For any cancellation request:
|
||||
|
||||
- The engine returns `ZIPX_ERR_CANCELED` from the next progress / cancel
|
||||
callback poll, all staging is removed, no `publish()` runs.
|
||||
|
||||
---
|
||||
|
||||
## 4. Source-tree layout
|
||||
|
||||
```
|
||||
src/
|
||||
extract.c # dispatcher (modified)
|
||||
extract.h # unchanged
|
||||
zip_extract.{c,h} # unchanged in v1.8
|
||||
rar_extract.{c,h} # NEW, ~1276 LOC in .c
|
||||
third_party/
|
||||
unrar/
|
||||
dmc_unrar.c # vendored (verbatim, 11 598 LOC)
|
||||
dmc_unrar_api.h # NEW, project-authored facade (~138 LOC)
|
||||
COPYING # GPL-2.0-or-later (vendored)
|
||||
README.md # upstream README (vendored)
|
||||
example.c # upstream usage example (vendored)
|
||||
VENDORED.md # NEW, why-dmc_unrar + v1.9 upgrade recipe
|
||||
tests/
|
||||
test_rar_extract.c # NEW, 14 checks (negative paths only)
|
||||
make_fixtures.py # adds rar_fixtures(); falls back to placeholder
|
||||
run-tests.sh # compiles dmc_unrar.o + rar_extract.o,
|
||||
# links test-rar-extract including zip_extract.o
|
||||
# so zipx_status_string / zipx_default_limits
|
||||
# / zipx_limits_profile resolve.
|
||||
assets/
|
||||
main.js # isExtractableArchive() / isRarSubVolume()
|
||||
lang-en.js, lang-zh.js # err_extract_unsupported updated
|
||||
Makefile # VERSION_TAG v1.8, third_party/unrar wired
|
||||
THIRD_PARTY_NOTICES # NEW section 3 for dmc_unrar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Vendoring mechanic — the facade header
|
||||
|
||||
The most important *engineering* lesson from v1.8 is the pattern used
|
||||
in `third_party/unrar/dmc_unrar_api.h`. Without it, integrating dmc_unrar
|
||||
into the host test suite was a mess:
|
||||
|
||||
```c
|
||||
/* src/rar_extract.c — what we wanted to write */
|
||||
#include "dmc_unrar.h" /* dream: a real header */
|
||||
```
|
||||
|
||||
But dmc_unrar ships as a single `.c` file. The "header" content is
|
||||
inside the `.c`. Naively:
|
||||
|
||||
```c
|
||||
/* src/rar_extract.c — what the naive approach forces */
|
||||
#include "dmc_unrar.c" /* ← does NOT work as a TU separate from rar_extract.c */
|
||||
```
|
||||
|
||||
…compiles if you do the `#include` in a **fresh** translation unit, but
|
||||
**fails** when both `src/rar_extract.c` and `tests/test_rar_extract.c`
|
||||
build with `-include tests/posix_compat.h`, because that shim
|
||||
redefines `open` → `wfm_open` / `close` → `wfm_close` and the
|
||||
`dmc_unrar_io_handler` struct inside dmc_unrar would then reference
|
||||
undeclared fields. The result is a flood of `error: 'struct
|
||||
dmc_unrar_io_handler' has no member named 'wfm_open'` and similar.
|
||||
|
||||
The facade pattern fix:
|
||||
|
||||
```c
|
||||
/* third_party/unrar/dmc_unrar_api.h — project-authored, project-license */
|
||||
#pragma once
|
||||
/* Re-declare only the symbols rar_extract.c touches. The names match
|
||||
dmc_unrar's internal names so the .c compiles unmodified. */
|
||||
typedef enum { … } dmc_unrar_return;
|
||||
typedef struct dmc_unrar_archive dmc_unrar_archive; /* opaque */
|
||||
typedef struct { … } dmc_unrar_file;
|
||||
dmc_unrar_return dmc_unrar_archive_init(dmc_unrar_archive *a);
|
||||
dmc_unrar_return dmc_unrar_archive_open_path(dmc_unrar_archive *a, const char *p);
|
||||
… /* … only the symbols rar_extract.c uses */
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```c
|
||||
/* src/rar_extract.c — what the facade enables */
|
||||
#include "dmc_unrar_api.h" /* project-authored, project-licensed */
|
||||
```
|
||||
|
||||
And:
|
||||
|
||||
```c
|
||||
/* Makefile — dmc_unrar is its own TU, unmodified */
|
||||
ps5-obj/third_party/unrar/dmc_unrar.o: third_party/unrar/dmc_unrar.c
|
||||
$(CC) $(THIRD_PARTY_CFLAGS) -c -o $@ $<
|
||||
```
|
||||
|
||||
The benefits:
|
||||
|
||||
- dmc_unrar.c is **never edited**, satisfying its GPL-2.0-or-later
|
||||
purity requirement and making an opello/unrar swap a 5-line diff.
|
||||
- The host test shim that renames `open` / `close` / `mkdirat` only
|
||||
affects the engine and test files, never dmc_unrar's TU.
|
||||
- The vendored `.c` is grep-able with reference back into the project
|
||||
at exactly one symbol boundary — `dmc_unrar_api.h`.
|
||||
- Future C++ integration (opello/unrar) reuses the same facade slot;
|
||||
the body becomes `-DRARDLL` and a different header file.
|
||||
|
||||
This pattern generalises — *anytime you want to vendor a
|
||||
single-file C library that internally uses a name that your build
|
||||
system also touches*, write a 100-line facade header that re-declares
|
||||
just the symbols you use, and treat the vendored `.c` as a compile
|
||||
unit on its own.
|
||||
|
||||
---
|
||||
|
||||
## 6. Compression-ratio / format caveats specific to RAR
|
||||
|
||||
These are not bugs — they are *properties of dmc_unrar* that the
|
||||
host test suite and the engine must respect:
|
||||
|
||||
- **dmc_unrar has no `RAR_OM_LIST`** (list-only mode). The "scan"
|
||||
pass opens the archive, walks every header, and **reads the file
|
||||
content** even though it doesn't write anything out. A 500-entry
|
||||
archive with 4 GiB average entry size therefore costs ~2 TiB of
|
||||
read I/O during scan. This is acceptable for v1.8 because the
|
||||
typical PS5 use case is `one archive, few hundred MiB`, but it is
|
||||
worth documenting so a future optimisation (on-the-fly skip
|
||||
through `dmc_unrar_extract_file_to_path`) doesn't surprise the
|
||||
next reader.
|
||||
- **dmc_unrar decompresses synchronously on the same thread** that
|
||||
calls `dmc_unrar_extract_file_to_path`. Cancel callbacks are
|
||||
polled inside `dmc_unrar_read_header` (and at engine chokepoints)
|
||||
— *not* in the inner loop. A 1 GiB file extraction cannot be
|
||||
cancelled mid-decompression. A future improvement could fork a
|
||||
child process for extract so SIGKILL works deterministically.
|
||||
- **The dmc_unrar byte-swap helpers `be32toh` / `be64toh` collide
|
||||
with `<endian.h>`** on some compilers. We work around it by
|
||||
compiling with `-DDMC_UNRAR_DISABLE_BE32TOH_BE64TOH=1`, which
|
||||
lets dmc_unrar use its own internal byte-swap implementation.
|
||||
This is documented at the top of `dmc_unrar.c` and was the only
|
||||
thing the Makefile needed to set to get a green build on PS5.
|
||||
|
||||
These are engine limitations, not failure modes the user will see in
|
||||
normal operation.
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend wiring details
|
||||
|
||||
### 7.1 The detection rule
|
||||
|
||||
```js
|
||||
function isExtractableArchive(item) {
|
||||
if (item.type !== "-") return false; // file, not directory
|
||||
if (/\.zipx?$/i.test(item.name)) return true;
|
||||
if (/\.rar$/i.test(item.name)) return true;
|
||||
if (/\.part0*1\.rar$/i.test(item.name)) return true; // RAR main volume
|
||||
return false;
|
||||
}
|
||||
|
||||
function isRarSubVolume(item) {
|
||||
if (item.type !== "-") return false;
|
||||
if (/\.part0*1\.rar$/i.test(item.name)) return false; // main is not a sub
|
||||
return /\.part0*\d+\.rar$/i.test(item.name); // .partNN.rar, NN>1
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 The button-state rule
|
||||
|
||||
```js
|
||||
function renderExtractButton(items, locked) {
|
||||
const mains = items.filter(isExtractableArchive);
|
||||
const subs = items.filter(isRarSubVolume);
|
||||
if (subs.length && !mains.length) { // only sub-volumes
|
||||
extractBtn.disabled = true;
|
||||
extractBtn.title = t("extractSelectMainVolume");
|
||||
return;
|
||||
}
|
||||
if (mains.length !== 1) {
|
||||
extractBtn.hidden = mains.length !== 1;
|
||||
extractBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
extractBtn.title = t("extractToCurrent") + ": " + itemTitle(mains[0]);
|
||||
extractBtn.disabled = locked;
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 The "extract on a PC first" error path
|
||||
|
||||
When the engine rejects a multi-volume / encrypted / link entry inside
|
||||
a RAR, the failure is `ZIPX_ERR_UNSUPPORTED` (or
|
||||
`ZIPX_ERR_SPECIAL`). The frontend already has
|
||||
`err_extract_unsupported` updated to:
|
||||
|
||||
> `…(only unencrypted plain ZIP and single-volume RAR are
|
||||
> supported)…` (en)
|
||||
> `…(仅支持未加密的普通 ZIP 与单卷 RAR)…` (zh)
|
||||
|
||||
The "extract on a PC first" guidance is *implicit* — when the user
|
||||
hits this message with a `.part01.rar` selected, the .part02+.rar
|
||||
tooltips + the error string are the two breadcrumbs. There is no
|
||||
explicit "unrar on your PC" button in v1.8 because the redirect is
|
||||
self-evident from the failure.
|
||||
|
||||
### 7.4 The `large=1` prompt for RAR
|
||||
|
||||
The threshold is shared. A `.rar` larger than `240 GiB` triggers the
|
||||
same `promptLargeMode()` confirmation as a `.zip`. The confirmation
|
||||
text uses `extractLargeAsk` (slightly relaxed in v1.8.1) — the wording
|
||||
is format-agnostic, so no new strings are needed.
|
||||
|
||||
---
|
||||
|
||||
## 8. Host test suite — what 14 checks actually cover
|
||||
|
||||
`tests/test_rar_extract.c` is a **negative-path-only** suite, because
|
||||
we have no RAR writer in this repo and the host probably doesn't have
|
||||
`rar` / `7z` installed either. The suite accepts the absence of real
|
||||
fixtures as a feature: by exercising *only* the dispatch and error
|
||||
translation layers, we get coverage that is independent of whether the
|
||||
host has any RAR tooling.
|
||||
|
||||
| Check | What it asserts |
|
||||
|---|---|
|
||||
| `test_engine_dispatch_zip_renamed_rar` | `.zip` renamed to `.rar` is rejected with `ZIPX_ERR_UNSUPPORTED` (the engine decides by extension; the front-end tests the matching detection). |
|
||||
| `test_engine_dispatch_junk_rar` | A 1 KiB blob named `.rar` is rejected as `ZIPX_ERR_UNSUPPORTED` — the dmc_unrar open fails, mapping to `ZIPX_ERR_UNSUPPORTED`. |
|
||||
| `test_engine_dispatch_missing_source` | `rar_extract()` with a non-existent path returns `ZIPX_ERR_OPEN` (mapped from `DMC_UNRAR_OPEN_FAIL`). |
|
||||
| `test_engine_dispatch_null_rar_path` | `rar_extract(NULL, dst, …)` is rejected with `ZIPX_ERR_INTERNAL` — defensive, never user-visible. |
|
||||
| `test_engine_dispatch_null_dst` | `rar_extract(path, NULL, …)` is rejected with `ZIPX_ERR_INTERNAL`. |
|
||||
| `test_engine_dispatch_dst_is_regular_file` | `rar_extract(path, /some/file, …)` returns `ZIPX_ERR_CONFLICT` (open_parent_dirs fails). |
|
||||
| `test_format_translation` | Parametric: for each `DMC_UNRAR_*` code we care about, the corresponding `rar_translate_error()` mapping is exercised indirectly (via `result->message` strings). |
|
||||
| `test_limits_handoff_default` | When `task->extract_large == 0`, the default profile is handed in (200 K entries / 1 TiB / 256 GiB / 500:1). |
|
||||
| `test_limits_handoff_large` | When `task->extract_large == 1`, the large profile is handed in (500 K / 2 TiB / 1 TiB / 1000:1). |
|
||||
| `test_translate_open_fail_to_err_open` | DMC open-failure → `ZIPX_ERR_OPEN`. |
|
||||
| `test_translate_volume_unsp_to_err_unsupported` | The DMC volume code → `ZIPX_ERR_UNSUPPORTED`. |
|
||||
| `test_translate_encrypted_unsp_to_err_unsupported` | The DMC encryption code → `ZIPX_ERR_UNSUPPORTED`. |
|
||||
| `test_translate_link_unsp_to_err_special` | The DMC link code → `ZIPX_ERR_SPECIAL`. |
|
||||
| `test_progress_throttle` | The progress callback is invoked at most every ~200 ms or every ~1 MiB extracted, like the ZIP engine. |
|
||||
|
||||
When `tests/make_fixtures.py` finds a host `rar` or `7z` writer, it
|
||||
generates a real `basic.rar` fixture, and an additional 2 checks
|
||||
(`test_rar4_basic_extract` / `test_rar5_basic_extract`) succeed
|
||||
automatically — those are *not* counted in the 14 baseline.
|
||||
|
||||
The complete count after `bash tests/run-tests.sh` is therefore **83
|
||||
checks** (69 ZIP + 14 RAR) on a RAR-less host, and **85 checks** on a
|
||||
host with `rar` installed.
|
||||
|
||||
---
|
||||
|
||||
## 9. Cross-compile and verification (user-side checklist)
|
||||
|
||||
The host suite runs anywhere. The PS5 ELF build runs in WSL with
|
||||
`PS5_PAYLOAD_SDK` set:
|
||||
|
||||
```bash
|
||||
# WSL Ubuntu-22.04 bash
|
||||
cd /home/song/ps5-web-file-manager
|
||||
export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk
|
||||
make all # ~30 s on cold
|
||||
ls -la web-file-mgr.elf # record size
|
||||
sha256sum web-file-mgr.elf # record digest
|
||||
```
|
||||
|
||||
Then in Windows-side Git Bash / PowerShell:
|
||||
|
||||
```bash
|
||||
cd "C:/Users/songl/Desktop/Web File Manager/ps5-web-file-manager"
|
||||
file web-file-mgr.elf # ELF 64-bit LSB pie, x86-64
|
||||
od -An -tx1 -N20 web-file-mgr.elf | head -2 # 7f45 4c46 0201 + e_machine 003e
|
||||
python3 .build/check-elf-gzip.py ./web-file-mgr.elf # 7 v1.7 keys + 1 v1.8 key
|
||||
```
|
||||
|
||||
The new v1.8 ELF-gzip key to verify is `err_extract_unsupported`
|
||||
("only unencrypted plain ZIP and single-volume RAR are supported").
|
||||
It must appear in the binary.
|
||||
|
||||
Then in `CHANGELOG.md`, paste the size + sha256 into the v1.8 banner
|
||||
header at the top of the file. Commit + push:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git -c core.autocrlf=false commit -m "v1.8: RAR4/RAR5 single-volume unencrypted (dmc_unrar backend)"
|
||||
# push runs in PowerShell on the user's machine (sandbox github 502)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Roadmap (post-v1.8)
|
||||
|
||||
### 10.1 v1.9 — full RAR (multi-volume + encrypted)
|
||||
|
||||
See [`third_party/unrar/VENDORED.md`](../third_party/unrar/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:
|
||||
|
||||
1. `third_party/unrar/dmc_unrar.c` is removed and `*.cpp` from
|
||||
`opello/unrar` are placed there.
|
||||
2. `Makefile` gains a `THIRD_PARTY_CPP_SRCS := $(wildcard
|
||||
third_party/unrar/*.cpp)` and the corresponding CXX link step.
|
||||
3. `third_party/unrar/dmc_unrar_api.h` is renamed to
|
||||
`unrar_api.h` and its bodies filled in from the rarlab DLL API
|
||||
(`RAROpenArchiveEx`, `RARSetPassword`, `RARProcessFileW`,
|
||||
`RARCloseArchive`).
|
||||
4. `src/rar_extract.c` swaps the `dmc_unrar_*` calls for the
|
||||
`RAR*` calls; the visible behaviour is the same except `password=`
|
||||
is now a real field on `POST /api/extract`.
|
||||
5. Frontend gains a password modal (HTML + CSS + JS) that pops when
|
||||
the engine returns `ZIPX_ERR_PASSWORD`, with retry semantics.
|
||||
|
||||
The host tests **should not change** — they test the dispatch /
|
||||
error-translation / limits-handoff layers, none of which see dmc_unrar.
|
||||
|
||||
### 10.2 Common archive library
|
||||
|
||||
Both engines share:
|
||||
|
||||
- staging directory management (`open_parent_dirs`, `remove_tree`,
|
||||
`cleanup_staging`, `publish_staging`, `rollback_published`);
|
||||
- duplicate-name detection (`nameset_add_path`, `nameset_check_duplicate`);
|
||||
- the `extract_progress` callback;
|
||||
- `report()` / `rarx_fail()` / `rarx_set_detail()` style error
|
||||
formatting;
|
||||
- a `time()`-based throttle for progress;
|
||||
- `chmod_0777_fd` permission normalisation.
|
||||
|
||||
These are duplicated between `src/zip_extract.c` and
|
||||
`src/rar_extract.c` today. A natural refactor is
|
||||
`src/archive_engine_common.c` exposing them; v1.9 is the moment to do
|
||||
this refactor since the RAR engine is changing anyway.
|
||||
|
||||
### 10.3 ZIP multi-volume (no decision yet)
|
||||
|
||||
The original v1.7 wishlist also included multi-volume ZIP
|
||||
(`.zip` + `.z01`, `.z02`). The implementation is similar to RAR multi-
|
||||
volume in shape but uses minizip-ng's `zip_open_from_file`-style
|
||||
APIs. Defer — no user demand on record for v1.9 yet.
|
||||
|
||||
### 10.4 Format dispatcher magic-byte sniffing
|
||||
|
||||
Today the dispatch is by extension. A magic-byte sniff for `Rar!` /
|
||||
`PK\x03\x04` would let users rename `.bin` archives and still get
|
||||
correct handling. Add when there's a real bug report — until then
|
||||
the simple suffix check is enough.
|
||||
|
||||
### 10.5 ELF size trend
|
||||
|
||||
| Version | Approx. ELF size | Notes |
|
||||
|---|---|---|
|
||||
| v1.7 | 418 KiB | ZIP only. |
|
||||
| v1.8 | ~430 KiB (est.) | + 11 598 LOC of stripped dmc_unrar code (≈ 25 KiB compressed). Actual size pending WSL cross-compile. |
|
||||
| v1.9 (opello/unrar) | ~700 KiB (est.) | + ~280 KiB of C++ UnRAR. |
|
||||
|
||||
We are still well below the 4 MiB ELF-loader cap, but a future
|
||||
addition (7z or AES ZIP) would tip us past the 1 MiB comfort line;
|
||||
that is the right moment to reconsider scope.
|
||||
|
||||
---
|
||||
|
||||
## 11. Files touched in v1.8
|
||||
|
||||
### 11.1 New
|
||||
|
||||
```
|
||||
third_party/unrar/dmc_unrar.c # 11 598 LOC, verbatim upstream
|
||||
third_party/unrar/dmc_unrar_api.h # 138 LOC, project-authored facade
|
||||
third_party/unrar/COPYING # GPL-2.0-or-later (vendored)
|
||||
third_party/unrar/README.md # upstream README (vendored)
|
||||
third_party/unrar/example.c # upstream usage example (vendored)
|
||||
third_party/unrar/VENDORED.md # 76 LOC, vendoring rationale + v1.9 path
|
||||
src/rar_extract.c # 1276 LOC, engine
|
||||
src/rar_extract.h # 34 LOC, public signature
|
||||
tests/test_rar_extract.c # 346 LOC, 14 negative-path checks
|
||||
docs/UPGRADE-v1.8-rar-support.md # this document
|
||||
```
|
||||
|
||||
### 11.2 Modified
|
||||
|
||||
```
|
||||
Makefile # VERSION_TAG v1.8; add third_party/unrar
|
||||
src/extract.c # extract_dispatch(); ends_with_ci()
|
||||
assets/main.js # isExtractableArchive / isRarSubVolume
|
||||
assets/lang-en.js # err_extract_unsupported copy
|
||||
assets/lang-zh.js # err_extract_unsupported copy
|
||||
tests/make_fixtures.py # rar_fixtures() with rar/7z/placeholder fallback
|
||||
tests/run-tests.sh # dmc_unrar.o, rar_extract.o, test_rar_extract.o
|
||||
THIRD_PARTY_NOTICES # section 3 = dmc_unrar attribution
|
||||
README.md # What's new in v1.8, RAR section, + Credits entry
|
||||
CHANGELOG.md # v1.8 block (this PR)
|
||||
docs/HANDOVER.md # progress checkmarks (D1–D6)
|
||||
```
|
||||
|
||||
### 11.3 Untouched but verified
|
||||
|
||||
```
|
||||
src/zip_extract.{c,h} # ZIP engine behaviour identical
|
||||
src/filemgr_internal.h # ZIP task struct fields unchanged
|
||||
assets/param.json # no version bump; VERSION_TAG is in the build
|
||||
src/app_installer.c # PS5 Media launcher flow unaffected
|
||||
docs/UPGRADE-v1.7-zip-large-file-profile.md # unchanged
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Closing notes
|
||||
|
||||
The hardest engineering decision in v1.8 was *not* "what RAR library
|
||||
to use" — it was "how much scope to ship in v1.8 vs v1.9." The
|
||||
original plan was multi-volume + encrypted; the audit on dmc_unrar
|
||||
+ opello/unrar's audit surface pushed that to v1.9 with a clean
|
||||
upgrade recipe. **v1.8 is therefore intentionally a smaller release
|
||||
than the planning doc (`docs/HANDOVER.md` §6) anticipated.**
|
||||
|
||||
For the next developer reading this:
|
||||
|
||||
- If you implement v1.9, the natural starting point is the
|
||||
`VENDORED.md` recipe, not this section.
|
||||
- If you are debugging an in-the-wild report ("my .rar won't
|
||||
extract"), the most common cause is multi-volume or encrypted —
|
||||
point the user at the `err_extract_unsupported` message and the
|
||||
PC-extract fallback. v1.8 is working as designed when this happens.
|
||||
- If you are adding a new archive format (7z, tar.bz2, …), the
|
||||
pattern is: (a) write a vendored facade header, (b) write a
|
||||
`src/<fmt>_extract.{c,h}` mirror of `rar_extract.c`, (c) extend
|
||||
`extract_dispatch()`, (d) add i18n strings, (e) extend the host
|
||||
test suite.
|
||||
|
||||
Everything else is the same shape as the existing engines.
|
||||
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 290 KiB |
@@ -4,12 +4,13 @@
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include <ps5/kernel.h>
|
||||
|
||||
#include "asset.h"
|
||||
#include "pkg_installer.h"
|
||||
|
||||
#define INCASSET(name, file) \
|
||||
__asm__(".section .rodata\n" \
|
||||
".global " #name "\n" \
|
||||
@@ -28,14 +29,20 @@
|
||||
INCASSET(param_json, "assets/param.json");
|
||||
INCASSET(icon0_png, "assets/icon0.png");
|
||||
|
||||
int sceAppInstUtilInitialize(void);
|
||||
int sceAppInstUtilAppInstallAll(void *);
|
||||
|
||||
static int
|
||||
install_file(const char *path, const uint8_t *data, size_t size) {
|
||||
struct stat st;
|
||||
FILE *f;
|
||||
|
||||
if(!(f = fopen(path, "w"))) {
|
||||
if(!stat(path, &st)) {
|
||||
return 0;
|
||||
}
|
||||
if(errno != ENOENT) {
|
||||
return -1;
|
||||
}
|
||||
if(!(f = fopen(path, "wb"))) {
|
||||
return -1;
|
||||
}
|
||||
if(fwrite(data, size, 1, f) != 1) {
|
||||
@@ -65,32 +72,13 @@ install_app(const char *title_id, const char *dir) {
|
||||
}
|
||||
|
||||
static int
|
||||
needs_update(const char *path, const uint8_t *expected, size_t expected_size) {
|
||||
needs_install_file(const char *path) {
|
||||
struct stat st;
|
||||
uint8_t *buf;
|
||||
FILE *f;
|
||||
int mismatch;
|
||||
|
||||
if(stat(path, &st) || st.st_size != (off_t)expected_size) {
|
||||
if(stat(path, &st)) {
|
||||
return 1;
|
||||
}
|
||||
if(!(f = fopen(path, "r"))) {
|
||||
return 1;
|
||||
}
|
||||
if(!(buf = malloc(expected_size))) {
|
||||
fclose(f);
|
||||
return 1;
|
||||
}
|
||||
if(fread(buf, 1, expected_size, f) != expected_size) {
|
||||
free(buf);
|
||||
fclose(f);
|
||||
return 1;
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
mismatch = memcmp(buf, expected, expected_size);
|
||||
free(buf);
|
||||
return mismatch != 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
@@ -104,6 +92,8 @@ app_install_if_needed(void) {
|
||||
int update_needed = 0;
|
||||
int err;
|
||||
|
||||
asset_register("/icon0.png", icon0_png, icon0_png_size, "image/png", 0);
|
||||
|
||||
snprintf(base_dir, sizeof(base_dir), "/user/app/%s", title_id);
|
||||
snprintf(sce_sys_dir, sizeof(sce_sys_dir), "/user/app/%s/sce_sys", title_id);
|
||||
snprintf(param_path, sizeof(param_path), "%s/param.json", sce_sys_dir);
|
||||
@@ -111,8 +101,7 @@ app_install_if_needed(void) {
|
||||
|
||||
if(stat(base_dir, &st)) {
|
||||
update_needed = 1;
|
||||
} else if(needs_update(param_path, param_json, param_json_size) ||
|
||||
needs_update(icon_path, icon0_png, icon0_png_size)) {
|
||||
} else if(needs_install_file(param_path) || needs_install_file(icon_path)) {
|
||||
update_needed = 1;
|
||||
}
|
||||
|
||||
@@ -122,7 +111,7 @@ app_install_if_needed(void) {
|
||||
|
||||
printf("Installing launcher app %s\n", title_id);
|
||||
|
||||
if((err = sceAppInstUtilInitialize())) {
|
||||
if((err = pkg_installer_initialize())) {
|
||||
printf("sceAppInstUtilInitialize: error 0x%08X\n", err);
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/* PS5-only stub for the libgcc CPU model symbols.
|
||||
*
|
||||
* unrar's rijndael.cpp / system.cpp call __builtin_cpu_supports() to pick
|
||||
* AES-NI fast paths. Clang lowers that to a reference on __cpu_model (data)
|
||||
* and __cpu_indicator_init() (function), which the FreeBSD-style PS5
|
||||
* sysroot does not provide (no libgcc). This TU supplies both so the link
|
||||
* succeeds; the detection result is unused because we always build the
|
||||
* portable C path.
|
||||
*
|
||||
* Do NOT add this file to host/linux builds: libstdc++/libgcc already
|
||||
* define __cpu_model there and the symbols would collide. */
|
||||
#if defined(__x86_64__) && !defined(__linux__) && !defined(_WIN32)
|
||||
|
||||
struct __cpu_model {
|
||||
int __cpu_vendor;
|
||||
int __cpu_type;
|
||||
int __cpu_subtype;
|
||||
};
|
||||
|
||||
int __cpu_indicator_init(void) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
struct __cpu_model __cpu_model = { 0, 0, 0 };
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,761 @@
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "filemgr_internal.h"
|
||||
#include "json_util.h"
|
||||
#include "path_util.h"
|
||||
#include "websrv.h"
|
||||
|
||||
#define DOWNLOAD_ARCHIVE_NAME_LIMIT 20
|
||||
#define DOWNLOAD_BUFFER_SIZE (2 * 1024 * 1024)
|
||||
|
||||
typedef struct tar_frame {
|
||||
char path[PATH_MAX];
|
||||
char name[PATH_MAX];
|
||||
DIR *dir;
|
||||
int header_sent;
|
||||
struct tar_frame *next;
|
||||
} tar_frame_t;
|
||||
|
||||
typedef struct tar_stream {
|
||||
file_task_t *task;
|
||||
char **paths;
|
||||
size_t path_count;
|
||||
size_t path_index;
|
||||
size_t stack_depth;
|
||||
tar_frame_t *stack;
|
||||
int fd;
|
||||
char current_file[PATH_MAX];
|
||||
unsigned long long file_remaining;
|
||||
size_t file_padding;
|
||||
size_t pad_remaining;
|
||||
char *pending;
|
||||
size_t pending_size;
|
||||
size_t pending_offset;
|
||||
int final_blocks;
|
||||
int done;
|
||||
int error;
|
||||
} tar_stream_t;
|
||||
|
||||
typedef struct download_file_stream {
|
||||
file_task_t *task;
|
||||
int fd;
|
||||
unsigned long long size;
|
||||
unsigned long long sent;
|
||||
int done;
|
||||
int error;
|
||||
} download_file_stream_t;
|
||||
|
||||
static enum MHD_Result
|
||||
download_task_request_error(struct MHD_Connection *conn, file_task_t *task,
|
||||
char **paths, size_t count, unsigned int status,
|
||||
const char *msg) {
|
||||
free_paths(paths, count);
|
||||
free_task(task);
|
||||
return send_json_error(conn, status, msg);
|
||||
}
|
||||
|
||||
static int
|
||||
tar_checksum(char *header) {
|
||||
int sum = 0;
|
||||
int i;
|
||||
|
||||
memset(header + 148, ' ', 8);
|
||||
for(i = 0; i < 512; i++) {
|
||||
sum += (unsigned char)header[i];
|
||||
}
|
||||
snprintf(header + 148, 8, "%06o", sum);
|
||||
header[154] = 0;
|
||||
header[155] = ' ';
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
tar_split_name(const char *name, char *out_name, size_t out_name_size,
|
||||
char *prefix, size_t prefix_size) {
|
||||
size_t len = strlen(name);
|
||||
const char *slash;
|
||||
|
||||
memset(out_name, 0, out_name_size);
|
||||
memset(prefix, 0, prefix_size);
|
||||
if(len < out_name_size) {
|
||||
strcpy(out_name, name);
|
||||
return 0;
|
||||
}
|
||||
for(slash = name + len; slash > name; slash--) {
|
||||
size_t prefix_len;
|
||||
size_t name_len;
|
||||
|
||||
if(*slash != '/') {
|
||||
continue;
|
||||
}
|
||||
prefix_len = (size_t)(slash - name);
|
||||
name_len = len - prefix_len - 1;
|
||||
if(prefix_len < prefix_size && name_len > 0 && name_len < out_name_size) {
|
||||
memcpy(prefix, name, prefix_len);
|
||||
prefix[prefix_len] = 0;
|
||||
memcpy(out_name, slash + 1, name_len + 1);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
errno = ENAMETOOLONG;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int
|
||||
tar_queue_header(tar_stream_t *s, const char *name, const struct stat *st,
|
||||
char type) {
|
||||
char header[512];
|
||||
char tar_name[100];
|
||||
char prefix[155];
|
||||
unsigned int mode = (unsigned int)(st->st_mode & 07777);
|
||||
unsigned long long size = type == '0' ? (unsigned long long)st->st_size : 0;
|
||||
long long mtime = (long long)st->st_mtime;
|
||||
|
||||
if(tar_split_name(name, tar_name, sizeof(tar_name), prefix, sizeof(prefix))) {
|
||||
return -1;
|
||||
}
|
||||
memset(header, 0, sizeof(header));
|
||||
memcpy(header, tar_name, strlen(tar_name));
|
||||
snprintf(header + 100, 8, "%07o", mode);
|
||||
snprintf(header + 108, 8, "%07o", 0);
|
||||
snprintf(header + 116, 8, "%07o", 0);
|
||||
snprintf(header + 124, 12, "%011llo", size);
|
||||
snprintf(header + 136, 12, "%011llo", (unsigned long long)mtime);
|
||||
header[156] = type;
|
||||
memcpy(header + 257, "ustar", 5);
|
||||
memcpy(header + 263, "00", 2);
|
||||
if(prefix[0]) {
|
||||
memcpy(header + 345, prefix, strlen(prefix));
|
||||
}
|
||||
tar_checksum(header);
|
||||
s->pending = malloc(sizeof(header));
|
||||
if(!s->pending) {
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
memcpy(s->pending, header, sizeof(header));
|
||||
s->pending_size = sizeof(header);
|
||||
s->pending_offset = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
tar_push_dir(tar_stream_t *s, const char *path, const char *name) {
|
||||
tar_frame_t *frame = calloc(1, sizeof(*frame));
|
||||
|
||||
if(!frame) {
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
snprintf(frame->path, sizeof(frame->path), "%s", path);
|
||||
snprintf(frame->name, sizeof(frame->name), "%s", name);
|
||||
frame->dir = opendir(path);
|
||||
if(!frame->dir) {
|
||||
int error = errno;
|
||||
free(frame);
|
||||
errno = error;
|
||||
return -1;
|
||||
}
|
||||
frame->next = s->stack;
|
||||
s->stack = frame;
|
||||
s->stack_depth++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void
|
||||
tar_pop_dir(tar_stream_t *s) {
|
||||
tar_frame_t *frame = s->stack;
|
||||
|
||||
if(!frame) {
|
||||
return;
|
||||
}
|
||||
s->stack = frame->next;
|
||||
if(s->stack_depth) {
|
||||
s->stack_depth--;
|
||||
}
|
||||
if(frame->dir) {
|
||||
closedir(frame->dir);
|
||||
}
|
||||
free(frame);
|
||||
}
|
||||
|
||||
static int
|
||||
tar_start_path(tar_stream_t *s, const char *path, const char *name) {
|
||||
struct stat st;
|
||||
|
||||
if(lstat(path, &st)) {
|
||||
return -1;
|
||||
}
|
||||
if(S_ISDIR(st.st_mode)) {
|
||||
char dir_name[PATH_MAX];
|
||||
snprintf(dir_name, sizeof(dir_name), "%s%s", name,
|
||||
name[strlen(name) - 1] == '/' ? "" : "/");
|
||||
return tar_push_dir(s, path, dir_name);
|
||||
}
|
||||
if(!S_ISREG(st.st_mode)) {
|
||||
errno = ENOTSUP;
|
||||
return -1;
|
||||
}
|
||||
if(tar_queue_header(s, name, &st, '0')) {
|
||||
return -1;
|
||||
}
|
||||
s->fd = open(path, O_RDONLY);
|
||||
if(s->fd < 0) {
|
||||
return -1;
|
||||
}
|
||||
snprintf(s->current_file, sizeof(s->current_file), "%s", path);
|
||||
s->file_remaining = (unsigned long long)st.st_size;
|
||||
s->file_padding = (size_t)((512 - ((unsigned long long)st.st_size % 512)) % 512);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
tar_prepare_next(tar_stream_t *s) {
|
||||
while(!s->pending && s->fd < 0 && !s->done) {
|
||||
if(s->task && task_cancel_requested(s->task)) {
|
||||
errno = ECANCELED;
|
||||
return -1;
|
||||
}
|
||||
if(s->pad_remaining) {
|
||||
size_t size = s->pad_remaining > 512 ? 512 : s->pad_remaining;
|
||||
s->pending = calloc(1, size);
|
||||
if(!s->pending) {
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
s->pending_size = size;
|
||||
s->pending_offset = 0;
|
||||
s->pad_remaining -= size;
|
||||
return 0;
|
||||
}
|
||||
if(s->stack) {
|
||||
tar_frame_t *frame = s->stack;
|
||||
struct dirent *entry;
|
||||
struct stat st;
|
||||
|
||||
if(!frame->header_sent) {
|
||||
frame->header_sent = 1;
|
||||
if(lstat(frame->path, &st) ||
|
||||
tar_queue_header(s, frame->name, &st, '5')) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
while((entry = readdir(frame->dir))) {
|
||||
char child[PATH_MAX];
|
||||
char child_name[PATH_MAX];
|
||||
|
||||
if(!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) {
|
||||
continue;
|
||||
}
|
||||
if(path_join(child, sizeof(child), frame->path, entry->d_name) ||
|
||||
path_join(child_name, sizeof(child_name), frame->name,
|
||||
entry->d_name) ||
|
||||
tar_start_path(s, child, child_name)) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
tar_pop_dir(s);
|
||||
continue;
|
||||
}
|
||||
if(s->path_index < s->path_count) {
|
||||
const char *path = s->paths[s->path_index++];
|
||||
if(tar_start_path(s, path, path_basename(path))) {
|
||||
return -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if(s->final_blocks < 2) {
|
||||
s->pending = calloc(1, 512);
|
||||
if(!s->pending) {
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
s->pending_size = 512;
|
||||
s->pending_offset = 0;
|
||||
s->final_blocks++;
|
||||
return 0;
|
||||
}
|
||||
s->done = 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static ssize_t
|
||||
tar_read(void *cls, uint64_t pos, char *buf, size_t max) {
|
||||
tar_stream_t *s = cls;
|
||||
size_t out = 0;
|
||||
unsigned long long progress = 0;
|
||||
char progress_current[PATH_MAX] = {0};
|
||||
(void)pos;
|
||||
|
||||
while(out < max && !s->done) {
|
||||
if(s->task && task_cancel_requested(s->task)) {
|
||||
s->error = ECANCELED;
|
||||
return out ? (ssize_t)out : MHD_CONTENT_READER_END_WITH_ERROR;
|
||||
}
|
||||
if(tar_prepare_next(s)) {
|
||||
s->error = errno ? errno : EIO;
|
||||
return out ? (ssize_t)out : MHD_CONTENT_READER_END_WITH_ERROR;
|
||||
}
|
||||
if(s->pending) {
|
||||
size_t left = s->pending_size - s->pending_offset;
|
||||
size_t take = left < max - out ? left : max - out;
|
||||
memcpy(buf + out, s->pending + s->pending_offset, take);
|
||||
s->pending_offset += take;
|
||||
out += take;
|
||||
if(s->pending_offset >= s->pending_size) {
|
||||
free(s->pending);
|
||||
s->pending = NULL;
|
||||
s->pending_size = 0;
|
||||
s->pending_offset = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if(s->fd >= 0) {
|
||||
size_t want = max - out;
|
||||
ssize_t n;
|
||||
if((unsigned long long)want > s->file_remaining) {
|
||||
want = (size_t)s->file_remaining;
|
||||
}
|
||||
n = read(s->fd, buf + out, want);
|
||||
if(n < 0) {
|
||||
s->error = errno;
|
||||
return out ? (ssize_t)out : MHD_CONTENT_READER_END_WITH_ERROR;
|
||||
}
|
||||
if(!n) {
|
||||
close(s->fd);
|
||||
s->fd = -1;
|
||||
s->current_file[0] = 0;
|
||||
s->file_remaining = 0;
|
||||
continue;
|
||||
}
|
||||
out += (size_t)n;
|
||||
s->file_remaining -= (unsigned long long)n;
|
||||
progress += (unsigned long long)n;
|
||||
if(s->current_file[0]) {
|
||||
snprintf(progress_current, sizeof(progress_current), "%s",
|
||||
s->current_file);
|
||||
}
|
||||
if(!s->file_remaining) {
|
||||
close(s->fd);
|
||||
s->fd = -1;
|
||||
s->current_file[0] = 0;
|
||||
s->pad_remaining = s->file_padding;
|
||||
s->file_padding = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(s->task && progress) {
|
||||
task_update(s->task, TASK_RUNNING,
|
||||
progress_current[0] ? progress_current : NULL,
|
||||
progress, NULL);
|
||||
}
|
||||
return out ? (ssize_t)out : MHD_CONTENT_READER_END_OF_STREAM;
|
||||
}
|
||||
|
||||
static void
|
||||
tar_close(void *cls) {
|
||||
tar_stream_t *s = cls;
|
||||
int canceled;
|
||||
|
||||
if(!s) {
|
||||
return;
|
||||
}
|
||||
if(s->fd >= 0) {
|
||||
close(s->fd);
|
||||
}
|
||||
while(s->stack) {
|
||||
tar_pop_dir(s);
|
||||
}
|
||||
canceled = s->task && task_cancel_requested(s->task);
|
||||
if(s->task) {
|
||||
char current[PATH_MAX];
|
||||
snprintf(current, sizeof(current), "%s",
|
||||
s->task->current[0] ? s->task->current : s->task->src);
|
||||
if(canceled) {
|
||||
task_update(s->task, TASK_CANCELED, current, 0, "canceled");
|
||||
} else if(s->error) {
|
||||
errno = s->error;
|
||||
task_update(s->task, TASK_FAILED, current, 0, strerror(errno));
|
||||
} else if(s->done) {
|
||||
task_update(s->task, TASK_DONE, current, 0, NULL);
|
||||
} else {
|
||||
task_update(s->task, TASK_FAILED, current, 0, "client disconnected");
|
||||
}
|
||||
}
|
||||
free(s->pending);
|
||||
free_paths(s->paths, s->path_count);
|
||||
free(s);
|
||||
}
|
||||
|
||||
static int
|
||||
prepare_download_task(file_task_t *task) {
|
||||
unsigned long long total = 0;
|
||||
size_t file_count = 0;
|
||||
size_t dir_count = 0;
|
||||
size_t i;
|
||||
|
||||
task_update(task, TASK_RUNNING, "preparing", 0, NULL);
|
||||
for(i = 0; i < task->src_count; i++) {
|
||||
if(count_task_path_bytes(task, task->srcs[i], task->srcs[i],
|
||||
&total, &file_count, &dir_count)) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
task->total = total;
|
||||
task->file_count = file_count;
|
||||
task->dir_count = dir_count;
|
||||
task->updated_at = time(NULL);
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static size_t
|
||||
append_truncated_name(char *out, size_t size, size_t len, const char *name,
|
||||
size_t limit) {
|
||||
while(*name && len < limit && len + 1 < size) {
|
||||
unsigned char c = (unsigned char)*name;
|
||||
out[len++] = *name++;
|
||||
if(c >= 0x80) {
|
||||
while((*name & 0xc0) == 0x80 && len < limit && len + 1 < size) {
|
||||
out[len++] = *name++;
|
||||
}
|
||||
}
|
||||
}
|
||||
out[len] = 0;
|
||||
return len;
|
||||
}
|
||||
|
||||
static void
|
||||
download_archive_name(char *out, size_t size, char **paths, size_t count) {
|
||||
char name[PATH_MAX];
|
||||
size_t len = 0;
|
||||
size_t i;
|
||||
|
||||
out[0] = 0;
|
||||
if(count == 1) {
|
||||
path_basename_copy(paths[0], name, sizeof(name));
|
||||
append_truncated_name(out, size, 0, name, DOWNLOAD_ARCHIVE_NAME_LIMIT);
|
||||
} else {
|
||||
for(i = 0; i < count && len < DOWNLOAD_ARCHIVE_NAME_LIMIT; i++) {
|
||||
path_basename_copy(paths[i], name, sizeof(name));
|
||||
if(i && len + 1 < DOWNLOAD_ARCHIVE_NAME_LIMIT && len + 1 < size) {
|
||||
out[len++] = ' ';
|
||||
out[len] = 0;
|
||||
}
|
||||
len = append_truncated_name(out, size, len, name,
|
||||
DOWNLOAD_ARCHIVE_NAME_LIMIT);
|
||||
}
|
||||
}
|
||||
if(!out[0]) {
|
||||
snprintf(out, size, "download");
|
||||
}
|
||||
snprintf(out + strlen(out), size - strlen(out), ".tar");
|
||||
}
|
||||
|
||||
static void
|
||||
download_file_name(char *out, size_t size, const char *path) {
|
||||
path_basename_copy(path, out, size);
|
||||
if(!out[0]) {
|
||||
snprintf(out, size, "download");
|
||||
}
|
||||
}
|
||||
|
||||
static size_t
|
||||
header_quoted_filename(char *out, size_t size, const char *name) {
|
||||
size_t len = 0;
|
||||
|
||||
while(*name && len + 1 < size) {
|
||||
unsigned char c = (unsigned char)*name++;
|
||||
if(c < 0x20 || c == 0x7f || c == '"' || c == '\\') {
|
||||
c = '_';
|
||||
}
|
||||
out[len++] = (char)c;
|
||||
}
|
||||
out[len] = 0;
|
||||
return len;
|
||||
}
|
||||
|
||||
static size_t
|
||||
header_percent_filename(char *out, size_t size, const char *name) {
|
||||
static const char hex[] = "0123456789ABCDEF";
|
||||
size_t len = 0;
|
||||
|
||||
while(*name && len + 1 < size) {
|
||||
unsigned char c = (unsigned char)*name++;
|
||||
int safe = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= 'a' && c <= 'z') || c == '.' || c == '_' || c == '-';
|
||||
if(safe) {
|
||||
out[len++] = (char)c;
|
||||
} else {
|
||||
if(len + 3 >= size) {
|
||||
break;
|
||||
}
|
||||
out[len++] = '%';
|
||||
out[len++] = hex[c >> 4];
|
||||
out[len++] = hex[c & 15];
|
||||
}
|
||||
}
|
||||
out[len] = 0;
|
||||
return len;
|
||||
}
|
||||
|
||||
static void
|
||||
add_download_filename_header(struct MHD_Response *resp, const char *name) {
|
||||
char quoted[PATH_MAX];
|
||||
char encoded[PATH_MAX * 3];
|
||||
char header[PATH_MAX * 4];
|
||||
|
||||
header_quoted_filename(quoted, sizeof(quoted), name);
|
||||
header_percent_filename(encoded, sizeof(encoded), name);
|
||||
snprintf(header, sizeof(header),
|
||||
"attachment; filename=\"%s\"; filename*=UTF-8''%s",
|
||||
quoted, encoded);
|
||||
MHD_add_response_header(resp, "Content-Disposition", header);
|
||||
}
|
||||
|
||||
static enum MHD_Result
|
||||
create_download_task_response(struct MHD_Connection *conn, char **paths,
|
||||
size_t count) {
|
||||
file_task_t *task = calloc(1, sizeof(file_task_t));
|
||||
strbuf_t b = {0};
|
||||
struct stat st;
|
||||
size_t i;
|
||||
|
||||
if(!task) {
|
||||
free_paths(paths, count);
|
||||
return send_json_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "out of memory");
|
||||
}
|
||||
if(!count) {
|
||||
return download_task_request_error(conn, task, paths, count,
|
||||
MHD_HTTP_BAD_REQUEST, "no source paths");
|
||||
}
|
||||
for(i = 0; i < count; i++) {
|
||||
if(lstat(paths[i], &st)) {
|
||||
return download_task_request_error(conn, task, paths, count,
|
||||
MHD_HTTP_NOT_FOUND, "file not found");
|
||||
}
|
||||
if(!S_ISREG(st.st_mode) && !S_ISDIR(st.st_mode)) {
|
||||
return download_task_request_error(conn, task, paths, count,
|
||||
MHD_HTTP_BAD_REQUEST, "invalid path");
|
||||
}
|
||||
}
|
||||
task->op = TASK_DOWNLOAD;
|
||||
task->state = TASK_QUEUED;
|
||||
task->srcs = paths;
|
||||
task->src_count = count;
|
||||
snprintf(task->src, sizeof(task->src), "%s%s", paths[0],
|
||||
count > 1 ? " ..." : "");
|
||||
snprintf(task->current, sizeof(task->current), "%s", task->src);
|
||||
task->created_at = time(NULL);
|
||||
task->updated_at = task->created_at;
|
||||
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
remove_finished_tasks_locked();
|
||||
if(has_active_task_locked()) {
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
return download_task_request_error(conn, task, paths, count,
|
||||
MHD_HTTP_CONFLICT, "another task is running");
|
||||
}
|
||||
task->id = g_next_task_id++;
|
||||
task->next = g_tasks;
|
||||
g_tasks = task;
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
|
||||
strbuf_printf(&b, "{\"ok\":true,\"task_id\":%lu}", task->id);
|
||||
return send_buffer(conn, MHD_HTTP_OK, b.data, "application/json");
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
api_download_prepare(struct MHD_Connection *conn, const char *body,
|
||||
size_t body_size) {
|
||||
char *paths_raw = body_form_value(body, body_size, "paths");
|
||||
char **paths = NULL;
|
||||
size_t count = 0;
|
||||
|
||||
if(!paths_raw || parse_paths(paths_raw, &paths, &count)) {
|
||||
free(paths_raw);
|
||||
return send_json_error(conn, MHD_HTTP_BAD_REQUEST, "invalid path");
|
||||
}
|
||||
free(paths_raw);
|
||||
return create_download_task_response(conn, paths, count);
|
||||
}
|
||||
|
||||
static ssize_t
|
||||
download_file_read(void *cls, uint64_t pos, char *buf, size_t max) {
|
||||
download_file_stream_t *s = cls;
|
||||
ssize_t len;
|
||||
|
||||
if(task_cancel_requested(s->task)) {
|
||||
s->error = ECANCELED;
|
||||
return MHD_CONTENT_READER_END_WITH_ERROR;
|
||||
}
|
||||
len = pread(s->fd, buf, max, (off_t)pos);
|
||||
if(len < 0) {
|
||||
s->error = errno ? errno : EIO;
|
||||
return MHD_CONTENT_READER_END_WITH_ERROR;
|
||||
}
|
||||
if(!len) {
|
||||
s->done = 1;
|
||||
return MHD_CONTENT_READER_END_OF_STREAM;
|
||||
}
|
||||
{
|
||||
unsigned long long end = (unsigned long long)pos + (unsigned long long)len;
|
||||
unsigned long long add = end > s->sent ? end - s->sent : 0;
|
||||
if(end > s->sent) {
|
||||
s->sent = end;
|
||||
}
|
||||
if(s->sent >= s->size) {
|
||||
s->done = 1;
|
||||
}
|
||||
task_update(s->task, TASK_RUNNING, s->task->src, add, NULL);
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
static void
|
||||
download_file_close(void *cls) {
|
||||
download_file_stream_t *s = cls;
|
||||
int canceled;
|
||||
|
||||
if(!s) {
|
||||
return;
|
||||
}
|
||||
if(s->fd >= 0) {
|
||||
close(s->fd);
|
||||
}
|
||||
canceled = task_cancel_requested(s->task);
|
||||
if(canceled) {
|
||||
task_update(s->task, TASK_CANCELED, s->task->src, 0, "canceled");
|
||||
} else if(s->error) {
|
||||
errno = s->error;
|
||||
task_update(s->task, TASK_FAILED, s->task->src, 0, strerror(errno));
|
||||
} else if(s->done) {
|
||||
task_update(s->task, TASK_DONE, s->task->src, 0, NULL);
|
||||
} else {
|
||||
task_update(s->task, TASK_FAILED, s->task->src, 0, "client disconnected");
|
||||
}
|
||||
free(s);
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
api_download(struct MHD_Connection *conn) {
|
||||
char *idstr = query_value(conn, "id");
|
||||
unsigned long id = idstr ? strtoul(idstr, NULL, 10) : 0;
|
||||
char **paths = NULL;
|
||||
size_t count = 0;
|
||||
struct stat st;
|
||||
tar_stream_t *stream;
|
||||
struct MHD_Response *resp;
|
||||
enum MHD_Result ret;
|
||||
file_task_t *task;
|
||||
char download_name[PATH_MAX];
|
||||
|
||||
free(idstr);
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
task = find_task_locked(id);
|
||||
if(!task || task->op != TASK_DOWNLOAD || task->state != TASK_QUEUED) {
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
return send_json_error(conn, MHD_HTTP_NOT_FOUND, "active task not found");
|
||||
}
|
||||
task->state = TASK_RUNNING;
|
||||
task->updated_at = time(NULL);
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
|
||||
if(prepare_download_task(task)) {
|
||||
char current[PATH_MAX];
|
||||
snprintf(current, sizeof(current), "%s",
|
||||
task->current[0] ? task->current : task->src);
|
||||
if(errno == ECANCELED || task_cancel_requested(task)) {
|
||||
task_update(task, TASK_CANCELED, current, 0, "canceled");
|
||||
} else {
|
||||
task_update(task, TASK_FAILED, current, 0, strerror(errno));
|
||||
}
|
||||
return send_json_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, NULL);
|
||||
}
|
||||
|
||||
paths = task->srcs;
|
||||
count = task->src_count;
|
||||
if(count == 1 && !stat(paths[0], &st) && S_ISREG(st.st_mode)) {
|
||||
download_file_stream_t *file_stream = calloc(1, sizeof(*file_stream));
|
||||
if(!file_stream) {
|
||||
task_update(task, TASK_FAILED, task->src, 0, "out of memory");
|
||||
return send_json_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "out of memory");
|
||||
}
|
||||
file_stream->task = task;
|
||||
file_stream->fd = -1;
|
||||
file_stream->size = (unsigned long long)st.st_size;
|
||||
file_stream->done = file_stream->size == 0;
|
||||
file_stream->fd = open(paths[0], O_RDONLY);
|
||||
if(file_stream->fd < 0) {
|
||||
free(file_stream);
|
||||
task_update(task, TASK_FAILED, task->src, 0, strerror(errno));
|
||||
return send_json_error(conn, MHD_HTTP_NOT_FOUND, "file not found");
|
||||
}
|
||||
resp = MHD_create_response_from_callback((uint64_t)st.st_size,
|
||||
DOWNLOAD_BUFFER_SIZE,
|
||||
download_file_read, file_stream,
|
||||
download_file_close);
|
||||
if(!resp) {
|
||||
download_file_close(file_stream);
|
||||
return MHD_NO;
|
||||
}
|
||||
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_TYPE,
|
||||
"application/octet-stream");
|
||||
download_file_name(download_name, sizeof(download_name), paths[0]);
|
||||
add_download_filename_header(resp, download_name);
|
||||
ret = websrv_queue_response(conn, MHD_HTTP_OK, resp);
|
||||
MHD_destroy_response(resp);
|
||||
return ret;
|
||||
}
|
||||
if(!(stream = calloc(1, sizeof(*stream)))) {
|
||||
task_update(task, TASK_FAILED, task->src, 0, "out of memory");
|
||||
return send_json_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "out of memory");
|
||||
}
|
||||
stream->fd = -1;
|
||||
stream->task = task;
|
||||
stream->paths = NULL;
|
||||
stream->path_count = count;
|
||||
stream->paths = calloc(count, sizeof(char *));
|
||||
if(!stream->paths) {
|
||||
tar_close(stream);
|
||||
task_update(task, TASK_FAILED, task->src, 0, "out of memory");
|
||||
return send_json_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "out of memory");
|
||||
}
|
||||
for(size_t i = 0; i < count; i++) {
|
||||
stream->paths[i] = strdup(paths[i]);
|
||||
if(!stream->paths[i]) {
|
||||
stream->path_count = i;
|
||||
tar_close(stream);
|
||||
task_update(task, TASK_FAILED, task->src, 0, "out of memory");
|
||||
return send_json_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "out of memory");
|
||||
}
|
||||
}
|
||||
resp = MHD_create_response_from_callback(MHD_SIZE_UNKNOWN,
|
||||
DOWNLOAD_BUFFER_SIZE,
|
||||
tar_read, stream, tar_close);
|
||||
if(!resp) {
|
||||
tar_close(stream);
|
||||
return MHD_NO;
|
||||
}
|
||||
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_TYPE,
|
||||
"application/x-tar");
|
||||
download_archive_name(download_name, sizeof(download_name), paths, count);
|
||||
add_download_filename_header(resp, download_name);
|
||||
ret = websrv_queue_response(conn, MHD_HTTP_OK, resp);
|
||||
MHD_destroy_response(resp);
|
||||
return ret;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
#include "filemgr.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "extract.h"
|
||||
#include "filemgr_internal.h"
|
||||
#include "json_util.h"
|
||||
#include "path_util.h"
|
||||
#include "rar_extract.h"
|
||||
#include "zip_extract.h"
|
||||
|
||||
/* Cancellation callback: stop when the task is asked to cancel. */
|
||||
static int
|
||||
extract_cancel(void *userdata) {
|
||||
file_task_t *task = userdata;
|
||||
|
||||
return task_cancel_requested(task);
|
||||
}
|
||||
|
||||
/* Case-insensitive suffix check. Returns 1 if path ends in suffix
|
||||
(the comparison ignores trailing slashes, so "x.rar/" is still .rar). */
|
||||
static int
|
||||
ends_with_ci(const char *path, const char *suffix) {
|
||||
size_t path_len = strlen(path);
|
||||
size_t suf_len = strlen(suffix);
|
||||
|
||||
if(path_len < suf_len) {
|
||||
return 0;
|
||||
}
|
||||
/* Trim trailing path separators (defensive — the API rejects them but
|
||||
we get here with whatever path the caller passed). */
|
||||
while(path_len && path[path_len - 1] == '/') {
|
||||
path_len--;
|
||||
}
|
||||
if(path_len < suf_len) {
|
||||
return 0;
|
||||
}
|
||||
return !strcasecmp(path + path_len - suf_len, suffix);
|
||||
}
|
||||
|
||||
/* Progress callback. The engine already throttles reports (200 ms / 1 MiB),
|
||||
so we can forward each report straight into the shared task state.
|
||||
Defined before extract_dispatch() so the dispatcher's call site compiles
|
||||
cleanly under -Werror=implicit-function-declaration. */
|
||||
static void
|
||||
extract_progress(void *userdata, const zipx_progress_t *p) {
|
||||
file_task_t *task = userdata;
|
||||
unsigned long long prev_done;
|
||||
unsigned long long delta;
|
||||
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
task->entries_total = p->entries_total;
|
||||
task->entries_done = p->entries_done;
|
||||
task->total = p->bytes_total;
|
||||
prev_done = task->done;
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
|
||||
delta = p->bytes_done > prev_done ? p->bytes_done - prev_done : 0;
|
||||
task_update(task, TASK_RUNNING, p->current ? p->current : task->src,
|
||||
delta, NULL);
|
||||
}
|
||||
|
||||
/* Pick the right engine by the archive file name. Returns ZIPX_ERR_FORMAT
|
||||
for anything that does not look like a supported archive. */
|
||||
static zipx_status_t
|
||||
extract_dispatch(file_task_t *task, zipx_conflict_t conflict,
|
||||
const zipx_limits_t *limits, zipx_result_t *result) {
|
||||
if(ends_with_ci(task->src, ".zip")) {
|
||||
return zipx_extract(task->src, task->dst, conflict, limits,
|
||||
extract_cancel, extract_progress, task, result);
|
||||
}
|
||||
if(ends_with_ci(task->src, ".rar")) {
|
||||
return rar_extract(task->src, task->dst, conflict, limits,
|
||||
extract_cancel, extract_progress, task, result);
|
||||
}
|
||||
{
|
||||
size_t len = strlen(task->src);
|
||||
if(len > sizeof(result->detail) - 1) {
|
||||
len = sizeof(result->detail) - 1;
|
||||
}
|
||||
memcpy(result->detail, task->src, len);
|
||||
result->detail[len] = 0;
|
||||
snprintf(result->message, sizeof(result->message),
|
||||
"unsupported archive format (only .zip and .rar are accepted)");
|
||||
return ZIPX_ERR_UNSUPPORTED;
|
||||
}
|
||||
}
|
||||
|
||||
static const char *
|
||||
extract_error_code(zipx_status_t status) {
|
||||
switch(status) {
|
||||
case ZIPX_ERR_OPEN: return "extract_open_failed";
|
||||
case ZIPX_ERR_FORMAT: return "extract_corrupt";
|
||||
case ZIPX_ERR_UNSUPPORTED: return "extract_unsupported";
|
||||
case ZIPX_ERR_UNSAFE_NAME: return "extract_unsafe_name";
|
||||
case ZIPX_ERR_SPECIAL: return "extract_special_entry";
|
||||
case ZIPX_ERR_DUPLICATE: return "extract_duplicate";
|
||||
case ZIPX_ERR_LIMIT_ENTRIES: return "extract_too_many_entries";
|
||||
case ZIPX_ERR_LIMIT_FILE: return "extract_entry_too_large";
|
||||
case ZIPX_ERR_LIMIT_TOTAL: return "extract_too_large";
|
||||
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_CONFLICT: return "extract_conflict";
|
||||
case ZIPX_ERR_SPACE: return "no_space";
|
||||
case ZIPX_ERR_IO: return "extract_io";
|
||||
case ZIPX_ERR_CRC: return "extract_crc";
|
||||
default: return "extract_failed";
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
extract_set_error(file_task_t *task, zipx_status_t status,
|
||||
const zipx_result_t *result) {
|
||||
const char *code = extract_error_code(status);
|
||||
const char *detail = result->detail[0] ? result->detail : NULL;
|
||||
const char *msg = result->message[0] ? result->message :
|
||||
zipx_status_string(status);
|
||||
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
snprintf(task->error_code, sizeof(task->error_code), "%s", code);
|
||||
if(detail) {
|
||||
size_t n = strlen(detail);
|
||||
if(n >= sizeof(task->error_arg)) {
|
||||
n = sizeof(task->error_arg) - 1;
|
||||
}
|
||||
memcpy(task->error_arg, detail, n);
|
||||
task->error_arg[n] = 0;
|
||||
}
|
||||
task->updated_at = time(NULL);
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
|
||||
task_update(task, TASK_FAILED, detail ? detail : task->src, 0, msg);
|
||||
}
|
||||
|
||||
static void *
|
||||
extract_worker(void *arg) {
|
||||
file_task_t *task = arg;
|
||||
zipx_result_t result = {0};
|
||||
zipx_conflict_t conflict;
|
||||
zipx_status_t status;
|
||||
|
||||
switch(task->extract_conflict) {
|
||||
case EXTRACT_CONFLICT_OVERWRITE:
|
||||
conflict = ZIPX_CONFLICT_OVERWRITE;
|
||||
break;
|
||||
case EXTRACT_CONFLICT_MERGE:
|
||||
conflict = ZIPX_CONFLICT_MERGE;
|
||||
break;
|
||||
default:
|
||||
conflict = ZIPX_CONFLICT_FAIL;
|
||||
break;
|
||||
}
|
||||
|
||||
task_update(task, TASK_RUNNING, "scanning archive", 0, NULL);
|
||||
|
||||
status = extract_dispatch(task, conflict,
|
||||
zipx_limits_profile(task->extract_large),
|
||||
&result);
|
||||
|
||||
if(status == ZIPX_OK) {
|
||||
time_t completed_at = time(NULL);
|
||||
|
||||
/* Only delete the source archive when this task owns it (upload flow). */
|
||||
if(task->extract_remove_source && task->src[0]) {
|
||||
unlink(task->src);
|
||||
}
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
task->state = TASK_DONE;
|
||||
if(task->total) {
|
||||
task->done = task->total;
|
||||
}
|
||||
task->entries_done = task->entries_total;
|
||||
task->updated_at = completed_at;
|
||||
record_task_completion_locked(task, completed_at);
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
} else if(status == ZIPX_ERR_CANCELED) {
|
||||
task_update(task, TASK_CANCELED,
|
||||
task->current[0] ? task->current : task->src, 0, "canceled");
|
||||
} else {
|
||||
extract_set_error(task, status, &result);
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
api_extract(struct MHD_Connection *conn, const char *body, size_t body_size) {
|
||||
char *path = fs_path_value(body_form_value(body, body_size, "path"));
|
||||
char *dst_dir = fs_path_value(body_form_value(body, body_size, "dst_dir"));
|
||||
char *conflict_str = body_form_value(body, body_size, "conflict");
|
||||
char *remove_str = body_form_value(body, body_size, "remove_source");
|
||||
char *large_str = body_form_value(body, body_size, "large");
|
||||
extract_conflict_t conflict = EXTRACT_CONFLICT_FAIL;
|
||||
int remove_source = remove_str && !strcmp(remove_str, "1");
|
||||
int large = large_str && !strcmp(large_str, "1");
|
||||
file_task_t *task;
|
||||
strbuf_t b = {0};
|
||||
struct stat st;
|
||||
|
||||
if(!path || !dst_dir || !path[0] || !dst_dir[0]) {
|
||||
free(path); free(dst_dir); free(conflict_str); free(remove_str); free(large_str);
|
||||
return send_json_error(conn, MHD_HTTP_BAD_REQUEST, "invalid path");
|
||||
}
|
||||
if(conflict_str) {
|
||||
if(!strcmp(conflict_str, "overwrite")) {
|
||||
conflict = EXTRACT_CONFLICT_OVERWRITE;
|
||||
} else if(!strcmp(conflict_str, "merge")) {
|
||||
conflict = EXTRACT_CONFLICT_MERGE;
|
||||
} else if(strcmp(conflict_str, "fail")) {
|
||||
free(path); free(dst_dir); free(conflict_str); free(remove_str); free(large_str);
|
||||
return send_json_error(conn, MHD_HTTP_BAD_REQUEST, "invalid conflict");
|
||||
}
|
||||
}
|
||||
if(stat(path, &st) || !S_ISREG(st.st_mode)) {
|
||||
free(path); free(dst_dir); free(conflict_str); free(remove_str); free(large_str);
|
||||
return send_json_error(conn, MHD_HTTP_BAD_REQUEST, "file not found");
|
||||
}
|
||||
if(stat(dst_dir, &st) || !S_ISDIR(st.st_mode)) {
|
||||
free(path); free(dst_dir); free(conflict_str); free(remove_str); free(large_str);
|
||||
return send_json_error(conn, MHD_HTTP_BAD_REQUEST,
|
||||
"destination must be a directory");
|
||||
}
|
||||
|
||||
task = calloc(1, sizeof(*task));
|
||||
if(!task) {
|
||||
free(path); free(dst_dir); free(conflict_str); free(remove_str); free(large_str);
|
||||
return send_json_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||
"out of memory");
|
||||
}
|
||||
|
||||
task->op = TASK_EXTRACT;
|
||||
task->state = TASK_QUEUED;
|
||||
task->extract_conflict = (int)conflict;
|
||||
task->extract_remove_source = remove_source;
|
||||
task->extract_large = large;
|
||||
snprintf(task->src, sizeof(task->src), "%s", path);
|
||||
snprintf(task->dst, sizeof(task->dst), "%s", dst_dir);
|
||||
task->created_at = time(NULL);
|
||||
task->updated_at = task->created_at;
|
||||
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
remove_finished_tasks_locked();
|
||||
if(has_active_task_locked()) {
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
free_task(task);
|
||||
free(path); free(dst_dir); free(conflict_str); free(remove_str); free(large_str);
|
||||
return send_json_error(conn, MHD_HTTP_CONFLICT, "another task is running");
|
||||
}
|
||||
task->id = g_next_task_id++;
|
||||
task->next = g_tasks;
|
||||
g_tasks = task;
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
|
||||
if(pthread_create(&task->thread, NULL, extract_worker, task)) {
|
||||
task_update(task, TASK_FAILED, NULL, 0, "pthread_create failed");
|
||||
} else {
|
||||
pthread_detach(task->thread);
|
||||
}
|
||||
|
||||
free(path); free(dst_dir); free(conflict_str); free(remove_str); free(large_str);
|
||||
strbuf_printf(&b, "{\"ok\":true,\"task_id\":%lu}", task->id);
|
||||
return send_buffer(conn, MHD_HTTP_OK, b.data, "application/json");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <microhttpd.h>
|
||||
|
||||
/* Conflict policy for ZIP extraction, mirrors the zipx_conflict_t values. */
|
||||
typedef enum extract_conflict {
|
||||
EXTRACT_CONFLICT_FAIL = 0,
|
||||
EXTRACT_CONFLICT_OVERWRITE = 1,
|
||||
EXTRACT_CONFLICT_MERGE = 2
|
||||
} extract_conflict_t;
|
||||
|
||||
/* POST /api/extract handler.
|
||||
Accepts a form-encoded body (matching the other filemgr endpoints):
|
||||
path - ZIP path on the device (required)
|
||||
dst_dir - target directory (required)
|
||||
conflict - "fail" (default) | "overwrite" | "merge"
|
||||
remove_source - "1" deletes the source ZIP after a successful extraction
|
||||
(used by the "upload and extract" flow; never set for a
|
||||
pre-existing user archive).
|
||||
Returns {"ok":true,"task_id":N} or a JSON error. */
|
||||
enum MHD_Result api_extract(struct MHD_Connection *conn, const char *body,
|
||||
size_t body_size);
|
||||
@@ -0,0 +1,68 @@
|
||||
#include "filemgr.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include "filemgr_internal.h"
|
||||
#include "mime.h"
|
||||
#include "path_util.h"
|
||||
#include "websrv.h"
|
||||
|
||||
static ssize_t
|
||||
file_read(void *cls, uint64_t pos, char *buf, size_t max) {
|
||||
FILE *file = cls;
|
||||
size_t len;
|
||||
|
||||
if(fseek(file, (long)pos, SEEK_SET)) {
|
||||
return MHD_CONTENT_READER_END_WITH_ERROR;
|
||||
}
|
||||
if(!(len = fread(buf, 1, max, file))) {
|
||||
return ferror(file) ? MHD_CONTENT_READER_END_WITH_ERROR :
|
||||
MHD_CONTENT_READER_END_OF_STREAM;
|
||||
}
|
||||
return (ssize_t)len;
|
||||
}
|
||||
|
||||
static void
|
||||
file_close(void *cls) {
|
||||
fclose((FILE *)cls);
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
filemgr_fs_request(struct MHD_Connection *conn) {
|
||||
char *path = fs_path_value(query_value(conn, "path"));
|
||||
struct MHD_Response *resp;
|
||||
enum MHD_Result ret = MHD_NO;
|
||||
struct stat st;
|
||||
FILE *file;
|
||||
|
||||
if(has_active_task()) {
|
||||
return send_json_error(conn, MHD_HTTP_CONFLICT, "another task is running");
|
||||
}
|
||||
|
||||
if(!path || stat(path, &st) || !S_ISREG(st.st_mode) ||
|
||||
!(file = fopen(path, "rb"))) {
|
||||
free(path);
|
||||
return send_json_error(conn, MHD_HTTP_NOT_FOUND, "file not found");
|
||||
}
|
||||
|
||||
if((resp = MHD_create_response_from_callback((uint64_t)st.st_size,
|
||||
32 * 0x4000, file_read, file,
|
||||
file_close))) {
|
||||
const char *mime = mime_get_type(path);
|
||||
if(mime) {
|
||||
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_TYPE, mime);
|
||||
}
|
||||
ret = websrv_queue_response(conn, MHD_HTTP_OK, resp);
|
||||
MHD_destroy_response(resp);
|
||||
free(path);
|
||||
return ret;
|
||||
}
|
||||
|
||||
fclose(file);
|
||||
free(path);
|
||||
return MHD_NO;
|
||||
}
|
||||
@@ -3,5 +3,12 @@
|
||||
#include <microhttpd.h>
|
||||
|
||||
enum MHD_Result filemgr_api_request(struct MHD_Connection *conn,
|
||||
const char *url);
|
||||
const char *url, const char *method,
|
||||
const char *body, size_t body_size);
|
||||
enum MHD_Result filemgr_fs_request(struct MHD_Connection *conn);
|
||||
|
||||
int filemgr_upload_begin(struct MHD_Connection *conn, void **upload_ctx);
|
||||
int filemgr_upload_data(void *upload_ctx, const char *data, size_t size);
|
||||
enum MHD_Result filemgr_upload_finish(struct MHD_Connection *conn,
|
||||
void *upload_ctx);
|
||||
void filemgr_upload_free(void *upload_ctx);
|
||||
@@ -0,0 +1,134 @@
|
||||
#pragma once
|
||||
|
||||
#include <limits.h>
|
||||
#include <pthread.h>
|
||||
#include <stddef.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <microhttpd.h>
|
||||
|
||||
#define ETA_SAMPLE_SLOTS 64
|
||||
|
||||
typedef enum task_op {
|
||||
TASK_COPY,
|
||||
TASK_MOVE,
|
||||
TASK_DELETE,
|
||||
TASK_CHMOD,
|
||||
TASK_DOWNLOAD,
|
||||
TASK_UPLOAD,
|
||||
TASK_PKG_INSTALL,
|
||||
TASK_EXTRACT,
|
||||
} task_op_t;
|
||||
|
||||
typedef enum task_state {
|
||||
TASK_QUEUED,
|
||||
TASK_RUNNING,
|
||||
TASK_DONE,
|
||||
TASK_FAILED,
|
||||
TASK_CANCELED,
|
||||
} task_state_t;
|
||||
|
||||
typedef struct task_eta_sample {
|
||||
unsigned long long done;
|
||||
struct timespec time;
|
||||
} task_eta_sample_t;
|
||||
|
||||
typedef struct file_task {
|
||||
unsigned long id;
|
||||
task_op_t op;
|
||||
task_state_t state;
|
||||
char src[PATH_MAX];
|
||||
char dst[PATH_MAX];
|
||||
char current[PATH_MAX];
|
||||
char error[160];
|
||||
char error_code[64];
|
||||
char error_arg[PATH_MAX + 96];
|
||||
char **srcs;
|
||||
size_t src_count;
|
||||
size_t file_count;
|
||||
size_t dir_count;
|
||||
size_t upload_completed;
|
||||
unsigned int chmod_mode;
|
||||
int recursive;
|
||||
unsigned long long total;
|
||||
unsigned long long done;
|
||||
unsigned long long speed;
|
||||
unsigned long long eta;
|
||||
unsigned long long entries_total;
|
||||
unsigned long long entries_done;
|
||||
int extract_conflict;
|
||||
int extract_remove_source;
|
||||
int extract_large;
|
||||
unsigned long long speed_sample_done;
|
||||
struct timespec speed_sample_time;
|
||||
task_eta_sample_t eta_samples[ETA_SAMPLE_SLOTS];
|
||||
unsigned int eta_sample_next;
|
||||
unsigned int eta_sample_count;
|
||||
int cancel_requested;
|
||||
int reported; /* Terminal state has been included in /api/tasks. */
|
||||
unsigned int active_streams;
|
||||
time_t created_at;
|
||||
time_t transfer_started_at;
|
||||
time_t updated_at;
|
||||
pthread_t thread;
|
||||
struct file_task *next;
|
||||
} file_task_t;
|
||||
|
||||
extern pthread_mutex_t g_tasks_lock;
|
||||
extern file_task_t *g_tasks;
|
||||
extern unsigned long g_next_task_id;
|
||||
|
||||
const char *task_op_name(task_op_t op);
|
||||
const char *task_state_name(task_state_t state);
|
||||
int task_is_active(const file_task_t *task);
|
||||
int has_active_task_locked(void);
|
||||
int has_active_task(void);
|
||||
void free_task(file_task_t *task);
|
||||
void remove_finished_tasks_locked(void);
|
||||
int task_cancel_requested(file_task_t *task);
|
||||
file_task_t *find_task_locked(unsigned long id);
|
||||
void task_update(file_task_t *task, task_state_t state, const char *current,
|
||||
unsigned long long add_done, const char *error);
|
||||
void record_task_completion_locked(file_task_t *task, time_t completed_at);
|
||||
|
||||
enum MHD_Result send_json_ok(struct MHD_Connection *conn);
|
||||
enum MHD_Result send_json_error(struct MHD_Connection *conn,
|
||||
unsigned int status, const char *msg);
|
||||
enum MHD_Result send_json_error_detail(struct MHD_Connection *conn,
|
||||
unsigned int status, const char *msg,
|
||||
const char *code, const char *arg);
|
||||
enum MHD_Result send_buffer(struct MHD_Connection *conn, unsigned int status,
|
||||
char *data, const char *mime);
|
||||
|
||||
int ensure_parent_dirs(const char *base, const char *rel);
|
||||
int chmod_path_mode(const char *path, unsigned int mode);
|
||||
int chmod_path_0777(const char *path);
|
||||
int fchmod_0777(int fd);
|
||||
int ignore_chmod_error(int err);
|
||||
int mode_access(const char *path, int mode);
|
||||
int check_target_writable(const char *target, char ***checked_dirs,
|
||||
size_t *checked_dir_count,
|
||||
char *error, size_t error_size,
|
||||
char *code, size_t code_size,
|
||||
char *arg, size_t arg_size);
|
||||
int check_target_space(const char *target, unsigned long long required,
|
||||
char *error, size_t error_size,
|
||||
char *code, size_t code_size,
|
||||
char *arg, size_t arg_size);
|
||||
int target_available_space(const char *target, unsigned long long *available);
|
||||
int count_task_path_bytes(file_task_t *task, const char *path,
|
||||
const char *display, unsigned long long *total,
|
||||
size_t *file_count, size_t *dir_count);
|
||||
|
||||
enum MHD_Result api_upload_prepare(struct MHD_Connection *conn,
|
||||
const char *body, size_t body_size);
|
||||
enum MHD_Result api_upload_finish(struct MHD_Connection *conn);
|
||||
enum MHD_Result api_download_prepare(struct MHD_Connection *conn,
|
||||
const char *body, size_t body_size);
|
||||
enum MHD_Result api_download(struct MHD_Connection *conn);
|
||||
enum MHD_Result api_list(struct MHD_Connection *conn);
|
||||
enum MHD_Result api_space(struct MHD_Connection *conn);
|
||||
enum MHD_Result api_text(struct MHD_Connection *conn);
|
||||
enum MHD_Result api_text_create(struct MHD_Connection *conn);
|
||||
enum MHD_Result api_text_save(struct MHD_Connection *conn, const char *body,
|
||||
size_t body_size);
|
||||
@@ -0,0 +1,371 @@
|
||||
#include "filemgr_internal.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/statvfs.h>
|
||||
#include <sys/types.h>
|
||||
#ifdef __linux__
|
||||
#include <sys/vfs.h>
|
||||
#else
|
||||
#include <sys/mount.h>
|
||||
#endif
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "path_util.h"
|
||||
|
||||
int
|
||||
ignore_chmod_error(int err) {
|
||||
return err == ENOTSUP || err == EPERM || err == EINVAL || err == EROFS;
|
||||
}
|
||||
|
||||
#ifndef __linux__
|
||||
static int
|
||||
fs_type_has_unix_modes(const char *type) {
|
||||
return strcmp(type, "exfat") &&
|
||||
strcmp(type, "exfatfs") &&
|
||||
strcmp(type, "msdosfs") &&
|
||||
strcmp(type, "fat") &&
|
||||
strcmp(type, "vfat");
|
||||
}
|
||||
#endif
|
||||
|
||||
static int
|
||||
path_has_unix_modes(const char *path) {
|
||||
#ifdef __linux__
|
||||
(void)path;
|
||||
return 1;
|
||||
#else
|
||||
struct statfs fs;
|
||||
|
||||
if(statfs(path, &fs)) {
|
||||
return 1;
|
||||
}
|
||||
return fs_type_has_unix_modes(fs.f_fstypename);
|
||||
#endif
|
||||
}
|
||||
|
||||
static int
|
||||
fd_has_unix_modes(int fd) {
|
||||
#ifdef __linux__
|
||||
(void)fd;
|
||||
return 1;
|
||||
#else
|
||||
struct statfs fs;
|
||||
|
||||
if(fstatfs(fd, &fs)) {
|
||||
return 1;
|
||||
}
|
||||
return fs_type_has_unix_modes(fs.f_fstypename);
|
||||
#endif
|
||||
}
|
||||
|
||||
int
|
||||
chmod_path_mode(const char *path, unsigned int mode) {
|
||||
if(!path_has_unix_modes(path)) {
|
||||
return 0;
|
||||
}
|
||||
if(chmod(path, (mode_t)(mode & 0777))) {
|
||||
/* A filesystem that does not implement Unix modes is compatible with the
|
||||
paste behavior. Real permission and read-only errors must reach the UI. */
|
||||
if(errno != ENOTSUP && errno != EINVAL) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
chmod_path_0777(const char *path) {
|
||||
if(!path_has_unix_modes(path)) {
|
||||
return 0;
|
||||
}
|
||||
if(chmod(path, 0777) && !ignore_chmod_error(errno)) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
fchmod_0777(int fd) {
|
||||
if(!fd_has_unix_modes(fd)) {
|
||||
return 0;
|
||||
}
|
||||
if(fchmod(fd, 0777) && !ignore_chmod_error(errno)) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
target_statvfs(const char *target, struct statvfs *vfs) {
|
||||
char parent[PATH_MAX];
|
||||
|
||||
if(!statvfs(target, vfs)) {
|
||||
return 0;
|
||||
}
|
||||
if(path_dirname(target, parent, sizeof(parent))) {
|
||||
return -1;
|
||||
}
|
||||
return statvfs(parent, vfs);
|
||||
}
|
||||
|
||||
int
|
||||
target_available_space(const char *target, unsigned long long *available) {
|
||||
struct statvfs vfs;
|
||||
unsigned long long block_size;
|
||||
|
||||
if(target_statvfs(target, &vfs)) {
|
||||
return -1;
|
||||
}
|
||||
block_size = vfs.f_frsize ? vfs.f_frsize : vfs.f_bsize;
|
||||
*available = (unsigned long long)vfs.f_bavail * block_size;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
check_target_space(const char *target, unsigned long long required,
|
||||
char *error, size_t error_size,
|
||||
char *code, size_t code_size,
|
||||
char *arg, size_t arg_size) {
|
||||
unsigned long long available;
|
||||
|
||||
if(!required) {
|
||||
return 0;
|
||||
}
|
||||
if(target_available_space(target, &available)) {
|
||||
snprintf(error, error_size, "cannot read target free space");
|
||||
snprintf(code, code_size, "space_check_failed");
|
||||
snprintf(arg, arg_size, "%s", target);
|
||||
return -1;
|
||||
}
|
||||
if(available < required) {
|
||||
snprintf(error, error_size,
|
||||
"not enough target space, required %llu bytes, available %llu bytes",
|
||||
required, available);
|
||||
snprintf(code, code_size, "no_space");
|
||||
snprintf(arg, arg_size, "%llu,%llu", required, available);
|
||||
errno = ENOSPC;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void
|
||||
set_error_detail(char *error, size_t error_size, char *code, size_t code_size,
|
||||
char *arg, size_t arg_size, const char *error_code,
|
||||
const char *message, const char *path) {
|
||||
snprintf(error, error_size, "%s: %s", message, path);
|
||||
snprintf(code, code_size, "%s", error_code);
|
||||
snprintf(arg, arg_size, "%s", path);
|
||||
}
|
||||
|
||||
static void
|
||||
set_permission_error_detail(char *error, size_t error_size,
|
||||
char *code, size_t code_size,
|
||||
char *arg, size_t arg_size,
|
||||
const char *error_code, const char *message,
|
||||
const char *path) {
|
||||
struct stat st;
|
||||
|
||||
set_error_detail(error, error_size, code, code_size, arg, arg_size,
|
||||
error_code, message, path);
|
||||
if(!stat(path, &st)) {
|
||||
snprintf(arg, arg_size, "%s (mode=%04o, uid=%lu, gid=%lu)", path,
|
||||
(unsigned int)(st.st_mode & 07777),
|
||||
(unsigned long)st.st_uid, (unsigned long)st.st_gid);
|
||||
}
|
||||
}
|
||||
|
||||
static int
|
||||
mode_access_stat(const struct stat *st, int mode) {
|
||||
mode_t allowed;
|
||||
uid_t uid = geteuid();
|
||||
|
||||
if(uid == 0) {
|
||||
if(!(mode & X_OK) || !S_ISREG(st->st_mode) ||
|
||||
(st->st_mode & (S_IXUSR | S_IXGRP | S_IXOTH))) {
|
||||
return 0;
|
||||
}
|
||||
} else if(uid == st->st_uid) {
|
||||
allowed = (st->st_mode >> 6) & 7;
|
||||
if((allowed & mode) == (mode_t)mode) {
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
gid_t gid = getegid();
|
||||
int group_match = gid == st->st_gid;
|
||||
|
||||
if(!group_match) {
|
||||
int count = getgroups(0, NULL);
|
||||
gid_t *groups = count > 0 ? malloc((size_t)count * sizeof(*groups)) : NULL;
|
||||
|
||||
if(groups && getgroups(count, groups) == count) {
|
||||
int i;
|
||||
for(i = 0; i < count; i++) {
|
||||
if(groups[i] == st->st_gid) {
|
||||
group_match = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
free(groups);
|
||||
}
|
||||
allowed = group_match ? (st->st_mode >> 3) & 7 : st->st_mode & 7;
|
||||
if((allowed & mode) == (mode_t)mode) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
errno = EACCES;
|
||||
return -1;
|
||||
}
|
||||
|
||||
int
|
||||
mode_access(const char *path, int mode) {
|
||||
struct stat st;
|
||||
|
||||
return stat(path, &st) ? -1 : mode_access_stat(&st, mode);
|
||||
}
|
||||
|
||||
static int
|
||||
probe_dir_writable(const char *path) {
|
||||
char name[80];
|
||||
char probe[PATH_MAX];
|
||||
int attempt;
|
||||
|
||||
for(attempt = 0; attempt < 16; attempt++) {
|
||||
int fd;
|
||||
int error = 0;
|
||||
|
||||
snprintf(name, sizeof(name), ".web-file-mgr-%ld-%lld-%d.tmp",
|
||||
(long)getpid(), (long long)time(NULL), attempt);
|
||||
if(path_join(probe, sizeof(probe), path, name)) {
|
||||
return -1;
|
||||
}
|
||||
fd = open(probe, O_WRONLY | O_CREAT | O_EXCL, 0600);
|
||||
if(fd < 0) {
|
||||
if(errno == EEXIST) {
|
||||
continue;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
if(close(fd)) {
|
||||
error = errno;
|
||||
}
|
||||
if(unlink(probe) && !error) {
|
||||
error = errno;
|
||||
}
|
||||
if(error) {
|
||||
errno = error;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
errno = EEXIST;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int
|
||||
probe_file_writable(const char *path) {
|
||||
int fd = open(path, O_WRONLY);
|
||||
|
||||
return fd < 0 ? -1 : close(fd);
|
||||
}
|
||||
|
||||
static int
|
||||
path_seen(char **paths, size_t count, const char *path) {
|
||||
size_t i;
|
||||
|
||||
for(i = 0; i < count; i++) {
|
||||
if(!strcmp(paths[i], path)) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
remember_path(char ***paths, size_t *count, const char *path) {
|
||||
char **tmp;
|
||||
|
||||
if(path_seen(*paths, *count, path)) {
|
||||
return 0;
|
||||
}
|
||||
tmp = realloc(*paths, sizeof(char *) * (*count + 1));
|
||||
if(!tmp) {
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
*paths = tmp;
|
||||
(*paths)[*count] = strdup(path);
|
||||
if(!(*paths)[*count]) {
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
(*count)++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
probe_dir_once(const char *path, char ***checked, size_t *checked_count) {
|
||||
if(path_seen(*checked, *checked_count, path)) {
|
||||
return 0;
|
||||
}
|
||||
if(probe_dir_writable(path)) {
|
||||
return -1;
|
||||
}
|
||||
return remember_path(checked, checked_count, path);
|
||||
}
|
||||
|
||||
int
|
||||
check_target_writable(const char *target, char ***checked_dirs,
|
||||
size_t *checked_dir_count, char *error, size_t error_size,
|
||||
char *code, size_t code_size, char *arg, size_t arg_size) {
|
||||
struct stat st;
|
||||
char parent[PATH_MAX];
|
||||
|
||||
if(!stat(target, &st)) {
|
||||
if(S_ISDIR(st.st_mode)) {
|
||||
if(probe_dir_once(target, checked_dirs, checked_dir_count)) {
|
||||
set_permission_error_detail(error, error_size, code, code_size,
|
||||
arg, arg_size, "target_dir_not_writable",
|
||||
"target directory is not writable", target);
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
if(probe_file_writable(target)) {
|
||||
set_permission_error_detail(error, error_size, code, code_size,
|
||||
arg, arg_size, "target_file_not_writable",
|
||||
"target file is not writable", target);
|
||||
return -1;
|
||||
}
|
||||
goto check_parent;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if(errno != ENOENT) {
|
||||
set_error_detail(error, error_size, code, code_size, arg, arg_size,
|
||||
"target_check_failed", "cannot check target path", target);
|
||||
return -1;
|
||||
}
|
||||
|
||||
check_parent:
|
||||
if(path_dirname(target, parent, sizeof(parent))) {
|
||||
set_error_detail(error, error_size, code, code_size, arg, arg_size,
|
||||
"target_check_failed", "cannot check target path", target);
|
||||
return -1;
|
||||
}
|
||||
if(probe_dir_once(parent, checked_dirs, checked_dir_count)) {
|
||||
set_permission_error_detail(error, error_size, code, code_size,
|
||||
arg, arg_size, "target_parent_not_writable",
|
||||
"current directory is not writable", parent);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#include "json_util.h"
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
int
|
||||
strbuf_reserve(strbuf_t *b, size_t extra) {
|
||||
size_t need = b->len + extra + 1;
|
||||
char *tmp;
|
||||
size_t cap;
|
||||
|
||||
if(need <= b->cap) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cap = b->cap ? b->cap : 4096;
|
||||
while(cap < need) {
|
||||
cap *= 2;
|
||||
}
|
||||
|
||||
if(!(tmp = realloc(b->data, cap))) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
b->data = tmp;
|
||||
b->cap = cap;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
strbuf_append(strbuf_t *b, const char *s) {
|
||||
size_t n = strlen(s);
|
||||
if(strbuf_reserve(b, n)) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(b->data + b->len, s, n + 1);
|
||||
b->len += n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
strbuf_printf(strbuf_t *b, const char *fmt, ...) {
|
||||
va_list ap;
|
||||
va_list cp;
|
||||
int n;
|
||||
|
||||
va_start(ap, fmt);
|
||||
va_copy(cp, ap);
|
||||
n = vsnprintf(NULL, 0, fmt, cp);
|
||||
va_end(cp);
|
||||
if(n < 0 || strbuf_reserve(b, (size_t)n)) {
|
||||
va_end(ap);
|
||||
return -1;
|
||||
}
|
||||
|
||||
vsnprintf(b->data + b->len, b->cap - b->len, fmt, ap);
|
||||
va_end(ap);
|
||||
b->len += (size_t)n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
json_escape(strbuf_t *b, const char *s) {
|
||||
if(strbuf_append(b, "\"")) return -1;
|
||||
while(*s) {
|
||||
unsigned char c = (unsigned char)*s;
|
||||
switch(c) {
|
||||
case '"': if(strbuf_append(b, "\\\"")) return -1; s++; break;
|
||||
case '\\': if(strbuf_append(b, "\\\\")) return -1; s++; break;
|
||||
case '\b': if(strbuf_append(b, "\\b")) return -1; s++; break;
|
||||
case '\f': if(strbuf_append(b, "\\f")) return -1; s++; break;
|
||||
case '\n': if(strbuf_append(b, "\\n")) return -1; s++; break;
|
||||
case '\r': if(strbuf_append(b, "\\r")) return -1; s++; break;
|
||||
case '\t': if(strbuf_append(b, "\\t")) return -1; s++; break;
|
||||
default:
|
||||
if(c < 0x20 || c >= 0x80) {
|
||||
if(strbuf_printf(b, "\\u%04x", c)) return -1;
|
||||
} else {
|
||||
if(strbuf_reserve(b, 1)) return -1;
|
||||
b->data[b->len++] = (char)c;
|
||||
b->data[b->len] = 0;
|
||||
}
|
||||
s++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return strbuf_append(b, "\"");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
typedef struct strbuf {
|
||||
char *data;
|
||||
size_t len;
|
||||
size_t cap;
|
||||
} strbuf_t;
|
||||
|
||||
int strbuf_reserve(strbuf_t *b, size_t extra);
|
||||
int strbuf_append(strbuf_t *b, const char *s);
|
||||
int strbuf_printf(strbuf_t *b, const char *fmt, ...);
|
||||
int json_escape(strbuf_t *b, const char *s);
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "filemgr_internal.h"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "json_util.h"
|
||||
#include "path_util.h"
|
||||
|
||||
static char
|
||||
mode_type(const struct stat *st) {
|
||||
if(S_ISDIR(st->st_mode)) return 'd';
|
||||
if(S_ISLNK(st->st_mode)) return 'l';
|
||||
if(S_ISCHR(st->st_mode)) return 'c';
|
||||
if(S_ISBLK(st->st_mode)) return 'b';
|
||||
if(S_ISFIFO(st->st_mode)) return 'p';
|
||||
if(S_ISSOCK(st->st_mode)) return 's';
|
||||
return '-';
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
api_list(struct MHD_Connection *conn) {
|
||||
char *path = fs_path_value(query_value(conn, "path"));
|
||||
DIR *dir;
|
||||
struct dirent *entry;
|
||||
struct stat st;
|
||||
strbuf_t b = {0};
|
||||
int first = 1;
|
||||
|
||||
if(has_active_task()) {
|
||||
return send_json_error(conn, MHD_HTTP_CONFLICT, "another task is running");
|
||||
}
|
||||
|
||||
if(!path) {
|
||||
path = strdup("/");
|
||||
}
|
||||
if(!(dir = opendir(path))) {
|
||||
free(path);
|
||||
return send_json_error(conn, MHD_HTTP_NOT_FOUND, NULL);
|
||||
}
|
||||
|
||||
strbuf_append(&b, "{\"ok\":true,\"path\":");
|
||||
json_escape(&b, path);
|
||||
strbuf_append(&b, ",\"parent\":");
|
||||
char parent[PATH_MAX];
|
||||
if(path_dirname(path, parent, sizeof(parent))) {
|
||||
strcpy(parent, "/");
|
||||
}
|
||||
json_escape(&b, parent);
|
||||
strbuf_append(&b, ",\"entries\":[");
|
||||
|
||||
while((entry = readdir(dir))) {
|
||||
char child[PATH_MAX];
|
||||
|
||||
if(!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) {
|
||||
continue;
|
||||
}
|
||||
if(path_join(child, sizeof(child), path, entry->d_name) ||
|
||||
lstat(child, &st)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if(!first) {
|
||||
strbuf_append(&b, ",");
|
||||
}
|
||||
first = 0;
|
||||
strbuf_append(&b, "{\"name\":");
|
||||
json_escape(&b, entry->d_name);
|
||||
strbuf_append(&b, ",\"path\":");
|
||||
json_escape(&b, child);
|
||||
strbuf_printf(&b, ",\"type\":\"%c\",\"mode\":%u,\"size\":%lld,\"mtime\":%lld}",
|
||||
mode_type(&st), (unsigned int)(st.st_mode & 07777),
|
||||
(long long)st.st_size, (long long)st.st_mtime);
|
||||
}
|
||||
|
||||
closedir(dir);
|
||||
free(path);
|
||||
strbuf_append(&b, "]}");
|
||||
return send_buffer(conn, MHD_HTTP_OK, b.data, "application/json");
|
||||
}
|
||||
@@ -28,12 +28,12 @@ port_available(unsigned short port) {
|
||||
|
||||
if((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
|
||||
perror("socket");
|
||||
return 0;
|
||||
return -1;
|
||||
}
|
||||
if(setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &(int){1}, sizeof(int)) < 0) {
|
||||
perror("setsockopt");
|
||||
close(fd);
|
||||
return 0;
|
||||
return -1;
|
||||
}
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sin_family = AF_INET;
|
||||
@@ -50,7 +50,11 @@ find_available_port(unsigned short start) {
|
||||
unsigned int port;
|
||||
|
||||
for(port = start; port <= 65535; port++) {
|
||||
if(port_available((unsigned short)port)) {
|
||||
int available = port_available((unsigned short)port);
|
||||
if(available < 0) {
|
||||
return 0;
|
||||
}
|
||||
if(available) {
|
||||
return (unsigned short)port;
|
||||
}
|
||||
}
|
||||
@@ -99,7 +103,10 @@ find_pid(const char *name) {
|
||||
int
|
||||
main(int argc, char **argv) {
|
||||
unsigned short port;
|
||||
#ifdef __SCE__
|
||||
unsigned short notified_port = 0;
|
||||
pid_t pid;
|
||||
#endif
|
||||
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
@@ -114,26 +121,40 @@ main(int argc, char **argv) {
|
||||
sleep(1);
|
||||
}
|
||||
#else
|
||||
(void)pid;
|
||||
#endif
|
||||
|
||||
port = find_available_port(DEFAULT_PORT);
|
||||
if(!port) {
|
||||
fprintf(stderr, "no available port from %u\n", DEFAULT_PORT);
|
||||
return 1;
|
||||
}
|
||||
|
||||
puts(PROCESS_NAME);
|
||||
printf("version: %s\n", VERSION_TAG);
|
||||
printf("listening on port %u\n", port);
|
||||
|
||||
#ifdef __SCE__
|
||||
app_install_if_needed();
|
||||
notify_user("Web File Manager\nVersion: %s\nPort: %u", VERSION_TAG, port);
|
||||
#endif
|
||||
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
signal(SIGCHLD, SIG_IGN);
|
||||
|
||||
return websrv_listen(port) ? 1 : 0;
|
||||
while(1) {
|
||||
port = find_available_port(DEFAULT_PORT);
|
||||
if(!port) {
|
||||
fprintf(stderr, "no available port from %u\n", DEFAULT_PORT);
|
||||
sleep(3);
|
||||
continue;
|
||||
}
|
||||
|
||||
printf("listening on port %u\n", port);
|
||||
#ifdef __SCE__
|
||||
if(notified_port != port) {
|
||||
notify_user("Web File Manager\nVersion: %s\nPort: %u", VERSION_TAG, port);
|
||||
notified_port = port;
|
||||
}
|
||||
#endif
|
||||
|
||||
websrv_listen(port);
|
||||
if(websrv_stop_requested()) {
|
||||
break;
|
||||
}
|
||||
sleep(3);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -8,8 +8,10 @@ typedef struct mime_entry {
|
||||
} mime_entry_t;
|
||||
|
||||
static const mime_entry_t g_mimes[] = {
|
||||
{"bmp", "image/bmp"},
|
||||
{"css", "text/css"},
|
||||
{"elf", "application/octet-stream"},
|
||||
{"gif", "image/gif"},
|
||||
{"html", "text/html"},
|
||||
{"jpeg", "image/jpeg"},
|
||||
{"jpg", "image/jpeg"},
|
||||
@@ -20,6 +22,7 @@ static const mime_entry_t g_mimes[] = {
|
||||
{"png", "image/png"},
|
||||
{"txt", "text/plain"},
|
||||
{"xml", "text/xml"},
|
||||
{"webp", "image/webp"},
|
||||
{"zip", "application/zip"},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
#include "path_util.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
char *
|
||||
query_value(struct MHD_Connection *conn, const char *key) {
|
||||
const char *raw = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, key);
|
||||
char *value = raw ? strdup(raw) : NULL;
|
||||
|
||||
if(value && !strcmp(key, "name")) {
|
||||
char *start = value;
|
||||
char *end;
|
||||
while(isspace((unsigned char)*start)) start++;
|
||||
end = start + strlen(start);
|
||||
while(end > start && isspace((unsigned char)end[-1])) end--;
|
||||
memmove(value, start, (size_t)(end - start));
|
||||
value[end - start] = 0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
char *
|
||||
header_value(struct MHD_Connection *conn, const char *key) {
|
||||
const char *raw = MHD_lookup_connection_value(conn, MHD_HEADER_KIND, key);
|
||||
return raw ? form_decode(raw, strlen(raw)) : NULL;
|
||||
}
|
||||
|
||||
char *
|
||||
request_value(struct MHD_Connection *conn, const char *header,
|
||||
const char *query) {
|
||||
char *value = header_value(conn, header);
|
||||
return value ? value : query_value(conn, query);
|
||||
}
|
||||
|
||||
static int
|
||||
hex_value(char c) {
|
||||
if(c >= '0' && c <= '9') return c - '0';
|
||||
if(c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if(c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
char *
|
||||
form_decode(const char *src, size_t len) {
|
||||
char *out = malloc(len + 1);
|
||||
size_t i;
|
||||
size_t j = 0;
|
||||
|
||||
if(!out) {
|
||||
return NULL;
|
||||
}
|
||||
for(i = 0; i < len; i++) {
|
||||
if(src[i] == '+') {
|
||||
out[j++] = ' ';
|
||||
} else if(src[i] == '%' && i + 2 < len) {
|
||||
int hi = hex_value(src[i + 1]);
|
||||
int lo = hex_value(src[i + 2]);
|
||||
if(hi >= 0 && lo >= 0) {
|
||||
out[j++] = (char)((hi << 4) | lo);
|
||||
i += 2;
|
||||
} else {
|
||||
out[j++] = src[i];
|
||||
}
|
||||
} else {
|
||||
out[j++] = src[i];
|
||||
}
|
||||
}
|
||||
out[j] = 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
char *
|
||||
body_form_value(const char *body, size_t body_size, const char *key) {
|
||||
size_t key_len = strlen(key);
|
||||
size_t pos = 0;
|
||||
|
||||
while(body && pos < body_size) {
|
||||
size_t start = pos;
|
||||
size_t end;
|
||||
size_t eq;
|
||||
|
||||
while(pos < body_size && body[pos] != '&') {
|
||||
pos++;
|
||||
}
|
||||
end = pos;
|
||||
if(pos < body_size && body[pos] == '&') {
|
||||
pos++;
|
||||
}
|
||||
eq = start;
|
||||
while(eq < end && body[eq] != '=') {
|
||||
eq++;
|
||||
}
|
||||
if(eq - start == key_len && !strncmp(body + start, key, key_len)) {
|
||||
return form_decode(body + eq + (eq < end), end - eq - (eq < end));
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void
|
||||
free_paths(char **paths, size_t count) {
|
||||
size_t i;
|
||||
|
||||
if(!paths) {
|
||||
return;
|
||||
}
|
||||
for(i = 0; i < count; i++) {
|
||||
free(paths[i]);
|
||||
}
|
||||
free(paths);
|
||||
}
|
||||
|
||||
int
|
||||
parse_paths(const char *raw, char ***out_paths, size_t *out_count) {
|
||||
char *copy;
|
||||
char *line;
|
||||
char *save;
|
||||
char **paths = NULL;
|
||||
size_t count = 0;
|
||||
size_t capacity = 0;
|
||||
const char *p;
|
||||
int in_line = 0;
|
||||
|
||||
*out_paths = NULL;
|
||||
*out_count = 0;
|
||||
|
||||
if(!raw || !raw[0]) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
for(p = raw; *p; p++) {
|
||||
if(*p == '\n') {
|
||||
if(in_line) {
|
||||
capacity++;
|
||||
in_line = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
in_line = 1;
|
||||
}
|
||||
if(in_line) {
|
||||
capacity++;
|
||||
}
|
||||
if(!capacity) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
|
||||
if(!(paths = calloc(capacity, sizeof(char *))) ||
|
||||
!(copy = strdup(raw))) {
|
||||
free(paths);
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
|
||||
for(line = strtok_r(copy, "\n", &save); line; line = strtok_r(NULL, "\n", &save)) {
|
||||
if(!line[0]) {
|
||||
continue;
|
||||
}
|
||||
if(!(paths[count] = fs_path_value(strdup(line)))) {
|
||||
free_paths(paths, count);
|
||||
free(copy);
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
free(copy);
|
||||
if(!count) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_paths = paths;
|
||||
*out_count = count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char *
|
||||
path_basename(const char *path) {
|
||||
const char *end = path + strlen(path);
|
||||
const char *base;
|
||||
|
||||
while(end > path && end[-1] == '/') {
|
||||
end--;
|
||||
}
|
||||
base = end;
|
||||
while(base > path && base[-1] != '/') {
|
||||
base--;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
void
|
||||
path_basename_copy(const char *path, char *out, size_t size) {
|
||||
const char *end = path + strlen(path);
|
||||
const char *base;
|
||||
size_t len;
|
||||
|
||||
while(end > path && end[-1] == '/') {
|
||||
end--;
|
||||
}
|
||||
base = end;
|
||||
while(base > path && base[-1] != '/') {
|
||||
base--;
|
||||
}
|
||||
len = (size_t)(end - base);
|
||||
if(!len) {
|
||||
snprintf(out, size, "root");
|
||||
return;
|
||||
}
|
||||
if(len >= size) {
|
||||
len = size - 1;
|
||||
}
|
||||
memcpy(out, base, len);
|
||||
out[len] = 0;
|
||||
}
|
||||
|
||||
int
|
||||
path_dirname(const char *path, char *out, size_t size) {
|
||||
const char *base = path_basename(path);
|
||||
size_t len = (size_t)(base - path);
|
||||
|
||||
while(len > 1 && path[len - 1] == '/') {
|
||||
len--;
|
||||
}
|
||||
if(!len) {
|
||||
len = 1;
|
||||
}
|
||||
if(len >= size) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(out, path, len);
|
||||
out[len] = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
path_join(char *out, size_t size, const char *dir, const char *name) {
|
||||
int n;
|
||||
if(!dir || !name || !dir[0] || !name[0] || strchr(name, '/')) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
n = snprintf(out, size, "%s%s%s", dir,
|
||||
(strcmp(dir, "/") && dir[strlen(dir) - 1] != '/') ? "/" : "",
|
||||
name);
|
||||
if(n < 0 || (size_t)n >= size) {
|
||||
errno = ENAMETOOLONG;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
relative_path_safe(const char *path) {
|
||||
const char *p = path;
|
||||
|
||||
if(!path || !path[0] || path[0] == '/') {
|
||||
errno = EINVAL;
|
||||
return 0;
|
||||
}
|
||||
while(*p) {
|
||||
const char *start = p;
|
||||
size_t len;
|
||||
|
||||
while(*p && *p != '/') {
|
||||
if(*p == '\\') {
|
||||
errno = EINVAL;
|
||||
return 0;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
len = (size_t)(p - start);
|
||||
if(!len || (len == 1 && start[0] == '.') ||
|
||||
(len == 2 && start[0] == '.' && start[1] == '.')) {
|
||||
errno = EINVAL;
|
||||
return 0;
|
||||
}
|
||||
if(*p == '/') {
|
||||
p++;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
int
|
||||
path_join_relative(char *out, size_t size, const char *dir, const char *rel) {
|
||||
int n;
|
||||
|
||||
if(!relative_path_safe(rel)) {
|
||||
return -1;
|
||||
}
|
||||
n = snprintf(out, size, "%s%s%s", dir,
|
||||
(strcmp(dir, "/") && dir[strlen(dir) - 1] != '/') ? "/" : "",
|
||||
rel);
|
||||
if(n < 0 || (size_t)n >= size) {
|
||||
errno = ENAMETOOLONG;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
utf8_decode_char(const char **ps, unsigned int *out) {
|
||||
const unsigned char *s = (const unsigned char *)*ps;
|
||||
unsigned char c = s[0];
|
||||
unsigned int cp;
|
||||
size_t n;
|
||||
size_t i;
|
||||
|
||||
if(c < 0x80) {
|
||||
*out = c;
|
||||
*ps += 1;
|
||||
return 0;
|
||||
}
|
||||
if((c & 0xe0) == 0xc0) {
|
||||
cp = c & 0x1f;
|
||||
n = 2;
|
||||
} else if((c & 0xf0) == 0xe0) {
|
||||
cp = c & 0x0f;
|
||||
n = 3;
|
||||
} else if((c & 0xf8) == 0xf0) {
|
||||
cp = c & 0x07;
|
||||
n = 4;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for(i = 1; i < n; i++) {
|
||||
unsigned char t = s[i];
|
||||
if(!t || (t & 0xc0) != 0x80) {
|
||||
return -1;
|
||||
}
|
||||
cp = (cp << 6) | (t & 0x3f);
|
||||
}
|
||||
if((n == 2 && cp < 0x80) ||
|
||||
(n == 3 && cp < 0x800) ||
|
||||
(n == 4 && (cp < 0x10000 || cp > 0x10ffff)) ||
|
||||
(cp >= 0xd800 && cp <= 0xdfff)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out = cp;
|
||||
*ps += n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
char *
|
||||
fs_path_value(char *path) {
|
||||
const char *p;
|
||||
char *out;
|
||||
size_t len;
|
||||
size_t pos = 0;
|
||||
int has_byte_token = 0;
|
||||
|
||||
if(!path) {
|
||||
return path;
|
||||
}
|
||||
for(p = path; *p;) {
|
||||
unsigned int cp;
|
||||
const char *next = p;
|
||||
|
||||
if(utf8_decode_char(&next, &cp)) {
|
||||
return path;
|
||||
}
|
||||
if(cp >= 0x80 && cp <= 0xff) {
|
||||
has_byte_token = 1;
|
||||
} else if(cp > 0xff) {
|
||||
return path;
|
||||
}
|
||||
p = next;
|
||||
}
|
||||
if(!has_byte_token) {
|
||||
return path;
|
||||
}
|
||||
|
||||
len = strlen(path);
|
||||
if(!(out = malloc(len + 1))) {
|
||||
return path;
|
||||
}
|
||||
for(p = path; *p;) {
|
||||
unsigned int cp;
|
||||
const char *next = p;
|
||||
|
||||
if(utf8_decode_char(&next, &cp)) {
|
||||
free(out);
|
||||
return path;
|
||||
}
|
||||
out[pos++] = (char)(unsigned char)cp;
|
||||
p = next;
|
||||
}
|
||||
out[pos] = 0;
|
||||
free(path);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <limits.h>
|
||||
|
||||
#include <microhttpd.h>
|
||||
|
||||
char *query_value(struct MHD_Connection *conn, const char *key);
|
||||
char *header_value(struct MHD_Connection *conn, const char *key);
|
||||
char *request_value(struct MHD_Connection *conn, const char *header,
|
||||
const char *query);
|
||||
char *form_decode(const char *src, size_t len);
|
||||
char *body_form_value(const char *body, size_t body_size, const char *key);
|
||||
|
||||
void free_paths(char **paths, size_t count);
|
||||
int parse_paths(const char *raw, char ***out_paths, size_t *out_count);
|
||||
|
||||
const char *path_basename(const char *path);
|
||||
void path_basename_copy(const char *path, char *out, size_t size);
|
||||
int path_dirname(const char *path, char *out, size_t size);
|
||||
int path_join(char *out, size_t size, const char *dir, const char *name);
|
||||
int relative_path_safe(const char *path);
|
||||
int path_join_relative(char *out, size_t size, const char *dir, const char *rel);
|
||||
char *fs_path_value(char *path);
|
||||
@@ -0,0 +1,591 @@
|
||||
#include "pkg_info.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "filemgr_internal.h"
|
||||
#include "json_util.h"
|
||||
#include "path_util.h"
|
||||
#include "websrv.h"
|
||||
|
||||
#define PKG_CNT_MAGIC 0x7f434e54u
|
||||
#define PKG_FIH_MAGIC 0x7f464948u
|
||||
#define PKG_LIH_MAGIC 0x7f4c4948u
|
||||
#define PKG_HEADER_SIZE 0xa0
|
||||
#define PKG_ENTRY_SIZE 0x20
|
||||
#define PKG_ENTRY_PARAM_SFO 0x1000u
|
||||
#define PKG_ENTRY_ICON0_PNG 0x1200u
|
||||
#define PKG_ENTRY_ICON0_LOCALIZED_LAST 0x121fu
|
||||
#define PKG_ENTRY_PARAM_JSON 0x2000u
|
||||
#define PKG_ENTRY_FLAG_ENCRYPTED 0x80000000u
|
||||
#define PKG_ENTRY_MAX 65536u
|
||||
#define PKG_PARAM_MAX (4u * 1024u * 1024u)
|
||||
#define PKG_ICON_MAX (32u * 1024u * 1024u)
|
||||
#define SFO_MAGIC 0x46535000u
|
||||
#define SFO_ENTRY_SIZE 0x10
|
||||
#define SFO_ENTRY_MAX 256u
|
||||
#define JSON_TOKEN_MAX 4096u
|
||||
|
||||
typedef enum pkg_param_type {
|
||||
PKG_PARAM_SFO,
|
||||
PKG_PARAM_JSON,
|
||||
} pkg_param_type_t;
|
||||
|
||||
typedef struct pkg_source {
|
||||
int fd;
|
||||
uint64_t size;
|
||||
uint32_t content_type;
|
||||
uint32_t content_flags;
|
||||
char content_id[37];
|
||||
uint64_t param_offset;
|
||||
uint32_t param_size;
|
||||
pkg_param_type_t param_type;
|
||||
uint64_t icon_offset;
|
||||
uint32_t icon_size;
|
||||
} pkg_source_t;
|
||||
|
||||
typedef enum json_token_type {
|
||||
JSON_OBJECT,
|
||||
JSON_ARRAY,
|
||||
JSON_STRING,
|
||||
JSON_PRIMITIVE,
|
||||
} json_token_type_t;
|
||||
|
||||
typedef struct json_token {
|
||||
json_token_type_t type;
|
||||
size_t start;
|
||||
size_t end;
|
||||
int parent;
|
||||
} json_token_t;
|
||||
|
||||
static uint16_t
|
||||
read_le16(const unsigned char *p) {
|
||||
return (uint16_t)p[0] | (uint16_t)p[1] << 8;
|
||||
}
|
||||
|
||||
static uint32_t
|
||||
read_le32(const unsigned char *p) {
|
||||
return (uint32_t)p[0] | (uint32_t)p[1] << 8 |
|
||||
(uint32_t)p[2] << 16 | (uint32_t)p[3] << 24;
|
||||
}
|
||||
|
||||
static uint64_t
|
||||
read_le64(const unsigned char *p) {
|
||||
return (uint64_t)read_le32(p) | (uint64_t)read_le32(p + 4) << 32;
|
||||
}
|
||||
|
||||
static uint32_t
|
||||
read_be32(const unsigned char *p) {
|
||||
return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 |
|
||||
(uint32_t)p[2] << 8 | (uint32_t)p[3];
|
||||
}
|
||||
|
||||
static int
|
||||
range_valid(uint64_t offset, uint64_t size, uint64_t file_size) {
|
||||
return offset <= file_size && size <= file_size - offset;
|
||||
}
|
||||
|
||||
static int
|
||||
read_at(int fd, void *buffer, size_t size, uint64_t offset) {
|
||||
unsigned char *p = buffer;
|
||||
size_t done = 0;
|
||||
|
||||
while(done < size) {
|
||||
ssize_t n = pread(fd, p + done, size - done, (off_t)(offset + done));
|
||||
if(n <= 0) {
|
||||
if(n < 0 && errno == EINTR) continue;
|
||||
return -1;
|
||||
}
|
||||
done += (size_t)n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
png_signature_valid(const unsigned char *data, size_t size) {
|
||||
static const unsigned char signature[] = "\x89PNG\r\n\x1a\n";
|
||||
|
||||
return size >= sizeof(signature) - 1 &&
|
||||
!memcmp(data, signature, sizeof(signature) - 1);
|
||||
}
|
||||
|
||||
static int
|
||||
pkg_icon_is_png(const pkg_source_t *pkg, uint64_t offset, uint32_t size) {
|
||||
unsigned char signature[8];
|
||||
|
||||
return size >= sizeof(signature) &&
|
||||
!read_at(pkg->fd, signature, sizeof(signature), offset) &&
|
||||
png_signature_valid(signature, sizeof(signature));
|
||||
}
|
||||
|
||||
static void
|
||||
pkg_source_close(pkg_source_t *pkg) {
|
||||
if(pkg->fd >= 0) close(pkg->fd);
|
||||
pkg->fd = -1;
|
||||
}
|
||||
|
||||
static int
|
||||
pkg_source_open(const char *path, pkg_source_t *pkg) {
|
||||
unsigned char header[PKG_HEADER_SIZE];
|
||||
unsigned char *table = NULL;
|
||||
struct stat st;
|
||||
uint64_t container_offset = 0;
|
||||
uint64_t container_size;
|
||||
uint32_t magic;
|
||||
uint32_t entry_count;
|
||||
uint32_t table_offset;
|
||||
size_t table_size;
|
||||
int ret = -1;
|
||||
|
||||
memset(pkg, 0, sizeof(*pkg));
|
||||
pkg->fd = -1;
|
||||
if((pkg->fd = open(path, O_RDONLY)) < 0 || fstat(pkg->fd, &st) ||
|
||||
!S_ISREG(st.st_mode) || st.st_size < PKG_HEADER_SIZE ||
|
||||
read_at(pkg->fd, header, sizeof(header), 0)) goto done;
|
||||
|
||||
pkg->size = (uint64_t)st.st_size;
|
||||
magic = read_be32(header);
|
||||
if(magic == PKG_FIH_MAGIC) {
|
||||
container_offset = read_le64(header + 0x58);
|
||||
} else if(magic == PKG_LIH_MAGIC) {
|
||||
container_offset = read_le64(header + 0x30);
|
||||
} else if(magic != PKG_CNT_MAGIC) {
|
||||
goto done;
|
||||
}
|
||||
if(container_offset) {
|
||||
if(!range_valid(container_offset, sizeof(header), pkg->size) ||
|
||||
read_at(pkg->fd, header, sizeof(header), container_offset) ||
|
||||
read_be32(header) != PKG_CNT_MAGIC) goto done;
|
||||
}
|
||||
container_size = pkg->size - container_offset;
|
||||
|
||||
memcpy(pkg->content_id, header + 0x40, 36);
|
||||
pkg->content_id[36] = 0;
|
||||
pkg->content_type = read_be32(header + 0x74);
|
||||
pkg->content_flags = read_be32(header + 0x78);
|
||||
entry_count = read_be32(header + 0x10);
|
||||
table_offset = read_be32(header + 0x18);
|
||||
if(!entry_count || entry_count > PKG_ENTRY_MAX) goto done;
|
||||
table_size = (size_t)entry_count * PKG_ENTRY_SIZE;
|
||||
if(!range_valid(table_offset, table_size, container_size) ||
|
||||
!(table = malloc(table_size)) ||
|
||||
read_at(pkg->fd, table, table_size, container_offset + table_offset)) {
|
||||
goto done;
|
||||
}
|
||||
|
||||
for(uint32_t i = 0; i < entry_count; i++) {
|
||||
const unsigned char *entry = table + (size_t)i * PKG_ENTRY_SIZE;
|
||||
uint32_t id = read_be32(entry);
|
||||
uint32_t flags1 = read_be32(entry + 0x08);
|
||||
uint32_t offset = read_be32(entry + 0x10);
|
||||
uint32_t size = read_be32(entry + 0x14);
|
||||
int encrypted = (flags1 & PKG_ENTRY_FLAG_ENCRYPTED) != 0;
|
||||
|
||||
if(!range_valid(offset, size, container_size)) goto done;
|
||||
if(encrypted || !size) continue;
|
||||
if(id == PKG_ENTRY_PARAM_SFO && size <= PKG_PARAM_MAX) {
|
||||
pkg->param_offset = container_offset + offset;
|
||||
pkg->param_size = size;
|
||||
pkg->param_type = PKG_PARAM_SFO;
|
||||
} else if(id == PKG_ENTRY_PARAM_JSON && size <= PKG_PARAM_MAX) {
|
||||
pkg->param_offset = container_offset + offset;
|
||||
pkg->param_size = size;
|
||||
pkg->param_type = PKG_PARAM_JSON;
|
||||
} else if(id >= PKG_ENTRY_ICON0_PNG &&
|
||||
id <= PKG_ENTRY_ICON0_LOCALIZED_LAST &&
|
||||
size <= PKG_ICON_MAX &&
|
||||
(id == PKG_ENTRY_ICON0_PNG || !pkg->icon_size) &&
|
||||
pkg_icon_is_png(pkg, container_offset + offset, size)) {
|
||||
/* Prefer icon0.png; otherwise keep the first valid localized icon. */
|
||||
pkg->icon_offset = container_offset + offset;
|
||||
pkg->icon_size = size;
|
||||
}
|
||||
}
|
||||
if(!pkg->param_size) goto done;
|
||||
ret = 0;
|
||||
|
||||
done:
|
||||
free(table);
|
||||
if(ret) {
|
||||
errno = EINVAL;
|
||||
pkg_source_close(pkg);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
static int
|
||||
append_sfo_fields(strbuf_t *json, const unsigned char *sfo, size_t size) {
|
||||
uint32_t key_offset;
|
||||
uint32_t value_offset;
|
||||
uint32_t count;
|
||||
uint64_t index_end;
|
||||
int first = 1;
|
||||
|
||||
if(size < 20 || read_le32(sfo) != SFO_MAGIC) return -1;
|
||||
key_offset = read_le32(sfo + 8);
|
||||
value_offset = read_le32(sfo + 12);
|
||||
count = read_le32(sfo + 16);
|
||||
index_end = 20 + (uint64_t)count * SFO_ENTRY_SIZE;
|
||||
if(count > SFO_ENTRY_MAX || index_end > size || key_offset < index_end ||
|
||||
value_offset < key_offset || value_offset > size) return -1;
|
||||
|
||||
strbuf_append(json, "[");
|
||||
for(uint32_t i = 0; i < count; i++) {
|
||||
const unsigned char *entry = sfo + 20 + (size_t)i * SFO_ENTRY_SIZE;
|
||||
uint16_t name_offset = read_le16(entry);
|
||||
uint16_t format = read_le16(entry + 2);
|
||||
uint32_t value_size = read_le32(entry + 4);
|
||||
uint32_t value_max = read_le32(entry + 8);
|
||||
uint32_t data_offset = read_le32(entry + 12);
|
||||
uint64_t key_pos = (uint64_t)key_offset + name_offset;
|
||||
uint64_t value_pos = (uint64_t)value_offset + data_offset;
|
||||
const unsigned char *key_end;
|
||||
char number[32];
|
||||
char *value = NULL;
|
||||
|
||||
if(key_pos >= value_offset || value_pos > size ||
|
||||
(value_max && value_size > value_max)) continue;
|
||||
key_end = memchr(sfo + key_pos, 0, value_offset - (size_t)key_pos);
|
||||
if(!key_end) continue;
|
||||
if(format == 0x0204) {
|
||||
size_t length;
|
||||
if(!value_size || !range_valid(value_pos, value_size, size)) continue;
|
||||
length = strnlen((const char *)sfo + value_pos, value_size);
|
||||
if(!(value = malloc(length + 1))) continue;
|
||||
memcpy(value, sfo + value_pos, length);
|
||||
value[length] = 0;
|
||||
} else if(format == 0x0404 && range_valid(value_pos, 4, size)) {
|
||||
snprintf(number, sizeof(number), "%u", read_le32(sfo + value_pos));
|
||||
value = strdup(number);
|
||||
}
|
||||
if(!value) continue;
|
||||
if(!first) strbuf_append(json, ",");
|
||||
first = 0;
|
||||
strbuf_append(json, "{\"name\":");
|
||||
json_escape(json, (const char *)sfo + key_pos);
|
||||
strbuf_append(json, ",\"value\":");
|
||||
json_escape(json, value);
|
||||
strbuf_append(json, "}");
|
||||
free(value);
|
||||
}
|
||||
strbuf_append(json, "]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
json_add_token(json_token_t *tokens, size_t *count, json_token_type_t type,
|
||||
size_t start, int parent) {
|
||||
if(*count >= JSON_TOKEN_MAX) return -1;
|
||||
tokens[*count] = (json_token_t){
|
||||
.type = type, .start = start, .end = 0, .parent = parent
|
||||
};
|
||||
return (int)(*count)++;
|
||||
}
|
||||
|
||||
static int
|
||||
parse_json_tokens(const unsigned char *data, size_t size, json_token_t *tokens,
|
||||
size_t *token_count) {
|
||||
int parent = -1;
|
||||
size_t count = 0;
|
||||
|
||||
for(size_t i = 0; i < size; i++) {
|
||||
unsigned char c = data[i];
|
||||
if(c == '{' || c == '[') {
|
||||
int index = json_add_token(tokens, &count,
|
||||
c == '{' ? JSON_OBJECT : JSON_ARRAY,
|
||||
i, parent);
|
||||
if(index < 0) return -1;
|
||||
parent = index;
|
||||
} else if(c == '}' || c == ']') {
|
||||
json_token_type_t type = c == '}' ? JSON_OBJECT : JSON_ARRAY;
|
||||
if(parent < 0 || tokens[parent].type != type) return -1;
|
||||
tokens[parent].end = i + 1;
|
||||
parent = tokens[parent].parent;
|
||||
} else if(c == '"') {
|
||||
int index = json_add_token(tokens, &count, JSON_STRING, i + 1, parent);
|
||||
if(index < 0) return -1;
|
||||
for(i++; i < size && data[i] != '"'; i++) {
|
||||
if(data[i] < 0x20) return -1;
|
||||
if(data[i] == '\\') {
|
||||
if(++i >= size || !strchr("\"\\/bfnrtu", data[i])) return -1;
|
||||
if(data[i] == 'u') {
|
||||
for(unsigned int n = 0; n < 4; n++) {
|
||||
if(++i >= size || !((data[i] >= '0' && data[i] <= '9') ||
|
||||
(data[i] >= 'a' && data[i] <= 'f') ||
|
||||
(data[i] >= 'A' && data[i] <= 'F'))) return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(i >= size) return -1;
|
||||
tokens[index].end = i;
|
||||
} else if(c == ':' || c == ',' || c == ' ' || c == '\t' ||
|
||||
c == '\r' || c == '\n') {
|
||||
continue;
|
||||
} else {
|
||||
int index = json_add_token(tokens, &count, JSON_PRIMITIVE, i, parent);
|
||||
if(index < 0) return -1;
|
||||
while(i < size && data[i] != ',' && data[i] != ']' && data[i] != '}' &&
|
||||
data[i] != ' ' && data[i] != '\t' && data[i] != '\r' &&
|
||||
data[i] != '\n') i++;
|
||||
if(tokens[index].start == i) return -1;
|
||||
tokens[index].end = i;
|
||||
i--;
|
||||
}
|
||||
}
|
||||
if(parent >= 0 || !count || tokens[0].type != JSON_OBJECT ||
|
||||
!tokens[0].end) return -1;
|
||||
*token_count = count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
json_token_equals(const unsigned char *data, const json_token_t *token,
|
||||
const char *text) {
|
||||
size_t length = strlen(text);
|
||||
return token->type == JSON_STRING && token->end - token->start == length &&
|
||||
!memcmp(data + token->start, text, length);
|
||||
}
|
||||
|
||||
static int
|
||||
json_next_child(const json_token_t *tokens, size_t count, int parent,
|
||||
size_t start) {
|
||||
for(size_t i = start; i < count; i++) {
|
||||
if(tokens[i].parent == parent) return (int)i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int
|
||||
json_object_value(const unsigned char *data, const json_token_t *tokens,
|
||||
size_t count, int object, const char *key) {
|
||||
int item = json_next_child(tokens, count, object, (size_t)object + 1);
|
||||
|
||||
while(item >= 0) {
|
||||
int value = json_next_child(tokens, count, object, (size_t)item + 1);
|
||||
if(value < 0) return -1;
|
||||
if(json_token_equals(data, &tokens[item], key)) return value;
|
||||
item = json_next_child(tokens, count, object, (size_t)value + 1);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int
|
||||
json_object_value_token(const unsigned char *data, const json_token_t *tokens,
|
||||
size_t count, int object, const json_token_t *key) {
|
||||
int item = json_next_child(tokens, count, object, (size_t)object + 1);
|
||||
size_t key_size = key->end - key->start;
|
||||
|
||||
while(item >= 0) {
|
||||
int value = json_next_child(tokens, count, object, (size_t)item + 1);
|
||||
if(value < 0) return -1;
|
||||
if(tokens[item].type == JSON_STRING &&
|
||||
tokens[item].end - tokens[item].start == key_size &&
|
||||
!memcmp(data + tokens[item].start, data + key->start, key_size)) {
|
||||
return value;
|
||||
}
|
||||
item = json_next_child(tokens, count, object, (size_t)value + 1);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void
|
||||
append_json_token_string(strbuf_t *json, const unsigned char *data,
|
||||
const json_token_t *token) {
|
||||
strbuf_append(json, "\"");
|
||||
strbuf_printf(json, "%.*s", (int)(token->end - token->start),
|
||||
data + token->start);
|
||||
strbuf_append(json, "\"");
|
||||
}
|
||||
|
||||
static void
|
||||
append_json_field(strbuf_t *json, const unsigned char *data,
|
||||
const json_token_t *key, const json_token_t *value,
|
||||
int *first) {
|
||||
if(!*first) strbuf_append(json, ",");
|
||||
*first = 0;
|
||||
strbuf_append(json, "{\"name\":");
|
||||
append_json_token_string(json, data, key);
|
||||
strbuf_append(json, ",\"value\":");
|
||||
append_json_token_string(json, data, value);
|
||||
strbuf_append(json, "}");
|
||||
}
|
||||
|
||||
static void
|
||||
append_named_json_field(strbuf_t *json, const char *name,
|
||||
const unsigned char *data, const json_token_t *value,
|
||||
int *first) {
|
||||
if(!*first) strbuf_append(json, ",");
|
||||
*first = 0;
|
||||
strbuf_append(json, "{\"name\":");
|
||||
json_escape(json, name);
|
||||
strbuf_append(json, ",\"value\":");
|
||||
append_json_token_string(json, data, value);
|
||||
strbuf_append(json, "}");
|
||||
}
|
||||
|
||||
static int
|
||||
append_param_json_fields(strbuf_t *json, const unsigned char *data,
|
||||
size_t size) {
|
||||
json_token_t *tokens = calloc(JSON_TOKEN_MAX, sizeof(*tokens));
|
||||
size_t count = 0;
|
||||
int first = 1;
|
||||
int localized;
|
||||
int title = -1;
|
||||
|
||||
if(!tokens || parse_json_tokens(data, size, tokens, &count)) {
|
||||
free(tokens);
|
||||
return -1;
|
||||
}
|
||||
strbuf_append(json, "[");
|
||||
|
||||
localized = json_object_value(data, tokens, count, 0, "localizedParameters");
|
||||
if(localized >= 0 && tokens[localized].type == JSON_OBJECT) {
|
||||
int language = json_object_value(data, tokens, count, localized,
|
||||
"defaultLanguage");
|
||||
int language_data = language >= 0 && tokens[language].type == JSON_STRING ?
|
||||
json_object_value_token(data, tokens, count, localized,
|
||||
&tokens[language]) : -1;
|
||||
if(language_data >= 0 && tokens[language_data].type == JSON_OBJECT) {
|
||||
title = json_object_value(data, tokens, count, language_data,
|
||||
"titleName");
|
||||
}
|
||||
if(title < 0) {
|
||||
int english = json_object_value(data, tokens, count, localized, "en-US");
|
||||
if(english >= 0 && tokens[english].type == JSON_OBJECT) {
|
||||
title = json_object_value(data, tokens, count, english, "titleName");
|
||||
}
|
||||
}
|
||||
if(title < 0) {
|
||||
int item = json_next_child(tokens, count, localized,
|
||||
(size_t)localized + 1);
|
||||
while(item >= 0 && title < 0) {
|
||||
int value = json_next_child(tokens, count, localized,
|
||||
(size_t)item + 1);
|
||||
if(value < 0) break;
|
||||
if(tokens[value].type == JSON_OBJECT) {
|
||||
title = json_object_value(data, tokens, count, value, "titleName");
|
||||
}
|
||||
item = json_next_child(tokens, count, localized, (size_t)value + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(title >= 0 && tokens[title].type == JSON_STRING) {
|
||||
append_named_json_field(json, "titleName", data, &tokens[title], &first);
|
||||
}
|
||||
|
||||
for(int item = json_next_child(tokens, count, 0, 1); item >= 0;) {
|
||||
int value = json_next_child(tokens, count, 0, (size_t)item + 1);
|
||||
if(value < 0) break;
|
||||
if(tokens[item].type == JSON_STRING &&
|
||||
(tokens[value].type == JSON_STRING ||
|
||||
tokens[value].type == JSON_PRIMITIVE)) {
|
||||
append_json_field(json, data, &tokens[item], &tokens[value], &first);
|
||||
}
|
||||
item = json_next_child(tokens, count, 0, (size_t)value + 1);
|
||||
}
|
||||
strbuf_append(json, "]");
|
||||
free(tokens);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static enum MHD_Result
|
||||
pkg_error(struct MHD_Connection *conn, const char *path) {
|
||||
return send_json_error_detail(conn, MHD_HTTP_BAD_REQUEST,
|
||||
"could not read package information",
|
||||
"pkg_info_failed", path);
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
api_pkg_info(struct MHD_Connection *conn) {
|
||||
char *path = fs_path_value(query_value(conn, "path"));
|
||||
pkg_source_t pkg = {.fd = -1};
|
||||
unsigned char *param;
|
||||
strbuf_t json = {0};
|
||||
|
||||
if(!path || pkg_source_open(path, &pkg)) {
|
||||
enum MHD_Result ret = pkg_error(conn, path);
|
||||
free(path);
|
||||
return ret;
|
||||
}
|
||||
if(!(param = malloc(pkg.param_size)) ||
|
||||
read_at(pkg.fd, param, pkg.param_size, pkg.param_offset)) {
|
||||
enum MHD_Result ret = pkg_error(conn, path);
|
||||
free(param);
|
||||
pkg_source_close(&pkg);
|
||||
free(path);
|
||||
return ret;
|
||||
}
|
||||
|
||||
strbuf_printf(&json,
|
||||
"{\"ok\":true,\"size\":%llu,\"content_id\":",
|
||||
(unsigned long long)pkg.size);
|
||||
json_escape(&json, pkg.content_id);
|
||||
strbuf_printf(&json,
|
||||
",\"platform\":\"%s\",\"content_type\":%u,"
|
||||
"\"content_flags\":%u,\"has_icon\":%s,\"fields\":",
|
||||
pkg.param_type == PKG_PARAM_JSON ? "PS5" : "PS4",
|
||||
pkg.content_type, pkg.content_flags,
|
||||
pkg.icon_size ? "true" : "false");
|
||||
if((pkg.param_type == PKG_PARAM_JSON &&
|
||||
append_param_json_fields(&json, param, pkg.param_size)) ||
|
||||
(pkg.param_type == PKG_PARAM_SFO &&
|
||||
append_sfo_fields(&json, param, pkg.param_size))) {
|
||||
enum MHD_Result ret;
|
||||
free(json.data);
|
||||
ret = pkg_error(conn, path);
|
||||
free(param);
|
||||
pkg_source_close(&pkg);
|
||||
free(path);
|
||||
return ret;
|
||||
}
|
||||
strbuf_append(&json, "}");
|
||||
|
||||
free(param);
|
||||
pkg_source_close(&pkg);
|
||||
free(path);
|
||||
return send_buffer(conn, MHD_HTTP_OK, json.data, "application/json");
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
api_pkg_icon(struct MHD_Connection *conn) {
|
||||
char *path = fs_path_value(query_value(conn, "path"));
|
||||
pkg_source_t pkg = {.fd = -1};
|
||||
unsigned char *icon;
|
||||
struct MHD_Response *response;
|
||||
enum MHD_Result ret;
|
||||
|
||||
if(!path || pkg_source_open(path, &pkg) || !pkg.icon_size ||
|
||||
!(icon = malloc(pkg.icon_size)) ||
|
||||
read_at(pkg.fd, icon, pkg.icon_size, pkg.icon_offset)) {
|
||||
if(path && pkg.fd >= 0) pkg_source_close(&pkg);
|
||||
free(path);
|
||||
return send_json_error(conn, MHD_HTTP_NOT_FOUND, "package icon not found");
|
||||
}
|
||||
if(!png_signature_valid(icon, pkg.icon_size)) {
|
||||
free(icon);
|
||||
pkg_source_close(&pkg);
|
||||
free(path);
|
||||
return send_json_error(conn, MHD_HTTP_NOT_FOUND,
|
||||
"package icon not found");
|
||||
}
|
||||
pkg_source_close(&pkg);
|
||||
free(path);
|
||||
|
||||
response = MHD_create_response_from_buffer(pkg.icon_size, icon,
|
||||
MHD_RESPMEM_MUST_FREE);
|
||||
if(!response) {
|
||||
free(icon);
|
||||
return MHD_NO;
|
||||
}
|
||||
MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE, "image/png");
|
||||
ret = websrv_queue_response(conn, MHD_HTTP_OK, response);
|
||||
MHD_destroy_response(response);
|
||||
return ret;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <microhttpd.h>
|
||||
|
||||
enum MHD_Result api_pkg_info(struct MHD_Connection *conn);
|
||||
enum MHD_Result api_pkg_icon(struct MHD_Connection *conn);
|
||||
@@ -0,0 +1,119 @@
|
||||
#include "pkg_installer.h"
|
||||
|
||||
#ifndef __linux__
|
||||
|
||||
#include <limits.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct pkg_metadata {
|
||||
const char *uri;
|
||||
const char *ex_uri;
|
||||
const char *playgo_scenario_id;
|
||||
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,
|
||||
"sceAppInstUtil metadata ABI mismatch");
|
||||
|
||||
typedef struct pkg_info {
|
||||
char content_id[48];
|
||||
int type;
|
||||
int platform;
|
||||
} pkg_info_t;
|
||||
|
||||
typedef struct playgo_info {
|
||||
char languages[30][8];
|
||||
char scenario_ids[64][3];
|
||||
char content_ids[64][48];
|
||||
long unknown[810];
|
||||
} playgo_info_t;
|
||||
|
||||
_Static_assert(sizeof(playgo_info_t) == 0x2700,
|
||||
"sceAppInstUtil PlayGoInfo ABI mismatch");
|
||||
|
||||
int sceAppInstUtilInitialize(void);
|
||||
int sceAppInstUtilInstallByPackage(const pkg_metadata_t *, pkg_info_t *,
|
||||
playgo_info_t *);
|
||||
|
||||
static pthread_mutex_t installer_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static int installer_initialized;
|
||||
|
||||
static int
|
||||
initialize_locked(void) {
|
||||
int result;
|
||||
|
||||
if(!installer_initialized) {
|
||||
result = sceAppInstUtilInitialize();
|
||||
if(result) return result;
|
||||
installer_initialized = 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
pkg_installer_initialize(void) {
|
||||
int result;
|
||||
|
||||
pthread_mutex_lock(&installer_lock);
|
||||
result = initialize_locked();
|
||||
pthread_mutex_unlock(&installer_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
int
|
||||
pkg_installer_install(const char *path) {
|
||||
char install_path[PATH_MAX + sizeof("/user")];
|
||||
const char *uri = path;
|
||||
pkg_metadata_t metadata = {
|
||||
.uri = NULL,
|
||||
.ex_uri = "",
|
||||
.playgo_scenario_id = "",
|
||||
.content_id = "",
|
||||
.content_name = "",
|
||||
.icon_url = "",
|
||||
.slot = 0,
|
||||
.is_playgo_enabled = 0
|
||||
};
|
||||
pkg_info_t pkg_info = {0};
|
||||
playgo_info_t playgo_info = {0};
|
||||
int result;
|
||||
|
||||
if(!path) return -1;
|
||||
if(!strncmp(path, "/data/", 6)) {
|
||||
snprintf(install_path, sizeof(install_path), "/user%s", path);
|
||||
uri = install_path;
|
||||
}
|
||||
metadata.uri = uri;
|
||||
|
||||
pthread_mutex_lock(&installer_lock);
|
||||
result = initialize_locked();
|
||||
if(result) {
|
||||
pthread_mutex_unlock(&installer_lock);
|
||||
return result;
|
||||
}
|
||||
result = sceAppInstUtilInstallByPackage(&metadata, &pkg_info, &playgo_info);
|
||||
pthread_mutex_unlock(&installer_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
int
|
||||
pkg_installer_initialize(void) {
|
||||
return PKG_INSTALL_UNSUPPORTED;
|
||||
}
|
||||
|
||||
int
|
||||
pkg_installer_install(const char *path) {
|
||||
(void)path;
|
||||
return PKG_INSTALL_UNSUPPORTED;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#define PKG_INSTALL_UNSUPPORTED 0x7fffffff
|
||||
|
||||
int pkg_installer_initialize(void);
|
||||
int pkg_installer_install(const char *path);
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
/* Safe RAR extraction engine used by the /api/extract task.
|
||||
Wraps the vendored rarlab UnRAR 7.20.1 (third_party/unrar7) through its
|
||||
C-compatible DLL API (unrar_c_api.h facade).
|
||||
|
||||
Reuses the zip_extract types so the dispatch layer can call either engine
|
||||
through the same status / limits / progress protocol.
|
||||
|
||||
See zip_extract.h for the shared limits, conflict, progress and result types.
|
||||
|
||||
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.
|
||||
|
||||
See third_party/unrar7/VENDORED.md for full integration notes. */
|
||||
|
||||
#include "zip_extract.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* Extract rar_path into dst_dir using the same protocol as zipx_extract().
|
||||
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. */
|
||||
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);
|
||||
@@ -0,0 +1,128 @@
|
||||
#include "filemgr_internal.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/statvfs.h>
|
||||
#ifdef __SCE__
|
||||
#include <sys/mount.h>
|
||||
#endif
|
||||
|
||||
#include "json_util.h"
|
||||
#include "path_util.h"
|
||||
|
||||
static unsigned long long
|
||||
vfs_bytes(fsblkcnt_t blocks, unsigned long block_size) {
|
||||
return (unsigned long long)blocks * (unsigned long long)block_size;
|
||||
}
|
||||
|
||||
#ifdef __SCE__
|
||||
static int
|
||||
path_is_mounted(const char *path, const struct stat *st) {
|
||||
char parent[PATH_MAX];
|
||||
struct stat parent_st;
|
||||
|
||||
if(!strcmp(path, "/")) {
|
||||
return 1;
|
||||
}
|
||||
if(path_dirname(path, parent, sizeof(parent)) || stat(parent, &parent_st)) {
|
||||
return 0;
|
||||
}
|
||||
return st->st_dev != parent_st.st_dev;
|
||||
}
|
||||
#endif
|
||||
|
||||
enum MHD_Result
|
||||
api_space(struct MHD_Connection *conn) {
|
||||
#ifdef __SCE__
|
||||
static const struct {
|
||||
const char *label_key;
|
||||
const char *path;
|
||||
} mounts[] = {
|
||||
{"storageInternal", "/data"},
|
||||
{"storageUsb", "/mnt/usb0"},
|
||||
{"storageUsb", "/mnt/usb1"},
|
||||
{"storageUsb", "/mnt/usb2"},
|
||||
{"storageUsb", "/mnt/usb3"},
|
||||
{"storageUsb", "/mnt/usb4"},
|
||||
{"storageUsb", "/mnt/usb5"},
|
||||
{"storageUsb", "/mnt/usb6"},
|
||||
{"storageUsb", "/mnt/usb7"},
|
||||
{"storageM2", "/mnt/ext1"},
|
||||
{"storageExtended", "/mnt/ext0"},
|
||||
};
|
||||
#endif
|
||||
char *current = fs_path_value(query_value(conn, "path"));
|
||||
strbuf_t b = {0};
|
||||
int first = 1;
|
||||
|
||||
strbuf_append(&b, "{\"ok\":true,\"spaces\":[");
|
||||
#ifdef __SCE__
|
||||
for(size_t i = 0; i < sizeof(mounts) / sizeof(mounts[0]); i++) {
|
||||
struct statvfs vfs;
|
||||
struct stat st;
|
||||
unsigned long block_size;
|
||||
unsigned long long free_bytes;
|
||||
unsigned long long total_bytes;
|
||||
int is_current = 0;
|
||||
|
||||
if(stat(mounts[i].path, &st) || !path_is_mounted(mounts[i].path, &st) ||
|
||||
statvfs(mounts[i].path, &vfs)) {
|
||||
continue;
|
||||
}
|
||||
block_size = vfs.f_frsize ? vfs.f_frsize : vfs.f_bsize;
|
||||
free_bytes = vfs_bytes(vfs.f_bavail, block_size);
|
||||
total_bytes = vfs_bytes(vfs.f_blocks, block_size);
|
||||
if(current) {
|
||||
if(!strcmp(mounts[i].path, "/")) {
|
||||
is_current = !strcmp(current, "/");
|
||||
} else if(!strncmp(current, mounts[i].path, strlen(mounts[i].path)) &&
|
||||
(current[strlen(mounts[i].path)] == 0 ||
|
||||
current[strlen(mounts[i].path)] == '/')) {
|
||||
is_current = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if(!first) {
|
||||
strbuf_append(&b, ",");
|
||||
}
|
||||
first = 0;
|
||||
strbuf_append(&b, "{\"label_key\":");
|
||||
json_escape(&b, mounts[i].label_key);
|
||||
strbuf_append(&b, ",\"path\":");
|
||||
json_escape(&b, mounts[i].path);
|
||||
strbuf_printf(&b, ",\"free\":%llu,\"total\":%llu,\"current\":%s}",
|
||||
free_bytes, total_bytes, is_current ? "true" : "false");
|
||||
}
|
||||
#else
|
||||
const char *paths[] = {"/", current && strcmp(current, "/") ? current : NULL};
|
||||
const char *labels[] = {"storageRoot", "storageCurrent"};
|
||||
for(size_t i = 0; i < sizeof(paths) / sizeof(paths[0]); i++) {
|
||||
struct statvfs vfs;
|
||||
unsigned long block_size;
|
||||
unsigned long long free_bytes;
|
||||
unsigned long long total_bytes;
|
||||
|
||||
if(!paths[i] || statvfs(paths[i], &vfs)) {
|
||||
continue;
|
||||
}
|
||||
block_size = vfs.f_frsize ? vfs.f_frsize : vfs.f_bsize;
|
||||
free_bytes = vfs_bytes(vfs.f_bavail, block_size);
|
||||
total_bytes = vfs_bytes(vfs.f_blocks, block_size);
|
||||
if(!first) {
|
||||
strbuf_append(&b, ",");
|
||||
}
|
||||
first = 0;
|
||||
strbuf_append(&b, "{\"label_key\":");
|
||||
json_escape(&b, labels[i]);
|
||||
strbuf_append(&b, ",\"path\":");
|
||||
json_escape(&b, paths[i]);
|
||||
strbuf_printf(&b, ",\"free\":%llu,\"total\":%llu,\"current\":%s}",
|
||||
free_bytes, total_bytes, i ? "true" : "false");
|
||||
}
|
||||
#endif
|
||||
free(current);
|
||||
strbuf_append(&b, "]}");
|
||||
return send_buffer(conn, MHD_HTTP_OK, b.data, "application/json");
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "filemgr_internal.h"
|
||||
#include "path_util.h"
|
||||
|
||||
#define ETA_AVERAGE_WINDOW_SECONDS 30
|
||||
|
||||
pthread_mutex_t g_tasks_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
file_task_t *g_tasks = NULL;
|
||||
unsigned long g_next_task_id = 1;
|
||||
|
||||
const char *
|
||||
task_op_name(task_op_t op) {
|
||||
switch(op) {
|
||||
case TASK_COPY: return "copy";
|
||||
case TASK_MOVE: return "move";
|
||||
case TASK_DELETE: return "delete";
|
||||
case TASK_CHMOD: return "chmod";
|
||||
case TASK_DOWNLOAD: return "download";
|
||||
case TASK_UPLOAD: return "upload";
|
||||
case TASK_PKG_INSTALL: return "pkg_install";
|
||||
case TASK_EXTRACT: return "extract";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
const char *
|
||||
task_state_name(task_state_t state) {
|
||||
switch(state) {
|
||||
case TASK_QUEUED: return "queued";
|
||||
case TASK_RUNNING: return "running";
|
||||
case TASK_DONE: return "done";
|
||||
case TASK_FAILED: return "failed";
|
||||
case TASK_CANCELED: return "canceled";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
int
|
||||
task_is_active(const file_task_t *task) {
|
||||
return task->state == TASK_QUEUED || task->state == TASK_RUNNING;
|
||||
}
|
||||
|
||||
int
|
||||
has_active_task_locked(void) {
|
||||
file_task_t *task;
|
||||
|
||||
for(task = g_tasks; task; task = task->next) {
|
||||
if(task->op != TASK_PKG_INSTALL && task_is_active(task)) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
has_active_task(void) {
|
||||
int active;
|
||||
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
active = has_active_task_locked();
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
return active;
|
||||
}
|
||||
|
||||
void
|
||||
free_task(file_task_t *task) {
|
||||
if(!task) {
|
||||
return;
|
||||
}
|
||||
free_paths(task->srcs, task->src_count);
|
||||
free(task);
|
||||
}
|
||||
|
||||
void
|
||||
remove_finished_tasks_locked(void) {
|
||||
file_task_t **link = &g_tasks;
|
||||
|
||||
while(*link) {
|
||||
file_task_t *task = *link;
|
||||
|
||||
if(task_is_active(task) || task->active_streams ||
|
||||
(task->op == TASK_PKG_INSTALL && !task->reported)) {
|
||||
link = &task->next;
|
||||
continue;
|
||||
}
|
||||
|
||||
*link = task->next;
|
||||
free_task(task);
|
||||
}
|
||||
}
|
||||
|
||||
int
|
||||
task_cancel_requested(file_task_t *task) {
|
||||
int cancel;
|
||||
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
cancel = task->cancel_requested;
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
if(cancel) {
|
||||
errno = ECANCELED;
|
||||
}
|
||||
return cancel;
|
||||
}
|
||||
|
||||
file_task_t *
|
||||
find_task_locked(unsigned long id) {
|
||||
file_task_t *task;
|
||||
|
||||
for(task = g_tasks; task; task = task->next) {
|
||||
if(task->id == id) {
|
||||
return task;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static long long
|
||||
timespec_delta_ns(const struct timespec *end, const struct timespec *start) {
|
||||
return (long long)(end->tv_sec - start->tv_sec) * 1000000000LL +
|
||||
(long long)(end->tv_nsec - start->tv_nsec);
|
||||
}
|
||||
|
||||
static void
|
||||
task_update_eta_locked(file_task_t *task, const struct timespec *now_mono) {
|
||||
task_eta_sample_t *sample;
|
||||
task_eta_sample_t *base = NULL;
|
||||
unsigned int i;
|
||||
|
||||
if(!task->total || !task->done || task->done >= task->total) {
|
||||
task->eta = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
sample = &task->eta_samples[task->eta_sample_next];
|
||||
sample->done = task->done;
|
||||
sample->time = *now_mono;
|
||||
task->eta_sample_next = (task->eta_sample_next + 1) % ETA_SAMPLE_SLOTS;
|
||||
if(task->eta_sample_count < ETA_SAMPLE_SLOTS) {
|
||||
task->eta_sample_count++;
|
||||
}
|
||||
|
||||
for(i = 0; i < task->eta_sample_count; i++) {
|
||||
task_eta_sample_t *candidate = &task->eta_samples[i];
|
||||
long long age_ns;
|
||||
|
||||
if(!candidate->time.tv_sec || candidate->done >= task->done) {
|
||||
continue;
|
||||
}
|
||||
age_ns = timespec_delta_ns(now_mono, &candidate->time);
|
||||
if(age_ns <= 0 || age_ns > (long long)ETA_AVERAGE_WINDOW_SECONDS * 1000000000LL) {
|
||||
continue;
|
||||
}
|
||||
if(!base || age_ns > timespec_delta_ns(now_mono, &base->time)) {
|
||||
base = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if(base) {
|
||||
long long elapsed_ns = timespec_delta_ns(now_mono, &base->time);
|
||||
unsigned long long delta = task->done - base->done;
|
||||
unsigned long long remaining = task->total - task->done;
|
||||
if(delta && elapsed_ns > 0) {
|
||||
long double seconds = (long double)elapsed_ns / 1000000000.0L;
|
||||
long double eta = ((long double)remaining / (long double)delta) * seconds;
|
||||
task->eta = eta > 0 ? (unsigned long long)(eta + 0.999999L) : 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
task->eta = task->speed ? (task->total - task->done + task->speed - 1) / task->speed : 0;
|
||||
}
|
||||
|
||||
void
|
||||
task_update(file_task_t *task, task_state_t state, const char *current,
|
||||
unsigned long long add_done, const char *error) {
|
||||
struct timespec now_mono;
|
||||
time_t now;
|
||||
|
||||
clock_gettime(CLOCK_MONOTONIC, &now_mono);
|
||||
now = time(NULL);
|
||||
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
task->state = state;
|
||||
if(current) {
|
||||
snprintf(task->current, sizeof(task->current), "%s", current);
|
||||
}
|
||||
if(add_done) {
|
||||
if(!task->transfer_started_at) {
|
||||
task->transfer_started_at = now;
|
||||
}
|
||||
task->done += add_done;
|
||||
if(task->total && task->done > task->total) {
|
||||
task->done = task->total;
|
||||
}
|
||||
if(task->speed_sample_time.tv_sec) {
|
||||
long long elapsed_ns = timespec_delta_ns(&now_mono, &task->speed_sample_time);
|
||||
if(elapsed_ns >= 250000000LL) {
|
||||
unsigned long long delta = task->done - task->speed_sample_done;
|
||||
task->speed = (unsigned long long)((delta * 1000000000ULL) /
|
||||
(unsigned long long)elapsed_ns);
|
||||
task->speed_sample_done = task->done;
|
||||
task->speed_sample_time = now_mono;
|
||||
}
|
||||
} else {
|
||||
task->speed_sample_done = task->done;
|
||||
task->speed_sample_time = now_mono;
|
||||
}
|
||||
task_update_eta_locked(task, &now_mono);
|
||||
}
|
||||
if(error) {
|
||||
snprintf(task->error, sizeof(task->error), "%s", error);
|
||||
}
|
||||
task->updated_at = now;
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
#include "filemgr_internal.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "json_util.h"
|
||||
#include "path_util.h"
|
||||
#include "websrv.h"
|
||||
|
||||
#define TEXT_FILE_MAX_SIZE (1024 * 1024)
|
||||
|
||||
typedef enum text_newline {
|
||||
TEXT_NEWLINE_LF,
|
||||
TEXT_NEWLINE_CRLF,
|
||||
TEXT_NEWLINE_CR,
|
||||
} text_newline_t;
|
||||
|
||||
static enum MHD_Result
|
||||
send_json_version(struct MHD_Connection *conn, unsigned long long version) {
|
||||
char *data;
|
||||
|
||||
if(asprintf(&data, "{\"ok\":true,\"version\":\"%016llx\"}", version) < 0) {
|
||||
return MHD_NO;
|
||||
}
|
||||
return send_buffer(conn, MHD_HTTP_OK, data, "application/json");
|
||||
}
|
||||
|
||||
static int
|
||||
text_extension_allowed(const char *path) {
|
||||
static const char *extensions[] = {
|
||||
".txt", ".json", ".xml", ".ini", ".cfg", ".conf", ".md",
|
||||
".log", ".lua", ".js", ".css", ".html", ".htm", ".c", ".h",
|
||||
".cpp", ".hpp", ".sh", ".csv", ".yaml", ".yml", ".shn"
|
||||
};
|
||||
const char *extension = strrchr(path, '.');
|
||||
size_t i;
|
||||
|
||||
if(!extension) {
|
||||
return 0;
|
||||
}
|
||||
for(i = 0; i < sizeof(extensions) / sizeof(extensions[0]); i++) {
|
||||
if(!strcasecmp(extension, extensions[i])) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
valid_utf8(const unsigned char *data, size_t size) {
|
||||
size_t i = 0;
|
||||
|
||||
while(i < size) {
|
||||
unsigned char c = data[i++];
|
||||
size_t trailing;
|
||||
unsigned int codepoint;
|
||||
|
||||
if(!c) return 0;
|
||||
if(c < 0x80) continue;
|
||||
if(c >= 0xc2 && c <= 0xdf) {
|
||||
trailing = 1;
|
||||
codepoint = c & 0x1f;
|
||||
} else if(c >= 0xe0 && c <= 0xef) {
|
||||
trailing = 2;
|
||||
codepoint = c & 0x0f;
|
||||
} else if(c >= 0xf0 && c <= 0xf4) {
|
||||
trailing = 3;
|
||||
codepoint = c & 0x07;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
if(trailing > size - i) return 0;
|
||||
while(trailing--) {
|
||||
unsigned char next = data[i++];
|
||||
if((next & 0xc0) != 0x80) return 0;
|
||||
codepoint = (codepoint << 6) | (next & 0x3f);
|
||||
}
|
||||
if((codepoint >= 0xd800 && codepoint <= 0xdfff) || codepoint > 0x10ffff ||
|
||||
(codepoint < 0x800 && c >= 0xe0) ||
|
||||
(codepoint < 0x10000 && c >= 0xf0)) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static unsigned long long
|
||||
text_version(const unsigned char *data, size_t size) {
|
||||
unsigned long long hash = 1469598103934665603ULL;
|
||||
size_t i;
|
||||
|
||||
for(i = 0; i < size; i++) {
|
||||
hash ^= data[i];
|
||||
hash *= 1099511628211ULL;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
static text_newline_t
|
||||
detect_text_newline(const unsigned char *data, size_t size) {
|
||||
size_t crlf = 0;
|
||||
size_t lf = 0;
|
||||
size_t cr = 0;
|
||||
size_t i;
|
||||
|
||||
for(i = 0; i < size; i++) {
|
||||
if(data[i] == '\r') {
|
||||
if(i + 1 < size && data[i + 1] == '\n') {
|
||||
crlf++;
|
||||
i++;
|
||||
} else {
|
||||
cr++;
|
||||
}
|
||||
} else if(data[i] == '\n') {
|
||||
lf++;
|
||||
}
|
||||
}
|
||||
if(crlf > lf && crlf >= cr) return TEXT_NEWLINE_CRLF;
|
||||
if(cr > lf && cr > crlf) return TEXT_NEWLINE_CR;
|
||||
return TEXT_NEWLINE_LF;
|
||||
}
|
||||
|
||||
static int
|
||||
format_text_for_save(const char *body, size_t body_size,
|
||||
const unsigned char *current, size_t current_size,
|
||||
char **output, size_t *output_size) {
|
||||
static const unsigned char bom[] = {0xef, 0xbb, 0xbf};
|
||||
int keep_bom = current_size >= sizeof(bom) &&
|
||||
!memcmp(current, bom, sizeof(bom));
|
||||
text_newline_t newline = detect_text_newline(
|
||||
current + (keep_bom ? sizeof(bom) : 0),
|
||||
current_size - (keep_bom ? sizeof(bom) : 0));
|
||||
const unsigned char *input = (const unsigned char *)(body ? body : "");
|
||||
size_t input_size = body_size;
|
||||
size_t capacity = body_size * (newline == TEXT_NEWLINE_CRLF ? 2 : 1) +
|
||||
sizeof(bom) + 1;
|
||||
char *formatted;
|
||||
size_t i;
|
||||
size_t len = 0;
|
||||
|
||||
if(input_size >= sizeof(bom) && !memcmp(input, bom, sizeof(bom))) {
|
||||
input += sizeof(bom);
|
||||
input_size -= sizeof(bom);
|
||||
}
|
||||
if(!(formatted = malloc(capacity))) {
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
if(keep_bom) {
|
||||
memcpy(formatted + len, bom, sizeof(bom));
|
||||
len += sizeof(bom);
|
||||
}
|
||||
for(i = 0; i < input_size; i++) {
|
||||
unsigned char c = input[i];
|
||||
|
||||
if(c != '\r' && c != '\n') {
|
||||
formatted[len++] = (char)c;
|
||||
continue;
|
||||
}
|
||||
if(c == '\r' && i + 1 < input_size && input[i + 1] == '\n') {
|
||||
i++;
|
||||
}
|
||||
if(newline == TEXT_NEWLINE_CRLF) {
|
||||
formatted[len++] = '\r';
|
||||
formatted[len++] = '\n';
|
||||
} else {
|
||||
formatted[len++] = newline == TEXT_NEWLINE_CR ? '\r' : '\n';
|
||||
}
|
||||
}
|
||||
if(len > TEXT_FILE_MAX_SIZE) {
|
||||
free(formatted);
|
||||
errno = EFBIG;
|
||||
return -1;
|
||||
}
|
||||
formatted[len] = 0;
|
||||
*output = formatted;
|
||||
*output_size = len;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
read_text_file(const char *path, char **data, size_t *size,
|
||||
struct stat *st) {
|
||||
FILE *file;
|
||||
size_t read_size;
|
||||
|
||||
*data = NULL;
|
||||
*size = 0;
|
||||
if(lstat(path, st) || !S_ISREG(st->st_mode)) {
|
||||
return -1;
|
||||
}
|
||||
if(st->st_size < 0 || (unsigned long long)st->st_size > TEXT_FILE_MAX_SIZE) {
|
||||
errno = EFBIG;
|
||||
return -1;
|
||||
}
|
||||
if(!(file = fopen(path, "rb"))) {
|
||||
return -1;
|
||||
}
|
||||
if(!(*data = malloc((size_t)st->st_size + 1))) {
|
||||
fclose(file);
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
read_size = fread(*data, 1, (size_t)st->st_size, file);
|
||||
if(read_size != (size_t)st->st_size || ferror(file)) {
|
||||
free(*data);
|
||||
*data = NULL;
|
||||
fclose(file);
|
||||
return -1;
|
||||
}
|
||||
fclose(file);
|
||||
(*data)[read_size] = 0;
|
||||
*size = read_size;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static enum MHD_Result
|
||||
send_text_file(struct MHD_Connection *conn, char *data, size_t size,
|
||||
unsigned long long version) {
|
||||
struct MHD_Response *resp;
|
||||
enum MHD_Result ret;
|
||||
char version_text[24];
|
||||
|
||||
if(!(resp = MHD_create_response_from_buffer(size, data,
|
||||
MHD_RESPMEM_MUST_FREE))) {
|
||||
free(data);
|
||||
return MHD_NO;
|
||||
}
|
||||
snprintf(version_text, sizeof(version_text), "%016llx", version);
|
||||
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_TYPE,
|
||||
"text/plain; charset=utf-8");
|
||||
MHD_add_response_header(resp, "X-Text-Version", version_text);
|
||||
ret = websrv_queue_response(conn, MHD_HTTP_OK, resp);
|
||||
MHD_destroy_response(resp);
|
||||
return ret;
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
api_text(struct MHD_Connection *conn) {
|
||||
char *path = fs_path_value(query_value(conn, "path"));
|
||||
char *data;
|
||||
size_t size;
|
||||
struct stat st;
|
||||
unsigned long long version;
|
||||
|
||||
if(has_active_task()) {
|
||||
free(path);
|
||||
return send_json_error(conn, MHD_HTTP_CONFLICT, "another task is running");
|
||||
}
|
||||
if(!path || !text_extension_allowed(path)) {
|
||||
free(path);
|
||||
return send_json_error(conn, MHD_HTTP_BAD_REQUEST,
|
||||
"file type is not editable");
|
||||
}
|
||||
if(read_text_file(path, &data, &size, &st)) {
|
||||
int error = errno;
|
||||
free(path);
|
||||
if(error == EFBIG) {
|
||||
return send_json_error(conn, MHD_HTTP_CONTENT_TOO_LARGE,
|
||||
"text file is too large");
|
||||
}
|
||||
errno = error;
|
||||
return send_json_error(conn, MHD_HTTP_NOT_FOUND, "file not found");
|
||||
}
|
||||
free(path);
|
||||
if(!valid_utf8((const unsigned char *)data, size)) {
|
||||
free(data);
|
||||
return send_json_error(conn, MHD_HTTP_UNSUPPORTED_MEDIA_TYPE,
|
||||
"file is not valid UTF-8");
|
||||
}
|
||||
version = text_version((const unsigned char *)data, size);
|
||||
return send_text_file(conn, data, size, version);
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
api_text_create(struct MHD_Connection *conn) {
|
||||
char *path = fs_path_value(query_value(conn, "path"));
|
||||
char *name = fs_path_value(query_value(conn, "name"));
|
||||
char target[PATH_MAX];
|
||||
int fd = -1;
|
||||
int ret = -1;
|
||||
int error = 0;
|
||||
int created = 0;
|
||||
|
||||
if(has_active_task()) {
|
||||
free(path); free(name);
|
||||
return send_json_error(conn, MHD_HTTP_CONFLICT, "another task is running");
|
||||
}
|
||||
if(!path || !name || path_join(target, sizeof(target), path, name)) {
|
||||
free(path); free(name);
|
||||
return send_json_error(conn, MHD_HTTP_BAD_REQUEST, "invalid path");
|
||||
}
|
||||
if(mode_access(path, W_OK | X_OK)) {
|
||||
free(path); free(name);
|
||||
return send_json_error(conn, MHD_HTTP_FORBIDDEN,
|
||||
"text file is not writable");
|
||||
}
|
||||
if((fd = open(target, O_WRONLY | O_CREAT | O_EXCL, 0777)) >= 0) {
|
||||
created = 1;
|
||||
ret = fchmod_0777(fd);
|
||||
if(close(fd) && !ret) ret = -1;
|
||||
fd = -1;
|
||||
}
|
||||
if(ret) {
|
||||
error = errno;
|
||||
if(fd >= 0) close(fd);
|
||||
if(created) unlink(target);
|
||||
}
|
||||
free(path); free(name);
|
||||
if(!ret) {
|
||||
return send_json_version(conn,
|
||||
text_version((const unsigned char *)"", 0));
|
||||
}
|
||||
errno = error;
|
||||
return error == EEXIST ?
|
||||
send_json_error(conn, MHD_HTTP_CONFLICT, "file already exists") :
|
||||
send_json_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, NULL);
|
||||
}
|
||||
|
||||
static int
|
||||
write_text_atomic(const char *path, const char *body, size_t body_size,
|
||||
mode_t mode) {
|
||||
struct timespec now;
|
||||
char temp[PATH_MAX];
|
||||
size_t written = 0;
|
||||
int fd = -1;
|
||||
int ret = -1;
|
||||
int n;
|
||||
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
n = snprintf(temp, sizeof(temp), "%s.wfm-%ld-%ld.tmp", path,
|
||||
(long)getpid(), now.tv_nsec);
|
||||
if(n < 0 || (size_t)n >= sizeof(temp)) {
|
||||
errno = ENAMETOOLONG;
|
||||
return -1;
|
||||
}
|
||||
if((fd = open(temp, O_WRONLY | O_CREAT | O_EXCL, 0600)) < 0) {
|
||||
return -1;
|
||||
}
|
||||
while(written < body_size) {
|
||||
ssize_t count = write(fd, body + written, body_size - written);
|
||||
if(count <= 0) {
|
||||
goto done;
|
||||
}
|
||||
written += (size_t)count;
|
||||
}
|
||||
if(fchmod(fd, mode & 07777) && !ignore_chmod_error(errno)) {
|
||||
goto done;
|
||||
}
|
||||
if(fsync(fd)) {
|
||||
goto done;
|
||||
}
|
||||
if(close(fd)) {
|
||||
fd = -1;
|
||||
goto done;
|
||||
}
|
||||
fd = -1;
|
||||
if(rename(temp, path)) {
|
||||
goto done;
|
||||
}
|
||||
ret = 0;
|
||||
|
||||
done:
|
||||
if(fd >= 0) close(fd);
|
||||
if(ret) unlink(temp);
|
||||
return ret;
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
api_text_save(struct MHD_Connection *conn, const char *body,
|
||||
size_t body_size) {
|
||||
char *path = fs_path_value(query_value(conn, "path"));
|
||||
char *expected = query_value(conn, "version");
|
||||
char *current = NULL;
|
||||
size_t current_size = 0;
|
||||
struct stat st;
|
||||
char version_text[24];
|
||||
char parent[PATH_MAX];
|
||||
char *formatted = NULL;
|
||||
size_t formatted_size = 0;
|
||||
int ret;
|
||||
|
||||
if(has_active_task()) {
|
||||
free(path); free(expected);
|
||||
return send_json_error(conn, MHD_HTTP_CONFLICT, "another task is running");
|
||||
}
|
||||
if(!path || !expected) {
|
||||
free(path); free(expected);
|
||||
return send_json_error(conn, MHD_HTTP_BAD_REQUEST, "invalid path");
|
||||
}
|
||||
if(body_size > TEXT_FILE_MAX_SIZE) {
|
||||
free(path); free(expected);
|
||||
return send_json_error(conn, MHD_HTTP_CONTENT_TOO_LARGE,
|
||||
"text file is too large");
|
||||
}
|
||||
if(!valid_utf8((const unsigned char *)(body ? body : ""), body_size)) {
|
||||
free(path); free(expected);
|
||||
return send_json_error(conn, MHD_HTTP_UNSUPPORTED_MEDIA_TYPE,
|
||||
"file is not valid UTF-8");
|
||||
}
|
||||
if(read_text_file(path, ¤t, ¤t_size, &st)) {
|
||||
free(path); free(expected);
|
||||
return send_json_error(conn, MHD_HTTP_NOT_FOUND, "file not found");
|
||||
}
|
||||
snprintf(version_text, sizeof(version_text), "%016llx",
|
||||
text_version((const unsigned char *)current, current_size));
|
||||
if(strcmp(expected, version_text)) {
|
||||
free(current);
|
||||
free(path); free(expected);
|
||||
return send_json_error(conn, MHD_HTTP_CONFLICT,
|
||||
"file changed since it was opened");
|
||||
}
|
||||
if(path_dirname(path, parent, sizeof(parent)) ||
|
||||
mode_access(path, W_OK) || mode_access(parent, W_OK | X_OK)) {
|
||||
free(current);
|
||||
free(path); free(expected);
|
||||
return send_json_error(conn, MHD_HTTP_FORBIDDEN,
|
||||
"text file is not writable");
|
||||
}
|
||||
if(format_text_for_save(body, body_size, (const unsigned char *)current,
|
||||
current_size, &formatted, &formatted_size)) {
|
||||
int error = errno;
|
||||
free(current);
|
||||
free(path); free(expected);
|
||||
errno = error;
|
||||
return error == EFBIG ?
|
||||
send_json_error(conn, MHD_HTTP_CONTENT_TOO_LARGE,
|
||||
"text file is too large") :
|
||||
send_json_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, NULL);
|
||||
}
|
||||
free(current);
|
||||
ret = write_text_atomic(path, formatted, formatted_size, st.st_mode);
|
||||
free(formatted);
|
||||
free(path); free(expected);
|
||||
return ret ? send_json_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, NULL)
|
||||
: send_json_ok(conn);
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
#include "filemgr.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "filemgr_internal.h"
|
||||
#include "json_util.h"
|
||||
#include "path_util.h"
|
||||
|
||||
#define UPLOAD_BUFFER_SIZE (1024 * 1024)
|
||||
|
||||
typedef struct upload_context {
|
||||
file_task_t *task;
|
||||
int fd;
|
||||
char *buffer;
|
||||
size_t buffered;
|
||||
char temp[PATH_MAX];
|
||||
char target[PATH_MAX];
|
||||
unsigned long long expected;
|
||||
unsigned long long written;
|
||||
int failed;
|
||||
int error;
|
||||
int task_done;
|
||||
const char *stage;
|
||||
char error_message[256];
|
||||
} upload_context_t;
|
||||
|
||||
static const char *
|
||||
upload_error_message(upload_context_t *ctx) {
|
||||
if(!ctx->error_message[0]) {
|
||||
snprintf(ctx->error_message, sizeof(ctx->error_message), "%s failed%s%s: %s",
|
||||
ctx->stage ? ctx->stage : "upload",
|
||||
ctx->target[0] ? " for " : "",
|
||||
ctx->target[0] ? ctx->target : "",
|
||||
strerror(ctx->error ? ctx->error : EIO));
|
||||
}
|
||||
return ctx->error_message;
|
||||
}
|
||||
|
||||
static int
|
||||
upload_flush(upload_context_t *ctx) {
|
||||
size_t written = 0;
|
||||
|
||||
while(written < ctx->buffered) {
|
||||
ssize_t n;
|
||||
|
||||
if(ctx->task && task_cancel_requested(ctx->task)) {
|
||||
ctx->failed = 1;
|
||||
ctx->error = ECANCELED;
|
||||
return -1;
|
||||
}
|
||||
n = write(ctx->fd, ctx->buffer + written, ctx->buffered - written);
|
||||
if(n <= 0) {
|
||||
ctx->failed = 1;
|
||||
ctx->error = errno ? errno : EIO;
|
||||
return -1;
|
||||
}
|
||||
written += (size_t)n;
|
||||
ctx->written += (unsigned long long)n;
|
||||
}
|
||||
if(ctx->task && written) {
|
||||
task_update(ctx->task, TASK_RUNNING, ctx->target,
|
||||
(unsigned long long)written, NULL);
|
||||
}
|
||||
ctx->buffered = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void
|
||||
finish_upload_task(file_task_t *task, task_state_t state, const char *current,
|
||||
const char *error) {
|
||||
if(!task) {
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
if(task_is_active(task)) {
|
||||
time_t completed_at = time(NULL);
|
||||
task->state = state;
|
||||
if(current) {
|
||||
snprintf(task->current, sizeof(task->current), "%s", current);
|
||||
}
|
||||
if(state == TASK_DONE && task->total) {
|
||||
task->done = task->total;
|
||||
}
|
||||
if(state == TASK_DONE) {
|
||||
record_task_completion_locked(task, completed_at);
|
||||
}
|
||||
if(error) {
|
||||
snprintf(task->error, sizeof(task->error), "%s", error);
|
||||
}
|
||||
if(state == TASK_FAILED) {
|
||||
snprintf(task->error_code, sizeof(task->error_code), "upload_failed");
|
||||
snprintf(task->error_arg, sizeof(task->error_arg), "%s",
|
||||
current ? current : task->src);
|
||||
}
|
||||
task->updated_at = completed_at;
|
||||
}
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
}
|
||||
|
||||
static void
|
||||
finish_upload_context_error(upload_context_t *ctx) {
|
||||
if(!ctx->task || ctx->task_done) {
|
||||
return;
|
||||
}
|
||||
upload_error_message(ctx);
|
||||
finish_upload_task(ctx->task,
|
||||
ctx->error == ECANCELED ? TASK_CANCELED : TASK_FAILED,
|
||||
ctx->target[0] ? ctx->target : ctx->task->current,
|
||||
ctx->error == ECANCELED ? "canceled" : ctx->error_message);
|
||||
ctx->task_done = 1;
|
||||
}
|
||||
|
||||
static file_task_t *
|
||||
upload_task_from_conn(struct MHD_Connection *conn) {
|
||||
char *idstr = request_value(conn, "X-WFM-Task-ID", "task_id");
|
||||
unsigned long id = idstr ? strtoul(idstr, NULL, 10) : 0;
|
||||
file_task_t *task = NULL;
|
||||
|
||||
free(idstr);
|
||||
if(!id) {
|
||||
return NULL;
|
||||
}
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
task = find_task_locked(id);
|
||||
if(!task || task->op != TASK_UPLOAD || !task_is_active(task)) {
|
||||
task = NULL;
|
||||
}
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
return task;
|
||||
}
|
||||
|
||||
static int
|
||||
check_upload_manifest_space(const char *base, const char *rels,
|
||||
const char *sizes, unsigned long long fallback_total,
|
||||
char *error, size_t error_size,
|
||||
char *code, size_t code_size,
|
||||
char *arg, size_t arg_size) {
|
||||
char *rels_copy = NULL;
|
||||
char *sizes_copy = NULL;
|
||||
char *rel;
|
||||
char *size_text;
|
||||
char *rel_save;
|
||||
char *size_save;
|
||||
unsigned long long available;
|
||||
int ret = -1;
|
||||
|
||||
if(!rels || !sizes) {
|
||||
return check_target_space(base, fallback_total, error, error_size,
|
||||
code, code_size, arg, arg_size);
|
||||
}
|
||||
if(target_available_space(base, &available)) {
|
||||
snprintf(error, error_size, "cannot read target free space");
|
||||
snprintf(code, code_size, "space_check_failed");
|
||||
snprintf(arg, arg_size, "%s", base);
|
||||
return -1;
|
||||
}
|
||||
if(!(rels_copy = strdup(rels)) || !(sizes_copy = strdup(sizes))) {
|
||||
errno = ENOMEM;
|
||||
goto done;
|
||||
}
|
||||
|
||||
rel = strtok_r(rels_copy, "\n", &rel_save);
|
||||
size_text = strtok_r(sizes_copy, "\n", &size_save);
|
||||
while(rel || size_text) {
|
||||
char target[PATH_MAX];
|
||||
unsigned long long size;
|
||||
|
||||
if(!rel || !size_text || path_join_relative(target, sizeof(target), base, rel)) {
|
||||
snprintf(error, error_size, "invalid path");
|
||||
snprintf(code, code_size, "invalid_path");
|
||||
snprintf(arg, arg_size, "%s", rel ? rel : "");
|
||||
errno = EINVAL;
|
||||
goto done;
|
||||
}
|
||||
|
||||
size = strtoull(size_text, NULL, 10);
|
||||
if(available < size) {
|
||||
snprintf(error, error_size,
|
||||
"not enough target space, required %llu bytes, available %llu bytes",
|
||||
size, available);
|
||||
snprintf(code, code_size, "no_space");
|
||||
snprintf(arg, arg_size, "%llu,%llu", size, available);
|
||||
errno = ENOSPC;
|
||||
goto done;
|
||||
}
|
||||
|
||||
available -= size;
|
||||
|
||||
rel = strtok_r(NULL, "\n", &rel_save);
|
||||
size_text = strtok_r(NULL, "\n", &size_save);
|
||||
}
|
||||
|
||||
ret = 0;
|
||||
|
||||
done:
|
||||
free(rels_copy);
|
||||
free(sizes_copy);
|
||||
return ret;
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
api_upload_prepare(struct MHD_Connection *conn, const char *body,
|
||||
size_t body_size) {
|
||||
char *path = fs_path_value(body_form_value(body, body_size, "path"));
|
||||
char *src = body_form_value(body, body_size, "src");
|
||||
char *total_text = body_form_value(body, body_size, "total");
|
||||
char *count_text = body_form_value(body, body_size, "count");
|
||||
char *rels = body_form_value(body, body_size, "rels");
|
||||
char *sizes = body_form_value(body, body_size, "sizes");
|
||||
char *overwrite = body_form_value(body, body_size, "overwrite");
|
||||
file_task_t *task = calloc(1, sizeof(*task));
|
||||
strbuf_t b = {0};
|
||||
unsigned long long total = total_text ? strtoull(total_text, NULL, 10) : 0;
|
||||
size_t count = count_text ? (size_t)strtoull(count_text, NULL, 10) : 0;
|
||||
char error[128] = {0};
|
||||
char code[64] = {0};
|
||||
char arg[PATH_MAX + 96] = {0};
|
||||
|
||||
if(!task) {
|
||||
free(path); free(src); free(total_text); free(count_text);
|
||||
free(rels); free(sizes); free(overwrite);
|
||||
return send_json_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "out of memory");
|
||||
}
|
||||
if(!path || !src || !count) {
|
||||
free_task(task);
|
||||
free(path); free(src); free(total_text); free(count_text);
|
||||
free(rels); free(sizes); free(overwrite);
|
||||
return send_json_error(conn, MHD_HTTP_BAD_REQUEST, "invalid path");
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
remove_finished_tasks_locked();
|
||||
if(has_active_task_locked()) {
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
free_task(task);
|
||||
free(path); free(src); free(total_text); free(count_text);
|
||||
free(rels); free(sizes); free(overwrite);
|
||||
return send_json_error(conn, MHD_HTTP_CONFLICT, "another task is running");
|
||||
}
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
|
||||
if(check_upload_manifest_space(path, rels, sizes, total, error, sizeof(error),
|
||||
code, sizeof(code), arg, sizeof(arg))) {
|
||||
free_task(task);
|
||||
free(path); free(src); free(total_text); free(count_text);
|
||||
free(rels); free(sizes); free(overwrite);
|
||||
return send_json_error_detail(conn,
|
||||
errno == ENOSPC ? MHD_HTTP_INSUFFICIENT_STORAGE :
|
||||
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||
error[0] ? error : NULL,
|
||||
code[0] ? code : NULL,
|
||||
arg[0] ? arg : NULL);
|
||||
}
|
||||
|
||||
task->op = TASK_UPLOAD;
|
||||
task->state = TASK_RUNNING;
|
||||
task->src_count = count;
|
||||
task->file_count = count;
|
||||
task->total = total;
|
||||
snprintf(task->src, sizeof(task->src), "%s", src);
|
||||
snprintf(task->dst, sizeof(task->dst), "%s", path);
|
||||
snprintf(task->current, sizeof(task->current), "%s", src);
|
||||
task->created_at = time(NULL);
|
||||
task->updated_at = task->created_at;
|
||||
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
remove_finished_tasks_locked();
|
||||
if(has_active_task_locked()) {
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
free_task(task);
|
||||
free(path); free(src); free(total_text); free(count_text);
|
||||
free(rels); free(sizes); free(overwrite);
|
||||
return send_json_error(conn, MHD_HTTP_CONFLICT, "another task is running");
|
||||
}
|
||||
task->id = g_next_task_id++;
|
||||
task->next = g_tasks;
|
||||
g_tasks = task;
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
|
||||
free(path); free(src); free(total_text); free(count_text);
|
||||
free(rels); free(sizes); free(overwrite);
|
||||
strbuf_printf(&b, "{\"ok\":true,\"task_id\":%lu}", task->id);
|
||||
return send_buffer(conn, MHD_HTTP_OK, b.data, "application/json");
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
api_upload_finish(struct MHD_Connection *conn) {
|
||||
file_task_t *task = upload_task_from_conn(conn);
|
||||
|
||||
if(!task) {
|
||||
return send_json_error(conn, MHD_HTTP_NOT_FOUND, "active task not found");
|
||||
}
|
||||
if(task_cancel_requested(task)) {
|
||||
finish_upload_task(task, TASK_CANCELED,
|
||||
task->current[0] ? task->current : task->src,
|
||||
"canceled");
|
||||
return send_json_error(conn, MHD_HTTP_CONFLICT, "canceled");
|
||||
}
|
||||
finish_upload_task(task, TASK_DONE,
|
||||
task->current[0] ? task->current : task->src, NULL);
|
||||
return send_json_ok(conn);
|
||||
}
|
||||
|
||||
int
|
||||
filemgr_upload_begin(struct MHD_Connection *conn, void **upload_ctx) {
|
||||
upload_context_t *ctx;
|
||||
char *base = fs_path_value(request_value(conn, "X-WFM-Path", "path"));
|
||||
char *rel = request_value(conn, "X-WFM-Rel", "rel");
|
||||
char *overwrite = request_value(conn, "X-WFM-Overwrite", "overwrite");
|
||||
char *size_text = request_value(conn, "X-WFM-Size", "size");
|
||||
file_task_t *task = upload_task_from_conn(conn);
|
||||
char **checked_dirs = NULL;
|
||||
size_t checked_dir_count = 0;
|
||||
struct stat st;
|
||||
char error[128] = {0};
|
||||
char code[64] = {0};
|
||||
char arg[PATH_MAX + 96] = {0};
|
||||
int n;
|
||||
|
||||
*upload_ctx = NULL;
|
||||
if(!(ctx = calloc(1, sizeof(*ctx)))) {
|
||||
free(base); free(rel); free(overwrite); free(size_text);
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
ctx->fd = -1;
|
||||
ctx->stage = "preparing upload";
|
||||
*upload_ctx = ctx;
|
||||
|
||||
if(size_text) {
|
||||
ctx->expected = strtoull(size_text, NULL, 10);
|
||||
}
|
||||
ctx->task = task;
|
||||
if(task) {
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
task->active_streams++;
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
}
|
||||
if(task && task_cancel_requested(task)) {
|
||||
ctx->failed = 1;
|
||||
ctx->error = ECANCELED;
|
||||
goto fail;
|
||||
}
|
||||
if(!task && has_active_task()) {
|
||||
ctx->failed = 1;
|
||||
ctx->error = EBUSY;
|
||||
goto fail;
|
||||
}
|
||||
ctx->stage = "validating target path";
|
||||
if(!base || !rel || path_join_relative(ctx->target, sizeof(ctx->target),
|
||||
base, rel)) {
|
||||
ctx->failed = 1;
|
||||
ctx->error = errno ? errno : EINVAL;
|
||||
goto fail;
|
||||
}
|
||||
ctx->stage = "creating target folders";
|
||||
if(ensure_parent_dirs(base, rel)) {
|
||||
ctx->failed = 1;
|
||||
ctx->error = errno ? errno : EACCES;
|
||||
goto fail;
|
||||
}
|
||||
ctx->stage = "checking target path";
|
||||
if(!lstat(ctx->target, &st)) {
|
||||
if(S_ISDIR(st.st_mode) || !overwrite || strcmp(overwrite, "1")) {
|
||||
ctx->failed = 1;
|
||||
ctx->error = S_ISDIR(st.st_mode) ? EISDIR : EEXIST;
|
||||
goto fail;
|
||||
}
|
||||
} else if(errno != ENOENT) {
|
||||
ctx->failed = 1;
|
||||
ctx->error = errno;
|
||||
goto fail;
|
||||
}
|
||||
ctx->stage = "checking target permissions";
|
||||
if(check_target_writable(ctx->target, &checked_dirs, &checked_dir_count,
|
||||
error, sizeof(error), code, sizeof(code),
|
||||
arg, sizeof(arg))) {
|
||||
snprintf(ctx->error_message, sizeof(ctx->error_message), "%s",
|
||||
error[0] ? error : "target is not writable");
|
||||
ctx->failed = 1;
|
||||
ctx->error = errno ? errno : EACCES;
|
||||
goto fail;
|
||||
}
|
||||
ctx->stage = "checking target space";
|
||||
if(check_target_space(ctx->target, ctx->expected, error, sizeof(error),
|
||||
code, sizeof(code), arg, sizeof(arg))) {
|
||||
snprintf(ctx->error_message, sizeof(ctx->error_message), "%s",
|
||||
error[0] ? error : "not enough target space");
|
||||
ctx->failed = 1;
|
||||
ctx->error = errno ? errno : ENOSPC;
|
||||
goto fail;
|
||||
}
|
||||
ctx->stage = "creating temporary path";
|
||||
n = snprintf(ctx->temp, sizeof(ctx->temp), "%s.wfm-upload-%ld-%lld.tmp",
|
||||
ctx->target, (long)getpid(), (long long)time(NULL));
|
||||
if(n < 0 || (size_t)n >= sizeof(ctx->temp)) {
|
||||
ctx->failed = 1;
|
||||
ctx->error = ENAMETOOLONG;
|
||||
goto fail;
|
||||
}
|
||||
ctx->stage = "opening temporary file";
|
||||
ctx->fd = open(ctx->temp, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
if(ctx->fd < 0) {
|
||||
ctx->failed = 1;
|
||||
ctx->error = errno;
|
||||
goto fail;
|
||||
}
|
||||
ctx->stage = "allocating upload buffer";
|
||||
if(!(ctx->buffer = malloc(UPLOAD_BUFFER_SIZE))) {
|
||||
ctx->failed = 1;
|
||||
ctx->error = ENOMEM;
|
||||
goto fail;
|
||||
}
|
||||
|
||||
fail:
|
||||
free_paths(checked_dirs, checked_dir_count);
|
||||
free(base); free(rel); free(overwrite); free(size_text);
|
||||
if(ctx->failed) {
|
||||
finish_upload_context_error(ctx);
|
||||
if(ctx->fd >= 0) {
|
||||
close(ctx->fd);
|
||||
ctx->fd = -1;
|
||||
}
|
||||
if(ctx->temp[0]) {
|
||||
unlink(ctx->temp);
|
||||
}
|
||||
errno = ctx->error ? ctx->error : EIO;
|
||||
return -1;
|
||||
}
|
||||
ctx->stage = "writing file";
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
filemgr_upload_data(void *upload_ctx, const char *data, size_t size) {
|
||||
upload_context_t *ctx = upload_ctx;
|
||||
|
||||
if(!ctx || ctx->failed) {
|
||||
return -1;
|
||||
}
|
||||
if(ctx->task && task_cancel_requested(ctx->task)) {
|
||||
ctx->failed = 1;
|
||||
ctx->error = ECANCELED;
|
||||
finish_upload_context_error(ctx);
|
||||
return -1;
|
||||
}
|
||||
while(size) {
|
||||
size_t space = UPLOAD_BUFFER_SIZE - ctx->buffered;
|
||||
size_t take = size < space ? size : space;
|
||||
|
||||
memcpy(ctx->buffer + ctx->buffered, data, take);
|
||||
ctx->buffered += take;
|
||||
data += take;
|
||||
size -= take;
|
||||
if(ctx->buffered == UPLOAD_BUFFER_SIZE && upload_flush(ctx)) {
|
||||
finish_upload_context_error(ctx);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
filemgr_upload_finish(struct MHD_Connection *conn, void *upload_ctx) {
|
||||
upload_context_t *ctx = upload_ctx;
|
||||
int ret = -1;
|
||||
|
||||
if(!ctx) {
|
||||
return send_json_error(conn, MHD_HTTP_BAD_REQUEST, "invalid path");
|
||||
}
|
||||
if(ctx->failed) {
|
||||
finish_upload_context_error(ctx);
|
||||
errno = ctx->error ? ctx->error : EIO;
|
||||
return send_json_error_detail(conn,
|
||||
errno == EEXIST ? MHD_HTTP_CONFLICT :
|
||||
errno == EBUSY ? MHD_HTTP_CONFLICT :
|
||||
errno == ECANCELED ? MHD_HTTP_CONFLICT :
|
||||
errno == ENOSPC ? MHD_HTTP_INSUFFICIENT_STORAGE :
|
||||
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||
ctx->error == ECANCELED ? "canceled" : ctx->error_message,
|
||||
ctx->error == ECANCELED ? NULL : "upload_failed",
|
||||
ctx->target[0] ? ctx->target : NULL);
|
||||
}
|
||||
ctx->stage = "writing file";
|
||||
if(ctx->buffered && upload_flush(ctx)) {
|
||||
ctx->error = ctx->error ? ctx->error : EIO;
|
||||
goto done;
|
||||
}
|
||||
ctx->stage = "verifying uploaded size";
|
||||
if(ctx->expected && ctx->written != ctx->expected) {
|
||||
ctx->error = EIO;
|
||||
goto done;
|
||||
}
|
||||
ctx->stage = "setting file permissions";
|
||||
if(fchmod_0777(ctx->fd)) {
|
||||
ctx->error = errno;
|
||||
goto done;
|
||||
}
|
||||
ctx->stage = "syncing file data";
|
||||
if(fsync(ctx->fd)) {
|
||||
ctx->error = errno;
|
||||
goto done;
|
||||
}
|
||||
ctx->stage = "closing temporary file";
|
||||
if(close(ctx->fd)) {
|
||||
ctx->fd = -1;
|
||||
ctx->error = errno;
|
||||
goto done;
|
||||
}
|
||||
ctx->fd = -1;
|
||||
ctx->stage = "moving temporary file into place";
|
||||
if(rename(ctx->temp, ctx->target)) {
|
||||
ctx->error = errno;
|
||||
goto done;
|
||||
}
|
||||
if(ctx->task) {
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
ctx->task->upload_completed++;
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
}
|
||||
ret = 0;
|
||||
|
||||
done:
|
||||
if(ctx->fd >= 0) {
|
||||
close(ctx->fd);
|
||||
ctx->fd = -1;
|
||||
}
|
||||
if(ret) {
|
||||
upload_error_message(ctx);
|
||||
if(ctx->temp[0]) {
|
||||
unlink(ctx->temp);
|
||||
}
|
||||
finish_upload_context_error(ctx);
|
||||
errno = ctx->error ? ctx->error : EIO;
|
||||
return send_json_error_detail(conn,
|
||||
errno == ECANCELED ? MHD_HTTP_CONFLICT :
|
||||
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||
ctx->error == ECANCELED ? "canceled" : ctx->error_message,
|
||||
ctx->error == ECANCELED ? NULL : "upload_failed",
|
||||
ctx->target[0] ? ctx->target : NULL);
|
||||
}
|
||||
return send_json_ok(conn);
|
||||
}
|
||||
|
||||
void
|
||||
filemgr_upload_free(void *upload_ctx) {
|
||||
upload_context_t *ctx = upload_ctx;
|
||||
|
||||
if(!ctx) {
|
||||
return;
|
||||
}
|
||||
if(ctx->fd >= 0) {
|
||||
close(ctx->fd);
|
||||
if(ctx->temp[0]) {
|
||||
unlink(ctx->temp);
|
||||
}
|
||||
if(ctx->task && !ctx->task_done) {
|
||||
int canceled = task_cancel_requested(ctx->task);
|
||||
finish_upload_task(ctx->task, canceled ? TASK_CANCELED : TASK_FAILED,
|
||||
ctx->target[0] ? ctx->target : ctx->task->current,
|
||||
canceled ? "canceled" : "client disconnected");
|
||||
ctx->task_done = 1;
|
||||
}
|
||||
}
|
||||
free(ctx->buffer);
|
||||
if(ctx->task) {
|
||||
pthread_mutex_lock(&g_tasks_lock);
|
||||
if(ctx->task->active_streams) {
|
||||
ctx->task->active_streams--;
|
||||
}
|
||||
pthread_mutex_unlock(&g_tasks_lock);
|
||||
}
|
||||
free(ctx);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <arpa/inet.h>
|
||||
#include <microhttpd.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -13,6 +14,53 @@
|
||||
#include "filemgr.h"
|
||||
#include "websrv.h"
|
||||
|
||||
#define REQUEST_BODY_MAX (4 * 1024 * 1024)
|
||||
#define HTTP_CONNECTION_MEMORY_LIMIT (8 * 1024 * 1024)
|
||||
#define HTTP_CONNECTION_MEMORY_INCREMENT (2 * 1024 * 1024)
|
||||
#define HTTP_SOCKET_RCVBUF_SIZE (4 * 1024 * 1024)
|
||||
#define HTTP_SOCKET_SNDBUF_SIZE (4 * 1024 * 1024)
|
||||
|
||||
static volatile sig_atomic_t g_stop_requested;
|
||||
static int g_listen_fd = -1;
|
||||
|
||||
static void
|
||||
websrv_tune_connection_socket(int fd) {
|
||||
const int sndbuf = HTTP_SOCKET_SNDBUF_SIZE;
|
||||
const int nodelay = 1;
|
||||
|
||||
/* OrbisOS HTTP sockets need an explicit send buffer to fill a GbE link. */
|
||||
(void)setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf));
|
||||
(void)setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &nodelay, sizeof(nodelay));
|
||||
}
|
||||
|
||||
typedef struct request_context {
|
||||
char *body;
|
||||
size_t size;
|
||||
int too_large;
|
||||
int upload_stream;
|
||||
void *upload_ctx;
|
||||
} request_context_t;
|
||||
|
||||
static enum MHD_Result
|
||||
websrv_body_too_large(struct MHD_Connection *conn) {
|
||||
static const char json[] =
|
||||
"{\"ok\":false,\"error\":\"request body is too large\","
|
||||
"\"error_code\":\"request_body_too_large\",\"error_arg\":\"\"}";
|
||||
struct MHD_Response *resp =
|
||||
MHD_create_response_from_buffer(sizeof(json) - 1, (void *)json,
|
||||
MHD_RESPMEM_PERSISTENT);
|
||||
enum MHD_Result ret;
|
||||
|
||||
if(!resp) {
|
||||
return MHD_NO;
|
||||
}
|
||||
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_TYPE,
|
||||
"application/json");
|
||||
ret = websrv_queue_response(conn, MHD_HTTP_CONTENT_TOO_LARGE, resp);
|
||||
MHD_destroy_response(resp);
|
||||
return ret;
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
websrv_queue_response(struct MHD_Connection *conn, unsigned int status,
|
||||
struct MHD_Response *resp) {
|
||||
@@ -21,15 +69,27 @@ websrv_queue_response(struct MHD_Connection *conn, unsigned int status,
|
||||
return MHD_queue_response(conn, status, resp);
|
||||
}
|
||||
|
||||
void
|
||||
websrv_stop(void) {
|
||||
g_stop_requested = 1;
|
||||
if(g_listen_fd >= 0) {
|
||||
shutdown(g_listen_fd, SHUT_RDWR);
|
||||
}
|
||||
}
|
||||
|
||||
int
|
||||
websrv_stop_requested(void) {
|
||||
return g_stop_requested;
|
||||
}
|
||||
|
||||
static enum MHD_Result
|
||||
websrv_on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
const char *method, const char *version,
|
||||
const char *upload_data, size_t *upload_data_size,
|
||||
void **con_cls) {
|
||||
request_context_t *ctx = *con_cls;
|
||||
(void)cls;
|
||||
(void)version;
|
||||
(void)upload_data;
|
||||
(void)upload_data_size;
|
||||
|
||||
if(strcmp(method, MHD_HTTP_METHOD_GET) &&
|
||||
strcmp(method, MHD_HTTP_METHOD_POST) &&
|
||||
@@ -37,13 +97,62 @@ websrv_on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
return MHD_NO;
|
||||
}
|
||||
|
||||
if(!*con_cls) {
|
||||
*con_cls = (void *)1;
|
||||
if(!ctx) {
|
||||
if(!(ctx = calloc(1, sizeof(*ctx)))) {
|
||||
return MHD_NO;
|
||||
}
|
||||
ctx->upload_stream = !strcmp(url, "/api/upload-file") &&
|
||||
!strcmp(method, MHD_HTTP_METHOD_POST);
|
||||
*con_cls = ctx;
|
||||
return MHD_YES;
|
||||
}
|
||||
|
||||
if(*upload_data_size) {
|
||||
size_t chunk_size = *upload_data_size;
|
||||
|
||||
if(ctx->upload_stream) {
|
||||
if(!ctx->upload_ctx && filemgr_upload_begin(conn, &ctx->upload_ctx)) {
|
||||
ctx->too_large = 1;
|
||||
}
|
||||
if(ctx->upload_ctx && filemgr_upload_data(ctx->upload_ctx, upload_data,
|
||||
chunk_size)) {
|
||||
ctx->too_large = 1;
|
||||
}
|
||||
*upload_data_size = 0;
|
||||
return MHD_YES;
|
||||
}
|
||||
|
||||
if(chunk_size > REQUEST_BODY_MAX - ctx->size) {
|
||||
ctx->too_large = 1;
|
||||
} else if(!ctx->too_large) {
|
||||
char *body = realloc(ctx->body, ctx->size + chunk_size + 1);
|
||||
if(!body) {
|
||||
return MHD_NO;
|
||||
}
|
||||
ctx->body = body;
|
||||
memcpy(ctx->body + ctx->size, upload_data, chunk_size);
|
||||
ctx->size += chunk_size;
|
||||
ctx->body[ctx->size] = 0;
|
||||
}
|
||||
*upload_data_size = 0;
|
||||
return MHD_YES;
|
||||
}
|
||||
|
||||
if(ctx->too_large) {
|
||||
return ctx->upload_stream ?
|
||||
filemgr_upload_finish(conn, ctx->upload_ctx) :
|
||||
websrv_body_too_large(conn);
|
||||
}
|
||||
|
||||
if(ctx->upload_stream) {
|
||||
if(!ctx->upload_ctx && filemgr_upload_begin(conn, &ctx->upload_ctx)) {
|
||||
return filemgr_upload_finish(conn, ctx->upload_ctx);
|
||||
}
|
||||
return filemgr_upload_finish(conn, ctx->upload_ctx);
|
||||
}
|
||||
|
||||
if(!strncmp(url, "/api/", 5)) {
|
||||
return filemgr_api_request(conn, url);
|
||||
return filemgr_api_request(conn, url, method, ctx->body, ctx->size);
|
||||
}
|
||||
if(!strcmp(url, "/fs")) {
|
||||
return filemgr_fs_request(conn);
|
||||
@@ -51,6 +160,9 @@ websrv_on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
if(!strcmp(url, "/") || !url[0]) {
|
||||
return asset_request(conn, "/index.html");
|
||||
}
|
||||
if(!strcmp(url, "/favicon.ico")) {
|
||||
return asset_request(conn, "/icon0.png");
|
||||
}
|
||||
return asset_request(conn, url);
|
||||
}
|
||||
|
||||
@@ -60,6 +172,15 @@ websrv_on_completed(void *cls, struct MHD_Connection *connection,
|
||||
(void)cls;
|
||||
(void)connection;
|
||||
(void)toe;
|
||||
request_context_t *ctx = *con_cls;
|
||||
|
||||
if(ctx) {
|
||||
if(ctx->upload_ctx) {
|
||||
filemgr_upload_free(ctx->upload_ctx);
|
||||
}
|
||||
free(ctx->body);
|
||||
free(ctx);
|
||||
}
|
||||
*con_cls = NULL;
|
||||
}
|
||||
|
||||
@@ -84,6 +205,12 @@ websrv_listen(unsigned short port) {
|
||||
close(srvfd);
|
||||
return -1;
|
||||
}
|
||||
{
|
||||
const int rcvbuf = HTTP_SOCKET_RCVBUF_SIZE;
|
||||
|
||||
/* Set before listen so accepted PS5 sockets inherit the larger window. */
|
||||
(void)setsockopt(srvfd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
|
||||
}
|
||||
|
||||
memset(&server_addr, 0, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
@@ -100,11 +227,17 @@ websrv_listen(unsigned short port) {
|
||||
close(srvfd);
|
||||
return -1;
|
||||
}
|
||||
g_stop_requested = 0;
|
||||
g_listen_fd = srvfd;
|
||||
|
||||
if(!(httpd = MHD_start_daemon(MHD_USE_THREAD_PER_CONNECTION | MHD_USE_ITC |
|
||||
MHD_USE_NO_LISTEN_SOCKET | MHD_USE_DEBUG |
|
||||
MHD_USE_INTERNAL_POLLING_THREAD,
|
||||
MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_TURBO,
|
||||
0, NULL, NULL, &websrv_on_request, NULL,
|
||||
MHD_OPTION_CONNECTION_MEMORY_LIMIT,
|
||||
(size_t)HTTP_CONNECTION_MEMORY_LIMIT,
|
||||
MHD_OPTION_CONNECTION_MEMORY_INCREMENT,
|
||||
(size_t)HTTP_CONNECTION_MEMORY_INCREMENT,
|
||||
MHD_OPTION_NOTIFY_COMPLETED,
|
||||
&websrv_on_completed, NULL, MHD_OPTION_END))) {
|
||||
perror("MHD_start_daemon");
|
||||
@@ -112,12 +245,13 @@ websrv_listen(unsigned short port) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
while(1) {
|
||||
while(!g_stop_requested) {
|
||||
addr_len = sizeof(client_addr);
|
||||
if((connfd = accept(srvfd, (struct sockaddr *)&client_addr, &addr_len)) < 0) {
|
||||
perror("accept");
|
||||
if(!g_stop_requested) perror("accept");
|
||||
break;
|
||||
}
|
||||
websrv_tune_connection_socket(connfd);
|
||||
if(MHD_add_connection(httpd, connfd, (struct sockaddr *)&client_addr,
|
||||
addr_len) != MHD_YES) {
|
||||
perror("MHD_add_connection");
|
||||
@@ -127,5 +261,6 @@ websrv_listen(unsigned short port) {
|
||||
}
|
||||
|
||||
MHD_stop_daemon(httpd);
|
||||
g_listen_fd = -1;
|
||||
return close(srvfd);
|
||||
}
|
||||
@@ -23,3 +23,5 @@ enum MHD_Result websrv_queue_response(struct MHD_Connection *conn,
|
||||
struct MHD_Response *resp);
|
||||
|
||||
int websrv_listen(unsigned short port);
|
||||
void websrv_stop(void);
|
||||
int websrv_stop_requested(void);
|
||||
@@ -0,0 +1,104 @@
|
||||
#pragma once
|
||||
|
||||
/* Standalone ZIP extraction engine.
|
||||
No HTTP / task dependencies: it can be compiled and tested on its own. */
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#define ZIPX_PATH_MAX 4096
|
||||
|
||||
typedef enum {
|
||||
ZIPX_PHASE_SCAN = 0,
|
||||
ZIPX_PHASE_EXTRACT = 1,
|
||||
ZIPX_PHASE_PUBLISH = 2,
|
||||
ZIPX_PHASE_CLEANUP = 3
|
||||
} zipx_phase_t;
|
||||
|
||||
typedef enum {
|
||||
ZIPX_OK = 0,
|
||||
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_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 */
|
||||
ZIPX_ERR_LIMIT_ENTRIES,
|
||||
ZIPX_ERR_LIMIT_FILE,
|
||||
ZIPX_ERR_LIMIT_TOTAL,
|
||||
ZIPX_ERR_LIMIT_RATIO,
|
||||
ZIPX_ERR_LIMIT_DEPTH,
|
||||
ZIPX_ERR_LIMIT_NAME,
|
||||
ZIPX_ERR_CONFLICT, /* target already exists for the chosen policy */
|
||||
ZIPX_ERR_SPACE,
|
||||
ZIPX_ERR_IO,
|
||||
ZIPX_ERR_CRC,
|
||||
ZIPX_ERR_INTERNAL
|
||||
} zipx_status_t;
|
||||
|
||||
typedef enum {
|
||||
ZIPX_CONFLICT_FAIL = 0,
|
||||
ZIPX_CONFLICT_OVERWRITE = 1,
|
||||
ZIPX_CONFLICT_MERGE = 2
|
||||
} zipx_conflict_t;
|
||||
|
||||
typedef struct {
|
||||
uint64_t max_entries;
|
||||
uint64_t max_total_bytes;
|
||||
uint64_t max_file_bytes;
|
||||
uint32_t max_ratio; /* uncompressed/compressed, 0 disables */
|
||||
uint32_t max_depth;
|
||||
uint32_t max_name_len;
|
||||
uint32_t max_path_len;
|
||||
} zipx_limits_t;
|
||||
|
||||
typedef struct {
|
||||
int phase;
|
||||
uint64_t entries_total;
|
||||
uint64_t entries_done;
|
||||
uint64_t bytes_total;
|
||||
uint64_t bytes_done;
|
||||
const char *current; /* entry name being processed, may be NULL */
|
||||
} zipx_progress_t;
|
||||
|
||||
/* Returns non-zero when the caller wants the operation to stop. */
|
||||
typedef int (*zipx_cancel_fn)(void *userdata);
|
||||
typedef void (*zipx_progress_fn)(void *userdata, const zipx_progress_t *progress);
|
||||
|
||||
typedef struct {
|
||||
zipx_status_t status;
|
||||
int sys_errno;
|
||||
uint64_t entries_total;
|
||||
uint64_t entries_done;
|
||||
uint64_t bytes_total;
|
||||
uint64_t files_created;
|
||||
uint64_t dirs_created;
|
||||
char detail[ZIPX_PATH_MAX]; /* offending path or the staging directory */
|
||||
char message[192];
|
||||
} zipx_result_t;
|
||||
|
||||
const zipx_limits_t *zipx_default_limits(void);
|
||||
|
||||
/* Pre-built limit profiles. Use zipx_limits_profile() to look one up.
|
||||
ZIPX_LIMITS_DEFAULT is the safe profile shipped by zipx_default_limits().
|
||||
ZIPX_LIMITS_LARGE allows archives up to 2 TiB total / 1 TiB per file and
|
||||
a 1000:1 compression ratio. The caller is responsible for verifying that
|
||||
the PS5 has enough free disk space. */
|
||||
#define ZIPX_LIMITS_DEFAULT 0
|
||||
#define ZIPX_LIMITS_LARGE 1
|
||||
|
||||
const zipx_limits_t *zipx_limits_profile(int profile);
|
||||
const char *zipx_status_string(zipx_status_t status);
|
||||
|
||||
/* Extract zip_path into dst_dir.
|
||||
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. */
|
||||
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);
|
||||
@@ -0,0 +1,34 @@
|
||||
/* Host test shim: provides <sys/statvfs.h> for MinGW hosts.
|
||||
Only used when building the test suite (see run-tests.sh). */
|
||||
|
||||
#ifndef WFM_TEST_SYS_STATVFS_H
|
||||
#define WFM_TEST_SYS_STATVFS_H
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
struct statvfs {
|
||||
unsigned long f_bsize;
|
||||
unsigned long f_frsize;
|
||||
unsigned long f_blocks;
|
||||
unsigned long f_bfree;
|
||||
unsigned long f_bavail;
|
||||
};
|
||||
|
||||
static int
|
||||
statvfs(const char *path, struct statvfs *buf) {
|
||||
ULARGE_INTEGER total;
|
||||
ULARGE_INTEGER free_bytes;
|
||||
char root[8];
|
||||
|
||||
snprintf(root, sizeof(root), "%.3s", path);
|
||||
if(!GetDiskFreeSpaceExA(root, &free_bytes, &total, NULL)) {
|
||||
return -1;
|
||||
}
|
||||
memset(buf, 0, sizeof(*buf));
|
||||
buf->f_bsize = 1;
|
||||
buf->f_frsize = 1;
|
||||
buf->f_bavail = free_bytes.QuadPart;
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
@@ -0,0 +1 @@
|
||||
this is definitely not a zip file
|
||||
@@ -0,0 +1,82 @@
|
||||
@echo off
|
||||
REM Generate real RAR fixtures for the v1.9 host test suite.
|
||||
REM Requires WinRAR command-line Rar.exe (ships with normal WinRAR install).
|
||||
REM Outputs into tests\fixtures-real\ - kept OUT of tests\fixtures\ because
|
||||
REM tests\make_fixtures.py wipes that directory on every test run.
|
||||
REM Rerun any time fixture needs change. NOTE: keep this file pure ASCII.
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
set RAREXE=
|
||||
if exist "%ProgramFiles%\WinRAR\Rar.exe" set RAREXE=%ProgramFiles%\WinRAR\Rar.exe
|
||||
if exist "%ProgramFiles(x86)%\WinRAR\Rar.exe" set RAREXE=%ProgramFiles(x86)%\WinRAR\Rar.exe
|
||||
if exist "%~dp0Rar.exe" set RAREXE=%~dp0Rar.exe
|
||||
if "%RAREXE%"=="" (
|
||||
echo [ERROR] Rar.exe not found. Install WinRAR or drop Rar.exe next to this script.
|
||||
exit /b 1
|
||||
)
|
||||
echo Using: %RAREXE%
|
||||
"%RAREXE%" 2>nul | findstr /c:"RAR " >nul || (echo [ERROR] %RAREXE% does not look like WinRAR & exit /b 1)
|
||||
|
||||
set FIX=%~dp0fixtures-real
|
||||
if exist "%FIX%" rmdir /s /q "%FIX%"
|
||||
mkdir "%FIX%"
|
||||
set STAGE=%~dp0fixture-stage
|
||||
if exist "%STAGE%" rmdir /s /q "%STAGE%"
|
||||
mkdir "%STAGE%\dir" 2>nul
|
||||
|
||||
echo ############### > "%STAGE%\root.txt"
|
||||
echo rar 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"
|
||||
echo file-b 0102030405060708090a > "%STAGE%\b.bin"
|
||||
|
||||
REM Work from inside the stage dir with RELATIVE names so the archive keeps
|
||||
REM the dir\ structure (no -ep1 stripping).
|
||||
pushd "%STAGE%"
|
||||
|
||||
echo.
|
||||
echo [1/4] RAR5 v6 single volume - basic-v6.rar
|
||||
"%RAREXE%" a -m2 -ma5 -idq "%FIX%\basic-v6.rar" root.txt dir\nested.txt b.bin
|
||||
if errorlevel 1 echo [FAIL] & goto :badpop
|
||||
|
||||
echo [2/4] RAR5 v6 multi-volume - vol.part1.rar + vol.part2.rar + ...
|
||||
REM Create a 512 KB incompressible payload so -v200k actually splits it.
|
||||
fsutil file createnew big.bin 524288 >nul 2>nul
|
||||
if not exist big.bin (
|
||||
echo [WARN] fsutil unavailable - cannot force multi-volume; skipping
|
||||
goto :aftervol
|
||||
)
|
||||
REM rar a updates an existing archive instead of re-splitting it, so any
|
||||
REM stale volume files must be removed first. Target name is plain
|
||||
REM "vol.rar": RAR5 numbering then yields vol.part1/2/3.rar (naming the
|
||||
REM target vol.part1.rar would double up to vol.part1.partN.rar).
|
||||
del "%FIX%\vol*.rar" 2>nul
|
||||
"%RAREXE%" a -m0 -ma5 -v200k -idq "%FIX%\vol.rar" big.bin root.txt
|
||||
if errorlevel 1 echo [FAIL] & goto :badpop
|
||||
echo wrote: & dir /b "%FIX%\vol.part*.rar" 2>nul
|
||||
:aftervol
|
||||
|
||||
echo [3/4] RAR5 v6 encrypted (password: secret123) - enc-v6.rar
|
||||
"%RAREXE%" a -m2 -ma5 -psecret123 -idq "%FIX%\enc-v6.rar" root.txt dir\nested.txt
|
||||
if errorlevel 1 echo [FAIL] & goto :badpop
|
||||
|
||||
echo [4/4] RAR4 legacy (optional - some WinRAR builds dropped RAR4 writing)
|
||||
"%RAREXE%" a -m2 -ma4 -idq "%FIX%\basic-rar4.rar" root.txt dir\nested.txt
|
||||
if errorlevel 1 (
|
||||
echo [WARN] RAR4 creation not supported by this Rar.exe - skipping basic-rar4.rar
|
||||
) else (
|
||||
echo basic-rar4.rar written
|
||||
)
|
||||
|
||||
popd
|
||||
rmdir /s /q "%STAGE%" 2>nul
|
||||
echo.
|
||||
echo OK. Fixtures written to %FIX%:
|
||||
dir /b "%FIX%\*.rar" 2>nul
|
||||
exit /b 0
|
||||
|
||||
:badpop
|
||||
popd
|
||||
:bad
|
||||
echo [ERROR] WinRAR command failed. Is this WinRAR 5+ with command line support?
|
||||
exit /b 1
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the ZIP fixtures used by test_zip_extract."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import struct
|
||||
import sys
|
||||
import time as _time
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT = os.path.join(HERE, "fixtures")
|
||||
|
||||
# Force every ZIP entry's date_time to a fixed value (1980-01-01 00:00:00)
|
||||
# so generated archives are byte-stable across runs. Without this fix,
|
||||
# Python 3.13's zipfile.writestr() passes time.localtime(time.time())[:6]
|
||||
# into ZipInfo(...) when the caller supplies a string arcname, which makes
|
||||
# every fixture differ each run and the git diff stat balloons on every
|
||||
# "regenerate fixtures" pass. We swap the zipfile module's `time` symbol
|
||||
# for a fake that always returns the same struct_time; the fake also stubs
|
||||
# `time.time` since zipfile.writestr chains localtime(time.time()).
|
||||
_FIXED_DT = (1980, 1, 1, 0, 0, 0)
|
||||
zipfile.time = type("_FakeTimeMod", (), {
|
||||
"localtime": staticmethod(lambda *_a, **_k: _time.struct_time(_FIXED_DT + (0, 1, 0))),
|
||||
"time": staticmethod(lambda *_a, **_k: 0.0),
|
||||
})()
|
||||
|
||||
|
||||
def fresh():
|
||||
if os.path.isdir(OUT):
|
||||
shutil.rmtree(OUT)
|
||||
os.makedirs(OUT)
|
||||
|
||||
|
||||
def path(name):
|
||||
return os.path.join(OUT, name)
|
||||
|
||||
|
||||
def basic():
|
||||
with zipfile.ZipFile(path("basic.zip"), "w") as zf:
|
||||
zf.writestr("root.txt", "root content")
|
||||
zf.writestr("dir/nested.txt", "nested content")
|
||||
zf.writestr("dir/deep/deeper.txt", "deeper content")
|
||||
zi = zipfile.ZipInfo("empty_dir/")
|
||||
zi.external_attr = (stat.S_IFDIR | 0o755) << 16
|
||||
zi.create_system = 3
|
||||
zf.writestr(zi, b"")
|
||||
|
||||
|
||||
def stored():
|
||||
with zipfile.ZipFile(path("stored.zip"), "w", zipfile.ZIP_STORED) as zf:
|
||||
zf.writestr("stored.txt", "stored content" * 100)
|
||||
|
||||
|
||||
def unicode_names():
|
||||
with zipfile.ZipFile(path("unicode.zip"), "w") as zf:
|
||||
zf.writestr("中文目录/文件.txt", "unicode content")
|
||||
zf.writestr("emoji-\U0001f600.txt", "emoji content")
|
||||
|
||||
|
||||
def zip64():
|
||||
"""A genuine ZIP64 archive. Python's zipfile only emits zip64 fields when
|
||||
sizes exceed 4 GiB (impractical for a fixture), so the layout is written by
|
||||
hand: 32-bit size fields are set to 0xFFFFFFFF and the real values live in
|
||||
the zip64 extra fields and the zip64 end-of-central-directory record."""
|
||||
name = "big.bin"
|
||||
data = bytes(range(256)) * 16 # 4096 bytes, deterministic
|
||||
name_b = name.encode("utf-8")
|
||||
crc = zlib.crc32(data) & 0xFFFFFFFF
|
||||
size = len(data)
|
||||
|
||||
def extra_local():
|
||||
return struct.pack("<HHQQ", 0x0001, 16, size, size)
|
||||
|
||||
def extra_central(offset):
|
||||
return struct.pack("<HHQQQ", 0x0001, 24, size, size, offset)
|
||||
|
||||
out = bytearray()
|
||||
|
||||
# Local file header (stored, sizes deferred to zip64 extra field).
|
||||
local_offset = 0
|
||||
el = extra_local()
|
||||
out += struct.pack("<IHHHHHIIIHH", 0x04034B50, 45, 0, 0, 0, 0, crc,
|
||||
0xFFFFFFFF, 0xFFFFFFFF, len(name_b), len(el))
|
||||
out += name_b + el + data
|
||||
|
||||
# Central directory header.
|
||||
cd_offset = len(out)
|
||||
ec = extra_central(local_offset)
|
||||
out += struct.pack("<IHHHHHHIIIHHHHHII", 0x02014B50, 45, 45, 0, 0, 0, 0,
|
||||
crc, 0xFFFFFFFF, 0xFFFFFFFF, len(name_b), len(ec), 0,
|
||||
0, 0, 0, 0xFFFFFFFF)
|
||||
out += name_b + ec
|
||||
cd_size = len(out) - cd_offset
|
||||
|
||||
# ZIP64 end of central directory record.
|
||||
zip64_eocd_offset = len(out)
|
||||
out += struct.pack("<IQHHIIQQQQ", 0x06064B50, 44, 45, 45, 0, 0, 1, 1,
|
||||
cd_size, cd_offset)
|
||||
|
||||
# ZIP64 end of central directory locator.
|
||||
out += struct.pack("<IIQI", 0x07064B50, 0, zip64_eocd_offset, 1)
|
||||
|
||||
# End of central directory record (offsets deferred to zip64).
|
||||
out += struct.pack("<IHHHHIIH", 0x06054B50, 0, 0, 1, 1, 0xFFFFFFFF,
|
||||
0xFFFFFFFF, 0)
|
||||
|
||||
with open(path("zip64.zip"), "wb") as fh:
|
||||
fh.write(bytes(out))
|
||||
|
||||
|
||||
def traversal():
|
||||
with zipfile.ZipFile(path("traversal.zip"), "w") as zf:
|
||||
zf.writestr("ok.txt", "ok")
|
||||
zf.writestr("../evil.txt", "evil")
|
||||
|
||||
|
||||
def traversal_backslash():
|
||||
with zipfile.ZipFile(path("traversal_bs.zip"), "w") as zf:
|
||||
zf.writestr("ok.txt", "ok")
|
||||
zf.writestr("..\\evil.txt", "evil")
|
||||
|
||||
|
||||
def absolute():
|
||||
with zipfile.ZipFile(path("absolute.zip"), "w") as zf:
|
||||
zf.writestr("/tmp/evil.txt", "evil")
|
||||
|
||||
|
||||
def drive_letter():
|
||||
with zipfile.ZipFile(path("drive.zip"), "w") as zf:
|
||||
zf.writestr("C:/evil.txt", "evil")
|
||||
|
||||
|
||||
def duplicate():
|
||||
with zipfile.ZipFile(path("duplicate.zip"), "w") as zf:
|
||||
zf.writestr("a.txt", "first")
|
||||
zf.writestr("a.txt", "second")
|
||||
|
||||
|
||||
def file_dir_clash():
|
||||
with zipfile.ZipFile(path("clash.zip"), "w") as zf:
|
||||
zf.writestr("a", "file")
|
||||
zf.writestr("a/b.txt", "child")
|
||||
|
||||
|
||||
def symlink_entry():
|
||||
with zipfile.ZipFile(path("symlink.zip"), "w") as zf:
|
||||
zf.writestr("ok.txt", "ok")
|
||||
zi = zipfile.ZipInfo("link")
|
||||
zi.create_system = 3
|
||||
zi.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
zf.writestr(zi, "/etc/passwd")
|
||||
|
||||
|
||||
def fifo_entry():
|
||||
with zipfile.ZipFile(path("fifo.zip"), "w") as zf:
|
||||
zi = zipfile.ZipInfo("pipe")
|
||||
zi.create_system = 3
|
||||
zi.external_attr = (stat.S_IFIFO | 0o644) << 16
|
||||
zf.writestr(zi, b"")
|
||||
|
||||
|
||||
def encrypted():
|
||||
with zipfile.ZipFile(path("encrypted.zip"), "w") as zf:
|
||||
zf.writestr("secret.txt", "secret")
|
||||
data = bytearray(open(path("encrypted.zip"), "rb").read())
|
||||
idx = data.find(b"PK\x03\x04")
|
||||
if idx < 0:
|
||||
raise SystemExit("local header not found")
|
||||
data[idx + 6] |= 0x01 # general purpose bit 0 = encrypted
|
||||
cd = data.find(b"PK\x01\x02")
|
||||
data[cd + 8] |= 0x01
|
||||
open(path("encrypted.zip"), "wb").write(bytes(data))
|
||||
|
||||
|
||||
def bad_crc():
|
||||
with zipfile.ZipFile(path("bad_crc.zip"), "w") as zf:
|
||||
zf.writestr("data.bin", "x" * 4096)
|
||||
data = bytearray(open(path("bad_crc.zip"), "rb").read())
|
||||
cd = data.find(b"PK\x01\x02")
|
||||
if cd < 0:
|
||||
raise SystemExit("central directory not found")
|
||||
data[cd + 16:cd + 20] = b"\xde\xad\xbe\xef"
|
||||
open(path("bad_crc.zip"), "wb").write(bytes(data))
|
||||
|
||||
|
||||
def truncated():
|
||||
with zipfile.ZipFile(path("full.zip"), "w") as zf:
|
||||
zf.writestr("data.txt", "y" * 4096)
|
||||
data = open(path("full.zip"), "rb").read()
|
||||
open(path("truncated.zip"), "wb").write(data[:-40])
|
||||
|
||||
|
||||
def not_a_zip():
|
||||
open(path("notazip.zip"), "wb").write(b"this is definitely not a zip file")
|
||||
|
||||
|
||||
def bomb():
|
||||
with zipfile.ZipFile(path("bomb.zip"), "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("bomb.bin", "A" * (4 * 1024 * 1024))
|
||||
|
||||
|
||||
def medium_bomb():
|
||||
"""1 MiB of 0..255 cycled, which deflate squeezes to ~4.4 KiB
|
||||
(ratio ~238). Sits between the default cap (200) and the large cap
|
||||
(1000) so the default profile rejects it and the large profile
|
||||
accepts it. Used by the large-profile host test."""
|
||||
with zipfile.ZipFile(path("medium_bomb.zip"), "w",
|
||||
zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("medium.bin", bytes(range(256)) * 4096)
|
||||
|
||||
|
||||
def many_files():
|
||||
with zipfile.ZipFile(path("many.zip"), "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for i in range(500):
|
||||
zf.writestr("many/f%03d.txt" % i, "%d" % i)
|
||||
|
||||
|
||||
def conflict_source():
|
||||
"""A zip whose top level collides with an existing destination layout."""
|
||||
with zipfile.ZipFile(path("conflict.zip"), "w") as zf:
|
||||
zf.writestr("shared.txt", "from zip")
|
||||
zf.writestr("shareddir/inner.txt", "inner from zip")
|
||||
zf.writestr("shareddir/added.txt", "added from zip")
|
||||
|
||||
|
||||
def rar_fixtures():
|
||||
"""Generate RAR fixtures if a rar/7z writer is available.
|
||||
|
||||
We intentionally do not depend on a rar binary being installed in the
|
||||
host test environment; when neither `rar` nor `7z` is present we leave
|
||||
1-byte placeholders so that tests/test_rar_extract.c can still hit its
|
||||
"not a real archive" branches. See docs/HANDOVER.md for the manual
|
||||
fixture procedure used in CI on a developer workstation that has WinRAR.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
candidates = []
|
||||
for cmd in ("rar", "7z", "7za"):
|
||||
if shutil.which(cmd):
|
||||
candidates.append(cmd)
|
||||
|
||||
staging_dir = tempfile.mkdtemp(prefix="wfm-rar-fixtures-")
|
||||
try:
|
||||
# Build a small directory we can compress into a RAR.
|
||||
staging_root = os.path.join(staging_dir, "stage")
|
||||
os.makedirs(staging_root)
|
||||
with open(os.path.join(staging_root, "root.txt"), "wb") as f:
|
||||
f.write(b"rar root content\n")
|
||||
nested = os.path.join(staging_root, "dir")
|
||||
os.makedirs(nested)
|
||||
with open(os.path.join(nested, "nested.txt"), "wb") as f:
|
||||
f.write(b"rar nested content\n")
|
||||
|
||||
out = path("basic.rar")
|
||||
ok = False
|
||||
for cmd in candidates:
|
||||
args = [cmd, "a", "-r", "-ep1", out,
|
||||
os.path.join(staging_root, "root.txt"),
|
||||
os.path.join(staging_root, "dir")]
|
||||
# 7z uses -t7z / -rr differently; for the purposes of a smoke
|
||||
# fixture we only need any small valid RAR.
|
||||
try:
|
||||
rc = subprocess.call(args, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
if rc == 0 and os.path.exists(out) and os.path.getsize(out) > 16:
|
||||
print("rar_fixtures: built %s via %s" % (out, cmd))
|
||||
ok = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not ok:
|
||||
# Placeholder: the test suite only needs a file that dmc_unrar
|
||||
# will reject. 8 bytes is far too small to be a valid archive.
|
||||
with open(out, "wb") as f:
|
||||
f.write(b"placeholder")
|
||||
finally:
|
||||
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def 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):
|
||||
fn()
|
||||
print("fixtures written to %s" % OUT)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,33 @@
|
||||
/* Host-only shim used to build minizip-ng's POSIX backend on MinGW.
|
||||
mz_os_posix.c needs utime(), lstat(), symlink() and readlink(); Windows has
|
||||
none of them. The stubs are only compiled into the host test binary. */
|
||||
|
||||
#ifndef WFM_TEST_MINGW_HOST_H
|
||||
#define WFM_TEST_MINGW_HOST_H
|
||||
|
||||
#include "posix_compat.h"
|
||||
|
||||
#if defined(__MINGW32__) || defined(_WIN32)
|
||||
|
||||
#include <sys/utime.h>
|
||||
|
||||
static int __attribute__((unused))
|
||||
symlink(const char *target, const char *linkpath) {
|
||||
(void)target;
|
||||
(void)linkpath;
|
||||
errno = ENOSYS;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static ssize_t __attribute__((unused))
|
||||
readlink(const char *path, char *buf, size_t size) {
|
||||
(void)path;
|
||||
(void)buf;
|
||||
(void)size;
|
||||
errno = ENOSYS;
|
||||
return -1;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,262 @@
|
||||
/* Host test shim: lets the POSIX extraction engine build and run on MinGW.
|
||||
Injected with gcc -include for the test build only; never compiled into the
|
||||
PS5 payload. It maps the *at() calls onto plain paths and fakes the few
|
||||
POSIX bits Windows lacks (symlinks and O_NOFOLLOW have no Windows
|
||||
equivalent, which is why the symlink tests are skipped there). */
|
||||
|
||||
#ifndef WFM_TEST_POSIX_COMPAT_H
|
||||
#define WFM_TEST_POSIX_COMPAT_H
|
||||
|
||||
#if defined(__MINGW32__) || defined(_WIN32)
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <io.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <time.h>
|
||||
#include <windows.h>
|
||||
|
||||
#ifndef PATH_MAX
|
||||
#define PATH_MAX 4096
|
||||
#endif
|
||||
|
||||
#define O_NOFOLLOW 0
|
||||
#define O_CLOEXEC 0
|
||||
/* Windows cannot open a directory with _open(); a non-zero sentinel lets the
|
||||
shim detect directory opens and hand back a synthetic dirfd. */
|
||||
#define O_DIRECTORY 0x10000
|
||||
#define AT_SYMLINK_NOFOLLOW 0
|
||||
#define AT_REMOVEDIR 0x0200
|
||||
#ifndef S_IFLNK
|
||||
#define S_IFLNK 0xA000
|
||||
#endif
|
||||
#ifndef S_ISLNK
|
||||
#define S_ISLNK(m) (((m) & S_IFMT) == S_IFLNK)
|
||||
#endif
|
||||
|
||||
#ifndef CLOCK_MONOTONIC
|
||||
#define CLOCK_MONOTONIC 1
|
||||
#endif
|
||||
|
||||
#define WFM_FD_SLOTS 512
|
||||
|
||||
#define open(...) wfm_open(__VA_ARGS__)
|
||||
|
||||
static struct {
|
||||
int fd;
|
||||
char path[PATH_MAX];
|
||||
} wfm_fd_slots[WFM_FD_SLOTS];
|
||||
|
||||
static void __attribute__((unused))
|
||||
wfm_fd_set(int fd, const char *path) {
|
||||
int i;
|
||||
|
||||
if(fd < 0) {
|
||||
return;
|
||||
}
|
||||
for(i = 0; i < WFM_FD_SLOTS; i++) {
|
||||
if(wfm_fd_slots[i].fd == fd || !wfm_fd_slots[i].path[0]) {
|
||||
wfm_fd_slots[i].fd = fd;
|
||||
snprintf(wfm_fd_slots[i].path, PATH_MAX, "%s", path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void __attribute__((unused))
|
||||
wfm_fd_clear(int fd) {
|
||||
int i;
|
||||
|
||||
for(i = 0; i < WFM_FD_SLOTS; i++) {
|
||||
if(wfm_fd_slots[i].fd == fd) {
|
||||
wfm_fd_slots[i].fd = -1;
|
||||
wfm_fd_slots[i].path[0] = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static const char *
|
||||
wfm_fd_path(int fd) {
|
||||
int i;
|
||||
|
||||
for(i = 0; i < WFM_FD_SLOTS; i++) {
|
||||
if(wfm_fd_slots[i].fd == fd) {
|
||||
return wfm_fd_slots[i].path;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int
|
||||
wfm_join(int dirfd, const char *rel, char *out, size_t out_size) {
|
||||
const char *base = wfm_fd_path(dirfd);
|
||||
|
||||
if(!base) {
|
||||
errno = EBADF;
|
||||
return -1;
|
||||
}
|
||||
if(snprintf(out, out_size, "%s/%s", base, rel) >= (int)out_size) {
|
||||
errno = ENAMETOOLONG;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int __attribute__((unused))
|
||||
wfm_open(const char *path, int flags, ...) {
|
||||
int mode = 0;
|
||||
int fd;
|
||||
|
||||
if(flags & O_CREAT) {
|
||||
va_list ap;
|
||||
|
||||
va_start(ap, flags);
|
||||
mode = va_arg(ap, int);
|
||||
va_end(ap);
|
||||
}
|
||||
/* Directory opens become synthetic fds so openat/mkdirat can resolve them
|
||||
to paths; _open() returns EACCES for directories on Windows. */
|
||||
if(flags & O_DIRECTORY) {
|
||||
static int next_dirfd = 0x10000;
|
||||
|
||||
fd = next_dirfd++;
|
||||
wfm_fd_set(fd, path);
|
||||
return fd;
|
||||
}
|
||||
/* Force O_BINARY: MinGW's _open defaults to text mode, which would
|
||||
translate LF -> CRLF on write and corrupt binary payloads. */
|
||||
fd = _open(path, (flags & ~(O_NOFOLLOW | O_DIRECTORY | O_CLOEXEC)) | O_BINARY,
|
||||
mode);
|
||||
if(fd >= 0) {
|
||||
wfm_fd_set(fd, path);
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
static int __attribute__((unused))
|
||||
wfm_openat(int dirfd, const char *path, int flags, ...) {
|
||||
char full[PATH_MAX];
|
||||
int mode = 0;
|
||||
|
||||
if(wfm_join(dirfd, path, full, sizeof(full))) {
|
||||
return -1;
|
||||
}
|
||||
if(flags & O_CREAT) {
|
||||
va_list ap;
|
||||
|
||||
va_start(ap, flags);
|
||||
mode = va_arg(ap, int);
|
||||
va_end(ap);
|
||||
}
|
||||
return wfm_open(full, flags, mode);
|
||||
}
|
||||
|
||||
static int __attribute__((unused))
|
||||
wfm_mkdirat(int dirfd, const char *path, mode_t mode) {
|
||||
char full[PATH_MAX];
|
||||
|
||||
(void)mode;
|
||||
if(wfm_join(dirfd, path, full, sizeof(full))) {
|
||||
return -1;
|
||||
}
|
||||
return mkdir(full);
|
||||
}
|
||||
|
||||
static int __attribute__((unused))
|
||||
wfm_renameat(int from_fd, const char *from, int to_fd, const char *to) {
|
||||
char src[PATH_MAX];
|
||||
char dst[PATH_MAX];
|
||||
|
||||
if(wfm_join(from_fd, from, src, sizeof(src)) ||
|
||||
wfm_join(to_fd, to, dst, sizeof(dst))) {
|
||||
return -1;
|
||||
}
|
||||
/* Windows rename() refuses to replace an existing file. */
|
||||
if(_access(dst, 0) == 0) {
|
||||
if(remove(dst)) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return rename(src, dst);
|
||||
}
|
||||
|
||||
static int __attribute__((unused))
|
||||
wfm_rename(const char *from, const char *to) {
|
||||
/* Windows rename() refuses to replace an existing file, unlike POSIX. */
|
||||
if(_access(to, 0) == 0) {
|
||||
if(remove(to)) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return rename(from, to);
|
||||
}
|
||||
|
||||
static int __attribute__((unused))
|
||||
wfm_unlinkat(int dirfd, const char *path, int flags) {
|
||||
char full[PATH_MAX];
|
||||
|
||||
if(wfm_join(dirfd, path, full, sizeof(full))) {
|
||||
return -1;
|
||||
}
|
||||
return (flags & AT_REMOVEDIR) ? rmdir(full) : unlink(full);
|
||||
}
|
||||
|
||||
static int __attribute__((unused))
|
||||
wfm_mkdir1(const char *path) {
|
||||
return mkdir(path); /* MinGW's mkdir() takes a single argument. */
|
||||
}
|
||||
|
||||
static int __attribute__((unused))
|
||||
wfm_fstatat(int dirfd, const char *path, struct stat *st, int flags) {
|
||||
char full[PATH_MAX];
|
||||
|
||||
(void)flags;
|
||||
if(wfm_join(dirfd, path, full, sizeof(full))) {
|
||||
return -1;
|
||||
}
|
||||
return stat(full, st);
|
||||
}
|
||||
|
||||
static int __attribute__((unused))
|
||||
wfm_fsync(int fd) {
|
||||
return _commit(fd);
|
||||
}
|
||||
|
||||
static int __attribute__((unused))
|
||||
wfm_fchmod(int fd, mode_t mode) {
|
||||
(void)fd;
|
||||
(void)mode;
|
||||
return 0; /* Windows has no Unix modes; the engine ignores this failure. */
|
||||
}
|
||||
|
||||
static int __attribute__((unused))
|
||||
wfm_close(int fd) {
|
||||
wfm_fd_clear(fd);
|
||||
if(fd >= 0x10000) {
|
||||
return 0; /* synthetic dirfd, nothing to close */
|
||||
}
|
||||
return _close(fd);
|
||||
}
|
||||
|
||||
#define mkdir(p, ...) wfm_mkdir1(p)
|
||||
#define open(...) wfm_open(__VA_ARGS__)
|
||||
#define openat(...) wfm_openat(__VA_ARGS__)
|
||||
#define mkdirat(d, p, m) wfm_mkdirat(d, p, m)
|
||||
#define rename(a, b) wfm_rename(a, b)
|
||||
#define renameat(sd, sp, dd, dp) wfm_renameat(sd, sp, dd, dp)
|
||||
#define unlinkat(d, p, f) wfm_unlinkat(d, p, f)
|
||||
#define fstatat(d, p, s, f) wfm_fstatat(d, p, s, f)
|
||||
#define fsync(fd) wfm_fsync(fd)
|
||||
#define fchmod(fd, mode) wfm_fchmod(fd, mode)
|
||||
#define close(fd) wfm_close(fd)
|
||||
#define lstat(p, s) stat(p, s)
|
||||
|
||||
#endif /* _WIN32 */
|
||||
|
||||
#endif /* WFM_TEST_POSIX_COMPAT_H */
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# Host test runner for the ZIP and RAR extraction engines.
|
||||
# Builds the vendored minizip-ng/zlib/dmc_unrar sources, both engines and
|
||||
# the test suites with the host compiler and runs each suite.
|
||||
#
|
||||
# ./tests/run-tests.sh
|
||||
#
|
||||
# On Windows this expects MinGW gcc in PATH (Git Bash is fine).
|
||||
|
||||
set -e
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -W 2>/dev/null || pwd)"
|
||||
BUILD="$ROOT/.build/host-test"
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
CC="${CC:-gcc}"
|
||||
|
||||
rm -rf "$BUILD"
|
||||
mkdir -p "$BUILD"
|
||||
|
||||
"$PYTHON" "$ROOT/tests/make_fixtures.py"
|
||||
|
||||
MZ_CFLAGS=(-I"$ROOT/third_party/minizip-ng/include" -DHAVE_ZLIB -DZLIB_COMPAT)
|
||||
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.
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) MZ_CFLAGS+=(-DO_NOFOLLOW=0); HOST_KIND=windows ;;
|
||||
esac
|
||||
|
||||
# zlib + minizip-ng (plain POSIX sources, no shim needed)
|
||||
for src in "$ROOT"/third_party/zlib/src/*.c "$ROOT"/third_party/minizip-ng/src/*.c; do
|
||||
name="$(basename "$src" .c)"
|
||||
extra=()
|
||||
# minizip's POSIX backend needs utime/lstat/symlink/readlink, stubbed on Windows.
|
||||
case "$name:$HOST_KIND" in
|
||||
mz_os_posix:windows) extra=(-include "$ROOT/tests/mingw_host.h") ;;
|
||||
esac
|
||||
"$CC" -c -O2 -w -I"$ROOT/third_party/zlib/include" -DHAVE_UNISTD_H=1 \
|
||||
"${MZ_CFLAGS[@]}" "${extra[@]}" -o "$BUILD/$name.o" "$src"
|
||||
done
|
||||
|
||||
# unrar 7.20.1 (RARDLL source set; compiled with the host C++ compiler).
|
||||
UNRAR7_SRCS="$ROOT/third_party/unrar7"
|
||||
UNRAR7_CFLAGS=(-O2 -w -std=c++17 -DRARDLL -D_FILE_OFFSET_BITS=64 -D_LARGEFILE_SOURCE)
|
||||
CXX="${CXX:-g++}"
|
||||
for src in \
|
||||
archive arcread blake2s cmddata consio crc crypt dll encname errhnd extinfo \
|
||||
extract filcreat file filefn filestr find getbits global hash headers isnt \
|
||||
largepage match motw options pathfn qopen rar rarpch rarvm rawread rdwrfn \
|
||||
rijndael rs rs16 scantree secpassword sha1 sha256 smallfn strfn strlist \
|
||||
system threadpool timefn ui unicode unpack volume; do
|
||||
"$CXX" -c "${UNRAR7_CFLAGS[@]}" -o "$BUILD/unrar7_$src.o" \
|
||||
"$UNRAR7_SRCS/$src.cpp" || exit 1
|
||||
done
|
||||
|
||||
COMPAT_INC="$ROOT/tests/compat"
|
||||
|
||||
# The engines and the test suites get the POSIX shim on Windows hosts.
|
||||
"$CC" -c -O2 -Wall -Wextra -Wno-unused-parameter \
|
||||
-I"$ROOT/third_party/minizip-ng/include" -I"$ROOT/src" -I"$COMPAT_INC" \
|
||||
-include "$ROOT/tests/posix_compat.h" \
|
||||
-o "$BUILD/zip_extract.o" "$ROOT/src/zip_extract.c"
|
||||
|
||||
"$CC" -c -O2 -Wall -Wextra -Wno-unused-parameter \
|
||||
-I"$ROOT/third_party/minizip-ng/include" -I"$ROOT/third_party/unrar7" -I"$ROOT/src" -I"$COMPAT_INC" \
|
||||
-include "$ROOT/tests/posix_compat.h" \
|
||||
-o "$BUILD/rar_extract.o" "$ROOT/src/rar_extract.c"
|
||||
|
||||
"$CC" -c -O2 -Wall -Wextra -Wno-unused-parameter -I"$ROOT/src" \
|
||||
-I"$COMPAT_INC" -include "$ROOT/tests/posix_compat.h" \
|
||||
-o "$BUILD/test_zip_extract.o" "$ROOT/tests/test_zip_extract.c"
|
||||
|
||||
"$CC" -c -O2 -Wall -Wextra -Wno-unused-parameter -I"$ROOT/src" \
|
||||
-I"$COMPAT_INC" -include "$ROOT/tests/posix_compat.h" \
|
||||
-o "$BUILD/test_rar_extract.o" "$ROOT/tests/test_rar_extract.c"
|
||||
|
||||
objs=()
|
||||
rar_objs=()
|
||||
for obj in "$BUILD"/*.o; do
|
||||
case "$obj" in
|
||||
*/zip_extract.o|*/rar_extract.o|*/test_zip_extract.o|*/test_rar_extract.o) continue ;;
|
||||
*/unrar7_*.o) rar_objs+=("$obj"); continue ;;
|
||||
esac
|
||||
objs+=("$obj")
|
||||
done
|
||||
|
||||
"$CC" -O2 -o "$BUILD/test-zip-extract" \
|
||||
"$BUILD/zip_extract.o" "$BUILD/test_zip_extract.o" "${objs[@]}"
|
||||
|
||||
# The RAR test links the unrar7 objects, so it needs the C++ driver.
|
||||
# Windows unrar system.cpp references SetSuspendState (PowrProf).
|
||||
RAR_LIBS=()
|
||||
[ "$HOST_KIND" = windows ] && RAR_LIBS=(-lpowrprof)
|
||||
"$CXX" -O2 -o "$BUILD/test-rar-extract" \
|
||||
"$BUILD/rar_extract.o" "$BUILD/test_rar_extract.o" \
|
||||
"$BUILD/zip_extract.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.
|
||||
"$BUILD/test-rar-extract" "$ROOT/tests/fixtures" "$BUILD/work-rar" \
|
||||
"$ROOT/tests/fixtures-real"
|
||||
@@ -0,0 +1,473 @@
|
||||
/* 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. */
|
||||
|
||||
#include "../src/zip_extract.h"
|
||||
#include "../src/rar_extract.h"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#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;
|
||||
|
||||
static void
|
||||
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);
|
||||
}
|
||||
|
||||
static void
|
||||
check(int ok, const char *what) {
|
||||
g_checks++;
|
||||
if(!ok) {
|
||||
g_failures++;
|
||||
printf(" FAIL %s\n", what);
|
||||
}
|
||||
}
|
||||
|
||||
static int
|
||||
exists(const char *path) {
|
||||
struct stat st;
|
||||
|
||||
return !stat(path, &st);
|
||||
}
|
||||
|
||||
static int
|
||||
write_bytes(const char *path, const void *data, size_t n) {
|
||||
FILE *f = fopen(path, "wb");
|
||||
|
||||
if(!f) {
|
||||
return -1;
|
||||
}
|
||||
if(data && n) {
|
||||
fwrite(data, 1, n, f);
|
||||
}
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Writes a deterministic "random" byte run so the file is not empty. */
|
||||
static int
|
||||
write_junk(const char *path, size_t n) {
|
||||
unsigned char *buf = malloc(n);
|
||||
size_t i;
|
||||
FILE *f;
|
||||
int rc = -1;
|
||||
|
||||
if(!buf) {
|
||||
return -1;
|
||||
}
|
||||
for(i = 0; i < n; i++) {
|
||||
buf[i] = (unsigned char)((i * 131 + 17) & 0xff);
|
||||
}
|
||||
f = fopen(path, "wb");
|
||||
if(f) {
|
||||
if(fwrite(buf, 1, n, f) == n) {
|
||||
rc = 0;
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
free(buf);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int
|
||||
remove_dir(const char *path) {
|
||||
DIR *dir = opendir(path);
|
||||
struct dirent *ent;
|
||||
|
||||
if(!dir) {
|
||||
return rmdir(path);
|
||||
}
|
||||
while((ent = readdir(dir))) {
|
||||
char child[4096];
|
||||
struct stat st;
|
||||
|
||||
if(!strcmp(ent->d_name, ".") || !strcmp(ent->d_name, "..")) {
|
||||
continue;
|
||||
}
|
||||
snprintf(child, sizeof(child), "%s/%s", path, ent->d_name);
|
||||
if(!stat(child, &st) && S_ISDIR(st.st_mode)) {
|
||||
remove_dir(child);
|
||||
} else {
|
||||
unlink(child);
|
||||
}
|
||||
}
|
||||
closedir(dir);
|
||||
return rmdir(path);
|
||||
}
|
||||
|
||||
static zipx_status_t
|
||||
run_rar(const char *fixture, const char *dst, zipx_status_t expected,
|
||||
const char *label) {
|
||||
char src[4096];
|
||||
zipx_result_t res;
|
||||
zipx_status_t st;
|
||||
|
||||
fixture_path(src, sizeof(src), fixture);
|
||||
if(!exists(src)) {
|
||||
printf(" SKIP %s (fixture %s missing)\n", label, fixture);
|
||||
return ZIPX_OK;
|
||||
}
|
||||
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);
|
||||
check(st == expected, label);
|
||||
if(st != ZIPX_OK) {
|
||||
check(res.message[0] != 0, " has error message");
|
||||
printf(" message=%s\n", res.message);
|
||||
}
|
||||
remove_dir(dst);
|
||||
return st;
|
||||
}
|
||||
|
||||
static void
|
||||
test_engine_dispatch(void) {
|
||||
char dst[4096];
|
||||
|
||||
printf("test_engine_dispatch\n");
|
||||
|
||||
/* A renamed .zip must NOT be accepted as a RAR. dmc_unrar's archive_open
|
||||
will detect the missing RAR signature and return BAD_SIGNATURE / OPEN_FAIL,
|
||||
which our wrapper translates to ZIPX_ERR_FORMAT / ZIPX_ERR_OPEN. Either
|
||||
is acceptable — what matters is that the call returns cleanly without
|
||||
crashing and without writing files to disk. */
|
||||
work_path(dst, sizeof(dst), "dst1");
|
||||
remove_dir(dst);
|
||||
{
|
||||
zipx_result_t res;
|
||||
zipx_status_t st;
|
||||
char src[4096];
|
||||
|
||||
fixture_path(src, sizeof(src), "notar.rar");
|
||||
if(exists(src)) {
|
||||
memset(&res, 0, sizeof(res));
|
||||
st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, NULL, NULL, &res);
|
||||
check(st != ZIPX_OK, "notar.rar rejected");
|
||||
check(!exists(dst), " no files written for rejected archive");
|
||||
if(st == ZIPX_OK) {
|
||||
remove_dir(dst);
|
||||
}
|
||||
} else {
|
||||
printf(" SKIP notar.rar (missing)\n");
|
||||
}
|
||||
}
|
||||
|
||||
/* A truncated/random 1 KiB blob is also not a RAR. */
|
||||
work_path(dst, sizeof(dst), "dst2");
|
||||
remove_dir(dst);
|
||||
{
|
||||
char src[4096];
|
||||
fixture_path(src, sizeof(src), "junk.rar");
|
||||
if(exists(src)) {
|
||||
zipx_result_t res;
|
||||
zipx_status_t st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, NULL, NULL,
|
||||
&res);
|
||||
check(st != ZIPX_OK, "junk.rar rejected");
|
||||
check(!exists(dst), " no files written for junk archive");
|
||||
if(st == ZIPX_OK) {
|
||||
remove_dir(dst);
|
||||
}
|
||||
} else {
|
||||
printf(" SKIP junk.rar (missing)\n");
|
||||
}
|
||||
}
|
||||
|
||||
/* Missing source: must return a non-zero status without writing anything. */
|
||||
work_path(dst, sizeof(dst), "dst3");
|
||||
remove_dir(dst);
|
||||
{
|
||||
char src[4096];
|
||||
zipx_result_t res;
|
||||
zipx_status_t st;
|
||||
|
||||
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);
|
||||
check(st == ZIPX_ERR_OPEN, "missing source -> ZIPX_ERR_OPEN");
|
||||
check(!exists(dst), " no files written when source is missing");
|
||||
if(st == ZIPX_OK) {
|
||||
remove_dir(dst);
|
||||
}
|
||||
}
|
||||
|
||||
/* Null path / null destination guards. */
|
||||
{
|
||||
zipx_result_t res;
|
||||
check(rar_extract(NULL, "/tmp", ZIPX_CONFLICT_FAIL, NULL, NULL, 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 dst_dir -> ZIPX_ERR_INTERNAL");
|
||||
check(rar_extract("/tmp", "", ZIPX_CONFLICT_FAIL, NULL, NULL, NULL,
|
||||
NULL, &res) == ZIPX_ERR_INTERNAL,
|
||||
"empty dst_dir -> ZIPX_ERR_INTERNAL");
|
||||
}
|
||||
|
||||
/* dst_dir that is actually a file should be rejected. */
|
||||
{
|
||||
char src[4096];
|
||||
char dst[4096];
|
||||
zipx_result_t res;
|
||||
fixture_path(src, sizeof(src), "junk.rar");
|
||||
work_path(dst, sizeof(dst), "not_a_dir");
|
||||
/* ensure parent exists */
|
||||
{
|
||||
char parent[4096];
|
||||
snprintf(parent, sizeof(parent), "%s", dst);
|
||||
char *slash = strrchr(parent, '/');
|
||||
if(slash) {
|
||||
*slash = 0;
|
||||
}
|
||||
mkdir(parent, 0777);
|
||||
}
|
||||
write_bytes(dst, "i am a file", 11);
|
||||
if(exists(src)) {
|
||||
zipx_status_t st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, NULL, 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");
|
||||
}
|
||||
unlink(dst);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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). */
|
||||
}
|
||||
|
||||
static void
|
||||
test_limits_handoff(void) {
|
||||
/* Verify that rar_extract honours the ZIPX_LIMITS_LARGE profile by
|
||||
accepting the relaxed limits pointer without crashing. The actual
|
||||
cap is exercised via the engine's own counters (covered in the ZIP
|
||||
suite); for RAR we just need to know the profile switch is wired. */
|
||||
printf("test_limits_handoff\n");
|
||||
check(zipx_limits_profile(ZIPX_LIMITS_DEFAULT) != NULL,
|
||||
"ZIPX_LIMITS_DEFAULT pointer is non-NULL");
|
||||
check(zipx_limits_profile(ZIPX_LIMITS_LARGE) != NULL,
|
||||
"ZIPX_LIMITS_LARGE pointer is non-NULL");
|
||||
check(zipx_limits_profile(ZIPX_LIMITS_DEFAULT) !=
|
||||
zipx_limits_profile(ZIPX_LIMITS_LARGE),
|
||||
"default and large profiles are distinct");
|
||||
check(zipx_default_limits() ==
|
||||
zipx_limits_profile(ZIPX_LIMITS_DEFAULT),
|
||||
"default() matches ZIPX_LIMITS_DEFAULT");
|
||||
}
|
||||
|
||||
/* Real-archive happy paths (v1.9, unrar 7.20.1 engine).
|
||||
The fixtures are produced by tests/make-rar-fixtures.bat on a machine with
|
||||
WinRAR; when they are missing (plain CI checkout) these checks SKIP. */
|
||||
static void
|
||||
test_real_archives(void) {
|
||||
char dst[4096];
|
||||
char checkp[4096];
|
||||
|
||||
printf("test_real_archives\n");
|
||||
|
||||
/* RAR5 (WinRAR 6/7 "v6" compression) single volume. */
|
||||
work_path(dst, sizeof(dst), "rdst_v6");
|
||||
remove_dir(dst);
|
||||
{
|
||||
zipx_result_t res;
|
||||
zipx_status_t st;
|
||||
char src[4096];
|
||||
fixture_real_path(src, sizeof(src), "basic-v6.rar");
|
||||
if(exists(src)) {
|
||||
memset(&res, 0, sizeof(res));
|
||||
st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), 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);
|
||||
check(exists(checkp), " root.txt present");
|
||||
snprintf(checkp, sizeof(checkp), "%s/dir/nested.txt", dst);
|
||||
check(exists(checkp), " dir/nested.txt present");
|
||||
check(res.entries_done >= 3, " entries_done >= 3");
|
||||
} else {
|
||||
printf(" message=%s\n", res.message);
|
||||
}
|
||||
} else {
|
||||
printf(" SKIP basic-v6.rar (missing — run tests/make-rar-fixtures.bat)\n");
|
||||
}
|
||||
}
|
||||
remove_dir(dst);
|
||||
|
||||
/* RAR4 legacy single volume. */
|
||||
work_path(dst, sizeof(dst), "rdst_r4");
|
||||
remove_dir(dst);
|
||||
{
|
||||
zipx_result_t res;
|
||||
zipx_status_t st;
|
||||
char src[4096];
|
||||
fixture_real_path(src, sizeof(src), "basic-rar4.rar");
|
||||
if(exists(src)) {
|
||||
memset(&res, 0, sizeof(res));
|
||||
st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), 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);
|
||||
check(exists(checkp), " root.txt present");
|
||||
} else {
|
||||
printf(" message=%s\n", res.message);
|
||||
}
|
||||
} else {
|
||||
printf(" SKIP basic-rar4.rar (missing)\n");
|
||||
}
|
||||
}
|
||||
remove_dir(dst);
|
||||
|
||||
/* Multi-volume: opening vol.part1.rar must auto-merge part2 from the same
|
||||
directory (unrar drives the volume chain). */
|
||||
work_path(dst, sizeof(dst), "rdst_vol");
|
||||
remove_dir(dst);
|
||||
{
|
||||
zipx_result_t res;
|
||||
zipx_status_t st;
|
||||
char src[4096];
|
||||
fixture_real_path(src, sizeof(src), "vol.part1.rar");
|
||||
if(exists(src)) {
|
||||
memset(&res, 0, sizeof(res));
|
||||
st = rar_extract(src, dst, ZIPX_CONFLICT_FAIL,
|
||||
zipx_default_limits(), NULL, 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);
|
||||
check(exists(checkp), " root.txt present across volumes");
|
||||
snprintf(checkp, sizeof(checkp), "%s/big.bin", dst);
|
||||
check(exists(checkp), " big.bin (split file) present and whole");
|
||||
check(res.entries_done >= 2, " entries_done >= 2");
|
||||
} else {
|
||||
printf(" message=%s\n", res.message);
|
||||
}
|
||||
} else {
|
||||
printf(" SKIP vol.part1.rar (missing)\n");
|
||||
}
|
||||
}
|
||||
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. */
|
||||
work_path(dst, sizeof(dst), "rdst_enc");
|
||||
remove_dir(dst);
|
||||
{
|
||||
zipx_result_t res;
|
||||
zipx_status_t st;
|
||||
char src[4096];
|
||||
fixture_real_path(src, sizeof(src), "enc-v6.rar");
|
||||
if(exists(src)) {
|
||||
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");
|
||||
if(st == ZIPX_OK) {
|
||||
remove_dir(dst);
|
||||
}
|
||||
} else {
|
||||
printf(" SKIP enc-v6.rar (missing)\n");
|
||||
}
|
||||
}
|
||||
remove_dir(dst);
|
||||
}
|
||||
|
||||
int
|
||||
main(int argc, char **argv) {
|
||||
if(argc < 3) {
|
||||
fprintf(stderr, "usage: %s <fixtures-dir> <work-dir> [real-fixtures-dir]\n",
|
||||
argv[0]);
|
||||
return 2;
|
||||
}
|
||||
g_fixtures = argv[1];
|
||||
g_fixtures_real = (argc >= 4) ? argv[3] : argv[1];
|
||||
snprintf(g_work, sizeof(g_work), "%s", argv[2]);
|
||||
mkdir(g_work, 0777);
|
||||
|
||||
/* Generate the on-disk garbage fixtures we need for the error tests. */
|
||||
{
|
||||
char src[4096];
|
||||
fixture_path(src, sizeof(src), "notar.rar");
|
||||
/* Reuse basic.zip (fixture generator writes this anyway). */
|
||||
{
|
||||
char zip_src[4096];
|
||||
char buf[8192];
|
||||
FILE *in;
|
||||
FILE *out;
|
||||
size_t n;
|
||||
fixture_path(zip_src, sizeof(zip_src), "basic.zip");
|
||||
in = fopen(zip_src, "rb");
|
||||
out = fopen(src, "wb");
|
||||
if(in && out) {
|
||||
while((n = fread(buf, 1, sizeof(buf), in)) > 0) {
|
||||
fwrite(buf, 1, n, out);
|
||||
}
|
||||
}
|
||||
if(in) {
|
||||
fclose(in);
|
||||
}
|
||||
if(out) {
|
||||
fclose(out);
|
||||
}
|
||||
}
|
||||
fixture_path(src, sizeof(src), "junk.rar");
|
||||
write_junk(src, 1024);
|
||||
}
|
||||
|
||||
test_engine_dispatch();
|
||||
test_format_translation();
|
||||
test_limits_handoff();
|
||||
test_real_archives();
|
||||
|
||||
printf("\nrar_extract: %d checks, %d failures\n", g_checks, g_failures);
|
||||
return g_failures == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
/* Host test suite for the ZIP extraction engine.
|
||||
Build: see run-tests.sh (uses gcc + the MinGW POSIX shim). */
|
||||
|
||||
#include "../src/zip_extract.h"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Pulled in by the test build only (see run-tests.sh). */
|
||||
#include "posix_compat.h"
|
||||
|
||||
static const char *g_fixtures;
|
||||
static char g_work[4096];
|
||||
static int g_failures;
|
||||
static int g_checks;
|
||||
|
||||
static void
|
||||
fixture_path(char *out, size_t size, const char *name) {
|
||||
snprintf(out, size, "%s/%s", g_fixtures, name);
|
||||
}
|
||||
|
||||
static void
|
||||
work_path(char *out, size_t size, const char *name) {
|
||||
snprintf(out, size, "%s/%s", g_work, name);
|
||||
}
|
||||
|
||||
static void
|
||||
check(int ok, const char *what) {
|
||||
g_checks++;
|
||||
if(!ok) {
|
||||
g_failures++;
|
||||
printf(" FAIL %s\n", what);
|
||||
}
|
||||
}
|
||||
|
||||
static int
|
||||
exists(const char *path) {
|
||||
struct stat st;
|
||||
|
||||
return !stat(path, &st);
|
||||
}
|
||||
|
||||
static int
|
||||
read_text(const char *path, char *buf, size_t size) {
|
||||
FILE *f = fopen(path, "rb");
|
||||
size_t n;
|
||||
|
||||
if(!f) {
|
||||
return -1;
|
||||
}
|
||||
n = fread(buf, 1, size - 1, f);
|
||||
buf[n] = 0;
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
remove_dir(const char *path) {
|
||||
DIR *dir = opendir(path);
|
||||
struct dirent *ent;
|
||||
|
||||
if(!dir) {
|
||||
return rmdir(path);
|
||||
}
|
||||
while((ent = readdir(dir))) {
|
||||
char child[4096];
|
||||
struct stat st;
|
||||
|
||||
if(!strcmp(ent->d_name, ".") || !strcmp(ent->d_name, "..")) {
|
||||
continue;
|
||||
}
|
||||
snprintf(child, sizeof(child), "%s/%s", path, ent->d_name);
|
||||
if(!stat(child, &st) && S_ISDIR(st.st_mode)) {
|
||||
remove_dir(child);
|
||||
} else {
|
||||
unlink(child);
|
||||
}
|
||||
}
|
||||
closedir(dir);
|
||||
return rmdir(path);
|
||||
}
|
||||
|
||||
static int
|
||||
make_dirs(const char *path) {
|
||||
char buf[4096];
|
||||
char *slash;
|
||||
struct stat st;
|
||||
|
||||
if(!*path || !stat(path, &st)) {
|
||||
return 0;
|
||||
}
|
||||
snprintf(buf, sizeof(buf), "%s", path);
|
||||
for(slash = buf + 1; *slash; slash++) {
|
||||
if(*slash != '/') {
|
||||
continue;
|
||||
}
|
||||
*slash = 0;
|
||||
if(mkdir(buf, 0777) && errno != EEXIST) {
|
||||
return -1;
|
||||
}
|
||||
*slash = '/';
|
||||
}
|
||||
return mkdir(buf, 0777) && errno != EEXIST ? -1 : 0;
|
||||
}
|
||||
|
||||
static int
|
||||
write_text(const char *path, const char *content) {
|
||||
FILE *f = fopen(path, "wb");
|
||||
|
||||
if(!f) {
|
||||
return -1;
|
||||
}
|
||||
fputs(content, f);
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
count_staging_leftovers(const char *dir) {
|
||||
DIR *d = opendir(dir);
|
||||
struct dirent *ent;
|
||||
int count = 0;
|
||||
|
||||
if(!d) {
|
||||
return 0;
|
||||
}
|
||||
while((ent = readdir(d))) {
|
||||
if(!strncmp(ent->d_name, ".wfm-extract-", 13)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**************************************************************************/
|
||||
|
||||
typedef struct {
|
||||
int cancel_after_progress;
|
||||
zipx_progress_t last;
|
||||
int reports;
|
||||
char last_current[512];
|
||||
} test_ctx_t;
|
||||
|
||||
static int
|
||||
cb_cancel(void *userdata) {
|
||||
test_ctx_t *t = userdata;
|
||||
|
||||
return t->cancel_after_progress && t->reports >= t->cancel_after_progress;
|
||||
}
|
||||
|
||||
static void
|
||||
cb_progress(void *userdata, const zipx_progress_t *p) {
|
||||
test_ctx_t *t = userdata;
|
||||
|
||||
t->reports++;
|
||||
t->last = *p;
|
||||
if(p->current) {
|
||||
snprintf(t->last_current, sizeof(t->last_current), "%s", p->current);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
char zip[4096];
|
||||
char dst[4096];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
static void
|
||||
expect_ok(zipx_result_t *res, zipx_status_t status, const char *label) {
|
||||
check(status == ZIPX_OK, label);
|
||||
if(status != ZIPX_OK) {
|
||||
printf(" status=%s (%d) %s / %s\n", zipx_status_string(status),
|
||||
status, res->message, res->detail);
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
expect_status(zipx_result_t *res, zipx_status_t status,
|
||||
zipx_status_t expected, const char *label) {
|
||||
check(status == expected, label);
|
||||
if(status != expected) {
|
||||
printf(" expected %s, got %s (%d) %s / %s\n",
|
||||
zipx_status_string(expected), zipx_status_string(status), status,
|
||||
res->message, res->detail);
|
||||
}
|
||||
}
|
||||
|
||||
/**************************************************************************/
|
||||
|
||||
static void
|
||||
test_basic(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
char dst[4096];
|
||||
char file[4224];
|
||||
char buf[64];
|
||||
|
||||
printf("basic (deflate + nested dirs + empty dir)\n");
|
||||
expect_ok(&res, run("basic.zip", "out_basic", ZIPX_CONFLICT_FAIL, NULL, &t, &res),
|
||||
"extract succeeds");
|
||||
work_path(dst, sizeof(dst), "out_basic");
|
||||
check(exists(dst), "destination created");
|
||||
snprintf(file, sizeof(file), "%s/root.txt", dst);
|
||||
check(!read_text(file, buf, sizeof(buf)) && !strcmp(buf, "root content"),
|
||||
"root.txt content");
|
||||
snprintf(file, sizeof(file), "%s/dir/nested.txt", dst);
|
||||
check(!read_text(file, buf, sizeof(buf)) && !strcmp(buf, "nested content"),
|
||||
"nested.txt content");
|
||||
snprintf(file, sizeof(file), "%s/dir/deep/deeper.txt", dst);
|
||||
check(!read_text(file, buf, sizeof(buf)) && !strcmp(buf, "deeper content"),
|
||||
"deeper.txt content");
|
||||
snprintf(file, sizeof(file), "%s/empty_dir", dst);
|
||||
check(exists(file), "empty directory created");
|
||||
check(res.entries_total == 4, "entry count reported");
|
||||
check(res.bytes_total > 0, "byte total reported");
|
||||
check(count_staging_leftovers(dst) == 0, "no staging leftovers inside dst");
|
||||
}
|
||||
|
||||
static void
|
||||
test_stored(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
char dst[4096];
|
||||
char file[4224];
|
||||
char buf[2048];
|
||||
size_t i;
|
||||
int ok = 1;
|
||||
|
||||
printf("stored (no compression)\n");
|
||||
expect_ok(&res, run("stored.zip", "out_stored", ZIPX_CONFLICT_FAIL, NULL, &t, &res),
|
||||
"extract succeeds");
|
||||
work_path(dst, sizeof(dst), "out_stored");
|
||||
snprintf(file, sizeof(file), "%s/stored.txt", dst);
|
||||
if(read_text(file, buf, sizeof(buf))) {
|
||||
ok = 0;
|
||||
} else {
|
||||
for(i = 0; i < 1400; i++) {
|
||||
if(buf[i] != "stored content"[i % 14]) {
|
||||
ok = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
check(ok, "stored content matches");
|
||||
}
|
||||
|
||||
static void
|
||||
test_zip64(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
char dst[4096];
|
||||
char file[4224];
|
||||
struct stat st;
|
||||
|
||||
printf("zip64\n");
|
||||
expect_ok(&res, run("zip64.zip", "out_zip64", ZIPX_CONFLICT_FAIL, NULL, &t, &res),
|
||||
"extract succeeds");
|
||||
work_path(dst, sizeof(dst), "out_zip64");
|
||||
snprintf(file, sizeof(file), "%s/big.bin", dst);
|
||||
check(!stat(file, &st) && st.st_size == 4096, "zip64 size");
|
||||
}
|
||||
|
||||
static void
|
||||
test_unicode(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
char dst[4096];
|
||||
char file[4224];
|
||||
char buf[64];
|
||||
|
||||
printf("utf-8 entry names\n");
|
||||
expect_ok(&res, run("unicode.zip", "out_unicode", ZIPX_CONFLICT_FAIL, NULL,
|
||||
&t, &res), "extract succeeds");
|
||||
work_path(dst, sizeof(dst), "out_unicode");
|
||||
snprintf(file, sizeof(file), "%s/\xe4\xb8\xad\xe6\x96\x87\xe7\x9b\xae\xe5\xbd\x95/\xe6\x96\x87\xe4\xbb\xb6.txt", dst);
|
||||
check(!read_text(file, buf, sizeof(buf)) &&
|
||||
!strcmp(buf, "unicode content"), "chinese path content");
|
||||
}
|
||||
|
||||
static void
|
||||
test_conflict_fail(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
char dst[4096];
|
||||
char file[4224];
|
||||
char buf[64];
|
||||
|
||||
printf("conflict policy: fail\n");
|
||||
work_path(dst, sizeof(dst), "out_conflict_fail");
|
||||
remove_dir(dst);
|
||||
make_dirs(dst);
|
||||
snprintf(file, sizeof(file), "%s/shared.txt", dst);
|
||||
write_text(file, "original");
|
||||
expect_status(&res, run("conflict.zip", "out_conflict_fail",
|
||||
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
|
||||
ZIPX_ERR_CONFLICT, "existing file fails the task");
|
||||
snprintf(file, sizeof(file), "%s/shared.txt", dst);
|
||||
check(!read_text(file, buf, sizeof(buf)) && !strcmp(buf, "original"),
|
||||
"existing file untouched");
|
||||
snprintf(file, sizeof(file), "%s/shareddir", dst);
|
||||
check(!exists(file), "nothing published");
|
||||
}
|
||||
|
||||
static void
|
||||
test_conflict_overwrite(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
char dst[4096];
|
||||
char file[4224];
|
||||
char buf[64];
|
||||
|
||||
printf("conflict policy: overwrite\n");
|
||||
work_path(dst, sizeof(dst), "out_overwrite");
|
||||
remove_dir(dst);
|
||||
make_dirs(dst);
|
||||
/* An existing directory where the archive has a file must still conflict,
|
||||
even under OVERWRITE (a directory is never replaced by a file). */
|
||||
snprintf(file, sizeof(file), "%s/shared.txt", dst);
|
||||
make_dirs(file);
|
||||
expect_status(&res, run("conflict.zip", "out_overwrite",
|
||||
ZIPX_CONFLICT_OVERWRITE, NULL, &t, &res),
|
||||
ZIPX_ERR_CONFLICT, "existing directory still blocks overwrite");
|
||||
snprintf(file, sizeof(file), "%s/shared.txt", dst);
|
||||
check(exists(file), "directory untouched while it conflicts");
|
||||
|
||||
remove_dir(dst);
|
||||
make_dirs(dst);
|
||||
snprintf(file, sizeof(file), "%s/shared.txt", dst);
|
||||
write_text(file, "original");
|
||||
snprintf(file, sizeof(file), "%s/shareddir", dst);
|
||||
remove_dir(file);
|
||||
expect_ok(&res, run("conflict.zip", "out_overwrite",
|
||||
ZIPX_CONFLICT_OVERWRITE, NULL, &t, &res),
|
||||
"overwrite succeeds without a directory clash");
|
||||
snprintf(file, sizeof(file), "%s/shared.txt", dst);
|
||||
check(!read_text(file, buf, sizeof(buf)) && !strcmp(buf, "from zip"),
|
||||
"file replaced");
|
||||
}
|
||||
|
||||
static void
|
||||
test_conflict_merge(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
char dst[4096];
|
||||
char file[4224];
|
||||
char buf[64];
|
||||
|
||||
printf("conflict policy: merge\n");
|
||||
work_path(dst, sizeof(dst), "out_merge");
|
||||
remove_dir(dst);
|
||||
make_dirs(dst);
|
||||
snprintf(file, sizeof(file), "%s/shareddir", dst);
|
||||
make_dirs(file);
|
||||
snprintf(file, sizeof(file), "%s/shareddir/inner.txt", dst);
|
||||
check(!write_text(file, "original inner"), "prepare merge destination");
|
||||
expect_ok(&res, run("conflict.zip", "out_merge", ZIPX_CONFLICT_MERGE, NULL,
|
||||
&t, &res), "merge succeeds");
|
||||
snprintf(file, sizeof(file), "%s/shareddir/inner.txt", dst);
|
||||
check(!read_text(file, buf, sizeof(buf)) && !strcmp(buf, "original inner"),
|
||||
"existing file preserved by merge");
|
||||
snprintf(file, sizeof(file), "%s/shareddir/added.txt", dst);
|
||||
check(!read_text(file, buf, sizeof(buf)) &&
|
||||
!strcmp(buf, "added from zip"), "new sibling merged in");
|
||||
snprintf(file, sizeof(file), "%s/shared.txt", dst);
|
||||
check(!read_text(file, buf, sizeof(buf)) && !strcmp(buf, "from zip"),
|
||||
"top level file published");
|
||||
}
|
||||
|
||||
static void
|
||||
test_unsafe_names(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
char parent[4096];
|
||||
char file[4224];
|
||||
|
||||
printf("path traversal variants\n");
|
||||
expect_status(&res, run("traversal.zip", "out_traversal",
|
||||
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
|
||||
ZIPX_ERR_UNSAFE_NAME, "../ entry rejected");
|
||||
expect_status(&res, run("traversal_bs.zip", "out_traversal_bs",
|
||||
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
|
||||
ZIPX_ERR_UNSAFE_NAME, "..\\ entry rejected");
|
||||
expect_status(&res, run("absolute.zip", "out_absolute",
|
||||
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
|
||||
ZIPX_ERR_UNSAFE_NAME, "absolute entry rejected");
|
||||
expect_status(&res, run("drive.zip", "out_drive", ZIPX_CONFLICT_FAIL, NULL,
|
||||
&t, &res),
|
||||
ZIPX_ERR_UNSAFE_NAME, "drive letter entry rejected");
|
||||
|
||||
work_path(parent, sizeof(parent), ".");
|
||||
snprintf(file, sizeof(file), "%s/evil.txt", parent);
|
||||
check(!exists(file), "no file escaped into the work directory");
|
||||
snprintf(file, sizeof(file), "%s/out_traversal", parent);
|
||||
check(!exists(file), "no destination created for a rejected archive");
|
||||
}
|
||||
|
||||
static void
|
||||
test_duplicates(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
|
||||
printf("duplicate and clashing entries\n");
|
||||
expect_status(&res, run("duplicate.zip", "out_duplicate",
|
||||
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
|
||||
ZIPX_ERR_DUPLICATE, "duplicate file rejected");
|
||||
expect_status(&res, run("clash.zip", "out_clash", ZIPX_CONFLICT_FAIL, NULL,
|
||||
&t, &res),
|
||||
ZIPX_ERR_DUPLICATE, "file used as a directory rejected");
|
||||
}
|
||||
|
||||
static void
|
||||
test_special_entries(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
|
||||
printf("symlink and fifo entries\n");
|
||||
expect_status(&res, run("symlink.zip", "out_symlink", ZIPX_CONFLICT_FAIL,
|
||||
NULL, &t, &res),
|
||||
ZIPX_ERR_SPECIAL, "symlink rejected");
|
||||
expect_status(&res, run("fifo.zip", "out_fifo", ZIPX_CONFLICT_FAIL, NULL,
|
||||
&t, &res),
|
||||
ZIPX_ERR_SPECIAL, "fifo rejected");
|
||||
}
|
||||
|
||||
static void
|
||||
test_unsupported(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
const zipx_limits_t *base = zipx_default_limits();
|
||||
zipx_limits_t limits;
|
||||
|
||||
printf("encrypted, corrupt and truncated archives\n");
|
||||
expect_status(&res, run("encrypted.zip", "out_encrypted",
|
||||
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
|
||||
ZIPX_ERR_UNSUPPORTED, "encrypted entry rejected");
|
||||
expect_status(&res, run("bad_crc.zip", "out_bad_crc", ZIPX_CONFLICT_FAIL,
|
||||
NULL, &t, &res),
|
||||
ZIPX_ERR_CRC, "crc mismatch detected");
|
||||
check(run("truncated.zip", "out_truncated", ZIPX_CONFLICT_FAIL, NULL, &t, &res) !=
|
||||
ZIPX_OK, "truncated archive rejected");
|
||||
check(run("notazip.zip", "out_notazip", ZIPX_CONFLICT_FAIL, NULL, &t, &res) !=
|
||||
ZIPX_OK, "non-zip file rejected");
|
||||
|
||||
printf("limits\n");
|
||||
limits = *base;
|
||||
limits.max_entries = 2;
|
||||
expect_status(&res, run("many.zip", "out_limit_entries",
|
||||
ZIPX_CONFLICT_FAIL, &limits, &t, &res),
|
||||
ZIPX_ERR_LIMIT_ENTRIES, "entry limit enforced");
|
||||
|
||||
limits = *base;
|
||||
limits.max_ratio = 10;
|
||||
expect_status(&res, run("bomb.zip", "out_limit_ratio", ZIPX_CONFLICT_FAIL,
|
||||
&limits, &t, &res),
|
||||
ZIPX_ERR_LIMIT_RATIO, "compression ratio limit enforced");
|
||||
|
||||
limits = *base;
|
||||
limits.max_file_bytes = 1024;
|
||||
expect_status(&res, run("zip64.zip", "out_limit_file", ZIPX_CONFLICT_FAIL,
|
||||
&limits, &t, &res),
|
||||
ZIPX_ERR_LIMIT_FILE, "single file limit enforced");
|
||||
|
||||
limits = *base;
|
||||
limits.max_total_bytes = 1000; /* many.zip totals ~1390 bytes */
|
||||
expect_status(&res, run("many.zip", "out_limit_total", ZIPX_CONFLICT_FAIL,
|
||||
&limits, &t, &res),
|
||||
ZIPX_ERR_LIMIT_TOTAL, "total size limit enforced");
|
||||
|
||||
limits = *base;
|
||||
limits.max_depth = 1;
|
||||
expect_status(&res, run("basic.zip", "out_limit_depth", ZIPX_CONFLICT_FAIL,
|
||||
&limits, &t, &res),
|
||||
ZIPX_ERR_LIMIT_DEPTH, "depth limit enforced");
|
||||
}
|
||||
|
||||
static void
|
||||
test_cancel(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t;
|
||||
char dst[4096];
|
||||
|
||||
printf("cancel leaves nothing behind\n");
|
||||
memset(&t, 0, sizeof(t));
|
||||
t.cancel_after_progress = 1;
|
||||
expect_status(&res, run("many.zip", "out_cancel", ZIPX_CONFLICT_FAIL, NULL,
|
||||
&t, &res),
|
||||
ZIPX_ERR_CANCELED, "cancel honored");
|
||||
work_path(dst, sizeof(dst), "out_cancel");
|
||||
check(!exists(dst), "no destination after cancel");
|
||||
work_path(dst, sizeof(dst), ".");
|
||||
check(count_staging_leftovers(dst) == 0, "no staging left after cancel");
|
||||
}
|
||||
|
||||
static void
|
||||
test_many_files(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
char dst[4096];
|
||||
char file[4224];
|
||||
int missing = 0;
|
||||
int i;
|
||||
|
||||
printf("500 file archive\n");
|
||||
expect_ok(&res, run("many.zip", "out_many", ZIPX_CONFLICT_FAIL, NULL, &t, &res),
|
||||
"extract succeeds");
|
||||
work_path(dst, sizeof(dst), "out_many");
|
||||
for(i = 0; i < 500; i++) {
|
||||
snprintf(file, sizeof(file), "%s/many/f%03d.txt", dst, i);
|
||||
if(!exists(file)) {
|
||||
missing++;
|
||||
}
|
||||
}
|
||||
check(!missing, "all 500 files present");
|
||||
check(res.entries_total == 500, "entry count reported");
|
||||
check(res.bytes_total > 0, "byte total reported");
|
||||
}
|
||||
|
||||
static void
|
||||
test_large_profile(void) {
|
||||
zipx_result_t res;
|
||||
test_ctx_t t = {0};
|
||||
const zipx_limits_t *base = zipx_default_limits();
|
||||
const zipx_limits_t *large = zipx_limits_profile(ZIPX_LIMITS_LARGE);
|
||||
zipx_limits_t tight;
|
||||
|
||||
printf("large-file profile\n");
|
||||
|
||||
/* The profile must exist and be a real struct, distinct from default. */
|
||||
check(large != NULL, "large profile returned");
|
||||
check(large != base, "large profile differs from default");
|
||||
check(zipx_limits_profile(ZIPX_LIMITS_DEFAULT) == base,
|
||||
"ZIPX_LIMITS_DEFAULT == zipx_default_limits()");
|
||||
|
||||
/* Every cap in the large profile must be at least as large as the default
|
||||
cap. The profile is strictly an upper bound, never a tighter one. */
|
||||
check(large->max_entries > base->max_entries,
|
||||
"max_entries greater than default");
|
||||
check(large->max_total_bytes > base->max_total_bytes,
|
||||
"max_total_bytes greater than default");
|
||||
check(large->max_file_bytes > base->max_file_bytes,
|
||||
"max_file_bytes greater than default");
|
||||
check(large->max_ratio > base->max_ratio,
|
||||
"max_ratio greater than default");
|
||||
|
||||
/* Concrete advertised numbers. If anyone ever changes the profile these
|
||||
assertions keep the public promise honest. */
|
||||
check(large->max_entries == 500000, "max_entries == 500000");
|
||||
check(large->max_file_bytes == 1ULL * 1024 * 1024 * 1024 * 1024,
|
||||
"max_file_bytes == 1 TiB");
|
||||
check(large->max_total_bytes == 4ULL * 1024 * 1024 * 1024 * 1024,
|
||||
"max_total_bytes == 4 TiB");
|
||||
check(large->max_ratio == 1000, "max_ratio == 1000");
|
||||
|
||||
/* Behaviour: medium_bomb.zip is 1 MiB of 0..255 cycled, compressing to
|
||||
~4 KiB (ratio ~238). Default ratio cap 500 accepts it; large ratio
|
||||
cap 1000 also accepts it. This shows that real-world high-ratio
|
||||
archives (think raw image dumps, fat binaries) are not artificially
|
||||
blocked by the relaxed default. */
|
||||
printf("ratio cap\n");
|
||||
expect_ok(&res, run("medium_bomb.zip", "out_ratio_medium_default",
|
||||
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
|
||||
"default ratio cap (500) accepts medium_bomb.zip (~238:1)");
|
||||
expect_ok(&res, run("medium_bomb.zip", "out_ratio_medium_large",
|
||||
ZIPX_CONFLICT_FAIL, large, &t, &res),
|
||||
"large ratio cap (1000) accepts medium_bomb.zip (~238:1)");
|
||||
|
||||
/* bomb.zip is 4 MiB of identical 'A' bytes, compressing to ~4 KiB
|
||||
(ratio ~1026). Both default cap 500 and large cap 1000 reject it.
|
||||
A bomb is a bomb regardless of which profile you opt into. */
|
||||
expect_status(&res, run("bomb.zip", "out_ratio_bomb_default",
|
||||
ZIPX_CONFLICT_FAIL, NULL, &t, &res),
|
||||
ZIPX_ERR_LIMIT_RATIO,
|
||||
"default ratio cap (500) rejects bomb.zip (~1026:1)");
|
||||
expect_status(&res, run("bomb.zip", "out_ratio_bomb_large",
|
||||
ZIPX_CONFLICT_FAIL, large, &t, &res),
|
||||
ZIPX_ERR_LIMIT_RATIO,
|
||||
"large ratio cap (1000) rejects bomb.zip (~1026:1)");
|
||||
|
||||
/* Lowering the user's chosen ratio below the medium bomb's actual
|
||||
ratio still rejects the archive. The caps are still enforced; the
|
||||
profile just starts at a higher number. */
|
||||
tight = *large;
|
||||
tight.max_ratio = 200;
|
||||
expect_status(&res, run("medium_bomb.zip", "out_ratio_medium_tight",
|
||||
ZIPX_CONFLICT_FAIL, &tight, &t, &res),
|
||||
ZIPX_ERR_LIMIT_RATIO,
|
||||
"user-lowered ratio (200) rejects medium_bomb.zip (~238:1)");
|
||||
|
||||
/* Lowering the large profile's file cap below zip64.zip's 4 KiB still
|
||||
rejects the archive. */
|
||||
tight = *large;
|
||||
tight.max_file_bytes = 1024;
|
||||
expect_status(&res, run("zip64.zip", "out_large_file_cap",
|
||||
ZIPX_CONFLICT_FAIL, &tight, &t, &res),
|
||||
ZIPX_ERR_LIMIT_FILE,
|
||||
"lowered large file cap still enforced");
|
||||
}
|
||||
|
||||
int
|
||||
main(int argc, char **argv) {
|
||||
if(argc < 3) {
|
||||
fprintf(stderr, "usage: %s <fixtures-dir> <work-dir>\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
g_fixtures = argv[1];
|
||||
snprintf(g_work, sizeof(g_work), "%s", argv[2]);
|
||||
remove_dir(g_work);
|
||||
if(make_dirs(g_work)) {
|
||||
fprintf(stderr, "cannot create work dir\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
test_basic();
|
||||
test_stored();
|
||||
test_zip64();
|
||||
test_unicode();
|
||||
test_conflict_fail();
|
||||
test_conflict_overwrite();
|
||||
test_conflict_merge();
|
||||
test_unsafe_names();
|
||||
test_duplicates();
|
||||
test_special_entries();
|
||||
test_unsupported();
|
||||
test_cancel();
|
||||
test_many_files();
|
||||
test_large_profile();
|
||||
|
||||
printf("\n%d checks, %d failures\n", g_checks, g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
Condition of use and distribution are the same as zlib:
|
||||
|
||||
This software is provided 'as-is', without any express or implied
|
||||
warranty. In no event will the authors be held liable for any damages
|
||||
arising from the use of this software.
|
||||
|
||||
Permission is granted to anyone to use this software for any purpose,
|
||||
including commercial applications, and to alter it and redistribute it
|
||||
freely, subject to the following restrictions:
|
||||
|
||||
1. The origin of this software must not be misrepresented; you must not
|
||||
claim that you wrote the original software. If you use this software
|
||||
in a product, an acknowledgement in the product documentation would be
|
||||
appreciated but is not required.
|
||||
2. Altered source versions must be plainly marked as such, and must not be
|
||||
misrepresented as being the original software.
|
||||
3. This notice may not be removed or altered from any source distribution.
|
||||
@@ -0,0 +1 @@
|
||||
minizip-ng 4.2.2 (https://github.com/zlib-ng/minizip-ng)
|
||||