mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Key rotation re-sealed a single-value backup in place, which holds the stored string, its bytes, the plaintext and the new ciphertext at once; a copy can be over a hundred megabytes and the in-app rotation walked 25 of them per page, enough to run a 1 GB container out of memory. Rotation now converts each such copy into pieces under the active key instead, one row at a time. The ~hlgcm1. stream of v1.38.6 to v1.39.1 is read a megabyte at a time with its tag checked at the end, inside one transaction, so a copy that fails the check is left untouched; the copy keeps its date. The scan reads only the first characters of each value. Measured on a copy of a 2.6 million-reading account (126 MB stored): converted in 25 s with the heap flat at 57 MB under a 200 MB limit, where re-sealing in place ran out of heap. The weekly and manual backup leave an account alone while a restore of its current copy is queued or running, and count it as skipped, not failed. A reader whose copy is replaced mid-read answers 409 backup_changed, "replaced by a newer one while it was being read", rather than the undecryptable error; a restore rolls back with the same code. Pieces are sealed with a fixed associated-data label, so a value the same key sealed for another purpose never opens as a piece; rotation re-seals under the same label. A row holding both a single value and pieces, which only an older release writing after a downgrade produces, is refused with a message saying so, and the next weekly run replaces it. Rotation treats a row deleted between its read and its write as gone rather than an error.
240 lines
8.4 KiB
TypeScript
240 lines
8.4 KiB
TypeScript
/**
|
|
* Read a stored backup (`data_backups` + `data_backup_chunks`) back as its
|
|
* JSON, whichever form it was stored in.
|
|
*
|
|
* Two forms exist. From v1.39.2 a copy is a run of sealed pieces
|
|
* (`backup-chunks.ts`), read here one piece at a time, so a copy of any size
|
|
* passes through this process a megabyte at a time. Before that a copy was one
|
|
* value in `data_backups.data`; `openBackupBlob` still opens those as it
|
|
* always did. Nothing converts the old form: the weekly copy is replaced in
|
|
* the new form by the next weekly run, and an uploaded copy stays readable as
|
|
* it is until someone deletes it.
|
|
*
|
|
* Integrity before anything else. `openStoredBackup` walks every piece once
|
|
* before it hands out a source: each is authenticated, and each has to name
|
|
* this copy, its own position, and (for the last) that it is the last. A copy
|
|
* with a piece missing, moved, altered, borrowed from another copy or cut
|
|
* short is refused there, which is before any restore deletes a row. Every
|
|
* later read through the source checks each piece again as it goes, so a copy
|
|
* replaced while a restore was running fails that read too, and the restore's
|
|
* transaction rolls back rather than writing a mixture.
|
|
*/
|
|
import { Readable, pipeline } from "node:stream";
|
|
import { createGunzip } from "node:zlib";
|
|
|
|
import type { PrismaClient } from "@/generated/prisma/client";
|
|
import {
|
|
BACKUP_UNDECRYPTABLE_CODE,
|
|
BACKUP_UNDECRYPTABLE_ERROR,
|
|
openBackupBlob,
|
|
} from "@/lib/export/backup-blob";
|
|
import {
|
|
BackupIntegrityError,
|
|
openBackupChunk,
|
|
} from "@/lib/export/backup-chunks";
|
|
import type { BackupSource } from "@/lib/export/streamed-backup";
|
|
|
|
/** The columns a reader needs to open a stored copy, in either form. */
|
|
export const STORED_BACKUP_SELECT = {
|
|
id: true,
|
|
userId: true,
|
|
data: true,
|
|
chunkCount: true,
|
|
chunkStreamId: true,
|
|
} as const;
|
|
|
|
export interface StoredBackupRef {
|
|
id: string;
|
|
userId: string;
|
|
data: string | null;
|
|
chunkCount: number | null;
|
|
chunkStreamId: string | null;
|
|
}
|
|
|
|
type ChunkReader = Pick<PrismaClient, "dataBackupChunk" | "dataBackup">;
|
|
|
|
/**
|
|
* Why a row holding both forms is refused. Only an older release writes a
|
|
* single value into a row that already has pieces (after a downgrade), and
|
|
* nothing on the row says which of the two is the newer copy.
|
|
*/
|
|
const BOTH_FORMS_MESSAGE_TEXT =
|
|
"This backup holds both a single stored value and pieces, which happens " +
|
|
"when an older HealthLog version wrote to it after an upgrade. There is no " +
|
|
"way to tell which of the two is current, so it is not read. The next " +
|
|
"weekly backup replaces it with a new copy.";
|
|
|
|
export const BOTH_FORMS_MESSAGE = BOTH_FORMS_MESSAGE_TEXT;
|
|
|
|
/** A row holding both forms (see {@link BOTH_FORMS_MESSAGE}). */
|
|
export class BackupFormsConflictError extends BackupIntegrityError {
|
|
constructor() {
|
|
super(BOTH_FORMS_MESSAGE);
|
|
this.name = "BackupFormsConflictError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The copy was replaced by a newer one (the weekly backup ran) while a
|
|
* restore, preview or download was reading it. Nothing is wrong with either
|
|
* copy; the reader has to start again on the new one.
|
|
*/
|
|
export class BackupReplacedError extends Error {
|
|
constructor() {
|
|
super(
|
|
"The backup was replaced by a newer one while it was being read. " +
|
|
"Nothing was changed; start again to use the new copy.",
|
|
);
|
|
this.name = "BackupReplacedError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A value that changes whenever the stored copy is replaced, for the restore
|
|
* job's check that the copy it was asked to restore is still the one there.
|
|
* For a chunked copy that is its stream id, which a new copy always changes
|
|
* and a key rotation never does; for a single-value copy it is the value.
|
|
*/
|
|
export function storedBackupIdentity(backup: StoredBackupRef): string {
|
|
if (backup.chunkStreamId != null) {
|
|
return `chunks:${backup.chunkStreamId}:${backup.chunkCount ?? ""}`;
|
|
}
|
|
return backup.data ?? "";
|
|
}
|
|
|
|
/** The pieces of one chunked copy, opened and checked, in order. */
|
|
async function* openedChunks(
|
|
prisma: ChunkReader,
|
|
backupId: string,
|
|
streamId: string,
|
|
count: number,
|
|
): AsyncGenerator<Buffer> {
|
|
for (let seq = 0; seq < count; seq++) {
|
|
let piece: Buffer;
|
|
try {
|
|
const row = await prisma.dataBackupChunk.findUnique({
|
|
where: { backupId_seq: { backupId, seq } },
|
|
select: { data: true },
|
|
});
|
|
if (!row) {
|
|
throw new BackupIntegrityError(
|
|
`Piece ${seq} of ${count} of the stored copy is missing.`,
|
|
);
|
|
}
|
|
piece = openBackupChunk(row.data, {
|
|
streamId,
|
|
seq,
|
|
last: seq === count - 1,
|
|
});
|
|
} catch (err) {
|
|
// A piece that no longer fits is either tampering or the copy having
|
|
// been replaced since it was opened. The row tells them apart: a new
|
|
// copy always has a new stream id.
|
|
const now = await prisma.dataBackup.findUnique({
|
|
where: { id: backupId },
|
|
select: { chunkStreamId: true },
|
|
});
|
|
if (now?.chunkStreamId !== streamId) throw new BackupReplacedError();
|
|
throw err;
|
|
}
|
|
yield piece;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a stored copy as a source of its JSON bytes, openable as many times as
|
|
* the caller needs. Rejects when the copy cannot be read whole: a key that is
|
|
* no longer configured, or a copy whose pieces do not add up (see the module
|
|
* comment). Nothing of the JSON is released before that check has passed.
|
|
*/
|
|
export async function openStoredBackup(
|
|
prisma: ChunkReader,
|
|
backup: StoredBackupRef,
|
|
): Promise<BackupSource> {
|
|
if (backup.data != null && backup.chunkStreamId != null) {
|
|
throw new BackupFormsConflictError();
|
|
}
|
|
if ((backup.chunkStreamId == null) !== (backup.chunkCount == null)) {
|
|
throw new BackupIntegrityError(
|
|
"The stored copy lists pieces without saying how many, or the reverse.",
|
|
);
|
|
}
|
|
if (backup.chunkStreamId == null || backup.chunkCount == null) {
|
|
if (backup.data == null) {
|
|
throw new BackupIntegrityError("The stored copy has no content.");
|
|
}
|
|
return openBackupBlob(backup.data);
|
|
}
|
|
const { id, chunkStreamId: streamId, chunkCount: count } = backup;
|
|
if (count < 1) {
|
|
throw new BackupIntegrityError("The stored copy lists no pieces.");
|
|
}
|
|
// Pieces beyond the count would be ignored by the reads below, but they are
|
|
// not something this code ever writes, so their presence is refused too.
|
|
const stored = await prisma.dataBackupChunk.count({
|
|
where: { backupId: id },
|
|
});
|
|
if (stored !== count) {
|
|
const now = await prisma.dataBackup.findUnique({
|
|
where: { id },
|
|
select: { chunkStreamId: true },
|
|
});
|
|
if (now?.chunkStreamId !== streamId) throw new BackupReplacedError();
|
|
throw new BackupIntegrityError(
|
|
`The stored copy lists ${count} pieces but ${stored} are stored.`,
|
|
);
|
|
}
|
|
// The whole copy, checked once before anyone reads a byte of it.
|
|
for await (const piece of openedChunks(prisma, id, streamId, count)) {
|
|
void piece;
|
|
}
|
|
|
|
return () => {
|
|
const gunzip = createGunzip();
|
|
// `pipeline`, not `pipe`: a piece that fails its check has to fail the
|
|
// reader, and `pipe` would leave the reader waiting for an end that never
|
|
// comes.
|
|
pipeline(
|
|
Readable.from(openedChunks(prisma, id, streamId, count)),
|
|
gunzip,
|
|
() => {},
|
|
);
|
|
return gunzip;
|
|
};
|
|
}
|
|
|
|
/** How a route or the restore job answers a stored copy it could not read. */
|
|
export interface StoredBackupRefusal {
|
|
status: 409 | 422;
|
|
/** `backup_changed` or `backup.payload.undecryptable`. */
|
|
code: "backup_changed" | typeof BACKUP_UNDECRYPTABLE_CODE;
|
|
message: string;
|
|
}
|
|
|
|
/**
|
|
* The answer for a failure to open or read a stored copy. A copy replaced
|
|
* while it was read is not damage and says so (409, `backup_changed`); a row
|
|
* holding both forms says why it is not read; everything else is the
|
|
* undecryptable refusal the routes have always given.
|
|
*/
|
|
export function storedBackupRefusal(err: unknown): StoredBackupRefusal {
|
|
if (err instanceof BackupReplacedError) {
|
|
return { status: 409, code: "backup_changed", message: err.message };
|
|
}
|
|
return {
|
|
status: 422,
|
|
code: BACKUP_UNDECRYPTABLE_CODE,
|
|
message:
|
|
err instanceof BackupFormsConflictError
|
|
? `${err.message} Nothing was changed.`
|
|
: BACKUP_UNDECRYPTABLE_ERROR,
|
|
};
|
|
}
|
|
|
|
/** True for the failures `storedBackupRefusal` describes, rather than a bug. */
|
|
export function isStoredBackupReadError(err: unknown): boolean {
|
|
return (
|
|
err instanceof BackupReplacedError || err instanceof BackupIntegrityError
|
|
);
|
|
}
|