From 97d96032cbe877dc8ddbefbe9d78129dc2d4494d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 16 May 2026 09:36:37 +0200 Subject: [PATCH] Add focused COW filesystem gate --- Makefile | 6 +- docs/merge-reliability.md | 9 +- tests/cow/filesystem.php | 173 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 tests/cow/filesystem.php diff --git a/Makefile b/Makefile index 1b4fc3c3..3e6fa417 100644 --- a/Makefile +++ b/Makefile @@ -75,7 +75,7 @@ else ifeq ($(UNAME_S)-$(UNAME_M),Linux-aarch64) FORKPRESS_TARGET ?= aarch64-unknown-linux-musl endif -.PHONY: all clean test test-compat test-branchfs test-cow test-cow-fast test-cow-git-server test-cow-id-bands test-cow-media-validator test-cow-merge test-cow-merge-smoke test-cow-plugin-validator test-cow-schema-review test-cow-stale-audit test-release init-db test-all forkpress forkpress-dev dist dist-dev +.PHONY: all clean test test-compat test-branchfs test-cow test-cow-fast test-cow-filesystem test-cow-git-server test-cow-id-bands test-cow-media-validator test-cow-merge test-cow-merge-smoke test-cow-plugin-validator test-cow-schema-review test-cow-stale-audit test-release init-db test-all forkpress forkpress-dev dist dist-dev all: $(BRANCHFS_EXT_SO) @@ -113,6 +113,9 @@ test-cow-merge: test-cow-merge-smoke test-cow-git-server: php $(COW_TEST_DIR)/git_server.php +test-cow-filesystem: + php $(COW_TEST_DIR)/filesystem.php + test-cow-id-bands: php $(COW_TEST_DIR)/id_bands.php @@ -129,6 +132,7 @@ test-cow-schema-review: php $(COW_TEST_DIR)/schema_review.php test-cow-fast: test-cow-git-server test-cow-merge-smoke + php $(COW_TEST_DIR)/filesystem.php php $(COW_TEST_DIR)/id_bands.php php $(COW_TEST_DIR)/media_validator.php php $(COW_TEST_DIR)/plugin_validator.php diff --git a/docs/merge-reliability.md b/docs/merge-reliability.md index 32444619..4e340d07 100644 --- a/docs/merge-reliability.md +++ b/docs/merge-reliability.md @@ -20,7 +20,7 @@ when there is a test or document that exercises the specific merge invariant. | 1. Real WordPress semantic merge coverage | `tests/cow/e2e.sh` creates source and target branches through runtime WordPress requests, validates each branch-local graph before merge, then merges pages, branch-local page edits/deletes with edited content and authors, postmeta, users/usermeta, authors, comments/commentmeta, hierarchical taxonomy terms, nav menus and menu locations, reusable `wp_block` rows, page-to-reusable-block refs, `core/image` block refs and featured-image refs to media attachments, options and JSON options with branch user/object IDs, media uploads with attachment parents plus generated-size metadata/files, a CPT-like `forkpress_note`, and plugin-shaped custom tables/files. The semantic E2E merge now requires `status: completed` and a zero-conflict merge run, so runtime-only state cannot hide behind a surviving object graph. `tests/cow/merge.php` adds deterministic WordPress row fingerprint and validator coverage. | Add broader concurrent edit/delete matrices for complete WP objects and deterministic repair policies only where the owner object is unambiguous. | | 2. Plugin-specific merge semantics | `docs/plugin-merge-validators.md` defines the validator contract, including rejecting contradictory status/finding output. `scripts/cow/merge.php` discovers active plugin and mu-plugin validators, runs explicit validators, records plugin-scoped conflicts, and rolls back inline validator failures. `tests/cow/plugin_validator.php` is a focused fast gate for discovered validator review of a plugin-owned DB/JSON/file graph, including plugin-scoped audit output for incoherent JSON and missing file references. `tests/cow/merge.php` covers clean custom-table graph merges, validator findings, audit/review grouping, validator rerun evidence, file-root context, active-plugin discovery, explicit-ID plugin graph validation, contradictory validator output rejection, and failed-validator rollback. `tests/cow/e2e.sh` covers a runtime plugin-shaped graph across custom table parent/child rows, child JSON payload refs, JSON, serialized data, options, postmeta, CPT data, and branch-owned file contents. | Add validators for real plugins and add merge drivers only for plugin-owned repairs that can prove correctness. | | 3. Remaining review-only schema cases | `scripts/cow/merge.php` validates source-added views/triggers, preserves invalid dependency cases as conflicts, and supports safe schema object resolution for deterministic subsets. `tests/cow/schema_review.php` is a focused fast gate proving cyclic source-added views/triggers stay reviewable, are not installed on the target, preserve the cycle reason, and do not record failed resolutions. `tests/cow/merge.php` covers broader cyclic/invalid view and trigger dependency handling, source-added dependent view ordering, and rebuild validation cases. | Improve dependency planning for more safe reorderings. Cyclic or semantically ambiguous cases should stay review-only. | -| 4. Filesystem merge hardening | `tests/cow/merge.php` covers file adds/deletes/conflicts, binary hash comparisons, symlink safety, directory/file and file/directory replacement review, rollback artifacts, upload-file validators, generated attachment file checks, original/generated dimension drift, generated-size filename drift, featured-image/image-block/media metadata drift, and unsafe metadata paths. `tests/cow/e2e.sh` verifies real merged upload originals and generated thumbnails. | Add stricter uploads-specific validators for more drift shapes and explicit attachment-regeneration decisions. | +| 4. Filesystem merge hardening | `tests/cow/filesystem.php` is a focused fast gate for safe source text/binary file application plus unsafe absolute symlinks staying as auditable file conflicts. `tests/cow/merge.php` covers file adds/deletes/conflicts, binary hash comparisons, symlink safety, directory/file and file/directory replacement review, rollback artifacts, upload-file validators, generated attachment file checks, original/generated dimension drift, generated-size filename drift, featured-image/image-block/media metadata drift, and unsafe metadata paths. `tests/cow/e2e.sh` verifies real merged upload originals and generated thumbnails. | Add stricter uploads-specific validators for more drift shapes and explicit attachment-regeneration decisions. | | 5. Crash consistency across DB/files/metadata/Git | `docs/merge-crash-consistency.md` lists the covered boundaries. `tests/cow/merge.php` covers target DB, metadata, file, rollback-failure, ID-band, and whole-branch rollback paths. `tests/cow/e2e.sh` drives public merge/create/reset/recover crash/retry flows for DB, metadata, before-file, after-file, recovery-cleanup, branch-birth, branch-reset publication failpoints, and actual smart-HTTP Git-created branch pushes interrupted before branch-birth metadata, before branch-list publication, and after branch-list publication, each verified after a fresh server restart. `tests/cow/git_server.php` covers Git-created branch birth, Git update/delete, stale cleanup, and object-prune interruption. | Broaden external kill harness coverage across the remaining Git-push failpoints and platform-specific APFS/cleanup checkpoints, then verify post-crash state from a fresh process. | | 6. Branch birth always captures merge bases | `crates/forkpress-storage/src/lib.rs` requires branch birth metadata for branch reuse/merge and blocks pending reset states. `tests/cow/git_server.php` covers Git-created branch DB/file base, ID-band, row identity, and cleanup/rollback paths. `tests/cow/e2e.sh` covers public create retry after interrupted birth metadata, public reset retry after interrupted reset publication, and remote-cache branch creation followed by AUTOINCREMENT-band insertion and mergeback to `main`. | Keep every new creation/reuse/reset path under the same invariant and add regressions whenever a new branch publication path is introduced. | | 7. ID-band enforcement beyond happy paths | `tests/cow/id_bands.php` is a focused fast gate for separate branch AUTOINCREMENT bands, JSON/serialized references that keep branch IDs distinct without rewrite, and review-held non-AUTOINCREMENT `INTEGER PRIMARY KEY` plugin collisions. `tests/cow/merge.php` covers AUTOINCREMENT allocation, rollback, reset below old bands, independent branch IDs, explicit out-of-band source IDs, child rows behind held explicit post/term/user IDs, inserted and updated scalar/serialized/theme/widget `wp_options`, `wp_posts`, `wp_postmeta`, `wp_comments`, `wp_commentmeta`, `wp_usermeta`, `wp_termmeta`, `wp_term_taxonomy`, `wp_term_relationships`, post-author, taxonomy menu-item, reusable/media/avatar/navigation/query block `post_content`, and comment-user references behind held explicit post/term/user IDs, JSON/serialized references that keep branch IDs distinct, plugin validator review for no-FK child rows behind held explicit plugin AUTOINCREMENT parents, and non-AUTOINCREMENT `INTEGER PRIMARY KEY` plugin graph collisions as review-held. `tests/cow/e2e.sh` verifies runtime branch post IDs fall inside branch bands and requires an independently banded source/target WordPress post merge to finish with `status: completed` and zero recorded conflicts while preserving embedded JSON/serialized post IDs. | Expand explicit-ID/import handling beyond currently covered AUTOINCREMENT row-insert/rewrite cases and enforce review for more plugin/custom logical identities that are not safely bandable. | @@ -75,6 +75,13 @@ For fast local iteration on merge logic, start with: make test-cow-merge-smoke ``` +For filesystem merge behavior, including binary changes and unsafe symlink +conflicts, run: + +```bash +make test-cow-filesystem +``` + For all cheap COW helper/UI/router checks, run: ```bash diff --git a/tests/cow/filesystem.php b/tests/cow/filesystem.php new file mode 100644 index 00000000..42cffd48 --- /dev/null +++ b/tests/cow/filesystem.php @@ -0,0 +1,173 @@ +isDir() && !$entry->isLink() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($path); +} + +function copy_tree_for_test(string $source, string $dest): void { + mkdir($dest, 0777, true); + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($it as $entry) { + $target = $dest . '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($entry->getPathname(), strlen($source) + 1)); + if ($entry->isDir()) { + if (!is_dir($target)) { + mkdir($target, 0777, true); + } + continue; + } + if (!is_dir(dirname($target))) { + mkdir(dirname($target), 0777, true); + } + copy($entry->getPathname(), $target); + } +} + +function write_test_file(string $path, string $contents): void { + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0777, true); + } + file_put_contents($path, $contents); +} + +function create_test_symlink(string $target, string $link): void { + if (!is_dir(dirname($link))) { + mkdir(dirname($link), 0777, true); + } + if ((file_exists($link) || is_link($link)) && !unlink($link)) { + throw new RuntimeException("failed to replace test symlink: $link"); + } + if (!symlink($target, $link)) { + throw new RuntimeException("failed to create test symlink: $link"); + } +} + +function open_db(string $path): SQLite3 { + $db = new SQLite3($path); + $db->busyTimeout(5000); + return $db; +} + +function create_filesystem_db(string $path): void { + $db = open_db($path); + $db->exec('CREATE TABLE plugin_items (item_id TEXT PRIMARY KEY, value TEXT NOT NULL)'); + $db->exec("INSERT INTO plugin_items (item_id, value) VALUES ('base', 'base')"); + $db->close(); +} + +function scalar(string $db_path, string $sql): mixed { + $db = open_db($db_path); + $value = $db->querySingle($sql); + $db->close(); + return $value; +} + +define('FORKPRESS_COW_MERGE_TESTS', true); +require_once __DIR__ . '/../../scripts/cow/merge.php'; + +echo "=== COW filesystem focused tests ===\n"; + +$tmp = sys_get_temp_dir() . '/forkpress-cow-filesystem-' . getmypid() . '-' . bin2hex(random_bytes(4)); +mkdir($tmp, 0777, true); + +try { + $base_root = $tmp . '/base'; + $source_root = $tmp . '/source'; + $target_root = $tmp . '/target'; + $base = $base_root . '/wp-content/database/.ht.sqlite'; + $source = $source_root . '/wp-content/database/.ht.sqlite'; + $target = $target_root . '/wp-content/database/.ht.sqlite'; + $metadata = $tmp . '/.forkpress/cow/merge/filesystem-metadata.sqlite'; + $file_base = $tmp . '/.forkpress/cow/merge/file-bases/filesystem.json'; + + mkdir($base_root . '/wp-content/database', 0777, true); + create_filesystem_db($base); + write_test_file($base_root . '/wp-content/uploads/shared.txt', 'base shared'); + write_test_file($base_root . '/wp-content/uploads/binary.bin', "base\0binary"); + copy_tree_for_test($base_root, $source_root); + copy_tree_for_test($base_root, $target_root); + + cow_merge_capture_file_base($base_root, $file_base); + + write_test_file($source_root . '/wp-content/uploads/shared.txt', 'source shared'); + write_test_file($source_root . '/wp-content/uploads/binary.bin', "source\0binary\xff"); + create_test_symlink('/etc/passwd', $source_root . '/wp-content/uploads/absolute-link.txt'); + + $result = cow_merge_branch_state( + $base, + $source, + $target, + $metadata, + 'feature-filesystem', + 'main', + $file_base, + $source_root, + $target_root + ); + + assert_same($result['status'], 'completed_with_conflicts', 'filesystem merge completes with review conflicts for unsafe source paths'); + assert_same($result['file_applied'], 2, 'filesystem merge applies safe source-only text and binary file changes'); + assert_same($result['file_conflicts'], 1, 'filesystem merge records one unsafe symlink conflict'); + assert_same(file_get_contents($target_root . '/wp-content/uploads/shared.txt'), 'source shared', 'safe source text file change is applied'); + assert_same(file_get_contents($target_root . '/wp-content/uploads/binary.bin'), "source\0binary\xff", 'safe source binary file change is applied exactly'); + assert_true(!file_exists($target_root . '/wp-content/uploads/absolute-link.txt') && !is_link($target_root . '/wp-content/uploads/absolute-link.txt'), 'unsafe absolute symlink is not installed on target'); + + assert_same( + (int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE table_name = '__files__' AND conflict_type = 'file-unsafe-symlink'"), + 1, + 'unsafe filesystem symlink conflict is auditable' + ); + $audit = cow_merge_audit_report($metadata, (int)$result['run_id'], 10, [ + 'scope' => 'files', + 'records' => 'conflicts', + 'conflict_type' => 'file-unsafe-symlink', + ]); + assert_same(count($audit['conflicts']), 1, 'file audit can focus on unsafe symlink conflicts'); + assert_same($audit['conflicts'][0]['row_identity'], cow_merge_file_identity_json('wp-content/uploads/absolute-link.txt'), 'unsafe symlink audit points at the blocked path'); + assert_true(str_contains((string)$audit['conflicts'][0]['source_preview'], '/etc/passwd'), 'unsafe symlink audit exposes the rejected source target'); +} finally { + remove_tree($tmp); +} + +if ($fail) { + echo "FAILURES: $fail\n"; + exit(1); +} +echo "COW filesystem focused tests passed ($pass assertions).\n";