mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
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:
1 parent
c12787d7e0
commit
1f16fb97fc
16 files changed
+322
-38
No files matched your search
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user