Repository navigation
fix: enforce storage uniqueness and fail-closed storage protection - #24
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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))onfoldersandUNIQUE (relative_path(191))onfiles. MySQL treatsNULLvalues as distinct in a unique index, soUNIQUE (parent_id, name)would not protect root folders;parent_keystores the parent id or0for the root. The 191-character file-path prefix follows the WordPress convention forVARCHARindexes.Migration strategy.
dbDeltaschema.dbDeltawould run theALTERbefore the existing duplicates are merged, so the keys are added by an explicitALTERguarded by themstv_schema_migrationsoption (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.parent_keyand backfill it fromparent_id→ merge duplicate sibling folders → merge duplicate file rows → add the unique keys.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 IGNOREfor 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).relative_pathkeep 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.
409(duplicate_error) instead of400; the message and error code are unchanged. A race that loses the unique key keeps the directory (it belongs to the surviving row) and returns409instead of deleting it and returning500.409(duplicate_file); the stored file is kept because the surviving row references that path.MSTV_Reindex_Lockreplaces the auto-reindex transient with an atomicadd_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
storage_unprotected, HTTP 500) when the storage directory is not writable or when.htaccess,web.config, orindex.phpcannot 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_contentsfailures;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.notice-errorfor the blocking states andnotice-warningfor Nginx, which ignores.htaccess. The legacy dismissal endpoint stays for already-loaded admin scripts but no longer hides the notice.docs/maintainer/local-development.mddocument the out-of-webroot storage path as the recommended deployment, especially on Nginx.Verification
node --checkon the five admin scripts andtools/parse-plugin-check-output.mjs: pass.node --test tests/plugin-check-output.test.mjs(22 tests) andnode --test tests/pdm-helpers.test.mjs(11 tests): pass.php-parserengine (PHP 8 grammar) — PHP and Composer are not installed in this environment, socomposer ci/PHPUnit were not run locally; CI is expected to run the suite.markdownlint-cli2@0.24.0with the repository config onREADME.md,SUPPORT.mdanddocs/maintainer/local-development.md: 0 issues.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 updatedPDMAdminSecurityNoticeTestand the reindex retry assertion inPDMRestControllerTest.Risks and notes
parent_key, which keeps the runtime safe if the code is deployed without the schema step.files.relative_pathis 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.