Files
webmin/sqlite/t/lib.t
Ilia Ross 2aca3f56e3
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Fix to limit SQLite table and editor data
2026-10-17 17:08:20 +02:00

626 lines
29 KiB
Perl
Executable File

#!/usr/bin/perl
# Tests SQLite helpers, connection modes and SQL sandboxing.
use strict;
use warnings;
use Test::More;
use File::Path qw(make_path);
use File::Temp qw(tempdir);
use FindBin;
BEGIN { $ENV{'SQLITE_TESTING'} = 1; }
chdir "$FindBin::Bin/.." or die "chdir: $!";
require './sqlite-lib.pl';
our (%access, %config, %text, $remote_user, $error_must_die);
# Make Webmin's error handler throw exceptions that the tests can inspect.
$error_must_die = 1;
# Build isolated allowed and denied directory trees for path checks.
my $root = tempdir(CLEANUP => 1);
my $allowed = "$root/allowed";
my $outside = "$root/outside";
make_path($allowed, $outside);
# Explicit ACL paths are canonical when saved by the access-control editor.
$allowed = simplify_path(resolve_links($allowed));
$outside = simplify_path(resolve_links($outside));
$access{'paths'} = $allowed;
$access{'create'} = 1;
$access{'write'} = 1;
$config{'busy_timeout'} = 100;
$config{'max_text'} = 8;
$config{'max_result_bytes'} = 1048576;
$text{'error_key'} = 'bad key';
$text{'error_resultcontrol'} = 'cannot limit results: $1';
$text{'error_resultlarge'} = 'result exceeds $1 bytes';
$text{'error_valuelarge'} = 'value exceeds $1';
$text{'value_more'} = '...';
$text{'value_more_chars'} = '+ $1 chars';
$text{'value_more_chars_truncated'} = '+ $1 of $2 chars';
$text{'value_blob'} = '[binary value, $1 bytes]';
# Check fail-closed ACL parsing and directory boundaries.
is_deeply([ sqlite_acl_paths(undef) ], [],
'missing path ACL grants no database access');
my @current_user = getpwuid($<);
SKIP: {
# A safe ACL can resolve $HOME only when the Webmin user exists in Unix.
skip('current Unix account has no absolute home directory', 1)
if (!$current_user[0] || !$current_user[7] ||
$current_user[7] !~ m!^/!);
local $remote_user = $current_user[0];
my $expected = simplify_path(resolve_links($current_user[7]));
is_deeply([ sqlite_acl_paths('$HOME') ], [ $expected ],
'safe path ACL expands the Unix user home directory');
}
{
# An unknown Webmin user must not inherit the Webmin process home.
local $remote_user = 'sqlite-safe-user-that-does-not-exist';
is_deeply([ sqlite_acl_paths('$HOME') ], [],
'safe path ACL fails closed without a matching Unix user');
}
SKIP: {
# With a named Unix user, $HOME is that user's home even when the
# Webmin user has no account of its own.
skip('current Unix account has no absolute home directory', 1)
if (!$current_user[0] || !$current_user[7] ||
$current_user[7] !~ m!^/!);
local $remote_user = 'sqlite-safe-user-that-does-not-exist';
local $access{'work_as_root'} = 0;
local $access{'work_as_user'} = $current_user[0];
my $expected = simplify_path(resolve_links($current_user[7]));
is_deeply([ sqlite_acl_paths('$HOME') ], [ $expected ],
'safe path ACL follows the named Unix user home directory');
}
my %safe_acl;
read_file('safeacl', \%safe_acl);
is_deeply(\%safe_acl, {
'paths' => '$HOME',
'write' => '1',
'create' => '1',
'sql' => '0',
'work_as_root' => '0',
'work_as_user' => '',
}, 'safe ACL allows structured changes only in the user home, as the user');
# Check which Unix user the module does its file work as.
{
local $remote_user = 'tester';
is(sqlite_work_as_user({ 'work_as_root' => 1, 'paths' => '$HOME' }), undef,
'an explicit root setting stays root');
is(sqlite_work_as_user({ 'work_as_root' => 0, 'paths' => '/' }), 'tester',
'an explicit same-user setting uses the Webmin user');
is(sqlite_work_as_user({ 'work_as_root' => 0, 'work_as_user' => 'app',
'paths' => '/' }), 'app', 'a named Unix user wins');
is(sqlite_work_as_user({ 'paths' => '$HOME' }), 'tester',
'an older home-only ACL implies the Webmin user');
is(sqlite_work_as_user({ 'paths' => "/srv\t\$HOME" }), undef,
'an older ACL with other directories stays root');
is(sqlite_work_as_user({ 'paths' => '/' }), undef,
'an older root ACL stays root');
is(sqlite_work_as_user({ }), undef, 'an empty ACL stays root');
# Once switched, the recent list moves into the user's home.
our $sqlite_unix_home;
local $sqlite_unix_home = '/home/tester';
like(sqlite_recent_file(), qr{^/home/tester/\.webmin-sqlite/tester$},
'a switched user keeps the recent list in the home directory');
}
ok(sqlite_path_under("$allowed/example.db", $allowed),
'child path is allowed');
ok(!sqlite_path_under("$outside/example.db", $allowed),
'neighbor path is rejected');
my $resolved_allowed = simplify_path(resolve_links($allowed));
is(sqlite_database_path("$allowed/new.db", 1), "$resolved_allowed/new.db",
'new database path is accepted inside allowed directory');
is(sqlite_filename_uri('/tmp/a;b=c%20.db'),
'file:/tmp/a%3Bb%3Dc%2520.db',
'filename URI escapes DBD connection syntax and literal percent signs');
# A saved ACL root stays fixed even if its former directory is replaced by
# a symbolic link to an outside location.
SKIP: {
my $replaceable = "$root/replaceable";
make_path($replaceable);
my $pinned = simplify_path(resolve_links($replaceable));
rmdir($replaceable) or die "rmdir $replaceable: $!";
skip('symbolic links are unavailable', 2)
if (!symlink($outside, $replaceable));
my $retargeted = simplify_path(resolve_links("$replaceable/private.db"));
my ($root_acl) = sqlite_acl_paths($pinned);
ok(!sqlite_path_under($retargeted, $root_acl),
'root keeps a saved ACL directory fixed');
my ($user_acl) = sqlite_acl_paths($pinned);
is($user_acl, $pinned,
'an unprivileged worker also keeps the ACL directory fixed');
}
# Restricted path ACLs require SQLite's full-path no-follow protection.
ok(!sqlite_secure_open_available(3038999),
'restricted access rejects SQLite before 3.39');
ok(sqlite_secure_open_available(3039000),
'restricted access accepts SQLite 3.39');
# The root ACL has no narrower directory boundary to enforce.
{
local $access{'paths'} = '/';
ok(sqlite_secure_open_available(3030999),
'an unrestricted ACL needs no directory boundary');
}
# The result-size limit defaults to 16 MiB and stays within its documented
# range.
{
local $config{'max_result_bytes'};
is(sqlite_result_bytes_limit(), 16777216,
'result data defaults to a 16 MiB limit');
local $config{'max_result_bytes'} = 0;
is(sqlite_result_bytes_limit(), 1048576,
'result data has a 1 MiB minimum');
$config{'max_result_bytes'} = 999999999;
is(sqlite_result_bytes_limit(), 67108864,
'result data has a 64 MiB maximum');
}
is(sqlite_row_bytes([ 'abc', undef, 'de' ]), 261,
'result byte counting includes row and scalar storage');
# Ownership changes must reject links instead of following them as root.
SKIP: {
my $target = "$outside/chown-target";
my $link = "$allowed/chown-link";
open(my $fh, '>', $target) or die "open $target: $!";
close($fh) or die "close $target: $!";
skip('symbolic links are unavailable', 1) if (!symlink($target, $link));
my @st = stat($target);
ok(!sqlite_safe_chown($link, $st[4], $st[5]),
'ownership helper refuses a symbolic link');
}
# Check row identities with NULL, punctuation and Unicode values.
my @input = (1, undef, "a:b;c", "\x{263a}");
my $token = sqlite_encode_key(@input);
my @output = sqlite_decode_key($token, scalar(@input));
is_deeply(\@output, \@input, 'row identity round trips');
# Check long-text disclosure markup.
$config{'max_text'} = 1000;
my $short_value = sqlite_display_value('unbroken-text-value');
like($short_value, qr/overflow-wrap:anywhere;word-break:break-word/,
'short text can shrink with its table column');
my $long_value = sqlite_display_value('x' x 140);
like($long_value, qr/<details class="inline inlined">/,
'long text uses an inline details disclosure');
like($long_value, qr/<summary>\+ 12 chars<\/summary>/,
'long text reports the number of hidden characters');
like($long_value, qr/^<span[^>]*>@{['x' x 128]} /,
'long text leaves the first 128 characters visible');
like($long_value, qr/overflow-wrap:anywhere;word-break:break-word/,
'long text can shrink with its table column');
unlike($long_value, qr/min-width:/,
'long text does not force a minimum table width');
like(sqlite_display_value('abcdefgh', 'text', 20), qr/>abcdefgh\.\.\.<\/span>/,
'text shortened by SQLite still shows its truncation marker');
$text{'value_more'} = '&hellip;';
like(sqlite_display_value('abcdefgh', 'text', 20), qr/abcdefgh&hellip;/,
'text shortened by SQLite uses the translated ellipsis');
$text{'value_more'} = '...';
$config{'max_text'} = 8;
my ($full_values) = sqlite_column_selects([ { 'name' => 'note' } ]);
unlike($full_values->[0], qr/substr/,
'non-display queries load full text values');
my ($bounded_values) = sqlite_column_selects(
[ { 'name' => 'note' } ], undef, 1);
like($bounded_values->[0], qr/substr\("note", 1, 8\)/,
'table SQL shortens text before returning it to Perl');
# Later helpers receive paths returned by sqlite_database_path, which are
# canonical even when a temporary-directory prefix is a symbolic link.
$allowed = $resolved_allowed;
$outside = simplify_path(resolve_links($outside));
$access{'paths'} = $allowed;
# Check the per-user recent list: the limit, ordering, de-duplication, ACL
# filtering, deleted files and clearing.
our $module_config_directory;
$module_config_directory = "$root/config";
make_path($module_config_directory);
$text{'index_today'} = 'Today, $1';
$text{'index_yesterday'} = 'Yesterday, $1';
# sub touch(path)
# Creates one empty fixture file.
sub touch
{
my ($path) = @_;
open(my $fh, '>', $path) or die "open $path: $!";
close($fh) or die "close $path: $!";
}
{
local $remote_user = 'tester';
is(sqlite_recent_limit(), 3, 'recent list keeps three entries by default');
$config{'recent'} = 2;
is(sqlite_recent_limit(), 2,
'recent list limit comes from the module config');
like(sqlite_recent_file(), qr{/recent/tester$},
'recent list is stored per Webmin user');
touch("$allowed/$_") foreach ('a.db', 'b.db', 'c.db');
touch("$outside/d.db");
sqlite_remember_recent("$allowed/a.db", 1);
sqlite_remember_recent("$allowed/b.db", 2);
is_deeply([ map { $_->{'path'} } sqlite_list_recent() ],
[ "$allowed/b.db", "$allowed/a.db" ], 'recent list is newest first');
sqlite_remember_recent("$allowed/a.db", 5);
my @again = sqlite_list_recent();
is_deeply([ map { $_->{'path'} } @again ],
[ "$allowed/a.db", "$allowed/b.db" ],
'reopening moves a database to the top without a duplicate');
is($again[0]->{'tables'}, 5, 'reopening refreshes the table count');
ok($again[0]->{'time'} > 0 && defined($again[0]->{'size'}),
'entries carry the opening time and the file size');
sqlite_remember_recent("$allowed/c.db", 0);
is_deeply([ map { $_->{'path'} } sqlite_list_recent() ],
[ "$allowed/c.db", "$allowed/a.db" ],
'recent list keeps only the configured number of entries');
$config{'recent'} = 3;
sqlite_remember_recent("$outside/d.db", 0);
ok(!(grep { $_->{'path'} eq "$outside/d.db" } sqlite_list_recent()),
'databases outside the ACL are hidden from the recent list');
unlink("$allowed/c.db");
ok(!(grep { $_->{'path'} eq "$allowed/c.db" } sqlite_list_recent()),
'deleted databases are hidden from the recent list');
sqlite_forget_recent("$allowed/a.db");
ok(!(grep { $_->{'path'} eq "$allowed/a.db" } sqlite_list_recent()),
'forgetting one database removes only that entry');
ok(-e sqlite_recent_file(),
'the list file stays while other entries remain');
sqlite_forget_recent();
is_deeply([ sqlite_list_recent() ], [],
'clearing the list removes every entry');
ok(!-e sqlite_recent_file(), 'clearing the list removes the stored file');
$config{'recent'} = 0;
sqlite_remember_recent("$allowed/a.db", 1);
is_deeply([ sqlite_list_recent() ], [],
'a zero limit disables the recent list');
ok(!-e sqlite_recent_file(), 'a zero limit records nothing');
delete($config{'recent'});
}
{
local $remote_user = '';
is(sqlite_recent_file(), undef, 'no recent list without a logged-in user');
}
like(sqlite_recent_time(time()), qr/^Today, \d\d:\d\d$/,
'a time from today shows the clock');
like(sqlite_recent_time(time() - 86400), qr/^Yesterday, \d\d:\d\d$/,
'a time from yesterday says so');
my $older = sqlite_recent_time(time() - 7 * 86400);
ok(length($older) && $older !~ /^(Today|Yesterday)/,
'older times show a date');
is(sqlite_recent_time(0), '', 'a missing time shows nothing');
# Check the statements built from the table form.
$text{'create_ename'} = "Invalid table name '\$1'";
$text{'create_ecolumns'} = 'No columns were entered';
$text{'create_ecolumn'} = "Invalid column name '\$1'";
$text{'create_edup'} = "Column '\$1' is listed more than once";
$text{'create_etype'} = "Invalid type for column '\$1'";
$text{'create_eauto'} = "Autoincrement requires '\$1' to be the only primary key column and have type INTEGER";
is(sqlite_default_literal('abc'), "'abc'", 'text defaults are quoted');
is(sqlite_default_literal("it's"), "'it''s'", 'quotes in defaults are doubled');
is(sqlite_default_literal('42'), '42', 'integer defaults stay numeric');
is(sqlite_default_literal('-1.5e3'), '-1.5e3', 'float defaults stay numeric');
is(sqlite_default_literal('null'), 'NULL', 'NULL default is the keyword');
is(sqlite_default_literal('current_timestamp'), 'CURRENT_TIMESTAMP',
'time keywords are passed through');
is(sqlite_default_literal('1 or 2'), "'1 or 2'",
'anything else is a string literal');
ok(sqlite_valid_name('users'), 'plain names are valid');
ok(sqlite_valid_name('odd "name'), 'names needing quotes are valid');
ok(!sqlite_valid_name(''), 'empty names are invalid');
ok(!sqlite_valid_name("a\nb"), 'names with control characters are invalid');
ok(!sqlite_valid_name('sqlite_master'), 'the sqlite_ namespace is reserved');
is_deeply([ sqlite_column_types() ],
[ 'INTEGER', 'TEXT', 'REAL', 'BLOB', 'NUMERIC' ],
'the form offers the five storage classes');
is(sqlite_create_table_sql('t', [
{ 'name' => 'id', 'type' => 'INTEGER', 'null' => 0, 'key' => 'pkai' },
{ 'name' => 'name', 'type' => 'TEXT', 'null' => 1, 'key' => '',
'default' => '' } ]),
'CREATE TABLE "t" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT)',
'a single integer key becomes the rowid alias');
is(sqlite_create_table_sql('t', [
{ 'name' => 'code', 'type' => 'TEXT', 'null' => 1, 'key' => 'pk' },
{ 'name' => 'email', 'type' => 'TEXT', 'null' => 0, 'key' => 'unique',
'default' => 'x' } ]),
'CREATE TABLE "t" ("code" TEXT PRIMARY KEY NOT NULL, "email" TEXT NOT NULL UNIQUE DEFAULT \'x\')',
'a text key is forced NOT NULL and unique columns take a default');
is(sqlite_create_table_sql('odd "t', [
{ 'name' => 'a', 'type' => 'TEXT', 'null' => 1, 'key' => 'pk' },
{ 'name' => 'b', 'type' => 'INTEGER', 'null' => 1, 'key' => 'pk' } ]),
'CREATE TABLE "odd ""t" ("a" TEXT NOT NULL, "b" INTEGER NOT NULL, PRIMARY KEY ("a", "b"))',
'several keys form a table-level composite key in a quoted table');
my $column = { 'name' => 'id', 'type' => 'INTEGER', 'null' => 1, 'key' => '' };
eval { sqlite_create_table_sql('sqlite_x', [ $column ]) };
like($@, qr/^Invalid table name 'sqlite_x'/, 'reserved table names are rejected');
eval { sqlite_create_table_sql('t', [ ]) };
like($@, qr/^No columns were entered/, 'a table needs a column');
eval { sqlite_create_table_sql('t', [ $column,
{ 'name' => 'ID', 'type' => 'TEXT', 'null' => 1, 'key' => '' } ]) };
like($@, qr/^Column 'ID' is listed more than once/,
'column names are unique without regard to case');
eval { sqlite_create_table_sql('t', [
{ 'name' => 'x', 'type' => 'VARCHAR', 'null' => 1, 'key' => '' } ]) };
like($@, qr/^Invalid type for column 'x'/, 'only the offered types are accepted');
eval { sqlite_create_table_sql('t', [
{ 'name' => 'x', 'type' => 'TEXT', 'null' => 1, 'key' => 'pkai' } ]) };
like($@, qr/^Autoincrement requires/, 'autoincrement needs an INTEGER column');
eval { sqlite_create_table_sql('t', [
{ 'name' => 'x', 'type' => 'INTEGER', 'null' => 1, 'key' => 'pkai' },
{ 'name' => 'y', 'type' => 'INTEGER', 'null' => 1, 'key' => 'pk' } ]) };
like($@, qr/^Autoincrement requires/, 'autoincrement needs a single key column');
eval { sqlite_create_table_sql('t', [
{ 'name' => "a\tb", 'type' => 'TEXT', 'null' => 1, 'key' => '' } ]) };
like($@, qr/^Invalid column name/, 'column names are checked too');
# Check the version gate and the statements for adding a column.
ok(!sqlite_supports('rename_column', 3024000),
'column renames need SQLite 3.25');
ok(sqlite_supports('rename_column', 3025000),
'SQLite 3.25 can rename columns');
ok(!sqlite_supports('drop_column', 3034999),
'column drops need SQLite 3.35');
ok(sqlite_supports('drop_column', 3035000),
'SQLite 3.35 can drop columns');
ok(!sqlite_supports('alter_column', 3999000),
'unknown features are never supported');
ok(!sqlite_supports('nofollow', 3038999),
'full-path no-follow needs SQLite 3.39');
ok(sqlite_supports('nofollow', 3039000),
'SQLite 3.39 refuses links in every path component');
# As a normal user the ownership helper changes nothing and never dies.
touch("$allowed/owned.db");
touch("$allowed/owned.db-wal");
my @before = stat("$allowed/owned.db-wal");
sqlite_adopt_ownership("$allowed/owned.db");
sqlite_adopt_ownership("$allowed/missing.db");
is_deeply([ (stat("$allowed/owned.db-wal"))[4, 5] ], [ @before[4, 5] ],
'ownership adoption leaves files alone when not running as root');
$text{'column_enotnull'} = 'A column that does not allow NULL needs a default value for the existing rows';
$text{'column_ecurrent'} = 'An added column cannot default to the current time or date';
is(sqlite_add_column_sql('t', { 'name' => 'note', 'type' => 'TEXT',
'null' => 1, 'default' => '' }),
'ALTER TABLE "t" ADD COLUMN "note" TEXT',
'an optional column needs no default');
is(sqlite_add_column_sql('t', { 'name' => 'count', 'type' => 'INTEGER',
'null' => 0, 'default' => '0' }),
'ALTER TABLE "t" ADD COLUMN "count" INTEGER NOT NULL DEFAULT 0',
'a required column carries its default');
eval { sqlite_add_column_sql('t', { 'name' => 'count', 'type' => 'INTEGER',
'null' => 0, 'default' => '' }) };
like($@, qr/^A column that does not allow NULL needs a default/,
'a required column without a default is rejected');
eval { sqlite_add_column_sql('t', { 'name' => 'count', 'type' => 'INTEGER',
'null' => 0, 'default' => 'null' }) };
like($@, qr/^A column that does not allow NULL needs a default/,
'a NULL default does not satisfy NOT NULL');
eval { sqlite_add_column_sql('t', { 'name' => 'when', 'type' => 'TEXT',
'null' => 1, 'default' => 'current_timestamp' }) };
like($@, qr/^An added column cannot default to the current time/,
'time defaults are rejected for added columns');
eval { sqlite_add_column_sql('t', { 'name' => 'x', 'type' => 'JSON',
'null' => 1, 'default' => '' }) };
like($@, qr/^Invalid type for column 'x'/, 'added columns take the offered types');
eval { sqlite_add_column_sql('t', { 'name' => 'sqlite_x', 'type' => 'TEXT',
'null' => 1, 'default' => '' }) };
like($@, qr/^Invalid column name 'sqlite_x'/, 'added column names are checked');
# Run database-level checks only when the optional SQLite driver is installed.
SKIP: {
my $driver_error = sqlite_load_driver();
# Skip only database tests when DBD::SQLite is unavailable.
skip("DBD::SQLite unavailable: $driver_error", 54) if ($driver_error);
my $limit_error = sqlite_result_limit_error();
# Verify the compatibility error before skipping capability-dependent tests.
if ($limit_error) {
like($limit_error, qr/does not provide sqlite_limit$/,
'an older driver loads but reports the missing capability');
skip("SQLite result limits unavailable: $limit_error", 53);
}
ok(!$limit_error,
'the installed driver exposes result-limit control');
{
no warnings 'once';
local *DBD::SQLite::db::sqlite_limit;
like(sqlite_result_limit_error(),
qr/^DBD::SQLite \S+ does not provide sqlite_limit$/,
'a loaded driver reports the missing capability');
}
# Database helper tests use an unrestricted ACL so they can run with an
# older local SQLite; restricted-path version gates are tested above.
local $access{'paths'} = '/';
# Create a writable fixture and test schema and query helpers.
my $database = "$allowed/example.db";
my $dbh = sqlite_connect($database, 1, 1);
my ($foreign_keys) = $dbh->selectrow_array('PRAGMA foreign_keys');
is($foreign_keys, 1, 'foreign-key enforcement is enabled');
my ($wide_sum) = $dbh->selectrow_array(
'SELECT '.sqlite_sum_sql(('1') x 2000));
is($wide_sum, 2000, 'wide schemas use a shallow size expression');
# The size guard must prevent SQLite from evaluating oversized expressions.
my $guarded_calls = 0;
# sub webmin_guarded_value()
# Counts calls when SQLite evaluates the registered test function.
$dbh->sqlite_create_function('webmin_guarded_value', 0, sub {
$guarded_calls++;
return 'guarded';
});
my $guarded_sql = sqlite_guarded_select('1', 0, [],
[ 'webmin_guarded_value()' ],
'FROM (SELECT 1) LIMIT 1 OFFSET 0');
my $guarded_rows = sqlite_select_rows_bounded($dbh, $guarded_sql);
is_deeply($guarded_rows, [ [ 0, undef ] ],
'an oversized guarded expression returns NULL');
is($guarded_calls, 0,
'an oversized guarded expression is not evaluated');
$dbh->do('CREATE TABLE sample (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, data BLOB)');
$dbh->do('INSERT INTO sample (name, data) VALUES (?, ?)', undef,
'first', "a\0b");
my $objects = sqlite_objects($dbh);
is(scalar(@$objects), 1, 'one table listed');
is($objects->[0]->{'name'}, 'sample', 'table name listed');
my $columns = sqlite_table_columns($dbh, 'sample');
is(scalar(@$columns), 3, 'table columns listed');
my $identity = sqlite_identity($dbh, $objects->[0], $columns);
is($identity->{'rowid'}, 'rowid', 'rowid selected for stable identity');
my $result = sqlite_query($dbh, 'SELECT name FROM sample', 10);
is_deeply($result->{'rows'}, [ [ 'first' ] ], 'query returns row data');
my $normal_limit = $dbh->sqlite_limit(
DBD::SQLite::Constants::SQLITE_LIMIT_LENGTH(), -1);
cmp_ok($normal_limit, '>', 1048576,
'database connections keep SQLite value limits unchanged');
# Schema SQL and default values must stay within the same result budgets as
# table data before Perl retains them.
my $oversized_default = 'x' x
(sqlite_result_bytes_limit() + 1024);
$dbh->do("CREATE TABLE oversized_schema (payload TEXT DEFAULT '".
$oversized_default."')");
my $objects_ok = eval { sqlite_objects($dbh); 1 };
ok(!$objects_ok, 'object lists reject oversized schema definitions');
like($@, qr/^result exceeds 1048576 bytes/,
'object-list failure reports the aggregate metadata limit');
my $object_ok = eval { sqlite_object($dbh, 'oversized_schema'); 1 };
ok(!$object_ok, 'single-object reads reject oversized schema definitions');
like($@, qr/^result exceeds 1048576 bytes/,
'single-object failure reports the aggregate metadata limit');
my $columns_ok = eval {
sqlite_table_columns($dbh, 'oversized_schema');
1;
};
ok(!$columns_ok, 'column reads reject oversized default expressions');
like($@, qr/^result exceeds 1048576 bytes/,
'column failure reports the aggregate metadata limit');
is($dbh->sqlite_limit(DBD::SQLite::Constants::SQLITE_LIMIT_LENGTH(), -1),
$normal_limit, 'metadata failures restore SQLite value limits');
$dbh->do('DROP TABLE oversized_schema');
my $result_dbh = sqlite_connect($database, 0);
is($result_dbh->sqlite_limit(
DBD::SQLite::Constants::SQLITE_LIMIT_LENGTH(), -1), $normal_limit,
'read connections restore SQLite value limits');
$result_dbh->disconnect();
# Exercise aggregate and hard value ceilings without restricting writes.
$dbh->do('CREATE TABLE result_sample (payload TEXT)');
my $result_insert = $dbh->prepare(
'INSERT INTO result_sample (payload) VALUES (?)');
$result_insert->execute('a' x 600000);
$result_insert->execute('b' x 600000);
my ($prefix, $full_length) = $dbh->selectrow_array(
'SELECT substr(payload, 1, 10), length(payload) '.
'FROM result_sample ORDER BY rowid LIMIT 1');
is($prefix, 'a' x 10,
'a shortened read succeeds when its source exceeds the budget');
is($full_length, 600000, 'the shortened read retains the source length');
my $byte_limited = sqlite_query($dbh,
'SELECT payload FROM result_sample ORDER BY rowid', 10);
is(scalar(@{$byte_limited->{'rows'}}), 1,
'query retains only rows inside the aggregate byte limit');
is($byte_limited->{'truncated_by'}, 'bytes',
'aggregate byte limit reports byte truncation');
is($dbh->sqlite_limit(DBD::SQLite::Constants::SQLITE_LIMIT_LENGTH(), -1),
$normal_limit, 'direct query restores SQLite value limits');
my $hard_limited = eval {
sqlite_query($dbh, 'SELECT zeroblob(67108865)', 10);
1;
};
ok(!$hard_limited, 'direct query rejects a value above the hard ceiling');
like($@, qr/^value exceeds 67108864/,
'hard ceiling failure reports the value limit');
is($dbh->sqlite_limit(DBD::SQLite::Constants::SQLITE_LIMIT_LENGTH(), -1),
$normal_limit, 'failed query restores SQLite value limits');
my $large_write = sqlite_query($dbh,
'INSERT INTO result_sample (payload) VALUES (zeroblob(2000000))', 10);
is($large_write->{'truncated'}, 0,
'direct SQL writes do not use the temporary result-value limit');
# A filename that resembles extra DBD parameters remains the literal
# validated filename instead of selecting a second database path.
my $injected = "$outside/injected.db";
my $crafted = "$allowed/literal;dbname=$injected";
(my $crafted_parent = $crafted) =~ s!/[^/]+$!!;
make_path($crafted_parent);
my $crafted_dbh = sqlite_connect($crafted, 1, 1);
my $resolved_crafted = simplify_path(resolve_links($crafted));
is($crafted_dbh->sqlite_db_filename(), $resolved_crafted,
'DBD opens the validated filename containing connection syntax');
$crafted_dbh->disconnect();
ok(-f $crafted, 'literal crafted filename is created inside the ACL');
ok(!-e $injected, 'second dbname text cannot escape the path ACL');
# A second statement is refused before anything runs, and the
# single-statement rule stays in force afterwards.
$text{'exec_multiple'} = 'Only one SQL statement can be run at a time';
eval { sqlite_query($dbh, 'SELECT name FROM sample; DROP TABLE sample', 10) };
like($@, qr/^Only one SQL statement/, 'a second statement is refused');
ok(sqlite_object($dbh, 'sample'), 'and the refused statement did not run');
ok(!$dbh->{'sqlite_allow_multiple_statements'},
'multiple statements stay disabled after the check');
$dbh->do('INSERT INTO sample (name) VALUES (?)', undef, 'second');
my $limited = sqlite_query($dbh, 'SELECT name FROM sample ORDER BY id', 1);
is_deeply($limited->{'rows'}, [ [ 'first' ] ], 'query result honors row limit');
is($limited->{'truncated_by'}, 'rows',
'limited query reports row truncation');
ok(sqlite_value_is_binary("a\0b"), 'binary value detected');
ok(!sqlite_value_is_binary('plain text'), 'text value detected');
# Keep an ASCII-only BLOB binary without fetching its bytes.
$dbh->do('CREATE TABLE blob_sample (id INTEGER PRIMARY KEY, data BLOB)');
my $blob_insert = $dbh->prepare(
'INSERT INTO blob_sample (data) VALUES (?)');
$blob_insert->bind_param(1, 'ascii blob', DBI::SQL_BLOB());
$blob_insert->execute();
my $blob_columns = sqlite_table_columns($dbh, 'blob_sample');
my ($blob_values, $blob_types, $blob_lengths) =
sqlite_column_selects($blob_columns);
my $blob_row = $dbh->selectrow_arrayref('SELECT '.join(', ',
@$blob_values, @$blob_types, @$blob_lengths).' FROM blob_sample');
is($blob_row->[1], undef, 'bounded row query omits the BLOB payload');
is($blob_row->[3], 'blob', 'bounded row query preserves the storage type');
is($blob_row->[5], 10, 'bounded row query preserves the BLOB length');
is(sqlite_display_value($blob_row->[1], $blob_row->[3], $blob_row->[5]),
'[binary value, 10 bytes]', 'ASCII BLOB is displayed as binary');
# Check quoted identifiers and WITHOUT ROWID identities.
my $odd_table = 'odd "table';
$dbh->do('CREATE TABLE '.sqlite_quote_identifier($odd_table).
' (first TEXT, second INTEGER, PRIMARY KEY (first, second)) WITHOUT ROWID');
my $odd_object = sqlite_object($dbh, $odd_table);
is($odd_object->{'type'}, 'table', 'quoted table name is found');
my $odd_columns = sqlite_table_columns($dbh, $odd_table);
is(scalar(@$odd_columns), 2, 'quoted table columns are listed');
is_deeply(sqlite_identity($dbh, $odd_object, $odd_columns),
{ 'columns' => [ 'first', 'second' ] },
'WITHOUT ROWID table uses its composite primary key');
$dbh->do('CREATE VIEW sample_view AS SELECT name FROM sample');
my $view = sqlite_object($dbh, 'sample_view');
ok(!sqlite_identity($dbh, $view, sqlite_table_columns($dbh, 'sample_view')),
'view is not editable');
ok(!sqlite_object($dbh, 'sqlite_sequence'),
'internal SQLite table cannot be browsed directly');
# Check read-only connections and authorizer restrictions.
my $readonly = sqlite_connect($database, 0);
my ($query_only) = $readonly->selectrow_array('PRAGMA query_only');
is($query_only, 1, 'read-only connection enables query-only mode');
my $wrote_readonly = eval {
$readonly->do("INSERT INTO sample (name) VALUES ('blocked')");
1;
};
ok(!$wrote_readonly, 'read-only connection rejects writes');
$readonly->disconnect();
sqlite_install_authorizer($dbh);
my $attached = eval { $dbh->do("ATTACH DATABASE '$outside/other.db' AS other"); 1 };
ok(!$attached, 'authorizer rejects attached database');
ok(!-e "$outside/other.db", 'rejected attachment creates no file');
my $vacuumed = eval { $dbh->do("VACUUM INTO '$outside/vacuum.db'"); 1 };
ok(!$vacuumed, 'authorizer rejects VACUUM INTO');
ok(!-e "$outside/vacuum.db", 'rejected VACUUM creates no file');
$dbh->disconnect();
}
done_testing();