helper-based atomic-write cleanup; replace @unlink/@chmod#52
Conversation
Changes:
cp_functions.php:
+ xoops_remove_file_quietly($path, $context): new helper for
best-effort file removal. Skips non-existent paths, suppresses
the unlink warning via a SCOPED error_reporting() toggle (no @
operator) wrapped in try/finally, and re-checks file_exists()
after a failed unlink — only logging when the file is still
present so TOCTOU races resolve silently. Uses
xoops_file_label() for non-sensitive path labels in warnings.
- 9 @Unlink cleanup sites replaced with the helper.
- @chmod($tempFile, $perms) replaced with checked chmod() that
logs on failure but continues (content is already written).
modules/system/class/maintenance.php:
+ Explicit `require_once .../include/cp_functions.php` so
SystemMaintenance is self-sufficient regardless of which
caller (admin, install, modulesadmin, preferences) loads it.
- 6 atomic-write @Unlink cleanup sites replaced with the helper.
- @chmod replaced with checked + warn (mirrors cp_functions.php).
- cleanOrphanedAvatars(): @Unlink replaced with the helper plus
a path-traversal-safe resolution. Avatar rows store
'avatars/<filename>' (kernel/avatar.php and 14 admin/profile
writers), so basename() would have stripped the directory and
silently bypassed all orphan cleanup. The new form:
- normalises backslashes to '/' (Windows-historic data)
- strips leading slashes (defends against absolute paths)
- resolves via realpath() so '../' segments collapse
- confirms containment under realpath(XOOPS_UPLOAD_PATH)
with a trailing-separator boundary check
- confirms is_file() before removal.
The DB row cleanup runs unconditionally (file gone or not).
Intentionally retained:
- The 7 @rename(...) calls inside `if (!...)` checks. These are
the core atomic-move operations; the boolean return is already
detected and reported via trigger_error(). Removing the @ would
let PHP's native warning fire alongside our diagnostic, double-
reporting one event into display_errors output. Comment block
in each file documents the rationale.
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughAdds scoped quiet chmod/unlink helpers, applies them to atomic-write and guard-file flows, hardens avatar deletion with path normalization and containment checks, requires the helpers in SystemMaintenance, and adds unit tests covering CleanAvatar() variants. ChangesFile Handling Safety and Error Recovery
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #52 +/- ##
============================================
- Coverage 18.08% 18.07% -0.02%
- Complexity 7840 7854 +14
============================================
Files 665 666 +1
Lines 42982 43142 +160
============================================
+ Hits 7775 7798 +23
- Misses 35207 35344 +137 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR reduces error-suppression usage in file cleanup paths by introducing a centralized “best-effort” delete helper and adopting it in atomic-write cleanup code, while also hardening orphaned-avatar deletion against path traversal.
Changes:
- Added
xoops_remove_file_quietly()incp_functions.phpand replaced multiple@unlinkcleanup sites with the helper. - Replaced
@chmodcalls with checkedchmod()that logs warnings on failure but continues. - Hardened
SystemMaintenance::CleanAvatar()orphan cleanup by normalizing and resolving avatar paths and enforcing containment underXOOPS_UPLOAD_PATH.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| htdocs/modules/system/class/maintenance.php | Requires cp_functions.php, adopts the new cleanup helper in atomic-write cleanup, and hardens orphaned avatar file removal with realpath() + containment checks. |
| htdocs/include/cp_functions.php | Introduces xoops_remove_file_quietly() and replaces multiple @unlink/@chmod usages in xoops_write_file_atomically(). |
…out of loop Address PR XOOPS#52 follow-up review (Copilot non-blocking comments): modules/system/class/maintenance.php (CleanAvatar): realpath(XOOPS_UPLOAD_PATH) and the trailing-separator prefix are constant for the whole run, so computing them per row in the while-loop is wasted filesystem work on installations with large orphaned-avatar tables. Hoisted outside the loop into \$uploadRoot and \$uploadRootPrefix; the in-loop check now uses str_starts_with against the cached prefix. tests/unit/htdocs/modules/system/SystemMaintenanceTest.php: Added six #[Test] cases covering the path-traversal-safe orphan cleanup logic introduced when @Unlink() was replaced by xoops_remove_file_quietly(): - cleanAvatarRemovesValidAvatarFileUnderUploadRoot — happy path - cleanAvatarSkipsTraversalPathButStillDeletesDbRow — ../ blocked - cleanAvatarSkipsAbsolutePathButStillDeletesDbRow — /etc/hosts - cleanAvatarHandlesMissingFileAndStillDeletesDbRow - cleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow - cleanAvatarNormalisesBackslashesInAvatarFile Each test asserts exec() is called exactly twice (avatar DELETE + avatar_user_link cleanup) regardless of file removal outcome, so the 'DB rows deleted regardless of file outcome' invariant is codified rather than relying on inspection. Filesystem fixtures live in a unique scratch subdirectory under XOOPS_UPLOAD_PATH/avatars/_test_<pid>_<uniqid> with finally-block cleanup so the tests do not collide with each other or with anything else in the upload tree.
| /** | ||
| * Stub the database mock so $db->query() returns a sentinel result and | ||
| * $db->fetchArray() yields the supplied avatar rows once, then false. | ||
| * Returns the mock for further expectations. | ||
| */ | ||
| private function stubAvatarSweep($db, array $rows): void | ||
| { | ||
| $db->method('query')->willReturn('mock_result'); | ||
| $db->method('isResultSet')->willReturn(true); | ||
| $rows[] = false; // end-of-result sentinel | ||
| $db->method('fetchArray')->willReturnOnConsecutiveCalls(...$rows); | ||
| } |
CI run XOOPS#52 failed with 6 IncompatibleReturnValueException errors: Method exec may not return value of type int, its declared return type is \"bool\" XoopsMySQLDatabase::exec(string \$sql): bool returns true on success and false on failure (or false from PHP's mysqli_query for many DDL statements). The CleanAvatar() tests stubbed exec() with willReturn(1), which works under loose return-type checking but trips PHPUnit 11's strict return-type validation. Fix: all 6 exec() stubs use willReturn(true).
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@htdocs/modules/system/class/maintenance.php`:
- Line 200: The DELETE SQL concatenates $myrow['avatar_id'] directly into the
string; change the interpolation to cast the value to int before concatenation
to avoid injection/scan warnings—update the calls that use $this->db->exec(...)
with $this->db->prefix('avatar') and $myrow['avatar_id'] (the deletes at the
current line and the adjacent one around line 213) so the avatar_id is cast to
(int) (e.g., use (int)$myrow['avatar_id']) when building the WHERE clause.
In `@tests/unit/htdocs/modules/system/SystemMaintenanceTest.php`:
- Line 368: The mocks for XoopsDatabase::exec() use willReturn(1) which violates
exec()'s bool return type and causes PHPUnit IncompatibleReturnValueException;
locate every occurrence of $db->expects(...)->method('exec')->willReturn(1) in
the SystemMaintenanceTest cleanAvatar* tests and change willReturn(1) to
willReturn(true) (apply to all six instances where exec() is stubbed).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 3dc5b9f2-faa6-4f9e-b34c-1076d70a5758
📒 Files selected for processing (2)
htdocs/modules/system/class/maintenance.phptests/unit/htdocs/modules/system/SystemMaintenanceTest.php
| /** | ||
| * Stub the database mock so $db->query() returns a sentinel result and | ||
| * $db->fetchArray() yields the supplied avatar rows once, then false. | ||
| * Returns the mock for further expectations. | ||
| */ | ||
| private function stubAvatarSweep($db, array $rows): void | ||
| { | ||
| $db->method('query')->willReturn('mock_result'); | ||
| $db->method('isResultSet')->willReturn(true); | ||
| $rows[] = false; // end-of-result sentinel | ||
| $db->method('fetchArray')->willReturnOnConsecutiveCalls(...$rows); | ||
| } |
…st; (int) avatar_id Address PR XOOPS#52 follow-up review (Copilot, CodeRabbit Trivial): modules/system/class/maintenance.php (CleanAvatar): Cast $myrow['avatar_id'] to int once at the top of the loop body and use $avatarId in both DELETE statements (avatar row + the empty-avatar_file fast path). Defence-in-depth on the SQL concat even though the value is DB-origin; also silences SonarCloud's string-concatenation warning on these statements per project SQL hygiene. tests/unit/htdocs/modules/system/SystemMaintenanceTest.php: cleanAvatarSkipsTraversalPathButStillDeletesDbRow: The previous form created the fixture in sys_get_temp_dir() but used '../../<basename>' as the avatar_file. That string resolves from htdocs/uploads/ to a path under/near the project root — NOT the temp dir. realpath() returned false, the cleanup was skipped, and the test passed for the wrong reason: it never exercised the containment-prefix branch. New form: place the fixture at dirname(realpath(XOOPS_UPLOAD_PATH)) so '../<basename>' actually resolves to it. realpath() now succeeds, and the prefix check is the rejection mechanism. Added an explicit assertSame() sanity assertion comparing the resolved path to realpath($outside) so the test self-documents that it really exercises the containment branch. cleanAvatarSkipsAbsolutePathButStillDeletesDbRow: Replaced the hard-coded /etc/hosts assertion with a temp fixture via tempnam(). Windows CI runners don't have /etc/hosts at the same path, and even on POSIX systems the test relied on a pre-existing system file. tempnam() works on every supported OS. stubAvatarSweep(): Removed the misleading "Returns the mock for further expectations" docblock line. The method is `: void`; callers attach further expectations directly to the $db they passed in.
| $avatarId = (int) ($myrow['avatar_id'] ?? 0); | ||
| $avatarFile = ltrim(str_replace('\\', '/', (string) ($myrow['avatar_file'] ?? '')), '/'); | ||
| if ('' === $avatarFile) { | ||
| $result1 = $this->db->exec('DELETE FROM ' . $this->db->prefix('avatar') . ' WHERE avatar_id=' . $avatarId); | ||
| continue; | ||
| } | ||
| $avatarPath = realpath(XOOPS_UPLOAD_PATH . '/' . $avatarFile); | ||
| if ( | ||
| is_string($avatarPath) | ||
| && is_string($uploadRootPrefix) | ||
| && str_starts_with($avatarPath, $uploadRootPrefix) | ||
| && is_file($avatarPath) | ||
| ) { | ||
| xoops_remove_file_quietly($avatarPath, 'orphaned avatar'); | ||
| } |
Address PR XOOPS#52 follow-up review (Copilot, 3 actionable): cp_functions.php: + xoops_chmod_quietly($path, $perms, $context): new helper mirroring xoops_remove_file_quietly(). Suppresses chmod()'s native PHP warning via the same scoped error_reporting() toggle + try/finally and emits a single project-standard trigger_error on the boolean false return. Without this, a single chmod failure produced TWO log lines (PHP warning + trigger_error). - The chmod() + trigger_error block in xoops_write_file_atomically() replaced with the helper. modules/system/class/maintenance.php: - chmod() + trigger_error block in writeFileWithWarning() replaced with the same helper (already require_once'ing cp_functions.php). - CleanAvatar(): containment check narrowed from realpath(XOOPS_UPLOAD_PATH) to realpath(XOOPS_UPLOAD_PATH . '/avatars'). Custom avatars are stored as 'avatars/<filename>' (kernel/avatar.php and 14 admin/edituser writers), so the broad upload-root check would have allowed deletion of any file under uploads/ if avatar_file pointed elsewhere — legacy data, custom- module write, or accidental insertion. The narrow prefix is defence-in-depth: skip rather than silently delete unrelated uploads. Hoisted out of the loop so the realpath() resolution is computed once per sweep. tests/unit/htdocs/modules/system/SystemMaintenanceTest.php: + cleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot: places a fixture at uploads/files/<unique>.doc and sets avatar_file to 'files/<unique>.doc'. Asserts the resolved path is under uploads/ (proving the test exercises the narrow-prefix branch, not the realpath-fail branch), runs CleanAvatar(), and verifies the fixture survives — codifying the narrowing as a regression test. No @ operators remain in either file's unlink/chmod paths; the seven @rename(...) calls inside `if (!...)` checks are still retained with the documented rationale.
…atar Address PR XOOPS#52 follow-up review (Copilot, line 316): placeFixtureAvatar() and the three inline file_put_contents() sites in cleanAvatar* tests previously ignored the return value. If the write failed (FS permissions, full disk, etc.) the happy-path tests could pass for the wrong reason — CleanAvatar() would find no file to delete and assertFileDoesNotExist() would succeed even though the cleanup never ran on a real fixture. All four fixture-creation sites now: - capture file_put_contents() return into $bytesWritten - assertNotFalse with a path-suffixed message for triage - assertSame(strlen(content), $bytesWritten) so partial writes are caught too, not just false-return failures Sites covered: - placeFixtureAvatar() (the one Copilot flagged) - cleanAvatarSkipsTraversalPathButStillDeletesDbRow - cleanAvatarSkipsAbsolutePathButStillDeletesDbRow - cleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
htdocs/modules/system/class/maintenance.php (1)
156-171:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAdd
@throws \RuntimeExceptionto theCleanAvatar()docblock.The method throws a
\RuntimeException(line 169-173) but the PHPDoc is silent about it. As per coding guidelines, public methods must declare@throwswhen applicable.📝 Proposed docblock update
/** * CleanAvatar * * Clean up orphaned custom avatars left when a user is deleted. * * `@author` slider84 of Team FrXoops * + * `@throws` \RuntimeException If the avatar table query fails. * `@return` boolean */As per coding guidelines: "New public methods have PHPDoc with
@param,@return, and@throwstags".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@htdocs/modules/system/class/maintenance.php` around lines 156 - 171, The CleanAvatar() method can throw a \RuntimeException but its PHPDoc lacks a `@throws` tag; update the method's docblock for CleanAvatar to include "@throws \RuntimeException" (with a brief description) so the public method documents the possible exception thrown by the database error branch (the RuntimeException raised around the $this->db->error() call).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@htdocs/include/cp_functions.php`:
- Around line 138-187: Both xoops_chmod_quietly and xoops_remove_file_quietly
risk using an uninitialized $ok if a user error handler throws inside the try;
to fix, initialize $ok = false immediately before each try block (in
xoops_chmod_quietly and xoops_remove_file_quietly) so the subsequent if (!$ok)
checks are safe even when unlink()/chmod() never return due to an exception;
keep the scoped error_reporting() and existing trigger_error() behavior
unchanged.
In `@htdocs/modules/system/class/maintenance.php`:
- Around line 210-227: The two assignments to $result1 in the CleanAvatar method
are unused; remove the variable capture and call $this->db->exec(...) directly
(or alternatively check its boolean result and log/return false on failure).
Locate the two calls to $this->db->exec(...) that currently assign to $result1
in class Maintenance::CleanAvatar and either (A) replace " $result1 =
$this->db->exec(...);" with "$this->db->exec(...);" to make the intent explicit,
or (B) change to "if (!$this->db->exec(...)) { /* log via
processLogger/db->error and return false; */ }" so failures are propagated.
---
Outside diff comments:
In `@htdocs/modules/system/class/maintenance.php`:
- Around line 156-171: The CleanAvatar() method can throw a \RuntimeException
but its PHPDoc lacks a `@throws` tag; update the method's docblock for CleanAvatar
to include "@throws \RuntimeException" (with a brief description) so the public
method documents the possible exception thrown by the database error branch (the
RuntimeException raised around the $this->db->error() call).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 663a51de-03a6-4402-a4f1-f40999eb6701
📒 Files selected for processing (3)
htdocs/include/cp_functions.phphtdocs/modules/system/class/maintenance.phptests/unit/htdocs/modules/system/SystemMaintenanceTest.php
Address PR XOOPS#52 follow-up review (CodeRabbit, 3 findings): cp_functions.php (xoops_chmod_quietly, xoops_remove_file_quietly): Initialise $ok = false before each try block. error_reporting(0) does NOT disable user-defined error handlers; only the native warning. A handler that throws (e.g. a strict ErrorException conversion that doesn't check error_reporting() & $errno) would propagate out of the try block before chmod()/unlink() returns, leaving $ok unset and triggering an "Undefined variable" warning on the subsequent if (!$ok) check. The defensive default makes the helpers safe under any error-handler shape. modules/system/class/maintenance.php (CleanAvatar): Drop the unused `$result1 = ` capture on both $this->db->exec() calls. PHPMD flagged this; the value was never read so the assignment was misleading (no error propagation either way). Removing makes the discard explicit. Also added `@throws \RuntimeException` to the CleanAvatar() docblock per project convention — the method does throw RuntimeException when the avatar query fails, but the PHPDoc was silent about it.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
htdocs/modules/system/class/maintenance.php (1)
278-358: 🧹 Nitpick | 🔵 Trivial | 💤 Low value
writeFileWithWarning()atomic-write paths are consistent with the new helper conventions.Helper routing for temp-guard and backup-guard cleanup is uniform, the chmod demotion to non-fatal matches
xoops_write_file_atomically()semantics, and the$backupFile = nullinitialisation at Line 325 cleanly disambiguates the "no prior file" path from the "restoration needed" path at Lines 347–351.One minor observability nit (not blocking): when the initial replace fails but
@rename($backupFile, $filename)succeeds, the user sees onlyFailed to replace guard file, with no indication that the original was restored. Tracking it as something to consider if the message ever surfaces in support tickets.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@htdocs/modules/system/class/maintenance.php` around lines 278 - 358, The failure path in writeFileWithWarning() currently logs only "Failed to replace guard file" when rename(temp->target) fails even if the subsequent restore of the backup ($backupFile -> $filename) succeeds; update writeFileWithWarning() so that inside the if (!@rename($tempFile, $filename)) { ... if ($backupFile !== null) { if (!@rename($backupFile, $filename)) { ... } } } block you detect the return of `@rename`($backupFile, $filename) and emit an additional, clear log when the restore succeeds (e.g. a trigger_error/notice like "Restored original guard file: %s" using $label) so callers can observe that the original was restored; keep the existing error path when the restore fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@htdocs/modules/system/class/maintenance.php`:
- Around line 278-358: The failure path in writeFileWithWarning() currently logs
only "Failed to replace guard file" when rename(temp->target) fails even if the
subsequent restore of the backup ($backupFile -> $filename) succeeds; update
writeFileWithWarning() so that inside the if (!@rename($tempFile, $filename)) {
... if ($backupFile !== null) { if (!@rename($backupFile, $filename)) { ... } }
} block you detect the return of `@rename`($backupFile, $filename) and emit an
additional, clear log when the restore succeeds (e.g. a trigger_error/notice
like "Restored original guard file: %s" using $label) so callers can observe
that the original was restored; keep the existing error path when the restore
fails.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1d53766d-6c3d-48ac-8b53-1513a1b1f78a
📒 Files selected for processing (2)
htdocs/include/cp_functions.phphtdocs/modules/system/class/maintenance.php
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
htdocs/modules/system/class/maintenance.php:248
- CleanAvatar() always returns true and ignores the return values from the DELETE exec() calls (including the final avatar_user_link cleanup, currently assigned to $result2 but never checked). Since callers use the boolean return to decide whether the avatar purge succeeded (e.g. system/admin/maintenance/main.php), failures here will be reported as success. Consider tracking exec() results and returning false when a delete fails (or removing the boolean return entirely if you don't want callers to rely on it).
$this->db->exec('DELETE FROM ' . $this->db->prefix('avatar') . ' WHERE avatar_id=' . $avatarId);
}
//clean any deleted users from avatar_user_link table
$result2 = $this->db->exec('DELETE FROM ' . $this->db->prefix('avatar_user_link') . ' WHERE user_id NOT IN (SELECT uid FROM ' . $this->db->prefix('users') . ')');
return true;
… writeFileWithWarning
modules/system/class/maintenance.php:
CleanAvatar() — return contract:
The method's @return bool was always returning true regardless
of whether any DELETE actually succeeded. If a row delete or
the final avatar_user_link cleanup failed, the orphaned row
was left behind and the caller had no way to know.
Track a $deleteOk flag across all three exec() sites
(empty-avatar_file branch, regular avatar-row branch, final
avatar_user_link cleanup) and return it. Existing CleanAvatar
tests stub exec() with willReturn(true) so $deleteOk stays
true and the return value is unchanged in the happy path.
writeFileWithWarning() — restore observability:
When the rename($tempFile, $filename) replace step fails but
the rename($backupFile, $filename) restore step succeeds, the
log previously said only "Failed to replace guard file: <X>",
leaving operators unsure whether manual recovery was needed.
Track $restoredBackup and fold the status into the existing
composite failure warning:
"Failed to replace guard file: <X> (original restored)"
when the restore worked, or the unsuffixed form (plus the
pre-existing "Failed to restore original guard file" line)
when both steps failed and manual intervention is needed.
No additional success warnings introduced.
| function xoops_chmod_quietly($path, $perms, $context = 'temp') | ||
| { | ||
| // Initialise $ok before the try block: error_reporting(0) does NOT | ||
| // disable user-defined error handlers, only the native warning. A | ||
| // naively written handler that always throws (without checking | ||
| // error_reporting() & $errno) would propagate out of the try block | ||
| // before chmod() returns, leaving $ok unset. Defensive default. | ||
| $ok = false; | ||
| $previousLevel = error_reporting(0); | ||
| try { | ||
| $ok = chmod($path, $perms); | ||
| } finally { | ||
| error_reporting($previousLevel); | ||
| } | ||
| if (!$ok) { | ||
| trigger_error( | ||
| sprintf('Failed to set permissions on %s file: %s', $context, xoops_file_label($path)), | ||
| E_USER_WARNING | ||
| ); | ||
| } | ||
|
|
||
| return $ok; | ||
| } | ||
|
|
||
| /** | ||
| * Best-effort file removal used by atomic-write cleanup paths and similar | ||
| * fire-and-forget cleanup. Skips non-existent paths so already-deleted | ||
| * files don't trigger warnings, suppresses the unlink() warning via a | ||
| * scoped error_reporting() toggle (no `@` operator), and re-checks | ||
| * existence after a failed unlink — only logging when the file is still | ||
| * present, so TOCTOU races resolve silently. | ||
| * | ||
| * @param string $path Absolute path to the file to remove. | ||
| * @param string $context Short label used in the warning message | ||
| * (e.g. 'temporary', 'backup'). | ||
| * | ||
| * @return void | ||
| */ | ||
| function xoops_remove_file_quietly($path, $context = 'temporary') | ||
| { | ||
| // file_exists() returns false for broken symlinks, so a dangling | ||
| // symlink would be skipped here and also bypass the post-unlink | ||
| // existence check below — leaving the orphaned link in place. Treat | ||
| // links as existing too: unlink() can remove broken symlinks just | ||
| // fine, and the targets they point to are not what we care about. | ||
| if (!file_exists($path) && !is_link($path)) { | ||
| return; | ||
| } | ||
| // Initialise $ok defensively — see xoops_chmod_quietly() for the | ||
| // rationale (error_reporting(0) does not disable user-defined | ||
| // error handlers). | ||
| $ok = false; | ||
| $previousLevel = error_reporting(0); | ||
| try { | ||
| $ok = unlink($path); | ||
| } finally { | ||
| error_reporting($previousLevel); | ||
| } | ||
| if (!$ok && (file_exists($path) || is_link($path))) { | ||
| trigger_error( | ||
| sprintf('Failed to remove %s file: %s', $context, xoops_file_label($path)), | ||
| E_USER_WARNING | ||
| ); | ||
| } | ||
| } |
| $avatarId = (int) ($myrow['avatar_id'] ?? 0); | ||
| $avatarFile = ltrim(str_replace('\\', '/', (string) ($myrow['avatar_file'] ?? '')), '/'); | ||
| if ('' === $avatarFile) { | ||
| if (!$this->db->exec('DELETE FROM ' . $this->db->prefix('avatar') . ' WHERE avatar_id=' . $avatarId)) { | ||
| $deleteOk = false; | ||
| } | ||
| continue; | ||
| } | ||
| $avatarCandidate = XOOPS_UPLOAD_PATH . '/' . $avatarFile; | ||
| $avatarParent = realpath(dirname($avatarCandidate)); | ||
| if ( | ||
| is_string($avatarParent) | ||
| && is_string($avatarRootPrefix) | ||
| && str_starts_with(rtrim($avatarParent, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR, $avatarRootPrefix) | ||
| && (is_file($avatarCandidate) || is_link($avatarCandidate)) | ||
| ) { | ||
| xoops_remove_file_quietly($avatarCandidate, 'orphaned avatar'); | ||
| } |
…ll-byte paths xoops_chmod_quietly() / xoops_remove_file_quietly(): wrap the fs syscall (and, in the remove helper, the file_exists()/is_link() probes) in catch(\Throwable) so a ValueError from a "\0"-bearing path — or a throwing user error handler — cannot abort the caller's unrelated work. CleanAvatar(): gate dirname/realpath/is_file/is_link/remove on "\0" not in normalized avatar_file; collapse the empty-string special branch into the same condition. Per-row and avatar_user_link DELETEs still run unconditionally so a malformed path never blocks reclaiming the orphaned row. Tests: add cleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow, covering the ValueError-skipped path with both DELETEs asserted.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@htdocs/include/cp_functions.php`:
- Around line 159-163: The trigger_error calls currently expose root-relative
paths by using xoops_file_label($path); change those warning messages to include
only the basename of the path (use basename($path) or an equivalent safe helper)
instead of xoops_file_label($path) in the trigger_error calls (the ones invoking
trigger_error(sprintf('Failed to set permissions on %s file: %s', $context,
xoops_file_label($path)), E_USER_WARNING) and the similar block at the other
occurrence), so non-fatal filesystem warnings log only the filename and not the
full install layout.
In `@tests/unit/htdocs/modules/system/SystemMaintenanceTest.php`:
- Around line 366-592: The tests exercise filesystem branches but never assert
the new boolean return from CleanAvatar(); update each existing test that calls
$maintenance->CleanAvatar() to assert the returned bool (e.g.
$this->assertTrue($maintenance->CleanAvatar(), ...)) and add one new unit test
that uses createMockDatabase()/stubAvatarSweep() and sets
$db->expects(...)->method('exec')->willReturnOnConsecutiveCalls(true, false) (or
otherwise simulate a DELETE failure) then
assertFalse($maintenance->CleanAvatar()) to cover the failing-exec path;
reference the CleanAvatar() call, createMockDatabase(), stubAvatarSweep(), and
the mock exec() expectations to locate where to add these assertions.
- Around line 366-592: Rename each PHPUnit test method to the test... convention
so PHPUnit 9.6 discovers them: change
cleanAvatarRemovesValidAvatarFileUnderUploadRoot() →
testCleanAvatarRemovesValidAvatarFileUnderUploadRoot(),
cleanAvatarSkipsTraversalPathButStillDeletesDbRow() →
testCleanAvatarSkipsTraversalPathButStillDeletesDbRow(),
cleanAvatarSkipsAbsolutePathButStillDeletesDbRow() →
testCleanAvatarSkipsAbsolutePathButStillDeletesDbRow(),
cleanAvatarHandlesMissingFileAndStillDeletesDbRow() →
testCleanAvatarHandlesMissingFileAndStillDeletesDbRow(),
cleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow() →
testCleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow(),
cleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow() →
testCleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow(),
cleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot() →
testCleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot(), and
cleanAvatarNormalisesBackslashesInAvatarFile() →
testCleanAvatarNormalisesBackslashesInAvatarFile(); keep the existing #[Test]
attributes or remove them if you prefer, but the critical change is renaming the
methods so PHPUnit 9.6 recognizes the tests.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: fc364852-5811-4398-a9fd-1bae3f576321
📒 Files selected for processing (3)
htdocs/include/cp_functions.phphtdocs/modules/system/class/maintenance.phptests/unit/htdocs/modules/system/SystemMaintenanceTest.php
| // execute outside a bootstrapped XOOPS context. Without this, the | ||
| // require_once below would fail with an "undefined constant" fatal | ||
| // when the file is hit via a direct URL, leaking server path details | ||
| // in the error message. Uses the project-standard one-liner shape | ||
| // for consistency with the rest of the codebase. | ||
| defined('XOOPS_ROOT_PATH') || exit('Restricted access'); | ||
|
|
||
| // xoops_remove_file_quietly() lives in cp_functions.php; admin and install | ||
| // callers normally load it via cp_header.php / page_moduleinstaller.php, | ||
| // but require it explicitly here so SystemMaintenance is self-sufficient | ||
| // regardless of which context instantiates it. | ||
| require_once XOOPS_ROOT_PATH . '/include/cp_functions.php'; | ||
|
|
| /** | ||
| * Recursively remove the per-test scratch directory. | ||
| */ | ||
| private function removeScratchDir(string $scratchRel): void | ||
| { |
… CleanAvatar test coverage Extract xoops_file_label, xoops_safe_basename, xoops_chmod_quietly, and xoops_remove_file_quietly into a new side-effect-free include — htdocs/include/file_safety.php. cp_functions.php now requires that file so existing CP call sites work unchanged; SystemMaintenance requires only file_safety.php and no longer pulls in cp_functions.php's XOOPS_CPFUNC_LOADED define, which was silently forcing redirect_header() into the 'default' theme during the upgrade flows that instantiate SystemMaintenance. Narrow the cleanup-helper warnings to basename-only labels via a new xoops_safe_basename(); atomic-write callers keep xoops_file_label() for broader install-relative context. Tests: rename the eight new CleanAvatar test methods to the test... prefix for PHPUnit 9.6 discovery (keeping #[Test] for 11.x), assert the boolean contract on every happy-path call, and add testCleanAvatarReturnsFalseWhenAvatarDeleteFails() to lock in the false-return path. Fix the removeScratchDir() docblock to match the single-level implementation.
| require_once __DIR__ . '/file_safety.php'; | ||
|
|
| require_once XOOPS_ROOT_PATH . '/include/file_safety.php'; | ||
|
|
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@htdocs/modules/system/class/maintenance.php`:
- Line 37: The SystemMaintenance class currently hard-requires the new helper at
the path used in the require_once statement (the line calling require_once
XOOPS_ROOT_PATH . '/include/file_safety.php'), but that helper file hasn't been
added to the repo so loading fatals; add and commit the missing helper file at
include/file_safety.php (implement the expected functions/constants used by
SystemMaintenance) so the require_once succeeds, or if you prefer a safer
interim change, replace the hard require with a guarded include_once using
file_exists(...) and fallback behavior so SystemMaintenance can load while the
helper is added.
In `@tests/unit/htdocs/modules/system/SystemMaintenanceTest.php`:
- Around line 336-352: The test helper removeScratchDir currently suppresses
errors with `@unlink/`@rmdir which hides cleanup failures; update removeScratchDir
to explicitly check for file/directory existence and assert successful deletions
instead of silencing errors: iterate entries in the directory returned by
scandir, skip '.'/'..', build $path = $scratchAbs . '/' . $entry, if
is_file($path) assert that unlink($path) returns true (or throw an
exception/assert with a clear message), and after the loop assert that
rmdir($scratchAbs) returns true (or throw/assert) so failures surface; keep
using uploadAbs to compute $scratchAbs and preserve the early return when
!is_dir($scratchAbs).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1ccc14e1-a7be-4195-9ae8-4bb45e66c5d0
📒 Files selected for processing (3)
htdocs/include/cp_functions.phphtdocs/modules/system/class/maintenance.phptests/unit/htdocs/modules/system/SystemMaintenanceTest.php
Commit 4723bd5 modified cp_functions.php and maintenance.php to require htdocs/include/file_safety.php but did not stage the new file itself. Every PHP CI job failed at PHPUnit bootstrap with \"Failed opening required '.../htdocs/include/file_safety.php'\". Adds the file. No code change — contents are exactly what was reviewed previously: xoops_file_label, xoops_safe_basename, xoops_chmod_quietly, and xoops_remove_file_quietly, each function_exists()-guarded with no module-level side effects beyond the XOOPS_ROOT_PATH direct-access exit.
| if (!$ok) { | ||
| // basename-only label: cleanup-helper warnings never need | ||
| // directory context, and the strict form keeps install | ||
| // layout out of any error log a site operator may share. | ||
| trigger_error( | ||
| sprintf('Failed to set permissions on %s file: %s', $context, xoops_safe_basename($path)), | ||
| E_USER_WARNING | ||
| ); |
CodeRabbit observation: xoops_safe_basename() served the cleanup
helpers' best-effort / non-propagating contract via raw basename().
Empirically basename() does not throw on a "\0"-bearing path in PHP
8.2-8.4 (it returns the byte verbatim), but the helpers it serves
guarantee no exception escapes — that guarantee must hold under any
future PHP, a userland override, or a throwing error handler. A
literal "\0" in trigger_error() output also confuses log parsers.
xoops_safe_basename() now rejects "\0"-bearing input up front and
wraps basename() in catch(\Throwable), returning the fixed
placeholder "invalid-path" on either branch. Same shape as
xoops_chmod_quietly() / xoops_remove_file_quietly() — defence in
depth without changing the warning-on-failure contract.
Adds tests/unit/htdocs/include/FileSafetyTest.php with seven cases:
- xoops_safe_basename: null-byte → placeholder, backslash
normalisation, ordinary pass-through.
- xoops_chmod_quietly: null-byte path returns false, fires one
E_USER_WARNING containing "invalid-path", does not throw.
- xoops_remove_file_quietly: null-byte path returns silently;
missing path is a no-op with no warning.
- xoops_file_label: strip-XOOPS_ROOT_PATH-prefix branch and
basename fallback
|
| // Same try/catch shape around the post-unlink probe: if the path | ||
| // contained a null byte we have nothing useful to report anyway. | ||
| try { | ||
| $stillPresent = file_exists($path) || is_link($path); | ||
| } catch (\Throwable $e) { | ||
| $stillPresent = false; | ||
| } | ||
| if (!$ok && $stillPresent) { |



Changes:
cp_functions.php:
+ xoops_remove_file_quietly($path, $context): new helper for best-effort file removal. Skips non-existent paths, suppresses the unlink warning via
a SCOPED error_reporting() toggle (no @ operator) wrapped in try/finally, and re-checks file_exists() after a failed unlink — only logging when the
file is still present so TOCTOU races resolve silently. Uses xoops_file_label() for non-sensitive path labels in warnings.
- 9 @Unlink cleanup sites replaced with the helper.
- @chmod($tempFile, $perms) replaced with checked chmod() that logs on failure but continues (content is already written).
modules/system/class/maintenance.php:
+ Explicit
require_once .../include/cp_functions.phpso SystemMaintenance is self-sufficient regardless of which caller (admin, install,modulesadmin, preferences) loads it.
- 6 atomic-write @Unlink cleanup sites replaced with the helper.
- @chmod replaced with checked + warn (mirrors cp_functions.php).
- cleanOrphanedAvatars(): @Unlink replaced with the helper plus a path-traversal-safe resolution. Avatar rows store 'avatars/'
(kernel/avatar.php and 14 admin/profile writers), so basename() would have stripped the directory and silently bypassed all orphan cleanup. The new form
normalises backslashes, strips leading slashes, resolves via realpath() with upload-root containment, and confirms is_file() before removal. The DB row
cleanup runs unconditionally.
Intentionally retained:
- The 7 @rename(...) calls inside
if (!...)checks. These are the core atomic-move operations; the boolean return is already detected and reportedvia trigger_error(). Removing the @ would let PHP's native warning fire alongside our diagnostic, double-reporting one event into display_errors
output. Comment block in each file documents the rationale.
Summary by CodeRabbit
New Features
Bug Fixes
Tests