kryptheon-night 0.1.3 → 0.1.5

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.
package/installer.js ADDED
@@ -0,0 +1,224 @@
1
+ // The second door: the engine stays put, and pg_cron calls it at night.
2
+ //
3
+ // Down `npx` the engine is installed into a throwaway schema, used, and
4
+ // dropped inside one command - the connection string never leaves the
5
+ // person's machine, but they have to run it. This door is for the thing they
6
+ // will not do every week. The same functions are installed once, pg_cron runs
7
+ // `nightly()` at three in the morning, and nothing about it needs a
8
+ // credential to move anywhere, because nothing moves: the work happens where
9
+ // the data already is.
10
+ //
11
+ // Everything here is written around one rule, and it is the rule fixture.js
12
+ // learned the expensive way: **only remove what you created**. A database
13
+ // that already had pg_cron in it before Kryptheon arrived must still have it
14
+ // afterwards, and a customer who removes Kryptheon must not be left with a
15
+ // nightly job nobody owns. So install writes down what it made, in a table
16
+ // inside its own schema, and uninstall reads that list before it drops
17
+ // anything.
18
+ //
19
+ // Measured on a real Supabase project before any of this was written:
20
+ //
21
+ // pg_net lands in `public` - the customer's own schema - unless it is told
22
+ // where to go, so it is always told.
23
+ // cron.unschedule is overloaded on (bigint) and (name), and an uncast
24
+ // parameter binds to the name one and fails with "could not find valid
25
+ // entry for job", which reads like the job was already gone.
26
+
27
+ const sqlengine = require('./sqlengine.js');
28
+
29
+ // Where the engine lives when it is staying. A fixed name rather than a
30
+ // timestamped one: the throwaway copies are named for the moment they were
31
+ // made so an abandoned one can be aged and swept, and this is the opposite -
32
+ // it is meant to be found again.
33
+ const SCHEMA = 'kryptheon';
34
+
35
+ // Three in the morning, in whatever the database thinks the time is. Late
36
+ // enough to be quiet, early enough that the answer is waiting before anybody
37
+ // looks.
38
+ const AT = '0 3 * * *';
39
+
40
+ const quote = (name) => '"' + String(name).split('"').join('""') + '"';
41
+
42
+ /** Is this extension already here, and did we have to put it here? */
43
+ async function extensionState(client, name) {
44
+ const { rows } = await client.query(
45
+ 'SELECT installed_version FROM pg_available_extensions WHERE name = $1', [name],
46
+ );
47
+ if (!rows.length) return { available: false, installed: false };
48
+ return { available: true, installed: Boolean(rows[0].installed_version) };
49
+ }
50
+
51
+ /**
52
+ * Puts an extension where it belongs, and says whether it had to.
53
+ *
54
+ * `pg_net` installs into whatever the search_path says unless it is told
55
+ * otherwise, and on Supabase that is `public`. Watching it appear in a
56
+ * customer's own schema is what made this function exist rather than a line
57
+ * of SQL.
58
+ */
59
+ async function ensureExtension(client, name, into) {
60
+ const state = await extensionState(client, name);
61
+ if (!state.available) {
62
+ throw new Error(
63
+ 'this database does not offer ' + name + ', so the nightly run cannot be installed. ' +
64
+ 'Supabase has it; a plain Postgres may not.',
65
+ );
66
+ }
67
+ if (state.installed) return { made: false };
68
+
69
+ const haveSchema = into
70
+ ? (await client.query('SELECT count(*)::int n FROM pg_namespace WHERE nspname = $1', [into])).rows[0].n > 0
71
+ : false;
72
+ await client.query('CREATE EXTENSION ' + name + (haveSchema ? ' WITH SCHEMA ' + quote(into) : ''));
73
+ return { made: true, into: haveSchema ? into : null };
74
+ }
75
+
76
+ /**
77
+ * Installs the engine, schedules the night, and writes down what it made.
78
+ *
79
+ * Safe to run twice: the engine is CREATE OR REPLACE throughout, and the job
80
+ * is replaced rather than added to, so an upgrade is the same operation as an
81
+ * install.
82
+ */
83
+ async function install(client, options) {
84
+ const opts = options || {};
85
+ const schema = opts.schema || SCHEMA;
86
+ const source = opts.source || 'public';
87
+ const at = opts.at || AT;
88
+ const made = { extensions: [], schema: false, jobid: null };
89
+
90
+ // pg_net first: it is the one that lands in the wrong place if nobody
91
+ // says where, and it is easier to take back out before anything depends
92
+ // on it.
93
+ const net = await ensureExtension(client, 'pg_net', 'extensions');
94
+ if (net.made) made.extensions.push('pg_net');
95
+
96
+ const cron = await ensureExtension(client, 'pg_cron', null);
97
+ if (cron.made) made.extensions.push('pg_cron');
98
+
99
+ const existed = (await client.query(
100
+ 'SELECT count(*)::int n FROM pg_namespace WHERE nspname = $1', [schema],
101
+ )).rows[0].n > 0;
102
+ made.schema = !existed;
103
+
104
+ await sqlengine.install(client, schema);
105
+
106
+ // What this install created, kept where uninstall can find it and nowhere
107
+ // else. In the schema it is about to drop, on purpose: if the schema is
108
+ // gone, so is everything this list describes.
109
+ await client.query(
110
+ 'CREATE TABLE IF NOT EXISTS ' + quote(schema) + '.installed (' +
111
+ 'id integer PRIMARY KEY DEFAULT 1, ' +
112
+ 'installed_at timestamptz NOT NULL DEFAULT now(), ' +
113
+ 'source text NOT NULL, ' +
114
+ 'made_extensions text[] NOT NULL DEFAULT \'{}\', ' +
115
+ 'made_schema boolean NOT NULL DEFAULT false, ' +
116
+ 'jobid bigint, ' +
117
+ 'CONSTRAINT only_one CHECK (id = 1))',
118
+ );
119
+
120
+ // Replaced rather than added to. cron.schedule with a name that already
121
+ // exists updates it, but an install that ran twice under two names would
122
+ // attack the same app twice a night for ever.
123
+ const { rows } = await client.query(
124
+ 'SELECT cron.schedule($1, $2, $3) AS jobid',
125
+ ['kryptheon_nightly', at, 'SELECT ' + quote(schema) + '.nightly(' + literal(source) + ')'],
126
+ );
127
+ made.jobid = rows[0].jobid;
128
+
129
+ await client.query(
130
+ 'INSERT INTO ' + quote(schema) + '.installed (id, source, made_extensions, made_schema, jobid) ' +
131
+ 'VALUES (1, $1, $2, $3, $4) ' +
132
+ 'ON CONFLICT (id) DO UPDATE SET source = EXCLUDED.source, jobid = EXCLUDED.jobid, ' +
133
+ 'made_extensions = ' + quote(schema) + '.installed.made_extensions || EXCLUDED.made_extensions, ' +
134
+ 'installed_at = now()',
135
+ [source, made.extensions, made.schema, made.jobid],
136
+ );
137
+
138
+ return { schema: schema, source: source, at: at, jobid: made.jobid, made: made };
139
+ }
140
+
141
+ /** A string, quoted the way Postgres wants it inside another statement. */
142
+ function literal(text) {
143
+ return "'" + String(text).split("'").join("''") + "'";
144
+ }
145
+
146
+ /** What is installed, or null. */
147
+ async function status(client, options) {
148
+ const schema = (options || {}).schema || SCHEMA;
149
+ const here = (await client.query(
150
+ 'SELECT count(*)::int n FROM pg_namespace WHERE nspname = $1', [schema],
151
+ )).rows[0].n > 0;
152
+ if (!here) return null;
153
+
154
+ const { rows } = await client.query(
155
+ 'SELECT source, installed_at, jobid, made_extensions, made_schema FROM ' +
156
+ quote(schema) + '.installed WHERE id = 1',
157
+ ).catch(() => ({ rows: [] }));
158
+ if (!rows.length) return { schema: schema, source: null, jobid: null, unrecorded: true };
159
+
160
+ const job = await client.query(
161
+ 'SELECT jobname, schedule, active FROM cron.job WHERE jobid = $1', [rows[0].jobid],
162
+ ).catch(() => ({ rows: [] }));
163
+
164
+ return Object.assign({ schema: schema }, rows[0], { job: job.rows[0] || null });
165
+ }
166
+
167
+ /**
168
+ * Takes it all away, and only what was put here.
169
+ *
170
+ * The order matters. The job goes first, because a job whose function has
171
+ * been dropped runs every night and fails every night, and pg_cron keeps
172
+ * every one of those failures in a log nobody reads.
173
+ */
174
+ async function uninstall(client, options) {
175
+ const schema = (options || {}).schema || SCHEMA;
176
+ const removed = { job: false, schema: false, extensions: [] };
177
+
178
+ const was = await status(client, { schema: schema });
179
+ if (!was) return removed;
180
+
181
+ if (was.jobid) {
182
+ // Cast on purpose. cron.unschedule is overloaded on (bigint) and (name),
183
+ // and a parameter sent as text binds to the name overload and goes
184
+ // looking for a job CALLED '1'. It fails with "could not find valid entry
185
+ // for job", which reads exactly like the job having already gone - so the
186
+ // uninstall would have reported success and left the job running.
187
+ await client.query('SELECT cron.unschedule($1::bigint)', [was.jobid]).catch(() => {});
188
+ const left = await client.query(
189
+ 'SELECT count(*)::int n FROM cron.job WHERE jobid = $1', [was.jobid],
190
+ ).catch(() => ({ rows: [{ n: 0 }] }));
191
+ if (left.rows[0].n) {
192
+ throw new Error('the nightly job ' + was.jobid + ' is still scheduled, so nothing else was removed');
193
+ }
194
+ removed.job = true;
195
+ }
196
+
197
+ if (was.made_schema !== false) {
198
+ await client.query('DROP SCHEMA IF EXISTS ' + quote(schema) + ' CASCADE');
199
+ removed.schema = true;
200
+ } else {
201
+ // The schema was somebody else's before we arrived. Take the engine's own
202
+ // things out of it and leave the schema itself where it was.
203
+ await sqlengine.uninstall(client, schema).catch(() => {});
204
+ }
205
+
206
+ // Extensions last, and only ours. A database that had pg_cron before
207
+ // Kryptheon arrived must still have it afterwards - and dropping pg_cron
208
+ // would take every other job in it with it.
209
+ for (const ext of was.made_extensions || []) {
210
+ await client.query('DROP EXTENSION IF EXISTS ' + ext).catch(() => {});
211
+ removed.extensions.push(ext);
212
+ }
213
+
214
+ return removed;
215
+ }
216
+
217
+ module.exports = {
218
+ SCHEMA: SCHEMA,
219
+ AT: AT,
220
+ install: install,
221
+ uninstall: uninstall,
222
+ status: status,
223
+ extensionState: extensionState,
224
+ };
package/intro.js CHANGED
@@ -98,6 +98,57 @@ function consentLines(target) {
98
98
  ];
99
99
  }
100
100
 
101
+ /**
102
+ * What staying costs, said before anything is left behind.
103
+ *
104
+ * The scan's consent screen above ends "I do not leave anything behind", and
105
+ * that is true of a scan and false of an install. Reusing it here would be
106
+ * the product lying on the one screen it exists to be honest on, so this is
107
+ * its own text and says the opposite plainly, first.
108
+ *
109
+ * Everything that gets left is named, because "it installs a few things" is
110
+ * the sentence somebody agrees to and then finds a `cron` schema they did not
111
+ * recognise. And the way out is on the screen where they say yes, not in a
112
+ * README they will not read again.
113
+ */
114
+ function installConsentLines(schema, source, at) {
115
+ return [
116
+ 'This one stays. Everything else this tool does is temporary; this is not.',
117
+ '',
118
+ 'What I will leave in your database:',
119
+ '',
120
+ ' - A schema called "' + schema + '", holding the functions that do the',
121
+ ' checking. No data of yours goes in it.',
122
+ '',
123
+ ' - Two Postgres extensions, if they are not already there: pg_cron, which',
124
+ ' runs things on a schedule, and pg_net. Supabase provides both.',
125
+ '',
126
+ ' - One scheduled job, named kryptheon_nightly, set to ' + at + '.',
127
+ '',
128
+ 'What it will do, every night:',
129
+ '',
130
+ ' - Exactly what the scan does now - copy the shape of your "' + source + '"',
131
+ ' tables into a temporary space, put two made-up people in it, attack',
132
+ ' that, write down what got through, and delete the copy.',
133
+ '',
134
+ ' - It still never reads your real data, and still sends nothing anywhere.',
135
+ ' Nothing leaves your database, because the checking happens inside it.',
136
+ '',
137
+ 'One thing it cannot do:',
138
+ '',
139
+ ' - It cannot race two requests against each other, which is how the',
140
+ ' "can this exist twice" question is answered. That needs two',
141
+ ' connections at the same instant, and nothing living inside a database',
142
+ ' has them. The nightly report names every column it could not race.',
143
+ ' Running the command by hand still tests them properly.',
144
+ '',
145
+ 'To take it all away again: npx kryptheon-night uninstall',
146
+ '',
147
+ 'That removes the job, the schema, and any extension I had to add - and',
148
+ 'leaves alone anything that was already here.',
149
+ ];
150
+ }
151
+
101
152
  /** Asks a question and hands back what was typed. */
102
153
  function ask(question) {
103
154
  return new Promise((resolve) => {
@@ -191,6 +242,7 @@ async function askForConnection(say) {
191
242
  module.exports = {
192
243
  whereToFindIt: whereToFindIt,
193
244
  consentLines: consentLines,
245
+ installConsentLines: installConsentLines,
194
246
  ask: ask,
195
247
  askSecret: askSecret,
196
248
  askYesNo: askYesNo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -20,13 +20,16 @@
20
20
  "intro.js",
21
21
  "trouble.js",
22
22
  "connect.js",
23
- "README.md"
23
+ "README.md",
24
+ "installer.js",
25
+ "sqlengine.js",
26
+ "engine.sql"
24
27
  ],
25
28
  "engines": {
26
29
  "node": ">=18.0.0"
27
30
  },
28
31
  "scripts": {
29
- "check": "node package.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node loop.check.js",
32
+ "check": "node package.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node loop.check.js",
30
33
  "check:dry": "node package.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js",
31
34
  "check:package": "node package.check.js",
32
35
  "check:trouble": "node trouble.check.js",
@@ -52,7 +55,9 @@
52
55
  "check:interruption": "node orphan.check.js",
53
56
  "probe:orphan": "node probe-orphan.js",
54
57
  "check:twin": "node twin.check.js",
55
- "prepublishOnly": "node package.check.js"
58
+ "prepublishOnly": "node package.check.js",
59
+ "check:engines": "node engines.check.js",
60
+ "check:installer": "node installer.check.js"
56
61
  },
57
62
  "keywords": [
58
63
  "supabase",
package/scan.js CHANGED
@@ -112,6 +112,35 @@ function suggest(schemas) {
112
112
  worth.map((entry) => entry.schema + ' (' + entry.tables + ')').join(', ');
113
113
  }
114
114
 
115
+ /**
116
+ * The engine, as six operations, so the scan does not know which one it has.
117
+ *
118
+ * `scan.js` named `schema.js`, `attack.js`, `tamper.js` and `orphan.js`
119
+ * directly, which meant the SQL engine could only ever be reached by the twin
120
+ * check. Two engines and one of them never on the real path is how the unused
121
+ * one rots however good the comparison is.
122
+ *
123
+ * Deliberately not included: the collision race. It needs two requests in
124
+ * flight at once, which needs two connections, and no credential ever moves
125
+ * into the database - so the SQL engine cannot have it and says so. The scan
126
+ * keeps racing for real down `openSession` whichever engine it is given, and
127
+ * the report says what could not be raced when there is no second connection.
128
+ * That is the one place the two doors are honestly not the same, and it is
129
+ * named rather than papered over.
130
+ */
131
+ function nodeEngine() {
132
+ return {
133
+ name: 'node',
134
+ readSchema: (client, target) => schema.readSchema(client, target),
135
+ writeSchema: (client, plan, into) => schema.writeSchema(client, plan, into),
136
+ seed: (client, into, tables) => attack.seed(client, into, tables),
137
+ impersonate: (client, into, tables) => attack.impersonate(client, into, tables),
138
+ tamper: (client, into, tables, seeded, policies) =>
139
+ tamper.tamper(client, into, tables, seeded, policies),
140
+ orphan: (client, into, tables, seeded) => orphan.orphan(client, into, tables, seeded),
141
+ };
142
+ }
143
+
115
144
  /**
116
145
  * Runs the whole thing and hands back what got through.
117
146
  *
@@ -122,6 +151,8 @@ async function scan(client, sourceSchema, options) {
122
151
  const opts = options || {};
123
152
  const copyName = 'kn_' + Date.now().toString(36);
124
153
  const say = opts.quiet ? () => {} : line;
154
+ // Whichever engine the caller handed over, or the one built in.
155
+ const engine = opts.engine || nodeEngine();
125
156
 
126
157
  // Anything an earlier run could not clean up after itself. The copy is
127
158
  // dropped in a `finally`, but a `finally` needs a connection: when the link
@@ -147,7 +178,7 @@ async function scan(client, sourceSchema, options) {
147
178
  }
148
179
 
149
180
  say(' Reading the shape of ' + sourceSchema + ' ...');
150
- const plan = await schema.readSchema(client, sourceSchema);
181
+ const plan = await engine.readSchema(client, sourceSchema);
151
182
 
152
183
  if (!plan.tables.length) {
153
184
  // Nothing wrong with the database, but nothing was tested either, and
@@ -171,8 +202,8 @@ async function scan(client, sourceSchema, options) {
171
202
  say(' ' + plan.tables.length + ' tables, ' + plan.policies.length + ' rules. Building a copy ...');
172
203
 
173
204
  try {
174
- await schema.writeSchema(client, plan, copyName);
175
- const copyPlan = await schema.readSchema(client, copyName);
205
+ await engine.writeSchema(client, plan, copyName);
206
+ const copyPlan = await engine.readSchema(client, copyName);
176
207
 
177
208
  // The copy has to be the app, or nothing that follows means anything.
178
209
  const differences = schema.diffSchemas(plan, copyPlan);
@@ -192,7 +223,7 @@ async function scan(client, sourceSchema, options) {
192
223
  // from every attack, and a table nobody attacks has nothing to report.
193
224
  const standIns = new Set((plan.external || []).map((entry) => entry.stub));
194
225
  const theirs = copyPlan.tables.filter((table) => !standIns.has(table.name));
195
- const sown = await attack.seed(client, copyName, theirs);
226
+ const sown = await engine.seed(client, copyName, theirs);
196
227
 
197
228
  // Views are read but never seeded: they have no rows of their own, they
198
229
  // show the rows of the tables underneath. That is exactly why they matter
@@ -206,7 +237,7 @@ async function scan(client, sourceSchema, options) {
206
237
  rlsEnabled: false,
207
238
  isView: true,
208
239
  }));
209
- const impersonation = await attack.impersonate(client, copyName, theirs.concat(views));
240
+ const impersonation = await engine.impersonate(client, copyName, theirs.concat(views));
210
241
 
211
242
  // Every attack genuinely run, named the way a finding is named. The
212
243
  // re-check needs this: a finding that disappears because its attack never
@@ -246,7 +277,7 @@ async function scan(client, sourceSchema, options) {
246
277
  // The copy's own policies, not the app's - they are the same rules, and
247
278
  // the copy is what was actually attacked. The report needs them to say
248
279
  // which rule let a write through rather than assuming one.
249
- const writes = await tamper.tamper(client, copyName, theirs, sown.seeded, copyPlan.policies);
280
+ const writes = await engine.tamper(client, copyName, theirs, sown.seeded, copyPlan.policies);
250
281
  for (const key of writes.completed) attempted.push(key);
251
282
  for (const stuck of writes.blocked) {
252
283
  notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
@@ -254,7 +285,7 @@ async function scan(client, sourceSchema, options) {
254
285
 
255
286
  // Can a half-finished write survive? Rolled back like the writes above.
256
287
  say(' Looking for rows that could point at nothing ...');
257
- const stranded = await orphan.orphan(client, copyName, theirs, sown.seeded);
288
+ const stranded = await engine.orphan(client, copyName, theirs, sown.seeded);
258
289
  for (const key of stranded.completed) attempted.push(key);
259
290
  for (const missed of stranded.notTried) {
260
291
  notChecked.push({
package/sqlengine.js ADDED
@@ -0,0 +1,200 @@
1
+ // Reaching the SQL engine from Node.
2
+ //
3
+ // engine.sql holds the work. This is only the door: put the functions
4
+ // somewhere, call them, take them away again.
5
+ //
6
+ // Two doors lead to the same engine, and this is the one the command line
7
+ // uses. The other is the installer, where the same functions stay put and
8
+ // pg_cron calls them nightly - so anything that happens here must be a thing
9
+ // the scheduler could do too. No cleverness in Node that the database cannot
10
+ // do on its own, or the two doors stop leading to the same place.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ // The token engine.sql carries wherever its own schema name belongs. A token
16
+ // rather than the word "kryptheon" so that replacing it cannot quietly hit a
17
+ // comment or a string that happened to say the same thing.
18
+ const PLACEHOLDER = '__KN__';
19
+
20
+ /** The engine, with its functions addressed to one schema. */
21
+ function engineFor(target) {
22
+ const source = fs.readFileSync(path.join(__dirname, 'engine.sql'), 'utf8');
23
+ if (!source.includes(PLACEHOLDER)) {
24
+ throw new Error('engine.sql has no ' + PLACEHOLDER + ' in it, so it cannot be addressed anywhere');
25
+ }
26
+ return source.split(PLACEHOLDER).join(quote(target));
27
+ }
28
+
29
+ function quote(name) {
30
+ return '"' + String(name).split('"').join('""') + '"';
31
+ }
32
+
33
+ /**
34
+ * Puts the engine somewhere it can be called from.
35
+ *
36
+ * `CREATE OR REPLACE` throughout, so installing twice is not an error and an
37
+ * upgrade is the same operation as an install.
38
+ */
39
+ async function install(client, target) {
40
+ await client.query(engineFor(target));
41
+ return target;
42
+ }
43
+
44
+ /** Takes it away again, functions and all. */
45
+ async function uninstall(client, target) {
46
+ await client.query('DROP SCHEMA IF EXISTS ' + quote(target) + ' CASCADE');
47
+ }
48
+
49
+ /** What the engine says the shape of an app is. */
50
+ async function readSchema(client, target, source) {
51
+ const { rows } = await client.query('SELECT ' + quote(target) + '.read_schema($1) AS shape', [source]);
52
+ return rows[0].shape;
53
+ }
54
+
55
+ /**
56
+ * What the engine would run to rebuild one schema as another.
57
+ *
58
+ * Handed back without running any of it, so the guard can look at every
59
+ * statement before one of them touches the database - and so a check can read
60
+ * what would have happened without anything happening.
61
+ */
62
+ async function copyStatements(client, target, plan, into) {
63
+ const { rows } = await client.query(
64
+ 'SELECT ' + quote(target) + '.copy_statements($1::jsonb, $2) AS statements',
65
+ [JSON.stringify(plan), into],
66
+ );
67
+ return rows[0].statements;
68
+ }
69
+
70
+ /** Builds the copy, and hands back every statement it ran. */
71
+ async function writeSchema(client, target, plan, into) {
72
+ const { rows } = await client.query(
73
+ 'SELECT ' + quote(target) + '.write_schema($1::jsonb, $2) AS statements',
74
+ [JSON.stringify(plan), into],
75
+ );
76
+ return rows[0].statements;
77
+ }
78
+
79
+ /*
80
+ * The attacks.
81
+ *
82
+ * Thin on purpose: each one hands the engine the same arguments its Node twin
83
+ * takes and gives back the same shape, so a caller can be pointed at either
84
+ * without knowing which it has. `twin.check.js` has been calling these through
85
+ * raw SQL since they were written; they are functions here so that `scan.js`
86
+ * can reach them too, which is what makes the two doors one engine rather than
87
+ * one on paper.
88
+ */
89
+
90
+ /** Puts two fake people in the copy, and says what it could not seed. */
91
+ async function seed(client, target, into, tables) {
92
+ const { rows } = await client.query(
93
+ 'SELECT ' + quote(target) + '.seed($1, $2::jsonb) AS answer',
94
+ [into, JSON.stringify(tables)],
95
+ );
96
+ return rows[0].answer;
97
+ }
98
+
99
+ /** Can the wrong person read this? */
100
+ async function impersonate(client, target, into, tables) {
101
+ const { rows } = await client.query(
102
+ 'SELECT ' + quote(target) + '.impersonate($1, $2::jsonb) AS answer',
103
+ [into, JSON.stringify(tables)],
104
+ );
105
+ return rows[0].answer;
106
+ }
107
+
108
+ /** Can a stranger change it? Every write rolled back inside the engine. */
109
+ async function tamper(client, target, into, tables, seeded, policies) {
110
+ const { rows } = await client.query(
111
+ 'SELECT ' + quote(target) + '.tamper($1, $2::jsonb, $3::jsonb, $4::jsonb) AS answer',
112
+ [into, JSON.stringify(tables), JSON.stringify(seeded), JSON.stringify(policies || [])],
113
+ );
114
+ return rows[0].answer;
115
+ }
116
+
117
+ /** Can a half-finished row survive? */
118
+ async function orphan(client, target, into, tables, seeded) {
119
+ const { rows } = await client.query(
120
+ 'SELECT ' + quote(target) + '.orphan($1, $2::jsonb, $3::jsonb) AS answer',
121
+ [into, JSON.stringify(tables), JSON.stringify(seeded)],
122
+ );
123
+ return rows[0].answer;
124
+ }
125
+
126
+ /**
127
+ * Can the same thing exist twice?
128
+ *
129
+ * Inside the database this one cannot actually race anything - two requests at
130
+ * the same instant need two connections, and no credential ever moves - so it
131
+ * comes back with every column it would have tried in `notTried`, with the
132
+ * reason. That is deliberate and is the honest answer: reporting nothing at
133
+ * all would read as safety.
134
+ */
135
+ async function collide(client, target, into, tables, indexes) {
136
+ const { rows } = await client.query(
137
+ 'SELECT ' + quote(target) + '.collide($1, $2::jsonb, $3::jsonb) AS answer',
138
+ [into, JSON.stringify(tables), JSON.stringify(indexes)],
139
+ );
140
+ return rows[0].answer;
141
+ }
142
+
143
+ /**
144
+ * The same six operations `scan.js` asks of an engine, done in SQL.
145
+ *
146
+ * Handed an engine schema that is already installed, because installing and
147
+ * removing it is the caller's business - `withEngine` below - and a scan
148
+ * should not have to know that this engine needs putting somewhere first.
149
+ *
150
+ * `collide` is not among them. The scan races two real requests down two real
151
+ * connections, which is something no engine living inside the database can do;
152
+ * that is measured and written up in STATE.md, and the report names what it
153
+ * could not race rather than leaving it out.
154
+ */
155
+ function adapterFor(target) {
156
+ return {
157
+ name: 'sql',
158
+ readSchema: (client, source) => readSchema(client, target, source),
159
+ writeSchema: (client, plan, into) => writeSchema(client, target, plan, into),
160
+ seed: (client, into, tables) => seed(client, target, into, tables),
161
+ impersonate: (client, into, tables) => impersonate(client, target, into, tables),
162
+ tamper: (client, into, tables, sown, policies) =>
163
+ tamper(client, target, into, tables, sown, policies),
164
+ orphan: (client, into, tables, sown) => orphan(client, target, into, tables, sown),
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Installs the engine, does something with it, and takes it away.
170
+ *
171
+ * The schema is named for the moment it was made, the same as the copy is, so
172
+ * that one abandoned by a dropped connection can be recognised and swept later
173
+ * rather than sitting there for ever.
174
+ */
175
+ async function withEngine(client, work) {
176
+ const target = 'kn_engine_' + Date.now().toString(36);
177
+ await install(client, target);
178
+ try {
179
+ return await work(target);
180
+ } finally {
181
+ await uninstall(client, target).catch(() => {});
182
+ }
183
+ }
184
+
185
+ module.exports = {
186
+ PLACEHOLDER: PLACEHOLDER,
187
+ engineFor: engineFor,
188
+ install: install,
189
+ uninstall: uninstall,
190
+ readSchema: readSchema,
191
+ copyStatements: copyStatements,
192
+ writeSchema: writeSchema,
193
+ seed: seed,
194
+ impersonate: impersonate,
195
+ tamper: tamper,
196
+ orphan: orphan,
197
+ collide: collide,
198
+ adapterFor: adapterFor,
199
+ withEngine: withEngine,
200
+ };