kryptheon-night 0.1.2 → 0.1.4

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.2",
3
+ "version": "0.1.4",
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/schema.js CHANGED
@@ -530,10 +530,23 @@ function qualifyOwnRefs(definition, plan, target) {
530
530
  // A replacer function, never a replacement string: `$&` and friends are read
531
531
  // as instructions inside one, and that has already cost this project an
532
532
  // engine that would not install.
533
+ // A quoted identifier can hold anything, including spaces and quotes of its
534
+ // own doubled up, so it is matched as a whole rather than as a run of safe
535
+ // characters. The first attempt used [^".\s(]+ inside the quotes and
536
+ // silently did not match `REFERENCES "Group Table"(...)` - which the twin
537
+ // fixture has, because it was built out of every shape that has ever been
538
+ // read wrong, and it caught this the first time it ran.
539
+ //
540
+ // The unquoted alternative excludes a dot, so a name that is already
541
+ // schema-qualified is left alone: that is the other rewrite's work.
533
542
  return String(definition).replace(
534
- /(\bREFERENCES\s+)("?)([^".\s(]+)\2(\s*\()/gi,
535
- (whole, before, quoteMark, name, after) =>
536
- (own.has(name) ? before + quote(target) + '.' + quote(name) + after : whole),
543
+ /(\bREFERENCES\s+)("(?:[^"]|"")*"|[^\s(".]+)(\s*\()/gi,
544
+ (whole, before, written, after) => {
545
+ const name = written.charAt(0) === '"'
546
+ ? written.slice(1, -1).split('""').join('"')
547
+ : written;
548
+ return own.has(name) ? before + quote(target) + '.' + quote(name) + after : whole;
549
+ },
537
550
  );
538
551
  }
539
552
 
@@ -809,8 +822,37 @@ async function writeSchema(client, plan, target) {
809
822
  }
810
823
 
811
824
  mustStayInside(statements, target);
812
- for (const statement of statements) {
813
- await client.query(statement);
825
+ // Built with the copy first on the search_path, so a bare name binds to the
826
+ // copy and not to whatever the customer happens to have.
827
+ //
828
+ // This is the structural half, and it went in after chasing the same bug
829
+ // through three different kinds of expression. Postgres writes a name
830
+ // without its schema whenever that name is already reachable - so a real app
831
+ // in `public` hands back "REFERENCES profiles(id)" and
832
+ // "nextval('orders_id_seq'::regclass)", and a rewrite that goes looking for
833
+ // a schema name finds nothing to change in either. Replayed into the copy,
834
+ // both bound to the customer's own objects: the foreign keys pointed at
835
+ // their tables, and the copy drew its keys from their sequences, which
836
+ // advanced them.
837
+ //
838
+ // Rewriting each kind of expression in turn is a game with no end - index
839
+ // predicates, checks that call a function, view bodies. Naming the copy
840
+ // first on the path ends all of them at once, because it changes what a
841
+ // bare name means rather than trying to find every place one can appear.
842
+ //
843
+ // `public` stays on the path, after the copy, because the copy legitimately
844
+ // needs what lives there - gen_random_uuid() and the like. `extensions` is
845
+ // where Supabase keeps them; a schema on the path that does not exist is
846
+ // ignored rather than an error.
847
+ const { rows: pathRows } = await client.query('SHOW search_path');
848
+ const restoreTo = pathRows[0].search_path;
849
+ await client.query('SET search_path TO ' + quote(target) + ', public, extensions');
850
+ try {
851
+ for (const statement of statements) {
852
+ await client.query(statement);
853
+ }
854
+ } finally {
855
+ await client.query('SET search_path TO ' + restoreTo).catch(() => {});
814
856
  }
815
857
 
816
858
  // And then look at what was actually built, rather than at what was meant.
@@ -825,20 +867,41 @@ async function writeSchema(client, plan, target) {
825
867
  // Nothing is written to a table outside the copy either way. The damage is
826
868
  // subtler than that: a copy tied to the customer's rows is not the app being
827
869
  // attacked, and every verdict taken from it is about something else.
870
+ // Foreign keys AND column defaults, because the second one is where this
871
+ // hid after the first was closed: a serial column's default is
872
+ // nextval('<sequence>'), and the copy was calling the customer's. Nothing
873
+ // was written to their tables, but nextval advances a sequence, so their
874
+ // database changed - and "we never touch your live app" says at all.
875
+ //
876
+ // Asked of pg_depend rather than of the text, so it holds for any
877
+ // expression that ends up pointing at a relation, not only the ones anybody
878
+ // thought to look at. pg_catalog is excluded because everything depends on
879
+ // it; anything else outside the copy is the bug.
828
880
  const { rows: strays } = await client.query(
829
- `SELECT con.conname, rn.nspname AS points_at
881
+ `SELECT con.conname AS what, rn.nspname AS points_at
830
882
  FROM pg_constraint con
831
883
  JOIN pg_class cl ON cl.oid = con.conrelid
832
884
  JOIN pg_namespace cn ON cn.oid = cl.relnamespace
833
885
  JOIN pg_class rc ON rc.oid = con.confrelid
834
886
  JOIN pg_namespace rn ON rn.oid = rc.relnamespace
835
- WHERE con.contype = 'f' AND cn.nspname = $1 AND rn.nspname <> $1`,
887
+ WHERE con.contype = 'f' AND cn.nspname = $1 AND rn.nspname <> $1
888
+ UNION ALL
889
+ SELECT cl.relname || '.' || a.attname || ' default' AS what,
890
+ rn.nspname || '.' || rc.relname AS points_at
891
+ FROM pg_depend d
892
+ JOIN pg_attrdef ad ON ad.oid = d.objid AND d.classid = 'pg_attrdef'::regclass
893
+ JOIN pg_class cl ON cl.oid = ad.adrelid
894
+ JOIN pg_namespace cn ON cn.oid = cl.relnamespace
895
+ JOIN pg_attribute a ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
896
+ JOIN pg_class rc ON rc.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
897
+ JOIN pg_namespace rn ON rn.oid = rc.relnamespace
898
+ WHERE cn.nspname = $1 AND rn.nspname <> $1 AND rn.nspname <> 'pg_catalog'`,
836
899
  [target],
837
900
  );
838
901
  if (strays.length) {
839
902
  throw new Error(
840
903
  'the copy points outside itself, so it is not the app: ' +
841
- strays.map((row) => row.conname + ' -> ' + row.points_at).join(', '),
904
+ strays.map((row) => row.what + ' -> ' + row.points_at).join(', '),
842
905
  );
843
906
  }
844
907
 
@@ -987,6 +1050,7 @@ module.exports = {
987
1050
  readExternalTargets: readExternalTargets,
988
1051
  referenceIn: referenceIn,
989
1052
  rewriteSchemaRefs: rewriteSchemaRefs,
1053
+ qualifyOwnRefs: qualifyOwnRefs,
990
1054
  stubNameFor: stubNameFor,
991
1055
  IDENTITIES: IDENTITIES,
992
1056
  quote: quote,