mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
fix(launcher): address code review findings in stats sync
- merge-catalog: stop dropping anilist_id on every new anime insert (bun:sqlite .get() returns null, so the old !== undefined guard always fired); the guard was dead anyway since a colliding id is caught earlier - sync-command: throw instead of fail() inside runHostSync so the finally block runs and temp dirs holding snapshot data are cleaned up on failure - ssh: shell-quote the user-supplied --remote-cmd in the probe, and reject option-like (-prefixed) SSH hosts that ssh/scp would parse as flags - sync-db: close the remote handle if opening the local DB throws - sync-shared: treat EPERM from process.kill(pid, 0) as alive, not dead - cli-parser: trim the sync host before the mode-exclusivity check - tests: cover anilist_id preservation, anilist-based anime matching, and the SSH host/shell-quote guards
This commit is contained in:
@@ -9,7 +9,13 @@ import {
|
|||||||
formatMergeSummary,
|
formatMergeSummary,
|
||||||
mergeSnapshotIntoDb,
|
mergeSnapshotIntoDb,
|
||||||
} from '../sync/sync-db.js';
|
} from '../sync/sync-db.js';
|
||||||
import { resolveRemoteSubminerCommand, runScp, runSsh, shellQuote } from '../sync/ssh.js';
|
import {
|
||||||
|
assertSafeSshHost,
|
||||||
|
resolveRemoteSubminerCommand,
|
||||||
|
runScp,
|
||||||
|
runSsh,
|
||||||
|
shellQuote,
|
||||||
|
} from '../sync/ssh.js';
|
||||||
import { resolvePathMaybe } from '../util.js';
|
import { resolvePathMaybe } from '../util.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
@@ -66,6 +72,7 @@ function cleanupRemote(host: string, remoteTmpDir: string): void {
|
|||||||
function runHostSync(context: LauncherCommandContext, dbPath: string): void {
|
function runHostSync(context: LauncherCommandContext, dbPath: string): void {
|
||||||
const { args } = context;
|
const { args } = context;
|
||||||
const host = args.syncHost;
|
const host = args.syncHost;
|
||||||
|
assertSafeSshHost(host);
|
||||||
|
|
||||||
ensureTrackerQuiescent(context, dbPath);
|
ensureTrackerQuiescent(context, dbPath);
|
||||||
|
|
||||||
@@ -75,10 +82,13 @@ function runHostSync(context: LauncherCommandContext, dbPath: string): void {
|
|||||||
const localTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
|
const localTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
|
||||||
let remoteTmpDir = '';
|
let remoteTmpDir = '';
|
||||||
try {
|
try {
|
||||||
|
// Signal failures by throwing (not fail(), which exits synchronously and
|
||||||
|
// would skip the finally cleanup, leaking temp dirs holding snapshot data).
|
||||||
|
// main().catch() reports the message the same way fail() would.
|
||||||
const mktemp = runSsh(host, 'mktemp -d /tmp/subminer-sync.XXXXXX');
|
const mktemp = runSsh(host, 'mktemp -d /tmp/subminer-sync.XXXXXX');
|
||||||
remoteTmpDir = mktemp.stdout.trim();
|
remoteTmpDir = mktemp.stdout.trim();
|
||||||
if (mktemp.status !== 0 || !remoteTmpDir.startsWith('/tmp/')) {
|
if (mktemp.status !== 0 || !remoteTmpDir.startsWith('/tmp/')) {
|
||||||
fail(`Could not create a temporary directory on ${host}.`);
|
throw new Error(`Could not create a temporary directory on ${host}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const forceFlag = args.syncForce ? ' --force' : '';
|
const forceFlag = args.syncForce ? ' --force' : '';
|
||||||
@@ -94,7 +104,7 @@ function runHostSync(context: LauncherCommandContext, dbPath: string): void {
|
|||||||
`${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`,
|
`${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`,
|
||||||
);
|
);
|
||||||
if (snapshotRun.status !== 0) {
|
if (snapshotRun.status !== 0) {
|
||||||
fail(`Remote snapshot failed on ${host}.`);
|
throw new Error(`Remote snapshot failed on ${host}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite');
|
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite');
|
||||||
@@ -113,7 +123,7 @@ function runHostSync(context: LauncherCommandContext, dbPath: string): void {
|
|||||||
);
|
);
|
||||||
process.stdout.write(mergeRun.stdout);
|
process.stdout.write(mergeRun.stdout);
|
||||||
if (mergeRun.status !== 0) {
|
if (mergeRun.status !== 0) {
|
||||||
fail(
|
throw new Error(
|
||||||
`Remote merge failed on ${host}. The local database was updated; re-run "subminer sync ${host}" once the remote issue is fixed.`,
|
`Remote merge failed on ${host}. The local database was updated; re-run "subminer sync ${host}" once the remote issue is fixed.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,7 +316,8 @@ export function parseCliPrograms(
|
|||||||
.option('--remote-cmd <cmd>', 'subminer command to run on the remote host')
|
.option('--remote-cmd <cmd>', 'subminer command to run on the remote host')
|
||||||
.option('-f, --force', 'Skip the running-app safety check')
|
.option('-f, --force', 'Skip the running-app safety check')
|
||||||
.option('--log-level <level>', 'Log level')
|
.option('--log-level <level>', 'Log level')
|
||||||
.action((host: string | undefined, options: Record<string, unknown>) => {
|
.action((rawHost: string | undefined, options: Record<string, unknown>) => {
|
||||||
|
const host = typeof rawHost === 'string' ? rawHost.trim() : '';
|
||||||
const snapshot = typeof options.snapshot === 'string' ? options.snapshot.trim() : '';
|
const snapshot = typeof options.snapshot === 'string' ? options.snapshot.trim() : '';
|
||||||
const merge = typeof options.merge === 'string' ? options.merge.trim() : '';
|
const merge = typeof options.merge === 'string' ? options.merge.trim() : '';
|
||||||
const modes = [Boolean(host), Boolean(snapshot), Boolean(merge)].filter(Boolean).length;
|
const modes = [Boolean(host), Boolean(snapshot), Boolean(merge)].filter(Boolean).length;
|
||||||
@@ -327,7 +328,7 @@ export function parseCliPrograms(
|
|||||||
throw new Error('Sync host, --snapshot, and --merge cannot be combined.');
|
throw new Error('Sync host, --snapshot, and --merge cannot be combined.');
|
||||||
}
|
}
|
||||||
syncTriggered = true;
|
syncTriggered = true;
|
||||||
syncHost = host?.trim() || null;
|
syncHost = host || null;
|
||||||
syncSnapshotPath = snapshot || null;
|
syncSnapshotPath = snapshot || null;
|
||||||
syncMergePath = merge || null;
|
syncMergePath = merge || null;
|
||||||
syncRemoteCmd = typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null;
|
syncRemoteCmd = typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null;
|
||||||
|
|||||||
@@ -138,15 +138,9 @@ export function mergeAnime(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// anilist_id is UNIQUE; drop it if another local row (matched by a
|
// No local row matched by anilist_id (checked first in `existing` above)
|
||||||
// different title key) already claims it.
|
// or title key, so the remote anilist_id — if any — is free to insert as-is.
|
||||||
const anilistTaken =
|
const values = ANIME_COPY_COLUMNS.map((column) => row[column]);
|
||||||
row.anilist_id !== null && byAnilist.get(row.anilist_id) !== undefined
|
|
||||||
? null
|
|
||||||
: row.anilist_id;
|
|
||||||
const values = ANIME_COPY_COLUMNS.map((column) =>
|
|
||||||
column === 'anilist_id' ? anilistTaken : row[column],
|
|
||||||
);
|
|
||||||
map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values));
|
map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values));
|
||||||
summary.animeAdded += 1;
|
summary.animeAdded += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { assertSafeSshHost, shellQuote } from './ssh.js';
|
||||||
|
|
||||||
|
test('assertSafeSshHost rejects option-like hosts', () => {
|
||||||
|
assert.throws(() => assertSafeSshHost('-oProxyCommand=touch pwned'), /looks like an option/);
|
||||||
|
assert.throws(() => assertSafeSshHost('-lroot'), /looks like an option/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assertSafeSshHost accepts normal destinations', () => {
|
||||||
|
assert.doesNotThrow(() => assertSafeSshHost('macbook'));
|
||||||
|
assert.doesNotThrow(() => assertSafeSshHost('user@192.168.1.20'));
|
||||||
|
assert.doesNotThrow(() => assertSafeSshHost('ssh-alias'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shellQuote escapes single quotes and wraps in quotes', () => {
|
||||||
|
assert.equal(shellQuote('subminer'), `'subminer'`);
|
||||||
|
assert.equal(shellQuote(`a'; rm -rf ~; '`), `'a'\\''; rm -rf ~; '\\'''`);
|
||||||
|
});
|
||||||
+22
-3
@@ -5,12 +5,24 @@ export interface RemoteRunResult {
|
|||||||
stdout: string;
|
stdout: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ssh/scp have no `--` terminator for the destination, so a host that starts
|
||||||
|
* with `-` (e.g. `-oProxyCommand=...`) is parsed as an option. Reject those
|
||||||
|
* before spawning.
|
||||||
|
*/
|
||||||
|
export function assertSafeSshHost(host: string): void {
|
||||||
|
if (host.startsWith('-')) {
|
||||||
|
throw new Error(`Refusing to use SSH host that looks like an option: ${host}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a command on the SSH host. stdin/stderr stay attached to the terminal
|
* Run a command on the SSH host. stdin/stderr stay attached to the terminal
|
||||||
* so key passphrase / password prompts and remote progress output work;
|
* so key passphrase / password prompts and remote progress output work;
|
||||||
* stdout is captured for the caller.
|
* stdout is captured for the caller.
|
||||||
*/
|
*/
|
||||||
export function runSsh(host: string, remoteCommand: string): RemoteRunResult {
|
export function runSsh(host: string, remoteCommand: string): RemoteRunResult {
|
||||||
|
assertSafeSshHost(host);
|
||||||
const result = spawnSync('ssh', [host, remoteCommand], {
|
const result = spawnSync('ssh', [host, remoteCommand], {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['inherit', 'pipe', 'inherit'],
|
stdio: ['inherit', 'pipe', 'inherit'],
|
||||||
@@ -43,11 +55,18 @@ export function shellQuote(value: string): string {
|
|||||||
* configured command first and fall back to the default install location.
|
* configured command first and fall back to the default install location.
|
||||||
*/
|
*/
|
||||||
export function resolveRemoteSubminerCommand(host: string, preferred: string | null): string {
|
export function resolveRemoteSubminerCommand(host: string, preferred: string | null): string {
|
||||||
const candidates = preferred ? [preferred] : ['subminer', '~/.local/bin/subminer'];
|
// Trusted defaults stay unquoted so the remote shell expands `~`; the
|
||||||
|
// user-supplied override is shell-quoted to prevent command injection.
|
||||||
|
const candidates: Array<{ value: string; probe: string }> = preferred
|
||||||
|
? [{ value: preferred, probe: shellQuote(preferred) }]
|
||||||
|
: [
|
||||||
|
{ value: 'subminer', probe: 'subminer' },
|
||||||
|
{ value: '~/.local/bin/subminer', probe: '~/.local/bin/subminer' },
|
||||||
|
];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const probe = runSsh(host, `command -v ${candidate} >/dev/null 2>&1`);
|
const probe = runSsh(host, `command -v ${candidate.probe} >/dev/null 2>&1`);
|
||||||
if (probe.status === 0) {
|
if (probe.status === 0) {
|
||||||
return candidate;
|
return candidate.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -151,6 +151,65 @@ test('is idempotent: re-merging the same snapshot changes nothing', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preserves anilist_id when inserting a new anime from the snapshot', () => {
|
||||||
|
const { dir, localPath, remotePath } = makeDbPair();
|
||||||
|
try {
|
||||||
|
insertFixtureSession(remotePath, {
|
||||||
|
uuid: 'remote-1',
|
||||||
|
videoKey: 'showb-e1',
|
||||||
|
animeTitleKey: 'showb',
|
||||||
|
startedAtMs: BASE_MS,
|
||||||
|
applyLifetime: true,
|
||||||
|
});
|
||||||
|
const remoteDb = new Database(remotePath, { readwrite: true });
|
||||||
|
remoteDb.prepare(`UPDATE imm_anime SET anilist_id = 12345 WHERE normalized_title_key = 'showb'`).run();
|
||||||
|
remoteDb.close();
|
||||||
|
|
||||||
|
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||||
|
assert.equal(summary.animeAdded, 1);
|
||||||
|
const anime = queryOne<{ anilist_id: number }>(
|
||||||
|
localPath,
|
||||||
|
`SELECT anilist_id FROM imm_anime WHERE normalized_title_key = 'showb'`,
|
||||||
|
);
|
||||||
|
assert.equal(anime?.anilist_id, 12345);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches an existing local anime by anilist_id even when the title key differs', () => {
|
||||||
|
const { dir, localPath, remotePath } = makeDbPair();
|
||||||
|
try {
|
||||||
|
insertFixtureSession(localPath, {
|
||||||
|
uuid: 'local-1',
|
||||||
|
videoKey: 'showa-e1',
|
||||||
|
animeTitleKey: 'show-romaji',
|
||||||
|
startedAtMs: BASE_MS,
|
||||||
|
applyLifetime: true,
|
||||||
|
});
|
||||||
|
insertFixtureSession(remotePath, {
|
||||||
|
uuid: 'remote-1',
|
||||||
|
videoKey: 'showa-e2',
|
||||||
|
animeTitleKey: 'show-native',
|
||||||
|
startedAtMs: BASE_MS + DAY_MS,
|
||||||
|
applyLifetime: true,
|
||||||
|
});
|
||||||
|
const localDb = new Database(localPath, { readwrite: true });
|
||||||
|
localDb.prepare(`UPDATE imm_anime SET anilist_id = 999 WHERE normalized_title_key = 'show-romaji'`).run();
|
||||||
|
localDb.close();
|
||||||
|
const remoteDb = new Database(remotePath, { readwrite: true });
|
||||||
|
remoteDb.prepare(`UPDATE imm_anime SET anilist_id = 999 WHERE normalized_title_key = 'show-native'`).run();
|
||||||
|
remoteDb.close();
|
||||||
|
|
||||||
|
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||||
|
// Same anilist_id → one anime, not two.
|
||||||
|
assert.equal(summary.animeAdded, 0);
|
||||||
|
assert.equal(count(localPath, 'SELECT COUNT(*) AS n FROM imm_anime'), 1);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('matches shared videos by video_key and merges the watched flag', () => {
|
test('matches shared videos by video_key and merges the watched flag', () => {
|
||||||
const { dir, localPath, remotePath } = makeDbPair();
|
const { dir, localPath, remotePath } = makeDbPair();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -35,7 +35,13 @@ export function mergeSnapshotIntoDb(localDbPath: string, snapshotPath: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const remote = new Database(snapshotPath, { readonly: true });
|
const remote = new Database(snapshotPath, { readonly: true });
|
||||||
const local = new Database(localDbPath, { readwrite: true, create: false });
|
let local: Database;
|
||||||
|
try {
|
||||||
|
local = new Database(localDbPath, { readwrite: true, create: false });
|
||||||
|
} catch (error) {
|
||||||
|
remote.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
assertMergeableSchema(remote, 'Snapshot');
|
assertMergeableSchema(remote, 'Snapshot');
|
||||||
assertMergeableSchema(local, 'Local');
|
assertMergeableSchema(local, 'Local');
|
||||||
|
|||||||
@@ -121,8 +121,10 @@ function isProcessAlive(pid: number): boolean {
|
|||||||
try {
|
try {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch (error) {
|
||||||
return false;
|
// EPERM means the process exists but we can't signal it → still alive.
|
||||||
|
// Only ESRCH (no such process) means it's actually gone.
|
||||||
|
return (error as NodeJS.ErrnoException)?.code === 'EPERM';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user