feat(migration): add safe legacy import preview
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./src/index.ts",
|
"exports": "./src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run --root ../.. apps/api/src/app.test.ts apps/api/src/start.test.ts apps/api/src/infrastructure/database/database.test.ts",
|
"test": "vitest run --root ../.. apps/api/src/app.test.ts apps/api/src/start.test.ts apps/api/src/infrastructure/database/database.test.ts apps/api/src/application/legacy-import/legacy-import.test.ts",
|
||||||
"typecheck": "tsc -p tsconfig.json"
|
"typecheck": "tsc -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { lstat, mkdtemp, readFile, rename, symlink, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||||
|
import {
|
||||||
|
confirmLegacyImport,
|
||||||
|
previewLegacyImport,
|
||||||
|
type LegacyImportPreview,
|
||||||
|
} from './legacy-import.js';
|
||||||
|
|
||||||
|
const databases: Database.Database[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const database of databases.splice(0)) database.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function database(): Database.Database {
|
||||||
|
const result = new Database(':memory:');
|
||||||
|
result.pragma('foreign_keys = ON');
|
||||||
|
migrateDatabase(result);
|
||||||
|
databases.push(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function legacyFile(value: unknown): Promise<string> {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'legacy-import-'));
|
||||||
|
const path = join(directory, 'legacy.json');
|
||||||
|
await writeFile(path, JSON.stringify(value), { mode: 0o600 });
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = 'phase-two-super-secret-value';
|
||||||
|
const valid = {
|
||||||
|
server: { host: '0.0.0.0', port: 8788 },
|
||||||
|
instances: [
|
||||||
|
{
|
||||||
|
id: 'alpha',
|
||||||
|
name: 'Alpha',
|
||||||
|
url: 'https://203.0.113.8/path?discard=yes#fragment',
|
||||||
|
description: 'primary',
|
||||||
|
tags: ['prod', 'blue'],
|
||||||
|
auth: { mode: 'password', password },
|
||||||
|
capabilities: ['status.read', 'sim.restart'],
|
||||||
|
},
|
||||||
|
{ id: 'beta', url: 'http://[2001:4860:4860::8888]:8080', auth: { mode: 'none' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function scan(value: unknown): string {
|
||||||
|
if (value instanceof Error) return JSON.stringify({ name: value.name, message: value.message });
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanDatabaseText(database: Database.Database): string {
|
||||||
|
const tables = database
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
|
||||||
|
.all() as { name: string }[];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
for (const { name } of tables) {
|
||||||
|
const quotedTable = `"${name.replaceAll('"', '""')}"`;
|
||||||
|
const columns = database.prepare(`PRAGMA table_info(${quotedTable})`).all() as {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}[];
|
||||||
|
const textColumns = columns.filter(({ type }) => type.toUpperCase().includes('TEXT'));
|
||||||
|
if (textColumns.length === 0) continue;
|
||||||
|
const selection = textColumns
|
||||||
|
.map(({ name: column }) => `"${column.replaceAll('"', '""')}"`)
|
||||||
|
.join(',');
|
||||||
|
values.push(...database.prepare(`SELECT ${selection} FROM ${quotedTable}`).all());
|
||||||
|
}
|
||||||
|
return JSON.stringify(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejection(action: () => Promise<unknown>): Promise<Error> {
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (error) {
|
||||||
|
return error as Error;
|
||||||
|
}
|
||||||
|
throw new Error('expected rejection');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('legacy import', () => {
|
||||||
|
it('previews a deeply frozen, redacted, digest-bound plan without writing the database', async () => {
|
||||||
|
const path = await legacyFile(valid);
|
||||||
|
const db = database();
|
||||||
|
const before = db.serialize();
|
||||||
|
const preview = await previewLegacyImport(path, db);
|
||||||
|
|
||||||
|
expect(preview.counts).toEqual({
|
||||||
|
total: 2,
|
||||||
|
planned: 2,
|
||||||
|
conflict: 0,
|
||||||
|
unchanged: 0,
|
||||||
|
secretPending: 1,
|
||||||
|
});
|
||||||
|
expect(preview.instances[0]).toEqual({
|
||||||
|
id: 'alpha',
|
||||||
|
name: 'Alpha',
|
||||||
|
baseUrl: 'https://203.0.113.8/path',
|
||||||
|
description: 'primary',
|
||||||
|
tags: ['prod', 'blue'],
|
||||||
|
capabilities: ['status.read', 'sim.restart'],
|
||||||
|
credentialPresent: true,
|
||||||
|
status: 'planned',
|
||||||
|
});
|
||||||
|
expect(preview.source.digest).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(preview.planDigest).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(preview.source.identity).toMatchObject({
|
||||||
|
device: expect.any(String),
|
||||||
|
inode: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(preview)).toBe(true);
|
||||||
|
expect(Object.isFrozen(preview.instances[0]?.tags)).toBe(true);
|
||||||
|
expect(scan(preview)).not.toContain(password);
|
||||||
|
expect(db.serialize()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['bad JSON', '{'],
|
||||||
|
['array root', '[]'],
|
||||||
|
['missing instances', '{}'],
|
||||||
|
['instances object', '{"instances":{}}'],
|
||||||
|
[
|
||||||
|
'dangerous key',
|
||||||
|
'{"instances":[{"id":"x","url":"https://203.0.113.1","__proto__":{"polluted":true}}]}',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'duplicate id',
|
||||||
|
'{"instances":[{"id":"x","url":"https://203.0.113.1"},{"id":"x","url":"https://203.0.113.2"}]}',
|
||||||
|
],
|
||||||
|
['credentials in URL', '{"instances":[{"id":"x","url":"https://u:p@203.0.113.1"}]}'],
|
||||||
|
['hostname', '{"instances":[{"id":"x","url":"https://example.com"}]}'],
|
||||||
|
['loopback', '{"instances":[{"id":"x","url":"http://127.0.0.1"}]}'],
|
||||||
|
['malformed member', '{"instances":[{"id":"ok","url":"https://203.0.113.1"},null]}'],
|
||||||
|
])('rejects %s as an all-or-nothing malformed preview', async (_name, contents) => {
|
||||||
|
const path = await legacyFile({});
|
||||||
|
await writeFile(path, contents);
|
||||||
|
const error = await rejection(() => previewLegacyImport(path));
|
||||||
|
expect(scan(error)).not.toContain(password);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects symlinks and non-regular files', async () => {
|
||||||
|
const path = await legacyFile(valid);
|
||||||
|
const link = `${path}.link`;
|
||||||
|
await symlink(path, link);
|
||||||
|
await expect(previewLegacyImport(link)).rejects.toThrow(/regular|symlink/i);
|
||||||
|
const details = await lstat(link);
|
||||||
|
expect(details.isSymbolicLink()).toBe(true);
|
||||||
|
await expect(previewLegacyImport(join(path, 'child'))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects oversized source files before parsing without echoing their content', async () => {
|
||||||
|
const path = await legacyFile({});
|
||||||
|
const marker = 'oversized-secret-marker';
|
||||||
|
await writeFile(path, JSON.stringify({ instances: [], padding: marker.repeat(100_000) }));
|
||||||
|
|
||||||
|
const error = await rejection(() => previewLegacyImport(path));
|
||||||
|
|
||||||
|
expect(error.message).toMatch(/large|size|limit/i);
|
||||||
|
expect(scan(error)).not.toContain(marker);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports atomically, ignores legacy server, records pending secrets without storing credentials, and is idempotent', async () => {
|
||||||
|
const path = await legacyFile(valid);
|
||||||
|
const db = database();
|
||||||
|
const preview = await previewLegacyImport(path, db);
|
||||||
|
const result = await confirmLegacyImport(path, preview, db);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ imported: 2, unchanged: 0, secretPending: ['alpha'] });
|
||||||
|
expect(scan(result)).not.toContain(password);
|
||||||
|
expect(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id,name,base_url,auth_mode,enabled,config_revision FROM instances ORDER BY id',
|
||||||
|
)
|
||||||
|
.all(),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
id: 'alpha',
|
||||||
|
name: 'Alpha',
|
||||||
|
base_url: 'https://203.0.113.8/path',
|
||||||
|
auth_mode: 'none',
|
||||||
|
enabled: 1,
|
||||||
|
config_revision: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'beta',
|
||||||
|
name: 'beta',
|
||||||
|
base_url: 'http://[2001:4860:4860::8888]:8080',
|
||||||
|
auth_mode: 'none',
|
||||||
|
enabled: 1,
|
||||||
|
config_revision: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
db.prepare('SELECT instance_id,tag FROM instance_tags ORDER BY instance_id,tag').all(),
|
||||||
|
).toEqual([
|
||||||
|
{ instance_id: 'alpha', tag: 'blue' },
|
||||||
|
{ instance_id: 'alpha', tag: 'prod' },
|
||||||
|
]);
|
||||||
|
expect(db.prepare('SELECT COUNT(*) AS count FROM secret_references').get()).toEqual({
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
db
|
||||||
|
.prepare("SELECT COUNT(*) AS count FROM app_settings WHERE key LIKE 'legacy-import.%'")
|
||||||
|
.get(),
|
||||||
|
).toEqual({ count: 2 });
|
||||||
|
expect(scanDatabaseText(db)).not.toContain(password);
|
||||||
|
|
||||||
|
const secondPreview = await previewLegacyImport(path, db);
|
||||||
|
expect(secondPreview.counts).toMatchObject({ planned: 0, unchanged: 2, conflict: 0 });
|
||||||
|
const second = await confirmLegacyImport(path, secondPreview, db);
|
||||||
|
expect(second).toMatchObject({ imported: 0, unchanged: 2, secretPending: ['alpha'] });
|
||||||
|
expect(db.prepare('SELECT id,config_revision FROM instances ORDER BY id').all()).toEqual([
|
||||||
|
{ id: 'alpha', config_revision: 1 },
|
||||||
|
{ id: 'beta', config_revision: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds confirmation to source identity, source digest, and plan digest', async () => {
|
||||||
|
const path = await legacyFile(valid);
|
||||||
|
const db = database();
|
||||||
|
const preview = await previewLegacyImport(path, db);
|
||||||
|
await writeFile(path, JSON.stringify({ instances: [] }));
|
||||||
|
await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow(
|
||||||
|
/changed|digest|identity/i,
|
||||||
|
);
|
||||||
|
expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 0 });
|
||||||
|
|
||||||
|
const fresh = await legacyFile(valid);
|
||||||
|
const good = await previewLegacyImport(fresh, db);
|
||||||
|
const replacement = `${fresh}.new`;
|
||||||
|
await writeFile(replacement, await readFile(fresh));
|
||||||
|
await rename(replacement, fresh);
|
||||||
|
const replacementPreview = await previewLegacyImport(fresh, db);
|
||||||
|
await expect(confirmLegacyImport(fresh, good, db)).rejects.toThrow(/changed|identity/i);
|
||||||
|
|
||||||
|
const tampered = { ...replacementPreview, planDigest: '0'.repeat(64) } as LegacyImportPreview;
|
||||||
|
await expect(confirmLegacyImport(fresh, tampered, db)).rejects.toThrow(/digest/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports conflicts and confirmation rejects the entire transaction', async () => {
|
||||||
|
const path = await legacyFile(valid);
|
||||||
|
const db = database();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)').run(
|
||||||
|
'alpha',
|
||||||
|
'Different',
|
||||||
|
'https://203.0.113.99',
|
||||||
|
'none',
|
||||||
|
1,
|
||||||
|
7,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
db.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)').run(
|
||||||
|
'owner',
|
||||||
|
'Owner',
|
||||||
|
'http://[2001:4860:4860::8888]:8080',
|
||||||
|
'none',
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
const preview = await previewLegacyImport(path, db);
|
||||||
|
expect(preview.instances.map((item) => item.status)).toEqual(['conflict', 'conflict']);
|
||||||
|
expect(preview.counts.conflict).toBe(2);
|
||||||
|
await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow(/conflict/i);
|
||||||
|
expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back all writes on any database failure', async () => {
|
||||||
|
const path = await legacyFile(valid);
|
||||||
|
const db = database();
|
||||||
|
db.exec(
|
||||||
|
"CREATE TRIGGER fail_beta BEFORE INSERT ON instances WHEN NEW.id = 'beta' BEGIN SELECT RAISE(ABORT, 'forced'); END",
|
||||||
|
);
|
||||||
|
const preview = await previewLegacyImport(path, db);
|
||||||
|
await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow(/forced/);
|
||||||
|
expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 0 });
|
||||||
|
expect(db.prepare('SELECT COUNT(*) AS count FROM app_settings').get()).toEqual({ count: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { constants } from 'node:fs';
|
||||||
|
import { lstat, open } from 'node:fs/promises';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
|
||||||
|
export type LegacyImportStatus = 'planned' | 'conflict' | 'unchanged';
|
||||||
|
|
||||||
|
export interface LegacyImportInstancePreview {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly baseUrl: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly tags: readonly string[];
|
||||||
|
readonly capabilities: readonly string[];
|
||||||
|
readonly credentialPresent: boolean;
|
||||||
|
readonly status: LegacyImportStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyImportPreview {
|
||||||
|
readonly source: {
|
||||||
|
readonly digest: string;
|
||||||
|
readonly identity: { readonly device: string; readonly inode: string };
|
||||||
|
};
|
||||||
|
readonly planDigest: string;
|
||||||
|
readonly counts: {
|
||||||
|
readonly total: number;
|
||||||
|
readonly planned: number;
|
||||||
|
readonly conflict: number;
|
||||||
|
readonly unchanged: number;
|
||||||
|
readonly secretPending: number;
|
||||||
|
};
|
||||||
|
readonly instances: readonly LegacyImportInstancePreview[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyImportResult {
|
||||||
|
readonly imported: number;
|
||||||
|
readonly unchanged: number;
|
||||||
|
readonly secretPending: readonly string[];
|
||||||
|
readonly sourceDigest: string;
|
||||||
|
readonly planDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NormalizedInstance extends Omit<LegacyImportInstancePreview, 'status'> {
|
||||||
|
readonly authMode: 'none' | 'password';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceRead {
|
||||||
|
readonly bytes: Buffer;
|
||||||
|
readonly digest: string;
|
||||||
|
readonly identity: { readonly device: string; readonly inode: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExistingInstance {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
base_url: string;
|
||||||
|
auth_mode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dangerousKeys = new Set(['__proto__', 'prototype', 'constructor']);
|
||||||
|
const MAX_LEGACY_CONFIG_BYTES = 1024 * 1024;
|
||||||
|
const metadataKey = (id: string) => `legacy-import.instance.${id}`;
|
||||||
|
const sha256 = (value: Buffer | string): string => createHash('sha256').update(value).digest('hex');
|
||||||
|
|
||||||
|
export class LegacyImportError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'LegacyImportError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepFreeze<T>(value: T): T {
|
||||||
|
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||||||
|
Object.freeze(value);
|
||||||
|
for (const child of Object.values(value)) deepFreeze(child);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameIdentity(
|
||||||
|
left: { dev: number | bigint; ino: number | bigint },
|
||||||
|
right: { dev: number | bigint; ino: number | bigint },
|
||||||
|
): boolean {
|
||||||
|
return String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function securelyRead(path: string): Promise<SourceRead> {
|
||||||
|
let beforePath;
|
||||||
|
try {
|
||||||
|
beforePath = await lstat(path, { bigint: true });
|
||||||
|
} catch (error) {
|
||||||
|
throw new LegacyImportError(`Cannot inspect legacy config: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
if (!beforePath.isFile() || beforePath.isSymbolicLink())
|
||||||
|
throw new LegacyImportError('Legacy config must be a regular non-symlink file');
|
||||||
|
if (beforePath.size > BigInt(MAX_LEGACY_CONFIG_BYTES))
|
||||||
|
throw new LegacyImportError('Legacy config exceeds the size limit');
|
||||||
|
|
||||||
|
let handle;
|
||||||
|
try {
|
||||||
|
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||||
|
const before = await handle.stat({ bigint: true });
|
||||||
|
if (!before.isFile() || !sameIdentity(beforePath, before))
|
||||||
|
throw new LegacyImportError('Legacy config identity changed before read');
|
||||||
|
if (before.size > BigInt(MAX_LEGACY_CONFIG_BYTES))
|
||||||
|
throw new LegacyImportError('Legacy config exceeds the size limit');
|
||||||
|
const bytes = await handle.readFile();
|
||||||
|
const after = await handle.stat({ bigint: true });
|
||||||
|
const afterPath = await lstat(path, { bigint: true });
|
||||||
|
if (
|
||||||
|
!after.isFile() ||
|
||||||
|
!afterPath.isFile() ||
|
||||||
|
afterPath.isSymbolicLink() ||
|
||||||
|
!sameIdentity(before, after) ||
|
||||||
|
!sameIdentity(after, afterPath) ||
|
||||||
|
before.size !== after.size ||
|
||||||
|
before.mtimeNs !== after.mtimeNs ||
|
||||||
|
before.ctimeNs !== after.ctimeNs
|
||||||
|
) {
|
||||||
|
throw new LegacyImportError('Legacy config changed while it was being read');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bytes,
|
||||||
|
digest: sha256(bytes),
|
||||||
|
identity: { device: String(after.dev), inode: String(after.ino) },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LegacyImportError) throw error;
|
||||||
|
throw new LegacyImportError(`Cannot safely read legacy config: ${(error as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
await handle?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(bytes: Buffer): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(bytes.toString('utf8'), (key, value: unknown) => {
|
||||||
|
if (dangerousKeys.has(key))
|
||||||
|
throw new LegacyImportError(`Dangerous JSON key is prohibited: ${key}`);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LegacyImportError) throw error;
|
||||||
|
throw new LegacyImportError(`Legacy config is not valid JSON: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(raw: string): string {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
throw new LegacyImportError('Instance URL is invalid');
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
||||||
|
throw new LegacyImportError('Instance URL must use http or https');
|
||||||
|
if (url.username || url.password)
|
||||||
|
throw new LegacyImportError('Instance URL must not contain credentials');
|
||||||
|
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||||
|
const ipv4 = hostname.split('.').map(Number);
|
||||||
|
const isIpv4 =
|
||||||
|
ipv4.length === 4 && ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255);
|
||||||
|
const prohibitedIpv4 =
|
||||||
|
isIpv4 &&
|
||||||
|
(ipv4[0] === 127 ||
|
||||||
|
ipv4[0] === 0 ||
|
||||||
|
(ipv4[0] === 169 && ipv4[1] === 254) ||
|
||||||
|
(ipv4[0] !== undefined && ipv4[0] >= 224));
|
||||||
|
const prohibitedIpv6 =
|
||||||
|
hostname === '::' ||
|
||||||
|
hostname === '::1' ||
|
||||||
|
/^fe[89ab][0-9a-f]:/.test(hostname) ||
|
||||||
|
hostname.startsWith('ff') ||
|
||||||
|
hostname.startsWith('::ffff:') ||
|
||||||
|
hostname.startsWith('64:ff9b:');
|
||||||
|
const prohibitedSpecial =
|
||||||
|
hostname === '100.100.100.200' || hostname === '168.63.129.16' || hostname.startsWith('2002:');
|
||||||
|
if (isIP(hostname) === 0 || prohibitedIpv4 || prohibitedIpv6 || prohibitedSpecial)
|
||||||
|
throw new LegacyImportError(
|
||||||
|
'Instance target must be a permitted IP literal; hostnames and local/metadata addresses are prohibited',
|
||||||
|
);
|
||||||
|
url.hash = '';
|
||||||
|
url.search = '';
|
||||||
|
return url.toString().replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(value: unknown): readonly string[] {
|
||||||
|
return Array.isArray(value) ? [...new Set(value.map(String))] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(raw: unknown): readonly NormalizedInstance[] {
|
||||||
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
||||||
|
throw new LegacyImportError('Legacy config root must be an object');
|
||||||
|
if (
|
||||||
|
!Object.hasOwn(raw, 'instances') ||
|
||||||
|
!Array.isArray((raw as { instances?: unknown }).instances)
|
||||||
|
)
|
||||||
|
throw new LegacyImportError('Legacy config instances must be an array');
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
return (raw as { instances: unknown[] }).instances.map((candidate, index) => {
|
||||||
|
if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate))
|
||||||
|
throw new LegacyImportError(`instances[${index}] must be an object`);
|
||||||
|
const item = candidate as Record<string, unknown>;
|
||||||
|
const id = String(item.id || '').trim();
|
||||||
|
if (!id || !/^[a-zA-Z0-9_.-]+$/.test(id))
|
||||||
|
throw new LegacyImportError(
|
||||||
|
`instances[${index}].id is required and may only contain letters, numbers, _, -, .`,
|
||||||
|
);
|
||||||
|
if (ids.has(id)) throw new LegacyImportError(`Duplicate instance id: ${id}`);
|
||||||
|
ids.add(id);
|
||||||
|
const rawUrl = String(item.url || '').trim();
|
||||||
|
if (!rawUrl) throw new LegacyImportError(`instances[${index}].url is required`);
|
||||||
|
const auth =
|
||||||
|
item.auth !== null && typeof item.auth === 'object'
|
||||||
|
? (item.auth as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
const credential = auth?.password ?? item.password ?? '';
|
||||||
|
const authMode = credential ? 'password' : String(auth?.mode || 'none');
|
||||||
|
if (authMode !== 'none' && authMode !== 'password')
|
||||||
|
throw new LegacyImportError(`instances[${index}].auth.mode must be none or password`);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: String(item.name || id || `SimAdmin ${index + 1}`).trim(),
|
||||||
|
baseUrl: normalizeBaseUrl(rawUrl),
|
||||||
|
description: String(item.description || '').trim(),
|
||||||
|
tags: uniqueStrings(item.tags),
|
||||||
|
capabilities: uniqueStrings(item.capabilities),
|
||||||
|
credentialPresent: Boolean(credential),
|
||||||
|
authMode,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadata(instance: NormalizedInstance): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
source: 'legacy-config',
|
||||||
|
description: instance.description,
|
||||||
|
capabilities: instance.capabilities,
|
||||||
|
secretPending: instance.credentialPresent,
|
||||||
|
requestedAuthMode: instance.authMode,
|
||||||
|
secretPurpose: instance.credentialPresent ? 'instance-password' : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFor(
|
||||||
|
database: Database.Database | undefined,
|
||||||
|
item: NormalizedInstance,
|
||||||
|
): LegacyImportStatus {
|
||||||
|
if (!database) return 'planned';
|
||||||
|
const byId = database
|
||||||
|
.prepare('SELECT id,name,base_url,auth_mode FROM instances WHERE id = ?')
|
||||||
|
.get(item.id) as ExistingInstance | undefined;
|
||||||
|
const byUrl = database
|
||||||
|
.prepare('SELECT id FROM instances WHERE base_url = ?')
|
||||||
|
.get(item.baseUrl) as { id: string } | undefined;
|
||||||
|
if (!byId) return byUrl ? 'conflict' : 'planned';
|
||||||
|
if (byUrl && byUrl.id !== item.id) return 'conflict';
|
||||||
|
const tags = (
|
||||||
|
database
|
||||||
|
.prepare('SELECT tag FROM instance_tags WHERE instance_id = ? ORDER BY tag')
|
||||||
|
.all(item.id) as {
|
||||||
|
tag: string;
|
||||||
|
}[]
|
||||||
|
).map(({ tag }) => tag);
|
||||||
|
const expectedTags = [...item.tags].sort();
|
||||||
|
const savedMetadata = database
|
||||||
|
.prepare('SELECT value_json FROM app_settings WHERE key = ?')
|
||||||
|
.get(metadataKey(item.id)) as { value_json: string } | undefined;
|
||||||
|
return byId.name === item.name &&
|
||||||
|
byId.base_url === item.baseUrl &&
|
||||||
|
byId.auth_mode === (item.credentialPresent ? 'none' : item.authMode) &&
|
||||||
|
JSON.stringify(tags) === JSON.stringify(expectedTags) &&
|
||||||
|
savedMetadata?.value_json === metadata(item)
|
||||||
|
? 'unchanged'
|
||||||
|
: 'conflict';
|
||||||
|
}
|
||||||
|
|
||||||
|
function digestPlan(source: SourceRead, instances: readonly LegacyImportInstancePreview[]): string {
|
||||||
|
const normalizedPlan = instances.map((instance) => ({
|
||||||
|
id: instance.id,
|
||||||
|
name: instance.name,
|
||||||
|
baseUrl: instance.baseUrl,
|
||||||
|
description: instance.description,
|
||||||
|
tags: instance.tags,
|
||||||
|
capabilities: instance.capabilities,
|
||||||
|
credentialPresent: instance.credentialPresent,
|
||||||
|
}));
|
||||||
|
return sha256(
|
||||||
|
JSON.stringify({
|
||||||
|
sourceDigest: source.digest,
|
||||||
|
sourceIdentity: source.identity,
|
||||||
|
instances: normalizedPlan,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewLegacyImport(
|
||||||
|
path: string,
|
||||||
|
database?: Database.Database,
|
||||||
|
): Promise<LegacyImportPreview> {
|
||||||
|
const source = await securelyRead(path);
|
||||||
|
const normalized = normalize(parseJson(source.bytes));
|
||||||
|
const instances = normalized.map(({ authMode: _authMode, ...item }) => ({
|
||||||
|
...item,
|
||||||
|
status: statusFor(database, { ...item, authMode: _authMode }),
|
||||||
|
}));
|
||||||
|
const counts = {
|
||||||
|
total: instances.length,
|
||||||
|
planned: instances.filter(({ status }) => status === 'planned').length,
|
||||||
|
conflict: instances.filter(({ status }) => status === 'conflict').length,
|
||||||
|
unchanged: instances.filter(({ status }) => status === 'unchanged').length,
|
||||||
|
secretPending: instances.filter(({ credentialPresent }) => credentialPresent).length,
|
||||||
|
};
|
||||||
|
return deepFreeze({
|
||||||
|
source: { digest: source.digest, identity: source.identity },
|
||||||
|
planDigest: digestPlan(source, instances),
|
||||||
|
counts,
|
||||||
|
instances,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConfirmation(preview: LegacyImportPreview, current: LegacyImportPreview): void {
|
||||||
|
if (
|
||||||
|
!preview ||
|
||||||
|
preview.source?.digest !== current.source.digest ||
|
||||||
|
preview.source?.identity?.device !== current.source.identity.device ||
|
||||||
|
preview.source?.identity?.inode !== current.source.identity.inode
|
||||||
|
)
|
||||||
|
throw new LegacyImportError('Legacy config source changed since preview');
|
||||||
|
if (preview.planDigest !== current.planDigest)
|
||||||
|
throw new LegacyImportError('Legacy import plan digest mismatch');
|
||||||
|
if (current.counts.conflict > 0)
|
||||||
|
throw new LegacyImportError('Legacy import plan contains database conflicts');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmLegacyImport(
|
||||||
|
path: string,
|
||||||
|
preview: LegacyImportPreview,
|
||||||
|
database: Database.Database,
|
||||||
|
): Promise<LegacyImportResult> {
|
||||||
|
const current = await previewLegacyImport(path, database);
|
||||||
|
validateConfirmation(preview, current);
|
||||||
|
// Re-read independently inside confirmation: data used for writes is never taken from the caller preview.
|
||||||
|
const source = await securelyRead(path);
|
||||||
|
if (
|
||||||
|
source.digest !== current.source.digest ||
|
||||||
|
source.identity.device !== current.source.identity.device ||
|
||||||
|
source.identity.inode !== current.source.identity.inode
|
||||||
|
)
|
||||||
|
throw new LegacyImportError('Legacy config changed during confirmation');
|
||||||
|
const normalized = normalize(parseJson(source.bytes));
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const plannedIds = new Set(
|
||||||
|
current.instances.filter(({ status }) => status === 'planned').map(({ id }) => id),
|
||||||
|
);
|
||||||
|
|
||||||
|
database.transaction(() => {
|
||||||
|
const insertInstance = database.prepare(
|
||||||
|
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,1,1,?,?)',
|
||||||
|
);
|
||||||
|
const insertTag = database.prepare(
|
||||||
|
'INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)',
|
||||||
|
);
|
||||||
|
const insertMetadata = database.prepare(
|
||||||
|
'INSERT INTO app_settings (key,value_json,created_at,updated_at) VALUES (?,?,?,?)',
|
||||||
|
);
|
||||||
|
for (const item of normalized) {
|
||||||
|
if (!plannedIds.has(item.id)) continue;
|
||||||
|
insertInstance.run(
|
||||||
|
item.id,
|
||||||
|
item.name,
|
||||||
|
item.baseUrl,
|
||||||
|
item.credentialPresent ? 'none' : item.authMode,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
for (const tag of item.tags) insertTag.run(item.id, tag, now);
|
||||||
|
insertMetadata.run(metadataKey(item.id), metadata(item), now, now);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return deepFreeze({
|
||||||
|
imported: current.counts.planned,
|
||||||
|
unchanged: current.counts.unchanged,
|
||||||
|
secretPending: normalized
|
||||||
|
.filter(({ credentialPresent }) => credentialPresent)
|
||||||
|
.map(({ id }) => id),
|
||||||
|
sourceDigest: current.source.digest,
|
||||||
|
planDigest: current.planDigest,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,5 +10,16 @@ export { startApi } from './start.js';
|
|||||||
export type { StartApiOptions } from './start.js';
|
export type { StartApiOptions } from './start.js';
|
||||||
export { backupDatabase, restoreDatabase } from './infrastructure/database/backup.js';
|
export { backupDatabase, restoreDatabase } from './infrastructure/database/backup.js';
|
||||||
export { openDatabase } from './infrastructure/database/database.js';
|
export { openDatabase } from './infrastructure/database/database.js';
|
||||||
|
export {
|
||||||
|
LegacyImportError,
|
||||||
|
confirmLegacyImport,
|
||||||
|
previewLegacyImport,
|
||||||
|
} from './application/legacy-import/legacy-import.js';
|
||||||
|
export type {
|
||||||
|
LegacyImportInstancePreview,
|
||||||
|
LegacyImportPreview,
|
||||||
|
LegacyImportResult,
|
||||||
|
LegacyImportStatus,
|
||||||
|
} from './application/legacy-import/legacy-import.js';
|
||||||
export { MIGRATIONS, migrateDatabase } from './infrastructure/database/migrations.js';
|
export { MIGRATIONS, migrateDatabase } from './infrastructure/database/migrations.js';
|
||||||
export * as databaseSchema from './infrastructure/database/schema.js';
|
export * as databaseSchema from './infrastructure/database/schema.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user