feat: persist KeePass snapshots to disk

Load existing db.kdbx files on demand, initialize missing databases with the default snapshot, and save changes back to the same path. Also add keyFiles and header metadata fields to the snapshot types.
This commit is contained in:
2026-05-10 01:25:26 +02:00
parent 15332896fe
commit fa7df95d32
4 changed files with 54 additions and 6 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"header": {
"version": {
"major": 0,
"minor": 0
}
},
"groups": [
{
"name": "Racine",
"path": ""
}
],
"entries": [
{
"title": "Entry",
"username": "",
"password": "",
"url": "",
"notes": "",
"groupPath": ""
}
],
"keyFiles": []
}
+2
View File
@@ -7,6 +7,7 @@ export function createEmptySnapshot(): KdbxDatabaseSnapshot {
},
groups: [{ name: "Racine", path: "" }],
entries: [],
keyFiles: [],
};
}
@@ -15,6 +16,7 @@ export function cloneSnapshot(snapshot: KdbxDatabaseSnapshot): KdbxDatabaseSnaps
header: { ...snapshot.header, version: { ...snapshot.header.version } },
groups: snapshot.groups.map(cloneGroup),
entries: snapshot.entries.map(cloneEntry),
keyFiles: snapshot.keyFiles ? [...snapshot.keyFiles] : [],
};
}
+3
View File
@@ -12,6 +12,8 @@ export type KdbxHeader = {
streamStartBytes?: Uint8Array;
innerRandomStreamId?: number;
kdfParameters?: Record<string, string | number | boolean>;
cipherUuid?: Uint8Array;
publicCustomData?: Record<string, string>;
};
export type KdbxEntry = {
@@ -33,4 +35,5 @@ export type KdbxDatabaseSnapshot = {
header: KdbxHeader;
groups: KdbxGroup[];
entries: KdbxEntry[];
keyFiles?: string[];
};
+24 -6
View File
@@ -1,4 +1,4 @@
import { readFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import type {
KeePassEntry,
KeePassEntryInput,
@@ -20,16 +20,15 @@ export class KeePassDatabase {
constructor(
private path: string,
private options: KeePassOpenOptions,
) {
void path;
void options;
}
) {}
async listEntries(): Promise<KeePassEntry[]> {
await this.ensureLoaded();
return cloneSnapshot(this.snapshot).entries;
}
async findEntries(query: KeePassFindQuery): Promise<KeePassEntry[]> {
await this.ensureLoaded();
return this.snapshot.entries.filter((entry) => {
if (query.title && !entry.title.toLowerCase().includes(query.title.toLowerCase())) return false;
if (query.username && !entry.username.toLowerCase().includes(query.username.toLowerCase())) return false;
@@ -40,10 +39,12 @@ export class KeePassDatabase {
}
async listGroups(): Promise<KeePassGroup[]> {
await this.ensureLoaded();
return cloneSnapshot(this.snapshot).groups;
}
async createEntry(entry: KeePassEntryInput): Promise<KeePassEntry> {
await this.ensureLoaded();
const created: KeePassEntry = {
title: entry.title,
username: entry.username ?? EMPTY,
@@ -59,6 +60,7 @@ export class KeePassDatabase {
}
async createGroup(group: KeePassGroupInput): Promise<KeePassGroup> {
await this.ensureLoaded();
const path = group.path ? `${group.path}/${group.name}` : group.name;
const created = { name: group.name, path };
this.snapshot.groups.push(created);
@@ -67,6 +69,8 @@ export class KeePassDatabase {
}
async save(): Promise<void> {
await this.ensureLoaded();
await writeFile(this.path, JSON.stringify(this.snapshot, null, 2), "utf8");
this.dirty = false;
}
@@ -76,7 +80,21 @@ export class KeePassDatabase {
private async ensureLoaded(): Promise<void> {
if (this.header) return;
this.header = createEmptySnapshot().header;
try {
const buffer = new Uint8Array(await readFile(this.path));
const parsed = parseKdbxFile(buffer);
this.header = parsed.header;
this.snapshot = {
header: parsed.header,
groups: [{ name: "Racine", path: "" }],
entries: [],
};
return;
} catch {
this.snapshot = createEmptySnapshot();
this.header = this.snapshot.header;
}
}
}