Skip to content

fix: enforce storage uniqueness and fail-closed storage protection - #24

Merged
TheStreamCode merged 4 commits into
mainfrom
fix/review-p2
Oct 21, 2026
Merged

TheStreamCode merged 4 commits into
mainfrom
fix/review-p2

Conversation

@TheStreamCode

Copy link
Copy Markdown
Owner

Summary

Fixes the two P2 findings from the repository code review (TMV-1, TMV-2). No version bump, no tag, no release, no WordPress.org deploy.

TMV-1 — database uniqueness and a serialised auto-reindex

Constraints. UNIQUE (parent_key, name(150)) on folders and UNIQUE (relative_path(191)) on files. MySQL treats NULL values as distinct in a unique index, so UNIQUE (parent_id, name) would not protect root folders; parent_key stores the parent id or 0 for the root. The 191-character file-path prefix follows the WordPress convention for VARCHAR indexes.

Migration strategy.

  • The unique keys are not part of the dbDelta schema. dbDelta would run the ALTER before the existing duplicates are merged, so the keys are added by an explicit ALTER guarded by the mstv_schema_migrations option (per-step flags: folder_parent_key, folder_unique, file_unique). A failed step is retried on the next activation/upgrade instead of leaving the schema half-applied; a successful one is skipped.
  • Order: add parent_key and backfill it from parent_id → merge duplicate sibling folders → merge duplicate file rows → add the unique keys.
  • Duplicate folders are found with GROUP BY parent_key, name, so the grouping uses the column collation exactly as the index would (PHP-side grouping would risk merging rows the database considers distinct). The lowest id survives; children folders, files and per-folder permission rules are re-pointed to it (UPDATE IGNORE for rules, then deleting only the rules the survivor already had), and duplicate subtrees are rewritten onto the surviving path. Re-pointing can create new duplicates, so the merge repeats until it converges (bounded to 10 passes; on failure the index is skipped and logged rather than deleting data).
  • Duplicate file rows with the same relative_path keep the lowest id. If two distinct paths share the 191-character index prefix, the file index is skipped and logged instead of deleting a valid row.

Runtime behaviour.

  • Duplicate folder creation returns 409 (duplicate_error) instead of 400; the message and error code are unchanged. A race that loses the unique key keeps the directory (it belongs to the surviving row) and returns 409 instead of deleting it and returning 500.
  • A duplicate upload path returns 409 (duplicate_file); the stored file is kept because the surviving row references that path.
  • The reindex path treats a rejected duplicate insert as a skip and reuses the existing row instead of failing the whole scan.
  • MSTV_Reindex_Lock replaces the auto-reindex transient with an atomic add_option() claim (the second writer fails), scoped per site, with a stale-claim takeover after 300 seconds. The claim is released when the scan fails so the self-healing path can retry; a successful scan keeps it until it goes stale, preserving the previous throttling.

TMV-2 — fail-closed storage protection

  • Uploads are refused (storage_unprotected, HTTP 500) when the storage directory is not writable or when .htaccess, web.config, or index.php cannot be created. Existing non-empty files count as protection; an empty or missing file is rewritten and reported as a failure when the write fails. The error names the failing files and points to the out-of-webroot option.
  • MSTV_Helpers::ensure_protection_files() returns a per-file result instead of ignoring @file_put_contents failures; create_protection_files() stays as a backward-compatible wrapper, and an empty storage path is reported as unprotected rather than writing at the filesystem root.
  • The dismissible warning becomes a persistent, non-dismissible notice on TeamVault admin pages: notice-error for the blocking states and notice-warning for Nginx, which ignores .htaccess. The legacy dismissal endpoint stays for already-loaded admin scripts but no longer hides the notice.
  • The README and docs/maintainer/local-development.md document the out-of-webroot storage path as the recommended deployment, especially on Nginx.

Verification

  • node --check on the five admin scripts and tools/parse-plugin-check-output.mjs: pass.
  • node --test tests/plugin-check-output.test.mjs (22 tests) and node --test tests/pdm-helpers.test.mjs (11 tests): pass.
  • Every changed PHP file parses with the php-parser engine (PHP 8 grammar) — PHP and Composer are not installed in this environment, so composer ci/PHPUnit were not run locally; CI is expected to run the suite.
  • markdownlint-cli2@0.24.0 with the repository config on README.md, SUPPORT.md and docs/maintainer/local-development.md: 0 issues.
  • New PHPUnit coverage: PDMMigrationDedupeTest (dedupe, path rewrite, idempotence, index creation), PDMDuplicateCreationTest (duplicate folder/upload 409, reindex skips), PDMReindexLockTest (claim, stale takeover, per-site scope), PDMStorageProtectionTest (failing writer, upload refusal/success), plus updated PDMAdminSecurityNoticeTest and the reindex retry assertion in PDMRestControllerTest.

Risks and notes

  • The migration runs on activation/upgrade, so it is applied when the next version is released; until then the repositories do not reference parent_key, which keeps the runtime safe if the code is deployed without the schema step.
  • files.relative_path is indexed on a 191-character prefix: two distinct paths sharing that prefix cannot coexist, so the index is skipped (and logged) instead of deleting valid rows.
  • Folder names longer than 150 characters are compared on the index prefix; names that differ only after 150 characters are treated as duplicates by the index.
  • Notification overrides keyed by a deleted duplicate folder id are not merged; the survivor's override is kept.

Add UNIQUE (parent_key, name) on folders and UNIQUE (relative_path) on files through an explicit, option-guarded migration instead of dbDelta, after merging existing duplicates: the lowest folder id survives, children, files and per-folder rules are re-pointed to it, and duplicate subtrees are rewritten onto the surviving path. parent_key stores the parent id or 0 for the root because MySQL unique indexes treat NULL values as distinct.

Map duplicate inserts to 409 conflicts (folder create, upload) and to skips in the reindex path instead of surfacing a database error, and replace the auto-reindex transient with an atomic add_option claim that is released when the scan fails.
Refuse uploads when the storage directory is not writable or when the required protection files (.htaccess, web.config, index.php) cannot be created, and report the failing files in the error. Replace the dismissible storage warning with a persistent, non-dismissible admin notice for the blocking states and for Nginx, which ignores .htaccess. Document the out-of-webroot storage path as the recommended deployment in the README and the maintainer notes.
The auto-reindex claim is persisted in an option, so tests that exercise the browser endpoint must start from a clean option state; without it the claim from an earlier test blocks the reindex expectations of the next one.
Cover the six new storage protection strings in the four runtime language maps, drop the obsolete dismissible-warning string, and update the POT template. Suppress the WordPress.DB sniffs for the migration region, where validated plugin table identifiers cannot be placeholders, and fix the new tests: stub the folder-name validator, observe the storage marker from a clean directory, and assert the prepared arguments of the permission merge queries.
@TheStreamCode
TheStreamCode merged commit 6f912ea into main Oct 21, 2026
15 checks passed
@TheStreamCode
TheStreamCode deleted the fix/review-p2 branch October 21, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant