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
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import type { spawn as spawnType, ChildProcess } from 'node:child_process';
import { allocatePort, startSidecar } from './sidecar-process';
import type { BundleBinaries } from './sidecar-bundle';
const binaries: BundleBinaries = {
javaPath: '/nonexistent/java',
jarPath: '/tmp/MExtensionServer.jar',
};
/** A ChildProcess stand-in: an EventEmitter with the bits startSidecar touches. */
function fakeChild(): ChildProcess {
const child = new EventEmitter();
Object.assign(child, { stdout: null, stderr: null, kill: () => true });
return child as unknown as ChildProcess;
}
test('a failed spawn rejects instead of throwing an unhandled error event', async () => {
const port = await allocatePort();
const child = fakeChild();
const spawnImpl = (() => {
// Node emits `error` asynchronously when the binary cannot be executed.
queueMicrotask(() => child.emit('error', new Error('spawn ENOENT')));
return child;
}) as unknown as typeof spawnType;
await assert.rejects(
() => startSidecar({ binaries, port, readyTimeoutMs: 2000, spawnImpl }),
/could not start.*ENOENT/,
);
});
test('an early exit is reported with its code rather than waiting out the deadline', async () => {
const port = await allocatePort();
const child = fakeChild();
const spawnImpl = (() => {
queueMicrotask(() => child.emit('exit', 1, null));
return child;
}) as unknown as typeof spawnType;
await assert.rejects(
() => startSidecar({ binaries, port, readyTimeoutMs: 2000, spawnImpl }),
/exited before becoming ready \(code 1/,
);
});