fix(storage): protect Yomitan profiles across Electron runtime changes

- Block unsupported or downgraded Electron runtimes before loading profile storage
- Isolate development profiles and guard against unexpected dictionary loss
This commit is contained in:
2026-08-18 22:19:47 -07:00
parent 4ed878270f
commit 06b21a68fa
21 changed files with 789 additions and 15 deletions
+53
View File
@@ -138,3 +138,56 @@ test('createMainBootServices builds boot-phase service bundle', () => {
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
assert.equal(setPathValue, '/tmp/subminer-config');
});
test('createMainBootServices honors the profile selected by the early entrypoint', () => {
const services = createMainBootServices({
platform: 'linux',
argv: ['electron', '.', '--dev'],
configDir: '/tmp/SubMiner-dev',
appDataDir: undefined,
xdgConfigHome: undefined,
homeDir: '/home/tester',
defaultMpvLogFile: '/tmp/default.log',
envMpvLog: undefined,
defaultTexthookerPort: 5174,
getDefaultSocketPath: () => '/tmp/subminer.sock',
resolveConfigDir: () => {
throw new Error('early profile should be authoritative');
},
existsSync: () => false,
mkdirSync: () => {},
joinPath: (...parts) => parts.join('/'),
app: {
setPath: () => {},
quit: () => {},
exit: () => {},
on: () => ({}),
whenReady: async () => {},
},
shouldBypassSingleInstanceLock: () => false,
requestSingleInstanceLockEarly: () => true,
registerSecondInstanceHandlerEarly: () => {},
onConfigStartupParseError: () => {},
createConfigService: (configDir) => ({ configDir }),
createAnilistTokenStore: (targetPath) => ({ targetPath }),
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
createSubtitleWebSocket: (payloadMode) => ({ payloadMode }),
createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }),
createMainRuntimeRegistry: () => ({}),
createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }),
createOverlayModalInputState: () => ({
getModalInputExclusive: () => false,
handleModalInputStateChange: () => {},
}),
createOverlayContentMeasurementStore: () => ({}),
getSyncOverlayShortcutsForModal: () => () => {},
getSyncOverlayVisibilityForModal: () => () => {},
createOverlayModalRuntime: () => ({}),
createAppState: (input) => input,
});
assert.equal(services.configDir, '/tmp/SubMiner-dev');
assert.equal(services.userDataPath, '/tmp/SubMiner-dev');
assert.deepEqual(services.configService, { configDir: '/tmp/SubMiner-dev' });
});
+10 -7
View File
@@ -31,6 +31,7 @@ export interface MainBootServicesParams<
> {
platform: NodeJS.Platform;
argv: string[];
configDir?: string;
appDataDir: string | undefined;
xdgConfigHome: string | undefined;
homeDir: string;
@@ -174,13 +175,15 @@ export function createMainBootServices<
TAppState,
TAppLifecycleApp
> {
const configDir = params.resolveConfigDir({
platform: params.platform,
appDataDir: params.appDataDir,
xdgConfigHome: params.xdgConfigHome,
homeDir: params.homeDir,
existsSync: params.existsSync,
});
const configDir =
params.configDir ??
params.resolveConfigDir({
platform: params.platform,
appDataDir: params.appDataDir,
xdgConfigHome: params.xdgConfigHome,
homeDir: params.homeDir,
existsSync: params.existsSync,
});
const userDataPath = configDir;
const defaultMpvLogPath = params.envMpvLog?.trim() || params.defaultMpvLogFile;
const defaultImmersionDbPath = params.joinPath(userDataPath, 'immersion.sqlite');
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { enforceElectronRuntimeGuard, SUPPORTED_ELECTRON_MAJOR } from './electron-runtime-guard';
function withTempDir(run: (directory: string) => void): void {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-electron-guard-'));
try {
run(directory);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
test('runtime guard major matches the pinned Electron dependency', () => {
const packageJson = JSON.parse(
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
) as { devDependencies: { electron: string } };
assert.equal(Number.parseInt(packageJson.devDependencies.electron.split('.', 1)[0]!, 10), 43);
assert.equal(SUPPORTED_ELECTRON_MAJOR, 43);
});
test('runtime guard records the supported Electron major', () => {
withTempDir((userDataPath) => {
const result = enforceElectronRuntimeGuard({
electronVersion: '43.4.1',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, true);
assert.deepEqual(JSON.parse(fs.readFileSync(result.statePath, 'utf8')), {
highestElectronMajor: 43,
lastElectronVersion: '43.4.1',
});
});
});
test('runtime guard rejects a runtime outside the build major without writing state', () => {
withTempDir((userDataPath) => {
const result = enforceElectronRuntimeGuard({
electronVersion: '44.0.0',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'Unsupported Electron runtime');
assert.match(result.details, /requires Electron 43/);
assert.equal(fs.existsSync(result.statePath), false);
});
});
test('runtime guard rejects prerelease Electron versions without writing state', () => {
withTempDir((userDataPath) => {
const result = enforceElectronRuntimeGuard({
electronVersion: '43.4.1-beta.1',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'SubMiner could not verify Electron');
assert.equal(fs.existsSync(result.statePath), false);
});
});
test('runtime guard blocks a profile downgrade before rewriting its safety record', () => {
withTempDir((userDataPath) => {
const statePath = path.join(userDataPath, 'electron-runtime.json');
fs.writeFileSync(
statePath,
JSON.stringify({ highestElectronMajor: 44, lastElectronVersion: '44.1.0' }),
'utf8',
);
const result = enforceElectronRuntimeGuard({
electronVersion: '43.4.1',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'Electron downgrade blocked');
assert.match(result.details, /destroy Yomitan dictionaries/);
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
highestElectronMajor: 44,
lastElectronVersion: '44.1.0',
});
});
});
test('runtime guard blocks a downgrade within the supported Electron major', () => {
withTempDir((userDataPath) => {
const statePath = path.join(userDataPath, 'electron-runtime.json');
fs.writeFileSync(
statePath,
JSON.stringify({ highestElectronMajor: 43, lastElectronVersion: '43.4.1' }),
'utf8',
);
const result = enforceElectronRuntimeGuard({
electronVersion: '43.3.0',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'Electron downgrade blocked');
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
highestElectronMajor: 43,
lastElectronVersion: '43.4.1',
});
});
});
test('runtime guard fails closed when its safety record is malformed', () => {
withTempDir((userDataPath) => {
const statePath = path.join(userDataPath, 'electron-runtime.json');
fs.writeFileSync(statePath, '{}', 'utf8');
const result = enforceElectronRuntimeGuard({
electronVersion: '43.4.1',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'SubMiner profile safety check failed');
assert.match(result.details, /invalid format/);
});
});
+170
View File
@@ -0,0 +1,170 @@
import fs from 'node:fs';
import path from 'node:path';
import { writeTextFileAtomicallyDurable } from '../shared/fs-utils';
export const SUPPORTED_ELECTRON_MAJOR = 43;
const RUNTIME_STATE_FILE_NAME = 'electron-runtime.json';
type ElectronRuntimeState = {
highestElectronMajor: number;
lastElectronVersion: string;
};
type ParsedElectronVersion = {
major: number;
minor: number;
patch: number;
};
type ValidatedElectronRuntimeState = {
state: ElectronRuntimeState;
version: ParsedElectronVersion;
};
export type ElectronRuntimeGuardResult =
| { ok: true; statePath: string }
| { ok: false; title: string; details: string; statePath: string };
function parseElectronVersion(version: string): ParsedElectronVersion | null {
const match = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
if (!match) return null;
const major = Number.parseInt(match[1]!, 10);
const minor = Number.parseInt(match[2]!, 10);
const patch = Number.parseInt(match[3]!, 10);
if (![major, minor, patch].every((part) => Number.isSafeInteger(part) && part >= 0)) {
return null;
}
if (major === 0) return null;
return { major, minor, patch };
}
function compareElectronVersions(
left: ParsedElectronVersion,
right: ParsedElectronVersion,
): number {
return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
}
function readRuntimeState(statePath: string): ValidatedElectronRuntimeState | null {
if (!fs.existsSync(statePath)) return null;
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')) as Partial<ElectronRuntimeState>;
const highestElectronMajor = parsed.highestElectronMajor;
const lastElectronVersion =
typeof parsed.lastElectronVersion === 'string'
? parseElectronVersion(parsed.lastElectronVersion)
: null;
if (
typeof highestElectronMajor !== 'number' ||
!Number.isSafeInteger(highestElectronMajor) ||
highestElectronMajor <= 0 ||
lastElectronVersion === null
) {
throw new Error('The runtime safety record has an invalid format.');
}
return {
state: {
highestElectronMajor,
lastElectronVersion: parsed.lastElectronVersion!,
},
version: lastElectronVersion,
};
}
function writeRuntimeState(statePath: string, state: ElectronRuntimeState): void {
writeTextFileAtomicallyDurable(statePath, `${JSON.stringify(state, null, 2)}\n`);
}
export function enforceElectronRuntimeGuard(options: {
electronVersion: string;
userDataPath: string;
supportedElectronMajor?: number;
}): ElectronRuntimeGuardResult {
const supportedElectronMajor = options.supportedElectronMajor ?? SUPPORTED_ELECTRON_MAJOR;
const statePath = path.join(options.userDataPath, RUNTIME_STATE_FILE_NAME);
const currentVersion = parseElectronVersion(options.electronVersion);
if (currentVersion === null) {
return {
ok: false,
title: 'SubMiner could not verify Electron',
details: `Electron reported an invalid version: ${JSON.stringify(options.electronVersion)}. SubMiner did not load Yomitan storage.`,
statePath,
};
}
if (currentVersion.major !== supportedElectronMajor) {
return {
ok: false,
title: 'Unsupported Electron runtime',
details: [
`This SubMiner build requires Electron ${supportedElectronMajor}.`,
`The current runtime is Electron ${options.electronVersion}.`,
'',
'Launch SubMiner through its packaged application or the repository package scripts. Yomitan storage was not loaded.',
].join('\n'),
statePath,
};
}
let previousState: ValidatedElectronRuntimeState | null;
try {
previousState = readRuntimeState(statePath);
} catch (error) {
return {
ok: false,
title: 'SubMiner profile safety check failed',
details: [
`SubMiner could not read the runtime safety record at ${statePath}.`,
(error as Error).message,
'',
'Yomitan storage was not loaded. Repair or remove only this safety record after verifying the profile backup.',
].join('\n'),
statePath,
};
}
if (
previousState &&
(currentVersion.major < previousState.state.highestElectronMajor ||
compareElectronVersions(currentVersion, previousState.version) < 0)
) {
return {
ok: false,
title: 'Electron downgrade blocked',
details: [
`This profile was previously opened with Electron ${previousState.state.lastElectronVersion}.`,
`The current runtime is Electron ${options.electronVersion}.`,
'',
'Opening Chromium storage with an older Electron version can destroy Yomitan dictionaries. Upgrade SubMiner before using this profile.',
].join('\n'),
statePath,
};
}
try {
writeRuntimeState(statePath, {
highestElectronMajor: Math.max(
currentVersion.major,
previousState?.state.highestElectronMajor ?? 0,
),
lastElectronVersion: options.electronVersion,
});
} catch (error) {
return {
ok: false,
title: 'SubMiner profile safety check failed',
details: [
`SubMiner could not update the runtime safety record at ${statePath}.`,
(error as Error).message,
'',
'Yomitan storage was not loaded.',
].join('\n'),
statePath,
};
}
return { ok: true, statePath };
}
@@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
assertYomitanDictionaryMutationSafe,
observeYomitanDictionaryCount,
} from './yomitan-dictionary-integrity';
function withTempDir(run: (directory: string) => void): void {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-dictionary-integrity-'));
try {
run(directory);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
test('dictionary integrity observation establishes and updates a non-empty baseline', () => {
withTempDir((userDataPath) => {
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 5), {
safe: true,
previousCount: null,
});
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 3), {
safe: true,
previousCount: 5,
});
assert.deepEqual(
JSON.parse(
fs.readFileSync(path.join(userDataPath, 'yomitan-dictionary-integrity.json'), 'utf8'),
),
{ lastKnownNonEmptyCount: 3 },
);
assert.deepEqual(fs.readdirSync(userDataPath), ['yomitan-dictionary-integrity.json']);
});
});
test('dictionary integrity permits an empty profile before dictionaries are installed', () => {
withTempDir((userDataPath) => {
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 0), {
safe: true,
previousCount: null,
});
});
});
test('dictionary integrity rejects invalid counts without creating a safety record', () => {
withTempDir((userDataPath) => {
for (const invalidCount of [Number.NaN, Number.POSITIVE_INFINITY, -1]) {
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, invalidCount), {
safe: false,
previousCount: null,
message: 'SubMiner could not verify Yomitan dictionary storage: invalid dictionary count.',
});
}
assert.equal(
fs.existsSync(path.join(userDataPath, 'yomitan-dictionary-integrity.json')),
false,
);
});
});
test('dictionary integrity migrates the previous count from first-run setup state', () => {
withTempDir((userDataPath) => {
fs.writeFileSync(
path.join(userDataPath, 'setup-state.json'),
JSON.stringify({
version: 4,
status: 'completed',
completedAt: '2026-08-18T00:00:00.000Z',
completionSource: 'user',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 4,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: {
startMenuEnabled: true,
desktopEnabled: false,
},
windowsMpvShortcutLastStatus: 'installed',
bunInstallStatus: 'installed',
launcherInstallStatus: 'installed',
launcherInstallPath: '/home/tester/.local/bin/subminer',
}),
'utf8',
);
assert.throws(
() => assertYomitanDictionaryMutationSafe(userDataPath, 0),
/reported zero dictionaries after previously reporting 4/,
);
});
});
test('dictionary integrity blocks automatic mutation after a non-empty profile becomes empty', () => {
withTempDir((userDataPath) => {
observeYomitanDictionaryCount(userDataPath, 6);
assert.throws(
() => assertYomitanDictionaryMutationSafe(userDataPath, 0),
/reported zero dictionaries after previously reporting 6/,
);
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 0), {
safe: false,
previousCount: 6,
message:
'Yomitan reported zero dictionaries after previously reporting 6. SubMiner blocked automatic dictionary changes because Chromium storage may have been reset. Close SubMiner and restore or inspect the profile before changing dictionaries.',
});
});
});
test('dictionary integrity fails closed when its state is malformed', () => {
withTempDir((userDataPath) => {
fs.writeFileSync(path.join(userDataPath, 'yomitan-dictionary-integrity.json'), '{}', 'utf8');
assert.throws(
() => assertYomitanDictionaryMutationSafe(userDataPath, 2),
/could not verify Yomitan dictionary storage/,
);
});
});
@@ -0,0 +1,112 @@
import fs from 'node:fs';
import path from 'node:path';
import { writeTextFileAtomicallyDurable } from '../../shared/fs-utils';
import { getSetupStatePath, readSetupState } from '../../shared/setup-state';
const INTEGRITY_STATE_FILE_NAME = 'yomitan-dictionary-integrity.json';
type DictionaryIntegrityState = {
lastKnownNonEmptyCount: number;
};
export type DictionaryIntegrityObservation =
| { safe: true; previousCount: number | null }
| { safe: false; previousCount: number | null; message: string };
function getStatePath(userDataPath: string): string {
return path.join(userDataPath, INTEGRITY_STATE_FILE_NAME);
}
function readState(statePath: string): DictionaryIntegrityState | null {
if (!fs.existsSync(statePath)) return null;
const parsed = JSON.parse(
fs.readFileSync(statePath, 'utf8'),
) as Partial<DictionaryIntegrityState>;
const lastKnownNonEmptyCount = parsed.lastKnownNonEmptyCount;
if (
typeof lastKnownNonEmptyCount !== 'number' ||
!Number.isSafeInteger(lastKnownNonEmptyCount) ||
lastKnownNonEmptyCount <= 0
) {
throw new Error('The dictionary integrity record has an invalid format.');
}
return { lastKnownNonEmptyCount };
}
function writeState(statePath: string, state: DictionaryIntegrityState): void {
writeTextFileAtomicallyDurable(statePath, `${JSON.stringify(state, null, 2)}\n`);
}
function readLegacySetupCount(userDataPath: string): number | null {
const setupState = readSetupState(getSetupStatePath(userDataPath));
return setupState && setupState.lastSeenYomitanDictionaryCount > 0
? setupState.lastSeenYomitanDictionaryCount
: null;
}
export function observeYomitanDictionaryCount(
userDataPath: string,
dictionaryCount: number,
): DictionaryIntegrityObservation {
if (!Number.isSafeInteger(dictionaryCount) || dictionaryCount < 0) {
return {
safe: false,
previousCount: null,
message: 'SubMiner could not verify Yomitan dictionary storage: invalid dictionary count.',
};
}
const normalizedCount = dictionaryCount;
const statePath = getStatePath(userDataPath);
let state: DictionaryIntegrityState | null;
try {
state = readState(statePath);
if (state === null) {
const legacyCount = readLegacySetupCount(userDataPath);
state = legacyCount === null ? null : { lastKnownNonEmptyCount: legacyCount };
}
} catch (error) {
return {
safe: false,
previousCount: null,
message: `SubMiner could not verify Yomitan dictionary storage: ${(error as Error).message}`,
};
}
if (normalizedCount === 0 && state !== null) {
return {
safe: false,
previousCount: state.lastKnownNonEmptyCount,
message: [
`Yomitan reported zero dictionaries after previously reporting ${state.lastKnownNonEmptyCount}.`,
'SubMiner blocked automatic dictionary changes because Chromium storage may have been reset.',
'Close SubMiner and restore or inspect the profile before changing dictionaries.',
].join(' '),
};
}
if (normalizedCount > 0) {
try {
writeState(statePath, { lastKnownNonEmptyCount: normalizedCount });
} catch (error) {
return {
safe: false,
previousCount: state?.lastKnownNonEmptyCount ?? null,
message: `SubMiner could not update the Yomitan dictionary integrity record: ${(error as Error).message}`,
};
}
}
return { safe: true, previousCount: state?.lastKnownNonEmptyCount ?? null };
}
export function assertYomitanDictionaryMutationSafe(
userDataPath: string,
dictionaryCount: number,
): void {
const observation = observeYomitanDictionaryCount(userDataPath, dictionaryCount);
if (!observation.safe) {
throw new Error(observation.message);
}
}