Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/desktop/src/main/vault-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { appstash, resolve } from 'appstash';
import { existsSync, promises as fs } from 'fs';
import * as path from 'path';

import type { TotpEntry, VaultStatus } from '../shared/api';
import type { RebuildReport, TotpEntry, VaultStatus } from '../shared/api';

const APP_NAME = 'dcrypt';

Expand Down Expand Up @@ -87,9 +87,9 @@ export class VaultService {
* Re-runs the pgpm deploy into a fresh database and moves every row across,
* so a vault created by an earlier module version picks up schema changes.
*/
async rebuild(): Promise<void> {
async rebuild(): Promise<RebuildReport> {
await this.flush();
await this.current().rebuild(vaultModulePath());
return this.current().rebuild(vaultModulePath());
}

/**
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/renderer/src/screens/SettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,12 @@ export const SettingsScreen = ({ onLocked }: { onLocked: () => void }) => {
const rebuild = async () => {
setBusy(true);
try {
await dcrypt.vault.rebuild();
toast.success('Database rebuilt. Every item was carried over.');
const report = await dcrypt.vault.rebuild();
const rows = Object.values(report.copied).reduce((sum, n) => sum + n, 0);
toast.success(
`Database rebuilt: ${report.tables} tables deployed, ${rows} rows carried over ` +
`(${report.copied.items ?? 0} items).`
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/shared/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ export type BrandIcon =
| { kind: 'logo'; title: string; slug: string; light: string; dark: string }
| { kind: 'glyph'; title: string; slug: string; path: string; hex: string };

/** What a rebuild carried across, so the UI can show it was not a no-op. */
export interface RebuildReport {
tables: number;
copied: Record<string, number>;
}

/**
* The complete surface the renderer can reach. Everything crosses the context
* bridge as plain JSON; secrets flow through only as explicit call results,
Expand All @@ -72,7 +78,7 @@ export interface DcryptApi {
save(): Promise<void>;
changePassphrase(next: string): Promise<void>;
/** Re-deploys the pgpm module into a fresh database, keeping every item. */
rebuild(): Promise<void>;
rebuild(): Promise<RebuildReport>;
/** Deletes the vault and every other file dcrypt keeps on this machine. */
eraseAll(): Promise<void>;
};
Expand Down
2 changes: 2 additions & 0 deletions packages/vault/__tests__/vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ describe('Vault', () => {
await vault.setField(code.id, 'seed', 'totp_seed', 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ');
const before = await vault.totpCode(code.id);

// the rebuild asserts the fresh database really carries the deployed schema,
// so this passing at all rules out a deploy that quietly did nothing
await vault.rebuild(MODULE_PATH);

// same ids, same ciphertext, same passphrase — nothing was re-keyed
Expand Down
41 changes: 37 additions & 4 deletions packages/vault/src/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ import {

const { pgcrypto } = require('@electric-sql/pglite/contrib/pgcrypto');

const VAULT_SCHEMA = 'dcrypt_vault';

/** What a rebuild moved, so the UI can show that it was not a no-op. */
export interface RebuildReport {
/** Tables in the freshly deployed schema that were copied through. */
tables: number;
/** Rows carried across, per table. */
copied: Record<string, number>;
}

const DB_KEY_INFO = 'dcrypt/db-values';
const DB_KEY_SALT_META = 'db_key_salt';

Expand Down Expand Up @@ -145,23 +155,44 @@ export class Vault {
// evict the cached pool so the next open never reaches this instance
await teardownPgPools();
handle.unregister();
await Vault.assertDeployed(handle.db as unknown as PGlite);
// the adapter's PGlite type is resolved through the ESM declarations while
// this CJS build resolves the CTS ones — identical runtime class
return handle.db as unknown as PGlite;
}

/**
* A deploy that decides it has nothing to do would leave an empty database
* that then silently swallows a rebuild, so prove the schema is really there.
*/
private static async assertDeployed(db: PGlite): Promise<void> {
const wanted = COPY_ORDER.map((spec) => spec.table);
const found = await db.query<{ tablename: string }>(
'SELECT tablename FROM pg_tables WHERE schemaname = $1',
[VAULT_SCHEMA]
);
const present = new Set(found.rows.map((row) => row.tablename));
const missing = wanted.filter((table) => !present.has(table));
if (missing.length > 0) {
throw new Error(
`pgpm deployed nothing usable: ${VAULT_SCHEMA} is missing ${missing.join(', ')}`
);
}
}

/**
* Re-deploys the pgpm module into a fresh database and copies every row
* across, so a vault created by an older module picks up schema changes.
* Values move as ciphertext and the key salt is preserved, so no plaintext
* is materialised and the master passphrase still opens the result.
*/
async rebuild(modulePath: string): Promise<void> {
async rebuild(modulePath: string): Promise<RebuildReport> {
const old = this.database;
const next = await Vault.deployFresh(modulePath);
const copied: Record<string, number> = {};
try {
for (const spec of COPY_ORDER) {
await copyTable(old, next, spec);
copied[spec.table] = await copyTable(old, next, spec);
}
// folders were inserted detached to satisfy their self-reference
await reattachFolders(old, next);
Expand All @@ -172,6 +203,7 @@ export class Vault {
this.db = next;
await old.close();
await this.save();
return { tables: COPY_ORDER.length, copied };
}

private static async readDbKeySalt(db: PGlite): Promise<string> {
Expand Down Expand Up @@ -568,7 +600,7 @@ const COPY_ORDER: CopySpec[] = [
},
];

const copyTable = async (from: PGlite, to: PGlite, spec: CopySpec): Promise<void> => {
const copyTable = async (from: PGlite, to: PGlite, spec: CopySpec): Promise<number> => {
const binary = new Set(spec.binary ?? []);
const detached = new Set(spec.detach ?? []);
const selected = spec.columns
Expand All @@ -581,7 +613,7 @@ const copyTable = async (from: PGlite, to: PGlite, spec: CopySpec): Promise<void
const rows = await from.query<Record<string, unknown>>(
`SELECT ${selected} FROM dcrypt_vault.${spec.table}`
);
if (!rows.rows.length) return;
if (!rows.rows.length) return 0;

const placeholders = spec.columns
.map((column, index) => {
Expand All @@ -599,6 +631,7 @@ const copyTable = async (from: PGlite, to: PGlite, spec: CopySpec): Promise<void
spec.columns.map((column) => (detached.has(column) ? null : (row[column] ?? null)))
);
}
return rows.rows.length;
};

/** Second pass for folders, whose parent may be inserted after the child. */
Expand Down
Loading