1
2# Copyright (c) 2021, PostgreSQL Global Development Group
3
4use strict;
5use warnings;
6
7use PostgresNode;
8use TestLib;
9
10use Fcntl qw(:seek);
11use Test::More tests => 63;
12
13my ($node, $port, %corrupt_page, %remove_relation);
14
15# Returns the filesystem path for the named relation.
16#
17# Assumes the test node is running
18sub relation_filepath
19{
20	my ($dbname, $relname) = @_;
21
22	my $pgdata = $node->data_dir;
23	my $rel =
24	  $node->safe_psql($dbname, qq(SELECT pg_relation_filepath('$relname')));
25	die "path not found for relation $relname" unless defined $rel;
26	return "$pgdata/$rel";
27}
28
29# Returns the name of the toast relation associated with the named relation.
30#
31# Assumes the test node is running
32sub relation_toast
33{
34	my ($dbname, $relname) = @_;
35
36	my $rel = $node->safe_psql(
37		$dbname, qq(
38		SELECT c.reltoastrelid::regclass
39			FROM pg_catalog.pg_class c
40			WHERE c.oid = '$relname'::regclass
41			  AND c.reltoastrelid != 0
42			));
43	return $rel;
44}
45
46# Adds the relation file for the given (dbname, relname) to the list
47# to be corrupted by means of overwriting junk in the first page.
48#
49# Assumes the test node is running.
50sub plan_to_corrupt_first_page
51{
52	my ($dbname, $relname) = @_;
53	my $relpath = relation_filepath($dbname, $relname);
54	$corrupt_page{$relpath} = 1;
55}
56
57# Adds the relation file for the given (dbname, relname) to the list
58# to be corrupted by means of removing the file..
59#
60# Assumes the test node is running
61sub plan_to_remove_relation_file
62{
63	my ($dbname, $relname) = @_;
64	my $relpath = relation_filepath($dbname, $relname);
65	$remove_relation{$relpath} = 1;
66}
67
68# For the given (dbname, relname), if a corresponding toast table
69# exists, adds that toast table's relation file to the list to be
70# corrupted by means of removing the file.
71#
72# Assumes the test node is running.
73sub plan_to_remove_toast_file
74{
75	my ($dbname, $relname) = @_;
76	my $toastname = relation_toast($dbname, $relname);
77	plan_to_remove_relation_file($dbname, $toastname) if ($toastname);
78}
79
80# Corrupts the first page of the given file path
81sub corrupt_first_page
82{
83	my ($relpath) = @_;
84
85	my $fh;
86	open($fh, '+<', $relpath)
87	  or BAIL_OUT("open failed: $!");
88	binmode $fh;
89
90	# Corrupt some line pointers.  The values are chosen to hit the
91	# various line-pointer-corruption checks in verify_heapam.c
92	# on both little-endian and big-endian architectures.
93	seek($fh, 32, SEEK_SET)
94	  or BAIL_OUT("seek failed: $!");
95	syswrite(
96		$fh,
97		pack("L*",
98			0xAAA15550, 0xAAA0D550, 0x00010000, 0x00008000,
99			0x0000800F, 0x001e8000, 0xFFFFFFFF)
100	) or BAIL_OUT("syswrite failed: $!");
101	close($fh)
102	  or BAIL_OUT("close failed: $!");
103}
104
105# Stops the node, performs all the corruptions previously planned, and
106# starts the node again.
107#
108sub perform_all_corruptions()
109{
110	$node->stop();
111	for my $relpath (keys %corrupt_page)
112	{
113		corrupt_first_page($relpath);
114	}
115	for my $relpath (keys %remove_relation)
116	{
117		unlink($relpath);
118	}
119	$node->start;
120}
121
122# Test set-up
123$node = get_new_node('test');
124$node->init;
125$node->append_conf('postgresql.conf', 'autovacuum=off');
126$node->start;
127$port = $node->port;
128
129for my $dbname (qw(db1 db2 db3))
130{
131	# Create the database
132	$node->safe_psql('postgres', qq(CREATE DATABASE $dbname));
133
134	# Load the amcheck extension, upon which pg_amcheck depends.  Put the
135	# extension in an unexpected location to test that pg_amcheck finds it
136	# correctly.  Create tables with names that look like pg_catalog names to
137	# check that pg_amcheck does not get confused by them.  Create functions in
138	# schema public that look like amcheck functions to check that pg_amcheck
139	# does not use them.
140	$node->safe_psql(
141		$dbname, q(
142		CREATE SCHEMA amcheck_schema;
143		CREATE EXTENSION amcheck WITH SCHEMA amcheck_schema;
144		CREATE TABLE amcheck_schema.pg_database (junk text);
145		CREATE TABLE amcheck_schema.pg_namespace (junk text);
146		CREATE TABLE amcheck_schema.pg_class (junk text);
147		CREATE TABLE amcheck_schema.pg_operator (junk text);
148		CREATE TABLE amcheck_schema.pg_proc (junk text);
149		CREATE TABLE amcheck_schema.pg_tablespace (junk text);
150
151		CREATE FUNCTION public.bt_index_check(index regclass,
152											  heapallindexed boolean default false)
153		RETURNS VOID AS $$
154		BEGIN
155			RAISE EXCEPTION 'Invoked wrong bt_index_check!';
156		END;
157		$$ LANGUAGE plpgsql;
158
159		CREATE FUNCTION public.bt_index_parent_check(index regclass,
160													 heapallindexed boolean default false,
161													 rootdescend boolean default false)
162		RETURNS VOID AS $$
163		BEGIN
164			RAISE EXCEPTION 'Invoked wrong bt_index_parent_check!';
165		END;
166		$$ LANGUAGE plpgsql;
167
168		CREATE FUNCTION public.verify_heapam(relation regclass,
169											 on_error_stop boolean default false,
170											 check_toast boolean default false,
171											 skip text default 'none',
172											 startblock bigint default null,
173											 endblock bigint default null,
174											 blkno OUT bigint,
175											 offnum OUT integer,
176											 attnum OUT integer,
177											 msg OUT text)
178		RETURNS SETOF record AS $$
179		BEGIN
180			RAISE EXCEPTION 'Invoked wrong verify_heapam!';
181		END;
182		$$ LANGUAGE plpgsql;
183	));
184
185	# Create schemas, tables and indexes in five separate
186	# schemas.  The schemas are all identical to start, but
187	# we will corrupt them differently later.
188	#
189	for my $schema (qw(s1 s2 s3 s4 s5))
190	{
191		$node->safe_psql(
192			$dbname, qq(
193			CREATE SCHEMA $schema;
194			CREATE SEQUENCE $schema.seq1;
195			CREATE SEQUENCE $schema.seq2;
196			CREATE TABLE $schema.t1 (
197				i INTEGER,
198				b BOX,
199				ia int4[],
200				ir int4range,
201				t TEXT
202			);
203			CREATE TABLE $schema.t2 (
204				i INTEGER,
205				b BOX,
206				ia int4[],
207				ir int4range,
208				t TEXT
209			);
210			CREATE VIEW $schema.t2_view AS (
211				SELECT i*2, t FROM $schema.t2
212			);
213			ALTER TABLE $schema.t2
214				ALTER COLUMN t
215				SET STORAGE EXTERNAL;
216
217			INSERT INTO $schema.t1 (i, b, ia, ir, t)
218				(SELECT gs::INTEGER AS i,
219						box(point(gs,gs+5),point(gs*2,gs*3)) AS b,
220						array[gs, gs + 1]::int4[] AS ia,
221						int4range(gs, gs+100) AS ir,
222						repeat('foo', gs) AS t
223					 FROM generate_series(1,10000,3000) AS gs);
224
225			INSERT INTO $schema.t2 (i, b, ia, ir, t)
226				(SELECT gs::INTEGER AS i,
227						box(point(gs,gs+5),point(gs*2,gs*3)) AS b,
228						array[gs, gs + 1]::int4[] AS ia,
229						int4range(gs, gs+100) AS ir,
230						repeat('foo', gs) AS t
231					 FROM generate_series(1,10000,3000) AS gs);
232
233			CREATE MATERIALIZED VIEW $schema.t1_mv AS SELECT * FROM $schema.t1;
234			CREATE MATERIALIZED VIEW $schema.t2_mv AS SELECT * FROM $schema.t2;
235
236			create table $schema.p1 (a int, b int) PARTITION BY list (a);
237			create table $schema.p2 (a int, b int) PARTITION BY list (a);
238
239			create table $schema.p1_1 partition of $schema.p1 for values in (1, 2, 3);
240			create table $schema.p1_2 partition of $schema.p1 for values in (4, 5, 6);
241			create table $schema.p2_1 partition of $schema.p2 for values in (1, 2, 3);
242			create table $schema.p2_2 partition of $schema.p2 for values in (4, 5, 6);
243
244			CREATE INDEX t1_btree ON $schema.t1 USING BTREE (i);
245			CREATE INDEX t2_btree ON $schema.t2 USING BTREE (i);
246
247			CREATE INDEX t1_hash ON $schema.t1 USING HASH (i);
248			CREATE INDEX t2_hash ON $schema.t2 USING HASH (i);
249
250			CREATE INDEX t1_brin ON $schema.t1 USING BRIN (i);
251			CREATE INDEX t2_brin ON $schema.t2 USING BRIN (i);
252
253			CREATE INDEX t1_gist ON $schema.t1 USING GIST (b);
254			CREATE INDEX t2_gist ON $schema.t2 USING GIST (b);
255
256			CREATE INDEX t1_gin ON $schema.t1 USING GIN (ia);
257			CREATE INDEX t2_gin ON $schema.t2 USING GIN (ia);
258
259			CREATE INDEX t1_spgist ON $schema.t1 USING SPGIST (ir);
260			CREATE INDEX t2_spgist ON $schema.t2 USING SPGIST (ir);
261		));
262	}
263}
264
265# Database 'db1' corruptions
266#
267
268# Corrupt indexes in schema "s1"
269plan_to_remove_relation_file('db1', 's1.t1_btree');
270plan_to_corrupt_first_page('db1', 's1.t2_btree');
271
272# Corrupt tables in schema "s2"
273plan_to_remove_relation_file('db1', 's2.t1');
274plan_to_corrupt_first_page('db1', 's2.t2');
275
276# Corrupt tables, partitions, matviews, and btrees in schema "s3"
277plan_to_remove_relation_file('db1', 's3.t1');
278plan_to_corrupt_first_page('db1', 's3.t2');
279
280plan_to_remove_relation_file('db1', 's3.t1_mv');
281plan_to_remove_relation_file('db1', 's3.p1_1');
282
283plan_to_corrupt_first_page('db1', 's3.t2_mv');
284plan_to_corrupt_first_page('db1', 's3.p2_1');
285
286plan_to_remove_relation_file('db1', 's3.t1_btree');
287plan_to_corrupt_first_page('db1', 's3.t2_btree');
288
289# Corrupt toast table, partitions, and materialized views in schema "s4"
290plan_to_remove_toast_file('db1', 's4.t2');
291
292# Corrupt all other object types in schema "s5".  We don't have amcheck support
293# for these types, but we check that their corruption does not trigger any
294# errors in pg_amcheck
295plan_to_remove_relation_file('db1', 's5.seq1');
296plan_to_remove_relation_file('db1', 's5.t1_hash');
297plan_to_remove_relation_file('db1', 's5.t1_gist');
298plan_to_remove_relation_file('db1', 's5.t1_gin');
299plan_to_remove_relation_file('db1', 's5.t1_brin');
300plan_to_remove_relation_file('db1', 's5.t1_spgist');
301
302plan_to_corrupt_first_page('db1', 's5.seq2');
303plan_to_corrupt_first_page('db1', 's5.t2_hash');
304plan_to_corrupt_first_page('db1', 's5.t2_gist');
305plan_to_corrupt_first_page('db1', 's5.t2_gin');
306plan_to_corrupt_first_page('db1', 's5.t2_brin');
307plan_to_corrupt_first_page('db1', 's5.t2_spgist');
308
309
310# Database 'db2' corruptions
311#
312plan_to_remove_relation_file('db2', 's1.t1');
313plan_to_remove_relation_file('db2', 's1.t1_btree');
314
315
316# Leave 'db3' uncorrupted
317#
318
319# Standard first arguments to TestLib functions
320my @cmd = ('pg_amcheck', '-p', $port);
321
322# Regular expressions to match various expected output
323my $no_output_re               = qr/^$/;
324my $line_pointer_corruption_re = qr/line pointer/;
325my $missing_file_re = qr/could not open file ".*": No such file or directory/;
326my $index_missing_relation_fork_re =
327  qr/index ".*" lacks a main relation fork/;
328
329# We have created test databases with tables populated with data, but have not
330# yet corrupted anything.  As such, we expect no corruption and verify that
331# none is reported
332#
333$node->command_checks_all([ @cmd, '-d', 'db1', '-d', 'db2', '-d', 'db3' ],
334	0, [$no_output_re], [$no_output_re], 'pg_amcheck prior to corruption');
335
336# Perform the corruptions we planned above using only a single database restart.
337#
338perform_all_corruptions();
339
340
341# Checking databases with amcheck installed and corrupt relations, pg_amcheck
342# command itself should return exit status = 2, because tables and indexes are
343# corrupt, not exit status = 1, which would mean the pg_amcheck command itself
344# failed.  Corruption messages should go to stdout, and nothing to stderr.
345#
346$node->command_checks_all(
347	[ @cmd, 'db1' ],
348	2,
349	[
350		$index_missing_relation_fork_re, $line_pointer_corruption_re,
351		$missing_file_re,
352	],
353	[$no_output_re],
354	'pg_amcheck all schemas, tables and indexes in database db1');
355
356$node->command_checks_all(
357	[ @cmd, '-d', 'db1', '-d', 'db2', '-d', 'db3' ],
358	2,
359	[
360		$index_missing_relation_fork_re, $line_pointer_corruption_re,
361		$missing_file_re,
362	],
363	[$no_output_re],
364	'pg_amcheck all schemas, tables and indexes in databases db1, db2, and db3'
365);
366
367# Scans of indexes in s1 should detect the specific corruption that we created
368# above.  For missing relation forks, we know what the error message looks
369# like.  For corrupted index pages, the error might vary depending on how the
370# page was formatted on disk, including variations due to alignment differences
371# between platforms, so we accept any non-empty error message.
372#
373# If we don't limit the check to databases with amcheck installed, we expect
374# complaint on stderr, but otherwise stderr should be quiet.
375#
376$node->command_checks_all(
377	[ @cmd, '--all', '-s', 's1', '-i', 't1_btree' ],
378	2,
379	[$index_missing_relation_fork_re],
380	[
381		qr/pg_amcheck: warning: skipping database "postgres": amcheck is not installed/
382	],
383	'pg_amcheck index s1.t1_btree reports missing main relation fork');
384
385$node->command_checks_all(
386	[ @cmd, '-d', 'db1', '-s', 's1', '-i', 't2_btree' ],
387	2,
388	[qr/.+/],    # Any non-empty error message is acceptable
389	[$no_output_re],
390	'pg_amcheck index s1.s2 reports index corruption');
391
392# Checking db1.s1 with indexes excluded should show no corruptions because we
393# did not corrupt any tables in db1.s1.  Verify that both stdout and stderr
394# are quiet.
395#
396$node->command_checks_all(
397	[ @cmd, '-t', 's1.*', '--no-dependent-indexes', 'db1' ],
398	0, [$no_output_re], [$no_output_re],
399	'pg_amcheck of db1.s1 excluding indexes');
400
401# Checking db2.s1 should show table corruptions if indexes are excluded
402#
403$node->command_checks_all(
404	[ @cmd, '-t', 's1.*', '--no-dependent-indexes', 'db2' ],
405	2, [$missing_file_re], [$no_output_re],
406	'pg_amcheck of db2.s1 excluding indexes');
407
408# In schema db1.s3, the tables and indexes are both corrupt.  We should see
409# corruption messages on stdout, and nothing on stderr.
410#
411$node->command_checks_all(
412	[ @cmd, '-s', 's3', 'db1' ],
413	2,
414	[
415		$index_missing_relation_fork_re, $line_pointer_corruption_re,
416		$missing_file_re,
417	],
418	[$no_output_re],
419	'pg_amcheck schema s3 reports table and index errors');
420
421# In schema db1.s4, only toast tables are corrupt.  Check that under default
422# options the toast corruption is reported, but when excluding toast we get no
423# error reports.
424$node->command_checks_all([ @cmd, '-s', 's4', 'db1' ],
425	2, [$missing_file_re], [$no_output_re],
426	'pg_amcheck in schema s4 reports toast corruption');
427
428$node->command_checks_all(
429	[
430		@cmd, '--no-dependent-toast', '--exclude-toast-pointers', '-s', 's4',
431		'db1'
432	],
433	0,
434	[$no_output_re],
435	[$no_output_re],
436	'pg_amcheck in schema s4 excluding toast reports no corruption');
437
438# Check that no corruption is reported in schema db1.s5
439$node->command_checks_all([ @cmd, '-s', 's5', 'db1' ],
440	0, [$no_output_re], [$no_output_re],
441	'pg_amcheck over schema s5 reports no corruption');
442
443# In schema db1.s1, only indexes are corrupt.  Verify that when we exclude
444# the indexes, no corruption is reported about the schema.
445#
446$node->command_checks_all(
447	[ @cmd, '-s', 's1', '-I', 't1_btree', '-I', 't2_btree', 'db1' ],
448	0,
449	[$no_output_re],
450	[$no_output_re],
451	'pg_amcheck over schema s1 with corrupt indexes excluded reports no corruption'
452);
453
454# In schema db1.s1, only indexes are corrupt.  Verify that when we provide only
455# table inclusions, and disable index expansion, no corruption is reported
456# about the schema.
457#
458$node->command_checks_all(
459	[ @cmd, '-t', 's1.*', '--no-dependent-indexes', 'db1' ],
460	0,
461	[$no_output_re],
462	[$no_output_re],
463	'pg_amcheck over schema s1 with all indexes excluded reports no corruption'
464);
465
466# In schema db1.s2, only tables are corrupt.  Verify that when we exclude those
467# tables that no corruption is reported.
468#
469$node->command_checks_all(
470	[ @cmd, '-s', 's2', '-T', 't1', '-T', 't2', 'db1' ],
471	0,
472	[$no_output_re],
473	[$no_output_re],
474	'pg_amcheck over schema s2 with corrupt tables excluded reports no corruption'
475);
476
477# Check errors about bad block range command line arguments.  We use schema s5
478# to avoid getting messages about corrupt tables or indexes.
479#
480command_fails_like(
481	[ @cmd, '-s', 's5', '--startblock', 'junk', 'db1' ],
482	qr/invalid start block/,
483	'pg_amcheck rejects garbage startblock');
484
485command_fails_like(
486	[ @cmd, '-s', 's5', '--endblock', '1234junk', 'db1' ],
487	qr/invalid end block/,
488	'pg_amcheck rejects garbage endblock');
489
490command_fails_like(
491	[ @cmd, '-s', 's5', '--startblock', '5', '--endblock', '4', 'db1' ],
492	qr/end block precedes start block/,
493	'pg_amcheck rejects invalid block range');
494
495# Check bt_index_parent_check alternates.  We don't create any index corruption
496# that would behave differently under these modes, so just smoke test that the
497# arguments are handled sensibly.
498#
499$node->command_checks_all(
500	[ @cmd, '-s', 's1', '-i', 't1_btree', '--parent-check', 'db1' ],
501	2,
502	[$index_missing_relation_fork_re],
503	[$no_output_re],
504	'pg_amcheck smoke test --parent-check');
505
506$node->command_checks_all(
507	[
508		@cmd, '-s', 's1', '-i', 't1_btree', '--heapallindexed',
509		'--rootdescend', 'db1'
510	],
511	2,
512	[$index_missing_relation_fork_re],
513	[$no_output_re],
514	'pg_amcheck smoke test --heapallindexed --rootdescend');
515
516$node->command_checks_all(
517	[ @cmd, '-d', 'db1', '-d', 'db2', '-d', 'db3', '-S', 's*' ],
518	0, [$no_output_re], [$no_output_re],
519	'pg_amcheck excluding all corrupt schemas');
520