597 lines
22 KiB
JavaScript
597 lines
22 KiB
JavaScript
|
|
const fs = require("node:fs");
|
||
|
|
const path = require("node:path");
|
||
|
|
const crypto = require("node:crypto");
|
||
|
|
|
||
|
|
const BACKUP_DIR_NAME = "vault-backups";
|
||
|
|
const BACKUP_FILE_PREFIX = "vault-backup-";
|
||
|
|
const BACKUP_FILE_EXT = ".json";
|
||
|
|
|
||
|
|
// The renderer is the untrusted input boundary for this bridge, so every
|
||
|
|
// piece of user-controlled data is validated before it reaches disk or
|
||
|
|
// propagates back into the UI. Keep these limits in sync with the
|
||
|
|
// renderer's `sanitizeLocalVaultBackupMaxCount` constants.
|
||
|
|
const MIN_MAX_COUNT = 1;
|
||
|
|
const MAX_MAX_COUNT = 100;
|
||
|
|
const DEFAULT_MAX_COUNT = 20;
|
||
|
|
// 25 MiB — two orders of magnitude above any realistic vault. A payload
|
||
|
|
// exceeding this is either a runaway test harness or a misbehaving/compromised
|
||
|
|
// renderer; refusing here prevents disk-fill DoS. The vault proper is capped
|
||
|
|
// at a much smaller size elsewhere in the app, so legitimate users never hit
|
||
|
|
// this limit.
|
||
|
|
const MAX_PAYLOAD_BYTES = 25 * 1024 * 1024;
|
||
|
|
const ALLOWED_REASONS = new Set(["app_version_change", "before_restore"]);
|
||
|
|
// Version strings are persisted and surfaced in the Settings UI, so they
|
||
|
|
// must not carry control chars that would break logs, parsing, or
|
||
|
|
// display. Keep alphanumerics + a handful of punctuation that covers
|
||
|
|
// SemVer-ish and prerelease tags.
|
||
|
|
const VERSION_STRING_PATTERN = /^[A-Za-z0-9._+\-]{1,64}$/;
|
||
|
|
|
||
|
|
function isPlainObject(value) {
|
||
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Normalize a payload into a form that hashes stably across runs:
|
||
|
|
// - object keys sorted so JSON.stringify output is deterministic
|
||
|
|
// - undefined values dropped (they'd stringify as gaps anyway)
|
||
|
|
// - the TOP-LEVEL `syncedAt` timestamp is zeroed so semantically-equal
|
||
|
|
// payloads produced seconds apart still dedupe. Nested `syncedAt`
|
||
|
|
// fields (e.g. a future per-record mtime) are preserved — zeroing
|
||
|
|
// them would silently collide two semantically-different payloads
|
||
|
|
// into the same fingerprint and cause the version-change / protective
|
||
|
|
// backup dedupe to drop a backup that should have been written.
|
||
|
|
//
|
||
|
|
// INVARIANT: array order is treated as semantically meaningful and is
|
||
|
|
// NOT canonicalized. Every domain array that flows through SyncPayload
|
||
|
|
// (hosts, keys, snippets, identities, portForwardingRules, …) is
|
||
|
|
// produced by a store that iterates its internal `Map`/`Set` in a
|
||
|
|
// stable, insertion-ordered way, so two semantically-equal payloads
|
||
|
|
// built in the same renderer session produce identical orderings. If a
|
||
|
|
// future refactor introduces a non-deterministic iteration source,
|
||
|
|
// fingerprints will flap and the dedupe will miss — sort at the
|
||
|
|
// producer, not here. Sorting inside the hash function would require
|
||
|
|
// choosing a stable key per array type and would silently hide
|
||
|
|
// intentionally-reordered payloads (user dragged a host in the list)
|
||
|
|
// as "the same backup," which would be a safety regression.
|
||
|
|
function normalizePayloadForHash(value, isRoot = true) {
|
||
|
|
if (Array.isArray(value)) {
|
||
|
|
return value.map((item) => normalizePayloadForHash(item, false));
|
||
|
|
}
|
||
|
|
if (isPlainObject(value)) {
|
||
|
|
const entries = Object.entries(value)
|
||
|
|
.filter(([, item]) => item !== undefined)
|
||
|
|
.sort(([a], [b]) => a.localeCompare(b));
|
||
|
|
return entries.reduce((acc, [entryKey, entryValue]) => {
|
||
|
|
acc[entryKey] =
|
||
|
|
isRoot && entryKey === "syncedAt"
|
||
|
|
? 0
|
||
|
|
: normalizePayloadForHash(entryValue, false);
|
||
|
|
return acc;
|
||
|
|
}, {});
|
||
|
|
}
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
|
||
|
|
function stableStringify(value) {
|
||
|
|
return JSON.stringify(normalizePayloadForHash(value));
|
||
|
|
}
|
||
|
|
|
||
|
|
function computePayloadFingerprint(payload) {
|
||
|
|
return crypto
|
||
|
|
.createHash("sha256")
|
||
|
|
.update(stableStringify(payload))
|
||
|
|
.digest("hex");
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildPreview(payload) {
|
||
|
|
return {
|
||
|
|
hostCount: Array.isArray(payload?.hosts) ? payload.hosts.length : 0,
|
||
|
|
keyCount: Array.isArray(payload?.keys) ? payload.keys.length : 0,
|
||
|
|
snippetCount: Array.isArray(payload?.snippets) ? payload.snippets.length : 0,
|
||
|
|
noteCount: Array.isArray(payload?.notes) ? payload.notes.length : 0,
|
||
|
|
identityCount: Array.isArray(payload?.identities) ? payload.identities.length : 0,
|
||
|
|
portForwardingRuleCount: Array.isArray(payload?.portForwardingRules) ? payload.portForwardingRules.length : 0,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function toBackupSummary(record) {
|
||
|
|
return {
|
||
|
|
id: record.id,
|
||
|
|
createdAt: record.createdAt,
|
||
|
|
reason: record.reason,
|
||
|
|
syncDataVersion: record.syncDataVersion,
|
||
|
|
sourceAppVersion: record.sourceAppVersion,
|
||
|
|
targetAppVersion: record.targetAppVersion,
|
||
|
|
preview: record.preview,
|
||
|
|
fingerprint: record.fingerprint,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clamp an unvalidated maxCount to the supported range. Returns
|
||
|
|
// DEFAULT_MAX_COUNT for anything non-finite or non-numeric so callers
|
||
|
|
// without a configured retention still get a sane cap.
|
||
|
|
function sanitizeMaxCount(rawMaxCount) {
|
||
|
|
const numeric = Number(rawMaxCount);
|
||
|
|
if (!Number.isFinite(numeric) || numeric <= 0) return DEFAULT_MAX_COUNT;
|
||
|
|
return Math.max(MIN_MAX_COUNT, Math.min(MAX_MAX_COUNT, Math.floor(numeric)));
|
||
|
|
}
|
||
|
|
|
||
|
|
function sanitizeReason(rawReason) {
|
||
|
|
// Fall back to the "before_restore" default rather than throwing — the
|
||
|
|
// default is the safer label for an unknown-cause backup, since it
|
||
|
|
// implies "this was taken defensively" in the UI.
|
||
|
|
if (typeof rawReason === "string" && ALLOWED_REASONS.has(rawReason)) {
|
||
|
|
return rawReason;
|
||
|
|
}
|
||
|
|
return "before_restore";
|
||
|
|
}
|
||
|
|
|
||
|
|
function sanitizeOptionalVersionString(value) {
|
||
|
|
if (typeof value !== "string") return undefined;
|
||
|
|
const trimmed = value.trim();
|
||
|
|
if (!trimmed) return undefined;
|
||
|
|
if (!VERSION_STRING_PATTERN.test(trimmed)) return undefined;
|
||
|
|
return trimmed;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sync data version is the integer that the CloudSyncManager increments
|
||
|
|
// on each successful cloud sync. Reject anything non-finite, non-positive,
|
||
|
|
// or non-integer so the persisted record only carries meaningful values.
|
||
|
|
function sanitizeOptionalSyncDataVersion(value) {
|
||
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
||
|
|
if (value < 1) return undefined;
|
||
|
|
return Math.floor(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
// UTF-8 byte length of a payload's JSON serialization. Earlier revisions
|
||
|
|
// returned `JSON.stringify(payload).length` (UTF-16 code units), which
|
||
|
|
// under-counted by ~3x for non-ASCII vaults — a deck full of CJK snippet
|
||
|
|
// labels would report ~12.5 MiB against the 25 MiB cap when the on-wire
|
||
|
|
// size was actually 25+ MiB. `Buffer.byteLength(..., 'utf8')` gives the
|
||
|
|
// true bytes-on-disk figure.
|
||
|
|
function estimatePayloadSize(payload) {
|
||
|
|
try {
|
||
|
|
return Buffer.byteLength(JSON.stringify(payload), "utf8");
|
||
|
|
} catch {
|
||
|
|
return Infinity;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Error thrown when the platform has no secure storage available. Backups
|
||
|
|
// would contain plaintext credentials (passwords, private keys, passphrases)
|
||
|
|
// in fields that SyncPayload carries unencrypted, so falling back to a
|
||
|
|
// plain-json file on disk would regress the vault's security posture below
|
||
|
|
// what the normal encrypted localStorage vault provides. We refuse rather
|
||
|
|
// than silently weaken the user's protection.
|
||
|
|
class VaultBackupEncryptionUnavailableError extends Error {
|
||
|
|
constructor() {
|
||
|
|
super(
|
||
|
|
"Secure storage is unavailable on this platform; vault backups cannot be created or read safely.",
|
||
|
|
);
|
||
|
|
this.name = "VaultBackupEncryptionUnavailableError";
|
||
|
|
this.code = "VAULT_BACKUP_ENCRYPTION_UNAVAILABLE";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class VaultBackupTooLargeError extends Error {
|
||
|
|
constructor(size) {
|
||
|
|
super(
|
||
|
|
`Vault backup payload exceeds maximum allowed size (${size} > ${MAX_PAYLOAD_BYTES}).`,
|
||
|
|
);
|
||
|
|
this.name = "VaultBackupTooLargeError";
|
||
|
|
this.code = "VAULT_BACKUP_TOO_LARGE";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function isSafeStorageAvailable(safeStorage) {
|
||
|
|
return Boolean(safeStorage?.isEncryptionAvailable?.());
|
||
|
|
}
|
||
|
|
|
||
|
|
function encodePayload(payload, safeStorage) {
|
||
|
|
if (!isSafeStorageAvailable(safeStorage)) {
|
||
|
|
throw new VaultBackupEncryptionUnavailableError();
|
||
|
|
}
|
||
|
|
const raw = JSON.stringify(payload);
|
||
|
|
return {
|
||
|
|
encoding: "safeStorage-v1",
|
||
|
|
data: safeStorage.encryptString(raw).toString("base64"),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function decodePayload(record, safeStorage) {
|
||
|
|
if (record.payloadEncoding === "safeStorage-v1") {
|
||
|
|
if (!safeStorage?.decryptString || !isSafeStorageAvailable(safeStorage)) {
|
||
|
|
throw new VaultBackupEncryptionUnavailableError();
|
||
|
|
}
|
||
|
|
const decrypted = safeStorage.decryptString(Buffer.from(record.payloadData, "base64"));
|
||
|
|
return JSON.parse(decrypted);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Legacy "plain-json-v1" records may exist from an earlier build; read
|
||
|
|
// them once so users can migrate their data, but never write new ones.
|
||
|
|
if (record.payloadEncoding === "plain-json-v1") {
|
||
|
|
return JSON.parse(record.payloadData);
|
||
|
|
}
|
||
|
|
|
||
|
|
throw new Error(`Unsupported vault backup encoding: ${record.payloadEncoding}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Upper bound for a backup file on disk. The plaintext payload is capped
|
||
|
|
// at MAX_PAYLOAD_BYTES on write; the encrypted-and-base64-encoded record
|
||
|
|
// plus JSON envelope inflates that by ~2x worst case (base64 adds ~33%,
|
||
|
|
// JSON formatting adds some, and the record metadata rounds up). A 2x
|
||
|
|
// multiplier leaves comfortable headroom for legitimate backups while
|
||
|
|
// still rejecting a 100+ MiB file that a user (or attacker) dropped
|
||
|
|
// into the backup directory manually.
|
||
|
|
const MAX_BACKUP_FILE_BYTES = MAX_PAYLOAD_BYTES * 2;
|
||
|
|
|
||
|
|
async function readBackupRecord(filePath) {
|
||
|
|
// Refuse oversized files BEFORE readFile. `fs.readFile` buffers the
|
||
|
|
// whole file into memory, so an attacker (or a corrupted state) that
|
||
|
|
// places a huge file in the backup dir could OOM the renderer during
|
||
|
|
// listBackups enumeration. Stat-then-read keeps the failure mode to
|
||
|
|
// a cheap rejection.
|
||
|
|
let stat;
|
||
|
|
try {
|
||
|
|
stat = await fs.promises.stat(filePath);
|
||
|
|
} catch (error) {
|
||
|
|
throw new Error(`Unable to stat vault backup ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
||
|
|
}
|
||
|
|
if (stat.size > MAX_BACKUP_FILE_BYTES) {
|
||
|
|
throw new VaultBackupTooLargeError(stat.size);
|
||
|
|
}
|
||
|
|
const raw = await fs.promises.readFile(filePath, "utf8");
|
||
|
|
const parsed = JSON.parse(raw);
|
||
|
|
if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") {
|
||
|
|
throw new Error(`Invalid vault backup record: ${filePath}`);
|
||
|
|
}
|
||
|
|
return parsed;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function listBackupRecords(dirPath) {
|
||
|
|
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
|
||
|
|
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||
|
|
const records = [];
|
||
|
|
|
||
|
|
for (const entry of entries) {
|
||
|
|
if (!entry.isFile()) continue;
|
||
|
|
if (!entry.name.startsWith(BACKUP_FILE_PREFIX) || !entry.name.endsWith(BACKUP_FILE_EXT)) continue;
|
||
|
|
const fullPath = path.join(dirPath, entry.name);
|
||
|
|
try {
|
||
|
|
const record = await readBackupRecord(fullPath);
|
||
|
|
records.push({ record, filePath: fullPath });
|
||
|
|
} catch (error) {
|
||
|
|
console.warn("[vaultBackupBridge] Failed to parse backup:", fullPath, error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
records.sort((a, b) => {
|
||
|
|
const aTime = Number(a.record.createdAt || 0);
|
||
|
|
const bTime = Number(b.record.createdAt || 0);
|
||
|
|
if (aTime !== bTime) return bTime - aTime;
|
||
|
|
// Stable, deterministic tiebreak when two backups share a millisecond
|
||
|
|
// (rapid successive creates, clock quantization). Without this the
|
||
|
|
// retention trimmer's "delete the oldest" pass is order-dependent and
|
||
|
|
// can drop a different record across list() → prune() passes.
|
||
|
|
const aId = String(a.record.id || '');
|
||
|
|
const bId = String(b.record.id || '');
|
||
|
|
return bId.localeCompare(aId);
|
||
|
|
});
|
||
|
|
|
||
|
|
return records;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Delete old backups, trusting the caller-provided `records` list when
|
||
|
|
// supplied to avoid a redundant directory scan. `createBackup` has just
|
||
|
|
// scanned + written, so it passes its freshly-enumerated records through
|
||
|
|
// here. External callers (retention-change UI, trim IPC) rescan.
|
||
|
|
async function pruneBackupRecords(dirPath, maxCount, records = null) {
|
||
|
|
const sanitizedMaxCount = sanitizeMaxCount(maxCount);
|
||
|
|
const sourceRecords = records ?? (await listBackupRecords(dirPath));
|
||
|
|
const toDelete = sourceRecords.slice(sanitizedMaxCount);
|
||
|
|
let deletedCount = 0;
|
||
|
|
|
||
|
|
for (const entry of toDelete) {
|
||
|
|
try {
|
||
|
|
await fs.promises.unlink(entry.filePath);
|
||
|
|
deletedCount += 1;
|
||
|
|
} catch (error) {
|
||
|
|
console.warn("[vaultBackupBridge] Failed to delete old backup:", entry.filePath, error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
deletedCount,
|
||
|
|
keptCount: Math.min(sourceRecords.length, sanitizedMaxCount),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function createVaultBackupService({ app, safeStorage, shell }) {
|
||
|
|
if (!app?.getPath) {
|
||
|
|
throw new Error("Electron app is unavailable.");
|
||
|
|
}
|
||
|
|
|
||
|
|
const getBackupDir = () => path.join(app.getPath("userData"), BACKUP_DIR_NAME);
|
||
|
|
|
||
|
|
// Serialize createBackup so two concurrent calls (version-change backup
|
||
|
|
// running at startup + an explicit protective-before-restore triggered
|
||
|
|
// by the user's click, etc.) observe each other's writes. Without this,
|
||
|
|
// both observers would see an empty directory, compute the same
|
||
|
|
// fingerprint, skip the dedupe, and write two identical files.
|
||
|
|
let createBackupLock = Promise.resolve();
|
||
|
|
// Monotonically increasing `createdAt` per service instance. `Date.now()`
|
||
|
|
// has 1ms resolution and back-to-back async calls (version-change backup
|
||
|
|
// followed immediately by a protective backup) can land in the same
|
||
|
|
// millisecond, producing ties that `listBackupRecords` cannot resolve
|
||
|
|
// (the sort has no tiebreaker). Bumping ensures strict ordering so
|
||
|
|
// callers always see the true newest record first.
|
||
|
|
let lastCreatedAt = 0;
|
||
|
|
|
||
|
|
return {
|
||
|
|
isEncryptionAvailable() {
|
||
|
|
return isSafeStorageAvailable(safeStorage);
|
||
|
|
},
|
||
|
|
|
||
|
|
async createBackup(options = {}) {
|
||
|
|
const next = createBackupLock.then(() => doCreateBackup(options));
|
||
|
|
// Swallow the rejection on the lock chain so one caller's error
|
||
|
|
// does not poison subsequent calls; each individual await sees its
|
||
|
|
// own rejection via the `next` return.
|
||
|
|
createBackupLock = next.catch(() => undefined);
|
||
|
|
return next;
|
||
|
|
},
|
||
|
|
|
||
|
|
async listBackups() {
|
||
|
|
const records = await listBackupRecords(getBackupDir());
|
||
|
|
return records.map(({ record }) => toBackupSummary(record));
|
||
|
|
},
|
||
|
|
|
||
|
|
async readBackup(options = {}) {
|
||
|
|
const backupId = typeof options.id === "string" ? options.id : "";
|
||
|
|
if (!backupId) {
|
||
|
|
throw new Error("Missing vault backup id.");
|
||
|
|
}
|
||
|
|
|
||
|
|
const records = await listBackupRecords(getBackupDir());
|
||
|
|
const match = records.find(({ record }) => record.id === backupId);
|
||
|
|
if (!match) {
|
||
|
|
throw new Error("Vault backup not found.");
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
backup: toBackupSummary(match.record),
|
||
|
|
payload: decodePayload(match.record, safeStorage),
|
||
|
|
};
|
||
|
|
},
|
||
|
|
|
||
|
|
async trimBackups(options = {}) {
|
||
|
|
return pruneBackupRecords(getBackupDir(), options.maxCount);
|
||
|
|
},
|
||
|
|
|
||
|
|
async openBackupDir() {
|
||
|
|
const dirPath = getBackupDir();
|
||
|
|
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
|
||
|
|
if (shell?.openPath) {
|
||
|
|
const errorMessage = await shell.openPath(dirPath);
|
||
|
|
if (errorMessage) {
|
||
|
|
throw new Error(errorMessage);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
path: dirPath,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
async function doCreateBackup(options) {
|
||
|
|
const payload = options.payload;
|
||
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||
|
|
throw new Error("Missing vault backup payload.");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Refuse early when the payload is too large to prevent a
|
||
|
|
// misbehaving or compromised renderer from filling the disk. The
|
||
|
|
// check runs before any side effect so callers see a deterministic
|
||
|
|
// failure rather than a partial write.
|
||
|
|
const estimatedSize = estimatePayloadSize(payload);
|
||
|
|
if (estimatedSize > MAX_PAYLOAD_BYTES) {
|
||
|
|
throw new VaultBackupTooLargeError(estimatedSize);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Refuse before doing anything side-effectful so callers get a clear
|
||
|
|
// error rather than a silently-weakened plaintext backup.
|
||
|
|
if (!isSafeStorageAvailable(safeStorage)) {
|
||
|
|
throw new VaultBackupEncryptionUnavailableError();
|
||
|
|
}
|
||
|
|
|
||
|
|
const dirPath = getBackupDir();
|
||
|
|
const existingRecords = await listBackupRecords(dirPath);
|
||
|
|
const fingerprint = computePayloadFingerprint(payload);
|
||
|
|
const latest = existingRecords[0]?.record ?? null;
|
||
|
|
if (latest?.fingerprint === fingerprint) {
|
||
|
|
return {
|
||
|
|
created: false,
|
||
|
|
backup: toBackupSummary(latest),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
let createdAt = Date.now();
|
||
|
|
if (createdAt <= lastCreatedAt) createdAt = lastCreatedAt + 1;
|
||
|
|
lastCreatedAt = createdAt;
|
||
|
|
const id = crypto.randomUUID();
|
||
|
|
const preview = buildPreview(payload);
|
||
|
|
const encoded = encodePayload(payload, safeStorage);
|
||
|
|
const record = {
|
||
|
|
formatVersion: 1,
|
||
|
|
id,
|
||
|
|
createdAt,
|
||
|
|
reason: sanitizeReason(options.reason),
|
||
|
|
syncDataVersion: sanitizeOptionalSyncDataVersion(options.syncDataVersion),
|
||
|
|
sourceAppVersion: sanitizeOptionalVersionString(options.sourceAppVersion),
|
||
|
|
targetAppVersion: sanitizeOptionalVersionString(options.targetAppVersion),
|
||
|
|
fingerprint,
|
||
|
|
preview,
|
||
|
|
payloadEncoding: encoded.encoding,
|
||
|
|
payloadData: encoded.data,
|
||
|
|
};
|
||
|
|
|
||
|
|
const filePath = path.join(
|
||
|
|
dirPath,
|
||
|
|
`${BACKUP_FILE_PREFIX}${createdAt}-${id}${BACKUP_FILE_EXT}`,
|
||
|
|
);
|
||
|
|
// Durable atomic write: serialize to a sibling tmp file, fsync the
|
||
|
|
// file's data+metadata to stable storage, rename into place, then
|
||
|
|
// fsync the directory entry itself. Without the file fsync a system
|
||
|
|
// crash between writeFile and rename can leave the OS with a
|
||
|
|
// successfully-renamed entry whose data blocks are still only in
|
||
|
|
// page cache — the file is visible but reads back as zeros or torn
|
||
|
|
// content. Without the directory fsync the rename itself may not be
|
||
|
|
// durable: on recovery listBackups sees an empty directory even
|
||
|
|
// though the file's blocks made it to disk. Both matter for the
|
||
|
|
// protective-before-restore case, where the user is about to
|
||
|
|
// overwrite their vault and the safety net MUST survive a crash
|
||
|
|
// between backup and restore.
|
||
|
|
const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`;
|
||
|
|
let tmpHandle;
|
||
|
|
try {
|
||
|
|
tmpHandle = await fs.promises.open(tmpPath, 'w', 0o600);
|
||
|
|
await tmpHandle.writeFile(`${JSON.stringify(record, null, 2)}\n`);
|
||
|
|
await tmpHandle.sync();
|
||
|
|
} finally {
|
||
|
|
if (tmpHandle) {
|
||
|
|
try {
|
||
|
|
await tmpHandle.close();
|
||
|
|
} catch {
|
||
|
|
/* ignore — close failure after successful sync still leaves
|
||
|
|
data durable on disk */
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await fs.promises.rename(tmpPath, filePath);
|
||
|
|
} catch (renameError) {
|
||
|
|
// Best-effort cleanup; swallow unlink errors so the rename error
|
||
|
|
// surfaces to the caller.
|
||
|
|
try {
|
||
|
|
await fs.promises.unlink(tmpPath);
|
||
|
|
} catch {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
throw renameError;
|
||
|
|
}
|
||
|
|
// fsync the directory so the rename itself is durably recorded.
|
||
|
|
// On Linux this is required; on macOS it is a no-op at the FS
|
||
|
|
// layer but still safe and portable. On Windows fs.open on a
|
||
|
|
// directory is not supported — the rename is durable as part of
|
||
|
|
// NTFS's journal, so skip the sync there.
|
||
|
|
if (process.platform !== 'win32') {
|
||
|
|
let dirHandle;
|
||
|
|
try {
|
||
|
|
dirHandle = await fs.promises.open(dirPath, 'r');
|
||
|
|
await dirHandle.sync();
|
||
|
|
} catch (dirSyncError) {
|
||
|
|
// Directory fsync is a defense-in-depth hardening step — if
|
||
|
|
// the filesystem refuses (tmpfs, some network mounts) the
|
||
|
|
// rename already happened and the file is reachable, so a
|
||
|
|
// failure here should not abort the backup. Log so a
|
||
|
|
// systematic issue is diagnosable.
|
||
|
|
console.warn('[vaultBackupBridge] Directory fsync failed:', dirSyncError);
|
||
|
|
} finally {
|
||
|
|
if (dirHandle) {
|
||
|
|
try {
|
||
|
|
await dirHandle.close();
|
||
|
|
} catch {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Reuse the enumeration we already did for dedupe, prepending the
|
||
|
|
// newly-written record so pruneBackupRecords can trim without
|
||
|
|
// re-scanning the directory. Records are ordered newest-first.
|
||
|
|
const nextRecords = [{ record, filePath }, ...existingRecords];
|
||
|
|
await pruneBackupRecords(dirPath, options.maxCount, nextRecords);
|
||
|
|
|
||
|
|
return {
|
||
|
|
created: true,
|
||
|
|
backup: toBackupSummary(record),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function registerHandlers(ipcMain, electronModule) {
|
||
|
|
const service = createVaultBackupService({
|
||
|
|
app: electronModule?.app,
|
||
|
|
safeStorage: electronModule?.safeStorage,
|
||
|
|
shell: electronModule?.shell,
|
||
|
|
});
|
||
|
|
|
||
|
|
const BrowserWindow = electronModule?.BrowserWindow;
|
||
|
|
|
||
|
|
// Broadcast a backup-changed event to every renderer so other windows
|
||
|
|
// (notably the Settings window's backup list) can refresh without the
|
||
|
|
// user manually navigating. Any successful create / trim path calls
|
||
|
|
// this. Failures fall through silently — a dropped notification is
|
||
|
|
// recoverable on the next manual refresh, while re-throwing here
|
||
|
|
// would turn a harmless broadcast failure into a user-visible error.
|
||
|
|
const broadcastBackupsChanged = () => {
|
||
|
|
if (!BrowserWindow?.getAllWindows) return;
|
||
|
|
try {
|
||
|
|
for (const win of BrowserWindow.getAllWindows()) {
|
||
|
|
if (win.isDestroyed?.()) continue;
|
||
|
|
try {
|
||
|
|
win.webContents?.send?.("netcatty:vaultBackups:changed");
|
||
|
|
} catch (error) {
|
||
|
|
console.warn("[vaultBackupBridge] Failed to notify window:", error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.warn("[vaultBackupBridge] Broadcast failed:", error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
ipcMain.handle("netcatty:vaultBackups:capabilities", async () => {
|
||
|
|
return { encryptionAvailable: service.isEncryptionAvailable() };
|
||
|
|
});
|
||
|
|
ipcMain.handle("netcatty:vaultBackups:create", async (_event, payload) => {
|
||
|
|
const result = await service.createBackup(payload || {});
|
||
|
|
// Only broadcast when a new record was actually written; a
|
||
|
|
// deduped (created=false) return means the on-disk state did not
|
||
|
|
// change, so other windows already show the latest backup.
|
||
|
|
if (result?.created) {
|
||
|
|
broadcastBackupsChanged();
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
});
|
||
|
|
ipcMain.handle("netcatty:vaultBackups:list", async () => {
|
||
|
|
return service.listBackups();
|
||
|
|
});
|
||
|
|
ipcMain.handle("netcatty:vaultBackups:read", async (_event, payload) => {
|
||
|
|
return service.readBackup(payload || {});
|
||
|
|
});
|
||
|
|
ipcMain.handle("netcatty:vaultBackups:trim", async (_event, payload) => {
|
||
|
|
const result = await service.trimBackups(payload || {});
|
||
|
|
if (result?.deletedCount) {
|
||
|
|
broadcastBackupsChanged();
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
});
|
||
|
|
ipcMain.handle("netcatty:vaultBackups:openDir", async () => {
|
||
|
|
return service.openBackupDir();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
BACKUP_DIR_NAME,
|
||
|
|
BACKUP_FILE_EXT,
|
||
|
|
BACKUP_FILE_PREFIX,
|
||
|
|
MAX_PAYLOAD_BYTES,
|
||
|
|
VaultBackupEncryptionUnavailableError,
|
||
|
|
VaultBackupTooLargeError,
|
||
|
|
buildPreview,
|
||
|
|
computePayloadFingerprint,
|
||
|
|
createVaultBackupService,
|
||
|
|
registerHandlers,
|
||
|
|
};
|