mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-09-13 05:38:05 -04:00
database.php called dbConnect() at file scope, so including it opened a socket,
and on failure rendered views/no_database_connection.php and exit()ed from
inside a library include. Every model in web/includes requires this file, so
merely loading a class did both.
Connect on first use instead. $dbConn becomes tri-state - false for "not
attempted", null for "attempt failed", a PDO for connected - and two accessors
sit on top of it:
zmDbConn() opens if needed; on failure renders the error view and
stops, which is what the include used to do, just at the
point a query is actually attempted.
zmDbConnOrNull() opens if needed but returns null instead of ending the
request, for callers with a fallback.
dbQuery() is the funnel every fetch helper goes through, so routing it plus
dbEscape(), dbError() and dbInsertId() through the accessors covers the library.
The five callers that reached for the raw global are updated: config.php.in,
Event.php and ajax/console.php need a connection and take zmDbConn(); logger.php
takes zmDbConnOrNull() and falls through to its error_log target, so a logging
call can no longer end the request or open a connection by itself.
ZMSessionHandler captured $dbConn in its constructor. It is constructed while
session.php is being included, before anything has needed the database, so with
a lazy connection that captured false. It now resolves per call and its methods
return "no session" rather than dereferencing a bool.
Two smaller fixes fall out. The error view was included by a relative path that
only resolved when the cwd was web/, so it never worked for requests served out
of web/api/; it is now anchored with __DIR__. And dbDisconnect() set $dbConn to
null, which in the new tri-state means "connecting failed" and would send the
next query to the error page; it sets false so a later query can reconnect.
Nothing calls dbDisconnect() today.
This does NOT make database.php includable without a database. It requires
logger.php, which requires config.php, which reads ZoneMinder's configuration
out of the Config table at include time. Until that cycle is broken the
connection still happens during bootstrap, just from config.php rather than from
here.
Tests: tests/php/test_database_lazy_connect.php, 7 assertions, all pass. It
tokenises database.php and asserts nothing runs at include time, that dbQuery()
goes through the accessor, and that only the connection plumbing touches the
global. Verified it reports the pre-refactor file's `if ( !dbConnect() )` - an
earlier version of the check skipped tokens inside parentheses and so passed on
exactly the code it exists to reject.
Not covered by tests: behaviour when the database is genuinely unreachable, and
the session handler against a live database. Needs manual testing on an
installed tree, including stopping mysql to confirm the error view still renders
for both a web request and an API request.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01477mR97vfnK6zczbHgzq6T
120 lines
4.9 KiB
PHP
120 lines
4.9 KiB
PHP
<?php
|
|
// Guards the invariant that including web/includes/database.php does not open a
|
|
// database connection. It used to call dbConnect() at file scope and, on
|
|
// failure, render views/no_database_connection.php and exit() - from inside a
|
|
// library include. Every model in web/includes requires this file, so loading a
|
|
// class opened a socket and could terminate the request.
|
|
//
|
|
// The check is structural (over the token stream) rather than behavioural,
|
|
// because database.php still cannot be included in isolation: it requires
|
|
// logger.php, which requires config.php, which reads ZoneMinder's configuration
|
|
// out of the database at include time. Breaking that cycle is a separate piece
|
|
// of work; until then this is what can be verified without an installed tree.
|
|
//
|
|
// Run: php tests/php/test_database_lazy_connect.php
|
|
|
|
$failures = 0;
|
|
$passes = 0;
|
|
|
|
function check($name, $got, $expected) {
|
|
global $failures, $passes;
|
|
if ($got === $expected) {
|
|
$passes++;
|
|
echo "ok - $name\n";
|
|
} else {
|
|
$failures++;
|
|
echo "not ok - $name\n got: ".var_export($got, true)."\n expected: ".var_export($expected, true)."\n";
|
|
}
|
|
}
|
|
|
|
// Anything at file scope (brace depth 0) that would run on include: control
|
|
// flow, and calls other than the include family and define(), which cannot
|
|
// themselves query.
|
|
//
|
|
// Note there is deliberately no "skip inside parentheses" rule. The call this
|
|
// exists to catch was `if ( !dbConnect() )`, i.e. nested inside an if
|
|
// condition; skipping parenthesised tokens made an earlier version of this
|
|
// check pass on the very code it is meant to reject.
|
|
function topLevelCalls($path) {
|
|
$tokens = token_get_all(file_get_contents($path));
|
|
$allowed = array('require', 'require_once', 'include', 'include_once', 'define', 'defined');
|
|
$controlFlow = array(T_IF, T_SWITCH, T_WHILE, T_DO, T_FOR, T_FOREACH, T_TRY, T_ECHO, T_PRINT);
|
|
$found = array();
|
|
$depth = 0;
|
|
foreach ($tokens as $i => $token) {
|
|
if (is_string($token)) {
|
|
if ($token === '{') $depth++;
|
|
else if ($token === '}') $depth--;
|
|
continue;
|
|
}
|
|
if ($depth !== 0) continue;
|
|
if (in_array($token[0], $controlFlow, true)) {
|
|
$found[] = $token[1];
|
|
continue;
|
|
}
|
|
if ($token[0] !== T_STRING) continue;
|
|
if (in_array(strtolower($token[1]), $allowed, true)) continue;
|
|
|
|
// Skip the name in a declaration - `function foo(` looks like a call.
|
|
$isDeclaration = false;
|
|
for ($j = $i - 1; $j >= 0; $j--) {
|
|
$prev = $tokens[$j];
|
|
if (is_array($prev) and $prev[0] === T_WHITESPACE) continue;
|
|
$isDeclaration = is_array($prev)
|
|
and in_array($prev[0], array(T_FUNCTION, T_CLASS, T_INTERFACE, T_TRAIT, T_CONST), true);
|
|
break;
|
|
}
|
|
if ($isDeclaration) continue;
|
|
|
|
for ($j = $i + 1; $j < count($tokens); $j++) {
|
|
$next = $tokens[$j];
|
|
if (is_array($next) and $next[0] === T_WHITESPACE) continue;
|
|
if ($next === '(') $found[] = $token[1];
|
|
break;
|
|
}
|
|
}
|
|
return $found;
|
|
}
|
|
|
|
$dbPath = __DIR__.'/../../web/includes/database.php';
|
|
$source = file_get_contents($dbPath);
|
|
|
|
check('database.php calls nothing at include time', topLevelCalls($dbPath), array());
|
|
|
|
// Would pass vacuously if the accessors did not exist.
|
|
check('zmDbConn() is defined',
|
|
(bool)preg_match('/^function zmDbConn\(\)/m', $source), true);
|
|
check('zmDbConnOrNull() is defined',
|
|
(bool)preg_match('/^function zmDbConnOrNull\(\)/m', $source), true);
|
|
|
|
// Every query funnels through dbQuery(), so that is the one that must open the
|
|
// connection. If it went back to `global $dbConn` it would dereference `false`.
|
|
check('dbQuery() obtains the connection through the accessor',
|
|
(bool)preg_match('/function dbQuery\([^)]*\)\s*\{\s*\$dbConn = zmDbConn\(\);/', $source), true);
|
|
|
|
// No caller should reach the raw global any more except the accessors,
|
|
// dbConnect() itself, and dbDisconnect().
|
|
preg_match_all('/function (\w+)\s*\([^)]*\)\s*\{(?:[^{}]|\{[^{}]*\})*?global \$dbConn/', $source, $m);
|
|
sort($m[1]);
|
|
check('only the connection plumbing touches the $dbConn global',
|
|
$m[1], array('dbConnect', 'dbDisconnect', 'zmDbConn', 'zmDbConnOrNull'));
|
|
|
|
// Sanity-check the detector against files that do run code at include time, so
|
|
// a broken checker cannot silently report success above. The second fixture is
|
|
// the shape this file actually had before the change - the call nested inside
|
|
// an if condition - which an earlier version of this check failed to detect.
|
|
$fixture = tempnam(sys_get_temp_dir(), 'zmtest').'.php';
|
|
|
|
file_put_contents($fixture, "<?php\nrequire_once('x.php');\ndefine('A', 1);\nfunction f() { g(); }\ndbConnect();\n");
|
|
check('the detector finds a bare file-scope call', topLevelCalls($fixture), array('dbConnect'));
|
|
|
|
file_put_contents($fixture, "<?php\nfunction f() { g(); }\nif ( !dbConnect() ) {\n include('v.php');\n exit();\n}\n");
|
|
check('the detector finds a call nested in a file-scope if',
|
|
topLevelCalls($fixture), array('if', 'dbConnect'));
|
|
|
|
unlink($fixture);
|
|
|
|
echo "\n$passes passed, $failures failed\n";
|
|
exit($failures ? 1 : 0);
|
|
?>
|