@celilo/cli 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +18 -2
  3. package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +4 -4
  6. package/src/capabilities/public-web-publish.test.ts +15 -15
  7. package/src/cli/commands/alerts-list.ts +10 -0
  8. package/src/cli/commands/alerts-poll.ts +12 -6
  9. package/src/cli/commands/alerts-sweep.ts +22 -81
  10. package/src/cli/commands/backup-sweep.ts +65 -0
  11. package/src/cli/commands/module-operations.test.ts +93 -0
  12. package/src/cli/commands/module-operations.ts +134 -0
  13. package/src/cli/commands/module-upgrade.test.ts +32 -20
  14. package/src/cli/commands/module-upgrade.ts +37 -32
  15. package/src/cli/commands/monitor.ts +26 -6
  16. package/src/cli/commands/system-audit.ts +9 -68
  17. package/src/cli/completion.ts +18 -1
  18. package/src/cli/index.ts +11 -0
  19. package/src/db/schema.ts +5 -3
  20. package/src/hooks/capability-loader.ts +0 -12
  21. package/src/manifest/schema.ts +4 -1
  22. package/src/module/packaging/build.ts +4 -0
  23. package/src/services/alerting/builtin-source.ts +18 -51
  24. package/src/services/alerting/delivery-loop.test.ts +5 -1
  25. package/src/services/alerting/format.test.ts +0 -1
  26. package/src/services/alerting/inbound-poller.test.ts +44 -8
  27. package/src/services/alerting/inbound-poller.ts +65 -28
  28. package/src/services/alerting/notify-deps.ts +113 -0
  29. package/src/services/alerting/run-monitor.ts +0 -1
  30. package/src/services/alerting/store.test.ts +1 -1
  31. package/src/services/alerting/store.ts +0 -2
  32. package/src/services/alerting/sweep-runner.test.ts +11 -2
  33. package/src/services/alerting/sweep-runner.ts +14 -7
  34. package/src/services/audit/backup-source.ts +54 -0
  35. package/src/services/audit/backups.test.ts +7 -2
  36. package/src/services/audit/backups.ts +10 -18
  37. package/src/services/backup-cipher.test.ts +188 -0
  38. package/src/services/backup-cipher.ts +178 -0
  39. package/src/services/backup-create.ts +20 -30
  40. package/src/services/backup-envelope-roundtrip.test.ts +6 -26
  41. package/src/services/backup-restore.ts +10 -16
  42. package/src/services/backup-schedule.ts +35 -0
  43. package/src/services/backup-sweep.test.ts +148 -0
  44. package/src/services/backup-sweep.ts +124 -0
  45. package/src/services/deploy-posture.ts +15 -2
  46. package/src/services/machine-probe.test.ts +50 -0
  47. package/src/services/machine-probe.ts +73 -0
  48. package/src/services/module-operations.test.ts +67 -6
  49. package/src/services/module-operations.ts +69 -19
  50. package/src/services/module-subscriptions.test.ts +33 -2
  51. package/src/services/module-subscriptions.ts +10 -1
  52. package/src/services/module-validator/typescript-build.test.ts +20 -1
  53. package/src/services/module-validator/typescript-build.ts +9 -5
  54. package/src/services/restore-from-file.ts +6 -21
  55. package/src/templates/generator.test.ts +88 -0
  56. package/src/templates/generator.ts +119 -16
@@ -63,13 +63,16 @@ export interface SweepReport {
63
63
  failed: number;
64
64
  /**
65
65
  * Live alerts nobody is configured to be told about — no escalation policy on
66
- * the monitor, so `notifyDepsFor` returns null.
66
+ * the monitor, so `notifyDepsFor` returns null. Each carries the monitor that
67
+ * owns it, which is the thing an operator has to assign a policy TO.
67
68
  *
68
- * Counted rather than skipped in silence: "nothing needed sending" and "an
69
- * alert is firing and no policy points at anyone" are opposite situations that
70
- * previously rendered identically as `0 notified`.
69
+ * Identified rather than merely counted: `1 no-policy` told an operator that
70
+ * something was unreachable but not WHICH thing, so the remedy the sweep
71
+ * printed alongside it could not be aimed at anything. Assigning the policy to
72
+ * all eighteen monitors then changed nothing observable (#481). A bare count
73
+ * is only half a step better than the silence it replaced.
71
74
  */
72
- noPolicy: number;
75
+ noPolicy: { alertKey: string; monitor: string }[];
73
76
  /**
74
77
  * Deliveries escalation declined, keyed by its reason (`within_grace`,
75
78
  * `no_eligible_route`, …).
@@ -113,7 +116,7 @@ export async function runSweep(
113
116
  deferred: 0,
114
117
  deferredDelivered: 0,
115
118
  failed: 0,
116
- noPolicy: 0,
119
+ noPolicy: [],
117
120
  skipped: {},
118
121
  failures: [],
119
122
  };
@@ -206,10 +209,14 @@ export async function runSweep(
206
209
  }
207
210
 
208
211
  // 5. Notify. Re-read: the steps above changed state under us.
212
+ const monitorTargets = new Map(monitors.map((m) => [m.id, m.target]));
209
213
  for (const alert of loadAllLiveAlerts(db)) {
210
214
  const notifyDeps = deps.notifyDepsFor(alert);
211
215
  if (!notifyDeps) {
212
- report.noPolicy++;
216
+ report.noPolicy.push({
217
+ alertKey: alert.key,
218
+ monitor: monitorTargets.get(alert.monitorId) ?? '(unknown monitor)',
219
+ });
213
220
  continue;
214
221
  }
215
222
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Reading the roster the backup-freshness check runs against.
3
+ *
4
+ * Split out from the check itself so the decision — is this backup too
5
+ * old — stays pure, while the queries that feed it live here (Rule 2.3).
6
+ * Shared by `celilo system audit` and by the scheduled `backups` monitor,
7
+ * so both judge the same fleet from the same data.
8
+ */
9
+
10
+ import { eq } from 'drizzle-orm';
11
+ import type { DbClient } from '../../db/client';
12
+ import { backups, modules } from '../../db/schema';
13
+ import type { ModuleManifest } from '../../manifest/schema';
14
+ import type { InstalledModuleBackupInfo } from './backups';
15
+
16
+ const DEPLOYED_STATES = ['INSTALLED', 'VERIFIED'];
17
+
18
+ /**
19
+ * Most recent COMPLETED backup per module, in epoch ms.
20
+ *
21
+ * The `backups` table is added via inline ALTER statements in
22
+ * db/client.ts for upgraded databases, so a freshly-initialized DB built
23
+ * from the drizzle journal alone may not have it yet. Absence means "no
24
+ * backups recorded", never a crashed audit.
25
+ */
26
+ function latestSuccessfulBackupByModule(db: DbClient): Map<string, number> {
27
+ const latest = new Map<string, number>();
28
+ try {
29
+ for (const backup of db.select().from(backups).where(eq(backups.status, 'completed')).all()) {
30
+ if (!backup.moduleId || !backup.completedAt) continue;
31
+ const at = backup.completedAt.getTime();
32
+ const previous = latest.get(backup.moduleId);
33
+ if (previous === undefined || at > previous) latest.set(backup.moduleId, at);
34
+ }
35
+ } catch {
36
+ // Table missing — leave the map empty.
37
+ }
38
+ return latest;
39
+ }
40
+
41
+ export function loadBackupAuditInfo(db: DbClient): InstalledModuleBackupInfo[] {
42
+ const latest = latestSuccessfulBackupByModule(db);
43
+ return db
44
+ .select()
45
+ .from(modules)
46
+ .all()
47
+ .filter((module) => DEPLOYED_STATES.includes(module.state))
48
+ .map((module) => ({
49
+ id: module.id,
50
+ state: module.state,
51
+ manifest: module.manifestData as ModuleManifest,
52
+ lastSuccessfulBackupAt: latest.get(module.id) ?? null,
53
+ }));
54
+ }
@@ -75,7 +75,10 @@ describe('auditBackups', () => {
75
75
  expect(result).toEqual([]);
76
76
  });
77
77
 
78
- test('no schedule declared treated as manual (no stale flag)', async () => {
78
+ // Silence-by-default is the bug: an undeclared cadence is how celilo-mgmt
79
+ // went 55 days without a backup and nobody was told. Opting out takes an
80
+ // explicit `manual` — see services/backup-schedule.ts.
81
+ test('no schedule declared → daily, so a year-old backup is stale', async () => {
79
82
  const result = await auditBackups({
80
83
  modules: [
81
84
  makeModule('lunacycle', {
@@ -85,7 +88,9 @@ describe('auditBackups', () => {
85
88
  ],
86
89
  now: () => NOW,
87
90
  });
88
- expect(result).toEqual([]);
91
+ expect(result).toHaveLength(1);
92
+ expect(result[0]).toMatchObject({ code: 'backup_stale', subject: 'lunacycle' });
93
+ expect(result[0].message).toContain('daily');
89
94
  });
90
95
 
91
96
  test('daily schedule: 26h-old is stale', async () => {
@@ -8,14 +8,17 @@
8
8
  * scheduled run doesn't flag drift on every audit.
9
9
  *
10
10
  * Modules without an `on_backup` hook are skipped — there's nothing
11
- * to back up. Modules whose schedule is `manual` (or unset) skip the
12
- * staleness check (the user decides cadence) but still get a
13
- * `backup_missing` finding if no backup has ever been recorded.
11
+ * to back up. Modules whose schedule is explicitly `manual` skip the
12
+ * staleness check (the operator decides cadence) but still get a
13
+ * `backup_missing` finding if no backup has ever been recorded. An
14
+ * unset schedule is `daily`, not `manual` — see
15
+ * [[services/backup-schedule.ts]] for why that default matters.
14
16
  *
15
17
  * Time is injected so tests can pin "now" deterministically.
16
18
  */
17
19
 
18
20
  import type { ModuleManifest } from '../../manifest/schema';
21
+ import { effectiveBackupSchedule } from '../backup-schedule';
19
22
  import type { DriftFinding } from './types';
20
23
 
21
24
  export interface InstalledModuleBackupInfo {
@@ -50,10 +53,7 @@ const DAY = 24 * HOUR;
50
53
  * - daily → 25h (24h + 1h grace)
51
54
  * - weekly → 8d (7d + 1d grace)
52
55
  * - monthly → 32d (~30d + 2d grace)
53
- * - manual → null (no staleness check; user-driven cadence)
54
- *
55
- * `undefined` (no `backup:` block in manifest) is treated as
56
- * `manual` — author opted out of declaring a cadence.
56
+ * - manual → null (no staleness check; operator-driven cadence)
57
57
  */
58
58
  const SCHEDULE_THRESHOLDS = {
59
59
  hourly: 2 * HOUR,
@@ -63,21 +63,13 @@ const SCHEDULE_THRESHOLDS = {
63
63
  manual: null,
64
64
  } as const;
65
65
 
66
- type ScheduleKey = keyof typeof SCHEDULE_THRESHOLDS;
67
-
68
66
  function moduleHasBackupHook(manifest: ModuleManifest): boolean {
69
67
  return Boolean(manifest.hooks?.on_backup);
70
68
  }
71
69
 
72
- function scheduleFor(manifest: ModuleManifest): ScheduleKey {
73
- const s = manifest.backup?.schedule;
74
- if (s === 'hourly' || s === 'daily' || s === 'weekly' || s === 'monthly') return s;
75
- return 'manual';
76
- }
77
-
78
70
  function thresholdFor(manifest: ModuleManifest, override: number | undefined): number | null {
79
71
  if (override !== undefined) return override;
80
- return SCHEDULE_THRESHOLDS[scheduleFor(manifest)];
72
+ return SCHEDULE_THRESHOLDS[effectiveBackupSchedule(manifest)];
81
73
  }
82
74
 
83
75
  function formatAge(ms: number): string {
@@ -109,7 +101,7 @@ export async function auditBackups(deps: BackupsAuditDeps): Promise<DriftFinding
109
101
  category: 'backups',
110
102
  severity: 'drift',
111
103
  code: 'backup_missing',
112
- message: `${m.id}: no successful backup recorded (schedule: ${scheduleFor(m.manifest)})`,
104
+ message: `${m.id}: no successful backup recorded (schedule: ${effectiveBackupSchedule(m.manifest)})`,
113
105
  remediation: `celilo backup create ${m.id} --force`,
114
106
  actionable: true,
115
107
  subject: m.id,
@@ -126,7 +118,7 @@ export async function auditBackups(deps: BackupsAuditDeps): Promise<DriftFinding
126
118
  category: 'backups',
127
119
  severity: 'drift',
128
120
  code: 'backup_stale',
129
- message: `${m.id}: last successful backup is ${formatAge(age)} old (schedule: ${scheduleFor(m.manifest)}, threshold: ${formatAge(threshold)})`,
121
+ message: `${m.id}: last successful backup is ${formatAge(age)} old (schedule: ${effectiveBackupSchedule(m.manifest)}, threshold: ${formatAge(threshold)})`,
130
122
  remediation: `celilo backup create ${m.id} --force`,
131
123
  actionable: true,
132
124
  subject: m.id,
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Backup artifact encryption.
3
+ *
4
+ * The load-bearing test here is `writes a streamed artifact, not a JSON
5
+ * envelope`. Everything else could pass while someone quietly reintroduces
6
+ * the whole-file-in-memory path — the format assertion is what goes red if
7
+ * they do, without needing an 800 MB fixture to prove it.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
11
+ import { randomBytes } from 'node:crypto';
12
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { encryptSecret } from '../secrets/encryption';
16
+ import {
17
+ ARTIFACT_FORMAT_VERSION,
18
+ ARTIFACT_MAGIC,
19
+ decryptFileToFile,
20
+ encryptFileToFile,
21
+ isStreamedArtifact,
22
+ } from './backup-cipher';
23
+
24
+ const MASTER_KEY = Buffer.alloc(32, 7);
25
+ const OTHER_KEY = Buffer.alloc(32, 9);
26
+
27
+ let dir: string;
28
+
29
+ beforeEach(() => {
30
+ dir = mkdtempSync(join(tmpdir(), 'celilo-cipher-test-'));
31
+ });
32
+
33
+ afterEach(() => {
34
+ rmSync(dir, { recursive: true, force: true });
35
+ });
36
+
37
+ function paths(name: string) {
38
+ return {
39
+ plain: join(dir, `${name}.tar`),
40
+ enc: join(dir, `${name}.enc`),
41
+ out: join(dir, `${name}.out`),
42
+ };
43
+ }
44
+
45
+ /** An artifact in the pre-streaming format: JSON envelope of base64-of-hex. */
46
+ function writeLegacyArtifact(path: string, payload: Buffer): void {
47
+ writeFileSync(path, JSON.stringify(encryptSecret(payload.toString('base64'), MASTER_KEY)));
48
+ }
49
+
50
+ describe('backup-cipher', () => {
51
+ test('round-trips a payload byte-for-byte', async () => {
52
+ const p = paths('roundtrip');
53
+ const payload = randomBytes(3 * 1024 * 1024);
54
+ writeFileSync(p.plain, payload);
55
+
56
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
57
+ await decryptFileToFile(p.enc, p.out, MASTER_KEY);
58
+
59
+ expect(readFileSync(p.out).equals(payload)).toBe(true);
60
+ });
61
+
62
+ test('writes a streamed artifact, not a JSON envelope', async () => {
63
+ const p = paths('format');
64
+ writeFileSync(p.plain, randomBytes(4096));
65
+
66
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
67
+
68
+ const written = readFileSync(p.enc);
69
+ expect(written.subarray(0, ARTIFACT_MAGIC.length).equals(ARTIFACT_MAGIC)).toBe(true);
70
+ expect(written[ARTIFACT_MAGIC.length]).toBe(ARTIFACT_FORMAT_VERSION);
71
+ expect(isStreamedArtifact(p.enc)).toBe(true);
72
+
73
+ // The old path produced base64-of-hex wrapped in JSON, which is 2.67x
74
+ // the input and starts with '{'. Both are what made it OOM.
75
+ expect(written[0]).not.toBe('{'.charCodeAt(0));
76
+ expect(written.length).toBeLessThan(4096 * 2);
77
+ });
78
+
79
+ test('artifact is barely larger than its plaintext', async () => {
80
+ const p = paths('overhead');
81
+ const size = 1024 * 1024;
82
+ writeFileSync(p.plain, randomBytes(size));
83
+
84
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
85
+
86
+ // header (25) + tag (16). AES-GCM is a stream cipher — no block padding.
87
+ expect(statSync(p.enc).size).toBe(size + 41);
88
+ });
89
+
90
+ test('still reads artifacts in the legacy JSON format', async () => {
91
+ const p = paths('legacy');
92
+ const payload = randomBytes(64 * 1024);
93
+ writeLegacyArtifact(p.enc, payload);
94
+
95
+ expect(isStreamedArtifact(p.enc)).toBe(false);
96
+ await decryptFileToFile(p.enc, p.out, MASTER_KEY);
97
+
98
+ expect(readFileSync(p.out).equals(payload)).toBe(true);
99
+ });
100
+
101
+ test('rejects a wrong master key rather than emitting garbage', async () => {
102
+ const p = paths('wrongkey');
103
+ writeFileSync(p.plain, randomBytes(8192));
104
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
105
+
106
+ expect(decryptFileToFile(p.enc, p.out, OTHER_KEY)).rejects.toThrow();
107
+ });
108
+
109
+ test('rejects tampered ciphertext', async () => {
110
+ const p = paths('tamper');
111
+ writeFileSync(p.plain, randomBytes(8192));
112
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
113
+
114
+ const bytes = readFileSync(p.enc);
115
+ bytes[100] ^= 0xff;
116
+ writeFileSync(p.enc, bytes);
117
+
118
+ expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow();
119
+ });
120
+
121
+ test('rejects a tampered auth tag', async () => {
122
+ const p = paths('tamper-tag');
123
+ writeFileSync(p.plain, randomBytes(8192));
124
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
125
+
126
+ const bytes = readFileSync(p.enc);
127
+ bytes[bytes.length - 1] ^= 0xff;
128
+ writeFileSync(p.enc, bytes);
129
+
130
+ expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow();
131
+ });
132
+
133
+ test('reports a truncated artifact clearly', async () => {
134
+ const p = paths('truncated');
135
+ writeFileSync(p.enc, Buffer.concat([ARTIFACT_MAGIC, Buffer.from([ARTIFACT_FORMAT_VERSION])]));
136
+
137
+ expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow(/truncated/i);
138
+ });
139
+
140
+ test('refuses an artifact written by a newer celilo', async () => {
141
+ const p = paths('future');
142
+ writeFileSync(p.plain, randomBytes(1024));
143
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
144
+
145
+ const bytes = readFileSync(p.enc);
146
+ bytes[ARTIFACT_MAGIC.length] = ARTIFACT_FORMAT_VERSION + 1;
147
+ writeFileSync(p.enc, bytes);
148
+
149
+ expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow(/Upgrade celilo/);
150
+ });
151
+
152
+ test('handles an empty plaintext', async () => {
153
+ const p = paths('empty');
154
+ writeFileSync(p.plain, Buffer.alloc(0));
155
+
156
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
157
+ // Header + tag only — nothing to stream back, and a real tar is never
158
+ // empty, so this is reported rather than silently producing 0 bytes.
159
+ expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow(/no data/i);
160
+ });
161
+
162
+ /**
163
+ * The ceiling the old format could not clear: hex-of-base64 is 2.67 chars
164
+ * per input byte against a ~2^31 max string length, so anything over
165
+ * ~805 MB was unrepresentable regardless of available RAM.
166
+ *
167
+ * Opt-in — it writes ~900 MB to disk and takes tens of seconds, which does
168
+ * not belong in every CI run. Run deliberately after touching this file:
169
+ * CELILO_TEST_LARGE_BACKUP=1 bun test src/services/backup-cipher.test.ts
170
+ */
171
+ test.skipIf(!process.env.CELILO_TEST_LARGE_BACKUP)(
172
+ 'round-trips an artifact past the old format ceiling',
173
+ async () => {
174
+ const p = paths('huge');
175
+ const chunk = randomBytes(1024 * 1024);
176
+ const file = Bun.file(p.plain).writer();
177
+ for (let i = 0; i < 900; i++) file.write(chunk);
178
+ await file.end();
179
+
180
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
181
+ await decryptFileToFile(p.enc, p.out, MASTER_KEY);
182
+
183
+ expect(existsSync(p.out)).toBe(true);
184
+ expect(statSync(p.out).size).toBe(statSync(p.plain).size);
185
+ },
186
+ 300_000,
187
+ );
188
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Encryption for backup artifacts — file in, file out, streamed.
3
+ *
4
+ * Deliberately NOT `encryptSecret`/`decryptSecret`. Those are string-in,
5
+ * string-out and correct for what they were built for: short values headed
6
+ * for a DB column. Backup artifacts are the opposite shape, and running them
7
+ * through a string API cost three full in-memory copies with an expansion
8
+ * factor at each step:
9
+ *
10
+ * read the tar 774 MB Buffer
11
+ * .toString('base64') 1032 MB string
12
+ * encrypt to hex 2064 MB string (hex is 2 chars per byte)
13
+ * JSON.stringify 2064 MB string
14
+ *
15
+ * — about 6.9 GB live for one 774 MB module, which is what OOM-killed the
16
+ * forgejo backup. Worse, it had a ceiling no amount of RAM could raise:
17
+ * hex-of-base64 is 2.67 chars per input byte against a ~2^31 max string
18
+ * length, so the old path simply could not represent a tar over ~805 MB.
19
+ *
20
+ * Here the plaintext never exists in memory at all. `createCipheriv` is a
21
+ * Transform, so file → cipher → file runs in constant memory regardless of
22
+ * artifact size, and both intermediate encodings disappear (the ciphertext
23
+ * is written as raw bytes, so an artifact is now *smaller* than its tar
24
+ * rather than 2.67x larger).
25
+ *
26
+ * On-disk format, all binary:
27
+ *
28
+ * magic 8 bytes "CELILOBK"
29
+ * version 1 byte currently 1
30
+ * iv 16 bytes
31
+ * ciphertext ... streamed
32
+ * auth tag 16 bytes trailer — GCM only produces it after final()
33
+ *
34
+ * The tag has to be a trailer because it does not exist until the last byte
35
+ * has been encrypted, and seeking back to patch a header would mean the
36
+ * writer could no longer be a plain stream. Reading it costs one 16-byte
37
+ * positional read before the stream starts.
38
+ *
39
+ * `decryptFileToFile` also reads the previous format (a JSON envelope of
40
+ * base64-of-hex). The magic bytes are the discriminator: the old writer
41
+ * always emitted JSON, so a file starting with `{` is legacy. The envelope's
42
+ * own schemaVersion cannot serve — it lives *inside* the encrypted tar and
43
+ * is unreadable until after decryption.
44
+ */
45
+
46
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
47
+ import {
48
+ appendFileSync,
49
+ closeSync,
50
+ createReadStream,
51
+ createWriteStream,
52
+ openSync,
53
+ readFileSync,
54
+ readSync,
55
+ statSync,
56
+ writeFileSync,
57
+ } from 'node:fs';
58
+ import { pipeline } from 'node:stream/promises';
59
+ import { decryptSecret } from '../secrets/encryption';
60
+ import { EncryptionEnvelopeSchema, parseJsonWithValidation } from '../validation/schemas';
61
+
62
+ const ALGORITHM = 'aes-256-gcm';
63
+
64
+ /** Identifies a streamed artifact. Legacy artifacts begin with `{`. */
65
+ export const ARTIFACT_MAGIC = Buffer.from('CELILOBK', 'ascii');
66
+
67
+ /** Bumped only for an incompatible layout change; readers reject unknown values. */
68
+ export const ARTIFACT_FORMAT_VERSION = 1;
69
+
70
+ const IV_LENGTH = 16;
71
+ const AUTH_TAG_LENGTH = 16;
72
+ const HEADER_LENGTH = ARTIFACT_MAGIC.length + 1 + IV_LENGTH;
73
+
74
+ /** Read `length` bytes at `offset` without opening a stream. */
75
+ function readBytesAt(path: string, offset: number, length: number): Buffer {
76
+ const buffer = Buffer.alloc(length);
77
+ const fd = openSync(path, 'r');
78
+ try {
79
+ readSync(fd, buffer, 0, length, offset);
80
+ } finally {
81
+ closeSync(fd);
82
+ }
83
+ return buffer;
84
+ }
85
+
86
+ /** Whether this artifact uses the streamed format rather than the JSON envelope. */
87
+ export function isStreamedArtifact(path: string): boolean {
88
+ if (statSync(path).size < ARTIFACT_MAGIC.length) return false;
89
+ return readBytesAt(path, 0, ARTIFACT_MAGIC.length).equals(ARTIFACT_MAGIC);
90
+ }
91
+
92
+ /**
93
+ * Encrypt `srcPath` to `destPath` in constant memory.
94
+ */
95
+ export async function encryptFileToFile(
96
+ srcPath: string,
97
+ destPath: string,
98
+ masterKey: Buffer,
99
+ ): Promise<void> {
100
+ const iv = randomBytes(IV_LENGTH);
101
+ const cipher = createCipheriv(ALGORITHM, masterKey, iv);
102
+
103
+ const out = createWriteStream(destPath);
104
+ out.write(Buffer.concat([ARTIFACT_MAGIC, Buffer.from([ARTIFACT_FORMAT_VERSION]), iv]));
105
+ await pipeline(createReadStream(srcPath), cipher, out);
106
+
107
+ // Available only once the stream has run final(), i.e. after the pipeline
108
+ // resolves. Appending 16 bytes is O(1) and keeps the writer a plain stream.
109
+ appendFileSync(destPath, cipher.getAuthTag());
110
+ }
111
+
112
+ /**
113
+ * Decrypt `srcPath` to `destPath`. Handles both the streamed format and the
114
+ * legacy JSON envelope.
115
+ *
116
+ * Throws on a truncated artifact, an unknown format version, or a failed
117
+ * authentication tag (wrong master key, or tampered/corrupted ciphertext).
118
+ */
119
+ export async function decryptFileToFile(
120
+ srcPath: string,
121
+ destPath: string,
122
+ masterKey: Buffer,
123
+ ): Promise<void> {
124
+ if (!isStreamedArtifact(srcPath)) {
125
+ decryptLegacyArtifact(srcPath, destPath, masterKey);
126
+ return;
127
+ }
128
+
129
+ const size = statSync(srcPath).size;
130
+ const overhead = HEADER_LENGTH + AUTH_TAG_LENGTH;
131
+ if (size < overhead) {
132
+ throw new Error(
133
+ `Backup artifact is truncated: ${size} bytes, but the format needs at least ${overhead}.`,
134
+ );
135
+ }
136
+ if (size === overhead) {
137
+ throw new Error('Backup artifact contains no data (header and auth tag only).');
138
+ }
139
+
140
+ const header = readBytesAt(srcPath, 0, HEADER_LENGTH);
141
+ const version = header[ARTIFACT_MAGIC.length];
142
+ if (version !== ARTIFACT_FORMAT_VERSION) {
143
+ throw new Error(
144
+ `Backup artifact uses format version ${version}, but this celilo understands ${ARTIFACT_FORMAT_VERSION}. Upgrade celilo to restore it.`,
145
+ );
146
+ }
147
+
148
+ const decipher = createDecipheriv(
149
+ ALGORITHM,
150
+ masterKey,
151
+ header.subarray(ARTIFACT_MAGIC.length + 1),
152
+ );
153
+ decipher.setAuthTag(readBytesAt(srcPath, size - AUTH_TAG_LENGTH, AUTH_TAG_LENGTH));
154
+
155
+ // `end` is inclusive, so the last ciphertext byte is the one before the tag.
156
+ await pipeline(
157
+ createReadStream(srcPath, { start: HEADER_LENGTH, end: size - AUTH_TAG_LENGTH - 1 }),
158
+ decipher,
159
+ createWriteStream(destPath),
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Read an artifact written before the streamed format.
165
+ *
166
+ * Reads the whole thing into memory, which is fine precisely because these
167
+ * are the artifacts the old writer produced: it could not emit one much over
168
+ * ~805 MB without dying, so the bound this function relies on is the same bug
169
+ * that motivated the new format. New artifacts never take this path.
170
+ */
171
+ function decryptLegacyArtifact(srcPath: string, destPath: string, masterKey: Buffer): void {
172
+ const envelope = parseJsonWithValidation(
173
+ readFileSync(srcPath, 'utf-8'),
174
+ EncryptionEnvelopeSchema,
175
+ 'backup artifact envelope',
176
+ );
177
+ writeFileSync(destPath, Buffer.from(decryptSecret(envelope, masterKey), 'base64'));
178
+ }