console files: decompress progress, case-only rename, unpack name hardening

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
This commit is contained in:
phantomptrandClaude Opus 5.5 committed 2026-10-17 13:48:00 -07:00
1 parent c12787d7e0
commit 1f16fb97fc
16 files changed
+322 -38

No files matched your search

+37 -4
View File
@@ -256,7 +256,14 @@ export function busyText(b: Busy | null): string | null {
if (b.op === "move") return t("cw_files_batch_move", { at, total: b.total });
if (b.op === "chmod") return t("cw_files_fixperm_batch", { at, total: b.total });
if (b.op === "rename") return t("cw_files_br_batch", { at, total: b.total });
if (b.op === "unpack") return t("cw_files_unpack_batch", { name: baseName(b.paths?.[0] ?? "") });
if (b.op === "unpack") {
const line = t("cw_files_unpack_batch", { name: baseName(b.paths?.[0] ?? "") });
const total = b.bytesTotal ?? 0;
if (total <= 0) return line;
const done = Math.min(b.bytesDone ?? 0, total);
const pct = Math.min(100, Math.floor((done / total) * 100));
return `${line} · ${t("cw_home_percent", { n: pct })} · ${formatBytes(done)} / ${formatBytes(total)}`;
}
return t("cw_files_batch_delete", { at, total: b.total });
}
@@ -287,6 +294,32 @@ export function renameItems(names: string[], shown: Entry[], entries: Entry[]):
return ordered.map((name) => ({ name, dir: kinds.get(name) === "dir" }));
}
/*
* askView is a plain call (no hooks), so the bulk rename dialog's inputs are
* kept by their last arguments: the same names/shown/entries arrays give the
* same items and names arrays, and the dialog's plan useMemo is not redone on
* every Files render (a job poll, the batch line).
*/
let lastItems: { names: string[]; shown: Entry[]; entries: Entry[]; out: RenameItem[] } | null = null;
let lastNames: { entries: Entry[]; out: string[] } | null = null;
/** renameItems, the same array again for the same arguments. */
export function renameItemsKept(names: string[], shown: Entry[], entries: Entry[]): RenameItem[] {
const c = lastItems;
if (c && c.names === names && c.shown === shown && c.entries === entries) return c.out;
const out = renameItems(names, shown, entries);
lastItems = { names, shown, entries, out };
return out;
}
/** The folder's names, the same array again for the same entries. */
export function entryNamesKept(entries: Entry[]): string[] {
if (lastNames && lastNames.entries === entries) return lastNames.out;
const out = entries.map((e) => e.name);
lastNames = { entries, out };
return out;
}
/** The line a finished bulk rename leaves. */
export function renameMessage(r: RenameResult): Message | null {
if (r.failedRow) {
@@ -529,8 +562,8 @@ function askView(p: FilesViewProps) {
case "bulkrename":
return (
<BulkRenameDialog
items={renameItems(a.names, p.shown ?? p.dir.entries, p.dir.entries)}
existing={p.dir.entries.map((e) => e.name)}
items={renameItemsKept(a.names, p.shown ?? p.dir.entries, p.dir.entries)}
existing={entryNamesKept(p.dir.entries)}
onRename={(plan) => p.onBulkRename?.(plan.rows)}
onCancel={p.onDismiss}
/>
@@ -1251,7 +1284,7 @@ export function Files({ ctx }: { ctx: SectionContext }) {
try {
const job = await startOp("/api/fs/unpack", outName ? { path: src, name: outName } : { path: src });
r.started.push(job);
end = await watchOp(job, { stopped: ctl.stopped });
end = await watchOp(job, { stopped: ctl.stopped, onStatus: (s) => ctl.onBytes(s.bytes_done, s.bytes_total) });
} catch (error) {
end = { end: "error", error };
}
+8 -1
View File
@@ -3,7 +3,9 @@ import { Calculator, Square } from "lucide-react";
import { formatBytes } from "../../lib/format";
import { call } from "../api";
import { getLanguage, t } from "../strings";
import { topModal } from "../nav";
import { Modal } from "../ui/Modal";
import { refocusIfLost } from "../ui/restoreFocus";
import { startFolderSize, SIZE_MAX_ENTRIES, SIZE_MAX_MS, type SizeCount, type SizeResult } from "./folderSize";
import { LIST_PAGE } from "./useDir";
@@ -56,6 +58,11 @@ function FolderSize({ path }: { path: string }) {
},
[],
);
// Calculate turns into Cancel and back: focus follows instead of falling out of the dialog.
const phase = walk?.state ?? null;
useEffect(() => {
refocusIfLost(topModal(), phase === "running" ? ['[data-action="size-cancel"]'] : ['[data-action="size-start"]']);
}, [phase]);
const start = () => {
const zero: SizeCount = { files: 0, folders: 0, bytes: 0, entries: 0, unreadable: 0 };
const w = startFolderSize(path, (count) => setWalk({ state: "running", count }));
@@ -103,7 +110,7 @@ function FolderSize({ path }: { path: string }) {
</dl>
) : null}
{note ? <p className={walk?.state === "ended" && walk.result.end === "error" ? "cw-field-error" : "cw-dim"}>{note}</p> : null}
{walk?.state === "held" ? <p className="cw-field-error">{t("cw_files_size_busy")}</p> : null}
{walk?.state === "held" ? <p className="cw-dim">{t("cw_files_size_busy")}</p> : null}
<button type="button" className="cw-focusable cw-button cw-size-go" data-action="size-start" onClick={start}>
<Calculator aria-hidden />
<span>{t(walk?.state === "ended" ? "cw_files_size_again" : "cw_files_size_start")}</span>
@@ -2,7 +2,9 @@ import { useEffect, useState } from "react";
import { formatBytes } from "../../lib/format";
import { ApiError } from "../api";
import { t } from "../strings";
import { topModal } from "../nav";
import { Modal } from "../ui/Modal";
import { refocusIfLost } from "../ui/restoreFocus";
import { filesErrorText } from "./Files";
import { freeName, statOrNull } from "./paste";
import { unpackBlocker, unpackInfo, type UnpackInfo } from "./opJob";
@@ -89,6 +91,11 @@ export function UnpackDialog({
const info = state && "info" in state ? state.info : null;
const blocker = info ? unpackBlocker(info, outName) : null;
const canStart = !busy && !naming && (info ? blocker == null : !!state && "generic" in state && state.generic);
// Keep both removes itself once a free name is found: focus moves on to Decompress (else Cancel).
const keepShown = blocker === "exists";
useEffect(() => {
if (!keepShown) refocusIfLost(topModal(), ['[data-action="unpack-go"]', "[data-nav-default]"]);
}, [keepShown]);
const keepBoth = () => {
if (!info) return;
setNaming(true);
+5 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { formatBytes } from "../../lib/format";
import { ApiError } from "../api";
import { t } from "../strings";
@@ -102,18 +102,18 @@ export function VerifyDialog({
initial?: VerifyState;
}) {
const [state, setState] = useState<VerifyState>(initial ?? { step: "hash", done: 0, total: 0 });
const stop = useRef(false);
const preset = initial != null;
useEffect(() => {
if (preset) return;
let live = true;
stop.current = false;
// This run's own stop flag: a re-run (StrictMode, a new path) never un-stops an earlier one.
let stopped = false;
runVerify(
path,
(s) => {
if (live) setState(s);
},
() => stop.current,
() => stopped,
).then(
(s) => {
if (live) setState(s);
@@ -125,7 +125,7 @@ export function VerifyDialog({
return () => {
live = false;
// Closed while a job runs: stop it (the helper drops it within a block).
stop.current = true;
stopped = true;
};
}, [path, preset]);
+14
View File
@@ -88,6 +88,20 @@ describe("the page's one batch", () => {
expect(currentBatch()).toBeNull();
});
it("carries the running item's bytes (unpack's percent)", async () => {
let release: () => void = () => undefined;
const gate = new Promise<void>((res) => (release = res));
const run = startBatch("unpack", 1, async (ctl) => {
ctl.onBytes(300, 1200);
await gate;
return empty();
});
expect(currentBatch()).toMatchObject({ bytesDone: 300, bytesTotal: 1200 });
release();
await run;
expect(currentBatch()).toBeNull();
});
it("keeps what it touches while it runs (eject reads it)", async () => {
let release: () => void = () => undefined;
const gate = new Promise<void>((res) => (release = res));
+11
View File
@@ -27,6 +27,9 @@ export interface BatchProgress {
stopping: boolean;
/** What it reads and writes: its items and (copy/move) the destination folder. */
paths?: string[];
/** Bytes written so far of `bytesTotal` (a one-job batch such as unpack reports them). */
bytesDone?: number;
bytesTotal?: number;
}
/** How often a running job is checked. */
@@ -110,6 +113,8 @@ export function stopBatch(): void {
export interface BatchControl {
onProgress: (at: number, total: number) => void;
/** Bytes done of total for the running item (shown with a percentage). */
onBytes: (done: number, total: number) => void;
stopped: () => boolean;
}
@@ -133,6 +138,12 @@ export function startBatch<R extends BatchResult = BatchResult>(
progress = { ...progress, at, total: n };
emit();
},
onBytes: (done, total) => {
if (!progress) return;
if (progress.bytesDone === done && progress.bytesTotal === total) return;
progress = { ...progress, bytesDone: done, bytesTotal: total };
emit();
},
stopped: () => stopAsked,
};
let run: Promise<R>;
+19 -1
View File
@@ -12,7 +12,7 @@ import type { Volume } from "../home/Home";
import { t } from "../strings";
import { ActionsSheet } from "./ActionsSheet";
import { BulkRenameDialog, EMPTY_FIELDS } from "./BulkRenameDialog";
import { busyText, FilesView, renameItems, renameMessage, sheetActions, unpackMessage, type FilesViewProps } from "./Files";
import { busyText, entryNamesKept, FilesView, renameItems, renameItemsKept, renameMessage, sheetActions, unpackMessage, type FilesViewProps } from "./Files";
import { InfoDialog, countText, walkNote } from "./InfoDialog";
import { UnpackDialog } from "./UnpackDialog";
import { VerifyDialog } from "./VerifyDialog";
@@ -261,12 +261,30 @@ describe("decompress", () => {
expect(onUnpack).toHaveBeenCalledWith("game.FFPFSC", "game (2).exfat");
});
it("bulk rename inputs keep their identity across renders (the plan memo hits)", () => {
const entries = [{ name: "a.png", kind: "file" }, { name: "b.png", kind: "file" }] as unknown as Parameters<typeof entryNamesKept>[0];
const names = ["a.png"];
expect(renameItemsKept(names, entries, entries)).toBe(renameItemsKept(names, entries, entries));
expect(entryNamesKept(entries)).toBe(entryNamesKept(entries));
expect(entryNamesKept([...entries])).not.toBe(entryNamesKept(entries));
const p = props({ ask: { kind: "bulkrename", names } });
const one = find(FilesView(p), BulkRenameDialog);
const two = find(FilesView(p), BulkRenameDialog);
expect(two.props.items).toBe(one.props.items);
expect(two.props.existing).toBe(one.props.existing);
});
it("its batch line has Stop; done and failed lines", () => {
const html = renderToStaticMarkup(
<FilesView {...props({ busy: { op: "unpack", at: 1, total: 1, stopping: false, paths: ["/data/x/game.FFPFSC", "/data/x"] }, onStopBatch: vi.fn() })} />,
);
expect(html).toContain(t("cw_files_unpack_batch", { name: "game.FFPFSC" }));
expect(html).toContain('data-action="stop-batch"');
// Bytes reported by the job: percent + bytes, like Verify.
const mid = busyText({ op: "unpack", at: 1, total: 1, stopping: false, paths: ["/data/x/game.FFPFSC"], bytesDone: 512, bytesTotal: 2048 });
expect(mid).toContain(t("cw_home_percent", { n: 25 }));
expect(mid).toContain(" / ");
expect(busyText({ op: "unpack", at: 1, total: 1, stopping: false, paths: ["/data/x/game.FFPFSC"] })).toBe(t("cw_files_unpack_batch", { name: "game.FFPFSC" }));
const status = { job: "j", state: "done" as const, files_done: 1, files_total: 1, bytes_done: 1, bytes_total: 1, message: null, result: null, code: null };
expect(unpackMessage({ end: "done", status: { ...status, result: '{"path":"/data/x/game.exfat","size":1}' } }, "game.FFPFSC")?.text).toBe(
t("cw_files_unpack_done", { name: "game.exfat", source: "game.FFPFSC" }),
+45 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { anchorOf, restoreFocus } from "./restoreFocus";
import { anchorOf, refocusIfLost, restoreFocus } from "./restoreFocus";
/*
* No DOM in vitest here: a fake with what restoreFocus touches (closest,
@@ -33,6 +33,8 @@ class El {
if (sel === "[data-nav-item]") return "data-nav-item" in this.attrs;
if (sel === ".cw-content") return this.attrs.class === "cw-content";
if (sel === "[data-nav-modal]") return "data-nav-modal" in this.attrs;
const act = /^\[data-action="([^"]+)"\]$/.exec(sel);
if (act) return this.attrs["data-action"] === act[1];
return this.tagName === "BUTTON"; // the focusable selector
}
closest(sel: string): El | null {
@@ -114,3 +116,45 @@ describe("ConfirmDialog focus restore", () => {
expect(restoreFocus(anchorOf(null))).toBeNull();
});
});
describe("refocusIfLost (a dialog button that removed itself)", () => {
let modal: El;
let close: El;
beforeEach(() => {
close = new El("close", {}, "BUTTON");
modal = new El("modal", { "data-nav-modal": "" });
root.add(modal);
});
it("Calculate became Cancel: focus goes to Cancel, not the dialog's first control", () => {
const calc = new El("calc", { "data-action": "size-start" }, "BUTTON");
modal.add(close, calc);
calc.focus();
calc.remove();
const cancel = new El("cancel", { "data-action": "size-cancel" }, "BUTTON");
modal.add(cancel);
expect(refocusIfLost(modal as unknown as ParentNode, ['[data-action="size-cancel"]', '[data-action="size-start"]'])).toBe(cancel);
expect(doc.activeElement).toBe(cancel);
});
it("Keep both gone: the next preferred control, else the first one", () => {
const keep = new El("keep", { "data-action": "unpack-keep" }, "BUTTON");
const go = new El("go", { "data-action": "unpack-go" }, "BUTTON");
modal.add(close, keep, go);
keep.focus();
keep.remove();
refocusIfLost(modal as unknown as ParentNode, ['[data-action="unpack-go"]']);
expect(doc.activeElement).toBe(go);
go.remove();
refocusIfLost(modal as unknown as ParentNode, ['[data-action="unpack-go"]']);
expect(doc.activeElement).toBe(close);
});
it("focus still on a live control: left alone", () => {
const go = new El("go", { "data-action": "unpack-go" }, "BUTTON");
modal.add(close, go);
close.focus();
expect(refocusIfLost(modal as unknown as ParentNode, ['[data-action="unpack-go"]'])).toBeNull();
expect(doc.activeElement).toBe(close);
});
});
+20
View File
@@ -51,6 +51,26 @@ export function focusLost(): boolean {
return !active || active === document.body || !active.isConnected;
}
/**
* The focused control removed itself (Calculate became Cancel, Keep both went
* away once a free name was found): focus the first of `prefer` found inside
* `within` (each a selector, in order), else its first control. Nothing happens
* while focus is still on a live element.
*/
export function refocusIfLost(within: ParentNode | null, prefer: string[]): HTMLElement | null {
if (!within || !focusLost()) return null;
for (const sel of prefer) {
const el = within.querySelector<HTMLElement>(sel);
if (el && el.matches(FOCUSABLE)) {
focusElement(el);
return el;
}
}
const any = within.querySelector<HTMLElement>(FOCUSABLE);
if (any) focusElement(any);
return any;
}
/**
* A modal's focus: on open, remember where focus was and move it to `first`
* (a control, or a container: its [data-nav-default], else its first control);
+1
View File
@@ -57,6 +57,7 @@ void fsj_register_ops(void);
* the string at top-level key `key`, unescaped (1 found, 0 absent or too long), and the
* unsigned integer or boolean at `key` (1 found; `true` is 1). */
int fsj_json_str(const char *json, const char *key, char *out, size_t cap);
int fsj_json_present(const char *json, const char *key);
int fsj_json_u64(const char *json, const char *key, uint64_t *out);
/* Tests only: every visited file waits this long (a slow disk), so a test can watch
+14
View File
@@ -74,6 +74,20 @@ int webui_job_status_to_json(const uint8_t *res, size_t n, char *out, size_t cap
/* 1 when `s` can be one file name: 1-255 bytes, no '/', no control character, not "." or "..". */
int webui_name_ok(const char *s);
/* A decompress's output name: as webui_name_ok, but at most WEBUI_UNPACK_NAME_MAX bytes so
* "<name>.part" (written first, renamed at the end) still fits the 255-byte NAME_MAX. Used by
* both the page route and the FFPFSC_UNPACK op itself. */
#define WEBUI_UNPACK_NAME_MAX 250
int webui_unpack_name_ok(const char *s);
/* A rename whose target already names the source itself (same device + inode: "a.txt" ->
* "A.txt" on case-insensitive exFAT) goes through a temporary name in the same folder:
* `from` -> `tmp` -> `to`. Fills `tmp` ("<dir>/.ps5upload-rename-<salt hex>") and returns 0
* when `from` and `to` are two spellings in ONE folder (same directory part, byte for byte, so
* the detour never leaves the mount); -1 otherwise (different folders, the same spelling, an
* empty name, or `tmp` does not fit `cap`). */
int webui_rename_detour(const char *from, const char *to, unsigned salt, char *tmp, size_t cap);
/* A one-path job operation's args as the engine sends them: {"path":"<escaped>"}, then
* ,"name":"<escaped>" when `name` is not NULL, then `tail` (raw JSON members such as
* ,"mode":"0777"; may be NULL) and the closing brace. Returns the length, or -1 (no path, or
+14 -9
View File
@@ -42,6 +42,7 @@
#include "ava1_op.h"
#include "path_policy.h"
#include "runtime.h"
#include "webui_codec.h"
#define UNP_BLOCK 0x10000ull
#define UNP_HEAD_BLOCKS 6ull /* blocks 0-5: header, inodes, directories */
@@ -183,14 +184,9 @@ static int ends_with_ci(const char *s, const char *suffix) {
return a >= b && strcasecmp(s + a - b, suffix) == 0;
}
/* One file name: 1-255 bytes, no '/', no control character, not "." or "..". */
static int unp_name_ok(const char *s) {
size_t i, n = strlen(s);
if (n == 0 || n > 255 || strcmp(s, ".") == 0 || strcmp(s, "..") == 0) return 0;
for (i = 0; i < n; i++)
if (s[i] == '/' || (unsigned char)s[i] < 0x20) return 0;
return 1;
}
/* The output's file name: one plain name with room for ".part" (the check the page route
* makes too, webui_codec.c). */
static int unp_name_ok(const char *s) { return webui_unpack_name_ok(s); }
/* The checks the unpack makes before it writes anything, for a caller that asks first (the
* console page): the source policy, the layout, the destination and the free space. */
@@ -292,7 +288,16 @@ int fsj_op_ffpfsc_unpack(void *arg, ava1_op_ctx_t *c, const uint8_t *args, size_
{
char want[256];
int rc = fsj_json_str((const char *)args, "name", want, sizeof want);
if (rc && !unp_name_ok(want)) {
/* Present but unusable (too long, not a string, a bad escape): refused, never the
* image's own name instead. */
int present = rc || fsj_json_present((const char *)args, "name");
if (present && (!rc || !unp_name_ok(want))) {
ava1_op_message(c, "fs_unpack_bad_name");
status = AVA1_ERR_PROTOCOL;
goto done;
}
/* The image's own name has to leave room for ".part" too. */
if (!present && !unp_name_ok(L.name)) {
ava1_op_message(c, "fs_unpack_bad_name");
status = AVA1_ERR_PROTOCOL;
goto done;
+3
View File
@@ -80,6 +80,9 @@ static const char *find_value(const char *j, const char *key) {
return NULL;
}
/* 1 when `key` is in the object at all (any value, null included). */
int fsj_json_present(const char *json, const char *key) { return json && find_value(json, key) != NULL; }
int fsj_json_str(const char *json, const char *key, char *out, size_t cap) {
const char *p = json ? find_value(json, key) : NULL;
size_t n = 0;
+64 -15
View File
@@ -416,17 +416,11 @@ static void r_fs_mkdir(const char *b, size_t n, uint8_t *out, int fd) {
else send_json(fd, 200, "{}", 2);
}
/* A rename never crosses a mount (it would panic the kernel): the helper refuses with
* ERR_CROSS_DEVICE, answered 409 cross_mount, and the page copies with move set instead. An
* existing target is refused (409 exists): the page does not overwrite by renaming. */
static void r_fs_rename(const char *b, size_t n, uint8_t *out, int fd) {
char from[API_PATH], to[API_PATH], req[2 * API_PATH + 32];
/* One FS_RENAME (never overwriting): the AVA1 status, the cause in out/ol. -1: does not encode. */
static int rename_once(const char *from, const char *to, uint8_t *out, size_t *ol) {
char req[2 * API_PATH + 32];
ava1_fs_rename_t q;
ava1_w_t w;
size_t ol;
int st;
if (need_path(fd, b, n, "from", from, sizeof from) != 0) return;
if (need_path(fd, b, n, "to", to, sizeof to) != 0) return;
memset(&q, 0, sizeof q);
q.from = (const uint8_t *)from;
q.from_len = (uint16_t)strlen(from);
@@ -434,12 +428,67 @@ static void r_fs_rename(const char *b, size_t n, uint8_t *out, int fd) {
q.to_len = (uint16_t)strlen(to);
q.overwrite = 0;
ava1_w_init(&w, (uint8_t *)req, sizeof req);
if (ava1_fs_rename_encode(&q, &w) != 0) {
send_bad(fd, "bad_path");
return;
if (ava1_fs_rename_encode(&q, &w) != 0) return -1;
return call(AVA1_METHOD_FS_RENAME, (const uint8_t *)req, w.len, out, ol);
}
/* A rename never crosses a mount (it would panic the kernel): the helper refuses with
* ERR_CROSS_DEVICE, answered 409 cross_mount, and the page copies with move set instead. An
* existing target is refused (409 exists): the page does not overwrite by renaming.
* A change of letter case only ("a.txt" -> "A.txt") on a case-insensitive drive (exFAT USB/
* images) finds its target "existing" as the source itself (same device + inode): it goes
* through a temporary name in the same folder (webui_rename_detour), so it never leaves the
* mount; a failed second step puts the source name back. */
static void r_fs_rename(const char *b, size_t n, uint8_t *out, int fd) {
char from[API_PATH], to[API_PATH], tmp[API_PATH];
struct stat fs_, ts_;
size_t ol = 0;
int st;
if (need_path(fd, b, n, "from", from, sizeof from) != 0) return;
if (need_path(fd, b, n, "to", to, sizeof to) != 0) return;
if (lstat(from, &fs_) == 0 && lstat(to, &ts_) == 0 && fs_.st_dev == ts_.st_dev && fs_.st_ino == ts_.st_ino) {
static unsigned seq;
unsigned salt = ((unsigned)time(NULL) << 8) ^ (unsigned)__atomic_add_fetch(&seq, 1, __ATOMIC_RELAXED);
struct stat xs;
int tries;
for (tries = 0; tries < 8; tries++, salt += 0x9e37u) {
if (webui_rename_detour(from, to, salt, tmp, sizeof tmp) != 0) break;
if (lstat(tmp, &xs) != 0) break; /* free */
tmp[0] = '\0';
}
if (tmp[0] && tries < 8) {
st = rename_once(from, tmp, out, &ol);
if (st < 0) {
send_bad(fd, "bad_path");
return;
}
if (st != AVA1_STATUS_OK) {
send_status_error(fd, st, out, ol);
return;
}
st = rename_once(tmp, to, out, &ol);
if (st == AVA1_STATUS_OK) {
send_json(fd, 200, "{}", 2);
return;
}
{
/* Put it back under its old name; the second step's cause is the answer (kept
* aside: the put-back call writes `out`). */
uint8_t cause[256];
size_t cl = ol < sizeof cause ? ol : sizeof cause;
size_t bl = 0;
if (cl) memcpy(cause, out, cl);
(void)rename_once(tmp, from, out, &bl);
if (st < 0) send_bad(fd, "bad_path");
else send_status_error(fd, st, cause, cl);
}
return;
}
/* Not one folder (a hard link elsewhere): the plain rename below refuses it (exists). */
}
st = call(AVA1_METHOD_FS_RENAME, (const uint8_t *)req, w.len, out, &ol);
if (st != AVA1_STATUS_OK) send_status_error(fd, st, out, ol);
st = rename_once(from, to, out, &ol);
if (st < 0) send_bad(fd, "bad_path");
else if (st != AVA1_STATUS_OK) send_status_error(fd, st, out, ol);
else send_json(fd, 200, "{}", 2);
}
@@ -613,7 +662,7 @@ static void r_fs_unpack(const char *b, size_t n, uint8_t *out, int fd) {
int rc;
if (need_path(fd, b, n, "path", path, sizeof path) != 0) return;
rc = webui_json_str(b, n, "name", name, sizeof name);
if (rc < 0 || (rc == 1 && !webui_name_ok(name))) {
if (rc < 0 || (rc == 1 && !webui_unpack_name_ok(name))) {
send_bad(fd, "bad_name");
return;
}
+28 -2
View File
@@ -559,14 +559,40 @@ fail:
return -1;
}
int webui_name_ok(const char *s) {
static int name_ok_max(const char *s, size_t max) {
size_t i, n = s ? strlen(s) : 0;
if (n == 0 || n > 255 || strcmp(s, ".") == 0 || strcmp(s, "..") == 0) return 0;
if (n == 0 || n > max || strcmp(s, ".") == 0 || strcmp(s, "..") == 0) return 0;
for (i = 0; i < n; i++)
if (s[i] == '/' || (unsigned char)s[i] < 0x20) return 0;
return 1;
}
int webui_name_ok(const char *s) { return name_ok_max(s, 255); }
int webui_unpack_name_ok(const char *s) { return name_ok_max(s, WEBUI_UNPACK_NAME_MAX); }
int webui_rename_detour(const char *from, const char *to, unsigned salt, char *tmp, size_t cap) {
const char *fs, *ts;
size_t dl;
int k;
if (!tmp || !cap) return -1;
tmp[0] = '\0';
if (!from || !to || strcmp(from, to) == 0) return -1;
fs = strrchr(from, '/');
ts = strrchr(to, '/');
if (!fs || !ts || !fs[1] || !ts[1]) return -1;
dl = (size_t)(fs - from);
if ((size_t)(ts - to) != dl || memcmp(from, to, dl) != 0) return -1;
if (strcmp(fs + 1, ".") == 0 || strcmp(fs + 1, "..") == 0 || strcmp(ts + 1, ".") == 0 || strcmp(ts + 1, "..") == 0)
return -1;
k = snprintf(tmp, cap, "%.*s/.ps5upload-rename-%08x", (int)dl, from, salt);
if (k < 0 || (size_t)k >= cap) {
tmp[0] = '\0';
return -1;
}
return 0;
}
int webui_path_job_args(char *out, size_t cap, const char *path, const char *name, const char *tail) {
size_t pos = 0, k;
if (!cap) return -1;
+32
View File
@@ -513,6 +513,37 @@ static void name_checks(void) {
long_name[255] = 'a';
long_name[256] = '\0';
CHECK(!webui_name_ok(long_name));
/* A decompress's output name leaves room for ".part": 250 bytes at most. */
memset(long_name, 'a', sizeof long_name);
long_name[WEBUI_UNPACK_NAME_MAX] = '\0';
CHECK(webui_unpack_name_ok(long_name) && strlen(long_name) + strlen(".part") <= 255);
long_name[WEBUI_UNPACK_NAME_MAX] = 'a';
long_name[WEBUI_UNPACK_NAME_MAX + 1] = '\0';
CHECK(!webui_unpack_name_ok(long_name) && webui_name_ok(long_name));
CHECK(webui_unpack_name_ok("img (2).exfat") && !webui_unpack_name_ok("..") && !webui_unpack_name_ok("a/b"));
CHECK(!webui_unpack_name_ok("") && !webui_unpack_name_ok(NULL) && !webui_unpack_name_ok("a\tb"));
}
static void rename_detours(void) {
char tmp[64];
/* A case-only rename in one folder: a temporary name beside it. */
CHECK(webui_rename_detour("/mnt/usb0/Game/a.txt", "/mnt/usb0/Game/A.txt", 0x1234abcdu, tmp, sizeof tmp) == 0);
CHECK(strcmp(tmp, "/mnt/usb0/Game/.ps5upload-rename-1234abcd") == 0);
CHECK(webui_rename_detour("/data/Unity.dll", "/data/unity.DLL", 1, tmp, sizeof tmp) == 0 &&
strcmp(tmp, "/data/.ps5upload-rename-00000001") == 0);
/* Never across folders (a hard link elsewhere, another mount): refused, tmp empty. */
CHECK(webui_rename_detour("/mnt/usb0/a/x", "/mnt/usb0/b/X", 1, tmp, sizeof tmp) == -1 && tmp[0] == '\0');
CHECK(webui_rename_detour("/data/x", "/mnt/usb0/X", 1, tmp, sizeof tmp) == -1);
CHECK(webui_rename_detour("/data/ab/x", "/data/a/x", 1, tmp, sizeof tmp) == -1);
/* The same spelling, no name, dot names, no folder. */
CHECK(webui_rename_detour("/data/x", "/data/x", 1, tmp, sizeof tmp) == -1);
CHECK(webui_rename_detour("/data/x/", "/data/X/", 1, tmp, sizeof tmp) == -1);
CHECK(webui_rename_detour("/data/.", "/data/..", 1, tmp, sizeof tmp) == -1);
CHECK(webui_rename_detour("x", "X", 1, tmp, sizeof tmp) == -1);
CHECK(webui_rename_detour(NULL, "/data/X", 1, tmp, sizeof tmp) == -1);
/* Does not fit: refused, never cut short. */
CHECK(webui_rename_detour("/data/a", "/data/A", 1, tmp, 20) == -1 && tmp[0] == '\0');
CHECK(webui_rename_detour("/data/a", "/data/A", 1, tmp, 0) == -1);
}
static void hex_ids(void) {
@@ -783,6 +814,7 @@ int main(void) {
job_status_to_json();
path_job_args();
name_checks();
rename_detours();
hex_ids();
status_mapping();
mime_types();