import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { normalizeStoredState, type PersistedState, } from '../../shared/contracts/state.js'; import { INITIAL_ROUTE_RULES, normalizeRouteRules } from '../../shared/routingRules.js'; import { normalizeServers, resolveServerId, serverIdentityKey, type NormalizedServer, } from '../../shared/serverIdentity.js'; export const STATE_SCHEMA_VERSION = 10; export interface AtomicWriteOptions { beforeRename?: (temporaryPath: string, filePath: string) => void; mode?: number; } interface RecoveryState { kind: 'corrupt-json'; backupPath: string; recoveredAt: string; } interface MigrationState { fromVersion: number; toVersion: unknown; backupPath: string; migratedAt: string; } interface JsonStoreBaseOptions { filePath: string; initializeMissing?: boolean; backupWhen?: (before: unknown, after: unknown) => boolean; now?: () => Date; } export interface JsonStoreOptions extends JsonStoreBaseOptions { defaultValue: T; migrate: (value: unknown) => T; } export interface RawJsonStoreOptions extends JsonStoreBaseOptions { defaultValue: unknown; migrate?: never; } export interface JsonStore { read(): T; write(value: T, options?: AtomicWriteOptions): T; update(mutator: (value: T) => T): T; remove(): void; readonly recovery: RecoveryState | null; readonly migration: MigrationState | null; } const clone = (value: T): T => structuredClone(value); const stamp = (value: Date) => value.toISOString().replace(/[:.]/g, '-'); function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function remapLegacyServerId( previousServers: NormalizedServer[], nextServers: NormalizedServer[], serverId: unknown, legacyTag: unknown = '', ) { const direct = resolveServerId(nextServers, serverId, legacyTag); if (direct) return direct; const previousId = resolveServerId(previousServers, serverId, legacyTag); const previous = previousServers.find((server) => server.id === previousId); if (!previous) return ''; const matches = nextServers.filter((server) => ( serverIdentityKey(server) === serverIdentityKey(previous) )); return matches.length === 1 ? matches[0].id : ''; } function syncDirectory(directory: string) { let descriptor: number | undefined; try { descriptor = fs.openSync(directory, 'r'); fs.fsyncSync(descriptor); } catch (error) { const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : ''; if (!['EINVAL', 'ENOTSUP', 'EPERM'].includes(code)) throw error; } finally { if (descriptor !== undefined) fs.closeSync(descriptor); } } export function atomicWriteFile( filePath: string, contents: string | NodeJS.ArrayBufferView, { beforeRename, mode }: AtomicWriteOptions = {}, ) { const directory = path.dirname(filePath); fs.mkdirSync(directory, { recursive: true }); const temporaryPath = path.join( directory, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`, ); const fileMode = mode ?? (fs.existsSync(filePath) ? fs.statSync(filePath).mode & 0o777 : 0o666); let descriptor: number | undefined; try { descriptor = fs.openSync(temporaryPath, 'wx', fileMode); fs.writeFileSync(descriptor, contents, 'utf8'); fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined; beforeRename?.(temporaryPath, filePath); fs.renameSync(temporaryPath, filePath); syncDirectory(directory); } finally { if (descriptor !== undefined) fs.closeSync(descriptor); fs.rmSync(temporaryPath, { force: true }); } } export function atomicWriteJson(filePath: string, value: unknown, options?: AtomicWriteOptions) { atomicWriteFile(filePath, JSON.stringify(value, null, 2), options); } export function migrateStoredState( value: unknown, legacySubscriptionCache: unknown = null, ): PersistedState & { schemaVersion: number } { const stored = record(value); const version = Number.isSafeInteger(stored.schemaVersion) ? Number(stored.schemaVersion) : 0; if (version < 0 || version > STATE_SCHEMA_VERSION) { throw new Error(`Unsupported Harbor state schemaVersion: ${version}`); } const routeRules = version < 3 ? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])] : stored.routeRules; const migratedRouteRules = normalizeRouteRules(routeRules, { strict: version >= 6 }); const migratedAppliedRouteRules = normalizeRouteRules(stored.appliedRouteRules, { strict: version >= 6 }); const legacyCache = record(legacySubscriptionCache); const storedSubscriptionUrl = String(stored.subscriptionUrl || '').trim(); const cachedSubscriptionUrl = String(legacyCache.url || '').trim(); const cacheOwnsStoredSubscription = Boolean(cachedSubscriptionUrl) && (!storedSubscriptionUrl || cachedSubscriptionUrl === storedSubscriptionUrl); const previousServers = normalizeServers(stored.servers); const cachedServers = cacheOwnsStoredSubscription ? normalizeServers(legacyCache.servers) : []; const migratedServers = cachedServers.length ? cachedServers : previousServers; const selectedServerId = remapLegacyServerId( previousServers, migratedServers, stored.selectedServerId, stored.selectedTag, ); const appliedServerId = remapLegacyServerId( previousServers, migratedServers, stored.appliedServerId, stored.appliedTag || stored.selectedTag, ); const keepLegacyApplied = !(version < 5 && stored.connectionDesired === 'stopped'); const normalized = normalizeStoredState({ ...stored, routeRules: migratedRouteRules, appliedRouteRules: migratedAppliedRouteRules, ...(version < 5 && !Array.isArray(stored.profiles) ? { subscriptionUrl: storedSubscriptionUrl || (cacheOwnsStoredSubscription ? cachedSubscriptionUrl : ''), subscriptionConfig: cacheOwnsStoredSubscription ? legacyCache.config : null, servers: migratedServers, selectedServerId, selectedTag: '', appliedServerId: keepLegacyApplied ? appliedServerId : '', appliedServerSnapshot: keepLegacyApplied ? stored.appliedServerSnapshot : null, appliedTag: '', userInfo: stored.userInfo || (cacheOwnsStoredSubscription ? legacyCache.userInfo : undefined), fetchedAt: stored.fetchedAt || (cacheOwnsStoredSubscription ? legacyCache.fetchedAt : undefined), } : {}), }); const canonical = { ...normalized } as Record; for (const key of [ 'subscriptionUrl', 'selectedServerId', 'selectedTag', 'appliedTag', 'servers', 'userInfo', 'fetchedAt', 'subscriptionConfig', ]) delete canonical[key]; return { ...canonical, schemaVersion: STATE_SCHEMA_VERSION, } as PersistedState & { schemaVersion: number }; } export function createJsonStore(options: JsonStoreOptions): JsonStore; export function createJsonStore(options: RawJsonStoreOptions): JsonStore; export function createJsonStore(options: JsonStoreOptions | RawJsonStoreOptions): JsonStore { const { filePath, defaultValue, initializeMissing = false, backupWhen = () => false, now = () => new Date(), } = options; const migrate = options.migrate || ((value: unknown) => value); let recovery: RecoveryState | null = null; let migration: MigrationState | null = null; function write(value: unknown, writeOptions?: AtomicWriteOptions): unknown { const migrated = migrate(clone(value)); atomicWriteJson(filePath, migrated, writeOptions); return clone(migrated); } function read(): unknown { if (!fs.existsSync(filePath)) { const initial = migrate(clone(defaultValue)); return initializeMissing ? write(initial) : clone(initial); } const raw = fs.readFileSync(filePath, 'utf8'); let parsed: unknown; try { parsed = JSON.parse(raw); } catch (cause) { const backupPath = `${filePath}.corrupt-${stamp(now())}`; fs.renameSync(filePath, backupPath); try { const recovered = write(defaultValue); recovery = { kind: 'corrupt-json', backupPath, recoveredAt: now().toISOString() }; return recovered; } catch (error) { fs.renameSync(backupPath, filePath); throw new AggregateError([cause, error], `Failed to recover corrupt JSON: ${filePath}`); } } const migrated = migrate(clone(parsed)); if (JSON.stringify(migrated) !== JSON.stringify(parsed)) { if (backupWhen(parsed, migrated)) { const parsedRecord = record(parsed); const fromVersion = Number.isSafeInteger(parsedRecord.schemaVersion) ? Number(parsedRecord.schemaVersion) : 0; const migratedRecord = record(migrated); const backupPath = `${filePath}.backup-v${fromVersion}-${stamp(now())}`; atomicWriteFile(backupPath, raw, { mode: fs.statSync(filePath).mode & 0o777 }); migration = { fromVersion, toVersion: migratedRecord.schemaVersion, backupPath, migratedAt: now().toISOString(), }; } atomicWriteJson(filePath, migrated); } return clone(migrated); } function update(mutator: (value: unknown) => unknown): unknown { // ponytail: sync mutators serialize in Node's event loop; add a queue only if updates must await I/O. const next = mutator(read()); if (next && typeof next === 'object' && 'then' in next) { throw new TypeError('State store mutator must be synchronous'); } return write(next); } return { read, write, update, remove: () => fs.rmSync(filePath, { force: true }), get recovery() { return recovery; }, get migration() { return migration; }, }; } export function createStateStore( filePath: string, options: Partial, 'filePath' | 'defaultValue' | 'migrate'>> & { legacySubscriptionCache?: unknown; } = {}, ) { const { legacySubscriptionCache = null, ...storeOptions } = options; return createJsonStore({ filePath, defaultValue: migrateStoredState({}, legacySubscriptionCache), migrate: (value) => migrateStoredState(value, legacySubscriptionCache), initializeMissing: true, backupWhen: (before, after) => record(before).schemaVersion !== record(after).schemaVersion, ...storeOptions, }); }