fix(anime): harden the extension bridge against untrusted repos and hangs

Addresses CodeRabbit review feedback on the Anime Browser:

- reject repository package/apk names that are not plain identifiers, and
  verify the install target resolves inside the extensions directory
- count mpv's %n% option escape in UTF-8 bytes, and escape backslashes in
  header values so a trailing one cannot eat the list separator
- key the bridge extension-id cache by APK content, so an in-place upgrade
  re-uploads instead of running the previous build
- bound every bridge, release-listing, and download request with a timeout
- enforce the APK size limit while streaming rather than after buffering
- read APK bytes on demand instead of holding a base64 copy per extension
  for the lifetime of the browser
- serialize preference mutations and write the file atomically
- handle the sidecar spawn error event, and wait for the child to exit in
  stop() before returning
- report a failed Anime Browser bootstrap instead of showing the starting
  banner forever
- keep the preferences panel's save confirmation and in-flight multi-select
  edits by re-rendering only on a structural schema change
This commit is contained in:
2026-07-31 17:57:01 -07:00
parent ee08512988
commit ca5fc341cb
19 changed files with 567 additions and 81 deletions
+33 -4
View File
@@ -7,6 +7,12 @@ import type { BundleBinaries } from './sidecar-bundle';
/** Cold start includes JVM boot plus AndroidCompat init; be generous. */
export const DEFAULT_READY_TIMEOUT_MS = 30_000;
const READY_POLL_INTERVAL_MS = 500;
/** How long to wait for the child to go after each signal before escalating. */
const STOP_TIMEOUT_MS = 5_000;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Ask the OS for a free loopback port, then hand it to the JVM. */
export async function allocatePort(): Promise<number> {
@@ -68,8 +74,20 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
}
let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null;
child.once('exit', (code, signal) => {
exited = { code, signal };
let spawnError: Error | null = null;
const hasExited = new Promise<void>((resolve) => {
child.once('exit', (code, signal) => {
exited = { code, signal };
resolve();
});
// A ChildProcess is an EventEmitter: without this listener a failed spawn
// (a missing or non-executable java) throws in the main process instead of
// failing the readiness loop below.
child.once('error', (error: Error) => {
spawnError = error;
if (exited === null) exited = { code: null, signal: null };
resolve();
});
});
const stop = async (): Promise<void> => {
@@ -80,13 +98,24 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
} catch {
// Falling through to a signal is fine; the endpoint may already be gone.
}
if (exited === null) child.kill();
if (exited !== null) return;
child.kill();
// kill() only sends the signal. Wait for the process to actually go, so a
// restart cannot race the old one still holding the port.
await Promise.race([hasExited, delay(STOP_TIMEOUT_MS)]);
if (exited === null) {
child.kill('SIGKILL');
await Promise.race([hasExited, delay(STOP_TIMEOUT_MS)]);
}
};
const client = new AnimeBridgeClient({ baseUrl });
const deadline = Date.now() + (options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
while (Date.now() < deadline) {
if (spawnError !== null) {
throw new Error(`Anime bridge could not start: ${(spawnError as Error).message}`);
}
if (exited !== null) {
const { code, signal } = exited as { code: number | null; signal: NodeJS.Signals | null };
throw new Error(
@@ -94,7 +123,7 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
);
}
if (await client.isReady()) return { baseUrl, port, client, stop };
await new Promise((resolve) => setTimeout(resolve, READY_POLL_INTERVAL_MS));
await delay(READY_POLL_INTERVAL_MS);
}
await stop();