From eba7381da2c7207b6c2bfdf75f7d9bf05e03aa46 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 26 Oct 2026 10:08:37 -0400 Subject: [PATCH 1/9] fix(inspections): newbb false positives and MenuAutoload fixtures for alpha 5 DeprecatedCoreApi no longer names synchronization(): it is the module handlers' own method and the core method carries no deprecation tag. InstallSql and SqlFileComment look only at the file the installer runs (sql/mysql.sql or a file the manifest names); dumps and old upgrade scripts beside it are skipped. Unlink accepts an SplFileInfo isFile()/isLink() check on the object whose getRealPath()/getPathname() is unlinked. MenuAutoload gains fixtures for a same-line strict_types declare, header comments, a bootstrap require, and newbb's real CRLF file. gradlew test -PlocalIde= runs the suite against an installed IDE. Version 1.0.0-alpha.5, changelog section added. --- .gitattributes | 2 ++ CHANGELOG.md | 21 ++++++++++++ build.gradle.kts | 4 ++- gradle.properties | 2 +- .../XoopsDeprecatedCoreApiInspection.kt | 1 - .../inspections/XoopsSmallFixInspections.kt | 10 +++++- .../inspections/XoopsSqlFileInspections.kt | 15 ++++++-- .../org/xoops/support/XoopsQuickFixTest.kt | 23 +++++++++++++ .../InstallSql/fk/modules/demo/sql/dump.sql | 8 +++++ .../bootstrap/modules/demo/admin/menu.php | 7 ++++ .../bootstrap/modules/demo/bootstrap.php | 2 ++ .../bootstrap/modules/demo/class/Helper.php | 11 ++++++ .../modules/demo/preloads/autoloader.php | 2 ++ .../bootstrap/modules/demo/xoops_version.php | 2 ++ .../crlf/modules/demo/admin/menu.after.php | 29 ++++++++++++++++ .../crlf/modules/demo/admin/menu.php | 28 +++++++++++++++ .../crlf/modules/demo/class/Helper.php | 11 ++++++ .../crlf/modules/demo/preloads/autoloader.php | 2 ++ .../crlf/modules/demo/xoops_version.php | 2 ++ .../strict/modules/demo/admin/menu.after.php | 8 +++++ .../strict/modules/demo/admin/menu.php | 7 ++++ .../strict/modules/demo/class/Helper.php | 11 ++++++ .../modules/demo/preloads/autoloader.php | 2 ++ .../strict/modules/demo/xoops_version.php | 2 ++ .../modules/demo/admin/menu.after.php | 32 +++++++++++++++++ .../tagstyle/modules/demo/admin/menu.php | 31 +++++++++++++++++ .../tagstyle/modules/demo/class/Helper.php | 11 ++++++ .../modules/demo/preloads/autoloader.php | 2 ++ .../tagstyle/modules/demo/xoops_version.php | 2 ++ .../Unlink/spl/modules/demo/class/Cleaner.php | 34 +++++++++++++++++++ 30 files changed, 318 insertions(+), 6 deletions(-) create mode 100644 src/test/resources/fixtures/InstallSql/fk/modules/demo/sql/dump.sql create mode 100644 src/test/resources/fixtures/MenuAutoload/bootstrap/modules/demo/admin/menu.php create mode 100644 src/test/resources/fixtures/MenuAutoload/bootstrap/modules/demo/bootstrap.php create mode 100644 src/test/resources/fixtures/MenuAutoload/bootstrap/modules/demo/class/Helper.php create mode 100644 src/test/resources/fixtures/MenuAutoload/bootstrap/modules/demo/preloads/autoloader.php create mode 100644 src/test/resources/fixtures/MenuAutoload/bootstrap/modules/demo/xoops_version.php create mode 100644 src/test/resources/fixtures/MenuAutoload/crlf/modules/demo/admin/menu.after.php create mode 100644 src/test/resources/fixtures/MenuAutoload/crlf/modules/demo/admin/menu.php create mode 100644 src/test/resources/fixtures/MenuAutoload/crlf/modules/demo/class/Helper.php create mode 100644 src/test/resources/fixtures/MenuAutoload/crlf/modules/demo/preloads/autoloader.php create mode 100644 src/test/resources/fixtures/MenuAutoload/crlf/modules/demo/xoops_version.php create mode 100644 src/test/resources/fixtures/MenuAutoload/strict/modules/demo/admin/menu.after.php create mode 100644 src/test/resources/fixtures/MenuAutoload/strict/modules/demo/admin/menu.php create mode 100644 src/test/resources/fixtures/MenuAutoload/strict/modules/demo/class/Helper.php create mode 100644 src/test/resources/fixtures/MenuAutoload/strict/modules/demo/preloads/autoloader.php create mode 100644 src/test/resources/fixtures/MenuAutoload/strict/modules/demo/xoops_version.php create mode 100644 src/test/resources/fixtures/MenuAutoload/tagstyle/modules/demo/admin/menu.after.php create mode 100644 src/test/resources/fixtures/MenuAutoload/tagstyle/modules/demo/admin/menu.php create mode 100644 src/test/resources/fixtures/MenuAutoload/tagstyle/modules/demo/class/Helper.php create mode 100644 src/test/resources/fixtures/MenuAutoload/tagstyle/modules/demo/preloads/autoloader.php create mode 100644 src/test/resources/fixtures/MenuAutoload/tagstyle/modules/demo/xoops_version.php create mode 100644 src/test/resources/fixtures/Unlink/spl/modules/demo/class/Cleaner.php diff --git a/.gitattributes b/.gitattributes index 438e25a..3ae30ab 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,5 @@ *.png binary *.svg text gradlew text eol=lf +# newbb fixture kept byte for byte: two spaces after isFile()` (or `isLink()`) dominating + `unlink($info->getRealPath())`, or a variable assigned from `$info->getPathname()`, is the is_file() check. +- **MenuAutoload** gains fixtures for `` runs the suite against an installed IDE instead of the + pinned 2024.3.5 platform, so a parser change in a newer PhpStorm is caught before a report comes in. + ## 1.0.0-alpha.4 — 1.0.0 Alpha 4 — Marketplace release 2026-10-26 Alpha 4 as published: the rebuild below (2026-10-24) plus the refinement rounds that followed it, six full runs over diff --git a/build.gradle.kts b/build.gradle.kts index 7b7a614..93b2680 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,7 +21,9 @@ repositories { dependencies { intellijPlatform { - phpstorm("2024.3.5") + // `-PlocalIde=` runs the suite against an installed IDE (newer parser, same tests). + val localIde = findProperty("localIde")?.toString() + if (localIde != null) local(localIde) else phpstorm("2024.3.5") bundledPlugin("com.jetbrains.php") testFramework(TestFrameworkType.Platform) } diff --git a/gradle.properties b/gradle.properties index fc76a0e..c0ff405 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -pluginVersion=1.0.0-alpha.4 +pluginVersion=1.0.0-alpha.5 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.caching=true org.gradle.parallel=true diff --git a/src/main/kotlin/org/xoops/support/inspections/XoopsDeprecatedCoreApiInspection.kt b/src/main/kotlin/org/xoops/support/inspections/XoopsDeprecatedCoreApiInspection.kt index 6579174..5c06c75 100644 --- a/src/main/kotlin/org/xoops/support/inspections/XoopsDeprecatedCoreApiInspection.kt +++ b/src/main/kotlin/org/xoops/support/inspections/XoopsDeprecatedCoreApiInspection.kt @@ -220,7 +220,6 @@ class XoopsDeprecatedCoreApiInspection : XoopsInspection() { // config / blocks / model "deleteConfigOption" to ("use the config handler's deleteConfig()" to null), "getCatByModule" to ("use getConfigsByModule()" to null), - "synchronization" to ("use the model's getAll()/getObjects()" to null), "getAllBlocksByGroup" to ("use XoopsBlockHandler::getAllBlocksByGroup() through xoops_getHandler('block')" to null), // Smarty 2 names on XoopsTpl (Smarty 3/4/5) "assign_by_ref" to ("use assign()" to "assign"), diff --git a/src/main/kotlin/org/xoops/support/inspections/XoopsSmallFixInspections.kt b/src/main/kotlin/org/xoops/support/inspections/XoopsSmallFixInspections.kt index 77a2def..091289e 100644 --- a/src/main/kotlin/org/xoops/support/inspections/XoopsSmallFixInspections.kt +++ b/src/main/kotlin/org/xoops/support/inspections/XoopsSmallFixInspections.kt @@ -201,7 +201,11 @@ class XoopsUnlinkInspection : XoopsInspection() { // `$resolved = realpath($x)` names the file `$x` names: a check on either path counts. val alias = ((Php.unwrap(arg) as? Variable)?.let(Php::resolveAssignment) as? FunctionReference) ?.takeIf { Php.isCall(it, "realpath") && it !is MethodReference }?.parameters?.firstOrNull() - val keys = setOfNotNull(normalize(arg.text), alias?.let { normalize(it.text) }) + // `unlink($info->getRealPath())`, or `$name = $info->getPathname(); unlink($name)`: the SplFileInfo + // object names the file, so `$info->isFile()` is the check (newbb FilesManagement, DirectoryIterator loops). + val target = Php.unwrap(arg).let { (it as? Variable)?.let(Php::resolveAssignment) ?: it } + val spl = (target as? MethodReference)?.takeIf { it.name in splPaths }?.classReference?.text?.let { SPL + normalize(it) } + val keys = setOfNotNull(normalize(arg.text), alias?.let { normalize(it.text) }, spl) if (dominated(reference, keys)) return holder.registerProblem( reference as PsiElement, @@ -214,8 +218,12 @@ class XoopsUnlinkInspection : XoopsInspection() { private fun normalize(text: String) = text.replace(Regex("\\s+"), "") + private val splPaths = setOf("getRealPath", "getPathname", "getPath") + private val SPL = "spl:" + private fun checks(scope: PsiElement, keys: Set): List = Php.calls(scope, "is_file", "file_exists", "is_link").filter { c -> c.parameters.firstOrNull()?.let { normalize(it.text) } in keys } + + Php.methodCalls(scope, "isFile", "isLink").filter { c -> c.classReference?.text?.let { SPL + normalize(it) } in keys } + PsiTreeUtil.collectElementsOfType(scope, MethodReference::class.java).filter { helperChecks(it, keys) } /** diff --git a/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt b/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt index c49d5e3..de5b935 100644 --- a/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt +++ b/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt @@ -42,6 +42,17 @@ object InstallSql { return p.endsWith(".sql") && p.contains("/sql/") && !XoopsPaths.isExcluded(file) && !XoopsPaths.isTestTree(file) } + /** + * The installer runs the file: `mysql.sql`, or any file the manifest names. A `sql/` folder also holds + * Adminer / phpMyAdmin dumps and old upgrade scripts (newbb: `2510debug1.sql`, `xoops_mysql.sql`, + * `mysql.400.sql`) that SqlUtility never sees; their engine, prefix and `--` lines are not findings. + */ + fun installerFile(file: PsiFile): Boolean { + if (file.name.equals("mysql.sql", true)) return true + val manifest = read(file.virtualFile?.parent?.parent?.findChild("xoops_version.php")) ?: return false + return manifest.contains(file.name, ignoreCase = true) + } + /** `-- ` and `#` line comments and block comments blanked, offsets kept. */ fun stripComments(text: String): String { val out = StringBuilder(text.length) @@ -89,7 +100,7 @@ class XoopsInstallSqlInspection : LocalInspectionTool() { override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor { return object : PsiElementVisitor() { override fun visitFile(file: PsiFile) { - if (!InstallSql.isInstallerSql(file)) return + if (!InstallSql.isInstallerSql(file) || !InstallSql.installerFile(file)) return val code = InstallSql.stripComments(file.text) for (m in foreignKey.findAll(code)) { InstallSql.report( @@ -204,7 +215,7 @@ class XoopsSqlFileCommentInspection : LocalInspectionTool() { override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor { return object : PsiElementVisitor() { override fun visitFile(file: PsiFile) { - if (!InstallSql.isInstallerSql(file)) return + if (!InstallSql.isInstallerSql(file) || !InstallSql.installerFile(file)) return val text = file.text for (offset in SqlCommentText.bareComments(text)) { val line = text.substring(0, offset).count { it == '\n' } diff --git a/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt b/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt index ea038b2..7d9134c 100644 --- a/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt +++ b/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt @@ -339,6 +339,13 @@ class XoopsQuickFixTest : BasePlatformTestCase() { fun testUnlinkGuardedByWholeExpression() = check(XoopsUnlinkInspection(), "Unlink/unlink", "modules/demo/include/onupdate.php", 1) + /** `$info->isFile()` guards `unlink($info->getRealPath())` and `$name = $info->getPathname(); unlink($name)`. */ + fun testUnlinkGuardedBySplFileInfoIsFile() = + check(XoopsUnlinkInspection(), "Unlink/spl", "modules/demo/class/Cleaner.php", 1) + + fun testInstallSqlIgnoresDumpsTheInstallerNeverRuns() = + checkSql(XoopsInstallSqlInspection(), "InstallSql/fk", "modules/demo/sql/dump.sql", 0) + fun testByRefResolvesParentConstructor() = check(XoopsByRefArgumentInspection(), "ByRefArgument/byref", "modules/demo/class/Form/CategoryForm.php", 1) @@ -515,6 +522,22 @@ class XoopsQuickFixTest : BasePlatformTestCase() { check(XoopsMenuAutoloadInspection(), "MenuAutoload/menu", "modules/demo/admin/menu.php", 1, "Require preloads", "modules/demo/admin/menu.after.php") + fun testMenuWithStrictTypesGetsAutoloaderBeforeHelper() = + check(XoopsMenuAutoloadInspection(), "MenuAutoload/strict", "modules/demo/admin/menu.php", 1, + "Require preloads", "modules/demo/admin/menu.after.php") + + fun testMenuWithStrictTypesAndBootstrapRequireIsFine() = + check(XoopsMenuAutoloadInspection(), "MenuAutoload/bootstrap", "modules/demo/admin/menu.php", 0) + + fun testMenuWithStrictTypesHeaderCommentsGetsAutoloaderBeforeHelper() = + check(XoopsMenuAutoloadInspection(), "MenuAutoload/tagstyle", "modules/demo/admin/menu.php", 1, + "Require preloads", "modules/demo/admin/menu.after.php") + + /** newbb's real menu.php: two spaces after the open tag, CRLF line endings, the Helper call several statements down. */ + fun testMenuWithCrlfAndDoubleSpacedDeclareGetsAutoloaderBeforeHelper() = + check(XoopsMenuAutoloadInspection(), "MenuAutoload/crlf", "modules/demo/admin/menu.php", 1, + "Require preloads", "modules/demo/admin/menu.after.php") + fun testClassConstantDoesNotLoadTheClass() = check(XoopsMenuAutoloadInspection(), "MenuAutoload/classconst", "modules/demo/admin/menu.php", 0) diff --git a/src/test/resources/fixtures/InstallSql/fk/modules/demo/sql/dump.sql b/src/test/resources/fixtures/InstallSql/fk/modules/demo/sql/dump.sql new file mode 100644 index 0000000..f9a6b05 --- /dev/null +++ b/src/test/resources/fixtures/InstallSql/fk/modules/demo/sql/dump.sql @@ -0,0 +1,8 @@ +-- Adminer 4.6.3 MySQL dump + +SET NAMES utf8; + +CREATE TABLE `xoops_demo_debug` ( + `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`id`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8; diff --git a/src/test/resources/fixtures/MenuAutoload/bootstrap/modules/demo/admin/menu.php b/src/test/resources/fixtures/MenuAutoload/bootstrap/modules/demo/admin/menu.php new file mode 100644 index 0000000..46dc74a --- /dev/null +++ b/src/test/resources/fixtures/MenuAutoload/bootstrap/modules/demo/admin/menu.php @@ -0,0 +1,7 @@ +loadLanguage('modinfo'); +$adminmenu[] = ['title' => 'Home', 'link' => 'admin/index.php']; diff --git a/src/test/resources/fixtures/MenuAutoload/bootstrap/modules/demo/bootstrap.php b/src/test/resources/fixtures/MenuAutoload/bootstrap/modules/demo/bootstrap.php new file mode 100644 index 0000000..05d869f --- /dev/null +++ b/src/test/resources/fixtures/MenuAutoload/bootstrap/modules/demo/bootstrap.php @@ -0,0 +1,2 @@ +loadLanguage('common'); +$adminmenu[] = ['title' => 'Home', 'link' => 'admin/index.php']; diff --git a/src/test/resources/fixtures/MenuAutoload/crlf/modules/demo/admin/menu.php b/src/test/resources/fixtures/MenuAutoload/crlf/modules/demo/admin/menu.php new file mode 100644 index 0000000..dfbc35c --- /dev/null +++ b/src/test/resources/fixtures/MenuAutoload/crlf/modules/demo/admin/menu.php @@ -0,0 +1,28 @@ +loadLanguage('common'); +$adminmenu[] = ['title' => 'Home', 'link' => 'admin/index.php']; diff --git a/src/test/resources/fixtures/MenuAutoload/crlf/modules/demo/class/Helper.php b/src/test/resources/fixtures/MenuAutoload/crlf/modules/demo/class/Helper.php new file mode 100644 index 0000000..b7fedeb --- /dev/null +++ b/src/test/resources/fixtures/MenuAutoload/crlf/modules/demo/class/Helper.php @@ -0,0 +1,11 @@ + 'Home', 'link' => 'admin/index.php']; diff --git a/src/test/resources/fixtures/MenuAutoload/strict/modules/demo/admin/menu.php b/src/test/resources/fixtures/MenuAutoload/strict/modules/demo/admin/menu.php new file mode 100644 index 0000000..db18631 --- /dev/null +++ b/src/test/resources/fixtures/MenuAutoload/strict/modules/demo/admin/menu.php @@ -0,0 +1,7 @@ + 'Home', 'link' => 'admin/index.php']; diff --git a/src/test/resources/fixtures/MenuAutoload/strict/modules/demo/class/Helper.php b/src/test/resources/fixtures/MenuAutoload/strict/modules/demo/class/Helper.php new file mode 100644 index 0000000..b7fedeb --- /dev/null +++ b/src/test/resources/fixtures/MenuAutoload/strict/modules/demo/class/Helper.php @@ -0,0 +1,11 @@ + + * @since 1.00 + */ + +use Xmf\Module\Admin; +use XoopsModules\Tag\{ + Helper +}; + +$moduleDirName = \basename(\dirname(__DIR__)); +$moduleDirNameUpper = \mb_strtoupper($moduleDirName); + +require_once \dirname(__DIR__) . '/preloads/autoloader.php'; +$helper = Helper::getInstance(); +$helper->loadLanguage('common'); +$adminmenu[] = ['title' => 'Home', 'link' => 'admin/index.php']; diff --git a/src/test/resources/fixtures/MenuAutoload/tagstyle/modules/demo/admin/menu.php b/src/test/resources/fixtures/MenuAutoload/tagstyle/modules/demo/admin/menu.php new file mode 100644 index 0000000..07b3c6c --- /dev/null +++ b/src/test/resources/fixtures/MenuAutoload/tagstyle/modules/demo/admin/menu.php @@ -0,0 +1,31 @@ + + * @since 1.00 + */ + +use Xmf\Module\Admin; +use XoopsModules\Tag\{ + Helper +}; + +$moduleDirName = \basename(\dirname(__DIR__)); +$moduleDirNameUpper = \mb_strtoupper($moduleDirName); + +$helper = Helper::getInstance(); +$helper->loadLanguage('common'); +$adminmenu[] = ['title' => 'Home', 'link' => 'admin/index.php']; diff --git a/src/test/resources/fixtures/MenuAutoload/tagstyle/modules/demo/class/Helper.php b/src/test/resources/fixtures/MenuAutoload/tagstyle/modules/demo/class/Helper.php new file mode 100644 index 0000000..b7fedeb --- /dev/null +++ b/src/test/resources/fixtures/MenuAutoload/tagstyle/modules/demo/class/Helper.php @@ -0,0 +1,11 @@ +isFile()) { + $filename = $fObj->getPathname(); + $fObj = null; + if (!\unlink($filename)) { + return false; + } + } + } + + return true; + } + + public function removeEntry(\SplFileInfo $info): void + { + if ($info->isFile() || $info->isLink()) { + \unlink($info->getRealPath()); + } + } + + public function removeBlindly(\SplFileInfo $info): void + { + \unlink($info->getRealPath()); + } +} From e54332a62a572ef062a8cd664bfa9cc4c4cfa5ae Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 26 Oct 2026 10:17:16 -0400 Subject: [PATCH 2/9] docs: INSPECTIONS.md lists every inspection, its level, fix and silences One page for all 52 inspections, written from the inspection descriptions PhpStorm shows in its settings: default level, what each reports, what the quick-fix changes and when the rule stays silent. README drops its Alpha 3 era table in favour of a summary and a link; TUTORIAL links to the page from its inspections section. --- CHANGELOG.md | 2 + INSPECTIONS.md | 431 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 45 +----- TUTORIAL.md | 6 +- 4 files changed, 445 insertions(+), 39 deletions(-) create mode 100644 INSPECTIONS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e76841..8984aa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ checked against the module source and pinned with a fixture. it there on PhpStorm 2024.3 and 2026.3. - **Build:** `gradlew test -PlocalIde=` runs the suite against an installed IDE instead of the pinned 2024.3.5 platform, so a parser change in a newer PhpStorm is caught before a report comes in. +- **Docs:** `INSPECTIONS.md` lists all 52 inspections with default level, what each reports, what its quick-fix + changes and when it stays silent. README and TUTORIAL link to it; the README's Alpha 3 era table is gone. ## 1.0.0-alpha.4 — 1.0.0 Alpha 4 — Marketplace release 2026-10-26 diff --git a/INSPECTIONS.md b/INSPECTIONS.md new file mode 100644 index 0000000..fe0656e --- /dev/null +++ b/INSPECTIONS.md @@ -0,0 +1,431 @@ +# XOOPS Support inspections + +Every inspection the plugin registers (52 in 1.0.0 Alpha 5), what it reports, what its quick-fix changes, and when it +stays silent. All of them live under **Settings → Editor → Inspections → PHP → XOOPS** and can be switched off or +re-levelled there; the levels below are the defaults. The same text, one page per inspection, is what PhpStorm shows +in the inspection's description pane. + +Conventions used throughout: + +- **Report only** means the finding has no quick-fix, because the right change depends on a decision the author must + make (which column to allow-list, whether a table is old enough to need no update path). +- A fix that is **Alt+Enter only** is offered in the editor but excluded from "Fix all" and the batch Inspect Code + run, because applying it blindly can change behaviour. +- **Test trees** (`tests/`, `test/`, `testdata/`, `fixtures/`), `assets/js/`, `jpgraph/` and `00/` are skipped by + every rule; third-party and vendor code is skipped by the rules that say so. +- **Core version** gating reads `include/version.php` of the project, or the Core Version setting when the module is + opened standalone. Rules that only matter on core 2.8 (Smarty 5) stay silent on a 2.5 / 2.7 tree. + +## Index + +| Short name | Level | Default | Quick-fix | Section | +|---|---|---|---|---| +| XoopsAllowlistAnchor | Warning | on | yes | [Security](#security) | +| XoopsAttributeEscape | Warning | on | yes | [Security](#security) | +| XoopsCsrf | Warning | on | yes | [Security](#security) | +| XoopsDynamicCode | Error | on | no | [Security](#security) | +| XoopsGuestUser | Warning | on | yes | [Security](#security) | +| XoopsLegacySuperglobal | Error | on | yes | [Security](#security) | +| XoopsOrderBy | Warning | on | no | [Security](#security) | +| XoopsPathContainment | Warning | on | no | [Security](#security) | +| XoopsSmartyRequestEscape | Warning | on | yes | [Security](#security) | +| XoopsSqlConcat | Warning | on | no | [Security](#security) | +| XoopsSuperglobal | Weak warning | on | yes | [Security](#security) | +| XoopsUnserialize | Warning | on | yes | [Security](#security) | +| XoopsWeakToken | Warning | on | yes | [Security](#security) | +| XoopsDeprecatedDbApi | Warning | on | yes | [Database](#database) | +| XoopsInstallSql | Warning | on | no | [Database](#database) | +| XoopsQueryExec | Warning | on | yes | [Database](#database) | +| XoopsResultSetGuard | Weak warning | on | yes | [Database](#database) | +| XoopsSqlComment | Error | on | yes | [Database](#database) | +| XoopsSqlFileComment | Error | on | yes | [Database](#database) | +| XoopsUpdateCreatesTable | Warning | on | no | [Database](#database) | +| XoopsByRefArgument | Warning | on | no | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsDoubleCpHeader | Error | on | Alt+Enter only | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsFileHeader | Weak warning | on | yes | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsFormLoader | Warning | on | yes | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsHandlerSignature | Error | on | no | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsHttpStatus | Weak warning | on | yes | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsIncludeOnceHeader | Weak warning | on | yes | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsIsFileAsset | Weak warning | on | yes | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsLocalXoopsOption | Warning | on | yes | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsMenuAutoload | Error | on | yes | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsPreloadGlobal | Warning | on | no | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsRootPathGuard | Weak warning | on | yes | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsSetcookie | Weak warning | on | yes | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsStrictCalendarCast | Warning | on | yes | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsUnlink | Weak warning | on | no | [Module files and bootstrap](#module-files-and-bootstrap) | +| XoopsMissingRegisteredTemplate | Warning | on | yes | [Templates and Smarty](#templates-and-smarty) | +| XoopsSmarty5Api | Warning | on | yes | [Templates and Smarty](#templates-and-smarty) | +| XoopsSmarty5TemplatePrep | Weak warning | **off** | no | [Templates and Smarty](#templates-and-smarty) | +| XoopsSmartyBlockShorthand | Warning | on | yes | [Templates and Smarty](#templates-and-smarty) | +| XoopsSmartyDateFormatStrftime | Warning | on | yes | [Templates and Smarty](#templates-and-smarty) | +| XoopsSmartyForeachItemShadowsFrom | Error | on | yes | [Templates and Smarty](#templates-and-smarty) | +| XoopsSmartyRemovedTag | Error | on | yes | [Templates and Smarty](#templates-and-smarty) | +| XoopsSmartyUnquotedPluginArg | Error | on | yes | [Templates and Smarty](#templates-and-smarty) | +| XoopsUnregisteredTemplate | Weak warning | on | Alt+Enter only | [Templates and Smarty](#templates-and-smarty) | +| XoopsWrongSmartyDelimiter | Error | on | yes | [Templates and Smarty](#templates-and-smarty) | +| XoopsDuplicateDefine | Warning | on | yes | [Language files](#language-files) | +| XoopsLanguageLoad | Weak warning | **off** | no | [Language files](#language-files) | +| XoopsLanguageRole | Weak warning | on | no | [Language files](#language-files) | +| XoopsLocaleConstant | Warning | on | no | [Language files](#language-files) | +| XoopsThemeConstant | Weak warning | on | no | [Language files](#language-files) | +| XoopsDeprecatedCoreApi | Warning | on | yes | [Deprecated APIs](#deprecated-apis) | +| XoopsDoubleEncodedAmp | Error | on | yes | [Deprecated APIs](#deprecated-apis) | + +## Security + +### XoopsCsrf — Mutating switch branch without CSRF token check +A `switch ($op)` branch that writes (handler insert or delete, `exec()`, file writes) without a CSRF token check. The +branch content decides, never the case label. A branch is covered by its own `$GLOBALS['xoopsSecurity']->check()` or +`xoops_confirm()` (directly, or through a function in the same file that checks and stops), by a top-level gate +before the switch, or by an `if (!$GLOBALS['xoopsSecurity']->check())` before it that exits or resets `$op` for the +branch's value. A gate behind any other condition, in an `else` or a loop covers nothing; a gate that only runs for +POST covers nothing when `$op` is read from GET and the GET path still reaches the switch. +**Fix:** inserts the token check at the top of the branch. + +### XoopsSuperglobal — Raw superglobal instead of Xmf\Request +Raw `$_GET` / `$_POST` / `$_COOKIE` reads go through `Xmf\Request`; `$_REQUEST` is banned outright. Checked in any +file that boots XOOPS (it names `xoops`, `mainfile.php` or `Xmf`), `assets/` endpoints included; a bundled script +that never loads the core is skipped. A write, `unset()`, `empty()`, a `=== null` comparison, a dynamic key, a bare +`$_POST` and a dynamic callee (`new $_REQUEST['c']()`) are reported without a fix. An assignment to `$_POST` itself +gets its own message: it changes what every later reader sees, `Xmf\Request` included. Not inspected: Protector (it +rewrites the superglobals before XOOPS boots), the core installer and upgrader (they run before `Xmf\Request` can +load), and test trees. +**Fix:** the accessor is chosen from the use: `getArray()` for `foreach`, `count()`, `[…]`, an array-typed parameter +or an `XOBJ_DTYPE_ARRAY` field; `getInt()` for `(int)`, `intval()` or arithmetic; `getString()` for a literal +comparison, `switch` or array key; `getText()` for concatenation, `echo`, string functions and text fields (it keeps +HTML). `isset()` and a strict null comparison become `hasVar()`; `isset ? : 'd'` and `?? 'd'` keep the default. Every +other read gets the byte-identical raw accessor `getVar(key, null, hash, 'none', MASK_ALLOW_RAW | MASK_NO_TRIM)`. +`$_REQUEST` maps to the library's `'default'` hash. The batch run offers three buttons: reads only, reads and +`isset()`, everything. + +### XoopsLegacySuperglobal — $HTTP_POST_VARS and other removed superglobals +`$HTTP_POST_VARS`, `$HTTP_GET_VARS` and the other long arrays were removed in PHP 5.4: the page reads null. +**Fix:** renames the variable to its `$_POST` / `$_GET` counterpart; XoopsSuperglobal then takes over. + +### XoopsSqlConcat — Request value concatenated into SQL +`$_GET['id']`, `$_POST[…]`, `$_REQUEST[…]` or `Request::getString()` concatenated into the SQL that reaches `query()`, +`exec()` or `queryF()`, with no `quote()`, `(int)` or `intval()` around it. The `$sql` variable is followed back to +its assignment in the same block. Report only: the right fix (quote, cast or allow-list) depends on the column. + +### XoopsOrderBy — ORDER BY built from getSort()/getOrder() or the request +`' ORDER BY ' . $criteria->getSort()` concatenates whatever `setSort()` received; `setSort(Request::getString('sort'))` +hands the column to the client. Quoting does not help: a column is an identifier, not a string. Allow-list the column +or use `XoopsObject::buildOrderBy()`. `getSort()` passed to `buildOrderBy()` is allow-listed and not reported. +Report only. + +### XoopsUnserialize — unserialize() without allowed_classes +`unserialize()` with one argument can instantiate arbitrary classes. +**Fix:** adds `['allowed_classes' => false]`; the argument count comes from the PSI, so an inner call with commas is +handled. + +### XoopsDynamicCode — Dynamic code: $$name, extract(), eval() +`$$name`, `${$name}`, `extract($_POST)`, `eval()`, `create_function()`. A request array copied into the symbol table +lets the client define any variable. There is no local rewrite, so nothing is offered as a quick-fix. + +### XoopsPathContainment — Prefix check used as path containment +`strpos($path, $base) === 0` or `str_starts_with($path, $base)` on a filesystem path is a prefix test, not +containment: `/safe/../outside/secret` starts with `/safe`, and `/uploads2/x` starts with `/uploads`. Not reported: a +`realpath()`'d path (directly, through a local, behind a failure ternary, or through a separator-only +`str_replace()`) against a base ending in a separator; a literal needle (`'/'`, `'www.'`, `'ldaps://'`); a prefix +strip for display; a URL or referer, or a prefix test inside a function that handles URLs. Report only: the fix is +`realpath()` on both sides and a comparison against the directory plus a separator, placed where the path is final. + +### XoopsWeakToken — Weak token generation, password hash or secret comparison +Three shapes. A variable named `*token*`, `*nonce*` or `*csrf*` assigned from `rand()`, `mt_rand()` or `uniqid()`: +predictable, use `bin2hex(random_bytes(n))` or the core token API. `md5()` / `sha1()` of a `*pass*` / `*secret*` +variable: not a password hash, use `password_hash()` / `password_verify()` (compared with a stored value it is the +2.2 login fallback and only a weak note). `==`, `!=` or `strcmp()` on a token, secret or password: `'0e123' == '0e456'` +is true and the comparison is not constant-time, use `hash_equals()`. A comparison with a non-numeric literal +(`$pass == ''`) is an emptiness test and is not reported. +**Fix:** only for two request-text operands assigned nowhere else, `!=` becomes `!==` and `==` becomes `===`. +Everything else is report only. + +### XoopsAllowlistAnchor — Allowlist regex anchored with $ before an exact comparison +In PCRE, `$` matches before a final newline: `preg_match('/^[a-z]+$/', $name)` accepts `"script\n"`, and the +`in_array()` / `===` comparison that follows does not recognise it. Reported when the pattern is a single-quoted literal +ending in an unescaped `$` without the `D` or `m` modifier, and the subject is compared exactly later in the same +scope with no reassignment in between. +**Fix:** replaces that `$` with `\z`. + +### XoopsAttributeEscape — Accessor interpolated into an HTML attribute or URL +`getVar()` is not an escaper: `XOBJ_DTYPE_OTHER` and `SOURCE` fields come back raw, and attribute context needs +`ENT_QUOTES` regardless. Context is decided from the string literal that precedes the accessor in the same +concatenation (`'getConfigsByCat(0, $mid)`. + +### XoopsHandlerSignature — Handler getObjects() not declared by reference +A handler extending `XoopsPersistableObjectHandler` / `XoopsObjectHandler` must declare `&getObjects(…)` and +`&getAll(…)`: the kernel returns by reference and PHP refuses an incompatible override at class load. Report only, +because adding `&` also requires `return $var;` instead of `return [];`. + +### XoopsFileHeader — File header docblock without @license +Module PHP files open with a docblock carrying `@copyright` and `@license`. A file that already has a leading comment +without the tag (third-party MIT / GPL prose) is only noted, never rewritten. +**Fix:** offered only for a file with no leading comment at all, and never for a `"exists()`-style helper that checks the same `$this->…` path, a check on `$x` when the unlinked path is +`realpath($x)`, an `SplFileInfo` object's `isFile()` / `isLink()` when its `getRealPath()` / `getPathname()` is +unlinked, and `if (false === file_put_contents($p, …)) return;` all count. A check behind an unrelated `&&` does not. +Report only. + +### XoopsIsFileAsset — file_exists() / is_readable() accepts a directory before a file read +`file_exists($x)` and `is_readable($x)` are true for a directory. When the condition's body then includes `$x` or reads +it with `readfile()`, `file_get_contents()`, `fopen()` or similar, a directory name reaches the reader. +**Fix:** appends `&& is_file($x)`; a negated check is reported without a fix, because `!a && b` would invert it. + +### XoopsByRefArgument — Temporary passed to a by-reference parameter +A call passes a temporary (a call result, a literal) where the resolved declaration takes a reference. Resolution goes +through the PSI, so `parent::__construct()` is checked against the parent's signature. Report only. + +### XoopsStrictCalendarCast — strict_types: string passed to mktime()/checkdate() +Under `declare(strict_types=1)`, `mktime(0, 0, 0, date('n'), 1, date('Y'))` throws a TypeError on every call: `date()` +returns a string and the calendar functions take `int`. +**Fix:** wraps the argument in `(int)`; on a numeric string the cast is exact. + +## Templates and Smarty + +### XoopsWrongSmartyDelimiter — Smarty tag without the XOOPS <{ }> delimiters +XOOPS compiles Smarty with `<{ }>` delimiters: a bare `{if $x}` or `{$var}` is printed literally. Comments and +`<{literal}>` blocks are masked; CSS and JavaScript braces do not match because only Smarty keywords and `{$…}` are +looked for. +**Fix:** wraps the tag in `<{ }>`. + +### XoopsUnregisteredTemplate — Template or db: include not registered +A module `.tpl` the manifest does not know, or a `db:` include nobody registers. Core `system_*` templates, templates +whose prefix belongs to another module (`db:tag_bar.tpl` inside Publisher) and includes inside a Smarty comment are not +reported. +**Fix:** registers the template in `xoops_version.php`, Alt+Enter only, because it changes what the installer stores. + +### XoopsMissingRegisteredTemplate — Registered template missing where the installer reads it +A template registered in `xoops_version.php` is not where `xoops_module_gettemplate()` reads it: `type => admin|blocks` +adds that folder, every other type is `templates/{file}` as written. +**Fix:** when a file with that name exists elsewhere under `templates/`, points the registration at it. No empty stub +is ever created. + +### XoopsSmartyUnquotedPluginArg — Unquoted argument to an xo* Smarty plugin +`<{xoAdminIcons home.png}>`: since Smarty 3 (XOOPS 2.5.11) a bare plugin argument is parsed as an expression and the +template does not compile. Not reported on cores older than 2.5.11, where the argument had to be bare. +**Fix:** quotes it with single quotes, also inside a single-quoted HTML attribute, because Smarty removes the whole +tag before the browser sees it. + +### XoopsSmartyForeachItemShadowsFrom — foreach item named like its from array +`<{foreach item=story from=$story}>`: Smarty 3 and later refuse an item variable with the same name as the array it +loops over. +**Fix:** renames the item to `story_item` in the tag and in every `$story` of the loop body, up to `<{foreachelse}>` +or `<{/foreach}>`; a nested loop that declares `item=story` again keeps its own variable. + +### XoopsSmartyRemovedTag — Removed Smarty tag (includeq, foreachq, php, include_php) +`<{includeq}>` and `<{foreachq}>` were XOOPS Smarty 2 plugins and no longer exist; `<{php}>` and `<{include_php}>` do +not compile on Smarty 3 and later. Not reported on cores older than 2.5.11. +**Fix:** renames `includeq` / `foreachq` to `include` / `foreach`. The PHP tags have no fix: move the code into the PHP +page and assign the result. + +### XoopsSmartyBlockShorthand — block_parent / block_child shorthand +`<{block_parent}>` and `<{block_child}>` are not Smarty tags. +**Fix:** `<{$smarty.block.parent}>` / `<{$smarty.block.child}>`, which work on Smarty 3, 4 and 5. + +### XoopsSmartyDateFormatStrftime — date_format with strftime() codes +`|date_format:'%d.%m.%Y'`: a format with `%` codes runs `strftime()`, deprecated since PHP 8.1 and removed from +Smarty 5. Not reported on cores older than 2.5.11. +**Fix:** rewrites `%Y %y %m %d %e %H %M %S %p %%` to `Y y m d j H i s A %` (`%M` is minutes, so it becomes `i`). A +format with a locale token (`%A`, `%B`, `%j`) or literal letters is reported without a fix. + +### XoopsSmarty5Api — Smarty API removed in Smarty 5 (core 2.8) +Smarty 5 removed `assignByRef()`, `appendByRef()`, the `register_*()` family and the public `template_dir` / +`compile_dir` properties, and has no global `\Smarty` class (core 2.8 templates are `\XoopsTpl`, which extends +`\Smarty\Smarty`). On a 2.5 / 2.7 tree these calls are the current API and nothing is reported; a standalone module +without a core is checked. +**Fix:** `assignByRef($k, $v)` becomes `assign($k, $v)` only when `$v` is never written again in its scope. Everything +else is reported for a hand rewrite. + +### XoopsSmarty5TemplatePrep — Template construct that needs rework for Smarty 5 +Constructs that work on Smarty 4 but need rework before a Smarty 5 core: bare `<{parent}>` / `<{child}>`, +`<{insert}>`, `<{make_nocache}>`, `config_load scope=`, and PHP functions used as modifiers (`|substr`, `|trim`, +`|strtolower`), which Smarty 5 only accepts when registered. **Off by default**: enable it for a tree that is being +prepared for Smarty 5. No quick-fix. + +## Language files + +### XoopsLanguageRole — Language constant in the wrong catalog +`_MI_` belongs in `modinfo.php`, `_AM_` in `admin.php`, `_MD_` in `main.php`, `_MB_` in `blocks.php`, `_CO_` in +`common.php`. A canonical file with a foreign prefix gets one note per constant; a non-canonical file (`modinfo1.php`, +a backup copy) gets one file-level note unless some code loads it by name. Only a module's own catalogs directly under +`language//` are checked. `_MD_AM_` (system) and `_MD_A_` (Protector, D3) count as admin constants. Report only. + +### XoopsLanguageLoad — Language constant used without a loaded catalog +A page script (it boots `mainfile.php`) uses `_MD_` / `_CO_` / `_AM_` constants but loads no catalog: neither +`xoops_loadLanguage()`, nor the XMF helper's `loadLanguage()`, nor an include of `header.php` / `admin_header.php` / +`common.php`. A file that does not boot `mainfile.php` is included by one that did and is not reported. **Off by +default.** Report only. + +### XoopsLocaleConstant — Module language constant defined nowhere +`_AM__X`, `_MD_…`, `_MI_…`, `_MB_…`, `_CO_…` referenced in the module but `define()`d in none of its language +files: PHP 8 throws "Undefined constant" where PHP 7 printed the name. Only constants carrying the module's own dirname +are checked, against PhpStorm's index of every `define()` in the project. Report only. + +### XoopsDuplicateDefine — Constant defined twice in a language file +The same constant `define()`d twice in one language file: PHP warns "Constant already defined" on every request and +keeps the first value. +**Fix:** deletes an identical duplicate; two different values are reported for a human to choose. + +### XoopsThemeConstant — THEME_* constant defined by a module +`define('THEME_…')` in a module language file. The `THEME_*` constants belong to the theme's own language pack; +whichever file loads second warns and the theme's text loses. Report only. + +## Deprecated APIs + +### XoopsDeprecatedCoreApi — Deprecated core API +Core APIs the 2.7.4 tree marks `@deprecated` (list generated from the real `class/`, `kernel/` and `include/` +docblocks) plus pre-2.5 idioms that still surface in old modules: `XoopsTree`, `XoopsComments`, Snoopy, +`XOBJ_DTYPE_UNICODE_*`, `xoops_getModuleOption()`, the sanitizer's `makeTboxData4*()` family, Smarty 2 method names on +`XoopsTpl`, and PHP 4 constructor calls (`parent::XoopsObject()`). It works on a standalone module and says what to +use instead. The UNICODE dtypes are never renamed: they decide how stored rows are decoded. +**Fix:** a rename, only where the replacement takes the same arguments (`destoryVars` → `destroyVars`, +`parent::XoopsObject()` → `parent::__construct()`, `assign_by_ref` → `assignByRef`). + +### XoopsDoubleEncodedAmp — &amp; inside a URL passed to header() / addItemButton() +`header('Location: …')` and `ModuleAdmin::addItemButton()` take a raw URL: `renderButton()` escapes it and `header()` +sends it as is. A literal `&` in that string reaches the server as a parameter named `amp;…`, so the token check +fails. A URL printed into HTML is left alone. `redirect_header()` and the helper's `redirect()` also take a raw URL but +turn `&` back into `&` for their meta refresh, so there it is only a weak note. +**Fix:** writes `&`. + +## Reading a finding + +Every message starts with `XOOPS:` so the Problems view can be filtered to this plugin, and names the construct it +saw, not only the rule. A message that ends in "report only", "no quick-fix" or "kept as is" is deliberate: the +inspection can prove the defect but not the correct replacement. When a fix is offered it is idempotent (applying it +twice changes nothing), recomputes its ranges at apply time (so a batch run over a file applies every fix), and keeps +the file parsing; the test suite asserts all three for every fixture. diff --git a/README.md b/README.md index b83994d..ab8a348 100644 --- a/README.md +++ b/README.md @@ -56,43 +56,14 @@ for the next restart. The plugin itself is unload-safe (every extension point it ## Inspections (Settings → Editor → Inspections → PHP → XOOPS) -| Inspection | Fires on | Quick-fix | -|---|---|---| -| RootPathGuard | include-only file (class / preload / include / block) with no `defined('XOOPS_ROOT_PATH') \|\| exit` guard (the `if (!defined) { throw … }` and compound `\|\| !($GLOBALS['xoopsUser'] instanceof …)` forms count) — never an entry point, never `preloads/autoloader.php`, never a 404 stub | guard after `namespace`/`declare`, before `use` or the class docblock | -| DoubleCpHeader | `xoops_cp_header()` after `require admin_header.php` **when that sibling file calls it** | delete the statement (excluded from Fix-all) | -| FormLoader | class extends `XoopsForm*` and the file loads neither `XoopsFormLoader` nor the parent | `\xoops_load('XoopsFormLoader');` on its own line before the class docblock | -| FileHeader | no comment in the first 4000 characters mentions `@license` / `license:` / `licensed under` / copyright; third-party and test trees, `xoops_version.php`, 404 stubs skipped | header after a same-line `declare`, only when the head has no comment at all | -| LanguageLoad | `_MD_`/`_CO_`/`_AM_` used in a page that loads no catalog (`xoops_loadLanguage`, helper `loadLanguage`, header include) | — | -| LanguageRole | `_MI_` outside modinfo.php etc.; one note per **non-canonical** file (`modinfo1.php`) | — | -| DeprecatedDbApi | `queryF()` / `quoteString()` on a XOOPS database receiver (never an application wrapper, never inside a database class) | `exec()` for a resolved write, `query()` for a read, nothing when unknown; `if ($force)` ⇒ `exec()`; `quote()` | -| MutatingQuery | `query()` on a database receiver whose SQL resolves to INSERT/UPDATE/DELETE/DDL (the same resolution makes such a `query()` a write for Csrf) | `exec()`; `queryF()` when the nearest ancestor `include/version.php` says the core is older than 2.5.12 (the same gate keeps `queryF()` unreported there and makes ResultSetGuard insert `if (!$result)` below 2.5.11) | -| ResultSetGuard | `fetch*()` with no dominating `isResultSet()` (in-condition, early-exit, reassignment aware); reversed `if (!isResultSet)` and nested `fetch*($db->query())` get their own messages; legacy `if (!$r)` / `if (!$r = query())` / `false === $r` guards get a weak note | early `return` with the function's failure value, never `throw`; legacy guard rewritten in place to `isResultSet()` | -| Superglobal | `$_GET`/`$_POST`/`$_COOKIE`/`$_REQUEST` in any file that boots XOOPS (names `xoops`, `mainfile.php` or `Xmf`), `assets/` endpoints included; a bundled script that never loads the core is skipped; writes, `empty()`, `=== null`, dynamic keys, bare arrays and dynamic callees (`new $_REQUEST['c']()`) reported without fix | accessor chosen from the use: `getArray()` (foreach, count, `[…]`, array-typed parameter, `XOBJ_DTYPE_ARRAY` field), `getInt()` (`(int)`, `intval()`, arithmetic), `getString()` (literal comparison, switch, array key), `getText()` (concatenation, echo, string functions, text fields — keeps HTML); `isset()` and a strict null comparison → `hasVar()`; `isset ? : 'd'` and `?? 'd'` keep the default; every other read, `empty()`, a loose null comparison and a bare `$_POST` get the byte-identical raw accessor `getVar(key, null, hash, 'none', MASK_ALLOW_RAW \| MASK_NO_TRIM)` / `get(hash, …)`; `$_REQUEST` is the library's `'default'` hash; three batch buttons | -| LegacySuperglobal | `$HTTP_POST_VARS`, `$HTTP_GET_VARS` … (removed in PHP 5.4) | rename to `$_POST` / `$_GET` … | -| AttributeEscape | `getVar()` inside an attribute or URL query per the preceding string literal, resolved against the field's `initVar()` DTYPE: numeric never, sanitised DTYPEs only with format `'n'`, `OTHER`/`SOURCE` always | `htmlspecialchars((string) , ENT_QUOTES \| ENT_SUBSTITUTE, 'UTF-8')` / `rawurlencode()` | -| DynamicCode | `$$name`, `${$name}`, `extract($_POST)`, `eval()`, `create_function()` | — | -| OrderBy | `' ORDER BY ' . $criteria->getSort()`, `setSort(Request::…)` | — | -| DeprecatedCoreApi | classes, functions, methods and `XOBJ_DTYPE_UNICODE_*` the 2.7.4 core marks `@deprecated`, plus PHP 4 constructor calls | rename only for same-signature replacements (`destoryVars`, `parent::XoopsObject()`, Smarty 2 names) | -| Csrf | `switch ($op)` branch that writes with no token check; a POST-only gate does not cover a GET `$op` | — | -| Unserialize | `unserialize($x)` with one argument (PSI count) | `, ['allowed_classes' => false]` | -| GuestUser | `isset($xoopsUser)` with no `is_object`/`instanceof` in the same `&&` chain | `$xoopsUser instanceof \XoopsUser` | -| Unlink | `unlink($p)` with no `is_file($p)` on the same expression | — | -| Setcookie | `setcookie()` | `xoops_setcookie()` | -| HttpStatus | `header('HTTP/1.0 404 …')` | `http_response_code(404)` | -| ByRefArgument | temporary passed to a resolved by-reference parameter (`parent::__construct` resolves to the parent) | — | -| MissingRegisteredTemplate | registration whose file is not where `xoops_module_gettemplate()` reads it: `templates//` for `admin` / `blocks` and every `$modversion['blocks']` entry, `templates/` as written otherwise (`'type' => 'block'` is not a folder) | re-point at the existing file, relative to the folder the installer uses (no stubs) | -| UnregisteredTemplate | module `.tpl` or `db:` include nobody registers — `system_*`, foreign prefixes (`db:tag_bar.tpl`), Smarty comments skipped | register in `xoops_version.php` (Alt+Enter only, never "Fix all") | -| SmartyDelimiter | `{if …}` / `{$var}` in a `.tpl` without the XOOPS `<{ }>` delimiters (comments and `<{literal}>` masked) | wrap in `<{ }>` | -| DoubleEncodedAmp | `&` in a URL literal passed to `redirect_header()`, `header()`, `addItemButton()`, `->redirect()` | `&` | -| StrictCalendarCast | `date()`/`substr()`/request string passed to `mktime()`, `checkdate()` … under `declare(strict_types=1)` | `(int)` cast | -| LocaleConstant | `_AM__X` … referenced but `define()`d nowhere in the project (PhpStorm index) | — | -| DuplicateDefine | same constant `define()`d twice in one language file | delete an identical duplicate; a conflict is reported | -| ThemeConstant | `define('THEME_…')` in a module language file | — | -| MenuAutoload | `admin/menu.php` uses a `XoopsModules\…` class before any include | `require_once \dirname(__DIR__) . '/preloads/autoloader.php';` when that file exists | -| HandlerSignature | `getObjects()` / `getAll()` override without `&` in a `XoopsPersistableObjectHandler` subclass (fatal at class load) | — | -| SqlConcat | `$_GET[…]` / `Request::getString()` concatenated into the SQL reaching `query()`/`exec()` without `quote()`/`(int)` (`$sql` followed to its assignment) | — | -| SqlComment | `xoops_version.php` names a `sql/*.sql` with a bare `--` line (installer skips the next CREATE TABLE) | a bare `--` line is blanked (a `-- ` would be stripped as trailing whitespace on save), `--text` becomes `-- text` | -| SmartyRequestEscape | `<{$smarty.get.x}>` / `$smarty.request` / `$smarty.post` printed without `\|escape` | append `\|escape` | +The plugin registers 52 inspections: security (CSRF, superglobals, SQL concatenation, escaping, tokens), the +database layer (`queryF()`, `exec()` vs `query()`, `isResultSet()` guards, installer SQL), module files and +bootstrap (`XOOPS_ROOT_PATH` guard, `admin/menu.php` autoloader, form loader, preload globals), templates and +Smarty (delimiters, registration, Smarty 3 / 5 migrations), language files, and deprecated core APIs. + +**[INSPECTIONS.md](INSPECTIONS.md)** lists every one of them with its default level, what it reports, what the +quick-fix changes and when it stays silent. It is written from the same descriptions PhpStorm shows in the +inspection settings (`src/main/resources/inspectionDescriptions/`), so the two stay in step. Plus, unchanged from 0.3: project detection, the XOOPS tool window, language-constant completion and five live templates. diff --git a/TUTORIAL.md b/TUTORIAL.md index 1dec3e4..3d07e4e 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -37,8 +37,10 @@ tour is the fixture tree the test-suite uses: copy and open the files — every `.php` next to a `.after.php` shows a highlighted problem whose Alt+Enter fix produces exactly the `.after.php` content. -The rules that matter most, with what changed against the old text-based -plugin: +The complete list, with every rule's default level, what it reports, what its fix +changes and when it stays silent, is **[INSPECTIONS.md](INSPECTIONS.md)**. This +section walks through the rules that matter most, with what changed against the +old text-based plugin: ### 4.1 XOOPS_ROOT_PATH guard From ee36c35595e6d4d87b9bc90d055f0bc84aad1d81 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 26 Oct 2026 11:09:25 -0400 Subject: [PATCH 3/9] fix(inspections): answer review on the installer-file gate, Unlink and docs InstallSql and SqlFileComment read the manifest through the PHP parser: a module file counts when it is mysql.sql or the target of a sqlfile assignment or array entry, resolved from the module root, with every database entry read. Comments, strings, nowdocs, comparisons, index reads, destructuring and .= no longer count as declarations. The core install/sql schema has no module manifest and stays inspected, as it was before this branch. Unlink pairs SplFileInfo checks with the accessor they cover: isFile() with getRealPath() and getPathname(), isLink() with getPathname() only. getPath() is the parent directory and is covered by neither. The template-registration rules skip test trees like every other rule. Docs: the tutorial no longer says assets/js is inspected, INSPECTIONS.md lists the excluded and third-party trees, and three description pages are updated. tools/gen_descriptions.py is rebuilt from the 52 shipped pages, which it now reproduces byte for byte with LF endings. --- CHANGELOG.md | 28 +- INSPECTIONS.md | 22 +- TUTORIAL.md | 6 +- .../inspections/XoopsSmallFixInspections.kt | 22 +- .../inspections/XoopsSqlFileInspections.kt | 53 ++- .../inspections/XoopsTemplateInspections.kt | 4 +- .../XoopsInstallSql.html | 2 +- .../XoopsSqlFileComment.html | 2 +- .../inspectionDescriptions/XoopsUnlink.html | 8 +- .../org/xoops/support/XoopsQuickFixTest.kt | 59 ++- .../modules/demo/sql/english/install.sql | 4 + .../modules/demo/xoops_version.php | 10 + .../install/sql/mysql.structure.sql | 5 + .../InstallSql/fk/modules/demo/sql/dump.sql | 2 + .../modules/demo/sql/english/install.sql | 4 + .../nested/modules/demo/sql/upgrade.sql | 4 + .../nested/modules/demo/xoops_version.php | 9 + .../testtree/modules/demo/tests/example.tpl | 1 + .../testtree/modules/demo/xoops_version.php | 3 + .../Unlink/spl/modules/demo/class/Cleaner.php | 27 +- tools/gen_descriptions.py | 450 ++++++++++++------ 21 files changed, 532 insertions(+), 193 deletions(-) create mode 100644 src/test/resources/fixtures/InstallSql/arraymanifest/modules/demo/sql/english/install.sql create mode 100644 src/test/resources/fixtures/InstallSql/arraymanifest/modules/demo/xoops_version.php create mode 100644 src/test/resources/fixtures/InstallSql/corecomment/install/sql/mysql.structure.sql create mode 100644 src/test/resources/fixtures/InstallSql/nested/modules/demo/sql/english/install.sql create mode 100644 src/test/resources/fixtures/InstallSql/nested/modules/demo/sql/upgrade.sql create mode 100644 src/test/resources/fixtures/InstallSql/nested/modules/demo/xoops_version.php create mode 100644 src/test/resources/fixtures/Templates/testtree/modules/demo/tests/example.tpl create mode 100644 src/test/resources/fixtures/Templates/testtree/modules/demo/xoops_version.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8984aa5..cb2ee1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,22 @@ checked against the module source and pinned with a fixture. - **DeprecatedCoreApi** no longer names `synchronization()`: it is the module handlers' own method (newbb reports 99 calls), and the core's `XoopsPersistableObjectHandler::synchronization()` carries no `@deprecated` tag either. -- **InstallSql** and **SqlFileComment** look only at the file the installer runs: `sql/mysql.sql`, or a file the - manifest names. Adminer / phpMyAdmin dumps and old upgrade scripts beside it (`2510debug1.sql`, `xoops_mysql.sql`, - `mysql.400.sql`) are never split by SqlUtility, so their MyISAM engine, `xoops_` prefixes and bare `--` lines were - 237 + 111 findings about files nothing executes. -- **Unlink** accepts an `SplFileInfo` check: `$info->isFile()` (or `isLink()`) dominating - `unlink($info->getRealPath())`, or a variable assigned from `$info->getPathname()`, is the is_file() check. +- **InstallSql** and **SqlFileComment** look only at the file the installer runs: a module's `sql/mysql.sql`, or the + file an active `sqlfile` entry names, resolved from the module root (so `sql/english/install.sql` counts, in both + the `$modversion['sqlfile']['mysql']` and the array-manifest form, with every database entry of an array read). + The manifest is read through the PHP parser, not as text: a commented-out declaration, a string or nowdoc that + looks like one, and a comparison name nothing, while a declaration behind an inline comment, after `?>`, or with a + multi-line `[XOOPS_DB_TYPE]` index still counts. Adminer / phpMyAdmin dumps and old upgrade + scripts beside it (`2510debug1.sql`, `xoops_mysql.sql`, `mysql.400.sql`) are never split by SqlUtility, so their + MyISAM engine, `xoops_` prefixes and bare `--` lines were 237 + 111 findings about files nothing executes. A file a + comment merely mentions is not an installer file. The core's own `install/sql/` schema has no module manifest and + stays inspected. +- **Unlink** accepts an `SplFileInfo` check: `$info->isFile()` dominating `unlink($info->getRealPath())`, or a + variable assigned from `$info->getPathname()`, is the is_file() check, and `$info->isLink()` guards + `unlink($info->getPathname())` (the link itself, also when it dangles). `isFile()` before `getPath()` (the parent + directory) and `isLink()` before `getRealPath()` (the link's target) are still reported. +- **UnregisteredTemplate** and **MissingRegisteredTemplate** skip test trees like every other rule; a `db:` include + in `tests/example.tpl` was reported as an unregistered template. - **MenuAutoload** gains fixtures for `` runs the suite against an installed IDE instead of the pinned 2024.3.5 platform, so a parser change in a newer PhpStorm is caught before a report comes in. - **Docs:** `INSPECTIONS.md` lists all 52 inspections with default level, what each reports, what its quick-fix - changes and when it stays silent. README and TUTORIAL link to it; the README's Alpha 3 era table is gone. + changes and when it stays silent. README and TUTORIAL link to it; the README's Alpha 3 era table is gone. The + tutorial no longer claims that `assets/js/` is read by the security rules: that tree is excluded. +- **Tools:** `tools/gen_descriptions.py` is rebuilt from the shipped description pages and reproduces all 52 byte + for byte. It had 35 entries, 12 of them shorter than the pages edited since and two under retired short names, so + running it would have overwritten reviewed descriptions. ## 1.0.0-alpha.4 — 1.0.0 Alpha 4 — Marketplace release 2026-10-26 diff --git a/INSPECTIONS.md b/INSPECTIONS.md index fe0656e..30ed35e 100644 --- a/INSPECTIONS.md +++ b/INSPECTIONS.md @@ -11,8 +11,12 @@ Conventions used throughout: make (which column to allow-list, whether a table is old enough to need no update path). - A fix that is **Alt+Enter only** is offered in the editor but excluded from "Fix all" and the batch Inspect Code run, because applying it blindly can change behaviour. -- **Test trees** (`tests/`, `test/`, `testdata/`, `fixtures/`), `assets/js/`, `jpgraph/` and `00/` are skipped by - every rule; third-party and vendor code is skipped by the rules that say so. +- **Never inspected** by any rule: test trees (`tests/`, `test/`, `testdata/`, `fixtures/`) and vendored, + generated or scratch trees (`vendor/`, `templates_c/`, `cache/`, `uploads/`, `xoops_data/`, `node_modules/`, + `assets/js/`, `jpgraph`, `00/`). +- **Bundled third-party code** (`assets/`, `libraries/`, `lib/`, `js/`, `jquery/`, `ckeditor/`, `tinymce/`, + `pear/`) is read by the security rules and skipped by the convention rules (file header, root-path guard, + `xoops_setcookie()`, status line). - **Core version** gating reads `include/version.php` of the project, or the Core Version setting when the module is opened standalone. Rules that only matter on core 2.8 (Smarty 5) stay silent on a 2.5 / 2.7 tree. @@ -204,14 +208,16 @@ statement, the CREATE TABLE prefixer fails, and that table is silently never cre **Fix:** inserts the space in the SQL file. ### XoopsSqlFileComment — Bare '--' comment line in installer SQL -The same defect reported on the SQL file itself, one finding per line, for the file the installer runs -(`sql/mysql.sql` or a file the manifest names; dumps and old upgrade scripts in the same folder are skipped). +The same defect reported on the SQL file itself, one finding per line, for the file the installer runs: a module's +`sql/mysql.sql`, the file an active `sqlfile` entry names (resolved from the module root, so +`sql/english/install.sql` counts), or the core's own `install/sql/` schema. Dumps and old upgrade scripts in a +module's `sql/` folder are skipped, as is a file only a comment mentions. **Fix:** inserts the space on that line only, re-checking the live line text, so it is safe in any batch order. A line that is exactly `--` is blanked rather than given a trailing space, which "strip trailing spaces on save" would remove again. ### XoopsInstallSql — Installer SQL the installer cannot run -Rules on the file the installer runs. `FOREIGN KEY … REFERENCES`: `SqlUtility::prefixQuery()` prefixes only the +Rules on the file the installer runs, chosen as for XoopsSqlFileComment above. `FOREIGN KEY … REFERENCES`: `SqlUtility::prefixQuery()` prefixes only the table being created, never the REFERENCES target, so the key dangles; a plain INSERT fails with 1452, an INSERT IGNORE silently writes nothing. Hard-coded prefix: `xoops_`, `{prefix}` and `%s` are not installer tokens. `ENGINE=MyISAM` (weak): no transactions, no row locks; InnoDB has supported FULLTEXT since MySQL 5.6. Not reported on the core @@ -296,8 +302,10 @@ without the tag (third-party MIT / GPL prose) is only noted, never rewritten. `unlink($path)` with no `is_file()` / `file_exists()` on the same expression dominating it. A check in the enclosing `if`, a preceding early exit at any block level up to the function, a previous `try` whose every catch exits, a `$this->exists()`-style helper that checks the same `$this->…` path, a check on `$x` when the unlinked path is -`realpath($x)`, an `SplFileInfo` object's `isFile()` / `isLink()` when its `getRealPath()` / `getPathname()` is -unlinked, and `if (false === file_put_contents($p, …)) return;` all count. A check behind an unrelated `&&` does not. +`realpath($x)`, an `SplFileInfo` object's `isFile()` when its `getRealPath()` or `getPathname()` is unlinked +(directly or through a variable), its `isLink()` when `getPathname()` (the link itself) is unlinked, and +`if (false === file_put_contents($p, …)) return;` all count. A check behind an unrelated `&&` does not, nor does +`isFile()` before `getPath()` (the containing directory) or `isLink()` before `getRealPath()` (the link's target). Report only. ### XoopsIsFileAsset — file_exists() / is_readable() accepts a directory before a file read diff --git a/TUTORIAL.md b/TUTORIAL.md index 3d07e4e..8dc2723 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -150,9 +150,11 @@ creating a stub; skip `system_*`, other modules and Smarty comments). 6. **`$HTTP_POST_VARS`** is an error with a rename to `$_POST`. 7. **Templates.** `{if $x}` or `{$var}` in a `.tpl` is an error with a "Wrap in <{ }>" fix; `<{$smarty.get.op}>` gets "Add |escape". -8. **Scope.** `assets/js/uploader.php` reading `$_POST` is reported (security +8. **Scope.** `assets/uploader.php` reading `$_POST` is reported (security rules read bundled code); it gets no header, guard or status-line note - (convention rules skip it). `db:tag_bar.tpl` inside Publisher is silent. + (convention rules skip it). Anything under `assets/js/` is not inspected + at all, nor are `tests/` and `vendor/`. `db:tag_bar.tpl` inside Publisher + is silent. ## 5. Language-constant completion, 6. Live templates diff --git a/src/main/kotlin/org/xoops/support/inspections/XoopsSmallFixInspections.kt b/src/main/kotlin/org/xoops/support/inspections/XoopsSmallFixInspections.kt index 091289e..d5af2fb 100644 --- a/src/main/kotlin/org/xoops/support/inspections/XoopsSmallFixInspections.kt +++ b/src/main/kotlin/org/xoops/support/inspections/XoopsSmallFixInspections.kt @@ -203,9 +203,15 @@ class XoopsUnlinkInspection : XoopsInspection() { ?.takeIf { Php.isCall(it, "realpath") && it !is MethodReference }?.parameters?.firstOrNull() // `unlink($info->getRealPath())`, or `$name = $info->getPathname(); unlink($name)`: the SplFileInfo // object names the file, so `$info->isFile()` is the check (newbb FilesManagement, DirectoryIterator loops). - val target = Php.unwrap(arg).let { (it as? Variable)?.let(Php::resolveAssignment) ?: it } - val spl = (target as? MethodReference)?.takeIf { it.name in splPaths }?.classReference?.text?.let { SPL + normalize(it) } - val keys = setOfNotNull(normalize(arg.text), alias?.let { normalize(it.text) }, spl) + val target = Php.unwrap(arg).let { (it as? Variable)?.let(Php::resolveAssignment) ?: it } as? MethodReference + val receiver = target?.classReference?.text?.let(::normalize) + val spl = when { + receiver == null -> emptyList() + target?.name == "getRealPath" -> listOf(SPL_FILE + receiver) + target?.name == "getPathname" -> listOf(SPL_FILE + receiver, SPL_LINK + receiver) + else -> emptyList() + } + val keys = setOfNotNull(normalize(arg.text), alias?.let { normalize(it.text) }) + spl if (dominated(reference, keys)) return holder.registerProblem( reference as PsiElement, @@ -218,12 +224,16 @@ class XoopsUnlinkInspection : XoopsInspection() { private fun normalize(text: String) = text.replace(Regex("\\s+"), "") - private val splPaths = setOf("getRealPath", "getPathname", "getPath") - private val SPL = "spl:" + // An SplFileInfo check covers the accessor that returns the entry it checked. isFile() covers getRealPath() and + // getPathname(); isLink() covers only getPathname() (the link itself), because getRealPath() resolves to the + // target. getPath() is the containing directory and is covered by neither. + private val SPL_FILE = "spl-file:" + private val SPL_LINK = "spl-link:" private fun checks(scope: PsiElement, keys: Set): List = Php.calls(scope, "is_file", "file_exists", "is_link").filter { c -> c.parameters.firstOrNull()?.let { normalize(it.text) } in keys } + - Php.methodCalls(scope, "isFile", "isLink").filter { c -> c.classReference?.text?.let { SPL + normalize(it) } in keys } + + Php.methodCalls(scope, "isFile").filter { c -> c.classReference?.text?.let { SPL_FILE + normalize(it) } in keys } + + Php.methodCalls(scope, "isLink").filter { c -> c.classReference?.text?.let { SPL_LINK + normalize(it) } in keys } + PsiTreeUtil.collectElementsOfType(scope, MethodReference::class.java).filter { helperChecks(it, keys) } /** diff --git a/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt b/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt index de5b935..12b1bb6 100644 --- a/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt +++ b/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt @@ -8,8 +8,17 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.TextRange import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement import com.intellij.psi.PsiElementVisitor import com.intellij.psi.PsiFile +import com.intellij.psi.PsiManager +import com.intellij.psi.util.PsiTreeUtil +import com.jetbrains.php.lang.psi.PhpFile +import com.jetbrains.php.lang.psi.elements.ArrayAccessExpression +import com.jetbrains.php.lang.psi.elements.ArrayHashElement +import com.jetbrains.php.lang.psi.elements.AssignmentExpression +import com.jetbrains.php.lang.psi.elements.SelfAssignmentExpression +import com.jetbrains.php.lang.psi.elements.StringLiteralExpression import org.xoops.support.fix.XoopsFix import org.xoops.support.psi.XoopsPaths import java.nio.charset.StandardCharsets @@ -43,16 +52,50 @@ object InstallSql { } /** - * The installer runs the file: `mysql.sql`, or any file the manifest names. A `sql/` folder also holds - * Adminer / phpMyAdmin dumps and old upgrade scripts (newbb: `2510debug1.sql`, `xoops_mysql.sql`, - * `mysql.400.sql`) that SqlUtility never sees; their engine, prefix and `--` lines are not findings. + * The installer runs the file: a module's `sql/mysql.sql`, or the file an active `sqlfile` entry of its + * `xoops_version.php` names, resolved from the module root (`sql/english/install.sql` is two levels down). + * A `sql/` folder also holds Adminer / phpMyAdmin dumps and old upgrade scripts (newbb: `2510debug1.sql`, + * `xoops_mysql.sql`, `mysql.400.sql`) that SqlUtility never sees; their engine, prefix and `--` lines are not + * findings. A file outside any module (the core's `install/sql/mysql.structure.sql`) belongs to the core + * installer and stays inspected. */ fun installerFile(file: PsiFile): Boolean { + val vf = file.virtualFile ?: return true + val root = XoopsPaths.moduleRoot(vf) ?: return true if (file.name.equals("mysql.sql", true)) return true - val manifest = read(file.virtualFile?.parent?.parent?.findChild("xoops_version.php")) ?: return false - return manifest.contains(file.name, ignoreCase = true) + val manifestFile = root.findChild("xoops_version.php") ?: return false + val manifest = PsiManager.getInstance(file.project).findFile(manifestFile) as? PhpFile ?: return false + return sqlfilePaths(manifest).any { root.findFileByRelativePath(it.removePrefix("./")) == vf } } + /** + * Every `.sql` path a `sqlfile` declaration of the manifest assigns, read from the PHP PSI, so comments, + * strings, heredocs, `?>` and comparisons are the parser's business. `$modversion['sqlfile']['mysql'] = 'x'`, + * `$modversion['sqlfile'][XOOPS_DB_TYPE] = 'x'`, `$modversion['sqlfile'] = ['pgsql' => 'y', 'mysql' => 'x']` and + * the array-manifest `'sqlfile' => ['mysql' => 'x']` (also `array(…)`) all count; every database entry is read. + */ + fun sqlfilePaths(manifest: PhpFile): List { + val values = ArrayList() + for (a in PsiTreeUtil.findChildrenOfType(manifest, AssignmentExpression::class.java)) { + // `.=` appends to a path set elsewhere; it is not the declaration. + if (a is SelfAssignmentExpression) continue + // Only the `[…]` chain being written counts: a `sqlfile` read inside an index, or a destructuring + // target, does not make the right-hand side an installer file. + var access = a.variable as? ArrayAccessExpression + while (access != null && !isSqlfileKey(access.index?.value)) access = access.value as? ArrayAccessExpression + if (access != null) a.value?.let(values::add) + } + for (h in PsiTreeUtil.findChildrenOfType(manifest, ArrayHashElement::class.java)) { + if (isSqlfileKey(h.key)) h.value?.let(values::add) + } + return values + .flatMap { listOfNotNull(it as? StringLiteralExpression) + PsiTreeUtil.findChildrenOfType(it, StringLiteralExpression::class.java) } + .map { it.contents } + .filter { it.endsWith(".sql", ignoreCase = true) } + } + + private fun isSqlfileKey(e: PsiElement?): Boolean = (e as? StringLiteralExpression)?.contents.equals("sqlfile", ignoreCase = true) + /** `-- ` and `#` line comments and block comments blanked, offsets kept. */ fun stripComments(text: String): String { val out = StringBuilder(text.length) diff --git a/src/main/kotlin/org/xoops/support/inspections/XoopsTemplateInspections.kt b/src/main/kotlin/org/xoops/support/inspections/XoopsTemplateInspections.kt index f14b5d9..5914098 100644 --- a/src/main/kotlin/org/xoops/support/inspections/XoopsTemplateInspections.kt +++ b/src/main/kotlin/org/xoops/support/inspections/XoopsTemplateInspections.kt @@ -134,7 +134,7 @@ class XoopsMissingRegisteredTemplateInspection : LocalInspectionTool() { return object : PsiElementVisitor() { override fun visitFile(file: PsiFile) { val php = file as? PhpFile ?: return - if (!php.name.equals("xoops_version.php", true) || !Php.isPrimary(php) || XoopsPaths.isExcluded(php)) return + if (!php.name.equals("xoops_version.php", true) || !Php.isPrimary(php) || XoopsPaths.isExcluded(php) || XoopsPaths.isTestTree(php)) return val root = php.virtualFile?.parent ?: return for (reg in ManifestTemplates.registrations(php)) { if (root.findFileByRelativePath(reg.diskPath) != null) continue @@ -189,7 +189,7 @@ class XoopsUnregisteredTemplateInspection : LocalInspectionTool() { override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor { return object : PsiElementVisitor() { override fun visitFile(file: PsiFile) { - if (!file.name.lowercase().endsWith(".tpl") || !Php.isPrimary(file) || XoopsPaths.isExcluded(file)) return + if (!file.name.lowercase().endsWith(".tpl") || !Php.isPrimary(file) || XoopsPaths.isExcluded(file) || XoopsPaths.isTestTree(file)) return val vf = file.virtualFile ?: return val root = XoopsPaths.moduleRoot(vf) ?: return val relative = XoopsPaths.relativeToModule(vf) ?: return diff --git a/src/main/resources/inspectionDescriptions/XoopsInstallSql.html b/src/main/resources/inspectionDescriptions/XoopsInstallSql.html index ca2707f..c6624ed 100644 --- a/src/main/resources/inspectionDescriptions/XoopsInstallSql.html +++ b/src/main/resources/inspectionDescriptions/XoopsInstallSql.html @@ -2,6 +2,6 @@ XOOPS: Install Sql -

Rules on modules/x/sql/*.sql, the file $modversion['sqlfile'] points at.

  • FOREIGN KEY … REFERENCES: SqlUtility::prefixQuery() prefixes only the table being created, never the REFERENCES target. The key dangles to a nonexistent table; a plain INSERT fails with 1452, an INSERT IGNORE silently writes nothing. Enforce the relation in the handler, or add the key in oninstall.php with $db->prefix().
  • Hard-coded prefix: xoops_, {prefix} and %s are not installer tokens; the installer adds the site prefix itself. Use the bare module table name.
  • ENGINE=MyISAM (weak): no transactions, no row locks; create module tables with InnoDB, which supports FULLTEXT since MySQL 5.6. Not reported on the core installer schema (install/sql/), which keeps its legacy tables MyISAM on purpose.

Report only: each change alters the schema and belongs to the author.

+

Rules on the SQL file the installer runs: a module's sql/mysql.sql, the file an active $modversion['sqlfile'] entry names (resolved from the module root, so sql/english/install.sql counts), or the core's install/sql/ schema. Adminer / phpMyAdmin dumps and old upgrade scripts in a module's sql/ folder are never run and are not checked, nor is a file that only a comment mentions.

  • FOREIGN KEY … REFERENCES: SqlUtility::prefixQuery() prefixes only the table being created, never the REFERENCES target. The key dangles to a nonexistent table; a plain INSERT fails with 1452, an INSERT IGNORE silently writes nothing. Enforce the relation in the handler, or add the key in oninstall.php with $db->prefix().
  • Hard-coded prefix: xoops_, {prefix} and %s are not installer tokens; the installer adds the site prefix itself. Use the bare module table name.
  • ENGINE=MyISAM (weak): no transactions, no row locks; create module tables with InnoDB, which supports FULLTEXT since MySQL 5.6. Not reported on the core installer schema (install/sql/), which keeps its legacy tables MyISAM on purpose.

Report only: each change alters the schema and belongs to the author.

diff --git a/src/main/resources/inspectionDescriptions/XoopsSqlFileComment.html b/src/main/resources/inspectionDescriptions/XoopsSqlFileComment.html index 87c5bd8..5a4d898 100644 --- a/src/main/resources/inspectionDescriptions/XoopsSqlFileComment.html +++ b/src/main/resources/inspectionDescriptions/XoopsSqlFileComment.html @@ -2,6 +2,6 @@ XOOPS: Sql File Comment -

A line that is exactly -- is not a comment for SqlUtility::splitMySqlFile(); only -- (dash dash space) and # are. The bare line stays glued to the next statement, prefixQuery() fails its ^CREATE TABLE match and the installer skips that table with no error.

One finding per line. The fix inserts the space on that line only, re-checking the live line text, so it is safe in any batch order. The same defect is also reported on xoops_version.php by "Bare -- comment in the SQL file".

+

A line that is exactly -- is not a comment for SqlUtility::splitMySqlFile(); only -- (dash dash space) and # are. The bare line stays glued to the next statement, prefixQuery() fails its ^CREATE TABLE match and the installer skips that table with no error.

Checked on the file the installer runs: a module's sql/mysql.sql, the file an active $modversion['sqlfile'] entry names, or the core's install/sql/ schema. Dumps and old upgrade scripts beside it are not checked.

One finding per line. The fix inserts the space on that line only, re-checking the live line text, so it is safe in any batch order. The same defect is also reported on xoops_version.php by "Bare -- comment in the SQL file".

diff --git a/src/main/resources/inspectionDescriptions/XoopsUnlink.html b/src/main/resources/inspectionDescriptions/XoopsUnlink.html index a7821bf..c1c542b 100644 --- a/src/main/resources/inspectionDescriptions/XoopsUnlink.html +++ b/src/main/resources/inspectionDescriptions/XoopsUnlink.html @@ -8,7 +8,11 @@ break / continue count as an exit. Also accepted: a $this->exists()-style helper of the same class whose body checks the same $this->… path; a check on $x when the unlinked path is realpath($x); and if (false === file_put_contents($p, …)) return; (or touch()): the code -created the file itself. A check behind an unrelated && (if ($a && !is_file($p)) return;) -does not count, because a false $a skips it. +created the file itself. An SplFileInfo object's isFile() counts when its +getRealPath() or getPathname() is unlinked, directly or through a variable (a +DirectoryIterator loop), and its isLink() counts when getPathname() (the link +itself) is unlinked. A check behind an unrelated && (if ($a && !is_file($p)) return;) +does not count, because a false $a skips it; nor does isFile() before getPath() +(the containing directory) or isLink() before getRealPath() (the link's target). diff --git a/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt b/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt index 7d9134c..c7db9fa 100644 --- a/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt +++ b/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt @@ -2,6 +2,7 @@ package org.xoops.support import com.intellij.codeInspection.LocalInspectionTool import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.jetbrains.php.lang.psi.PhpFile import org.xoops.support.inspections.XoopsAttributeEscapeInspection import org.xoops.support.inspections.XoopsAllowlistAnchorInspection import org.xoops.support.inspections.XoopsInstallSqlInspection @@ -339,13 +340,67 @@ class XoopsQuickFixTest : BasePlatformTestCase() { fun testUnlinkGuardedByWholeExpression() = check(XoopsUnlinkInspection(), "Unlink/unlink", "modules/demo/include/onupdate.php", 1) - /** `$info->isFile()` guards `unlink($info->getRealPath())` and `$name = $info->getPathname(); unlink($name)`. */ + /** + * `$info->isFile()` guards `unlink($info->getRealPath())` and `$name = $info->getPathname(); unlink($name)`. + * Reported: no check, `getPath()` (the parent directory), and `isLink()` before `getRealPath()` (the link's target). + */ fun testUnlinkGuardedBySplFileInfoIsFile() = - check(XoopsUnlinkInspection(), "Unlink/spl", "modules/demo/class/Cleaner.php", 1) + check(XoopsUnlinkInspection(), "Unlink/spl", "modules/demo/class/Cleaner.php", 3) fun testInstallSqlIgnoresDumpsTheInstallerNeverRuns() = checkSql(XoopsInstallSqlInspection(), "InstallSql/fk", "modules/demo/sql/dump.sql", 0) + fun testSqlFileCommentIgnoresDumpsTheInstallerNeverRuns() = + checkSql(XoopsSqlFileCommentInspection(), "InstallSql/fk", "modules/demo/sql/dump.sql", 0) + + /** The core schema has no module manifest, and the core installer still runs it. */ + fun testSqlFileCommentStillChecksTheCoreSchema() = + checkSql(XoopsSqlFileCommentInspection(), "InstallSql/corecomment", "install/sql/mysql.structure.sql", 1) + + /** `sqlfile` naming `sql/english/install.sql` resolves from the module root, two levels above the file. */ + fun testInstallSqlChecksANestedSqlfileEntry() = + checkSql(XoopsInstallSqlInspection(), "InstallSql/nested", "modules/demo/sql/english/install.sql", 1) + + /** A comment that mentions `sql/upgrade.sql` does not make the installer run it. */ + fun testInstallSqlIgnoresAFileOnlyACommentNames() = + checkSql(XoopsInstallSqlInspection(), "InstallSql/nested", "modules/demo/sql/upgrade.sql", 0) + + /** The array manifest form: `'sqlfile' => ['mysql' => 'sql/english/install.sql']`. */ + fun testInstallSqlChecksTheArrayManifestSqlfile() = + checkSql(XoopsInstallSqlInspection(), "InstallSql/arraymanifest", "modules/demo/sql/english/install.sql", 1) + + /** + * The manifest is read from the PHP PSI: inline and block comments, a nowdoc holding a comment opener, a string that + * looks like a declaration, a comparison, `?>` after a line comment, a multi-line index and several DB entries. + */ + fun testSqlfilePathsReadsOnlyLiveDeclarations() { + fun paths(php: String): List = + org.xoops.support.inspections.InstallSql.sqlfilePaths(myFixture.configureByText("xoops_version.php", php) as PhpFile) + val live = listOf("sql/live.sql") + assertEquals(live, paths("(), paths("(), paths("(), paths(" 'sql/backup.sql'\";")) + assertEquals(emptyList(), paths(" 'sql/pg.sql', 'mysql' => 'sql/live.sql'];"), + ) + assertEquals(live, paths(" 'Demo', 'sqlfile' => array('mysql' => 'sql/live.sql')];")) + assertEquals(emptyList(), paths("(), paths(" 'Demo', + 'dirname' => 'demo', + 'sqlfile' => [ + 'pgsql' => 'sql/pgsql/install.sql', + 'mysql' => 'sql/english/install.sql', + ], + 'tables' => ['demo_item'], +]; diff --git a/src/test/resources/fixtures/InstallSql/corecomment/install/sql/mysql.structure.sql b/src/test/resources/fixtures/InstallSql/corecomment/install/sql/mysql.structure.sql new file mode 100644 index 0000000..8c40b8e --- /dev/null +++ b/src/test/resources/fixtures/InstallSql/corecomment/install/sql/mysql.structure.sql @@ -0,0 +1,5 @@ +-- +CREATE TABLE avatar ( + avatar_id MEDIUMINT(8) UNSIGNED NOT NULL AUTO_INCREMENT, + PRIMARY KEY (avatar_id) +) ENGINE=MyISAM; diff --git a/src/test/resources/fixtures/InstallSql/fk/modules/demo/sql/dump.sql b/src/test/resources/fixtures/InstallSql/fk/modules/demo/sql/dump.sql index f9a6b05..a11b991 100644 --- a/src/test/resources/fixtures/InstallSql/fk/modules/demo/sql/dump.sql +++ b/src/test/resources/fixtures/InstallSql/fk/modules/demo/sql/dump.sql @@ -1,4 +1,6 @@ -- Adminer 4.6.3 MySQL dump +-- +-- a phpMyAdmin-style separator: bare, but the installer never reads this file SET NAMES utf8; diff --git a/src/test/resources/fixtures/InstallSql/nested/modules/demo/sql/english/install.sql b/src/test/resources/fixtures/InstallSql/nested/modules/demo/sql/english/install.sql new file mode 100644 index 0000000..5ceea25 --- /dev/null +++ b/src/test/resources/fixtures/InstallSql/nested/modules/demo/sql/english/install.sql @@ -0,0 +1,4 @@ +CREATE TABLE demo_item ( + id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + PRIMARY KEY (id) +) ENGINE=MyISAM; diff --git a/src/test/resources/fixtures/InstallSql/nested/modules/demo/sql/upgrade.sql b/src/test/resources/fixtures/InstallSql/nested/modules/demo/sql/upgrade.sql new file mode 100644 index 0000000..7de799c --- /dev/null +++ b/src/test/resources/fixtures/InstallSql/nested/modules/demo/sql/upgrade.sql @@ -0,0 +1,4 @@ +CREATE TABLE demo_legacy ( + id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + PRIMARY KEY (id) +) ENGINE=MyISAM; diff --git a/src/test/resources/fixtures/InstallSql/nested/modules/demo/xoops_version.php b/src/test/resources/fixtures/InstallSql/nested/modules/demo/xoops_version.php new file mode 100644 index 0000000..ba05284 --- /dev/null +++ b/src/test/resources/fixtures/InstallSql/nested/modules/demo/xoops_version.php @@ -0,0 +1,9 @@ + diff --git a/src/test/resources/fixtures/Templates/testtree/modules/demo/xoops_version.php b/src/test/resources/fixtures/Templates/testtree/modules/demo/xoops_version.php new file mode 100644 index 0000000..fd56c36 --- /dev/null +++ b/src/test/resources/fixtures/Templates/testtree/modules/demo/xoops_version.php @@ -0,0 +1,3 @@ + 'demo_index.tpl', 'description' => '']; diff --git a/src/test/resources/fixtures/Unlink/spl/modules/demo/class/Cleaner.php b/src/test/resources/fixtures/Unlink/spl/modules/demo/class/Cleaner.php index 04d5108..84bb8f5 100644 --- a/src/test/resources/fixtures/Unlink/spl/modules/demo/class/Cleaner.php +++ b/src/test/resources/fixtures/Unlink/spl/modules/demo/class/Cleaner.php @@ -22,13 +22,38 @@ public function emptyDirectory(string $src): bool public function removeEntry(\SplFileInfo $info): void { - if ($info->isFile() || $info->isLink()) { + if ($info->isFile()) { \unlink($info->getRealPath()); } } + // Reported: no check at all. public function removeBlindly(\SplFileInfo $info): void { \unlink($info->getRealPath()); } + + // Reported: getPath() is the containing directory, not the entry isFile() checked. + public function removeParent(\SplFileInfo $info): void + { + if ($info->isFile()) { + \unlink($info->getPath()); + } + } + + // Not reported: isLink() checks the link, and getPathname() is the link itself (also when it dangles). + public function removeLink(\SplFileInfo $info): void + { + if ($info->isLink()) { + \unlink($info->getPathname()); + } + } + + // Reported: isLink() checks the link, getRealPath() resolves to its target. + public function removeLinkTarget(\SplFileInfo $info): void + { + if ($info->isLink()) { + \unlink($info->getRealPath()); + } + } } diff --git a/tools/gen_descriptions.py b/tools/gen_descriptions.py index e2170da..0277840 100644 --- a/tools/gen_descriptions.py +++ b/tools/gen_descriptions.py @@ -8,24 +8,8 @@ OUT = Path(__file__).resolve().parent.parent / "src/main/resources/inspectionDescriptions" DESCRIPTIONS = { - "XoopsRootPathGuard": r""" -Include-only PHP files (classes, preloads, includes, blocks) start with -
defined('XOOPS_ROOT_PATH') || exit('Restricted access');
-

A file that bootstraps the core itself — any top-level require of -mainfile.php, header.php, cp_header.php, -admin_header.php, print.php … — is a public entry point and is -never reported, even when error_reporting() or flag assignments precede that require. -preloads/autoloader.php, language files, tests, 404 stubs and fully qualified -\defined() guards are recognised.

-

The quick-fix inserts the guard after namespace / declare and before -use or the class docblock, on its own line.

-""", - "XoopsDoubleCpHeader": r""" -A second xoops_cp_header() on one request breaks the admin menu. -

The inspection reads the sibling admin_header.php: only when that file really calls -xoops_cp_header() is the page's own call a duplicate. Modules whose header file only -loads cp_header.php keep their call. The delete fix is deliberately not offered in -"Fix all".

+ "XoopsAllowlistAnchor": r""" +

In PCRE, $ matches before a final newline. preg_match('/^[a-z]+$/', $name) accepts "script\n", and the in_array() / === comparison that follows does not recognise it.

Reported when the pattern is a single-quoted literal ending in an unescaped $ without the D or m modifier and the subject variable is compared exactly later in the same scope, with no reassignment in between ($file = substr($file, 0, -4) compares something else). The fix replaces that $ with \z.

""", "XoopsAttributeEscape": r""" getVar() is not an escaper — XOBJ_DTYPE_OTHER / SOURCE fields @@ -33,8 +17,44 @@

Context is decided from the string literal that precedes the accessor in the same concatenation ('<a href="' . $obj->getVar('url')). PHP code such as $title = …, array keys and link text are never attribute context. Inside a URL -query ('?src=' . …) the fix uses rawurlencode(). The fix wraps the complete +query ('?src=' . …) the fix uses rawurlencode() on the raw value. The fix wraps the complete call expression, receiver included, and is idempotent.

+

The DTYPE comes from the class's initVar(); for an untyped receiver, from the one declaration of +the key in the module's class/ / src/, else in core kernel/. Numeric DTYPEs are never reported; TXTBOX, TXTAREA, +URL and EMAIL are already htmlspecialchars()'d (ENT_QUOTES) by the default and +'e' formats. The attribute fix is offered only for a raw value (OTHER, SOURCE, +format 'n'): when the DTYPE cannot be resolved the finding is a weak note without a fix, because +wrapping an already-escaped value prints &amp;#039;.

+""", + "XoopsByRefArgument": r""" +A call passes a temporary (a call result, a literal) where the resolved declaration takes a reference. +Resolution goes through the PSI, so parent::__construct() is checked against the parent's +signature, not the current file's. +""", + "XoopsCsrf": r""" +A switch ($op) branch that writes (handler insert / delete, exec(), file writes) +without a CSRF token check. +

A branch is covered by its own $GLOBALS['xoopsSecurity']->check() or +xoops_confirm() (directly or through a function of the same file that checks and stops), by a +top-level gate before the switch, or by an if (!$GLOBALS['xoopsSecurity']->check()) before it +that exits or resets $op for the branch's value: if (in_array($op, ['save', 'delete'])) { +if (!…->check()) { … } }. A gate behind any other condition, in an else or a loop covers +nothing; a gate that only runs for POST covers nothing when $op is read from GET and the GET +path still reaches the switch. The branch content decides, never the case label.

+""", + "XoopsDeprecatedCoreApi": r""" +Core APIs the 2.7.4 tree marks @deprecated (list generated from the real +class/, kernel/ and include/ docblocks) plus pre-2.5 idioms that still +surface in old modules: XoopsTree, XoopsComments, Snoopy, +XOBJ_DTYPE_UNICODE_*, xoops_getModuleOption(), the sanitizer's +makeTboxData4*() family, Smarty 2 method names on XoopsTpl, and PHP 4 +constructor calls (parent::XoopsObject()). +

PhpStorm's own deprecation inspection needs the core in the project; this one works on a +standalone module and says what to use instead. A rename fix is offered only where the +replacement takes the same arguments (destoryVars → destroyVars, +parent::XoopsObject() → parent::__construct(), assign_by_ref → +assignByRef …). The UNICODE dtypes are never renamed: they decide how stored rows +are decoded.

""", "XoopsDeprecatedDbApi": r""" queryF() and quoteString() are deprecated. @@ -44,11 +64,39 @@ assignment when needed), and no automatic rewrite when the SQL cannot be resolved. A call inside if ($force) is a write by intent.

""", - "XoopsMutatingQuery": r""" -query() with an INSERT / UPDATE / DELETE / DDL statement: the safe database class refuses it -on GET and strict mode warns on POST. Use exec(). -

Only a statement whose SQL resolves to a write is reported — an unreadable $sql is not -a finding.

+ "XoopsDoubleCpHeader": r""" +A second xoops_cp_header() on one request breaks the admin menu. +

The inspection reads the sibling admin_header.php: only when that file really calls +xoops_cp_header() is the page's own call a duplicate. Modules whose header file only +loads cp_header.php keep their call. Without an admin_header.php, a later call +duplicates the page's own first call only when that first call is a top-level statement; one call in +each branch of an if / else renders once.

+

The delete fix is offered only for the admin_header.php case, and not when +$xoopsOption (for example template_main) changes on the way to the second call. A +duplicate of the page's own call (the system admin pages: one on top, another in an error branch) is +reported without a fix: the page has used the first theme in between, so deleting the second call is not +output-neutral. The fix is never part of "Fix all".

+""", + "XoopsDoubleEncodedAmp": r""" +header('Location: …') and ModuleAdmin::addItemButton() take a raw URL: +renderButton() escapes it and header() sends it as is. A literal +&amp; in that string reaches the server as a parameter named amp;…, so the +token check fails. The fix writes &. A URL printed into HTML is left alone. +

redirect_header() (and the helper's redirect()) also take a raw URL, but turn +&amp; back into & for their meta refresh, so the browser requests the same URL +either way: there it is a weak note, and the same fix gives identical output.

+""", + "XoopsDuplicateDefine": r""" +The same constant define()d twice in one language file: PHP warns "Constant already defined" on +every request and keeps the first value. +

An identical duplicate is deleted by the fix; two different values are reported for a human to choose.

+""", + "XoopsDynamicCode": r""" +Code that runs names as code: $$name, ${$name}, extract($_POST), +eval(), create_function(). +

A request array copied into the symbol table lets the client define any variable and is an +error. There is no local rewrite — the replacement is a different design — so nothing is +offered as a quick-fix.

""", "XoopsFileHeader": r""" Module PHP files open with a docblock carrying @copyright and @license. @@ -56,104 +104,64 @@ (third-party MIT / GPL prose) is only noted, never rewritten. The fix is offered only for a file with no leading comment at all, and only on the host file — a "<?php …" string literal is an injected fragment, never a file.

-""", - "XoopsResultSetGuard": r""" -fetchArray() / fetchRow() / fetchBoth() on a handle that no -isResultSet() check dominates. -

Recognised guards: a positive check in the enclosing if / while condition -(including while ($db->isResultSet($r) && $row = …)), an early-exit -if (!$db->isResultSet($r)) or if (!$r) in a preceding statement, until the -handle is reassigned. A fetch inside if (!$db->isResultSet($r)) { … } is a reversed -condition and gets its own message.

-

The fix inserts an early return that keeps the enclosing function's failure contract -([], 0, false, null) — never a throw.

""", "XoopsFormLoader": r""" XoopsForm* classes are not autoloaded. A file whose class extends one of them needs xoops_load('XoopsFormLoader') (or a load of exactly the parent class) before the class keyword.

The fix inserts a real statement in front of the class and its docblock — never on an existing -line, never inside a comment.

+line, never inside a comment. The core's own class/xoopsform/ is what the loader includes and is +not reported.

""", - "XoopsSuperglobal": r""" -Raw $_GET / $_POST / $_COOKIE reads go through Xmf\Request; -$_REQUEST is banned outright. -

The rewrite is offered only where it preserves meaning: a keyed read becomes -Request::getString('key', '', 'POST'), a keyed isset() becomes -Request::hasVar(). A write, unset(), empty(), a === null -comparison, a dynamic key or a bare $_POST are reported without a fix.

+ "XoopsGuestUser": r""" +isset($xoopsUser) is true for a guest (the global is ''). Test +$xoopsUser instanceof \XoopsUser. A condition that already joins the isset with +is_object($xoopsUser) or instanceof is not reported: isset($u) && is_object($u), +or its De Morgan form !isset($u) || !is_object($u). """, - "XoopsUnserialize": r""" -unserialize() without ['allowed_classes' => false] can instantiate arbitrary classes. -

The fix adds the option; the argument count comes from the PSI, so an inner call with commas is handled.

+ "XoopsHandlerSignature": r""" +A handler extending XoopsPersistableObjectHandler / XoopsObjectHandler must declare +&getObjects(…) and &getAll(…): the kernel returns by reference and PHP refuses an +incompatible override at class load. Reported only — adding & also requires +return $var; instead of return [];.

""", "XoopsHttpStatus": r""" header('HTTP/1.0 404 Not Found') hard-codes the protocol version. Use http_response_code(404). """, - "XoopsSetcookie": r""" -Prefer xoops_setcookie() over setcookie(): same arguments, plus the site-wide -SameSite / domain policy. + "XoopsIncludeOnceHeader": r""" +

Reports include or require of header.php / footer.php +without the _once form.

+

The core header defines functions and starts the theme. When the same request reaches it twice +through different include paths, PHP stops with a "cannot redeclare" fatal. The quick-fix renames the +keyword to include_once / require_once and changes nothing else.

+ +

Carried over from XOOPS Support 1.0.0 Alpha 3.

""", - "XoopsGuestUser": r""" -isset($xoopsUser) is true for a guest (the global is ''). Test -$xoopsUser instanceof \XoopsUser. A condition that already includes -is_object($xoopsUser) or instanceof is not reported. + "XoopsInstallSql": r""" +

Rules on the SQL file the installer runs: a module's sql/mysql.sql, the file an active $modversion['sqlfile'] entry names (resolved from the module root, so sql/english/install.sql counts), or the core's install/sql/ schema. Adminer / phpMyAdmin dumps and old upgrade scripts in a module's sql/ folder are never run and are not checked, nor is a file that only a comment mentions.

  • FOREIGN KEY … REFERENCES: SqlUtility::prefixQuery() prefixes only the table being created, never the REFERENCES target. The key dangles to a nonexistent table; a plain INSERT fails with 1452, an INSERT IGNORE silently writes nothing. Enforce the relation in the handler, or add the key in oninstall.php with $db->prefix().
  • Hard-coded prefix: xoops_, {prefix} and %s are not installer tokens; the installer adds the site prefix itself. Use the bare module table name.
  • ENGINE=MyISAM (weak): no transactions, no row locks; create module tables with InnoDB, which supports FULLTEXT since MySQL 5.6. Not reported on the core installer schema (install/sql/), which keeps its legacy tables MyISAM on purpose.

Report only: each change alters the schema and belongs to the author.

""", - "XoopsUnlink": r""" -unlink($path) with no is_file() / file_exists() on the same expression -dominating it. A check in the enclosing if or a preceding early-exit counts. -""", - "XoopsByRefArgument": r""" -A call passes a temporary (a call result, a literal) where the resolved declaration takes a reference. -Resolution goes through the PSI, so parent::__construct() is checked against the parent's -signature, not the current file's. + "XoopsIsFileAsset": r""" +

file_exists($x) and is_readable($x) are true for a directory. When the condition's body then includes $x or reads it with readfile(), file_get_contents(), fopen() or similar, a directory name reaches the reader.

The fix appends && is_file($x): it only narrows the condition. A negated check (!file_exists($x)) is reported without a fix, because !a && b would invert it.

""", "XoopsLanguageLoad": r""" -A page script uses _MD_ / _CO_ / _AM_ constants but loads no catalog: -neither xoops_loadLanguage(), nor the XMF helper's loadLanguage(), nor an include -of header.php / admin_header.php / common.php. -""", - "XoopsCsrf": r""" -A switch ($op) branch that writes (handler insert / delete, exec(), file writes) -without a CSRF token check. -

A branch is covered by its own $GLOBALS['xoopsSecurity']->check() or -xoops_confirm(), or by a top-level gate before the switch — but a gate that only runs for -POST covers nothing when $op is read from GET. The branch content decides, never the case -label.

+A page script (it boots mainfile.php) uses _MD_ / _CO_ / _AM_ +constants but loads no catalog: neither xoops_loadLanguage(), nor the XMF helper's +loadLanguage(), nor an include of header.php / admin_header.php / +common.php. +

A file that does not boot mainfile.php is included by one that did and uses its catalogs +(system admin pages, blocks, admin_footer.php, Smarty plugins): not reported.

""", "XoopsLanguageRole": r""" Language catalogs: _MI_ in modinfo.php, _AM_ in admin.php, _MD_ in main.php, _MB_ in blocks.php, _CO_ in common.php.

A canonical file with a foreign prefix gets one note per constant. A non-canonical file -(modinfo1.php, a backup copy) gets one file-level note.

-""", - "XoopsMissingRegisteredTemplate": r""" -A template registered in xoops_version.php is not where the installer will read it: -type => admin|blocks adds that folder, every other type (including an invented -partial) is templates/{file} as written. -

When a file with that name exists elsewhere under templates/, the fix points the -registration at it. No empty stub is ever created.

-""", - "XoopsUnregisteredTemplate": r""" -A module .tpl the manifest does not know, or a db: include nobody registers. -

Core system_* templates, templates whose prefix belongs to another module -(db:tag_bar.tpl inside Publisher) and includes inside a Smarty comment are not reported. -Registering a template changes what the installer stores, so the fix is an explicit Alt+Enter and -never part of "Fix all".

-""", - "XoopsDynamicCode": r""" -Code that runs names as code: $$name, ${$name}, extract($_POST), -eval(), create_function(). -

A request array copied into the symbol table lets the client define any variable and is an -error. There is no local rewrite — the replacement is a different design — so nothing is -offered as a quick-fix.

-""", - "XoopsOrderBy": r""" -' ORDER BY ' . $criteria->getSort() concatenates whatever setSort() received; -setSort(Request::getString('sort')) hands the column to the client. -

Quoting does not help: a column is an identifier, not a string. Allow-list the column or use -XoopsObject::buildOrderBy(). Reported without a fix.

+(modinfo1.php, a backup copy) gets one file-level note, unless some code loads it by name +(xoops_loadLanguage('cpanel', 'system')).

+

Only a module's own catalogs directly under language/<lang>/ are checked: the core's +htdocs/language/, the Frameworks/ libraries (moduleadmin ships a xoops_version.php but loads its main.php by path) and sub-catalogs such as +language/<lang>/admin/<page>.php follow their own loaders. _MD_AM_ (system) +and _MD_A_ (Protector, D3) are admin constants by name and belong in admin.php.

""", "XoopsLegacySuperglobal": r""" $HTTP_POST_VARS, $HTTP_GET_VARS and the other long arrays were removed in PHP 5.4: @@ -161,37 +169,12 @@

The fix renames the variable to its $_POST / $_GET counterpart; the raw superglobal inspection then takes over.

""", - "XoopsDeprecatedCoreApi": r""" -Core APIs the 2.7.4 tree marks @deprecated (list generated from the real -class/, kernel/ and include/ docblocks) plus pre-2.5 idioms that still -surface in old modules: XoopsTree, XoopsComments, Snoopy, -XOBJ_DTYPE_UNICODE_*, xoops_getModuleOption(), the sanitizer's -makeTboxData4*() family, Smarty 2 method names on XoopsTpl, and PHP 4 -constructor calls (parent::XoopsObject()). -

PhpStorm's own deprecation inspection needs the core in the project; this one works on a -standalone module and says what to use instead. A rename fix is offered only where the -replacement takes the same arguments (destoryVars → destroyVars, -parent::XoopsObject() → parent::__construct(), assign_by_ref → -assignByRef …). The UNICODE dtypes are never renamed: they decide how stored rows -are decoded.

-""", - "XoopsSmartyDelimiter": r""" -XOOPS compiles Smarty with <{ }> delimiters: a bare {if $x} or -{$var} is printed literally. -

Comments (<{* *}>) and <{literal}> blocks are masked; CSS and JavaScript -braces do not match because only Smarty keywords and {$…} are looked for. The fix wraps the -tag in <{ }>.

-""", - "XoopsDoubleEncodedAmp": r""" -redirect_header(), header() and ModuleAdmin::addItemButton() take a raw URL: -renderButton() escapes it and redirect_header() sends it as is. A literal -&amp; in that string reaches the server as a parameter named amp;…, so the -token check fails. The fix writes &. A URL printed into HTML is left alone.

-""", - "XoopsStrictCalendarCast": r""" -Under declare(strict_types=1), mktime(0, 0, 0, date('n'), 1, date('Y')) throws a -TypeError on every call: date() returns a string and the calendar functions take int. -

The fix wraps the argument in (int); on a numeric string the cast is exact.

+ "XoopsLocalXoopsOption": r""" +$xoopsOption['template_main'] = 'x.tpl' inside a function or method writes a local array unless the +function declares global $xoopsOption. The page then renders without its template, and a read is an +undefined variable. +

The fix writes $GLOBALS['xoopsOption']. Script-level code already runs in the global scope and is +not reported, nor is a function that declares the global or takes $xoopsOption as a parameter.

""", "XoopsLocaleConstant": r""" _AM_<DIRNAME>_X, _MD_…, _MI_…, _MB_…, _CO_… @@ -199,15 +182,6 @@ Error: Undefined constant where PHP 7 printed the constant's name.

Only constants carrying the module's own dirname are checked, against PhpStorm's index of every define() in the project. Reported without a fix.

-""", - "XoopsDuplicateDefine": r""" -The same constant define()d twice in one language file: PHP warns "Constant already defined" on -every request and keeps the first value. -

An identical duplicate is deleted by the fix; two different values are reported for a human to choose.

-""", - "XoopsThemeConstant": r""" -define('THEME_…') in a module language file. The THEME_* constants belong to the -theme's own language pack; whichever file loads second warns and the theme's text loses. Reported only.

""", "XoopsMenuAutoload": r""" admin/menu.php is read by the system module's menu builder before the module's own bootstrap: @@ -216,18 +190,116 @@

The fix inserts require_once \dirname(__DIR__) . '/preloads/autoloader.php'; before the first statement that uses the class, when that autoloader exists.

""", - "XoopsHandlerSignature": r""" -A handler extending XoopsPersistableObjectHandler / XoopsObjectHandler must declare -&getObjects(…) and &getAll(…): the kernel returns by reference and PHP refuses an -incompatible override at class load. Reported only — adding & also requires -return $var; instead of return [];.

+ "XoopsMissingRegisteredTemplate": r""" +A template registered in xoops_version.php is not where the installer will read it: +type => admin|blocks adds that folder, every other type (including an invented +partial) is templates/{file} as written. +

When a file with that name exists elsewhere under templates/, the fix points the +registration at it. No empty stub is ever created.

""", - "XoopsSqlConcat": r""" -$_GET['id'], $_POST[…], $_REQUEST[…] or Request::getString() -concatenated into the SQL that reaches query() / exec() / queryF(), with -no quote(), (int) or intval() around it. The $sql variable is -followed back to its assignment in the same block. Reported only: the right fix (quote, cast or allow-list) -depends on the column.

+ "XoopsOrderBy": r""" +' ORDER BY ' . $criteria->getSort() concatenates whatever setSort() received; +setSort(Request::getString('sort')) hands the column to the client. +

Quoting does not help: a column is an identifier, not a string. Allow-list the column or use +XoopsObject::buildOrderBy(). Reported without a fix. getSort() passed to +buildOrderBy() is allow-listed and not reported.

+""", + "XoopsPathContainment": r""" +

strpos($path, $base) === 0 or str_starts_with($path, $base) on a filesystem path is a prefix test, not containment: /safe/../outside/secret starts with /safe, and /uploads2/x starts with /uploads.

Not reported: a realpath()'d path (directly, through a local, or behind a failure ternary such as $root !== false ? realpath(…) : false, through a str_replace() that only swaps separators, or a private method's parameter whose every caller in the class passes one) against a base ending in DIRECTORY_SEPARATOR or '/' — that is containment; a literal needle ('/', 'www.', 'ldaps://') — a shape test; a prefix strip for display (str_starts_with($p, $root . '/') ? substr($p, strlen($root)) : $p); a URL, a referer, or a prefix test inside a function that handles URLs (named for URLs or redirects, taking a URL, or calling parse_url()). A resolved path against a base without a separator gets its own message.

Report only. The fix is realpath() on both sides and a comparison against the directory plus a separator, placed where the path is final.

+""", + "XoopsPreloadGlobal": r""" +

A file under preloads/ runs inside every module's request. Assigning $xoopsModule or $xoopsModuleConfig there (or declaring them global, the prelude to it) repoints every later reader on the page to the wrong module: "Division by zero", null config keys, in a module that has nothing to do with the preload. Inside a function, $xoopsModule = … is a local unless the function declares it global; $xoopsModule = $GLOBALS['xoopsModule'] ?? null; is a read and is not reported.

Report only. Load the preload's own config into a local: $cfg = xoops_getHandler('config')->getConfigsByCat(0, $mod->getVar('mid')).

+""", + "XoopsQueryExec": r""" +query() with an INSERT / UPDATE / DELETE / DDL statement: the safe database class refuses it +on GET and strict mode warns on POST. Use exec(). +

Only a statement whose SQL resolves to a write is reported — an unreadable $sql is not +a finding. $force ? $db->exec($sql) : $db->query($sql) is the deliberate GET gate of a +non-forced write and is left alone.

+""", + "XoopsResultSetGuard": r""" +fetchArray() / fetchRow() / fetchBoth() on a handle that no +isResultSet() check dominates. +

Recognised guards: a positive check in the enclosing if / while condition +(including while ($db->isResultSet($r) && $row = …)), an early-exit +if (!$db->isResultSet($r)) or if (!$r) in a preceding statement, until the +handle is reassigned. An early exit may also end in break / continue; a check in +the same expression guards too (isResultSet($r) && … ? fetch($r) : false, +isResultSet($r) && … && fetch($r)), and a handle the function receives as a +parameter is the caller's to guard. A fetch inside if (!$db->isResultSet($r)) { … } is a reversed +condition and gets its own message.

+

The fix inserts an early return that keeps the enclosing function's failure contract +([], 0, false, null) — never a throw.

+""", + "XoopsRootPathGuard": r""" +Include-only PHP files (classes, preloads, includes, blocks) start with +
defined('XOOPS_ROOT_PATH') || exit('Restricted access');
+

A file that bootstraps the core itself — any top-level require of +mainfile.php, header.php, cp_header.php, +admin_header.php, print.php … — is a public entry point and is +never reported, even when error_reporting() or flag assignments precede that require. +preloads/autoloader.php, language files, tests, 404 stubs and fully qualified +\defined() guards are recognised.

+

The quick-fix inserts the guard after namespace / declare and before +use or the class docblock, on its own line.

+""", + "XoopsSetcookie": r""" +Prefer xoops_setcookie() over setcookie(): same arguments, plus the site-wide +SameSite / domain policy. +

Not reported inside xoops_setcookie() itself, nor in Protector, which runs from +mainfile.php before include/common.php defines the wrapper.

+""", + "XoopsSmarty5Api": r""" +

Smarty 5 removed assignByRef(), appendByRef(), the register_*() family and the public template_dir / compile_dir properties, and has no global \Smarty class (core 2.8 templates are \XoopsTpl, which extends \Smarty\Smarty).

assignByRef($k, $v) → assign($k, $v) is offered only when $v is never written again in its scope — then by-reference and by-value assignment are the same thing. Everything else is reported for a hand rewrite.

Smarty 5 ships with core 2.8: on a 2.5 / 2.7 tree (Smarty 2 / 4, detected from include/version.php or the Core Version setting) these calls are the current API and nothing is reported. A standalone module without a core is checked.

+""", + "XoopsSmarty5TemplatePrep": r""" +Constructs that work on Smarty 4 but need rework before a Smarty 5 core: bare <{parent}> / +<{child}>, <{insert}>, <{make_nocache}>, +config_load scope=, and PHP functions used as modifiers (|substr, |trim, +|strtolower …), which Smarty 5 only accepts when registered. +

Off by default: enable it for a tree that is being prepared for Smarty 5. There is no quick-fix.

+""", + "XoopsSmartyBlockShorthand": r""" +<{block_parent}> and <{block_child}> are not Smarty tags. The fix writes the +canonical <{$smarty.block.parent}> / <{$smarty.block.child}>, which work on +Smarty 3, 4 and 5. +""", + "XoopsSmartyDateFormatStrftime": r""" +|date_format:'%d.%m.%Y': a format with % codes runs strftime(), deprecated +since PHP 8.1 and removed from Smarty 5. Without %, date_format uses date(). +

The fix rewrites %Y %y %m %d %e %H %M %S %p %% to Y y m d j H i s A % +(%M is minutes, so it becomes i). A format with a locale token +(%A, %B, %j …) or with literal letters is reported without a fix: letters +are format codes in date(), so 'Le %d' cannot be rewritten mechanically.

+

Not reported on cores older than 2.5.11, whose Smarty always used strftime().

+""", + "XoopsSmartyForeachItemShadowsFrom": r""" +<{foreach item=story from=$story}>: Smarty 3 and later refuse an item variable with the same +name as the array it loops over. +

The fix renames the item to story_item in the tag and in every $story of the loop body, +up to <{foreachelse}> or <{/foreach}>. A nested loop that declares +item=story again keeps its own variable.

+""", + "XoopsSmartyRemovedTag": r""" +<{includeq}> and <{foreachq}> were XOOPS Smarty 2 plugins and no longer exist; +the fix renames them to <{include}> and <{foreach}>. +

<{php}> and <{include_php}> do not compile on Smarty 3 and later. They have +no fix: move the code into the PHP page and assign the result to the template.

+

Not reported on cores older than 2.5.11.

+""", + "XoopsSmartyRequestEscape": r""" +<{$smarty.get.x}>, $smarty.request, $smarty.post, +$smarty.cookie and $smarty.server print the request into the page. +

Unless the tag carries |escape that is reflected XSS. The fix appends +|escape after any other modifier.

+""", + "XoopsSmartyUnquotedPluginArg": r""" +<{xoAdminIcons home.png}>: since Smarty 3 (XOOPS 2.5.11) a bare plugin argument is parsed as +an expression and the template does not compile. +

The fix quotes it: <{xoAdminIcons 'home.png'}>. Single quotes are used everywhere, also +inside a single-quoted HTML attribute, because Smarty removes the whole tag before the browser sees it and most +compiler.xo*.php plugins strip only '.

+

Not reported on cores older than 2.5.11, where the argument had to be bare.

""", "XoopsSqlComment": r""" The module installer treats a line of sql/*.sql as a comment only when it starts with @@ -236,11 +308,74 @@ created. The SQL file is checked from the $modversion['sqlfile'] entry in xoops_version.php; the fix inserts the space in the SQL file.

""", - "XoopsSmartyRequestEscape": r""" -<{$smarty.get.x}>, $smarty.request, $smarty.post, -$smarty.cookie and $smarty.server print the request into the page. -

Unless the tag carries |escape that is reflected XSS. The fix appends -|escape after any other modifier.

+ "XoopsSqlConcat": r""" +$_GET['id'], $_POST[…], $_REQUEST[…] or Request::getString() +concatenated into the SQL that reaches query() / exec() / queryF(), with +no quote(), (int) or intval() around it. The $sql variable is +followed back to its assignment in the same block. Reported only: the right fix (quote, cast or allow-list) +depends on the column.

+""", + "XoopsSqlFileComment": r""" +

A line that is exactly -- is not a comment for SqlUtility::splitMySqlFile(); only -- (dash dash space) and # are. The bare line stays glued to the next statement, prefixQuery() fails its ^CREATE TABLE match and the installer skips that table with no error.

Checked on the file the installer runs: a module's sql/mysql.sql, the file an active $modversion['sqlfile'] entry names, or the core's install/sql/ schema. Dumps and old upgrade scripts beside it are not checked.

One finding per line. The fix inserts the space on that line only, re-checking the live line text, so it is safe in any batch order. The same defect is also reported on xoops_version.php by "Bare -- comment in the SQL file".

+""", + "XoopsStrictCalendarCast": r""" +Under declare(strict_types=1), mktime(0, 0, 0, date('n'), 1, date('Y')) throws a +TypeError on every call: date() returns a string and the calendar functions take int. +

The fix wraps the argument in (int); on a numeric string the cast is exact.

+""", + "XoopsSuperglobal": r""" +Raw $_GET / $_POST / $_COOKIE reads go through Xmf\Request; +$_REQUEST is banned outright. +

The rewrite is offered only where it preserves meaning: a keyed read becomes +Request::getString('key', '', 'POST'), a keyed isset() becomes +Request::hasVar(). A write, unset(), empty(), a === null +comparison, a dynamic key or a bare $_POST are reported without a fix. An assignment to $_POST itself +gets its own message: it changes what every later reader sees, Xmf\Request included, which may be the point.

+

Not inspected: Protector (the request firewall rewrites the raw superglobals before XOOPS boots), the core installer and upgrader (they run before the +xoops_lib path is known, so Xmf\Request cannot load) and, like +every security rule, test trees (tests/, test/, testdata/, fixtures/).

+""", + "XoopsThemeConstant": r""" +define('THEME_…') in a module language file. The THEME_* constants belong to the +theme's own language pack; whichever file loads second warns and the theme's text loses. Reported only.

+""", + "XoopsUnlink": r""" +unlink($path) with no is_file() / file_exists() on the same expression +dominating it. A check in the enclosing if or a preceding early-exit counts, at any block level up to +the function, and also inside a previous try whose every catch exits as well. +break / continue count as an exit. Also accepted: a $this->exists()-style helper of the +same class whose body checks the same $this->… path; a check on $x when the unlinked path is +realpath($x); and if (false === file_put_contents($p, …)) return; (or touch()): the code +created the file itself. An SplFileInfo object's isFile() counts when its +getRealPath() or getPathname() is unlinked, directly or through a variable (a +DirectoryIterator loop), and its isLink() counts when getPathname() (the link +itself) is unlinked. A check behind an unrelated && (if ($a && !is_file($p)) return;) +does not count, because a false $a skips it; nor does isFile() before getPath() +(the containing directory) or isLink() before getRealPath() (the link's target). +""", + "XoopsUnregisteredTemplate": r""" +A module .tpl the manifest does not know, or a db: include nobody registers. +

Core system_* templates, templates whose prefix belongs to another module +(db:tag_bar.tpl inside Publisher) and includes inside a Smarty comment are not reported. +Registering a template changes what the installer stores, so the fix is an explicit Alt+Enter and +never part of "Fix all".

+""", + "XoopsUnserialize": r""" +unserialize() without ['allowed_classes' => false] can instantiate arbitrary classes. +

The fix adds the option; the argument count comes from the PSI, so an inner call with commas is handled.

+""", + "XoopsUpdateCreatesTable": r""" +

A CREATE TABLE in sql/mysql.sql that no update path mentions is never created for a module that is already installed. If a released version shipped without the table, sites updating from it fail with error 1146 on first use. Whether such a version exists cannot be told from the tree, and a table as old as the module needs no update path, so this is a weak note.

Update paths searched: onupdate.php, include/onupdate.php, include/update.php, include/install.php and every YAML under sql/. An update that re-runs the SQL file or calls createMissingTables() / synchronizeSchema() covers every table. Modules without any update callback are not reported, nor is an SQL file the installer never runs: no active $modversion['sqlfile'] (commented out or false) and no install callback that reads mysql.sql itself.

Report only: the idempotent create (table-exists check, migrate YAML) belongs to the author.

+""", + "XoopsWeakToken": r""" +
  • A variable named *token*, *nonce* or *csrf* assigned from rand(), mt_rand() or uniqid(): predictable. Use bin2hex(random_bytes(n)) sized to the storage column, or the core token API.
  • md5() / sha1() of a *pass* / *secret* variable: not a password hash. Use password_hash() / password_verify(). Compared with a value or passed as a Criteria value, it looks up a hash an old XOOPS stored (the 2.2 login fallback): a weak note to keep it only as a migration path and rehash on success once the password column holds modern hashes (the upgrader widens it to 255 in 2.5.8; before that a bcrypt hash would not fit).
  • ==, !=, strcmp() on a token, secret or password: '0e123' == '0e456' is true, and the comparison is not constant-time. Use hash_equals() on two strings. A comparison with a literal that is not a numeric string ($pass == '', $addtoken != false) is an emptiness or flag test and is not reported.

Fix: when both sides are request text (Request::getString() / getText() / getPath() / getUrl(), or Request::getVar() with a literal default and type 'string'), assigned nowhere else in the scope, != becomes !== and == becomes ===. For such values the strict form only ever rejects more, and only where the loose one calls two different inputs equal — the password-confirmation case of edituser.php. Everything else is report only: the token's storage width and consumers are unknown, so no generator is swapped in.

+""", + "XoopsWrongSmartyDelimiter": r""" +XOOPS compiles Smarty with <{ }> delimiters: a bare {if $x} or +{$var} is printed literally. +

Comments (<{* *}>) and <{literal}> blocks are masked; CSS and JavaScript +braces do not match because only Smarty keywords and {$…} are looked for. The fix wraps the +tag in <{ }>.

""", } @@ -250,5 +385,6 @@ title = "XOOPS: " + re.sub(r"(?\n\n" + title + "\n\n" + body.strip() + "\n\n\n") - (OUT / f"{name}.html").write_text(html, encoding="utf-8") + # newline="\n": the repository stores these pages with LF; Windows' default would write CRLF. + (OUT / f"{name}.html").write_text(html, encoding="utf-8", newline="\n") print(f"wrote {len(DESCRIPTIONS)} descriptions to {OUT}") From 171008cd4debed9ad0d166acb7abb99025cb2c8c Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 26 Oct 2026 13:12:44 -0400 Subject: [PATCH 4/9] fix(sql): read sqlfile only from $modversion, gate loose SQL dumps - sqlfilePaths: only a $modversion assignment, or a top-level entry of the array assigned to $modversion, declares the installer file; $other['sqlfile'] and nested option arrays do not. - Join literal-only concatenations ('sql/' . 'install.sql') so the exact file match still finds the installer file. - Outside any module only the core installer's install/sql/ schema is inspected; a loose sql/ folder holds dumps. - MODULE_SCHEMA constant for "mysql.sql"; flatten the SplFileInfo receiver check so the target is smart-cast. - Changelog wording for the manifest parser contract. --- CHANGELOG.md | 7 +-- .../inspections/XoopsSmallFixInspections.kt | 7 ++- .../inspections/XoopsSqlFileInspections.kt | 49 ++++++++++++++----- .../org/xoops/support/XoopsQuickFixTest.kt | 12 +++++ .../InstallSql/loose/backup/sql/dump.sql | 5 ++ .../sql/modules/demo/xoops_version.php | 3 ++ 6 files changed, 63 insertions(+), 20 deletions(-) create mode 100644 src/test/resources/fixtures/InstallSql/loose/backup/sql/dump.sql create mode 100644 src/test/resources/fixtures/SqlFileComment/sql/modules/demo/xoops_version.php diff --git a/CHANGELOG.md b/CHANGELOG.md index cb2ee1e..d15c8a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,13 @@ checked against the module source and pinned with a fixture. file an active `sqlfile` entry names, resolved from the module root (so `sql/english/install.sql` counts, in both the `$modversion['sqlfile']['mysql']` and the array-manifest form, with every database entry of an array read). The manifest is read through the PHP parser, not as text: a commented-out declaration, a string or nowdoc that - looks like one, and a comparison name nothing, while a declaration behind an inline comment, after `?>`, or with a - multi-line `[XOOPS_DB_TYPE]` index still counts. Adminer / phpMyAdmin dumps and old upgrade + looks like one, a comparison against `sqlfile`, and a `sqlfile` key on any variable other than `$modversion` do + not count, while a declaration behind an inline comment, after `?>`, with a multi-line `[XOOPS_DB_TYPE]` index, + or joined from literals (`'sql/' . 'install.sql'`) still does. Adminer / phpMyAdmin dumps and old upgrade scripts beside it (`2510debug1.sql`, `xoops_mysql.sql`, `mysql.400.sql`) are never split by SqlUtility, so their MyISAM engine, `xoops_` prefixes and bare `--` lines were 237 + 111 findings about files nothing executes. A file a comment merely mentions is not an installer file. The core's own `install/sql/` schema has no module manifest and - stays inspected. + stays inspected; any other `sql/` folder outside a module does not. - **Unlink** accepts an `SplFileInfo` check: `$info->isFile()` dominating `unlink($info->getRealPath())`, or a variable assigned from `$info->getPathname()`, is the is_file() check, and `$info->isLink()` guards `unlink($info->getPathname())` (the link itself, also when it dangles). `isFile()` before `getPath()` (the parent diff --git a/src/main/kotlin/org/xoops/support/inspections/XoopsSmallFixInspections.kt b/src/main/kotlin/org/xoops/support/inspections/XoopsSmallFixInspections.kt index d5af2fb..27587cc 100644 --- a/src/main/kotlin/org/xoops/support/inspections/XoopsSmallFixInspections.kt +++ b/src/main/kotlin/org/xoops/support/inspections/XoopsSmallFixInspections.kt @@ -205,10 +205,9 @@ class XoopsUnlinkInspection : XoopsInspection() { // object names the file, so `$info->isFile()` is the check (newbb FilesManagement, DirectoryIterator loops). val target = Php.unwrap(arg).let { (it as? Variable)?.let(Php::resolveAssignment) ?: it } as? MethodReference val receiver = target?.classReference?.text?.let(::normalize) - val spl = when { - receiver == null -> emptyList() - target?.name == "getRealPath" -> listOf(SPL_FILE + receiver) - target?.name == "getPathname" -> listOf(SPL_FILE + receiver, SPL_LINK + receiver) + val spl = if (target == null || receiver == null) emptyList() else when (target.name) { + "getRealPath" -> listOf(SPL_FILE + receiver) + "getPathname" -> listOf(SPL_FILE + receiver, SPL_LINK + receiver) else -> emptyList() } val keys = setOfNotNull(normalize(arg.text), alias?.let { normalize(it.text) }) + spl diff --git a/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt b/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt index 12b1bb6..daa9c67 100644 --- a/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt +++ b/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt @@ -17,8 +17,11 @@ import com.jetbrains.php.lang.psi.PhpFile import com.jetbrains.php.lang.psi.elements.ArrayAccessExpression import com.jetbrains.php.lang.psi.elements.ArrayHashElement import com.jetbrains.php.lang.psi.elements.AssignmentExpression +import com.jetbrains.php.lang.psi.elements.BinaryExpression +import com.jetbrains.php.lang.psi.elements.ConcatenationExpression import com.jetbrains.php.lang.psi.elements.SelfAssignmentExpression import com.jetbrains.php.lang.psi.elements.StringLiteralExpression +import com.jetbrains.php.lang.psi.elements.Variable import org.xoops.support.fix.XoopsFix import org.xoops.support.psi.XoopsPaths import java.nio.charset.StandardCharsets @@ -31,6 +34,9 @@ import java.nio.charset.StandardCharsets /** Shared gate and text helpers for the SQL rules. */ object InstallSql { + /** The module schema the installer runs when no `sqlfile` entry names another file. */ + const val MODULE_SCHEMA = "mysql.sql" + /** A finding on a text range of a non-PHP file; the plain range overload registered nothing in tests. */ fun report( holder: ProblemsHolder, file: PsiFile, range: TextRange, message: String, onTheFly: Boolean, @@ -56,13 +62,13 @@ object InstallSql { * `xoops_version.php` names, resolved from the module root (`sql/english/install.sql` is two levels down). * A `sql/` folder also holds Adminer / phpMyAdmin dumps and old upgrade scripts (newbb: `2510debug1.sql`, * `xoops_mysql.sql`, `mysql.400.sql`) that SqlUtility never sees; their engine, prefix and `--` lines are not - * findings. A file outside any module (the core's `install/sql/mysql.structure.sql`) belongs to the core - * installer and stays inspected. + * findings. Outside any module only the core installer's `install/sql/` schema stays inspected; any other + * loose `sql/` folder holds dumps too. */ fun installerFile(file: PsiFile): Boolean { val vf = file.virtualFile ?: return true - val root = XoopsPaths.moduleRoot(vf) ?: return true - if (file.name.equals("mysql.sql", true)) return true + val root = XoopsPaths.moduleRoot(vf) ?: return XoopsPaths.path(file).contains("/install/sql/") + if (file.name.equals(MODULE_SCHEMA, true)) return true val manifestFile = root.findChild("xoops_version.php") ?: return false val manifest = PsiManager.getInstance(file.project).findFile(manifestFile) as? PhpFile ?: return false return sqlfilePaths(manifest).any { root.findFileByRelativePath(it.removePrefix("./")) == vf } @@ -72,13 +78,14 @@ object InstallSql { * Every `.sql` path a `sqlfile` declaration of the manifest assigns, read from the PHP PSI, so comments, * strings, heredocs, `?>` and comparisons are the parser's business. `$modversion['sqlfile']['mysql'] = 'x'`, * `$modversion['sqlfile'][XOOPS_DB_TYPE] = 'x'`, `$modversion['sqlfile'] = ['pgsql' => 'y', 'mysql' => 'x']` and - * the array-manifest `'sqlfile' => ['mysql' => 'x']` (also `array(…)`) all count; every database entry is read. + * the array-manifest `$modversion = ['sqlfile' => ['mysql' => 'x']]` (also `array(…)`) all count; every database + * entry is read, and `'sql/' . 'install.sql'` is joined. Only `$modversion` declares: `$other['sqlfile']` does not. */ fun sqlfilePaths(manifest: PhpFile): List { val values = ArrayList() for (a in PsiTreeUtil.findChildrenOfType(manifest, AssignmentExpression::class.java)) { // `.=` appends to a path set elsewhere; it is not the declaration. - if (a is SelfAssignmentExpression) continue + if (a is SelfAssignmentExpression || !isModversion(a.variable)) continue // Only the `[…]` chain being written counts: a `sqlfile` read inside an index, or a destructuring // target, does not make the right-hand side an installer file. var access = a.variable as? ArrayAccessExpression @@ -86,16 +93,32 @@ object InstallSql { if (access != null) a.value?.let(values::add) } for (h in PsiTreeUtil.findChildrenOfType(manifest, ArrayHashElement::class.java)) { - if (isSqlfileKey(h.key)) h.value?.let(values::add) + // A top-level entry of the array assigned to `$modversion` itself. + val owner = h.parent?.parent as? AssignmentExpression + if (isSqlfileKey(h.key) && owner?.value === h.parent && isModversion(owner.variable)) h.value?.let(values::add) } - return values - .flatMap { listOfNotNull(it as? StringLiteralExpression) + PsiTreeUtil.findChildrenOfType(it, StringLiteralExpression::class.java) } - .map { it.contents } - .filter { it.endsWith(".sql", ignoreCase = true) } + return values.flatMap(::strings).filter { it.endsWith(".sql", ignoreCase = true) } } private fun isSqlfileKey(e: PsiElement?): Boolean = (e as? StringLiteralExpression)?.contents.equals("sqlfile", ignoreCase = true) + private fun isModversion(e: PsiElement?): Boolean { + var root = e + while (root is ArrayAccessExpression) root = root.value + return (root as? Variable)?.name == "modversion" + } + + /** The constant string of a literal or a literal-only concatenation, else null. */ + private fun constant(e: PsiElement?): String? = when (e) { + is StringLiteralExpression -> e.contents + is ConcatenationExpression -> constant(e.leftOperand)?.let { l -> constant(e.rightOperand)?.let { l + it } } + else -> null + } + + /** Every constant string in an `sqlfile` value; a concatenation with a variable part names no file. */ + private fun strings(e: PsiElement): List = + constant(e)?.let(::listOf) ?: if (e is BinaryExpression) emptyList() else e.children.flatMap(::strings) + /** `-- ` and `#` line comments and block comments blanked, offsets kept. */ fun stripComments(text: String): String { val out = StringBuilder(text.length) @@ -190,7 +213,7 @@ class XoopsUpdateCreatesTableInspection : LocalInspectionTool() { override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor { return object : PsiElementVisitor() { override fun visitFile(file: PsiFile) { - if (!InstallSql.isInstallerSql(file) || !file.name.equals("mysql.sql", true)) return + if (!InstallSql.isInstallerSql(file) || !file.name.equals(InstallSql.MODULE_SCHEMA, true)) return val module = file.virtualFile?.parent?.parent ?: return if (!installerRuns(module)) return val updates = updateText(module) ?: return @@ -225,7 +248,7 @@ class XoopsUpdateCreatesTableInspection : LocalInspectionTool() { } if (declared) return true return listOf("oninstall.php", "include/oninstall.php", "include/install.php").any { rel -> - InstallSql.read(module.findFileByRelativePath(rel))?.contains("mysql.sql") == true + InstallSql.read(module.findFileByRelativePath(rel))?.contains(InstallSql.MODULE_SCHEMA) == true } } diff --git a/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt b/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt index c7db9fa..801f1f2 100644 --- a/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt +++ b/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt @@ -357,6 +357,10 @@ class XoopsQuickFixTest : BasePlatformTestCase() { fun testSqlFileCommentStillChecksTheCoreSchema() = checkSql(XoopsSqlFileCommentInspection(), "InstallSql/corecomment", "install/sql/mysql.structure.sql", 1) + /** A dump in a loose `sql/` folder, outside any module and outside `install/sql/`, is run by nothing. */ + fun testSqlFileCommentIgnoresALooseDumpOutsideAnyModule() = + checkSql(XoopsSqlFileCommentInspection(), "InstallSql/loose", "backup/sql/dump.sql", 0) + /** `sqlfile` naming `sql/english/install.sql` resolves from the module root, two levels above the file. */ fun testInstallSqlChecksANestedSqlfileEntry() = checkSql(XoopsInstallSqlInspection(), "InstallSql/nested", "modules/demo/sql/english/install.sql", 1) @@ -396,6 +400,14 @@ class XoopsQuickFixTest : BasePlatformTestCase() { listOf("sql/live.sql"), paths("(), paths("(), paths("(), paths(" ['mysql' => 'sql/backup.sql']];")) + assertEquals( + emptyList(), + paths(" 'x', 'options' => ['sqlfile' => 'sql/backup.sql']];"), + ) } fun testUnregisteredTemplateSkipsTestTrees() = diff --git a/src/test/resources/fixtures/InstallSql/loose/backup/sql/dump.sql b/src/test/resources/fixtures/InstallSql/loose/backup/sql/dump.sql new file mode 100644 index 0000000..f852370 --- /dev/null +++ b/src/test/resources/fixtures/InstallSql/loose/backup/sql/dump.sql @@ -0,0 +1,5 @@ +-- +CREATE TABLE xoops_avatar ( + avatar_id MEDIUMINT(8) UNSIGNED NOT NULL AUTO_INCREMENT, + PRIMARY KEY (avatar_id) +) ENGINE=MyISAM; diff --git a/src/test/resources/fixtures/SqlFileComment/sql/modules/demo/xoops_version.php b/src/test/resources/fixtures/SqlFileComment/sql/modules/demo/xoops_version.php new file mode 100644 index 0000000..c34cb38 --- /dev/null +++ b/src/test/resources/fixtures/SqlFileComment/sql/modules/demo/xoops_version.php @@ -0,0 +1,3 @@ + Date: Mon, 26 Oct 2026 16:42:17 -0400 Subject: [PATCH 5/9] docs(inspections): explain UpdateCreatesTable in plain words The description said what the rule checks but not why a table can go missing. It now explains that mysql.sql runs only on a fresh install, walks through a version 1.0 / 1.1 example ending in error 1146, points at xnewsletter's include/onupdate.php as the pattern, and says when the weak note can be ignored. Same text in the description page, the generator and INSPECTIONS.md. --- CHANGELOG.md | 4 +++- INSPECTIONS.md | 15 +++++++++++++-- .../XoopsUpdateCreatesTable.html | 2 +- tools/gen_descriptions.py | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d15c8a5..fd88e25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,9 @@ checked against the module source and pinned with a fixture. pinned 2024.3.5 platform, so a parser change in a newer PhpStorm is caught before a report comes in. - **Docs:** `INSPECTIONS.md` lists all 52 inspections with default level, what each reports, what its quick-fix changes and when it stays silent. README and TUTORIAL link to it; the README's Alpha 3 era table is gone. The - tutorial no longer claims that `assets/js/` is read by the security rules: that tree is excluded. + tutorial no longer claims that `assets/js/` is read by the security rules: that tree is excluded. The + **UpdateCreatesTable** description is rewritten in plain words, with a version 1.0 / 1.1 example and xnewsletter's + `include/onupdate.php` as the pattern to follow. - **Tools:** `tools/gen_descriptions.py` is rebuilt from the shipped description pages and reproduces all 52 byte for byte. It had 35 entries, 12 of them shorter than the pages edited since and two under retired short names, so running it would have overwritten reviewed descriptions. diff --git a/INSPECTIONS.md b/INSPECTIONS.md index 30ed35e..dd3c9fb 100644 --- a/INSPECTIONS.md +++ b/INSPECTIONS.md @@ -224,8 +224,19 @@ silently writes nothing. Hard-coded prefix: `xoops_`, `{prefix}` and `%s` are no installer schema under `install/sql/`. Report only. ### XoopsUpdateCreatesTable — CREATE TABLE that no module update path creates -A `CREATE TABLE` in `sql/mysql.sql` that no update path mentions is never created for a module that is already -installed; sites updating from a version that shipped without it fail with error 1146. Update paths searched: +XOOPS runs `sql/mysql.sql` only on a fresh install. When a site updates a module it already has, that file is not +run again, so a table added in a later version must also be created by the module's update code. + +Example: version 1.0 ships tables `a` and `b`, and version 1.1 adds table `c` to `mysql.sql`. A new install of 1.1 +gets all three. A site updating from 1.0 still has only `a` and `b`, and the first page that uses `c` fails with MySQL +error 1146 (table doesn't exist). xnewsletter's `include/onupdate.php` shows the fix: one update step per version that +added a table, each creating it if it is missing. + +This note marks every `CREATE TABLE` that no update code mentions. The plugin cannot tell whether the table is new. +If it has existed since the module's first release, every site already has it and the note can be ignored. If it was +added later, add a create-if-missing step to the update code. That is why it is only a weak note. + +Update paths searched: `onupdate.php`, `include/onupdate.php`, `include/update.php`, `include/install.php` and every YAML under `sql/`. An update that re-runs the SQL file or calls `createMissingTables()` / `synchronizeSchema()` covers every table. Modules without any update callback are not reported, nor is an SQL file the installer never runs (`sqlfile` commented out or diff --git a/src/main/resources/inspectionDescriptions/XoopsUpdateCreatesTable.html b/src/main/resources/inspectionDescriptions/XoopsUpdateCreatesTable.html index 7638a39..0bb1b72 100644 --- a/src/main/resources/inspectionDescriptions/XoopsUpdateCreatesTable.html +++ b/src/main/resources/inspectionDescriptions/XoopsUpdateCreatesTable.html @@ -2,6 +2,6 @@ XOOPS: Update Creates Table -

A CREATE TABLE in sql/mysql.sql that no update path mentions is never created for a module that is already installed. If a released version shipped without the table, sites updating from it fail with error 1146 on first use. Whether such a version exists cannot be told from the tree, and a table as old as the module needs no update path, so this is a weak note.

Update paths searched: onupdate.php, include/onupdate.php, include/update.php, include/install.php and every YAML under sql/. An update that re-runs the SQL file or calls createMissingTables() / synchronizeSchema() covers every table. Modules without any update callback are not reported, nor is an SQL file the installer never runs: no active $modversion['sqlfile'] (commented out or false) and no install callback that reads mysql.sql itself.

Report only: the idempotent create (table-exists check, migrate YAML) belongs to the author.

+

XOOPS runs sql/mysql.sql only on a fresh install. When a site updates a module it already has, that file is not run again, so a table added in a later version must also be created by the module's update code.

Example: version 1.0 ships tables a and b, and version 1.1 adds table c to mysql.sql. A new install of 1.1 gets all three. A site updating from 1.0 still has only a and b, and the first page that uses c fails with MySQL error 1146 (table doesn't exist). xnewsletter's include/onupdate.php shows the fix: one update step per version that added a table, each creating it if it is missing.

This note marks every CREATE TABLE that no update code mentions. The plugin cannot tell whether the table is new. If it has existed since the module's first release, every site already has it and the note can be ignored. If it was added later, add a create-if-missing step to the update code. That is why it is only a weak note.

Update paths searched: onupdate.php, include/onupdate.php, include/update.php, include/install.php and every YAML under sql/. An update that re-runs the SQL file or calls createMissingTables() / synchronizeSchema() covers every table. Modules without any update callback are not reported, nor is an SQL file the installer never runs: no active $modversion['sqlfile'] (commented out or false) and no install callback that reads mysql.sql itself.

Report only: the idempotent create (table-exists check, migrate YAML) belongs to the author.

diff --git a/tools/gen_descriptions.py b/tools/gen_descriptions.py index 0277840..8dddc80 100644 --- a/tools/gen_descriptions.py +++ b/tools/gen_descriptions.py @@ -365,7 +365,7 @@

The fix adds the option; the argument count comes from the PSI, so an inner call with commas is handled.

""", "XoopsUpdateCreatesTable": r""" -

A CREATE TABLE in sql/mysql.sql that no update path mentions is never created for a module that is already installed. If a released version shipped without the table, sites updating from it fail with error 1146 on first use. Whether such a version exists cannot be told from the tree, and a table as old as the module needs no update path, so this is a weak note.

Update paths searched: onupdate.php, include/onupdate.php, include/update.php, include/install.php and every YAML under sql/. An update that re-runs the SQL file or calls createMissingTables() / synchronizeSchema() covers every table. Modules without any update callback are not reported, nor is an SQL file the installer never runs: no active $modversion['sqlfile'] (commented out or false) and no install callback that reads mysql.sql itself.

Report only: the idempotent create (table-exists check, migrate YAML) belongs to the author.

+

XOOPS runs sql/mysql.sql only on a fresh install. When a site updates a module it already has, that file is not run again, so a table added in a later version must also be created by the module's update code.

Example: version 1.0 ships tables a and b, and version 1.1 adds table c to mysql.sql. A new install of 1.1 gets all three. A site updating from 1.0 still has only a and b, and the first page that uses c fails with MySQL error 1146 (table doesn't exist). xnewsletter's include/onupdate.php shows the fix: one update step per version that added a table, each creating it if it is missing.

This note marks every CREATE TABLE that no update code mentions. The plugin cannot tell whether the table is new. If it has existed since the module's first release, every site already has it and the note can be ignored. If it was added later, add a create-if-missing step to the update code. That is why it is only a weak note.

Update paths searched: onupdate.php, include/onupdate.php, include/update.php, include/install.php and every YAML under sql/. An update that re-runs the SQL file or calls createMissingTables() / synchronizeSchema() covers every table. Modules without any update callback are not reported, nor is an SQL file the installer never runs: no active $modversion['sqlfile'] (commented out or false) and no install callback that reads mysql.sql itself.

Report only: the idempotent create (table-exists check, migrate YAML) belongs to the author.

""", "XoopsWeakToken": r"""
  • A variable named *token*, *nonce* or *csrf* assigned from rand(), mt_rand() or uniqid(): predictable. Use bin2hex(random_bytes(n)) sized to the storage column, or the core token API.
  • md5() / sha1() of a *pass* / *secret* variable: not a password hash. Use password_hash() / password_verify(). Compared with a value or passed as a Criteria value, it looks up a hash an old XOOPS stored (the 2.2 login fallback): a weak note to keep it only as a migration path and rehash on success once the password column holds modern hashes (the upgrader widens it to 255 in 2.5.8; before that a bcrypt hash would not fit).
  • ==, !=, strcmp() on a token, secret or password: '0e123' == '0e456' is true, and the comparison is not constant-time. Use hash_equals() on two strings. A comparison with a literal that is not a numeric string ($pass == '', $addtoken != false) is an emptiness or flag test and is not reported.

Fix: when both sides are request text (Request::getString() / getText() / getPath() / getUrl(), or Request::getVar() with a literal default and type 'string'), assigned nowhere else in the scope, != becomes !== and == becomes ===. For such values the strict form only ever rejects more, and only where the loose one calls two different inputs equal — the password-confirmation case of edituser.php. Everything else is report only: the token's storage width and consumers are unknown, so no generator is swapped in.

From b482b3b3c95ae482d39b8d457100f7b4db0dab70 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 26 Oct 2026 16:47:43 -0400 Subject: [PATCH 6/9] fix(sql): honour the selected sqlfile over the mysql.sql default - sql/mysql.sql is assumed only when the manifest names no file; a leftover mysql.sql beside a selected sql/english/install.sql, or any other file named mysql.sql, is no longer inspected. - sqlfile must be the first key after $modversion: $modversion['config']['sqlfile'] no longer declares a file. - An outer array assigned to $modversion may sit inside a call, so $modversion = array_merge($defaults, ['sqlfile' => ...]) still counts. - Description pages, generator, INSPECTIONS.md and changelog updated. --- CHANGELOG.md | 17 +++++--- INSPECTIONS.md | 6 +-- .../inspections/XoopsSqlFileInspections.kt | 43 ++++++++++--------- .../XoopsInstallSql.html | 2 +- .../XoopsSqlFileComment.html | 2 +- .../org/xoops/support/XoopsQuickFixTest.kt | 6 +++ .../nested/modules/demo/sql/mysql.sql | 5 +++ tools/gen_descriptions.py | 4 +- 8 files changed, 51 insertions(+), 34 deletions(-) create mode 100644 src/test/resources/fixtures/InstallSql/nested/modules/demo/sql/mysql.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index fd88e25..055e7c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,16 @@ checked against the module source and pinned with a fixture. - **DeprecatedCoreApi** no longer names `synchronization()`: it is the module handlers' own method (newbb reports 99 calls), and the core's `XoopsPersistableObjectHandler::synchronization()` carries no `@deprecated` tag either. -- **InstallSql** and **SqlFileComment** look only at the file the installer runs: a module's `sql/mysql.sql`, or the - file an active `sqlfile` entry names, resolved from the module root (so `sql/english/install.sql` counts, in both - the `$modversion['sqlfile']['mysql']` and the array-manifest form, with every database entry of an array read). - The manifest is read through the PHP parser, not as text: a commented-out declaration, a string or nowdoc that - looks like one, a comparison against `sqlfile`, and a `sqlfile` key on any variable other than `$modversion` do - not count, while a declaration behind an inline comment, after `?>`, with a multi-line `[XOOPS_DB_TYPE]` index, - or joined from literals (`'sql/' . 'install.sql'`) still does. Adminer / phpMyAdmin dumps and old upgrade +- **InstallSql** and **SqlFileComment** look only at the file the installer runs: the file an active `sqlfile` + entry names, resolved from the module root (so `sql/english/install.sql` counts, in both the + `$modversion['sqlfile']['mysql']` and the array-manifest form, with every database entry of an array read), or + `sql/mysql.sql` when the manifest names no file. A leftover `sql/mysql.sql` beside a selected + `sql/english/install.sql` is not run and not checked. The manifest is read through the PHP parser, not as text: + a commented-out declaration, a string or nowdoc that looks like one, a comparison against `sqlfile`, a `sqlfile` + key on any variable other than `$modversion`, and a nested key such as `$modversion['config']['sqlfile']` do not + count, while a declaration behind an inline comment, after `?>`, with a multi-line `[XOOPS_DB_TYPE]` index, + joined from literals (`'sql/' . 'install.sql'`), or inside `$modversion = array_merge($defaults, [...])` still + does. Adminer / phpMyAdmin dumps and old upgrade scripts beside it (`2510debug1.sql`, `xoops_mysql.sql`, `mysql.400.sql`) are never split by SqlUtility, so their MyISAM engine, `xoops_` prefixes and bare `--` lines were 237 + 111 findings about files nothing executes. A file a comment merely mentions is not an installer file. The core's own `install/sql/` schema has no module manifest and diff --git a/INSPECTIONS.md b/INSPECTIONS.md index dd3c9fb..d36b5f1 100644 --- a/INSPECTIONS.md +++ b/INSPECTIONS.md @@ -208,9 +208,9 @@ statement, the CREATE TABLE prefixer fails, and that table is silently never cre **Fix:** inserts the space in the SQL file. ### XoopsSqlFileComment — Bare '--' comment line in installer SQL -The same defect reported on the SQL file itself, one finding per line, for the file the installer runs: a module's -`sql/mysql.sql`, the file an active `sqlfile` entry names (resolved from the module root, so -`sql/english/install.sql` counts), or the core's own `install/sql/` schema. Dumps and old upgrade scripts in a +The same defect reported on the SQL file itself, one finding per line, for the file the installer runs: the file an +active `sqlfile` entry names (resolved from the module root, so `sql/english/install.sql` counts; `sql/mysql.sql` +when the manifest names none), or the core's own `install/sql/` schema. Dumps and old upgrade scripts in a module's `sql/` folder are skipped, as is a file only a comment mentions. **Fix:** inserts the space on that line only, re-checking the live line text, so it is safe in any batch order. A line that is exactly `--` is blanked rather than given a trailing space, which "strip trailing spaces on save" would remove diff --git a/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt b/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt index daa9c67..d37313c 100644 --- a/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt +++ b/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt @@ -15,6 +15,7 @@ import com.intellij.psi.PsiManager import com.intellij.psi.util.PsiTreeUtil import com.jetbrains.php.lang.psi.PhpFile import com.jetbrains.php.lang.psi.elements.ArrayAccessExpression +import com.jetbrains.php.lang.psi.elements.ArrayCreationExpression import com.jetbrains.php.lang.psi.elements.ArrayHashElement import com.jetbrains.php.lang.psi.elements.AssignmentExpression import com.jetbrains.php.lang.psi.elements.BinaryExpression @@ -58,8 +59,9 @@ object InstallSql { } /** - * The installer runs the file: a module's `sql/mysql.sql`, or the file an active `sqlfile` entry of its - * `xoops_version.php` names, resolved from the module root (`sql/english/install.sql` is two levels down). + * The installer runs the file an active `sqlfile` entry of the module's `xoops_version.php` names, resolved from + * the module root (`sql/english/install.sql` is two levels down). Only when the manifest names no file (none + * declared, or a path built at runtime) is the conventional `sql/mysql.sql` assumed. * A `sql/` folder also holds Adminer / phpMyAdmin dumps and old upgrade scripts (newbb: `2510debug1.sql`, * `xoops_mysql.sql`, `mysql.400.sql`) that SqlUtility never sees; their engine, prefix and `--` lines are not * findings. Outside any module only the core installer's `install/sql/` schema stays inspected; any other @@ -68,10 +70,11 @@ object InstallSql { fun installerFile(file: PsiFile): Boolean { val vf = file.virtualFile ?: return true val root = XoopsPaths.moduleRoot(vf) ?: return XoopsPaths.path(file).contains("/install/sql/") - if (file.name.equals(MODULE_SCHEMA, true)) return true - val manifestFile = root.findChild("xoops_version.php") ?: return false - val manifest = PsiManager.getInstance(file.project).findFile(manifestFile) as? PhpFile ?: return false - return sqlfilePaths(manifest).any { root.findFileByRelativePath(it.removePrefix("./")) == vf } + val manifest = root.findChild("xoops_version.php") + ?.let { PsiManager.getInstance(file.project).findFile(it) as? PhpFile } + val declared = manifest?.let(::sqlfilePaths).orEmpty() + if (declared.isNotEmpty()) return declared.any { root.findFileByRelativePath(it.removePrefix("./")) == vf } + return vf == root.findFileByRelativePath("sql/$MODULE_SCHEMA") } /** @@ -79,34 +82,34 @@ object InstallSql { * strings, heredocs, `?>` and comparisons are the parser's business. `$modversion['sqlfile']['mysql'] = 'x'`, * `$modversion['sqlfile'][XOOPS_DB_TYPE] = 'x'`, `$modversion['sqlfile'] = ['pgsql' => 'y', 'mysql' => 'x']` and * the array-manifest `$modversion = ['sqlfile' => ['mysql' => 'x']]` (also `array(…)`) all count; every database - * entry is read, and `'sql/' . 'install.sql'` is joined. Only `$modversion` declares: `$other['sqlfile']` does not. + * entry is read, and `'sql/' . 'install.sql'` is joined. Only a top-level `sqlfile` key of `$modversion` declares: + * `$other['sqlfile']` and `$modversion['config']['sqlfile']` do not. An outer array assigned to `$modversion` may + * sit inside a call (`array_merge($defaults, [...])`). */ fun sqlfilePaths(manifest: PhpFile): List { val values = ArrayList() for (a in PsiTreeUtil.findChildrenOfType(manifest, AssignmentExpression::class.java)) { // `.=` appends to a path set elsewhere; it is not the declaration. - if (a is SelfAssignmentExpression || !isModversion(a.variable)) continue - // Only the `[…]` chain being written counts: a `sqlfile` read inside an index, or a destructuring - // target, does not make the right-hand side an installer file. + if (a is SelfAssignmentExpression) continue + // The first `[…]` after `$modversion` in the chain being written must be `sqlfile`: a `sqlfile` read + // inside an index, a destructuring target or a nested key does not declare the installer file. var access = a.variable as? ArrayAccessExpression - while (access != null && !isSqlfileKey(access.index?.value)) access = access.value as? ArrayAccessExpression - if (access != null) a.value?.let(values::add) + while (access?.value is ArrayAccessExpression) access = access.value as ArrayAccessExpression + if (access != null && isModversion(access.value) && isSqlfileKey(access.index?.value)) a.value?.let(values::add) } for (h in PsiTreeUtil.findChildrenOfType(manifest, ArrayHashElement::class.java)) { - // A top-level entry of the array assigned to `$modversion` itself. - val owner = h.parent?.parent as? AssignmentExpression - if (isSqlfileKey(h.key) && owner?.value === h.parent && isModversion(owner.variable)) h.value?.let(values::add) + if (!isSqlfileKey(h.key)) continue + // An entry of the outermost array in `$modversion = …`, not of an array nested in another one. + val owner = PsiTreeUtil.getParentOfType(h, AssignmentExpression::class.java) ?: continue + val nested = PsiTreeUtil.getParentOfType(h.parent, ArrayCreationExpression::class.java, true, AssignmentExpression::class.java) + if (nested == null && isModversion(owner.variable)) h.value?.let(values::add) } return values.flatMap(::strings).filter { it.endsWith(".sql", ignoreCase = true) } } private fun isSqlfileKey(e: PsiElement?): Boolean = (e as? StringLiteralExpression)?.contents.equals("sqlfile", ignoreCase = true) - private fun isModversion(e: PsiElement?): Boolean { - var root = e - while (root is ArrayAccessExpression) root = root.value - return (root as? Variable)?.name == "modversion" - } + private fun isModversion(e: PsiElement?): Boolean = (e as? Variable)?.name == "modversion" /** The constant string of a literal or a literal-only concatenation, else null. */ private fun constant(e: PsiElement?): String? = when (e) { diff --git a/src/main/resources/inspectionDescriptions/XoopsInstallSql.html b/src/main/resources/inspectionDescriptions/XoopsInstallSql.html index c6624ed..054881f 100644 --- a/src/main/resources/inspectionDescriptions/XoopsInstallSql.html +++ b/src/main/resources/inspectionDescriptions/XoopsInstallSql.html @@ -2,6 +2,6 @@ XOOPS: Install Sql -

Rules on the SQL file the installer runs: a module's sql/mysql.sql, the file an active $modversion['sqlfile'] entry names (resolved from the module root, so sql/english/install.sql counts), or the core's install/sql/ schema. Adminer / phpMyAdmin dumps and old upgrade scripts in a module's sql/ folder are never run and are not checked, nor is a file that only a comment mentions.

  • FOREIGN KEY … REFERENCES: SqlUtility::prefixQuery() prefixes only the table being created, never the REFERENCES target. The key dangles to a nonexistent table; a plain INSERT fails with 1452, an INSERT IGNORE silently writes nothing. Enforce the relation in the handler, or add the key in oninstall.php with $db->prefix().
  • Hard-coded prefix: xoops_, {prefix} and %s are not installer tokens; the installer adds the site prefix itself. Use the bare module table name.
  • ENGINE=MyISAM (weak): no transactions, no row locks; create module tables with InnoDB, which supports FULLTEXT since MySQL 5.6. Not reported on the core installer schema (install/sql/), which keeps its legacy tables MyISAM on purpose.

Report only: each change alters the schema and belongs to the author.

+

Rules on the SQL file the installer runs: the file an active $modversion['sqlfile'] entry names (sql/mysql.sql when the manifest names none) (resolved from the module root, so sql/english/install.sql counts), or the core's install/sql/ schema. Adminer / phpMyAdmin dumps and old upgrade scripts in a module's sql/ folder are never run and are not checked, nor is a file that only a comment mentions.

  • FOREIGN KEY … REFERENCES: SqlUtility::prefixQuery() prefixes only the table being created, never the REFERENCES target. The key dangles to a nonexistent table; a plain INSERT fails with 1452, an INSERT IGNORE silently writes nothing. Enforce the relation in the handler, or add the key in oninstall.php with $db->prefix().
  • Hard-coded prefix: xoops_, {prefix} and %s are not installer tokens; the installer adds the site prefix itself. Use the bare module table name.
  • ENGINE=MyISAM (weak): no transactions, no row locks; create module tables with InnoDB, which supports FULLTEXT since MySQL 5.6. Not reported on the core installer schema (install/sql/), which keeps its legacy tables MyISAM on purpose.

Report only: each change alters the schema and belongs to the author.

diff --git a/src/main/resources/inspectionDescriptions/XoopsSqlFileComment.html b/src/main/resources/inspectionDescriptions/XoopsSqlFileComment.html index 5a4d898..3a9ec3d 100644 --- a/src/main/resources/inspectionDescriptions/XoopsSqlFileComment.html +++ b/src/main/resources/inspectionDescriptions/XoopsSqlFileComment.html @@ -2,6 +2,6 @@ XOOPS: Sql File Comment -

A line that is exactly -- is not a comment for SqlUtility::splitMySqlFile(); only -- (dash dash space) and # are. The bare line stays glued to the next statement, prefixQuery() fails its ^CREATE TABLE match and the installer skips that table with no error.

Checked on the file the installer runs: a module's sql/mysql.sql, the file an active $modversion['sqlfile'] entry names, or the core's install/sql/ schema. Dumps and old upgrade scripts beside it are not checked.

One finding per line. The fix inserts the space on that line only, re-checking the live line text, so it is safe in any batch order. The same defect is also reported on xoops_version.php by "Bare -- comment in the SQL file".

+

A line that is exactly -- is not a comment for SqlUtility::splitMySqlFile(); only -- (dash dash space) and # are. The bare line stays glued to the next statement, prefixQuery() fails its ^CREATE TABLE match and the installer skips that table with no error.

Checked on the file the installer runs: the file an active $modversion['sqlfile'] entry names (sql/mysql.sql when the manifest names none), or the core's install/sql/ schema. Dumps and old upgrade scripts beside it are not checked.

One finding per line. The fix inserts the space on that line only, re-checking the live line text, so it is safe in any batch order. The same defect is also reported on xoops_version.php by "Bare -- comment in the SQL file".

diff --git a/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt b/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt index 801f1f2..63ef711 100644 --- a/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt +++ b/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt @@ -369,6 +369,10 @@ class XoopsQuickFixTest : BasePlatformTestCase() { fun testInstallSqlIgnoresAFileOnlyACommentNames() = checkSql(XoopsInstallSqlInspection(), "InstallSql/nested", "modules/demo/sql/upgrade.sql", 0) + /** `sql/mysql.sql` is only the default: when `sqlfile` selects another file, the installer never runs it. */ + fun testInstallSqlIgnoresMysqlSqlWhenTheManifestSelectsAnotherFile() = + checkSql(XoopsInstallSqlInspection(), "InstallSql/nested", "modules/demo/sql/mysql.sql", 0) + /** The array manifest form: `'sqlfile' => ['mysql' => 'sql/english/install.sql']`. */ fun testInstallSqlChecksTheArrayManifestSqlfile() = checkSql(XoopsInstallSqlInspection(), "InstallSql/arraymanifest", "modules/demo/sql/english/install.sql", 1) @@ -403,6 +407,8 @@ class XoopsQuickFixTest : BasePlatformTestCase() { assertEquals(live, paths("(), paths("(), paths("(), paths(" ['mysql' => 'sql/live.sql']]);")) assertEquals(emptyList(), paths(" ['mysql' => 'sql/backup.sql']];")) assertEquals( emptyList(), diff --git a/src/test/resources/fixtures/InstallSql/nested/modules/demo/sql/mysql.sql b/src/test/resources/fixtures/InstallSql/nested/modules/demo/sql/mysql.sql new file mode 100644 index 0000000..c0e6c76 --- /dev/null +++ b/src/test/resources/fixtures/InstallSql/nested/modules/demo/sql/mysql.sql @@ -0,0 +1,5 @@ +-- Left over from 1.x; the manifest selects sql/english/install.sql. +CREATE TABLE demo_item ( + item_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + PRIMARY KEY (item_id) +) ENGINE=MyISAM; diff --git a/tools/gen_descriptions.py b/tools/gen_descriptions.py index 8dddc80..0b60d67 100644 --- a/tools/gen_descriptions.py +++ b/tools/gen_descriptions.py @@ -139,7 +139,7 @@

Carried over from XOOPS Support 1.0.0 Alpha 3.

""", "XoopsInstallSql": r""" -

Rules on the SQL file the installer runs: a module's sql/mysql.sql, the file an active $modversion['sqlfile'] entry names (resolved from the module root, so sql/english/install.sql counts), or the core's install/sql/ schema. Adminer / phpMyAdmin dumps and old upgrade scripts in a module's sql/ folder are never run and are not checked, nor is a file that only a comment mentions.

  • FOREIGN KEY … REFERENCES: SqlUtility::prefixQuery() prefixes only the table being created, never the REFERENCES target. The key dangles to a nonexistent table; a plain INSERT fails with 1452, an INSERT IGNORE silently writes nothing. Enforce the relation in the handler, or add the key in oninstall.php with $db->prefix().
  • Hard-coded prefix: xoops_, {prefix} and %s are not installer tokens; the installer adds the site prefix itself. Use the bare module table name.
  • ENGINE=MyISAM (weak): no transactions, no row locks; create module tables with InnoDB, which supports FULLTEXT since MySQL 5.6. Not reported on the core installer schema (install/sql/), which keeps its legacy tables MyISAM on purpose.

Report only: each change alters the schema and belongs to the author.

+

Rules on the SQL file the installer runs: the file an active $modversion['sqlfile'] entry names (sql/mysql.sql when the manifest names none) (resolved from the module root, so sql/english/install.sql counts), or the core's install/sql/ schema. Adminer / phpMyAdmin dumps and old upgrade scripts in a module's sql/ folder are never run and are not checked, nor is a file that only a comment mentions.

  • FOREIGN KEY … REFERENCES: SqlUtility::prefixQuery() prefixes only the table being created, never the REFERENCES target. The key dangles to a nonexistent table; a plain INSERT fails with 1452, an INSERT IGNORE silently writes nothing. Enforce the relation in the handler, or add the key in oninstall.php with $db->prefix().
  • Hard-coded prefix: xoops_, {prefix} and %s are not installer tokens; the installer adds the site prefix itself. Use the bare module table name.
  • ENGINE=MyISAM (weak): no transactions, no row locks; create module tables with InnoDB, which supports FULLTEXT since MySQL 5.6. Not reported on the core installer schema (install/sql/), which keeps its legacy tables MyISAM on purpose.

Report only: each change alters the schema and belongs to the author.

""", "XoopsIsFileAsset": r"""

file_exists($x) and is_readable($x) are true for a directory. When the condition's body then includes $x or reads it with readfile(), file_get_contents(), fopen() or similar, a directory name reaches the reader.

The fix appends && is_file($x): it only narrows the condition. A negated check (!file_exists($x)) is reported without a fix, because !a && b would invert it.

@@ -316,7 +316,7 @@ depends on the column.

""", "XoopsSqlFileComment": r""" -

A line that is exactly -- is not a comment for SqlUtility::splitMySqlFile(); only -- (dash dash space) and # are. The bare line stays glued to the next statement, prefixQuery() fails its ^CREATE TABLE match and the installer skips that table with no error.

Checked on the file the installer runs: a module's sql/mysql.sql, the file an active $modversion['sqlfile'] entry names, or the core's install/sql/ schema. Dumps and old upgrade scripts beside it are not checked.

One finding per line. The fix inserts the space on that line only, re-checking the live line text, so it is safe in any batch order. The same defect is also reported on xoops_version.php by "Bare -- comment in the SQL file".

+

A line that is exactly -- is not a comment for SqlUtility::splitMySqlFile(); only -- (dash dash space) and # are. The bare line stays glued to the next statement, prefixQuery() fails its ^CREATE TABLE match and the installer skips that table with no error.

Checked on the file the installer runs: the file an active $modversion['sqlfile'] entry names (sql/mysql.sql when the manifest names none), or the core's install/sql/ schema. Dumps and old upgrade scripts beside it are not checked.

One finding per line. The fix inserts the space on that line only, re-checking the live line text, so it is safe in any batch order. The same defect is also reported on xoops_version.php by "Bare -- comment in the SQL file".

""", "XoopsStrictCalendarCast": r""" Under declare(strict_types=1), mktime(0, 0, 0, date('n'), 1, date('Y')) throws a From 38ab8beaebfa0d72956abae15f7ef45cc21e3b1d Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 26 Oct 2026 16:48:50 -0400 Subject: [PATCH 7/9] docs(inspections): state UpdateCreatesTable's update sources exactly Only the YAML files directly in sql/ are read, not nested ones, and include/install.php counts as an update source, so the exclusion is a module with none of the listed files rather than one without an update callback. --- INSPECTIONS.md | 6 +++--- .../xoops/support/inspections/XoopsSqlFileInspections.kt | 2 +- .../inspectionDescriptions/XoopsUpdateCreatesTable.html | 2 +- tools/gen_descriptions.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/INSPECTIONS.md b/INSPECTIONS.md index d36b5f1..67b0aea 100644 --- a/INSPECTIONS.md +++ b/INSPECTIONS.md @@ -237,9 +237,9 @@ If it has existed since the module's first release, every site already has it an added later, add a create-if-missing step to the update code. That is why it is only a weak note. Update paths searched: -`onupdate.php`, `include/onupdate.php`, `include/update.php`, `include/install.php` and every YAML under `sql/`. An -update that re-runs the SQL file or calls `createMissingTables()` / `synchronizeSchema()` covers every table. Modules -without any update callback are not reported, nor is an SQL file the installer never runs (`sqlfile` commented out or +`onupdate.php`, `include/onupdate.php`, `include/update.php`, `include/install.php` and the YAML files directly in `sql/`. An +update that re-runs the SQL file or calls `createMissingTables()` / `synchronizeSchema()` covers every table. A module +with none of these files is not reported, nor is an SQL file the installer never runs (`sqlfile` commented out or `false`). A weak note, report only. ## Module files and bootstrap diff --git a/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt b/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt index d37313c..e6804e0 100644 --- a/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt +++ b/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt @@ -205,7 +205,7 @@ class XoopsInstallSqlInspection : LocalInspectionTool() { /** * A `CREATE TABLE` in `sql/mysql.sql` that no update path mentions is never created for a module * that is already installed: the next upgrade fails with 1146. Update paths: `onupdate.php`, - * `include/onupdate.php`, `include/update.php`, every YAML under `sql`, or an update that re-runs the + * `include/onupdate.php`, `include/update.php`, `include/install.php`, the YAML files directly in `sql`, or an update that re-runs the * SQL file / `createMissingTables()`. Report only — the idempotent create belongs to the author. */ class XoopsUpdateCreatesTableInspection : LocalInspectionTool() { diff --git a/src/main/resources/inspectionDescriptions/XoopsUpdateCreatesTable.html b/src/main/resources/inspectionDescriptions/XoopsUpdateCreatesTable.html index 0bb1b72..4298ca4 100644 --- a/src/main/resources/inspectionDescriptions/XoopsUpdateCreatesTable.html +++ b/src/main/resources/inspectionDescriptions/XoopsUpdateCreatesTable.html @@ -2,6 +2,6 @@ XOOPS: Update Creates Table -

XOOPS runs sql/mysql.sql only on a fresh install. When a site updates a module it already has, that file is not run again, so a table added in a later version must also be created by the module's update code.

Example: version 1.0 ships tables a and b, and version 1.1 adds table c to mysql.sql. A new install of 1.1 gets all three. A site updating from 1.0 still has only a and b, and the first page that uses c fails with MySQL error 1146 (table doesn't exist). xnewsletter's include/onupdate.php shows the fix: one update step per version that added a table, each creating it if it is missing.

This note marks every CREATE TABLE that no update code mentions. The plugin cannot tell whether the table is new. If it has existed since the module's first release, every site already has it and the note can be ignored. If it was added later, add a create-if-missing step to the update code. That is why it is only a weak note.

Update paths searched: onupdate.php, include/onupdate.php, include/update.php, include/install.php and every YAML under sql/. An update that re-runs the SQL file or calls createMissingTables() / synchronizeSchema() covers every table. Modules without any update callback are not reported, nor is an SQL file the installer never runs: no active $modversion['sqlfile'] (commented out or false) and no install callback that reads mysql.sql itself.

Report only: the idempotent create (table-exists check, migrate YAML) belongs to the author.

+

XOOPS runs sql/mysql.sql only on a fresh install. When a site updates a module it already has, that file is not run again, so a table added in a later version must also be created by the module's update code.

Example: version 1.0 ships tables a and b, and version 1.1 adds table c to mysql.sql. A new install of 1.1 gets all three. A site updating from 1.0 still has only a and b, and the first page that uses c fails with MySQL error 1146 (table doesn't exist). xnewsletter's include/onupdate.php shows the fix: one update step per version that added a table, each creating it if it is missing.

This note marks every CREATE TABLE that no update code mentions. The plugin cannot tell whether the table is new. If it has existed since the module's first release, every site already has it and the note can be ignored. If it was added later, add a create-if-missing step to the update code. That is why it is only a weak note.

Update paths searched: onupdate.php, include/onupdate.php, include/update.php, include/install.php and the YAML files directly in sql/. An update that re-runs the SQL file or calls createMissingTables() / synchronizeSchema() covers every table. A module with none of these files is not reported, nor is an SQL file the installer never runs: no active $modversion['sqlfile'] (commented out or false) and no install callback that reads mysql.sql itself.

Report only: the idempotent create (table-exists check, migrate YAML) belongs to the author.

diff --git a/tools/gen_descriptions.py b/tools/gen_descriptions.py index 0b60d67..8b8a9c7 100644 --- a/tools/gen_descriptions.py +++ b/tools/gen_descriptions.py @@ -365,7 +365,7 @@

The fix adds the option; the argument count comes from the PSI, so an inner call with commas is handled.

""", "XoopsUpdateCreatesTable": r""" -

XOOPS runs sql/mysql.sql only on a fresh install. When a site updates a module it already has, that file is not run again, so a table added in a later version must also be created by the module's update code.

Example: version 1.0 ships tables a and b, and version 1.1 adds table c to mysql.sql. A new install of 1.1 gets all three. A site updating from 1.0 still has only a and b, and the first page that uses c fails with MySQL error 1146 (table doesn't exist). xnewsletter's include/onupdate.php shows the fix: one update step per version that added a table, each creating it if it is missing.

This note marks every CREATE TABLE that no update code mentions. The plugin cannot tell whether the table is new. If it has existed since the module's first release, every site already has it and the note can be ignored. If it was added later, add a create-if-missing step to the update code. That is why it is only a weak note.

Update paths searched: onupdate.php, include/onupdate.php, include/update.php, include/install.php and every YAML under sql/. An update that re-runs the SQL file or calls createMissingTables() / synchronizeSchema() covers every table. Modules without any update callback are not reported, nor is an SQL file the installer never runs: no active $modversion['sqlfile'] (commented out or false) and no install callback that reads mysql.sql itself.

Report only: the idempotent create (table-exists check, migrate YAML) belongs to the author.

+

XOOPS runs sql/mysql.sql only on a fresh install. When a site updates a module it already has, that file is not run again, so a table added in a later version must also be created by the module's update code.

Example: version 1.0 ships tables a and b, and version 1.1 adds table c to mysql.sql. A new install of 1.1 gets all three. A site updating from 1.0 still has only a and b, and the first page that uses c fails with MySQL error 1146 (table doesn't exist). xnewsletter's include/onupdate.php shows the fix: one update step per version that added a table, each creating it if it is missing.

This note marks every CREATE TABLE that no update code mentions. The plugin cannot tell whether the table is new. If it has existed since the module's first release, every site already has it and the note can be ignored. If it was added later, add a create-if-missing step to the update code. That is why it is only a weak note.

Update paths searched: onupdate.php, include/onupdate.php, include/update.php, include/install.php and the YAML files directly in sql/. An update that re-runs the SQL file or calls createMissingTables() / synchronizeSchema() covers every table. A module with none of these files is not reported, nor is an SQL file the installer never runs: no active $modversion['sqlfile'] (commented out or false) and no install callback that reads mysql.sql itself.

Report only: the idempotent create (table-exists check, migrate YAML) belongs to the author.

""", "XoopsWeakToken": r"""
  • A variable named *token*, *nonce* or *csrf* assigned from rand(), mt_rand() or uniqid(): predictable. Use bin2hex(random_bytes(n)) sized to the storage column, or the core token API.
  • md5() / sha1() of a *pass* / *secret* variable: not a password hash. Use password_hash() / password_verify(). Compared with a value or passed as a Criteria value, it looks up a hash an old XOOPS stored (the 2.2 login fallback): a weak note to keep it only as a migration path and rehash on success once the password column holds modern hashes (the upgrader widens it to 255 in 2.5.8; before that a bcrypt hash would not fit).
  • ==, !=, strcmp() on a token, secret or password: '0e123' == '0e456' is true, and the comparison is not constant-time. Use hash_equals() on two strings. A comparison with a literal that is not a numeric string ($pass == '', $addtoken != false) is an emptiness or flag test and is not reported.

Fix: when both sides are request text (Request::getString() / getText() / getPath() / getUrl(), or Request::getVar() with a literal default and type 'string'), assigned nowhere else in the scope, != becomes !== and == becomes ===. For such values the strict form only ever rejects more, and only where the loose one calls two different inputs equal — the password-confirmation case of edituser.php. Everything else is report only: the token's storage width and consumers are unknown, so no generator is swapped in.

From 27827c34a759d42081aac8ec8b30c9379b4617c9 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 26 Oct 2026 16:56:11 -0400 Subject: [PATCH 8/9] fix(sql): keep the mysql.sql default beside a runtime-built path A literal pgsql entry made the declared list non-empty, which turned off the sql/mysql.sql fallback even when the mysql entry was built at runtime ('sql/' . XOOPS_DB_TYPE . '.sql') and may resolve to it. Each sqlfile value that is not a constant string now counts as unresolved, and an unresolved entry keeps the fallback. --- CHANGELOG.md | 3 ++- .../inspections/XoopsSqlFileInspections.kt | 23 +++++++++++++------ .../org/xoops/support/XoopsQuickFixTest.kt | 4 ++++ .../partial/modules/demo/sql/mysql.sql | 4 ++++ .../partial/modules/demo/xoops_version.php | 5 ++++ 5 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 src/test/resources/fixtures/InstallSql/partial/modules/demo/sql/mysql.sql create mode 100644 src/test/resources/fixtures/InstallSql/partial/modules/demo/xoops_version.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 055e7c8..2cfb5f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ checked against the module source and pinned with a fixture. - **InstallSql** and **SqlFileComment** look only at the file the installer runs: the file an active `sqlfile` entry names, resolved from the module root (so `sql/english/install.sql` counts, in both the `$modversion['sqlfile']['mysql']` and the array-manifest form, with every database entry of an array read), or - `sql/mysql.sql` when the manifest names no file. A leftover `sql/mysql.sql` beside a selected + `sql/mysql.sql` when the manifest names no file or builds one at runtime + (`'sql/' . XOOPS_DB_TYPE . '.sql'`). A leftover `sql/mysql.sql` beside a selected `sql/english/install.sql` is not run and not checked. The manifest is read through the PHP parser, not as text: a commented-out declaration, a string or nowdoc that looks like one, a comparison against `sqlfile`, a `sqlfile` key on any variable other than `$modversion`, and a nested key such as `$modversion['config']['sqlfile']` do not diff --git a/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt b/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt index e6804e0..b299931 100644 --- a/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt +++ b/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt @@ -72,8 +72,11 @@ object InstallSql { val root = XoopsPaths.moduleRoot(vf) ?: return XoopsPaths.path(file).contains("/install/sql/") val manifest = root.findChild("xoops_version.php") ?.let { PsiManager.getInstance(file.project).findFile(it) as? PhpFile } - val declared = manifest?.let(::sqlfilePaths).orEmpty() - if (declared.isNotEmpty()) return declared.any { root.findFileByRelativePath(it.removePrefix("./")) == vf } + val entries = manifest?.let(::sqlfileEntries).orEmpty() + val declared = entries.filterNotNull() + if (declared.any { root.findFileByRelativePath(it.removePrefix("./")) == vf }) return true + // A path built at runtime may be the default one, so a partly resolved list keeps the fallback. + if (declared.isNotEmpty() && null !in entries) return false return vf == root.findFileByRelativePath("sql/$MODULE_SCHEMA") } @@ -86,7 +89,10 @@ object InstallSql { * `$other['sqlfile']` and `$modversion['config']['sqlfile']` do not. An outer array assigned to `$modversion` may * sit inside a call (`array_merge($defaults, [...])`). */ - fun sqlfilePaths(manifest: PhpFile): List { + fun sqlfilePaths(manifest: PhpFile): List = sqlfileEntries(manifest).filterNotNull() + + /** The declared paths, with null for each value built at runtime (`'sql/' . XOOPS_DB_TYPE . '.sql'`). */ + private fun sqlfileEntries(manifest: PhpFile): List { val values = ArrayList() for (a in PsiTreeUtil.findChildrenOfType(manifest, AssignmentExpression::class.java)) { // `.=` appends to a path set elsewhere; it is not the declaration. @@ -104,7 +110,7 @@ object InstallSql { val nested = PsiTreeUtil.getParentOfType(h.parent, ArrayCreationExpression::class.java, true, AssignmentExpression::class.java) if (nested == null && isModversion(owner.variable)) h.value?.let(values::add) } - return values.flatMap(::strings).filter { it.endsWith(".sql", ignoreCase = true) } + return values.flatMap(::strings).filter { it == null || it.endsWith(".sql", ignoreCase = true) } } private fun isSqlfileKey(e: PsiElement?): Boolean = (e as? StringLiteralExpression)?.contents.equals("sqlfile", ignoreCase = true) @@ -118,9 +124,12 @@ object InstallSql { else -> null } - /** Every constant string in an `sqlfile` value; a concatenation with a variable part names no file. */ - private fun strings(e: PsiElement): List = - constant(e)?.let(::listOf) ?: if (e is BinaryExpression) emptyList() else e.children.flatMap(::strings) + /** Every constant string in an `sqlfile` value, and null for a part built at runtime (variable, constant, call). */ + private fun strings(e: PsiElement): List = constant(e)?.let(::listOf) ?: when { + e is ArrayHashElement -> e.value?.let(::strings).orEmpty() + e is BinaryExpression || e.children.isEmpty() -> listOf(null) + else -> e.children.flatMap(::strings) + } /** `-- ` and `#` line comments and block comments blanked, offsets kept. */ fun stripComments(text: String): String { diff --git a/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt b/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt index 63ef711..383a035 100644 --- a/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt +++ b/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt @@ -373,6 +373,10 @@ class XoopsQuickFixTest : BasePlatformTestCase() { fun testInstallSqlIgnoresMysqlSqlWhenTheManifestSelectsAnotherFile() = checkSql(XoopsInstallSqlInspection(), "InstallSql/nested", "modules/demo/sql/mysql.sql", 0) + /** A MySQL path built at runtime may be `sql/mysql.sql`: a literal pgsql entry must not hide it. */ + fun testInstallSqlKeepsTheDefaultBesideARuntimePath() = + checkSql(XoopsInstallSqlInspection(), "InstallSql/partial", "modules/demo/sql/mysql.sql", 1) + /** The array manifest form: `'sqlfile' => ['mysql' => 'sql/english/install.sql']`. */ fun testInstallSqlChecksTheArrayManifestSqlfile() = checkSql(XoopsInstallSqlInspection(), "InstallSql/arraymanifest", "modules/demo/sql/english/install.sql", 1) diff --git a/src/test/resources/fixtures/InstallSql/partial/modules/demo/sql/mysql.sql b/src/test/resources/fixtures/InstallSql/partial/modules/demo/sql/mysql.sql new file mode 100644 index 0000000..5ceea25 --- /dev/null +++ b/src/test/resources/fixtures/InstallSql/partial/modules/demo/sql/mysql.sql @@ -0,0 +1,4 @@ +CREATE TABLE demo_item ( + id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + PRIMARY KEY (id) +) ENGINE=MyISAM; diff --git a/src/test/resources/fixtures/InstallSql/partial/modules/demo/xoops_version.php b/src/test/resources/fixtures/InstallSql/partial/modules/demo/xoops_version.php new file mode 100644 index 0000000..44c152e --- /dev/null +++ b/src/test/resources/fixtures/InstallSql/partial/modules/demo/xoops_version.php @@ -0,0 +1,5 @@ + Date: Mon, 26 Oct 2026 17:10:46 -0400 Subject: [PATCH 9/9] fix(sql): treat a false sqlfile entry as disabled, not runtime-built 'mysql' => false (or null, or '') was read as a path built at runtime, which kept the sql/mysql.sql fallback and inspected a file the installer never runs. Such an entry is now declared-but-disabled: it turns the fallback off. $modversion['sqlfile'] = false, which used to fall back to mysql.sql, is covered too. --- CHANGELOG.md | 2 +- .../support/inspections/XoopsSqlFileInspections.kt | 14 +++++++++++--- .../kotlin/org/xoops/support/XoopsQuickFixTest.kt | 8 ++++++++ .../InstallSql/disabled/modules/demo/sql/mysql.sql | 4 ++++ .../disabled/modules/demo/xoops_version.php | 4 ++++ .../disabledall/modules/demo/sql/mysql.sql | 4 ++++ .../disabledall/modules/demo/xoops_version.php | 4 ++++ 7 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 src/test/resources/fixtures/InstallSql/disabled/modules/demo/sql/mysql.sql create mode 100644 src/test/resources/fixtures/InstallSql/disabled/modules/demo/xoops_version.php create mode 100644 src/test/resources/fixtures/InstallSql/disabledall/modules/demo/sql/mysql.sql create mode 100644 src/test/resources/fixtures/InstallSql/disabledall/modules/demo/xoops_version.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cfb5f8..58f9ebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ checked against the module source and pinned with a fixture. entry names, resolved from the module root (so `sql/english/install.sql` counts, in both the `$modversion['sqlfile']['mysql']` and the array-manifest form, with every database entry of an array read), or `sql/mysql.sql` when the manifest names no file or builds one at runtime - (`'sql/' . XOOPS_DB_TYPE . '.sql'`). A leftover `sql/mysql.sql` beside a selected + (`'sql/' . XOOPS_DB_TYPE . '.sql'`); an entry set to `false` switches it off. A leftover `sql/mysql.sql` beside a selected `sql/english/install.sql` is not run and not checked. The manifest is read through the PHP parser, not as text: a commented-out declaration, a string or nowdoc that looks like one, a comparison against `sqlfile`, a `sqlfile` key on any variable other than `$modversion`, and a nested key such as `$modversion['config']['sqlfile']` do not diff --git a/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt b/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt index b299931..8349bb5 100644 --- a/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt +++ b/src/main/kotlin/org/xoops/support/inspections/XoopsSqlFileInspections.kt @@ -20,6 +20,7 @@ import com.jetbrains.php.lang.psi.elements.ArrayHashElement import com.jetbrains.php.lang.psi.elements.AssignmentExpression import com.jetbrains.php.lang.psi.elements.BinaryExpression import com.jetbrains.php.lang.psi.elements.ConcatenationExpression +import com.jetbrains.php.lang.psi.elements.ConstantReference import com.jetbrains.php.lang.psi.elements.SelfAssignmentExpression import com.jetbrains.php.lang.psi.elements.StringLiteralExpression import com.jetbrains.php.lang.psi.elements.Variable @@ -89,9 +90,15 @@ object InstallSql { * `$other['sqlfile']` and `$modversion['config']['sqlfile']` do not. An outer array assigned to `$modversion` may * sit inside a call (`array_merge($defaults, [...])`). */ - fun sqlfilePaths(manifest: PhpFile): List = sqlfileEntries(manifest).filterNotNull() + fun sqlfilePaths(manifest: PhpFile): List = sqlfileEntries(manifest).filterNotNull().filter { it != DISABLED } - /** The declared paths, with null for each value built at runtime (`'sql/' . XOOPS_DB_TYPE . '.sql'`). */ + /** A `false`, `null` or `''` entry: declared, and names no file. */ + private const val DISABLED = "" + + /** + * The declared paths, with [DISABLED] for a switched-off entry and null for each value built at runtime + * (`'sql/' . XOOPS_DB_TYPE . '.sql'`). + */ private fun sqlfileEntries(manifest: PhpFile): List { val values = ArrayList() for (a in PsiTreeUtil.findChildrenOfType(manifest, AssignmentExpression::class.java)) { @@ -110,7 +117,7 @@ object InstallSql { val nested = PsiTreeUtil.getParentOfType(h.parent, ArrayCreationExpression::class.java, true, AssignmentExpression::class.java) if (nested == null && isModversion(owner.variable)) h.value?.let(values::add) } - return values.flatMap(::strings).filter { it == null || it.endsWith(".sql", ignoreCase = true) } + return values.flatMap(::strings).filter { it == null || it == DISABLED || it.endsWith(".sql", ignoreCase = true) } } private fun isSqlfileKey(e: PsiElement?): Boolean = (e as? StringLiteralExpression)?.contents.equals("sqlfile", ignoreCase = true) @@ -127,6 +134,7 @@ object InstallSql { /** Every constant string in an `sqlfile` value, and null for a part built at runtime (variable, constant, call). */ private fun strings(e: PsiElement): List = constant(e)?.let(::listOf) ?: when { e is ArrayHashElement -> e.value?.let(::strings).orEmpty() + e is ConstantReference && e.name?.lowercase() in setOf("false", "null") -> listOf(DISABLED) e is BinaryExpression || e.children.isEmpty() -> listOf(null) else -> e.children.flatMap(::strings) } diff --git a/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt b/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt index 383a035..3625ef5 100644 --- a/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt +++ b/src/test/kotlin/org/xoops/support/XoopsQuickFixTest.kt @@ -377,6 +377,14 @@ class XoopsQuickFixTest : BasePlatformTestCase() { fun testInstallSqlKeepsTheDefaultBesideARuntimePath() = checkSql(XoopsInstallSqlInspection(), "InstallSql/partial", "modules/demo/sql/mysql.sql", 1) + /** `'mysql' => false` switches the MySQL install off: the leftover `sql/mysql.sql` is never run. */ + fun testInstallSqlIgnoresMysqlSqlWhenTheMysqlEntryIsDisabled() = + checkSql(XoopsInstallSqlInspection(), "InstallSql/disabled", "modules/demo/sql/mysql.sql", 0) + + /** `$modversion['sqlfile'] = false` declares no install SQL at all. */ + fun testInstallSqlIgnoresMysqlSqlWhenSqlfileIsFalse() = + checkSql(XoopsInstallSqlInspection(), "InstallSql/disabledall", "modules/demo/sql/mysql.sql", 0) + /** The array manifest form: `'sqlfile' => ['mysql' => 'sql/english/install.sql']`. */ fun testInstallSqlChecksTheArrayManifestSqlfile() = checkSql(XoopsInstallSqlInspection(), "InstallSql/arraymanifest", "modules/demo/sql/english/install.sql", 1) diff --git a/src/test/resources/fixtures/InstallSql/disabled/modules/demo/sql/mysql.sql b/src/test/resources/fixtures/InstallSql/disabled/modules/demo/sql/mysql.sql new file mode 100644 index 0000000..5ceea25 --- /dev/null +++ b/src/test/resources/fixtures/InstallSql/disabled/modules/demo/sql/mysql.sql @@ -0,0 +1,4 @@ +CREATE TABLE demo_item ( + id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + PRIMARY KEY (id) +) ENGINE=MyISAM; diff --git a/src/test/resources/fixtures/InstallSql/disabled/modules/demo/xoops_version.php b/src/test/resources/fixtures/InstallSql/disabled/modules/demo/xoops_version.php new file mode 100644 index 0000000..8e65947 --- /dev/null +++ b/src/test/resources/fixtures/InstallSql/disabled/modules/demo/xoops_version.php @@ -0,0 +1,4 @@ + false, 'pgsql' => 'sql/pgsql.sql']; diff --git a/src/test/resources/fixtures/InstallSql/disabledall/modules/demo/sql/mysql.sql b/src/test/resources/fixtures/InstallSql/disabledall/modules/demo/sql/mysql.sql new file mode 100644 index 0000000..5ceea25 --- /dev/null +++ b/src/test/resources/fixtures/InstallSql/disabledall/modules/demo/sql/mysql.sql @@ -0,0 +1,4 @@ +CREATE TABLE demo_item ( + id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + PRIMARY KEY (id) +) ENGINE=MyISAM; diff --git a/src/test/resources/fixtures/InstallSql/disabledall/modules/demo/xoops_version.php b/src/test/resources/fixtures/InstallSql/disabledall/modules/demo/xoops_version.php new file mode 100644 index 0000000..69d567b --- /dev/null +++ b/src/test/resources/fixtures/InstallSql/disabledall/modules/demo/xoops_version.php @@ -0,0 +1,4 @@ +