Files
zoneminder/tests/php/test_auth_no_include_side_effects.php
Isaac ConnorandClaude Opus 5 4b79fac6cd test: detect calls nested in file-scope conditions
The side-effect detector skipped tokens inside parentheses, so a call in a
condition - `if ( !dbConnect() )`, the shape database.php had - was invisible to
it. Only the enclosing control-flow keyword was reported, and a file whose sole
include-time work sat inside a condition would have passed.

Drop the parenthesis rule and add a fixture for that shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01477mR97vfnK6zczbHgzq6T
2026-08-17 20:52:10 -04:00

109 lines
4.4 KiB
PHP

<?php
// Guards the invariant that including web/includes/auth.php only *defines*
// things. It used to run a 130-line block at file scope, so requiring the file
// authenticated the current request: reading $_REQUEST, opening a session,
// querying the database, potentially logging a user in and rewriting their
// stored password hash. That work now lives in zm_authenticate_request(), which
// callers invoke deliberately.
//
// This is a structural check over the token stream rather than a behavioural
// one, because actually including auth.php still requires a database (User.php
// pulls in database.php, which connects at include time). If that changes, this
// can become a real behavioural test.
//
// Run: php tests/php/test_auth_no_include_side_effects.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";
}
}
// Return every statement at file scope (brace depth 0) that would *execute*
// when the file is included: control flow, and calls other than the include
// family and define(). Each entry is "line: snippet".
function topLevelExecutableStatements($path) {
$tokens = token_get_all(file_get_contents($path));
$allowedCalls = 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);
// No "skip inside parentheses" rule: a call nested in a condition, such as
// `if ( !dbConnect() )`, is still executed on include, and skipping
// parenthesised tokens would hide it.
$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;
list($id, $text, $line) = array($token[0], $token[1], $token[2]);
if (in_array($id, $controlFlow, true)) {
$found[] = "$line: $text";
continue;
}
// A bare function call at file scope, e.g. `zm_authenticate_request();`.
// The name in a `function foo(` / `class Foo` declaration is also a T_STRING
// followed by '(', so look back and skip declarations.
if ($id === T_STRING and !in_array(strtolower($text), $allowedCalls, true)) {
$declaration = false;
for ($j = $i - 1; $j >= 0; $j--) {
$prev = $tokens[$j];
if (is_array($prev) and $prev[0] === T_WHITESPACE) continue;
$declaration = is_array($prev)
and in_array($prev[0], array(T_FUNCTION, T_CLASS, T_INTERFACE, T_TRAIT, T_CONST), true);
break;
}
if ($declaration) 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[] = "$line: $text(";
break;
}
}
}
return $found;
}
$authPath = __DIR__.'/../../web/includes/auth.php';
check('auth.php executes nothing at include time',
topLevelExecutableStatements($authPath), array());
// The extracted work must still exist, or the above would pass vacuously.
$source = file_get_contents($authPath);
check('zm_authenticate_request() is defined',
(bool)preg_match('/^function zm_authenticate_request\(\)/m', $source), true);
// Sanity-check the detector itself against a file that does run code on
// include, so a broken checker cannot silently report success above.
$fixture = tempnam(sys_get_temp_dir(), 'zmtest').'.php';
file_put_contents($fixture, "<?php\nrequire_once('x.php');\nfunction f() { if (true) { g(); } }\nif (SOMETHING) {\n h();\n}\n");
$detected = topLevelExecutableStatements($fixture);
check('the detector finds a file-scope if()', count($detected), 1);
check('the detector ignores code inside functions and require_once',
$detected, array('4: if'));
// A call nested inside a file-scope condition still runs on include.
file_put_contents($fixture, "<?php\nfunction f() { g(); }\nif ( !connect() ) {\n exit();\n}\n");
check('the detector finds a call nested in a file-scope if',
topLevelExecutableStatements($fixture), array('3: if', '3: connect('));
unlink($fixture);
echo "\n$passes passed, $failures failed\n";
exit($failures ? 1 : 0);
?>