Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion docs/merge-reliability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down
173 changes: 173 additions & 0 deletions tests/cow/filesystem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<?php

$pass = 0;
$fail = 0;

function assert_true($cond, $msg) {
global $pass, $fail;
if ($cond) {
echo " PASS: $msg\n";
$pass++;
} else {
echo " FAIL: $msg\n";
$fail++;
}
}

function assert_same($actual, $expected, $msg) {
assert_true(
$actual === $expected,
"$msg (got " . var_export($actual, true) . ", expected " . var_export($expected, true) . ")"
);
}

function remove_tree(string $path): void {
if (!file_exists($path) && !is_link($path)) {
return;
}
if (is_file($path) || is_link($path)) {
unlink($path);
return;
}
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $entry) {
$entry->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";
Loading